v1.0.10 - UX upgrades

This commit is contained in:
LINING-PC\lining
2025-07-06 11:03:03 +08:00
parent 94ee66cbb4
commit af4a35254f
20 changed files with 2026 additions and 191 deletions
+23 -90
View File
@@ -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% {
+3 -3
View File
@@ -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;
+505 -6
View File
@@ -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>
+195
View File
@@ -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
View File
@@ -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;
+10 -22
View File
@@ -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">
+2 -1
View File
@@ -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
View File
@@ -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 {
// TaskBarAPI
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 // 30dayWidth12
//
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
}
// TimelineresizeTaskList
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]
// TaskBarTaskBar
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]
// TaskBarTaskBar
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)
// TimelineresizeTaskList
window.addEventListener(
'timeline-container-resized',
handleTimelineContainerResized as EventListener,
)
//
window.addEventListener('milestone-click-locate', handleMilestoneClickLocate as EventListener)
// ResizeObservertimeline-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) // 300msresize
}
}
})
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>