Merge remote-tracking branch 'src'

This commit is contained in:
qiuchengw
2025-12-01 16:36:13 +08:00
13 changed files with 795 additions and 110 deletions

View File

@@ -5,6 +5,24 @@ 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
### Fixed
- jspdf弱点升级
- jspdf vulnerabilies
## [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

@@ -31,7 +31,7 @@
"id": 1101,
"name": "试验方案设计与<span style='font-weight: bold;color:red;'>伦理审查</span>",
"assignee": "方案设计师 李明",
"avatar": "https://i.pravatar.cc/150?img=1",
"avatar": "https://i.pravatar.cc/50?img=1",
"startDate": "2025-01-01",
"endDate": "2025-02-28",
"progress": 100,
@@ -46,7 +46,7 @@
"id": 1102,
"name": "受试者招募与筛选",
"assignee": "招募专员 张丽",
"avatar": "https://i.pravatar.cc/150?img=5",
"avatar": "https://i.pravatar.cc/50?img=5",
"startDate": "2025-03-01",
"endDate": "2025-04-30",
"progress": 90,
@@ -62,7 +62,7 @@
"id": 1103,
"name": "药物给药与安全性监测",
"assignee": "临床医生 Dr. Liu",
"avatar": "https://i.pravatar.cc/150?img=8",
"avatar": "https://i.pravatar.cc/50?img=8",
"startDate": "2025-05-01",
"endDate": "2025-08-31",
"progress": 40,
@@ -95,7 +95,7 @@
"id": 1201,
"name": "多中心试验<span style='font-weight: bold; color: blue;'>启动</span>",
"assignee": "项目经理 王芳",
"avatar": "https://i.pravatar.cc/150?img=10",
"avatar": "https://i.pravatar.cc/50?img=10",
"startDate": "2025-09-01",
"endDate": "2025-11-30",
"progress": 25,
@@ -111,7 +111,7 @@
"id": 1202,
"name": "患者入组与随机化",
"assignee": "数据管理员 陈静",
"avatar": "https://i.pravatar.cc/150?img=20",
"avatar": "https://i.pravatar.cc/50?img=20",
"startDate": "2025-12-01",
"endDate": "2026-03-31",
"progress": 0,
@@ -127,7 +127,7 @@
"id": 1203,
"name": "疗效评估与数据收集",
"assignee": "统计师 赵磊",
"avatar": "https://i.pravatar.cc/150?img=15",
"avatar": "https://i.pravatar.cc/50?img=15",
"startDate": "2026-01-01",
"endDate": "2026-08-31",
"progress": 0,

View File

@@ -306,5 +306,21 @@
"<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增加虚拟滚动支持大幅降低节点数和重绘开销",
"性能优化:大数据量任务渲染性能优化",
"性能优化:虚拟渲染优化",
"新增:大数据加载演示",
"修复jspdf弱点升级",
"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",
"Fix: jspdf vulnerabilies"
]
}
]

80
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jordium-gantt-vue3",
"version": "1.4.2",
"version": "1.4.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jordium-gantt-vue3",
"version": "1.4.2",
"version": "1.4.3",
"license": "MIT",
"dependencies": {
"date-fns": "^4.1.0",
@@ -60,9 +60,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"engines": {
"node": ">=6.9.0"
}
@@ -957,6 +957,11 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz",
@@ -1488,17 +1493,6 @@
"node": ">=8"
}
},
"node_modules/atob": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/atob/-/atob-2.1.2.tgz",
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
"bin": {
"atob": "bin/atob.js"
},
"engines": {
"node": ">= 4.5.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1540,17 +1534,6 @@
"node": ">=8"
}
},
"node_modules/btoa": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/btoa/-/btoa-1.2.1.tgz",
"integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
"bin": {
"btoa": "bin/btoa.js"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz",
@@ -2090,6 +2073,16 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz",
@@ -2375,6 +2368,11 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2421,9 +2419,9 @@
"dev": true
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"dependencies": {
"argparse": "^2.0.1"
@@ -2451,13 +2449,12 @@
"dev": true
},
"node_modules/jspdf": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/jspdf/-/jspdf-3.0.1.tgz",
"integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==",
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz",
"integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==",
"dependencies": {
"@babel/runtime": "^7.26.7",
"atob": "^2.1.2",
"btoa": "^1.2.1",
"@babel/runtime": "^7.28.4",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
@@ -2676,6 +2673,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
@@ -3213,9 +3215,9 @@
}
},
"node_modules/vite": {
"version": "6.3.5",
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"dependencies": {
"esbuild": "^0.25.0",

View File

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

View File

@@ -24,6 +24,7 @@ interface Props {
width: number
height: number
offsetLeft?: number // Canvas 在全局坐标系中的偏移量(用于虚拟渲染)
offsetTop?: number // Canvas 在垂直方向的偏移量(用于虚拟渲染)
highlightedTaskId: number | null
highlightedTaskIds: Set<number>
hoveredTaskId: number | null
@@ -36,6 +37,7 @@ const props = withDefaults(defineProps<Props>(), {
verticalLines: () => [],
showVerticalLines: true,
offsetLeft: 0,
offsetTop: 0,
})
// Canvas 引用
@@ -124,6 +126,8 @@ const drawLinks = () => {
// 虚拟渲染:计算 Canvas 覆盖的范围
const canvasStartX = props.offsetLeft
const canvasEndX = props.offsetLeft + displayWidth
const canvasStartY = props.offsetTop
const canvasEndY = props.offsetTop + displayHeight
// 定义线条数据类型
interface LineData {
@@ -157,16 +161,6 @@ const drawLinks = () => {
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
@@ -189,11 +183,26 @@ const drawLinks = () => {
const globalX2 = toBar.left
const globalY2 = toBar.top + toBar.height / 2 + toYOffset
// 虚拟渲染:跳过不在 Canvas 覆盖范围内的关系线
// 如果起点和终点都在 Canvas 外,跳过
const lineMinX = Math.min(globalX1, globalX2)
const lineMaxX = Math.max(globalX1, globalX2)
const lineMinY = Math.min(globalY1, globalY2)
const lineMaxY = Math.max(globalY1, globalY2)
if (
lineMaxX < canvasStartX ||
lineMinX > canvasEndX ||
lineMaxY < canvasStartY ||
lineMinY > canvasEndY
) {
continue // 完全在 Canvas 外,跳过
}
// 转换为 Canvas 局部坐标
const x1 = globalX1 - props.offsetLeft
const y1 = globalY1
const y1 = globalY1 - props.offsetTop
const x2 = globalX2 - props.offsetLeft
const y2 = globalY2
const y2 = globalY2 - props.offsetTop
const c1x = x1 + 40
const c1y = y1
@@ -369,6 +378,7 @@ watch(
() => props.verticalLines,
() => props.showVerticalLines,
() => props.offsetLeft, // 监听虚拟渲染的偏移量变化
() => props.offsetTop,
],
() => {
// 使用 RAF 调度重绘,合并连续的多次变化为单次绘制
@@ -430,7 +440,7 @@ defineExpose({
top: 0,
width: `${width}px`,
height: `${height}px`,
transform: `translateX(${offsetLeft}px)`,
transform: `translate(${offsetLeft}px, ${offsetTop}px)`,
zIndex: highlightedTaskId !== null ? 1001 : 25,
pointerEvents: 'none',
}"

View File

@@ -119,6 +119,25 @@ const createLocalToday = (): Date => {
return new Date(now.getFullYear(), now.getMonth(), now.getDate())
}
// 缓存今天的日期,避免频繁创建
// 每分钟更新一次缓存(对于日期判断来说足够了)
const cachedToday = ref(createLocalToday())
let todayCacheTimer: number | null = null
onMounted(() => {
// 每60秒更新一次今天的日期缓存
todayCacheTimer = window.setInterval(() => {
cachedToday.value = createLocalToday()
}, 60000)
})
onUnmounted(() => {
if (todayCacheTimer !== null) {
clearInterval(todayCacheTimer)
todayCacheTimer = null
}
})
const formatDateToLocalString = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
@@ -223,7 +242,7 @@ const taskBarStyle = computed(() => {
const startDate = createLocalDate(currentStartDate)
const endDate = createLocalDate(currentEndDate)
const baseStart = createLocalDate(props.startDate)
const baseStart = parsedBaseStartDate.value
if (!startDate || !endDate || !baseStart) {
return {
left: '0px',
@@ -392,6 +411,24 @@ const taskBarStyle = computed(() => {
}
})
// 缓存 TaskBar 的位置信息,减少 DOM 读取频率
const cachedPosition = ref({
left: 0,
top: 0,
width: 0,
height: 0,
timestamp: 0,
})
// 位置缓存有效期(毫秒)
const POSITION_CACHE_TTL = 100 // 100ms 内使用缓存值
// 缓存解析后的结束日期,避免在 taskStatus 中重复解析
const parsedEndDate = computed(() => createLocalDate(props.task.endDate || ''))
// 缓存解析后的基准开始日期
const parsedBaseStartDate = computed(() => createLocalDate(props.startDate))
// 计算任务状态和颜色
const taskStatus = computed(() => {
// 父级任务(Story类型)使用与新建按钮一致的配色
@@ -404,8 +441,9 @@ const taskStatus = computed(() => {
}
}
const today = createLocalToday()
const endDate = createLocalDate(props.task.endDate || '')
// 使用缓存的今天日期,避免频繁创建日期对象
const today = cachedToday.value
const endDate = parsedEndDate.value
const progress = props.task.progress || 0
// 已完成
@@ -556,6 +594,8 @@ const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-r
const timelineContainer = document.querySelector('.timeline') as HTMLElement
if (!timelineContainer || !barRef.value) return
// 在 mousedown 事件中读取位置是合理的(不是高频操作)
// 这个值用于计算拖拽偏移量,只在开始拖拽时读取一次
const barRect = barRef.value.getBoundingClientRect()
// 计算鼠标相对于TaskBar的位置
@@ -595,17 +635,57 @@ const handleAutoScroll = (event: CustomEvent) => {
}
}
// 使用缓存机制减少 DOM 读取频率,但保证位置准确性
let reportPositionScheduled = false
function reportBarPosition() {
if (barRef.value) {
// 如果已经安排了本帧的位置报告,则跳过
if (reportPositionScheduled) return
reportPositionScheduled = true
requestAnimationFrame(() => {
reportPositionScheduled = false
if (!barRef.value) return
const now = Date.now()
// 如果缓存未过期,使用缓存值
if (now - cachedPosition.value.timestamp < POSITION_CACHE_TTL) {
emit('bar-mounted', {
id: props.task.id,
left: cachedPosition.value.left,
top: cachedPosition.value.top,
width: cachedPosition.value.width,
height: cachedPosition.value.height,
})
return
}
// 缓存过期或首次调用,读取 DOM 并更新缓存
// TaskBar 传递绝对位置相对于视口Timeline 会将其转换为相对位置
const rect = barRef.value.getBoundingClientRect()
emit('bar-mounted', {
id: props.task.id,
// 计算并缓存位置
const position = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
}
// 更新缓存
cachedPosition.value = {
...position,
timestamp: now,
}
emit('bar-mounted', {
id: props.task.id,
...position,
})
}
})
}
// 拖拽时的实时日期提示框状态
@@ -1123,8 +1203,20 @@ const handleMouseUp = () => {
onMounted(() => {
nextTick(() => {
reportBarPosition()
// 使用 ResizeObserver 监听任务名称宽度变化
if (taskBarNameRef.value) {
nameTextWidth.value = taskBarNameRef.value.getBoundingClientRect().width
const nameResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
nameTextWidth.value = entry.contentRect.width
}
})
nameResizeObserver.observe(taskBarNameRef.value)
// 组件卸载时清理
onUnmounted(() => {
nameResizeObserver.disconnect()
})
}
})
@@ -1395,7 +1487,7 @@ const stickyStyles = computed(() => {
} else if (nameNeedsRightSticky) {
const offset = rightBoundary - taskLeft - nameWidth
// name 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
nameLeft = `${offset - handleWidth - 3}px` // 考虑手柄宽度 + 间距
nameLeft = `${offset - handleWidth - 10}px` // 考虑手柄宽度 + 间距
namePosition = 'absolute'
nameTop = '2px'
}
@@ -1431,7 +1523,7 @@ const stickyStyles = computed(() => {
progressTop = '18px'
} else if (progressNeedsRightSticky) {
const offset = rightBoundary - taskLeft - progressWidth
// progress 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
// 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
progressLeft = `${offset - handleWidth - 3}px` // 考虑手柄宽度 + 间距
progressPosition = 'absolute'
progressTop = '18px'

View File

@@ -35,6 +35,20 @@ const { t } = useI18n()
// TaskList 容器引用
const taskListRef = ref<HTMLElement | null>(null)
const taskListBodyRef = ref<HTMLElement | null>(null)
// 缓存容器宽度,避免频繁读取 offsetWidth 导致强制重排
const cachedContainerWidth = ref(0)
// 使用 ResizeObserver 监听容器宽度变化
let containerResizeObserver: ResizeObserver | null = null
let bodyResizeObserver: ResizeObserver | null = null
// 纵向虚拟滚动相关状态
const ROW_HEIGHT = 51 // 每行高度与TaskList Row一致
const VERTICAL_BUFFER = 5 // 上下额外渲染的缓冲行数
const taskListScrollTop = ref(0)
const taskListBodyHeight = ref(0)
// 获取列宽度样式(百分比转像素)
const getColumnWidthStyle = (column: { width?: number | string }) => {
@@ -44,7 +58,7 @@ const getColumnWidthStyle = (column: { width?: number | string }) => {
// 如果是百分比,转换为像素
if (typeof column.width === 'string' && column.width.includes('%')) {
const containerWidth = taskListRef.value?.offsetWidth || 0
const containerWidth = cachedContainerWidth.value || 0
if (containerWidth > 0) {
const percentage = parseFloat(column.width) / 100
const pixels = Math.floor(containerWidth * percentage)
@@ -89,6 +103,14 @@ const handleSplitterDragStart = () => {
// 处理拖拽结束事件
const handleSplitterDragEnd = () => {
isSplitterDragging.value = false
// ⚠️ 拖拽结束后,手动更新一次容器宽度,触发列宽重新计算
if (taskListRef.value) {
const newWidth = taskListRef.value.offsetWidth
if (Math.abs(newWidth - cachedContainerWidth.value) > 1) {
cachedContainerWidth.value = newWidth
}
}
}
// 处理任务行悬停事件
@@ -228,6 +250,61 @@ const getAllTasks = (taskList: Task[]): Task[] => {
return allTasks
}
// 获取当前折叠状态下的可见任务列表
const getFlattenedVisibleTasks = (
taskList: Task[],
level = 0,
): Array<{ task: Task; level: number }> => {
const result: Array<{ task: Task; level: number }> = []
for (const task of taskList) {
result.push({ task, level })
const isMilestoneGroup = task.type === 'milestone-group'
if (!isMilestoneGroup && task.children && task.children.length > 0 && !task.collapsed) {
result.push(...getFlattenedVisibleTasks(task.children, level + 1))
}
}
return result
}
// 扁平化后的可见任务列表
const flattenedTasks = computed(() => getFlattenedVisibleTasks(localTasks.value))
// 计算可视区域任务范围
const visibleTaskRange = computed(() => {
const scrollTop = taskListScrollTop.value
const containerHeight = taskListBodyHeight.value || 600
const startIndex = Math.floor(scrollTop / ROW_HEIGHT) - VERTICAL_BUFFER
const endIndex = Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + VERTICAL_BUFFER
const total = flattenedTasks.value.length
const clampedStart = Math.min(Math.max(0, startIndex), total)
const clampedEnd = Math.min(total, Math.max(clampedStart + 1, endIndex))
return {
startIndex: clampedStart,
endIndex: clampedEnd,
}
})
// 虚拟列表中需要渲染的任务
const visibleTasks = computed(() => {
const { startIndex, endIndex } = visibleTaskRange.value
return flattenedTasks.value.slice(startIndex, endIndex)
})
// Spacer 高度用于撑起滚动区域
const totalContentHeight = computed(() => flattenedTasks.value.length * ROW_HEIGHT)
const startSpacerHeight = computed(() => visibleTaskRange.value.startIndex * ROW_HEIGHT)
const endSpacerHeight = computed(() => {
const visibleHeight = visibleTasks.value.length * ROW_HEIGHT
return Math.max(0, totalContentHeight.value - startSpacerHeight.value - visibleHeight)
})
// 监听外部传入的 tasks 数据变化
watch(
() => props.tasks,
@@ -331,16 +408,23 @@ const handleTaskListScroll = (event: Event) => {
const scrollTop = target.scrollTop
taskListScrollTop.value = scrollTop
// 同步垂直滚动到Timeline
window.dispatchEvent(
new CustomEvent('task-list-vertical-scroll', {
detail: { scrollTop },
}),
)
} // 处理Timeline垂直滚动同步
}
// 处理Timeline垂直滚动同步
const handleTimelineVerticalScroll = (event: CustomEvent) => {
const { scrollTop } = event.detail
const taskListBodyElement = document.querySelector('.task-list-body') as HTMLElement
const taskListBodyElement = taskListBodyRef.value
taskListScrollTop.value = scrollTop
if (taskListBodyElement && Math.abs(taskListBodyElement.scrollTop - scrollTop) > 1) {
// 使用更精确的比较避免1px以内的细微差异导致的循环触发
taskListBodyElement.scrollTop = scrollTop
@@ -358,6 +442,7 @@ const handleTaskRowContextMenu = (event: { task: Task; position: { x: number; y:
}),
)
} catch (error) {
// eslint-disable-next-line no-console
console.error('TaskList - Failed to dispatch context-menu event', error)
}
}
@@ -386,6 +471,37 @@ const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
}
onMounted(async () => {
// 初始化 ResizeObserver 监听容器宽度变化
if (taskListRef.value) {
containerResizeObserver = new ResizeObserver((entries) => {
// ⚠️ 拖拽期间跳过更新,避免高频重新计算列宽
// TaskList的列宽不需要在拖拽期间实时更新只需在拖拽结束后更新一次
if (isSplitterDragging.value) {
return
}
for (const entry of entries) {
// 使用 contentRect.width 避免强制重排
cachedContainerWidth.value = entry.contentRect.width
}
})
containerResizeObserver.observe(taskListRef.value)
}
// 监听TaskList body高度变化提供虚拟滚动所需尺寸
if (taskListBodyRef.value) {
taskListBodyHeight.value = taskListBodyRef.value.clientHeight
taskListScrollTop.value = taskListBodyRef.value.scrollTop
bodyResizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
taskListBodyHeight.value = entry.contentRect.height
}
})
bodyResizeObserver.observe(taskListBodyRef.value)
}
window.addEventListener('task-updated', handleTaskUpdated as EventListener)
window.addEventListener('task-added', handleTaskAdded as EventListener)
window.addEventListener('request-task-list', handleRequestTaskList as EventListener)
@@ -401,6 +517,17 @@ onMounted(async () => {
})
onUnmounted(() => {
// 清理 ResizeObserver
if (containerResizeObserver) {
containerResizeObserver.disconnect()
containerResizeObserver = null
}
if (bodyResizeObserver) {
bodyResizeObserver.disconnect()
bodyResizeObserver = null
}
window.removeEventListener('task-updated', handleTaskUpdated as EventListener)
window.removeEventListener('task-added', handleTaskAdded as EventListener)
window.removeEventListener('request-task-list', handleRequestTaskList as EventListener)
@@ -433,17 +560,20 @@ onUnmounted(() => {
{{ (t as any)[column.key] || column.label }}
</div>
</div>
<div class="task-list-body" @scroll="handleTaskListScroll">
<TaskRow
v-for="task in localTasks"
<div ref="taskListBodyRef" class="task-list-body" @scroll="handleTaskListScroll">
<div class="task-list-body-spacer" :style="{ height: `${startSpacerHeight}px` }"></div>
<TaskRow
v-for="{ task, level } in visibleTasks"
:key="task.id"
:task="task"
:level="0"
:level="level"
:is-hovered="hoveredTaskId === task.id"
:hovered-task-id="hoveredTaskId"
:on-hover="handleTaskRowHover"
:columns="visibleColumns"
:get-column-width-style="getColumnWidthStyle"
:disable-children-render="true"
@toggle="toggleCollapse"
@dblclick="handleTaskRowDoubleClick"
@contextmenu="handleTaskRowContextMenu"
@@ -457,6 +587,8 @@ onUnmounted(() => {
<slot name="custom-task-content" v-bind="rowScope" />
</template>
</TaskRow>
<div class="task-list-body-spacer" :style="{ height: `${endSpacerHeight}px` }"></div>
</div>
</div>
</template>
@@ -509,14 +641,18 @@ onUnmounted(() => {
width: max-content;
background: var(--gantt-bg-primary);
flex: 1;
overflow-x: hidden; /* 让body部分可以滚动 */
overflow-y: auto; /* 允许垂直滚动 */
overflow-x: hidden; /* 允许横向滚动,确保列完整展示 */
overflow-y: auto;
/* Webkit浏览器滚动条样式 */
scrollbar-width: thin;
scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
}
.task-list-body-spacer {
width: 100%;
}
.task-list-body::-webkit-scrollbar {
width: 8px;
height: 8px;

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch, useSlots } from 'vue'
import type { CSSProperties } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from '../composables/useI18n'
import { formatPredecessorDisplay } from '../utils/predecessorUtils'
import type { Task } from '../models/classes/Task'
@@ -36,7 +36,8 @@ interface Props {
hoveredTaskId?: number | null
onHover?: (taskId: number | null) => void
columns: TaskListColumnConfig[]
getColumnWidthStyle?: (column: { width?: number | string }) => CSSProperties
getColumnWidthStyle?: (column: { width?: number | string }) => StyleValue
disableChildrenRender?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits([
@@ -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">
@@ -469,7 +470,7 @@ onUnmounted(() => {
</template>
</div>
</div>
<template v-if="hasChildren && !props.task.collapsed && !isMilestoneGroup">
<template v-if="!props.disableChildrenRender && hasChildren && !props.task.collapsed && !isMilestoneGroup">
<TaskRow
v-for="child in props.task.children"
:key="child.id"
@@ -480,6 +481,7 @@ onUnmounted(() => {
:on-hover="props.onHover"
:columns="props.columns"
:get-column-width-style="props.getColumnWidthStyle"
:disable-children-render="props.disableChildrenRender"
@toggle="emit('toggle', $event)"
@dblclick="emit('dblclick', $event)"
@start-timer="emit('start-timer', $event)"

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
}
// 获取小时视图的时间范围
@@ -657,6 +669,11 @@ let hideBubblesTimeout: number | null = null // 半圆显示恢复定时器
const HOUR_WIDTH = 40 // 每小时40px
const VIRTUAL_BUFFER = 10 // 减少缓冲区以提升滑动性能
// 纵向虚拟滚动相关状态
const ROW_HEIGHT = 51 // 每行高度51px (50px + 1px border)
const VERTICAL_BUFFER = 5 // 纵向缓冲区行数
const timelineBodyScrollTop = ref(0) // 纵向滚动位置
// 数据缓存
const timelineDataCache = new Map<string, unknown>()
@@ -701,6 +718,30 @@ const visibleHourRange = computed(() => {
}
})
// 计算纵向可视区域的任务范围
const visibleTaskRange = computed(() => {
const scrollTop = timelineBodyScrollTop.value
const containerHeight = timelineBodyHeight.value || 600
// 计算可视区域的开始和结束任务索引
const startIndex = Math.floor(scrollTop / ROW_HEIGHT) - VERTICAL_BUFFER
const endIndex = Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + VERTICAL_BUFFER
return {
startIndex: Math.max(0, startIndex),
endIndex: Math.min(tasks.value.length, Math.max(startIndex + 1, endIndex)),
}
})
// 获取虚拟滚动优化后的可见任务列表
const visibleTasks = computed(() => {
const { startIndex, endIndex } = visibleTaskRange.value
return tasks.value.slice(startIndex, endIndex).map((task, index) => ({
task,
originalIndex: startIndex + index,
}))
})
// 防抖处理滚动事件(优化:增加防抖时间)
const debounce = <T extends (...args: unknown[]) => void>(func: T, wait: number): T => {
let timeout: number | null = null
@@ -717,12 +758,12 @@ const debounce = <T extends (...args: unknown[]) => void>(func: T, wait: number)
// 优化的滚动处理器(增加防抖时间到 50ms
const debouncedUpdatePositions = debounce(() => {
computeAllMilestonesPositions()
}, 50)
}, 200)
// 虚拟渲染:防抖更新 Canvas 位置(滚动时触发)
const debouncedUpdateCanvasPosition = debounce(() => {
updateSvgSize() // 重新计算 Canvas 位置和尺寸
}, 50)
}, 200)
// 缓存时间轴数据的函数
const getCachedTimelineData = (): unknown => {
@@ -1938,17 +1979,20 @@ const svgHeight = ref(0)
const canvasWidth = ref(0)
const canvasHeight = ref(0)
const canvasOffsetLeft = ref(0) // Canvas 在全局坐标系中的偏移量
const canvasOffsetTop = ref(0)
// 虚拟渲染 Canvas 的安全宽度(防止超过浏览器限制)
// 可根据实际需求调整:
// - 5000: 最小内存 (~30MB),适合低端设备,但滚动时更频繁更新
// - 10000: 平衡选择 (~60MB),覆盖小时视图 10 天,周视图 2 年
const SAFE_CANVAS_WIDTH = 5000 // 平衡性能和覆盖范围
const SAFE_CANVAS_HEIGHT = 5000
function updateSvgSize() {
if (bodyContentRef.value) {
// 获取 bodyContent 的总宽度和可视区域宽度
const totalWidth = bodyContentRef.value.offsetWidth
const totalHeight = contentHeight.value
// 使用已经维护的 timelineScrollLeft而不是从 DOM 重新读取
// 因为 handleTimelineScroll 已经实时更新了这个值
@@ -1975,9 +2019,23 @@ function updateSvgSize() {
canvasOffsetLeft.value = idealOffsetLeft
const clampedHeight = Math.min(totalHeight, SAFE_CANVAS_HEIGHT)
canvasHeight.value = clampedHeight
svgWidth.value = canvasWidth.value
svgHeight.value = contentHeight.value
canvasHeight.value = contentHeight.value
svgHeight.value = clampedHeight
const scrollTop = timelineBodyScrollTop.value
const bufferTop = clampedHeight / 3
let idealOffsetTop = Math.max(0, scrollTop - bufferTop)
if (totalHeight <= clampedHeight) {
idealOffsetTop = 0
} else {
const maxOffsetTop = totalHeight - clampedHeight
idealOffsetTop = Math.min(idealOffsetTop, maxOffsetTop)
}
canvasOffsetTop.value = idealOffsetTop
}
}
@@ -2001,7 +2059,9 @@ function handleBarMounted(payload: {
height: payload.height,
},
}
updateSvgSize()
setTimeout(() => {
updateSvgSize()
}, 200)
}
// 向上传递 TaskBar 拖拽/拉伸事件
@@ -2162,6 +2222,12 @@ onMounted(() => {
// 处理TaskList垂直滚动同步
const handleTaskListVerticalScroll = (event: CustomEvent) => {
const { scrollTop } = event.detail
// 立即更新纵向滚动位置(用于虚拟滚动计算)
timelineBodyScrollTop.value = scrollTop
debouncedUpdateCanvasPosition()
if (timelineBodyElement.value && Math.abs(timelineBodyElement.value.scrollTop - scrollTop) > 1) {
// 使用更精确的比较避免1px以内的细微差异导致的循环触发
timelineBodyElement.value.scrollTop = scrollTop
@@ -2175,6 +2241,11 @@ const handleTimelineBodyScroll = (event: Event) => {
const scrollTop = target.scrollTop
// 立即更新纵向滚动位置(用于虚拟滚动计算)
timelineBodyScrollTop.value = scrollTop
debouncedUpdateCanvasPosition()
// 拖拽时不同步滚动事件,避免性能问题
if (isDragging.value) return
@@ -2701,6 +2772,7 @@ const convertTaskToMilestone = (task: Task): Milestone => {
watch(
() => tasks.value.length,
() => {
invalidateTaskDateRangeCache()
computeAllMilestonesPositions()
},
{ immediate: true },
@@ -2749,6 +2821,9 @@ const updateTimelineRange = () => {
watch(
() => tasks.value?.length,
(newLength, oldLength) => {
if (newLength !== oldLength) {
invalidateTaskDateRangeCache()
}
// 当任务从无到有时,重新计算时间范围
if (oldLength === 0 && newLength > 0) {
debouncedUpdateTimelineRange(50)
@@ -2789,7 +2864,7 @@ watch([timelineData, timelineContainerWidth], () => {
nextTick(() => {
setTimeout(() => {
updateSvgSize()
}, 50)
}, 200)
})
taskBarRenderTimer = null
}, 100)
@@ -2799,6 +2874,7 @@ watch([timelineData, timelineContainerWidth], () => {
watch(
() => tasks.value,
newTasks => {
invalidateTaskDateRangeCache()
// 优化:使用 for 循环直接构建 Set避免 map 创建临时数组
const currentTaskIds = new Set<number>()
for (const task of newTasks) {
@@ -3327,6 +3403,7 @@ const handleAddSuccessor = (task: Task) => {
:width="canvasWidth"
:height="canvasHeight"
:offset-left="canvasOffsetLeft"
:offset-top="canvasOffsetTop"
:highlighted-task-id="highlightedTaskId"
:highlighted-task-ids="highlightedTaskIds"
:hovered-task-id="hoveredTaskId"
@@ -3510,13 +3587,13 @@ const handleAddSuccessor = (task: Task) => {
<!-- 同时需要考虑左侧TaskList包含1px的bottom border -->
<div class="task-bar-container" :style="{ height: `${contentHeight}px` }">
<div class="task-rows" :style="{ height: `${contentHeight}px` }">
<!-- 使用v-memo减少渲染 -->
<!-- 使用虚拟滚动渲染可见任务 -->
<div
v-for="(task, index) in tasks"
v-for="{ task, originalIndex } in visibleTasks"
:key="task.id"
class="task-row"
:class="{ 'task-row-hovered': hoveredTaskId === task.id }"
:style="{ top: `${index * 51}px` }"
:style="{ top: `${originalIndex * 51}px` }"
@mouseenter="handleTaskRowHover(task.id)"
@mouseleave="handleTaskRowHover(null)"
>

View File

@@ -240,6 +240,29 @@ const messages = {
},
},
disableTaskbarFocusMode: '关闭聚焦功能',
dataSourceAlreadyLoaded: '{name} 已是当前数据源',
dataSourceLoadSuccess: '已加载 {name}',
dataSourceLoadFailed: '{name} 加载失败',
dataSourceSwitch: {
title: '数据源切换',
subtitle: '对比常规与超大数据集的初始化体验',
loading: '数据加载中,请稍候...',
alreadyLoaded: '{name} 已是当前数据源',
loadSuccess: '已加载 {name}',
loadFailed: '{name} 加载失败',
sources: {
normal: {
label: '常规数据源',
description: 'data.json · 含完整前/后置依赖,适合功能演示',
badge: 'data.json',
},
large: {
label: '超大数据源',
description: 'data-large-1m.json · 百万级任务,验证虚拟渲染性能',
badge: 'data-large-1m.json',
},
},
},
},
'en-US': {
dateNotSet: 'Not set',
@@ -478,6 +501,29 @@ const messages = {
},
},
disableTaskbarFocusMode: 'Disable Focus Mode',
dataSourceAlreadyLoaded: '{name} is already active',
dataSourceLoadSuccess: '{name} loaded successfully',
dataSourceLoadFailed: '{name} failed to load',
dataSourceSwitch: {
title: 'Data Sources',
subtitle: 'Compare default vs. mega dataset initialization',
loading: 'Loading data, please wait…',
alreadyLoaded: '{name} is already active',
loadSuccess: '{name} loaded successfully',
loadFailed: '{name} failed to load',
sources: {
normal: {
label: 'Standard Dataset',
description: 'data.json · Full predecessor graph for feature demos',
badge: 'data.json',
},
large: {
label: 'Massive Dataset',
description: 'data-large-1m.json · Million-level tasks to stress virtual rendering',
badge: 'data-large-1m.json',
},
},
},
},
}