Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
I
incorrupt_party_build
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
incorrupt_party_build
Commits
5f6746ea
Commit
5f6746ea
authored
Aug 26, 2026
by
Andy-bubu
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: align cross-app Xinyuan capabilities
parent
4ecd1406
Hide whitespace changes
Inline
Side-by-side
Showing
20 changed files
with
850 additions
and
250 deletions
+850
-250
.umirc.ts
.umirc.ts
+5
-0
ui-architecture.md
docs/ui-architecture.md
+10
-1
ui-migration-checklist.md
docs/ui-migration-checklist.md
+25
-4
xinyuan-design-system.md
docs/xinyuan-design-system.md
+9
-0
xinyuan-ui-audit.md
docs/xinyuan-ui-audit.md
+13
-1
AppIcons.jsx
src/components/AppIcons.jsx
+2
-0
AppShell.jsx
src/components/AppShell.jsx
+4
-0
DashboardNavbar.jsx
src/components/DashboardNavbar.jsx
+13
-3
FileUploadField.jsx
src/components/FileUploadField.jsx
+4
-2
XinyuanAutocomplete.jsx
src/components/XinyuanAutocomplete.jsx
+26
-96
ApplicationConfig.js
src/config/ApplicationConfig.js
+3
-1
Layout.jsx
src/pages/project_manager_xy/Layout.jsx
+18
-1
integrity-party.business-flow.md
...pages/project_manager_xy/integrity-party.business-flow.md
+24
-8
IntegrityPartyMaterialField.jsx
...anager_xy/integrity-party/IntegrityPartyMaterialField.jsx
+72
-0
index.jsx
src/pages/project_manager_xy/integrity-party/index.jsx
+168
-105
integrityPartyMaterialService.js
...pages/project_manager_xy/integrityPartyMaterialService.js
+69
-0
integrityPartyService.js
src/pages/project_manager_xy/integrityPartyService.js
+32
-28
NotificationListPage.jsx
...project_manager_xy/notifications/NotificationListPage.jsx
+145
-0
notificationService.js
...s/project_manager_xy/notifications/notificationService.js
+96
-0
subjectView.js
src/utils/subjectView.js
+112
-0
No files found.
.umirc.ts
View file @
5f6746ea
...
...
@@ -9,6 +9,7 @@ const ROUTE_PATHS = {
login
:
'/login'
,
resourceStat
:
'/resource/stat'
,
integrityParty
:
'/integrity-party'
,
notifications
:
'/notifications'
,
}
as
const
;
export
default
defineConfig
({
...
...
@@ -47,6 +48,10 @@ export default defineConfig({
path
:
ROUTE_PATHS
.
integrityParty
,
component
:
'./project_manager_xy/integrity-party/index'
,
},
{
path
:
ROUTE_PATHS
.
notifications
,
component
:
'./project_manager_xy/notifications/NotificationListPage'
,
},
],
},
],
...
...
docs/ui-architecture.md
View file @
5f6746ea
...
...
@@ -26,6 +26,15 @@
-
日期 UI 值在页面/服务边界转换,网络载荷保持 ISO 8601。
-
UI 迁移不得改变路由、权限、请求时机、Excel 映射、标题生成、批量处理或错误策略。
## View、通知与材料事务
-
`src/config/ApplicationConfig.js`
以
`APPLICATION_NAME = "incorrupt_party_build"`
统一路由、授权和材料来源标识;
`src/utils/subjectView.js`
只解析
`user.applications[]`
中该应用项,不合并顶层身份或其它应用权限。
-
`integrityPartyService.loadLedgerViewResources`
加载 Subject、View summaries/details 并过滤可访问 Grid View。没有真实可访问 Grid View 时不创建兜底 View、不查询记录,也不显示新增、批量或行操作。
-
Detail 存在任一有效
`settings.auth`
或
`settings.disableColumns`
时整套作为字段权限来源;两项都未配置或没有实际 Detail 时整体回退当前 Grid。两套权限不合并、不逐字段回退,选定来源中的
`disableColumns`
对所有用户始终只读。
-
Detail
`columns`
只决定字段展示范围和顺序,
`auth`
独立决定编辑能力;普通用户在选定权限来源中缺少字段
`auth`
时只读。切换 Detail 或 Grid 后展示和字段权限立即重新计算。
-
`notifications/notificationService.js`
复用平台 Notice API;页面负责搜索、分页、单条/全部已读、动态 action 与 base-aware 跳转,Shell 只维护 60 秒未读轮询和 Navbar Badge。
-
`IntegrityPartyMaterialField`
管理文件快照和待建档列表;
`pendingLocalMaterialsRef`
属于一次详情编辑会话。保存时先补建未建档
`am_material`
,全部成功后才写业务 Record;失败保留已建档标记供本次重试跳过,关闭弹窗清空会话。
## 依赖来源
-
当前仓库通过官方
`hpsetup`
独立安装 HeroUI Pro,精确版本为
`1.0.0-beta.8`
,npm lockfile 解析到官方 registry tarball。
...
...
@@ -37,4 +46,4 @@
已移除活动源码中的
`src/catalyst-ui-kit`
、Headless UI ApplicationSwitcher、旧
`FormSelect`
DOM API、旧 Table 兼容解析和页容量 Select。业务列表与只读 Excel 导入预览均使用 HeroUI Pro DataGrid。
Native HeroUI 静态架构门禁已通过:
`src/xinyuan-portable.css`
与 ECharts 已移除,页面目录无私有 CSS/Less/SCSS;仅保留与确认基准逐字节一致的共享样式层。HeroUI 与 HeroUI Pro 的样式分别通过
`@heroui/styles/css`
和
`@heroui-pro/react/css`
显式加载;包根
`@heroui/styles`
是 JavaScript 样式工具入口,不能替代 CSS 入口。
运行态浏览器门禁仍需在浏览器实例可用后完成
。
Native HeroUI 静态架构门禁已通过:
`src/xinyuan-portable.css`
与 ECharts 已移除,页面目录无私有 CSS/Less/SCSS;仅保留与确认基准逐字节一致的共享样式层。HeroUI 与 HeroUI Pro 的样式分别通过
`@heroui/styles/css`
和
`@heroui-pro/react/css`
显式加载;包根
`@heroui/styles`
是 JavaScript 样式工具入口,不能替代 CSS 入口。
2026-08-26 已完成四视口、深浅主题、权限、通知和文件事务的运行态浏览器门禁
。
docs/ui-migration-checklist.md
View file @
5f6746ea
...
...
@@ -40,11 +40,11 @@
-
[
x
]
HeroUI / React Aria 依赖树检查
-
[
x
]
HeroUI / HeroUI Pro 官方 CSS 入口进入生产构建产物
-
[
x
]
Pro CSS 文件和 sidebar/navbar/data-grid/drop-zone/action-bar/empty-state ESM 子路径
-
[
]
1440x900、1280x720、768x1024、390x844
-
[
]
浅色/深色、打开态弹层、选择与 Checkbox、Modal、Sidebar
-
[
]
控制台
无 runtime、slot、provider、重复 Context 错误
-
[
x
]
1440x900、1280x720、768x1024、390x844
-
[
x
]
浅色/深色、打开态弹层、选择与 Checkbox、Modal、Sidebar
-
[
x
]
活动页面
无 runtime、slot、provider、重复 Context 错误
运行态验收说明:2026-08-
19 本地开发服务已启动,但当前执行环境未暴露可连接的应用内浏览器或 Chrome 实例,以上三项浏览器门禁尚未执行,不能以静态检查替代
。
运行态验收说明:2026-08-
26 使用 Playwright CLI 隔离会话完成四视口与深浅主题检查;1440x900 浅色、1280x720/768x1024/390x844 深色台账均已截图,390px
`scrollWidth === 390`
。通知页额外完成 1440/390 浅色截图。既有 HeroUI Pro Mobile Sidebar 隐藏 Dialog title 告警仍存在,但活动页面无运行时错误,未发现 slot、provider 或重复 Context 错误
。
## 2026-08-26 应用切换专项验收
...
...
@@ -55,3 +55,24 @@
-
[
x
]
Escape、关闭按钮、焦点约束、dialog accessible name 与
`aria-current`
通过。
-
[
x
]
应用切换自身无控制台错误或告警。
-
[
]
390px 从 Mobile Sidebar 入口打开时仍会触发既有 Sidebar Dialog title 告警,与本次应用切换组件无关,未扩大范围修改。
## 2026-08-26 跨应用五项能力
-
[
x
]
应用归属判定为完整功能白名单“党风廉政建设”。
-
[
x
]
Grid
`shortTitle`
只控制 title 展示列,操作列入口保留。
-
[
x
]
Detail
`shortTitle`
随当前 Detail View 更新,Modal 功能标题始终保留。
-
[
x
]
`viewAuth/settings.auth/disableColumns`
只读取
`incorrupt_party_build`
授权;无权限不查询、不显示操作。
-
[
x
]
对齐问题整改
`c047ec4`
:稳定
`APPLICATION_NAME`
统一为
`incorrupt_party_build`
,不合并其它应用身份,授权类型只接受 group/role/permission。
-
[
x
]
对齐问题整改
`6763c26`
:Detail 有有效
`auth/disableColumns`
时整套优先,两项均无配置时整体回退 Grid;不合并、不逐字段回退。
-
[
x
]
无真实可访问 Grid View 时不再为管理员或本应用“管理”角色创建静态兜底 View。
-
[
x
]
同步本机
`$xinyuan-design-system`
跨应用契约并通过 Skill 校验,移除旧的 Grid-only 详情授权规则。
-
[
x
]
`/notifications`
、消息/待办、全部/未读、搜索、固定 10 条分页、单条/全部已读、动态 action 与跳转。
-
[
x
]
Navbar 原生 Badge Bell,0 隐藏、1-99 原值、100+ 为
`99+`
,Sidebar 无通知项。
-
[
x
]
文件字段按
`multiple`
支持材料中心与本地上传;材料远程搜索标题/文件名并只保存文件快照。
-
[
x
]
本地文件保存时建档、全部成功后更新业务记录、失败重试跳过已建档项、取消不建档。
-
[
x
]
管理员/管理角色/普通/无权限浏览器 fixture:其它应用“管理”不串权;本应用“管理”和系统管理员可旁路
`auth`
,但均不能旁路 Detail
`disableColumns`
;普通无权限时新增/行操作和
`/record/query`
均为 0。
-
[
x
]
Detail 权限来源浏览器 fixture:完整配置通过
`GET /subject/:subject/view/:view`
加载;Detail 有配置时优先、无配置或无实际 Detail 时整体回退 Grid;Detail 只配置其它字段时,当前展示字段不会逐字段回退 Grid。
-
[
x
]
零 Grid 浏览器 fixture:管理员也不建立兜底 View,新增按钮和
`/record/query`
均为 0;当前真实后端前五个 Subject 无 Grid View,因此按合同显示无可访问视图。
-
[
x
]
通知 0/1/99/100、已读、全部已读、动态 action 与内外链浏览器 fixture 验收:Badge 分别为隐藏/1/99/99+;搜索、未读筛选、第 2 页、单条/全部已读均命中原接口;动态 action 发送
`{notice_id}`
;站内链接落到当前 base,外链新窗口打开。
-
[
x
]
适用文件字段的材料选择、本地上传、取消、失败与重试浏览器 fixture 验收:当前六类台账的活动文件字段均为
`multiple: true`
,无活动单值文件字段;材料中心打开态和远程列表已验证。本地上传夹具中上传 1 次、材料建档 1 次、业务保存 2 次,首次业务失败后弹窗保留,重试不重复建档;取消夹具中上传 1 次、材料/业务创建均为 0。
-
[
x
]
文件 payload 验收:业务附件只包含
`{name,type,size,url}`
,不含材料 ID 或内部重试标记;材料记录
`source_app`
为
`incorrupt_party_build`
。
docs/xinyuan-design-system.md
View file @
5f6746ea
...
...
@@ -46,3 +46,12 @@
## 可访问性与验收
所有纯图标操作需要 Tooltip 和可访问名称;Modal 保持焦点约束;Popover、Dropdown、Tooltip 和 Modal 必须继承根主题。验收覆盖 1440x900、1280x720、768x1024、390x844,包含浅色、深色、Sidebar、打开态弹层、选择状态和移动端溢出。
## 跨应用能力
-
本应用显示名“党风廉政建设”属于完整功能白名单,实施 Grid/Detail
`shortTitle`
、Subject View 权限、消息通知、材料中心与保存时建档五项能力。
-
稳定应用标识为
`APPLICATION_NAME = "incorrupt_party_build"`
;授权只读取
`user.applications[]`
中该应用项的角色、组和权限,不合并其它应用或顶层角色。管理员、系统账号及本应用“管理”角色可旁路
`viewAuth/auth`
,但不能旁路
`disableColumns`
。
-
Grid View 缺少
`viewAuth`
时普通用户默认拒绝;无可访问 Grid View 时不得查询记录或展示新增、批量、选择和行操作。仅一个可访问 View 时隐藏二级 Tabs。
-
详情字段权限优先使用当前 Detail 的完整
`auth/disableColumns`
;Detail 两项均未有效配置或不存在实际 Detail 时整体回退当前 Grid。Detail 与 Grid 不合并、不逐字段回退,
`columns`
与字段授权独立。
-
Navbar 通知使用
`Badge.Anchor + Badge + Button + Tooltip`
,Bell 固定在主题按钮左侧;Sidebar 不增加通知入口。应用路由为
`/notifications`
。
-
文件字段按运行时
`multiple`
使用“材料中心 / 本地上传”。材料选择仅保存
`{name,type,size,url}`
;本地上传只形成草稿,保存详情时才以
`source_app: incorrupt_party_build`
创建
`am_material`
。
docs/xinyuan-ui-audit.md
View file @
5f6746ea
...
...
@@ -73,4 +73,16 @@
## 工具能力说明
当前会话未暴露 HeroUI Pro MCP 的
`list_components`
/
`get_component_docs`
工具。组件 API 以当前仓库已安装包的真实 exports、类型声明和确认基准实现为依据;不得猜测 API。浏览器门禁必须在可用的应用内浏览器实例出现后执行,期间不得标记完成。
当前会话未暴露 HeroUI Pro MCP 的
`list_components`
/
`get_component_docs`
工具。组件 API 以当前仓库已安装包的真实 exports、类型声明和确认基准实现为依据;不得猜测 API。2026-08-26 已改用 Playwright CLI 隔离会话完成四视口、深浅主题、权限、通知与文件事务浏览器门禁,结果记录于迁移清单。
## 2026-08-26 五项能力审计
-
范围判定:本应用为“党风廉政建设”,属于六个完整功能应用;实施功能 1-5。
-
Subject View:六个 Subject 中前五个无 View;
`integrity_party_holiday_reminder`
有一个 Grid View
`view_TqTjZv`
。迁移前页面未加载 View、未执行
`viewAuth/settings.auth/disableColumns`
,也未支持
`shortTitle`
。
-
授权结构:当前账号 Andy 为
`admin`
且
`applications=[]`
,用于管理员旁路验收;授权边界固定为
`incorrupt_party_build`
。
-
文件协议:现有文件字段为
`{name,type,size,url}[]`
,上传调用
`/upload`
;迁移前不会创建
`am_material`
。新流程保留快照协议并新增保存时建档。
-
通知协议:
`DataService.js`
已有
`queryUserNotice/readUserNotice/unReadNoticeCount/exacteActionFunction`
,迁移前无通知路由与 Bell。
-
路由与 Shell:base 为
`/incorrupt_party_build`
;站内通知链接只剥离该 base,外部或其它应用链接新窗口打开。
-
应用切换:当前
`ApplicationSwitcherDialog`
已使用 HeroUI Modal,
`sm:grid-cols-2`
严格两列,无需结构变更。
-
2026-08-26 权限复查:按问题整改
`c047ec4`
增加稳定
`APPLICATION_NAME = "incorrupt_party_build"`
,授权仅取当前应用项;按
`6763c26`
改为 Detail 完整字段权限优先、无配置时整体回退 Grid,并移除管理员无真实 Grid 时的伪造兜底 View。
-
当前真实后端前五个台账 Subject 没有 Grid View。移除兜底后这些分类对管理员也不发起 Record 查询,需由后台配置真实 Grid View 后才可访问;这是最新跨应用权限合同的预期行为,不再以静态页面配置代替授权 View。
src/components/AppIcons.jsx
View file @
5f6746ea
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'
;
...
...
@@ -58,6 +59,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 @
5f6746ea
...
...
@@ -23,6 +23,8 @@ function getInitialTheme() {
export
default
function
AppShell
({
children
,
notificationCount
,
notificationPath
,
open
,
onOpenChange
,
title
,
...
...
@@ -60,6 +62,8 @@ export default function AppShell({
/>
<
Sidebar
.
Main
className=
"app-main-glass rc-app-main"
>
<
DashboardNavbar
notificationCount=
{
notificationCount
}
onOpenNotifications=
{
()
=>
history
.
push
(
notificationPath
)
}
theme=
{
theme
}
title=
{
title
}
onThemeChange=
{
setTheme
}
...
...
src/components/DashboardNavbar.jsx
View file @
5f6746ea
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
count
=
Math
.
max
(
0
,
Number
(
notificationCount
)
||
0
);
return
(
<
Navbar
className=
"app-navbar rc-navbar"
maxWidth=
"full"
>
...
...
@@ -20,6 +21,15 @@ 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"
>
<
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
>
{
count
>
0
?
<
Badge
aria
-
label=
{
`${count}条未读通知`
}
color=
"danger"
size=
"sm"
>
{
count
>
99
?
'99+'
:
count
}
</
Badge
>
:
null
}
</
Badge
.
Anchor
>
<
Tooltip
.
Content
>
通知消息
</
Tooltip
.
Content
>
</
Tooltip
>
<
Tooltip
delay=
{
0
}
>
<
Button
isIconOnly
...
...
src/components/FileUploadField.jsx
View file @
5f6746ea
...
...
@@ -9,6 +9,7 @@ export default function FileUploadField({
description
=
'将文件拖到此处,或从本地选择。'
,
disabled
=
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=
{
disabled
}
multiple=
{
multiple
}
onSelect=
{
emitFiles
}
/>
<
DropZone
.
Icon
>
...
...
src/components/XinyuanAutocomplete.jsx
View file @
5f6746ea
import
{
ChevronDown
,
Close
,
FIELD_SUFFIX_ICON_STYLE
,
Search
,
}
from
'@/components/AppIcons'
;
import
{
Autocomplete
,
Label
,
ListBox
,
SearchField
,
}
from
'@heroui/react'
;
import
{
ChevronDown
,
Close
,
FIELD_SUFFIX_ICON_STYLE
,
Search
}
from
'@/components/AppIcons'
;
import
{
Autocomplete
,
Label
,
ListBox
,
SearchField
,
Spinner
}
from
'@heroui/react'
;
function
filterOption
(
textValue
,
inputValue
)
{
function
defaultFilter
(
textValue
,
inputValue
)
{
const
query
=
String
(
inputValue
||
''
).
trim
().
toLocaleLowerCase
();
return
!
query
||
String
(
textValue
||
''
).
toLocaleLowerCase
().
includes
(
query
);
}
const
optionKey
=
(
option
)
=>
option
?.
id
??
option
?.
value
;
export
default
function
XinyuanAutocomplete
({
ariaLabel
,
className
,
emptyText
=
'暂无可选项'
,
isDisabled
=
false
,
isRequired
=
false
,
onSelectionChange
,
options
=
[],
placeholder
=
'请选择'
,
selectedKey
,
variant
=
'secondary'
,
'aria-label'
:
ariaLabelProp
,
ariaLabel
,
className
,
emptyText
=
'暂无可选项'
,
errorText
=
''
,
filter
,
fullWidth
=
true
,
inputValue
,
isDisabled
=
false
,
isLoading
=
false
,
isOpen
,
isRequired
=
false
,
name
,
onInputChange
,
onOpenChange
,
onSelectionChange
,
options
=
[],
placeholder
=
'请选择'
,
placement
=
'bottom start'
,
popoverHeader
,
renderOption
,
searchPlaceholder
=
'输入关键词搜索'
,
selectedKey
,
selectedKeys
,
selectionMode
=
'single'
,
shouldFilter
=
true
,
valueText
,
variant
=
'secondary'
,
})
{
const
selectedOption
=
options
.
find
(
(
option
)
=>
String
(
option
.
value
)
===
String
(
selectedKey
??
''
),
);
const
effectiveAriaLabel
=
ariaLabelProp
||
ariaLabel
||
placeholder
;
const
selectedSet
=
new
Set
(
selectionMode
===
'multiple'
?
Array
.
from
(
selectedKeys
||
[]).
map
(
String
)
:
selectedKey
===
null
||
selectedKey
===
undefined
||
selectedKey
===
''
?
[]
:
[
String
(
selectedKey
)]);
const
selectedOptions
=
options
.
filter
((
option
)
=>
selectedSet
.
has
(
String
(
optionKey
(
option
))));
const
resolvedValueText
=
valueText
??
(
selectedOptions
.
length
?
selectedOptions
.
map
((
option
)
=>
option
.
label
).
join
(
'、'
)
:
placeholder
);
const
selectionProps
=
selectionMode
===
'multiple'
?
{
selectedKeys
}
:
{
selectedKey
:
selectedKey
||
null
};
const
visibleOptions
=
isLoading
||
errorText
?
[]
:
options
;
return
(
<
Autocomplete
aria
-
label=
{
ariaLabel
||
placeholder
}
className=
{
className
}
fullWidth
isDisabled=
{
isDisabled
}
isRequired=
{
isRequired
}
selectedKey=
{
selectedKey
||
null
}
variant=
{
variant
}
onClear=
{
()
=>
onSelectionChange
?.(
''
)
}
onSelectionChange=
{
(
key
)
=>
onSelectionChange
?.(
key
?
String
(
key
)
:
''
)
}
>
<
Autocomplete
{
...
selectionProps
}
aria
-
label=
{
effectiveAriaLabel
}
className=
{
className
}
fullWidth=
{
fullWidth
}
isDisabled=
{
isDisabled
}
isOpen=
{
isOpen
}
isRequired=
{
isRequired
}
name=
{
name
}
selectionMode=
{
selectionMode
}
variant=
{
variant
}
onOpenChange=
{
onOpenChange
}
onSelectionChange=
{
(
key
)
=>
onSelectionChange
?.(
key
?
String
(
key
)
:
''
)
}
>
<
Autocomplete
.
Trigger
>
<
Autocomplete
.
Value
>
<
span
className=
"block min-w-0 truncate"
title=
{
selectedOption
?.
label
||
placeholder
}
>
{
selectedOption
?.
label
||
placeholder
}
</
span
>
</
Autocomplete
.
Value
>
{
selectedKey
?
(
<
Autocomplete
.
ClearButton
aria
-
label=
"清空选择"
>
<
Close
className=
"grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size=
{
16
}
style=
{
FIELD_SUFFIX_ICON_STYLE
}
/>
</
Autocomplete
.
ClearButton
>
)
:
null
}
<
Autocomplete
.
Indicator
>
<
ChevronDown
size=
{
16
}
style=
{
FIELD_SUFFIX_ICON_STYLE
}
/>
</
Autocomplete
.
Indicator
>
<
Autocomplete
.
Value
><
span
className=
"block min-w-0 truncate"
title=
{
resolvedValueText
}
>
{
resolvedValueText
}
</
span
></
Autocomplete
.
Value
>
{
selectionMode
===
'single'
&&
selectedKey
?
<
Autocomplete
.
ClearButton
aria
-
label=
"清空选择"
onPress=
{
()
=>
onSelectionChange
?.(
''
)
}
><
Close
className=
"grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size=
{
16
}
style=
{
FIELD_SUFFIX_ICON_STYLE
}
/></
Autocomplete
.
ClearButton
>
:
null
}
<
Autocomplete
.
Indicator
><
ChevronDown
size=
{
16
}
style=
{
FIELD_SUFFIX_ICON_STYLE
}
/></
Autocomplete
.
Indicator
>
</
Autocomplete
.
Trigger
>
<
Autocomplete
.
Popover
placement=
"bottom start"
>
<
Autocomplete
.
Filter
filter=
{
filterOption
}
>
<
SearchField
aria
-
label=
"搜索选项"
fullWidth
variant=
"secondary"
>
<
SearchField
.
Group
>
<
SearchField
.
SearchIcon
>
<
Search
size=
{
16
}
/>
</
SearchField
.
SearchIcon
>
<
SearchField
.
Input
autoFocus
placeholder=
"输入关键词搜索"
/>
<
SearchField
.
ClearButton
aria
-
label=
"清空搜索"
>
<
Close
size=
{
14
}
/>
</
SearchField
.
ClearButton
>
</
SearchField
.
Group
>
</
SearchField
>
<
ListBox
aria
-
label=
{
ariaLabel
||
placeholder
}
items=
{
options
}
renderEmptyState=
{
()
=>
(
<
div
className=
"grid min-h-20 place-items-center px-3 py-4 text-sm text-muted"
>
{
emptyText
}
</
div
>
)
}
>
{
(
option
)
=>
(
<
ListBox
.
Item
id=
{
String
(
option
.
value
)
}
isDisabled=
{
option
.
disabled
}
textValue=
{
option
.
label
}
>
<
Label
className=
"truncate"
title=
{
option
.
label
}
>
{
option
.
label
}
</
Label
>
<
ListBox
.
ItemIndicator
/>
</
ListBox
.
Item
>
)
}
<
Autocomplete
.
Popover
placement=
{
placement
}
>
<
Autocomplete
.
Filter
filter=
{
shouldFilter
?
filter
||
defaultFilter
:
()
=>
true
}
inputValue=
{
inputValue
}
onInputChange=
{
onInputChange
}
>
{
popoverHeader
}
<
SearchField
aria
-
label=
{
searchPlaceholder
}
fullWidth
variant=
{
variant
}
><
SearchField
.
Group
><
SearchField
.
SearchIcon
><
Search
size=
{
16
}
/></
SearchField
.
SearchIcon
><
SearchField
.
Input
autoFocus
placeholder=
{
searchPlaceholder
}
/><
SearchField
.
ClearButton
aria
-
label=
"清空搜索"
><
Close
size=
{
14
}
/></
SearchField
.
ClearButton
></
SearchField
.
Group
></
SearchField
>
<
ListBox
aria
-
label=
{
effectiveAriaLabel
}
items=
{
visibleOptions
}
renderEmptyState=
{
()
=>
<
div
className=
"grid min-h-20 place-items-center px-3 py-4"
data
-
slot=
"empty-state"
>
{
isLoading
?
<
Spinner
size=
"sm"
/>
:
errorText
||
emptyText
}
</
div
>
}
>
{
(
option
)
=>
<
ListBox
.
Item
id=
{
String
(
optionKey
(
option
))
}
isDisabled=
{
option
.
isDisabled
??
option
.
disabled
}
textValue=
{
option
.
textValue
||
option
.
label
}
>
{
renderOption
?
renderOption
(
option
)
:
<
Label
className=
"truncate"
title=
{
option
.
label
}
>
{
option
.
label
}
</
Label
>
}
<
ListBox
.
ItemIndicator
/></
ListBox
.
Item
>
}
</
ListBox
>
</
Autocomplete
.
Filter
>
</
Autocomplete
.
Popover
>
...
...
src/config/ApplicationConfig.js
View file @
5f6746ea
export
const
APP_NAME
=
'党风廉政建设'
;
export
const
APP_ROUTE_NAME
=
'incorrupt_party_build'
;
export
const
APPLICATION_NAME
=
'incorrupt_party_build'
;
export
const
APP_ROUTE_NAME
=
APPLICATION_NAME
;
export
const
AUTHORIZATION_APPLICATION_NAME
=
APPLICATION_NAME
;
export
const
APP_BASE_PATH
=
`/
${
APP_ROUTE_NAME
}
`
;
export
const
LOGIN_PATH
=
'/login'
;
...
...
src/pages/project_manager_xy/Layout.jsx
View file @
5f6746ea
import
AppShell
from
'@/components/AppShell'
;
import
{
BarChart
,
ClipboardDocumentListIcon
}
from
'@/components/AppIcons'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
useContext
,
useMemo
,
useState
}
from
'react'
;
import
{
loadUnreadNotificationCount
,
NOTIFICATION_STATE_EVENT
}
from
'./notifications/notificationService'
;
import
{
useContext
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
Outlet
,
useLocation
}
from
'umi'
;
const
menuGroups
=
[
...
...
@@ -28,18 +29,34 @@ export default function ProjectManagerLayout() {
const
location
=
useLocation
();
const
userInfo
=
useContext
(
UserContext
);
const
[
sidebarOpen
,
setSidebarOpen
]
=
useState
(
true
);
const
[
unreadNotificationCount
,
setUnreadNotificationCount
]
=
useState
(
0
);
const
title
=
useMemo
(()
=>
{
for
(
const
group
of
menuGroups
)
{
const
current
=
group
.
items
.
find
((
item
)
=>
item
.
path
===
location
.
pathname
);
if
(
current
)
return
current
.
name
;
}
if
(
location
.
pathname
===
'/notifications'
)
return
'消息通知'
;
return
'驾驶舱'
;
},
[
location
.
pathname
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
const
refresh
=
async
()
=>
{
try
{
const
count
=
await
loadUnreadNotificationCount
();
if
(
!
cancelled
)
setUnreadNotificationCount
(
count
);
}
catch
{
if
(
!
cancelled
)
setUnreadNotificationCount
(
0
);
}
};
refresh
();
window
.
addEventListener
(
NOTIFICATION_STATE_EVENT
,
refresh
);
const
timer
=
window
.
setInterval
(
refresh
,
60000
);
return
()
=>
{
cancelled
=
true
;
window
.
removeEventListener
(
NOTIFICATION_STATE_EVENT
,
refresh
);
window
.
clearInterval
(
timer
);
};
},
[
location
.
pathname
]);
return
(
<
AppShell
title=
{
title
}
notificationCount=
{
unreadNotificationCount
}
notificationPath=
"/notifications"
open=
{
sidebarOpen
}
onOpenChange=
{
setSidebarOpen
}
menuGroups=
{
menuGroups
}
...
...
src/pages/project_manager_xy/integrity-party.business-flow.md
View file @
5f6746ea
...
...
@@ -11,6 +11,7 @@
-
所有业务字段均写入
`metadata`
-
附件字段均按多附件数组保存,由系统内上传控件维护
-
Excel 导入不处理附件,导出时输出已上传附件的文件名和 URL
-
Subject View 的
`shortTitle`
、
`viewAuth`
、
`auth`
与
`disableColumns`
控制列表展示和字段编辑权限
## 2. 功能入口
...
...
@@ -49,16 +50,21 @@
9.
完整字段在编辑弹窗中维护
10.
DataGrid 固定每页展示 10 条,不提供页容量切换;通过底部 Pagination 翻页
11.
勾选记录后显示 ActionBar,集中提供导出勾选、批量修改和批量删除入口;清空选择后 ActionBar 关闭
12.
Grid View
`shortTitle=true`
时只隐藏标题列,标题数据和操作列入口保持不变;只有一个可访问 View 时不显示二级 View Tabs
13.
普通用户缺少 View 授权时不查询数据,也不显示新增、批量、选择或行操作;系统管理员及本应用“管理”角色按规则旁路
14.
权限只读取稳定应用标识
`incorrupt_party_build`
对应的
`user.applications[]`
项;其它应用的角色、组和权限不参与判断
15.
没有真实可访问 Grid View 时不建立兜底视图,即使是管理员也不查询该 Subject
### 4.2 手动新建台账
1.
用户点击
`新建记录`
2.
前端根据当前 Tab 字段配置在 HeroUI Modal 中生成表单
3.
用户填写最小必填字段
4.
附件字段通过 DropZone 上传,一个字段可保存多个附件
5.
前端自动生成
`title`
6.
前端调用
`POST /record`
7.
保存成功后提示并刷新当前列表
4.
附件字段按运行时
`multiple`
支持“材料中心 / 本地上传”;材料中心查询
`am_material`
最近 20 条并搜索标题或文件名
5.
材料中心选择只写入
`{name,type,size,url}`
快照;本地文件调用
`/upload`
后仅进入详情草稿
6.
用户保存时先逐文件创建
`am_material`
,
`source_app`
为
`incorrupt_party_build`
;全部成功后再调用
`POST /record`
7.
建档失败保留草稿和已成功建档标记,本次重试不重复建档;取消编辑不创建材料主档
8.
前端自动生成
`title`
,保存成功后提示并刷新当前列表
最小必填字段:
...
...
@@ -76,8 +82,18 @@
3.
用户修改字段或上传、移除附件
4.
前端调用
`PUT /record/:name`
5.
保存成功后刷新当前列表
6.
当前 Detail View 存在有效
`auth`
或
`disableColumns`
时整套优先作为字段权限;两项都未配置或不存在实际 Detail 时整体回退当前 Grid,二者不合并或逐字段回退
7.
Detail
`columns`
仅决定展示字段及顺序;普通用户在选定权限来源中缺少字段
`auth`
时只读,
`disableColumns`
对管理员也始终只读
### 4.4 删除台账
### 4.4 消息通知
1.
用户点击 Navbar 主题按钮左侧 Bell 进入
`/notifications`
,Sidebar 不提供通知入口
2.
页面支持消息/待办、全部/未读、300ms 搜索和固定 10 条分页
3.
用户可单条已读、全部已读,并执行后端返回的动态 action;action 参数仅为
`{notice_id}`
4.
站内链接仅去除
`/incorrupt_party_build`
base 后在当前应用跳转;其它应用和外链新窗口打开
5.
通知内容按纯文本展示,不执行 HTML;未读 Badge 为 0 时隐藏,100 及以上显示
`99+`
### 4.5 删除台账
1.
用户在编辑弹窗中点击删除,或在表格勾选后点击批量删除
2.
页面弹出受控 HeroUI 删除确认弹窗
...
...
@@ -85,7 +101,7 @@
4.
删除后刷新当前列表
5.
删除默认走软删除
### 4.
5
Excel 导入
### 4.
6
Excel 导入
1.
用户在当前 Tab 点击
`下载模板`
2.
前端导出当前分类的独立模板
...
...
@@ -96,7 +112,7 @@
7.
当前阶段不做重复判断、不自动覆盖,导入全部新增
8.
导入完成后提示成功条数和失败条数
### 4.
6
Excel 导出
### 4.
7
Excel 导出
1.
用户点击
`Excel 导出`
2.
前端按当前筛选条件分页读取全部记录
...
...
@@ -104,7 +120,7 @@
4.
附件字段导出为文件名和 URL,多附件用换行分隔
5.
用户也可以先勾选记录,再点击
`导出勾选`
### 4.
7
批量修改
### 4.
8
批量修改
1.
用户勾选一条或多条记录
2.
点击
`批量修改`
...
...
src/pages/project_manager_xy/integrity-party/IntegrityPartyMaterialField.jsx
0 → 100644
View file @
5f6746ea
import
{
ArrowPathIcon
,
Delete
}
from
'@/components/AppIcons'
;
import
FileUploadField
from
'@/components/FileUploadField'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
{
Alert
,
Button
,
Label
,
Tabs
,
Tooltip
}
from
'@heroui/react'
;
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
queryAuditMaterials
,
uploadIntegrityPartyFile
}
from
'../integrityPartyMaterialService'
;
const
toFiles
=
(
value
)
=>
Array
.
isArray
(
value
)
?
value
.
filter
(
Boolean
)
:
value
?
[
value
]
:
[];
const
fileKey
=
(
file
=
{})
=>
file
.
url
||
[
file
.
name
,
file
.
type
,
file
.
size
].
filter
(
Boolean
).
join
(
'|'
);
function
uniqueFiles
(
files
)
{
return
[...
new
Map
(
files
.
map
((
file
)
=>
[
fileKey
(
file
),
file
]).
filter
(([
key
])
=>
key
)).
values
()];
}
export
default
function
IntegrityPartyMaterialField
({
editSessionId
,
fieldPath
,
multiple
=
false
,
onChange
,
onPendingChange
,
value
,
disabled
=
false
})
{
const
[
tab
,
setTab
]
=
useState
(
'material-center'
);
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
debouncedKeyword
,
setDebouncedKeyword
]
=
useState
(
''
);
const
[
materials
,
setMaterials
]
=
useState
([]);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
retry
,
setRetry
]
=
useState
(
0
);
const
[
localKeys
,
setLocalKeys
]
=
useState
(()
=>
new
Set
());
const
files
=
useMemo
(()
=>
(
multiple
?
toFiles
(
value
)
:
toFiles
(
value
).
slice
(
0
,
1
)),
[
multiple
,
value
]);
useEffect
(()
=>
{
setTab
(
'material-center'
);
setKeyword
(
''
);
setDebouncedKeyword
(
''
);
setError
(
''
);
setLocalKeys
(
new
Set
());
},
[
editSessionId
]);
useEffect
(()
=>
{
const
timer
=
window
.
setTimeout
(()
=>
setDebouncedKeyword
(
keyword
),
300
);
return
()
=>
window
.
clearTimeout
(
timer
);
},
[
keyword
]);
useEffect
(()
=>
{
if
(
tab
!==
'material-center'
)
return
undefined
;
let
cancelled
=
false
;
setLoading
(
true
);
setError
(
''
);
queryAuditMaterials
(
debouncedKeyword
).
then
((
items
)
=>
{
if
(
!
cancelled
)
setMaterials
(
items
);
}).
catch
((
requestError
)
=>
{
if
(
!
cancelled
)
{
setMaterials
([]);
setError
(
requestError
?.
message
||
'审计材料加载失败,请稍后重试。'
);
}
}).
finally
(()
=>
{
if
(
!
cancelled
)
setLoading
(
false
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
debouncedKeyword
,
retry
,
tab
]);
function
selectMaterial
(
key
)
{
const
material
=
materials
.
find
((
item
)
=>
String
(
item
.
id
)
===
String
(
key
||
''
));
if
(
!
material
?.
fileAsset
?.
url
)
return
;
const
snapshot
=
{
name
:
material
.
fileAsset
.
name
,
type
:
material
.
fileAsset
.
type
||
'application/octet-stream'
,
size
:
Number
(
material
.
fileAsset
.
size
||
0
),
url
:
material
.
fileAsset
.
url
};
if
(
multiple
)
onChange
(
uniqueFiles
([...
files
,
snapshot
]));
else
{
setLocalKeys
(
new
Set
());
onPendingChange
(
fieldPath
,
{
type
:
'replace'
,
materials
:
[]
});
onChange
(
snapshot
);
}
}
async
function
uploadLocal
(
selected
)
{
const
inputFiles
=
multiple
?
toFiles
(
selected
)
:
toFiles
(
selected
).
slice
(
0
,
1
);
if
(
inputFiles
.
length
===
0
)
return
;
setUploading
(
true
);
setError
(
''
);
const
uploaded
=
[];
try
{
for
(
const
file
of
inputFiles
)
uploaded
.
push
(
await
uploadIntegrityPartyFile
(
file
));
const
pending
=
uploaded
.
map
((
asset
)
=>
({
clientId
:
fileKey
(
asset
),
fileAsset
:
asset
,
materialRecordId
:
null
}));
setLocalKeys
((
current
)
=>
new
Set
(
multiple
?
[...
current
,
...
pending
.
map
((
item
)
=>
item
.
clientId
)]
:
pending
.
map
((
item
)
=>
item
.
clientId
)));
onPendingChange
(
fieldPath
,
{
type
:
multiple
?
'add'
:
'replace'
,
materials
:
pending
});
onChange
(
multiple
?
uniqueFiles
([...
files
,
...
uploaded
])
:
uploaded
[
uploaded
.
length
-
1
]);
}
catch
(
uploadError
)
{
setError
(
uploadError
?.
message
||
'附件上传失败,请稍后重试。'
);
}
finally
{
setUploading
(
false
);
}
}
function
removeFile
(
file
,
index
)
{
const
key
=
fileKey
(
file
);
setLocalKeys
((
current
)
=>
{
const
next
=
new
Set
(
current
);
next
.
delete
(
key
);
return
next
;
});
onPendingChange
(
fieldPath
,
{
type
:
'remove'
,
clientId
:
key
});
onChange
(
multiple
?
files
.
filter
((
_
,
itemIndex
)
=>
itemIndex
!==
index
)
:
null
);
}
const
options
=
materials
.
map
((
item
)
=>
({
...
item
,
label
:
item
.
materialTitle
,
textValue
:
`
${
item
.
materialTitle
}
${
item
.
fileAsset
?.
name
||
''
}
` }));
return <div className="space-y-4">
{files.length ? <div className="space-y-2">{files.map((file, index) => <div className="flex min-w-0 items-center justify-between gap-3" key={`
$
{
fileKey
(
file
)}
-
$
{
index
}
`}><div className="min-w-0"><div className="truncate text-sm font-medium">{file.name || file.url}</div><div className="mt-1 text-xs text-muted">{localKeys.has(fileKey(file)) ? '本地文件,保存后同步至审计材料中心' : '已选择附件'}</div></div><Tooltip><Button isIconOnly aria-label={`
删除附件:
$
{
file
.
name
||
index
+
1
}
`} isDisabled={disabled || uploading} size="sm" variant="danger-soft" onPress={() => removeFile(file, index)}><Delete size={16} /></Button><Tooltip.Content>删除当前附件</Tooltip.Content></Tooltip></div>)}</div> : <span className="text-sm text-muted">未上传</span>}
<Tabs aria-label="附件来源" selectedKey={tab} variant="secondary" onSelectionChange={(key) => setTab(String(key))}>
<Tabs.ListContainer className="w-fit max-w-full"><Tabs.List><Tabs.Tab id="material-center">材料中心<Tabs.Indicator /></Tabs.Tab><Tabs.Tab id="local-upload">本地上传<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer>
<Tabs.Panel className="pt-3" id="material-center"><XinyuanAutocomplete ariaLabel="选择审计材料" emptyText="没有找到可选择的审计材料" inputValue={keyword} isDisabled={disabled || uploading} isLoading={loading} options={options} placeholder={files.length && multiple ? `
已选
$
{
files
.
length
}
个附件,可继续添加
` : '请选择审计材料'} searchPlaceholder="搜索材料标题或文件名" shouldFilter={false} variant="secondary" onInputChange={setKeyword} onSelectionChange={selectMaterial} renderOption={(item) => <span className="min-w-0"><Label className="block truncate">{item.materialTitle}</Label><span className="mt-0.5 block truncate text-xs text-muted">{[item.fileAsset?.name, item.archiveCategory, item.archiveYear].filter(Boolean).join(' · ')}</span></span>} />{error ? <Alert className="mt-3" status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content><Button size="sm" variant="danger-soft" onPress={() => setRetry((value) => value + 1)}><ArrowPathIcon size={14} />重试</Button></Alert> : null}</Tabs.Panel>
<Tabs.Panel className="pt-3" id="local-upload"><FileUploadField description={multiple ? '可选择多个文件;保存详情时同步至审计材料中心。' : '保存详情时同步至审计材料中心。'} disabled={disabled || uploading} multiple={multiple} triggerLabel={uploading ? '上传中...' : '选择本地文件'} onFilesChange={uploadLocal} />{error ? <Alert className="mt-3" status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}</Tabs.Panel>
</Tabs>
</div>;
}
src/pages/project_manager_xy/integrity-party/index.jsx
View file @
5f6746ea
...
...
@@ -23,12 +23,22 @@ import {
Disclosure
,
Input
,
SearchField
,
Surface
,
Tabs
,
TextArea
,
Toast
,
}
from
'@heroui/react'
;
import
clsx
from
'clsx'
;
import
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
import
{
useContext
,
useEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
hasFieldAuthorizationConfiguration
,
isFieldDisabledByView
,
shouldHideViewTitleColumn
,
}
from
'@/utils/subjectView'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
IntegrityPartyMaterialField
from
'./IntegrityPartyMaterialField'
;
import
{
createIntegrityPartyMaterial
}
from
'../integrityPartyMaterialService'
;
import
{
DEFAULT_INTEGRITY_PAGE_SIZE
,
INTEGRITY_LEDGER_CONFIG_MAP
,
...
...
@@ -42,10 +52,10 @@ import {
exportSelectedLedgerWorkbook
,
importLedgerWorkbook
,
loadLedgerPage
,
loadLedgerViewResources
,
previewLedgerWorkbook
,
updateLedgerRecord
,
updateLedgerRecords
,
uploadLedgerAttachment
,
}
from
'../integrityPartyService'
;
function
LedgerCategoryTab
({
tabKey
,
label
})
{
...
...
@@ -78,20 +88,6 @@ function FilterField({ label, children, className }) {
);
}
function
formatFileSize
(
size
=
0
)
{
const
numericSize
=
Number
(
size
||
0
);
if
(
!
numericSize
)
{
return
''
;
}
if
(
numericSize
<
1024
)
{
return
`
${
numericSize
}
B`
;
}
if
(
numericSize
<
1024
*
1024
)
{
return
`
${(
numericSize
/
1024
).
toFixed
(
1
)}
KB`
;
}
return
`
${(
numericSize
/
1024
/
1024
).
toFixed
(
1
)}
MB`
;
}
function
formatCellValue
(
field
,
value
)
{
if
(
field
?.
type
===
'boolean'
)
{
if
(
value
===
true
||
value
===
'true'
)
{
...
...
@@ -149,89 +145,14 @@ const TABLE_COLUMN_WIDTHS = {
};
const
DEFAULT_TABLE_COLUMN_WIDTH
=
156
;
function
AttachmentField
({
field
,
value
=
[],
onChange
,
disabled
})
{
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
files
=
Array
.
isArray
(
value
)
?
value
:
[];
async
function
handleUpload
(
selectedFiles
)
{
if
(
selectedFiles
.
length
===
0
)
{
return
;
}
setUploading
(
true
);
try
{
const
uploadedFiles
=
[];
for
(
const
file
of
selectedFiles
)
{
uploadedFiles
.
push
(
await
uploadLedgerAttachment
(
file
));
}
onChange
([...
files
,
...
uploadedFiles
]);
}
finally
{
setUploading
(
false
);
}
}
return
(
<
div
>
<
div
className=
"grid gap-3"
>
<
FileUploadField
ariaLabel=
{
`上传${field.label}`
}
disabled=
{
disabled
||
uploading
}
fileName=
{
uploading
?
'上传中...'
:
''
}
onFilesChange=
{
handleUpload
}
triggerLabel=
{
`上传${field.label}`
}
/>
<
Description
>
已上传
{
files
.
length
}
个
</
Description
>
</
div
>
{
files
.
length
>
0
?
(
<
div
className=
"mt-3 space-y-2"
>
{
files
.
map
((
file
,
index
)
=>
(
<
Surface
key=
{
`${file.name || file.url}-${index}`
}
className=
"flex min-w-0 items-center justify-between gap-3 p-3"
variant=
"secondary"
>
<
div
className=
"min-w-0"
>
{
file
.
url
?
(
<
a
href=
{
file
.
url
}
target=
"_blank"
rel=
"noreferrer"
className=
"block truncate underline"
>
{
file
.
name
||
file
.
url
}
</
a
>
)
:
(
<
div
className=
"truncate text-sm"
>
{
file
.
name
||
'未命名附件'
}
</
div
>
)
}
<
Description
>
{
[
file
.
type
,
formatFileSize
(
file
.
size
)].
filter
(
Boolean
).
join
(
' · '
)
||
'系统附件'
}
</
Description
>
</
div
>
<
Button
type=
"button"
isDisabled=
{
disabled
}
size=
"sm"
variant=
"danger-soft"
onPress=
{
()
=>
onChange
(
files
.
filter
((
_
,
fileIndex
)
=>
fileIndex
!==
index
))
}
>
移除
</
Button
>
</
Surface
>
))
}
</
div
>
)
:
null
}
</
div
>
);
}
function
FieldControl
({
field
,
value
,
onChange
,
compact
=
false
})
{
function
FieldControl
({
field
,
value
,
onChange
,
compact
=
false
,
disabled
=
false
,
editSessionId
,
onPendingMaterialChange
})
{
if
(
field
.
type
===
'select'
)
{
return
(
<
XinyuanAutocomplete
ariaLabel=
{
field
.
label
}
isRequired=
{
field
.
required
}
isDisabled=
{
disabled
}
options=
{
(
field
.
options
||
[]).
map
((
option
)
=>
({
label
:
option
,
value
:
option
,
...
...
@@ -247,6 +168,7 @@ function FieldControl({ field, value, onChange, compact = false }) {
return
(
<
XinyuanAutocomplete
ariaLabel=
{
field
.
label
}
isDisabled=
{
disabled
}
options=
{
[
{
label
:
'是'
,
value
:
'true'
},
{
label
:
'否'
,
value
:
'false'
},
...
...
@@ -262,6 +184,7 @@ function FieldControl({ field, value, onChange, compact = false }) {
return
(
<
TextArea
value=
{
value
??
''
}
disabled=
{
disabled
}
onChange=
{
(
event
)
=>
onChange
(
event
.
target
.
value
)
}
fullWidth
variant=
"secondary"
...
...
@@ -273,21 +196,26 @@ function FieldControl({ field, value, onChange, compact = false }) {
if
(
field
.
type
===
'file'
)
{
return
(
<
AttachmentField
field=
{
field
}
value=
{
Array
.
isArray
(
value
)
?
value
:
[]
}
<
IntegrityPartyMaterialField
disabled=
{
disabled
}
editSessionId=
{
editSessionId
}
fieldPath=
{
field
.
name
}
multiple=
{
field
.
multiple
}
value=
{
value
}
onChange=
{
onChange
}
onPendingChange=
{
onPendingMaterialChange
}
/>
);
}
if
(
field
.
type
===
'date'
)
{
return
<
FormDatePicker
value=
{
value
??
''
}
onChange=
{
onChange
}
type=
"date"
/>;
return
<
FormDatePicker
disabled=
{
disabled
}
value=
{
value
??
''
}
onChange=
{
onChange
}
type=
"date"
/>;
}
return
(
<
Input
fullWidth
disabled=
{
disabled
}
type=
{
field
.
type
===
'number'
?
'number'
:
'text'
}
value=
{
value
??
''
}
variant=
"secondary"
...
...
@@ -297,7 +225,7 @@ function FieldControl({ field, value, onChange, compact = false }) {
);
}
function
LedgerForm
({
config
,
form
,
setForm
,
fields
=
null
,
compact
=
false
})
{
function
LedgerForm
({
config
,
form
,
setForm
,
fields
=
null
,
compact
=
false
,
isFieldDisabled
=
()
=>
false
,
editSessionId
,
onPendingMaterialChange
})
{
const
visibleFields
=
fields
||
config
.
fields
;
return
(
...
...
@@ -316,6 +244,9 @@ function LedgerForm({ config, form, setForm, fields = null, compact = false }) {
field=
{
field
}
value=
{
form
[
field
.
name
]
}
compact=
{
compact
}
disabled=
{
isFieldDisabled
(
field
)
}
editSessionId=
{
editSessionId
}
onPendingMaterialChange=
{
onPendingMaterialChange
}
onChange=
{
(
nextValue
)
=>
setForm
((
current
)
=>
({
...
current
,
...
...
@@ -349,6 +280,7 @@ function createEmptyFilters(config) {
}
export
default
function
IntegrityPartyPage
()
{
const
userInfo
=
useContext
(
UserContext
);
const
[
activeKey
,
setActiveKey
]
=
useState
(
INTEGRITY_LEDGER_CONFIGS
[
0
].
key
);
const
activeConfig
=
INTEGRITY_LEDGER_CONFIG_MAP
[
activeKey
];
const
[
filters
,
setFilters
]
=
useState
(()
=>
createInitialFilters
());
...
...
@@ -374,7 +306,14 @@ export default function IntegrityPartyPage() {
const
[
batchSaving
,
setBatchSaving
]
=
useState
(
false
);
const
[
deleteConfirm
,
setDeleteConfirm
]
=
useState
({
open
:
false
,
mode
:
'single'
});
const
[
filtersExpanded
,
setFiltersExpanded
]
=
useState
(
false
);
const
[
viewResourcesLoaded
,
setViewResourcesLoaded
]
=
useState
(
false
);
const
[
gridViews
,
setGridViews
]
=
useState
([]);
const
[
allViews
,
setAllViews
]
=
useState
([]);
const
[
activeViewName
,
setActiveViewName
]
=
useState
(
''
);
const
[
activeDetailViewName
,
setActiveDetailViewName
]
=
useState
(
''
);
const
reloadRequestIdRef
=
useRef
(
0
);
const
pendingLocalMaterialsRef
=
useRef
({});
const
[
detailSessionId
,
setDetailSessionId
]
=
useState
(
0
);
useEffect
(()
=>
{
if
(
!
notice
)
{
...
...
@@ -395,8 +334,51 @@ export default function IntegrityPartyPage() {
()
=>
records
.
filter
((
record
)
=>
selectedIds
.
includes
(
record
.
id
)),
[
records
,
selectedIds
],
);
const
activeGridView
=
gridViews
.
find
((
view
)
=>
view
.
name
===
activeViewName
)
||
gridViews
[
0
]
||
null
;
const
detailViews
=
useMemo
(()
=>
allViews
.
filter
((
view
)
=>
view
?.
type
===
'detail'
),
[
allViews
]);
const
activeDetailView
=
detailViews
.
find
((
view
)
=>
view
.
name
===
activeDetailViewName
)
||
detailViews
[
0
]
||
null
;
const
detailDisplayView
=
activeDetailView
||
activeGridView
;
const
detailAuthorizationView
=
hasFieldAuthorizationConfiguration
(
activeDetailView
)
?
activeDetailView
:
activeGridView
;
const
canAccessLedger
=
viewResourcesLoaded
&&
Boolean
(
activeGridView
);
const
detailFields
=
useMemo
(()
=>
{
const
configured
=
detailDisplayView
?.
settings
?.
columns
;
if
(
!
Array
.
isArray
(
configured
)
||
configured
.
length
===
0
)
return
activeConfig
.
fields
;
const
names
=
configured
.
filter
((
column
)
=>
!
column
?.
hide
&&
column
?.
field
!==
'title'
)
.
map
((
column
)
=>
String
(
column
?.
field
||
''
).
replace
(
/^metadata
\.
/
,
''
));
const
fields
=
names
.
map
((
name
)
=>
activeConfig
.
fields
.
find
((
field
)
=>
field
.
name
===
name
)).
filter
(
Boolean
);
return
fields
.
length
>
0
?
fields
:
activeConfig
.
fields
;
},
[
activeConfig
,
detailDisplayView
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
setViewResourcesLoaded
(
false
);
setGridViews
([]);
setAllViews
([]);
setRecords
([]);
setTotal
(
0
);
loadLedgerViewResources
(
activeKey
,
userInfo
).
then
((
resources
)
=>
{
if
(
cancelled
)
return
;
const
nextViews
=
resources
.
views
||
[];
const
nextDetailViews
=
(
resources
.
allViews
||
[]).
filter
((
view
)
=>
view
?.
type
===
'detail'
);
setGridViews
(
nextViews
);
setAllViews
(
resources
.
allViews
||
[]);
setActiveViewName
(
nextViews
[
0
]?.
name
||
''
);
setActiveDetailViewName
(
nextDetailViews
[
0
]?.
name
||
''
);
setViewResourcesLoaded
(
true
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
activeKey
,
userInfo
]);
async
function
reload
(
nextPage
=
page
,
nextFilters
=
filters
[
activeKey
])
{
if
(
!
canAccessLedger
)
{
setRecords
([]);
setTotal
(
0
);
setSelectedIds
([]);
return
;
}
const
requestId
=
reloadRequestIdRef
.
current
+
1
;
reloadRequestIdRef
.
current
=
requestId
;
setLoading
(
true
);
...
...
@@ -426,7 +408,7 @@ export default function IntegrityPartyPage() {
return
()
=>
window
.
clearTimeout
(
timer
);
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[
activeKey
,
filters
]);
},
[
activeKey
,
activeViewName
,
canAccessLedger
,
filters
]);
function
updateFilter
(
name
,
value
)
{
setFilters
((
current
)
=>
({
...
...
@@ -440,6 +422,8 @@ export default function IntegrityPartyPage() {
}
function
openCreateDialog
()
{
pendingLocalMaterialsRef
.
current
=
{};
setDetailSessionId
((
value
)
=>
value
+
1
);
setDetailMode
(
'create'
);
setActiveRecord
(
null
);
setDetailForm
(
createEmptyLedgerForm
(
activeKey
));
...
...
@@ -447,6 +431,8 @@ export default function IntegrityPartyPage() {
}
function
openEditDialog
(
record
)
{
pendingLocalMaterialsRef
.
current
=
{};
setDetailSessionId
((
value
)
=>
value
+
1
);
setDetailMode
(
'edit'
);
setActiveRecord
(
record
);
setDetailForm
({
...
...
@@ -465,6 +451,13 @@ export default function IntegrityPartyPage() {
setSaving
(
true
);
setError
(
''
);
try
{
for
(
const
materials
of
Object
.
values
(
pendingLocalMaterialsRef
.
current
))
{
for
(
const
material
of
materials
)
{
if
(
material
.
materialRecordId
)
continue
;
const
createdMaterial
=
await
createIntegrityPartyMaterial
({
fileAsset
:
material
.
fileAsset
,
currentUser
:
userInfo
});
material
.
materialRecordId
=
createdMaterial
.
id
;
}
}
if
(
detailMode
===
'create'
)
{
const
created
=
await
createLedgerRecord
(
activeKey
,
detailForm
);
setNotice
(
`记录“
${
created
.
title
}
”已创建。`
);
...
...
@@ -473,6 +466,7 @@ export default function IntegrityPartyPage() {
setNotice
(
`记录“
${
updated
.
title
}
”已保存。`
);
}
setDetailOpen
(
false
);
pendingLocalMaterialsRef
.
current
=
{};
await
reload
(
page
);
}
catch
(
saveError
)
{
setError
(
saveError
?.
message
||
'保存失败,请稍后重试。'
);
...
...
@@ -481,6 +475,26 @@ export default function IntegrityPartyPage() {
}
}
function
handlePendingMaterialChange
(
fieldPath
,
action
)
{
const
current
=
pendingLocalMaterialsRef
.
current
[
fieldPath
]
||
[];
if
(
action
?.
type
===
'replace'
)
{
pendingLocalMaterialsRef
.
current
[
fieldPath
]
=
action
.
materials
||
[];
}
else
if
(
action
?.
type
===
'add'
)
{
const
byId
=
new
Map
([...
current
,
...(
action
.
materials
||
[])].
map
((
item
)
=>
[
item
.
clientId
,
item
]));
pendingLocalMaterialsRef
.
current
[
fieldPath
]
=
[...
byId
.
values
()];
}
else
if
(
action
?.
type
===
'remove'
)
{
pendingLocalMaterialsRef
.
current
[
fieldPath
]
=
current
.
filter
((
item
)
=>
item
.
clientId
!==
action
.
clientId
);
}
}
function
handleDetailOpenChange
(
nextOpen
)
{
setDetailOpen
(
nextOpen
);
if
(
!
nextOpen
)
{
pendingLocalMaterialsRef
.
current
=
{};
setDetailSessionId
((
value
)
=>
value
+
1
);
}
}
async
function
confirmDelete
()
{
setSaving
(
true
);
setError
(
''
);
...
...
@@ -665,6 +679,14 @@ export default function IntegrityPartyPage() {
const
expandedFilterItems
=
filterItems
.
slice
(
3
);
const
tableColumns
=
useMemo
(
()
=>
[
...(
!
shouldHideViewTitleColumn
(
activeGridView
)
?
[{
key
:
'title'
,
title
:
'标题'
,
minWidth
:
220
,
render
:
(
record
)
=>
<
DataTableText
text=
{
record
.
title
}
showTooltip
/>,
}]
:
[]),
...
activeConfig
.
tableColumns
.
map
((
fieldName
)
=>
({
key
:
fieldName
,
title
:
fieldMap
[
fieldName
]?.
label
||
fieldName
,
...
...
@@ -676,7 +698,7 @@ export default function IntegrityPartyPage() {
/>
),
})),
{
...(
canAccessLedger
?
[
{
key
:
'actions'
,
title
:
'操作'
,
isActions
:
true
,
...
...
@@ -697,9 +719,9 @@ export default function IntegrityPartyPage() {
/>
</
TableActionGroup
>
),
},
}
]
:
[])
,
],
[
activeConfig
,
fieldMap
],
[
activeConfig
,
activeGridView
,
canAccessLedger
,
fieldMap
],
);
return
(
...
...
@@ -741,6 +763,26 @@ export default function IntegrityPartyPage() {
</
Alert
>
)
:
null
}
{
gridViews
.
length
>
1
?
(
<
Tabs
selectedKey=
{
activeViewName
}
onSelectionChange=
{
(
key
)
=>
{
setActiveViewName
(
String
(
key
));
setPage
(
0
);
setSelectedIds
([]);
}
}
>
<
Tabs
.
ListContainer
className=
"w-fit max-w-full min-w-0"
>
<
Tabs
.
List
aria
-
label=
{
`${activeConfig.label}视图`
}
className=
"w-max min-w-max"
>
{
gridViews
.
map
((
view
)
=>
<
LedgerCategoryTab
key=
{
view
.
name
}
tabKey=
{
view
.
name
}
label=
{
view
.
title
||
view
.
name
}
/>)
}
</
Tabs
.
List
>
</
Tabs
.
ListContainer
>
</
Tabs
>
)
:
null
}
{
!
viewResourcesLoaded
?
(
<
div
className=
"flex min-h-48 items-center justify-center"
><
span
className=
"text-sm text-muted"
>
正在加载视图权限...
</
span
></
div
>
)
:
!
canAccessLedger
?
(
<
EmptyState
className=
"min-h-64"
size=
"md"
>
<
EmptyState
.
Header
>
<
EmptyState
.
Title
>
暂无可访问视图
</
EmptyState
.
Title
>
<
EmptyState
.
Description
>
当前账号未获得该台账的 Grid View 访问权限。
</
EmptyState
.
Description
>
</
EmptyState
.
Header
>
</
EmptyState
>
)
:
<>
<
XinyuanFilterSurface
>
<
Disclosure
isExpanded=
{
filtersExpanded
}
...
...
@@ -869,15 +911,16 @@ export default function IntegrityPartyPage() {
onPageChange=
{
(
nextPage
)
=>
reload
(
Math
.
max
(
nextPage
,
0
))
}
/>
</
section
>
</>
}
<
XinyuanModal
isOpen=
{
detailOpen
}
onOpenChange=
{
setDetailOpen
}
onOpenChange=
{
handleDetailOpenChange
}
size=
"4xl"
title=
{
detailMode
===
'create'
?
`新建${activeConfig.tabLabel}记录`
:
'编辑台账记录'
}
footer=
{
<>
<
Button
type=
"button"
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
setDetailOpen
(
false
)
}
>
<
Button
type=
"button"
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
handleDetailOpenChange
(
false
)
}
>
取消
</
Button
>
<
Button
type=
"button"
isPending=
{
saving
}
size=
"sm"
variant=
"primary"
onPress=
{
saveDetail
}
>
...
...
@@ -886,7 +929,26 @@ export default function IntegrityPartyPage() {
</>
}
>
<
LedgerForm
config=
{
activeConfig
}
form=
{
detailForm
}
setForm=
{
setDetailForm
}
/>
{
detailViews
.
length
>
1
?
(
<
Tabs
className=
"mb-5"
selectedKey=
{
activeDetailViewName
}
onSelectionChange=
{
(
key
)
=>
setActiveDetailViewName
(
String
(
key
))
}
>
<
Tabs
.
ListContainer
className=
"w-fit max-w-full min-w-0"
><
Tabs
.
List
aria
-
label=
"详情视图"
className=
"w-max min-w-max"
>
{
detailViews
.
map
((
view
)
=>
<
LedgerCategoryTab
key=
{
view
.
name
}
tabKey=
{
view
.
name
}
label=
{
view
.
title
||
view
.
name
}
/>)
}
</
Tabs
.
List
></
Tabs
.
ListContainer
>
</
Tabs
>
)
:
null
}
{
detailMode
===
'edit'
&&
activeRecord
&&
!
shouldHideViewTitleColumn
(
detailDisplayView
)
?
(
<
Surface
className=
"mb-5 p-4"
variant=
"secondary"
>
<
Description
>
记录标题
</
Description
>
<
div
className=
"mt-1 text-sm font-medium"
>
{
activeRecord
.
title
}
</
div
>
</
Surface
>
)
:
null
}
<
LedgerForm
config=
{
activeConfig
}
fields=
{
detailFields
}
form=
{
detailForm
}
setForm=
{
setDetailForm
}
editSessionId=
{
detailSessionId
}
onPendingMaterialChange=
{
handlePendingMaterialChange
}
isFieldDisabled=
{
(
field
)
=>
isFieldDisabledByView
(
`metadata.${field.name}`
,
detailAuthorizationView
,
userInfo
)
}
/>
</
XinyuanModal
>
<
XinyuanModal
...
...
@@ -978,6 +1040,7 @@ export default function IntegrityPartyPage() {
setForm=
{
setBatchForm
}
fields=
{
activeConfig
.
batchFields
.
map
((
fieldName
)
=>
fieldMap
[
fieldName
]).
filter
(
Boolean
)
}
compact
isFieldDisabled=
{
(
field
)
=>
isFieldDisabledByView
(
`metadata.${field.name}`
,
activeGridView
,
userInfo
)
}
/>
</
XinyuanModal
>
...
...
src/pages/project_manager_xy/integrityPartyMaterialService.js
0 → 100644
View file @
5f6746ea
import
dayjs
from
'dayjs'
;
import
{
createRecord
,
queryRecords
,
uploadFile
}
from
'@/services/DataService'
;
import
{
APPLICATION_NAME
}
from
'@/config/ApplicationConfig'
;
const
MATERIAL_SUBJECT
=
'am_material'
;
function
recordsFrom
(
response
)
{
return
[
response
,
response
?.
data
,
response
?.
data
?.
list
,
response
?.
data
?.
records
,
response
?.
list
,
response
?.
records
,
response
?.
items
,
response
?.
result
]
.
find
((
item
)
=>
Array
.
isArray
(
item
))
||
[];
}
const
escapeRegExp
=
(
value
)
=>
String
(
value
||
''
).
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
'
\\
$&'
);
const
inferTitle
=
(
name
)
=>
String
(
name
||
''
).
replace
(
/
\.[^
.
]
+$/
,
''
).
trim
()
||
'未命名材料'
;
function
normalizeFileAsset
(
result
,
file
)
{
const
candidates
=
[
result
?.
file
,
result
?.
data
?.
file
,
result
?.
data
,
result
?.
result
?.
file
,
result
?.
result
,
result
];
for
(
const
item
of
candidates
)
{
if
(
!
item
||
typeof
item
!==
'object'
)
continue
;
const
url
=
item
.
url
||
item
.
path
||
item
.
location
||
item
?.
data
?.
url
||
''
;
if
(
!
url
)
continue
;
return
{
name
:
item
.
name
||
item
.
filename
||
file
?.
name
||
'未命名文件'
,
type
:
item
.
type
||
item
.
mimetype
||
file
?.
type
||
'application/octet-stream'
,
size
:
Number
(
item
.
size
||
file
?.
size
||
0
),
url
};
}
throw
new
Error
(
'文件上传成功,但未获取到可用的文件地址。'
);
}
export
async
function
queryAuditMaterials
(
keyword
=
''
)
{
const
text
=
escapeRegExp
(
String
(
keyword
||
''
).
trim
());
const
filter
=
text
?
{
$or
:
[
{
'metadata.material_title'
:
{
$regex
:
text
,
$options
:
'i'
}
},
{
'metadata.file_asset.name'
:
{
$regex
:
text
,
$options
:
'i'
}
},
]
}
:
{};
const
response
=
await
queryRecords
({
subject
:
MATERIAL_SUBJECT
,
page
:
0
,
page_size
:
20
,
filter
,
sort
:
'-updated_at -created_at'
,
mode
:
'full'
});
return
recordsFrom
(
response
).
map
((
record
)
=>
{
const
metadata
=
record
?.
metadata
||
{};
return
{
id
:
record
.
_id
||
record
.
id
||
record
.
name
||
''
,
materialTitle
:
metadata
.
material_title
||
record
.
title
||
metadata
.
file_asset
?.
name
||
'未命名材料'
,
fileAsset
:
metadata
.
file_asset
||
{},
archiveCategory
:
metadata
.
archive_category
||
''
,
archiveYear
:
metadata
.
archive_year
||
''
,
};
}).
filter
((
item
)
=>
item
.
id
&&
item
.
fileAsset
?.
url
);
}
export
async
function
uploadIntegrityPartyFile
(
file
)
{
if
(
!
file
)
throw
new
Error
(
'请选择要上传的本地文件。'
);
return
normalizeFileAsset
(
await
uploadFile
(
file
),
file
);
}
export
async
function
createIntegrityPartyMaterial
({
fileAsset
,
currentUser
})
{
if
(
!
fileAsset
?.
url
)
throw
new
Error
(
'缺少有效的文件地址,无法写入审计材料中心。'
);
const
uploader
=
currentUser
?.
_id
||
currentUser
?.
id
||
''
;
if
(
!
uploader
)
throw
new
Error
(
'未获取到当前登录用户,无法写入审计材料中心。'
);
const
materialTitle
=
inferTitle
(
fileAsset
.
name
);
const
response
=
await
createRecord
({
subject
:
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
:
APPLICATION_NAME
,
uploader
,
uploaded_at
:
dayjs
().
toISOString
(),
},
});
const
record
=
response
?.
data
||
response
?.
result
||
response
;
return
{
id
:
record
?.
_id
||
record
?.
id
||
record
?.
name
||
true
,
record
};
}
src/pages/project_manager_xy/integrityPartyService.js
View file @
5f6746ea
import
{
countRecords
,
createRecord
,
getSubjectView
,
getSubjectViewDetail
,
loadSubject
,
queryRecords
,
removeRecord
,
updateRecord
,
uploadFile
,
}
from
'@/services/DataService'
;
import
{
isViewAccessible
}
from
'@/utils/subjectView'
;
import
dayjs
from
'dayjs'
;
import
*
as
XLSX
from
'xlsx'
;
export
const
DEFAULT_INTEGRITY_PAGE_SIZE
=
10
;
const
viewResourceCache
=
new
Map
();
export
async
function
loadLedgerViewResources
(
configKey
,
user
)
{
const
config
=
getConfig
(
configKey
);
const
cacheKey
=
config
.
subject
;
if
(
!
viewResourceCache
.
has
(
cacheKey
))
{
const
promise
=
Promise
.
all
([
loadSubject
(
config
.
subject
),
getSubjectView
(
config
.
subject
)])
.
then
(
async
([
subject
,
summaries
])
=>
{
const
views
=
await
Promise
.
all
((
Array
.
isArray
(
summaries
)
?
summaries
:
[]).
map
(
async
(
view
)
=>
{
if
(
!
view
?.
name
)
return
view
;
try
{
const
detail
=
await
getSubjectViewDetail
(
config
.
subject
,
view
.
name
);
return
{
...
view
,
...
detail
,
settings
:
detail
?.
settings
||
view
?.
settings
||
{}
};
}
catch
{
return
view
;
}
}));
return
{
subject
,
views
};
})
.
catch
(()
=>
({
subject
:
null
,
views
:
[]
}));
viewResourceCache
.
set
(
cacheKey
,
promise
);
}
const
resources
=
await
viewResourceCache
.
get
(
cacheKey
);
const
configuredGridViews
=
resources
.
views
.
filter
((
view
)
=>
view
?.
type
===
'grid'
);
const
accessibleGridViews
=
configuredGridViews
.
filter
((
view
)
=>
isViewAccessible
(
view
,
user
));
return
{
...
resources
,
allViews
:
resources
.
views
,
views
:
accessibleGridViews
};
}
export
const
TRAINING_CATEGORY_OPTIONS
=
[
'廉政党课讲授'
,
'廉洁合规培训'
,
...
...
@@ -1206,33 +1237,6 @@ export async function exportLedgerTemplateWorkbook(configKey) {
};
}
function
findUploadUrl
(
response
)
{
const
candidates
=
[
response
?.
url
,
response
?.
data
?.
url
,
response
?.
result
?.
url
,
response
?.
path
,
response
?.
data
?.
path
,
response
?.
result
?.
path
,
response
?.
file
?.
url
,
response
?.
data
?.
file
?.
url
,
];
return
candidates
.
find
(
Boolean
)
||
''
;
}
export
async
function
uploadLedgerAttachment
(
file
)
{
const
response
=
await
uploadFile
(
file
);
const
url
=
findUploadUrl
(
response
);
return
{
name
:
file
.
name
,
type
:
file
.
type
,
size
:
file
.
size
,
url
,
};
}
function
buildGroup
(
list
,
keyGetter
,
labelGetter
)
{
return
Object
.
values
(
list
.
reduce
((
accumulator
,
item
)
=>
{
...
...
src/pages/project_manager_xy/notifications/NotificationListPage.jsx
0 → 100644
View file @
5f6746ea
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_OPTIONS
=
[{
key
:
'all'
,
label
:
'全部'
},
{
key
:
'unread'
,
label
:
'未读'
}];
function
formatTime
(
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
target
=
resolveNotificationTarget
(
extractNotificationTarget
(
notification
));
const
actions
=
Array
.
isArray
(
notification
?.
actions
)
?
notification
.
actions
:
[];
return
(
<
Card
>
<
Card
.
Content
className=
"flex flex-col gap-4 p-4 sm:p-5"
>
<
div
className=
"flex min-w-0 items-start gap-3"
>
<
Avatar
className=
"shrink-0"
size=
"sm"
><
Avatar
.
Fallback
>
{
sender
.
trim
().
slice
(
0
,
1
)
||
'系'
}
</
Avatar
.
Fallback
></
Avatar
>
<
div
className=
"min-w-0 flex-1"
>
<
div
className=
"flex flex-wrap items-start justify-between gap-2"
>
<
div
className=
"min-w-0"
>
<
h2
className=
"break-words text-sm font-medium sm:text-base"
>
{
notification
?.
title
||
(
category
===
'todo'
?
'待办通知'
:
'通知消息'
)
}
</
h2
>
<
p
className=
"mt-1 text-xs text-muted sm:text-sm"
>
{
sender
}
·
{
formatTime
(
notification
?.
sent_at
||
notification
?.
created_at
)
}
</
p
>
</
div
>
<
Chip
color=
{
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"
>
{
getNotificationContent
(
notification
)
||
'暂无通知内容'
}
</
p
>
</
div
>
</
div
>
<
div
className=
"flex min-h-8 flex-wrap 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
[
items
,
setItems
]
=
useState
([]);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
unread
,
setUnread
]
=
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
,
category
,
readStatus
,
keyword
})
.
then
((
result
)
=>
{
if
(
!
cancelled
)
{
setItems
(
result
.
list
);
setTotal
(
result
.
total
);
}
})
.
catch
((
requestError
)
=>
{
if
(
!
cancelled
)
{
setItems
([]);
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
)
setUnread
(
count
);
}).
catch
(()
=>
{
if
(
!
cancelled
)
setUnread
(
0
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
reloadKey
]);
const
refresh
=
useCallback
(()
=>
setReloadKey
((
value
)
=>
value
+
1
),
[]);
const
run
=
useCallback
(
async
(
key
,
operation
,
message
)
=>
{
setBusyKey
(
key
);
try
{
await
operation
();
toast
.
success
(
message
,
{
timeout
:
3000
});
refresh
();
return
true
;
}
catch
(
operationError
)
{
toast
.
danger
(
operationError
?.
message
||
'操作失败,请稍后重试'
,
{
timeout
:
4000
});
return
false
;
}
finally
{
setBusyKey
(
''
);
}
},
[
refresh
]);
const
openTarget
=
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
}
onSelectionChange=
{
(
key
)
=>
{
setCategory
(
String
(
key
));
setPage
(
0
);
}
}
>
<
Tabs
.
ListContainer
><
Tabs
.
List
aria
-
label=
"通知分类"
>
{
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
||
unread
===
0
}
isPending=
{
busyKey
===
'all'
}
variant=
"secondary"
onPress=
{
()
=>
run
(
'all'
,
markAllNotificationsRead
,
'全部通知已标记为已读'
)
}
>
全部已读
</
Button
>
</
div
>
<
div
className=
"rc-page-toolbar"
>
<
SearchField
aria
-
label=
"搜索通知内容"
className=
"w-full sm:w-80 lg:w-96"
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
>
<
Tabs
selectedKey=
{
readStatus
}
onSelectionChange=
{
(
key
)
=>
{
setReadStatus
(
String
(
key
));
setPage
(
0
);
}
}
>
<
Tabs
.
ListContainer
><
Tabs
.
List
aria
-
label=
"通知状态"
>
{
READ_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
&&
items
.
length
===
0
?
<
div
className=
"flex min-h-64 items-center justify-center"
><
Spinner
size=
"sm"
/></
div
>
:
null
}
{
!
loading
&&
!
error
&&
items
.
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
}
{
items
.
length
>
0
?
<
div
className=
"flex min-w-0 flex-col gap-3"
aria
-
busy=
{
loading
}
>
{
items
.
map
((
item
)
=>
<
NotificationItem
category=
{
category
}
currentUserId=
{
userInfo
?.
_id
}
isBusy=
{
busyKey
===
item
.
name
}
key=
{
item
.
name
||
item
.
_id
}
notification=
{
item
}
onAction=
{
(
notification
,
action
)
=>
run
(
notification
.
name
,
()
=>
executeNotificationAction
(
notification
,
action
),
'待办已处理'
)
}
onMarkRead=
{
(
notification
)
=>
run
(
notification
.
name
,
()
=>
markNotificationRead
(
notification
.
name
),
'已标记为已读'
)
}
onOpenTarget=
{
openTarget
}
/>)
}
</
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 @
5f6746ea
import
{
exacteActionFunction
,
queryUserNotice
,
readUserNotice
,
unReadNoticeCount
,
}
from
'@/services/DataService'
;
import
{
APP_BASE_PATH
}
from
'@/config/ApplicationConfig'
;
export
const
NOTIFICATION_PAGE_SIZE
=
10
;
export
const
NOTIFICATION_STATE_EVENT
=
'incorrupt-party-build:notification-state'
;
const
toArray
=
(
value
)
=>
(
Array
.
isArray
(
value
)
?
value
:
[]);
const
toId
=
(
value
)
=>
String
(
value
&&
typeof
value
===
'object'
?
value
.
_id
||
value
.
id
||
value
.
name
||
''
:
value
||
''
);
const
escapeRegExp
=
(
value
)
=>
String
(
value
||
''
).
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
'
\\
$&'
);
export
async
function
loadNotificationPage
({
page
=
0
,
pageSize
=
NOTIFICATION_PAGE_SIZE
,
category
=
'message'
,
readStatus
=
'all'
,
keyword
=
''
}
=
{})
{
const
filter
=
{
category
};
const
normalizedKeyword
=
String
(
keyword
||
''
).
trim
();
if
(
normalizedKeyword
)
filter
.
content
=
{
$regex
:
escapeRegExp
(
normalizedKeyword
),
$options
:
'i'
};
const
response
=
await
queryUserNotice
(
page
,
pageSize
,
filter
,
readStatus
===
'unread'
?
'unread'
:
null
);
return
{
list
:
toArray
(
response
?.
list
),
page
:
Number
(
response
?.
current_page
??
page
)
||
0
,
pageSize
:
Number
(
response
?.
page_size
??
pageSize
)
||
pageSize
,
total
:
Number
(
response
?.
total_notices
??
response
?.
total
??
0
)
||
0
,
};
}
export
async
function
loadUnreadNotificationCount
()
{
const
response
=
await
unReadNoticeCount
();
return
Math
.
max
(
0
,
Number
(
response
?.
total_notices
??
response
?.
count
??
response
?.
total
??
response
??
0
)
||
0
);
}
function
notifyChanged
()
{
window
.
dispatchEvent
(
new
CustomEvent
(
NOTIFICATION_STATE_EVENT
));
}
export
async
function
markNotificationRead
(
name
)
{
if
(
!
name
)
throw
new
Error
(
'通知缺少已读标识'
);
await
readUserNotice
(
name
);
notifyChanged
();
}
export
async
function
markAllNotificationsRead
()
{
await
readUserNotice
();
notifyChanged
();
}
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
});
notifyChanged
();
return
result
;
}
export
function
isNotificationRead
(
notification
,
userId
)
{
const
id
=
toId
(
userId
);
return
Boolean
(
id
)
&&
toArray
(
notification
?.
data
?.
logs
).
some
((
log
)
=>
toId
(
log
?.
user_id
)
===
id
);
}
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
(
value
)
{
return
String
(
value
||
''
).
replace
(
/
[
),.;!?,。;!?)
]
+$/
u
,
''
);
}
export
function
extractNotificationTarget
(
notification
)
{
const
configured
=
notification
?.
settings
?.
url
||
notification
?.
settings
?.
link
||
notification
?.
data
?.
url
||
notification
?.
data
?.
link
;
if
(
configured
)
return
trimTarget
(
configured
);
return
trimTarget
(
getNotificationContent
(
notification
).
match
(
/https
?
:
\/\/[^\s
<>"'
]
+|
\/
incorrupt_party_build
\/[^\s
<>"'
]
+/
)?.[
0
]);
}
export
function
resolveNotificationTarget
(
target
)
{
const
normalized
=
trimTarget
(
target
);
if
(
!
normalized
)
return
null
;
if
(
/^https
?
:
\/\/
/i
.
test
(
normalized
))
{
try
{
const
url
=
new
URL
(
normalized
);
if
(
url
.
pathname
===
APP_BASE_PATH
||
url
.
pathname
.
startsWith
(
`
${
APP_BASE_PATH
}
/`
))
{
return
resolveNotificationTarget
(
`
${
url
.
pathname
}${
url
.
search
}${
url
.
hash
}
`
);
}
return
{
href
:
url
.
toString
(),
external
:
true
};
}
catch
{
return
null
;
}
}
if
(
normalized
===
APP_BASE_PATH
)
return
{
href
:
'/'
,
external
:
false
};
if
(
normalized
.
startsWith
(
`
${
APP_BASE_PATH
}
/`
))
return
{
href
:
normalized
.
slice
(
APP_BASE_PATH
.
length
),
external
:
false
};
return
normalized
.
startsWith
(
'/'
)
?
{
href
:
normalized
,
external
:
true
}
:
null
;
}
src/utils/subjectView.js
0 → 100644
View file @
5f6746ea
import
{
AUTHORIZATION_APPLICATION_NAME
}
from
'@/config/ApplicationConfig'
;
export
function
toArray
(
value
)
{
if
(
value
===
null
||
value
===
undefined
||
value
===
''
)
return
[];
return
Array
.
isArray
(
value
)
?
value
:
[
value
];
}
function
normalize
(
value
)
{
return
String
(
value
??
''
).
trim
();
}
function
identityValues
(
values
)
{
return
toArray
(
values
)
.
flatMap
((
value
)
=>
value
&&
typeof
value
===
'object'
?
[
value
.
_id
,
value
.
name
,
value
.
display_name
,
value
.
username
]
:
value
,
)
.
map
(
normalize
)
.
filter
(
Boolean
);
}
function
applicationAuthorization
(
user
,
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
const
application
=
toArray
(
user
?.
applications
).
find
(
(
item
)
=>
normalize
(
item
?.
name
)
===
normalize
(
applicationName
),
);
return
{
groups
:
identityValues
(
application
?.
groups
),
roles
:
identityValues
(
application
?.
roles
),
permissions
:
identityValues
(
application
?.
permissions
),
};
}
function
intersects
(
left
,
right
)
{
const
expected
=
new
Set
(
identityValues
(
right
));
return
identityValues
(
left
).
some
((
item
)
=>
expected
.
has
(
item
));
}
export
function
isSystemAdministrator
(
user
)
{
return
user
?.
type
===
'admin'
||
user
?.
type
===
'system'
;
}
export
function
hasApplicationManagementAccess
(
user
,
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
if
(
isSystemAdministrator
(
user
))
return
true
;
return
intersects
(
applicationAuthorization
(
user
,
applicationName
).
roles
,
[
'管理'
]);
}
export
function
hasAuthAccess
(
authRule
,
user
,
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
if
(
!
user
)
return
false
;
if
(
hasApplicationManagementAccess
(
user
,
applicationName
))
return
true
;
const
authorization
=
applicationAuthorization
(
user
,
applicationName
);
if
(
typeof
authRule
===
'string'
)
return
intersects
(
authorization
.
roles
,
[
authRule
]);
if
(
authRule
?.
type
===
'group'
)
return
intersects
(
authorization
.
groups
,
authRule
.
groups
);
if
(
authRule
?.
type
===
'role'
)
return
intersects
(
authorization
.
roles
,
authRule
.
roles
);
if
(
authRule
?.
type
===
'permission'
)
return
intersects
(
authorization
.
permissions
,
authRule
.
permissions
);
return
false
;
}
function
configuredRule
(
rule
)
{
return
[
'group'
,
'role'
,
'permission'
].
includes
(
rule
?.
type
)
?
rule
:
'管理'
;
}
export
function
isViewAccessible
(
view
,
user
,
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
return
hasAuthAccess
(
configuredRule
(
view
?.
settings
?.
viewAuth
),
user
,
applicationName
,
);
}
export
function
hasFieldAuthorizationConfiguration
(
view
)
{
const
settings
=
view
?.
settings
;
return
(
toArray
(
settings
?.
auth
).
some
((
rule
)
=>
normalize
(
rule
?.
field
))
||
toArray
(
settings
?.
disableColumns
).
some
((
column
)
=>
normalize
(
column
?.
field
))
);
}
export
function
isFieldDisabledByView
(
fieldPath
,
view
,
user
,
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
if
(
toArray
(
view
?.
settings
?.
disableColumns
).
some
((
item
)
=>
item
?.
field
===
fieldPath
))
{
return
true
;
}
if
(
hasApplicationManagementAccess
(
user
,
applicationName
))
return
false
;
const
rule
=
toArray
(
view
?.
settings
?.
auth
).
find
((
item
)
=>
item
?.
field
===
fieldPath
);
return
!
hasAuthAccess
(
configuredRule
(
rule
),
user
,
applicationName
);
}
export
function
shouldHideViewTitleColumn
(
view
)
{
return
view
?.
settings
?.
shortTitle
===
true
;
}
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