v1.3.0 - add Annual/Quarter/Hourly timeline views

This commit is contained in:
LINING-PC\lining
2025-09-12 16:41:32 +08:00
parent bb0a00ad05
commit 281c2d9c5b
27 changed files with 4253 additions and 454 deletions
+694 -45
View File
@@ -1,22 +1,6 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from '../composables/useI18n'
const { t } = useI18n()
interface Props {
modelValue?: string | [string, string]
type?: 'date' | 'daterange'
placeholder?: string
startPlaceholder?: string
endPlaceholder?: string
disabled?: boolean
clearable?: boolean
size?: 'small' | 'default' | 'large'
format?: string
valueFormat?: string
rangeSeparator?: string
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
type: 'date',
@@ -38,14 +22,35 @@ const emit = defineEmits<{
blur: [event: FocusEvent]
}>()
const { t } = useI18n()
interface Props {
modelValue?: string | [string, string]
type?: 'date' | 'daterange'
placeholder?: string
startPlaceholder?: string
endPlaceholder?: string
disabled?: boolean
clearable?: boolean
size?: 'small' | 'default' | 'large'
format?: string
valueFormat?: string
rangeSeparator?: string
}
// 内部状态
const isFocused = ref(false)
const isHovered = ref(false)
const showPicker = ref(false)
const showYearPicker = ref(false)
const showMonthPicker = ref(false)
const showTimePicker = ref(false)
const inputRef = ref<HTMLElement>()
const pickerRef = ref<HTMLElement>()
const timePickerRef = ref<HTMLElement>()
const timeInputRef = ref<HTMLElement>()
const hourListRef = ref<HTMLElement>()
const minuteListRef = ref<HTMLElement>()
const blurTimer = ref<number | null>(null)
const positionUpdateKey = ref(0) // 用于强制重新计算位置
@@ -63,43 +68,107 @@ const startValue = ref('')
const endValue = ref('')
const rangeSelection = ref<'start' | 'end'>('start')
// 格式化日期显示
const formatDisplayDate = (dateStr: string) => {
// 时间选择器的值
const selectedTime = ref('12:00')
const tempHour = ref(12)
const tempMinute = ref(0)
// 格式化显示日期时间
const formatDisplayDateTime = (dateStr: string, timeStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
if (isNaN(date.getTime())) return ''
const dateFormat = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`
// 总是显示时间部分
if (timeStr) {
return `${dateFormat} ${timeStr}`
}
return dateFormat
}
// 显示值
const displayValue = computed(() => {
if (props.type === 'daterange') {
const start = startValue.value ? formatDisplayDate(startValue.value) : ''
const end = endValue.value ? formatDisplayDate(endValue.value) : ''
const start = startValue.value
? formatDisplayDateTime(startValue.value, selectedTime.value)
: ''
const end = endValue.value ? formatDisplayDateTime(endValue.value, selectedTime.value) : ''
if (start && end) {
return `${start} ${props.rangeSeparator} ${end}`
}
return start || end || ''
}
return singleValue.value ? formatDisplayDate(singleValue.value) : ''
return singleValue.value ? formatDisplayDateTime(singleValue.value, selectedTime.value) : ''
})
// 解析日期时间字符串
const parseDateTimeString = (dateTimeStr: string) => {
if (!dateTimeStr) return { dateStr: '', timeStr: '12:00' }
// 检查是否包含时间部分
const parts = dateTimeStr.trim().split(' ')
if (parts.length >= 2) {
// 包含时间部分
const dateStr = parts[0]
const timeStr = parts[1]
// 转换日期格式从 yyyy/MM/dd 到 yyyy-MM-dd
const dateParts = dateStr.split('/')
if (dateParts.length === 3) {
const formattedDate = `${dateParts[0]}-${dateParts[1].padStart(2, '0')}-${dateParts[2].padStart(2, '0')}`
return { dateStr: formattedDate, timeStr }
}
// 已经是 yyyy-MM-dd 格式,直接返回
return { dateStr, timeStr }
}
// 只有日期部分,或者已经是标准格式
const dateStr = parts[0]
if (dateStr.includes('/')) {
// 转换格式
const dateParts = dateStr.split('/')
if (dateParts.length === 3) {
const formattedDate = `${dateParts[0]}-${dateParts[1].padStart(2, '0')}-${dateParts[2].padStart(2, '0')}`
return { dateStr: formattedDate, timeStr: '12:00' }
}
}
return { dateStr, timeStr: '12:00' }
}
// 监听外部值变化
watch(
() => props.modelValue,
newValue => {
if (props.type === 'daterange') {
if (Array.isArray(newValue) && newValue.length === 2) {
startValue.value = newValue[0] || ''
endValue.value = newValue[1] || ''
const startParsed = parseDateTimeString(newValue[0] || '')
const endParsed = parseDateTimeString(newValue[1] || '')
startValue.value = startParsed.dateStr
endValue.value = endParsed.dateStr
// 如果有时间信息,使用第一个时间作为选择器的时间
if (startParsed.timeStr !== '12:00') {
selectedTime.value = startParsed.timeStr
}
} else {
startValue.value = ''
endValue.value = ''
selectedTime.value = '12:00'
}
} else {
singleValue.value = (newValue as string) || ''
const parsed = parseDateTimeString((newValue as string) || '')
singleValue.value = parsed.dateStr
selectedTime.value = parsed.timeStr
}
},
{ immediate: true }
{ immediate: true },
)
// 处理单日期输入变化(预留,当前版本不使用)
@@ -158,12 +227,22 @@ const togglePicker = () => {
// 设置当前年月为选中日期的年月
let currentDate: Date
if (props.type === 'daterange') {
currentDate = startValue.value ? new Date(startValue.value) : new Date()
// 对于日期范围,优先使用开始日期,如果没有则使用结束日期
const targetDateStr = startValue.value || endValue.value
currentDate = targetDateStr ? new Date(targetDateStr) : new Date()
} else {
currentDate = singleValue.value ? new Date(singleValue.value) : new Date()
}
currentYear.value = currentDate.getFullYear()
currentMonth.value = currentDate.getMonth()
// 确保日期有效
if (!isNaN(currentDate.getTime())) {
currentYear.value = currentDate.getFullYear()
currentMonth.value = currentDate.getMonth()
} else {
currentDate = new Date()
currentYear.value = currentDate.getFullYear()
currentMonth.value = currentDate.getMonth()
}
}
}
@@ -172,6 +251,7 @@ const closePicker = () => {
showPicker.value = false
showYearPicker.value = false
showMonthPicker.value = false
showTimePicker.value = false // 关闭日历时也关闭时间选择器
isFocused.value = false
// 清理失焦定时器
if (blurTimer.value) {
@@ -180,11 +260,143 @@ const closePicker = () => {
}
}
// 时间选择器相关函数
const openTimePicker = (event?: MouseEvent) => {
if (event) {
event.preventDefault()
event.stopPropagation()
}
if (showTimePicker.value) return
// 阻止失焦关闭日历选择器
if (blurTimer.value) {
clearTimeout(blurTimer.value)
blurTimer.value = null
}
showTimePicker.value = true
// 解析当前时间
const [hour, minute] = selectedTime.value.split(':').map(Number)
tempHour.value = hour
tempMinute.value = minute
// 下一帧滚动到选中的时间位置
nextTick(() => {
scrollToSelectedTime()
})
}
// 滚动到选中的时间位置
const scrollToSelectedTime = () => {
if (hourListRef.value) {
const hourItems = hourListRef.value.querySelectorAll('.el-time-item')
const selectedHourIndex = tempHour.value
if (hourItems[selectedHourIndex]) {
const itemHeight = 28 // el-time-item 的高度
const containerHeight = 160 // el-time-column-list 的高度
const scrollTop = Math.max(
0,
selectedHourIndex * itemHeight - containerHeight / 2 + itemHeight / 2,
)
hourListRef.value.scrollTop = scrollTop
}
}
if (minuteListRef.value) {
const minuteItems = minuteListRef.value.querySelectorAll('.el-time-item')
const minuteOptions = [0, 15, 30, 45]
const selectedMinuteIndex = minuteOptions.indexOf(tempMinute.value)
if (selectedMinuteIndex >= 0 && minuteItems[selectedMinuteIndex]) {
const itemHeight = 28
const containerHeight = 160
const scrollTop = Math.max(
0,
selectedMinuteIndex * itemHeight - containerHeight / 2 + itemHeight / 2,
)
minuteListRef.value.scrollTop = scrollTop
}
}
}
const closeTimePicker = () => {
showTimePicker.value = false
}
const confirmTime = () => {
selectedTime.value = `${String(tempHour.value).padStart(2, '0')}:${String(tempMinute.value).padStart(2, '0')}`
closeTimePicker()
}
// 生成小时选项(0-23
const hourOptions = computed(() => {
return Array.from({ length: 24 }, (_, i) => i)
})
// 生成分钟选项(15分间隔:0, 15, 30, 45
const minuteOptions = computed(() => {
return [0, 15, 30, 45]
})
// 滚动选择时间
const selectHour = (hour: number) => {
tempHour.value = hour
}
const selectMinute = (minute: number) => {
tempMinute.value = minute
}
// 确认日期选择
const confirmDate = () => {
if (props.type === 'daterange') {
if (startValue.value && endValue.value) {
// 格式化日期和时间
const formatDateTime = (dateStr: string) => {
const date = new Date(dateStr)
const dateFormat = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`
return selectedTime.value ? `${dateFormat} ${selectedTime.value}` : dateFormat
}
const startDateTime = formatDateTime(startValue.value)
const endDateTime = formatDateTime(endValue.value)
const newValue: [string, string] = [startDateTime, endDateTime]
emit('update:modelValue', newValue)
emit('change', newValue)
}
} else {
if (singleValue.value) {
// 格式化日期和时间
const date = new Date(singleValue.value)
const dateFormat = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`
const formattedDateTime = selectedTime.value
? `${dateFormat} ${selectedTime.value}`
: dateFormat
emit('update:modelValue', formattedDateTime)
emit('change', formattedDateTime)
}
}
// 确认后关闭面板
setTimeout(() => {
closePicker()
}, 150)
}
// 处理点击外部区域
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element
if (!inputRef.value?.contains(target) && !pickerRef.value?.contains(target)) {
const isInsideInput = inputRef.value?.contains(target)
const isInsidePicker = pickerRef.value?.contains(target)
const isInsideTimePicker = timePickerRef.value?.contains(target)
if (!isInsideInput && !isInsidePicker && !isInsideTimePicker) {
closePicker()
} else if (!isInsideTimePicker && showTimePicker.value && !isInsidePicker) {
// 如果点击了时间选择器外部但在日历选择器内部,只关闭时间选择器
closeTimePicker()
}
}
@@ -280,22 +492,11 @@ const selectDate = (dateStr: string) => {
endValue.value = dateStr
}
rangeSelection.value = 'start'
const newValue: [string, string] = [startValue.value, endValue.value]
emit('update:modelValue', newValue)
emit('change', newValue)
// 选择完成后自动关闭面板
setTimeout(() => {
closePicker()
}, 150)
// 不再自动提交,等待用户点击确认按钮
}
} else {
singleValue.value = dateStr
emit('update:modelValue', dateStr)
emit('change', dateStr)
// 单日期选择完成后自动关闭面板
setTimeout(() => {
closePicker()
}, 150)
// 不再自动提交,等待用户点击确认按钮
}
}
@@ -537,8 +738,26 @@ const handlePickerFocus = () => {
// 面板失去焦点时关闭
const handlePickerBlur = () => {
// 只有在时间选择器未打开时才关闭日历选择器
if (!showTimePicker.value) {
blurTimer.value = setTimeout(() => {
closePicker()
}, 150)
}
}
// 时间选择器获得焦点时取消关闭
const handleTimePickerFocus = () => {
if (blurTimer.value) {
clearTimeout(blurTimer.value)
blurTimer.value = null
}
}
// 时间选择器失去焦点时关闭
const handleTimePickerBlur = () => {
blurTimer.value = setTimeout(() => {
closePicker()
closeTimePicker()
}, 150)
}
@@ -637,6 +856,53 @@ const panelStyle = computed(() => {
return style
})
// 计算时间选择器位置
const timePickerStyle = computed(() => {
if (!timeInputRef.value || !showTimePicker.value) return {}
const rect = timeInputRef.value.getBoundingClientRect()
const panelWidth = 180 // 减小宽度从280到180
const panelHeight = 300
const spacing = 4
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const style: Record<string, string> = {
position: 'fixed',
zIndex: '10002', // 比日历选择器层级更高
}
// 水平位置:与时间输入框左对齐
const spaceRight = viewportWidth - rect.left
if (spaceRight >= panelWidth) {
// 左对齐
style.left = `${rect.left}px`
} else {
// 右对齐,确保不超出视窗
style.left = `${Math.max(spacing, viewportWidth - panelWidth - spacing)}px`
}
// 垂直位置:显示在输入框上方
const spaceAbove = rect.top
if (spaceAbove >= panelHeight + spacing) {
// 显示在上方
style.top = `${rect.top - panelHeight - spacing}px`
} else {
// 空间不足时显示在下方
style.top = `${rect.bottom + spacing}px`
}
// 确保面板完全在视窗内
const finalTop = parseFloat(style.top!)
if (finalTop < spacing) {
style.top = `${spacing}px`
} else if (finalTop + panelHeight > viewportHeight - spacing) {
style.top = `${viewportHeight - panelHeight - spacing}px`
}
return style
})
</script>
<template>
@@ -695,6 +961,80 @@ const panelStyle = computed(() => {
</div>
</div>
<!-- 时间选择器弹窗 -->
<Teleport to="body">
<Transition name="picker-fade">
<div
v-if="showTimePicker"
ref="timePickerRef"
class="el-time-picker-panel"
:style="timePickerStyle"
tabindex="-1"
@click.stop
@mousedown.prevent
@focus="handleTimePickerFocus"
@blur="handleTimePickerBlur"
>
<div class="el-time-picker-header">
<span class="el-time-picker-title">{{ t.selectTime }}</span>
</div>
<div class="el-time-picker-content">
<!-- 小时选择 -->
<div class="el-time-column">
<div class="el-time-column-header">{{ t.hour }}</div>
<div ref="hourListRef" class="el-time-column-list">
<div
v-for="hour in hourOptions"
:key="hour"
class="el-time-item"
:class="{ 'is-active': hour === tempHour }"
@click.stop="selectHour(hour)"
@mousedown.prevent
>
{{ String(hour).padStart(2, '0') }}
</div>
</div>
</div>
<!-- 分钟选择 -->
<div class="el-time-column">
<div class="el-time-column-header">{{ t.minute }}</div>
<div ref="minuteListRef" class="el-time-column-list">
<div
v-for="minute in minuteOptions"
:key="minute"
class="el-time-item"
:class="{ 'is-active': minute === tempMinute }"
@click.stop="selectMinute(minute)"
@mousedown.prevent
>
{{ String(minute).padStart(2, '0') }}
</div>
</div>
</div>
</div>
<div class="el-time-picker-footer">
<button
class="el-time-picker-btn el-time-picker-btn--cancel"
@click.stop="closeTimePicker"
@mousedown.prevent
>
{{ t.cancel }}
</button>
<button
class="el-time-picker-btn el-time-picker-btn--confirm"
@click.stop="confirmTime"
@mousedown.prevent
>
{{ t.confirm }}
</button>
</div>
</div>
</Transition>
</Teleport>
<!-- 日期选择器面板 -->
<Teleport to="body">
<Transition name="picker-fade">
@@ -814,6 +1154,32 @@ const panelStyle = computed(() => {
</div>
</div>
</div>
<!-- 时间选择器输入框 -->
<div class="el-time-picker-input">
<label class="el-time-picker-label">{{ t.time }}:</label>
<input
ref="timeInputRef"
type="text"
class="el-time-input"
:value="selectedTime"
:placeholder="t.selectTime"
readonly
@click="openTimePicker($event)"
@mousedown.prevent
/>
</div>
<!-- 日期选择器确认按钮 -->
<div class="el-date-picker-footer">
<button
class="el-date-picker-btn el-date-picker-btn--confirm"
@click.stop="confirmDate"
@mousedown.prevent
>
{{ t.confirm }}
</button>
</div>
</div>
</div>
</Transition>
@@ -1444,6 +1810,38 @@ const panelStyle = computed(() => {
background: var(--gantt-primary-light, #ecf5ff);
}
/* 日期选择器确认按钮样式 */
.el-date-picker-footer {
padding: 8px 0 0;
border-top: 1px solid var(--gantt-border-light, #ebeef5);
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
.el-date-picker-btn {
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid;
outline: none;
height: 24px;
line-height: 14px;
}
.el-date-picker-btn--confirm {
background: var(--gantt-primary, #409eff);
border-color: var(--gantt-primary, #409eff);
color: #ffffff;
}
.el-date-picker-btn--confirm:hover {
background: var(--gantt-primary-dark, #337ecc);
border-color: var(--gantt-primary-dark, #337ecc);
}
/* 暗黑模式下的日期选择器面板 */
:global(html[data-theme='dark']) .el-picker-panel {
background: var(--gantt-bg-secondary, #2c2c2c);
@@ -1488,6 +1886,11 @@ const panelStyle = computed(() => {
background: rgba(64, 158, 255, 0.2);
}
/* 暗黑模式下的日期选择器确认按钮 */
:global(html[data-theme='dark']) .el-date-picker-footer {
border-top-color: var(--gantt-border-dark, #414243);
}
/* 响应式适配 */
@media (max-width: 768px) {
.el-date-picker--large .el-input {
@@ -1548,4 +1951,250 @@ const panelStyle = computed(() => {
opacity: 1;
transform: translateY(0) scale(1);
}
/* 时间选择器样式 */
.el-time-picker-input {
padding: 8px 0;
border-top: 1px solid var(--gantt-border-light, #ebeef5);
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.el-time-picker-label {
font-size: 12px;
color: var(--gantt-text-regular, #909399);
font-weight: 500;
min-width: 30px;
}
.el-time-input {
flex: 1;
height: 28px;
padding: 0 8px;
border: 1px solid var(--gantt-border-color, #dcdfe6);
border-radius: 4px;
font-size: 12px;
color: var(--gantt-text-primary, #606266);
background: var(--gantt-bg-primary, #ffffff);
cursor: pointer;
transition: all 0.2s;
}
.el-time-input:hover {
border-color: var(--gantt-border-hover, #c0c4cc);
}
.el-time-input:focus {
outline: none;
border-color: var(--gantt-primary, #409eff);
}
.el-time-picker-panel {
position: fixed;
background: var(--gantt-bg-primary, #ffffff);
border: 1px solid var(--gantt-border-color, #e4e7ed);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10002;
width: 180px;
user-select: none;
padding: 8px;
}
.el-time-picker-header {
padding: 0 8px 8px;
border-bottom: 1px solid var(--gantt-border-light, #ebeef5);
margin-bottom: 8px;
text-align: center;
}
.el-time-picker-title {
font-size: 14px;
font-weight: 500;
color: var(--gantt-text-primary, #303133);
}
.el-time-picker-content {
padding: 0;
display: flex;
gap: 4px;
justify-content: center;
}
.el-time-column {
flex: 0 0 50px;
text-align: center;
}
.el-time-column-header {
font-size: 12px;
font-weight: 500;
color: var(--gantt-text-primary, #606266);
margin-bottom: 4px;
}
.el-time-column-list {
max-height: 160px;
overflow-y: auto;
border-radius: 4px;
/* 隐藏滚动条,但保持滚动功能 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.el-time-column-list::-webkit-scrollbar {
width: 0px;
background: transparent;
}
/* 鼠标悬停时显示滚动条 */
.el-time-column-list:hover {
scrollbar-width: thin;
}
.el-time-column-list:hover::-webkit-scrollbar {
width: 4px;
}
.el-time-column-list:hover::-webkit-scrollbar-track {
background: transparent;
}
.el-time-column-list:hover::-webkit-scrollbar-thumb {
background: var(--gantt-border-color, #dcdfe6);
border-radius: 2px;
}
.el-time-item {
height: 28px;
line-height: 28px;
cursor: pointer;
font-size: 12px;
color: var(--gantt-text-primary, #606266);
transition: all 0.2s;
}
.el-time-item:hover {
background: var(--gantt-bg-hover, #f5f7fa);
color: var(--gantt-primary, #409eff);
}
.el-time-item.is-active {
background: var(--gantt-primary, #409eff);
color: #ffffff;
font-weight: 500;
}
.el-time-picker-footer {
padding: 8px 0 0;
border-top: 1px solid var(--gantt-border-light, #ebeef5);
margin-top: 8px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.el-time-picker-btn {
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid;
outline: none;
height: 24px;
line-height: 14px;
}
.el-time-picker-btn--cancel {
background: var(--gantt-bg-primary, #ffffff);
border-color: var(--gantt-border-color, #dcdfe6);
color: var(--gantt-text-primary, #606266);
}
.el-time-picker-btn--cancel:hover {
background: var(--gantt-bg-hover, #f5f7fa);
border-color: var(--gantt-border-hover, #c0c4cc);
}
.el-time-picker-btn--confirm {
background: var(--gantt-primary, #409eff);
border-color: var(--gantt-primary, #409eff);
color: #ffffff;
}
.el-time-picker-btn--confirm:hover {
background: var(--gantt-primary-dark, #337ecc);
border-color: var(--gantt-primary-dark, #337ecc);
}
/* 暗黑模式下的时间选择器 */
:global(html[data-theme='dark']) .el-time-picker-input {
border-top-color: var(--gantt-border-dark, #414243);
}
:global(html[data-theme='dark']) .el-time-picker-label {
color: var(--gantt-text-secondary, #909399);
}
:global(html[data-theme='dark']) .el-time-input {
background: var(--gantt-bg-secondary, #2c2c2c);
border-color: var(--gantt-border-dark, #414243);
color: var(--gantt-text-white, #ffffff);
}
:global(html[data-theme='dark']) .el-time-input:hover {
border-color: var(--gantt-border-hover, #606266);
}
:global(html[data-theme='dark']) .el-time-picker-panel {
background: var(--gantt-bg-secondary, #2c2c2c);
border-color: var(--gantt-border-dark, #414243);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
:global(html[data-theme='dark']) .el-time-picker-header {
border-bottom-color: var(--gantt-border-dark, #414243);
}
:global(html[data-theme='dark']) .el-time-picker-title {
color: var(--gantt-text-white, #ffffff);
}
:global(html[data-theme='dark']) .el-time-column-header {
color: var(--gantt-text-white, #ffffff);
}
:global(html[data-theme='dark']) .el-time-column-list {
border-color: var(--gantt-border-dark, #414243);
}
:global(html[data-theme='dark']) .el-time-column-list:hover::-webkit-scrollbar-thumb {
background: var(--gantt-border-hover, #606266);
}
:global(html[data-theme='dark']) .el-time-item {
color: var(--gantt-text-white, #ffffff);
}
:global(html[data-theme='dark']) .el-time-item:hover {
background: var(--gantt-bg-hover-dark, #3c3e40);
}
:global(html[data-theme='dark']) .el-time-picker-footer {
border-top-color: var(--gantt-border-dark, #414243);
}
:global(html[data-theme='dark']) .el-time-picker-btn--cancel {
background: var(--gantt-bg-secondary, #2c2c2c);
border-color: var(--gantt-border-dark, #414243);
color: var(--gantt-text-white, #ffffff);
}
:global(html[data-theme='dark']) .el-time-picker-btn--cancel:hover {
background: var(--gantt-bg-hover-dark, #3c3e40);
border-color: var(--gantt-border-hover, #606266);
}
</style>
+16 -6
View File
@@ -36,6 +36,10 @@ const props = withDefaults(defineProps<Props>(), {
onThemeChange: undefined,
onFullscreenChange: undefined,
localeMessages: undefined,
workingHours: () => ({
morning: { start: 8, end: 11 },
afternoon: { start: 13, end: 17 },
}),
})
const emit = defineEmits([
@@ -97,6 +101,11 @@ interface Props {
* 仅在组件初始化时合并,运行时变更会自动响应。
*/
localeMessages?: Partial<import('../composables/useI18n').Messages['zh-CN']>
// 工作时间配置
workingHours?: {
morning?: { start: number; end: number } // 上午工作时间,如 { start: 8, end: 11 }
afternoon?: { start: number; end: number } // 下午工作时间,如 { start: 13, end: 17 }
}
}
const leftPanelWidth = ref(320)
@@ -249,7 +258,7 @@ const toggleTaskList = () => {
window.dispatchEvent(
new CustomEvent('timeline-container-resized', {
detail: { source: 'manual-task-list-toggle' },
})
}),
)
})
}, 400)
@@ -265,7 +274,7 @@ const handleToggleTaskList = (event: CustomEvent) => {
window.dispatchEvent(
new CustomEvent('timeline-container-resized', {
detail: { source: 'task-list-toggle' },
})
}),
)
})
}
@@ -298,7 +307,7 @@ const handleTaskCollapseChange = (task: Task) => {
const updateTaskCollapsedState = (
tasks: Task[],
targetId: number,
collapsed: boolean
collapsed: boolean,
): boolean => {
for (const t of tasks) {
if (t.id === targetId) {
@@ -369,7 +378,7 @@ watch(
notifyTaskListUpdated()
})
},
{ deep: true, immediate: true }
{ deep: true, immediate: true },
)
onMounted(() => {
@@ -407,7 +416,7 @@ onUnmounted(() => {
window.removeEventListener('task-added', handleTaskAdd as EventListener)
window.removeEventListener(
'milestone-icon-changed',
handleMilestoneIconChangeEvent as EventListener
handleMilestoneIconChangeEvent as EventListener,
)
window.removeEventListener('milestone-deleted', handleMilestoneDeleted as EventListener)
window.removeEventListener('milestone-data-changed', handleMilestoneDataChanged as EventListener)
@@ -1183,7 +1192,7 @@ watch(
val => {
if (val) setCustomMessages(locale.value, val)
},
{ deep: true }
{ deep: true },
)
// 右键菜单状态管理
@@ -1543,6 +1552,7 @@ function handleTaskDelete(task: Task, deleteChildren?: boolean) {
:milestones="props.milestones"
:start-date="timelineDateRange.min"
:end-date="timelineDateRange.max"
:working-hours="props.workingHours"
:on-task-double-click="props.onTaskDoubleClick"
:edit-component="props.editComponent"
:use-default-drawer="props.useDefaultDrawer"
+45 -26
View File
@@ -277,19 +277,52 @@ const handleTimeScaleChange = (scale: TimelineScale) => {
}
}
// 获取可用的时间刻度维度
const availableTimeScales = computed(() => {
const defaultScales = ['hour', 'day', 'week', 'month', 'year'] as const
return props.config?.timeScaleDimensions || defaultScales
})
// 时间刻度配置映射
const timeScaleMap = {
hour: { value: TimelineScale.HOUR, label: () => t('timeScaleHour') },
day: { value: TimelineScale.DAY, label: () => t('timeScaleDay') },
week: { value: TimelineScale.WEEK, label: () => t('timeScaleWeek') },
month: { value: TimelineScale.MONTH, label: () => t('timeScaleMonth') },
quarter: { value: TimelineScale.QUARTER, label: () => t('timeScaleQuarter') },
year: { value: TimelineScale.YEAR, label: () => t('timeScaleYear') },
}
// 获取当前时间刻度对应的字符串键
const currentTimeScaleKey = computed(() => {
const entry = Object.entries(timeScaleMap).find(
([, config]) => config.value === currentTimeScale.value,
)
return entry ? entry[0] : 'day'
})
// 计算分段控制器滑块位置
const getThumbStyle = () => {
const scaleIndex = {
[TimelineScale.MONTH]: 0,
[TimelineScale.WEEK]: 1,
[TimelineScale.DAY]: 2,
const currentIndex = availableTimeScales.value.findIndex(
scale => scale === currentTimeScaleKey.value,
)
const totalScales = availableTimeScales.value.length
if (totalScales === 0 || currentIndex < 0) {
return {
transform: 'translateX(0%)',
width: `${100 / totalScales || 25}%`,
}
}
const index = scaleIndex[currentTimeScale.value] || 0
const translateX = index * 100 // 每个选项占33.33%,所以移动100%的倍数
// 每个按钮占据的百分比宽度
const itemWidth = 100 / totalScales
// 滑块的位置(左移的距离)
const translateX = currentIndex * 100
return {
transform: `translateX(${translateX}%)`,
width: `${itemWidth}%`,
}
}
@@ -451,28 +484,14 @@ onUnmounted(() => {
<div class="segmented-thumb" :style="getThumbStyle()"></div>
</div>
<button
v-for="scale in availableTimeScales"
:key="scale"
class="segmented-item"
:class="{ active: currentTimeScale === 'month' }"
:class="{ active: currentTimeScaleKey === scale }"
:title="t('timeScaleTooltip')"
@click="handleTimeScaleChange(TimelineScale.MONTH)"
@click="handleTimeScaleChange(timeScaleMap[scale].value)"
>
{{ 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') }}
{{ timeScaleMap[scale].label() }}
</button>
</div>
<!-- 语言选择下拉菜单 -->
@@ -1175,7 +1194,7 @@ onUnmounted(() => {
position: absolute;
top: 0;
left: 0;
width: 33.333333%;
width: 25%; /* 默认宽度,将通过内联样式动态设置 */
height: 100%;
background: var(--gantt-primary, #409eff);
border-radius: 5px;
+214 -47
View File
@@ -1,8 +1,19 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
<script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue'
import type { Milestone } from '../models/classes/Milestone'
import { TimelineScale } from '../models/types/TimelineScale'
import { useI18n } from '../composables/useI18n'
import { createLocalDate } from '../utils/predecessorUtils'
const props = defineProps<Props>()
// 添加事件定义
const emit = defineEmits<{
'milestone-double-click': [milestone: Milestone]
'update:milestone': [milestone: Milestone] // 新增里程碑更新事件
'drag-end': [milestone: Milestone] // 新增
}>()
const { getTranslation } = useI18n()
const t = (key: string): string => {
@@ -30,27 +41,11 @@ interface Props {
priority: number // 推挤优先级
}> // 其他里程碑的位置信息
// 新增:时间线数据,用于精确计算subDays定位
timelineData?: Array<{
year: number
month: number
startDate: Date
endDate: Date
subDays?: Array<{ date: Date; dayOfWeek?: number }>
monthData?: { dayCount: number }
}>
timelineData?: unknown[]
// 新增:当前时间刻度
currentTimeScale?: TimelineScale
}
const props = defineProps<Props>()
// 添加事件定义
const emit = defineEmits<{
'milestone-double-click': [milestone: Milestone]
'update:milestone': [milestone: Milestone] // 新增里程碑更新事件
'drag-end': [milestone: Milestone] // 新增
}>()
// 拖拽相关状态
const isDragging = ref(false)
const dragStartX = ref(0)
@@ -157,7 +152,7 @@ const handleMouseMove = (e: MouseEvent) => {
mouseX: e.clientX,
isDragging: isDragging.value,
},
})
}),
)
const deltaX = e.clientX - dragStartX.value
@@ -184,7 +179,7 @@ const handleMouseUp = () => {
mouseX: 0,
isDragging: false,
},
})
}),
)
// 只有在真正拖拽了(有临时数据)且状态为拖拽中时才触发更新
@@ -246,19 +241,18 @@ const handleMilestoneClick = (e: MouseEvent) => {
scrollLeft: targetScrollLeft,
smooth: true,
},
})
}),
)
}
}
// 计算菱形位置 - 考虑拖拽临时数据
const milestoneStyle = computed(() => {
const milestoneDate = tempMilestoneData.value?.startDate
? new Date(tempMilestoneData.value.startDate)
: new Date(props.date)
const currentMilestoneDate = tempMilestoneData.value?.startDate || props.date
const milestoneDate = createLocalDate(currentMilestoneDate)
// 修正:props.startDate 可能为 undefined,需防御性处理
if (!props.startDate || isNaN(new Date(props.date).getTime())) {
// 修正:防御性处理日期和startDate
if (!props.startDate || !milestoneDate || isNaN(milestoneDate.getTime())) {
return {
left: '0px',
top: '0px',
@@ -268,25 +262,56 @@ const milestoneStyle = computed(() => {
}
let left = 0
const size = Math.min(props.rowHeight, props.dayWidth * 1.2, 24)
// 修复:根据不同时间刻度使用合适的图标大小
let size = 24 // 默认图标大小
// 优先使用基于timelineData的精确定位(适用于周视图和月视图)
if (
props.currentTimeScale === TimelineScale.YEAR ||
props.currentTimeScale === TimelineScale.QUARTER
) {
// 年度视图:使用固定大小,不依赖dayWidth
size = Math.min(props.rowHeight, 24)
} else if (props.currentTimeScale === TimelineScale.MONTH) {
// 月度视图:使用固定大小,不依赖dayWidth(因为dayWidth太小)
size = Math.min(props.rowHeight, 20)
} else if (props.currentTimeScale === TimelineScale.WEEK) {
// 周视图:可以稍微依赖dayWidth,但有合理范围
size = Math.min(props.rowHeight, Math.max(props.dayWidth * 0.8, 16), 24)
} else {
// 日视图:保持原有逻辑
size = Math.min(props.rowHeight, props.dayWidth * 1.2, 24)
}
// 年度视图:使用专门的年度位置计算
if (props.currentTimeScale === TimelineScale.YEAR) {
const centerPosition = calculateYearViewMilestonePosition(milestoneDate, props.startDate)
left = centerPosition - size / 2 // 从中心位置偏移到图标左上角
} else if (props.currentTimeScale === TimelineScale.QUARTER) {
// 季度视图:使用类似年度视图的简单计算方法
const centerPosition = calculateQuarterViewMilestonePosition(milestoneDate, props.startDate)
left = centerPosition - size / 2 // 从中心位置偏移到图标左上角
} else if (props.currentTimeScale === TimelineScale.HOUR) {
// 小时视图:精确到小时和分钟的定位
const centerPosition = calculateHourViewMilestonePosition(milestoneDate, props.startDate)
left = centerPosition - size / 2 // 从中心位置偏移到图标左上角
} else if (
props.timelineData &&
props.currentTimeScale &&
(props.currentTimeScale === TimelineScale.WEEK ||
props.currentTimeScale === TimelineScale.MONTH)
) {
// 优先使用基于timelineData的精确定位(适用于周视图和月视图)
const centerPosition = calculateMilestonePositionFromTimelineData(
milestoneDate,
props.timelineData,
props.currentTimeScale
props.currentTimeScale,
)
left = centerPosition - size / 2 // 从中心位置偏移到图标左上角
} else {
// 日视图:保持原有逻辑
const startDiff = Math.floor(
(milestoneDate.getTime() - props.startDate.getTime()) / (1000 * 60 * 60 * 24)
(milestoneDate.getTime() - props.startDate.getTime()) / (1000 * 60 * 60 * 24),
)
left = startDiff * props.dayWidth + props.dayWidth / 2 - size / 2
}
@@ -352,7 +377,7 @@ const milestoneVisibility = computed(() => {
if (iconRight <= leftBoundary + iconSize / 2) {
// 检查左侧是否有其他停靠的里程碑,需要判断推挤优先级
const leftStickyMilestones = otherMilestones.filter(
m => m.id !== currentId && m.stickyPosition === 'left' && m.isSticky
m => m.id !== currentId && m.stickyPosition === 'left' && m.isSticky,
)
// 如果有其他里程碑已经停靠在左侧,比较优先级决定推挤顺序
@@ -398,7 +423,7 @@ const milestoneVisibility = computed(() => {
if (iconLeft >= rightBoundary - iconSize / 2) {
// 检查右侧是否有其他停靠的里程碑,需要判断推挤优先级
const rightStickyMilestones = otherMilestones.filter(
m => m.id !== currentId && m.stickyPosition === 'right' && m.isSticky
m => m.id !== currentId && m.stickyPosition === 'right' && m.isSticky,
)
// 如果有其他里程碑已经停靠在右侧,比较优先级决定推挤顺序
@@ -517,24 +542,161 @@ onUnmounted(() => {
document.removeEventListener('mouseup', handleMouseUp)
})
// 年度视图里程碑位置计算函数
const calculateYearViewMilestonePosition = (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
}
// 小时视图里程碑位置计算 - 精确到小时和分钟
const calculateHourViewMilestonePosition = (targetDate: Date, baseStartDate: Date): number => {
// 计算基础天数差
const targetNormalized = new Date(
targetDate.getFullYear(),
targetDate.getMonth(),
targetDate.getDate(),
)
const baseNormalized = new Date(
baseStartDate.getFullYear(),
baseStartDate.getMonth(),
baseStartDate.getDate(),
)
const timeDiff = targetNormalized.getTime() - baseNormalized.getTime()
const daysDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24))
// 每天960px (24小时 * 40px)
const dayWidth = 960
const baseDayPosition = daysDiff * dayWidth
// 小时偏移:每小时40px
const currentHour = targetDate.getHours()
const hourOffset = currentHour * 40
// 分钟偏移:在当前小时内的精确位置
const currentMinute = targetDate.getMinutes()
const minuteOffset = (currentMinute / 60) * 40
const totalPosition = baseDayPosition + hourOffset + minuteOffset
return totalPosition
}
// 季度视图里程碑位置计算 - 参考年度视图的简单算法
const calculateQuarterViewMilestonePosition = (targetDate: Date, baseStartDate: Date): number => {
const targetYear = targetDate.getFullYear()
const baseYear = baseStartDate.getFullYear()
// 每年的宽度是240px (4季度 * 60px),每季度60px
const yearWidth = 240
const quarterWidth = 60
// 计算目标年份相对于基准年份的偏移
const yearOffset = targetYear - baseYear
let position = yearOffset * yearWidth
// 判断是哪个季度
const month = targetDate.getMonth() + 1 // getMonth()返回0-11,需要+1
let quarter = 1
if (month >= 1 && month <= 3) {
quarter = 1 // Q1: 1-3月
} else if (month >= 4 && month <= 6) {
quarter = 2 // Q2: 4-6月
} else if (month >= 7 && month <= 9) {
quarter = 3 // Q3: 7-9月
} else {
quarter = 4 // Q4: 10-12月
}
// 添加季度偏移 (Q1=0px, Q2=60px, Q3=120px, Q4=180px)
position += (quarter - 1) * quarterWidth
// 在季度内的具体位置计算
let dayOffset = 0
let startOfQuarter: Date
let endOfQuarter: Date
if (quarter === 1) {
startOfQuarter = new Date(targetYear, 0, 1) // 1月1日
endOfQuarter = new Date(targetYear, 2, 31) // 3月31日
} else if (quarter === 2) {
startOfQuarter = new Date(targetYear, 3, 1) // 4月1日
endOfQuarter = new Date(targetYear, 5, 30) // 6月30日
} else if (quarter === 3) {
startOfQuarter = new Date(targetYear, 6, 1) // 7月1日
endOfQuarter = new Date(targetYear, 8, 30) // 9月30日
} else {
startOfQuarter = new Date(targetYear, 9, 1) // 10月1日
endOfQuarter = new Date(targetYear, 11, 31) // 12月31日
}
dayOffset = Math.floor((targetDate.getTime() - startOfQuarter.getTime()) / (1000 * 60 * 60 * 24))
// 计算季度内的天数,将天数映射到60px的宽度
const endTime = endOfQuarter.getTime()
const startTime = startOfQuarter.getTime()
const daysInQuarter = Math.floor((endTime - startTime) / (1000 * 60 * 60 * 24)) + 1 // 包含结束日期
const dayPositionInQuarter = (dayOffset / daysInQuarter) * quarterWidth
position += dayPositionInQuarter
return position
}
// 基于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
timelineData: any,
timeScale: TimelineScale,
) => {
// 回退到原来的逻辑用于其他时间刻度
let cumulativePosition = 0
for (const periodData of timelineData) {
@@ -567,8 +729,11 @@ const calculateMilestonePositionFromTimelineData = (
}
// 如果没找到精确匹配,回退到dayOfWeek计算
// 注意:getDay()返回0=星期日,1=星期一...6=星期六
// 但subDays数组是从星期一开始:索引0=星期一,索引1=星期二...索引6=星期日
const dayOfWeek = targetDate.getDay()
return cumulativePosition + dayOfWeek * dayWidth + dayWidth / 2
const adjustedDayIndex = dayOfWeek === 0 ? 6 : dayOfWeek - 1 // 转换为subDays数组索引
return cumulativePosition + adjustedDayIndex * dayWidth + dayWidth / 2
}
// 累加每周的宽度
@@ -585,7 +750,9 @@ const calculateMilestonePositionFromTimelineData = (
const daysInMonth = periodData.monthData?.dayCount || 30
const dayWidth = monthWidth / daysInMonth
const dayInMonth = targetDate.getDate()
return cumulativePosition + (dayInMonth - 1) * dayWidth + dayWidth / 2
const finalPosition = cumulativePosition + (dayInMonth - 1) * dayWidth + dayWidth / 2
return finalPosition
}
// 累加每月的宽度
+2 -2
View File
@@ -41,7 +41,7 @@ const availableTasks = computed(() => {
task =>
task.type === 'task' &&
task.id !== props.currentTaskId &&
!selectedPredecessorIds.value.includes(task.id)
!selectedPredecessorIds.value.includes(task.id),
)
})
@@ -69,7 +69,7 @@ watch(
() => {
selectedValue.value = ''
},
{ immediate: true }
{ immediate: true },
)
</script>
+361 -85
View File
@@ -1,3 +1,4 @@
/* 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'
@@ -19,25 +20,13 @@ interface Props {
// 新增:外部控制半圆隐藏状态(用于Timeline初始化等场景)
hideBubbles?: boolean
// 新增:时间线数据,用于精确计算subDays定位
timelineData?: Array<{
year: number
month: number
startDate: Date
endDate: Date
subDays?: Array<{ date: Date; dayOfWeek?: number }>
monthData?: { dayCount: number }
}>
timelineData?: any
// 新增:当前时间刻度
currentTimeScale?: TimelineScale
}
const props = defineProps<Props>()
const { getTranslation } = useI18n()
const t = (key: string): string => {
return getTranslation(key)
}
const emit = defineEmits([
'update:task',
'bar-mounted',
@@ -50,7 +39,12 @@ const emit = defineEmits([
'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 => {
@@ -62,6 +56,13 @@ const createLocalDate = (dateString: string | Date | undefined | null): Date | n
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
}
@@ -75,6 +76,14 @@ 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}`
}
@@ -84,6 +93,23 @@ const addDaysToLocalDate = (date: Date, days: number): Date => {
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)
@@ -126,51 +152,125 @@ const taskBarStyle = computed(() => {
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,
)
// 小时视图:按分钟精确计算位置(需要考虑时间部分
if (props.currentTimeScale === TimelineScale.HOUR) {
// 确保 baseStart 是当天的 00:00:00
const baseStartOfDay = new Date(baseStart)
baseStartOfDay.setHours(0, 0, 0, 0)
// 如果结束日期+1天超出范围,使用结束日期的位置+一天的宽度
if (endPosition === startPosition) {
const dayWidth = props.currentTimeScale === TimelineScale.WEEK ? 60 / 7 : 60 / 30
endPosition =
calculatePositionFromTimelineData(endDate, props.timelineData, props.currentTimeScale) +
dayWidth
// 处理没有时间部分的日期字符串
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)
}
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
// 如果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)
}
left = startDiff * props.dayWidth
width = duration * props.dayWidth
// 计算从当天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 {
@@ -260,8 +360,8 @@ const needsOverflowEffect = computed(() => isWeekView.value && isShortTaskBar.va
// 鼠标事件处理 - 使用相对位置拖拽方案
const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-right') => {
// 如果已完成或是父级任务,禁用所有交互
if (isCompleted.value || props.isParent) {
// 如果已完成或是父级任务或年度视图,禁用所有交互
if (isCompleted.value || props.isParent || isInteractionDisabled.value) {
return
}
@@ -342,39 +442,132 @@ const handleMouseMove = (e: MouseEvent) => {
if (isDragging.value) {
const deltaX = e.clientX - dragStartX.value
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),
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
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, // 保持原来的结束日期
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
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),
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),
}
}
}
}
@@ -901,6 +1094,59 @@ const getProgressStyles = () => {
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,
@@ -922,7 +1168,32 @@ const calculatePositionFromTimelineData = (
let cumulativePosition = 0
for (const periodData of timelineData) {
if (timeScale === TimelineScale.WEEK) {
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 || []
@@ -1056,13 +1327,18 @@ onUnmounted(() => {
<!-- 左侧调整把手 -->
<div
v-if="!isCompleted && !isParent"
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" @mousedown="e => handleMouseDown(e, 'drag')">
<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 }}
@@ -1076,7 +1352,7 @@ onUnmounted(() => {
<!-- 右侧调整把手 -->
<div
v-if="!isCompleted && !isParent"
v-if="!isCompleted && !isParent && !isInteractionDisabled"
class="resize-handle resize-handle-right"
@mousedown="e => handleMouseDown(e, 'resize-right')"
></div>
@@ -1158,7 +1434,7 @@ onUnmounted(() => {
user-select: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: box-shadow 0.2s;
min-width: 60px;
/*min-width: 60px;*/
z-index: 100;
border: 2px solid;
overflow: visible; /* 允许内容超出 TaskBar */
+3 -3
View File
@@ -279,7 +279,7 @@ onUnmounted(() => {
:style="{
left: `${adjustedPosition.x}px`,
top: `${adjustedPosition.y}px`,
zIndex: 3000,
zIndex: 10000,
position: 'fixed',
}"
>
@@ -355,7 +355,7 @@ onUnmounted(() => {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
width: 180px; /* 调整固定宽度,确保文本不会被截断 */
z-index: 1000;
z-index: 10000; /* 确保在全屏模式(z-index: 9999)之上显示 */
user-select: none;
animation: fadeIn 0.15s ease-out;
border: 1px solid #e4e7ed;
@@ -520,7 +520,7 @@ onUnmounted(() => {
border-bottom: 8px solid #fff; /* 匹配菜单背景色 */
transform-origin: center;
filter: drop-shadow(0 -1px 2px rgba(0, 0, 0, 0.1)); /* 为箭头添加阴影效果 */
z-index: 1001;
z-index: 10001; /* 确保在全屏模式之上显示 */
pointer-events: none; /* 确保箭头不会干扰鼠标事件 */
}
+82 -15
View File
@@ -68,7 +68,7 @@ watch(
() => [props.task?.isTimerRunning, props.task?.timerStartTime, props.task?.timerElapsedTime],
() => {
updateTimer()
}
},
)
// UI
@@ -84,7 +84,7 @@ watch(
timerInterval.value = null
}
},
{ immediate: true }
{ immediate: true },
)
//
@@ -102,7 +102,7 @@ watch(
updateTimer()
}
},
{ immediate: true }
{ immediate: true },
)
onUnmounted(() => {
@@ -153,7 +153,7 @@ const availableParentTasks = computed(() => {
.filter(
task =>
task.id !== props.task?.id && //
(task.type === 'story' || task.type === 'task') // storytask
(task.type === 'story' || task.type === 'task'), // storytask
)
.map(task => ({
...task,
@@ -209,6 +209,38 @@ const handleProgressInputBlur = () => {
progressDisplayValue.value = progress.toString()
}
//
const handleEstimatedHoursInput = (event: Event) => {
const target = event.target as HTMLInputElement
let value = parseFloat(target.value)
//
if (isNaN(value) || value < 0) {
value = 0
} else if (value > 99999) {
value = 99999
}
//
formData.estimatedHours = Math.round(value * 100) / 100
}
//
const handleActualHoursInput = (event: Event) => {
const target = event.target as HTMLInputElement
let value = parseFloat(target.value)
//
if (isNaN(value) || value < 0) {
value = 0
} else if (value > 99999) {
value = 99999
}
//
formData.actualHours = Math.round(value * 100) / 100
}
//
const handleProgressInputFocus = (event: Event) => {
const target = event.target as HTMLInputElement
@@ -256,6 +288,31 @@ const handleProgressKeydown = (event: KeyboardEvent) => {
}
}
//
const processDateForHourView = (dateStr: string | undefined, type: 'start' | 'end'): string => {
if (!dateStr) return ''
// YYYY-MM-DD
if (typeof dateStr === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateStr.trim())) {
// 00:00
if (type === 'start') {
return `${dateStr} 00:00`
}
// 00:00
if (type === 'end') {
const date = new Date(dateStr)
date.setDate(date.getDate() + 1)
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} 00:00`
}
}
//
return dateStr
}
//
const errors = reactive({
name: '',
@@ -273,7 +330,13 @@ watch(
resetForm()
if (props.task && props.isEdit) {
//
Object.assign(formData, props.task)
const taskData = { ...props.task }
//
taskData.startDate = processDateForHourView(taskData.startDate, 'start')
taskData.endDate = processDateForHourView(taskData.endDate, 'end')
Object.assign(formData, taskData)
} else if (props.task && !props.isEdit) {
//
formData.parentId = props.task.parentId ?? undefined
@@ -283,7 +346,7 @@ watch(
//
window.dispatchEvent(new CustomEvent('request-task-list'))
}
}
},
)
// isVisible
@@ -300,7 +363,7 @@ watch(
formData.parentId = newTask.parentId ?? undefined
}
},
{ immediate: true }
{ immediate: true },
)
//
@@ -485,7 +548,7 @@ watch(
newValue => {
progressDisplayValue.value = (newValue || 0).toString()
},
{ immediate: true }
{ immediate: true },
)
// timerElapsed timerStartTime
@@ -503,7 +566,7 @@ watch(
}
}
},
{ immediate: true }
{ immediate: true },
)
const handleStartTimer = (desc?: string) => {
@@ -783,12 +846,14 @@ function confirmTimer(desc: string) {
<label class="form-label" for="task-estimated-hours">{{ t.estimatedHours }}</label>
<input
id="task-estimated-hours"
v-model.number="formData.estimatedHours"
v-model="formData.estimatedHours"
type="number"
class="form-input"
placeholder="0"
placeholder="0.00"
min="0"
max="999"
max="99999"
step="0.01"
@input="handleEstimatedHoursInput"
/>
</div>
@@ -796,12 +861,14 @@ function confirmTimer(desc: string) {
<label class="form-label" for="task-actual-hours">{{ t.actualHours }}</label>
<input
id="task-actual-hours"
v-model.number="formData.actualHours"
v-model="formData.actualHours"
type="number"
class="form-input"
placeholder="0"
placeholder="0.00"
min="0"
max="999"
max="99999"
step="0.01"
@input="handleActualHoursInput"
/>
</div>
</div>
+1508 -129
View File
File diff suppressed because it is too large Load Diff