Commit 9d6dea24 authored by Andy-bubu's avatar Andy-bubu

feat: align cross-app permissions and workflows

parent 473e68e6
......@@ -12,6 +12,7 @@ const ROUTE_PATHS = {
measureDescription: '/measure_description',
archive: '/integrity_risk_control_archive',
controlMeasureForm: '/control_measure_form',
notifications: '/notifications',
} as const;
const APP_BASE_PATH = '/integrity_risk_prevention_new/';
......@@ -61,6 +62,10 @@ export default defineConfig({
path: ROUTE_PATHS.archive,
component: './integrity_risk_prevention/IntegrityRiskArchivePage',
},
{
path: ROUTE_PATHS.notifications,
component: './integrity_risk_prevention/notifications/NotificationListPage',
},
],
},
],
......
......@@ -34,4 +34,13 @@
## 验收证据
浏览器截图、控制台检查、构建、依赖树和扫描结果已写入 `docs/xinyuan-ui-audit.md`。本轮目标服务运行于 `http://localhost:8000/integrity_risk_prevention_new/`,应用切换专项基准运行于 `http://localhost:8004/model_analysis/`。业务 API、参数、数据结构、路由、权限、校验、分页口径、保存回调、登录退出和请求时机均未改动;变更集中在 HeroUI 组件结构、动态分组编排和共享布局几何。
浏览器截图、控制台检查、构建、依赖树和扫描结果已写入 `docs/xinyuan-ui-audit.md`。本轮目标服务运行于 `http://localhost:8002/integrity_risk_prevention_new/`;问题整改及专项基准的对照证据记录在审计文档中。业务 API、参数、数据结构、原路由、原权限、校验、分页口径、保存回调、登录退出和请求时机均保持不变;五项跨应用能力仅增加合同明确授权的路由、请求与状态。
## Subject View、通知与材料状态所有权
- `SubjectWorkbenchService` 先读取 Subject 和完整 View 详情,使用目标应用单一授权项过滤 Grid View,只有存在可访问 View 时才发起记录查询。页面只消费授权后的 View,不制造普通用户兜底。
- `subjectView.js` 负责严格 `shortTitle`、标题搜索列和应用内 groups/roles/permissions 匹配;Detail 显示列与字段授权分离,`RecordDetailDrawer` 按当前 Detail 或完整回退 Grid View 选择一套权限来源。
- `APPLICATION_NAME` 是路由 base、应用切换当前态和 Subject View 授权的单一身份源。`useSubjectRecord` 展开每个 View 摘要为完整配置;`RecordDetailDrawer` 先按当前 Detail 是否存在有效字段权限选择整套 Detail 或活动 Grid,不合并权限、不逐字段回退。
- `SubjectRecordWorkbench` 在 View 尚未加载时只渲染 HeroUI Spinner,在授权结果没有 Grid View 时终止数据区编排并直接渲染 HeroUI Pro `EmptyState`;`buildGridViews` 永不制造兜底 View。`XinyuanAutocomplete` 只在收到显式布尔 `isOpen` 时采用受控打开态,其余打开、焦点和 Portal 状态由 HeroUI 管理。
- 通知 service 复用 DataService 的平台接口,Shell 只持有未读数量和 60 秒刷新;通知页面持有分类、未读筛选、搜索、分页和操作状态。链接解析仅剥离当前应用 basePath。
- `IntegrityRiskMaterialField` 持有材料搜索与上传草稿,`RecordDetailDrawer` 持有编辑会话内待建档标记。所有待建档文件成功创建材料主档后,原 `handleFieldsUpdate` 才按既有 payload 更新业务记录;失败不会清空草稿或成功标记。
......@@ -30,5 +30,16 @@
- [x] 浅色与深色四视口浏览器验收完成,截图和控制台结果已记录在 `docs/xinyuan-ui-audit.md`。
- [x] `pnpm run build`、`git diff --check`、Skill audit 已执行;audit 的 pnpm store/CJS 子路径警告已人工复核为工具误报。
- [x] Umi watcher 已排除浏览器验收/构建产物;开发态 `umi.css`/Inter 字体 HMR 循环已复测并记录。
- [x] 完整 View 详情已加载,Grid `shortTitle` 保留标题搜索语义,Detail `shortTitle` 跟随当前详情 View。
- [x] Subject View 授权只读取 `integrity_risk_prevention_new`,无权限时不查询记录且隐藏新增、批量、选择和行操作。
- [x] 新增独立 `/notifications` 页面和 Navbar 原生 Badge 通知入口,复用平台通知、已读、动态 action 与跳转协议。
- [x] 可编辑 file 字段已接入材料中心/本地上传,使用目标 `source_app`、文件快照和保存时延迟建档去重流程。
- [x] View 加载阶段只显示 HeroUI Spinner;无 Grid View 权限直接展示 HeroUI Pro EmptyState,不建立兜底 View、不查询业务记录且隐藏所有数据操作入口。
- [x] Autocomplete 未传入 `isOpen` 时保持非受控,避免 Trigger 被 `undefined` 锁死;受控打开态仍按调用方布尔值工作。
- [x] 使用 Playwright fixture 验证四视口浅/深色的权限矩阵、通知数量边界、材料选择/本地上传、单值替换、多值 Tag 独立删除、取消和失败重试去重;后端恢复后已补做真实登录、列表/View、通知空态和应用切换只读验收。
- [x] 通知事务覆盖搜索正则转义、未读、分页、单条/全部已读、动态 action 与站内外跳转;材料事务验证业务保存失败后草稿保留且重试不重复创建材料主档。
- [x] 对齐问题整改 `c047ec4`:稳定 `APPLICATION_NAME` 统一路由、应用切换与授权归属,顶层及其他应用“管理”角色不能越权。
- [x] 对齐问题整改 `6763c26`:详情加载完整 View,Detail 字段权限整套优先、空配置整套回退 Grid、`disableColumns` 不可旁路且切换 Detail 立即重算。
- [x] Playwright 权限矩阵覆盖其他应用管理、顶层管理、系统账号、本应用管理、管理员 `disableColumns`、Detail 优先、Grid 正反向回退、不逐字段合并和多 Detail 切换;控制台为零。
剩余说明:业务没有活动图表路由,因此没有凭空添加 KPI/图表数据;`recharts` 仅保留原依赖且无源码 import。应用切换遵循 `model_analysis` 专项基准和鑫元规范,使用动态分组、移动一列及 `sm` 起固定两列。
剩余说明:业务没有活动图表路由,因此没有凭空添加 KPI/图表数据;`recharts` 仅保留原依赖且无源码 import。应用切换遵循 `model_analysis` 专项基准和鑫元规范,使用动态分组、移动一列及 `sm` 起固定两列。当前真实 Detail View 没有 editable file 字段,未为验收修改后端 View 配置;文件事务由浏览器 fixture 验收。
......@@ -17,6 +17,7 @@
- 列表使用 HeroUI Pro DataGrid、ActionBar、EmptyState 和 HeroUI Pagination。
- 筛选使用 HeroUI Surface、SearchField、Autocomplete、DatePicker 和 Disclosure。
- 受控 Autocomplete 的已选值必须可逆操作:单选使用官方 `Autocomplete.ClearButton` 清除当前值;多选在 Trigger 内使用官方 `TagGroup + Tag + Tag.RemoveButton` 将每个值显示为独立 Tag 并支持单项删除,同时保留官方清除入口清空集合;下拉中的官方 `ListBox.Item` 再次点选也可移除单项。Tag、清除按钮和选中态都从同一受控 `selectedKeys` 派生,不得合并为逗号文本、使用页面自制标签或维护第二套选择状态。
- `isOpen` 只在调用方明确提供布尔值时作为受控属性传给 Autocomplete;未提供时必须让 HeroUI 管理打开态,禁止用 `isOpen={undefined}` 锁死 Trigger。
- KPI 使用 HeroUI Pro KPI;图表使用 HeroUI Pro 图表与 ChartTooltip。
- Modal/Dropdown/Popover/Tooltip 等 Portal 必须继承根级深色主题。
- Modal 面板保持 HeroUI Overlay 的 24px 圆角;弹窗内结构性 Surface 只声明官方 `secondary/tertiary` variant 和布局间距,不再通过业务 JSX 覆盖圆角、背景、描边或阴影。详情中的关联记录属于 L2 详情 Surface 内的静态 L3 内容,使用官方 `Surface variant="tertiary"` 承载,并在其内部使用共享 `DataTable` 的 HeroUI Pro DataGrid `secondary` variant;表头保持 `surface-secondary`、表体保持透明并继承 L3 Surface。禁止页面自定义颜色、描边、阴影或 HeroUI 内部 BEM/slot 外观。
......@@ -46,3 +47,11 @@
- Sidebar 使用 Pro `collapsible="offcanvas"`,折叠后宽度为零。
- 登录页使用居中认证结构,桌面卡片约 28rem,字段组间真实间距 24px。
- Umi 开发 watcher 必须忽略 `.playwright-cli`、`docs`、`dist`、`artifacts` 与 `node_modules` 运行产物,避免验收日志触发 `umi.css` HMR 循环和 Inter 字体反复卸载/回退。
## 跨应用五项能力
- 廉洁风险防控以 `APPLICATION_NAME = integrity_risk_prevention_new` 作为路由 base、应用切换和 Subject View 授权的唯一稳定标识;授权只读取同名 `user.applications[]` 项,禁止合并顶层授权或其他应用项。无可访问 Grid View 时不查询业务记录,不渲染新增、批量、选择或行操作。
- Grid `viewAuth` 只控制 Tab 可见性。详情必须加载完整 Detail View;Detail 有任一有效 `auth`/`disableColumns` 时整套优先,两项都没有或不存在实际 Detail 时整套回退活动 Grid。两套权限不得合并或逐字段回退,`disableColumns` 对管理员同样强制只读,编辑按钮只由当前 Detail 的展示字段计算。
- `settings.shortTitle` 只接受严格布尔值 `true`。Grid 隐藏的标题仍参与关键词搜索;Detail 按当前激活 View 同步隐藏记录标题和 title 字段,但始终保留 Modal 功能标题及可访问名称。
- 通知入口固定使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,36px Ant Bell 位于主题按钮左侧;通知页面沿用平台协议并只渲染纯文本。
- 可编辑 file 字段使用 HeroUI Tabs、Autocomplete 和 HeroUI Pro DropZone。材料选择只保存文件快照;本地上传在详情保存时以 `source_app = integrity_risk_prevention` 延迟创建材料主档,并保留失败重试去重标记。
......@@ -117,3 +117,44 @@
- 根因:Umi watcher 未排除 `.playwright-cli`,浏览器控制台/截图日志持续写入时触发 CSS 重新编译;20 秒观测到 `umi.css` 重载 10 次,并伴随每次 3 份 Inter 字体请求。
- 修复:`.umirc.ts` 的 `chainWebpack` 增加 `config.watchOptions({ ignored: ['**/artifacts/**', '**/.playwright-cli/**', '**/docs/**', '**/dist/**', '**/node_modules/**'] })`,与“问题整改”基准一致。该配置只影响开发 watcher,不改变生产构建、路由或业务状态。
- 验收标准:重启开发服务后,在无源码变更的 20 秒浏览器观测窗口内 `umi.css` 与 `.woff2` 请求均为 0 次新增,`document.fonts.status` 保持 `loaded`,`document.fonts.check('14px Inter')` 为 `true`。
### 权限空态与选择器状态专项复核(2026-08-27)
- `SubjectRecordWorkbench` 在 View 资源加载期间只渲染 HeroUI `Spinner`,无可访问 Grid View 时直接渲染 HeroUI Pro `EmptyState`;`buildGridViews` 不再为零配置或未完成加载制造“全部记录”兜底。无权限时不发起记录查询,不显示 Tabs、搜索、分页、新增、批量、选择或行操作。已有可访问 View 的查询和业务回调保持原协议。
- `XinyuanAutocomplete` 仅在调用方明确传入布尔 `isOpen` 时使用受控打开态;未传入时交由 HeroUI Autocomplete 自身管理,避免 `undefined` 属性锁死 Trigger。选中值、清除、Tag 删除和远程搜索协议不变。
- 本轮使用新的 Playwright 会话复核四视口浅/深色 Shell、应用切换、详情/编辑弹窗、通知页和材料字段;截图与控制台结果沿用 `/tmp/qa-target-*` 及专项截图目录。浏览器临时 route fixture 仅用于权限、通知边界和材料交互验证,不写入源码或后端业务数据。
- 本轮早期真实后端曾连接超时;最终复核时 `http://localhost:8002/api/info` 与 `https://xy.streams7.com/info` 已恢复为 HTTP 200,并已补做 Andy 真实认证会话的只读验收。失败重试等破坏性边界仍仅用浏览器 route fixture 验证,不写入后端业务数据。
### 五项能力浏览器事务验收(2026-08-27)
- 通知 Badge fixture:未读 `0 / 1 / 99 / 100` 分别得到隐藏、`1`、`99`、`99+`;Badge 的 `aria-label` 在 100 时仍为“100条未读通知”。
- 通知页 fixture:消息/待办、全部/未读、`流程.*` 搜索转义、第二页、单条已读、全部已读、动态 `approval/approve` action 均命中原平台接口;站内链接解析为 `/integrity_risk_prevention_new/risk_prevention_control_measures_table` 且不打开新窗口,外部 `https://example.com/workflow` 在新窗口打开,原页面保持通知路由。通知内容只以纯文本渲染。
- 通知页四视口浅/深色截图为 `/tmp/integrity-five-features-notifications-{1440x900,1280x720,768x1024,390x844}-{light,dark}.png`;八组均满足 `documentElement.scrollWidth === clientWidth`,正常路径控制台无 error/warning。
- 无权限 fixture:普通用户的目标应用授权不含 View 要求的 permission 时,`/record/query` 请求数为 0,Grid、Tabs、搜索、新增入口均为 0,仅渲染一个 HeroUI Pro `EmptyState`。截图为 `/tmp/integrity-permission-empty-state-{1440x900,390x844}-light.png`。
- 材料 fixture:材料中心 Trigger 可打开,`制度.*` 远程搜索请求发送转义后的 `制度\\.\\*`;选中后业务保存 payload 只包含 `{name,type,size,url}` 快照。多值已选附件独立显示并可单项删除。
- 本地上传事务 fixture:首次业务更新故意返回 HTTP 500 后,编辑草稿仍可见,材料主档创建计数为 1;重试成功时材料创建仍为 1、业务更新为 2,证明不会重复建档。第二次上传后取消,材料创建和业务更新计数均未增加,取消草稿已清除。材料 Subject 为 `am_material`,`source_app` 为 `integrity_risk_prevention`。故意失败阶段控制台出现的 3 条 error 均对应预期的 HTTP 500;正常路径没有额外控制台错误。
### 真实后端只读复核(2026-08-27)
- 使用用户提供的 Andy 账号完成真实登录;认证状态只在 Playwright 临时上下文中使用,未写入仓库或环境文件。干净会话最终控制台为 0 error / 0 warning。
- 真实过程表加载 12 条可见行和两个可访问 Grid View;当前 View 的 `shortTitle=true` 最终渲染时标题列计数为 0,新建入口按管理员授权正常显示。
- 真实通知接口返回当前账号未读 0、列表为空;Navbar 不显示 Badge,独立 `/notifications` 页面显示 HeroUI Pro EmptyState,无加载错误。
- 真实应用切换弹窗计算宽度 736px、网格列为 `334px 334px`、间距 12px,当前授权数据按“演示”分组显示;截图为 `/tmp/integrity-real-app-switcher-1440x900-light.png`。
- 当前真实 Detail View 配置没有 editable file 字段,因此不会出现材料中心入口;未为验收修改 Subject/View 配置。材料查询、选择、本地上传、取消、失败与重试均由同一应用运行时的浏览器 fixture 覆盖。
### 问题整改权限提交对齐(2026-08-27)
- 直接审阅并对齐“问题整改”提交 `c047ec4`(当前应用权限归属)和 `6763c26`(Detail 字段权限优先级),没有仅依据总结重写逻辑。
- `src/config/application.js` 现在以 `APPLICATION_NAME = 'integrity_risk_prevention_new'` 为唯一稳定标识,`APPLICATION_BASE_PATH` 与 `AUTHORIZATION_APPLICATION_NAME` 直接派生;应用切换也直接使用 `APPLICATION_NAME`,删除可独立漂移的 route-name 别名。
- `subjectView.js` 只读取 `user.applications[]` 中名称为 `integrity_risk_prevention_new` 的一项,不读取用户顶层 roles/groups/permissions,也不合并其他应用授权。系统管理员、系统账号和本应用“管理”角色旁路普通授权,但 `disableColumns` 始终先判定并保持只读。
- 详情加载通过 `GET /subject/:subjectName/view/:viewName` 获取完整 View;当前 Detail 存在任一有效 `auth` 或 `disableColumns` 时整套使用 Detail 权限,两项均无有效配置或没有实际 Detail 时整套回退活动 Grid。Detail 与 Grid 不合并,也不逐字段回退;`hasEditableFields` 只检查当前激活 Detail 的展示字段。
- Playwright 权限矩阵结果:其他应用“管理”和顶层“管理”均为记录查询 0、Grid 0;系统账号与本应用“管理”均可进入 Grid;管理员遇到 Detail `disableColumns` 时编辑按钮为 0;Detail 允许/Grid 拒绝时编辑按钮与输入框均为 1;空 Detail 回退到拒绝 Grid 时为 0、回退到允许 Grid 时为 1;Detail 只配置其他字段 auth 时当前字段不会回退 Grid;切换可编辑/只读 Detail 后编辑按钮从 1 立即变为 0。矩阵控制台 0 error / 0 warning。
# 五项跨应用能力迁移前审计(2026-08-26)
- 应用范围:统一展示名为“廉洁风险防控”,属于完整功能白名单,需实施 Grid/Detail `shortTitle`、Subject View 权限、通知中心和审计材料中心文件流程。
- 应用标识与路由:授权名和路由名均应使用 `integrity_risk_prevention_new`,base/publicPath 为 `/integrity_risk_prevention_new/`;禁止读取 `integrity_risk_manager`、`problem_rectification_new` 或其他应用授权。
- Subject View:现有 `subjectView.js` 会合并顶层及全部 `user.applications[]` 的角色/权限,缺失规则默认允许;`SubjectWorkbenchService` 在校验 Grid View 权限前并行查询全部记录,并在无可访问视图时生成“全部记录”兜底;`RecordDetailDrawer` 还会以 `viewAuth` 二次过滤 Detail View。以上均不符合当前授权合同。
- `shortTitle`:现有 `buildViewColumns` 未按严格布尔值隐藏 `title`,详情 Header 和字段区也未跟随当前激活 Detail View 隐藏记录标题;关键词搜索目前仅使用可见列,隐藏标题后会丢失标题检索语义。
- 通知:目标 DataService 已保留平台 `queryUserNotice`、`readUserNotice`、`unReadNoticeCount` 和动态 action 接口,但目标应用尚无 `/notifications` 路由、通知页面/service 或 Navbar Badge;Sidebar 当前没有通知入口,符合要求。
- 文件字段:运行时 Subject 协议支持 `file` 单值对象和 `multiple=true` 数组;现有详情仅直接上传业务附件。目标详情曾残留问题整改材料服务引用,必须改为廉洁风险防控自己的 `source_app`,并保持 `{name,type,size,url}` 快照协议、延迟建档和重试去重语义。
- UI 边界:活动业务源码使用直接 HeroUI/HeroUI Pro 组合;DataGrid、Modal、DropZone、Autocomplete、Badge/Tooltip 的新增实现继续保持原生 compound API,不引入 Catalyst、Headless UI、旧适配器或页面私有皮肤。
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';
......@@ -61,6 +62,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);
......
......@@ -22,6 +22,8 @@ export default function AppShell({
children,
open,
onOpenChange,
notificationCount,
notificationPath,
title,
...sidebarProps
}) {
......@@ -52,6 +54,8 @@ export default function AppShell({
<DashboardSidebar {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined}
theme={theme}
title={title}
onThemeChange={setTheme}
......
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react';
import { Moon, SidebarPanel, Sun } from './AppIcons';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, Moon, SidebarPanel, Sun } from './AppIcons';
export default function DashboardNavbar({ onThemeChange, theme, title }) {
export default function DashboardNavbar({
notificationCount = 0,
onOpenNotifications,
onThemeChange,
theme,
title,
}) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0);
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
......@@ -20,6 +27,32 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) {
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
{onOpenNotifications ? (
<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>
{normalizedNotificationCount > 0 ? (
<Badge
aria-label={`${normalizedNotificationCount}条未读通知`}
color="danger"
size="sm"
>
{normalizedNotificationCount > 99 ? '99+' : normalizedNotificationCount}
</Badge>
) : null}
</Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
) : null}
<Tooltip delay={0}>
<Button
isIconOnly
......
......@@ -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>
......@@ -51,7 +53,9 @@ export default function FileUploadField({
</DropZone.Icon>
<DropZone.Label>{fileName || '将文件拖到此处'}</DropZone.Label>
<DropZone.Description>
{fileName ? '已选择文件,可重新选择进行替换。' : description}
{fileName
? `已选择${multiple ? '文件,可继续添加。' : '文件,可重新选择进行替换。'}`
: description}
</DropZone.Description>
<DropZone.Trigger isDisabled={isDisabled}>
<Upload aria-hidden="true" />
......
import { Button } from '@heroui/react';
import { Button, Spinner } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import RecordWorkbench from '@/components/record-workbench';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import XinyuanModal from '@/components/XinyuanModal';
......@@ -194,6 +195,7 @@ export default function SubjectRecordWorkbench({
() => gridViews.find((view) => view.name === activeViewName) || gridViews[0] || null,
[gridViews, activeViewName],
);
const hasAccessibleGridView = Boolean(activeView);
const configuredView = useMemo(
() => buildConfiguredWorkbenchView(records, filters, activeView, subject),
[records, filters, activeView, subject],
......@@ -358,8 +360,8 @@ export default function SubjectRecordWorkbench({
];
const builtToolbarActions = [
...toolbarActions.map(wrapAction),
...(allowCreate && !readonly
...(hasAccessibleGridView ? toolbarActions.map(wrapAction) : []),
...(hasAccessibleGridView && allowCreate && !readonly
? [{
key: 'create',
label: '新建记录',
......@@ -375,8 +377,8 @@ export default function SubjectRecordWorkbench({
];
const builtBulkActions = [
...bulkActions.map(wrapAction),
...(allowDelete && !readonly
...(hasAccessibleGridView ? bulkActions.map(wrapAction) : []),
...(hasAccessibleGridView && allowDelete && !readonly
? [{
key: 'bulk-delete',
label: '删除所选',
......@@ -393,7 +395,7 @@ export default function SubjectRecordWorkbench({
: []),
];
const builtRowActions = [
const builtRowActions = hasAccessibleGridView ? [
{
key: 'view-detail',
label: '查看详情',
......@@ -432,7 +434,30 @@ export default function SubjectRecordWorkbench({
onClick: ({ record }) => setDeleteTargets([record.raw || record]),
}]
: []),
];
] : [];
if (loading && !hasAccessibleGridView) {
return (
<div className="flex min-h-64 items-center justify-center" role="status" aria-label={`正在加载${title}`}>
<Spinner size="sm" />
</div>
);
}
if (!loading && !error && !hasAccessibleGridView) {
return (
<div className="flex min-h-64 items-center justify-center">
<EmptyState size="lg">
<EmptyState.Header>
<EmptyState.Title>暂无可访问视图</EmptyState.Title>
<EmptyState.Description>
当前账号没有该对象的 Grid View 查看权限。
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</div>
);
}
return (
<>
......@@ -464,6 +489,7 @@ export default function SubjectRecordWorkbench({
records={pageRecords}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
selectable={hasAccessibleGridView}
toolbarActions={builtToolbarActions}
bulkActions={builtBulkActions}
rowActions={builtRowActions}
......@@ -478,6 +504,7 @@ export default function SubjectRecordWorkbench({
open={open}
recordName={recordName}
subjectName={subjectName}
authorizationView={activeView}
onClose={onClose}
onDataChange={onDataChange}
preferredViewName={activeView?.title || activeView?.name}
......@@ -490,8 +517,10 @@ export default function SubjectRecordWorkbench({
success,
loadingTitle: `正在加载${title}`,
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: '当前视图暂无数据',
emptyDescription: emptyDescription || '可以切换其他视图或调整筛选条件。',
emptyTitle: hasAccessibleGridView ? '当前视图暂无数据' : '暂无可访问视图',
emptyDescription: hasAccessibleGridView
? emptyDescription || '可以切换其他视图或调整筛选条件。'
: '当前账号没有该对象的 Grid View 查看权限。',
}}
pagination={{
page: safePage,
......
......@@ -92,6 +92,7 @@ export default function XinyuanAutocomplete({
selectionMode === 'multiple'
? { selectedKeys }
: { selectedKey };
const rootOpenProps = typeof isOpen === 'boolean' ? { isOpen } : {};
const activeFilter = shouldFilter ? filter || defaultFilter : null;
const visibleOptions = isLoading || errorText
? []
......@@ -139,12 +140,12 @@ export default function XinyuanAutocomplete({
return (
<Autocomplete
{...rootOpenProps}
{...rootSelectionProps}
aria-label={ariaLabel || placeholder}
className={className}
fullWidth={fullWidth}
isDisabled={isDisabled}
isOpen={isOpen}
isRequired={isRequired}
name={name}
selectionMode={selectionMode}
......
export const APPLICATION_DISPLAY_NAME = '廉洁风险防控';
export const APPLICATION_BASE_PATH = '/integrity_risk_prevention_new/';
export const APPLICATION_ROUTE_NAME = 'integrity_risk_prevention_new';
export const APPLICATION_NAME = 'integrity_risk_prevention_new';
export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`;
export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME;
export const AUDIT_MATERIAL_SOURCE_APP = 'integrity_risk_prevention';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({
......@@ -9,4 +11,5 @@ export const ROUTE_TITLES = Object.freeze({
'/measure_description': '廉洁风险防控措施数据库',
'/integrity_risk_control_archive': '往期材料归档',
'/control_measure_form': '廉洁风险防控填报',
'/notifications': '通知消息',
});
......@@ -31,6 +31,7 @@ export default function SubjectViewContent({
editSessionId,
mode = 'view',
onFieldChange,
onPendingLocalMaterialChange,
onFieldValidation,
readonly,
isFieldDisabled,
......@@ -63,9 +64,9 @@ export default function SubjectViewContent({
const fieldType = field.fieldDef?.type || 'text';
const fieldLabel = getFieldDisplayLabel(field.fieldDef, field.field);
const isFullWidth = FULL_WIDTH_FIELD_TYPES.has(fieldType);
const fieldDisabled =
isFieldDisabled?.(field.field, view) ||
isFieldDisabledByView(field.field, view);
const fieldDisabled = isFieldDisabled
? isFieldDisabled(field.field, view)
: isFieldDisabledByView(field.field, view);
return (
<div
......@@ -97,6 +98,7 @@ export default function SubjectViewContent({
)
}
onValidationChange={onFieldValidation}
onPendingLocalMaterialChange={onPendingLocalMaterialChange}
readonly={readonly}
disabled={fieldDisabled}
/>
......
......@@ -15,6 +15,7 @@ import FileUploadField from '@/components/FileUploadField';
import { queryUsers, uploadFile } from '@/services/DataService';
import ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import IntegrityRiskMaterialField from '@/pages/integrity_risk_prevention/IntegrityRiskMaterialField';
import ReferenceRecordsTable from './ReferenceRecordsTable';
import {
dateInputValue,
......@@ -305,6 +306,7 @@ function ControlledFieldEditor({
fieldVariant,
placeholder,
onChange,
onPendingLocalMaterialChange,
onValidationChange,
}) {
const [error, setError] = useState('');
......@@ -391,44 +393,15 @@ function ControlledFieldEditor({
if (fieldType === 'file') {
return (
<Card variant="secondary">
<Card.Content className="space-y-3">
{toArray(value).length === 0 ? (
<span className="text-sm text-muted">未上传</span>
) : (
<div className="space-y-2">
{toArray(value).map((file, index) => {
const label = file?.name || file?.title || file?.url || `附件 ${index + 1}`;
return (
<div key={`${label}-${index}`} className="flex items-center justify-between gap-3">
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{label}</span>
<Button
size="sm"
variant="danger-soft"
onPress={() =>
onChange?.(
fieldDef?.multiple
? toArray(value).filter((_, fileIndex) => fileIndex !== index)
: null,
)
}
>
删除
</Button>
</div>
);
})}
</div>
)}
<FileUploadField
isDisabled={uploading}
onFilesChange={handleFileUpload}
description="上传该字段的附件。"
triggerLabel="选择附件"
<IntegrityRiskMaterialField
editSessionId={editSessionId}
fieldPath={fieldPath}
multiple={Boolean(fieldDef?.multiple)}
value={value}
onChange={onChange}
onPendingLocalMaterialChange={onPendingLocalMaterialChange}
onValidationChange={onValidationChange}
/>
<FieldError>{error}</FieldError>
</Card.Content>
</Card>
);
}
......@@ -520,6 +493,7 @@ export default function TailwindEditableField({
placeholder,
onSave,
onChange,
onPendingLocalMaterialChange,
onValidationChange,
mode = 'inline',
readonly = false,
......@@ -561,6 +535,7 @@ export default function TailwindEditableField({
fieldVariant={fieldVariant}
placeholder={textPlaceholder}
onChange={onChange}
onPendingLocalMaterialChange={onPendingLocalMaterialChange}
onValidationChange={onValidationChange}
/>
);
......
import { useEffect, useRef, useState } from 'react';
import {
getSubjectView,
getSubjectViewDetail,
loadRecord,
loadSubject,
updateRecord,
......@@ -97,9 +98,25 @@ export function useSubjectRecord(
try {
const record = await loadRecord(recordName);
const resolvedSubjectName = subjectName || record?.__t || record?.subject?.name || record?.subject;
const [subjectData, viewsData] = resolvedSubjectName
const [subjectData, viewSummaries] = resolvedSubjectName
? await Promise.all([loadSubject(resolvedSubjectName), getSubjectView(resolvedSubjectName)])
: [null, []];
const viewsData = await Promise.all(
(Array.isArray(viewSummaries) ? viewSummaries : []).map(async (view) => {
if (!view?.name || !resolvedSubjectName) return view;
try {
const detail = await getSubjectViewDetail(resolvedSubjectName, view.name);
return {
...view,
...detail,
settings: detail?.settings || view?.settings || {},
};
} catch (error) {
return view;
}
}),
);
if (!cancelled && requestIdRef.current === requestId) {
setRemoteRecord(record);
......
......@@ -9,15 +9,17 @@ import React, {
useRef,
useState,
} from 'react';
import { UserContext } from '@/wrapper/Auth';
import { createIntegrityRiskMaterial } from '@/pages/integrity_risk_prevention/IntegrityRiskMaterialService';
import { useSubjectRecord } from './hooks/useSubjectRecord';
import SubjectViewContent from './components/SubjectViewContent';
import {
buildViewColumns,
checkViewCondition,
getFieldValue,
isFieldDisabledByView,
isViewAccessible,
hasFieldAuthorizationConfiguration,
setFieldValue,
shouldHideViewTitleColumn,
} from '@/utils/subjectView';
const EMPTY_ARRAY = [];
......@@ -58,6 +60,8 @@ const RecordDetailDrawer = (props, ref) => {
deriveFieldValues,
} = props;
const currentUser = React.useContext(UserContext);
const [refreshKey, setRefreshKey] = useState(() => Date.now());
const [isFullscreen, setIsFullscreen] = useState(false);
const [activeTabKey, setActiveTabKey] = useState();
......@@ -68,6 +72,7 @@ const RecordDetailDrawer = (props, ref) => {
const [validationErrors, setValidationErrors] = useState({});
const [saveError, setSaveError] = useState('');
const editSessionRef = useRef(0);
const pendingLocalMaterialsRef = useRef({});
const {
editableRecord,
......@@ -98,6 +103,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({});
setValidationErrors({});
setSaveError('');
pendingLocalMaterialsRef.current = {};
editSessionRef.current += 1;
}, [open, recordName]);
......@@ -111,7 +117,6 @@ const RecordDetailDrawer = (props, ref) => {
const filtered = (views || [])
.filter((view) => view.type === 'detail')
.filter((view) => (filterView.length === 0 ? true : filterView.includes(view.name)))
.filter((view) => isViewAccessible(view))
.filter((view) => checkViewCondition(view.settings?.visabled, remoteRecord));
if (filtered.length > 0) {
......@@ -159,6 +164,7 @@ const RecordDetailDrawer = (props, ref) => {
);
const activeView = detailViews.find((view) => view.name === activeTabKey) || detailViews[0];
const hideRecordTitle = shouldHideViewTitleColumn(activeView);
const isRecordLocked = remoteRecord?.metadata?.is_locked === true;
const effectiveReadonly = readonly || isRecordLocked;
const disabledFieldSet = useMemo(
......@@ -168,10 +174,16 @@ const RecordDetailDrawer = (props, ref) => {
),
[disabledFields],
);
const isDrawerFieldDisabled = (fieldPath, currentView) =>
const isDrawerFieldDisabled = (fieldPath, currentView) => {
const effectiveAuthorizationView = hasFieldAuthorizationConfiguration(currentView)
? currentView
: authorizationView || currentView;
return (
disabledFieldSet.has(fieldPath) ||
isFieldDisabled(fieldPath, currentView) ||
isFieldDisabledByView(fieldPath, authorizationView);
isFieldDisabled(fieldPath, effectiveAuthorizationView)
);
};
const hasEditableFields =
Boolean(editableRecord) &&
......@@ -179,12 +191,10 @@ const RecordDetailDrawer = (props, ref) => {
!error &&
!effectiveReadonly &&
!minimalContent &&
detailViews.some((view) =>
buildViewColumns(view, subject).some(
buildViewColumns(activeView, subject).some(
(field) =>
!NON_EDITABLE_FIELD_TYPES.has(field.fieldDef?.type) &&
!isDrawerFieldDisabled(field.field, view),
),
!isDrawerFieldDisabled(field.field, activeView),
);
const hasDirtyFields = Object.keys(dirtyFields).length > 0;
const hasValidationErrors = Object.values(validationErrors).some(Boolean);
......@@ -198,6 +208,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({});
setValidationErrors({});
setSaveError('');
pendingLocalMaterialsRef.current = {};
};
const handleRequestClose = () => {
......@@ -213,6 +224,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({});
setValidationErrors({});
setSaveError('');
pendingLocalMaterialsRef.current = {};
setIsEditing(true);
};
......@@ -255,6 +267,36 @@ const RecordDetailDrawer = (props, ref) => {
});
}, []);
const handlePendingLocalMaterialChange = useCallback((fieldPath, change, editSessionId) => {
if (editSessionId !== editSessionRef.current) return;
const nextPendingMaterials = { ...pendingLocalMaterialsRef.current };
const currentFieldMaterials = Array.isArray(nextPendingMaterials[fieldPath])
? nextPendingMaterials[fieldPath]
: [];
let nextFieldMaterials = currentFieldMaterials;
if (change?.type === 'replace') {
nextFieldMaterials = change.materials || [];
} else if (change?.type === 'add') {
const materialsById = new Map(
currentFieldMaterials.map((material) => [material.clientId, material]),
);
(change.materials || []).forEach((material) => materialsById.set(material.clientId, material));
nextFieldMaterials = [...materialsById.values()];
} else if (change?.type === 'remove') {
nextFieldMaterials = currentFieldMaterials.filter(
(material) => material.clientId !== change.clientId,
);
}
if (nextFieldMaterials.length > 0) {
nextPendingMaterials[fieldPath] = nextFieldMaterials;
} else {
delete nextPendingMaterials[fieldPath];
}
pendingLocalMaterialsRef.current = nextPendingMaterials;
}, []);
const handleSave = async () => {
if (!hasDirtyFields || hasValidationErrors || isSaving) return;
......@@ -262,10 +304,33 @@ const RecordDetailDrawer = (props, ref) => {
setSaveError('');
try {
const pendingMaterials = Object.entries(pendingLocalMaterialsRef.current)
.filter(([fieldPath]) => dirtyFields[fieldPath]);
for (const [fieldPath, fieldMaterials] of pendingMaterials) {
for (const pendingMaterial of fieldMaterials) {
if (pendingMaterial.materialRecordId) continue;
const createdMaterial = await createIntegrityRiskMaterial({
fileAsset: pendingMaterial.fileAsset,
currentUser,
});
const currentFieldMaterials = pendingLocalMaterialsRef.current[fieldPath] || [];
pendingLocalMaterialsRef.current = {
...pendingLocalMaterialsRef.current,
[fieldPath]: currentFieldMaterials.map((material) =>
material.clientId === pendingMaterial.clientId
? { ...material, materialRecordId: createdMaterial.id }
: material,
),
};
}
}
await handleFieldsUpdate(Object.values(dirtyFields));
setIsEditing(false);
setDirtyFields({});
setValidationErrors({});
pendingLocalMaterialsRef.current = {};
onDataChange?.(true);
} catch (saveFailure) {
setSaveError(saveFailure?.message || '保存失败,请稍后重试。');
......@@ -324,12 +389,16 @@ const RecordDetailDrawer = (props, ref) => {
<Modal.Header className="pr-16">
<Modal.Heading>{title || '记录详情'}</Modal.Heading>
{!hideRecordTitle ? (
<Tooltip delay={0}>
<Tooltip.Trigger className="block min-w-0 truncate text-start">
<Description>{loading ? '正在读取记录信息' : recordTitle}</Description>
</Tooltip.Trigger>
<Tooltip.Content>{recordTitle}</Tooltip.Content>
</Tooltip>
) : (
<Description>查看并维护当前记录信息。</Description>
)}
</Modal.Header>
{detailViews.length > 1 ? (
......@@ -390,6 +459,7 @@ const RecordDetailDrawer = (props, ref) => {
mode={isEditing ? 'edit' : 'view'}
onFieldChange={handleDraftFieldChange}
onFieldValidation={handleFieldValidation}
onPendingLocalMaterialChange={handlePendingLocalMaterialChange}
readonly={effectiveReadonly}
isFieldDisabled={isDrawerFieldDisabled}
minimal={minimalContent}
......
......@@ -4,7 +4,7 @@ import ApplicationIdentityWell from '@/components/ApplicationIdentityWell';
import xinyuanLogo from '@/assets/xy-logo-red.png';
import {
APPLICATION_DISPLAY_NAME,
APPLICATION_ROUTE_NAME,
APPLICATION_NAME,
} from '@/config/application';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
......@@ -43,7 +43,7 @@ function resolveApplicationUrl(application) {
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
const current = name === APPLICATION_ROUTE_NAME;
const current = name === APPLICATION_NAME;
return {
name,
......@@ -74,7 +74,7 @@ function mergeApplications(fetchedApplications, fallbackApplications) {
[
...fallbackApplications,
{
name: APPLICATION_ROUTE_NAME,
name: APPLICATION_NAME,
title: APPLICATION_DISPLAY_NAME,
logo: xinyuanLogo,
},
......@@ -161,7 +161,7 @@ function buildApplicationSections(applications, applicationGroups) {
}
function isCurrentApplication(application) {
if (application?.name === APPLICATION_ROUTE_NAME) {
if (application?.name === APPLICATION_NAME) {
return true;
}
......@@ -266,7 +266,7 @@ export default function ApplicationSwitcherDialog({
);
const [errorMessage, setErrorMessage] = useState('');
const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || APPLICATION_ROUTE_NAME;
return applications.find(isCurrentApplication)?.name || APPLICATION_NAME;
}, [applications]);
const applicationSections = useMemo(
() => buildApplicationSections(applications, siteInfo?.group),
......
import dayjs from 'dayjs';
import { AUDIT_MATERIAL_SOURCE_APP } from '@/config/application';
import { createRecord, queryRecords, uploadFile } from '@/services/DataService';
export const AUDIT_MATERIAL_SUBJECT = 'am_material';
const MATERIAL_PAGE_SIZE = 20;
function extractRecords(response) {
const candidates = [
response,
response?.data,
response?.data?.list,
response?.data?.records,
response?.list,
response?.records,
response?.items,
response?.result,
response?.result?.records,
];
return candidates.find((candidate) => Array.isArray(candidate)) || [];
}
function escapeRegExp(value = '') {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function inferMaterialTitle(fileName = '') {
return String(fileName || '').replace(/\.[^.]+$/, '').trim() || '未命名材料';
}
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 queryIntegrityRiskMaterials(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 uploadIntegrityRiskFile(file) {
if (!file) throw new Error('请选择要上传的本地文件。');
return normalizeFileAsset(await uploadFile(file), file);
}
export async function createIntegrityRiskMaterial({ fileAsset, currentUser }) {
if (!fileAsset?.url) throw new Error('缺少有效的文件地址,无法写入审计材料中心。');
const uploaderId = currentUser?._id || currentUser?.id || '';
if (!uploaderId) throw new Error('未获取到当前登录用户,无法写入审计材料中心。');
const materialTitle = inferMaterialTitle(fileAsset.name);
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: AUDIT_MATERIAL_SOURCE_APP,
uploader: uploaderId,
uploaded_at: dayjs().toISOString(),
},
});
const createdRecord = response?.data || response?.result || response;
return {
id: createdRecord?._id || createdRecord?.id || createdRecord?.name || true,
record: createdRecord,
};
}
......@@ -24,22 +24,17 @@ const PAGE_CONFIG = {
process: {
subjectName: INTEGRITY_RISK_SUBJECTS.process,
title: '廉洁风险防控表 (过程表)',
hiddenColumnFields: ['title', 'metadata.department_control_items'],
hiddenDetailFields: ['title'],
hiddenColumnFields: ['metadata.department_control_items'],
query: { sort: 'metadata.original_index' },
},
show: {
subjectName: INTEGRITY_RISK_SUBJECTS.show,
title: '廉洁风险防控表 (展示表)',
hiddenColumnFields: ['title'],
hiddenDetailFields: ['title'],
query: { sort: 'metadata.original_index' },
},
measure: {
subjectName: INTEGRITY_RISK_SUBJECTS.measure,
title: '廉洁风险防控措施数据库',
hiddenColumnFields: ['title'],
hiddenDetailFields: ['title'],
},
archive: {
subjectName: INTEGRITY_RISK_SUBJECTS.archive,
......@@ -327,7 +322,6 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
detailDrawerProps={{
title: `${pageConfig.title}详情`,
readonly: pageConfig.readonly,
hiddenFields: pageConfig.hiddenDetailFields || [],
}}
isRowExpandable={pageType === 'process'
? (record) => normalizeDepartmentControlItems(record).length > 0
......
......@@ -2,6 +2,10 @@ import { APPLICATION_DISPLAY_NAME, ROUTE_TITLES } from '@/config/application';
import AppShell from '@/components/AppShell';
import { Archive, ClipboardDocumentListIcon, Database, TableCellsIcon } from '@/components/AppIcons';
import ApplicationSwitcherDialog from './ApplicationSwitcherDialog';
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from './notifications/notificationService';
import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import { useContext, useEffect, useMemo, useState } from 'react';
......@@ -24,6 +28,7 @@ export default function IntegrityRiskLayout() {
const siteInfo = useContext(SiteContext);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [applicationDialogOpen, setApplicationDialogOpen] = useState(false);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const currentTitle = useMemo(
() => ROUTE_TITLES[location.pathname] || APPLICATION_DISPLAY_NAME,
[location.pathname],
......@@ -33,10 +38,33 @@ export default function IntegrityRiskLayout() {
document.title = `${currentTitle} - ${APPLICATION_DISPLAY_NAME}`;
}, [currentTitle]);
useEffect(() => {
let cancelled = false;
const refreshUnreadCount = async () => {
try {
const count = await loadUnreadNotificationCount();
if (!cancelled) setUnreadNotificationCount(count);
} catch (error) {
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]);
return (
<>
<AppShell
menuGroups={MENU_GROUPS}
notificationCount={unreadNotificationCount}
notificationPath="/notifications"
onLogout={userInfo?.logout}
onOpenApplicationSwitcher={() => setApplicationDialogOpen(true)}
open={sidebarOpen}
......
......@@ -12,6 +12,7 @@
| 廉洁风险防控措施数据库 | `/measure_description` | 维护、导入、导出和归档防控措施 |
| 往期材料归档 | `/integrity_risk_control_archive` | 只读查看归档批次和历史快照 |
| 廉洁风险防控填写表 | `/control_measure_form?id=<flow-record>` | 填报、一级审核和最终审核 |
| 通知消息 | `/notifications` | 查看消息/待办并进入关联流程 |
侧栏只注册上述四个业务入口。`streams-admin` 中的自查底稿、廉洁从业报告和其他廉洁
风险页面不在本应用配置中,不注册为路由。
......@@ -27,8 +28,9 @@
5. 归档页为只读,不提供新建、编辑、复制或删除。
6. 页面请求必须展示加载、成功和错误状态;删除与替换导入必须提供确认。删除确认弹窗
仅展示选中记录数量和软删除说明,不展示记录标题列表。
7. 过程表、展示表和防控措施数据库的列表及详情侧栏隐藏 Record `title` 字段;归档页
保留该字段。
7. Grid View 的 `settings.shortTitle === true` 时仅隐藏列表 `title` 展示列;Detail View
的同名配置同时隐藏详情 Header 的记录标题和字段区 `title`。`false` 或未配置时正常
显示。该配置不删除标题数据,也不改变标题搜索、排序、筛选、详情身份或保存协议。
8. Subject 配置为多选的选择字段,在新建弹窗和详情编辑中使用可搜索的下拉勾选器;
已选项以标签展示并支持逐项移除或清空,保存值仍为 `settings.options` 中的 value 数组。
9. 列表工具栏默认只显示新建、导入和主要导出等常用入口;展示表的次要导出与归档动作
......@@ -62,6 +64,36 @@
19. 过程表列表不直接展示内部嵌套字段 `department_control_items`;部门展开行中的用户和引用
字段仍保存 ID,但列表批量解析并显示用户名称、记录标题,解析失败时才回退显示 ID。
### 2.1 Subject View 授权
- Grid View 的 `settings.viewAuth` 只控制视图可见性。普通用户只读取
`user.applications[]` 中 `name = "integrity_risk_prevention_new"` 的 groups、roles 和
permissions;缺少配置默认拒绝。系统管理员、系统账号和该应用自身“管理”角色可旁路。
- 无可访问 Grid View 时不查询记录,不展示新建、批量、选择和行操作;仅一个可访问 View
时隐藏 Tabs。View 详情通过 `/subject/:subjectName/view/:viewName` 完整读取。
- Detail View 不再以 `viewAuth` 二次隐藏字段。字段编辑优先使用当前 Detail 的完整
`settings.auth`/`disableColumns`;Detail 没有有效配置时整套回退当前 Grid View,不能逐字段
合并。普通用户缺少字段 auth 时只读,`disableColumns` 对所有用户始终只读。
### 2.2 文件与审计材料中心
- 运行时 Subject 定义为可编辑 `file` 的字段使用“材料中心 / 本地上传”,单值保持对象,
`multiple=true` 保持对象数组;快照始终只含 `{name,type,size,url}`。
- 材料中心查询 `am_material` 最近 20 条,按材料标题或文件名远程搜索;单值替换,多值追加
并按 URL 去重,清空业务附件不删除材料主档。
- 本地文件先调用 `/upload` 进入详情草稿。取消编辑不建档;保存详情时,先逐文件创建
`am_material`,写入 `source_app = integrity_risk_prevention`、当前用户和 ISO 上传时间,
全部成功后才更新业务记录。部分失败保留草稿与已建档标记,重试不重复建档。
### 2.3 通知消息
- Navbar 的通知入口读取平台未读总数,0 隐藏、1-99 显示真实值、100 及以上显示 `99+`;
每 60 秒和通知状态变化后刷新,Sidebar 不增加通知菜单。
- `/notifications` 支持消息/待办、全部/未读、300ms 搜索、固定 10 条分页、单条/全部已读、
服务端动态 action 和关联流程跳转。通知内容只按纯文本显示。
- 本应用 `/integrity_risk_prevention_new` 前缀只在站内路由前移除;其他应用路径和外部 HTTP(S)
链接在新窗口打开,不改变原通知接口、`notice_id` payload 或鉴权。
## 3. 过程表操作
### 3.1 推送风险防控表
......@@ -203,3 +235,13 @@
加载关联记录以 `restriction_value` 为准。
- Excel 替换和 OA 流程均跨多个非事务接口;页面能阻止明显的前置错误,但服务端部分成功
时无法自动回滚,管理员需根据错误信息核查对应归档、待办和通知。
## 8. Subject View 权限流程
1. 应用稳定标识为 `integrity_risk_prevention_new`;路由 base、应用切换当前态和 View 授权均从该标识派生。
2. View 授权只读取当前用户 `applications[]` 中同名应用的 `groups`、`roles`、`permissions`,不读取顶层身份或其他应用授权。系统管理员、系统账号和本应用“管理”角色可旁路普通授权。
3. Grid `settings.viewAuth` 只控制业务 Tab 是否可见。没有可访问 Grid 时不查询记录,不展示新增、批量、选择或行操作,也不创建兜底 View。
4. 打开详情时加载每个 View 的完整配置,并把当前 Grid 完整配置作为 `authorizationView`。Detail 不使用 `viewAuth` 隐藏字段,`columns` 只控制展示范围和顺序。
5. 当前 Detail 的 `auth` 或 `disableColumns` 只要存在任一有效字段配置,就整套使用 Detail 权限;两项都没有或没有实际 Detail 时,整套回退当前 Grid。两套权限不合并,也不逐字段回退。
6. 选定权限来源后,普通用户未配置 `auth` 的展示字段默认只读;`disableColumns` 对所有账号优先且强制只读,管理员也不能旁路。
7. 切换 Grid 或 Detail 后立即重新计算编辑按钮和字段状态;编辑按钮只检查当前 Detail 实际展示且可编辑的字段,不受其他 Detail 权限影响。
import {
exacteActionFunction,
queryUserNotice,
readUserNotice,
unReadNoticeCount,
} from '@/services/DataService';
import { APPLICATION_BASE_PATH } from '@/config/application';
export const NOTIFICATION_PAGE_SIZE = 10;
export const NOTIFICATION_STATE_EVENT = 'integrity-risk-prevention:notification-state';
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,
pageSize = NOTIFICATION_PAGE_SIZE,
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,
pageSize,
filter,
readStatus === 'unread' ? 'unread' : null,
);
return {
list: toArray(response?.list),
page: Number(response?.current_page ?? page) || 0,
pageSize: Number(response?.page_size ?? pageSize) || pageSize,
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);
if (!normalizedUserId) return false;
return toArray(notification?.data?.logs).some(
(log) => toId(log?.user_id) === normalizedUserId,
);
}
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 content = getNotificationContent(notification);
const match = content.match(/https?:\/\/[^\s<>"']+|\/integrity_risk_prevention_new\/[^\s<>"']+|\/control_measure_form(?:\?[^\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) {
return resolveNotificationTarget(`${url.pathname}${url.search}${url.hash}`);
}
return { href: url.toString(), external: true };
} catch (error) {
return null;
}
}
const appPrefix = APPLICATION_BASE_PATH.replace(/\/$/, '');
if (normalizedTarget === appPrefix || normalizedTarget.startsWith(`${appPrefix}/`)) {
return {
href: normalizedTarget.slice(appPrefix.length) || '/',
external: false,
};
}
if (normalizedTarget.startsWith('/')) {
const localRoutes = ['/control_measure_form', '/risk_prevention_control_measures_table', '/risk_prevention_control_measures_table_show', '/measure_description', '/integrity_risk_control_archive', '/notifications'];
return localRoutes.some((route) => normalizedTarget === route || normalizedTarget.startsWith(`${route}?`))
? { href: normalizedTarget, external: false }
: { href: normalizedTarget, external: true };
}
return null;
}
export function notifyNotificationStateChanged() {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(NOTIFICATION_STATE_EVENT));
}
}
import {
getSubjectView,
getSubjectViewDetail,
loadSubject,
queryRecords,
} from '@/services/DataService';
import {
buildViewColumns,
buildViewSearchColumns,
checkViewCondition,
formatFieldValue,
getFieldValue,
......@@ -56,11 +58,30 @@ export async function queryAllSubjectRecords(subjectName, query = {}) {
}
export async function loadSubjectWorkbenchResources(subjectName, query = {}) {
const [records, subject, views] = await Promise.all([
queryAllSubjectRecords(subjectName, query),
const [subject, viewSummaries] = await Promise.all([
loadSubject(subjectName),
getSubjectView(subjectName),
]);
const views = await Promise.all(
(Array.isArray(viewSummaries) ? viewSummaries : []).map(async (view) => {
if (!view?.name) return view;
try {
const detail = await getSubjectViewDetail(subjectName, view.name);
return {
...view,
...detail,
settings: detail?.settings || view?.settings || {},
};
} catch (error) {
return view;
}
}),
);
const accessibleGridViews = buildGridViews(views);
const records = accessibleGridViews.length > 0
? await queryAllSubjectRecords(subjectName, query)
: [];
return {
records: records.map(normalizeWorkbenchRecord),
......@@ -70,27 +91,15 @@ export async function loadSubjectWorkbenchResources(subjectName, query = {}) {
}
export function buildGridViews(views = []) {
const gridViews = views
return views
.filter((view) => view.type === 'grid')
.filter((view) => isViewAccessible(view));
if (gridViews.length > 0) {
return gridViews;
}
return [
{
title: '全部记录',
type: 'grid',
name: 'all',
settings: {},
},
];
}
export function buildConfiguredWorkbenchView(records, filters, view, subject) {
const keyword = normalizeText(filters?.keyword).toLowerCase();
const columns = buildViewColumns(view, subject);
const searchColumns = buildViewSearchColumns(view, subject);
const filteredRecords = records.filter((record) => {
const rawRecord = record.raw || record;
......@@ -114,7 +123,7 @@ export function buildConfiguredWorkbenchView(records, filters, view, subject) {
return true;
}
const searchText = columns
const searchText = searchColumns
.map((column) => formatFieldValue(getFieldValue(rawRecord, column.field), column.fieldDef))
.join(' ')
.toLowerCase();
......
import dayjs from 'dayjs';
import { AUTHORIZATION_APPLICATION_NAME } from '@/config/application';
export function normalizeText(value) {
return String(value ?? '').trim();
......@@ -77,60 +78,103 @@ export function getCurrentUserFromStorage() {
}
}
function collectUserGroups(user) {
return toArray(user?.groups)
.flatMap((group) => [group?._id, group?.name, group?.display_name, group])
function collectIdentityValues(values) {
return toArray(values)
.flatMap((value) => {
if (value && typeof value === 'object') {
return [
value._id,
value.name,
value.display_name,
value.displayName,
value.username,
];
}
return value;
})
.map(normalizeText)
.filter(Boolean);
}
function collectUserRoles(user) {
const directRoles = toArray(user?.roles);
const appRoles = toArray(user?.applications).flatMap((app) => app?.roles || []);
return [...directRoles, ...appRoles].map(normalizeText).filter(Boolean);
function getUserApplication(user, applicationName = AUTHORIZATION_APPLICATION_NAME) {
return toArray(user?.applications).find(
(application) => normalizeText(application?.name) === normalizeText(applicationName),
) || null;
}
function collectUserPermissions(user) {
const directPermissions = toArray(user?.permissions);
const appPermissions = toArray(user?.applications).flatMap((app) => app?.permissions || []);
return [...directPermissions, ...appPermissions].map(normalizeText).filter(Boolean);
function collectApplicationAuthorization(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 rightSet = new Set(toArray(right).map(normalizeText).filter(Boolean));
return toArray(left).some((item) => rightSet.has(normalizeText(item)));
const rightSet = new Set(collectIdentityValues(right));
return collectIdentityValues(left).some((item) => rightSet.has(item));
}
function getMultidimensionalTableAuthRule(authRule) {
return ['group', 'role', 'permission'].includes(authRule?.type)
? authRule
: '管理';
}
export function isSystemAdministrator(user = getCurrentUserFromStorage()) {
return user?.type === 'admin' || user?.type === 'system';
}
export function hasAuthAccess(authRule, user = getCurrentUserFromStorage()) {
if (!authRule) {
export function hasApplicationManagementAccess(
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (isSystemAdministrator(user)) {
return true;
}
const { roles } = collectApplicationAuthorization(user, applicationName);
return intersects(roles, ['管理']);
}
export function hasAuthAccess(
authRule,
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (!user) {
return false;
}
if (user.type === 'admin') {
if (hasApplicationManagementAccess(user, applicationName)) {
return true;
}
if (authRule.type === 'group') {
return intersects(collectUserGroups(user), authRule.groups);
const { groups, roles, permissions } = collectApplicationAuthorization(
user,
applicationName,
);
if (typeof authRule === 'string') {
return intersects(roles, [authRule]);
}
if (authRule.type === 'role') {
return intersects(collectUserRoles(user), authRule.roles);
if (authRule?.type === 'group') {
return intersects(groups, authRule.groups);
}
if (authRule.type === 'permission') {
return intersects(collectUserPermissions(user), authRule.permissions);
if (authRule?.type === 'role') {
return intersects(roles, authRule.roles);
}
if (authRule.type === 'username') {
return intersects([user.username, user.display_name, user._id], authRule.usernames);
if (authRule?.type === 'permission') {
return intersects(permissions, authRule.permissions);
}
return true;
return false;
}
export function getFieldValue(record, fieldPath) {
......@@ -296,12 +340,21 @@ export function normalizeRecordLabel(value) {
return normalizeText(value);
}
export function shouldHideViewTitleColumn(view) {
return view?.settings?.shortTitle === true;
}
export function buildViewColumns(view, subject, { includeHidden = false } = {}) {
const configuredColumns = view?.settings?.columns || [];
const hideTitleColumn = !includeHidden && shouldHideViewTitleColumn(view);
if (configuredColumns.length > 0) {
return configuredColumns
.filter((column) => includeHidden || !column.hide)
.filter(
(column) =>
(includeHidden || !column.hide) &&
(!hideTitleColumn || column.field !== 'title'),
)
.map((column) => {
const fieldDef = getFieldDefinition(column.field, subject);
return {
......@@ -314,12 +367,14 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
}
const defaultColumns = [];
if (!hideTitleColumn) {
defaultColumns.push({
field: 'title',
width: 180,
label: '标题',
fieldDef: { name: 'title', label: '标题', type: 'text' },
});
}
toArray(subject?.fields).forEach((field) => {
defaultColumns.push({
......@@ -333,6 +388,23 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
return defaultColumns;
}
export function buildViewSearchColumns(view, subject) {
const displayColumns = buildViewColumns(view, subject);
if (displayColumns.some((column) => column.field === 'title')) {
return displayColumns;
}
return [
{
field: 'title',
width: 180,
label: '标题',
fieldDef: getFieldDefinition('title', subject),
},
...displayColumns,
];
}
function compareRuleValue(recordValue, comparator, expectedValue) {
const expectedValues = toArray(expectedValue).map(normalizeText);
const recordValues = toArray(recordValue).map((item) => {
......@@ -391,18 +463,47 @@ export function checkViewCondition(condition, record) {
: results.every(Boolean);
}
export function isViewAccessible(view, user = getCurrentUserFromStorage()) {
return hasAuthAccess(view?.settings?.viewAuth, user);
export function isViewAccessible(
view,
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
return hasAuthAccess(
getMultidimensionalTableAuthRule(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 = getCurrentUserFromStorage()) {
export function isFieldDisabledByView(
fieldPath,
view,
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
const disabledColumn = toArray(view?.settings?.disableColumns).some((column) => column.field === fieldPath);
if (disabledColumn) {
return true;
}
if (hasApplicationManagementAccess(user, applicationName)) {
return false;
}
const fieldAuthRule = toArray(view?.settings?.auth).find((rule) => rule.field === fieldPath);
return fieldAuthRule ? !hasAuthAccess(fieldAuthRule, user) : false;
return !hasAuthAccess(
getMultidimensionalTableAuthRule(fieldAuthRule),
user,
applicationName,
);
}
export function recordMatchesRestrictions(record, restrictions = []) {
......
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