Commit 84dfb853 authored by Andy-bubu's avatar Andy-bubu

feat: align Xinyuan cross-app capabilities

parent 0f9e79a0
...@@ -14,6 +14,7 @@ const ROUTE_PATHS = { ...@@ -14,6 +14,7 @@ const ROUTE_PATHS = {
selfCheckDraftTemplate: '/self_check_the_draft_template', selfCheckDraftTemplate: '/self_check_the_draft_template',
selfCheckWorkingDraft: '/self_check_the_working_draft', selfCheckWorkingDraft: '/self_check_the_working_draft',
annualSubmissionMaterials: '/self_inspection_annual_submission_materials', annualSubmissionMaterials: '/self_inspection_annual_submission_materials',
notifications: '/notifications',
} as const; } as const;
const APP_BASE_PATH = '/integrity_practice_management/'; const APP_BASE_PATH = '/integrity_practice_management/';
...@@ -75,6 +76,10 @@ export default defineConfig({ ...@@ -75,6 +76,10 @@ export default defineConfig({
path: ROUTE_PATHS.annualSubmissionMaterials, path: ROUTE_PATHS.annualSubmissionMaterials,
component: './integrity_practice_management/SubjectWorkbench', component: './integrity_practice_management/SubjectWorkbench',
}, },
{
path: ROUTE_PATHS.notifications,
component: './integrity_practice_management/notifications/NotificationListPage',
},
], ],
}, },
], ],
......
...@@ -9,21 +9,25 @@ app.js (主题初始化) ...@@ -9,21 +9,25 @@ app.js (主题初始化)
-> AppShell (Pro Sidebar.Provider) -> AppShell (Pro Sidebar.Provider)
-> DashboardSidebar (Pro Sidebar + HeroUI Dropdown) -> DashboardSidebar (Pro Sidebar + HeroUI Dropdown)
-> DashboardNavbar (Pro Navbar + Sidebar.Trigger) -> DashboardNavbar (Pro Navbar + Sidebar.Trigger)
-> Badge.Anchor + Badge 通知入口 -> /notifications
-> SubjectWorkbench (七个 Subject 配置入口) -> SubjectWorkbench (七个 Subject 配置入口)
-> RecordWorkbench (Tabs/Search/DataGrid/Pagination/ActionBar) -> RecordWorkbench (Tabs/Search/DataGrid/Pagination/ActionBar)
-> RecordDetailDrawer (Modal/Tabs/Surface/动态字段) -> RecordDetailDrawer (Modal/Tabs/Surface/动态字段)
-> 整窗草稿 -> 待建档本地附件 -> am_material -> 业务 Record
-> UserAssignmentDialog / AlertDialog -> UserAssignmentDialog / AlertDialog
``` ```
## 责任边界 ## 责任边界
- `src/config/application.js`:应用名、base、主题存储键。 - `src/config/application.js`:稳定 `APPLICATION_NAME`、展示名、base、主题存储键;路由、应用切换与授权判断从同一应用标识派生。
- `src/design-system/`:权威主题、Tokens 和跨应用 Shell 样式;页面不得复制这些规则。 - `src/design-system/`:权威主题、Tokens 和跨应用 Shell 样式;页面不得复制这些规则。
- `AppShell/DashboardSidebar/DashboardNavbar/ApplicationSwitcherDialog`:业务无关的共享壳层。 - `AppShell/DashboardSidebar/DashboardNavbar/ApplicationSwitcherDialog`:业务无关的共享壳层。
- `ApplicationSwitcherDialog` 从既有 `SiteContext.site.group` 读取动态分组,从 `GET /application/info` 与 `UserContext.applications` 合并真实可访问应用;分组仅影响呈现,不改变路由、会话或跳转。 - `ApplicationSwitcherDialog` 从既有 `SiteContext.site.group` 读取动态分组,从 `GET /application/info` 与 `UserContext.applications` 合并真实可访问应用;分组仅影响呈现,不改变路由、会话或跳转。
- `SubjectWorkbench`:把现有 Subject/View/动作配置适配为工作台 props,不定义组件外观。 - `SubjectWorkbench`:把现有 Subject/View/动作配置适配为工作台 props,不定义组件外观。
- `record-workbench`:DataGrid/Pagination/ActionBar 组合;详情能力统一映射为操作列首个预览图标,首个业务列仅展示文本;不发请求、不解释业务动作。 - `record-workbench`:DataGrid/Pagination/ActionBar 组合;详情能力统一映射为操作列首个预览图标,首个业务列仅展示文本;不发请求、不解释业务动作。
- `RecordDetailDrawer`:详情草稿、Modal 状态和字段编排;保存仍由 `useSubjectRecord` 处理。 - `RecordDetailDrawer`:详情整窗草稿、Modal 状态和字段编排;一次保存通过 `useSubjectRecord.handleFieldsUpdate` 提交。
- `integrityPracticeMaterialService`:`am_material` 查询、`/upload` 结果快照规范化和材料建档;应用标识由廉洁从业业务层固定为 `integrity_practice_management`。
- `notifications/notificationService`:平台通知查询、已读、动态 action、未读事件和 base-aware 链接解析。
- `TailwindEditableField`:按动态字段类型选择官方控件;不硬编码选项或 API 字段。 - `TailwindEditableField`:按动态字段类型选择官方控件;不硬编码选项或 API 字段。
- service/hook:唯一业务请求与数据转换边界,UI 组件不改变协议。 - service/hook:唯一业务请求与数据转换边界,UI 组件不改变协议。
...@@ -33,8 +37,12 @@ app.js (主题初始化) ...@@ -33,8 +37,12 @@ app.js (主题初始化)
路由 -> Subject 配置 -> GET Subject/View -> POST record/query 路由 -> Subject 配置 -> GET Subject/View -> POST record/query
-> DataGrid 展示/选择 -> 配置动作 -> 现有 service 回调 -> DataGrid 展示/选择 -> 配置动作 -> 现有 service 回调
-> 详情 Modal -> 本地草稿 -> 统一保存 -> PUT record/:name -> 详情 Modal -> 本地草稿 -> 统一保存 -> PUT record/:name
-> 本地附件草稿 -> POST am_material(逐文件、可重试)-> PUT record/:name
Navbar -> GET notice/customer/count -> /notifications -> query/read/action
``` ```
Grid View 的 `viewAuth` 在查询前过滤;没有可访问 View 时数据链在 `POST record/query` 之前终止。打开详情时,`useSubjectRecord` 在 View 摘要之后继续读取每个 View 的完整配置。当前 Detail 有有效 `auth` 或 `disableColumns` 时,其字段权限整套优先;两项都未配置或没有实际 Detail 时,整体回退打开它的 Grid `authorizationView`。Detail 与 Grid 不合并、也不逐字段回退;`disableColumns` 始终强制只读。切换 Detail 后,字段状态和编辑入口只按当前 Detail 的展示字段立即重算。Grid `viewAuth` 不用于隐藏详情字段。
主题状态写入 `xinyuan_color_theme`,同时更新根 `data-theme` 与 `dark` class,使 Portal 与页面一致。应用切换数据来自真实 application info;登出调用现有 logout API 后清理登录态并回到 base-aware 登录路径。 主题状态写入 `xinyuan_color_theme`,同时更新根 `data-theme` 与 `dark` class,使 Portal 与页面一致。应用切换数据来自真实 application info;登出调用现有 logout API 后清理登录态并回到 base-aware 登录路径。
## 历史源码隔离 ## 历史源码隔离
......
...@@ -22,6 +22,15 @@ ...@@ -22,6 +22,15 @@
- [x] 详情/编辑 Modal、指派 Modal、删除 AlertDialog - [x] 详情/编辑 Modal、指派 Modal、删除 AlertDialog
- [x] Select、Autocomplete、DatePicker、DropZone 直接 compound API - [x] Select、Autocomplete、DatePicker、DropZone 直接 compound API
- [x] 活动路由 KPI/图表判定为不适用 - [x] 活动路由 KPI/图表判定为不适用
- [x] Grid `shortTitle` 严格布尔隐藏标题展示列,标题数据与查询协议保留
- [x] Detail `shortTitle` 随活动详情 View 隐藏记录标题与 title 字段
- [x] View/字段授权只读取 `integrity_practice_management` 应用项;零 View 不查询、不展示操作,单 View 隐藏 Tabs
- [x] 稳定 `APPLICATION_NAME` 统一路由、应用切换与授权归属;不合并用户顶层或其他应用角色/用户组
- [x] Detail 加载完整 View 配置;有效 Detail 字段权限整套优先,无配置时整体回退当前 Grid,不合并或逐字段回退
- [x] `disableColumns` 对管理员仍强制只读;编辑入口只检查当前激活 Detail 展示字段
- [x] `/notifications` 消息/待办、全部/未读、搜索、分页、单条/全部已读、动态 action 与 base-aware 跳转
- [x] Navbar 36px Bell、真实未读数和 0/99/99+ 显示规则
- [x] 所有可编辑 file 字段提供材料中心/本地上传;本地草稿仅在详情保存时建档
## 保护与扫描 ## 保护与扫描
...@@ -55,3 +64,17 @@ ...@@ -55,3 +64,17 @@
- [ ] 全仓旧组件扫描为零:未完成。未挂载历史 `src/pages/problem_rectification` 与 `src/catalyst-ui-kit` 仍保留,且不进入当前应用路由。 - [ ] 全仓旧组件扫描为零:未完成。未挂载历史 `src/pages/problem_rectification` 与 `src/catalyst-ui-kit` 仍保留,且不进入当前应用路由。
- [ ] KPI/图表 Tooltip 页面验收:不适用。七条活动路由没有这些业务模块,未伪造数据或页面。 - [ ] KPI/图表 Tooltip 页面验收:不适用。七条活动路由没有这些业务模块,未伪造数据或页面。
- [ ] 五项功能全部真实线上失败/重试矩阵:未完成。当前列表、详情和通知接口可用,但没有制造材料部分成功、业务保存失败或通知 1/99/100 的业务数据;不得以浏览器夹具替代真实线上状态验收。
## 2026-08-26 五项功能追加验收
- [x] 真实 `shortTitle: true` Grid View 隐藏标题展示列,保留上下文首列和操作列预览入口
- [x] Navbar 未读 0 隐藏 Badge,Bell 进入本应用 `/notifications`
- [x] 通知页真实调用 `/notice/customer/query` 与 `/notice/customer/count`,空态、消息/待办和全部/未读控件可见
- [x] 目标页面重新登录后的控制台 0 error;保留两条既有 React Aria 开发 warning
- [x] 真实详情权限来源:Andy 打开未归档记录时记录、Subject、View 摘要及四个完整 View 请求均为 200;无字段权限配置的 Detail 整体回退 Grid,并正常显示编辑入口
- [x] 真实 `disableColumns`:已归档 Detail 的完整配置优先,Andy 虽为系统管理员,进入编辑后配置禁用的模板、部门、项目结果、填报人、审批人等仍为只读,仅未禁用字段可编辑
- [ ] 真实材料事务:尚未完成文件单值/多值、取消、失败和无重复重试验收;未制造业务数据
- [ ] Detail 完整角色矩阵:Andy 是系统管理员且 `applications` 为空,未提供当前应用“管理”角色和普通用户登录凭据;无权限视图、普通用户默认只读及角色旁路仍未做真实账号验收
- [ ] 真实角色矩阵和通知 1/99/100/action:当前 Andy 运行数据只有 admin 身份且通知总数为 0,没有制造或写入测试业务数据
- [ ] 问题整改同视口稳定会话:基准服务在线,但独立登录验证码会话未稳定建立;本轮没有把仅代码 diff 计作浏览器对比通过
...@@ -24,6 +24,11 @@ Inter 禁止通过 `src/tailwind.css` 导入。Tailwind v4 内容扫描必须排 ...@@ -24,6 +24,11 @@ Inter 禁止通过 `src/tailwind.css` 导入。Tailwind v4 内容扫描必须排
- 行动作:1-3 项全部直出;4 项以上直出前两项和 More Dropdown;危险项排序最后。 - 行动作:1-3 项全部直出;4 项以上直出前两项和 More Dropdown;危险项排序最后。
- Modal:详情/表单使用 HeroUI Modal;确认使用 AlertDialog;内容型详情使用 4xl/1088px、24px 圆角,查看态只允许一个 16px 圆角的 secondary Surface。嵌套 DataGrid 使用 20px 圆角,表头与固定表头使用 tertiary Surface,表体与固定单元格回到 default Surface,禁止表头与外层详情 Surface 使用同一层颜色。 - Modal:详情/表单使用 HeroUI Modal;确认使用 AlertDialog;内容型详情使用 4xl/1088px、24px 圆角,查看态只允许一个 16px 圆角的 secondary Surface。嵌套 DataGrid 使用 20px 圆角,表头与固定表头使用 tertiary Surface,表体与固定单元格回到 default Surface,禁止表头与外层详情 Surface 使用同一层颜色。
- 动态字段:Input、TextArea、Checkbox、Select、Autocomplete、DatePicker;附件使用 Pro DropZone。 - 动态字段:Input、TextArea、Checkbox、Select、Autocomplete、DatePicker;附件使用 Pro DropZone。
- View 标题:仅 `settings.shortTitle === true` 隐藏 Grid 的 title 展示列;Detail View 同配置隐藏记录副标题与 title 字段,Modal 功能标题始终存在。
- View 权限:以稳定 `APPLICATION_NAME = integrity_practice_management` 定位授权,只读取 `user.applications[]` 中当前应用项目的 groups/roles/permissions;不得合并顶层身份或其他应用授权。Grid `viewAuth` 只控制 Tab 可见性;零可访问 Grid View 时禁止查询与操作,一个 View 时隐藏 Tabs。
- Detail 字段权限:打开详情时加载 `GET /subject/:subjectName/view/:viewName` 的完整配置。当前 Detail 有有效 `auth` 或 `disableColumns` 时整套使用 Detail;两项均未配置或不存在实际 Detail 时整体回退打开它的 Grid。两套配置不合并、不逐字段回退;`disableColumns` 对所有用户始终只读,编辑入口只根据当前激活 Detail 展示字段计算。
- 通知:Navbar 直接组合 `Badge.Anchor + Badge + Button + Tooltip`,Bell 位于主题按钮左侧;通知页使用平台真实接口和 HeroUI Tabs/SearchField/Pagination/Card。
- 文件:可编辑 file 字段统一使用 HeroUI Tabs、Autocomplete 和 Pro DropZone;材料中心选择及本地上传都只向业务草稿写 `{name,type,size,url}`。
- 远程引用:使用 HeroUI `Popover.Root + Button + SearchField + controlled ListBox`。多选触发器固定 40px 高并只显示“已选择 N 项”,候选长标题单行省略且通过 `title` 暴露全文;弹层最大 24rem、移动端收敛到 `100vw - 24px`、列表最大 288px。多选后保持打开,单选后关闭,清空不改变原 ID 保存协议。 - 远程引用:使用 HeroUI `Popover.Root + Button + SearchField + controlled ListBox`。多选触发器固定 40px 高并只显示“已选择 N 项”,候选长标题单行省略且通过 `title` 暴露全文;弹层最大 24rem、移动端收敛到 `100vw - 24px`、列表最大 288px。多选后保持打开,单选后关闭,清空不改变原 ID 保存协议。
- KPI/图表:仅在真实业务路由存在时使用 Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip;禁止 Card 数字卡或 ECharts 私有主题。 - KPI/图表:仅在真实业务路由存在时使用 Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip;禁止 Card 数字卡或 ECharts 私有主题。
......
...@@ -132,3 +132,16 @@ pnpm install --lockfile-only --offline --frozen-lockfile ...@@ -132,3 +132,16 @@ pnpm install --lockfile-only --offline --frozen-lockfile
- 根运行时为 React/React DOM `19.2.8`、HeroUI/Styles `3.2.4`、React Aria Components `1.20.0`、React Aria Utils `3.34.1`,满足 HeroUI Pro `1.0.0-beta.8` peer 范围;冻结锁文件检查显示 `Already up to date`。 - 根运行时为 React/React DOM `19.2.8`、HeroUI/Styles `3.2.4`、React Aria Components `1.20.0`、React Aria Utils `3.34.1`,满足 HeroUI Pro `1.0.0-beta.8` peer 范围;冻结锁文件检查显示 `Already up to date`。
- 依赖树还包含 Umi/DVA 工具链内部 React/React DOM 18 副本;`.umirc.ts` 已把应用运行时 alias 到根 React 19。没有缺失 HeroUI peer。 - 依赖树还包含 Umi/DVA 工具链内部 React/React DOM 18 副本;`.umirc.ts` 已把应用运行时 alias 到根 React 19。没有缺失 HeroUI peer。
- Node ESM 脚本已逐项导入根入口、Sidebar、Navbar、DataGrid、DropZone、ActionBar、EmptyState、KPI、PieChart、BarChart、ComposedChart、ChartTooltip,并解析 CSS 子路径,全部通过。 - Node ESM 脚本已逐项导入根入口、Sidebar、Navbar、DataGrid、DropZone、ActionBar、EmptyState、KPI、PieChart、BarChart、ComposedChart、ChartTooltip,并解析 CSS 子路径,全部通过。
## 2026-08-26 五项功能审计
- 应用范围:统一展示名为“廉洁从业管理”,属于六个完整功能应用,实施 Grid/Detail `shortTitle`、Subject View 权限、通知中心和审计材料中心文件流程。
- 授权来源:`APPLICATION_NAME = integrity_practice_management` 是路由、应用切换和授权判断的唯一稳定标识。只读取 `user.applications[]` 中该应用项目,不合并顶层身份、其他应用角色或其他应用用户组;系统管理员/系统账号及该应用“管理”角色旁路 View/字段授权,`disableColumns` 始终只读。
- Detail 权限来源:详情先获取 View 摘要,再逐项调用 `GET /subject/:subjectName/view/:viewName` 补齐完整配置。当前 Detail 有有效 `auth`/`disableColumns` 时整套优先;两项都未配置或没有实际 Detail 时整体回退当前 Grid,二者不合并也不逐字段回退。Grid `viewAuth` 仅控制 Tab 可见性,编辑入口只检查当前激活 Detail 展示字段。
- 真实 View:`self_inspection_submission_form`、`statistical_table`、`integrity_practice_report`、`self_check_the_working_draft` 已确认存在严格布尔 `shortTitle: true`;未配置的 View 保留标题列。
- 文件协议:运行时确认 `integrity_practice_report.metadata.archive_attachment` 为 `file` 且 `multiple: true`;业务值继续使用 `{name,type,size,url}` 快照。
- 通知协议:沿用既有 `/notice/customer/query`、`/notice/customer/count`、`/notice/customer/read` 与动态 action API,新增活动路由 `/notifications`,base 仍为 `/integrity_practice_management/`。
- 材料协议:查询 `am_material` 最近 20 条;本地文件仍先走 `/upload`,详情保存时才以 `source_app = integrity_practice_management` 建档,材料全部成功后才更新业务记录。
- 原始缺口:旧实现会在零授权 View 时创建“全部”兜底并查询;详情按单字段累计 payload;Navbar 无通知入口;文件字段只有本地上传。上述缺口已按问题整改基准收敛。
- 浏览器实测:目标运行于 `http://localhost:8006/integrity_practice_management/`,问题整改基准运行于 `http://localhost:8004/problem_rectification_new/`。Andy 登录后,`self_inspection_submission_form` 的真实 `shortTitle: true` View 已确认不渲染标题列,首列不承担预览,操作列保留预览/编辑/更多;Navbar 未读数真实返回 0,角标不显示;点击 Bell 正确进入 `/notifications`,并实际发出 `/notice/customer/count` 与 `/notice/customer/query` 请求。打开未归档真实记录时,记录、Subject、View 摘要和四个 `GET /subject/:subjectName/view/:viewName` 请求全部为 200;该 Detail 无字段权限配置,整体回退当前 Grid 后正常显示编辑入口。切换已归档 Grid 后,实际 Detail 带 `disableColumns`,Andy 虽为系统管理员,编辑态中模板、部门、项目结果、填报人、审批人等仍为只读,仅未禁用字段可编辑。控制台 0 error(保留两条既有 React Aria 开发 warning)。
- 浏览器限制:Andy 是系统管理员且当前返回的 `applications` 为空,能够验证系统管理员旁路与 Detail `disableColumns` 不可旁路,但不能替代目标应用“管理”角色、普通用户和无权限用户矩阵。材料选择/上传/取消/部分失败/重试、通知 1/99/100 和动态 action 也没有制造业务数据验收;问题整改基准的独立登录会话未稳定建立。未使用伪造业务数据绕过这些限制。
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined'; import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import DownloadOutlined from '@ant-design/icons/DownloadOutlined'; import DownloadOutlined from '@ant-design/icons/DownloadOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined'; import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined'; import CheckOutlined from '@ant-design/icons/CheckOutlined';
...@@ -25,11 +26,16 @@ import UploadOutlined from '@ant-design/icons/UploadOutlined'; ...@@ -25,11 +26,16 @@ import UploadOutlined from '@ant-design/icons/UploadOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined'; import PlusOutlined from '@ant-design/icons/PlusOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined'; import WarningOutlined from '@ant-design/icons/WarningOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined'; import MoreOutlined from '@ant-design/icons/MoreOutlined';
import ExportOutlined from '@ant-design/icons/ExportOutlined';
function iconStyle(size, style) { 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({ className, size, style, ...props }) { return function AppIcon({ className, size, style, ...props }) {
return ( return (
...@@ -44,6 +50,7 @@ function withSize(Icon) { ...@@ -44,6 +50,7 @@ function withSize(Icon) {
} }
export const AppGrid = withSize(AppstoreOutlined); export const AppGrid = withSize(AppstoreOutlined);
export const Bell = withSize(BellOutlined);
export const Archive = withSize(ArchiveOutlined); export const Archive = withSize(ArchiveOutlined);
export const Calendar = withSize(CalendarOutlined); export const Calendar = withSize(CalendarOutlined);
export const Check = withSize(CheckOutlined); export const Check = withSize(CheckOutlined);
...@@ -54,6 +61,7 @@ export const Delete = withSize(DeleteOutlined); ...@@ -54,6 +61,7 @@ export const Delete = withSize(DeleteOutlined);
export const Download = withSize(DownloadOutlined); export const Download = withSize(DownloadOutlined);
export const Edit = withSize(EditOutlined); export const Edit = withSize(EditOutlined);
export const Eye = withSize(EyeOutlined); export const Eye = withSize(EyeOutlined);
export const OpenExternal = withSize(ExportOutlined);
export const FileDone = withSize(FileDoneOutlined); export const FileDone = withSize(FileDoneOutlined);
export const FileText = withSize(FileTextOutlined); export const FileText = withSize(FileTextOutlined);
export const Fullscreen = withSize(FullscreenOutlined); export const Fullscreen = withSize(FullscreenOutlined);
...@@ -62,8 +70,10 @@ export const LogOut = withSize(LogoutOutlined); ...@@ -62,8 +70,10 @@ export const LogOut = withSize(LogoutOutlined);
export const Moon = withSize(MoonOutlined); export const Moon = withSize(MoonOutlined);
export const More = withSize(MoreOutlined); export const More = withSize(MoreOutlined);
export const Reload = withSize(ReloadOutlined); export const Reload = withSize(ReloadOutlined);
export const ArrowPathIcon = Reload;
export const Send = withSize(SendOutlined); export const Send = withSize(SendOutlined);
export const Search = withSize(SearchOutlined); export const Search = withSize(SearchOutlined);
export const MagnifyingGlassIcon = Search;
export const Settings = withSize(SettingOutlined); export const Settings = withSize(SettingOutlined);
export const Sun = withSize(SunOutlined); export const Sun = withSize(SunOutlined);
export const Tool = withSize(ToolOutlined); export const Tool = withSize(ToolOutlined);
......
...@@ -15,7 +15,7 @@ function getInitialTheme() { ...@@ -15,7 +15,7 @@ function getInitialTheme() {
} }
} }
export default function AppShell({ children, open, onOpenChange, title, ...sidebarProps }) { export default function AppShell({ children, notificationCount, notificationPath, open, onOpenChange, title, ...sidebarProps }) {
const [theme, setTheme] = useState(getInitialTheme); const [theme, setTheme] = useState(getInitialTheme);
useLayoutEffect(() => { useLayoutEffect(() => {
...@@ -39,7 +39,13 @@ export default function AppShell({ children, open, onOpenChange, title, ...sideb ...@@ -39,7 +39,13 @@ export default function AppShell({ children, open, onOpenChange, title, ...sideb
> >
<DashboardSidebar theme={theme} {...sidebarProps} /> <DashboardSidebar theme={theme} {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main"> <Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar theme={theme} title={title} onThemeChange={setTheme} /> <DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined}
theme={theme}
title={title}
onThemeChange={setTheme}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto"> <div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div> <div className="rc-app-page">{children}</div>
</div> </div>
......
import xinyuanLogo from '@/assets/xy-logo-red.png'; import xinyuanLogo from '@/assets/xy-logo-red.png';
import { ExclamationTriangleIcon } from '@/components/AppIcons'; import { ExclamationTriangleIcon } from '@/components/AppIcons';
import { APPLICATION_DISPLAY_NAME, APPLICATION_ROUTE_NAME } from '@/config/application'; import { APPLICATION_DISPLAY_NAME, APPLICATION_NAME } from '@/config/application';
import { getApplications } from '@/services/DataService'; import { getApplications } from '@/services/DataService';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Avatar, Card, Chip, Description, Modal, Skeleton, Surface } from '@heroui/react'; import { Alert, Avatar, Card, Chip, Description, Modal, Skeleton, Surface } from '@heroui/react';
...@@ -15,7 +15,7 @@ function unwrapApplications(response) { ...@@ -15,7 +15,7 @@ function unwrapApplications(response) {
function normalizeApplication(application = {}) { function normalizeApplication(application = {}) {
const name = application.name || ''; const name = application.name || '';
const current = name === APPLICATION_ROUTE_NAME; const current = name === APPLICATION_NAME;
return { return {
name, name,
title: current ? APPLICATION_DISPLAY_NAME : application.title || name || '未命名应用', title: current ? APPLICATION_DISPLAY_NAME : application.title || name || '未命名应用',
...@@ -28,7 +28,7 @@ function normalizeApplication(application = {}) { ...@@ -28,7 +28,7 @@ function normalizeApplication(application = {}) {
function mergeApplications(fetched, fallbacks) { function mergeApplications(fetched, fallbacks) {
const merged = new Map(); const merged = new Map();
[...fallbacks, ...fetched, { name: APPLICATION_ROUTE_NAME, title: APPLICATION_DISPLAY_NAME }] [...fallbacks, ...fetched, { name: APPLICATION_NAME, title: APPLICATION_DISPLAY_NAME }]
.map(normalizeApplication) .map(normalizeApplication)
.filter((application) => application.name) .filter((application) => application.name)
.forEach((application) => merged.set(application.name, { ...merged.get(application.name), ...application })); .forEach((application) => merged.set(application.name, { ...merged.get(application.name), ...application }));
...@@ -77,7 +77,7 @@ function buildApplicationSections(applications, applicationGroups) { ...@@ -77,7 +77,7 @@ function buildApplicationSections(applications, applicationGroups) {
} }
function isCurrentApplication(application) { function isCurrentApplication(application) {
if (application.name === APPLICATION_ROUTE_NAME) return true; if (application.name === APPLICATION_NAME) return true;
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
try { try {
const target = new URL(application.url, window.location.origin); const target = new URL(application.url, window.location.origin);
...@@ -142,7 +142,7 @@ export default function ApplicationSwitcherDialog({ open, onClose, siteInfo, use ...@@ -142,7 +142,7 @@ export default function ApplicationSwitcherDialog({ open, onClose, siteInfo, use
const [status, setStatus] = useState('idle'); const [status, setStatus] = useState('idle');
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const currentApplicationName = useMemo( const currentApplicationName = useMemo(
() => applications.find(isCurrentApplication)?.name || APPLICATION_ROUTE_NAME, () => applications.find(isCurrentApplication)?.name || APPLICATION_NAME,
[applications], [applications],
); );
const applicationSections = useMemo( const applicationSections = useMemo(
......
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 { Button, Tooltip } from '@heroui/react'; import { Badge, Button, Tooltip } from '@heroui/react';
import { Moon, SidebarPanel, Sun } from './AppIcons'; 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 { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark'; const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式'; const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0);
return ( return (
<Navbar className="app-navbar rc-navbar" maxWidth="full"> <Navbar className="app-navbar rc-navbar" maxWidth="full">
...@@ -20,6 +21,26 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) { ...@@ -20,6 +21,26 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) {
<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={`通知消息,${normalizedNotificationCount}条未读`}
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>
<Tooltip delay={0}> <Tooltip delay={0}>
<Button <Button
isIconOnly isIconOnly
......
import { Upload } from '@/components/AppIcons';
import { DropZone } from '@heroui-pro/react/drop-zone';
import { useCallback } from 'react';
export default function FileUploadField({
accept,
ariaLabel = '选择文件',
className = '',
description = '将文件拖到此处,或从本地选择。',
isDisabled = false,
fileName = '',
multiple = false,
onFilesChange,
triggerLabel = '选择文件',
}) {
const emitFiles = useCallback(
(files) => {
if (!isDisabled) {
onFilesChange?.(Array.from(files || []));
}
},
[isDisabled, onFilesChange],
);
const handleDrop = useCallback(
async (event) => {
const fileItems = Array.from(event?.items || []).filter(
(item) => item?.kind === 'file' && typeof item.getFile === 'function',
);
if (fileItems.length === 0) return;
const files = await Promise.all(fileItems.map((item) => item.getFile()));
emitFiles(multiple ? files : files.slice(0, 1));
},
[emitFiles, multiple],
);
return (
<DropZone className={className}>
<DropZone.Area
aria-label={ariaLabel}
isDisabled={isDisabled}
onDrop={handleDrop}
>
<DropZone.Input
accept={accept}
disabled={isDisabled}
multiple={multiple}
onSelect={emitFiles}
/>
<DropZone.Icon>
<Upload aria-hidden="true" />
</DropZone.Icon>
<DropZone.Label>{fileName || '将文件拖到此处'}</DropZone.Label>
<DropZone.Description>
{fileName
? `已选择${multiple ? '文件,可继续添加。' : '文件,可重新选择进行替换。'}`
: description}
</DropZone.Description>
<DropZone.Trigger isDisabled={isDisabled}>
<Upload aria-hidden="true" />
{triggerLabel}
</DropZone.Trigger>
</DropZone.Area>
</DropZone>
);
}
import {
Calendar as HeroCalendar,
CloseButton,
DateField,
DatePicker,
I18nProvider,
TimeField,
} from '@heroui/react';
import { parseDate, parseDateTime, parseTime } from '@internationalized/date';
import { useMemo } from 'react';
import {
Calendar as CalendarIcon,
Close,
FIELD_SUFFIX_ICON_STYLE,
} from './AppIcons';
function parseValue(value, mode) {
if (!value) {
return null;
}
try {
if (mode === 'month') {
return parseDate(`${String(value).slice(0, 7)}-01`);
}
return mode === 'datetime'
? parseDateTime(String(value).slice(0, 16))
: parseDate(String(value).slice(0, 10));
} catch {
return null;
}
}
function formatValue(value, mode) {
if (!value) {
return '';
}
const rawValue = value.toString();
if (mode === 'month') {
return rawValue.slice(0, 7);
}
if (mode === 'date') {
return rawValue.slice(0, 10);
}
if (rawValue.includes('T')) {
return rawValue.slice(0, 16);
}
return `${rawValue.slice(0, 10)}T00:00`;
}
function parseTimeValue(value) {
if (!value || !String(value).includes('T')) {
return null;
}
try {
return parseTime(String(value).slice(11, 16));
} catch {
return null;
}
}
function formatTimeValue(value) {
return value ? value.toString().slice(0, 5) : '00:00';
}
function combineDateAndTime(value, dateValue, timeValue) {
const dateText = dateValue
? dateValue.toString().slice(0, 10)
: String(value || '').slice(0, 10);
if (!dateText) {
return '';
}
return `${dateText}T${formatTimeValue(timeValue)}`;
}
function emitDateChange(onChange, name, nextValue) {
onChange?.({
target: {
name,
value: nextValue,
},
});
}
export default function FormDatePicker({
'aria-label': ariaLabel,
className,
disabled = false,
granularity,
isClearable = true,
max,
min,
mode,
name,
onChange,
readOnly = false,
required = false,
type = 'date',
value,
variant,
...props
}) {
const resolvedMode =
mode === 'month' || type === 'month'
? 'month'
: mode === 'datetime' || type === 'datetime-local'
? 'datetime'
: 'date';
const resolvedGranularity =
granularity ||
(resolvedMode === 'datetime'
? 'minute'
: resolvedMode === 'month'
? 'month'
: 'day');
const parsedValue = useMemo(
() => parseValue(value, resolvedMode),
[resolvedMode, value],
);
const minValue = useMemo(() => parseValue(min, resolvedMode), [min, resolvedMode]);
const maxValue = useMemo(() => parseValue(max, resolvedMode), [max, resolvedMode]);
const timeValue = useMemo(() => parseTimeValue(value), [value]);
const canClear =
isClearable && Boolean(value) && !disabled && !readOnly && !required;
return (
<I18nProvider locale="zh-CN">
<DatePicker
{...props}
aria-label={ariaLabel || '选择日期'}
className={['w-full', className].filter(Boolean).join(' ')}
granularity={resolvedGranularity}
isDisabled={disabled}
isReadOnly={readOnly}
isRequired={required}
maxValue={maxValue || undefined}
minValue={minValue || undefined}
value={parsedValue}
onChange={(nextValue) =>
emitDateChange(onChange, name, formatValue(nextValue, resolvedMode))
}
>
<DateField.Group className="relative" fullWidth variant={variant}>
<DateField.Input className={canClear ? 'pe-14' : 'pe-8'}>
{(segment) => (
<DateField.Segment segment={segment} />
)}
</DateField.Input>
<DateField.Suffix className="absolute inset-y-0 end-1 mr-0 gap-0">
{canClear ? (
<CloseButton
aria-label={`清空${ariaLabel || '日期'}`}
onPress={() => emitDateChange(onChange, name, '')}
>
<Close
className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size={16}
style={FIELD_SUFFIX_ICON_STYLE}
/>
</CloseButton>
) : null}
<DatePicker.Trigger
aria-label="打开日期选择"
className="size-6 w-6 shrink-0 justify-center p-1 text-field-placeholder"
>
<DatePicker.TriggerIndicator>
<CalendarIcon
className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size={16}
style={FIELD_SUFFIX_ICON_STYLE}
/>
</DatePicker.TriggerIndicator>
</DatePicker.Trigger>
</DateField.Suffix>
</DateField.Group>
<DatePicker.Popover placement="bottom start">
<HeroCalendar>
<HeroCalendar.Header>
<HeroCalendar.NavButton slot="previous" />
<HeroCalendar.Heading />
<HeroCalendar.NavButton slot="next" />
</HeroCalendar.Header>
<HeroCalendar.Grid>
<HeroCalendar.GridHeader>
{(day) => <HeroCalendar.HeaderCell>{day}</HeroCalendar.HeaderCell>}
</HeroCalendar.GridHeader>
<HeroCalendar.GridBody>
{(date) => <HeroCalendar.Cell date={date} />}
</HeroCalendar.GridBody>
</HeroCalendar.Grid>
</HeroCalendar>
{resolvedMode === 'datetime' ? (
<div className="flex items-center justify-between gap-3 p-3">
<span className="text-sm text-muted">时间</span>
<TimeField
aria-label="选择时间"
className="w-36"
granularity="minute"
hideTimeZone
hourCycle={24}
value={timeValue}
onChange={(nextTime) =>
emitDateChange(
onChange,
name,
combineDateAndTime(value, parsedValue, nextTime),
)
}
>
<TimeField.Group fullWidth variant={variant}>
<TimeField.Input>
{(segment) => (
<TimeField.Segment segment={segment} />
)}
</TimeField.Input>
</TimeField.Group>
</TimeField>
</div>
) : null}
</DatePicker.Popover>
</DatePicker>
</I18nProvider>
);
}
This diff is collapsed.
import {
ChevronDown,
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]);
}
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 = '请选择',
placement = 'bottom start',
popoverHeader,
renderOption,
searchPlaceholder = '输入关键词搜索',
selectedKey,
selectedKeys,
selectionMode = 'single',
shouldFilter = true,
valueText,
variant,
}) {
const selectedKeySet = getSelectedKeySet(
selectionMode,
selectedKey,
selectedKeys,
);
const selectedOptions = options.filter((option) =>
selectedKeySet.has(option.id),
);
const resolvedValueText =
valueText ??
(selectedOptions.length > 0
? selectedOptions.map((option) => option.label).join('、')
: placeholder);
const rootSelectionProps =
selectionMode === 'multiple'
? { selectedKeys }
: { selectedKey };
const visibleOptions = isLoading || errorText ? [] : options;
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={resolvedValueText}
>
{resolvedValueText}
</span>
</Autocomplete.Value>
<Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover placement={placement}>
<Autocomplete.Filter
filter={shouldFilter ? filter || defaultFilter : () => true}
inputValue={inputValue}
onInputChange={onInputChange}
>
{popoverHeader}
<SearchField
aria-label={searchPlaceholder}
fullWidth
variant={variant}
>
<SearchField.Group>
<SearchField.SearchIcon>
<Search size={16} />
</SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder={searchPlaceholder} />
<SearchField.ClearButton aria-label="清空搜索">
<Close size={14} />
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
<ListBox
aria-label={ariaLabel || placeholder}
items={visibleOptions}
renderEmptyState={() => (
<div
className="grid min-h-20 place-items-center px-3 py-4"
data-slot="empty-state"
>
{isLoading ? <Spinner size="sm" /> : errorText || emptyText}
</div>
)}
>
{(option) => (
<ListBox.Item
id={option.id}
isDisabled={option.isDisabled}
textValue={option.textValue || option.label}
>
{renderOption ? (
renderOption(option)
) : (
<Label className="truncate" title={option.label}>
{option.label}
</Label>
)}
<ListBox.ItemIndicator />
</ListBox.Item>
)}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
);
}
...@@ -175,6 +175,7 @@ function WorkbenchPagination({ pagination }) { ...@@ -175,6 +175,7 @@ function WorkbenchPagination({ pagination }) {
} }
export default function RecordWorkbench({ export default function RecordWorkbench({
accessDenied = false,
views = EMPTY_ARRAY, views = EMPTY_ARRAY,
activeViewName, activeViewName,
onViewChange, onViewChange,
...@@ -370,6 +371,9 @@ export default function RecordWorkbench({ ...@@ -370,6 +371,9 @@ export default function RecordWorkbench({
) : null} ) : null}
{states.error ? <Alert status="danger"><Alert.Content><Alert.Description>{states.error}</Alert.Description></Alert.Content></Alert> : null} {states.error ? <Alert status="danger"><Alert.Content><Alert.Description>{states.error}</Alert.Description></Alert.Content></Alert> : null}
<div className="relative min-w-0" ref={gridWrapRef}> <div className="relative min-w-0" ref={gridWrapRef}>
{accessDenied ? (
<DataGridEmpty title="当前账号没有可访问的数据视图" />
) : (
<DataGrid <DataGrid
showSelectionCheckboxes={Boolean(onSelectionChange)} showSelectionCheckboxes={Boolean(onSelectionChange)}
allowsColumnResize allowsColumnResize
...@@ -387,12 +391,13 @@ export default function RecordWorkbench({ ...@@ -387,12 +391,13 @@ export default function RecordWorkbench({
verticalAlign="middle" verticalAlign="middle"
onSelectionChange={onSelectionChange ? handleSelectionChange : undefined} onSelectionChange={onSelectionChange ? handleSelectionChange : undefined}
/> />
)}
{states.loading && records.length > 0 ? <Surface className="absolute inset-0 grid place-items-center" variant="secondary"><Spinner size="sm" /></Surface> : null} {states.loading && records.length > 0 ? <Surface className="absolute inset-0 grid place-items-center" variant="secondary"><Spinner size="sm" /></Surface> : null}
</div> </div>
<WorkbenchPagination pagination={pagination} /> {!accessDenied ? <WorkbenchPagination pagination={pagination} /> : null}
</section> </section>
<ActionBar aria-label="批量操作" isOpen={selectedIds.length > 0}> <ActionBar aria-label="批量操作" isOpen={!accessDenied && selectedIds.length > 0}>
<ActionBar.Prefix><span className="whitespace-nowrap text-sm text-muted">已选择 {selectedIds.length} 条记录</span></ActionBar.Prefix> <ActionBar.Prefix><span className="whitespace-nowrap text-sm text-muted">已选择 {selectedIds.length} 条记录</span></ActionBar.Prefix>
{visibleSelectionActions.length > 0 ? ( {visibleSelectionActions.length > 0 ? (
<ActionBar.Content> <ActionBar.Content>
......
export const APPLICATION_DISPLAY_NAME = '廉洁从业管理'; export const APPLICATION_DISPLAY_NAME = '廉洁从业管理';
export const APPLICATION_ROUTE_NAME = 'integrity_practice_management'; export const APPLICATION_NAME = 'integrity_practice_management';
export const APPLICATION_BASE_PATH = '/integrity_practice_management/'; export const APPLICATION_ROUTE_NAME = APPLICATION_NAME;
export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME;
export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`;
export const THEME_STORAGE_KEY = 'xinyuan_color_theme'; export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({ export const ROUTE_TITLES = Object.freeze({
...@@ -11,4 +13,5 @@ export const ROUTE_TITLES = Object.freeze({ ...@@ -11,4 +13,5 @@ export const ROUTE_TITLES = Object.freeze({
'/self_check_the_draft_template': '自查底稿模版', '/self_check_the_draft_template': '自查底稿模版',
'/self_check_the_working_draft': '自查底稿项目', '/self_check_the_working_draft': '自查底稿项目',
'/self_inspection_annual_submission_materials': '历年报送材料', '/self_inspection_annual_submission_materials': '历年报送材料',
'/notifications': '通知消息',
}); });
import clsx from 'clsx'; import clsx from 'clsx';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { Surface } from '@heroui/react';
import TailwindEditableField from './TailwindEditableField'; import TailwindEditableField from './TailwindEditableField';
import { import {
buildViewColumns, buildViewColumns,
getFieldValue, getFieldValue,
isFieldDisabledByView, isFieldDisabledByView,
isReferenceFieldType,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
const FIELD_LABEL_OVERRIDES = {
problem_discovery_situation: '问题发现情况',
};
const FULL_WIDTH_FIELD_TYPES = new Set([
'long_text',
'rich_text',
'object',
'form',
'subject',
'file',
'reference',
'document',
'view',
'template',
'task',
]);
function getFieldName(fieldPath = '') {
return String(fieldPath).replace(/^metadata\./, '').split('.').pop();
}
function getFieldLabel(field) {
const fieldName = getFieldName(field?.field);
const configuredLabel = String(field?.label || '').trim();
const isTechnicalLabel =
!configuredLabel ||
configuredLabel === field?.field ||
configuredLabel === fieldName ||
configuredLabel.startsWith('metadata.');
if (!isTechnicalLabel) {
return configuredLabel;
}
return FIELD_LABEL_OVERRIDES[fieldName] || field?.fieldDef?.label || '其他信息';
}
export default function SubjectViewContent({ export default function SubjectViewContent({
view, view,
subject, subject,
editableRecord, editableRecord,
onFieldUpdate, editSessionId,
mode = 'view', mode = 'view',
onFieldChange,
onPendingLocalMaterialChange,
onFieldValidation,
readonly, readonly,
isFieldDisabled, isFieldDisabled,
onOpenReference, minimal = false,
useAuditMaterialCenter = false,
}) { }) {
const displayFields = buildViewColumns(view, subject); const displayFields = buildViewColumns(view, subject);
const fullWidthFieldTypes = new Set([ const contentFields = minimal
'long_text', ? (displayFields || []).filter((field) => field.field !== 'title')
'rich_text', : displayFields;
'object', const fieldVariant = 'secondary';
'form',
'subject',
'file',
'reference',
]);
const handleFieldUpdate = (fieldPath, newValue, fieldDef) => { if (!contentFields || contentFields.length === 0) {
onFieldUpdate(fieldPath, newValue, fieldDef); if (minimal) {
}; return null;
}
if (!displayFields || displayFields.length === 0) {
return ( return (
<EmptyState size="md"> <EmptyState size="md">
<EmptyState.Header><EmptyState.Title>暂无可显示的字段</EmptyState.Title></EmptyState.Header> <EmptyState.Header><EmptyState.Title>暂无可显示的字段</EmptyState.Title></EmptyState.Header>
...@@ -41,43 +78,68 @@ export default function SubjectViewContent({ ...@@ -41,43 +78,68 @@ export default function SubjectViewContent({
); );
} }
return ( const fields = (
<section className="min-w-0"> <section
<div className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2"> className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2"
{displayFields.map((field) => { >
{contentFields.map((field) => {
const value = getFieldValue(editableRecord, field.field); const value = getFieldValue(editableRecord, field.field);
const fieldDisabled = const fieldType = field.fieldDef?.type || 'text';
isFieldDisabled?.(field.field, view) || const fieldLabel = getFieldLabel(field);
isFieldDisabledByView(field.field, view); const isFullWidth = FULL_WIDTH_FIELD_TYPES.has(fieldType);
const isFullWidth = const fieldDisabled = isFieldDisabled
fullWidthFieldTypes.has(field.fieldDef?.type) || ? isFieldDisabled(field.field, view)
isReferenceFieldType(field.fieldDef?.type) || : isFieldDisabledByView(field.field, view);
field.fieldDef?.multiple && field.fieldDef?.type !== 'select';
return ( return (
<section <div
key={field.field} key={field.field}
className={clsx( className={clsx(
'min-w-0 py-3', 'min-w-0',
minimal ? 'py-1' : 'py-3',
isFullWidth && 'md:col-span-2', isFullWidth && 'md:col-span-2',
)} )}
> >
<div className="text-sm font-medium text-muted">{field.label}</div> <div className={minimal ? 'sr-only' : 'text-sm font-medium text-muted'}>
<div className="mt-1.5 min-w-0"> {fieldLabel}
</div>
<div className={minimal ? undefined : 'mt-1.5 min-w-0'}>
<TailwindEditableField <TailwindEditableField
value={value} value={value}
fieldDef={field.fieldDef} fieldDef={field.fieldDef}
onSave={(newValue) => handleFieldUpdate(field.field, newValue, field.fieldDef)} fieldPath={field.field}
editSessionId={editSessionId}
fieldVariant={fieldVariant}
placeholder={`请输入${fieldLabel}`}
mode={mode} mode={mode}
onChange={(newValue) =>
onFieldChange?.(
field.field,
newValue,
field.fieldDef,
editSessionId,
)
}
onValidationChange={onFieldValidation}
onPendingLocalMaterialChange={onPendingLocalMaterialChange}
readonly={readonly} readonly={readonly}
disabled={fieldDisabled} disabled={fieldDisabled}
onOpenReference={onOpenReference} useAuditMaterialCenter={useAuditMaterialCenter}
/> />
</div> </div>
</section> </div>
); );
})} })}
</div>
</section> </section>
); );
if (mode === 'view' && !minimal) {
return (
<Surface className="px-5 py-2 sm:px-6 sm:py-3" variant="secondary">
{fields}
</Surface>
);
}
return fields;
} }
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
getSubjectView, getSubjectView,
getSubjectViewDetail,
loadRecord, loadRecord,
loadSubject, loadSubject,
updateRecord, updateRecord,
...@@ -37,6 +38,14 @@ function transformSaveValue(newValue, fieldDef) { ...@@ -37,6 +38,14 @@ function transformSaveValue(newValue, fieldDef) {
return newValue === '' ? null : Number(newValue); return newValue === '' ? null : Number(newValue);
} }
if (fieldDef?.type === 'object' && typeof newValue === 'string') {
try {
return JSON.parse(newValue);
} catch (error) {
throw new Error(`${fieldDef?.label || '对象字段'}的 JSON 格式不正确,请检查后再保存。`);
}
}
return newValue; return newValue;
} }
...@@ -66,10 +75,8 @@ export function useSubjectRecord( ...@@ -66,10 +75,8 @@ export function useSubjectRecord(
const [subject, setSubject] = useState(null); const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]); const [views, setViews] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const requestIdRef = useRef(0); const requestIdRef = useRef(0);
const pendingPayloadRef = useRef({});
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -91,14 +98,31 @@ export function useSubjectRecord( ...@@ -91,14 +98,31 @@ export function useSubjectRecord(
try { try {
const record = await loadRecord(recordName); const record = await loadRecord(recordName);
const resolvedSubjectName = subjectName || record?.__t || record?.subject?.name || record?.subject; 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)]) ? await Promise.all([loadSubject(resolvedSubjectName), getSubjectView(resolvedSubjectName)])
: [null, []]; : [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) { if (!cancelled && requestIdRef.current === requestId) {
setRemoteRecord(record); setRemoteRecord(record);
setEditableRecord(JSON.parse(JSON.stringify(record))); setEditableRecord(JSON.parse(JSON.stringify(record)));
pendingPayloadRef.current = {};
setSubject(subjectData); setSubject(subjectData);
setViews(viewsData || []); setViews(viewsData || []);
} }
...@@ -124,14 +148,26 @@ export function useSubjectRecord( ...@@ -124,14 +148,26 @@ export function useSubjectRecord(
}; };
}, [recordName, refreshKey, subjectName]); }, [recordName, refreshKey, subjectName]);
const handleFieldUpdate = (fieldPath, newValue, fieldDef) => { const handleFieldsUpdate = async (changes = []) => {
if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) { if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) {
return; return;
} }
let nextRecord = setFieldValue(editableRecord, fieldPath, newValue); const normalizedChanges = changes.filter((change) => change?.fieldPath);
if (normalizedChanges.length === 0) {
return;
}
const previousRecord = editableRecord;
let nextRecord = editableRecord;
let payload = {};
try {
normalizedChanges.forEach(({ fieldPath, newValue, fieldDef }) => {
const saveValue = transformSaveValue(newValue, fieldDef); const saveValue = transformSaveValue(newValue, fieldDef);
let payload = buildNestedPayload(fieldPath, saveValue); nextRecord = setFieldValue(nextRecord, fieldPath, saveValue);
payload = mergePayload(payload, buildNestedPayload(fieldPath, saveValue));
const derivedFieldValues = deriveFieldValues?.({ const derivedFieldValues = deriveFieldValues?.({
fieldPath, fieldPath,
fieldDef, fieldDef,
...@@ -143,31 +179,20 @@ export function useSubjectRecord( ...@@ -143,31 +179,20 @@ export function useSubjectRecord(
nextRecord = setFieldValue(nextRecord, derivedFieldPath, derivedValue); nextRecord = setFieldValue(nextRecord, derivedFieldPath, derivedValue);
payload = mergePayload(payload, buildNestedPayload(derivedFieldPath, derivedValue)); payload = mergePayload(payload, buildNestedPayload(derivedFieldPath, derivedValue));
}); });
});
pendingPayloadRef.current = mergePayload(pendingPayloadRef.current, payload);
setEditableRecord(nextRecord); setEditableRecord(nextRecord);
};
const resetDraft = () => {
pendingPayloadRef.current = {};
setEditableRecord(remoteRecord ? JSON.parse(JSON.stringify(remoteRecord)) : null);
};
const saveDraft = async () => {
if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) return;
const payload = pendingPayloadRef.current;
if (Object.keys(payload).length === 0) return;
setSaving(true);
try {
await updateRecord(recordName, payload); await updateRecord(recordName, payload);
setRemoteRecord(JSON.parse(JSON.stringify(editableRecord))); setRemoteRecord(nextRecord);
pendingPayloadRef.current = {}; } catch (updateError) {
} finally { setEditableRecord(previousRecord);
setSaving(false); throw updateError;
} }
}; };
const handleFieldUpdate = (fieldPath, newValue, fieldDef) =>
handleFieldsUpdate([{ fieldPath, newValue, fieldDef }]);
const isFieldDisabled = (fieldPath, currentView) => { const isFieldDisabled = (fieldPath, currentView) => {
return ( return (
fieldPath === 'is_locked' || fieldPath === 'is_locked' ||
...@@ -184,9 +209,7 @@ export function useSubjectRecord( ...@@ -184,9 +209,7 @@ export function useSubjectRecord(
views, views,
error, error,
handleFieldUpdate, handleFieldUpdate,
resetDraft, handleFieldsUpdate,
saveDraft,
saving,
isFieldDisabled, isFieldDisabled,
loading, loading,
}; };
......
This diff is collapsed.
import AppShell from '@/components/AppShell'; import AppShell from '@/components/AppShell';
import { Archive, CheckSquare, FileDone, FileText, Settings } from '@/components/AppIcons'; import { Archive, CheckSquare, FileDone, FileText, Settings } from '@/components/AppIcons';
import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog'; import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog';
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from './notifications/notificationService';
import { APPLICATION_DISPLAY_NAME, ROUTE_TITLES } from '@/config/application'; import { APPLICATION_DISPLAY_NAME, ROUTE_TITLES } from '@/config/application';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
...@@ -25,6 +29,7 @@ export default function IntegrityPracticeLayout() { ...@@ -25,6 +29,7 @@ export default function IntegrityPracticeLayout() {
const siteInfo = useContext(SiteContext); const siteInfo = useContext(SiteContext);
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [switcherOpen, setSwitcherOpen] = useState(false); const [switcherOpen, setSwitcherOpen] = useState(false);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const pageTitle = useMemo( const pageTitle = useMemo(
() => ROUTE_TITLES[location.pathname] || APPLICATION_DISPLAY_NAME, () => ROUTE_TITLES[location.pathname] || APPLICATION_DISPLAY_NAME,
[location.pathname], [location.pathname],
...@@ -34,10 +39,32 @@ export default function IntegrityPracticeLayout() { ...@@ -34,10 +39,32 @@ export default function IntegrityPracticeLayout() {
document.title = `${pageTitle} - ${APPLICATION_DISPLAY_NAME}`; document.title = `${pageTitle} - ${APPLICATION_DISPLAY_NAME}`;
}, [pageTitle]); }, [pageTitle]);
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]);
return ( return (
<> <>
<AppShell <AppShell
menuGroups={MENU_GROUPS} menuGroups={MENU_GROUPS}
notificationCount={unreadNotificationCount}
notificationPath="/notifications"
onLogout={userInfo?.logout} onLogout={userInfo?.logout}
onOpenApplicationSwitcher={() => setSwitcherOpen(true)} onOpenApplicationSwitcher={() => setSwitcherOpen(true)}
open={sidebarOpen} open={sidebarOpen}
......
...@@ -94,6 +94,7 @@ export default function SubjectWorkbench() { ...@@ -94,6 +94,7 @@ export default function SubjectWorkbench() {
() => views.find((view) => view.name === activeViewName) || views[0] || null, () => views.find((view) => view.name === activeViewName) || views[0] || null,
[activeViewName, views], [activeViewName, views],
); );
const hasAccessibleView = views.length > 0;
const columns = useMemo( const columns = useMemo(
() => buildSubjectColumns(activeView, subject), () => buildSubjectColumns(activeView, subject),
[activeView, subject], [activeView, subject],
...@@ -274,6 +275,7 @@ export default function SubjectWorkbench() { ...@@ -274,6 +275,7 @@ export default function SubjectWorkbench() {
<Toast.Provider placement="top" /> <Toast.Provider placement="top" />
<RecordWorkbench <RecordWorkbench
accessDenied={!loading && !hasAccessibleView}
views={views.map((view) => ({ ...view }))} views={views.map((view) => ({ ...view }))}
activeViewName={activeViewName} activeViewName={activeViewName}
onViewChange={(viewName) => { onViewChange={(viewName) => {
...@@ -307,17 +309,17 @@ export default function SubjectWorkbench() { ...@@ -307,17 +309,17 @@ export default function SubjectWorkbench() {
columns={columns} columns={columns}
records={records} records={records}
selectedIds={selectedIds} selectedIds={selectedIds}
onSelectionChange={setSelectedIds} onSelectionChange={hasAccessibleView ? setSelectedIds : undefined}
toolbarActions={toolbarActions} toolbarActions={hasAccessibleView ? toolbarActions : EMPTY_ARRAY}
selectionActions={selectionActions} selectionActions={hasAccessibleView ? selectionActions : EMPTY_ARRAY}
rowActions={rowActions} rowActions={hasAccessibleView ? rowActions : EMPTY_ARRAY}
states={{ states={{
loading, loading,
error, error,
loadingTitle: '正在加载业务数据', loadingTitle: '正在加载业务数据',
loadingDescription: '正在读取对象结构、视图配置和记录数据。', loadingDescription: '正在读取对象结构、视图配置和记录数据。',
}} }}
detailPanel={{ detailPanel={hasAccessibleView ? {
getRecordName: (record) => record?.name, getRecordName: (record) => record?.name,
onDirtyClose: () => setRefreshKey((value) => value + 1), onDirtyClose: () => setRefreshKey((value) => value + 1),
render: ({ open, recordName, initialMode, onClose, onDataChange }) => ( render: ({ open, recordName, initialMode, onClose, onDataChange }) => (
...@@ -329,9 +331,11 @@ export default function SubjectWorkbench() { ...@@ -329,9 +331,11 @@ export default function SubjectWorkbench() {
onClose={onClose} onClose={onClose}
onDataChange={onDataChange} onDataChange={onDataChange}
filterView={subjectConfig?.drawerConfig?.filterView || EMPTY_ARRAY} filterView={subjectConfig?.drawerConfig?.filterView || EMPTY_ARRAY}
authorizationView={activeView}
useAuditMaterialCenter
/> />
), ),
}} } : null}
pagination={ pagination={
pagination pagination
? { ? {
......
...@@ -21,21 +21,23 @@ ...@@ -21,21 +21,23 @@
## 2. 列表与视图流程 ## 2. 列表与视图流程
1. 进入页面后并行调用 `GET /subject/:name` 和 `GET /subject/:name/view`。 1. 进入页面后并行调用 `GET /subject/:name` 和 `GET /subject/:name/view`;打开详情后再按 View 名调用 `GET /subject/:subjectName/view/:viewName` 获取完整 Detail 配置。
2. 只展示当前用户有权访问的 `grid` 视图;配置包含 `filterView` 且非空时,只展示指定视图。 2. 只展示当前用户有权访问的 `grid` 视图;授权只读取 `user.applications[]` 中 `name = integrity_practice_management` 的项目。系统管理员、系统账号和该应用“管理”角色可旁路,但不能旁路 `disableColumns`。配置包含 `filterView` 且非空时,只展示指定视图。
3. 切换视图后,根据视图的 `settings.restrictions`、`settings.filters.conditions`、列排序配置调用 `POST /record/query`。 3. 切换视图后,根据视图的 `settings.restrictions`、`settings.filters.conditions`、列排序配置调用 `POST /record/query`。
4. `gridRecordConfig.pagination = false` 时单次最多读取 1000 条;其余页面使用服务端分页。 4. `gridRecordConfig.pagination = false` 时单次最多读取 1000 条;其余页面使用服务端分页。
5. 关键词查找通过 `/record/query` 的 `keyword` 查询标题;清空筛选后回到第 1 页。 5. 关键词查找通过 `/record/query` 的 `keyword` 查询标题;清空筛选后回到第 1 页。
6. 列定义、列顺序、初始排序、固定列和字段显示格式由 Subject View 与 Subject 字段定义共同决定;HeroUI Pro DataGrid 仅在真实横向溢出时固定首列和操作列,并允许当前页原生列排序和列宽调整,不改变服务端分页口径。 6. 列定义、列顺序、初始排序、固定列和字段显示格式由 Subject View 与 Subject 字段定义共同决定;HeroUI Pro DataGrid 仅在真实横向溢出时固定首列和操作列,并允许当前页原生列排序和列宽调整,不改变服务端分页口径。
`settings.shortTitle === true` 时只隐藏 title 展示列,标题仍参与查询、记录身份和保存;false 或未配置时正常展示。
7. DataGrid 使用原生 Checkbox 选择;选中后显示 HeroUI Pro ActionBar。行操作 1-3 项直接展示,4 项以上展示前两项与“更多”菜单,删除等危险动作始终排在最后。 7. DataGrid 使用原生 Checkbox 选择;选中后显示 HeroUI Pro ActionBar。行操作 1-3 项直接展示,4 项以上展示前两项与“更多”菜单,删除等危险动作始终排在最后。
8. 点击列表操作列的预览图标打开 HeroUI Modal 并进入只读查看态;首个业务列只展示记录文本,不再承担预览交互。点击弹窗统一的“编辑”按钮或列表操作列铅笔图标进入整体编辑态,保存后返回查看态。字段权限、锁定规则和 Subject 字段类型保持不变。 8. 点击列表操作列的预览图标打开 HeroUI Modal 并进入只读查看态;首个业务列只展示记录文本,不再承担预览交互。点击弹窗统一的“编辑”按钮或列表操作列铅笔图标进入整体编辑态,保存后返回查看态。字段权限、锁定规则和 Subject 字段类型保持不变。
9. 编辑态远程引用字段通过 HeroUI 弹层搜索选择。多值字段在触发器中显示已选数量,可在弹层内连续选择、取消或清空;单值字段选中后关闭弹层。候选标题过长时单行省略但保留全文提示,写入值仍严格为 `_id` 或 `_id[]`,不改变保存时机和 `metadata` 协议。 9. 当前 Detail 有有效 `settings.auth` 或 `settings.disableColumns` 时,字段权限整套使用 Detail;两项都未配置或没有实际 Detail 时,整体回退打开详情的当前 Grid。两套权限不合并、不逐字段回退;`disableColumns` 对管理员也始终只读,普通用户在选定权限来源中缺少字段 `auth` 时只读。切换 Detail 后立即重算字段状态和编辑入口,编辑入口只检查当前 Detail 展示字段。Grid `viewAuth` 只控制 Tab 可见性,不隐藏详情字段。
9. 编辑态的单选和多选属性使用 HeroUI Select/Autocomplete,日期使用 DatePicker,附件使用 HeroUI Pro DropZone;选择后立即更新当前弹窗草稿,最终仍由弹窗底部统一“保存”提交。 10. 编辑态远程引用字段通过 HeroUI 弹层搜索选择。多值字段在触发器中显示已选数量,可在弹层内连续选择、取消或清空;单值字段选中后关闭弹层。候选标题过长时单行省略但保留全文提示,写入值仍严格为 `_id` 或 `_id[]`,不改变保存时机和 `metadata` 协议。
11. 编辑态的单选和多选属性使用 HeroUI Select/Autocomplete,日期使用 DatePicker,附件使用 HeroUI Pro DropZone;选择后立即更新当前弹窗草稿,最终仍由弹窗底部统一“保存”提交。
异常处理: 异常处理:
- Subject、视图或记录加载失败时,使用 HeroUI Alert/EmptyState 显示错误或空态,不使用猜测字段兜底。 - Subject、视图或记录加载失败时,使用 HeroUI Alert/EmptyState 显示错误或空态,不使用猜测字段兜底。
- 后端没有可用 `grid` 视图时,使用“全部”临时视图并按 Subject 字段生成列。 - 没有可访问 `grid` 视图时不创建兜底 View、不查询记录,并隐藏新增、批量和行操作;只有一个可访问 View 时隐藏 Tabs 轨道。
- 配置动作执行期间禁用重复点击;成功后使用顶部 Toast,失败时保留当前列表并显示服务端错误。 - 配置动作执行期间禁用重复点击;成功后使用顶部 Toast,失败时保留当前列表并显示服务端错误。
## 3. 记录选择与动作规则 ## 3. 记录选择与动作规则
...@@ -127,3 +129,17 @@ ...@@ -127,3 +129,17 @@
- `POST /record/batch/remove` - `POST /record/batch/remove`
所有业务字段写入 `metadata`;引用字段、人员字段和归档关联字段只保存 `_id` 或 `_id[]`。日期由产生该状态的动作统一写入 ISO 8601 或历史协议明确要求的年份字符串。 所有业务字段写入 `metadata`;引用字段、人员字段和归档关联字段只保存 `_id` 或 `_id[]`。日期由产生该状态的动作统一写入 ISO 8601 或历史协议明确要求的年份字符串。
## 9. 通知中心
- Navbar Bell 进入 `/notifications`,Sidebar 不增加通知项;未读数来自 `GET /notice/customer/count`,通知变更事件和 60 秒轮询刷新。
- 通知页按消息/待办、全部/未读、300ms 关键词、每页 10 条查询 `POST /notice/customer/query`;未读状态传服务端 `status = unread`。
- 单条和全部已读调用 `PUT /notice/customer/read`;待办动态 action 保留服务端 action/method 名并只提交 `{notice_id}`。
- 正文只按纯文本渲染。仅去除本应用 `/integrity_practice_management` base 后站内跳转;其它应用和外部链接新窗口打开。
## 10. 文件与审计材料中心
- 所有运行时可编辑 `file` 字段按 `multiple` 提供“材料中心 / 本地上传”。材料中心查询 `am_material` 最近 20 条并远程搜索材料标题、文件名。
- 选择材料只保存 `{name,type,size,url}` 快照;单值替换,多值追加并按 URL 去重。清空附件不删除材料主档。
- 本地文件先调用 `/upload` 写入详情草稿;取消编辑不建档。点击保存后,对仍保留的本地文件逐个创建 `am_material`,`source_app = integrity_practice_management`,全部成功后才更新业务 Record。
- 同一编辑会话记录已建档文件;材料部分成功或业务更新失败后重试只处理未建档项,避免重复材料。
import dayjs from 'dayjs';
import { createRecord, queryRecords, uploadFile } from '@/services/DataService';
export const AUDIT_MATERIAL_SUBJECT = 'am_material';
export const INTEGRITY_PRACTICE_SOURCE_APP = 'integrity_practice_management';
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 || '',
sourceApp: metadata.source_app || '',
uploadedAt: metadata.uploaded_at || record.created_at || '',
};
}
export async function queryAuditMaterials(keyword = '') {
const normalizedKeyword = String(keyword || '').trim();
const escapedKeyword = escapeRegExp(normalizedKeyword);
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 uploadIntegrityPracticeFile(file) {
if (!file) {
throw new Error('请选择要上传的本地文件。');
}
const uploadResult = await uploadFile(file);
return normalizeFileAsset(uploadResult, file);
}
export async function createIntegrityPracticeMaterial({ 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: INTEGRITY_PRACTICE_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,
};
}
...@@ -81,10 +81,7 @@ export async function loadSubjectResources(subjectName, config = {}) { ...@@ -81,10 +81,7 @@ export async function loadSubjectResources(subjectName, config = {}) {
return { return {
subject, subject,
views: views: availableViews,
availableViews.length > 0
? availableViews
: [{ name: 'all', title: '全部', type: 'grid', settings: {} }],
}; };
} }
......
import {
exacteActionFunction,
queryUserNotice,
readUserNotice,
unReadNoticeCount,
} from '@/services/DataService';
export const NOTIFICATION_PAGE_SIZE = 10;
export const NOTIFICATION_STATE_EVENT = 'integrity-practice-management: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,
totalPages: Number(response?.total_pages ?? 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_practice_management\/[^\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.pathname.startsWith('/integrity_practice_management/')) {
return resolveNotificationTarget(`${url.pathname}${url.search}${url.hash}`);
}
return { href: url.toString(), external: true };
} catch (error) {
return null;
}
}
const appPrefix = '/integrity_practice_management';
if (!normalizedTarget.startsWith(`${appPrefix}/`)) {
return normalizedTarget.startsWith('/')
? { href: normalizedTarget, external: true }
: null;
}
return {
href: normalizedTarget.slice(appPrefix.length),
external: false,
};
}
export function notifyNotificationStateChanged() {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(NOTIFICATION_STATE_EVENT));
}
}
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { AUTHORIZATION_APPLICATION_NAME } from '@/config/application';
export function normalizeText(value) { export function normalizeText(value) {
return String(value ?? '').trim(); return String(value ?? '').trim();
...@@ -57,62 +58,74 @@ export function getCurrentUserFromStorage() { ...@@ -57,62 +58,74 @@ export function getCurrentUserFromStorage() {
} }
} }
function collectUserGroups(user) { function collectIdentityValues(values) {
const directGroups = toArray(user?.groups) return toArray(values)
.flatMap((group) => [group?._id, group?.name, group?.display_name, group]) .flatMap((value) => value && typeof value === 'object'
? [value._id, value.name, value.display_name, value.displayName, value.username]
: value)
.map(normalizeText) .map(normalizeText)
.filter(Boolean); .filter(Boolean);
const appGroups = toArray(user?.applications).flatMap((app) => app?.groups || []);
return [...directGroups, ...appGroups].map(normalizeText).filter(Boolean);
} }
function collectUserRoles(user) { function getUserApplication(user, applicationName = AUTHORIZATION_APPLICATION_NAME) {
const directRoles = toArray(user?.roles); return toArray(user?.applications).find(
const appRoles = toArray(user?.applications).flatMap((app) => app?.roles || []); (application) => normalizeText(application?.name) === normalizeText(applicationName),
return [...directRoles, ...appRoles].map(normalizeText).filter(Boolean); ) || null;
} }
function collectUserPermissions(user) { function collectApplicationAuthorization(user, applicationName) {
const directPermissions = toArray(user?.permissions); const application = getUserApplication(user, applicationName);
const appPermissions = toArray(user?.applications).flatMap((app) => app?.permissions || []); return {
return [...directPermissions, ...appPermissions].map(normalizeText).filter(Boolean); groups: collectIdentityValues(application?.groups),
roles: collectIdentityValues(application?.roles),
permissions: collectIdentityValues(application?.permissions),
};
} }
function intersects(left, right) { function intersects(left, right) {
const rightSet = new Set(toArray(right).map(normalizeText).filter(Boolean)); const rightSet = new Set(collectIdentityValues(right));
return toArray(left).some((item) => rightSet.has(normalizeText(item))); return collectIdentityValues(left).some((item) => rightSet.has(item));
} }
export function hasAuthAccess(authRule, user = getCurrentUserFromStorage()) { export function isSystemAdministrator(user = getCurrentUserFromStorage()) {
if (!authRule) { return user?.type === 'admin' || user?.type === 'system';
return true; }
}
export function hasApplicationManagementAccess(
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (isSystemAdministrator(user)) return true;
return intersects(collectApplicationAuthorization(user, applicationName).roles, ['管理']);
}
export function hasAuthAccess(
authRule,
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (!user) { if (!user) {
return false; return false;
} }
if (user.type === 'admin' || user.type === 'system') { if (hasApplicationManagementAccess(user, applicationName)) {
return true; return true;
} }
if (authRule.type === 'group') { const { groups, roles, permissions } = collectApplicationAuthorization(user, applicationName);
return intersects(collectUserGroups(user), authRule.groups); if (typeof authRule === 'string') {
return intersects(roles, [authRule]);
} }
if (authRule?.type === 'group') {
if (authRule.type === 'role') { return intersects(groups, authRule.groups);
return intersects(collectUserRoles(user), authRule.roles);
} }
if (authRule?.type === 'role') {
if (authRule.type === 'permission') { return intersects(roles, authRule.roles);
return intersects(collectUserPermissions(user), authRule.permissions);
} }
if (authRule?.type === 'permission') {
if (authRule.type === 'username') { return intersects(permissions, authRule.permissions);
return intersects([user.username, user.display_name, user._id], authRule.usernames);
} }
return false;
return intersects(collectUserRoles(user), ['管理']);
} }
export function getFieldValue(record, fieldPath) { export function getFieldValue(record, fieldPath) {
...@@ -262,12 +275,18 @@ export function normalizeRecordLabel(value) { ...@@ -262,12 +275,18 @@ export function normalizeRecordLabel(value) {
return normalizeText(value); return normalizeText(value);
} }
export function shouldHideViewTitleColumn(view) {
return view?.settings?.shortTitle === true;
}
export function buildViewColumns(view, subject, { includeHidden = false } = {}) { export function buildViewColumns(view, subject, { includeHidden = false } = {}) {
const configuredColumns = view?.settings?.columns || []; const configuredColumns = view?.settings?.columns || [];
const hideTitleColumn = !includeHidden && shouldHideViewTitleColumn(view);
if (configuredColumns.length > 0) { if (configuredColumns.length > 0) {
return configuredColumns return configuredColumns
.filter((column) => includeHidden || !column.hide) .filter((column) =>
(includeHidden || !column.hide) && (!hideTitleColumn || column.field !== 'title'))
.map((column) => { .map((column) => {
const fieldDef = getFieldDefinition(column.field, subject); const fieldDef = getFieldDefinition(column.field, subject);
const label = getFieldLabel(column.field, fieldDef, column.label); const label = getFieldLabel(column.field, fieldDef, column.label);
...@@ -285,12 +304,14 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {}) ...@@ -285,12 +304,14 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
} }
const defaultColumns = []; const defaultColumns = [];
if (!hideTitleColumn) {
defaultColumns.push({ defaultColumns.push({
field: 'title', field: 'title',
width: 180, width: 180,
label: '标题', label: '标题',
fieldDef: { name: 'title', label: '标题', type: 'text' }, fieldDef: { name: 'title', label: '标题', type: 'text' },
}); });
}
toArray(subject?.fields).forEach((field) => { toArray(subject?.fields).forEach((field) => {
const fieldPath = `metadata.${field.name}`; const fieldPath = `metadata.${field.name}`;
...@@ -363,18 +384,44 @@ export function checkViewCondition(condition, record) { ...@@ -363,18 +384,44 @@ export function checkViewCondition(condition, record) {
: results.every(Boolean); : results.every(Boolean);
} }
export function isViewAccessible(view, user = getCurrentUserFromStorage()) { export function isViewAccessible(
return hasAuthAccess(view?.settings?.viewAuth, user); view,
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
const viewAuth = view?.settings?.viewAuth;
const rule = ['group', 'role', 'permission'].includes(viewAuth?.type) ? viewAuth : '管理';
return hasAuthAccess(rule, 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); const disabledColumn = toArray(view?.settings?.disableColumns).some((column) => column.field === fieldPath);
if (disabledColumn) { if (disabledColumn) {
return true; return true;
} }
if (hasApplicationManagementAccess(user, applicationName)) {
return false;
}
const fieldAuthRule = toArray(view?.settings?.auth).find((rule) => rule.field === fieldPath); const fieldAuthRule = toArray(view?.settings?.auth).find((rule) => rule.field === fieldPath);
return fieldAuthRule ? !hasAuthAccess(fieldAuthRule, user) : false; const rule = ['group', 'role', 'permission'].includes(fieldAuthRule?.type)
? fieldAuthRule
: '管理';
return !hasAuthAccess(rule, user, applicationName);
} }
export function recordMatchesRestrictions(record, restrictions = []) { 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