v1.3.0 - add Annual/Quarter/Hourly timeline views
This commit is contained in:
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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/),
|
||||
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
|
||||
|
||||
### Fixed
|
||||
|
||||
258
README-EN.md
258
README-EN.md
@@ -59,26 +59,71 @@ pnpm add jordium-gantt-vue3
|
||||
|
||||
```
|
||||
jordium-gantt-vue3/
|
||||
├── src/ # Source code directory
|
||||
│ ├── components/ # Core components
|
||||
│ │ ├── GanttChart.vue # Main entry component
|
||||
│ │ ├── TaskList.vue # Task list
|
||||
│ │ ├── Timeline.vue # Timeline
|
||||
│ │ ├── TaskBar.vue # Task bar
|
||||
│ │ ├── MilestonePoint.vue # Milestone
|
||||
│ │ └── ... # Other components
|
||||
│ ├── models/ # Data models
|
||||
│ │ ├── classes/ # Class definitions
|
||||
│ │ └── configs/ # Configuration interfaces
|
||||
│ ├── composables/ # Composable functions
|
||||
│ ├── styles/ # Style files
|
||||
│ └── index.ts # Export entry
|
||||
├── demo/ # Development demo
|
||||
├── dist/ # Build output
|
||||
├── docs/ # Documentation
|
||||
└── package.json
|
||||
├── src/ # Source code directory
|
||||
│ ├── components/ # Core Vue components
|
||||
│ │ ├── GanttChart.vue # Main entry component
|
||||
│ │ ├── TaskList.vue # Task list component
|
||||
│ │ ├── Timeline.vue # Timeline component
|
||||
│ │ ├── TaskBar.vue # Task bar component
|
||||
│ │ ├── TaskDrawer.vue # Task edit drawer
|
||||
│ │ ├── TaskContextMenu.vue # Task context menu
|
||||
│ │ ├── GanttToolbar.vue # Toolbar component
|
||||
│ │ ├── MilestonePoint.vue # Milestone point
|
||||
│ │ ├── MilestoneDialog.vue # Milestone dialog
|
||||
│ │ ├── DatePicker.vue # Date picker
|
||||
│ │ └── ... # Other components
|
||||
│ ├── models/ # Data models and configurations
|
||||
│ │ ├── classes/ # Class definitions
|
||||
│ │ │ ├── Task.ts # Task model
|
||||
│ │ │ ├── Milestone.ts # Milestone model
|
||||
│ │ │ └── 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
|
||||
|
||||
### GanttChart Properties
|
||||
@@ -92,8 +137,9 @@ jordium-gantt-vue3/
|
||||
| `showToolbar` | `boolean` | `true` | Show toolbar |
|
||||
| `toolbarConfig` | `ToolbarConfig` | `{}` | Toolbar configuration |
|
||||
| `localeMessages` | `Partial<Messages['zh-CN']>` | - | Custom locale messages |
|
||||
| `workingHours` | `WorkingHours` | - | Working hours configuration |
|
||||
| `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 |
|
||||
| `onTaskAdd` | `(task: Task) => void` | - | Task add event callback |
|
||||
| `onMilestoneSave` | `(milestone: Task) => void` | - | Milestone save event callback |
|
||||
@@ -184,16 +230,16 @@ function onTaskUpdated(e) {
|
||||
**Task Type**
|
||||
```typescript
|
||||
export interface Task {
|
||||
id: number // Unique task ID
|
||||
name: string // Task name
|
||||
predecessor?: number[] // Predecessor task ID array
|
||||
assignee?: string // Assignee
|
||||
startDate?: string // Start date (ISO string)
|
||||
endDate?: string // End date (ISO string)
|
||||
progress?: number // Progress percentage 0-100
|
||||
estimatedHours?: number // Estimated hours
|
||||
actualHours?: number // Actual hours
|
||||
parentId?: number // Parent task ID
|
||||
id: number // Unique task ID
|
||||
name: string // Task name
|
||||
predecessor?: number[] // Predecessor task ID array
|
||||
assignee?: string // Assignee
|
||||
startDate?: string // Start date (ISO string)
|
||||
endDate?: string // End date (ISO string)
|
||||
progress?: number // Progress percentage 0-100
|
||||
estimatedHours?: number // Estimated hours (supports decimal, up to 2 decimal places)
|
||||
actualHours?: number // Actual hours (supports decimal, up to 2 decimal places)
|
||||
parentId?: number // Parent task ID
|
||||
children?: Task[] // Subtask array
|
||||
collapsed?: boolean // Collapsed state
|
||||
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)
|
||||
|
||||
**useI18n Internationalization Tool**
|
||||
@@ -399,6 +525,80 @@ const customLocaleMessages = {
|
||||
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
|
||||
const handleLanguageChange = (lang) => {
|
||||
console.log('Language changed to:', lang)
|
||||
|
||||
268
README.md
268
README.md
@@ -60,28 +60,70 @@ pnpm add jordium-gantt-vue3
|
||||
|
||||
```
|
||||
jordium-gantt-vue3/
|
||||
├── src/ # 组件源码与核心逻辑
|
||||
│ ├── components/ # 主要 Vue 组件
|
||||
│ ├── models/ # 数据类型与配置
|
||||
│ ├── composables/ # 组合式函数
|
||||
│ ├── styles/ # 样式文件
|
||||
│ └── index.ts # 入口导出
|
||||
├── demo/ # 组件开发与交互演示(本地开发/预览用)
|
||||
├── packageDemo/ # npm 包集成演示(模拟外部项目集成效果)
|
||||
├── dist/ # 构建产物(发布/静态站点/打包输出)
|
||||
├── docs/ # 相关文档(如部署、API 说明等)
|
||||
├── design/ # 设计资源与截图
|
||||
├── public/ # 公共静态资源
|
||||
├── README.md # 中文说明文档
|
||||
├── README-EN.md # 英文说明文档
|
||||
└── ... # 其他配置、脚本与元数据
|
||||
├── src/ # 组件源码与核心逻辑
|
||||
│ ├── components/ # 主要 Vue 组件
|
||||
│ │ ├── GanttChart.vue # 主入口组件
|
||||
│ │ ├── TaskList.vue # 任务列表
|
||||
│ │ ├── Timeline.vue # 时间轴组件
|
||||
│ │ ├── TaskBar.vue # 任务条
|
||||
│ │ ├── TaskDrawer.vue # 任务编辑抽屉
|
||||
│ │ ├── TaskContextMenu.vue # 任务右键菜单
|
||||
│ │ ├── GanttToolbar.vue # 工具栏
|
||||
│ │ ├── MilestonePoint.vue # 里程碑点
|
||||
│ │ ├── MilestoneDialog.vue # 里程碑对话框
|
||||
│ │ ├── DatePicker.vue # 日期选择器
|
||||
│ │ └── ... # 其他组件
|
||||
│ ├── 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 或静态站点的产物。
|
||||
- `docs/`:项目相关文档,如部署说明、API 参考等。
|
||||
- 其余目录请参考注释。
|
||||
### 目录说明
|
||||
|
||||
- **`src/components/`**:核心Vue组件,包含甘特图的所有功能组件
|
||||
- **`src/models/`**:数据模型、类型定义和配置接口
|
||||
- **`src/composables/`**:Vue 3组合式函数,提供可复用的逻辑
|
||||
- **`src/styles/`**:样式文件,包含主题系统和CSS变量
|
||||
- **`src/utils/`**:工具函数,处理业务逻辑和数据转换
|
||||
- **`demo/`**:本地开发和功能演示,包含完整的交互页面和药物临床试验样例数据
|
||||
- **`packageDemo/`**:模拟npm包在外部项目中的集成与使用场景
|
||||
- **`dist/`**:构建输出目录,包含发布到npm或静态站点的产物
|
||||
- **`docs/`**:项目文档,包括部署说明、API参考等
|
||||
|
||||
## 🔧 API 参考
|
||||
|
||||
@@ -96,8 +138,22 @@ jordium-gantt-vue3/
|
||||
| `showToolbar` | `boolean` | `true` | 是否显示工具栏 |
|
||||
| `toolbarConfig` | `ToolbarConfig` | `{}` | 工具栏配置 |
|
||||
| `localeMessages` | `Partial<Messages['zh-CN']>` | - | 自定义多语言配置 |
|
||||
| `workingHours` | `WorkingHours` | - | 工作时间配置 |
|
||||
| `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` | - | 任务更新事件回调 |
|
||||
| `onTaskAdd` | `(task: Task) => void` | - | 任务添加事件回调 |
|
||||
| `onMilestoneSave` | `(milestone: Task) => void` | - | 里程碑保存事件回调 |
|
||||
@@ -188,16 +244,16 @@ function onTaskUpdated(e) {
|
||||
**Task 任务类型**
|
||||
```typescript
|
||||
export interface Task {
|
||||
id: number // 任务唯一ID
|
||||
name: string // 任务名称
|
||||
predecessor?: number[] // 前置任务ID数组
|
||||
assignee?: string // 负责人
|
||||
startDate?: string // 开始日期(ISO字符串)
|
||||
endDate?: string // 结束日期(ISO字符串)
|
||||
progress?: number // 进度百分比 0-100
|
||||
estimatedHours?: number // 预估工时
|
||||
actualHours?: number // 实际工时
|
||||
parentId?: number // 上级任务ID
|
||||
id: number // 任务唯一ID
|
||||
name: string // 任务名称
|
||||
predecessor?: number[] // 前置任务ID数组
|
||||
assignee?: string // 负责人
|
||||
startDate?: string // 开始日期(ISO字符串)
|
||||
endDate?: string // 结束日期(ISO字符串)
|
||||
progress?: number // 进度百分比 0-100
|
||||
estimatedHours?: number // 预估工时(支持小数,最多2位)
|
||||
actualHours?: number // 实际工时(支持小数,最多2位)
|
||||
parentId?: number // 上级任务ID
|
||||
children?: Task[] // 子任务数组
|
||||
collapsed?: 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)
|
||||
|
||||
**useI18n 国际化工具**
|
||||
@@ -386,15 +522,15 @@ import { GanttChart } from 'jordium-gantt-vue3'
|
||||
|
||||
// 工具栏配置
|
||||
const toolbarConfig = {
|
||||
showLanguage: true,
|
||||
showTheme: true,
|
||||
showAddTask: true,
|
||||
showAddMilestone: true,
|
||||
showTodayLocate: true,
|
||||
showExportCsv: true,
|
||||
showExportPdf: true,
|
||||
showFullscreen: true,
|
||||
showTimeScale: true // 控制日|周|月时间刻度按钮组的可见性
|
||||
showLanguage: true, // 语言切换
|
||||
showTheme: true, // 主题切换
|
||||
showAddTask: true, // 新增任务
|
||||
showAddMilestone: true, // 新增里程碑
|
||||
showTodayLocate: true, // 定位今天
|
||||
showExportCsv: true, // 导出CSV
|
||||
showExportPdf: true, // 导出PDF
|
||||
showFullscreen: true, // 全屏模式
|
||||
showTimeScale: true // 时间刻度切换(日|周|月按钮组)
|
||||
}
|
||||
|
||||
// 自定义多语言配置
|
||||
@@ -411,6 +547,17 @@ const handleLanguageChange = (lang) => {
|
||||
const handleThemeChange = (isDark) => {
|
||||
console.log('主题切换到:', isDark ? '暗色' : '亮色')
|
||||
}
|
||||
|
||||
// 监听时间刻度变化
|
||||
const handleTimeScaleChange = (scale) => {
|
||||
console.log('时间刻度切换至:', scale)
|
||||
// 根据刻度调整显示逻辑
|
||||
if (scale === 'day') {
|
||||
// 日视图下的特殊处理
|
||||
} else if (scale === 'week') {
|
||||
// 周视图下的特殊处理
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -421,10 +568,51 @@ const handleThemeChange = (isDark) => {
|
||||
:locale-messages="customLocaleMessages"
|
||||
:on-language-change="handleLanguageChange"
|
||||
:on-theme-change="handleThemeChange"
|
||||
@timescale-changed="handleTimeScaleChange"
|
||||
/>
|
||||
</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>
|
||||
```
|
||||
|
||||
## 🤝 贡献与合作
|
||||
|
||||
### 参与贡献
|
||||
|
||||
27
demo/App.vue
27
demo/App.vue
@@ -37,6 +37,14 @@ const toolbarConfig = {
|
||||
showTheme: true,
|
||||
showFullscreen: 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导出处理器(可选)
|
||||
@@ -118,14 +126,14 @@ const handleMilestoneDelete = async (milestoneId: number) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('milestone-deleted', {
|
||||
detail: { milestoneId },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
// 触发强制更新事件,确保Timeline重新渲染
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('milestone-data-changed', {
|
||||
detail: { milestones: milestones.value },
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,7 +227,7 @@ const handleTaskUpdate = (updatedTask: Task) => {
|
||||
showMessage(
|
||||
formatTranslation('newParentTaskNotFound', { parentId: taskToAdd.parentId }),
|
||||
'warning',
|
||||
{ closable: true }
|
||||
{ closable: true },
|
||||
)
|
||||
tasks.value.push(taskToAdd)
|
||||
}
|
||||
@@ -267,7 +275,7 @@ const handleTaskAdd = (newTask: Task) => {
|
||||
const maxId = Math.max(
|
||||
...tasks.value.map(t => t.id || 0),
|
||||
...milestones.value.map(m => m.id || 0),
|
||||
0
|
||||
0,
|
||||
)
|
||||
newTask.id = maxId + 1
|
||||
}
|
||||
@@ -362,7 +370,7 @@ const handleStoryDeleteWithChildren = (storyToDelete: Task) => {
|
||||
'success',
|
||||
{
|
||||
closable: false,
|
||||
}
|
||||
},
|
||||
)
|
||||
return true
|
||||
}
|
||||
@@ -424,7 +432,7 @@ const handleStoryDeleteOnly = (storyToDelete: Task) => {
|
||||
'success',
|
||||
{
|
||||
closable: false,
|
||||
}
|
||||
},
|
||||
)
|
||||
return true
|
||||
}
|
||||
@@ -498,7 +506,7 @@ function handleTaskbarDragOrResizeEnd(newTask) {
|
||||
`开始: ${oldTask.startDate} → ${newTask.startDate}\n` +
|
||||
`结束: ${oldTask.endDate} → ${newTask.endDate}`,
|
||||
'info',
|
||||
{ closable: true }
|
||||
{ closable: true },
|
||||
)
|
||||
}
|
||||
function handleMilestoneDragEnd(newMilestone) {
|
||||
@@ -508,7 +516,7 @@ function handleMilestoneDragEnd(newMilestone) {
|
||||
`里程碑【${newMilestone.name}】\n` +
|
||||
`开始: ${oldMilestone.endDate} → ${newMilestone.startDate}`,
|
||||
'info',
|
||||
{ closable: true }
|
||||
{ closable: true },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -575,7 +583,7 @@ function onTimerStarted(task: Task) {
|
||||
showMessage(
|
||||
`Demo 任务【${task.name}】\n开始计时:${new Date(task.timerStartTime).toLocaleString()}\n计时说明:${task.timerStartDesc ? task.timerStartDesc : ''}`,
|
||||
'info',
|
||||
{ closable: true }
|
||||
{ closable: true },
|
||||
)
|
||||
}
|
||||
function onTimerStopped(task: Task) {
|
||||
@@ -628,6 +636,7 @@ function onTimerStopped(task: Task) {
|
||||
:tasks="tasks"
|
||||
:milestones="milestones"
|
||||
:toolbar-config="toolbarConfig"
|
||||
:working-hours="workingHoursConfig"
|
||||
:on-add-task="handleAddTask"
|
||||
:on-add-milestone="handleAddMilestone"
|
||||
:on-export-csv="handleCustomCsvExport"
|
||||
|
||||
273
demo/data.json
273
demo/data.json
@@ -1,5 +1,248 @@
|
||||
{
|
||||
"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,
|
||||
"name": "项目启动",
|
||||
@@ -162,6 +405,36 @@
|
||||
}
|
||||
],
|
||||
"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,
|
||||
"name": "技术选型完成",
|
||||
|
||||
@@ -162,5 +162,23 @@
|
||||
"version": "1.2.1",
|
||||
"date": "2025-07-22",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jordium-gantt-vue3",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"main": "dist/jordium-gantt-vue3.cjs.js",
|
||||
"module": "dist/jordium-gantt-vue3.es.js",
|
||||
@@ -48,7 +48,7 @@
|
||||
"scripts": {
|
||||
"dev": "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:lib": "vite build --config vite.config.lib.ts",
|
||||
"preview": "vite preview",
|
||||
|
||||
@@ -37,6 +37,14 @@ const toolbarConfig = {
|
||||
showTheme: true,
|
||||
showFullscreen: 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导出处理器(可选)
|
||||
@@ -118,14 +126,14 @@ const handleMilestoneDelete = async (milestoneId: number) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('milestone-deleted', {
|
||||
detail: { milestoneId },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
// 触发强制更新事件,确保Timeline重新渲染
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('milestone-data-changed', {
|
||||
detail: { milestones: milestones.value },
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,7 +227,7 @@ const handleTaskUpdate = (updatedTask: Task) => {
|
||||
showMessage(
|
||||
formatTranslation('newParentTaskNotFound', { parentId: taskToAdd.parentId }),
|
||||
'warning',
|
||||
{ closable: true }
|
||||
{ closable: true },
|
||||
)
|
||||
tasks.value.push(taskToAdd)
|
||||
}
|
||||
@@ -267,7 +275,7 @@ const handleTaskAdd = (newTask: Task) => {
|
||||
const maxId = Math.max(
|
||||
...tasks.value.map(t => t.id || 0),
|
||||
...milestones.value.map(m => m.id || 0),
|
||||
0
|
||||
0,
|
||||
)
|
||||
newTask.id = maxId + 1
|
||||
}
|
||||
@@ -362,7 +370,7 @@ const handleStoryDeleteWithChildren = (storyToDelete: Task) => {
|
||||
'success',
|
||||
{
|
||||
closable: false,
|
||||
}
|
||||
},
|
||||
)
|
||||
return true
|
||||
}
|
||||
@@ -424,7 +432,7 @@ const handleStoryDeleteOnly = (storyToDelete: Task) => {
|
||||
'success',
|
||||
{
|
||||
closable: false,
|
||||
}
|
||||
},
|
||||
)
|
||||
return true
|
||||
}
|
||||
@@ -498,7 +506,7 @@ function handleTaskbarDragOrResizeEnd(newTask) {
|
||||
`开始: ${oldTask.startDate} → ${newTask.startDate}\n` +
|
||||
`结束: ${oldTask.endDate} → ${newTask.endDate}`,
|
||||
'info',
|
||||
{ closable: true }
|
||||
{ closable: true },
|
||||
)
|
||||
}
|
||||
function handleMilestoneDragEnd(newMilestone) {
|
||||
@@ -508,7 +516,7 @@ function handleMilestoneDragEnd(newMilestone) {
|
||||
`里程碑【${newMilestone.name}】\n` +
|
||||
`开始: ${oldMilestone.endDate} → ${newMilestone.startDate}`,
|
||||
'info',
|
||||
{ closable: true }
|
||||
{ closable: true },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -575,7 +583,7 @@ function onTimerStarted(task: Task) {
|
||||
showMessage(
|
||||
`Demo 任务【${task.name}】\n开始计时:${new Date(task.timerStartTime).toLocaleString()}\n计时说明:${task.timerStartDesc ? task.timerStartDesc : ''}`,
|
||||
'info',
|
||||
{ closable: true }
|
||||
{ closable: true },
|
||||
)
|
||||
}
|
||||
function onTimerStopped(task: Task) {
|
||||
@@ -628,6 +636,7 @@ function onTimerStopped(task: Task) {
|
||||
:tasks="tasks"
|
||||
:milestones="milestones"
|
||||
:toolbar-config="toolbarConfig"
|
||||
:working-hours="workingHoursConfig"
|
||||
:on-add-task="handleAddTask"
|
||||
:on-add-milestone="handleAddMilestone"
|
||||
:on-export-csv="handleCustomCsvExport"
|
||||
|
||||
@@ -1,5 +1,248 @@
|
||||
{
|
||||
"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,
|
||||
"name": "项目启动",
|
||||
@@ -162,6 +405,36 @@
|
||||
}
|
||||
],
|
||||
"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,
|
||||
"name": "技术选型完成",
|
||||
|
||||
@@ -162,5 +162,23 @@
|
||||
"version": "1.2.1",
|
||||
"date": "2025-07-22",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
<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'
|
||||
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>(), {
|
||||
modelValue: '',
|
||||
type: 'date',
|
||||
@@ -38,14 +22,35 @@ const emit = defineEmits<{
|
||||
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 isHovered = ref(false)
|
||||
const showPicker = ref(false)
|
||||
const showYearPicker = ref(false)
|
||||
const showMonthPicker = ref(false)
|
||||
const showTimePicker = ref(false)
|
||||
const inputRef = 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 positionUpdateKey = ref(0) // 用于强制重新计算位置
|
||||
|
||||
@@ -63,43 +68,107 @@ const startValue = ref('')
|
||||
const endValue = ref('')
|
||||
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 ''
|
||||
|
||||
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(() => {
|
||||
if (props.type === 'daterange') {
|
||||
const start = startValue.value ? formatDisplayDate(startValue.value) : ''
|
||||
const end = endValue.value ? formatDisplayDate(endValue.value) : ''
|
||||
const start = startValue.value
|
||||
? formatDisplayDateTime(startValue.value, selectedTime.value)
|
||||
: ''
|
||||
const end = endValue.value ? formatDisplayDateTime(endValue.value, selectedTime.value) : ''
|
||||
if (start && end) {
|
||||
return `${start} ${props.rangeSeparator} ${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(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
if (props.type === 'daterange') {
|
||||
if (Array.isArray(newValue) && newValue.length === 2) {
|
||||
startValue.value = newValue[0] || ''
|
||||
endValue.value = newValue[1] || ''
|
||||
const startParsed = parseDateTimeString(newValue[0] || '')
|
||||
const endParsed = parseDateTimeString(newValue[1] || '')
|
||||
|
||||
startValue.value = startParsed.dateStr
|
||||
endValue.value = endParsed.dateStr
|
||||
|
||||
// 如果有时间信息,使用第一个时间作为选择器的时间
|
||||
if (startParsed.timeStr !== '12:00') {
|
||||
selectedTime.value = startParsed.timeStr
|
||||
}
|
||||
} else {
|
||||
startValue.value = ''
|
||||
endValue.value = ''
|
||||
selectedTime.value = '12:00'
|
||||
}
|
||||
} 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
|
||||
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 {
|
||||
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
|
||||
showYearPicker.value = false
|
||||
showMonthPicker.value = false
|
||||
showTimePicker.value = false // 关闭日历时也关闭时间选择器
|
||||
isFocused.value = false
|
||||
// 清理失焦定时器
|
||||
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 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()
|
||||
} else if (!isInsideTimePicker && showTimePicker.value && !isInsidePicker) {
|
||||
// 如果点击了时间选择器外部但在日历选择器内部,只关闭时间选择器
|
||||
closeTimePicker()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,22 +492,11 @@ const selectDate = (dateStr: string) => {
|
||||
endValue.value = dateStr
|
||||
}
|
||||
rangeSelection.value = 'start'
|
||||
const newValue: [string, string] = [startValue.value, endValue.value]
|
||||
emit('update:modelValue', newValue)
|
||||
emit('change', newValue)
|
||||
// 选择完成后自动关闭面板
|
||||
setTimeout(() => {
|
||||
closePicker()
|
||||
}, 150)
|
||||
// 不再自动提交,等待用户点击确认按钮
|
||||
}
|
||||
} else {
|
||||
singleValue.value = dateStr
|
||||
emit('update:modelValue', dateStr)
|
||||
emit('change', dateStr)
|
||||
// 单日期选择完成后自动关闭面板
|
||||
setTimeout(() => {
|
||||
closePicker()
|
||||
}, 150)
|
||||
// 不再自动提交,等待用户点击确认按钮
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,8 +738,26 @@ const handlePickerFocus = () => {
|
||||
|
||||
// 面板失去焦点时关闭
|
||||
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(() => {
|
||||
closePicker()
|
||||
closeTimePicker()
|
||||
}, 150)
|
||||
}
|
||||
|
||||
@@ -637,6 +856,53 @@ const panelStyle = computed(() => {
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -695,6 +961,80 @@ const panelStyle = computed(() => {
|
||||
</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">
|
||||
<Transition name="picker-fade">
|
||||
@@ -814,6 +1154,32 @@ const panelStyle = computed(() => {
|
||||
</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>
|
||||
</Transition>
|
||||
@@ -1444,6 +1810,38 @@ const panelStyle = computed(() => {
|
||||
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 {
|
||||
background: var(--gantt-bg-secondary, #2c2c2c);
|
||||
@@ -1488,6 +1886,11 @@ const panelStyle = computed(() => {
|
||||
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) {
|
||||
.el-date-picker--large .el-input {
|
||||
@@ -1548,4 +1951,250 @@ const panelStyle = computed(() => {
|
||||
opacity: 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>
|
||||
|
||||
@@ -36,6 +36,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
onThemeChange: undefined,
|
||||
onFullscreenChange: undefined,
|
||||
localeMessages: undefined,
|
||||
workingHours: () => ({
|
||||
morning: { start: 8, end: 11 },
|
||||
afternoon: { start: 13, end: 17 },
|
||||
}),
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -97,6 +101,11 @@ interface Props {
|
||||
* 仅在组件初始化时合并,运行时变更会自动响应。
|
||||
*/
|
||||
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)
|
||||
@@ -249,7 +258,7 @@ const toggleTaskList = () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('timeline-container-resized', {
|
||||
detail: { source: 'manual-task-list-toggle' },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}, 400)
|
||||
@@ -265,7 +274,7 @@ const handleToggleTaskList = (event: CustomEvent) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('timeline-container-resized', {
|
||||
detail: { source: 'task-list-toggle' },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -298,7 +307,7 @@ const handleTaskCollapseChange = (task: Task) => {
|
||||
const updateTaskCollapsedState = (
|
||||
tasks: Task[],
|
||||
targetId: number,
|
||||
collapsed: boolean
|
||||
collapsed: boolean,
|
||||
): boolean => {
|
||||
for (const t of tasks) {
|
||||
if (t.id === targetId) {
|
||||
@@ -369,7 +378,7 @@ watch(
|
||||
notifyTaskListUpdated()
|
||||
})
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -407,7 +416,7 @@ onUnmounted(() => {
|
||||
window.removeEventListener('task-added', handleTaskAdd as EventListener)
|
||||
window.removeEventListener(
|
||||
'milestone-icon-changed',
|
||||
handleMilestoneIconChangeEvent as EventListener
|
||||
handleMilestoneIconChangeEvent as EventListener,
|
||||
)
|
||||
window.removeEventListener('milestone-deleted', handleMilestoneDeleted as EventListener)
|
||||
window.removeEventListener('milestone-data-changed', handleMilestoneDataChanged as EventListener)
|
||||
@@ -1183,7 +1192,7 @@ watch(
|
||||
val => {
|
||||
if (val) setCustomMessages(locale.value, val)
|
||||
},
|
||||
{ deep: true }
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 右键菜单状态管理
|
||||
@@ -1543,6 +1552,7 @@ function handleTaskDelete(task: Task, deleteChildren?: boolean) {
|
||||
:milestones="props.milestones"
|
||||
:start-date="timelineDateRange.min"
|
||||
:end-date="timelineDateRange.max"
|
||||
:working-hours="props.workingHours"
|
||||
:on-task-double-click="props.onTaskDoubleClick"
|
||||
:edit-component="props.editComponent"
|
||||
:use-default-drawer="props.useDefaultDrawer"
|
||||
|
||||
@@ -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 scaleIndex = {
|
||||
[TimelineScale.MONTH]: 0,
|
||||
[TimelineScale.WEEK]: 1,
|
||||
[TimelineScale.DAY]: 2,
|
||||
const currentIndex = availableTimeScales.value.findIndex(
|
||||
scale => scale === currentTimeScaleKey.value,
|
||||
)
|
||||
|
||||
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 {
|
||||
transform: `translateX(${translateX}%)`,
|
||||
width: `${itemWidth}%`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,28 +484,14 @@ onUnmounted(() => {
|
||||
<div class="segmented-thumb" :style="getThumbStyle()"></div>
|
||||
</div>
|
||||
<button
|
||||
v-for="scale in availableTimeScales"
|
||||
:key="scale"
|
||||
class="segmented-item"
|
||||
:class="{ active: currentTimeScale === 'month' }"
|
||||
:class="{ active: currentTimeScaleKey === scale }"
|
||||
:title="t('timeScaleTooltip')"
|
||||
@click="handleTimeScaleChange(TimelineScale.MONTH)"
|
||||
@click="handleTimeScaleChange(timeScaleMap[scale].value)"
|
||||
>
|
||||
{{ t('timeScaleMonth') }}
|
||||
</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') }}
|
||||
{{ timeScaleMap[scale].label() }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 语言选择下拉菜单 -->
|
||||
@@ -1175,7 +1194,7 @@ onUnmounted(() => {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 33.333333%;
|
||||
width: 25%; /* 默认宽度,将通过内联样式动态设置 */
|
||||
height: 100%;
|
||||
background: var(--gantt-primary, #409eff);
|
||||
border-radius: 5px;
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onUnmounted } from 'vue'
|
||||
import type { Milestone } from '../models/classes/Milestone'
|
||||
import { TimelineScale } from '../models/types/TimelineScale'
|
||||
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 t = (key: string): string => {
|
||||
@@ -30,27 +41,11 @@ interface Props {
|
||||
priority: number // 推挤优先级
|
||||
}> // 其他里程碑的位置信息
|
||||
// 新增:时间线数据,用于精确计算subDays定位
|
||||
timelineData?: Array<{
|
||||
year: number
|
||||
month: number
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
subDays?: Array<{ date: Date; dayOfWeek?: number }>
|
||||
monthData?: { dayCount: number }
|
||||
}>
|
||||
timelineData?: unknown[]
|
||||
// 新增:当前时间刻度
|
||||
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 dragStartX = ref(0)
|
||||
@@ -157,7 +152,7 @@ const handleMouseMove = (e: MouseEvent) => {
|
||||
mouseX: e.clientX,
|
||||
isDragging: isDragging.value,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const deltaX = e.clientX - dragStartX.value
|
||||
@@ -184,7 +179,7 @@ const handleMouseUp = () => {
|
||||
mouseX: 0,
|
||||
isDragging: false,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
// 只有在真正拖拽了(有临时数据)且状态为拖拽中时才触发更新
|
||||
@@ -246,19 +241,18 @@ const handleMilestoneClick = (e: MouseEvent) => {
|
||||
scrollLeft: targetScrollLeft,
|
||||
smooth: true,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算菱形位置 - 考虑拖拽临时数据
|
||||
const milestoneStyle = computed(() => {
|
||||
const milestoneDate = tempMilestoneData.value?.startDate
|
||||
? new Date(tempMilestoneData.value.startDate)
|
||||
: new Date(props.date)
|
||||
const currentMilestoneDate = tempMilestoneData.value?.startDate || props.date
|
||||
const milestoneDate = createLocalDate(currentMilestoneDate)
|
||||
|
||||
// 修正:props.startDate 可能为 undefined,需防御性处理
|
||||
if (!props.startDate || isNaN(new Date(props.date).getTime())) {
|
||||
// 修正:防御性处理日期和startDate
|
||||
if (!props.startDate || !milestoneDate || isNaN(milestoneDate.getTime())) {
|
||||
return {
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
@@ -268,25 +262,56 @@ const milestoneStyle = computed(() => {
|
||||
}
|
||||
|
||||
let left = 0
|
||||
const size = Math.min(props.rowHeight, props.dayWidth * 1.2, 24)
|
||||
// 修复:根据不同时间刻度使用合适的图标大小
|
||||
let size = 24 // 默认图标大小
|
||||
|
||||
// 优先使用基于timelineData的精确定位(适用于周视图和月视图)
|
||||
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.currentTimeScale &&
|
||||
(props.currentTimeScale === TimelineScale.WEEK ||
|
||||
props.currentTimeScale === TimelineScale.MONTH)
|
||||
) {
|
||||
// 优先使用基于timelineData的精确定位(适用于周视图和月视图)
|
||||
const centerPosition = calculateMilestonePositionFromTimelineData(
|
||||
milestoneDate,
|
||||
props.timelineData,
|
||||
props.currentTimeScale
|
||||
props.currentTimeScale,
|
||||
)
|
||||
|
||||
left = centerPosition - size / 2 // 从中心位置偏移到图标左上角
|
||||
} else {
|
||||
// 日视图:保持原有逻辑
|
||||
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
|
||||
}
|
||||
@@ -352,7 +377,7 @@ const milestoneVisibility = computed(() => {
|
||||
if (iconRight <= leftBoundary + iconSize / 2) {
|
||||
// 检查左侧是否有其他停靠的里程碑,需要判断推挤优先级
|
||||
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) {
|
||||
// 检查右侧是否有其他停靠的里程碑,需要判断推挤优先级
|
||||
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)
|
||||
})
|
||||
|
||||
// 年度视图里程碑位置计算函数
|
||||
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精确计算里程碑位置的函数
|
||||
const calculateMilestonePositionFromTimelineData = (
|
||||
targetDate: Date,
|
||||
timelineData: Array<{
|
||||
year: number
|
||||
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
|
||||
timelineData: any,
|
||||
timeScale: TimelineScale,
|
||||
) => {
|
||||
// 回退到原来的逻辑用于其他时间刻度
|
||||
let cumulativePosition = 0
|
||||
|
||||
for (const periodData of timelineData) {
|
||||
@@ -567,8 +729,11 @@ const calculateMilestonePositionFromTimelineData = (
|
||||
}
|
||||
|
||||
// 如果没找到精确匹配,回退到dayOfWeek计算
|
||||
// 注意:getDay()返回0=星期日,1=星期一...6=星期六
|
||||
// 但subDays数组是从星期一开始:索引0=星期一,索引1=星期二...索引6=星期日
|
||||
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 dayWidth = monthWidth / daysInMonth
|
||||
const dayInMonth = targetDate.getDate()
|
||||
return cumulativePosition + (dayInMonth - 1) * dayWidth + dayWidth / 2
|
||||
const finalPosition = cumulativePosition + (dayInMonth - 1) * dayWidth + dayWidth / 2
|
||||
|
||||
return finalPosition
|
||||
}
|
||||
|
||||
// 累加每月的宽度
|
||||
|
||||
@@ -41,7 +41,7 @@ const availableTasks = computed(() => {
|
||||
task =>
|
||||
task.type === 'task' &&
|
||||
task.id !== props.currentTaskId &&
|
||||
!selectedPredecessorIds.value.includes(task.id)
|
||||
!selectedPredecessorIds.value.includes(task.id),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -69,7 +69,7 @@ watch(
|
||||
() => {
|
||||
selectedValue.value = ''
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted, onMounted, nextTick, watch } from 'vue'
|
||||
import type { Task } from '../models/classes/Task'
|
||||
@@ -19,25 +20,13 @@ interface Props {
|
||||
// 新增:外部控制半圆隐藏状态(用于Timeline初始化等场景)
|
||||
hideBubbles?: boolean
|
||||
// 新增:时间线数据,用于精确计算subDays定位
|
||||
timelineData?: Array<{
|
||||
year: number
|
||||
month: number
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
subDays?: Array<{ date: Date; dayOfWeek?: number }>
|
||||
monthData?: { dayCount: number }
|
||||
}>
|
||||
timelineData?: any
|
||||
// 新增:当前时间刻度
|
||||
currentTimeScale?: TimelineScale
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { getTranslation } = useI18n()
|
||||
const t = (key: string): string => {
|
||||
return getTranslation(key)
|
||||
}
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:task',
|
||||
'bar-mounted',
|
||||
@@ -50,7 +39,12 @@ const emit = defineEmits([
|
||||
'add-predecessor',
|
||||
'add-successor',
|
||||
'delete',
|
||||
'contextmenu', // 添加原生contextmenu事件声明
|
||||
])
|
||||
const { getTranslation } = useI18n()
|
||||
const t = (key: string): string => {
|
||||
return getTranslation(key)
|
||||
}
|
||||
|
||||
// 日期工具函数 - 处理时区安全的日期创建和操作
|
||||
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)
|
||||
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)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
@@ -75,6 +76,14 @@ const formatDateToLocalString = (date: Date): string => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).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}`
|
||||
}
|
||||
|
||||
@@ -84,6 +93,23 @@ const addDaysToLocalDate = (date: Date, days: number): Date => {
|
||||
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 isResizingLeft = ref(false)
|
||||
@@ -126,51 +152,125 @@ const taskBarStyle = computed(() => {
|
||||
let left = 0
|
||||
let width = 0
|
||||
|
||||
// 优先使用基于timelineData的精确定位(适用于周视图和月视图)
|
||||
if (
|
||||
props.timelineData &&
|
||||
props.currentTimeScale &&
|
||||
(props.currentTimeScale === TimelineScale.WEEK ||
|
||||
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,
|
||||
)
|
||||
// 小时视图:按分钟精确计算位置(需要考虑时间部分)
|
||||
if (props.currentTimeScale === TimelineScale.HOUR) {
|
||||
// 确保 baseStart 是当天的 00:00:00
|
||||
const baseStartOfDay = new Date(baseStart)
|
||||
baseStartOfDay.setHours(0, 0, 0, 0)
|
||||
|
||||
// 如果结束日期+1天超出范围,使用结束日期的位置+一天的宽度
|
||||
if (endPosition === startPosition) {
|
||||
const dayWidth = props.currentTimeScale === TimelineScale.WEEK ? 60 / 7 : 60 / 30
|
||||
endPosition =
|
||||
calculatePositionFromTimelineData(endDate, props.timelineData, props.currentTimeScale) +
|
||||
dayWidth
|
||||
// 处理没有时间部分的日期字符串
|
||||
let adjustedStartDate = startDate
|
||||
let adjustedEndDate = endDate
|
||||
|
||||
// 检查原始日期字符串是否包含时间部分
|
||||
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
|
||||
width = Math.max(endPosition - startPosition, 4) // 确保最小4px宽度
|
||||
} else {
|
||||
// 日视图:保持原有逻辑
|
||||
const startDiff = Math.floor(
|
||||
(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
|
||||
// 如果endDate没有时间部分(格式为YYYY-MM-DD),设置为次日00:00
|
||||
if (typeof originalEndStr === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(originalEndStr.trim())) {
|
||||
adjustedEndDate = new Date(endDate)
|
||||
adjustedEndDate.setDate(adjustedEndDate.getDate() + 1)
|
||||
adjustedEndDate.setHours(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
left = startDiff * props.dayWidth
|
||||
width = duration * props.dayWidth
|
||||
// 计算从当天00:00到任务开始和结束的分钟数
|
||||
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 {
|
||||
@@ -260,8 +360,8 @@ const needsOverflowEffect = computed(() => isWeekView.value && isShortTaskBar.va
|
||||
|
||||
// 鼠标事件处理 - 使用相对位置拖拽方案
|
||||
const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-right') => {
|
||||
// 如果已完成或是父级任务,禁用所有交互
|
||||
if (isCompleted.value || props.isParent) {
|
||||
// 如果已完成或是父级任务或年度视图,禁用所有交互
|
||||
if (isCompleted.value || props.isParent || isInteractionDisabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -342,39 +442,132 @@ const handleMouseMove = (e: MouseEvent) => {
|
||||
|
||||
if (isDragging.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)
|
||||
|
||||
// 只更新临时数据,不触发事件
|
||||
tempTaskData.value = {
|
||||
startDate: formatDateToLocalString(newStartDate),
|
||||
endDate: formatDateToLocalString(newEndDate),
|
||||
if (props.currentTimeScale === TimelineScale.HOUR) {
|
||||
// 小时视图:15分钟刻度对齐
|
||||
const pixelPerMinute = 40 / 60 // 每分钟的像素数
|
||||
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) {
|
||||
const deltaX = e.clientX - resizeStartX.value
|
||||
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, // 保持原来的结束日期
|
||||
if (props.currentTimeScale === TimelineScale.HOUR) {
|
||||
// 小时视图:15分钟刻度对齐
|
||||
const pixelPerMinute = 40 / 60
|
||||
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) {
|
||||
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,
|
||||
)
|
||||
|
||||
// 只更新临时数据,不触发事件
|
||||
tempTaskData.value = {
|
||||
startDate: props.task.startDate, // 保持原来的开始日期
|
||||
endDate: formatDateToLocalString(newEndDate),
|
||||
if (props.currentTimeScale === TimelineScale.HOUR) {
|
||||
// 小时视图:15分钟刻度对齐
|
||||
const pixelPerMinute = 40 / 60
|
||||
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
|
||||
}
|
||||
|
||||
// 年度视图位置计算函数
|
||||
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精确计算日期位置的函数
|
||||
const calculatePositionFromTimelineData = (
|
||||
targetDate: Date,
|
||||
@@ -922,7 +1168,32 @@ const calculatePositionFromTimelineData = (
|
||||
let cumulativePosition = 0
|
||||
|
||||
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结构
|
||||
const weeks = periodData.weeks || []
|
||||
|
||||
@@ -1056,13 +1327,18 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 左侧调整把手 -->
|
||||
<div
|
||||
v-if="!isCompleted && !isParent"
|
||||
v-if="!isCompleted && !isParent && !isInteractionDisabled"
|
||||
class="resize-handle resize-handle-left"
|
||||
@mousedown="e => handleMouseDown(e, 'resize-left')"
|
||||
></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()">
|
||||
{{ task.name }}
|
||||
@@ -1076,7 +1352,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 右侧调整把手 -->
|
||||
<div
|
||||
v-if="!isCompleted && !isParent"
|
||||
v-if="!isCompleted && !isParent && !isInteractionDisabled"
|
||||
class="resize-handle resize-handle-right"
|
||||
@mousedown="e => handleMouseDown(e, 'resize-right')"
|
||||
></div>
|
||||
@@ -1158,7 +1434,7 @@ onUnmounted(() => {
|
||||
user-select: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transition: box-shadow 0.2s;
|
||||
min-width: 60px;
|
||||
/*min-width: 60px;*/
|
||||
z-index: 100;
|
||||
border: 2px solid;
|
||||
overflow: visible; /* 允许内容超出 TaskBar */
|
||||
|
||||
@@ -279,7 +279,7 @@ onUnmounted(() => {
|
||||
:style="{
|
||||
left: `${adjustedPosition.x}px`,
|
||||
top: `${adjustedPosition.y}px`,
|
||||
zIndex: 3000,
|
||||
zIndex: 10000,
|
||||
position: 'fixed',
|
||||
}"
|
||||
>
|
||||
@@ -355,7 +355,7 @@ onUnmounted(() => {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px 0;
|
||||
width: 180px; /* 调整固定宽度,确保文本不会被截断 */
|
||||
z-index: 1000;
|
||||
z-index: 10000; /* 确保在全屏模式(z-index: 9999)之上显示 */
|
||||
user-select: none;
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
border: 1px solid #e4e7ed;
|
||||
@@ -520,7 +520,7 @@ onUnmounted(() => {
|
||||
border-bottom: 8px solid #fff; /* 匹配菜单背景色 */
|
||||
transform-origin: center;
|
||||
filter: drop-shadow(0 -1px 2px rgba(0, 0, 0, 0.1)); /* 为箭头添加阴影效果 */
|
||||
z-index: 1001;
|
||||
z-index: 10001; /* 确保在全屏模式之上显示 */
|
||||
pointer-events: none; /* 确保箭头不会干扰鼠标事件 */
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ watch(
|
||||
() => [props.task?.isTimerRunning, props.task?.timerStartTime, props.task?.timerElapsedTime],
|
||||
() => {
|
||||
updateTimer()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 计时器本地状态,保证点击后UI立即切换
|
||||
@@ -84,7 +84,7 @@ watch(
|
||||
timerInterval.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 修正计时器每秒递增逻辑,保证计时器正常跳动
|
||||
@@ -102,7 +102,7 @@ watch(
|
||||
updateTimer()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -153,7 +153,7 @@ const availableParentTasks = computed(() => {
|
||||
.filter(
|
||||
task =>
|
||||
task.id !== props.task?.id && // 排除当前任务自己
|
||||
(task.type === 'story' || task.type === 'task') // 只显示story和task类型
|
||||
(task.type === 'story' || task.type === 'task'), // 只显示story和task类型
|
||||
)
|
||||
.map(task => ({
|
||||
...task,
|
||||
@@ -209,6 +209,38 @@ const handleProgressInputBlur = () => {
|
||||
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 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({
|
||||
name: '',
|
||||
@@ -273,7 +330,13 @@ watch(
|
||||
resetForm()
|
||||
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) {
|
||||
// 新建模式,自动绑定上级任务
|
||||
formData.parentId = props.task.parentId ?? undefined
|
||||
@@ -283,7 +346,7 @@ watch(
|
||||
// 抽屉显示时重新请求任务数据,确保前置任务列表是最新的
|
||||
window.dispatchEvent(new CustomEvent('request-task-list'))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 监听 isVisible 变化,同步到父组件
|
||||
@@ -300,7 +363,7 @@ watch(
|
||||
formData.parentId = newTask.parentId ?? undefined
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 重置表单
|
||||
@@ -485,7 +548,7 @@ watch(
|
||||
newValue => {
|
||||
progressDisplayValue.value = (newValue || 0).toString()
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 修正计时器首次启动不跳动问题:每次打开抽屉时重置 timerElapsed,且 timerStartTime 为空时立即赋值
|
||||
@@ -503,7 +566,7 @@ watch(
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const handleStartTimer = (desc?: string) => {
|
||||
@@ -783,12 +846,14 @@ function confirmTimer(desc: string) {
|
||||
<label class="form-label" for="task-estimated-hours">{{ t.estimatedHours }}</label>
|
||||
<input
|
||||
id="task-estimated-hours"
|
||||
v-model.number="formData.estimatedHours"
|
||||
v-model="formData.estimatedHours"
|
||||
type="number"
|
||||
class="form-input"
|
||||
placeholder="0"
|
||||
placeholder="0.00"
|
||||
min="0"
|
||||
max="999"
|
||||
max="99999"
|
||||
step="0.01"
|
||||
@input="handleEstimatedHoursInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -796,12 +861,14 @@ function confirmTimer(desc: string) {
|
||||
<label class="form-label" for="task-actual-hours">{{ t.actualHours }}</label>
|
||||
<input
|
||||
id="task-actual-hours"
|
||||
v-model.number="formData.actualHours"
|
||||
v-model="formData.actualHours"
|
||||
type="number"
|
||||
class="form-input"
|
||||
placeholder="0"
|
||||
placeholder="0.00"
|
||||
min="0"
|
||||
max="999"
|
||||
max="99999"
|
||||
step="0.01"
|
||||
@input="handleActualHoursInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -93,10 +93,15 @@ const messages = {
|
||||
githubDocs: '查看Github文档',
|
||||
giteeDocs: '查看Gitee文档',
|
||||
// 时间刻度按钮
|
||||
timeScaleMonth: '月',
|
||||
timeScaleWeek: '周',
|
||||
timeScaleHour: '时',
|
||||
timeScaleDay: '日',
|
||||
timeScaleWeek: '周',
|
||||
timeScaleMonth: '月',
|
||||
timeScaleQuarter: '季',
|
||||
timeScaleYear: '年',
|
||||
timeScaleTooltip: '切换时间刻度',
|
||||
halfYearFirst: '上半年',
|
||||
halfYearSecond: '下半年',
|
||||
// 确认对话框
|
||||
confirmDialogMessage: '是否需要保留该设置?',
|
||||
// 新建任务对话框
|
||||
@@ -144,6 +149,12 @@ const messages = {
|
||||
},
|
||||
editTask: '编辑任务',
|
||||
parentTask: '上级任务',
|
||||
|
||||
// 时间选择器相关
|
||||
time: '时间',
|
||||
selectTime: '选择时间',
|
||||
hour: '时',
|
||||
minute: '分',
|
||||
noParentTask: '无上级任务',
|
||||
update: '更新',
|
||||
taskNameTooLong: '任务名称不能超过50个字符',
|
||||
@@ -217,7 +228,7 @@ const messages = {
|
||||
// 日期格式
|
||||
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
|
||||
monthNames: [
|
||||
@@ -274,10 +285,15 @@ const messages = {
|
||||
githubDocs: 'GitHub Docs',
|
||||
giteeDocs: 'Gitee Docs',
|
||||
// 时间刻度按钮
|
||||
timeScaleMonth: 'Month',
|
||||
timeScaleWeek: 'Week',
|
||||
timeScaleHour: 'Hour',
|
||||
timeScaleDay: 'Day',
|
||||
timeScaleWeek: 'Week',
|
||||
timeScaleMonth: 'Month',
|
||||
timeScaleQuarter: 'Quarter',
|
||||
timeScaleYear: 'Year',
|
||||
timeScaleTooltip: 'Switch Time Scale',
|
||||
halfYearFirst: 'First Half',
|
||||
halfYearSecond: 'Second Half',
|
||||
// Confirm dialog
|
||||
confirmDialogMessage: 'Do you want to save this setting?',
|
||||
taskNamePlaceholder: 'Enter task name',
|
||||
@@ -324,6 +340,13 @@ const messages = {
|
||||
editTask: 'Edit Task',
|
||||
newTask: 'New Task',
|
||||
parentTask: 'Parent Task',
|
||||
|
||||
// 时间选择器相关
|
||||
time: 'Time',
|
||||
selectTime: 'Select Time',
|
||||
hour: 'Hour',
|
||||
minute: 'Minute',
|
||||
|
||||
noParentTask: 'No Parent Task',
|
||||
update: 'Update',
|
||||
taskNameTooLong: 'Task name cannot exceed 50 characters',
|
||||
@@ -425,7 +448,7 @@ export function useI18n() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('locale-changed', {
|
||||
detail: { locale },
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,4 +9,5 @@ export interface ToolbarConfig {
|
||||
showTheme?: boolean
|
||||
showFullscreen?: boolean
|
||||
showTimeScale?: boolean // 显示时间刻度按钮组
|
||||
timeScaleDimensions?: ('hour' | 'day' | 'week' | 'month' | 'year')[] // 设置时间刻度按钮的展示维度
|
||||
}
|
||||
|
||||
20
src/models/types/TimelineCompat.ts
Normal file
20
src/models/types/TimelineCompat.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 时间轴数据类型兼容性定义
|
||||
* 用于处理现有代码的类型兼容问题
|
||||
*/
|
||||
|
||||
// 简化的通用时间轴数据类型,用于向下兼容
|
||||
export type LegacyTimelineData = Array<Record<string, any>>
|
||||
|
||||
// 类型守卫函数
|
||||
export function isTimelineMonth(item: any): item is Record<string, any> {
|
||||
return item && typeof item === 'object' && 'year' in item && 'month' in item
|
||||
}
|
||||
|
||||
export function isTimelineYear(item: any): item is Record<string, any> {
|
||||
return item && typeof item === 'object' && 'year' in item && 'yearLabel' in item
|
||||
}
|
||||
|
||||
export function isTimelineDay(item: any): item is Record<string, any> {
|
||||
return item && typeof item === 'object' && 'year' in item && 'day' in item
|
||||
}
|
||||
131
src/models/types/TimelineDataTypes.ts
Normal file
131
src/models/types/TimelineDataTypes.ts
Normal 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
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
// 时间轴比例类型定义
|
||||
|
||||
// 使用字符串字面量类型代替enum,兼容erasableSyntaxOnly
|
||||
export type TimelineScale = 'day' | 'week' | 'month'
|
||||
export type TimelineScale = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'
|
||||
|
||||
// 导出常量值以便于使用
|
||||
export const TimelineScale = {
|
||||
HOUR: 'hour' as TimelineScale, // 小时视图 - 每列显示一小时
|
||||
DAY: 'day' as TimelineScale, // 日视图 - 每列显示一天
|
||||
WEEK: 'week' as TimelineScale, // 周视图 - 每列显示一周
|
||||
MONTH: 'month' as TimelineScale, // 月视图 - 每列显示一个月
|
||||
QUARTER: 'quarter' as TimelineScale, // 季度视图 - 每列显示一个季度
|
||||
YEAR: 'year' as TimelineScale, // 年视图 - 每列显示一年
|
||||
}
|
||||
|
||||
export interface TimelineScaleConfig {
|
||||
@@ -22,6 +25,12 @@ export interface TimelineScaleConfig {
|
||||
|
||||
// 预设配置
|
||||
export const SCALE_CONFIGS = {
|
||||
hour: {
|
||||
scale: TimelineScale.HOUR,
|
||||
cellWidth: 40,
|
||||
headerLevels: 2,
|
||||
formatters: { primary: 'yyyy年MM月dd日', secondary: 'HH:mm' },
|
||||
},
|
||||
day: {
|
||||
scale: TimelineScale.DAY,
|
||||
cellWidth: 30,
|
||||
@@ -40,6 +49,12 @@ export const SCALE_CONFIGS = {
|
||||
headerLevels: 2,
|
||||
formatters: { primary: 'yyyy年', secondary: 'MM月' },
|
||||
},
|
||||
year: {
|
||||
scale: TimelineScale.YEAR,
|
||||
cellWidth: 360,
|
||||
headerLevels: 2,
|
||||
formatters: { primary: 'yyyy年', secondary: '上半年|下半年' },
|
||||
},
|
||||
} as Record<TimelineScale, TimelineScaleConfig>
|
||||
|
||||
// 时间单元数据接口
|
||||
@@ -72,4 +87,16 @@ export interface TimelineData {
|
||||
endDate: Date
|
||||
units: TimelineUnit[]
|
||||
}>
|
||||
years?: Array<{
|
||||
year: number
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
halfYears: Array<{
|
||||
label: string
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
width: number
|
||||
}>
|
||||
width: number
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -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数组
|
||||
* @param predecessor 前置任务字段,可能是数组、字符串或空
|
||||
|
||||
10
tsconfig.build.json
Normal file
10
tsconfig.build.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmitOnError": false,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user