Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
A
audit_project_manager
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
David Yang
audit_project_manager
Commits
fd8348b3
Commit
fd8348b3
authored
Aug 26, 2026
by
Andy-bubu
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add cross-application record features
parent
f8a83c57
Show whitespace changes
Inline
Side-by-side
Showing
23 changed files
with
1352 additions
and
80 deletions
+1352
-80
.umirc.ts
.umirc.ts
+5
-0
ui-architecture.md
docs/ui-architecture.md
+27
-0
ui-migration-checklist.md
docs/ui-migration-checklist.md
+13
-0
xinyuan-design-system.md
docs/xinyuan-design-system.md
+14
-0
xinyuan-ui-audit.md
docs/xinyuan-ui-audit.md
+114
-3
AppIcons.jsx
src/components/AppIcons.jsx
+2
-0
AppShell.jsx
src/components/AppShell.jsx
+4
-0
DashboardNavbar.jsx
src/components/DashboardNavbar.jsx
+36
-3
FileUploadField.jsx
src/components/FileUploadField.jsx
+7
-3
SubjectRecordWorkbench.jsx
src/components/SubjectRecordWorkbench.jsx
+14
-10
application.js
src/config/application.js
+3
-0
SubjectViewContent.jsx
...ages/RecordDetailDrawer/components/SubjectViewContent.jsx
+5
-1
TailwindEditableField.jsx
...s/RecordDetailDrawer/components/TailwindEditableField.jsx
+21
-0
index.jsx
src/pages/RecordDetailDrawer/index.jsx
+65
-3
AuditProjectListPage.jsx
src/pages/project_manager_xy/AuditProjectListPage.jsx
+1
-3
Layout.jsx
src/pages/project_manager_xy/Layout.jsx
+28
-0
SubjectWorkbenchService.js
src/pages/project_manager_xy/SubjectWorkbenchService.js
+15
-15
auditProjectMaterialService.js
src/pages/project_manager_xy/auditProjectMaterialService.js
+119
-0
AuditProjectMaterialField.jsx
...oject_manager_xy/components/AuditProjectMaterialField.jsx
+275
-0
NotificationListPage.jsx
...project_manager_xy/notifications/NotificationListPage.jsx
+296
-0
notificationService.js
...s/project_manager_xy/notifications/notificationService.js
+154
-0
project-management.business-flow.md
...es/project_manager_xy/project-management.business-flow.md
+35
-5
subjectView.js
src/utils/subjectView.js
+99
-34
No files found.
.umirc.ts
View file @
fd8348b3
...
...
@@ -12,6 +12,7 @@ const ROUTE_PATHS = {
auditProjectArchive
:
'/audit_project_archive'
,
projectForm
:
'/project_form'
,
auditProjectForm
:
'/audit_project_form'
,
notifications
:
'/notifications'
,
}
as
const
;
const
APP_BASE_PATH
=
'/audit_project_manager_new/'
;
...
...
@@ -65,6 +66,10 @@ export default defineConfig({
path
:
ROUTE_PATHS
.
auditProjectForm
,
component
:
'./project_manager_xy/AuditProjectFormPage'
,
},
{
path
:
ROUTE_PATHS
.
notifications
,
component
:
'./project_manager_xy/notifications/NotificationListPage'
,
},
],
},
],
...
...
docs/ui-architecture.md
View file @
fd8348b3
...
...
@@ -135,3 +135,30 @@ CSS,并用显式 `@source` 仅扫描入口、组件、页面和 wrapper;`src
动态表单缺少字段定义时不从数据对象 key 猜测表头,改为 Pro EmptyState,避免把内部字段
标识或存储结构作为业务界面。
关联记录在缺少 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 建档状态。
docs/ui-migration-checklist.md
View file @
fd8348b3
...
...
@@ -93,6 +93,19 @@
-
[
x
]
四份项目 UI 文档更新为最终事实
-
[
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
peer,以及 Umi 工具链锁定的 React 18 子图;应用与 HeroUI/React Aria 子图实际统一解析
React 19.2.8,构建和浏览器验收通过。详见
`docs/xinyuan-ui-audit.md`
。
docs/xinyuan-design-system.md
View file @
fd8348b3
...
...
@@ -92,3 +92,17 @@ Inter 与系统回退字体之间切换。
Portal、错误/空/加载、键盘 focus 和移动响应式。合入前运行
`npm run build`
、
`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`
和拖放处理,不能只在业务层把返回值当数组。
docs/xinyuan-ui-audit.md
View file @
fd8348b3
...
...
@@ -7,9 +7,9 @@
-
分支:
`codex/ui-ux-xinyuan-migration`
-
唯一实现基准:
`problem_rectification`
-
应用基路径:
`/audit_project_manager_new/`
-
本轮启动命令:
`npm run dev -- --port 80
14`
(Umi 自动选择实际空闲端口
`8005`
)
-
目标验收地址:
`http://127.0.0.1:800
5
/audit_project_manager_new/`
-
基准验收地址:
`http://127.0.0.1:800
6/problem_rectification_new/problem_rectification
`
-
本轮启动命令:
`npm run dev -- --port 80
00`
-
目标验收地址:
`http://127.0.0.1:800
0
/audit_project_manager_new/`
-
基准验收地址:
`http://127.0.0.1:800
1/problem_rectification_new/
`
迁移前已启动未修改项目并记录业务基线。原登录页为蓝色营销式左右分栏,Shell、表格、
弹窗和表单混用 Catalyst、Headless UI、Heroicons、ECharts 与可见原生控件。浏览器检查的
...
...
@@ -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(0.9373 0.0036 27.03)`
、L2、L1;深色对应
`0.2103`
、
`0.257`
、
`0.2721`
、L2、L1。
-
目标与“问题整改”同时打开对照,所有 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
长分组只在官方 ScrollShadow 内滚动。点击“标准化业务制度修订情况”后仅该分组选中,表格
正确切换为“问题一 / 符合 / 王五”,控制台 0 errors / 0 warnings。
...
...
@@ -282,6 +319,80 @@ HeroUI Context。`@heroui/styles@3.2.4` 的两个 pnpm peer 虚拟目录仅分
-
Auth/Site 路由加载态删除
`text-slate-500`
,改用 HeroUI Spinner;详情全屏图标统一收口到
`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 长文本边界复核
-
全局三个 HeroUI Pro DataGrid 消费入口均复用
`DataTable`
:主业务列表、动态表单数据表和
...
...
src/components/AppIcons.jsx
View file @
fd8348b3
import
BankOutlined
from
'@ant-design/icons/BankOutlined'
;
import
BarChartOutlined
from
'@ant-design/icons/BarChartOutlined'
;
import
BellOutlined
from
'@ant-design/icons/BellOutlined'
;
import
AppstoreOutlined
from
'@ant-design/icons/AppstoreOutlined'
;
import
CalendarOutlined
from
'@ant-design/icons/CalendarOutlined'
;
import
CheckOutlined
from
'@ant-design/icons/CheckOutlined'
;
...
...
@@ -65,6 +66,7 @@ export const Bank = withSize(BankOutlined);
export
const
Archive
=
withSize
(
InboxOutlined
);
export
const
AppGrid
=
withSize
(
AppstoreOutlined
);
export
const
BarChart
=
withSize
(
BarChartOutlined
);
export
const
Bell
=
withSize
(
BellOutlined
);
export
const
Calendar
=
withSize
(
CalendarOutlined
);
export
const
Check
=
withSize
(
CheckOutlined
);
export
const
ChevronDown
=
withSize
(
DownOutlined
);
...
...
src/components/AppShell.jsx
View file @
fd8348b3
...
...
@@ -20,6 +20,8 @@ function getInitialTheme() {
export
default
function
AppShell
({
children
,
notificationCount
,
notificationPath
,
open
,
onOpenChange
,
title
,
...
...
@@ -52,6 +54,8 @@ export default function AppShell({
<
DashboardSidebar
{
...
sidebarProps
}
/>
<
Sidebar
.
Main
className=
"app-main-glass rc-app-main"
>
<
DashboardNavbar
notificationCount=
{
notificationCount
}
onOpenNotifications=
{
notificationPath
?
()
=>
history
.
push
(
notificationPath
)
:
undefined
}
theme=
{
theme
}
title=
{
title
}
onThemeChange=
{
setTheme
}
...
...
src/components/DashboardNavbar.jsx
View file @
fd8348b3
import
{
Navbar
}
from
'@heroui-pro/react/navbar'
;
import
{
Sidebar
,
useSidebar
}
from
'@heroui-pro/react/sidebar'
;
import
{
Button
,
Tooltip
}
from
'@heroui/react'
;
import
{
Moon
,
SidebarPanel
,
Sun
}
from
'./AppIcons'
;
import
{
B
adge
,
B
utton
,
Tooltip
}
from
'@heroui/react'
;
import
{
Bell
,
Moon
,
SidebarPanel
,
Sun
}
from
'./AppIcons'
;
export
default
function
DashboardNavbar
({
onThemeChange
,
theme
,
title
})
{
export
default
function
DashboardNavbar
({
notificationCount
=
0
,
onOpenNotifications
,
onThemeChange
,
theme
,
title
,
})
{
const
{
isMobile
,
isOpen
}
=
useSidebar
();
const
isDark
=
theme
===
'dark'
;
const
themeLabel
=
isDark
?
'切换至浅色模式'
:
'切换至深色模式'
;
const
normalizedNotificationCount
=
Math
.
max
(
0
,
Number
(
notificationCount
)
||
0
);
return
(
<
Navbar
className=
"app-navbar rc-navbar"
maxWidth=
"full"
>
...
...
@@ -20,6 +27,32 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) {
<
h1
className=
"app-navbar-page-title rc-navbar-title"
>
{
title
}
</
h1
>
<
Navbar
.
Spacer
/>
<
div
className=
"rc-navbar-actions"
>
{
onOpenNotifications
?
(
<
Tooltip
delay=
{
0
}
>
<
Badge
.
Anchor
>
<
Button
isIconOnly
aria
-
label=
"通知消息"
className=
"app-navbar-icon-button rc-navbar-icon-button"
size=
"sm"
variant=
"ghost"
onPress=
{
onOpenNotifications
}
>
<
Bell
size=
{
16
}
/>
</
Button
>
{
normalizedNotificationCount
>
0
?
(
<
Badge
aria
-
label=
{
`${normalizedNotificationCount}条未读通知`
}
color=
"danger"
size=
"sm"
>
{
normalizedNotificationCount
>
99
?
'99+'
:
normalizedNotificationCount
}
</
Badge
>
)
:
null
}
</
Badge
.
Anchor
>
<
Tooltip
.
Content
>
通知消息
</
Tooltip
.
Content
>
</
Tooltip
>
)
:
null
}
<
Tooltip
delay=
{
0
}
>
<
Button
isIconOnly
...
...
src/components/FileUploadField.jsx
View file @
fd8348b3
...
...
@@ -9,6 +9,7 @@ export default function FileUploadField({
description
=
'将文件拖到此处,或从本地选择。'
,
isDisabled
=
false
,
fileName
=
''
,
multiple
=
false
,
onFilesChange
,
triggerLabel
=
'选择文件'
,
})
{
...
...
@@ -29,9 +30,9 @@ export default function FileUploadField({
if
(
fileItems
.
length
===
0
)
return
;
const
files
=
await
Promise
.
all
(
fileItems
.
map
((
item
)
=>
item
.
getFile
()));
emitFiles
(
files
.
slice
(
0
,
1
));
emitFiles
(
multiple
?
files
:
files
.
slice
(
0
,
1
));
},
[
emitFiles
],
[
emitFiles
,
multiple
],
);
return
(
...
...
@@ -44,6 +45,7 @@ export default function FileUploadField({
<
DropZone
.
Input
accept=
{
accept
}
disabled=
{
isDisabled
}
multiple=
{
multiple
}
onSelect=
{
emitFiles
}
/>
<
DropZone
.
Icon
>
...
...
@@ -51,7 +53,9 @@ export default function FileUploadField({
</
DropZone
.
Icon
>
<
DropZone
.
Label
>
{
fileName
||
'将文件拖到此处'
}
</
DropZone
.
Label
>
<
DropZone
.
Description
>
{
fileName
?
'已选择文件,可重新选择进行替换。'
:
description
}
{
fileName
?
`已选择${multiple ? '文件,可继续添加。' : '文件,可重新选择进行替换。'}`
:
description
}
</
DropZone
.
Description
>
<
DropZone
.
Trigger
isDisabled=
{
isDisabled
}
>
<
Upload
aria
-
hidden=
"true"
/>
...
...
src/components/SubjectRecordWorkbench.jsx
View file @
fd8348b3
...
...
@@ -104,6 +104,7 @@ export default function SubjectRecordWorkbench({
()
=>
gridViews
.
find
((
view
)
=>
view
.
name
===
activeViewName
)
||
gridViews
[
0
]
||
null
,
[
gridViews
,
activeViewName
],
);
const
hasAccessibleView
=
Boolean
(
activeView
);
const
configuredView
=
useMemo
(
()
=>
buildConfiguredWorkbenchView
(
records
,
filters
,
activeView
,
subject
),
[
records
,
filters
,
activeView
,
subject
],
...
...
@@ -219,7 +220,7 @@ export default function SubjectRecordWorkbench({
{
label
:
'当前已选'
,
value
:
selectedIds
.
length
,
hint
:
'可执行批量操作'
,
tone
:
'amber'
},
];
const
builtToolbarActions
=
[
const
builtToolbarActions
=
hasAccessibleView
?
[
...
toolbarActions
.
map
(
wrapAction
),
...(
allowCreate
&&
!
readonly
?
[{
...
...
@@ -234,9 +235,9 @@ export default function SubjectRecordWorkbench({
},
}]
:
[]),
];
]
:
[]
;
const
builtBulkActions
=
[
const
builtBulkActions
=
hasAccessibleView
?
[
...(
allowDelete
&&
!
readonly
?
[{
key
:
'bulk-delete'
,
...
...
@@ -251,9 +252,9 @@ export default function SubjectRecordWorkbench({
),
}]
:
[]),
];
]
:
[]
;
const
builtRowActions
=
[
const
builtRowActions
=
hasAccessibleView
?
[
{
key
:
'view-detail'
,
label
:
'查看详情'
,
...
...
@@ -292,7 +293,7 @@ export default function SubjectRecordWorkbench({
onClick
:
({
record
})
=>
setDeleteTargets
([
record
.
raw
||
record
]),
}]
:
[]),
];
]
:
[]
;
return
(
<>
...
...
@@ -322,7 +323,7 @@ export default function SubjectRecordWorkbench({
toolbarActions=
{
builtToolbarActions
}
bulkActions=
{
builtBulkActions
}
rowActions=
{
builtRowActions
}
detailPanel=
{
{
detailPanel=
{
hasAccessibleView
?
{
getRecordName
:
(
record
)
=>
record
.
name
||
record
.
id
,
onDirtyClose
:
reload
,
render
:
({
open
,
recordName
,
onClose
,
onDataChange
})
=>
(
...
...
@@ -334,17 +335,20 @@ export default function SubjectRecordWorkbench({
onClose=
{
onClose
}
onDataChange=
{
onDataChange
}
preferredViewName=
{
activeView
?.
title
||
activeView
?.
name
}
authorizationView=
{
activeView
}
/>
),
}
}
}
:
null
}
states=
{
{
loading
,
error
,
success
,
loadingTitle
:
`正在加载${title}`
,
loadingDescription
:
'正在读取对象结构、视图配置和记录数据,请稍候。'
,
emptyTitle
:
'当前视图暂无数据'
,
emptyDescription
:
emptyDescription
||
'可以切换其他视图或调整筛选条件。'
,
emptyTitle
:
hasAccessibleView
?
'当前视图暂无数据'
:
'暂无可访问的视图'
,
emptyDescription
:
hasAccessibleView
?
emptyDescription
||
'可以切换其他视图或调整筛选条件。'
:
'当前账号未获得该对象 Grid 视图的查看权限。'
,
}
}
pagination=
{
{
page
:
safePage
,
...
...
src/config/application.js
View file @
fd8348b3
export
const
APPLICATION_DISPLAY_NAME
=
'审计项目管理'
;
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
ROUTE_TITLES
=
Object
.
freeze
({
...
...
@@ -8,4 +10,5 @@ export const ROUTE_TITLES = Object.freeze({
'/audit_project_archive'
:
'往期项目归档'
,
'/project_form'
:
'项目表单'
,
'/audit_project_form'
:
'审计项目填写表'
,
'/notifications'
:
'通知消息'
,
});
src/pages/RecordDetailDrawer/components/SubjectViewContent.jsx
View file @
fd8348b3
...
...
@@ -54,13 +54,15 @@ export default function SubjectViewContent({
editSessionId
,
mode
=
'view'
,
onFieldChange
,
onPendingLocalMaterialChange
,
onFieldValidation
,
readonly
,
isFieldDisabled
,
minimal
=
false
,
useAuditMaterialCenter
=
false
,
})
{
const
displayFields
=
buildViewColumns
(
view
,
subject
);
const
contentFields
=
m
ode
===
'view'
||
m
inimal
const
contentFields
=
minimal
?
(
displayFields
||
[]).
filter
((
field
)
=>
field
.
field
!==
'title'
)
:
displayFields
;
const
fieldVariant
=
'secondary'
;
...
...
@@ -117,8 +119,10 @@ export default function SubjectViewContent({
)
}
onValidationChange=
{
onFieldValidation
}
onPendingLocalMaterialChange=
{
onPendingLocalMaterialChange
}
readonly=
{
readonly
}
disabled=
{
fieldDisabled
}
useAuditMaterialCenter=
{
useAuditMaterialCenter
}
/>
</
div
>
</>
...
...
src/pages/RecordDetailDrawer/components/TailwindEditableField.jsx
View file @
fd8348b3
...
...
@@ -17,6 +17,7 @@ import { queryUsers, uploadFile } from '@/services/DataService';
import
ReferenceSearchSelect
from
'@/components/ReferenceSearchSelect'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
DynamicFormValueEditor
from
'@/components/DynamicFormValueEditor'
;
import
AuditProjectMaterialField
from
'@/pages/project_manager_xy/components/AuditProjectMaterialField'
;
import
ReferenceRecordsTable
from
'./ReferenceRecordsTable'
;
import
{
dateInputValue
,
...
...
@@ -315,7 +316,9 @@ function ControlledFieldEditor({
fieldVariant
,
placeholder
,
onChange
,
onPendingLocalMaterialChange
,
onValidationChange
,
useAuditMaterialCenter
,
})
{
const
[
error
,
setError
]
=
useState
(
''
);
const
[
uploading
,
setUploading
]
=
useState
(
false
);
...
...
@@ -402,6 +405,20 @@ function ControlledFieldEditor({
}
if
(
fieldType
===
'file'
)
{
if
(
useAuditMaterialCenter
)
{
return
(
<
AuditProjectMaterialField
editSessionId=
{
editSessionId
}
fieldPath=
{
fieldPath
}
multiple=
{
Boolean
(
fieldDef
?.
multiple
)
}
onChange=
{
onChange
}
onPendingLocalMaterialChange=
{
onPendingLocalMaterialChange
}
onValidationChange=
{
onValidationChange
}
value=
{
value
}
/>
);
}
return
(
<
Card
variant=
"secondary"
>
<
Card
.
Content
className=
"space-y-3"
>
...
...
@@ -548,7 +565,9 @@ export default function TailwindEditableField({
placeholder
,
onSave
,
onChange
,
onPendingLocalMaterialChange
,
onValidationChange
,
useAuditMaterialCenter
=
false
,
mode
=
'inline'
,
readonly
=
false
,
disabled
=
false
,
...
...
@@ -591,7 +610,9 @@ export default function TailwindEditableField({
fieldVariant=
{
fieldVariant
}
placeholder=
{
textPlaceholder
}
onChange=
{
onChange
}
onPendingLocalMaterialChange=
{
onPendingLocalMaterialChange
}
onValidationChange=
{
onValidationChange
}
useAuditMaterialCenter=
{
useAuditMaterialCenter
}
/>
);
}
...
...
src/pages/RecordDetailDrawer/index.jsx
View file @
fd8348b3
...
...
@@ -2,6 +2,7 @@ import { Close, Fullscreen, FullscreenExit } from '@/components/AppIcons';
import
{
Alert
,
Button
,
Modal
,
Spinner
,
Tabs
,
Tooltip
,
toast
}
from
'@heroui/react'
;
import
React
,
{
useCallback
,
useContext
,
forwardRef
,
useEffect
,
useImperativeHandle
,
...
...
@@ -11,13 +12,15 @@ import React, {
}
from
'react'
;
import
{
useSubjectRecord
}
from
'./hooks/useSubjectRecord'
;
import
SubjectViewContent
from
'./components/SubjectViewContent'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
createAuditProjectMaterial
}
from
'@/pages/project_manager_xy/auditProjectMaterialService'
;
import
{
buildViewColumns
,
checkViewCondition
,
getFieldValue
,
isFieldDisabledByView
,
isViewAccessible
,
setFieldValue
,
shouldHideViewTitleColumn
,
}
from
'@/utils/subjectView'
;
const
EMPTY_ARRAY
=
[];
...
...
@@ -99,8 +102,11 @@ const RecordDetailDrawer = (props, ref) => {
footerActions
=
EMPTY_ARRAY
,
autoSwitchToLastTab
=
false
,
isViewDisabled
:
isViewDisabledProp
,
useAuditMaterialCenter
=
false
,
}
=
props
;
const
currentUser
=
useContext
(
UserContext
);
const
[
refreshKey
,
setRefreshKey
]
=
useState
(()
=>
Date
.
now
());
const
[
isFullscreen
,
setIsFullscreen
]
=
useState
(
false
);
const
[
activeTabKey
,
setActiveTabKey
]
=
useState
();
...
...
@@ -114,6 +120,7 @@ const RecordDetailDrawer = (props, ref) => {
const
[
actionLoadingKey
,
setActionLoadingKey
]
=
useState
(
''
);
const
[
lastTabSwitchRevision
,
setLastTabSwitchRevision
]
=
useState
(
''
);
const
editSessionRef
=
useRef
(
0
);
const
pendingLocalMaterialsRef
=
useRef
({});
const
{
editableRecord
,
...
...
@@ -147,6 +154,7 @@ const RecordDetailDrawer = (props, ref) => {
setValidationErrors
({});
setSaveError
(
''
);
setLastTabSwitchRevision
(
''
);
pendingLocalMaterialsRef
.
current
=
{};
editSessionRef
.
current
+=
1
;
},
[
open
,
recordName
]);
...
...
@@ -160,7 +168,6 @@ const RecordDetailDrawer = (props, ref) => {
const
filtered
=
(
views
||
[])
.
filter
((
view
)
=>
view
.
type
===
'detail'
)
.
filter
((
view
)
=>
(
filterView
.
length
===
0
?
true
:
filterView
.
includes
(
view
.
name
)))
.
filter
((
view
)
=>
isViewAccessible
(
view
))
.
filter
((
view
)
=>
checkViewCondition
(
view
.
settings
?.
visabled
,
remoteRecord
));
if
(
filtered
.
length
>
0
)
{
...
...
@@ -248,6 +255,7 @@ const RecordDetailDrawer = (props, ref) => {
const
activeView
=
enabledDetailViews
.
find
((
view
)
=>
view
.
name
===
activeTabKey
)
||
enabledDetailViews
[
enabledDetailViews
.
length
-
1
]
||
detailViews
[
0
];
const
hideRecordTitle
=
shouldHideViewTitleColumn
(
activeView
);
const
isRecordLocked
=
remoteRecord
?.
metadata
?.
is_locked
===
true
;
const
effectiveReadonly
=
readonly
||
isRecordLocked
;
const
disabledFieldSet
=
useMemo
(
...
...
@@ -330,6 +338,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields
({});
setValidationErrors
({});
setSaveError
(
''
);
pendingLocalMaterialsRef
.
current
=
{};
};
const
handleRequestClose
=
()
=>
{
...
...
@@ -345,6 +354,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields
({});
setValidationErrors
({});
setSaveError
(
''
);
pendingLocalMaterialsRef
.
current
=
{};
setIsEditing
(
true
);
};
...
...
@@ -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
()
=>
{
if
(
!
hasDirtyFields
||
hasValidationErrors
||
isSaving
)
return
;
...
...
@@ -394,10 +433,31 @@ const RecordDetailDrawer = (props, ref) => {
setSaveError
(
''
);
try
{
const
pendingMaterials
=
Object
.
entries
(
pendingLocalMaterialsRef
.
current
)
.
filter
(([
fieldPath
])
=>
dirtyFields
[
fieldPath
]);
for
(
const
[
fieldPath
,
fieldMaterials
]
of
pendingMaterials
)
{
for
(
const
pendingMaterial
of
fieldMaterials
)
{
if
(
pendingMaterial
.
materialRecordId
)
continue
;
const
createdMaterial
=
await
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
));
setIsEditing
(
false
);
setDirtyFields
({});
setValidationErrors
({});
pendingLocalMaterialsRef
.
current
=
{};
onDataChange
?.(
true
);
}
catch
(
saveFailure
)
{
setSaveError
(
saveFailure
?.
message
||
'保存失败,请稍后重试。'
);
...
...
@@ -412,7 +472,7 @@ const RecordDetailDrawer = (props, ref) => {
displayedRecord
?.
name
||
recordName
||
'记录'
;
const
showRecordTitle
=
loading
||
!
isPlaceholderTitle
(
recordTitle
);
const
showRecordTitle
=
!
hideRecordTitle
&&
(
loading
||
!
isPlaceholderTitle
(
recordTitle
)
);
return
(
<
Modal
.
Root
>
...
...
@@ -547,9 +607,11 @@ const RecordDetailDrawer = (props, ref) => {
mode=
{
isEditing
?
'edit'
:
'view'
}
onFieldChange=
{
handleDraftFieldChange
}
onFieldValidation=
{
handleFieldValidation
}
onPendingLocalMaterialChange=
{
handlePendingLocalMaterialChange
}
readonly=
{
effectiveReadonly
}
isFieldDisabled=
{
isDrawerFieldDisabled
}
minimal=
{
minimalContent
}
useAuditMaterialCenter=
{
useAuditMaterialCenter
}
/>
</
div
>
)
}
...
...
src/pages/project_manager_xy/AuditProjectListPage.jsx
View file @
fd8348b3
...
...
@@ -12,8 +12,6 @@ import {
transitionAuditProject
,
}
from
'./AuditProjectService'
;
const
HIDDEN_LIST_COLUMNS
=
[
'title'
];
function
hasStatus
(
record
,
statuses
)
{
return
statuses
.
includes
(
record
?.
metadata
?.
project_status
);
}
...
...
@@ -92,7 +90,6 @@ export default function AuditProjectListPage() {
description=
"按线上视图配置展示审计项目,支持详情维护、状态流转、导出和批量归档。"
allowEdit=
{
false
}
allowClone=
{
false
}
hiddenColumnFields=
{
HIDDEN_LIST_COLUMNS
}
buildSummaryItems=
{
()
=>
[]
}
toolbarActions=
{
[
{
...
...
@@ -118,6 +115,7 @@ export default function AuditProjectListPage() {
headerActions
:
HEADER_ACTIONS
,
footerActions
:
FOOTER_ACTIONS
,
autoSwitchToLastTab
:
true
,
useAuditMaterialCenter
:
true
,
}
}
emptyDescription=
"当前视图暂无审计项目,可以切换视图、清空筛选或新建记录。"
/>
...
...
src/pages/project_manager_xy/Layout.jsx
View file @
fd8348b3
...
...
@@ -10,6 +10,10 @@ import {
Project
,
}
from
'@/components/AppIcons'
;
import
ApplicationSwitcherDialog
from
'./ApplicationSwitcherDialog'
;
import
{
loadUnreadNotificationCount
,
NOTIFICATION_STATE_EVENT
,
}
from
'./notifications/notificationService'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
import
{
useContext
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
...
...
@@ -50,6 +54,7 @@ export default function ProjectManagerLayout() {
const
siteInfo
=
useContext
(
SiteContext
);
const
[
sidebarOpen
,
setSidebarOpen
]
=
useState
(
true
);
const
[
isApplicationDialogOpen
,
setIsApplicationDialogOpen
]
=
useState
(
false
);
const
[
unreadNotificationCount
,
setUnreadNotificationCount
]
=
useState
(
0
);
const
currentTitle
=
useMemo
(
()
=>
ROUTE_TITLES
[
location
.
pathname
]
||
APPLICATION_DISPLAY_NAME
,
...
...
@@ -60,10 +65,33 @@ export default function ProjectManagerLayout() {
document
.
title
=
`
${
currentTitle
}
-
${
APPLICATION_DISPLAY_NAME
}
`
;
},
[
currentTitle
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
const
refreshUnreadCount
=
async
()
=>
{
try
{
const
count
=
await
loadUnreadNotificationCount
();
if
(
!
cancelled
)
setUnreadNotificationCount
(
count
);
}
catch
(
error
)
{
if
(
!
cancelled
)
setUnreadNotificationCount
(
0
);
}
};
refreshUnreadCount
();
window
.
addEventListener
(
NOTIFICATION_STATE_EVENT
,
refreshUnreadCount
);
const
timer
=
window
.
setInterval
(
refreshUnreadCount
,
60000
);
return
()
=>
{
cancelled
=
true
;
window
.
removeEventListener
(
NOTIFICATION_STATE_EVENT
,
refreshUnreadCount
);
window
.
clearInterval
(
timer
);
};
},
[
location
.
pathname
]);
return
(
<>
<
AppShell
menuGroups=
{
MENU_GROUPS
}
notificationCount=
{
unreadNotificationCount
}
notificationPath=
"/notifications"
onLogout=
{
userInfo
?.
logout
}
onOpenApplicationSwitcher=
{
()
=>
setIsApplicationDialogOpen
(
true
)
}
open=
{
sidebarOpen
}
...
...
src/pages/project_manager_xy/SubjectWorkbenchService.js
View file @
fd8348b3
...
...
@@ -5,6 +5,7 @@ import {
}
from
'@/services/DataService'
;
import
{
buildViewColumns
,
buildViewSearchColumns
,
checkViewCondition
,
formatFieldValue
,
getFieldValue
,
...
...
@@ -56,11 +57,14 @@ export async function queryAllSubjectRecords(subjectName, query = {}) {
}
export
async
function
loadSubjectWorkbenchResources
(
subjectName
,
query
=
{})
{
const
[
records
,
subject
,
views
]
=
await
Promise
.
all
([
queryAllSubjectRecords
(
subjectName
,
query
),
const
[
subject
,
views
]
=
await
Promise
.
all
([
loadSubject
(
subjectName
),
getSubjectView
(
subjectName
),
]);
const
accessibleGridViews
=
buildGridViews
(
views
||
[]);
const
records
=
accessibleGridViews
.
length
>
0
?
await
queryAllSubjectRecords
(
subjectName
,
query
)
:
[];
return
{
records
:
records
.
map
(
normalizeWorkbenchRecord
),
...
...
@@ -70,29 +74,25 @@ export async function loadSubjectWorkbenchResources(subjectName, query = {}) {
}
export
function
buildGridViews
(
views
=
[])
{
const
gridViews
=
views
.
filter
((
view
)
=>
view
.
type
===
'grid'
)
.
filter
((
view
)
=>
isViewAccessible
(
view
));
const
configuredGridViews
=
views
.
filter
((
view
)
=>
view
.
type
===
'grid'
);
const
gridViews
=
configuredGridViews
.
filter
((
view
)
=>
isViewAccessible
(
view
));
if
(
gridViews
.
length
>
0
)
{
if
(
gridViews
.
length
>
0
||
configuredGridViews
.
length
>
0
)
{
return
gridViews
;
}
return
[
{
title
:
'全部记录'
,
type
:
'grid'
,
name
:
'all'
,
settings
:
{},
},
];
const
fallbackView
=
{
title
:
'全部记录'
,
type
:
'grid'
,
name
:
'all'
,
settings
:
{}
};
return
isViewAccessible
(
fallbackView
)
?
[
fallbackView
]
:
[];
}
export
function
buildConfiguredWorkbenchView
(
records
,
filters
,
view
,
subject
)
{
if
(
!
view
)
return
{
columns
:
[],
filteredRecords
:
[]
};
const
keyword
=
normalizeText
(
filters
?.
keyword
).
toLowerCase
();
const
status
=
normalizeText
(
filters
?.
status
);
const
year
=
normalizeText
(
filters
?.
year
);
const
columns
=
buildViewColumns
(
view
,
subject
);
const
searchColumns
=
buildViewSearchColumns
(
view
,
subject
);
const
filteredRecords
=
records
.
filter
((
record
)
=>
{
const
rawRecord
=
record
.
raw
||
record
;
const
metadata
=
rawRecord
?.
metadata
||
{};
...
...
@@ -125,7 +125,7 @@ export function buildConfiguredWorkbenchView(records, filters, view, subject) {
return
true
;
}
const
searchText
=
c
olumns
const
searchText
=
searchC
olumns
.
map
((
column
)
=>
formatFieldValue
(
getFieldValue
(
rawRecord
,
column
.
field
),
column
.
fieldDef
))
.
join
(
' '
)
.
toLowerCase
();
...
...
src/pages/project_manager_xy/auditProjectMaterialService.js
0 → 100644
View file @
fd8348b3
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
,
};
}
src/pages/project_manager_xy/components/AuditProjectMaterialField.jsx
0 → 100644
View file @
fd8348b3
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>
);
}
src/pages/project_manager_xy/notifications/NotificationListPage.jsx
0 → 100644
View file @
fd8348b3
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
>
);
}
src/pages/project_manager_xy/notifications/notificationService.js
0 → 100644
View file @
fd8348b3
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
));
}
}
src/pages/project_manager_xy/project-management.business-flow.md
View file @
fd8348b3
...
...
@@ -12,6 +12,7 @@
| 往期项目归档 |
`/audit_project_archive`
| 只读查看审计项目历史快照 |
| 项目表单 |
`/project_form`
| 维护动态表单及字段 |
| 审计项目填写表 |
`/audit_project_form`
| 兼容外部填写和复核链接 |
| 通知消息 |
`/notifications`
| 查看消息和待办,并处理已读、动态动作及关联流程跳转 |
配置中的
`audit_indicator`
、
`risk_indicator`
、
`audit_process`
、
`audit_report`
没有菜单入口,属于复制配置残留,不注册为本应用路由。
...
...
@@ -26,15 +27,17 @@
## 2. 项目列表与详情
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`
时按数据定义显示为“项目完成时间”;
内部
`metadata.*`
路径不作为可见表头,字段取值路径和服务端配置保持不变。
3.
新建和编辑时业务字段统一写入
`metadata`
;引用和用户字段只提交 ID,日期提交 ISO 8601。
4.
编辑模式下主键字段禁用;必填字段按 Subject 配置校验。
5.
新建与编辑弹窗的空控件按可见字段标签显示输入、选择或搜索提示;提示仅用于界面引导,不参与校验,也不写入提交数据。
6.
删除调用
`POST /record/batch/remove`
,默认软删除;列表行内不提供编辑、复制或归档按钮,字段维护统一从详情侧栏进入。
7.
详情弹窗按可访问的 detail 视图显示字段,并支持字段内联维护;标题、阶段 Tabs 和顶部业务
操作固定在 Header,字段内容只在 Body 内纵向滚动,滚动内容不得穿过或遮挡阶段 Tabs。
7.
详情弹窗按符合条件的 detail 视图显示字段,并支持字段内联维护;当前 Detail View 的
`settings.shortTitle === true`
时隐藏记录标题和
`title`
字段,但始终保留“记录详情”功能标题和
Modal 可访问名称。标题、阶段 Tabs 和顶部业务操作固定在 Header,字段内容只在 Body 内纵向
滚动,滚动内容不得穿过或遮挡阶段 Tabs。
8.
列表页不展示前端写死的项目总数、完成率等摘要卡片,仅展示由服务端 Subject/View 配置驱动的视图、筛选和表格内容;项目统计统一在驾驶舱查看。
9.
业务列表、动态表单数据表和关联记录表中的长文本最多展示两行,内容始终限制在所属列内;超出内容通过 HeroUI Tooltip 或项目详情查看,避免遮挡相邻列或由单条长文本过度拉高整行。弹窗内只读关联记录表按实际数据行高度收缩,不提供无业务价值的列宽拖拽。
...
...
@@ -108,7 +111,8 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
-
`settings`
必须是合法 JSON;选择字段选项存放在
`settings.options`
。
-
审计项目中的
`form`
字段在项目详情侧栏内默认完整展开:左侧按
`settings.group`
展示默认分组和自定义分组,右侧展示当前分组下的全部表单及数据表格。
-
表单字段支持在侧栏内添加/删除分组、添加/删除表单、切换表单模板和调整所属分组;删除自定义分组时,其中已配置的表单移入默认分组。
-
表单数据行使用通用表格展示,通过 Catalyst 弹窗新增或编辑;侧栏中的修改先作为草稿保留,点击“保存表单”后统一写回
`{ form, data, settings }`
,不向业务用户暴露 JSON。
-
表单数据行使用 HeroUI Pro DataGrid 展示,通过 HeroUI Modal 新增或编辑;详情中的修改先作为
草稿保留,点击详情“保存”后统一写回
`{ form, data, settings }`
,不向业务用户暴露 JSON。
-
关联的表单模板不存在或加载失败时,在当前表单内容内显示“表单模板不可用”,不展示内部
模板标识,也不继续展示缺少定义的表格;编辑态可以重新选择有效模板,现有数据不会自动改写。
-
模板存在但缺少字段定义时显示“暂无可展示字段”,不使用已有数据对象的内部 key 临时生成
...
...
@@ -122,6 +126,32 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
-
登录页首次打开时调用
`GET /auth/captcha`
获取验证码图片和 token;登录请求在既有账号、
加密密码与
`type=password`
基础上提交
`captcha`
和
`token`
。用户可点击验证码刷新;登录
失败时清空验证码输入并重新获取验证码,成功后的 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 时按失败处理,并保留原审计项目数据。
## 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`
。全部建档成功后才调用既有业务记录更新回调;部分失败会在当前
编辑会话保留已建档标记,重试只创建尚未成功的材料,避免重复主档。
src/utils/subjectView.js
View file @
fd8348b3
import
dayjs
from
'dayjs'
;
import
{
AUTHORIZATION_APPLICATION_NAME
}
from
'@/config/application'
;
export
function
normalizeText
(
value
)
{
return
String
(
value
??
''
).
trim
();
...
...
@@ -62,60 +63,88 @@ export function getCurrentUserFromStorage() {
}
}
function
collectUserGroups
(
user
)
{
return
toArray
(
user
?.
groups
)
.
flatMap
((
group
)
=>
[
group
?.
_id
,
group
?.
name
,
group
?.
display_name
,
group
])
function
collectIdentityValues
(
values
)
{
return
toArray
(
values
)
.
flatMap
((
value
)
=>
value
&&
typeof
value
===
'object'
?
[
value
.
_id
,
value
.
name
,
value
.
display_name
,
value
.
displayName
,
value
.
username
]
:
value
)
.
map
(
normalizeText
)
.
filter
(
Boolean
);
}
function
collectUserRoles
(
user
)
{
const
directRoles
=
toArray
(
user
?.
roles
);
const
appRoles
=
toArray
(
user
?.
applications
).
flatMap
((
app
)
=>
app
?.
roles
||
[]);
return
[...
directRoles
,
...
appRoles
].
map
(
normalizeText
).
filter
(
Boolean
)
;
function
getUserApplication
(
user
,
applicationName
=
AUTHORIZATION_APPLICATION_NAME
)
{
return
toArray
(
user
?.
applications
).
find
(
(
application
)
=>
normalizeText
(
application
?.
name
)
===
normalizeText
(
applicationName
),
)
||
null
;
}
function
collectUserPermissions
(
user
)
{
const
directPermissions
=
toArray
(
user
?.
permissions
);
const
appPermissions
=
toArray
(
user
?.
applications
).
flatMap
((
app
)
=>
app
?.
permissions
||
[]);
return
[...
directPermissions
,
...
appPermissions
].
map
(
normalizeText
).
filter
(
Boolean
);
function
collectApplicationAuthorization
(
user
,
applicationName
)
{
const
application
=
getUserApplication
(
user
,
applicationName
);
return
{
groups
:
collectIdentityValues
(
application
?.
groups
),
roles
:
collectIdentityValues
(
application
?.
roles
),
permissions
:
collectIdentityValues
(
application
?.
permissions
),
};
}
function
intersects
(
left
,
right
)
{
const
rightSet
=
new
Set
(
toArray
(
right
).
map
(
normalizeText
).
filter
(
Boolean
));
return
toArray
(
left
).
some
((
item
)
=>
rightSet
.
has
(
normalizeText
(
item
)));
const
rightSet
=
new
Set
(
collectIdentityValues
(
right
));
return
collectIdentityValues
(
left
).
some
((
item
)
=>
rightSet
.
has
(
item
));
}
function
getAuthorizationRule
(
authRule
)
{
return
[
'group'
,
'role'
,
'permission'
,
'username'
].
includes
(
authRule
?.
type
)
?
authRule
:
'管理'
;
}
export
function
hasAuthAccess
(
authRule
,
user
=
getCurrentUserFromStorage
())
{
if
(
!
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
;
}
if
(
!
user
)
{
return
false
;
}
const
{
roles
}
=
collectApplicationAuthorization
(
user
,
applicationName
);
return
intersects
(
roles
,
[
'管理'
])
;
}
if
(
user
.
type
===
'admin'
)
{
return
true
;
export
function
hasAuthAccess
(
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'
)
{
return
intersects
(
collectUserGroups
(
user
)
,
authRule
.
groups
);
if
(
authRule
?
.
type
===
'group'
)
{
return
intersects
(
groups
,
authRule
.
groups
);
}
if
(
authRule
.
type
===
'role'
)
{
return
intersects
(
collectUserRoles
(
user
)
,
authRule
.
roles
);
if
(
authRule
?
.
type
===
'role'
)
{
return
intersects
(
roles
,
authRule
.
roles
);
}
if
(
authRule
.
type
===
'permission'
)
{
return
intersects
(
collectUserPermissions
(
user
)
,
authRule
.
permissions
);
if
(
authRule
?
.
type
===
'permission'
)
{
return
intersects
(
permissions
,
authRule
.
permissions
);
}
if
(
authRule
.
type
===
'username'
)
{
return
intersects
([
user
.
username
,
user
.
display_name
,
user
.
_id
],
authRule
.
usernames
);
if
(
authRule
?
.
type
===
'username'
)
{
return
intersects
([
user
.
username
],
authRule
.
usernames
);
}
return
tru
e
;
return
fals
e
;
}
export
function
getFieldValue
(
record
,
fieldPath
)
{
...
...
@@ -249,12 +278,18 @@ export function normalizeRecordLabel(value) {
return
normalizeText
(
value
);
}
export
function
shouldHideViewTitleColumn
(
view
)
{
return
view
?.
settings
?.
shortTitle
===
true
;
}
export
function
buildViewColumns
(
view
,
subject
,
{
includeHidden
=
false
}
=
{})
{
const
configuredColumns
=
view
?.
settings
?.
columns
||
[];
const
hideTitleColumn
=
!
includeHidden
&&
shouldHideViewTitleColumn
(
view
);
if
(
configuredColumns
.
length
>
0
)
{
return
configuredColumns
.
filter
((
column
)
=>
includeHidden
||
!
column
.
hide
)
.
filter
((
column
)
=>
(
includeHidden
||
!
column
.
hide
)
&&
(
!
hideTitleColumn
||
column
.
field
!==
'title'
))
.
map
((
column
)
=>
{
const
fieldName
=
getFieldNameFromPath
(
column
.
field
);
const
configuredFieldDef
=
getFieldDefinition
(
column
.
field
,
subject
);
...
...
@@ -274,12 +309,14 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
}
const
defaultColumns
=
[];
if
(
!
hideTitleColumn
)
{
defaultColumns
.
push
({
field
:
'title'
,
width
:
180
,
label
:
'标题'
,
fieldDef
:
{
name
:
'title'
,
label
:
'标题'
,
type
:
'text'
},
});
}
toArray
(
subject
?.
fields
).
forEach
((
field
)
=>
{
const
fieldPath
=
`metadata.
${
field
.
name
}
`
;
...
...
@@ -298,6 +335,21 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
return
defaultColumns
;
}
export
function
buildViewSearchColumns
(
view
,
subject
)
{
const
displayColumns
=
buildViewColumns
(
view
,
subject
);
if
(
displayColumns
.
some
((
column
)
=>
column
.
field
===
'title'
))
return
displayColumns
;
return
[
{
field
:
'title'
,
width
:
180
,
label
:
'标题'
,
fieldDef
:
getFieldDefinition
(
'title'
,
subject
),
},
...
displayColumns
,
];
}
function
compareRuleValue
(
recordValue
,
comparator
,
expectedValue
)
{
const
expectedValues
=
toArray
(
expectedValue
).
map
(
normalizeText
);
const
recordValues
=
toArray
(
recordValue
).
map
((
item
)
=>
{
...
...
@@ -356,18 +408,31 @@ export function checkViewCondition(condition, record) {
:
results
.
every
(
Boolean
);
}
export
function
isViewAccessible
(
view
,
user
=
getCurrentUserFromStorage
())
{
return
hasAuthAccess
(
view
?.
settings
?.
viewAuth
,
user
);
export
function
isViewAccessible
(
view
,
user
=
getCurrentUserFromStorage
(),
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
return
hasAuthAccess
(
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
);
if
(
disabledColumn
)
{
return
true
;
}
if
(
hasApplicationManagementAccess
(
user
,
applicationName
))
{
return
false
;
}
const
fieldAuthRule
=
toArray
(
view
?.
settings
?.
auth
).
find
((
rule
)
=>
rule
.
field
===
fieldPath
);
return
fieldAuthRule
?
!
hasAuthAccess
(
fieldAuthRule
,
user
)
:
false
;
return
!
hasAuthAccess
(
getAuthorizationRule
(
fieldAuthRule
),
user
,
applicationName
)
;
}
export
function
recordMatchesRestrictions
(
record
,
restrictions
=
[])
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment