Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
I
integrity_practice_management_new
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
integrity_practice_management_new
Commits
84dfb853
Commit
84dfb853
authored
Aug 26, 2026
by
Andy-bubu
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: align Xinyuan cross-app capabilities
parent
0f9e79a0
Show whitespace changes
Inline
Side-by-side
Showing
28 changed files
with
3810 additions
and
421 deletions
+3810
-421
.umirc.ts
.umirc.ts
+5
-0
ui-architecture.md
docs/ui-architecture.md
+10
-2
ui-migration-checklist.md
docs/ui-migration-checklist.md
+23
-0
xinyuan-design-system.md
docs/xinyuan-design-system.md
+5
-0
xinyuan-ui-audit.md
docs/xinyuan-ui-audit.md
+13
-0
AppIcons.jsx
src/components/AppIcons.jsx
+10
-0
AppShell.jsx
src/components/AppShell.jsx
+8
-2
ApplicationSwitcherDialog.jsx
src/components/ApplicationSwitcherDialog.jsx
+5
-5
DashboardNavbar.jsx
src/components/DashboardNavbar.jsx
+24
-3
FileUploadField.jsx
src/components/FileUploadField.jsx
+67
-0
FormDatePicker.jsx
src/components/FormDatePicker.jsx
+234
-0
ReferenceSearchSelect.jsx
src/components/ReferenceSearchSelect.jsx
+611
-0
XinyuanAutocomplete.jsx
src/components/XinyuanAutocomplete.jsx
+159
-0
index.jsx
src/components/record-workbench/index.jsx
+7
-2
application.js
src/config/application.js
+5
-2
SubjectViewContent.jsx
...ages/RecordDetailDrawer/components/SubjectViewContent.jsx
+97
-35
TailwindEditableField.jsx
...s/RecordDetailDrawer/components/TailwindEditableField.jsx
+793
-201
useSubjectRecord.js
src/pages/RecordDetailDrawer/hooks/useSubjectRecord.js
+52
-29
index.jsx
src/pages/RecordDetailDrawer/index.jsx
+375
-87
Layout.jsx
src/pages/integrity_practice_management/Layout.jsx
+27
-0
SubjectWorkbench.jsx
src/pages/integrity_practice_management/SubjectWorkbench.jsx
+10
-6
IntegrityPracticeMaterialField.jsx
..._management/components/IntegrityPracticeMaterialField.jsx
+368
-0
integrity-practice-management.business-flow.md
...management/integrity-practice-management.business-flow.md
+21
-5
integrityPracticeMaterialService.js
...y_practice_management/integrityPracticeMaterialService.js
+159
-0
integrityPracticeWorkbenchService.js
..._practice_management/integrityPracticeWorkbenchService.js
+1
-4
NotificationListPage.jsx
...ractice_management/notifications/NotificationListPage.jsx
+456
-0
notificationService.js
..._practice_management/notifications/notificationService.js
+180
-0
subjectView.js
src/utils/subjectView.js
+85
-38
No files found.
.umirc.ts
View file @
84dfb853
...
...
@@ -14,6 +14,7 @@ const ROUTE_PATHS = {
selfCheckDraftTemplate
:
'/self_check_the_draft_template'
,
selfCheckWorkingDraft
:
'/self_check_the_working_draft'
,
annualSubmissionMaterials
:
'/self_inspection_annual_submission_materials'
,
notifications
:
'/notifications'
,
}
as
const
;
const
APP_BASE_PATH
=
'/integrity_practice_management/'
;
...
...
@@ -75,6 +76,10 @@ export default defineConfig({
path
:
ROUTE_PATHS
.
annualSubmissionMaterials
,
component
:
'./integrity_practice_management/SubjectWorkbench'
,
},
{
path
:
ROUTE_PATHS
.
notifications
,
component
:
'./integrity_practice_management/notifications/NotificationListPage'
,
},
],
},
],
...
...
docs/ui-architecture.md
View file @
84dfb853
...
...
@@ -9,21 +9,25 @@ app.js (主题初始化)
-> AppShell (Pro Sidebar.Provider)
-> DashboardSidebar (Pro Sidebar + HeroUI Dropdown)
-> DashboardNavbar (Pro Navbar + Sidebar.Trigger)
-> Badge.Anchor + Badge 通知入口 -> /notifications
-> SubjectWorkbench (七个 Subject 配置入口)
-> RecordWorkbench (Tabs/Search/DataGrid/Pagination/ActionBar)
-> RecordDetailDrawer (Modal/Tabs/Surface/动态字段)
-> 整窗草稿 -> 待建档本地附件 -> am_material -> 业务 Record
-> UserAssignmentDialog / AlertDialog
```
## 责任边界
-
`src/config/application.js`
:
应用名、base、主题存储键
。
-
`src/config/application.js`
:
稳定
`APPLICATION_NAME`
、展示名、base、主题存储键;路由、应用切换与授权判断从同一应用标识派生
。
-
`src/design-system/`
:权威主题、Tokens 和跨应用 Shell 样式;页面不得复制这些规则。
-
`AppShell/DashboardSidebar/DashboardNavbar/ApplicationSwitcherDialog`
:业务无关的共享壳层。
-
`ApplicationSwitcherDialog`
从既有
`SiteContext.site.group`
读取动态分组,从
`GET /application/info`
与
`UserContext.applications`
合并真实可访问应用;分组仅影响呈现,不改变路由、会话或跳转。
-
`SubjectWorkbench`
:把现有 Subject/View/动作配置适配为工作台 props,不定义组件外观。
-
`record-workbench`
:DataGrid/Pagination/ActionBar 组合;详情能力统一映射为操作列首个预览图标,首个业务列仅展示文本;不发请求、不解释业务动作。
-
`RecordDetailDrawer`
:详情草稿、Modal 状态和字段编排;保存仍由
`useSubjectRecord`
处理。
-
`RecordDetailDrawer`
:详情整窗草稿、Modal 状态和字段编排;一次保存通过
`useSubjectRecord.handleFieldsUpdate`
提交。
-
`integrityPracticeMaterialService`
:
`am_material`
查询、
`/upload`
结果快照规范化和材料建档;应用标识由廉洁从业业务层固定为
`integrity_practice_management`
。
-
`notifications/notificationService`
:平台通知查询、已读、动态 action、未读事件和 base-aware 链接解析。
-
`TailwindEditableField`
:按动态字段类型选择官方控件;不硬编码选项或 API 字段。
-
service/hook:唯一业务请求与数据转换边界,UI 组件不改变协议。
...
...
@@ -33,8 +37,12 @@ app.js (主题初始化)
路由 -> Subject 配置 -> GET Subject/View -> POST record/query
-> DataGrid 展示/选择 -> 配置动作 -> 现有 service 回调
-> 详情 Modal -> 本地草稿 -> 统一保存 -> PUT record/:name
-> 本地附件草稿 -> POST am_material(逐文件、可重试)-> PUT record/:name
Navbar -> GET notice/customer/count -> /notifications -> query/read/action
```
Grid View 的
`viewAuth`
在查询前过滤;没有可访问 View 时数据链在
`POST record/query`
之前终止。打开详情时,
`useSubjectRecord`
在 View 摘要之后继续读取每个 View 的完整配置。当前 Detail 有有效
`auth`
或
`disableColumns`
时,其字段权限整套优先;两项都未配置或没有实际 Detail 时,整体回退打开它的 Grid
`authorizationView`
。Detail 与 Grid 不合并、也不逐字段回退;
`disableColumns`
始终强制只读。切换 Detail 后,字段状态和编辑入口只按当前 Detail 的展示字段立即重算。Grid
`viewAuth`
不用于隐藏详情字段。
主题状态写入
`xinyuan_color_theme`
,同时更新根
`data-theme`
与
`dark`
class,使 Portal 与页面一致。应用切换数据来自真实 application info;登出调用现有 logout API 后清理登录态并回到 base-aware 登录路径。
## 历史源码隔离
...
...
docs/ui-migration-checklist.md
View file @
84dfb853
...
...
@@ -22,6 +22,15 @@
-
[
x
]
详情/编辑 Modal、指派 Modal、删除 AlertDialog
-
[
x
]
Select、Autocomplete、DatePicker、DropZone 直接 compound API
-
[
x
]
活动路由 KPI/图表判定为不适用
-
[
x
]
Grid
`shortTitle`
严格布尔隐藏标题展示列,标题数据与查询协议保留
-
[
x
]
Detail
`shortTitle`
随活动详情 View 隐藏记录标题与 title 字段
-
[
x
]
View/字段授权只读取
`integrity_practice_management`
应用项;零 View 不查询、不展示操作,单 View 隐藏 Tabs
-
[
x
]
稳定
`APPLICATION_NAME`
统一路由、应用切换与授权归属;不合并用户顶层或其他应用角色/用户组
-
[
x
]
Detail 加载完整 View 配置;有效 Detail 字段权限整套优先,无配置时整体回退当前 Grid,不合并或逐字段回退
-
[
x
]
`disableColumns`
对管理员仍强制只读;编辑入口只检查当前激活 Detail 展示字段
-
[
x
]
`/notifications`
消息/待办、全部/未读、搜索、分页、单条/全部已读、动态 action 与 base-aware 跳转
-
[
x
]
Navbar 36px Bell、真实未读数和 0/99/99+ 显示规则
-
[
x
]
所有可编辑 file 字段提供材料中心/本地上传;本地草稿仅在详情保存时建档
## 保护与扫描
...
...
@@ -55,3 +64,17 @@
-
[
]
全仓旧组件扫描为零:未完成。未挂载历史
`src/pages/problem_rectification`
与
`src/catalyst-ui-kit`
仍保留,且不进入当前应用路由。
-
[
]
KPI/图表 Tooltip 页面验收:不适用。七条活动路由没有这些业务模块,未伪造数据或页面。
-
[
]
五项功能全部真实线上失败/重试矩阵:未完成。当前列表、详情和通知接口可用,但没有制造材料部分成功、业务保存失败或通知 1/99/100 的业务数据;不得以浏览器夹具替代真实线上状态验收。
## 2026-08-26 五项功能追加验收
-
[
x
]
真实
`shortTitle: true`
Grid View 隐藏标题展示列,保留上下文首列和操作列预览入口
-
[
x
]
Navbar 未读 0 隐藏 Badge,Bell 进入本应用
`/notifications`
-
[
x
]
通知页真实调用
`/notice/customer/query`
与
`/notice/customer/count`
,空态、消息/待办和全部/未读控件可见
-
[
x
]
目标页面重新登录后的控制台 0 error;保留两条既有 React Aria 开发 warning
-
[
x
]
真实详情权限来源:Andy 打开未归档记录时记录、Subject、View 摘要及四个完整 View 请求均为 200;无字段权限配置的 Detail 整体回退 Grid,并正常显示编辑入口
-
[
x
]
真实
`disableColumns`
:已归档 Detail 的完整配置优先,Andy 虽为系统管理员,进入编辑后配置禁用的模板、部门、项目结果、填报人、审批人等仍为只读,仅未禁用字段可编辑
-
[
]
真实材料事务:尚未完成文件单值/多值、取消、失败和无重复重试验收;未制造业务数据
-
[
]
Detail 完整角色矩阵:Andy 是系统管理员且
`applications`
为空,未提供当前应用“管理”角色和普通用户登录凭据;无权限视图、普通用户默认只读及角色旁路仍未做真实账号验收
-
[
]
真实角色矩阵和通知 1/99/100/action:当前 Andy 运行数据只有 admin 身份且通知总数为 0,没有制造或写入测试业务数据
-
[
]
问题整改同视口稳定会话:基准服务在线,但独立登录验证码会话未稳定建立;本轮没有把仅代码 diff 计作浏览器对比通过
docs/xinyuan-design-system.md
View file @
84dfb853
...
...
@@ -24,6 +24,11 @@ Inter 禁止通过 `src/tailwind.css` 导入。Tailwind v4 内容扫描必须排
-
行动作:1-3 项全部直出;4 项以上直出前两项和 More Dropdown;危险项排序最后。
-
Modal:详情/表单使用 HeroUI Modal;确认使用 AlertDialog;内容型详情使用 4xl/1088px、24px 圆角,查看态只允许一个 16px 圆角的 secondary Surface。嵌套 DataGrid 使用 20px 圆角,表头与固定表头使用 tertiary Surface,表体与固定单元格回到 default Surface,禁止表头与外层详情 Surface 使用同一层颜色。
-
动态字段:Input、TextArea、Checkbox、Select、Autocomplete、DatePicker;附件使用 Pro DropZone。
-
View 标题:仅
`settings.shortTitle === true`
隐藏 Grid 的 title 展示列;Detail View 同配置隐藏记录副标题与 title 字段,Modal 功能标题始终存在。
-
View 权限:以稳定
`APPLICATION_NAME = integrity_practice_management`
定位授权,只读取
`user.applications[]`
中当前应用项目的 groups/roles/permissions;不得合并顶层身份或其他应用授权。Grid
`viewAuth`
只控制 Tab 可见性;零可访问 Grid View 时禁止查询与操作,一个 View 时隐藏 Tabs。
-
Detail 字段权限:打开详情时加载
`GET /subject/:subjectName/view/:viewName`
的完整配置。当前 Detail 有有效
`auth`
或
`disableColumns`
时整套使用 Detail;两项均未配置或不存在实际 Detail 时整体回退打开它的 Grid。两套配置不合并、不逐字段回退;
`disableColumns`
对所有用户始终只读,编辑入口只根据当前激活 Detail 展示字段计算。
-
通知:Navbar 直接组合
`Badge.Anchor + Badge + Button + Tooltip`
,Bell 位于主题按钮左侧;通知页使用平台真实接口和 HeroUI Tabs/SearchField/Pagination/Card。
-
文件:可编辑 file 字段统一使用 HeroUI Tabs、Autocomplete 和 Pro DropZone;材料中心选择及本地上传都只向业务草稿写
`{name,type,size,url}`
。
-
远程引用:使用 HeroUI
`Popover.Root + Button + SearchField + controlled ListBox`
。多选触发器固定 40px 高并只显示“已选择 N 项”,候选长标题单行省略且通过
`title`
暴露全文;弹层最大 24rem、移动端收敛到
`100vw - 24px`
、列表最大 288px。多选后保持打开,单选后关闭,清空不改变原 ID 保存协议。
-
KPI/图表:仅在真实业务路由存在时使用 Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip;禁止 Card 数字卡或 ECharts 私有主题。
...
...
docs/xinyuan-ui-audit.md
View file @
84dfb853
...
...
@@ -132,3 +132,16 @@ pnpm install --lockfile-only --offline --frozen-lockfile
-
根运行时为 React/React DOM
`19.2.8`
、HeroUI/Styles
`3.2.4`
、React Aria Components
`1.20.0`
、React Aria Utils
`3.34.1`
,满足 HeroUI Pro
`1.0.0-beta.8`
peer 范围;冻结锁文件检查显示
`Already up to date`
。
-
依赖树还包含 Umi/DVA 工具链内部 React/React DOM 18 副本;
`.umirc.ts`
已把应用运行时 alias 到根 React 19。没有缺失 HeroUI peer。
-
Node ESM 脚本已逐项导入根入口、Sidebar、Navbar、DataGrid、DropZone、ActionBar、EmptyState、KPI、PieChart、BarChart、ComposedChart、ChartTooltip,并解析 CSS 子路径,全部通过。
## 2026-08-26 五项功能审计
-
应用范围:统一展示名为“廉洁从业管理”,属于六个完整功能应用,实施 Grid/Detail
`shortTitle`
、Subject View 权限、通知中心和审计材料中心文件流程。
-
授权来源:
`APPLICATION_NAME = integrity_practice_management`
是路由、应用切换和授权判断的唯一稳定标识。只读取
`user.applications[]`
中该应用项目,不合并顶层身份、其他应用角色或其他应用用户组;系统管理员/系统账号及该应用“管理”角色旁路 View/字段授权,
`disableColumns`
始终只读。
-
Detail 权限来源:详情先获取 View 摘要,再逐项调用
`GET /subject/:subjectName/view/:viewName`
补齐完整配置。当前 Detail 有有效
`auth`
/
`disableColumns`
时整套优先;两项都未配置或没有实际 Detail 时整体回退当前 Grid,二者不合并也不逐字段回退。Grid
`viewAuth`
仅控制 Tab 可见性,编辑入口只检查当前激活 Detail 展示字段。
-
真实 View:
`self_inspection_submission_form`
、
`statistical_table`
、
`integrity_practice_report`
、
`self_check_the_working_draft`
已确认存在严格布尔
`shortTitle: true`
;未配置的 View 保留标题列。
-
文件协议:运行时确认
`integrity_practice_report.metadata.archive_attachment`
为
`file`
且
`multiple: true`
;业务值继续使用
`{name,type,size,url}`
快照。
-
通知协议:沿用既有
`/notice/customer/query`
、
`/notice/customer/count`
、
`/notice/customer/read`
与动态 action API,新增活动路由
`/notifications`
,base 仍为
`/integrity_practice_management/`
。
-
材料协议:查询
`am_material`
最近 20 条;本地文件仍先走
`/upload`
,详情保存时才以
`source_app = integrity_practice_management`
建档,材料全部成功后才更新业务记录。
-
原始缺口:旧实现会在零授权 View 时创建“全部”兜底并查询;详情按单字段累计 payload;Navbar 无通知入口;文件字段只有本地上传。上述缺口已按问题整改基准收敛。
-
浏览器实测:目标运行于
`http://localhost:8006/integrity_practice_management/`
,问题整改基准运行于
`http://localhost:8004/problem_rectification_new/`
。Andy 登录后,
`self_inspection_submission_form`
的真实
`shortTitle: true`
View 已确认不渲染标题列,首列不承担预览,操作列保留预览/编辑/更多;Navbar 未读数真实返回 0,角标不显示;点击 Bell 正确进入
`/notifications`
,并实际发出
`/notice/customer/count`
与
`/notice/customer/query`
请求。打开未归档真实记录时,记录、Subject、View 摘要和四个
`GET /subject/:subjectName/view/:viewName`
请求全部为 200;该 Detail 无字段权限配置,整体回退当前 Grid 后正常显示编辑入口。切换已归档 Grid 后,实际 Detail 带
`disableColumns`
,Andy 虽为系统管理员,编辑态中模板、部门、项目结果、填报人、审批人等仍为只读,仅未禁用字段可编辑。控制台 0 error(保留两条既有 React Aria 开发 warning)。
-
浏览器限制:Andy 是系统管理员且当前返回的
`applications`
为空,能够验证系统管理员旁路与 Detail
`disableColumns`
不可旁路,但不能替代目标应用“管理”角色、普通用户和无权限用户矩阵。材料选择/上传/取消/部分失败/重试、通知 1/99/100 和动态 action 也没有制造业务数据验收;问题整改基准的独立登录会话未稳定建立。未使用伪造业务数据绕过这些限制。
src/components/AppIcons.jsx
View file @
84dfb853
import
AppstoreOutlined
from
'@ant-design/icons/AppstoreOutlined'
;
import
BellOutlined
from
'@ant-design/icons/BellOutlined'
;
import
DownloadOutlined
from
'@ant-design/icons/DownloadOutlined'
;
import
CalendarOutlined
from
'@ant-design/icons/CalendarOutlined'
;
import
CheckOutlined
from
'@ant-design/icons/CheckOutlined'
;
...
...
@@ -25,11 +26,16 @@ import UploadOutlined from '@ant-design/icons/UploadOutlined';
import
PlusOutlined
from
'@ant-design/icons/PlusOutlined'
;
import
WarningOutlined
from
'@ant-design/icons/WarningOutlined'
;
import
MoreOutlined
from
'@ant-design/icons/MoreOutlined'
;
import
ExportOutlined
from
'@ant-design/icons/ExportOutlined'
;
function
iconStyle
(
size
,
style
)
{
return
size
===
undefined
?
style
:
{
fontSize
:
size
,
...
style
};
}
export
const
FIELD_SUFFIX_ICON_STYLE
=
Object
.
freeze
({
color
:
'var(--field-placeholder, var(--muted))'
,
});
function
withSize
(
Icon
)
{
return
function
AppIcon
({
className
,
size
,
style
,
...
props
})
{
return
(
...
...
@@ -44,6 +50,7 @@ function withSize(Icon) {
}
export
const
AppGrid
=
withSize
(
AppstoreOutlined
);
export
const
Bell
=
withSize
(
BellOutlined
);
export
const
Archive
=
withSize
(
ArchiveOutlined
);
export
const
Calendar
=
withSize
(
CalendarOutlined
);
export
const
Check
=
withSize
(
CheckOutlined
);
...
...
@@ -54,6 +61,7 @@ export const Delete = withSize(DeleteOutlined);
export
const
Download
=
withSize
(
DownloadOutlined
);
export
const
Edit
=
withSize
(
EditOutlined
);
export
const
Eye
=
withSize
(
EyeOutlined
);
export
const
OpenExternal
=
withSize
(
ExportOutlined
);
export
const
FileDone
=
withSize
(
FileDoneOutlined
);
export
const
FileText
=
withSize
(
FileTextOutlined
);
export
const
Fullscreen
=
withSize
(
FullscreenOutlined
);
...
...
@@ -62,8 +70,10 @@ export const LogOut = withSize(LogoutOutlined);
export
const
Moon
=
withSize
(
MoonOutlined
);
export
const
More
=
withSize
(
MoreOutlined
);
export
const
Reload
=
withSize
(
ReloadOutlined
);
export
const
ArrowPathIcon
=
Reload
;
export
const
Send
=
withSize
(
SendOutlined
);
export
const
Search
=
withSize
(
SearchOutlined
);
export
const
MagnifyingGlassIcon
=
Search
;
export
const
Settings
=
withSize
(
SettingOutlined
);
export
const
Sun
=
withSize
(
SunOutlined
);
export
const
Tool
=
withSize
(
ToolOutlined
);
...
...
src/components/AppShell.jsx
View file @
84dfb853
...
...
@@ -15,7 +15,7 @@ function getInitialTheme() {
}
}
export
default
function
AppShell
({
children
,
open
,
onOpenChange
,
title
,
...
sidebarProps
})
{
export
default
function
AppShell
({
children
,
notificationCount
,
notificationPath
,
open
,
onOpenChange
,
title
,
...
sidebarProps
})
{
const
[
theme
,
setTheme
]
=
useState
(
getInitialTheme
);
useLayoutEffect
(()
=>
{
...
...
@@ -39,7 +39,13 @@ export default function AppShell({ children, open, onOpenChange, title, ...sideb
>
<
DashboardSidebar
theme=
{
theme
}
{
...
sidebarProps
}
/>
<
Sidebar
.
Main
className=
"app-main-glass rc-app-main"
>
<
DashboardNavbar
theme=
{
theme
}
title=
{
title
}
onThemeChange=
{
setTheme
}
/>
<
DashboardNavbar
notificationCount=
{
notificationCount
}
onOpenNotifications=
{
notificationPath
?
()
=>
history
.
push
(
notificationPath
)
:
undefined
}
theme=
{
theme
}
title=
{
title
}
onThemeChange=
{
setTheme
}
/>
<
div
className=
"app-shell-content-scroll min-h-0 flex-1 overflow-y-auto"
>
<
div
className=
"rc-app-page"
>
{
children
}
</
div
>
</
div
>
...
...
src/components/ApplicationSwitcherDialog.jsx
View file @
84dfb853
import
xinyuanLogo
from
'@/assets/xy-logo-red.png'
;
import
{
ExclamationTriangleIcon
}
from
'@/components/AppIcons'
;
import
{
APPLICATION_DISPLAY_NAME
,
APPLICATION_
ROUTE_
NAME
}
from
'@/config/application'
;
import
{
APPLICATION_DISPLAY_NAME
,
APPLICATION_NAME
}
from
'@/config/application'
;
import
{
getApplications
}
from
'@/services/DataService'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Alert
,
Avatar
,
Card
,
Chip
,
Description
,
Modal
,
Skeleton
,
Surface
}
from
'@heroui/react'
;
...
...
@@ -15,7 +15,7 @@ function unwrapApplications(response) {
function
normalizeApplication
(
application
=
{})
{
const
name
=
application
.
name
||
''
;
const
current
=
name
===
APPLICATION_
ROUTE_
NAME
;
const
current
=
name
===
APPLICATION_NAME
;
return
{
name
,
title
:
current
?
APPLICATION_DISPLAY_NAME
:
application
.
title
||
name
||
'未命名应用'
,
...
...
@@ -28,7 +28,7 @@ function normalizeApplication(application = {}) {
function
mergeApplications
(
fetched
,
fallbacks
)
{
const
merged
=
new
Map
();
[...
fallbacks
,
...
fetched
,
{
name
:
APPLICATION_
ROUTE_
NAME
,
title
:
APPLICATION_DISPLAY_NAME
}]
[...
fallbacks
,
...
fetched
,
{
name
:
APPLICATION_NAME
,
title
:
APPLICATION_DISPLAY_NAME
}]
.
map
(
normalizeApplication
)
.
filter
((
application
)
=>
application
.
name
)
.
forEach
((
application
)
=>
merged
.
set
(
application
.
name
,
{
...
merged
.
get
(
application
.
name
),
...
application
}));
...
...
@@ -77,7 +77,7 @@ function buildApplicationSections(applications, applicationGroups) {
}
function isCurrentApplication(application) {
if (application.name === APPLICATION_
ROUTE_
NAME) return true;
if (application.name === APPLICATION_NAME) return true;
if (typeof window === 'undefined') return false;
try {
const target = new URL(application.url, window.location.origin);
...
...
@@ -142,7 +142,7 @@ export default function ApplicationSwitcherDialog({ open, onClose, siteInfo, use
const
[
status
,
setStatus
]
=
useState
(
'idle'
);
const
[
errorMessage
,
setErrorMessage
]
=
useState
(
''
);
const
currentApplicationName
=
useMemo
(
()
=>
applications
.
find
(
isCurrentApplication
)?.
name
||
APPLICATION_
ROUTE_
NAME
,
()
=>
applications
.
find
(
isCurrentApplication
)?.
name
||
APPLICATION_NAME
,
[
applications
],
);
const
applicationSections
=
useMemo
(
...
...
src/components/DashboardNavbar.jsx
View file @
84dfb853
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 +21,26 @@ 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=
{
`通知消息,${normalizedNotificationCount}条未读`
}
className=
"app-navbar-icon-button rc-navbar-icon-button"
size=
"sm"
variant=
"ghost"
onPress=
{
onOpenNotifications
}
>
<
Bell
size=
{
16
}
/>
</
Button
>
{
normalizedNotificationCount
>
0
?
(
<
Badge
aria
-
label=
{
`${normalizedNotificationCount}条未读通知`
}
color=
"danger"
size=
"sm"
>
{
normalizedNotificationCount
>
99
?
'99+'
:
normalizedNotificationCount
}
</
Badge
>
)
:
null
}
</
Badge
.
Anchor
>
<
Tooltip
.
Content
>
通知消息
</
Tooltip
.
Content
>
</
Tooltip
>
<
Tooltip
delay=
{
0
}
>
<
Button
isIconOnly
...
...
src/components/FileUploadField.jsx
0 → 100644
View file @
84dfb853
import
{
Upload
}
from
'@/components/AppIcons'
;
import
{
DropZone
}
from
'@heroui-pro/react/drop-zone'
;
import
{
useCallback
}
from
'react'
;
export
default
function
FileUploadField
({
accept
,
ariaLabel
=
'选择文件'
,
className
=
''
,
description
=
'将文件拖到此处,或从本地选择。'
,
isDisabled
=
false
,
fileName
=
''
,
multiple
=
false
,
onFilesChange
,
triggerLabel
=
'选择文件'
,
})
{
const
emitFiles
=
useCallback
(
(
files
)
=>
{
if
(
!
isDisabled
)
{
onFilesChange
?.(
Array
.
from
(
files
||
[]));
}
},
[
isDisabled
,
onFilesChange
],
);
const
handleDrop
=
useCallback
(
async
(
event
)
=>
{
const
fileItems
=
Array
.
from
(
event
?.
items
||
[]).
filter
(
(
item
)
=>
item
?.
kind
===
'file'
&&
typeof
item
.
getFile
===
'function'
,
);
if
(
fileItems
.
length
===
0
)
return
;
const
files
=
await
Promise
.
all
(
fileItems
.
map
((
item
)
=>
item
.
getFile
()));
emitFiles
(
multiple
?
files
:
files
.
slice
(
0
,
1
));
},
[
emitFiles
,
multiple
],
);
return
(
<
DropZone
className=
{
className
}
>
<
DropZone
.
Area
aria
-
label=
{
ariaLabel
}
isDisabled=
{
isDisabled
}
onDrop=
{
handleDrop
}
>
<
DropZone
.
Input
accept=
{
accept
}
disabled=
{
isDisabled
}
multiple=
{
multiple
}
onSelect=
{
emitFiles
}
/>
<
DropZone
.
Icon
>
<
Upload
aria
-
hidden=
"true"
/>
</
DropZone
.
Icon
>
<
DropZone
.
Label
>
{
fileName
||
'将文件拖到此处'
}
</
DropZone
.
Label
>
<
DropZone
.
Description
>
{
fileName
?
`已选择${multiple ? '文件,可继续添加。' : '文件,可重新选择进行替换。'}`
:
description
}
</
DropZone
.
Description
>
<
DropZone
.
Trigger
isDisabled=
{
isDisabled
}
>
<
Upload
aria
-
hidden=
"true"
/>
{
triggerLabel
}
</
DropZone
.
Trigger
>
</
DropZone
.
Area
>
</
DropZone
>
);
}
src/components/FormDatePicker.jsx
0 → 100644
View file @
84dfb853
import
{
Calendar
as
HeroCalendar
,
CloseButton
,
DateField
,
DatePicker
,
I18nProvider
,
TimeField
,
}
from
'@heroui/react'
;
import
{
parseDate
,
parseDateTime
,
parseTime
}
from
'@internationalized/date'
;
import
{
useMemo
}
from
'react'
;
import
{
Calendar
as
CalendarIcon
,
Close
,
FIELD_SUFFIX_ICON_STYLE
,
}
from
'./AppIcons'
;
function
parseValue
(
value
,
mode
)
{
if
(
!
value
)
{
return
null
;
}
try
{
if
(
mode
===
'month'
)
{
return
parseDate
(
`
${
String
(
value
).
slice
(
0
,
7
)}
-01`
);
}
return
mode
===
'datetime'
?
parseDateTime
(
String
(
value
).
slice
(
0
,
16
))
:
parseDate
(
String
(
value
).
slice
(
0
,
10
));
}
catch
{
return
null
;
}
}
function
formatValue
(
value
,
mode
)
{
if
(
!
value
)
{
return
''
;
}
const
rawValue
=
value
.
toString
();
if
(
mode
===
'month'
)
{
return
rawValue
.
slice
(
0
,
7
);
}
if
(
mode
===
'date'
)
{
return
rawValue
.
slice
(
0
,
10
);
}
if
(
rawValue
.
includes
(
'T'
))
{
return
rawValue
.
slice
(
0
,
16
);
}
return
`
${
rawValue
.
slice
(
0
,
10
)}
T00:00`
;
}
function
parseTimeValue
(
value
)
{
if
(
!
value
||
!
String
(
value
).
includes
(
'T'
))
{
return
null
;
}
try
{
return
parseTime
(
String
(
value
).
slice
(
11
,
16
));
}
catch
{
return
null
;
}
}
function
formatTimeValue
(
value
)
{
return
value
?
value
.
toString
().
slice
(
0
,
5
)
:
'00:00'
;
}
function
combineDateAndTime
(
value
,
dateValue
,
timeValue
)
{
const
dateText
=
dateValue
?
dateValue
.
toString
().
slice
(
0
,
10
)
:
String
(
value
||
''
).
slice
(
0
,
10
);
if
(
!
dateText
)
{
return
''
;
}
return
`
${
dateText
}
T
${
formatTimeValue
(
timeValue
)}
`
;
}
function
emitDateChange
(
onChange
,
name
,
nextValue
)
{
onChange
?.({
target
:
{
name
,
value
:
nextValue
,
},
});
}
export
default
function
FormDatePicker
({
'aria-label'
:
ariaLabel
,
className
,
disabled
=
false
,
granularity
,
isClearable
=
true
,
max
,
min
,
mode
,
name
,
onChange
,
readOnly
=
false
,
required
=
false
,
type
=
'date'
,
value
,
variant
,
...
props
})
{
const
resolvedMode
=
mode
===
'month'
||
type
===
'month'
?
'month'
:
mode
===
'datetime'
||
type
===
'datetime-local'
?
'datetime'
:
'date'
;
const
resolvedGranularity
=
granularity
||
(
resolvedMode
===
'datetime'
?
'minute'
:
resolvedMode
===
'month'
?
'month'
:
'day'
);
const
parsedValue
=
useMemo
(
()
=>
parseValue
(
value
,
resolvedMode
),
[
resolvedMode
,
value
],
);
const
minValue
=
useMemo
(()
=>
parseValue
(
min
,
resolvedMode
),
[
min
,
resolvedMode
]);
const
maxValue
=
useMemo
(()
=>
parseValue
(
max
,
resolvedMode
),
[
max
,
resolvedMode
]);
const
timeValue
=
useMemo
(()
=>
parseTimeValue
(
value
),
[
value
]);
const
canClear
=
isClearable
&&
Boolean
(
value
)
&&
!
disabled
&&
!
readOnly
&&
!
required
;
return
(
<
I18nProvider
locale=
"zh-CN"
>
<
DatePicker
{
...
props
}
aria
-
label=
{
ariaLabel
||
'选择日期'
}
className=
{
[
'w-full'
,
className
].
filter
(
Boolean
).
join
(
' '
)
}
granularity=
{
resolvedGranularity
}
isDisabled=
{
disabled
}
isReadOnly=
{
readOnly
}
isRequired=
{
required
}
maxValue=
{
maxValue
||
undefined
}
minValue=
{
minValue
||
undefined
}
value=
{
parsedValue
}
onChange=
{
(
nextValue
)
=>
emitDateChange
(
onChange
,
name
,
formatValue
(
nextValue
,
resolvedMode
))
}
>
<
DateField
.
Group
className=
"relative"
fullWidth
variant=
{
variant
}
>
<
DateField
.
Input
className=
{
canClear
?
'pe-14'
:
'pe-8'
}
>
{
(
segment
)
=>
(
<
DateField
.
Segment
segment=
{
segment
}
/>
)
}
</
DateField
.
Input
>
<
DateField
.
Suffix
className=
"absolute inset-y-0 end-1 mr-0 gap-0"
>
{
canClear
?
(
<
CloseButton
aria
-
label=
{
`清空${ariaLabel || '日期'}`
}
onPress=
{
()
=>
emitDateChange
(
onChange
,
name
,
''
)
}
>
<
Close
className=
"grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size=
{
16
}
style=
{
FIELD_SUFFIX_ICON_STYLE
}
/>
</
CloseButton
>
)
:
null
}
<
DatePicker
.
Trigger
aria
-
label=
"打开日期选择"
className=
"size-6 w-6 shrink-0 justify-center p-1 text-field-placeholder"
>
<
DatePicker
.
TriggerIndicator
>
<
CalendarIcon
className=
"grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size=
{
16
}
style=
{
FIELD_SUFFIX_ICON_STYLE
}
/>
</
DatePicker
.
TriggerIndicator
>
</
DatePicker
.
Trigger
>
</
DateField
.
Suffix
>
</
DateField
.
Group
>
<
DatePicker
.
Popover
placement=
"bottom start"
>
<
HeroCalendar
>
<
HeroCalendar
.
Header
>
<
HeroCalendar
.
NavButton
slot=
"previous"
/>
<
HeroCalendar
.
Heading
/>
<
HeroCalendar
.
NavButton
slot=
"next"
/>
</
HeroCalendar
.
Header
>
<
HeroCalendar
.
Grid
>
<
HeroCalendar
.
GridHeader
>
{
(
day
)
=>
<
HeroCalendar
.
HeaderCell
>
{
day
}
</
HeroCalendar
.
HeaderCell
>
}
</
HeroCalendar
.
GridHeader
>
<
HeroCalendar
.
GridBody
>
{
(
date
)
=>
<
HeroCalendar
.
Cell
date=
{
date
}
/>
}
</
HeroCalendar
.
GridBody
>
</
HeroCalendar
.
Grid
>
</
HeroCalendar
>
{
resolvedMode
===
'datetime'
?
(
<
div
className=
"flex items-center justify-between gap-3 p-3"
>
<
span
className=
"text-sm text-muted"
>
时间
</
span
>
<
TimeField
aria
-
label=
"选择时间"
className=
"w-36"
granularity=
"minute"
hideTimeZone
hourCycle=
{
24
}
value=
{
timeValue
}
onChange=
{
(
nextTime
)
=>
emitDateChange
(
onChange
,
name
,
combineDateAndTime
(
value
,
parsedValue
,
nextTime
),
)
}
>
<
TimeField
.
Group
fullWidth
variant=
{
variant
}
>
<
TimeField
.
Input
>
{
(
segment
)
=>
(
<
TimeField
.
Segment
segment=
{
segment
}
/>
)
}
</
TimeField
.
Input
>
</
TimeField
.
Group
>
</
TimeField
>
</
div
>
)
:
null
}
</
DatePicker
.
Popover
>
</
DatePicker
>
</
I18nProvider
>
);
}
src/components/ReferenceSearchSelect.jsx
0 → 100644
View file @
84dfb853
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
{
queryRecords
}
from
'@/services/DataService'
;
import
{
Button
,
Label
}
from
'@heroui/react'
;
import
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
const
DEFAULT_ID_LOOKUP_CHUNK_SIZE
=
50
;
const
DEFAULT_SEARCH_FIELDS
=
[];
const
PERSONNEL_SUBJECTS
=
new
Set
([
'project_member'
,
'user'
]);
const
FULL_MODE_SUBJECTS
=
new
Set
([
'eg_project_info'
,
'project_member'
,
]);
function
normalizeText
(
value
)
{
return
String
(
value
||
''
).
trim
();
}
function
escapeMongoRegex
(
value
)
{
return
normalizeText
(
value
).
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
'
\\
$&'
);
}
function
buildSearchFilter
(
baseFilter
,
query
,
searchFields
=
[])
{
const
trimmedQuery
=
normalizeText
(
query
);
const
normalizedSearchFields
=
Array
.
from
(
new
Set
((
searchFields
||
[]).
map
((
field
)
=>
normalizeText
(
field
))),
).
filter
(
Boolean
);
if
(
!
trimmedQuery
||
normalizedSearchFields
.
length
===
0
)
{
return
baseFilter
;
}
const
keywordPattern
=
escapeMongoRegex
(
trimmedQuery
);
const
searchFilter
=
{
$or
:
normalizedSearchFields
.
map
((
field
)
=>
({
[
field
]:
{
$regex
:
keywordPattern
,
$options
:
'i'
,
},
})),
};
if
(
!
baseFilter
||
Object
.
keys
(
baseFilter
).
length
===
0
)
{
return
searchFilter
;
}
return
{
$and
:
[
baseFilter
,
searchFilter
],
};
}
function
pickSecondaryText
(
candidates
,
{
label
=
''
,
excluded
=
[]
}
=
{})
{
const
normalizedLabel
=
normalizeText
(
label
);
const
excludedSet
=
new
Set
(
[
normalizedLabel
,
...
excluded
.
map
((
item
)
=>
normalizeText
(
item
))].
filter
(
Boolean
,
),
);
for
(
const
candidate
of
candidates
)
{
const
value
=
normalizeText
(
candidate
);
if
(
!
value
||
excludedSet
.
has
(
value
))
{
continue
;
}
return
value
;
}
return
''
;
}
function
resolveRecordId
(
record
)
{
if
(
!
record
)
{
return
undefined
;
}
// Reference 写入协议:只接受 _id / id
return
record
.
id
||
record
.
_id
;
}
function
resolveReferenceValueId
(
value
)
{
if
(
!
value
)
{
return
''
;
}
if
(
typeof
value
===
'object'
)
{
return
normalizeText
(
resolveRecordId
(
value
));
}
return
normalizeText
(
value
);
}
function
resolveRecordSubjectName
(
record
,
fallbackSubject
=
''
)
{
if
(
!
record
)
{
return
normalizeText
(
fallbackSubject
);
}
if
(
typeof
record
.
subject
===
'string'
)
{
return
normalizeText
(
record
.
subject
);
}
return
normalizeText
(
record
.
subject
?.
name
||
fallbackSubject
);
}
function
resolvePersonnelPresentation
(
record
)
{
const
metadata
=
record
?.
metadata
||
{};
const
id
=
resolveRecordId
(
record
)
||
'-'
;
const
label
=
normalizeText
(
metadata
.
user_name
||
record
?.
user_name
||
record
?.
label
||
record
?.
display_name
||
metadata
.
display_name
||
record
?.
title
,
)
||
normalizeText
(
metadata
.
login_name
||
record
?.
login_name
||
record
?.
username
||
metadata
.
username
||
record
?.
name
,
)
||
id
;
const
description
=
pickSecondaryText
(
[
metadata
.
login_name
,
record
?.
login_name
,
record
?.
username
,
metadata
.
username
,
],
{
label
,
excluded
:
[
id
]
},
);
return
{
label
,
description
,
};
}
function
resolveRecordPresentation
(
record
,
fallbackSubject
=
''
)
{
if
(
!
record
)
{
return
{
label
:
'-'
,
description
:
''
,
};
}
const
metadata
=
record
.
metadata
||
{};
const
id
=
resolveRecordId
(
record
)
||
'-'
;
const
subjectName
=
resolveRecordSubjectName
(
record
,
fallbackSubject
);
if
(
PERSONNEL_SUBJECTS
.
has
(
subjectName
))
{
return
resolvePersonnelPresentation
(
record
);
}
const
projectName
=
normalizeText
(
metadata
.
project_name
||
record
.
project_name
,
);
const
projectCode
=
normalizeText
(
metadata
.
project_code
||
record
.
project_code
,
);
if
(
projectName
&&
projectCode
)
{
return
{
label
:
`
${
projectName
}
(
${
projectCode
}
)`
,
description
:
''
,
};
}
const
label
=
normalizeText
(
record
.
title
||
record
.
label
||
metadata
.
project_name
||
metadata
.
project_code
||
metadata
.
customer_full_name
||
metadata
.
user_name
||
metadata
.
login_name
||
metadata
.
code
||
record
.
name
,
)
||
id
;
const
description
=
pickSecondaryText
(
[
metadata
.
project_code
,
record
.
project_code
,
metadata
.
customer_full_name
,
metadata
.
user_name
,
metadata
.
login_name
,
metadata
.
code
,
record
.
name
,
],
{
label
,
excluded
:
[
id
]
},
);
return
{
label
,
description
,
};
}
function
createOptionFromRecord
(
record
,
fallbackSubject
=
''
)
{
const
id
=
resolveRecordId
(
record
);
if
(
!
id
)
{
return
null
;
}
const
presentation
=
resolveRecordPresentation
(
record
,
fallbackSubject
);
return
{
id
,
label
:
presentation
.
label
,
description
:
presentation
.
description
,
record
,
};
}
function
createOptionFromReferenceValue
(
value
,
fallbackSubject
=
''
)
{
if
(
!
value
||
typeof
value
!==
'object'
)
{
return
null
;
}
const
option
=
createOptionFromRecord
(
value
,
fallbackSubject
);
if
(
option
&&
(
option
.
description
||
(
option
.
label
&&
option
.
id
&&
normalizeText
(
option
.
label
)
!==
option
.
id
))
)
{
return
option
;
}
const
id
=
resolveReferenceValueId
(
value
);
if
(
!
id
)
{
return
null
;
}
const
label
=
normalizeText
(
value
.
label
||
value
.
title
||
value
.
name
||
value
.
display_name
,
)
||
''
;
if
(
!
label
||
label
===
id
)
{
return
null
;
}
return
{
id
,
label
,
description
:
''
,
record
:
value
,
};
}
function
uniqIds
(
ids
)
{
const
result
=
[];
const
seen
=
new
Set
();
for
(
const
id
of
ids
)
{
const
normalizedId
=
resolveReferenceValueId
(
id
);
if
(
!
normalizedId
||
seen
.
has
(
normalizedId
))
{
continue
;
}
seen
.
add
(
normalizedId
);
result
.
push
(
normalizedId
);
}
return
result
;
}
function
resolveQueryMode
(
subject
)
{
return
FULL_MODE_SUBJECTS
.
has
(
normalizeText
(
subject
))
?
'full'
:
'basic'
;
}
export
default
function
ReferenceSearchSelect
({
subject
,
value
,
onChange
,
multiple
=
false
,
multipleDisplayMode
=
'tags'
,
disabled
=
false
,
placeholder
=
'搜索并选择'
,
pageSize
=
20
,
queryFilter
,
searchFields
=
DEFAULT_SEARCH_FIELDS
,
optionsAnchor
,
showClearAction
=
true
,
selectedSummaryMode
=
'labels'
,
variant
,
})
{
const
rawSelectedValues
=
useMemo
(()
=>
{
if
(
multiple
)
{
return
Array
.
isArray
(
value
)
?
value
.
filter
(
Boolean
)
:
[];
}
return
value
?
[
value
]
:
[];
},
[
multiple
,
value
]);
const
selectedIds
=
useMemo
(
()
=>
uniqIds
(
rawSelectedValues
),
[
rawSelectedValues
],
);
const
[
query
,
setQuery
]
=
useState
(
''
);
const
[
options
,
setOptions
]
=
useState
([]);
const
[
selectedById
,
setSelectedById
]
=
useState
({});
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
active
,
setActive
]
=
useState
(
false
);
const
lastSearchRequestId
=
useRef
(
0
);
const
lastLookupRequestId
=
useRef
(
0
);
const
lookedUpIdsRef
=
useRef
(
new
Set
());
useEffect
(()
=>
{
const
selectedOptionsFromValue
=
rawSelectedValues
.
map
((
item
)
=>
createOptionFromReferenceValue
(
item
,
subject
))
.
filter
(
Boolean
);
if
(
selectedOptionsFromValue
.
length
===
0
)
{
return
;
}
setSelectedById
((
previous
)
=>
{
let
changed
=
false
;
const
next
=
{
...
previous
};
for
(
const
option
of
selectedOptionsFromValue
)
{
if
(
!
option
?.
id
)
{
continue
;
}
if
(
next
[
option
.
id
]?.
label
===
option
.
label
&&
next
[
option
.
id
]?.
description
===
option
.
description
)
{
continue
;
}
next
[
option
.
id
]
=
option
;
changed
=
true
;
}
return
changed
?
next
:
previous
;
});
},
[
rawSelectedValues
,
subject
]);
useEffect
(()
=>
{
const
missingIds
=
selectedIds
.
filter
(
(
id
)
=>
id
&&
!
selectedById
[
id
]
&&
!
lookedUpIdsRef
.
current
.
has
(
id
),
);
if
(
!
subject
||
missingIds
.
length
===
0
)
{
return
undefined
;
}
let
cancelled
=
false
;
const
requestId
=
++
lastLookupRequestId
.
current
;
const
fetchSelected
=
async
()
=>
{
try
{
missingIds
.
forEach
((
id
)
=>
lookedUpIdsRef
.
current
.
add
(
id
));
const
chunks
=
[];
for
(
let
i
=
0
;
i
<
missingIds
.
length
;
i
+=
DEFAULT_ID_LOOKUP_CHUNK_SIZE
)
{
chunks
.
push
(
missingIds
.
slice
(
i
,
i
+
DEFAULT_ID_LOOKUP_CHUNK_SIZE
));
}
for
(
const
chunk
of
chunks
)
{
const
response
=
await
queryRecords
({
subject
,
page
:
0
,
page_size
:
Math
.
max
(
chunk
.
length
,
1
),
filter
:
{
_id
:
{
$in
:
chunk
},
},
mode
:
resolveQueryMode
(
subject
),
});
if
(
cancelled
||
requestId
!==
lastLookupRequestId
.
current
)
{
return
;
}
const
records
=
response
?.
records
||
[];
setSelectedById
((
previous
)
=>
{
const
next
=
{
...
previous
};
for
(
const
record
of
records
)
{
const
option
=
createOptionFromRecord
(
record
,
subject
);
if
(
!
option
?.
id
)
{
continue
;
}
next
[
option
.
id
]
=
option
;
}
return
next
;
});
}
}
catch
{
// Ignore: fallback to showing raw ids if lookup fails.
}
};
fetchSelected
();
return
()
=>
{
cancelled
=
true
;
};
},
[
selectedIds
,
selectedById
,
subject
]);
useEffect
(()
=>
{
if
(
!
active
)
{
return
undefined
;
}
let
cancelled
=
false
;
const
requestId
=
++
lastSearchRequestId
.
current
;
setLoading
(
true
);
setError
(
''
);
const
timer
=
setTimeout
(
async
()
=>
{
if
(
!
subject
)
{
setOptions
([]);
setLoading
(
false
);
setError
(
'未配置引用 subject'
);
return
;
}
try
{
const
searchFilter
=
buildSearchFilter
(
queryFilter
,
query
,
searchFields
,
);
const
hasCustomSearchFields
=
(
searchFields
||
[]).
length
>
0
;
const
response
=
await
queryRecords
({
subject
,
page
:
0
,
page_size
:
pageSize
,
keyword
:
hasCustomSearchFields
?
undefined
:
query
.
trim
()
||
undefined
,
mode
:
resolveQueryMode
(
subject
),
filter
:
searchFilter
,
});
if
(
cancelled
||
requestId
!==
lastSearchRequestId
.
current
)
{
return
;
}
const
records
=
response
?.
records
||
[];
const
nextOptions
=
records
.
map
((
record
)
=>
createOptionFromRecord
(
record
,
subject
))
.
filter
(
Boolean
);
setOptions
(
nextOptions
);
}
catch
(
requestError
)
{
if
(
!
cancelled
)
{
setError
(
requestError
?.
message
||
'搜索失败'
);
}
}
finally
{
if
(
!
cancelled
)
{
setLoading
(
false
);
}
}
},
250
);
return
()
=>
{
cancelled
=
true
;
clearTimeout
(
timer
);
};
},
[
active
,
pageSize
,
query
,
queryFilter
,
searchFields
,
subject
]);
useEffect
(()
=>
{
if
(
options
.
length
===
0
)
{
return
;
}
setSelectedById
((
previous
)
=>
{
const
next
=
{
...
previous
};
for
(
const
option
of
options
)
{
next
[
option
.
id
]
=
option
;
}
return
next
;
});
},
[
options
]);
const
selectedOptions
=
useMemo
(()
=>
{
const
base
=
selectedIds
.
map
(
(
id
)
=>
selectedById
[
id
]
||
{
id
,
label
:
id
,
description
:
''
},
);
return
base
.
filter
(
Boolean
);
},
[
selectedById
,
selectedIds
]);
const
menuOptions
=
useMemo
(()
=>
{
if
(
selectedOptions
.
length
===
0
)
{
return
options
;
}
const
selectedIdSet
=
new
Set
(
selectedOptions
.
map
((
option
)
=>
option
.
id
));
return
[
...
selectedOptions
,
...
options
.
filter
((
option
)
=>
!
selectedIdSet
.
has
(
option
.
id
)),
];
},
[
options
,
selectedOptions
]);
const
commitChange
=
(
nextIds
)
=>
{
const
normalized
=
uniqIds
(
nextIds
);
if
(
multiple
)
{
onChange
?.(
normalized
);
return
;
}
onChange
?.(
normalized
[
0
]
||
''
);
};
const
allowClear
=
showClearAction
&&
!
disabled
&&
selectedIds
.
length
>
0
;
const
clearSelection
=
()
=>
{
if
(
disabled
)
{
return
;
}
commitChange
([]);
setQuery
(
''
);
};
const
displayText
=
multiple
&&
selectedSummaryMode
===
'count'
?
selectedOptions
.
length
===
0
?
placeholder
:
`已选择
${
selectedOptions
.
length
}
条记录`
:
multiple
?
selectedOptions
.
length
===
0
?
placeholder
:
selectedOptions
.
length
<=
2
?
selectedOptions
.
map
((
option
)
=>
option
.
label
).
join
(
'、'
)
:
`
${
selectedOptions
.
slice
(
0
,
2
)
.
map
((
option
)
=>
option
.
label
)
.
join
(
'、'
)}
等
${
selectedOptions
.
length
}
项`
:
selectedOptions
[
0
]?.
label
||
placeholder
;
const
selectionProps
=
multiple
?
{
selectedKeys
:
new
Set
(
selectedIds
),
onSelectionChange
:
(
keys
)
=>
{
const
nextIds
=
keys
===
'all'
?
menuOptions
.
map
((
item
)
=>
item
.
id
)
:
[...
keys
];
commitChange
(
nextIds
);
},
}
:
{
selectedKey
:
selectedIds
[
0
]
||
null
,
onSelectionChange
:
(
key
)
=>
{
const
nextKey
=
key
?
String
(
key
)
:
''
;
commitChange
(
nextKey
?
[
String
(
nextKey
)]
:
[]);
setActive
(
false
);
},
};
const
handleOpenChange
=
(
isOpen
)
=>
{
setActive
(
isOpen
);
if
(
!
isOpen
)
{
setQuery
(
''
);
setError
(
''
);
}
};
return
(
<
XinyuanAutocomplete
{
...
selectionProps
}
aria
-
label=
{
placeholder
}
emptyText=
"无匹配结果"
errorText=
{
error
}
inputValue=
{
query
}
isDisabled=
{
disabled
}
isLoading=
{
loading
}
isOpen=
{
active
}
options=
{
menuOptions
}
placement=
{
optionsAnchor
||
'bottom start'
}
placeholder=
{
placeholder
}
popoverHeader=
{
allowClear
?
(
<
div
className=
"flex justify-end px-3 pb-1"
>
<
Button
size=
"sm"
variant=
"tertiary"
onPress=
{
clearSelection
}
>
清空
</
Button
>
</
div
>
)
:
null
}
searchPlaceholder=
{
placeholder
}
selectionMode=
{
multiple
?
'multiple'
:
'single'
}
shouldFilter=
{
false
}
valueText=
{
displayText
}
variant=
{
variant
}
onInputChange=
{
setQuery
}
onOpenChange=
{
handleOpenChange
}
renderOption=
{
(
option
)
=>
(
<
div
className=
"min-w-0 flex-1"
>
<
Label
className=
"block truncate"
title=
{
option
.
label
}
>
{
option
.
label
}
</
Label
>
{
option
.
description
?
(
<
div
className=
"truncate text-xs text-muted"
title=
{
option
.
description
}
>
{
option
.
description
}
</
div
>
)
:
null
}
</
div
>
)
}
/>
);
}
src/components/XinyuanAutocomplete.jsx
0 → 100644
View file @
84dfb853
import
{
ChevronDown
,
Close
,
FIELD_SUFFIX_ICON_STYLE
,
Search
,
}
from
'@/components/AppIcons'
;
import
{
Autocomplete
,
Label
,
ListBox
,
SearchField
,
Spinner
,
}
from
'@heroui/react'
;
function
defaultFilter
(
textValue
,
inputValue
)
{
const
query
=
String
(
inputValue
||
''
).
trim
().
toLocaleLowerCase
();
return
!
query
||
String
(
textValue
||
''
).
toLocaleLowerCase
().
includes
(
query
);
}
function
getSelectedKeySet
(
selectionMode
,
selectedKey
,
selectedKeys
)
{
if
(
selectionMode
===
'multiple'
)
{
return
new
Set
(
selectedKeys
||
[]);
}
return
new
Set
(
selectedKey
===
null
||
selectedKey
===
undefined
?
[]
:
[
selectedKey
]);
}
export
default
function
XinyuanAutocomplete
({
'aria-label'
:
ariaLabel
,
className
,
emptyText
=
'暂无可选项'
,
errorText
=
''
,
filter
,
fullWidth
=
true
,
inputValue
,
isDisabled
=
false
,
isLoading
=
false
,
isOpen
,
isRequired
=
false
,
name
,
onInputChange
,
onOpenChange
,
onSelectionChange
,
options
=
[],
placeholder
=
'请选择'
,
placement
=
'bottom start'
,
popoverHeader
,
renderOption
,
searchPlaceholder
=
'输入关键词搜索'
,
selectedKey
,
selectedKeys
,
selectionMode
=
'single'
,
shouldFilter
=
true
,
valueText
,
variant
,
})
{
const
selectedKeySet
=
getSelectedKeySet
(
selectionMode
,
selectedKey
,
selectedKeys
,
);
const
selectedOptions
=
options
.
filter
((
option
)
=>
selectedKeySet
.
has
(
option
.
id
),
);
const
resolvedValueText
=
valueText
??
(
selectedOptions
.
length
>
0
?
selectedOptions
.
map
((
option
)
=>
option
.
label
).
join
(
'、'
)
:
placeholder
);
const
rootSelectionProps
=
selectionMode
===
'multiple'
?
{
selectedKeys
}
:
{
selectedKey
};
const
visibleOptions
=
isLoading
||
errorText
?
[]
:
options
;
return
(
<
Autocomplete
{
...
rootSelectionProps
}
aria
-
label=
{
ariaLabel
||
placeholder
}
className=
{
className
}
fullWidth=
{
fullWidth
}
isDisabled=
{
isDisabled
}
isOpen=
{
isOpen
}
isRequired=
{
isRequired
}
name=
{
name
}
selectionMode=
{
selectionMode
}
variant=
{
variant
}
onOpenChange=
{
onOpenChange
}
onSelectionChange=
{
onSelectionChange
}
>
<
Autocomplete
.
Trigger
>
<
Autocomplete
.
Value
>
<
span
className=
"block min-w-0 truncate"
title=
{
resolvedValueText
}
>
{
resolvedValueText
}
</
span
>
</
Autocomplete
.
Value
>
<
Autocomplete
.
Indicator
>
<
ChevronDown
size=
{
16
}
style=
{
FIELD_SUFFIX_ICON_STYLE
}
/>
</
Autocomplete
.
Indicator
>
</
Autocomplete
.
Trigger
>
<
Autocomplete
.
Popover
placement=
{
placement
}
>
<
Autocomplete
.
Filter
filter=
{
shouldFilter
?
filter
||
defaultFilter
:
()
=>
true
}
inputValue=
{
inputValue
}
onInputChange=
{
onInputChange
}
>
{
popoverHeader
}
<
SearchField
aria
-
label=
{
searchPlaceholder
}
fullWidth
variant=
{
variant
}
>
<
SearchField
.
Group
>
<
SearchField
.
SearchIcon
>
<
Search
size=
{
16
}
/>
</
SearchField
.
SearchIcon
>
<
SearchField
.
Input
autoFocus
placeholder=
{
searchPlaceholder
}
/>
<
SearchField
.
ClearButton
aria
-
label=
"清空搜索"
>
<
Close
size=
{
14
}
/>
</
SearchField
.
ClearButton
>
</
SearchField
.
Group
>
</
SearchField
>
<
ListBox
aria
-
label=
{
ariaLabel
||
placeholder
}
items=
{
visibleOptions
}
renderEmptyState=
{
()
=>
(
<
div
className=
"grid min-h-20 place-items-center px-3 py-4"
data
-
slot=
"empty-state"
>
{
isLoading
?
<
Spinner
size=
"sm"
/>
:
errorText
||
emptyText
}
</
div
>
)
}
>
{
(
option
)
=>
(
<
ListBox
.
Item
id=
{
option
.
id
}
isDisabled=
{
option
.
isDisabled
}
textValue=
{
option
.
textValue
||
option
.
label
}
>
{
renderOption
?
(
renderOption
(
option
)
)
:
(
<
Label
className=
"truncate"
title=
{
option
.
label
}
>
{
option
.
label
}
</
Label
>
)
}
<
ListBox
.
ItemIndicator
/>
</
ListBox
.
Item
>
)
}
</
ListBox
>
</
Autocomplete
.
Filter
>
</
Autocomplete
.
Popover
>
</
Autocomplete
>
);
}
src/components/record-workbench/index.jsx
View file @
84dfb853
...
...
@@ -175,6 +175,7 @@ function WorkbenchPagination({ pagination }) {
}
export default function RecordWorkbench({
accessDenied = false,
views = EMPTY_ARRAY,
activeViewName,
onViewChange,
...
...
@@ -370,6 +371,9 @@ export default function RecordWorkbench({
) : null}
{states.error ? <Alert status="danger"><Alert.Content><Alert.Description>{states.error}</Alert.Description></Alert.Content></Alert> : null}
<div className="relative min-w-0" ref={gridWrapRef}>
{accessDenied ? (
<DataGridEmpty title="当前账号没有可访问的数据视图" />
) : (
<DataGrid
showSelectionCheckboxes={Boolean(onSelectionChange)}
allowsColumnResize
...
...
@@ -387,12 +391,13 @@ export default function RecordWorkbench({
verticalAlign="middle"
onSelectionChange={onSelectionChange ? handleSelectionChange : undefined}
/>
)}
{states.loading && records.length > 0 ? <Surface className="absolute inset-0 grid place-items-center" variant="secondary"><Spinner size="sm" /></Surface> : null}
</div>
<WorkbenchPagination pagination={pagination} />
{!accessDenied ? <WorkbenchPagination pagination={pagination} /> : null}
</section>
<ActionBar aria-label="批量操作" isOpen={selectedIds.length > 0}>
<ActionBar aria-label="批量操作" isOpen={
!accessDenied &&
selectedIds.length > 0}>
<ActionBar.Prefix><span className="whitespace-nowrap text-sm text-muted">已选择 {selectedIds.length} 条记录</span></ActionBar.Prefix>
{visibleSelectionActions.length > 0 ? (
<ActionBar.Content>
...
...
src/config/application.js
View file @
84dfb853
export
const
APPLICATION_DISPLAY_NAME
=
'廉洁从业管理'
;
export
const
APPLICATION_ROUTE_NAME
=
'integrity_practice_management'
;
export
const
APPLICATION_BASE_PATH
=
'/integrity_practice_management/'
;
export
const
APPLICATION_NAME
=
'integrity_practice_management'
;
export
const
APPLICATION_ROUTE_NAME
=
APPLICATION_NAME
;
export
const
AUTHORIZATION_APPLICATION_NAME
=
APPLICATION_NAME
;
export
const
APPLICATION_BASE_PATH
=
`/
${
APPLICATION_NAME
}
/`
;
export
const
THEME_STORAGE_KEY
=
'xinyuan_color_theme'
;
export
const
ROUTE_TITLES
=
Object
.
freeze
({
...
...
@@ -11,4 +13,5 @@ export const ROUTE_TITLES = Object.freeze({
'/self_check_the_draft_template'
:
'自查底稿模版'
,
'/self_check_the_working_draft'
:
'自查底稿项目'
,
'/self_inspection_annual_submission_materials'
:
'历年报送材料'
,
'/notifications'
:
'通知消息'
,
});
src/pages/RecordDetailDrawer/components/SubjectViewContent.jsx
View file @
84dfb853
import
clsx
from
'clsx'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Surface
}
from
'@heroui/react'
;
import
TailwindEditableField
from
'./TailwindEditableField'
;
import
{
buildViewColumns
,
getFieldValue
,
isFieldDisabledByView
,
isReferenceFieldType
,
}
from
'@/utils/subjectView'
;
const
FIELD_LABEL_OVERRIDES
=
{
problem_discovery_situation
:
'问题发现情况'
,
};
const
FULL_WIDTH_FIELD_TYPES
=
new
Set
([
'long_text'
,
'rich_text'
,
'object'
,
'form'
,
'subject'
,
'file'
,
'reference'
,
'document'
,
'view'
,
'template'
,
'task'
,
]);
function
getFieldName
(
fieldPath
=
''
)
{
return
String
(
fieldPath
).
replace
(
/^metadata
\.
/
,
''
).
split
(
'.'
).
pop
();
}
function
getFieldLabel
(
field
)
{
const
fieldName
=
getFieldName
(
field
?.
field
);
const
configuredLabel
=
String
(
field
?.
label
||
''
).
trim
();
const
isTechnicalLabel
=
!
configuredLabel
||
configuredLabel
===
field
?.
field
||
configuredLabel
===
fieldName
||
configuredLabel
.
startsWith
(
'metadata.'
);
if
(
!
isTechnicalLabel
)
{
return
configuredLabel
;
}
return
FIELD_LABEL_OVERRIDES
[
fieldName
]
||
field
?.
fieldDef
?.
label
||
'其他信息'
;
}
export
default
function
SubjectViewContent
({
view
,
subject
,
editableRecord
,
onFieldUpdate
,
editSessionId
,
mode
=
'view'
,
onFieldChange
,
onPendingLocalMaterialChange
,
onFieldValidation
,
readonly
,
isFieldDisabled
,
onOpenReference
,
minimal
=
false
,
useAuditMaterialCenter
=
false
,
})
{
const
displayFields
=
buildViewColumns
(
view
,
subject
);
const
fullWidthFieldTypes
=
new
Set
([
'long_text'
,
'rich_text'
,
'object'
,
'form'
,
'subject'
,
'file'
,
'reference'
,
]);
const
contentFields
=
minimal
?
(
displayFields
||
[]).
filter
((
field
)
=>
field
.
field
!==
'title'
)
:
displayFields
;
const
fieldVariant
=
'secondary'
;
const
handleFieldUpdate
=
(
fieldPath
,
newValue
,
fieldDef
)
=>
{
onFieldUpdate
(
fieldPath
,
newValue
,
fieldDef
);
};
if
(
!
contentFields
||
contentFields
.
length
===
0
)
{
if
(
minimal
)
{
return
null
;
}
if
(
!
displayFields
||
displayFields
.
length
===
0
)
{
return
(
<
EmptyState
size=
"md"
>
<
EmptyState
.
Header
><
EmptyState
.
Title
>
暂无可显示的字段
</
EmptyState
.
Title
></
EmptyState
.
Header
>
...
...
@@ -41,43 +78,68 @@ export default function SubjectViewContent({
);
}
return
(
<
section
className=
"min-w-0"
>
<
div
className=
"grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2"
>
{
displayFields
.
map
((
field
)
=>
{
const
fields
=
(
<
section
className=
"grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2"
>
{
contentFields
.
map
((
field
)
=>
{
const
value
=
getFieldValue
(
editableRecord
,
field
.
field
);
const
fieldDisabled
=
isFieldDisabled
?.(
field
.
field
,
view
)
||
isFieldDisabledByView
(
field
.
field
,
view
);
const
isFullWidth
=
fullWidthFieldTypes
.
has
(
field
.
fieldDef
?.
type
)
||
isReferenceFieldType
(
field
.
fieldDef
?.
type
)
||
field
.
fieldDef
?.
multiple
&&
field
.
fieldDef
?.
type
!==
'select'
;
const
fieldType
=
field
.
fieldDef
?.
type
||
'text'
;
const
fieldLabel
=
getFieldLabel
(
field
);
const
isFullWidth
=
FULL_WIDTH_FIELD_TYPES
.
has
(
fieldType
);
const
fieldDisabled
=
isFieldDisabled
?
isFieldDisabled
(
field
.
field
,
view
)
:
isFieldDisabledByView
(
field
.
field
,
view
);
return
(
<
section
<
div
key=
{
field
.
field
}
className=
{
clsx
(
'min-w-0 py-3'
,
'min-w-0'
,
minimal
?
'py-1'
:
'py-3'
,
isFullWidth
&&
'md:col-span-2'
,
)
}
>
<
div
className=
"text-sm font-medium text-muted"
>
{
field
.
label
}
</
div
>
<
div
className=
"mt-1.5 min-w-0"
>
<
div
className=
{
minimal
?
'sr-only'
:
'text-sm font-medium text-muted'
}
>
{
fieldLabel
}
</
div
>
<
div
className=
{
minimal
?
undefined
:
'mt-1.5 min-w-0'
}
>
<
TailwindEditableField
value=
{
value
}
fieldDef=
{
field
.
fieldDef
}
onSave=
{
(
newValue
)
=>
handleFieldUpdate
(
field
.
field
,
newValue
,
field
.
fieldDef
)
}
fieldPath=
{
field
.
field
}
editSessionId=
{
editSessionId
}
fieldVariant=
{
fieldVariant
}
placeholder=
{
`请输入${fieldLabel}`
}
mode=
{
mode
}
onChange=
{
(
newValue
)
=>
onFieldChange
?.(
field
.
field
,
newValue
,
field
.
fieldDef
,
editSessionId
,
)
}
onValidationChange=
{
onFieldValidation
}
onPendingLocalMaterialChange=
{
onPendingLocalMaterialChange
}
readonly=
{
readonly
}
disabled=
{
fieldDisabled
}
onOpenReference=
{
onOpenReference
}
useAuditMaterialCenter=
{
useAuditMaterialCenter
}
/>
</
div
>
</
section
>
</
div
>
);
})
}
</
div
>
</
section
>
);
if
(
mode
===
'view'
&&
!
minimal
)
{
return
(
<
Surface
className=
"px-5 py-2 sm:px-6 sm:py-3"
variant=
"secondary"
>
{
fields
}
</
Surface
>
);
}
return
fields
;
}
src/pages/RecordDetailDrawer/components/TailwindEditableField.jsx
View file @
84dfb853
import
{
Calendar
as
CalendarIcon
,
ChevronDown
,
Close
,
Search
,
Upload
}
from
'@/components/AppIcons'
;
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
clsx
from
'clsx'
;
import
{
Alert
,
Button
,
Card
,
Checkbox
,
Chip
,
Input
,
Label
,
TextArea
,
}
from
'@heroui/react'
;
import
FormDatePicker
from
'@/components/FormDatePicker'
;
import
FileUploadField
from
'@/components/FileUploadField'
;
import
{
queryUsers
,
uploadFile
}
from
'@/services/DataService'
;
import
ReferenceSearchSelect
from
'@/components/ReferenceSearchSelect'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
IntegrityPracticeMaterialField
from
'@/pages/integrity_practice_management/components/IntegrityPracticeMaterialField'
;
import
ReferenceRecordsTable
from
'./ReferenceRecordsTable'
;
import
{
dateInputValue
,
formatFieldValue
,
getSelectOptions
,
isDateFieldType
,
isReferenceFieldType
,
getReferenceSubjectName
,
normalizeUserLabel
,
toArray
,
}
from
'@/utils/subjectView'
;
import
{
DropZone
}
from
'@heroui-pro/react/drop-zone'
;
import
{
Alert
,
Autocomplete
,
Calendar
,
Checkbox
,
Chip
,
DateField
,
DatePicker
,
I18nProvider
,
Input
,
Label
,
ListBox
,
SearchField
,
Select
,
TextArea
,
TimeField
,
}
from
'@heroui/react'
;
import
{
parseDate
,
parseDateTime
,
parseTime
}
from
'@internationalized/date'
;
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
ReferenceRecordsTable
from
'./ReferenceRecordsTable'
;
import
ReferenceSearchSelect
from
'./ReferenceSearchSelect'
;
function
asSaveValue
(
value
,
fieldDef
)
{
if
(
fieldDef
?.
multiple
)
{
return
toArray
(
value
).
filter
((
item
)
=>
item
!==
''
);
}
return
value
===
''
?
null
:
value
;
if
(
value
===
''
)
{
return
null
;
}
return
value
;
}
function
getClearedValue
(
fieldDef
)
{
return
fieldDef
?.
multiple
?
[]
:
null
;
}
function
Pill
({
children
,
tone
=
'neutral'
})
{
const
semanticTone
=
{
amber
:
'warning'
,
emerald
:
'success'
,
rose
:
'danger'
,
zinc
:
'neutral'
,
}[
tone
]
||
tone
;
return
(
<
Chip
color=
{
semanticTone
===
'neutral'
?
undefined
:
semanticTone
}
size=
"sm"
variant=
"secondary"
>
{
children
}
</
Chip
>
);
}
function
FieldError
({
children
})
{
function
FieldError
({
children
,
className
})
{
if
(
!
children
)
return
null
;
return
(
<
Alert
status=
"danger"
>
<
Alert
.
Content
><
Alert
.
Description
>
{
children
}
</
Alert
.
Description
></
Alert
.
Content
>
<
Alert
className=
{
className
}
status=
"danger"
>
<
Alert
.
Content
>
<
Alert
.
Description
>
{
children
}
</
Alert
.
Description
>
</
Alert
.
Content
>
</
Alert
>
);
}
function
isEmptyFieldValue
(
value
)
{
if
(
value
===
null
||
value
===
undefined
)
return
true
;
if
(
typeof
value
===
'string'
)
return
value
.
trim
()
===
''
;
if
(
Array
.
isArray
(
value
))
{
return
value
.
length
===
0
||
value
.
every
((
item
)
=>
isEmptyFieldValue
(
item
));
}
return
false
;
}
function
getTextPlaceholder
(
placeholder
,
fieldDef
)
{
const
explicitPlaceholder
=
String
(
placeholder
||
''
).
trim
();
if
(
explicitPlaceholder
)
return
explicitPlaceholder
;
const
fieldLabel
=
String
(
fieldDef
?.
label
||
''
).
trim
();
return
fieldLabel
?
`请输入
${
fieldLabel
}
`
:
'请输入内容'
;
}
function
FieldValue
({
value
,
fieldDef
})
{
const
isEmpty
=
value
===
null
||
value
===
undefined
||
value
===
''
||
(
Array
.
isArray
(
value
)
&&
value
.
length
===
0
);
if
(
isEmpty
)
return
<
span
className=
"text-sm text-muted"
>
未填写
</
span
>;
if
(
isEmptyFieldValue
(
value
))
{
return
<
span
className=
"text-sm text-muted"
>
暂无内容
</
span
>;
}
if
(
fieldDef
?.
type
===
'select'
)
{
const
text
=
formatFieldValue
(
value
,
fieldDef
);
return
(
<
div
className=
"flex flex-wrap gap-1.5"
>
{
toArray
(
text
.
split
(
'、'
)).
filter
(
Boolean
).
map
((
item
)
=>
<
Chip
key=
{
item
}
size=
"sm"
variant=
"secondary"
>
{
item
}
</
Chip
>)
}
{
toArray
(
text
.
split
(
'、'
)).
filter
(
Boolean
).
map
((
item
)
=>
(
<
Pill
key=
{
item
}
>
{
item
}
</
Pill
>
))
}
</
div
>
);
}
...
...
@@ -64,7 +111,11 @@ function FieldValue({ value, fieldDef }) {
if
(
fieldDef
?.
type
===
'user'
)
{
return
(
<
div
className=
"flex flex-wrap gap-1.5"
>
{
toArray
(
value
).
map
((
item
,
index
)
=>
<
Chip
color=
"success"
key=
{
`${normalizeUserLabel(item)}-${index}`
}
size=
"sm"
variant=
"secondary"
>
{
normalizeUserLabel
(
item
)
||
'-'
}
</
Chip
>)
}
{
toArray
(
value
).
map
((
item
,
index
)
=>
(
<
Pill
key=
{
`${normalizeUserLabel(item)}-${index}`
}
tone=
"success"
>
{
normalizeUserLabel
(
item
)
||
'-'
}
</
Pill
>
))
}
</
div
>
);
}
...
...
@@ -74,252 +125,793 @@ function FieldValue({ value, fieldDef }) {
<
div
className=
"flex flex-wrap gap-2"
>
{
toArray
(
value
).
map
((
file
,
index
)
=>
{
const
label
=
file
?.
name
||
file
?.
title
||
file
?.
url
||
`附件 ${index + 1}`
;
return
file
?.
url
?
<
a
href=
{
file
.
url
}
key=
{
`${label}-${index}`
}
rel=
"noreferrer"
target=
"_blank"
><
Chip
color=
"accent"
size=
"sm"
variant=
"secondary"
>
{
label
}
</
Chip
></
a
>
:
<
Chip
key=
{
`${label}-${index}`
}
size=
"sm"
variant=
"secondary"
>
{
label
}
</
Chip
>;
if
(
!
file
?.
url
)
{
return
<
Pill
key=
{
`${label}-${index}`
}
>
{
label
}
</
Pill
>;
}
return
(
<
a
key=
{
`${label}-${index}`
}
href=
{
file
.
url
}
target=
"_blank"
rel=
"noreferrer"
className=
"no-underline"
>
<
Pill
tone=
"info"
>
{
label
}
</
Pill
>
</
a
>
);
})
}
</
div
>
);
}
if
([
'object'
,
'form'
,
'subject'
].
includes
(
fieldDef
?.
type
))
{
return
<
pre
className=
"max-h-48 overflow-auto whitespace-pre-wrap text-xs leading-5"
>
{
JSON
.
stringify
(
value
,
null
,
2
)
}
</
pre
>;
if
(
fieldDef
?.
type
===
'object'
||
fieldDef
?.
type
===
'form'
||
fieldDef
?.
type
===
'subject'
)
{
return
(
<
Card
variant=
"tertiary"
>
<
Card
.
Content
>
<
pre
className=
"max-h-48 overflow-auto text-xs leading-5"
>
{
JSON
.
stringify
(
value
,
null
,
2
)
}
</
pre
>
</
Card
.
Content
>
</
Card
>
);
}
return
<
span
className=
"whitespace-pre-wrap"
>
{
formatFieldValue
(
value
,
fieldDef
)
||
'-'
}
</
span
>;
const
text
=
formatFieldValue
(
value
,
fieldDef
);
return
(
<
span
className=
{
clsx
(
'whitespace-pre-wrap'
,
isEmptyFieldValue
(
text
)
?
'text-sm text-muted'
:
'text-foreground'
)
}
>
{
isEmptyFieldValue
(
text
)
?
'暂无内容'
:
text
}
</
span
>
);
}
function
UserSelector
({
value
,
onChange
,
multiple
})
{
function
UserSelector
({
value
,
onChange
,
multiple
,
variant
})
{
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
options
,
setOptions
]
=
useState
([]);
const
selectedIds
=
useMemo
(()
=>
toArray
(
value
).
map
((
item
)
=>
item
?.
_id
||
item
).
filter
(
Boolean
).
map
(
String
),
[
value
]);
const
selectedIds
=
useMemo
(
()
=>
toArray
(
value
).
map
((
item
)
=>
item
?.
_id
||
item
).
filter
(
Boolean
),
[
value
],
);
useEffect
(()
=>
{
let
cancelled
=
false
;
const
loadOptions
=
async
()
=>
{
const
filter
=
keyword
.
trim
()
?
{
$or
:
[
const
filter
=
keyword
.
trim
()
?
{
$or
:
[
{
username
:
{
$regex
:
keyword
.
trim
(),
$options
:
'i'
}
},
{
display_name
:
{
$regex
:
keyword
.
trim
(),
$options
:
'i'
}
},
]
}
:
{};
],
}
:
{};
try
{
const
result
=
await
queryUsers
(
0
,
20
,
filter
);
if
(
!
cancelled
)
setOptions
(
result
?.
users
||
[]);
}
catch
{
if
(
!
cancelled
)
setOptions
([]);
if
(
!
cancelled
)
{
setOptions
(
result
?.
users
||
[]);
}
}
catch
(
error
)
{
if
(
!
cancelled
)
{
setOptions
([]);
}
}
};
loadOptions
();
return
()
=>
{
cancelled
=
true
;
};
return
()
=>
{
cancelled
=
true
;
};
},
[
keyword
]);
const
selectedOptions
=
options
.
filter
((
user
)
=>
selectedIds
.
includes
(
String
(
user
.
_id
)));
const
displayText
=
selectedOptions
.
length
>
0
?
selectedOptions
.
map
((
user
)
=>
user
.
display_name
||
user
.
username
).
join
(
'、'
)
:
selectedIds
.
length
>
0
?
`已选择
${
selectedIds
.
length
}
人`
:
'请选择用户'
;
const
handleSelectionChange
=
(
keys
)
=>
{
if
(
!
multiple
)
{
onChange
(
keys
?
String
(
keys
)
:
null
);
return
;
}
const
nextIds
=
keys
===
'all'
?
options
.
map
((
user
)
=>
user
.
_id
).
filter
(
Boolean
)
:
Array
.
from
(
keys
).
map
(
String
);
onChange
(
nextIds
);
};
const
autocompleteOptions
=
options
.
map
((
user
)
=>
({
id
:
user
.
_id
,
label
:
user
.
display_name
||
user
.
username
,
description
:
user
.
username
,
}));
const
selectionProps
=
multiple
?
{
selectedKeys
:
new
Set
(
selectedIds
)
}
:
{
selectedKey
:
selectedIds
[
0
]
||
null
};
const
valueText
=
toArray
(
value
)
.
map
((
item
)
=>
normalizeUserLabel
(
item
))
.
filter
(
Boolean
)
.
join
(
'、'
);
return
(
<
Autocomplete
aria
-
label=
"选择用户"
fullWidth
selectedKey=
{
multiple
?
undefined
:
selectedIds
[
0
]
||
null
}
selectedKeys=
{
multiple
?
new
Set
(
selectedIds
)
:
undefined
}
<
XinyuanAutocomplete
{
...
selectionProps
}
aria
-
label=
"用户"
emptyText=
"暂无用户"
inputValue=
{
keyword
}
options=
{
autocompleteOptions
}
placeholder=
"请选择用户"
searchPlaceholder=
"搜索用户名或显示名"
selectionMode=
{
multiple
?
'multiple'
:
'single'
}
variant=
"secondary"
onSelectionChange=
{
(
selection
)
=>
{
if
(
multiple
)
{
onChange
(
selection
===
'all'
?
options
.
map
((
user
)
=>
String
(
user
.
_id
))
:
Array
.
from
(
selection
||
[]).
map
(
String
));
}
else
{
onChange
(
selection
?
String
(
selection
)
:
null
);
}
}
}
>
<
Autocomplete
.
Trigger
>
<
Autocomplete
.
Value
><
span
className=
"block min-w-0 truncate"
>
{
displayText
}
</
span
></
Autocomplete
.
Value
>
<
Autocomplete
.
Indicator
><
ChevronDown
size=
{
16
}
/></
Autocomplete
.
Indicator
>
</
Autocomplete
.
Trigger
>
<
Autocomplete
.
Popover
placement=
"bottom start"
>
<
Autocomplete
.
Filter
filter=
{
()
=>
true
}
inputValue=
{
keyword
}
onInputChange=
{
setKeyword
}
>
<
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=
"用户候选项"
items=
{
options
}
renderEmptyState=
{
()
=>
<
div
className=
"grid min-h-20 place-items-center p-4 text-sm text-muted"
>
暂无用户
</
div
>
}
>
{
(
user
)
=>
<
ListBox
.
Item
id=
{
String
(
user
.
_id
)
}
textValue=
{
user
.
display_name
||
user
.
username
}
><
span
className=
"min-w-0"
><
Label
className=
"block truncate"
>
{
user
.
display_name
||
user
.
username
}
</
Label
><
span
className=
"block truncate text-xs text-muted"
>
{
user
.
username
}
</
span
></
span
><
ListBox
.
ItemIndicator
/></
ListBox
.
Item
>
}
</
ListBox
>
</
Autocomplete
.
Filter
>
</
Autocomplete
.
Popover
>
</
Autocomplete
>
shouldFilter=
{
false
}
valueText=
{
valueText
||
'请选择用户'
}
variant=
{
variant
}
onInputChange=
{
setKeyword
}
onSelectionChange=
{
handleSelectionChange
}
renderOption=
{
(
option
)
=>
(
<
span
className=
"min-w-0"
>
<
Label
className=
"block truncate"
>
{
option
.
label
}
</
Label
>
<
span
className=
"block truncate text-xs text-muted"
>
{
option
.
description
}
</
span
>
</
span
>
)
}
/>
);
}
function
FieldOptionSelect
({
fieldDef
,
value
,
onChange
})
{
const
options
=
getSelectOptions
(
fieldDef
);
const
multiple
=
Boolean
(
fieldDef
?.
multiple
);
const
normalizedValue
=
multiple
?
toArray
(
value
).
map
(
String
)
:
value
===
null
||
value
===
undefined
||
value
===
''
?
null
:
String
(
value
);
const
selectedValues
=
multiple
?
normalizedValue
:
normalizedValue
===
null
?
[]
:
[
normalizedValue
];
const
displayText
=
options
.
filter
((
option
)
=>
selectedValues
.
includes
(
String
(
option
.
value
))).
map
((
option
)
=>
option
.
label
).
join
(
'、'
)
||
'请选择'
;
function
FieldOptionSelect
({
multiple
,
onChange
,
options
,
value
,
variant
})
{
const
values
=
multiple
?
toArray
(
value
).
map
(
String
)
:
[
String
(
value
||
''
)];
const
selectedOptions
=
options
.
filter
((
option
)
=>
values
.
includes
(
String
(
option
.
value
)));
const
displayValue
=
selectedOptions
.
length
?
selectedOptions
.
map
((
option
)
=>
option
.
label
).
join
(
'、'
)
:
'请选择'
;
const
selectProps
=
multiple
?
{
selectedKeys
:
new
Set
(
values
),
onSelectionChange
:
(
keys
)
=>
onChange
(
keys
===
'all'
?
options
.
map
((
option
)
=>
option
.
value
)
:
[...
keys
]),
}
:
{
selectedKey
:
values
[
0
]
||
null
,
onSelectionChange
:
(
key
)
=>
onChange
(
key
?
String
(
key
)
:
''
),
};
return
(
<
Select
aria
-
label=
{
fieldDef
?.
label
||
fieldDef
?.
name
||
'请选择'
}
fullWidth
<
XinyuanAutocomplete
{
...
selectProps
}
aria
-
label=
"选择字段值"
options=
{
options
.
map
((
option
)
=>
({
id
:
String
(
option
.
value
),
label
:
option
.
label
,
}))
}
placeholder=
"请选择"
selectionMode=
{
multiple
?
'multiple'
:
'single'
}
shouldCloseOnSelect=
{
!
multiple
}
value=
{
normalizedValue
}
variant=
"secondary"
onChange=
{
(
nextValue
)
=>
onChange
(
multiple
?
(
Array
.
isArray
(
nextValue
)
?
nextValue
.
map
(
String
)
:
[])
:
nextValue
===
null
||
nextValue
===
undefined
||
nextValue
===
''
?
null
:
String
(
nextValue
))
}
>
<
Select
.
Trigger
><
Select
.
Value
><
span
className=
"block min-w-0 truncate"
>
{
displayText
}
</
span
></
Select
.
Value
><
Select
.
Indicator
><
ChevronDown
size=
{
16
}
/></
Select
.
Indicator
></
Select
.
Trigger
>
<
Select
.
Popover
placement=
"bottom start"
>
<
ListBox
aria
-
label=
{
fieldDef
?.
label
||
'字段选项'
}
>
{
options
.
map
((
option
)
=>
<
ListBox
.
Item
id=
{
String
(
option
.
value
)
}
isDisabled=
{
Boolean
(
option
.
disabled
)
}
key=
{
String
(
option
.
value
)
}
textValue=
{
String
(
option
.
label
)
}
><
Label
>
{
option
.
label
}
</
Label
><
ListBox
.
ItemIndicator
/></
ListBox
.
Item
>)
}
</
ListBox
>
</
Select
.
Popover
>
</
Select
>
valueText=
{
displayValue
}
variant=
{
variant
}
/>
);
}
function
parseDateValue
(
value
,
mode
)
{
if
(
!
value
)
return
null
;
try
{
return
mode
===
'datetime'
?
parseDateTime
(
String
(
value
).
slice
(
0
,
16
))
:
parseDate
(
String
(
value
).
slice
(
0
,
10
));
}
catch
{
return
null
;
function
ReadonlyFieldValue
({
value
,
fieldDef
})
{
if
(
isReferenceFieldType
(
fieldDef
?.
type
))
{
return
<
ReferenceRecordsTable
value=
{
value
}
fieldDef=
{
fieldDef
}
/>;
}
}
function
DateEditor
({
value
,
mode
,
onChange
})
{
const
parsedValue
=
useMemo
(()
=>
parseDateValue
(
value
,
mode
),
[
mode
,
value
]);
const
timeValue
=
useMemo
(()
=>
{
if
(
!
value
||
!
String
(
value
).
includes
(
'T'
))
return
null
;
try
{
return
parseTime
(
String
(
value
).
slice
(
11
,
16
));
}
catch
{
return
null
;
}
},
[
value
]);
const
emitDate
=
(
nextValue
)
=>
{
if
(
!
nextValue
)
return
onChange
(
null
);
const
dateText
=
nextValue
.
toString
().
slice
(
0
,
10
);
const
timeText
=
timeValue
?.
toString
().
slice
(
0
,
5
)
||
'00:00'
;
onChange
(
mode
===
'datetime'
?
`
${
dateText
}
T
${
timeText
}
`
:
dateText
);
};
return
(
<
I18nProvider
locale=
"zh-CN"
>
<
DatePicker
aria
-
label=
"选择日期"
granularity=
{
mode
===
'datetime'
?
'minute'
:
'day'
}
value=
{
parsedValue
}
onChange=
{
emitDate
}
>
<
DateField
.
Group
fullWidth
variant=
"secondary"
>
<
DateField
.
Input
>
{
(
segment
)
=>
<
DateField
.
Segment
segment=
{
segment
}
/>
}
</
DateField
.
Input
>
<
DateField
.
Suffix
>
<
DatePicker
.
Trigger
aria
-
label=
"打开日期选择"
><
DatePicker
.
TriggerIndicator
><
CalendarIcon
size=
{
16
}
/></
DatePicker
.
TriggerIndicator
></
DatePicker
.
Trigger
>
</
DateField
.
Suffix
>
</
DateField
.
Group
>
<
DatePicker
.
Popover
placement=
"bottom start"
>
<
Calendar
>
<
Calendar
.
Header
><
Calendar
.
NavButton
slot=
"previous"
/><
Calendar
.
Heading
/><
Calendar
.
NavButton
slot=
"next"
/></
Calendar
.
Header
>
<
Calendar
.
Grid
><
Calendar
.
GridHeader
>
{
(
day
)
=>
<
Calendar
.
HeaderCell
>
{
day
}
</
Calendar
.
HeaderCell
>
}
</
Calendar
.
GridHeader
><
Calendar
.
GridBody
>
{
(
date
)
=>
<
Calendar
.
Cell
date=
{
date
}
/>
}
</
Calendar
.
GridBody
></
Calendar
.
Grid
>
</
Calendar
>
{
mode
===
'datetime'
?
(
<
div
className=
"flex items-center justify-between gap-3 p-3"
>
<
span
className=
"text-sm text-muted"
>
时间
</
span
>
<
TimeField
aria
-
label=
"选择时间"
granularity=
"minute"
hideTimeZone
hourCycle=
{
24
}
value=
{
timeValue
}
onChange=
{
(
nextTime
)
=>
{
const
dateText
=
parsedValue
?.
toString
().
slice
(
0
,
10
)
||
String
(
value
||
''
).
slice
(
0
,
10
);
if
(
dateText
)
onChange
(
`${dateText}T${nextTime?.toString().slice(0, 5) || '00:00'}`
);
}
}
>
<
TimeField
.
Group
fullWidth
variant=
"secondary"
><
TimeField
.
Input
>
{
(
segment
)
=>
<
TimeField
.
Segment
segment=
{
segment
}
/>
}
</
TimeField
.
Input
></
TimeField
.
Group
>
</
TimeField
>
</
div
>
)
:
null
}
</
DatePicker
.
Popover
>
</
DatePicker
>
</
I18nProvider
>
);
return
<
FieldValue
value=
{
value
}
fieldDef=
{
fieldDef
}
/>;
}
function
FileEditor
({
value
,
fieldDef
,
onChange
})
{
function
ControlledFieldEditor
({
value
,
fieldDef
,
fieldPath
,
editSessionId
,
fieldVariant
,
placeholder
,
onChange
,
onPendingLocalMaterialChange
,
onValidationChange
,
useAuditMaterialCenter
,
})
{
const
[
error
,
setError
]
=
useState
(
''
);
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
fieldType
=
fieldDef
?.
type
||
'text'
;
const
isDateField
=
isDateFieldType
(
fieldType
);
const
isLongText
=
[
'long_text'
,
'rich_text'
].
includes
(
fieldType
);
const
textPlaceholder
=
getTextPlaceholder
(
placeholder
,
fieldDef
);
const
u
pload
=
async
(
files
)
=>
{
const
handleFileU
pload
=
async
(
files
)
=>
{
const
file
=
files
?.[
0
];
if
(
!
file
)
return
;
setUploading
(
true
);
setError
(
''
);
onValidationChange
?.(
fieldPath
,
'附件正在上传,请稍候。'
,
editSessionId
);
try
{
const
uploaded
=
await
uploadFile
(
file
);
const
fileValue
=
uploaded
?.
file
||
uploaded
?.
data
||
uploaded
;
onChange
(
fieldDef
?.
multiple
?
[...
toArray
(
value
),
fileValue
]
:
fileValue
);
onChange
?.
(
fieldDef
?.
multiple
?
[...
toArray
(
value
),
fileValue
]
:
fileValue
);
}
catch
(
uploadError
)
{
setError
(
uploadError
?.
message
||
'附件上传失败,请稍后重试。'
);
}
finally
{
setUploading
(
false
);
onValidationChange
?.(
fieldPath
,
''
,
editSessionId
);
}
};
if
(
fieldType
===
'boolean'
)
{
return
(
<
div
className=
"space-y-3"
>
{
toArray
(
value
).
length
>
0
?
(
<
DropZone
.
FileList
>
<
Checkbox
isSelected=
{
Boolean
(
value
)
}
onChange=
{
(
nextValue
)
=>
onChange
?.(
nextValue
)
}
>
<
Checkbox
.
Content
>
<
Checkbox
.
Control
>
<
Checkbox
.
Indicator
/>
</
Checkbox
.
Control
>
{
value
?
'是'
:
'否'
}
</
Checkbox
.
Content
>
</
Checkbox
>
);
}
if
(
fieldType
===
'select'
)
{
return
(
<
FieldOptionSelect
multiple=
{
Boolean
(
fieldDef
?.
multiple
)
}
onChange=
{
onChange
}
options=
{
getSelectOptions
(
fieldDef
)
}
value=
{
value
}
variant=
{
fieldVariant
}
/>
);
}
if
(
fieldType
===
'user'
)
{
return
(
<
UserSelector
value=
{
value
}
onChange=
{
onChange
}
multiple=
{
Boolean
(
fieldDef
?.
multiple
)
}
variant=
{
fieldVariant
}
/>
);
}
if
(
isReferenceFieldType
(
fieldType
))
{
const
referenceSubject
=
getReferenceSubjectName
(
fieldDef
);
return
(
<
ReferenceSearchSelect
subject=
{
referenceSubject
}
value=
{
value
}
onChange=
{
onChange
}
multiple=
{
Boolean
(
fieldDef
?.
multiple
)
}
placeholder=
"搜索并选择关联记录"
optionsAnchor=
"bottom start"
selectedSummaryMode=
"labels"
showClearAction
variant=
{
fieldVariant
}
/>
);
}
if
(
fieldType
===
'file'
)
{
if
(
useAuditMaterialCenter
)
{
return
(
<
IntegrityPracticeMaterialField
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"
>
{
toArray
(
value
).
length
===
0
?
(
<
span
className=
"text-sm text-muted"
>
未上传
</
span
>
)
:
(
<
div
className=
"space-y-2"
>
{
toArray
(
value
).
map
((
file
,
index
)
=>
{
const
label
=
file
?.
name
||
file
?.
title
||
file
?.
url
||
`附件 ${index + 1}`
;
return
<
DropZone
.
FileItem
key=
{
`${label}-${index}`
}
status=
"complete"
><
DropZone
.
FileFormatIcon
format=
{
String
(
label
).
split
(
'.'
).
pop
()?.
slice
(
0
,
4
).
toUpperCase
()
}
/><
DropZone
.
FileInfo
><
DropZone
.
FileName
>
{
label
}
</
DropZone
.
FileName
><
DropZone
.
FileMeta
>
已上传
</
DropZone
.
FileMeta
></
DropZone
.
FileInfo
><
DropZone
.
FileRemoveTrigger
aria
-
label=
{
`删除${label}`
}
onPress=
{
()
=>
onChange
(
fieldDef
?.
multiple
?
toArray
(
value
).
filter
((
_
,
fileIndex
)
=>
fileIndex
!==
index
)
:
null
)
}
><
Close
size=
{
14
}
/></
DropZone
.
FileRemoveTrigger
></
DropZone
.
FileItem
>;
return
(
<
div
key=
{
`${label}-${index}`
}
className=
"flex items-center justify-between gap-3"
>
<
span
className=
"min-w-0 flex-1 truncate text-sm text-foreground"
>
{
label
}
</
span
>
<
Button
size=
"sm"
variant=
"danger-soft"
onPress=
{
()
=>
onChange
?.(
fieldDef
?.
multiple
?
toArray
(
value
).
filter
((
_
,
fileIndex
)
=>
fileIndex
!==
index
)
:
null
,
)
}
>
删除
</
Button
>
</
div
>
);
})
}
</
DropZone
.
FileList
>
)
:
null
}
<
DropZone
>
<
DropZone
.
Area
aria
-
label=
"选择附件"
isDisabled=
{
uploading
}
onDrop=
{
async
(
event
)
=>
{
const
items
=
Array
.
from
(
event
?.
items
||
[]).
filter
((
item
)
=>
item
?.
kind
===
'file'
&&
typeof
item
.
getFile
===
'function'
);
upload
(
await
Promise
.
all
(
items
.
map
((
item
)
=>
item
.
getFile
())));
}
}
>
<
DropZone
.
Input
disabled=
{
uploading
}
multiple=
{
Boolean
(
fieldDef
?.
multiple
)
}
onSelect=
{
(
files
)
=>
upload
(
Array
.
from
(
files
||
[]))
}
/>
<
DropZone
.
Icon
><
Upload
size=
{
20
}
/></
DropZone
.
Icon
>
<
DropZone
.
Label
>
{
uploading
?
'附件上传中'
:
'将文件拖到此处'
}
</
DropZone
.
Label
>
<
DropZone
.
Description
>
将文件拖到此处,或从本地选择。
</
DropZone
.
Description
>
<
DropZone
.
Trigger
isDisabled=
{
uploading
}
><
Upload
size=
{
16
}
/>
选择附件
</
DropZone
.
Trigger
>
</
DropZone
.
Area
>
</
DropZone
>
</
div
>
)
}
<
FileUploadField
isDisabled=
{
uploading
}
onFilesChange=
{
handleFileUpload
}
description=
"上传该字段的附件。"
triggerLabel=
"选择附件"
/>
<
FieldError
>
{
error
}
</
FieldError
>
</
Card
.
Content
>
</
Card
>
);
}
if
(
fieldType
===
'form'
||
fieldType
===
'subject'
)
{
return
<
FieldValue
value=
{
value
}
fieldDef=
{
fieldDef
}
/>;
}
if
(
fieldType
===
'object'
)
{
const
jsonValue
=
typeof
value
===
'string'
?
value
:
JSON
.
stringify
(
value
??
null
,
null
,
2
);
return
(
<
div
className=
"space-y-2"
>
<
TextArea
fullWidth
rows=
{
8
}
value=
{
jsonValue
}
variant=
{
fieldVariant
}
onChange=
{
(
event
)
=>
{
const
nextValue
=
event
.
target
.
value
;
onChange
?.(
nextValue
);
if
(
!
nextValue
.
trim
())
{
setError
(
''
);
onValidationChange
?.(
fieldPath
,
''
,
editSessionId
);
return
;
}
try
{
JSON
.
parse
(
nextValue
);
setError
(
''
);
onValidationChange
?.(
fieldPath
,
''
,
editSessionId
);
}
catch
(
parseError
)
{
const
message
=
'JSON 格式不正确,请检查后再保存。'
;
setError
(
message
);
onValidationChange
?.(
fieldPath
,
message
,
editSessionId
);
}
}
}
/>
<
FieldError
>
{
error
}
</
FieldError
>
</
div
>
);
}
if
(
isLongText
)
{
return
(
<
TextArea
fullWidth
placeholder=
{
textPlaceholder
}
rows=
{
5
}
value=
{
value
||
''
}
variant=
{
fieldVariant
}
onChange=
{
(
event
)
=>
onChange
?.(
event
.
target
.
value
)
}
/>
);
}
if
(
isDateField
)
{
return
(
<
FormDatePicker
value=
{
dateInputValue
(
value
)
}
variant=
{
fieldVariant
}
onChange=
{
(
event
)
=>
onChange
?.(
event
.
target
.
value
)
}
/>
);
}
return
(
<
Input
fullWidth
placeholder=
{
textPlaceholder
}
type=
{
fieldType
===
'number'
?
'number'
:
'text'
}
value=
{
value
??
''
}
variant=
{
fieldVariant
}
onChange=
{
(
event
)
=>
{
const
nextValue
=
fieldType
===
'number'
?
event
.
target
.
value
===
''
?
''
:
Number
(
event
.
target
.
value
)
:
event
.
target
.
value
;
onChange
?.(
nextValue
);
}
}
/>
);
}
function
ControlledFieldEditor
({
value
,
fieldDef
,
disabled
,
onChange
})
{
export
default
function
TailwindEditableField
({
value
,
fieldDef
,
fieldPath
,
editSessionId
,
fieldVariant
=
'secondary'
,
placeholder
,
onSave
,
onChange
,
onPendingLocalMaterialChange
,
onValidationChange
,
useAuditMaterialCenter
=
false
,
mode
=
'inline'
,
readonly
=
false
,
disabled
=
false
,
})
{
const
[
editing
,
setEditing
]
=
useState
(
false
);
const
[
draft
,
setDraft
]
=
useState
(
value
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
jsonDraft
,
setJsonDraft
]
=
useState
(()
=>
typeof
value
===
'string'
?
value
:
JSON
.
stringify
(
value
??
null
,
null
,
2
));
const
fieldType
=
fieldDef
?.
type
||
'text'
;
const
[
uploading
,
setUploading
]
=
useState
(
false
);
useEffect
(()
=>
{
set
JsonDraft
(
typeof
value
===
'string'
?
value
:
JSON
.
stringify
(
value
??
null
,
null
,
2
)
);
set
Draft
(
value
);
},
[
value
]);
if
(
disabled
)
return
<
div
className=
"space-y-2"
><
FieldValue
fieldDef=
{
fieldDef
}
value=
{
value
}
/><
div
className=
"text-xs text-warning"
>
当前视图无编辑权限
</
div
></
div
>;
if
(
fieldType
===
'boolean'
)
return
<
Checkbox
isSelected=
{
Boolean
(
value
)
}
onChange=
{
onChange
}
><
Checkbox
.
Content
><
Checkbox
.
Control
><
Checkbox
.
Indicator
/></
Checkbox
.
Control
>
{
value
?
'是'
:
'否'
}
</
Checkbox
.
Content
></
Checkbox
>;
if
(
fieldType
===
'select'
)
return
<
FieldOptionSelect
fieldDef=
{
fieldDef
}
onChange=
{
onChange
}
value=
{
value
}
/>;
if
(
fieldType
===
'user'
)
return
<
UserSelector
multiple=
{
Boolean
(
fieldDef
?.
multiple
)
}
onChange=
{
onChange
}
value=
{
value
}
/>;
if
(
isReferenceFieldType
(
fieldType
))
return
<
ReferenceSearchSelect
fieldDef=
{
fieldDef
}
onChange=
{
onChange
}
value=
{
value
}
/>;
if
(
fieldType
===
'file'
)
return
<
FileEditor
fieldDef=
{
fieldDef
}
onChange=
{
onChange
}
value=
{
value
}
/>;
if
([
'form'
,
'subject'
].
includes
(
fieldType
))
return
<
div
className=
"space-y-2"
><
FieldValue
fieldDef=
{
fieldDef
}
value=
{
value
}
/><
div
className=
"text-xs text-muted"
>
该结构字段仅支持查看
</
div
></
div
>;
if
(
fieldType
===
'object'
)
return
<
div
className=
"space-y-2"
><
TextArea
fullWidth
rows=
{
8
}
value=
{
jsonDraft
}
variant=
"secondary"
onChange=
{
(
event
)
=>
{
const
nextValue
=
event
.
target
.
value
;
setJsonDraft
(
nextValue
);
useEffect
(()
=>
{
if
(
readonly
||
disabled
)
{
setEditing
(
false
);
}
},
[
readonly
,
disabled
]);
const
canEdit
=
!
readonly
&&
!
disabled
;
const
fieldType
=
fieldDef
?.
type
||
'text'
;
const
isDateField
=
isDateFieldType
(
fieldType
);
const
isLongText
=
[
'long_text'
,
'rich_text'
].
includes
(
fieldType
);
const
isJsonLike
=
[
'object'
,
'form'
,
'subject'
].
includes
(
fieldType
);
const
textPlaceholder
=
getTextPlaceholder
(
placeholder
,
fieldDef
);
if
(
mode
===
'view'
||
(
mode
===
'edit'
&&
(
readonly
||
disabled
)))
{
return
<
ReadonlyFieldValue
value=
{
value
}
fieldDef=
{
fieldDef
}
/>;
}
if
(
mode
===
'edit'
)
{
return
(
<
ControlledFieldEditor
value=
{
value
}
fieldDef=
{
fieldDef
}
fieldPath=
{
fieldPath
}
editSessionId=
{
editSessionId
}
fieldVariant=
{
fieldVariant
}
placeholder=
{
textPlaceholder
}
onChange=
{
onChange
}
onPendingLocalMaterialChange=
{
onPendingLocalMaterialChange
}
onValidationChange=
{
onValidationChange
}
useAuditMaterialCenter=
{
useAuditMaterialCenter
}
/>
);
}
const
save
=
async
(
nextValue
=
draft
)
=>
{
setError
(
''
);
if
(
fieldType
===
'object'
&&
typeof
nextValue
===
'string'
&&
nextValue
.
trim
())
{
try
{
onChange
(
nextValue
.
trim
()
?
JSON
.
parse
(
nextValue
)
:
null
);
nextValue
=
JSON
.
parse
(
nextValue
);
}
catch
(
parseError
)
{
setError
(
'JSON 格式不正确,请检查后再保存。'
);
return
;
}
}
try
{
await
onSave
(
asSaveValue
(
nextValue
,
fieldDef
));
setEditing
(
false
);
}
catch
(
saveError
)
{
setError
(
saveError
?.
message
||
'保存失败,请稍后重试。'
);
}
};
const
clearValue
=
async
()
=>
{
const
clearedValue
=
getClearedValue
(
fieldDef
);
setDraft
(
clearedValue
);
await
save
(
clearedValue
);
};
const
cancel
=
()
=>
{
setDraft
(
value
);
setError
(
''
);
}
catch
{
setError
(
'JSON 格式不正确'
);
setEditing
(
false
);
};
const
handleFileUpload
=
async
(
files
)
=>
{
const
file
=
files
?.[
0
];
if
(
!
file
)
{
return
;
}
}
}
/><
FieldError
>
{
error
}
</
FieldError
></
div
>;
if
([
'long_text'
,
'rich_text'
].
includes
(
fieldType
))
return
<
TextArea
fullWidth
rows=
{
5
}
value=
{
value
||
''
}
variant=
"secondary"
onChange=
{
(
event
)
=>
onChange
(
event
.
target
.
value
)
}
/>;
if
(
isDateFieldType
(
fieldType
))
return
<
DateEditor
mode=
{
fieldType
===
'date'
?
'date'
:
'datetime'
}
onChange=
{
onChange
}
value=
{
fieldType
===
'date'
?
dateInputValue
(
value
)
:
value
}
/>;
return
<
Input
fullWidth
type=
{
fieldType
===
'number'
?
'number'
:
'text'
}
value=
{
value
??
''
}
variant=
"secondary"
onChange=
{
(
event
)
=>
onChange
(
fieldType
===
'number'
?
event
.
target
.
value
===
''
?
''
:
Number
(
event
.
target
.
value
)
:
event
.
target
.
value
)
}
/>;
}
export
default
function
TailwindEditableField
({
value
,
fieldDef
,
onSave
,
mode
,
readonly
=
false
,
disabled
=
false
,
onOpenReference
})
{
const
fieldType
=
fieldDef
?.
type
||
'text'
;
setUploading
(
true
);
setError
(
''
);
try
{
const
uploaded
=
await
uploadFile
(
file
);
const
fileValue
=
uploaded
?.
file
||
uploaded
?.
data
||
uploaded
;
const
nextValue
=
fieldDef
?.
multiple
?
[...
toArray
(
draft
),
fileValue
]
:
fileValue
;
setDraft
(
nextValue
);
await
onSave
(
nextValue
);
setEditing
(
false
);
}
catch
(
uploadError
)
{
setError
(
uploadError
?.
message
||
'附件上传失败,请稍后重试。'
);
}
finally
{
setUploading
(
false
);
}
};
const
handleRemoveFile
=
async
(
indexToRemove
)
=>
{
setError
(
''
);
const
nextValue
=
fieldDef
?.
multiple
?
toArray
(
draft
).
filter
((
_
,
index
)
=>
index
!==
indexToRemove
)
:
null
;
try
{
setDraft
(
nextValue
);
await
onSave
(
nextValue
);
if
(
!
fieldDef
?.
multiple
)
{
setEditing
(
false
);
}
}
catch
(
saveError
)
{
setDraft
(
draft
);
setError
(
saveError
?.
message
||
'删除附件失败,请稍后重试。'
);
}
};
if
(
!
editing
)
{
if
(
isReferenceFieldType
(
fieldType
))
{
return
(
<
div
>
<
ReferenceRecordsTable
value=
{
value
}
fieldDef=
{
fieldDef
}
action=
{
canEdit
?
(
<
Button
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
setEditing
(
true
)
}
>
编辑关联记录
</
Button
>
)
:
null
}
/>
{
disabled
?
<
div
className=
"mt-2 text-xs text-warning"
>
当前视图无编辑权限
</
div
>
:
null
}
</
div
>
);
}
return
(
<
Button
type=
"button"
isDisabled=
{
!
canEdit
}
onPress=
{
()
=>
canEdit
&&
setEditing
(
true
)
}
title=
{
canEdit
?
'点击编辑'
:
undefined
}
variant=
"ghost"
className=
{
clsx
(
'w-full justify-start text-left'
,
!
canEdit
&&
'cursor-default'
)
}
>
<
FieldValue
value=
{
value
}
fieldDef=
{
fieldDef
}
/>
{
disabled
?
(
<
div
className=
"mt-2 text-xs text-warning"
>
当前视图无编辑权限
</
div
>
)
:
null
}
</
Button
>
);
}
if
(
fieldType
===
'boolean'
)
{
return
(
<
div
className=
"flex flex-wrap items-center gap-3"
>
<
Checkbox
isSelected=
{
Boolean
(
draft
)
}
onChange=
{
setDraft
}
>
<
Checkbox
.
Content
>
<
Checkbox
.
Control
>
<
Checkbox
.
Indicator
/>
</
Checkbox
.
Control
>
{
draft
?
'是'
:
'否'
}
</
Checkbox
.
Content
>
</
Checkbox
>
<
Button
size=
"sm"
variant=
"tertiary"
onPress=
{
clearValue
}
className=
"ml-auto"
>
清空
</
Button
>
<
Button
size=
"sm"
onPress=
{
()
=>
save
(
Boolean
(
draft
))
}
>
保存
</
Button
>
<
Button
size=
"sm"
variant=
"secondary"
onPress=
{
cancel
}
>
取消
</
Button
>
<
FieldError
className=
"basis-full"
>
{
error
}
</
FieldError
>
</
div
>
);
}
if
(
fieldType
===
'select'
)
{
const
options
=
getSelectOptions
(
fieldDef
);
return
(
<
div
className=
"space-y-3"
>
<
FieldOptionSelect
multiple=
{
Boolean
(
fieldDef
?.
multiple
)
}
onChange=
{
setDraft
}
options=
{
options
}
value=
{
draft
}
/>
<
div
className=
"flex justify-end gap-2"
>
<
Button
size=
"sm"
variant=
"tertiary"
onPress=
{
clearValue
}
>
清空
</
Button
>
<
Button
size=
"sm"
variant=
"secondary"
onPress=
{
cancel
}
>
取消
</
Button
>
<
Button
size=
"sm"
onPress=
{
()
=>
save
()
}
>
保存
</
Button
>
</
div
>
<
FieldError
>
{
error
}
</
FieldError
>
</
div
>
);
}
if
(
fieldType
===
'user'
)
{
return
(
<
div
className=
"space-y-3"
>
<
UserSelector
value=
{
draft
}
onChange=
{
setDraft
}
multiple=
{
fieldDef
?.
multiple
}
/>
<
div
className=
"flex justify-end gap-2"
>
<
Button
size=
"sm"
variant=
"tertiary"
onPress=
{
clearValue
}
>
清空
</
Button
>
<
Button
size=
"sm"
variant=
"secondary"
onPress=
{
cancel
}
>
取消
</
Button
>
<
Button
size=
"sm"
onPress=
{
()
=>
save
()
}
>
保存
</
Button
>
</
div
>
<
FieldError
>
{
error
}
</
FieldError
>
</
div
>
);
}
if
(
isReferenceFieldType
(
fieldType
))
{
const
referenceSubject
=
getReferenceSubjectName
(
fieldDef
);
return
(
<
div
className=
"space-y-3"
>
<
ReferenceSearchSelect
subject=
{
referenceSubject
}
value=
{
draft
}
onChange=
{
setDraft
}
multiple=
{
Boolean
(
fieldDef
?.
multiple
)
}
placeholder=
"搜索并选择关联记录"
optionsAnchor=
"bottom start"
selectedSummaryMode=
"labels"
showClearAction
/>
<
div
className=
"flex justify-end gap-2"
>
<
Button
size=
"sm"
variant=
"tertiary"
onPress=
{
clearValue
}
>
清空
</
Button
>
<
Button
size=
"sm"
variant=
"secondary"
onPress=
{
cancel
}
>
取消
</
Button
>
<
Button
size=
"sm"
onPress=
{
()
=>
save
()
}
>
保存
</
Button
>
</
div
>
<
FieldError
>
{
error
}
</
FieldError
>
</
div
>
);
}
if
(
fieldType
===
'file'
)
{
return
(
<
Card
variant=
"secondary"
>
<
Card
.
Content
className=
"space-y-3"
>
<
div
className=
"space-y-2"
>
{
toArray
(
draft
).
length
===
0
?
(
<
span
className=
"text-muted"
>
未上传
</
span
>
)
:
(
toArray
(
draft
).
map
((
file
,
index
)
=>
{
const
label
=
file
?.
name
||
file
?.
title
||
file
?.
url
||
`附件 ${index + 1}`
;
return
(
<
div
key=
{
`${label}-${index}`
}
className=
"flex items-center justify-between gap-3"
>
<
div
className=
"min-w-0 flex-1"
>
{
file
?.
url
?
(
<
a
href=
{
file
.
url
}
target=
"_blank"
rel=
"noreferrer"
className=
"text-sm font-medium text-accent transition hover:text-accent-hover"
>
{
label
}
</
a
>
)
:
(
<
div
className=
"text-sm font-medium text-foreground"
>
{
label
}
</
div
>
)
}
</
div
>
<
Button
size=
"sm"
variant=
"danger-soft"
onPress=
{
()
=>
handleRemoveFile
(
index
)
}
>
删除
</
Button
>
</
div
>
);
})
)
}
</
div
>
<
FileUploadField
isDisabled=
{
uploading
}
onFilesChange=
{
handleFileUpload
}
description=
"上传该字段的附件。"
triggerLabel=
"选择附件"
/>
<
FieldError
>
{
error
}
</
FieldError
>
<
div
className=
"flex justify-end gap-2"
>
<
Button
size=
"sm"
variant=
"tertiary"
onPress=
{
clearValue
}
>
清空
</
Button
>
<
Button
size=
"sm"
variant=
"secondary"
onPress=
{
cancel
}
>
取消
</
Button
>
<
span
className=
"text-sm text-muted"
>
{
uploading
?
'上传中...'
:
'选择文件后自动保存'
}
</
span
>
</
div
>
</
Card
.
Content
>
</
Card
>
);
}
if
(
mode
===
'view'
)
{
if
(
isReferenceFieldType
(
fieldType
))
return
<
ReferenceRecordsTable
fieldDef=
{
fieldDef
}
onOpenReference=
{
onOpenReference
}
value=
{
value
}
/>;
return
<
FieldValue
fieldDef=
{
fieldDef
}
value=
{
value
}
/>;
if
(
isJsonLike
)
{
const
jsonValue
=
typeof
draft
===
'string'
?
draft
:
JSON
.
stringify
(
draft
??
null
,
null
,
2
);
return
(
<
div
className=
"space-y-3"
>
<
TextArea
fullWidth
rows=
{
8
}
value=
{
jsonValue
}
variant=
"secondary"
onChange=
{
(
event
)
=>
setDraft
(
event
.
target
.
value
)
}
/>
<
FieldError
>
{
error
}
</
FieldError
>
<
div
className=
"flex justify-end gap-2"
>
<
Button
size=
"sm"
variant=
"tertiary"
onPress=
{
clearValue
}
>
清空
</
Button
>
<
Button
size=
"sm"
variant=
"secondary"
onPress=
{
cancel
}
>
取消
</
Button
>
<
Button
size=
"sm"
onPress=
{
()
=>
(
fieldType
===
'object'
?
save
()
:
cancel
())
}
>
{
fieldType
===
'object'
?
'保存'
:
'关闭'
}
</
Button
>
</
div
>
</
div
>
);
}
return
<
ControlledFieldEditor
disabled=
{
readonly
||
disabled
}
fieldDef=
{
fieldDef
}
value=
{
value
}
onChange=
{
(
nextValue
)
=>
onSave
(
asSaveValue
(
nextValue
,
fieldDef
))
}
/>;
if
(
isLongText
)
{
return
(
<
div
className=
"space-y-3"
>
<
TextArea
autoFocus
fullWidth
placeholder=
{
textPlaceholder
}
rows=
{
5
}
value=
{
draft
||
''
}
variant=
"secondary"
onChange=
{
(
event
)
=>
setDraft
(
event
.
target
.
value
)
}
/>
<
div
className=
"flex justify-end gap-2"
>
<
Button
size=
"sm"
variant=
"tertiary"
onPress=
{
clearValue
}
>
清空
</
Button
>
<
Button
size=
"sm"
variant=
"secondary"
onPress=
{
cancel
}
>
取消
</
Button
>
<
Button
size=
"sm"
onPress=
{
()
=>
save
()
}
>
保存
</
Button
>
</
div
>
<
FieldError
>
{
error
}
</
FieldError
>
</
div
>
);
}
return
(
<
div
className=
"space-y-3"
>
{
isDateField
?
(
<
FormDatePicker
value=
{
dateInputValue
(
draft
)
}
onChange=
{
(
event
)
=>
setDraft
(
event
.
target
.
value
)
}
/>
)
:
(
<
Input
autoFocus
fullWidth
placeholder=
{
textPlaceholder
}
type=
{
fieldType
===
'number'
?
'number'
:
'text'
}
value=
{
draft
??
''
}
variant=
"secondary"
onChange=
{
(
event
)
=>
{
const
nextValue
=
fieldType
===
'number'
?
event
.
target
.
value
===
''
?
''
:
Number
(
event
.
target
.
value
)
:
event
.
target
.
value
;
setDraft
(
nextValue
);
}
}
/>
)
}
<
div
className=
"flex justify-end gap-2"
>
<
Button
size=
"sm"
variant=
"tertiary"
onPress=
{
clearValue
}
>
清空
</
Button
>
<
Button
size=
"sm"
variant=
"secondary"
onPress=
{
cancel
}
>
取消
</
Button
>
<
Button
size=
"sm"
onPress=
{
()
=>
save
()
}
>
保存
</
Button
>
</
div
>
<
FieldError
>
{
error
}
</
FieldError
>
</
div
>
);
}
src/pages/RecordDetailDrawer/hooks/useSubjectRecord.js
View file @
84dfb853
import
{
useEffect
,
useRef
,
useState
}
from
'react'
;
import
{
getSubjectView
,
getSubjectViewDetail
,
loadRecord
,
loadSubject
,
updateRecord
,
...
...
@@ -37,6 +38,14 @@ function transformSaveValue(newValue, fieldDef) {
return
newValue
===
''
?
null
:
Number
(
newValue
);
}
if
(
fieldDef
?.
type
===
'object'
&&
typeof
newValue
===
'string'
)
{
try
{
return
JSON
.
parse
(
newValue
);
}
catch
(
error
)
{
throw
new
Error
(
`
${
fieldDef
?.
label
||
'对象字段'
}
的
JSON
格式不正确,请检查后再保存。
`);
}
}
return newValue;
}
...
...
@@ -66,10 +75,8 @@ export function useSubjectRecord(
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [loading, setLoading] = useState(false);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const [error, setError] = useState('');
const requestIdRef = useRef(0);
const
pendingPayloadRef
=
useRef
({});
useEffect(() => {
let cancelled = false;
...
...
@@ -91,14 +98,31 @@ export function useSubjectRecord(
try {
const record = await loadRecord(recordName);
const resolvedSubjectName = subjectName || record?.__t || record?.subject?.name || record?.subject;
const
[
subjectData
,
view
sData
]
=
resolvedSubjectName
const [subjectData, view
Summaries
] = resolvedSubjectName
? await Promise.all([loadSubject(resolvedSubjectName), getSubjectView(resolvedSubjectName)])
: [null, []];
const viewsData = await Promise.all(
(Array.isArray(viewSummaries) ? viewSummaries : []).map(async (view) => {
if (!view?.name || !resolvedSubjectName) {
return view;
}
try {
const detail = await getSubjectViewDetail(resolvedSubjectName, view.name);
return {
...view,
...detail,
settings: detail?.settings || view?.settings || {},
};
} catch (error) {
return view;
}
}),
);
if (!cancelled && requestIdRef.current === requestId) {
setRemoteRecord(record);
setEditableRecord(JSON.parse(JSON.stringify(record)));
pendingPayloadRef
.
current
=
{};
setSubject(subjectData);
setViews(viewsData || []);
}
...
...
@@ -124,14 +148,26 @@ export function useSubjectRecord(
};
}, [recordName, refreshKey, subjectName]);
const
handleField
Update
=
(
fieldPath
,
newValue
,
fieldDef
)
=>
{
const handleField
sUpdate = async (changes = []
) => {
if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) {
return;
}
let
nextRecord
=
setFieldValue
(
editableRecord
,
fieldPath
,
newValue
);
const normalizedChanges = changes.filter((change) => change?.fieldPath);
if (normalizedChanges.length === 0) {
return;
}
const previousRecord = editableRecord;
let nextRecord = editableRecord;
let payload = {};
try {
normalizedChanges.forEach(({ fieldPath, newValue, fieldDef }) => {
const saveValue = transformSaveValue(newValue, fieldDef);
let
payload
=
buildNestedPayload
(
fieldPath
,
saveValue
);
nextRecord = setFieldValue(nextRecord, fieldPath, saveValue);
payload = mergePayload(payload, buildNestedPayload(fieldPath, saveValue));
const derivedFieldValues = deriveFieldValues?.({
fieldPath,
fieldDef,
...
...
@@ -143,31 +179,20 @@ export function useSubjectRecord(
nextRecord = setFieldValue(nextRecord, derivedFieldPath, derivedValue);
payload = mergePayload(payload, buildNestedPayload(derivedFieldPath, derivedValue));
});
});
pendingPayloadRef
.
current
=
mergePayload
(
pendingPayloadRef
.
current
,
payload
);
setEditableRecord(nextRecord);
};
const
resetDraft
=
()
=>
{
pendingPayloadRef
.
current
=
{};
setEditableRecord
(
remoteRecord
?
JSON
.
parse
(
JSON
.
stringify
(
remoteRecord
))
:
null
);
};
const
saveDraft
=
async
()
=>
{
if
(
readonly
||
remoteRecord
?.
metadata
?.
is_locked
===
true
||
!
editableRecord
)
return
;
const
payload
=
pendingPayloadRef
.
current
;
if
(
Object
.
keys
(
payload
).
length
===
0
)
return
;
setSaving
(
true
);
try
{
await updateRecord(recordName, payload);
setRemoteRecord
(
JSON
.
parse
(
JSON
.
stringify
(
editableRecord
))
);
pendingPayloadRef
.
current
=
{};
}
finally
{
setSaving
(
false
)
;
setRemoteRecord(
nextRecord
);
} catch (updateError) {
setEditableRecord(previousRecord);
throw updateError
;
}
};
const handleFieldUpdate = (fieldPath, newValue, fieldDef) =>
handleFieldsUpdate([{ fieldPath, newValue, fieldDef }]);
const isFieldDisabled = (fieldPath, currentView) => {
return (
fieldPath === 'is_locked' ||
...
...
@@ -184,9 +209,7 @@ export function useSubjectRecord(
views,
error,
handleFieldUpdate,
resetDraft
,
saveDraft
,
saving
,
handleFieldsUpdate,
isFieldDisabled,
loading,
};
...
...
src/pages/RecordDetailDrawer/index.jsx
View file @
84dfb853
import
{
Alert
,
Button
,
Modal
,
Surface
,
Tabs
,
Toast
,
toast
}
from
'@heroui/react'
;
import
FullscreenExitOutlined
from
'@ant-design/icons/FullscreenExitOutlined'
;
import
FullscreenOutlined
from
'@ant-design/icons/FullscreenOutlined'
;
import
{
Close
}
from
'@/components/AppIcons'
;
import
{
Alert
,
Button
,
Modal
,
Spinner
,
Tabs
,
Tooltip
}
from
'@heroui/react'
;
import
React
,
{
useCallback
,
useContext
,
forwardRef
,
useEffect
,
useImperativeHandle
,
useMemo
,
useRef
,
useState
,
}
from
'react'
;
import
{
Close
}
from
'@/components/AppIcons'
;
import
{
useSubjectRecord
}
from
'./hooks/useSubjectRecord'
;
import
SubjectViewContent
from
'./components/SubjectViewContent'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
createIntegrityPracticeMaterial
}
from
'@/pages/integrity_practice_management/integrityPracticeMaterialService'
;
import
{
buildViewColumns
,
checkViewCondition
,
isFieldDisabledByView
,
isViewAccessible
,
getFieldValue
,
hasFieldAuthorizationConfiguration
,
setFieldValue
,
shouldHideViewTitleColumn
,
}
from
'@/utils/subjectView'
;
const
EMPTY_ARRAY
=
[];
const
NON_EDITABLE_FIELD_TYPES
=
new
Set
([
'form'
,
'subject'
]);
function
cloneRecord
(
record
)
{
return
record
?
JSON
.
parse
(
JSON
.
stringify
(
record
))
:
null
;
}
function
fieldValuesEqual
(
left
,
right
,
fieldDef
)
{
let
normalizedRight
=
right
;
if
(
fieldDef
?.
type
===
'object'
&&
typeof
right
===
'string'
&&
right
.
trim
())
{
try
{
normalizedRight
=
JSON
.
parse
(
right
);
}
catch
(
error
)
{
return
false
;
}
}
return
JSON
.
stringify
(
left
??
null
)
===
JSON
.
stringify
(
normalizedRight
??
null
);
}
const
RecordDetailDrawer
=
(
props
,
ref
)
=>
{
const
{
open
,
recordName
,
subjectName
,
title
,
minimalContent
=
false
,
onClose
,
onDataChange
,
readonly
=
false
,
...
...
@@ -30,15 +61,24 @@ const RecordDetailDrawer = (props, ref) => {
disabledFields
=
EMPTY_ARRAY
,
authorizationView
=
null
,
deriveFieldValues
,
useAuditMaterialCenter
=
false
,
initialMode
=
'view'
,
}
=
props
;
const
currentUser
=
useContext
(
UserContext
);
const
[
refreshKey
,
setRefreshKey
]
=
useState
(()
=>
Date
.
now
());
const
[
isFullscreen
,
setIsFullscreen
]
=
useState
(
false
);
const
[
activeTabKey
,
setActiveTabKey
]
=
useState
();
const
[
mode
,
setMode
]
=
useState
(
'view'
);
const
[
activeRecordName
,
setActiveRecordName
]
=
useState
(
recordName
);
const
[
activeSubjectName
,
setActiveSubjectName
]
=
useState
(
subjectName
);
const
[
referenceStack
,
setReferenceStack
]
=
useState
([]);
const
[
isEditing
,
setIsEditing
]
=
useState
(
false
);
const
[
isSaving
,
setIsSaving
]
=
useState
(
false
);
const
[
draftRecord
,
setDraftRecord
]
=
useState
(
null
);
const
[
dirtyFields
,
setDirtyFields
]
=
useState
({});
const
[
validationErrors
,
setValidationErrors
]
=
useState
({});
const
[
saveError
,
setSaveError
]
=
useState
(
''
);
const
editSessionRef
=
useRef
(
0
);
const
initialModeAppliedRef
=
useRef
(
''
);
const
pendingLocalMaterialsRef
=
useRef
({});
const
{
editableRecord
,
...
...
@@ -46,35 +86,60 @@ const RecordDetailDrawer = (props, ref) => {
subject
,
views
,
error
,
handleFieldUpdate
,
resetDraft
,
saveDraft
,
saving
,
handleFieldsUpdate
,
isFieldDisabled
,
loading
,
}
=
useSubjectRecord
(
activeR
ecordName
,
r
ecordName
,
refreshKey
,
readonly
,
activeS
ubjectName
,
s
ubjectName
,
deriveFieldValues
,
);
useEffect
(()
=>
{
if
(
open
)
{
setRefreshKey
(
Date
.
now
());
setMode
(
initialMode
===
'edit'
?
'edit'
:
'view'
);
setActiveRecordName
(
recordName
);
setActiveSubjectName
(
subjectName
);
setReferenceStack
([]);
setIsFullscreen
(
false
);
}
setIsEditing
(
false
);
setIsSaving
(
false
);
setDraftRecord
(
null
);
setDirtyFields
({});
setValidationErrors
({});
setSaveError
(
''
);
pendingLocalMaterialsRef
.
current
=
{};
initialModeAppliedRef
.
current
=
''
;
editSessionRef
.
current
+=
1
;
},
[
open
,
recordName
]);
useEffect
(()
=>
{
const
openingKey
=
`
${
recordName
}
:edit`
;
if
(
open
&&
initialMode
===
'edit'
&&
editableRecord
&&
!
readonly
&&
initialModeAppliedRef
.
current
!==
openingKey
)
{
initialModeAppliedRef
.
current
=
openingKey
;
handleStartEditing
();
}
},
[
initialMode
,
open
,
recordName
,
subjectName
]);
// Opening mode is applied once after the record has loaded.
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[
editableRecord
,
open
,
recordName
]);
useEffect
(()
=>
{
if
(
!
isEditing
)
{
setDraftRecord
(
cloneRecord
(
editableRecord
));
}
},
[
editableRecord
,
isEditing
]);
const
detailViews
=
useMemo
(()
=>
{
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
)
{
...
...
@@ -122,12 +187,9 @@ const RecordDetailDrawer = (props, ref) => {
);
const
activeView
=
detailViews
.
find
((
view
)
=>
view
.
name
===
activeTabKey
)
||
detailViews
[
0
];
const
hideRecordTitle
=
shouldHideViewTitleColumn
(
activeView
);
const
isRecordLocked
=
remoteRecord
?.
metadata
?.
is_locked
===
true
;
const
isReferenceDetail
=
referenceStack
.
length
>
0
;
const
effectiveReadonly
=
readonly
||
isRecordLocked
||
isReferenceDetail
;
useEffect
(()
=>
{
if
(
effectiveReadonly
&&
mode
===
'edit'
)
setMode
(
'view'
);
},
[
effectiveReadonly
,
mode
]);
const
effectiveReadonly
=
readonly
||
isRecordLocked
;
const
disabledFieldSet
=
useMemo
(
()
=>
new
Set
(
...
...
@@ -135,98 +197,324 @@ const RecordDetailDrawer = (props, ref) => {
),
[
disabledFields
],
);
const
isDrawerFieldDisabled
=
(
fieldPath
,
currentView
)
=>
const
isDrawerFieldDisabled
=
(
fieldPath
,
currentView
)
=>
{
const
effectiveAuthorizationView
=
hasFieldAuthorizationConfiguration
(
currentView
)
?
currentView
:
authorizationView
||
currentView
;
return
(
disabledFieldSet
.
has
(
fieldPath
)
||
isFieldDisabled
(
fieldPath
,
currentView
)
||
isFieldDisabledByView
(
fieldPath
,
authorizationView
);
const
closeDrawer
=
()
=>
{
resetDraft
();
setMode
(
'view'
);
if
(
referenceStack
.
length
>
0
)
{
const
previous
=
referenceStack
[
referenceStack
.
length
-
1
];
setReferenceStack
((
current
)
=>
current
.
slice
(
0
,
-
1
));
setActiveRecordName
(
previous
.
recordName
);
setActiveSubjectName
(
previous
.
subjectName
);
setRefreshKey
(
Date
.
now
());
return
;
}
isFieldDisabled
(
fieldPath
,
effectiveAuthorizationView
)
);
};
const
hasEditableFields
=
Boolean
(
editableRecord
)
&&
!
loading
&&
!
error
&&
!
effectiveReadonly
&&
!
minimalContent
&&
buildViewColumns
(
activeView
,
subject
).
some
(
(
field
)
=>
!
NON_EDITABLE_FIELD_TYPES
.
has
(
field
.
fieldDef
?.
type
)
&&
!
isDrawerFieldDisabled
(
field
.
field
,
activeView
),
);
const
hasDirtyFields
=
Object
.
keys
(
dirtyFields
).
length
>
0
;
const
hasValidationErrors
=
Object
.
values
(
validationErrors
).
some
(
Boolean
);
const
displayedRecord
=
isEditing
?
draftRecord
||
editableRecord
:
editableRecord
;
const
resetEditSession
=
()
=>
{
editSessionRef
.
current
+=
1
;
setIsEditing
(
false
);
setIsSaving
(
false
);
setDraftRecord
(
cloneRecord
(
editableRecord
));
setDirtyFields
({});
setValidationErrors
({});
setSaveError
(
''
);
pendingLocalMaterialsRef
.
current
=
{};
};
const
handleRequestClose
=
()
=>
{
resetEditSession
();
onClose
?.();
};
const
openReference
=
(
nextReference
)
=>
{
setReferenceStack
((
current
)
=>
[...
current
,
{
recordName
:
activeRecordName
,
subjectName
:
activeSubjectName
}]);
setActiveRecordName
(
nextReference
.
recordName
);
setActiveSubjectName
(
nextReference
.
subjectName
);
setMode
(
'view'
);
setRefreshKey
(
Date
.
now
());
const
handleStartEditing
=
()
=>
{
if
(
!
hasEditableFields
)
return
;
editSessionRef
.
current
+=
1
;
setDraftRecord
(
cloneRecord
(
editableRecord
));
setDirtyFields
({});
setValidationErrors
({});
setSaveError
(
''
);
pendingLocalMaterialsRef
.
current
=
{};
setIsEditing
(
true
);
};
const
cancelEdit
=
()
=>
{
resetDraft
();
setMode
(
'view'
);
const
handleDraftFieldChange
=
(
fieldPath
,
newValue
,
fieldDef
,
editSessionId
)
=>
{
if
(
!
isEditing
||
editSessionId
!==
editSessionRef
.
current
)
return
;
setDraftRecord
((
currentRecord
)
=>
setFieldValue
(
currentRecord
||
editableRecord
,
fieldPath
,
newValue
),
);
setDirtyFields
((
currentFields
)
=>
{
const
nextFields
=
{
...
currentFields
};
const
originalValue
=
getFieldValue
(
editableRecord
,
fieldPath
);
if
(
fieldValuesEqual
(
originalValue
,
newValue
,
fieldDef
))
{
delete
nextFields
[
fieldPath
];
}
else
{
nextFields
[
fieldPath
]
=
{
fieldPath
,
newValue
,
fieldDef
};
}
return
nextFields
;
});
setSaveError
(
''
);
};
const
saveChanges
=
async
()
=>
{
const
handleFieldValidation
=
useCallback
((
fieldPath
,
message
,
editSessionId
)
=>
{
if
(
editSessionId
!==
editSessionRef
.
current
)
return
;
setValidationErrors
((
currentErrors
)
=>
{
if
(
currentErrors
[
fieldPath
]
===
message
)
{
return
currentErrors
;
}
const
nextErrors
=
{
...
currentErrors
};
if
(
message
)
{
nextErrors
[
fieldPath
]
=
message
;
}
else
{
delete
nextErrors
[
fieldPath
];
}
return
nextErrors
;
});
},
[]);
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
;
setIsSaving
(
true
);
setSaveError
(
''
);
try
{
await
saveDraft
();
setMode
(
'view'
);
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
createIntegrityPracticeMaterial
({
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
);
toast
.
success
(
'记录已保存'
,
{
timeout
:
3500
});
}
catch
(
saveError
)
{
toast
.
danger
(
saveError
?.
message
||
'保存失败,请稍后重试'
,
{
timeout
:
4500
});
}
catch
(
saveFailure
)
{
setSaveError
(
saveFailure
?.
message
||
'保存失败,请稍后重试。'
);
}
finally
{
setIsSaving
(
false
);
}
};
const
recordTitle
=
displayedRecord
?.
title
||
displayedRecord
?.
display_name
||
displayedRecord
?.
name
||
recordName
||
'记录'
;
return
(
<
Modal
.
Root
>
<
Modal
.
Trigger
aria
-
hidden=
"true"
className=
"sr-only"
tabIndex=
{
-
1
}
/>
<
Modal
.
Backdrop
isOpen=
{
open
}
variant=
"blur"
onOpenChange=
{
(
isOpen
)
=>
!
isOpen
&&
closeDrawer
()
}
>
<
Modal
.
Container
placement=
"center"
scroll=
"inside"
size=
"lg"
>
<
Modal
.
Dialog
className=
"rc-detail-dialog rc-dialog-panel--4xl"
>
<
Modal
.
CloseTrigger
aria
-
label=
"关闭"
><
Close
size=
{
16
}
/></
Modal
.
CloseTrigger
>
<
Modal
.
Header
>
<
Modal
.
Heading
>
{
mode
===
'edit'
?
'编辑记录'
:
referenceStack
.
length
>
0
?
'关联记录详情'
:
'记录详情'
}
</
Modal
.
Heading
>
<
Modal
.
Backdrop
isOpen=
{
Boolean
(
open
)
}
isDismissable=
{
!
hasDirtyFields
&&
!
isSaving
}
isKeyboardDismissDisabled=
{
hasDirtyFields
||
isSaving
}
onOpenChange=
{
(
isOpen
)
=>
{
if
(
!
isOpen
)
handleRequestClose
();
}
}
variant=
"blur"
>
<
Modal
.
Container
placement=
"center"
scroll=
"inside"
size=
{
isFullscreen
?
'full'
:
'lg'
}
>
<
Modal
.
Dialog
className=
{
isFullscreen
?
undefined
:
'p-7 sm:max-w-[68rem]'
}
>
<
div
className=
"absolute right-7 top-7 z-10 flex items-center gap-2"
>
{
!
minimalContent
?
(
<
Tooltip
>
<
Button
aria
-
label=
{
isFullscreen
?
'退出全屏'
:
'全屏查看'
}
className=
"size-6 min-w-6 p-0"
isIconOnly
size=
"sm"
variant=
"ghost"
onPress=
{
()
=>
setIsFullscreen
((
current
)
=>
!
current
)
}
>
{
isFullscreen
?
(
<
FullscreenExitOutlined
className=
"block"
aria
-
hidden=
"true"
/>
)
:
(
<
FullscreenOutlined
className=
"block"
aria
-
hidden=
"true"
/>
)
}
</
Button
>
<
Tooltip
.
Content
>
{
isFullscreen
?
'退出全屏'
:
'全屏查看'
}
</
Tooltip
.
Content
>
</
Tooltip
>
)
:
null
}
{
!
isEditing
?
(
<
Modal
.
CloseTrigger
className=
"static"
aria
-
label=
"关闭"
>
<
Close
size=
{
16
}
/>
</
Modal
.
CloseTrigger
>
)
:
null
}
</
div
>
<
Modal
.
Header
className=
"pr-16"
>
<
Modal
.
Heading
>
{
title
||
'记录详情'
}
</
Modal
.
Heading
>
{
!
hideRecordTitle
?
(
<
div
className=
"truncate text-sm text-muted"
title=
{
recordTitle
}
>
{
loading
?
'正在读取记录信息'
:
recordTitle
}
</
div
>
)
:
null
}
</
Modal
.
Header
>
<
Modal
.
Body
>
<
Toast
.
Provider
placement=
"top"
/>
{
detailViews
.
length
>
1
?
(
<
div
className=
"max-w-full overflow-x-auto"
>
<
Tabs
className=
"w-fit max-w-full"
selectedKey=
{
activeView
?.
name
}
onSelectionChange=
{
(
key
)
=>
setActiveTabKey
(
String
(
key
))
}
>
<
Tabs
.
ListContainer
className=
"w-fit max-w-full"
>
<
Tabs
.
List
aria
-
label=
"详情视图"
className=
"min-w-max"
>
{
detailViews
.
map
((
view
)
=>
<
Tabs
.
Tab
className=
"flex-none whitespace-nowrap"
id=
{
view
.
name
}
key=
{
view
.
name
}
>
{
view
.
title
||
view
.
name
}
<
Tabs
.
Indicator
/></
Tabs
.
Tab
>)
}
<
Tabs
aria
-
label=
"详情视图"
className=
"w-fit max-w-full min-w-0"
selectedKey=
{
activeView
?.
name
}
variant=
"secondary"
onSelectionChange=
{
setActiveTabKey
}
>
<
Tabs
.
ListContainer
className=
"w-fit max-w-full min-w-0"
>
<
Tabs
.
List
className=
"w-max min-w-max"
>
{
detailViews
.
map
((
view
)
=>
(
<
Tabs
.
Tab
className=
"w-auto min-w-max flex-none whitespace-nowrap"
id=
{
view
.
name
}
key=
{
view
.
name
}
>
{
view
.
title
||
view
.
name
}
<
Tabs
.
Indicator
/>
</
Tabs
.
Tab
>
))
}
</
Tabs
.
List
>
</
Tabs
.
ListContainer
>
</
Tabs
>
</
div
>
)
:
null
}
<
Modal
.
Body
>
{
loading
?
(
<
div
className=
"grid min-h-48 place-items-center text-sm text-muted"
>
正在加载详情...
</
div
>
<
div
className=
"flex min-h-48 items-center justify-center"
role=
"status"
>
<
Spinner
size=
"sm"
/>
</
div
>
)
:
error
?
(
<
Alert
status=
"danger"
><
Alert
.
Content
><
Alert
.
Description
>
{
error
}
</
Alert
.
Description
></
Alert
.
Content
></
Alert
>
)
:
(
<
div
className=
"space-y-4"
>
{
isRecordLocked
?
<
Alert
status=
"warning"
><
Alert
.
Content
><
Alert
.
Description
>
当前问题整改记录已锁定,仅可查看。如需修改,请先在问题清单中解锁。
</
Alert
.
Description
></
Alert
.
Content
></
Alert
>
:
null
}
{
mode
===
'view'
?
(
<
Surface
className=
"rc-detail-surface p-5"
variant=
"secondary"
>
<
SubjectViewContent
view=
{
activeView
}
subject=
{
subject
}
editableRecord=
{
editableRecord
}
onFieldUpdate=
{
handleFieldUpdate
}
mode=
{
mode
}
readonly=
{
effectiveReadonly
}
isFieldDisabled=
{
isDrawerFieldDisabled
}
onOpenReference=
{
openReference
}
/>
</
Surface
>
<
Alert
status=
"danger"
>
<
Alert
.
Content
><
Alert
.
Description
>
{
error
}
</
Alert
.
Description
></
Alert
.
Content
>
</
Alert
>
)
:
(
<
SubjectViewContent
view=
{
activeView
}
subject=
{
subject
}
editableRecord=
{
editableRecord
}
onFieldUpdate=
{
handleFieldUpdate
}
mode=
{
mode
}
readonly=
{
effectiveReadonly
}
isFieldDisabled=
{
isDrawerFieldDisabled
}
onOpenReference=
{
openReference
}
/>
)
}
<
div
>
{
saveError
?
(
<
Alert
className=
"mb-4"
status=
"danger"
>
<
Alert
.
Content
>
<
Alert
.
Description
>
{
saveError
}
</
Alert
.
Description
>
</
Alert
.
Content
>
</
Alert
>
)
:
null
}
{
isRecordLocked
?
(
<
Alert
status=
"warning"
>
<
Alert
.
Content
>
<
Alert
.
Description
>
当前记录已锁定,仅可查看。
</
Alert
.
Description
>
</
Alert
.
Content
>
</
Alert
>
)
:
null
}
<
SubjectViewContent
view=
{
activeView
}
subject=
{
subject
}
editableRecord=
{
displayedRecord
}
editSessionId=
{
editSessionRef
.
current
}
mode=
{
isEditing
?
'edit'
:
'view'
}
onFieldChange=
{
handleDraftFieldChange
}
onFieldValidation=
{
handleFieldValidation
}
onPendingLocalMaterialChange=
{
handlePendingLocalMaterialChange
}
readonly=
{
effectiveReadonly
}
isFieldDisabled=
{
isDrawerFieldDisabled
}
minimal=
{
minimalContent
}
useAuditMaterialCenter=
{
useAuditMaterialCenter
}
/>
</
div
>
)
}
</
Modal
.
Body
>
{
!
minimalContent
&&
(
isEditing
||
hasEditableFields
)
?
(
<
Modal
.
Footer
>
{
mode
===
'edit'
?
(
<><
Button
isDisabled=
{
saving
}
size=
"sm"
variant=
"secondary"
onPress=
{
cancelEdit
}
>
取消
</
Button
><
Button
isPending=
{
saving
}
size=
"sm"
variant=
"primary"
onPress=
{
saveChanges
}
>
{
saving
?
'保存中...'
:
'保存'
}
</
Button
></>
)
:
effectiveReadonly
||
referenceStack
.
length
>
0
?
(
<
Button
size=
"sm"
variant=
"secondary"
onPress=
{
closeDrawer
}
>
{
referenceStack
.
length
>
0
?
'返回上一级'
:
'关闭'
}
</
Button
>
)
:
(
<
Button
size=
"sm"
variant=
"primary"
onPress=
{
()
=>
setMode
(
'edit'
)
}
>
编辑
</
Button
>
)
}
{
isEditing
?
(
<>
<
Button
size=
"sm"
variant=
"secondary"
isDisabled=
{
isSaving
}
onPress=
{
resetEditSession
}
>
取消
</
Button
>
<
Button
isDisabled=
{
!
hasDirtyFields
||
hasValidationErrors
}
isPending=
{
isSaving
}
size=
"sm"
onPress=
{
handleSave
}
>
保存
</
Button
>
</>
)
:
hasEditableFields
?
(
<
Button
size=
"sm"
onPress=
{
handleStartEditing
}
>
编辑
</
Button
>
)
:
null
}
</
Modal
.
Footer
>
)
:
null
}
</
Modal
.
Dialog
>
</
Modal
.
Container
>
</
Modal
.
Backdrop
>
...
...
src/pages/integrity_practice_management/Layout.jsx
View file @
84dfb853
import
AppShell
from
'@/components/AppShell'
;
import
{
Archive
,
CheckSquare
,
FileDone
,
FileText
,
Settings
}
from
'@/components/AppIcons'
;
import
ApplicationSwitcherDialog
from
'@/components/ApplicationSwitcherDialog'
;
import
{
loadUnreadNotificationCount
,
NOTIFICATION_STATE_EVENT
,
}
from
'./notifications/notificationService'
;
import
{
APPLICATION_DISPLAY_NAME
,
ROUTE_TITLES
}
from
'@/config/application'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
...
...
@@ -25,6 +29,7 @@ export default function IntegrityPracticeLayout() {
const
siteInfo
=
useContext
(
SiteContext
);
const
[
sidebarOpen
,
setSidebarOpen
]
=
useState
(
true
);
const
[
switcherOpen
,
setSwitcherOpen
]
=
useState
(
false
);
const
[
unreadNotificationCount
,
setUnreadNotificationCount
]
=
useState
(
0
);
const
pageTitle
=
useMemo
(
()
=>
ROUTE_TITLES
[
location
.
pathname
]
||
APPLICATION_DISPLAY_NAME
,
[
location
.
pathname
],
...
...
@@ -34,10 +39,32 @@ export default function IntegrityPracticeLayout() {
document
.
title
=
`
${
pageTitle
}
-
${
APPLICATION_DISPLAY_NAME
}
`
;
},
[
pageTitle
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
const
refreshUnreadCount
=
async
()
=>
{
try
{
const
count
=
await
loadUnreadNotificationCount
();
if
(
!
cancelled
)
setUnreadNotificationCount
(
count
);
}
catch
{
if
(
!
cancelled
)
setUnreadNotificationCount
(
0
);
}
};
refreshUnreadCount
();
window
.
addEventListener
(
NOTIFICATION_STATE_EVENT
,
refreshUnreadCount
);
const
timer
=
window
.
setInterval
(
refreshUnreadCount
,
60000
);
return
()
=>
{
cancelled
=
true
;
window
.
removeEventListener
(
NOTIFICATION_STATE_EVENT
,
refreshUnreadCount
);
window
.
clearInterval
(
timer
);
};
},
[
location
.
pathname
]);
return
(
<>
<
AppShell
menuGroups=
{
MENU_GROUPS
}
notificationCount=
{
unreadNotificationCount
}
notificationPath=
"/notifications"
onLogout=
{
userInfo
?.
logout
}
onOpenApplicationSwitcher=
{
()
=>
setSwitcherOpen
(
true
)
}
open=
{
sidebarOpen
}
...
...
src/pages/integrity_practice_management/SubjectWorkbench.jsx
View file @
84dfb853
...
...
@@ -94,6 +94,7 @@ export default function SubjectWorkbench() {
()
=>
views
.
find
((
view
)
=>
view
.
name
===
activeViewName
)
||
views
[
0
]
||
null
,
[
activeViewName
,
views
],
);
const
hasAccessibleView
=
views
.
length
>
0
;
const
columns
=
useMemo
(
()
=>
buildSubjectColumns
(
activeView
,
subject
),
[
activeView
,
subject
],
...
...
@@ -274,6 +275,7 @@ export default function SubjectWorkbench() {
<
Toast
.
Provider
placement=
"top"
/>
<
RecordWorkbench
accessDenied=
{
!
loading
&&
!
hasAccessibleView
}
views=
{
views
.
map
((
view
)
=>
({
...
view
}))
}
activeViewName=
{
activeViewName
}
onViewChange=
{
(
viewName
)
=>
{
...
...
@@ -307,17 +309,17 @@ export default function SubjectWorkbench() {
columns=
{
columns
}
records=
{
records
}
selectedIds=
{
selectedIds
}
onSelectionChange=
{
setSelectedIds
}
toolbarActions=
{
toolbarActions
}
selectionActions=
{
selectionActions
}
rowActions=
{
rowActions
}
onSelectionChange=
{
hasAccessibleView
?
setSelectedIds
:
undefined
}
toolbarActions=
{
hasAccessibleView
?
toolbarActions
:
EMPTY_ARRAY
}
selectionActions=
{
hasAccessibleView
?
selectionActions
:
EMPTY_ARRAY
}
rowActions=
{
hasAccessibleView
?
rowActions
:
EMPTY_ARRAY
}
states=
{
{
loading
,
error
,
loadingTitle
:
'正在加载业务数据'
,
loadingDescription
:
'正在读取对象结构、视图配置和记录数据。'
,
}
}
detailPanel=
{
{
detailPanel=
{
hasAccessibleView
?
{
getRecordName
:
(
record
)
=>
record
?.
name
,
onDirtyClose
:
()
=>
setRefreshKey
((
value
)
=>
value
+
1
),
render
:
({
open
,
recordName
,
initialMode
,
onClose
,
onDataChange
})
=>
(
...
...
@@ -329,9 +331,11 @@ export default function SubjectWorkbench() {
onClose=
{
onClose
}
onDataChange=
{
onDataChange
}
filterView=
{
subjectConfig
?.
drawerConfig
?.
filterView
||
EMPTY_ARRAY
}
authorizationView=
{
activeView
}
useAuditMaterialCenter
/>
),
}
}
}
:
null
}
pagination=
{
pagination
?
{
...
...
src/pages/integrity_practice_management/components/IntegrityPracticeMaterialField.jsx
0 → 100644
View file @
84dfb853
import
{
ArrowPathIcon
,
Delete
,
}
from
'@/components/AppIcons'
;
import
FileUploadField
from
'@/components/FileUploadField'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
{
queryAuditMaterials
,
uploadIntegrityPracticeFile
,
}
from
'../integrityPracticeMaterialService'
;
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
IntegrityPracticeMaterialField
({
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 = useMemo(
() => materials.find((material) => material.id === selectedMaterialId),
[materials, selectedMaterialId],
);
const selectedValueText = multiple
? fileValues.length > 0
? `
已选
$
{
fileValues
.
length
}
个附件,可继续添加
`
: '请选择审计材料'
: selectedMaterial?.materialTitle || fileValues[0]?.name || '请选择审计材料';
const handleMaterialSelection = (key) => {
const materialId = key ? String(key) : '';
const material = materials.find((item) => String(item.id) === materialId);
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 uploadIntegrityPracticeFile(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((currentKeys) => new Set(
multiple ? [...currentKeys, ...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((currentKeys) => {
const nextKeys = new Set(currentKeys);
nextKeys.delete(fileKey);
return nextKeys;
});
onPendingLocalMaterialChange?.(
fieldPath,
{ type: 'remove', clientId: fileKey },
editSessionId,
);
if (multiple) {
onChange?.(fileValues.filter((_, index) => index !== fileIndex));
return;
}
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/integrity_practice_management/integrity-practice-management.business-flow.md
View file @
84dfb853
...
...
@@ -21,21 +21,23 @@
## 2. 列表与视图流程
1.
进入页面后并行调用
`GET /subject/:name`
和
`GET /subject/:name/view`
。
2.
只展示当前用户有权访问的
`grid`
视图;配置包含
`filterView`
且非空时,只展示指定视图。
1.
进入页面后并行调用
`GET /subject/:name`
和
`GET /subject/:name/view`
;打开详情后再按 View 名调用
`GET /subject/:subjectName/view/:viewName`
获取完整 Detail 配置
。
2.
只展示当前用户有权访问的
`grid`
视图;
授权只读取
`user.applications[]`
中
`name = integrity_practice_management`
的项目。系统管理员、系统账号和该应用“管理”角色可旁路,但不能旁路
`disableColumns`
。
配置包含
`filterView`
且非空时,只展示指定视图。
3.
切换视图后,根据视图的
`settings.restrictions`
、
`settings.filters.conditions`
、列排序配置调用
`POST /record/query`
。
4.
`gridRecordConfig.pagination = false`
时单次最多读取 1000 条;其余页面使用服务端分页。
5.
关键词查找通过
`/record/query`
的
`keyword`
查询标题;清空筛选后回到第 1 页。
6.
列定义、列顺序、初始排序、固定列和字段显示格式由 Subject View 与 Subject 字段定义共同决定;HeroUI Pro DataGrid 仅在真实横向溢出时固定首列和操作列,并允许当前页原生列排序和列宽调整,不改变服务端分页口径。
`settings.shortTitle === true`
时只隐藏 title 展示列,标题仍参与查询、记录身份和保存;false 或未配置时正常展示。
7.
DataGrid 使用原生 Checkbox 选择;选中后显示 HeroUI Pro ActionBar。行操作 1-3 项直接展示,4 项以上展示前两项与“更多”菜单,删除等危险动作始终排在最后。
8.
点击列表操作列的预览图标打开 HeroUI Modal 并进入只读查看态;首个业务列只展示记录文本,不再承担预览交互。点击弹窗统一的“编辑”按钮或列表操作列铅笔图标进入整体编辑态,保存后返回查看态。字段权限、锁定规则和 Subject 字段类型保持不变。
9.
编辑态远程引用字段通过 HeroUI 弹层搜索选择。多值字段在触发器中显示已选数量,可在弹层内连续选择、取消或清空;单值字段选中后关闭弹层。候选标题过长时单行省略但保留全文提示,写入值仍严格为
`_id`
或
`_id[]`
,不改变保存时机和
`metadata`
协议。
9.
编辑态的单选和多选属性使用 HeroUI Select/Autocomplete,日期使用 DatePicker,附件使用 HeroUI Pro DropZone;选择后立即更新当前弹窗草稿,最终仍由弹窗底部统一“保存”提交。
9.
当前 Detail 有有效
`settings.auth`
或
`settings.disableColumns`
时,字段权限整套使用 Detail;两项都未配置或没有实际 Detail 时,整体回退打开详情的当前 Grid。两套权限不合并、不逐字段回退;
`disableColumns`
对管理员也始终只读,普通用户在选定权限来源中缺少字段
`auth`
时只读。切换 Detail 后立即重算字段状态和编辑入口,编辑入口只检查当前 Detail 展示字段。Grid
`viewAuth`
只控制 Tab 可见性,不隐藏详情字段。
10.
编辑态远程引用字段通过 HeroUI 弹层搜索选择。多值字段在触发器中显示已选数量,可在弹层内连续选择、取消或清空;单值字段选中后关闭弹层。候选标题过长时单行省略但保留全文提示,写入值仍严格为
`_id`
或
`_id[]`
,不改变保存时机和
`metadata`
协议。
11.
编辑态的单选和多选属性使用 HeroUI Select/Autocomplete,日期使用 DatePicker,附件使用 HeroUI Pro DropZone;选择后立即更新当前弹窗草稿,最终仍由弹窗底部统一“保存”提交。
异常处理:
-
Subject、视图或记录加载失败时,使用 HeroUI Alert/EmptyState 显示错误或空态,不使用猜测字段兜底。
-
后端没有可用
`grid`
视图时,使用“全部”临时视图并按 Subject 字段生成列
。
-
没有可访问
`grid`
视图时不创建兜底 View、不查询记录,并隐藏新增、批量和行操作;只有一个可访问 View 时隐藏 Tabs 轨道
。
-
配置动作执行期间禁用重复点击;成功后使用顶部 Toast,失败时保留当前列表并显示服务端错误。
## 3. 记录选择与动作规则
...
...
@@ -127,3 +129,17 @@
-
`POST /record/batch/remove`
所有业务字段写入
`metadata`
;引用字段、人员字段和归档关联字段只保存
`_id`
或
`_id[]`
。日期由产生该状态的动作统一写入 ISO 8601 或历史协议明确要求的年份字符串。
## 9. 通知中心
-
Navbar Bell 进入
`/notifications`
,Sidebar 不增加通知项;未读数来自
`GET /notice/customer/count`
,通知变更事件和 60 秒轮询刷新。
-
通知页按消息/待办、全部/未读、300ms 关键词、每页 10 条查询
`POST /notice/customer/query`
;未读状态传服务端
`status = unread`
。
-
单条和全部已读调用
`PUT /notice/customer/read`
;待办动态 action 保留服务端 action/method 名并只提交
`{notice_id}`
。
-
正文只按纯文本渲染。仅去除本应用
`/integrity_practice_management`
base 后站内跳转;其它应用和外部链接新窗口打开。
## 10. 文件与审计材料中心
-
所有运行时可编辑
`file`
字段按
`multiple`
提供“材料中心 / 本地上传”。材料中心查询
`am_material`
最近 20 条并远程搜索材料标题、文件名。
-
选择材料只保存
`{name,type,size,url}`
快照;单值替换,多值追加并按 URL 去重。清空附件不删除材料主档。
-
本地文件先调用
`/upload`
写入详情草稿;取消编辑不建档。点击保存后,对仍保留的本地文件逐个创建
`am_material`
,
`source_app = integrity_practice_management`
,全部成功后才更新业务 Record。
-
同一编辑会话记录已建档文件;材料部分成功或业务更新失败后重试只处理未建档项,避免重复材料。
src/pages/integrity_practice_management/integrityPracticeMaterialService.js
0 → 100644
View file @
84dfb853
import
dayjs
from
'dayjs'
;
import
{
createRecord
,
queryRecords
,
uploadFile
}
from
'@/services/DataService'
;
export
const
AUDIT_MATERIAL_SUBJECT
=
'am_material'
;
export
const
INTEGRITY_PRACTICE_SOURCE_APP
=
'integrity_practice_management'
;
const
MATERIAL_PAGE_SIZE
=
20
;
function
extractRecords
(
response
)
{
const
candidates
=
[
response
,
response
?.
data
,
response
?.
data
?.
list
,
response
?.
data
?.
records
,
response
?.
list
,
response
?.
records
,
response
?.
items
,
response
?.
result
,
response
?.
result
?.
records
,
];
return
candidates
.
find
((
candidate
)
=>
Array
.
isArray
(
candidate
))
||
[];
}
function
escapeRegExp
(
value
=
''
)
{
return
String
(
value
).
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
'
\\
$&'
);
}
function
inferMaterialTitle
(
fileName
=
''
)
{
return
String
(
fileName
||
''
).
replace
(
/
\.[^
.
]
+$/
,
''
).
trim
()
||
'未命名材料'
;
}
function
normalizeFileAsset
(
uploadResult
,
file
)
{
const
candidates
=
[
uploadResult
?.
file
,
uploadResult
?.
data
?.
file
,
uploadResult
?.
data
,
uploadResult
?.
result
?.
file
,
uploadResult
?.
result
,
uploadResult
,
];
for
(
const
candidate
of
candidates
)
{
if
(
!
candidate
||
typeof
candidate
!==
'object'
)
continue
;
const
url
=
candidate
.
url
||
candidate
.
path
||
candidate
.
location
||
candidate
?.
data
?.
url
||
''
;
if
(
!
url
)
continue
;
return
{
name
:
candidate
.
name
||
candidate
.
filename
||
file
?.
name
||
'未命名文件'
,
type
:
candidate
.
type
||
candidate
.
mimetype
||
file
?.
type
||
'application/octet-stream'
,
size
:
Number
(
candidate
.
size
||
file
?.
size
||
0
),
url
,
};
}
throw
new
Error
(
'文件上传成功,但未获取到可用的文件地址。'
);
}
function
normalizeMaterial
(
record
=
{})
{
const
metadata
=
record
?.
metadata
||
{};
const
fileAsset
=
metadata
.
file_asset
||
{};
return
{
id
:
record
.
_id
||
record
.
id
||
record
.
name
||
''
,
materialTitle
:
metadata
.
material_title
||
record
.
title
||
fileAsset
.
name
||
'未命名材料'
,
fileAsset
,
archiveCategory
:
metadata
.
archive_category
||
''
,
archiveYear
:
metadata
.
archive_year
||
''
,
sourceApp
:
metadata
.
source_app
||
''
,
uploadedAt
:
metadata
.
uploaded_at
||
record
.
created_at
||
''
,
};
}
export
async
function
queryAuditMaterials
(
keyword
=
''
)
{
const
normalizedKeyword
=
String
(
keyword
||
''
).
trim
();
const
escapedKeyword
=
escapeRegExp
(
normalizedKeyword
);
const
filter
=
escapedKeyword
?
{
$or
:
[
{
'metadata.material_title'
:
{
$regex
:
escapedKeyword
,
$options
:
'i'
,
},
},
{
'metadata.file_asset.name'
:
{
$regex
:
escapedKeyword
,
$options
:
'i'
,
},
},
],
}
:
{};
const
response
=
await
queryRecords
({
subject
:
AUDIT_MATERIAL_SUBJECT
,
page
:
0
,
page_size
:
MATERIAL_PAGE_SIZE
,
filter
,
sort
:
'-updated_at -created_at'
,
mode
:
'full'
,
});
return
extractRecords
(
response
)
.
map
(
normalizeMaterial
)
.
filter
((
material
)
=>
material
.
id
&&
material
.
fileAsset
?.
url
);
}
export
async
function
uploadIntegrityPracticeFile
(
file
)
{
if
(
!
file
)
{
throw
new
Error
(
'请选择要上传的本地文件。'
);
}
const
uploadResult
=
await
uploadFile
(
file
);
return
normalizeFileAsset
(
uploadResult
,
file
);
}
export
async
function
createIntegrityPracticeMaterial
({
fileAsset
,
currentUser
})
{
if
(
!
fileAsset
?.
url
)
{
throw
new
Error
(
'缺少有效的文件地址,无法写入审计材料中心。'
);
}
const
uploaderId
=
currentUser
?.
_id
||
currentUser
?.
id
||
''
;
if
(
!
uploaderId
)
{
throw
new
Error
(
'未获取到当前登录用户,无法写入审计材料中心。'
);
}
const
materialTitle
=
inferMaterialTitle
(
fileAsset
.
name
);
const
response
=
await
createRecord
({
subject
:
AUDIT_MATERIAL_SUBJECT
,
title
:
materialTitle
,
content_type
:
'text'
,
metadata
:
{
material_title
:
materialTitle
,
file_asset
:
{
name
:
fileAsset
.
name
,
type
:
fileAsset
.
type
||
'application/octet-stream'
,
size
:
Number
(
fileAsset
.
size
||
0
),
url
:
fileAsset
.
url
,
},
source_app
:
INTEGRITY_PRACTICE_SOURCE_APP
,
uploader
:
uploaderId
,
uploaded_at
:
dayjs
().
toISOString
(),
},
});
const
createdRecord
=
response
?.
data
||
response
?.
result
||
response
;
return
{
id
:
createdRecord
?.
_id
||
createdRecord
?.
id
||
createdRecord
?.
name
||
true
,
record
:
createdRecord
,
};
}
src/pages/integrity_practice_management/integrityPracticeWorkbenchService.js
View file @
84dfb853
...
...
@@ -81,10 +81,7 @@ export async function loadSubjectResources(subjectName, config = {}) {
return
{
subject
,
views
:
availableViews
.
length
>
0
?
availableViews
:
[{
name
:
'all'
,
title
:
'全部'
,
type
:
'grid'
,
settings
:
{}
}],
views
:
availableViews
,
};
}
...
...
src/pages/integrity_practice_management/notifications/NotificationListPage.jsx
0 → 100644
View file @
84dfb853
import
{
Bell
,
Close
,
MagnifyingGlassIcon
,
OpenExternal
}
from
'@/components/AppIcons'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Alert
,
Avatar
,
Button
,
Card
,
Chip
,
SearchField
,
Spinner
,
Pagination
,
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
NotificationPagination
({
isDisabled
,
page
,
pageSize
,
total
,
onPageChange
})
{
const
totalPages
=
Math
.
max
(
1
,
Math
.
ceil
(
total
/
pageSize
));
const
pageWindowStart
=
Math
.
max
(
0
,
Math
.
min
(
page
-
2
,
totalPages
-
5
));
const
pageWindowEnd
=
total
?
Math
.
min
(
totalPages
,
pageWindowStart
+
5
)
:
pageWindowStart
;
return
(
<
Pagination
aria
-
label=
"通知分页"
size=
"sm"
>
<
Pagination
.
Summary
>
共
{
total
}
条通知,第
{
page
+
1
}
/
{
totalPages
}
页
</
Pagination
.
Summary
>
<
Pagination
.
Content
>
<
Pagination
.
Item
>
<
Pagination
.
Previous
isDisabled=
{
isDisabled
||
page
<=
0
}
onPress=
{
()
=>
onPageChange
(
page
-
1
)
}
>
<
Pagination
.
PreviousIcon
/><
span
>
上一页
</
span
>
</
Pagination
.
Previous
>
</
Pagination
.
Item
>
{
Array
.
from
({
length
:
pageWindowEnd
-
pageWindowStart
},
(
_
,
index
)
=>
{
const
pageIndex
=
pageWindowStart
+
index
;
return
(
<
Pagination
.
Item
key=
{
pageIndex
}
>
<
Pagination
.
Link
isActive=
{
pageIndex
===
page
}
isDisabled=
{
isDisabled
}
onPress=
{
()
=>
onPageChange
(
pageIndex
)
}
>
{
pageIndex
+
1
}
</
Pagination
.
Link
>
</
Pagination
.
Item
>
);
})
}
<
Pagination
.
Item
>
<
Pagination
.
Next
isDisabled=
{
isDisabled
||
page
+
1
>=
totalPages
}
onPress=
{
()
=>
onPageChange
(
page
+
1
)
}
>
<
span
>
下一页
</
span
><
Pagination
.
NextIcon
/>
</
Pagination
.
Next
>
</
Pagination
.
Item
>
</
Pagination
.
Content
>
</
Pagination
>
);
}
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
:
[];
const
avatarText
=
sender
.
trim
().
slice
(
0
,
1
).
toUpperCase
()
||
'系'
;
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
>
{
avatarText
}
</
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
(
async
()
=>
{
await
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
className=
"w-fit max-w-full min-w-0"
selectedKey=
{
category
}
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
}
isLoading=
{
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
}
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
?
(
<
NotificationPagination
isDisabled=
{
loading
}
page=
{
page
}
pageSize=
{
NOTIFICATION_PAGE_SIZE
}
total=
{
total
}
onPageChange=
{
setPage
}
/>
)
:
null
}
</
div
>
);
}
src/pages/integrity_practice_management/notifications/notificationService.js
0 → 100644
View file @
84dfb853
import
{
exacteActionFunction
,
queryUserNotice
,
readUserNotice
,
unReadNoticeCount
,
}
from
'@/services/DataService'
;
export
const
NOTIFICATION_PAGE_SIZE
=
10
;
export
const
NOTIFICATION_STATE_EVENT
=
'integrity-practice-management:notification-state'
;
function
toArray
(
value
)
{
return
Array
.
isArray
(
value
)
?
value
:
[];
}
function
toId
(
value
)
{
if
(
value
&&
typeof
value
===
'object'
)
{
return
String
(
value
.
_id
||
value
.
id
||
value
.
name
||
''
);
}
return
String
(
value
||
''
);
}
function
escapeRegExp
(
value
)
{
return
String
(
value
||
''
).
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
'
\\
$&'
);
}
export
async
function
loadNotificationPage
({
page
=
0
,
pageSize
=
NOTIFICATION_PAGE_SIZE
,
category
=
'message'
,
readStatus
=
'all'
,
keyword
=
''
,
}
=
{})
{
const
filter
=
{
category
};
const
normalizedKeyword
=
String
(
keyword
||
''
).
trim
();
if
(
normalizedKeyword
)
{
filter
.
content
=
{
$regex
:
escapeRegExp
(
normalizedKeyword
),
$options
:
'i'
,
};
}
const
response
=
await
queryUserNotice
(
page
,
pageSize
,
filter
,
readStatus
===
'unread'
?
'unread'
:
null
,
);
return
{
list
:
toArray
(
response
?.
list
),
page
:
Number
(
response
?.
current_page
??
page
)
||
0
,
pageSize
:
Number
(
response
?.
page_size
??
pageSize
)
||
pageSize
,
total
:
Number
(
response
?.
total_notices
??
response
?.
total
??
0
)
||
0
,
totalPages
:
Number
(
response
?.
total_pages
??
0
)
||
0
,
};
}
export
async
function
loadUnreadNotificationCount
()
{
const
response
=
await
unReadNoticeCount
();
return
Math
.
max
(
0
,
Number
(
response
?.
total_notices
??
response
?.
count
??
response
?.
total
??
response
??
0
,
)
||
0
,
);
}
export
async
function
markNotificationRead
(
name
)
{
if
(
!
name
)
{
throw
new
Error
(
'通知缺少已读标识'
);
}
await
readUserNotice
(
name
);
notifyNotificationStateChanged
();
}
export
async
function
markAllNotificationsRead
()
{
await
readUserNotice
();
notifyNotificationStateChanged
();
}
export
async
function
executeNotificationAction
(
notification
,
action
)
{
const
actionName
=
typeof
action
?.
action
===
'string'
?
action
.
action
:
action
?.
action
?.
name
||
action
?.
name
;
const
methodName
=
action
?.
method_name
||
action
?.
methodName
;
if
(
!
actionName
||
!
methodName
)
{
throw
new
Error
(
'该待办操作缺少执行配置'
);
}
const
result
=
await
exacteActionFunction
(
actionName
,
methodName
,
{
notice_id
:
notification
?.
_id
,
});
notifyNotificationStateChanged
();
return
result
;
}
export
function
isNotificationRead
(
notification
,
userId
)
{
const
normalizedUserId
=
toId
(
userId
);
if
(
!
normalizedUserId
)
return
false
;
return
toArray
(
notification
?.
data
?.
logs
).
some
(
(
log
)
=>
toId
(
log
?.
user_id
)
===
normalizedUserId
,
);
}
export
function
getNotificationSender
(
notification
)
{
return
(
notification
?.
from
?.
display_name
||
notification
?.
from
?.
username
||
notification
?.
created_by
?.
display_name
||
notification
?.
created_by
?.
username
||
'系统'
);
}
export
function
getNotificationContent
(
notification
)
{
return
String
(
notification
?.
content
||
notification
?.
content_compile
||
''
).
trim
();
}
function
trimTarget
(
target
)
{
return
String
(
target
||
''
).
replace
(
/
[
),.;!?,。;!?)
]
+$/
u
,
''
);
}
export
function
extractNotificationTarget
(
notification
)
{
const
configuredTarget
=
notification
?.
settings
?.
url
||
notification
?.
settings
?.
link
||
notification
?.
data
?.
url
||
notification
?.
data
?.
link
;
if
(
configuredTarget
)
return
trimTarget
(
configuredTarget
);
const
content
=
getNotificationContent
(
notification
);
const
match
=
content
.
match
(
/https
?
:
\/\/[^\s
<>"'
]
+|
\/
integrity_practice_management
\/[^\s
<>"'
]
+/
,
);
return
trimTarget
(
match
?.[
0
]);
}
export
function
resolveNotificationTarget
(
target
)
{
const
normalizedTarget
=
trimTarget
(
target
);
if
(
!
normalizedTarget
)
return
null
;
if
(
/^https
?
:
\/\/
/i
.
test
(
normalizedTarget
))
{
try
{
const
url
=
new
URL
(
normalizedTarget
);
if
(
url
.
pathname
.
startsWith
(
'/integrity_practice_management/'
))
{
return
resolveNotificationTarget
(
`
${
url
.
pathname
}${
url
.
search
}${
url
.
hash
}
`
);
}
return
{
href
:
url
.
toString
(),
external
:
true
};
}
catch
(
error
)
{
return
null
;
}
}
const
appPrefix
=
'/integrity_practice_management'
;
if
(
!
normalizedTarget
.
startsWith
(
`
${
appPrefix
}
/`
))
{
return
normalizedTarget
.
startsWith
(
'/'
)
?
{
href
:
normalizedTarget
,
external
:
true
}
:
null
;
}
return
{
href
:
normalizedTarget
.
slice
(
appPrefix
.
length
),
external
:
false
,
};
}
export
function
notifyNotificationStateChanged
()
{
if
(
typeof
window
!==
'undefined'
)
{
window
.
dispatchEvent
(
new
CustomEvent
(
NOTIFICATION_STATE_EVENT
));
}
}
src/utils/subjectView.js
View file @
84dfb853
import
dayjs
from
'dayjs'
;
import
{
AUTHORIZATION_APPLICATION_NAME
}
from
'@/config/application'
;
export
function
normalizeText
(
value
)
{
return
String
(
value
??
''
).
trim
();
...
...
@@ -57,62 +58,74 @@ export function getCurrentUserFromStorage() {
}
}
function
collectUserGroups
(
user
)
{
const
directGroups
=
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
);
const
appGroups
=
toArray
(
user
?.
applications
).
flatMap
((
app
)
=>
app
?.
groups
||
[]);
return
[...
directGroups
,
...
appGroups
].
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
));
}
export
function
hasAuthAccess
(
authRule
,
user
=
getCurrentUserFromStorage
())
{
if
(
!
authRule
)
{
return
true
;
}
export
function
isSystemAdministrator
(
user
=
getCurrentUserFromStorage
())
{
return
user
?.
type
===
'admin'
||
user
?.
type
===
'system'
;
}
export
function
hasApplicationManagementAccess
(
user
=
getCurrentUserFromStorage
(),
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
if
(
isSystemAdministrator
(
user
))
return
true
;
return
intersects
(
collectApplicationAuthorization
(
user
,
applicationName
).
roles
,
[
'管理'
]);
}
export
function
hasAuthAccess
(
authRule
,
user
=
getCurrentUserFromStorage
(),
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
if
(
!
user
)
{
return
false
;
}
if
(
user
.
type
===
'admin'
||
user
.
type
===
'system'
)
{
if
(
hasApplicationManagementAccess
(
user
,
applicationName
)
)
{
return
true
;
}
if
(
authRule
.
type
===
'group'
)
{
return
intersects
(
collectUserGroups
(
user
),
authRule
.
groups
);
const
{
groups
,
roles
,
permissions
}
=
collectApplicationAuthorization
(
user
,
applicationName
);
if
(
typeof
authRule
===
'string'
)
{
return
intersects
(
roles
,
[
authRule
]);
}
if
(
authRule
.
type
===
'role'
)
{
return
intersects
(
collectUserRoles
(
user
),
authRule
.
roles
);
if
(
authRule
?.
type
===
'group'
)
{
return
intersects
(
groups
,
authRule
.
groups
);
}
if
(
authRule
.
type
===
'permission'
)
{
return
intersects
(
collectUserPermissions
(
user
),
authRule
.
permissions
);
if
(
authRule
?.
type
===
'role'
)
{
return
intersects
(
roles
,
authRule
.
roles
);
}
if
(
authRule
.
type
===
'username'
)
{
return
intersects
([
user
.
username
,
user
.
display_name
,
user
.
_id
],
authRule
.
usernames
);
if
(
authRule
?.
type
===
'permission'
)
{
return
intersects
(
permissions
,
authRule
.
permissions
);
}
return
intersects
(
collectUserRoles
(
user
),
[
'管理'
]);
return
false
;
}
export
function
getFieldValue
(
record
,
fieldPath
)
{
...
...
@@ -262,12 +275,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
fieldDef
=
getFieldDefinition
(
column
.
field
,
subject
);
const
label
=
getFieldLabel
(
column
.
field
,
fieldDef
,
column
.
label
);
...
...
@@ -285,12 +304,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
}
`
;
...
...
@@ -363,18 +384,44 @@ 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
,
)
{
const
viewAuth
=
view
?.
settings
?.
viewAuth
;
const
rule
=
[
'group'
,
'role'
,
'permission'
].
includes
(
viewAuth
?.
type
)
?
viewAuth
:
'管理'
;
return
hasAuthAccess
(
rule
,
user
,
applicationName
);
}
export
function
hasFieldAuthorizationConfiguration
(
view
)
{
const
settings
=
view
?.
settings
;
return
(
toArray
(
settings
?.
auth
).
some
((
rule
)
=>
normalizeText
(
rule
?.
field
))
||
toArray
(
settings
?.
disableColumns
).
some
((
column
)
=>
normalizeText
(
column
?.
field
))
);
}
export
function
isFieldDisabledByView
(
fieldPath
,
view
,
user
=
getCurrentUserFromStorage
())
{
export
function
isFieldDisabledByView
(
fieldPath
,
view
,
user
=
getCurrentUserFromStorage
(),
applicationName
=
AUTHORIZATION_APPLICATION_NAME
,
)
{
const
disabledColumn
=
toArray
(
view
?.
settings
?.
disableColumns
).
some
((
column
)
=>
column
.
field
===
fieldPath
);
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
;
const
rule
=
[
'group'
,
'role'
,
'permission'
].
includes
(
fieldAuthRule
?.
type
)
?
fieldAuthRule
:
'管理'
;
return
!
hasAuthAccess
(
rule
,
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