This commit is contained in:
LINING-PC\lining
2025-06-28 21:20:50 +08:00
commit 5ba84d5309
57 changed files with 15687 additions and 0 deletions

24
.editorconfig Normal file
View File

@@ -0,0 +1,24 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{json,yml,yaml}]
indent_size = 2
[*.vue]
indent_size = 2
[*.{js,ts,tsx,jsx}]
indent_size = 2
[*.{css,scss,sass,less}]
indent_size = 2

31
.eslintignore Normal file
View File

@@ -0,0 +1,31 @@
# Dependencies
node_modules/
# Build outputs
dist/
dist-ssr/
coverage/
# Generated files
*.d.ts
.tmp/
.cache/
.vscode/
.git/
# Config files
*.config.js
*.config.ts
vite.config.*.ts
.eslintrc.cjs
# Test files
**/*.test.ts
**/*.spec.ts
# OS generated files
.DS_Store
Thumbs.db
# Logs
*.log

144
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,144 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:vue/vue3-essential',
'plugin:vue/vue3-strongly-recommended',
'plugin:vue/vue3-recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting',
],
overrides: [
{
env: {
node: true,
},
files: ['.eslintrc.{js,cjs}'],
parserOptions: {
sourceType: 'script',
},
},
],
parserOptions: {
ecmaVersion: 'latest',
parser: '@typescript-eslint/parser',
sourceType: 'module',
// 性能优化:禁用 type checking
project: false,
},
plugins: ['@typescript-eslint', 'vue'],
ignorePatterns: [
'dist',
'node_modules',
'*.d.ts',
'.eslintrc.cjs',
'vite.config.*.ts',
'**/*.test.ts',
'**/*.spec.ts',
'coverage',
'.tmp',
'.cache',
],
rules: {
// Vue.js 规则
'vue/multi-word-component-names': 'off',
'vue/no-unused-vars': 'error',
'vue/no-mutating-props': 'error',
'vue/require-default-prop': 'error',
'vue/require-prop-types': 'error',
'vue/prefer-import-from-vue': 'error',
'vue/component-tags-order': [
'error',
{
order: ['script', 'template', 'style'],
},
],
'vue/block-tag-newline': [
'error',
{
singleline: 'always',
multiline: 'always',
},
],
'vue/component-name-in-template-casing': [
'error',
'PascalCase',
{
registeredComponentsOnly: true,
},
],
'vue/define-macros-order': [
'error',
{
order: ['defineOptions', 'defineProps', 'defineEmits', 'defineSlots'],
},
],
// TypeScript 规则
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-empty-function': 'warn',
'@typescript-eslint/no-inferrable-types': 'error',
// '@typescript-eslint/prefer-const': 'error', // 与 prefer-const 冲突,移除
'@typescript-eslint/no-var-requires': 'error',
// 一般规则
'no-console': 'warn',
'no-debugger': 'error',
'no-alert': 'warn',
'no-var': 'error',
'prefer-const': 'error',
'object-shorthand': 'error',
'prefer-template': 'error',
'template-curly-spacing': 'error',
'arrow-spacing': 'error',
'comma-dangle': ['error', 'always-multiline'],
semi: ['error', 'never'],
quotes: ['error', 'single'],
indent: ['error', 2],
'max-len': [
'error',
{
code: 100,
ignoreUrls: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreRegExpLiterals: true,
ignorePattern: 'd="[^"]*"', // 忽略SVG路径定义
},
],
'eol-last': 'error',
'no-trailing-spaces': 'error',
'space-before-function-paren': [
'error',
{
anonymous: 'always',
named: 'never',
asyncArrow: 'always',
},
],
'keyword-spacing': 'error',
'space-infix-ops': 'error',
'object-curly-spacing': ['error', 'always'],
'array-bracket-spacing': ['error', 'never'],
'computed-property-spacing': ['error', 'never'],
'func-call-spacing': ['error', 'never'],
'key-spacing': ['error', { beforeColon: false, afterColon: true }],
'comma-spacing': ['error', { before: false, after: true }],
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }],
'padded-blocks': ['error', 'never'],
'space-before-blocks': 'error',
'brace-style': ['error', '1tbs', { allowSingleLine: true }],
},
// 忽略特定文件或目录
ignorePatterns: ['dist/', 'node_modules/', '*.d.ts', 'vite.config.*.ts', 'tsconfig.*.json'],
}

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Performance optimization
.tmp/
.cache/
.tsbuildinfo
*.tsbuildinfo
.eslintcache
# OS generated files
Thumbs.db
ehthumbs.db
Desktop.ini

19
.prettierignore Normal file
View File

@@ -0,0 +1,19 @@
dist
node_modules
.nuxt
.output
.vite
.next
coverage
*.min.js
*.min.css
*.bundle.js
package-lock.json
yarn.lock
pnpm-lock.yaml
*.md
CHANGELOG*
LICENSE*
.gitignore
.eslintignore
.prettierignore

20
.prettierrc Normal file
View File

@@ -0,0 +1,20 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": true,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"vueIndentScriptAndStyle": false,
"embeddedLanguageFormatting": "auto"
}

11
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"recommendations": [
"Vue.volar",
"Vue.vscode-typescript-vue-plugin",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss",
"formulahendry.auto-rename-tag",
"christian-kohler.path-intellisense",
"ms-vscode.vscode-typescript-next"
]
}

15
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"typescript.preferences.includePackageJsonAutoImports": "off",
"typescript.suggest.autoImports": false,
"typescript.disableAutomaticTypeAcquisition": true,
"typescript.validate.enable": false,
"javascript.validate.enable": false,
"vetur.validation.template": false,
"vetur.validation.script": false,
"vetur.validation.style": false,
"files.exclude": {
"**/node_modules": true,
"**/dist": true,
"**/.git": true
}
}

26
API_USAGE.md Normal file
View File

@@ -0,0 +1,26 @@
# API_USAGE
## 组件导出
```js
import { GanttChart, TaskList, TaskRow, MilestonePoint, GanttToolbar, GanttConfirmDialog } from 'jordium-gantt-vue3'
```
## 主要 Props 说明
- `tasks`: 任务数据数组
- `milestones`: 里程碑数据数组
- `onDelete`: 删除回调,已统一弹窗交互
- `theme`: 主题切换(如 'light'/'dark'
- 其它详见各组件 props 类型定义
## 事件
- `@delete`:删除任务/里程碑
- `@update`:任务/里程碑更新
- `@confirm`/`@cancel`:弹窗交互
## 样式与主题
- 全局按钮、弹窗、表格等样式统一于 `src/styles/app.css`
- 主题变量见 `src/styles/theme-variables.css`
## 扩展
- 支持自定义工具栏、弹窗内容、国际化
- 详细用法见 README.md

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 JORDIUM.COM
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

88
README.en.md Normal file
View File

@@ -0,0 +1,88 @@
# <svg width="28" height="28" viewBox="0 0 24 24" style="vertical-align:middle;"><rect x="2" y="4" width="20" height="2" rx="1" fill="#409eff"/><rect x="2" y="8" width="12" height="2" rx="1" fill="#67c23a"/><rect x="2" y="12" width="16" height="2" rx="1" fill="#e6a23c"/><rect x="2" y="16" width="8" height="2" rx="1" fill="#f56c6c"/><rect x="2" y="20" width="14" height="2" rx="1" fill="#909399"/><circle cx="22" cy="5" r="1" fill="#409eff"/><circle cx="16" cy="9" r="1" fill="#67c23a"/><circle cx="20" cy="13" r="1" fill="#e6a23c"/><circle cx="12" cy="17" r="1" fill="#f56c6c"/><circle cx="18" cy="21" r="1" fill="#909399"/></svg> Jordium Gantt Vue3
> Modern, open-source, and high-performance Gantt chart component library for Vue3 + TypeScript
---
## 🚀 Product Positioning
Jordium Gantt Vue3 is a high-performance Gantt chart component library for modern web applications, focusing on excellent interaction, flexible extension, and open-source compliance. Ideal for project management, progress visualization, R&D collaboration, and more.
- **Tech Stack**: Vue 3 + TypeScript
- **Design Language**: Element Plus style (no dependency)
- **Use Cases**: Enterprise project management, scheduling, resource allocation, etc.
## ✨ Key Features
- **Modern UI/UX**: Minimalist, clean, responsive, supports light/dark themes, auto system adaptation
- **High Performance**: Virtual scrolling, auto-expanding timeline, smooth with large datasets
- **Task Management**: Multi-level tasks, milestones, drag & resize, progress, dependencies
- **Enhanced Interaction**: Today locator, fullscreen, version history, toolbar, quick actions
- **Internationalization**: Built-in Chinese/English, easy to extend to more languages
- **Flexible Extension**: Custom editors, event handling, data sources, export, etc.
- **Open Source Compliance**: MIT License, suitable for secondary development and commercial use
## 🖌️ Design Philosophy
- **Ultimate Experience**: Interaction details match top-tier products, smooth animation, positioning, and theme switching
- **No External Dependencies**: Only Vue3/TS required, Element Plus style but zero dependency
- **Easy to Use & Extend**: Friendly API, supports slots, events, and deep prop customization
- **Ready to Use**: Comprehensive documentation, easy integration
## 📦 Installation & Usage
```bash
npm install jordium-gantt-vue3
```
### Basic Usage
```vue
<template>
<GanttChart :tasks="tasks" />
</template>
<script setup lang="ts">
import GanttChart from 'jordium-gantt-vue3'
const tasks = [/* ...task data... */]
</script>
```
### Advanced Usage
- Custom task editor (disable default drawer, handle double-click events)
- Custom toolbar buttons, export, theme switching
- Listen to task/milestone changes, drag, dependency events
- Support for external data sources, async loading
See [API_USAGE.md](./API_USAGE.md) for details.
## 🎨 Theming & Adaptation
- Supports light/dark themes, auto system detection
- Theme variables customizable for easy brand adaptation
- Fully responsive, works on desktop and mobile
## 📝 Version History
See [version-history.json](./demo/version-history.json) for details.
- **0.9.x-ALPHA**: Productization refactor, full interaction & visual upgrade, MIT License
- **0.2 Beta**: API improvements, tasks/milestones/dependencies/toolbar/i18n/theme
- **0.1 Beta**: Basic features, Element Plus style
## 📚 API Documentation
See [API_USAGE.md](./API_USAGE.md) for full API reference.
## 🤝 Contribution & Community
Issues, PRs, suggestions, and stars are welcome! For custom development, enterprise support, or bug reports, contact [jordium.com](https://jordium.com).
## 📄 License
MIT License © Jordium
# README.en.md
This file has been deprecated. Please refer to `README.md` for the latest documentation.

35
README.md Normal file
View File

@@ -0,0 +1,35 @@
# jordium-gantt-vue3
Vue3 甘特图/Gantt 组件库,支持任务、里程碑、虚拟滚动、主题切换、弹窗交互、全局样式共通化等。
## 特性
- 任务/里程碑可视化、可编辑
- 虚拟滚动与性能优化
- 全局样式统一与主题变量
- 删除确认弹窗统一美化
- 组件/类型解耦,命名规范
- 支持多语言与自定义扩展
## 快速开始
```bash
npm install jordium-gantt-vue3
```
```js
import { GanttChart } from 'jordium-gantt-vue3'
```
## 主要组件
- GanttChart
- TaskList / TaskRow
- MilestonePoint
- GanttToolbar
- GanttConfirmDialog
## 贡献与开发
- 统一样式请参考 `src/styles/app.css`
- 组件开发请遵循解耦、命名规范
- 详细API见 API_USAGE.md
## License
MIT

775
demo/App.vue Normal file
View File

@@ -0,0 +1,775 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import GanttChart from '../src/components/GanttChart.vue'
import TaskDrawer from '../src/components/TaskDrawer.vue'
import MilestoneDialog from '../src/components/MilestoneDialog.vue'
import demoData from './data.json'
import packageInfo from '../package.json'
// 导入主题变量
import '../src/styles/theme-variables.css'
import VersionHistoryDrawer from './VersionHistoryDrawer.vue'
interface Task {
id: number
name: string
predecessor?: string
assignee?: string
startDate?: string
endDate?: string
progress?: number
estimatedHours?: number
actualHours?: number
children?: Task[]
collapsed?: boolean
isParent?: boolean
type?: string
description?: string
parentId?: number // 上级任务ID
}
const tasks = ref<Task[]>([])
const milestones = ref<Task[]>([])
// TaskDrawer状态管理
const showTaskDrawer = ref(false)
const currentTask = ref<Task | null>(null)
const isEditMode = ref(false)
// MilestoneDialog状态管理
const showMilestoneDialog = ref(false)
const currentMilestone = ref<Task | null>(null)
const isMilestoneEditMode = ref(false)
// 版本历史Drawer状态
const showVersionDrawer = ref(false)
const toolbarConfig = {
showAddTask: true,
showAddMilestone: true,
showTodayLocate: true,
showExportCsv: true,
showExportPdf: true,
showLanguage: true,
showTheme: true,
showFullscreen: true,
}
// 自定义CSV导出处理器可选
const handleCustomCsvExport = () => {
console.log('自定义CSV导出被调用')
console.log('当前任务数据:', tasks.value)
console.log('当前里程碑数据:', milestones.value)
// 这里可以实现自定义的CSV导出逻辑
// 例如:添加额外的数据处理、格式化、或发送到服务器等
// 如果不实现自定义逻辑,组件会使用内置的默认导出功能
// return false // 返回false让组件使用默认实现
// 示例:这里直接使用默认实现
return false
}
// 其他工具栏事件处理器示例
const handleAddTask = () => {
// 打开TaskDrawer进行新建任务
currentTask.value = null
isEditMode.value = false
showTaskDrawer.value = true
}
const handleAddMilestone = () => {
// 打开MilestoneDialog进行新建里程碑
currentMilestone.value = null
isMilestoneEditMode.value = false
showMilestoneDialog.value = true
}
const handleLanguageChange = (lang: 'zh' | 'en') => {
console.log('语言切换到:', lang)
}
const handleThemeChange = (isDark: boolean) => {
console.log('主题切换到:', isDark ? '暗黑模式' : '明亮模式')
}
// 里程碑保存处理器示例
const handleMilestoneSave = (milestone: Task) => {
// 更新本地里程碑数据
const milestoneIndex = milestones.value.findIndex(m => m.id === milestone.id)
if (milestoneIndex !== -1) {
// 更新现有里程碑
milestones.value[milestoneIndex] = { ...milestone }
} else {
// 新增里程碑
const newMilestone = {
...milestone,
id: Date.now(), // 生成临时ID
type: 'milestone',
}
milestones.value.push(newMilestone)
}
// 关闭里程碑对话框
showMilestoneDialog.value = false
}
// 里程碑删除处理器
const handleMilestoneDelete = async (milestoneId: number) => {
console.log('删除里程碑ID', milestoneId)
// 从里程碑数据中删除
const milestoneIndex = milestones.value.findIndex(m => m.id === milestoneId)
if (milestoneIndex !== -1) {
milestones.value.splice(milestoneIndex, 1)
console.log('里程碑删除成功')
// 等待DOM更新完成
await nextTick()
// 触发全局事件,通知其他组件里程碑已删除
window.dispatchEvent(
new CustomEvent('milestone-deleted', {
detail: { milestoneId },
}),
)
// 触发强制更新事件确保Timeline重新渲染
window.dispatchEvent(
new CustomEvent('milestone-data-changed', {
detail: { milestones: milestones.value },
}),
)
}
// 关闭里程碑对话框
showMilestoneDialog.value = false
}
// 任务更新处理器
const handleTaskUpdate = (updatedTask: Task) => {
console.log('Demo App 接收到任务更新:', updatedTask)
console.log('updatedTask.type:', updatedTask.type)
console.log('updatedTask.parentId:', updatedTask.parentId)
// 先找到原任务检查parentId是否改变了
const findOriginalTask = (taskArray: Task[]): Task | null => {
for (const task of taskArray) {
if (task.id === updatedTask.id) {
return task
}
if (task.children && task.children.length > 0) {
const found = findOriginalTask(task.children)
if (found) return found
}
}
return null
}
const originalTask = findOriginalTask(tasks.value)
if (!originalTask) {
console.log('未找到要更新的任务ID', updatedTask.id)
return
}
// 检查parentId是否改变了
const parentIdChanged = originalTask.parentId !== updatedTask.parentId
if (parentIdChanged) {
// parentId改变了需要移除任务并重新添加到新位置
console.log('任务父级关系改变,执行移动操作')
const removeTaskFromArray = (taskArray: Task[]): Task | null => {
for (let i = 0; i < taskArray.length; i++) {
if (taskArray[i].id === updatedTask.id) {
const removedTask = taskArray.splice(i, 1)[0]
console.log('从数组中移除任务:', removedTask)
return removedTask
}
if (taskArray[i].children && taskArray[i].children.length > 0) {
const removedTask = removeTaskFromArray(taskArray[i].children!)
if (removedTask) {
if (taskArray[i].children!.length === 0) {
delete taskArray[i].children
taskArray[i].isParent = taskArray[i].type === 'story'
}
return removedTask
}
}
}
return null
}
const removedTask = removeTaskFromArray(tasks.value)
if (!removedTask) return
const taskToAdd = {
...updatedTask,
isParent:
updatedTask.type === 'story' || (updatedTask.children && updatedTask.children.length > 0),
}
// 重新添加到新位置
if (taskToAdd.parentId) {
const addToParentChildren = (taskArray: Task[]): boolean => {
for (const task of taskArray) {
if (task.id === taskToAdd.parentId) {
if (!task.children) task.children = []
task.children.push(taskToAdd)
task.isParent = true
console.log('已将任务添加到新父任务的children中父任务ID', taskToAdd.parentId)
return true
}
if (task.children && task.children.length > 0) {
if (addToParentChildren(task.children)) return true
}
}
return false
}
if (!addToParentChildren(tasks.value)) {
console.warn('未找到新父任务ID', taskToAdd.parentId, ',将作为顶级任务添加')
tasks.value.push(taskToAdd)
}
} else {
tasks.value.push(taskToAdd)
console.log('已将任务添加为顶级任务')
}
} else {
// parentId没有改变只是就地更新任务数据
console.log('任务父级关系未变,执行就地更新')
const updateTaskInPlace = (taskArray: Task[]): boolean => {
for (let i = 0; i < taskArray.length; i++) {
if (taskArray[i].id === updatedTask.id) {
// 保持原有的children和层级关系
taskArray[i] = {
...updatedTask,
children: taskArray[i].children, // 保持原有的children
isParent:
updatedTask.type === 'story' ||
(taskArray[i].children && taskArray[i].children.length > 0),
}
console.log('已就地更新任务数据:', taskArray[i])
return true
}
if (taskArray[i].children && taskArray[i].children.length > 0) {
if (updateTaskInPlace(taskArray[i].children!)) {
return true
}
}
}
return false
}
if (!updateTaskInPlace(tasks.value)) {
console.log('就地更新失败未找到任务ID', updatedTask.id)
}
}
console.log('任务更新完成,当前任务数据:', tasks.value)
}
// 任务添加处理器
const handleTaskAdd = (newTask: Task) => {
console.log('Demo App 接收到新增任务:', newTask)
console.log('newTask.type:', newTask.type)
// 为新任务生成ID如果没有的话
if (!newTask.id) {
// 找到当前最大的ID然后+1
const maxId = Math.max(
...tasks.value.map(t => t.id || 0),
...milestones.value.map(m => m.id || 0),
0,
)
newTask.id = maxId + 1
}
// 设置isParent属性
newTask.isParent = newTask.type === 'story' || (newTask.children && newTask.children.length > 0)
// 处理父子关系
if (newTask.parentId) {
// 如果有上级任务需要将子任务添加到父任务的children中
const addToParentChildren = (taskArray: Task[]): boolean => {
for (const task of taskArray) {
if (task.id === newTask.parentId) {
// 找到父任务
if (!task.children) {
// 如果父任务没有children属性创建一个
task.children = []
}
// 将新任务添加到父任务的children中
task.children.push({ ...newTask })
console.log('已将子任务添加到父任务的children中父任务ID', newTask.parentId)
return true
}
// 递归查找父任务(支持多层嵌套)
if (task.children && task.children.length > 0) {
if (addToParentChildren(task.children)) {
return true
}
}
}
return false
}
// 尝试添加到父任务的children中
if (!addToParentChildren(tasks.value)) {
console.warn('未找到父任务ID', newTask.parentId, ',将作为顶级任务添加')
// 如果没找到父任务,作为顶级任务添加
tasks.value.push({ ...newTask })
}
} else {
// 没有父任务,作为顶级任务添加
tasks.value.push({ ...newTask })
}
}
// 任务删除处理器
const handleTaskDelete = (taskToDelete: Task) => {
console.log('删除任务:', taskToDelete)
// 递归查找和删除任务(支持嵌套结构)
const deleteTaskFromArray = (taskArray: Task[]): boolean => {
for (let i = 0; i < taskArray.length; i++) {
if (taskArray[i].id === taskToDelete.id) {
// 找到任务,删除它
taskArray.splice(i, 1)
console.log('已删除任务')
return true
}
// 如果有子任务,递归查找
if (taskArray[i].children && taskArray[i].children.length > 0) {
if (deleteTaskFromArray(taskArray[i].children!)) {
return true
}
}
}
return false
}
if (!deleteTaskFromArray(tasks.value)) {
console.log('未找到要删除的任务ID', taskToDelete.id)
}
}
// 里程碑图标变更处理器
const handleMilestoneIconChange = (milestoneId: number, icon: string) => {
console.log('里程碑图标变更:', { milestoneId, icon })
const milestoneIndex = milestones.value.findIndex(m => m.id === milestoneId)
if (milestoneIndex !== -1) {
milestones.value[milestoneIndex].icon = icon
console.log('已更新里程碑图标')
} else {
console.log('未找到要更新图标的里程碑ID', milestoneId)
}
}
// TaskDrawer事件处理器
const handleTaskDrawerSubmit = (task: Task) => {
if (isEditMode.value) {
// 编辑模式:更新任务
handleTaskUpdate(task)
} else {
// 新建模式:添加任务
handleTaskAdd(task)
}
showTaskDrawer.value = false
}
const handleTaskDrawerClose = () => {
showTaskDrawer.value = false
currentTask.value = null
isEditMode.value = false
}
const handleTaskDrawerDelete = (taskId: number) => {
const taskToDelete = tasks.value.find(t => t.id === taskId)
if (taskToDelete) {
handleTaskDelete(taskToDelete)
}
showTaskDrawer.value = false
}
// GitHub 文档处理函数
const handleGithubDocsClick = (event: Event) => {
event.preventDefault()
// 打开GitHub仓库的README页面
window.open('https://github.com/jordium-gantt/jordium-gantt-vue3#readme', '_blank')
}
// Gitee 文档处理函数
const handleGiteeDocsClick = (event: Event) => {
event.preventDefault()
// 打开Gitee仓库的README页面
window.open('https://gitee.com/jordium-gantt/jordium-gantt-vue3#readme', '_blank')
}
onMounted(() => {
tasks.value = demoData.tasks as Task[]
milestones.value = demoData.milestones as Task[]
// 调试信息:打印原始任务数据
console.log('原始任务数据:', tasks.value)
console.log('里程碑数据:', milestones.value)
})
</script>
<template>
<div class="app-container">
<h1 class="page-title">
<div class="title-left">
<svg class="gantt-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="4" width="20" height="2" rx="1" fill="#409eff" />
<rect x="2" y="8" width="12" height="2" rx="1" fill="#67c23a" />
<rect x="2" y="12" width="16" height="2" rx="1" fill="#e6a23c" />
<rect x="2" y="16" width="8" height="2" rx="1" fill="#f56c6c" />
<rect x="2" y="20" width="14" height="2" rx="1" fill="#909399" />
<circle cx="22" cy="5" r="1" fill="#409eff" />
<circle cx="16" cy="9" r="1" fill="#67c23a" />
<circle cx="20" cy="13" r="1" fill="#e6a23c" />
<circle cx="12" cy="17" r="1" fill="#f56c6c" />
<circle cx="18" cy="21" r="1" fill="#909399" />
</svg>
Jordium Gantt Vue3 Demo
<span class="version-badge" style="cursor: pointer" @click="showVersionDrawer = true">{{
packageInfo.version
}}</span>
</div>
<div class="docs-links">
<a href="#github-docs" class="doc-link github-link" @click="handleGithubDocsClick">
<img class="doc-icon" src="./public/github.svg" alt="GitHub" />
</a>
<span class="docs-divider"></span>
<a href="#gitee-docs" class="doc-link gitee-link" @click="handleGiteeDocsClick">
<img class="doc-icon" src="./public/gitee.svg" alt="Gitee" />
</a>
</div>
</h1>
<VersionHistoryDrawer :visible="showVersionDrawer" @close="showVersionDrawer = false" />
<div class="gantt-wrapper">
<GanttChart
:tasks="tasks"
:milestones="milestones"
:toolbar-config="toolbarConfig"
:on-add-task="handleAddTask"
:on-add-milestone="handleAddMilestone"
:on-export-csv="handleCustomCsvExport"
:on-language-change="handleLanguageChange"
:on-theme-change="handleThemeChange"
:on-milestone-save="handleMilestoneSave"
:on-milestone-delete="handleMilestoneDelete"
:on-task-update="handleTaskUpdate"
:on-task-add="handleTaskAdd"
:on-task-delete="handleTaskDelete"
:on-milestone-icon-change="handleMilestoneIconChange"
/>
</div>
<div class="license-info">
<p>MIT License @JORDIUM.COM</p>
</div>
<!-- TaskDrawer用于新建/编辑任务 -->
<TaskDrawer
v-model:visible="showTaskDrawer"
:task="currentTask"
:is-edit="isEditMode"
@submit="handleTaskDrawerSubmit"
@close="handleTaskDrawerClose"
@delete="handleTaskDrawerDelete"
/>
<!-- MilestoneDialog用于新建/编辑里程碑 -->
<MilestoneDialog
:visible="showMilestoneDialog"
:milestone="currentMilestone"
@update:visible="showMilestoneDialog = $event"
@save="handleMilestoneSave"
@delete="handleMilestoneDelete"
@close="showMilestoneDialog = false"
/>
</div>
</template>
<style scoped>
.app-container {
width: 100%;
height: 100%;
padding: 20px;
box-sizing: border-box;
background: var(--gantt-bg-secondary, #f0f2f5);
display: flex;
flex-direction: column;
}
.page-title {
margin: 20px 0;
font-size: 1.8rem;
font-weight: 600;
color: var(--gantt-text-primary, #333);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.title-left {
display: flex;
align-items: center;
gap: 12px;
}
.gantt-icon {
width: 32px;
height: 32px;
flex-shrink: 0;
transition: all 0.3s ease;
}
.gantt-icon:hover {
transform: scale(1.05);
}
.version-badge {
display: inline-block;
background: linear-gradient(135deg, #409eff 0%, #36d1dc 50%, #667eea 100%);
color: white;
font-size: 0.7rem;
font-weight: 700;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
padding: 6px 12px;
border-radius: 16px;
line-height: 1;
margin-left: 8px;
position: relative;
overflow: hidden;
text-transform: uppercase;
letter-spacing: 0.5px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
0 0 20px rgba(64, 158, 255, 0.3),
0 4px 15px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.version-badge::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transition: left 0.6s ease;
}
.version-badge:hover {
transform: scale(1.05) translateY(-1px);
box-shadow:
0 0 30px rgba(64, 158, 255, 0.5),
0 8px 25px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
background: linear-gradient(135deg, #4dabf7 0%, #40c9ff 50%, #74c0fc 100%);
}
.version-badge:hover::before {
left: 100%;
}
/* 科技感呼吸动画 */
@keyframes glow-pulse {
0%,
100% {
box-shadow:
0 0 20px rgba(64, 158, 255, 0.3),
0 4px 15px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
50% {
box-shadow:
0 0 30px rgba(64, 158, 255, 0.5),
0 4px 15px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
}
.gantt-wrapper {
flex: 1 1 0%;
min-height: 0;
min-width: 0;
display: flex;
align-items: flex-start;
justify-content: flex-start;
margin-bottom: 24px;
}
.license-info {
text-align: center;
color: var(--gantt-text-muted, #c0c4cc);
font-size: 14px;
font-weight: 400;
letter-spacing: 0.5px;
}
/* 全局暗色主题支持 */
:global(html[data-theme='dark']) {
background: #1e1e1e !important;
}
:global(html[data-theme='dark']) body {
background: #1e1e1e !important;
color: #e5e5e5 !important;
}
/* 暗黑模式下的版本标签 */
:global(html[data-theme='dark']) .version-badge {
background: linear-gradient(135deg, #1a73e8 0%, #00bcd4 50%, #3f51b5 100%);
box-shadow:
0 0 25px rgba(102, 177, 255, 0.4),
0 4px 15px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
:global(html[data-theme='dark']) .version-badge:hover {
background: linear-gradient(135deg, #2196f3 0%, #00e5ff 50%, #5c6bc0 100%);
box-shadow:
0 0 35px rgba(102, 177, 255, 0.6),
0 8px 25px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
border-color: rgba(102, 177, 255, 0.5);
}
/* 暗黑模式的呼吸动画 */
@keyframes glow-pulse-dark {
0%,
100% {
box-shadow:
0 0 25px rgba(102, 177, 255, 0.4),
0 4px 15px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
50% {
box-shadow:
0 0 35px rgba(102, 177, 255, 0.6),
0 4px 15px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
}
.docs-links {
display: flex;
align-items: center;
gap: 8px;
}
.docs-divider {
display: inline-block;
width: 1px;
height: 24px;
border-left: 1.5px dashed #bbb;
margin: 0 8px;
background: none;
}
.doc-link {
display: flex;
align-items: center;
gap: 6px;
color: var(--gantt-text-primary, #333333);
text-decoration: none;
font-size: 1rem;
font-weight: 500;
transition: color 0.2s ease;
padding: 4px 8px;
border-radius: 4px;
}
.doc-link:hover {
color: var(--gantt-text-primary, #333333);
background-color: rgba(0, 0, 0, 0.1);
}
.doc-link:nth-child(2) {
color: #c71d23;
}
.doc-link:nth-child(2):hover {
color: #a91b1b;
background-color: rgba(199, 29, 35, 0.1);
}
.doc-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
transition: filter 0.2s ease;
}
/* GitHub 图标样式 - 黑色 */
.github-link .doc-icon {
filter: brightness(0) saturate(100%);
}
.github-link:hover .doc-icon {
filter: brightness(0) saturate(100%) invert(20%) sepia(15%) saturate(1500%) hue-rotate(200deg);
}
/* Gitee 图标样式 - 红色 */
.gitee-link .doc-icon {
filter: brightness(0) saturate(100%) invert(20%) sepia(100%) saturate(2000%) hue-rotate(350deg)
brightness(0.8);
}
.gitee-link:hover .doc-icon {
filter: brightness(0) saturate(100%) invert(15%) sepia(100%) saturate(2500%) hue-rotate(350deg)
brightness(0.7);
}
/* 移除旧的基于 SVG color 的样式,现在使用 filter */
/* 暗黑模式下覆盖所有链接样式 */
:global(html[data-theme='dark']) .doc-link {
color: #ffffff;
}
:global(html[data-theme='dark']) .doc-link:hover {
color: #ffffff;
background-color: rgba(255, 255, 255, 0.1);
}
:global(html[data-theme='dark']) .doc-link:nth-child(2) {
color: #ffffff !important;
}
:global(html[data-theme='dark']) .doc-link:nth-child(2):hover {
color: #ffffff !important;
background-color: rgba(199, 29, 35, 0.1);
}
/* 暗黑模式下图标样式 */
:global(html[data-theme='dark']) .github-link .doc-icon {
filter: brightness(0) saturate(100%) invert(100%);
}
:global(html[data-theme='dark']) .github-link:hover .doc-icon {
filter: brightness(0) saturate(100%) invert(70%) sepia(50%) saturate(2000%) hue-rotate(190deg)
brightness(1.2);
}
:global(html[data-theme='dark']) .gitee-link .doc-icon {
filter: brightness(0) saturate(100%) invert(45%) sepia(100%) saturate(1500%) hue-rotate(340deg)
brightness(1.1);
}
:global(html[data-theme='dark']) .gitee-link:hover .doc-icon {
filter: brightness(0) saturate(100%) invert(50%) sepia(100%) saturate(1800%) hue-rotate(340deg)
brightness(1.2);
}
</style>

3
demo/README.md Normal file
View File

@@ -0,0 +1,3 @@
# README.md
This file has been deprecated. Please refer to the main project `README.md` for documentation.

View File

@@ -0,0 +1,335 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const props = defineProps<{ visible: boolean }>()
const versionList = ref<any[]>([])
onMounted(async () => {
const res = await fetch('./version-history.json')
const data = await res.json()
// 按日期和版本号倒序排列
versionList.value = data.sort((a, b) => {
if (a.date === b.date) {
// 版本号倒序
return b.version.localeCompare(a.version, undefined, { numeric: true })
}
return b.date.localeCompare(a.date)
})
})
</script>
<template>
<div>
<div v-if="props.visible" class="drawer-mask" @click="$emit('close')"></div>
<div class="version-history-drawer" :class="{ open: props.visible }">
<div class="drawer-header">
<h3 class="drawer-title">版本历史</h3>
<button class="drawer-close-btn" type="button" @click="$emit('close')">
<svg class="close-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="drawer-body">
<div class="version-timeline">
<div
v-for="(item, idx) in versionList"
:key="item.version"
class="version-timeline-group"
>
<div :class="['version-timeline-dot', idx === 0 ? 'latest' : '']">
<div class="dot-label">
<span class="dot-version">{{ item.version }}</span>
<span class="dot-date">{{ item.date }}</span>
</div>
</div>
<div class="version-timeline-content version-card">
<ul class="version-notes">
<li v-for="(note, nidx) in item.notes" :key="nidx">{{ note }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.version-history-drawer {
position: fixed;
top: 0;
left: 0;
width: 540px; /* 增加Drawer宽度适中大气 */
height: 100vh;
background: var(--gantt-bg-primary, #fff);
box-shadow: 2px 0 24px rgba(0, 0, 0, 0.1);
z-index: 2000;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
/* 去除圆角 */
}
.version-history-drawer.open {
transform: translateX(0);
}
.drawer-mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5); /* 与TaskDrawer一致 */
z-index: 1999;
transition: background 0.2s;
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--gantt-border-light, #ebeef5);
background: var(--gantt-bg-secondary, #f5f7fa);
}
.drawer-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--gantt-text-primary, #303133);
}
.drawer-close-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: var(--gantt-text-muted, #909399);
transition: color 0.2s;
}
.drawer-close-btn:hover {
color: var(--gantt-text-secondary, #606266);
}
.close-icon {
width: 16px;
height: 16px;
stroke-width: 2;
}
.drawer-body {
flex: 1;
overflow-y: auto;
padding: 32px 32px 32px 60px;
background: var(--gantt-bg-primary, #fff); /* 统一用变量,便于主题切换 */
scrollbar-width: thin;
scrollbar-color: #b3c6e0 #f0f2f5;
/* 去除圆角 */
}
.drawer-body::-webkit-scrollbar {
width: 6px;
background: #f0f2f5;
}
.drawer-body::-webkit-scrollbar-thumb {
background: #b3c6e0;
border-radius: 4px;
}
.drawer-body::-webkit-scrollbar-track {
background: #f0f2f5;
}
.version-timeline {
position: relative;
margin-left: 140px;
border-left: 0;
}
.version-timeline::before {
content: '';
position: absolute;
left: -8px;
top: 0;
width: 2px;
height: 100%;
background: repeating-linear-gradient(
to bottom,
var(--gantt-timeline-line, #a0cfff) 0 8px,
transparent 8px 16px
);
border-radius: 1px;
z-index: 0;
}
.version-timeline-group {
position: relative;
margin-bottom: 40px;
padding-left: 18px;
min-height: 60px;
transition: box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.version-timeline-dot {
position: absolute;
left: -13px;
top: 18px;
width: 10px;
height: 10px;
background: var(--gantt-timeline-dot, #a0cfff); /* 更柔和主色 */
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 2px var(--gantt-timeline-line, #b3d8ff);
z-index: 3;
display: flex;
align-items: flex-start;
justify-content: flex-end;
transition:
background 0.2s,
box-shadow 0.2s;
}
.version-timeline-group:hover .version-timeline-dot {
background: var(--gantt-timeline-dot-hover, #409eff); /* hover主色 */
}
.version-timeline-dot.latest {
background: var(--gantt-primary, #409eff);
}
.dot-label {
position: absolute;
left: -140px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
align-items: flex-end;
min-width: 90px;
max-width: 110px;
font-size: 13px;
pointer-events: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: left 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.version-timeline-group:hover .dot-label {
left: -110px; /* 悬停时向右移动,接近原点 */
}
.version-timeline-content.version-card {
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.08); /* 主色阴影 */
padding: 18px 18px 12px 18px;
margin-left: 0;
margin-top: 0;
margin-bottom: 0;
min-width: 0;
border: none;
transition: box-shadow 0.2s;
position: relative;
z-index: 2;
}
.version-timeline-group:hover .version-timeline-content.version-card {
box-shadow: 0 8px 24px rgba(64, 158, 255, 0.16);
}
.version-timeline-content.version-card::before {
content: '';
position: absolute;
left: -16px; /* 让箭头更靠近原点但不覆盖 */
top: 18px;
width: 0;
height: 0;
/*border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-right: 16px solid #fff;*/
filter: drop-shadow(-2px 0 2px var(--gantt-timeline-line, #a0cfff));
z-index: 1;
}
.version-timeline-group:hover .version-timeline-content.version-card::before {
filter: drop-shadow(-2px 0 2px var(--gantt-primary, #409eff));
}
.version-notes {
margin: 0;
padding-left: 18px;
color: #aaa;
font-size: 14px;
list-style: disc;
transition: color 0.2s;
}
.version-timeline-group:hover .version-notes {
color: #333;
}
.dot-version {
font-weight: 700;
font-size: 16px;
color: var(--gantt-primary, #409eff); /* 统一主色 */
letter-spacing: 0.5px;
line-height: 1.2;
text-shadow: 0 1px 4px rgba(64, 158, 255, 0.08);
}
.dot-date {
color: #b0b3bb;
font-size: 12px;
margin-top: 2px;
font-weight: 400;
letter-spacing: 0.2px;
line-height: 1.1;
}
/* 暗黑主题适配完全对齐TaskDrawer风格 */
:global(html[data-theme='dark']) .version-history-drawer {
background: var(--gantt-bg-primary, #6b6b6b) !important;
box-shadow: 2px 0 24px rgba(0, 0, 0, 0.4) !important;
}
:global(html[data-theme='dark']) .drawer-header {
background: var(--gantt-bg-secondary, #4b4b4b) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !important;
}
:global(html[data-theme='dark']) .drawer-title {
color: var(--gantt-text-white, #fff) !important;
}
:global(html[data-theme='dark']) .drawer-close-btn:hover {
background: var(--gantt-bg-hover, rgba(255, 255, 255, 0.1)) !important;
border-radius: 4px;
}
:global(html[data-theme='dark']) .drawer-body {
background: var(--gantt-bg-primary, #6b6b6b) !important;
scrollbar-color: #888888 #4b4b4b !important;
}
:global(html[data-theme='dark']) .drawer-body::-webkit-scrollbar-thumb {
background: #888888 !important;
}
:global(html[data-theme='dark']) .drawer-body::-webkit-scrollbar-track {
background: #4b4b4b !important;
}
:global(html[data-theme='dark']) .version-timeline-content.version-card {
background: var(--gantt-bg-secondary, #4b4b4b) !important;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.32) !important;
color: var(--gantt-text-white, #fff) !important;
}
:global(html[data-theme='dark'])
.version-timeline-group:hover
.version-timeline-content.version-card {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.44) !important;
}
:global(html[data-theme='dark']) .version-timeline-content.version-card::before {
filter: drop-shadow(-2px 0 2px #222) !important;
}
:global(html[data-theme='dark']) .dot-version {
color: var(--gantt-primary, #3399ff) !important;
text-shadow: 0 1px 4px rgba(51, 153, 255, 0.12) !important;
}
:global(html[data-theme='dark']) .dot-date {
color: #e0e0e0 !important;
}
:global(html[data-theme='dark']) .version-notes {
color: #e0e0e0 !important;
}
:global(html[data-theme='dark']) .version-timeline-dot {
background: var(--gantt-timeline-dot, #3399ff) !important;
box-shadow: 0 0 0 2px #222 !important;
}
:global(html[data-theme='dark']) .version-timeline-dot.latest {
background: var(--gantt-primary, #3399ff) !important;
}
:global(html[data-theme='dark']) .version-timeline-group:hover .version-timeline-dot {
background: var(--gantt-primary, #3399ff) !important;
}
:global(html[data-theme='dark']) .version-timeline::before {
background: repeating-linear-gradient(to bottom, #3399ff 0 8px, transparent 8px 16px) !important;
}
</style>

187
demo/data.json Normal file
View File

@@ -0,0 +1,187 @@
{
"tasks": [
{
"id": 1,
"name": "项目启动",
"assignee": "张三",
"startDate": "2025-06-15",
"endDate": "2025-06-25",
"progress": 100,
"estimatedHours": 40,
"actualHours": 38,
"type": "story",
"children": [
{
"id": 2,
"name": "需求分析",
"assignee": "李四",
"startDate": "2025-04-16",
"endDate": "2025-06-20",
"progress": 100,
"estimatedHours": 16,
"actualHours": 15,
"type": "task"
},
{
"id": 3,
"name": "技术选型",
"assignee": "王五",
"startDate": "2025-06-21",
"endDate": "2025-06-28",
"progress": 80,
"estimatedHours": 24,
"actualHours": 28,
"type": "story",
"children": [
{
"id": 4,
"name": "调研A",
"predecessor": "2",
"assignee": "赵六",
"startDate": "2025-06-21",
"endDate": "2025-06-23",
"progress": 80,
"estimatedHours": 8,
"actualHours": 10,
"type": "task"
},
{
"id": 5,
"name": "调研B",
"predecessor": "4",
"assignee": "钱七",
"startDate": "2025-06-24",
"endDate": "2025-06-28",
"progress": 0,
"estimatedHours": 16,
"actualHours": 0,
"type": "task"
}
],
"collapsed": false
}
],
"collapsed": false
},
{
"id": 7,
"name": "开发阶段",
"assignee": "开发团队",
"startDate": "2025-06-29",
"endDate": "2025-07-15",
"progress": 30,
"estimatedHours": 120,
"actualHours": 45,
"type": "story",
"children": [
{
"id": 8,
"name": "前端开发",
"assignee": "李四",
"startDate": "2025-06-29",
"endDate": "2025-07-10",
"progress": 40,
"estimatedHours": 80,
"actualHours": 35,
"type": "task",
"predecessor": "5"
},
{
"id": 11,
"name": "后端开发",
"assignee": "李四",
"startDate": "2025-07-07",
"endDate": "2025-07-15",
"progress": 0,
"estimatedHours": 80,
"actualHours": 0,
"type": "task",
"predecessor": "5"
},
{
"id": 12,
"name": "Job开发",
"assignee": "李四",
"startDate": "2025-07-07",
"endDate": "2025-07-18",
"progress": 0,
"estimatedHours": 88,
"actualHours": 0,
"type": "task",
"predecessor": "5"
}
],
"collapsed": false
},
{
"id": 13,
"name": "测试阶段",
"assignee": "测试团队",
"startDate": "2025-07-21",
"endDate": "2025-07-31",
"progress": 0,
"estimatedHours": 80,
"actualHours": 0,
"type": "story",
"children": [
{
"id": 14,
"name": "SIT",
"assignee": "李四",
"startDate": "2025-07-21",
"endDate": "2025-07-28",
"progress": 0,
"estimatedHours": 48,
"actualHours": 0,
"type": "task",
"predecessor": "7"
},
{
"id": 15,
"name": "UAT",
"assignee": "李四",
"startDate": "2025-07-29",
"endDate": "2025-07-31",
"progress": 0,
"estimatedHours": 24,
"actualHours": 0,
"type": "task",
"predecessor": "7"
}
],
"collapsed": false
}
],
"milestones": [
{
"id": 6,
"name": "技术选型完成",
"assignee": "王五",
"startDate": "2025-06-25",
"endDate": "2025-06-25",
"type": "milestone",
"icon": "diamond",
"description": "完成技术选型调研,确定项目技术栈和架构方案。"
},
{
"id": 10,
"name": "开发完成",
"assignee": "开发团队",
"startDate": "2025-07-11",
"endDate": "2025-07-11",
"type": "milestone",
"icon": "rocket",
"description": "完成所有开发任务,准备进入测试阶段。"
},
{
"id": 9,
"name": "Alpha版本发布",
"assignee": "项目经理",
"startDate": "2025-07-15",
"endDate": "2025-07-15",
"type": "milestone",
"icon": "rocket",
"description": "发布Alpha测试版本提供给内部团队进行功能验证和测试。"
}
]
}

13
demo/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/public/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jordium Gantt Vue3 Demo</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

5
demo/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

12
demo/public/favicon.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="4" width="20" height="2" rx="1" fill="#409eff" />
<rect x="2" y="8" width="12" height="2" rx="1" fill="#67c23a" />
<rect x="2" y="12" width="16" height="2" rx="1" fill="#e6a23c" />
<rect x="2" y="16" width="8" height="2" rx="1" fill="#f56c6c" />
<rect x="2" y="20" width="14" height="2" rx="1" fill="#909399" />
<circle cx="22" cy="5" r="1" fill="#409eff" />
<circle cx="16" cy="9" r="1" fill="#67c23a" />
<circle cx="20" cy="13" r="1" fill="#e6a23c" />
<circle cx="12" cy="17" r="1" fill="#f56c6c" />
<circle cx="18" cy="21" r="1" fill="#909399" />
</svg>

After

Width:  |  Height:  |  Size: 665 B

1
demo/public/gitee.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1750864628544" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5058" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 1024C229.222 1024 0 794.778 0 512S229.222 0 512 0s512 229.222 512 512-229.222 512-512 512z m259.149-568.883h-290.74a25.293 25.293 0 0 0-25.292 25.293l-0.026 63.206c0 13.952 11.315 25.293 25.267 25.293h177.024c13.978 0 25.293 11.315 25.293 25.267v12.646a75.853 75.853 0 0 1-75.853 75.853h-240.23a25.293 25.293 0 0 1-25.267-25.293V417.203a75.853 75.853 0 0 1 75.827-75.853h353.946a25.293 25.293 0 0 0 25.267-25.292l0.077-63.207a25.293 25.293 0 0 0-25.268-25.293H417.152a189.62 189.62 0 0 0-189.62 189.645V771.15c0 13.977 11.316 25.293 25.294 25.293h372.94a170.65 170.65 0 0 0 170.65-170.65V480.384a25.293 25.293 0 0 0-25.293-25.267z" fill="#C71D23" p-id="5059"></path></svg>

After

Width:  |  Height:  |  Size: 1010 B

1
demo/public/github.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1750864675427" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6018" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 42.666667A464.64 464.64 0 0 0 42.666667 502.186667 460.373333 460.373333 0 0 0 363.52 938.666667c23.466667 4.266667 32-9.813333 32-22.186667v-78.08c-130.56 27.733333-158.293333-61.44-158.293333-61.44a122.026667 122.026667 0 0 0-52.053334-67.413333c-42.666667-28.16 3.413333-27.733333 3.413334-27.733334a98.56 98.56 0 0 1 71.68 47.36 101.12 101.12 0 0 0 136.533333 37.973334 99.413333 99.413333 0 0 1 29.866667-61.44c-104.106667-11.52-213.333333-50.773333-213.333334-226.986667a177.066667 177.066667 0 0 1 47.36-124.16 161.28 161.28 0 0 1 4.693334-121.173333s39.68-12.373333 128 46.933333a455.68 455.68 0 0 1 234.666666 0c89.6-59.306667 128-46.933333 128-46.933333a161.28 161.28 0 0 1 4.693334 121.173333A177.066667 177.066667 0 0 1 810.666667 477.866667c0 176.64-110.08 215.466667-213.333334 226.986666a106.666667 106.666667 0 0 1 32 85.333334v125.866666c0 14.933333 8.533333 26.88 32 22.186667A460.8 460.8 0 0 0 981.333333 502.186667 464.64 464.64 0 0 0 512 42.666667" fill="#231F20" p-id="6019"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
demo/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

113
demo/style.css Normal file
View File

@@ -0,0 +1,113 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
min-width: 320px;
height: 100vh;
padding: 30px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
width: 100%;
height: 100%;
max-width: 100vw;
max-height: 100vh;
min-width: 0;
min-height: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
padding: 0;
text-align: left;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
/* 黑暗模式全局样式 */
html[data-theme='dark'] {
background: #1e1e1e !important;
color-scheme: dark !important;
}
html[data-theme='dark'] body {
background: #1e1e1e !important;
color: #e5e5e5 !important;
}
/* 明亮模式全局样式 */
html[data-theme='light'] {
background: #ffffff !important;
color-scheme: light !important;
}
html[data-theme='light'] body {
background: #ffffff !important;
color: #333333 !important;
}

72
demo/version-history.json Normal file
View File

@@ -0,0 +1,72 @@
[
{
"version": "alpha 0.8.1",
"date": "2025-06-25",
"notes": ["升级优化DatePicker组件"]
},
{
"version": "alpha 0.8.2",
"date": "2025-06-25",
"notes": ["升级Demo", "移除导出按钮的初始化光晕效果"]
},
{
"version": "alpha 0.8.3",
"date": "2025-06-25",
"notes": ["升级里程碑Taskbar允许拖拽"]
},
{
"version": "alpha 0.8.4",
"date": "2025-06-25",
"notes": ["升级需求/任务TaskBar新建以及上下级关系变更", "添加Github和Gitee的文档入口"]
},
{
"version": "alpha 0.8.5",
"date": "2025-06-26",
"notes": ["升级Timeline今日定位和滑动问题"]
},
{
"version": "alpha 0.9.0",
"date": "2025-06-26",
"notes": ["添加新增里程碑和删除里程碑功能"]
},
{
"version": "alpha 0.9.1",
"date": "2025-06-27",
"notes": ["优化时间轴的延伸"]
},
{
"version": "alpha 0.9.2",
"date": "2025-06-27",
"notes": ["增加历史版本查看"]
},
{
"version": "alpha 0.9.3",
"date": "2025-06-27",
"notes": ["修复今日定位问题"]
},
{
"version": "alpha 0.9.4",
"date": "2025-06-27",
"notes": ["调整暗黑主题的亮度", "调整暗黑主题的布局", "修复暗黑主题下的版本历史的样式"]
},
{
"version": "alpha 0.9.5",
"date": "2025-06-27",
"notes": ["统一管理各个组件的全球化配置"]
},
{
"version": "alpha 0.9.6",
"date": "2025-06-27",
"notes": ["各个组件分离Task/Milestone/Language对象"]
},
{
"version": "alpha 0.9.7",
"date": "2025-06-28",
"notes": ["解决Milestone组件和类型的同名问题"]
},
{
"version": "alpha 0.9.8",
"date": "2025-06-28",
"notes": ["按钮样式统一管理"]
}
]

203
design/ui-preview.html Normal file
View File

@@ -0,0 +1,203 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>火箭图标最终版本</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 40px;
min-height: 100vh;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 20px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center;
color: #333;
margin-bottom: 50px;
font-size: 2.5rem;
}
.rocket-showcase {
text-align: center;
margin-bottom: 40px;
}
.rocket-card {
background: linear-gradient(135deg, #ffe8e8 0%, #fff5f5 100%);
border-radius: 20px;
padding: 40px;
border: 3px solid #ff6b6b;
box-shadow: 0 15px 35px rgba(255, 107, 107, 0.2);
margin: 0 auto;
max-width: 400px;
}
.rocket-title {
font-size: 1.5rem;
font-weight: 600;
color: #333;
margin-bottom: 15px;
}
.rocket-description {
color: #666;
margin-bottom: 30px;
font-size: 1rem;
line-height: 1.5;
}
.rocket-display {
background: white;
border-radius: 50%;
width: 120px;
height: 120px;
margin: 0 auto 30px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
font-size: 60px;
line-height: 1;
transform: rotate(-45deg);
transition: transform 0.3s ease;
}
.rocket-display:hover {
transform: rotate(-45deg) scale(1.1);
}
.rocket-display svg {
width: 80px;
height: 80px;
}
.feature-badge {
background: #4caf50;
color: white;
padding: 8px 20px;
border-radius: 25px;
font-size: 0.9rem;
font-weight: 500;
display: inline-block;
margin-top: 20px;
}
.features-list {
background: #f8f9fa;
border-radius: 15px;
padding: 30px;
margin-top: 30px;
}
.features-list h3 {
margin-top: 0;
color: #333;
font-size: 1.3rem;
}
.features-list ul {
list-style: none;
padding: 0;
margin: 0;
}
.features-list li {
padding: 8px 0;
color: #555;
font-size: 0.95rem;
}
.features-list li:before {
content: '✅ ';
margin-right: 10px;
}
.timeline-preview {
background: #f8f9fa;
border-radius: 10px;
padding: 20px;
margin-top: 20px;
border: 2px dashed #dee2e6;
}
.timeline-preview h4 {
margin-top: 0;
color: #495057;
}
.milestone-demo {
display: flex;
align-items: center;
gap: 15px;
padding: 10px;
background: white;
border-radius: 8px;
margin-top: 10px;
}
.milestone-demo svg {
width: 24px;
height: 24px;
}
.milestone-rocket {
font-size: 24px;
transform: rotate(-45deg);
display: inline-block;
}
</style>
</head>
<body>
<div class="container">
<h1>🚀 最终火箭图标方案</h1>
<div class="rocket-showcase">
<div class="rocket-card">
<div class="rocket-title">方案26🚀 垂直Emoji火箭最终版</div>
<div class="rocket-description">
使用原生🚀 emoji并通过CSS旋转至完全垂直朝上简洁清晰兼容性强无需额外SVG代码
</div>
<div class="rocket-display">🚀</div>
<div class="feature-badge">✨ 最终选择</div>
</div>
</div>
<div class="features-list">
<h3>🎯 设计特点</h3>
<ul>
<li>使用原生🚀 emoji无需额外SVG代码</li>
<li>通过CSS rotate(-45deg)旋转至完全垂直朝上</li>
<li>符合火箭发射方向,视觉效果更佳</li>
<li>跨平台兼容性强,所有设备都能完美显示</li>
<li>简洁清晰,视觉识别度高</li>
<li>加载速度快,性能优秀</li>
<li>颜色鲜艳,视觉效果突出</li>
<li>标准化图标,用户熟悉度高</li>
<li>可缩放性好,适应各种尺寸</li>
<li>无需维护复杂的SVG代码</li>
</ul>
</div>
<div class="timeline-preview">
<h4>📍 Timeline 中的效果预览</h4>
<div class="milestone-demo">
<span>里程碑示例:</span>
<span class="milestone-rocket">🚀</span>
<span>这就是里程碑在甘特图时间轴中的显示效果</span>
</div>
</div>
</div>
</body>
</html>

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jordium Gantt Vue3</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.ts"></script>
</body>
</html>

3389
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "jordium-gantt-vue3",
"private": true,
"version": "0.9.8-alpha",
"type": "module",
"scripts": {
"dev": "vite",
"dev:demo": "vite",
"build": "vue-tsc -b && vite build",
"build:demo": "vue-tsc -b && vite build",
"build:lib": "vite build --config vite.config.lib.ts",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"format": "prettier --write .",
"format:check": "prettier --check .",
"type-check": "vue-tsc --noEmit -p tsconfig.json --composite false"
},
"dependencies": {
"date-fns": "^4.1.0",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.1",
"vue": "^3.5.13"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.25.0",
"prettier": "^3.2.5",
"typescript": "~5.8.3",
"vite": "^6.3.5",
"vue-tsc": "^2.2.8"
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue'
import '../styles/app.css'
const props = defineProps({
visible: Boolean,
title: { type: String, default: '确认' },
message: { type: String, default: '' },
confirmText: { type: String, default: '确认' },
cancelText: { type: String, default: '取消' },
})
const emit = defineEmits(['confirm', 'cancel'])
const onConfirm = () => emit('confirm')
const onCancel = () => emit('cancel')
</script>
<template>
<div v-if="visible" class="gantt-confirm-overlay" @click="onCancel">
<div class="gantt-confirm-dialog" @click.stop>
<div class="gantt-confirm-header">
<h4 class="gantt-confirm-title">{{ props.title }}</h4>
</div>
<div class="gantt-confirm-content">
<p>{{ props.message }}</p>
</div>
<div class="gantt-confirm-footer">
<button type="button" class="btn btn-default" @click="onCancel">
{{ props.cancelText }}
</button>
<button type="button" class="btn btn-danger" @click="onConfirm">
{{ props.confirmText }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.gantt-confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.25);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.gantt-confirm-dialog {
background: var(--gantt-bg-primary, #fff);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18);
min-width: 320px;
max-width: 90vw;
padding: 24px 28px 18px 28px;
display: flex;
flex-direction: column;
}
.gantt-confirm-header {
margin-bottom: 12px;
}
.gantt-confirm-title {
font-size: 18px;
font-weight: 600;
color: var(--gantt-text-primary, #303133);
margin: 0;
}
.gantt-confirm-content {
font-size: 15px;
color: var(--gantt-text-secondary, #606266);
margin-bottom: 18px;
}
.gantt-confirm-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
:global(html[data-theme='dark']) .gantt-confirm-dialog {
background: var(--gantt-bg-secondary, #f8f9fa) !important;
border-color: var(--gantt-border-dark, #999999) !important;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,773 @@
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue'
import { useI18n } from '../composables/useI18n'
import DatePicker from './DatePicker.vue'
import GanttConfirmDialog from './GanttConfirmDialog.vue'
import type { Milestone } from '../models/classes/Milestone'
import '../styles/app.css'
interface Props {
visible: boolean
milestone?: Milestone | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:visible': [visible: boolean]
close: []
save: [milestone: Milestone]
delete: [milestoneId: number]
}>()
// 表单数据
const formData = reactive<Milestone>({
name: '',
startDate: '',
assignee: '',
type: 'milestone',
icon: 'diamond',
description: '',
})
// 表单验证错误
const errors = reactive({
name: '',
startDate: '',
})
// 下拉菜单状态
const dropdownOpen = ref(false)
// 删除确认状态
const showDeleteConfirm = ref(false)
// 描述文本框引用
const descriptionTextarea = ref<HTMLTextAreaElement | null>(null)
// 自动调整文本框高度
const adjustTextareaHeight = () => {
const textarea = descriptionTextarea.value
if (textarea) {
textarea.style.height = 'auto'
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`
}
}
// 监听里程碑变化,初始化表单数据
watch(
() => props.milestone,
newMilestone => {
if (newMilestone) {
Object.assign(formData, {
id: newMilestone.id,
name: newMilestone.name || '',
startDate: newMilestone.startDate || '',
assignee: newMilestone.assignee || '',
type: newMilestone.type || 'milestone',
icon: newMilestone.icon || 'diamond',
description: newMilestone.description || '',
})
} else {
// 新建里程碑时重置表单
Object.assign(formData, {
id: undefined,
name: '',
startDate: '',
assignee: '',
type: 'milestone',
icon: 'diamond',
description: '',
})
}
// 清空错误
errors.name = ''
errors.startDate = ''
},
{ immediate: true },
)
// 表单验证
const validateForm = () => {
errors.name = ''
errors.startDate = ''
let isValid = true
if (!formData.name.trim()) {
errors.name = t('milestoneNameRequired')
isValid = false
}
if (!formData.startDate) {
errors.startDate = t('milestoneDateRequired')
isValid = false
}
return isValid
}
// 表单是否有效
const isFormValid = computed(() => {
return formData.name.trim() && formData.startDate
})
// 选择图标
const selectIcon = (icon: string) => {
formData.icon = icon
dropdownOpen.value = false
}
// 保存处理
const handleSave = () => {
if (validateForm()) {
emit('save', { ...formData })
closeDialog()
}
}
// 删除处理
const handleDelete = () => {
if (formData.id) {
showDeleteConfirm.value = true
}
}
// 确认删除
const confirmDelete = () => {
if (formData.id) {
emit('delete', formData.id)
showDeleteConfirm.value = false
closeDialog()
}
}
// 取消删除
const cancelDelete = () => {
showDeleteConfirm.value = false
}
// 是否为编辑模式
const isEditMode = computed(() => {
return props.milestone && props.milestone.id
})
// 关闭对话框
const closeDialog = () => {
dropdownOpen.value = false
emit('update:visible', false)
emit('close')
}
// 点击遮罩层关闭
const handleOverlayClick = () => {
closeDialog()
}
// 多语言
const { t: globalT } = useI18n()
// 直接用全局 t 获取翻译
const t = (key: string) => {
const globalValue = (globalT.value as any)[key]
return globalValue || key
}
</script>
<template>
<div v-if="visible" class="milestone-dialog-overlay" @click="handleOverlayClick">
<div class="milestone-dialog" @click.stop>
<div class="milestone-dialog-header">
<h3 class="milestone-dialog-title">
<svg
class="milestone-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<g transform="rotate(45 12 12)">
<rect
x="4"
y="4"
width="16"
height="16"
rx="4"
ry="4"
fill="currentColor"
opacity="0.1"
/>
<rect
x="4"
y="4"
width="16"
height="16"
rx="4"
ry="4"
stroke="currentColor"
fill="none"
/>
</g>
</svg>
{{ isEditMode ? globalT.editMilestone : globalT.newMilestone }}
</h3>
<button class="milestone-dialog-close" :title="t('close')" @click="closeDialog">×</button>
</div>
<div class="milestone-dialog-content">
<form class="milestone-form" @submit.prevent="handleSave">
<!-- 第一行里程碑名称 -->
<div class="milestone-form-row">
<div class="milestone-form-item milestone-form-item-full">
<label class="milestone-form-label required" for="milestone-name">{{
t('milestoneName')
}}</label>
<input
id="milestone-name"
v-model="formData.name"
type="text"
class="milestone-form-input"
:class="{ error: errors.name }"
:placeholder="t('enterMilestoneName')"
required
/>
<span v-if="errors.name" class="milestone-form-error">{{ errors.name }}</span>
</div>
</div>
<!-- 第二行里程碑日期和图标 -->
<div class="milestone-form-row">
<div class="milestone-form-item">
<label class="milestone-form-label required" for="milestone-date">{{
t('milestoneDate')
}}</label>
<DatePicker
id="milestone-date"
v-model="formData.startDate"
type="date"
placeholder="请选择里程碑日期"
:class="{ error: errors.startDate }"
/>
<span v-if="errors.startDate" class="milestone-form-error">{{
errors.startDate
}}</span>
</div>
<div class="milestone-form-item">
<label class="milestone-form-label" for="milestone-icon">{{
t('milestoneIcon')
}}</label>
<div class="milestone-icon-dropdown" :class="{ active: dropdownOpen }">
<button
id="milestone-icon"
type="button"
class="milestone-icon-trigger"
:aria-expanded="dropdownOpen"
aria-haspopup="listbox"
@click="dropdownOpen = !dropdownOpen"
>
<div class="selected-icon">
<svg
v-if="formData.icon === 'diamond'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<g transform="rotate(45 12 12)">
<rect
x="6"
y="6"
width="12"
height="12"
rx="3"
ry="3"
fill="currentColor"
/>
</g>
</svg>
<div v-else-if="formData.icon === 'rocket'" class="rocket-emoji-mini">🚀</div>
<span>{{ t(formData.icon || 'diamond') }}</span>
</div>
<svg
class="dropdown-arrow"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6,9 12,15 18,9"></polyline>
</svg>
</button>
<div v-if="dropdownOpen" class="milestone-icon-options">
<div
class="icon-option"
:class="{ selected: formData.icon === 'diamond' }"
@click="selectIcon('diamond')"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<g transform="rotate(45 12 12)">
<rect
x="6"
y="6"
width="12"
height="12"
rx="3"
ry="3"
fill="currentColor"
/>
</g>
</svg>
<span>{{ t('diamond') }}</span>
</div>
<div
class="icon-option"
:class="{ selected: formData.icon === 'rocket' }"
@click="selectIcon('rocket')"
>
<div class="rocket-emoji-option">🚀</div>
<span>{{ t('rocket') }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 第三行描述 -->
<div class="milestone-form-row">
<div class="milestone-form-item milestone-form-item-full">
<label class="milestone-form-label" for="milestone-description">{{
t('description')
}}</label>
<div class="textarea-wrapper">
<textarea
id="milestone-description"
ref="descriptionTextarea"
v-model="formData.description"
class="milestone-form-textarea"
:placeholder="t('enterDescription')"
rows="4"
maxlength="500"
@input="adjustTextareaHeight"
></textarea>
<div class="textarea-footer">
<span class="char-count">{{ formData.description?.length || 0 }}/500</span>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="milestone-dialog-footer">
<div class="milestone-dialog-footer-left">
<button v-if="isEditMode" type="button" class="btn btn-danger" @click="handleDelete">
{{ globalT.delete }}
</button>
</div>
<div class="milestone-dialog-footer-right">
<button type="button" class="btn btn-default" @click="closeDialog">
{{ globalT.cancel }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!isFormValid"
@click="handleSave"
>
{{ globalT.confirm }}
</button>
</div>
</div>
</div>
<GanttConfirmDialog
:visible="showDeleteConfirm"
:title="globalT.delete"
:message="globalT.confirmDelete"
:confirm-text="globalT.confirm"
:cancel-text="globalT.cancel"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</div>
</template>
<style scoped>
@import '../styles/theme-variables.css';
.milestone-dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000; /* 确保在全屏模式下也能正常显示 */
}
.milestone-dialog {
background: var(--gantt-bg-primary, #ffffff);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow: hidden;
border: 1px solid var(--gantt-border-color, #dcdfe6);
}
.milestone-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--gantt-border-color, #dcdfe6);
background: var(--gantt-bg-secondary, #f8f9fa);
}
.milestone-dialog-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--gantt-text-primary, #303133);
}
.milestone-icon {
width: 20px;
height: 20px;
color: var(--gantt-danger, #f56c6c);
filter: drop-shadow(0 0 4px var(--gantt-danger, #f56c6c));
}
.milestone-dialog-close {
width: 32px;
height: 32px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: var(--gantt-text-secondary, #909399);
transition: all 0.2s ease;
font-size: 24px;
font-weight: bold;
line-height: 1;
}
.milestone-dialog-content {
padding: 24px;
max-height: 60vh;
overflow-y: auto;
}
/* 表单样式 */
.milestone-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.milestone-form-row {
display: flex;
gap: 16px;
align-items: flex-start;
}
.milestone-form-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.milestone-form-item-full {
flex: 1 1 100%;
}
.milestone-form-label {
font-size: 14px;
font-weight: 500;
color: var(--gantt-text-secondary, #606266);
line-height: 1.4;
margin: 0;
}
.milestone-form-label.required::after {
content: '*';
color: var(--gantt-danger, #f56c6c);
margin-left: 4px;
}
.milestone-form-input {
padding: 12px 16px;
border: 1px solid var(--gantt-border-color, #dcdfe6);
border-radius: 4px;
font-size: 14px;
color: var(--gantt-text-primary, #303133);
background: var(--gantt-bg-primary, #ffffff);
transition: all 0.2s ease;
box-sizing: border-box;
height: 44px;
}
.milestone-form-input:focus {
outline: none;
border-color: var(--gantt-primary, #409eff);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
.milestone-form-input.error {
border-color: var(--gantt-danger, #f56c6c);
box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.1);
}
.milestone-form-input::placeholder {
color: var(--gantt-text-placeholder, #c0c4cc);
}
.milestone-form-textarea {
width: 100%;
box-sizing: border-box;
padding: 12px 16px;
border: 1px solid var(--gantt-border-color, #dcdfe6);
border-radius: 4px;
font-size: 14px;
color: var(--gantt-text-primary, #303133);
background: var(--gantt-bg-primary, #ffffff);
transition: all 0.2s ease;
resize: none;
min-height: 80px;
max-height: 120px;
font-family: inherit;
line-height: 1.5;
overflow-y: auto;
}
.milestone-form-textarea:focus {
outline: none;
border-color: var(--gantt-primary, #409eff);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
.milestone-form-textarea::placeholder {
color: var(--gantt-text-placeholder, #c0c4cc);
}
.textarea-wrapper {
position: relative;
}
.textarea-footer {
display: flex;
justify-content: flex-end;
margin-top: 4px;
}
.char-count {
font-size: 12px;
color: var(--gantt-text-secondary, #909399);
}
.milestone-form-error {
font-size: 12px;
color: var(--gantt-danger, #f56c6c);
margin-top: 4px;
}
/* 图标下拉菜单样式 */
.milestone-icon-dropdown {
position: relative;
}
.milestone-icon-trigger {
width: 100%;
height: 44px; /* 固定高度 */
padding: 12px 16px;
border: 1px solid var(--gantt-border-color, #dcdfe6);
border-radius: 4px;
background: var(--gantt-bg-primary, #ffffff);
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.2s ease;
box-sizing: border-box;
}
.milestone-icon-trigger:hover {
border-color: var(--gantt-primary, #409eff);
}
.milestone-icon-dropdown.active .milestone-icon-trigger {
border-color: var(--gantt-primary, #409eff);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
.selected-icon {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--gantt-text-primary, #303133);
}
.selected-icon svg {
width: 16px;
height: 16px;
color: var(--gantt-danger, #f56c6c);
}
.rocket-emoji-mini {
font-size: 16px;
transform: rotate(-45deg);
display: inline-block;
}
.dropdown-arrow {
width: 16px;
height: 16px;
color: var(--gantt-text-secondary, #909399);
transition: transform 0.2s ease;
}
.milestone-icon-dropdown.active .dropdown-arrow {
transform: rotate(180deg);
}
.milestone-icon-options {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--gantt-bg-primary, #ffffff);
border: 1px solid var(--gantt-border-color, #dcdfe6);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
margin-top: 4px;
}
.icon-option {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid var(--gantt-border-light, #e4e7ed);
}
.icon-option:last-child {
border-bottom: none;
}
.icon-option:hover {
background: var(--gantt-bg-light, #f5f7fa);
}
.icon-option.selected {
background: var(--gantt-primary-lightest, #ecf5ff);
color: var(--gantt-primary, #409eff);
}
.icon-option svg {
width: 16px;
height: 16px;
color: var(--gantt-danger, #f56c6c);
}
.rocket-emoji-option {
font-size: 16px;
transform: rotate(-45deg);
display: inline-block;
}
.icon-option span {
font-size: 14px;
}
/* 对话框底部 */
.milestone-dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid var(--gantt-border-color, #dcdfe6);
background: var(--gantt-bg-secondary, #f8f9fa);
}
.milestone-dialog-footer-left {
display: flex;
align-items: center;
}
.milestone-dialog-footer-right {
display: flex;
align-items: center;
gap: 12px;
}
/* 删除确认弹窗样式 */
.milestone-confirm-dialog {
background: var(--gantt-bg-primary, #ffffff);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 400px;
border: 1px solid var(--gantt-border-color, #dcdfe6);
}
.milestone-confirm-header {
padding: 20px 24px 0;
}
.milestone-confirm-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--gantt-text-primary, #303133);
}
.milestone-confirm-content {
padding: 16px 24px;
}
.milestone-confirm-content p {
margin: 0;
font-size: 14px;
color: var(--gantt-text-secondary, #606266);
line-height: 1.5;
}
.milestone-confirm-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--gantt-border-color, #dcdfe6);
background: var(--gantt-bg-secondary, #f8f9fa);
}
/* 暗黑模式下的确认弹窗 */
:global(html[data-theme='dark']) .milestone-confirm-dialog {
background: var(--gantt-bg-dark, #1d1e1f);
border-color: var(--gantt-border-dark, #3c3e40);
}
:global(html[data-theme='dark']) .milestone-confirm-footer {
background: var(--gantt-bg-darker, #141414);
border-color: var(--gantt-border-dark, #3c3e40);
}
</style>

View File

@@ -0,0 +1,343 @@
<script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue'
import type { Milestone } from '../models/classes/Milestone'
interface Props {
date: string // 里程碑日期
rowHeight: number
dayWidth: number
startDate: Date
name?: string
milestone?: Milestone // 完整的里程碑数据
}
const props = defineProps<Props>()
// 添加事件定义
const emit = defineEmits<{
'milestone-double-click': [milestone: Milestone]
'update:milestone': [milestone: Milestone] // 新增里程碑更新事件
}>()
// 拖拽相关状态
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartLeft = ref(0)
const tempMilestoneData = ref<{ startDate?: string } | null>(null)
// 双击事件处理
const handleDoubleClick = () => {
if (props.milestone) {
emit('milestone-double-click', props.milestone)
} else {
// 如果没有完整数据,构造基本的里程碑对象
const basicMilestone: Milestone = {
name: props.name || '里程碑',
startDate: props.date,
type: 'milestone',
}
emit('milestone-double-click', basicMilestone)
}
}
// 日期工具函数
const addDaysToLocalDate = (date: Date, days: number): Date => {
const result = new Date(date)
result.setDate(result.getDate() + days)
return result
}
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')
return `${year}-${month}-${day}`
}
// 拖拽事件处理
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
isDragging.value = true
dragStartX.value = e.clientX
dragStartLeft.value = parseInt(milestoneStyle.value.left)
tempMilestoneData.value = null
// 添加全局事件监听器
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
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)
// 只更新临时数据,不触发事件
tempMilestoneData.value = {
startDate: formatDateToLocalString(newStartDate),
}
}
}
const handleMouseUp = () => {
// 如果有临时数据,说明发生了拖拽,提交数据更新
if (tempMilestoneData.value && props.milestone) {
const updatedMilestone = {
...props.milestone,
...tempMilestoneData.value,
}
console.log('里程碑拖拽完成,提交数据更新:', updatedMilestone)
emit('update:milestone', updatedMilestone)
// 清空临时数据
tempMilestoneData.value = null
}
isDragging.value = false
// 移除全局事件监听器
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
// 计算菱形位置 - 考虑拖拽临时数据
const milestoneStyle = computed(() => {
const milestoneDate = tempMilestoneData.value?.startDate
? new Date(tempMilestoneData.value.startDate)
: new Date(props.date)
// 修正props.startDate 可能为 undefined需防御性处理
if (!props.startDate || isNaN(new Date(props.date).getTime())) {
return {
left: '0px',
top: '0px',
width: 'auto',
height: 'auto',
}
}
const startDiff = Math.floor(
(milestoneDate.getTime() - props.startDate.getTime()) / (1000 * 60 * 60 * 24),
)
const size = Math.min(props.rowHeight, props.dayWidth * 1.2, 24)
return {
left: `${startDiff * props.dayWidth + props.dayWidth / 2 - size / 2}px`,
top: `${(props.rowHeight - size) / 2}px`,
width: 'auto',
height: 'auto',
}
})
// 里程碑统一使用红色配色
const milestoneColor = computed(() => {
// 使用危险色(红色)统一里程碑配色
return 'var(--gantt-danger, #f56c6c)'
})
const milestoneBorder = computed(() => {
// 稍浅的红色作为边框
return 'var(--gantt-danger-light, #fab6b6)'
})
// 计算里程碑图标类型
const milestoneIcon = computed(() => {
return props.milestone?.icon || 'diamond' // 默认为菱形
})
// 组件销毁时清理事件监听器
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
})
</script>
<template>
<div
class="milestone"
:style="milestoneStyle"
:title="props.name || '里程碑'"
:class="{ dragging: isDragging }"
@dblclick="handleDoubleClick"
@mousedown="handleMouseDown"
>
<svg :width="24" :height="24" :viewBox="`0 0 24 24`">
<!-- 菱形图标 -->
<g v-if="milestoneIcon === 'diamond'" transform="rotate(45 16 16)">
<rect
x="4"
y="8"
width="15"
height="15"
rx="6"
ry="6"
:fill="milestoneColor"
:stroke="milestoneBorder"
stroke-width="2"
/>
</g>
<!-- 火箭图标 -->
<g v-else-if="milestoneIcon === 'rocket'">
<foreignObject x="0" y="0" width="24" height="24">
<div class="rocket-emoji">🚀</div>
</foreignObject>
</g>
<!-- 默认菱形图标 -->
<g v-else transform="rotate(45 16 16)">
<rect
x="4"
y="8"
width="15"
height="15"
rx="6"
ry="6"
:fill="milestoneColor"
:stroke="milestoneBorder"
stroke-width="2"
/>
</g>
</svg>
<span v-if="props.name" class="milestone-label milestone-label-right">{{ props.name }}</span>
</div>
</template>
<style scoped>
@import '../styles/theme-variables.css';
.milestone {
position: absolute;
z-index: 120;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
cursor: pointer;
user-select: none;
}
/* 里程碑SVG发光效果 */
.milestone svg {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f56c6c));
animation: milestone-glow 2s ease-in-out infinite alternate;
}
/* 里程碑发光动画 */
@keyframes milestone-glow {
from {
filter: drop-shadow(0 0 4px var(--gantt-danger, #f56c6c));
}
to {
filter: drop-shadow(0 0 12px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 20px rgba(245, 108, 108, 0.3));
}
}
/* 悬停时增强发光效果 */
.milestone:hover svg {
filter: drop-shadow(0 0 16px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 24px rgba(245, 108, 108, 0.4));
animation: milestone-glow-intense 1.5s ease-in-out infinite alternate;
}
@keyframes milestone-glow-intense {
from {
filter: drop-shadow(0 0 12px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 20px rgba(245, 108, 108, 0.4));
}
to {
filter: drop-shadow(0 0 20px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 32px rgba(245, 108, 108, 0.6));
}
}
.milestone-label {
font-size: 12px;
font-weight: bold;
color: var(--gantt-text-primary, #222);
white-space: nowrap;
}
.milestone-label-right {
margin-left: 5px;
align-self: center;
}
/* 火箭emoji样式 */
.rocket-emoji {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
line-height: 1;
transform: rotate(-45deg);
transition: transform 0.3s ease;
}
/* 火箭emoji悬停效果 */
.milestone:hover .rocket-emoji {
transform: rotate(-45deg) scale(1.1);
}
/* 暗黑模式下的适配 */
:global(html[data-theme='dark']) .milestone-label {
color: var(--gantt-text-white, #ffffff) !important;
}
:global(html[data-theme='dark']) .milestone svg {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f67c7c));
animation: milestone-glow-dark 2s ease-in-out infinite alternate;
}
:global(html[data-theme='dark']) .milestone:hover svg {
filter: drop-shadow(0 0 16px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 24px rgba(246, 124, 124, 0.4));
animation: milestone-glow-intense-dark 1.5s ease-in-out infinite alternate;
}
/* 暗黑模式发光动画 */
@keyframes milestone-glow-dark {
from {
filter: drop-shadow(0 0 4px var(--gantt-danger, #f67c7c));
}
to {
filter: drop-shadow(0 0 12px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 20px rgba(246, 124, 124, 0.3));
}
}
@keyframes milestone-glow-intense-dark {
from {
filter: drop-shadow(0 0 12px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 20px rgba(246, 124, 124, 0.4));
}
to {
filter: drop-shadow(0 0 20px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 32px rgba(246, 124, 124, 0.6));
}
}
/* 拖拽状态样式 */
.milestone.dragging {
z-index: 1000;
opacity: 0.8;
transform: scale(1.1);
cursor: grabbing;
}
.milestone.dragging svg {
filter: drop-shadow(0 0 20px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 32px rgba(245, 108, 108, 0.6));
animation: none;
}
:global(html[data-theme='dark']) .milestone.dragging svg {
filter: drop-shadow(0 0 20px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 32px rgba(246, 124, 124, 0.6));
}
</style>

596
src/components/TaskBar.vue Normal file
View File

@@ -0,0 +1,596 @@
<script setup lang="ts">
import { ref, computed, onUnmounted, onMounted, nextTick, watch } from 'vue'
import type { Task } from '../models/classes/Task'
interface Props {
task: Task
rowHeight: number
dayWidth: number
startDate: Date
isParent?: boolean
onDoubleClick?: (task: Task) => void
}
const props = defineProps<Props>()
const emit = defineEmits(['update:task', 'bar-mounted', 'dblclick'])
// 日期工具函数 - 处理时区安全的日期创建和操作
const createLocalDate = (dateString: string | Date | undefined | null): Date | null => {
if (!dateString) return null
if (dateString instanceof Date) {
return dateString
}
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
const [year, month, day] = dateString.split('-').map(Number)
return new Date(year, month - 1, day)
}
const d = new Date(dateString)
return isNaN(d.getTime()) ? null : d
}
const createLocalToday = (): Date => {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), now.getDate())
}
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')
return `${year}-${month}-${day}`
}
const addDaysToLocalDate = (date: Date, days: number): Date => {
const result = new Date(date)
result.setDate(result.getDate() + days)
return result
}
// 拖拽状态
const isDragging = ref(false)
const isResizingLeft = ref(false)
const isResizingRight = ref(false)
const dragStartX = ref(0)
const dragStartLeft = ref(0)
const dragStartWidth = ref(0)
const resizeStartX = ref(0)
const resizeStartWidth = ref(0)
const resizeStartLeft = ref(0)
// 缓存拖拽/拉伸过程中的临时数据,只在鼠标抬起时提交更新
const tempTaskData = ref<{
startDate?: string
endDate?: string
} | null>(null)
const barRef = ref<HTMLElement | null>(null)
// 计算任务条位置和宽度
const taskBarStyle = computed(() => {
const currentStartDate = tempTaskData.value?.startDate || props.task.startDate
const currentEndDate = tempTaskData.value?.endDate || props.task.endDate
const startDate = createLocalDate(currentStartDate)
const endDate = createLocalDate(currentEndDate)
const baseStart = createLocalDate(props.startDate)
if (!startDate || !endDate || !baseStart) {
return {
left: '0px',
width: '0px',
height: `${props.rowHeight - 10}px`,
top: '4px',
}
}
const startDiff = Math.floor((startDate.getTime() - baseStart.getTime()) / (1000 * 60 * 60 * 24))
const duration = Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1
return {
left: `${startDiff * props.dayWidth}px`,
width: `${duration * props.dayWidth}px`,
height: `${props.rowHeight - 10}px`,
top: '4px',
}
})
// 计算任务状态和颜色
const taskStatus = computed(() => {
// 父级任务(Story类型)使用与新建按钮一致的配色
if (props.isParent) {
return {
type: 'parent',
color: '#409eff', // 与新建按钮一致的蓝色
bgColor: '#409eff',
borderColor: '#409eff',
}
}
const today = createLocalToday()
const endDate = createLocalDate(props.task.endDate || '')
const progress = props.task.progress || 0
// 已完成
if (progress >= 100) {
return {
type: 'completed',
color: '#909399', // info color
bgColor: '#f4f4f5',
borderColor: '#d3d4d6',
}
}
// 已延迟(结束日期早于今天且未完成)
if (endDate && endDate < today && progress < 100) {
return {
type: 'delayed',
color: '#f56c6c', // danger color
bgColor: '#fef0f0',
borderColor: '#fbc4c4',
}
}
// 进行中(结束日期晚于今天且进度>0
if (endDate && endDate >= today && progress > 0) {
return {
type: 'in-progress',
color: '#e6a23c', // warning color
bgColor: '#fdf6ec',
borderColor: '#f5dab1',
}
}
// 未开始进度为0且未延迟
return {
type: 'not-started',
color: '#409eff', // primary color
bgColor: '#ecf5ff',
borderColor: '#b3d8ff',
}
})
// 判断是否已完成
const isCompleted = computed(() => {
return (props.task.progress || 0) >= 100
})
// 计算完成部分的宽度
const progressWidth = computed(() => {
const progress = props.task.progress || 0
const totalWidth = parseInt(taskBarStyle.value.width)
return `${(progress / 100) * totalWidth}px`
})
// 鼠标事件处理
const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-right') => {
// 如果已完成或是父级任务,禁用所有交互
if (isCompleted.value || props.isParent) {
return
}
e.preventDefault()
e.stopPropagation()
// 清空之前的临时数据
tempTaskData.value = null
if (type === 'drag') {
isDragging.value = true
dragStartX.value = e.clientX
dragStartLeft.value = parseInt(taskBarStyle.value.left)
dragStartWidth.value = parseInt(taskBarStyle.value.width)
} else if (type === 'resize-left') {
isResizingLeft.value = true
resizeStartX.value = e.clientX
resizeStartWidth.value = parseInt(taskBarStyle.value.width)
resizeStartLeft.value = parseInt(taskBarStyle.value.left)
} else if (type === 'resize-right') {
isResizingRight.value = true
resizeStartX.value = e.clientX
resizeStartWidth.value = parseInt(taskBarStyle.value.width)
resizeStartLeft.value = parseInt(taskBarStyle.value.left)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
function reportBarPosition() {
if (barRef.value) {
const rect = barRef.value.getBoundingClientRect()
emit('bar-mounted', {
id: props.task.id,
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
})
}
}
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),
}
} 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, // 保持原来的结束日期
}
} 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),
}
}
}
const handleMouseUp = () => {
// 如果有临时数据,说明发生了拖拽或拉伸,提交数据更新
if (tempTaskData.value) {
const updatedTask = {
...props.task,
...tempTaskData.value,
}
console.log('TaskBar操作完成提交数据更新', updatedTask)
emit('update:task', updatedTask)
// 清空临时数据
tempTaskData.value = null
// 下一帧报告新位置
nextTick(() => {
reportBarPosition()
})
}
isDragging.value = false
isResizingLeft.value = false
isResizingRight.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
onMounted(() => {
nextTick(() => {
reportBarPosition()
})
})
// 监听任务数据变化,重新报告位置
watch(
() => [props.task.startDate, props.task.endDate],
() => {
nextTick(() => {
reportBarPosition()
})
},
{ deep: true },
)
// 处理TaskBar双击事件
const handleTaskBarDoubleClick = (e: MouseEvent) => {
// 阻止事件冒泡,避免触发拖拽等其他事件
e.stopPropagation()
// 如果正在拖拽或调整大小,不触发双击事件
if (isDragging.value || isResizingLeft.value || isResizingRight.value) {
return
}
// 优先调用外部传入的双击处理器
if (props.onDoubleClick && typeof props.onDoubleClick === 'function') {
props.onDoubleClick(props.task)
} else {
// 默认行为:发出双击事件给父组件
emit('dblclick', props.task)
}
}
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
})
</script>
<template>
<div
ref="barRef"
class="task-bar"
:style="{
...taskBarStyle,
backgroundColor: taskStatus.bgColor,
borderColor: taskStatus.borderColor,
color: taskStatus.color,
cursor: isCompleted || isParent ? 'default' : 'move',
}"
:class="{
dragging: isDragging,
resizing: isResizingLeft || isResizingRight,
completed: isCompleted,
'parent-task': isParent,
}"
@dblclick="handleTaskBarDoubleClick"
>
<!-- 父级任务的标签 -->
<div v-if="isParent" class="parent-label">{{ task.name }} ({{ task.progress || 0 }}%)</div>
<!-- 完成进度条非父级任务 -->
<div
v-if="!isParent && task.progress && task.progress > 0"
class="progress-bar"
:style="{
width: progressWidth,
backgroundColor: taskStatus.color,
}"
></div>
<!-- 左侧调整把手 -->
<div
v-if="!isCompleted && !isParent"
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 class="task-name">{{ task.name }}</div>
<div v-if="task.progress !== undefined" class="task-progress">{{ task.progress }}%</div>
</div>
<!-- 右侧调整把手 -->
<div
v-if="!isCompleted && !isParent"
class="resize-handle resize-handle-right"
@mousedown="e => handleMouseDown(e, 'resize-right')"
></div>
</div>
</template>
<style scoped>
.task-bar {
position: absolute;
border-radius: 4px;
user-select: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: box-shadow 0.2s;
min-width: 60px;
z-index: 100;
border: 2px solid;
overflow: hidden;
}
.task-bar:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.task-bar.completed {
cursor: pointer !important;
}
.task-bar.completed:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
cursor: pointer;
}
.task-bar.dragging {
opacity: 0.8;
z-index: 1000;
}
.task-bar.resizing {
z-index: 1000;
}
.task-bar.parent-task {
position: relative;
border-radius: 0; /* 移除圆角,使用线性设计 */
margin-bottom: 20px; /* 为标签和垂直线留出空间 */
height: 10px !important; /* 降低高度,让条更细 */
border: none; /* 移除边框 */
background: #409eff !important; /* 与新建按钮一致的蓝色 */
box-shadow: none; /* 移除阴影 */
top: 50% !important; /* 上下居中 */
transform: translateY(-50%); /* 上下居中 */
cursor: pointer !important; /* 允许双击编辑 */
overflow: visible; /* 确保伪元素可见 */
}
/* 父级任务的标签 */
.task-bar.parent-task .parent-label {
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
background: #409eff; /* 与新建按钮一致的蓝色 */
color: white;
padding: 4px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
z-index: 20;
}
/* 左侧向下箭头 - 更尖 */
.task-bar.parent-task::before {
content: '';
position: absolute;
top: 10px; /* 位于进度条下方 */
left: 0;
width: 0;
height: 0;
border-right: 6px solid transparent; /* 减小宽度,让箭头更尖 */
border-top: 10px solid #409eff; /* 与新建按钮一致的蓝色 */
z-index: 15;
}
/* 右侧向下箭头 - 更尖 */
.task-bar.parent-task::after {
content: '';
position: absolute;
top: 10px; /* 位于进度条下方 */
right: 0;
width: 0;
height: 0;
border-left: 6px solid transparent; /* 减小宽度,让箭头更尖 */
border-top: 10px solid #409eff; /* 与新建按钮一致的蓝色 */
z-index: 15;
}
.progress-bar {
position: absolute;
top: 0;
left: 0;
height: 100%;
opacity: 0.3;
transition: width 0.3s ease;
}
.task-bar-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
padding: 0 8px;
font-size: 12px;
font-weight: 500;
text-align: center;
overflow: hidden;
position: relative;
z-index: 1;
}
.task-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
line-height: 1.2;
}
.task-progress {
opacity: 0.9;
margin-top: 2px;
}
.resize-handle {
position: absolute;
top: 0;
width: 6px;
height: 100%;
cursor: ew-resize;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
transition: background 0.2s;
z-index: 2;
}
.resize-handle:hover {
background: rgba(0, 0, 0, 0.2);
}
.resize-handle-left {
left: 0;
}
.resize-handle-right {
right: 0;
}
/* 暗色主题支持 */
:global(html[data-theme='dark']) .task-bar {
border-color: #111827 !important;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.7),
0 2px 4px rgba(0, 0, 0, 0.3) !important;
}
:global(html[data-theme='dark']) .task-bar:hover {
box-shadow:
0 6px 20px rgba(0, 0, 0, 0.8),
0 4px 8px rgba(0, 0, 0, 0.4) !important;
transform: translateY(-2px);
transition: all 0.2s ease;
}
:global(html[data-theme='dark']) .task-bar:hover::after {
background: rgba(7, 10, 15, 0.98) !important;
color: #f9fafb !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6) !important;
}
:global(html[data-theme='dark']) .task-bar.normal {
background: linear-gradient(135deg, #1e40af, #1e3a8a) !important;
border-color: #1e3a8a !important;
}
:global(html[data-theme='dark']) .task-bar.milestone {
background: linear-gradient(135deg, #c2410c, #9a3412) !important;
border-color: #9a3412 !important;
}
:global(html[data-theme='dark']) .task-bar.completed {
background: linear-gradient(135deg, #14532d, #16a34a) !important;
border-color: #14532d !important;
}
:global(html[data-theme='dark']) .task-bar.delayed {
background: linear-gradient(135deg, #991b1b, #dc2626) !important;
border-color: #991b1b !important;
}
:global(html[data-theme='dark']) .task-bar.parent {
background: linear-gradient(135deg, #581c87, #7c3aed) !important;
border-color: #581c87 !important;
}
:global(html[data-theme='dark']) .task-content {
color: #ffffff !important;
}
:global(html[data-theme='dark']) .task-name {
color: #ffffff !important;
}
:global(html[data-theme='dark']) .progress-bar {
background: rgba(255, 255, 255, 0.2) !important;
}
:global(html[data-theme='dark']) .progress-fill {
background: rgba(255, 255, 255, 0.8) !important;
}
:global(html[data-theme='dark']) .resize-handle {
background: rgba(255, 255, 255, 0.1) !important;
}
:global(html[data-theme='dark']) .resize-handle:hover {
background: rgba(255, 255, 255, 0.3) !important;
}
</style>

View File

@@ -0,0 +1,761 @@
<script setup lang="ts">
import { ref, reactive, watch, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from '../composables/useI18n'
import DatePicker from './DatePicker.vue'
import GanttConfirmDialog from './GanttConfirmDialog.vue'
import type { Task } from '../models/classes/Task'
import '../styles/app.css'
interface Props {
visible: boolean
task?: Task | null
isEdit?: boolean
onDelete?: (task: Task) => void
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
task: null,
isEdit: false,
onDelete: undefined,
})
const emit = defineEmits<{
'update:visible': [value: boolean]
submit: [task: Task]
close: []
delete: [task: Task]
}>()
const { t } = useI18n()
const submitting = ref(false)
const isVisible = ref(props.visible)
const showDeleteConfirm = ref(false)
// 表单数据
const formData = reactive<Task>({
id: 0, // 默认值,创建时会被重新分配
name: '',
type: 'task',
assignee: '',
startDate: '',
endDate: '',
predecessor: '',
estimatedHours: 0,
actualHours: 0,
progress: 0,
description: '',
parentId: undefined, // 上级任务ID
})
// 任务列表数据
const allTasks = ref<Task[]>([])
// 获取可作为前置任务的任务列表只包含type="task"的任务,且不包含当前任务)
const availablePredecessorTasks = computed(() => {
return allTasks.value.filter(
task => task.type === 'task' && task.id !== props.task?.id, // 排除当前任务自己
)
})
// 获取可作为上级任务的任务列表只显示story和task类型排除当前任务自己
const availableParentTasks = computed(() => {
return allTasks.value
.filter(
task =>
task.id !== props.task?.id && // 排除当前任务自己
(task.type === 'story' || task.type === 'task'), // 只显示story和task类型
)
.map(task => ({
...task,
displayName: `${task.name} (${getTaskTypeDisplay(task.type || 'task')})`,
}))
})
// 获取任务类型的显示文本
const getTaskTypeDisplay = (type: string): string => {
return (t.value.taskTypeMap as Record<string, string>)?.[type] || type
}
// 错误信息
const errors = reactive({
name: '',
type: '',
startDate: '',
endDate: '',
})
// 监听 visible 属性变化
watch(
() => props.visible,
newVal => {
isVisible.value = newVal
if (newVal) {
resetForm()
if (props.task && props.isEdit) {
// 编辑模式,填充表单数据
Object.assign(formData, props.task)
}
// 抽屉显示时重新请求任务数据,确保前置任务列表是最新的
window.dispatchEvent(new CustomEvent('request-task-list'))
}
},
)
// 监听 isVisible 变化,同步到父组件
watch(isVisible, newVal => {
emit('update:visible', newVal)
})
// 重置表单
const resetForm = () => {
Object.assign(formData, {
name: '',
type: 'task',
assignee: '',
startDate: '',
endDate: '',
predecessor: '',
estimatedHours: 0,
actualHours: 0,
progress: 0,
description: '',
parentId: undefined,
})
// 清除错误信息
Object.keys(errors).forEach(key => {
errors[key as keyof typeof errors] = ''
})
}
// 表单验证
const validateForm = (): boolean => {
let isValid = true
Object.keys(errors).forEach(key => {
errors[key as keyof typeof errors] = ''
})
if (!formData.name?.trim()) {
errors.name = t.value.taskNameRequired
isValid = false
} else if (formData.name.length > 50) {
errors.name = t.value.taskNameTooLong
isValid = false
}
if (!formData.type) {
errors.type = t.value.taskTypeRequired
isValid = false
}
if (!formData.startDate) {
errors.startDate = t.value.startDateRequired
isValid = false
}
if (!formData.endDate) {
errors.endDate = t.value.endDateRequired
isValid = false
} else if (formData.startDate && new Date(formData.endDate) < new Date(formData.startDate)) {
errors.endDate = t.value.endDateInvalid
isValid = false
}
return isValid
}
// 关闭抽屉
const handleClose = () => {
isVisible.value = false
emit('close')
}
// 点击遮罩层关闭
const handleOverlayClick = () => {
handleClose()
}
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) {
return
}
try {
submitting.value = true
const taskData: Task = {
...formData,
id: props.isEdit && props.task ? props.task.id : Date.now(),
}
emit('submit', taskData)
showMessage(props.isEdit ? t.value.taskUpdateSuccess : t.value.taskCreateSuccess, 'success')
handleClose()
} catch (error) {
// 记录异常,保证 lint 通过
console.error(error)
showMessage(t.value.operationFailed, 'error')
} finally {
submitting.value = false
}
}
// 删除任务
const handleDelete = () => {
showDeleteConfirm.value = true
}
const confirmDelete = () => {
showDeleteConfirm.value = false
if (props.task && props.isEdit) {
try {
submitting.value = true
emit('delete', props.task)
handleClose()
} catch (error) {
showMessage(t.value.taskDeleteFailed, 'error')
} finally {
submitting.value = false
}
}
}
const cancelDelete = () => {
showDeleteConfirm.value = false
}
// 获取任务数据的事件处理器
const handleTasksChanged = (event: CustomEvent) => {
allTasks.value = event.detail || []
}
// 组件挂载时添加事件监听器
onMounted(() => {
window.addEventListener('task-list-updated', handleTasksChanged as EventListener)
// 请求初始任务数据
window.dispatchEvent(new CustomEvent('request-task-list'))
})
// 组件卸载时移除事件监听器
onUnmounted(() => {
window.removeEventListener('task-list-updated', handleTasksChanged as EventListener)
})
// 简单的消息提示函数
const showMessage = (message: string, type: 'success' | 'error') => {
// 创建消息元素
const messageEl = document.createElement('div')
messageEl.className = `message ${type}`
messageEl.textContent = message
messageEl.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 4px;
color: white;
font-size: 14px;
z-index: 9999;
background: ${type === 'success' ? '#67c23a' : '#f56c6c'};
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
`
document.body.appendChild(messageEl)
// 3秒后自动移除
setTimeout(() => {
if (messageEl.parentNode) {
document.body.removeChild(messageEl)
}
}, 3000)
}
</script>
<template>
<div v-if="isVisible" class="drawer-overlay" @click="handleOverlayClick">
<div class="drawer-container" @click.stop>
<!-- Drawer Header -->
<div class="drawer-header">
<h3 class="drawer-title">{{ isEdit ? t.editTask : t.newTask }}</h3>
<button class="drawer-close-btn" type="button" @click="handleClose">
<svg class="close-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<!-- Drawer Body -->
<div class="drawer-body">
<form class="task-form" @submit.prevent="handleSubmit">
<div class="form-group">
<label class="form-label" for="task-name">
{{ t.taskName }} <span class="required">*</span></label
>
<input
id="task-name"
v-model="formData.name"
type="text"
class="form-input"
:class="{ error: errors.name }"
:placeholder="t.taskNamePlaceholder"
/>
<span v-if="errors.name" class="error-text">{{ errors.name }}</span>
</div>
<div class="form-group">
<label class="form-label" for="task-type">
{{ t.taskType }} <span class="required">*</span></label
>
<select
id="task-type"
v-model="formData.type"
class="form-select"
:class="{ error: errors.type }"
>
<option value="story">{{ t.taskTypeMap.story }}</option>
<option value="task">{{ t.taskTypeMap.task }}</option>
<option value="bug">{{ t.taskTypeMap.bug }}</option>
</select>
<span v-if="errors.type" class="error-text">{{ errors.type }}</span>
</div>
<div class="form-group">
<label class="form-label" for="task-assignee">{{ t.assignee }}</label>
<select id="task-assignee" v-model="formData.assignee" class="form-select">
<option value="">请选择负责人</option>
<option value="张三">张三</option>
<option value="李四">李四</option>
<option value="王五">王五</option>
<option value="赵六">赵六</option>
<option value="钱七">钱七</option>
</select>
</div>
<!-- 上级任务选择 -->
<div class="form-group">
<label class="form-label" for="task-parent">{{ t.parentTask }}</label>
<select id="task-parent" v-model="formData.parentId" class="form-select">
<option :value="undefined">{{ t.noParentTask }}</option>
<option
v-for="parentTask in availableParentTasks"
:key="parentTask.id"
:value="parentTask.id"
>
{{ parentTask.displayName }}
</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="task-start-date">
{{ t.startDate }} <span class="required">*</span></label
>
<DatePicker
id="task-start-date"
v-model="formData.startDate"
type="date"
:placeholder="t.startDateRequired"
:class="{ error: errors.startDate }"
/>
<span v-if="errors.startDate" class="error-text">{{ errors.startDate }}</span>
</div>
<div class="form-group">
<label class="form-label" for="task-end-date">
{{ t.endDate }} <span class="required">*</span></label
>
<DatePicker
id="task-end-date"
v-model="formData.endDate"
type="date"
:placeholder="t.endDateRequired"
:class="{ error: errors.endDate }"
/>
<span v-if="errors.endDate" class="error-text">{{ errors.endDate }}</span>
</div>
</div>
<div class="form-group">
<label class="form-label" for="task-predecessor">{{ t.predecessor }}</label>
<select id="task-predecessor" v-model="formData.predecessor" class="form-select">
<option value="">{{ t.predecessorPlaceholder }}</option>
<option
v-for="predTask in availablePredecessorTasks"
:key="predTask.id"
:value="predTask.id"
>
{{ predTask.name }} (ID: {{ predTask.id }})
</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="task-estimated-hours">{{ t.estimatedHours }}</label>
<input
id="task-estimated-hours"
v-model.number="formData.estimatedHours"
type="number"
class="form-input"
placeholder="0"
min="0"
max="999"
/>
</div>
<div class="form-group">
<label class="form-label" for="task-actual-hours">{{ t.actualHours }}</label>
<input
id="task-actual-hours"
v-model.number="formData.actualHours"
type="number"
class="form-input"
placeholder="0"
min="0"
max="999"
/>
</div>
</div>
<div class="form-group">
<label class="form-label" for="task-progress">{{ t.progress }}</label>
<div class="progress-container">
<input
id="task-progress"
v-model.number="formData.progress"
type="range"
class="progress-slider"
min="0"
max="100"
step="5"
/>
<div class="progress-value">{{ formData.progress }}%</div>
</div>
</div>
<div class="form-group">
<label class="form-label" for="task-description">{{ t.description }}</label>
<textarea
id="task-description"
v-model="formData.description"
class="form-textarea"
:placeholder="t.descriptionPlaceholder"
rows="3"
></textarea>
</div>
</form>
</div>
<!-- Drawer Footer -->
<div class="drawer-footer">
<div class="footer-left">
<!-- 删除按钮仅在编辑模式下显示 -->
<button
v-if="isEdit && task"
type="button"
class="btn btn-danger"
:disabled="submitting"
@click="handleDelete"
>
<span v-if="submitting" class="loading-spinner"></span>
{{ t.delete }}
</button>
<GanttConfirmDialog
:visible="showDeleteConfirm"
:title="t.delete"
:message="t.confirmDeleteTask.replace('{name}', task?.name || '')"
:confirm-text="t.confirm"
:cancel-text="t.cancel"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</div>
<div class="footer-right">
<button type="button" class="btn btn-default" @click="handleClose">{{ t.cancel }}</button>
<button
type="button"
class="btn btn-primary"
:disabled="submitting"
@click="handleSubmit"
>
<span v-if="submitting" class="loading-spinner"></span>
{{ isEdit ? t.update : t.create }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@import '../styles/theme-variables.css';
/* 抽屉遮罩层 */
.drawer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 10000; /* 确保在全屏模式下也能正常显示 */
display: flex;
justify-content: flex-end;
align-items: stretch;
}
/* 抽屉容器 */
.drawer-container {
width: 500px;
background: var(--gantt-bg-primary, white);
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease-out;
color: var(--gantt-text-primary, #303133);
}
@keyframes slideIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
/* 抽屉头部 */
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--gantt-border-light, #ebeef5);
background: var(--gantt-bg-secondary, #f5f7fa);
}
.drawer-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--gantt-text-primary, #303133);
}
.drawer-close-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: var(--gantt-text-muted, #909399);
transition: color 0.2s;
}
.drawer-close-btn:hover {
color: var(--gantt-text-secondary, #606266);
}
.close-icon {
width: 16px;
height: 16px;
stroke-width: 2;
}
/* 抽屉主体 */
.drawer-body {
flex: 1;
padding: 24px;
overflow-y: auto;
}
/* 表单样式 */
.task-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-row {
display: flex;
gap: 16px;
}
.form-row .form-group {
flex: 1;
}
.form-label {
font-size: 14px;
font-weight: 500;
color: var(--gantt-text-secondary, #606266);
line-height: 1.4;
}
.required {
color: var(--gantt-danger, #f56c6c);
margin-left: 2px;
}
.form-input,
.form-select,
.form-textarea {
padding: 12px 16px;
border: 1px solid var(--gantt-border-medium, #dcdfe6);
border-radius: 4px;
font-size: 14px;
color: var(--gantt-text-primary, #303133); /* 录入后为黑色,与 MilestoneDialog 保持一致 */
background: var(--gantt-bg-primary, white);
transition: border-color 0.2s;
outline: none;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
border-color: var(--gantt-primary, #409eff);
}
.form-input.error,
.form-select.error {
border-color: var(--gantt-danger, #f56c6c);
}
.form-input::placeholder,
.form-select::placeholder,
.form-textarea::placeholder {
color: var(--gantt-text-placeholder, #c0c4cc);
}
.form-textarea {
resize: vertical;
min-height: 80px;
}
.error-text {
color: var(--gantt-danger, #f56c6c);
font-size: 12px;
line-height: 1.4;
}
/* 进度条容器 */
.progress-container {
display: flex;
align-items: center;
gap: 12px;
}
.progress-slider {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--gantt-border-light, #e4e7ed);
outline: none;
appearance: none;
cursor: pointer;
}
.progress-slider::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--gantt-primary, #409eff);
cursor: pointer;
}
.progress-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--gantt-primary, #409eff);
cursor: pointer;
border: none;
}
.progress-value {
font-size: 14px;
font-weight: 500;
color: var(--gantt-text-secondary, #606266);
min-width: 40px;
text-align: right;
}
/* 抽屉底部 */
.drawer-footer {
padding: 16px 24px;
border-top: 1px solid var(--gantt-border-light, #ebeef5);
background: var(--gantt-bg-toolbar, #fafafa);
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-left {
display: flex;
align-items: center;
}
.footer-right {
display: flex;
align-items: center;
gap: 12px;
}
/* 加载动画 */
.loading-spinner {
width: 12px;
height: 12px;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 消息提示样式 */
.message {
animation: messageSlideIn 0.3s ease-out;
}
@keyframes messageSlideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* 暗黑模式样式优化 */
:global(html[data-theme='dark']) .drawer-overlay {
background: rgba(0, 0, 0, 0.7) !important;
}
:global(html[data-theme='dark']) .drawer-container {
box-shadow: -4px 0 15px rgba(0, 0, 0, 0.4) !important;
}
:global(html[data-theme='dark']) .drawer-close-btn:hover {
background: var(--gantt-bg-hover, rgba(255, 255, 255, 0.1)) !important;
border-radius: 4px;
}
:global(html[data-theme='dark']) .form-input:focus,
:global(html[data-theme='dark']) .form-select:focus,
:global(html[data-theme='dark']) .form-textarea:focus {
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2) !important;
}
:global(html[data-theme='dark']) .form-input::placeholder,
:global(html[data-theme='dark']) .form-textarea::placeholder {
color: var(--gantt-text-muted, #9e9e9e) !important;
}
</style>

485
src/components/TaskList.vue Normal file
View File

@@ -0,0 +1,485 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import TaskRow from './TaskRow.vue'
import { useI18n } from '../composables/useI18n'
import type { Task } from '../models/classes/Task'
interface Props {
tasks?: Task[]
onTaskDoubleClick?: (task: Task) => void
editComponent?: any
useDefaultDrawer?: boolean
}
const props = defineProps<Props>()
// 定义emit事件
const emit = defineEmits(['task-collapse-change'])
// 多语言支持
const { t } = useI18n()
// 内部响应式任务列表
const localTasks = ref<Task[]>([])
// 悬停状态管理
const hoveredTaskId = ref<number | null>(null)
// 拖拽状态管理
const isSplitterDragging = ref(false)
// 处理拖拽开始事件
const handleSplitterDragStart = () => {
isSplitterDragging.value = true
}
// 处理拖拽结束事件
const handleSplitterDragEnd = () => {
isSplitterDragging.value = false
}
// 处理任务行悬停事件
const handleTaskRowHover = (taskId: number | null) => {
// 如果正在拖拽Splitter则不响应悬停事件
if (isSplitterDragging.value) {
return
}
hoveredTaskId.value = taskId
// 发送事件通知Timeline组件
window.dispatchEvent(
new CustomEvent('task-list-hover', {
detail: taskId,
}),
)
}
// 监听Timeline的悬停事件
const handleTimelineHover = (event: CustomEvent) => {
hoveredTaskId.value = event.detail
}
// 处理TaskRow双击事件 (与Timeline的逻辑保持一致)
const handleTaskRowDoubleClick = (task: Task) => {
// 如果是里程碑类型,则不响应双击事件
if (task.type === 'milestone' || task.type === 'milestone-group') {
return
}
// 优先调用外部传入的双击处理器
if (props.onTaskDoubleClick && typeof props.onTaskDoubleClick === 'function') {
props.onTaskDoubleClick(task)
} else if (props.useDefaultDrawer) {
// 默认行为发送到Timeline处理通过全局事件
window.dispatchEvent(
new CustomEvent('task-row-double-click', {
detail: task,
}),
)
}
}
// 计算父级任务的进度和日期范围
const calculateParentTaskData = (
task: Task,
): { progress: number; startDate: string; endDate: string } => {
if (!task.children || task.children.length === 0) {
return {
progress: task.progress || 0,
startDate: task.startDate || '',
endDate: task.endDate || '',
}
}
// 递归计算所有子任务(包括折叠的子任务)
const allChildTasks: Task[] = []
const collectChildTasks = (tasks: Task[]) => {
tasks.forEach(childTask => {
allChildTasks.push(childTask)
if (childTask.children && childTask.children.length > 0) {
collectChildTasks(childTask.children)
}
})
}
collectChildTasks(task.children)
// 计算进度:所有子任务进度的平均值
const totalProgress = allChildTasks.reduce((sum, childTask) => {
return sum + (childTask.progress || 0)
}, 0)
const averageProgress =
allChildTasks.length > 0 ? Math.round(totalProgress / allChildTasks.length) : 0
// 计算日期范围:最早开始日期和最晚结束日期
const validTasks = allChildTasks.filter(childTask => childTask.startDate && childTask.endDate)
if (validTasks.length === 0) {
return {
progress: averageProgress,
startDate: task.startDate || '',
endDate: task.endDate || '',
}
}
const startDates = validTasks.map(childTask => new Date(childTask.startDate!))
const endDates = validTasks.map(childTask => new Date(childTask.endDate!))
const earliestStart = new Date(Math.min(...startDates.map(date => date.getTime())))
const latestEnd = new Date(Math.max(...endDates.map(date => date.getTime())))
return {
progress: averageProgress,
startDate: earliestStart.toISOString().split('T')[0],
endDate: latestEnd.toISOString().split('T')[0],
}
}
// 更新所有父级任务的进度和日期范围
const updateParentTasksData = () => {
const updateParentTask = (taskList: Task[]): Task[] => {
return taskList.map(task => {
if (task.children && task.children.length > 0) {
// 先更新子任务
const updatedChildren = updateParentTask(task.children)
// 计算父级任务的进度和日期范围
const parentData = calculateParentTaskData({
...task,
children: updatedChildren,
})
return {
...task,
progress: parentData.progress,
startDate: parentData.startDate,
endDate: parentData.endDate,
children: updatedChildren,
}
}
return task
})
}
localTasks.value = updateParentTask(localTasks.value)
}
// 获取所有任务的扁平化列表(包括子任务)
const getAllTasks = (taskList: Task[]): Task[] => {
const allTasks: Task[] = []
const collectTasks = (tasks: Task[]) => {
tasks.forEach(task => {
allTasks.push(task)
if (task.children && task.children.length > 0) {
collectTasks(task.children)
}
})
}
collectTasks(taskList)
return allTasks
}
// 监听外部传入的 tasks 数据变化
watch(
() => props.tasks,
newTasks => {
localTasks.value = newTasks || []
// 更新父级任务数据
updateParentTasksData()
},
{ immediate: true, deep: true },
)
function toggleCollapse(task: Task) {
task.collapsed = !task.collapsed
// 触发自定义事件,通知父组件任务折叠状态变化
emit('task-collapse-change', task)
}
// 更新任务数据
const updateTaskData = (updatedTask: Task) => {
const updateTaskInTree = (taskList: Task[]): Task[] => {
return taskList.map(task => {
if (task.id === updatedTask.id) {
return {
...task,
...updatedTask, // 完整更新所有字段
children: task.children, // 保留子任务结构
}
}
if (task.children) {
return {
...task,
children: updateTaskInTree(task.children),
}
}
return task
})
}
localTasks.value = updateTaskInTree(localTasks.value)
// 更新父级任务的进度和日期范围
updateParentTasksData()
}
// 监听TaskBar更新事件
const handleTaskUpdated = (event: CustomEvent) => {
const updatedTask = event.detail
updateTaskData(updatedTask)
}
// 监听Timeline的任务新增事件
const handleTaskAdded = (event: CustomEvent) => {
const newTask = event.detail
localTasks.value.push(newTask)
// 更新父级任务数据
updateParentTasksData()
}
// 监听 TaskDrawer 请求任务列表事件
const handleRequestTaskList = () => {
// 获取所有任务的扁平化列表
const allTasks = getAllTasks(localTasks.value)
// 发送任务列表给 TaskDrawer
window.dispatchEvent(
new CustomEvent('task-list-updated', {
detail: allTasks,
}),
)
}
// 处理里程碑图标变更事件
const handleMilestoneIconChange = (event: CustomEvent) => {
const { milestoneId, icon } = event.detail
// 递归更新里程碑图标
const updateMilestoneIcon = (taskList: Task[]) => {
for (const task of taskList) {
if (task.type === 'milestone-group' && task.children) {
const milestone = task.children.find(m => m.id === milestoneId)
if (milestone) {
milestone.icon = icon
return true
}
}
if (task.children && updateMilestoneIcon(task.children)) {
return true
}
}
return false
}
updateMilestoneIcon(localTasks.value)
}
// 垂直滚动同步处理
const handleTaskListScroll = (event: Event) => {
const target = event.target as HTMLElement
if (!target) return
const scrollTop = target.scrollTop
// 同步垂直滚动到Timeline
window.dispatchEvent(
new CustomEvent('task-list-vertical-scroll', {
detail: { scrollTop },
}),
)
}
// 处理Timeline垂直滚动同步
const handleTimelineVerticalScroll = (event: CustomEvent) => {
const { scrollTop } = event.detail
const taskListBodyElement = document.querySelector('.task-list-body') as HTMLElement
if (taskListBodyElement && taskListBodyElement.scrollTop !== scrollTop) {
// 避免循环触发只在scrollTop不同时才设置
taskListBodyElement.scrollTop = scrollTop
}
}
onMounted(async () => {
window.addEventListener('task-updated', handleTaskUpdated as EventListener)
window.addEventListener('task-added', handleTaskAdded as EventListener)
window.addEventListener('request-task-list', handleRequestTaskList as EventListener)
window.addEventListener('timeline-task-hover', handleTimelineHover as EventListener)
window.addEventListener('timeline-vertical-scroll', handleTimelineVerticalScroll as EventListener)
window.addEventListener('milestone-icon-changed', handleMilestoneIconChange as EventListener)
// 监听Splitter拖拽事件
window.addEventListener('splitter-drag-start', handleSplitterDragStart as EventListener)
window.addEventListener('splitter-drag-end', handleSplitterDragEnd as EventListener)
// 初始化时计算父级任务的进度和日期范围
updateParentTasksData()
})
onUnmounted(() => {
window.removeEventListener('task-updated', handleTaskUpdated as EventListener)
window.removeEventListener('task-added', handleTaskAdded as EventListener)
window.removeEventListener('request-task-list', handleRequestTaskList as EventListener)
window.removeEventListener('timeline-task-hover', handleTimelineHover as EventListener)
window.removeEventListener(
'timeline-vertical-scroll',
handleTimelineVerticalScroll as EventListener,
)
window.removeEventListener('milestone-icon-changed', handleMilestoneIconChange as EventListener)
window.removeEventListener('splitter-drag-start', handleSplitterDragStart as EventListener)
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd as EventListener)
})
</script>
<template>
<div class="task-list">
<div class="task-list-header">
<div class="col col-name">{{ t.taskName }}</div>
<div class="col col-pre">{{ t.predecessor }}</div>
<div class="col col-assignee">{{ t.assignee }}</div>
<div class="col col-date">{{ t.startDate }}</div>
<div class="col col-date">{{ t.endDate }}</div>
<div class="col col-hours">{{ t.estimatedHours }}</div>
<div class="col col-hours">{{ t.actualHours }}</div>
<div class="col col-progress">{{ t.progress }}</div>
</div>
<div class="task-list-body" @scroll="handleTaskListScroll">
<TaskRow
v-for="task in localTasks"
:key="task.id"
:task="task"
:level="0"
:is-hovered="hoveredTaskId === task.id"
:hovered-task-id="hoveredTaskId"
:on-double-click="props.onTaskDoubleClick"
:on-hover="handleTaskRowHover"
@toggle="toggleCollapse"
@dblclick="handleTaskRowDoubleClick"
/>
</div>
</div>
</template>
<style scoped>
@import '../styles/theme-variables.css';
.task-list {
width: 100%;
height: 100%;
font-size: 15px;
color: var(--gantt-text-primary);
background: var(--gantt-bg-primary);
display: flex;
flex-direction: column;
overflow-x: auto; /* 防止内容溢出 */
/* Webkit浏览器滚动条样式 */
scrollbar-width: thin;
scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
}
.task-list-header {
display: flex;
background: var(--gantt-bg-secondary);
border-bottom: 1px solid var(--gantt-border-medium);
border-left: 3px solid transparent; /* 添加3px透明左边框保持对齐 */
font-weight: 700;
padding: 0;
height: 80px;
align-items: center;
width: max-content;
flex-shrink: 0; /* 防止header被压缩 */
position: sticky; /* 使header固定 */
top: 0;
z-index: 10; /* 确保在滚动时保持在最上层 */
}
.col {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
border-right: 1px solid var(--gantt-border-light);
box-sizing: border-box;
overflow: hidden;
font-weight: 400;
}
.task-list-header .col {
justify-content: center;
font-weight: 700;
background: var(--gantt-bg-secondary);
color: var(--gantt-text-header);
border-right-color: var(--gantt-border-medium);
}
.col:last-child {
border-right: none;
}
.col-name {
flex: 2 0 300px;
min-width: 300px;
justify-content: flex-start;
}
.col-pre {
flex: 1 0 120px;
min-width: 120px;
}
.col-assignee {
flex: 1 0 120px;
min-width: 120px;
}
.col-date {
flex: 1.2 0 140px;
min-width: 140px;
}
.col-hours {
flex: 1 0 100px;
min-width: 100px;
}
.col-progress {
flex: 1 0 100px;
min-width: 100px;
}
.task-list-body {
width: max-content;
background: var(--gantt-bg-primary);
flex: 1;
overflow-x: hidden; /* 让body部分可以滚动 */
overflow-y: auto; /* 允许垂直滚动 */
/* Webkit浏览器滚动条样式 */
scrollbar-width: thin;
scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
}
.task-list-body::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.task-list-body::-webkit-scrollbar-track {
background: transparent;
}
.task-list-body::-webkit-scrollbar-thumb {
background-color: var(--gantt-scrollbar-thumb);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
.task-list-body::-webkit-scrollbar-thumb:hover {
background-color: var(--gantt-scrollbar-thumb-hover);
}
.task-list-body::-webkit-scrollbar-corner {
background: transparent;
}
</style>

722
src/components/TaskRow.vue Normal file
View File

@@ -0,0 +1,722 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useI18n } from '../composables/useI18n'
import type { Task } from '../models/classes/Task'
interface Props {
task: Task
level: number
onDoubleClick?: (task: Task) => void
isHovered?: boolean
hoveredTaskId?: number | null
onHover?: (taskId: number | null) => void
}
const props = defineProps<Props>()
const emit = defineEmits(['toggle', 'dblclick'])
const { t } = useI18n()
const overtimeText = computed(() => t.value?.overtime ?? '')
const overdueText = computed(() => t.value?.overdue ?? '')
const daysText = computed(() => t.value?.days ?? '')
const baseIndent = 10
const indent = `${baseIndent + props.level * 20}px`
function handleToggle() {
emit('toggle', props.task)
}
function handleRowClick() {
// 如果是普通父级任务story类型或有子任务的任务非里程碑分组点击行也可以展开/收起
if (
(props.task.type === 'story' || (props.task.children && props.task.children.length > 0)) &&
props.task.type !== 'milestone-group'
) {
emit('toggle', props.task)
}
}
// 处理TaskRow双击事件 (与TaskBar逻辑保持一致)
const handleTaskRowDoubleClick = (e: MouseEvent) => {
// 阻止事件冒泡
e.stopPropagation()
// 优先调用外部传入的双击处理器
if (props.onDoubleClick && typeof props.onDoubleClick === 'function') {
props.onDoubleClick(props.task)
} else {
// 默认行为:发出双击事件给父组件
emit('dblclick', props.task)
}
}
// 处理悬停事件
const handleMouseEnter = () => {
// 如果正在拖拽Splitter忽略悬停事件
if (isSplitterDragging.value) return
if (props.onHover) {
props.onHover(props.task.id)
}
}
const handleMouseLeave = () => {
// 如果正在拖拽Splitter忽略悬停事件
if (isSplitterDragging.value) return
if (props.onHover) {
props.onHover(null)
}
}
// 获取进度值的样式类
function getProgressClass() {
const progress = props.task.progress || 0
const today = new Date()
const endDate = props.task.endDate ? new Date(props.task.endDate) : null
// 超期且未完成
if (endDate && today > endDate && progress < 100) {
return 'progress-danger'
}
// 已完成
if (progress >= 100) {
return 'progress-success'
}
// 进行中
if (progress > 0) {
return 'progress-warning'
}
return ''
}
// 检查是否超时
function isOvertime() {
return (
props.task.actualHours &&
props.task.estimatedHours &&
props.task.actualHours > props.task.estimatedHours
)
}
// 检查是否逾期,返回天数
function overdueDays() {
const today = new Date()
const endDate = props.task.endDate ? new Date(props.task.endDate) : null
const progress = props.task.progress || 0
if (endDate && today > endDate && progress < 100) {
// 只计算日期部分
const t = new Date(today.getFullYear(), today.getMonth(), today.getDate())
const e = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
const diff = Math.floor((t.getTime() - e.getTime()) / (1000 * 60 * 60 * 24))
return diff
}
return 0
}
// 拖拽状态管理
const isSplitterDragging = ref(false)
// 处理拖拽开始事件
const handleSplitterDragStart = () => {
isSplitterDragging.value = true
}
// 处理拖拽结束事件
const handleSplitterDragEnd = () => {
isSplitterDragging.value = false
}
// 生命周期钩子 - 注册事件监听器
onMounted(() => {
window.addEventListener('splitter-drag-start', handleSplitterDragStart)
window.addEventListener('splitter-drag-end', handleSplitterDragEnd)
})
onUnmounted(() => {
window.removeEventListener('splitter-drag-start', handleSplitterDragStart)
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd)
})
</script>
<template>
<div>
<div
class="task-row"
:class="{
'task-row-hovered': isHovered,
'parent-task':
props.task.type === 'story' ||
(props.task.children && props.task.children.length > 0) ||
props.task.type === 'milestone-group',
'milestone-group-row': props.task.type === 'milestone-group',
'task-type-story': props.task.type === 'story',
'task-type-task': props.task.type === 'task',
'task-type-milestone': props.task.type === 'milestone',
}"
@click="handleRowClick"
@dblclick="handleTaskRowDoubleClick"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div class="col col-name" :style="{ paddingLeft: indent }">
<span
v-if="
(props.task.type === 'story' ||
(props.task.children && props.task.children.length > 0)) &&
props.task.type !== 'milestone-group'
"
class="collapse-btn"
@click.stop="handleToggle"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline v-if="props.task.collapsed" points="9,18 15,12 9,6" />
<polyline v-else points="18,15 12,9 6,15" />
</svg>
</span>
<!-- 里程碑分组的占位空间,用于与有折叠按钮的任务对齐 -->
<span v-if="props.task.type === 'milestone-group'" class="milestone-spacer"></span>
<!-- 任务图标 -->
<span class="task-icon">
<!-- 里程碑分组图标 - 使用菱形图标 -->
<svg
v-if="props.task.type === 'milestone-group'"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="milestone-group-icon"
>
<polygon points="12,2 22,12 12,22 2,12" />
</svg>
<!-- 父级任务图标 -->
<svg
v-else-if="
props.task.type === 'story' || (props.task.children && props.task.children.length > 0)
"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
</svg>
<!-- 普通任务图标 -->
<svg
v-else
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14,2 14,8 20,8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10,9 9,9 8,9" />
</svg>
</span>
<span
class="task-name-text"
:class="{
'parent-task':
props.task.type === 'story' ||
(props.task.children && props.task.children.length > 0) ||
props.task.type === 'milestone-group',
}"
:title="props.task.name"
>
{{ props.task.name }}
<span v-if="isOvertime()" class="status-badge overtime">{{ overtimeText }}</span>
<span v-if="overdueDays() > 0" class="status-badge overdue">
{{ overdueText }}{{ overdueDays() > 0 ? overdueDays() + daysText : '' }}
</span>
</span>
</div>
<!-- 里程碑分组不显示详细信息 -->
<template v-if="props.task.type === 'milestone-group'">
<div class="col col-pre milestone-empty-col"></div>
<div class="col col-assignee milestone-empty-col"></div>
<div class="col col-date milestone-empty-col"></div>
<div class="col col-date milestone-empty-col"></div>
<div class="col col-hours milestone-empty-col"></div>
<div class="col col-hours milestone-empty-col"></div>
<div class="col col-progress milestone-empty-col"></div>
</template>
<!-- 普通任务显示详细信息 -->
<template v-else>
<div class="col col-pre">{{ props.task.predecessor || '-' }}</div>
<div class="col col-assignee">
<div class="assignee-info">
<div class="avatar">
{{ props.task.assignee ? props.task.assignee.charAt(0) : '-' }}
</div>
<span class="assignee-name">{{ props.task.assignee || '-' }}</span>
</div>
</div>
<div class="col col-date">{{ props.task.startDate || '-' }}</div>
<div class="col col-date">{{ props.task.endDate || '-' }}</div>
<div class="col col-hours">{{ props.task.estimatedHours || '-' }}</div>
<div class="col col-hours">{{ props.task.actualHours || '-' }}</div>
<div class="col col-progress">
<span class="progress-value" :class="getProgressClass()">
{{ props.task.progress != null ? props.task.progress + '%' : '-' }}
</span>
</div>
</template>
</div>
<template
v-if="props.task.children && !props.task.collapsed && props.task.type !== 'milestone-group'"
>
<TaskRow
v-for="child in props.task.children"
:key="child.id"
:task="child"
:level="props.level + 1"
:is-hovered="props.hoveredTaskId === child.id"
:hovered-task-id="props.hoveredTaskId"
:on-double-click="props.onDoubleClick"
:on-hover="props.onHover"
@toggle="emit('toggle', $event)"
@dblclick="emit('dblclick', $event)"
/>
</template>
</div>
</template>
<style scoped>
@import '../styles/theme-variables.css';
.task-row {
display: flex;
border-bottom: 1px solid var(--gantt-border-light);
height: 50px;
background: var(--gantt-bg-primary);
align-items: center;
color: var(--gantt-text-secondary);
cursor: pointer;
transition: all 0.3s ease;
transform: scale(1);
transform-origin: 5px center; /* 从左侧偏右5px的位置作为放大中心 */
z-index: 1;
position: relative;
}
.task-row:hover {
background-color: var(--gantt-bg-hover);
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10;
}
.task-row-hovered {
background-color: var(--gantt-bg-hover) !important;
transform: scale(1.02) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
z-index: 10 !important;
}
.task-row.parent-task {
background: var(--gantt-bg-tertiary);
font-weight: 600;
}
.task-row.parent-task:hover {
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover));
transform: scale(1.02);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
z-index: 10;
}
.task-row.parent-task.task-row-hovered {
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover)) !important;
transform: scale(1.02) !important;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2) !important;
z-index: 10 !important;
}
/* 里程碑分组行特殊样式 - 使用红色边框 */
.milestone-group-row {
border-left: 3px solid var(--gantt-danger, #f56c6c);
background: linear-gradient(90deg, var(--gantt-bg-tertiary) 0%, var(--gantt-bg-primary) 100%);
}
.milestone-group-row:hover {
background: linear-gradient(90deg, var(--gantt-bg-hover-parent) 0%, var(--gantt-bg-hover) 100%);
transform: scale(1.02);
box-shadow:
0 6px 16px rgba(245, 108, 108, 0.3),
0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 10;
border-left-color: var(--gantt-danger, #f56c6c);
border-left-width: 4px; /* 悬停时边框稍微加粗 */
}
/* 任务类型左边框颜色 */
.task-type-story {
border-left: 3px solid var(--gantt-primary, #409eff);
}
.task-type-task {
border-left: 3px solid var(--gantt-warning, #e6a23c);
}
.task-type-milestone {
border-left: 3px solid var(--gantt-danger, #f56c6c);
}
/* 任务类型悬停时保持并增强左边框 */
.task-type-story:hover {
border-left: 5px solid var(--gantt-primary, #409eff);
}
.task-type-task:hover {
border-left: 5px solid var(--gantt-warning, #e6a23c);
}
.task-type-milestone:hover {
border-left: 5px solid var(--gantt-danger, #f56c6c);
}
/* 悬停状态下的左边框保持 */
.task-row-hovered.task-type-story {
border-left: 5px solid var(--gantt-primary, #409eff) !important;
}
.task-row-hovered.task-type-task {
border-left: 5px solid var(--gantt-warning, #e6a23c) !important;
}
.task-row-hovered.task-type-milestone {
border-left: 5px solid var(--gantt-danger, #f56c6c) !important;
}
:global(html[data-theme='dark']) .milestone-group-row {
border-left-color: var(--gantt-danger, #f67c7c);
}
/* 暗黑模式下的任务类型左边框颜色 */
:global(html[data-theme='dark']) .task-type-story {
border-left-color: var(--gantt-primary, #7db4f0);
}
:global(html[data-theme='dark']) .task-type-task {
border-left-color: var(--gantt-warning, #f0b83c);
}
:global(html[data-theme='dark']) .task-type-milestone {
border-left-color: var(--gantt-danger, #f67c7c);
}
.collapse-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
cursor: pointer;
margin-right: 4px;
color: var(--gantt-primary);
border-radius: 2px;
transition: background-color 0.2s ease;
}
.collapse-btn:hover {
background-color: var(--gantt-primary-light);
}
.collapse-btn svg {
transition: transform 0.2s ease;
}
/* 里程碑分组占位空间 - 与折叠按钮对齐 */
.milestone-spacer {
display: inline-flex;
width: 18px;
height: 18px;
margin-right: 4px;
}
.col {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
border-right: 1px solid var(--gantt-border-light);
box-sizing: border-box;
overflow: hidden;
}
.col:last-child {
border-right: none;
}
.col-name {
flex: 2 0 300px;
min-width: 300px;
justify-content: flex-start;
}
.col-pre {
flex: 1 0 120px;
min-width: 120px;
}
.col-assignee {
flex: 1 0 120px;
min-width: 120px;
}
.col-date {
flex: 1.2 0 140px;
min-width: 140px;
}
.col-hours {
flex: 1 0 100px;
min-width: 100px;
}
.col-progress {
flex: 1 0 100px;
min-width: 100px;
}
.task-name-text {
display: inline-block;
max-width: calc(100% - 24px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.task-name-text.parent-task {
font-weight: bold;
color: var(--gantt-text-parent, var(--gantt-text-primary));
}
.task-icon {
margin-right: 4px;
color: var(--gantt-text-muted);
}
.task-icon svg {
vertical-align: middle;
}
.assignee-info {
display: flex;
align-items: center;
gap: 8px;
}
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--gantt-primary);
color: var(--gantt-text-white);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 500;
border: 2px solid var(--gantt-border-medium);
box-sizing: border-box;
}
.assignee-name {
font-size: 14px;
color: var(--gantt-text-secondary);
}
.progress-value {
font-weight: 500;
color: var(--gantt-text-secondary);
}
.progress-success {
color: var(--gantt-success);
}
.progress-warning {
color: var(--gantt-warning);
}
.progress-danger {
color: var(--gantt-danger);
}
.status-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 2px;
font-size: 10px;
font-weight: bold;
margin-left: 6px;
color: white;
}
.status-badge.overtime {
background-color: transparent;
border: 1px solid var(--gantt-danger);
color: var(--gantt-danger);
}
.status-badge.overdue {
background-color: var(--gantt-danger);
}
/* 里程碑分组图标样式 - 统一使用红色并添加发光效果 */
.milestone-group-icon {
color: var(--gantt-danger, #f56c6c);
fill: var(--gantt-danger, #f56c6c);
opacity: 0.9;
filter: drop-shadow(0 0 6px var(--gantt-danger, #f56c6c));
animation: milestone-icon-glow 2.5s ease-in-out infinite alternate;
}
/* 里程碑图标悬停效果 */
.task-row:hover .milestone-group-icon {
filter: drop-shadow(0 0 10px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 16px rgba(245, 108, 108, 0.4));
animation: milestone-icon-glow-intense 1.8s ease-in-out infinite alternate;
}
/* 里程碑图标发光动画 */
@keyframes milestone-icon-glow {
from {
filter: drop-shadow(0 0 3px var(--gantt-danger, #f56c6c));
}
to {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 12px rgba(245, 108, 108, 0.3));
}
}
@keyframes milestone-icon-glow-intense {
from {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 12px rgba(245, 108, 108, 0.3));
}
to {
filter: drop-shadow(0 0 12px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 20px rgba(245, 108, 108, 0.5));
}
}
/* 里程碑行样式 - 统一使用红色 */
.milestone-item-icon {
color: var(--gantt-danger, #f56c6c);
}
.milestone-empty-col {
color: var(--gantt-text-disabled, #c0c4cc);
/* 确保边框颜色与普通数据行一致 */
border-right-color: var(--gantt-border-light) !important;
}
.milestone-empty-col::after {
content: '-';
}
/* 暗黑模式适配 */
:global(html[data-theme='dark']) .milestone-row-icon {
color: var(--gantt-danger, #f67c7c);
}
/* 暗黑模式下的里程碑图标发光效果 */
:global(html[data-theme='dark']) .milestone-group-icon {
color: var(--gantt-danger, #f67c7c);
fill: var(--gantt-danger, #f67c7c);
filter: drop-shadow(0 0 6px var(--gantt-danger, #f67c7c));
animation: milestone-icon-glow-dark 2.5s ease-in-out infinite alternate;
}
:global(html[data-theme='dark']) .task-row:hover .milestone-group-icon {
filter: drop-shadow(0 0 10px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 16px rgba(246, 124, 124, 0.4));
animation: milestone-icon-glow-intense-dark 1.8s ease-in-out infinite alternate;
}
/* 暗黑模式发光动画 */
@keyframes milestone-icon-glow-dark {
from {
filter: drop-shadow(0 0 3px var(--gantt-danger, #f67c7c));
}
to {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 12px rgba(246, 124, 124, 0.3));
}
}
@keyframes milestone-icon-glow-intense-dark {
from {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 12px rgba(246, 124, 124, 0.3));
}
to {
filter: drop-shadow(0 0 12px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 20px rgba(246, 124, 124, 0.5));
}
}
:global(html[data-theme='dark']) .milestone-empty-col {
color: var(--gantt-text-disabled, #606266);
/* 确保暗黑模式下边框颜色与普通数据行一致 */
border-right-color: var(--gantt-border-light) !important;
}
/* 暗黑模式的悬停效果 */
:global(html[data-theme='dark']) .task-row:hover {
box-shadow:
0 4px 12px rgba(255, 255, 255, 0.1),
0 2px 8px rgba(0, 0, 0, 0.3);
}
:global(html[data-theme='dark']) .task-row.task-row-hovered {
background-color: var(--gantt-bg-hover) !important;
box-shadow:
0 4px 12px rgba(255, 255, 255, 0.1),
0 2px 8px rgba(0, 0, 0, 0.3) !important;
}
:global(html[data-theme='dark']) .task-row.parent-task:hover {
box-shadow:
0 6px 16px rgba(255, 255, 255, 0.15),
0 2px 8px rgba(0, 0, 0, 0.4);
}
:global(html[data-theme='dark']) .task-row.parent-task.task-row-hovered {
background: var(--gantt-bg-hover-parent) !important;
box-shadow:
0 6px 16px rgba(255, 255, 255, 0.15),
0 2px 8px rgba(0, 0, 0, 0.4) !important;
}
:global(html[data-theme='dark']) .milestone-group-row:hover {
box-shadow:
0 6px 16px rgba(246, 124, 124, 0.4),
0 2px 8px rgba(255, 255, 255, 0.1);
}
</style>

1500
src/components/Timeline.vue Normal file

File diff suppressed because it is too large Load Diff

10
src/components/index.ts Normal file
View File

@@ -0,0 +1,10 @@
export { default as GanttChart } from './GanttChart.vue'
export { default as GanttToolbar } from './GanttToolbar.vue'
export { default as TaskList } from './TaskList.vue'
export { default as TaskRow } from './TaskRow.vue'
export { default as Timeline } from './Timeline.vue'
export { default as TaskBar } from './TaskBar.vue'
export { default as MilestonePoint } from './MilestonePoint.vue'
export { default as MilestoneDialog } from './MilestoneDialog.vue'
export { default as TaskDrawer } from './TaskDrawer.vue'
export { default as DatePicker } from './DatePicker.vue'

319
src/composables/useI18n.ts Normal file
View File

@@ -0,0 +1,319 @@
import { ref, computed } from 'vue'
// 支持的语言类型
export type Locale = 'zh-CN' | 'en-US'
// 多语言配置
const messages = {
'zh-CN': {
// TaskList Header
taskName: '任务名称',
predecessor: '前置任务',
assignee: '分配人',
startDate: '开始日期',
endDate: '结束日期',
estimatedHours: '预计工时(hr)',
actualHours: '实际工时(hr)',
progress: '进度',
type: '类型',
description: '描述',
// CSV Export Headers去除重复仅保留引用
csvHeaders: {
id: 'ID',
taskName: '任务名称',
predecessor: '前置任务',
assignee: '分配人',
startDate: '开始日期',
endDate: '结束日期',
estimatedHours: '预计工时(hr)',
actualHours: '实际工时(hr)',
progress: '进度(%)',
type: '类型',
description: '描述',
},
// 日期格式
yearMonthFormat: (year: number, month: number) =>
`${year}${String(month).padStart(2, '0')}`,
// 其他
milestone: '里程碑',
today: '今天',
// 里程碑对话框
milestoneDetails: '里程碑详情',
milestoneName: '里程碑名称',
milestoneDate: '里程碑日期',
milestoneIcon: '里程碑图标',
diamond: '菱形',
rocket: '火箭',
enterMilestoneName: '请输入里程碑名称',
enterAssignee: '请输入负责人',
enterDescription: '请输入描述信息',
milestoneNameRequired: '里程碑名称为必填项',
milestoneDateRequired: '里程碑日期为必填项',
save: '保存',
close: '关闭',
confirm: '确认',
delete: '删除',
confirmDelete: '确定要删除这个里程碑吗?',
// 工具栏按钮
addTask: '新增需求/任务',
addMilestone: '新增里程碑',
todayLocate: '今日',
todayLocateTooltip: '定位到今天',
exportCsv: '导出 CSV',
exportPdf: '导出 PDF',
language: '中文',
languageTooltip: '选择语言',
lightMode: '明亮模式',
darkMode: '暗黑模式',
fullscreen: '全屏',
exitFullscreen: '退出全屏',
githubDocs: '查看Github文档',
giteeDocs: '查看Gitee文档',
// 确认对话框
confirmDialogMessage: '是否需要保留该设置?',
// 新建任务对话框
newTask: '新建任务',
taskNamePlaceholder: '请输入任务名称',
assigneePlaceholder: '请输入负责人',
progressPlaceholder: '0-100',
hoursPlaceholder: '工时',
descriptionPlaceholder: '请输入任务描述...',
hours: '小时',
create: '创建',
taskNameRequired: '任务名称不能为空',
startDateRequired: '开始日期不能为空',
endDateRequired: '结束日期不能为空',
endDateInvalid: '结束日期不能早于开始日期',
// 新建里程碑对话框
newMilestone: '新建里程碑',
editMilestone: '编辑里程碑',
cancel: '取消',
// PDF导出相关
pdfExportLoading: '正在生成PDF请稍候...',
pdfExportTitle: '甘特图导出',
pdfExportDate: '导出日期',
milestoneGroup: '里程碑',
collapseTaskList: '收起任务列表',
expandTaskList: '展开任务列表',
// TaskDrawer 相关
taskType: '任务类型',
taskTypeRequired: '请选择任务类型',
taskTypeMap: {
task: '任务',
milestone: '里程碑',
story: '需求',
epic: '史诗',
bug: '缺陷',
},
editTask: '编辑任务',
parentTask: '上级任务',
noParentTask: '无上级任务',
update: '更新',
taskNameTooLong: '任务名称不能超过50个字符',
predecessorPlaceholder: '请选择前置任务',
operationFailed: '操作失败,请重试',
taskUpdateSuccess: '任务更新成功',
taskCreateSuccess: '任务创建成功',
confirmDeleteTask: '确定要删除任务"{name}"吗?此操作不可撤销。',
taskDeleteSuccess: '任务删除成功',
taskDeleteFailed: '删除失败,请重试',
overtime: '超',
overdue: '逾期',
days: '天',
},
'en-US': {
// TaskList Header
taskName: 'Task Name',
predecessor: 'Predecessor',
assignee: 'Assignee',
startDate: 'Start Date',
endDate: 'End Date',
estimatedHours: 'Est. Hours',
actualHours: 'Act. Hours',
progress: 'Progress',
type: 'Type',
// CSV Export Headers去除重复仅保留引用
csvHeaders: {
id: 'ID',
taskName: 'Task Name',
predecessor: 'Predecessor',
assignee: 'Assignee',
startDate: 'Start Date',
endDate: 'End Date',
estimatedHours: 'Est. Hours',
actualHours: 'Act. Hours',
progress: 'Progress (%)',
type: 'Type',
description: 'Description',
},
// 日期格式
yearMonthFormat: (year: number, month: number) => `${year}/${String(month).padStart(2, '0')}`,
// 其他
milestone: 'Milestone',
today: 'Today',
// 里程碑对话框
milestoneDetails: 'Milestone Details',
milestoneName: 'Milestone Name',
milestoneDate: 'Milestone Date',
milestoneIcon: 'Milestone Icon',
diamond: 'Diamond',
rocket: 'Rocket',
enterMilestoneName: 'Enter milestone name',
enterAssignee: 'Enter assignee',
enterDescription: 'Enter description',
milestoneNameRequired: 'Milestone name is required',
milestoneDateRequired: 'Milestone date is required',
save: 'Save',
close: 'Close',
confirm: 'Confirm',
description: 'Description',
delete: 'Delete',
confirmDelete: 'Are you sure you want to delete this milestone?',
// 工具栏按钮
addTask: 'Add Requirement/Task',
addMilestone: 'Add Milestone',
todayLocate: 'Today',
todayLocateTooltip: 'Locate to today',
exportCsv: 'Export CSV',
exportPdf: 'Export PDF',
language: 'English',
languageTooltip: 'Select language',
lightMode: 'Light Mode',
darkMode: 'Dark Mode',
fullscreen: 'Fullscreen',
exitFullscreen: 'Exit Fullscreen',
githubDocs: 'GitHub Docs',
giteeDocs: 'Gitee Docs',
// Confirm dialog
confirmDialogMessage: 'Do you want to save this setting?',
taskNamePlaceholder: 'Enter task name',
assigneePlaceholder: 'Enter assignee',
progressPlaceholder: '0-100',
hoursPlaceholder: 'Hours',
descriptionPlaceholder: 'Enter task description...',
hours: 'Hours',
create: 'Create',
taskNameRequired: 'Task name is required',
startDateRequired: 'Start date is required',
endDateRequired: 'End date is required',
endDateInvalid: 'End date cannot be earlier than start date',
// 新建里程碑对话框
newMilestone: 'New Milestone',
editMilestone: 'Edit Milestone',
cancel: 'Cancel',
// PDF export
pdfExportLoading: 'Generating PDF, please wait...',
pdfExportTitle: 'Gantt Chart Export',
pdfExportDate: 'Export Date',
milestoneGroup: 'Milestone',
collapseTaskList: 'Collapse Task List',
expandTaskList: 'Expand Task List',
// TaskDrawer related
taskType: 'Task Type',
taskTypeRequired: 'Please select task type',
taskTypeMap: {
task: 'Task',
milestone: 'Milestone',
story: 'Story',
epic: 'Epic',
bug: 'Bug',
},
editTask: 'Edit Task',
newTask: 'New Task',
parentTask: 'Parent Task',
noParentTask: 'No Parent Task',
update: 'Update',
taskNameTooLong: 'Task name cannot exceed 50 characters',
predecessorPlaceholder: 'Select predecessor',
operationFailed: 'Operation failed, please try again',
taskUpdateSuccess: 'Task updated successfully',
taskCreateSuccess: 'Task created successfully',
confirmDeleteTask:
'Are you sure you want to delete task "{name}"? This action cannot be undone.',
taskDeleteSuccess: 'Task deleted successfully',
taskDeleteFailed: 'Delete failed, please try again',
overtime: 'Over',
overdue: 'Overdue',
days: ' days',
},
}
// 允许外部合并自定义多语言
export function setCustomMessages(locale: Locale, custom: Partial<(typeof messages)['zh-CN']>) {
if (!messages[locale]) return
Object.assign(messages[locale], custom)
}
// LocalStorage key
const LOCALE_STORAGE_KEY = 'gantt-locale'
// 从localStorage读取语言设置如果没有则使用默认值
const getInitialLocale = (): Locale => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY)
if (stored && (stored === 'zh-CN' || stored === 'en-US')) {
return stored as Locale
}
}
return 'zh-CN'
}
// 当前语言状态 - 从localStorage初始化
const currentLocale = ref<Locale>(getInitialLocale())
// 多语言 Hook
export function useI18n() {
// 获取当前语言的消息
const t = computed(() => {
return messages[currentLocale.value]
})
// 安全获取翻译文本的函数
const getTranslation = (key: string): string => {
const translation = t.value[key as keyof typeof t.value]
return typeof translation === 'string' ? translation : key
}
// 切换语言
const setLocale = (locale: Locale) => {
currentLocale.value = locale
// 触发全局事件,通知其他组件语言已切换
window.dispatchEvent(
new CustomEvent('locale-changed', {
detail: { locale },
}),
)
}
// 获取当前语言
const locale = computed(() => currentLocale.value)
// 格式化年月
const formatYearMonth = (year: number, month: number) => {
return t.value.yearMonthFormat(year, month)
}
return {
t,
getTranslation,
locale,
setLocale,
formatYearMonth,
}
}
// 导出当前语言状态,供其他模块使用
export { currentLocale }
export { messages }
// 为了类型推断,导出 Messages 类型
export type Messages = typeof messages

24
src/index.ts Normal file
View File

@@ -0,0 +1,24 @@
// 导出所有组件
export { default as GanttChart } from './components/GanttChart.vue'
export { default as TaskList } from './components/TaskList.vue'
export { default as Timeline } from './components/Timeline.vue'
export { default as TaskBar } from './components/TaskBar.vue'
export { default as TaskDrawer } from './components/TaskDrawer.vue'
export { default as MilestonePoint } from './components/MilestonePoint.vue'
export { default as TaskRow } from './components/TaskRow.vue'
export type { Task } from './models/classes/Task.ts' // 导出Task类型
// 导出样式文件
import './styles/theme-variables.css'
// 导出安装函数可选用于Vue.use()
import type { App } from 'vue'
import GanttChart from './components/GanttChart.vue'
export const install = (app: App) => {
app.component('GanttChart', GanttChart)
}
export default {
install,
}

0
src/models/GanttTypes.ts Normal file
View File

View File

@@ -0,0 +1,2 @@
// Language 类型定义
export type Language = 'zh' | 'en'

View File

@@ -0,0 +1,11 @@
// Milestone 类型定义
export interface Milestone {
id?: number
name: string
startDate: string
endDate?: string
assignee?: string
type: string
icon?: string
description?: string
}

View File

@@ -0,0 +1,20 @@
// Task 类型定义
export interface Task {
id: number
name: string
predecessor?: string
assignee?: string
startDate?: string
endDate?: string
progress?: number
estimatedHours?: number
actualHours?: number
parentId?: number // 上级任务ID
children?: Task[]
collapsed?: boolean
isParent?: boolean
type?: string
description?: string
icon?: string
level?: number
}

View File

@@ -0,0 +1,6 @@
// TimelineConfig 类型定义
export interface TimelineConfig {
startDate: Date
endDate: Date
zoomLevel: number
}

View File

@@ -0,0 +1,11 @@
// ToolbarConfig 类型定义
export interface ToolbarConfig {
showAddTask?: boolean
showAddMilestone?: boolean
showTodayLocate?: boolean
showExportCsv?: boolean
showExportPdf?: boolean
showLanguage?: boolean
showTheme?: boolean
showFullscreen?: boolean
}

67
src/styles/app.css Normal file
View File

@@ -0,0 +1,67 @@
/* 通用 .btn 基础按钮样式 */
.btn {
padding: 10px 20px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: 1px solid;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
outline: none;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* 通用 .btn-default 按钮样式(亮色+暗黑模式) */
.btn-default {
background: var(--gantt-bg-secondary, #f5f7fa);
border-color: var(--gantt-border-medium, #dcdfe6);
color: var(--gantt-text-secondary, #8d9095);
}
.btn-default:hover:not(:disabled) {
border-color: var(--gantt-border-dark, #c0c4cc);
color: var(--gantt-primary, #409eff);
background: var(--gantt-bg-hover, #f0f1f3);
}
/* 暗黑主题下 .btn-default 全局样式 */
:global(html[data-theme='dark']) .btn-default {
background: var(--gantt-bg-tertiary, #454545) !important;
border-color: var(--gantt-border-color, #dcdfe6) !important;
color: var(--gantt-text-white, #ffffff) !important;
}
:global(html[data-theme='dark']) .btn-default:hover:not(:disabled) {
background: var(--gantt-bg-hover, rgba(255, 255, 255, 0.1)) !important;
border-color: var(--gantt-primary, #409eff) !important;
color: var(--gantt-primary, #409eff) !important;
}
/* 通用 .btn-primary 按钮样式 */
.btn-primary {
background: var(--gantt-primary, #409eff);
border-color: var(--gantt-primary, #409eff);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: #66b1ff;
border-color: #66b1ff;
}
/* 通用 .btn-danger 按钮样式 */
.btn-danger {
background: var(--gantt-danger, #f56c6c);
border-color: var(--gantt-danger, #f56c6c);
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #f78989;
border-color: #f78989;
}

View File

@@ -0,0 +1,76 @@
/* 甘特图组件主题变量 */
/* 明亮主题 */
:root {
/* 背景色 - 原始的浅色设计 */
--gantt-bg-primary: #ffffff; /* 最浅背景 - body主体 */
--gantt-bg-secondary: #f5f7fa; /* Header背景 - 浅色 */
--gantt-bg-tertiary: #f9f9f9; /* 三级背景 - 父任务 */
--gantt-bg-hover: rgba(225, 228, 231, 0.2); /* 悬停背景 - 普通任务,增加透明度 */
--gantt-bg-hover-parent: rgba(245, 247, 250, 0.8); /* 悬停背景 - 父任务,稍高透明度 */
--gantt-bg-toolbar: #f8f9fa; /* 工具栏背景 - 中性浅色 */
/* 文字颜色 */
--gantt-text-primary: #333333;
--gantt-text-secondary: #8d9095;
--gantt-text-muted: #909399;
--gantt-text-white: #ffffff;
--gantt-text-header: #333333; /* Header文字颜色 - 明亮模式下Header为浅色背景使用深色文字 */
/* 边框颜色 */
--gantt-border-light: #ebeef5;
--gantt-border-medium: #dcdfe6; /* Header边框 - 与浅色Header搭配 */
--gantt-border-dark: #c0c4cc;
--gantt-border-color: #dcdfe6; /* 通用边框色,与按钮边框一致 */
/* 强调色 */
--gantt-primary: #409eff;
--gantt-primary-light: #ecf5ff;
--gantt-success: #67c23a;
--gantt-warning: #e6a23c;
--gantt-warning-light: #f5dab1;
--gantt-danger: #f56c6c;
--gantt-danger-light: #fab6b6; /* 里程碑边框浅红色 */
/* 滚动条 */
--gantt-scrollbar-thumb: #c1c1c1;
--gantt-scrollbar-thumb-hover: #a8a8a8;
}
/* 暗黑主题 */
html[data-theme='dark'] {
/* 背景色 - 大幅提升亮度,接近中性灰 */
--gantt-bg-primary: #6b6b6b; /* Body主体 - 明显更亮,接近中性灰 */
--gantt-bg-secondary: #4b4b4b; /* Header背景 - 中等亮度,保持层次 */
--gantt-bg-tertiary: #7b7b7b; /* 三级背景 - 父任务,最亮的背景 */
--gantt-bg-hover: rgba(180, 180, 180, 0.35); /* 悬停背景 - 明亮的悬停效果 */
--gantt-bg-hover-parent: rgba(140, 140, 140, 0.8); /* 悬停背景 - 父任务 */
--gantt-bg-toolbar: #5b5b5b; /* 工具栏背景 - 与Body背景保持协调 */
/* 文字颜色 - 最大化对比度,确保清晰可读 */
--gantt-text-primary: #ffffff; /* 主要文字纯白 */
--gantt-text-secondary: #f8f8f8; /* 次要文字接近纯白 */
--gantt-text-muted: #e0e0e0; /* 弱化文字保持高可读性 */
--gantt-text-white: #ffffff; /* 纯白 */
--gantt-text-parent: #ffffff; /* 父任务文字纯白 */
--gantt-text-header: #ffffff; /* Header文字纯白 */
/* 边框颜色 - 高对比度分界线 */
--gantt-border-light: #888888; /* body内边框 - 高可见性 */
--gantt-border-medium: #666666; /* header边框 - 中等对比 */
--gantt-border-dark: #999999; /* 强调边框 - 最清晰 */
--gantt-border-color: #808080; /* 通用边框色,高可见性 */
/* 强调色 - 鲜艳醒目,在亮灰背景上突出 */
--gantt-primary: #3399ff; /* 鲜艳的蓝色,在亮灰背景上突出 */
--gantt-primary-light: #4d6699; /* 深蓝背景 */
--gantt-success: #66cc33; /* 鲜艳的绿色 */
--gantt-warning: #ff9933; /* 鲜艳的橙色 */
--gantt-warning-light: #cc7722; /* 深橙背景 */
--gantt-danger: #ff4444; /* 鲜艳的红色 */
--gantt-danger-light: #cc3333; /* 深红背景,里程碑边框 */
/* 滚动条 - 高可见性 */
--gantt-scrollbar-thumb: #888888; /* 明亮的滚动条 */
--gantt-scrollbar-thumb-hover: #999999; /* 悬停时更亮 */
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
tsconfig.app.json Normal file
View File

@@ -0,0 +1,21 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Performance optimizations */
"skipLibCheck": true,
"incremental": true,
"assumeChangesOnlyAffectDirectDependencies": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

4
tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

25
tsconfig.node.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

24
vite.config.lib.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: './src/index.ts',
name: 'JordiumGanttVue3',
fileName: format => `jordium-gantt-vue3.${format}.js`,
},
rollupOptions: {
// 确保外部化处理那些你不想打包进库的依赖
external: ['vue'],
output: {
// 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
globals: {
vue: 'Vue',
},
},
},
},
})

20
vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
// 开发服务器配置
root: 'demo',
// 构建配置
build: {
outDir: '../dist',
emptyOutDir: true,
},
// 路径别名
resolve: {
alias: {
'@': '../src',
},
},
})