Commit fd8348b3 authored by Andy-bubu's avatar Andy-bubu

feat: add cross-application record features

parent f8a83c57
...@@ -12,6 +12,7 @@ const ROUTE_PATHS = { ...@@ -12,6 +12,7 @@ const ROUTE_PATHS = {
auditProjectArchive: '/audit_project_archive', auditProjectArchive: '/audit_project_archive',
projectForm: '/project_form', projectForm: '/project_form',
auditProjectForm: '/audit_project_form', auditProjectForm: '/audit_project_form',
notifications: '/notifications',
} as const; } as const;
const APP_BASE_PATH = '/audit_project_manager_new/'; const APP_BASE_PATH = '/audit_project_manager_new/';
...@@ -65,6 +66,10 @@ export default defineConfig({ ...@@ -65,6 +66,10 @@ export default defineConfig({
path: ROUTE_PATHS.auditProjectForm, path: ROUTE_PATHS.auditProjectForm,
component: './project_manager_xy/AuditProjectFormPage', component: './project_manager_xy/AuditProjectFormPage',
}, },
{
path: ROUTE_PATHS.notifications,
component: './project_manager_xy/notifications/NotificationListPage',
},
], ],
}, },
], ],
......
...@@ -135,3 +135,30 @@ CSS,并用显式 `@source` 仅扫描入口、组件、页面和 wrapper;`src ...@@ -135,3 +135,30 @@ CSS,并用显式 `@source` 仅扫描入口、组件、页面和 wrapper;`src
动态表单缺少字段定义时不从数据对象 key 猜测表头,改为 Pro EmptyState,避免把内部字段 动态表单缺少字段定义时不从数据对象 key 猜测表头,改为 Pro EmptyState,避免把内部字段
标识或存储结构作为业务界面。 标识或存储结构作为业务界面。
关联记录在缺少 Subject 字段定义时同样只保留标题列,不从 `metadata` key 生成未知表头。 关联记录在缺少 Subject 字段定义时同样只保留标题列,不从 `metadata` key 生成未知表头。
## 跨应用功能数据流
```text
UserContext
-> user.applications[name = audit_project_manager]
-> Grid viewAuth / field auth(Detail View 只负责编排与 shortTitle)
Navbar notification badge
-> platform notice APIs -> /notifications -> base-aware internal navigation
Editable file field
-> material-center selection -> file snapshot in detail draft
-> local /upload -> pending material draft
-> detail Save -> create am_material (idempotent retry marker)
-> all created -> existing record update callback
```
Subject/View 资源必须先加载并完成 Grid View 授权过滤;只有存在可访问 View 才允许查询记录。
`RecordDetailDrawer` 接收激活 Grid View 作为唯一 `authorizationView`,激活 Detail View 只决定字段
布局、条件和记录标题是否显示。通知与材料服务只封装既有接口参数,不拥有 Shell、记录更新或
业务状态流。
通知路由解析以 `ROUTE_TITLES` 的真实注册路径为本应用站内白名单,并只剥离
`/audit_project_manager_new` 自身 base;不能把其它应用的裸路径交给当前 Umi Router。
`FileUploadField` 直接组合 HeroUI Pro `DropZone.Input`,将运行时 `multiple` 同时应用于点击选择和
拖放;材料字段继续负责单值替换、多值追加、URL 去重及详情编辑会话的 pending 建档状态。
...@@ -93,6 +93,19 @@ ...@@ -93,6 +93,19 @@
- [x] 四份项目 UI 文档更新为最终事实 - [x] 四份项目 UI 文档更新为最终事实
- [x] 未提交、未推送、未合并 - [x] 未提交、未推送、未合并
## 2026-08-26 五项功能
- [x] Grid `shortTitle` 按激活 View 生效,title 仍参与搜索且业务入口保留
- [x] Detail `shortTitle` 随激活 Detail View 即时更新
- [x] 权限只读取 `audit_project_manager` 应用项,缺省拒绝且管理旁路正确
- [x] 无可访问 Grid View 时不查询记录、不建 fallback、不显示新增/批量/行操作
- [x] 只有一个可访问 Grid View 时隐藏 Tabs 轨道
- [x] `/notifications`、Navbar Badge、消息/待办、全部/未读和动态操作完成
- [x] 通知未读 0/1/99/100、站内外跳转、纯文本正文完成浏览器验收
- [x] 所有 editable file 字段支持材料中心和本地上传
- [x] 单值/多值、材料选择、URL 去重、取消、失败与重试事务完成浏览器验收
- [x] 四视口浅深色完成;最终构建、审计脚本、定向扫描和干净控制台复验通过
说明:扩展的全项目 `npm ls ... react react-dom` 会报告两个 Pro peer 包仍声明 React 18 说明:扩展的全项目 `npm ls ... react react-dom` 会报告两个 Pro peer 包仍声明 React 18
peer,以及 Umi 工具链锁定的 React 18 子图;应用与 HeroUI/React Aria 子图实际统一解析 peer,以及 Umi 工具链锁定的 React 18 子图;应用与 HeroUI/React Aria 子图实际统一解析
React 19.2.8,构建和浏览器验收通过。详见 `docs/xinyuan-ui-audit.md`。 React 19.2.8,构建和浏览器验收通过。详见 `docs/xinyuan-ui-audit.md`。
...@@ -92,3 +92,17 @@ Inter 与系统回退字体之间切换。 ...@@ -92,3 +92,17 @@ Inter 与系统回退字体之间切换。
Portal、错误/空/加载、键盘 focus 和移动响应式。合入前运行 `npm run build`、 Portal、错误/空/加载、键盘 focus 和移动响应式。合入前运行 `npm run build`、
`git diff --check`、鑫元审计脚本、旧组件/私有视觉/本机路径扫描、依赖树和 Pro 子路径检查; `git diff --check`、鑫元审计脚本、旧组件/私有视觉/本机路径扫描、依赖树和 Pro 子路径检查;
不得以构建成功替代浏览器验收。 不得以构建成功替代浏览器验收。
## 配置化权限、通知与材料
- `settings.shortTitle === true` 只隐藏当前 View 的 `title` 展示;title 数据、关键词搜索、排序、
保存与详情入口不得丢失。Detail 标题显示只跟随当前激活 Detail View。
- View/字段权限只能读取正式应用 `audit_project_manager` 在 `user.applications[]` 中的授权项。
系统管理员及该应用“管理”角色可旁路;普通用户缺失配置时默认拒绝,`disableColumns` 不旁路。
- Navbar 通知使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,位于主题按钮左侧;通知页面
使用原平台接口,正文按纯文本呈现,站内路径剥离 `/audit_project_manager_new` 后交给 Umi。
- editable file 字段统一提供“材料中心 / 本地上传”。选择材料只写文件快照;本地文件先上传到
详情草稿,保存时逐个创建 `am_material`,全部成功后才能更新业务记录,失败重试不得重复建档。
- 材料来源切换使用内容宽度 HeroUI `Tabs` 的 `secondary` variant;选择材料使用 Overlay 内
`secondary` Autocomplete,本地上传使用 HeroUI Pro `DropZone`。运行时 `multiple` 必须传给
`DropZone.Input` 和拖放处理,不能只在业务层把返回值当数组。
...@@ -7,9 +7,9 @@ ...@@ -7,9 +7,9 @@
- 分支:`codex/ui-ux-xinyuan-migration` - 分支:`codex/ui-ux-xinyuan-migration`
- 唯一实现基准:`problem_rectification` - 唯一实现基准:`problem_rectification`
- 应用基路径:`/audit_project_manager_new/` - 应用基路径:`/audit_project_manager_new/`
- 本轮启动命令:`npm run dev -- --port 8014`(Umi 自动选择实际空闲端口 `8005`) - 本轮启动命令:`npm run dev -- --port 8000`
- 目标验收地址:`http://127.0.0.1:8005/audit_project_manager_new/` - 目标验收地址:`http://127.0.0.1:8000/audit_project_manager_new/`
- 基准验收地址:`http://127.0.0.1:8006/problem_rectification_new/problem_rectification` - 基准验收地址:`http://127.0.0.1:8001/problem_rectification_new/`
迁移前已启动未修改项目并记录业务基线。原登录页为蓝色营销式左右分栏,Shell、表格、 迁移前已启动未修改项目并记录业务基线。原登录页为蓝色营销式左右分栏,Shell、表格、
弹窗和表单混用 Catalyst、Headless UI、Heroicons、ECharts 与可见原生控件。浏览器检查的 弹窗和表单混用 Catalyst、Headless UI、Heroicons、ECharts 与可见原生控件。浏览器检查的
...@@ -258,6 +258,43 @@ HeroUI Context。`@heroui/styles@3.2.4` 的两个 pnpm peer 虚拟目录仅分 ...@@ -258,6 +258,43 @@ HeroUI Context。`@heroui/styles@3.2.4` 的两个 pnpm peer 虚拟目录仅分
`oklch(1 0.0022 27.03)`、`oklch(0.9524 0.0036 27.03)`、 `oklch(1 0.0022 27.03)`、`oklch(0.9524 0.0036 27.03)`、
`oklch(0.9373 0.0036 27.03)`、L2、L1;深色对应 `0.2103`、`0.257`、`0.2721`、L2、L1。 `oklch(0.9373 0.0036 27.03)`、L2、L1;深色对应 `0.2103`、`0.257`、`0.2721`、L2、L1。
- 目标与“问题整改”同时打开对照,所有 Tabs 均由 HeroUI 官方 variant 决定外观。 - 目标与“问题整改”同时打开对照,所有 Tabs 均由 HeroUI 官方 variant 决定外观。
## 2026-08-26 跨应用五项功能审计
- 功能白名单判定:本应用为“审计项目管理”,属于完整功能应用,实施 Grid/Detail
`shortTitle`、视图权限、消息通知、文件与审计材料中心五项功能。
- 运行标识:Subject 为 `audit_project`,正式应用授权名与材料 `source_app` 为
`audit_project_manager`;Umi 路由 base 为 `/audit_project_manager_new/`。站点配置同时存在
演示入口 `audit_project_manager_new`,但它不作为权限或材料来源标识。
- 登录样本:Andy 的 `type` 为 `admin`、`applications` 为空,只能验证系统管理员旁路;普通
用户必须仅从 `user.applications[]` 中名称为 `audit_project_manager` 的项目读取 group、role
和 permission,禁止合并顶层或其他应用授权。
- View 现状:七个 Grid View 均配置 `shortTitle: true`;“计划”额外配置 group
`viewAuth` 与字段 `auth`。页面此前通过常量无条件隐藏 `title`,搜索也随之丢失 title;
View 权限缺省允许、无权时创建 fallback,并在鉴权前查询数据,均不符合基准。
- Detail 现状:详情视图未统一配置 `shortTitle`;抽屉始终显示记录标题,字段权限同时受 Detail
View 影响。目标行为应随当前激活 Detail View 即时切换标题显示,但字段权限只读取激活 Grid
View,`disableColumns` 永远只读。
- 通知现状:原 `DataService` 已提供 `queryUserNotice`、`readUserNotice`、
`unReadNoticeCount` 和 `exacteActionFunction`,但没有独立页面、路由或 Navbar 入口。
- 文件现状:editable file 字段只执行 `/upload` 并立即写入详情草稿,没有材料中心选择,也没有
“保存详情时建档”的事务边界。材料 Subject 为 `am_material`,快照协议必须保持
`{name,type,size,url}`,不得保存材料 ID。
### 本轮浏览器验收记录
- Andy 管理员、目标应用“管理”角色、普通用户、无权限用户及单 View 用户均通过浏览器响应夹具
验证;无权限状态没有 `/record/query`,也没有新增、批量、行操作或兜底 View。
- Grid `shortTitle` 的 true/false 和 Detail View 动态切换均已验证;隐藏 title 后项目名称和固定
操作列仍可打开详情,关键词搜索仍包含记录 title。
- 通知验证 0/1/99/100(100 显示 `99+`)、纯文本正文、`status = "unread"`、正则搜索转义、
单条/全部已读、动态 action、站内 `/project_form` 跳转和外部无 opener 新窗口。
- 材料验证单值保存顺序、两文件多值选择、已有材料 URL 去重、取消不建档及部分失败重试;失败后
第一个成功材料未重复创建,全部材料成功后才出现业务记录 PUT。所有夹具只拦截浏览器请求,
没有向真实后端写测试数据。
- 目标与“问题整改”同时打开,在 1440x900、1280x720、768x1024、390x844 的浅色和深色检查
通知页、列表、详情及材料控件;Navbar 动作均为 36px 且坐标一致,页面横向溢出为零。390px
详情面板为 358x812,四角、内部滚动、Tabs、Autocomplete 和 DropZone 在两种主题下可辨。
1440x900、1280x720、768x1024、390x844 浅色及相关深色状态均无页面横向溢出;390px 1440x900、1280x720、768x1024、390x844 浅色及相关深色状态均无页面横向溢出;390px
长分组只在官方 ScrollShadow 内滚动。点击“标准化业务制度修订情况”后仅该分组选中,表格 长分组只在官方 ScrollShadow 内滚动。点击“标准化业务制度修订情况”后仅该分组选中,表格
正确切换为“问题一 / 符合 / 王五”,控制台 0 errors / 0 warnings。 正确切换为“问题一 / 符合 / 王五”,控制台 0 errors / 0 warnings。
...@@ -282,6 +319,80 @@ HeroUI Context。`@heroui/styles@3.2.4` 的两个 pnpm peer 虚拟目录仅分 ...@@ -282,6 +319,80 @@ HeroUI Context。`@heroui/styles@3.2.4` 的两个 pnpm peer 虚拟目录仅分
- Auth/Site 路由加载态删除 `text-slate-500`,改用 HeroUI Spinner;详情全屏图标统一收口到 - Auth/Site 路由加载态删除 `text-slate-500`,改用 HeroUI Spinner;详情全屏图标统一收口到
`AppIcons`,页面直接导入图标包归零。 `AppIcons`,页面直接导入图标包归零。
### 2026-08-26 最终硬门槛复核
以下命令均在 `/Users/liuchenlong/Desktop/job/audit_project_manager` 执行:
```bash
npm run build
git diff --check
bash /Users/liuchenlong/.codex/skills/xinyuan-design-system/scripts/audit_project.sh \
/Users/liuchenlong/Desktop/job/audit_project_manager
cmp src/design-system/theme.css \
/Users/liuchenlong/.codex/skills/xinyuan-design-system/assets/baseline/design-system/theme.css
npm ls @heroui-pro/react @heroui/react @heroui/styles \
react-aria-components @react-aria/utils --depth=1
```
结果:Umi/Webpack 在 6.86 秒内编译成功;`git diff --check`、鑫元审计脚本、权威主题
字节比较与定向依赖树均以退出码 0 完成。依赖树解析为 `@heroui-pro/react@1.0.0-beta.8`、
`@heroui/react@3.2.4`、`@heroui/styles@3.2.4`、`react-aria-components@1.20.0` 和
`@react-aria/utils@3.34.1`。
HeroUI Pro 使用 Node ESM 动态 `import()` 检查根包及以下子路径:
```bash
node --input-type=module <<'NODE'
import fs from 'node:fs';
import {createRequire} from 'node:module';
const require = createRequire(import.meta.url);
const packagePath = require.resolve('@heroui-pro/react/package.json');
const packageDirectory = packagePath.replace(/package\.json$/, '');
for (const relativePath of ['dist/index.js', 'dist/css/index.css']) {
if (!fs.existsSync(`${packageDirectory}${relativePath}`)) process.exitCode = 1;
}
for (const specifier of [
'@heroui-pro/react',
'@heroui-pro/react/sidebar',
'@heroui-pro/react/navbar',
'@heroui-pro/react/data-grid',
'@heroui-pro/react/drop-zone',
'@heroui-pro/react/action-bar',
'@heroui-pro/react/empty-state',
'@heroui-pro/react/list-view',
]) await import(specifier);
console.log(import.meta.resolve('@heroui-pro/react/css'));
NODE
```
根包与八个 JS 子路径全部成功;CSS export 解析到本仓库 pnpm virtual store 内的
`dist/css/index.css`,`dist/index.js` 和 `dist/css/index.css` 均存在。审计脚本自身仍会对六个
ESM-only 子路径打印 CommonJS `require` WARN,但其退出码为 0,且上述 ESM 检查逐项通过。
```bash
rg -n 'catalyst-ui-kit|@headlessui/react|@heroicons' src --glob '*.{js,jsx,ts,tsx}'
rg -n '/compat/|\\\\compat\\\\' src --glob '*.{js,jsx,ts,tsx}'
rg -n "<(select|datalist)\\b|<input[^>]+type=['\"](date|datetime-local|file)['\"]" \
src --glob '*.{js,jsx,ts,tsx}'
rg -n "className=.*(bg-|border|rounded|shadow|ring|outline|hover:|focus:|active:)" \
src/pages src/components --glob '*.{js,jsx,ts,tsx}'
rg -n "sky-|blue-|cyan-|rose-|pink-|zinc-|slate-|bg-gradient|#[0-9A-Fa-f]{6}" \
src/pages --glob '*.{js,jsx,ts,tsx,css,less,scss}'
find src/pages -type f \( -name '*.css' -o -name '*.less' -o -name '*.scss' \) -print
rg -n "file:(\\.\\.?/|/Users/)|/Users/.+/node_modules|project_manager_new/node_modules" \
package.json pnpm-lock.yaml pnpm-workspace.yaml
rg -n 'hp_[A-Za-z0-9]+' . --glob '!node_modules/**' --glob '!dist/**' --glob '!.git/**'
```
结果:以上旧组件、compat、可见原生选择/日期/文件控件、页面私有视觉 utility、页面旧色调、
页面私有样式文件、`file:`/本机依赖路径及授权字面值扫描均为 0 命中。
最终浏览器控制台使用 `$playwright-cli` 在既有目标/基准对照 Context 中新建页面,先绑定
`console` 与 `pageerror` 监听器,再依次访问 `/audit_project` 和 `/notifications`。两个页面均
返回 200,标题正确,页面级横向溢出均为 0,捕获结果为 `0 errors / 0 warnings`;此前为验证
材料失败重试而故意模拟的 500 日志未计入本次干净结果。
### 2026-08-24 DataGrid 长文本边界复核 ### 2026-08-24 DataGrid 长文本边界复核
- 全局三个 HeroUI Pro DataGrid 消费入口均复用 `DataTable`:主业务列表、动态表单数据表和 - 全局三个 HeroUI Pro DataGrid 消费入口均复用 `DataTable`:主业务列表、动态表单数据表和
......
import BankOutlined from '@ant-design/icons/BankOutlined'; import BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined'; import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined'; import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined'; import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined'; import CheckOutlined from '@ant-design/icons/CheckOutlined';
...@@ -65,6 +66,7 @@ export const Bank = withSize(BankOutlined); ...@@ -65,6 +66,7 @@ export const Bank = withSize(BankOutlined);
export const Archive = withSize(InboxOutlined); export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined); export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined); export const BarChart = withSize(BarChartOutlined);
export const Bell = withSize(BellOutlined);
export const Calendar = withSize(CalendarOutlined); export const Calendar = withSize(CalendarOutlined);
export const Check = withSize(CheckOutlined); export const Check = withSize(CheckOutlined);
export const ChevronDown = withSize(DownOutlined); export const ChevronDown = withSize(DownOutlined);
......
...@@ -20,6 +20,8 @@ function getInitialTheme() { ...@@ -20,6 +20,8 @@ function getInitialTheme() {
export default function AppShell({ export default function AppShell({
children, children,
notificationCount,
notificationPath,
open, open,
onOpenChange, onOpenChange,
title, title,
...@@ -52,6 +54,8 @@ export default function AppShell({ ...@@ -52,6 +54,8 @@ export default function AppShell({
<DashboardSidebar {...sidebarProps} /> <DashboardSidebar {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main"> <Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar <DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined}
theme={theme} theme={theme}
title={title} title={title}
onThemeChange={setTheme} onThemeChange={setTheme}
......
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 +27,32 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) { ...@@ -20,6 +27,32 @@ 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">
{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}> <Tooltip delay={0}>
<Button <Button
isIconOnly isIconOnly
......
...@@ -9,6 +9,7 @@ export default function FileUploadField({ ...@@ -9,6 +9,7 @@ export default function FileUploadField({
description = '将文件拖到此处,或从本地选择。', description = '将文件拖到此处,或从本地选择。',
isDisabled = false, isDisabled = false,
fileName = '', fileName = '',
multiple = false,
onFilesChange, onFilesChange,
triggerLabel = '选择文件', triggerLabel = '选择文件',
}) { }) {
...@@ -29,9 +30,9 @@ export default function FileUploadField({ ...@@ -29,9 +30,9 @@ export default function FileUploadField({
if (fileItems.length === 0) return; if (fileItems.length === 0) return;
const files = await Promise.all(fileItems.map((item) => item.getFile())); const files = await Promise.all(fileItems.map((item) => item.getFile()));
emitFiles(files.slice(0, 1)); emitFiles(multiple ? files : files.slice(0, 1));
}, },
[emitFiles], [emitFiles, multiple],
); );
return ( return (
...@@ -44,6 +45,7 @@ export default function FileUploadField({ ...@@ -44,6 +45,7 @@ export default function FileUploadField({
<DropZone.Input <DropZone.Input
accept={accept} accept={accept}
disabled={isDisabled} disabled={isDisabled}
multiple={multiple}
onSelect={emitFiles} onSelect={emitFiles}
/> />
<DropZone.Icon> <DropZone.Icon>
...@@ -51,7 +53,9 @@ export default function FileUploadField({ ...@@ -51,7 +53,9 @@ export default function FileUploadField({
</DropZone.Icon> </DropZone.Icon>
<DropZone.Label>{fileName || '将文件拖到此处'}</DropZone.Label> <DropZone.Label>{fileName || '将文件拖到此处'}</DropZone.Label>
<DropZone.Description> <DropZone.Description>
{fileName ? '已选择文件,可重新选择进行替换。' : description} {fileName
? `已选择${multiple ? '文件,可继续添加。' : '文件,可重新选择进行替换。'}`
: description}
</DropZone.Description> </DropZone.Description>
<DropZone.Trigger isDisabled={isDisabled}> <DropZone.Trigger isDisabled={isDisabled}>
<Upload aria-hidden="true" /> <Upload aria-hidden="true" />
......
...@@ -104,6 +104,7 @@ export default function SubjectRecordWorkbench({ ...@@ -104,6 +104,7 @@ export default function SubjectRecordWorkbench({
() => gridViews.find((view) => view.name === activeViewName) || gridViews[0] || null, () => gridViews.find((view) => view.name === activeViewName) || gridViews[0] || null,
[gridViews, activeViewName], [gridViews, activeViewName],
); );
const hasAccessibleView = Boolean(activeView);
const configuredView = useMemo( const configuredView = useMemo(
() => buildConfiguredWorkbenchView(records, filters, activeView, subject), () => buildConfiguredWorkbenchView(records, filters, activeView, subject),
[records, filters, activeView, subject], [records, filters, activeView, subject],
...@@ -219,7 +220,7 @@ export default function SubjectRecordWorkbench({ ...@@ -219,7 +220,7 @@ export default function SubjectRecordWorkbench({
{ label: '当前已选', value: selectedIds.length, hint: '可执行批量操作', tone: 'amber' }, { label: '当前已选', value: selectedIds.length, hint: '可执行批量操作', tone: 'amber' },
]; ];
const builtToolbarActions = [ const builtToolbarActions = hasAccessibleView ? [
...toolbarActions.map(wrapAction), ...toolbarActions.map(wrapAction),
...(allowCreate && !readonly ...(allowCreate && !readonly
? [{ ? [{
...@@ -234,9 +235,9 @@ export default function SubjectRecordWorkbench({ ...@@ -234,9 +235,9 @@ export default function SubjectRecordWorkbench({
}, },
}] }]
: []), : []),
]; ] : [];
const builtBulkActions = [ const builtBulkActions = hasAccessibleView ? [
...(allowDelete && !readonly ...(allowDelete && !readonly
? [{ ? [{
key: 'bulk-delete', key: 'bulk-delete',
...@@ -251,9 +252,9 @@ export default function SubjectRecordWorkbench({ ...@@ -251,9 +252,9 @@ export default function SubjectRecordWorkbench({
), ),
}] }]
: []), : []),
]; ] : [];
const builtRowActions = [ const builtRowActions = hasAccessibleView ? [
{ {
key: 'view-detail', key: 'view-detail',
label: '查看详情', label: '查看详情',
...@@ -292,7 +293,7 @@ export default function SubjectRecordWorkbench({ ...@@ -292,7 +293,7 @@ export default function SubjectRecordWorkbench({
onClick: ({ record }) => setDeleteTargets([record.raw || record]), onClick: ({ record }) => setDeleteTargets([record.raw || record]),
}] }]
: []), : []),
]; ] : [];
return ( return (
<> <>
...@@ -322,7 +323,7 @@ export default function SubjectRecordWorkbench({ ...@@ -322,7 +323,7 @@ export default function SubjectRecordWorkbench({
toolbarActions={builtToolbarActions} toolbarActions={builtToolbarActions}
bulkActions={builtBulkActions} bulkActions={builtBulkActions}
rowActions={builtRowActions} rowActions={builtRowActions}
detailPanel={{ detailPanel={hasAccessibleView ? {
getRecordName: (record) => record.name || record.id, getRecordName: (record) => record.name || record.id,
onDirtyClose: reload, onDirtyClose: reload,
render: ({ open, recordName, onClose, onDataChange }) => ( render: ({ open, recordName, onClose, onDataChange }) => (
...@@ -334,17 +335,20 @@ export default function SubjectRecordWorkbench({ ...@@ -334,17 +335,20 @@ export default function SubjectRecordWorkbench({
onClose={onClose} onClose={onClose}
onDataChange={onDataChange} onDataChange={onDataChange}
preferredViewName={activeView?.title || activeView?.name} preferredViewName={activeView?.title || activeView?.name}
authorizationView={activeView}
/> />
), ),
}} } : null}
states={{ states={{
loading, loading,
error, error,
success, success,
loadingTitle: `正在加载${title}`, loadingTitle: `正在加载${title}`,
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。', loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: '当前视图暂无数据', emptyTitle: hasAccessibleView ? '当前视图暂无数据' : '暂无可访问的视图',
emptyDescription: emptyDescription || '可以切换其他视图或调整筛选条件。', emptyDescription: hasAccessibleView
? emptyDescription || '可以切换其他视图或调整筛选条件。'
: '当前账号未获得该对象 Grid 视图的查看权限。',
}} }}
pagination={{ pagination={{
page: safePage, page: safePage,
......
export const APPLICATION_DISPLAY_NAME = '审计项目管理'; export const APPLICATION_DISPLAY_NAME = '审计项目管理';
export const APPLICATION_BASE_PATH = '/audit_project_manager_new/'; export const APPLICATION_BASE_PATH = '/audit_project_manager_new/';
export const AUTHORIZATION_APPLICATION_NAME = 'audit_project_manager';
export const SOURCE_APPLICATION_NAME = 'audit_project_manager';
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({
...@@ -8,4 +10,5 @@ export const ROUTE_TITLES = Object.freeze({ ...@@ -8,4 +10,5 @@ export const ROUTE_TITLES = Object.freeze({
'/audit_project_archive': '往期项目归档', '/audit_project_archive': '往期项目归档',
'/project_form': '项目表单', '/project_form': '项目表单',
'/audit_project_form': '审计项目填写表', '/audit_project_form': '审计项目填写表',
'/notifications': '通知消息',
}); });
...@@ -54,13 +54,15 @@ export default function SubjectViewContent({ ...@@ -54,13 +54,15 @@ export default function SubjectViewContent({
editSessionId, editSessionId,
mode = 'view', mode = 'view',
onFieldChange, onFieldChange,
onPendingLocalMaterialChange,
onFieldValidation, onFieldValidation,
readonly, readonly,
isFieldDisabled, isFieldDisabled,
minimal = false, minimal = false,
useAuditMaterialCenter = false,
}) { }) {
const displayFields = buildViewColumns(view, subject); const displayFields = buildViewColumns(view, subject);
const contentFields = mode === 'view' || minimal const contentFields = minimal
? (displayFields || []).filter((field) => field.field !== 'title') ? (displayFields || []).filter((field) => field.field !== 'title')
: displayFields; : displayFields;
const fieldVariant = 'secondary'; const fieldVariant = 'secondary';
...@@ -117,8 +119,10 @@ export default function SubjectViewContent({ ...@@ -117,8 +119,10 @@ export default function SubjectViewContent({
) )
} }
onValidationChange={onFieldValidation} onValidationChange={onFieldValidation}
onPendingLocalMaterialChange={onPendingLocalMaterialChange}
readonly={readonly} readonly={readonly}
disabled={fieldDisabled} disabled={fieldDisabled}
useAuditMaterialCenter={useAuditMaterialCenter}
/> />
</div> </div>
</> </>
......
...@@ -17,6 +17,7 @@ import { queryUsers, uploadFile } from '@/services/DataService'; ...@@ -17,6 +17,7 @@ import { queryUsers, uploadFile } from '@/services/DataService';
import ReferenceSearchSelect from '@/components/ReferenceSearchSelect'; import ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import DynamicFormValueEditor from '@/components/DynamicFormValueEditor'; import DynamicFormValueEditor from '@/components/DynamicFormValueEditor';
import AuditProjectMaterialField from '@/pages/project_manager_xy/components/AuditProjectMaterialField';
import ReferenceRecordsTable from './ReferenceRecordsTable'; import ReferenceRecordsTable from './ReferenceRecordsTable';
import { import {
dateInputValue, dateInputValue,
...@@ -315,7 +316,9 @@ function ControlledFieldEditor({ ...@@ -315,7 +316,9 @@ function ControlledFieldEditor({
fieldVariant, fieldVariant,
placeholder, placeholder,
onChange, onChange,
onPendingLocalMaterialChange,
onValidationChange, onValidationChange,
useAuditMaterialCenter,
}) { }) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
...@@ -402,6 +405,20 @@ function ControlledFieldEditor({ ...@@ -402,6 +405,20 @@ function ControlledFieldEditor({
} }
if (fieldType === 'file') { if (fieldType === 'file') {
if (useAuditMaterialCenter) {
return (
<AuditProjectMaterialField
editSessionId={editSessionId}
fieldPath={fieldPath}
multiple={Boolean(fieldDef?.multiple)}
onChange={onChange}
onPendingLocalMaterialChange={onPendingLocalMaterialChange}
onValidationChange={onValidationChange}
value={value}
/>
);
}
return ( return (
<Card variant="secondary"> <Card variant="secondary">
<Card.Content className="space-y-3"> <Card.Content className="space-y-3">
...@@ -548,7 +565,9 @@ export default function TailwindEditableField({ ...@@ -548,7 +565,9 @@ export default function TailwindEditableField({
placeholder, placeholder,
onSave, onSave,
onChange, onChange,
onPendingLocalMaterialChange,
onValidationChange, onValidationChange,
useAuditMaterialCenter = false,
mode = 'inline', mode = 'inline',
readonly = false, readonly = false,
disabled = false, disabled = false,
...@@ -591,7 +610,9 @@ export default function TailwindEditableField({ ...@@ -591,7 +610,9 @@ export default function TailwindEditableField({
fieldVariant={fieldVariant} fieldVariant={fieldVariant}
placeholder={textPlaceholder} placeholder={textPlaceholder}
onChange={onChange} onChange={onChange}
onPendingLocalMaterialChange={onPendingLocalMaterialChange}
onValidationChange={onValidationChange} onValidationChange={onValidationChange}
useAuditMaterialCenter={useAuditMaterialCenter}
/> />
); );
} }
......
...@@ -2,6 +2,7 @@ import { Close, Fullscreen, FullscreenExit } from '@/components/AppIcons'; ...@@ -2,6 +2,7 @@ import { Close, Fullscreen, FullscreenExit } from '@/components/AppIcons';
import { Alert, Button, Modal, Spinner, Tabs, Tooltip, toast } from '@heroui/react'; import { Alert, Button, Modal, Spinner, Tabs, Tooltip, toast } from '@heroui/react';
import React, { import React, {
useCallback, useCallback,
useContext,
forwardRef, forwardRef,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
...@@ -11,13 +12,15 @@ import React, { ...@@ -11,13 +12,15 @@ import React, {
} from 'react'; } from 'react';
import { useSubjectRecord } from './hooks/useSubjectRecord'; import { useSubjectRecord } from './hooks/useSubjectRecord';
import SubjectViewContent from './components/SubjectViewContent'; import SubjectViewContent from './components/SubjectViewContent';
import { UserContext } from '@/wrapper/Auth';
import { createAuditProjectMaterial } from '@/pages/project_manager_xy/auditProjectMaterialService';
import { import {
buildViewColumns, buildViewColumns,
checkViewCondition, checkViewCondition,
getFieldValue, getFieldValue,
isFieldDisabledByView, isFieldDisabledByView,
isViewAccessible,
setFieldValue, setFieldValue,
shouldHideViewTitleColumn,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
const EMPTY_ARRAY = []; const EMPTY_ARRAY = [];
...@@ -99,8 +102,11 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -99,8 +102,11 @@ const RecordDetailDrawer = (props, ref) => {
footerActions = EMPTY_ARRAY, footerActions = EMPTY_ARRAY,
autoSwitchToLastTab = false, autoSwitchToLastTab = false,
isViewDisabled: isViewDisabledProp, isViewDisabled: isViewDisabledProp,
useAuditMaterialCenter = false,
} = props; } = props;
const currentUser = useContext(UserContext);
const [refreshKey, setRefreshKey] = useState(() => Date.now()); const [refreshKey, setRefreshKey] = useState(() => Date.now());
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [activeTabKey, setActiveTabKey] = useState(); const [activeTabKey, setActiveTabKey] = useState();
...@@ -114,6 +120,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -114,6 +120,7 @@ const RecordDetailDrawer = (props, ref) => {
const [actionLoadingKey, setActionLoadingKey] = useState(''); const [actionLoadingKey, setActionLoadingKey] = useState('');
const [lastTabSwitchRevision, setLastTabSwitchRevision] = useState(''); const [lastTabSwitchRevision, setLastTabSwitchRevision] = useState('');
const editSessionRef = useRef(0); const editSessionRef = useRef(0);
const pendingLocalMaterialsRef = useRef({});
const { const {
editableRecord, editableRecord,
...@@ -147,6 +154,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -147,6 +154,7 @@ const RecordDetailDrawer = (props, ref) => {
setValidationErrors({}); setValidationErrors({});
setSaveError(''); setSaveError('');
setLastTabSwitchRevision(''); setLastTabSwitchRevision('');
pendingLocalMaterialsRef.current = {};
editSessionRef.current += 1; editSessionRef.current += 1;
}, [open, recordName]); }, [open, recordName]);
...@@ -160,7 +168,6 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -160,7 +168,6 @@ const RecordDetailDrawer = (props, ref) => {
const filtered = (views || []) const filtered = (views || [])
.filter((view) => view.type === 'detail') .filter((view) => view.type === 'detail')
.filter((view) => (filterView.length === 0 ? true : filterView.includes(view.name))) .filter((view) => (filterView.length === 0 ? true : filterView.includes(view.name)))
.filter((view) => isViewAccessible(view))
.filter((view) => checkViewCondition(view.settings?.visabled, remoteRecord)); .filter((view) => checkViewCondition(view.settings?.visabled, remoteRecord));
if (filtered.length > 0) { if (filtered.length > 0) {
...@@ -248,6 +255,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -248,6 +255,7 @@ const RecordDetailDrawer = (props, ref) => {
const activeView = enabledDetailViews.find((view) => view.name === activeTabKey) const activeView = enabledDetailViews.find((view) => view.name === activeTabKey)
|| enabledDetailViews[enabledDetailViews.length - 1] || enabledDetailViews[enabledDetailViews.length - 1]
|| detailViews[0]; || detailViews[0];
const hideRecordTitle = shouldHideViewTitleColumn(activeView);
const isRecordLocked = remoteRecord?.metadata?.is_locked === true; const isRecordLocked = remoteRecord?.metadata?.is_locked === true;
const effectiveReadonly = readonly || isRecordLocked; const effectiveReadonly = readonly || isRecordLocked;
const disabledFieldSet = useMemo( const disabledFieldSet = useMemo(
...@@ -330,6 +338,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -330,6 +338,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({}); setDirtyFields({});
setValidationErrors({}); setValidationErrors({});
setSaveError(''); setSaveError('');
pendingLocalMaterialsRef.current = {};
}; };
const handleRequestClose = () => { const handleRequestClose = () => {
...@@ -345,6 +354,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -345,6 +354,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({}); setDirtyFields({});
setValidationErrors({}); setValidationErrors({});
setSaveError(''); setSaveError('');
pendingLocalMaterialsRef.current = {};
setIsEditing(true); setIsEditing(true);
}; };
...@@ -387,6 +397,35 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -387,6 +397,35 @@ 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 () => { const handleSave = async () => {
if (!hasDirtyFields || hasValidationErrors || isSaving) return; if (!hasDirtyFields || hasValidationErrors || isSaving) return;
...@@ -394,10 +433,31 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -394,10 +433,31 @@ const RecordDetailDrawer = (props, ref) => {
setSaveError(''); setSaveError('');
try { 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 createAuditProjectMaterial({
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)); await handleFieldsUpdate(Object.values(dirtyFields));
setIsEditing(false); setIsEditing(false);
setDirtyFields({}); setDirtyFields({});
setValidationErrors({}); setValidationErrors({});
pendingLocalMaterialsRef.current = {};
onDataChange?.(true); onDataChange?.(true);
} catch (saveFailure) { } catch (saveFailure) {
setSaveError(saveFailure?.message || '保存失败,请稍后重试。'); setSaveError(saveFailure?.message || '保存失败,请稍后重试。');
...@@ -412,7 +472,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -412,7 +472,7 @@ const RecordDetailDrawer = (props, ref) => {
displayedRecord?.name || displayedRecord?.name ||
recordName || recordName ||
'记录'; '记录';
const showRecordTitle = loading || !isPlaceholderTitle(recordTitle); const showRecordTitle = !hideRecordTitle && (loading || !isPlaceholderTitle(recordTitle));
return ( return (
<Modal.Root> <Modal.Root>
...@@ -547,9 +607,11 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -547,9 +607,11 @@ const RecordDetailDrawer = (props, ref) => {
mode={isEditing ? 'edit' : 'view'} mode={isEditing ? 'edit' : 'view'}
onFieldChange={handleDraftFieldChange} onFieldChange={handleDraftFieldChange}
onFieldValidation={handleFieldValidation} onFieldValidation={handleFieldValidation}
onPendingLocalMaterialChange={handlePendingLocalMaterialChange}
readonly={effectiveReadonly} readonly={effectiveReadonly}
isFieldDisabled={isDrawerFieldDisabled} isFieldDisabled={isDrawerFieldDisabled}
minimal={minimalContent} minimal={minimalContent}
useAuditMaterialCenter={useAuditMaterialCenter}
/> />
</div> </div>
)} )}
......
...@@ -12,8 +12,6 @@ import { ...@@ -12,8 +12,6 @@ import {
transitionAuditProject, transitionAuditProject,
} from './AuditProjectService'; } from './AuditProjectService';
const HIDDEN_LIST_COLUMNS = ['title'];
function hasStatus(record, statuses) { function hasStatus(record, statuses) {
return statuses.includes(record?.metadata?.project_status); return statuses.includes(record?.metadata?.project_status);
} }
...@@ -92,7 +90,6 @@ export default function AuditProjectListPage() { ...@@ -92,7 +90,6 @@ export default function AuditProjectListPage() {
description="按线上视图配置展示审计项目,支持详情维护、状态流转、导出和批量归档。" description="按线上视图配置展示审计项目,支持详情维护、状态流转、导出和批量归档。"
allowEdit={false} allowEdit={false}
allowClone={false} allowClone={false}
hiddenColumnFields={HIDDEN_LIST_COLUMNS}
buildSummaryItems={() => []} buildSummaryItems={() => []}
toolbarActions={[ toolbarActions={[
{ {
...@@ -118,6 +115,7 @@ export default function AuditProjectListPage() { ...@@ -118,6 +115,7 @@ export default function AuditProjectListPage() {
headerActions: HEADER_ACTIONS, headerActions: HEADER_ACTIONS,
footerActions: FOOTER_ACTIONS, footerActions: FOOTER_ACTIONS,
autoSwitchToLastTab: true, autoSwitchToLastTab: true,
useAuditMaterialCenter: true,
}} }}
emptyDescription="当前视图暂无审计项目,可以切换视图、清空筛选或新建记录。" emptyDescription="当前视图暂无审计项目,可以切换视图、清空筛选或新建记录。"
/> />
......
...@@ -10,6 +10,10 @@ import { ...@@ -10,6 +10,10 @@ import {
Project, Project,
} from '@/components/AppIcons'; } from '@/components/AppIcons';
import ApplicationSwitcherDialog from './ApplicationSwitcherDialog'; import ApplicationSwitcherDialog from './ApplicationSwitcherDialog';
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from './notifications/notificationService';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import { useContext, useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
...@@ -50,6 +54,7 @@ export default function ProjectManagerLayout() { ...@@ -50,6 +54,7 @@ export default function ProjectManagerLayout() {
const siteInfo = useContext(SiteContext); const siteInfo = useContext(SiteContext);
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false); const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const currentTitle = useMemo( const currentTitle = useMemo(
() => ROUTE_TITLES[location.pathname] || APPLICATION_DISPLAY_NAME, () => ROUTE_TITLES[location.pathname] || APPLICATION_DISPLAY_NAME,
...@@ -60,10 +65,33 @@ export default function ProjectManagerLayout() { ...@@ -60,10 +65,33 @@ export default function ProjectManagerLayout() {
document.title = `${currentTitle} - ${APPLICATION_DISPLAY_NAME}`; document.title = `${currentTitle} - ${APPLICATION_DISPLAY_NAME}`;
}, [currentTitle]); }, [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 ( return (
<> <>
<AppShell <AppShell
menuGroups={MENU_GROUPS} menuGroups={MENU_GROUPS}
notificationCount={unreadNotificationCount}
notificationPath="/notifications"
onLogout={userInfo?.logout} onLogout={userInfo?.logout}
onOpenApplicationSwitcher={() => setIsApplicationDialogOpen(true)} onOpenApplicationSwitcher={() => setIsApplicationDialogOpen(true)}
open={sidebarOpen} open={sidebarOpen}
......
...@@ -5,6 +5,7 @@ import { ...@@ -5,6 +5,7 @@ import {
} from '@/services/DataService'; } from '@/services/DataService';
import { import {
buildViewColumns, buildViewColumns,
buildViewSearchColumns,
checkViewCondition, checkViewCondition,
formatFieldValue, formatFieldValue,
getFieldValue, getFieldValue,
...@@ -56,11 +57,14 @@ export async function queryAllSubjectRecords(subjectName, query = {}) { ...@@ -56,11 +57,14 @@ export async function queryAllSubjectRecords(subjectName, query = {}) {
} }
export async function loadSubjectWorkbenchResources(subjectName, query = {}) { export async function loadSubjectWorkbenchResources(subjectName, query = {}) {
const [records, subject, views] = await Promise.all([ const [subject, views] = await Promise.all([
queryAllSubjectRecords(subjectName, query),
loadSubject(subjectName), loadSubject(subjectName),
getSubjectView(subjectName), getSubjectView(subjectName),
]); ]);
const accessibleGridViews = buildGridViews(views || []);
const records = accessibleGridViews.length > 0
? await queryAllSubjectRecords(subjectName, query)
: [];
return { return {
records: records.map(normalizeWorkbenchRecord), records: records.map(normalizeWorkbenchRecord),
...@@ -70,29 +74,25 @@ export async function loadSubjectWorkbenchResources(subjectName, query = {}) { ...@@ -70,29 +74,25 @@ export async function loadSubjectWorkbenchResources(subjectName, query = {}) {
} }
export function buildGridViews(views = []) { export function buildGridViews(views = []) {
const gridViews = views const configuredGridViews = views.filter((view) => view.type === 'grid');
.filter((view) => view.type === 'grid') const gridViews = configuredGridViews.filter((view) => isViewAccessible(view));
.filter((view) => isViewAccessible(view));
if (gridViews.length > 0) { if (gridViews.length > 0 || configuredGridViews.length > 0) {
return gridViews; return gridViews;
} }
return [ const fallbackView = { title: '全部记录', type: 'grid', name: 'all', settings: {} };
{ return isViewAccessible(fallbackView) ? [fallbackView] : [];
title: '全部记录',
type: 'grid',
name: 'all',
settings: {},
},
];
} }
export function buildConfiguredWorkbenchView(records, filters, view, subject) { export function buildConfiguredWorkbenchView(records, filters, view, subject) {
if (!view) return { columns: [], filteredRecords: [] };
const keyword = normalizeText(filters?.keyword).toLowerCase(); const keyword = normalizeText(filters?.keyword).toLowerCase();
const status = normalizeText(filters?.status); const status = normalizeText(filters?.status);
const year = normalizeText(filters?.year); const year = normalizeText(filters?.year);
const columns = buildViewColumns(view, subject); const columns = buildViewColumns(view, subject);
const searchColumns = buildViewSearchColumns(view, subject);
const filteredRecords = records.filter((record) => { const filteredRecords = records.filter((record) => {
const rawRecord = record.raw || record; const rawRecord = record.raw || record;
const metadata = rawRecord?.metadata || {}; const metadata = rawRecord?.metadata || {};
...@@ -125,7 +125,7 @@ export function buildConfiguredWorkbenchView(records, filters, view, subject) { ...@@ -125,7 +125,7 @@ export function buildConfiguredWorkbenchView(records, filters, view, subject) {
return true; return true;
} }
const searchText = columns const searchText = searchColumns
.map((column) => formatFieldValue(getFieldValue(rawRecord, column.field), column.fieldDef)) .map((column) => formatFieldValue(getFieldValue(rawRecord, column.field), column.fieldDef))
.join(' ') .join(' ')
.toLowerCase(); .toLowerCase();
......
import dayjs from 'dayjs';
import { createRecord, queryRecords, uploadFile } from '@/services/DataService';
import { SOURCE_APPLICATION_NAME } from '@/config/application';
export const AUDIT_MATERIAL_SUBJECT = 'am_material';
const MATERIAL_PAGE_SIZE = 20;
function extractRecords(response) {
return [
response,
response?.data,
response?.data?.list,
response?.data?.records,
response?.list,
response?.records,
response?.items,
response?.result,
response?.result?.records,
].find(Array.isArray) || [];
}
function escapeRegExp(value = '') {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function 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 queryAuditMaterials(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 uploadAuditProjectFile(file) {
if (!file) throw new Error('请选择要上传的本地文件。');
return normalizeFileAsset(await uploadFile(file), file);
}
export async function createAuditProjectMaterial({ 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: SOURCE_APPLICATION_NAME,
uploader: uploaderId,
uploaded_at: dayjs().toISOString(),
},
});
const createdRecord = response?.data || response?.result || response;
return {
id: createdRecord?._id || createdRecord?.id || createdRecord?.name || true,
record: createdRecord,
};
}
import { ArrowPathIcon, Delete } from '@/components/AppIcons';
import FileUploadField from '@/components/FileUploadField';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import {
queryAuditMaterials,
uploadAuditProjectFile,
} from '../auditProjectMaterialService';
import { Alert, Button, Label, Tabs, Tooltip } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
const MATERIAL_CENTER_TAB = 'material-center';
const LOCAL_UPLOAD_TAB = 'local-upload';
function toFileArray(value) {
if (Array.isArray(value)) return value.filter(Boolean);
return value ? [value] : [];
}
function getFileKey(file = {}) {
return file.url || [file.name, file.type, file.size].filter(Boolean).join('|');
}
function appendUniqueFiles(currentFiles, nextFiles) {
const filesByKey = new Map();
[...currentFiles, ...nextFiles].forEach((file) => {
const key = getFileKey(file);
if (key) filesByKey.set(key, file);
});
return [...filesByKey.values()];
}
function formatMaterialDescription(material) {
return [material.fileAsset?.name, material.archiveCategory, material.archiveYear]
.filter(Boolean).join(' · ');
}
export default function AuditProjectMaterialField({
editSessionId,
fieldPath,
multiple = false,
onChange,
onPendingLocalMaterialChange,
onValidationChange,
value,
}) {
const [activeTab, setActiveTab] = useState(MATERIAL_CENTER_TAB);
const [keyword, setKeyword] = useState('');
const [debouncedKeyword, setDebouncedKeyword] = useState('');
const [materials, setMaterials] = useState([]);
const [selectedMaterialId, setSelectedMaterialId] = useState(null);
const [materialsLoading, setMaterialsLoading] = useState(false);
const [materialsError, setMaterialsError] = useState('');
const [retryKey, setRetryKey] = useState(0);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState('');
const [localFileKeys, setLocalFileKeys] = useState(() => new Set());
const fileValues = useMemo(
() => (multiple ? toFileArray(value) : toFileArray(value).slice(0, 1)),
[multiple, value],
);
useEffect(() => {
setActiveTab(MATERIAL_CENTER_TAB);
setKeyword('');
setDebouncedKeyword('');
setSelectedMaterialId(null);
setMaterialsError('');
setUploadError('');
setLocalFileKeys(new Set());
}, [editSessionId]);
useEffect(() => {
const timeoutId = window.setTimeout(() => setDebouncedKeyword(keyword), 300);
return () => window.clearTimeout(timeoutId);
}, [keyword]);
useEffect(() => {
if (activeTab !== MATERIAL_CENTER_TAB) return undefined;
let cancelled = false;
setMaterialsLoading(true);
setMaterialsError('');
queryAuditMaterials(debouncedKeyword)
.then((nextMaterials) => { if (!cancelled) setMaterials(nextMaterials); })
.catch((error) => {
if (!cancelled) {
setMaterials([]);
setMaterialsError(error?.message || '审计材料加载失败,请稍后重试。');
}
})
.finally(() => { if (!cancelled) setMaterialsLoading(false); });
return () => { cancelled = true; };
}, [activeTab, debouncedKeyword, retryKey]);
const materialOptions = useMemo(() => materials.map((material) => ({
...material,
id: material.id,
label: material.materialTitle,
textValue: `${material.materialTitle} ${material.fileAsset?.name || ''}`,
})), [materials]);
const selectedMaterial = materials.find((material) => material.id === selectedMaterialId);
const selectedValueText = multiple
? fileValues.length > 0 ? `已选 ${fileValues.length} 个附件,可继续添加` : '请选择审计材料'
: selectedMaterial?.materialTitle || fileValues[0]?.name || '请选择审计材料';
const handleMaterialSelection = (key) => {
const material = materials.find((item) => String(item.id) === String(key || ''));
if (!material?.fileAsset?.url) return;
const fileAsset = {
name: material.fileAsset.name,
type: material.fileAsset.type || 'application/octet-stream',
size: Number(material.fileAsset.size || 0),
url: material.fileAsset.url,
};
setUploadError('');
if (multiple) {
onChange?.(appendUniqueFiles(fileValues, [fileAsset]));
setSelectedMaterialId(null);
return;
}
setSelectedMaterialId(material.id);
setLocalFileKeys(new Set());
onPendingLocalMaterialChange?.(fieldPath, { type: 'replace', materials: [] }, editSessionId);
onChange?.(fileAsset);
};
const handleLocalUpload = async (files) => {
const selectedFiles = multiple ? toFileArray(files) : toFileArray(files).slice(0, 1);
if (selectedFiles.length === 0) return;
setUploading(true);
setUploadError('');
onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId);
const uploadedAssets = [];
const failedFiles = [];
for (const file of selectedFiles) {
try {
uploadedAssets.push(await uploadAuditProjectFile(file));
} catch (error) {
failedFiles.push({ file, error });
}
}
if (uploadedAssets.length > 0) {
const pendingMaterials = uploadedAssets.map((fileAsset) => ({
clientId: getFileKey(fileAsset), fileAsset, materialRecordId: null,
}));
const uploadedKeys = pendingMaterials.map((item) => item.clientId);
setLocalFileKeys((current) => new Set(multiple ? [...current, ...uploadedKeys] : uploadedKeys));
setSelectedMaterialId(null);
onPendingLocalMaterialChange?.(fieldPath, {
type: multiple ? 'add' : 'replace', materials: pendingMaterials,
}, editSessionId);
onChange?.(multiple
? appendUniqueFiles(fileValues, uploadedAssets)
: uploadedAssets[uploadedAssets.length - 1]);
}
if (failedFiles.length > 0) {
const failedNames = failedFiles.map(({ file }) => file?.name).filter(Boolean).join('、');
setUploadError(failedFiles.length === selectedFiles.length
? failedFiles[0].error?.message || '附件上传失败,请稍后重试。'
: `部分附件上传失败:${failedNames || `${failedFiles.length} 个文件`},可重新选择后再试。`);
}
setUploading(false);
onValidationChange?.(fieldPath, '', editSessionId);
};
const handleRemove = (file, fileIndex) => {
const fileKey = getFileKey(file);
setUploadError('');
setLocalFileKeys((current) => {
const next = new Set(current);
next.delete(fileKey);
return next;
});
onPendingLocalMaterialChange?.(fieldPath, { type: 'remove', clientId: fileKey }, editSessionId);
if (multiple) onChange?.(fileValues.filter((_, index) => index !== fileIndex));
else { setSelectedMaterialId(null); onChange?.(null); }
};
return (
<div className="space-y-4">
{fileValues.length > 0 ? (
<div className="space-y-2">
{fileValues.map((file, index) => {
const fileKey = getFileKey(file);
const fileLabel = file?.name || file?.url || `附件 ${index + 1}`;
return (
<div className="flex min-w-0 items-center justify-between gap-3" key={`${fileKey}-${index}`}>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground" title={fileLabel}>{fileLabel}</div>
<div className="mt-1 text-xs text-muted">
{localFileKeys.has(fileKey) ? '本地文件,保存后同步至审计材料中心' : '已选择附件'}
</div>
</div>
<Tooltip>
<Button aria-label={`删除附件:${fileLabel}`} isDisabled={uploading} isIconOnly size="sm" variant="danger-soft" onPress={() => handleRemove(file, index)}>
<Delete size={16} />
</Button>
<Tooltip.Content>删除当前附件</Tooltip.Content>
</Tooltip>
</div>
);
})}
</div>
) : <span className="text-sm text-muted">未上传</span>}
<Tabs
aria-label="附件来源"
className="w-fit max-w-full min-w-0"
selectedKey={activeTab}
variant="secondary"
onSelectionChange={(key) => setActiveTab(String(key))}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List className="w-max min-w-max">
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={MATERIAL_CENTER_TAB}>
材料中心
<Tabs.Indicator />
</Tabs.Tab>
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={LOCAL_UPLOAD_TAB}>
本地上传
<Tabs.Indicator />
</Tabs.Tab>
</Tabs.List>
</Tabs.ListContainer>
<Tabs.Panel className="pt-3" id={MATERIAL_CENTER_TAB}>
<div className="space-y-3">
<XinyuanAutocomplete
aria-label="选择审计材料"
emptyText="没有找到可选择的审计材料"
errorText={materialsError}
inputValue={keyword}
isDisabled={uploading}
isLoading={materialsLoading}
options={materialOptions}
placeholder="请选择审计材料"
searchPlaceholder="搜索材料标题或文件名"
selectedKey={selectedMaterialId}
shouldFilter={false}
valueText={selectedValueText}
variant="secondary"
onInputChange={setKeyword}
onSelectionChange={handleMaterialSelection}
renderOption={(material) => (
<span className="min-w-0">
<Label className="block truncate" title={material.materialTitle}>{material.materialTitle}</Label>
<span className="mt-0.5 block truncate text-xs text-muted" title={formatMaterialDescription(material)}>{formatMaterialDescription(material)}</span>
</span>
)}
/>
{materialsError ? (
<Alert status="danger"><Alert.Content><Alert.Description>{materialsError}</Alert.Description></Alert.Content>
<Button size="sm" variant="danger-soft" onPress={() => setRetryKey((key) => key + 1)}><ArrowPathIcon size={14} />重试</Button>
</Alert>
) : null}
</div>
</Tabs.Panel>
<Tabs.Panel className="pt-3" id={LOCAL_UPLOAD_TAB}>
<div className="space-y-3">
<FileUploadField
description={multiple
? '可一次选择多个文件;上传后先保留为详情草稿,保存详情时逐个同步写入审计材料中心。'
: '上传后先保留为详情草稿,保存详情时同步写入审计材料中心。'}
isDisabled={uploading}
multiple={multiple}
triggerLabel={uploading ? '上传中...' : '选择本地文件'}
onFilesChange={handleLocalUpload}
/>
{uploadError ? <Alert status="danger"><Alert.Content><Alert.Description>{uploadError}</Alert.Description></Alert.Content></Alert> : null}
</div>
</Tabs.Panel>
</Tabs>
</div>
);
}
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
Avatar,
Button,
Card,
Chip,
SearchField,
Spinner,
Tabs,
toast,
} from '@heroui/react';
import { useCallback, useContext, useEffect, useState } from 'react';
import { history } from 'umi';
import {
executeNotificationAction,
extractNotificationTarget,
getNotificationContent,
getNotificationSender,
isNotificationRead,
loadNotificationPage,
loadUnreadNotificationCount,
markAllNotificationsRead,
markNotificationRead,
NOTIFICATION_PAGE_SIZE,
resolveNotificationTarget,
} from './notificationService';
const CATEGORY_OPTIONS = [
{ key: 'message', label: '消息' },
{ key: 'todo', label: '待办' },
];
const READ_STATUS_OPTIONS = [
{ key: 'all', label: '全部' },
{ key: 'unread', label: '未读' },
];
function formatNotificationTime(value) {
if (!value) return '时间未知';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).format(date);
}
function NotificationItem({
notification,
category,
currentUserId,
isBusy,
onAction,
onMarkRead,
onOpenTarget,
}) {
const isRead = isNotificationRead(notification, currentUserId);
const sender = getNotificationSender(notification);
const content = getNotificationContent(notification) || '暂无通知内容';
const target = resolveNotificationTarget(extractNotificationTarget(notification));
const actions = Array.isArray(notification?.actions) ? notification.actions : [];
return (
<Card>
<Card.Content className="flex flex-col gap-4 p-4 sm:p-5">
<div className="flex min-w-0 items-start gap-3">
<Avatar className="shrink-0" size="sm">
<Avatar.Fallback>{sender.trim().slice(0, 1).toUpperCase() || '系'}</Avatar.Fallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h2 className="break-words text-sm font-medium text-foreground sm:text-base">
{notification?.title || (category === 'todo' ? '待办通知' : '通知消息')}
</h2>
<p className="mt-1 text-xs text-muted sm:text-sm">
{sender} · {formatNotificationTime(notification?.sent_at || notification?.created_at)}
</p>
</div>
<Chip color={isRead ? undefined : 'danger'} size="sm" variant="soft">
{isRead ? (category === 'todo' ? '已完成' : '已读') : category === 'todo' ? '待处理' : '未读'}
</Chip>
</div>
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-foreground">
{content}
</p>
</div>
</div>
<div className="flex min-h-8 flex-wrap items-center justify-end gap-2">
{!isRead ? (
<Button isDisabled={isBusy} size="sm" variant="ghost" onPress={() => onMarkRead(notification)}>
标为已读
</Button>
) : null}
{category === 'todo' ? actions.map((action, index) => (
<Button
isDisabled={isBusy || isRead}
key={`${action?.label || 'action'}-${index}`}
size="sm"
variant="secondary"
onPress={() => onAction(notification, action)}
>
{action?.label || '处理'}
</Button>
)) : null}
{target ? (
<Button isDisabled={isBusy} size="sm" variant="primary" onPress={() => onOpenTarget(notification, target)}>
<OpenExternal size={14} />
进入流程
</Button>
) : null}
</div>
</Card.Content>
</Card>
);
}
export default function NotificationListPage() {
const userInfo = useContext(UserContext);
const [category, setCategory] = useState('message');
const [readStatus, setReadStatus] = useState('all');
const [searchText, setSearchText] = useState('');
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(0);
const [notifications, setNotifications] = useState([]);
const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
const timer = window.setTimeout(() => {
setKeyword(searchText.trim());
setPage(0);
}, 300);
return () => window.clearTimeout(timer);
}, [searchText]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
loadNotificationPage({ page, pageSize: NOTIFICATION_PAGE_SIZE, category, readStatus, keyword })
.then((result) => {
if (cancelled) return;
setNotifications(result.list);
setTotal(result.total);
})
.catch((requestError) => {
if (cancelled) return;
setNotifications([]);
setTotal(0);
setError(requestError?.message || '通知列表加载失败,请稍后重试');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [category, keyword, page, readStatus, reloadKey]);
useEffect(() => {
let cancelled = false;
loadUnreadNotificationCount()
.then((count) => { if (!cancelled) setUnreadTotal(count); })
.catch(() => { if (!cancelled) setUnreadTotal(0); });
return () => { cancelled = true; };
}, [reloadKey]);
const refresh = useCallback(() => setReloadKey((value) => value + 1), []);
const runItemOperation = useCallback(async (key, operation, successMessage) => {
setBusyKey(key);
try {
await operation();
toast.success(successMessage, { timeout: 3000 });
refresh();
return true;
} catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 4000 });
return false;
} finally {
setBusyKey('');
}
}, [refresh]);
const handleMarkRead = useCallback((notification) => runItemOperation(
notification.name,
() => markNotificationRead(notification.name),
'已标记为已读',
), [runItemOperation]);
const handleMarkAllRead = useCallback(() => runItemOperation(
'all', markAllNotificationsRead, '全部通知已标记为已读',
), [runItemOperation]);
const handleAction = useCallback((notification, action) => runItemOperation(
notification.name,
() => executeNotificationAction(notification, action),
'待办已处理',
), [runItemOperation]);
const handleOpenTarget = useCallback(async (notification, target) => {
setBusyKey(notification.name);
try {
if (!isNotificationRead(notification, userInfo?._id)) {
await markNotificationRead(notification.name);
}
if (target.external) window.open(target.href, '_blank', 'noopener,noreferrer');
else history.push(target.href);
refresh();
} catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 4000 });
} finally {
setBusyKey('');
}
}, [refresh, userInfo?._id]);
return (
<div className="rc-page-stack">
<div className="rc-page-toolbar rc-page-toolbar-responsive">
<Tabs selectedKey={category} variant="primary" onSelectionChange={(key) => { setCategory(String(key)); setPage(0); }}>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="通知分类" className="w-max min-w-max">
{CATEGORY_OPTIONS.map((option) => (
<Tabs.Tab id={option.key} key={option.key}>{option.label}<Tabs.Indicator /></Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
<Button isDisabled={loading || unreadTotal === 0} isPending={busyKey === 'all'} variant="secondary" onPress={handleMarkAllRead}>
全部已读
</Button>
</div>
<div className="rc-page-toolbar">
<div className="w-full min-w-0 sm:w-80 lg:w-96">
<SearchField aria-label="搜索通知内容" fullWidth value={searchText} variant="primary" onChange={setSearchText}>
<SearchField.Group>
<SearchField.SearchIcon><MagnifyingGlassIcon size={16} /></SearchField.SearchIcon>
<SearchField.Input placeholder="搜索通知内容" />
<SearchField.ClearButton aria-label="清空通知搜索"><Close size={12} /></SearchField.ClearButton>
</SearchField.Group>
</SearchField>
</div>
<Tabs selectedKey={readStatus} variant="primary" onSelectionChange={(key) => { setReadStatus(String(key)); setPage(0); }}>
<Tabs.ListContainer><Tabs.List aria-label="通知状态">
{READ_STATUS_OPTIONS.map((option) => (
<Tabs.Tab id={option.key} key={option.key}>{option.label}<Tabs.Indicator /></Tabs.Tab>
))}
</Tabs.List></Tabs.ListContainer>
</Tabs>
</div>
{error ? (
<Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content>
<Button size="sm" variant="ghost" onPress={refresh}>重试</Button>
</Alert>
) : null}
{loading && notifications.length === 0 ? (
<div className="flex min-h-64 items-center justify-center" aria-label="通知加载中"><Spinner size="sm" /></div>
) : null}
{!loading && !error && notifications.length === 0 ? (
<EmptyState className="min-h-64" size="md">
<EmptyState.Media><Bell size={22} /></EmptyState.Media>
<EmptyState.Header>
<EmptyState.Title>{category === 'todo' ? '暂无待办通知' : '暂无通知消息'}</EmptyState.Title>
<EmptyState.Description>
{keyword ? '没有找到匹配的通知,请调整搜索条件。' : readStatus === 'unread' ? '当前没有未读通知。' : '新的通知会显示在这里。'}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
) : null}
{notifications.length > 0 ? (
<div className="flex min-w-0 flex-col gap-3" aria-busy={loading}>
{notifications.map((notification) => (
<NotificationItem
category={category}
currentUserId={userInfo?._id}
isBusy={busyKey === notification.name}
key={notification.name || notification._id}
notification={notification}
onAction={handleAction}
onMarkRead={handleMarkRead}
onOpenTarget={handleOpenTarget}
/>
))}
</div>
) : null}
{!error && total > 0 ? (
<DataTablePagination isDisabled={loading} itemLabel="条通知" page={page} pageSize={NOTIFICATION_PAGE_SIZE} total={total} onPageChange={setPage} />
) : null}
</div>
);
}
import {
exacteActionFunction,
queryUserNotice,
readUserNotice,
unReadNoticeCount,
} from '@/services/DataService';
import { APPLICATION_BASE_PATH, ROUTE_TITLES } from '@/config/application';
export const NOTIFICATION_PAGE_SIZE = 10;
export const NOTIFICATION_STATE_EVENT = 'audit-project-manager:notification-state';
const INTERNAL_ROUTE_PATHS = Object.keys(ROUTE_TITLES);
const INTERNAL_ROUTE_PATTERN = INTERNAL_ROUTE_PATHS
.map((path) => path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.sort((left, right) => right.length - left.length)
.join('|');
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);
return normalizedUserId
? toArray(notification?.data?.logs).some((log) => toId(log?.user_id) === normalizedUserId)
: false;
}
export function getNotificationSender(notification) {
return notification?.from?.display_name || notification?.from?.username ||
notification?.created_by?.display_name || notification?.created_by?.username || '系统';
}
export function getNotificationContent(notification) {
return String(notification?.content || notification?.content_compile || '').trim();
}
function trimTarget(target) {
return String(target || '').replace(/[),.;!?,。;!?)]+$/u, '');
}
export function extractNotificationTarget(notification) {
const configuredTarget = notification?.settings?.url || notification?.settings?.link ||
notification?.data?.url || notification?.data?.link;
if (configuredTarget) return trimTarget(configuredTarget);
const prefix = APPLICATION_BASE_PATH.replace(/\/$/, '');
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const content = getNotificationContent(notification);
const match = content.match(new RegExp(
`https?:\\/\\/[^\\s<>"']+|${escapedPrefix}\\/[^\\s<>"']+|(?:${INTERNAL_ROUTE_PATTERN})(?:[/?#][^\\s<>"']*)?`,
));
return trimTarget(match?.[0]);
}
export function resolveNotificationTarget(target) {
const normalizedTarget = trimTarget(target);
if (!normalizedTarget) return null;
const appPrefix = APPLICATION_BASE_PATH.replace(/\/$/, '');
if (/^https?:\/\//i.test(normalizedTarget)) {
try {
const url = new URL(normalizedTarget);
if (url.pathname.startsWith(`${appPrefix}/`)) {
return resolveNotificationTarget(`${url.pathname}${url.search}${url.hash}`);
}
return { href: url.toString(), external: true };
} catch (error) {
return null;
}
}
if (normalizedTarget.startsWith(`${appPrefix}/`)) {
return { href: normalizedTarget.slice(appPrefix.length), external: false };
}
return normalizedTarget.startsWith('/')
? { href: normalizedTarget, external: !INTERNAL_ROUTE_PATHS
.some((path) => normalizedTarget === path || normalizedTarget.startsWith(`${path}?`)) }
: null;
}
export function notifyNotificationStateChanged() {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(NOTIFICATION_STATE_EVENT));
}
}
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
| 往期项目归档 | `/audit_project_archive` | 只读查看审计项目历史快照 | | 往期项目归档 | `/audit_project_archive` | 只读查看审计项目历史快照 |
| 项目表单 | `/project_form` | 维护动态表单及字段 | | 项目表单 | `/project_form` | 维护动态表单及字段 |
| 审计项目填写表 | `/audit_project_form` | 兼容外部填写和复核链接 | | 审计项目填写表 | `/audit_project_form` | 兼容外部填写和复核链接 |
| 通知消息 | `/notifications` | 查看消息和待办,并处理已读、动态动作及关联流程跳转 |
配置中的 `audit_indicator`、`risk_indicator`、`audit_process`、`audit_report` 配置中的 `audit_indicator`、`risk_indicator`、`audit_process`、`audit_report`
没有菜单入口,属于复制配置残留,不注册为本应用路由。 没有菜单入口,属于复制配置残留,不注册为本应用路由。
...@@ -26,15 +27,17 @@ ...@@ -26,15 +27,17 @@
## 2. 项目列表与详情 ## 2. 项目列表与详情
1. 页面通过 `loadSubject`、`getSubjectView` 和 `POST /record/query` 读取字段、视图和记录。 1. 页面通过 `loadSubject`、`getSubjectView` 和 `POST /record/query` 读取字段、视图和记录。
2. 表格列、视图限制、排序、选择字段选项和字段分组来自服务端配置;字段分组读取当前 Grid View 的 `settings.groups`,按配置展示分组表头并支持展开或收起。审计项目管理列表不展示通用 `title` 列,项目名称及其他业务字段仍按 View 配置展示。 2. 表格列、视图限制、排序、选择字段选项和字段分组来自服务端配置;字段分组读取当前 Grid View 的 `settings.groups`,按配置展示分组表头并支持展开或收起。当前 Grid View 的 `settings.shortTitle === true` 时只隐藏通用 `title` 展示列;`false` 或未配置时正常展示。`title` 数据仍参与关键词搜索、排序、详情和保存,详情入口保留在项目名称和固定操作列。
View 中兼容历史字段 `metadata.project_finish_time` 时按数据定义显示为“项目完成时间”; View 中兼容历史字段 `metadata.project_finish_time` 时按数据定义显示为“项目完成时间”;
内部 `metadata.*` 路径不作为可见表头,字段取值路径和服务端配置保持不变。 内部 `metadata.*` 路径不作为可见表头,字段取值路径和服务端配置保持不变。
3. 新建和编辑时业务字段统一写入 `metadata`;引用和用户字段只提交 ID,日期提交 ISO 8601。 3. 新建和编辑时业务字段统一写入 `metadata`;引用和用户字段只提交 ID,日期提交 ISO 8601。
4. 编辑模式下主键字段禁用;必填字段按 Subject 配置校验。 4. 编辑模式下主键字段禁用;必填字段按 Subject 配置校验。
5. 新建与编辑弹窗的空控件按可见字段标签显示输入、选择或搜索提示;提示仅用于界面引导,不参与校验,也不写入提交数据。 5. 新建与编辑弹窗的空控件按可见字段标签显示输入、选择或搜索提示;提示仅用于界面引导,不参与校验,也不写入提交数据。
6. 删除调用 `POST /record/batch/remove`,默认软删除;列表行内不提供编辑、复制或归档按钮,字段维护统一从详情侧栏进入。 6. 删除调用 `POST /record/batch/remove`,默认软删除;列表行内不提供编辑、复制或归档按钮,字段维护统一从详情侧栏进入。
7. 详情弹窗按可访问的 detail 视图显示字段,并支持字段内联维护;标题、阶段 Tabs 和顶部业务 7. 详情弹窗按符合条件的 detail 视图显示字段,并支持字段内联维护;当前 Detail View 的
操作固定在 Header,字段内容只在 Body 内纵向滚动,滚动内容不得穿过或遮挡阶段 Tabs。 `settings.shortTitle === true` 时隐藏记录标题和 `title` 字段,但始终保留“记录详情”功能标题和
Modal 可访问名称。标题、阶段 Tabs 和顶部业务操作固定在 Header,字段内容只在 Body 内纵向
滚动,滚动内容不得穿过或遮挡阶段 Tabs。
8. 列表页不展示前端写死的项目总数、完成率等摘要卡片,仅展示由服务端 Subject/View 配置驱动的视图、筛选和表格内容;项目统计统一在驾驶舱查看。 8. 列表页不展示前端写死的项目总数、完成率等摘要卡片,仅展示由服务端 Subject/View 配置驱动的视图、筛选和表格内容;项目统计统一在驾驶舱查看。
9. 业务列表、动态表单数据表和关联记录表中的长文本最多展示两行,内容始终限制在所属列内;超出内容通过 HeroUI Tooltip 或项目详情查看,避免遮挡相邻列或由单条长文本过度拉高整行。弹窗内只读关联记录表按实际数据行高度收缩,不提供无业务价值的列宽拖拽。 9. 业务列表、动态表单数据表和关联记录表中的长文本最多展示两行,内容始终限制在所属列内;超出内容通过 HeroUI Tooltip 或项目详情查看,避免遮挡相邻列或由单条长文本过度拉高整行。弹窗内只读关联记录表按实际数据行高度收缩,不提供无业务价值的列宽拖拽。
...@@ -108,7 +111,8 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template` ...@@ -108,7 +111,8 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
- `settings` 必须是合法 JSON;选择字段选项存放在 `settings.options`。 - `settings` 必须是合法 JSON;选择字段选项存放在 `settings.options`。
- 审计项目中的 `form` 字段在项目详情侧栏内默认完整展开:左侧按 `settings.group` 展示默认分组和自定义分组,右侧展示当前分组下的全部表单及数据表格。 - 审计项目中的 `form` 字段在项目详情侧栏内默认完整展开:左侧按 `settings.group` 展示默认分组和自定义分组,右侧展示当前分组下的全部表单及数据表格。
- 表单字段支持在侧栏内添加/删除分组、添加/删除表单、切换表单模板和调整所属分组;删除自定义分组时,其中已配置的表单移入默认分组。 - 表单字段支持在侧栏内添加/删除分组、添加/删除表单、切换表单模板和调整所属分组;删除自定义分组时,其中已配置的表单移入默认分组。
- 表单数据行使用通用表格展示,通过 Catalyst 弹窗新增或编辑;侧栏中的修改先作为草稿保留,点击“保存表单”后统一写回 `{ form, data, settings }`,不向业务用户暴露 JSON。 - 表单数据行使用 HeroUI Pro DataGrid 展示,通过 HeroUI Modal 新增或编辑;详情中的修改先作为
草稿保留,点击详情“保存”后统一写回 `{ form, data, settings }`,不向业务用户暴露 JSON。
- 关联的表单模板不存在或加载失败时,在当前表单内容内显示“表单模板不可用”,不展示内部 - 关联的表单模板不存在或加载失败时,在当前表单内容内显示“表单模板不可用”,不展示内部
模板标识,也不继续展示缺少定义的表格;编辑态可以重新选择有效模板,现有数据不会自动改写。 模板标识,也不继续展示缺少定义的表格;编辑态可以重新选择有效模板,现有数据不会自动改写。
- 模板存在但缺少字段定义时显示“暂无可展示字段”,不使用已有数据对象的内部 key 临时生成 - 模板存在但缺少字段定义时显示“暂无可展示字段”,不使用已有数据对象的内部 key 临时生成
...@@ -122,6 +126,32 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template` ...@@ -122,6 +126,32 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
- 登录页首次打开时调用 `GET /auth/captcha` 获取验证码图片和 token;登录请求在既有账号、 - 登录页首次打开时调用 `GET /auth/captcha` 获取验证码图片和 token;登录请求在既有账号、
加密密码与 `type=password` 基础上提交 `captcha` 和 `token`。用户可点击验证码刷新;登录 加密密码与 `type=password` 基础上提交 `captcha` 和 `token`。用户可点击验证码刷新;登录
失败时清空验证码输入并重新获取验证码,成功后的 token 保存和 redirect 流程保持不变。 失败时清空验证码输入并重新获取验证码,成功后的 token 保存和 redirect 流程保持不变。
- Subject 视图继续执行服务端配置的访问限制、条件过滤和字段禁用规则。 - Grid View 的 `settings.viewAuth` 控制视图可见性,当前激活 Grid View 的 `settings.auth` 控制
详情字段编辑权限,`settings.disableColumns` 对所有用户始终只读。授权只读取
`user.applications[]` 中 `name = "audit_project_manager"` 的应用项;系统管理员、系统账号及该
应用“管理”角色可旁路 View/字段 auth,但不能旁路 `disableColumns`。普通用户缺少配置时默认
拒绝;没有可访问 Grid View 时不查询记录、不创建兜底视图,也不展示新增、批量和行操作;只有
一个可访问 View 时隐藏 Tabs 轨道。
- 历史快照列表只读,不允许新建、复制或删除。 - 历史快照列表只读,不允许新建、复制或删除。
- 批量归档未返回任何历史记录 ID 时按失败处理,并保留原审计项目数据。 - 批量归档未返回任何历史记录 ID 时按失败处理,并保留原审计项目数据。
## 10. 消息通知
- Navbar 的通知按钮位于主题按钮左侧,未读数来自 `GET /notice/customer/count`;0 不显示,
1–99 显示真实数量,100 及以上显示 `99+`,并通过通知状态事件和 60 秒轮询刷新。
- `/notifications` 使用原平台通知接口,支持消息/待办、全部/未读、300ms 搜索、固定每页 10 条、
单条已读、全部已读和服务端配置的动态动作;未读筛选向服务端提交 `status = "unread"`。
- 通知正文按纯文本显示,不执行 HTML。以 `/audit_project_manager_new` 开头的站内链接剥离自身
base 后交给 Umi;本应用已注册的裸业务路由按站内处理,其它应用和外部 HTTP(S) 链接在无
`window.opener` 的新窗口打开。
## 11. 文件与审计材料中心
- 审计项目详情中所有运行时可编辑的 `file` 字段提供“材料中心 / 本地上传”,单值或多值取自
Subject 字段的 `multiple` 配置。材料中心查询 `am_material` 最近 20 条,并按材料标题和文件名
远程搜索;选择后只把 `{name,type,size,url}` 快照写入业务草稿。
- 本地文件先调用 `/upload` 并进入当前详情草稿。取消编辑不创建材料主档;单值替换,多值追加并
按 URL 去重,移除或清空业务附件不删除材料中心主档或底层文件。
- 用户点击详情“保存”后,系统才用 `source_app = "audit_project_manager"`、当前用户、字段快照和
ISO 时间逐个创建 `am_material`。全部建档成功后才调用既有业务记录更新回调;部分失败会在当前
编辑会话保留已建档标记,重试只创建尚未成功的材料,避免重复主档。
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();
...@@ -62,60 +63,88 @@ export function getCurrentUserFromStorage() { ...@@ -62,60 +63,88 @@ export function getCurrentUserFromStorage() {
} }
} }
function collectUserGroups(user) { function collectIdentityValues(values) {
return 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);
} }
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()) { function getAuthorizationRule(authRule) {
if (!authRule) { return ['group', 'role', 'permission', 'username'].includes(authRule?.type)
? authRule
: '管理';
}
export function isSystemAdministrator(user = getCurrentUserFromStorage()) {
return user?.type === 'admin' || user?.type === 'system';
}
export function hasApplicationManagementAccess(
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (isSystemAdministrator(user)) {
return true; return true;
} }
if (!user) { const { roles } = collectApplicationAuthorization(user, applicationName);
return false; return intersects(roles, ['管理']);
} }
if (user.type === 'admin') { export function hasAuthAccess(
return true; authRule,
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (!user) return false;
if (hasApplicationManagementAccess(user, applicationName)) return true;
const { groups, roles, permissions } = collectApplicationAuthorization(user, applicationName);
if (typeof authRule === 'string') {
return intersects(roles, [authRule]);
} }
if (authRule.type === 'group') { if (authRule?.type === 'group') {
return intersects(collectUserGroups(user), authRule.groups); return intersects(groups, authRule.groups);
} }
if (authRule.type === 'role') { if (authRule?.type === 'role') {
return intersects(collectUserRoles(user), authRule.roles); return intersects(roles, authRule.roles);
} }
if (authRule.type === 'permission') { if (authRule?.type === 'permission') {
return intersects(collectUserPermissions(user), authRule.permissions); return intersects(permissions, authRule.permissions);
} }
if (authRule.type === 'username') { if (authRule?.type === 'username') {
return intersects([user.username, user.display_name, user._id], authRule.usernames); return intersects([user.username], authRule.usernames);
} }
return true; return false;
} }
export function getFieldValue(record, fieldPath) { export function getFieldValue(record, fieldPath) {
...@@ -249,12 +278,18 @@ export function normalizeRecordLabel(value) { ...@@ -249,12 +278,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 fieldName = getFieldNameFromPath(column.field); const fieldName = getFieldNameFromPath(column.field);
const configuredFieldDef = getFieldDefinition(column.field, subject); const configuredFieldDef = getFieldDefinition(column.field, subject);
...@@ -274,12 +309,14 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {}) ...@@ -274,12 +309,14 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
} }
const defaultColumns = []; const defaultColumns = [];
defaultColumns.push({ if (!hideTitleColumn) {
field: 'title', defaultColumns.push({
width: 180, field: 'title',
label: '标题', width: 180,
fieldDef: { name: 'title', label: '标题', type: 'text' }, label: '标题',
}); 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}`;
...@@ -298,6 +335,21 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {}) ...@@ -298,6 +335,21 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
return defaultColumns; 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) { function compareRuleValue(recordValue, comparator, expectedValue) {
const expectedValues = toArray(expectedValue).map(normalizeText); const expectedValues = toArray(expectedValue).map(normalizeText);
const recordValues = toArray(recordValue).map((item) => { const recordValues = toArray(recordValue).map((item) => {
...@@ -356,18 +408,31 @@ export function checkViewCondition(condition, record) { ...@@ -356,18 +408,31 @@ 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,
) {
return hasAuthAccess(getAuthorizationRule(view?.settings?.viewAuth), user, applicationName);
} }
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; return !hasAuthAccess(getAuthorizationRule(fieldAuthRule), 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