Merge remote-tracking branch 'src'
This commit is contained in:
@@ -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/),
|
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.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
|
## [1.4.2-patch3] - 2025-11-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+295
-10
@@ -3,7 +3,8 @@ import { ref, computed, onMounted, nextTick } from 'vue'
|
|||||||
import GanttChart from '../src/components/GanttChart.vue'
|
import GanttChart from '../src/components/GanttChart.vue'
|
||||||
// import TaskDrawer from '../src/components/TaskDrawer.vue' // 移除
|
// import TaskDrawer from '../src/components/TaskDrawer.vue' // 移除
|
||||||
import MilestoneDialog from '../src/components/MilestoneDialog.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 packageInfo from '../package.json'
|
||||||
// 导入主题变量
|
// 导入主题变量
|
||||||
import '../src/styles/theme-variables.css'
|
import '../src/styles/theme-variables.css'
|
||||||
@@ -22,6 +23,100 @@ const { t, formatTranslation } = useI18n()
|
|||||||
const tasks = ref<Task[]>([])
|
const tasks = ref<Task[]>([])
|
||||||
const milestones = 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状态管理
|
// MilestoneDialog状态管理
|
||||||
const showMilestoneDialog = ref(false)
|
const showMilestoneDialog = ref(false)
|
||||||
const currentMilestone = ref<Task | null>(null)
|
const currentMilestone = ref<Task | null>(null)
|
||||||
@@ -110,6 +205,7 @@ const taskBarConfig = computed<TaskBarConfig>(() => ({
|
|||||||
|
|
||||||
// 配置面板折叠状态
|
// 配置面板折叠状态
|
||||||
const isConfigPanelCollapsed = ref(false)
|
const isConfigPanelCollapsed = ref(false)
|
||||||
|
const isDataSourcePanelCollapsed = ref(false)
|
||||||
|
|
||||||
// TaskList 配置区域折叠状态(默认收起)
|
// TaskList 配置区域折叠状态(默认收起)
|
||||||
const isTaskListConfigCollapsed = ref(true)
|
const isTaskListConfigCollapsed = ref(true)
|
||||||
@@ -122,6 +218,10 @@ const toggleConfigPanel = () => {
|
|||||||
isConfigPanelCollapsed.value = !isConfigPanelCollapsed.value
|
isConfigPanelCollapsed.value = !isConfigPanelCollapsed.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleDataSourcePanel = () => {
|
||||||
|
isDataSourcePanelCollapsed.value = !isDataSourcePanelCollapsed.value
|
||||||
|
}
|
||||||
|
|
||||||
// 切换 TaskList 配置区域
|
// 切换 TaskList 配置区域
|
||||||
const toggleTaskListConfig = () => {
|
const toggleTaskListConfig = () => {
|
||||||
isTaskListConfigCollapsed.value = !isTaskListConfigCollapsed.value
|
isTaskListConfigCollapsed.value = !isTaskListConfigCollapsed.value
|
||||||
@@ -377,8 +477,7 @@ function handleMilestoneDragEnd(newMilestone) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
tasks.value = demoData.tasks as Task[]
|
switchDataSource(currentDataSource.value, { silent: true, force: true })
|
||||||
milestones.value = demoData.milestones as Task[]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 清理被删除任务的predecessor依赖关系
|
// 清理被删除任务的predecessor依赖关系
|
||||||
@@ -480,6 +579,64 @@ function taskDebug(item: any) {
|
|||||||
</h1>
|
</h1>
|
||||||
<VersionHistoryDrawer :visible="showVersionDrawer" @close="showVersionDrawer = false" />
|
<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-panel" :class="{ collapsed: isConfigPanelCollapsed }">
|
||||||
<div class="config-header" @click="toggleConfigPanel">
|
<div class="config-header" @click="toggleConfigPanel">
|
||||||
@@ -505,7 +662,7 @@ function taskDebug(item: any) {
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M7 10l5 5 5-5"
|
:d="isConfigPanelCollapsed ? 'M7 14l5-5 5 5' : 'M7 10l5 5 5-5'"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -917,6 +1074,106 @@ function taskDebug(item: any) {
|
|||||||
flex-direction: column;
|
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列配置面板样式 - 可折叠 */
|
/* TaskList列配置面板样式 - 可折叠 */
|
||||||
.config-panel {
|
.config-panel {
|
||||||
background: var(--gantt-bg-primary, #ffffff);
|
background: var(--gantt-bg-primary, #ffffff);
|
||||||
@@ -964,8 +1221,6 @@ function taskDebug(item: any) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -974,6 +1229,10 @@ function taskDebug(item: any) {
|
|||||||
color: var(--gantt-text-secondary, #666);
|
color: var(--gantt-text-secondary, #666);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collapse-button.collapsed {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
.collapse-button:hover {
|
.collapse-button:hover {
|
||||||
background-color: var(--gantt-hover-bg, #e8f4fd);
|
background-color: var(--gantt-hover-bg, #e8f4fd);
|
||||||
color: var(--gantt-primary-color, #409eff);
|
color: var(--gantt-primary-color, #409eff);
|
||||||
@@ -985,10 +1244,6 @@ function taskDebug(item: any) {
|
|||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapse-button.collapsed .collapse-icon {
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 过渡动画 */
|
/* 过渡动画 */
|
||||||
.config-content-enter-active,
|
.config-content-enter-active,
|
||||||
.config-content-leave-active {
|
.config-content-leave-active {
|
||||||
@@ -1479,6 +1734,36 @@ function taskDebug(item: any) {
|
|||||||
border-color: var(--gantt-border-color, #4a5568);
|
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 {
|
:global(html[data-theme='dark']) .config-title {
|
||||||
color: var(--gantt-text-primary, #e2e8f0);
|
color: var(--gantt-text-primary, #e2e8f0);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+6
-6
@@ -31,7 +31,7 @@
|
|||||||
"id": 1101,
|
"id": 1101,
|
||||||
"name": "试验方案设计与<span style='font-weight: bold;color:red;'>伦理审查</span>",
|
"name": "试验方案设计与<span style='font-weight: bold;color:red;'>伦理审查</span>",
|
||||||
"assignee": "方案设计师 李明",
|
"assignee": "方案设计师 李明",
|
||||||
"avatar": "https://i.pravatar.cc/150?img=1",
|
"avatar": "https://i.pravatar.cc/50?img=1",
|
||||||
"startDate": "2025-01-01",
|
"startDate": "2025-01-01",
|
||||||
"endDate": "2025-02-28",
|
"endDate": "2025-02-28",
|
||||||
"progress": 100,
|
"progress": 100,
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
"id": 1102,
|
"id": 1102,
|
||||||
"name": "受试者招募与筛选",
|
"name": "受试者招募与筛选",
|
||||||
"assignee": "招募专员 张丽",
|
"assignee": "招募专员 张丽",
|
||||||
"avatar": "https://i.pravatar.cc/150?img=5",
|
"avatar": "https://i.pravatar.cc/50?img=5",
|
||||||
"startDate": "2025-03-01",
|
"startDate": "2025-03-01",
|
||||||
"endDate": "2025-04-30",
|
"endDate": "2025-04-30",
|
||||||
"progress": 90,
|
"progress": 90,
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"id": 1103,
|
"id": 1103,
|
||||||
"name": "药物给药与安全性监测",
|
"name": "药物给药与安全性监测",
|
||||||
"assignee": "临床医生 Dr. Liu",
|
"assignee": "临床医生 Dr. Liu",
|
||||||
"avatar": "https://i.pravatar.cc/150?img=8",
|
"avatar": "https://i.pravatar.cc/50?img=8",
|
||||||
"startDate": "2025-05-01",
|
"startDate": "2025-05-01",
|
||||||
"endDate": "2025-08-31",
|
"endDate": "2025-08-31",
|
||||||
"progress": 40,
|
"progress": 40,
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
"id": 1201,
|
"id": 1201,
|
||||||
"name": "多中心试验<span style='font-weight: bold; color: blue;'>启动</span>",
|
"name": "多中心试验<span style='font-weight: bold; color: blue;'>启动</span>",
|
||||||
"assignee": "项目经理 王芳",
|
"assignee": "项目经理 王芳",
|
||||||
"avatar": "https://i.pravatar.cc/150?img=10",
|
"avatar": "https://i.pravatar.cc/50?img=10",
|
||||||
"startDate": "2025-09-01",
|
"startDate": "2025-09-01",
|
||||||
"endDate": "2025-11-30",
|
"endDate": "2025-11-30",
|
||||||
"progress": 25,
|
"progress": 25,
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
"id": 1202,
|
"id": 1202,
|
||||||
"name": "患者入组与随机化",
|
"name": "患者入组与随机化",
|
||||||
"assignee": "数据管理员 陈静",
|
"assignee": "数据管理员 陈静",
|
||||||
"avatar": "https://i.pravatar.cc/150?img=20",
|
"avatar": "https://i.pravatar.cc/50?img=20",
|
||||||
"startDate": "2025-12-01",
|
"startDate": "2025-12-01",
|
||||||
"endDate": "2026-03-31",
|
"endDate": "2026-03-31",
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
"id": 1203,
|
"id": 1203,
|
||||||
"name": "疗效评估与数据收集",
|
"name": "疗效评估与数据收集",
|
||||||
"assignee": "统计师 赵磊",
|
"assignee": "统计师 赵磊",
|
||||||
"avatar": "https://i.pravatar.cc/150?img=15",
|
"avatar": "https://i.pravatar.cc/50?img=15",
|
||||||
"startDate": "2026-01-01",
|
"startDate": "2026-01-01",
|
||||||
"endDate": "2026-08-31",
|
"endDate": "2026-08-31",
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
|
|||||||
@@ -306,5 +306,21 @@
|
|||||||
"<span style=\"font-weight: bold; color: #f00;\">特别感谢 @qiuchengw的专业贡献</span>",
|
"<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>"
|
"<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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
+41
-39
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "jordium-gantt-vue3",
|
"name": "jordium-gantt-vue3",
|
||||||
"version": "1.4.2",
|
"version": "1.4.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "jordium-gantt-vue3",
|
"name": "jordium-gantt-vue3",
|
||||||
"version": "1.4.2",
|
"version": "1.4.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -60,9 +60,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
"version": "7.27.6",
|
"version": "7.28.4",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.27.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
@@ -957,6 +957,11 @@
|
|||||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@types/raf": {
|
||||||
"version": "3.4.3",
|
"version": "3.4.3",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz",
|
||||||
@@ -1488,17 +1493,6 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -1540,17 +1534,6 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -2090,6 +2073,16 @@
|
|||||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/fastq": {
|
||||||
"version": "1.19.1",
|
"version": "1.19.1",
|
||||||
"resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz",
|
"resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz",
|
||||||
@@ -2375,6 +2368,11 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
@@ -2421,9 +2419,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -2451,13 +2449,12 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/jspdf": {
|
"node_modules/jspdf": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmmirror.com/jspdf/-/jspdf-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz",
|
||||||
"integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==",
|
"integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.26.7",
|
"@babel/runtime": "^7.28.4",
|
||||||
"atob": "^2.1.2",
|
"fast-png": "^6.2.0",
|
||||||
"btoa": "^1.2.1",
|
|
||||||
"fflate": "^0.8.1"
|
"fflate": "^0.8.1"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
@@ -2676,6 +2673,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -3213,9 +3215,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.3.5",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jordium-gantt-vue3",
|
"name": "jordium-gantt-vue3",
|
||||||
"version": "1.4.2-patch.3",
|
"version": "1.4.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "npm-package/dist/jordium-gantt-vue3.cjs.js",
|
"main": "npm-package/dist/jordium-gantt-vue3.cjs.js",
|
||||||
"module": "npm-package/dist/jordium-gantt-vue3.es.js",
|
"module": "npm-package/dist/jordium-gantt-vue3.es.js",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface Props {
|
|||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
offsetLeft?: number // Canvas 在全局坐标系中的偏移量(用于虚拟渲染)
|
offsetLeft?: number // Canvas 在全局坐标系中的偏移量(用于虚拟渲染)
|
||||||
|
offsetTop?: number // Canvas 在垂直方向的偏移量(用于虚拟渲染)
|
||||||
highlightedTaskId: number | null
|
highlightedTaskId: number | null
|
||||||
highlightedTaskIds: Set<number>
|
highlightedTaskIds: Set<number>
|
||||||
hoveredTaskId: number | null
|
hoveredTaskId: number | null
|
||||||
@@ -36,6 +37,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
verticalLines: () => [],
|
verticalLines: () => [],
|
||||||
showVerticalLines: true,
|
showVerticalLines: true,
|
||||||
offsetLeft: 0,
|
offsetLeft: 0,
|
||||||
|
offsetTop: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Canvas 引用
|
// Canvas 引用
|
||||||
@@ -124,6 +126,8 @@ const drawLinks = () => {
|
|||||||
// 虚拟渲染:计算 Canvas 覆盖的范围
|
// 虚拟渲染:计算 Canvas 覆盖的范围
|
||||||
const canvasStartX = props.offsetLeft
|
const canvasStartX = props.offsetLeft
|
||||||
const canvasEndX = props.offsetLeft + displayWidth
|
const canvasEndX = props.offsetLeft + displayWidth
|
||||||
|
const canvasStartY = props.offsetTop
|
||||||
|
const canvasEndY = props.offsetTop + displayHeight
|
||||||
|
|
||||||
// 定义线条数据类型
|
// 定义线条数据类型
|
||||||
interface LineData {
|
interface LineData {
|
||||||
@@ -157,16 +161,6 @@ const drawLinks = () => {
|
|||||||
continue
|
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 fromIsPrimary = props.highlightedTaskId === predecessorId
|
||||||
const toIsPrimary = props.highlightedTaskId === task.id
|
const toIsPrimary = props.highlightedTaskId === task.id
|
||||||
@@ -189,11 +183,26 @@ const drawLinks = () => {
|
|||||||
const globalX2 = toBar.left
|
const globalX2 = toBar.left
|
||||||
const globalY2 = toBar.top + toBar.height / 2 + toYOffset
|
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 局部坐标
|
// 转换为 Canvas 局部坐标
|
||||||
const x1 = globalX1 - props.offsetLeft
|
const x1 = globalX1 - props.offsetLeft
|
||||||
const y1 = globalY1
|
const y1 = globalY1 - props.offsetTop
|
||||||
const x2 = globalX2 - props.offsetLeft
|
const x2 = globalX2 - props.offsetLeft
|
||||||
const y2 = globalY2
|
const y2 = globalY2 - props.offsetTop
|
||||||
|
|
||||||
const c1x = x1 + 40
|
const c1x = x1 + 40
|
||||||
const c1y = y1
|
const c1y = y1
|
||||||
@@ -369,6 +378,7 @@ watch(
|
|||||||
() => props.verticalLines,
|
() => props.verticalLines,
|
||||||
() => props.showVerticalLines,
|
() => props.showVerticalLines,
|
||||||
() => props.offsetLeft, // 监听虚拟渲染的偏移量变化
|
() => props.offsetLeft, // 监听虚拟渲染的偏移量变化
|
||||||
|
() => props.offsetTop,
|
||||||
],
|
],
|
||||||
() => {
|
() => {
|
||||||
// 使用 RAF 调度重绘,合并连续的多次变化为单次绘制
|
// 使用 RAF 调度重绘,合并连续的多次变化为单次绘制
|
||||||
@@ -430,7 +440,7 @@ defineExpose({
|
|||||||
top: 0,
|
top: 0,
|
||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
height: `${height}px`,
|
height: `${height}px`,
|
||||||
transform: `translateX(${offsetLeft}px)`,
|
transform: `translate(${offsetLeft}px, ${offsetTop}px)`,
|
||||||
zIndex: highlightedTaskId !== null ? 1001 : 25,
|
zIndex: highlightedTaskId !== null ? 1001 : 25,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}"
|
}"
|
||||||
|
|||||||
+102
-10
@@ -119,6 +119,25 @@ const createLocalToday = (): Date => {
|
|||||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
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 formatDateToLocalString = (date: Date): string => {
|
||||||
const year = date.getFullYear()
|
const year = date.getFullYear()
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
@@ -223,7 +242,7 @@ const taskBarStyle = computed(() => {
|
|||||||
|
|
||||||
const startDate = createLocalDate(currentStartDate)
|
const startDate = createLocalDate(currentStartDate)
|
||||||
const endDate = createLocalDate(currentEndDate)
|
const endDate = createLocalDate(currentEndDate)
|
||||||
const baseStart = createLocalDate(props.startDate)
|
const baseStart = parsedBaseStartDate.value
|
||||||
if (!startDate || !endDate || !baseStart) {
|
if (!startDate || !endDate || !baseStart) {
|
||||||
return {
|
return {
|
||||||
left: '0px',
|
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(() => {
|
const taskStatus = computed(() => {
|
||||||
// 父级任务(Story类型)使用与新建按钮一致的配色
|
// 父级任务(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
|
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
|
const timelineContainer = document.querySelector('.timeline') as HTMLElement
|
||||||
if (!timelineContainer || !barRef.value) return
|
if (!timelineContainer || !barRef.value) return
|
||||||
|
|
||||||
|
// 在 mousedown 事件中读取位置是合理的(不是高频操作)
|
||||||
|
// 这个值用于计算拖拽偏移量,只在开始拖拽时读取一次
|
||||||
const barRect = barRef.value.getBoundingClientRect()
|
const barRect = barRef.value.getBoundingClientRect()
|
||||||
|
|
||||||
// 计算鼠标相对于TaskBar的位置
|
// 计算鼠标相对于TaskBar的位置
|
||||||
@@ -595,17 +635,57 @@ const handleAutoScroll = (event: CustomEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用缓存机制减少 DOM 读取频率,但保证位置准确性
|
||||||
|
let reportPositionScheduled = false
|
||||||
|
|
||||||
function reportBarPosition() {
|
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()
|
const rect = barRef.value.getBoundingClientRect()
|
||||||
emit('bar-mounted', {
|
|
||||||
id: props.task.id,
|
// 计算并缓存位置
|
||||||
|
const position = {
|
||||||
left: rect.left,
|
left: rect.left,
|
||||||
top: rect.top,
|
top: rect.top,
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height,
|
height: rect.height,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
cachedPosition.value = {
|
||||||
|
...position,
|
||||||
|
timestamp: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('bar-mounted', {
|
||||||
|
id: props.task.id,
|
||||||
|
...position,
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拖拽时的实时日期提示框状态
|
// 拖拽时的实时日期提示框状态
|
||||||
@@ -1123,8 +1203,20 @@ const handleMouseUp = () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
reportBarPosition()
|
reportBarPosition()
|
||||||
|
|
||||||
|
// 使用 ResizeObserver 监听任务名称宽度变化
|
||||||
if (taskBarNameRef.value) {
|
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) {
|
} else if (nameNeedsRightSticky) {
|
||||||
const offset = rightBoundary - taskLeft - nameWidth
|
const offset = rightBoundary - taskLeft - nameWidth
|
||||||
// name 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
|
// name 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
|
||||||
nameLeft = `${offset - handleWidth - 3}px` // 考虑手柄宽度 + 间距
|
nameLeft = `${offset - handleWidth - 10}px` // 考虑手柄宽度 + 间距
|
||||||
namePosition = 'absolute'
|
namePosition = 'absolute'
|
||||||
nameTop = '2px'
|
nameTop = '2px'
|
||||||
}
|
}
|
||||||
@@ -1431,7 +1523,7 @@ const stickyStyles = computed(() => {
|
|||||||
progressTop = '18px'
|
progressTop = '18px'
|
||||||
} else if (progressNeedsRightSticky) {
|
} else if (progressNeedsRightSticky) {
|
||||||
const offset = rightBoundary - taskLeft - progressWidth
|
const offset = rightBoundary - taskLeft - progressWidth
|
||||||
// progress 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
|
// 右侧磁吸时应始终保持与右边框固定距离,需要减去右侧手柄宽度
|
||||||
progressLeft = `${offset - handleWidth - 3}px` // 考虑手柄宽度 + 间距
|
progressLeft = `${offset - handleWidth - 3}px` // 考虑手柄宽度 + 间距
|
||||||
progressPosition = 'absolute'
|
progressPosition = 'absolute'
|
||||||
progressTop = '18px'
|
progressTop = '18px'
|
||||||
|
|||||||
+145
-9
@@ -35,6 +35,20 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
// TaskList 容器引用
|
// TaskList 容器引用
|
||||||
const taskListRef = ref<HTMLElement | null>(null)
|
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 }) => {
|
const getColumnWidthStyle = (column: { width?: number | string }) => {
|
||||||
@@ -44,7 +58,7 @@ const getColumnWidthStyle = (column: { width?: number | string }) => {
|
|||||||
|
|
||||||
// 如果是百分比,转换为像素
|
// 如果是百分比,转换为像素
|
||||||
if (typeof column.width === 'string' && column.width.includes('%')) {
|
if (typeof column.width === 'string' && column.width.includes('%')) {
|
||||||
const containerWidth = taskListRef.value?.offsetWidth || 0
|
const containerWidth = cachedContainerWidth.value || 0
|
||||||
if (containerWidth > 0) {
|
if (containerWidth > 0) {
|
||||||
const percentage = parseFloat(column.width) / 100
|
const percentage = parseFloat(column.width) / 100
|
||||||
const pixels = Math.floor(containerWidth * percentage)
|
const pixels = Math.floor(containerWidth * percentage)
|
||||||
@@ -89,6 +103,14 @@ const handleSplitterDragStart = () => {
|
|||||||
// 处理拖拽结束事件
|
// 处理拖拽结束事件
|
||||||
const handleSplitterDragEnd = () => {
|
const handleSplitterDragEnd = () => {
|
||||||
isSplitterDragging.value = false
|
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
|
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 数据变化
|
// 监听外部传入的 tasks 数据变化
|
||||||
watch(
|
watch(
|
||||||
() => props.tasks,
|
() => props.tasks,
|
||||||
@@ -331,16 +408,23 @@ const handleTaskListScroll = (event: Event) => {
|
|||||||
|
|
||||||
const scrollTop = target.scrollTop
|
const scrollTop = target.scrollTop
|
||||||
|
|
||||||
|
taskListScrollTop.value = scrollTop
|
||||||
|
|
||||||
// 同步垂直滚动到Timeline
|
// 同步垂直滚动到Timeline
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('task-list-vertical-scroll', {
|
new CustomEvent('task-list-vertical-scroll', {
|
||||||
detail: { scrollTop },
|
detail: { scrollTop },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} // 处理Timeline垂直滚动同步
|
}
|
||||||
|
|
||||||
|
// 处理Timeline垂直滚动同步
|
||||||
const handleTimelineVerticalScroll = (event: CustomEvent) => {
|
const handleTimelineVerticalScroll = (event: CustomEvent) => {
|
||||||
const { scrollTop } = event.detail
|
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) {
|
if (taskListBodyElement && Math.abs(taskListBodyElement.scrollTop - scrollTop) > 1) {
|
||||||
// 使用更精确的比较,避免1px以内的细微差异导致的循环触发
|
// 使用更精确的比较,避免1px以内的细微差异导致的循环触发
|
||||||
taskListBodyElement.scrollTop = scrollTop
|
taskListBodyElement.scrollTop = scrollTop
|
||||||
@@ -358,6 +442,7 @@ const handleTaskRowContextMenu = (event: { task: Task; position: { x: number; y:
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
console.error('TaskList - Failed to dispatch context-menu event', error)
|
console.error('TaskList - Failed to dispatch context-menu event', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,6 +471,37 @@ const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
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-updated', handleTaskUpdated as EventListener)
|
||||||
window.addEventListener('task-added', handleTaskAdded as EventListener)
|
window.addEventListener('task-added', handleTaskAdded as EventListener)
|
||||||
window.addEventListener('request-task-list', handleRequestTaskList as EventListener)
|
window.addEventListener('request-task-list', handleRequestTaskList as EventListener)
|
||||||
@@ -401,6 +517,17 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
// 清理 ResizeObserver
|
||||||
|
if (containerResizeObserver) {
|
||||||
|
containerResizeObserver.disconnect()
|
||||||
|
containerResizeObserver = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bodyResizeObserver) {
|
||||||
|
bodyResizeObserver.disconnect()
|
||||||
|
bodyResizeObserver = null
|
||||||
|
}
|
||||||
|
|
||||||
window.removeEventListener('task-updated', handleTaskUpdated as EventListener)
|
window.removeEventListener('task-updated', handleTaskUpdated as EventListener)
|
||||||
window.removeEventListener('task-added', handleTaskAdded as EventListener)
|
window.removeEventListener('task-added', handleTaskAdded as EventListener)
|
||||||
window.removeEventListener('request-task-list', handleRequestTaskList as EventListener)
|
window.removeEventListener('request-task-list', handleRequestTaskList as EventListener)
|
||||||
@@ -433,17 +560,20 @@ onUnmounted(() => {
|
|||||||
{{ (t as any)[column.key] || column.label }}
|
{{ (t as any)[column.key] || column.label }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-list-body" @scroll="handleTaskListScroll">
|
<div ref="taskListBodyRef" class="task-list-body" @scroll="handleTaskListScroll">
|
||||||
<TaskRow
|
<div class="task-list-body-spacer" :style="{ height: `${startSpacerHeight}px` }"></div>
|
||||||
v-for="task in localTasks"
|
|
||||||
|
<TaskRow
|
||||||
|
v-for="{ task, level } in visibleTasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
:task="task"
|
:task="task"
|
||||||
:level="0"
|
:level="level"
|
||||||
:is-hovered="hoveredTaskId === task.id"
|
:is-hovered="hoveredTaskId === task.id"
|
||||||
:hovered-task-id="hoveredTaskId"
|
:hovered-task-id="hoveredTaskId"
|
||||||
:on-hover="handleTaskRowHover"
|
:on-hover="handleTaskRowHover"
|
||||||
:columns="visibleColumns"
|
:columns="visibleColumns"
|
||||||
:get-column-width-style="getColumnWidthStyle"
|
:get-column-width-style="getColumnWidthStyle"
|
||||||
|
:disable-children-render="true"
|
||||||
@toggle="toggleCollapse"
|
@toggle="toggleCollapse"
|
||||||
@dblclick="handleTaskRowDoubleClick"
|
@dblclick="handleTaskRowDoubleClick"
|
||||||
@contextmenu="handleTaskRowContextMenu"
|
@contextmenu="handleTaskRowContextMenu"
|
||||||
@@ -457,6 +587,8 @@ onUnmounted(() => {
|
|||||||
<slot name="custom-task-content" v-bind="rowScope" />
|
<slot name="custom-task-content" v-bind="rowScope" />
|
||||||
</template>
|
</template>
|
||||||
</TaskRow>
|
</TaskRow>
|
||||||
|
|
||||||
|
<div class="task-list-body-spacer" :style="{ height: `${endSpacerHeight}px` }"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -509,14 +641,18 @@ onUnmounted(() => {
|
|||||||
width: max-content;
|
width: max-content;
|
||||||
background: var(--gantt-bg-primary);
|
background: var(--gantt-bg-primary);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-x: hidden; /* 让body部分可以滚动 */
|
overflow-x: hidden; /* 允许横向滚动,确保列完整展示 */
|
||||||
overflow-y: auto; /* 允许垂直滚动 */
|
overflow-y: auto;
|
||||||
|
|
||||||
/* Webkit浏览器滚动条样式 */
|
/* Webkit浏览器滚动条样式 */
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
|
scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-list-body-spacer {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.task-list-body::-webkit-scrollbar {
|
.task-list-body::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, computed, watch, useSlots } from 'vue'
|
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 { useI18n } from '../composables/useI18n'
|
||||||
import { formatPredecessorDisplay } from '../utils/predecessorUtils'
|
import { formatPredecessorDisplay } from '../utils/predecessorUtils'
|
||||||
import type { Task } from '../models/classes/Task'
|
import type { Task } from '../models/classes/Task'
|
||||||
@@ -36,7 +36,8 @@ interface Props {
|
|||||||
hoveredTaskId?: number | null
|
hoveredTaskId?: number | null
|
||||||
onHover?: (taskId: number | null) => void
|
onHover?: (taskId: number | null) => void
|
||||||
columns: TaskListColumnConfig[]
|
columns: TaskListColumnConfig[]
|
||||||
getColumnWidthStyle?: (column: { width?: number | string }) => CSSProperties
|
getColumnWidthStyle?: (column: { width?: number | string }) => StyleValue
|
||||||
|
disableChildrenRender?: boolean
|
||||||
}
|
}
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -412,7 +413,7 @@ onUnmounted(() => {
|
|||||||
:key="column.key"
|
:key="column.key"
|
||||||
class="col"
|
class="col"
|
||||||
:class="column.cssClass || `col-${column.key}`"
|
:class="column.cssClass || `col-${column.key}`"
|
||||||
:style="getColumnWidthStyle ? getColumnWidthStyle(column) : {}"
|
:style="getColumnWidthStyle ? getColumnWidthStyle(column) : undefined"
|
||||||
>
|
>
|
||||||
<!-- 里程碑分组显示空列 -->
|
<!-- 里程碑分组显示空列 -->
|
||||||
<template v-if="isMilestoneGroup">
|
<template v-if="isMilestoneGroup">
|
||||||
@@ -469,7 +470,7 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="hasChildren && !props.task.collapsed && !isMilestoneGroup">
|
<template v-if="!props.disableChildrenRender && hasChildren && !props.task.collapsed && !isMilestoneGroup">
|
||||||
<TaskRow
|
<TaskRow
|
||||||
v-for="child in props.task.children"
|
v-for="child in props.task.children"
|
||||||
:key="child.id"
|
:key="child.id"
|
||||||
@@ -480,6 +481,7 @@ onUnmounted(() => {
|
|||||||
:on-hover="props.onHover"
|
:on-hover="props.onHover"
|
||||||
:columns="props.columns"
|
:columns="props.columns"
|
||||||
:get-column-width-style="props.getColumnWidthStyle"
|
:get-column-width-style="props.getColumnWidthStyle"
|
||||||
|
:disable-children-render="props.disableChildrenRender"
|
||||||
@toggle="emit('toggle', $event)"
|
@toggle="emit('toggle', $event)"
|
||||||
@dblclick="emit('dblclick', $event)"
|
@dblclick="emit('dblclick', $event)"
|
||||||
@start-timer="emit('start-timer', $event)"
|
@start-timer="emit('start-timer', $event)"
|
||||||
|
|||||||
+95
-18
@@ -157,15 +157,20 @@ const dayWidth = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取任务数据的日期范围(用于月度视图时间轴范围计算)
|
type TaskDateRange = { minDate: Date; maxDate: Date } | null
|
||||||
const getTasksDateRange = () => {
|
let cachedTaskDateRange: TaskDateRange = null
|
||||||
|
|
||||||
|
const invalidateTaskDateRangeCache = () => {
|
||||||
|
cachedTaskDateRange = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const computeTasksDateRange = (): TaskDateRange => {
|
||||||
if (!tasks.value || tasks.value.length === 0) {
|
if (!tasks.value || tasks.value.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const dates: Date[] = []
|
const dates: Date[] = []
|
||||||
|
|
||||||
// 收集所有任务的开始和结束日期
|
|
||||||
const collectDatesFromTask = (task: Task) => {
|
const collectDatesFromTask = (task: Task) => {
|
||||||
if (task.startDate) {
|
if (task.startDate) {
|
||||||
dates.push(new Date(task.startDate))
|
dates.push(new Date(task.startDate))
|
||||||
@@ -174,7 +179,6 @@ const getTasksDateRange = () => {
|
|||||||
dates.push(new Date(task.endDate))
|
dates.push(new Date(task.endDate))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 递归处理子任务 - 使用 for 循环代替 forEach
|
|
||||||
if (task.children && task.children.length > 0) {
|
if (task.children && task.children.length > 0) {
|
||||||
for (const child of task.children) {
|
for (const child of task.children) {
|
||||||
collectDatesFromTask(child)
|
collectDatesFromTask(child)
|
||||||
@@ -182,7 +186,6 @@ const getTasksDateRange = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 for...of 循环代替 forEach
|
|
||||||
for (const task of tasks.value) {
|
for (const task of tasks.value) {
|
||||||
collectDatesFromTask(task)
|
collectDatesFromTask(task)
|
||||||
}
|
}
|
||||||
@@ -191,7 +194,6 @@ const getTasksDateRange = () => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤有效日期并直接获取最小/最大时间戳
|
|
||||||
let minTime = Infinity
|
let minTime = Infinity
|
||||||
let maxTime = -Infinity
|
let maxTime = -Infinity
|
||||||
for (const date of dates) {
|
for (const date of dates) {
|
||||||
@@ -206,10 +208,20 @@ const getTasksDateRange = () => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const minDate = new Date(minTime)
|
return {
|
||||||
const maxDate = new Date(maxTime)
|
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 HOUR_WIDTH = 40 // 每小时40px
|
||||||
const VIRTUAL_BUFFER = 10 // 减少缓冲区以提升滑动性能
|
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>()
|
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 => {
|
const debounce = <T extends (...args: unknown[]) => void>(func: T, wait: number): T => {
|
||||||
let timeout: number | null = null
|
let timeout: number | null = null
|
||||||
@@ -717,12 +758,12 @@ const debounce = <T extends (...args: unknown[]) => void>(func: T, wait: number)
|
|||||||
// 优化的滚动处理器(增加防抖时间到 50ms)
|
// 优化的滚动处理器(增加防抖时间到 50ms)
|
||||||
const debouncedUpdatePositions = debounce(() => {
|
const debouncedUpdatePositions = debounce(() => {
|
||||||
computeAllMilestonesPositions()
|
computeAllMilestonesPositions()
|
||||||
}, 50)
|
}, 200)
|
||||||
|
|
||||||
// 虚拟渲染:防抖更新 Canvas 位置(滚动时触发)
|
// 虚拟渲染:防抖更新 Canvas 位置(滚动时触发)
|
||||||
const debouncedUpdateCanvasPosition = debounce(() => {
|
const debouncedUpdateCanvasPosition = debounce(() => {
|
||||||
updateSvgSize() // 重新计算 Canvas 位置和尺寸
|
updateSvgSize() // 重新计算 Canvas 位置和尺寸
|
||||||
}, 50)
|
}, 200)
|
||||||
|
|
||||||
// 缓存时间轴数据的函数
|
// 缓存时间轴数据的函数
|
||||||
const getCachedTimelineData = (): unknown => {
|
const getCachedTimelineData = (): unknown => {
|
||||||
@@ -1938,17 +1979,20 @@ const svgHeight = ref(0)
|
|||||||
const canvasWidth = ref(0)
|
const canvasWidth = ref(0)
|
||||||
const canvasHeight = ref(0)
|
const canvasHeight = ref(0)
|
||||||
const canvasOffsetLeft = ref(0) // Canvas 在全局坐标系中的偏移量
|
const canvasOffsetLeft = ref(0) // Canvas 在全局坐标系中的偏移量
|
||||||
|
const canvasOffsetTop = ref(0)
|
||||||
|
|
||||||
// 虚拟渲染 Canvas 的安全宽度(防止超过浏览器限制)
|
// 虚拟渲染 Canvas 的安全宽度(防止超过浏览器限制)
|
||||||
// 可根据实际需求调整:
|
// 可根据实际需求调整:
|
||||||
// - 5000: 最小内存 (~30MB),适合低端设备,但滚动时更频繁更新
|
// - 5000: 最小内存 (~30MB),适合低端设备,但滚动时更频繁更新
|
||||||
// - 10000: 平衡选择 (~60MB),覆盖小时视图 10 天,周视图 2 年
|
// - 10000: 平衡选择 (~60MB),覆盖小时视图 10 天,周视图 2 年
|
||||||
const SAFE_CANVAS_WIDTH = 5000 // 平衡性能和覆盖范围
|
const SAFE_CANVAS_WIDTH = 5000 // 平衡性能和覆盖范围
|
||||||
|
const SAFE_CANVAS_HEIGHT = 5000
|
||||||
|
|
||||||
function updateSvgSize() {
|
function updateSvgSize() {
|
||||||
if (bodyContentRef.value) {
|
if (bodyContentRef.value) {
|
||||||
// 获取 bodyContent 的总宽度和可视区域宽度
|
// 获取 bodyContent 的总宽度和可视区域宽度
|
||||||
const totalWidth = bodyContentRef.value.offsetWidth
|
const totalWidth = bodyContentRef.value.offsetWidth
|
||||||
|
const totalHeight = contentHeight.value
|
||||||
|
|
||||||
// 使用已经维护的 timelineScrollLeft,而不是从 DOM 重新读取
|
// 使用已经维护的 timelineScrollLeft,而不是从 DOM 重新读取
|
||||||
// 因为 handleTimelineScroll 已经实时更新了这个值
|
// 因为 handleTimelineScroll 已经实时更新了这个值
|
||||||
@@ -1975,9 +2019,23 @@ function updateSvgSize() {
|
|||||||
|
|
||||||
canvasOffsetLeft.value = idealOffsetLeft
|
canvasOffsetLeft.value = idealOffsetLeft
|
||||||
|
|
||||||
|
const clampedHeight = Math.min(totalHeight, SAFE_CANVAS_HEIGHT)
|
||||||
|
canvasHeight.value = clampedHeight
|
||||||
svgWidth.value = canvasWidth.value
|
svgWidth.value = canvasWidth.value
|
||||||
svgHeight.value = contentHeight.value
|
svgHeight.value = clampedHeight
|
||||||
canvasHeight.value = contentHeight.value
|
|
||||||
|
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,
|
height: payload.height,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
updateSvgSize()
|
setTimeout(() => {
|
||||||
|
updateSvgSize()
|
||||||
|
}, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 向上传递 TaskBar 拖拽/拉伸事件
|
// 向上传递 TaskBar 拖拽/拉伸事件
|
||||||
@@ -2162,6 +2222,12 @@ onMounted(() => {
|
|||||||
// 处理TaskList垂直滚动同步
|
// 处理TaskList垂直滚动同步
|
||||||
const handleTaskListVerticalScroll = (event: CustomEvent) => {
|
const handleTaskListVerticalScroll = (event: CustomEvent) => {
|
||||||
const { scrollTop } = event.detail
|
const { scrollTop } = event.detail
|
||||||
|
|
||||||
|
// 立即更新纵向滚动位置(用于虚拟滚动计算)
|
||||||
|
timelineBodyScrollTop.value = scrollTop
|
||||||
|
|
||||||
|
debouncedUpdateCanvasPosition()
|
||||||
|
|
||||||
if (timelineBodyElement.value && Math.abs(timelineBodyElement.value.scrollTop - scrollTop) > 1) {
|
if (timelineBodyElement.value && Math.abs(timelineBodyElement.value.scrollTop - scrollTop) > 1) {
|
||||||
// 使用更精确的比较,避免1px以内的细微差异导致的循环触发
|
// 使用更精确的比较,避免1px以内的细微差异导致的循环触发
|
||||||
timelineBodyElement.value.scrollTop = scrollTop
|
timelineBodyElement.value.scrollTop = scrollTop
|
||||||
@@ -2175,6 +2241,11 @@ const handleTimelineBodyScroll = (event: Event) => {
|
|||||||
|
|
||||||
const scrollTop = target.scrollTop
|
const scrollTop = target.scrollTop
|
||||||
|
|
||||||
|
// 立即更新纵向滚动位置(用于虚拟滚动计算)
|
||||||
|
timelineBodyScrollTop.value = scrollTop
|
||||||
|
|
||||||
|
debouncedUpdateCanvasPosition()
|
||||||
|
|
||||||
// 拖拽时不同步滚动事件,避免性能问题
|
// 拖拽时不同步滚动事件,避免性能问题
|
||||||
if (isDragging.value) return
|
if (isDragging.value) return
|
||||||
|
|
||||||
@@ -2701,6 +2772,7 @@ const convertTaskToMilestone = (task: Task): Milestone => {
|
|||||||
watch(
|
watch(
|
||||||
() => tasks.value.length,
|
() => tasks.value.length,
|
||||||
() => {
|
() => {
|
||||||
|
invalidateTaskDateRangeCache()
|
||||||
computeAllMilestonesPositions()
|
computeAllMilestonesPositions()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -2749,6 +2821,9 @@ const updateTimelineRange = () => {
|
|||||||
watch(
|
watch(
|
||||||
() => tasks.value?.length,
|
() => tasks.value?.length,
|
||||||
(newLength, oldLength) => {
|
(newLength, oldLength) => {
|
||||||
|
if (newLength !== oldLength) {
|
||||||
|
invalidateTaskDateRangeCache()
|
||||||
|
}
|
||||||
// 当任务从无到有时,重新计算时间范围
|
// 当任务从无到有时,重新计算时间范围
|
||||||
if (oldLength === 0 && newLength > 0) {
|
if (oldLength === 0 && newLength > 0) {
|
||||||
debouncedUpdateTimelineRange(50)
|
debouncedUpdateTimelineRange(50)
|
||||||
@@ -2789,7 +2864,7 @@ watch([timelineData, timelineContainerWidth], () => {
|
|||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
updateSvgSize()
|
updateSvgSize()
|
||||||
}, 50)
|
}, 200)
|
||||||
})
|
})
|
||||||
taskBarRenderTimer = null
|
taskBarRenderTimer = null
|
||||||
}, 100)
|
}, 100)
|
||||||
@@ -2799,6 +2874,7 @@ watch([timelineData, timelineContainerWidth], () => {
|
|||||||
watch(
|
watch(
|
||||||
() => tasks.value,
|
() => tasks.value,
|
||||||
newTasks => {
|
newTasks => {
|
||||||
|
invalidateTaskDateRangeCache()
|
||||||
// 优化:使用 for 循环直接构建 Set,避免 map 创建临时数组
|
// 优化:使用 for 循环直接构建 Set,避免 map 创建临时数组
|
||||||
const currentTaskIds = new Set<number>()
|
const currentTaskIds = new Set<number>()
|
||||||
for (const task of newTasks) {
|
for (const task of newTasks) {
|
||||||
@@ -3327,6 +3403,7 @@ const handleAddSuccessor = (task: Task) => {
|
|||||||
:width="canvasWidth"
|
:width="canvasWidth"
|
||||||
:height="canvasHeight"
|
:height="canvasHeight"
|
||||||
:offset-left="canvasOffsetLeft"
|
:offset-left="canvasOffsetLeft"
|
||||||
|
:offset-top="canvasOffsetTop"
|
||||||
:highlighted-task-id="highlightedTaskId"
|
:highlighted-task-id="highlightedTaskId"
|
||||||
:highlighted-task-ids="highlightedTaskIds"
|
:highlighted-task-ids="highlightedTaskIds"
|
||||||
:hovered-task-id="hoveredTaskId"
|
:hovered-task-id="hoveredTaskId"
|
||||||
@@ -3510,13 +3587,13 @@ const handleAddSuccessor = (task: Task) => {
|
|||||||
<!-- 同时需要考虑左侧TaskList包含1px的bottom border -->
|
<!-- 同时需要考虑左侧TaskList包含1px的bottom border -->
|
||||||
<div class="task-bar-container" :style="{ height: `${contentHeight}px` }">
|
<div class="task-bar-container" :style="{ height: `${contentHeight}px` }">
|
||||||
<div class="task-rows" :style="{ height: `${contentHeight}px` }">
|
<div class="task-rows" :style="{ height: `${contentHeight}px` }">
|
||||||
<!-- 使用v-memo减少渲染 -->
|
<!-- 使用虚拟滚动渲染可见任务 -->
|
||||||
<div
|
<div
|
||||||
v-for="(task, index) in tasks"
|
v-for="{ task, originalIndex } in visibleTasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
class="task-row"
|
class="task-row"
|
||||||
:class="{ 'task-row-hovered': hoveredTaskId === task.id }"
|
:class="{ 'task-row-hovered': hoveredTaskId === task.id }"
|
||||||
:style="{ top: `${index * 51}px` }"
|
:style="{ top: `${originalIndex * 51}px` }"
|
||||||
@mouseenter="handleTaskRowHover(task.id)"
|
@mouseenter="handleTaskRowHover(task.id)"
|
||||||
@mouseleave="handleTaskRowHover(null)"
|
@mouseleave="handleTaskRowHover(null)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -240,6 +240,29 @@ const messages = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
disableTaskbarFocusMode: '关闭聚焦功能',
|
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': {
|
'en-US': {
|
||||||
dateNotSet: 'Not set',
|
dateNotSet: 'Not set',
|
||||||
@@ -478,6 +501,29 @@ const messages = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
disableTaskbarFocusMode: 'Disable Focus Mode',
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user