2024 lines
59 KiB
Vue
2024 lines
59 KiB
Vue
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||
<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'
|
||
import TaskContextMenu from './TaskContextMenu.vue'
|
||
|
||
import { useI18n } from '../composables/useI18n'
|
||
|
||
interface Props {
|
||
task: Task
|
||
rowHeight: number
|
||
dayWidth: number
|
||
startDate: Date
|
||
isParent?: boolean
|
||
onDoubleClick?: (task: Task) => void
|
||
// 新增:用于粘性文字显示的滚动位置信息
|
||
scrollLeft?: number
|
||
containerWidth?: number
|
||
// 新增:外部控制半圆隐藏状态(用于Timeline初始化等场景)
|
||
hideBubbles?: boolean
|
||
// 新增:时间线数据,用于精确计算subDays定位
|
||
timelineData?: any
|
||
// 新增:当前时间刻度
|
||
currentTimeScale?: TimelineScale
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
|
||
const emit = defineEmits([
|
||
'update:task',
|
||
'bar-mounted',
|
||
'dblclick',
|
||
'drag-end',
|
||
'resize-end',
|
||
'scroll-to-position',
|
||
'start-timer',
|
||
'stop-timer',
|
||
'add-predecessor',
|
||
'add-successor',
|
||
'delete',
|
||
'contextmenu', // 添加原生contextmenu事件声明
|
||
])
|
||
const { getTranslation } = useI18n()
|
||
const t = (key: string): string => {
|
||
return getTranslation(key)
|
||
}
|
||
|
||
// 日期工具函数 - 处理时区安全的日期创建和操作
|
||
const createLocalDate = (dateString: string | Date | undefined | null): Date | null => {
|
||
if (!dateString) return null
|
||
if (dateString instanceof Date) {
|
||
return dateString
|
||
}
|
||
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||
const [year, month, day] = dateString.split('-').map(Number)
|
||
return new Date(year, month - 1, day)
|
||
}
|
||
// 支持带时间的日期字符串 (yyyy-mm-dd hh:mm)
|
||
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(dateString)) {
|
||
const [datePart, timePart] = dateString.split(' ')
|
||
const [year, month, day] = datePart.split('-').map(Number)
|
||
const [hour, minute] = timePart.split(':').map(Number)
|
||
return new Date(year, month - 1, day, hour, minute)
|
||
}
|
||
const d = new Date(dateString)
|
||
return isNaN(d.getTime()) ? null : d
|
||
}
|
||
|
||
const createLocalToday = (): Date => {
|
||
const now = new Date()
|
||
return new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||
}
|
||
|
||
const formatDateToLocalString = (date: Date): string => {
|
||
const year = date.getFullYear()
|
||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||
const day = String(date.getDate()).padStart(2, '0')
|
||
|
||
// 在小时视图中,格式化为包含时间的字符串
|
||
if (props.currentTimeScale === TimelineScale.HOUR) {
|
||
const hour = String(date.getHours()).padStart(2, '0')
|
||
const minute = String(date.getMinutes()).padStart(2, '0')
|
||
return `${year}-${month}-${day} ${hour}:${minute}`
|
||
}
|
||
|
||
return `${year}-${month}-${day}`
|
||
}
|
||
|
||
const addDaysToLocalDate = (date: Date, days: number): Date => {
|
||
const result = new Date(date)
|
||
result.setDate(result.getDate() + days)
|
||
return result
|
||
}
|
||
|
||
// 新增:小时视图下的时间计算工具函数
|
||
const addMinutesToDate = (date: Date, minutes: number): Date => {
|
||
const result = new Date(date)
|
||
result.setMinutes(result.getMinutes() + minutes)
|
||
return result
|
||
}
|
||
|
||
// 新增:计算两个日期之间的分钟差
|
||
const getMinutesDiff = (startDate: Date, endDate: Date): number => {
|
||
return Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60))
|
||
}
|
||
|
||
// 计算是否应该禁用拖拽和调整大小(年度视图下禁用)
|
||
const isInteractionDisabled = computed(() => {
|
||
return props.currentTimeScale === TimelineScale.YEAR
|
||
})
|
||
|
||
// 拖拽状态
|
||
const isDragging = ref(false)
|
||
const isResizingLeft = ref(false)
|
||
const isResizingRight = ref(false)
|
||
const dragStartX = ref(0)
|
||
const dragStartLeft = ref(0)
|
||
const dragStartWidth = ref(0)
|
||
const resizeStartX = ref(0)
|
||
const resizeStartWidth = ref(0)
|
||
const resizeStartLeft = ref(0)
|
||
|
||
// 相对位置拖拽方案:记录鼠标相对于TaskBar的位置
|
||
const mouseOffsetX = ref(0) // 鼠标在TaskBar内的相对位置
|
||
|
||
// 缓存拖拽/拉伸过程中的临时数据,只在鼠标抬起时提交更新
|
||
const tempTaskData = ref<{
|
||
startDate?: string
|
||
endDate?: string
|
||
} | null>(null)
|
||
|
||
const barRef = ref<HTMLElement | null>(null)
|
||
|
||
// 计算任务条位置和宽度
|
||
const taskBarStyle = computed(() => {
|
||
const currentStartDate = tempTaskData.value?.startDate || props.task.startDate
|
||
const currentEndDate = tempTaskData.value?.endDate || props.task.endDate
|
||
|
||
const startDate = createLocalDate(currentStartDate)
|
||
const endDate = createLocalDate(currentEndDate)
|
||
const baseStart = createLocalDate(props.startDate)
|
||
if (!startDate || !endDate || !baseStart) {
|
||
return {
|
||
left: '0px',
|
||
width: '0px',
|
||
height: `${props.rowHeight - 10}px`,
|
||
top: '4px',
|
||
}
|
||
}
|
||
|
||
let left = 0
|
||
let width = 0
|
||
|
||
// 小时视图:按分钟精确计算位置(需要考虑时间部分)
|
||
if (props.currentTimeScale === TimelineScale.HOUR) {
|
||
// 确保 baseStart 是当天的 00:00:00
|
||
const baseStartOfDay = new Date(baseStart)
|
||
baseStartOfDay.setHours(0, 0, 0, 0)
|
||
|
||
// 处理没有时间部分的日期字符串
|
||
let adjustedStartDate = startDate
|
||
let adjustedEndDate = endDate
|
||
|
||
// 检查原始日期字符串是否包含时间部分
|
||
const originalStartStr = currentStartDate || props.task.startDate
|
||
const originalEndStr = currentEndDate || props.task.endDate
|
||
|
||
// 如果startDate没有时间部分(格式为YYYY-MM-DD),设置为当日00:00
|
||
if (
|
||
typeof originalStartStr === 'string' &&
|
||
/^\d{4}-\d{2}-\d{2}$/.test(originalStartStr.trim())
|
||
) {
|
||
adjustedStartDate = new Date(startDate)
|
||
adjustedStartDate.setHours(0, 0, 0, 0)
|
||
}
|
||
|
||
// 如果endDate没有时间部分(格式为YYYY-MM-DD),设置为次日00:00
|
||
if (typeof originalEndStr === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(originalEndStr.trim())) {
|
||
adjustedEndDate = new Date(endDate)
|
||
adjustedEndDate.setDate(adjustedEndDate.getDate() + 1)
|
||
adjustedEndDate.setHours(0, 0, 0, 0)
|
||
}
|
||
|
||
// 计算从当天00:00到任务开始和结束的分钟数
|
||
const startMinutes = getMinutesDiff(baseStartOfDay, adjustedStartDate)
|
||
const endMinutes = getMinutesDiff(baseStartOfDay, adjustedEndDate)
|
||
|
||
// 每小时40px,每分钟40/60 = 2/3 px
|
||
const pixelPerMinute = 40 / 60
|
||
|
||
left = Math.max(0, startMinutes * pixelPerMinute)
|
||
width = Math.max(4, (endMinutes - startMinutes) * pixelPerMinute) // 确保最小4px宽度
|
||
} else {
|
||
// 日视图、周视图、月视图、年视图:只考虑日期部分,忽略时间部分
|
||
|
||
// 将日期标准化为当天的00:00:00,忽略时间部分
|
||
const startDateOnly = new Date(
|
||
startDate.getFullYear(),
|
||
startDate.getMonth(),
|
||
startDate.getDate(),
|
||
)
|
||
const endDateOnly = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
|
||
const baseStartOnly = new Date(
|
||
baseStart.getFullYear(),
|
||
baseStart.getMonth(),
|
||
baseStart.getDate(),
|
||
)
|
||
|
||
if (props.currentTimeScale === TimelineScale.YEAR) {
|
||
// 年度视图计算逻辑
|
||
const startPosition = calculateYearViewPosition(startDateOnly, baseStartOnly)
|
||
const endPosition = calculateYearViewPosition(endDateOnly, baseStartOnly)
|
||
|
||
left = startPosition
|
||
width = Math.max(endPosition - startPosition, 4) // 确保最小4px宽度
|
||
} else if (
|
||
props.timelineData &&
|
||
props.currentTimeScale &&
|
||
(props.currentTimeScale === TimelineScale.WEEK ||
|
||
props.currentTimeScale === TimelineScale.MONTH ||
|
||
props.currentTimeScale === TimelineScale.QUARTER)
|
||
) {
|
||
// 优先使用基于timelineData的精确定位(适用于周视图、月视图和季度视图)
|
||
// 计算开始位置
|
||
const startPosition = calculatePositionFromTimelineData(
|
||
startDateOnly,
|
||
props.timelineData,
|
||
props.currentTimeScale,
|
||
)
|
||
// 计算结束位置:为结束日期添加一天来获取正确的结束位置
|
||
const nextDay = new Date(endDateOnly)
|
||
nextDay.setDate(nextDay.getDate() + 1)
|
||
let endPosition = calculatePositionFromTimelineData(
|
||
nextDay,
|
||
props.timelineData,
|
||
props.currentTimeScale,
|
||
)
|
||
|
||
// 如果结束日期+1天超出范围,使用结束日期的位置+一天的宽度
|
||
if (endPosition === startPosition) {
|
||
let dayWidth = 60 / 30 // 默认月视图
|
||
if (props.currentTimeScale === TimelineScale.WEEK) {
|
||
dayWidth = 60 / 7
|
||
} else if (props.currentTimeScale === TimelineScale.QUARTER) {
|
||
dayWidth = 60 / 90 // 季度视图:每季度60px,约90天
|
||
}
|
||
endPosition =
|
||
calculatePositionFromTimelineData(
|
||
endDateOnly,
|
||
props.timelineData,
|
||
props.currentTimeScale,
|
||
) + dayWidth
|
||
}
|
||
|
||
left = startPosition
|
||
width = Math.max(endPosition - startPosition, 4) // 确保最小4px宽度
|
||
} else {
|
||
// 日视图:基于日期的简单计算
|
||
const startDiff = Math.floor(
|
||
(startDateOnly.getTime() - baseStartOnly.getTime()) / (1000 * 60 * 60 * 24),
|
||
)
|
||
|
||
// 计算持续天数(基于日期,忽略时间)
|
||
const timeDiffMs = endDateOnly.getTime() - startDateOnly.getTime()
|
||
const daysDiff = Math.round(timeDiffMs / (1000 * 60 * 60 * 24))
|
||
|
||
// 如果开始和结束是同一天,duration = 1;否则是实际天数差 + 1(包含结束日期)
|
||
const duration = daysDiff === 0 ? 1 : daysDiff + 1
|
||
|
||
left = startDiff * props.dayWidth
|
||
width = duration * props.dayWidth
|
||
}
|
||
}
|
||
|
||
return {
|
||
left: `${left}px`,
|
||
width: `${width}px`,
|
||
height: `${props.rowHeight - 10}px`,
|
||
top: '4px',
|
||
}
|
||
})
|
||
|
||
// 计算任务状态和颜色
|
||
const taskStatus = computed(() => {
|
||
// 父级任务(Story类型)使用与新建按钮一致的配色
|
||
if (props.isParent) {
|
||
return {
|
||
type: 'parent',
|
||
color: '#409eff', // 与新建按钮一致的蓝色
|
||
bgColor: '#409eff',
|
||
borderColor: '#409eff',
|
||
}
|
||
}
|
||
|
||
const today = createLocalToday()
|
||
const endDate = createLocalDate(props.task.endDate || '')
|
||
const progress = props.task.progress || 0
|
||
|
||
// 已完成
|
||
if (progress >= 100) {
|
||
return {
|
||
type: 'completed',
|
||
color: '#909399', // info color
|
||
bgColor: '#f4f4f5',
|
||
borderColor: '#d3d4d6',
|
||
}
|
||
}
|
||
|
||
// 已延迟(结束日期早于今天且未完成)
|
||
if (endDate && endDate < today && progress < 100) {
|
||
return {
|
||
type: 'delayed',
|
||
color: '#f56c6c', // danger color
|
||
bgColor: '#fef0f0',
|
||
borderColor: '#fbc4c4',
|
||
}
|
||
}
|
||
|
||
// 进行中(结束日期晚于今天且进度>0)
|
||
if (endDate && endDate >= today && progress > 0) {
|
||
return {
|
||
type: 'in-progress',
|
||
color: '#e6a23c', // warning color
|
||
bgColor: '#fdf6ec',
|
||
borderColor: '#f5dab1',
|
||
}
|
||
}
|
||
|
||
// 未开始(进度为0且未延迟)
|
||
return {
|
||
type: 'not-started',
|
||
color: '#409eff', // primary color
|
||
bgColor: '#ecf5ff',
|
||
borderColor: '#b3d8ff',
|
||
}
|
||
})
|
||
|
||
// 判断是否已完成
|
||
const isCompleted = computed(() => (props.task.progress || 0) >= 100)
|
||
|
||
// 计算完成部分的宽度
|
||
const progressWidth = computed(() => {
|
||
const progress = props.task.progress || 0
|
||
const totalWidth = parseInt(taskBarStyle.value.width)
|
||
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 || isInteractionDisabled.value) {
|
||
return
|
||
}
|
||
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
|
||
// 清空之前的临时数据
|
||
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
|
||
dragStartLeft.value = parseInt(taskBarStyle.value.left)
|
||
dragStartWidth.value = parseInt(taskBarStyle.value.width)
|
||
} else if (type === 'resize-left') {
|
||
isResizingLeft.value = true
|
||
resizeStartX.value = e.clientX
|
||
resizeStartWidth.value = parseInt(taskBarStyle.value.width)
|
||
resizeStartLeft.value = parseInt(taskBarStyle.value.left)
|
||
} else if (type === 'resize-right') {
|
||
isResizingRight.value = true
|
||
resizeStartX.value = e.clientX
|
||
resizeStartWidth.value = parseInt(taskBarStyle.value.width)
|
||
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()
|
||
emit('bar-mounted', {
|
||
id: props.task.id,
|
||
left: rect.left,
|
||
top: rect.top,
|
||
width: rect.width,
|
||
height: rect.height,
|
||
})
|
||
}
|
||
}
|
||
|
||
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
|
||
|
||
if (props.currentTimeScale === TimelineScale.HOUR) {
|
||
// 小时视图:15分钟刻度对齐
|
||
const pixelPerMinute = 40 / 60 // 每分钟的像素数
|
||
const pixelPer15Minutes = pixelPerMinute * 15 // 15分钟的像素数
|
||
|
||
// 计算新的左侧位置,对齐到15分钟刻度
|
||
const newLeftRaw = Math.max(0, dragStartLeft.value + deltaX)
|
||
const newLeft = Math.round(newLeftRaw / pixelPer15Minutes) * pixelPer15Minutes
|
||
|
||
// 计算新的开始时间(分钟精度)
|
||
const newStartMinutes = Math.round(newLeft / pixelPerMinute)
|
||
// 确保使用当天的00:00:00作为基准
|
||
const baseStartOfDay = new Date(props.startDate)
|
||
baseStartOfDay.setHours(0, 0, 0, 0)
|
||
const newStartDate = addMinutesToDate(baseStartOfDay, newStartMinutes)
|
||
|
||
// 保持任务的持续时间(计算原始任务的时长)
|
||
const originalStartDate = createLocalDate(props.task.startDate) || props.startDate
|
||
const originalEndDate = createLocalDate(props.task.endDate) || props.startDate
|
||
|
||
// 如果原始任务是日期格式,转换为当天的时间范围
|
||
let originalDurationMinutes: number
|
||
if (props.task.startDate && !props.task.startDate.includes(' ')) {
|
||
// 纯日期格式,默认按天计算(一天 = 1440 分钟)
|
||
const timeDiffMs = originalEndDate.getTime() - originalStartDate.getTime()
|
||
const daysDiff = Math.max(1, Math.round(timeDiffMs / (1000 * 60 * 60 * 24)) + 1)
|
||
originalDurationMinutes = daysDiff * 24 * 60 // 天数转分钟
|
||
} else {
|
||
// 包含时间格式,按实际时间差计算
|
||
originalDurationMinutes = getMinutesDiff(originalStartDate, originalEndDate)
|
||
}
|
||
|
||
const newEndDate = addMinutesToDate(newStartDate, originalDurationMinutes)
|
||
|
||
// 只更新临时数据,不触发事件
|
||
tempTaskData.value = {
|
||
startDate: formatDateToLocalString(newStartDate),
|
||
endDate: formatDateToLocalString(newEndDate),
|
||
}
|
||
} else {
|
||
// 其他视图:保持原有逻辑
|
||
const newLeft = Math.max(0, dragStartLeft.value + deltaX)
|
||
const newStartDate = addDaysToLocalDate(props.startDate, newLeft / props.dayWidth)
|
||
const duration = dragStartWidth.value / props.dayWidth
|
||
const newEndDate = addDaysToLocalDate(newStartDate, duration - 1)
|
||
|
||
// 只更新临时数据,不触发事件
|
||
tempTaskData.value = {
|
||
startDate: formatDateToLocalString(newStartDate),
|
||
endDate: formatDateToLocalString(newEndDate),
|
||
}
|
||
}
|
||
} else if (isResizingLeft.value) {
|
||
const deltaX = e.clientX - resizeStartX.value
|
||
|
||
if (props.currentTimeScale === TimelineScale.HOUR) {
|
||
// 小时视图:15分钟刻度对齐
|
||
const pixelPerMinute = 40 / 60
|
||
const pixelPer15Minutes = pixelPerMinute * 15
|
||
|
||
// 计算新的左侧位置,对齐到15分钟刻度
|
||
const newLeftRaw = Math.max(0, resizeStartLeft.value + deltaX)
|
||
const newLeft = Math.round(newLeftRaw / pixelPer15Minutes) * pixelPer15Minutes
|
||
|
||
// 计算新的开始时间
|
||
const newStartMinutes = Math.round(newLeft / pixelPerMinute)
|
||
// 确保使用当天的00:00:00作为基准
|
||
const baseStartOfDay = new Date(props.startDate)
|
||
baseStartOfDay.setHours(0, 0, 0, 0)
|
||
const newStartDate = addMinutesToDate(baseStartOfDay, newStartMinutes)
|
||
|
||
// 只更新临时数据,不触发事件
|
||
tempTaskData.value = {
|
||
startDate: formatDateToLocalString(newStartDate),
|
||
endDate: props.task.endDate, // 保持原来的结束日期
|
||
}
|
||
} else {
|
||
// 其他视图:保持原有逻辑
|
||
const newLeft = Math.max(0, resizeStartLeft.value + deltaX)
|
||
const newStartDate = addDaysToLocalDate(props.startDate, newLeft / props.dayWidth)
|
||
|
||
// 只更新临时数据,不触发事件
|
||
tempTaskData.value = {
|
||
startDate: formatDateToLocalString(newStartDate),
|
||
endDate: props.task.endDate, // 保持原来的结束日期
|
||
}
|
||
}
|
||
} else if (isResizingRight.value) {
|
||
const deltaX = e.clientX - resizeStartX.value
|
||
|
||
if (props.currentTimeScale === TimelineScale.HOUR) {
|
||
// 小时视图:15分钟刻度对齐
|
||
const pixelPerMinute = 40 / 60
|
||
const pixelPer15Minutes = pixelPerMinute * 15
|
||
|
||
// 计算新的宽度,对齐到15分钟刻度
|
||
const newWidthRaw = Math.max(pixelPer15Minutes, resizeStartWidth.value + deltaX)
|
||
const newWidth = Math.round(newWidthRaw / pixelPer15Minutes) * pixelPer15Minutes
|
||
|
||
// 计算新的持续时间(分钟)
|
||
const newDurationMinutes = Math.round(newWidth / pixelPerMinute)
|
||
|
||
// 计算新的结束时间
|
||
const originalStartDate = createLocalDate(props.task.startDate) || props.startDate
|
||
const newEndDate = addMinutesToDate(originalStartDate, newDurationMinutes)
|
||
|
||
// 只更新临时数据,不触发事件
|
||
tempTaskData.value = {
|
||
startDate: props.task.startDate, // 保持原来的开始日期
|
||
endDate: formatDateToLocalString(newEndDate),
|
||
}
|
||
} else {
|
||
// 其他视图:保持原有逻辑
|
||
const newWidth = Math.max(props.dayWidth, resizeStartWidth.value + deltaX)
|
||
const newDurationDays = newWidth / props.dayWidth
|
||
const newEndDate = addDaysToLocalDate(
|
||
props.startDate,
|
||
resizeStartLeft.value / props.dayWidth + newDurationDays - 1,
|
||
)
|
||
|
||
// 只更新临时数据,不触发事件
|
||
tempTaskData.value = {
|
||
startDate: props.task.startDate, // 保持原来的开始日期
|
||
endDate: formatDateToLocalString(newEndDate),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleMouseUp = () => {
|
||
// 停止边界检测
|
||
window.dispatchEvent(
|
||
new CustomEvent('drag-boundary-check', {
|
||
detail: {
|
||
mouseX: 0,
|
||
isDragging: false,
|
||
},
|
||
}),
|
||
)
|
||
|
||
// 如果有临时数据,说明发生了拖拽或拉伸,提交数据更新
|
||
if (tempTaskData.value) {
|
||
const updatedTask = {
|
||
...props.task,
|
||
...tempTaskData.value,
|
||
}
|
||
|
||
// 判断是拖拽还是拉伸
|
||
if (isDragging.value) {
|
||
emit('drag-end', updatedTask)
|
||
} else if (isResizingLeft.value || isResizingRight.value) {
|
||
emit('resize-end', updatedTask)
|
||
}
|
||
emit('update:task', updatedTask)
|
||
|
||
// 清空临时数据
|
||
tempTaskData.value = null
|
||
|
||
// 下一帧报告新位置
|
||
nextTick(() => {
|
||
reportBarPosition()
|
||
})
|
||
}
|
||
|
||
// 清理自动滚动监听器
|
||
window.removeEventListener('timeline-auto-scroll', handleAutoScroll as EventListener)
|
||
|
||
isDragging.value = false
|
||
isResizingLeft.value = false
|
||
isResizingRight.value = false
|
||
document.removeEventListener('mousemove', handleMouseMove)
|
||
document.removeEventListener('mouseup', handleMouseUp)
|
||
}
|
||
|
||
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)
|
||
|
||
// 监听全局关闭菜单事件
|
||
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
|
||
|
||
// 清理函数
|
||
onUnmounted(() => {
|
||
window.removeEventListener('timeline-scale-updated', handleTimelineScaleUpdate)
|
||
window.removeEventListener('timeline-force-recalculate', handleForceRecalculate)
|
||
window.removeEventListener('close-all-taskbar-menus', closeContextMenu)
|
||
})
|
||
})
|
||
|
||
// 监听任务数据变化,重新报告位置
|
||
watch(
|
||
() => [props.task.startDate, props.task.endDate],
|
||
() => {
|
||
nextTick(() => {
|
||
reportBarPosition()
|
||
})
|
||
},
|
||
{ deep: true },
|
||
)
|
||
|
||
// 处理TaskBar双击事件
|
||
const handleTaskBarDoubleClick = (e: MouseEvent) => {
|
||
// 阻止事件冒泡,避免触发拖拽等其他事件
|
||
e.stopPropagation()
|
||
e.preventDefault()
|
||
|
||
// 清理任何可能残留的拖拽状态和临时数据
|
||
isDragging.value = false
|
||
isResizingLeft.value = false
|
||
isResizingRight.value = false
|
||
tempTaskData.value = null
|
||
|
||
// 移除可能残留的事件监听器
|
||
document.removeEventListener('mousemove', handleMouseMove)
|
||
document.removeEventListener('mouseup', handleMouseUp)
|
||
|
||
// 优先调用外部传入的双击处理器
|
||
if (props.onDoubleClick && typeof props.onDoubleClick === 'function') {
|
||
props.onDoubleClick(props.task)
|
||
} else {
|
||
// 默认行为:发出双击事件给父组件
|
||
emit('dblclick', props.task)
|
||
}
|
||
}
|
||
|
||
// 计算粘性样式 - 支持左右边界的精细控制
|
||
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 t('dateNotSet')
|
||
const date = createLocalDate(dateStr)
|
||
if (!date) return t('dateNotSet')
|
||
|
||
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,
|
||
}
|
||
})
|
||
|
||
// 判断是否应该显示进度百分比
|
||
const shouldShowProgress = computed(() => {
|
||
// 任何情况下都显示完成率(包括0%和undefined),确保始终展示
|
||
return true
|
||
})
|
||
|
||
// 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
|
||
}
|
||
|
||
// 年度视图位置计算函数
|
||
const calculateYearViewPosition = (targetDate: Date, baseStartDate: Date): number => {
|
||
const targetYear = targetDate.getFullYear()
|
||
const baseYear = baseStartDate.getFullYear()
|
||
|
||
// 每年的宽度是360px,每半年180px
|
||
const yearWidth = 360
|
||
const halfYearWidth = 180
|
||
|
||
// 计算目标年份相对于基准年份的偏移
|
||
const yearOffset = targetYear - baseYear
|
||
let position = yearOffset * yearWidth
|
||
|
||
// 判断是上半年还是下半年
|
||
const month = targetDate.getMonth() + 1 // getMonth()返回0-11,需要+1
|
||
|
||
if (month > 6) {
|
||
// 下半年,添加半年偏移
|
||
position += halfYearWidth
|
||
}
|
||
|
||
// 在半年内的具体位置计算
|
||
let dayOffset = 0
|
||
let startOfHalfYear: Date
|
||
|
||
if (month <= 6) {
|
||
// 上半年:1-6月
|
||
startOfHalfYear = new Date(targetYear, 0, 1) // 1月1日
|
||
} else {
|
||
// 下半年:7-12月
|
||
startOfHalfYear = new Date(targetYear, 6, 1) // 7月1日
|
||
}
|
||
|
||
dayOffset = Math.floor((targetDate.getTime() - startOfHalfYear.getTime()) / (1000 * 60 * 60 * 24))
|
||
|
||
// 半年大约181-184天,将天数映射到180px的宽度
|
||
const daysInHalfYear =
|
||
month <= 6
|
||
? Math.floor(
|
||
(new Date(targetYear, 6, 1).getTime() - new Date(targetYear, 0, 1).getTime()) /
|
||
(1000 * 60 * 60 * 24),
|
||
)
|
||
: Math.floor(
|
||
(new Date(targetYear + 1, 0, 1).getTime() - new Date(targetYear, 6, 1).getTime()) /
|
||
(1000 * 60 * 60 * 24),
|
||
)
|
||
|
||
const dayPositionInHalfYear = (dayOffset / daysInHalfYear) * halfYearWidth
|
||
position += dayPositionInHalfYear
|
||
|
||
return position
|
||
}
|
||
|
||
// 基于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.QUARTER) {
|
||
// 季度视图:处理quarters结构
|
||
const quarters = ((periodData as Record<string, unknown>).quarters as unknown[]) || []
|
||
|
||
for (const quarter of quarters) {
|
||
const quarterObj = quarter as Record<string, unknown>
|
||
const quarterStart = new Date(quarterObj.startDate as string)
|
||
const quarterEnd = new Date(quarterObj.endDate as string)
|
||
|
||
if (targetDate >= quarterStart && targetDate <= quarterEnd) {
|
||
// 找到目标日期所在的季度
|
||
const quarterWidth = 60
|
||
const daysInQuarter = Math.ceil(
|
||
(quarterEnd.getTime() - quarterStart.getTime()) / (1000 * 60 * 60 * 24),
|
||
)
|
||
const dayWidth = quarterWidth / daysInQuarter
|
||
const dayInQuarter = Math.ceil(
|
||
(targetDate.getTime() - quarterStart.getTime()) / (1000 * 60 * 60 * 24),
|
||
)
|
||
return cumulativePosition + dayInQuarter * dayWidth
|
||
}
|
||
|
||
// 累加每季度的宽度
|
||
cumulativePosition += 60
|
||
}
|
||
} else 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 // 如果没找到,返回累计位置
|
||
}
|
||
|
||
// 处理右键菜单
|
||
const contextMenuVisible = ref(false)
|
||
const contextMenuPosition = ref({ x: 0, y: 0 })
|
||
const contextMenuTask = computed(() => props.task)
|
||
|
||
function handleContextMenu(event: MouseEvent) {
|
||
// 先广播关闭所有TaskBar菜单
|
||
window.dispatchEvent(new CustomEvent('close-all-taskbar-menus'))
|
||
if (props.task.type !== 'task' && props.task.type !== 'story') {
|
||
// 为了排除里程碑类型
|
||
event.preventDefault()
|
||
contextMenuVisible.value = false
|
||
return
|
||
}
|
||
event.preventDefault()
|
||
contextMenuVisible.value = true
|
||
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
|
||
}
|
||
function closeContextMenu() {
|
||
contextMenuVisible.value = false
|
||
}
|
||
|
||
const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
|
||
// 触发删除事件
|
||
emit('delete', task, deleteChildren)
|
||
closeContextMenu()
|
||
}
|
||
|
||
// 监听全局关闭菜单事件
|
||
onMounted(() => {
|
||
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
|
||
})
|
||
onUnmounted(() => {
|
||
window.removeEventListener('close-all-taskbar-menus', closeContextMenu)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
ref="barRef"
|
||
class="task-bar"
|
||
:style="{
|
||
...taskBarStyle,
|
||
backgroundColor: taskStatus.bgColor,
|
||
borderColor: taskStatus.borderColor,
|
||
color: taskStatus.color,
|
||
cursor: isCompleted || isParent ? 'default' : 'move',
|
||
'--row-height': `${rowHeight}px` /* 传递行高给CSS变量 */,
|
||
}"
|
||
:class="{
|
||
dragging: isDragging,
|
||
resizing: isResizingLeft || isResizingRight,
|
||
completed: isCompleted,
|
||
'parent-task': isParent,
|
||
'week-view': isWeekView,
|
||
'short-task-bar': isShortTaskBar,
|
||
'overflow-effect': needsOverflowEffect,
|
||
}"
|
||
@contextmenu="handleContextMenu"
|
||
@dblclick="handleTaskBarDoubleClick"
|
||
>
|
||
<!-- 父级任务的标签 -->
|
||
<div v-if="isParent" class="parent-label">{{ task.name }} ({{ task.progress || 0 }}%)</div>
|
||
|
||
<!-- 完成进度条(非父级任务) -->
|
||
<div
|
||
v-if="!isParent && task.progress && task.progress > 0"
|
||
class="progress-bar"
|
||
:style="{
|
||
width: progressWidth,
|
||
backgroundColor: taskStatus.color,
|
||
}"
|
||
></div>
|
||
|
||
<!-- 左侧调整把手 -->
|
||
<div
|
||
v-if="!isCompleted && !isParent && !isInteractionDisabled"
|
||
class="resize-handle resize-handle-left"
|
||
@mousedown="e => handleMouseDown(e, 'resize-left')"
|
||
></div>
|
||
|
||
<!-- 任务条主体(非父级任务) -->
|
||
<div
|
||
v-if="!isParent"
|
||
class="task-bar-content"
|
||
:style="{ cursor: isInteractionDisabled ? 'default' : 'move' }"
|
||
@mousedown="e => (isInteractionDisabled ? null : handleMouseDown(e, 'drag'))"
|
||
>
|
||
<!-- 任务名称 -->
|
||
<div class="task-name" :style="getNameStyles()">
|
||
{{ task.name }}
|
||
</div>
|
||
|
||
<!-- 进度百分比 -->
|
||
<div v-if="shouldShowProgress" class="task-progress" :style="getProgressStyles()">
|
||
{{ task.progress || 0 }}%
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧调整把手 -->
|
||
<div
|
||
v-if="!isCompleted && !isParent && !isInteractionDisabled"
|
||
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>
|
||
|
||
<TaskContextMenu
|
||
:visible="contextMenuVisible"
|
||
:task="contextMenuTask"
|
||
:position="contextMenuPosition"
|
||
@close="closeContextMenu"
|
||
@start-timer="$emit('start-timer', props.task)"
|
||
@stop-timer="$emit('stop-timer', props.task)"
|
||
@add-predecessor="$emit('add-predecessor', props.task)"
|
||
@add-successor="$emit('add-successor', props.task)"
|
||
@delete="handleTaskDelete"
|
||
/>
|
||
</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"> {{ t('startDate') }}:</span>
|
||
<span class="tooltip-value">{{ formatDisplayDate(task.startDate) }}</span>
|
||
</div>
|
||
<div class="tooltip-row">
|
||
<span class="tooltip-label">{{ t('endDate') }}:</span>
|
||
<span class="tooltip-value">{{ formatDisplayDate(task.endDate) }}</span>
|
||
</div>
|
||
<div class="tooltip-row">
|
||
<span class="tooltip-label">{{ t('estimatedHours') }}:</span>
|
||
<span class="tooltip-value">{{ workHourInfo.total }}h</span>
|
||
</div>
|
||
<div class="tooltip-row">
|
||
<span class="tooltip-label"> {{ t('actualHours') }}:</span>
|
||
<span class="tooltip-value">{{ workHourInfo.used }}h</span>
|
||
</div>
|
||
<div class="tooltip-row">
|
||
<span class="tooltip-label"> {{ t('progress') }}:</span>
|
||
<span class="tooltip-value">{{ task.progress || 0 }}%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.task-bar {
|
||
position: absolute;
|
||
border-radius: 4px;
|
||
user-select: none;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||
transition: box-shadow 0.2s;
|
||
z-index: 100;
|
||
border: 2px solid;
|
||
overflow: visible; /* 允许内容超出 TaskBar */
|
||
}
|
||
|
||
.task-bar:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.task-bar.completed {
|
||
cursor: pointer !important;
|
||
}
|
||
|
||
.task-bar.completed:hover {
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.task-bar.dragging {
|
||
opacity: 0.8;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.task-bar.resizing {
|
||
z-index: 1000;
|
||
}
|
||
|
||
.task-bar.parent-task {
|
||
position: relative;
|
||
border-radius: 0; /* 移除圆角,使用线性设计 */
|
||
margin-bottom: 20px; /* 为标签和垂直线留出空间 */
|
||
height: 10px !important; /* 降低高度,让条更细 */
|
||
border: none; /* 移除边框 */
|
||
background: #409eff !important; /* 与新建按钮一致的蓝色 */
|
||
box-shadow: none; /* 移除阴影 */
|
||
top: 50% !important; /* 上下居中 */
|
||
transform: translateY(-50%); /* 上下居中 */
|
||
cursor: pointer !important; /* 允许双击编辑 */
|
||
overflow: visible; /* 确保伪元素可见 */
|
||
}
|
||
|
||
/* 父级任务的标签 */
|
||
.task-bar.parent-task .parent-label {
|
||
position: absolute;
|
||
top: -8px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: #409eff; /* 与新建按钮一致的蓝色 */
|
||
color: white;
|
||
padding: 4px 10px;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
z-index: 20;
|
||
}
|
||
|
||
/* 左侧向下箭头 - 更尖 */
|
||
.task-bar.parent-task::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 10px; /* 位于进度条下方 */
|
||
left: 0;
|
||
width: 0;
|
||
height: 0;
|
||
border-right: 6px solid transparent; /* 减小宽度,让箭头更尖 */
|
||
border-top: 10px solid #409eff; /* 与新建按钮一致的蓝色 */
|
||
z-index: 15;
|
||
}
|
||
|
||
/* 右侧向下箭头 - 更尖 */
|
||
.task-bar.parent-task::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 10px; /* 位于进度条下方 */
|
||
right: 0;
|
||
width: 0;
|
||
height: 0;
|
||
border-left: 6px solid transparent; /* 减小宽度,让箭头更尖 */
|
||
border-top: 10px solid #409eff; /* 与新建按钮一致的蓝色 */
|
||
z-index: 15;
|
||
}
|
||
|
||
.progress-bar {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
height: 100%;
|
||
opacity: 0.3;
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.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: 1;
|
||
}
|
||
|
||
.task-name {
|
||
white-space: nowrap;
|
||
overflow: visible;
|
||
line-height: 1.2;
|
||
font-size: 12px;
|
||
font-weight: 700; /* 加粗显示 */
|
||
z-index: 10;
|
||
/* 移除背景样式,保持原始状态 */
|
||
}
|
||
|
||
.task-progress {
|
||
opacity: 0.9;
|
||
font-size: 11px;
|
||
font-weight: 700; /* 加粗显示 */
|
||
z-index: 10;
|
||
/* 移除背景样式,保持原始状态 */
|
||
}
|
||
|
||
.resize-handle {
|
||
position: absolute;
|
||
top: 0;
|
||
width: 6px;
|
||
height: 100%;
|
||
cursor: ew-resize;
|
||
background: rgba(0, 0, 0, 0.1);
|
||
border-radius: 2px;
|
||
transition: background 0.2s;
|
||
z-index: 2;
|
||
}
|
||
|
||
.resize-handle:hover {
|
||
background: rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.resize-handle-left {
|
||
left: 0;
|
||
}
|
||
|
||
.resize-handle-right {
|
||
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;
|
||
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;
|
||
box-shadow:
|
||
0 4px 12px rgba(0, 0, 0, 0.7),
|
||
0 2px 4px rgba(0, 0, 0, 0.3) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .task-bar:hover {
|
||
box-shadow:
|
||
0 6px 20px rgba(0, 0, 0, 0.8),
|
||
0 4px 8px rgba(0, 0, 0, 0.4) !important;
|
||
transform: translateY(-2px);
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .task-bar:hover::after {
|
||
background: rgba(7, 10, 15, 0.98) !important;
|
||
color: #f9fafb !important;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .task-bar.normal {
|
||
background: linear-gradient(135deg, #1e40af, #1e3a8a) !important;
|
||
border-color: #1e3a8a !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .task-bar.milestone {
|
||
background: linear-gradient(135deg, #c2410c, #9a3412) !important;
|
||
border-color: #9a3412 !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .task-bar.completed {
|
||
background: linear-gradient(135deg, #14532d, #16a34a) !important;
|
||
border-color: #14532d !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .task-bar.delayed {
|
||
background: linear-gradient(135deg, #991b1b, #dc2626) !important;
|
||
border-color: #991b1b !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .task-bar.parent {
|
||
background: linear-gradient(135deg, #581c87, #7c3aed) !important;
|
||
border-color: #581c87 !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .task-content {
|
||
color: #ffffff !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .task-name {
|
||
color: #ffffff !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .progress-bar {
|
||
background: rgba(255, 255, 255, 0.2) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .progress-fill {
|
||
background: rgba(255, 255, 255, 0.8) !important;
|
||
}
|
||
|
||
:global(html[data-theme='dark']) .resize-handle {
|
||
background: rgba(255, 255, 255, 0.1) !important;
|
||
}
|
||
|
||
: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>
|