init
This commit is contained in:
24
.editorconfig
Normal file
24
.editorconfig
Normal file
@@ -0,0 +1,24 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{json,yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.vue]
|
||||
indent_size = 2
|
||||
|
||||
[*.{js,ts,tsx,jsx}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{css,scss,sass,less}]
|
||||
indent_size = 2
|
||||
31
.eslintignore
Normal file
31
.eslintignore
Normal file
@@ -0,0 +1,31 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
dist-ssr/
|
||||
coverage/
|
||||
|
||||
# Generated files
|
||||
*.d.ts
|
||||
.tmp/
|
||||
.cache/
|
||||
.vscode/
|
||||
.git/
|
||||
|
||||
# Config files
|
||||
*.config.js
|
||||
*.config.ts
|
||||
vite.config.*.ts
|
||||
.eslintrc.cjs
|
||||
|
||||
# Test files
|
||||
**/*.test.ts
|
||||
**/*.spec.ts
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
144
.eslintrc.cjs
Normal file
144
.eslintrc.cjs
Normal file
@@ -0,0 +1,144 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:vue/vue3-essential',
|
||||
'plugin:vue/vue3-strongly-recommended',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'@vue/eslint-config-typescript',
|
||||
'@vue/eslint-config-prettier/skip-formatting',
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
files: ['.eslintrc.{js,cjs}'],
|
||||
parserOptions: {
|
||||
sourceType: 'script',
|
||||
},
|
||||
},
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
parser: '@typescript-eslint/parser',
|
||||
sourceType: 'module',
|
||||
// 性能优化:禁用 type checking
|
||||
project: false,
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'vue'],
|
||||
ignorePatterns: [
|
||||
'dist',
|
||||
'node_modules',
|
||||
'*.d.ts',
|
||||
'.eslintrc.cjs',
|
||||
'vite.config.*.ts',
|
||||
'**/*.test.ts',
|
||||
'**/*.spec.ts',
|
||||
'coverage',
|
||||
'.tmp',
|
||||
'.cache',
|
||||
],
|
||||
rules: {
|
||||
// Vue.js 规则
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-unused-vars': 'error',
|
||||
'vue/no-mutating-props': 'error',
|
||||
'vue/require-default-prop': 'error',
|
||||
'vue/require-prop-types': 'error',
|
||||
'vue/prefer-import-from-vue': 'error',
|
||||
'vue/component-tags-order': [
|
||||
'error',
|
||||
{
|
||||
order: ['script', 'template', 'style'],
|
||||
},
|
||||
],
|
||||
'vue/block-tag-newline': [
|
||||
'error',
|
||||
{
|
||||
singleline: 'always',
|
||||
multiline: 'always',
|
||||
},
|
||||
],
|
||||
'vue/component-name-in-template-casing': [
|
||||
'error',
|
||||
'PascalCase',
|
||||
{
|
||||
registeredComponentsOnly: true,
|
||||
},
|
||||
],
|
||||
'vue/define-macros-order': [
|
||||
'error',
|
||||
{
|
||||
order: ['defineOptions', 'defineProps', 'defineEmits', 'defineSlots'],
|
||||
},
|
||||
],
|
||||
|
||||
// TypeScript 规则
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'warn',
|
||||
'@typescript-eslint/no-inferrable-types': 'error',
|
||||
// '@typescript-eslint/prefer-const': 'error', // 与 prefer-const 冲突,移除
|
||||
'@typescript-eslint/no-var-requires': 'error',
|
||||
|
||||
// 一般规则
|
||||
'no-console': 'warn',
|
||||
'no-debugger': 'error',
|
||||
'no-alert': 'warn',
|
||||
'no-var': 'error',
|
||||
'prefer-const': 'error',
|
||||
'object-shorthand': 'error',
|
||||
'prefer-template': 'error',
|
||||
'template-curly-spacing': 'error',
|
||||
'arrow-spacing': 'error',
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
semi: ['error', 'never'],
|
||||
quotes: ['error', 'single'],
|
||||
indent: ['error', 2],
|
||||
'max-len': [
|
||||
'error',
|
||||
{
|
||||
code: 100,
|
||||
ignoreUrls: true,
|
||||
ignoreStrings: true,
|
||||
ignoreTemplateLiterals: true,
|
||||
ignoreRegExpLiterals: true,
|
||||
ignorePattern: 'd="[^"]*"', // 忽略SVG路径定义
|
||||
},
|
||||
],
|
||||
'eol-last': 'error',
|
||||
'no-trailing-spaces': 'error',
|
||||
'space-before-function-paren': [
|
||||
'error',
|
||||
{
|
||||
anonymous: 'always',
|
||||
named: 'never',
|
||||
asyncArrow: 'always',
|
||||
},
|
||||
],
|
||||
'keyword-spacing': 'error',
|
||||
'space-infix-ops': 'error',
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'array-bracket-spacing': ['error', 'never'],
|
||||
'computed-property-spacing': ['error', 'never'],
|
||||
'func-call-spacing': ['error', 'never'],
|
||||
'key-spacing': ['error', { beforeColon: false, afterColon: true }],
|
||||
'comma-spacing': ['error', { before: false, after: true }],
|
||||
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }],
|
||||
'padded-blocks': ['error', 'never'],
|
||||
'space-before-blocks': 'error',
|
||||
'brace-style': ['error', '1tbs', { allowSingleLine: true }],
|
||||
},
|
||||
|
||||
// 忽略特定文件或目录
|
||||
ignorePatterns: ['dist/', 'node_modules/', '*.d.ts', 'vite.config.*.ts', 'tsconfig.*.json'],
|
||||
}
|
||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Performance optimization
|
||||
.tmp/
|
||||
.cache/
|
||||
.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
.eslintcache
|
||||
|
||||
# OS generated files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
19
.prettierignore
Normal file
19
.prettierignore
Normal file
@@ -0,0 +1,19 @@
|
||||
dist
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.vite
|
||||
.next
|
||||
coverage
|
||||
*.min.js
|
||||
*.min.css
|
||||
*.bundle.js
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
*.md
|
||||
CHANGELOG*
|
||||
LICENSE*
|
||||
.gitignore
|
||||
.eslintignore
|
||||
.prettierignore
|
||||
20
.prettierrc
Normal file
20
.prettierrc
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"jsxSingleQuote": true,
|
||||
"proseWrap": "preserve",
|
||||
"quoteProps": "as-needed",
|
||||
"requirePragma": false,
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"embeddedLanguageFormatting": "auto"
|
||||
}
|
||||
11
.vscode/extensions.json
vendored
Normal file
11
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"Vue.vscode-typescript-vue-plugin",
|
||||
"esbenp.prettier-vscode",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"formulahendry.auto-rename-tag",
|
||||
"christian-kohler.path-intellisense",
|
||||
"ms-vscode.vscode-typescript-next"
|
||||
]
|
||||
}
|
||||
15
.vscode/settings.json
vendored
Normal file
15
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"typescript.preferences.includePackageJsonAutoImports": "off",
|
||||
"typescript.suggest.autoImports": false,
|
||||
"typescript.disableAutomaticTypeAcquisition": true,
|
||||
"typescript.validate.enable": false,
|
||||
"javascript.validate.enable": false,
|
||||
"vetur.validation.template": false,
|
||||
"vetur.validation.script": false,
|
||||
"vetur.validation.style": false,
|
||||
"files.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/dist": true,
|
||||
"**/.git": true
|
||||
}
|
||||
}
|
||||
26
API_USAGE.md
Normal file
26
API_USAGE.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# API_USAGE
|
||||
|
||||
## 组件导出
|
||||
```js
|
||||
import { GanttChart, TaskList, TaskRow, MilestonePoint, GanttToolbar, GanttConfirmDialog } from 'jordium-gantt-vue3'
|
||||
```
|
||||
|
||||
## 主要 Props 说明
|
||||
- `tasks`: 任务数据数组
|
||||
- `milestones`: 里程碑数据数组
|
||||
- `onDelete`: 删除回调,已统一弹窗交互
|
||||
- `theme`: 主题切换(如 'light'/'dark')
|
||||
- 其它详见各组件 props 类型定义
|
||||
|
||||
## 事件
|
||||
- `@delete`:删除任务/里程碑
|
||||
- `@update`:任务/里程碑更新
|
||||
- `@confirm`/`@cancel`:弹窗交互
|
||||
|
||||
## 样式与主题
|
||||
- 全局按钮、弹窗、表格等样式统一于 `src/styles/app.css`
|
||||
- 主题变量见 `src/styles/theme-variables.css`
|
||||
|
||||
## 扩展
|
||||
- 支持自定义工具栏、弹窗内容、国际化
|
||||
- 详细用法见 README.md
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 JORDIUM.COM
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
88
README.en.md
Normal file
88
README.en.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# <svg width="28" height="28" viewBox="0 0 24 24" style="vertical-align:middle;"><rect x="2" y="4" width="20" height="2" rx="1" fill="#409eff"/><rect x="2" y="8" width="12" height="2" rx="1" fill="#67c23a"/><rect x="2" y="12" width="16" height="2" rx="1" fill="#e6a23c"/><rect x="2" y="16" width="8" height="2" rx="1" fill="#f56c6c"/><rect x="2" y="20" width="14" height="2" rx="1" fill="#909399"/><circle cx="22" cy="5" r="1" fill="#409eff"/><circle cx="16" cy="9" r="1" fill="#67c23a"/><circle cx="20" cy="13" r="1" fill="#e6a23c"/><circle cx="12" cy="17" r="1" fill="#f56c6c"/><circle cx="18" cy="21" r="1" fill="#909399"/></svg> Jordium Gantt Vue3
|
||||
|
||||
> Modern, open-source, and high-performance Gantt chart component library for Vue3 + TypeScript
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Product Positioning
|
||||
|
||||
Jordium Gantt Vue3 is a high-performance Gantt chart component library for modern web applications, focusing on excellent interaction, flexible extension, and open-source compliance. Ideal for project management, progress visualization, R&D collaboration, and more.
|
||||
|
||||
- **Tech Stack**: Vue 3 + TypeScript
|
||||
- **Design Language**: Element Plus style (no dependency)
|
||||
- **Use Cases**: Enterprise project management, scheduling, resource allocation, etc.
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
- **Modern UI/UX**: Minimalist, clean, responsive, supports light/dark themes, auto system adaptation
|
||||
- **High Performance**: Virtual scrolling, auto-expanding timeline, smooth with large datasets
|
||||
- **Task Management**: Multi-level tasks, milestones, drag & resize, progress, dependencies
|
||||
- **Enhanced Interaction**: Today locator, fullscreen, version history, toolbar, quick actions
|
||||
- **Internationalization**: Built-in Chinese/English, easy to extend to more languages
|
||||
- **Flexible Extension**: Custom editors, event handling, data sources, export, etc.
|
||||
- **Open Source Compliance**: MIT License, suitable for secondary development and commercial use
|
||||
|
||||
## 🖌️ Design Philosophy
|
||||
|
||||
- **Ultimate Experience**: Interaction details match top-tier products, smooth animation, positioning, and theme switching
|
||||
- **No External Dependencies**: Only Vue3/TS required, Element Plus style but zero dependency
|
||||
- **Easy to Use & Extend**: Friendly API, supports slots, events, and deep prop customization
|
||||
- **Ready to Use**: Comprehensive documentation, easy integration
|
||||
|
||||
## 📦 Installation & Usage
|
||||
|
||||
```bash
|
||||
npm install jordium-gantt-vue3
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<GanttChart :tasks="tasks" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GanttChart from 'jordium-gantt-vue3'
|
||||
const tasks = [/* ...task data... */]
|
||||
</script>
|
||||
```
|
||||
|
||||
### Advanced Usage
|
||||
|
||||
- Custom task editor (disable default drawer, handle double-click events)
|
||||
- Custom toolbar buttons, export, theme switching
|
||||
- Listen to task/milestone changes, drag, dependency events
|
||||
- Support for external data sources, async loading
|
||||
|
||||
See [API_USAGE.md](./API_USAGE.md) for details.
|
||||
|
||||
## 🎨 Theming & Adaptation
|
||||
|
||||
- Supports light/dark themes, auto system detection
|
||||
- Theme variables customizable for easy brand adaptation
|
||||
- Fully responsive, works on desktop and mobile
|
||||
|
||||
## 📝 Version History
|
||||
|
||||
See [version-history.json](./demo/version-history.json) for details.
|
||||
|
||||
- **0.9.x-ALPHA**: Productization refactor, full interaction & visual upgrade, MIT License
|
||||
- **0.2 Beta**: API improvements, tasks/milestones/dependencies/toolbar/i18n/theme
|
||||
- **0.1 Beta**: Basic features, Element Plus style
|
||||
|
||||
## 📚 API Documentation
|
||||
|
||||
See [API_USAGE.md](./API_USAGE.md) for full API reference.
|
||||
|
||||
## 🤝 Contribution & Community
|
||||
|
||||
Issues, PRs, suggestions, and stars are welcome! For custom development, enterprise support, or bug reports, contact [jordium.com](https://jordium.com).
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License © Jordium
|
||||
|
||||
# README.en.md
|
||||
|
||||
This file has been deprecated. Please refer to `README.md` for the latest documentation.
|
||||
35
README.md
Normal file
35
README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# jordium-gantt-vue3
|
||||
|
||||
Vue3 甘特图/Gantt 组件库,支持任务、里程碑、虚拟滚动、主题切换、弹窗交互、全局样式共通化等。
|
||||
|
||||
## 特性
|
||||
- 任务/里程碑可视化、可编辑
|
||||
- 虚拟滚动与性能优化
|
||||
- 全局样式统一与主题变量
|
||||
- 删除确认弹窗统一美化
|
||||
- 组件/类型解耦,命名规范
|
||||
- 支持多语言与自定义扩展
|
||||
|
||||
## 快速开始
|
||||
```bash
|
||||
npm install jordium-gantt-vue3
|
||||
```
|
||||
|
||||
```js
|
||||
import { GanttChart } from 'jordium-gantt-vue3'
|
||||
```
|
||||
|
||||
## 主要组件
|
||||
- GanttChart
|
||||
- TaskList / TaskRow
|
||||
- MilestonePoint
|
||||
- GanttToolbar
|
||||
- GanttConfirmDialog
|
||||
|
||||
## 贡献与开发
|
||||
- 统一样式请参考 `src/styles/app.css`
|
||||
- 组件开发请遵循解耦、命名规范
|
||||
- 详细API见 API_USAGE.md
|
||||
|
||||
## License
|
||||
MIT
|
||||
775
demo/App.vue
Normal file
775
demo/App.vue
Normal file
@@ -0,0 +1,775 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import GanttChart from '../src/components/GanttChart.vue'
|
||||
import TaskDrawer from '../src/components/TaskDrawer.vue'
|
||||
import MilestoneDialog from '../src/components/MilestoneDialog.vue'
|
||||
import demoData from './data.json'
|
||||
import packageInfo from '../package.json'
|
||||
// 导入主题变量
|
||||
import '../src/styles/theme-variables.css'
|
||||
import VersionHistoryDrawer from './VersionHistoryDrawer.vue'
|
||||
|
||||
interface Task {
|
||||
id: number
|
||||
name: string
|
||||
predecessor?: string
|
||||
assignee?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
progress?: number
|
||||
estimatedHours?: number
|
||||
actualHours?: number
|
||||
children?: Task[]
|
||||
collapsed?: boolean
|
||||
isParent?: boolean
|
||||
type?: string
|
||||
description?: string
|
||||
parentId?: number // 上级任务ID
|
||||
}
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const milestones = ref<Task[]>([])
|
||||
|
||||
// TaskDrawer状态管理
|
||||
const showTaskDrawer = ref(false)
|
||||
const currentTask = ref<Task | null>(null)
|
||||
const isEditMode = ref(false)
|
||||
|
||||
// MilestoneDialog状态管理
|
||||
const showMilestoneDialog = ref(false)
|
||||
const currentMilestone = ref<Task | null>(null)
|
||||
const isMilestoneEditMode = ref(false)
|
||||
|
||||
// 版本历史Drawer状态
|
||||
const showVersionDrawer = ref(false)
|
||||
|
||||
const toolbarConfig = {
|
||||
showAddTask: true,
|
||||
showAddMilestone: true,
|
||||
showTodayLocate: true,
|
||||
showExportCsv: true,
|
||||
showExportPdf: true,
|
||||
showLanguage: true,
|
||||
showTheme: true,
|
||||
showFullscreen: true,
|
||||
}
|
||||
|
||||
// 自定义CSV导出处理器(可选)
|
||||
const handleCustomCsvExport = () => {
|
||||
console.log('自定义CSV导出被调用')
|
||||
console.log('当前任务数据:', tasks.value)
|
||||
console.log('当前里程碑数据:', milestones.value)
|
||||
|
||||
// 这里可以实现自定义的CSV导出逻辑
|
||||
// 例如:添加额外的数据处理、格式化、或发送到服务器等
|
||||
|
||||
// 如果不实现自定义逻辑,组件会使用内置的默认导出功能
|
||||
// return false // 返回false让组件使用默认实现
|
||||
|
||||
// 示例:这里直接使用默认实现
|
||||
return false
|
||||
}
|
||||
|
||||
// 其他工具栏事件处理器示例
|
||||
const handleAddTask = () => {
|
||||
// 打开TaskDrawer进行新建任务
|
||||
currentTask.value = null
|
||||
isEditMode.value = false
|
||||
showTaskDrawer.value = true
|
||||
}
|
||||
|
||||
const handleAddMilestone = () => {
|
||||
// 打开MilestoneDialog进行新建里程碑
|
||||
currentMilestone.value = null
|
||||
isMilestoneEditMode.value = false
|
||||
showMilestoneDialog.value = true
|
||||
}
|
||||
|
||||
const handleLanguageChange = (lang: 'zh' | 'en') => {
|
||||
console.log('语言切换到:', lang)
|
||||
}
|
||||
|
||||
const handleThemeChange = (isDark: boolean) => {
|
||||
console.log('主题切换到:', isDark ? '暗黑模式' : '明亮模式')
|
||||
}
|
||||
|
||||
// 里程碑保存处理器示例
|
||||
const handleMilestoneSave = (milestone: Task) => {
|
||||
// 更新本地里程碑数据
|
||||
const milestoneIndex = milestones.value.findIndex(m => m.id === milestone.id)
|
||||
if (milestoneIndex !== -1) {
|
||||
// 更新现有里程碑
|
||||
milestones.value[milestoneIndex] = { ...milestone }
|
||||
} else {
|
||||
// 新增里程碑
|
||||
const newMilestone = {
|
||||
...milestone,
|
||||
id: Date.now(), // 生成临时ID
|
||||
type: 'milestone',
|
||||
}
|
||||
milestones.value.push(newMilestone)
|
||||
}
|
||||
|
||||
// 关闭里程碑对话框
|
||||
showMilestoneDialog.value = false
|
||||
}
|
||||
|
||||
// 里程碑删除处理器
|
||||
const handleMilestoneDelete = async (milestoneId: number) => {
|
||||
console.log('删除里程碑,ID:', milestoneId)
|
||||
|
||||
// 从里程碑数据中删除
|
||||
const milestoneIndex = milestones.value.findIndex(m => m.id === milestoneId)
|
||||
if (milestoneIndex !== -1) {
|
||||
milestones.value.splice(milestoneIndex, 1)
|
||||
console.log('里程碑删除成功')
|
||||
|
||||
// 等待DOM更新完成
|
||||
await nextTick()
|
||||
|
||||
// 触发全局事件,通知其他组件里程碑已删除
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('milestone-deleted', {
|
||||
detail: { milestoneId },
|
||||
}),
|
||||
)
|
||||
|
||||
// 触发强制更新事件,确保Timeline重新渲染
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('milestone-data-changed', {
|
||||
detail: { milestones: milestones.value },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 关闭里程碑对话框
|
||||
showMilestoneDialog.value = false
|
||||
}
|
||||
|
||||
// 任务更新处理器
|
||||
const handleTaskUpdate = (updatedTask: Task) => {
|
||||
console.log('Demo App 接收到任务更新:', updatedTask)
|
||||
console.log('updatedTask.type:', updatedTask.type)
|
||||
console.log('updatedTask.parentId:', updatedTask.parentId)
|
||||
|
||||
// 先找到原任务,检查parentId是否改变了
|
||||
const findOriginalTask = (taskArray: Task[]): Task | null => {
|
||||
for (const task of taskArray) {
|
||||
if (task.id === updatedTask.id) {
|
||||
return task
|
||||
}
|
||||
if (task.children && task.children.length > 0) {
|
||||
const found = findOriginalTask(task.children)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const originalTask = findOriginalTask(tasks.value)
|
||||
if (!originalTask) {
|
||||
console.log('未找到要更新的任务,ID:', updatedTask.id)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查parentId是否改变了
|
||||
const parentIdChanged = originalTask.parentId !== updatedTask.parentId
|
||||
|
||||
if (parentIdChanged) {
|
||||
// parentId改变了,需要移除任务并重新添加到新位置
|
||||
console.log('任务父级关系改变,执行移动操作')
|
||||
|
||||
const removeTaskFromArray = (taskArray: Task[]): Task | null => {
|
||||
for (let i = 0; i < taskArray.length; i++) {
|
||||
if (taskArray[i].id === updatedTask.id) {
|
||||
const removedTask = taskArray.splice(i, 1)[0]
|
||||
console.log('从数组中移除任务:', removedTask)
|
||||
return removedTask
|
||||
}
|
||||
|
||||
if (taskArray[i].children && taskArray[i].children.length > 0) {
|
||||
const removedTask = removeTaskFromArray(taskArray[i].children!)
|
||||
if (removedTask) {
|
||||
if (taskArray[i].children!.length === 0) {
|
||||
delete taskArray[i].children
|
||||
taskArray[i].isParent = taskArray[i].type === 'story'
|
||||
}
|
||||
return removedTask
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const removedTask = removeTaskFromArray(tasks.value)
|
||||
if (!removedTask) return
|
||||
|
||||
const taskToAdd = {
|
||||
...updatedTask,
|
||||
isParent:
|
||||
updatedTask.type === 'story' || (updatedTask.children && updatedTask.children.length > 0),
|
||||
}
|
||||
|
||||
// 重新添加到新位置
|
||||
if (taskToAdd.parentId) {
|
||||
const addToParentChildren = (taskArray: Task[]): boolean => {
|
||||
for (const task of taskArray) {
|
||||
if (task.id === taskToAdd.parentId) {
|
||||
if (!task.children) task.children = []
|
||||
task.children.push(taskToAdd)
|
||||
task.isParent = true
|
||||
console.log('已将任务添加到新父任务的children中,父任务ID:', taskToAdd.parentId)
|
||||
return true
|
||||
}
|
||||
if (task.children && task.children.length > 0) {
|
||||
if (addToParentChildren(task.children)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (!addToParentChildren(tasks.value)) {
|
||||
console.warn('未找到新父任务,ID:', taskToAdd.parentId, ',将作为顶级任务添加')
|
||||
tasks.value.push(taskToAdd)
|
||||
}
|
||||
} else {
|
||||
tasks.value.push(taskToAdd)
|
||||
console.log('已将任务添加为顶级任务')
|
||||
}
|
||||
} else {
|
||||
// parentId没有改变,只是就地更新任务数据
|
||||
console.log('任务父级关系未变,执行就地更新')
|
||||
|
||||
const updateTaskInPlace = (taskArray: Task[]): boolean => {
|
||||
for (let i = 0; i < taskArray.length; i++) {
|
||||
if (taskArray[i].id === updatedTask.id) {
|
||||
// 保持原有的children和层级关系
|
||||
taskArray[i] = {
|
||||
...updatedTask,
|
||||
children: taskArray[i].children, // 保持原有的children
|
||||
isParent:
|
||||
updatedTask.type === 'story' ||
|
||||
(taskArray[i].children && taskArray[i].children.length > 0),
|
||||
}
|
||||
console.log('已就地更新任务数据:', taskArray[i])
|
||||
return true
|
||||
}
|
||||
|
||||
if (taskArray[i].children && taskArray[i].children.length > 0) {
|
||||
if (updateTaskInPlace(taskArray[i].children!)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (!updateTaskInPlace(tasks.value)) {
|
||||
console.log('就地更新失败,未找到任务,ID:', updatedTask.id)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('任务更新完成,当前任务数据:', tasks.value)
|
||||
}
|
||||
|
||||
// 任务添加处理器
|
||||
const handleTaskAdd = (newTask: Task) => {
|
||||
console.log('Demo App 接收到新增任务:', newTask)
|
||||
console.log('newTask.type:', newTask.type)
|
||||
|
||||
// 为新任务生成ID(如果没有的话)
|
||||
if (!newTask.id) {
|
||||
// 找到当前最大的ID,然后+1
|
||||
const maxId = Math.max(
|
||||
...tasks.value.map(t => t.id || 0),
|
||||
...milestones.value.map(m => m.id || 0),
|
||||
0,
|
||||
)
|
||||
newTask.id = maxId + 1
|
||||
}
|
||||
|
||||
// 设置isParent属性
|
||||
newTask.isParent = newTask.type === 'story' || (newTask.children && newTask.children.length > 0)
|
||||
|
||||
// 处理父子关系
|
||||
if (newTask.parentId) {
|
||||
// 如果有上级任务,需要将子任务添加到父任务的children中
|
||||
const addToParentChildren = (taskArray: Task[]): boolean => {
|
||||
for (const task of taskArray) {
|
||||
if (task.id === newTask.parentId) {
|
||||
// 找到父任务
|
||||
if (!task.children) {
|
||||
// 如果父任务没有children属性,创建一个
|
||||
task.children = []
|
||||
}
|
||||
// 将新任务添加到父任务的children中
|
||||
task.children.push({ ...newTask })
|
||||
console.log('已将子任务添加到父任务的children中,父任务ID:', newTask.parentId)
|
||||
return true
|
||||
}
|
||||
// 递归查找父任务(支持多层嵌套)
|
||||
if (task.children && task.children.length > 0) {
|
||||
if (addToParentChildren(task.children)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 尝试添加到父任务的children中
|
||||
if (!addToParentChildren(tasks.value)) {
|
||||
console.warn('未找到父任务,ID:', newTask.parentId, ',将作为顶级任务添加')
|
||||
// 如果没找到父任务,作为顶级任务添加
|
||||
tasks.value.push({ ...newTask })
|
||||
}
|
||||
} else {
|
||||
// 没有父任务,作为顶级任务添加
|
||||
tasks.value.push({ ...newTask })
|
||||
}
|
||||
}
|
||||
|
||||
// 任务删除处理器
|
||||
const handleTaskDelete = (taskToDelete: Task) => {
|
||||
console.log('删除任务:', taskToDelete)
|
||||
|
||||
// 递归查找和删除任务(支持嵌套结构)
|
||||
const deleteTaskFromArray = (taskArray: Task[]): boolean => {
|
||||
for (let i = 0; i < taskArray.length; i++) {
|
||||
if (taskArray[i].id === taskToDelete.id) {
|
||||
// 找到任务,删除它
|
||||
taskArray.splice(i, 1)
|
||||
console.log('已删除任务')
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果有子任务,递归查找
|
||||
if (taskArray[i].children && taskArray[i].children.length > 0) {
|
||||
if (deleteTaskFromArray(taskArray[i].children!)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (!deleteTaskFromArray(tasks.value)) {
|
||||
console.log('未找到要删除的任务,ID:', taskToDelete.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 里程碑图标变更处理器
|
||||
const handleMilestoneIconChange = (milestoneId: number, icon: string) => {
|
||||
console.log('里程碑图标变更:', { milestoneId, icon })
|
||||
|
||||
const milestoneIndex = milestones.value.findIndex(m => m.id === milestoneId)
|
||||
if (milestoneIndex !== -1) {
|
||||
milestones.value[milestoneIndex].icon = icon
|
||||
console.log('已更新里程碑图标')
|
||||
} else {
|
||||
console.log('未找到要更新图标的里程碑,ID:', milestoneId)
|
||||
}
|
||||
}
|
||||
|
||||
// TaskDrawer事件处理器
|
||||
const handleTaskDrawerSubmit = (task: Task) => {
|
||||
if (isEditMode.value) {
|
||||
// 编辑模式:更新任务
|
||||
handleTaskUpdate(task)
|
||||
} else {
|
||||
// 新建模式:添加任务
|
||||
handleTaskAdd(task)
|
||||
}
|
||||
showTaskDrawer.value = false
|
||||
}
|
||||
|
||||
const handleTaskDrawerClose = () => {
|
||||
showTaskDrawer.value = false
|
||||
currentTask.value = null
|
||||
isEditMode.value = false
|
||||
}
|
||||
|
||||
const handleTaskDrawerDelete = (taskId: number) => {
|
||||
const taskToDelete = tasks.value.find(t => t.id === taskId)
|
||||
if (taskToDelete) {
|
||||
handleTaskDelete(taskToDelete)
|
||||
}
|
||||
showTaskDrawer.value = false
|
||||
}
|
||||
|
||||
// GitHub 文档处理函数
|
||||
const handleGithubDocsClick = (event: Event) => {
|
||||
event.preventDefault()
|
||||
// 打开GitHub仓库的README页面
|
||||
window.open('https://github.com/jordium-gantt/jordium-gantt-vue3#readme', '_blank')
|
||||
}
|
||||
|
||||
// Gitee 文档处理函数
|
||||
const handleGiteeDocsClick = (event: Event) => {
|
||||
event.preventDefault()
|
||||
// 打开Gitee仓库的README页面
|
||||
window.open('https://gitee.com/jordium-gantt/jordium-gantt-vue3#readme', '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tasks.value = demoData.tasks as Task[]
|
||||
milestones.value = demoData.milestones as Task[]
|
||||
|
||||
// 调试信息:打印原始任务数据
|
||||
console.log('原始任务数据:', tasks.value)
|
||||
console.log('里程碑数据:', milestones.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<h1 class="page-title">
|
||||
<div class="title-left">
|
||||
<svg class="gantt-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="4" width="20" height="2" rx="1" fill="#409eff" />
|
||||
<rect x="2" y="8" width="12" height="2" rx="1" fill="#67c23a" />
|
||||
<rect x="2" y="12" width="16" height="2" rx="1" fill="#e6a23c" />
|
||||
<rect x="2" y="16" width="8" height="2" rx="1" fill="#f56c6c" />
|
||||
<rect x="2" y="20" width="14" height="2" rx="1" fill="#909399" />
|
||||
<circle cx="22" cy="5" r="1" fill="#409eff" />
|
||||
<circle cx="16" cy="9" r="1" fill="#67c23a" />
|
||||
<circle cx="20" cy="13" r="1" fill="#e6a23c" />
|
||||
<circle cx="12" cy="17" r="1" fill="#f56c6c" />
|
||||
<circle cx="18" cy="21" r="1" fill="#909399" />
|
||||
</svg>
|
||||
Jordium Gantt Vue3 Demo
|
||||
<span class="version-badge" style="cursor: pointer" @click="showVersionDrawer = true">{{
|
||||
packageInfo.version
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="docs-links">
|
||||
<a href="#github-docs" class="doc-link github-link" @click="handleGithubDocsClick">
|
||||
<img class="doc-icon" src="./public/github.svg" alt="GitHub" />
|
||||
</a>
|
||||
<span class="docs-divider"></span>
|
||||
<a href="#gitee-docs" class="doc-link gitee-link" @click="handleGiteeDocsClick">
|
||||
<img class="doc-icon" src="./public/gitee.svg" alt="Gitee" />
|
||||
</a>
|
||||
</div>
|
||||
</h1>
|
||||
<VersionHistoryDrawer :visible="showVersionDrawer" @close="showVersionDrawer = false" />
|
||||
<div class="gantt-wrapper">
|
||||
<GanttChart
|
||||
:tasks="tasks"
|
||||
:milestones="milestones"
|
||||
:toolbar-config="toolbarConfig"
|
||||
:on-add-task="handleAddTask"
|
||||
:on-add-milestone="handleAddMilestone"
|
||||
:on-export-csv="handleCustomCsvExport"
|
||||
:on-language-change="handleLanguageChange"
|
||||
:on-theme-change="handleThemeChange"
|
||||
:on-milestone-save="handleMilestoneSave"
|
||||
:on-milestone-delete="handleMilestoneDelete"
|
||||
:on-task-update="handleTaskUpdate"
|
||||
:on-task-add="handleTaskAdd"
|
||||
:on-task-delete="handleTaskDelete"
|
||||
:on-milestone-icon-change="handleMilestoneIconChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="license-info">
|
||||
<p>MIT License @JORDIUM.COM</p>
|
||||
</div>
|
||||
|
||||
<!-- TaskDrawer用于新建/编辑任务 -->
|
||||
<TaskDrawer
|
||||
v-model:visible="showTaskDrawer"
|
||||
:task="currentTask"
|
||||
:is-edit="isEditMode"
|
||||
@submit="handleTaskDrawerSubmit"
|
||||
@close="handleTaskDrawerClose"
|
||||
@delete="handleTaskDrawerDelete"
|
||||
/>
|
||||
|
||||
<!-- MilestoneDialog用于新建/编辑里程碑 -->
|
||||
<MilestoneDialog
|
||||
:visible="showMilestoneDialog"
|
||||
:milestone="currentMilestone"
|
||||
@update:visible="showMilestoneDialog = $event"
|
||||
@save="handleMilestoneSave"
|
||||
@delete="handleMilestoneDelete"
|
||||
@close="showMilestoneDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
background: var(--gantt-bg-secondary, #f0f2f5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 20px 0;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--gantt-text-primary, #333);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gantt-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.gantt-icon:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #409eff 0%, #36d1dc 50%, #667eea 100%);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
line-height: 1;
|
||||
margin-left: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 0 20px rgba(64, 158, 255, 0.3),
|
||||
0 4px 15px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.version-badge::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
transition: left 0.6s ease;
|
||||
}
|
||||
|
||||
.version-badge:hover {
|
||||
transform: scale(1.05) translateY(-1px);
|
||||
box-shadow:
|
||||
0 0 30px rgba(64, 158, 255, 0.5),
|
||||
0 8px 25px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
background: linear-gradient(135deg, #4dabf7 0%, #40c9ff 50%, #74c0fc 100%);
|
||||
}
|
||||
|
||||
.version-badge:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* 科技感呼吸动画 */
|
||||
@keyframes glow-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 20px rgba(64, 158, 255, 0.3),
|
||||
0 4px 15px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 30px rgba(64, 158, 255, 0.5),
|
||||
0 4px 15px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.gantt-wrapper {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.license-info {
|
||||
text-align: center;
|
||||
color: var(--gantt-text-muted, #c0c4cc);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* 全局暗色主题支持 */
|
||||
:global(html[data-theme='dark']) {
|
||||
background: #1e1e1e !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) body {
|
||||
background: #1e1e1e !important;
|
||||
color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
/* 暗黑模式下的版本标签 */
|
||||
:global(html[data-theme='dark']) .version-badge {
|
||||
background: linear-gradient(135deg, #1a73e8 0%, #00bcd4 50%, #3f51b5 100%);
|
||||
box-shadow:
|
||||
0 0 25px rgba(102, 177, 255, 0.4),
|
||||
0 4px 15px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .version-badge:hover {
|
||||
background: linear-gradient(135deg, #2196f3 0%, #00e5ff 50%, #5c6bc0 100%);
|
||||
box-shadow:
|
||||
0 0 35px rgba(102, 177, 255, 0.6),
|
||||
0 8px 25px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(102, 177, 255, 0.5);
|
||||
}
|
||||
|
||||
/* 暗黑模式的呼吸动画 */
|
||||
@keyframes glow-pulse-dark {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 25px rgba(102, 177, 255, 0.4),
|
||||
0 4px 15px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 35px rgba(102, 177, 255, 0.6),
|
||||
0 4px 15px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.docs-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.docs-divider {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
border-left: 1.5px dashed #bbb;
|
||||
margin: 0 8px;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.doc-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--gantt-text-primary, #333333);
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.doc-link:hover {
|
||||
color: var(--gantt-text-primary, #333333);
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.doc-link:nth-child(2) {
|
||||
color: #c71d23;
|
||||
}
|
||||
|
||||
.doc-link:nth-child(2):hover {
|
||||
color: #a91b1b;
|
||||
background-color: rgba(199, 29, 35, 0.1);
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
/* GitHub 图标样式 - 黑色 */
|
||||
.github-link .doc-icon {
|
||||
filter: brightness(0) saturate(100%);
|
||||
}
|
||||
|
||||
.github-link:hover .doc-icon {
|
||||
filter: brightness(0) saturate(100%) invert(20%) sepia(15%) saturate(1500%) hue-rotate(200deg);
|
||||
}
|
||||
|
||||
/* Gitee 图标样式 - 红色 */
|
||||
.gitee-link .doc-icon {
|
||||
filter: brightness(0) saturate(100%) invert(20%) sepia(100%) saturate(2000%) hue-rotate(350deg)
|
||||
brightness(0.8);
|
||||
}
|
||||
|
||||
.gitee-link:hover .doc-icon {
|
||||
filter: brightness(0) saturate(100%) invert(15%) sepia(100%) saturate(2500%) hue-rotate(350deg)
|
||||
brightness(0.7);
|
||||
}
|
||||
|
||||
/* 移除旧的基于 SVG color 的样式,现在使用 filter */
|
||||
|
||||
/* 暗黑模式下覆盖所有链接样式 */
|
||||
:global(html[data-theme='dark']) .doc-link {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .doc-link:hover {
|
||||
color: #ffffff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .doc-link:nth-child(2) {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .doc-link:nth-child(2):hover {
|
||||
color: #ffffff !important;
|
||||
background-color: rgba(199, 29, 35, 0.1);
|
||||
}
|
||||
|
||||
/* 暗黑模式下图标样式 */
|
||||
:global(html[data-theme='dark']) .github-link .doc-icon {
|
||||
filter: brightness(0) saturate(100%) invert(100%);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .github-link:hover .doc-icon {
|
||||
filter: brightness(0) saturate(100%) invert(70%) sepia(50%) saturate(2000%) hue-rotate(190deg)
|
||||
brightness(1.2);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .gitee-link .doc-icon {
|
||||
filter: brightness(0) saturate(100%) invert(45%) sepia(100%) saturate(1500%) hue-rotate(340deg)
|
||||
brightness(1.1);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .gitee-link:hover .doc-icon {
|
||||
filter: brightness(0) saturate(100%) invert(50%) sepia(100%) saturate(1800%) hue-rotate(340deg)
|
||||
brightness(1.2);
|
||||
}
|
||||
</style>
|
||||
3
demo/README.md
Normal file
3
demo/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# README.md
|
||||
|
||||
This file has been deprecated. Please refer to the main project `README.md` for documentation.
|
||||
335
demo/VersionHistoryDrawer.vue
Normal file
335
demo/VersionHistoryDrawer.vue
Normal file
@@ -0,0 +1,335 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
const props = defineProps<{ visible: boolean }>()
|
||||
|
||||
const versionList = ref<any[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await fetch('./version-history.json')
|
||||
const data = await res.json()
|
||||
// 按日期和版本号倒序排列
|
||||
versionList.value = data.sort((a, b) => {
|
||||
if (a.date === b.date) {
|
||||
// 版本号倒序
|
||||
return b.version.localeCompare(a.version, undefined, { numeric: true })
|
||||
}
|
||||
return b.date.localeCompare(a.date)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="props.visible" class="drawer-mask" @click="$emit('close')"></div>
|
||||
<div class="version-history-drawer" :class="{ open: props.visible }">
|
||||
<div class="drawer-header">
|
||||
<h3 class="drawer-title">版本历史</h3>
|
||||
<button class="drawer-close-btn" type="button" @click="$emit('close')">
|
||||
<svg class="close-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="drawer-body">
|
||||
<div class="version-timeline">
|
||||
<div
|
||||
v-for="(item, idx) in versionList"
|
||||
:key="item.version"
|
||||
class="version-timeline-group"
|
||||
>
|
||||
<div :class="['version-timeline-dot', idx === 0 ? 'latest' : '']">
|
||||
<div class="dot-label">
|
||||
<span class="dot-version">{{ item.version }}</span>
|
||||
<span class="dot-date">{{ item.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-timeline-content version-card">
|
||||
<ul class="version-notes">
|
||||
<li v-for="(note, nidx) in item.notes" :key="nidx">{{ note }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.version-history-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 540px; /* 增加Drawer宽度,适中大气 */
|
||||
height: 100vh;
|
||||
background: var(--gantt-bg-primary, #fff);
|
||||
box-shadow: 2px 0 24px rgba(0, 0, 0, 0.1);
|
||||
z-index: 2000;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 去除圆角 */
|
||||
}
|
||||
.version-history-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.drawer-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.5); /* 与TaskDrawer一致 */
|
||||
z-index: 1999;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--gantt-border-light, #ebeef5);
|
||||
background: var(--gantt-bg-secondary, #f5f7fa);
|
||||
}
|
||||
.drawer-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--gantt-text-primary, #303133);
|
||||
}
|
||||
|
||||
.drawer-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
color: var(--gantt-text-muted, #909399);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.drawer-close-btn:hover {
|
||||
color: var(--gantt-text-secondary, #606266);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 32px 32px 32px 60px;
|
||||
background: var(--gantt-bg-primary, #fff); /* 统一用变量,便于主题切换 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #b3c6e0 #f0f2f5;
|
||||
/* 去除圆角 */
|
||||
}
|
||||
.drawer-body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
.drawer-body::-webkit-scrollbar-thumb {
|
||||
background: #b3c6e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.drawer-body::-webkit-scrollbar-track {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
.version-timeline {
|
||||
position: relative;
|
||||
margin-left: 140px;
|
||||
border-left: 0;
|
||||
}
|
||||
.version-timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--gantt-timeline-line, #a0cfff) 0 8px,
|
||||
transparent 8px 16px
|
||||
);
|
||||
border-radius: 1px;
|
||||
z-index: 0;
|
||||
}
|
||||
.version-timeline-group {
|
||||
position: relative;
|
||||
margin-bottom: 40px;
|
||||
padding-left: 18px;
|
||||
min-height: 60px;
|
||||
transition: box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.version-timeline-dot {
|
||||
position: absolute;
|
||||
left: -13px;
|
||||
top: 18px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--gantt-timeline-dot, #a0cfff); /* 更柔和主色 */
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 2px var(--gantt-timeline-line, #b3d8ff);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
transition:
|
||||
background 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
.version-timeline-group:hover .version-timeline-dot {
|
||||
background: var(--gantt-timeline-dot-hover, #409eff); /* hover主色 */
|
||||
}
|
||||
.version-timeline-dot.latest {
|
||||
background: var(--gantt-primary, #409eff);
|
||||
}
|
||||
.dot-label {
|
||||
position: absolute;
|
||||
left: -140px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
min-width: 90px;
|
||||
max-width: 110px;
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: left 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.version-timeline-group:hover .dot-label {
|
||||
left: -110px; /* 悬停时向右移动,接近原点 */
|
||||
}
|
||||
.version-timeline-content.version-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.08); /* 主色阴影 */
|
||||
padding: 18px 18px 12px 18px;
|
||||
margin-left: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
transition: box-shadow 0.2s;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.version-timeline-group:hover .version-timeline-content.version-card {
|
||||
box-shadow: 0 8px 24px rgba(64, 158, 255, 0.16);
|
||||
}
|
||||
.version-timeline-content.version-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -16px; /* 让箭头更靠近原点但不覆盖 */
|
||||
top: 18px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
/*border-top: 6px solid transparent;
|
||||
border-bottom: 6px solid transparent;
|
||||
border-right: 16px solid #fff;*/
|
||||
filter: drop-shadow(-2px 0 2px var(--gantt-timeline-line, #a0cfff));
|
||||
z-index: 1;
|
||||
}
|
||||
.version-timeline-group:hover .version-timeline-content.version-card::before {
|
||||
filter: drop-shadow(-2px 0 2px var(--gantt-primary, #409eff));
|
||||
}
|
||||
.version-notes {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #aaa;
|
||||
font-size: 14px;
|
||||
list-style: disc;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.version-timeline-group:hover .version-notes {
|
||||
color: #333;
|
||||
}
|
||||
.dot-version {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
color: var(--gantt-primary, #409eff); /* 统一主色 */
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1.2;
|
||||
text-shadow: 0 1px 4px rgba(64, 158, 255, 0.08);
|
||||
}
|
||||
.dot-date {
|
||||
color: #b0b3bb;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.2px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* 暗黑主题适配,完全对齐TaskDrawer风格 */
|
||||
:global(html[data-theme='dark']) .version-history-drawer {
|
||||
background: var(--gantt-bg-primary, #6b6b6b) !important;
|
||||
box-shadow: 2px 0 24px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .drawer-header {
|
||||
background: var(--gantt-bg-secondary, #4b4b4b) !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .drawer-title {
|
||||
color: var(--gantt-text-white, #fff) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .drawer-close-btn:hover {
|
||||
background: var(--gantt-bg-hover, rgba(255, 255, 255, 0.1)) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
:global(html[data-theme='dark']) .drawer-body {
|
||||
background: var(--gantt-bg-primary, #6b6b6b) !important;
|
||||
scrollbar-color: #888888 #4b4b4b !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .drawer-body::-webkit-scrollbar-thumb {
|
||||
background: #888888 !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .drawer-body::-webkit-scrollbar-track {
|
||||
background: #4b4b4b !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .version-timeline-content.version-card {
|
||||
background: var(--gantt-bg-secondary, #4b4b4b) !important;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.32) !important;
|
||||
color: var(--gantt-text-white, #fff) !important;
|
||||
}
|
||||
:global(html[data-theme='dark'])
|
||||
.version-timeline-group:hover
|
||||
.version-timeline-content.version-card {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.44) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .version-timeline-content.version-card::before {
|
||||
filter: drop-shadow(-2px 0 2px #222) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .dot-version {
|
||||
color: var(--gantt-primary, #3399ff) !important;
|
||||
text-shadow: 0 1px 4px rgba(51, 153, 255, 0.12) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .dot-date {
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .version-notes {
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .version-timeline-dot {
|
||||
background: var(--gantt-timeline-dot, #3399ff) !important;
|
||||
box-shadow: 0 0 0 2px #222 !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .version-timeline-dot.latest {
|
||||
background: var(--gantt-primary, #3399ff) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .version-timeline-group:hover .version-timeline-dot {
|
||||
background: var(--gantt-primary, #3399ff) !important;
|
||||
}
|
||||
:global(html[data-theme='dark']) .version-timeline::before {
|
||||
background: repeating-linear-gradient(to bottom, #3399ff 0 8px, transparent 8px 16px) !important;
|
||||
}
|
||||
</style>
|
||||
187
demo/data.json
Normal file
187
demo/data.json
Normal file
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "项目启动",
|
||||
"assignee": "张三",
|
||||
"startDate": "2025-06-15",
|
||||
"endDate": "2025-06-25",
|
||||
"progress": 100,
|
||||
"estimatedHours": 40,
|
||||
"actualHours": 38,
|
||||
"type": "story",
|
||||
"children": [
|
||||
{
|
||||
"id": 2,
|
||||
"name": "需求分析",
|
||||
"assignee": "李四",
|
||||
"startDate": "2025-04-16",
|
||||
"endDate": "2025-06-20",
|
||||
"progress": 100,
|
||||
"estimatedHours": 16,
|
||||
"actualHours": 15,
|
||||
"type": "task"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "技术选型",
|
||||
"assignee": "王五",
|
||||
"startDate": "2025-06-21",
|
||||
"endDate": "2025-06-28",
|
||||
"progress": 80,
|
||||
"estimatedHours": 24,
|
||||
"actualHours": 28,
|
||||
"type": "story",
|
||||
"children": [
|
||||
{
|
||||
"id": 4,
|
||||
"name": "调研A",
|
||||
"predecessor": "2",
|
||||
"assignee": "赵六",
|
||||
"startDate": "2025-06-21",
|
||||
"endDate": "2025-06-23",
|
||||
"progress": 80,
|
||||
"estimatedHours": 8,
|
||||
"actualHours": 10,
|
||||
"type": "task"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "调研B",
|
||||
"predecessor": "4",
|
||||
"assignee": "钱七",
|
||||
"startDate": "2025-06-24",
|
||||
"endDate": "2025-06-28",
|
||||
"progress": 0,
|
||||
"estimatedHours": 16,
|
||||
"actualHours": 0,
|
||||
"type": "task"
|
||||
}
|
||||
],
|
||||
"collapsed": false
|
||||
}
|
||||
],
|
||||
"collapsed": false
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "开发阶段",
|
||||
"assignee": "开发团队",
|
||||
"startDate": "2025-06-29",
|
||||
"endDate": "2025-07-15",
|
||||
"progress": 30,
|
||||
"estimatedHours": 120,
|
||||
"actualHours": 45,
|
||||
"type": "story",
|
||||
"children": [
|
||||
{
|
||||
"id": 8,
|
||||
"name": "前端开发",
|
||||
"assignee": "李四",
|
||||
"startDate": "2025-06-29",
|
||||
"endDate": "2025-07-10",
|
||||
"progress": 40,
|
||||
"estimatedHours": 80,
|
||||
"actualHours": 35,
|
||||
"type": "task",
|
||||
"predecessor": "5"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "后端开发",
|
||||
"assignee": "李四",
|
||||
"startDate": "2025-07-07",
|
||||
"endDate": "2025-07-15",
|
||||
"progress": 0,
|
||||
"estimatedHours": 80,
|
||||
"actualHours": 0,
|
||||
"type": "task",
|
||||
"predecessor": "5"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "Job开发",
|
||||
"assignee": "李四",
|
||||
"startDate": "2025-07-07",
|
||||
"endDate": "2025-07-18",
|
||||
"progress": 0,
|
||||
"estimatedHours": 88,
|
||||
"actualHours": 0,
|
||||
"type": "task",
|
||||
"predecessor": "5"
|
||||
}
|
||||
],
|
||||
"collapsed": false
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"name": "测试阶段",
|
||||
"assignee": "测试团队",
|
||||
"startDate": "2025-07-21",
|
||||
"endDate": "2025-07-31",
|
||||
"progress": 0,
|
||||
"estimatedHours": 80,
|
||||
"actualHours": 0,
|
||||
"type": "story",
|
||||
"children": [
|
||||
{
|
||||
"id": 14,
|
||||
"name": "SIT",
|
||||
"assignee": "李四",
|
||||
"startDate": "2025-07-21",
|
||||
"endDate": "2025-07-28",
|
||||
"progress": 0,
|
||||
"estimatedHours": 48,
|
||||
"actualHours": 0,
|
||||
"type": "task",
|
||||
"predecessor": "7"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"name": "UAT",
|
||||
"assignee": "李四",
|
||||
"startDate": "2025-07-29",
|
||||
"endDate": "2025-07-31",
|
||||
"progress": 0,
|
||||
"estimatedHours": 24,
|
||||
"actualHours": 0,
|
||||
"type": "task",
|
||||
"predecessor": "7"
|
||||
}
|
||||
],
|
||||
"collapsed": false
|
||||
}
|
||||
],
|
||||
"milestones": [
|
||||
{
|
||||
"id": 6,
|
||||
"name": "技术选型完成",
|
||||
"assignee": "王五",
|
||||
"startDate": "2025-06-25",
|
||||
"endDate": "2025-06-25",
|
||||
"type": "milestone",
|
||||
"icon": "diamond",
|
||||
"description": "完成技术选型调研,确定项目技术栈和架构方案。"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "开发完成",
|
||||
"assignee": "开发团队",
|
||||
"startDate": "2025-07-11",
|
||||
"endDate": "2025-07-11",
|
||||
"type": "milestone",
|
||||
"icon": "rocket",
|
||||
"description": "完成所有开发任务,准备进入测试阶段。"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "Alpha版本发布",
|
||||
"assignee": "项目经理",
|
||||
"startDate": "2025-07-15",
|
||||
"endDate": "2025-07-15",
|
||||
"type": "milestone",
|
||||
"icon": "rocket",
|
||||
"description": "发布Alpha测试版本,提供给内部团队进行功能验证和测试。"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
demo/index.html
Normal file
13
demo/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/public/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jordium Gantt Vue3 Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
5
demo/main.ts
Normal file
5
demo/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
12
demo/public/favicon.svg
Normal file
12
demo/public/favicon.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="4" width="20" height="2" rx="1" fill="#409eff" />
|
||||
<rect x="2" y="8" width="12" height="2" rx="1" fill="#67c23a" />
|
||||
<rect x="2" y="12" width="16" height="2" rx="1" fill="#e6a23c" />
|
||||
<rect x="2" y="16" width="8" height="2" rx="1" fill="#f56c6c" />
|
||||
<rect x="2" y="20" width="14" height="2" rx="1" fill="#909399" />
|
||||
<circle cx="22" cy="5" r="1" fill="#409eff" />
|
||||
<circle cx="16" cy="9" r="1" fill="#67c23a" />
|
||||
<circle cx="20" cy="13" r="1" fill="#e6a23c" />
|
||||
<circle cx="12" cy="17" r="1" fill="#f56c6c" />
|
||||
<circle cx="18" cy="21" r="1" fill="#909399" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 665 B |
1
demo/public/gitee.svg
Normal file
1
demo/public/gitee.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1750864628544" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5058" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 1024C229.222 1024 0 794.778 0 512S229.222 0 512 0s512 229.222 512 512-229.222 512-512 512z m259.149-568.883h-290.74a25.293 25.293 0 0 0-25.292 25.293l-0.026 63.206c0 13.952 11.315 25.293 25.267 25.293h177.024c13.978 0 25.293 11.315 25.293 25.267v12.646a75.853 75.853 0 0 1-75.853 75.853h-240.23a25.293 25.293 0 0 1-25.267-25.293V417.203a75.853 75.853 0 0 1 75.827-75.853h353.946a25.293 25.293 0 0 0 25.267-25.292l0.077-63.207a25.293 25.293 0 0 0-25.268-25.293H417.152a189.62 189.62 0 0 0-189.62 189.645V771.15c0 13.977 11.316 25.293 25.294 25.293h372.94a170.65 170.65 0 0 0 170.65-170.65V480.384a25.293 25.293 0 0 0-25.293-25.267z" fill="#C71D23" p-id="5059"></path></svg>
|
||||
|
After Width: | Height: | Size: 1010 B |
1
demo/public/github.svg
Normal file
1
demo/public/github.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1750864675427" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6018" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 42.666667A464.64 464.64 0 0 0 42.666667 502.186667 460.373333 460.373333 0 0 0 363.52 938.666667c23.466667 4.266667 32-9.813333 32-22.186667v-78.08c-130.56 27.733333-158.293333-61.44-158.293333-61.44a122.026667 122.026667 0 0 0-52.053334-67.413333c-42.666667-28.16 3.413333-27.733333 3.413334-27.733334a98.56 98.56 0 0 1 71.68 47.36 101.12 101.12 0 0 0 136.533333 37.973334 99.413333 99.413333 0 0 1 29.866667-61.44c-104.106667-11.52-213.333333-50.773333-213.333334-226.986667a177.066667 177.066667 0 0 1 47.36-124.16 161.28 161.28 0 0 1 4.693334-121.173333s39.68-12.373333 128 46.933333a455.68 455.68 0 0 1 234.666666 0c89.6-59.306667 128-46.933333 128-46.933333a161.28 161.28 0 0 1 4.693334 121.173333A177.066667 177.066667 0 0 1 810.666667 477.866667c0 176.64-110.08 215.466667-213.333334 226.986666a106.666667 106.666667 0 0 1 32 85.333334v125.866666c0 14.933333 8.533333 26.88 32 22.186667A460.8 460.8 0 0 0 981.333333 502.186667 464.64 464.64 0 0 0 512 42.666667" fill="#231F20" p-id="6019"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
demo/public/vite.svg
Normal file
1
demo/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
113
demo/style.css
Normal file
113
demo/style.css
Normal file
@@ -0,0 +1,113 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
height: 100vh;
|
||||
padding: 30px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
/* 黑暗模式全局样式 */
|
||||
html[data-theme='dark'] {
|
||||
background: #1e1e1e !important;
|
||||
color-scheme: dark !important;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] body {
|
||||
background: #1e1e1e !important;
|
||||
color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
/* 明亮模式全局样式 */
|
||||
html[data-theme='light'] {
|
||||
background: #ffffff !important;
|
||||
color-scheme: light !important;
|
||||
}
|
||||
|
||||
html[data-theme='light'] body {
|
||||
background: #ffffff !important;
|
||||
color: #333333 !important;
|
||||
}
|
||||
72
demo/version-history.json
Normal file
72
demo/version-history.json
Normal file
@@ -0,0 +1,72 @@
|
||||
[
|
||||
{
|
||||
"version": "alpha 0.8.1",
|
||||
"date": "2025-06-25",
|
||||
"notes": ["升级优化DatePicker组件"]
|
||||
},
|
||||
{
|
||||
"version": "alpha 0.8.2",
|
||||
"date": "2025-06-25",
|
||||
"notes": ["升级Demo", "移除导出按钮的初始化光晕效果"]
|
||||
},
|
||||
{
|
||||
"version": "alpha 0.8.3",
|
||||
"date": "2025-06-25",
|
||||
"notes": ["升级里程碑Taskbar,允许拖拽"]
|
||||
},
|
||||
{
|
||||
"version": "alpha 0.8.4",
|
||||
"date": "2025-06-25",
|
||||
"notes": ["升级需求/任务TaskBar新建以及上下级关系变更", "添加Github和Gitee的文档入口"]
|
||||
},
|
||||
{
|
||||
"version": "alpha 0.8.5",
|
||||
"date": "2025-06-26",
|
||||
"notes": ["升级Timeline今日定位和滑动问题"]
|
||||
},
|
||||
{
|
||||
"version": "alpha 0.9.0",
|
||||
"date": "2025-06-26",
|
||||
"notes": ["添加新增里程碑和删除里程碑功能"]
|
||||
},
|
||||
{
|
||||
"version": "alpha 0.9.1",
|
||||
"date": "2025-06-27",
|
||||
"notes": ["优化时间轴的延伸"]
|
||||
},
|
||||
{
|
||||
"version": "alpha 0.9.2",
|
||||
"date": "2025-06-27",
|
||||
"notes": ["增加历史版本查看"]
|
||||
},
|
||||
{
|
||||
"version": "alpha 0.9.3",
|
||||
"date": "2025-06-27",
|
||||
"notes": ["修复今日定位问题"]
|
||||
},
|
||||
{
|
||||
"version": "alpha 0.9.4",
|
||||
"date": "2025-06-27",
|
||||
"notes": ["调整暗黑主题的亮度", "调整暗黑主题的布局", "修复暗黑主题下的版本历史的样式"]
|
||||
},
|
||||
{
|
||||
"version": "alpha 0.9.5",
|
||||
"date": "2025-06-27",
|
||||
"notes": ["统一管理各个组件的全球化配置"]
|
||||
},
|
||||
{
|
||||
"version": "alpha 0.9.6",
|
||||
"date": "2025-06-27",
|
||||
"notes": ["各个组件分离Task/Milestone/Language对象"]
|
||||
},
|
||||
{
|
||||
"version": "alpha 0.9.7",
|
||||
"date": "2025-06-28",
|
||||
"notes": ["解决Milestone组件和类型的同名问题"]
|
||||
},
|
||||
{
|
||||
"version": "alpha 0.9.8",
|
||||
"date": "2025-06-28",
|
||||
"notes": ["按钮样式统一管理"]
|
||||
}
|
||||
]
|
||||
203
design/ui-preview.html
Normal file
203
design/ui-preview.html
Normal file
@@ -0,0 +1,203 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>火箭图标最终版本</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-bottom: 50px;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.rocket-showcase {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.rocket-card {
|
||||
background: linear-gradient(135deg, #ffe8e8 0%, #fff5f5 100%);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
border: 3px solid #ff6b6b;
|
||||
box-shadow: 0 15px 35px rgba(255, 107, 107, 0.2);
|
||||
margin: 0 auto;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.rocket-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.rocket-description {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rocket-display {
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
font-size: 60px;
|
||||
line-height: 1;
|
||||
transform: rotate(-45deg);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.rocket-display:hover {
|
||||
transform: rotate(-45deg) scale(1.1);
|
||||
}
|
||||
|
||||
.rocket-display svg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.feature-badge {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
padding: 8px 20px;
|
||||
border-radius: 25px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.features-list {
|
||||
background: #f8f9fa;
|
||||
border-radius: 15px;
|
||||
padding: 30px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.features-list h3 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.features-list ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.features-list li {
|
||||
padding: 8px 0;
|
||||
color: #555;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.features-list li:before {
|
||||
content: '✅ ';
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.timeline-preview {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
border: 2px dashed #dee2e6;
|
||||
}
|
||||
|
||||
.timeline-preview h4 {
|
||||
margin-top: 0;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.milestone-demo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.milestone-demo svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.milestone-rocket {
|
||||
font-size: 24px;
|
||||
transform: rotate(-45deg);
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🚀 最终火箭图标方案</h1>
|
||||
|
||||
<div class="rocket-showcase">
|
||||
<div class="rocket-card">
|
||||
<div class="rocket-title">方案26:🚀 垂直Emoji火箭(最终版)</div>
|
||||
<div class="rocket-description">
|
||||
使用原生🚀 emoji并通过CSS旋转至完全垂直朝上,简洁清晰,兼容性强,无需额外SVG代码
|
||||
</div>
|
||||
<div class="rocket-display">🚀</div>
|
||||
<div class="feature-badge">✨ 最终选择</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="features-list">
|
||||
<h3>🎯 设计特点</h3>
|
||||
<ul>
|
||||
<li>使用原生🚀 emoji,无需额外SVG代码</li>
|
||||
<li>通过CSS rotate(-45deg)旋转至完全垂直朝上</li>
|
||||
<li>符合火箭发射方向,视觉效果更佳</li>
|
||||
<li>跨平台兼容性强,所有设备都能完美显示</li>
|
||||
<li>简洁清晰,视觉识别度高</li>
|
||||
<li>加载速度快,性能优秀</li>
|
||||
<li>颜色鲜艳,视觉效果突出</li>
|
||||
<li>标准化图标,用户熟悉度高</li>
|
||||
<li>可缩放性好,适应各种尺寸</li>
|
||||
<li>无需维护复杂的SVG代码</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="timeline-preview">
|
||||
<h4>📍 Timeline 中的效果预览</h4>
|
||||
<div class="milestone-demo">
|
||||
<span>里程碑示例:</span>
|
||||
<span class="milestone-rocket">🚀</span>
|
||||
<span>这就是里程碑在甘特图时间轴中的显示效果</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jordium Gantt Vue3</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
3389
package-lock.json
generated
Normal file
3389
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "jordium-gantt-vue3",
|
||||
"private": true,
|
||||
"version": "0.9.8-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:demo": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"build:demo": "vue-tsc -b && vite build",
|
||||
"build:lib": "vite build --config vite.config.lib.ts",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
"lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.json --composite false"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.1",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.25.0",
|
||||
"prettier": "^3.2.5",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vue-tsc": "^2.2.8"
|
||||
}
|
||||
}
|
||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal 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 |
1550
src/components/DatePicker.vue
Normal file
1550
src/components/DatePicker.vue
Normal file
File diff suppressed because it is too large
Load Diff
1505
src/components/GanttChart.vue
Normal file
1505
src/components/GanttChart.vue
Normal file
File diff suppressed because it is too large
Load Diff
84
src/components/GanttConfirmDialog.vue
Normal file
84
src/components/GanttConfirmDialog.vue
Normal 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>
|
||||
1093
src/components/GanttToolbar.vue
Normal file
1093
src/components/GanttToolbar.vue
Normal file
File diff suppressed because it is too large
Load Diff
773
src/components/MilestoneDialog.vue
Normal file
773
src/components/MilestoneDialog.vue
Normal 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
src/components/MilestonePoint.vue
Normal file
343
src/components/MilestonePoint.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onUnmounted } from 'vue'
|
||||
import type { Milestone } from '../models/classes/Milestone'
|
||||
|
||||
interface Props {
|
||||
date: string // 里程碑日期
|
||||
rowHeight: number
|
||||
dayWidth: number
|
||||
startDate: Date
|
||||
name?: string
|
||||
milestone?: Milestone // 完整的里程碑数据
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 添加事件定义
|
||||
const emit = defineEmits<{
|
||||
'milestone-double-click': [milestone: Milestone]
|
||||
'update:milestone': [milestone: Milestone] // 新增里程碑更新事件
|
||||
}>()
|
||||
|
||||
// 拖拽相关状态
|
||||
const isDragging = ref(false)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartLeft = ref(0)
|
||||
const tempMilestoneData = ref<{ startDate?: string } | null>(null)
|
||||
|
||||
// 双击事件处理
|
||||
const handleDoubleClick = () => {
|
||||
if (props.milestone) {
|
||||
emit('milestone-double-click', props.milestone)
|
||||
} else {
|
||||
// 如果没有完整数据,构造基本的里程碑对象
|
||||
const basicMilestone: Milestone = {
|
||||
name: props.name || '里程碑',
|
||||
startDate: props.date,
|
||||
type: 'milestone',
|
||||
}
|
||||
emit('milestone-double-click', basicMilestone)
|
||||
}
|
||||
}
|
||||
|
||||
// 日期工具函数
|
||||
const addDaysToLocalDate = (date: Date, days: number): Date => {
|
||||
const result = new Date(date)
|
||||
result.setDate(result.getDate() + days)
|
||||
return result
|
||||
}
|
||||
|
||||
const formatDateToLocalString = (date: Date): string => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
// 拖拽事件处理
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
isDragging.value = true
|
||||
dragStartX.value = e.clientX
|
||||
dragStartLeft.value = parseInt(milestoneStyle.value.left)
|
||||
tempMilestoneData.value = null
|
||||
|
||||
// 添加全局事件监听器
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging.value) {
|
||||
const deltaX = e.clientX - dragStartX.value
|
||||
const newLeft = Math.max(0, dragStartLeft.value + deltaX)
|
||||
const newStartDate = addDaysToLocalDate(props.startDate, newLeft / props.dayWidth)
|
||||
|
||||
// 只更新临时数据,不触发事件
|
||||
tempMilestoneData.value = {
|
||||
startDate: formatDateToLocalString(newStartDate),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// 如果有临时数据,说明发生了拖拽,提交数据更新
|
||||
if (tempMilestoneData.value && props.milestone) {
|
||||
const updatedMilestone = {
|
||||
...props.milestone,
|
||||
...tempMilestoneData.value,
|
||||
}
|
||||
|
||||
console.log('里程碑拖拽完成,提交数据更新:', updatedMilestone)
|
||||
emit('update:milestone', updatedMilestone)
|
||||
|
||||
// 清空临时数据
|
||||
tempMilestoneData.value = null
|
||||
}
|
||||
|
||||
isDragging.value = false
|
||||
|
||||
// 移除全局事件监听器
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
// 计算菱形位置 - 考虑拖拽临时数据
|
||||
const milestoneStyle = computed(() => {
|
||||
const milestoneDate = tempMilestoneData.value?.startDate
|
||||
? new Date(tempMilestoneData.value.startDate)
|
||||
: new Date(props.date)
|
||||
|
||||
// 修正:props.startDate 可能为 undefined,需防御性处理
|
||||
if (!props.startDate || isNaN(new Date(props.date).getTime())) {
|
||||
return {
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
}
|
||||
}
|
||||
|
||||
const startDiff = Math.floor(
|
||||
(milestoneDate.getTime() - props.startDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||
)
|
||||
const size = Math.min(props.rowHeight, props.dayWidth * 1.2, 24)
|
||||
return {
|
||||
left: `${startDiff * props.dayWidth + props.dayWidth / 2 - size / 2}px`,
|
||||
top: `${(props.rowHeight - size) / 2}px`,
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
}
|
||||
})
|
||||
|
||||
// 里程碑统一使用红色配色
|
||||
const milestoneColor = computed(() => {
|
||||
// 使用危险色(红色)统一里程碑配色
|
||||
return 'var(--gantt-danger, #f56c6c)'
|
||||
})
|
||||
const milestoneBorder = computed(() => {
|
||||
// 稍浅的红色作为边框
|
||||
return 'var(--gantt-danger-light, #fab6b6)'
|
||||
})
|
||||
|
||||
// 计算里程碑图标类型
|
||||
const milestoneIcon = computed(() => {
|
||||
return props.milestone?.icon || 'diamond' // 默认为菱形
|
||||
})
|
||||
|
||||
// 组件销毁时清理事件监听器
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="milestone"
|
||||
:style="milestoneStyle"
|
||||
:title="props.name || '里程碑'"
|
||||
:class="{ dragging: isDragging }"
|
||||
@dblclick="handleDoubleClick"
|
||||
@mousedown="handleMouseDown"
|
||||
>
|
||||
<svg :width="24" :height="24" :viewBox="`0 0 24 24`">
|
||||
<!-- 菱形图标 -->
|
||||
<g v-if="milestoneIcon === 'diamond'" transform="rotate(45 16 16)">
|
||||
<rect
|
||||
x="4"
|
||||
y="8"
|
||||
width="15"
|
||||
height="15"
|
||||
rx="6"
|
||||
ry="6"
|
||||
:fill="milestoneColor"
|
||||
:stroke="milestoneBorder"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 火箭图标 -->
|
||||
<g v-else-if="milestoneIcon === 'rocket'">
|
||||
<foreignObject x="0" y="0" width="24" height="24">
|
||||
<div class="rocket-emoji">🚀</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
|
||||
<!-- 默认菱形图标 -->
|
||||
<g v-else transform="rotate(45 16 16)">
|
||||
<rect
|
||||
x="4"
|
||||
y="8"
|
||||
width="15"
|
||||
height="15"
|
||||
rx="6"
|
||||
ry="6"
|
||||
:fill="milestoneColor"
|
||||
:stroke="milestoneBorder"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<span v-if="props.name" class="milestone-label milestone-label-right">{{ props.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import '../styles/theme-variables.css';
|
||||
|
||||
.milestone {
|
||||
position: absolute;
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 里程碑SVG发光效果 */
|
||||
.milestone svg {
|
||||
filter: drop-shadow(0 0 8px var(--gantt-danger, #f56c6c));
|
||||
animation: milestone-glow 2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* 里程碑发光动画 */
|
||||
@keyframes milestone-glow {
|
||||
from {
|
||||
filter: drop-shadow(0 0 4px var(--gantt-danger, #f56c6c));
|
||||
}
|
||||
to {
|
||||
filter: drop-shadow(0 0 12px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 20px rgba(245, 108, 108, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
/* 悬停时增强发光效果 */
|
||||
.milestone:hover svg {
|
||||
filter: drop-shadow(0 0 16px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 24px rgba(245, 108, 108, 0.4));
|
||||
animation: milestone-glow-intense 1.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes milestone-glow-intense {
|
||||
from {
|
||||
filter: drop-shadow(0 0 12px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 20px rgba(245, 108, 108, 0.4));
|
||||
}
|
||||
to {
|
||||
filter: drop-shadow(0 0 20px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 32px rgba(245, 108, 108, 0.6));
|
||||
}
|
||||
}
|
||||
|
||||
.milestone-label {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: var(--gantt-text-primary, #222);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.milestone-label-right {
|
||||
margin-left: 5px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* 火箭emoji样式 */
|
||||
.rocket-emoji {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
transform: rotate(-45deg);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* 火箭emoji悬停效果 */
|
||||
.milestone:hover .rocket-emoji {
|
||||
transform: rotate(-45deg) scale(1.1);
|
||||
}
|
||||
|
||||
/* 暗黑模式下的适配 */
|
||||
:global(html[data-theme='dark']) .milestone-label {
|
||||
color: var(--gantt-text-white, #ffffff) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .milestone svg {
|
||||
filter: drop-shadow(0 0 8px var(--gantt-danger, #f67c7c));
|
||||
animation: milestone-glow-dark 2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .milestone:hover svg {
|
||||
filter: drop-shadow(0 0 16px var(--gantt-danger, #f67c7c))
|
||||
drop-shadow(0 0 24px rgba(246, 124, 124, 0.4));
|
||||
animation: milestone-glow-intense-dark 1.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* 暗黑模式发光动画 */
|
||||
@keyframes milestone-glow-dark {
|
||||
from {
|
||||
filter: drop-shadow(0 0 4px var(--gantt-danger, #f67c7c));
|
||||
}
|
||||
to {
|
||||
filter: drop-shadow(0 0 12px var(--gantt-danger, #f67c7c))
|
||||
drop-shadow(0 0 20px rgba(246, 124, 124, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes milestone-glow-intense-dark {
|
||||
from {
|
||||
filter: drop-shadow(0 0 12px var(--gantt-danger, #f67c7c))
|
||||
drop-shadow(0 0 20px rgba(246, 124, 124, 0.4));
|
||||
}
|
||||
to {
|
||||
filter: drop-shadow(0 0 20px var(--gantt-danger, #f67c7c))
|
||||
drop-shadow(0 0 32px rgba(246, 124, 124, 0.6));
|
||||
}
|
||||
}
|
||||
|
||||
/* 拖拽状态样式 */
|
||||
.milestone.dragging {
|
||||
z-index: 1000;
|
||||
opacity: 0.8;
|
||||
transform: scale(1.1);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.milestone.dragging svg {
|
||||
filter: drop-shadow(0 0 20px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 32px rgba(245, 108, 108, 0.6));
|
||||
animation: none;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .milestone.dragging svg {
|
||||
filter: drop-shadow(0 0 20px var(--gantt-danger, #f67c7c))
|
||||
drop-shadow(0 0 32px rgba(246, 124, 124, 0.6));
|
||||
}
|
||||
</style>
|
||||
596
src/components/TaskBar.vue
Normal file
596
src/components/TaskBar.vue
Normal 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
src/components/TaskDrawer.vue
Normal file
761
src/components/TaskDrawer.vue
Normal file
@@ -0,0 +1,761 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from '../composables/useI18n'
|
||||
import DatePicker from './DatePicker.vue'
|
||||
import GanttConfirmDialog from './GanttConfirmDialog.vue'
|
||||
import type { Task } from '../models/classes/Task'
|
||||
import '../styles/app.css'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
task?: Task | null
|
||||
isEdit?: boolean
|
||||
onDelete?: (task: Task) => void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
task: null,
|
||||
isEdit: false,
|
||||
onDelete: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
submit: [task: Task]
|
||||
close: []
|
||||
delete: [task: Task]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const submitting = ref(false)
|
||||
const isVisible = ref(props.visible)
|
||||
const showDeleteConfirm = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive<Task>({
|
||||
id: 0, // 默认值,创建时会被重新分配
|
||||
name: '',
|
||||
type: 'task',
|
||||
assignee: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
predecessor: '',
|
||||
estimatedHours: 0,
|
||||
actualHours: 0,
|
||||
progress: 0,
|
||||
description: '',
|
||||
parentId: undefined, // 上级任务ID
|
||||
})
|
||||
|
||||
// 任务列表数据
|
||||
const allTasks = ref<Task[]>([])
|
||||
|
||||
// 获取可作为前置任务的任务列表(只包含type="task"的任务,且不包含当前任务)
|
||||
const availablePredecessorTasks = computed(() => {
|
||||
return allTasks.value.filter(
|
||||
task => task.type === 'task' && task.id !== props.task?.id, // 排除当前任务自己
|
||||
)
|
||||
})
|
||||
|
||||
// 获取可作为上级任务的任务列表(只显示story和task类型,排除当前任务自己)
|
||||
const availableParentTasks = computed(() => {
|
||||
return allTasks.value
|
||||
.filter(
|
||||
task =>
|
||||
task.id !== props.task?.id && // 排除当前任务自己
|
||||
(task.type === 'story' || task.type === 'task'), // 只显示story和task类型
|
||||
)
|
||||
.map(task => ({
|
||||
...task,
|
||||
displayName: `${task.name} (${getTaskTypeDisplay(task.type || 'task')})`,
|
||||
}))
|
||||
})
|
||||
|
||||
// 获取任务类型的显示文本
|
||||
const getTaskTypeDisplay = (type: string): string => {
|
||||
return (t.value.taskTypeMap as Record<string, string>)?.[type] || type
|
||||
}
|
||||
|
||||
// 错误信息
|
||||
const errors = reactive({
|
||||
name: '',
|
||||
type: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
})
|
||||
|
||||
// 监听 visible 属性变化
|
||||
watch(
|
||||
() => props.visible,
|
||||
newVal => {
|
||||
isVisible.value = newVal
|
||||
if (newVal) {
|
||||
resetForm()
|
||||
if (props.task && props.isEdit) {
|
||||
// 编辑模式,填充表单数据
|
||||
Object.assign(formData, props.task)
|
||||
}
|
||||
// 抽屉显示时重新请求任务数据,确保前置任务列表是最新的
|
||||
window.dispatchEvent(new CustomEvent('request-task-list'))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 监听 isVisible 变化,同步到父组件
|
||||
watch(isVisible, newVal => {
|
||||
emit('update:visible', newVal)
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
name: '',
|
||||
type: 'task',
|
||||
assignee: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
predecessor: '',
|
||||
estimatedHours: 0,
|
||||
actualHours: 0,
|
||||
progress: 0,
|
||||
description: '',
|
||||
parentId: undefined,
|
||||
})
|
||||
|
||||
// 清除错误信息
|
||||
Object.keys(errors).forEach(key => {
|
||||
errors[key as keyof typeof errors] = ''
|
||||
})
|
||||
}
|
||||
|
||||
// 表单验证
|
||||
const validateForm = (): boolean => {
|
||||
let isValid = true
|
||||
Object.keys(errors).forEach(key => {
|
||||
errors[key as keyof typeof errors] = ''
|
||||
})
|
||||
if (!formData.name?.trim()) {
|
||||
errors.name = t.value.taskNameRequired
|
||||
isValid = false
|
||||
} else if (formData.name.length > 50) {
|
||||
errors.name = t.value.taskNameTooLong
|
||||
isValid = false
|
||||
}
|
||||
if (!formData.type) {
|
||||
errors.type = t.value.taskTypeRequired
|
||||
isValid = false
|
||||
}
|
||||
if (!formData.startDate) {
|
||||
errors.startDate = t.value.startDateRequired
|
||||
isValid = false
|
||||
}
|
||||
if (!formData.endDate) {
|
||||
errors.endDate = t.value.endDateRequired
|
||||
isValid = false
|
||||
} else if (formData.startDate && new Date(formData.endDate) < new Date(formData.startDate)) {
|
||||
errors.endDate = t.value.endDateInvalid
|
||||
isValid = false
|
||||
}
|
||||
return isValid
|
||||
}
|
||||
|
||||
// 关闭抽屉
|
||||
const handleClose = () => {
|
||||
isVisible.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 点击遮罩层关闭
|
||||
const handleOverlayClick = () => {
|
||||
handleClose()
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
submitting.value = true
|
||||
const taskData: Task = {
|
||||
...formData,
|
||||
id: props.isEdit && props.task ? props.task.id : Date.now(),
|
||||
}
|
||||
emit('submit', taskData)
|
||||
showMessage(props.isEdit ? t.value.taskUpdateSuccess : t.value.taskCreateSuccess, 'success')
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
// 记录异常,保证 lint 通过
|
||||
console.error(error)
|
||||
showMessage(t.value.operationFailed, 'error')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
const handleDelete = () => {
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
showDeleteConfirm.value = false
|
||||
if (props.task && props.isEdit) {
|
||||
try {
|
||||
submitting.value = true
|
||||
emit('delete', props.task)
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
showMessage(t.value.taskDeleteFailed, 'error')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cancelDelete = () => {
|
||||
showDeleteConfirm.value = false
|
||||
}
|
||||
|
||||
// 获取任务数据的事件处理器
|
||||
const handleTasksChanged = (event: CustomEvent) => {
|
||||
allTasks.value = event.detail || []
|
||||
}
|
||||
|
||||
// 组件挂载时添加事件监听器
|
||||
onMounted(() => {
|
||||
window.addEventListener('task-list-updated', handleTasksChanged as EventListener)
|
||||
// 请求初始任务数据
|
||||
window.dispatchEvent(new CustomEvent('request-task-list'))
|
||||
})
|
||||
|
||||
// 组件卸载时移除事件监听器
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('task-list-updated', handleTasksChanged as EventListener)
|
||||
})
|
||||
|
||||
// 简单的消息提示函数
|
||||
const showMessage = (message: string, type: 'success' | 'error') => {
|
||||
// 创建消息元素
|
||||
const messageEl = document.createElement('div')
|
||||
messageEl.className = `message ${type}`
|
||||
messageEl.textContent = message
|
||||
messageEl.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
z-index: 9999;
|
||||
background: ${type === 'success' ? '#67c23a' : '#f56c6c'};
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
`
|
||||
|
||||
document.body.appendChild(messageEl)
|
||||
|
||||
// 3秒后自动移除
|
||||
setTimeout(() => {
|
||||
if (messageEl.parentNode) {
|
||||
document.body.removeChild(messageEl)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isVisible" class="drawer-overlay" @click="handleOverlayClick">
|
||||
<div class="drawer-container" @click.stop>
|
||||
<!-- Drawer Header -->
|
||||
<div class="drawer-header">
|
||||
<h3 class="drawer-title">{{ isEdit ? t.editTask : t.newTask }}</h3>
|
||||
<button class="drawer-close-btn" type="button" @click="handleClose">
|
||||
<svg class="close-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Drawer Body -->
|
||||
<div class="drawer-body">
|
||||
<form class="task-form" @submit.prevent="handleSubmit">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-name">
|
||||
{{ t.taskName }} <span class="required">*</span></label
|
||||
>
|
||||
<input
|
||||
id="task-name"
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
class="form-input"
|
||||
:class="{ error: errors.name }"
|
||||
:placeholder="t.taskNamePlaceholder"
|
||||
/>
|
||||
<span v-if="errors.name" class="error-text">{{ errors.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-type">
|
||||
{{ t.taskType }} <span class="required">*</span></label
|
||||
>
|
||||
<select
|
||||
id="task-type"
|
||||
v-model="formData.type"
|
||||
class="form-select"
|
||||
:class="{ error: errors.type }"
|
||||
>
|
||||
<option value="story">{{ t.taskTypeMap.story }}</option>
|
||||
<option value="task">{{ t.taskTypeMap.task }}</option>
|
||||
<option value="bug">{{ t.taskTypeMap.bug }}</option>
|
||||
</select>
|
||||
<span v-if="errors.type" class="error-text">{{ errors.type }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-assignee">{{ t.assignee }}</label>
|
||||
<select id="task-assignee" v-model="formData.assignee" class="form-select">
|
||||
<option value="">请选择负责人</option>
|
||||
<option value="张三">张三</option>
|
||||
<option value="李四">李四</option>
|
||||
<option value="王五">王五</option>
|
||||
<option value="赵六">赵六</option>
|
||||
<option value="钱七">钱七</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 上级任务选择 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-parent">{{ t.parentTask }}</label>
|
||||
<select id="task-parent" v-model="formData.parentId" class="form-select">
|
||||
<option :value="undefined">{{ t.noParentTask }}</option>
|
||||
<option
|
||||
v-for="parentTask in availableParentTasks"
|
||||
:key="parentTask.id"
|
||||
:value="parentTask.id"
|
||||
>
|
||||
{{ parentTask.displayName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-start-date">
|
||||
{{ t.startDate }} <span class="required">*</span></label
|
||||
>
|
||||
<DatePicker
|
||||
id="task-start-date"
|
||||
v-model="formData.startDate"
|
||||
type="date"
|
||||
:placeholder="t.startDateRequired"
|
||||
:class="{ error: errors.startDate }"
|
||||
/>
|
||||
<span v-if="errors.startDate" class="error-text">{{ errors.startDate }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-end-date">
|
||||
{{ t.endDate }} <span class="required">*</span></label
|
||||
>
|
||||
<DatePicker
|
||||
id="task-end-date"
|
||||
v-model="formData.endDate"
|
||||
type="date"
|
||||
:placeholder="t.endDateRequired"
|
||||
:class="{ error: errors.endDate }"
|
||||
/>
|
||||
<span v-if="errors.endDate" class="error-text">{{ errors.endDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-predecessor">{{ t.predecessor }}</label>
|
||||
<select id="task-predecessor" v-model="formData.predecessor" class="form-select">
|
||||
<option value="">{{ t.predecessorPlaceholder }}</option>
|
||||
<option
|
||||
v-for="predTask in availablePredecessorTasks"
|
||||
:key="predTask.id"
|
||||
:value="predTask.id"
|
||||
>
|
||||
{{ predTask.name }} (ID: {{ predTask.id }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-estimated-hours">{{ t.estimatedHours }}</label>
|
||||
<input
|
||||
id="task-estimated-hours"
|
||||
v-model.number="formData.estimatedHours"
|
||||
type="number"
|
||||
class="form-input"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
max="999"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-actual-hours">{{ t.actualHours }}</label>
|
||||
<input
|
||||
id="task-actual-hours"
|
||||
v-model.number="formData.actualHours"
|
||||
type="number"
|
||||
class="form-input"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
max="999"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-progress">{{ t.progress }}</label>
|
||||
<div class="progress-container">
|
||||
<input
|
||||
id="task-progress"
|
||||
v-model.number="formData.progress"
|
||||
type="range"
|
||||
class="progress-slider"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
/>
|
||||
<div class="progress-value">{{ formData.progress }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-description">{{ t.description }}</label>
|
||||
<textarea
|
||||
id="task-description"
|
||||
v-model="formData.description"
|
||||
class="form-textarea"
|
||||
:placeholder="t.descriptionPlaceholder"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Drawer Footer -->
|
||||
<div class="drawer-footer">
|
||||
<div class="footer-left">
|
||||
<!-- 删除按钮,仅在编辑模式下显示 -->
|
||||
<button
|
||||
v-if="isEdit && task"
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
:disabled="submitting"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<span v-if="submitting" class="loading-spinner"></span>
|
||||
{{ t.delete }}
|
||||
</button>
|
||||
<GanttConfirmDialog
|
||||
:visible="showDeleteConfirm"
|
||||
:title="t.delete"
|
||||
:message="t.confirmDeleteTask.replace('{name}', task?.name || '')"
|
||||
:confirm-text="t.confirm"
|
||||
:cancel-text="t.cancel"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="cancelDelete"
|
||||
/>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<button type="button" class="btn btn-default" @click="handleClose">{{ t.cancel }}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<span v-if="submitting" class="loading-spinner"></span>
|
||||
{{ isEdit ? t.update : t.create }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import '../styles/theme-variables.css';
|
||||
|
||||
/* 抽屉遮罩层 */
|
||||
.drawer-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 10000; /* 确保在全屏模式下也能正常显示 */
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 抽屉容器 */
|
||||
.drawer-container {
|
||||
width: 500px;
|
||||
background: var(--gantt-bg-primary, white);
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
color: var(--gantt-text-primary, #303133);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 抽屉头部 */
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--gantt-border-light, #ebeef5);
|
||||
background: var(--gantt-bg-secondary, #f5f7fa);
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--gantt-text-primary, #303133);
|
||||
}
|
||||
|
||||
.drawer-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
color: var(--gantt-text-muted, #909399);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.drawer-close-btn:hover {
|
||||
color: var(--gantt-text-secondary, #606266);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
/* 抽屉主体 */
|
||||
.drawer-body {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
.task-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gantt-text-secondary, #606266);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--gantt-danger, #f56c6c);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--gantt-border-medium, #dcdfe6);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: var(--gantt-text-primary, #303133); /* 录入后为黑色,与 MilestoneDialog 保持一致 */
|
||||
background: var(--gantt-bg-primary, white);
|
||||
transition: border-color 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus,
|
||||
.form-textarea:focus {
|
||||
border-color: var(--gantt-primary, #409eff);
|
||||
}
|
||||
|
||||
.form-input.error,
|
||||
.form-select.error {
|
||||
border-color: var(--gantt-danger, #f56c6c);
|
||||
}
|
||||
|
||||
.form-input::placeholder,
|
||||
.form-select::placeholder,
|
||||
.form-textarea::placeholder {
|
||||
color: var(--gantt-text-placeholder, #c0c4cc);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--gantt-danger, #f56c6c);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 进度条容器 */
|
||||
.progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.progress-slider {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--gantt-border-light, #e4e7ed);
|
||||
outline: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--gantt-primary, #409eff);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--gantt-primary, #409eff);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gantt-text-secondary, #606266);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 抽屉底部 */
|
||||
.drawer-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--gantt-border-light, #ebeef5);
|
||||
background: var(--gantt-bg-toolbar, #fafafa);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 消息提示样式 */
|
||||
.message {
|
||||
animation: messageSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes messageSlideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗黑模式样式优化 */
|
||||
:global(html[data-theme='dark']) .drawer-overlay {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .drawer-container {
|
||||
box-shadow: -4px 0 15px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .drawer-close-btn:hover {
|
||||
background: var(--gantt-bg-hover, rgba(255, 255, 255, 0.1)) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .form-input:focus,
|
||||
:global(html[data-theme='dark']) .form-select:focus,
|
||||
:global(html[data-theme='dark']) .form-textarea:focus {
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .form-input::placeholder,
|
||||
:global(html[data-theme='dark']) .form-textarea::placeholder {
|
||||
color: var(--gantt-text-muted, #9e9e9e) !important;
|
||||
}
|
||||
</style>
|
||||
485
src/components/TaskList.vue
Normal file
485
src/components/TaskList.vue
Normal file
@@ -0,0 +1,485 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import TaskRow from './TaskRow.vue'
|
||||
import { useI18n } from '../composables/useI18n'
|
||||
import type { Task } from '../models/classes/Task'
|
||||
|
||||
interface Props {
|
||||
tasks?: Task[]
|
||||
onTaskDoubleClick?: (task: Task) => void
|
||||
editComponent?: any
|
||||
useDefaultDrawer?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 定义emit事件
|
||||
const emit = defineEmits(['task-collapse-change'])
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 内部响应式任务列表
|
||||
const localTasks = ref<Task[]>([])
|
||||
|
||||
// 悬停状态管理
|
||||
const hoveredTaskId = ref<number | null>(null)
|
||||
|
||||
// 拖拽状态管理
|
||||
const isSplitterDragging = ref(false)
|
||||
|
||||
// 处理拖拽开始事件
|
||||
const handleSplitterDragStart = () => {
|
||||
isSplitterDragging.value = true
|
||||
}
|
||||
|
||||
// 处理拖拽结束事件
|
||||
const handleSplitterDragEnd = () => {
|
||||
isSplitterDragging.value = false
|
||||
}
|
||||
|
||||
// 处理任务行悬停事件
|
||||
const handleTaskRowHover = (taskId: number | null) => {
|
||||
// 如果正在拖拽Splitter,则不响应悬停事件
|
||||
if (isSplitterDragging.value) {
|
||||
return
|
||||
}
|
||||
|
||||
hoveredTaskId.value = taskId
|
||||
// 发送事件通知Timeline组件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('task-list-hover', {
|
||||
detail: taskId,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 监听Timeline的悬停事件
|
||||
const handleTimelineHover = (event: CustomEvent) => {
|
||||
hoveredTaskId.value = event.detail
|
||||
}
|
||||
|
||||
// 处理TaskRow双击事件 (与Timeline的逻辑保持一致)
|
||||
const handleTaskRowDoubleClick = (task: Task) => {
|
||||
// 如果是里程碑类型,则不响应双击事件
|
||||
if (task.type === 'milestone' || task.type === 'milestone-group') {
|
||||
return
|
||||
}
|
||||
|
||||
// 优先调用外部传入的双击处理器
|
||||
if (props.onTaskDoubleClick && typeof props.onTaskDoubleClick === 'function') {
|
||||
props.onTaskDoubleClick(task)
|
||||
} else if (props.useDefaultDrawer) {
|
||||
// 默认行为:发送到Timeline处理(通过全局事件)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('task-row-double-click', {
|
||||
detail: task,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算父级任务的进度和日期范围
|
||||
const calculateParentTaskData = (
|
||||
task: Task,
|
||||
): { progress: number; startDate: string; endDate: string } => {
|
||||
if (!task.children || task.children.length === 0) {
|
||||
return {
|
||||
progress: task.progress || 0,
|
||||
startDate: task.startDate || '',
|
||||
endDate: task.endDate || '',
|
||||
}
|
||||
}
|
||||
|
||||
// 递归计算所有子任务(包括折叠的子任务)
|
||||
const allChildTasks: Task[] = []
|
||||
const collectChildTasks = (tasks: Task[]) => {
|
||||
tasks.forEach(childTask => {
|
||||
allChildTasks.push(childTask)
|
||||
if (childTask.children && childTask.children.length > 0) {
|
||||
collectChildTasks(childTask.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
collectChildTasks(task.children)
|
||||
|
||||
// 计算进度:所有子任务进度的平均值
|
||||
const totalProgress = allChildTasks.reduce((sum, childTask) => {
|
||||
return sum + (childTask.progress || 0)
|
||||
}, 0)
|
||||
const averageProgress =
|
||||
allChildTasks.length > 0 ? Math.round(totalProgress / allChildTasks.length) : 0
|
||||
|
||||
// 计算日期范围:最早开始日期和最晚结束日期
|
||||
const validTasks = allChildTasks.filter(childTask => childTask.startDate && childTask.endDate)
|
||||
if (validTasks.length === 0) {
|
||||
return {
|
||||
progress: averageProgress,
|
||||
startDate: task.startDate || '',
|
||||
endDate: task.endDate || '',
|
||||
}
|
||||
}
|
||||
|
||||
const startDates = validTasks.map(childTask => new Date(childTask.startDate!))
|
||||
const endDates = validTasks.map(childTask => new Date(childTask.endDate!))
|
||||
|
||||
const earliestStart = new Date(Math.min(...startDates.map(date => date.getTime())))
|
||||
const latestEnd = new Date(Math.max(...endDates.map(date => date.getTime())))
|
||||
|
||||
return {
|
||||
progress: averageProgress,
|
||||
startDate: earliestStart.toISOString().split('T')[0],
|
||||
endDate: latestEnd.toISOString().split('T')[0],
|
||||
}
|
||||
}
|
||||
|
||||
// 更新所有父级任务的进度和日期范围
|
||||
const updateParentTasksData = () => {
|
||||
const updateParentTask = (taskList: Task[]): Task[] => {
|
||||
return taskList.map(task => {
|
||||
if (task.children && task.children.length > 0) {
|
||||
// 先更新子任务
|
||||
const updatedChildren = updateParentTask(task.children)
|
||||
|
||||
// 计算父级任务的进度和日期范围
|
||||
const parentData = calculateParentTaskData({
|
||||
...task,
|
||||
children: updatedChildren,
|
||||
})
|
||||
|
||||
return {
|
||||
...task,
|
||||
progress: parentData.progress,
|
||||
startDate: parentData.startDate,
|
||||
endDate: parentData.endDate,
|
||||
children: updatedChildren,
|
||||
}
|
||||
}
|
||||
return task
|
||||
})
|
||||
}
|
||||
|
||||
localTasks.value = updateParentTask(localTasks.value)
|
||||
}
|
||||
|
||||
// 获取所有任务的扁平化列表(包括子任务)
|
||||
const getAllTasks = (taskList: Task[]): Task[] => {
|
||||
const allTasks: Task[] = []
|
||||
|
||||
const collectTasks = (tasks: Task[]) => {
|
||||
tasks.forEach(task => {
|
||||
allTasks.push(task)
|
||||
if (task.children && task.children.length > 0) {
|
||||
collectTasks(task.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
collectTasks(taskList)
|
||||
return allTasks
|
||||
}
|
||||
|
||||
// 监听外部传入的 tasks 数据变化
|
||||
watch(
|
||||
() => props.tasks,
|
||||
newTasks => {
|
||||
localTasks.value = newTasks || []
|
||||
// 更新父级任务数据
|
||||
updateParentTasksData()
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
function toggleCollapse(task: Task) {
|
||||
task.collapsed = !task.collapsed
|
||||
|
||||
// 触发自定义事件,通知父组件任务折叠状态变化
|
||||
emit('task-collapse-change', task)
|
||||
}
|
||||
|
||||
// 更新任务数据
|
||||
const updateTaskData = (updatedTask: Task) => {
|
||||
const updateTaskInTree = (taskList: Task[]): Task[] => {
|
||||
return taskList.map(task => {
|
||||
if (task.id === updatedTask.id) {
|
||||
return {
|
||||
...task,
|
||||
...updatedTask, // 完整更新所有字段
|
||||
children: task.children, // 保留子任务结构
|
||||
}
|
||||
}
|
||||
if (task.children) {
|
||||
return {
|
||||
...task,
|
||||
children: updateTaskInTree(task.children),
|
||||
}
|
||||
}
|
||||
return task
|
||||
})
|
||||
}
|
||||
|
||||
localTasks.value = updateTaskInTree(localTasks.value)
|
||||
|
||||
// 更新父级任务的进度和日期范围
|
||||
updateParentTasksData()
|
||||
}
|
||||
|
||||
// 监听TaskBar更新事件
|
||||
const handleTaskUpdated = (event: CustomEvent) => {
|
||||
const updatedTask = event.detail
|
||||
updateTaskData(updatedTask)
|
||||
}
|
||||
|
||||
// 监听Timeline的任务新增事件
|
||||
const handleTaskAdded = (event: CustomEvent) => {
|
||||
const newTask = event.detail
|
||||
localTasks.value.push(newTask)
|
||||
|
||||
// 更新父级任务数据
|
||||
updateParentTasksData()
|
||||
}
|
||||
|
||||
// 监听 TaskDrawer 请求任务列表事件
|
||||
const handleRequestTaskList = () => {
|
||||
// 获取所有任务的扁平化列表
|
||||
const allTasks = getAllTasks(localTasks.value)
|
||||
// 发送任务列表给 TaskDrawer
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('task-list-updated', {
|
||||
detail: allTasks,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 处理里程碑图标变更事件
|
||||
const handleMilestoneIconChange = (event: CustomEvent) => {
|
||||
const { milestoneId, icon } = event.detail
|
||||
|
||||
// 递归更新里程碑图标
|
||||
const updateMilestoneIcon = (taskList: Task[]) => {
|
||||
for (const task of taskList) {
|
||||
if (task.type === 'milestone-group' && task.children) {
|
||||
const milestone = task.children.find(m => m.id === milestoneId)
|
||||
if (milestone) {
|
||||
milestone.icon = icon
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (task.children && updateMilestoneIcon(task.children)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
updateMilestoneIcon(localTasks.value)
|
||||
}
|
||||
|
||||
// 垂直滚动同步处理
|
||||
const handleTaskListScroll = (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target) return
|
||||
|
||||
const scrollTop = target.scrollTop
|
||||
|
||||
// 同步垂直滚动到Timeline
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('task-list-vertical-scroll', {
|
||||
detail: { scrollTop },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 处理Timeline垂直滚动同步
|
||||
const handleTimelineVerticalScroll = (event: CustomEvent) => {
|
||||
const { scrollTop } = event.detail
|
||||
const taskListBodyElement = document.querySelector('.task-list-body') as HTMLElement
|
||||
if (taskListBodyElement && taskListBodyElement.scrollTop !== scrollTop) {
|
||||
// 避免循环触发,只在scrollTop不同时才设置
|
||||
taskListBodyElement.scrollTop = scrollTop
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('task-updated', handleTaskUpdated as EventListener)
|
||||
window.addEventListener('task-added', handleTaskAdded as EventListener)
|
||||
window.addEventListener('request-task-list', handleRequestTaskList as EventListener)
|
||||
window.addEventListener('timeline-task-hover', handleTimelineHover as EventListener)
|
||||
window.addEventListener('timeline-vertical-scroll', handleTimelineVerticalScroll as EventListener)
|
||||
window.addEventListener('milestone-icon-changed', handleMilestoneIconChange as EventListener)
|
||||
// 监听Splitter拖拽事件
|
||||
window.addEventListener('splitter-drag-start', handleSplitterDragStart as EventListener)
|
||||
window.addEventListener('splitter-drag-end', handleSplitterDragEnd as EventListener)
|
||||
|
||||
// 初始化时计算父级任务的进度和日期范围
|
||||
updateParentTasksData()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('task-updated', handleTaskUpdated as EventListener)
|
||||
window.removeEventListener('task-added', handleTaskAdded as EventListener)
|
||||
window.removeEventListener('request-task-list', handleRequestTaskList as EventListener)
|
||||
window.removeEventListener('timeline-task-hover', handleTimelineHover as EventListener)
|
||||
window.removeEventListener(
|
||||
'timeline-vertical-scroll',
|
||||
handleTimelineVerticalScroll as EventListener,
|
||||
)
|
||||
window.removeEventListener('milestone-icon-changed', handleMilestoneIconChange as EventListener)
|
||||
window.removeEventListener('splitter-drag-start', handleSplitterDragStart as EventListener)
|
||||
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd as EventListener)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="task-list">
|
||||
<div class="task-list-header">
|
||||
<div class="col col-name">{{ t.taskName }}</div>
|
||||
<div class="col col-pre">{{ t.predecessor }}</div>
|
||||
<div class="col col-assignee">{{ t.assignee }}</div>
|
||||
<div class="col col-date">{{ t.startDate }}</div>
|
||||
<div class="col col-date">{{ t.endDate }}</div>
|
||||
<div class="col col-hours">{{ t.estimatedHours }}</div>
|
||||
<div class="col col-hours">{{ t.actualHours }}</div>
|
||||
<div class="col col-progress">{{ t.progress }}</div>
|
||||
</div>
|
||||
<div class="task-list-body" @scroll="handleTaskListScroll">
|
||||
<TaskRow
|
||||
v-for="task in localTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:level="0"
|
||||
:is-hovered="hoveredTaskId === task.id"
|
||||
:hovered-task-id="hoveredTaskId"
|
||||
:on-double-click="props.onTaskDoubleClick"
|
||||
:on-hover="handleTaskRowHover"
|
||||
@toggle="toggleCollapse"
|
||||
@dblclick="handleTaskRowDoubleClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import '../styles/theme-variables.css';
|
||||
|
||||
.task-list {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 15px;
|
||||
color: var(--gantt-text-primary);
|
||||
background: var(--gantt-bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: auto; /* 防止内容溢出 */
|
||||
|
||||
/* Webkit浏览器滚动条样式 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
|
||||
}
|
||||
|
||||
.task-list-header {
|
||||
display: flex;
|
||||
background: var(--gantt-bg-secondary);
|
||||
border-bottom: 1px solid var(--gantt-border-medium);
|
||||
border-left: 3px solid transparent; /* 添加3px透明左边框保持对齐 */
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
height: 80px;
|
||||
align-items: center;
|
||||
width: max-content;
|
||||
flex-shrink: 0; /* 防止header被压缩 */
|
||||
position: sticky; /* 使header固定 */
|
||||
top: 0;
|
||||
z-index: 10; /* 确保在滚动时保持在最上层 */
|
||||
}
|
||||
|
||||
.col {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--gantt-border-light);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.task-list-header .col {
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
background: var(--gantt-bg-secondary);
|
||||
color: var(--gantt-text-header);
|
||||
border-right-color: var(--gantt-border-medium);
|
||||
}
|
||||
|
||||
.col:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
flex: 2 0 300px;
|
||||
min-width: 300px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.col-pre {
|
||||
flex: 1 0 120px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.col-assignee {
|
||||
flex: 1 0 120px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.col-date {
|
||||
flex: 1.2 0 140px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.col-hours {
|
||||
flex: 1 0 100px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.col-progress {
|
||||
flex: 1 0 100px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.task-list-body {
|
||||
width: max-content;
|
||||
background: var(--gantt-bg-primary);
|
||||
flex: 1;
|
||||
overflow-x: hidden; /* 让body部分可以滚动 */
|
||||
overflow-y: auto; /* 允许垂直滚动 */
|
||||
|
||||
/* Webkit浏览器滚动条样式 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
|
||||
}
|
||||
|
||||
.task-list-body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.task-list-body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.task-list-body::-webkit-scrollbar-thumb {
|
||||
background-color: var(--gantt-scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.task-list-body::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--gantt-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
.task-list-body::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
722
src/components/TaskRow.vue
Normal file
722
src/components/TaskRow.vue
Normal file
@@ -0,0 +1,722 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useI18n } from '../composables/useI18n'
|
||||
import type { Task } from '../models/classes/Task'
|
||||
|
||||
interface Props {
|
||||
task: Task
|
||||
level: number
|
||||
onDoubleClick?: (task: Task) => void
|
||||
isHovered?: boolean
|
||||
hoveredTaskId?: number | null
|
||||
onHover?: (taskId: number | null) => void
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['toggle', 'dblclick'])
|
||||
const { t } = useI18n()
|
||||
const overtimeText = computed(() => t.value?.overtime ?? '')
|
||||
const overdueText = computed(() => t.value?.overdue ?? '')
|
||||
const daysText = computed(() => t.value?.days ?? '')
|
||||
|
||||
const baseIndent = 10
|
||||
const indent = `${baseIndent + props.level * 20}px`
|
||||
function handleToggle() {
|
||||
emit('toggle', props.task)
|
||||
}
|
||||
|
||||
function handleRowClick() {
|
||||
// 如果是普通父级任务(story类型或有子任务的任务,非里程碑分组),点击行也可以展开/收起
|
||||
if (
|
||||
(props.task.type === 'story' || (props.task.children && props.task.children.length > 0)) &&
|
||||
props.task.type !== 'milestone-group'
|
||||
) {
|
||||
emit('toggle', props.task)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理TaskRow双击事件 (与TaskBar逻辑保持一致)
|
||||
const handleTaskRowDoubleClick = (e: MouseEvent) => {
|
||||
// 阻止事件冒泡
|
||||
e.stopPropagation()
|
||||
|
||||
// 优先调用外部传入的双击处理器
|
||||
if (props.onDoubleClick && typeof props.onDoubleClick === 'function') {
|
||||
props.onDoubleClick(props.task)
|
||||
} else {
|
||||
// 默认行为:发出双击事件给父组件
|
||||
emit('dblclick', props.task)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理悬停事件
|
||||
const handleMouseEnter = () => {
|
||||
// 如果正在拖拽Splitter,忽略悬停事件
|
||||
if (isSplitterDragging.value) return
|
||||
|
||||
if (props.onHover) {
|
||||
props.onHover(props.task.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
// 如果正在拖拽Splitter,忽略悬停事件
|
||||
if (isSplitterDragging.value) return
|
||||
|
||||
if (props.onHover) {
|
||||
props.onHover(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取进度值的样式类
|
||||
function getProgressClass() {
|
||||
const progress = props.task.progress || 0
|
||||
const today = new Date()
|
||||
const endDate = props.task.endDate ? new Date(props.task.endDate) : null
|
||||
|
||||
// 超期且未完成
|
||||
if (endDate && today > endDate && progress < 100) {
|
||||
return 'progress-danger'
|
||||
}
|
||||
|
||||
// 已完成
|
||||
if (progress >= 100) {
|
||||
return 'progress-success'
|
||||
}
|
||||
|
||||
// 进行中
|
||||
if (progress > 0) {
|
||||
return 'progress-warning'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
// 检查是否超时
|
||||
function isOvertime() {
|
||||
return (
|
||||
props.task.actualHours &&
|
||||
props.task.estimatedHours &&
|
||||
props.task.actualHours > props.task.estimatedHours
|
||||
)
|
||||
}
|
||||
|
||||
// 检查是否逾期,返回天数
|
||||
function overdueDays() {
|
||||
const today = new Date()
|
||||
const endDate = props.task.endDate ? new Date(props.task.endDate) : null
|
||||
const progress = props.task.progress || 0
|
||||
if (endDate && today > endDate && progress < 100) {
|
||||
// 只计算日期部分
|
||||
const t = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
const e = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
|
||||
const diff = Math.floor((t.getTime() - e.getTime()) / (1000 * 60 * 60 * 24))
|
||||
return diff
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 拖拽状态管理
|
||||
const isSplitterDragging = ref(false)
|
||||
|
||||
// 处理拖拽开始事件
|
||||
const handleSplitterDragStart = () => {
|
||||
isSplitterDragging.value = true
|
||||
}
|
||||
|
||||
// 处理拖拽结束事件
|
||||
const handleSplitterDragEnd = () => {
|
||||
isSplitterDragging.value = false
|
||||
}
|
||||
|
||||
// 生命周期钩子 - 注册事件监听器
|
||||
onMounted(() => {
|
||||
window.addEventListener('splitter-drag-start', handleSplitterDragStart)
|
||||
window.addEventListener('splitter-drag-end', handleSplitterDragEnd)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('splitter-drag-start', handleSplitterDragStart)
|
||||
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="task-row"
|
||||
:class="{
|
||||
'task-row-hovered': isHovered,
|
||||
'parent-task':
|
||||
props.task.type === 'story' ||
|
||||
(props.task.children && props.task.children.length > 0) ||
|
||||
props.task.type === 'milestone-group',
|
||||
'milestone-group-row': props.task.type === 'milestone-group',
|
||||
'task-type-story': props.task.type === 'story',
|
||||
'task-type-task': props.task.type === 'task',
|
||||
'task-type-milestone': props.task.type === 'milestone',
|
||||
}"
|
||||
@click="handleRowClick"
|
||||
@dblclick="handleTaskRowDoubleClick"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<div class="col col-name" :style="{ paddingLeft: indent }">
|
||||
<span
|
||||
v-if="
|
||||
(props.task.type === 'story' ||
|
||||
(props.task.children && props.task.children.length > 0)) &&
|
||||
props.task.type !== 'milestone-group'
|
||||
"
|
||||
class="collapse-btn"
|
||||
@click.stop="handleToggle"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline v-if="props.task.collapsed" points="9,18 15,12 9,6" />
|
||||
<polyline v-else points="18,15 12,9 6,15" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<!-- 里程碑分组的占位空间,用于与有折叠按钮的任务对齐 -->
|
||||
<span v-if="props.task.type === 'milestone-group'" class="milestone-spacer"></span>
|
||||
|
||||
<!-- 任务图标 -->
|
||||
<span class="task-icon">
|
||||
<!-- 里程碑分组图标 - 使用菱形图标 -->
|
||||
<svg
|
||||
v-if="props.task.type === 'milestone-group'"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="milestone-group-icon"
|
||||
>
|
||||
<polygon points="12,2 22,12 12,22 2,12" />
|
||||
</svg>
|
||||
<!-- 父级任务图标 -->
|
||||
<svg
|
||||
v-else-if="
|
||||
props.task.type === 'story' || (props.task.children && props.task.children.length > 0)
|
||||
"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<!-- 普通任务图标 -->
|
||||
<svg
|
||||
v-else
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10,9 9,9 8,9" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="task-name-text"
|
||||
:class="{
|
||||
'parent-task':
|
||||
props.task.type === 'story' ||
|
||||
(props.task.children && props.task.children.length > 0) ||
|
||||
props.task.type === 'milestone-group',
|
||||
}"
|
||||
:title="props.task.name"
|
||||
>
|
||||
{{ props.task.name }}
|
||||
<span v-if="isOvertime()" class="status-badge overtime">{{ overtimeText }}</span>
|
||||
<span v-if="overdueDays() > 0" class="status-badge overdue">
|
||||
{{ overdueText }}{{ overdueDays() > 0 ? overdueDays() + daysText : '' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 里程碑分组不显示详细信息 -->
|
||||
<template v-if="props.task.type === 'milestone-group'">
|
||||
<div class="col col-pre milestone-empty-col"></div>
|
||||
<div class="col col-assignee milestone-empty-col"></div>
|
||||
<div class="col col-date milestone-empty-col"></div>
|
||||
<div class="col col-date milestone-empty-col"></div>
|
||||
<div class="col col-hours milestone-empty-col"></div>
|
||||
<div class="col col-hours milestone-empty-col"></div>
|
||||
<div class="col col-progress milestone-empty-col"></div>
|
||||
</template>
|
||||
|
||||
<!-- 普通任务显示详细信息 -->
|
||||
<template v-else>
|
||||
<div class="col col-pre">{{ props.task.predecessor || '-' }}</div>
|
||||
<div class="col col-assignee">
|
||||
<div class="assignee-info">
|
||||
<div class="avatar">
|
||||
{{ props.task.assignee ? props.task.assignee.charAt(0) : '-' }}
|
||||
</div>
|
||||
<span class="assignee-name">{{ props.task.assignee || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-date">{{ props.task.startDate || '-' }}</div>
|
||||
<div class="col col-date">{{ props.task.endDate || '-' }}</div>
|
||||
<div class="col col-hours">{{ props.task.estimatedHours || '-' }}</div>
|
||||
<div class="col col-hours">{{ props.task.actualHours || '-' }}</div>
|
||||
<div class="col col-progress">
|
||||
<span class="progress-value" :class="getProgressClass()">
|
||||
{{ props.task.progress != null ? props.task.progress + '%' : '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template
|
||||
v-if="props.task.children && !props.task.collapsed && props.task.type !== 'milestone-group'"
|
||||
>
|
||||
<TaskRow
|
||||
v-for="child in props.task.children"
|
||||
:key="child.id"
|
||||
:task="child"
|
||||
:level="props.level + 1"
|
||||
:is-hovered="props.hoveredTaskId === child.id"
|
||||
:hovered-task-id="props.hoveredTaskId"
|
||||
:on-double-click="props.onDoubleClick"
|
||||
:on-hover="props.onHover"
|
||||
@toggle="emit('toggle', $event)"
|
||||
@dblclick="emit('dblclick', $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import '../styles/theme-variables.css';
|
||||
|
||||
.task-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--gantt-border-light);
|
||||
height: 50px;
|
||||
background: var(--gantt-bg-primary);
|
||||
align-items: center;
|
||||
color: var(--gantt-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
transform: scale(1);
|
||||
transform-origin: 5px center; /* 从左侧偏右5px的位置作为放大中心 */
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-row:hover {
|
||||
background-color: var(--gantt-bg-hover);
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.task-row-hovered {
|
||||
background-color: var(--gantt-bg-hover) !important;
|
||||
transform: scale(1.02) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
.task-row.parent-task {
|
||||
background: var(--gantt-bg-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-row.parent-task:hover {
|
||||
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover));
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.task-row.parent-task.task-row-hovered {
|
||||
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover)) !important;
|
||||
transform: scale(1.02) !important;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2) !important;
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
/* 里程碑分组行特殊样式 - 使用红色边框 */
|
||||
.milestone-group-row {
|
||||
border-left: 3px solid var(--gantt-danger, #f56c6c);
|
||||
background: linear-gradient(90deg, var(--gantt-bg-tertiary) 0%, var(--gantt-bg-primary) 100%);
|
||||
}
|
||||
|
||||
.milestone-group-row:hover {
|
||||
background: linear-gradient(90deg, var(--gantt-bg-hover-parent) 0%, var(--gantt-bg-hover) 100%);
|
||||
transform: scale(1.02);
|
||||
box-shadow:
|
||||
0 6px 16px rgba(245, 108, 108, 0.3),
|
||||
0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
border-left-color: var(--gantt-danger, #f56c6c);
|
||||
border-left-width: 4px; /* 悬停时边框稍微加粗 */
|
||||
}
|
||||
|
||||
/* 任务类型左边框颜色 */
|
||||
.task-type-story {
|
||||
border-left: 3px solid var(--gantt-primary, #409eff);
|
||||
}
|
||||
|
||||
.task-type-task {
|
||||
border-left: 3px solid var(--gantt-warning, #e6a23c);
|
||||
}
|
||||
|
||||
.task-type-milestone {
|
||||
border-left: 3px solid var(--gantt-danger, #f56c6c);
|
||||
}
|
||||
|
||||
/* 任务类型悬停时保持并增强左边框 */
|
||||
.task-type-story:hover {
|
||||
border-left: 5px solid var(--gantt-primary, #409eff);
|
||||
}
|
||||
|
||||
.task-type-task:hover {
|
||||
border-left: 5px solid var(--gantt-warning, #e6a23c);
|
||||
}
|
||||
|
||||
.task-type-milestone:hover {
|
||||
border-left: 5px solid var(--gantt-danger, #f56c6c);
|
||||
}
|
||||
|
||||
/* 悬停状态下的左边框保持 */
|
||||
.task-row-hovered.task-type-story {
|
||||
border-left: 5px solid var(--gantt-primary, #409eff) !important;
|
||||
}
|
||||
|
||||
.task-row-hovered.task-type-task {
|
||||
border-left: 5px solid var(--gantt-warning, #e6a23c) !important;
|
||||
}
|
||||
|
||||
.task-row-hovered.task-type-milestone {
|
||||
border-left: 5px solid var(--gantt-danger, #f56c6c) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .milestone-group-row {
|
||||
border-left-color: var(--gantt-danger, #f67c7c);
|
||||
}
|
||||
|
||||
/* 暗黑模式下的任务类型左边框颜色 */
|
||||
:global(html[data-theme='dark']) .task-type-story {
|
||||
border-left-color: var(--gantt-primary, #7db4f0);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .task-type-task {
|
||||
border-left-color: var(--gantt-warning, #f0b83c);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .task-type-milestone {
|
||||
border-left-color: var(--gantt-danger, #f67c7c);
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
margin-right: 4px;
|
||||
color: var(--gantt-primary);
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background-color: var(--gantt-primary-light);
|
||||
}
|
||||
|
||||
.collapse-btn svg {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* 里程碑分组占位空间 - 与折叠按钮对齐 */
|
||||
.milestone-spacer {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.col {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--gantt-border-light);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.col:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
flex: 2 0 300px;
|
||||
min-width: 300px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.col-pre {
|
||||
flex: 1 0 120px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.col-assignee {
|
||||
flex: 1 0 120px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.col-date {
|
||||
flex: 1.2 0 140px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.col-hours {
|
||||
flex: 1 0 100px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.col-progress {
|
||||
flex: 1 0 100px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.task-name-text {
|
||||
display: inline-block;
|
||||
max-width: calc(100% - 24px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.task-name-text.parent-task {
|
||||
font-weight: bold;
|
||||
color: var(--gantt-text-parent, var(--gantt-text-primary));
|
||||
}
|
||||
|
||||
.task-icon {
|
||||
margin-right: 4px;
|
||||
color: var(--gantt-text-muted);
|
||||
}
|
||||
|
||||
.task-icon svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.assignee-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--gantt-primary);
|
||||
color: var(--gantt-text-white);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 2px solid var(--gantt-border-medium);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.assignee-name {
|
||||
font-size: 14px;
|
||||
color: var(--gantt-text-secondary);
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
font-weight: 500;
|
||||
color: var(--gantt-text-secondary);
|
||||
}
|
||||
|
||||
.progress-success {
|
||||
color: var(--gantt-success);
|
||||
}
|
||||
|
||||
.progress-warning {
|
||||
color: var(--gantt-warning);
|
||||
}
|
||||
|
||||
.progress-danger {
|
||||
color: var(--gantt-danger);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
margin-left: 6px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.overtime {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--gantt-danger);
|
||||
color: var(--gantt-danger);
|
||||
}
|
||||
|
||||
.status-badge.overdue {
|
||||
background-color: var(--gantt-danger);
|
||||
}
|
||||
|
||||
/* 里程碑分组图标样式 - 统一使用红色并添加发光效果 */
|
||||
.milestone-group-icon {
|
||||
color: var(--gantt-danger, #f56c6c);
|
||||
fill: var(--gantt-danger, #f56c6c);
|
||||
opacity: 0.9;
|
||||
filter: drop-shadow(0 0 6px var(--gantt-danger, #f56c6c));
|
||||
animation: milestone-icon-glow 2.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* 里程碑图标悬停效果 */
|
||||
.task-row:hover .milestone-group-icon {
|
||||
filter: drop-shadow(0 0 10px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 16px rgba(245, 108, 108, 0.4));
|
||||
animation: milestone-icon-glow-intense 1.8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* 里程碑图标发光动画 */
|
||||
@keyframes milestone-icon-glow {
|
||||
from {
|
||||
filter: drop-shadow(0 0 3px var(--gantt-danger, #f56c6c));
|
||||
}
|
||||
to {
|
||||
filter: drop-shadow(0 0 8px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 12px rgba(245, 108, 108, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes milestone-icon-glow-intense {
|
||||
from {
|
||||
filter: drop-shadow(0 0 8px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 12px rgba(245, 108, 108, 0.3));
|
||||
}
|
||||
to {
|
||||
filter: drop-shadow(0 0 12px var(--gantt-danger, #f56c6c))
|
||||
drop-shadow(0 0 20px rgba(245, 108, 108, 0.5));
|
||||
}
|
||||
}
|
||||
|
||||
/* 里程碑行样式 - 统一使用红色 */
|
||||
.milestone-item-icon {
|
||||
color: var(--gantt-danger, #f56c6c);
|
||||
}
|
||||
|
||||
.milestone-empty-col {
|
||||
color: var(--gantt-text-disabled, #c0c4cc);
|
||||
/* 确保边框颜色与普通数据行一致 */
|
||||
border-right-color: var(--gantt-border-light) !important;
|
||||
}
|
||||
|
||||
.milestone-empty-col::after {
|
||||
content: '-';
|
||||
}
|
||||
|
||||
/* 暗黑模式适配 */
|
||||
:global(html[data-theme='dark']) .milestone-row-icon {
|
||||
color: var(--gantt-danger, #f67c7c);
|
||||
}
|
||||
|
||||
/* 暗黑模式下的里程碑图标发光效果 */
|
||||
:global(html[data-theme='dark']) .milestone-group-icon {
|
||||
color: var(--gantt-danger, #f67c7c);
|
||||
fill: var(--gantt-danger, #f67c7c);
|
||||
filter: drop-shadow(0 0 6px var(--gantt-danger, #f67c7c));
|
||||
animation: milestone-icon-glow-dark 2.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .task-row:hover .milestone-group-icon {
|
||||
filter: drop-shadow(0 0 10px var(--gantt-danger, #f67c7c))
|
||||
drop-shadow(0 0 16px rgba(246, 124, 124, 0.4));
|
||||
animation: milestone-icon-glow-intense-dark 1.8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* 暗黑模式发光动画 */
|
||||
@keyframes milestone-icon-glow-dark {
|
||||
from {
|
||||
filter: drop-shadow(0 0 3px var(--gantt-danger, #f67c7c));
|
||||
}
|
||||
to {
|
||||
filter: drop-shadow(0 0 8px var(--gantt-danger, #f67c7c))
|
||||
drop-shadow(0 0 12px rgba(246, 124, 124, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes milestone-icon-glow-intense-dark {
|
||||
from {
|
||||
filter: drop-shadow(0 0 8px var(--gantt-danger, #f67c7c))
|
||||
drop-shadow(0 0 12px rgba(246, 124, 124, 0.3));
|
||||
}
|
||||
to {
|
||||
filter: drop-shadow(0 0 12px var(--gantt-danger, #f67c7c))
|
||||
drop-shadow(0 0 20px rgba(246, 124, 124, 0.5));
|
||||
}
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .milestone-empty-col {
|
||||
color: var(--gantt-text-disabled, #606266);
|
||||
/* 确保暗黑模式下边框颜色与普通数据行一致 */
|
||||
border-right-color: var(--gantt-border-light) !important;
|
||||
}
|
||||
|
||||
/* 暗黑模式的悬停效果 */
|
||||
:global(html[data-theme='dark']) .task-row:hover {
|
||||
box-shadow:
|
||||
0 4px 12px rgba(255, 255, 255, 0.1),
|
||||
0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .task-row.task-row-hovered {
|
||||
background-color: var(--gantt-bg-hover) !important;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(255, 255, 255, 0.1),
|
||||
0 2px 8px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .task-row.parent-task:hover {
|
||||
box-shadow:
|
||||
0 6px 16px rgba(255, 255, 255, 0.15),
|
||||
0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .task-row.parent-task.task-row-hovered {
|
||||
background: var(--gantt-bg-hover-parent) !important;
|
||||
box-shadow:
|
||||
0 6px 16px rgba(255, 255, 255, 0.15),
|
||||
0 2px 8px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .milestone-group-row:hover {
|
||||
box-shadow:
|
||||
0 6px 16px rgba(246, 124, 124, 0.4),
|
||||
0 2px 8px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
1500
src/components/Timeline.vue
Normal file
1500
src/components/Timeline.vue
Normal file
File diff suppressed because it is too large
Load Diff
10
src/components/index.ts
Normal file
10
src/components/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { default as GanttChart } from './GanttChart.vue'
|
||||
export { default as GanttToolbar } from './GanttToolbar.vue'
|
||||
export { default as TaskList } from './TaskList.vue'
|
||||
export { default as TaskRow } from './TaskRow.vue'
|
||||
export { default as Timeline } from './Timeline.vue'
|
||||
export { default as TaskBar } from './TaskBar.vue'
|
||||
export { default as MilestonePoint } from './MilestonePoint.vue'
|
||||
export { default as MilestoneDialog } from './MilestoneDialog.vue'
|
||||
export { default as TaskDrawer } from './TaskDrawer.vue'
|
||||
export { default as DatePicker } from './DatePicker.vue'
|
||||
319
src/composables/useI18n.ts
Normal file
319
src/composables/useI18n.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// 支持的语言类型
|
||||
export type Locale = 'zh-CN' | 'en-US'
|
||||
|
||||
// 多语言配置
|
||||
const messages = {
|
||||
'zh-CN': {
|
||||
// TaskList Header
|
||||
taskName: '任务名称',
|
||||
predecessor: '前置任务',
|
||||
assignee: '分配人',
|
||||
startDate: '开始日期',
|
||||
endDate: '结束日期',
|
||||
estimatedHours: '预计工时(hr)',
|
||||
actualHours: '实际工时(hr)',
|
||||
progress: '进度',
|
||||
type: '类型',
|
||||
description: '描述',
|
||||
// CSV Export Headers(去除重复,仅保留引用)
|
||||
csvHeaders: {
|
||||
id: 'ID',
|
||||
taskName: '任务名称',
|
||||
predecessor: '前置任务',
|
||||
assignee: '分配人',
|
||||
startDate: '开始日期',
|
||||
endDate: '结束日期',
|
||||
estimatedHours: '预计工时(hr)',
|
||||
actualHours: '实际工时(hr)',
|
||||
progress: '进度(%)',
|
||||
type: '类型',
|
||||
description: '描述',
|
||||
},
|
||||
|
||||
// 日期格式
|
||||
yearMonthFormat: (year: number, month: number) =>
|
||||
`${year}年${String(month).padStart(2, '0')}月`,
|
||||
|
||||
// 其他
|
||||
milestone: '里程碑',
|
||||
today: '今天',
|
||||
// 里程碑对话框
|
||||
milestoneDetails: '里程碑详情',
|
||||
milestoneName: '里程碑名称',
|
||||
milestoneDate: '里程碑日期',
|
||||
milestoneIcon: '里程碑图标',
|
||||
diamond: '菱形',
|
||||
rocket: '火箭',
|
||||
enterMilestoneName: '请输入里程碑名称',
|
||||
enterAssignee: '请输入负责人',
|
||||
enterDescription: '请输入描述信息',
|
||||
milestoneNameRequired: '里程碑名称为必填项',
|
||||
milestoneDateRequired: '里程碑日期为必填项',
|
||||
save: '保存',
|
||||
close: '关闭',
|
||||
confirm: '确认',
|
||||
delete: '删除',
|
||||
confirmDelete: '确定要删除这个里程碑吗?',
|
||||
// 工具栏按钮
|
||||
addTask: '新增需求/任务',
|
||||
addMilestone: '新增里程碑',
|
||||
todayLocate: '今日',
|
||||
todayLocateTooltip: '定位到今天',
|
||||
exportCsv: '导出 CSV',
|
||||
exportPdf: '导出 PDF',
|
||||
language: '中文',
|
||||
languageTooltip: '选择语言',
|
||||
lightMode: '明亮模式',
|
||||
darkMode: '暗黑模式',
|
||||
fullscreen: '全屏',
|
||||
exitFullscreen: '退出全屏',
|
||||
githubDocs: '查看Github文档',
|
||||
giteeDocs: '查看Gitee文档',
|
||||
// 确认对话框
|
||||
confirmDialogMessage: '是否需要保留该设置?',
|
||||
// 新建任务对话框
|
||||
newTask: '新建任务',
|
||||
taskNamePlaceholder: '请输入任务名称',
|
||||
assigneePlaceholder: '请输入负责人',
|
||||
progressPlaceholder: '0-100',
|
||||
hoursPlaceholder: '工时',
|
||||
descriptionPlaceholder: '请输入任务描述...',
|
||||
hours: '小时',
|
||||
create: '创建',
|
||||
taskNameRequired: '任务名称不能为空',
|
||||
startDateRequired: '开始日期不能为空',
|
||||
endDateRequired: '结束日期不能为空',
|
||||
endDateInvalid: '结束日期不能早于开始日期',
|
||||
// 新建里程碑对话框
|
||||
newMilestone: '新建里程碑',
|
||||
editMilestone: '编辑里程碑',
|
||||
cancel: '取消',
|
||||
|
||||
// PDF导出相关
|
||||
pdfExportLoading: '正在生成PDF,请稍候...',
|
||||
pdfExportTitle: '甘特图导出',
|
||||
pdfExportDate: '导出日期',
|
||||
|
||||
milestoneGroup: '里程碑',
|
||||
collapseTaskList: '收起任务列表',
|
||||
expandTaskList: '展开任务列表',
|
||||
// TaskDrawer 相关
|
||||
taskType: '任务类型',
|
||||
taskTypeRequired: '请选择任务类型',
|
||||
taskTypeMap: {
|
||||
task: '任务',
|
||||
milestone: '里程碑',
|
||||
story: '需求',
|
||||
epic: '史诗',
|
||||
bug: '缺陷',
|
||||
},
|
||||
editTask: '编辑任务',
|
||||
parentTask: '上级任务',
|
||||
noParentTask: '无上级任务',
|
||||
update: '更新',
|
||||
taskNameTooLong: '任务名称不能超过50个字符',
|
||||
predecessorPlaceholder: '请选择前置任务',
|
||||
operationFailed: '操作失败,请重试',
|
||||
taskUpdateSuccess: '任务更新成功',
|
||||
taskCreateSuccess: '任务创建成功',
|
||||
confirmDeleteTask: '确定要删除任务"{name}"吗?此操作不可撤销。',
|
||||
taskDeleteSuccess: '任务删除成功',
|
||||
taskDeleteFailed: '删除失败,请重试',
|
||||
overtime: '超',
|
||||
overdue: '逾期',
|
||||
days: '天',
|
||||
},
|
||||
'en-US': {
|
||||
// TaskList Header
|
||||
taskName: 'Task Name',
|
||||
predecessor: 'Predecessor',
|
||||
assignee: 'Assignee',
|
||||
startDate: 'Start Date',
|
||||
endDate: 'End Date',
|
||||
estimatedHours: 'Est. Hours',
|
||||
actualHours: 'Act. Hours',
|
||||
progress: 'Progress',
|
||||
type: 'Type',
|
||||
// CSV Export Headers(去除重复,仅保留引用)
|
||||
csvHeaders: {
|
||||
id: 'ID',
|
||||
taskName: 'Task Name',
|
||||
predecessor: 'Predecessor',
|
||||
assignee: 'Assignee',
|
||||
startDate: 'Start Date',
|
||||
endDate: 'End Date',
|
||||
estimatedHours: 'Est. Hours',
|
||||
actualHours: 'Act. Hours',
|
||||
progress: 'Progress (%)',
|
||||
type: 'Type',
|
||||
description: 'Description',
|
||||
},
|
||||
|
||||
// 日期格式
|
||||
yearMonthFormat: (year: number, month: number) => `${year}/${String(month).padStart(2, '0')}`,
|
||||
|
||||
// 其他
|
||||
milestone: 'Milestone',
|
||||
today: 'Today',
|
||||
// 里程碑对话框
|
||||
milestoneDetails: 'Milestone Details',
|
||||
milestoneName: 'Milestone Name',
|
||||
milestoneDate: 'Milestone Date',
|
||||
milestoneIcon: 'Milestone Icon',
|
||||
diamond: 'Diamond',
|
||||
rocket: 'Rocket',
|
||||
enterMilestoneName: 'Enter milestone name',
|
||||
enterAssignee: 'Enter assignee',
|
||||
enterDescription: 'Enter description',
|
||||
milestoneNameRequired: 'Milestone name is required',
|
||||
milestoneDateRequired: 'Milestone date is required',
|
||||
save: 'Save',
|
||||
close: 'Close',
|
||||
confirm: 'Confirm',
|
||||
description: 'Description',
|
||||
delete: 'Delete',
|
||||
confirmDelete: 'Are you sure you want to delete this milestone?',
|
||||
// 工具栏按钮
|
||||
addTask: 'Add Requirement/Task',
|
||||
addMilestone: 'Add Milestone',
|
||||
todayLocate: 'Today',
|
||||
todayLocateTooltip: 'Locate to today',
|
||||
exportCsv: 'Export CSV',
|
||||
exportPdf: 'Export PDF',
|
||||
language: 'English',
|
||||
languageTooltip: 'Select language',
|
||||
lightMode: 'Light Mode',
|
||||
darkMode: 'Dark Mode',
|
||||
fullscreen: 'Fullscreen',
|
||||
exitFullscreen: 'Exit Fullscreen',
|
||||
githubDocs: 'GitHub Docs',
|
||||
giteeDocs: 'Gitee Docs',
|
||||
// Confirm dialog
|
||||
confirmDialogMessage: 'Do you want to save this setting?',
|
||||
taskNamePlaceholder: 'Enter task name',
|
||||
assigneePlaceholder: 'Enter assignee',
|
||||
progressPlaceholder: '0-100',
|
||||
hoursPlaceholder: 'Hours',
|
||||
descriptionPlaceholder: 'Enter task description...',
|
||||
hours: 'Hours',
|
||||
create: 'Create',
|
||||
taskNameRequired: 'Task name is required',
|
||||
startDateRequired: 'Start date is required',
|
||||
endDateRequired: 'End date is required',
|
||||
endDateInvalid: 'End date cannot be earlier than start date',
|
||||
// 新建里程碑对话框
|
||||
newMilestone: 'New Milestone',
|
||||
editMilestone: 'Edit Milestone',
|
||||
cancel: 'Cancel',
|
||||
|
||||
// PDF export
|
||||
pdfExportLoading: 'Generating PDF, please wait...',
|
||||
pdfExportTitle: 'Gantt Chart Export',
|
||||
pdfExportDate: 'Export Date',
|
||||
|
||||
milestoneGroup: 'Milestone',
|
||||
collapseTaskList: 'Collapse Task List',
|
||||
expandTaskList: 'Expand Task List',
|
||||
// TaskDrawer related
|
||||
taskType: 'Task Type',
|
||||
taskTypeRequired: 'Please select task type',
|
||||
taskTypeMap: {
|
||||
task: 'Task',
|
||||
milestone: 'Milestone',
|
||||
story: 'Story',
|
||||
epic: 'Epic',
|
||||
bug: 'Bug',
|
||||
},
|
||||
editTask: 'Edit Task',
|
||||
newTask: 'New Task',
|
||||
parentTask: 'Parent Task',
|
||||
noParentTask: 'No Parent Task',
|
||||
update: 'Update',
|
||||
taskNameTooLong: 'Task name cannot exceed 50 characters',
|
||||
predecessorPlaceholder: 'Select predecessor',
|
||||
operationFailed: 'Operation failed, please try again',
|
||||
taskUpdateSuccess: 'Task updated successfully',
|
||||
taskCreateSuccess: 'Task created successfully',
|
||||
confirmDeleteTask:
|
||||
'Are you sure you want to delete task "{name}"? This action cannot be undone.',
|
||||
taskDeleteSuccess: 'Task deleted successfully',
|
||||
taskDeleteFailed: 'Delete failed, please try again',
|
||||
overtime: 'Over',
|
||||
overdue: 'Overdue',
|
||||
days: ' days',
|
||||
},
|
||||
}
|
||||
|
||||
// 允许外部合并自定义多语言
|
||||
export function setCustomMessages(locale: Locale, custom: Partial<(typeof messages)['zh-CN']>) {
|
||||
if (!messages[locale]) return
|
||||
Object.assign(messages[locale], custom)
|
||||
}
|
||||
|
||||
// LocalStorage key
|
||||
const LOCALE_STORAGE_KEY = 'gantt-locale'
|
||||
|
||||
// 从localStorage读取语言设置,如果没有则使用默认值
|
||||
const getInitialLocale = (): Locale => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(LOCALE_STORAGE_KEY)
|
||||
if (stored && (stored === 'zh-CN' || stored === 'en-US')) {
|
||||
return stored as Locale
|
||||
}
|
||||
}
|
||||
return 'zh-CN'
|
||||
}
|
||||
|
||||
// 当前语言状态 - 从localStorage初始化
|
||||
const currentLocale = ref<Locale>(getInitialLocale())
|
||||
|
||||
// 多语言 Hook
|
||||
export function useI18n() {
|
||||
// 获取当前语言的消息
|
||||
const t = computed(() => {
|
||||
return messages[currentLocale.value]
|
||||
})
|
||||
|
||||
// 安全获取翻译文本的函数
|
||||
const getTranslation = (key: string): string => {
|
||||
const translation = t.value[key as keyof typeof t.value]
|
||||
return typeof translation === 'string' ? translation : key
|
||||
}
|
||||
|
||||
// 切换语言
|
||||
const setLocale = (locale: Locale) => {
|
||||
currentLocale.value = locale
|
||||
|
||||
// 触发全局事件,通知其他组件语言已切换
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('locale-changed', {
|
||||
detail: { locale },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 获取当前语言
|
||||
const locale = computed(() => currentLocale.value)
|
||||
|
||||
// 格式化年月
|
||||
const formatYearMonth = (year: number, month: number) => {
|
||||
return t.value.yearMonthFormat(year, month)
|
||||
}
|
||||
|
||||
return {
|
||||
t,
|
||||
getTranslation,
|
||||
locale,
|
||||
setLocale,
|
||||
formatYearMonth,
|
||||
}
|
||||
}
|
||||
|
||||
// 导出当前语言状态,供其他模块使用
|
||||
export { currentLocale }
|
||||
export { messages }
|
||||
|
||||
// 为了类型推断,导出 Messages 类型
|
||||
export type Messages = typeof messages
|
||||
24
src/index.ts
Normal file
24
src/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// 导出所有组件
|
||||
export { default as GanttChart } from './components/GanttChart.vue'
|
||||
export { default as TaskList } from './components/TaskList.vue'
|
||||
export { default as Timeline } from './components/Timeline.vue'
|
||||
export { default as TaskBar } from './components/TaskBar.vue'
|
||||
export { default as TaskDrawer } from './components/TaskDrawer.vue'
|
||||
export { default as MilestonePoint } from './components/MilestonePoint.vue'
|
||||
export { default as TaskRow } from './components/TaskRow.vue'
|
||||
export type { Task } from './models/classes/Task.ts' // 导出Task类型
|
||||
|
||||
// 导出样式文件
|
||||
import './styles/theme-variables.css'
|
||||
|
||||
// 导出安装函数(可选,用于Vue.use())
|
||||
import type { App } from 'vue'
|
||||
import GanttChart from './components/GanttChart.vue'
|
||||
|
||||
export const install = (app: App) => {
|
||||
app.component('GanttChart', GanttChart)
|
||||
}
|
||||
|
||||
export default {
|
||||
install,
|
||||
}
|
||||
0
src/models/GanttTypes.ts
Normal file
0
src/models/GanttTypes.ts
Normal file
2
src/models/classes/Language.ts
Normal file
2
src/models/classes/Language.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Language 类型定义
|
||||
export type Language = 'zh' | 'en'
|
||||
11
src/models/classes/Milestone.ts
Normal file
11
src/models/classes/Milestone.ts
Normal 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
src/models/classes/Task.ts
Normal file
20
src/models/classes/Task.ts
Normal 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
src/models/configs/TimelineConfig.ts
Normal file
6
src/models/configs/TimelineConfig.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// TimelineConfig 类型定义
|
||||
export interface TimelineConfig {
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
zoomLevel: number
|
||||
}
|
||||
11
src/models/configs/ToolbarConfig.ts
Normal file
11
src/models/configs/ToolbarConfig.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// ToolbarConfig 类型定义
|
||||
export interface ToolbarConfig {
|
||||
showAddTask?: boolean
|
||||
showAddMilestone?: boolean
|
||||
showTodayLocate?: boolean
|
||||
showExportCsv?: boolean
|
||||
showExportPdf?: boolean
|
||||
showLanguage?: boolean
|
||||
showTheme?: boolean
|
||||
showFullscreen?: boolean
|
||||
}
|
||||
67
src/styles/app.css
Normal file
67
src/styles/app.css
Normal 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
src/styles/theme-variables.css
Normal file
76
src/styles/theme-variables.css
Normal file
@@ -0,0 +1,76 @@
|
||||
/* 甘特图组件主题变量 */
|
||||
|
||||
/* 明亮主题 */
|
||||
:root {
|
||||
/* 背景色 - 原始的浅色设计 */
|
||||
--gantt-bg-primary: #ffffff; /* 最浅背景 - body主体 */
|
||||
--gantt-bg-secondary: #f5f7fa; /* Header背景 - 浅色 */
|
||||
--gantt-bg-tertiary: #f9f9f9; /* 三级背景 - 父任务 */
|
||||
--gantt-bg-hover: rgba(225, 228, 231, 0.2); /* 悬停背景 - 普通任务,增加透明度 */
|
||||
--gantt-bg-hover-parent: rgba(245, 247, 250, 0.8); /* 悬停背景 - 父任务,稍高透明度 */
|
||||
--gantt-bg-toolbar: #f8f9fa; /* 工具栏背景 - 中性浅色 */
|
||||
|
||||
/* 文字颜色 */
|
||||
--gantt-text-primary: #333333;
|
||||
--gantt-text-secondary: #8d9095;
|
||||
--gantt-text-muted: #909399;
|
||||
--gantt-text-white: #ffffff;
|
||||
--gantt-text-header: #333333; /* Header文字颜色 - 明亮模式下Header为浅色背景,使用深色文字 */
|
||||
|
||||
/* 边框颜色 */
|
||||
--gantt-border-light: #ebeef5;
|
||||
--gantt-border-medium: #dcdfe6; /* Header边框 - 与浅色Header搭配 */
|
||||
--gantt-border-dark: #c0c4cc;
|
||||
--gantt-border-color: #dcdfe6; /* 通用边框色,与按钮边框一致 */
|
||||
|
||||
/* 强调色 */
|
||||
--gantt-primary: #409eff;
|
||||
--gantt-primary-light: #ecf5ff;
|
||||
--gantt-success: #67c23a;
|
||||
--gantt-warning: #e6a23c;
|
||||
--gantt-warning-light: #f5dab1;
|
||||
--gantt-danger: #f56c6c;
|
||||
--gantt-danger-light: #fab6b6; /* 里程碑边框浅红色 */
|
||||
|
||||
/* 滚动条 */
|
||||
--gantt-scrollbar-thumb: #c1c1c1;
|
||||
--gantt-scrollbar-thumb-hover: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 暗黑主题 */
|
||||
html[data-theme='dark'] {
|
||||
/* 背景色 - 大幅提升亮度,接近中性灰 */
|
||||
--gantt-bg-primary: #6b6b6b; /* Body主体 - 明显更亮,接近中性灰 */
|
||||
--gantt-bg-secondary: #4b4b4b; /* Header背景 - 中等亮度,保持层次 */
|
||||
--gantt-bg-tertiary: #7b7b7b; /* 三级背景 - 父任务,最亮的背景 */
|
||||
--gantt-bg-hover: rgba(180, 180, 180, 0.35); /* 悬停背景 - 明亮的悬停效果 */
|
||||
--gantt-bg-hover-parent: rgba(140, 140, 140, 0.8); /* 悬停背景 - 父任务 */
|
||||
--gantt-bg-toolbar: #5b5b5b; /* 工具栏背景 - 与Body背景保持协调 */
|
||||
|
||||
/* 文字颜色 - 最大化对比度,确保清晰可读 */
|
||||
--gantt-text-primary: #ffffff; /* 主要文字纯白 */
|
||||
--gantt-text-secondary: #f8f8f8; /* 次要文字接近纯白 */
|
||||
--gantt-text-muted: #e0e0e0; /* 弱化文字保持高可读性 */
|
||||
--gantt-text-white: #ffffff; /* 纯白 */
|
||||
--gantt-text-parent: #ffffff; /* 父任务文字纯白 */
|
||||
--gantt-text-header: #ffffff; /* Header文字纯白 */
|
||||
|
||||
/* 边框颜色 - 高对比度分界线 */
|
||||
--gantt-border-light: #888888; /* body内边框 - 高可见性 */
|
||||
--gantt-border-medium: #666666; /* header边框 - 中等对比 */
|
||||
--gantt-border-dark: #999999; /* 强调边框 - 最清晰 */
|
||||
--gantt-border-color: #808080; /* 通用边框色,高可见性 */
|
||||
|
||||
/* 强调色 - 鲜艳醒目,在亮灰背景上突出 */
|
||||
--gantt-primary: #3399ff; /* 鲜艳的蓝色,在亮灰背景上突出 */
|
||||
--gantt-primary-light: #4d6699; /* 深蓝背景 */
|
||||
--gantt-success: #66cc33; /* 鲜艳的绿色 */
|
||||
--gantt-warning: #ff9933; /* 鲜艳的橙色 */
|
||||
--gantt-warning-light: #cc7722; /* 深橙背景 */
|
||||
--gantt-danger: #ff4444; /* 鲜艳的红色 */
|
||||
--gantt-danger-light: #cc3333; /* 深红背景,里程碑边框 */
|
||||
|
||||
/* 滚动条 - 高可见性 */
|
||||
--gantt-scrollbar-thumb: #888888; /* 明亮的滚动条 */
|
||||
--gantt-scrollbar-thumb-hover: #999999; /* 悬停时更亮 */
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
tsconfig.app.json
Normal file
21
tsconfig.app.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
/* Performance optimizations */
|
||||
"skipLibCheck": true,
|
||||
"incremental": true,
|
||||
"assumeChangesOnlyAffectDirectDependencies": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
25
tsconfig.node.json
Normal file
25
tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
24
vite.config.lib.ts
Normal file
24
vite.config.lib.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: './src/index.ts',
|
||||
name: 'JordiumGanttVue3',
|
||||
fileName: format => `jordium-gantt-vue3.${format}.js`,
|
||||
},
|
||||
rollupOptions: {
|
||||
// 确保外部化处理那些你不想打包进库的依赖
|
||||
external: ['vue'],
|
||||
output: {
|
||||
// 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
|
||||
globals: {
|
||||
vue: 'Vue',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
20
vite.config.ts
Normal file
20
vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user