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
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
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
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
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
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
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
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
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
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
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
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
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
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
View File
@@ -0,0 +1,3 @@
# README.md
This file has been deprecated. Please refer to the main project `README.md` for documentation.
+335
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
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
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
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
+12
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
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
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
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
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
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
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
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
View File
File diff suppressed because it is too large Load Diff
+39
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
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
+84
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
+773
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>
+343
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
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>
+761
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, //
)
})
// storytask
const availableParentTasks = computed(() => {
return allTasks.value
.filter(
task =>
task.id !== props.task?.id && //
(task.type === 'story' || task.type === 'task'), // storytask
)
.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
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
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>
File diff suppressed because it is too large Load Diff
+10
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
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
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,
}
View File
+2
View File
@@ -0,0 +1,2 @@
// Language 类型定义
export type Language = 'zh' | 'en'
+11
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
}
+20
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
}
+6
View File
@@ -0,0 +1,6 @@
// TimelineConfig 类型定义
export interface TimelineConfig {
startDate: Date
endDate: Date
zoomLevel: number
}
+11
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
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;
}
+76
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
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
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
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+25
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
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
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',
},
},
})