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
3963f7ff
Commit
3963f7ff
authored
Sep 30, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: 优化项目视图、任务状态与导入日期处理
parent
75369fa1
Hide whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
326 additions
and
150 deletions
+326
-150
ui-architecture.md
docs/ui-architecture.md
+2
-1
xinyuan-design-system.md
docs/xinyuan-design-system.md
+1
-1
manifest.json
public/manifest.json
+3
-2
ProjectBookingWorkspace.jsx
src/pages/project_manager_xy/ProjectBookingWorkspace.jsx
+269
-126
notificationService.js
...s/project_manager_xy/notifications/notificationService.js
+0
-1
project-management.business-flow.md
...es/project_manager_xy/project-management.business-flow.md
+14
-9
projectTaskService.js
src/pages/project_manager_xy/projectTaskService.js
+37
-10
No files found.
docs/ui-architecture.md
View file @
3963f7ff
...
@@ -93,7 +93,7 @@ Layout route/path change + 60s timer + notification-state event
...
@@ -93,7 +93,7 @@ Layout route/path change + 60s timer + notification-state event
-
HeroUI Pro 当前没有 Gantt 组件;甘特图继续复用项目既有日期窗口、列计算和任务区间算法,不误用垂直事件
`Timeline`
。
-
HeroUI Pro 当前没有 Gantt 组件;甘特图继续复用项目既有日期窗口、列计算和任务区间算法,不误用垂直事件
`Timeline`
。
-
可见结构使用 HeroUI 原生组合:L1
`Card`
承载分析模块,L2
`Surface secondary`
承载时间轴,L3
`Surface tertiary`
承载刻度和任务行。
-
可见结构使用 HeroUI 原生组合:L1
`Card`
承载分析模块,L2
`Surface secondary`
承载时间轴,L3
`Surface tertiary`
承载刻度和任务行。
-
实际进度与时间进度使用
`ProgressBar`
compound API;
结果状态使用语义
`Chip`
,空范围使用 Pro
`EmptyState`
。
-
实际进度与时间进度使用
`ProgressBar`
compound API;
项目业务状态与日期派生结果在甘特图展示层合并为唯一语义状态标签
,空范围使用 Pro
`EmptyState`
。
-
共享 CSS 只控制网格、宽度、间距、对齐与横向滚动;组件颜色、圆角、描边和交互状态由 HeroUI variant 与鑫元共享 Tokens 负责。
-
共享 CSS 只控制网格、宽度、间距、对齐与横向滚动;组件颜色、圆角、描边和交互状态由 HeroUI variant 与鑫元共享 Tokens 负责。
-
图表颜色由
`tokens.css`
的共享
`--rc-chart-*`
序列和
`XinyuanEChart`
适配器统一提供,优先使用品牌红深浅、暖色、中性和灰蓝层级;业务页面不得添加绿色、荧光色或页面私有图表色。
-
图表颜色由
`tokens.css`
的共享
`--rc-chart-*`
序列和
`XinyuanEChart`
适配器统一提供,优先使用品牌红深浅、暖色、中性和灰蓝层级;业务页面不得添加绿色、荧光色或页面私有图表色。
...
@@ -102,6 +102,7 @@ Layout route/path change + 60s timer + notification-state event
...
@@ -102,6 +102,7 @@ Layout route/path change + 60s timer + notification-state event
-
个人日历直接使用 HeroUI Pro
`Agenda`
的 month compound API:
`Header`
、
`Navigation`
、
`Body`
、
`MonthGrid`
、
`MonthRow`
、
`MonthCell`
、
`MonthSpanningEvent`
与
`MonthEvent`
。
-
个人日历直接使用 HeroUI Pro
`Agenda`
的 month compound API:
`Header`
、
`Navigation`
、
`Body`
、
`MonthGrid`
、
`MonthRow`
、
`MonthCell`
、
`MonthSpanningEvent`
与
`MonthEvent`
。
-
Agenda Header 的月份导航由官方
`NavButton`
、受控
`heading`
和
`TodayButton`
组成,顺序固定为上月、当前年月、下月、今天;Header 水平与垂直 inset 均与甘特图 Card Header 对齐。
-
Agenda Header 的月份导航由官方
`NavButton`
、受控
`heading`
和
`TodayButton`
组成,顺序固定为上月、当前年月、下月、今天;Header 水平与垂直 inset 均与甘特图 Card Header 对齐。
-
页面只把
`pm_project_task`
的计划开始/结束日期转换为只读全天事件;Agenda 持有月份矩阵、跨周分段、非本月日期、今日状态和键盘交互。
-
页面只把
`pm_project_task`
的计划开始/结束日期转换为只读全天事件;Agenda 持有月份矩阵、跨周分段、非本月日期、今日状态和键盘交互。
-
页面按 Record
`_id`
对日历输入去重,并在月视图的第一个可见事件片段展示完整标题;同一事件的后续跨周片段仅展示“持续中”,同时保留可访问名称和点击编辑能力。
-
`onEventSelect`
继续映射到既有项目编辑回调;不传入创建、移动、缩放或删除回调,日历不会修改业务数据。
-
`onEventSelect`
继续映射到既有项目编辑回调;不传入创建、移动、缩放或删除回调,日历不会修改业务数据。
-
外层业务模块使用批准的 HeroUI Card contract;窄视口只在 Agenda Body 内横向滚动,不允许形成页面级横向溢出。
-
外层业务模块使用批准的 HeroUI Card contract;窄视口只在 Agenda Body 内横向滚动,不允许形成页面级横向溢出。
...
...
docs/xinyuan-design-system.md
View file @
3963f7ff
...
@@ -62,7 +62,7 @@
...
@@ -62,7 +62,7 @@
-
图表:ECharts +
`echarts-for-react`
通过共享
`XinyuanEChart`
适配器渲染;应用扩展
`--rc-chart-*`
使用
`#DA241A`
品牌红及低饱和红、暖色、中性、灰蓝层级,禁止绿色系、荧光色和彩虹式相邻对撞。适配器读取扩展色板后再向 ECharts 注入具体颜色;Tooltip、坐标轴和饼图间隙由适配器统一处理。
-
图表:ECharts +
`echarts-for-react`
通过共享
`XinyuanEChart`
适配器渲染;应用扩展
`--rc-chart-*`
使用
`#DA241A`
品牌红及低饱和红、暖色、中性、灰蓝层级,禁止绿色系、荧光色和彩虹式相邻对撞。适配器读取扩展色板后再向 ECharts 注入具体颜色;Tooltip、坐标轴和饼图间隙由适配器统一处理。
-
模块头部只保留功能标题;图表、甘特图、日历和清单不显示重复性副标题或日期说明,必要的范围和解释由控件、坐标轴或弹窗上下文承载。
-
模块头部只保留功能标题;图表、甘特图、日历和清单不显示重复性副标题或日期说明,必要的范围和解释由控件、坐标轴或弹窗上下文承载。
-
字段标题旁的持久性规则说明使用小型 HeroUI Tooltip 信息图标承载;以项目状态为例,工作流说明不再占用字段下方垂直空间。
-
字段标题旁的持久性规则说明使用小型 HeroUI Tooltip 信息图标承载;以项目状态为例,工作流说明不再占用字段下方垂直空间。
-
个人日历:HeroUI Pro Agenda 月视图;跨日期项目使用 MonthSpanningEvent,单日项目使用 MonthEvent,导航和日期状态使用 Agenda 官方 compound API。
-
个人日历:HeroUI Pro Agenda 月视图;跨日期项目使用 MonthSpanningEvent,单日项目使用 MonthEvent,导航和日期状态使用 Agenda 官方 compound API
;同一项目在当月首次可见片段展示完整标题,后续跨周片段统一展示“持续中”,避免重复标题干扰日期阅读
。
-
个人日历 Header 导航按“上月、当前年月、下月、今天”顺序排列,当前年月使用 Agenda 的
`heading`
受控显示,不把“本月”按钮置于箭头之间;Agenda Header 移除额外水平和垂直 inset,与甘特图标题共享 Card 内容边缘。
-
个人日历 Header 导航按“上月、当前年月、下月、今天”顺序排列,当前年月使用 Agenda 的
`heading`
受控显示,不把“本月”按钮置于箭头之间;Agenda Header 移除额外水平和垂直 inset,与甘特图标题共享 Card 内容边缘。
-
消息通知:Navbar 直接使用 HeroUI
`Badge.Anchor + Badge + Button + Tooltip`
;36px Bell 位于右侧动作区。独立通知页使用 HeroUI Tabs、SearchField、Card、Pagination 和 HeroUI Pro EmptyState,侧栏不增加通知入口。
-
消息通知:Navbar 直接使用 HeroUI
`Badge.Anchor + Badge + Button + Tooltip`
;36px Bell 位于右侧动作区。独立通知页使用 HeroUI Tabs、SearchField、Card、Pagination 和 HeroUI Pro EmptyState,侧栏不增加通知入口。
...
...
public/manifest.json
View file @
3963f7ff
...
@@ -2,7 +2,8 @@
...
@@ -2,7 +2,8 @@
"name"
:
"project_manager_xy"
,
"name"
:
"project_manager_xy"
,
"version"
:
"1.0.4"
,
"version"
:
"1.0.4"
,
"build"
:
"abc"
,
"build"
:
"abc"
,
"description"
:
"
merge master
"
,
"description"
:
"
部门任务管理
"
,
"date"
:
"2025-07-14 19:27:20"
,
"date"
:
"2025-07-14 19:27:20"
,
"author"
:
"yang"
"author"
:
"yang"
,
"spa"
:
true
}
}
src/pages/project_manager_xy/ProjectBookingWorkspace.jsx
View file @
3963f7ff
...
@@ -90,6 +90,32 @@ function formatUserLabel(user) {
...
@@ -90,6 +90,32 @@ function formatUserLabel(user) {
return
user
?.
displayName
||
user
?.
username
||
user
?.
id
||
'-'
;
return
user
?.
displayName
||
user
?.
username
||
user
?.
id
||
'-'
;
}
}
function
formatUserListLabel
(
users
=
[])
{
return
users
.
filter
(
Boolean
).
map
(
formatUserLabel
).
join
(
'、'
)
||
'-'
;
}
function
UserListCell
({
users
=
[]
})
{
const
normalizedUsers
=
users
.
filter
(
Boolean
);
const
displayNames
=
formatUserListLabel
(
normalizedUsers
);
const
usernames
=
normalizedUsers
.
map
((
user
)
=>
user
?.
username
||
user
?.
id
)
.
filter
(
Boolean
)
.
join
(
'、'
);
return
(
<
span
className=
"rc-data-grid-text-pair"
>
<
span
className=
"rc-data-grid-primary block whitespace-normal break-words"
>
{
displayNames
}
</
span
>
{
usernames
?
(
<
span
className=
"rc-data-grid-secondary block whitespace-normal break-words"
>
{
usernames
}
</
span
>
)
:
null
}
</
span
>
);
}
function
UserSearchSelect
(
props
)
{
function
UserSearchSelect
(
props
)
{
return
<
XinyuanUserAutocomplete
{
...
props
}
/>;
return
<
XinyuanUserAutocomplete
{
...
props
}
/>;
}
}
...
@@ -103,6 +129,7 @@ function OptionAutocomplete({
...
@@ -103,6 +129,7 @@ function OptionAutocomplete({
onChange
,
onChange
,
options
,
options
,
placeholder
=
'请选择'
,
placeholder
=
'请选择'
,
isDisabled
=
false
,
isRequired
=
false
,
isRequired
=
false
,
})
{
})
{
const
normalizedOptions
=
(
options
||
[]).
map
((
option
)
=>
({
const
normalizedOptions
=
(
options
||
[]).
map
((
option
)
=>
({
...
@@ -111,6 +138,7 @@ function OptionAutocomplete({
...
@@ -111,6 +138,7 @@ function OptionAutocomplete({
}));
}));
return
(
return
(
<
XinyuanAutocomplete
<
XinyuanAutocomplete
isDisabled=
{
isDisabled
}
isRequired=
{
isRequired
}
isRequired=
{
isRequired
}
options=
{
normalizedOptions
}
options=
{
normalizedOptions
}
placeholder=
{
placeholder
}
placeholder=
{
placeholder
}
...
@@ -147,17 +175,33 @@ function normalizeImportText(value) {
...
@@ -147,17 +175,33 @@ function normalizeImportText(value) {
return
String
(
value
??
''
).
trim
();
return
String
(
value
??
''
).
trim
();
}
}
function
normalizeCalendarDate
(
yearValue
,
monthValue
,
dayValue
)
{
const
year
=
Number
(
yearValue
);
const
month
=
Number
(
monthValue
);
const
day
=
Number
(
dayValue
);
const
date
=
new
Date
(
Date
.
UTC
(
year
,
month
-
1
,
day
));
if
(
!
Number
.
isInteger
(
year
)
||
!
Number
.
isInteger
(
month
)
||
!
Number
.
isInteger
(
day
)
||
year
<
1000
||
date
.
getUTCFullYear
()
!==
year
||
date
.
getUTCMonth
()
!==
month
-
1
||
date
.
getUTCDate
()
!==
day
)
{
return
''
;
}
return
`
${
String
(
year
).
padStart
(
4
,
'0'
)}
-
${
String
(
month
).
padStart
(
2
,
'0'
)}
-
${
String
(
day
).
padStart
(
2
,
'0'
)}
`
;
}
function
parseLooseDateInput
(
value
)
{
function
parseLooseDateInput
(
value
)
{
const
normalizedValue
=
normalizeImportText
(
value
);
const
normalizedValue
=
normalizeImportText
(
value
);
if
(
!
normalizedValue
)
{
if
(
!
normalizedValue
)
{
return
''
;
return
''
;
}
}
const
directDate
=
formatDateInput
(
normalizedValue
);
if
(
directDate
)
{
return
directDate
;
}
const
excelDate
=
Number
(
normalizedValue
);
const
excelDate
=
Number
(
normalizedValue
);
if
(
Number
.
isFinite
(
excelDate
)
&&
excelDate
>
59
)
{
if
(
Number
.
isFinite
(
excelDate
)
&&
excelDate
>
59
)
{
const
parsedExcelDate
=
new
Date
(
Math
.
round
((
excelDate
-
25569
)
*
86400
*
1000
));
const
parsedExcelDate
=
new
Date
(
Math
.
round
((
excelDate
-
25569
)
*
86400
*
1000
));
...
@@ -177,21 +221,27 @@ function parseLooseDateInput(value) {
...
@@ -177,21 +221,27 @@ function parseLooseDateInput(value) {
const
fullDateMatch
=
sanitizedValue
.
match
(
/^
(\d{4})
-
(\d{1,2})
-
(\d{1,2})
$/
);
const
fullDateMatch
=
sanitizedValue
.
match
(
/^
(\d{4})
-
(\d{1,2})
-
(\d{1,2})
$/
);
if
(
fullDateMatch
)
{
if
(
fullDateMatch
)
{
const
[,
year
,
month
,
day
]
=
fullDateMatch
;
const
[,
year
,
month
,
day
]
=
fullDateMatch
;
return
formatDateInput
(
return
normalizeCalendarDate
(
year
,
month
,
day
);
`
${
year
}
-
${
String
(
month
).
padStart
(
2
,
'0'
)}
-
${
String
(
day
).
padStart
(
2
,
'0'
)}
`
,
}
);
const
excelFormattedDateMatch
=
sanitizedValue
.
match
(
/^
(\d{1,2})
-
(\d{1,2})
-
(\d{2}
|
\d{4})
$/
);
if
(
excelFormattedDateMatch
)
{
const
[,
month
,
day
,
yearValue
]
=
excelFormattedDateMatch
;
const
shortYear
=
Number
(
yearValue
);
const
year
=
yearValue
.
length
===
2
?
shortYear
+
(
shortYear
>=
50
?
1900
:
2000
)
:
shortYear
;
return
normalizeCalendarDate
(
year
,
month
,
day
);
}
}
const
monthDayMatch
=
normalizedValue
.
match
(
/^
(\d{1,2})
月
(\d{1,2})
日
?
$/
);
const
monthDayMatch
=
normalizedValue
.
match
(
/^
(\d{1,2})
月
(\d{1,2})
日
?
$/
);
if
(
monthDayMatch
)
{
if
(
monthDayMatch
)
{
const
currentYear
=
new
Date
().
getFullYear
();
const
currentYear
=
new
Date
().
getFullYear
();
const
[,
month
,
day
]
=
monthDayMatch
;
const
[,
month
,
day
]
=
monthDayMatch
;
return
formatDateInput
(
return
normalizeCalendarDate
(
currentYear
,
month
,
day
);
`
${
currentYear
}
-
${
String
(
month
).
padStart
(
2
,
'0'
)}
-
${
String
(
day
).
padStart
(
2
,
'0'
)}
`
,
);
}
}
return
''
;
return
formatDateInput
(
normalizedValue
)
;
}
}
function
parsePercentInput
(
value
)
{
function
parsePercentInput
(
value
)
{
...
@@ -654,6 +704,7 @@ function getGanttColumnMinWidth(viewType) {
...
@@ -654,6 +704,7 @@ function getGanttColumnMinWidth(viewType) {
function
buildTaskFormValues
(
task
)
{
function
buildTaskFormValues
(
task
)
{
const
metadata
=
task
?.
metadata
||
{};
const
metadata
=
task
?.
metadata
||
{};
const
actualFinishDate
=
formatDateInput
(
metadata
.
actual_finish_date
);
return
{
return
{
project_name
:
metadata
.
project_name
||
''
,
project_name
:
metadata
.
project_name
||
''
,
big_category
:
metadata
.
big_category
||
''
,
big_category
:
metadata
.
big_category
||
''
,
...
@@ -674,12 +725,12 @@ function buildTaskFormValues(task) {
...
@@ -674,12 +725,12 @@ function buildTaskFormValues(task) {
planned_start_date
:
formatDateInput
(
metadata
.
planned_start_date
),
planned_start_date
:
formatDateInput
(
metadata
.
planned_start_date
),
planned_end_date
:
formatDateInput
(
metadata
.
planned_end_date
),
planned_end_date
:
formatDateInput
(
metadata
.
planned_end_date
),
actual_start_date
:
formatDateInput
(
metadata
.
actual_start_date
),
actual_start_date
:
formatDateInput
(
metadata
.
actual_start_date
),
actual_finish_date
:
formatDateInput
(
metadata
.
actual_finish_date
)
,
actual_finish_date
:
actualFinishDate
,
description
:
metadata
.
description
||
''
,
description
:
metadata
.
description
||
''
,
remark
:
metadata
.
remark
||
''
,
remark
:
metadata
.
remark
||
''
,
priority
:
metadata
.
priority
||
''
,
priority
:
metadata
.
priority
||
''
,
is_annual_audit_plan
:
Boolean
(
metadata
.
is_annual_audit_plan
),
is_annual_audit_plan
:
Boolean
(
metadata
.
is_annual_audit_plan
),
project_status
:
metadata
.
project_status
||
'draft'
,
project_status
:
actualFinishDate
?
'completed'
:
metadata
.
project_status
||
'draft'
,
completion_percent
:
metadata
.
completion_percent
??
0
,
completion_percent
:
metadata
.
completion_percent
??
0
,
delay_reason
:
metadata
.
delay_reason
||
''
,
delay_reason
:
metadata
.
delay_reason
||
''
,
weekly_updates
:
normalizeWeeklyUpdatesValue
(
metadata
.
weekly_updates
),
weekly_updates
:
normalizeWeeklyUpdatesValue
(
metadata
.
weekly_updates
),
...
@@ -732,35 +783,81 @@ function FilterField({ label, children }) {
...
@@ -732,35 +783,81 @@ function FilterField({ label, children }) {
</
div
>
</
div
>
);
);
}
}
function
getTaskTone
(
task
)
{
function
getGanttDisplayStatus
(
task
)
{
if
(
task
.
projectStatus
===
'cancelled'
)
{
return
{
key
:
'cancelled'
,
label
:
'已取消'
};
}
if
(
task
.
resultStatus
===
'completed_early'
)
{
if
(
task
.
resultStatus
===
'completed_early'
)
{
return
{
key
:
'completed_early'
,
label
:
'提前完成'
};
}
if
(
task
.
resultStatus
===
'completed_on_time'
)
{
return
{
key
:
'completed_on_time'
,
label
:
'准时完成'
};
}
if
(
task
.
resultStatus
===
'completed_overdue'
)
{
return
{
key
:
'completed_overdue'
,
label
:
'超时完成'
};
}
if
(
task
.
projectStatus
===
'blocked'
)
{
return
{
key
:
'blocked'
,
label
:
task
.
isOvertime
?
`受阻(超时
${
task
.
overtimeDays
}
天)`
:
'受阻'
,
};
}
if
(
task
.
resultStatus
===
'overdue'
)
{
return
{
key
:
'overdue'
,
label
:
task
.
isOvertime
?
`已超时(
${
task
.
overtimeDays
}
天)`
:
'已超时'
,
};
}
if
(
task
.
resultStatus
===
'not_started'
)
{
return
{
key
:
'not_started'
,
label
:
'未开始'
};
}
if
(
task
.
projectStatus
===
'in_progress'
)
{
return
{
key
:
'in_progress'
,
label
:
'进行中'
};
}
if
(
task
.
projectStatus
===
'completed'
)
{
return
{
key
:
'completed'
,
label
:
'已完成'
};
}
return
{
key
:
task
.
projectStatus
||
'draft'
,
label
:
task
.
projectStatusLabel
||
'草稿'
,
};
}
function
getTaskTone
(
displayStatus
)
{
if
(
displayStatus
.
key
===
'completed_early'
||
displayStatus
.
key
===
'completed_on_time'
||
displayStatus
.
key
===
'completed'
)
{
return
{
return
{
surface
:
'bg-emerald-50 border-emerald-200'
,
surface
:
'bg-emerald-50 border-emerald-200'
,
accent
:
'bg-emerald-500'
,
accent
:
'bg-emerald-500'
,
text
:
'text-emerald-700'
,
text
:
'text-emerald-700'
,
};
};
}
}
if
(
task
.
resultStatus
===
'completed_overdue'
||
task
.
resultStatus
===
'overdue'
)
{
if
(
displayStatus
.
key
===
'completed_overdue'
||
displayStatus
.
key
===
'overdue'
)
{
return
{
return
{
surface
:
'bg-rose-50 border-rose-200'
,
surface
:
'bg-rose-50 border-rose-200'
,
accent
:
'bg-rose-500'
,
accent
:
'bg-rose-500'
,
text
:
'text-rose-700'
,
text
:
'text-rose-700'
,
};
};
}
}
if
(
task
.
projectStatus
===
'blocked'
)
{
if
(
displayStatus
.
key
===
'blocked'
)
{
return
{
return
{
surface
:
'bg-amber-50 border-amber-200'
,
surface
:
'bg-amber-50 border-amber-200'
,
accent
:
'bg-amber-500'
,
accent
:
'bg-amber-500'
,
text
:
'text-amber-700'
,
text
:
'text-amber-700'
,
};
};
}
}
if
(
task
.
projectStatus
===
'completed'
)
{
return
{
surface
:
'bg-rose-50 border-rose-200'
,
accent
:
'bg-rose-500'
,
text
:
'text-rose-700'
,
};
}
return
{
return
{
surface
:
'bg-zinc-50 border-zinc-200'
,
surface
:
'bg-zinc-50 border-zinc-200'
,
accent
:
'bg-[var(--pm-brand)]'
,
accent
:
'bg-[var(--pm-brand)]'
,
...
@@ -770,11 +867,12 @@ function getTaskTone(task) {
...
@@ -770,11 +867,12 @@ function getTaskTone(task) {
function
GanttLegend
()
{
function
GanttLegend
()
{
const
items
=
[
const
items
=
[
{
label
:
'
正常进行
'
,
className
:
'bg-zinc-400'
},
{
label
:
'
待开始
'
,
className
:
'bg-zinc-400'
},
{
label
:
'
提前完成'
,
className
:
'bg-emerald-500
'
},
{
label
:
'
进行中'
,
className
:
'bg-[var(--pm-brand)]
'
},
{
label
:
'
已超时'
,
className
:
'bg-rose
-500'
},
{
label
:
'
完成'
,
className
:
'bg-emerald
-500'
},
{
label
:
'
已完成
'
,
className
:
'bg-rose-500'
},
{
label
:
'
超时
'
,
className
:
'bg-rose-500'
},
{
label
:
'受阻'
,
className
:
'bg-amber-500'
},
{
label
:
'受阻'
,
className
:
'bg-amber-500'
},
{
label
:
'已取消'
,
className
:
'bg-zinc-400'
},
];
];
return
(
return
(
...
@@ -842,7 +940,8 @@ function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask })
...
@@ -842,7 +940,8 @@ function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask })
</
div
>
</
div
>
))
}
))
}
{
visibleTasks
.
length
>
0
?
visibleTasks
.
map
((
task
)
=>
{
{
visibleTasks
.
length
>
0
?
visibleTasks
.
map
((
task
)
=>
{
const
tone
=
getTaskTone
(
task
);
const
displayStatus
=
getGanttDisplayStatus
(
task
);
const
tone
=
getTaskTone
(
displayStatus
);
const
taskId
=
getTaskRecordId
(
task
);
const
taskId
=
getTaskRecordId
(
task
);
const
visibleStartIndex
=
timelineMeta
.
columns
.
findIndex
((
column
)
=>
column
.
endKey
>=
task
.
plannedStartDate
);
const
visibleStartIndex
=
timelineMeta
.
columns
.
findIndex
((
column
)
=>
column
.
endKey
>=
task
.
plannedStartDate
);
const
visibleEndIndex
=
[...
timelineMeta
.
columns
].
reverse
().
findIndex
((
column
)
=>
column
.
startKey
<=
task
.
plannedEndDate
);
const
visibleEndIndex
=
[...
timelineMeta
.
columns
].
reverse
().
findIndex
((
column
)
=>
column
.
startKey
<=
task
.
plannedEndDate
);
...
@@ -855,16 +954,25 @@ function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask })
...
@@ -855,16 +954,25 @@ function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask })
<
button
type=
"button"
onClick=
{
()
=>
onSelectTask
(
taskId
)
}
className=
{
clsx
(
'sticky left-0 z-[5] flex h-full min-h-[104px] flex-col items-start justify-center border-b border-r border-zinc-200 bg-white px-4 py-2 text-left transition hover:bg-zinc-50'
,
selectedTaskId
===
taskId
?
'bg-rose-50'
:
''
)
}
>
<
button
type=
"button"
onClick=
{
()
=>
onSelectTask
(
taskId
)
}
className=
{
clsx
(
'sticky left-0 z-[5] flex h-full min-h-[104px] flex-col items-start justify-center border-b border-r border-zinc-200 bg-white px-4 py-2 text-left transition hover:bg-zinc-50'
,
selectedTaskId
===
taskId
?
'bg-rose-50'
:
''
)
}
>
<
div
className=
"w-full"
>
<
div
className=
"w-full"
>
<
div
className=
"truncate text-sm font-semibold text-zinc-900"
>
{
task
.
projectName
}
</
div
>
<
div
className=
"truncate text-sm font-semibold text-zinc-900"
>
{
task
.
projectName
}
</
div
>
<
div
className=
"mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] leading-4 text-zinc-500"
>
<
div
className=
"mt-1 grid gap-0.5 text-[11px] leading-4 text-zinc-500"
>
<
span
className=
"truncate"
>
{
task
.
bigCategory
||
task
.
projectCategory
||
'未分类'
}
</
span
>
<
span
className=
"whitespace-normal break-words"
>
<
span
className=
"truncate"
>
负责人
{
formatUserLabel
(
task
.
projectOwnerUser
)
}
</
span
>
{
task
.
bigCategory
||
task
.
projectCategory
||
'未分类'
}
<
span
className=
{
clsx
(
'font-medium'
,
tone
.
text
)
}
>
{
task
.
projectStatusLabel
}
/
{
task
.
resultStatusLabel
}
</
span
>
</
span
>
<
span
className=
"whitespace-normal break-words"
>
负责人
{
formatUserListLabel
(
task
.
projectOwnerUsers
)
}
</
span
>
<
span
className=
"whitespace-normal break-words"
>
协作人
{
formatUserListLabel
(
task
.
projectCollaboratorUsers
)
}
</
span
>
<
span
className=
{
clsx
(
'font-medium'
,
tone
.
text
)
}
>
{
displayStatus
.
label
}
</
span
>
</
div
>
</
div
>
</
div
>
</
div
>
</
button
>
</
button
>
<
div
className=
"relative h-[104px] border-b border-zinc-200"
style=
{
{
gridColumn
:
`2 / span ${timelineMeta.columns.length}`
}
}
>
<
div
className=
"relative h-
full min-h-
[104px] border-b border-zinc-200"
style=
{
{
gridColumn
:
`2 / span ${timelineMeta.columns.length}`
}
}
>
<
div
className=
"absolute inset-0 grid"
style=
{
{
gridTemplateColumns
:
`repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))`
}
}
>
<
div
className=
"absolute inset-0 grid"
style=
{
{
gridTemplateColumns
:
`repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))`
}
}
>
{
timelineMeta
.
columns
.
map
((
column
)
=>
<
div
key=
{
`${taskId}-${column.key}`
}
className=
{
clsx
(
'h-[104px] border-r border-zinc-200'
,
column
.
isToday
?
'bg-rose-50/60'
:
'bg-white'
)
}
/>)
}
{
timelineMeta
.
columns
.
map
((
column
)
=>
<
div
key=
{
`${taskId}-${column.key}`
}
className=
{
clsx
(
'h-
full min-h-
[104px] border-r border-zinc-200'
,
column
.
isToday
?
'bg-rose-50/60'
:
'bg-white'
)
}
/>)
}
</
div
>
</
div
>
{
hasVisibleBar
?
<
div
className=
"absolute inset-0 grid items-center"
style=
{
{
gridTemplateColumns
:
`repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))`
,
gridTemplateRows
:
'1fr'
}
}
>
{
hasVisibleBar
?
<
div
className=
"absolute inset-0 grid items-center"
style=
{
{
gridTemplateColumns
:
`repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))`
,
gridTemplateRows
:
'1fr'
}
}
>
<
div
className=
"flex h-full items-center"
style=
{
{
gridColumn
:
`${startIndex + 1} / span ${span}`
}
}
>
<
div
className=
"flex h-full items-center"
style=
{
{
gridColumn
:
`${startIndex + 1} / span ${span}`
}
}
>
...
@@ -873,7 +981,7 @@ function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask })
...
@@ -873,7 +981,7 @@ function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask })
<
div
className=
"min-w-0 flex-1 text-left"
>
<
div
className=
"min-w-0 flex-1 text-left"
>
<
div
className=
"flex items-center justify-between gap-2"
>
<
div
className=
"flex items-center justify-between gap-2"
>
<
div
className=
"truncate text-sm font-semibold text-zinc-900"
>
{
task
.
projectName
}
</
div
>
<
div
className=
"truncate text-sm font-semibold text-zinc-900"
>
{
task
.
projectName
}
</
div
>
<
span
className=
{
clsx
(
'ml-3 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium'
,
tone
.
surface
,
tone
.
text
)
}
>
{
task
.
resultStatusL
abel
}
</
span
>
<
span
className=
{
clsx
(
'ml-3 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium'
,
tone
.
surface
,
tone
.
text
)
}
>
{
displayStatus
.
l
abel
}
</
span
>
</
div
>
</
div
>
<
div
className=
"mt-1 truncate text-[11px] leading-4 text-zinc-500"
>
时间
{
formatPercent
(
task
.
plannedProgressPercent
)
}
· 实际
{
formatPercent
(
task
.
progressPercent
)
}
·
{
task
.
plannedStartDate
}
~
{
task
.
plannedEndDate
}
· 周进度
{
task
.
weeklyUpdateCount
}
条
{
task
.
latestWeeklySummary
?
` · ${task.latestWeeklySummary}`
:
''
}
</
div
>
<
div
className=
"mt-1 truncate text-[11px] leading-4 text-zinc-500"
>
时间
{
formatPercent
(
task
.
plannedProgressPercent
)
}
· 实际
{
formatPercent
(
task
.
progressPercent
)
}
·
{
task
.
plannedStartDate
}
~
{
task
.
plannedEndDate
}
· 周进度
{
task
.
weeklyUpdateCount
}
条
{
task
.
latestWeeklySummary
?
` · ${task.latestWeeklySummary}`
:
''
}
</
div
>
</
div
>
</
div
>
...
@@ -898,10 +1006,24 @@ function ProjectCalendarBoard({
...
@@ -898,10 +1006,24 @@ function ProjectCalendarBoard({
onOpenTask
,
onOpenTask
,
})
{
})
{
const
personalTasks
=
useMemo
(
const
personalTasks
=
useMemo
(
()
=>
()
=>
{
currentUserId
c
onst
matchingTasks
=
c
urrentUserId
?
tasks
.
filter
((
task
)
=>
task
.
relatedUserIds
.
includes
(
currentUserId
))
?
tasks
.
filter
((
task
)
=>
task
.
relatedUserIds
.
includes
(
currentUserId
))
:
tasks
,
:
tasks
;
const
seenTaskIds
=
new
Set
();
return
matchingTasks
.
filter
((
task
)
=>
{
const
taskId
=
String
(
getTaskRecordId
(
task
)
||
''
);
if
(
!
taskId
)
{
return
true
;
}
if
(
seenTaskIds
.
has
(
taskId
))
{
return
false
;
}
seenTaskIds
.
add
(
taskId
);
return
true
;
});
},
[
currentUserId
,
tasks
],
[
currentUserId
,
tasks
],
);
);
const
calendarEvents
=
useMemo
(
const
calendarEvents
=
useMemo
(
...
@@ -956,6 +1078,37 @@ function ProjectCalendarBoard({
...
@@ -956,6 +1078,37 @@ function ProjectCalendarBoard({
}
}
},
},
});
});
const
monthRows
=
useMemo
(()
=>
{
const
labeledEventIds
=
new
Set
();
const
markEventFragment
=
(
event
)
=>
{
const
eventId
=
String
(
event
.
id
);
const
isContinuation
=
labeledEventIds
.
has
(
eventId
);
labeledEventIds
.
add
(
eventId
);
return
{
event
,
isContinuation
};
};
return
agenda
.
visibleWeeks
.
map
((
week
)
=>
{
const
rowLayout
=
agenda
.
getMonthRowLayout
(
week
);
const
spanningItems
=
rowLayout
.
items
.
map
((
item
)
=>
({
...
item
,
...
markEventFragment
(
item
.
event
),
}));
const
cells
=
week
.
map
((
date
)
=>
({
date
,
events
:
agenda
.
getPerCellEvents
(
date
,
week
)
.
map
((
event
)
=>
markEventFragment
(
event
)),
}));
return
{
key
:
week
[
0
].
toString
(),
rowCount
:
rowLayout
.
rowCount
,
spanningItems
,
cells
,
};
});
},
[
agenda
.
getMonthRowLayout
,
agenda
.
getPerCellEvents
,
agenda
.
visibleWeeks
]);
return
(
return
(
<
Card
className=
"rc-card-surface rc-dashboard-card"
>
<
Card
className=
"rc-card-surface rc-dashboard-card"
>
...
@@ -981,43 +1134,47 @@ function ProjectCalendarBoard({
...
@@ -981,43 +1134,47 @@ function ProjectCalendarBoard({
</
Agenda
.
Header
>
</
Agenda
.
Header
>
<
Agenda
.
Body
>
<
Agenda
.
Body
>
<
Agenda
.
MonthGrid
className=
"min-w-[720px]"
>
<
Agenda
.
MonthGrid
className=
"min-w-[720px]"
>
{
agenda
.
visibleWeeks
.
map
((
week
)
=>
{
{
monthRows
.
map
((
monthRow
)
=>
(
const
rowLayout
=
agenda
.
getMonthRowLayout
(
week
);
<
Agenda
.
MonthRow
return
(
key=
{
monthRow
.
key
}
<
Agenda
.
MonthRow
spanningRowCount=
{
monthRow
.
rowCount
}
key=
{
week
[
0
].
toString
()
}
>
spanningRowCount=
{
rowLayout
.
rowCount
}
{
monthRow
.
spanningItems
.
map
((
item
)
=>
(
>
<
Agenda
.
MonthSpanningEvent
{
rowLayout
.
items
.
map
((
item
)
=>
(
key=
{
`${monthRow.key}-${item.event.id}`
}
<
Agenda
.
MonthSpanningEvent
aria
-
label=
{
key=
{
item
.
event
.
id
}
item
.
isContinuation
aria
-
label=
{
item
.
event
.
title
}
?
`${item.event.title},排期延续`
event=
{
item
.
event
}
:
item
.
event
.
title
colStart=
{
item
.
colStart
}
}
colSpan=
{
item
.
colSpan
}
event=
{
item
.
event
}
row=
{
item
.
row
}
colStart=
{
item
.
colStart
}
>
colSpan=
{
item
.
colSpan
}
{
item
.
event
.
title
}
row=
{
item
.
row
}
</
Agenda
.
MonthSpanningEvent
>
>
))
}
{
item
.
isContinuation
?
'持续中'
:
item
.
event
.
title
}
{
week
.
map
((
date
)
=>
(
</
Agenda
.
MonthSpanningEvent
>
<
Agenda
.
MonthCell
))
}
key=
{
date
.
toString
()
}
{
monthRow
.
cells
.
map
(({
date
,
events
})
=>
(
date=
{
date
}
<
Agenda
.
MonthCell
maxEvents=
{
2
}
key=
{
date
.
toString
()
}
moreLabel=
{
(
count
)
=>
`另有 ${count} 项`
}
date=
{
date
}
spanningRowCount=
{
rowLayout
.
rowCount
}
maxEvents=
{
2
}
>
moreLabel=
{
(
count
)
=>
`另有 ${count} 项`
}
{
agenda
.
getPerCellEvents
(
date
,
week
).
map
((
event
)
=>
(
spanningRowCount=
{
monthRow
.
rowCount
}
<
Agenda
.
MonthEvent
key=
{
event
.
id
}
event=
{
event
}
>
>
{
event
.
title
}
{
events
.
map
(({
event
,
isContinuation
})
=>
(
</
Agenda
.
MonthEvent
>
<
Agenda
.
MonthEvent
))
}
key=
{
`${date.toString()}-${event.id}`
}
</
Agenda
.
MonthCell
>
event=
{
event
}
))
}
>
</
Agenda
.
MonthRow
>
{
isContinuation
?
'持续中'
:
event
.
title
}
);
</
Agenda
.
MonthEvent
>
})
}
))
}
</
Agenda
.
MonthCell
>
))
}
</
Agenda
.
MonthRow
>
))
}
</
Agenda
.
MonthGrid
>
</
Agenda
.
MonthGrid
>
</
Agenda
.
Body
>
</
Agenda
.
Body
>
</
Agenda
>
</
Agenda
>
...
@@ -1061,15 +1218,14 @@ function ProjectListPanel({ tasks, onSelectTask, onEditTask, onPreviewTask }) {
...
@@ -1061,15 +1218,14 @@ function ProjectListPanel({ tasks, onSelectTask, onEditTask, onPreviewTask }) {
{
{
id
:
'owner'
,
id
:
'owner'
,
header
:
'负责人'
,
header
:
'负责人'
,
minWidth
:
160
,
minWidth
:
180
,
cell
:
(
task
)
=>
(
cell
:
(
task
)
=>
<
UserListCell
users=
{
task
.
projectOwnerUsers
}
/>,
<
span
className=
"rc-data-grid-text-pair"
>
},
<
span
className=
"rc-data-grid-primary block truncate"
>
{
formatUserLabel
(
task
.
projectOwnerUser
)
}
</
span
>
{
<
span
className=
"rc-data-grid-secondary block truncate"
>
id
:
'collaborator'
,
{
task
.
projectOwnerUser
?.
username
||
task
.
projectOwnerUser
?.
id
||
''
}
header
:
'协作人'
,
</
span
>
minWidth
:
180
,
</
span
>
cell
:
(
task
)
=>
<
UserListCell
users=
{
task
.
projectCollaboratorUsers
}
/>,
),
},
},
{
{
id
:
'status'
,
id
:
'status'
,
...
@@ -1152,7 +1308,7 @@ function ProjectListPanel({ tasks, onSelectTask, onEditTask, onPreviewTask }) {
...
@@ -1152,7 +1308,7 @@ function ProjectListPanel({ tasks, onSelectTask, onEditTask, onPreviewTask }) {
allowsColumnResize
allowsColumnResize
aria
-
label=
"项目列表"
aria
-
label=
"项目列表"
columns=
{
columns
}
columns=
{
columns
}
contentClassName=
"min-w-[1
3
20px]"
contentClassName=
"min-w-[1
5
20px]"
data=
{
pageRows
}
data=
{
pageRows
}
getRowId=
{
getTaskRecordId
}
getRowId=
{
getTaskRecordId
}
renderEmptyState=
{
()
=>
(
renderEmptyState=
{
()
=>
(
...
@@ -1360,7 +1516,7 @@ function WeeklyUpdateEditorDialog({
...
@@ -1360,7 +1516,7 @@ function WeeklyUpdateEditorDialog({
}
}
const
IMPORT_PREVIEW_COLUMNS
=
[
const
IMPORT_PREVIEW_COLUMNS
=
[
{
id
:
'__rowIndex'
,
header
:
'
#
'
,
accessorKey
:
'__rowIndex'
,
width
:
72
,
isRowHeader
:
true
},
{
id
:
'__rowIndex'
,
header
:
'
序号
'
,
accessorKey
:
'__rowIndex'
,
width
:
72
,
isRowHeader
:
true
},
{
{
id
:
'project_name'
,
id
:
'project_name'
,
header
:
'项目名称'
,
header
:
'项目名称'
,
...
@@ -1655,10 +1811,18 @@ function ProjectTaskDialog({
...
@@ -1655,10 +1811,18 @@ function ProjectTaskDialog({
}, [open, task, viewOnly]);
}, [open, task, viewOnly]);
const updateField = (field, value) => {
const updateField = (field, value) => {
setFormValues((current) => ({
setFormValues((current) => {
...current,
const nextValues = {
[field]: value,
...current,
}));
[field]: value,
};
if (formatDateInput(nextValues.actual_finish_date)) {
nextValues.project_status = 'completed';
}
return nextValues;
});
};
};
const handleStartProject = () => {
const handleStartProject = () => {
...
@@ -2030,13 +2194,16 @@ function ProjectTaskDialog({
...
@@ -2030,13 +2194,16 @@ function ProjectTaskDialog({
>
>
<Info size={14} />
<Info size={14} />
</Tooltip.Trigger>
</Tooltip.Trigger>
<Tooltip.Content>工作流:草稿 {'->'} 进行中 {'->'} 已完成</Tooltip.Content>
<Tooltip.Content>
工作流:草稿 {'->'} 进行中 {'->'} 已完成;填写实际完成日期后自动锁定为已完成
</Tooltip.Content>
</Tooltip>
</Tooltip>
</span>
</span>
<OptionAutocomplete
<OptionAutocomplete
value={formValues.project_status}
value={formValues.project_status}
onChange={(value) => updateField('project_status', value)}
onChange={(value) => updateField('project_status', value)}
options={projectStatusOptions}
options={projectStatusOptions}
isDisabled={Boolean(formatDateInput(formValues.actual_finish_date))}
isRequired
isRequired
/>
/>
</label>
</label>
...
@@ -2311,39 +2478,9 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
...
@@ -2311,39 +2478,9 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
}
}
};
};
useEffect(() => {
const hasChanged = Object.keys(filters).some(
(key) => filters[key] !== queryFilters[key],
);
if (!hasChanged) {
return undefined;
}
const delay = filters.keyword !== queryFilters.keyword ? 250 : 0;
const timeoutId = window.setTimeout(() => {
setQueryFilters({ ...filters });
}, delay);
return () => window.clearTimeout(timeoutId);
}, [
filters.keyword,
filters.status,
filters.ownerId,
filters.collaboratorId,
queryFilters.keyword,
queryFilters.status,
queryFilters.ownerId,
queryFilters.collaboratorId,
]);
useEffect(() => {
useEffect(() => {
loadWorkspace(queryFilters);
loadWorkspace(queryFilters);
}, [
}, [queryFilters]);
queryFilters.keyword,
queryFilters.status,
queryFilters.ownerId,
queryFilters.collaboratorId,
]);
useEffect(() => {
useEffect(() => {
let cancelled = false;
let cancelled = false;
...
@@ -2443,7 +2580,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
...
@@ -2443,7 +2580,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
try {
try {
const rows = await readProjectTaskImportRows(file);
const rows = await readProjectTaskImportRows(file);
const preview = await Promise.all(
const preview = await Promise.all(
rows.map((row, index) => buildImportPreviewRow(row, index +
2
)),
rows.map((row, index) => buildImportPreviewRow(row, index +
1
)),
);
);
setImportPreviewRows(preview);
setImportPreviewRows(preview);
setImportDialogOpen(true);
setImportDialogOpen(true);
...
@@ -2565,7 +2702,6 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
...
@@ -2565,7 +2702,6 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
const handleOpenTaskFromCalendar = (task) => {
const handleOpenTaskFromCalendar = (task) => {
setSelectedTaskId(getTaskRecordId(task));
setSelectedTaskId(getTaskRecordId(task));
setActiveTab('gantt');
openEditDialog(task);
openEditDialog(task);
};
};
...
@@ -2651,7 +2787,14 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
...
@@ -2651,7 +2787,14 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
</FilterField>
</FilterField>
</div>
</div>
<div className="flex flex-wrap items-end xl:flex-nowrap">
<div className="flex flex-wrap items-end gap-2 xl:flex-nowrap">
<Button
isPending={loading}
variant="primary"
onPress={() => setQueryFilters({ ...filters })}
>
查询
</Button>
<Disclosure.Trigger className="button button--sm button--ghost">
<Disclosure.Trigger className="button button--sm button--ghost">
<span>{filtersExpanded ? '收起' : '展开'}</span>
<span>{filtersExpanded ? '收起' : '展开'}</span>
<Disclosure.Indicator><ChevronDownIcon size={14} /></Disclosure.Indicator>
<Disclosure.Indicator><ChevronDownIcon size={14} /></Disclosure.Indicator>
...
...
src/pages/project_manager_xy/notifications/notificationService.js
View file @
3963f7ff
...
@@ -36,7 +36,6 @@ export async function loadNotificationPage({
...
@@ -36,7 +36,6 @@ export async function loadNotificationPage({
if
(
normalizedKeyword
)
{
if
(
normalizedKeyword
)
{
filter
.
content
=
{
filter
.
content
=
{
$regex
:
escapeRegExp
(
normalizedKeyword
),
$regex
:
escapeRegExp
(
normalizedKeyword
),
$options
:
'i'
,
};
};
}
}
...
...
src/pages/project_manager_xy/project-management.business-flow.md
View file @
3963f7ff
...
@@ -20,7 +20,8 @@
...
@@ -20,7 +20,8 @@
-
左侧菜单:
`资源预定`
-
左侧菜单:
`资源预定`
-
`资源预定`
顶部使用项目甘特图、个人日历图、项目清单三个 Tabs;移动端 Tabs 可横向滚动,批量导入和新建项目操作位于独立工具行
-
`资源预定`
顶部使用项目甘特图、个人日历图、项目清单三个 Tabs;移动端 Tabs 可横向滚动,批量导入和新建项目操作位于独立工具行
-
筛选区默认展示项目搜索、项目状态、项目负责人;项目协作人位于可展开的筛选区域。筛选不提供刷新或查询按钮,选择类条件变化后立即查询,关键词停止输入 250ms 后自动查询;连续请求只允许最后一次结果写回页面。请求加载期间筛选 Surface 保持稳定,不显示常驻加载提示。
-
筛选区默认展示项目搜索、项目状态、项目负责人和“查询”按钮;项目协作人位于可展开的筛选区域。首次进入页面自动加载项目;输入、选择或清除筛选条件仅更新待查询条件,点击“查询”后才统一应用到甘特图、个人日历图、项目清单及统计卡片,相同条件可再次点击查询。保存、删除或导入后的刷新沿用上次已提交的条件,不应用尚未点击查询的修改。查询期间按钮显示加载状态,筛选 Surface 保持稳定;连续请求只允许最后一次结果写回页面。查询失败显示错误提示,可再次点击“查询”重试;无匹配数据时展示空状态。
-
项目关键词通过项目名称、大类、项目、任务来源和任务性质的正则条件查询,使用服务端默认匹配规则(不指定忽略大小写选项)。
-
枚举和用户选择字段统一使用 HeroUI Autocomplete:选择后触发器立即回显当前标签,再次打开时保留选项勾选;非必填且可编辑的已选字段显示清除按钮,清除后按原字段协议写回空字符串或空 ID 数组
-
枚举和用户选择字段统一使用 HeroUI Autocomplete:选择后触发器立即回显当前标签,再次打开时保留选项勾选;非必填且可编辑的已选字段显示清除按钮,清除后按原字段协议写回空字符串或空 ID 数组
-
`项目清单`
使用原生行选择;选择一条记录后在视口下方显示操作栏,可编辑所选项目或清空选择
-
`项目清单`
使用原生行选择;选择一条记录后在视口下方显示操作栏,可编辑所选项目或清空选择
...
@@ -70,7 +71,7 @@
...
@@ -70,7 +71,7 @@
1.
成员登录系统
1.
成员登录系统
2.
日历图查询当前用户包含在
`project_owner`
或
`project_collaborator`
数组中的项目记录
2.
日历图查询当前用户包含在
`project_owner`
或
`project_collaborator`
数组中的项目记录
3.
甘特图按项目展示所有任务;个人日历图仅展示当前用户关联项目
3.
甘特图按项目展示所有任务;个人日历图仅展示当前用户关联项目
4.
项目清单默认每页 10 条分页,可在分页栏选择 10、20 或 50 条/页;行操作区提供“预览项目”和“编辑项目”两个图标入口,选择后操作栏继续进入原项目编辑流程
4.
项目清单默认每页 10 条分页,可在分页栏选择 10、20 或 50 条/页;
负责人与协作人分别按多值用户数组完整展示显示名和用户名,不仅取第一人;
行操作区提供“预览项目”和“编辑项目”两个图标入口,选择后操作栏继续进入原项目编辑流程
5.
点击“预览项目”打开 4xl 详情弹窗,以无框标签/值布局展示项目基础信息、状态与进度、计划/实际日期及周进度;查看态底部“编辑”按钮沿用原项目编辑流程,右上角关闭图标退出预览,关闭或切换编辑不会触发额外查询请求
5.
点击“预览项目”打开 4xl 详情弹窗,以无框标签/值布局展示项目基础信息、状态与进度、计划/实际日期及周进度;查看态底部“编辑”按钮沿用原项目编辑流程,右上角关闭图标退出预览,关闭或切换编辑不会触发额外查询请求
6.
可在甘特图、日历图或项目清单中查看项目名称、计划时间、共享进度、状态
6.
可在甘特图、日历图或项目清单中查看项目名称、计划时间、共享进度、状态
...
@@ -81,7 +82,7 @@
...
@@ -81,7 +82,7 @@
3.
新建弹窗在项目尚未保存落库前,只允许编辑基础字段并点击“保存项目”;此时不展示“启动任务”或“完成任务”
3.
新建弹窗在项目尚未保存落库前,只允许编辑基础字段并点击“保存项目”;此时不展示“启动任务”或“完成任务”
4.
点击“启动项目”后,前端直接调用保存接口,状态切换为
`in_progress`
,并自动带入
`actual_start_date=当天`
;保存成功后关闭弹窗,如需调整实际开始日期可再次打开编辑
4.
点击“启动项目”后,前端直接调用保存接口,状态切换为
`in_progress`
,并自动带入
`actual_start_date=当天`
;保存成功后关闭弹窗,如需调整实际开始日期可再次打开编辑
5.
点击“完成项目”后,前端直接调用保存接口,状态切换为
`completed`
,并自动带入
`actual_finish_date=当天`
;如
`actual_start_date`
为空也会一并补当天,完成进度不足 100 时自动补到 100,保存成功后关闭弹窗
5.
点击“完成项目”后,前端直接调用保存接口,状态切换为
`completed`
,并自动带入
`actual_finish_date=当天`
;如
`actual_start_date`
为空也会一并补当天,完成进度不足 100 时自动补到 100,保存成功后关闭弹窗
6.
项目完成时,如当前完成进度小于 100,可默认补到 100
6.
手动填写实际完成日期时,表单立即将项目状态切换为
`completed`
并在该日期存在期间锁定状态选择;保存层会再次按同一规则校正,并沿用完成项目规则在完成进度小于 100 时自动补到 100。清空实际完成日期后解除状态锁定,但不自动回退项目状态,需由用户按实际情况选择。
7.
项目新建/编辑使用 4xl 弹窗;枚举字段从 Subject 配置进入可搜索选项弹层,用户字段进入远程搜索弹层,日期字段进入日期面板
7.
项目新建/编辑使用 4xl 弹窗;枚举字段从 Subject 配置进入可搜索选项弹层,用户字段进入远程搜索弹层,日期字段进入日期面板
8.
在项目详情弹窗中通过“新增本周进度”录入一条周进度记录;已有项目的删除入口位于编辑态 Footer 右侧操作组、排在“取消”之前,点击后通过确认弹窗执行原删除流程;如填写了周进度里的累计项目进度,保存时自动同步覆盖主记录的
`completion_percent`
8.
在项目详情弹窗中通过“新增本周进度”录入一条周进度记录;已有项目的删除入口位于编辑态 Footer 右侧操作组、排在“取消”之前,点击后通过确认弹窗执行原删除流程;如填写了周进度里的累计项目进度,保存时自动同步覆盖主记录的
`completion_percent`
9.
若已超过计划结束日期仍未完成,补充
`delay_reason`
9.
若已超过计划结束日期仍未完成,补充
`delay_reason`
...
@@ -155,18 +156,19 @@
...
@@ -155,18 +156,19 @@
### 5.1 甘特图
### 5.1 甘特图
-
以
`pm_project_task`
为数据源
-
以
`pm_project_task`
为数据源
-
展示项目名称、项目负责人、计划时间、实际时间、项目状态,以及按时间规则派生的结果状态
-
展示项目名称、项目负责人、计划时间、实际时间和单一的“展示状态”;不在甘特图中并列“项目状态 / 结果状态”
-
甘特图展示状态不单独入库,由
`project_status`
与日期派生结果合并,优先级为:已取消 → 提前/准时/超时完成 → 受阻(如同时超时则附带超时天数)→ 已超时 → 未开始 → 进行中 → 草稿
-
同时展示两个百分比:
-
同时展示两个百分比:
-
时间进度百分比:按
`planned_start_date`
到
`planned_end_date`
与当前日期的关系自动计算
-
时间进度百分比:按
`planned_start_date`
到
`planned_end_date`
与当前日期的关系自动计算
-
实际进度百分比:读取
`completion_percent`
-
实际进度百分比:读取
`completion_percent`
-
已完成、超时、提前完成等状态需要有明显视觉区分
-
已完成、超时、提前完成等状态需要有明显视觉区分
-
甘特图使用周/月/年 Tabs 切换时间窗口,并保留上一时间段、回到今天、下一时间段操作;窄视口下时间轴只在模块内部横向滚动,不产生页面级横向溢出
-
甘特图使用周/月/年 Tabs 切换时间窗口,并保留上一时间段、回到今天、下一时间段操作;窄视口下时间轴只在模块内部横向滚动,不产生页面级横向溢出
-
每条项目排期
同时展示语义状态、负责人、计划日期、实际进度和时间进度
;点击项目名称区域继续打开原项目编辑弹窗
-
每条项目排期
展示一个统一状态标签、负责人、协作人、计划日期、实际进度和时间进度;负责人与协作人均按多值用户数组完整展示,多人时允许摘要区自然换行并同步撑高时间轴行
;点击项目名称区域继续打开原项目编辑弹窗
-
甘特图条内展示最新一周的周进度总结;如存在多条周记录,以最新一条摘要为准
-
甘特图条内展示最新一周的周进度总结;如存在多条周记录,以最新一条摘要为准
-
周进度记录数量可作为辅助信息一并展示,帮助判断项目是否持续更新
-
周进度记录数量可作为辅助信息一并展示,帮助判断项目是否持续更新
-
周进度明细通过项目编辑弹窗内的记录列表和新增/编辑弹窗维护
-
周进度明细通过项目编辑弹窗内的记录列表和新增/编辑弹窗维护
-
当前时间窗口没有匹配项目时展示空态提示,不生成测试项目或伪造业务数据
-
当前时间窗口没有匹配项目时展示空态提示,不生成测试项目或伪造业务数据
-
后续可按状态映射颜色,不必先存颜色字段
-
颜色与甘特图统一展示状态同步映射,并始终同时显示文字标签,不单独存储颜色字段或仅依赖颜色传达状态
### 5.2 个人日历图
### 5.2 个人日历图
...
@@ -174,8 +176,10 @@
...
@@ -174,8 +176,10 @@
-
展示项目名称、计划时间、项目状态、共享进度
-
展示项目名称、计划时间、项目状态、共享进度
-
月历使用 HeroUI Pro
`Agenda`
月视图 compound API;月份导航、今天定位、星期表头、非本月弱化、今日状态、月份单元和项目事件均由 Agenda 管理
-
月历使用 HeroUI Pro
`Agenda`
月视图 compound API;月份导航、今天定位、星期表头、非本月弱化、今日状态、月份单元和项目事件均由 Agenda 管理
-
日历图按
`planned_start_date`
到
`planned_end_date`
生成连续区间条,不再按天重复拆成多个独立卡片
-
日历图按
`planned_start_date`
到
`planned_end_date`
生成连续区间条,不再按天重复拆成多个独立卡片
-
当项目跨周时,区间条在对应周内自动拆段显示,但视觉上保持连续排期的阅读方式
-
当项目跨周时,区间条在对应周内自动拆段显示;当前月首次可见的区间段展示完整项目名称、状态和进度,后续周段仅标记“持续中”,避免把同一项目误读成多条重复记录
-
日历事件按 Record
`_id`
去重;同一查询结果中重复返回的相同记录只生成一个日历事件,不合并
`_id`
不同的真实项目记录
-
项目事件为只读,点击后继续打开原项目编辑流程;日历不提供拖动、缩放、新建或删除入口,不改变任何项目日期和状态
-
项目事件为只读,点击后继续打开原项目编辑流程;日历不提供拖动、缩放、新建或删除入口,不改变任何项目日期和状态
-
从个人日历点击项目打开弹窗时保留当前 Tab 和浏览月份;取消、关闭或保存成功后仍停留在该月份的个人日历,不自动切换到项目甘特图。保存失败时保留弹窗并展示错误提示。
-
共享进度与共享状态对负责人和协作人一致
-
共享进度与共享状态对负责人和协作人一致
-
如项目存在最新周进度,可在详情或弹窗中查看周记录内容
-
如项目存在最新周进度,可在详情或弹窗中查看周记录内容
...
@@ -236,6 +240,7 @@ const result = await queryUsers(0, 20, {
...
@@ -236,6 +240,7 @@ const result = await queryUsers(0, 20, {
-
点击“批量导入”后打开导入弹窗;文件通过 DropZone 选择或拖入,格式校验和预览完成前“开始导入”保持禁用
-
点击“批量导入”后打开导入弹窗;文件通过 DropZone 选择或拖入,格式校验和预览完成前“开始导入”保持禁用
-
解析成功后先展示 DataGrid 预览,用户确认后才调用原有批量导入接口;取消弹窗不会写入业务数据
-
解析成功后先展示 DataGrid 预览,用户确认后才调用原有批量导入接口;取消弹窗不会写入业务数据
-
预览表的“序号”是当前可见导入数据的连续序号,从 1 开始,不表示 Excel 原始行号
-
Excel 模板采用业务列头:
`大类`
、
`项目`
、
`是否属于年度审计计划`
、
`发生频率`
、
`任务名称`
、
`任务来源`
、
`任务性质`
、
`计划启动时间`
、
`计划完成时间`
、
`时间进度`
、
`任务进度`
、
`任务状态`
、
`主办人`
、
`协办人1`
-
Excel 模板采用业务列头:
`大类`
、
`项目`
、
`是否属于年度审计计划`
、
`发生频率`
、
`任务名称`
、
`任务来源`
、
`任务性质`
、
`计划启动时间`
、
`计划完成时间`
、
`时间进度`
、
`任务进度`
、
`任务状态`
、
`主办人`
、
`协办人1`
-
`大类`
导入到
`metadata.big_category`
,当前仅支持
`部门管理`
、
`纪检工作`
、
`审计工作`
-
`大类`
导入到
`metadata.big_category`
,当前仅支持
`部门管理`
、
`纪检工作`
、
`审计工作`
...
@@ -243,7 +248,7 @@ const result = await queryUsers(0, 20, {
...
@@ -243,7 +248,7 @@ const result = await queryUsers(0, 20, {
-
`任务名称`
导入到
`metadata.project_name`
-
`任务名称`
导入到
`metadata.project_name`
-
`是否属于年度审计计划`
导入到
`metadata.is_annual_audit_plan`
,支持
`是/否`
、
`true/false`
、
`1/0`
-
`是否属于年度审计计划`
导入到
`metadata.is_annual_audit_plan`
,支持
`是/否`
、
`true/false`
、
`1/0`
-
`主办人`
导入到
`metadata.project_owner`
,
`协办人1`
导入到
`metadata.project_collaborator`
;支持填写系统用户
`display_name`
或
`username`
,前端会先按
`username/display_name`
的
`$regex`
条件调用
`queryUsers`
查询,并最终保存为用户
`_id`
数组
-
`主办人`
导入到
`metadata.project_owner`
,
`协办人1`
导入到
`metadata.project_collaborator`
;支持填写系统用户
`display_name`
或
`username`
,前端会先按
`username/display_name`
的
`$regex`
条件调用
`queryUsers`
查询,并最终保存为用户
`_id`
数组
-
`计划启动时间`
/
`计划完成时间`
支持 Excel 日期、
`YYYY-MM-DD`
、
`YYYY/MM/DD`
、
`2026年5月1日`
、
`5月1日`
;入库时统一转换为 ISO 8601
-
`计划启动时间`
/
`计划完成时间`
支持 Excel 日期、
`YYYY-MM-DD`
、
`YYYY/MM/DD`
、
`2026年5月1日`
、
`5月1日`
;
Excel 日期序列值和无时区的文本日期均按日历日解析,不做 UTC 时区换算,
入库时统一转换为 ISO 8601
-
`任务进度`
支持
`20`
或
`20%`
,导入到
`metadata.completion_percent`
-
`任务进度`
支持
`20`
或
`20%`
,导入到
`metadata.completion_percent`
-
`任务状态`
支持中文状态值,导入时转换为
`draft`
/
`in_progress`
/
`completed`
/
`blocked`
/
`cancelled`
-
`任务状态`
支持中文状态值,导入时转换为
`draft`
/
`in_progress`
/
`completed`
/
`blocked`
/
`cancelled`
-
`时间进度`
属于前端派生展示值,不参与系统计算;为避免丢失,导入时仅保留在
`remark`
-
`时间进度`
属于前端派生展示值,不参与系统计算;为避免丢失,导入时仅保留在
`remark`
...
@@ -271,7 +276,7 @@ const result = await queryUsers(0, 20, {
...
@@ -271,7 +276,7 @@ const result = await queryUsers(0, 20, {
### 8.2 查询与筛选
### 8.2 查询与筛选
-
页面按“消息/待办”分类,按“全部/未读”状态查看。
-
页面按“消息/待办”分类,按“全部/未读”状态查看。
-
内容搜索输入后 300ms 发起查询,关键词作为转义后的
`content.$regex`
传入;切换分类、状态或关键词均回到第 1 页。
-
内容搜索输入后 300ms 发起查询,关键词作为转义后的
`content.$regex`
传入
,使用服务端默认匹配规则(不指定忽略大小写选项)
;切换分类、状态或关键词均回到第 1 页。
-
每页固定 10 条,使用
`POST /notice/customer/query`
;未读状态通过接口
`status: 'unread'`
查询,不对当前页做前端过滤。
-
每页固定 10 条,使用
`POST /notice/customer/query`
;未读状态通过接口
`status: 'unread'`
查询,不对当前页做前端过滤。
### 8.3 已读、待办与跳转
### 8.3 已读、待办与跳转
...
...
src/pages/project_manager_xy/projectTaskService.js
View file @
3963f7ff
...
@@ -407,6 +407,18 @@ export const PROJECT_TASK_IMPORT_EXAMPLE_ROW = [
...
@@ -407,6 +407,18 @@ export const PROJECT_TASK_IMPORT_EXAMPLE_ROW = [
'牟文博'
,
'牟文博'
,
];
];
const
PROJECT_TASK_IMPORT_DATE_HEADERS
=
[
'计划启动时间'
,
'计划开始日期'
,
'计划完成时间'
,
'计划结束日期'
,
'实际开始日期'
,
'实际完成日期'
,
'周进度开始日期'
,
'周进度结束日期'
,
'周进度记录日期'
,
];
export
function
buildProjectTaskImportWorkbook
()
{
export
function
buildProjectTaskImportWorkbook
()
{
const
workbook
=
XLSX
.
utils
.
book_new
();
const
workbook
=
XLSX
.
utils
.
book_new
();
const
templateSheet
=
XLSX
.
utils
.
aoa_to_sheet
([
const
templateSheet
=
XLSX
.
utils
.
aoa_to_sheet
([
...
@@ -465,10 +477,28 @@ export async function readProjectTaskImportRows(file) {
...
@@ -465,10 +477,28 @@ export async function readProjectTaskImportRows(file) {
return
[];
return
[];
}
}
return
XLSX
.
utils
const
formattedRows
=
XLSX
.
utils
.
sheet_to_json
(
sheet
,
{
.
sheet_to_json
(
sheet
,
{
defval
:
''
,
defval
:
''
,
raw
:
false
,
raw
:
false
,
});
const
rawRows
=
XLSX
.
utils
.
sheet_to_json
(
sheet
,
{
defval
:
''
,
raw
:
true
,
});
return
formattedRows
.
map
((
row
,
rowIndex
)
=>
{
const
rawRow
=
rawRows
[
rowIndex
]
||
{};
const
normalizedRow
=
{
...
row
};
PROJECT_TASK_IMPORT_DATE_HEADERS
.
forEach
((
header
)
=>
{
const
rawValue
=
rawRow
[
header
];
if
(
typeof
rawValue
===
'number'
&&
Number
.
isFinite
(
rawValue
))
{
normalizedRow
[
header
]
=
rawValue
;
}
});
return
normalizedRow
;
})
})
.
filter
((
row
)
=>
.
filter
((
row
)
=>
Object
.
values
(
row
||
{}).
some
((
value
)
=>
String
(
value
??
''
).
trim
()
!==
''
),
Object
.
values
(
row
||
{}).
some
((
value
)
=>
String
(
value
??
''
).
trim
()
!==
''
),
...
@@ -619,31 +649,26 @@ export function buildTaskFilter({
...
@@ -619,31 +649,26 @@ export function buildTaskFilter({
{
{
'metadata.project_name'
:
{
'metadata.project_name'
:
{
$regex
:
keyword
,
$regex
:
keyword
,
$options
:
'i'
,
},
},
},
},
{
{
'metadata.big_category'
:
{
'metadata.big_category'
:
{
$regex
:
keyword
,
$regex
:
keyword
,
$options
:
'i'
,
},
},
},
},
{
{
'metadata.project'
:
{
'metadata.project'
:
{
$regex
:
keyword
,
$regex
:
keyword
,
$options
:
'i'
,
},
},
},
},
{
{
'metadata.task_source'
:
{
'metadata.task_source'
:
{
$regex
:
keyword
,
$regex
:
keyword
,
$options
:
'i'
,
},
},
},
},
{
{
'metadata.task_nature'
:
{
'metadata.task_nature'
:
{
$regex
:
keyword
,
$regex
:
keyword
,
$options
:
'i'
,
},
},
},
},
],
],
...
@@ -1063,9 +1088,11 @@ export function buildTaskPayload(values) {
...
@@ -1063,9 +1088,11 @@ export function buildTaskPayload(values) {
const
projectCollaborator
=
normalizeUserReferenceList
(
values
.
project_collaborator
);
const
projectCollaborator
=
normalizeUserReferenceList
(
values
.
project_collaborator
);
const
plannedStartDate
=
formatDateInput
(
values
.
planned_start_date
);
const
plannedStartDate
=
formatDateInput
(
values
.
planned_start_date
);
const
plannedEndDate
=
formatDateInput
(
values
.
planned_end_date
);
const
plannedEndDate
=
formatDateInput
(
values
.
planned_end_date
);
const
projectStatus
=
values
.
project_status
||
'draft'
;
const
actualStartDateInput
=
formatDateInput
(
values
.
actual_start_date
);
const
actualStartDateInput
=
formatDateInput
(
values
.
actual_start_date
);
const
actualFinishDateInput
=
formatDateInput
(
values
.
actual_finish_date
);
const
actualFinishDateInput
=
formatDateInput
(
values
.
actual_finish_date
);
const
projectStatus
=
actualFinishDateInput
?
'completed'
:
values
.
project_status
||
'draft'
;
const
today
=
new
Date
().
toISOString
().
slice
(
0
,
10
);
const
today
=
new
Date
().
toISOString
().
slice
(
0
,
10
);
const
serializedWeeklyUpdates
=
serializeWeeklyUpdatesValue
(
values
.
weekly_updates
,
{
const
serializedWeeklyUpdates
=
serializeWeeklyUpdatesValue
(
values
.
weekly_updates
,
{
throwOnInvalid
:
true
,
throwOnInvalid
:
true
,
...
...
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