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
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
208 additions
and
57 deletions
+208
-57
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
+0
-0
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 @@
-
DataGrid 使用原生 selection、排序、固定列、横向滚动、ActionBar、EmptyState 和 compound Pagination。
-
流程业务列表默认每页 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 标识。
...
...
public/manifest.json
View file @
4c7cbb86
{
"name"
:
"oa_process_monitor"
,
"version"
:
"1.0.
4
"
,
"version"
:
"1.0.
5
"
,
"build"
:
"abc"
,
"description"
:
"oa 流程监控"
,
"date"
:
"2025-07-14 19:27:20"
,
...
...
src/app.js
View file @
4c7cbb86
import
'@/global.less'
;
import
'@/tailwind.css'
;
import
{
I18nProvider
}
from
'@heroui/react'
;
import
AppFeedback
from
'@/components/AppFeedback'
;
import
{
createElement
}
from
'react'
;
import
{
defineApp
}
from
'umi'
;
...
...
@@ -12,6 +13,10 @@ export async function getInitialState() {
export
default
defineApp
({
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
{
Toast
}
from
'@heroui/react'
;
import
{
history
}
from
'umi'
;
import
DashboardNavbar
from
'./DashboardNavbar'
;
import
DashboardSidebar
from
'./DashboardSidebar'
;
...
...
@@ -36,7 +35,6 @@ export default function AppShell({
<
div
className=
"rc-app-page"
>
{
children
}
</
div
>
</
div
>
</
Sidebar
.
Main
>
<
Toast
.
Provider
maxVisibleToasts=
{
3
}
placement=
"top"
/>
</
Sidebar
.
Provider
>
);
}
src/components/ApplicationSwitcherDialog.jsx
View file @
4c7cbb86
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
xinyuanLogo
from
'@/assets/xy-logo-red.png'
;
import
{
APPLICATION_BASE_PATH
,
...
...
@@ -5,12 +6,11 @@ import {
APPLICATION_NAME
,
}
from
'@/config/application'
;
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
{
ItemCard
}
from
'@heroui-pro/react/item-card'
;
import
{
ItemCardGroup
}
from
'@heroui-pro/react/item-card-group'
;
import
{
Alert
,
Avatar
,
Chip
,
Modal
,
...
...
@@ -312,7 +312,7 @@ export default function ApplicationSwitcherDialog({
const
[
status
,
setStatus
]
=
useState
(
fallbackApplications
.
length
>
0
?
'success'
:
'idle'
,
);
const
[
errorMessage
,
setErrorMessage
]
=
useState
(
'
'
);
const
[
,
setErrorMessage
]
=
useFeedbackState
(
'warning
'
);
const
[
applicationSearch
,
setApplicationSearch
]
=
useState
(
''
);
const
currentApplicationName
=
useMemo
(()
=>
{
return
applications
.
find
(
isCurrentApplication
)?.
name
||
CURRENT_APPLICATION_NAME
;
...
...
@@ -392,7 +392,7 @@ export default function ApplicationSwitcherDialog({
return
()
=>
{
cancelled
=
true
;
};
},
[
fallbackApplications
,
open
]);
},
[
fallbackApplications
,
open
,
setErrorMessage
]);
return
(
<
Modal
.
Root
>
...
...
@@ -443,13 +443,6 @@ export default function ApplicationSwitcherDialog({
offset=
{
12
}
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
?
(
<
LoadingSkeleton
/>
)
:
filteredApplicationSections
.
length
>
0
?
(
...
...
src/components/ProcessInstanceDetailContent.jsx
View file @
4c7cbb86
...
...
@@ -8,13 +8,14 @@ import {
import
{
Button
,
Surface
}
from
'@heroui/react'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
FileText
}
from
'@/components/AppIcons'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
useMemo
}
from
'react'
;
function
resolveDetailValue
(
field
,
fieldMap
,
lookups
)
{
function
resolveDetailValue
(
field
,
fieldMap
,
lookups
,
onDownload
)
{
if
(
field
?.
type
===
'file'
)
{
const
files
=
Array
.
isArray
(
field
?.
value
)
?
field
.
value
:
[];
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
];
...
...
@@ -35,6 +36,11 @@ function DetailField({ label, children, fullWidth = 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
fieldsDefinition
=
data
?.
fieldsDefinition
||
[];
const
members
=
data
?.
members
||
[];
...
...
@@ -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
?.
department
||
<
span
className=
"text-muted"
>
暂无内容
</
span
>
}
</
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
>;
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
xyLogoWhite
from
'@/assets/xy-logo-white.png'
;
import
{
...
...
@@ -7,7 +8,7 @@ import {
import
authService
from
'@/services/authService'
;
import
{
getCaptcha
,
userLogin
}
from
'@/services/DataService'
;
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
{
useContext
,
useEffect
,
useState
}
from
'react'
;
import
{
history
,
useSearchParams
}
from
'umi'
;
...
...
@@ -55,7 +56,7 @@ export default function LoginPage() {
const
[
captchaData
,
setCaptchaData
]
=
useState
(
null
);
const
[
captchaLoading
,
setCaptchaLoading
]
=
useState
(
false
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
'
'
);
const
[
,
setError
]
=
useFeedbackState
(
'error
'
);
const
redirect
=
normalizeRedirect
(
searchParams
.
get
(
'redirect'
));
const
refreshCaptcha
=
async
()
=>
{
...
...
@@ -205,14 +206,6 @@ export default function LoginPage() {
</
div
>
</
div
>
{
error
?
(
<
Alert
status=
"danger"
>
<
Alert
.
Content
>
<
Alert
.
Description
>
{
error
}
</
Alert
.
Description
>
</
Alert
.
Content
>
</
Alert
>
)
:
null
}
<
Button
fullWidth
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
{
useUserInfo
}
from
'@/hooks/user'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Alert
,
Avatar
,
Button
,
Card
,
...
...
@@ -205,7 +205,7 @@ export default function NotificationListPage() {
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
unreadTotal
,
setUnreadTotal
]
=
useState
(
0
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
error
,
setError
]
=
use
State
(
'
'
);
const
[
error
,
setError
]
=
use
FeedbackState
(
'error
'
);
const
[
busyKey
,
setBusyKey
]
=
useState
(
''
);
const
[
reloadKey
,
setReloadKey
]
=
useState
(
0
);
...
...
@@ -247,7 +247,7 @@ export default function NotificationListPage() {
return
()
=>
{
cancelled
=
true
;
};
},
[
category
,
keyword
,
page
,
readStatus
,
reloadKey
]);
},
[
category
,
keyword
,
page
,
readStatus
,
reloadKey
,
setError
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
...
...
@@ -278,12 +278,12 @@ export default function NotificationListPage() {
setBusyKey
(
key
);
try
{
await
operation
();
toast
.
success
(
successMessage
,
{
timeout
:
3
000
});
toast
.
success
(
successMessage
,
{
timeout
:
4
000
});
refresh
();
return
true
;
}
catch
(
operationError
)
{
toast
.
danger
(
operationError
?.
message
||
'操作失败,请稍后重试'
,
{
timeout
:
4
000
,
timeout
:
8
000
,
});
return
false
;
}
finally
{
...
...
@@ -333,7 +333,7 @@ export default function NotificationListPage() {
refresh
();
}
catch
(
operationError
)
{
toast
.
danger
(
operationError
?.
message
||
'无法打开通知关联流程'
,
{
timeout
:
4
000
,
timeout
:
8
000
,
});
}
finally
{
setBusyKey
(
''
);
...
...
@@ -418,14 +418,15 @@ export default function NotificationListPage() {
</
div
>
{
error
?
(
<
Alert
status=
"danger"
>
<
Alert
.
Content
>
<
Alert
.
Description
>
{
error
}
</
Alert
.
Description
>
</
Alert
.
Content
>
<
Button
size=
"sm"
variant=
"ghost"
onPress=
{
refresh
}
>
重试
</
Button
>
</
Alert
>
<
EmptyState
className=
"min-h-64"
size=
"md"
>
<
EmptyState
.
Header
>
<
EmptyState
.
Title
>
通知加载失败
</
EmptyState
.
Title
>
<
EmptyState
.
Description
>
详细错误见操作提示,请重新加载。
</
EmptyState
.
Description
>
</
EmptyState
.
Header
>
<
EmptyState
.
Content
>
<
Button
variant=
"secondary"
onPress=
{
refresh
}
>
重试
</
Button
>
</
EmptyState
.
Content
>
</
EmptyState
>
)
:
null
}
{
loading
&&
notifications
.
length
===
0
?
(
...
...
src/pages/process_monitor/ProcessDetail.jsx
View file @
4c7cbb86
...
...
@@ -2,33 +2,53 @@ import {
loadProcessInstanceDetail
,
}
from
'@/pages/process_monitor/processMonitorService'
;
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
{
useParams
}
from
'umi'
;
export
default
function
ProcessDetailPage
()
{
const
{
application
}
=
useParams
();
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
[
fieldsDefinition
,
setFieldsDefinition
]
=
useState
([]);
const
[
members
,
setMembers
]
=
useState
([]);
const
[
departments
,
setDepartments
]
=
useState
([]);
useEffect
(()
=>
{
let
cancelled
=
false
;
setLoading
(
true
);
setErrorMessage
(
''
);
loadProcessInstanceDetail
(
application
).
then
((
result
)
=>
{
if
(
cancelled
)
return
;
setRecord
(
result
.
record
);
setFieldsDefinition
(
result
.
fieldsDefinition
);
setMembers
(
result
.
members
);
setDepartments
(
result
.
departments
);
setErrorMessage
(
''
);
}).
catch
((
error
)
=>
setErrorMessage
(
error
?.
message
||
'加载流程详情失败'
)).
finally
(()
=>
setLoading
(
false
));
},
[
application
]);
}).
catch
((
error
)
=>
{
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
]);
return
(
<
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
>
<
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
>
);
}
src/pages/process_monitor/ProcessMonitor.jsx
View file @
4c7cbb86
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
useAuthorization
}
from
'@/hooks/user'
;
import
{
Check
,
Close
,
DataStore
,
Delete
,
Edit
,
Eye
,
FileText
,
FilterControls
,
FolderOpen
,
PlusIcon
,
Search
,
Team
}
from
'@/components/AppIcons'
;
import
{
TableActionButton
,
TableActionGroup
}
from
'@/components/TableActionButton'
;
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
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
KPI
}
from
'@heroui-pro/react/kpi'
;
...
...
@@ -92,7 +93,9 @@ export default function ProcessMonitorPage() {
const
[
departmentFilter
,
setDepartmentFilter
]
=
useState
(
''
);
const
[
listPage
,
setListPage
]
=
useState
(
0
);
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
[
createGroupTitle
,
setCreateGroupTitle
]
=
useState
(
''
);
const
[
addTemplateOpen
,
setAddTemplateOpen
]
=
useState
(
false
);
...
...
@@ -107,6 +110,8 @@ export default function ProcessMonitorPage() {
const
loadPage
=
async
(
preferredGroupName
)
=>
{
setLoading
(
true
);
setLoadFailed
(
false
);
setErrorMessage
(
''
);
try
{
const
result
=
await
loadProcessMonitorOverview
();
setGroups
(
result
.
groups
);
...
...
@@ -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
;
setActiveGroupName
(
nextActiveGroup
?.
name
||
''
);
setErrorMessage
(
''
);
}
catch
(
error
)
{
setErrorMessage
(
error
?.
message
||
'加载流程监控数据失败'
);
}
}
catch
(
error
)
{
set
LoadFailed
(
true
);
set
ErrorMessage
(
error
?.
message
||
'加载流程监控数据失败'
);
}
finally
{
setLoading
(
false
);
}
};
...
...
@@ -133,6 +138,7 @@ export default function ProcessMonitorPage() {
useEffect
(()
=>
{
setListPage
(
0
);
},
[
activeGroupName
,
departmentFilter
,
templateKeyword
,
pageSize
]);
const
handleCreateGroup
=
async
()
=>
{
setErrorMessage
(
''
);
if
(
!
createGroupTitle
.
trim
())
{
setErrorMessage
(
'请先填写分组名称'
);
return
;
}
setSubmitting
(
true
);
try
{
...
...
@@ -145,6 +151,7 @@ export default function ProcessMonitorPage() {
};
const handleAddTemplates = async () => {
setErrorMessage('');
if (!activeGroup) { setErrorMessage('请先创建或选择一个分组'); return; }
const selectedTemplates = pickerTemplates.filter((template) => selectedTemplateIds.includes(template._id));
if (!selectedTemplates.length) { setErrorMessage('请至少选择一个流程模板'); return; }
...
...
@@ -158,21 +165,33 @@ export default function ProcessMonitorPage() {
};
const openEditDialog = (template) => {
setErrorMessage('');
setSaveSuccess('');
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 || '' });
setEditTemplateOpen(true);
};
const handleUpdateTemplate = async () => {
if (submitting) return;
setErrorMessage('');
setSaveSuccess('');
if (!editingTemplate?._id) return;
if (!templateForm.title.trim()) { setErrorMessage('模板标题不能为空'); return; }
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 || '模板更新失败'); }
finally { setSubmitting(false); }
};
const handleRemoveTemplate = async () => {
setErrorMessage('');
if (!activeGroup || !removeTarget) return;
setSubmitting(true);
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() {
};
const handleRemoveGroup = async () => {
setErrorMessage('');
if (!removeGroupTarget) return;
const nextGroup = groups.find((group) => group.name !== removeGroupTarget.name) || null;
setSubmitting(true);
...
...
@@ -208,7 +228,6 @@ export default function ProcessMonitorPage() {
return (
<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">
<Metric label="监控分组" value={groups.length} hint="按业务视角组织模板监控范围" icon={Team} tone="info" />
...
...
@@ -240,8 +259,8 @@ export default function ProcessMonitorPage() {
</div>
<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>
{!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}
<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
&& !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>
</section>
...
...
src/pages/process_monitor/ProcessTemplateList.jsx
View file @
4c7cbb86
This diff is collapsed.
Click to expand it.
src/pages/process_monitor/process-monitor.business-flow.md
View file @
4c7cbb86
...
...
@@ -208,3 +208,16 @@ Navbar 的 Bell 进入 `/notifications`。OA流程监控不在六个完整功能
通知中的站内 URL 只剥离
`/oa_process_monitor_new/`
后交给 Umi history;其他应用或外部
HTTP(S) 链接新窗口打开。
`content`
始终作为纯文本渲染,不执行服务端 HTML。Navbar 首次加载
及后续每 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 @@
@import
"./design-system/theme.css"
;
@import
"./design-system/tokens.css"
;
@import
"./design-system/shell.css"
;
@import
"./design-system/feedback.css"
;
@source
"./*.{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
{
Spinner
}
from
'@heroui/react'
;
import
dayjs
from
'dayjs'
;
...
...
@@ -19,7 +20,7 @@ export default function SiteWrapper() {
const
[
siteInfo
,
setSiteInfo
]
=
useState
(
null
);
const
[
metadata
,
setMetadata
]
=
useState
(
null
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
loadError
,
setLoadError
]
=
use
State
(
'
'
);
const
[
loadError
,
setLoadError
]
=
use
FeedbackState
(
'warning
'
);
useEffect
(()
=>
{
let
cancelled
=
false
;
...
...
@@ -55,7 +56,7 @@ export default function SiteWrapper() {
return
()
=>
{
cancelled
=
true
;
};
},
[]);
},
[
setLoadError
]);
if
(
loading
)
{
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