Merge branch 'master' into my-feature-partial

This commit is contained in:
nelson.li
2025-08-12 21:01:02 +08:00
committed by GitHub
24 changed files with 2140 additions and 407 deletions
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { ref, watch, defineProps, defineEmits, computed } from 'vue'
import { useI18n } from '../composables/useI18n'
const props = defineProps({
visible: Boolean,
title: { type: String, default: '确认开始计时' },
message: { type: String, default: '' },
defaultDesc: { type: String, default: '' },
placeholder: { type: String, default: '请输入计时说明' },
})
const emit = defineEmits(['confirm', 'cancel'])
const { getTranslation: t } = useI18n()
const desc = ref(props.defaultDesc)
watch(
() => props.visible,
v => {
if (v) desc.value = props.defaultDesc
},
)
const onConfirm = () => emit('confirm', desc.value)
const messageTaskName = computed(() => {
if (props.message) {
// 支持多语言下的任务名提取
const match = props.message.match(/任务([\S\s]+?)计时|Task ([\S\s]+?) timing/)
if (match && (match[1] || match[2])) return match[1] || match[2]
}
return props.defaultDesc
})
</script>
<template>
<div class="confirm-timer-dialog-overlay">
<div class="confirm-timer-dialog">
<div class="dialog-message">
<span>{{ t('timerConfirmPrefix') }}</span>
<span class="task-name-highlight">{{ messageTaskName }}</span>
<span>{{ t('timerConfirmSuffix') }}</span>
</div>
<textarea
v-model="desc"
class="dialog-textarea"
:placeholder="t('timerConfirmPlaceholder')"
rows="3"
></textarea>
<div class="dialog-actions">
<button class="btn btn-default" @click="$emit('cancel')">{{ t('cancel') }}</button>
<button class="btn btn-confirm" @click="onConfirm">{{ t('startTimer') }}</button>
</div>
</div>
</div>
</template>
<style scoped>
.confirm-timer-dialog-overlay {
position: fixed;
z-index: 99999;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.25);
display: flex;
align-items: center;
justify-content: center;
}
.confirm-timer-dialog {
background: var(--gantt-bg-primary, #fff);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
padding: 24px 32px 18px 32px; /* 左右padding完全一致 */
min-width: 340px;
max-width: 90vw;
display: flex;
flex-direction: column;
gap: 16px;
}
:global(html[data-theme='dark']) .confirm-timer-dialog {
background: var(--gantt-bg-primary, #6b6b6b);
}
.dialog-message {
font-size: 14px;
margin-bottom: 4px;
line-height: 1.7;
}
.dialog-message .task-name-highlight {
color: #f44336;
font-size: 18px;
font-weight: bold;
margin: 0 2px;
display: inline-block;
}
.dialog-textarea {
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
padding: 8px 10px;
resize: vertical;
min-height: 60px;
margin-bottom: 8px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
}
.btn {
min-width: 96px;
padding: 10px 0;
border: none;
border-radius: 4px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
text-align: center;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-confirm {
background: #4caf50;
color: #fff;
}
.btn-confirm:hover {
background: #43a047;
}
</style>
+5 -11
View File
@@ -5,7 +5,6 @@ import { useI18n } from '../composables/useI18n'
const { t} = useI18n()
interface Props {
modelValue?: string | [string, string]
type?: 'date' | 'daterange'
@@ -72,9 +71,6 @@ const formatDisplayDate = (dateStr: string) => {
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// 显示值
const displayValue = computed(() => {
if (props.type === 'daterange') {
@@ -471,7 +467,6 @@ const yearList = computed(() => {
return years
})
/*
// 月份名称
const monthNames = [
'一月',
@@ -488,7 +483,6 @@ const monthNames = [
'十二月',
]
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
*/
// 生命周期
onMounted(() => {
@@ -745,7 +739,7 @@ const panelStyle = computed(() => {
&lt;&lt;
</button>
<span class="el-month-picker__header-label" @click="showYearSelector($event)">
{{ currentYear }}
{{ currentYear }}
</span>
<button type="button" class="el-picker-panel__icon-btn" @click="nextYear">
&gt;&gt;
@@ -753,7 +747,7 @@ const panelStyle = computed(() => {
</div>
<div class="el-month-picker__content">
<div
v-for="(monthName, index) in t.monthNames"
v-for="(monthName, index) in monthNames"
:key="index"
class="el-month-picker__item"
:class="{ 'is-current': index === currentMonth }"
@@ -775,10 +769,10 @@ const panelStyle = computed(() => {
</button>
<span class="el-date-picker__header-label">
<span class="el-date-picker__header-year" @click="showYearSelector($event)">
{{ currentYear }}
{{ currentYear }}
</span>
<span class="el-date-picker__header-month" @click="showMonthSelector($event)">
{{ t.monthNames[currentMonth] }}
{{ monthNames[currentMonth] }}
</span>
</span>
<button type="button" class="el-picker-panel__icon-btn" @click="nextMonth">
@@ -792,7 +786,7 @@ const panelStyle = computed(() => {
<div class="el-date-picker__content">
<!-- 星期标题 -->
<div class="el-date-table__header">
<div v-for="day in t.weekDays" :key="day" class="el-date-table__header-cell">
<div v-for="day in weekDays" :key="day" class="el-date-table__header-cell">
{{ day }}
</div>
</div>
+331 -10
View File
@@ -3,6 +3,7 @@ import { ref, onUnmounted, onMounted, computed, watch, nextTick, defineEmits } f
import TaskList from './TaskList.vue'
import Timeline from './Timeline.vue'
import GanttToolbar from './GanttToolbar.vue'
import TaskDrawer from './TaskDrawer.vue'
import { useI18n, setCustomMessages } from '../composables/useI18n'
import { formatPredecessorDisplay } from '../utils/predecessorUtils'
import jsPDF from 'jspdf'
@@ -37,7 +38,18 @@ const props = withDefaults(defineProps<Props>(), {
localeMessages: undefined,
})
const emit = defineEmits(['taskbar-drag-end', 'taskbar-resize-end', 'milestone-drag-end'])
const emit = defineEmits([
'taskbar-drag-end',
'taskbar-resize-end',
'milestone-drag-end',
'timer-started',
'timer-stopped',
'predecessor-added',
'successor-added',
'task-deleted',
'task-added',
'task-updated',
])
const { showMessage } = useMessage()
@@ -237,7 +249,7 @@ const toggleTaskList = () => {
window.dispatchEvent(
new CustomEvent('timeline-container-resized', {
detail: { source: 'manual-task-list-toggle' },
}),
})
)
})
}, 400)
@@ -253,7 +265,7 @@ const handleToggleTaskList = (event: CustomEvent) => {
window.dispatchEvent(
new CustomEvent('timeline-container-resized', {
detail: { source: 'task-list-toggle' },
}),
})
)
})
}
@@ -268,6 +280,7 @@ function handleTaskBarResizeEnd(event: CustomEvent) {
function handleMilestoneDragEnd(event: CustomEvent) {
emit('milestone-drag-end', event.detail)
}
onMounted(() => {
window.addEventListener('taskbar-drag-end', handleTaskBarDragEnd as EventListener)
window.addEventListener('taskbar-resize-end', handleTaskBarResizeEnd as EventListener)
@@ -285,7 +298,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) {
@@ -356,7 +369,7 @@ watch(
notifyTaskListUpdated()
})
},
{ deep: true, immediate: true },
{ deep: true, immediate: true }
)
onMounted(() => {
@@ -375,6 +388,8 @@ onMounted(() => {
window.addEventListener('request-task-list', handleRequestTaskList as EventListener)
// 监听窗口大小变化
window.addEventListener('resize', handleWindowResize)
// 监听TaskBar的右键菜单事件
window.addEventListener('context-menu', handleTaskContextMenu as EventListener)
nextTick(() => {
if (timelineRef.value && typeof timelineRef.value.scrollToTodayCenter === 'function') {
@@ -392,12 +407,13 @@ 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)
window.removeEventListener('request-task-list', handleRequestTaskList as EventListener)
window.removeEventListener('resize', handleWindowResize)
window.removeEventListener('context-menu', handleTaskContextMenu as EventListener)
})
// 全屏状态管理
@@ -1167,8 +1183,289 @@ watch(
val => {
if (val) setCustomMessages(locale.value, val)
},
{ deep: true },
{ deep: true }
)
// 右键菜单状态管理
const contextMenuPosition = ref({ x: 0, y: 0 })
const contextMenuVisible = ref(false)
const contextMenuTask = ref<Task | null>(null)
// TaskDrawer 相关变量
const taskDrawerVisible = ref(false)
const taskDrawerTask = ref<Task | null>(null)
const taskDrawerEditMode = ref(false)
// 添加前置任务功能相关变量
const taskToAddPredecessorTo = ref<Task | null>(null) // 要添加前置任务的目标任务
// 添加后置任务功能相关变量
const taskToAddSuccessorTo = ref<Task | null>(null) // 要添加后置任务的目标任务
// 处理任务条的右键菜单事件
const handleTaskContextMenu = (event: CustomEvent) => {
// 显示右键菜单
const { task, position } = event.detail
// 显示右键菜单
contextMenuTask.value = task
contextMenuPosition.value = position
contextMenuVisible.value = true
}
// 关闭右键菜单
const closeContextMenu = () => {
contextMenuVisible.value = false
}
// 工具栏新建任务事件处理
function handleToolbarAddTask() {
// 构造一个空的新任务对象
const newTask: Task = {
id: Date.now(), // 临时id,实际保存时应由后端分配
name: '',
type: 'task',
assignee: '',
startDate: '',
endDate: '',
predecessor: [],
estimatedHours: 0,
actualHours: 0,
progress: 0,
description: '',
parentId: undefined,
children: [],
}
taskDrawerTask.value = newTask
taskDrawerEditMode.value = false
taskDrawerVisible.value = true
}
// 监听TaskDrawer、TaskList、Timeline的计时事件,统一处理
const handleStartTimer = (task: Task) => {
// 任务树内状态同步
if (props.tasks) {
const updateTask = (tasks: Task[]): boolean => {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === task.id) {
tasks[i].isTimerRunning = true
tasks[i].timerStartTime = task.timerStartTime || Date.now()
tasks[i].timerEndTime = undefined
tasks[i].timerElapsedTime = 0
return true
}
if (tasks[i].children?.length) {
if (updateTask(tasks[i].children as Task[])) return true
}
}
return false
}
updateTask(props.tasks)
}
closeContextMenu()
emit('timer-started', task)
}
const handleStopTimer = (task: Task) => {
// 任务树内状态同步
if (props.tasks) {
const updateTask = (tasks: Task[]): boolean => {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === task.id) {
if (tasks[i].isTimerRunning && tasks[i].timerStartTime !== undefined) {
const elapsed = tasks[i].timerElapsedTime || 0
tasks[i].timerElapsedTime = elapsed + (Date.now() - tasks[i].timerStartTime!)
tasks[i].timerEndTime = Date.now()
}
tasks[i].isTimerRunning = false
if (
taskDrawerVisible.value &&
taskDrawerTask.value &&
taskDrawerTask.value.id === task.id
) {
taskDrawerTask.value.isTimerRunning = false
taskDrawerTask.value.timerEndTime = Date.now()
}
return true
}
if (tasks[i].children?.length) {
if (updateTask(tasks[i].children as Task[])) return true
}
}
return false
}
updateTask(props.tasks)
}
closeContextMenu()
emit('timer-stopped', task)
}
// 监听来自Timeline的任务编辑事件
function handleTimelineEditTask(task: Task) {
taskDrawerTask.value = task
taskDrawerEditMode.value = true
taskDrawerVisible.value = true
}
// 处理添加前置任务事件
function handleAddPredecessor(targetTask: Task) {
if (!targetTask) return
// 1. 记录要添加前置任务的目标任务
taskToAddPredecessorTo.value = targetTask
// 2. 打开TaskDrawer,进入新增模式
// 新建任务,parentId与目标任务一致
const newTask: Task = {
id: Date.now(), // 临时id,实际保存时应由后端分配
name: '',
type: 'task',
assignee: '',
startDate: '',
endDate: '',
predecessor: [],
estimatedHours: 0,
actualHours: 0,
progress: 0,
description: '',
parentId: targetTask.parentId,
children: [],
isTimerRunning: false,
timerStartTime: undefined,
timerEndTime: undefined,
timerElapsedTime: 0,
}
taskDrawerTask.value = newTask
taskDrawerEditMode.value = false
taskDrawerVisible.value = true
}
// 处理添加后置任务事件
function handleAddSuccessor(targetTask: Task) {
if (!targetTask) return
// 记录要添加后置任务的目标任务
taskToAddSuccessorTo.value = targetTask
// 构造新任务,parentId 与目标任务一致,predecessor 仅包含目标任务 id
const newTask: Task = {
id: Date.now(), // 临时id,实际保存时应由后端分配
name: '',
type: 'task',
assignee: '',
startDate: '',
endDate: '',
predecessor: [targetTask.id],
estimatedHours: 0,
actualHours: 0,
progress: 0,
description: '',
parentId: targetTask.parentId,
children: [],
isTimerRunning: false,
timerStartTime: undefined,
timerEndTime: undefined,
timerElapsedTime: 0,
}
taskDrawerTask.value = newTask
taskDrawerEditMode.value = false
taskDrawerVisible.value = true
}
// 新增Task插入到任务树中
// 插入新任务到任务树(parentId 已在打开 TaskDrawer 时预设好)
const insertTask = (tasks: Task[], newTask: Task) => {
if (!newTask.parentId) {
tasks.push(newTask)
return true
}
for (const t of tasks) {
if (t.id === newTask.parentId) {
if (!t.children) t.children = []
t.children.push(newTask)
return true
}
if (t.children && t.children.length > 0) {
if (insertTask(t.children, newTask)) return true
}
}
return false
}
// 编辑模式:递归查找并更新任务树节点
const updateTaskInTree = (tasks: Task[], updatedTask: Task): boolean => {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === updatedTask.id) {
tasks[i] = { ...tasks[i], ...updatedTask }
return true
}
if (tasks[i].children && (tasks[i].children as Task[]).length > 0) {
if (updateTaskInTree(tasks[i].children as Task[], updatedTask)) return true
}
}
return false
}
// 在 handleTaskDrawerSubmit 里补充:如果是添加前置任务,自动将新任务id加入目标任务的 predecessor
function handleTaskDrawerSubmit(task: Task) {
if (!taskDrawerEditMode.value) {
if (props.tasks) {
insertTask(props.tasks, task)
}
// emit 新增任务事件
emit('task-added', { task })
if (taskToAddPredecessorTo.value) {
if (!taskToAddPredecessorTo.value.predecessor) {
taskToAddPredecessorTo.value.predecessor = []
}
taskToAddPredecessorTo.value.predecessor.push(task.id)
// emit 添加前置任务事件
emit('predecessor-added', { targetTask: taskToAddPredecessorTo.value, newTask: task })
taskToAddPredecessorTo.value = null
}
if (taskToAddSuccessorTo.value) {
// emit 添加后置任务事件
emit('successor-added', { targetTask: taskToAddSuccessorTo.value, newTask: task })
taskToAddSuccessorTo.value = null
}
} else {
if (props.tasks) {
updateTaskInTree(props.tasks, task)
}
updateTaskTrigger.value++
// emit 任务更新事件
emit('task-updated', { task })
}
}
// 删除任务的递归工具函数,支持 deleteChildren 逻辑
function removeTaskFromTree(tasks: Task[], taskId: number, deleteChildren?: boolean): boolean {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === taskId) {
if (deleteChildren) {
// 递归删除该节点及所有子节点(直接 splice 即可)
tasks.splice(i, 1)
} else {
// 只删除该节点,把 children 提升到同级
const children = tasks[i].children || []
tasks.splice(i, 1, ...children)
}
return true
}
if (tasks[i].children && (tasks[i].children as Task[]).length > 0) {
if (removeTaskFromTree(tasks[i].children as Task[], taskId, deleteChildren)) return true
}
}
return false
}
// 处理 Task 的删除事件
function handleTaskDelete(task: Task, deleteChildren?: boolean) {
if (props.tasks) {
removeTaskFromTree(props.tasks, task.id, deleteChildren)
}
taskDrawerVisible.value = false
taskDrawerTask.value = null
// emit 删除事件
emit('task-deleted', { task })
}
</script>
<template>
@@ -1189,6 +1486,7 @@ watch(
:on-theme-change="props.onThemeChange"
:on-fullscreen-change="props.onFullscreenChange"
:on-time-scale-change="handleTimeScaleChange"
@add-task="handleToolbarAddTask"
/>
<!-- 甘特图主体 -->
@@ -1204,6 +1502,11 @@ watch(
:edit-component="props.editComponent"
:use-default-drawer="props.useDefaultDrawer"
@task-collapse-change="handleTaskCollapseChange"
@start-timer="handleStartTimer"
@stop-timer="handleStopTimer"
@add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor"
@delete="handleTaskDelete"
/>
</div>
<div class="gantt-splitter" @mousedown="onMouseDown">
@@ -1243,12 +1546,30 @@ watch(
:on-task-double-click="props.onTaskDoubleClick"
:edit-component="props.editComponent"
:use-default-drawer="props.useDefaultDrawer"
:on-task-delete="props.onTaskDelete"
:on-milestone-save="handleMilestoneSave"
@timeline-scale-changed="handleTimelineScaleChanged"
@edit-task="handleTimelineEditTask"
@start-timer="handleStartTimer"
@stop-timer="handleStopTimer"
@add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor"
@delete="handleTaskDelete"
/>
</div>
</div>
<!-- 任务抽屉组件 - 用于添加前置任务 -->
<TaskDrawer
v-if="props.useDefaultDrawer"
v-model:visible="taskDrawerVisible"
:task="taskDrawerTask"
:is-edit="taskDrawerEditMode"
@submit="handleTaskDrawerSubmit"
@close="taskDrawerVisible = false"
@start-timer="handleStartTimer"
@stop-timer="handleStopTimer"
@delete="handleTaskDelete"
/>
</div>
</template>
@@ -1535,8 +1856,8 @@ watch(
}
.gantt-root.splitter-dragging .gantt-panel-right {
/* 拖拽期间禁用Timeline区域的指针事件 */
pointer-events: none;
/* 拖拽时高亮右侧面板 */
background: rgba(255, 255, 255, 0.1);
}
.gantt-root.splitter-dragging * {
+8 -7
View File
@@ -485,26 +485,26 @@ const handleMilestoneMouseLeave = () => {
// 格式化日期显示
const formatDisplayDate = (dateStr: string): string => {
if (!dateStr) return t('dateNotSet') //Not Set
if (!dateStr) return '未设置'
try {
const date = new Date(dateStr)
if (isNaN(date.getTime())) return t('dateNotSet')
if (isNaN(date.getTime())) return '未设置'
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}`
} catch {
return t('dateNotSet')
return '未设置'
}
}
// Tooltip内容 Target date
// Tooltip内容
const tooltipContent = computed(() => {
const milestoneName = props.name || props.milestone?.name || t('milestone')
const milestoneName = props.name || props.milestone?.name || '里程碑'
const targetDate = formatDisplayDate(props.date || props.milestone?.startDate || '')
return `${t('milestone')}${milestoneName} <br> ${t('targetDate')}${targetDate}`
return `里程碑:${milestoneName} - 目标日期${targetDate}`
})
// 组件销毁时清理事件监听器
@@ -688,7 +688,8 @@ const calculateMilestonePositionFromTimelineData = (
top: `${tooltipPosition.y}px`,
}"
>
<div class="tooltip-content" v-html="tooltipContent">
<div class="tooltip-content">
{{ tooltipContent }}
</div>
</div>
</Teleport>
+9 -4
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,13 +69,13 @@ watch(
() => {
selectedValue.value = ''
},
{ immediate: true },
{ immediate: true }
)
</script>
<template>
<div class="multi-select-predecessor">
<label class="form-label">{{ label }}</label>
<label class="form-label" for="predecessor-select">{{ label }}</label>
<div class="predecessor-selector">
<!-- 已选择的前置任务标签 -->
<div v-if="selectedPredecessors.length > 0" class="selected-tags">
@@ -89,7 +89,12 @@ watch(
<!-- 下拉选择器 -->
<div class="select-wrapper">
<select v-model="selectedValue" class="form-select" @change="addPredecessor">
<select
id="predecessor-select"
v-model="selectedValue"
class="form-select"
@change="addPredecessor"
>
<option value="">{{ placeholder }}</option>
<option v-for="task in availableTasks" :key="task.id" :value="task.id">
{{ task.name }} (ID: {{ task.id }})
+69 -18
View File
@@ -2,10 +2,7 @@
import { ref, computed, onUnmounted, onMounted, nextTick, watch } from 'vue'
import type { Task } from '../models/classes/Task'
import { TimelineScale } from '../models/types/TimelineScale'
import { useI18n } from '../composables/useI18n'
import TaskContextMenu from './TaskContextMenu.vue'
interface Props {
task: Task
@@ -44,9 +41,14 @@ const emit = defineEmits([
'update:task',
'bar-mounted',
'dblclick',
'drag-end', // 新增
'resize-end', // 新增
'scroll-to-position', // 新增:半圆点击定位事件
'drag-end',
'resize-end',
'scroll-to-position',
'start-timer',
'stop-timer',
'add-predecessor',
'add-successor',
'delete',
])
// 日期工具函数 - 处理时区安全的日期创建和操作
@@ -234,9 +236,7 @@ const taskStatus = computed(() => {
})
// 判断是否已完成
const isCompleted = computed(() => {
return (props.task.progress || 0) >= 100
})
const isCompleted = computed(() => (props.task.progress || 0) >= 100)
// 计算完成部分的宽度
const progressWidth = computed(() => {
@@ -448,10 +448,14 @@ onMounted(() => {
window.addEventListener('timeline-scale-updated', handleTimelineScaleUpdate)
window.addEventListener('timeline-force-recalculate', handleForceRecalculate)
// 监听全局关闭菜单事件
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
// 清理函数
onUnmounted(() => {
window.removeEventListener('timeline-scale-updated', handleTimelineScaleUpdate)
window.removeEventListener('timeline-force-recalculate', handleForceRecalculate)
window.removeEventListener('close-all-taskbar-menus', closeContextMenu)
})
})
@@ -835,9 +839,9 @@ const handleBubbleMouseDown = (event: MouseEvent) => {
// 格式化日期显示
const formatDisplayDate = (dateStr: string | undefined): string => {
if (!dateStr) return t('dateNotSet')
if (!dateStr) return '未设置'
const date = createLocalDate(dateStr)
if (!date) return t('dateNotSet')
if (!date) return '未设置'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
@@ -975,7 +979,41 @@ const calculatePositionFromTimelineData = (
return cumulativePosition // 如果没找到,返回累计位置
}
// ...existing code...
// 处理右键菜单
const contextMenuVisible = ref(false)
const contextMenuPosition = ref({ x: 0, y: 0 })
const contextMenuTask = computed(() => props.task)
function handleContextMenu(event: MouseEvent) {
// 先广播关闭所有TaskBar菜单
window.dispatchEvent(new CustomEvent('close-all-taskbar-menus'))
if (props.task.type !== 'task' && props.task.type !== 'story') {
// 为了排除里程碑类型
event.preventDefault()
contextMenuVisible.value = false
return
}
event.preventDefault()
contextMenuVisible.value = true
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
}
function closeContextMenu() {
contextMenuVisible.value = false
}
const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
// 触发删除事件
emit('delete', task, deleteChildren)
closeContextMenu()
}
// 监听全局关闭菜单事件
onMounted(() => {
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
})
onUnmounted(() => {
window.removeEventListener('close-all-taskbar-menus', closeContextMenu)
})
</script>
<template>
@@ -999,6 +1037,7 @@ const calculatePositionFromTimelineData = (
'short-task-bar': isShortTaskBar,
'overflow-effect': needsOverflowEffect,
}"
@contextmenu="handleContextMenu"
@dblclick="handleTaskBarDoubleClick"
>
<!-- 父级任务的标签 -->
@@ -1059,6 +1098,18 @@ const calculatePositionFromTimelineData = (
@mousedown="handleBubbleMouseDown"
@click="handleBubbleClick"
></div>
<TaskContextMenu
:visible="contextMenuVisible"
:task="contextMenuTask"
:position="contextMenuPosition"
@close="closeContextMenu"
@start-timer="$emit('start-timer', props.task)"
@stop-timer="$emit('stop-timer', props.task)"
@add-predecessor="$emit('add-predecessor', props.task)"
@add-successor="$emit('add-successor', props.task)"
@delete="handleTaskDelete"
/>
</div>
<!-- Tooltip 弹窗 -->
@@ -1075,23 +1126,23 @@ const calculatePositionFromTimelineData = (
<div class="tooltip-title">{{ task.name }}</div>
<div class="tooltip-content">
<div class="tooltip-row">
<span class="tooltip-label"> {{ t('startDate') }}:</span>
<span class="tooltip-label">计划开始:</span>
<span class="tooltip-value">{{ formatDisplayDate(task.startDate) }}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">{{ t('endDate') }}:</span>
<span class="tooltip-label">计划结束:</span>
<span class="tooltip-value">{{ formatDisplayDate(task.endDate) }}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">{{ t('estimatedHours') }}:</span>
<span class="tooltip-label">计划工时:</span>
<span class="tooltip-value">{{ workHourInfo.total }}h</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label"> {{ t('actualHours') }}:</span>
<span class="tooltip-label">已用工时:</span>
<span class="tooltip-value">{{ workHourInfo.used }}h</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label"> {{ t('progress') }}:</span>
<span class="tooltip-label">完成率:</span>
<span class="tooltip-value">{{ task.progress || 0 }}%</span>
</div>
</div>
+591
View File
@@ -0,0 +1,591 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { useI18n } from '../composables/useI18n'
import type { Task } from '../models/classes/Task'
import ConfirmTimerDialog from './ConfirmTimerDialog.vue'
import GanttConfirmDialog from './GanttConfirmDialog.vue'
// 定义Props接口
interface Props {
visible: boolean
position: {
x: number
y: number
}
task: Task | null
}
const props = defineProps<Props>()
const emit = defineEmits([
'start-timer',
'stop-timer',
'add-predecessor',
'add-successor',
'delete', // 新增delete事件
'close',
])
// 多语言支持
const { t } = useI18n()
// 菜单容器ref
const menuRef = ref<HTMLElement | null>(null)
// 调整菜单位置,确保不会超出视窗
const adjustedPosition = ref({
x: 0,
y: 0,
})
// 箭头位置和方向
const arrowStyle = ref({
display: 'none', // 初始不显示
left: '50%',
top: '-8px',
transform: 'rotate(0deg)',
})
// 确认对话框相关
const showTimerConfirm = ref(false)
const timerDesc = ref('')
// 删除确认相关
const showDeleteConfirm = ref(false)
// 根据任务类型确定dialog类型
const dialogType = computed(() => {
return props.task?.type === 'story' ? 'yes-no-cancel' : 'confirm-cancel'
})
// 根据任务类型确定dialog消息
const dialogMessage = computed(() => {
if (props.task?.type === 'story') {
return t.value.confirmDeleteStory.replace('{name}', props.task?.name || '')
}
return t.value.confirmDeleteTask.replace('{name}', props.task?.name || '')
})
// 打开确认对话框
function openTimerConfirm() {
timerDesc.value = props.task?.name || ''
showTimerConfirm.value = true
}
// 取消确认
function cancelTimerConfirm() {
showTimerConfirm.value = false
}
// 确认计时
function confirmTimer(desc: string) {
showTimerConfirm.value = false
if (props.task && typeof props.task === 'object') {
emit('close')
// 记录当前时间为开始时间
const now = Date.now()
Object.assign(props.task, {
timerStartDesc: desc || '',
isTimerRunning: true,
timerStartTime: now,
timerElapsedTime: 0, // 每次从0秒开始
})
emit('start-timer', props.task)
}
}
// 处理删除任务点击
function handleDeleteClick() {
showDeleteConfirm.value = true
}
const confirmDelete = () => {
showDeleteConfirm.value = false
if (props.task) {
emit('delete', props.task)
emit('close')
}
}
const cancelDelete = () => {
showDeleteConfirm.value = false
}
// Story删除:选择"是" - 删除story及其所有子任务
const handleDeleteYes = () => {
showDeleteConfirm.value = false
if (props.task) {
// 传递true表示删除所有子任务
emit('delete', props.task, true) // 传递true表示删除所有子任务
closeMenu()
}
}
// Story删除:选择"否" - 仅删除story,保留子任务
const handleDeleteNo = () => {
showDeleteConfirm.value = false
if (props.task) {
// 传递false表示仅删除story
emit('delete', props.task, false) // 传递false表示仅删除story
closeMenu()
}
}
// 新增:是否显示计时菜单项(非story类型才显示)
const showTimerMenu = computed(() => props.task?.type !== 'story')
// 监听菜单可见状态和位置变化
watch(
[() => props.visible, () => props.position],
([visible, newPosition]) => {
// 仅在菜单可见且位置有效时调整位置
if (visible && newPosition) {
nextTick(() => {
if (menuRef.value) {
// 获取视窗和菜单尺寸
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const menuWidth = menuRef.value.offsetWidth
const menuHeight = menuRef.value.offsetHeight
// 初始位置:菜单位于鼠标指针下方,箭头指向上方
let adjustedX = newPosition.x - menuWidth / 2 // 居中对齐
let adjustedY = newPosition.y + 10 // 鼠标下方10px
let arrowLeft = '50%' // 箭头默认居中
let arrowTop = '-8px' // 箭头在菜单顶部
let arrowTransform = 'rotate(0deg)' // 箭头指向上方
// 检查右边界
if (adjustedX + menuWidth > viewportWidth) {
const overflow = adjustedX + menuWidth - viewportWidth + 5
adjustedX -= overflow
// 调整箭头位置
arrowLeft = `calc(50% + ${overflow}px)`
}
// 检查左边界
if (adjustedX < 5) {
const overflow = 5 - adjustedX
adjustedX = 5
// 调整箭头位置
arrowLeft = `calc(50% - ${overflow}px)`
}
// 检查下边界 - 如果超出,将菜单放在鼠标上方
if (adjustedY + menuHeight > viewportHeight) {
adjustedY = newPosition.y - menuHeight - 10
arrowTop = '100%' // 箭头在菜单底部
arrowTransform = 'rotate(180deg)' // 箭头指向下方
}
// 更新位置和箭头样式
adjustedPosition.value = {
x: adjustedX,
y: adjustedY,
}
arrowStyle.value = {
display: 'block',
left: arrowLeft,
top: arrowTop,
transform: arrowTransform,
}
}
})
}
},
{ immediate: true, deep: true },
)
// 关闭菜单的方法
const closeMenu = () => {
emit('close')
}
// 处理菜单项点击
const handleStartTimer = () => {
if (props.task?.isTimerRunning) {
emit('close')
// 统一格式,补充 timerEndTime 以便 formatTimerStopMessage 能正确显示周期
Object.assign(props.task, {
isTimerRunning: false,
timerEndTime: Date.now(),
timerElapsedTime:
(props.task.timerElapsedTime || 0) +
(Date.now() - (props.task.timerStartTime || Date.now())),
})
emit('stop-timer', props.task)
} else {
// 未计时,弹出确认弹窗
openTimerConfirm()
}
}
const handleAddPredecessor = () => {
if (props.task) {
// 先关闭菜单,再触发事件
emit('close')
nextTick(() => {
emit('add-predecessor', props.task)
})
}
}
const handleAddSuccessor = () => {
if (props.task) {
// 先关闭菜单,再触发事件
emit('close')
nextTick(() => {
emit('add-successor', props.task)
})
}
}
// 处理点击其他地方关闭菜单
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
closeMenu()
}
}
// 处理ESC键关闭菜单
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeMenu()
}
}
onMounted(() => {
// 添加全局点击和按键事件监听
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
// 移除事件监听
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
})
</script>
<template>
<teleport to="body">
<div
v-if="visible"
ref="menuRef"
class="task-context-menu"
:style="{
left: `${adjustedPosition.x}px`,
top: `${adjustedPosition.y}px`,
zIndex: 3000,
position: 'fixed',
}"
>
<!-- 气泡箭头 -->
<div
class="menu-arrow"
:style="{
display: arrowStyle.display,
left: arrowStyle.left,
top: arrowStyle.top,
transform: arrowStyle.transform,
}"
></div>
<div v-if="showTimerMenu" class="menu-item" @click="handleStartTimer">
<div class="icon-wrapper">
<i class="menu-icon" :class="props.task?.isTimerRunning ? 'stop-icon' : 'timer-icon'"></i>
</div>
{{ props.task?.isTimerRunning ? t.stopTimer : t.startTimer }}
</div>
<div class="menu-item" @click="handleAddPredecessor">
<div class="icon-wrapper">
<i class="menu-icon predecessor-icon"></i>
</div>
{{ t.addPredecessor }}
</div>
<div class="menu-item" @click="handleAddSuccessor">
<div class="icon-wrapper">
<i class="menu-icon successor-icon"></i>
</div>
{{ t.addSuccessor }}
</div>
<div class="menu-divider"></div>
<div class="menu-item menu-item-danger" @click="handleDeleteClick">
<div class="icon-wrapper">
<i class="menu-icon delete-icon"></i>
</div>
{{ t.delete }}
</div>
<!-- 删除确认弹窗 -->
<GanttConfirmDialog
:visible="showDeleteConfirm"
:title="t.delete"
:type="dialogType"
:message="dialogMessage"
:confirm-text="t.confirm"
:cancel-text="t.cancel"
:yes-text="t.storyDeleteYes"
:no-text="t.storyDeleteNo"
@confirm="confirmDelete"
@yes="handleDeleteYes"
@no="handleDeleteNo"
@cancel="cancelDelete"
/>
<!-- 确认计时对话框 -->
<ConfirmTimerDialog
v-if="showTimerConfirm"
:visible="showTimerConfirm"
:title="'确认开始计时'"
:message="`即将为任务${props.task?.name}计时,若有特殊说明请完善下面的描述`"
:default-desc="props.task?.name || ''"
@confirm="confirmTimer"
@cancel="cancelTimerConfirm"
/>
</div>
</teleport>
</template>
<style scoped>
.task-context-menu {
position: fixed;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
width: 180px; /* 调整固定宽度,确保文本不会被截断 */
z-index: 1000;
user-select: none;
animation: fadeIn 0.15s ease-out;
border: 1px solid #e4e7ed;
}
.menu-item {
padding: 6px 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: flex-start;
font-size: 14px;
transition: all 0.2s ease;
color: #333;
gap: 10px; /* 增加图标与文本之间的间距 */
height: 36px; /* 保持高度以适应32px的图标 */
}
.menu-item:hover {
background-color: #f5f7fa;
color: #409eff;
}
.icon-wrapper {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.menu-icon {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
flex-shrink: 0; /* 防止图标被压缩 */
border-radius: 2px;
overflow: visible;
}
/* 计时图标保持32px,前置后置任务图标使用20px */
.timer-icon,
.stop-icon {
width: 32px;
height: 32px;
}
.predecessor-icon,
.successor-icon {
width: 20px;
height: 20px;
}
.timer-icon::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
border: 2px solid currentColor;
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-sizing: border-box;
}
/* 右箭头样式 */
.timer-icon::after {
content: '';
position: absolute;
width: 0;
height: 0;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
border-left: 8px solid currentColor;
top: 50%;
left: 50%;
transform: translate(-30%, -50%);
}
.predecessor-icon::before {
content: '';
position: absolute;
width: 12px;
height: 2px;
background-color: currentColor;
top: 50%;
left: 50%;
transform: translate(-35%, -50%);
}
.predecessor-icon::after {
content: '';
position: absolute;
width: 5px;
height: 5px;
border-left: 2px solid currentColor;
border-bottom: 2px solid currentColor;
top: 50%;
left: 50%;
transform: translate(-120%, -50%) rotate(45deg);
}
.successor-icon::before {
content: '';
position: absolute;
width: 12px;
height: 2px;
background-color: currentColor;
top: 50%;
left: 50%;
transform: translate(-65%, -50%);
}
.successor-icon::after {
content: '';
position: absolute;
width: 5px;
height: 5px;
border-right: 2px solid currentColor;
border-top: 2px solid currentColor;
top: 50%;
left: 50%;
transform: translate(20%, -50%) rotate(45deg);
}
/* 停止计时图标 */
.stop-icon::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
border: 2px solid currentColor;
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-sizing: border-box;
}
.stop-icon::after {
content: '';
position: absolute;
width: 8px;
height: 8px;
background-color: currentColor;
border-radius: 0;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* 菜单气泡箭头 */
.menu-arrow {
position: absolute;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid #fff; /* 匹配菜单背景色 */
transform-origin: center;
filter: drop-shadow(0 -1px 2px rgba(0, 0, 0, 0.1)); /* 为箭头添加阴影效果 */
z-index: 1001;
pointer-events: none; /* 确保箭头不会干扰鼠标事件 */
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 暗色主题支持 */
:global(html[data-theme='dark']) .task-context-menu {
background-color: #2c2c2c;
border-color: #444444;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
}
:global(html[data-theme='dark']) .menu-item {
color: #e5e5e5;
}
:global(html[data-theme='dark']) .menu-item:hover {
background-color: #3a3a3a;
color: #409eff;
}
/* 暗色主题下箭头颜色 */
:global(html[data-theme='dark']) .menu-arrow {
border-bottom-color: #2c2c2c; /* 匹配暗色菜单背景色 */
filter: drop-shadow(0 -1px 2px rgba(0, 0, 0, 0.25));
}
.menu-item-danger {
color: #e74c3c;
}
.menu-item-danger:hover {
background-color: #faeaea;
color: #c0392b;
}
.menu-icon.delete-icon {
width: 20px;
height: 20px;
display: inline-block;
background: none;
position: relative;
}
.menu-icon.delete-icon::before {
content: '';
display: block;
width: 16px;
height: 16px;
margin: 2px auto;
background: url('data:image/svg+xml;utf8,<svg fill="%23e74c3c" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14z" stroke="%23e74c3c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>')
no-repeat center center;
background-size: contain;
}
.menu-divider {
height: 1px;
background: #ececec;
margin: 4px 0;
width: 92%;
margin-left: 4%;
}
</style>
+314 -20
View File
@@ -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<number | null>(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<Task>({
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)
}
</script>
<template>
<div v-if="isVisible" class="drawer-overlay" @click="handleOverlayClick">
<div class="drawer-container" @click.stop>
<!-- Drawer Header -->
<div class="drawer-header">
<h3 class="drawer-title">{{ isEdit ? t.editTask : t.newTask }}</h3>
<div
class="drawer-header"
style="display: flex; align-items: center; justify-content: flex-start; gap: 8px"
>
<h3 class="drawer-title" style="margin: 0">{{ isEdit ? t.editTask : t.newTask }}</h3>
<div
v-if="props.task?.type !== 'story' && isEdit"
class="drawer-timer"
style="display: flex; align-items: center; gap: 6px; margin-left: 8px"
>
<button
v-if="!localTimerRunning"
class="timer-btn start minimal"
title="开始计时"
style="
width: 24px;
height: 24px;
background: #4caf50;
border: none;
padding: 0;
margin: 0;
box-shadow: none;
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
"
@click.stop="openTimerConfirm"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
style="display: block; margin: 0 auto"
>
<circle cx="12" cy="12" r="11" stroke="#4caf50" stroke-width="2" fill="#4caf50" />
<polygon points="9,7 18,12 9,17" fill="#fff" />
</svg>
</button>
<button
v-else
class="timer-btn stop minimal"
title="停止计时"
style="
width: 24px;
height: 24px;
background: #f44336;
border: none;
padding: 0;
margin: 0;
box-shadow: none;
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
"
@click.stop="handleStopTimer"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" stroke="#f44336" stroke-width="2" fill="#f44336" />
<rect x="7" y="7" width="10" height="10" fill="#fff" rx="1.5" />
</svg>
</button>
<span
v-if="localTimerRunning"
class="timer-badge"
:class="{ 'timer-active': localTimerRunning }"
style="
margin-left: 8px;
font-size: 13px;
font-weight: 700;
padding: 2px 10px;
border-radius: 10px;
background: #fffbe6;
color: #e6a23c;
box-shadow: 0 0 0 1px #ffe58f;
display: inline-flex;
align-items: center;
min-width: 80px;
justify-content: center;
"
>
<span
v-if="localTimerRunning"
class="timer-dot"
style="
background: #67c23a;
width: 7px;
height: 7px;
border-radius: 50%;
margin-right: 5px;
animation: pulse 1s infinite;
"
></span>
{{ formattedTimer }}
</span>
</div>
<div style="flex: 1"></div>
<button class="drawer-close-btn" type="button" @click="handleClose">
<svg class="close-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<svg
class="close-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
style="vertical-align: middle"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
@@ -426,15 +713,15 @@ watch(
<span v-if="errors.type" class="error-text">{{ errors.type }}</span>
</div>
<div class="form-group">
<div class="form-group">
<label class="form-label" for="task-assignee">{{ t.assignee }}</label>
<select id="task-assignee" v-model="formData.assignee" class="form-select">
<option value="">{{ t.selectAssignee }}</option>
<!-- <option value="张三">张三</option>
<option value="">{{ t.selectAssignee }}</option>
<option value="张三">张三</option>
<option value="李四">李四</option>
<option value="王五">王五</option>
<option value="赵六">赵六</option>
<option value="钱七">钱七</option>-->
<option value="钱七">钱七</option>
</select>
</div>
@@ -606,6 +893,17 @@ watch(
</div>
</div>
</div>
<!-- 计时器确认弹窗 -->
<ConfirmTimerDialog
v-if="showTimerConfirm"
:visible="showTimerConfirm"
:title="'确认开始计时'"
:message="`即将为任务${props.task?.name}计时,若有特殊说明请完善下面的描述`"
:default-desc="props.task?.name || ''"
@confirm="confirmTimer"
@cancel="cancelTimerConfirm"
/>
</template>
<style scoped>
@@ -819,10 +1117,6 @@ watch(
background: var(--gantt-primary, #409eff);
}
.progress-slider::-moz-range-track {
background: var(--gantt-border-light, #e4e7ed);
}
/* 为更好的兼容性,添加一个伪元素来显示已完成部分 */
.progress-slider::before {
content: '';
+56 -1
View File
@@ -14,7 +14,18 @@ interface Props {
const props = defineProps<Props>()
// emit
const emit = defineEmits(['task-collapse-change'])
// const emit = defineEmits(['task-collapse-change', 'start-timer', 'stop-timer'])
// +'add-predecessor': [task: Task] //
// 'add-successor': [task: Task] //
const emit = defineEmits<{
'task-collapse-change': [task: Task]
'start-timer': [task: Task]
'stop-timer': [task: Task]
'add-predecessor': [task: Task] //
'add-successor': [task: Task] //
delete: [task: Task, deleteChildren?: boolean]
}>()
//
const { t } = useI18n()
@@ -300,6 +311,44 @@ const handleTimelineVerticalScroll = (event: CustomEvent) => {
}
}
//
const handleTaskRowContextMenu = (event: { task: Task; position: { x: number; y: number } }) => {
// TaskRowTaskList
// GanttChart
try {
window.dispatchEvent(
new CustomEvent('context-menu', {
detail: event,
}),
)
} catch (error) {
console.error('TaskList - Failed to dispatch context-menu event', error)
}
}
// TaskRow
const handleStartTimer = (task: Task) => {
emit('start-timer', task)
}
const handleStopTimer = (task: Task) => {
emit('stop-timer', task)
}
//
const handleAddPredecessor = (task: Task) => {
emit('add-predecessor', task)
}
//
const handleAddSuccessor = (task: Task) => {
emit('add-successor', task)
}
const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
//
emit('delete', task, deleteChildren)
}
onMounted(async () => {
window.addEventListener('task-updated', handleTaskUpdated as EventListener)
window.addEventListener('task-added', handleTaskAdded as EventListener)
@@ -354,6 +403,12 @@ onUnmounted(() => {
:on-hover="handleTaskRowHover"
@toggle="toggleCollapse"
@dblclick="handleTaskRowDoubleClick"
@contextmenu="handleTaskRowContextMenu"
@start-timer="handleStartTimer"
@stop-timer="handleStopTimer"
@add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor"
@delete="handleTaskDelete"
/>
</div>
</div>
+179 -2
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useI18n } from '../composables/useI18n'
import { formatPredecessorDisplay } from '../utils/predecessorUtils'
import type { Task } from '../models/classes/Task'
import TaskContextMenu from './TaskContextMenu.vue'
interface Props {
task: Task
@@ -13,7 +14,16 @@ interface Props {
onHover?: (taskId: number | null) => void
}
const props = defineProps<Props>()
const emit = defineEmits(['toggle', 'dblclick'])
const emit = defineEmits([
'toggle',
'dblclick',
'contextmenu',
'start-timer',
'stop-timer',
'add-predecessor',
'add-successor',
'delete',
])
const { t } = useI18n()
const overtimeText = computed(() => t.value?.overtime ?? '')
const overdueText = computed(() => t.value?.overdue ?? '')
@@ -129,15 +139,101 @@ const handleSplitterDragEnd = () => {
isSplitterDragging.value = false
}
//
const timerElapsed = ref(0)
const timerInterval = ref<number | null>(null)
// HH:MM:SS
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 (props.task.isTimerRunning && props.task.timerStartTime) {
// = - +
const previousElapsed = props.task.timerElapsedTime || 0
timerElapsed.value = Date.now() - props.task.timerStartTime + previousElapsed
} else if (props.task.timerElapsedTime) {
//
timerElapsed.value = props.task.timerElapsedTime
} else {
// 0
timerElapsed.value = 0
}
}
//
watch(
() => [props.task.isTimerRunning, props.task.timerStartTime, props.task.timerElapsedTime],
() => {
//
if (timerInterval.value) {
clearInterval(timerInterval.value)
timerInterval.value = null
}
//
if (props.task.isTimerRunning) {
updateTimer()
timerInterval.value = window.setInterval(updateTimer, 1000)
} else {
//
updateTimer()
}
},
{ immediate: true },
)
//
const contextMenuVisible = ref(false)
const contextMenuPosition = ref({ x: 0, y: 0 })
const contextMenuTask = computed(() => props.task)
//
function handleContextMenu(event: MouseEvent) {
// 广TaskRow
window.dispatchEvent(new CustomEvent('close-all-taskbar-menus'))
if (props.task.type !== 'task' && props.task.type !== 'story') {
//
event.preventDefault()
contextMenuVisible.value = false
return
}
event.preventDefault()
contextMenuVisible.value = true
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
}
//
function closeContextMenu() {
contextMenuVisible.value = false
}
const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
//
emit('delete', task, deleteChildren)
closeContextMenu()
}
// -
onMounted(() => {
window.addEventListener('splitter-drag-start', handleSplitterDragStart)
window.addEventListener('splitter-drag-end', handleSplitterDragEnd)
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
})
onUnmounted(() => {
window.removeEventListener('splitter-drag-start', handleSplitterDragStart)
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd)
window.removeEventListener('close-all-taskbar-menus', closeContextMenu)
if (timerInterval.value) {
clearInterval(timerInterval.value)
}
})
</script>
@@ -160,6 +256,7 @@ onUnmounted(() => {
@dblclick="handleTaskRowDoubleClick"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@contextmenu="handleContextMenu"
>
<div class="col col-name" :style="{ paddingLeft: indent }">
<span
@@ -245,6 +342,15 @@ onUnmounted(() => {
:title="props.task.name"
>
{{ props.task.name }}
<!-- 计时器显示 -->
<span
v-if="props.task.isTimerRunning || props.task.timerElapsedTime"
class="timer-badge"
:class="{ 'timer-active': props.task.isTimerRunning }"
>
<span v-if="props.task.isTimerRunning" class="timer-dot"></span>
{{ formattedTimer }}
</span>
<span v-if="isOvertime()" class="status-badge overtime">{{ overtimeText }}</span>
<span v-if="overdueDays() > 0" class="status-badge overdue">
{{ overdueText }}{{ overdueDays() > 0 ? overdueDays() + daysText : '' }}
@@ -299,8 +405,25 @@ onUnmounted(() => {
:on-hover="props.onHover"
@toggle="emit('toggle', $event)"
@dblclick="emit('dblclick', $event)"
@start-timer="emit('start-timer', $event)"
@stop-timer="emit('stop-timer', $event)"
@add-predecessor="emit('add-predecessor', $event)"
@add-successor="emit('add-successor', $event)"
@delete="handleTaskDelete"
/>
</template>
<TaskContextMenu
:visible="contextMenuVisible"
:task="contextMenuTask"
:position="contextMenuPosition"
@close="closeContextMenu"
@start-timer="$emit('start-timer', props.task)"
@stop-timer="$emit('stop-timer', props.task)"
@add-predecessor="$emit('add-predecessor', props.task)"
@add-successor="$emit('add-successor', props.task)"
@delete="handleTaskDelete"
/>
</div>
</template>
@@ -720,4 +843,58 @@ onUnmounted(() => {
0 6px 16px rgba(246, 124, 124, 0.4),
0 2px 8px rgba(255, 255, 255, 0.1);
}
/* 计时器样式 */
.timer-badge {
display: inline-flex;
align-items: center;
font-size: 12px;
font-weight: 700;
margin-left: 8px;
padding: 1px 6px;
border-radius: 10px;
background-color: rgba(0, 0, 0, 0.05);
color: var(--text-color-secondary);
}
.timer-badge.timer-active {
color: #e6a23c;
}
.timer-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #67c23a; /* 绿色 */
margin-right: 4px;
animation: pulse 1s infinite;
}
@keyframes pulse {
0% {
transform: scale(0.8);
opacity: 0.8;
}
50% {
transform: scale(1.2);
opacity: 1;
}
100% {
transform: scale(0.8);
opacity: 0.8;
}
}
:global(html[data-theme='dark']) .timer-badge {
background-color: rgba(255, 255, 255, 0.1);
color: var(--text-color-secondary-dark);
}
:global(html[data-theme='dark']) .timer-badge.timer-active {
color: #e6c07b;
}
:global(html[data-theme='dark']) .timer-dot {
background-color: #85ce61; /* 暗色主题下的绿色 */
}
</style>
+47 -79
View File
@@ -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<Props>(), {
// 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<Task | null>(null)
const isEditMode = ref(false)
//
const milestoneDialogVisible = ref(false)
const currentMilestone = ref<Milestone | null>(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)
}
// TaskMilestone, 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)
}
</script>
<template>
@@ -1648,7 +1620,7 @@ const generateMonthTimelineData = () => {
class="timeline-year"
:style="{ width: '719px' }"
>
<div class="year-label">{{ yearValue }}</div>
<div class="year-label">{{ yearValue }}</div>
</div>
</div>
@@ -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"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Task Drawer 抽屉组件 - 仅在使用默认Drawer时显示 -->
<TaskDrawer
v-if="props.useDefaultDrawer"
v-model:visible="drawerVisible"
:task="currentTask"
:is-edit="isEditMode"
@submit="handleDrawerSubmit"
@close="handleDrawerClose"
@delete="handleDrawerTaskDelete"
/>
<!-- Milestone Dialog 里程碑对话框组件 -->
<MilestoneDialog
v-model:visible="milestoneDialogVisible"
+1
View File
@@ -8,3 +8,4 @@ export { default as MilestonePoint } from './MilestonePoint.vue'
export { default as MilestoneDialog } from './MilestoneDialog.vue'
export { default as TaskDrawer } from './TaskDrawer.vue'
export { default as DatePicker } from './DatePicker.vue'
export { default as TaskContextMenu } from './TaskContextMenu.vue'