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
+9
View File
@@ -5,6 +5,15 @@ 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.1.0] - 2025-07-08
### Added
- TaskBar拖拽触发Timeline水平滑动
- 增加日|周|月视图切换
### Fixed
- 问题修复
## [1.0.10] - 2025-07-06
### Added
+3 -1
View File
@@ -188,6 +188,7 @@ interface ToolbarConfig {
showLanguage?: boolean // Show language switch button
showTheme?: boolean // Show theme switch button
showFullscreen?: boolean // Show fullscreen toggle button
showTimeScale?: boolean // Show time scale toggle buttons (Day|Week|Month)
}
```
@@ -327,7 +328,8 @@ const toolbarConfig = {
showTodayLocate: true,
showExportCsv: true,
showExportPdf: true,
showFullscreen: true
showFullscreen: true,
showTimeScale: true // Control visibility of Day|Week|Month time scale toggle buttons
}
// Custom locale messages
+3 -1
View File
@@ -189,6 +189,7 @@ interface ToolbarConfig {
showLanguage?: boolean // 是否显示语言切换按钮
showTheme?: boolean // 是否显示主题切换按钮
showFullscreen?: boolean // 是否显示全屏切换按钮
showTimeScale?: boolean // 是否显示时间刻度切换按钮组(日|周|月)
}
```
@@ -328,7 +329,8 @@ const toolbarConfig = {
showTodayLocate: true,
showExportCsv: true,
showExportPdf: true,
showFullscreen: true
showFullscreen: true,
showTimeScale: true // 控制日|周|月时间刻度按钮组的可见性
}
// 自定义多语言配置
+1
View File
@@ -41,6 +41,7 @@ const toolbarConfig = {
showLanguage: true,
showTheme: true,
showFullscreen: true,
showTimeScale: true, // 控制日|周|月时间刻度按钮组的可见性
}
// 自定义CSV导出处理器(可选)
+5
View File
@@ -135,5 +135,10 @@
"Timeline中sub-task隐藏后,关系线也随之隐藏",
"增强TaskBar可以存在多个前置任务"
]
},
{
"version": "1.1.0",
"date": "2025-07-08",
"notes": ["TaskBar拖拽触发Timeline水平滑动", "增加日|周|月视图切换", "问题修复"]
}
]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "jordium-gantt-vue3",
"version": "1.0.9",
"version": "1.0.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jordium-gantt-vue3",
"version": "1.0.9",
"version": "1.0.10",
"license": "MIT",
"dependencies": {
"date-fns": "^4.1.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jordium-gantt-vue3",
"version": "1.0.10",
"version": "1.1.0",
"type": "module",
"main": "dist/jordium-gantt-vue3.cjs.js",
"module": "dist/jordium-gantt-vue3.es.js",
+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
+20
View File
@@ -35,6 +35,8 @@ const messages = {
// 日期格式
yearMonthFormat: (year: number, month: number) =>
`${year}${String(month).padStart(2, '0')}`,
// 月份格式
monthFormat: (month: number) => `${month}`,
// 其他
milestone: '里程碑',
@@ -71,6 +73,11 @@ const messages = {
exitFullscreen: '退出全屏',
githubDocs: '查看Github文档',
giteeDocs: '查看Gitee文档',
// 时间刻度按钮
timeScaleMonth: '月',
timeScaleWeek: '周',
timeScaleDay: '日',
timeScaleTooltip: '切换时间刻度',
// 确认对话框
confirmDialogMessage: '是否需要保留该设置?',
// 新建任务对话框
@@ -178,6 +185,8 @@ const messages = {
// 日期格式
yearMonthFormat: (year: number, month: number) => `${year}/${String(month).padStart(2, '0')}`,
// 月份格式
monthFormat: (month: number) => `M${String(month).padStart(2, '0')}`,
// 其他
milestone: 'Milestone',
@@ -215,6 +224,11 @@ const messages = {
exitFullscreen: 'Exit Fullscreen',
githubDocs: 'GitHub Docs',
giteeDocs: 'Gitee Docs',
// 时间刻度按钮
timeScaleMonth: 'Month',
timeScaleWeek: 'Week',
timeScaleDay: 'Day',
timeScaleTooltip: 'Switch Time Scale',
// Confirm dialog
confirmDialogMessage: 'Do you want to save this setting?',
taskNamePlaceholder: 'Enter task name',
@@ -363,6 +377,11 @@ export function useI18n() {
return t.value.yearMonthFormat(year, month)
}
// 格式化月份
const formatMonth = (month: number) => {
return t.value.monthFormat(month)
}
return {
t,
getTranslation,
@@ -370,6 +389,7 @@ export function useI18n() {
locale,
setLocale,
formatYearMonth,
formatMonth,
}
}
+1
View File
@@ -8,4 +8,5 @@ export interface ToolbarConfig {
showLanguage?: boolean
showTheme?: boolean
showFullscreen?: boolean
showTimeScale?: boolean // 显示时间刻度按钮组
}
+75
View File
@@ -0,0 +1,75 @@
// 时间轴比例类型定义
// 使用字符串字面量类型代替enum,兼容erasableSyntaxOnly
export type TimelineScale = 'day' | 'week' | 'month'
// 导出常量值以便于使用
export const TimelineScale = {
DAY: 'day' as TimelineScale, // 日视图 - 每列显示一天
WEEK: 'week' as TimelineScale, // 周视图 - 每列显示一周
MONTH: 'month' as TimelineScale, // 月视图 - 每列显示一个月
}
export interface TimelineScaleConfig {
scale: TimelineScale
cellWidth: number // 每个时间单元的宽度(px)
headerLevels: number // 表头层级数
formatters: {
primary: string // 主要时间标签格式
secondary?: string // 次要时间标签格式
}
}
// 预设配置
export const SCALE_CONFIGS = {
day: {
scale: TimelineScale.DAY,
cellWidth: 30,
headerLevels: 2,
formatters: { primary: 'yyyy年MM月', secondary: 'dd' },
},
week: {
scale: TimelineScale.WEEK,
cellWidth: 120,
headerLevels: 2,
formatters: { primary: 'yyyy年MM月', secondary: 'W周' },
},
month: {
scale: TimelineScale.MONTH,
cellWidth: 180,
headerLevels: 2,
formatters: { primary: 'yyyy年', secondary: 'MM月' },
},
} as Record<TimelineScale, TimelineScaleConfig>
// 时间单元数据接口
export interface TimelineUnit {
id: string
startDate: Date
endDate: Date
label: string
isToday?: boolean
isWeekend?: boolean
width: number
}
// 时间轴数据结构
export interface TimelineData {
scale: TimelineScale
units: TimelineUnit[]
months?: Array<{
year: number
month: number
yearMonthLabel: string
startDate: Date
endDate: Date
units: TimelineUnit[]
}>
weeks?: Array<{
weekNumber: number
year: number
startDate: Date
endDate: Date
units: TimelineUnit[]
}>
}