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 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