Merge branch 'master' into develop

# Conflicts:
#	src/components/GanttChart.vue
#	src/components/Timeline.vue
This commit is contained in:
LINING-PC\lining
2025-11-20 11:21:43 +08:00
15 changed files with 1600 additions and 893 deletions
+2
View File
@@ -35,3 +35,5 @@ dist-ssr
Thumbs.db Thumbs.db
ehthumbs.db ehthumbs.db
Desktop.ini Desktop.ini
yarn.lock
+24
View File
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.2-patch3] - 2025-11-13
### Added
- 支持视图上下/左右拖拽
- Support for vertical/horizontal dragging of the view
### Enhancement
- 性能优化:canvas绘制性能优化
- 性能优化:拖拽视图性能优化,拖拽期间禁止重绘canvas
- 性能优化:周视图月开始分隔线实现使用canvas替代dom
- 性能优化:使用canvas替代svg links实现
- 性能优化:路径缓存+防抖+shallowRef计算
- Performance Optimization: Canvas drawing performance optimization
- Performance Optimization: View dragging performance optimization, disabling canvas redraw during dragging
- Performance Optimization: Weekly view month start separator line implemented using canvas instead of DOM
- Performance Optimization: Using canvas instead of SVG links implementation
- Performance Optimization: Path caching + debounce + shallowRef calculation
## [1.4.2-patch1] - 2025-11-03
### Fixed
- 缺陷修复:更新父级任务后,再次更新子级任务是发生无限循环调用的问题修复
- Defect fix: Fixed the issue of infinite loop calls when updating child tasks after updating the parent task
## [1.4.2-patch2] - 2025-11-11 ## [1.4.2-patch2] - 2025-11-11
### Fixed ### Fixed
+47 -44
View File
@@ -1778,53 +1778,56 @@ Override or extend default translations via `localeMessages` prop:
<script setup lang="ts"> <script setup lang="ts">
const customMessages = { const customMessages = {
// Task list related "zh-CN": {
name: 'Task Name (Custom)', // Task list related
startDate: 'Start Date', name: 'Task Name (Custom)',
endDate: 'End Date', startDate: 'Start Date',
duration: 'Duration', endDate: 'End Date',
progress: 'Completion', duration: 'Duration',
predecessor: 'Predecessors', progress: 'Completion',
assignee: 'Assignee', predecessor: 'Predecessors',
estimatedHours: 'Estimated Hours', assignee: 'Assignee',
actualHours: 'Actual Hours' estimatedHours: 'Estimated Hours',
actualHours: 'Actual Hours'
// Toolbar related // Toolbar related
addTask: 'New Task', addTask: 'New Task',
addMilestone: 'New Milestone', addMilestone: 'New Milestone',
today: 'Today', today: 'Today',
exportCsv: 'Export CSV', exportCsv: 'Export CSV',
exportPdf: 'Export PDF', exportPdf: 'Export PDF',
fullscreen: 'Fullscreen', fullscreen: 'Fullscreen',
exitFullscreen: 'Exit Fullscreen', exitFullscreen: 'Exit Fullscreen',
language: 'Language', language: 'Language',
theme: 'Theme', theme: 'Theme',
expandAll: 'Expand All', expandAll: 'Expand All',
collapseAll: 'Collapse All' collapseAll: 'Collapse All'
// Internal Task editor related // Internal Task editor related
title: 'Task Details', title: 'Task Details',
titleEdit: 'Edit Task', titleEdit: 'Edit Task',
titleNew: 'New Task', titleNew: 'New Task',
name: 'Task Name', name: 'Task Name',
startDate: 'Start Date', startDate: 'Start Date',
endDate: 'End Date', endDate: 'End Date',
assignee: 'Assignee', assignee: 'Assignee',
predecessor: 'Predecessors', predecessor: 'Predecessors',
description: 'Description', description: 'Description',
estimatedHours: 'Estimated Hours', estimatedHours: 'Estimated Hours',
actualHours: 'Actual Hours', actualHours: 'Actual Hours',
progress: 'Progress', progress: 'Progress',
save: 'Save', save: 'Save',
cancel: 'Cancel', cancel: 'Cancel',
delete: 'Delete' delete: 'Delete'
// Other texts // Other texts
days: 'days', days: 'days',
hours: 'hours', hours: 'hours',
overtime: 'overtime', overtime: 'overtime',
overdue: 'overdue', overdue: 'overdue',
// ... More custom translations // ... More custom translations
},
"en-US": {......}
} }
</script> </script>
``` ```
+47 -44
View File
@@ -1769,53 +1769,56 @@ const handleLanguageChange = (lang: 'zh-CN' | 'en-US') => {
<script setup lang="ts"> <script setup lang="ts">
const customMessages = { const customMessages = {
// 任务列表相关 "zh-CN": {
name: '任务名称(自定义)', // 任务列表相关
startDate: '开始日期', name: '任务名称(自定义)',
endDate: '结束日期', startDate: '开始日期',
duration: '工期', endDate: '结束日期',
progress: '完成度', duration: '工期',
predecessor: '前置任务', progress: '完成度',
assignee: '负责人', predecessor: '前置任务',
estimatedHours: '预估工时', assignee: '负责人',
actualHours: '实际工时' estimatedHours: '预估工时',
actualHours: '实际工时'
// 工具栏相关 // 工具栏相关
addTask: '新建任务', addTask: '新建任务',
addMilestone: '新建里程碑', addMilestone: '新建里程碑',
today: '今天', today: '今天',
exportCsv: '导出 CSV', exportCsv: '导出 CSV',
exportPdf: '导出 PDF', exportPdf: '导出 PDF',
fullscreen: '全屏', fullscreen: '全屏',
exitFullscreen: '退出全屏', exitFullscreen: '退出全屏',
language: '语言', language: '语言',
theme: '主题', theme: '主题',
expandAll: '全部展开', expandAll: '全部展开',
collapseAll: '全部折叠' collapseAll: '全部折叠'
// 内置任务编辑器相关 // 内置任务编辑器相关
title: '任务详情', title: '任务详情',
titleEdit: '编辑任务', titleEdit: '编辑任务',
titleNew: '新建任务', titleNew: '新建任务',
name: '任务名称', name: '任务名称',
startDate: '开始日期', startDate: '开始日期',
endDate: '结束日期', endDate: '结束日期',
assignee: '负责人', assignee: '负责人',
predecessor: '前置任务', predecessor: '前置任务',
description: '描述', description: '描述',
estimatedHours: '预估工时', estimatedHours: '预估工时',
actualHours: '实际工时', actualHours: '实际工时',
progress: '进度', progress: '进度',
save: '保存', save: '保存',
cancel: '取消', cancel: '取消',
delete: '删除' delete: '删除'
// 其他文本 // 其他文本
days: '天', days: '天',
hours: '小时', hours: '小时',
overtime: '超时', overtime: '超时',
overdue: '逾期', overdue: '逾期',
// ... 更多自定义翻译 // ... 更多自定义翻译
},
"en-US": {......}
} }
</script> </script>
``` ```
+2 -3
View File
@@ -910,7 +910,7 @@ function taskDebug(item: any) {
.app-container { .app-container {
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 20px; padding: 10px;
box-sizing: border-box; box-sizing: border-box;
background: var(--gantt-bg-secondary, #f0f2f5); background: var(--gantt-bg-secondary, #f0f2f5);
display: flex; display: flex;
@@ -922,7 +922,7 @@ function taskDebug(item: any) {
background: var(--gantt-bg-primary, #ffffff); background: var(--gantt-bg-primary, #ffffff);
border: 1px solid var(--gantt-border-color, #e4e7ed); border: 1px solid var(--gantt-border-color, #e4e7ed);
border-radius: 8px; border-radius: 8px;
margin-bottom: 20px; margin-bottom: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease; transition: all 0.3s ease;
overflow: hidden; overflow: hidden;
@@ -1453,7 +1453,6 @@ function taskDebug(item: any) {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: flex-start; justify-content: flex-start;
margin-bottom: 24px;
} }
.license-info { .license-info {
-1
View File
@@ -26,7 +26,6 @@ body {
margin: 0; margin: 0;
min-width: 320px; min-width: 320px;
height: 100vh; height: 100vh;
padding: 30px;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
align-items: center; align-items: center;
+20
View File
@@ -286,5 +286,25 @@
"Defect fix: Fixed the issue of invalid column width configuration in TaskList", "Defect fix: Fixed the issue of invalid column width configuration in TaskList",
"<span style=\"font-weight: bold; color: #f00;\">Special thanks to @SHENGLONG749 for their valuable use and feedback</span>" "<span style=\"font-weight: bold; color: #f00;\">Special thanks to @SHENGLONG749 for their valuable use and feedback</span>"
] ]
},
{
"version": "1.4.2-patch3",
"date": "2025-11-13",
"notes": [
"新增:支持视图上下/左右拖拽",
"性能优化:canvas绘制性能优化",
"性能优化:拖拽视图性能优化,拖拽期间禁止重绘canvas",
"性能优化:周视图月开始分隔线实现使用canvas替代dom",
"性能优化:使用canvas替代svg links实现",
"性能优化:路径缓存+防抖+shallowRef计算",
"Added: Support for vertical/horizontal dragging of the view",
"Performance Optimization: Canvas drawing performance optimization",
"Performance Optimization: View dragging performance optimization, disabling canvas redraw during dragging",
"Performance Optimization: Weekly view month start separator line implemented using canvas instead of DOM",
"Performance Optimization: Using canvas instead of SVG links implementation",
"Performance Optimization: Path caching + debounce + shallowRef calculation",
"<span style=\"font-weight: bold; color: #f00;\">特别感谢 @qiuchengw的专业贡献</span>",
"<span style=\"font-weight: bold; color: #f00;\">Special thanks to @qiuchengw for his professional contribution</span>"
]
} }
] ]
+105 -106
View File
@@ -1,90 +1,5 @@
<template>
<div>
<div style="height: 600px;">
<GanttChart
:tasks="tasks"
:milestones="milestones"
:task-list-config="taskListConfig"
:toolbar-config="toolbarConfig"
:use-default-drawer="false"
:use-default-milestone-dialog="false"
:locale-messages="customMessages"
:allow-drag-and-resize="true"
@add-task="showAddTaskDrawer = true"
@add-milestone="showAddMilestoneDialog = true"
@task-double-click="onTaskDblclick"
@task-click="onTaskClick"
@milestone-double-click="onMilestoneDblclick"
>
</GanttChart>
</div>
<!-- 自定义添加任务按钮 -->
<div>
<button class="btn btn-primary" @click="showAddTaskDrawer = true">添加任务</button>
<button class="btn btn-primary" @click="showAddMilestoneDialog = true">添加里程碑</button>
</div>
<!-- 自定义抽屉组件 (原生HTML替代 el-drawer) -->
<div v-if="showAddTaskDrawer" class="drawer-overlay" @click="showAddTaskDrawer = false">
<div class="drawer-container" @click.stop>
<div class="drawer-header">
<h3>自定义添加任务组件</h3>
<button class="close-btn" @click="showAddTaskDrawer = false">×</button>
</div>
<div class="drawer-body">
<div class="form-item">
<label>任务名称:</label>
<input v-model="newTask.name" type="text" placeholder="请输入任务名称" />
</div>
<div class="form-item">
<label>开始日期:</label>
<input v-model="newTask.startDate" type="date" />
</div>
<div class="form-item">
<label>结束日期:</label>
<input v-model="newTask.endDate" type="date" />
</div>
</div>
<div class="drawer-footer">
<button class="btn btn-primary" @click="addTask">确定</button>
<button class="btn btn-default" @click="showAddTaskDrawer = false">取消</button>
</div>
</div>
</div>
<!-- 自定义Dialog组件基于element plus -->
<el-dialog
title="自定义添加里程碑组件 - Element Plus"
v-model="showAddMilestoneDialog"
width="400px"
@close="newTask = { name: '', startDate: '', endDate: '' }"
>
<template #default>
<div class="form-item">
<label>任务名称:</label>
<el-input v-model="newTask.name" placeholder="请输入任务名称" />
</div>
<div class="form-item">
<label>日期:</label>
<el-date-picker v-model="newTask.startDate" type="date" value-format="YYYY-MM-DD" />
</div>
</template>
<template #footer>
<el-button @click="addMilestone">确定</el-button>
<el-button @click="showAddMilestoneDialog = false">取消</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue'
import { GanttChart } from 'jordium-gantt-vue3' import { GanttChart } from 'jordium-gantt-vue3'
import 'jordium-gantt-vue3/dist/assets/jordium-gantt-vue3.css' import 'jordium-gantt-vue3/dist/assets/jordium-gantt-vue3.css'
@@ -121,7 +36,7 @@ const tasks = ref([
department: '管理部', department: '管理部',
departmentCode: 'D001', departmentCode: 'D001',
type: 'task', type: 'task',
} },
]) ])
const milestones = ref([ const milestones = ref([
@@ -130,8 +45,8 @@ const milestones = ref([
name: '项目立项', name: '项目立项',
startDate: '2025-10-29', startDate: '2025-10-29',
type: 'milestone', type: 'milestone',
icon: 'diamond' icon: 'diamond',
} },
]) ])
const customMessages = { const customMessages = {
@@ -142,13 +57,13 @@ const customMessages = {
'en-US': { 'en-US': {
department: 'Department', department: 'Department',
departmentCode: 'Department Code', departmentCode: 'Department Code',
} },
} }
// const tasks = ref([]) // const tasks = ref([])
// const milestones = ref([]) // const milestones = ref([])
const showAddTaskDrawer = ref(false); const showAddTaskDrawer = ref(false)
const showAddMilestoneDialog = ref(false); const showAddMilestoneDialog = ref(false)
// //
const availableColumns = ref<TaskListColumnConfig[]>([ const availableColumns = ref<TaskListColumnConfig[]>([
@@ -164,7 +79,7 @@ const taskListConfig = {
defaultWidth: '50%', // 50% defaultWidth: '50%', // 50%
minWidth: '300px', // 300px280px minWidth: '300px', // 300px280px
maxWidth: '1200px', // 1200px1160px maxWidth: '1200px', // 1200px1160px
columns: availableColumns.value columns: availableColumns.value,
} }
// toolbar // toolbar
@@ -179,18 +94,17 @@ const toolbarConfig: ToolbarConfig = {
showFullscreen: true, // showFullscreen: true, //
showTimeScale: true, // showTimeScale: true, //
timeScaleDimensions: [ // timeScaleDimensions: [ //
'hour', 'day', 'week', 'month', 'quarter', 'year' 'hour', 'day', 'week', 'month', 'quarter', 'year',
], ],
defaultTimeScale: 'week', // defaultTimeScale: 'week', //
showExpandCollapse: false // / showExpandCollapse: false, // /
} }
const newTask = ref({ const newTask = ref({
name: '', name: '',
startDate: '', startDate: '',
endDate: '' endDate: '',
}); })
const addTask = () => { const addTask = () => {
tasks.value.push({ tasks.value.push({
@@ -202,10 +116,10 @@ const addTask = () => {
department: '未分配', department: '未分配',
departmentCode: 'D000', departmentCode: 'D000',
type: 'task', type: 'task',
}); })
newTask.value = { name: '', startDate: '', endDate: '' }; newTask.value = { name: '', startDate: '', endDate: '' }
showAddTaskDrawer.value = false; showAddTaskDrawer.value = false
}; }
const addMilestone = () => { const addMilestone = () => {
milestones.value.push({ milestones.value.push({
@@ -213,11 +127,11 @@ const addMilestone = () => {
name: newTask.value.name, name: newTask.value.name,
startDate: newTask.value.startDate, startDate: newTask.value.startDate,
type: 'milestone', type: 'milestone',
icon: 'diamond' icon: 'diamond',
}); })
console.log('milestones: ', milestones.value) console.log('milestones: ', milestones.value)
newTask.value = { name: '', startDate: '', endDate: '' }; newTask.value = { name: '', startDate: '', endDate: '' }
showAddMilestoneDialog.value = false; showAddMilestoneDialog.value = false
} }
const onTaskDblclick = (task: any) => { const onTaskDblclick = (task: any) => {
@@ -231,6 +145,91 @@ const onMilestoneDblclick = (milestone: any) => {
} }
</script> </script>
<template>
<div>
<div style="height: 600px;">
<GanttChart
:tasks="tasks"
:milestones="milestones"
:task-list-config="taskListConfig"
:toolbar-config="toolbarConfig"
:use-default-drawer="false"
:use-default-milestone-dialog="false"
:locale-messages="customMessages"
:allow-drag-and-resize="true"
@add-task="showAddTaskDrawer = true"
@add-milestone="showAddMilestoneDialog = true"
@task-double-click="onTaskDblclick"
@task-click="onTaskClick"
@milestone-double-click="onMilestoneDblclick"
>
</GanttChart>
</div>
<!-- 自定义添加任务按钮 -->
<div>
<button class="btn btn-primary" @click="showAddTaskDrawer = true">添加任务</button>
<button class="btn btn-primary" @click="showAddMilestoneDialog = true">添加里程碑</button>
</div>
<!-- 自定义抽屉组件 (原生HTML替代 el-drawer) -->
<div v-if="showAddTaskDrawer" class="drawer-overlay" @click="showAddTaskDrawer = false">
<div class="drawer-container" @click.stop>
<div class="drawer-header">
<h3>自定义添加任务组件</h3>
<button class="close-btn" @click="showAddTaskDrawer = false">×</button>
</div>
<div class="drawer-body">
<div class="form-item">
<label>任务名称:</label>
<input v-model="newTask.name" type="text" placeholder="请输入任务名称" />
</div>
<div class="form-item">
<label>开始日期:</label>
<input v-model="newTask.startDate" type="date" />
</div>
<div class="form-item">
<label>结束日期:</label>
<input v-model="newTask.endDate" type="date" />
</div>
</div>
<div class="drawer-footer">
<button class="btn btn-primary" @click="addTask">确定</button>
<button class="btn btn-default" @click="showAddTaskDrawer = false">取消</button>
</div>
</div>
</div>
<!-- 自定义Dialog组件基于element plus -->
<el-dialog
v-model="showAddMilestoneDialog"
title="自定义添加里程碑组件 - Element Plus"
width="400px"
@close="newTask = { name: '', startDate: '', endDate: '' }"
>
<template #default>
<div class="form-item">
<label>任务名称:</label>
<el-input v-model="newTask.name" placeholder="请输入任务名称" />
</div>
<div class="form-item">
<label>日期:</label>
<el-date-picker v-model="newTask.startDate" type="date" value-format="YYYY-MM-DD" />
</div>
</template>
<template #footer>
<el-button @click="addMilestone">确定</el-button>
<el-button @click="showAddMilestoneDialog = false">取消</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped> <style scoped>
/* 抽屉遮罩层 */ /* 抽屉遮罩层 */
.drawer-overlay { .drawer-overlay {
+7 -7
View File
@@ -1,8 +1,8 @@
import { createApp } from 'vue'; import { createApp } from 'vue'
import App from './App.vue'; import App from './App.vue'
import ElementPlus from 'element-plus'; import ElementPlus from 'element-plus'
import 'element-plus/theme-chalk/index.css'; import 'element-plus/theme-chalk/index.css'
const app = createApp(App); const app = createApp(App)
app.use(ElementPlus); app.use(ElementPlus)
app.mount('#app'); app.mount('#app')
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jordium-gantt-vue3", "name": "jordium-gantt-vue3",
"version": "1.4.2-patch.2", "version": "1.4.2-patch.3",
"type": "module", "type": "module",
"main": "dist/jordium-gantt-vue3.cjs.js", "main": "dist/jordium-gantt-vue3.cjs.js",
"module": "dist/jordium-gantt-vue3.es.js", "module": "dist/jordium-gantt-vue3.es.js",
+56 -58
View File
@@ -136,45 +136,64 @@ interface Props {
const ganttRootRef = ref<HTMLElement | null>(null) const ganttRootRef = ref<HTMLElement | null>(null)
const ganttContainerWidth = ref(1920) // 使 const ganttContainerWidth = ref(1920) // 使
// ResizeObserver
let ganttRootResizeObserver: ResizeObserver | null = null
// //
const updateContainerWidth = (newWidth: number) => { //
if (newWidth !== ganttContainerWidth.value) { const throttle = <T extends (...args: unknown[]) => unknown>(func: T, delay: number): T => {
ganttContainerWidth.value = newWidth let lastCall = 0
// TaskList let timeoutId: number | null = null
ganttPanelLeftMinWidth.value = getTaskListMinWidth()
taskListBodyWidth.value = getTaskListMaxWidth()
taskListBodyProposedWidth.value = getTaskListMaxWidth()
taskListBodyWidthLimit.value = getTaskListMaxWidth()
// return ((...args: Parameters<T>) => {
const adjustedWidth = checkWidthLimits(leftPanelWidth.value) const now = Date.now()
if (adjustedWidth !== leftPanelWidth.value) { const remaining = delay - (now - lastCall)
leftPanelWidth.value = adjustedWidth
if (timeoutId) {
clearTimeout(timeoutId)
}
if (remaining <= 0) {
lastCall = now
func(...args)
} else {
timeoutId = window.setTimeout(() => {
lastCall = Date.now()
func(...args)
timeoutId = null
}, remaining)
}
}) as T
}
const updateContainerWidth = () => {
if (ganttRootRef.value) {
const newWidth = ganttRootRef.value.clientWidth
if (newWidth !== ganttContainerWidth.value) {
ganttContainerWidth.value = newWidth
// TaskList
ganttPanelLeftMinWidth.value = getTaskListMinWidth()
taskListBodyWidth.value = getTaskListMaxWidth()
taskListBodyProposedWidth.value = getTaskListMaxWidth()
taskListBodyWidthLimit.value = getTaskListMaxWidth()
//
const adjustedWidth = checkWidthLimits(leftPanelWidth.value)
if (adjustedWidth !== leftPanelWidth.value) {
leftPanelWidth.value = adjustedWidth
}
} }
} }
} }
// updateContainerWidth
const throttledUpdateContainerWidth = throttle(updateContainerWidth, 100)
onMounted(() => { onMounted(() => {
if (ganttRootRef.value) { updateContainerWidth()
// 使 ResizeObserver clientWidth // 使
ganttRootResizeObserver = new ResizeObserver((entries) => { window.addEventListener('resize', throttledUpdateContainerWidth)
for (const entry of entries) {
// 使 contentRect.width
updateContainerWidth(entry.contentRect.width)
}
})
ganttRootResizeObserver.observe(ganttRootRef.value)
}
}) })
onUnmounted(() => { onUnmounted(() => {
if (ganttRootResizeObserver) { window.removeEventListener('resize', throttledUpdateContainerWidth)
ganttRootResizeObserver.disconnect()
ganttRootResizeObserver = null
}
}) })
// TaskListtaskListConfig // TaskListtaskListConfig
@@ -341,11 +360,9 @@ function onMouseDown(e: MouseEvent) {
document.addEventListener('wheel', blockAllEvents, { capture: true, passive: false }) document.addEventListener('wheel', blockAllEvents, { capture: true, passive: false })
document.addEventListener('contextmenu', blockAllEvents, { capture: true }) document.addEventListener('contextmenu', blockAllEvents, { capture: true })
// 使requestAnimationFrame
let rafId: number | null = null
function onMouseMove(ev: MouseEvent) { function onMouseMove(ev: MouseEvent) {
if (!dragging.value) return if (!dragging.value) return
// //
ev.preventDefault() ev.preventDefault()
ev.stopPropagation() ev.stopPropagation()
@@ -353,29 +370,15 @@ function onMouseDown(e: MouseEvent) {
const delta = ev.clientX - startX const delta = ev.clientX - startX
const proposedWidth = startWidth + delta const proposedWidth = startWidth + delta
// 使
const finalWidth = checkWidthLimits(proposedWidth) const finalWidth = checkWidthLimits(proposedWidth)
leftPanelWidth.value = finalWidth
//
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
// 60fps
rafId = requestAnimationFrame(() => {
leftPanelWidth.value = finalWidth
rafId = null
})
} }
function onMouseUp() { function onMouseUp() {
dragging.value = false dragging.value = false
//
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
// //
document.removeEventListener('mousedown', blockAllEvents, { capture: true }) document.removeEventListener('mousedown', blockAllEvents, { capture: true })
document.removeEventListener('click', blockAllEvents, { capture: true }) document.removeEventListener('click', blockAllEvents, { capture: true })
@@ -506,8 +509,10 @@ onMounted(() => {
// timeline // timeline
const rightPanel = document.querySelector('.gantt-panel-right') const rightPanel = document.querySelector('.gantt-panel-right')
if (rightPanel) { if (rightPanel) {
// 使 ResizeObserver clientWidth //
// ResizeObserver timelineContainerWidth.value = rightPanel.clientWidth
// 使 ResizeObserver
resizeObserver = new ResizeObserver(entries => { resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) { for (const entry of entries) {
timelineContainerWidth.value = entry.contentRect.width timelineContainerWidth.value = entry.contentRect.width
@@ -2186,7 +2191,6 @@ function handleMilestoneDialogDelete(milestoneId: number) {
<div <div
v-if="isTaskListVisible" v-if="isTaskListVisible"
class="gantt-panel gantt-panel-left" class="gantt-panel gantt-panel-left"
:class="{ dragging: dragging }"
:style="{ width: leftPanelWidth + 'px' }" :style="{ width: leftPanelWidth + 'px' }"
> >
<TaskList <TaskList
@@ -2342,12 +2346,6 @@ function handleMilestoneDialogDelete(milestoneId: number) {
.gantt-panel-left { .gantt-panel-left {
/* width 由js控制 */ /* width 由js控制 */
min-width: 320px; min-width: 320px;
/* ⚠️ 拖拽时禁用transition,提升响应速度 */
transition: none;
}
.gantt-panel-left:not(.dragging) {
/* 非拖拽时保留平滑过渡效果(如toggle时) */
transition: width 0.1s; transition: width 0.1s;
} }
+446
View File
@@ -0,0 +1,446 @@
<script setup lang="ts">
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import type { Task } from '../models/classes/Task'
import { getPredecessorIds } from '../utils/predecessorUtils'
// TaskBar
interface TaskBarPosition {
left: number
top: number
width: number
height: number
}
// 线
interface VerticalLine {
left: number
label?: string
}
// Props
interface Props {
tasks: Task[]
taskBarPositions: Record<number, TaskBarPosition>
width: number
height: number
offsetLeft?: number // Canvas
highlightedTaskId: number | null
highlightedTaskIds: Set<number>
hoveredTaskId: number | null
// 线
verticalLines?: VerticalLine[]
showVerticalLines?: boolean
}
const props = withDefaults(defineProps<Props>(), {
verticalLines: () => [],
showVerticalLines: true,
offsetLeft: 0,
})
// Canvas
const canvasRef = ref<HTMLCanvasElement | null>(null)
// requestAnimationFrame
let rafId: number | null = null
let pendingRedraw = false
let themeObserver: MutationObserver | null = null
// 线
const isDarkTheme = ref(document.documentElement.getAttribute('data-theme') === 'dark')
//
const updateTheme = () => {
isDarkTheme.value = document.documentElement.getAttribute('data-theme') === 'dark'
scheduleRedraw()
}
/**
* 绘制关系线到 Canvas
* 性能优势相比 SVG 提升 18 倍渲染性能
*/
const drawLinks = () => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d', { alpha: true })
if (!ctx) {
// eslint-disable-next-line no-console
console.error('❌ Canvas context 获取失败,可能是尺寸超限')
return
}
// Retina
const dpr = window.devicePixelRatio || 1
const displayWidth = props.width
const displayHeight = props.height
// canvas
const pixelWidth = displayWidth * dpr
const pixelHeight = displayHeight * dpr
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth
canvas.height = pixelHeight
ctx.scale(dpr, dpr)
}
//
ctx.clearRect(0, 0, displayWidth, displayHeight)
// 线线
if (props.showVerticalLines && props.verticalLines && props.verticalLines.length > 0) {
ctx.save()
// 使 #409eff #66b1ff
const lineColor = isDarkTheme.value ? '#66b1ff' : '#409eff'
ctx.strokeStyle = lineColor
ctx.lineWidth = 1
// 线 stroke()
// Canvas
ctx.beginPath()
for (const line of props.verticalLines) {
const localX = line.left - props.offsetLeft
// Canvas 线
if (localX >= 0 && localX <= displayWidth) {
ctx.moveTo(localX, 0)
ctx.lineTo(localX, displayHeight)
}
}
ctx.stroke()
ctx.restore()
}
// ID
const currentTaskIds = new Set<number>()
for (const task of props.tasks) {
currentTaskIds.add(task.id)
}
//
const isHighlightMode = props.highlightedTaskId !== null
// Canvas
const canvasStartX = props.offsetLeft
const canvasEndX = props.offsetLeft + displayWidth
// 线
interface LineData {
x1: number
y1: number
x2: number
y2: number
c1x: number
c1y: number
c2x: number
c2y: number
arrowAngle: number
}
// 线
const highlightedLines: LineData[] = []
const hoveredLines: LineData[] = []
const normalLines: LineData[] = []
const fadedLines: LineData[] = [] // 线
for (const task of props.tasks) {
if (!task.predecessor || !props.taskBarPositions[task.id]) continue
const predecessorIds = getPredecessorIds(task.predecessor)
for (const predecessorId of predecessorIds) {
const fromBar = props.taskBarPositions[predecessorId]
const toBar = props.taskBarPositions[task.id]
if (!fromBar || !toBar || !currentTaskIds.has(predecessorId)) {
continue
}
// Canvas 线
// Canvas
const fromX = fromBar.left + fromBar.width
const toX = toBar.left
const lineMinX = Math.min(fromX, toX)
const lineMaxX = Math.max(fromX, toX)
if (lineMaxX < canvasStartX || lineMinX > canvasEndX) {
continue // Canvas
}
//
const fromIsPrimary = props.highlightedTaskId === predecessorId
const toIsPrimary = props.highlightedTaskId === task.id
const fromIsHighlighted = props.highlightedTaskIds.has(predecessorId)
const toIsHighlighted = props.highlightedTaskIds.has(task.id)
const isLineHighlighted = fromIsHighlighted && toIsHighlighted
// hover
const fromIsHovered = props.hoveredTaskId === predecessorId
const toIsHovered = props.hoveredTaskId === task.id
const isLineHovered = fromIsHovered || toIsHovered
// Y
const fromYOffset = fromIsPrimary ? -8 : fromIsHighlighted ? -5 : 0
const toYOffset = toIsPrimary ? -8 : toIsHighlighted ? -5 : 0
//
const globalX1 = fromBar.left + fromBar.width
const globalY1 = fromBar.top + fromBar.height / 2 + fromYOffset
const globalX2 = toBar.left
const globalY2 = toBar.top + toBar.height / 2 + toYOffset
// Canvas
const x1 = globalX1 - props.offsetLeft
const y1 = globalY1
const x2 = globalX2 - props.offsetLeft
const y2 = globalY2
const c1x = x1 + 40
const c1y = y1
const c2x = x2 - 40
const c2y = y2
//
const arrowAngle = Math.atan2(y2 - c2y, x2 - c2x)
const lineData: LineData = { x1, y1, x2, y2, c1x, c1y, c2x, c2y, arrowAngle }
//
if (isLineHighlighted) {
highlightedLines.push(lineData)
} else if (isLineHovered) {
hoveredLines.push(lineData)
} else if (isHighlightMode) {
fadedLines.push(lineData)
} else {
normalLines.push(lineData)
}
}
}
// 线线
ctx.setLineDash([6, 4])
// 线
if (highlightedLines.length > 0) {
ctx.strokeStyle = '#409eff'
ctx.fillStyle = '#409eff'
ctx.lineWidth = 4
ctx.globalAlpha = 1
ctx.beginPath()
for (const line of highlightedLines) {
ctx.moveTo(line.x1, line.y1)
ctx.bezierCurveTo(line.c1x, line.c1y, line.c2x, line.c2y, line.x2, line.y2)
}
ctx.stroke()
//
for (const line of highlightedLines) {
drawArrowOptimized(ctx, line.x2, line.y2, line.arrowAngle)
}
}
// 线
if (hoveredLines.length > 0) {
ctx.strokeStyle = '#67c23a'
ctx.fillStyle = '#67c23a'
ctx.lineWidth = 3
ctx.globalAlpha = 1
ctx.beginPath()
for (const line of hoveredLines) {
ctx.moveTo(line.x1, line.y1)
ctx.bezierCurveTo(line.c1x, line.c1y, line.c2x, line.c2y, line.x2, line.y2)
}
ctx.stroke()
//
for (const line of hoveredLines) {
drawArrowOptimized(ctx, line.x2, line.y2, line.arrowAngle)
}
}
// 线
if (normalLines.length > 0) {
ctx.strokeStyle = '#c0c4cc'
ctx.fillStyle = '#c0c4cc'
ctx.lineWidth = 2
ctx.globalAlpha = 1
ctx.beginPath()
for (const line of normalLines) {
ctx.moveTo(line.x1, line.y1)
ctx.bezierCurveTo(line.c1x, line.c1y, line.c2x, line.c2y, line.x2, line.y2)
}
ctx.stroke()
//
for (const line of normalLines) {
drawArrowOptimized(ctx, line.x2, line.y2, line.arrowAngle)
}
}
// 线线
if (fadedLines.length > 0) {
ctx.strokeStyle = '#c0c4cc'
ctx.fillStyle = '#c0c4cc'
ctx.lineWidth = 2
ctx.globalAlpha = 0.2
ctx.beginPath()
for (const line of fadedLines) {
ctx.moveTo(line.x1, line.y1)
ctx.bezierCurveTo(line.c1x, line.c1y, line.c2x, line.c2y, line.x2, line.y2)
}
ctx.stroke()
//
for (const line of fadedLines) {
drawArrowOptimized(ctx, line.x2, line.y2, line.arrowAngle)
}
//
ctx.globalAlpha = 1
}
}
/**
* 优化版箭头绘制减少参数传递复用已设置的 fillStyle
*/
const drawArrowOptimized = (
ctx: CanvasRenderingContext2D,
x2: number,
y2: number,
angle: number,
) => {
const arrowLength = 8
const arrowWidth = 4
ctx.beginPath()
// fillStyle
ctx.moveTo(x2, y2)
ctx.lineTo(
x2 - arrowLength * Math.cos(angle) - arrowWidth * Math.sin(angle),
y2 - arrowLength * Math.sin(angle) + arrowWidth * Math.cos(angle),
)
ctx.lineTo(
x2 - arrowLength * Math.cos(angle) + arrowWidth * Math.sin(angle),
y2 - arrowLength * Math.sin(angle) - arrowWidth * Math.cos(angle),
)
ctx.closePath()
ctx.fill()
}
/**
* 使用 requestAnimationFrame 优化的重绘调度器
* 合并多个连续的重绘请求为单次绘制
*/
const scheduleRedraw = () => {
if (pendingRedraw) {
//
return
}
pendingRedraw = true
// RAF
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
//
rafId = requestAnimationFrame(() => {
pendingRedraw = false
rafId = null
drawLinks()
})
}
// Canvas
watch(
[
() => props.taskBarPositions,
() => props.tasks,
() => props.highlightedTaskId,
() => props.highlightedTaskIds,
() => props.hoveredTaskId,
() => props.width,
() => props.height,
() => props.verticalLines,
() => props.showVerticalLines,
() => props.offsetLeft, //
],
() => {
// 使 RAF
scheduleRedraw()
},
{ deep: false }, // shallowRef deep
)
//
onMounted(() => {
//
themeObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.attributeName === 'data-theme') {
updateTheme()
break
}
}
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
})
nextTick(() => {
drawLinks()
})
})
//
onUnmounted(() => {
//
if (themeObserver) {
themeObserver.disconnect()
themeObserver = null
}
// RAF
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
})
//
defineExpose({
redraw: drawLinks,
})
</script>
<template>
<canvas
ref="canvasRef"
class="gantt-links-canvas"
:style="{
position: 'absolute',
left: 0,
top: 0,
width: `${width}px`,
height: `${height}px`,
transform: `translateX(${offsetLeft}px)`,
zIndex: highlightedTaskId !== null ? 1001 : 25,
pointerEvents: 'none',
}"
/>
</template>
<style scoped>
.gantt-links-canvas {
display: block;
background: transparent; /* 确保背景透明 */
opacity: 1; /* 确保不透明度为 100% */
}
</style>
File diff suppressed because it is too large Load Diff
+263
View File
@@ -0,0 +1,263 @@
/**
*
*/
let lastLogTime = 0
const LOG_THROTTLE = 100 // 最多每 100ms 输出一次日志
// FPS 监控相关
let fpsFrameCount = 0
let fpsLastTime = performance.now()
let fpsValues: number[] = []
let fpsMonitorEnabled = false
let fpsRafId: number | null = null
// 绘制性能统计
interface DrawStats {
totalDraws: number
totalDuration: number
minDuration: number
maxDuration: number
avgDuration: number
recentDraws: Array<{ timestamp: number; duration: number; details?: unknown }>
}
const drawStats: DrawStats = {
totalDraws: 0,
totalDuration: 0,
minDuration: Infinity,
maxDuration: 0,
avgDuration: 0,
recentDraws: [],
}
const MAX_RECENT_DRAWS = 100 // 保留最近 100 次绘制记录
export const perfMonitor = {
/**
*
*/
log(tag: string, data: unknown) {
const now = Date.now()
if (now - lastLogTime < LOG_THROTTLE) {
return
}
lastLogTime = now
// eslint-disable-next-line no-console
console.log(`[Perf] ${tag}:`, data)
},
/**
*
*/
measure<T>(tag: string, fn: () => T): T {
const start = performance.now()
const result = fn()
const end = performance.now()
const duration = end - start
if (duration > 5) {
// 只记录耗时超过 5ms 的操作
// eslint-disable-next-line no-console
console.log(`[Perf] ${tag}: ${duration.toFixed(2)}ms`)
}
return result
},
/**
* FPS
* @param intervalMs 1000ms
*/
startFpsMonitor(intervalMs = 1000) {
if (fpsMonitorEnabled) {
// eslint-disable-next-line no-console
console.warn('[Perf] FPS 监控已在运行中')
return
}
fpsMonitorEnabled = true
fpsFrameCount = 0
fpsLastTime = performance.now()
fpsValues = []
// eslint-disable-next-line no-console
console.log('[Perf] FPS 监控已启动')
const updateFps = () => {
if (!fpsMonitorEnabled) return
fpsFrameCount++
const currentTime = performance.now()
const elapsed = currentTime - fpsLastTime
if (elapsed >= intervalMs) {
const fps = Math.round((fpsFrameCount * 1000) / elapsed)
fpsValues.push(fps)
// 保留最近 60 个 FPS 值用于计算平均值
if (fpsValues.length > 60) {
fpsValues.shift()
}
const avgFps = Math.round(fpsValues.reduce((a, b) => a + b, 0) / fpsValues.length)
const minFps = Math.min(...fpsValues)
const maxFps = Math.max(...fpsValues)
// 根据 FPS 设置不同颜色
const color = fps >= 55 ? '#67c23a' : fps >= 30 ? '#e6a23c' : '#f56c6c'
// eslint-disable-next-line no-console
console.log(
`%c[Perf] FPS: ${fps} | 平均: ${avgFps} | 最小: ${minFps} | 最大: ${maxFps}`,
`color: ${color}; font-weight: bold;`,
)
fpsFrameCount = 0
fpsLastTime = currentTime
}
fpsRafId = requestAnimationFrame(updateFps)
}
fpsRafId = requestAnimationFrame(updateFps)
},
/**
* FPS
*/
stopFpsMonitor() {
if (!fpsMonitorEnabled) {
// eslint-disable-next-line no-console
console.warn('[Perf] FPS 监控未运行')
return
}
fpsMonitorEnabled = false
if (fpsRafId !== null) {
cancelAnimationFrame(fpsRafId)
fpsRafId = null
}
// 输出最终统计
if (fpsValues.length > 0) {
const avgFps = Math.round(fpsValues.reduce((a, b) => a + b, 0) / fpsValues.length)
const minFps = Math.min(...fpsValues)
const maxFps = Math.max(...fpsValues)
// eslint-disable-next-line no-console
console.log(
`%c[Perf] FPS 监控已停止 | 总采样: ${fpsValues.length} | 平均: ${avgFps} | 最小: ${minFps} | 最大: ${maxFps}`,
'color: #909399; font-weight: bold;',
)
}
fpsValues = []
},
/**
*
* @param duration
* @param details
*/
recordDraw(duration: number, details?: unknown) {
drawStats.totalDraws++
drawStats.totalDuration += duration
drawStats.minDuration = Math.min(drawStats.minDuration, duration)
drawStats.maxDuration = Math.max(drawStats.maxDuration, duration)
drawStats.avgDuration = drawStats.totalDuration / drawStats.totalDraws
// 记录最近的绘制
drawStats.recentDraws.push({
timestamp: Date.now(),
duration,
details,
})
// 保持数组大小在限制内
if (drawStats.recentDraws.length > MAX_RECENT_DRAWS) {
drawStats.recentDraws.shift()
}
},
/**
*
*/
getDrawStats() {
return {
...drawStats,
recentDraws: drawStats.recentDraws.slice(-10), // 只返回最近 10 次
}
},
/**
*
*/
printDrawStats() {
if (drawStats.totalDraws === 0) {
// eslint-disable-next-line no-console
console.log('[Perf] 暂无绘制数据')
return
}
const recent10 = drawStats.recentDraws.slice(-10)
const recent10Avg =
recent10.reduce((sum, item) => sum + item.duration, 0) / recent10.length
// eslint-disable-next-line no-console
console.log(
'%c[Perf] Canvas 绘制统计',
'color: #409eff; font-weight: bold; font-size: 14px;',
)
// eslint-disable-next-line no-console
console.table({
总绘制次数: drawStats.totalDraws,
: `${drawStats.avgDuration.toFixed(2)}ms`,
: `${drawStats.minDuration.toFixed(2)}ms`,
: `${drawStats.maxDuration.toFixed(2)}ms`,
10: `${recent10Avg.toFixed(2)}ms`,
})
},
/**
*
*/
resetDrawStats() {
drawStats.totalDraws = 0
drawStats.totalDuration = 0
drawStats.minDuration = Infinity
drawStats.maxDuration = 0
drawStats.avgDuration = 0
drawStats.recentDraws = []
// eslint-disable-next-line no-console
console.log('[Perf] 绘制统计已重置')
},
/**
* FPS +
* @param durationMs 10000ms (10)
*/
startPerformanceTest(durationMs = 10000) {
// eslint-disable-next-line no-console
console.log(
`%c[Perf] 性能测试开始(持续 ${durationMs / 1000} 秒)`,
'color: #409eff; font-weight: bold; font-size: 16px;',
)
this.resetDrawStats()
this.startFpsMonitor()
setTimeout(() => {
this.stopFpsMonitor()
this.printDrawStats()
// eslint-disable-next-line no-console
console.log(
'%c[Perf] 性能测试完成',
'color: #67c23a; font-weight: bold; font-size: 16px;',
)
}, durationMs)
},
}
+2
View File
@@ -27,6 +27,8 @@ export default defineConfig({
// 确保外部化处理那些你不想打包进库的依赖 // 确保外部化处理那些你不想打包进库的依赖
external: ['vue'], external: ['vue'],
output: { output: {
// 使用命名导出,避免默认导出警告
exports: 'named',
// 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量 // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
globals: { globals: {
vue: 'Vue', vue: 'Vue',