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
+22
View File
@@ -5,6 +5,28 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.1] - 2025-07-22
### Fixed
- SonarQube代码质量检查问题修改
## [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 ## [1.1.0] - 2025-07-08
### Added ### Added
+87 -26
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 ## 📁 Project Structure
``` ```
@@ -116,11 +110,72 @@ jordium-gantt-vue3/
### GanttChart Events ### GanttChart Events
| Event | Parameters | Description | | Event | Parameters | Description |
|-------|------------|-------------| |--------------------|----------------------------|------------------------------------|
| `taskbar-drag-end` | `task: Task` | Task bar drag end | | `taskbar-drag-end` | `task: Task` | Task bar drag end |
| `taskbar-resize-end` | `task: Task` | Task bar resize end | | `taskbar-resize-end` | `task: Task` | Task bar resize end |
| `milestone-drag-end` | `milestone: Task` | Milestone drag 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 ### Data Types
@@ -128,24 +183,30 @@ jordium-gantt-vue3/
**Task Type** **Task Type**
```typescript ```typescript
interface Task { export interface Task {
id: number // Unique task identifier id: number // Unique task ID
name: string // Task name name: string // Task name
predecessor?: string // Predecessor task ID predecessor?: number[] // Predecessor task ID array
assignee?: string // Assignee assignee?: string // Assignee
startDate?: string // Start date (YYYY-MM-DD format) startDate?: string // Start date (ISO string)
endDate?: string // End date (YYYY-MM-DD format) endDate?: string // End date (ISO string)
progress?: number // Completion progress (0-100) progress?: number // Progress percentage 0-100
estimatedHours?: number // Estimated hours estimatedHours?: number // Estimated hours
actualHours?: number // Actual hours actualHours?: number // Actual hours
parentId?: number // Parent task ID parentId?: number // Parent task ID
children?: Task[] // Child tasks array (supports nested structure) children?: Task[] // Subtask array
collapsed?: boolean // Whether child tasks are collapsed collapsed?: boolean // Collapsed state
isParent?: boolean // Whether it's a parent task isParent?: boolean // Is parent task
type?: string // Task type (task/story/bug/milestone) type?: string // Task type (e.g. task, story, milestone)
description?: string // Task description description?: string // Task description
icon?: string // Task icon icon?: string // Icon
level?: number // Task level 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)
} }
``` ```
+108 -44
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/ jordium-gantt-vue3/
├── src/ # 源码目录 ├── src/ # 组件源码与核心逻辑
│ ├── components/ # 核心组件 │ ├── components/ # 主要 Vue 组件
│ ├── GanttChart.vue # 主入口组件 │ ├── models/ # 数据类型与配置
│ ├── TaskList.vue # 任务列表 │ ├── composables/ # 组合式函数
│ ├── Timeline.vue # 时间轴 │ ├── styles/ # 样式文件
│ ├── TaskBar.vue # 任务条 └── index.ts # 入口导出
│ │ ├── MilestonePoint.vue # 里程碑 ├── demo/ # 组件开发与交互演示(本地开发/预览用)
│ │ └── ... # 其他组件 ├── packageDemo/ # npm 包集成演示(模拟外部项目集成效果)
│ ├── models/ # 数据模型 ├── dist/ # 构建产物(发布/静态站点/打包输出)
│ │ ├── classes/ # 类定义 ├── docs/ # 相关文档(如部署、API 说明等)
│ │ └── configs/ # 配置接口 ├── design/ # 设计资源与截图
│ ├── composables/ # 组合式函数 ├── public/ # 公共静态资源
│ ├── styles/ # 样式文件 ├── README.md # 中文说明文档
│ └── index.ts # 导出入口 ├── README-EN.md # 英文说明文档
── demo/ # 开发演示 ── ... # 其他配置、脚本与元数据
├── dist/ # 构建产物
├── docs/ # 文档
└── package.json
``` ```
- `demo/`:用于本地开发和功能演示,包含完整的交互页面。
- `packageDemo/`:用于模拟 npm 包在外部项目中的集成与使用场景。
- `dist/`:构建输出目录,包含发布到 npm 或静态站点的产物。
- `docs/`:项目相关文档,如部署说明、API 参考等。
- 其余目录请参考注释。
## 🔧 API 参考 ## 🔧 API 参考
### GanttChart 属性 ### GanttChart 属性
@@ -117,11 +114,72 @@ jordium-gantt-vue3/
### GanttChart 事件 ### GanttChart 事件
| 事件名 | 参数 | 说明 | | 事件名 | 参数 | 说明 |
|--------|------|------| |----------------------|----------------------------|------------------------------|
| `taskbar-drag-end` | `task: Task` | 任务条拖拽结束 | | `taskbar-drag-end` | `task: Task` | 任务条拖拽结束 |
| `taskbar-resize-end` | `task: Task` | 任务条大小调整结束 | | `taskbar-resize-end` | `task: Task` | 任务条大小调整结束 |
| `milestone-drag-end` | `milestone: 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 任务类型** **Task 任务类型**
```typescript ```typescript
interface Task { export interface Task {
id: number // 任务唯一标识 id: number // 任务唯一ID
name: string // 任务名称 name: string // 任务名称
predecessor?: string // 前置任务ID predecessor?: number[] // 前置任务ID数组
assignee?: string // 负责人 assignee?: string // 负责人
startDate?: string // 开始日期 (YYYY-MM-DD格式) startDate?: string // 开始日期(ISO字符串)
endDate?: string // 结束日期 (YYYY-MM-DD格式) endDate?: string // 结束日期(ISO字符串)
progress?: number // 完成进度 (0-100) progress?: number // 进度百分比 0-100
estimatedHours?: number // 预估工时 estimatedHours?: number // 预估工时
actualHours?: number // 实际工时 actualHours?: number // 实际工时
parentId?: number // 上级任务ID parentId?: number // 上级任务ID
children?: Task[] // 子任务数组(支持嵌套结构) children?: Task[] // 子任务数组
collapsed?: boolean // 是否折叠子任务 collapsed?: boolean // 是否折叠
isParent?: boolean // 是否为父任务 isParent?: boolean // 是否为父任务
type?: string // 任务类型 (task/story/bug/milestone) type?: string // 任务类型(如 taskstorymilestone 等)
description?: string // 任务描述 description?: string // 任务描述
icon?: string // 任务图标 icon?: string // 图标
level?: number // 任务层级 level?: number // 层级
// 计时相关字段
isTimerRunning?: boolean // 计时是否进行中
timerStartTime?: number // 计时开始时间(时间戳)
timerEndTime?: number // 计时结束时间(时间戳)
timerStartDesc?: string // 计时开始时的描述
timerElapsedTime?: number // 已累计计时时长(秒)
} }
``` ```
+50 -42
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue' import { ref, onMounted, nextTick } from 'vue'
import GanttChart from '../src/components/GanttChart.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 MilestoneDialog from '../src/components/MilestoneDialog.vue'
import demoData from './data.json' import demoData from './data.json'
import packageInfo from '../package.json' import packageInfo from '../package.json'
@@ -19,11 +19,6 @@ const { t, formatTranslation } = useI18n()
const tasks = ref<Task[]>([]) const tasks = ref<Task[]>([])
const milestones = ref<Task[]>([]) const milestones = ref<Task[]>([])
// TaskDrawer状态管理
const showTaskDrawer = ref(false)
const currentTask = ref<Task | null>(null)
const isEditMode = ref(false)
// MilestoneDialog状态管理 // MilestoneDialog状态管理
const showMilestoneDialog = ref(false) const showMilestoneDialog = ref(false)
const currentMilestone = ref<Task | null>(null) const currentMilestone = ref<Task | null>(null)
@@ -123,14 +118,14 @@ const handleMilestoneDelete = async (milestoneId: number) => {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('milestone-deleted', { new CustomEvent('milestone-deleted', {
detail: { milestoneId }, detail: { milestoneId },
}), })
) )
// 触发强制更新事件,确保Timeline重新渲染 // 触发强制更新事件,确保Timeline重新渲染
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('milestone-data-changed', { new CustomEvent('milestone-data-changed', {
detail: { milestones: milestones.value }, detail: { milestones: milestones.value },
}), })
) )
} }
@@ -140,6 +135,12 @@ const handleMilestoneDelete = async (milestoneId: number) => {
// 任务更新处理器 // 任务更新处理器
const handleTaskUpdate = (updatedTask: Task) => { const handleTaskUpdate = (updatedTask: Task) => {
// 计时信息展示(无论来源于 TaskBar/TaskRow 还是 TaskDrawer header
if (updatedTask.timerStartTime) {
const msg = `任务【${updatedTask.name}】已更新`
showMessage(msg, 'success', { closable: true })
}
// 先找到原任务,检查parentId是否改变了 // 先找到原任务,检查parentId是否改变了
const findOriginalTask = (taskArray: Task[]): Task | null => { const findOriginalTask = (taskArray: Task[]): Task | null => {
for (const task of taskArray) { for (const task of taskArray) {
@@ -218,7 +219,7 @@ const handleTaskUpdate = (updatedTask: Task) => {
showMessage( showMessage(
formatTranslation('newParentTaskNotFound', { parentId: taskToAdd.parentId }), formatTranslation('newParentTaskNotFound', { parentId: taskToAdd.parentId }),
'warning', 'warning',
{ closable: true }, { closable: true }
) )
tasks.value.push(taskToAdd) tasks.value.push(taskToAdd)
} }
@@ -266,7 +267,7 @@ const handleTaskAdd = (newTask: Task) => {
const maxId = Math.max( const maxId = Math.max(
...tasks.value.map(t => t.id || 0), ...tasks.value.map(t => t.id || 0),
...milestones.value.map(m => m.id || 0), ...milestones.value.map(m => m.id || 0),
0, 0
) )
newTask.id = maxId + 1 newTask.id = maxId + 1
} }
@@ -361,7 +362,7 @@ const handleStoryDeleteWithChildren = (storyToDelete: Task) => {
'success', 'success',
{ {
closable: false, closable: false,
}, }
) )
return true return true
} }
@@ -423,7 +424,7 @@ const handleStoryDeleteOnly = (storyToDelete: Task) => {
'success', 'success',
{ {
closable: false, closable: false,
}, }
) )
return true return true
} }
@@ -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) => { const handleTaskDrawerDelete = (task: Task, deleteChildren?: boolean) => {
if (task.type === 'story' && deleteChildren === true) { if (task.type === 'story' && deleteChildren === true) {
// 删除story及其所有子任务 // 删除story及其所有子任务
@@ -515,7 +498,7 @@ function handleTaskbarDragOrResizeEnd(newTask) {
`开始: ${oldTask.startDate}${newTask.startDate}\n` + `开始: ${oldTask.startDate}${newTask.startDate}\n` +
`结束: ${oldTask.endDate}${newTask.endDate}`, `结束: ${oldTask.endDate}${newTask.endDate}`,
'info', 'info',
{ closable: true }, { closable: true }
) )
} }
function handleMilestoneDragEnd(newMilestone) { function handleMilestoneDragEnd(newMilestone) {
@@ -525,7 +508,7 @@ function handleMilestoneDragEnd(newMilestone) {
`里程碑【${newMilestone.name}\n` + `里程碑【${newMilestone.name}\n` +
`开始: ${oldMilestone.endDate}${newMilestone.startDate}`, `开始: ${oldMilestone.endDate}${newMilestone.startDate}`,
'info', 'info',
{ closable: true }, { closable: true }
) )
} }
@@ -586,6 +569,26 @@ const collectAllTaskIds = (task: Task): number[] => {
} }
return ids return ids
} }
// Timer事件处理
function onTimerStarted(task: Task) {
showMessage(
`Demo 任务【${task.name}\n开始计时:${new Date(task.timerStartTime).toLocaleString()}\n计时说明:${task.timerStartDesc ? 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> </script>
<template> <template>
@@ -639,22 +642,27 @@ const collectAllTaskIds = (task: Task): number[] => {
@taskbar-drag-end="handleTaskbarDragOrResizeEnd" @taskbar-drag-end="handleTaskbarDragOrResizeEnd"
@taskbar-resize-end="handleTaskbarDragOrResizeEnd" @taskbar-resize-end="handleTaskbarDragOrResizeEnd"
@milestone-drag-end="handleMilestoneDragEnd" @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>
<div class="license-info"> <div class="license-info">
<p>MIT License @JORDIUM.COM</p> <p>MIT License @JORDIUM.COM</p>
</div> </div>
<!-- TaskDrawer用于新建/编辑任务 -->
<TaskDrawer
v-model:visible="showTaskDrawer"
:task="currentTask"
:is-edit="isEditMode"
@submit="handleTaskDrawerSubmit"
@close="handleTaskDrawerClose"
@delete="handleTaskDrawerDelete"
/>
<!-- MilestoneDialog用于新建/编辑里程碑 --> <!-- MilestoneDialog用于新建/编辑里程碑 -->
<MilestoneDialog <MilestoneDialog
:visible="showMilestoneDialog" :visible="showMilestoneDialog"
+22
View File
@@ -140,5 +140,27 @@
"version": "1.1.0", "version": "1.1.0",
"date": "2025-07-08", "date": "2025-07-08",
"notes": ["TaskBar拖拽触发Timeline水平滑动", "增加日|周|月视图切换", "问题修复"] "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中管理"
]
},
{
"version": "1.2.1",
"date": "2025-07-22",
"notes": ["SonarQube代码质量检查问题修改"]
} }
] ]
-61
View File
@@ -1,61 +0,0 @@
# GitHub Pages Deployment Guide
This project has been configured with automatic deployment of GitHub Pages, and users can experience the complete Gantt charting function online.
## 🌐 Online visit
**Demo Address**: https://nelson820125.github.io/jordium-gantt-vue3/
## 🔧 Deployment Configuration
### Automated deployment
Projects are automatically deployed to GitHub Pages using GitHub Actions:
- **Triggering condition**: Push to `main` or `master` branch
- **Build Command**: `npm run build:pages`
- **Deployment Directory**: `./dist`
- **Workflows**: `.github/workflows/deploy-github-pages.yml`
### Enable GitHub Pages manually
1. Enter the GitHub repository settings page
2. Find the "Pages" setting options
3. Select "Source" as "GitHub Actions"
4. Push code to the main branch and automatically trigger deployment
## 📁 Construct the product
- **Development and Construction**: `npm run build` → `dist/`
- **GitHub Pages**: `npm run build:pages` → `dist/` (contains the correct base path)
- **NPM package build**: `npm run build:lib` → `npm-package/dist/`
## 🛠️ Local Preview
```bash
# Installation dependencies
npm install
# Start the development server
npm run dev
# Build and preview the production version
npm run build:pages
npm run preview
```
## 🔄 Update deployment
GitHub Actions will automatically:
1. Check out the code
2. Install Node.js and dependencies
3. Build demo application
4. Deploy to GitHub Pages
## 📝 Notes
- Deployment usually takes 1-5 minutes to take effect
- Make sure GitHub Pages is enabled in repository settings
- Custom domain names need to be configured in the `demo/public/CNAME` file
- Static resource paths use relative paths to ensure normal loading in the Pages environment
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "jordium-gantt-vue3", "name": "jordium-gantt-vue3",
"version": "1.0.10", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "jordium-gantt-vue3", "name": "jordium-gantt-vue3",
"version": "1.0.10", "version": "1.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jordium-gantt-vue3", "name": "jordium-gantt-vue3",
"version": "1.1.0", "version": "1.2.1",
"type": "module", "type": "module",
"main": "dist/jordium-gantt-vue3.cjs.js", "main": "dist/jordium-gantt-vue3.cjs.js",
"module": "dist/jordium-gantt-vue3.es.js", "module": "dist/jordium-gantt-vue3.es.js",
+51 -42
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue' import { ref, onMounted, nextTick } from 'vue'
import GanttChart from '../src/components/GanttChart.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 MilestoneDialog from '../src/components/MilestoneDialog.vue'
import demoData from './data.json' import demoData from './data.json'
import packageInfo from '../package.json' import packageInfo from '../package.json'
@@ -19,11 +19,6 @@ const { t, formatTranslation } = useI18n()
const tasks = ref<Task[]>([]) const tasks = ref<Task[]>([])
const milestones = ref<Task[]>([]) const milestones = ref<Task[]>([])
// TaskDrawer状态管理
const showTaskDrawer = ref(false)
const currentTask = ref<Task | null>(null)
const isEditMode = ref(false)
// MilestoneDialog状态管理 // MilestoneDialog状态管理
const showMilestoneDialog = ref(false) const showMilestoneDialog = ref(false)
const currentMilestone = ref<Task | null>(null) const currentMilestone = ref<Task | null>(null)
@@ -41,6 +36,7 @@ const toolbarConfig = {
showLanguage: true, showLanguage: true,
showTheme: true, showTheme: true,
showFullscreen: true, showFullscreen: true,
showTimeScale: true, // 控制日|周|月时间刻度按钮组的可见性
} }
// 自定义CSV导出处理器(可选) // 自定义CSV导出处理器(可选)
@@ -122,14 +118,14 @@ const handleMilestoneDelete = async (milestoneId: number) => {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('milestone-deleted', { new CustomEvent('milestone-deleted', {
detail: { milestoneId }, detail: { milestoneId },
}), })
) )
// 触发强制更新事件,确保Timeline重新渲染 // 触发强制更新事件,确保Timeline重新渲染
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('milestone-data-changed', { new CustomEvent('milestone-data-changed', {
detail: { milestones: milestones.value }, detail: { milestones: milestones.value },
}), })
) )
} }
@@ -139,6 +135,12 @@ const handleMilestoneDelete = async (milestoneId: number) => {
// 任务更新处理器 // 任务更新处理器
const handleTaskUpdate = (updatedTask: Task) => { const handleTaskUpdate = (updatedTask: Task) => {
// 计时信息展示(无论来源于 TaskBar/TaskRow 还是 TaskDrawer header
if (updatedTask.timerStartTime) {
const msg = `任务【${updatedTask.name}】已更新`
showMessage(msg, 'success', { closable: true })
}
// 先找到原任务,检查parentId是否改变了 // 先找到原任务,检查parentId是否改变了
const findOriginalTask = (taskArray: Task[]): Task | null => { const findOriginalTask = (taskArray: Task[]): Task | null => {
for (const task of taskArray) { for (const task of taskArray) {
@@ -217,7 +219,7 @@ const handleTaskUpdate = (updatedTask: Task) => {
showMessage( showMessage(
formatTranslation('newParentTaskNotFound', { parentId: taskToAdd.parentId }), formatTranslation('newParentTaskNotFound', { parentId: taskToAdd.parentId }),
'warning', 'warning',
{ closable: true }, { closable: true }
) )
tasks.value.push(taskToAdd) tasks.value.push(taskToAdd)
} }
@@ -265,7 +267,7 @@ const handleTaskAdd = (newTask: Task) => {
const maxId = Math.max( const maxId = Math.max(
...tasks.value.map(t => t.id || 0), ...tasks.value.map(t => t.id || 0),
...milestones.value.map(m => m.id || 0), ...milestones.value.map(m => m.id || 0),
0, 0
) )
newTask.id = maxId + 1 newTask.id = maxId + 1
} }
@@ -360,7 +362,7 @@ const handleStoryDeleteWithChildren = (storyToDelete: Task) => {
'success', 'success',
{ {
closable: false, closable: false,
}, }
) )
return true return true
} }
@@ -422,7 +424,7 @@ const handleStoryDeleteOnly = (storyToDelete: Task) => {
'success', 'success',
{ {
closable: false, closable: false,
}, }
) )
return true return true
} }
@@ -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) => { const handleTaskDrawerDelete = (task: Task, deleteChildren?: boolean) => {
if (task.type === 'story' && deleteChildren === true) { if (task.type === 'story' && deleteChildren === true) {
// 删除story及其所有子任务 // 删除story及其所有子任务
@@ -514,7 +498,7 @@ function handleTaskbarDragOrResizeEnd(newTask) {
`开始: ${oldTask.startDate}${newTask.startDate}\n` + `开始: ${oldTask.startDate}${newTask.startDate}\n` +
`结束: ${oldTask.endDate}${newTask.endDate}`, `结束: ${oldTask.endDate}${newTask.endDate}`,
'info', 'info',
{ closable: true }, { closable: true }
) )
} }
function handleMilestoneDragEnd(newMilestone) { function handleMilestoneDragEnd(newMilestone) {
@@ -524,7 +508,7 @@ function handleMilestoneDragEnd(newMilestone) {
`里程碑【${newMilestone.name}\n` + `里程碑【${newMilestone.name}\n` +
`开始: ${oldMilestone.endDate}${newMilestone.startDate}`, `开始: ${oldMilestone.endDate}${newMilestone.startDate}`,
'info', 'info',
{ closable: true }, { closable: true }
) )
} }
@@ -585,6 +569,26 @@ const collectAllTaskIds = (task: Task): number[] => {
} }
return ids return ids
} }
// Timer事件处理
function onTimerStarted(task: Task) {
showMessage(
`Demo 任务【${task.name}\n开始计时:${new Date(task.timerStartTime).toLocaleString()}\n计时说明:${task.timerStartDesc ? 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> </script>
<template> <template>
@@ -638,22 +642,27 @@ const collectAllTaskIds = (task: Task): number[] => {
@taskbar-drag-end="handleTaskbarDragOrResizeEnd" @taskbar-drag-end="handleTaskbarDragOrResizeEnd"
@taskbar-resize-end="handleTaskbarDragOrResizeEnd" @taskbar-resize-end="handleTaskbarDragOrResizeEnd"
@milestone-drag-end="handleMilestoneDragEnd" @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>
<div class="license-info"> <div class="license-info">
<p>MIT License @JORDIUM.COM</p> <p>MIT License @JORDIUM.COM</p>
</div> </div>
<!-- TaskDrawer用于新建/编辑任务 -->
<TaskDrawer
v-model:visible="showTaskDrawer"
:task="currentTask"
:is-edit="isEditMode"
@submit="handleTaskDrawerSubmit"
@close="handleTaskDrawerClose"
@delete="handleTaskDrawerDelete"
/>
<!-- MilestoneDialog用于新建/编辑里程碑 --> <!-- MilestoneDialog用于新建/编辑里程碑 -->
<MilestoneDialog <MilestoneDialog
:visible="showMilestoneDialog" :visible="showMilestoneDialog"
+27
View File
@@ -135,5 +135,32 @@
"Timeline中sub-task隐藏后,关系线也随之隐藏", "Timeline中sub-task隐藏后,关系线也随之隐藏",
"增强TaskBar可以存在多个前置任务" "增强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中管理"
]
},
{
"version": "1.2.1",
"date": "2025-07-22",
"notes": ["SonarQube代码质量检查问题修改"]
} }
] ]
+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() const { t} = useI18n()
interface Props { interface Props {
modelValue?: string | [string, string] modelValue?: string | [string, string]
type?: 'date' | 'daterange' type?: 'date' | 'daterange'
@@ -72,9 +71,6 @@ const formatDisplayDate = (dateStr: string) => {
const date = new Date(dateStr) const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
} }
// 显示值 // 显示值
const displayValue = computed(() => { const displayValue = computed(() => {
if (props.type === 'daterange') { if (props.type === 'daterange') {
@@ -471,7 +467,6 @@ const yearList = computed(() => {
return years return years
}) })
/*
// 月份名称 // 月份名称
const monthNames = [ const monthNames = [
'一月', '一月',
@@ -488,7 +483,6 @@ const monthNames = [
'十二月', '十二月',
] ]
const weekDays = ['日', '一', '二', '三', '四', '五', '六'] const weekDays = ['日', '一', '二', '三', '四', '五', '六']
*/
// 生命周期 // 生命周期
onMounted(() => { onMounted(() => {
@@ -745,7 +739,7 @@ const panelStyle = computed(() => {
&lt;&lt; &lt;&lt;
</button> </button>
<span class="el-month-picker__header-label" @click="showYearSelector($event)"> <span class="el-month-picker__header-label" @click="showYearSelector($event)">
{{ currentYear }} {{ currentYear }}
</span> </span>
<button type="button" class="el-picker-panel__icon-btn" @click="nextYear"> <button type="button" class="el-picker-panel__icon-btn" @click="nextYear">
&gt;&gt; &gt;&gt;
@@ -753,7 +747,7 @@ const panelStyle = computed(() => {
</div> </div>
<div class="el-month-picker__content"> <div class="el-month-picker__content">
<div <div
v-for="(monthName, index) in t.monthNames" v-for="(monthName, index) in monthNames"
:key="index" :key="index"
class="el-month-picker__item" class="el-month-picker__item"
:class="{ 'is-current': index === currentMonth }" :class="{ 'is-current': index === currentMonth }"
@@ -775,10 +769,10 @@ const panelStyle = computed(() => {
</button> </button>
<span class="el-date-picker__header-label"> <span class="el-date-picker__header-label">
<span class="el-date-picker__header-year" @click="showYearSelector($event)"> <span class="el-date-picker__header-year" @click="showYearSelector($event)">
{{ currentYear }} {{ currentYear }}
</span> </span>
<span class="el-date-picker__header-month" @click="showMonthSelector($event)"> <span class="el-date-picker__header-month" @click="showMonthSelector($event)">
{{ t.monthNames[currentMonth] }} {{ monthNames[currentMonth] }}
</span> </span>
</span> </span>
<button type="button" class="el-picker-panel__icon-btn" @click="nextMonth"> <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-picker__content">
<!-- 星期标题 --> <!-- 星期标题 -->
<div class="el-date-table__header"> <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 }} {{ day }}
</div> </div>
</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 TaskList from './TaskList.vue'
import Timeline from './Timeline.vue' import Timeline from './Timeline.vue'
import GanttToolbar from './GanttToolbar.vue' import GanttToolbar from './GanttToolbar.vue'
import TaskDrawer from './TaskDrawer.vue'
import { useI18n, setCustomMessages } from '../composables/useI18n' import { useI18n, setCustomMessages } from '../composables/useI18n'
import { formatPredecessorDisplay } from '../utils/predecessorUtils' import { formatPredecessorDisplay } from '../utils/predecessorUtils'
import jsPDF from 'jspdf' import jsPDF from 'jspdf'
@@ -37,7 +38,18 @@ const props = withDefaults(defineProps<Props>(), {
localeMessages: undefined, 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() const { showMessage } = useMessage()
@@ -237,7 +249,7 @@ const toggleTaskList = () => {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('timeline-container-resized', { new CustomEvent('timeline-container-resized', {
detail: { source: 'manual-task-list-toggle' }, detail: { source: 'manual-task-list-toggle' },
}), })
) )
}) })
}, 400) }, 400)
@@ -253,7 +265,7 @@ const handleToggleTaskList = (event: CustomEvent) => {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('timeline-container-resized', { new CustomEvent('timeline-container-resized', {
detail: { source: 'task-list-toggle' }, detail: { source: 'task-list-toggle' },
}), })
) )
}) })
} }
@@ -268,6 +280,7 @@ function handleTaskBarResizeEnd(event: CustomEvent) {
function handleMilestoneDragEnd(event: CustomEvent) { function handleMilestoneDragEnd(event: CustomEvent) {
emit('milestone-drag-end', event.detail) emit('milestone-drag-end', event.detail)
} }
onMounted(() => { onMounted(() => {
window.addEventListener('taskbar-drag-end', handleTaskBarDragEnd as EventListener) window.addEventListener('taskbar-drag-end', handleTaskBarDragEnd as EventListener)
window.addEventListener('taskbar-resize-end', handleTaskBarResizeEnd as EventListener) window.addEventListener('taskbar-resize-end', handleTaskBarResizeEnd as EventListener)
@@ -285,7 +298,7 @@ const handleTaskCollapseChange = (task: Task) => {
const updateTaskCollapsedState = ( const updateTaskCollapsedState = (
tasks: Task[], tasks: Task[],
targetId: number, targetId: number,
collapsed: boolean, collapsed: boolean
): boolean => { ): boolean => {
for (const t of tasks) { for (const t of tasks) {
if (t.id === targetId) { if (t.id === targetId) {
@@ -356,7 +369,7 @@ watch(
notifyTaskListUpdated() notifyTaskListUpdated()
}) })
}, },
{ deep: true, immediate: true }, { deep: true, immediate: true }
) )
onMounted(() => { onMounted(() => {
@@ -375,6 +388,8 @@ onMounted(() => {
window.addEventListener('request-task-list', handleRequestTaskList as EventListener) window.addEventListener('request-task-list', handleRequestTaskList as EventListener)
// //
window.addEventListener('resize', handleWindowResize) window.addEventListener('resize', handleWindowResize)
// TaskBar
window.addEventListener('context-menu', handleTaskContextMenu as EventListener)
nextTick(() => { nextTick(() => {
if (timelineRef.value && typeof timelineRef.value.scrollToTodayCenter === 'function') { if (timelineRef.value && typeof timelineRef.value.scrollToTodayCenter === 'function') {
@@ -392,12 +407,13 @@ onUnmounted(() => {
window.removeEventListener('task-added', handleTaskAdd as EventListener) window.removeEventListener('task-added', handleTaskAdd as EventListener)
window.removeEventListener( window.removeEventListener(
'milestone-icon-changed', 'milestone-icon-changed',
handleMilestoneIconChangeEvent as EventListener, handleMilestoneIconChangeEvent as EventListener
) )
window.removeEventListener('milestone-deleted', handleMilestoneDeleted as EventListener) window.removeEventListener('milestone-deleted', handleMilestoneDeleted as EventListener)
window.removeEventListener('milestone-data-changed', handleMilestoneDataChanged as EventListener) window.removeEventListener('milestone-data-changed', handleMilestoneDataChanged as EventListener)
window.removeEventListener('request-task-list', handleRequestTaskList as EventListener) window.removeEventListener('request-task-list', handleRequestTaskList as EventListener)
window.removeEventListener('resize', handleWindowResize) window.removeEventListener('resize', handleWindowResize)
window.removeEventListener('context-menu', handleTaskContextMenu as EventListener)
}) })
// //
@@ -1167,8 +1183,289 @@ watch(
val => { val => {
if (val) setCustomMessages(locale.value, 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
}
// TaskDrawerTaskListTimeline
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> </script>
<template> <template>
@@ -1189,6 +1486,7 @@ watch(
:on-theme-change="props.onThemeChange" :on-theme-change="props.onThemeChange"
:on-fullscreen-change="props.onFullscreenChange" :on-fullscreen-change="props.onFullscreenChange"
:on-time-scale-change="handleTimeScaleChange" :on-time-scale-change="handleTimeScaleChange"
@add-task="handleToolbarAddTask"
/> />
<!-- 甘特图主体 --> <!-- 甘特图主体 -->
@@ -1204,6 +1502,11 @@ watch(
:edit-component="props.editComponent" :edit-component="props.editComponent"
:use-default-drawer="props.useDefaultDrawer" :use-default-drawer="props.useDefaultDrawer"
@task-collapse-change="handleTaskCollapseChange" @task-collapse-change="handleTaskCollapseChange"
@start-timer="handleStartTimer"
@stop-timer="handleStopTimer"
@add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor"
@delete="handleTaskDelete"
/> />
</div> </div>
<div class="gantt-splitter" @mousedown="onMouseDown"> <div class="gantt-splitter" @mousedown="onMouseDown">
@@ -1243,12 +1546,30 @@ watch(
:on-task-double-click="props.onTaskDoubleClick" :on-task-double-click="props.onTaskDoubleClick"
:edit-component="props.editComponent" :edit-component="props.editComponent"
:use-default-drawer="props.useDefaultDrawer" :use-default-drawer="props.useDefaultDrawer"
:on-task-delete="props.onTaskDelete"
:on-milestone-save="handleMilestoneSave" :on-milestone-save="handleMilestoneSave"
@timeline-scale-changed="handleTimelineScaleChanged" @timeline-scale-changed="handleTimelineScaleChanged"
@edit-task="handleTimelineEditTask"
@start-timer="handleStartTimer"
@stop-timer="handleStopTimer"
@add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor"
@delete="handleTaskDelete"
/> />
</div> </div>
</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> </div>
</template> </template>
@@ -1535,8 +1856,8 @@ watch(
} }
.gantt-root.splitter-dragging .gantt-panel-right { .gantt-root.splitter-dragging .gantt-panel-right {
/* 拖拽期间禁用Timeline区域的指针事件 */ /* 拖拽时高亮右侧面板 */
pointer-events: none; background: rgba(255, 255, 255, 0.1);
} }
.gantt-root.splitter-dragging * { .gantt-root.splitter-dragging * {
+8 -7
View File
@@ -485,26 +485,26 @@ const handleMilestoneMouseLeave = () => {
// //
const formatDisplayDate = (dateStr: string): string => { const formatDisplayDate = (dateStr: string): string => {
if (!dateStr) return t('dateNotSet') //Not Set if (!dateStr) return '未设置'
try { try {
const date = new Date(dateStr) const date = new Date(dateStr)
if (isNaN(date.getTime())) return t('dateNotSet') if (isNaN(date.getTime())) return '未设置'
const year = date.getFullYear() const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0') const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}` return `${year}-${month}-${day}`
} catch { } catch {
return t('dateNotSet') return '未设置'
} }
} }
// Tooltip Target date // Tooltip
const tooltipContent = computed(() => { 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 || '') 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`, top: `${tooltipPosition.y}px`,
}" }"
> >
<div class="tooltip-content" v-html="tooltipContent"> <div class="tooltip-content">
{{ tooltipContent }}
</div> </div>
</div> </div>
</Teleport> </Teleport>
+9 -4
View File
@@ -41,7 +41,7 @@ const availableTasks = computed(() => {
task => task =>
task.type === 'task' && task.type === 'task' &&
task.id !== props.currentTaskId && task.id !== props.currentTaskId &&
!selectedPredecessorIds.value.includes(task.id), !selectedPredecessorIds.value.includes(task.id)
) )
}) })
@@ -69,13 +69,13 @@ watch(
() => { () => {
selectedValue.value = '' selectedValue.value = ''
}, },
{ immediate: true }, { immediate: true }
) )
</script> </script>
<template> <template>
<div class="multi-select-predecessor"> <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 class="predecessor-selector">
<!-- 已选择的前置任务标签 --> <!-- 已选择的前置任务标签 -->
<div v-if="selectedPredecessors.length > 0" class="selected-tags"> <div v-if="selectedPredecessors.length > 0" class="selected-tags">
@@ -89,7 +89,12 @@ watch(
<!-- 下拉选择器 --> <!-- 下拉选择器 -->
<div class="select-wrapper"> <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 value="">{{ placeholder }}</option>
<option v-for="task in availableTasks" :key="task.id" :value="task.id"> <option v-for="task in availableTasks" :key="task.id" :value="task.id">
{{ task.name }} (ID: {{ 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 { ref, computed, onUnmounted, onMounted, nextTick, watch } from 'vue'
import type { Task } from '../models/classes/Task' import type { Task } from '../models/classes/Task'
import { TimelineScale } from '../models/types/TimelineScale' import { TimelineScale } from '../models/types/TimelineScale'
import TaskContextMenu from './TaskContextMenu.vue'
import { useI18n } from '../composables/useI18n'
interface Props { interface Props {
task: Task task: Task
@@ -44,9 +41,14 @@ const emit = defineEmits([
'update:task', 'update:task',
'bar-mounted', 'bar-mounted',
'dblclick', 'dblclick',
'drag-end', // 'drag-end',
'resize-end', // 'resize-end',
'scroll-to-position', // 'scroll-to-position',
'start-timer',
'stop-timer',
'add-predecessor',
'add-successor',
'delete',
]) ])
// - // -
@@ -234,9 +236,7 @@ const taskStatus = computed(() => {
}) })
// //
const isCompleted = computed(() => { const isCompleted = computed(() => (props.task.progress || 0) >= 100)
return (props.task.progress || 0) >= 100
})
// //
const progressWidth = computed(() => { const progressWidth = computed(() => {
@@ -448,10 +448,14 @@ onMounted(() => {
window.addEventListener('timeline-scale-updated', handleTimelineScaleUpdate) window.addEventListener('timeline-scale-updated', handleTimelineScaleUpdate)
window.addEventListener('timeline-force-recalculate', handleForceRecalculate) window.addEventListener('timeline-force-recalculate', handleForceRecalculate)
//
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
// //
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('timeline-scale-updated', handleTimelineScaleUpdate) window.removeEventListener('timeline-scale-updated', handleTimelineScaleUpdate)
window.removeEventListener('timeline-force-recalculate', handleForceRecalculate) 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 => { const formatDisplayDate = (dateStr: string | undefined): string => {
if (!dateStr) return t('dateNotSet') if (!dateStr) return '未设置'
const date = createLocalDate(dateStr) const date = createLocalDate(dateStr)
if (!date) return t('dateNotSet') if (!date) return '未设置'
const year = date.getFullYear() const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0') const month = String(date.getMonth() + 1).padStart(2, '0')
@@ -975,7 +979,41 @@ const calculatePositionFromTimelineData = (
return cumulativePosition // 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> </script>
<template> <template>
@@ -999,6 +1037,7 @@ const calculatePositionFromTimelineData = (
'short-task-bar': isShortTaskBar, 'short-task-bar': isShortTaskBar,
'overflow-effect': needsOverflowEffect, 'overflow-effect': needsOverflowEffect,
}" }"
@contextmenu="handleContextMenu"
@dblclick="handleTaskBarDoubleClick" @dblclick="handleTaskBarDoubleClick"
> >
<!-- 父级任务的标签 --> <!-- 父级任务的标签 -->
@@ -1059,6 +1098,18 @@ const calculatePositionFromTimelineData = (
@mousedown="handleBubbleMouseDown" @mousedown="handleBubbleMouseDown"
@click="handleBubbleClick" @click="handleBubbleClick"
></div> ></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> </div>
<!-- Tooltip 弹窗 --> <!-- Tooltip 弹窗 -->
@@ -1075,23 +1126,23 @@ const calculatePositionFromTimelineData = (
<div class="tooltip-title">{{ task.name }}</div> <div class="tooltip-title">{{ task.name }}</div>
<div class="tooltip-content"> <div class="tooltip-content">
<div class="tooltip-row"> <div class="tooltip-row">
<span class="tooltip-label"> {{ t('startDate') }}:</span> <span class="tooltip-label">计划开始:</span>
<span class="tooltip-value">{{ formatDisplayDate(task.startDate) }}</span> <span class="tooltip-value">{{ formatDisplayDate(task.startDate) }}</span>
</div> </div>
<div class="tooltip-row"> <div class="tooltip-row">
<span class="tooltip-label">{{ t('endDate') }}:</span> <span class="tooltip-label">计划结束:</span>
<span class="tooltip-value">{{ formatDisplayDate(task.endDate) }}</span> <span class="tooltip-value">{{ formatDisplayDate(task.endDate) }}</span>
</div> </div>
<div class="tooltip-row"> <div class="tooltip-row">
<span class="tooltip-label">{{ t('estimatedHours') }}:</span> <span class="tooltip-label">计划工时:</span>
<span class="tooltip-value">{{ workHourInfo.total }}h</span> <span class="tooltip-value">{{ workHourInfo.total }}h</span>
</div> </div>
<div class="tooltip-row"> <div class="tooltip-row">
<span class="tooltip-label"> {{ t('actualHours') }}:</span> <span class="tooltip-label">已用工时:</span>
<span class="tooltip-value">{{ workHourInfo.used }}h</span> <span class="tooltip-value">{{ workHourInfo.used }}h</span>
</div> </div>
<div class="tooltip-row"> <div class="tooltip-row">
<span class="tooltip-label"> {{ t('progress') }}:</span> <span class="tooltip-label">完成率:</span>
<span class="tooltip-value">{{ task.progress || 0 }}%</span> <span class="tooltip-value">{{ task.progress || 0 }}%</span>
</div> </div>
</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) {
// falsestory
emit('delete', props.task, false) // falsestory
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 DatePicker from './DatePicker.vue'
import GanttConfirmDialog from './GanttConfirmDialog.vue' import GanttConfirmDialog from './GanttConfirmDialog.vue'
import MultiSelectPredecessor from './MultiSelectPredecessor.vue' import MultiSelectPredecessor from './MultiSelectPredecessor.vue'
import ConfirmTimerDialog from './ConfirmTimerDialog.vue'
import type { Task } from '../models/classes/Task' import type { Task } from '../models/classes/Task'
import '../styles/app.css' import '../styles/app.css'
@@ -27,6 +28,8 @@ const emit = defineEmits<{
submit: [task: Task] submit: [task: Task]
close: [] close: []
delete: [task: Task, deleteChildren?: boolean] delete: [task: Task, deleteChildren?: boolean]
'start-timer': [task: Task]
'stop-timer': [task: Task]
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@@ -36,6 +39,78 @@ const submitting = ref(false)
const isVisible = ref(props.visible) const isVisible = ref(props.visible)
const showDeleteConfirm = ref(false) 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 isTimerRunningtimerStartTimetimerElapsedTime 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 // dialog
const dialogType = computed(() => { const dialogType = computed(() => {
return props.task?.type === 'story' ? 'yes-no-cancel' : 'confirm-cancel' return props.task?.type === 'story' ? 'yes-no-cancel' : 'confirm-cancel'
@@ -63,6 +138,10 @@ const formData = reactive<Task>({
progress: 0, progress: 0,
description: '', description: '',
parentId: undefined, // ID parentId: undefined, // ID
isTimerRunning: false, //
timerStartTime: undefined, //
timerEndTime: undefined, //
timerElapsedTime: 0, //
}) })
// //
@@ -74,7 +153,7 @@ const availableParentTasks = computed(() => {
.filter( .filter(
task => task =>
task.id !== props.task?.id && // task.id !== props.task?.id && //
(task.type === 'story' || task.type === 'task'), // storytask (task.type === 'story' || task.type === 'task') // storytask
) )
.map(task => ({ .map(task => ({
...task, ...task,
@@ -195,11 +274,16 @@ watch(
if (props.task && props.isEdit) { if (props.task && props.isEdit) {
// //
Object.assign(formData, props.task) 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')) window.dispatchEvent(new CustomEvent('request-task-list'))
} }
}, }
) )
// isVisible // isVisible
@@ -207,6 +291,18 @@ watch(isVisible, newVal => {
emit('update:visible', 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 = () => { const resetForm = () => {
Object.assign(formData, { Object.assign(formData, {
@@ -221,6 +317,10 @@ const resetForm = () => {
progress: 0, progress: 0,
description: '', description: '',
parentId: undefined, 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(), id: props.isEdit && props.task ? props.task.id : Date.now(),
} }
emit('submit', taskData) emit('submit', taskData)
showMessage(props.isEdit ? t.value.taskUpdateSuccess : t.value.taskCreateSuccess, 'success')
handleClose() handleClose()
} catch (error) { } catch (error) {
// //
showMessage(t.value.operationFailed, 'error')
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -298,6 +396,21 @@ const handleDelete = () => {
showDeleteConfirm.value = true 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 = () => { const confirmDelete = () => {
showDeleteConfirm.value = false showDeleteConfirm.value = false
if (props.task && props.isEdit) { if (props.task && props.isEdit) {
@@ -306,7 +419,7 @@ const confirmDelete = () => {
emit('delete', props.task) emit('delete', props.task)
handleClose() handleClose()
} catch (error) { } catch (error) {
showMessage(t.value.taskDeleteFailed, 'error') handleError(error)
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -326,14 +439,13 @@ const handleDeleteYes = () => {
emit('delete', props.task, true) // true emit('delete', props.task, true) // true
handleClose() handleClose()
} catch (error) { } catch (error) {
showMessage(t.value.taskDeleteFailed, 'error') handleError(error)
} finally { } finally {
submitting.value = false submitting.value = false
} }
} }
} }
// Story"" - story
// Story"" - story // Story"" - story
const handleDeleteNo = () => { const handleDeleteNo = () => {
showDeleteConfirm.value = false showDeleteConfirm.value = false
@@ -343,7 +455,7 @@ const handleDeleteNo = () => {
emit('delete', props.task, false) // falsestory emit('delete', props.task, false) // falsestory
handleClose() handleClose()
} catch (error) { } catch (error) {
showMessage(t.value.taskDeleteFailed, 'error') handleError(error)
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -373,18 +485,193 @@ watch(
newValue => { newValue => {
progressDisplayValue.value = (newValue || 0).toString() 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> </script>
<template> <template>
<div v-if="isVisible" class="drawer-overlay" @click="handleOverlayClick"> <div v-if="isVisible" class="drawer-overlay" @click="handleOverlayClick">
<div class="drawer-container" @click.stop> <div class="drawer-container" @click.stop>
<!-- Drawer Header --> <!-- Drawer Header -->
<div class="drawer-header"> <div
<h3 class="drawer-title">{{ isEdit ? t.editTask : t.newTask }}</h3> 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"> <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="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line> <line x1="6" y1="6" x2="18" y2="18"></line>
</svg> </svg>
@@ -426,15 +713,15 @@ watch(
<span v-if="errors.type" class="error-text">{{ errors.type }}</span> <span v-if="errors.type" class="error-text">{{ errors.type }}</span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="task-assignee">{{ t.assignee }}</label> <label class="form-label" for="task-assignee">{{ t.assignee }}</label>
<select id="task-assignee" v-model="formData.assignee" class="form-select"> <select id="task-assignee" v-model="formData.assignee" class="form-select">
<option value="">{{ t.selectAssignee }}</option> <option value="">{{ t.selectAssignee }}</option>
<!-- <option value="张三">张三</option> <option value="张三">张三</option>
<option value="李四">李四</option> <option value="李四">李四</option>
<option value="王五">王五</option> <option value="王五">王五</option>
<option value="赵六">赵六</option> <option value="赵六">赵六</option>
<option value="钱七">钱七</option>--> <option value="钱七">钱七</option>
</select> </select>
</div> </div>
@@ -606,6 +893,17 @@ watch(
</div> </div>
</div> </div>
</div> </div>
<!-- 计时器确认弹窗 -->
<ConfirmTimerDialog
v-if="showTimerConfirm"
:visible="showTimerConfirm"
:title="'确认开始计时'"
:message="`即将为任务${props.task?.name}计时,若有特殊说明请完善下面的描述`"
:default-desc="props.task?.name || ''"
@confirm="confirmTimer"
@cancel="cancelTimerConfirm"
/>
</template> </template>
<style scoped> <style scoped>
@@ -819,10 +1117,6 @@ watch(
background: var(--gantt-primary, #409eff); background: var(--gantt-primary, #409eff);
} }
.progress-slider::-moz-range-track {
background: var(--gantt-border-light, #e4e7ed);
}
/* 为更好的兼容性,添加一个伪元素来显示已完成部分 */ /* 为更好的兼容性,添加一个伪元素来显示已完成部分 */
.progress-slider::before { .progress-slider::before {
content: ''; content: '';
+56 -1
View File
@@ -14,7 +14,18 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
// emit // 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() 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 () => { onMounted(async () => {
window.addEventListener('task-updated', handleTaskUpdated as EventListener) window.addEventListener('task-updated', handleTaskUpdated as EventListener)
window.addEventListener('task-added', handleTaskAdded as EventListener) window.addEventListener('task-added', handleTaskAdded as EventListener)
@@ -354,6 +403,12 @@ onUnmounted(() => {
:on-hover="handleTaskRowHover" :on-hover="handleTaskRowHover"
@toggle="toggleCollapse" @toggle="toggleCollapse"
@dblclick="handleTaskRowDoubleClick" @dblclick="handleTaskRowDoubleClick"
@contextmenu="handleTaskRowContextMenu"
@start-timer="handleStartTimer"
@stop-timer="handleStopTimer"
@add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor"
@delete="handleTaskDelete"
/> />
</div> </div>
</div> </div>
+179 -2
View File
@@ -1,8 +1,9 @@
<script setup lang="ts"> <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 { useI18n } from '../composables/useI18n'
import { formatPredecessorDisplay } from '../utils/predecessorUtils' import { formatPredecessorDisplay } from '../utils/predecessorUtils'
import type { Task } from '../models/classes/Task' import type { Task } from '../models/classes/Task'
import TaskContextMenu from './TaskContextMenu.vue'
interface Props { interface Props {
task: Task task: Task
@@ -13,7 +14,16 @@ interface Props {
onHover?: (taskId: number | null) => void onHover?: (taskId: number | null) => void
} }
const props = defineProps<Props>() 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 { t } = useI18n()
const overtimeText = computed(() => t.value?.overtime ?? '') const overtimeText = computed(() => t.value?.overtime ?? '')
const overdueText = computed(() => t.value?.overdue ?? '') const overdueText = computed(() => t.value?.overdue ?? '')
@@ -129,15 +139,101 @@ const handleSplitterDragEnd = () => {
isSplitterDragging.value = false 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(() => { onMounted(() => {
window.addEventListener('splitter-drag-start', handleSplitterDragStart) window.addEventListener('splitter-drag-start', handleSplitterDragStart)
window.addEventListener('splitter-drag-end', handleSplitterDragEnd) window.addEventListener('splitter-drag-end', handleSplitterDragEnd)
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('splitter-drag-start', handleSplitterDragStart) window.removeEventListener('splitter-drag-start', handleSplitterDragStart)
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd) window.removeEventListener('splitter-drag-end', handleSplitterDragEnd)
window.removeEventListener('close-all-taskbar-menus', closeContextMenu)
if (timerInterval.value) {
clearInterval(timerInterval.value)
}
}) })
</script> </script>
@@ -160,6 +256,7 @@ onUnmounted(() => {
@dblclick="handleTaskRowDoubleClick" @dblclick="handleTaskRowDoubleClick"
@mouseenter="handleMouseEnter" @mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave" @mouseleave="handleMouseLeave"
@contextmenu="handleContextMenu"
> >
<div class="col col-name" :style="{ paddingLeft: indent }"> <div class="col col-name" :style="{ paddingLeft: indent }">
<span <span
@@ -245,6 +342,15 @@ onUnmounted(() => {
:title="props.task.name" :title="props.task.name"
> >
{{ 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="isOvertime()" class="status-badge overtime">{{ overtimeText }}</span>
<span v-if="overdueDays() > 0" class="status-badge overdue"> <span v-if="overdueDays() > 0" class="status-badge overdue">
{{ overdueText }}{{ overdueDays() > 0 ? overdueDays() + daysText : '' }} {{ overdueText }}{{ overdueDays() > 0 ? overdueDays() + daysText : '' }}
@@ -299,8 +405,25 @@ onUnmounted(() => {
:on-hover="props.onHover" :on-hover="props.onHover"
@toggle="emit('toggle', $event)" @toggle="emit('toggle', $event)"
@dblclick="emit('dblclick', $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> </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> </div>
</template> </template>
@@ -720,4 +843,58 @@ onUnmounted(() => {
0 6px 16px rgba(246, 124, 124, 0.4), 0 6px 16px rgba(246, 124, 124, 0.4),
0 2px 8px rgba(255, 255, 255, 0.1); 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> </style>
+47 -79
View File
@@ -2,7 +2,6 @@
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue' import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
import TaskBar from './TaskBar.vue' import TaskBar from './TaskBar.vue'
import MilestonePoint from './MilestonePoint.vue' import MilestonePoint from './MilestonePoint.vue'
import TaskDrawer from './TaskDrawer.vue'
import MilestoneDialog from './MilestoneDialog.vue' import MilestoneDialog from './MilestoneDialog.vue'
import { useI18n } from '../composables/useI18n' import { useI18n } from '../composables/useI18n'
import { getPredecessorIds } from '../utils/predecessorUtils' import { getPredecessorIds } from '../utils/predecessorUtils'
@@ -47,6 +46,12 @@ const props = withDefaults(defineProps<Props>(), {
// emits // emits
const emit = defineEmits<{ const emit = defineEmits<{
'timeline-scale-changed': [scale: TimelineScale] '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 } return { startDate, endDate }
} }
//
const drawerVisible = ref(false)
const currentTask = ref<Task | null>(null)
const isEditMode = ref(false)
// //
const milestoneDialogVisible = ref(false) const milestoneDialogVisible = ref(false)
const currentMilestone = ref<Milestone | null>(null) const currentMilestone = ref<Milestone | null>(null)
@@ -861,44 +861,9 @@ const updateTask = (updatedTask: Task) => {
) )
} }
// TaskBar - API // TaskBar - emit
const handleTaskBarDoubleClick = (task: Task) => { const handleTaskBarDoubleClick = (task: Task) => {
// emit('edit-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
} }
// TaskBar // TaskBar
@@ -946,6 +911,16 @@ const handleTaskBarResizeEnd = (updatedTask: Task) => {
window.dispatchEvent(new CustomEvent('taskbar-resize-end', { detail: updatedTask })) 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 // TaskBar
const handleScrollToPosition = (targetScrollLeft: number) => { const handleScrollToPosition = (targetScrollLeft: number) => {
if (timelineContainer.value) { if (timelineContainer.value) {
@@ -1386,31 +1361,8 @@ onUnmounted(() => {
document.removeEventListener('mouseup', handleMouseUp) document.removeEventListener('mouseup', handleMouseUp)
}) })
const handleTaskDelete = (taskId: number, deleteChildren?: boolean) => { const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
// emit('delete', task, deleteChildren)
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)
} }
// //
@@ -1442,6 +1394,16 @@ defineExpose({
// //
updateTimeScale, updateTimeScale,
}) })
//
const handleStartTimer = (task: Task) => {
emit('start-timer', task)
}
//
const handleStopTimer = (task: Task) => {
emit('stop-timer', task)
}
// TaskMilestone, Milestone // TaskMilestone, Milestone
const convertTaskToMilestone = (task: Task): Milestone => { const convertTaskToMilestone = (task: Task): Milestone => {
// startDate string undefined // startDate string undefined
@@ -1627,6 +1589,16 @@ const generateMonthTimelineData = () => {
return result return result
} }
//
const handleAddPredecessor = (task: Task) => {
emit('add-predecessor', task)
}
//
const handleAddSuccessor = (task: Task) => {
emit('add-successor', task)
}
</script> </script>
<template> <template>
@@ -1648,7 +1620,7 @@ const generateMonthTimelineData = () => {
class="timeline-year" class="timeline-year"
:style="{ width: '719px' }" :style="{ width: '719px' }"
> >
<div class="year-label">{{ yearValue }}</div> <div class="year-label">{{ yearValue }}</div>
</div> </div>
</div> </div>
@@ -1913,22 +1885,18 @@ const generateMonthTimelineData = () => {
@drag-end="handleTaskBarDragEnd" @drag-end="handleTaskBarDragEnd"
@resize-end="handleTaskBarResizeEnd" @resize-end="handleTaskBarResizeEnd"
@scroll-to-position="handleScrollToPosition" @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> </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 里程碑对话框组件 --> <!-- Milestone Dialog 里程碑对话框组件 -->
<MilestoneDialog <MilestoneDialog
v-model:visible="milestoneDialogVisible" 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 MilestoneDialog } from './MilestoneDialog.vue'
export { default as TaskDrawer } from './TaskDrawer.vue' export { default as TaskDrawer } from './TaskDrawer.vue'
export { default as DatePicker } from './DatePicker.vue' export { default as DatePicker } from './DatePicker.vue'
export { default as TaskContextMenu } from './TaskContextMenu.vue'
+22 -37
View File
@@ -6,7 +6,6 @@ export type Locale = 'zh-CN' | 'en-US'
// 多语言配置 // 多语言配置
const messages = { const messages = {
'zh-CN': { 'zh-CN': {
dateNotSet: '未设置',
// TaskList Header // TaskList Header
taskName: '任务名称', taskName: '任务名称',
predecessor: '前置任务', predecessor: '前置任务',
@@ -39,26 +38,8 @@ const messages = {
// 月份格式 // 月份格式
monthFormat: (month: number) => `${month}`, monthFormat: (month: number) => `${month}`,
// 月份名称
monthNames: [
'一月',
'二月',
'三月',
'四月',
'五月',
'六月',
'七月',
'八月',
'九月',
'十月',
'十一月',
'十二月',
],
weekDays:['日', '一', '二', '三', '四', '五', '六'],
// 其他 // 其他
milestone: '里程碑', milestone: '里程碑',
targetDate: '目标日期',
today: '今天', today: '今天',
// 里程碑对话框 // 里程碑对话框
milestoneDetails: '里程碑详情', milestoneDetails: '里程碑详情',
@@ -116,6 +97,13 @@ const messages = {
newMilestone: '新建里程碑', newMilestone: '新建里程碑',
editMilestone: '编辑里程碑', editMilestone: '编辑里程碑',
cancel: '取消', cancel: '取消',
// 右键菜单
startTimer: '开始计时',
stopTimer: '停止计时',
timerStarted: '任务计时已开始',
timerStopped: '任务计时已停止',
addPredecessor: '添加前置任务',
addSuccessor: '添加后置任务',
// PDF导出相关 // PDF导出相关
pdfExportLoading: '正在生成PDF,请稍候...', pdfExportLoading: '正在生成PDF,请稍候...',
@@ -175,9 +163,12 @@ const messages = {
overtime: '超', overtime: '超',
overdue: '逾期', overdue: '逾期',
days: '天', days: '天',
// 计时确认弹窗
timerConfirmPrefix: '即将为任务',
timerConfirmSuffix: '计时,若有特殊说明请完善下面的描述',
timerConfirmPlaceholder: '请输入计时说明',
}, },
'en-US': { 'en-US': {
dateNotSet:'Not set',
// TaskList Header // TaskList Header
taskName: 'Task Name', taskName: 'Task Name',
predecessor: 'Predecessor', predecessor: 'Predecessor',
@@ -208,25 +199,8 @@ const messages = {
// 月份格式 // 月份格式
monthFormat: (month: number) => `M${String(month).padStart(2, '0')}`, monthFormat: (month: number) => `M${String(month).padStart(2, '0')}`,
// Month name
monthNames: [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
],
weekDays:['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
// 其他 // 其他
milestone: 'Milestone', milestone: 'Milestone',
targetDate: 'Target Date',
today: 'Today', today: 'Today',
// 里程碑对话框 // 里程碑对话框
milestoneDetails: 'Milestone Details', milestoneDetails: 'Milestone Details',
@@ -283,6 +257,13 @@ const messages = {
newMilestone: 'New Milestone', newMilestone: 'New Milestone',
editMilestone: 'Edit Milestone', editMilestone: 'Edit Milestone',
cancel: 'Cancel', cancel: 'Cancel',
// Context menu
timerStarted: 'Timer Started',
timerStopped: 'Timer Stopped',
startTimer: 'Start Timer',
stopTimer: 'Stop Timer',
addPredecessor: 'Add Predecessor',
addSuccessor: 'Add Successor',
// PDF export // PDF export
pdfExportLoading: 'Generating PDF, please wait...', pdfExportLoading: 'Generating PDF, please wait...',
@@ -346,6 +327,10 @@ const messages = {
overtime: 'Over', overtime: 'Over',
overdue: 'Overdue', overdue: 'Overdue',
days: ' days', days: ' days',
// 计时确认弹窗
timerConfirmPrefix: 'About to start timing for',
timerConfirmSuffix: '. If there are special notes, please complete the description below.',
timerConfirmPlaceholder: 'Please enter timer description',
}, },
} }
+6
View File
@@ -17,4 +17,10 @@ export interface Task {
description?: string description?: string
icon?: string icon?: string
level?: number level?: number
// 计时相关字段
isTimerRunning?: boolean
timerStartTime?: number
timerEndTime?: number // 结束计时时间
timerStartDesc?: string // 计时开始时填写的描述
timerElapsedTime?: number
} }