Commit 177dadc8 authored by Andy-bubu's avatar Andy-bubu

feat: align risk case views and permissions

parent 4abbdebf
import { defineConfig } from '@umijs/max';
import path from 'path';
import { APPLICATION_BASE_PATH } from './src/constants/app';
function resolvePackageRoot(packageName: string) {
return path.dirname(require.resolve(`${packageName}/package.json`));
......@@ -9,13 +10,14 @@ const ROUTE_PATHS = {
login: '/login',
resourceStat: '/resource/stat',
resourceBooking: '/resource/booking',
notifications: '/notifications',
} as const;
export default defineConfig({
layout: false,
fastRefresh: true,
base: '/risk_case_library/',
publicPath: '/risk_case_library/',
base: APPLICATION_BASE_PATH,
publicPath: APPLICATION_BASE_PATH,
alias: {
react: resolvePackageRoot('react'),
'react-dom': resolvePackageRoot('react-dom'),
......@@ -47,6 +49,10 @@ export default defineConfig({
path: ROUTE_PATHS.resourceBooking,
component: './project_manager_xy/resource-booking/index',
},
{
path: ROUTE_PATHS.notifications,
component: './project_manager_xy/notifications/NotificationListPage',
},
],
},
],
......
......@@ -25,3 +25,19 @@
## 业务边界
服务层继续负责 `risk_case_library` 的 `/record` 协议,所有业务字段仍写入 `metadata`。本次迁移不得改变路由、鉴权、权限、验证、分页、保存回调、请求时机、错误处理或业务状态流。
## Subject View 与材料事务边界
- `riskCaseViewService.js` 先读取 Subject、View 摘要和每个 View 详情;页面只有在存在当前用户可访问的 Grid View 后才进入案例查询数据流。
- `src/constants/app.js` 定义唯一稳定 `APPLICATION_NAME = risk_case_library`,路由 base、应用切换、授权归属和材料 `source_app` 均由该标识派生。
- `src/utils/subjectView.js` 是业务中立的 View 解析工具,默认授权应用从 `APPLICATION_NAME` 派生。它只读取当前应用项,不读取或合并其他应用及用户顶层的 groups、roles 或 permissions。
- Grid View 负责可见性、查询限制、排序和列表字段。详情字段权限优先取当前完整 Detail View 的整套 `auth/disableColumns`;Detail 完全没有有效字段权限配置或不存在实际 Detail 时,整体回退活动 Grid,不合并两套配置。
- Detail `columns` 独立负责字段展示范围、顺序、条件和 `shortTitle`;编辑入口与字段状态只按当前 Detail 展示字段和当前选定权限源计算。
- `RiskCaseMaterialField.jsx` 是 HeroUI 原生材料选择/上传组合,业务服务通过 `/upload`、`am_material` 和 `source_app=risk_case_library` 工作。本地上传先进入详情草稿,保存详情前创建材料主档;成功标记保留在当前编辑会话中,重试不重复建档。
- 现网 `risk_case_library` Subject 当前没有 file 字段,因此材料组件处于运行时待配置状态,不添加假字段,也不改变现有 Record schema。
## 通知架构
- `/notifications` 由独立页面承载,Navbar 只负责真实未读计数和路由入口,Sidebar 不增加通知项。
- 页面使用平台 `/notice/customer/query`、`/notice/customer/count`、已读和动态 Action 接口;通知正文按纯文本渲染。
- 站内链接只移除自身 `/risk_case_library` base 后交给 Umi history;其他应用或外部 HTTP(S) 链接新窗口打开。
......@@ -17,6 +17,13 @@
- [x] 案例管理:Tabs、筛选工具栏、DataGrid、Pagination、ActionBar、EmptyState(代码结构)
- [x] 弹窗:新建、查看/编辑、批量修改、导入、删除确认(代码结构)
- [x] 清除 Catalyst/Headless/ECharts/原生可见控件与页面私有视觉 CSS
- [x] Grid `shortTitle`:严格布尔值,仅隐藏 `title` 展示列,操作入口保留在右侧操作列
- [x] Detail `shortTitle`:随当前 Detail View 切换,功能标题始终保留
- [x] Subject View 权限:目标应用授权隔离、管理员/管理旁路、`disableColumns` 优先
- [x] Detail 权限来源:完整 Detail 有有效 `auth/disableColumns` 时整套优先,否则整体回退活动 Grid;编辑入口只检查当前 Detail 字段
- [x] 零 Grid View:无兜底、无案例查询、无新增/批量/行操作;单 View 隐藏 Tabs
- [x] 通知中心:Navbar Badge、独立页面、搜索/分页/已读/动态操作/站内外跳转
- [x] 材料中心:运行时 file 字段、Autocomplete、DropZone、草稿/保存建档/重试去重
## 浏览器验收
......@@ -28,6 +35,14 @@
- [ ] 打开 Sidebar、用户退出菜单、应用切换真实动态分组、Dropdown/ListBox/DatePicker
- [ ] 验证 Tabs、筛选、Checkbox、DataGrid、Pagination、ActionBar、Modal、KPI、图表 Tooltip
- [x] Login 控制台 0 runtime/slot/provider/accessibility 错误或警告
- [x] 1280x720 浅色:Navbar Bell、0 角标隐藏、零 View 空状态、控制台 0 错误/警告
- [x] 零 View 网络门禁:仅 Subject、View、通知计数请求,无案例 `record/query` 或 `record/count`
- [x] 零 View 状态已覆盖 1440x900、1280x720、768x1024、390x844 浅/深色;无页面横向溢出或控制台错误
- [x] 通知空状态已覆盖 1280x720 浅/深色;查询和计数接口均返回 200,控制台无错误
- [ ] shortTitle true/false、Detail/Grid 权限优先、三类用户、disableColumns:后端当前没有任何 View 配置,无法真实触发
- [ ] 单值/多值文件、材料选择、取消、失败与重试:Subject 当前没有 file 字段,无法真实触发
- [ ] 通知 1/99/100、已读/全部已读/动态操作和站内外跳转:当前账号通知总数为 0,未伪造数据
- [ ] 通知非空态和可配置 View/file 状态的四视口浅/深色并排对照:真实后端状态缺失,未伪造数据
## 最终门槛
......
......@@ -20,6 +20,18 @@
- 指标必须使用 HeroUI Pro KPI compound API;图表必须使用 Pro PieChart、BarChart、ComposedChart 与 ChartTooltipContent。
- 图表业务序列只使用 `--rc-chart-*` Token,不使用 UI 状态色或页面硬编码颜色。
## 跨应用五项功能
警示案例库属于鑫元六个完整功能应用,以下规则作为运行时契约:
- Grid 与 Detail View 只把严格布尔值 `settings.shortTitle === true` 解释为隐藏标题。Grid 仅隐藏 `title` 展示列;Detail 隐藏记录标题和 `title` 字段,但始终保留 Modal 功能标题及可访问名称。
- Grid View 可见性只读取 `settings.viewAuth`。详情编辑权限优先使用当前 Detail View 的整套 `settings.auth` / `settings.disableColumns`;两项均无有效字段配置或不存在实际 Detail 时,整体回退当前 Grid View。两套权限不合并、不逐字段回退,选定来源的 `disableColumns` 对任何用户始终只读。
- 应用授权只读取 `user.applications[]` 中 `name = "risk_case_library"` 的项目。系统管理员和该应用自身“管理”角色可旁路 View/字段 auth,但不能旁路 `disableColumns`,也不能制造不存在的 View。
- Detail `columns` 只控制当前详情字段展示范围和顺序,`auth` 可配置更多字段;“编辑”按钮只检查当前 Detail 实际展示且可编辑的字段。
- 零个可访问 Grid View 时不建立兜底 View、不查询案例记录,也不展示新建、批量或行操作;单个可访问 View 不显示 Tabs 轨道。
- Navbar 通知使用真实未读数和独立 `/notifications` 页面,0 隐藏角标,1-99 显示真实值,100 以上显示 `99+`。
- 运行时可编辑 `file` 字段使用材料中心/本地上传。材料快照只保存 `{name,type,size,url}`,目标 `source_app` 固定为 `risk_case_library`;当前 Subject 未配置 file 字段时不显示入口。
## 禁止项
- Catalyst、Headless UI、旧 UI 包装层、可见原生 select/date/file 控件。
......
# 鑫元 UI 审计
审计日期:2026-08-26。目标仓库为 Umi 4 / React 19,部署基路径为 `/risk_case_library/`。整体实现基准为“问题整改”,应用切换专项基准为 `model_analysis` 当前 HeroUI 实现。
审计日期:2026-08-26。目标仓库为 Umi 4 / React 19,部署基路径为 `/risk_case_library/`。整体实现及本次五项功能的唯一基准为“问题整改”。
## 安装与运行基线
- 官方安装命令已在项目根目录执行成功,`@heroui-pro/react` 解析为本项目 `node_modules` 内的 `1.0.0-beta.8`,不是符号链接或跨项目 `file:` 依赖。
- 项目使用 npm,保留 `package-lock.json`;旧 `pnpm-lock.yaml` 已删除。
- 当前迁移实例运行于 `http://localhost:8000/risk_case_library/`。
- 当前迁移实例运行于 `http://localhost:8002/risk_case_library/`。
- “问题整改”基准运行于 `http://localhost:8001/problem_rectification_new/`。
- 应用切换专项基准 `model_analysis` 运行于 `http://localhost:8002/model_analysis/`;该隔离浏览器会话停留在登录页,因此没有把未登录状态冒充为打开态对照。
......@@ -36,3 +36,28 @@
- 不改变 API URL、请求参数、响应映射、路由、权限、校验、请求时机、保存回调、登录退出或业务状态流。
- 不在源码或浏览器中加入测试记录、伪造 token 或统计数字。
- 应用切换已使用现有真实登录会话完成浅/深色四视口验收;未注入 token、应用、分组或业务数据。其他模块的既有未验收项仍按迁移清单保留,不能据此标记全量应用迁移完成。
## 五项功能审计
| 审计项 | 真实状态 | 实施结论 |
| --- | --- | --- |
| 应用范围 | 应用名为“警示案例库” | 属于六应用完整范围,实施功能 1-5 |
| 用户结构 | Andy:`type=admin`,`applications=[]` | 系统管理员旁路 auth,但不制造 View;普通用户授权只能读取 `risk_case_library` 应用项 |
| Subject View | `GET /subject/risk_case_library/view` 返回 `[]` | 案例管理进入“暂无可访问的案例视图”;不查询记录,不展示业务操作 |
| Detail 权限来源 | View 摘要与每个完整 View 详情均通过现有 Subject View 接口加载 | 当前 Detail 有有效 `auth/disableColumns` 时整套优先;否则整体回退活动 Grid;两套配置不合并 |
| Subject file 字段 | 当前字段集中没有 `type=file` | 文件能力按运行时字段接入,不增加测试字段或伪造入口 |
| 通知 | `GET /notice/customer/count` 真实返回 0;查询返回空列表 | Navbar 显示 Bell 且隐藏角标;通知页实现真实空状态 |
| 审计材料 | `am_material` 可查询且存在真实记录 | 只在未来可编辑 file 字段中按最近 20 条和远程搜索使用 |
| 路由与 Shell | base 为 `/risk_case_library`,通知路由为 `/notifications` | 站内目标只去除自身 base,Sidebar 无通知入口 |
代码审计确认原页面会无条件调用案例查询,并在后端空数据时展示 `DEMO_CASES`。本次 View 门禁使零可访问 View 状态不进入该数据流;没有删除旧业务回调或演示常量,但受门禁保护的案例管理页面不会触发或展示它们。
## 本轮真实浏览器证据
- 零可访问 Grid View 状态已在 1440x900、1280x720、768x1024、390x844 的浅色和深色模式逐项打开;Navbar Bell、空状态、响应式 Shell 和页面边距正常,控制台无错误或警告。
- 1280x720 网络记录只包含 `/api/info`、`/api/user/info`、Subject/View 与通知计数请求,没有案例 `record/query` 或 `record/count`。
- 通知空状态已在 1280x720 浅色和深色模式打开;通知 query/count 均返回 200,页面按真实 0 条数据展示且控制台干净。
- 本轮收尾重新使用真实账号完成验证码登录,并再次打开案例管理、通知页、应用切换和用户菜单:案例管理只新增 Subject/View/通知计数请求,未新增案例 query/count;应用切换显示真实动态分组与唯一当前态;用户菜单保留真实退出入口;目标应用控制台仍为 0 错误、0 警告。
- “问题整改”基准在同一浏览器第二标签打开,但独立验证码登录未成功,因此本轮没有把其登录页冒充为已登录业务态对照;此前完成的对照证据继续保留,缺失状态仍不标记完成。
- 截图保存在本机 `/tmp/risk-booking-*.png`、`/tmp/risk-notifications-1280-light.png` 与 `/tmp/risk-notifications-1280-dark.png`,不纳入 Git。
- 当前后端没有 View、file 字段或非空通知,故 shortTitle、Detail/Grid 权限优先矩阵、材料事务异常链路与通知 1/99/100 等状态仍明确未验收;没有注入测试记录或篡改接口返回。
import BankOutlined from '@ant-design/icons/BankOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
......@@ -54,6 +55,7 @@ function withSize(Icon) {
}
export const Bank = withSize(BankOutlined);
export const Bell = withSize(BellOutlined);
export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined);
......
......@@ -9,6 +9,7 @@ export default function FileUploadField({
description = '将文件拖到此处,或从本地选择。',
isDisabled = false,
fileName = '',
multiple = false,
onFilesChange,
triggerLabel = '选择文件',
}) {
......@@ -29,9 +30,9 @@ export default function FileUploadField({
if (fileItems.length === 0) return;
const files = await Promise.all(fileItems.map((item) => item.getFile()));
emitFiles(files.slice(0, 1));
emitFiles(multiple ? files : files.slice(0, 1));
},
[emitFiles],
[emitFiles, multiple],
);
return (
......@@ -44,6 +45,7 @@ export default function FileUploadField({
<DropZone.Input
accept={accept}
disabled={isDisabled}
multiple={multiple}
onSelect={emitFiles}
/>
<DropZone.Icon>
......
import {
ChevronDown,
Close,
Search,
} from '@/components/AppIcons';
import {
Autocomplete,
Label,
ListBox,
SearchField,
Spinner,
} from '@heroui/react';
function defaultFilter(textValue, inputValue) {
const query = String(inputValue || '').trim().toLocaleLowerCase();
return !query || String(textValue || '').toLocaleLowerCase().includes(query);
}
export default function XinyuanAutocomplete({
'aria-label': ariaLabel,
className,
emptyText = '暂无可选项',
errorText = '',
filter,
inputValue,
isDisabled = false,
isLoading = false,
onInputChange,
onSelectionChange,
options = [],
placeholder = '请选择',
renderOption,
searchPlaceholder = '输入关键词搜索',
selectedKey,
shouldFilter = true,
valueText,
variant = 'secondary',
}) {
const selectedOption = options.find((option) => option.id === selectedKey);
const resolvedValue = valueText ?? selectedOption?.label ?? placeholder;
const visibleOptions = isLoading || errorText ? [] : options;
return (
<Autocomplete
aria-label={ariaLabel || placeholder}
className={className}
fullWidth
isDisabled={isDisabled}
selectedKey={selectedKey}
variant={variant}
onSelectionChange={onSelectionChange}
>
<Autocomplete.Trigger>
<Autocomplete.Value>
<span className="block min-w-0 truncate" title={resolvedValue}>
{resolvedValue}
</span>
</Autocomplete.Value>
<Autocomplete.Indicator>
<ChevronDown size={16} />
</Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover placement="bottom start">
<Autocomplete.Filter
filter={shouldFilter ? filter || defaultFilter : () => true}
inputValue={inputValue}
onInputChange={onInputChange}
>
<SearchField aria-label={searchPlaceholder} fullWidth variant="secondary">
<SearchField.Group>
<SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder={searchPlaceholder} />
<SearchField.ClearButton aria-label="清空搜索"><Close size={14} /></SearchField.ClearButton>
</SearchField.Group>
</SearchField>
<ListBox
aria-label={ariaLabel || placeholder}
items={visibleOptions}
renderEmptyState={() => (
<div className="grid min-h-20 place-items-center px-3 py-4">
{isLoading ? <Spinner size="sm" /> : errorText || emptyText}
</div>
)}
>
{(option) => (
<ListBox.Item id={option.id} textValue={option.textValue || option.label}>
{renderOption ? renderOption(option) : <Label>{option.label}</Label>}
<ListBox.ItemIndicator />
</ListBox.Item>
)}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
);
}
export const APP_ROUTE_NAME = 'risk_case_library';
export const APPLICATION_NAME = 'risk_case_library';
export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`;
export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME;
export const APP_DISPLAY_NAME = '警示案例库';
export const APP_LOGIN_SUBTITLE = '案例库工作台';
import BankOutlined from '@ant-design/icons/BankOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
......@@ -54,6 +55,7 @@ function withSize(Icon) {
}
export const Bank = withSize(BankOutlined);
export const Bell = withSize(BellOutlined);
export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined);
......
......@@ -3,7 +3,7 @@ import xinyuanLogo from '@/assets/identity/xy-logo-red.png';
import {
APP_DISPLAY_NAME,
APP_LOGIN_SUBTITLE,
APP_ROUTE_NAME,
APPLICATION_NAME,
} from '@/constants/app';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
import { EmptyState } from '@heroui-pro/react/empty-state';
......@@ -36,7 +36,7 @@ function resolveApplicationUrl(application) {
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
const isCurrentSystem = name === APP_ROUTE_NAME;
const isCurrentSystem = name === APPLICATION_NAME;
return {
name,
......@@ -97,11 +97,11 @@ function mergeApplications(fetchedApplications, fallbackApplications) {
);
});
if (!mergedMap.has(APP_ROUTE_NAME)) {
if (!mergedMap.has(APPLICATION_NAME)) {
mergedMap.set(
APP_ROUTE_NAME,
APPLICATION_NAME,
normalizeApplication({
name: APP_ROUTE_NAME,
name: APPLICATION_NAME,
title: APP_DISPLAY_NAME,
description: APP_LOGIN_SUBTITLE,
logo: xinyuanLogo,
......
......@@ -2,15 +2,19 @@ import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { APP_DISPLAY_NAME } from '@/constants/app';
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from '@/pages/project_manager_xy/notifications/notificationService';
import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { useContext, useLayoutEffect, useMemo, useState } from 'react';
import { Avatar, Badge, Button, Dropdown, Label, Toast, Tooltip } from '@heroui/react';
import { useContext, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { Heading } from 'react-aria-components/Dialog';
import { history, Outlet, useLocation } from 'umi';
import { AppGrid, Archive, BarChart, LogOut, Moon, Sun } from './AppIcons';
import { AppGrid, Archive, BarChart, Bell, LogOut, Moon, Sun } from './AppIcons';
const APP_NAME = APP_DISPLAY_NAME;
const THEME_STORAGE_KEY = 'xinyuan_color_theme';
......@@ -38,6 +42,7 @@ function getInitialTheme() {
}
function getRouteTitle(pathname) {
if (pathname === '/notifications') return '通知消息';
for (const group of menuGroups) {
const activeItem = group.items.find((item) => item.path === pathname);
if (activeItem) return activeItem.name;
......@@ -216,6 +221,8 @@ function SidebarContents({ onOpenApplicationSwitcher, pathname, userInfo }) {
}
function DashboardNavbar({
notificationCount,
onOpenNotifications,
onThemeChange,
theme,
title,
......@@ -223,6 +230,7 @@ function DashboardNavbar({
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const unreadCount = Math.max(0, Number(notificationCount) || 0);
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
......@@ -234,6 +242,26 @@ function DashboardNavbar({
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
<Tooltip delay={0}>
<Badge.Anchor>
<Button
isIconOnly
aria-label="通知消息"
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={onOpenNotifications}
>
<Bell size={16} />
</Button>
{unreadCount > 0 ? (
<Badge aria-label={`${unreadCount}条未读通知`} color="danger" size="sm">
{unreadCount > 99 ? '99+' : unreadCount}
</Badge>
) : null}
</Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
<Tooltip delay={0}>
<Button
isIconOnly
......@@ -259,6 +287,7 @@ export default function ProjectManagerLayout() {
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [theme, setTheme] = useState(getInitialTheme);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const userInfo = useContext(UserContext);
const siteInfo = useContext(SiteContext);
......@@ -280,6 +309,26 @@ export default function ProjectManagerLayout() {
document.title = APP_NAME;
}, []);
useEffect(() => {
let cancelled = false;
const refreshUnreadCount = async () => {
try {
const count = await loadUnreadNotificationCount();
if (!cancelled) setUnreadNotificationCount(count);
} catch {
if (!cancelled) setUnreadNotificationCount(0);
}
};
refreshUnreadCount();
window.addEventListener(NOTIFICATION_STATE_EVENT, refreshUnreadCount);
const timer = window.setInterval(refreshUnreadCount, 60000);
return () => {
cancelled = true;
window.removeEventListener(NOTIFICATION_STATE_EVENT, refreshUnreadCount);
window.clearInterval(timer);
};
}, [location.pathname]);
const routeTitle = useMemo(
() => getRouteTitle(location.pathname),
[location.pathname],
......@@ -300,6 +349,8 @@ export default function ProjectManagerLayout() {
/>
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
notificationCount={unreadNotificationCount}
onOpenNotifications={() => history.push('/notifications')}
theme={theme}
title={routeTitle}
onThemeChange={setTheme}
......@@ -318,6 +369,7 @@ export default function ProjectManagerLayout() {
userInfo={userInfo}
siteInfo={siteInfo}
/>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
import { ArrowPathIcon, Delete } from '@/components/AppIcons';
import FileUploadField from '@/components/FileUploadField';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Alert, Button, Label, Tabs, Tooltip } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
import { queryRiskCaseMaterials, uploadRiskCaseFile } from './riskCaseMaterialService';
const MATERIAL_CENTER_TAB = 'material-center';
const LOCAL_UPLOAD_TAB = 'local-upload';
function toFiles(value) {
return (Array.isArray(value) ? value : value ? [value] : []).filter(Boolean);
}
function fileKey(file = {}) {
return file.url || [file.name, file.type, file.size].filter(Boolean).join('|');
}
function appendUnique(current, next) {
return [...new Map([...current, ...next].map((file) => [fileKey(file), file])).values()];
}
export default function RiskCaseMaterialField({
editSessionId,
fieldPath,
multiple = false,
onChange,
onPendingLocalMaterialChange,
onValidationChange,
value,
}) {
const [activeTab, setActiveTab] = useState(MATERIAL_CENTER_TAB);
const [keyword, setKeyword] = useState('');
const [debouncedKeyword, setDebouncedKeyword] = useState('');
const [materials, setMaterials] = useState([]);
const [selectedKey, setSelectedKey] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [retryKey, setRetryKey] = useState(0);
const [uploading, setUploading] = useState(false);
const [localKeys, setLocalKeys] = useState(() => new Set());
const files = useMemo(() => multiple ? toFiles(value) : toFiles(value).slice(0, 1), [multiple, value]);
useEffect(() => {
setActiveTab(MATERIAL_CENTER_TAB);
setKeyword('');
setSelectedKey(null);
setError('');
setLocalKeys(new Set());
}, [editSessionId]);
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedKeyword(keyword), 300);
return () => window.clearTimeout(timer);
}, [keyword]);
useEffect(() => {
if (activeTab !== MATERIAL_CENTER_TAB) return undefined;
let cancelled = false;
setLoading(true);
setError('');
queryRiskCaseMaterials(debouncedKeyword)
.then((items) => { if (!cancelled) setMaterials(items); })
.catch((requestError) => { if (!cancelled) { setMaterials([]); setError(requestError?.message || '审计材料加载失败。'); } })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [activeTab, debouncedKeyword, retryKey]);
const options = materials.map((material) => ({
...material,
label: material.materialTitle,
textValue: `${material.materialTitle} ${material.fileAsset?.name || ''}`,
}));
function handleMaterialSelection(key) {
const material = materials.find((item) => String(item.id) === String(key || ''));
if (!material?.fileAsset?.url) return;
const snapshot = {
name: material.fileAsset.name,
type: material.fileAsset.type || 'application/octet-stream',
size: Number(material.fileAsset.size || 0),
url: material.fileAsset.url,
};
setSelectedKey(multiple ? null : material.id);
if (!multiple) {
setLocalKeys(new Set());
onPendingLocalMaterialChange?.(fieldPath, { type: 'replace', materials: [] }, editSessionId);
}
onChange?.(multiple ? appendUnique(files, [snapshot]) : snapshot);
}
async function handleLocalUpload(selectedFiles) {
const nextFiles = multiple ? toFiles(selectedFiles) : toFiles(selectedFiles).slice(0, 1);
if (nextFiles.length === 0) return;
setUploading(true);
setError('');
onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId);
const uploaded = [];
const failed = [];
for (const file of nextFiles) {
try { uploaded.push(await uploadRiskCaseFile(file)); }
catch (uploadError) { failed.push({ file, uploadError }); }
}
if (uploaded.length > 0) {
const pending = uploaded.map((fileAsset) => ({ clientId: fileKey(fileAsset), fileAsset, materialRecordId: null }));
setLocalKeys((current) => new Set(multiple ? [...current, ...pending.map((item) => item.clientId)] : pending.map((item) => item.clientId)));
setSelectedKey(null);
onPendingLocalMaterialChange?.(fieldPath, { type: multiple ? 'add' : 'replace', materials: pending }, editSessionId);
onChange?.(multiple ? appendUnique(files, uploaded) : uploaded[uploaded.length - 1]);
}
if (failed.length > 0) setError(failed.length === nextFiles.length ? failed[0].uploadError?.message || '附件上传失败。' : `部分附件上传失败:${failed.map((item) => item.file?.name).filter(Boolean).join('、')}`);
setUploading(false);
onValidationChange?.(fieldPath, '', editSessionId);
}
function removeFile(file, index) {
const key = fileKey(file);
setLocalKeys((current) => { const next = new Set(current); next.delete(key); return next; });
onPendingLocalMaterialChange?.(fieldPath, { type: 'remove', clientId: key }, editSessionId);
onChange?.(multiple ? files.filter((_, fileIndex) => fileIndex !== index) : null);
}
return <div className="space-y-4">
{files.length > 0 ? <div className="space-y-2">{files.map((file, index) => <div className="flex min-w-0 items-center justify-between gap-3" key={`${fileKey(file)}-${index}`}>
<div className="min-w-0"><div className="truncate text-sm font-medium" title={file.name || file.url}>{file.name || file.url}</div><div className="mt-1 text-xs text-muted">{localKeys.has(fileKey(file)) ? '本地文件,保存后同步至审计材料中心' : '已选择附件'}</div></div>
<Tooltip><Button isIconOnly aria-label={`删除附件:${file.name || index + 1}`} isDisabled={uploading} size="sm" variant="danger-soft" onPress={() => removeFile(file, index)}><Delete size={16} /></Button><Tooltip.Content>删除当前附件</Tooltip.Content></Tooltip>
</div>)}</div> : <span className="text-sm text-muted">未上传</span>}
<Tabs selectedKey={activeTab} variant="secondary" onSelectionChange={(key) => setActiveTab(String(key))}>
<Tabs.ListContainer><Tabs.List aria-label="附件来源"><Tabs.Tab id={MATERIAL_CENTER_TAB}>材料中心<Tabs.Indicator /></Tabs.Tab><Tabs.Tab id={LOCAL_UPLOAD_TAB}>本地上传<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer>
<Tabs.Panel className="pt-3" id={MATERIAL_CENTER_TAB}>
<XinyuanAutocomplete aria-label="选择审计材料" emptyText="没有找到可选择的审计材料" errorText={error} inputValue={keyword} isDisabled={uploading} isLoading={loading} options={options} placeholder={multiple && files.length ? `已选 ${files.length} 个附件,可继续添加` : '请选择审计材料'} searchPlaceholder="搜索材料标题或文件名" selectedKey={selectedKey} shouldFilter={false} onInputChange={setKeyword} onSelectionChange={handleMaterialSelection} renderOption={(material) => <span className="min-w-0"><Label className="block truncate">{material.materialTitle}</Label><span className="block truncate text-xs text-muted">{[material.fileAsset?.name, material.archiveCategory, material.archiveYear].filter(Boolean).join(' · ')}</span></span>} />
{error ? <Alert className="mt-3" status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content><Button size="sm" variant="danger-soft" onPress={() => setRetryKey((value) => value + 1)}><ArrowPathIcon size={14} />重试</Button></Alert> : null}
</Tabs.Panel>
<Tabs.Panel className="pt-3" id={LOCAL_UPLOAD_TAB}><FileUploadField isDisabled={uploading} multiple={multiple} triggerLabel={uploading ? '上传中...' : '选择本地文件'} onFilesChange={handleLocalUpload} /></Tabs.Panel>
</Tabs>
</div>;
}
......@@ -15,6 +15,7 @@
- 每条案例对应一条独立 `Record`
- 案例详情以文本为主,不额外拆分附件对象
- 页面优先保证“看起来直观、方便查”
- Grid/Detail 的展示与编辑权限以运行时 Subject View 为准;没有可访问 Grid View 时不绕过授权查询案例
---
......@@ -180,6 +181,37 @@
5. 前端逐条调用 `DELETE /record/:name`
6. 删除后刷新列表,并保留失败项提示
### 4.9 Subject View、shortTitle 与权限
1. 进入案例管理后,前端先读取 `risk_case_library` Subject、View 摘要及每个 View 详情。
2. 仅 `type = "grid"` 且通过 `settings.viewAuth` 的 View 可作为列表视图;普通用户缺少配置时默认拒绝,系统管理员及 `user.applications[]` 中 `name = "risk_case_library"` 的“管理”角色可旁路。
3. 没有可访问 Grid View 时显示无可访问视图状态,不建立兜底 View,不调用案例记录/计数接口,也不展示新建、导入导出、批量或行操作。
4. 只有一个可访问 Grid View 时隐藏 Tabs 轨道;多个 View 时切换后立即应用其 columns、restrictions、filters 与 sort,并回到第一页。
5. Grid View 的 `settings.shortTitle === true` 时只隐藏根 `title` 展示列,`title` 仍参与关键词搜索、排序、查询和保存;查看详情继续通过固定右侧操作列进入。
6. Detail View 决定详情字段顺序、条件和 `shortTitle`。当前 Detail View `shortTitle === true` 时隐藏记录标题和 title 字段,但始终保留“案例详情”功能标题和 Modal 可访问名称。
7. 详情字段编辑权限优先使用当前完整 Detail View:只要其 `settings.auth` 或 `settings.disableColumns` 中存在任一有效字段配置,就整套使用该 Detail;两项均无有效配置或不存在实际 Detail 时,整体回退当前 Grid View。Detail 与 Grid 权限不合并,也不逐字段回退。
8. 选定权限来源后,`disableColumns` 对包括管理员在内的所有用户始终只读;普通用户缺少字段 `auth` 时默认只读。系统管理员、系统账号和 `risk_case_library` 自身“管理”角色只能旁路字段 `auth`。
9. Detail `columns` 仅控制当前详情字段的展示范围和顺序,和 `auth` 相互独立;未展示字段的权限配置不产生界面效果。“编辑”按钮只检查当前 Detail 实际展示的字段,切换 Detail 或 Grid 后立即重算。
### 4.10 通知消息
1. Navbar 主题按钮左侧显示 36px Bell,点击进入独立 `/notifications` 页面;Sidebar 不增加通知入口。
2. Navbar 每 60 秒读取平台真实未读数,并在通知状态变化后立即刷新;0 隐藏角标,1-99 显示真实值,100 以上显示 `99+`。
3. 页面支持消息/待办、全部/未读、300ms 远程搜索和固定每页 10 条分页。
4. 用户可标记单条已读、全部已读,并执行服务器配置的动态 Action;Action 请求保留 `{notice_id}` 协议。
5. 通知正文只作为纯文本显示,不执行 HTML。站内链接移除自身 `/risk_case_library` base 后跳转;其他应用和外部 HTTP(S) 地址新窗口打开。
6. 查询或操作失败时保留页面状态并展示可重试错误,不伪造通知数量或内容。
### 4.11 文件与审计材料中心
1. 仅当运行时 Subject/Detail View 中出现可编辑 `type = "file"` 字段时,展示“材料中心 / 本地上传”;单值或多值由字段 `multiple` 配置决定。
2. 材料中心通过 `am_material` 查询最近 20 条,搜索同时匹配材料标题和文件名。选中后仅把 `{name,type,size,url}` 快照写入详情草稿,不保存材料 ID。
3. 本地文件先通过 `/upload` 上传并进入详情草稿,不立即创建 `am_material`。取消编辑会清空会话草稿与待建档标记,不创建材料主档。
4. 用户保存详情时,前端先为仍保留在草稿中的本地文件逐个创建 `am_material`,`source_app` 固定为 `risk_case_library`;全部成功后再调用原案例更新回调。
5. 若部分材料创建或业务更新失败,详情草稿与已建档标记保留。重试时跳过已有 `materialRecordId` 的文件,避免重复建档。
6. 单值字段替换,多值字段追加并按 URL 去重。清空或替换业务附件不删除材料中心主档或已上传二进制。
7. 当前真实 `risk_case_library` Subject 没有 file 字段,因此现网页面不会显示材料入口,也不会增加测试字段或改变既有 schema。
---
## 5. 联动规则
......
......@@ -5,6 +5,7 @@ import {
removeRecord,
updateRecord,
} from '@/services/DataService';
import { buildViewSearchColumns, toArray } from '@/utils/subjectView';
import dayjs from 'dayjs';
import * as XLSX from 'xlsx';
......@@ -336,7 +337,7 @@ export function createCaseTitle(values = {}) {
return '未命名案例';
}
function buildKeywordFilter(keyword = '') {
function buildKeywordFilter(keyword = '', searchColumns = []) {
const normalizedKeyword = String(keyword || '').trim();
if (!normalizedKeyword) {
return null;
......@@ -347,8 +348,8 @@ function buildKeywordFilter(keyword = '') {
$options: 'i',
};
return {
$or: [
const configuredFields = searchColumns.map((column) => column?.field).filter(Boolean);
const fields = configuredFields.length > 0 ? [...new Set(configuredFields)] : [
{ title: regex },
{ 'metadata.label': regex },
{ 'metadata.case_type': regex },
......@@ -360,8 +361,13 @@ function buildKeywordFilter(keyword = '') {
{ 'metadata.case_level': regex },
{ 'metadata.category': regex },
{ 'metadata.tag': regex },
],
};
];
if (configuredFields.length > 0) {
return { $or: fields.map((field) => ({ [field]: regex })) };
}
return { $or: fields };
}
function buildMetadataTextFilter(fieldName, value = '') {
......@@ -378,9 +384,52 @@ function buildMetadataTextFilter(fieldName, value = '') {
};
}
function buildRecordFilter(filters = {}) {
function buildComparatorFilter(field, comparator = '$eq', value) {
if (!field) return null;
if (comparator === '$eq') return { [field]: value };
if (comparator === '$contains') {
return { [field]: { $regex: escapeRegExp(String(value ?? '')), $options: 'i' } };
}
if (comparator === '$exists') return { [field]: { $exists: Boolean(value) } };
return { [field]: { [comparator]: value } };
}
function buildViewClauses(view) {
const restrictions = toArray(view?.settings?.restrictions)
.filter((restriction) => restriction?.type === 'field' && restriction.field)
.map((restriction) => {
const comparator = restriction.comparator || '$in';
return buildComparatorFilter(
restriction.field,
comparator,
['$in', '$nin'].includes(comparator) ? toArray(restriction.value) : restriction.value,
);
});
const filterSettings = view?.settings?.filters || {};
const conditions = toArray(filterSettings.conditions)
.filter((condition) => condition?.field)
.map((condition) => buildComparatorFilter(
condition.field,
condition.comparator || '$eq',
['$in', '$nin'].includes(condition.comparator) ? toArray(condition.value) : condition.value,
));
if (conditions.length > 0) {
restrictions.push(
filterSettings.relationship === 'any' || filterSettings.relationship === 'or'
? { $or: conditions }
: conditions.length === 1 ? conditions[0] : { $and: conditions },
);
}
return restrictions.filter(Boolean);
}
function buildRecordFilter(filters = {}, view = null, subject = null) {
const clauses = [];
const keywordFilter = buildKeywordFilter(filters.keyword);
const keywordFilter = buildKeywordFilter(
filters.keyword,
view ? buildViewSearchColumns(view, subject) : [],
);
if (keywordFilter) {
clauses.push(keywordFilter);
......@@ -415,6 +464,8 @@ function buildRecordFilter(filters = {}) {
}
});
clauses.push(...buildViewClauses(view));
if (clauses.length === 0) {
return {};
}
......@@ -428,6 +479,14 @@ function buildRecordFilter(filters = {}) {
};
}
function buildViewSort(view) {
const configured = toArray(view?.settings?.columns)
.filter((column) => column?.field && column.sort)
.sort((left, right) => (left.sortIndex ?? 0) - (right.sortIndex ?? 0))
.map((column) => column.sort === 'desc' ? `-${column.field}` : column.field);
return [...configured, '-updated_at', '-created_at', '_id'].join(' ');
}
function hasActiveFilters(filters = {}) {
return Boolean(
String(filters.keyword || '').trim() ||
......@@ -580,8 +639,8 @@ async function queryCaseRecordPage(filters = {}) {
subject: CASE_SUBJECT,
page: Number(filters.page || 0),
page_size: Number(filters.pageSize || DEFAULT_PAGE_SIZE),
filter: buildRecordFilter(filters),
sort: '-updated_at -created_at',
filter: buildRecordFilter(filters, filters.view, filters.subject),
sort: buildViewSort(filters.view),
});
const records = extractArray(response);
......@@ -595,7 +654,7 @@ async function queryCaseRecordPage(filters = {}) {
export async function loadCasePage(filters = {}) {
const page = Number(filters.page || 0);
const pageSize = Number(filters.pageSize || DEFAULT_PAGE_SIZE);
const recordFilter = buildRecordFilter(filters);
const recordFilter = buildRecordFilter(filters, filters.view, filters.subject);
try {
const pageResult = await queryCaseRecordPage({
......@@ -731,6 +790,7 @@ export function buildCasePayload(values = {}) {
content_type: 'text',
content: caseDetails,
metadata: {
...(values.metadataDraft || {}),
label,
category,
tag,
......
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Avatar, Button, Card, Chip, SearchField, Spinner, Tabs, toast } from '@heroui/react';
import { useCallback, useContext, useEffect, useState } from 'react';
import { history } from 'umi';
import {
executeNotificationAction,
extractNotificationTarget,
getNotificationContent,
getNotificationSender,
isNotificationRead,
loadNotificationPage,
loadUnreadNotificationCount,
markAllNotificationsRead,
markNotificationRead,
NOTIFICATION_PAGE_SIZE,
resolveNotificationTarget,
} from './notificationService';
const CATEGORY_OPTIONS = [{ key: 'message', label: '消息' }, { key: 'todo', label: '待办' }];
const READ_STATUS_OPTIONS = [{ key: 'all', label: '全部' }, { key: 'unread', label: '未读' }];
function formatTime(value) {
if (!value) return '时间未知';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
}).format(date);
}
function NotificationItem({ notification, category, currentUserId, busy, onAction, onMarkRead, onOpenTarget }) {
const read = isNotificationRead(notification, currentUserId);
const sender = getNotificationSender(notification);
const target = resolveNotificationTarget(extractNotificationTarget(notification));
const actions = Array.isArray(notification?.actions) ? notification.actions : [];
return (
<Card>
<Card.Content className="flex flex-col gap-4 p-4 sm:p-5">
<div className="flex min-w-0 items-start gap-3">
<Avatar className="shrink-0" size="sm"><Avatar.Fallback>{sender.trim().slice(0, 1) || '系'}</Avatar.Fallback></Avatar>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h2 className="break-words text-sm font-medium sm:text-base">{notification?.title || (category === 'todo' ? '待办通知' : '通知消息')}</h2>
<p className="mt-1 text-xs text-muted sm:text-sm">{sender} · {formatTime(notification?.sent_at || notification?.created_at)}</p>
</div>
<Chip color={read ? undefined : 'danger'} size="sm" variant="soft">{read ? (category === 'todo' ? '已完成' : '已读') : category === 'todo' ? '待处理' : '未读'}</Chip>
</div>
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-6">{getNotificationContent(notification) || '暂无通知内容'}</p>
</div>
</div>
<div className="flex min-h-8 flex-wrap items-center justify-end gap-2">
{!read ? <Button isDisabled={busy} size="sm" variant="secondary" onPress={() => onMarkRead(notification)}>标为已读</Button> : null}
{category === 'todo' ? actions.map((action, index) => (
<Button isDisabled={busy || read} key={`${action?.label || 'action'}-${index}`} size="sm" variant="secondary" onPress={() => onAction(notification, action)}>{action?.label || '处理'}</Button>
)) : null}
{target ? <Button isDisabled={busy} size="sm" variant="primary" onPress={() => onOpenTarget(notification, target)}><OpenExternal size={14} />进入流程</Button> : null}
</div>
</Card.Content>
</Card>
);
}
export default function NotificationListPage() {
const userInfo = useContext(UserContext);
const [category, setCategory] = useState('message');
const [readStatus, setReadStatus] = useState('all');
const [searchText, setSearchText] = useState('');
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(0);
const [notifications, setNotifications] = useState([]);
const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
const timer = window.setTimeout(() => { setKeyword(searchText.trim()); setPage(0); }, 300);
return () => window.clearTimeout(timer);
}, [searchText]);
useEffect(() => {
let cancelled = false;
setLoading(true); setError('');
Promise.all([
loadNotificationPage({ page, category, readStatus, keyword }),
loadUnreadNotificationCount(),
]).then(([result, count]) => {
if (cancelled) return;
setNotifications(result.list); setTotal(result.total); setUnreadTotal(count);
}).catch((requestError) => {
if (!cancelled) { setNotifications([]); setTotal(0); setError(requestError?.message || '通知列表加载失败,请稍后重试'); }
}).finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [category, keyword, page, readStatus, reloadKey]);
const refresh = useCallback(() => setReloadKey((value) => value + 1), []);
const runOperation = useCallback(async (key, operation, message) => {
setBusyKey(key);
try { await operation(); toast.success(message, { timeout: 3000 }); refresh(); return true; }
catch (operationError) { toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 4000 }); return false; }
finally { setBusyKey(''); }
}, [refresh]);
const handleOpenTarget = useCallback(async (notification, target) => {
setBusyKey(notification.name);
try {
if (!isNotificationRead(notification, userInfo?._id)) await markNotificationRead(notification.name);
if (target.external) window.open(target.href, '_blank', 'noopener,noreferrer');
else history.push(target.href);
refresh();
} catch (operationError) { toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 4000 }); }
finally { setBusyKey(''); }
}, [refresh, userInfo?._id]);
return (
<div className="rc-page-stack">
<div className="rc-page-toolbar rc-page-toolbar-responsive">
<Tabs className="w-fit max-w-full min-w-0" selectedKey={category} onSelectionChange={(key) => { setCategory(String(key)); setPage(0); }}>
<Tabs.ListContainer className="w-fit max-w-full min-w-0"><Tabs.List aria-label="通知分类" className="w-max min-w-max">
{CATEGORY_OPTIONS.map((option) => <Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={option.key} key={option.key}>{option.label}<Tabs.Indicator /></Tabs.Tab>)}
</Tabs.List></Tabs.ListContainer>
</Tabs>
<Button isDisabled={loading || unreadTotal === 0} isPending={busyKey === 'all'} size="sm" variant="secondary" onPress={() => runOperation('all', markAllNotificationsRead, '全部通知已标记为已读')}>全部已读</Button>
</div>
<div className="rc-page-toolbar">
<div className="w-full min-w-0 sm:w-80 lg:w-96">
<SearchField aria-label="搜索通知内容" fullWidth value={searchText} variant="primary" onChange={setSearchText}>
<SearchField.Group><SearchField.SearchIcon><MagnifyingGlassIcon size={16} /></SearchField.SearchIcon><SearchField.Input placeholder="搜索通知内容" /><SearchField.ClearButton aria-label="清空通知搜索"><Close size={12} /></SearchField.ClearButton></SearchField.Group>
</SearchField>
</div>
<Tabs selectedKey={readStatus} onSelectionChange={(key) => { setReadStatus(String(key)); setPage(0); }}>
<Tabs.ListContainer><Tabs.List aria-label="通知状态">{READ_STATUS_OPTIONS.map((option) => <Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={option.key} key={option.key}>{option.label}<Tabs.Indicator /></Tabs.Tab>)}</Tabs.List></Tabs.ListContainer>
</Tabs>
</div>
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content><Button size="sm" variant="danger-soft" onPress={refresh}>重试</Button></Alert> : null}
{loading && notifications.length === 0 ? <div className="flex min-h-64 items-center justify-center"><Spinner size="sm" /></div> : null}
{!loading && !error && notifications.length === 0 ? <EmptyState className="min-h-64" size="md"><EmptyState.Media><Bell size={22} /></EmptyState.Media><EmptyState.Header><EmptyState.Title>{category === 'todo' ? '暂无待办通知' : '暂无通知消息'}</EmptyState.Title><EmptyState.Description>{keyword ? '没有找到匹配的通知,请调整搜索条件。' : readStatus === 'unread' ? '当前没有未读通知。' : '新的通知会显示在这里。'}</EmptyState.Description></EmptyState.Header></EmptyState> : null}
{notifications.length > 0 ? <div className="flex min-w-0 flex-col gap-3" aria-busy={loading}>{notifications.map((notification) => <NotificationItem notification={notification} category={category} currentUserId={userInfo?._id} busy={busyKey === notification.name} key={notification.name || notification._id} onMarkRead={(item) => runOperation(item.name, () => markNotificationRead(item.name), '已标记为已读')} onAction={(item, action) => runOperation(item.name, () => executeNotificationAction(item, action), '待办已处理')} onOpenTarget={handleOpenTarget} />)}</div> : null}
{!error && total > 0 ? <DataTablePagination isDisabled={loading} itemLabel="条通知" page={page} pageSize={NOTIFICATION_PAGE_SIZE} total={total} onPageChange={setPage} /> : null}
</div>
);
}
import {
exacteActionFunction,
queryUserNotice,
readUserNotice,
unReadNoticeCount,
} from '@/services/DataService';
import { APPLICATION_NAME } from '@/constants/app';
export const NOTIFICATION_PAGE_SIZE = 10;
export const NOTIFICATION_STATE_EVENT = 'risk-case-library:notification-state';
const APPLICATION_PATH_PREFIX = `/${APPLICATION_NAME}`;
function toArray(value) {
return Array.isArray(value) ? value : [];
}
function toId(value) {
if (value && typeof value === 'object') {
return String(value._id || value.id || value.name || '');
}
return String(value || '');
}
function escapeRegExp(value) {
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export async function loadNotificationPage({
page = 0,
category = 'message',
readStatus = 'all',
keyword = '',
} = {}) {
const filter = { category };
const normalizedKeyword = String(keyword || '').trim();
if (normalizedKeyword) {
filter.content = { $regex: escapeRegExp(normalizedKeyword), $options: 'i' };
}
const response = await queryUserNotice(
page,
NOTIFICATION_PAGE_SIZE,
filter,
readStatus === 'unread' ? 'unread' : null,
);
return {
list: toArray(response?.list),
total: Number(response?.total_notices ?? response?.total ?? 0) || 0,
};
}
export async function loadUnreadNotificationCount() {
const response = await unReadNoticeCount();
return Math.max(0, Number(response?.total_notices ?? response?.count ?? response?.total ?? response ?? 0) || 0);
}
export async function markNotificationRead(name) {
if (!name) throw new Error('通知缺少已读标识');
await readUserNotice(name);
notifyNotificationStateChanged();
}
export async function markAllNotificationsRead() {
await readUserNotice();
notifyNotificationStateChanged();
}
export async function executeNotificationAction(notification, action) {
const actionName = typeof action?.action === 'string'
? action.action
: action?.action?.name || action?.name;
const methodName = action?.method_name || action?.methodName;
if (!actionName || !methodName) throw new Error('该待办操作缺少执行配置');
const result = await exacteActionFunction(actionName, methodName, {
notice_id: notification?._id,
});
notifyNotificationStateChanged();
return result;
}
export function isNotificationRead(notification, userId) {
const normalizedUserId = toId(userId);
return normalizedUserId
? toArray(notification?.data?.logs).some((log) => toId(log?.user_id) === normalizedUserId)
: false;
}
export function getNotificationSender(notification) {
return notification?.from?.display_name || notification?.from?.username ||
notification?.created_by?.display_name || notification?.created_by?.username || '系统';
}
export function getNotificationContent(notification) {
return String(notification?.content || notification?.content_compile || '').trim();
}
function trimTarget(target) {
return String(target || '').replace(/[),.;!?,。;!?)]+$/u, '');
}
export function extractNotificationTarget(notification) {
const configuredTarget = notification?.settings?.url || notification?.settings?.link ||
notification?.data?.url || notification?.data?.link;
if (configuredTarget) return trimTarget(configuredTarget);
const match = getNotificationContent(notification).match(new RegExp(
`https?:\\/\\/[^\\s<>"']+|${escapeRegExp(APPLICATION_PATH_PREFIX)}\\/[^\\s<>"']+|\\/resource\\/(?:stat|booking)(?:\\?[^\\s<>"']*)?`,
));
return trimTarget(match?.[0]);
}
export function resolveNotificationTarget(target) {
const normalizedTarget = trimTarget(target);
if (!normalizedTarget) return null;
if (/^https?:\/\//i.test(normalizedTarget)) {
try {
const url = new URL(normalizedTarget);
if (url.origin === window.location.origin && url.pathname.startsWith(`${APPLICATION_PATH_PREFIX}/`)) {
return resolveNotificationTarget(`${url.pathname}${url.search}${url.hash}`);
}
return { href: url.toString(), external: true };
} catch {
return null;
}
}
const appPrefix = APPLICATION_PATH_PREFIX;
if (normalizedTarget === appPrefix || normalizedTarget.startsWith(`${appPrefix}/`)) {
return { href: normalizedTarget.slice(appPrefix.length) || '/', external: false };
}
if (normalizedTarget.startsWith('/resource/') || normalizedTarget === '/notifications') {
return { href: normalizedTarget, external: false };
}
return normalizedTarget.startsWith('/') ? { href: normalizedTarget, external: true } : null;
}
export function notifyNotificationStateChanged() {
window.dispatchEvent(new CustomEvent(NOTIFICATION_STATE_EVENT));
}
import {
DataTable,
DataTableChip,
DataTablePagination,
DataTablePrimaryText,
DataTableSecondaryText,
DataTableText,
DataTableTextPair,
} from '@/components/DataTable';
import { TableActionButton, TABLE_ACTION_ICONS } from '@/components/TableActionButton';
import FileUploadField from '@/components/FileUploadField';
import { UserContext } from '@/wrapper/Auth';
import {
buildViewColumns,
checkViewCondition,
formatFieldValue,
getFieldNameFromPath,
getFieldValue,
hasFieldAuthorizationConfiguration,
isFieldDisabledByView,
shouldHideViewTitleColumn,
} from '@/utils/subjectView';
import {
Alert,
Button,
Checkbox,
Chip,
Disclosure,
Input,
......@@ -40,11 +48,16 @@ import {
import clsx from 'clsx';
import {
startTransition,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import EmptyStatePage from '../EmptyStatePage';
import RiskCaseMaterialField from '../RiskCaseMaterialField';
import { createRiskCaseMaterial } from '../riskCaseMaterialService';
import { loadRiskCaseViewResources } from '../riskCaseViewService';
import {
buildCaseDashboard,
createCaseRecord,
......@@ -326,6 +339,7 @@ function buildEmptyCaseForm() {
caseSource: '',
caseLevel: '',
caseYear: '',
metadataDraft: {},
};
}
......@@ -346,6 +360,39 @@ function buildCaseForm(caseItem) {
caseSource: caseItem.caseSource || '',
caseLevel: caseItem.caseLevel || '',
caseYear: caseItem.caseYear || '',
metadataDraft: { ...(caseItem.rawMetadata || {}) },
};
}
const FORM_FIELD_KEYS = {
label: 'label',
category: 'category',
tag: 'tag',
case_type: 'caseType',
case_attribute: 'caseAttribute',
case_details: 'caseDetails',
case_warning: 'caseWarning',
keyword: 'keyword',
case_source: 'caseSource',
case_level: 'caseLevel',
case_year: 'caseYear',
};
function getDetailFormValue(form, fieldPath) {
if (fieldPath === 'title') return form.label;
const fieldName = getFieldNameFromPath(fieldPath);
const formKey = FORM_FIELD_KEYS[fieldName];
return formKey ? form[formKey] : form.metadataDraft?.[fieldName];
}
function setDetailFormValue(form, fieldPath, value) {
if (fieldPath === 'title') return { ...form, label: value };
const fieldName = getFieldNameFromPath(fieldPath);
const formKey = FORM_FIELD_KEYS[fieldName];
if (formKey) return { ...form, [formKey]: value };
return {
...form,
metadataDraft: { ...(form.metadataDraft || {}), [fieldName]: value },
};
}
......@@ -415,6 +462,7 @@ function buildImportPreviewRows(preview) {
}
export default function ResourceBookingPage() {
const currentUser = useContext(UserContext);
const [query, setQuery] = useState('');
const [tagFilter, setTagFilter] = useState('');
const [caseSourceFilter, setCaseSourceFilter] = useState('');
......@@ -457,11 +505,61 @@ export default function ResourceBookingPage() {
const [batchEditForm, setBatchEditForm] = useState(buildEmptyBatchEditForm);
const [importFile, setImportFile] = useState(null);
const [importPreview, setImportPreview] = useState(null);
const [subject, setSubject] = useState(null);
const [gridViews, setGridViews] = useState([]);
const [detailViews, setDetailViews] = useState([]);
const [activeGridViewName, setActiveGridViewName] = useState('');
const [activeDetailViewName, setActiveDetailViewName] = useState('');
const [resourcesLoading, setResourcesLoading] = useState(true);
const [resourceError, setResourceError] = useState('');
const [materialValidationErrors, setMaterialValidationErrors] = useState({});
const pendingLocalMaterialsRef = useRef({});
const editSessionRef = useRef(0);
const activeGridView = useMemo(
() => gridViews.find((view) => view.name === activeGridViewName) || null,
[activeGridViewName, gridViews],
);
const activeCase = useMemo(() => {
return cases.find((item) => item.id === activeCaseId) || null;
}, [activeCaseId, cases]);
const visibleDetailViews = useMemo(() => {
const configured = detailViews.filter((view) =>
checkViewCondition(view?.settings?.visabled, activeCase?.rawRecord));
return configured.length > 0 ? configured : [{
name: 'base',
title: '基础信息',
type: 'detail',
settings: {},
}];
}, [activeCase?.rawRecord, detailViews]);
const activeDetailView = useMemo(
() => visibleDetailViews.find((view) => view.name === activeDetailViewName) || visibleDetailViews[0],
[activeDetailViewName, visibleDetailViews],
);
const activeDetailColumns = useMemo(
() => buildViewColumns(activeDetailView, subject),
[activeDetailView, subject],
);
const hideDetailTitle = shouldHideViewTitleColumn(activeDetailView);
const activeDetailAuthorizationView = useMemo(
() => hasFieldAuthorizationConfiguration(activeDetailView)
? activeDetailView
: activeGridView || activeDetailView,
[activeDetailView, activeGridView],
);
const hasEditableDetailFields = useMemo(
() => activeDetailColumns.some((column) => !isFieldDisabledByView(
column.field,
activeDetailAuthorizationView,
currentUser,
)),
[activeDetailAuthorizationView, activeDetailColumns, currentUser],
);
const isDetailDirty = useMemo(
() => JSON.stringify(detailForm) !== JSON.stringify(buildCaseForm(activeCase)),
[activeCase, detailForm],
......@@ -514,100 +612,20 @@ export default function ResourceBookingPage() {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const caseTableColumns = useMemo(
() => [
{
key: 'label',
title: '标题',
width: 380,
pin: 'start',
isPrimary: true,
render: (item) => (
<div className="flex min-w-0 max-w-full flex-col gap-1 overflow-hidden">
<DataTablePrimaryText
text={item.label || '未命名案例'}
showTooltip
>
{item.label || '未命名案例'}
</DataTablePrimaryText>
<DataTableSecondaryText
text={item.contentPreview || '-'}
>
{item.contentPreview || '-'}
</DataTableSecondaryText>
</div>
),
},
{
key: 'category',
title: '分类',
width: 180,
render: (item) => (
<DataTableChip color="accent" text={item.category || '-'} variant="soft">
{item.category || '-'}
</DataTableChip>
),
},
{
key: 'tag',
title: '标签',
width: 380,
...buildViewColumns(activeGridView, subject).map((column, index) => ({
key: column.field,
title: column.label,
width: column.width,
pin: column.fixed === 'left' || column.pin === 'start' ? 'start' : undefined,
isPrimary: index === 0,
sortable: Boolean(column.sortable || column.sort),
render: (item) => (
item.tag ? (
<DataTableChip color="success" text={item.tag} variant="soft">
{item.tag}
</DataTableChip>
) : '-'
),
},
{
key: 'caseType',
title: '案例类型',
width: 240,
render: (item) => (
<DataTableTextPair
primary={item.caseType || '-'}
secondary={item.caseAttribute || ''}
<DataTableText
showTooltip
text={formatFieldValue(getFieldValue(item.rawRecord, column.field), column.fieldDef) || '-'}
/>
),
},
{
key: 'keyword',
title: '关键字',
width: 340,
render: (item) => (
<KeywordChips
value={item.keywordList || item.keyword}
emptyText="-"
limit={3}
/>
),
},
{
key: 'caseSource',
title: '案例来源',
width: 180,
render: (item) => (
<DataTableText text={item.caseSource || '-'} showTooltip />
),
},
{
key: 'caseLevel',
title: '等级 / 年份',
width: 150,
render: (item) => (
<DataTableTextPair
primary={item.caseLevel || '-'}
secondary={item.caseYear ? `${item.caseYear} 年` : ''}
/>
),
},
{
key: 'updatedAtLabel',
title: '更新时间',
width: 150,
render: (item) => (
<DataTableText text={item.updatedAtLabel || '-'} />
),
},
})),
{
key: 'actions',
title: '操作',
......@@ -624,9 +642,42 @@ export default function ResourceBookingPage() {
),
},
],
[],
[activeGridView, subject],
);
useEffect(() => {
let cancelled = false;
setResourcesLoading(true);
setResourceError('');
loadRiskCaseViewResources(currentUser)
.then((resources) => {
if (cancelled) return;
setSubject(resources.subject);
setGridViews(resources.gridViews);
setDetailViews(resources.detailViews);
setActiveGridViewName((current) =>
resources.gridViews.some((view) => view.name === current)
? current
: resources.gridViews[0]?.name || '');
})
.catch((loadError) => {
if (!cancelled) {
setSubject(null);
setGridViews([]);
setDetailViews([]);
setResourceError(loadError?.message || '案例视图配置加载失败。');
}
})
.finally(() => { if (!cancelled) setResourcesLoading(false); });
return () => { cancelled = true; };
}, [currentUser]);
useEffect(() => {
if (!visibleDetailViews.some((view) => view.name === activeDetailViewName)) {
setActiveDetailViewName(visibleDetailViews[0]?.name || '');
}
}, [activeDetailViewName, visibleDetailViews]);
useEffect(() => {
setDetailForm(buildCaseForm(activeCase));
}, [activeCase]);
......@@ -640,6 +691,12 @@ export default function ResourceBookingPage() {
}, [visibleCases]);
async function loadCaseList(nextPage = 0, nextPageSize = pageSize) {
if (!activeGridView) {
setCases([]);
setTotal(0);
setLoading(false);
return { cases: [], total: 0 };
}
setLoading(true);
setError('');
......@@ -647,6 +704,8 @@ export default function ResourceBookingPage() {
...activeFilters,
page: nextPage,
pageSize: nextPageSize,
view: activeGridView,
subject,
});
setCases(result.cases || []);
......@@ -685,6 +744,14 @@ export default function ResourceBookingPage() {
let cancelled = false;
async function runFirstPage() {
if (!activeGridView) {
setCases([]);
setDashboard(buildCaseDashboard([]));
setSelectedCaseIds([]);
setTotal(0);
setLoading(false);
return;
}
setLoading(true);
setError('');
......@@ -692,6 +759,8 @@ export default function ResourceBookingPage() {
...activeFilters,
page: 0,
pageSize,
view: activeGridView,
subject,
});
if (cancelled) {
......@@ -733,7 +802,7 @@ export default function ResourceBookingPage() {
return () => {
cancelled = true;
};
}, [activeFilters, pageSize]);
}, [activeFilters, activeGridView, pageSize, subject]);
async function refreshCurrentList(preferredPage = page) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
......@@ -772,6 +841,9 @@ export default function ResourceBookingPage() {
}
function openCaseDetail(caseItem) {
editSessionRef.current += 1;
pendingLocalMaterialsRef.current = {};
setMaterialValidationErrors({});
setActiveCaseId(caseItem.id);
setDetailForm(buildCaseForm(caseItem));
setIsDetailEditing(false);
......@@ -786,6 +858,8 @@ export default function ResourceBookingPage() {
}
if (!isOpen) {
pendingLocalMaterialsRef.current = {};
setMaterialValidationErrors({});
setDetailForm(buildCaseForm(activeCase));
setIsDetailEditing(false);
}
......@@ -794,11 +868,42 @@ export default function ResourceBookingPage() {
}
function handleCancelDetailEdit() {
editSessionRef.current += 1;
pendingLocalMaterialsRef.current = {};
setMaterialValidationErrors({});
setDetailForm(buildCaseForm(activeCase));
setIsDetailEditing(false);
setError('');
}
function handlePendingLocalMaterialChange(fieldPath, change, editSessionId) {
if (editSessionId !== editSessionRef.current) return;
const pendingByField = { ...pendingLocalMaterialsRef.current };
const current = Array.isArray(pendingByField[fieldPath]) ? pendingByField[fieldPath] : [];
let next = current;
if (change?.type === 'replace') next = change.materials || [];
if (change?.type === 'add') {
next = [...new Map([...current, ...(change.materials || [])]
.map((material) => [material.clientId, material])).values()];
}
if (change?.type === 'remove') {
next = current.filter((material) => material.clientId !== change.clientId);
}
if (next.length > 0) pendingByField[fieldPath] = next;
else delete pendingByField[fieldPath];
pendingLocalMaterialsRef.current = pendingByField;
}
function handleMaterialValidation(fieldPath, message, editSessionId) {
if (editSessionId !== editSessionRef.current) return;
setMaterialValidationErrors((current) => {
const next = { ...current };
if (message) next[fieldPath] = message;
else delete next[fieldPath];
return next;
});
}
async function handleCreateSubmit() {
if (
!String(createForm.label || '').trim() &&
......@@ -847,11 +952,32 @@ export default function ResourceBookingPage() {
setNotice('');
try {
for (const [fieldPath, pendingMaterials] of Object.entries(pendingLocalMaterialsRef.current)) {
const currentValue = getDetailFormValue(detailForm, fieldPath);
const retainedUrls = new Set((Array.isArray(currentValue) ? currentValue : currentValue ? [currentValue] : [])
.map((file) => file?.url).filter(Boolean));
for (const pendingMaterial of pendingMaterials) {
if (pendingMaterial.materialRecordId || !retainedUrls.has(pendingMaterial.fileAsset?.url)) continue;
const createdMaterial = await createRiskCaseMaterial({
fileAsset: pendingMaterial.fileAsset,
currentUser,
});
pendingLocalMaterialsRef.current = {
...pendingLocalMaterialsRef.current,
[fieldPath]: (pendingLocalMaterialsRef.current[fieldPath] || []).map((material) =>
material.clientId === pendingMaterial.clientId
? { ...material, materialRecordId: createdMaterial.id }
: material),
};
}
}
const updated = await updateCaseRecord(activeCase, detailForm);
setNotice(`案例“${updated.label}”已更新。`);
await refreshCurrentList();
setActiveCaseId(updated.id || activeCase.id);
setIsDetailEditing(false);
pendingLocalMaterialsRef.current = {};
setMaterialValidationErrors({});
} catch (saveError) {
setError(saveError?.message || '案例保存失败,请稍后重试。');
} finally {
......@@ -1093,8 +1219,43 @@ export default function ResourceBookingPage() {
}
}
if (resourcesLoading) {
return <div className="flex min-h-64 items-center justify-center gap-2" role="status"><Spinner size="sm" /><span>正在加载案例视图...</span></div>;
}
if (resourceError) {
return <Alert status="danger"><Alert.Content><Alert.Description>{resourceError}</Alert.Description></Alert.Content></Alert>;
}
if (!activeGridView) {
return <EmptyStatePage title="暂无可访问的案例视图" />;
}
return (
<div className="space-y-6">
{gridViews.length > 1 ? (
<Tabs
className="w-fit max-w-full min-w-0"
selectedKey={activeGridViewName}
variant="primary"
onSelectionChange={(key) => {
setActiveGridViewName(String(key));
setPage(0);
setSelectedCaseIds([]);
}}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="案例视图" className="w-max min-w-max">
{gridViews.map((view) => (
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={view.name} key={view.name}>
{view.title || view.name}
<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
) : null}
<div className="min-w-0 max-w-full">
<Tabs
className="w-fit max-w-full min-w-0"
......@@ -1578,195 +1739,73 @@ export default function ResourceBookingPage() {
<Modal.Body>
{activeCase ? (
<div className="space-y-6">
{!hideDetailTitle ? (
<h2 className="break-words text-lg font-semibold">{activeCase.title || activeCase.label}</h2>
) : null}
<div className="flex flex-wrap gap-x-4 gap-y-2">
<span>创建于 {activeCase.createdAtLabel}</span>
<span>最近更新 {activeCase.updatedAtLabel}</span>
</div>
{visibleDetailViews.length > 1 ? (
<Tabs selectedKey={activeDetailView?.name} onSelectionChange={(key) => setActiveDetailViewName(String(key))}>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="详情视图" className="w-max min-w-max">
{visibleDetailViews.map((view) => (
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={view.name} key={view.name}>
{view.title || view.name}<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
) : null}
{!isDetailEditing ? (
<Surface
className="px-5 py-2 sm:px-6 sm:py-3"
variant="secondary"
>
<div className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2">
<CaseDetailField fullWidth label="标题" value={activeCase.label} />
<CaseDetailField label="分类" value={activeCase.category} />
<CaseDetailField label="标签" value={activeCase.tag} />
<CaseDetailField label="案例类型" value={activeCase.caseType} />
<CaseDetailField label="案例属性" value={activeCase.caseAttribute} />
<CaseDetailField fullWidth label="关键字" value={activeCase.keywordList}>
<KeywordChips value={activeCase.keywordList} />
</CaseDetailField>
<CaseDetailField label="案例来源" value={activeCase.caseSource} />
<CaseDetailField label="案例等级" value={activeCase.caseLevel} />
<CaseDetailField label="案例年份" value={activeCase.caseYear} />
<CaseDetailField fullWidth label="案例详情" value={activeCase.caseDetails} />
<CaseDetailField fullWidth label="案例警示" value={activeCase.caseWarning} />
{activeDetailColumns.map((column) => {
const value = getFieldValue(activeCase.rawRecord, column.field);
const fieldType = String(column.fieldDef?.type || '').toLowerCase();
const fullWidth = ['long_text', 'rich_text', 'object', 'subject', 'file'].includes(fieldType);
return (
<CaseDetailField fullWidth={fullWidth} key={column.field} label={column.label} value={value}>
{formatFieldValue(value, column.fieldDef) || '暂无内容'}
</CaseDetailField>
);
})}
</div>
</Surface>
) : (
<div className="grid min-w-0 gap-4 md:grid-cols-2">
<TextField className="md:col-span-2">
<Label>标题</Label>
<Input
fullWidth
value={detailForm.label}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
label: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>分类</Label>
<Input
fullWidth
value={detailForm.category}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
category: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>标签</Label>
<Input
fullWidth
value={detailForm.tag}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
tag: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>案例类型</Label>
<Input
fullWidth
value={detailForm.caseType}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseType: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>案例属性</Label>
<Input
fullWidth
value={detailForm.caseAttribute}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseAttribute: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>关键字</Label>
<KeywordEditor
value={detailForm.keyword}
onChange={(nextKeywords) =>
setDetailForm((prev) => ({
...prev,
keyword: nextKeywords,
}))
}
placeholder="输入关键字后按 Enter 新增"
/>
</TextField>
<TextField>
<Label>案例来源</Label>
<Input
fullWidth
value={detailForm.caseSource}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseSource: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>案例等级</Label>
<Input
fullWidth
value={detailForm.caseLevel}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseLevel: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>案例年份</Label>
<Input
fullWidth
type="number"
value={detailForm.caseYear}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseYear: event.target.value,
}))
}
/>
</TextField>
<TextField className="md:col-span-2">
<div className="mb-2 flex items-center justify-between gap-3">
<Label>案例详情</Label>
<span>
当前 {String(detailForm.caseDetails || '').replace(/\s+/g, '').length} 字
</span>
</div>
<TextArea
fullWidth
rows={16}
variant="secondary"
value={detailForm.caseDetails}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseDetails: event.target.value,
}))
}
/>
</TextField>
<TextField className="md:col-span-2">
<Label>案例警示</Label>
<TextArea
fullWidth
rows={4}
variant="secondary"
value={detailForm.caseWarning}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseWarning: event.target.value,
}))
}
/>
</TextField>
{activeDetailColumns.map((column) => {
const fieldType = String(column.fieldDef?.type || 'text').toLowerCase();
const fieldName = getFieldNameFromPath(column.field);
const value = getDetailFormValue(detailForm, column.field);
const disabled = isFieldDisabledByView(
column.field,
activeDetailAuthorizationView,
currentUser,
);
const fullWidth = ['long_text', 'rich_text', 'object', 'subject', 'file'].includes(fieldType);
const label = `${column.label}${column.fieldDef?.isRequired ? ' *' : ''}`;
if (fieldType === 'file') {
return <div className={clsx('min-w-0', fullWidth && 'md:col-span-2')} key={column.field}><Label>{label}</Label><div className="mt-2">{disabled ? <span className="text-sm text-muted">{formatFieldValue(value, column.fieldDef) || '未上传'}</span> : <RiskCaseMaterialField editSessionId={editSessionRef.current} fieldPath={column.field} multiple={Boolean(column.fieldDef?.multiple || column.fieldDef?.settings?.multiple)} value={value} onChange={(nextValue) => setDetailForm((current) => setDetailFormValue(current, column.field, nextValue))} onPendingLocalMaterialChange={handlePendingLocalMaterialChange} onValidationChange={handleMaterialValidation} />}</div></div>;
}
if (fieldName === 'keyword') {
return <TextField className={fullWidth ? 'md:col-span-2' : undefined} isDisabled={disabled} key={column.field}><Label>{label}</Label><KeywordEditor value={value} onChange={(nextValue) => setDetailForm((current) => setDetailFormValue(current, column.field, nextValue))} /></TextField>;
}
if (fieldType === 'boolean') {
return <Checkbox isDisabled={disabled} isSelected={Boolean(value)} key={column.field} onChange={(selected) => setDetailForm((current) => setDetailFormValue(current, column.field, selected))}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>{label}</Checkbox.Content></Checkbox>;
}
if (['long_text', 'rich_text'].includes(fieldType) || ['case_details', 'case_warning'].includes(fieldName)) {
return <TextField className="md:col-span-2" isDisabled={disabled} key={column.field}><Label>{label}</Label><TextArea fullWidth rows={fieldName === 'case_details' ? 16 : 4} variant="secondary" value={value || ''} onChange={(event) => setDetailForm((current) => setDetailFormValue(current, column.field, event.target.value))} /></TextField>;
}
return <TextField isDisabled={disabled} key={column.field}><Label>{label}</Label><Input fullWidth type={fieldType === 'number' ? 'number' : 'text'} variant="secondary" value={value ?? ''} onChange={(event) => setDetailForm((current) => setDetailFormValue(current, column.field, event.target.value))} /></TextField>;
})}
</div>
)}
</div>
......@@ -1797,7 +1836,7 @@ export default function ResourceBookingPage() {
取消
</Button>
<Button
isDisabled={saving || !activeCase || !isDetailDirty}
isDisabled={saving || !activeCase || !isDetailDirty || Object.keys(materialValidationErrors).length > 0}
size="sm"
variant="primary"
onPress={handleSaveDetail}
......@@ -1805,7 +1844,7 @@ export default function ResourceBookingPage() {
{saving ? '保存中...' : '保存'}
</Button>
</>
) : (
) : hasEditableDetailFields ? (
<Button
isDisabled={!activeCase}
size="sm"
......@@ -1814,7 +1853,7 @@ export default function ResourceBookingPage() {
>
编辑
</Button>
)}
) : null}
</div>
</Modal.Footer>
</Modal.Dialog>
......
import dayjs from 'dayjs';
import { APPLICATION_NAME } from '@/constants/app';
import { createRecord, queryRecords, uploadFile } from '@/services/DataService';
export const AUDIT_MATERIAL_SUBJECT = 'am_material';
export const RISK_CASE_SOURCE_APP = APPLICATION_NAME;
const MATERIAL_PAGE_SIZE = 20;
function extractRecords(response) {
return [
response,
response?.data,
response?.data?.list,
response?.data?.records,
response?.list,
response?.records,
response?.items,
response?.result,
response?.result?.records,
].find(Array.isArray) || [];
}
function escapeRegExp(value = '') {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function normalizeFileAsset(uploadResult, file) {
const candidates = [
uploadResult?.file,
uploadResult?.data?.file,
uploadResult?.data,
uploadResult?.result?.file,
uploadResult?.result,
uploadResult,
];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== 'object') continue;
const url = candidate.url || candidate.path || candidate.location || candidate?.data?.url;
if (!url) continue;
return {
name: candidate.name || candidate.filename || file?.name || '未命名文件',
type: candidate.type || candidate.mimetype || file?.type || 'application/octet-stream',
size: Number(candidate.size || file?.size || 0),
url,
};
}
throw new Error('文件上传成功,但未获取到可用的文件地址。');
}
function normalizeMaterial(record = {}) {
const metadata = record.metadata || {};
const fileAsset = metadata.file_asset || {};
return {
id: record._id || record.id || record.name || '',
materialTitle: metadata.material_title || record.title || fileAsset.name || '未命名材料',
fileAsset,
archiveCategory: metadata.archive_category || '',
archiveYear: metadata.archive_year || '',
};
}
export async function queryRiskCaseMaterials(keyword = '') {
const escapedKeyword = escapeRegExp(String(keyword || '').trim());
const filter = escapedKeyword ? {
$or: [
{ 'metadata.material_title': { $regex: escapedKeyword, $options: 'i' } },
{ 'metadata.file_asset.name': { $regex: escapedKeyword, $options: 'i' } },
],
} : {};
const response = await queryRecords({
subject: AUDIT_MATERIAL_SUBJECT,
page: 0,
page_size: MATERIAL_PAGE_SIZE,
filter,
sort: '-updated_at -created_at',
mode: 'full',
});
return extractRecords(response).map(normalizeMaterial)
.filter((material) => material.id && material.fileAsset?.url);
}
export async function uploadRiskCaseFile(file) {
if (!file) throw new Error('请选择要上传的本地文件。');
return normalizeFileAsset(await uploadFile(file), file);
}
export async function createRiskCaseMaterial({ fileAsset, currentUser }) {
if (!fileAsset?.url) throw new Error('缺少有效的文件地址,无法写入审计材料中心。');
const uploaderId = currentUser?._id || currentUser?.id;
if (!uploaderId) throw new Error('未获取到当前登录用户,无法写入审计材料中心。');
const materialTitle = String(fileAsset.name || '').replace(/\.[^.]+$/, '').trim() || '未命名材料';
const response = await createRecord({
subject: AUDIT_MATERIAL_SUBJECT,
title: materialTitle,
content_type: 'text',
metadata: {
material_title: materialTitle,
file_asset: {
name: fileAsset.name,
type: fileAsset.type || 'application/octet-stream',
size: Number(fileAsset.size || 0),
url: fileAsset.url,
},
source_app: RISK_CASE_SOURCE_APP,
uploader: uploaderId,
uploaded_at: dayjs().toISOString(),
},
});
const created = response?.data || response?.result || response;
return { id: created?._id || created?.id || created?.name || true, record: created };
}
import {
getSubjectView,
getSubjectViewDetail,
loadSubject,
} from '@/services/DataService';
import { isViewAccessible, toArray } from '@/utils/subjectView';
import { CASE_SUBJECT } from './materialService';
export async function loadRiskCaseViewResources(user) {
const [subject, viewSummaries] = await Promise.all([
loadSubject(CASE_SUBJECT),
getSubjectView(CASE_SUBJECT),
]);
const views = await Promise.all(toArray(viewSummaries).map(async (view) => {
if (!view?.name) return view;
try {
const detail = await getSubjectViewDetail(CASE_SUBJECT, view.name);
return {
...view,
...detail,
settings: detail?.settings || view?.settings || {},
};
} catch {
return view;
}
}));
return {
subject,
views,
gridViews: views.filter((view) => view?.type === 'grid' && isViewAccessible(view, user)),
detailViews: views.filter((view) => view?.type === 'detail'),
};
}
import { AUTHORIZATION_APPLICATION_NAME } from '@/constants/app';
export function normalizeText(value) {
return String(value ?? '').trim();
}
export function toArray(value) {
if (value === null || value === undefined || value === '') return [];
return Array.isArray(value) ? value : [value];
}
function collectIdentityValues(values) {
return toArray(values)
.flatMap((value) => value && typeof value === 'object'
? [value._id, value.name, value.display_name, value.displayName, value.username]
: value)
.map(normalizeText)
.filter(Boolean);
}
export function getUserApplication(user, applicationName = AUTHORIZATION_APPLICATION_NAME) {
return toArray(user?.applications).find(
(application) => normalizeText(application?.name) === normalizeText(applicationName),
) || null;
}
function getApplicationAuthorization(user, applicationName) {
const application = getUserApplication(user, applicationName);
return {
groups: collectIdentityValues(application?.groups),
roles: collectIdentityValues(application?.roles),
permissions: collectIdentityValues(application?.permissions),
};
}
function intersects(left, right) {
const allowed = new Set(collectIdentityValues(right));
return collectIdentityValues(left).some((value) => allowed.has(value));
}
function normalizeAuthRule(authRule) {
return ['group', 'role', 'permission'].includes(authRule?.type) ? authRule : '管理';
}
export function isSystemAdministrator(user) {
return user?.type === 'admin' || user?.type === 'system';
}
export function hasApplicationManagementAccess(
user,
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (isSystemAdministrator(user)) return true;
return intersects(getApplicationAuthorization(user, applicationName).roles, ['管理']);
}
export function hasAuthAccess(
authRule,
user,
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (!user) return false;
if (hasApplicationManagementAccess(user, applicationName)) return true;
const { groups, roles, permissions } = getApplicationAuthorization(user, applicationName);
if (typeof authRule === 'string') return intersects(roles, [authRule]);
if (authRule?.type === 'group') return intersects(groups, authRule.groups);
if (authRule?.type === 'role') return intersects(roles, authRule.roles);
if (authRule?.type === 'permission') return intersects(permissions, authRule.permissions);
return false;
}
export function isViewAccessible(
view,
user,
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
return hasAuthAccess(normalizeAuthRule(view?.settings?.viewAuth), user, applicationName);
}
export function hasFieldAuthorizationConfiguration(view) {
const settings = view?.settings;
return (
toArray(settings?.auth).some((rule) => normalizeText(rule?.field)) ||
toArray(settings?.disableColumns).some((column) => normalizeText(column?.field))
);
}
export function isFieldDisabledByView(
fieldPath,
view,
user,
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (toArray(view?.settings?.disableColumns).some((column) => column?.field === fieldPath)) {
return true;
}
if (hasApplicationManagementAccess(user, applicationName)) return false;
const fieldAuth = toArray(view?.settings?.auth).find((rule) => rule?.field === fieldPath);
return !hasAuthAccess(normalizeAuthRule(fieldAuth), user, applicationName);
}
export function shouldHideViewTitleColumn(view) {
return view?.settings?.shortTitle === true;
}
export function getFieldValue(record, fieldPath) {
if (!record || !fieldPath) return null;
return fieldPath.split('.').reduce((value, key) => value?.[key], record);
}
export function getFieldDefinition(fieldPath, subject) {
if (fieldPath === 'title') return { name: 'title', label: '标题', type: 'text' };
const fieldName = String(fieldPath || '').replace(/^metadata\./, '').split('.').pop();
return toArray(subject?.fields).find((field) => field?.name === fieldName) || null;
}
function getColumnLabel(column, fieldDefinition) {
return [column?.label, column?.title, column?.header, column?.displayName, column?.display_name]
.map(normalizeText)
.find(Boolean) || fieldDefinition?.label || column?.field || '未命名字段';
}
export function buildViewColumns(view, subject, { includeHidden = false } = {}) {
const hideTitle = !includeHidden && shouldHideViewTitleColumn(view);
const configuredColumns = toArray(view?.settings?.columns);
const columns = configuredColumns.length > 0
? configuredColumns
: [
{ field: 'title', width: 240 },
...toArray(subject?.fields).map((field) => ({ field: `metadata.${field.name}`, width: 180 })),
];
return columns
.filter((column) => column?.field && (includeHidden || !column.hide))
.filter((column) => !hideTitle || column.field !== 'title')
.map((column) => {
const fieldDef = getFieldDefinition(column.field, subject) || {
name: column.field,
label: column.field,
type: 'text',
};
return {
...column,
label: getColumnLabel(column, fieldDef),
width: Number(column.width) || 180,
fieldDef,
};
});
}
export function buildViewSearchColumns(view, subject) {
const columns = buildViewColumns(view, subject);
return columns.some((column) => column.field === 'title')
? columns
: [{ field: 'title', label: '标题', width: 240, fieldDef: getFieldDefinition('title', subject) }, ...columns];
}
function compareValue(recordValue, comparator, expectedValue) {
const actual = toArray(recordValue).map((value) => normalizeText(
value && typeof value === 'object'
? value._id || value.name || value.title || value.display_name || value.username
: value,
));
const expected = toArray(expectedValue).map(normalizeText);
if (comparator === '$nin' || comparator === '$ne') return !actual.some((value) => expected.includes(value));
if (comparator === '$exists') return Boolean(expectedValue) ? recordValue != null : recordValue == null;
if (comparator === '$contains') return actual.some((value) => value.includes(expected.join(' ')));
return actual.some((value) => expected.includes(value));
}
export function checkViewCondition(condition, record) {
if (condition === undefined || condition === null) return true;
if (typeof condition === 'boolean') return condition;
const rules = toArray(condition?.rules || condition?.conditions);
if (rules.length === 0) return true;
const results = rules.map((rule) => compareValue(
getFieldValue(record, rule.field),
rule.comparator || '$eq',
rule.value,
));
return condition.relationship === 'any' || condition.relationship === 'or'
? results.some(Boolean)
: results.every(Boolean);
}
export function formatFieldValue(value, fieldDef) {
if (value === null || value === undefined || value === '') return '';
if (Array.isArray(value)) {
return value.map((item) => formatFieldValue(item, { ...fieldDef, multiple: false })).filter(Boolean).join('、');
}
if (fieldDef?.type === 'boolean') return value ? '是' : '否';
if (fieldDef?.type === 'file') return value?.name || value?.url || '';
if (typeof value === 'object') {
return value.title || value.display_name || value.username || value.name || value._id || JSON.stringify(value);
}
return String(value);
}
export function getFieldNameFromPath(fieldPath) {
return String(fieldPath || '').replace(/^metadata\./, '').split('.').pop();
}
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