Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
O
oa_process_monitor
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
oa_process_monitor
Commits
4c7cbb86
Commit
4c7cbb86
authored
Oct 08, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: unify operation feedback and improve failure recovery
parent
cd0dd754
Show whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
274 additions
and
79 deletions
+274
-79
xinyuan-design-system.md
docs/xinyuan-design-system.md
+8
-0
manifest.json
public/manifest.json
+1
-1
app.js
src/app.js
+6
-1
AppFeedback.jsx
src/components/AppFeedback.jsx
+44
-0
AppShell.jsx
src/components/AppShell.jsx
+0
-2
ApplicationSwitcherDialog.jsx
src/components/ApplicationSwitcherDialog.jsx
+4
-11
ProcessInstanceDetailContent.jsx
src/components/ProcessInstanceDetailContent.jsx
+9
-3
feedback.css
src/design-system/feedback.css
+11
-0
useFeedbackState.js
src/hooks/useFeedbackState.js
+38
-0
Login.jsx
src/pages/Login.jsx
+3
-10
NotificationListPage.jsx
src/pages/notifications/NotificationListPage.jsx
+15
-14
ProcessDetail.jsx
src/pages/process_monitor/ProcessDetail.jsx
+26
-6
ProcessMonitor.jsx
src/pages/process_monitor/ProcessMonitor.jsx
+26
-7
ProcessTemplateList.jsx
src/pages/process_monitor/ProcessTemplateList.jsx
+66
-22
process-monitor.business-flow.md
src/pages/process_monitor/process-monitor.business-flow.md
+13
-0
tailwind.css
src/tailwind.css
+1
-0
Site.jsx
src/wrapper/Site.jsx
+3
-2
No files found.
docs/xinyuan-design-system.md
View file @
4c7cbb86
...
@@ -75,6 +75,14 @@
...
@@ -75,6 +75,14 @@
-
DataGrid 使用原生 selection、排序、固定列、横向滚动、ActionBar、EmptyState 和 compound Pagination。
-
DataGrid 使用原生 selection、排序、固定列、横向滚动、ActionBar、EmptyState 和 compound Pagination。
-
流程业务列表默认每页 20 条,分页条使用宽度 128px 的 HeroUI Autocomplete 提供 20/50/100 条选择;通知中心仍固定每页 10 条。
-
流程业务列表默认每页 20 条,分页条使用宽度 128px 的 HeroUI Autocomplete 提供 20/50/100 条选择;通知中心仍固定每页 10 条。
## 操作反馈浮层
-
参考问题整改
`513706c`
的全局反馈及
`bfc5862`
的关闭按钮与保存成功反馈,接口成功、失败和降级警告统一由根部
`AppFeedback`
的 HeroUI
`Toast.Provider`
展示;登录页、流程监控页、通知页和应用切换弹窗共用一个 Provider,页面不再渲染接口反馈横条。
-
成功提示默认 4 秒、错误和警告默认 8 秒后自动消失。关闭按钮直接组合 HeroUI
`Button`
(
`ghost`
、
`sm`
、
`isIconOnly`
),通过官方
`slot="close"`
接入 Toast 关闭行为,使用
`AppIcons.Close`
,位于内容右侧并随正文参与布局;前排图标始终可见,后排堆叠提示隐藏关闭按钮,Hover 和焦点样式沿用官方主题。关闭提示不清除请求失败状态、表单草稿或弹窗。
-
`src/design-system/feedback.css`
只约定 Toast 层级、顶部安全区与后排关闭按钮的可见性;层级 300 高于业务 Modal 和移动侧栏。不再放大带边框的默认关闭按钮,也不设置额外绝对位置或正文右侧占位;长文滚动和换行由 compound 内容组件承担,颜色、边框、圆角、阴影和 Hover 沿用 HeroUI 主题。
-
“编辑模板监控配置”和“定义显示字段”弹窗仅在
`updateRecord`
成功后关闭并显示保存成功 Toast;重新打开编辑、再次保存或离开页面时清理该来源提示,模板实例页切换模板或重新加载时也清理提示。保存失败保留弹窗和草稿。
-
加载失败使用 HeroUI Pro EmptyState 和重新加载按钮;错误详情只出现在 Toast 中,提示关闭后仍可重试。同样的错误在每次重试失败时重新提示。
## 主题与资产
## 主题与资产
-
当前固定浅色内容画布与深藏蓝侧栏直接使用
`xy-logo-red.png`
,不加底色、不滤镜、不反色、不替换 RSM 标识。
-
当前固定浅色内容画布与深藏蓝侧栏直接使用
`xy-logo-red.png`
,不加底色、不滤镜、不反色、不替换 RSM 标识。
...
...
public/manifest.json
View file @
4c7cbb86
{
{
"name"
:
"oa_process_monitor"
,
"name"
:
"oa_process_monitor"
,
"version"
:
"1.0.
4
"
,
"version"
:
"1.0.
5
"
,
"build"
:
"abc"
,
"build"
:
"abc"
,
"description"
:
"oa 流程监控"
,
"description"
:
"oa 流程监控"
,
"date"
:
"2025-07-14 19:27:20"
,
"date"
:
"2025-07-14 19:27:20"
,
...
...
src/app.js
View file @
4c7cbb86
import
'@/global.less'
;
import
'@/global.less'
;
import
'@/tailwind.css'
;
import
'@/tailwind.css'
;
import
{
I18nProvider
}
from
'@heroui/react'
;
import
{
I18nProvider
}
from
'@heroui/react'
;
import
AppFeedback
from
'@/components/AppFeedback'
;
import
{
createElement
}
from
'react'
;
import
{
createElement
}
from
'react'
;
import
{
defineApp
}
from
'umi'
;
import
{
defineApp
}
from
'umi'
;
...
@@ -12,6 +13,10 @@ export async function getInitialState() {
...
@@ -12,6 +13,10 @@ export async function getInitialState() {
export
default
defineApp
({
export
default
defineApp
({
rootContainer
(
container
)
{
rootContainer
(
container
)
{
return
createElement
(
I18nProvider
,
{
locale
:
'zh-CN'
},
container
);
return
createElement
(
I18nProvider
,
{
locale
:
'zh-CN'
},
createElement
(
AppFeedback
,
null
,
container
),
);
},
},
});
});
src/components/AppFeedback.jsx
0 → 100644
View file @
4c7cbb86
import
{
Button
,
Toast
}
from
'@heroui/react'
;
import
{
Close
}
from
'@/components/AppIcons'
;
export
default
function
AppFeedback
({
children
})
{
return
(
<>
{
children
}
<
Toast
.
Provider
aria
-
label=
"操作提示"
className=
"app-feedback-region"
maxVisibleToasts=
{
3
}
placement=
"top"
width=
"min(30rem, calc(100vw - 2rem))"
>
{
({
toast
})
=>
(
<
Toast
className=
"app-feedback-toast gap-2"
toast=
{
toast
}
variant=
{
toast
.
content
.
variant
}
>
<
Toast
.
Indicator
variant=
{
toast
.
content
.
variant
}
/>
<
Toast
.
Content
className=
"min-w-0"
>
<
Toast
.
Title
className=
"max-h-[40dvh] w-full overflow-y-auto whitespace-pre-wrap [overflow-wrap:anywhere]"
>
{
toast
.
content
.
title
}
</
Toast
.
Title
>
{
toast
.
content
.
description
?
(
<
Toast
.
Description
className=
"max-h-[40dvh] w-full overflow-y-auto whitespace-pre-wrap text-foreground [overflow-wrap:anywhere]"
>
{
toast
.
content
.
description
}
</
Toast
.
Description
>
)
:
null
}
{
toast
.
content
.
actionProps
?
<
Toast
.
ActionButton
{
...
toast
.
content
.
actionProps
}
/>
:
null
}
</
Toast
.
Content
>
<
Button
aria
-
label=
"关闭提示"
className=
"app-feedback-close shrink-0 self-start"
isIconOnly
size=
"sm"
slot=
"close"
variant=
"ghost"
>
<
Close
size=
{
16
}
/>
</
Button
>
</
Toast
>
)
}
</
Toast
.
Provider
>
</>
);
}
src/components/AppShell.jsx
View file @
4c7cbb86
import
{
Sidebar
}
from
'@heroui-pro/react/sidebar'
;
import
{
Sidebar
}
from
'@heroui-pro/react/sidebar'
;
import
{
Toast
}
from
'@heroui/react'
;
import
{
history
}
from
'umi'
;
import
{
history
}
from
'umi'
;
import
DashboardNavbar
from
'./DashboardNavbar'
;
import
DashboardNavbar
from
'./DashboardNavbar'
;
import
DashboardSidebar
from
'./DashboardSidebar'
;
import
DashboardSidebar
from
'./DashboardSidebar'
;
...
@@ -36,7 +35,6 @@ export default function AppShell({
...
@@ -36,7 +35,6 @@ export default function AppShell({
<
div
className=
"rc-app-page"
>
{
children
}
</
div
>
<
div
className=
"rc-app-page"
>
{
children
}
</
div
>
</
div
>
</
div
>
</
Sidebar
.
Main
>
</
Sidebar
.
Main
>
<
Toast
.
Provider
maxVisibleToasts=
{
3
}
placement=
"top"
/>
</
Sidebar
.
Provider
>
</
Sidebar
.
Provider
>
);
);
}
}
src/components/ApplicationSwitcherDialog.jsx
View file @
4c7cbb86
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
xinyuanLogo
from
'@/assets/xy-logo-red.png'
;
import
xinyuanLogo
from
'@/assets/xy-logo-red.png'
;
import
{
import
{
APPLICATION_BASE_PATH
,
APPLICATION_BASE_PATH
,
...
@@ -5,12 +6,11 @@ import {
...
@@ -5,12 +6,11 @@ import {
APPLICATION_NAME
,
APPLICATION_NAME
,
}
from
'@/config/application'
;
}
from
'@/config/application'
;
import
{
getApplications
}
from
'@/services/DataService'
;
import
{
getApplications
}
from
'@/services/DataService'
;
import
{
ExclamationTriangleIcon
,
MagnifyingGlassIcon
}
from
'@/components/AppIcons'
;
import
{
MagnifyingGlassIcon
}
from
'@/components/AppIcons'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
ItemCard
}
from
'@heroui-pro/react/item-card'
;
import
{
ItemCard
}
from
'@heroui-pro/react/item-card'
;
import
{
ItemCardGroup
}
from
'@heroui-pro/react/item-card-group'
;
import
{
ItemCardGroup
}
from
'@heroui-pro/react/item-card-group'
;
import
{
import
{
Alert
,
Avatar
,
Avatar
,
Chip
,
Chip
,
Modal
,
Modal
,
...
@@ -312,7 +312,7 @@ export default function ApplicationSwitcherDialog({
...
@@ -312,7 +312,7 @@ export default function ApplicationSwitcherDialog({
const
[
status
,
setStatus
]
=
useState
(
const
[
status
,
setStatus
]
=
useState
(
fallbackApplications
.
length
>
0
?
'success'
:
'idle'
,
fallbackApplications
.
length
>
0
?
'success'
:
'idle'
,
);
);
const
[
errorMessage
,
setErrorMessage
]
=
useState
(
'
'
);
const
[
,
setErrorMessage
]
=
useFeedbackState
(
'warning
'
);
const
[
applicationSearch
,
setApplicationSearch
]
=
useState
(
''
);
const
[
applicationSearch
,
setApplicationSearch
]
=
useState
(
''
);
const
currentApplicationName
=
useMemo
(()
=>
{
const
currentApplicationName
=
useMemo
(()
=>
{
return
applications
.
find
(
isCurrentApplication
)?.
name
||
CURRENT_APPLICATION_NAME
;
return
applications
.
find
(
isCurrentApplication
)?.
name
||
CURRENT_APPLICATION_NAME
;
...
@@ -392,7 +392,7 @@ export default function ApplicationSwitcherDialog({
...
@@ -392,7 +392,7 @@ export default function ApplicationSwitcherDialog({
return
()
=>
{
return
()
=>
{
cancelled
=
true
;
cancelled
=
true
;
};
};
},
[
fallbackApplications
,
open
]);
},
[
fallbackApplications
,
open
,
setErrorMessage
]);
return
(
return
(
<
Modal
.
Root
>
<
Modal
.
Root
>
...
@@ -443,13 +443,6 @@ export default function ApplicationSwitcherDialog({
...
@@ -443,13 +443,6 @@ export default function ApplicationSwitcherDialog({
offset=
{
12
}
offset=
{
12
}
size=
{
24
}
size=
{
24
}
>
>
{
status
===
'error'
&&
errorMessage
?
(
<
Alert
className=
"mb-4"
status=
"warning"
>
<
Alert
.
Indicator
><
ExclamationTriangleIcon
size=
{
20
}
/></
Alert
.
Indicator
>
<
Alert
.
Content
><
Alert
.
Description
>
{
errorMessage
}
</
Alert
.
Description
></
Alert
.
Content
>
</
Alert
>
)
:
null
}
{
status
===
'loading'
&&
applications
.
length
===
0
?
(
{
status
===
'loading'
&&
applications
.
length
===
0
?
(
<
LoadingSkeleton
/>
<
LoadingSkeleton
/>
)
:
filteredApplicationSections
.
length
>
0
?
(
)
:
filteredApplicationSections
.
length
>
0
?
(
...
...
src/components/ProcessInstanceDetailContent.jsx
View file @
4c7cbb86
...
@@ -8,13 +8,14 @@ import {
...
@@ -8,13 +8,14 @@ import {
import
{
Button
,
Surface
}
from
'@heroui/react'
;
import
{
Button
,
Surface
}
from
'@heroui/react'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
FileText
}
from
'@/components/AppIcons'
;
import
{
FileText
}
from
'@/components/AppIcons'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
useMemo
}
from
'react'
;
import
{
useMemo
}
from
'react'
;
function
resolveDetailValue
(
field
,
fieldMap
,
lookups
)
{
function
resolveDetailValue
(
field
,
fieldMap
,
lookups
,
onDownload
)
{
if
(
field
?.
type
===
'file'
)
{
if
(
field
?.
type
===
'file'
)
{
const
files
=
Array
.
isArray
(
field
?.
value
)
?
field
.
value
:
[];
const
files
=
Array
.
isArray
(
field
?.
value
)
?
field
.
value
:
[];
if
(
!
files
.
length
)
return
<
span
className=
"text-muted"
>
暂无内容
</
span
>;
if
(
!
files
.
length
)
return
<
span
className=
"text-muted"
>
暂无内容
</
span
>;
return
<
div
className=
"flex flex-wrap gap-2"
>
{
files
.
map
((
file
)
=>
<
Button
key=
{
`${file.fileUrl || file.url}-${file.filename}`
}
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
downloadProcessAttachment
(
file
)
}
>
{
file
.
filename
||
file
.
fileName
||
'附件'
}
</
Button
>)
}
</
div
>;
return
<
div
className=
"flex flex-wrap gap-2"
>
{
files
.
map
((
file
)
=>
<
Button
key=
{
`${file.fileUrl || file.url}-${file.filename}`
}
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
onDownload
(
file
)
}
>
{
file
.
filename
||
file
.
fileName
||
'附件'
}
</
Button
>)
}
</
div
>;
}
}
const
fieldDefinition
=
fieldMap
[
field
?.
label
];
const
fieldDefinition
=
fieldMap
[
field
?.
label
];
...
@@ -35,6 +36,11 @@ function DetailField({ label, children, fullWidth = false }) {
...
@@ -35,6 +36,11 @@ function DetailField({ label, children, fullWidth = false }) {
}
}
export
default
function
ProcessInstanceDetailContent
({
data
,
loading
=
false
,
withSurface
=
false
})
{
export
default
function
ProcessInstanceDetailContent
({
data
,
loading
=
false
,
withSurface
=
false
})
{
const
[,
setDownloadError
]
=
useFeedbackState
(
'error'
);
const
handleDownloadAttachment
=
async
(
file
)
=>
{
try
{
await
downloadProcessAttachment
(
file
);
}
catch
(
error
)
{
setDownloadError
(
error
?.
message
||
'附件下载失败,请重试'
);
}
};
const
record
=
data
?.
record
;
const
record
=
data
?.
record
;
const
fieldsDefinition
=
data
?.
fieldsDefinition
||
[];
const
fieldsDefinition
=
data
?.
fieldsDefinition
||
[];
const
members
=
data
?.
members
||
[];
const
members
=
data
?.
members
||
[];
...
@@ -55,7 +61,7 @@ export default function ProcessInstanceDetailContent({ data, loading = false, wi
...
@@ -55,7 +61,7 @@ export default function ProcessInstanceDetailContent({ data, loading = false, wi
<
DetailField
label=
"审批者"
>
{
record
?.
metadata
?.
approver
||
<
span
className=
"text-muted"
>
暂无内容
</
span
>
}
</
DetailField
>
<
DetailField
label=
"审批者"
>
{
record
?.
metadata
?.
approver
||
<
span
className=
"text-muted"
>
暂无内容
</
span
>
}
</
DetailField
>
<
DetailField
label=
"所属部门"
>
{
record
?.
metadata
?.
department
||
<
span
className=
"text-muted"
>
暂无内容
</
span
>
}
</
DetailField
>
<
DetailField
label=
"所属部门"
>
{
record
?.
metadata
?.
department
||
<
span
className=
"text-muted"
>
暂无内容
</
span
>
}
</
DetailField
>
<
DetailField
label=
"显示条件"
>
{
relatedCondition
?.
key
?
`${relatedCondition.key} = ${relatedCondition.value || '-'}`
:
'未配置'
}
</
DetailField
>
<
DetailField
label=
"显示条件"
>
{
relatedCondition
?.
key
?
`${relatedCondition.key} = ${relatedCondition.value || '-'}`
:
'未配置'
}
</
DetailField
>
{
details
.
map
((
field
,
index
)
=>
<
DetailField
key=
{
`${field.label}-${index}`
}
label=
{
field
.
label
||
`字段 ${index + 1}`
}
fullWidth=
{
field
?.
type
===
'file'
||
String
(
field
?.
value
||
''
).
length
>
80
}
>
{
resolveDetailValue
(
field
,
fieldMap
,
lookups
)
}
</
DetailField
>)
}
{
details
.
map
((
field
,
index
)
=>
<
DetailField
key=
{
`${field.label}-${index}`
}
label=
{
field
.
label
||
`字段 ${index + 1}`
}
fullWidth=
{
field
?.
type
===
'file'
||
String
(
field
?.
value
||
''
).
length
>
80
}
>
{
resolveDetailValue
(
field
,
fieldMap
,
lookups
,
handleDownloadAttachment
)
}
</
DetailField
>)
}
</
div
>;
</
div
>;
return
withSurface
?
<
Surface
className=
"rounded-2xl px-5 py-5 sm:px-6 sm:py-6"
variant=
"secondary"
>
{
fieldGrid
}
</
Surface
>
:
fieldGrid
;
return
withSurface
?
<
Surface
className=
"rounded-2xl px-5 py-5 sm:px-6 sm:py-6"
variant=
"secondary"
>
{
fieldGrid
}
</
Surface
>
:
fieldGrid
;
...
...
src/design-system/feedback.css
0 → 100644
View file @
4c7cbb86
/* Operation feedback stays above Modal and the mobile sidebar, outside page scrolling. */
.app-feedback-region.toast-region
{
z-index
:
300
;
top
:
max
(
1rem
,
env
(
safe-area-inset-top
));
}
/* Only the front toast is interactive; its ghost close button stays visible without hover. */
.app-feedback-toast
:not
([
data-frontmost
=
"true"
])
.app-feedback-close
{
visibility
:
hidden
;
pointer-events
:
none
;
}
src/hooks/useFeedbackState.js
0 → 100644
View file @
4c7cbb86
import
{
toast
}
from
'@heroui/react'
;
import
{
useCallback
,
useEffect
,
useRef
,
useState
}
from
'react'
;
const
FEEDBACK_OPTIONS
=
{
error
:
{
title
:
'操作失败'
,
variant
:
'danger'
,
timeout
:
8000
},
success
:
{
title
:
'操作成功'
,
variant
:
'success'
,
timeout
:
4000
},
warning
:
{
title
:
'请注意'
,
variant
:
'warning'
,
timeout
:
8000
},
};
// Closing a toast leaves the request's failure state and form draft intact.
export
default
function
useFeedbackState
(
tone
=
'error'
)
{
const
[
message
,
setMessage
]
=
useState
(
''
);
const
toastKeyRef
=
useRef
(
null
);
const
isMountedRef
=
useRef
(
true
);
const
updateMessage
=
useCallback
((
nextMessage
)
=>
{
if
(
!
isMountedRef
.
current
)
return
;
if
(
toastKeyRef
.
current
)
toast
.
close
(
toastKeyRef
.
current
);
toastKeyRef
.
current
=
null
;
setMessage
(
nextMessage
);
if
(
!
nextMessage
)
return
;
const
{
title
,
variant
,
timeout
}
=
FEEDBACK_OPTIONS
[
tone
]
||
FEEDBACK_OPTIONS
.
error
;
// A retry must report feedback even when the error text is identical.
toastKeyRef
.
current
=
toast
(
title
,
{
description
:
nextMessage
,
variant
,
timeout
});
},
[
tone
]);
useEffect
(()
=>
{
isMountedRef
.
current
=
true
;
return
()
=>
{
isMountedRef
.
current
=
false
;
if
(
toastKeyRef
.
current
)
toast
.
close
(
toastKeyRef
.
current
);
toastKeyRef
.
current
=
null
;
};
},
[]);
return
[
message
,
updateMessage
];
}
src/pages/Login.jsx
View file @
4c7cbb86
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
xyLogoRed
from
'@/assets/xy-logo-red.png'
;
import
xyLogoRed
from
'@/assets/xy-logo-red.png'
;
import
xyLogoWhite
from
'@/assets/xy-logo-white.png'
;
import
xyLogoWhite
from
'@/assets/xy-logo-white.png'
;
import
{
import
{
...
@@ -7,7 +8,7 @@ import {
...
@@ -7,7 +8,7 @@ import {
import
authService
from
'@/services/authService'
;
import
authService
from
'@/services/authService'
;
import
{
getCaptcha
,
userLogin
}
from
'@/services/DataService'
;
import
{
getCaptcha
,
userLogin
}
from
'@/services/DataService'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
import
{
Alert
,
Button
,
Card
,
Input
,
Label
,
TextField
}
from
'@heroui/react'
;
import
{
Button
,
Card
,
Input
,
Label
,
TextField
}
from
'@heroui/react'
;
import
CryptoJS
from
'crypto-js'
;
import
CryptoJS
from
'crypto-js'
;
import
{
useContext
,
useEffect
,
useState
}
from
'react'
;
import
{
useContext
,
useEffect
,
useState
}
from
'react'
;
import
{
history
,
useSearchParams
}
from
'umi'
;
import
{
history
,
useSearchParams
}
from
'umi'
;
...
@@ -55,7 +56,7 @@ export default function LoginPage() {
...
@@ -55,7 +56,7 @@ export default function LoginPage() {
const
[
captchaData
,
setCaptchaData
]
=
useState
(
null
);
const
[
captchaData
,
setCaptchaData
]
=
useState
(
null
);
const
[
captchaLoading
,
setCaptchaLoading
]
=
useState
(
false
);
const
[
captchaLoading
,
setCaptchaLoading
]
=
useState
(
false
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
'
'
);
const
[
,
setError
]
=
useFeedbackState
(
'error
'
);
const
redirect
=
normalizeRedirect
(
searchParams
.
get
(
'redirect'
));
const
redirect
=
normalizeRedirect
(
searchParams
.
get
(
'redirect'
));
const
refreshCaptcha
=
async
()
=>
{
const
refreshCaptcha
=
async
()
=>
{
...
@@ -205,14 +206,6 @@ export default function LoginPage() {
...
@@ -205,14 +206,6 @@ export default function LoginPage() {
</
div
>
</
div
>
</
div
>
</
div
>
{
error
?
(
<
Alert
status=
"danger"
>
<
Alert
.
Content
>
<
Alert
.
Description
>
{
error
}
</
Alert
.
Description
>
</
Alert
.
Content
>
</
Alert
>
)
:
null
}
<
Button
<
Button
fullWidth
fullWidth
className=
"login-submit-button"
className=
"login-submit-button"
...
...
src/pages/notifications/NotificationListPage.jsx
View file @
4c7cbb86
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
Bell
,
Close
,
MagnifyingGlassIcon
}
from
'@/components/AppIcons'
;
import
{
Bell
,
Close
,
MagnifyingGlassIcon
}
from
'@/components/AppIcons'
;
import
{
useUserInfo
}
from
'@/hooks/user'
;
import
{
useUserInfo
}
from
'@/hooks/user'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
import
{
Alert
,
Avatar
,
Avatar
,
Button
,
Button
,
Card
,
Card
,
...
@@ -205,7 +205,7 @@ export default function NotificationListPage() {
...
@@ -205,7 +205,7 @@ export default function NotificationListPage() {
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
unreadTotal
,
setUnreadTotal
]
=
useState
(
0
);
const
[
unreadTotal
,
setUnreadTotal
]
=
useState
(
0
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
error
,
setError
]
=
use
State
(
'
'
);
const
[
error
,
setError
]
=
use
FeedbackState
(
'error
'
);
const
[
busyKey
,
setBusyKey
]
=
useState
(
''
);
const
[
busyKey
,
setBusyKey
]
=
useState
(
''
);
const
[
reloadKey
,
setReloadKey
]
=
useState
(
0
);
const
[
reloadKey
,
setReloadKey
]
=
useState
(
0
);
...
@@ -247,7 +247,7 @@ export default function NotificationListPage() {
...
@@ -247,7 +247,7 @@ export default function NotificationListPage() {
return
()
=>
{
return
()
=>
{
cancelled
=
true
;
cancelled
=
true
;
};
};
},
[
category
,
keyword
,
page
,
readStatus
,
reloadKey
]);
},
[
category
,
keyword
,
page
,
readStatus
,
reloadKey
,
setError
]);
useEffect
(()
=>
{
useEffect
(()
=>
{
let
cancelled
=
false
;
let
cancelled
=
false
;
...
@@ -278,12 +278,12 @@ export default function NotificationListPage() {
...
@@ -278,12 +278,12 @@ export default function NotificationListPage() {
setBusyKey
(
key
);
setBusyKey
(
key
);
try
{
try
{
await
operation
();
await
operation
();
toast
.
success
(
successMessage
,
{
timeout
:
3
000
});
toast
.
success
(
successMessage
,
{
timeout
:
4
000
});
refresh
();
refresh
();
return
true
;
return
true
;
}
catch
(
operationError
)
{
}
catch
(
operationError
)
{
toast
.
danger
(
operationError
?.
message
||
'操作失败,请稍后重试'
,
{
toast
.
danger
(
operationError
?.
message
||
'操作失败,请稍后重试'
,
{
timeout
:
4
000
,
timeout
:
8
000
,
});
});
return
false
;
return
false
;
}
finally
{
}
finally
{
...
@@ -333,7 +333,7 @@ export default function NotificationListPage() {
...
@@ -333,7 +333,7 @@ export default function NotificationListPage() {
refresh
();
refresh
();
}
catch
(
operationError
)
{
}
catch
(
operationError
)
{
toast
.
danger
(
operationError
?.
message
||
'无法打开通知关联流程'
,
{
toast
.
danger
(
operationError
?.
message
||
'无法打开通知关联流程'
,
{
timeout
:
4
000
,
timeout
:
8
000
,
});
});
}
finally
{
}
finally
{
setBusyKey
(
''
);
setBusyKey
(
''
);
...
@@ -418,14 +418,15 @@ export default function NotificationListPage() {
...
@@ -418,14 +418,15 @@ export default function NotificationListPage() {
</
div
>
</
div
>
{
error
?
(
{
error
?
(
<
Alert
status=
"danger"
>
<
EmptyState
className=
"min-h-64"
size=
"md"
>
<
Alert
.
Content
>
<
EmptyState
.
Header
>
<
Alert
.
Description
>
{
error
}
</
Alert
.
Description
>
<
EmptyState
.
Title
>
通知加载失败
</
EmptyState
.
Title
>
</
Alert
.
Content
>
<
EmptyState
.
Description
>
详细错误见操作提示,请重新加载。
</
EmptyState
.
Description
>
<
Button
size=
"sm"
variant=
"ghost"
onPress=
{
refresh
}
>
</
EmptyState
.
Header
>
重试
<
EmptyState
.
Content
>
</
Button
>
<
Button
variant=
"secondary"
onPress=
{
refresh
}
>
重试
</
Button
>
</
Alert
>
</
EmptyState
.
Content
>
</
EmptyState
>
)
:
null
}
)
:
null
}
{
loading
&&
notifications
.
length
===
0
?
(
{
loading
&&
notifications
.
length
===
0
?
(
...
...
src/pages/process_monitor/ProcessDetail.jsx
View file @
4c7cbb86
...
@@ -2,33 +2,53 @@ import {
...
@@ -2,33 +2,53 @@ import {
loadProcessInstanceDetail
,
loadProcessInstanceDetail
,
}
from
'@/pages/process_monitor/processMonitorService'
;
}
from
'@/pages/process_monitor/processMonitorService'
;
import
ProcessInstanceDetailContent
from
'@/components/ProcessInstanceDetailContent'
;
import
ProcessInstanceDetailContent
from
'@/components/ProcessInstanceDetailContent'
;
import
{
Alert
}
from
'@heroui/react'
;
import
{
Button
}
from
'@heroui/react'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
useParams
}
from
'umi'
;
import
{
useParams
}
from
'umi'
;
export
default
function
ProcessDetailPage
()
{
export
default
function
ProcessDetailPage
()
{
const
{
application
}
=
useParams
();
const
{
application
}
=
useParams
();
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
errorMessage
,
setErrorMessage
]
=
useState
(
''
);
const
[
errorMessage
,
setErrorMessage
]
=
useFeedbackState
(
'error'
);
const
[
retryKey
,
setRetryKey
]
=
useState
(
0
);
const
[
record
,
setRecord
]
=
useState
(
null
);
const
[
record
,
setRecord
]
=
useState
(
null
);
const
[
fieldsDefinition
,
setFieldsDefinition
]
=
useState
([]);
const
[
fieldsDefinition
,
setFieldsDefinition
]
=
useState
([]);
const
[
members
,
setMembers
]
=
useState
([]);
const
[
members
,
setMembers
]
=
useState
([]);
const
[
departments
,
setDepartments
]
=
useState
([]);
const
[
departments
,
setDepartments
]
=
useState
([]);
useEffect
(()
=>
{
useEffect
(()
=>
{
let
cancelled
=
false
;
setLoading
(
true
);
setLoading
(
true
);
setErrorMessage
(
''
);
loadProcessInstanceDetail
(
application
).
then
((
result
)
=>
{
loadProcessInstanceDetail
(
application
).
then
((
result
)
=>
{
if
(
cancelled
)
return
;
setRecord
(
result
.
record
);
setFieldsDefinition
(
result
.
fieldsDefinition
);
setMembers
(
result
.
members
);
setDepartments
(
result
.
departments
);
setErrorMessage
(
''
);
setRecord
(
result
.
record
);
setFieldsDefinition
(
result
.
fieldsDefinition
);
setMembers
(
result
.
members
);
setDepartments
(
result
.
departments
);
setErrorMessage
(
''
);
}).
catch
((
error
)
=>
setErrorMessage
(
error
?.
message
||
'加载流程详情失败'
)).
finally
(()
=>
setLoading
(
false
));
}).
catch
((
error
)
=>
{
},
[
application
]);
if
(
!
cancelled
)
setErrorMessage
(
error
?.
message
||
'加载流程详情失败'
);
}).
finally
(()
=>
{
if
(
!
cancelled
)
setLoading
(
false
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
application
,
retryKey
,
setErrorMessage
]);
const
detailData
=
useMemo
(()
=>
({
record
,
fieldsDefinition
,
members
,
departments
}),
[
departments
,
fieldsDefinition
,
members
,
record
]);
const
detailData
=
useMemo
(()
=>
({
record
,
fieldsDefinition
,
members
,
departments
}),
[
departments
,
fieldsDefinition
,
members
,
record
]);
return
(
return
(
<
div
className=
"rc-page-stack"
>
<
div
className=
"rc-page-stack"
>
{
errorMessage
?
<
Alert
status=
"danger"
><
Alert
.
Indicator
/><
Alert
.
Content
><
Alert
.
Title
>
详情加载失败
</
Alert
.
Title
><
Alert
.
Description
>
{
errorMessage
}
</
Alert
.
Description
></
Alert
.
Content
></
Alert
>
:
null
}
<
div
className=
"rc-list-action-bar"
><
h2
>
流程实例信息
</
h2
></
div
>
<
div
className=
"rc-list-action-bar"
><
h2
>
流程实例信息
</
h2
></
div
>
<
ProcessInstanceDetailContent
data=
{
detailData
}
loading=
{
loading
}
withSurface
/>
{
!
loading
&&
errorMessage
?
(
<
EmptyState
className=
"min-h-48"
size=
"md"
>
<
EmptyState
.
Header
>
<
EmptyState
.
Title
>
流程详情加载失败
</
EmptyState
.
Title
>
<
EmptyState
.
Description
>
详细错误见操作提示,请重新加载。
</
EmptyState
.
Description
>
</
EmptyState
.
Header
>
<
EmptyState
.
Content
>
<
Button
variant=
"secondary"
onPress=
{
()
=>
setRetryKey
((
key
)
=>
key
+
1
)
}
>
重新加载
</
Button
>
</
EmptyState
.
Content
>
</
EmptyState
>
)
:
<
ProcessInstanceDetailContent
data=
{
detailData
}
loading=
{
loading
}
withSurface
/>
}
</
div
>
</
div
>
);
);
}
}
src/pages/process_monitor/ProcessMonitor.jsx
View file @
4c7cbb86
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
useAuthorization
}
from
'@/hooks/user'
;
import
{
useAuthorization
}
from
'@/hooks/user'
;
import
{
Check
,
Close
,
DataStore
,
Delete
,
Edit
,
Eye
,
FileText
,
FilterControls
,
FolderOpen
,
PlusIcon
,
Search
,
Team
}
from
'@/components/AppIcons'
;
import
{
Check
,
Close
,
DataStore
,
Delete
,
Edit
,
Eye
,
FileText
,
FilterControls
,
FolderOpen
,
PlusIcon
,
Search
,
Team
}
from
'@/components/AppIcons'
;
import
{
TableActionButton
,
TableActionGroup
}
from
'@/components/TableActionButton'
;
import
{
TableActionButton
,
TableActionGroup
}
from
'@/components/TableActionButton'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
{
Alert
,
Alert
Dialog
,
Button
,
Checkbox
,
Input
,
Label
,
Link
,
Modal
,
Pagination
,
Popover
,
SearchField
,
Surface
,
Tabs
,
Tooltip
,
toast
}
from
'@heroui/react'
;
import
{
AlertDialog
,
Button
,
Checkbox
,
Input
,
Label
,
Link
,
Modal
,
Pagination
,
Popover
,
SearchField
,
Surface
,
Tabs
,
Tooltip
,
toast
}
from
'@heroui/react'
;
import
{
DataGrid
}
from
'@heroui-pro/react/data-grid'
;
import
{
DataGrid
}
from
'@heroui-pro/react/data-grid'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
KPI
}
from
'@heroui-pro/react/kpi'
;
import
{
KPI
}
from
'@heroui-pro/react/kpi'
;
...
@@ -92,7 +93,9 @@ export default function ProcessMonitorPage() {
...
@@ -92,7 +93,9 @@ export default function ProcessMonitorPage() {
const
[
departmentFilter
,
setDepartmentFilter
]
=
useState
(
''
);
const
[
departmentFilter
,
setDepartmentFilter
]
=
useState
(
''
);
const
[
listPage
,
setListPage
]
=
useState
(
0
);
const
[
listPage
,
setListPage
]
=
useState
(
0
);
const
[
pageSize
,
setPageSize
]
=
useState
(
PAGE_SIZE
);
const
[
pageSize
,
setPageSize
]
=
useState
(
PAGE_SIZE
);
const
[
errorMessage
,
setErrorMessage
]
=
useState
(
''
);
const
[,
setErrorMessage
]
=
useFeedbackState
(
'error'
);
const
[,
setSaveSuccess
]
=
useFeedbackState
(
'success'
);
const
[
loadFailed
,
setLoadFailed
]
=
useState
(
false
);
const
[
createGroupOpen
,
setCreateGroupOpen
]
=
useState
(
false
);
const
[
createGroupOpen
,
setCreateGroupOpen
]
=
useState
(
false
);
const
[
createGroupTitle
,
setCreateGroupTitle
]
=
useState
(
''
);
const
[
createGroupTitle
,
setCreateGroupTitle
]
=
useState
(
''
);
const
[
addTemplateOpen
,
setAddTemplateOpen
]
=
useState
(
false
);
const
[
addTemplateOpen
,
setAddTemplateOpen
]
=
useState
(
false
);
...
@@ -107,6 +110,8 @@ export default function ProcessMonitorPage() {
...
@@ -107,6 +110,8 @@ export default function ProcessMonitorPage() {
const
loadPage
=
async
(
preferredGroupName
)
=>
{
const
loadPage
=
async
(
preferredGroupName
)
=>
{
setLoading
(
true
);
setLoading
(
true
);
setLoadFailed
(
false
);
setErrorMessage
(
''
);
try
{
try
{
const
result
=
await
loadProcessMonitorOverview
();
const
result
=
await
loadProcessMonitorOverview
();
setGroups
(
result
.
groups
);
setGroups
(
result
.
groups
);
...
@@ -116,7 +121,7 @@ export default function ProcessMonitorPage() {
...
@@ -116,7 +121,7 @@ export default function ProcessMonitorPage() {
const
nextActiveGroup
=
result
.
groups
.
find
((
group
)
=>
group
.
name
===
preferredGroupName
)
||
result
.
groups
.
find
((
group
)
=>
group
.
name
===
activeGroupName
)
||
result
.
groups
[
0
]
||
null
;
const
nextActiveGroup
=
result
.
groups
.
find
((
group
)
=>
group
.
name
===
preferredGroupName
)
||
result
.
groups
.
find
((
group
)
=>
group
.
name
===
activeGroupName
)
||
result
.
groups
[
0
]
||
null
;
setActiveGroupName
(
nextActiveGroup
?.
name
||
''
);
setActiveGroupName
(
nextActiveGroup
?.
name
||
''
);
setErrorMessage
(
''
);
setErrorMessage
(
''
);
}
catch
(
error
)
{
setErrorMessage
(
error
?.
message
||
'加载流程监控数据失败'
);
}
}
catch
(
error
)
{
set
LoadFailed
(
true
);
set
ErrorMessage
(
error
?.
message
||
'加载流程监控数据失败'
);
}
finally
{
setLoading
(
false
);
}
finally
{
setLoading
(
false
);
}
};
};
...
@@ -133,6 +138,7 @@ export default function ProcessMonitorPage() {
...
@@ -133,6 +138,7 @@ export default function ProcessMonitorPage() {
useEffect
(()
=>
{
setListPage
(
0
);
},
[
activeGroupName
,
departmentFilter
,
templateKeyword
,
pageSize
]);
useEffect
(()
=>
{
setListPage
(
0
);
},
[
activeGroupName
,
departmentFilter
,
templateKeyword
,
pageSize
]);
const
handleCreateGroup
=
async
()
=>
{
const
handleCreateGroup
=
async
()
=>
{
setErrorMessage
(
''
);
if
(
!
createGroupTitle
.
trim
())
{
setErrorMessage
(
'请先填写分组名称'
);
return
;
}
if
(
!
createGroupTitle
.
trim
())
{
setErrorMessage
(
'请先填写分组名称'
);
return
;
}
setSubmitting
(
true
);
setSubmitting
(
true
);
try
{
try
{
...
@@ -145,6 +151,7 @@ export default function ProcessMonitorPage() {
...
@@ -145,6 +151,7 @@ export default function ProcessMonitorPage() {
};
};
const handleAddTemplates = async () => {
const handleAddTemplates = async () => {
setErrorMessage('');
if (!activeGroup) { setErrorMessage('请先创建或选择一个分组'); return; }
if (!activeGroup) { setErrorMessage('请先创建或选择一个分组'); return; }
const selectedTemplates = pickerTemplates.filter((template) => selectedTemplateIds.includes(template._id));
const selectedTemplates = pickerTemplates.filter((template) => selectedTemplateIds.includes(template._id));
if (!selectedTemplates.length) { setErrorMessage('请至少选择一个流程模板'); return; }
if (!selectedTemplates.length) { setErrorMessage('请至少选择一个流程模板'); return; }
...
@@ -158,21 +165,33 @@ export default function ProcessMonitorPage() {
...
@@ -158,21 +165,33 @@ export default function ProcessMonitorPage() {
};
};
const openEditDialog = (template) => {
const openEditDialog = (template) => {
setErrorMessage('');
setSaveSuccess('');
setEditingTemplate(template);
setEditingTemplate(template);
setTemplateForm({ title: template?.title || '', department: template?.metadata?.department || '', templateCode: template?.metadata?.template_code || '', conditionKey: template?.metadata?.conditions?.key || '', conditionValue: template?.metadata?.conditions?.value || '' });
setTemplateForm({ title: template?.title || '', department: template?.metadata?.department || '', templateCode: template?.metadata?.template_code || '', conditionKey: template?.metadata?.conditions?.key || '', conditionValue: template?.metadata?.conditions?.value || '' });
setEditTemplateOpen(true);
setEditTemplateOpen(true);
};
};
const handleUpdateTemplate = async () => {
const handleUpdateTemplate = async () => {
if (submitting) return;
setErrorMessage('');
setSaveSuccess('');
if (!editingTemplate?._id) return;
if (!editingTemplate?._id) return;
if (!templateForm.title.trim()) { setErrorMessage('模板标题不能为空'); return; }
if (!templateForm.title.trim()) { setErrorMessage('模板标题不能为空'); return; }
setSubmitting(true);
setSubmitting(true);
try { await updateMonitoredTemplate(editingTemplate._id, templateForm); setEditTemplateOpen(false); setEditingTemplate(null); await loadPage(activeGroup?.name); toast.success('模板配置已更新'); }
try {
await updateMonitoredTemplate(editingTemplate._id, templateForm);
setEditTemplateOpen(false);
setEditingTemplate(null);
setSaveSuccess('模板配置已保存。');
await loadPage(activeGroup?.name);
}
catch (error) { setErrorMessage(error?.message || '模板更新失败'); }
catch (error) { setErrorMessage(error?.message || '模板更新失败'); }
finally { setSubmitting(false); }
finally { setSubmitting(false); }
};
};
const handleRemoveTemplate = async () => {
const handleRemoveTemplate = async () => {
setErrorMessage('');
if (!activeGroup || !removeTarget) return;
if (!activeGroup || !removeTarget) return;
setSubmitting(true);
setSubmitting(true);
try { const latestGroup = await loadProcessGroup(activeGroup.name); await removeMonitoredTemplate(latestGroup, removeTarget); setRemoveTarget(null); await loadPage(activeGroup.name); toast.success('已取消该流程模板监控'); }
try { const latestGroup = await loadProcessGroup(activeGroup.name); await removeMonitoredTemplate(latestGroup, removeTarget); setRemoveTarget(null); await loadPage(activeGroup.name); toast.success('已取消该流程模板监控'); }
...
@@ -181,6 +200,7 @@ export default function ProcessMonitorPage() {
...
@@ -181,6 +200,7 @@ export default function ProcessMonitorPage() {
};
};
const handleRemoveGroup = async () => {
const handleRemoveGroup = async () => {
setErrorMessage('');
if (!removeGroupTarget) return;
if (!removeGroupTarget) return;
const nextGroup = groups.find((group) => group.name !== removeGroupTarget.name) || null;
const nextGroup = groups.find((group) => group.name !== removeGroupTarget.name) || null;
setSubmitting(true);
setSubmitting(true);
...
@@ -208,7 +228,6 @@ export default function ProcessMonitorPage() {
...
@@ -208,7 +228,6 @@ export default function ProcessMonitorPage() {
return (
return (
<div className="rc-page-stack">
<div className="rc-page-stack">
{errorMessage ? <Alert status="danger"><Alert.Indicator /><Alert.Content><Alert.Title>操作未完成</Alert.Title><Alert.Description>{errorMessage}</Alert.Description></Alert.Content></Alert> : null}
<div className="rc-kpi-grid" data-count="4">
<div className="rc-kpi-grid" data-count="4">
<Metric label="监控分组" value={groups.length} hint="按业务视角组织模板监控范围" icon={Team} tone="info" />
<Metric label="监控分组" value={groups.length} hint="按业务视角组织模板监控范围" icon={Team} tone="info" />
...
@@ -240,8 +259,8 @@ export default function ProcessMonitorPage() {
...
@@ -240,8 +259,8 @@ export default function ProcessMonitorPage() {
</div>
</div>
<div className="rc-list-data-stack">
<div className="rc-list-data-stack">
<section className="rc-list-data-grid rc-data-table-sticky"><DataGrid allowsColumnResize aria-label="流程监控模板列表" className="pm-data-grid" columns={listColumns} contentClassName="min-w-[1074px]" data={loading
? [] : pagedTemplates} getRowId={(template) => template._id || template.name} renderEmptyState={() => loading ? <div className="py-8 text-sm text-muted">正在加载流程监控列表...</div
> : <DataEmptyState title={activeGroup ? '当前分组还没有监控模板' : '暂无可展示模板'} action={canManage && activeGroup ? <Button size="sm" variant="secondary" onPress={() => setAddTemplateOpen(true)}>添加监控模板</Button> : null} />} scrollContainerClassName="data-table-scroll" verticalAlign="middle" /></section>
<section className="rc-list-data-grid rc-data-table-sticky"><DataGrid allowsColumnResize aria-label="流程监控模板列表" className="pm-data-grid" columns={listColumns} contentClassName="min-w-[1074px]" data={loading
|| loadFailed ? [] : pagedTemplates} getRowId={(template) => template._id || template.name} renderEmptyState={() => loading ? <div className="py-8 text-sm text-muted">正在加载流程监控列表...</div> : loadFailed ? <DataEmptyState title="流程监控列表加载失败" action={<Button size="sm" variant="secondary" onPress={() => loadPage(activeGroupName)}>重新加载</Button>} /
> : <DataEmptyState title={activeGroup ? '当前分组还没有监控模板' : '暂无可展示模板'} action={canManage && activeGroup ? <Button size="sm" variant="secondary" onPress={() => setAddTemplateOpen(true)}>添加监控模板</Button> : null} />} scrollContainerClassName="data-table-scroll" verticalAlign="middle" /></section>
{!loading ? <div className="pm-pagination-bar"><PagePagination page={listPage} pageSize={pageSize} total={filteredTemplates.length} onPageChange={setListPage} onPageSizeChange={(nextPageSize) => { if (PAGE_SIZE_OPTIONS.includes(nextPageSize)) setPageSize(nextPageSize); }} /></div> : null}
{!loading
&& !loadFailed
? <div className="pm-pagination-bar"><PagePagination page={listPage} pageSize={pageSize} total={filteredTemplates.length} onPageChange={setListPage} onPageSizeChange={(nextPageSize) => { if (PAGE_SIZE_OPTIONS.includes(nextPageSize)) setPageSize(nextPageSize); }} /></div> : null}
</div>
</div>
</section>
</section>
...
...
src/pages/process_monitor/ProcessTemplateList.jsx
View file @
4c7cbb86
...
@@ -2,7 +2,8 @@ import { Close, DataStore, Delete, Eye, FieldTime, FileText, Filter, FolderOpen,
...
@@ -2,7 +2,8 @@ import { Close, DataStore, Delete, Eye, FieldTime, FileText, Filter, FolderOpen,
import
{
TableActionButton
,
TableActionGroup
}
from
'@/components/TableActionButton'
;
import
{
TableActionButton
,
TableActionGroup
}
from
'@/components/TableActionButton'
;
import
{
TitleInfo
}
from
'@/components/XinyuanUi'
;
import
{
TitleInfo
}
from
'@/components/XinyuanUi'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
{
Alert
,
Button
,
Input
,
Label
,
Link
,
Modal
,
Pagination
,
SearchField
,
Tooltip
,
toast
}
from
'@heroui/react'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
Button
,
Input
,
Label
,
Link
,
Modal
,
Pagination
,
SearchField
,
Tooltip
,
toast
}
from
'@heroui/react'
;
import
{
DataGrid
}
from
'@heroui-pro/react/data-grid'
;
import
{
DataGrid
}
from
'@heroui-pro/react/data-grid'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
KPI
}
from
'@heroui-pro/react/kpi'
;
import
{
KPI
}
from
'@heroui-pro/react/kpi'
;
...
@@ -18,7 +19,7 @@ import {
...
@@ -18,7 +19,7 @@ import {
queryProcessInstances
,
queryProcessInstances
,
updateTemplateDisplayFields
,
updateTemplateDisplayFields
,
}
from
'@/pages/process_monitor/processMonitorService'
;
}
from
'@/pages/process_monitor/processMonitorService'
;
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
use
Callback
,
use
Effect
,
useMemo
,
useState
}
from
'react'
;
import
{
useNavigate
,
useParams
,
useSearchParams
}
from
'umi'
;
import
{
useNavigate
,
useParams
,
useSearchParams
}
from
'umi'
;
const
PAGE_SIZE
=
20
;
const
PAGE_SIZE
=
20
;
...
@@ -52,13 +53,13 @@ function formatLookupValue(type, value, lookups) {
...
@@ -52,13 +53,13 @@ function formatLookupValue(type, value, lookups) {
return
values
.
map
((
item
)
=>
mapping
[
item
]
||
item
).
join
(
'、'
);
return
values
.
map
((
item
)
=>
mapping
[
item
]
||
item
).
join
(
'、'
);
}
}
function
renderFieldValue
(
fieldDefinition
,
record
,
lookups
)
{
function
renderFieldValue
(
fieldDefinition
,
record
,
lookups
,
onDownload
)
{
const
fieldDetail
=
findDetailField
(
record
,
fieldDefinition
.
display_name
);
const
fieldDetail
=
findDetailField
(
record
,
fieldDefinition
.
display_name
);
if
(
!
fieldDetail
)
return
'-'
;
if
(
!
fieldDetail
)
return
'-'
;
if
(
fieldDetail
.
type
===
'file'
)
{
if
(
fieldDetail
.
type
===
'file'
)
{
const
files
=
Array
.
isArray
(
fieldDetail
.
value
)
?
fieldDetail
.
value
:
[];
const
files
=
Array
.
isArray
(
fieldDetail
.
value
)
?
fieldDetail
.
value
:
[];
if
(
!
files
.
length
)
return
'-'
;
if
(
!
files
.
length
)
return
'-'
;
return
<
div
className=
"flex flex-wrap gap-2"
>
{
files
.
map
((
file
)
=>
<
Button
key=
{
`${file.fileUrl || file.url}-${file.filename}`
}
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
downloadProcessAttachment
(
file
)
}
>
{
file
.
filename
||
file
.
fileName
||
'附件'
}
</
Button
>)
}
</
div
>;
return
<
div
className=
"flex flex-wrap gap-2"
>
{
files
.
map
((
file
)
=>
<
Button
key=
{
`${file.fileUrl || file.url}-${file.filename}`
}
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
onDownload
(
file
)
}
>
{
file
.
filename
||
file
.
fileName
||
'附件'
}
</
Button
>)
}
</
div
>;
}
}
if
(
fieldDefinition
.
type
===
'member'
)
return
formatLookupValue
(
'member'
,
fieldDetail
.
value
,
lookups
);
if
(
fieldDefinition
.
type
===
'member'
)
return
formatLookupValue
(
'member'
,
fieldDetail
.
value
,
lookups
);
if
(
fieldDefinition
.
type
===
'department'
)
return
formatLookupValue
(
'department'
,
fieldDetail
.
value
,
lookups
);
if
(
fieldDefinition
.
type
===
'department'
)
return
formatLookupValue
(
'department'
,
fieldDetail
.
value
,
lookups
);
...
@@ -98,8 +99,13 @@ export default function ProcessTemplateListPage() {
...
@@ -98,8 +99,13 @@ export default function ProcessTemplateListPage() {
const
[
searchParams
]
=
useSearchParams
();
const
[
searchParams
]
=
useSearchParams
();
const
tabName
=
searchParams
.
get
(
'tab'
)
||
''
;
const
tabName
=
searchParams
.
get
(
'tab'
)
||
''
;
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
instancesLoading
,
setInstancesLoading
]
=
useState
(
false
);
const
[
retryKey
,
setRetryKey
]
=
useState
(
0
);
const
[
downloading
,
setDownloading
]
=
useState
(
false
);
const
[
downloading
,
setDownloading
]
=
useState
(
false
);
const
[
errorMessage
,
setErrorMessage
]
=
useState
(
''
);
const
[,
setErrorMessage
]
=
useFeedbackState
(
'error'
);
const
[,
setSaveSuccess
]
=
useFeedbackState
(
'success'
);
const
[
metadataError
,
setMetadataError
]
=
useFeedbackState
(
'error'
);
const
[
instancesError
,
setInstancesError
]
=
useFeedbackState
(
'error'
);
const
[
template
,
setTemplate
]
=
useState
(
null
);
const
[
template
,
setTemplate
]
=
useState
(
null
);
const
[
fieldsDefinition
,
setFieldsDefinition
]
=
useState
([]);
const
[
fieldsDefinition
,
setFieldsDefinition
]
=
useState
([]);
const
[
members
,
setMembers
]
=
useState
([]);
const
[
members
,
setMembers
]
=
useState
([]);
...
@@ -114,20 +120,48 @@ export default function ProcessTemplateListPage() {
...
@@ -114,20 +120,48 @@ export default function ProcessTemplateListPage() {
const
[
fieldDrafts
,
setFieldDrafts
]
=
useState
([]);
const
[
fieldDrafts
,
setFieldDrafts
]
=
useState
([]);
const
[
fieldSaving
,
setFieldSaving
]
=
useState
(
false
);
const
[
fieldSaving
,
setFieldSaving
]
=
useState
(
false
);
const
loadTemplateMeta
=
async
()
=>
{
const
listLoading
=
loading
||
instancesLoading
;
const
result
=
await
loadTemplateMonitorPage
(
processId
);
const
loadFailed
=
Boolean
(
metadataError
||
instancesError
);
setTemplate
(
result
.
template
);
setFieldsDefinition
(
result
.
fieldsDefinition
);
setMembers
(
result
.
members
);
setDepartments
(
result
.
departments
);
setConditions
(
result
.
conditions
);
};
const
loadInstances
=
async
()
=>
{
const
result
=
await
queryProcessInstances
({
processId
,
page
,
pageSize
,
keyword
,
conditions
});
setInstances
(
result
.
records
);
setTotal
(
result
.
total
);
};
useEffect
(()
=>
{
useEffect
(()
=>
{
let
cancelled
=
false
;
setLoading
(
true
);
setLoading
(
true
);
Promise
.
all
([
loadTemplateMeta
(),
loadInstances
()]).
then
(()
=>
setErrorMessage
(
''
)).
catch
((
error
)
=>
setErrorMessage
(
error
?.
message
||
'加载流程实例失败'
)).
finally
(()
=>
setLoading
(
false
));
setMetadataError
(
''
);
},
[
processId
]);
setSaveSuccess
(
''
);
useEffect
(()
=>
{
loadInstances
().
catch
((
error
)
=>
setErrorMessage
(
error
?.
message
||
'查询流程实例失败'
));
},
[
page
,
pageSize
,
keyword
,
conditions
]);
loadTemplateMonitorPage
(
processId
).
then
((
result
)
=>
{
if
(
cancelled
)
return
;
setTemplate
(
result
.
template
);
setFieldsDefinition
(
result
.
fieldsDefinition
);
setMembers
(
result
.
members
);
setDepartments
(
result
.
departments
);
setConditions
(
result
.
conditions
);
}).
catch
((
error
)
=>
{
if
(
!
cancelled
)
setMetadataError
(
error
?.
message
||
'加载流程模板失败'
);
}).
finally
(()
=>
{
if
(
!
cancelled
)
setLoading
(
false
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
processId
,
retryKey
,
setMetadataError
,
setSaveSuccess
]);
useEffect
(()
=>
{
if
(
loading
||
metadataError
)
{
setInstancesLoading
(
false
);
return
;
}
let
cancelled
=
false
;
setInstancesLoading
(
true
);
setInstancesError
(
''
);
queryProcessInstances
({
processId
,
page
,
pageSize
,
keyword
,
conditions
}).
then
((
result
)
=>
{
if
(
cancelled
)
return
;
setInstances
(
result
.
records
);
setTotal
(
result
.
total
);
}).
catch
((
error
)
=>
{
if
(
!
cancelled
)
setInstancesError
(
error
?.
message
||
'查询流程实例失败'
);
}).
finally
(()
=>
{
if
(
!
cancelled
)
setInstancesLoading
(
false
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
processId
,
page
,
pageSize
,
keyword
,
conditions
,
loading
,
metadataError
,
setInstancesError
]);
const
handleDownloadAttachment
=
useCallback
(
async
(
file
)
=>
{
try
{
await
downloadProcessAttachment
(
file
);
}
catch
(
error
)
{
setErrorMessage
(
error
?.
message
||
'附件下载失败,请重试'
);
}
},
[
setErrorMessage
]);
const
lookups
=
useMemo
(()
=>
({
memberLookup
:
buildMemberLookup
(
members
),
departmentLookup
:
buildDepartmentLookup
(
departments
)
}),
[
departments
,
members
]);
const
lookups
=
useMemo
(()
=>
({
memberLookup
:
buildMemberLookup
(
members
),
departmentLookup
:
buildDepartmentLookup
(
departments
)
}),
[
departments
,
members
]);
const
totalPages
=
Math
.
max
(
Math
.
ceil
(
total
/
pageSize
),
1
);
const
totalPages
=
Math
.
max
(
Math
.
ceil
(
total
/
pageSize
),
1
);
...
@@ -137,27 +171,38 @@ export default function ProcessTemplateListPage() {
...
@@ -137,27 +171,38 @@ export default function ProcessTemplateListPage() {
const
tabQuery
=
tabName
?
`?tab=${encodeURIComponent(tabName)}`
:
''
;
const
tabQuery
=
tabName
?
`?tab=${encodeURIComponent(tabName)}`
:
''
;
navigate
(
`/process_monitor/${processId}/${record._id}${tabQuery}`
);
navigate
(
`/process_monitor/${processId}/${record._id}${tabQuery}`
);
}
}
>
{
record
.
title
}
</
Link
>
},
}
}
>
{
record
.
title
}
</
Link
>
},
...
fieldsDefinition
.
map
((
field
)
=>
({
id
:
`
${
field
.
display_name
}
-
${
field
.
type
}
`
,
header
:
field
.
display_name
,
width
:
220
,
minWidth
:
180
,
allowsResizing
:
false
,
cell
:
(
record
)
=>
<
span
className=
"pm-data-grid-text"
>
{
renderFieldValue
(
field
,
record
,
lookups
)
}
</
span
>
})),
...
fieldsDefinition
.
map
((
field
)
=>
({
id
:
`
${
field
.
display_name
}
-
${
field
.
type
}
`
,
header
:
field
.
display_name
,
width
:
220
,
minWidth
:
180
,
allowsResizing
:
false
,
cell
:
(
record
)
=>
<
span
className=
"pm-data-grid-text"
>
{
renderFieldValue
(
field
,
record
,
lookups
,
handleDownloadAttachment
)
}
</
span
>
})),
{
id
:
'actions'
,
header
:
'操作'
,
width
:
80
,
minWidth
:
80
,
maxWidth
:
80
,
allowsResizing
:
false
,
align
:
'start'
,
pinned
:
'end'
,
cell
:
(
record
)
=>
<
TableActionGroup
><
TableActionButton
icon=
{
Eye
}
label=
"查看"
onPress=
{
()
=>
{
{
id
:
'actions'
,
header
:
'操作'
,
width
:
80
,
minWidth
:
80
,
maxWidth
:
80
,
allowsResizing
:
false
,
align
:
'start'
,
pinned
:
'end'
,
cell
:
(
record
)
=>
<
TableActionGroup
><
TableActionButton
icon=
{
Eye
}
label=
"查看"
onPress=
{
()
=>
{
if
(
!
record
?.
_id
)
return
;
if
(
!
record
?.
_id
)
return
;
const
tabQuery
=
tabName
?
`?tab=${encodeURIComponent(tabName)}`
:
''
;
const
tabQuery
=
tabName
?
`?tab=${encodeURIComponent(tabName)}`
:
''
;
navigate
(
`/process_monitor/${processId}/${record._id}${tabQuery}`
);
navigate
(
`/process_monitor/${processId}/${record._id}${tabQuery}`
);
}
}
/></
TableActionGroup
>
},
}
}
/></
TableActionGroup
>
},
],
[
fieldsDefinition
,
lookups
,
navigate
,
processId
,
tabName
]);
],
[
fieldsDefinition
,
handleDownloadAttachment
,
lookups
,
navigate
,
processId
,
tabName
]);
const
openFieldDialog
=
()
=>
{
const
openFieldDialog
=
()
=>
{
setErrorMessage
(
''
);
setSaveSuccess
(
''
);
const
normalizedFields
=
normalizeDisplayFieldDefinitions
(
fieldsDefinition
);
const
normalizedFields
=
normalizeDisplayFieldDefinitions
(
fieldsDefinition
);
setFieldDrafts
((
normalizedFields
.
length
?
normalizedFields
:
[{}]).
map
(
createFieldDraft
));
setFieldDrafts
((
normalizedFields
.
length
?
normalizedFields
:
[{}]).
map
(
createFieldDraft
));
setFieldDialogOpen
(
true
);
setFieldDialogOpen
(
true
);
};
};
const
saveFieldDefinitions
=
async
()
=>
{
const
saveFieldDefinitions
=
async
()
=>
{
if
(
fieldSaving
)
return
;
setErrorMessage
(
''
);
setSaveSuccess
(
''
);
const
normalizedFields
=
fieldDrafts
.
map
((
field
)
=>
({
display_name
:
field
.
display_name
?.
trim
()
||
''
,
type
:
field
.
type
||
'text'
})).
filter
((
field
)
=>
field
.
display_name
);
const
normalizedFields
=
fieldDrafts
.
map
((
field
)
=>
({
display_name
:
field
.
display_name
?.
trim
()
||
''
,
type
:
field
.
type
||
'text'
})).
filter
((
field
)
=>
field
.
display_name
);
setFieldSaving
(
true
);
setFieldSaving
(
true
);
try
{
await
updateTemplateDisplayFields
(
processId
,
normalizedFields
);
setFieldsDefinition
(
normalizedFields
);
setFieldDialogOpen
(
false
);
toast
.
success
(
'显示字段配置已更新'
);
}
try
{
await
updateTemplateDisplayFields
(
processId
,
normalizedFields
);
setFieldsDefinition
(
normalizedFields
);
setFieldDialogOpen
(
false
);
setSaveSuccess
(
'显示字段配置已保存。'
);
}
catch
(
error
)
{
setErrorMessage
(
error
?.
message
||
'显示字段保存失败'
);
}
catch
(
error
)
{
setErrorMessage
(
error
?.
message
||
'显示字段保存失败'
);
}
finally
{
setFieldSaving
(
false
);
}
finally
{
setFieldSaving
(
false
);
}
};
};
const
handleDownloadAll
=
async
()
=>
{
const
handleDownloadAll
=
async
()
=>
{
setErrorMessage
(
''
);
setDownloading
(
true
);
setDownloading
(
true
);
try
{
const
count
=
await
downloadAllProcessAttachments
(
processId
);
count
>
0
?
toast
.
success
(
`已触发
${
count
}
个附件下载`
)
:
toast
.
info
(
'当前模板下没有可下载附件'
);
}
try
{
const
count
=
await
downloadAllProcessAttachments
(
processId
);
count
>
0
?
toast
.
success
(
`已触发
${
count
}
个附件下载`
)
:
toast
.
info
(
'当前模板下没有可下载附件'
);
}
catch
(
error
)
{
setErrorMessage
(
error
?.
message
||
'批量下载失败'
);
}
catch
(
error
)
{
setErrorMessage
(
error
?.
message
||
'批量下载失败'
);
}
...
@@ -167,7 +212,6 @@ export default function ProcessTemplateListPage() {
...
@@ -167,7 +212,6 @@ export default function ProcessTemplateListPage() {
return
(
return
(
<
div
className=
"rc-page-stack"
>
<
div
className=
"rc-page-stack"
>
<
div
className=
"rc-detail-toolbar flex min-w-0 flex-wrap items-center justify-end gap-x-4 gap-y-3"
><
div
className=
"rc-page-command-bar ml-auto flex shrink-0 justify-end"
><
div
className=
"flex flex-nowrap gap-2"
><
Button
isDisabled=
{
loading
}
isPending=
{
downloading
}
size=
"sm"
variant=
"secondary"
onPress=
{
handleDownloadAll
}
>
批量下载附件
</
Button
><
Button
isDisabled=
{
loading
}
size=
"sm"
variant=
"primary"
onPress=
{
openFieldDialog
}
>
定义显示字段
</
Button
></
div
></
div
></
div
>
<
div
className=
"rc-detail-toolbar flex min-w-0 flex-wrap items-center justify-end gap-x-4 gap-y-3"
><
div
className=
"rc-page-command-bar ml-auto flex shrink-0 justify-end"
><
div
className=
"flex flex-nowrap gap-2"
><
Button
isDisabled=
{
loading
}
isPending=
{
downloading
}
size=
"sm"
variant=
"secondary"
onPress=
{
handleDownloadAll
}
>
批量下载附件
</
Button
><
Button
isDisabled=
{
loading
}
size=
"sm"
variant=
"primary"
onPress=
{
openFieldDialog
}
>
定义显示字段
</
Button
></
div
></
div
></
div
>
{
errorMessage
?
<
Alert
status=
"danger"
><
Alert
.
Indicator
/><
Alert
.
Content
><
Alert
.
Title
>
操作未完成
</
Alert
.
Title
><
Alert
.
Description
>
{
errorMessage
}
</
Alert
.
Description
></
Alert
.
Content
></
Alert
>
:
null
}
<
div
className=
"rc-kpi-grid"
data
-
count=
"4"
><
Metric
label=
"模板实例"
value=
{
total
}
hint=
"当前模板下命中的提交流程数量"
icon=
{
DataStore
}
tone=
"info"
/><
Metric
label=
"显示字段"
value=
{
fieldsDefinition
.
length
}
hint=
"由模板配置决定表格展开的动态列"
icon=
{
FileText
}
tone=
"success"
/><
Metric
label=
"条件筛选"
value=
{
conditions
?.
key
?
'已启用'
:
'未启用'
}
hint=
{
conditions
?.
key
?
`${conditions.key} = ${conditions.value || '-'}`
:
'当前没有预设显示条件'
}
icon=
{
Filter
}
tone=
"warning"
/><
Metric
label=
"分页设置"
value=
{
pageSize
}
hint=
{
`当前第 ${page} / ${totalPages} 页`
}
icon=
{
FieldTime
}
tone=
"teal"
/></
div
>
<
div
className=
"rc-kpi-grid"
data
-
count=
"4"
><
Metric
label=
"模板实例"
value=
{
total
}
hint=
"当前模板下命中的提交流程数量"
icon=
{
DataStore
}
tone=
"info"
/><
Metric
label=
"显示字段"
value=
{
fieldsDefinition
.
length
}
hint=
"由模板配置决定表格展开的动态列"
icon=
{
FileText
}
tone=
"success"
/><
Metric
label=
"条件筛选"
value=
{
conditions
?.
key
?
'已启用'
:
'未启用'
}
hint=
{
conditions
?.
key
?
`${conditions.key} = ${conditions.value || '-'}`
:
'当前没有预设显示条件'
}
icon=
{
Filter
}
tone=
"warning"
/><
Metric
label=
"分页设置"
value=
{
pageSize
}
hint=
{
`当前第 ${page} / ${totalPages} 页`
}
icon=
{
FieldTime
}
tone=
"teal"
/></
div
>
...
@@ -175,8 +219,8 @@ export default function ProcessTemplateListPage() {
...
@@ -175,8 +219,8 @@ export default function ProcessTemplateListPage() {
<
div
className=
"rc-list-action-bar"
><
div
className=
"flex items-center gap-1.5"
><
h2
>
实例列表
</
h2
><
TitleInfo
label=
"实例列表"
content=
"按模板展示实际提交的流程实例,可通过操作列查看单条详情。"
/></
div
></
div
>
<
div
className=
"rc-list-action-bar"
><
div
className=
"flex items-center gap-1.5"
><
h2
>
实例列表
</
h2
><
TitleInfo
label=
"实例列表"
content=
"按模板展示实际提交的流程实例,可通过操作列查看单条详情。"
/></
div
></
div
>
<
div
className=
"flex flex-wrap items-center justify-between gap-3"
><
SearchField
aria
-
label=
"搜索表单名称"
className=
"w-full sm:w-96"
value=
{
keyword
}
onChange=
{
(
value
)
=>
{
setPage
(
1
);
setKeyword
(
value
);
}
}
><
SearchField
.
Group
><
SearchField
.
SearchIcon
><
Search
size=
{
16
}
/></
SearchField
.
SearchIcon
><
SearchField
.
Input
placeholder=
"搜索表单名称"
/><
SearchField
.
ClearButton
aria
-
label=
"清空关键词"
><
Close
size=
{
14
}
/></
SearchField
.
ClearButton
></
SearchField
.
Group
></
SearchField
></
div
>
<
div
className=
"flex flex-wrap items-center justify-between gap-3"
><
SearchField
aria
-
label=
"搜索表单名称"
className=
"w-full sm:w-96"
value=
{
keyword
}
onChange=
{
(
value
)
=>
{
setPage
(
1
);
setKeyword
(
value
);
}
}
><
SearchField
.
Group
><
SearchField
.
SearchIcon
><
Search
size=
{
16
}
/></
SearchField
.
SearchIcon
><
SearchField
.
Input
placeholder=
"搜索表单名称"
/><
SearchField
.
ClearButton
aria
-
label=
"清空关键词"
><
Close
size=
{
14
}
/></
SearchField
.
ClearButton
></
SearchField
.
Group
></
SearchField
></
div
>
<
div
className=
"rc-list-data-stack"
>
<
div
className=
"rc-list-data-stack"
>
<
section
className=
"rc-list-data-grid rc-data-table-sticky"
><
DataGrid
allowsColumnResize
aria
-
label=
"流程实例列表"
className=
"pm-data-grid"
columns=
{
instanceColumns
}
contentClassName=
"min-w-[760px]"
data=
{
l
oading
?
[]
:
instances
}
getRowId=
{
(
record
)
=>
record
.
_id
||
record
.
name
}
renderEmptyState=
{
()
=>
loading
?
<
div
className=
"py-8 text-sm text-muted"
>
正在加载模板实例...
</
div
>
:
<
DataEmptyState
/>
}
scrollContainerClassName=
"data-table-scroll"
verticalAlign=
"middle"
/></
section
>
<
section
className=
"rc-list-data-grid rc-data-table-sticky"
><
DataGrid
allowsColumnResize
aria
-
label=
"流程实例列表"
className=
"pm-data-grid"
columns=
{
instanceColumns
}
contentClassName=
"min-w-[760px]"
data=
{
l
istLoading
||
loadFailed
?
[]
:
instances
}
getRowId=
{
(
record
)
=>
record
.
_id
||
record
.
name
}
renderEmptyState=
{
()
=>
listLoading
?
<
div
className=
"py-8 text-sm text-muted"
>
正在加载模板实例...
</
div
>
:
loadFailed
?
<
EmptyState
size=
"sm"
><
EmptyState
.
Header
><
EmptyState
.
Title
>
流程实例列表加载失败
</
EmptyState
.
Title
><
EmptyState
.
Description
>
详细错误见操作提示,请重新加载。
</
EmptyState
.
Description
></
EmptyState
.
Header
><
EmptyState
.
Content
><
Button
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
setRetryKey
((
key
)
=>
key
+
1
)
}
>
重新加载
</
Button
></
EmptyState
.
Content
></
EmptyState
>
:
<
DataEmptyState
/>
}
scrollContainerClassName=
"data-table-scroll"
verticalAlign=
"middle"
/></
section
>
{
!
l
oading
?
<
div
className=
"pm-pagination-bar"
><
PagePagination
page=
{
page
}
pageSize=
{
pageSize
}
total=
{
total
}
onPageChange=
{
setPage
}
onPageSizeChange=
{
(
nextPageSize
)
=>
{
if
(
PAGE_SIZE_OPTIONS
.
includes
(
nextPageSize
))
{
setPageSize
(
nextPageSize
);
setPage
(
1
);
}
}
}
/></
div
>
:
null
}
{
!
l
istLoading
&&
!
loadFailed
?
<
div
className=
"pm-pagination-bar"
><
PagePagination
page=
{
page
}
pageSize=
{
pageSize
}
total=
{
total
}
onPageChange=
{
setPage
}
onPageSizeChange=
{
(
nextPageSize
)
=>
{
if
(
PAGE_SIZE_OPTIONS
.
includes
(
nextPageSize
))
{
setPageSize
(
nextPageSize
);
setPage
(
1
);
}
}
}
/></
div
>
:
null
}
</
div
>
</
div
>
</
section
>
</
section
>
...
...
src/pages/process_monitor/process-monitor.business-flow.md
View file @
4c7cbb86
...
@@ -208,3 +208,16 @@ Navbar 的 Bell 进入 `/notifications`。OA流程监控不在六个完整功能
...
@@ -208,3 +208,16 @@ Navbar 的 Bell 进入 `/notifications`。OA流程监控不在六个完整功能
通知中的站内 URL 只剥离
`/oa_process_monitor_new/`
后交给 Umi history;其他应用或外部
通知中的站内 URL 只剥离
`/oa_process_monitor_new/`
后交给 Umi history;其他应用或外部
HTTP(S) 链接新窗口打开。
`content`
始终作为纯文本渲染,不执行服务端 HTML。Navbar 首次加载
HTTP(S) 链接新窗口打开。
`content`
始终作为纯文本渲染,不执行服务端 HTML。Navbar 首次加载
及后续每 60 秒调用
`/notice/customer/count`
,真实 0 条时不显示徽标,100 及以上显示
`99+`
。
及后续每 60 秒调用
`/notice/customer/count`
,真实 0 条时不显示徽标,100 及以上显示
`99+`
。
## 10. 接口操作反馈与失败恢复
入口覆盖登录与验证码、分组总览、模板实例列表、流程详情、通知中心和 Navbar 应用切换弹窗。
-
新建分组、添加模板、编辑配置、取消监控、删除空分组、保存显示字段、附件下载及通知操作的成功或失败统一通过全局 HeroUI Toast 提示,不再在页面顶部追加常驻横条。Toast 位于业务弹窗和移动侧栏之上,页面滚动不影响其可见性。
-
成功提示默认 4 秒后消失;错误和降级警告默认 8 秒后消失,悬停或键盘聚焦暂停计时。当前提示右侧的关闭图标始终可见,使用 HeroUI 默认小号透明按钮(
`ghost`
/
`sm`
/
`isIconOnly`
),点击或键盘激活通过 Toast 的
`close`
slot 关闭;后排堆叠提示隐藏关闭按钮,避免误关闭。关闭与超时仅收起提示,不修改接口结果、失败状态、输入内容、选择项或弹窗状态;失败后可在原弹窗修正内容再次提交,相同错误也会重新提示。
-
保存入口为分组总览的“编辑模板监控配置”和模板实例列表的“定义显示字段”弹窗。只有
`PUT /record/:name`
(
`updateRecord`
)成功后才关闭弹窗,分别弹出“模板配置已保存。”或“显示字段配置已保存。”;显示字段同时更新当前列表列。模板配置在保存成功时立即反馈,随后重读列表;重读失败由加载错误和重试入口单独反馈。
-
保存成功提示通过
`useFeedbackState('success')`
按来源管理;重新打开编辑、再次保存或离开页面时清理上一条提示,模板实例页切换模板或重新加载时也清理。正在保存时不重复提交;模板标题为空或缺少记录 ID 时不提交、不提示成功。保存失败只提示错误并保留弹窗及草稿。写入对象为
`audit_related_progress`
,核心字段仍为
`title`
、
`metadata.department`
、
`metadata.template_code`
、
`metadata.conditions`
和
`metadata.fields_definition`
;流程实例详情维持第 5.3 节的查看流程。
-
分组总览、模板实例列表、流程详情和通知列表加载失败时保留 EmptyState 与重新加载入口,隐藏失败数据对应的列表或详情;关闭错误 Toast 后仍可重试,不把失败当作空数据。实例列表先读取模板与预设条件,再按当前关键字、页码、每页条数和条件查询实例;旧请求返回时不覆盖新请求结果。
-
模板显示字段保存或附件下载失败只反馈本次操作错误,不把已加载列表切换为加载失败态;保存失败保留字段草稿。单个附件下载失败同样显示可关闭的错误提示。
-
应用列表接口失败时通过警告 Toast 告知已回退当前账号的可访问应用;站点配置加载失败沿用原有默认配置回退并通过警告 Toast 提示。
-
本次仅调整反馈呈现与重试交互,记录写入仍使用
`metadata`
,对象仍为
`process_monitor_group`
、
`audit_related_progress`
、
`audit_progress_info`
、
`oa_member`
和
`oa_department`
;Record API、通知接口、字段规则和权限保持现有协议。
src/tailwind.css
View file @
4c7cbb86
...
@@ -4,6 +4,7 @@
...
@@ -4,6 +4,7 @@
@import
"./design-system/theme.css"
;
@import
"./design-system/theme.css"
;
@import
"./design-system/tokens.css"
;
@import
"./design-system/tokens.css"
;
@import
"./design-system/shell.css"
;
@import
"./design-system/shell.css"
;
@import
"./design-system/feedback.css"
;
@source
"./*.{js,jsx,ts,tsx}"
;
@source
"./*.{js,jsx,ts,tsx}"
;
@source
"./components/**/*.{js,jsx,ts,tsx}"
;
@source
"./components/**/*.{js,jsx,ts,tsx}"
;
...
...
src/wrapper/Site.jsx
View file @
4c7cbb86
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
getSystemInfo
}
from
'@/services/DataService'
;
import
{
getSystemInfo
}
from
'@/services/DataService'
;
import
{
Spinner
}
from
'@heroui/react'
;
import
{
Spinner
}
from
'@heroui/react'
;
import
dayjs
from
'dayjs'
;
import
dayjs
from
'dayjs'
;
...
@@ -19,7 +20,7 @@ export default function SiteWrapper() {
...
@@ -19,7 +20,7 @@ export default function SiteWrapper() {
const
[
siteInfo
,
setSiteInfo
]
=
useState
(
null
);
const
[
siteInfo
,
setSiteInfo
]
=
useState
(
null
);
const
[
metadata
,
setMetadata
]
=
useState
(
null
);
const
[
metadata
,
setMetadata
]
=
useState
(
null
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
loadError
,
setLoadError
]
=
use
State
(
'
'
);
const
[
loadError
,
setLoadError
]
=
use
FeedbackState
(
'warning
'
);
useEffect
(()
=>
{
useEffect
(()
=>
{
let
cancelled
=
false
;
let
cancelled
=
false
;
...
@@ -55,7 +56,7 @@ export default function SiteWrapper() {
...
@@ -55,7 +56,7 @@ export default function SiteWrapper() {
return
()
=>
{
return
()
=>
{
cancelled
=
true
;
cancelled
=
true
;
};
};
},
[]);
},
[
setLoadError
]);
if
(
loading
)
{
if
(
loading
)
{
return
(
return
(
...
...
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