v1.4.3 - performance enhancement - virtualized table & scroll

This commit is contained in:
LINING-PC\lining
2025-11-28 23:54:14 +08:00
parent fc1799b4a3
commit bd92208027
7 changed files with 354 additions and 27 deletions

View File

@@ -5,6 +5,20 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.3] - 2025-11-28
### Added
- 大数据加载演示
- Large data loading demonstration
### Enhancement
- 性能优化TaskList和Timeline增加虚拟滚动支持大幅降低节点数和重绘开销
- 性能优化:大数据量任务渲染性能优化
- 性能优化:虚拟渲染优化
- Performance Optimization: TaskList and Timeline add virtualized scrolling support, greatly reducing node count and redraw overhead
- Performance Optimization: Large data volume task rendering performance optimization
- Performance Optimization: Virtualized rendering optimization
## [1.4.2-patch3] - 2025-11-13
### Added

View File

@@ -3,7 +3,8 @@ import { ref, computed, onMounted, nextTick } from 'vue'
import GanttChart from '../src/components/GanttChart.vue'
// import TaskDrawer from '../src/components/TaskDrawer.vue' // 移除
import MilestoneDialog from '../src/components/MilestoneDialog.vue'
import demoData from './data.json'
import normalData from './data.json'
import largeData from './data-large-1m.json'
import packageInfo from '../package.json'
// 导入主题变量
import '../src/styles/theme-variables.css'
@@ -22,6 +23,100 @@ const { t, formatTranslation } = useI18n()
const tasks = ref<Task[]>([])
const milestones = ref<Task[]>([])
const rawDataSources = [
{
key: 'normal',
fileName: 'data.json',
payload: normalData,
},
{
key: 'large',
fileName: 'data-large-1m.json',
payload: largeData,
},
] as const
type DataSourceKey = (typeof rawDataSources)[number]['key']
type RawDataSource = (typeof rawDataSources)[number]
const dataSourceOptions = computed(() => {
const dsTranslations = t.value.dataSourceSwitch?.sources ?? {}
return rawDataSources.map(source => {
const optionTexts = dsTranslations[source.key as keyof typeof dsTranslations] || {}
return {
...source,
label: optionTexts.label || source.fileName,
description: optionTexts.description || '',
badge: optionTexts.badge || source.fileName,
}
})
})
const currentDataSource = ref<DataSourceKey>('large')
const dataLoading = ref(false)
const cloneData = <T>(data: T): T => {
const globalClone = (globalThis as unknown as { structuredClone?: <K>(value: K) => K }).structuredClone
if (typeof globalClone === 'function') {
return globalClone(data)
}
return JSON.parse(JSON.stringify(data)) as T
}
const findRawDataSource = (key: DataSourceKey) => rawDataSources.find(source => source.key === key)
const applyDataSource = (source: RawDataSource) => {
const payload = source.payload as { tasks?: Task[]; milestones?: Task[] }
tasks.value = cloneData(payload.tasks ?? [])
milestones.value = cloneData(payload.milestones ?? [])
}
const getSourceTexts = (key: DataSourceKey) => {
const dsTranslations = t.value.dataSourceSwitch?.sources ?? {}
return dsTranslations[key as keyof typeof dsTranslations] || { label: key }
}
const switchDataSource = async (
key: DataSourceKey,
options: { silent?: boolean; force?: boolean } = {},
) => {
if (dataLoading.value) return
const source = findRawDataSource(key)
if (!source) return
const sourceTexts = getSourceTexts(key)
const displayName = sourceTexts.label || source.fileName
if (!options.force && currentDataSource.value === key && tasks.value.length > 0) {
showMessage(formatTranslation('dataSourceAlreadyLoaded', { name: displayName }), 'info', {
closable: true,
})
return
}
currentDataSource.value = key
dataLoading.value = true
try {
await nextTick()
applyDataSource(source)
if (!options.silent) {
showMessage(formatTranslation('dataSourceLoadSuccess', { name: displayName }), 'success', {
closable: false,
})
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('切换数据源失败', error)
showMessage(formatTranslation('dataSourceLoadFailed', { name: displayName }), 'error', {
closable: true,
})
} finally {
dataLoading.value = false
}
}
// MilestoneDialog状态管理
const showMilestoneDialog = ref(false)
const currentMilestone = ref<Task | null>(null)
@@ -110,6 +205,7 @@ const taskBarConfig = computed<TaskBarConfig>(() => ({
// 配置面板折叠状态
const isConfigPanelCollapsed = ref(false)
const isDataSourcePanelCollapsed = ref(false)
// TaskList 配置区域折叠状态(默认收起)
const isTaskListConfigCollapsed = ref(true)
@@ -122,6 +218,10 @@ const toggleConfigPanel = () => {
isConfigPanelCollapsed.value = !isConfigPanelCollapsed.value
}
const toggleDataSourcePanel = () => {
isDataSourcePanelCollapsed.value = !isDataSourcePanelCollapsed.value
}
// 切换 TaskList 配置区域
const toggleTaskListConfig = () => {
isTaskListConfigCollapsed.value = !isTaskListConfigCollapsed.value
@@ -377,8 +477,7 @@ function handleMilestoneDragEnd(newMilestone) {
}
onMounted(() => {
tasks.value = demoData.tasks as Task[]
milestones.value = demoData.milestones as Task[]
switchDataSource(currentDataSource.value, { silent: true, force: true })
})
// 清理被删除任务的predecessor依赖关系
@@ -480,6 +579,64 @@ function taskDebug(item: any) {
</h1>
<VersionHistoryDrawer :visible="showVersionDrawer" @close="showVersionDrawer = false" />
<div class="data-source-panel" :class="{ collapsed: isDataSourcePanelCollapsed }">
<div class="data-source-header" @click="toggleDataSourcePanel">
<h3 class="config-title">
<svg
class="data-source-icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 4h16a2 2 0 0 1 2 2v2H2V6a2 2 0 0 1 2-2zm16 6H4v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10zm-5 4h4v4h-4v-4z"
fill="currentColor"
/>
</svg>
{{ t.dataSourceSwitch?.title }} - {{ t.dataSourceSwitch?.subtitle }}
</h3>
<button class="collapse-button" :class="{ collapsed: isDataSourcePanelCollapsed }">
<svg
class="collapse-icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
:d="isDataSourcePanelCollapsed ? 'M7 14l5-5 5 5' : 'M7 10l5 5 5-5'"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<transition name="config-content">
<div v-show="!isDataSourcePanelCollapsed" class="data-source-content">
<div class="data-source-buttons">
<button
v-for="source in dataSourceOptions"
:key="source.key"
class="data-source-button"
:class="{ active: currentDataSource === source.key }"
:disabled="dataLoading && currentDataSource === source.key"
@click="switchDataSource(source.key)"
>
<div class="ds-label-row">
<span class="ds-label">{{ source.label }}</span>
<span class="ds-file">{{ source.badge }}</span>
</div>
<div class="ds-desc">{{ source.description }}</div>
</button>
<span v-if="dataLoading" class="data-loading-hint">
{{ t.dataSourceSwitch?.loading }}
</span>
</div>
</div>
</transition>
</div>
<!-- 配置说明面板 - 可折叠 -->
<div class="config-panel" :class="{ collapsed: isConfigPanelCollapsed }">
<div class="config-header" @click="toggleConfigPanel">
@@ -505,7 +662,7 @@ function taskDebug(item: any) {
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 10l5 5 5-5"
:d="isConfigPanelCollapsed ? 'M7 14l5-5 5 5' : 'M7 10l5 5 5-5'"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
@@ -917,6 +1074,106 @@ function taskDebug(item: any) {
flex-direction: column;
}
.data-source-panel {
background: var(--gantt-bg-primary, #ffffff);
border: 1px solid var(--gantt-border-color, #e4e7ed);
border-radius: 8px;
padding: 16px;
margin-bottom: 10px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 16px;
}
.data-source-panel.collapsed {
padding-bottom: 12px;
}
.data-source-header {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-size: 14px;
justify-content: space-between;
}
.data-source-icon {
width: 16px;
height: 16px;
color: var(--gantt-primary-color, #409eff);
}
.data-source-content {
padding-top: 4px;
}
.data-source-buttons {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.data-source-button {
flex: 1 1 220px;
min-width: 200px;
border: 1px solid var(--gantt-border-color, #e4e7ed);
border-radius: 8px;
padding: 12px 16px;
background: var(--gantt-bg-secondary, #f9fafc);
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
}
.data-source-button:hover {
border-color: var(--gantt-primary-color, #409eff);
box-shadow: 0 2px 10px rgba(64, 158, 255, 0.15);
}
.data-source-button.active {
border-color: var(--gantt-primary-color, #409eff);
background: rgba(64, 158, 255, 0.08);
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.25);
}
.data-source-button:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.ds-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.ds-label {
font-size: 14px;
font-weight: 600;
color: var(--gantt-text-primary, #333);
}
.ds-file {
font-size: 12px;
color: var(--gantt-text-muted, #909399);
}
.ds-desc {
font-size: 13px;
color: var(--gantt-text-secondary, #666);
line-height: 1.4;
}
.data-loading-hint {
font-size: 13px;
color: var(--gantt-primary-color, #409eff);
font-weight: 500;
}
/* TaskList列配置面板样式 - 可折叠 */
.config-panel {
background: var(--gantt-bg-primary, #ffffff);
@@ -964,8 +1221,6 @@ function taskDebug(item: any) {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 6px;
@@ -974,6 +1229,10 @@ function taskDebug(item: any) {
color: var(--gantt-text-secondary, #666);
}
.collapse-button.collapsed {
transform: rotate(90deg);
}
.collapse-button:hover {
background-color: var(--gantt-hover-bg, #e8f4fd);
color: var(--gantt-primary-color, #409eff);
@@ -985,10 +1244,6 @@ function taskDebug(item: any) {
transition: transform 0.3s ease;
}
.collapse-button.collapsed .collapse-icon {
transform: rotate(-90deg);
}
/* 过渡动画 */
.config-content-enter-active,
.config-content-leave-active {
@@ -1479,6 +1734,36 @@ function taskDebug(item: any) {
border-color: var(--gantt-border-color, #4a5568);
}
:global(html[data-theme='dark']) .data-source-panel {
background: var(--gantt-bg-primary, #2d3748);
border-color: var(--gantt-border-color, #4a5568);
box-shadow: none;
}
:global(html[data-theme='dark']) .data-source-sub {
color: var(--gantt-text-secondary, #a0aec0);
}
:global(html[data-theme='dark']) .data-source-button {
background: var(--gantt-bg-secondary, #1a202c);
border-color: var(--gantt-border-color, #4a5568);
}
:global(html[data-theme='dark']) .data-source-button.active {
background: rgba(64, 158, 255, 0.18);
border-color: var(--gantt-primary-color, #66b3ff);
}
:global(html[data-theme='dark']) .ds-label,
:global(html[data-theme='dark']) .ds-desc,
:global(html[data-theme='dark']) .ds-file {
color: var(--gantt-text-primary, #e2e8f0);
}
:global(html[data-theme='dark']) .ds-file {
color: var(--gantt-text-muted, #a0aec0);
}
:global(html[data-theme='dark']) .config-title {
color: var(--gantt-text-primary, #e2e8f0);
}

1
demo/data-large-1m.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -306,5 +306,19 @@
"<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>"
]
},
{
"version": "1.4.3",
"date": "2025-11-28",
"notes": [
"性能优化TaskList和Timeline增加虚拟滚动支持大幅降低节点数和重绘开销",
"性能优化:大数据量任务渲染性能优化",
"性能优化:虚拟渲染优化",
"新增:大数据加载演示",
"Performance Optimization: TaskList and Timeline add virtualized scrolling support, greatly reducing node count and redraw overhead",
"Performance Optimization: Large data volume task rendering performance optimization",
"Performance Optimization: Virtualized rendering optimization",
"Added: Large data loading demonstration"
]
}
]

View File

@@ -1,6 +1,6 @@
{
"name": "jordium-gantt-vue3",
"version": "1.4.2-patch.3",
"version": "1.4.3",
"type": "module",
"main": "dist/jordium-gantt-vue3.cjs.js",
"module": "dist/jordium-gantt-vue3.es.js",

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch, useSlots } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from '../composables/useI18n'
import { formatPredecessorDisplay } from '../utils/predecessorUtils'
import type { Task } from '../models/classes/Task'
@@ -35,7 +36,7 @@ interface Props {
hoveredTaskId?: number | null
onHover?: (taskId: number | null) => void
columns: TaskListColumnConfig[]
getColumnWidthStyle?: (column: { width?: number | string }) => object
getColumnWidthStyle?: (column: { width?: number | string }) => StyleValue
disableChildrenRender?: boolean
}
const props = defineProps<Props>()
@@ -412,7 +413,7 @@ onUnmounted(() => {
:key="column.key"
class="col"
:class="column.cssClass || `col-${column.key}`"
:style="getColumnWidthStyle ? getColumnWidthStyle(column) : {}"
:style="getColumnWidthStyle ? getColumnWidthStyle(column) : undefined"
>
<!-- 里程碑分组显示空列 -->
<template v-if="isMilestoneGroup">

View File

@@ -157,15 +157,20 @@ const dayWidth = computed(() => {
}
})
// 获取任务数据的日期范围(用于月度视图时间轴范围计算)
const getTasksDateRange = () => {
type TaskDateRange = { minDate: Date; maxDate: Date } | null
let cachedTaskDateRange: TaskDateRange = null
const invalidateTaskDateRangeCache = () => {
cachedTaskDateRange = null
}
const computeTasksDateRange = (): TaskDateRange => {
if (!tasks.value || tasks.value.length === 0) {
return null
}
const dates: Date[] = []
// 收集所有任务的开始和结束日期
const collectDatesFromTask = (task: Task) => {
if (task.startDate) {
dates.push(new Date(task.startDate))
@@ -174,7 +179,6 @@ const getTasksDateRange = () => {
dates.push(new Date(task.endDate))
}
// 递归处理子任务 - 使用 for 循环代替 forEach
if (task.children && task.children.length > 0) {
for (const child of task.children) {
collectDatesFromTask(child)
@@ -182,7 +186,6 @@ const getTasksDateRange = () => {
}
}
// 使用 for...of 循环代替 forEach
for (const task of tasks.value) {
collectDatesFromTask(task)
}
@@ -191,7 +194,6 @@ const getTasksDateRange = () => {
return null
}
// 过滤有效日期并直接获取最小/最大时间戳
let minTime = Infinity
let maxTime = -Infinity
for (const date of dates) {
@@ -206,10 +208,20 @@ const getTasksDateRange = () => {
return null
}
const minDate = new Date(minTime)
const maxDate = new Date(maxTime)
return {
minDate: new Date(minTime),
maxDate: new Date(maxTime),
}
}
return { minDate, maxDate }
// 获取任务数据的日期范围(用于月度/年度视图时间轴范围计算)
const getTasksDateRange = () => {
if (cachedTaskDateRange) {
return cachedTaskDateRange
}
cachedTaskDateRange = computeTasksDateRange()
return cachedTaskDateRange
}
// 获取小时视图的时间范围
@@ -753,11 +765,6 @@ const debouncedUpdateCanvasPosition = debounce(() => {
updateSvgSize() // 重新计算 Canvas 位置和尺寸
}, 200)
// 防抖更新纵向滚动位置
const debouncedUpdateVerticalScroll = debounce((scrollTop: number) => {
timelineBodyScrollTop.value = scrollTop
}, 16) // 使用16ms约等于60fps保证流畅性
// 缓存时间轴数据的函数
const getCachedTimelineData = (): unknown => {
const scale = currentTimeScale.value
@@ -2765,6 +2772,7 @@ const convertTaskToMilestone = (task: Task): Milestone => {
watch(
() => tasks.value.length,
() => {
invalidateTaskDateRangeCache()
computeAllMilestonesPositions()
},
{ immediate: true },
@@ -2813,6 +2821,9 @@ const updateTimelineRange = () => {
watch(
() => tasks.value?.length,
(newLength, oldLength) => {
if (newLength !== oldLength) {
invalidateTaskDateRangeCache()
}
// 当任务从无到有时,重新计算时间范围
if (oldLength === 0 && newLength > 0) {
debouncedUpdateTimelineRange(50)
@@ -2863,6 +2874,7 @@ watch([timelineData, timelineContainerWidth], () => {
watch(
() => tasks.value,
newTasks => {
invalidateTaskDateRangeCache()
// 优化:使用 for 循环直接构建 Set避免 map 创建临时数组
const currentTaskIds = new Set<number>()
for (const task of newTasks) {