Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
I
integrity_practice_management_new
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
David Yang
integrity_practice_management_new
Commits
0c1be1bb
Commit
0c1be1bb
authored
Oct 08, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 统一操作反馈并完善详情保存提示
新增全局 Toast 容器、反馈服务和状态 Hook,统一各页面操作反馈及加载失败重试。同步材料来源应用名称、业务流程和设计文档,并更新应用版本。
parent
fb868f94
Hide whitespace changes
Inline
Side-by-side
Showing
25 changed files
with
276 additions
and
122 deletions
+276
-122
xinyuan-design-system.md
docs/xinyuan-design-system.md
+8
-0
manifest.json
public/manifest.json
+1
-1
app.js
src/app.js
+7
-1
AppFeedback.jsx
src/components/AppFeedback.jsx
+44
-0
AppShell.jsx
src/components/AppShell.jsx
+0
-2
ApplicationSwitcherDialog.jsx
src/components/ApplicationSwitcherDialog.jsx
+3
-10
ReferenceSearchSelect.jsx
src/components/ReferenceSearchSelect.jsx
+2
-1
index.jsx
src/components/record-workbench/index.jsx
+10
-3
useFeedbackState.js
src/hooks/useFeedbackState.js
+38
-0
Login.jsx
src/pages/Login.jsx
+3
-10
ReferenceRecordsTable.jsx
...s/RecordDetailDrawer/components/ReferenceRecordsTable.jsx
+4
-6
ReferenceSearchSelect.jsx
...s/RecordDetailDrawer/components/ReferenceSearchSelect.jsx
+3
-2
TailwindEditableField.jsx
...s/RecordDetailDrawer/components/TailwindEditableField.jsx
+12
-6
useSubjectRecord.js
src/pages/RecordDetailDrawer/hooks/useSubjectRecord.js
+3
-2
index.jsx
src/pages/RecordDetailDrawer/index.jsx
+16
-11
SelfInspectionFlowPage.jsx
.../integrity_practice_management/SelfInspectionFlowPage.jsx
+16
-5
SubjectWorkbench.jsx
src/pages/integrity_practice_management/SubjectWorkbench.jsx
+15
-18
UserAssignmentDialog.jsx
...es/integrity_practice_management/UserAssignmentDialog.jsx
+10
-4
IntegrityPracticeMaterialField.jsx
..._management/components/IntegrityPracticeMaterialField.jsx
+8
-22
integrity-practice-management.business-flow.md
...management/integrity-practice-management.business-flow.md
+12
-2
integrityPracticeMaterialService.js
...y_practice_management/integrityPracticeMaterialService.js
+2
-1
NotificationListPage.jsx
...ractice_management/notifications/NotificationListPage.jsx
+13
-14
feedbackService.js
src/services/feedbackService.js
+18
-0
tailwind.css
src/tailwind.css
+26
-0
Site.jsx
src/wrapper/Site.jsx
+2
-1
No files found.
docs/xinyuan-design-system.md
View file @
0c1be1bb
...
...
@@ -21,6 +21,14 @@ Inter 禁止通过 `src/tailwind.css` 导入。Tailwind v4 内容扫描必须排
页面
`className`
仅承担 flex、grid、宽度、间距、对齐、滚动和响应式。背景、颜色、描边、圆角、阴影及交互状态由官方 variant、compound API 和上述共享样式负责。
## 操作反馈浮层
接口成功、失败及降级警告统一使用应用根部
`AppFeedback`
的 HeroUI
`Toast.Provider`
;登录、独立流程页和 AppShell 共用一个容器。最多显示 3 条,成功默认 4 秒、错误/警告默认 8 秒后消失,关闭不改变请求结果或表单草稿。
同步问题整改应用
`bfc5862`
的关闭按钮:组合官方 HeroUI
`Button`
(
`ghost`
、
`sm`
、
`isIconOnly`
)和
`AppIcons.Close`
,通过
`slot="close"`
接入关闭行为。按钮在正文右侧参与布局,前排图标始终可见,后排堆叠提示隐藏关闭按钮;不设置额外绝对位置、边框或正文右侧占位,沿用官方 Hover、焦点和主题样式。
`src/tailwind.css`
负责安全区位置、层级 300、14px 圆角、局部进入动画和后排按钮可见性,保证提示高于业务弹窗和移动侧栏。保留独立
`feedbackQueue`
的同步更新,不触发整页 View Transition,关闭提示不会误点弹窗遮罩。详情保存成功反馈使用
`useFeedbackState('success')`
,随编辑会话和记录切换清理。
## 品牌与图表色板
-
鑫元品牌主色固定为
`#DA241A`
。HeroUI v3 的
`--accent`
、
`--accent-hover`
、
`--focus`
以及共享
`--rc-brand-primary`
均不得被页面或深色主题改写为其他色相;primary 色阶仅作为兼容别名维护。
...
...
public/manifest.json
View file @
0c1be1bb
{
"name"
:
"integrity_practice_management_new"
,
"version"
:
"1.0.
9
"
,
"version"
:
"1.0.
11
"
,
"build"
:
"abc"
,
"description"
:
"廉洁从业管理"
,
"date"
:
"2025-07-14 19:27:20"
,
...
...
src/app.js
View file @
0c1be1bb
import
'@/global.less'
;
import
'@/tailwind.css'
;
import
{
defineApp
}
from
'umi'
;
import
{
createElement
}
from
'react'
;
import
AppFeedback
from
'@/components/AppFeedback'
;
import
{
APPLICATION_BASE_PATH
}
from
'@/config/application'
;
if
(
typeof
document
!==
'undefined'
)
{
...
...
@@ -26,4 +28,8 @@ export async function getInitialState() {
};
}
export
default
defineApp
({});
export
function
rootContainer
(
container
)
{
return
createElement
(
AppFeedback
,
null
,
container
);
}
export
default
defineApp
({
rootContainer
});
src/components/AppFeedback.jsx
0 → 100644
View file @
0c1be1bb
import
{
Button
,
Toast
}
from
'@heroui/react'
;
import
{
Close
}
from
'@/components/AppIcons'
;
import
{
feedbackQueue
}
from
'@/services/feedbackService'
;
export
default
function
AppFeedback
({
children
})
{
return
(
<>
{
children
}
<
Toast
.
Provider
aria
-
label=
"操作提示"
className=
"app-feedback-region"
maxVisibleToasts=
{
3
}
placement=
"top"
queue=
{
feedbackQueue
}
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
>
{
toast
.
content
.
title
}
</
Toast
.
Title
>
{
toast
.
content
.
description
?
(
<
Toast
.
Description
className=
"max-h-[40dvh] overflow-y-auto whitespace-pre-wrap break-words text-foreground"
>
{
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 @
0c1be1bb
import
{
Sidebar
}
from
'@heroui-pro/react/sidebar'
;
import
{
Toast
}
from
'@heroui/react'
;
import
{
history
}
from
'umi'
;
import
DashboardNavbar
from
'./DashboardNavbar'
;
import
DashboardSidebar
from
'./DashboardSidebar'
;
...
...
@@ -24,7 +23,6 @@ export default function AppShell({ children, notificationCount, notificationPath
<
div
className=
"rc-app-page"
>
{
children
}
</
div
>
</
div
>
</
Sidebar
.
Main
>
<
Toast
.
Provider
maxVisibleToasts=
{
3
}
placement=
"top"
/>
</
Sidebar
.
Provider
>
);
}
src/components/ApplicationSwitcherDialog.jsx
View file @
0c1be1bb
...
...
@@ -6,12 +6,12 @@ import {
}
from
'@/config/application'
;
import
{
getApplications
}
from
'@/services/DataService'
;
import
{
EmptyStateSurface
}
from
'@/components/DataTable'
;
import
{
ExclamationTriangleIcon
,
MagnifyingGlassIcon
}
from
'@/components/AppIcons'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
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
,
...
...
@@ -313,7 +313,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
;
...
...
@@ -444,13 +444,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/ReferenceSearchSelect.jsx
View file @
0c1be1bb
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
queryRecords
}
from
'@/services/DataService'
;
import
{
Label
}
from
'@heroui/react'
;
import
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
...
...
@@ -304,7 +305,7 @@ export default function ReferenceSearchSelect({
const
[
options
,
setOptions
]
=
useState
([]);
const
[
selectedById
,
setSelectedById
]
=
useState
({});
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
use
State
(
''
);
const
[
error
,
setError
]
=
use
FeedbackState
(
);
const
[
active
,
setActive
]
=
useState
(
false
);
const
lastSearchRequestId
=
useRef
(
0
);
...
...
src/components/record-workbench/index.jsx
View file @
0c1be1bb
...
...
@@ -470,9 +470,16 @@ export default function RecordWorkbench({
</header>
) : null}
{states.warning ? <Alert status="warning"><Alert.Content><Alert.Description>{states.warning}</Alert.Description></Alert.Content></Alert> : null}
{states.error ? <Alert status="danger"><Alert.Content><Alert.Description>{states.error}</Alert.Description></Alert.Content></Alert> : null}
<div className="rc-list-data-stack rc-data-table-sticky relative min-w-0" ref={gridWrapRef}>
{accessDenied ? (
{!states.loading && states.error ? (
<EmptyState className="min-h-64" size="md">
<EmptyState.Header>
<EmptyState.Title>业务数据加载失败</EmptyState.Title>
<EmptyState.Description>请重新加载,详细错误见操作提示。</EmptyState.Description>
</EmptyState.Header>
{onQuery ? <EmptyState.Content><Button variant="secondary" onPress={onQuery}>重新加载</Button></EmptyState.Content> : null}
</EmptyState>
) : accessDenied ? (
<DataGridEmpty title="当前账号没有可访问的数据视图" />
) : (
<DataGrid
...
...
@@ -495,7 +502,7 @@ export default function RecordWorkbench({
)}
{states.loading && records.length > 0 ? <Surface className="absolute inset-0 grid place-items-center" variant="secondary"><Spinner size="sm" /></Surface> : null}
</div>
{!accessDenied ? <WorkbenchPagination pagination={pagination} /> : null}
{!accessDenied
&& !states.error
? <WorkbenchPagination pagination={pagination} /> : null}
</section>
<ActionBar aria-label="批量操作" isOpen={!accessDenied && selectedIds.length > 0}>
...
...
src/hooks/useFeedbackState.js
0 → 100644
View file @
0c1be1bb
import
toast
from
'@/services/feedbackService'
;
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
},
};
// Dismissing feedback does not clear request failure or validation state.
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 repeated attempt must show feedback even when the error text is unchanged.
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 @
0c1be1bb
...
...
@@ -7,7 +7,8 @@ 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
useFeedbackState
from
'@/hooks/useFeedbackState'
;
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
(
);
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/RecordDetailDrawer/components/ReferenceRecordsTable.jsx
View file @
0c1be1bb
import
{
useEffect
,
useLayoutEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
DataGrid
}
from
'@heroui-pro/react/data-grid'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Alert
,
Button
,
Spinner
,
Surface
,
Tooltip
}
from
'@heroui/react'
;
...
...
@@ -152,7 +153,7 @@ export default function ReferenceRecordsTable({
const
[
views
,
setViews
]
=
useState
([]);
const
[
records
,
setRecords
]
=
useState
([]);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
use
State
(
''
);
const
[
error
,
setError
]
=
use
FeedbackState
(
);
const
[
hasHorizontalOverflow
,
setHasHorizontalOverflow
]
=
useState
(
false
);
const
gridWrapRef
=
useRef
(
null
);
...
...
@@ -222,7 +223,7 @@ export default function ReferenceRecordsTable({
return
()
=>
{
cancelled
=
true
;
};
},
[
pageSize
,
populatedRecords
,
referenceIds
,
subjectName
]);
},
[
pageSize
,
populatedRecords
,
referenceIds
,
subjectName
,
setError
]);
const
columns
=
useMemo
(()
=>
buildColumns
(
subject
,
records
,
views
),
[
records
,
subject
,
views
]);
const
isSingleColumn
=
columns
.
length
===
1
;
...
...
@@ -311,9 +312,6 @@ export default function ReferenceRecordsTable({
</
Button
>
)
:
null
}
</
div
>
{
error
?
(
<
Alert
status=
"warning"
><
Alert
.
Content
><
Alert
.
Description
>
{
error
}
</
Alert
.
Description
></
Alert
.
Content
></
Alert
>
)
:
null
}
{
configurationMissing
?
(
<
Alert
status=
"warning"
><
Alert
.
Content
><
Alert
.
Description
>
引用字段缺少目标 Subject 配置,暂不能加载关联明细。
</
Alert
.
Description
></
Alert
.
Content
></
Alert
>
)
:
null
}
...
...
@@ -333,7 +331,7 @@ export default function ReferenceRecordsTable({
)
:
(
<
EmptyState
size=
"sm"
>
<
EmptyState
.
Header
>
<
EmptyState
.
Title
>
{
referenceIds
.
length
===
0
?
'暂无关联记录'
:
'暂无可展示的关联明细'
}
</
EmptyState
.
Title
>
<
EmptyState
.
Title
>
{
error
?
'关联记录加载失败,请重新打开详情'
:
referenceIds
.
length
===
0
?
'暂无关联记录'
:
'暂无可展示的关联明细'
}
</
EmptyState
.
Title
>
</
EmptyState
.
Header
>
</
EmptyState
>
)
}
...
...
src/pages/RecordDetailDrawer/components/ReferenceSearchSelect.jsx
View file @
0c1be1bb
import
{
ChevronDown
,
Close
,
Search
}
from
'@/components/AppIcons'
;
import
{
queryRecords
}
from
'@/services/DataService'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
getReferenceSubjectName
,
normalizeRecordId
,
...
...
@@ -33,7 +34,7 @@ export default function ReferenceSearchSelect({ fieldDef, value, onChange, place
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
options
,
setOptions
]
=
useState
([]);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
use
State
(
''
);
const
[
error
,
setError
]
=
use
FeedbackState
(
);
const
subjectName
=
getReferenceSubjectName
(
fieldDef
);
const
multiple
=
Boolean
(
fieldDef
?.
multiple
);
const
selectedValues
=
useMemo
(()
=>
toArray
(
value
).
filter
(
Boolean
),
[
value
]);
...
...
@@ -76,7 +77,7 @@ export default function ReferenceSearchSelect({ fieldDef, value, onChange, place
loadOptions
();
return
()
=>
{
cancelled
=
true
;
};
},
[
keyword
,
subjectName
]);
},
[
keyword
,
subjectName
,
setError
]);
const
menuOptions
=
useMemo
(
()
=>
uniqueRecords
([
...
...
src/pages/RecordDetailDrawer/components/TailwindEditableField.jsx
View file @
0c1be1bb
...
...
@@ -12,6 +12,7 @@ import {
}
from
'@heroui/react'
;
import
FormDatePicker
from
'@/components/FormDatePicker'
;
import
FileUploadField
from
'@/components/FileUploadField'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
queryUsers
,
uploadFile
}
from
'@/services/DataService'
;
import
ReferenceSearchSelect
from
'@/components/ReferenceSearchSelect'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
...
...
@@ -167,6 +168,7 @@ function FieldValue({ value, fieldDef }) {
function
UserSelector
({
value
,
onChange
,
multiple
,
variant
})
{
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
options
,
setOptions
]
=
useState
([]);
const
[
error
,
setError
]
=
useFeedbackState
();
const
selectedIds
=
useMemo
(
()
=>
toArray
(
value
).
map
((
item
)
=>
item
?.
_id
||
item
).
filter
(
Boolean
),
[
value
],
...
...
@@ -176,6 +178,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
let
cancelled
=
false
;
const
loadOptions
=
async
()
=>
{
setError
(
''
);
const
filter
=
keyword
.
trim
()
?
{
$or
:
[
...
...
@@ -193,6 +196,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
}
catch
(
error
)
{
if
(
!
cancelled
)
{
setOptions
([]);
setError
(
error
?.
message
||
'用户列表加载失败,请重新搜索。'
);
}
}
};
...
...
@@ -202,7 +206,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
return
()
=>
{
cancelled
=
true
;
};
},
[
keyword
]);
},
[
keyword
,
setError
]);
const
handleSelectionChange
=
(
keys
)
=>
{
if
(
!
multiple
)
{
...
...
@@ -232,6 +236,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
<
XinyuanAutocomplete
{
...
selectionProps
}
aria
-
label=
"用户"
errorText=
{
error
}
emptyText=
"暂无用户"
inputValue=
{
keyword
}
isClearable
...
...
@@ -318,6 +323,7 @@ function ControlledFieldEditor({
useAuditMaterialCenter
,
})
{
const
[
error
,
setError
]
=
useState
(
''
);
const
[,
setRequestError
]
=
useFeedbackState
();
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
fieldType
=
fieldDef
?.
type
||
'text'
;
const
isDateField
=
isDateFieldType
(
fieldType
);
...
...
@@ -337,7 +343,7 @@ function ControlledFieldEditor({
const
fileValue
=
uploaded
?.
file
||
uploaded
?.
data
||
uploaded
;
onChange
?.(
fieldDef
?.
multiple
?
[...
toArray
(
value
),
fileValue
]
:
fileValue
);
}
catch
(
uploadError
)
{
setError
(
uploadError
?.
message
||
'附件上传失败,请稍后重试。'
);
set
Request
Error
(
uploadError
?.
message
||
'附件上传失败,请稍后重试。'
);
}
finally
{
setUploading
(
false
);
onValidationChange
?.(
fieldPath
,
''
,
editSessionId
);
...
...
@@ -448,7 +454,6 @@ function ControlledFieldEditor({
description=
"上传该字段的附件。"
triggerLabel=
"选择附件"
/>
<
FieldError
>
{
error
}
</
FieldError
>
</
Card
.
Content
>
</
Card
>
);
...
...
@@ -552,6 +557,7 @@ export default function TailwindEditableField({
const
[
editing
,
setEditing
]
=
useState
(
false
);
const
[
draft
,
setDraft
]
=
useState
(
value
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[,
setRequestError
]
=
useFeedbackState
();
const
[
uploading
,
setUploading
]
=
useState
(
false
);
useEffect
(()
=>
{
...
...
@@ -608,7 +614,7 @@ export default function TailwindEditableField({
await
onSave
(
asSaveValue
(
nextValue
,
fieldDef
));
setEditing
(
false
);
}
catch
(
saveError
)
{
setError
(
saveError
?.
message
||
'保存失败,请稍后重试。'
);
set
Request
Error
(
saveError
?.
message
||
'保存失败,请稍后重试。'
);
}
};
...
...
@@ -641,7 +647,7 @@ export default function TailwindEditableField({
await
onSave
(
nextValue
);
setEditing
(
false
);
}
catch
(
uploadError
)
{
setError
(
uploadError
?.
message
||
'附件上传失败,请稍后重试。'
);
set
Request
Error
(
uploadError
?.
message
||
'附件上传失败,请稍后重试。'
);
}
finally
{
setUploading
(
false
);
}
...
...
@@ -662,7 +668,7 @@ export default function TailwindEditableField({
}
}
catch
(
saveError
)
{
setDraft
(
draft
);
setError
(
saveError
?.
message
||
'删除附件失败,请稍后重试。'
);
set
Request
Error
(
saveError
?.
message
||
'删除附件失败,请稍后重试。'
);
}
};
...
...
src/pages/RecordDetailDrawer/hooks/useSubjectRecord.js
View file @
0c1be1bb
import
{
useEffect
,
useRef
,
useState
}
from
'react'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
getSubjectView
,
getSubjectViewDetail
,
...
...
@@ -75,7 +76,7 @@ export function useSubjectRecord(
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = use
State(''
);
const [error, setError] = use
FeedbackState(
);
const requestIdRef = useRef(0);
useEffect(() => {
...
...
@@ -146,7 +147,7 @@ export function useSubjectRecord(
return () => {
cancelled = true;
};
}, [recordName, refreshKey, subjectName]);
}, [recordName, refreshKey, subjectName
, setError
]);
const handleFieldsUpdate = async (changes = []) => {
if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) {
...
...
src/pages/RecordDetailDrawer/index.jsx
View file @
0c1be1bb
import
FullscreenExitOutlined
from
'@ant-design/icons/FullscreenExitOutlined'
;
import
FullscreenOutlined
from
'@ant-design/icons/FullscreenOutlined'
;
import
{
Close
}
from
'@/components/AppIcons'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Alert
,
Button
,
Modal
,
Spinner
,
Tabs
,
Tooltip
}
from
'@heroui/react'
;
import
React
,
{
useCallback
,
...
...
@@ -76,7 +78,8 @@ const RecordDetailDrawer = (props, ref) => {
const
[
draftRecord
,
setDraftRecord
]
=
useState
(
null
);
const
[
dirtyFields
,
setDirtyFields
]
=
useState
({});
const
[
validationErrors
,
setValidationErrors
]
=
useState
({});
const
[
saveError
,
setSaveError
]
=
useState
(
''
);
const
[,
setSaveError
]
=
useFeedbackState
();
const
[,
setSaveSuccess
]
=
useFeedbackState
(
'success'
);
const
editSessionRef
=
useRef
(
0
);
const
initialModeAppliedRef
=
useRef
(
''
);
const
pendingLocalMaterialsRef
=
useRef
({});
...
...
@@ -110,6 +113,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields
({});
setValidationErrors
({});
setSaveError
(
''
);
setSaveSuccess
(
''
);
pendingLocalMaterialsRef
.
current
=
{};
initialModeAppliedRef
.
current
=
''
;
editSessionRef
.
current
+=
1
;
...
...
@@ -232,6 +236,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields
({});
setValidationErrors
({});
setSaveError
(
''
);
setSaveSuccess
(
''
);
pendingLocalMaterialsRef
.
current
=
{};
};
...
...
@@ -248,6 +253,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields
({});
setValidationErrors
({});
setSaveError
(
''
);
setSaveSuccess
(
''
);
pendingLocalMaterialsRef
.
current
=
{};
setIsEditing
(
true
);
};
...
...
@@ -332,6 +338,7 @@ const RecordDetailDrawer = (props, ref) => {
setIsSaving
(
true
);
setSaveError
(
''
);
setSaveSuccess
(
''
);
try
{
const
pendingMaterials
=
Object
.
entries
(
pendingLocalMaterialsRef
.
current
)
...
...
@@ -363,6 +370,7 @@ const RecordDetailDrawer = (props, ref) => {
setValidationErrors
({});
pendingLocalMaterialsRef
.
current
=
{};
onDataChange
?.(
true
);
setSaveSuccess
(
'详情已保存。'
);
}
catch
(
saveFailure
)
{
setSaveError
(
saveFailure
?.
message
||
'保存失败,请稍后重试。'
);
}
finally
{
...
...
@@ -457,18 +465,15 @@ const RecordDetailDrawer = (props, ref) => {
<
Spinner
size=
"sm"
/>
</
div
>
)
:
error
?
(
<
Alert
status=
"danger"
>
<
Alert
.
Content
><
Alert
.
Description
>
{
error
}
</
Alert
.
Description
></
Alert
.
Content
>
</
Alert
>
<
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=
{
()
=>
setRefreshKey
((
value
)
=>
value
+
1
)
}
>
重新加载
</
Button
></
EmptyState
.
Content
>
</
EmptyState
>
)
:
(
<
div
>
{
saveError
?
(
<
Alert
className=
"mb-4"
status=
"danger"
>
<
Alert
.
Content
>
<
Alert
.
Description
>
{
saveError
}
</
Alert
.
Description
>
</
Alert
.
Content
>
</
Alert
>
)
:
null
}
{
isRecordLocked
?
(
<
Alert
status=
"warning"
>
<
Alert
.
Content
>
...
...
src/pages/integrity_practice_management/SelfInspectionFlowPage.jsx
View file @
0c1be1bb
import
{
Reload
,
Info
}
from
'@/components/AppIcons'
;
import
{
DataTable
,
DataTableText
,
DataTableTextPair
}
from
'@/components/DataTable'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
Alert
,
AlertDialog
,
Button
,
Card
,
Chip
,
Spinner
,
TextArea
,
Toast
,
Tooltip
,
toast
}
from
'@heroui/react'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
toast
from
'@/services/feedbackService'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
AlertDialog
,
Button
,
Card
,
Chip
,
Spinner
,
TextArea
,
Tooltip
}
from
'@heroui/react'
;
import
{
useCallback
,
useContext
,
useEffect
,
useRef
,
useState
}
from
'react'
;
import
{
useSearchParams
}
from
'umi'
;
import
IntegrityPracticeMaterialField
from
'./components/IntegrityPracticeMaterialField'
;
...
...
@@ -27,7 +30,7 @@ export default function SelfInspectionFlowPage() {
const
[
flow
,
setFlow
]
=
useState
(
null
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
busy
,
setBusy
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
,
setError
]
=
useFeedbackState
(
);
const
[
changes
,
setChanges
]
=
useState
({});
const
[
validation
,
setValidation
]
=
useState
({});
const
[
confirmAction
,
setConfirmAction
]
=
useState
(
null
);
...
...
@@ -40,6 +43,7 @@ export default function SelfInspectionFlowPage() {
const
reload
=
useCallback
(
async
()
=>
{
const
version
=
++
requestVersion
.
current
;
setLoading
(
true
);
setError
(
''
);
try
{
const
result
=
await
loadSelfInspectionFlow
(
messageName
,
currentUser
);
if
(
version
!==
requestVersion
.
current
)
return
;
...
...
@@ -56,7 +60,7 @@ export default function SelfInspectionFlowPage() {
}
finally
{
if
(
version
===
requestVersion
.
current
)
setLoading
(
false
);
}
},
[
messageName
,
currentUser
?.
_id
]);
},
[
messageName
,
currentUser
?.
_id
,
setError
]);
useEffect
(()
=>
{
setError
(
''
);
...
...
@@ -187,7 +191,15 @@ export default function SelfInspectionFlowPage() {
if
(
hasChanges
)
setConfirmAction
(
'refresh'
);
else
{
setError
(
''
);
reload
();
}
}
}
><
Reload
size=
{
16
}
/></
Button
><
Tooltip
.
Content
>
刷新流程
</
Tooltip
.
Content
></
Tooltip
>
</
Card
.
Content
></
Card
>
{
error
?
<
Alert
status=
"danger"
><
Alert
.
Content
><
Alert
.
Description
>
{
error
}
</
Alert
.
Description
></
Alert
.
Content
></
Alert
>
:
null
}
{
!
loading
&&
!
flow
?
(
<
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=
{
reload
}
>
重新加载
</
Button
></
EmptyState
.
Content
>
</
EmptyState
>
)
:
null
}
{
loading
?
<
div
className=
"grid min-h-56 place-items-center"
role=
"status"
aria
-
label=
"正在加载自查底稿"
><
Spinner
/></
div
>
:
flow
?
(
<
section
className=
"flex min-w-0 flex-col gap-4"
>
<
div
className=
"flex flex-wrap items-center justify-between gap-3"
>
...
...
@@ -210,7 +222,6 @@ export default function SelfInspectionFlowPage() {
}
}
>
确认
</
Button
></
AlertDialog
.
Footer
>
</
AlertDialog
.
Dialog
></
AlertDialog
.
Container
></
AlertDialog
.
Backdrop
>
</
AlertDialog
>
<
Toast
.
Provider
maxVisibleToasts=
{
3
}
placement=
"top"
/>
</
main
>
);
}
src/pages/integrity_practice_management/SubjectWorkbench.jsx
View file @
0c1be1bb
import
RecordWorkbench
from
'@/components/record-workbench'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
toast
from
'@/services/feedbackService'
;
import
RecordDetailDrawer
from
'@/pages/RecordDetailDrawer'
;
import
{
Archive
,
Close
,
Delete
,
Download
,
Edit
,
Plus
,
Reload
,
Send
}
from
'@/components/AppIcons'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
Alert
,
AlertDialog
,
Button
,
Input
,
Label
,
Modal
,
TextArea
,
TextField
,
Toast
,
toast
}
from
'@heroui/react'
;
import
{
Alert
Dialog
,
Button
,
Input
,
Label
,
Modal
,
TextArea
,
TextField
}
from
'@heroui/react'
;
import
{
useCallback
,
useContext
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
useLocation
}
from
'umi'
;
import
UserAssignmentDialog
from
'./UserAssignmentDialog'
;
...
...
@@ -82,7 +84,7 @@ export default function SubjectWorkbench() {
const
paginationEnabled
=
gridConfig
.
pagination
!==
false
;
const
[
subject
,
setSubject
]
=
useState
(
null
);
const
[
displayConfig
,
setDisplayConfig
]
=
useState
({});
const
[
displayConfigError
,
setDisplayConfigError
]
=
useState
(
'
'
);
const
[
,
setDisplayConfigError
]
=
useFeedbackState
(
'warning
'
);
const
[
views
,
setViews
]
=
useState
(
EMPTY_ARRAY
);
const
[
activeViewName
,
setActiveViewName
]
=
useState
(
''
);
const
[
records
,
setRecords
]
=
useState
(
EMPTY_ARRAY
);
...
...
@@ -93,13 +95,14 @@ export default function SubjectWorkbench() {
const
[
pagination
,
setPagination
]
=
useState
(
null
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
actionLoading
,
setActionLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
use
State
(
''
);
const
[
error
,
setError
]
=
use
FeedbackState
(
);
const
[
refreshKey
,
setRefreshKey
]
=
useState
(
0
);
const
[
resourceRefreshKey
,
setResourceRefreshKey
]
=
useState
(
0
);
const
[
assignmentState
,
setAssignmentState
]
=
useState
(
null
);
const
[
confirmationState
,
setConfirmationState
]
=
useState
(
null
);
const
[
showCreate
,
setShowCreate
]
=
useState
(
false
);
const
[
createValues
,
setCreateValues
]
=
useState
({});
const
[
createError
,
setCreateError
]
=
useState
(
''
);
const
[
,
setCreateError
]
=
useFeedbackState
(
);
const
activeView
=
useMemo
(
()
=>
views
.
find
((
view
)
=>
view
.
name
===
activeViewName
)
||
views
[
0
]
||
null
,
...
...
@@ -120,6 +123,8 @@ export default function SubjectWorkbench() {
let
cancelled
=
false
;
setLoading
(
true
);
setError
(
''
);
setSubject
(
null
);
setViews
(
EMPTY_ARRAY
);
setDisplayConfig
({});
setDisplayConfigError
(
''
);
setRecords
(
EMPTY_ARRAY
);
...
...
@@ -147,7 +152,7 @@ export default function SubjectWorkbench() {
return
()
=>
{
cancelled
=
true
;
};
},
[
subjectName
]);
},
[
subjectName
,
resourceRefreshKey
,
setError
,
setDisplayConfigError
]);
useEffect
(()
=>
{
if
(
!
subjectName
||
!
activeView
)
return
undefined
;
...
...
@@ -181,7 +186,7 @@ export default function SubjectWorkbench() {
return
()
=>
{
cancelled
=
true
;
};
},
[
subjectName
,
activeView
,
page
,
pageSize
,
filters
,
filterSchema
,
paginationEnabled
,
refreshKey
]);
},
[
subjectName
,
activeView
,
page
,
pageSize
,
filters
,
filterSchema
,
paginationEnabled
,
refreshKey
,
setError
]);
useEffect
(()
=>
{
if
(
!
showCreate
)
setCreateValues
(
createFields
.
reduce
((
values
,
field
)
=>
({
...
values
,
[
field
.
fieldDef
.
name
]:
restrictionDefaults
.
metadata
?.[
field
.
fieldDef
.
name
]
??
emptyValueForField
(
field
.
fieldDef
)
}),
{}));
...
...
@@ -193,6 +198,7 @@ export default function SubjectWorkbench() {
setActionLoading
(
true
);
setCreateError
(
''
);
try
{
await
createRecord
(
buildIntegrityCreatePayload
(
subjectName
,
createValues
,
createFields
,
restrictionDefaults
,
{
hideTitle
:
hideCreateTitle
}));
toast
.
success
(
'记录已创建'
,
{
timeout
:
4000
});
setShowCreate
(
false
);
setRefreshKey
((
value
)
=>
value
+
1
);
}
catch
(
error
)
{
setCreateError
(
error
?.
message
||
'新增记录失败,请稍后重试。'
);
}
finally
{
setActionLoading
(
false
);
}
...
...
@@ -214,7 +220,7 @@ export default function SubjectWorkbench() {
setRefreshKey
((
value
)
=>
value
+
1
);
}
}
catch
(
actionError
)
{
toast
.
danger
(
actionError
?.
message
||
'操作失败,请稍后重试'
,
{
timeout
:
45
00
});
toast
.
danger
(
actionError
?.
message
||
'操作失败,请稍后重试'
,
{
timeout
:
80
00
});
}
finally
{
setActionLoading
(
false
);
}
...
...
@@ -309,8 +315,6 @@ export default function SubjectWorkbench() {
return
(
<
div
>
<
Toast
.
Provider
placement=
"top"
/>
<
RecordWorkbench
accessDenied=
{
!
loading
&&
!
hasAccessibleView
}
views=
{
views
.
map
((
view
)
=>
({
...
view
}))
}
...
...
@@ -332,7 +336,8 @@ export default function SubjectWorkbench() {
setPage
(
0
);
}
}
onQuery=
{
()
=>
{
setRefreshKey
((
value
)
=>
value
+
1
);
if
(
!
subject
)
setResourceRefreshKey
((
value
)
=>
value
+
1
);
else
setRefreshKey
((
value
)
=>
value
+
1
);
}
}
title=
{
subjectConfig
.
title
||
subject
?.
title
||
subjectName
}
listTitle=
{
subjectConfig
.
listTitle
}
...
...
@@ -351,7 +356,6 @@ export default function SubjectWorkbench() {
states=
{
{
loading
,
error
,
warning
:
displayConfigError
,
loadingTitle
:
'正在加载业务数据'
,
loadingDescription
:
'正在读取对象结构、视图配置和记录数据。'
,
}
}
...
...
@@ -416,13 +420,6 @@ export default function SubjectWorkbench() {
<
Modal
.
Heading
>
新增记录
</
Modal
.
Heading
>
</
Modal
.
Header
>
<
Modal
.
Body
className=
"min-w-0"
>
{
createError
?
(
<
Alert
className=
"mb-5"
status=
"danger"
>
<
Alert
.
Content
>
<
Alert
.
Description
className=
"whitespace-pre-line"
>
{
createError
}
</
Alert
.
Description
>
</
Alert
.
Content
>
</
Alert
>
)
:
null
}
<
div
className=
"grid min-w-0 grid-cols-1 gap-x-8 gap-y-5 md:grid-cols-2"
>
{
createFields
.
map
((
field
)
=>
{
const
name
=
field
.
fieldDef
.
name
;
...
...
src/pages/integrity_practice_management/UserAssignmentDialog.jsx
View file @
0c1be1bb
import
{
Close
}
from
'@/components/AppIcons'
;
import
{
queryUsers
}
from
'@/services/DataService'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
Button
,
Chip
,
ComboBox
,
Input
,
Label
,
ListBox
,
Modal
,
Spinner
,
Tooltip
}
from
'@heroui/react'
;
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
...
...
@@ -9,25 +10,30 @@ function UserMultiSelect({ label, value, onChange }) {
const
[
query
,
setQuery
]
=
useState
(
''
);
const
[
options
,
setOptions
]
=
useState
(
EMPTY_ARRAY
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useFeedbackState
();
const
selectedIds
=
useMemo
(()
=>
new
Set
(
value
.
map
((
user
)
=>
user
.
_id
)),
[
value
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
const
timer
=
window
.
setTimeout
(
async
()
=>
{
setLoading
(
true
);
setError
(
''
);
try
{
const
text
=
query
.
trim
();
const
filter
=
text
?
{
$or
:
[{
username
:
{
$regex
:
text
}
},
{
display_name
:
{
$regex
:
text
}
}]
}
:
{};
const
result
=
await
queryUsers
(
0
,
20
,
filter
);
if
(
!
cancelled
)
setOptions
(
result
?.
users
||
EMPTY_ARRAY
);
}
catch
{
if
(
!
cancelled
)
setOptions
(
EMPTY_ARRAY
);
}
catch
(
requestError
)
{
if
(
!
cancelled
)
{
setOptions
(
EMPTY_ARRAY
);
setError
(
requestError
?.
message
||
`
${
label
}
列表加载失败,请重新搜索。`
);
}
}
finally
{
if
(
!
cancelled
)
setLoading
(
false
);
}
},
180
);
return
()
=>
{
cancelled
=
true
;
window
.
clearTimeout
(
timer
);
};
},
[
query
]);
},
[
query
,
label
,
setError
]);
const
availableOptions
=
options
.
filter
((
user
)
=>
!
selectedIds
.
has
(
user
.
_id
));
return
(
...
...
@@ -50,7 +56,7 @@ function UserMultiSelect({ label, value, onChange }) {
<
ComboBox
.
Trigger
aria
-
label=
{
`打开${label}列表`
}
/>
</
ComboBox
.
InputGroup
>
<
ComboBox
.
Popover
placement=
"bottom start"
>
<
ListBox
aria
-
label=
{
`${label}候选人`
}
items=
{
availableOptions
}
renderEmptyState=
{
()
=>
<
div
className=
"grid min-h-20 place-items-center p-4 text-sm text-muted"
>
{
loading
?
'正在搜索...'
:
'没有匹配的用户'
}
</
div
>
}
>
<
ListBox
aria
-
label=
{
`${label}候选人`
}
items=
{
availableOptions
}
renderEmptyState=
{
()
=>
<
div
className=
"grid min-h-20 place-items-center p-4 text-sm text-muted"
>
{
loading
?
'正在搜索...'
:
error
?
'加载失败,请重新搜索'
:
'没有匹配的用户'
}
</
div
>
}
>
{
(
user
)
=>
<
ListBox
.
Item
id=
{
user
.
_id
}
textValue=
{
user
.
display_name
||
user
.
username
}
><
span
className=
"block min-w-0"
><
span
className=
"block truncate font-medium text-foreground"
>
{
user
.
display_name
||
user
.
username
}
</
span
><
span
className=
"block truncate text-xs text-muted"
>
{
user
.
username
}
</
span
></
span
><
ListBox
.
ItemIndicator
/></
ListBox
.
Item
>
}
</
ListBox
>
{
loading
?
<
div
className=
"grid place-items-center p-2"
><
Spinner
size=
"sm"
/></
div
>
:
null
}
...
...
src/pages/integrity_practice_management/components/IntegrityPracticeMaterialField.jsx
View file @
0c1be1bb
import
{
ArrowPathIcon
,
Delete
,
}
from
'@/components/AppIcons'
;
import
{
Delete
}
from
'@/components/AppIcons'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
FileUploadField
from
'@/components/FileUploadField'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
{
...
...
@@ -9,7 +7,6 @@ import {
uploadIntegrityPracticeFile
,
}
from
'../integrityPracticeMaterialService'
;
import
{
Alert
,
Button
,
Label
,
Tabs
,
...
...
@@ -61,10 +58,10 @@ export default function IntegrityPracticeMaterialField({
const
[
materials
,
setMaterials
]
=
useState
([]);
const
[
selectedMaterialId
,
setSelectedMaterialId
]
=
useState
(
null
);
const
[
materialsLoading
,
setMaterialsLoading
]
=
useState
(
false
);
const
[
materialsError
,
setMaterialsError
]
=
use
State
(
''
);
const
[
materialsError
,
setMaterialsError
]
=
use
FeedbackState
(
);
const
[
retryKey
,
setRetryKey
]
=
useState
(
0
);
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
[
uploadError
,
setUploadError
]
=
useState
(
''
);
const
[
,
setUploadError
]
=
useFeedbackState
(
);
const
[
localFileKeys
,
setLocalFileKeys
]
=
useState
(()
=>
new
Set
());
const
fileValues
=
useMemo
(
()
=>
(
multiple
?
toFileArray
(
value
)
:
toFileArray
(
value
).
slice
(
0
,
1
)),
...
...
@@ -113,7 +110,7 @@ export default function IntegrityPracticeMaterialField({
return
()
=>
{
cancelled
=
true
;
};
},
[
activeTab
,
debouncedKeyword
,
retryKey
]);
},
[
activeTab
,
debouncedKeyword
,
retryKey
,
setMaterialsError
]);
const
materialOptions
=
useMemo
(
()
=>
materials
.
map
((
material
)
=>
({
...
...
@@ -336,15 +333,9 @@ export default function IntegrityPracticeMaterialField({
/>
{materialsError ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{materialsError}</Alert.Description>
</Alert.Content>
<Button size="sm" variant="danger-soft" onPress={() => setRetryKey((key) => key + 1)}>
<ArrowPathIcon size={14} />
重试
</Button>
</Alert>
<Button size="sm" variant="secondary" onPress={() => setRetryKey((key) => key + 1)}>
重新加载材料
</Button>
) : null}
</div>
</Tabs.Panel>
...
...
@@ -360,11 +351,6 @@ export default function IntegrityPracticeMaterialField({
triggerLabel={uploading ? '上传中...' : '选择本地文件'}
onFilesChange={handleLocalUpload}
/>
{uploadError ? (
<Alert status="danger">
<Alert.Content><Alert.Description>{uploadError}</Alert.Description></Alert.Content>
</Alert>
) : null}
</div>
</Tabs.Panel>
</Tabs>
...
...
src/pages/integrity_practice_management/integrity-practice-management.business-flow.md
View file @
0c1be1bb
...
...
@@ -36,7 +36,7 @@
异常处理:
-
Subject、视图或记录加载失败时,
使用 HeroUI Alert/EmptyState 显示错误或空态,不使用猜测字段兜底
。
-
Subject、视图或记录加载失败时,
顶部 Toast 显示具体错误,列表显示加载失败 EmptyState 与“重新加载”入口,不使用猜测字段兜底;关闭或自动消失提示不会清除加载失败状态
。
-
没有可访问
`grid`
视图时不创建兜底 View、不查询记录,并隐藏新增、批量和行操作;只有一个可访问 View 时隐藏 Tabs 轨道。
-
配置动作执行期间禁用重复点击;成功后使用顶部 Toast,失败时保留当前列表并显示服务端错误。
...
...
@@ -215,5 +215,15 @@ Grid 视图配置示例(列举的是配置片段,修改时应保留完整原
-
所有运行时可编辑
`file`
字段按
`multiple`
提供“材料中心 / 本地上传”。材料中心查询
`am_material`
最近 20 条并远程搜索材料标题、文件名。
-
选择材料只保存
`{name,type,size,url}`
快照;单值替换,多值追加并按 URL 去重。清空附件不删除材料主档。
-
本地文件先调用
`/upload`
写入详情草稿;取消编辑不建档。点击保存后,对仍保留的本地文件逐个创建
`am_material`
,
`
source_app = integrity_practice_management`
,全部成功后才更新业务 Record
。
-
本地文件先调用
`/upload`
写入详情草稿;取消编辑不建档。点击保存后,对仍保留的本地文件逐个创建
`am_material`
,
`
metadata.source_app`
取统一应用配置
`src/config/application.js`
的
`APPLICATION_DISPLAY_NAME`
(当前为“廉洁从业管理”),全部成功后才更新业务 Record。详情编辑与自查流程保存均使用此规则
。
-
同一编辑会话记录已建档文件;材料部分成功或业务更新失败后重试只处理未建档项,避免重复材料。
## 11. 操作反馈与异常状态
-
登录、应用切换、业务列表、新增/编辑详情、自查填写与审核、通知中心、材料查询/上传及远程引用/人员搜索共用应用根部的 HeroUI Toast 容器,最多展示 3 条提示。提示固定在视口顶部中央,层级高于弹窗和移动端侧栏,不占用页面布局,也不随列表或弹窗正文滚动。
-
保存、新增、删除、归档、导出及通知操作成功后显示短暂成功提示,约 3–4 秒自动消失;接口失败或配置回退显示错误/警告提示,约 8 秒自动消失,悬停或键盘聚焦暂停计时。当前提示右侧的关闭图标始终可见,使用 HeroUI 小号透明图标按钮,通过官方
`close`
slot 支持点击与键盘关闭,移动端同样可直接关闭;后排堆叠提示隐藏关闭按钮,避免误关闭。相同错误在再次请求失败时重新提示。
-
提示关闭只影响展示,不改变请求结果、权限、记录状态或表单草稿。新增/编辑失败保留当前草稿,自查流程保存失败保留未保存修改;字段必填、JSON 校验和记录锁定等需要修正或了解的字段状态继续在对应位置展示。
-
详情弹窗点击“保存”后,只有待建档材料及
`PUT /record/:name`
全部成功,才退出编辑并显示“详情已保存。”成功提示;弹窗继续展示已保存内容。重新编辑、再次保存、取消编辑、关闭弹窗或切换记录时清理上一条成功提示;无字段修改、校验未通过或正在保存时不提交、不显示成功提示。任一接口失败保留当前草稿并显示错误,重试沿用第 10 节的材料建档规则。
-
列表、通知、自查流程及详情首次加载失败时保留失败空态和重试入口;材料查询失败时保留“重新加载材料”按钮,引用/人员候选列表保留失败状态并允许重新搜索。应用配置或应用列表回退警告也通过 Toast 展示,不再使用常驻接口反馈横条。
-
Toast 使用 HeroUI 的可访问通知区域与语义状态图标,长错误文案换行并限制高度;请求与写入继续使用第 8 节的接口、原 Subject 字段及
`metadata`
协议。
-
提示使用独立 HeroUI 队列和局部进入动画,不触发整页 View Transition 快照;提示出现后立即点击“关闭提示”也只关闭该提示,不会误触弹窗遮罩。
src/pages/integrity_practice_management/integrityPracticeMaterialService.js
View file @
0c1be1bb
import
dayjs
from
'dayjs'
;
import
{
APPLICATION_DISPLAY_NAME
}
from
'@/config/application'
;
import
{
createRecord
,
queryRecords
,
uploadFile
}
from
'@/services/DataService'
;
export
const
AUDIT_MATERIAL_SUBJECT
=
'am_material'
;
export
const
INTEGRITY_PRACTICE_SOURCE_APP
=
'integrity_practice_management'
;
export
const
INTEGRITY_PRACTICE_SOURCE_APP
=
APPLICATION_DISPLAY_NAME
;
const
MATERIAL_PAGE_SIZE
=
20
;
...
...
src/pages/integrity_practice_management/notifications/NotificationListPage.jsx
View file @
0c1be1bb
import
{
Bell
,
Close
,
MagnifyingGlassIcon
,
OpenExternal
}
from
'@/components/AppIcons'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
toast
from
'@/services/feedbackService'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Alert
,
Avatar
,
Button
,
Card
,
...
...
@@ -11,7 +12,6 @@ import {
Spinner
,
Pagination
,
Tabs
,
toast
,
}
from
'@heroui/react'
;
import
{
useCallback
,
useContext
,
useEffect
,
useState
}
from
'react'
;
import
{
history
}
from
'umi'
;
...
...
@@ -183,7 +183,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
(
);
const
[
busyKey
,
setBusyKey
]
=
useState
(
''
);
const
[
reloadKey
,
setReloadKey
]
=
useState
(
0
);
...
...
@@ -225,7 +225,7 @@ export default function NotificationListPage() {
return
()
=>
{
cancelled
=
true
;
};
},
[
category
,
keyword
,
page
,
readStatus
,
reloadKey
]);
},
[
category
,
keyword
,
page
,
readStatus
,
reloadKey
,
setError
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
...
...
@@ -252,7 +252,7 @@ export default function NotificationListPage() {
return
true
;
}
catch
(
operationError
)
{
toast
.
danger
(
operationError
?.
message
||
'操作失败,请稍后重试'
,
{
timeout
:
4
000
,
timeout
:
8
000
,
});
return
false
;
}
finally
{
...
...
@@ -305,7 +305,7 @@ export default function NotificationListPage() {
refresh
();
}
catch
(
operationError
)
{
toast
.
danger
(
operationError
?.
message
||
'无法打开通知关联流程'
,
{
timeout
:
4
000
,
timeout
:
8
000
,
});
}
finally
{
setBusyKey
(
''
);
...
...
@@ -392,14 +392,13 @@ 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/services/feedbackService.js
0 → 100644
View file @
0c1be1bb
import
{
ToastQueue
}
from
'@heroui/react'
;
// Update only the Toast region: document-wide view transitions temporarily hide
// live hit targets and can turn a click on "关闭提示" into a backdrop click.
export
const
feedbackQueue
=
new
ToastQueue
({
maxVisibleToasts
:
3
,
wrapUpdate
:
(
update
)
=>
update
(),
});
function
toast
(
title
,
{
timeout
=
4000
,
onClose
,
...
content
}
=
{})
{
return
feedbackQueue
.
add
({
title
,
...
content
},
{
timeout
,
onClose
});
}
toast
.
close
=
(
key
)
=>
feedbackQueue
.
close
(
key
);
toast
.
success
=
(
title
,
options
)
=>
toast
(
title
,
{
variant
:
'success'
,
timeout
:
4000
,
...
options
});
toast
.
danger
=
(
title
,
options
)
=>
toast
(
title
,
{
variant
:
'danger'
,
timeout
:
8000
,
...
options
});
export
default
toast
;
src/tailwind.css
View file @
0c1be1bb
...
...
@@ -32,6 +32,32 @@ html[data-theme="dark"] {
color-scheme
:
dark
!important
;
}
/* Operation feedback stays above dialogs, outside page scroll containers. */
.app-feedback-region.toast-region
{
z-index
:
300
;
top
:
max
(
1rem
,
env
(
safe-area-inset-top
));
}
.app-feedback-toast.toast
{
border-radius
:
14px
;
animation
:
app-feedback-enter
180ms
ease-out
;
}
@keyframes
app-feedback-enter
{
from
{
transform
:
translateY
(
-8px
);
opacity
:
0
;
}
to
{
transform
:
translateY
(
0
);
opacity
:
1
;
}
}
@media
(
prefers-reduced-motion
:
reduce
)
{
.app-feedback-toast.toast
{
animation
:
none
;
}
}
/* Only the front toast exposes a close button, including on touch screens. */
.app-feedback-toast
:not
([
data-frontmost
=
"true"
])
.app-feedback-close
{
visibility
:
hidden
;
pointer-events
:
none
;
}
body
{
margin
:
0
;
font-family
:
var
(
--rc-font-sans
);
...
...
src/wrapper/Site.jsx
View file @
0c1be1bb
import
{
getSystemInfo
}
from
'@/services/DataService'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
dayjs
from
'dayjs'
;
import
customParseFormat
from
'dayjs/plugin/customParseFormat'
;
import
localeData
from
'dayjs/plugin/localeData'
;
...
...
@@ -18,7 +19,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
(
);
useEffect
(()
=>
{
let
cancelled
=
false
;
...
...
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