Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
P
project_manager_xy
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
project_manager_xy
Commits
28960d22
Commit
28960d22
authored
Oct 08, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: 统一操作反馈并完善失败重试
parent
3963f7ff
Hide whitespace changes
Inline
Side-by-side
Showing
19 changed files
with
232 additions
and
81 deletions
+232
-81
UI_UX_AUDIT.md
docs/UI_UX_AUDIT.md
+1
-1
ui-architecture.md
docs/ui-architecture.md
+3
-0
xinyuan-design-system.md
docs/xinyuan-design-system.md
+1
-0
manifest.json
public/manifest.json
+1
-1
app.js
src/app.js
+2
-1
AppFeedback.jsx
src/components/AppFeedback.jsx
+42
-0
AppShell.jsx
src/components/AppShell.jsx
+0
-2
XinyuanUserAutocomplete.jsx
src/components/XinyuanUserAutocomplete.jsx
+21
-7
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
ProjectBookingWorkspace.jsx
src/pages/project_manager_xy/ProjectBookingWorkspace.jsx
+46
-29
NotificationListPage.jsx
...project_manager_xy/notifications/NotificationListPage.jsx
+15
-7
project-management.business-flow.md
...es/project_manager_xy/project-management.business-flow.md
+13
-1
projectStatisticsService.js
src/pages/project_manager_xy/projectStatisticsService.js
+4
-1
index.jsx
src/pages/project_manager_xy/resource-stat/index.jsx
+23
-9
tailwind.css
src/tailwind.css
+1
-0
Site.jsx
src/wrapper/Site.jsx
+2
-1
No files found.
docs/UI_UX_AUDIT.md
View file @
28960d22
...
...
@@ -69,7 +69,7 @@
-
日期与时间字段需迁移到 HeroUI DatePicker 适配器,保留现有字符串格式和回调。
-
Excel 导入仍使用可见原生文件按钮,必须迁移到 HeroUI Pro DropZone。
-
项目编辑/新建、导入、应用切换弹窗需统一遮罩、圆角、关闭按钮、Footer 和移动端按钮堆叠。
-
例行成功反馈和“暂无真实项目记录”提示不得占用页面布局,应统一为顶部居中、约 3-4 秒自动消失的 Toast;筛选请求加载期间不显示常驻文案,保持 Surface 高度稳定;需要用户修正的请求错误才保留上下文 Alert
。
-
接口成功、失败和“暂无真实项目记录”等警告统一使用根部顶部居中 Toast,成功 4 秒、失败/警告 8 秒后消失。当前提示右侧使用始终可见的小号 HeroUI 透明关闭按钮,参与正文布局,后排提示隐藏关闭按钮;浮层高于业务弹窗。详情保存成功提示在刷新结束、编辑弹窗关闭时开始计时;筛选请求加载期间不显示常驻文案,保持 Surface 高度稳定。加载失败保留失败空状态与读取重试入口,表单/文件校验继续留在上下文中
。
## P1 页面级迁移
...
...
docs/ui-architecture.md
View file @
28960d22
...
...
@@ -108,6 +108,9 @@ Layout route/path change + 60s timer + notification-state event
## Overlay 架构
-
应用根通过
`I18nProvider -> AppFeedback`
统一提供中文环境与 HeroUI Toast Portal;AppShell 不再自行挂载 Provider。
`useFeedbackState`
保存业务失败状态并为每次反馈创建 Toast,相同错误重试仍重新提示,同一来源的新消息替换旧消息,清空或卸载时清理该来源 Toast。关闭/超时仅影响浮层展示,失败空状态、重试入口、草稿和筛选仍由页面持有。
-
共享
`feedback.css`
沿用问题整改
`bfc5862`
的窄范围交互合同,仅控制 Toast 层级、顶部安全区和后排关闭按钮可见性。前排关闭按钮直接组合官方
`Button ghost/sm/isIconOnly`
、
`AppIcons.Close`
和
`slot="close"`
,随正文布局,保持可见并使用默认 Hover/焦点样式。接口成功/失败/降级警告使用 Toast;字段、周进度和导入文件校验继续保留在表单上下文中。
-
项目详情保存成功提示由工作台持有;保存接口成功、刷新流程结束并关闭编辑弹窗后再触发 4 秒成功 Toast,避免刷新耗时提前用尽展示时间。开始新建、编辑/预览其他记录或再次提交时清理上一条成功提示;保存失败保留弹窗草稿,仅报告错误。
-
Modal/AlertDialog/Dropdown/Autocomplete/DatePicker/Tooltip 直接使用 HeroUI Portal 结构。
-
Autocomplete 统一在共享 HeroUI-native 组合中使用当前
`react-stately`
Select 的受控
`value/onChange`
契约:单选值为 ID 或
`null`
,多选值为 ID 数组;官方 ClearButton、触发器显示和选项勾选均由该值驱动。远程用户字段只在业务边界输出原有 ID、
`''`
、ID 数组或
`[]`
。
`selectedKeys/onSelectionChange`
仅用于 DataGrid 等集合选择,不用于 Autocomplete 根。
-
内容 Shell 固定
`light`
,Sidebar 通过共享暗色 token 渲染;Portal 从根继承主题,不提供主题切换状态。
...
...
docs/xinyuan-design-system.md
View file @
28960d22
...
...
@@ -51,6 +51,7 @@
## 组件合同
-
操作反馈:根部
`AppFeedback`
统一挂载 HeroUI
`Toast.Provider`
,登录、AppShell 和所有业务弹窗共用一个 Provider。成功默认 4 秒、失败/警告默认 8 秒后消失,悬停或键盘聚焦暂停计时。与问题整改提交
`bfc5862`
保持一致,关闭按钮直接使用 HeroUI
`Button`
(
`ghost`
、
`sm`
、
`isIconOnly`
)及
`AppIcons.Close`
,通过官方
`slot="close"`
接入关闭行为,在正文右侧随内容参与布局;前排图标始终可见,后排堆叠提示隐藏关闭按钮。
`src/design-system/feedback.css`
仅控制层级 300、顶部安全区与后排按钮可见性;浮层高于业务 Modal、确认框与移动侧栏,长文在正文内滚动,按钮使用默认 Hover/焦点样式,颜色、边框、圆角和阴影继续使用 HeroUI 主题。接口反馈不再渲染为页面横条,关闭提示不改变请求状态或表单草稿。
-
登录:约 28rem 卡片、36px Logo、卡内品牌、24px 字段组节奏、40px 字段、46px提交按钮。
-
应用切换:46rem/42rem,24px 圆角与 28px inset,手机一列、sm 起固定两列,36px
`Surface tertiary + Avatar soft`
identity well;分组来自
`site.group`
,按后端顺序渲染并将未引用应用归入“其他应用”。
-
筛选:1-2 个字段不加容器;3 个以上使用
`Surface default rounded-3xl p-6`
,前三项常用字段,其余放 HeroUI Disclosure。
...
...
public/manifest.json
View file @
28960d22
{
"name"
:
"project_manager_xy"
,
"version"
:
"1.0.
4
"
,
"version"
:
"1.0.
5
"
,
"build"
:
"abc"
,
"description"
:
"部门任务管理"
,
"date"
:
"2025-07-14 19:27:20"
,
...
...
src/app.js
View file @
28960d22
import
'@/tailwind.css'
;
import
AppFeedback
from
'@/components/AppFeedback'
;
import
{
I18nProvider
}
from
'@heroui/react'
;
import
{
createElement
}
from
'react'
;
import
{
defineApp
}
from
'umi'
;
...
...
@@ -11,6 +12,6 @@ export async function getInitialState() {
export
default
defineApp
({
rootContainer
(
container
)
{
return
createElement
(
I18nProvider
,
{
locale
:
'zh-CN'
},
c
ontainer
);
return
createElement
(
I18nProvider
,
{
locale
:
'zh-CN'
},
c
reateElement
(
AppFeedback
,
null
,
container
)
);
},
});
src/components/AppFeedback.jsx
0 → 100644
View file @
28960d22
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/components/AppShell.jsx
View file @
28960d22
import
{
Sidebar
}
from
'@heroui-pro/react/sidebar'
;
import
{
Toast
}
from
'@heroui/react'
;
import
{
history
}
from
'umi'
;
import
DashboardNavbar
from
'./DashboardNavbar'
;
import
DashboardSidebar
from
'./DashboardSidebar'
;
...
...
@@ -32,7 +31,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/XinyuanUserAutocomplete.jsx
View file @
28960d22
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
fetchSystemUsersByIds
,
mergeUserRecords
,
...
...
@@ -29,6 +30,7 @@ export default function XinyuanUserAutocomplete({
const
[
selectedUsers
,
setSelectedUsers
]
=
useState
([]);
const
[
isOpen
,
setIsOpen
]
=
useState
(
false
);
const
[
isLoading
,
setIsLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useFeedbackState
(
'error'
);
const
requestIdRef
=
useRef
(
0
);
useEffect
(()
=>
{
...
...
@@ -42,31 +44,42 @@ export default function XinyuanUserAutocomplete({
setSelectedUsers
(
records
||
[]);
setOptions
((
current
)
=>
mergeUserRecords
([...(
current
||
[]),
...(
records
||
[])]));
}
}).
catch
(()
=>
{
}).
catch
((
requestError
)
=>
{
if
(
!
cancelled
)
{
set
SelectedUsers
([]
);
set
Error
(
requestError
?.
message
||
'已选用户信息加载失败,请重新搜索。'
);
}
});
return
()
=>
{
cancelled
=
true
;
};
},
[
selectedIds
.
join
(
'|'
)]);
},
[
selectedIds
.
join
(
'|'
)
,
setError
]);
useEffect
(()
=>
{
if
(
!
isOpen
)
return
undefined
;
let
cancelled
=
false
;
const
requestId
=
++
requestIdRef
.
current
;
const
timer
=
window
.
setTimeout
(()
=>
{
setIsLoading
(
true
);
setError
(
''
);
searchSystemUsers
(
query
)
.
then
((
records
)
=>
{
if
(
requestId
===
requestIdRef
.
current
)
{
if
(
!
cancelled
&&
requestId
===
requestIdRef
.
current
)
{
setOptions
(
mergeUserRecords
([...(
selectedUsers
||
[]),
...(
records
||
[])]));
}
})
.
catch
((
requestError
)
=>
{
if
(
!
cancelled
&&
requestId
===
requestIdRef
.
current
)
{
setError
(
requestError
?.
message
||
'用户列表加载失败,请重新搜索。'
);
}
})
.
finally
(()
=>
{
if
(
requestId
===
requestIdRef
.
current
)
setIsLoading
(
false
);
if
(
!
cancelled
&&
requestId
===
requestIdRef
.
current
)
setIsLoading
(
false
);
});
},
200
);
return
()
=>
window
.
clearTimeout
(
timer
);
},
[
isOpen
,
query
,
selectedUsers
]);
return
()
=>
{
cancelled
=
true
;
window
.
clearTimeout
(
timer
);
setIsLoading
(
false
);
};
},
[
isOpen
,
query
,
selectedUsers
,
setError
]);
const
mergedUsers
=
useMemo
(
()
=>
mergeUserRecords
([...(
selectedUsers
||
[]),
...(
options
||
[])]),
...
...
@@ -92,6 +105,7 @@ export default function XinyuanUserAutocomplete({
return
(
<
XinyuanAutocomplete
aria
-
label=
{
placeholder
}
errorText=
{
error
}
inputValue=
{
query
}
isDisabled=
{
isDisabled
}
isLoading=
{
isLoading
}
...
...
src/design-system/feedback.css
0 → 100644
View file @
28960d22
/* 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 @
28960d22
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
},
};
// Dismissing feedback must not clear the request's failure state or the form draft.
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
;
// Every attempt reports feedback, including identical errors on 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 @
28960d22
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/project_manager_xy/ApplicationSwitcherDialog.jsx
View file @
28960d22
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
xinyuanLogo
from
'@/assets/xy-logo-red.png'
;
import
{
APPLICATION_DISPLAY_NAME
,
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
,
...
...
@@ -308,7 +308,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
;
...
...
@@ -348,6 +348,7 @@ export default function ApplicationSwitcherDialog({
useEffect
(()
=>
{
if
(
!
open
)
{
setApplicationSearch
(
''
);
setErrorMessage
(
''
);
return
;
}
...
...
@@ -388,7 +389,7 @@ export default function ApplicationSwitcherDialog({
return
()
=>
{
cancelled
=
true
;
};
},
[
fallbackApplications
,
open
]);
},
[
fallbackApplications
,
open
,
setErrorMessage
]);
return
(
<
Modal
.
Root
>
...
...
@@ -439,13 +440,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/ProjectBookingWorkspace.jsx
View file @
28960d22
import
clsx
from
'clsx'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
XinyuanDatePicker
from
'@/components/XinyuanDatePicker'
;
import
XinyuanModal
from
'@/components/XinyuanModal'
;
...
...
@@ -38,7 +39,6 @@ import {
Surface
,
Tabs
,
TextArea
,
toast
,
Tooltip
,
}
from
'@heroui/react'
;
import
{
parseDate
,
parseDateTime
}
from
'@internationalized/date'
;
...
...
@@ -1781,7 +1781,6 @@ function ProjectTaskDialog({
task,
submitting,
deleting,
submitError,
onClose,
onSubmit,
onDelete,
...
...
@@ -2328,10 +2327,6 @@ function ProjectTaskDialog({
</div>
</Surface>
{submitError ? (
<Alert status="danger"><Alert.Content><Alert.Description>{submitError}</Alert.Description></Alert.Content></Alert>
) : null}
<div>
<div>
<div className="text-sm text-muted">
...
...
@@ -2394,7 +2389,9 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
const [tasks, setTasks] = useState([]);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [, setWarning] = useFeedbackState('warning');
const [, setSuccess] = useFeedbackState('success');
const [filters, setFilters] = useState(emptyFilters);
const [queryFilters, setQueryFilters] = useState(emptyFilters);
const [filtersExpanded, setFiltersExpanded] = useState(false);
...
...
@@ -2406,10 +2403,11 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
const [previewTask, setPreviewTask] = useState(null);
const [dialogSubmitting, setDialogSubmitting] = useState(false);
const [dialogDeleting, setDialogDeleting] = useState(false);
const [
dialogError, setDialogError] = useState('
');
const [
, setDialogError] = useFeedbackState('error
');
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importPreviewRows, setImportPreviewRows] = useState([]);
const [importError, setImportError] = useState('');
const [, setImportRequestError] = useFeedbackState('error');
const [importLoading, setImportLoading] = useState(false);
const [importFileName, setImportFileName] = useState('');
const latestWorkspaceRequestId = useRef(0);
...
...
@@ -2427,6 +2425,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
latestWorkspaceRequestId.current = requestId;
setLoading(true);
setError('');
setWarning('');
try {
const taskRecords = await fetchProjectTasks(activeFilters);
...
...
@@ -2460,9 +2459,9 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
setUsers(normalizedUsers);
setTasks(enrichedTasks);
if (enrichedTasks.length === 0) {
toast.warning('当前未读取到真实项目记录,请先创建或导入 `
pm_project_task
` 数据。', {
timeout: 3500,
}
);
setWarning('当前暂无项目记录,请先创建或导入项目。');
} else {
setWarning(''
);
}
} catch (requestError) {
if (requestId !== latestWorkspaceRequestId.current) {
...
...
@@ -2548,11 +2547,6 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
};
}, [visibleTasks]);
const persistTask = async (values, currentTask) => {
await saveProjectTask(values, currentTask);
await loadWorkspace();
};
const handleImportTemplateDownload = () => {
downloadProjectTaskImportTemplate();
};
...
...
@@ -2564,6 +2558,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
setImportDialogOpen(false);
setImportPreviewRows([]);
setImportError('');
setImportRequestError('');
setImportFileName('');
};
...
...
@@ -2575,6 +2570,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
setImportLoading(true);
setImportError('');
setImportRequestError('');
setImportFileName(file.name);
try {
...
...
@@ -2603,6 +2599,8 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
setImportLoading(true);
setImportError('');
setImportRequestError('');
setSuccess('');
try {
await importRecords(
...
...
@@ -2632,11 +2630,15 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
}),
})),
);
setSuccess(`
已导入
$
{
validRows
.
length
}
个项目。
`);
await loadWorkspace();
handleImportDialogClose();
setImportDialogOpen(false);
setImportPreviewRows([]);
setImportError('');
setImportFileName('');
} catch (requestError) {
setImportError(requestError?.message || '导入失败,请稍后重试');
setImport
Request
Error(requestError?.message || '导入失败,请稍后重试');
} finally {
setImportLoading(false);
}
...
...
@@ -2646,6 +2648,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
setEditingTask(null);
setDialogDeleting(false);
setDialogError('');
setSuccess('');
setDialogOpen(true);
};
...
...
@@ -2653,21 +2656,26 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
setEditingTask(task || selectedTask || null);
setDialogDeleting(false);
setDialogError('');
setSuccess('');
setDialogOpen(true);
};
const openPreviewDialog = (task) => {
setSuccess('');
setPreviewTask(task || null);
};
const handleDialogSubmit = async (values) => {
setDialogSubmitting(true);
setDialogError('');
setSuccess('');
try {
await persistTask(values, editingTask);
await saveProjectTask(values, editingTask);
await loadWorkspace();
setDialogOpen(false);
setEditingTask(null);
setSuccess(editingTask ? '详情已保存。' : '项目已创建。');
} catch (requestError) {
setDialogError(requestError?.message || '保存失败,请稍后重试');
} finally {
...
...
@@ -2684,9 +2692,11 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
const deletingTaskId = getTaskRecordId(taskToDelete);
setDialogDeleting(true);
setDialogError('');
setSuccess('');
try {
await deleteProjectTask(taskToDelete);
setSuccess('项目已删除。');
await loadWorkspace();
if (selectedTaskId && deletingTaskId === selectedTaskId) {
setSelectedTaskId('');
...
...
@@ -2818,16 +2828,24 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
</div>
</Disclosure.Content>
{error ? (
<Alert status="warning"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert>
) : null}
</div>
</div>
</Disclosure>
</ToolbarCard>
<div className="rc-kpi-grid" data-count="4">
{!loading && error ? (
<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={() => loadWorkspace()}>重新加载</Button>
</EmptyState.Content>
</EmptyState>
) : null}
<div className={error ? 'hidden' : 'rc-kpi-grid'} data-count="4">
<StatKpi
label="项目总数"
value={stats.total}
...
...
@@ -2854,7 +2872,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
/>
</div>
{activeTab === 'gantt' && !loading ? (
{activeTab === 'gantt' && !loading
&& !error
? (
<ProjectGanttBoard
tasks={visibleTasks}
selectedTaskId={selectedTaskId}
...
...
@@ -2863,7 +2881,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
/>
) : null}
{activeTab === 'calendar' && hasVisibleTasks ? (
{activeTab === 'calendar' && hasVisibleTasks
&& !error
? (
<ProjectCalendarBoard
tasks={visibleTasks}
currentUserId={currentUser?.id || ''}
...
...
@@ -2873,7 +2891,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
/>
) : null}
{activeTab === 'list' && !loading ? (
{activeTab === 'list' && !loading
&& !error
? (
<ProjectListPanel
tasks={visibleTasks}
onSelectTask={setSelectedTaskId}
...
...
@@ -2882,7 +2900,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
/>
) : null}
{activeTab === 'calendar' && !loading && !hasVisibleTasks ? (
{activeTab === 'calendar' && !loading && !
error && !
hasVisibleTasks ? (
<EmptyState className="min-h-64" size="md">
<EmptyState.Header>
<EmptyState.Title>当前暂无项目</EmptyState.Title>
...
...
@@ -2897,7 +2915,6 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
viewOnly={!dialogOpen && Boolean(previewTask)}
submitting={dialogSubmitting}
deleting={dialogDeleting}
submitError={dialogError}
fieldOptions={fieldOptions}
onClose={() => {
if (dialogSubmitting || dialogDeleting) {
...
...
src/pages/project_manager_xy/notifications/NotificationListPage.jsx
View file @
28960d22
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
Bell
,
Close
,
MagnifyingGlassIcon
,
OpenExternal
}
from
'@/components/AppIcons'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Alert
,
Avatar
,
Button
,
Card
,
...
...
@@ -144,7 +144,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
);
const
totalPages
=
Math
.
max
(
1
,
Math
.
ceil
(
total
/
pageSize
));
...
...
@@ -189,7 +189,7 @@ export default function NotificationListPage() {
return
()
=>
{
cancelled
=
true
;
};
},
[
category
,
keyword
,
page
,
pageSize
,
readStatus
,
reloadKey
]);
},
[
category
,
keyword
,
page
,
pageSize
,
readStatus
,
reloadKey
,
setError
]);
useEffect
(()
=>
{
const
totalPagesForSize
=
Math
.
max
(
1
,
Math
.
ceil
(
total
/
pageSize
));
...
...
@@ -215,11 +215,11 @@ 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
});
toast
.
danger
(
operationError
?.
message
||
'操作失败,请稍后重试'
,
{
timeout
:
8
000
});
return
false
;
}
finally
{
setBusyKey
(
''
);
...
...
@@ -254,7 +254,7 @@ export default function NotificationListPage() {
}
refresh
();
}
catch
(
operationError
)
{
toast
.
danger
(
operationError
?.
message
||
'无法打开通知关联流程'
,
{
timeout
:
4
000
});
toast
.
danger
(
operationError
?.
message
||
'无法打开通知关联流程'
,
{
timeout
:
8
000
});
}
finally
{
setBusyKey
(
''
);
}
...
...
@@ -299,7 +299,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
?
(
<
div
aria
-
label=
"通知加载中"
className=
"flex min-h-64 items-center justify-center"
><
Spinner
size=
"sm"
/></
div
>
...
...
src/pages/project_manager_xy/project-management.business-flow.md
View file @
28960d22
...
...
@@ -56,6 +56,18 @@
## 4. 业务流程
### 操作提示与失败重试
-
功能入口:登录/验证码、站点配置、应用切换、资源统计、资源预定的查询/新建/编辑/启动/完成/删除/导入、系统用户远程搜索和通知中心。
-
接口成功、失败及降级警告统一显示根部 HeroUI Toast,位于视口顶部、业务弹窗/确认框/移动侧栏上方;页面滚动或弹窗打开时仍可看到并关闭,提示不会主动抢走表单焦点。
-
成功默认 4 秒、失败和警告默认 8 秒后消失,悬停或键盘聚焦暂停计时。当前提示右侧使用 HeroUI 默认小号透明图标按钮,始终可见,通过 Toast 的
`close`
slot 支持点击和键盘关闭;后排堆叠提示隐藏关闭按钮,避免误关。同一来源的新消息替换旧消息,相同错误再次发生时重新提示,清空反馈或离开页面时清理该来源提示。
-
关闭或超时只改变提示展示,不把失败改为成功,不清空草稿、用户引用、周进度、导入预览、当前 Tab/月份或筛选,不重新执行保存、删除、导入或待办操作。必填、周进度和文件/行校验继续在对应表单内展示。
-
项目、资源统计和通知列表加载失败时展示失败空状态及“重新加载/重试”;关闭 Toast 后仍可重试,不显示“暂无数据”或零值统计冒充成功。项目重试沿用上次已提交的筛选,通知重试沿用当前分类、已读状态、关键词和页码;重试只读取数据。
-
项目保存/启动/完成/删除或批量导入成功后显示成功提示并刷新项目;导入完成后关闭导入弹窗。写入失败保留弹窗和当前输入/预览,可修正后再次提交;写入成功但刷新失败时单独提示查询错误,不重复执行写入。
-
项目预览进入编辑、甘特图/个人日历/清单进入编辑后,点击“保存项目”(含“启动项目/完成项目”)必须等
`saveProjectTask`
的创建或更新接口成功,才关闭编辑弹窗并显示成功提示:已有记录显示“详情已保存”,新建显示“项目已创建”。提示在刷新流程结束、弹窗关闭时开始 4 秒计时,由工作台持有,不因弹窗关闭而清除;刷新失败仍单独保留读取错误及重试入口。重新新建、编辑/预览其他记录或再次提交时清理旧成功提示;表单校验或保存失败时不显示成功提示并保留草稿。
-
系统用户搜索失败弹出错误提示,选择器保留字段级错误和已有引用 ID,可重新输入或重新打开搜索。站点配置或应用列表失败继续使用既有回退数据,并显示 warning;成功查询但没有项目时显示空数据警告,与加载失败区分。
-
数据仍使用
`pm_project_task.metadata`
,用户引用只传 ID/ID 数组;提示关闭不更改业务状态、字段规则、接口 URL、请求参数或权限。
### 4.1 领导下发项目
1.
领导创建一条
`pm_project_task`
...
...
@@ -290,5 +302,5 @@ const result = await queryUsers(0, 20, {
### 8.4 状态刷新与异常
-
Shell 在首次加载、路由变化、通知状态事件和每 60 秒刷新未读数。
-
列表加载失败显示
可重试 Alert;操作失败保留当前数据并显示 Toast
。
-
列表加载失败显示
失败空状态及“重试”,详细错误通过可关闭 Toast 展示;操作失败保留当前数据并显示 Toast。成功默认 4 秒、失败默认 8 秒后消失
。
-
该功能不改变
`pm_project_task`
的查询、字段、校验、分页、保存回调或业务状态流。
src/pages/project_manager_xy/projectStatisticsService.js
View file @
28960d22
...
...
@@ -44,6 +44,7 @@ export async function loadProjectDashboardData(currentUsername = '') {
fieldOptions
,
isDemoMode
:
false
,
error
:
''
,
warning
:
''
,
};
}
...
...
@@ -52,7 +53,8 @@ export async function loadProjectDashboardData(currentUsername = '') {
users
,
fieldOptions
,
isDemoMode
:
false
,
error
:
'当前未读取到真实项目记录,请先创建或导入 `pm_project_task` 数据。'
,
error
:
''
,
warning
:
'当前暂无项目记录,请先创建或导入项目。'
,
};
}
catch
(
error
)
{
return
{
...
...
@@ -61,6 +63,7 @@ export async function loadProjectDashboardData(currentUsername = '') {
fieldOptions
:
{},
isDemoMode
:
false
,
error
:
error
?.
message
||
'统计数据加载失败,请稍后重试。'
,
warning
:
''
,
};
}
}
src/pages/project_manager_xy/resource-stat/index.jsx
View file @
28960d22
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
createResolvedXinyuanChartColorMap
,
getXinyuanEChartsTheme
,
...
...
@@ -14,7 +15,7 @@ import { UserContext } from '@/wrapper/Auth';
import
{
DataGrid
}
from
'@heroui-pro/react/data-grid'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
KPI
}
from
'@heroui-pro/react/kpi'
;
import
{
Alert
,
Card
,
Spinner
,
Surface
}
from
'@heroui/react'
;
import
{
Button
,
Card
,
Spinner
,
Surface
}
from
'@heroui/react'
;
import
{
useContext
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
formatDateLabel
,
...
...
@@ -171,22 +172,27 @@ export default function ResourceStatPage({ currentUsername = '' }) {
const
[
tasks
,
setTasks
]
=
useState
([]);
const
[
users
,
setUsers
]
=
useState
([]);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
error
,
setError
]
=
useFeedbackState
(
'error'
);
const
[,
setWarning
]
=
useFeedbackState
(
'warning'
);
const
[
reloadKey
,
setReloadKey
]
=
useState
(
0
);
useEffect
(()
=>
{
let
cancelled
=
false
;
(
async
()
=>
{
setLoading
(
true
);
setError
(
''
);
setWarning
(
''
);
const
result
=
await
loadProjectDashboardData
(
effectiveCurrentUsername
);
if
(
!
cancelled
)
{
setTasks
(
result
.
tasks
||
[]);
setUsers
(
result
.
users
||
[]);
setError
(
result
.
error
||
''
);
setWarning
(
result
.
warning
||
''
);
setLoading
(
false
);
}
})();
return
()
=>
{
cancelled
=
true
;
};
},
[
effectiveCurrentUsername
]);
},
[
effectiveCurrentUsername
,
reloadKey
,
setError
,
setWarning
]);
const
dashboard
=
useMemo
(()
=>
{
const
total
=
tasks
.
length
;
...
...
@@ -260,14 +266,22 @@ export default function ResourceStatPage({ currentUsername = '' }) {
return
<
div
className=
"flex min-h-80 items-center justify-center"
><
Spinner
label=
"正在加载资源统计"
/></
div
>;
}
if
(
error
)
{
return
(
<
EmptyState
className=
"min-h-80"
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
>
);
}
return
(
<
div
className=
"rc-page-stack"
>
{
error
?
(
<
Alert
status=
"warning"
>
<
Alert
.
Content
><
Alert
.
Description
>
{
error
}
</
Alert
.
Description
></
Alert
.
Content
>
</
Alert
>
)
:
null
}
<
div
className=
"rc-kpi-grid"
data
-
count=
"3"
>
<
StatKpi
label=
"我的关联项目"
...
...
src/tailwind.css
View file @
28960d22
...
...
@@ -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 @
28960d22
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
getSystemInfo
}
from
'@/services/DataService'
;
import
dayjs
from
'dayjs'
;
import
customParseFormat
from
'dayjs/plugin/customParseFormat'
;
...
...
@@ -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
;
...
...
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