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

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

View File

@@ -59,26 +59,71 @@ 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)

268
README.md
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>
```
## 🤝 贡献与合作 ## 🤝 贡献与合作
### 参与贡献 ### 参与贡献

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"

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": "技术选型完成",

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"
]
} }
] ]

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",

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"

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": "技术选型完成",

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"
]
} }
] ]

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>

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"

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;

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) {
// 月度视图使用固定大小不依赖dayWidth因为dayWidth太小
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()
// 每年的宽度是360px每半年180px
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) // 1月1日
} else {
// 下半年7-12月
startOfHalfYear = new Date(targetYear, 6, 1) // 7月1日
}
dayOffset = Math.floor((targetDate.getTime() - startOfHalfYear.getTime()) / (1000 * 60 * 60 * 24))
// 半年大约181-184天将天数映射到180px的宽度
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) // 1月1日
endOfQuarter = new Date(targetYear, 2, 31) // 3月31日
} else if (quarter === 2) {
startOfQuarter = new Date(targetYear, 3, 1) // 4月1日
endOfQuarter = new Date(targetYear, 5, 30) // 6月30日
} else if (quarter === 3) {
startOfQuarter = new Date(targetYear, 6, 1) // 7月1日
endOfQuarter = new Date(targetYear, 8, 30) // 9月30日
} else {
startOfQuarter = new Date(targetYear, 9, 1) // 10月1日
endOfQuarter = new Date(targetYear, 11, 31) // 12月31日
}
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
}
// 基于timelineData和subDays精确计算里程碑位置的函数 // 基于timelineData和subDays精确计算里程碑位置的函数
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=星期六
// 但subDays数组是从星期一开始索引0=星期一索引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
} }
// 累加每月的宽度 // 累加每月的宽度

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>

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
// 如果startDate没有时间部分格式为YYYY-MM-DD设置为当日00: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 // 如果endDate没有时间部分格式为YYYY-MM-DD设置为次日00: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)
// 每小时40px每分钟40/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 // 季度视图每季度60px约90天
}
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()
// 每年的宽度是360px每半年180px
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) // 1月1日
} else {
// 下半年7-12月
startOfHalfYear = new Date(targetYear, 6, 1) // 7月1日
}
dayOffset = Math.floor((targetDate.getTime() - startOfHalfYear.getTime()) / (1000 * 60 * 60 * 24))
// 半年大约181-184天将天数映射到180px的宽度
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
}
// 基于timelineData和subDays精确计算日期位置的函数 // 基于timelineData和subDays精确计算日期位置的函数
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 */

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; /* 确保箭头不会干扰鼠标事件 */
} }

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') // 只显示story和task类型 (task.type === 'story' || task.type === 'task'), // 只显示story和task类型
) )
.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>

File diff suppressed because it is too large Load Diff

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

@@ -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')[] // 设置时间刻度按钮的展示维度
} }

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
}

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
}

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
}>
} }

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
tsconfig.build.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmitOnError": false,
"skipLibCheck": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}