@@ -89,7 +89,12 @@ watch(
-
+
+
@@ -1075,23 +1126,23 @@ const calculatePositionFromTimelineData = (
{{ task.name }}
- {{ t('startDate') }}:
+ 计划开始:
{{ formatDisplayDate(task.startDate) }}
- {{ t('endDate') }}:
+ 计划结束:
{{ formatDisplayDate(task.endDate) }}
- {{ t('estimatedHours') }}:
+ 计划工时:
{{ workHourInfo.total }}h
- {{ t('actualHours') }}:
+ 已用工时:
{{ workHourInfo.used }}h
- {{ t('progress') }}:
+ 完成率:
{{ task.progress || 0 }}%
diff --git a/src/components/TaskContextMenu.vue b/src/components/TaskContextMenu.vue
new file mode 100644
index 0000000..e76dca5
--- /dev/null
+++ b/src/components/TaskContextMenu.vue
@@ -0,0 +1,591 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/TaskDrawer.vue b/src/components/TaskDrawer.vue
index 8aa91c0..c5272f5 100644
--- a/src/components/TaskDrawer.vue
+++ b/src/components/TaskDrawer.vue
@@ -5,6 +5,7 @@ import { useMessage } from '../composables/useMessage'
import DatePicker from './DatePicker.vue'
import GanttConfirmDialog from './GanttConfirmDialog.vue'
import MultiSelectPredecessor from './MultiSelectPredecessor.vue'
+import ConfirmTimerDialog from './ConfirmTimerDialog.vue'
import type { Task } from '../models/classes/Task'
import '../styles/app.css'
@@ -27,6 +28,8 @@ const emit = defineEmits<{
submit: [task: Task]
close: []
delete: [task: Task, deleteChildren?: boolean]
+ 'start-timer': [task: Task]
+ 'stop-timer': [task: Task]
}>()
const { t } = useI18n()
@@ -36,6 +39,78 @@ const submitting = ref(false)
const isVisible = ref(props.visible)
const showDeleteConfirm = ref(false)
+// 计时器相关
+const timerElapsed = ref(0)
+const timerInterval = ref
(null)
+
+const isTimerRunning = computed(() => props.task?.isTimerRunning)
+const timerStartTime = computed(() => props.task?.timerStartTime)
+const timerElapsedTime = computed(() => props.task?.timerElapsedTime || 0)
+
+const formattedTimer = computed(() => {
+ const totalSeconds = Math.floor(timerElapsed.value / 1000)
+ const hours = Math.floor(totalSeconds / 3600)
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
+ const seconds = totalSeconds % 60
+ return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
+})
+
+const updateTimer = () => {
+ if (isTimerRunning.value && timerStartTime.value) {
+ timerElapsed.value = Date.now() - timerStartTime.value + timerElapsedTime.value
+ } else {
+ timerElapsed.value = timerElapsedTime.value
+ }
+}
+
+// 新增:监听 props.task 的 isTimerRunning、timerStartTime、timerElapsedTime 变化,确保 header 区域按钮和计时器展示实时同步
+watch(
+ () => [props.task?.isTimerRunning, props.task?.timerStartTime, props.task?.timerElapsedTime],
+ () => {
+ updateTimer()
+ }
+)
+
+// 计时器本地状态,保证点击后UI立即切换
+const localTimerRunning = ref(false)
+
+// 只要 props.task 变化或全局事件变化,立即同步本地状态
+watch(
+ () => props.task?.isTimerRunning,
+ val => {
+ localTimerRunning.value = !!val
+ if (!val && timerInterval.value) {
+ clearInterval(timerInterval.value)
+ timerInterval.value = null
+ }
+ },
+ { immediate: true }
+)
+
+// 修正计时器每秒递增逻辑,保证计时器正常跳动
+watch(
+ [localTimerRunning, timerStartTime, timerElapsedTime],
+ () => {
+ if (timerInterval.value) {
+ clearInterval(timerInterval.value)
+ timerInterval.value = null
+ }
+ if (localTimerRunning.value && timerStartTime.value) {
+ updateTimer()
+ timerInterval.value = window.setInterval(updateTimer, 1000)
+ } else {
+ updateTimer()
+ }
+ },
+ { immediate: true }
+)
+
+onUnmounted(() => {
+ if (timerInterval.value) {
+ clearInterval(timerInterval.value)
+ }
+})
+
// 根据任务类型确定dialog类型
const dialogType = computed(() => {
return props.task?.type === 'story' ? 'yes-no-cancel' : 'confirm-cancel'
@@ -63,6 +138,10 @@ const formData = reactive({
progress: 0,
description: '',
parentId: undefined, // 上级任务ID
+ isTimerRunning: false, // 是否正在计时
+ timerStartTime: undefined, // 计时器开始时间
+ timerEndTime: undefined, // 计时器结束时间
+ timerElapsedTime: 0, // 计时器已用时间
})
// 任务列表数据
@@ -74,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,
@@ -195,11 +274,16 @@ watch(
if (props.task && props.isEdit) {
// 编辑模式,填充表单数据
Object.assign(formData, props.task)
+ } else if (props.task && !props.isEdit) {
+ // 新建模式,自动绑定上级任务
+ formData.parentId = props.task.parentId ?? undefined
+ // 新建模式,自动绑定前置任务
+ formData.predecessor = props.task.predecessor ?? []
}
// 抽屉显示时重新请求任务数据,确保前置任务列表是最新的
window.dispatchEvent(new CustomEvent('request-task-list'))
}
- },
+ }
)
// 监听 isVisible 变化,同步到父组件
@@ -207,6 +291,18 @@ watch(isVisible, newVal => {
emit('update:visible', newVal)
})
+// 监听 task 变化,同步更新 parentId
+watch(
+ () => props.task,
+ newTask => {
+ if (newTask && !props.isEdit) {
+ // 新建时,确保 parentId 与传入的 parentId 同步
+ formData.parentId = newTask.parentId ?? undefined
+ }
+ },
+ { immediate: true }
+)
+
// 重置表单
const resetForm = () => {
Object.assign(formData, {
@@ -221,6 +317,10 @@ const resetForm = () => {
progress: 0,
description: '',
parentId: undefined,
+ isTimerRunning: false,
+ timerStartTime: undefined,
+ timerEndTime: undefined,
+ timerElapsedTime: 0,
})
// 清除错误信息
@@ -283,11 +383,9 @@ const handleSubmit = async () => {
id: props.isEdit && props.task ? props.task.id : Date.now(),
}
emit('submit', taskData)
- showMessage(props.isEdit ? t.value.taskUpdateSuccess : t.value.taskCreateSuccess, 'success')
handleClose()
} catch (error) {
// 处理错误但不在控制台输出
- showMessage(t.value.operationFailed, 'error')
} finally {
submitting.value = false
}
@@ -298,6 +396,21 @@ const handleDelete = () => {
showDeleteConfirm.value = true
}
+const handleError = (error: unknown) => {
+ let msg = ''
+ if (
+ error &&
+ typeof error === 'object' &&
+ 'message' in error &&
+ typeof (error as { message?: unknown }).message === 'string'
+ ) {
+ msg = (error as { message: string }).message
+ } else {
+ msg = String(error)
+ }
+ showMessage(msg, 'error', { closable: true })
+}
+
const confirmDelete = () => {
showDeleteConfirm.value = false
if (props.task && props.isEdit) {
@@ -306,7 +419,7 @@ const confirmDelete = () => {
emit('delete', props.task)
handleClose()
} catch (error) {
- showMessage(t.value.taskDeleteFailed, 'error')
+ handleError(error)
} finally {
submitting.value = false
}
@@ -326,14 +439,13 @@ const handleDeleteYes = () => {
emit('delete', props.task, true) // 传递true表示删除所有子任务
handleClose()
} catch (error) {
- showMessage(t.value.taskDeleteFailed, 'error')
+ handleError(error)
} finally {
submitting.value = false
}
}
}
-// Story删除:选择"否" - 仅删除story,保留子任务
// Story删除:选择"否" - 仅删除story,保留子任务
const handleDeleteNo = () => {
showDeleteConfirm.value = false
@@ -343,7 +455,7 @@ const handleDeleteNo = () => {
emit('delete', props.task, false) // 传递false表示仅删除story
handleClose()
} catch (error) {
- showMessage(t.value.taskDeleteFailed, 'error')
+ handleError(error)
} finally {
submitting.value = false
}
@@ -373,18 +485,193 @@ watch(
newValue => {
progressDisplayValue.value = (newValue || 0).toString()
},
- { immediate: true },
+ { immediate: true }
)
+
+// 修正计时器首次启动不跳动问题:每次打开抽屉时重置 timerElapsed,且 timerStartTime 为空时立即赋值
+watch(
+ () => props.visible,
+ visible => {
+ if (visible && props.task && props.task.type !== 'story') {
+ timerElapsed.value = props.task.timerElapsedTime || 0
+ // 若计时器未启动,重置本地interval
+ if (!props.task.isTimerRunning) {
+ if (timerInterval.value) {
+ clearInterval(timerInterval.value)
+ timerInterval.value = null
+ }
+ }
+ }
+ },
+ { immediate: true }
+)
+
+const handleStartTimer = (desc?: string) => {
+ const now = Date.now()
+ if (props.task && typeof props.task === 'object') {
+ Object.assign(props.task, {
+ timerStartTime: now,
+ isTimerRunning: true,
+ timerElapsedTime: 0, // 每次从0秒开始
+ timerStartDesc: desc || '',
+ })
+ emit('start-timer', props.task)
+ }
+ timerElapsed.value = 0 // 启动时重置本地计时器
+ localTimerRunning.value = true
+ updateTimer()
+ if (timerInterval.value) {
+ clearInterval(timerInterval.value)
+ timerInterval.value = null
+ }
+ window.dispatchEvent(new CustomEvent('start-timer', { detail: props.task }))
+}
+const handleStopTimer = () => {
+ if (props.task && typeof props.task === 'object') {
+ emit('stop-timer', props.task)
+ }
+ localTimerRunning.value = false
+ if (timerInterval.value) {
+ clearInterval(timerInterval.value)
+ timerInterval.value = null
+ }
+ updateTimer()
+ window.dispatchEvent(new CustomEvent('stop-timer', { detail: props.task }))
+}
+
+// 计时器确认弹窗
+const showTimerConfirm = ref(false)
+const timerDesc = ref('')
+
+function openTimerConfirm() {
+ timerDesc.value = props.task?.name || ''
+ showTimerConfirm.value = true
+}
+function cancelTimerConfirm() {
+ showTimerConfirm.value = false
+}
+function confirmTimer(desc: string) {
+ showTimerConfirm.value = false
+ // desc 可用于后续业务
+ handleStartTimer(desc)
+}
-
+
+
+
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
index b7bf082..5e6039f 100644
--- a/src/components/Timeline.vue
+++ b/src/components/Timeline.vue
@@ -2,7 +2,6 @@
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
import TaskBar from './TaskBar.vue'
import MilestonePoint from './MilestonePoint.vue'
-import TaskDrawer from './TaskDrawer.vue'
import MilestoneDialog from './MilestoneDialog.vue'
import { useI18n } from '../composables/useI18n'
import { getPredecessorIds } from '../utils/predecessorUtils'
@@ -47,6 +46,12 @@ const props = withDefaults(defineProps
(), {
// 定义emits
const emit = defineEmits<{
'timeline-scale-changed': [scale: TimelineScale]
+ 'edit-task': [task: Task]
+ 'start-timer': [task: Task]
+ 'stop-timer': [task: Task]
+ 'add-predecessor': [task: Task] // 新增:添加前置任务事件
+ 'add-successor': [task: Task] // 新增:添加后置任务事件
+ delete: [task: Task, deleteChildren?: boolean]
}>()
// 多语言
@@ -176,11 +181,6 @@ const getMonthTimelineRange = () => {
return { startDate, endDate }
}
-// 抽屉状态管理
-const drawerVisible = ref(false)
-const currentTask = ref(null)
-const isEditMode = ref(false)
-
// 里程碑对话框状态管理
const milestoneDialogVisible = ref(false)
const currentMilestone = ref(null)
@@ -861,44 +861,9 @@ const updateTask = (updatedTask: Task) => {
)
}
-// 处理TaskBar双击事件 - 支持API和默认行为
+// 处理TaskBar双击事件 - 只emit事件
const handleTaskBarDoubleClick = (task: Task) => {
- // 优先调用外部传入的双击处理器
- if (props.onTaskDoubleClick && typeof props.onTaskDoubleClick === 'function') {
- props.onTaskDoubleClick(task)
- } else if (props.useDefaultDrawer) {
- // 默认行为:打开内置的TaskDrawer
- currentTask.value = task
- isEditMode.value = true
- drawerVisible.value = true
- }
-
- // 如果有自定义编辑组件,也需要处理
- // 这里可以根据需要扩展
-}
-
-// 处理抽屉提交事件
-const handleDrawerSubmit = (task: Task) => {
- if (isEditMode.value) {
- // 编辑模式 - 更新现有任务
- updateTask(task)
- } else {
- // 新增模式 - 添加新任务,通过事件通知父组件
- window.dispatchEvent(
- new CustomEvent('task-added', {
- detail: task,
- }),
- )
- }
-
- // 关闭抽屉
- drawerVisible.value = false
-}
-
-// 处理抽屉关闭事件
-const handleDrawerClose = () => {
- currentTask.value = null
- isEditMode.value = false
+ emit('edit-task', task)
}
// 存储所有TaskBar的位置信息
@@ -946,6 +911,16 @@ const handleTaskBarResizeEnd = (updatedTask: Task) => {
window.dispatchEvent(new CustomEvent('taskbar-resize-end', { detail: updatedTask }))
}
+// 处理TaskBar右键菜单事件 - 将事件转发给父组件
+const handleTaskBarContextMenu = (event: { task: Task; position: { x: number; y: number } }) => {
+ // 将事件转发为全局事件,让GanttChart组件处理
+ window.dispatchEvent(
+ new CustomEvent('context-menu', {
+ detail: event,
+ }),
+ )
+}
+
// 处理TaskBar的滚动定位请求
const handleScrollToPosition = (targetScrollLeft: number) => {
if (timelineContainer.value) {
@@ -1386,31 +1361,8 @@ onUnmounted(() => {
document.removeEventListener('mouseup', handleMouseUp)
})
-const handleTaskDelete = (taskId: number, deleteChildren?: boolean) => {
- // 调用父组件传入的删除处理器
- if (props.onTaskDelete && typeof props.onTaskDelete === 'function') {
- const taskToDelete = tasks.value.find(task => task.id === taskId)
- if (taskToDelete) {
- props.onTaskDelete(taskToDelete, deleteChildren)
- }
- }
-
- // 触发全局事件,通知其他组件任务已删除
- window.dispatchEvent(
- new CustomEvent('task-deleted', {
- detail: taskId,
- }),
- )
-
- // 关闭抽屉
- drawerVisible.value = false
- currentTask.value = null
- isEditMode.value = false
-}
-
-// TaskDrawer删除事件适配器
-const handleDrawerTaskDelete = (task: Task, deleteChildren?: boolean) => {
- handleTaskDelete(task.id, deleteChildren)
+const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
+ emit('delete', task, deleteChildren)
}
// 月度视图中按年份分组的计算属性
@@ -1442,6 +1394,16 @@ defineExpose({
// 时间刻度更新
updateTimeScale,
})
+
+// 处理开始计时事件
+const handleStartTimer = (task: Task) => {
+ emit('start-timer', task)
+}
+// 处理停止计时事件
+const handleStopTimer = (task: Task) => {
+ emit('stop-timer', task)
+}
+
// Task类型转换成Milestone类型, 需要返回一个Milestone对象
const convertTaskToMilestone = (task: Task): Milestone => {
// 保证 startDate 一定为 string,避免 undefined
@@ -1627,6 +1589,16 @@ const generateMonthTimelineData = () => {
return result
}
+
+// 添加前置任务事件
+const handleAddPredecessor = (task: Task) => {
+ emit('add-predecessor', task)
+}
+
+// 添加后置任务事件
+const handleAddSuccessor = (task: Task) => {
+ emit('add-successor', task)
+}
@@ -1648,7 +1620,7 @@ const generateMonthTimelineData = () => {
class="timeline-year"
:style="{ width: '719px' }"
>
- {{ yearValue }}
+ {{ yearValue }}年
@@ -1913,22 +1885,18 @@ const generateMonthTimelineData = () => {
@drag-end="handleTaskBarDragEnd"
@resize-end="handleTaskBarResizeEnd"
@scroll-to-position="handleScrollToPosition"
+ @context-menu="handleTaskBarContextMenu"
+ @start-timer="handleStartTimer"
+ @stop-timer="handleStopTimer"
+ @add-predecessor="handleAddPredecessor"
+ @add-successor="handleAddSuccessor"
+ @delete="handleTaskDelete"
/>