v1.3.0 - add Annual/Quarter/Hourly timeline views

This commit is contained in:
LINING-PC\lining
2025-09-12 16:41:32 +08:00
parent bb0a00ad05
commit 281c2d9c5b
27 changed files with 4253 additions and 454 deletions
+20
View File
@@ -5,6 +5,26 @@ 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.3.0] - 2025-09-12
### Added
- 增加年度视图
- 增加季度视图
- 增加小时视图
- Add annual timeline view
- Add a quarterly timeline view
- Add hourly timeline view
### Changed
- 升级TaskDrawer中DatePicker组件,接受小时的设定
- 升级TaskDrawer中预计时间和实际时间的控件,接受小数后两位的录入
- Upgrade the DatePicker component in TaskDrawer to accept the setting of hours
- Upgrade the control for estimated and actual time in TaskDrawer to accept entries with two decimal places
### Fixed
- SonarQube代码质量检查问题修改
- Modification of SonarQube code quality inspection issues
## [1.2.1] - 2025-07-22 ## [1.2.1] - 2025-07-22
### Fixed ### Fixed
+228 -28
View File
@@ -60,25 +60,70 @@ pnpm add jordium-gantt-vue3
``` ```
jordium-gantt-vue3/ jordium-gantt-vue3/
├── src/ # Source code directory ├── src/ # Source code directory
│ ├── components/ # Core components │ ├── components/ # Core Vue components
│ │ ├── GanttChart.vue # Main entry component │ │ ├── GanttChart.vue # Main entry component
│ │ ├── TaskList.vue # Task list │ │ ├── TaskList.vue # Task list component
│ │ ├── Timeline.vue # Timeline │ │ ├── Timeline.vue # Timeline component
│ │ ├── TaskBar.vue # Task bar │ │ ├── TaskBar.vue # Task bar component
│ │ ├── MilestonePoint.vue # Milestone │ │ ├── TaskDrawer.vue # Task edit drawer
│ │ ── ... # Other components │ │ ── TaskContextMenu.vue # Task context menu
│ ├── models/ # Data models │ ├── GanttToolbar.vue # Toolbar component
│ │ ├── classes/ # Class definitions │ │ ├── MilestonePoint.vue # Milestone point
│ │ ── configs/ # Configuration interfaces │ │ ── MilestoneDialog.vue # Milestone dialog
│ ├── composables/ # Composable functions │ ├── DatePicker.vue # Date picker
├── styles/ # Style files │ └── ... # Other components
── index.ts # Export entry ── models/ # Data models and configurations
├── demo/ # Development demo │ │ ├── classes/ # Class definitions
├── dist/ # Build output │ │ │ ├── Task.ts # Task model
├── docs/ # Documentation │ │ │ ├── Milestone.ts # Milestone model
└── package.json │ │ │ └── Language.ts # Language configuration
│ │ ├── configs/ # Configuration interfaces
│ │ │ ├── TimelineConfig.ts # Timeline configuration
│ │ │ └── ToolbarConfig.ts # Toolbar configuration
│ │ └── types/ # Type definitions
│ │ └── TimelineScale.ts # Timeline scale types
│ ├── composables/ # Vue composable functions
│ │ ├── useI18n.ts # Internationalization utilities
│ │ └── useMessage.ts # Message utilities
│ ├── styles/ # Style files
│ │ ├── app.css # Main styles
│ │ └── theme-variables.css # Theme variables
│ ├── utils/ # Utility functions
│ │ └── predecessorUtils.ts # Predecessor utilities
│ └── index.ts # Export entry
├── demo/ # Development demo & interactive showcase
│ ├── App.vue # Demo application main component
│ ├── data.json # Demo data (includes clinical trial examples)
│ ├── main.ts # Demo application entry
│ └── ... # Other demo files
├── packageDemo/ # npm package integration demo
├── dist/ # Build output directory
├── docs/ # Documentation
├── design/ # Design resources and screenshots
│ └── screenshots/ # Theme screenshots
├── public/ # Public static resources
│ └── assets/ # Static asset files
├── README.md # Chinese documentation
├── README-EN.md # English documentation
├── package.json # Project configuration
├── vite.config.ts # Vite development configuration
├── vite.config.lib.ts # Vite library build configuration
├── tsconfig.json # TypeScript configuration
└── ... # Other configuration files and metadata
``` ```
### Directory Description
- **`src/components/`**: Core Vue components containing all Gantt chart functionality
- **`src/models/`**: Data models, type definitions and configuration interfaces
- **`src/composables/`**: Vue 3 composable functions providing reusable logic
- **`src/styles/`**: Style files including theme system and CSS variables
- **`src/utils/`**: Utility functions for business logic and data transformation
- **`demo/`**: Local development and feature demonstration with complete interactive pages and clinical trial sample data
- **`packageDemo/`**: Simulates npm package integration in external projects
- **`dist/`**: Build output directory for npm publishing or static sites
- **`docs/`**: Project documentation including deployment guides and API references
## 🔧 API Reference ## 🔧 API Reference
### GanttChart Properties ### GanttChart Properties
@@ -92,8 +137,9 @@ jordium-gantt-vue3/
| `showToolbar` | `boolean` | `true` | Show toolbar | | `showToolbar` | `boolean` | `true` | Show toolbar |
| `toolbarConfig` | `ToolbarConfig` | `{}` | Toolbar configuration | | `toolbarConfig` | `ToolbarConfig` | `{}` | Toolbar configuration |
| `localeMessages` | `Partial<Messages['zh-CN']>` | - | Custom locale messages | | `localeMessages` | `Partial<Messages['zh-CN']>` | - | Custom locale messages |
| `workingHours` | `WorkingHours` | - | Working hours configuration |
| `onTaskDoubleClick` | `(task: Task) => void` | - | Task double-click event callback | | `onTaskDoubleClick` | `(task: Task) => void` | - | Task double-click event callback |
| `onTaskDelete` | `(task: Task) => void` | - | Task delete event callback | | `onTaskDelete` | `(task: Task, deleteChildren?: boolean) => void` | - | Task delete event callback |
| `onTaskUpdate` | `(task: Task) => void` | - | Task update event callback | | `onTaskUpdate` | `(task: Task) => void` | - | Task update event callback |
| `onTaskAdd` | `(task: Task) => void` | - | Task add event callback | | `onTaskAdd` | `(task: Task) => void` | - | Task add event callback |
| `onMilestoneSave` | `(milestone: Task) => void` | - | Milestone save event callback | | `onMilestoneSave` | `(milestone: Task) => void` | - | Milestone save event callback |
@@ -184,16 +230,16 @@ function onTaskUpdated(e) {
**Task Type** **Task Type**
```typescript ```typescript
export interface Task { export interface Task {
id: number // Unique task ID id: number // Unique task ID
name: string // Task name name: string // Task name
predecessor?: number[] // Predecessor task ID array predecessor?: number[] // Predecessor task ID array
assignee?: string // Assignee assignee?: string // Assignee
startDate?: string // Start date (ISO string) startDate?: string // Start date (ISO string)
endDate?: string // End date (ISO string) endDate?: string // End date (ISO string)
progress?: number // Progress percentage 0-100 progress?: number // Progress percentage 0-100
estimatedHours?: number // Estimated hours estimatedHours?: number // Estimated hours (supports decimal, up to 2 decimal places)
actualHours?: number // Actual hours actualHours?: number // Actual hours (supports decimal, up to 2 decimal places)
parentId?: number // Parent task ID parentId?: number // Parent task ID
children?: Task[] // Subtask array children?: Task[] // Subtask array
collapsed?: boolean // Collapsed state collapsed?: boolean // Collapsed state
isParent?: boolean // Is parent task isParent?: boolean // Is parent task
@@ -253,6 +299,86 @@ interface ToolbarConfig {
} }
``` ```
**WorkingHours Configuration**
```typescript
interface WorkingHours {
morning?: { start: number; end: number } // Morning work hours, e.g. { start: 8, end: 11 }
afternoon?: { start: number; end: number } // Afternoon work hours, e.g. { start: 13, end: 17 }
}
```
**TimelineScale Types**
```typescript
// Timeline display scale types
type TimelineScale = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'
// Timeline scale constants
export const TimelineScale = {
HOUR: 'hour', // Hour view - each column displays one hour
DAY: 'day', // Day view - each column displays one day
WEEK: 'week', // Week view - each column displays one week
MONTH: 'month', // Month view - each column displays one month
QUARTER: 'quarter', // Quarter view - each column displays one quarter
YEAR: 'year', // Year view - each column displays one year
}
// Timeline scale configuration
interface TimelineScaleConfig {
scale: TimelineScale // Scale type
cellWidth: number // Width of each time unit (px)
headerLevels: number // Number of header levels
formatters: {
primary: string // Primary time label format
secondary?: string // Secondary time label format
}
}
```
### 🕐 Timeline Scale Features
The component supports multiple timeline scale displays. Users can switch timeline granularity through the Day/Week/Month button group in the toolbar or programmatically:
#### Built-in Scale Configurations
| Scale Type | Cell Width | Primary Format | Secondary Format | Use Case |
|------------|------------|----------------|------------------|----------|
| `hour` | 40px | yyyy/MM/dd | HH | Short-term projects with hourly precision, such as drug clinical trials |
| `day` | 30px | yyyy/MM | dd | Standard view for daily project management |
| `week` | 120px | yyyy/MM | W | Weekly planning view for medium-term projects |
| `month` | 180px | yyyy | MM | Monthly view for long-term projects |
| `quarter` | 360px | yyyy | Q | Quarterly view for strategic planning |
| `year` | 360px | yyyy | First Half\|Second Half | Annual view for very long-term projects |
#### Usage Example
```vue
<script setup>
import { ref } from 'vue'
import { GanttChart, TimelineScale } from 'jordium-gantt-vue3'
const tasks = ref([/* task data */])
// Toolbar configuration - enable timeline scale toggle buttons
const toolbarConfig = {
showTimeScale: true // Display Day|Week|Month button group
}
// Listen to scale changes (optional)
const handleTimeScaleChange = (scale) => {
console.log('Timeline scale changed to:', scale)
// Business logic can be added here, such as saving user preferences
}
</script>
<template>
<GanttChart
:tasks="tasks"
:toolbar-config="toolbarConfig"
@timescale-changed="handleTimeScaleChange"
/>
</template>
```
#### Composable Functions (src/composables) #### Composable Functions (src/composables)
**useI18n Internationalization Tool** **useI18n Internationalization Tool**
@@ -399,6 +525,80 @@ const customLocaleMessages = {
addTask: 'Custom Add Task' addTask: 'Custom Add Task'
} }
// Handle toolbar events
const handleLanguageChange = (lang) => {
console.log('Language switched to:', lang)
}
const handleThemeChange = (isDark) => {
console.log('Theme switched to:', isDark ? 'dark' : 'light')
}
// Listen to timeline scale changes
const handleTimeScaleChange = (scale) => {
console.log('Timeline scale changed to:', scale)
// Adjust display logic based on scale
if (scale === 'day') {
// Special handling for day view
} else if (scale === 'week') {
// Special handling for week view
}
}
</script>
<template>
<GanttChart
:tasks="tasks"
:milestones="milestones"
:toolbar-config="toolbarConfig"
:locale-messages="customLocaleMessages"
:on-language-change="handleLanguageChange"
:on-theme-change="handleThemeChange"
@timescale-changed="handleTimeScaleChange"
/>
</template>
```
### 🔧 Working Hours Configuration
The component supports setting working hours, affecting task duration calculations and progress display:
```vue
<script setup lang="ts">
// Configure working hours (24-hour format)
const workingHours = {
morning: { start: 9, end: 12 }, // 9 AM - 12 PM
afternoon: { start: 14, end: 18 } // 2 PM - 6 PM
}
</script>
<template>
<GanttChart
:tasks="tasks"
:working-hours="workingHours"
/>
</template>
```
### 📊 High-Precision Work Hours Management
The component supports work hour recording precise to 2 decimal places, suitable for projects requiring precise billing:
```vue
<script setup lang="ts">
const tasks = ref([
{
id: 1,
name: 'High-precision Task',
estimatedHours: 8.75, // 8 hours 45 minutes
actualHours: 7.25, // 7 hours 15 minutes
startDate: '2025-01-01',
endDate: '2025-01-02'
}
])
</script>
```
// Handle toolbar events // Handle toolbar events
const handleLanguageChange = (lang) => { const handleLanguageChange = (lang) => {
console.log('Language changed to:', lang) console.log('Language changed to:', lang)
+228 -40
View File
@@ -60,28 +60,70 @@ pnpm add jordium-gantt-vue3
``` ```
jordium-gantt-vue3/ jordium-gantt-vue3/
├── src/ # 组件源码与核心逻辑 ├── src/ # 组件源码与核心逻辑
│ ├── components/ # 主要 Vue 组件 │ ├── components/ # 主要 Vue 组件
│ ├── models/ # 数据类型与配置 │ ├── GanttChart.vue # 主入口组件
│ ├── composables/ # 组合式函数 │ ├── TaskList.vue # 任务列表
│ ├── styles/ # 样式文 │ ├── Timeline.vue # 时间轴组
└── index.ts # 入口导出 │ ├── TaskBar.vue # 任务条
├── demo/ # 组件开发与交互演示(本地开发/预览用) │ │ ├── TaskDrawer.vue # 任务编辑抽屉
├── packageDemo/ # npm 包集成演示(模拟外部项目集成效果) │ │ ├── TaskContextMenu.vue # 任务右键菜单
├── dist/ # 构建产物(发布/静态站点/打包输出) │ │ ├── GanttToolbar.vue # 工具栏
├── docs/ # 相关文档(如部署、API 说明等) │ │ ├── MilestonePoint.vue # 里程碑点
├── design/ # 设计资源与截图 │ │ ├── MilestoneDialog.vue # 里程碑对话框
├── public/ # 公共静态资源 │ │ ├── DatePicker.vue # 日期选择器
├── README.md # 中文说明文档 │ │ └── ... # 其他组件
├── README-EN.md # 英文说明文档 │ ├── models/ # 数据模型与配置
└── ... # 其他配置、脚本与元数据 │ │ ├── classes/ # 类定义
│ │ │ ├── Task.ts # 任务模型
│ │ │ ├── Milestone.ts # 里程碑模型
│ │ │ └── Language.ts # 语言配置
│ │ ├── configs/ # 配置接口
│ │ │ ├── TimelineConfig.ts # 时间轴配置
│ │ │ └── ToolbarConfig.ts # 工具栏配置
│ │ └── types/ # 类型定义
│ │ └── TimelineScale.ts # 时间刻度类型
│ ├── composables/ # 组合式函数
│ │ ├── useI18n.ts # 国际化工具
│ │ └── useMessage.ts # 消息提示工具
│ ├── styles/ # 样式文件
│ │ ├── app.css # 主样式
│ │ └── theme-variables.css # 主题变量
│ ├── utils/ # 工具函数
│ │ └── predecessorUtils.ts # 前置依赖工具
│ └── index.ts # 入口导出
├── demo/ # 组件开发与交互演示(本地开发/预览用)
│ ├── App.vue # 演示应用主组件
│ ├── data.json # 演示数据(包含药物临床试验案例)
│ ├── main.ts # 演示应用入口
│ └── ... # 其他演示文件
├── packageDemo/ # npm 包集成演示(模拟外部项目集成效果)
├── dist/ # 构建产物(发布/静态站点/打包输出)
├── docs/ # 相关文档(如部署、API 说明等)
├── design/ # 设计资源与截图
│ └── screenshots/ # 主题截图
├── public/ # 公共静态资源
│ └── assets/ # 静态资源文件
├── README.md # 中文说明文档
├── README-EN.md # 英文说明文档
├── package.json # 项目配置
├── vite.config.ts # Vite开发配置
├── vite.config.lib.ts # Vite库构建配置
├── tsconfig.json # TypeScript配置
└── ... # 其他配置、脚本与元数据
``` ```
- `demo/`:用于本地开发和功能演示,包含完整的交互页面。 ### 目录说明
- `packageDemo/`:用于模拟 npm 包在外部项目中的集成与使用场景。
- `dist/`:构建输出目录,包含发布到 npm 或静态站点的产物。 - **`src/components/`**:核心Vue组件,包含甘特图的所有功能组件
- `docs/`:项目相关文档,如部署说明、API 参考等。 - **`src/models/`**:数据模型、类型定义和配置接口
- 其余目录请参考注释。 - **`src/composables/`**:Vue 3组合式函数,提供可复用的逻辑
- **`src/styles/`**:样式文件,包含主题系统和CSS变量
- **`src/utils/`**:工具函数,处理业务逻辑和数据转换
- **`demo/`**:本地开发和功能演示,包含完整的交互页面和药物临床试验样例数据
- **`packageDemo/`**:模拟npm包在外部项目中的集成与使用场景
- **`dist/`**:构建输出目录,包含发布到npm或静态站点的产物
- **`docs/`**:项目文档,包括部署说明、API参考等
## 🔧 API 参考 ## 🔧 API 参考
@@ -96,8 +138,22 @@ jordium-gantt-vue3/
| `showToolbar` | `boolean` | `true` | 是否显示工具栏 | | `showToolbar` | `boolean` | `true` | 是否显示工具栏 |
| `toolbarConfig` | `ToolbarConfig` | `{}` | 工具栏配置 | | `toolbarConfig` | `ToolbarConfig` | `{}` | 工具栏配置 |
| `localeMessages` | `Partial<Messages['zh-CN']>` | - | 自定义多语言配置 | | `localeMessages` | `Partial<Messages['zh-CN']>` | - | 自定义多语言配置 |
| `workingHours` | `WorkingHours` | - | 工作时间配置 |
| `onTaskDoubleClick` | `(task: Task) => void` | - | 任务双击事件回调 | | `onTaskDoubleClick` | `(task: Task) => void` | - | 任务双击事件回调 |
| `onTaskDelete` | `(task: Task) => void` | - | 任务删除事件回调 | | `onTaskDelete` | `(task: Task, deleteChildren?: boolean) => void` | - | 任务删除事件回调 |
| `onTaskUpdate` | `(task: Task) => void` | - | 任务更新事件回调 |
| `onTaskAdd` | `(task: Task) => void` | - | 任务添加事件回调 |
| `onMilestoneSave` | `(milestone: Task) => void` | - | 里程碑保存事件回调 |
| `onMilestoneDelete` | `(milestoneId: number) => void` | - | 里程碑删除事件回调 |
| `onMilestoneIconChange` | `(milestoneId: number, icon: string) => void` | - | 里程碑图标变更事件回调 |
| `onAddTask` | `() => void` | - | 新增任务工具栏事件回调 |
| `onAddMilestone` | `() => void` | - | 新增里程碑工具栏事件回调 |
| `onTodayLocate` | `() => void` | - | 定位今天工具栏事件回调 |
| `onExportCsv` | `() => boolean \| void` | - | 导出CSV工具栏事件回调 |
| `onExportPdf` | `() => void` | - | 导出PDF工具栏事件回调 |
| `onLanguageChange` | `(lang: 'zh-CN' \| 'en-US') => void` | - | 语言切换工具栏事件回调 |
| `onThemeChange` | `(isDark: boolean) => void` | - | 主题切换工具栏事件回调 |
| `onFullscreenChange` | `(isFullscreen: boolean) => void` | - | 全屏切换工具栏事件回调 |
| `onTaskUpdate` | `(task: Task) => void` | - | 任务更新事件回调 | | `onTaskUpdate` | `(task: Task) => void` | - | 任务更新事件回调 |
| `onTaskAdd` | `(task: Task) => void` | - | 任务添加事件回调 | | `onTaskAdd` | `(task: Task) => void` | - | 任务添加事件回调 |
| `onMilestoneSave` | `(milestone: Task) => void` | - | 里程碑保存事件回调 | | `onMilestoneSave` | `(milestone: Task) => void` | - | 里程碑保存事件回调 |
@@ -188,16 +244,16 @@ function onTaskUpdated(e) {
**Task 任务类型** **Task 任务类型**
```typescript ```typescript
export interface Task { export interface Task {
id: number // 任务唯一ID id: number // 任务唯一ID
name: string // 任务名称 name: string // 任务名称
predecessor?: number[] // 前置任务ID数组 predecessor?: number[] // 前置任务ID数组
assignee?: string // 负责人 assignee?: string // 负责人
startDate?: string // 开始日期(ISO字符串) startDate?: string // 开始日期(ISO字符串)
endDate?: string // 结束日期(ISO字符串) endDate?: string // 结束日期(ISO字符串)
progress?: number // 进度百分比 0-100 progress?: number // 进度百分比 0-100
estimatedHours?: number // 预估工时 estimatedHours?: number // 预估工时(支持小数,最多2位)
actualHours?: number // 实际工时 actualHours?: number // 实际工时(支持小数,最多2位)
parentId?: number // 上级任务ID parentId?: number // 上级任务ID
children?: Task[] // 子任务数组 children?: Task[] // 子任务数组
collapsed?: boolean // 是否折叠 collapsed?: boolean // 是否折叠
isParent?: boolean // 是否为父任务 isParent?: boolean // 是否为父任务
@@ -257,6 +313,86 @@ interface ToolbarConfig {
} }
``` ```
**WorkingHours 工作时间配置**
```typescript
interface WorkingHours {
morning?: { start: number; end: number } // 上午工作时间,如 { start: 8, end: 11 }
afternoon?: { start: number; end: number } // 下午工作时间,如 { start: 13, end: 17 }
}
```
**TimelineScale 时间刻度类型**
```typescript
// 时间轴显示刻度类型
type TimelineScale = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'
// 时间刻度常量
export const TimelineScale = {
HOUR: 'hour', // 小时视图 - 每列显示一小时
DAY: 'day', // 日视图 - 每列显示一天
WEEK: 'week', // 周视图 - 每列显示一周
MONTH: 'month', // 月视图 - 每列显示一个月
QUARTER: 'quarter', // 季度视图 - 每列显示一个季度
YEAR: 'year', // 年视图 - 每列显示一年
}
// 时间刻度配置
interface TimelineScaleConfig {
scale: TimelineScale // 刻度类型
cellWidth: number // 每个时间单元的宽度(px)
headerLevels: number // 表头层级数
formatters: {
primary: string // 主要时间标签格式
secondary?: string // 次要时间标签格式
}
}
```
### 🕐 时间刻度功能说明
组件支持多种时间刻度显示,用户可以通过工具栏的日/周/月按钮组或者编程方式切换时间轴的显示粒度:
#### 内置刻度配置
| 刻度类型 | 单元宽度 | 主标签格式 | 副标签格式 | 适用场景 |
|----------|----------|------------|------------|----------|
| `hour` | 40px | yyyy/MM/dd | HH | 精确到小时的项目, 例如药物临床试验 |
| `day` | 30px | yyyy年MM月 | dd | 日常项目管理的标准视图 |
| `week` | 120px | yyyy年MM月 | W | 中期项目的周计划视图 |
| `month` | 180px | yyyy | MM | 长期项目的月度视图 |
| `quarter` | 360px | yyyy | Q | 战略规划的季度视图 |
| `year` | 360px | yyyy | 上半年\|下半年 | 超长期项目年度视图 |
#### 使用示例
```vue
<script setup>
import { ref } from 'vue'
import { GanttChart, TimelineScale } from 'jordium-gantt-vue3'
const tasks = ref([/* 任务数据 */])
// 工具栏配置 - 启用时间刻度切换按钮
const toolbarConfig = {
showTimeScale: true // 显示日|周|月按钮组
}
// 监听刻度切换(可选)
const handleTimeScaleChange = (scale) => {
console.log('时间刻度切换至:', scale)
// 可以在这里做一些业务逻辑,如保存用户偏好设置
}
</script>
<template>
<GanttChart
:tasks="tasks"
:toolbar-config="toolbarConfig"
@timescale-changed="handleTimeScaleChange"
/>
</template>
```
#### 组合式函数 (src/composables) #### 组合式函数 (src/composables)
**useI18n 国际化工具** **useI18n 国际化工具**
@@ -386,15 +522,15 @@ import { GanttChart } from 'jordium-gantt-vue3'
// 工具栏配置 // 工具栏配置
const toolbarConfig = { const toolbarConfig = {
showLanguage: true, showLanguage: true, // 语言切换
showTheme: true, showTheme: true, // 主题切换
showAddTask: true, showAddTask: true, // 新增任务
showAddMilestone: true, showAddMilestone: true, // 新增里程碑
showTodayLocate: true, showTodayLocate: true, // 定位今天
showExportCsv: true, showExportCsv: true, // 导出CSV
showExportPdf: true, showExportPdf: true, // 导出PDF
showFullscreen: true, showFullscreen: true, // 全屏模式
showTimeScale: true // 控制日|周|月时间刻度按钮组的可见性 showTimeScale: true // 时间刻度切换(日|周|月按钮组)
} }
// 自定义多语言配置 // 自定义多语言配置
@@ -411,6 +547,17 @@ const handleLanguageChange = (lang) => {
const handleThemeChange = (isDark) => { const handleThemeChange = (isDark) => {
console.log('主题切换到:', isDark ? '暗色' : '亮色') console.log('主题切换到:', isDark ? '暗色' : '亮色')
} }
// 监听时间刻度变化
const handleTimeScaleChange = (scale) => {
console.log('时间刻度切换至:', scale)
// 根据刻度调整显示逻辑
if (scale === 'day') {
// 日视图下的特殊处理
} else if (scale === 'week') {
// 周视图下的特殊处理
}
}
</script> </script>
<template> <template>
@@ -421,10 +568,51 @@ const handleThemeChange = (isDark) => {
:locale-messages="customLocaleMessages" :locale-messages="customLocaleMessages"
:on-language-change="handleLanguageChange" :on-language-change="handleLanguageChange"
:on-theme-change="handleThemeChange" :on-theme-change="handleThemeChange"
@timescale-changed="handleTimeScaleChange"
/> />
</template> </template>
``` ```
### 🔧 工作时间配置
组件支持设置工作时间,影响任务时长计算和进度显示:
```vue
<script setup lang="ts">
// 配置工作时间(24小时制)
const workingHours = {
morning: { start: 9, end: 12 }, // 上午9点-12点
afternoon: { start: 14, end: 18 } // 下午2点-6点
}
</script>
<template>
<GanttChart
:tasks="tasks"
:working-hours="workingHours"
/>
</template>
```
### 📊 高精度工时管理
组件支持精确到小数点后2位的工时记录,适合需要精确计费的项目:
```vue
<script setup lang="ts">
const tasks = ref([
{
id: 1,
name: '高精度任务',
estimatedHours: 8.75, // 8小时45分钟
actualHours: 7.25, // 7小时15分钟
startDate: '2025-01-01',
endDate: '2025-01-02'
}
])
</script>
```
## 🤝 贡献与合作 ## 🤝 贡献与合作
### 参与贡献 ### 参与贡献
+18 -9
View File
@@ -37,6 +37,14 @@ const toolbarConfig = {
showTheme: true, showTheme: true,
showFullscreen: true, showFullscreen: true,
showTimeScale: true, // 控制日|周|月时间刻度按钮组的可见性 showTimeScale: true, // 控制日|周|月时间刻度按钮组的可见性
timeScaleDimensions: ['hour', 'day', 'week', 'month', 'quarter', 'year'], // 设置时间刻度按钮的展示维度,包含所有时间维度
}
// 工作时间配置示例
const workingHoursConfig = {
morning: { start: 8, end: 11 }, // 上午8:00-11:59为工作时间
afternoon: { start: 13, end: 17 }, // 下午13:00-17:00为工作时间
// 其他时间(12:00-12:59, 18:00-07:59)为休息时间,显示为灰色背景
} }
// 自定义CSV导出处理器(可选) // 自定义CSV导出处理器(可选)
@@ -118,14 +126,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 },
}) }),
) )
} }
@@ -219,7 +227,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)
} }
@@ -267,7 +275,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
} }
@@ -362,7 +370,7 @@ const handleStoryDeleteWithChildren = (storyToDelete: Task) => {
'success', 'success',
{ {
closable: false, closable: false,
} },
) )
return true return true
} }
@@ -424,7 +432,7 @@ const handleStoryDeleteOnly = (storyToDelete: Task) => {
'success', 'success',
{ {
closable: false, closable: false,
} },
) )
return true return true
} }
@@ -498,7 +506,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) {
@@ -508,7 +516,7 @@ function handleMilestoneDragEnd(newMilestone) {
`里程碑【${newMilestone.name}\n` + `里程碑【${newMilestone.name}\n` +
`开始: ${oldMilestone.endDate}${newMilestone.startDate}`, `开始: ${oldMilestone.endDate}${newMilestone.startDate}`,
'info', 'info',
{ closable: true } { closable: true },
) )
} }
@@ -575,7 +583,7 @@ function onTimerStarted(task: Task) {
showMessage( showMessage(
`Demo 任务【${task.name}\n开始计时:${new Date(task.timerStartTime).toLocaleString()}\n计时说明:${task.timerStartDesc ? task.timerStartDesc : ''}`, `Demo 任务【${task.name}\n开始计时:${new Date(task.timerStartTime).toLocaleString()}\n计时说明:${task.timerStartDesc ? task.timerStartDesc : ''}`,
'info', 'info',
{ closable: true } { closable: true },
) )
} }
function onTimerStopped(task: Task) { function onTimerStopped(task: Task) {
@@ -628,6 +636,7 @@ function onTimerStopped(task: Task) {
:tasks="tasks" :tasks="tasks"
:milestones="milestones" :milestones="milestones"
:toolbar-config="toolbarConfig" :toolbar-config="toolbarConfig"
:working-hours="workingHoursConfig"
:on-add-task="handleAddTask" :on-add-task="handleAddTask"
:on-add-milestone="handleAddMilestone" :on-add-milestone="handleAddMilestone"
:on-export-csv="handleCustomCsvExport" :on-export-csv="handleCustomCsvExport"
+273
View File
@@ -1,5 +1,248 @@
{ {
"tasks": [ "tasks": [
{
"id": 1000,
"name": "新药ADX-2024临床试验项目",
"assignee": "首席研究员 Dr. Chen",
"startDate": "2025-01-01",
"endDate": "2027-12-31",
"progress": 15,
"estimatedHours": 26280.5,
"actualHours": 3942.75,
"type": "story",
"description": "ADX-2024新药完整临床试验计划,从I期到III期临床试验",
"children": [
{
"id": 1100,
"name": "I期临床试验",
"assignee": "I期试验主任 Dr. Wang",
"startDate": "2025-01-01",
"endDate": "2025-08-31",
"progress": 65,
"estimatedHours": 5256,
"actualHours": 3417,
"type": "story",
"parentId": 1000,
"description": "评估ADX-2024在健康志愿者和患者中的安全性、耐受性和药代动力学特征",
"children": [
{
"id": 1101,
"name": "试验方案设计与伦理审查",
"assignee": "方案设计师 李明",
"startDate": "2025-01-01",
"endDate": "2025-02-28",
"progress": 100,
"estimatedHours": 464,
"actualHours": 480,
"type": "task",
"parentId": 1100,
"description": "完成I期试验方案设计、伦理委员会审批及监管部门申报"
},
{
"id": 1102,
"name": "受试者招募与筛选",
"assignee": "招募专员 张丽",
"startDate": "2025-03-01",
"endDate": "2025-04-30",
"progress": 90,
"estimatedHours": 1392.5,
"actualHours": 1250.25,
"type": "task",
"predecessor": [1101],
"parentId": 1100,
"description": "招募24名健康志愿者,完成医学筛选和入组评估"
},
{
"id": 1103,
"name": "药物给药与安全性监测",
"assignee": "临床医生 Dr. Liu",
"startDate": "2025-05-01",
"endDate": "2025-08-31",
"progress": 40,
"estimatedHours": 3400,
"actualHours": 1687,
"type": "task",
"predecessor": [1102],
"parentId": 1100,
"description": "按照剂量递增方案给药,密切监测不良反应和药代动力学参数"
}
],
"collapsed": false
},
{
"id": 1200,
"name": "II期临床试验",
"assignee": "II期试验主任 Dr. Zhang",
"startDate": "2025-09-01",
"endDate": "2026-08-31",
"progress": 5,
"estimatedHours": 8760.75,
"actualHours": 438.25,
"type": "story",
"parentId": 1000,
"description": "在目标患者群体中评估ADX-2024的初步疗效和进一步的安全性",
"children": [
{
"id": 1201,
"name": "多中心试验启动",
"assignee": "项目经理 王芳",
"startDate": "2025-09-01",
"endDate": "2025-11-30",
"progress": 25,
"estimatedHours": 2160,
"actualHours": 324,
"type": "task",
"predecessor": [1103],
"parentId": 1200,
"description": "启动5个临床中心,完成研究者培训和质量体系建立"
},
{
"id": 1202,
"name": "患者入组与随机化",
"assignee": "数据管理员 陈静",
"startDate": "2025-12-01",
"endDate": "2026-03-31",
"progress": 0,
"estimatedHours": 2880,
"actualHours": 0,
"type": "task",
"predecessor": [1201],
"parentId": 1200,
"description": "计划入组120名患者,采用双盲随机对照设计"
},
{
"id": 1203,
"name": "疗效评估与数据收集",
"assignee": "统计师 赵磊",
"startDate": "2026-01-01",
"endDate": "2026-08-31",
"progress": 0,
"estimatedHours": 3720,
"actualHours": 0,
"type": "task",
"predecessor": [1202],
"parentId": 1200,
"description": "定期评估患者疗效指标,收集安全性数据,进行中期分析"
}
],
"collapsed": false
},
{
"id": 1300,
"name": "III期临床试验",
"assignee": "III期试验主任 Dr. Li",
"startDate": "2026-09-01",
"endDate": "2027-12-31",
"progress": 0,
"estimatedHours": 12264,
"actualHours": 0,
"type": "story",
"parentId": 1000,
"description": "大规模多中心随机对照试验,确认ADX-2024的疗效和安全性",
"children": [
{
"id": 1301,
"name": "国际多中心试验准备",
"assignee": "国际事务专员 刘倩",
"startDate": "2026-09-01",
"endDate": "2026-12-31",
"progress": 0,
"estimatedHours": 2928,
"actualHours": 0,
"type": "task",
"predecessor": [1203],
"parentId": 1300,
"description": "启动15个国际临床中心,获得各国监管审批"
},
{
"id": 1302,
"name": "大规模患者招募",
"assignee": "多中心协调员 孙伟",
"startDate": "2027-01-01",
"endDate": "2027-06-30",
"progress": 0,
"estimatedHours": 4380,
"actualHours": 0,
"type": "task",
"predecessor": [1301],
"parentId": 1300,
"description": "计划入组500名患者,严格按照纳排标准执行"
},
{
"id": 1303,
"name": "最终疗效分析与报告",
"assignee": "首席统计师 马强",
"startDate": "2027-03-01",
"endDate": "2027-12-31",
"progress": 0,
"estimatedHours": 4956,
"actualHours": 0,
"type": "task",
"predecessor": [1302],
"parentId": 1300,
"description": "完成最终疗效分析,撰写临床研究报告,准备新药上市申请"
}
],
"collapsed": false
}
],
"collapsed": false
},
{
"id": 1001,
"name": "上午会议",
"assignee": "测试用户",
"startDate": "2025-09-10 09:00",
"endDate": "2025-09-10 10:30",
"progress": 50,
"estimatedHours": 1.5,
"actualHours": 0.75,
"type": "task"
},
{
"id": 1002,
"name": "午后开发",
"assignee": "开发者",
"startDate": "2025-09-10 14:00",
"endDate": "2025-09-10 16:15",
"progress": 30,
"estimatedHours": 2.25,
"actualHours": 1.33,
"type": "task"
},
{
"id": 1003,
"name": "测试任务",
"assignee": "测试员",
"startDate": "2025-09-10 16:30",
"endDate": "2025-09-10 17:45",
"progress": 0,
"estimatedHours": 1.25,
"actualHours": 0,
"type": "task"
},
{
"id": 1004,
"name": "当日整天任务",
"assignee": "项目经理",
"startDate": "2025-09-10",
"endDate": "2025-09-10",
"progress": 20,
"estimatedHours": 8.0,
"actualHours": 1.5,
"type": "task"
},
{
"id": 1005,
"name": "12点瞬间任务",
"assignee": "测试员",
"startDate": "2025-09-10 12:00",
"endDate": "2025-09-10 12:00",
"progress": 100,
"estimatedHours": 0,
"actualHours": 0,
"type": "task"
},
{ {
"id": 1, "id": 1,
"name": "项目启动", "name": "项目启动",
@@ -162,6 +405,36 @@
} }
], ],
"milestones": [ "milestones": [
{
"id": 1150,
"name": "I期临床试验完成",
"assignee": "I期试验主任 Dr. Wang",
"startDate": "2025-08-31",
"endDate": "2025-08-31",
"type": "milestone",
"icon": "shield-check",
"description": "完成I期临床试验,确认ADX-2024在人体的安全性和耐受性,为II期试验奠定基础。"
},
{
"id": 1250,
"name": "II期临床中期分析",
"assignee": "统计师 赵磊",
"startDate": "2026-04-30",
"endDate": "2026-04-30",
"type": "milestone",
"icon": "bar-chart",
"description": "完成II期临床试验中期疗效分析,评估是否继续进行III期试验。"
},
{
"id": 1350,
"name": "新药上市申请提交",
"assignee": "首席研究员 Dr. Chen",
"startDate": "2027-12-31",
"endDate": "2027-12-31",
"type": "milestone",
"icon": "trophy",
"description": "完成III期临床试验最终分析,向监管部门提交新药上市申请(NDA)。"
},
{ {
"id": 6, "id": 6,
"name": "技术选型完成", "name": "技术选型完成",
+18
View File
@@ -162,5 +162,23 @@
"version": "1.2.1", "version": "1.2.1",
"date": "2025-07-22", "date": "2025-07-22",
"notes": ["SonarQube代码质量检查问题修改"] "notes": ["SonarQube代码质量检查问题修改"]
},
{
"version": "1.3.0",
"date": "2025-09-12",
"notes": [
"增加年度视图",
"Add annual timeline view",
"增加季度视图",
"Add quarterly timeline view",
"增加小时视图",
"Add hourly timeline view",
"升级TaskDrawer中DatePicker组件,接受小时的设定",
"Upgrade DatePicker component in TaskDrawer to accept hour settings",
"升级TaskDrawer中预计时间和实际时间的控件,接受小数后两位的录入",
"Upgrade the estimated time and actual time controls in TaskDrawer to accept two decimal places",
"SonarQube代码质量检查问题修改",
"SonarQube code quality inspection issue modification"
]
} }
] ]
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "jordium-gantt-vue3", "name": "jordium-gantt-vue3",
"version": "1.2.1", "version": "1.3.0",
"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",
@@ -48,7 +48,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:demo": "vite", "dev:demo": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc --noEmit --skipLibCheck && vite build",
"build:demo": "vue-tsc -b && vite build", "build:demo": "vue-tsc -b && vite build",
"build:lib": "vite build --config vite.config.lib.ts", "build:lib": "vite build --config vite.config.lib.ts",
"preview": "vite preview", "preview": "vite preview",
+18 -9
View File
@@ -37,6 +37,14 @@ const toolbarConfig = {
showTheme: true, showTheme: true,
showFullscreen: true, showFullscreen: true,
showTimeScale: true, // 控制日|周|月时间刻度按钮组的可见性 showTimeScale: true, // 控制日|周|月时间刻度按钮组的可见性
timeScaleDimensions: ['hour', 'day', 'week', 'month', 'quarter', 'year'], // 设置时间刻度按钮的展示维度,包含所有时间维度
}
// 工作时间配置示例
const workingHoursConfig = {
morning: { start: 8, end: 11 }, // 上午8:00-11:59为工作时间
afternoon: { start: 13, end: 17 }, // 下午13:00-17:00为工作时间
// 其他时间(12:00-12:59, 18:00-07:59)为休息时间,显示为灰色背景
} }
// 自定义CSV导出处理器(可选) // 自定义CSV导出处理器(可选)
@@ -118,14 +126,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 },
}) }),
) )
} }
@@ -219,7 +227,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)
} }
@@ -267,7 +275,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
} }
@@ -362,7 +370,7 @@ const handleStoryDeleteWithChildren = (storyToDelete: Task) => {
'success', 'success',
{ {
closable: false, closable: false,
} },
) )
return true return true
} }
@@ -424,7 +432,7 @@ const handleStoryDeleteOnly = (storyToDelete: Task) => {
'success', 'success',
{ {
closable: false, closable: false,
} },
) )
return true return true
} }
@@ -498,7 +506,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) {
@@ -508,7 +516,7 @@ function handleMilestoneDragEnd(newMilestone) {
`里程碑【${newMilestone.name}\n` + `里程碑【${newMilestone.name}\n` +
`开始: ${oldMilestone.endDate}${newMilestone.startDate}`, `开始: ${oldMilestone.endDate}${newMilestone.startDate}`,
'info', 'info',
{ closable: true } { closable: true },
) )
} }
@@ -575,7 +583,7 @@ function onTimerStarted(task: Task) {
showMessage( showMessage(
`Demo 任务【${task.name}\n开始计时:${new Date(task.timerStartTime).toLocaleString()}\n计时说明:${task.timerStartDesc ? task.timerStartDesc : ''}`, `Demo 任务【${task.name}\n开始计时:${new Date(task.timerStartTime).toLocaleString()}\n计时说明:${task.timerStartDesc ? task.timerStartDesc : ''}`,
'info', 'info',
{ closable: true } { closable: true },
) )
} }
function onTimerStopped(task: Task) { function onTimerStopped(task: Task) {
@@ -628,6 +636,7 @@ function onTimerStopped(task: Task) {
:tasks="tasks" :tasks="tasks"
:milestones="milestones" :milestones="milestones"
:toolbar-config="toolbarConfig" :toolbar-config="toolbarConfig"
:working-hours="workingHoursConfig"
:on-add-task="handleAddTask" :on-add-task="handleAddTask"
:on-add-milestone="handleAddMilestone" :on-add-milestone="handleAddMilestone"
:on-export-csv="handleCustomCsvExport" :on-export-csv="handleCustomCsvExport"
+273
View File
@@ -1,5 +1,248 @@
{ {
"tasks": [ "tasks": [
{
"id": 1000,
"name": "新药ADX-2024临床试验项目",
"assignee": "首席研究员 Dr. Chen",
"startDate": "2025-01-01",
"endDate": "2027-12-31",
"progress": 15,
"estimatedHours": 26280.5,
"actualHours": 3942.75,
"type": "story",
"description": "ADX-2024新药完整临床试验计划,从I期到III期临床试验",
"children": [
{
"id": 1100,
"name": "I期临床试验",
"assignee": "I期试验主任 Dr. Wang",
"startDate": "2025-01-01",
"endDate": "2025-08-31",
"progress": 65,
"estimatedHours": 5256,
"actualHours": 3417,
"type": "story",
"parentId": 1000,
"description": "评估ADX-2024在健康志愿者和患者中的安全性、耐受性和药代动力学特征",
"children": [
{
"id": 1101,
"name": "试验方案设计与伦理审查",
"assignee": "方案设计师 李明",
"startDate": "2025-01-01",
"endDate": "2025-02-28",
"progress": 100,
"estimatedHours": 464,
"actualHours": 480,
"type": "task",
"parentId": 1100,
"description": "完成I期试验方案设计、伦理委员会审批及监管部门申报"
},
{
"id": 1102,
"name": "受试者招募与筛选",
"assignee": "招募专员 张丽",
"startDate": "2025-03-01",
"endDate": "2025-04-30",
"progress": 90,
"estimatedHours": 1392.5,
"actualHours": 1250.25,
"type": "task",
"predecessor": [1101],
"parentId": 1100,
"description": "招募24名健康志愿者,完成医学筛选和入组评估"
},
{
"id": 1103,
"name": "药物给药与安全性监测",
"assignee": "临床医生 Dr. Liu",
"startDate": "2025-05-01",
"endDate": "2025-08-31",
"progress": 40,
"estimatedHours": 3400,
"actualHours": 1687,
"type": "task",
"predecessor": [1102],
"parentId": 1100,
"description": "按照剂量递增方案给药,密切监测不良反应和药代动力学参数"
}
],
"collapsed": false
},
{
"id": 1200,
"name": "II期临床试验",
"assignee": "II期试验主任 Dr. Zhang",
"startDate": "2025-09-01",
"endDate": "2026-08-31",
"progress": 5,
"estimatedHours": 8760.75,
"actualHours": 438.25,
"type": "story",
"parentId": 1000,
"description": "在目标患者群体中评估ADX-2024的初步疗效和进一步的安全性",
"children": [
{
"id": 1201,
"name": "多中心试验启动",
"assignee": "项目经理 王芳",
"startDate": "2025-09-01",
"endDate": "2025-11-30",
"progress": 25,
"estimatedHours": 2160,
"actualHours": 324,
"type": "task",
"predecessor": [1103],
"parentId": 1200,
"description": "启动5个临床中心,完成研究者培训和质量体系建立"
},
{
"id": 1202,
"name": "患者入组与随机化",
"assignee": "数据管理员 陈静",
"startDate": "2025-12-01",
"endDate": "2026-03-31",
"progress": 0,
"estimatedHours": 2880,
"actualHours": 0,
"type": "task",
"predecessor": [1201],
"parentId": 1200,
"description": "计划入组120名患者,采用双盲随机对照设计"
},
{
"id": 1203,
"name": "疗效评估与数据收集",
"assignee": "统计师 赵磊",
"startDate": "2026-01-01",
"endDate": "2026-08-31",
"progress": 0,
"estimatedHours": 3720,
"actualHours": 0,
"type": "task",
"predecessor": [1202],
"parentId": 1200,
"description": "定期评估患者疗效指标,收集安全性数据,进行中期分析"
}
],
"collapsed": false
},
{
"id": 1300,
"name": "III期临床试验",
"assignee": "III期试验主任 Dr. Li",
"startDate": "2026-09-01",
"endDate": "2027-12-31",
"progress": 0,
"estimatedHours": 12264,
"actualHours": 0,
"type": "story",
"parentId": 1000,
"description": "大规模多中心随机对照试验,确认ADX-2024的疗效和安全性",
"children": [
{
"id": 1301,
"name": "国际多中心试验准备",
"assignee": "国际事务专员 刘倩",
"startDate": "2026-09-01",
"endDate": "2026-12-31",
"progress": 0,
"estimatedHours": 2928,
"actualHours": 0,
"type": "task",
"predecessor": [1203],
"parentId": 1300,
"description": "启动15个国际临床中心,获得各国监管审批"
},
{
"id": 1302,
"name": "大规模患者招募",
"assignee": "多中心协调员 孙伟",
"startDate": "2027-01-01",
"endDate": "2027-06-30",
"progress": 0,
"estimatedHours": 4380,
"actualHours": 0,
"type": "task",
"predecessor": [1301],
"parentId": 1300,
"description": "计划入组500名患者,严格按照纳排标准执行"
},
{
"id": 1303,
"name": "最终疗效分析与报告",
"assignee": "首席统计师 马强",
"startDate": "2027-03-01",
"endDate": "2027-12-31",
"progress": 0,
"estimatedHours": 4956,
"actualHours": 0,
"type": "task",
"predecessor": [1302],
"parentId": 1300,
"description": "完成最终疗效分析,撰写临床研究报告,准备新药上市申请"
}
],
"collapsed": false
}
],
"collapsed": false
},
{
"id": 1001,
"name": "上午会议",
"assignee": "测试用户",
"startDate": "2025-09-10 09:00",
"endDate": "2025-09-10 10:30",
"progress": 50,
"estimatedHours": 1.5,
"actualHours": 0.75,
"type": "task"
},
{
"id": 1002,
"name": "午后开发",
"assignee": "开发者",
"startDate": "2025-09-10 14:00",
"endDate": "2025-09-10 16:15",
"progress": 30,
"estimatedHours": 2.25,
"actualHours": 1.33,
"type": "task"
},
{
"id": 1003,
"name": "测试任务",
"assignee": "测试员",
"startDate": "2025-09-10 16:30",
"endDate": "2025-09-10 17:45",
"progress": 0,
"estimatedHours": 1.25,
"actualHours": 0,
"type": "task"
},
{
"id": 1004,
"name": "当日整天任务",
"assignee": "项目经理",
"startDate": "2025-09-10",
"endDate": "2025-09-10",
"progress": 20,
"estimatedHours": 8.0,
"actualHours": 1.5,
"type": "task"
},
{
"id": 1005,
"name": "12点瞬间任务",
"assignee": "测试员",
"startDate": "2025-09-10 12:00",
"endDate": "2025-09-10 12:00",
"progress": 100,
"estimatedHours": 0,
"actualHours": 0,
"type": "task"
},
{ {
"id": 1, "id": 1,
"name": "项目启动", "name": "项目启动",
@@ -162,6 +405,36 @@
} }
], ],
"milestones": [ "milestones": [
{
"id": 1150,
"name": "I期临床试验完成",
"assignee": "I期试验主任 Dr. Wang",
"startDate": "2025-08-31",
"endDate": "2025-08-31",
"type": "milestone",
"icon": "shield-check",
"description": "完成I期临床试验,确认ADX-2024在人体的安全性和耐受性,为II期试验奠定基础。"
},
{
"id": 1250,
"name": "II期临床中期分析",
"assignee": "统计师 赵磊",
"startDate": "2026-04-30",
"endDate": "2026-04-30",
"type": "milestone",
"icon": "bar-chart",
"description": "完成II期临床试验中期疗效分析,评估是否继续进行III期试验。"
},
{
"id": 1350,
"name": "新药上市申请提交",
"assignee": "首席研究员 Dr. Chen",
"startDate": "2027-12-31",
"endDate": "2027-12-31",
"type": "milestone",
"icon": "trophy",
"description": "完成III期临床试验最终分析,向监管部门提交新药上市申请(NDA)。"
},
{ {
"id": 6, "id": 6,
"name": "技术选型完成", "name": "技术选型完成",
+18
View File
@@ -162,5 +162,23 @@
"version": "1.2.1", "version": "1.2.1",
"date": "2025-07-22", "date": "2025-07-22",
"notes": ["SonarQube代码质量检查问题修改"] "notes": ["SonarQube代码质量检查问题修改"]
},
{
"version": "1.3.0",
"date": "2025-09-12",
"notes": [
"增加年度视图",
"Add annual timeline view",
"增加季度视图",
"Add quarterly timeline view",
"增加小时视图",
"Add hourly timeline view",
"升级TaskDrawer中DatePicker组件,接受小时的设定",
"Upgrade DatePicker component in TaskDrawer to accept hour settings",
"升级TaskDrawer中预计时间和实际时间的控件,接受小数后两位的录入",
"Upgrade the estimated time and actual time controls in TaskDrawer to accept two decimal places",
"SonarQube代码质量检查问题修改",
"SonarQube code quality inspection issue modification"
]
} }
] ]
+694 -45
View File
@@ -1,22 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from '../composables/useI18n' import { useI18n } from '../composables/useI18n'
const { t } = useI18n()
interface Props {
modelValue?: string | [string, string]
type?: 'date' | 'daterange'
placeholder?: string
startPlaceholder?: string
endPlaceholder?: string
disabled?: boolean
clearable?: boolean
size?: 'small' | 'default' | 'large'
format?: string
valueFormat?: string
rangeSeparator?: string
}
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
modelValue: '', modelValue: '',
type: 'date', type: 'date',
@@ -38,14 +22,35 @@ const emit = defineEmits<{
blur: [event: FocusEvent] blur: [event: FocusEvent]
}>() }>()
const { t } = useI18n()
interface Props {
modelValue?: string | [string, string]
type?: 'date' | 'daterange'
placeholder?: string
startPlaceholder?: string
endPlaceholder?: string
disabled?: boolean
clearable?: boolean
size?: 'small' | 'default' | 'large'
format?: string
valueFormat?: string
rangeSeparator?: string
}
// 内部状态 // 内部状态
const isFocused = ref(false) const isFocused = ref(false)
const isHovered = ref(false) const isHovered = ref(false)
const showPicker = ref(false) const showPicker = ref(false)
const showYearPicker = ref(false) const showYearPicker = ref(false)
const showMonthPicker = ref(false) const showMonthPicker = ref(false)
const showTimePicker = ref(false)
const inputRef = ref<HTMLElement>() const inputRef = ref<HTMLElement>()
const pickerRef = ref<HTMLElement>() const pickerRef = ref<HTMLElement>()
const timePickerRef = ref<HTMLElement>()
const timeInputRef = ref<HTMLElement>()
const hourListRef = ref<HTMLElement>()
const minuteListRef = ref<HTMLElement>()
const blurTimer = ref<number | null>(null) const blurTimer = ref<number | null>(null)
const positionUpdateKey = ref(0) // 用于强制重新计算位置 const positionUpdateKey = ref(0) // 用于强制重新计算位置
@@ -63,43 +68,107 @@ const startValue = ref('')
const endValue = ref('') const endValue = ref('')
const rangeSelection = ref<'start' | 'end'>('start') const rangeSelection = ref<'start' | 'end'>('start')
// 格式化日期显示 // 时间选择器的值
const formatDisplayDate = (dateStr: string) => { const selectedTime = ref('12:00')
const tempHour = ref(12)
const tempMinute = ref(0)
// 格式化显示日期时间
const formatDisplayDateTime = (dateStr: string, timeStr: string) => {
if (!dateStr) return '' if (!dateStr) return ''
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')}` if (isNaN(date.getTime())) return ''
const dateFormat = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`
// 总是显示时间部分
if (timeStr) {
return `${dateFormat} ${timeStr}`
}
return dateFormat
} }
// 显示值 // 显示值
const displayValue = computed(() => { const displayValue = computed(() => {
if (props.type === 'daterange') { if (props.type === 'daterange') {
const start = startValue.value ? formatDisplayDate(startValue.value) : '' const start = startValue.value
const end = endValue.value ? formatDisplayDate(endValue.value) : '' ? formatDisplayDateTime(startValue.value, selectedTime.value)
: ''
const end = endValue.value ? formatDisplayDateTime(endValue.value, selectedTime.value) : ''
if (start && end) { if (start && end) {
return `${start} ${props.rangeSeparator} ${end}` return `${start} ${props.rangeSeparator} ${end}`
} }
return start || end || '' return start || end || ''
} }
return singleValue.value ? formatDisplayDate(singleValue.value) : '' return singleValue.value ? formatDisplayDateTime(singleValue.value, selectedTime.value) : ''
}) })
// 解析日期时间字符串
const parseDateTimeString = (dateTimeStr: string) => {
if (!dateTimeStr) return { dateStr: '', timeStr: '12:00' }
// 检查是否包含时间部分
const parts = dateTimeStr.trim().split(' ')
if (parts.length >= 2) {
// 包含时间部分
const dateStr = parts[0]
const timeStr = parts[1]
// 转换日期格式从 yyyy/MM/dd 到 yyyy-MM-dd
const dateParts = dateStr.split('/')
if (dateParts.length === 3) {
const formattedDate = `${dateParts[0]}-${dateParts[1].padStart(2, '0')}-${dateParts[2].padStart(2, '0')}`
return { dateStr: formattedDate, timeStr }
}
// 已经是 yyyy-MM-dd 格式,直接返回
return { dateStr, timeStr }
}
// 只有日期部分,或者已经是标准格式
const dateStr = parts[0]
if (dateStr.includes('/')) {
// 转换格式
const dateParts = dateStr.split('/')
if (dateParts.length === 3) {
const formattedDate = `${dateParts[0]}-${dateParts[1].padStart(2, '0')}-${dateParts[2].padStart(2, '0')}`
return { dateStr: formattedDate, timeStr: '12:00' }
}
}
return { dateStr, timeStr: '12:00' }
}
// 监听外部值变化 // 监听外部值变化
watch( watch(
() => props.modelValue, () => props.modelValue,
newValue => { newValue => {
if (props.type === 'daterange') { if (props.type === 'daterange') {
if (Array.isArray(newValue) && newValue.length === 2) { if (Array.isArray(newValue) && newValue.length === 2) {
startValue.value = newValue[0] || '' const startParsed = parseDateTimeString(newValue[0] || '')
endValue.value = newValue[1] || '' const endParsed = parseDateTimeString(newValue[1] || '')
startValue.value = startParsed.dateStr
endValue.value = endParsed.dateStr
// 如果有时间信息,使用第一个时间作为选择器的时间
if (startParsed.timeStr !== '12:00') {
selectedTime.value = startParsed.timeStr
}
} else { } else {
startValue.value = '' startValue.value = ''
endValue.value = '' endValue.value = ''
selectedTime.value = '12:00'
} }
} else { } else {
singleValue.value = (newValue as string) || '' const parsed = parseDateTimeString((newValue as string) || '')
singleValue.value = parsed.dateStr
selectedTime.value = parsed.timeStr
} }
}, },
{ immediate: true } { immediate: true },
) )
// 处理单日期输入变化(预留,当前版本不使用) // 处理单日期输入变化(预留,当前版本不使用)
@@ -158,12 +227,22 @@ const togglePicker = () => {
// 设置当前年月为选中日期的年月 // 设置当前年月为选中日期的年月
let currentDate: Date let currentDate: Date
if (props.type === 'daterange') { if (props.type === 'daterange') {
currentDate = startValue.value ? new Date(startValue.value) : new Date() // 对于日期范围,优先使用开始日期,如果没有则使用结束日期
const targetDateStr = startValue.value || endValue.value
currentDate = targetDateStr ? new Date(targetDateStr) : new Date()
} else { } else {
currentDate = singleValue.value ? new Date(singleValue.value) : new Date() currentDate = singleValue.value ? new Date(singleValue.value) : new Date()
} }
currentYear.value = currentDate.getFullYear()
currentMonth.value = currentDate.getMonth() // 确保日期有效
if (!isNaN(currentDate.getTime())) {
currentYear.value = currentDate.getFullYear()
currentMonth.value = currentDate.getMonth()
} else {
currentDate = new Date()
currentYear.value = currentDate.getFullYear()
currentMonth.value = currentDate.getMonth()
}
} }
} }
@@ -172,6 +251,7 @@ const closePicker = () => {
showPicker.value = false showPicker.value = false
showYearPicker.value = false showYearPicker.value = false
showMonthPicker.value = false showMonthPicker.value = false
showTimePicker.value = false // 关闭日历时也关闭时间选择器
isFocused.value = false isFocused.value = false
// 清理失焦定时器 // 清理失焦定时器
if (blurTimer.value) { if (blurTimer.value) {
@@ -180,11 +260,143 @@ const closePicker = () => {
} }
} }
// 时间选择器相关函数
const openTimePicker = (event?: MouseEvent) => {
if (event) {
event.preventDefault()
event.stopPropagation()
}
if (showTimePicker.value) return
// 阻止失焦关闭日历选择器
if (blurTimer.value) {
clearTimeout(blurTimer.value)
blurTimer.value = null
}
showTimePicker.value = true
// 解析当前时间
const [hour, minute] = selectedTime.value.split(':').map(Number)
tempHour.value = hour
tempMinute.value = minute
// 下一帧滚动到选中的时间位置
nextTick(() => {
scrollToSelectedTime()
})
}
// 滚动到选中的时间位置
const scrollToSelectedTime = () => {
if (hourListRef.value) {
const hourItems = hourListRef.value.querySelectorAll('.el-time-item')
const selectedHourIndex = tempHour.value
if (hourItems[selectedHourIndex]) {
const itemHeight = 28 // el-time-item 的高度
const containerHeight = 160 // el-time-column-list 的高度
const scrollTop = Math.max(
0,
selectedHourIndex * itemHeight - containerHeight / 2 + itemHeight / 2,
)
hourListRef.value.scrollTop = scrollTop
}
}
if (minuteListRef.value) {
const minuteItems = minuteListRef.value.querySelectorAll('.el-time-item')
const minuteOptions = [0, 15, 30, 45]
const selectedMinuteIndex = minuteOptions.indexOf(tempMinute.value)
if (selectedMinuteIndex >= 0 && minuteItems[selectedMinuteIndex]) {
const itemHeight = 28
const containerHeight = 160
const scrollTop = Math.max(
0,
selectedMinuteIndex * itemHeight - containerHeight / 2 + itemHeight / 2,
)
minuteListRef.value.scrollTop = scrollTop
}
}
}
const closeTimePicker = () => {
showTimePicker.value = false
}
const confirmTime = () => {
selectedTime.value = `${String(tempHour.value).padStart(2, '0')}:${String(tempMinute.value).padStart(2, '0')}`
closeTimePicker()
}
// 生成小时选项(0-23
const hourOptions = computed(() => {
return Array.from({ length: 24 }, (_, i) => i)
})
// 生成分钟选项(15分间隔:0, 15, 30, 45
const minuteOptions = computed(() => {
return [0, 15, 30, 45]
})
// 滚动选择时间
const selectHour = (hour: number) => {
tempHour.value = hour
}
const selectMinute = (minute: number) => {
tempMinute.value = minute
}
// 确认日期选择
const confirmDate = () => {
if (props.type === 'daterange') {
if (startValue.value && endValue.value) {
// 格式化日期和时间
const formatDateTime = (dateStr: string) => {
const date = new Date(dateStr)
const dateFormat = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`
return selectedTime.value ? `${dateFormat} ${selectedTime.value}` : dateFormat
}
const startDateTime = formatDateTime(startValue.value)
const endDateTime = formatDateTime(endValue.value)
const newValue: [string, string] = [startDateTime, endDateTime]
emit('update:modelValue', newValue)
emit('change', newValue)
}
} else {
if (singleValue.value) {
// 格式化日期和时间
const date = new Date(singleValue.value)
const dateFormat = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`
const formattedDateTime = selectedTime.value
? `${dateFormat} ${selectedTime.value}`
: dateFormat
emit('update:modelValue', formattedDateTime)
emit('change', formattedDateTime)
}
}
// 确认后关闭面板
setTimeout(() => {
closePicker()
}, 150)
}
// 处理点击外部区域 // 处理点击外部区域
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element const target = event.target as Element
if (!inputRef.value?.contains(target) && !pickerRef.value?.contains(target)) { const isInsideInput = inputRef.value?.contains(target)
const isInsidePicker = pickerRef.value?.contains(target)
const isInsideTimePicker = timePickerRef.value?.contains(target)
if (!isInsideInput && !isInsidePicker && !isInsideTimePicker) {
closePicker() closePicker()
} else if (!isInsideTimePicker && showTimePicker.value && !isInsidePicker) {
// 如果点击了时间选择器外部但在日历选择器内部,只关闭时间选择器
closeTimePicker()
} }
} }
@@ -280,22 +492,11 @@ const selectDate = (dateStr: string) => {
endValue.value = dateStr endValue.value = dateStr
} }
rangeSelection.value = 'start' rangeSelection.value = 'start'
const newValue: [string, string] = [startValue.value, endValue.value] // 不再自动提交,等待用户点击确认按钮
emit('update:modelValue', newValue)
emit('change', newValue)
// 选择完成后自动关闭面板
setTimeout(() => {
closePicker()
}, 150)
} }
} else { } else {
singleValue.value = dateStr singleValue.value = dateStr
emit('update:modelValue', dateStr) // 不再自动提交,等待用户点击确认按钮
emit('change', dateStr)
// 单日期选择完成后自动关闭面板
setTimeout(() => {
closePicker()
}, 150)
} }
} }
@@ -537,8 +738,26 @@ const handlePickerFocus = () => {
// 面板失去焦点时关闭 // 面板失去焦点时关闭
const handlePickerBlur = () => { const handlePickerBlur = () => {
// 只有在时间选择器未打开时才关闭日历选择器
if (!showTimePicker.value) {
blurTimer.value = setTimeout(() => {
closePicker()
}, 150)
}
}
// 时间选择器获得焦点时取消关闭
const handleTimePickerFocus = () => {
if (blurTimer.value) {
clearTimeout(blurTimer.value)
blurTimer.value = null
}
}
// 时间选择器失去焦点时关闭
const handleTimePickerBlur = () => {
blurTimer.value = setTimeout(() => { blurTimer.value = setTimeout(() => {
closePicker() closeTimePicker()
}, 150) }, 150)
} }
@@ -637,6 +856,53 @@ const panelStyle = computed(() => {
return style return style
}) })
// 计算时间选择器位置
const timePickerStyle = computed(() => {
if (!timeInputRef.value || !showTimePicker.value) return {}
const rect = timeInputRef.value.getBoundingClientRect()
const panelWidth = 180 // 减小宽度从280到180
const panelHeight = 300
const spacing = 4
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const style: Record<string, string> = {
position: 'fixed',
zIndex: '10002', // 比日历选择器层级更高
}
// 水平位置:与时间输入框左对齐
const spaceRight = viewportWidth - rect.left
if (spaceRight >= panelWidth) {
// 左对齐
style.left = `${rect.left}px`
} else {
// 右对齐,确保不超出视窗
style.left = `${Math.max(spacing, viewportWidth - panelWidth - spacing)}px`
}
// 垂直位置:显示在输入框上方
const spaceAbove = rect.top
if (spaceAbove >= panelHeight + spacing) {
// 显示在上方
style.top = `${rect.top - panelHeight - spacing}px`
} else {
// 空间不足时显示在下方
style.top = `${rect.bottom + spacing}px`
}
// 确保面板完全在视窗内
const finalTop = parseFloat(style.top!)
if (finalTop < spacing) {
style.top = `${spacing}px`
} else if (finalTop + panelHeight > viewportHeight - spacing) {
style.top = `${viewportHeight - panelHeight - spacing}px`
}
return style
})
</script> </script>
<template> <template>
@@ -695,6 +961,80 @@ const panelStyle = computed(() => {
</div> </div>
</div> </div>
<!-- 时间选择器弹窗 -->
<Teleport to="body">
<Transition name="picker-fade">
<div
v-if="showTimePicker"
ref="timePickerRef"
class="el-time-picker-panel"
:style="timePickerStyle"
tabindex="-1"
@click.stop
@mousedown.prevent
@focus="handleTimePickerFocus"
@blur="handleTimePickerBlur"
>
<div class="el-time-picker-header">
<span class="el-time-picker-title">{{ t.selectTime }}</span>
</div>
<div class="el-time-picker-content">
<!-- 小时选择 -->
<div class="el-time-column">
<div class="el-time-column-header">{{ t.hour }}</div>
<div ref="hourListRef" class="el-time-column-list">
<div
v-for="hour in hourOptions"
:key="hour"
class="el-time-item"
:class="{ 'is-active': hour === tempHour }"
@click.stop="selectHour(hour)"
@mousedown.prevent
>
{{ String(hour).padStart(2, '0') }}
</div>
</div>
</div>
<!-- 分钟选择 -->
<div class="el-time-column">
<div class="el-time-column-header">{{ t.minute }}</div>
<div ref="minuteListRef" class="el-time-column-list">
<div
v-for="minute in minuteOptions"
:key="minute"
class="el-time-item"
:class="{ 'is-active': minute === tempMinute }"
@click.stop="selectMinute(minute)"
@mousedown.prevent
>
{{ String(minute).padStart(2, '0') }}
</div>
</div>
</div>
</div>
<div class="el-time-picker-footer">
<button
class="el-time-picker-btn el-time-picker-btn--cancel"
@click.stop="closeTimePicker"
@mousedown.prevent
>
{{ t.cancel }}
</button>
<button
class="el-time-picker-btn el-time-picker-btn--confirm"
@click.stop="confirmTime"
@mousedown.prevent
>
{{ t.confirm }}
</button>
</div>
</div>
</Transition>
</Teleport>
<!-- 日期选择器面板 --> <!-- 日期选择器面板 -->
<Teleport to="body"> <Teleport to="body">
<Transition name="picker-fade"> <Transition name="picker-fade">
@@ -814,6 +1154,32 @@ const panelStyle = computed(() => {
</div> </div>
</div> </div>
</div> </div>
<!-- 时间选择器输入框 -->
<div class="el-time-picker-input">
<label class="el-time-picker-label">{{ t.time }}:</label>
<input
ref="timeInputRef"
type="text"
class="el-time-input"
:value="selectedTime"
:placeholder="t.selectTime"
readonly
@click="openTimePicker($event)"
@mousedown.prevent
/>
</div>
<!-- 日期选择器确认按钮 -->
<div class="el-date-picker-footer">
<button
class="el-date-picker-btn el-date-picker-btn--confirm"
@click.stop="confirmDate"
@mousedown.prevent
>
{{ t.confirm }}
</button>
</div>
</div> </div>
</div> </div>
</Transition> </Transition>
@@ -1444,6 +1810,38 @@ const panelStyle = computed(() => {
background: var(--gantt-primary-light, #ecf5ff); background: var(--gantt-primary-light, #ecf5ff);
} }
/* 日期选择器确认按钮样式 */
.el-date-picker-footer {
padding: 8px 0 0;
border-top: 1px solid var(--gantt-border-light, #ebeef5);
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
.el-date-picker-btn {
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid;
outline: none;
height: 24px;
line-height: 14px;
}
.el-date-picker-btn--confirm {
background: var(--gantt-primary, #409eff);
border-color: var(--gantt-primary, #409eff);
color: #ffffff;
}
.el-date-picker-btn--confirm:hover {
background: var(--gantt-primary-dark, #337ecc);
border-color: var(--gantt-primary-dark, #337ecc);
}
/* 暗黑模式下的日期选择器面板 */ /* 暗黑模式下的日期选择器面板 */
:global(html[data-theme='dark']) .el-picker-panel { :global(html[data-theme='dark']) .el-picker-panel {
background: var(--gantt-bg-secondary, #2c2c2c); background: var(--gantt-bg-secondary, #2c2c2c);
@@ -1488,6 +1886,11 @@ const panelStyle = computed(() => {
background: rgba(64, 158, 255, 0.2); background: rgba(64, 158, 255, 0.2);
} }
/* 暗黑模式下的日期选择器确认按钮 */
:global(html[data-theme='dark']) .el-date-picker-footer {
border-top-color: var(--gantt-border-dark, #414243);
}
/* 响应式适配 */ /* 响应式适配 */
@media (max-width: 768px) { @media (max-width: 768px) {
.el-date-picker--large .el-input { .el-date-picker--large .el-input {
@@ -1548,4 +1951,250 @@ const panelStyle = computed(() => {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
} }
/* 时间选择器样式 */
.el-time-picker-input {
padding: 8px 0;
border-top: 1px solid var(--gantt-border-light, #ebeef5);
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.el-time-picker-label {
font-size: 12px;
color: var(--gantt-text-regular, #909399);
font-weight: 500;
min-width: 30px;
}
.el-time-input {
flex: 1;
height: 28px;
padding: 0 8px;
border: 1px solid var(--gantt-border-color, #dcdfe6);
border-radius: 4px;
font-size: 12px;
color: var(--gantt-text-primary, #606266);
background: var(--gantt-bg-primary, #ffffff);
cursor: pointer;
transition: all 0.2s;
}
.el-time-input:hover {
border-color: var(--gantt-border-hover, #c0c4cc);
}
.el-time-input:focus {
outline: none;
border-color: var(--gantt-primary, #409eff);
}
.el-time-picker-panel {
position: fixed;
background: var(--gantt-bg-primary, #ffffff);
border: 1px solid var(--gantt-border-color, #e4e7ed);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10002;
width: 180px;
user-select: none;
padding: 8px;
}
.el-time-picker-header {
padding: 0 8px 8px;
border-bottom: 1px solid var(--gantt-border-light, #ebeef5);
margin-bottom: 8px;
text-align: center;
}
.el-time-picker-title {
font-size: 14px;
font-weight: 500;
color: var(--gantt-text-primary, #303133);
}
.el-time-picker-content {
padding: 0;
display: flex;
gap: 4px;
justify-content: center;
}
.el-time-column {
flex: 0 0 50px;
text-align: center;
}
.el-time-column-header {
font-size: 12px;
font-weight: 500;
color: var(--gantt-text-primary, #606266);
margin-bottom: 4px;
}
.el-time-column-list {
max-height: 160px;
overflow-y: auto;
border-radius: 4px;
/* 隐藏滚动条,但保持滚动功能 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.el-time-column-list::-webkit-scrollbar {
width: 0px;
background: transparent;
}
/* 鼠标悬停时显示滚动条 */
.el-time-column-list:hover {
scrollbar-width: thin;
}
.el-time-column-list:hover::-webkit-scrollbar {
width: 4px;
}
.el-time-column-list:hover::-webkit-scrollbar-track {
background: transparent;
}
.el-time-column-list:hover::-webkit-scrollbar-thumb {
background: var(--gantt-border-color, #dcdfe6);
border-radius: 2px;
}
.el-time-item {
height: 28px;
line-height: 28px;
cursor: pointer;
font-size: 12px;
color: var(--gantt-text-primary, #606266);
transition: all 0.2s;
}
.el-time-item:hover {
background: var(--gantt-bg-hover, #f5f7fa);
color: var(--gantt-primary, #409eff);
}
.el-time-item.is-active {
background: var(--gantt-primary, #409eff);
color: #ffffff;
font-weight: 500;
}
.el-time-picker-footer {
padding: 8px 0 0;
border-top: 1px solid var(--gantt-border-light, #ebeef5);
margin-top: 8px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.el-time-picker-btn {
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid;
outline: none;
height: 24px;
line-height: 14px;
}
.el-time-picker-btn--cancel {
background: var(--gantt-bg-primary, #ffffff);
border-color: var(--gantt-border-color, #dcdfe6);
color: var(--gantt-text-primary, #606266);
}
.el-time-picker-btn--cancel:hover {
background: var(--gantt-bg-hover, #f5f7fa);
border-color: var(--gantt-border-hover, #c0c4cc);
}
.el-time-picker-btn--confirm {
background: var(--gantt-primary, #409eff);
border-color: var(--gantt-primary, #409eff);
color: #ffffff;
}
.el-time-picker-btn--confirm:hover {
background: var(--gantt-primary-dark, #337ecc);
border-color: var(--gantt-primary-dark, #337ecc);
}
/* 暗黑模式下的时间选择器 */
:global(html[data-theme='dark']) .el-time-picker-input {
border-top-color: var(--gantt-border-dark, #414243);
}
:global(html[data-theme='dark']) .el-time-picker-label {
color: var(--gantt-text-secondary, #909399);
}
:global(html[data-theme='dark']) .el-time-input {
background: var(--gantt-bg-secondary, #2c2c2c);
border-color: var(--gantt-border-dark, #414243);
color: var(--gantt-text-white, #ffffff);
}
:global(html[data-theme='dark']) .el-time-input:hover {
border-color: var(--gantt-border-hover, #606266);
}
:global(html[data-theme='dark']) .el-time-picker-panel {
background: var(--gantt-bg-secondary, #2c2c2c);
border-color: var(--gantt-border-dark, #414243);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
:global(html[data-theme='dark']) .el-time-picker-header {
border-bottom-color: var(--gantt-border-dark, #414243);
}
:global(html[data-theme='dark']) .el-time-picker-title {
color: var(--gantt-text-white, #ffffff);
}
:global(html[data-theme='dark']) .el-time-column-header {
color: var(--gantt-text-white, #ffffff);
}
:global(html[data-theme='dark']) .el-time-column-list {
border-color: var(--gantt-border-dark, #414243);
}
:global(html[data-theme='dark']) .el-time-column-list:hover::-webkit-scrollbar-thumb {
background: var(--gantt-border-hover, #606266);
}
:global(html[data-theme='dark']) .el-time-item {
color: var(--gantt-text-white, #ffffff);
}
:global(html[data-theme='dark']) .el-time-item:hover {
background: var(--gantt-bg-hover-dark, #3c3e40);
}
:global(html[data-theme='dark']) .el-time-picker-footer {
border-top-color: var(--gantt-border-dark, #414243);
}
:global(html[data-theme='dark']) .el-time-picker-btn--cancel {
background: var(--gantt-bg-secondary, #2c2c2c);
border-color: var(--gantt-border-dark, #414243);
color: var(--gantt-text-white, #ffffff);
}
:global(html[data-theme='dark']) .el-time-picker-btn--cancel:hover {
background: var(--gantt-bg-hover-dark, #3c3e40);
border-color: var(--gantt-border-hover, #606266);
}
</style> </style>
+16 -6
View File
@@ -36,6 +36,10 @@ const props = withDefaults(defineProps<Props>(), {
onThemeChange: undefined, onThemeChange: undefined,
onFullscreenChange: undefined, onFullscreenChange: undefined,
localeMessages: undefined, localeMessages: undefined,
workingHours: () => ({
morning: { start: 8, end: 11 },
afternoon: { start: 13, end: 17 },
}),
}) })
const emit = defineEmits([ const emit = defineEmits([
@@ -97,6 +101,11 @@ interface Props {
* 仅在组件初始化时合并,运行时变更会自动响应。 * 仅在组件初始化时合并,运行时变更会自动响应。
*/ */
localeMessages?: Partial<import('../composables/useI18n').Messages['zh-CN']> localeMessages?: Partial<import('../composables/useI18n').Messages['zh-CN']>
// 工作时间配置
workingHours?: {
morning?: { start: number; end: number } // 上午工作时间,如 { start: 8, end: 11 }
afternoon?: { start: number; end: number } // 下午工作时间,如 { start: 13, end: 17 }
}
} }
const leftPanelWidth = ref(320) const leftPanelWidth = ref(320)
@@ -249,7 +258,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)
@@ -265,7 +274,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' },
}) }),
) )
}) })
} }
@@ -298,7 +307,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) {
@@ -369,7 +378,7 @@ watch(
notifyTaskListUpdated() notifyTaskListUpdated()
}) })
}, },
{ deep: true, immediate: true } { deep: true, immediate: true },
) )
onMounted(() => { onMounted(() => {
@@ -407,7 +416,7 @@ 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)
@@ -1183,7 +1192,7 @@ watch(
val => { val => {
if (val) setCustomMessages(locale.value, val) if (val) setCustomMessages(locale.value, val)
}, },
{ deep: true } { deep: true },
) )
// 右键菜单状态管理 // 右键菜单状态管理
@@ -1543,6 +1552,7 @@ function handleTaskDelete(task: Task, deleteChildren?: boolean) {
:milestones="props.milestones" :milestones="props.milestones"
:start-date="timelineDateRange.min" :start-date="timelineDateRange.min"
:end-date="timelineDateRange.max" :end-date="timelineDateRange.max"
:working-hours="props.workingHours"
: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"
+45 -26
View File
@@ -277,19 +277,52 @@ const handleTimeScaleChange = (scale: TimelineScale) => {
} }
} }
//
const availableTimeScales = computed(() => {
const defaultScales = ['hour', 'day', 'week', 'month', 'year'] as const
return props.config?.timeScaleDimensions || defaultScales
})
//
const timeScaleMap = {
hour: { value: TimelineScale.HOUR, label: () => t('timeScaleHour') },
day: { value: TimelineScale.DAY, label: () => t('timeScaleDay') },
week: { value: TimelineScale.WEEK, label: () => t('timeScaleWeek') },
month: { value: TimelineScale.MONTH, label: () => t('timeScaleMonth') },
quarter: { value: TimelineScale.QUARTER, label: () => t('timeScaleQuarter') },
year: { value: TimelineScale.YEAR, label: () => t('timeScaleYear') },
}
//
const currentTimeScaleKey = computed(() => {
const entry = Object.entries(timeScaleMap).find(
([, config]) => config.value === currentTimeScale.value,
)
return entry ? entry[0] : 'day'
})
// //
const getThumbStyle = () => { const getThumbStyle = () => {
const scaleIndex = { const currentIndex = availableTimeScales.value.findIndex(
[TimelineScale.MONTH]: 0, scale => scale === currentTimeScaleKey.value,
[TimelineScale.WEEK]: 1, )
[TimelineScale.DAY]: 2,
const totalScales = availableTimeScales.value.length
if (totalScales === 0 || currentIndex < 0) {
return {
transform: 'translateX(0%)',
width: `${100 / totalScales || 25}%`,
}
} }
const index = scaleIndex[currentTimeScale.value] || 0 //
const translateX = index * 100 // 33.33%100% const itemWidth = 100 / totalScales
//
const translateX = currentIndex * 100
return { return {
transform: `translateX(${translateX}%)`, transform: `translateX(${translateX}%)`,
width: `${itemWidth}%`,
} }
} }
@@ -451,28 +484,14 @@ onUnmounted(() => {
<div class="segmented-thumb" :style="getThumbStyle()"></div> <div class="segmented-thumb" :style="getThumbStyle()"></div>
</div> </div>
<button <button
v-for="scale in availableTimeScales"
:key="scale"
class="segmented-item" class="segmented-item"
:class="{ active: currentTimeScale === 'month' }" :class="{ active: currentTimeScaleKey === scale }"
:title="t('timeScaleTooltip')" :title="t('timeScaleTooltip')"
@click="handleTimeScaleChange(TimelineScale.MONTH)" @click="handleTimeScaleChange(timeScaleMap[scale].value)"
> >
{{ t('timeScaleMonth') }} {{ timeScaleMap[scale].label() }}
</button>
<button
class="segmented-item"
:class="{ active: currentTimeScale === 'week' }"
:title="t('timeScaleTooltip')"
@click="handleTimeScaleChange(TimelineScale.WEEK)"
>
{{ t('timeScaleWeek') }}
</button>
<button
class="segmented-item"
:class="{ active: currentTimeScale === 'day' }"
:title="t('timeScaleTooltip')"
@click="handleTimeScaleChange(TimelineScale.DAY)"
>
{{ t('timeScaleDay') }}
</button> </button>
</div> </div>
<!-- 语言选择下拉菜单 --> <!-- 语言选择下拉菜单 -->
@@ -1175,7 +1194,7 @@ onUnmounted(() => {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 33.333333%; width: 25%; /* 默认宽度,将通过内联样式动态设置 */
height: 100%; height: 100%;
background: var(--gantt-primary, #409eff); background: var(--gantt-primary, #409eff);
border-radius: 5px; border-radius: 5px;
+214 -47
View File
@@ -1,8 +1,19 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue' import { computed, ref, onUnmounted } from 'vue'
import type { Milestone } from '../models/classes/Milestone' import type { Milestone } from '../models/classes/Milestone'
import { TimelineScale } from '../models/types/TimelineScale' import { TimelineScale } from '../models/types/TimelineScale'
import { useI18n } from '../composables/useI18n' import { useI18n } from '../composables/useI18n'
import { createLocalDate } from '../utils/predecessorUtils'
const props = defineProps<Props>()
//
const emit = defineEmits<{
'milestone-double-click': [milestone: Milestone]
'update:milestone': [milestone: Milestone] //
'drag-end': [milestone: Milestone] //
}>()
const { getTranslation } = useI18n() const { getTranslation } = useI18n()
const t = (key: string): string => { const t = (key: string): string => {
@@ -30,27 +41,11 @@ interface Props {
priority: number // priority: number //
}> // }> //
// 线subDays // 线subDays
timelineData?: Array<{ timelineData?: unknown[]
year: number
month: number
startDate: Date
endDate: Date
subDays?: Array<{ date: Date; dayOfWeek?: number }>
monthData?: { dayCount: number }
}>
// //
currentTimeScale?: TimelineScale currentTimeScale?: TimelineScale
} }
const props = defineProps<Props>()
//
const emit = defineEmits<{
'milestone-double-click': [milestone: Milestone]
'update:milestone': [milestone: Milestone] //
'drag-end': [milestone: Milestone] //
}>()
// //
const isDragging = ref(false) const isDragging = ref(false)
const dragStartX = ref(0) const dragStartX = ref(0)
@@ -157,7 +152,7 @@ const handleMouseMove = (e: MouseEvent) => {
mouseX: e.clientX, mouseX: e.clientX,
isDragging: isDragging.value, isDragging: isDragging.value,
}, },
}) }),
) )
const deltaX = e.clientX - dragStartX.value const deltaX = e.clientX - dragStartX.value
@@ -184,7 +179,7 @@ const handleMouseUp = () => {
mouseX: 0, mouseX: 0,
isDragging: false, isDragging: false,
}, },
}) }),
) )
// //
@@ -246,19 +241,18 @@ const handleMilestoneClick = (e: MouseEvent) => {
scrollLeft: targetScrollLeft, scrollLeft: targetScrollLeft,
smooth: true, smooth: true,
}, },
}) }),
) )
} }
} }
// - // -
const milestoneStyle = computed(() => { const milestoneStyle = computed(() => {
const milestoneDate = tempMilestoneData.value?.startDate const currentMilestoneDate = tempMilestoneData.value?.startDate || props.date
? new Date(tempMilestoneData.value.startDate) const milestoneDate = createLocalDate(currentMilestoneDate)
: new Date(props.date)
// props.startDate undefined // startDate
if (!props.startDate || isNaN(new Date(props.date).getTime())) { if (!props.startDate || !milestoneDate || isNaN(milestoneDate.getTime())) {
return { return {
left: '0px', left: '0px',
top: '0px', top: '0px',
@@ -268,25 +262,56 @@ const milestoneStyle = computed(() => {
} }
let left = 0 let left = 0
const size = Math.min(props.rowHeight, props.dayWidth * 1.2, 24) // 使
let size = 24 //
// 使timelineData
if ( if (
props.currentTimeScale === TimelineScale.YEAR ||
props.currentTimeScale === TimelineScale.QUARTER
) {
// 使dayWidth
size = Math.min(props.rowHeight, 24)
} else if (props.currentTimeScale === TimelineScale.MONTH) {
// 使dayWidthdayWidth
size = Math.min(props.rowHeight, 20)
} else if (props.currentTimeScale === TimelineScale.WEEK) {
// dayWidth
size = Math.min(props.rowHeight, Math.max(props.dayWidth * 0.8, 16), 24)
} else {
//
size = Math.min(props.rowHeight, props.dayWidth * 1.2, 24)
}
// 使
if (props.currentTimeScale === TimelineScale.YEAR) {
const centerPosition = calculateYearViewMilestonePosition(milestoneDate, props.startDate)
left = centerPosition - size / 2 //
} else if (props.currentTimeScale === TimelineScale.QUARTER) {
// 使
const centerPosition = calculateQuarterViewMilestonePosition(milestoneDate, props.startDate)
left = centerPosition - size / 2 //
} else if (props.currentTimeScale === TimelineScale.HOUR) {
//
const centerPosition = calculateHourViewMilestonePosition(milestoneDate, props.startDate)
left = centerPosition - size / 2 //
} else if (
props.timelineData && props.timelineData &&
props.currentTimeScale && props.currentTimeScale &&
(props.currentTimeScale === TimelineScale.WEEK || (props.currentTimeScale === TimelineScale.WEEK ||
props.currentTimeScale === TimelineScale.MONTH) props.currentTimeScale === TimelineScale.MONTH)
) { ) {
// 使timelineData
const centerPosition = calculateMilestonePositionFromTimelineData( const centerPosition = calculateMilestonePositionFromTimelineData(
milestoneDate, milestoneDate,
props.timelineData, props.timelineData,
props.currentTimeScale props.currentTimeScale,
) )
left = centerPosition - size / 2 // left = centerPosition - size / 2 //
} else { } else {
// //
const startDiff = Math.floor( const startDiff = Math.floor(
(milestoneDate.getTime() - props.startDate.getTime()) / (1000 * 60 * 60 * 24) (milestoneDate.getTime() - props.startDate.getTime()) / (1000 * 60 * 60 * 24),
) )
left = startDiff * props.dayWidth + props.dayWidth / 2 - size / 2 left = startDiff * props.dayWidth + props.dayWidth / 2 - size / 2
} }
@@ -352,7 +377,7 @@ const milestoneVisibility = computed(() => {
if (iconRight <= leftBoundary + iconSize / 2) { if (iconRight <= leftBoundary + iconSize / 2) {
// //
const leftStickyMilestones = otherMilestones.filter( const leftStickyMilestones = otherMilestones.filter(
m => m.id !== currentId && m.stickyPosition === 'left' && m.isSticky m => m.id !== currentId && m.stickyPosition === 'left' && m.isSticky,
) )
// //
@@ -398,7 +423,7 @@ const milestoneVisibility = computed(() => {
if (iconLeft >= rightBoundary - iconSize / 2) { if (iconLeft >= rightBoundary - iconSize / 2) {
// //
const rightStickyMilestones = otherMilestones.filter( const rightStickyMilestones = otherMilestones.filter(
m => m.id !== currentId && m.stickyPosition === 'right' && m.isSticky m => m.id !== currentId && m.stickyPosition === 'right' && m.isSticky,
) )
// //
@@ -517,24 +542,161 @@ onUnmounted(() => {
document.removeEventListener('mouseup', handleMouseUp) document.removeEventListener('mouseup', handleMouseUp)
}) })
//
const calculateYearViewMilestonePosition = (targetDate: Date, baseStartDate: Date): number => {
const targetYear = targetDate.getFullYear()
const baseYear = baseStartDate.getFullYear()
// 360px180px
const yearWidth = 360
const halfYearWidth = 180
//
const yearOffset = targetYear - baseYear
let position = yearOffset * yearWidth
//
const month = targetDate.getMonth() + 1 // getMonth()0-11+1
if (month > 6) {
//
position += halfYearWidth
}
//
let dayOffset = 0
let startOfHalfYear: Date
if (month <= 6) {
// 1-6
startOfHalfYear = new Date(targetYear, 0, 1) // 11
} else {
// 7-12
startOfHalfYear = new Date(targetYear, 6, 1) // 71
}
dayOffset = Math.floor((targetDate.getTime() - startOfHalfYear.getTime()) / (1000 * 60 * 60 * 24))
// 181-184180px
const daysInHalfYear =
month <= 6
? Math.floor(
(new Date(targetYear, 6, 1).getTime() - new Date(targetYear, 0, 1).getTime()) /
(1000 * 60 * 60 * 24),
)
: Math.floor(
(new Date(targetYear + 1, 0, 1).getTime() - new Date(targetYear, 6, 1).getTime()) /
(1000 * 60 * 60 * 24),
)
const dayPositionInHalfYear = (dayOffset / daysInHalfYear) * halfYearWidth
position += dayPositionInHalfYear
return position
}
// -
const calculateHourViewMilestonePosition = (targetDate: Date, baseStartDate: Date): number => {
//
const targetNormalized = new Date(
targetDate.getFullYear(),
targetDate.getMonth(),
targetDate.getDate(),
)
const baseNormalized = new Date(
baseStartDate.getFullYear(),
baseStartDate.getMonth(),
baseStartDate.getDate(),
)
const timeDiff = targetNormalized.getTime() - baseNormalized.getTime()
const daysDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24))
// 960px (24 * 40px)
const dayWidth = 960
const baseDayPosition = daysDiff * dayWidth
// 40px
const currentHour = targetDate.getHours()
const hourOffset = currentHour * 40
//
const currentMinute = targetDate.getMinutes()
const minuteOffset = (currentMinute / 60) * 40
const totalPosition = baseDayPosition + hourOffset + minuteOffset
return totalPosition
}
// -
const calculateQuarterViewMilestonePosition = (targetDate: Date, baseStartDate: Date): number => {
const targetYear = targetDate.getFullYear()
const baseYear = baseStartDate.getFullYear()
// 240px (4 * 60px)60px
const yearWidth = 240
const quarterWidth = 60
//
const yearOffset = targetYear - baseYear
let position = yearOffset * yearWidth
//
const month = targetDate.getMonth() + 1 // getMonth()0-11+1
let quarter = 1
if (month >= 1 && month <= 3) {
quarter = 1 // Q1: 1-3
} else if (month >= 4 && month <= 6) {
quarter = 2 // Q2: 4-6
} else if (month >= 7 && month <= 9) {
quarter = 3 // Q3: 7-9
} else {
quarter = 4 // Q4: 10-12
}
// (Q1=0px, Q2=60px, Q3=120px, Q4=180px)
position += (quarter - 1) * quarterWidth
//
let dayOffset = 0
let startOfQuarter: Date
let endOfQuarter: Date
if (quarter === 1) {
startOfQuarter = new Date(targetYear, 0, 1) // 11
endOfQuarter = new Date(targetYear, 2, 31) // 331
} else if (quarter === 2) {
startOfQuarter = new Date(targetYear, 3, 1) // 41
endOfQuarter = new Date(targetYear, 5, 30) // 630
} else if (quarter === 3) {
startOfQuarter = new Date(targetYear, 6, 1) // 71
endOfQuarter = new Date(targetYear, 8, 30) // 930
} else {
startOfQuarter = new Date(targetYear, 9, 1) // 101
endOfQuarter = new Date(targetYear, 11, 31) // 1231
}
dayOffset = Math.floor((targetDate.getTime() - startOfQuarter.getTime()) / (1000 * 60 * 60 * 24))
// 60px
const endTime = endOfQuarter.getTime()
const startTime = startOfQuarter.getTime()
const daysInQuarter = Math.floor((endTime - startTime) / (1000 * 60 * 60 * 24)) + 1 //
const dayPositionInQuarter = (dayOffset / daysInQuarter) * quarterWidth
position += dayPositionInQuarter
return position
}
// timelineDatasubDays // timelineDatasubDays
const calculateMilestonePositionFromTimelineData = ( const calculateMilestonePositionFromTimelineData = (
targetDate: Date, targetDate: Date,
timelineData: Array<{ timelineData: any,
year: number timeScale: TimelineScale,
month: number
startDate: Date
endDate: Date
subDays?: Array<{ date: Date; dayOfWeek?: number }>
monthData?: { dayCount: number }
weeks?: Array<{
weekStart: Date
weekEnd: Date
subDays: Array<{ date: Date; dayOfWeek?: number }>
}>
}>,
timeScale: TimelineScale
) => { ) => {
// 退
let cumulativePosition = 0 let cumulativePosition = 0
for (const periodData of timelineData) { for (const periodData of timelineData) {
@@ -567,8 +729,11 @@ const calculateMilestonePositionFromTimelineData = (
} }
// 退dayOfWeek // 退dayOfWeek
// getDay()0=1=...6=
// subDays0=1=...6=
const dayOfWeek = targetDate.getDay() const dayOfWeek = targetDate.getDay()
return cumulativePosition + dayOfWeek * dayWidth + dayWidth / 2 const adjustedDayIndex = dayOfWeek === 0 ? 6 : dayOfWeek - 1 // subDays
return cumulativePosition + adjustedDayIndex * dayWidth + dayWidth / 2
} }
// //
@@ -585,7 +750,9 @@ const calculateMilestonePositionFromTimelineData = (
const daysInMonth = periodData.monthData?.dayCount || 30 const daysInMonth = periodData.monthData?.dayCount || 30
const dayWidth = monthWidth / daysInMonth const dayWidth = monthWidth / daysInMonth
const dayInMonth = targetDate.getDate() const dayInMonth = targetDate.getDate()
return cumulativePosition + (dayInMonth - 1) * dayWidth + dayWidth / 2 const finalPosition = cumulativePosition + (dayInMonth - 1) * dayWidth + dayWidth / 2
return finalPosition
} }
// //
+2 -2
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,7 +69,7 @@ watch(
() => { () => {
selectedValue.value = '' selectedValue.value = ''
}, },
{ immediate: true } { immediate: true },
) )
</script> </script>
+361 -85
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
<script setup lang="ts"> <script setup lang="ts">
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'
@@ -19,25 +20,13 @@ interface Props {
// Timeline // Timeline
hideBubbles?: boolean hideBubbles?: boolean
// 线subDays // 线subDays
timelineData?: Array<{ timelineData?: any
year: number
month: number
startDate: Date
endDate: Date
subDays?: Array<{ date: Date; dayOfWeek?: number }>
monthData?: { dayCount: number }
}>
// //
currentTimeScale?: TimelineScale currentTimeScale?: TimelineScale
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const { getTranslation } = useI18n()
const t = (key: string): string => {
return getTranslation(key)
}
const emit = defineEmits([ const emit = defineEmits([
'update:task', 'update:task',
'bar-mounted', 'bar-mounted',
@@ -50,7 +39,12 @@ const emit = defineEmits([
'add-predecessor', 'add-predecessor',
'add-successor', 'add-successor',
'delete', 'delete',
'contextmenu', // contextmenu
]) ])
const { getTranslation } = useI18n()
const t = (key: string): string => {
return getTranslation(key)
}
// - // -
const createLocalDate = (dateString: string | Date | undefined | null): Date | null => { const createLocalDate = (dateString: string | Date | undefined | null): Date | null => {
@@ -62,6 +56,13 @@ const createLocalDate = (dateString: string | Date | undefined | null): Date | n
const [year, month, day] = dateString.split('-').map(Number) const [year, month, day] = dateString.split('-').map(Number)
return new Date(year, month - 1, day) return new Date(year, month - 1, day)
} }
// (yyyy-mm-dd hh:mm)
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(dateString)) {
const [datePart, timePart] = dateString.split(' ')
const [year, month, day] = datePart.split('-').map(Number)
const [hour, minute] = timePart.split(':').map(Number)
return new Date(year, month - 1, day, hour, minute)
}
const d = new Date(dateString) const d = new Date(dateString)
return isNaN(d.getTime()) ? null : d return isNaN(d.getTime()) ? null : d
} }
@@ -75,6 +76,14 @@ const formatDateToLocalString = (date: Date): string => {
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')
//
if (props.currentTimeScale === TimelineScale.HOUR) {
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
return `${year}-${month}-${day}` return `${year}-${month}-${day}`
} }
@@ -84,6 +93,23 @@ const addDaysToLocalDate = (date: Date, days: number): Date => {
return result return result
} }
//
const addMinutesToDate = (date: Date, minutes: number): Date => {
const result = new Date(date)
result.setMinutes(result.getMinutes() + minutes)
return result
}
//
const getMinutesDiff = (startDate: Date, endDate: Date): number => {
return Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60))
}
//
const isInteractionDisabled = computed(() => {
return props.currentTimeScale === TimelineScale.YEAR
})
// //
const isDragging = ref(false) const isDragging = ref(false)
const isResizingLeft = ref(false) const isResizingLeft = ref(false)
@@ -126,51 +152,125 @@ const taskBarStyle = computed(() => {
let left = 0 let left = 0
let width = 0 let width = 0
// 使timelineData //
if ( if (props.currentTimeScale === TimelineScale.HOUR) {
props.timelineData && // baseStart 00:00:00
props.currentTimeScale && const baseStartOfDay = new Date(baseStart)
(props.currentTimeScale === TimelineScale.WEEK || baseStartOfDay.setHours(0, 0, 0, 0)
props.currentTimeScale === TimelineScale.MONTH)
) {
//
const startPosition = calculatePositionFromTimelineData(
startDate,
props.timelineData,
props.currentTimeScale,
)
//
const nextDay = new Date(endDate)
nextDay.setDate(nextDay.getDate() + 1)
let endPosition = calculatePositionFromTimelineData(
nextDay,
props.timelineData,
props.currentTimeScale,
)
// +1使+ //
if (endPosition === startPosition) { let adjustedStartDate = startDate
const dayWidth = props.currentTimeScale === TimelineScale.WEEK ? 60 / 7 : 60 / 30 let adjustedEndDate = endDate
endPosition =
calculatePositionFromTimelineData(endDate, props.timelineData, props.currentTimeScale) + //
dayWidth const originalStartStr = currentStartDate || props.task.startDate
const originalEndStr = currentEndDate || props.task.endDate
// startDateYYYY-MM-DD00:00
if (
typeof originalStartStr === 'string' &&
/^\d{4}-\d{2}-\d{2}$/.test(originalStartStr.trim())
) {
adjustedStartDate = new Date(startDate)
adjustedStartDate.setHours(0, 0, 0, 0)
} }
left = startPosition // endDateYYYY-MM-DD00:00
width = Math.max(endPosition - startPosition, 4) // 4px if (typeof originalEndStr === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(originalEndStr.trim())) {
} else { adjustedEndDate = new Date(endDate)
// adjustedEndDate.setDate(adjustedEndDate.getDate() + 1)
const startDiff = Math.floor( adjustedEndDate.setHours(0, 0, 0, 0)
(startDate.getTime() - baseStart.getTime()) / (1000 * 60 * 60 * 24), }
)
// duration
const timeDiffMs = endDate.getTime() - startDate.getTime()
const daysDiff = timeDiffMs / (1000 * 60 * 60 * 24)
//
const duration = Math.floor(daysDiff) + 1
left = startDiff * props.dayWidth // 00:00
width = duration * props.dayWidth const startMinutes = getMinutesDiff(baseStartOfDay, adjustedStartDate)
const endMinutes = getMinutesDiff(baseStartOfDay, adjustedEndDate)
// 40px40/60 = 2/3 px
const pixelPerMinute = 40 / 60
left = Math.max(0, startMinutes * pixelPerMinute)
width = Math.max(4, (endMinutes - startMinutes) * pixelPerMinute) // 4px
} else {
//
// 00:00:00
const startDateOnly = new Date(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate(),
)
const endDateOnly = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
const baseStartOnly = new Date(
baseStart.getFullYear(),
baseStart.getMonth(),
baseStart.getDate(),
)
if (props.currentTimeScale === TimelineScale.YEAR) {
//
const startPosition = calculateYearViewPosition(startDateOnly, baseStartOnly)
const endPosition = calculateYearViewPosition(endDateOnly, baseStartOnly)
left = startPosition
width = Math.max(endPosition - startPosition, 4) // 4px
} else if (
props.timelineData &&
props.currentTimeScale &&
(props.currentTimeScale === TimelineScale.WEEK ||
props.currentTimeScale === TimelineScale.MONTH ||
props.currentTimeScale === TimelineScale.QUARTER)
) {
// 使timelineData
//
const startPosition = calculatePositionFromTimelineData(
startDateOnly,
props.timelineData,
props.currentTimeScale,
)
//
const nextDay = new Date(endDateOnly)
nextDay.setDate(nextDay.getDate() + 1)
let endPosition = calculatePositionFromTimelineData(
nextDay,
props.timelineData,
props.currentTimeScale,
)
// +1使+
if (endPosition === startPosition) {
let dayWidth = 60 / 30 //
if (props.currentTimeScale === TimelineScale.WEEK) {
dayWidth = 60 / 7
} else if (props.currentTimeScale === TimelineScale.QUARTER) {
dayWidth = 60 / 90 // 60px90
}
endPosition =
calculatePositionFromTimelineData(
endDateOnly,
props.timelineData,
props.currentTimeScale,
) + dayWidth
}
left = startPosition
width = Math.max(endPosition - startPosition, 4) // 4px
} else {
//
const startDiff = Math.floor(
(startDateOnly.getTime() - baseStartOnly.getTime()) / (1000 * 60 * 60 * 24),
)
//
const timeDiffMs = endDateOnly.getTime() - startDateOnly.getTime()
const daysDiff = Math.round(timeDiffMs / (1000 * 60 * 60 * 24))
// duration = 1 + 1
const duration = daysDiff === 0 ? 1 : daysDiff + 1
left = startDiff * props.dayWidth
width = duration * props.dayWidth
}
} }
return { return {
@@ -260,8 +360,8 @@ const needsOverflowEffect = computed(() => isWeekView.value && isShortTaskBar.va
// - 使 // - 使
const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-right') => { const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-right') => {
// //
if (isCompleted.value || props.isParent) { if (isCompleted.value || props.isParent || isInteractionDisabled.value) {
return return
} }
@@ -342,39 +442,132 @@ const handleMouseMove = (e: MouseEvent) => {
if (isDragging.value) { if (isDragging.value) {
const deltaX = e.clientX - dragStartX.value const deltaX = e.clientX - dragStartX.value
const newLeft = Math.max(0, dragStartLeft.value + deltaX)
const newStartDate = addDaysToLocalDate(props.startDate, newLeft / props.dayWidth)
const duration = dragStartWidth.value / props.dayWidth
const newEndDate = addDaysToLocalDate(newStartDate, duration - 1)
// if (props.currentTimeScale === TimelineScale.HOUR) {
tempTaskData.value = { // 15
startDate: formatDateToLocalString(newStartDate), const pixelPerMinute = 40 / 60 //
endDate: formatDateToLocalString(newEndDate), const pixelPer15Minutes = pixelPerMinute * 15 // 15
// 15
const newLeftRaw = Math.max(0, dragStartLeft.value + deltaX)
const newLeft = Math.round(newLeftRaw / pixelPer15Minutes) * pixelPer15Minutes
//
const newStartMinutes = Math.round(newLeft / pixelPerMinute)
// 使00:00:00
const baseStartOfDay = new Date(props.startDate)
baseStartOfDay.setHours(0, 0, 0, 0)
const newStartDate = addMinutesToDate(baseStartOfDay, newStartMinutes)
//
const originalStartDate = createLocalDate(props.task.startDate) || props.startDate
const originalEndDate = createLocalDate(props.task.endDate) || props.startDate
//
let originalDurationMinutes: number
if (props.task.startDate && !props.task.startDate.includes(' ')) {
// = 1440
const timeDiffMs = originalEndDate.getTime() - originalStartDate.getTime()
const daysDiff = Math.max(1, Math.round(timeDiffMs / (1000 * 60 * 60 * 24)) + 1)
originalDurationMinutes = daysDiff * 24 * 60 //
} else {
//
originalDurationMinutes = getMinutesDiff(originalStartDate, originalEndDate)
}
const newEndDate = addMinutesToDate(newStartDate, originalDurationMinutes)
//
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
} else {
//
const newLeft = Math.max(0, dragStartLeft.value + deltaX)
const newStartDate = addDaysToLocalDate(props.startDate, newLeft / props.dayWidth)
const duration = dragStartWidth.value / props.dayWidth
const newEndDate = addDaysToLocalDate(newStartDate, duration - 1)
//
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
} }
} else if (isResizingLeft.value) { } else if (isResizingLeft.value) {
const deltaX = e.clientX - resizeStartX.value const deltaX = e.clientX - resizeStartX.value
const newLeft = Math.max(0, resizeStartLeft.value + deltaX)
const newStartDate = addDaysToLocalDate(props.startDate, newLeft / props.dayWidth)
// if (props.currentTimeScale === TimelineScale.HOUR) {
tempTaskData.value = { // 15
startDate: formatDateToLocalString(newStartDate), const pixelPerMinute = 40 / 60
endDate: props.task.endDate, // const pixelPer15Minutes = pixelPerMinute * 15
// 15
const newLeftRaw = Math.max(0, resizeStartLeft.value + deltaX)
const newLeft = Math.round(newLeftRaw / pixelPer15Minutes) * pixelPer15Minutes
//
const newStartMinutes = Math.round(newLeft / pixelPerMinute)
// 使00:00:00
const baseStartOfDay = new Date(props.startDate)
baseStartOfDay.setHours(0, 0, 0, 0)
const newStartDate = addMinutesToDate(baseStartOfDay, newStartMinutes)
//
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate, //
}
} else {
//
const newLeft = Math.max(0, resizeStartLeft.value + deltaX)
const newStartDate = addDaysToLocalDate(props.startDate, newLeft / props.dayWidth)
//
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate, //
}
} }
} else if (isResizingRight.value) { } else if (isResizingRight.value) {
const deltaX = e.clientX - resizeStartX.value const deltaX = e.clientX - resizeStartX.value
const newWidth = Math.max(props.dayWidth, resizeStartWidth.value + deltaX)
const newDurationDays = newWidth / props.dayWidth
const newEndDate = addDaysToLocalDate(
props.startDate,
resizeStartLeft.value / props.dayWidth + newDurationDays - 1,
)
// if (props.currentTimeScale === TimelineScale.HOUR) {
tempTaskData.value = { // 15
startDate: props.task.startDate, // const pixelPerMinute = 40 / 60
endDate: formatDateToLocalString(newEndDate), const pixelPer15Minutes = pixelPerMinute * 15
// 15
const newWidthRaw = Math.max(pixelPer15Minutes, resizeStartWidth.value + deltaX)
const newWidth = Math.round(newWidthRaw / pixelPer15Minutes) * pixelPer15Minutes
//
const newDurationMinutes = Math.round(newWidth / pixelPerMinute)
//
const originalStartDate = createLocalDate(props.task.startDate) || props.startDate
const newEndDate = addMinutesToDate(originalStartDate, newDurationMinutes)
//
tempTaskData.value = {
startDate: props.task.startDate, //
endDate: formatDateToLocalString(newEndDate),
}
} else {
//
const newWidth = Math.max(props.dayWidth, resizeStartWidth.value + deltaX)
const newDurationDays = newWidth / props.dayWidth
const newEndDate = addDaysToLocalDate(
props.startDate,
resizeStartLeft.value / props.dayWidth + newDurationDays - 1,
)
//
tempTaskData.value = {
startDate: props.task.startDate, //
endDate: formatDateToLocalString(newEndDate),
}
} }
} }
} }
@@ -901,6 +1094,59 @@ const getProgressStyles = () => {
return result return result
} }
//
const calculateYearViewPosition = (targetDate: Date, baseStartDate: Date): number => {
const targetYear = targetDate.getFullYear()
const baseYear = baseStartDate.getFullYear()
// 360px180px
const yearWidth = 360
const halfYearWidth = 180
//
const yearOffset = targetYear - baseYear
let position = yearOffset * yearWidth
//
const month = targetDate.getMonth() + 1 // getMonth()0-11+1
if (month > 6) {
//
position += halfYearWidth
}
//
let dayOffset = 0
let startOfHalfYear: Date
if (month <= 6) {
// 1-6
startOfHalfYear = new Date(targetYear, 0, 1) // 11
} else {
// 7-12
startOfHalfYear = new Date(targetYear, 6, 1) // 71
}
dayOffset = Math.floor((targetDate.getTime() - startOfHalfYear.getTime()) / (1000 * 60 * 60 * 24))
// 181-184180px
const daysInHalfYear =
month <= 6
? Math.floor(
(new Date(targetYear, 6, 1).getTime() - new Date(targetYear, 0, 1).getTime()) /
(1000 * 60 * 60 * 24),
)
: Math.floor(
(new Date(targetYear + 1, 0, 1).getTime() - new Date(targetYear, 6, 1).getTime()) /
(1000 * 60 * 60 * 24),
)
const dayPositionInHalfYear = (dayOffset / daysInHalfYear) * halfYearWidth
position += dayPositionInHalfYear
return position
}
// timelineDatasubDays // timelineDatasubDays
const calculatePositionFromTimelineData = ( const calculatePositionFromTimelineData = (
targetDate: Date, targetDate: Date,
@@ -922,7 +1168,32 @@ const calculatePositionFromTimelineData = (
let cumulativePosition = 0 let cumulativePosition = 0
for (const periodData of timelineData) { for (const periodData of timelineData) {
if (timeScale === TimelineScale.WEEK) { if (timeScale === TimelineScale.QUARTER) {
// quarters
const quarters = ((periodData as Record<string, unknown>).quarters as unknown[]) || []
for (const quarter of quarters) {
const quarterObj = quarter as Record<string, unknown>
const quarterStart = new Date(quarterObj.startDate as string)
const quarterEnd = new Date(quarterObj.endDate as string)
if (targetDate >= quarterStart && targetDate <= quarterEnd) {
//
const quarterWidth = 60
const daysInQuarter = Math.ceil(
(quarterEnd.getTime() - quarterStart.getTime()) / (1000 * 60 * 60 * 24),
)
const dayWidth = quarterWidth / daysInQuarter
const dayInQuarter = Math.ceil(
(targetDate.getTime() - quarterStart.getTime()) / (1000 * 60 * 60 * 24),
)
return cumulativePosition + dayInQuarter * dayWidth
}
//
cumulativePosition += 60
}
} else if (timeScale === TimelineScale.WEEK) {
// weeks // weeks
const weeks = periodData.weeks || [] const weeks = periodData.weeks || []
@@ -1056,13 +1327,18 @@ onUnmounted(() => {
<!-- 左侧调整把手 --> <!-- 左侧调整把手 -->
<div <div
v-if="!isCompleted && !isParent" v-if="!isCompleted && !isParent && !isInteractionDisabled"
class="resize-handle resize-handle-left" class="resize-handle resize-handle-left"
@mousedown="e => handleMouseDown(e, 'resize-left')" @mousedown="e => handleMouseDown(e, 'resize-left')"
></div> ></div>
<!-- 任务条主体非父级任务 --> <!-- 任务条主体非父级任务 -->
<div v-if="!isParent" class="task-bar-content" @mousedown="e => handleMouseDown(e, 'drag')"> <div
v-if="!isParent"
class="task-bar-content"
:style="{ cursor: isInteractionDisabled ? 'default' : 'move' }"
@mousedown="e => (isInteractionDisabled ? null : handleMouseDown(e, 'drag'))"
>
<!-- 任务名称 --> <!-- 任务名称 -->
<div class="task-name" :style="getNameStyles()"> <div class="task-name" :style="getNameStyles()">
{{ task.name }} {{ task.name }}
@@ -1076,7 +1352,7 @@ onUnmounted(() => {
<!-- 右侧调整把手 --> <!-- 右侧调整把手 -->
<div <div
v-if="!isCompleted && !isParent" v-if="!isCompleted && !isParent && !isInteractionDisabled"
class="resize-handle resize-handle-right" class="resize-handle resize-handle-right"
@mousedown="e => handleMouseDown(e, 'resize-right')" @mousedown="e => handleMouseDown(e, 'resize-right')"
></div> ></div>
@@ -1158,7 +1434,7 @@ onUnmounted(() => {
user-select: none; user-select: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
min-width: 60px; /*min-width: 60px;*/
z-index: 100; z-index: 100;
border: 2px solid; border: 2px solid;
overflow: visible; /* 允许内容超出 TaskBar */ overflow: visible; /* 允许内容超出 TaskBar */
+3 -3
View File
@@ -279,7 +279,7 @@ onUnmounted(() => {
:style="{ :style="{
left: `${adjustedPosition.x}px`, left: `${adjustedPosition.x}px`,
top: `${adjustedPosition.y}px`, top: `${adjustedPosition.y}px`,
zIndex: 3000, zIndex: 10000,
position: 'fixed', position: 'fixed',
}" }"
> >
@@ -355,7 +355,7 @@ onUnmounted(() => {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0; padding: 4px 0;
width: 180px; /* 调整固定宽度,确保文本不会被截断 */ width: 180px; /* 调整固定宽度,确保文本不会被截断 */
z-index: 1000; z-index: 10000; /* 确保在全屏模式(z-index: 9999)之上显示 */
user-select: none; user-select: none;
animation: fadeIn 0.15s ease-out; animation: fadeIn 0.15s ease-out;
border: 1px solid #e4e7ed; border: 1px solid #e4e7ed;
@@ -520,7 +520,7 @@ onUnmounted(() => {
border-bottom: 8px solid #fff; /* 匹配菜单背景色 */ border-bottom: 8px solid #fff; /* 匹配菜单背景色 */
transform-origin: center; transform-origin: center;
filter: drop-shadow(0 -1px 2px rgba(0, 0, 0, 0.1)); /* 为箭头添加阴影效果 */ filter: drop-shadow(0 -1px 2px rgba(0, 0, 0, 0.1)); /* 为箭头添加阴影效果 */
z-index: 1001; z-index: 10001; /* 确保在全屏模式之上显示 */
pointer-events: none; /* 确保箭头不会干扰鼠标事件 */ pointer-events: none; /* 确保箭头不会干扰鼠标事件 */
} }
+82 -15
View File
@@ -68,7 +68,7 @@ watch(
() => [props.task?.isTimerRunning, props.task?.timerStartTime, props.task?.timerElapsedTime], () => [props.task?.isTimerRunning, props.task?.timerStartTime, props.task?.timerElapsedTime],
() => { () => {
updateTimer() updateTimer()
} },
) )
// UI // UI
@@ -84,7 +84,7 @@ watch(
timerInterval.value = null timerInterval.value = null
} }
}, },
{ immediate: true } { immediate: true },
) )
// //
@@ -102,7 +102,7 @@ watch(
updateTimer() updateTimer()
} }
}, },
{ immediate: true } { immediate: true },
) )
onUnmounted(() => { onUnmounted(() => {
@@ -153,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,
@@ -209,6 +209,38 @@ const handleProgressInputBlur = () => {
progressDisplayValue.value = progress.toString() progressDisplayValue.value = progress.toString()
} }
//
const handleEstimatedHoursInput = (event: Event) => {
const target = event.target as HTMLInputElement
let value = parseFloat(target.value)
//
if (isNaN(value) || value < 0) {
value = 0
} else if (value > 99999) {
value = 99999
}
//
formData.estimatedHours = Math.round(value * 100) / 100
}
//
const handleActualHoursInput = (event: Event) => {
const target = event.target as HTMLInputElement
let value = parseFloat(target.value)
//
if (isNaN(value) || value < 0) {
value = 0
} else if (value > 99999) {
value = 99999
}
//
formData.actualHours = Math.round(value * 100) / 100
}
// //
const handleProgressInputFocus = (event: Event) => { const handleProgressInputFocus = (event: Event) => {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement
@@ -256,6 +288,31 @@ const handleProgressKeydown = (event: KeyboardEvent) => {
} }
} }
//
const processDateForHourView = (dateStr: string | undefined, type: 'start' | 'end'): string => {
if (!dateStr) return ''
// YYYY-MM-DD
if (typeof dateStr === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateStr.trim())) {
// 00:00
if (type === 'start') {
return `${dateStr} 00:00`
}
// 00:00
if (type === 'end') {
const date = new Date(dateStr)
date.setDate(date.getDate() + 1)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day} 00:00`
}
}
//
return dateStr
}
// //
const errors = reactive({ const errors = reactive({
name: '', name: '',
@@ -273,7 +330,13 @@ watch(
resetForm() resetForm()
if (props.task && props.isEdit) { if (props.task && props.isEdit) {
// //
Object.assign(formData, props.task) const taskData = { ...props.task }
//
taskData.startDate = processDateForHourView(taskData.startDate, 'start')
taskData.endDate = processDateForHourView(taskData.endDate, 'end')
Object.assign(formData, taskData)
} else if (props.task && !props.isEdit) { } else if (props.task && !props.isEdit) {
// //
formData.parentId = props.task.parentId ?? undefined formData.parentId = props.task.parentId ?? undefined
@@ -283,7 +346,7 @@ watch(
// //
window.dispatchEvent(new CustomEvent('request-task-list')) window.dispatchEvent(new CustomEvent('request-task-list'))
} }
} },
) )
// isVisible // isVisible
@@ -300,7 +363,7 @@ watch(
formData.parentId = newTask.parentId ?? undefined formData.parentId = newTask.parentId ?? undefined
} }
}, },
{ immediate: true } { immediate: true },
) )
// //
@@ -485,7 +548,7 @@ watch(
newValue => { newValue => {
progressDisplayValue.value = (newValue || 0).toString() progressDisplayValue.value = (newValue || 0).toString()
}, },
{ immediate: true } { immediate: true },
) )
// timerElapsed timerStartTime // timerElapsed timerStartTime
@@ -503,7 +566,7 @@ watch(
} }
} }
}, },
{ immediate: true } { immediate: true },
) )
const handleStartTimer = (desc?: string) => { const handleStartTimer = (desc?: string) => {
@@ -783,12 +846,14 @@ function confirmTimer(desc: string) {
<label class="form-label" for="task-estimated-hours">{{ t.estimatedHours }}</label> <label class="form-label" for="task-estimated-hours">{{ t.estimatedHours }}</label>
<input <input
id="task-estimated-hours" id="task-estimated-hours"
v-model.number="formData.estimatedHours" v-model="formData.estimatedHours"
type="number" type="number"
class="form-input" class="form-input"
placeholder="0" placeholder="0.00"
min="0" min="0"
max="999" max="99999"
step="0.01"
@input="handleEstimatedHoursInput"
/> />
</div> </div>
@@ -796,12 +861,14 @@ function confirmTimer(desc: string) {
<label class="form-label" for="task-actual-hours">{{ t.actualHours }}</label> <label class="form-label" for="task-actual-hours">{{ t.actualHours }}</label>
<input <input
id="task-actual-hours" id="task-actual-hours"
v-model.number="formData.actualHours" v-model="formData.actualHours"
type="number" type="number"
class="form-input" class="form-input"
placeholder="0" placeholder="0.00"
min="0" min="0"
max="999" max="99999"
step="0.01"
@input="handleActualHoursInput"
/> />
</div> </div>
</div> </div>
+1508 -129
View File
File diff suppressed because it is too large Load Diff
+29 -6
View File
@@ -93,10 +93,15 @@ const messages = {
githubDocs: '查看Github文档', githubDocs: '查看Github文档',
giteeDocs: '查看Gitee文档', giteeDocs: '查看Gitee文档',
// 时间刻度按钮 // 时间刻度按钮
timeScaleMonth: '', timeScaleHour: '',
timeScaleWeek: '周',
timeScaleDay: '日', timeScaleDay: '日',
timeScaleWeek: '周',
timeScaleMonth: '月',
timeScaleQuarter: '季',
timeScaleYear: '年',
timeScaleTooltip: '切换时间刻度', timeScaleTooltip: '切换时间刻度',
halfYearFirst: '上半年',
halfYearSecond: '下半年',
// 确认对话框 // 确认对话框
confirmDialogMessage: '是否需要保留该设置?', confirmDialogMessage: '是否需要保留该设置?',
// 新建任务对话框 // 新建任务对话框
@@ -144,6 +149,12 @@ const messages = {
}, },
editTask: '编辑任务', editTask: '编辑任务',
parentTask: '上级任务', parentTask: '上级任务',
// 时间选择器相关
time: '时间',
selectTime: '选择时间',
hour: '时',
minute: '分',
noParentTask: '无上级任务', noParentTask: '无上级任务',
update: '更新', update: '更新',
taskNameTooLong: '任务名称不能超过50个字符', taskNameTooLong: '任务名称不能超过50个字符',
@@ -217,7 +228,7 @@ const messages = {
// 日期格式 // 日期格式
yearMonthFormat: (year: number, month: number) => `${year}/${String(month).padStart(2, '0')}`, yearMonthFormat: (year: number, month: number) => `${year}/${String(month).padStart(2, '0')}`,
// 月份格式 // 月份格式
monthFormat: (month: number) => `M${String(month).padStart(2, '0')}`, monthFormat: (month: number) => `${String(month).padStart(2, '0')}`,
// Month name // Month name
monthNames: [ monthNames: [
@@ -274,10 +285,15 @@ const messages = {
githubDocs: 'GitHub Docs', githubDocs: 'GitHub Docs',
giteeDocs: 'Gitee Docs', giteeDocs: 'Gitee Docs',
// 时间刻度按钮 // 时间刻度按钮
timeScaleMonth: 'Month', timeScaleHour: 'Hour',
timeScaleWeek: 'Week',
timeScaleDay: 'Day', timeScaleDay: 'Day',
timeScaleWeek: 'Week',
timeScaleMonth: 'Month',
timeScaleQuarter: 'Quarter',
timeScaleYear: 'Year',
timeScaleTooltip: 'Switch Time Scale', timeScaleTooltip: 'Switch Time Scale',
halfYearFirst: 'First Half',
halfYearSecond: 'Second Half',
// Confirm dialog // Confirm dialog
confirmDialogMessage: 'Do you want to save this setting?', confirmDialogMessage: 'Do you want to save this setting?',
taskNamePlaceholder: 'Enter task name', taskNamePlaceholder: 'Enter task name',
@@ -324,6 +340,13 @@ const messages = {
editTask: 'Edit Task', editTask: 'Edit Task',
newTask: 'New Task', newTask: 'New Task',
parentTask: 'Parent Task', parentTask: 'Parent Task',
// 时间选择器相关
time: 'Time',
selectTime: 'Select Time',
hour: 'Hour',
minute: 'Minute',
noParentTask: 'No Parent Task', noParentTask: 'No Parent Task',
update: 'Update', update: 'Update',
taskNameTooLong: 'Task name cannot exceed 50 characters', taskNameTooLong: 'Task name cannot exceed 50 characters',
@@ -425,7 +448,7 @@ export function useI18n() {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('locale-changed', { new CustomEvent('locale-changed', {
detail: { locale }, detail: { locale },
}) }),
) )
} }
View File
+1
View File
@@ -9,4 +9,5 @@ export interface ToolbarConfig {
showTheme?: boolean showTheme?: boolean
showFullscreen?: boolean showFullscreen?: boolean
showTimeScale?: boolean // 显示时间刻度按钮组 showTimeScale?: boolean // 显示时间刻度按钮组
timeScaleDimensions?: ('hour' | 'day' | 'week' | 'month' | 'year')[] // 设置时间刻度按钮的展示维度
} }
+20
View File
@@ -0,0 +1,20 @@
/**
*
*
*/
// 简化的通用时间轴数据类型,用于向下兼容
export type LegacyTimelineData = Array<Record<string, any>>
// 类型守卫函数
export function isTimelineMonth(item: any): item is Record<string, any> {
return item && typeof item === 'object' && 'year' in item && 'month' in item
}
export function isTimelineYear(item: any): item is Record<string, any> {
return item && typeof item === 'object' && 'year' in item && 'yearLabel' in item
}
export function isTimelineDay(item: any): item is Record<string, any> {
return item && typeof item === 'object' && 'year' in item && 'day' in item
}
+131
View File
@@ -0,0 +1,131 @@
/**
*
*
*/
// 基础时间单位接口
export interface TimelineHour {
hour: number
label: string
shortLabel?: string
hourLabel?: string
date?: Date
isToday?: boolean
isWorkingHour?: boolean
isWeekend?: boolean
}
export interface TimelineDay {
year: number
month: number
day: number
date: Date
dateLabel: string
label?: string
dayOfWeek?: number
isToday?: boolean
isWeekend?: boolean
hours?: TimelineHour[]
hourOffset?: number
}
export interface TimelineWeek {
label: string
startDate: Date
endDate: Date
weekStart?: Date
weekEnd?: Date
weekNumber?: number
isToday?: boolean
belongsToYear?: number
belongsToMonth?: number
subDays?: Array<{
date: Date
dayOfWeek?: number
}>
}
export interface TimelineMonth {
year: number
month: number
startDate: Date
endDate: Date
monthLabel?: string
yearMonthLabel?: string
isToday?: boolean
// 月视图相关
isMonthView?: boolean
monthData?: {
dayCount: number
monthLabel?: string
isToday?: boolean
}
// 周视图相关
isWeekView?: boolean
weeks?: Array<{
weekStart: Date
weekEnd: Date
subDays: Array<{
date: Date
dayOfWeek?: number
}>
}>
// 日视图相关
days?: Array<{
day: number
date: Date
label: string
isToday: boolean
isWeekend: boolean
}>
subDays?: Array<{
date: Date
dayOfWeek?: number
}>
// 调试信息
_debugInfo?: Record<string, unknown>
}
export interface TimelineQuarter {
quarter: number
label?: string
fullLabel?: string
quarterLabel: string
startDate: Date
endDate: Date
isToday?: boolean
year?: number
}
export interface TimelineYear {
year: number
yearLabel: string
startDate: Date
endDate: Date
isToday?: boolean
quarters?: TimelineQuarter[]
halfYears?: Array<{
startDate: Date
endDate: Date
label: string
}>
}
// 联合类型,支持不同时间尺度的数据
export type TimelineData = TimelineMonth[] | TimelineYear[] | TimelineDay[]
// 时间轴数据项类型(用于模板中的泛型推断)
export type TimelineDataItem = TimelineMonth | TimelineYear | TimelineDay
// 缓存键类型
export interface TimelineCacheKey {
startDate: string
endDate: string
scale: string
workingHours: string
}
+28 -1
View File
@@ -1,13 +1,16 @@
// 时间轴比例类型定义 // 时间轴比例类型定义
// 使用字符串字面量类型代替enum,兼容erasableSyntaxOnly // 使用字符串字面量类型代替enum,兼容erasableSyntaxOnly
export type TimelineScale = 'day' | 'week' | 'month' export type TimelineScale = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'
// 导出常量值以便于使用 // 导出常量值以便于使用
export const TimelineScale = { export const TimelineScale = {
HOUR: 'hour' as TimelineScale, // 小时视图 - 每列显示一小时
DAY: 'day' as TimelineScale, // 日视图 - 每列显示一天 DAY: 'day' as TimelineScale, // 日视图 - 每列显示一天
WEEK: 'week' as TimelineScale, // 周视图 - 每列显示一周 WEEK: 'week' as TimelineScale, // 周视图 - 每列显示一周
MONTH: 'month' as TimelineScale, // 月视图 - 每列显示一个月 MONTH: 'month' as TimelineScale, // 月视图 - 每列显示一个月
QUARTER: 'quarter' as TimelineScale, // 季度视图 - 每列显示一个季度
YEAR: 'year' as TimelineScale, // 年视图 - 每列显示一年
} }
export interface TimelineScaleConfig { export interface TimelineScaleConfig {
@@ -22,6 +25,12 @@ export interface TimelineScaleConfig {
// 预设配置 // 预设配置
export const SCALE_CONFIGS = { export const SCALE_CONFIGS = {
hour: {
scale: TimelineScale.HOUR,
cellWidth: 40,
headerLevels: 2,
formatters: { primary: 'yyyy年MM月dd日', secondary: 'HH:mm' },
},
day: { day: {
scale: TimelineScale.DAY, scale: TimelineScale.DAY,
cellWidth: 30, cellWidth: 30,
@@ -40,6 +49,12 @@ export const SCALE_CONFIGS = {
headerLevels: 2, headerLevels: 2,
formatters: { primary: 'yyyy年', secondary: 'MM月' }, formatters: { primary: 'yyyy年', secondary: 'MM月' },
}, },
year: {
scale: TimelineScale.YEAR,
cellWidth: 360,
headerLevels: 2,
formatters: { primary: 'yyyy年', secondary: '上半年|下半年' },
},
} as Record<TimelineScale, TimelineScaleConfig> } as Record<TimelineScale, TimelineScaleConfig>
// 时间单元数据接口 // 时间单元数据接口
@@ -72,4 +87,16 @@ export interface TimelineData {
endDate: Date endDate: Date
units: TimelineUnit[] units: TimelineUnit[]
}> }>
years?: Array<{
year: number
startDate: Date
endDate: Date
halfYears: Array<{
label: string
startDate: Date
endDate: Date
width: number
}>
width: number
}>
} }
+12
View File
@@ -2,6 +2,18 @@
* *
*/ */
/**
*
* @param dateString YYYY-MM-DD
* @returns
*/
export function createLocalDate(dateString: string): Date | null {
if (!dateString) return null
const [year, month, day] = dateString.split('-').map(Number)
return new Date(year, month - 1, day)
}
/** /**
* ID数组 * ID数组
* @param predecessor * @param predecessor
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmitOnError": false,
"skipLibCheck": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}