Commit 2078e013 authored by Andy-bubu's avatar Andy-bubu

feat: sync risk case Xinyuan capabilities

parent b4b5a6e7
...@@ -9,6 +9,7 @@ const ROUTE_PATHS = { ...@@ -9,6 +9,7 @@ const ROUTE_PATHS = {
login: '/login', login: '/login',
resourceStat: '/resource/stat', resourceStat: '/resource/stat',
resourceBooking: '/resource/booking', resourceBooking: '/resource/booking',
notifications: '/notifications',
} as const; } as const;
export default defineConfig({ export default defineConfig({
...@@ -47,6 +48,10 @@ export default defineConfig({ ...@@ -47,6 +48,10 @@ export default defineConfig({
path: ROUTE_PATHS.resourceBooking, path: ROUTE_PATHS.resourceBooking,
component: './project_manager_xy/resource-booking/index', component: './project_manager_xy/resource-booking/index',
}, },
{
path: ROUTE_PATHS.notifications,
component: './project_manager_xy/notifications/NotificationListPage',
},
], ],
}, },
], ],
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
- UI 原语与 compound component 在页面或业务中立共享组合中直接导入 HeroUI/HeroUI Pro。 - UI 原语与 compound component 在页面或业务中立共享组合中直接导入 HeroUI/HeroUI Pro。
- 共享组合只可封装重复布局、可访问性与当前 HeroUI value/selection/event 模型,不得复刻 Catalyst props、DOM event 或旧 CSS hook。 - 共享组合只可封装重复布局、可访问性与当前 HeroUI value/selection/event 模型,不得复刻 Catalyst props、DOM event 或旧 CSS hook。
- DataGrid 组合映射列定义并转发原生 selection/fixed-column/ActionBar/Pagination;分页容量继续保持 20/50/100,并使用 HeroUI Autocomplete compound API。 - DataGrid 组合映射列定义并转发原生 selection/fixed-column/ActionBar/Pagination;案例列表分页容量固定为 20,不展示容量选择器。
- 文件上传采用 HeroUI Pro DropZone 的 `File[]` 协议;页面边界继续只取既有单文件值。 - 文件上传采用 HeroUI Pro DropZone 的 `File[]` 协议;页面边界继续只取既有单文件值。
- 应用切换直接组合 HeroUI Modal/Card/Surface/Avatar 与 HeroUI Pro EmptyState;分组只读取现有 `SiteContext` 的 `/info -> site.group`,应用仍合并 `/application/info` 与当前用户权限数据,不引入硬编码分组或应用。 - 应用切换直接组合 HeroUI Modal/Card/Surface/Avatar 与 HeroUI Pro EmptyState;分组只读取现有 `SiteContext` 的 `/info -> site.group`,应用仍合并 `/application/info` 与当前用户权限数据,不引入硬编码分组或应用。
...@@ -25,3 +25,11 @@ ...@@ -25,3 +25,11 @@
## 业务边界 ## 业务边界
服务层继续负责 `risk_case_library` 的 `/record` 协议,所有业务字段仍写入 `metadata`。本次迁移不得改变路由、鉴权、权限、验证、分页、保存回调、请求时机、错误处理或业务状态流。 服务层继续负责 `risk_case_library` 的 `/record` 协议,所有业务字段仍写入 `metadata`。本次迁移不得改变路由、鉴权、权限、验证、分页、保存回调、请求时机、错误处理或业务状态流。
## 四项功能数据流
- `riskCaseViewService.js` 读取 `risk_case_library` Subject 及完整 Grid/Detail View。页面只消费 columns、visabled 与 shortTitle;不读取或执行 `viewAuth`、`auth`、`disableColumns`、角色、用户组或权限旁路。
- View 资源不可用时,案例查询照常执行,并回退到原案例字段集合;不会因为配置接口失败而只剩标题列,也不会改变 `POST /record/query` 的筛选和 `-updated_at -created_at` 排序。
- `notifications/notificationService.js` 封装平台 `/notice/customer/query`、`/count`、`/read` 与动态 action;站内目标剥离 `/risk_case_library` base,外部目标新窗口打开,通知 HTML 只作为文本展示。
- `RiskCaseMaterialField.jsx` 按运行时 `multiple` 决定单值替换或多值追加。材料中心只写文件快照;本地 `/upload` 先进入详情草稿,保存详情时才创建 `am_material`,再更新业务记录。
- 本地材料草稿按编辑会话隔离;取消不建档。创建成功标记保留在草稿中,后续业务保存失败重试时不重复创建材料主档。
...@@ -18,6 +18,15 @@ ...@@ -18,6 +18,15 @@
- [x] 弹窗:新建、查看/编辑、批量修改、导入、删除确认(代码结构) - [x] 弹窗:新建、查看/编辑、批量修改、导入、删除确认(代码结构)
- [x] 清除 Catalyst/Headless/ECharts/原生可见控件与页面私有视觉 CSS - [x] 清除 Catalyst/Headless/ECharts/原生可见控件与页面私有视觉 CSS
## 四项功能
- [x] Grid shortTitle 只隐藏 title 展示列,详情入口保留在操作列
- [x] Detail shortTitle 随激活 View 隐藏记录标题和 title 字段
- [x] `/notifications`、Navbar Badge、平台通知交互与 base-aware 跳转
- [x] 可编辑 file 字段的材料中心/本地上传、草稿、保存建档与失败重试去重
- [x] View/字段权限逻辑不在本次范围,活动实现扫描为零
- [x] Subject/View 加载失败时回退完整默认案例字段,业务查询不阻断
## 浏览器验收 ## 浏览器验收
- [x] Login 1440x900 light/dark - [x] Login 1440x900 light/dark
...@@ -26,7 +35,14 @@ ...@@ -26,7 +35,14 @@
- [x] Login 390x844 light/dark - [x] Login 390x844 light/dark
- [x] 应用切换 1440x900、1280x720、768x1024、390x844 light/dark:尺寸、两列/单列、动态分组、全部应用回退、唯一当前态、Body 滚动、Escape 与焦点回归 - [x] 应用切换 1440x900、1280x720、768x1024、390x844 light/dark:尺寸、两列/单列、动态分组、全部应用回退、唯一当前态、Body 滚动、Escape 与焦点回归
- [ ] 打开 Sidebar、用户退出菜单、应用切换真实动态分组、Dropdown/ListBox/DatePicker - [ ] 打开 Sidebar、用户退出菜单、应用切换真实动态分组、Dropdown/ListBox/DatePicker
- [ ] 验证 Tabs、筛选、Checkbox、DataGrid、Pagination、ActionBar、Modal、KPI、图表 Tooltip - [ ] 验证 KPI 动态状态与图表 Tooltip
- [x] 目标与问题整改在 1440x900、1280x720、768x1024、390x844 完成浅/深色成对截图,页面无 viewport 级横向溢出
- [x] 真实验证 Tabs、三项折叠筛选、Checkbox、DataGrid、Pagination、ActionBar、详情查看/编辑/取消和移动端通知空态
- [x] 目标与基准最终控制台 0 error / 0 warning
- [ ] 验证 shortTitle true/false、单/多值附件、材料选择、本地上传、取消、失败与重试
- [ ] 验证通知未读 0/1/99/100、单条/全部已读、动态操作与站内外跳转
未勾选原因:真实 View 接口返回空数组,Subject 没有 file 字段,通知接口返回 0 条;未伪造后台配置、通知或业务数据。
- [x] Login 控制台 0 runtime/slot/provider/accessibility 错误或警告 - [x] Login 控制台 0 runtime/slot/provider/accessibility 错误或警告
## 最终门槛 ## 最终门槛
......
...@@ -20,6 +20,17 @@ ...@@ -20,6 +20,17 @@
- 指标必须使用 HeroUI Pro KPI compound API;图表必须使用 Pro PieChart、BarChart、ComposedChart 与 ChartTooltipContent。 - 指标必须使用 HeroUI Pro KPI compound API;图表必须使用 Pro PieChart、BarChart、ComposedChart 与 ChartTooltipContent。
- 图表业务序列只使用 `--rc-chart-*` Token,不使用 UI 状态色或页面硬编码颜色。 - 图表业务序列只使用 `--rc-chart-*` Token,不使用 UI 状态色或页面硬编码颜色。
## 跨应用能力
风险案例库属于“警示案例库”,按完整功能应用范围实施本次四项能力:
- Grid View 的 `settings.shortTitle === true` 只隐藏 `title` 展示列;原始标题数据、查询、排序、筛选、详情和保存协议不变,详情入口保留在右侧操作列。
- 当前 Detail View 的 `settings.shortTitle === true` 同时隐藏记录标题和 `title` 字段;Modal 功能标题与可访问名称始终存在。
- 通知中心使用独立 `/notifications` 路由和平台原通知接口,Navbar 以 HeroUI `Badge.Anchor + Badge + Button + Tooltip` 提供入口。
- 运行时 Subject 中可编辑的 `file` 字段使用 HeroUI Pro DropZone 本地上传,或通过 HeroUI Autocomplete 选择 `am_material`;业务记录只保存 `{name,type,size,url}` 快照。
本次范围不包含 View 权限、字段权限、角色旁路或用户组判断。View 配置只决定列、字段顺序、条件可见性和 `shortTitle` 展示。
## 禁止项 ## 禁止项
- Catalyst、Headless UI、旧 UI 包装层、可见原生 select/date/file 控件。 - Catalyst、Headless UI、旧 UI 包装层、可见原生 select/date/file 控件。
......
# 鑫元 UI 审计 # 鑫元 UI 审计
审计日期:2026-08-26。目标仓库为 Umi 4 / React 19,部署基路径为 `/risk_case_library/`。整体实现基准为“问题整改”,应用切换专项基准为 `model_analysis` 当前 HeroUI 实现。 审计日期:2026-08-27。目标仓库为 Umi 4 / React 19,部署基路径为 `/risk_case_library/`。整体实现基准为“问题整改”,应用切换专项基准为 `model_analysis` 当前 HeroUI 实现。
## 安装与运行基线 ## 安装与运行基线
...@@ -31,8 +31,30 @@ ...@@ -31,8 +31,30 @@
| 旧组件 | Catalyst、Headless、ECharts、旧 Select/Date/Table 兼容层已移除 | 活动源码扫描为零 | | 旧组件 | Catalyst、Headless、ECharts、旧 Select/Date/Table 兼容层已移除 | 活动源码扫描为零 |
| 样式 | `theme.css` 匹配 Skill;`tokens.css`、`shell.css`、`tailwind.css`、`global.less` 匹配问题整改 | `portable.css` 与页面私有视觉 CSS 已移除 | | 样式 | `theme.css` 匹配 Skill;`tokens.css`、`shell.css`、`tailwind.css`、`global.less` 匹配问题整改 | `portable.css` 与页面私有视觉 CSS 已移除 |
## 四项功能审计
| 功能 | 目标实现 | 业务保护 |
| --- | --- | --- |
| Grid shortTitle | 完整 Grid View 的 `settings.shortTitle` 仅过滤 `title` 展示列;详情入口固定在操作列 | 不删除标题数据,不修改查询、排序、筛选、详情或保存 payload |
| Detail shortTitle | 随当前 Detail View 即时隐藏顶部记录标题和 `title` 字段 | Modal 标题和 aria-label 保留;Detail 切换不触发业务请求 |
| 消息通知 | 新增 `/notifications`;消息/待办、全部/未读、搜索、分页、单条/全部已读、动态 actions、站内外跳转;Navbar Bell 显示 1–99/99+ | 复用平台原通知接口;不执行通知 HTML;Sidebar 无通知入口 |
| 文件与材料中心 | 可编辑 file 字段按 multiple 支持材料中心/本地上传;最近 20 条与远程搜索;保存时建档 | 快照固定 `{name,type,size,url}`;`source_app=risk_case_library`;取消不建档,失败重试去重 |
View 配置审计确认本次没有实现权限功能:活动代码不读取 `viewAuth`、`settings.auth`、`disableColumns`、用户角色、用户组或 permissions。
## 风险与保护 ## 风险与保护
- 不改变 API URL、请求参数、响应映射、路由、权限、校验、请求时机、保存回调、登录退出或业务状态流。 - 除新增 `/notifications` 和材料中心明确请求外,不改变原 API URL、请求参数、响应映射、路由、校验、请求时机、保存回调、登录退出或业务状态流。
- 不在源码或浏览器中加入测试记录、伪造 token 或统计数字。 - 不在源码或浏览器中加入测试记录、伪造 token 或统计数字。
- 应用切换已使用现有真实登录会话完成浅/深色四视口验收;未注入 token、应用、分组或业务数据。其他模块的既有未验收项仍按迁移清单保留,不能据此标记全量应用迁移完成。 - 应用切换已使用现有真实登录会话完成浅/深色四视口验收;未注入 token、应用、分组或业务数据。其他模块的既有未验收项仍按迁移清单保留,不能据此标记全量应用迁移完成。
## 2026-08-27 浏览器验收
- 使用账号 Andy 经真实验证码登录目标应用和“问题整改”,未注入 token、通知、View、附件或测试记录。
- 在 1440x900、1280x720、768x1024、390x844 分别采集两应用浅色和深色截图。各视口目标页面 `body.scrollWidth === innerWidth`,宽表滚动限制在 DataGrid 内;桌面 Sidebar/Navbar 为 240px/64px,移动端 Main 与 Navbar 均占满视口。
- 对照确认两应用在相同视口下的 Sidebar、Navbar、Trigger、Bell、主题按钮、页面内容边界、Tabs、筛选 Surface 与工具栏层级一致。
- 目标真实验证:筛选默认三项与 Disclosure、卡片/表格 Tabs、DataGrid 固定操作列、Checkbox、ActionBar、固定 20 条 Pagination、详情 4xl Modal、查看态单一 secondary Surface、编辑 dirty、取消恢复且无 PUT、移动端通知空态、移动端 Sidebar、应用切换分组/计数/当前态。
- 目标与基准最终控制台均为 0 error、0 warning(忽略开发模式 React DevTools info)。
- 当前真实接口 `GET /subject/risk_case_library/view` 返回空数组,因此没有 shortTitle true/false 配置可触发;代码路径与配置失败完整字段回退已审计,但未把 Mock 当作浏览器通过。
- 当前 Subject 的 11 个字段均非 `file`,无法在真实页面触发单值/多值、材料选择、本地上传、取消、失败与重试。
- 当前通知列表和未读数均为 0,可验证空态与 Badge 隐藏;1/99/100、单条/全部已读、动态 actions 和站内外目标没有真实通知数据可操作。
import BankOutlined from '@ant-design/icons/BankOutlined'; import BankOutlined from '@ant-design/icons/BankOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined'; import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined'; import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined'; import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
...@@ -45,6 +46,10 @@ function iconStyle(size, style) { ...@@ -45,6 +46,10 @@ function iconStyle(size, style) {
return size === undefined ? style : { fontSize: size, ...style }; return size === undefined ? style : { fontSize: size, ...style };
} }
export const FIELD_SUFFIX_ICON_STYLE = Object.freeze({
color: 'var(--field-placeholder, var(--muted))',
});
function withSize(Icon) { function withSize(Icon) {
return function AppIcon({ size, style, ...props }) { return function AppIcon({ size, style, ...props }) {
return ( return (
...@@ -54,6 +59,7 @@ function withSize(Icon) { ...@@ -54,6 +59,7 @@ function withSize(Icon) {
} }
export const Bank = withSize(BankOutlined); export const Bank = withSize(BankOutlined);
export const Bell = withSize(BellOutlined);
export const Archive = withSize(InboxOutlined); export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined); export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined); export const BarChart = withSize(BarChartOutlined);
......
...@@ -9,6 +9,7 @@ export default function FileUploadField({ ...@@ -9,6 +9,7 @@ export default function FileUploadField({
description = '将文件拖到此处,或从本地选择。', description = '将文件拖到此处,或从本地选择。',
isDisabled = false, isDisabled = false,
fileName = '', fileName = '',
multiple = false,
onFilesChange, onFilesChange,
triggerLabel = '选择文件', triggerLabel = '选择文件',
}) { }) {
...@@ -29,9 +30,9 @@ export default function FileUploadField({ ...@@ -29,9 +30,9 @@ export default function FileUploadField({
if (fileItems.length === 0) return; if (fileItems.length === 0) return;
const files = await Promise.all(fileItems.map((item) => item.getFile())); 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 ( return (
...@@ -44,6 +45,7 @@ export default function FileUploadField({ ...@@ -44,6 +45,7 @@ export default function FileUploadField({
<DropZone.Input <DropZone.Input
accept={accept} accept={accept}
disabled={isDisabled} disabled={isDisabled}
multiple={multiple}
onSelect={emitFiles} onSelect={emitFiles}
/> />
<DropZone.Icon> <DropZone.Icon>
......
import {
ChevronDown,
Check,
Close,
FIELD_SUFFIX_ICON_STYLE,
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);
}
function getSelectedKeySet(selectionMode, selectedKey, selectedKeys) {
if (selectionMode === 'multiple') {
return new Set(selectedKeys || []);
}
return new Set(selectedKey === null || selectedKey === undefined ? [] : [selectedKey]);
}
const toSelectionKey = (value) => String(value ?? '');
export default function XinyuanAutocomplete({
'aria-label': ariaLabel,
className,
emptyText = '暂无可选项',
errorText = '',
filter,
fullWidth = true,
inputValue,
isDisabled = false,
isLoading = false,
isOpen,
isRequired = false,
name,
onInputChange,
onOpenChange,
onSelectionChange,
options = [],
placeholder = '请选择',
renderOption,
searchPlaceholder = '输入关键词搜索',
selectedKey,
selectedKeys,
selectionMode = 'single',
shouldFilter = true,
valueText,
variant = 'secondary',
}) {
const selectedKeySet = getSelectedKeySet(selectionMode, selectedKey, selectedKeys);
const selectedKeyStringSet = new Set(
Array.from(selectedKeySet).map(toSelectionKey),
);
const selectedOptions = options.filter((option) =>
selectedKeyStringSet.has(toSelectionKey(option.id)));
const resolvedValue = valueText ?? (selectedOptions.length > 0
? selectedOptions.map((option) => option.label).join('、')
: placeholder);
const rootSelectionProps = selectionMode === 'multiple'
? { selectedKeys }
: { selectedKey };
const listBoxSelectionProps = selectionMode === 'multiple'
? { selectionMode, selectedKeys }
: {};
const visibleOptions = isLoading || errorText ? [] : options;
const handleMultipleOptionPress = (option) => {
if (selectionMode !== 'multiple' || !onSelectionChange || option?.isDisabled) return;
const optionKey = toSelectionKey(option.id);
if (!optionKey) return;
const nextKeys = new Set(Array.from(selectedKeyStringSet).filter(Boolean));
if (nextKeys.has(optionKey)) nextKeys.delete(optionKey);
else nextKeys.add(optionKey);
onSelectionChange(nextKeys);
};
return (
<Autocomplete
{...rootSelectionProps}
aria-label={ariaLabel || placeholder}
className={className}
fullWidth={fullWidth}
isDisabled={isDisabled}
isOpen={isOpen}
isRequired={isRequired}
name={name}
selectionMode={selectionMode}
variant={variant}
onOpenChange={onOpenChange}
onSelectionChange={onSelectionChange}
>
<Autocomplete.Trigger>
<Autocomplete.Value>
<span className="block min-w-0 truncate" title={resolvedValue}>
{resolvedValue}
</span>
</Autocomplete.Value>
<Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</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}
{...listBoxSelectionProps}
renderEmptyState={() => (
<div className="grid min-h-20 place-items-center px-3 py-4">
{isLoading ? <Spinner size="sm" /> : errorText || emptyText}
</div>
)}
>
{(option) => {
const isSelected = selectedKeyStringSet.has(toSelectionKey(option.id));
return (
<ListBox.Item
id={option.id}
isDisabled={option.isDisabled}
textValue={option.textValue || option.label}
{...(selectionMode === 'multiple'
? { onPress: () => handleMultipleOptionPress(option) }
: {})}
>
{renderOption ? renderOption(option) : <Label>{option.label}</Label>}
{selectionMode === 'multiple' ? (
<span
aria-hidden="true"
className="ml-auto inline-flex w-5 justify-center text-foreground"
>
{isSelected ? <Check size={14} /> : null}
</span>
) : (
<ListBox.ItemIndicator />
)}
</ListBox.Item>
);
}}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
);
}
import BankOutlined from '@ant-design/icons/BankOutlined'; import BankOutlined from '@ant-design/icons/BankOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined'; import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined'; import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined'; import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
...@@ -54,6 +55,7 @@ function withSize(Icon) { ...@@ -54,6 +55,7 @@ function withSize(Icon) {
} }
export const Bank = withSize(BankOutlined); export const Bank = withSize(BankOutlined);
export const Bell = withSize(BellOutlined);
export const Archive = withSize(InboxOutlined); export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined); export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined); export const BarChart = withSize(BarChartOutlined);
......
...@@ -2,15 +2,19 @@ import xyLogoRed from '@/assets/identity/xy-logo-red.png'; ...@@ -2,15 +2,19 @@ import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png'; import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { APP_DISPLAY_NAME } from '@/constants/app'; import { APP_DISPLAY_NAME } from '@/constants/app';
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog'; 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 { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import { Navbar } from '@heroui-pro/react/navbar'; import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar'; import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react'; import { Avatar, Badge, Button, Dropdown, Label, Toast, Tooltip } from '@heroui/react';
import { useContext, useLayoutEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { Heading } from 'react-aria-components/Dialog'; import { Heading } from 'react-aria-components/Dialog';
import { history, Outlet, useLocation } from 'umi'; 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 APP_NAME = APP_DISPLAY_NAME;
const THEME_STORAGE_KEY = 'xinyuan_color_theme'; const THEME_STORAGE_KEY = 'xinyuan_color_theme';
...@@ -38,6 +42,7 @@ function getInitialTheme() { ...@@ -38,6 +42,7 @@ function getInitialTheme() {
} }
function getRouteTitle(pathname) { function getRouteTitle(pathname) {
if (pathname === '/notifications') return '通知消息';
for (const group of menuGroups) { for (const group of menuGroups) {
const activeItem = group.items.find((item) => item.path === pathname); const activeItem = group.items.find((item) => item.path === pathname);
if (activeItem) return activeItem.name; if (activeItem) return activeItem.name;
...@@ -216,6 +221,8 @@ function SidebarContents({ onOpenApplicationSwitcher, pathname, userInfo }) { ...@@ -216,6 +221,8 @@ function SidebarContents({ onOpenApplicationSwitcher, pathname, userInfo }) {
} }
function DashboardNavbar({ function DashboardNavbar({
notificationCount,
onOpenNotifications,
onThemeChange, onThemeChange,
theme, theme,
title, title,
...@@ -223,6 +230,7 @@ function DashboardNavbar({ ...@@ -223,6 +230,7 @@ function DashboardNavbar({
const { isMobile, isOpen } = useSidebar(); const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark'; const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式'; const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const unreadCount = Math.max(0, Number(notificationCount) || 0);
return ( return (
<Navbar className="app-navbar rc-navbar" maxWidth="full"> <Navbar className="app-navbar rc-navbar" maxWidth="full">
...@@ -234,6 +242,26 @@ function DashboardNavbar({ ...@@ -234,6 +242,26 @@ function DashboardNavbar({
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1> <h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer /> <Navbar.Spacer />
<div className="rc-navbar-actions"> <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}> <Tooltip delay={0}>
<Button <Button
isIconOnly isIconOnly
...@@ -259,6 +287,7 @@ export default function ProjectManagerLayout() { ...@@ -259,6 +287,7 @@ export default function ProjectManagerLayout() {
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false); const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [theme, setTheme] = useState(getInitialTheme); const [theme, setTheme] = useState(getInitialTheme);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const userInfo = useContext(UserContext); const userInfo = useContext(UserContext);
const siteInfo = useContext(SiteContext); const siteInfo = useContext(SiteContext);
...@@ -280,6 +309,26 @@ export default function ProjectManagerLayout() { ...@@ -280,6 +309,26 @@ export default function ProjectManagerLayout() {
document.title = APP_NAME; 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( const routeTitle = useMemo(
() => getRouteTitle(location.pathname), () => getRouteTitle(location.pathname),
[location.pathname], [location.pathname],
...@@ -300,6 +349,8 @@ export default function ProjectManagerLayout() { ...@@ -300,6 +349,8 @@ export default function ProjectManagerLayout() {
/> />
<Sidebar.Main className="app-main-glass rc-app-main"> <Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar <DashboardNavbar
notificationCount={unreadNotificationCount}
onOpenNotifications={() => history.push('/notifications')}
theme={theme} theme={theme}
title={routeTitle} title={routeTitle}
onThemeChange={setTheme} onThemeChange={setTheme}
...@@ -318,6 +369,7 @@ export default function ProjectManagerLayout() { ...@@ -318,6 +369,7 @@ export default function ProjectManagerLayout() {
userInfo={userInfo} userInfo={userInfo}
siteInfo={siteInfo} siteInfo={siteInfo}
/> />
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider> </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>;
}
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
当前阶段的核心原则是: 当前阶段的核心原则是:
- 每条案例对应一条独立 `Record` - 每条案例对应一条独立 `Record`
- 案例详情以文本为主,不额外拆分附件对象 - 案例详情以文本为主;若运行时 Subject/View 配置出现可编辑 `file` 字段,则按统一文件快照协议接入审计材料中心
- 页面优先保证“看起来直观、方便查” - 页面优先保证“看起来直观、方便查”
--- ---
...@@ -341,5 +341,36 @@ ...@@ -341,5 +341,36 @@
1. `risk_case_library` 1. `risk_case_library`
当前不拆分独立关键字表、案例性质表或附件表。 当前不拆分独立关键字表、案例性质表或案例专属附件表。运行时 `file` 字段可引用共享 `am_material`,但风险案例记录只保存文件快照,不保存材料 ID。
案例性质先通过文本字段实现,关键字通过多值文本数组实现,后续如有需要再演进。 案例性质先通过文本字段实现,关键字通过多值文本数组实现,后续如有需要再演进。
---
## 11. Subject View 展示规则
1. 页面读取 `risk_case_library` 的 Grid 与 Detail View 完整配置。
2. Grid `settings.shortTitle === true` 时只隐藏根字段 `title` 的展示列;标题数据仍参与原有业务协议,查看入口统一保留在右侧操作列。
3. 当前 Detail View `settings.shortTitle === true` 时,隐藏弹窗顶部记录标题和字段列表中的根 `title` 字段;Modal 功能标题与可访问名称保持不变。
4. 切换 Grid View 只切换列展示,切换 Detail View 只切换字段展示;两者都不修改案例查询、排序、筛选或保存协议。
5. Subject/View 加载失败时继续查询案例,并回退展示原案例字段集合。
6. 本模块不根据 View 配置执行可见权限或字段编辑权限,不读取用户角色、用户组或其他应用授权。
## 12. 消息通知流程
1. Navbar 主题按钮左侧的 Bell 打开独立 `/notifications` 页面;Sidebar 不提供通知入口。
2. 页面通过平台通知接口查询消息/待办,支持全部/未读、300ms 搜索与固定 10 条分页。
3. 单条已读调用平台已读接口;全部已读调用同一接口的空 name 协议;操作成功后刷新列表和 Navbar 数量。
4. 待办 actions 使用通知原配置调用动态 action 接口,不构造自定义动作。
5. 关联地址带 `/risk_case_library` 前缀时去除自身 base 后站内跳转;外部地址新窗口打开。
6. 通知内容始终按纯文本渲染,不执行其中的 HTML。
7. 未读数为 0 时隐藏 Badge,1–99 显示真实值,100 及以上显示 `99+`。
## 13. 文件与审计材料中心流程
1. 仅运行时 Detail View 中可编辑的 `file` 字段显示“材料中心 / 本地上传”,并按字段 `multiple` 配置决定单值或多值。
2. 材料中心查询 `am_material` 最近 20 条,搜索时远程匹配材料标题和文件名。
3. 选中材料后只向案例草稿写入 `{name,type,size,url}`;不保存材料 ID。单值替换,多值按 URL 去重追加。
4. 本地文件先调用 `/upload`,成功后只进入当前详情编辑会话的草稿。
5. 用户保存详情时,前端先为仍保留在草稿中的本地文件创建 `am_material`,`source_app` 固定使用 `risk_case_library`;全部成功后才更新风险案例记录。
6. 取消编辑或关闭详情不创建材料主档。材料建档成功但案例保存失败时保留建档标记,重试不重复建档。
7. 清空或替换案例附件只更新案例记录,不删除共享材料中心主档。
...@@ -731,6 +731,7 @@ export function buildCasePayload(values = {}) { ...@@ -731,6 +731,7 @@ export function buildCasePayload(values = {}) {
content_type: 'text', content_type: 'text',
content: caseDetails, content: caseDetails,
metadata: { metadata: {
...(values.metadataDraft || {}),
label, label,
category, category,
tag, 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 { APP_ROUTE_NAME } from '@/constants/app';
export const NOTIFICATION_PAGE_SIZE = 10;
export const NOTIFICATION_STATE_EVENT = 'risk-case-library:notification-state';
const APPLICATION_PATH_PREFIX = `/${APP_ROUTE_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 { APP_ROUTE_NAME } from '@/constants/app';
import { createRecord, queryRecords, uploadFile } from '@/services/DataService';
export const AUDIT_MATERIAL_SUBJECT = 'am_material';
export const RISK_CASE_SOURCE_APP = APP_ROUTE_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 { toArray } from '@/utils/subjectView';
import { CASE_SUBJECT } from './materialService';
export async function loadRiskCaseViewResources() {
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'),
detailViews: views.filter((view) => view?.type === 'detail'),
};
}
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];
}
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,
};
});
}
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