v2.0.0-preview.1 - TaskList重构 - TaskRow组件拆分重构
This commit is contained in:
343
docs/TaskRow-重构测试清单-20241216.md
Normal file
343
docs/TaskRow-重构测试清单-20241216.md
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
# TaskRow 重构测试清单
|
||||||
|
|
||||||
|
**重构日期**: 2024-12-16
|
||||||
|
**版本**: v1.6.1
|
||||||
|
**重构范围**: Composables 合并优化 + 组件目录重组
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 重构概述
|
||||||
|
|
||||||
|
### 变更内容
|
||||||
|
1. **Composables 合并** (9个 → 5个)
|
||||||
|
- `useTaskRowDragInteraction.ts` → `useTaskRowEventHandlers.ts`
|
||||||
|
- `useTaskRowTimer.ts` → `useTaskRowContextMenu.ts`
|
||||||
|
- `useTaskRowProgress.ts` + `useTaskRowStyles.ts` → `useTaskRowState.ts`
|
||||||
|
|
||||||
|
2. **组件目录重组**
|
||||||
|
- 新建 `src/components/TaskList/taskRow/` 目录
|
||||||
|
- 移动 5 个 TaskRow 相关组件到新目录
|
||||||
|
|
||||||
|
3. **引用路径更新**
|
||||||
|
- 所有导入导出路径已更新
|
||||||
|
- 样式导入路径已修正
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 基础功能测试
|
||||||
|
|
||||||
|
### 1. 构建与编译
|
||||||
|
- [ ] `npm run build` 成功执行无错误
|
||||||
|
- [ ] TypeScript 类型检查通过
|
||||||
|
- [ ] Vite 构建输出正常
|
||||||
|
- [ ] CSS 文件大小未异常增长 (预期: ~153KB)
|
||||||
|
- [ ] JS 文件大小正常 (预期: ~3577KB)
|
||||||
|
|
||||||
|
### 2. 开发环境启动
|
||||||
|
- [ ] `npm run dev` 成功启动
|
||||||
|
- [ ] 浏览器无控制台错误
|
||||||
|
- [ ] 热更新正常工作
|
||||||
|
- [ ] 样式正确加载
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 TaskRow 核心功能测试
|
||||||
|
|
||||||
|
### 3. 任务行渲染
|
||||||
|
- [ ] 普通任务行正确显示
|
||||||
|
- [ ] 父任务行(story 类型)正确显示
|
||||||
|
- [ ] 叶子任务行正确显示
|
||||||
|
- [ ] 里程碑任务行正确显示
|
||||||
|
- [ ] 里程碑分组行正确显示
|
||||||
|
|
||||||
|
### 4. 折叠/展开功能
|
||||||
|
- [ ] 点击折叠按钮可以折叠子任务
|
||||||
|
- [ ] 点击折叠按钮可以展开子任务
|
||||||
|
- [ ] 折叠按钮图标旋转动画正常
|
||||||
|
- [ ] Story 类型任务可以折叠/展开
|
||||||
|
- [ ] 有子任务的普通任务可以折叠/展开
|
||||||
|
- [ ] 里程碑分组不显示折叠按钮
|
||||||
|
- [ ] 点击任务行(非按钮区域)也可触发折叠/展开
|
||||||
|
|
||||||
|
### 5. 任务图标显示
|
||||||
|
- [ ] 父任务图标正确显示(文件夹图标)
|
||||||
|
- [ ] Story 任务图标正确显示(书本图标)
|
||||||
|
- [ ] 普通任务图标正确显示(复选框图标)
|
||||||
|
- [ ] 里程碑分组图标正确显示(红色菱形,无发光效果)
|
||||||
|
- [ ] `showTaskIcon=false` 时图标隐藏且保持对齐
|
||||||
|
|
||||||
|
### 6. 悬停效果 (Hover)
|
||||||
|
- [ ] 鼠标悬停在任务行上时背景色变化
|
||||||
|
- [ ] 悬停时行放大效果 (scale 1.02)
|
||||||
|
- [ ] 悬停时阴影效果正常
|
||||||
|
- [ ] 悬停时 z-index 提升(浮起效果)
|
||||||
|
- [ ] 父任务悬停效果增强
|
||||||
|
- [ ] 里程碑分组悬停时边框加粗
|
||||||
|
- [ ] 任务类型左边框在悬停时正确加粗
|
||||||
|
- [ ] **里程碑图标无红色发光效果(已修复)**
|
||||||
|
|
||||||
|
### 7. 拖拽功能
|
||||||
|
- [ ] `enableDrag=true` 时可以拖拽任务行
|
||||||
|
- [ ] 拖拽时鼠标指针变为 move
|
||||||
|
- [ ] 拖拽时源任务行透明度降低
|
||||||
|
- [ ] 拖拽目标位置高亮显示
|
||||||
|
- [ ] 拖拽到其他任务后方显示蓝色下边框
|
||||||
|
- [ ] 拖拽到任务内部(作为子任务)显示蓝色边框
|
||||||
|
- [ ] 点击按钮、输入框等交互元素不触发拖拽
|
||||||
|
- [ ] Splitter 拖拽时不触发行悬停效果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 数据显示测试
|
||||||
|
|
||||||
|
### 8. 列数据渲染
|
||||||
|
- [ ] 任务名称列正确显示
|
||||||
|
- [ ] 前置任务列格式化显示 (FS+2, SS-1 等)
|
||||||
|
- [ ] 负责人列显示头像和姓名
|
||||||
|
- [ ] 开始日期列正确显示
|
||||||
|
- [ ] 结束日期列正确显示
|
||||||
|
- [ ] 预估工时列正确显示
|
||||||
|
- [ ] 实际工时列正确显示
|
||||||
|
- [ ] 进度列显示百分比
|
||||||
|
|
||||||
|
### 9. 进度状态样式
|
||||||
|
- [ ] 进度 0% 无特殊样式
|
||||||
|
- [ ] 进度 1-99% 显示警告色 (warning)
|
||||||
|
- [ ] 进度 100% 显示成功色 (success)
|
||||||
|
- [ ] 超期且未完成显示危险色 (danger)
|
||||||
|
|
||||||
|
### 10. 计时器功能
|
||||||
|
- [ ] 计时器正在运行时显示当前时长
|
||||||
|
- [ ] 计时器格式为 HH:MM:SS
|
||||||
|
- [ ] 停止计时后显示累计时长
|
||||||
|
- [ ] 计时器每秒更新
|
||||||
|
- [ ] 多个任务同时计时互不干扰
|
||||||
|
|
||||||
|
### 11. 徽章显示
|
||||||
|
- [ ] 超时徽章正确显示(实际工时 > 预估工时)
|
||||||
|
- [ ] 逾期徽章正确显示(当前日期 > 结束日期且未完成)
|
||||||
|
- [ ] 逾期天数计算正确
|
||||||
|
- [ ] 徽章文案国际化正确
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 样式与主题测试
|
||||||
|
|
||||||
|
### 12. 明亮模式样式
|
||||||
|
- [ ] 任务行背景色正确
|
||||||
|
- [ ] 父任务背景色正确
|
||||||
|
- [ ] 里程碑分组渐变背景正确
|
||||||
|
- [ ] 边框颜色正确
|
||||||
|
- [ ] 文字颜色正确
|
||||||
|
- [ ] 悬停效果颜色正确
|
||||||
|
|
||||||
|
### 13. 暗黑模式样式
|
||||||
|
- [ ] 切换到暗黑模式后样式正确
|
||||||
|
- [ ] 任务行背景色适配暗黑模式
|
||||||
|
- [ ] 边框颜色适配暗黑模式
|
||||||
|
- [ ] 文字颜色适配暗黑模式
|
||||||
|
- [ ] 里程碑图标颜色适配 (#f67c7c)
|
||||||
|
- [ ] 悬停阴影效果适配暗黑模式
|
||||||
|
|
||||||
|
### 14. 任务类型边框
|
||||||
|
- [ ] Story 类型左边框为蓝色 (#409eff)
|
||||||
|
- [ ] Task 类型左边框为橙色 (#e6a23c)
|
||||||
|
- [ ] Milestone 类型左边框为红色 (#f56c6c)
|
||||||
|
- [ ] 里程碑分组左边框为红色且宽度 3px
|
||||||
|
- [ ] 悬停时边框加粗至 4-5px
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 交互功能测试
|
||||||
|
|
||||||
|
### 15. 双击事件
|
||||||
|
- [ ] 双击任务行触发 `dblclick` 事件
|
||||||
|
- [ ] 双击事件参数正确传递任务对象
|
||||||
|
- [ ] 双击不会同时触发单击事件
|
||||||
|
|
||||||
|
### 16. 右键菜单
|
||||||
|
- [ ] 右键点击任务行显示上下文菜单
|
||||||
|
- [ ] 菜单位置跟随鼠标
|
||||||
|
- [ ] 菜单选项正确显示(开始计时、停止计时等)
|
||||||
|
- [ ] 点击菜单外部关闭菜单
|
||||||
|
- [ ] 里程碑类型不显示右键菜单
|
||||||
|
- [ ] 菜单操作触发正确的事件
|
||||||
|
|
||||||
|
### 17. 自定义行样式
|
||||||
|
- [ ] `taskListRowClassName` 字符串模式正确应用
|
||||||
|
- [ ] `taskListRowClassName` 函数模式正确计算
|
||||||
|
- [ ] `taskListRowStyle` 对象模式正确应用
|
||||||
|
- [ ] `taskListRowStyle` 函数模式正确计算
|
||||||
|
- [ ] 自定义样式优先级高于默认样式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 插槽与扩展测试
|
||||||
|
|
||||||
|
### 18. 自定义内容插槽
|
||||||
|
- [ ] `#custom-task-content` 插槽正确渲染
|
||||||
|
- [ ] 插槽接收的 props 数据完整
|
||||||
|
- [ ] 插槽内容替换默认任务名称显示
|
||||||
|
|
||||||
|
### 19. 列级自定义插槽
|
||||||
|
- [ ] `#column-{key}` 插槽正确渲染
|
||||||
|
- [ ] 列插槽接收正确的 task、column、value 参数
|
||||||
|
- [ ] 列插槽优先级高于 formatter 函数
|
||||||
|
- [ ] 多个自定义列插槽可以共存
|
||||||
|
|
||||||
|
### 20. Formatter 函数
|
||||||
|
- [ ] 列的 `formatter` 函数正确调用
|
||||||
|
- [ ] Formatter 接收正确的 task 和 column 参数
|
||||||
|
- [ ] Formatter 返回值正确显示
|
||||||
|
- [ ] Formatter 优先级低于插槽,高于默认渲染
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 声明式渲染模式测试
|
||||||
|
|
||||||
|
### 21. 声明式列配置
|
||||||
|
- [ ] `renderMode="declarative"` 正确切换渲染模式
|
||||||
|
- [ ] 声明式列配置正确解析
|
||||||
|
- [ ] 第一列保留折叠按钮和图标
|
||||||
|
- [ ] 列对齐方式正确应用 (left/center/right)
|
||||||
|
- [ ] 列宽度样式正确应用
|
||||||
|
- [ ] 自定义 cssClass 正确应用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 边界条件测试
|
||||||
|
|
||||||
|
### 22. 空数据与异常
|
||||||
|
- [ ] 任务数据为空时不报错
|
||||||
|
- [ ] 缺少必需字段时有默认值或占位符
|
||||||
|
- [ ] 子任务数组为空时正确处理
|
||||||
|
- [ ] 日期格式异常时不崩溃
|
||||||
|
- [ ] 进度值超出 0-100 范围时正确处理
|
||||||
|
|
||||||
|
### 23. 大数据量测试
|
||||||
|
- [ ] 100+ 任务渲染性能正常
|
||||||
|
- [ ] 虚拟滚动正常工作
|
||||||
|
- [ ] 展开/折叠大量子任务不卡顿
|
||||||
|
- [ ] 拖拽大列表时性能正常
|
||||||
|
|
||||||
|
### 24. 多语言测试
|
||||||
|
- [ ] 中文界面文案正确
|
||||||
|
- [ ] 英文界面文案正确
|
||||||
|
- [ ] 切换语言后徽章文案更新
|
||||||
|
- [ ] 日期格式跟随语言设置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Composables 单元测试
|
||||||
|
|
||||||
|
### 25. useTaskRowState
|
||||||
|
- [ ] 任务缩进计算正确 (baseIndent + level * 20)
|
||||||
|
- [ ] hasChildren 判断正确
|
||||||
|
- [ ] 任务类型判断正确 (isStoryTask, isMilestoneGroup 等)
|
||||||
|
- [ ] 进度样式类计算正确
|
||||||
|
- [ ] 超时判断逻辑正确 (actualHours > estimatedHours)
|
||||||
|
- [ ] 逾期天数计算正确(仅计算日期部分)
|
||||||
|
- [ ] 自定义行样式计算正确
|
||||||
|
|
||||||
|
### 26. useTaskRowEventHandlers
|
||||||
|
- [ ] 折叠/展开事件正确触发
|
||||||
|
- [ ] 点击行事件正确触发
|
||||||
|
- [ ] 双击事件正确触发且阻止冒泡
|
||||||
|
- [ ] 悬停进入/离开事件正确触发
|
||||||
|
- [ ] Splitter 拖拽时悬停事件被禁用
|
||||||
|
- [ ] 拖拽鼠标按下事件正确处理
|
||||||
|
- [ ] 拖拽悬停事件正确监听
|
||||||
|
|
||||||
|
### 27. useTaskRowContextMenu
|
||||||
|
- [ ] 右键菜单显示/隐藏状态正确
|
||||||
|
- [ ] 菜单位置计算正确
|
||||||
|
- [ ] 删除任务事件正确触发
|
||||||
|
- [ ] 计时器初始值正确
|
||||||
|
- [ ] 计时器格式化正确 (HH:MM:SS)
|
||||||
|
- [ ] 计时器更新逻辑正确
|
||||||
|
- [ ] 组件卸载时定时器正确清理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎭 回归测试
|
||||||
|
|
||||||
|
### 28. 与其他组件集成
|
||||||
|
- [ ] TaskList 正确使用 TaskRow
|
||||||
|
- [ ] GanttChart 中 TaskList 正常工作
|
||||||
|
- [ ] Timeline 与 TaskList 联动正常
|
||||||
|
- [ ] 任务行与甘特条联动高亮正常
|
||||||
|
|
||||||
|
### 29. Demo 验证
|
||||||
|
- [ ] Demo 页面正常加载
|
||||||
|
- [ ] 所有 Demo 示例正常工作
|
||||||
|
- [ ] 自定义列 Demo 正常
|
||||||
|
- [ ] 声明式渲染 Demo 正常
|
||||||
|
- [ ] 大数据量 Demo 正常 (data-large-1m.json)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 代码质量检查
|
||||||
|
|
||||||
|
### 30. 代码规范
|
||||||
|
- [ ] ESLint 无错误
|
||||||
|
- [ ] TypeScript 严格模式通过
|
||||||
|
- [ ] 无 `any` 类型滥用
|
||||||
|
- [ ] 注释完整且准确
|
||||||
|
|
||||||
|
### 31. 性能优化验证
|
||||||
|
- [ ] `computed` 正确使用,避免重复计算
|
||||||
|
- [ ] `toRef` 仅用于会变化的 props
|
||||||
|
- [ ] 事件监听器正确清理
|
||||||
|
- [ ] Watch 懒加载逻辑正常(计时器)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 测试通过标准
|
||||||
|
|
||||||
|
- [ ] **所有核心功能测试通过** (1-17)
|
||||||
|
- [ ] **所有样式测试通过** (12-14)
|
||||||
|
- [ ] **所有交互测试通过** (15-16)
|
||||||
|
- [ ] **声明式渲染测试通过** (21)
|
||||||
|
- [ ] **Composables 逻辑测试通过** (25-27)
|
||||||
|
- [ ] **回归测试通过** (28-29)
|
||||||
|
- [ ] **构建成功无警告**
|
||||||
|
- [ ] **浏览器控制台无错误**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 已知问题记录
|
||||||
|
|
||||||
|
| 问题 | 状态 | 描述 | 解决方案 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| 里程碑图标红色发光 | ✅ 已修复 | 悬停时出现不必要的红色发光效果 | 移除所有 filter 和 animation 属性 |
|
||||||
|
| - | - | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 测试注意事项
|
||||||
|
|
||||||
|
1. **测试环境**:
|
||||||
|
- Node.js 版本: 建议 v18+
|
||||||
|
- 浏览器: Chrome/Edge/Firefox 最新版
|
||||||
|
- 屏幕分辨率: 1920x1080 及以上
|
||||||
|
|
||||||
|
2. **测试数据**:
|
||||||
|
- 使用 `demo/data.json` 进行常规测试
|
||||||
|
- 使用 `demo/data-large-1m.json` 进行性能测试
|
||||||
|
|
||||||
|
3. **回归测试建议**:
|
||||||
|
- 与 v1.6.0 版本对比功能一致性
|
||||||
|
- 重点测试拖拽、计时器、右键菜单功能
|
||||||
|
|
||||||
|
4. **性能基准**:
|
||||||
|
- 构建时间: < 10s
|
||||||
|
- 页面加载时间: < 2s
|
||||||
|
- 列表滚动帧率: > 30fps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**测试人**: _______________
|
||||||
|
**测试日期**: _______________
|
||||||
|
**测试版本**: v1.6.1
|
||||||
|
**测试结果**: [ ] 通过 / [ ] 不通过
|
||||||
|
**备注**: _______________
|
||||||
86
package-lock.json
generated
86
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "jordium-gantt-vue3",
|
"name": "jordium-gantt-vue3",
|
||||||
"version": "1.4.3",
|
"version": "1.6.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "jordium-gantt-vue3",
|
"name": "jordium-gantt-vue3",
|
||||||
"version": "1.4.3",
|
"version": "1.6.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -31,26 +31,26 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.27.1",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.27.5",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.27.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
||||||
"integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==",
|
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.27.3"
|
"@babel/types": "^7.28.5"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
@@ -68,12 +68,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.27.6",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.27.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
||||||
"integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==",
|
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.27.1",
|
||||||
"@babel/helper-validator-identifier": "^7.27.1"
|
"@babel/helper-validator-identifier": "^7.28.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -634,9 +634,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
|
||||||
},
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
@@ -957,6 +957,17 @@
|
|||||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "20.19.27",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
|
||||||
|
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/pako": {
|
"node_modules/@types/pako": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||||
@@ -2093,10 +2104,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.4.6",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"picomatch": "^3 || ^4"
|
"picomatch": "^3 || ^4"
|
||||||
},
|
},
|
||||||
@@ -2514,11 +2528,11 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.17",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
@@ -2744,9 +2758,9 @@
|
|||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -3115,13 +3129,13 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.14",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.2"
|
"picomatch": "^4.0.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@@ -3191,6 +3205,14 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/uri-js": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, useSlots, computed, inject } from 'vue'
|
import { ref, onMounted, onUnmounted, useSlots, computed, inject } from 'vue'
|
||||||
import type { StyleValue, Slots } from 'vue'
|
import type { StyleValue, Slots } from 'vue'
|
||||||
import TaskRow from '../TaskRow.vue'
|
import TaskRow from './taskRow/TaskRow.vue'
|
||||||
import { useI18n } from '../../composables/useI18n'
|
import { useI18n } from '../../composables/useI18n'
|
||||||
import type { Task } from '../../models/classes/Task'
|
import type { Task } from '../../models/classes/Task'
|
||||||
import type { TaskListConfig, TaskListColumnConfig } from '../../models/configs/TaskListConfig'
|
import type { TaskListConfig, TaskListColumnConfig } from '../../models/configs/TaskListConfig'
|
||||||
import { DEFAULT_TASK_LIST_COLUMNS } from '../../models/configs/TaskListConfig'
|
import { DEFAULT_TASK_LIST_COLUMNS } from '../../models/configs/TaskListConfig'
|
||||||
import { useTaskRowDrag } from '../../composables/useTaskRowDrag'
|
import { useTaskRowDrag } from '../../composables/useTaskRowDrag'
|
||||||
import { useTaskListColumns } from './composables/useTaskListColumns'
|
import { useTaskListColumns } from './composables/taskList/useTaskListColumns'
|
||||||
import { useTaskListLayout } from './composables/useTaskListLayout'
|
import { useTaskListLayout } from './composables/taskList/useTaskListLayout'
|
||||||
import { useTaskListResize } from './composables/useTaskListResize'
|
import { useTaskListResize } from './composables/taskList/useTaskListResize'
|
||||||
import { useTaskListEventHandlers } from './composables/useTaskListEventHandlers'
|
import { useTaskListEventHandlers } from './composables/taskList/useTaskListEventHandlers'
|
||||||
import { updateParentTasksData } from './composables/useTaskParentCalculation'
|
import { updateParentTasksData } from './composables/taskList/useTaskParentCalculation'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tasks?: Task[]
|
tasks?: Task[]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { computed, type VNode, type Slots, type VNodeChild, type ComputedRef } from 'vue'
|
import { computed, type VNode, type Slots, type VNodeChild, type ComputedRef } from 'vue'
|
||||||
import type { Task } from '../../../models/classes/Task'
|
import type { Task } from '../../../../models/classes/Task'
|
||||||
import type { TaskListColumnConfig } from '../../../models/configs/TaskListConfig'
|
import type { TaskListColumnConfig } from '../../../../models/configs/TaskListConfig'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 声明式列配置接口
|
* 声明式列配置接口
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import type { Task } from '../../../models/classes/Task'
|
import type { Task } from '../../../../models/classes/Task'
|
||||||
import { updateParentTasksData, getAllTasks } from './useTaskParentCalculation'
|
import { updateParentTasksData, getAllTasks } from './useTaskParentCalculation'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,7 +124,7 @@ export function useTaskListEventHandlers(options: TaskListEventHandlersOptions)
|
|||||||
const updateMilestoneIcon = (taskList: Task[]) => {
|
const updateMilestoneIcon = (taskList: Task[]) => {
|
||||||
for (const task of taskList) {
|
for (const task of taskList) {
|
||||||
if (task.type === 'milestone-group' && task.children) {
|
if (task.type === 'milestone-group' && task.children) {
|
||||||
const milestone = task.children.find(m => m.id === milestoneId)
|
const milestone = task.children.find((m: Task) => m.id === milestoneId)
|
||||||
if (milestone) {
|
if (milestone) {
|
||||||
milestone.icon = icon
|
milestone.icon = icon
|
||||||
return true
|
return true
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref, computed, type Ref } from 'vue'
|
import { ref, computed, type Ref } from 'vue'
|
||||||
import type { Task } from '../../../models/classes/Task'
|
import type { Task } from '../../../../models/classes/Task'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TaskList 布局计算逻辑
|
* TaskList 布局计算逻辑
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Task } from '../../../models/classes/Task'
|
import type { Task } from '../../../../models/classes/Task'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 父任务数据计算逻辑
|
* 父任务数据计算逻辑
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { inject, type Slots } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TaskRow 列级 Slot 管理
|
||||||
|
* 管理从 GanttChart 注入的列级自定义 slots
|
||||||
|
*/
|
||||||
|
export function useTaskRowColumnSlots() {
|
||||||
|
// 从 GanttChart 注入列级 slots
|
||||||
|
const columnSlots = inject<Slots>('gantt-column-slots', {})
|
||||||
|
|
||||||
|
// 检查指定列是否有自定义 slot(从注入的 slots 中查找)
|
||||||
|
const hasColumnSlot = (columnKey: string) => {
|
||||||
|
const slotName = `column-${columnKey}`
|
||||||
|
return Boolean(columnSlots[slotName])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染列级 slot
|
||||||
|
const renderColumnSlot = (columnKey: string, slotProps: any) => {
|
||||||
|
const slotName = `column-${columnKey}`
|
||||||
|
const slotFn = columnSlots[slotName]
|
||||||
|
if (slotFn) {
|
||||||
|
return slotFn(slotProps)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnSlots,
|
||||||
|
hasColumnSlot,
|
||||||
|
renderColumnSlot,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { ref, computed, watch, onMounted, onUnmounted, type Ref, type WatchStopHandle } from 'vue'
|
||||||
|
import type { Task } from '../../../../models/classes/Task'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TaskRow 右键菜单和计时器管理
|
||||||
|
* 管理右键菜单的显示、隐藏和事件处理,以及任务计时器的状态和更新
|
||||||
|
*/
|
||||||
|
export function useTaskRowContextMenu(
|
||||||
|
task: Ref<Task>,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
emit: (...args: any[]) => void,
|
||||||
|
) {
|
||||||
|
// 右键菜单相关状态
|
||||||
|
const contextMenuVisible = ref(false)
|
||||||
|
const contextMenuPosition = ref({ x: 0, y: 0 })
|
||||||
|
const contextMenuTask = computed(() => task.value)
|
||||||
|
|
||||||
|
// 任务计时器状态
|
||||||
|
const timerElapsed = ref(0)
|
||||||
|
const timerInterval = ref<number | null>(null)
|
||||||
|
let watchStopHandle: WatchStopHandle | null = null
|
||||||
|
|
||||||
|
// 格式化计时器显示:转换为 HH:MM:SS 格式
|
||||||
|
const formattedTimer = computed(() => {
|
||||||
|
const totalSeconds = Math.floor(timerElapsed.value / 1000)
|
||||||
|
const hours = Math.floor(totalSeconds / 3600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新计时器显示
|
||||||
|
const updateTimer = () => {
|
||||||
|
if (task.value.isTimerRunning && task.value.timerStartTime) {
|
||||||
|
// 计算已经运行的时间 = 当前时间 - 开始时间 + 之前累积的时间
|
||||||
|
const previousElapsed = task.value.timerElapsedTime || 0
|
||||||
|
timerElapsed.value = Date.now() - task.value.timerStartTime + previousElapsed
|
||||||
|
} else if (task.value.timerElapsedTime) {
|
||||||
|
// 如果任务不在运行中,但有累计时间,显示累计时间
|
||||||
|
timerElapsed.value = task.value.timerElapsedTime
|
||||||
|
} else {
|
||||||
|
// 默认情况下,计时器为0
|
||||||
|
timerElapsed.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动watch监听(懒加载)
|
||||||
|
const startTimerWatch = () => {
|
||||||
|
if (watchStopHandle) return // 已经创建,避免重复
|
||||||
|
|
||||||
|
watchStopHandle = watch(
|
||||||
|
() => [task.value.isTimerRunning, task.value.timerStartTime, task.value.timerElapsedTime],
|
||||||
|
() => {
|
||||||
|
// 清除之前的计时器
|
||||||
|
if (timerInterval.value) {
|
||||||
|
clearInterval(timerInterval.value)
|
||||||
|
timerInterval.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果任务正在计时,开始计时器
|
||||||
|
if (task.value.isTimerRunning) {
|
||||||
|
updateTimer()
|
||||||
|
timerInterval.value = window.setInterval(updateTimer, 1000)
|
||||||
|
} else {
|
||||||
|
// 更新一次最终值
|
||||||
|
updateTimer()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 性能优化:只在任务有计时相关数据时才启动watch
|
||||||
|
if (task.value.isTimerRunning || task.value.timerElapsedTime) {
|
||||||
|
startTimerWatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理右键菜单显示
|
||||||
|
const handleContextMenu = (event: MouseEvent) => {
|
||||||
|
// 先广播关闭所有TaskRow菜单
|
||||||
|
window.dispatchEvent(new CustomEvent('close-all-taskbar-menus'))
|
||||||
|
if (task.value.type !== 'task' && task.value.type !== 'story') {
|
||||||
|
// 为了排除里程碑类型
|
||||||
|
event.preventDefault()
|
||||||
|
contextMenuVisible.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
contextMenuVisible.value = true
|
||||||
|
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭右键菜单
|
||||||
|
const closeContextMenu = () => {
|
||||||
|
contextMenuVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理任务删除
|
||||||
|
const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
|
||||||
|
emit('delete', task, deleteChildren)
|
||||||
|
closeContextMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期钩子 - 注册事件监听器
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('close-all-taskbar-menus', closeContextMenu)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('close-all-taskbar-menus', closeContextMenu)
|
||||||
|
// 清理定时器和watch
|
||||||
|
if (timerInterval.value) {
|
||||||
|
clearInterval(timerInterval.value)
|
||||||
|
}
|
||||||
|
if (watchStopHandle) {
|
||||||
|
watchStopHandle()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
contextMenuVisible,
|
||||||
|
contextMenuPosition,
|
||||||
|
contextMenuTask,
|
||||||
|
handleContextMenu,
|
||||||
|
closeContextMenu,
|
||||||
|
handleTaskDelete,
|
||||||
|
timerElapsed,
|
||||||
|
formattedTimer,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { type Ref, h } from 'vue'
|
||||||
|
import type { Task } from '../../../../models/classes/Task'
|
||||||
|
import type { DeclarativeColumnConfig } from '../taskList/useTaskListColumns'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TaskRow 声明式列渲染逻辑
|
||||||
|
* 处理声明式模式下的列渲染
|
||||||
|
*/
|
||||||
|
export function useTaskRowDeclarativeColumns(
|
||||||
|
task: Ref<Task>,
|
||||||
|
isParentTask: Ref<boolean>,
|
||||||
|
isMilestoneGroup: Ref<boolean>,
|
||||||
|
isStoryTask: Ref<boolean>,
|
||||||
|
hasChildren: Ref<boolean>,
|
||||||
|
showTaskIcon: Ref<boolean | undefined>,
|
||||||
|
) {
|
||||||
|
// 判断是否是第一列
|
||||||
|
const isFirstColumn = (index: number) => index === 0
|
||||||
|
|
||||||
|
// 获取声明式列的对齐样式
|
||||||
|
const getDeclarativeColumnAlign = (column: DeclarativeColumnConfig) => {
|
||||||
|
return {
|
||||||
|
justifyContent:
|
||||||
|
column.align === 'center' ? 'center' : column.align === 'right' ? 'flex-end' : 'flex-start',
|
||||||
|
textAlign: column.align || 'left',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染声明式列的内容
|
||||||
|
const renderDeclarativeColumn = (column: DeclarativeColumnConfig, index: number) => {
|
||||||
|
// 第一列特殊处理:需要显示折叠按钮、图标等
|
||||||
|
if (isFirstColumn(index)) {
|
||||||
|
// 获取列对应的值(使用 prop 或默认为 name)
|
||||||
|
const columnValue = column.prop
|
||||||
|
? (task.value as Record<string, unknown>)[column.prop]
|
||||||
|
: task.value.name
|
||||||
|
|
||||||
|
// 如果有自定义 slot
|
||||||
|
if (column.defaultSlot) {
|
||||||
|
return h('div', { class: 'task-name-content' }, [
|
||||||
|
// 自定义内容
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{
|
||||||
|
class: ['task-name-text', { 'parent-task': isParentTask.value }],
|
||||||
|
title: columnValue,
|
||||||
|
},
|
||||||
|
[column.defaultSlot({
|
||||||
|
row: task.value,
|
||||||
|
$index: index,
|
||||||
|
})],
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认显示列的值(使用 prop)
|
||||||
|
return h('div', { class: 'task-name-content' }, [
|
||||||
|
// 任务图标
|
||||||
|
showTaskIcon.value !== false
|
||||||
|
? h(
|
||||||
|
'span',
|
||||||
|
{ class: 'task-icon' },
|
||||||
|
isMilestoneGroup.value
|
||||||
|
? h(
|
||||||
|
'svg',
|
||||||
|
{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
'stroke-width': 2,
|
||||||
|
class: 'milestone-group-icon',
|
||||||
|
},
|
||||||
|
h('polygon', { points: '12,2 22,12 12,22 2,12' }),
|
||||||
|
)
|
||||||
|
: isStoryTask.value || hasChildren.value
|
||||||
|
? h(
|
||||||
|
'svg',
|
||||||
|
{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
'stroke-width': 2,
|
||||||
|
},
|
||||||
|
h('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',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: h(
|
||||||
|
'svg',
|
||||||
|
{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
'stroke-width': 2,
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h('path', {
|
||||||
|
d: 'M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z',
|
||||||
|
}),
|
||||||
|
h('polyline', { points: '14,2 14,8 20,8' }),
|
||||||
|
h('line', { x1: 16, y1: 13, x2: 8, y2: 13 }),
|
||||||
|
h('line', { x1: 16, y1: 17, x2: 8, y2: 17 }),
|
||||||
|
h('polyline', { points: '10,9 9,9 8,9' }),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
// 列的值(使用 prop 或默认 name)
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{
|
||||||
|
class: ['task-name-text', { 'parent-task': isParentTask.value }],
|
||||||
|
title: String(columnValue),
|
||||||
|
},
|
||||||
|
String(columnValue || '-'),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他列:正常处理
|
||||||
|
// 如果有自定义 slot,使用 slot
|
||||||
|
if (column.defaultSlot) {
|
||||||
|
return column.defaultSlot({
|
||||||
|
row: task.value,
|
||||||
|
$index: index,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则使用 prop 访问任务数据
|
||||||
|
if (column.prop) {
|
||||||
|
const value = (task.value as Record<string, unknown>)[column.prop]
|
||||||
|
return value !== undefined && value !== null ? value : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isFirstColumn,
|
||||||
|
getDeclarativeColumnAlign,
|
||||||
|
renderDeclarativeColumn,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { onMounted, onUnmounted, type Ref } from 'vue'
|
||||||
|
import type { Task } from '../../../../models/classes/Task'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TaskRow 事件处理和拖拽交互
|
||||||
|
* 管理任务行的点击、悬停、拖拽等事件
|
||||||
|
*/
|
||||||
|
export function useTaskRowEventHandlers(
|
||||||
|
task: Ref<Task>,
|
||||||
|
isStoryTask: Ref<boolean>,
|
||||||
|
hasChildren: Ref<boolean>,
|
||||||
|
isMilestoneGroup: Ref<boolean>,
|
||||||
|
isSplitterDragging: Ref<boolean>,
|
||||||
|
taskRowRef: Ref<HTMLElement | null>,
|
||||||
|
enableDrag: Ref<boolean | undefined>,
|
||||||
|
onHover?: (taskId: number | null) => void,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
emit?: (...args: any[]) => void,
|
||||||
|
dragStart?: (task: Task, element: HTMLElement, event: MouseEvent) => void,
|
||||||
|
dragOver?: (task: Task, element: HTMLElement, event: MouseEvent) => void,
|
||||||
|
) {
|
||||||
|
// 处理折叠/展开
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (emit) {
|
||||||
|
emit('toggle', task.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理行点击
|
||||||
|
const handleRowClick = () => {
|
||||||
|
// 如果是普通父级任务(story类型或有子任务的任务,非里程碑分组),点击行也可以展开/收起
|
||||||
|
if ((isStoryTask.value || hasChildren.value) && !isMilestoneGroup.value) {
|
||||||
|
if (emit) {
|
||||||
|
emit('toggle', task.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理TaskRow双击事件 (与TaskBar逻辑保持一致)
|
||||||
|
const handleTaskRowDoubleClick = (e: MouseEvent) => {
|
||||||
|
// 阻止事件冒泡
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
// 发出双击事件给父组件
|
||||||
|
if (emit) {
|
||||||
|
emit('dblclick', task.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理悬停事件
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
// 如果正在拖拽Splitter,忽略悬停事件
|
||||||
|
if (isSplitterDragging.value) return
|
||||||
|
|
||||||
|
if (onHover) {
|
||||||
|
onHover(task.value.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
// 如果正在拖拽Splitter,忽略悬停事件
|
||||||
|
if (isSplitterDragging.value) return
|
||||||
|
|
||||||
|
if (onHover) {
|
||||||
|
onHover(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽状态管理
|
||||||
|
const handleSplitterDragStart = () => {
|
||||||
|
isSplitterDragging.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSplitterDragEnd = () => {
|
||||||
|
isSplitterDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理鼠标按下事件(拖拽)
|
||||||
|
const handleMouseDown = (event: MouseEvent) => {
|
||||||
|
// 如果未启用拖拽,直接返回
|
||||||
|
if (!enableDrag.value) return
|
||||||
|
|
||||||
|
// 如果点击的是按钮、输入框等交互元素,不触发拖拽
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
if (
|
||||||
|
target.tagName === 'BUTTON' ||
|
||||||
|
target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'SELECT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.closest('.collapse-btn') ||
|
||||||
|
target.closest('.task-context-menu')
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taskRowRef.value && dragStart) {
|
||||||
|
dragStart(task.value, taskRowRef.value, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理全局拖拽悬停事件
|
||||||
|
const handleTaskRowDragOver = (event: CustomEvent) => {
|
||||||
|
if (!enableDrag.value || !taskRowRef.value || !dragOver) return
|
||||||
|
|
||||||
|
const { taskId, event: mouseEvent } = event.detail
|
||||||
|
if (taskId === task.value.id) {
|
||||||
|
dragOver(task.value, taskRowRef.value, mouseEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期钩子 - 注册事件监听器
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('splitter-drag-start', handleSplitterDragStart)
|
||||||
|
window.addEventListener('splitter-drag-end', handleSplitterDragEnd)
|
||||||
|
window.addEventListener('task-row-drag-over', handleTaskRowDragOver as EventListener)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('splitter-drag-start', handleSplitterDragStart)
|
||||||
|
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd)
|
||||||
|
window.removeEventListener('task-row-drag-over', handleTaskRowDragOver as EventListener)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleToggle,
|
||||||
|
handleRowClick,
|
||||||
|
handleTaskRowDoubleClick,
|
||||||
|
handleMouseEnter,
|
||||||
|
handleMouseLeave,
|
||||||
|
handleMouseDown,
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/components/TaskList/composables/taskRow/useTaskRowState.ts
Normal file
125
src/components/TaskList/composables/taskRow/useTaskRowState.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { computed, type Ref, type StyleValue } from 'vue'
|
||||||
|
import type { Task } from '../../../../models/classes/Task'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TaskRow 状态管理
|
||||||
|
* 管理任务行的基础状态计算、进度状态、样式计算
|
||||||
|
* 性能优化:合并多个computed为单个,减少计算链
|
||||||
|
*/
|
||||||
|
export function useTaskRowState(
|
||||||
|
task: Ref<Task>,
|
||||||
|
level: Ref<number>,
|
||||||
|
rowIndex?: Ref<number | undefined>,
|
||||||
|
taskListRowClassName?: Ref<string | ((row: Task, rowIndex: number) => string) | undefined>,
|
||||||
|
taskListRowStyle?: Ref<StyleValue | ((row: Task, rowIndex: number) => StyleValue) | undefined>,
|
||||||
|
) {
|
||||||
|
const baseIndent = 10
|
||||||
|
|
||||||
|
// 性能优化:合并所有状态计算到单个computed中
|
||||||
|
const taskState = computed(() => {
|
||||||
|
const type = task.value.type
|
||||||
|
const children = task.value.children
|
||||||
|
const hasChildren = !!(children && children.length > 0)
|
||||||
|
const isStoryTask = type === 'story'
|
||||||
|
const isMilestoneGroup = type === 'milestone-group'
|
||||||
|
const isMilestoneTask = type === 'milestone'
|
||||||
|
const isParentTask = isStoryTask || hasChildren || isMilestoneGroup
|
||||||
|
|
||||||
|
return {
|
||||||
|
indent: `${baseIndent + level.value * 20}px`,
|
||||||
|
hasChildren,
|
||||||
|
isStoryTask,
|
||||||
|
isMilestoneGroup,
|
||||||
|
isMilestoneTask,
|
||||||
|
isParentTask,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取进度值的样式类
|
||||||
|
const getProgressClass = () => {
|
||||||
|
const progress = task.value.progress || 0
|
||||||
|
const today = new Date()
|
||||||
|
const endDate = task.value.endDate ? new Date(task.value.endDate) : null
|
||||||
|
|
||||||
|
// 超期且未完成
|
||||||
|
if (endDate && today > endDate && progress < 100) {
|
||||||
|
return 'progress-danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已完成
|
||||||
|
if (progress >= 100) {
|
||||||
|
return 'progress-success'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进行中
|
||||||
|
if (progress > 0) {
|
||||||
|
return 'progress-warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否超时
|
||||||
|
const isOvertime = () => {
|
||||||
|
return (
|
||||||
|
task.value.actualHours &&
|
||||||
|
task.value.estimatedHours &&
|
||||||
|
task.value.actualHours > task.value.estimatedHours
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否逾期,返回天数
|
||||||
|
const overdueDays = () => {
|
||||||
|
const today = new Date()
|
||||||
|
const endDate = task.value.endDate ? new Date(task.value.endDate) : null
|
||||||
|
const progress = task.value.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 progressClass = computed(() => getProgressClass())
|
||||||
|
|
||||||
|
// 计算自定义行类名
|
||||||
|
const customRowClass = computed(() => {
|
||||||
|
if (!taskListRowClassName?.value) return ''
|
||||||
|
|
||||||
|
if (typeof taskListRowClassName.value === 'function') {
|
||||||
|
return taskListRowClassName.value(task.value, rowIndex?.value ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return taskListRowClassName.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算自定义行样式(优先级高于类名)
|
||||||
|
const customRowStyle = computed(() => {
|
||||||
|
if (!taskListRowStyle?.value) return {}
|
||||||
|
|
||||||
|
if (typeof taskListRowStyle.value === 'function') {
|
||||||
|
return taskListRowStyle.value(task.value, rowIndex?.value ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return taskListRowStyle.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 使用解构返回单个computed的属性
|
||||||
|
return {
|
||||||
|
indent: computed(() => taskState.value.indent),
|
||||||
|
hasChildren: computed(() => taskState.value.hasChildren),
|
||||||
|
isStoryTask: computed(() => taskState.value.isStoryTask),
|
||||||
|
isMilestoneGroup: computed(() => taskState.value.isMilestoneGroup),
|
||||||
|
isMilestoneTask: computed(() => taskState.value.isMilestoneTask),
|
||||||
|
isParentTask: computed(() => taskState.value.isParentTask),
|
||||||
|
getProgressClass,
|
||||||
|
isOvertime,
|
||||||
|
overdueDays,
|
||||||
|
progressClass,
|
||||||
|
customRowClass,
|
||||||
|
customRowStyle,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
export { default as TaskList } from './TaskList.vue'
|
export { default as TaskList } from './TaskList.vue'
|
||||||
export { default as TaskListColumn } from './TaskListColumn.vue'
|
export { default as TaskListColumn } from './TaskListColumn.vue'
|
||||||
export { useTaskListLayout } from './composables/useTaskListLayout'
|
export { default as TaskRow } from './taskRow/TaskRow.vue'
|
||||||
export { useTaskListColumns } from './composables/useTaskListColumns'
|
export { useTaskListLayout } from './composables/taskList/useTaskListLayout'
|
||||||
export { useTaskListResize } from './composables/useTaskListResize'
|
export { useTaskListColumns } from './composables/taskList/useTaskListColumns'
|
||||||
export { useTaskListEventHandlers } from './composables/useTaskListEventHandlers'
|
export { useTaskListResize } from './composables/taskList/useTaskListResize'
|
||||||
|
export { useTaskListEventHandlers } from './composables/taskList/useTaskListEventHandlers'
|
||||||
export {
|
export {
|
||||||
calculateParentTaskData,
|
calculateParentTaskData,
|
||||||
updateParentTasksData,
|
updateParentTasksData,
|
||||||
getAllTasks,
|
getAllTasks,
|
||||||
} from './composables/useTaskParentCalculation'
|
} from './composables/taskList/useTaskParentCalculation'
|
||||||
export type { DeclarativeColumnConfig } from './composables/useTaskListColumns'
|
export type { DeclarativeColumnConfig } from './composables/taskList/useTaskListColumns'
|
||||||
|
|||||||
676
src/components/TaskList/taskRow/TaskRow.vue
Normal file
676
src/components/TaskList/taskRow/TaskRow.vue
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, useSlots, toRef } from 'vue'
|
||||||
|
import type { StyleValue } from 'vue'
|
||||||
|
import { useI18n } from '../../../composables/useI18n'
|
||||||
|
import { formatPredecessorDisplay } from '../../../utils/predecessorUtils'
|
||||||
|
import type { Task } from '../../../models/classes/Task'
|
||||||
|
import type { TaskListColumnConfig } from '../../../models/configs/TaskListConfig'
|
||||||
|
import type { DeclarativeColumnConfig } from '../composables/taskList/useTaskListColumns'
|
||||||
|
import TaskContextMenu from '../../TaskContextMenu.vue'
|
||||||
|
import TaskRowCollapseButton from './TaskRowCollapseButton.vue'
|
||||||
|
import TaskRowNameContent from './TaskRowNameContent.vue'
|
||||||
|
import { useTaskRowState } from '../composables/taskRow/useTaskRowState'
|
||||||
|
import { useTaskRowDeclarativeColumns } from '../composables/taskRow/useTaskRowDeclarativeColumns'
|
||||||
|
import { useTaskRowContextMenu } from '../composables/taskRow/useTaskRowContextMenu'
|
||||||
|
import { useTaskRowEventHandlers } from '../composables/taskRow/useTaskRowEventHandlers'
|
||||||
|
import { useTaskRowColumnSlots } from '../composables/taskRow/useTaskRowColumnSlots'
|
||||||
|
|
||||||
|
interface TaskRowSlotProps {
|
||||||
|
isRowContent: boolean
|
||||||
|
task: Task
|
||||||
|
level: number
|
||||||
|
indent: string
|
||||||
|
isHovered: boolean
|
||||||
|
hoveredTaskId: number | null
|
||||||
|
isParent: boolean
|
||||||
|
hasChildren: boolean
|
||||||
|
collapsed: boolean
|
||||||
|
formattedTimer: string
|
||||||
|
timerRunning: boolean
|
||||||
|
timerElapsed: number
|
||||||
|
isOvertime: number | boolean | undefined
|
||||||
|
overdueDays: number
|
||||||
|
overtimeText: string
|
||||||
|
overdueText: string
|
||||||
|
daysText: string
|
||||||
|
progressClass: string
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
task: Task
|
||||||
|
level: number
|
||||||
|
rowIndex?: number
|
||||||
|
isHovered?: boolean
|
||||||
|
hoveredTaskId?: number | null
|
||||||
|
onHover?: (taskId: number | null) => void
|
||||||
|
columns: TaskListColumnConfig[]
|
||||||
|
declarativeColumns?: DeclarativeColumnConfig[]
|
||||||
|
renderMode?: 'default' | 'declarative'
|
||||||
|
getColumnWidthStyle?: (column: { width?: number | string }) => StyleValue
|
||||||
|
disableChildrenRender?: boolean
|
||||||
|
showTaskIcon?: boolean // 是否显示任务图标,默认true
|
||||||
|
enableDrag?: boolean
|
||||||
|
dragStart?: (task: Task, element: HTMLElement, event: MouseEvent) => void
|
||||||
|
dragOver?: (task: Task, element: HTMLElement, event: MouseEvent) => void
|
||||||
|
taskListRowClassName?: string | ((row: Task, rowIndex: number) => string)
|
||||||
|
taskListRowStyle?: StyleValue | ((row: Task, rowIndex: number) => StyleValue)
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
renderMode: 'default',
|
||||||
|
})
|
||||||
|
const emit = defineEmits([
|
||||||
|
'toggle',
|
||||||
|
'dblclick',
|
||||||
|
'contextmenu',
|
||||||
|
'start-timer',
|
||||||
|
'stop-timer',
|
||||||
|
'add-predecessor',
|
||||||
|
'add-successor',
|
||||||
|
'delete',
|
||||||
|
])
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
'custom-task-content'(props: TaskRowSlotProps): unknown
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const overtimeText = computed(() => t.value?.overtime ?? '')
|
||||||
|
const overdueText = computed(() => t.value?.overdue ?? '')
|
||||||
|
const daysText = computed(() => t.value?.days ?? '')
|
||||||
|
|
||||||
|
const slots = useSlots()
|
||||||
|
const hasContentSlot = computed(() => Boolean(slots['custom-task-content']))
|
||||||
|
|
||||||
|
// 性能优化:只对会变化的props使用toRef,其他直接传值
|
||||||
|
const taskRef = toRef(props, 'task')
|
||||||
|
const levelRef = toRef(props, 'level')
|
||||||
|
|
||||||
|
// 使用 composables
|
||||||
|
const { indent, hasChildren, isStoryTask, isMilestoneGroup, isMilestoneTask, isParentTask, isOvertime, overdueDays, progressClass, customRowClass, customRowStyle } =
|
||||||
|
useTaskRowState(
|
||||||
|
taskRef,
|
||||||
|
levelRef,
|
||||||
|
computed(() => props.rowIndex),
|
||||||
|
computed(() => props.taskListRowClassName),
|
||||||
|
computed(() => props.taskListRowStyle),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { hasColumnSlot, renderColumnSlot } = useTaskRowColumnSlots()
|
||||||
|
|
||||||
|
const { isFirstColumn, getDeclarativeColumnAlign, renderDeclarativeColumn } =
|
||||||
|
useTaskRowDeclarativeColumns(
|
||||||
|
taskRef,
|
||||||
|
isParentTask,
|
||||||
|
isMilestoneGroup,
|
||||||
|
isStoryTask,
|
||||||
|
hasChildren,
|
||||||
|
computed(() => props.showTaskIcon),
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
contextMenuVisible,
|
||||||
|
contextMenuPosition,
|
||||||
|
contextMenuTask,
|
||||||
|
handleContextMenu,
|
||||||
|
closeContextMenu,
|
||||||
|
handleTaskDelete,
|
||||||
|
timerElapsed,
|
||||||
|
formattedTimer,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} = useTaskRowContextMenu(taskRef, emit as any)
|
||||||
|
|
||||||
|
// TaskRow拖拽功能
|
||||||
|
const taskRowRef = ref<HTMLElement | null>(null)
|
||||||
|
const isSplitterDragging = ref(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleToggle,
|
||||||
|
handleRowClick,
|
||||||
|
handleTaskRowDoubleClick,
|
||||||
|
handleMouseEnter,
|
||||||
|
handleMouseLeave,
|
||||||
|
handleMouseDown,
|
||||||
|
} = useTaskRowEventHandlers(
|
||||||
|
taskRef,
|
||||||
|
isStoryTask,
|
||||||
|
hasChildren,
|
||||||
|
isMilestoneGroup,
|
||||||
|
isSplitterDragging,
|
||||||
|
taskRowRef,
|
||||||
|
computed(() => props.enableDrag),
|
||||||
|
props.onHover,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
emit as any,
|
||||||
|
props.dragStart,
|
||||||
|
props.dragOver,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 计算实际要渲染的列
|
||||||
|
const columnsToRender = computed(() => {
|
||||||
|
if (props.renderMode === 'declarative' && props.declarativeColumns) {
|
||||||
|
return props.declarativeColumns
|
||||||
|
}
|
||||||
|
return props.columns
|
||||||
|
})
|
||||||
|
|
||||||
|
const slotPayload = computed(() => ({
|
||||||
|
isRowContent: true,
|
||||||
|
task: props.task,
|
||||||
|
level: props.level,
|
||||||
|
indent: indent.value,
|
||||||
|
isHovered: props.isHovered ?? false,
|
||||||
|
hoveredTaskId: props.hoveredTaskId ?? null,
|
||||||
|
isParent: isParentTask.value,
|
||||||
|
hasChildren: hasChildren.value,
|
||||||
|
collapsed: !!props.task.collapsed,
|
||||||
|
formattedTimer: formattedTimer.value,
|
||||||
|
timerRunning: !!props.task.isTimerRunning,
|
||||||
|
timerElapsed: timerElapsed.value,
|
||||||
|
isOvertime: isOvertime(),
|
||||||
|
overdueDays: overdueDays(),
|
||||||
|
overtimeText: overtimeText.value,
|
||||||
|
overdueText: overdueText.value,
|
||||||
|
daysText: daysText.value,
|
||||||
|
progressClass: progressClass.value,
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
ref="taskRowRef"
|
||||||
|
class="task-row"
|
||||||
|
:data-task-id="props.task.id"
|
||||||
|
:class="{
|
||||||
|
'task-row-hovered': isHovered,
|
||||||
|
'parent-task': isParentTask,
|
||||||
|
'milestone-group-row': isMilestoneGroup,
|
||||||
|
'task-type-story': isStoryTask,
|
||||||
|
'task-type-task': props.task.type === 'task',
|
||||||
|
'task-type-milestone': isMilestoneTask,
|
||||||
|
[customRowClass]: customRowClass,
|
||||||
|
}"
|
||||||
|
:style="customRowStyle"
|
||||||
|
@click="handleRowClick"
|
||||||
|
@dblclick="handleTaskRowDoubleClick"
|
||||||
|
@mousedown="handleMouseDown"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
|
@mouseleave="handleMouseLeave"
|
||||||
|
@contextmenu="handleContextMenu"
|
||||||
|
>
|
||||||
|
<!-- 声明式渲染模式 -->
|
||||||
|
<template v-if="renderMode === 'declarative' && declarativeColumns">
|
||||||
|
<div
|
||||||
|
v-for="(column, index) in columnsToRender"
|
||||||
|
:key="index"
|
||||||
|
class="col"
|
||||||
|
:class="[column.cssClass, { 'col-name': isFirstColumn(index) }]"
|
||||||
|
:style="[
|
||||||
|
getColumnWidthStyle ? getColumnWidthStyle(column) : {},
|
||||||
|
getDeclarativeColumnAlign(column),
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- 第一列:保留 collapse-btn, milestone-spacer, leaf-spacer -->
|
||||||
|
<div
|
||||||
|
v-if="isFirstColumn(index)"
|
||||||
|
:style="{ paddingLeft: indent }"
|
||||||
|
class="first-col-wrapper"
|
||||||
|
>
|
||||||
|
<TaskRowCollapseButton
|
||||||
|
:collapsed="!!props.task.collapsed"
|
||||||
|
:visible="(isStoryTask || hasChildren) && !isMilestoneGroup"
|
||||||
|
@toggle="handleToggle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span v-if="isMilestoneGroup" class="milestone-spacer"></span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false"
|
||||||
|
class="leaf-spacer"
|
||||||
|
></span>
|
||||||
|
|
||||||
|
<!-- 渲染列内容 -->
|
||||||
|
<component :is="() => renderDeclarativeColumn(column, index)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 其他列:直接渲染内容 -->
|
||||||
|
<component :is="() => renderDeclarativeColumn(column, index)" v-else />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 默认渲染模式 -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="col col-name" :style="{ paddingLeft: indent }">
|
||||||
|
<TaskRowCollapseButton
|
||||||
|
:collapsed="!!props.task.collapsed"
|
||||||
|
:visible="(isStoryTask || hasChildren) && !isMilestoneGroup"
|
||||||
|
@toggle="handleToggle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 里程碑分组的占位空间,用于与有折叠按钮的任务对齐 -->
|
||||||
|
<span v-if="isMilestoneGroup" class="milestone-spacer"></span>
|
||||||
|
|
||||||
|
<!-- 叶子节点的占位空间(无折叠按钮且无图标显示时) -->
|
||||||
|
<span
|
||||||
|
v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false"
|
||||||
|
class="leaf-spacer"
|
||||||
|
></span>
|
||||||
|
|
||||||
|
<TaskRowNameContent
|
||||||
|
:task="props.task"
|
||||||
|
:isParentTask="isParentTask"
|
||||||
|
:hasChildren="hasChildren"
|
||||||
|
:isStoryTask="isStoryTask"
|
||||||
|
:isMilestoneGroup="isMilestoneGroup"
|
||||||
|
:isMilestoneTask="isMilestoneTask"
|
||||||
|
:showTaskIcon="showTaskIcon !== false"
|
||||||
|
:formattedTimer="formattedTimer"
|
||||||
|
:isOvertime="!!isOvertime()"
|
||||||
|
:overdueDays="overdueDays()"
|
||||||
|
:overtimeText="overtimeText"
|
||||||
|
:overdueText="overdueText"
|
||||||
|
:daysText="daysText"
|
||||||
|
:hasContentSlot="hasContentSlot"
|
||||||
|
:renderColumnSlot="renderColumnSlot"
|
||||||
|
:hasColumnSlot="hasColumnSlot"
|
||||||
|
>
|
||||||
|
<template #custom-task-content>
|
||||||
|
<slot
|
||||||
|
name="custom-task-content"
|
||||||
|
v-bind="slotPayload"
|
||||||
|
type="task-row"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</TaskRowNameContent>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 动态渲染列 -->
|
||||||
|
<div
|
||||||
|
v-for="column in columns"
|
||||||
|
:key="column.key"
|
||||||
|
class="col"
|
||||||
|
:class="column.cssClass || `col-${column.key}`"
|
||||||
|
:style="getColumnWidthStyle ? getColumnWidthStyle(column) : undefined"
|
||||||
|
>
|
||||||
|
<!-- 里程碑分组显示空列 -->
|
||||||
|
<template v-if="isMilestoneGroup">
|
||||||
|
<div class="milestone-empty-col"></div>
|
||||||
|
</template>
|
||||||
|
<!-- 普通任务显示具体内容 -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- 优先级1: 列级自定义 Slot (约定: #column-{key}),通过 renderColumnSlot 动态渲染 -->
|
||||||
|
<component
|
||||||
|
:is="() =>
|
||||||
|
renderColumnSlot(column.key,
|
||||||
|
{ task: props.task, column, value: (props.task as any)[column.key] })"
|
||||||
|
v-if="hasColumnSlot(column.key)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 优先级2: Formatter 函数 -->
|
||||||
|
<template v-else-if="column.formatter">
|
||||||
|
{{ column.formatter(props.task, column) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 优先级3: 内置列类型渲染 -->
|
||||||
|
<!-- 前置任务列 -->
|
||||||
|
<template v-else-if="column.key === 'predecessor'">
|
||||||
|
{{ formatPredecessorDisplay(props.task.predecessor) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 负责人列 -->
|
||||||
|
<template v-else-if="column.key === '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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 开始日期列 -->
|
||||||
|
<template v-else-if="column.key === 'startDate'">
|
||||||
|
{{ props.task.startDate || '-' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 结束日期列 -->
|
||||||
|
<template v-else-if="column.key === 'endDate'">
|
||||||
|
{{ props.task.endDate || '-' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 预估工时列 -->
|
||||||
|
<template v-else-if="column.key === 'estimatedHours'">
|
||||||
|
{{ props.task.estimatedHours || '-' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 实际工时列 -->
|
||||||
|
<template v-else-if="column.key === 'actualHours'">
|
||||||
|
{{ props.task.actualHours || '-' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 进度列 -->
|
||||||
|
<template v-else-if="column.key === 'progress'">
|
||||||
|
<span class="progress-value" :class="progressClass">
|
||||||
|
{{ props.task.progress != null ? props.task.progress + '%' : '-' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 优先级4: 默认渲染 - 自定义列通过task对象的key动态获取值 -->
|
||||||
|
<template v-else>
|
||||||
|
{{ (props.task as any)[column.key] || '-' }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- 结束默认渲染模式 -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
性能优化:移除递归渲染
|
||||||
|
TaskList已经通过虚拟滚动和扁平化处理子任务
|
||||||
|
TaskRow只负责渲染单个任务行,不再递归渲染子任务
|
||||||
|
-->
|
||||||
|
|
||||||
|
<TaskContextMenu
|
||||||
|
:visible="contextMenuVisible"
|
||||||
|
:task="contextMenuTask"
|
||||||
|
:position="contextMenuPosition"
|
||||||
|
@close="closeContextMenu"
|
||||||
|
@start-timer="$emit('start-timer', props.task)"
|
||||||
|
@stop-timer="$emit('stop-timer', props.task)"
|
||||||
|
@add-predecessor="$emit('add-predecessor', props.task)"
|
||||||
|
@add-successor="$emit('add-successor', props.task)"
|
||||||
|
@delete="handleTaskDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import '../../../styles/theme-variables.css';
|
||||||
|
@import '../../../styles/list.css';
|
||||||
|
|
||||||
|
.task-row {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--gantt-border-light);
|
||||||
|
height: 51px; /* 修改为51px,与Timeline中的task-row高度保持一致,包含border-bottom 1px */
|
||||||
|
box-sizing: border-box; /* 确保border包含在高度计算中 */
|
||||||
|
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: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 叶子节点占位空间 - 当不显示图标时保持缩进层级 */
|
||||||
|
.leaf-spacer {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 20px; /* 18px (折叠按钮宽度) + 2px (额外间距) */
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignee-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
min-width: 25px;
|
||||||
|
min-height: 25px;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 里程碑行样式 - 统一使用红色 */
|
||||||
|
.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-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TaskRow拖拽样式 */
|
||||||
|
.task-row-dragging {
|
||||||
|
opacity: 0.6 !important;
|
||||||
|
cursor: move !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖拽目标高亮样式 */
|
||||||
|
.task-row-drop-target.drop-after {
|
||||||
|
border-bottom: 3px solid var(--gantt-primary, #409eff) !important;
|
||||||
|
background-color: rgba(64, 158, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row-drop-target.drop-child {
|
||||||
|
border: 2px solid var(--gantt-primary, #409eff) !important;
|
||||||
|
background-color: rgba(64, 158, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-theme='dark']) .task-row-drop-target.drop-after {
|
||||||
|
background-color: rgba(125, 180, 240, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-theme='dark']) .task-row-drop-target.drop-child {
|
||||||
|
background-color: rgba(125, 180, 240, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 声明式列第一列的包装器 */
|
||||||
|
.first-col-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 声明式列的 padding,与 header 保持一致 */
|
||||||
|
.task-row .col {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
88
src/components/TaskList/taskRow/TaskRowBadges.vue
Normal file
88
src/components/TaskList/taskRow/TaskRowBadges.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
formattedTimer: string
|
||||||
|
isTimerRunning: boolean
|
||||||
|
timerElapsedTime?: number
|
||||||
|
isOvertime: boolean
|
||||||
|
overdueDays: number
|
||||||
|
overtimeText: string
|
||||||
|
overdueText: string
|
||||||
|
daysText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- 计时器显示 -->
|
||||||
|
<span
|
||||||
|
v-if="isTimerRunning || timerElapsedTime"
|
||||||
|
class="timer-badge"
|
||||||
|
:class="{ 'timer-active': isTimerRunning }"
|
||||||
|
>
|
||||||
|
<span v-if="isTimerRunning" class="timer-dot"></span>
|
||||||
|
{{ formattedTimer }}
|
||||||
|
</span>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 6px;
|
||||||
|
background-color: var(--gantt-info, #909399);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-badge.timer-active {
|
||||||
|
background-color: var(--gantt-success, #67c23a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: white;
|
||||||
|
margin-right: 4px;
|
||||||
|
animation: timer-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes timer-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
54
src/components/TaskList/taskRow/TaskRowCollapseButton.vue
Normal file
54
src/components/TaskList/taskRow/TaskRowCollapseButton.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
collapsed: boolean
|
||||||
|
visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
defineEmits<{
|
||||||
|
toggle: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
v-if="visible"
|
||||||
|
class="collapse-btn"
|
||||||
|
@click.stop="$emit('toggle')"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline v-if="collapsed" points="9,18 15,12 9,6" />
|
||||||
|
<polyline v-else points="18,15 12,9 6,15" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
82
src/components/TaskList/taskRow/TaskRowIcon.vue
Normal file
82
src/components/TaskList/taskRow/TaskRowIcon.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
isMilestoneGroup: boolean
|
||||||
|
isParentTask: boolean
|
||||||
|
hasChildren: boolean
|
||||||
|
isStoryTask: boolean
|
||||||
|
showIcon?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
showIcon: true,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span v-if="showIcon" class="task-icon">
|
||||||
|
<!-- 里程碑分组图标 - 使用菱形图标 -->
|
||||||
|
<svg
|
||||||
|
v-if="isMilestoneGroup"
|
||||||
|
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="isStoryTask || hasChildren"
|
||||||
|
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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.task-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: var(--gantt-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-icon svg {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 里程碑分组图标样式 - 统一使用红色 */
|
||||||
|
.milestone-group-icon {
|
||||||
|
color: var(--gantt-danger, #f56c6c);
|
||||||
|
fill: var(--gantt-danger, #f56c6c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗黑模式下的里程碑图标 */
|
||||||
|
:global(html[data-theme='dark']) .milestone-group-icon {
|
||||||
|
color: var(--gantt-danger, #f67c7c);
|
||||||
|
fill: var(--gantt-danger, #f67c7c);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
129
src/components/TaskList/taskRow/TaskRowNameContent.vue
Normal file
129
src/components/TaskList/taskRow/TaskRowNameContent.vue
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Task } from '../../../models/classes/Task'
|
||||||
|
import TaskRowIcon from './TaskRowIcon.vue'
|
||||||
|
import TaskRowBadges from './TaskRowBadges.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
task: Task
|
||||||
|
isParentTask: boolean
|
||||||
|
hasChildren: boolean
|
||||||
|
isStoryTask: boolean
|
||||||
|
isMilestoneGroup: boolean
|
||||||
|
isMilestoneTask: boolean
|
||||||
|
showTaskIcon: boolean
|
||||||
|
formattedTimer: string
|
||||||
|
isOvertime: boolean
|
||||||
|
overdueDays: number
|
||||||
|
overtimeText: string
|
||||||
|
overdueText: string
|
||||||
|
daysText: string
|
||||||
|
hasContentSlot: boolean
|
||||||
|
renderColumnSlot?: (key: string, payload: any) => any
|
||||||
|
hasColumnSlot?: (key: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
'custom-task-content'(props: any): unknown
|
||||||
|
'column-name'(props: any): unknown
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- 优先级1: 列级自定义 Slot (#column-name) - 覆盖整列内容(图标+文本+徽章) -->
|
||||||
|
<component
|
||||||
|
:is="() => renderColumnSlot?.('name', {
|
||||||
|
task: props.task,
|
||||||
|
column: { key: 'name' },
|
||||||
|
value: props.task.name,
|
||||||
|
isParentTask,
|
||||||
|
hasChildren,
|
||||||
|
isStoryTask,
|
||||||
|
isMilestoneGroup,
|
||||||
|
isMilestoneTask,
|
||||||
|
showTaskIcon,
|
||||||
|
formattedTimer,
|
||||||
|
isOvertime,
|
||||||
|
overdueDays,
|
||||||
|
overtimeText,
|
||||||
|
overdueText,
|
||||||
|
daysText,
|
||||||
|
})"
|
||||||
|
v-if="hasColumnSlot?.('name')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 优先级2: custom-task-content Slot (向后兼容) - 仅替换文本部分 -->
|
||||||
|
<template v-else-if="hasContentSlot">
|
||||||
|
<TaskRowIcon
|
||||||
|
:isMilestoneGroup="isMilestoneGroup"
|
||||||
|
:isParentTask="isParentTask"
|
||||||
|
:hasChildren="hasChildren"
|
||||||
|
:isStoryTask="isStoryTask"
|
||||||
|
:showIcon="showTaskIcon"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="task-name-text"
|
||||||
|
:class="{ 'parent-task': isParentTask }"
|
||||||
|
:title="task.name"
|
||||||
|
>
|
||||||
|
<slot name="custom-task-content" />
|
||||||
|
<TaskRowBadges
|
||||||
|
:formattedTimer="formattedTimer"
|
||||||
|
:isTimerRunning="!!task.isTimerRunning"
|
||||||
|
:timerElapsedTime="task.timerElapsedTime"
|
||||||
|
:isOvertime="isOvertime"
|
||||||
|
:overdueDays="overdueDays"
|
||||||
|
:overtimeText="overtimeText"
|
||||||
|
:overdueText="overdueText"
|
||||||
|
:daysText="daysText"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 优先级3: 默认渲染 -->
|
||||||
|
<template v-else>
|
||||||
|
<TaskRowIcon
|
||||||
|
:isMilestoneGroup="isMilestoneGroup"
|
||||||
|
:isParentTask="isParentTask"
|
||||||
|
:hasChildren="hasChildren"
|
||||||
|
:isStoryTask="isStoryTask"
|
||||||
|
:showIcon="showTaskIcon"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="task-name-text"
|
||||||
|
:class="{ 'parent-task': isParentTask }"
|
||||||
|
:title="task.name"
|
||||||
|
>
|
||||||
|
{{ task.name }}
|
||||||
|
<TaskRowBadges
|
||||||
|
:formattedTimer="formattedTimer"
|
||||||
|
:isTimerRunning="!!task.isTimerRunning"
|
||||||
|
:timerElapsedTime="task.timerElapsedTime"
|
||||||
|
:isOvertime="isOvertime"
|
||||||
|
:overdueDays="overdueDays"
|
||||||
|
:overtimeText="overtimeText"
|
||||||
|
:overdueText="overdueText"
|
||||||
|
:daysText="daysText"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ export { default as GanttChart } from './GanttChart.vue'
|
|||||||
export { default as GanttToolbar } from './GanttToolbar.vue'
|
export { default as GanttToolbar } from './GanttToolbar.vue'
|
||||||
export { default as TaskList } from './TaskList/TaskList.vue'
|
export { default as TaskList } from './TaskList/TaskList.vue'
|
||||||
export { default as TaskListColumn } from './TaskList/TaskListColumn.vue'
|
export { default as TaskListColumn } from './TaskList/TaskListColumn.vue'
|
||||||
export { default as TaskRow } from './TaskRow.vue'
|
export { default as TaskRow } from './TaskList/taskRow/TaskRow.vue'
|
||||||
export { default as Timeline } from './Timeline.vue'
|
export { default as Timeline } from './Timeline.vue'
|
||||||
export { default as TaskBar } from './TaskBar.vue'
|
export { default as TaskBar } from './TaskBar.vue'
|
||||||
export { default as MilestonePoint } from './MilestonePoint.vue'
|
export { default as MilestonePoint } from './MilestonePoint.vue'
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export { default as TaskBar } from './components/TaskBar.vue'
|
|||||||
export { default as TaskDrawer } from './components/TaskDrawer.vue'
|
export { default as TaskDrawer } from './components/TaskDrawer.vue'
|
||||||
export { default as MilestonePoint } from './components/MilestonePoint.vue'
|
export { default as MilestonePoint } from './components/MilestonePoint.vue'
|
||||||
export { default as MilestoneDialog } from './components/MilestoneDialog.vue'
|
export { default as MilestoneDialog } from './components/MilestoneDialog.vue'
|
||||||
export { default as TaskRow } from './components/TaskRow.vue'
|
export { default as TaskRow } from './components/TaskList/taskRow/TaskRow.vue'
|
||||||
export type { Task } from './models/classes/Task.ts' // 导出Task类型
|
export type { Task } from './models/classes/Task.ts' // 导出Task类型
|
||||||
export { useMessage } from './composables/useMessage.ts' // 导出useMessage组合式函数
|
export { useMessage } from './composables/useMessage.ts' // 导出useMessage组合式函数
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user