v1.1.0 - Trigger timeline scroll when TaskBar dragging; Add 'Day|Week|Month' timeline scale; Issues fixed.

This commit is contained in:
LINING-PC\lining
2025-07-08 20:03:04 +08:00
parent 11ee864d36
commit 9cc769c622
15 changed files with 1680 additions and 92 deletions
+37 -3
View File
@@ -9,6 +9,7 @@ import jsPDF from 'jspdf'
import html2canvas from 'html2canvas'
import type { Task } from '../models/classes/Task'
import type { ToolbarConfig } from '../models/configs/ToolbarConfig'
import { TimelineScale } from '../models/types/TimelineScale'
import { useMessage } from '../composables/useMessage'
const props = withDefaults(defineProps<Props>(), {
@@ -91,6 +92,9 @@ const leftPanelWidth = ref(320)
// Timeline组件的引用
const timelineRef = ref<InstanceType<typeof Timeline> | null>(null)
// 时间刻度状态
const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY)
// TaskList的固定总长度(所有列的最小宽度之和 + 边框等额外空间)
// 列宽: 300+120+120+140+140+100+100+100 = 1120px
// 边框: 7个列间边框 * 1px = 7px
@@ -615,9 +619,18 @@ const timelineDateRange = computed(() => {
const minDate = new Date(Math.min(...startDates.map(d => d.getTime())))
const maxDate = new Date(Math.max(...endDates.map(d => d.getTime())))
// 前后各延伸6个月
const min = new Date(minDate.getFullYear(), minDate.getMonth() - 6, 1)
const max = new Date(maxDate.getFullYear(), maxDate.getMonth() + 6 + 1, 0)
// 日视图前后各延伸6个月
let min = new Date(minDate.getFullYear(), minDate.getMonth() - 6, 1)
let max = new Date(maxDate.getFullYear(), maxDate.getMonth() + 6 + 1, 0)
if (currentTimeScale.value === TimelineScale.WEEK) {
// 月视图Timeline周期为往前1年~往后1年
min = new Date(minDate.getFullYear() - 1, minDate.getMonth(), 1)
max = new Date(maxDate.getFullYear() + 1, maxDate.getMonth() + 1, 0)
} else if (currentTimeScale.value === TimelineScale.MONTH) {
// 月视图Timeline周期为往前2年~往后2年
min = new Date(minDate.getFullYear() - 2, minDate.getMonth(), 1)
max = new Date(maxDate.getFullYear() + 2, maxDate.getMonth() + 1, 0)
}
return { min, max }
})
@@ -638,6 +651,25 @@ const csvExportHandler = () => {
defaultExportCsv()
}
// 时间刻度变化处理函数
const handleTimeScaleChange = (scale: TimelineScale) => {
currentTimeScale.value = scale
// 通知 Timeline 组件更新时间刻度
if (timelineRef.value) {
timelineRef.value.updateTimeScale(scale)
}
}
// Timeline组件时间刻度变化完成后的处理函数
const handleTimelineScaleChanged = (scale: TimelineScale) => {
// 强制重新渲染所有TaskBar,触发位置重新计算
nextTick(() => {
// 触发强制更新,让所有TaskBar重新计算位置
const event = new CustomEvent('timeline-scale-updated', { detail: scale })
window.dispatchEvent(event)
})
}
// 默认CSV导出功能
const defaultExportCsv = () => {
try {
@@ -1156,6 +1188,7 @@ watch(
:on-language-change="props.onLanguageChange"
:on-theme-change="props.onThemeChange"
:on-fullscreen-change="props.onFullscreenChange"
:on-time-scale-change="handleTimeScaleChange"
/>
<!-- 甘特图主体 -->
@@ -1212,6 +1245,7 @@ watch(
:use-default-drawer="props.useDefaultDrawer"
:on-task-delete="props.onTaskDelete"
:on-milestone-save="handleMilestoneSave"
@timeline-scale-changed="handleTimelineScaleChanged"
/>
</div>
</div>
+185 -7
View File
@@ -2,6 +2,7 @@
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useI18n } from '../composables/useI18n'
import type { ToolbarConfig } from '../models/configs/ToolbarConfig'
import { TimelineScale } from '../models/types/TimelineScale'
import '../styles/app.css'
// 语言定义 - 使用多语言系统的类型
@@ -18,6 +19,7 @@ const props = withDefaults(defineProps<Props>(), {
onThemeChange: undefined,
onFullscreenChange: undefined,
onSettingsConfirm: undefined,
onTimeScaleChange: undefined,
})
const emit = defineEmits<{
@@ -29,6 +31,7 @@ const emit = defineEmits<{
'language-change': [lang: 'zh-CN' | 'en-US']
'theme-change': [isDark: boolean]
'fullscreen-change': [isFullscreen: boolean]
'time-scale-change': [scale: TimelineScale]
}>()
// LocalStorage keys
@@ -52,6 +55,7 @@ interface Props {
onLanguageChange?: (lang: 'zh-CN' | 'en-US') => void
onThemeChange?: (isDark: boolean) => void
onFullscreenChange?: (isFullscreen: boolean) => void
onTimeScaleChange?: (scale: TimelineScale) => void
// 外部确认接口
onSettingsConfirm?: (
type: 'theme' | 'language',
@@ -78,6 +82,7 @@ const currentLanguage = ref<Language>('zh')
const isDarkMode = ref(getInitialTheme())
const isFullscreen = ref(false)
const showLanguageDropdown = ref(false)
const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY)
// 翻译函数 - 使用 useI18n 提供的 getTranslation 函数
const t = (key: string): string => {
@@ -261,6 +266,33 @@ const handleFullscreenToggle = () => {
}
}
// 时间刻度切换处理
const handleTimeScaleChange = (scale: TimelineScale) => {
currentTimeScale.value = scale
if (props.onTimeScaleChange && typeof props.onTimeScaleChange === 'function') {
props.onTimeScaleChange(scale)
} else {
emit('time-scale-change', scale)
}
}
// 计算分段控制器滑块位置
const getThumbStyle = () => {
const scaleIndex = {
[TimelineScale.MONTH]: 0,
[TimelineScale.WEEK]: 1,
[TimelineScale.DAY]: 2,
}
const index = scaleIndex[currentTimeScale.value] || 0
const translateX = index * 100 // 每个选项占33.33%,所以移动100%的倍数
return {
transform: `translateX(${translateX}%)`,
}
}
// 点击外部关闭下拉菜单
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
@@ -413,6 +445,36 @@ onUnmounted(() => {
<!-- 右侧设置区 -->
<div class="toolbar-right">
<!-- 时间刻度分段控制器 (Segmented) -->
<div v-if="config.showTimeScale !== false" class="segmented-control time-scale-segmented">
<div class="segmented-track">
<div class="segmented-thumb" :style="getThumbStyle()"></div>
</div>
<button
class="segmented-item"
:class="{ active: currentTimeScale === 'month' }"
:title="t('timeScaleTooltip')"
@click="handleTimeScaleChange(TimelineScale.MONTH)"
>
{{ t('timeScaleMonth') }}
</button>
<button
class="segmented-item"
:class="{ active: currentTimeScale === 'week' }"
:title="t('timeScaleTooltip')"
@click="handleTimeScaleChange(TimelineScale.WEEK)"
>
{{ t('timeScaleWeek') }}
</button>
<button
class="segmented-item"
:class="{ active: currentTimeScale === 'day' }"
:title="t('timeScaleTooltip')"
@click="handleTimeScaleChange(TimelineScale.DAY)"
>
{{ t('timeScaleDay') }}
</button>
</div>
<!-- 语言选择下拉菜单 -->
<div v-if="config.showLanguage !== false" class="language-dropdown">
<button
@@ -587,7 +649,6 @@ 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;*/
}
.toolbar-left {
@@ -616,7 +677,6 @@ onUnmounted(() => {
background: transparent;
color: var(--gantt-text-primary, #606266);
cursor: pointer;
/*transition: all 0.2s ease;*/
outline: none;
}
@@ -682,7 +742,6 @@ onUnmounted(() => {
border: 1px solid var(--gantt-border-color, #dcdfe6);
color: var(--gantt-text-primary, #606266);
cursor: pointer;
/*transition: all 0.2s ease;*/
outline: none;
font-size: 14px;
white-space: nowrap;
@@ -1085,9 +1144,128 @@ onUnmounted(() => {
color: #e5e5e5;
}
:global(html[data-theme='dark']) .btn-group:not(.add-btn-group) .btn-group-item:hover {
background: #404040;
border-color: #66b1ff;
color: #66b1ff;
/* 分段控制器样式 - Element Plus Segmented 风格 */
.segmented-control {
position: relative;
display: inline-flex;
background: var(--gantt-bg-primary, #ffffff);
border: 1px solid var(--gantt-border-color, #dcdfe6);
border-radius: 6px;
padding: 1px;
margin-right: 8px;
overflow: hidden;
transition: border-color 0.2s ease;
height: 36px;
}
.segmented-control:hover {
border-color: var(--gantt-primary-light, #79bbff);
}
.segmented-track {
position: absolute;
top: 1px;
left: 1px;
right: 1px;
bottom: 1px;
pointer-events: none;
}
.segmented-thumb {
position: absolute;
top: 0;
left: 0;
width: 33.333333%;
height: 100%;
background: var(--gantt-primary, #409eff);
border-radius: 5px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.1),
0 1px 6px -1px rgba(0, 0, 0, 0.1);
}
.segmented-item {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1;
height: 34px;
padding: 0 12px;
border: none;
background: transparent;
font-size: 14px;
font-weight: 500;
cursor: pointer;
outline: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
min-width: 40px;
z-index: 1;
border-radius: 5px;
user-select: none;
}
.segmented-item:hover:not(.active) {
color: var(--gantt-primary, #409eff);
background: var(--gantt-bg-hover, rgba(64, 158, 255, 0.06));
}
.segmented-item:active:not(.active) {
background: var(--gantt-bg-active, rgba(64, 158, 255, 0.12));
}
.segmented-item.active {
color: #ffffff;
font-weight: 600;
}
.time-scale-segmented {
height: 36px;
}
.time-scale-segmented .segmented-item {
height: 34px;
font-size: 13px;
min-width: 36px;
}
/* 暗黑模式下的分段控制器样式 */
:global(html[data-theme='dark']) .segmented-control {
background: var(--gantt-bg-secondary, #4b4b4b);
border-color: var(--gantt-border-color, #808080);
}
:global(html[data-theme='dark']) .segmented-control:hover {
border-color: var(--gantt-primary, #3399ff);
}
:global(html[data-theme='dark']) .segmented-thumb {
background: var(--gantt-primary, #3399ff);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.3),
0 1px 6px -1px rgba(0, 0, 0, 0.3);
}
:global(html[data-theme='dark']) .segmented-item {
color: #ffffff !important; /* 强制使用纯白色,更加突出 */
}
/* 特别针对时间刻度分段控制器的暗黑模式样式 */
:global(html[data-theme='dark']) .time-scale-segmented .segmented-item {
color: #ffffff !important; /* 确保时间刻度按钮也使用纯白色 */
}
:global(html[data-theme='dark']) .segmented-item:hover:not(.active) {
color: var(--gantt-primary, #3399ff); /* 使用主色调,更加鲜艳 */
background: rgba(51, 153, 255, 0.12); /* 调整背景透明度,与主色调匹配 */
}
:global(html[data-theme='dark']) .segmented-item:active:not(.active) {
background: rgba(51, 153, 255, 0.2); /* 调整背景透明度,与主色调匹配 */
}
:global(html[data-theme='dark']) .segmented-item.active {
color: #ffffff;
}
</style>
+158 -5
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue'
import type { Milestone } from '../models/classes/Milestone'
import { TimelineScale } from '../models/types/TimelineScale'
interface Props {
date: string // 里程碑日期
@@ -22,6 +23,17 @@ interface Props {
stickyPosition: 'left' | 'right' | 'none'
priority: number // 推挤优先级
}> // 其他里程碑的位置信息
// 新增:时间线数据,用于精确计算subDays定位
timelineData?: Array<{
year: number
month: number
startDate: Date
endDate: Date
subDays?: Array<{ date: Date; dayOfWeek?: number }>
monthData?: { dayCount: number }
}>
// 新增:当前时间刻度
currentTimeScale?: TimelineScale
}
const props = defineProps<Props>()
@@ -89,7 +101,7 @@ const formatDateToLocalString = (date: Date): string => {
return `${year}-${month}-${day}`
}
// 拖拽事件处理
// 拖拽事件处理 - 使用相对位置拖拽方案
const handleMouseDown = (e: MouseEvent) => {
// 如果是停靠状态或被推出边界,禁止拖拽
if (
@@ -104,17 +116,44 @@ const handleMouseDown = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
// 获取当前里程碑相对位置
const timelineContainer = document.querySelector('.timeline') as HTMLElement
if (!timelineContainer) return
// 设置拖拽状态,但不立即开始拖拽
dragStartX.value = e.clientX
dragStartLeft.value = parseInt(milestoneStyle.value.left)
tempMilestoneData.value = null
// 监听自动滚动事件
window.addEventListener('timeline-auto-scroll', handleAutoScroll as EventListener)
// 添加全局事件监听器
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
// 处理自动滚动事件
const handleAutoScroll = (event: CustomEvent) => {
const { scrollDelta } = event.detail
// 当Timeline滚动时,调整鼠标起始位置以保持相对位置
if (isDragging.value) {
dragStartX.value -= scrollDelta
}
}
const handleMouseMove = (e: MouseEvent) => {
// 发送边界检测事件给Timeline
window.dispatchEvent(
new CustomEvent('drag-boundary-check', {
detail: {
mouseX: e.clientX,
isDragging: isDragging.value,
},
}),
)
const deltaX = e.clientX - dragStartX.value
// 只有在真正移动了一定距离后才开始拖拽(避免意外触发)
@@ -132,6 +171,16 @@ const handleMouseMove = (e: MouseEvent) => {
}
const handleMouseUp = () => {
// 停止边界检测
window.dispatchEvent(
new CustomEvent('drag-boundary-check', {
detail: {
mouseX: 0,
isDragging: false,
},
}),
)
// 只有在真正拖拽了(有临时数据)且状态为拖拽中时才触发更新
if (isDragging.value && tempMilestoneData.value && props.milestone) {
const updatedMilestone = {
@@ -142,6 +191,9 @@ const handleMouseUp = () => {
emit('drag-end', updatedMilestone)
}
// 清理自动滚动监听器
window.removeEventListener('timeline-auto-scroll', handleAutoScroll as EventListener)
// 重置所有拖拽状态
isDragging.value = false
tempMilestoneData.value = null
@@ -209,12 +261,32 @@ const milestoneStyle = computed(() => {
}
}
const startDiff = Math.floor(
(milestoneDate.getTime() - props.startDate.getTime()) / (1000 * 60 * 60 * 24),
)
let left = 0
const size = Math.min(props.rowHeight, props.dayWidth * 1.2, 24)
// 优先使用基于timelineData的精确定位(适用于周视图和月视图)
if (
props.timelineData &&
props.currentTimeScale &&
(props.currentTimeScale === TimelineScale.WEEK ||
props.currentTimeScale === TimelineScale.MONTH)
) {
const centerPosition = calculateMilestonePositionFromTimelineData(
milestoneDate,
props.timelineData,
props.currentTimeScale,
)
left = centerPosition - size / 2 // 从中心位置偏移到图标左上角
} else {
// 日视图:保持原有逻辑
const startDiff = Math.floor(
(milestoneDate.getTime() - props.startDate.getTime()) / (1000 * 60 * 60 * 24),
)
left = startDiff * props.dayWidth + props.dayWidth / 2 - size / 2
}
return {
left: `${startDiff * props.dayWidth + props.dayWidth / 2 - size / 2}px`,
left: `${left}px`,
top: `${(props.rowHeight - size) / 2}px`,
width: 'auto',
height: 'auto',
@@ -438,6 +510,87 @@ onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
})
// 基于timelineData和subDays精确计算里程碑位置的函数
const calculateMilestonePositionFromTimelineData = (
targetDate: Date,
timelineData: Array<{
year: number
month: number
startDate: Date
endDate: Date
subDays?: Array<{ date: Date; dayOfWeek?: number }>
monthData?: { dayCount: number }
weeks?: Array<{
weekStart: Date
weekEnd: Date
subDays: Array<{ date: Date; dayOfWeek?: number }>
}>
}>,
timeScale: TimelineScale,
) => {
let cumulativePosition = 0
for (const periodData of timelineData) {
if (timeScale === TimelineScale.WEEK) {
// 周视图:处理嵌套的weeks结构,返回中心位置
const weeks = periodData.weeks || []
for (const week of weeks) {
const weekStart = new Date(week.weekStart)
const weekEnd = new Date(week.weekEnd)
if (targetDate >= weekStart && targetDate <= weekEnd) {
// 找到目标日期所在的周
const weekWidth = 60
const subDays = week.subDays || []
const dayWidth = weekWidth / 7
// 在subDays中查找目标日期的位置
for (let i = 0; i < subDays.length; i++) {
const subDay = subDays[i]
const subDayDate = new Date(subDay.date)
// 比较日期(忽略时分秒)
if (
subDayDate.getFullYear() === targetDate.getFullYear() &&
subDayDate.getMonth() === targetDate.getMonth() &&
subDayDate.getDate() === targetDate.getDate()
) {
return cumulativePosition + i * dayWidth + dayWidth / 2
}
}
// 如果没找到精确匹配,回退到dayOfWeek计算
const dayOfWeek = targetDate.getDay()
return cumulativePosition + dayOfWeek * dayWidth + dayWidth / 2
}
// 累加每周的宽度
cumulativePosition += 60
}
} else if (timeScale === TimelineScale.MONTH) {
// 月视图:处理扁平化的subDays结构,返回中心位置
const periodStart = new Date(periodData.startDate)
const periodEnd = new Date(periodData.endDate)
if (targetDate >= periodStart && targetDate <= periodEnd) {
// 找到目标日期所在的时间段
const monthWidth = 60
const daysInMonth = periodData.monthData?.dayCount || 30
const dayWidth = monthWidth / daysInMonth
const dayInMonth = targetDate.getDate()
return cumulativePosition + (dayInMonth - 1) * dayWidth + dayWidth / 2
}
// 累加每月的宽度
cumulativePosition += 60
}
}
return cumulativePosition // 如果没找到,返回累计位置
}
// ...existing code...
</script>
<template>
+341 -7
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, onUnmounted, onMounted, nextTick, watch } from 'vue'
import type { Task } from '../models/classes/Task'
import { TimelineScale } from '../models/types/TimelineScale'
interface Props {
task: Task
@@ -14,6 +15,17 @@ interface Props {
containerWidth?: number
// 新增:外部控制半圆隐藏状态(用于Timeline初始化等场景)
hideBubbles?: boolean
// 新增:时间线数据,用于精确计算subDays定位
timelineData?: Array<{
year: number
month: number
startDate: Date
endDate: Date
subDays?: Array<{ date: Date; dayOfWeek?: number }>
monthData?: { dayCount: number }
}>
// 新增:当前时间刻度
currentTimeScale?: TimelineScale
}
const props = defineProps<Props>()
@@ -70,6 +82,9 @@ const resizeStartX = ref(0)
const resizeStartWidth = ref(0)
const resizeStartLeft = ref(0)
// 相对位置拖拽方案:记录鼠标相对于TaskBar的位置
const mouseOffsetX = ref(0) // 鼠标在TaskBar内的相对位置
// 缓存拖拽/拉伸过程中的临时数据,只在鼠标抬起时提交更新
const tempTaskData = ref<{
startDate?: string
@@ -94,12 +109,60 @@ const taskBarStyle = computed(() => {
top: '4px',
}
}
const startDiff = Math.floor((startDate.getTime() - baseStart.getTime()) / (1000 * 60 * 60 * 24))
const duration = Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1
let left = 0
let width = 0
// 优先使用基于timelineData的精确定位(适用于周视图和月视图)
if (
props.timelineData &&
props.currentTimeScale &&
(props.currentTimeScale === TimelineScale.WEEK ||
props.currentTimeScale === TimelineScale.MONTH)
) {
// 计算开始位置
const startPosition = calculatePositionFromTimelineData(
startDate,
props.timelineData,
props.currentTimeScale,
)
// 计算结束位置:为结束日期添加一天来获取正确的结束位置
const nextDay = new Date(endDate)
nextDay.setDate(nextDay.getDate() + 1)
let endPosition = calculatePositionFromTimelineData(
nextDay,
props.timelineData,
props.currentTimeScale,
)
// 如果结束日期+1天超出范围,使用结束日期的位置+一天的宽度
if (endPosition === startPosition) {
const dayWidth = props.currentTimeScale === TimelineScale.WEEK ? 60 / 7 : 60 / 30
endPosition =
calculatePositionFromTimelineData(endDate, props.timelineData, props.currentTimeScale) +
dayWidth
}
left = startPosition
width = Math.max(endPosition - startPosition, 4) // 确保最小4px宽度
} else {
// 日视图:保持原有逻辑
const startDiff = Math.floor(
(startDate.getTime() - baseStart.getTime()) / (1000 * 60 * 60 * 24),
)
// 重新计算duration,确保包含结束日期当天
const timeDiffMs = endDate.getTime() - startDate.getTime()
const daysDiff = timeDiffMs / (1000 * 60 * 60 * 24)
// 对于跨天的任务,需要包含开始和结束两天
const duration = Math.floor(daysDiff) + 1
left = startDiff * props.dayWidth
width = duration * props.dayWidth
}
return {
left: `${startDiff * props.dayWidth}px`,
width: `${duration * props.dayWidth}px`,
left: `${left}px`,
width: `${width}px`,
height: `${props.rowHeight - 10}px`,
top: '4px',
}
@@ -172,7 +235,19 @@ const progressWidth = computed(() => {
return `${(progress / 100) * totalWidth}px`
})
// 鼠标事件处理
// 判断是否为周视图(dayWidth小于等于9为周视图)
const isWeekView = computed(() => props.dayWidth <= 9)
// 判断是否为短TaskBar(宽度小于80px
const isShortTaskBar = computed(() => {
const width = parseFloat(taskBarStyle.value.width || '0')
return width < 80
})
// 判断是否需要溢出效果(周视图且短TaskBar)
const needsOverflowEffect = computed(() => isWeekView.value && isShortTaskBar.value)
// 鼠标事件处理 - 使用相对位置拖拽方案
const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-right') => {
// 如果已完成或是父级任务,禁用所有交互
if (isCompleted.value || props.isParent) {
@@ -185,6 +260,15 @@ const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-r
// 清空之前的临时数据
tempTaskData.value = null
// 获取TaskBar相对于Timeline容器的位置
const timelineContainer = document.querySelector('.timeline') as HTMLElement
if (!timelineContainer || !barRef.value) return
const barRect = barRef.value.getBoundingClientRect()
// 计算鼠标相对于TaskBar的位置
mouseOffsetX.value = e.clientX - barRect.left
if (type === 'drag') {
isDragging.value = true
dragStartX.value = e.clientX
@@ -202,10 +286,25 @@ const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-r
resizeStartLeft.value = parseInt(taskBarStyle.value.left)
}
// 监听自动滚动事件
window.addEventListener('timeline-auto-scroll', handleAutoScroll as EventListener)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
// 处理自动滚动事件
const handleAutoScroll = (event: CustomEvent) => {
const { scrollDelta } = event.detail
// 当Timeline滚动时,调整鼠标起始位置以保持相对位置
if (isDragging.value) {
dragStartX.value -= scrollDelta
} else if (isResizingLeft.value || isResizingRight.value) {
resizeStartX.value -= scrollDelta
}
}
function reportBarPosition() {
if (barRef.value) {
const rect = barRef.value.getBoundingClientRect()
@@ -220,6 +319,16 @@ function reportBarPosition() {
}
const handleMouseMove = (e: MouseEvent) => {
// 发送边界检测事件给Timeline
window.dispatchEvent(
new CustomEvent('drag-boundary-check', {
detail: {
mouseX: e.clientX,
isDragging: isDragging.value || isResizingLeft.value || isResizingRight.value,
},
}),
)
if (isDragging.value) {
const deltaX = e.clientX - dragStartX.value
const newLeft = Math.max(0, dragStartLeft.value + deltaX)
@@ -260,6 +369,16 @@ const handleMouseMove = (e: MouseEvent) => {
}
const handleMouseUp = () => {
// 停止边界检测
window.dispatchEvent(
new CustomEvent('drag-boundary-check', {
detail: {
mouseX: 0,
isDragging: false,
},
}),
)
// 如果有临时数据,说明发生了拖拽或拉伸,提交数据更新
if (tempTaskData.value) {
const updatedTask = {
@@ -284,6 +403,9 @@ const handleMouseUp = () => {
})
}
// 清理自动滚动监听器
window.removeEventListener('timeline-auto-scroll', handleAutoScroll as EventListener)
isDragging.value = false
isResizingLeft.value = false
isResizingRight.value = false
@@ -295,6 +417,32 @@ onMounted(() => {
nextTick(() => {
reportBarPosition()
})
// 监听时间刻度变化事件,重新计算位置
const handleTimelineScaleUpdate = () => {
nextTick(() => {
reportBarPosition()
})
}
// 监听强制重新计算事件
const handleForceRecalculate = () => {
// 延迟稍长一点,确保DOM完全更新
nextTick(() => {
setTimeout(() => {
reportBarPosition()
}, 10)
})
}
window.addEventListener('timeline-scale-updated', handleTimelineScaleUpdate)
window.addEventListener('timeline-force-recalculate', handleForceRecalculate)
// 清理函数
onUnmounted(() => {
window.removeEventListener('timeline-scale-updated', handleTimelineScaleUpdate)
window.removeEventListener('timeline-force-recalculate', handleForceRecalculate)
})
})
// 监听任务数据变化,重新报告位置
@@ -709,6 +857,12 @@ const workHourInfo = computed(() => {
}
})
// 判断是否应该显示进度百分比
const shouldShowProgress = computed(() => {
// 任何情况下都显示完成率(包括0%和undefined),确保始终展示
return true
})
// Helper functions to create type-safe style objects
const getNameStyles = () => {
const styles = stickyStyles.value
@@ -731,6 +885,87 @@ const getProgressStyles = () => {
return result
}
// 基于timelineData和subDays精确计算日期位置的函数
const calculatePositionFromTimelineData = (
targetDate: Date,
timelineData: Array<{
year: number
month: number
startDate: Date
endDate: Date
subDays?: Array<{ date: Date; dayOfWeek?: number }>
monthData?: { dayCount: number }
weeks?: Array<{
weekStart: Date
weekEnd: Date
subDays: Array<{ date: Date; dayOfWeek?: number }>
}>
}>,
timeScale: TimelineScale,
) => {
let cumulativePosition = 0
for (const periodData of timelineData) {
if (timeScale === TimelineScale.WEEK) {
// 周视图:处理嵌套的weeks结构
const weeks = periodData.weeks || []
for (const week of weeks) {
const weekStart = new Date(week.weekStart)
const weekEnd = new Date(week.weekEnd)
if (targetDate >= weekStart && targetDate <= weekEnd) {
// 找到目标日期所在的周
const weekWidth = 60
const subDays = week.subDays || []
const dayWidth = weekWidth / 7
// 在subDays中查找目标日期的位置
for (let i = 0; i < subDays.length; i++) {
const subDay = subDays[i]
const subDayDate = new Date(subDay.date)
// 比较日期(忽略时分秒)
if (
subDayDate.getFullYear() === targetDate.getFullYear() &&
subDayDate.getMonth() === targetDate.getMonth() &&
subDayDate.getDate() === targetDate.getDate()
) {
return cumulativePosition + i * dayWidth
}
}
// 如果没找到精确匹配,回退到dayOfWeek计算
const dayOfWeek = targetDate.getDay()
return cumulativePosition + dayOfWeek * dayWidth
}
// 累加每周的宽度
cumulativePosition += 60
}
} else if (timeScale === TimelineScale.MONTH) {
// 月视图:处理扁平化的subDays结构
const periodStart = new Date(periodData.startDate)
const periodEnd = new Date(periodData.endDate)
if (targetDate >= periodStart && targetDate <= periodEnd) {
// 找到目标日期所在的时间段
const monthWidth = 60
const daysInMonth = periodData.monthData?.dayCount || 30
const dayWidth = monthWidth / daysInMonth
const dayInMonth = targetDate.getDate()
return cumulativePosition + (dayInMonth - 1) * dayWidth
}
// 累加每月的宽度
cumulativePosition += 60
}
}
return cumulativePosition // 如果没找到,返回累计位置
}
// ...existing code...
</script>
<template>
@@ -750,6 +985,9 @@ const getProgressStyles = () => {
resizing: isResizingLeft || isResizingRight,
completed: isCompleted,
'parent-task': isParent,
'week-view': isWeekView,
'short-task-bar': isShortTaskBar,
'overflow-effect': needsOverflowEffect,
}"
@dblclick="handleTaskBarDoubleClick"
>
@@ -781,8 +1019,8 @@ const getProgressStyles = () => {
</div>
<!-- 进度百分比 -->
<div v-if="task.progress !== undefined" class="task-progress" :style="getProgressStyles()">
{{ task.progress }}%
<div v-if="shouldShowProgress" class="task-progress" :style="getProgressStyles()">
{{ task.progress || 0 }}%
</div>
</div>
@@ -1009,6 +1247,26 @@ const getProgressStyles = () => {
right: 0;
}
/* 溢出效果下的拉伸handle优化 */
.task-bar.overflow-effect .resize-handle {
z-index: 20; /* 确保handle在溢出内容之上 */
background: rgba(0, 0, 0, 0.15); /* 稍微加深以提高可见性 */
}
.task-bar.overflow-effect .resize-handle:hover {
background: rgba(0, 0, 0, 0.3);
width: 8px; /* 悬停时稍微加宽 */
}
/* 溢出模式下左右handle的位置调整 */
.task-bar.overflow-effect .resize-handle-left {
left: 0;
}
.task-bar.overflow-effect .resize-handle-right {
right: 0;
}
/* === 半圆气泡指示器样式 === */
.bubble-indicator {
position: absolute;
@@ -1349,4 +1607,80 @@ const getProgressStyles = () => {
:global(html[data-theme='dark']) .resize-handle:hover {
background: rgba(255, 255, 255, 0.3) !important;
}
/* 周视图下的短TaskBar样式优化 */
.task-bar.week-view.short-task-bar {
position: relative;
overflow: visible;
}
/* 周视图下短TaskBar的内容溢出效果 */
.task-bar.overflow-effect .task-bar-content {
/* 保持与日视图一致的布局 */
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
padding: 0 8px;
font-size: 12px;
font-weight: 500;
text-align: center;
overflow: visible;
position: relative;
z-index: 10;
/* 确保主体内容区域仍可拖拽 */
pointer-events: auto;
}
.task-bar.overflow-effect .task-name {
/* 保持与日视图一致的样式 */
white-space: nowrap;
overflow: visible;
line-height: 1.2;
font-size: 12px;
font-weight: 700;
z-index: 15;
pointer-events: none;
/* 允许溢出显示,但保持原始样式 */
min-width: max-content;
}
/* 周视图下进度百分比保持与日视图一致 */
.task-bar.overflow-effect .task-progress {
/* 保持与日视图一致的基础样式 */
opacity: 0.9;
font-size: 11px;
font-weight: 700;
z-index: 16;
pointer-events: none;
padding: 1px 3px;
border-radius: 2px;
}
/* 周视图下的TaskBar基础样式调整 */
.task-bar.week-view {
min-width: 4px; /* 确保即使很短的任务也有最小可见宽度 */
border-width: 1px;
border-radius: 2px;
}
/* 暗色主题下的短TaskBar溢出效果 */
:global(html[data-theme='dark']) .task-bar.overflow-effect .resize-handle {
background: rgba(255, 255, 255, 0.15);
}
:global(html[data-theme='dark']) .task-bar.overflow-effect .resize-handle:hover {
background: rgba(255, 255, 255, 0.3);
}
/* 暗色主题下的进度百分比样式 */
:global(html[data-theme='dark']) .task-bar.overflow-effect .task-progress {
background: rgba(0, 0, 0, 0.9);
color: white;
}
:global(html[data-theme='dark']) .task-bar.week-view {
border-color: var(--gantt-border-light, #555555);
}
</style>
File diff suppressed because it is too large Load Diff