v1.0.10 - UX upgrades
This commit is contained in:
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.10] - 2025-07-06
|
||||
|
||||
### Added
|
||||
- 支持多前置任务功能
|
||||
- 增加边框粘性toolBar,表示隐藏的TaskBar和MilestoneBar
|
||||
- 点击边框粘性toolBar,快速定位滑动到对应的TaskBar和MilestoneBar
|
||||
|
||||
### Changed
|
||||
- 支持多前置任务功能
|
||||
- Timeline中sub-task隐藏后,关系线也随之隐藏
|
||||
|
||||
## [1.0.9] - 2025-07-04
|
||||
|
||||
### Changed
|
||||
|
||||
+4
-6
@@ -10,6 +10,7 @@ import '../src/styles/theme-variables.css'
|
||||
import VersionHistoryDrawer from './VersionHistoryDrawer.vue'
|
||||
import { useMessage } from '../src/composables/useMessage'
|
||||
import { useI18n } from '../src/composables/useI18n'
|
||||
import { getPredecessorIds, predecessorIdsToString } from '../src/utils/predecessorUtils'
|
||||
import type { Task } from '../src/models/Task'
|
||||
|
||||
const { showMessage } = useMessage()
|
||||
@@ -550,11 +551,8 @@ const cleanupPredecessorReferences = (deletedTaskIds: number[]) => {
|
||||
const cleanupTaskArray = (taskArray: Task[]) => {
|
||||
taskArray.forEach(task => {
|
||||
if (task.predecessor) {
|
||||
// 将predecessor字符串解析为ID数组
|
||||
const predecessorIds = task.predecessor
|
||||
.split(',')
|
||||
.map(id => parseInt(id.trim()))
|
||||
.filter(id => !isNaN(id))
|
||||
// 使用工具函数获取前置任务ID数组
|
||||
const predecessorIds = getPredecessorIds(task.predecessor)
|
||||
|
||||
// 过滤掉被删除的任务ID
|
||||
const validPredecessorIds = predecessorIds.filter(id => !deletedTaskIds.includes(id))
|
||||
@@ -563,7 +561,7 @@ const cleanupPredecessorReferences = (deletedTaskIds: number[]) => {
|
||||
if (validPredecessorIds.length === 0) {
|
||||
delete task.predecessor
|
||||
} else {
|
||||
task.predecessor = validPredecessorIds.join(',')
|
||||
task.predecessor = predecessorIdsToString(validPredecessorIds)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -38,7 +38,7 @@
|
||||
{
|
||||
"id": 4,
|
||||
"name": "调研A",
|
||||
"predecessor": "2",
|
||||
"predecessor": [2],
|
||||
"assignee": "赵六",
|
||||
"startDate": "2025-06-21",
|
||||
"endDate": "2025-06-23",
|
||||
@@ -51,7 +51,7 @@
|
||||
{
|
||||
"id": 5,
|
||||
"name": "调研B",
|
||||
"predecessor": "4",
|
||||
"predecessor": [4],
|
||||
"assignee": "钱七",
|
||||
"startDate": "2025-06-24",
|
||||
"endDate": "2025-06-28",
|
||||
@@ -88,7 +88,7 @@
|
||||
"estimatedHours": 80,
|
||||
"actualHours": 35,
|
||||
"type": "task",
|
||||
"predecessor": "5",
|
||||
"predecessor": [5],
|
||||
"parentId": 7
|
||||
},
|
||||
{
|
||||
@@ -101,7 +101,7 @@
|
||||
"estimatedHours": 80,
|
||||
"actualHours": 0,
|
||||
"type": "task",
|
||||
"predecessor": "5",
|
||||
"predecessor": [5],
|
||||
"parentId": 7
|
||||
},
|
||||
{
|
||||
@@ -114,7 +114,7 @@
|
||||
"estimatedHours": 88,
|
||||
"actualHours": 0,
|
||||
"type": "task",
|
||||
"predecessor": "5",
|
||||
"predecessor": [5],
|
||||
"parentId": 7
|
||||
}
|
||||
],
|
||||
@@ -141,7 +141,7 @@
|
||||
"estimatedHours": 48,
|
||||
"actualHours": 0,
|
||||
"type": "task",
|
||||
"predecessor": "7",
|
||||
"predecessor": [8, 11, 12],
|
||||
"parentId": 13
|
||||
},
|
||||
{
|
||||
@@ -154,7 +154,7 @@
|
||||
"estimatedHours": 24,
|
||||
"actualHours": 0,
|
||||
"type": "task",
|
||||
"predecessor": "7",
|
||||
"predecessor": [14],
|
||||
"parentId": 13
|
||||
}
|
||||
],
|
||||
|
||||
@@ -124,5 +124,16 @@
|
||||
"增加Story删除时选择是否删除子任务的选项",
|
||||
"删除Story或Task时同时删除前后的关系线路"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.0.10",
|
||||
"date": "2025-07-06",
|
||||
"notes": [
|
||||
"升级用户体验",
|
||||
"增加边框粘性toolBar,表示隐藏的TaskBar和MilestoneBar",
|
||||
"点击边框粘性toolBar,快速定位滑动到对应的TaskBar和MilestoneBar",
|
||||
"Timeline中sub-task隐藏后,关系线也随之隐藏",
|
||||
"增强TaskBar可以存在多个前置任务"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jordium-gantt-vue3",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"type": "module",
|
||||
"main": "dist/jordium-gantt-vue3.cjs.js",
|
||||
"module": "dist/jordium-gantt-vue3.es.js",
|
||||
|
||||
+4
-6
@@ -10,6 +10,7 @@ import '../src/styles/theme-variables.css'
|
||||
import VersionHistoryDrawer from './VersionHistoryDrawer.vue'
|
||||
import { useMessage } from '../src/composables/useMessage'
|
||||
import { useI18n } from '../src/composables/useI18n'
|
||||
import { getPredecessorIds, predecessorIdsToString } from '../src/utils/predecessorUtils'
|
||||
import type { Task } from '../src/models/Task'
|
||||
|
||||
const { showMessage } = useMessage()
|
||||
@@ -550,11 +551,8 @@ const cleanupPredecessorReferences = (deletedTaskIds: number[]) => {
|
||||
const cleanupTaskArray = (taskArray: Task[]) => {
|
||||
taskArray.forEach(task => {
|
||||
if (task.predecessor) {
|
||||
// 将predecessor字符串解析为ID数组
|
||||
const predecessorIds = task.predecessor
|
||||
.split(',')
|
||||
.map(id => parseInt(id.trim()))
|
||||
.filter(id => !isNaN(id))
|
||||
// 使用工具函数获取前置任务ID数组
|
||||
const predecessorIds = getPredecessorIds(task.predecessor)
|
||||
|
||||
// 过滤掉被删除的任务ID
|
||||
const validPredecessorIds = predecessorIds.filter(id => !deletedTaskIds.includes(id))
|
||||
@@ -563,7 +561,7 @@ const cleanupPredecessorReferences = (deletedTaskIds: number[]) => {
|
||||
if (validPredecessorIds.length === 0) {
|
||||
delete task.predecessor
|
||||
} else {
|
||||
task.predecessor = validPredecessorIds.join(',')
|
||||
task.predecessor = predecessorIdsToString(validPredecessorIds)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
{
|
||||
"id": 4,
|
||||
"name": "调研A",
|
||||
"predecessor": "2",
|
||||
"predecessor": [2],
|
||||
"assignee": "赵六",
|
||||
"startDate": "2025-06-21",
|
||||
"endDate": "2025-06-23",
|
||||
@@ -51,7 +51,7 @@
|
||||
{
|
||||
"id": 5,
|
||||
"name": "调研B",
|
||||
"predecessor": "4",
|
||||
"predecessor": [4],
|
||||
"assignee": "钱七",
|
||||
"startDate": "2025-06-24",
|
||||
"endDate": "2025-06-28",
|
||||
@@ -88,7 +88,7 @@
|
||||
"estimatedHours": 80,
|
||||
"actualHours": 35,
|
||||
"type": "task",
|
||||
"predecessor": "5",
|
||||
"predecessor": [5],
|
||||
"parentId": 7
|
||||
},
|
||||
{
|
||||
@@ -101,7 +101,7 @@
|
||||
"estimatedHours": 80,
|
||||
"actualHours": 0,
|
||||
"type": "task",
|
||||
"predecessor": "5",
|
||||
"predecessor": [5],
|
||||
"parentId": 7
|
||||
},
|
||||
{
|
||||
@@ -114,7 +114,7 @@
|
||||
"estimatedHours": 88,
|
||||
"actualHours": 0,
|
||||
"type": "task",
|
||||
"predecessor": "5",
|
||||
"predecessor": [5],
|
||||
"parentId": 7
|
||||
}
|
||||
],
|
||||
@@ -141,7 +141,7 @@
|
||||
"estimatedHours": 48,
|
||||
"actualHours": 0,
|
||||
"type": "task",
|
||||
"predecessor": "7",
|
||||
"predecessor": [8, 11, 12],
|
||||
"parentId": 13
|
||||
},
|
||||
{
|
||||
@@ -154,7 +154,7 @@
|
||||
"estimatedHours": 24,
|
||||
"actualHours": 0,
|
||||
"type": "task",
|
||||
"predecessor": "7",
|
||||
"predecessor": [14],
|
||||
"parentId": 13
|
||||
}
|
||||
],
|
||||
|
||||
@@ -124,5 +124,16 @@
|
||||
"增加Story删除时选择是否删除子任务的选项",
|
||||
"删除Story或Task时同时删除前后的关系线路"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.0.10",
|
||||
"date": "2025-07-06",
|
||||
"notes": [
|
||||
"升级用户体验",
|
||||
"增加边框粘性toolBar,表示隐藏的TaskBar和MilestoneBar",
|
||||
"点击边框粘性toolBar,快速定位滑动到对应的TaskBar和MilestoneBar",
|
||||
"Timeline中sub-task隐藏后,关系线也随之隐藏",
|
||||
"增强TaskBar可以存在多个前置任务"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@ import TaskList from './TaskList.vue'
|
||||
import Timeline from './Timeline.vue'
|
||||
import GanttToolbar from './GanttToolbar.vue'
|
||||
import { useI18n, setCustomMessages } from '../composables/useI18n'
|
||||
import { formatPredecessorDisplay } from '../utils/predecessorUtils'
|
||||
import jsPDF from 'jspdf'
|
||||
import html2canvas from 'html2canvas'
|
||||
import type { Task } from '../models/classes/Task'
|
||||
@@ -222,16 +223,35 @@ const toggleTaskList = () => {
|
||||
isTaskListVisible.value = !isTaskListVisible.value
|
||||
}, 200)
|
||||
|
||||
// 动画结束后清理状态
|
||||
// 动画结束后清理状态,并通知Timeline容器变化
|
||||
setTimeout(() => {
|
||||
isAnimating.value = false
|
||||
animationClass.value = ''
|
||||
|
||||
// 手动切换TaskList后,通知Timeline重新计算半圆
|
||||
nextTick(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('timeline-container-resized', {
|
||||
detail: { source: 'manual-task-list-toggle' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
}, 400)
|
||||
}
|
||||
|
||||
// 监听Timeline的TaskList切换事件
|
||||
const handleToggleTaskList = (event: CustomEvent) => {
|
||||
isTaskListVisible.value = event.detail
|
||||
|
||||
// TaskList切换会改变Timeline容器宽度,需要通知Timeline重新计算半圆
|
||||
// 派发事件通知Timeline容器宽度发生了变化
|
||||
nextTick(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('timeline-container-resized', {
|
||||
detail: { source: 'task-list-toggle' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// --- 事件链路:监听 Timeline 传递上来的拖拽/拉伸事件,并通过 props 回调暴露 ---
|
||||
@@ -716,7 +736,7 @@ const generateCsvContent = (tasks: Task[]): string => {
|
||||
result.push({
|
||||
id: task.id,
|
||||
name: task.name || '',
|
||||
predecessor: task.predecessor || '',
|
||||
predecessor: formatPredecessorDisplay(task.predecessor),
|
||||
assignee: task.assignee || '',
|
||||
startDate: task.startDate || '',
|
||||
endDate: task.endDate || '',
|
||||
@@ -747,7 +767,7 @@ const generateCsvContent = (tasks: Task[]): string => {
|
||||
return [
|
||||
escapeCSVField(task.id),
|
||||
escapeCSVField(task.name),
|
||||
escapeCSVField(task.predecessor),
|
||||
escapeCSVField(formatPredecessorDisplay(task.predecessor)),
|
||||
escapeCSVField(task.assignee),
|
||||
escapeCSVField(task.startDate),
|
||||
escapeCSVField(task.endDate),
|
||||
@@ -1181,12 +1201,6 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="gantt-panel gantt-panel-right" :class="{ 'full-width': !isTaskListVisible }">
|
||||
<!-- 左侧渐隐效果覆盖层 -->
|
||||
<div class="timeline-fade-overlay timeline-fade-left"></div>
|
||||
|
||||
<!-- 右侧渐隐效果覆盖层 -->
|
||||
<div class="timeline-fade-overlay timeline-fade-right"></div>
|
||||
|
||||
<Timeline
|
||||
ref="timelineRef"
|
||||
:tasks="tasksForTimeline"
|
||||
@@ -1204,38 +1218,6 @@ watch(
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 独立的无scoped样式块处理暗色主题渐隐效果 -->
|
||||
<style>
|
||||
/* 暗色主题下的渐隐效果 */
|
||||
html[data-theme='dark'] .timeline-fade-left {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--gantt-bg-primary, rgb(35, 35, 35)) 0%,
|
||||
rgba(35, 35, 35, 0.95) 0%,
|
||||
rgba(35, 35, 35, 0.85) 20%,
|
||||
rgba(35, 35, 35, 0.7) 35%,
|
||||
rgba(35, 35, 35, 0.5) 50%,
|
||||
rgba(35, 35, 35, 0.3) 70%,
|
||||
rgba(35, 35, 35, 0.15) 85%,
|
||||
transparent 100%
|
||||
) !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .timeline-fade-right {
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
var(--gantt-bg-primary, rgb(35, 35, 35)) 0%,
|
||||
rgba(35, 35, 35, 0.95) 0%,
|
||||
rgba(35, 35, 35, 0.85) 20%,
|
||||
rgba(35, 35, 35, 0.7) 35%,
|
||||
rgba(35, 35, 35, 0.5) 50%,
|
||||
rgba(35, 35, 35, 0.3) 70%,
|
||||
rgba(35, 35, 35, 0.15) 85%,
|
||||
transparent 100%
|
||||
) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
@import '../styles/theme-variables.css';
|
||||
.gantt-root {
|
||||
@@ -1308,55 +1290,6 @@ html[data-theme='dark'] .timeline-fade-right {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Timeline区域左右两侧渐隐覆盖层 */
|
||||
.timeline-fade-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 120px;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 左侧渐隐效果 - 增强版 */
|
||||
.timeline-fade-left {
|
||||
left: 0;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--gantt-bg-primary, #ffffff) 0%,
|
||||
rgba(255, 255, 255, 0.9) 15%,
|
||||
rgba(255, 255, 255, 0.7) 30%,
|
||||
rgba(255, 255, 255, 0.5) 50%,
|
||||
rgba(255, 255, 255, 0.3) 70%,
|
||||
rgba(255, 255, 255, 0.1) 85%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* 右侧渐隐效果 - 增强版 */
|
||||
.timeline-fade-right {
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
var(--gantt-bg-primary, #ffffff) 0%,
|
||||
rgba(255, 255, 255, 0.9) 15%,
|
||||
rgba(255, 255, 255, 0.7) 30%,
|
||||
rgba(255, 255, 255, 0.5) 50%,
|
||||
rgba(255, 255, 255, 0.3) 70%,
|
||||
rgba(255, 255, 255, 0.1) 85%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.timeline-fade-overlay {
|
||||
width: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 左侧撞击动画 */
|
||||
@keyframes slideLeftImpact {
|
||||
0% {
|
||||
|
||||
@@ -587,7 +587,7 @@ onUnmounted(() => {
|
||||
background: var(--gantt-bg-toolbar, #f8f9fa);
|
||||
border-bottom: 1px solid var(--gantt-border-color, #ebeef5);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
/*transition: all 0.2s ease;*/
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
@@ -616,7 +616,7 @@ onUnmounted(() => {
|
||||
background: transparent;
|
||||
color: var(--gantt-text-primary, #606266);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
/*transition: all 0.2s ease;*/
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -682,7 +682,7 @@ onUnmounted(() => {
|
||||
border: 1px solid var(--gantt-border-color, #dcdfe6);
|
||||
color: var(--gantt-text-primary, #606266);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
/*transition: all 0.2s ease;*/
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -9,6 +9,19 @@ interface Props {
|
||||
startDate: Date
|
||||
name?: string
|
||||
milestone?: Milestone // 完整的里程碑数据
|
||||
// 新增:用于边界粘性显示的滚动位置信息
|
||||
scrollLeft?: number
|
||||
containerWidth?: number
|
||||
// 新增:里程碑推挤效果所需的信息
|
||||
milestoneId?: string | number // 唯一标识符
|
||||
otherMilestones?: Array<{
|
||||
id: string | number
|
||||
left: number
|
||||
originalLeft: number // 原始位置(不考虑停靠)
|
||||
isSticky: boolean
|
||||
stickyPosition: 'left' | 'right' | 'none'
|
||||
priority: number // 推挤优先级
|
||||
}> // 其他里程碑的位置信息
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -32,6 +45,15 @@ const handleDoubleClick = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// 如果是停靠状态或被推出边界,禁止双击编辑
|
||||
if (
|
||||
milestoneVisibility.value.isSticky ||
|
||||
milestoneVisibility.value.isPushedOut ||
|
||||
!milestoneVisibility.value.showIcon
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// 清理任何可能残留的拖拽状态
|
||||
isDragging.value = false
|
||||
tempMilestoneData.value = null
|
||||
@@ -69,6 +91,15 @@ const formatDateToLocalString = (date: Date): string => {
|
||||
|
||||
// 拖拽事件处理
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// 如果是停靠状态或被推出边界,禁止拖拽
|
||||
if (
|
||||
milestoneVisibility.value.isSticky ||
|
||||
milestoneVisibility.value.isPushedOut ||
|
||||
!milestoneVisibility.value.showIcon
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果正在双击过程中,不启动拖拽
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -120,6 +151,48 @@ const handleMouseUp = () => {
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
// 单击事件处理 - 定位到里程碑位置(居中)
|
||||
const handleMilestoneClick = (e: MouseEvent) => {
|
||||
// 阻止事件冒泡
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// 如果正在拖拽,不响应单击
|
||||
if (isDragging.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果里程碑被推出边界(完全隐藏),不响应点击
|
||||
if (milestoneVisibility.value.isPushedOut || !milestoneVisibility.value.showIcon) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果里程碑完全在视野内,不需要定位
|
||||
if (milestoneVisibility.value.isFullyVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
const containerWidth = props.containerWidth || 0
|
||||
|
||||
// 计算里程碑的原始位置(用户点击停靠里程碑是想定位到原始位置)
|
||||
const milestoneLeft = parseInt(milestoneStyle.value.left) + 12 // 图标中心位置
|
||||
|
||||
if (containerWidth > 0) {
|
||||
// 计算需要滚动到的位置,让里程碑居中
|
||||
const targetScrollLeft = Math.max(0, milestoneLeft - containerWidth / 2)
|
||||
|
||||
// 发送滚动定位事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('milestone-click-locate', {
|
||||
detail: {
|
||||
scrollLeft: targetScrollLeft,
|
||||
smooth: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算菱形位置 - 考虑拖拽临时数据
|
||||
const milestoneStyle = computed(() => {
|
||||
const milestoneDate = tempMilestoneData.value?.startDate
|
||||
@@ -163,6 +236,198 @@ const milestoneIcon = computed(() => {
|
||||
return props.milestone?.icon || 'diamond' // 默认为菱形
|
||||
})
|
||||
|
||||
// 计算里程碑的边界粘性显示状态(包含推挤效果)
|
||||
const milestoneVisibility = computed(() => {
|
||||
const scrollLeft = props.scrollLeft || 0
|
||||
const containerWidth = props.containerWidth || 0
|
||||
|
||||
// 如果没有有效的滚动信息,正常显示
|
||||
if (!containerWidth || containerWidth <= 0) {
|
||||
return {
|
||||
showIcon: true,
|
||||
showLabel: true,
|
||||
isSticky: false,
|
||||
stickyPosition: 'none',
|
||||
iconLeft: '0px',
|
||||
isPushedOut: false,
|
||||
clipPath: 'none',
|
||||
isFullyVisible: true, // 无滚动信息时认为完全可见
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前里程碑的位置
|
||||
const milestoneLeft = parseInt(milestoneStyle.value.left) + 12 // 图标中心位置
|
||||
const leftBoundary = scrollLeft
|
||||
const rightBoundary = scrollLeft + containerWidth
|
||||
const iconSize = 24 // 图标大小
|
||||
const iconLeft = milestoneLeft - iconSize / 2
|
||||
const iconRight = milestoneLeft + iconSize / 2
|
||||
const currentId = props.milestoneId
|
||||
|
||||
// 判断里程碑是否完全在视野内(左右边界都不碰到)
|
||||
const isFullyVisible = iconLeft >= leftBoundary && iconRight <= rightBoundary
|
||||
|
||||
// 检查是否被其他里程碑推挤
|
||||
const otherMilestones = props.otherMilestones || []
|
||||
|
||||
// 左侧边界逻辑
|
||||
if (iconRight <= leftBoundary + iconSize / 2) {
|
||||
// 检查左侧是否有其他停靠的里程碑,需要判断推挤优先级
|
||||
const leftStickyMilestones = otherMilestones.filter(
|
||||
m => m.id !== currentId && m.stickyPosition === 'left' && m.isSticky,
|
||||
)
|
||||
|
||||
// 如果有其他里程碑已经停靠在左侧,比较优先级决定推挤顺序
|
||||
if (leftStickyMilestones.length > 0) {
|
||||
// 获取当前里程碑的原始位置(不考虑停靠)
|
||||
const currentOriginalLeft = parseInt(milestoneStyle.value.left) + 12
|
||||
|
||||
// 检查是否有里程碑的原始位置比当前里程碑更靠右(即后来者推挤先来者)
|
||||
const hasLaterMilestone = leftStickyMilestones.some(m => {
|
||||
// 后来的里程碑(原始位置更靠右,数值更大)会推挤先来的人
|
||||
return m.originalLeft > currentOriginalLeft
|
||||
})
|
||||
|
||||
if (hasLaterMilestone) {
|
||||
// 被后来的里程碑推出边界,完全隐藏
|
||||
return {
|
||||
showIcon: false,
|
||||
showLabel: false,
|
||||
isSticky: false,
|
||||
stickyPosition: 'none',
|
||||
iconLeft: '0px',
|
||||
isPushedOut: true,
|
||||
clipPath: 'none',
|
||||
isFullyVisible: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 停靠在左边界,显示右半部分
|
||||
return {
|
||||
showIcon: true,
|
||||
showLabel: false,
|
||||
isSticky: true,
|
||||
stickyPosition: 'left',
|
||||
iconLeft: `${leftBoundary - parseInt(milestoneStyle.value.left) - iconSize / 2}px`,
|
||||
isPushedOut: false,
|
||||
clipPath: 'polygon(50% 0%, 100% 0%, 100% 100%, 50% 100%)', // 只显示右半部分
|
||||
isFullyVisible: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧边界逻辑
|
||||
if (iconLeft >= rightBoundary - iconSize / 2) {
|
||||
// 检查右侧是否有其他停靠的里程碑,需要判断推挤优先级
|
||||
const rightStickyMilestones = otherMilestones.filter(
|
||||
m => m.id !== currentId && m.stickyPosition === 'right' && m.isSticky,
|
||||
)
|
||||
|
||||
// 如果有其他里程碑已经停靠在右侧,比较优先级决定推挤顺序
|
||||
if (rightStickyMilestones.length > 0) {
|
||||
// 获取当前里程碑的原始位置(不考虑停靠)
|
||||
const currentOriginalLeft = parseInt(milestoneStyle.value.left) + 12
|
||||
|
||||
// 检查是否有里程碑的原始位置比当前里程碑更靠左(即后来者推挤先来者)
|
||||
const hasLaterMilestone = rightStickyMilestones.some(m => {
|
||||
// 后来的里程碑(原始位置更靠左,数值更小)会推挤先来的人
|
||||
return m.originalLeft < currentOriginalLeft
|
||||
})
|
||||
|
||||
if (hasLaterMilestone) {
|
||||
// 被后来的里程碑推出边界,完全隐藏
|
||||
return {
|
||||
showIcon: false,
|
||||
showLabel: false,
|
||||
isSticky: false,
|
||||
stickyPosition: 'none',
|
||||
iconLeft: '0px',
|
||||
isPushedOut: true,
|
||||
clipPath: 'none',
|
||||
isFullyVisible: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 停靠在右边界,显示左半部分
|
||||
return {
|
||||
showIcon: true,
|
||||
showLabel: false,
|
||||
isSticky: true,
|
||||
stickyPosition: 'right',
|
||||
iconLeft: `${rightBoundary - parseInt(milestoneStyle.value.left) - iconSize / 2}px`,
|
||||
isPushedOut: false,
|
||||
clipPath: 'polygon(0% 0%, 50% 0%, 50% 100%, 0% 100%)', // 只显示左半部分
|
||||
isFullyVisible: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 图标在边界内,正常显示
|
||||
return {
|
||||
showIcon: true,
|
||||
showLabel: true,
|
||||
isSticky: false,
|
||||
stickyPosition: 'none',
|
||||
iconLeft: '0px',
|
||||
isPushedOut: false,
|
||||
clipPath: 'none',
|
||||
isFullyVisible,
|
||||
}
|
||||
})
|
||||
|
||||
// Tooltip状态管理
|
||||
const showTooltip = ref(false)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
// 处理里程碑悬停 - 只在停靠状态且显示图标时显示tooltip
|
||||
const handleMilestoneMouseEnter = (event: MouseEvent) => {
|
||||
// 只有在停靠状态、显示图标且未被推出时才显示tooltip
|
||||
if (
|
||||
milestoneVisibility.value.isSticky &&
|
||||
milestoneVisibility.value.showIcon &&
|
||||
!milestoneVisibility.value.isPushedOut
|
||||
) {
|
||||
showTooltip.value = true
|
||||
|
||||
// 计算tooltip位置
|
||||
const rightOffset = !props.milestone || props.milestone?.icon === 'diamond' ? -300 : -270
|
||||
const offsetX = milestoneVisibility.value.stickyPosition === 'left' ? 10 : rightOffset // 左侧停靠在右侧显示,右侧停靠在左侧显示
|
||||
|
||||
tooltipPosition.value = {
|
||||
x: event.clientX + offsetX,
|
||||
y: event.clientY - 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMilestoneMouseLeave = () => {
|
||||
showTooltip.value = false
|
||||
}
|
||||
|
||||
// 格式化日期显示
|
||||
const formatDisplayDate = (dateStr: string): string => {
|
||||
if (!dateStr) return '未设置'
|
||||
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return '未设置'
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
} catch {
|
||||
return '未设置'
|
||||
}
|
||||
}
|
||||
|
||||
// Tooltip内容
|
||||
const tooltipContent = computed(() => {
|
||||
const milestoneName = props.name || props.milestone?.name || '里程碑'
|
||||
const targetDate = formatDisplayDate(props.date || props.milestone?.startDate || '')
|
||||
return `里程碑:${milestoneName} - 目标日期:${targetDate}`
|
||||
})
|
||||
|
||||
// 组件销毁时清理事件监听器
|
||||
onUnmounted(() => {
|
||||
// 清理拖拽状态
|
||||
@@ -179,12 +444,34 @@ onUnmounted(() => {
|
||||
<div
|
||||
class="milestone"
|
||||
:style="milestoneStyle"
|
||||
:title="props.name || '里程碑'"
|
||||
:class="{ dragging: isDragging }"
|
||||
@dblclick="handleDoubleClick"
|
||||
@mousedown="handleMouseDown"
|
||||
:title="milestoneVisibility.isSticky ? '' : props.name || '里程碑'"
|
||||
:class="{
|
||||
dragging: isDragging,
|
||||
'milestone-sticky': milestoneVisibility.isSticky,
|
||||
'milestone-sticky-left': milestoneVisibility.stickyPosition === 'left',
|
||||
'milestone-sticky-right': milestoneVisibility.stickyPosition === 'right',
|
||||
'milestone-pushed-out': milestoneVisibility.isPushedOut,
|
||||
}"
|
||||
@click.stop="handleMilestoneClick"
|
||||
>
|
||||
<svg :width="24" :height="24" :viewBox="`0 0 24 24`">
|
||||
<svg
|
||||
v-if="milestoneVisibility.showIcon"
|
||||
:width="24"
|
||||
:height="24"
|
||||
:viewBox="`0 0 24 24`"
|
||||
:style="{
|
||||
position: milestoneVisibility.isSticky ? 'relative' : 'static',
|
||||
left: milestoneVisibility.isSticky ? milestoneVisibility.iconLeft : '0px',
|
||||
clipPath: milestoneVisibility.clipPath,
|
||||
zIndex: milestoneVisibility.isSticky ? 200 : 120,
|
||||
}"
|
||||
style="cursor: pointer"
|
||||
@mouseenter="handleMilestoneMouseEnter"
|
||||
@mouseleave="handleMilestoneMouseLeave"
|
||||
@click.stop="handleMilestoneClick"
|
||||
@dblclick.stop="handleDoubleClick"
|
||||
@mousedown.stop="handleMouseDown"
|
||||
>
|
||||
<!-- 菱形图标 -->
|
||||
<g v-if="milestoneIcon === 'diamond'" transform="rotate(45 16 16)">
|
||||
<rect
|
||||
@@ -222,8 +509,30 @@ onUnmounted(() => {
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<span v-if="props.name" class="milestone-label milestone-label-right">{{ props.name }}</span>
|
||||
<!-- 里程碑标签 - 只在非停靠状态显示 -->
|
||||
<span
|
||||
v-if="props.name && milestoneVisibility.showLabel"
|
||||
class="milestone-label milestone-label-right"
|
||||
>
|
||||
{{ props.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip 弹窗 - 只在停靠状态显示 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showTooltip && milestoneVisibility.isSticky"
|
||||
class="milestone-tooltip"
|
||||
:style="{
|
||||
left: `${tooltipPosition.x}px`,
|
||||
top: `${tooltipPosition.y}px`,
|
||||
}"
|
||||
>
|
||||
<div class="tooltip-content">
|
||||
{{ tooltipContent }}
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -280,6 +589,7 @@ onUnmounted(() => {
|
||||
font-weight: bold;
|
||||
color: var(--gantt-text-primary, #222);
|
||||
white-space: nowrap;
|
||||
z-index: 10; /* 确保标签在上层 */
|
||||
}
|
||||
|
||||
.milestone-label-right {
|
||||
@@ -287,6 +597,16 @@ onUnmounted(() => {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* 粘性标签的特殊样式 */
|
||||
.milestone-label[style*='position: absolute'] {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(245, 108, 108, 0.2);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* 火箭emoji样式 */
|
||||
.rocket-emoji {
|
||||
width: 100%;
|
||||
@@ -310,6 +630,13 @@ onUnmounted(() => {
|
||||
color: var(--gantt-text-white, #ffffff) !important;
|
||||
}
|
||||
|
||||
/* 暗黑模式下的粘性标签样式 */
|
||||
:global(html[data-theme='dark']) .milestone-label[style*='position: absolute'] {
|
||||
background: rgba(30, 30, 30, 0.9) !important;
|
||||
border-color: rgba(246, 124, 124, 0.3) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .milestone svg {
|
||||
filter: drop-shadow(0 0 8px var(--gantt-danger, #f67c7c));
|
||||
animation: milestone-glow-dark 2s ease-in-out infinite alternate;
|
||||
@@ -361,4 +688,176 @@ onUnmounted(() => {
|
||||
filter: drop-shadow(0 0 20px var(--gantt-danger, #f67c7c))
|
||||
drop-shadow(0 0 32px rgba(246, 124, 124, 0.6));
|
||||
}
|
||||
|
||||
/* 停靠状态的特殊样式 */
|
||||
.milestone-sticky svg {
|
||||
z-index: 150;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.milestone-sticky-left svg {
|
||||
animation: milestone-glow-sticky 3s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.milestone-sticky-right svg {
|
||||
animation: milestone-glow-sticky 3s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* 半图标显示时取消发光效果 */
|
||||
.milestone-sticky-left svg[style*='clip-path'],
|
||||
.milestone-sticky-right svg[style*='clip-path'] {
|
||||
animation: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
@keyframes milestone-glow-sticky {
|
||||
from {
|
||||
filter: drop-shadow(0 0 6px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 12px rgba(245, 108, 108, 0.4));
|
||||
}
|
||||
to {
|
||||
filter: drop-shadow(0 0 10px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 20px rgba(245, 108, 108, 0.6));
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗黑模式下的停靠状态样式 */
|
||||
:global(html[data-theme='dark']) .milestone-sticky-left svg,
|
||||
:global(html[data-theme='dark']) .milestone-sticky-right svg {
|
||||
animation: milestone-glow-sticky-dark 3s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* 暗黑模式下半图标显示时取消发光效果 */
|
||||
:global(html[data-theme='dark']) .milestone-sticky-left svg[style*='clip-path'],
|
||||
:global(html[data-theme='dark']) .milestone-sticky-right svg[style*='clip-path'] {
|
||||
animation: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
@keyframes milestone-glow-sticky-dark {
|
||||
from {
|
||||
filter: drop-shadow(0 0 6px var(--gantt-danger, #f67c7c))
|
||||
drop-shadow(0 0 12px rgba(246, 124, 124, 0.4));
|
||||
}
|
||||
to {
|
||||
filter: drop-shadow(0 0 10px var(--gantt-danger, #f67c7c))
|
||||
drop-shadow(0 0 20px rgba(246, 124, 124, 0.6));
|
||||
}
|
||||
}
|
||||
|
||||
/* 半图标显示效果 - 优化clip-path过渡 */
|
||||
.milestone-sticky svg[style*='clip-path'] {
|
||||
transition:
|
||||
clip-path 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
filter 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* 左侧停靠的半图标效果增强 */
|
||||
.milestone-sticky-left svg[style*='clip-path'] {
|
||||
transform-origin: 100% 50%; /* 右侧为缩放原点 */
|
||||
}
|
||||
|
||||
/* 右侧停靠的半图标效果增强 */
|
||||
.milestone-sticky-right svg[style*='clip-path'] {
|
||||
transform-origin: 0% 50%; /* 左侧为缩放原点 */
|
||||
}
|
||||
|
||||
/* 半图标悬停效果 */
|
||||
.milestone-sticky svg[style*='clip-path']:hover {
|
||||
transform: scale(1.15);
|
||||
filter: drop-shadow(0 0 18px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 30px rgba(245, 108, 108, 0.8));
|
||||
}
|
||||
|
||||
.milestone-sticky.milestone-pushing svg {
|
||||
transform: scale(1.1);
|
||||
filter: drop-shadow(0 0 15px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 25px rgba(245, 108, 108, 0.7));
|
||||
}
|
||||
|
||||
/* 停靠状态的增强发光效果 */
|
||||
.milestone-sticky-left svg,
|
||||
.milestone-sticky-right svg {
|
||||
animation: milestone-glow-sticky-enhanced 2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes milestone-glow-sticky-enhanced {
|
||||
from {
|
||||
filter: drop-shadow(0 0 8px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 16px rgba(245, 108, 108, 0.5));
|
||||
}
|
||||
to {
|
||||
filter: drop-shadow(0 0 12px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 24px rgba(245, 108, 108, 0.7)) drop-shadow(0 0 32px rgba(245, 108, 108, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
/* === Milestone Tooltip 样式 === */
|
||||
.milestone-tooltip {
|
||||
position: fixed;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
z-index: 10000; /* 确保在最上层 */
|
||||
max-width: 300px;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.3),
|
||||
0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.milestone-tooltip .tooltip-content {
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 暗黑模式下的Tooltip样式 */
|
||||
:global(html[data-theme='dark']) .milestone-tooltip {
|
||||
background: rgba(30, 30, 30, 0.95) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* 推挤状态的视觉增强 */
|
||||
.milestone-pushing {
|
||||
animation: milestone-pushing-pulse 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes milestone-pushing-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
filter: drop-shadow(0 0 12px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 20px rgba(245, 108, 108, 0.6));
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 推挤动画效果 - 被推出边界的里程碑 */
|
||||
.milestone-pushed-out {
|
||||
opacity: 0;
|
||||
transform: scale(0.6) translateY(-10px);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 停靠状态的交互提示 */
|
||||
.milestone-sticky svg:hover {
|
||||
transform: scale(1.05);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 停靠状态下的点击提示 */
|
||||
.milestone-sticky svg:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { Task } from '../models/classes/Task'
|
||||
|
||||
interface Props {
|
||||
modelValue?: number[]
|
||||
tasks: Task[]
|
||||
currentTaskId?: number
|
||||
label?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
currentTaskId: undefined,
|
||||
label: '前置任务',
|
||||
placeholder: '请选择前置任务',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number[]]
|
||||
}>()
|
||||
|
||||
const selectedValue = ref('')
|
||||
|
||||
// 当前选中的前置任务ID数组
|
||||
const selectedPredecessorIds = computed(() => {
|
||||
return props.modelValue || []
|
||||
})
|
||||
|
||||
// 已选择的前置任务对象
|
||||
const selectedPredecessors = computed(() => {
|
||||
return selectedPredecessorIds.value
|
||||
.map(id => props.tasks.find(task => task.id === id))
|
||||
.filter(task => task) as Task[]
|
||||
})
|
||||
|
||||
// 可选择的任务列表(排除当前任务和已选择的任务)
|
||||
const availableTasks = computed(() => {
|
||||
return props.tasks.filter(
|
||||
task =>
|
||||
task.type === 'task' &&
|
||||
task.id !== props.currentTaskId &&
|
||||
!selectedPredecessorIds.value.includes(task.id),
|
||||
)
|
||||
})
|
||||
|
||||
// 添加前置任务
|
||||
const addPredecessor = () => {
|
||||
if (selectedValue.value) {
|
||||
const newId = Number(selectedValue.value)
|
||||
if (!selectedPredecessorIds.value.includes(newId)) {
|
||||
const newIds = [...selectedPredecessorIds.value, newId]
|
||||
emit('update:modelValue', newIds)
|
||||
}
|
||||
selectedValue.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 移除前置任务
|
||||
const removePredecessor = (taskId: number) => {
|
||||
const newIds = selectedPredecessorIds.value.filter(id => id !== taskId)
|
||||
emit('update:modelValue', newIds)
|
||||
}
|
||||
|
||||
// 监听modelValue变化,更新内部状态
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
selectedValue.value = ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multi-select-predecessor">
|
||||
<label class="form-label">{{ label }}</label>
|
||||
<div class="predecessor-selector">
|
||||
<!-- 已选择的前置任务标签 -->
|
||||
<div v-if="selectedPredecessors.length > 0" class="selected-tags">
|
||||
<span v-for="pred in selectedPredecessors" :key="pred.id" class="predecessor-tag">
|
||||
{{ pred.name }} ({{ pred.id }})
|
||||
<button type="button" class="remove-tag-btn" @click="removePredecessor(pred.id)">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 下拉选择器 -->
|
||||
<div class="select-wrapper">
|
||||
<select v-model="selectedValue" class="form-select" @change="addPredecessor">
|
||||
<option value="">{{ placeholder }}</option>
|
||||
<option v-for="task in availableTasks" :key="task.id" :value="task.id">
|
||||
{{ task.name }} (ID: {{ task.id }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.multi-select-predecessor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gantt-text-secondary, #606266);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.predecessor-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selected-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.predecessor-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
background: var(--gantt-primary, #409eff);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
border-radius: 14px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.remove-tag-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.remove-tag-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.select-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--gantt-border-medium, #dcdfe6);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: var(--gantt-text-primary, #303133);
|
||||
background: var(--gantt-bg-primary, white);
|
||||
transition: border-color 0.2s;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-select:focus {
|
||||
border-color: var(--gantt-primary, #409eff);
|
||||
}
|
||||
|
||||
/* 暗黑模式 */
|
||||
:global(html[data-theme='dark']) .predecessor-tag {
|
||||
background: var(--gantt-primary-dark, #337ecc);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .form-select {
|
||||
background: var(--gantt-bg-primary, #2b2b2b);
|
||||
border-color: var(--gantt-border-medium, #4c4d4f);
|
||||
color: var(--gantt-text-primary, #e5eaf3);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .form-select:focus {
|
||||
border-color: var(--gantt-primary, #409eff);
|
||||
}
|
||||
</style>
|
||||
+747
-8
@@ -9,6 +9,11 @@ interface Props {
|
||||
startDate: Date
|
||||
isParent?: boolean
|
||||
onDoubleClick?: (task: Task) => void
|
||||
// 新增:用于粘性文字显示的滚动位置信息
|
||||
scrollLeft?: number
|
||||
containerWidth?: number
|
||||
// 新增:外部控制半圆隐藏状态(用于Timeline初始化等场景)
|
||||
hideBubbles?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -19,6 +24,7 @@ const emit = defineEmits([
|
||||
'dblclick',
|
||||
'drag-end', // 新增
|
||||
'resize-end', // 新增
|
||||
'scroll-to-position', // 新增:半圆点击定位事件
|
||||
])
|
||||
|
||||
// 日期工具函数 - 处理时区安全的日期创建和操作
|
||||
@@ -327,10 +333,404 @@ const handleTaskBarDoubleClick = (e: MouseEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 计算粘性样式 - 支持左右边界的精细控制
|
||||
const stickyStyles = computed(() => {
|
||||
const scrollLeft = props.scrollLeft || 0
|
||||
const containerWidth = props.containerWidth || 0
|
||||
|
||||
if (!scrollLeft && !containerWidth) {
|
||||
return {
|
||||
nameLeft: '',
|
||||
namePosition: '',
|
||||
nameTop: '',
|
||||
progressLeft: '',
|
||||
progressPosition: '',
|
||||
progressTop: '',
|
||||
}
|
||||
}
|
||||
|
||||
const taskLeft = parseInt(taskBarStyle.value.left)
|
||||
const taskWidth = parseInt(taskBarStyle.value.width)
|
||||
const taskRight = taskLeft + taskWidth
|
||||
const taskCenterX = taskLeft + taskWidth / 2
|
||||
const leftBoundary = scrollLeft
|
||||
const rightBoundary = scrollLeft + containerWidth
|
||||
|
||||
// 默认样式
|
||||
let nameLeft = ''
|
||||
let namePosition = ''
|
||||
let nameTop = ''
|
||||
let progressLeft = ''
|
||||
let progressPosition = ''
|
||||
let progressTop = ''
|
||||
|
||||
// 估算文字内容的实际位置
|
||||
const nameText = props.task.name || ''
|
||||
const nameWidth = Math.max(nameText.length * 7, 40)
|
||||
const progressWidth = 35
|
||||
|
||||
// 计算名称和进度在默认居中状态下的位置
|
||||
const nameLeftPos = taskCenterX - nameWidth / 2
|
||||
const nameRightPos = taskCenterX + nameWidth / 2
|
||||
const progressLeftPos = taskCenterX - progressWidth / 2
|
||||
const progressRightPos = taskCenterX + progressWidth / 2
|
||||
|
||||
// 左侧边界粘性逻辑
|
||||
const nameNeedsLeftSticky =
|
||||
nameLeftPos < leftBoundary && taskRight > leftBoundary && taskCenterX < leftBoundary
|
||||
|
||||
// 右侧边界粘性逻辑
|
||||
const nameNeedsRightSticky =
|
||||
nameRightPos > rightBoundary && taskLeft < rightBoundary && taskCenterX > rightBoundary
|
||||
|
||||
// 名称粘性处理
|
||||
if (nameNeedsLeftSticky) {
|
||||
const offset = leftBoundary - taskLeft
|
||||
nameLeft = `${offset + 8}px`
|
||||
namePosition = 'absolute'
|
||||
nameTop = '6px'
|
||||
} else if (nameNeedsRightSticky) {
|
||||
const offset = rightBoundary - taskLeft - nameWidth
|
||||
nameLeft = `${offset - 8}px`
|
||||
namePosition = 'absolute'
|
||||
nameTop = '6px'
|
||||
}
|
||||
|
||||
// 进度左侧边界粘性逻辑
|
||||
const progressNeedsLeftSticky =
|
||||
progressLeftPos < leftBoundary && taskRight > leftBoundary && taskCenterX < leftBoundary
|
||||
|
||||
// 进度右侧边界粘性逻辑
|
||||
const progressNeedsRightSticky =
|
||||
progressRightPos > rightBoundary && taskLeft < rightBoundary && taskCenterX > rightBoundary
|
||||
|
||||
// 进度粘性处理
|
||||
if (progressNeedsLeftSticky) {
|
||||
const offset = leftBoundary - taskLeft
|
||||
progressLeft = `${offset + 8}px`
|
||||
progressPosition = 'absolute'
|
||||
progressTop = '24px'
|
||||
} else if (progressNeedsRightSticky) {
|
||||
const offset = rightBoundary - taskLeft - progressWidth
|
||||
progressLeft = `${offset - 8}px`
|
||||
progressPosition = 'absolute'
|
||||
progressTop = '24px'
|
||||
}
|
||||
|
||||
return {
|
||||
nameLeft,
|
||||
namePosition,
|
||||
nameTop,
|
||||
progressLeft,
|
||||
progressPosition,
|
||||
progressTop,
|
||||
}
|
||||
})
|
||||
|
||||
// 计算气泡指示器的显示状态和位置
|
||||
const bubbleIndicator = computed(() => {
|
||||
const scrollLeft = props.scrollLeft || 0
|
||||
const containerWidth = props.containerWidth || 0
|
||||
|
||||
// 如果没有有效的滚动信息,不显示气泡
|
||||
if (!containerWidth || containerWidth <= 0) {
|
||||
return {
|
||||
show: false,
|
||||
left: '0px',
|
||||
side: 'left',
|
||||
color: '#409eff',
|
||||
animationType: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
// 如果正在初始化、强制隐藏状态或外部要求隐藏,不显示气泡
|
||||
if (isInitializing.value || bubbleHidden.value || props.hideBubbles) {
|
||||
return {
|
||||
show: false,
|
||||
left: '0px',
|
||||
side: 'left',
|
||||
color: taskStatus.value.color,
|
||||
animationType: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
// 获取实际的DOM位置(考虑缩放等因素)
|
||||
const taskLeft = parseInt(taskBarStyle.value.left)
|
||||
const taskWidth = parseInt(taskBarStyle.value.width)
|
||||
const taskRight = taskLeft + taskWidth
|
||||
const leftBoundary = scrollLeft
|
||||
const rightBoundary = scrollLeft + containerWidth
|
||||
|
||||
// 检查边界状态
|
||||
const isCompletelyOutOfLeft = taskRight <= leftBoundary
|
||||
const isCompletelyOutOfRight = taskLeft >= rightBoundary
|
||||
|
||||
// 只有完全超出边界时才显示半圆
|
||||
if (isCompletelyOutOfLeft) {
|
||||
return {
|
||||
show: true,
|
||||
left: `${leftBoundary - taskLeft - 4}px`, // 圆心在边界上,向左偏移半径(4px)
|
||||
side: 'left',
|
||||
color: taskStatus.value.color,
|
||||
animationType: 'morphToSemiCircle',
|
||||
}
|
||||
}
|
||||
|
||||
if (isCompletelyOutOfRight) {
|
||||
return {
|
||||
show: true,
|
||||
left: `${rightBoundary - taskLeft - 8}px`, // 右侧半圆位置调整,减少偏移量
|
||||
side: 'right',
|
||||
color: taskStatus.value.color,
|
||||
animationType: 'morphToSemiCircle',
|
||||
}
|
||||
}
|
||||
|
||||
// 部分可见或完全可见时不显示
|
||||
return {
|
||||
show: false,
|
||||
left: '0px',
|
||||
side: 'left',
|
||||
color: taskStatus.value.color,
|
||||
animationType: 'none',
|
||||
}
|
||||
})
|
||||
|
||||
// 气泡 tooltip 状态
|
||||
const showTooltip = ref(false)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
// 跟踪滚动状态,避免非滚动时的动画
|
||||
const isScrollingContext = ref(false)
|
||||
const scrollTimeout = ref<number | null>(null)
|
||||
const hasManualResize = ref(false) // 跟踪是否有手动resize事件
|
||||
const isInitializing = ref(true) // 跟踪初始化状态
|
||||
const isAutoScrolling = ref(false) // 跟踪自动滚动状态(如定位到今日、点击半圆定位等)
|
||||
const bubbleHidden = ref(false) // 控制半圆的强制隐藏状态
|
||||
|
||||
// 气泡点击事件 - 将TaskBar定位到Timeline中间
|
||||
const handleBubbleClick = () => {
|
||||
const scrollLeft = props.scrollLeft || 0
|
||||
const containerWidth = props.containerWidth || 0
|
||||
|
||||
if (!scrollLeft && !containerWidth) return
|
||||
|
||||
const taskLeft = parseInt(taskBarStyle.value.left)
|
||||
const taskWidth = parseInt(taskBarStyle.value.width)
|
||||
const taskCenterX = taskLeft + taskWidth / 2
|
||||
|
||||
// 计算将TaskBar中心定位到Timeline中心所需的滚动位置
|
||||
const targetScrollLeft = taskCenterX - containerWidth / 2
|
||||
|
||||
// 标记为自动滚动状态,隐藏所有半圆
|
||||
isAutoScrolling.value = true
|
||||
bubbleHidden.value = true
|
||||
|
||||
// 立即隐藏tooltip
|
||||
showTooltip.value = false
|
||||
|
||||
// 通过事件向Timeline发送滚动请求
|
||||
emit('scroll-to-position', targetScrollLeft)
|
||||
}
|
||||
|
||||
// 监听滚动相关的props变化,判断是否在滚动
|
||||
watch(
|
||||
() => [props.scrollLeft, props.containerWidth],
|
||||
(newValues, oldValues) => {
|
||||
// 检查是否是真实的滚动变化(而非初始化或resize)
|
||||
const [newScrollLeft, newContainerWidth] = newValues
|
||||
const [oldScrollLeft, oldContainerWidth] = oldValues || [0, 0]
|
||||
|
||||
// 确保数值有效
|
||||
const safeNewScrollLeft = newScrollLeft || 0
|
||||
const safeNewContainerWidth = newContainerWidth || 0
|
||||
const safeOldScrollLeft = oldScrollLeft || 0
|
||||
const safeOldContainerWidth = oldContainerWidth || 0
|
||||
|
||||
// 如果容器宽度发生变化(包括Splitter拖拽、TaskList展开收起、窗口resize等)
|
||||
if (Math.abs(safeNewContainerWidth - safeOldContainerWidth) > 1 && safeOldContainerWidth > 0) {
|
||||
hasManualResize.value = true
|
||||
|
||||
// 容器宽度变化时,强制重新计算气泡显示状态
|
||||
// 立即触发bubbleIndicator重新计算
|
||||
nextTick(() => {
|
||||
// computed会自动重新计算
|
||||
})
|
||||
|
||||
// 延长禁用动画的时间,确保各种resize操作稳定
|
||||
setTimeout(() => {
|
||||
hasManualResize.value = false
|
||||
}, 500) // 给容器变化留足够时间稳定
|
||||
return
|
||||
}
|
||||
|
||||
// 首次接收到有效的滚动数据,标记初始化完成
|
||||
if (isInitializing.value && safeNewScrollLeft >= 0 && safeNewContainerWidth > 0) {
|
||||
// 延迟标记初始化完成,等待初始滚动动画结束
|
||||
setTimeout(() => {
|
||||
isInitializing.value = false
|
||||
}, 1000) // 给初始化滚动留足够时间
|
||||
}
|
||||
|
||||
// 只有在scrollLeft变化且没有resize时才认为是滚动
|
||||
if (safeNewScrollLeft !== safeOldScrollLeft && !hasManualResize.value) {
|
||||
isScrollingContext.value = true
|
||||
|
||||
// 清除之前的超时
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
// 滚动结束后的处理
|
||||
scrollTimeout.value = setTimeout(() => {
|
||||
isScrollingContext.value = false
|
||||
|
||||
// 如果是自动滚动结束,恢复半圆显示
|
||||
if (isAutoScrolling.value) {
|
||||
isAutoScrolling.value = false
|
||||
// 延迟一点时间再显示半圆,确保滚动完全停止
|
||||
setTimeout(() => {
|
||||
bubbleHidden.value = false
|
||||
}, 300)
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 监听外部hideBubbles属性变化,确保Timeline的容器变化能及时反应
|
||||
watch(
|
||||
() => props.hideBubbles,
|
||||
(newHidden, oldHidden) => {
|
||||
// 当Timeline设置hideBubbles从true变为false时,强制重新计算半圆状态
|
||||
if (oldHidden && !newHidden) {
|
||||
nextTick(() => {
|
||||
// 强制重新计算bubbleIndicator,确保容器宽度变化后正确显示半圆
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 监听TaskBar可见性变化,只在滚动时实现重新出现动画
|
||||
watch(
|
||||
() => bubbleIndicator.value.show,
|
||||
() => {
|
||||
// TaskBar重新出现时,不需要动画效果
|
||||
// 半圆会自然消失,TaskBar会立即显示
|
||||
},
|
||||
)
|
||||
|
||||
// 监听页面缩放和大小变化,重新计算气泡位置
|
||||
const handleResize = () => {
|
||||
// timeline区域resize时,立即重新计算半圆显示状态
|
||||
hasManualResize.value = true
|
||||
|
||||
// 强制重新计算bubbleIndicator
|
||||
nextTick(() => {
|
||||
// computed会自动重新计算
|
||||
})
|
||||
|
||||
// 短时间后恢复正常状态,允许动画
|
||||
setTimeout(() => {
|
||||
hasManualResize.value = false
|
||||
}, 300) // 缩短时间,快速恢复
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 监听窗口大小变化和缩放
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('zoom', handleResize) // 某些浏览器支持
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('zoom', handleResize)
|
||||
})
|
||||
|
||||
// 处理气泡悬停
|
||||
const handleBubbleMouseEnter = (event: MouseEvent) => {
|
||||
showTooltip.value = true
|
||||
|
||||
// 智能定位:右侧气泡在左侧显示tooltip,左侧气泡在右侧显示
|
||||
const isRightBubble = bubbleIndicator.value.side === 'right'
|
||||
const offsetX = isRightBubble ? -180 : 15 // 右侧气泡向左偏移距离调整,与左侧距离一致
|
||||
|
||||
tooltipPosition.value = {
|
||||
x: event.clientX + offsetX,
|
||||
y: event.clientY - 10,
|
||||
}
|
||||
}
|
||||
|
||||
const handleBubbleMouseLeave = () => {
|
||||
showTooltip.value = false
|
||||
}
|
||||
|
||||
// 处理气泡点击事件 - 点击时隐藏tooltip,但不影响定位功能
|
||||
const handleBubbleMouseDown = (event: MouseEvent) => {
|
||||
// 阻止mousedown事件冒泡,防止影响其他功能
|
||||
event.stopPropagation()
|
||||
// 点击时隐藏tooltip
|
||||
showTooltip.value = false
|
||||
}
|
||||
|
||||
// 格式化日期显示
|
||||
const formatDisplayDate = (dateStr: string | undefined): string => {
|
||||
if (!dateStr) return '未设置'
|
||||
const date = createLocalDate(dateStr)
|
||||
if (!date) return '未设置'
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
// 计算工时信息
|
||||
const workHourInfo = computed(() => {
|
||||
// 这里可以根据实际的数据结构调整
|
||||
const startDate = createLocalDate(props.task.startDate)
|
||||
const endDate = createLocalDate(props.task.endDate)
|
||||
|
||||
let totalHours = 0
|
||||
if (startDate && endDate) {
|
||||
const diffTime = Math.abs(endDate.getTime() - startDate.getTime())
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
totalHours = diffDays * 8 // 假设每天8小时
|
||||
}
|
||||
|
||||
const progress = props.task.progress || 0
|
||||
const usedHours = Math.round((totalHours * progress) / 100)
|
||||
|
||||
return {
|
||||
total: totalHours,
|
||||
used: usedHours,
|
||||
}
|
||||
})
|
||||
|
||||
// Helper functions to create type-safe style objects
|
||||
const getNameStyles = () => {
|
||||
const styles = stickyStyles.value
|
||||
const result: Record<string, string> = {}
|
||||
|
||||
if (styles.nameLeft) result.left = styles.nameLeft
|
||||
if (styles.namePosition) result.position = styles.namePosition
|
||||
if (styles.nameTop) result.top = styles.nameTop
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const getProgressStyles = () => {
|
||||
const styles = stickyStyles.value
|
||||
const result: Record<string, string> = {}
|
||||
|
||||
if (styles.progressLeft) result.left = styles.progressLeft
|
||||
if (styles.progressPosition) result.position = styles.progressPosition
|
||||
if (styles.progressTop) result.top = styles.progressTop
|
||||
|
||||
return result
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -343,6 +743,7 @@ onUnmounted(() => {
|
||||
borderColor: taskStatus.borderColor,
|
||||
color: taskStatus.color,
|
||||
cursor: isCompleted || isParent ? 'default' : 'move',
|
||||
'--row-height': `${rowHeight}px` /* 传递行高给CSS变量 */,
|
||||
}"
|
||||
:class="{
|
||||
dragging: isDragging,
|
||||
@@ -374,8 +775,15 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 任务条主体(非父级任务) -->
|
||||
<div v-if="!isParent" class="task-bar-content" @mousedown="e => handleMouseDown(e, 'drag')">
|
||||
<div class="task-name">{{ task.name }}</div>
|
||||
<div v-if="task.progress !== undefined" class="task-progress">{{ task.progress }}%</div>
|
||||
<!-- 任务名称 -->
|
||||
<div class="task-name" :style="getNameStyles()">
|
||||
{{ task.name }}
|
||||
</div>
|
||||
|
||||
<!-- 进度百分比 -->
|
||||
<div v-if="task.progress !== undefined" class="task-progress" :style="getProgressStyles()">
|
||||
{{ task.progress }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧调整把手 -->
|
||||
@@ -384,7 +792,63 @@ onUnmounted(() => {
|
||||
class="resize-handle resize-handle-right"
|
||||
@mousedown="e => handleMouseDown(e, 'resize-right')"
|
||||
></div>
|
||||
|
||||
<!-- 半圆气泡指示器 - 只在 TaskBar 完全消失时显示 -->
|
||||
<div
|
||||
v-if="bubbleIndicator.show && !isParent"
|
||||
class="bubble-indicator"
|
||||
:class="[
|
||||
`bubble-${bubbleIndicator.side}`,
|
||||
`bubble-animation-${bubbleIndicator.animationType}`,
|
||||
]"
|
||||
:style="{
|
||||
left: bubbleIndicator.left,
|
||||
backgroundColor: bubbleIndicator.color,
|
||||
borderColor: bubbleIndicator.color,
|
||||
}"
|
||||
@mouseenter="handleBubbleMouseEnter"
|
||||
@mouseleave="handleBubbleMouseLeave"
|
||||
@mousedown="handleBubbleMouseDown"
|
||||
@click="handleBubbleClick"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip 弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showTooltip"
|
||||
class="task-tooltip"
|
||||
:style="{
|
||||
left: `${tooltipPosition.x}px`,
|
||||
top: `${tooltipPosition.y}px`,
|
||||
}"
|
||||
>
|
||||
<div class="tooltip-arrow"></div>
|
||||
<div class="tooltip-title">{{ task.name }}</div>
|
||||
<div class="tooltip-content">
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-label">计划开始:</span>
|
||||
<span class="tooltip-value">{{ formatDisplayDate(task.startDate) }}</span>
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-label">计划结束:</span>
|
||||
<span class="tooltip-value">{{ formatDisplayDate(task.endDate) }}</span>
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-label">计划工时:</span>
|
||||
<span class="tooltip-value">{{ workHourInfo.total }}h</span>
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-label">已用工时:</span>
|
||||
<span class="tooltip-value">{{ workHourInfo.used }}h</span>
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-label">完成率:</span>
|
||||
<span class="tooltip-value">{{ task.progress || 0 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -397,7 +861,7 @@ onUnmounted(() => {
|
||||
min-width: 60px;
|
||||
z-index: 100;
|
||||
border: 2px solid;
|
||||
overflow: hidden;
|
||||
overflow: visible; /* 允许内容超出 TaskBar */
|
||||
}
|
||||
|
||||
.task-bar:hover {
|
||||
@@ -498,22 +962,27 @@ onUnmounted(() => {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
overflow: visible; /* 允许内容超出 */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
overflow: visible;
|
||||
line-height: 1.2;
|
||||
font-size: 12px;
|
||||
font-weight: 700; /* 加粗显示 */
|
||||
z-index: 10;
|
||||
/* 移除背景样式,保持原始状态 */
|
||||
}
|
||||
|
||||
.task-progress {
|
||||
opacity: 0.9;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 700; /* 加粗显示 */
|
||||
z-index: 10;
|
||||
/* 移除背景样式,保持原始状态 */
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
@@ -540,6 +1009,276 @@ onUnmounted(() => {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* === 半圆气泡指示器样式 === */
|
||||
.bubble-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 8px; /* 半圆宽度 */
|
||||
height: 16px; /* 半圆高度 */
|
||||
z-index: 15;
|
||||
cursor: pointer;
|
||||
border: 2px solid;
|
||||
transform: translateY(-50%);
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.15),
|
||||
0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 左侧半圆 - 圆心在边界上,只显示右半部分 */
|
||||
.bubble-left {
|
||||
border-radius: 0 8px 8px 0;
|
||||
border-left: none;
|
||||
transform: translateY(-50%); /* 不需要额外偏移,圆心已在边界 */
|
||||
}
|
||||
|
||||
/* 右侧半圆 - 圆心在边界上,只显示左半部分 */
|
||||
.bubble-right {
|
||||
border-radius: 8px 0 0 8px;
|
||||
border-right: none;
|
||||
transform: translateY(-50%); /* 不需要额外偏移,圆心已在边界 */
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
.bubble-indicator:hover {
|
||||
transform: translateY(-50%) scale(1.2);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.2),
|
||||
0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bubble-left:hover {
|
||||
transform: translateY(-50%) scale(1.2);
|
||||
}
|
||||
|
||||
.bubble-right:hover {
|
||||
transform: translateY(-50%) scale(1.2);
|
||||
}
|
||||
|
||||
/* === 半圆气泡动画效果 === */
|
||||
|
||||
/* TaskBar 边缘变成左侧半圆的动画 */
|
||||
@keyframes morphToLeftSemiCircle {
|
||||
0% {
|
||||
width: 60px;
|
||||
height: 30px;
|
||||
border-radius: 4px 0 0 4px;
|
||||
border-right: 2px solid;
|
||||
border-left: none;
|
||||
opacity: 0.8;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
30% {
|
||||
width: 30px;
|
||||
height: 28px;
|
||||
border-radius: 6px 0 0 6px;
|
||||
opacity: 0.9;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
70% {
|
||||
width: 12px;
|
||||
height: 20px;
|
||||
border-radius: 0 10px 10px 0;
|
||||
border-right: 2px solid;
|
||||
border-left: none;
|
||||
opacity: 1;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
100% {
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
border-right: 2px solid;
|
||||
border-left: none;
|
||||
opacity: 1;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* TaskBar 边缘变成右侧半圆的动画 */
|
||||
@keyframes morphToRightSemiCircle {
|
||||
0% {
|
||||
width: 60px;
|
||||
height: 30px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
border-left: 2px solid;
|
||||
border-right: none;
|
||||
opacity: 0.8;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
30% {
|
||||
width: 30px;
|
||||
height: 28px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
opacity: 0.9;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
70% {
|
||||
width: 12px;
|
||||
height: 20px;
|
||||
border-radius: 10px 0 0 10px;
|
||||
border-left: 2px solid;
|
||||
border-right: none;
|
||||
opacity: 1;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
100% {
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
border-radius: 8px 0 0 8px;
|
||||
border-left: 2px solid;
|
||||
border-right: none;
|
||||
opacity: 1;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 半圆的脉动效果 */
|
||||
@keyframes semiCirclePulse {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-50%) scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-50%) scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* 左侧半圆脉动 */
|
||||
@keyframes leftSemiCirclePulse {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-50%) scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-50%) scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* 右侧半圆脉动 */
|
||||
@keyframes rightSemiCirclePulse {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-50%) scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-50%) scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* 应用动画类 */
|
||||
.bubble-animation-morphToSemiCircle {
|
||||
animation: semiCirclePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 左侧半圆的变换动画 */
|
||||
.bubble-left.bubble-animation-morphToSemiCircle {
|
||||
animation:
|
||||
morphToLeftSemiCircle 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards,
|
||||
leftSemiCirclePulse 2s ease-in-out 0.8s infinite;
|
||||
}
|
||||
|
||||
/* 右侧半圆的变换动画 */
|
||||
.bubble-right.bubble-animation-morphToSemiCircle {
|
||||
animation:
|
||||
morphToRightSemiCircle 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards,
|
||||
rightSemiCirclePulse 2s ease-in-out 0.8s infinite;
|
||||
}
|
||||
|
||||
/* TaskBar 重新出现动画已移除,保持简洁 */
|
||||
|
||||
/* === Tooltip 样式 === */
|
||||
.task-tooltip {
|
||||
position: fixed;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
z-index: 10000; /* 确保在最上层 */
|
||||
max-width: 250px;
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.4),
|
||||
0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(4px); /* 增加模糊背景效果 */
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tooltip-title {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tooltip-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.tooltip-label {
|
||||
opacity: 0.8;
|
||||
min-width: 60px;
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
.tooltip-value {
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sticky-text {
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
line-height: 1.2;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 暗色主题支持 */
|
||||
:global(html[data-theme='dark']) .task-bar {
|
||||
border-color: #111827 !important;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from '../composables/useI18n'
|
||||
import { useMessage } from '../composables/useMessage'
|
||||
import DatePicker from './DatePicker.vue'
|
||||
import GanttConfirmDialog from './GanttConfirmDialog.vue'
|
||||
import MultiSelectPredecessor from './MultiSelectPredecessor.vue'
|
||||
import type { Task } from '../models/classes/Task'
|
||||
import '../styles/app.css'
|
||||
|
||||
@@ -56,7 +57,7 @@ const formData = reactive<Task>({
|
||||
assignee: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
predecessor: '',
|
||||
predecessor: [],
|
||||
estimatedHours: 0,
|
||||
actualHours: 0,
|
||||
progress: 0,
|
||||
@@ -67,13 +68,6 @@ const formData = reactive<Task>({
|
||||
// 任务列表数据
|
||||
const allTasks = ref<Task[]>([])
|
||||
|
||||
// 获取可作为前置任务的任务列表(只包含type="task"的任务,且不包含当前任务)
|
||||
const availablePredecessorTasks = computed(() => {
|
||||
return allTasks.value.filter(
|
||||
task => task.type === 'task' && task.id !== props.task?.id, // 排除当前任务自己
|
||||
)
|
||||
})
|
||||
|
||||
// 获取可作为上级任务的任务列表(只显示story和task类型,排除当前任务自己)
|
||||
const availableParentTasks = computed(() => {
|
||||
return allTasks.value
|
||||
@@ -221,7 +215,7 @@ const resetForm = () => {
|
||||
assignee: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
predecessor: '',
|
||||
predecessor: [],
|
||||
estimatedHours: 0,
|
||||
actualHours: 0,
|
||||
progress: 0,
|
||||
@@ -489,19 +483,13 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-predecessor">{{ t.predecessor }}</label>
|
||||
<select id="task-predecessor" v-model="formData.predecessor" class="form-select">
|
||||
<option value="">{{ t.predecessorPlaceholder }}</option>
|
||||
<option
|
||||
v-for="predTask in availablePredecessorTasks"
|
||||
:key="predTask.id"
|
||||
:value="predTask.id"
|
||||
>
|
||||
{{ predTask.name }} (ID: {{ predTask.id }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<MultiSelectPredecessor
|
||||
v-model="formData.predecessor"
|
||||
:tasks="allTasks"
|
||||
:current-task-id="props.task?.id"
|
||||
:label="t.predecessor"
|
||||
:placeholder="t.predecessorPlaceholder"
|
||||
/>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useI18n } from '../composables/useI18n'
|
||||
import { formatPredecessorDisplay } from '../utils/predecessorUtils'
|
||||
import type { Task } from '../models/classes/Task'
|
||||
|
||||
interface Props {
|
||||
@@ -264,7 +265,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 普通任务显示详细信息 -->
|
||||
<template v-else>
|
||||
<div class="col col-pre">{{ props.task.predecessor || '-' }}</div>
|
||||
<div class="col col-pre">{{ formatPredecessorDisplay(props.task.predecessor) }}</div>
|
||||
<div class="col col-assignee">
|
||||
<div class="assignee-info">
|
||||
<div class="avatar">
|
||||
|
||||
+366
-32
@@ -5,9 +5,10 @@ import MilestonePoint from './MilestonePoint.vue'
|
||||
import TaskDrawer from './TaskDrawer.vue'
|
||||
import MilestoneDialog from './MilestoneDialog.vue'
|
||||
import { useI18n } from '../composables/useI18n'
|
||||
import { getPredecessorIds } from '../utils/predecessorUtils'
|
||||
import type { Task } from '../models/classes/Task'
|
||||
import type { TimelineConfig } from '../models/configs/TimelineConfig'
|
||||
import type { Milestone } from '../models/classes/Milestone'
|
||||
import type { TimelineConfig } from '../models/configs/TimelineConfig'
|
||||
|
||||
// 定义Props接口
|
||||
interface Props {
|
||||
@@ -18,7 +19,7 @@ interface Props {
|
||||
// TaskBar双击事件处理器API
|
||||
onTaskDoubleClick?: (task: Task) => void
|
||||
// 自定义编辑组件
|
||||
editComponent?: any
|
||||
editComponent?: unknown
|
||||
// 是否使用默认的TaskDrawer
|
||||
useDefaultDrawer?: boolean
|
||||
// 自定义删除处理器
|
||||
@@ -93,7 +94,7 @@ const isEditMode = ref(false)
|
||||
|
||||
// 里程碑对话框状态管理
|
||||
const milestoneDialogVisible = ref(false)
|
||||
const currentMilestone = ref(null)
|
||||
const currentMilestone = ref<Milestone | null>(null)
|
||||
|
||||
// 悬停状态管理
|
||||
const hoveredTaskId = ref<number | null>(null)
|
||||
@@ -108,10 +109,138 @@ const scrollProgress = ref(0)
|
||||
const isScrolling = ref(false)
|
||||
let scrollTimeout: number | null = null
|
||||
|
||||
// 粘性效果所需的滚动位置信息
|
||||
const timelineScrollLeft = ref(0)
|
||||
const timelineContainerWidth = ref(0)
|
||||
|
||||
// 半圆气泡控制状态
|
||||
const hideBubbles = ref(true) // 初始时隐藏半圆,等待初始滚动完成
|
||||
const isInitialScrolling = ref(true) // 跟踪初始滚动状态
|
||||
|
||||
// 容器高度状态管理
|
||||
const timelineBodyHeight = ref(0)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
// 里程碑位置信息管理(用于推挤效果)
|
||||
const milestonePositions = ref<
|
||||
Map<
|
||||
string | number,
|
||||
{
|
||||
left: number
|
||||
originalLeft: number // 原始位置(不考虑停靠)
|
||||
isSticky: boolean
|
||||
stickyPosition: 'left' | 'right' | 'none'
|
||||
}
|
||||
>
|
||||
>(new Map())
|
||||
|
||||
// 计算当前所有里程碑的位置信息
|
||||
const computeAllMilestonesPositions = () => {
|
||||
const positions = new Map()
|
||||
|
||||
// 遍历所有里程碑分组
|
||||
tasks.value.forEach(task => {
|
||||
if (task.type === 'milestone-group' && task.children) {
|
||||
task.children.forEach(milestone => {
|
||||
const milestoneDate = new Date(milestone.startDate || '')
|
||||
if (!isNaN(milestoneDate.getTime())) {
|
||||
const startDiff = Math.floor(
|
||||
(milestoneDate.getTime() - timelineConfig.value.startDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)
|
||||
const left = startDiff * 30 + 30 / 2 - 12 // 30是dayWidth,12是图标半径
|
||||
|
||||
// 计算边界粘性状态
|
||||
const iconLeft = left - 12
|
||||
const iconRight = left + 12
|
||||
const leftBoundary = timelineScrollLeft.value
|
||||
const rightBoundary = timelineScrollLeft.value + timelineContainerWidth.value
|
||||
|
||||
let isSticky = false
|
||||
let stickyPosition: 'left' | 'right' | 'none' = 'none'
|
||||
|
||||
if (iconRight <= leftBoundary + 12) {
|
||||
isSticky = true
|
||||
stickyPosition = 'left'
|
||||
} else if (iconLeft >= rightBoundary - 12) {
|
||||
isSticky = true
|
||||
stickyPosition = 'right'
|
||||
}
|
||||
|
||||
positions.set(milestone.id, {
|
||||
left,
|
||||
originalLeft: left, // 保存原始位置
|
||||
isSticky,
|
||||
stickyPosition,
|
||||
})
|
||||
}
|
||||
})
|
||||
} else if (task.type === 'milestone') {
|
||||
const milestoneDate = new Date(task.startDate || '')
|
||||
if (!isNaN(milestoneDate.getTime())) {
|
||||
const startDiff = Math.floor(
|
||||
(milestoneDate.getTime() - timelineConfig.value.startDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)
|
||||
const left = startDiff * 30 + 30 / 2 - 12
|
||||
|
||||
// 计算边界粘性状态
|
||||
const iconLeft = left - 12
|
||||
const iconRight = left + 12
|
||||
const leftBoundary = timelineScrollLeft.value
|
||||
const rightBoundary = timelineScrollLeft.value + timelineContainerWidth.value
|
||||
|
||||
let isSticky = false
|
||||
let stickyPosition: 'left' | 'right' | 'none' = 'none'
|
||||
|
||||
if (iconRight <= leftBoundary + 12) {
|
||||
isSticky = true
|
||||
stickyPosition = 'left'
|
||||
} else if (iconLeft >= rightBoundary - 12) {
|
||||
isSticky = true
|
||||
stickyPosition = 'right'
|
||||
}
|
||||
|
||||
positions.set(task.id, {
|
||||
left,
|
||||
originalLeft: left, // 保存原始位置
|
||||
isSticky,
|
||||
stickyPosition,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
milestonePositions.value = positions
|
||||
}
|
||||
|
||||
// 获取其他里程碑的位置信息(排除当前里程碑)
|
||||
const getOtherMilestonesInfo = (currentId: string | number) => {
|
||||
const result: Array<{
|
||||
id: string | number
|
||||
left: number
|
||||
originalLeft: number // 新增:原始位置(不考虑停靠)
|
||||
isSticky: boolean
|
||||
stickyPosition: 'left' | 'right' | 'none'
|
||||
priority: number // 新增:推挤优先级(基于原始位置)
|
||||
}> = []
|
||||
|
||||
milestonePositions.value.forEach((position, id) => {
|
||||
if (id !== currentId) {
|
||||
result.push({
|
||||
id,
|
||||
left: position.left,
|
||||
originalLeft: position.originalLeft, // 使用保存的原始位置
|
||||
isSticky: position.isSticky,
|
||||
stickyPosition: position.stickyPosition,
|
||||
priority: position.originalLeft, // 使用原始位置作为优先级
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 处理拖拽开始事件
|
||||
const handleSplitterDragStart = () => {
|
||||
isSplitterDragging.value = true
|
||||
@@ -120,6 +249,26 @@ const handleSplitterDragStart = () => {
|
||||
// 处理拖拽结束事件
|
||||
const handleSplitterDragEnd = () => {
|
||||
isSplitterDragging.value = false
|
||||
|
||||
// Splitter拖拽结束后,强制重新计算半圆显示状态
|
||||
// 因为Timeline容器宽度可能发生了变化
|
||||
hideBubbles.value = true
|
||||
setTimeout(() => {
|
||||
hideBubbles.value = false
|
||||
}, 300) // 300ms后恢复半圆显示
|
||||
}
|
||||
|
||||
// 处理Timeline容器resize事件(如TaskList切换等)
|
||||
const handleTimelineContainerResized = () => {
|
||||
// Timeline容器大小发生变化,需要强制重新计算半圆显示状态
|
||||
|
||||
// 立即隐藏半圆,让TaskBar重新计算边界
|
||||
hideBubbles.value = true
|
||||
|
||||
// 延迟恢复显示,确保容器变化完全生效
|
||||
setTimeout(() => {
|
||||
hideBubbles.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 处理任务行悬停事件
|
||||
@@ -169,7 +318,7 @@ const handleLocaleChange = () => {
|
||||
}
|
||||
|
||||
// 处理里程碑双击事件
|
||||
const handleMilestoneDoubleClick = (milestone: any) => {
|
||||
const handleMilestoneDoubleClick = (milestone: Milestone) => {
|
||||
currentMilestone.value = milestone
|
||||
milestoneDialogVisible.value = true
|
||||
}
|
||||
@@ -191,10 +340,10 @@ const handleMilestoneIconChange = (milestoneId: number, icon: string) => {
|
||||
}
|
||||
|
||||
// 处理里程碑保存事件
|
||||
const handleMilestoneSave = (updatedMilestone: any) => {
|
||||
const handleMilestoneSave = (updatedMilestone: Milestone) => {
|
||||
// 通知父组件里程碑数据已更新
|
||||
if (props.onMilestoneSave && typeof props.onMilestoneSave === 'function') {
|
||||
props.onMilestoneSave(updatedMilestone)
|
||||
props.onMilestoneSave(updatedMilestone as Task) // Type conversion for backward compatibility
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
@@ -229,10 +378,10 @@ const handleMilestoneDelete = (milestoneId: number) => {
|
||||
}
|
||||
|
||||
// 处理里程碑拖拽更新事件
|
||||
const handleMilestoneUpdate = (updatedMilestone: any) => {
|
||||
const handleMilestoneUpdate = (updatedMilestone: Milestone) => {
|
||||
// 通知父组件里程碑数据已更新
|
||||
if (props.onMilestoneSave && typeof props.onMilestoneSave === 'function') {
|
||||
props.onMilestoneSave(updatedMilestone)
|
||||
props.onMilestoneSave(updatedMilestone as Task) // Type conversion for backward compatibility
|
||||
}
|
||||
|
||||
// 广播里程碑更新事件,通知其他组件数据变化
|
||||
@@ -321,6 +470,10 @@ watch(
|
||||
|
||||
// 将今日定位到时间线中间位置
|
||||
const scrollToTodayCenter = (retry = 0) => {
|
||||
// 开始滚动时隐藏半圆
|
||||
hideBubbles.value = true
|
||||
isInitialScrolling.value = true
|
||||
|
||||
const today = new Date()
|
||||
const timelineStart = timelineConfig.value.startDate
|
||||
|
||||
@@ -356,6 +509,14 @@ const scrollToTodayCenter = (retry = 0) => {
|
||||
} else {
|
||||
scrollContainer.scrollLeft = Math.max(0, centeredScrollPosition)
|
||||
}
|
||||
|
||||
// 滚动结束后延迟显示半圆
|
||||
setTimeout(() => {
|
||||
isInitialScrolling.value = false
|
||||
setTimeout(() => {
|
||||
hideBubbles.value = false
|
||||
}, 300) // 再等300ms确保滚动完全停止
|
||||
}, 1500) // 给滚动动画留1.5秒时间
|
||||
}
|
||||
|
||||
const scrollToTasks = () => {
|
||||
@@ -551,35 +712,67 @@ const handleTaskBarDragEnd = (updatedTask: Task) => {
|
||||
const handleTaskBarResizeEnd = (updatedTask: Task) => {
|
||||
window.dispatchEvent(new CustomEvent('taskbar-resize-end', { detail: updatedTask }))
|
||||
}
|
||||
|
||||
// 处理TaskBar的滚动定位请求
|
||||
const handleScrollToPosition = (targetScrollLeft: number) => {
|
||||
if (timelineContainer.value) {
|
||||
// 开始自动滚动时隐藏半圆
|
||||
hideBubbles.value = true
|
||||
|
||||
// 确保滚动位置在有效范围内
|
||||
const maxScrollLeft = timelineContainer.value.scrollWidth - timelineContainer.value.clientWidth
|
||||
const clampedScrollLeft = Math.max(0, Math.min(targetScrollLeft, maxScrollLeft))
|
||||
|
||||
// 平滑滚动到目标位置
|
||||
timelineContainer.value.scrollTo({
|
||||
left: clampedScrollLeft,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
|
||||
// 滚动结束后延迟显示半圆
|
||||
setTimeout(() => {
|
||||
hideBubbles.value = false
|
||||
}, 1000) // 给滚动动画留1秒时间
|
||||
}
|
||||
}
|
||||
// 向上传递 MilestonePoint 拖拽事件
|
||||
const handleMilestoneDragEnd = (updatedMilestone: any) => {
|
||||
const handleMilestoneDragEnd = (updatedMilestone: Milestone) => {
|
||||
window.dispatchEvent(new CustomEvent('milestone-drag-end', { detail: updatedMilestone }))
|
||||
}
|
||||
|
||||
// 计算所有连线
|
||||
const links = computed(() => {
|
||||
const result: { from: number; to: number; path: string }[] = []
|
||||
|
||||
// 获取当前渲染的任务ID集合,用于过滤关系线
|
||||
const currentTaskIds = new Set(tasks.value.map(task => task.id))
|
||||
|
||||
for (const task of tasks.value) {
|
||||
if (
|
||||
task.predecessor &&
|
||||
taskBarPositions.value[task.id] &&
|
||||
taskBarPositions.value[Number(task.predecessor)]
|
||||
) {
|
||||
const fromBar = taskBarPositions.value[Number(task.predecessor)]
|
||||
const toBar = taskBarPositions.value[task.id]
|
||||
// 起点为前置TaskBar右侧中点,终点为当前TaskBar左侧中点
|
||||
const x1 = fromBar.left + fromBar.width
|
||||
const y1 = fromBar.top + fromBar.height / 2
|
||||
const x2 = toBar.left
|
||||
const y2 = toBar.top + toBar.height / 2
|
||||
// 控制点:横向中点,纵向分别为起点和终点
|
||||
const c1x = x1 + 40
|
||||
const c1y = y1
|
||||
const c2x = x2 - 40
|
||||
const c2y = y2
|
||||
// 三次贝塞尔曲线
|
||||
const path = `M${x1},${y1} C${c1x},${c1y} ${c2x},${c2y} ${x2},${y2}`
|
||||
result.push({ from: Number(task.predecessor), to: task.id, path })
|
||||
if (task.predecessor && taskBarPositions.value[task.id]) {
|
||||
// 获取所有前置任务ID
|
||||
const predecessorIds = getPredecessorIds(task.predecessor)
|
||||
|
||||
// 为每个前置任务创建连线
|
||||
for (const predecessorId of predecessorIds) {
|
||||
// 只有当前置任务也在当前渲染列表中时,才绘制关系线
|
||||
if (taskBarPositions.value[predecessorId] && currentTaskIds.has(predecessorId)) {
|
||||
const fromBar = taskBarPositions.value[predecessorId]
|
||||
const toBar = taskBarPositions.value[task.id]
|
||||
// 起点为前置TaskBar右侧中点,终点为当前TaskBar左侧中点
|
||||
const x1 = fromBar.left + fromBar.width
|
||||
const y1 = fromBar.top + fromBar.height / 2
|
||||
const x2 = toBar.left
|
||||
const y2 = toBar.top + toBar.height / 2
|
||||
// 控制点:横向中点,纵向分别为起点和终点
|
||||
const c1x = x1 + 40
|
||||
const c1y = y1
|
||||
const c2x = x2 - 40
|
||||
const c2y = y2
|
||||
// 三次贝塞尔曲线
|
||||
const path = `M${x1},${y1} C${c1x},${c1y} ${c2x},${c2y} ${x2},${y2}`
|
||||
result.push({ from: predecessorId, to: task.id, path })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -606,10 +799,19 @@ onMounted(() => {
|
||||
// 监听Splitter拖拽事件
|
||||
window.addEventListener('splitter-drag-start', handleSplitterDragStart as EventListener)
|
||||
window.addEventListener('splitter-drag-end', handleSplitterDragEnd as EventListener)
|
||||
// 监听Timeline容器resize事件(TaskList切换等)
|
||||
window.addEventListener(
|
||||
'timeline-container-resized',
|
||||
handleTimelineContainerResized as EventListener,
|
||||
)
|
||||
|
||||
// 监听里程碑点击定位事件
|
||||
window.addEventListener('milestone-click-locate', handleMilestoneClickLocate as EventListener)
|
||||
|
||||
// 设置ResizeObserver监听timeline-body的尺寸变化
|
||||
nextTick(() => {
|
||||
const timelineBody = document.querySelector('.timeline-body') as HTMLElement
|
||||
const timelineContainer = document.querySelector('.timeline') as HTMLElement
|
||||
if (timelineBody) {
|
||||
timelineBodyHeight.value = timelineBody.clientHeight
|
||||
|
||||
@@ -621,6 +823,38 @@ onMounted(() => {
|
||||
|
||||
resizeObserver.observe(timelineBody)
|
||||
}
|
||||
|
||||
// 初始化滚动位置信息,使用正确的滚动容器
|
||||
if (timelineContainer) {
|
||||
timelineScrollLeft.value = timelineContainer.scrollLeft
|
||||
timelineContainerWidth.value = timelineContainer.clientWidth
|
||||
|
||||
// 为容器宽度变化创建独立的ResizeObserver
|
||||
const containerResizeObserver = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
const newWidth = entry.contentRect.width
|
||||
// 当容器宽度发生变化时,立即更新宽度并重新计算半圆显示
|
||||
if (Math.abs(newWidth - timelineContainerWidth.value) > 1) {
|
||||
timelineContainerWidth.value = newWidth
|
||||
|
||||
// 对于容器宽度变化,我们需要立即重新计算半圆状态
|
||||
// 短时间隐藏后重新显示,让TaskBar重新计算边界
|
||||
hideBubbles.value = true
|
||||
|
||||
// 延迟恢复显示,确保宽度变化完全生效
|
||||
setTimeout(() => {
|
||||
hideBubbles.value = false
|
||||
}, 300) // 增加到300ms,确保resize完全结束
|
||||
}
|
||||
}
|
||||
})
|
||||
containerResizeObserver.observe(timelineContainer)
|
||||
|
||||
// 将容器ResizeObserver也存储起来,用于清理
|
||||
if (!resizeObserver) {
|
||||
resizeObserver = containerResizeObserver
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 页面加载后,直接将今日定位到中间
|
||||
@@ -750,6 +984,13 @@ const handleTimelineScroll = (event: Event) => {
|
||||
const clientWidth = target.clientWidth
|
||||
const maxScroll = scrollWidth - clientWidth
|
||||
|
||||
// 更新粘性效果所需的滚动位置信息
|
||||
timelineScrollLeft.value = scrollLeft
|
||||
timelineContainerWidth.value = clientWidth
|
||||
|
||||
// 滚动时重新计算里程碑位置信息,支持推挤效果
|
||||
computeAllMilestonesPositions()
|
||||
|
||||
// 计算滚动进度 (0-1)
|
||||
scrollProgress.value = maxScroll > 0 ? scrollLeft / maxScroll : 0
|
||||
|
||||
@@ -800,6 +1041,11 @@ onUnmounted(() => {
|
||||
window.removeEventListener('locale-changed', handleLocaleChange as EventListener)
|
||||
window.removeEventListener('splitter-drag-start', handleSplitterDragStart as EventListener)
|
||||
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd as EventListener)
|
||||
window.removeEventListener(
|
||||
'timeline-container-resized',
|
||||
handleTimelineContainerResized as EventListener,
|
||||
)
|
||||
window.removeEventListener('milestone-click-locate', handleMilestoneClickLocate as EventListener)
|
||||
window.removeEventListener('resize', updateSvgSize)
|
||||
window.removeEventListener('scroll', handleTimelineScroll as EventListener)
|
||||
|
||||
@@ -857,10 +1103,72 @@ const convertTaskToMilestone = (task: Task): Milestone => {
|
||||
endDate: task.startDate || task.endDate,
|
||||
}
|
||||
}
|
||||
|
||||
// 监听tasks变化,重新计算里程碑位置
|
||||
watch(tasks, computeAllMilestonesPositions, { immediate: true, deep: true })
|
||||
|
||||
// 监听滚动变化,重新计算里程碑位置
|
||||
watch([timelineScrollLeft, timelineContainerWidth], computeAllMilestonesPositions)
|
||||
|
||||
// 监听tasks变化,清理不再存在的任务的位置信息
|
||||
watch(
|
||||
() => tasks.value,
|
||||
newTasks => {
|
||||
const currentTaskIds = new Set(newTasks.map(task => task.id))
|
||||
|
||||
// 清理不再存在的任务的位置信息
|
||||
Object.keys(taskBarPositions.value).forEach(taskIdStr => {
|
||||
const taskId = parseInt(taskIdStr)
|
||||
if (!currentTaskIds.has(taskId)) {
|
||||
delete taskBarPositions.value[taskId]
|
||||
}
|
||||
})
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 处理里程碑点击定位事件
|
||||
const handleMilestoneClickLocate = (event: CustomEvent) => {
|
||||
const { scrollLeft, smooth } = event.detail
|
||||
|
||||
// 获取Timeline容器 - 尝试两个可能的滚动容器
|
||||
const timelineMain = document.querySelector('.timeline') as HTMLElement
|
||||
const timelineBody = document.querySelector('.timeline-body') as HTMLElement
|
||||
|
||||
// 选择有滚动能力的容器
|
||||
let scrollContainer: HTMLElement | null = null
|
||||
if (timelineMain && timelineMain.scrollWidth > timelineMain.clientWidth) {
|
||||
scrollContainer = timelineMain
|
||||
} else if (timelineBody && timelineBody.scrollWidth > timelineBody.clientWidth) {
|
||||
scrollContainer = timelineBody
|
||||
}
|
||||
|
||||
if (scrollContainer) {
|
||||
// 确保滚动位置在有效范围内
|
||||
const maxScrollLeft = scrollContainer.scrollWidth - scrollContainer.clientWidth
|
||||
const targetScrollLeft = Math.min(Math.max(0, scrollLeft), maxScrollLeft)
|
||||
|
||||
if (smooth) {
|
||||
// 平滑滚动
|
||||
scrollContainer.scrollTo({
|
||||
left: targetScrollLeft,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
} else {
|
||||
// 立即滚动
|
||||
scrollContainer.scrollLeft = targetScrollLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="timelineContainer" class="timeline" @mousedown="handleMouseDown">
|
||||
<div
|
||||
ref="timelineContainer"
|
||||
class="timeline"
|
||||
@mousedown="handleMouseDown"
|
||||
@scroll="handleTimelineScroll"
|
||||
>
|
||||
<!-- Timeline Header -->
|
||||
<div class="timeline-header">
|
||||
<!-- 第一行:年月 -->
|
||||
@@ -899,7 +1207,7 @@ const convertTaskToMilestone = (task: Task): Milestone => {
|
||||
</div>
|
||||
|
||||
<!-- Timeline Body (Task Bar Area) -->
|
||||
<div class="timeline-body" @scroll="handleTimelineScroll">
|
||||
<div class="timeline-body">
|
||||
<div ref="bodyContentRef" class="timeline-body-content">
|
||||
<!-- SVG关系线层 -->
|
||||
<svg
|
||||
@@ -980,6 +1288,29 @@ const convertTaskToMilestone = (task: Task): Milestone => {
|
||||
:start-date="timelineConfig.startDate"
|
||||
:name="milestone.name"
|
||||
:milestone="convertTaskToMilestone(milestone)"
|
||||
:scroll-left="timelineScrollLeft"
|
||||
:container-width="timelineContainerWidth"
|
||||
:milestone-id="milestone.id"
|
||||
:other-milestones="getOtherMilestonesInfo(milestone.id)"
|
||||
@milestone-double-click="handleMilestoneDoubleClick"
|
||||
@update:milestone="handleMilestoneUpdate"
|
||||
@drag-end="handleMilestoneDragEnd"
|
||||
/>
|
||||
</template>
|
||||
<!-- 独立里程碑 -->
|
||||
<template v-else-if="task.type === 'milestone'">
|
||||
<MilestonePoint
|
||||
:key="task.id"
|
||||
:date="task.startDate || ''"
|
||||
:row-height="50"
|
||||
:day-width="30"
|
||||
:start-date="timelineConfig.startDate"
|
||||
:name="task.name"
|
||||
:milestone="convertTaskToMilestone(task)"
|
||||
:scroll-left="timelineScrollLeft"
|
||||
:container-width="timelineContainerWidth"
|
||||
:milestone-id="task.id"
|
||||
:other-milestones="getOtherMilestonesInfo(task.id)"
|
||||
@milestone-double-click="handleMilestoneDoubleClick"
|
||||
@update:milestone="handleMilestoneUpdate"
|
||||
@drag-end="handleMilestoneDragEnd"
|
||||
@@ -994,12 +1325,15 @@ const convertTaskToMilestone = (task: Task): Milestone => {
|
||||
:start-date="timelineConfig.startDate"
|
||||
:is-parent="task.isParent"
|
||||
:on-double-click="props.onTaskDoubleClick"
|
||||
:edit-component="props.editComponent"
|
||||
:scroll-left="timelineScrollLeft"
|
||||
:container-width="timelineContainerWidth"
|
||||
:hide-bubbles="hideBubbles"
|
||||
@update:task="updateTask"
|
||||
@bar-mounted="handleBarMounted"
|
||||
@dblclick="handleTaskBarDoubleClick(task)"
|
||||
@drag-end="handleTaskBarDragEnd"
|
||||
@resize-end="handleTaskBarResizeEnd"
|
||||
@scroll-to-position="handleScrollToPosition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -115,6 +115,8 @@ const messages = {
|
||||
update: '更新',
|
||||
taskNameTooLong: '任务名称不能超过50个字符',
|
||||
predecessorPlaceholder: '请选择前置任务',
|
||||
selectPredecessor: '选择前置任务',
|
||||
removePredecessor: '移除前置任务',
|
||||
operationFailed: '操作失败,请重试',
|
||||
taskUpdateSuccess: '任务更新成功',
|
||||
taskCreateSuccess: '任务创建成功',
|
||||
@@ -256,6 +258,8 @@ const messages = {
|
||||
update: 'Update',
|
||||
taskNameTooLong: 'Task name cannot exceed 50 characters',
|
||||
predecessorPlaceholder: 'Select predecessor',
|
||||
selectPredecessor: 'Select Predecessor',
|
||||
removePredecessor: 'Remove Predecessor',
|
||||
operationFailed: 'Operation failed, please try again',
|
||||
taskUpdateSuccess: 'Task updated successfully',
|
||||
taskCreateSuccess: 'Task created successfully',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
export interface Task {
|
||||
id: number
|
||||
name: string
|
||||
predecessor?: string
|
||||
predecessor?: number[] // 前置任务ID数组
|
||||
assignee?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
|
||||
+5
-1
@@ -6,7 +6,11 @@
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid;
|
||||
transition: all 0.2s;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 前置任务工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取任务的前置任务ID数组
|
||||
* @param predecessor 前置任务字段,可能是数组、字符串或空
|
||||
* @returns 前置任务ID数组
|
||||
*/
|
||||
export function getPredecessorIds(predecessor?: number[] | string | string[]): number[] {
|
||||
if (!predecessor) return []
|
||||
|
||||
// 如果已经是number数组,直接返回
|
||||
if (Array.isArray(predecessor) && typeof predecessor[0] === 'number') {
|
||||
return predecessor as number[]
|
||||
}
|
||||
|
||||
// 如果是字符串数组
|
||||
if (Array.isArray(predecessor)) {
|
||||
return predecessor
|
||||
.filter(id => id && id.toString().trim())
|
||||
.map(id => Number(id.toString().trim()))
|
||||
.filter(id => !isNaN(id))
|
||||
}
|
||||
|
||||
// 字符串格式,支持逗号分隔的多个ID
|
||||
return predecessor
|
||||
.split(',')
|
||||
.map(id => id.trim())
|
||||
.filter(id => id)
|
||||
.map(id => Number(id))
|
||||
.filter(id => !isNaN(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 将前置任务ID数组转换为数组格式(标准格式)
|
||||
* @param predecessorIds 前置任务ID数组
|
||||
* @returns number数组
|
||||
*/
|
||||
export function predecessorIdsToArray(predecessorIds: number[]): number[] {
|
||||
return predecessorIds.filter(id => id > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将前置任务ID数组转换为字符串格式(兼容性)
|
||||
* @param predecessorIds 前置任务ID数组
|
||||
* @returns 逗号分隔的字符串
|
||||
*/
|
||||
export function predecessorIdsToString(predecessorIds: number[]): string {
|
||||
return predecessorIds.filter(id => id > 0).join(',')
|
||||
}
|
||||
|
||||
/**
|
||||
* 将前置任务字符串转换为数组格式
|
||||
* @param predecessor 前置任务字符串
|
||||
* @returns 前置任务ID数组
|
||||
*/
|
||||
export function predecessorStringToArray(predecessor?: string): string[] {
|
||||
if (!predecessor) return []
|
||||
return predecessor
|
||||
.split(',')
|
||||
.map(id => id.trim())
|
||||
.filter(id => id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查任务是否有特定的前置任务
|
||||
* @param task 任务对象
|
||||
* @param predecessorId 要检查的前置任务ID
|
||||
* @returns 是否包含该前置任务
|
||||
*/
|
||||
export function hasPredecessor(task: { predecessor?: number[] }, predecessorId: number): boolean {
|
||||
const ids = getPredecessorIds(task.predecessor)
|
||||
return ids.includes(predecessorId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加前置任务到任务
|
||||
* @param task 任务对象
|
||||
* @param predecessorId 要添加的前置任务ID
|
||||
*/
|
||||
export function addPredecessor(task: { predecessor?: number[] }, predecessorId: number): void {
|
||||
const ids = getPredecessorIds(task.predecessor)
|
||||
if (!ids.includes(predecessorId)) {
|
||||
ids.push(predecessorId)
|
||||
task.predecessor = predecessorIdsToArray(ids)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从任务中移除前置任务
|
||||
* @param task 任务对象
|
||||
* @param predecessorId 要移除的前置任务ID
|
||||
*/
|
||||
export function removePredecessor(task: { predecessor?: number[] }, predecessorId: number): void {
|
||||
const ids = getPredecessorIds(task.predecessor)
|
||||
const filteredIds = ids.filter(id => id !== predecessorId)
|
||||
task.predecessor = filteredIds.length > 0 ? predecessorIdsToArray(filteredIds) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化前置任务显示文本
|
||||
* @param predecessor 前置任务字段
|
||||
* @returns 格式化后的显示文本
|
||||
*/
|
||||
export function formatPredecessorDisplay(predecessor?: number[]): string {
|
||||
const ids = getPredecessorIds(predecessor)
|
||||
return ids.length > 0 ? ids.join(', ') : '-'
|
||||
}
|
||||
Reference in New Issue
Block a user