v1.2.0 - Add TaskContextMenu & new features; TaskDrawer is uniformly managed by GanttChart.

This commit is contained in:
LINING-PC\lining
2025-07-22 00:07:09 +08:00
parent 9cc769c622
commit 064eba945d
20 changed files with 2084 additions and 242 deletions

View File

@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.0] - 2025-07-21
### Added
- 增加TaskRow/TaskBar的右键菜单
- 增加Task的执行定时
- 增加添加前置任务功能
- 增加添加后置任务功能
- 增加TaskRow/TaskBar的删除功能
- GanttChart增加@predecessor-added(添加前置任务完成)事件
- GanttChart增加@successor-added(添加后置任务完成)事件
- GanttChart增加@task-deleted(删除任务完成)事件
- GanttChart增加@task-added(添加任务完成)事件
- GanttChart增加@task-updated(编辑任务完成)事件
### Changed
- 升级TaskDrawer统一在GanttChart中管理
## [1.1.0] - 2025-07-08
### Added

View File

@@ -55,12 +55,6 @@ pnpm add jordium-gantt-vue3
---
> 💡 **Badge Downloads**
> - npm version badgehttps://img.shields.io/npm/v/jordium-gantt-vue3.svg
> - MIT license badgehttps://img.shields.io/badge/license-MIT-blue.svg
> - Vue version badgehttps://img.shields.io/badge/vue-3.x-green.svg
> - TypeScript badgehttps://img.shields.io/badge/typescript-5.x-blue.svg
## 📁 Project Structure
```
@@ -116,11 +110,72 @@ jordium-gantt-vue3/
### GanttChart Events
| Event | Parameters | Description |
|-------|------------|-------------|
| Event | Parameters | Description |
|--------------------|----------------------------|------------------------------------|
| `taskbar-drag-end` | `task: Task` | Task bar drag end |
| `taskbar-resize-end` | `task: Task` | Task bar resize end |
| `milestone-drag-end` | `milestone: Task` | Milestone drag end |
| `predecessor-added`| `{ targetTask, newTask }` | Triggered after adding predecessor.<br>Parameters:<br>`targetTask`: The task to which a predecessor was added (Task object)<br>`newTask`: The newly added predecessor task (Task object) |
| `successor-added` | `{ targetTask, newTask }` | Triggered after adding successor.<br>Parameters:<br>`targetTask`: The task to which a successor was added (Task object)<br>`newTask`: The newly added successor task (Task object) |
| `task-deleted` | `{ task }` | Triggered after deleting a task |
| `task-added` | `{ task }` | Triggered after creating a task |
| `task-updated` | `{ task }` | Triggered after updating a task |
#### Timer Event Usage Example
```vue
<GanttChart
...
@timer-started="onTimerStarted"
@timer-stopped="onTimerStopped"
/>
<script setup>
function onTimerStarted(task) {
// Custom notification, logging, or business logic
alert(`Task [${task.name}] started at: ${new Date(task.timerStartTime).toLocaleString()}`)
}
function onTimerStopped(task) {
alert(`Task [${task.name}] stopped`)
}
</script>
```
#### Task Event Usage Example
```vue
<GanttChart
...
@predecessor-added="onPredecessorAdded"
@successor-added="onSuccessorAdded"
@task-deleted="onTaskDeleted"
@task-added="onTaskAdded"
@task-updated="onTaskUpdated"
/>
<script setup>
function onPredecessorAdded(e) {
// e: { targetTask: Task, newTask: Task }
alert(`Task [${e.targetTask.name}] predecessor added [${e.newTask.name}]`)
}
function onSuccessorAdded(e) {
// e: { targetTask: Task, newTask: Task }
alert(`Task [${e.targetTask.name}] successor added [${e.newTask.name}]`)
}
function onTaskDeleted(e) {
// e: { task: Task }
alert(`Task [${e.task.name}] deleted`)
}
function onTaskAdded(e) {
// e: { task: Task }
alert(`Task [${e.task.name}] created`)
}
function onTaskUpdated(e) {
// e: { task: Task }
alert(`Task [${e.task.name}] updated`)
}
</script>
```
### Data Types
@@ -128,24 +183,30 @@ jordium-gantt-vue3/
**Task Type**
```typescript
interface Task {
id: number // Unique task identifier
name: string // Task name
predecessor?: string // Predecessor task ID
assignee?: string // Assignee
startDate?: string // Start date (YYYY-MM-DD format)
endDate?: string // End date (YYYY-MM-DD format)
progress?: number // Completion progress (0-100)
estimatedHours?: number // Estimated hours
actualHours?: number // Actual hours
parentId?: number // Parent task ID
children?: Task[] // Child tasks array (supports nested structure)
collapsed?: boolean // Whether child tasks are collapsed
isParent?: boolean // Whether it's a parent task
type?: string // Task type (task/story/bug/milestone)
description?: string // Task description
icon?: string // Task icon
level?: number // Task level
export interface Task {
id: number // Unique task ID
name: string // Task name
predecessor?: number[] // Predecessor task ID array
assignee?: string // Assignee
startDate?: string // Start date (ISO string)
endDate?: string // End date (ISO string)
progress?: number // Progress percentage 0-100
estimatedHours?: number // Estimated hours
actualHours?: number // Actual hours
parentId?: number // Parent task ID
children?: Task[] // Subtask array
collapsed?: boolean // Collapsed state
isParent?: boolean // Is parent task
type?: string // Task type (e.g. task, story, milestone)
description?: string // Task description
icon?: string // Icon
level?: number // Level
// Timer related fields
isTimerRunning?: boolean // Is timer running
timerStartTime?: number // Timer start timestamp
timerEndTime?: number // Timer end timestamp
timerStartDesc?: string // Timer start description
timerElapsedTime?: number // Accumulated timer duration (seconds)
}
```

152
README.md
View File

@@ -56,36 +56,33 @@ pnpm add jordium-gantt-vue3
---
> 💡 **徽章下载**
> - npm 版本徽章https://img.shields.io/npm/v/jordium-gantt-vue3.svg
> - MIT 许可证徽章https://img.shields.io/badge/license-MIT-blue.svg
> - Vue 版本徽章https://img.shields.io/badge/vue-3.x-green.svg
> - TypeScript 徽章https://img.shields.io/badge/typescript-5.x-blue.svg
## 📁 项目结构
```
jordium-gantt-vue3/
├── src/ # 源码目录
│ ├── components/ # 核心组件
│ ├── GanttChart.vue # 主入口组件
│ ├── TaskList.vue # 任务列表
│ ├── Timeline.vue # 时间轴
│ ├── TaskBar.vue # 任务条
├── MilestonePoint.vue # 里程碑
│ │ └── ... # 其他组件
│ ├── models/ # 数据模型
│ │ ├── classes/ # 类定义
│ │ └── configs/ # 配置接口
│ ├── composables/ # 组合式函数
│ ├── styles/ # 样式文件
│ └── index.ts # 导出入口
── demo/ # 开发演示
├── dist/ # 构建产物
├── docs/ # 文档
└── package.json
├── src/ # 组件源码与核心逻辑
│ ├── components/ # 主要 Vue 组件
│ ├── models/ # 数据类型与配置
│ ├── composables/ # 组合式函数
│ ├── styles/ # 样式文件
└── index.ts # 入口导出
├── demo/ # 组件开发与交互演示(本地开发/预览用)
├── packageDemo/ # npm 包集成演示(模拟外部项目集成效果)
├── dist/ # 构建产物(发布/静态站点/打包输出)
├── docs/ # 相关文档如部署、API 说明等)
├── design/ # 设计资源与截图
├── public/ # 公共静态资源
├── README.md # 中文说明文档
├── README-EN.md # 英文说明文档
── ... # 其他配置、脚本与元数据
```
- `demo/`:用于本地开发和功能演示,包含完整的交互页面。
- `packageDemo/`:用于模拟 npm 包在外部项目中的集成与使用场景。
- `dist/`:构建输出目录,包含发布到 npm 或静态站点的产物。
- `docs/`项目相关文档如部署说明、API 参考等。
- 其余目录请参考注释。
## 🔧 API 参考
### GanttChart 属性
@@ -117,11 +114,72 @@ jordium-gantt-vue3/
### GanttChart 事件
| 事件名 | 参数 | 说明 |
|--------|------|------|
| 事件名 | 参数 | 说明 |
|----------------------|----------------------------|------------------------------|
| `taskbar-drag-end` | `task: Task` | 任务条拖拽结束 |
| `taskbar-resize-end` | `task: Task` | 任务条大小调整结束 |
| `milestone-drag-end` | `milestone: Task` | 里程碑拖拽结束 |
| `predecessor-added` | `{ targetTask, newTask }` | 添加前置任务后触发。<br>参数说明:<br>`targetTask`被添加前置任务的目标任务Task对象<br>`newTask`新添加的前置任务Task对象 |
| `successor-added` | `{ targetTask, newTask }` | 添加后置任务后触发。<br>参数说明:<br>`targetTask`被添加后置任务的目标任务Task对象<br>`newTask`新添加的后置任务Task对象 |
| `task-deleted` | `{ task }` | 删除任务后触发 |
| `task-added` | `{ task }` | 新建任务后触发 |
| `task-updated` | `{ task }` | 更新任务后触发 |
#### 计时事件用法示例
```vue
<GanttChart
...
@timer-started="onTimerStarted"
@timer-stopped="onTimerStopped"
/>
<script setup>
function onTimerStarted(task) {
// 这里可以自定义提示、日志或业务逻辑
alert(`任务【${task.name}】开始计时:${new Date(task.timerStartTime).toLocaleString()}`)
}
function onTimerStopped(task) {
alert(`任务【${task.name}】停止计时`)
}
</script>
```
#### 任务事件用法示例
```vue
<GanttChart
...
@predecessor-added="onPredecessorAdded"
@successor-added="onSuccessorAdded"
@task-deleted="onTaskDeleted"
@task-added="onTaskAdded"
@task-updated="onTaskUpdated"
/>
<script setup>
function onPredecessorAdded(e) {
// e: { targetTask: Task, newTask: Task }
alert(`任务【${e.targetTask.name}】添加前置任务【${e.newTask.name}】`)
}
function onSuccessorAdded(e) {
// e: { targetTask: Task, newTask: Task }
alert(`任务【${e.targetTask.name}】添加后置任务【${e.newTask.name}】`)
}
function onTaskDeleted(e) {
// e: { task: Task }
alert(`任务【${e.task.name}】已删除`)
}
function onTaskAdded(e) {
// e: { task: Task }
alert(`任务【${e.task.name}】已创建`)
}
function onTaskUpdated(e) {
// e: { task: Task }
alert(`任务【${e.task.name}】已更新`)
}
</script>
```
### 数据类型
@@ -129,24 +187,30 @@ jordium-gantt-vue3/
**Task 任务类型**
```typescript
interface Task {
id: number // 任务唯一标识
name: string // 任务名称
predecessor?: string // 前置任务ID
assignee?: string // 负责人
startDate?: string // 开始日期 (YYYY-MM-DD格式)
endDate?: string // 结束日期 (YYYY-MM-DD格式)
progress?: number // 完成进度 (0-100)
estimatedHours?: number // 预估工时
actualHours?: number // 实际工时
parentId?: number // 上级任务ID
children?: Task[] // 子任务数组(支持嵌套结构)
collapsed?: boolean // 是否折叠子任务
isParent?: boolean // 是否为父任务
type?: string // 任务类型 (task/story/bug/milestone)
description?: string // 任务描述
icon?: string // 任务图标
level?: number // 任务层级
export interface Task {
id: number // 任务唯一ID
name: string // 任务名称
predecessor?: number[] // 前置任务ID数组
assignee?: string // 负责人
startDate?: string // 开始日期ISO字符串
endDate?: string // 结束日期ISO字符串
progress?: number // 进度百分比 0-100
estimatedHours?: number // 预估工时
actualHours?: number // 实际工时
parentId?: number // 上级任务ID
children?: Task[] // 子任务数组
collapsed?: boolean // 是否折叠
isParent?: boolean // 是否为父任务
type?: string // 任务类型(如 taskstorymilestone 等)
description?: string // 任务描述
icon?: string // 图标
level?: number // 层级
// 计时相关字段
isTimerRunning?: boolean // 计时是否进行中
timerStartTime?: number // 计时开始时间(时间戳)
timerEndTime?: number // 计时结束时间(时间戳)
timerStartDesc?: string // 计时开始时的描述
timerElapsedTime?: number // 已累计计时时长(秒)
}
```

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import GanttChart from '../src/components/GanttChart.vue'
import TaskDrawer from '../src/components/TaskDrawer.vue'
// import TaskDrawer from '../src/components/TaskDrawer.vue' // 移除
import MilestoneDialog from '../src/components/MilestoneDialog.vue'
import demoData from './data.json'
import packageInfo from '../package.json'
@@ -19,11 +19,6 @@ const { t, formatTranslation } = useI18n()
const tasks = ref<Task[]>([])
const milestones = ref<Task[]>([])
// TaskDrawer状态管理
const showTaskDrawer = ref(false)
const currentTask = ref<Task | null>(null)
const isEditMode = ref(false)
// MilestoneDialog状态管理
const showMilestoneDialog = ref(false)
const currentMilestone = ref<Task | null>(null)
@@ -140,6 +135,12 @@ const handleMilestoneDelete = async (milestoneId: number) => {
// 任务更新处理器
const handleTaskUpdate = (updatedTask: Task) => {
// 计时信息展示(无论来源于 TaskBar/TaskRow 还是 TaskDrawer header
if (updatedTask.timerStartTime) {
const msg = `任务【${updatedTask.name}】已更新`
showMessage(msg, 'success', { closable: true })
}
// 先找到原任务检查parentId是否改变了
const findOriginalTask = (taskArray: Task[]): Task | null => {
for (const task of taskArray) {
@@ -460,24 +461,6 @@ const handleMilestoneIconChange = (milestoneId: number, icon: string) => {
}
}
// TaskDrawer事件处理器
const handleTaskDrawerSubmit = (task: Task) => {
if (isEditMode.value) {
// 编辑模式:更新任务
handleTaskUpdate(task)
} else {
// 新建模式:添加任务
handleTaskAdd(task)
}
showTaskDrawer.value = false
}
const handleTaskDrawerClose = () => {
showTaskDrawer.value = false
currentTask.value = null
isEditMode.value = false
}
const handleTaskDrawerDelete = (task: Task, deleteChildren?: boolean) => {
if (task.type === 'story' && deleteChildren === true) {
// 删除story及其所有子任务
@@ -586,6 +569,26 @@ const collectAllTaskIds = (task: Task): number[] => {
}
return ids
}
// Timer事件处理
function onTimerStarted(task: Task) {
showMessage(
`Demo 任务【${task.name}\n开始计时${new Date(task.timerStartTime).toLocaleString()}${task.timerStartDesc ? `\n计时说明${task.timerStartDesc}` : ''}`,
'info',
{ closable: true },
)
}
function onTimerStopped(task: Task) {
let msg = `Demo 任务【${task.name}`
if (task.timerStartTime) {
msg += `\n开始计时${new Date(task.timerStartTime).toLocaleString()}`
msg += `\n结束计时${new Date().toLocaleString()}`
if (task.timerStartDesc) msg += `\n计时说明${task.timerStartDesc}`
} else {
msg += `\n结束计时${new Date().toLocaleString()}`
}
showMessage(msg, 'info', { closable: true })
}
</script>
<template>
@@ -639,22 +642,27 @@ const collectAllTaskIds = (task: Task): number[] => {
@taskbar-drag-end="handleTaskbarDragOrResizeEnd"
@taskbar-resize-end="handleTaskbarDragOrResizeEnd"
@milestone-drag-end="handleMilestoneDragEnd"
@edit-task="task => showMessage(`进入任务编辑:${task.name}`)"
@close="() => showMessage('已关闭任务编辑', 'info')"
@timer-started="onTimerStarted"
@timer-stopped="onTimerStopped"
@predecessor-added="
e =>
showMessage(`Demo 任务[${e.targetTask.name}] 添加前置任务 [${e.newTask.name}]`, 'info')
"
@successor-added="
e =>
showMessage(`Demo 任务[${e.targetTask.name}] 添加后置任务 [${e.newTask.name}]`, 'info')
"
@task-deleted="e => showMessage(`Demo 任务[${e.task.name}] 已删除`, 'info')"
@task-added="e => showMessage(`Demo 任务[${e.task.name}] 已创建`, 'info')"
@task-updated="e => showMessage(`Demo 任务[${e.task.name}] 已更新`, 'info')"
/>
</div>
<div class="license-info">
<p>MIT License @JORDIUM.COM</p>
</div>
<!-- TaskDrawer用于新建/编辑任务 -->
<TaskDrawer
v-model:visible="showTaskDrawer"
:task="currentTask"
:is-edit="isEditMode"
@submit="handleTaskDrawerSubmit"
@close="handleTaskDrawerClose"
@delete="handleTaskDrawerDelete"
/>
<!-- MilestoneDialog用于新建/编辑里程碑 -->
<MilestoneDialog
:visible="showMilestoneDialog"

View File

@@ -140,5 +140,22 @@
"version": "1.1.0",
"date": "2025-07-08",
"notes": ["TaskBar拖拽触发Timeline水平滑动", "增加日|周|月视图切换", "问题修复"]
},
{
"version": "1.2.0",
"date": "2025-07-21",
"notes": [
"增加TaskRow/TaskBar的右键菜单",
"增加Task的执行定时",
"增加添加前置任务功能",
"增加添加后置任务功能",
"增加TaskRow/TaskBar的删除功能",
"GanttChart增加@predecessor-added(添加前置任务完成)事件",
"GanttChart增加@successor-added(添加后置任务完成)事件",
"GanttChart增加@task-deleted(删除任务完成)事件",
"GanttChart增加@task-added(添加任务完成)事件",
"GanttChart增加@task-updated(编辑任务完成)事件",
"升级TaskDrawer统一在GanttChart中管理"
]
}
]

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jordium-gantt-vue3",
"version": "1.0.10",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jordium-gantt-vue3",
"version": "1.0.10",
"version": "1.1.0",
"license": "MIT",
"dependencies": {
"date-fns": "^4.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "jordium-gantt-vue3",
"version": "1.1.0",
"version": "1.2.0",
"type": "module",
"main": "dist/jordium-gantt-vue3.cjs.js",
"module": "dist/jordium-gantt-vue3.es.js",

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import GanttChart from '../src/components/GanttChart.vue'
import TaskDrawer from '../src/components/TaskDrawer.vue'
// import TaskDrawer from '../src/components/TaskDrawer.vue' // 移除
import MilestoneDialog from '../src/components/MilestoneDialog.vue'
import demoData from './data.json'
import packageInfo from '../package.json'
@@ -19,11 +19,6 @@ const { t, formatTranslation } = useI18n()
const tasks = ref<Task[]>([])
const milestones = ref<Task[]>([])
// TaskDrawer状态管理
const showTaskDrawer = ref(false)
const currentTask = ref<Task | null>(null)
const isEditMode = ref(false)
// MilestoneDialog状态管理
const showMilestoneDialog = ref(false)
const currentMilestone = ref<Task | null>(null)
@@ -41,6 +36,7 @@ const toolbarConfig = {
showLanguage: true,
showTheme: true,
showFullscreen: true,
showTimeScale: true, // 控制日|周|月时间刻度按钮组的可见性
}
// 自定义CSV导出处理器可选
@@ -139,6 +135,12 @@ const handleMilestoneDelete = async (milestoneId: number) => {
// 任务更新处理器
const handleTaskUpdate = (updatedTask: Task) => {
// 计时信息展示(无论来源于 TaskBar/TaskRow 还是 TaskDrawer header
if (updatedTask.timerStartTime) {
const msg = `任务【${updatedTask.name}】已更新`
showMessage(msg, 'success', { closable: true })
}
// 先找到原任务检查parentId是否改变了
const findOriginalTask = (taskArray: Task[]): Task | null => {
for (const task of taskArray) {
@@ -459,24 +461,6 @@ const handleMilestoneIconChange = (milestoneId: number, icon: string) => {
}
}
// TaskDrawer事件处理器
const handleTaskDrawerSubmit = (task: Task) => {
if (isEditMode.value) {
// 编辑模式:更新任务
handleTaskUpdate(task)
} else {
// 新建模式:添加任务
handleTaskAdd(task)
}
showTaskDrawer.value = false
}
const handleTaskDrawerClose = () => {
showTaskDrawer.value = false
currentTask.value = null
isEditMode.value = false
}
const handleTaskDrawerDelete = (task: Task, deleteChildren?: boolean) => {
if (task.type === 'story' && deleteChildren === true) {
// 删除story及其所有子任务
@@ -585,6 +569,26 @@ const collectAllTaskIds = (task: Task): number[] => {
}
return ids
}
// Timer事件处理
function onTimerStarted(task: Task) {
showMessage(
`Demo 任务【${task.name}\n开始计时${new Date(task.timerStartTime).toLocaleString()}${task.timerStartDesc ? `\n计时说明${task.timerStartDesc}` : ''}`,
'info',
{ closable: true },
)
}
function onTimerStopped(task: Task) {
let msg = `Demo 任务【${task.name}`
if (task.timerStartTime) {
msg += `\n开始计时${new Date(task.timerStartTime).toLocaleString()}`
msg += `\n结束计时${new Date().toLocaleString()}`
if (task.timerStartDesc) msg += `\n计时说明${task.timerStartDesc}`
} else {
msg += `\n结束计时${new Date().toLocaleString()}`
}
showMessage(msg, 'info', { closable: true })
}
</script>
<template>
@@ -638,22 +642,27 @@ const collectAllTaskIds = (task: Task): number[] => {
@taskbar-drag-end="handleTaskbarDragOrResizeEnd"
@taskbar-resize-end="handleTaskbarDragOrResizeEnd"
@milestone-drag-end="handleMilestoneDragEnd"
@edit-task="task => showMessage(`进入任务编辑:${task.name}`)"
@close="() => showMessage('已关闭任务编辑', 'info')"
@timer-started="onTimerStarted"
@timer-stopped="onTimerStopped"
@predecessor-added="
e =>
showMessage(`Demo 任务[${e.targetTask.name}] 添加前置任务 [${e.newTask.name}]`, 'info')
"
@successor-added="
e =>
showMessage(`Demo 任务[${e.targetTask.name}] 添加后置任务 [${e.newTask.name}]`, 'info')
"
@task-deleted="e => showMessage(`Demo 任务[${e.task.name}] 已删除`, 'info')"
@task-added="e => showMessage(`Demo 任务[${e.task.name}] 已创建`, 'info')"
@task-updated="e => showMessage(`Demo 任务[${e.task.name}] 已更新`, 'info')"
/>
</div>
<div class="license-info">
<p>MIT License @JORDIUM.COM</p>
</div>
<!-- TaskDrawer用于新建/编辑任务 -->
<TaskDrawer
v-model:visible="showTaskDrawer"
:task="currentTask"
:is-edit="isEditMode"
@submit="handleTaskDrawerSubmit"
@close="handleTaskDrawerClose"
@delete="handleTaskDrawerDelete"
/>
<!-- MilestoneDialog用于新建/编辑里程碑 -->
<MilestoneDialog
:visible="showMilestoneDialog"

View File

@@ -135,5 +135,27 @@
"Timeline中sub-task隐藏后关系线也随之隐藏",
"增强TaskBar可以存在多个前置任务"
]
},
{
"version": "1.1.0",
"date": "2025-07-08",
"notes": ["TaskBar拖拽触发Timeline水平滑动", "增加日|周|月视图切换", "问题修复"]
},
{
"version": "1.2.0",
"date": "2025-07-21",
"notes": [
"增加TaskRow/TaskBar的右键菜单",
"增加Task的执行定时",
"增加添加前置任务功能",
"增加添加后置任务功能",
"增加TaskRow/TaskBar的删除功能",
"GanttChart增加@predecessor-added(添加前置任务完成)事件",
"GanttChart增加@successor-added(添加后置任务完成)事件",
"GanttChart增加@task-deleted(删除任务完成)事件",
"GanttChart增加@task-added(添加任务完成)事件",
"GanttChart增加@task-updated(编辑任务完成)事件",
"升级TaskDrawer统一在GanttChart中管理"
]
}
]

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>

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()
@@ -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)
@@ -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') {
@@ -398,6 +413,7 @@ onUnmounted(() => {
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)
})
// 全屏状态管理
@@ -1169,6 +1185,279 @@ watch(
},
{ 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: [],
}
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: [],
}
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 +1478,7 @@ watch(
:on-theme-change="props.onThemeChange"
:on-fullscreen-change="props.onFullscreenChange"
:on-time-scale-change="handleTimeScaleChange"
@add-task="handleToolbarAddTask"
/>
<!-- 甘特图主体 -->
@@ -1204,6 +1494,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 +1538,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 +1848,8 @@ watch(
}
.gantt-root.splitter-dragging .gantt-panel-right {
/* 拖拽期间禁用Timeline区域的指针事件 */
pointer-events: none;
/* 拖拽时高亮右侧面板 */
background: rgba(255, 255, 255, 0.1);
}
.gantt-root.splitter-dragging * {

View File

@@ -2,6 +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 TaskContextMenu from './TaskContextMenu.vue'
interface Props {
task: Task
@@ -34,9 +35,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',
])
// 日期工具函数 - 处理时区安全的日期创建和操作
@@ -224,9 +230,7 @@ const taskStatus = computed(() => {
})
// 判断是否已完成
const isCompleted = computed(() => {
return (props.task.progress || 0) >= 100
})
const isCompleted = computed(() => (props.task.progress || 0) >= 100)
// 计算完成部分的宽度
const progressWidth = computed(() => {
@@ -438,10 +442,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)
})
})
@@ -965,7 +973,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>
@@ -989,6 +1031,7 @@ const calculatePositionFromTimelineData = (
'short-task-bar': isShortTaskBar,
'overflow-effect': needsOverflowEffect,
}"
@contextmenu="handleContextMenu"
@dblclick="handleTaskBarDoubleClick"
>
<!-- 父级任务的标签 -->
@@ -1049,6 +1092,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 弹窗 -->

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>

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'
@@ -195,6 +270,11 @@ 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'))
@@ -207,6 +287,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, {
@@ -283,11 +375,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 +388,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 +411,7 @@ const confirmDelete = () => {
emit('delete', props.task)
handleClose()
} catch (error) {
showMessage(t.value.taskDeleteFailed, 'error')
handleError(error)
} finally {
submitting.value = false
}
@@ -326,14 +431,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 +447,7 @@ const handleDeleteNo = () => {
emit('delete', props.task, false) // 传递false表示仅删除story
handleClose()
} catch (error) {
showMessage(t.value.taskDeleteFailed, 'error')
handleError(error)
} finally {
submitting.value = false
}
@@ -375,16 +479,225 @@ watch(
},
{ immediate: true },
)
// 计时器本地状态保证点击后UI立即切换
// !!! 只保留一处 localTimerRunning 定义,彻底移除重复声明 !!!
// 只要 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 },
)
// 修正计时器首次启动不跳动问题:每次打开抽屉时重置 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>
@@ -606,6 +919,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>

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 } }) => {
// 直接从TaskRow触发全局事件跳过TaskList中转
// 将事件转发为全局事件让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>

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>

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)
}
// 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)
}
</script>
<template>
@@ -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"

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'

View File

@@ -97,6 +97,13 @@ const messages = {
newMilestone: '新建里程碑',
editMilestone: '编辑里程碑',
cancel: '取消',
// 右键菜单
startTimer: '开始计时',
stopTimer: '停止计时',
timerStarted: '任务计时已开始',
timerStopped: '任务计时已停止',
addPredecessor: '添加前置任务',
addSuccessor: '添加后置任务',
// PDF导出相关
pdfExportLoading: '正在生成PDF请稍候...',
@@ -156,6 +163,10 @@ const messages = {
overtime: '超',
overdue: '逾期',
days: '天',
// 计时确认弹窗
timerConfirmPrefix: '即将为任务',
timerConfirmSuffix: '计时,若有特殊说明请完善下面的描述',
timerConfirmPlaceholder: '请输入计时说明',
},
'en-US': {
// TaskList Header
@@ -246,6 +257,13 @@ const messages = {
newMilestone: 'New Milestone',
editMilestone: 'Edit Milestone',
cancel: 'Cancel',
// Context menu
timerStarted: 'Timer Started',
timerStopped: 'Timer Stopped',
startTimer: 'Start Timer',
stopTimer: 'Stop Timer',
addPredecessor: 'Add Predecessor',
addSuccessor: 'Add Successor',
// PDF export
pdfExportLoading: 'Generating PDF, please wait...',
@@ -309,6 +327,10 @@ const messages = {
overtime: 'Over',
overdue: 'Overdue',
days: ' days',
// 计时确认弹窗
timerConfirmPrefix: 'About to start timing for',
timerConfirmSuffix: '. If there are special notes, please complete the description below.',
timerConfirmPlaceholder: 'Please enter timer description',
},
}

View File

@@ -17,4 +17,10 @@ export interface Task {
description?: string
icon?: string
level?: number
// 计时相关字段
isTimerRunning?: boolean
timerStartTime?: number
timerEndTime?: number // 结束计时时间
timerStartDesc?: string // 计时开始时填写的描述
timerElapsedTime?: number
}