v1.3.0 - add Annual/Quarter/Hourly timeline views
This commit is contained in:
+694
-45
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 累加每月的宽度
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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; /* 确保箭头不会干扰鼠标事件 */
|
||||
}
|
||||
|
||||
|
||||
@@ -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') // 只显示story和task类型
|
||||
(task.type === 'story' || task.type === 'task'), // 只显示story和task类型
|
||||
)
|
||||
.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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user