Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
R
risk_case_library
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
risk_case_library
Commits
e97b68f0
Commit
e97b68f0
authored
Oct 08, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: unify operation feedback and improve case save interactions
parent
894205db
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
16 changed files
with
191 additions
and
61 deletions
+191
-61
xinyuan-design-system.md
docs/xinyuan-design-system.md
+6
-0
manifest.json
public/manifest.json
+1
-1
app.js
src/app.js
+7
-1
AppFeedback.jsx
src/components/AppFeedback.jsx
+42
-0
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
ApplicationSwitcherDialog.jsx
src/pages/project_manager_xy/ApplicationSwitcherDialog.jsx
+5
-11
Layout.jsx
src/pages/project_manager_xy/Layout.jsx
+1
-2
RiskCaseMaterialField.jsx
src/pages/project_manager_xy/RiskCaseMaterialField.jsx
+10
-7
audit-material-center.business-flow.md
...project_manager_xy/audit-material-center.business-flow.md
+13
-2
NotificationListPage.jsx
...project_manager_xy/notifications/NotificationListPage.jsx
+23
-9
index.jsx
src/pages/project_manager_xy/resource-booking/index.jsx
+0
-0
index.jsx
src/pages/project_manager_xy/resource-stat/index.jsx
+27
-16
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 @
e97b68f0
...
...
@@ -18,6 +18,12 @@
-
`theme.css`
保留 HeroUI 官方变量结构;本项目允许的品牌覆盖仅为鑫元基金品牌主色
`#DA241A`
、品牌 Hover
`#C91F17`
、固定 primary 色阶和对应图表/指标 Token。页面不得覆盖 HeroUI 外观、slot、BEM 类或交互状态。
-
页面
`className`
只承担 flex、grid、宽度、间距、对齐和响应式布局。
### 操作反馈浮层
接口成功、失败及降级警告统一使用应用根部
`AppFeedback`
的 HeroUI
`Toast.Provider`
和官方 compound 组件。登录、业务页面和弹框共用一个 Provider,不再渲染接口反馈横条;成功默认 4 秒、错误/警告默认 8 秒后消失,支持主动关闭。
关闭按钮沿用问题整改
`bfc5862`
:组合 HeroUI
`Button`
(
`ghost`
、
`sm`
、
`isIconOnly`
)与
`AppIcons.Close`
,通过官方
`slot="close"`
接入 Toast 关闭行为,位于正文右侧并参与布局。当前提示的关闭图标始终可见,使用官方透明按钮及默认 Hover/焦点样式;不再放大带边框的 Toast 默认关闭按钮,也不设置额外绝对位置和正文右侧占位。
`src/design-system/feedback.css`
仅调整 Toast 层级、安全区位置及后排关闭按钮的可见性;层级 300,高于业务 Modal 和移动侧栏。颜色、边框、圆角、阴影与 Hover 继续使用 HeroUI 默认主题。
## 页面规则
-
Shell 使用 HeroUI Pro offcanvas Sidebar 与 Navbar;页面和 Navbar 使用 48/32/16px 对称 gutter。
...
...
public/manifest.json
View file @
e97b68f0
{
"name"
:
"risk_case_library"
,
"version"
:
"1.0.
4
"
,
"version"
:
"1.0.
5
"
,
"build"
:
"abc"
,
"description"
:
"警示案例库"
,
"date"
:
"2025-07-14 19:27:20"
,
...
...
src/app.js
View file @
e97b68f0
import
'@/global.less'
;
import
'@/tailwind.css'
;
import
{
defineApp
}
from
'umi'
;
import
{
createElement
}
from
'react'
;
import
AppFeedback
from
'@/components/AppFeedback'
;
export
async
function
getInitialState
()
{
return
{
...
...
@@ -8,4 +10,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 @
e97b68f0
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
>
{
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/design-system/feedback.css
0 → 100644
View file @
e97b68f0
/* Keep operation feedback 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 @
e97b68f0
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 feedback only changes its presentation; request and draft state stay 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
;
// Report every attempt, including the same error after a retry.
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 @
e97b68f0
import
xyLogoRed
from
'@/assets/identity/xy-logo-red.png'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
xyLogoWhite
from
'@/assets/identity/xy-logo-white.png'
;
import
{
APP_DISPLAY_NAME
}
from
'@/constants/app'
;
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'
;
...
...
@@ -26,7 +27,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
=
searchParams
.
get
(
'redirect'
)
||
'/'
;
...
...
@@ -185,14 +186,6 @@ export default function LoginPage() {
</
div
>
</
TextField
>
{
error
?
(
<
Alert
status=
"danger"
>
<
Alert
.
Content
>
<
Alert
.
Description
>
{
error
}
</
Alert
.
Description
>
</
Alert
.
Content
>
</
Alert
>
)
:
null
}
<
Button
fullWidth
className=
"login-submit-button"
...
...
src/pages/project_manager_xy/ApplicationSwitcherDialog.jsx
View file @
e97b68f0
import
xinyuanLogo
from
'@/assets/identity/xy-logo-red.png'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
EmptyStateSurface
}
from
'@/components/DataTable'
;
import
{
ExclamationTriangleIcon
,
MagnifyingGlassIcon
}
from
'@/components/AppIcons'
;
import
{
MagnifyingGlassIcon
}
from
'@/components/AppIcons'
;
import
{
APP_DISPLAY_NAME
,
APP_LOGIN_SUBTITLE
,
...
...
@@ -11,7 +12,6 @@ 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
,
...
...
@@ -321,7 +321,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
(
()
=>
...
...
@@ -363,6 +363,7 @@ export default function ApplicationSwitcherDialog({
useEffect
(()
=>
{
if
(
!
open
)
{
setApplicationSearch
(
''
);
setErrorMessage
(
''
);
return
;
}
...
...
@@ -396,7 +397,7 @@ export default function ApplicationSwitcherDialog({
return
()
=>
{
cancelled
=
true
;
};
},
[
fallbackApplications
,
open
]);
},
[
fallbackApplications
,
open
,
setErrorMessage
]);
return
(
<
Modal
.
Root
>
...
...
@@ -446,13 +447,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/pages/project_manager_xy/Layout.jsx
View file @
e97b68f0
...
...
@@ -9,7 +9,7 @@ import { UserContext } from '@/wrapper/Auth';
import
{
SiteContext
}
from
'@/wrapper/Site'
;
import
{
Navbar
}
from
'@heroui-pro/react/navbar'
;
import
{
Sidebar
,
useSidebar
}
from
'@heroui-pro/react/sidebar'
;
import
{
Avatar
,
Badge
,
Button
,
Dropdown
,
Label
,
To
ast
,
To
oltip
}
from
'@heroui/react'
;
import
{
Avatar
,
Badge
,
Button
,
Dropdown
,
Label
,
Tooltip
}
from
'@heroui/react'
;
import
{
useContext
,
useEffect
,
useLayoutEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
Heading
}
from
'react-aria-components/Dialog'
;
import
{
history
,
Outlet
,
useLocation
}
from
'umi'
;
...
...
@@ -308,7 +308,6 @@ export default function ProjectManagerLayout() {
userInfo=
{
userInfo
}
siteInfo=
{
siteInfo
}
/>
<
Toast
.
Provider
maxVisibleToasts=
{
3
}
placement=
"top"
/>
</
Sidebar
.
Provider
>
);
}
src/pages/project_manager_xy/RiskCaseMaterialField.jsx
View file @
e97b68f0
import
{
ArrowPathIcon
,
Delete
}
from
'@/components/AppIcons'
;
import
FileUploadField
from
'@/components/FileUploadField'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
{
Alert
,
Button
,
Label
,
Tabs
,
Tooltip
}
from
'@heroui/react'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
Button
,
Label
,
Tabs
,
Tooltip
}
from
'@heroui/react'
;
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
queryRiskCaseMaterials
,
uploadRiskCaseFile
}
from
'./riskCaseMaterialService'
;
...
...
@@ -35,7 +36,8 @@ export default function RiskCaseMaterialField({
const
[
materials
,
setMaterials
]
=
useState
([]);
const
[
selectedKey
,
setSelectedKey
]
=
useState
(
null
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
error
,
setError
]
=
useFeedbackState
(
'error'
);
const
[,
setUploadError
]
=
useFeedbackState
(
'error'
);
const
[
retryKey
,
setRetryKey
]
=
useState
(
0
);
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
[
localKeys
,
setLocalKeys
]
=
useState
(()
=>
new
Set
());
...
...
@@ -46,8 +48,9 @@ export default function RiskCaseMaterialField({
setKeyword
(
''
);
setSelectedKey
(
null
);
setError
(
''
);
setUploadError
(
''
);
setLocalKeys
(
new
Set
());
},
[
editSessionId
]);
},
[
editSessionId
,
setError
,
setUploadError
]);
useEffect
(()
=>
{
const
timer
=
window
.
setTimeout
(()
=>
setDebouncedKeyword
(
keyword
),
300
);
...
...
@@ -64,7 +67,7 @@ export default function RiskCaseMaterialField({
.
catch
((
requestError
)
=>
{
if
(
!
cancelled
)
{
setMaterials
([]);
setError
(
requestError
?.
message
||
'审计材料加载失败。'
);
}
})
.
finally
(()
=>
{
if
(
!
cancelled
)
setLoading
(
false
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
activeTab
,
debouncedKeyword
,
retryKey
]);
},
[
activeTab
,
debouncedKeyword
,
retryKey
,
setError
]);
const
options
=
materials
.
map
((
material
)
=>
({
...
material
,
...
...
@@ -93,7 +96,7 @@ export default function RiskCaseMaterialField({
const nextFiles = multiple ? toFiles(selectedFiles) : toFiles(selectedFiles).slice(0, 1);
if (nextFiles.length === 0) return;
setUploading(true);
setError('');
set
Upload
Error('');
onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId);
const uploaded = [];
const failed = [];
...
...
@@ -108,7 +111,7 @@ export default function RiskCaseMaterialField({
onPendingLocalMaterialChange?.(fieldPath, { type: multiple ? 'add' : 'replace', materials: pending }, editSessionId);
onChange?.(multiple ? appendUnique(files, uploaded) : uploaded[uploaded.length - 1]);
}
if (failed.length > 0) setError(failed.length === nextFiles.length ? failed[0].uploadError?.message || '附件上传失败。' : `
部分附件上传失败:
$
{
failed
.
map
((
item
)
=>
item
.
file
?.
name
).
filter
(
Boolean
).
join
(
'、'
)}
`);
if (failed.length > 0) set
Upload
Error(failed.length === nextFiles.length ? failed[0].uploadError?.message || '附件上传失败。' : `
部分附件上传失败:
$
{
failed
.
map
((
item
)
=>
item
.
file
?.
name
).
filter
(
Boolean
).
join
(
'、'
)}
`);
setUploading(false);
onValidationChange?.(fieldPath, '', editSessionId);
}
...
...
@@ -129,7 +132,7 @@ export default function RiskCaseMaterialField({
<Tabs.ListContainer><Tabs.List aria-label="附件来源"><Tabs.Tab id={MATERIAL_CENTER_TAB}>材料中心<Tabs.Indicator /></Tabs.Tab><Tabs.Tab id={LOCAL_UPLOAD_TAB}>本地上传<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer>
<Tabs.Panel className="pt-3" id={MATERIAL_CENTER_TAB}>
<XinyuanAutocomplete aria-label="选择审计材料" emptyText="没有找到可选择的审计材料" errorText={error} inputValue={keyword} isDisabled={uploading} isLoading={loading} options={options} placeholder={multiple && files.length ? `
已选
$
{
files
.
length
}
个附件,可继续添加
` : '请选择审计材料'} searchPlaceholder="搜索材料标题或文件名" selectedKey={selectedKey} shouldFilter={false} onInputChange={setKeyword} onSelectionChange={handleMaterialSelection} renderOption={(material) => <span className="min-w-0"><Label className="block truncate">{material.materialTitle}</Label><span className="block truncate text-xs text-muted">{[material.fileAsset?.name, material.archiveCategory, material.archiveYear].filter(Boolean).join(' · ')}</span></span>} />
{error ? <
Alert className="mt-3" status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content><Button size="sm" variant="danger-soft" onPress={() => setRetryKey((value) => value + 1)}><ArrowPathIcon size={14} />重试</Button></Alert
> : null}
{error ? <
Button className="mt-3" isDisabled={loading} size="sm" variant="secondary" onPress={() => setRetryKey((value) => value + 1)}><ArrowPathIcon size={14} />重新加载审计材料</Button
> : null}
</Tabs.Panel>
<Tabs.Panel className="pt-3" id={LOCAL_UPLOAD_TAB}><FileUploadField isDisabled={uploading} multiple={multiple} triggerLabel={uploading ? '上传中...' : '选择本地文件'} onFilesChange={handleLocalUpload} /></Tabs.Panel>
</Tabs>
...
...
src/pages/project_manager_xy/audit-material-center.business-flow.md
View file @
e97b68f0
...
...
@@ -82,7 +82,7 @@
-
标题、案例类型、案例详情至少填写一项;未满足时在三个相关表单字段内部展示必填校验、标记错误状态并聚焦标题输入框,弹框保持打开
4.
前端调用
`POST /record`
5.
成功后生成一条
`risk_case_library`
记录
6.
页面顶部提示
创建成功,并刷新当前列表
6.
通过视口顶部操作提示浮层反馈
创建成功,并刷新当前列表
7.
新案例出现在列表中,用户可按需再打开详情继续编辑
### 4.2 Excel 批量导入案例
...
...
@@ -152,7 +152,7 @@
5.
关键字编辑区的输入框仅用于新增关键字,已有关键字以标签展示并支持逐个删除;保存时写入
`metadata.keyword`
字符串数组
6.
点击
`取消`
时丢弃本次草稿并恢复打开详情时的原值,不发起更新请求
7.
点击
`保存`
后,前端调用
`PUT /record/:name`
8.
保存成功后刷新列表与统计数据,并返回查看态;
失败时保留当前草稿和错误提示
8.
待建档材料和案例更新全部成功后刷新列表与统计数据,并返回查看态,在弹窗上方弹出“详情已保存”成功提示;写入
失败时保留当前草稿和错误提示
创建、编辑、删除、批量修改、批量删除和 Excel 导入成功后,除刷新当前列表外,还必须重新调用分类 aggregate 刷新案例性质标签;aggregate 失败时保留当前标签并展示重试提示。
...
...
@@ -183,6 +183,17 @@
5.
前端逐条调用
`DELETE /record/:name`
6.
删除后刷新列表,并保留失败项提示
### 4.9 操作提示与失败重试
-
功能入口:登录、站点配置、应用切换、驾驶舱、案例管理及其新建/详情/批量操作/Excel 导入弹框、通知中心、材料中心选择与本地附件上传。
-
查询、创建、编辑、删除、复制、批量修改/删除、Excel 导入/导出和上传的现有成功/失败反馈统一使用根部 HeroUI Toast;站点/应用列表/Subject 与 View 配置及演示数据降级使用 warning。提示显示在视口顶部,层级高于业务 Modal、确认框和移动侧栏,页面滚动及弹框打开时仍可看到并关闭。
-
成功提示默认 4 秒、错误和警告默认 8 秒后消失,悬停或键盘聚焦暂停计时;当前提示右侧的小号透明关闭图标始终可见,通过 HeroUI Toast 的
`close`
slot 支持点击和键盘激活。后排堆叠提示隐藏关闭按钮,避免误关闭。相同错误再次发生时重新提示,同一来源的新反馈替换旧反馈;清空反馈或离开所属页面时清理该来源提示。
-
关闭提示只影响展示,不改变请求结果,不清空案例草稿、附件、导入文件或勾选项,不发起创建、保存、删除或上传。新建案例的“标题/案例类型/案例详情至少一项”继续使用字段级校验和聚焦,删除与覆盖字段等确认说明保留在弹框内。
-
详情保存成功提示独立管理,在材料建档、案例更新及本次列表/分类读取结束、弹窗返回查看态后发出,保证提示的 4 秒展示时间不被刷新占用;详情弹窗继续展示保存结果。重新编辑、再次保存、取消编辑、关闭详情或打开其他案例时清理上一条保存成功提示。无修改、校验未通过、附件仍在上传或正在保存时不提交、不显示成功提示;写入失败仅显示错误并保留草稿。
-
案例列表和驾驶舱在未进入既有演示数据降级的加载失败场景中保留失败空状态及“重新加载”,不会误显示“暂无案例”;演示数据降级通过 warning 提示并继续展示案例。通知中心保留“重试”。关闭错误提示不会消除失败状态;重试只读取数据,并保留当前筛选条件、案例性质、页码和每页条数,通知沿用当前分类、已读状态及搜索词。
-
案例分类 aggregate 失败时保留旧标签及“重新加载案例分类”;视图配置失败时继续使用默认字段并以 warning 告知。查询反馈与创建/保存/批量操作/导入反馈分开管理,列表刷新不会清掉操作失败原因。批量删除/修改及导入部分失败时,用一条错误提示同时展示成功数、失败数及首条失败原因,不额外弹出成功提示。
-
材料查询失败时保留选择器字段级错误和读取重试入口;附件上传失败在弹框上方提示,上传部分失败时保留成功附件。创建/保存失败时弹框及草稿保留,重新提交仍执行原有
`metadata`
、附件快照和材料建档协议。
---
## 5. 联动规则
...
...
src/pages/project_manager_xy/notifications/NotificationListPage.jsx
View file @
e97b68f0
import
{
Bell
,
Close
,
MagnifyingGlassIcon
,
OpenExternal
}
from
'@/components/AppIcons'
;
import
{
DataTablePagination
}
from
'@/components/DataTable'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
A
lert
,
Avatar
,
Button
,
Card
,
Chip
,
SearchField
,
Spinner
,
Tabs
,
toast
}
from
'@heroui/react'
;
import
{
A
vatar
,
Button
,
Card
,
Chip
,
SearchField
,
Spinner
,
Tabs
}
from
'@heroui/react'
;
import
{
useCallback
,
useContext
,
useEffect
,
useState
}
from
'react'
;
import
{
history
}
from
'umi'
;
import
{
...
...
@@ -75,7 +76,9 @@ export default function NotificationListPage() {
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
unreadTotal
,
setUnreadTotal
]
=
useState
(
0
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
error
,
setError
]
=
useFeedbackState
(
'error'
);
const
[,
setOperationError
]
=
useFeedbackState
(
'error'
);
const
[,
setSuccess
]
=
useFeedbackState
(
'success'
);
const
[
busyKey
,
setBusyKey
]
=
useState
(
''
);
const
[
reloadKey
,
setReloadKey
]
=
useState
(
0
);
...
...
@@ -97,26 +100,29 @@ export default function NotificationListPage() {
if
(
!
cancelled
)
{
setNotifications
([]);
setTotal
(
0
);
setError
(
requestError
?.
message
||
'通知列表加载失败,请稍后重试'
);
}
}).
finally
(()
=>
{
if
(
!
cancelled
)
setLoading
(
false
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
category
,
keyword
,
page
,
readStatus
,
reloadKey
]);
},
[
category
,
keyword
,
page
,
readStatus
,
reloadKey
,
setError
]);
const
refresh
=
useCallback
(()
=>
setReloadKey
((
value
)
=>
value
+
1
),
[]);
const
runOperation
=
useCallback
(
async
(
key
,
operation
,
message
)
=>
{
setBusyKey
(
key
);
try
{
await
operation
();
toast
.
success
(
message
,
{
timeout
:
3000
});
refresh
();
return
true
;
}
catch
(
operationError
)
{
toast
.
danger
(
operationError
?.
message
||
'操作失败,请稍后重试'
,
{
timeout
:
4000
});
return
false
;
}
setOperationError
(
''
);
setSuccess
(
''
);
try
{
await
operation
();
setSuccess
(
message
);
refresh
();
return
true
;
}
catch
(
operationError
)
{
setOperationError
(
operationError
?.
message
||
'操作失败,请稍后重试'
);
return
false
;
}
finally
{
setBusyKey
(
''
);
}
},
[
refresh
]);
},
[
refresh
,
setOperationError
,
setSuccess
]);
const
handleOpenTarget
=
useCallback
(
async
(
notification
,
target
)
=>
{
setBusyKey
(
notification
.
name
);
setOperationError
(
''
);
try
{
if
(
!
isNotificationRead
(
notification
,
userInfo
?.
_id
))
await
markNotificationRead
(
notification
.
name
);
if
(
target
.
external
)
window
.
open
(
target
.
href
,
'_blank'
,
'noopener,noreferrer'
);
else
history
.
push
(
target
.
href
);
refresh
();
}
catch
(
operationError
)
{
toast
.
danger
(
operationError
?.
message
||
'无法打开通知关联流程'
,
{
timeout
:
4000
}
);
}
}
catch
(
operationError
)
{
setOperationError
(
operationError
?.
message
||
'无法打开通知关联流程'
);
}
finally
{
setBusyKey
(
''
);
}
},
[
refresh
,
userInfo
?.
_id
]);
},
[
refresh
,
setOperationError
,
userInfo
?.
_id
]);
return
(
<
div
className=
"rc-page-stack"
>
...
...
@@ -138,7 +144,15 @@ export default function NotificationListPage() {
<
Tabs
.
ListContainer
><
Tabs
.
List
aria
-
label=
"通知状态"
>
{
READ_STATUS_OPTIONS
.
map
((
option
)
=>
<
Tabs
.
Tab
className=
"w-auto min-w-max flex-none whitespace-nowrap"
id=
{
option
.
key
}
key=
{
option
.
key
}
>
{
option
.
label
}
<
Tabs
.
Indicator
/></
Tabs
.
Tab
>)
}
</
Tabs
.
List
></
Tabs
.
ListContainer
>
</
Tabs
>
</
div
>
{
error
?
<
Alert
status=
"danger"
><
Alert
.
Content
><
Alert
.
Description
>
{
error
}
</
Alert
.
Description
></
Alert
.
Content
><
Button
size=
"sm"
variant=
"danger-soft"
onPress=
{
refresh
}
>
重试
</
Button
></
Alert
>
:
null
}
{
!
loading
&&
error
?
(
<
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=
{
refresh
}
>
重试
</
Button
></
EmptyState
.
Content
>
</
EmptyState
>
)
:
null
}
{
loading
&&
notifications
.
length
===
0
?
<
div
className=
"flex min-h-64 items-center justify-center"
><
Spinner
size=
"sm"
/></
div
>
:
null
}
{
!
loading
&&
!
error
&&
notifications
.
length
===
0
?
<
EmptyState
className=
"min-h-64"
size=
"md"
><
EmptyState
.
Media
><
Bell
size=
{
22
}
/></
EmptyState
.
Media
><
EmptyState
.
Header
><
EmptyState
.
Title
>
{
category
===
'todo'
?
'暂无待办通知'
:
'暂无通知消息'
}
</
EmptyState
.
Title
><
EmptyState
.
Description
>
{
keyword
?
'没有找到匹配的通知,请调整搜索条件。'
:
readStatus
===
'unread'
?
'当前没有未读通知。'
:
'新的通知会显示在这里。'
}
</
EmptyState
.
Description
></
EmptyState
.
Header
></
EmptyState
>
:
null
}
{
notifications
.
length
>
0
?
<
div
className=
"flex min-w-0 flex-col gap-3"
aria
-
busy=
{
loading
}
>
{
notifications
.
map
((
notification
)
=>
<
NotificationItem
notification=
{
notification
}
category=
{
category
}
currentUserId=
{
userInfo
?.
_id
}
busy=
{
busyKey
===
notification
.
name
}
key=
{
notification
.
name
||
notification
.
_id
}
onMarkRead=
{
(
item
)
=>
runOperation
(
item
.
name
,
()
=>
markNotificationRead
(
item
.
name
),
'已标记为已读'
)
}
onAction=
{
(
item
,
action
)
=>
runOperation
(
item
.
name
,
()
=>
executeNotificationAction
(
item
,
action
),
'待办已处理'
)
}
onOpenTarget=
{
handleOpenTarget
}
/>)
}
</
div
>
:
null
}
...
...
src/pages/project_manager_xy/resource-booking/index.jsx
View file @
e97b68f0
This diff is collapsed.
Click to expand it.
src/pages/project_manager_xy/resource-stat/index.jsx
View file @
e97b68f0
...
...
@@ -12,7 +12,8 @@ import {
XINYUAN_CHART_SEMANTIC
,
}
from
'@/design-system/chartPalette'
;
import
XinyuanEChart
from
'@/components/XinyuanEChart'
;
import
{
Alert
,
Card
,
Spinner
}
from
'@heroui/react'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
Button
,
Card
,
Spinner
}
from
'@heroui/react'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
KPI
}
from
'@heroui-pro/react/kpi'
;
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
...
...
@@ -281,40 +282,38 @@ export default function ResourceStatPage() {
const
[
cases
,
setCases
]
=
useState
([]);
const
[
dashboard
,
setDashboard
]
=
useState
(()
=>
buildCaseDashboard
([]));
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
isDemoMode
,
setIsDemoMode
]
=
useState
(
false
);
const
[,
setError
]
=
useFeedbackState
(
'error'
);
const
[,
setDemoWarning
]
=
useFeedbackState
(
'warning'
);
const
[
loadFailed
,
setLoadFailed
]
=
useState
(
false
);
const
[
reloadKey
,
setReloadKey
]
=
useState
(
0
);
useEffect
(()
=>
{
let
cancelled
=
false
;
(
async
()
=>
{
setLoading
(
true
);
setLoadFailed
(
false
);
setError
(
''
);
setDemoWarning
(
''
);
const
caseResult
=
await
loadCaseRecords
({});
if
(
cancelled
)
return
;
setCases
(
caseResult
.
cases
||
[]);
setDashboard
(
caseResult
.
dashboard
||
buildCaseDashboard
([]));
setError
(
caseResult
.
error
||
''
);
setIsDemoMode
(
Boolean
(
caseResult
.
isDemoMode
));
setLoadFailed
(
Boolean
(
caseResult
.
error
)
&&
!
caseResult
.
isDemoMode
);
setError
(
caseResult
.
isDemoMode
?
''
:
caseResult
.
error
||
''
);
setDemoWarning
(
caseResult
.
isDemoMode
?
caseResult
.
error
||
''
:
''
);
setLoading
(
false
);
})();
return
()
=>
{
cancelled
=
true
;
};
},
[]);
},
[
reloadKey
,
setDemoWarning
,
setError
]);
return
(
<
div
className=
"rc-page-stack"
>
{
error
?
(
<
Alert
status=
{
isDemoMode
?
'warning'
:
'danger'
}
>
<
Alert
.
Content
>
<
Alert
.
Description
>
{
error
}
</
Alert
.
Description
>
</
Alert
.
Content
>
</
Alert
>
)
:
null
}
{
loading
?
(
<
div
className=
"flex min-h-48 items-center justify-center gap-2"
role=
"status"
>
<
Spinner
size=
"sm"
/>
...
...
@@ -322,9 +321,21 @@ export default function ResourceStatPage() {
</
div
>
)
:
null
}
{
!
loading
&&
cases
.
length
===
0
?
<
EmptyBlock
title=
"当前没有可展示的案例记录"
/>
:
null
}
{
!
loading
&&
loadFailed
?
(
<
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=
{
()
=>
setReloadKey
((
key
)
=>
key
+
1
)
}
>
重新加载
</
Button
>
</
EmptyState
.
Content
>
</
EmptyState
>
)
:
null
}
{
!
loading
&&
!
loadFailed
&&
cases
.
length
===
0
?
<
EmptyBlock
title=
"当前没有可展示的案例记录"
/>
:
null
}
{
!
loading
&&
cases
.
length
>
0
?
(
{
!
loading
&&
!
loadFailed
&&
cases
.
length
>
0
?
(
<>
<
section
className=
"rc-page-section"
>
<
div
className=
"rc-kpi-grid"
data
-
count=
"5"
>
...
...
src/tailwind.css
View file @
e97b68f0
...
...
@@ -7,6 +7,7 @@
@import
"./design-system/theme.css"
;
@import
"./design-system/tokens.css"
;
@import
"./design-system/shell.css"
;
@import
"./design-system/feedback.css"
;
@theme
{
--font-inter
:
...
...
src/wrapper/Site.jsx
View file @
e97b68f0
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
(
'warning
'
);
useEffect
(()
=>
{
let
cancelled
=
false
;
...
...
@@ -54,7 +55,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