This commit is contained in:
LINING-PC\lining
2025-10-11 15:11:19 +08:00
26 changed files with 2376 additions and 238 deletions

View File

@@ -136,6 +136,7 @@ jordium-gantt-vue3/
| `useDefaultDrawer` | `boolean` | `true` | Use default edit drawer |
| `showToolbar` | `boolean` | `true` | Show toolbar |
| `toolbarConfig` | `ToolbarConfig` | `{}` | Toolbar configuration |
| `taskListConfig` | `TaskListConfig` | `{}` | Task list configuration (including default width, min/max width limits, etc.) |
| `localeMessages` | `Partial<Messages['zh-CN']>` | - | Custom locale messages |
| `workingHours` | `WorkingHours` | - | Working hours configuration |
| `onTaskDoubleClick` | `(task: Task) => void` | - | Task double-click event callback |
@@ -299,6 +300,31 @@ interface ToolbarConfig {
}
```
**TaskListConfig**
```typescript
interface TaskListConfig {
columns?: TaskListColumnConfig[] // Column configuration array
showAllColumns?: boolean // Show all columns, default true
defaultWidth?: number // Default expanded width in pixels, default 320px
minWidth?: number // Minimum width in pixels, default 280px, cannot be less than 280px
maxWidth?: number // Maximum width in pixels, default 1160px
}
interface TaskListColumnConfig {
type?: TaskListColumnType // Column type
key: string // Key for internationalization, also used as identifier
label?: string // Display label
cssClass?: string // CSS class name
width?: number // Optional column width
visible?: boolean // Whether to display, default true
}
type TaskListColumnType =
| 'name' | 'predecessor' | 'assignee'
| 'startDate' | 'endDate' | 'estimatedHours'
| 'actualHours' | 'progress'
```
**WorkingHours Configuration**
```typescript
interface WorkingHours {
@@ -445,6 +471,13 @@ const milestones = ref([
type: 'milestone'
}
])
// TaskList width configuration example
const taskListConfig = {
defaultWidth: 400, // Default expanded width 400px (default 320px)
minWidth: 300, // Minimum width 300px (default 280px)
maxWidth: 1200 // Maximum width 1200px (default 1160px)
}
</script>
<template>
@@ -452,6 +485,7 @@ const milestones = ref([
<GanttChart
:tasks="tasks"
:milestones="milestones"
:task-list-config="taskListConfig"
/>
</div>
</template>

View File

@@ -137,6 +137,7 @@ jordium-gantt-vue3/
| `useDefaultDrawer` | `boolean` | `true` | 是否使用默认编辑抽屉 |
| `showToolbar` | `boolean` | `true` | 是否显示工具栏 |
| `toolbarConfig` | `ToolbarConfig` | `{}` | 工具栏配置 |
| `taskListConfig` | `TaskListConfig` | `{}` | 任务列表配置(包括默认宽度、最小最大宽度限制等) |
| `localeMessages` | `Partial<Messages['zh-CN']>` | - | 自定义多语言配置 |
| `workingHours` | `WorkingHours` | - | 工作时间配置 |
| `onTaskDoubleClick` | `(task: Task) => void` | - | 任务双击事件回调 |
@@ -313,6 +314,31 @@ interface ToolbarConfig {
}
```
**TaskListConfig 任务列表配置**
```typescript
interface TaskListConfig {
columns?: TaskListColumnConfig[] // 列配置数组
showAllColumns?: boolean // 是否显示所有列默认true
defaultWidth?: number // 默认展开宽度单位像素默认320px
minWidth?: number // 最小宽度单位像素默认280px不能小于280px
maxWidth?: number // 最大宽度单位像素默认1160px
}
interface TaskListColumnConfig {
type?: TaskListColumnType // 列类型
key: string // 用于国际化的key也可以作为识别符
label?: string // 显示标签
cssClass?: string // CSS类名
width?: number // 可选的列宽度
visible?: boolean // 是否显示默认true
}
type TaskListColumnType =
| 'name' | 'predecessor' | 'assignee'
| 'startDate' | 'endDate' | 'estimatedHours'
| 'actualHours' | 'progress'
```
**WorkingHours 工作时间配置**
```typescript
interface WorkingHours {
@@ -459,6 +485,13 @@ const milestones = ref([
type: 'milestone'
}
])
// TaskList宽度配置示例
const taskListConfig = {
defaultWidth: 400, // 默认展开宽度400px默认320px
minWidth: 300, // 最小宽度300px默认280px
maxWidth: 1200 // 最大宽度1200px默认1160px
}
</script>
<template>
@@ -466,6 +499,7 @@ const milestones = ref([
<GanttChart
:tasks="tasks"
:milestones="milestones"
:task-list-config="taskListConfig"
/>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
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'
@@ -8,10 +8,12 @@ import packageInfo from '../package.json'
// 导入主题变量
import '../src/styles/theme-variables.css'
import VersionHistoryDrawer from './VersionHistoryDrawer.vue'
import HtmlContent from './HtmlContent.vue'
import { useMessage } from '../src/composables/useMessage'
import { useI18n } from '../src/composables/useI18n'
import { getPredecessorIds, predecessorIdsToString } from '../src/utils/predecessorUtils'
import type { Task } from '../src/models/Task'
import type { TaskListConfig, TaskListColumnConfig } from '../src/models/configs/TaskListConfig'
const { showMessage } = useMessage()
const { t, formatTranslation } = useI18n()
@@ -37,10 +39,54 @@ const toolbarConfig = {
showTheme: true,
showFullscreen: true,
showTimeScale: true, // 控制日|周|月时间刻度按钮组的可见性
timeScaleDimensions: ['hour', 'day', 'week', 'month', 'quarter', 'year'], // 设置时间刻度按钮的展示维度,包含所有时间维度
timeScaleDimensions: ['day', 'week', 'month', 'quarter', 'year'], // 设置时间刻度按钮的展示维度,包含所有时间维度
defaultTimeScale: 'week',
showExpandCollapse: true, // 显示全部展开/折叠按钮
}
// 工作时间配置示例
// TaskList列配置
const availableColumns = ref<TaskListColumnConfig[]>([
// { key: 'predecessor', label: '前置任务', visible: true },
// { key: 'assignee', label: '负责人', visible: true },
{ key: 'startDate', label: '开始日期', visible: true },
// { key: 'endDate', label: '结束日期', visible: true },
// { key: 'estimatedHours', label: '预估工时', visible: true },
// { key: 'actualHours', label: '实际工时', visible: true },
// { key: 'progress', label: '进度', visible: true },
])
// TaskList宽度配置
const taskListWidth = ref({
defaultWidth: 450, // 默认宽度400px比默认320px更宽
minWidth: 300, // 最小宽度300px比默认280px略大
maxWidth: 1200, // 最大宽度1200px比默认1160px略大
})
const taskListConfig = computed<TaskListConfig>(() => ({
columns: availableColumns.value,
defaultWidth: taskListWidth.value.defaultWidth,
minWidth: taskListWidth.value.minWidth,
maxWidth: taskListWidth.value.maxWidth,
}))
// 配置面板折叠状态
const isConfigPanelCollapsed = ref(false)
// 切换配置面板折叠状态
const toggleConfigPanel = () => {
isConfigPanelCollapsed.value = !isConfigPanelCollapsed.value
}
// 切换列显示状态
const toggleColumn = (columnKey: string, event: Event) => {
const target = event.target as HTMLInputElement
const visible = target?.checked ?? false
const column = availableColumns.value.find(col => col.key === columnKey)
if (column) {
column.visible = visible
}
} // 工作时间配置示例
const workingHoursConfig = {
morning: { start: 8, end: 11 }, // 上午8:00-11:59为工作时间
afternoon: { start: 13, end: 17 }, // 下午13:00-17:00为工作时间
@@ -126,14 +172,14 @@ const handleMilestoneDelete = async (milestoneId: number) => {
window.dispatchEvent(
new CustomEvent('milestone-deleted', {
detail: { milestoneId },
}),
})
)
// 触发强制更新事件确保Timeline重新渲染
window.dispatchEvent(
new CustomEvent('milestone-data-changed', {
detail: { milestones: milestones.value },
}),
})
)
}
@@ -227,7 +273,7 @@ const handleTaskUpdate = (updatedTask: Task) => {
showMessage(
formatTranslation('newParentTaskNotFound', { parentId: taskToAdd.parentId }),
'warning',
{ closable: true },
{ closable: true }
)
tasks.value.push(taskToAdd)
}
@@ -275,7 +321,7 @@ const handleTaskAdd = (newTask: Task) => {
const maxId = Math.max(
...tasks.value.map(t => t.id || 0),
...milestones.value.map(m => m.id || 0),
0,
0
)
newTask.id = maxId + 1
}
@@ -370,7 +416,7 @@ const handleStoryDeleteWithChildren = (storyToDelete: Task) => {
'success',
{
closable: false,
},
}
)
return true
}
@@ -432,7 +478,7 @@ const handleStoryDeleteOnly = (storyToDelete: Task) => {
'success',
{
closable: false,
},
}
)
return true
}
@@ -506,7 +552,7 @@ function handleTaskbarDragOrResizeEnd(newTask) {
`开始: ${oldTask.startDate}${newTask.startDate}\n` +
`结束: ${oldTask.endDate}${newTask.endDate}`,
'info',
{ closable: true },
{ closable: true }
)
}
function handleMilestoneDragEnd(newMilestone) {
@@ -516,7 +562,7 @@ function handleMilestoneDragEnd(newMilestone) {
`里程碑【${newMilestone.name}\n` +
`开始: ${oldMilestone.endDate}${newMilestone.startDate}`,
'info',
{ closable: true },
{ closable: true }
)
}
@@ -583,7 +629,7 @@ function onTimerStarted(task: Task) {
showMessage(
`Demo 任务【${task.name}\n开始计时${new Date(task.timerStartTime).toLocaleString()}\n计时说明${task.timerStartDesc ? task.timerStartDesc : ''}`,
'info',
{ closable: true },
{ closable: true }
)
}
function onTimerStopped(task: Task) {
@@ -597,6 +643,10 @@ function onTimerStopped(task: Task) {
}
showMessage(msg, 'info', { closable: true })
}
function taskDebug(item: any) {
console.log('Task Debug:', item)
}
</script>
<template>
@@ -631,11 +681,143 @@ function onTimerStopped(task: Task) {
</div>
</h1>
<VersionHistoryDrawer :visible="showVersionDrawer" @close="showVersionDrawer = false" />
<!-- TaskList配置面板 - 可折叠 -->
<div class="config-panel" :class="{ collapsed: isConfigPanelCollapsed }">
<div class="config-header" @click="toggleConfigPanel">
<h3 class="config-title">
<svg
class="config-icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M3 6h18v2H3V6zm0 5h18v2H3v-2zm0 5h18v2H3v-2z" fill="currentColor" />
</svg>
TaskList 配置
</h3>
<button class="collapse-button" :class="{ collapsed: isConfigPanelCollapsed }">
<svg
class="collapse-icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="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="!isConfigPanelCollapsed" class="config-content">
<!-- 宽度配置区域 -->
<div class="config-section">
<h4 class="section-title">
<svg
class="section-icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="6"
width="18"
height="12"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
<path
d="M8 12h8M8 9l-2 3 2 3M16 9l2 3-2 3"
stroke="currentColor"
stroke-width="1.5"
fill="none"
/>
</svg>
宽度设置
</h4>
<div class="width-controls">
<div class="width-control">
<label class="width-label">默认宽度:</label>
<input
v-model.number="taskListWidth.defaultWidth"
type="number"
:min="taskListWidth.minWidth"
:max="taskListWidth.maxWidth"
step="10"
class="width-input"
/>
<span class="width-unit">px</span>
</div>
<div class="width-control">
<label class="width-label">最小宽度:</label>
<input
v-model.number="taskListWidth.minWidth"
type="number"
min="280"
:max="taskListWidth.defaultWidth"
step="10"
class="width-input"
/>
<span class="width-unit">px</span>
</div>
<div class="width-control">
<label class="width-label">最大宽度:</label>
<input
v-model.number="taskListWidth.maxWidth"
type="number"
:min="taskListWidth.defaultWidth"
max="2000"
step="10"
class="width-input"
/>
<span class="width-unit">px</span>
</div>
</div>
</div>
<!-- 列配置区域 -->
<div class="config-section">
<h4 class="section-title">
<svg
class="section-icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M3 6h18v2H3V6zm0 5h18v2H3v-2zm0 5h18v2H3v-2z" fill="currentColor" />
</svg>
列显示
</h4>
<div class="column-controls">
<label v-for="column in availableColumns" :key="column.key" class="column-control">
<input
type="checkbox"
:checked="column.visible"
@change="toggleColumn(column.key, $event)"
/>
<span class="column-label">{{ column.label }}</span>
</label>
</div>
</div>
</div>
</transition>
</div>
<div class="gantt-wrapper">
<GanttChart
:tasks="tasks"
:milestones="milestones"
:toolbar-config="toolbarConfig"
:task-list-config="taskListConfig"
:working-hours="workingHoursConfig"
:on-add-task="handleAddTask"
:on-add-milestone="handleAddMilestone"
@@ -666,7 +848,11 @@ function onTimerStopped(task: Task) {
@task-deleted="e => showMessage(`Demo 任务[${e.task.name}] 已删除`, 'info')"
@task-added="e => showMessage(`Demo 任务[${e.task.name}] 已创建`, 'info')"
@task-updated="e => showMessage(`Demo 任务[${e.task.name}] 已更新`, 'info')"
/>
>
<template #custom-task-content="item">
<HtmlContent :item="taskDebug(item)" :task="item.task" :type="item.type" />
</template>
</GanttChart>
</div>
<div class="license-info">
<p>MIT License @JORDIUM.COM</p>
@@ -695,6 +881,217 @@ function onTimerStopped(task: Task) {
flex-direction: column;
}
/* TaskList列配置面板样式 - 可折叠 */
.config-panel {
background: var(--gantt-bg-primary, #ffffff);
border: 1px solid var(--gantt-border-color, #e4e7ed);
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
overflow: hidden;
}
.config-panel:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.config-panel.collapsed {
border-radius: 8px;
}
.config-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
border-bottom: 1px solid var(--gantt-border-color, #e4e7ed);
}
.config-panel.collapsed .config-header {
border-bottom: none;
}
.config-header:hover {
background-color: var(--gantt-hover-bg, #f8f9fa);
}
.config-content {
padding: 0 16px 16px;
overflow: hidden;
}
.collapse-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
color: var(--gantt-text-secondary, #666);
}
.collapse-button:hover {
background-color: var(--gantt-hover-bg, #e8f4fd);
color: var(--gantt-primary-color, #409eff);
}
.collapse-icon {
width: 20px;
height: 20px;
transition: transform 0.3s ease;
}
.collapse-button.collapsed .collapse-icon {
transform: rotate(-90deg);
}
/* 过渡动画 */
.config-content-enter-active,
.config-content-leave-active {
transition: all 0.3s ease;
overflow: hidden;
}
.config-content-enter-from,
.config-content-leave-to {
height: 0;
opacity: 0;
}
.config-content-enter-to,
.config-content-leave-from {
opacity: 1;
}
.config-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--gantt-text-primary, #333);
display: flex;
align-items: center;
gap: 8px;
}
.config-icon {
width: 20px;
height: 20px;
color: var(--gantt-primary-color, #409eff);
}
.config-section {
margin-bottom: 24px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-color);
}
.section-icon {
width: 16px;
height: 16px;
color: var(--primary-color);
}
.width-controls {
display: flex;
flex-direction: row;
gap: 12px;
}
.width-control {
display: flex;
align-items: center;
gap: 8px;
}
.width-label {
flex: 0 0 80px;
font-size: 13px;
color: var(--text-secondary);
}
.width-input {
flex: 1;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--input-background, #fff);
color: var(--text-primary);
font-size: 13px;
max-width: 100px;
}
.width-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.width-unit {
flex: 0 0 20px;
font-size: 12px;
color: var(--text-tertiary);
}
.column-controls {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.column-control {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 6px 12px;
border-radius: 6px;
transition: all 0.2s ease;
background: var(--gantt-bg-secondary, #f8f9fa);
border: 1px solid transparent;
}
.column-control:hover {
background: var(--gantt-hover-bg, #e8f4fd);
border-color: var(--gantt-primary-color, #409eff);
}
.column-control input[type='checkbox'] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--gantt-primary-color, #409eff);
}
.column-label {
font-size: 14px;
font-weight: 500;
color: var(--gantt-text-primary, #333);
user-select: none;
transition: color 0.2s ease;
}
.column-control:hover .column-label {
color: var(--gantt-primary-color, #409eff);
}
.page-title {
margin: 20px 0;
font-size: 1.8rem;
@@ -816,6 +1213,50 @@ function onTimerStopped(task: Task) {
color: #e5e5e5 !important;
}
/* 暗色主题下的配置面板样式 */
:global(html[data-theme='dark']) .config-panel {
background: var(--gantt-bg-primary, #2d3748);
border-color: var(--gantt-border-color, #4a5568);
}
:global(html[data-theme='dark']) .config-title {
color: var(--gantt-text-primary, #e2e8f0);
}
:global(html[data-theme='dark']) .column-control {
background: var(--gantt-bg-secondary, #1a202c);
}
:global(html[data-theme='dark']) .column-control:hover {
background: var(--gantt-hover-bg, #2d3748);
}
:global(html[data-theme='dark']) .column-label {
color: var(--gantt-text-primary, #e2e8f0);
}
:global(html[data-theme='dark']) .column-control:hover .column-label {
color: var(--gantt-primary-color, #66b3ff);
}
/* 暗色主题下的折叠面板样式 */
:global(html[data-theme='dark']) .config-header {
border-bottom-color: var(--gantt-border-color, #4a5568);
}
:global(html[data-theme='dark']) .config-header:hover {
background-color: var(--gantt-hover-bg, #2d3748);
}
:global(html[data-theme='dark']) .collapse-button {
color: var(--gantt-text-secondary, #a0aec0);
}
:global(html[data-theme='dark']) .collapse-button:hover {
background-color: var(--gantt-hover-bg, #2d3748);
color: var(--gantt-primary-color, #66b3ff);
}
/* 暗黑模式下的版本标签 */
:global(html[data-theme='dark']) .version-badge {
background: linear-gradient(135deg, #1a73e8 0%, #00bcd4 50%, #3f51b5 100%);

44
demo/HtmlContent.vue Normal file
View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { Task } from '../src/models/Task'
interface Props {
task: Task,
type: string, // 'task-row' | 'task-bar'
}
const props = withDefaults(defineProps<Props>(), {
})
// console.error('props', props)
</script>
<template>
<div class="html-content-card">
<div v-if="type==='task-row'" class="task-row" v-html="task.name" />
<div v-else-if="type==='task-bar'" class="task-bar" v-html="task.name" />
</div>
</template>
<style scoped>
.html-content-card {
display: inline-block;
}
.task-row {
flex: 1;
padding: 0 4px;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-bar {
flex: 1;
padding: 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn {
height: 20px;
margin-left: 20px;
}
</style>

239
demo/TaskListConfigDemo.vue Normal file
View File

@@ -0,0 +1,239 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { GanttChart } from '../src/index'
import type { Task } from '../src/models/classes/Task'
import type { TaskListConfig, TaskListColumnConfig } from '../src/models/configs/TaskListConfig'
// 示例任务数据
const tasks = ref<Task[]>([
{
id: 1,
name: '项目启动',
startDate: '2024-01-15',
endDate: '2024-01-20',
progress: 100,
assignee: '张三',
estimatedHours: 40,
actualHours: 35,
predecessor: null,
type: 'task',
},
{
id: 2,
name: '需求分析',
startDate: '2024-01-21',
endDate: '2024-01-30',
progress: 80,
assignee: '李四',
estimatedHours: 80,
actualHours: 75,
predecessor: [1],
type: 'task',
},
{
id: 3,
name: '系统设计',
startDate: '2024-02-01',
endDate: '2024-02-15',
progress: 60,
assignee: '王五',
estimatedHours: 120,
actualHours: 90,
predecessor: [3],
type: 'task',
},
{
id: 4,
name: '开发实现',
startDate: '2024-02-16',
endDate: '2024-03-30',
progress: 30,
assignee: '赵六',
estimatedHours: 200,
actualHours: 80,
predecessor: [3],
type: 'task',
},
] as Task[])
// 可用的列配置
const availableColumns = reactive<TaskListColumnConfig[]>([
{ key: 'predecessor', label: '前置任务', visible: true },
{ key: 'assignee', label: '负责人', visible: true },
{ key: 'startDate', label: '开始日期', visible: true },
{ key: 'endDate', label: '结束日期', visible: true },
{ key: 'estimatedHours', label: '预估工时', visible: true },
{ key: 'actualHours', label: '实际工时', visible: true },
{ key: 'progress', label: '进度', visible: true },
])
// 任务列表配置
const taskListConfig = reactive<TaskListConfig>({
columns: availableColumns,
})
// 切换列显示状态
const toggleColumn = (columnKey: string, visible: boolean) => {
const column = availableColumns.find(col => col.key === columnKey)
if (column) {
column.visible = visible
}
}
</script>
<template>
<div class="demo-container">
<h1>TaskList 列配置演示</h1>
<div class="config-section">
<h3>列配置:</h3>
<div class="column-controls">
<label v-for="column in availableColumns" :key="column.key" class="column-control">
<input
type="checkbox"
:checked="column.visible"
@change="toggleColumn(column.key, $event.target.checked)"
/>
{{ column.label }}
</label>
</div>
</div>
<div class="gantt-container">
<GanttChart
:tasks="tasks"
:task-list-config="taskListConfig"
:show-toolbar="false"
/>
</div>
</div>
</template>
import { ref, reactive } from 'vue'
import { GanttChart } from '../src/index'
import type { Task } from '../src/models/classes/Task'
import type { TaskListConfig, TaskListColumnConfig } from '../src/models/configs/TaskListConfig'
// 示例任务数据
const tasks = ref<Task[]>([
{
id: 1,
name: '项目启动',
startDate: '2024-01-15',
endDate: '2024-01-20',
progress: 100,
assignee: '张三',
estimatedHours: 40,
actualHours: 35,
predecessor: null,
type: 'task'
},
{
id: 2,
name: '需求分析',
startDate: '2024-01-21',
endDate: '2024-01-30',
progress: 80,
assignee: '李四',
estimatedHours: 80,
actualHours: 75,
predecessor: [1],
type: 'task'
},
{
id: 3,
name: '系统设计',
startDate: '2024-02-01',
endDate: '2024-02-15',
progress: 60,
assignee: '王五',
estimatedHours: 120,
actualHours: 90,
predecessor: [2],
type: 'task'
},
{
id: 4,
name: '开发实现',
startDate: '2024-02-16',
endDate: '2024-03-30',
progress: 30,
assignee: '赵六',
estimatedHours: 200,
actualHours: 80,
predecessor: [3],
type: 'task'
}
] as Task[])
// 可用的列配置
const availableColumns = reactive<TaskListColumnConfig[]>([
{ key: 'predecessor', label: '前置任务', visible: true },
{ key: 'assignee', label: '负责人', visible: true },
{ key: 'startDate', label: '开始日期', visible: true },
{ key: 'endDate', label: '结束日期', visible: true },
{ key: 'estimatedHours', label: '预估工时', visible: true },
{ key: 'actualHours', label: '实际工时', visible: true },
{ key: 'progress', label: '进度', visible: true }
])
// 任务列表配置
const taskListConfig = reactive<TaskListConfig>({
columns: availableColumns
})
// 切换列显示状态
const toggleColumn = (columnKey: string, visible: boolean) => {
const column = availableColumns.find(col => col.key === columnKey)
if (column) {
column.visible = visible
}
}
</script>
<style scoped>
.demo-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.config-section {
background: #f5f5f5;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.column-controls {
display: flex;
flex-wrap: wrap;
gap: 15px;
}
.column-control {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.column-control input[type="checkbox"] {
cursor: pointer;
}
.gantt-container {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
height: 400px;
}
h1 {
color: #333;
margin-bottom: 20px;
}
h3 {
color: #666;
margin: 0 0 10px 0;
}
</style>

View File

@@ -27,7 +27,7 @@
"children": [
{
"id": 1101,
"name": "试验方案设计与伦理审查",
"name": "试验方案设计与<span style='font-weight: bold;color:red;'>伦理审查</span>",
"assignee": "方案设计师 李明",
"startDate": "2025-01-01",
"endDate": "2025-02-28",
@@ -84,7 +84,7 @@
"children": [
{
"id": 1201,
"name": "多中心试验启动",
"name": "多中心试验<span style='font-weight: bold; color: blue;'>启动</span>",
"assignee": "项目经理 王芳",
"startDate": "2025-09-01",
"endDate": "2025-11-30",

78
demo/test-result.html Normal file
View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TaskList 列配置演示</title>
</head>
<body>
<div id="app"></div>
<script type="module">
// 模拟测试数据
const testData = {
availableColumns: [
{ key: 'predecessor', label: '前置任务', visible: true },
{ key: 'assignee', label: '负责人', visible: true },
{ key: 'startDate', label: '开始日期', visible: true },
{ key: 'endDate', label: '结束日期', visible: true },
{ key: 'estimatedHours', label: '预估工时', visible: false },
{ key: 'actualHours', label: '实际工时', visible: false },
{ key: 'progress', label: '进度', visible: true }
]
}
// 创建配置界面
const app = document.getElementById('app')
app.innerHTML = `
<div style="padding: 20px; font-family: Arial, sans-serif;">
<h2>🎉 TaskList 列配置功能实现完成!</h2>
<div style="background: #f5f5f5; padding: 15px; border-radius: 8px; margin: 20px 0;">
<h3>✅ 已完成的功能</h3>
<ul>
<li><strong>Timeline Scale 定制化</strong>:支持只显示"周/月/年",支持默认时间刻度</li>
<li><strong>TaskList 列配置</strong>:支持外部传入配置控制列的显示/隐藏</li>
<li><strong>类型安全</strong>:完整的 TypeScript 类型支持</li>
<li><strong>响应式设计</strong>:配置变更时 UI 自动更新</li>
<li><strong>向后兼容</strong>:未传配置时使用默认显示</li>
</ul>
</div>
<div style="background: #e8f4fd; padding: 15px; border-radius: 8px; margin: 20px 0;">
<h3>🎮 演示说明</h3>
<p>在 <code>demo/App.vue</code> 中已经添加了完整的列配置控制界面:</p>
<ol>
<li>运行 <code>npm run dev</code> 启动演示应用</li>
<li>在页面顶部可以看到 "TaskList 列配置" 面板</li>
<li>通过复选框控制各列的显示/隐藏</li>
<li>配置实时生效,甘特图 TaskList 部分会相应更新</li>
</ol>
</div>
<div style="background: #f0f9ff; padding: 15px; border-radius: 8px; margin: 20px 0;">
<h3>📝 使用方式</h3>
<pre style="background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 6px; overflow-x: auto;"><code>// 在组件中配置 TaskList 列显示
const taskListConfig = {
columns: [
{ key: 'predecessor', label: '前置任务', visible: false },
{ key: 'assignee', label: '负责人', visible: true },
{ key: 'startDate', label: '开始日期', visible: true },
{ key: 'endDate', label: '结束日期', visible: true },
{ key: 'estimatedHours', label: '预估工时', visible: false },
{ key: 'actualHours', label: '实际工时', visible: false },
{ key: 'progress', label: '进度', visible: true }
]
}
// 传递给 GanttChart 组件
&lt;GanttChart :task-list-config="taskListConfig" /&gt;</code></pre>
</div>
<div style="text-align: center; margin: 30px 0;">
<h3 style="color: #67c23a;">🎊 功能完成,可以开始使用了!</h3>
<p style="color: #909399;">现在可以根据不同业务场景灵活控制 TaskList 中显示的列</p>
</div>
</div>
`
</script>
</body>
</html>

View File

@@ -0,0 +1,307 @@
# 周视图跨月问题记录
## 问题概述
当甘特图使用周视图(Week View)时,存在多个关键问题影响用户体验,主要围绕跨月显示和任务定位的准确性。
## 已识别的核心问题
### 1. 任务对齐问题 ⚠️
**问题描述**:周视图显示时,任务条(task bar)不能与表头上的日期正确对齐,会超出边界跨到别的月份区域。
**具体表现**
- 任务开始日期为10月1日但任务条显示在9月区域
- 任务条位置与表头日期不匹配
- 视觉上造成混乱,用户难以准确判断任务的实际时间范围
**根本原因**
- 位置计算算法没有考虑跨月周的特殊情况
- 周的归属月份判定逻辑不合理(以周一所在月份为准)
### 2. 拖拽跳动问题 ⚠️
**问题描述**:在周视图中拖动任务跨月时,会导致任务长度异常跳动。
**具体表现**
- 拖拽过程中任务条长度突然变化
- 拖拽结束后任务位置与预期不符
- 跨月边界时拖拽体验不连贯
**技术原因**
- 正向位置计算与反向位置计算逻辑不一致
- 缺少 `calculateDateFromPixelPosition` 反向计算函数
- 拖拽过程中的像素-日期转换存在精度问题
### 3. 月份边界限制问题 ⚠️
**问题描述**:由于周的定义以周一为起始,导致某些月份的前几天无法在该月份区域内操作。
**典型场景**
- 2025年10月6日是周一该周包含9月29日-10月5日
- 当前实现中10月1-5日无法在10月区域显示和操作
- 用户无法将任务拖拽到10月的前5天
**设计缺陷**
- 周的归属完全基于周一所在月份
- 没有考虑部分周显示的需求
- Timeline数据结构不支持跨月周的多重显示
### 4. 全视图刷新性能问题 ✅ (已解决)
**问题描述**:每次更新单个任务都导致整个甘特图视图重新渲染。
**解决方案**:移除深度监听,改用引用监听。
## 技术分析
### 当前架构限制
1. **Timeline.vue 数据结构**
- 周数据按月份分组,每个周只属于一个月份
- 跨月的周只在周一所在月份显示
- 缺少跨月周的分片显示机制
2. **TaskBar.vue 位置计算**
- 缺少跨月位置映射函数
- 没有反向像素-日期计算逻辑
- 拖拽时的边界处理不完善
### 需要的核心功能
#### Timeline 数据重构
- [ ] 支持周在多个月份中同时显示
- [ ] 每个月份只显示属于该月的日期部分
- [ ] 保持现有的60px周宽度标准
- [ ] 新增 `displayMonth``displayYear` 标识
#### 位置计算算法
- [ ] `findTargetMonthPosition()`:查找目标月份显示区域
- [ ] `calculateDateFromPixelPosition()`:像素位置反向计算日期
- [ ] 跨月拖拽的平滑过渡处理
- [ ] 边界条件的鲁棒性处理
## 已尝试的解决方案
### 方案1Timeline数据结构改造 ❌ (已废弃)
**实施状态**:部分完成,后续回滚
**具体改动**
```javascript
// 修改 generateWeekTimelineData 函数
// 允许跨月周在多个月份中显示
for (const month of monthsToDisplay) {
results.push({
...week,
displayMonth: month,
displayYear: year
});
}
```
**遇到的问题**
- TypeScript类型错误
- spread操作符类型兼容性问题
- 可能的性能影响
- 过度工程化,增加了系统复杂性
### 方案2TaskBar位置计算增强 ❌ (已废弃)
**实施状态**:部分完成,后续回滚
**具体改动**
- 新增 `findTargetMonthPosition` 函数
- 实现 `calculateDateFromPixelPosition` 反向计算
- 修改拖拽事件处理逻辑
**遇到的问题**
- 复杂度增加
- 边界情况处理不完善
- 与现有代码集成困难
- 维护成本高
### 方案3背景色标记方案 ⚠️ (已废弃)
**实施状态**:已完成但存在问题
**问题**:背景色遮挡了 week-label 的显示,影响用户体验
### 方案4旗帜标记方案 ✅ (当前实施)
**实施状态**:已完成并构建成功
**核心理念**
- 使用绝对定位的旗帜标记,避免遮挡文字
- 在 timeline-month 容器中添加旗帜元素
- 保持视觉清晰,不影响现有布局
**具体改动**
```vue
<!-- timeline-month 中添加旗帜标记 -->
<template v-if="month.isWeekView && month.weeks">
<template v-for="(week, weekIndex) in month.weeks">
<template v-for="(subDay, dayIndex) in week.subDays || []">
<div
v-if="subDay.date && subDay.date.getDate() === 1"
class="month-first-flag"
:style="{ left: `${weekIndex * 60 + dayIndex * (60/7)}px` }"
>
<div class="flag-pole"></div>
<div class="flag-content">1</div>
</div>
</template>
</template>
</template>
```
```css
/* 旗帜标记样式 */
.month-first-flag {
position: absolute;
top: -8px;
z-index: 10;
}
.flag-pole {
width: 1px;
height: 20px;
background-color: var(--gantt-primary, #409eff);
}
.flag-content {
background-color: var(--gantt-primary, #409eff);
color: white;
font-size: 10px;
padding: 1px 4px;
border-radius: 2px;
}
```
**优势**
- 不遮挡任何现有文字内容
- 视觉效果突出且美观
- 精确定位到每月1号的位置
- 支持暗色主题
- 代码清晰,易于维护
## 推荐的解决路径
### Phase 1: 基础重构 ❌ (已废弃)
1. ~~简化Timeline数据结构支持跨月显示~~
2. ~~实现基本的位置计算函数~~
3. ~~确保类型安全和向后兼容~~
### 新方案: UI视觉优化 ✅ (已实施)
**核心思路**:这不是逻辑问题,而是交互呈现问题。通过视觉提示让用户理解跨月周的含义。
**实施方案**
1. 在周视图表头的每月1号位置添加特殊标记
2. 使用颜色区分和边框线突出显示月份边界
3. 保持现有数据结构和算法不变
**技术实现**
- 修改 Timeline.vue 模板,为 `week-sub-day` 添加 `month-first-day`
- 添加CSS样式蓝色背景和左边框标记每月1号
- 同时在表头和背景列中应用相同的视觉提示
## 当前状态
**最新更新**2025-09-25
**问题状态**
- ✅ 全视图刷新问题已解决
- ✅ 月份边界显示问题已通过旗帜标记解决
- ⚠️ 任务对齐问题需进一步验证
- ⚠️ 拖拽跳动问题需进一步验证
**旗帜标记方案效果**
- 用户可以清晰看到每月1号在周视图中的精确位置
- 旗帜不遮挡任何现有内容,保持布局清爽
- 跨月周的理解变得直观且美观
- 无需复杂的数据结构重构
- 保持了代码的简洁性和性能
- 支持主题切换,视觉效果一致
## 测试用例
### 场景1旗帜标识测试 ✅ (已升级)
- 测试内容周视图中每月1号的旗帜标记
- 预期每月1号位置有蓝色旗帜标记不遮挡文字
- 实施结果:✅ 已实现旗帜方案,构建成功,视觉效果优秀
### 场景210月边界显示测试
- 时间2025年10月1-5日周一是10月6日
- 预期能看到10月1日在周视图中的明确标记
- 当前结果:✅ 通过视觉标记解决了理解问题
### 场景3跨月拖拽测试
- 操作将任务从9月30日拖拽到10月1日
- 预期:平滑过渡,无长度跳动
- 当前结果:⚠️ 需要进一步测试验证
### 场景4对齐精度测试
- 检查:任务条与表头日期的对齐精度
- 预期:像素级精确对齐
- 当前结果:⚠️ 需要进一步测试验证
### 场景5用户理解测试
- 测试内容:用户能否快速理解跨月周的含义
- 预期通过月份1号标记用户能直观理解日期归属
- 实施结果:✅ 视觉提示清晰明了
## 最终实施方案总结
### 方案选择的智慧
经过多次复杂方案的尝试和回滚,我们最终选择了最简洁有效的解决路径:
**从复杂到简单的思维转变**
1. **原始想法**:重构数据结构,实现复杂的跨月显示逻辑
2. **问题重新定义**:这是用户体验问题,不是技术逻辑问题
3. **优雅解决**:通过视觉提示让用户理解现有逻辑
### 实施细节
#### 最终代码修改内容 (旗帜方案)
1. **Timeline.vue模板修改**
-`timeline-month` 容器中添加旗帜标记元素
- 使用嵌套 template 遍历周和日期数据
- 精确计算每月1号的位置`weekIndex * 60 + dayIndex * (60/7)`
2. **CSS样式设计**
```css
.month-first-flag {
position: absolute;
top: -8px;
z-index: 10;
}
.flag-pole {
width: 1px;
height: 20px;
background-color: var(--gantt-primary, #409eff);
}
.flag-content {
background-color: var(--gantt-primary, #409eff);
color: white;
font-size: 10px;
padding: 1px 4px;
border-radius: 2px;
}
```
3. **主题适配**
- 完整支持暗色主题
- 使用CSS变量确保主题一致性
- 添加阴影效果提升视觉层次
### 旗帜方案优势
1. **视觉清晰**:旗帜标记突出,不遮挡任何内容
2. **精确定位**准确标记每月1号的具体位置
3. **美观设计**:类似旗帜的设计符合用户直觉
4. **性能优秀**使用CSS定位无JavaScript计算开销
5. **主题友好**:完整支持亮色和暗色主题
6. **易于维护**:代码结构清晰,样式独立
7. **向后兼容**:不影响现有功能和布局
### 经验教训
1. **问题定义很关键**:重新审视问题本质比盲目优化更重要
2. **简单往往更好**:复杂方案不一定是最佳方案
3. **用户视角思考**:站在用户角度理解问题,而不只是技术角度
4. **迭代优于革命**:渐进式改进比颠覆式重构更安全
---
*此文档记录了从问题发现、复杂方案尝试到最终优雅解决的完整过程,为后续类似问题提供参考*

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jordium-gantt-vue3",
"version": "1.1.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jordium-gantt-vue3",
"version": "1.1.0",
"version": "1.3.0",
"license": "MIT",
"dependencies": {
"date-fns": "^4.1.0",

View File

@@ -50,7 +50,7 @@
"dev:demo": "vite",
"build": "vue-tsc -b && vite build",
"build:demo": "vue-tsc -b && vite build",
"build:lib": "vite build --config vite.config.lib.ts",
"build:lib": "vite build --config vite.config.lib.ts && node scripts/generate-css-export.js",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
// ES模块中的__dirname等价物
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// 构建后生成CSS导出文件
function generateCssExport() {
const distDir = path.resolve(__dirname, '../npm-package/dist')
const cssFile = path.join(distDir, 'assets/jordium-gantt-vue3.css')
const outputFile = path.join(distDir, 'jordium-gantt-vue3-styles.js')
try {
if (fs.existsSync(cssFile)) {
const cssContent = fs.readFileSync(cssFile, 'utf8')
// 生成JS导出文件
const jsContent = `// CSS styles for jordium-gantt-vue3
// Auto-generated file - do not edit manually
export const ganttStyles = ${JSON.stringify(cssContent)}
export default ganttStyles
`
fs.writeFileSync(outputFile, jsContent, 'utf8')
console.log('✅ Generated CSS export file:', outputFile)
} else {
console.warn('⚠️ CSS file not found:', cssFile)
}
} catch (error) {
console.error('❌ Failed to generate CSS export:', error)
process.exit(1)
}
}
// 运行
generateCssExport()

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, defineProps, defineEmits, computed } from 'vue'
import { ref, watch, computed } from 'vue'
import { useI18n } from '../composables/useI18n'
const props = defineProps({
visible: Boolean,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onUnmounted, onMounted, computed, watch, nextTick, defineEmits } from 'vue'
import { ref, onUnmounted, onMounted, computed, watch, nextTick } from 'vue'
import TaskList from './TaskList.vue'
import Timeline from './Timeline.vue'
import GanttToolbar from './GanttToolbar.vue'
@@ -11,6 +11,7 @@ import html2canvas from 'html2canvas'
import type { Task } from '../models/classes/Task'
import type { Milestone } from '../models/classes/Milestone'
import type { ToolbarConfig } from '../models/configs/ToolbarConfig'
import type { TaskListConfig } from '../models/configs/TaskListConfig'
import { TimelineScale } from '../models/types/TimelineScale'
import { useMessage } from '../composables/useMessage'
@@ -36,11 +37,15 @@ const props = withDefaults(defineProps<Props>(), {
onLanguageChange: undefined,
onThemeChange: undefined,
onFullscreenChange: undefined,
onExpandAll: undefined,
onCollapseAll: undefined,
localeMessages: undefined,
workingHours: () => ({
morning: { start: 8, end: 11 },
afternoon: { start: 13, end: 17 },
}),
taskListConfig: undefined,
autoSortByStartDate: false,
})
const emit = defineEmits([
@@ -94,6 +99,8 @@ interface Props {
onLanguageChange?: (lang: 'zh-CN' | 'en-US') => void
onThemeChange?: (isDark: boolean) => void
onFullscreenChange?: (isFullscreen: boolean) => void
onExpandAll?: () => void
onCollapseAll?: () => void
/**
* 自定义多语言(国际化)配置,结构参考内置 messages['zh-CN'],只需传递需要覆盖的 key 即可。
* 例如:
@@ -107,9 +114,41 @@ interface Props {
morning?: { start: number; end: number } // 上午工作时间,如 { start: 8, end: 11 }
afternoon?: { start: number; end: number } // 下午工作时间,如 { start: 13, end: 17 }
}
// 任务列表配置
taskListConfig?: TaskListConfig
// 是否启用自动排序(根据开始时间排序任务)
autoSortByStartDate?: boolean
}
const leftPanelWidth = ref(320)
// 使用taskListConfig中的默认宽度如果未配置则使用320px
const leftPanelWidth = ref(props.taskListConfig?.defaultWidth || 320)
// 监听taskListConfig变化更新相关配置
watch(
() => props.taskListConfig,
(newConfig) => {
if (newConfig) {
// 更新默认宽度
if (newConfig.defaultWidth !== undefined) {
leftPanelWidth.value = newConfig.defaultWidth
}
// 更新最小最大宽度限制
ganttPanelLeftMinWidth.value = getTaskListMinWidth()
taskListBodyWidth.value = getTaskListMaxWidth()
taskListBodyProposedWidth.value = getTaskListMaxWidth()
taskListBodyWidthLimit.value = getTaskListMaxWidth()
ganttPanelLeftCurrentWidth.value = getTaskListMinWidth()
// 确保当前宽度在新的限制范围内
const adjustedWidth = checkWidthLimits(leftPanelWidth.value)
if (adjustedWidth !== leftPanelWidth.value) {
leftPanelWidth.value = adjustedWidth
}
}
},
{ immediate: false, deep: true },
)
// Timeline组件的引用
const timelineRef = ref<InstanceType<typeof Timeline> | null>(null)
@@ -117,19 +156,39 @@ const timelineRef = ref<InstanceType<typeof Timeline> | null>(null)
// 时间刻度状态
const currentTimeScale = ref<TimelineScale>(TimelineScale.DAY)
watch(
() => timelineRef.value,
newTimeline => {
if (newTimeline) {
newTimeline.updateTimeScale(currentTimeScale.value)
}
},
)
// TaskList的固定总长度所有列的最小宽度之和 + 边框等额外空间)
// 列宽: 300+120+120+140+140+100+100+100 = 1120px
// 边框: 7个列间边框 * 1px = 7px
// 滚动条预留: 20px
// 额外边距: 13px (task-list-header的左边距3px + 其他10px预留)
const TASK_LIST_MAX_WIDTH = 1120 + 7 + 20 + 13 // = 1160px
const TASK_LIST_MIN_WIDTH = 320 // TaskList最小宽度
const DEFAULT_TASK_LIST_MAX_WIDTH = 1120 + 7 + 20 + 13 // = 1160px
const DEFAULT_TASK_LIST_MIN_WIDTH = 280 // 最小宽度不能小于280px
const taskListBodyWidth = ref(TASK_LIST_MAX_WIDTH) // TaskList默认宽度
const ganttPanelLeftMinWidth = ref(TASK_LIST_MIN_WIDTH) // 左侧面板最小宽度
const ganttPanelLeftCurrentWidth = ref(TASK_LIST_MIN_WIDTH) // 当前左侧面板宽度
const taskListBodyProposedWidth = ref(TASK_LIST_MAX_WIDTH)
const taskListBodyWidthLimit = ref(TASK_LIST_MAX_WIDTH)
// TaskList最小宽度支持通过taskListConfig配置
const getTaskListMinWidth = () => {
const configMinWidth = props.taskListConfig?.minWidth || DEFAULT_TASK_LIST_MIN_WIDTH
return Math.max(configMinWidth, DEFAULT_TASK_LIST_MIN_WIDTH) // 确保不小于280px
}
// TaskList最大宽度支持通过taskListConfig配置
const getTaskListMaxWidth = () => {
return props.taskListConfig?.maxWidth || DEFAULT_TASK_LIST_MAX_WIDTH
}
const taskListBodyWidth = ref(getTaskListMaxWidth()) // TaskList默认宽度
const ganttPanelLeftMinWidth = ref(getTaskListMinWidth()) // 左侧面板最小宽度
const ganttPanelLeftCurrentWidth = ref(getTaskListMinWidth()) // 当前左侧面板宽度
const taskListBodyProposedWidth = ref(getTaskListMaxWidth())
const taskListBodyWidthLimit = ref(getTaskListMaxWidth())
// 简化的限制检查函数:直接基于面板实际宽度判断
const checkWidthLimits = (proposedLeftWidth: number): number => {
@@ -208,11 +267,11 @@ function onMouseDown(e: MouseEvent) {
document.body.style.webkitUserSelect = ''
document.body.style.cursor = ''
taskListBodyWidth.value = TASK_LIST_MAX_WIDTH // TaskList默认宽度
ganttPanelLeftMinWidth.value = TASK_LIST_MIN_WIDTH // 左侧面板最小宽度
taskListBodyProposedWidth.value = TASK_LIST_MAX_WIDTH
taskListBodyWidthLimit.value = TASK_LIST_MAX_WIDTH
ganttPanelLeftCurrentWidth.value = TASK_LIST_MIN_WIDTH // 当前左侧面板宽度
taskListBodyWidth.value = getTaskListMaxWidth() // TaskList默认宽度
ganttPanelLeftMinWidth.value = getTaskListMinWidth() // 左侧面板最小宽度
taskListBodyProposedWidth.value = getTaskListMaxWidth()
taskListBodyWidthLimit.value = getTaskListMaxWidth()
ganttPanelLeftCurrentWidth.value = getTaskListMinWidth() // 当前左侧面板宽度
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
@@ -333,6 +392,52 @@ const handleTaskCollapseChange = (task: Task) => {
updateTaskTrigger.value++
}
// 全部展开任务
const handleExpandAll = () => {
if (props.onExpandAll && typeof props.onExpandAll === 'function') {
props.onExpandAll()
} else {
// 默认行为:递归展开所有任务
const expandAllTasks = (tasks: Task[]): void => {
tasks.forEach(task => {
if (task.children && task.children.length > 0) {
task.collapsed = false
expandAllTasks(task.children)
}
})
}
if (props.tasks) {
expandAllTasks(props.tasks)
// 触发Timeline重新计算
updateTaskTrigger.value++
}
}
}
// 全部折叠任务
const handleCollapseAll = () => {
if (props.onCollapseAll && typeof props.onCollapseAll === 'function') {
props.onCollapseAll()
} else {
// 默认行为:递归折叠所有任务
const collapseAllTasks = (tasks: Task[]): void => {
tasks.forEach(task => {
if (task.children && task.children.length > 0) {
task.collapsed = true
collapseAllTasks(task.children)
}
})
}
if (props.tasks) {
collapseAllTasks(props.tasks)
// 触发Timeline重新计算
updateTaskTrigger.value++
}
}
}
// 用于强制触发Timeline重新计算的响应式值
const updateTaskTrigger = ref(0)
@@ -455,10 +560,54 @@ const tasksForTaskList = computed(() => {
// 添加原始任务数据(完全保持层级结构,不扁平化)
if (props.tasks && props.tasks.length > 0) {
result.push(...props.tasks)
}
// 根据配置决定是否排序
if (props.autoSortByStartDate) {
// 递归排序函数:根据实际开始时间排序
const sortTasksByStartDate = (tasks: Task[]): Task[] => {
return [...tasks]
.map(task => {
// 递归处理子任务
const sortedTask = { ...task }
if (task.children && task.children.length > 0) {
sortedTask.children = sortTasksByStartDate(task.children)
}
return sortedTask
})
.sort((a, b) => {
// 获取实际开始时间(考虑子任务的最早时间)
const getEarliestStartDate = (task: Task): Date => {
// 如果有子任务,找子任务中的最早时间
if (task.children && task.children.length > 0) {
const childDates = task.children
.map(child => getEarliestStartDate(child))
.filter(date => date.getTime() > 0) // 过滤无效日期
return result
if (childDates.length > 0) {
return new Date(Math.min(...childDates.map(d => d.getTime())))
}
}
// 没有子任务或子任务都没有时间,使用自身时间
return task.startDate ? new Date(task.startDate) : new Date('9999-12-31')
}
const dateA = getEarliestStartDate(a)
const dateB = getEarliestStartDate(b)
// 按时间排序时间相同时按ID排序
const timeDiff = dateA.getTime() - dateB.getTime()
return timeDiff !== 0 ? timeDiff : a.id - b.id
})
}
// 启用排序:对任务进行递归排序
const sortedTasks = sortTasksByStartDate(props.tasks)
result.push(...sortedTasks)
} else {
// 不排序:直接使用原始任务数据
result.push(...props.tasks)
}
} return result
})
// 为Timeline提供正确的扁平化数据
@@ -1515,7 +1664,11 @@ function handleTaskDelete(task: Task, deleteChildren?: boolean) {
:on-theme-change="props.onThemeChange"
:on-fullscreen-change="props.onFullscreenChange"
:on-time-scale-change="handleTimeScaleChange"
:on-expand-all="handleExpandAll"
:on-collapse-all="handleCollapseAll"
@add-task="handleToolbarAddTask"
@expand-all="handleExpandAll"
@collapse-all="handleCollapseAll"
/>
<!-- 甘特图主体 -->
@@ -1530,13 +1683,18 @@ function handleTaskDelete(task: Task, deleteChildren?: boolean) {
:on-task-double-click="props.onTaskDoubleClick"
:edit-component="props.editComponent"
:use-default-drawer="props.useDefaultDrawer"
:task-list-config="props.taskListConfig"
@task-collapse-change="handleTaskCollapseChange"
@start-timer="handleStartTimer"
@stop-timer="handleStopTimer"
@add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor"
@delete="handleTaskDelete"
/>
>
<template v-if="$slots['custom-task-content']" #custom-task-content="rowScope">
<slot name="custom-task-content" v-bind="rowScope" />
</template>
</TaskList>
</div>
<div class="gantt-splitter" @mousedown="onMouseDown">
<!-- TaskList切换按钮 - 贴合splitter右侧 -->
@@ -1584,7 +1742,11 @@ function handleTaskDelete(task: Task, deleteChildren?: boolean) {
@add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor"
@delete="handleTaskDelete"
/>
>
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
<slot name="custom-task-content" v-bind="barScope" />
</template>
</Timeline>
</div>
</div>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue'
import '../styles/app.css'
const props = defineProps({
visible: Boolean,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useI18n } from '../composables/useI18n'
import type { ToolbarConfig } from '../models/configs/ToolbarConfig'
import { TimelineScale } from '../models/types/TimelineScale'
@@ -20,6 +20,8 @@ const props = withDefaults(defineProps<Props>(), {
onFullscreenChange: undefined,
onSettingsConfirm: undefined,
onTimeScaleChange: undefined,
onExpandAll: undefined,
onCollapseAll: undefined,
})
const emit = defineEmits<{
@@ -32,6 +34,8 @@ const emit = defineEmits<{
'theme-change': [isDark: boolean]
'fullscreen-change': [isFullscreen: boolean]
'time-scale-change': [scale: TimelineScale]
'expand-all': []
'collapse-all': []
}>()
// LocalStorage keys
@@ -56,6 +60,8 @@ interface Props {
onThemeChange?: (isDark: boolean) => void
onFullscreenChange?: (isFullscreen: boolean) => void
onTimeScaleChange?: (scale: TimelineScale) => void
onExpandAll?: () => void
onCollapseAll?: () => void
// 外部确认接口
onSettingsConfirm?: (
type: 'theme' | 'language',
@@ -140,6 +146,23 @@ const handleExportPdf = () => {
}
}
// 展开/折叠处理函数
const handleExpandAll = () => {
if (props.onExpandAll && typeof props.onExpandAll === 'function') {
props.onExpandAll()
} else {
emit('expand-all')
}
}
const handleCollapseAll = () => {
if (props.onCollapseAll && typeof props.onCollapseAll === 'function') {
props.onCollapseAll()
} else {
emit('collapse-all')
}
}
// 语言下拉菜单控制
const toggleLanguageDropdown = () => {
showLanguageDropdown.value = !showLanguageDropdown.value
@@ -266,6 +289,44 @@ const handleFullscreenToggle = () => {
}
}
// 时间刻度配置映射
const timeScaleMap = {
hour: { value: TimelineScale.HOUR, label: () => t('timeScaleHour') },
day: { value: TimelineScale.DAY, label: () => t('timeScaleDay') },
week: { value: TimelineScale.WEEK, label: () => t('timeScaleWeek') },
month: { value: TimelineScale.MONTH, label: () => t('timeScaleMonth') },
quarter: { value: TimelineScale.QUARTER, label: () => t('timeScaleQuarter') },
year: { value: TimelineScale.YEAR, label: () => t('timeScaleYear') },
}
type TimeScaleKey = keyof typeof timeScaleMap
const defaultScaleKeys: TimeScaleKey[] = ['hour', 'day', 'week', 'month', 'year']
// 获取可用的时间刻度维度
const availableTimeScales = computed<TimeScaleKey[]>(() => {
const configured = props.config?.timeScaleDimensions as TimeScaleKey[] | undefined
const normalized = (configured ?? defaultScaleKeys).filter(
(scale): scale is TimeScaleKey => scale in timeScaleMap,
)
return normalized.length > 0 ? normalized : [...defaultScaleKeys]
})
// 根据配置解析默认时间刻度
const resolvedDefaultScaleKey = computed<TimeScaleKey>(() => {
const keys = availableTimeScales.value
const candidate = props.config?.defaultTimeScale as TimeScaleKey | undefined
if (candidate && keys.includes(candidate)) {
return candidate
}
if (keys.includes('day')) {
return 'day'
}
return keys[0] ?? 'day'
})
// 时间刻度切换处理
const handleTimeScaleChange = (scale: TimelineScale) => {
currentTimeScale.value = scale
@@ -277,28 +338,26 @@ const handleTimeScaleChange = (scale: TimelineScale) => {
}
}
// 获取可用的时间刻度维度
const availableTimeScales = computed(() => {
const defaultScales = ['hour', 'day', 'week', 'month', 'year'] as const
return props.config?.timeScaleDimensions || defaultScales
})
// 时间刻度配置映射
const timeScaleMap = {
hour: { value: TimelineScale.HOUR, label: () => t('timeScaleHour') },
day: { value: TimelineScale.DAY, label: () => t('timeScaleDay') },
week: { value: TimelineScale.WEEK, label: () => t('timeScaleWeek') },
month: { value: TimelineScale.MONTH, label: () => t('timeScaleMonth') },
quarter: { value: TimelineScale.QUARTER, label: () => t('timeScaleQuarter') },
year: { value: TimelineScale.YEAR, label: () => t('timeScaleYear') },
}
// 监听配置变化并应用默认时间刻度
let hasAppliedConfigScale = false
watch(
() => resolvedDefaultScaleKey.value,
newKey => {
const targetScale = timeScaleMap[newKey].value
if (currentTimeScale.value !== targetScale || !hasAppliedConfigScale) {
hasAppliedConfigScale = true
handleTimeScaleChange(targetScale)
}
},
{ immediate: true },
)
// 获取当前时间刻度对应的字符串键
const currentTimeScaleKey = computed(() => {
const entry = Object.entries(timeScaleMap).find(
([, config]) => config.value === currentTimeScale.value,
const currentTimeScaleKey = computed<TimeScaleKey>(() => {
const matchedKey = availableTimeScales.value.find(
scaleKey => timeScaleMap[scaleKey].value === currentTimeScale.value,
)
return entry ? entry[0] : 'day'
return matchedKey ?? resolvedDefaultScaleKey.value
})
// 计算分段控制器滑块位置
@@ -425,6 +484,45 @@ onUnmounted(() => {
</button>
</div>
<!-- 展开/折叠按钮组 -->
<div
v-if="config.showExpandCollapse !== false"
class="btn-group expand-collapse-btn-group"
>
<button
class="btn-group-item"
:title="t('expandAll')"
@click="handleExpandAll"
>
<svg
class="btn-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
{{ t('expandAll') }}
</button>
<button
class="btn-group-item"
:title="t('collapseAll')"
@click="handleCollapseAll"
>
<svg
class="btn-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
{{ t('collapseAll') }}
</button>
</div>
<!-- 导出按钮组 -->
<div
v-if="config.showExportCsv !== false || config.showExportPdf !== false"

View File

@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
<script setup lang="ts">
import { ref, computed, onUnmounted, onMounted, nextTick, watch } from 'vue'
import { ref, computed, onUnmounted, onMounted, nextTick, watch, useSlots } from 'vue'
import type { Task } from '../models/classes/Task'
import { TimelineScale } from '../models/types/TimelineScale'
import TaskContextMenu from './TaskContextMenu.vue'
@@ -25,6 +25,25 @@ interface Props {
currentTimeScale?: TimelineScale
}
interface TaskStatus {
type: string
color: string
bgColor: string
borderColor: string
}
interface TaskBarSlotProps {
type: string
task: Task
status: TaskStatus
statusType: string
isParent?: boolean
progress: number
currentTimeScale?: TimelineScale
rowHeight: number
dayWidth: number
}
const props = defineProps<Props>()
const emit = defineEmits([
@@ -39,13 +58,21 @@ const emit = defineEmits([
'add-predecessor',
'add-successor',
'delete',
'contextmenu', // 添加原生contextmenu事件声明
'context-menu',
])
defineSlots<{
'custom-task-content'(props: TaskBarSlotProps): unknown
}>()
const slots = useSlots()
const { getTranslation } = useI18n()
const t = (key: string): string => {
return getTranslation(key)
}
const hasContentSlot = computed(() => Boolean(slots['custom-task-content']))
// 日期工具函数 - 处理时区安全的日期创建和操作
const createLocalDate = (dateString: string | Date | undefined | null): Date | null => {
if (!dateString) return null
@@ -130,10 +157,26 @@ const tempTaskData = ref<{
endDate?: string
} | null>(null)
// 季度视图拖拽时的位置覆盖(直接使用像素位置)
const quarterDragOverride = ref<{
left?: number
width?: number
} | null>(null)
const barRef = ref<HTMLElement | null>(null)
// 计算任务条位置和宽度
const taskBarStyle = computed(() => {
// 季度视图拖拽时使用位置覆盖
if (quarterDragOverride.value && props.currentTimeScale === TimelineScale.QUARTER) {
return {
left: `${quarterDragOverride.value.left ?? 0}px`,
width: `${quarterDragOverride.value.width ?? 100}px`,
height: `${props.rowHeight - 10}px`,
top: '4px',
}
}
const currentStartDate = tempTaskData.value?.startDate || props.task.startDate
const currentEndDate = tempTaskData.value?.endDate || props.task.endDate
@@ -336,6 +379,19 @@ const taskStatus = computed(() => {
}
})
// Slot payload for content slot - 使用 v-bind 方式传递所有属性
const slotPayload = computed(() => ({
type: 'task-bar',
task: props.task,
status: taskStatus.value,
statusType: taskStatus.value.type,
isParent: props.isParent ?? false,
progress: props.task.progress || 0,
currentTimeScale: props.currentTimeScale,
rowHeight: props.rowHeight,
dayWidth: props.dayWidth,
}))
// 判断是否已完成
const isCompleted = computed(() => (props.task.progress || 0) >= 100)
@@ -429,6 +485,11 @@ function reportBarPosition() {
}
}
// 拖拽时的实时日期提示框状态
const dragTooltipVisible = ref(false)
const dragTooltipPosition = ref({ x: 0, y: 0 })
const dragTooltipContent = ref({ startDate: '', endDate: '' })
const handleMouseMove = (e: MouseEvent) => {
// 发送边界检测事件给Timeline
window.dispatchEvent(
@@ -440,6 +501,15 @@ const handleMouseMove = (e: MouseEvent) => {
}),
)
// 更新拖拽提示框位置
if (isDragging.value || isResizingLeft.value || isResizingRight.value) {
dragTooltipVisible.value = true
dragTooltipPosition.value = {
x: e.clientX + 15, // 鼠标右侧偏移
y: e.clientY - 60, // 鼠标上方偏移
}
}
if (isDragging.value) {
const deltaX = e.clientX - dragStartX.value
@@ -482,6 +552,52 @@ const handleMouseMove = (e: MouseEvent) => {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
} else if (props.currentTimeScale === TimelineScale.QUARTER) {
// 季度视图:直接使用像素位置,保持拖拽跟随鼠标
const newLeft = Math.max(0, dragStartLeft.value + deltaX)
// 更新位置覆盖
quarterDragOverride.value = {
left: newLeft,
width: dragStartWidth.value,
}
// 同时计算日期用于提示框使用与Timeline相同的季度视图计算
const quarterWidth = 60 // 与Timeline.vue保持一致
const daysInQuarter = 90 // 季度平均天数
const pixelsPerDay = quarterWidth / daysInQuarter // 约0.67px/天
const dayOffset = Math.round(deltaX / pixelsPerDay)
// 基于原始任务日期计算新日期
const originalStartDate = createLocalDate(props.task.startDate) || props.startDate
const originalEndDate = createLocalDate(props.task.endDate) || props.startDate
const newStartDate = new Date(originalStartDate)
newStartDate.setDate(newStartDate.getDate() + dayOffset)
// 计算任务持续天数
const durationMs = originalEndDate.getTime() - originalStartDate.getTime()
const duration = Math.ceil(durationMs / (1000 * 60 * 60 * 24))
const newEndDate = new Date(newStartDate)
newEndDate.setDate(newEndDate.getDate() + Math.max(0, duration))
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
} else {
// 其他视图:保持原有逻辑
const newLeft = Math.max(0, dragStartLeft.value + deltaX)
@@ -494,6 +610,12 @@ const handleMouseMove = (e: MouseEvent) => {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
}
} else if (isResizingLeft.value) {
const deltaX = e.clientX - resizeStartX.value
@@ -519,6 +641,44 @@ const handleMouseMove = (e: MouseEvent) => {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate, // 保持原来的结束日期
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate || '',
}
} else if (props.currentTimeScale === TimelineScale.QUARTER) {
// 季度视图左侧resize直接使用像素位置
const newLeft = Math.max(0, resizeStartLeft.value + deltaX)
const newWidth = Math.max(10, resizeStartWidth.value - deltaX) // 保持最小宽度
// 更新位置覆盖
quarterDragOverride.value = {
left: newLeft,
width: newWidth,
}
// 计算日期用于提示框使用与Timeline相同的季度视图计算
const quarterWidth = 60 // 与Timeline.vue保持一致
const daysInQuarter = 90 // 季度平均天数
const pixelsPerDay = quarterWidth / daysInQuarter // 约0.67px/天
const dayOffset = Math.round(deltaX / pixelsPerDay)
const originalStartDate = createLocalDate(props.task.startDate) || props.startDate
const newStartDate = new Date(originalStartDate)
newStartDate.setDate(newStartDate.getDate() + dayOffset)
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate, // 保持原来的结束日期
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate || '',
}
} else {
// 其他视图:保持原有逻辑
const newLeft = Math.max(0, resizeStartLeft.value + deltaX)
@@ -529,6 +689,12 @@ const handleMouseMove = (e: MouseEvent) => {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate, // 保持原来的结束日期
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate || '',
}
}
} else if (isResizingRight.value) {
const deltaX = e.clientX - resizeStartX.value
@@ -554,6 +720,43 @@ const handleMouseMove = (e: MouseEvent) => {
startDate: props.task.startDate, // 保持原来的开始日期
endDate: formatDateToLocalString(newEndDate),
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: props.task.startDate || '',
endDate: formatDateToLocalString(newEndDate),
}
} else if (props.currentTimeScale === TimelineScale.QUARTER) {
// 季度视图右侧resize直接使用像素位置
const newWidth = Math.max(10, resizeStartWidth.value + deltaX) // 保持最小宽度
// 更新位置覆盖
quarterDragOverride.value = {
left: resizeStartLeft.value,
width: newWidth,
}
// 计算日期用于提示框使用与Timeline相同的季度视图计算
const quarterWidth = 60 // 与Timeline.vue保持一致
const daysInQuarter = 90 // 季度平均天数
const pixelsPerDay = quarterWidth / daysInQuarter // 约0.67px/天
const dayOffset = Math.round(deltaX / pixelsPerDay)
const originalEndDate = createLocalDate(props.task.endDate) || props.startDate
const newEndDate = new Date(originalEndDate)
newEndDate.setDate(newEndDate.getDate() + dayOffset)
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: props.task.startDate, // 保持原来的开始日期
endDate: formatDateToLocalString(newEndDate),
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: props.task.startDate || '',
endDate: formatDateToLocalString(newEndDate),
}
} else {
// 其他视图:保持原有逻辑
const newWidth = Math.max(props.dayWidth, resizeStartWidth.value + deltaX)
@@ -568,11 +771,23 @@ const handleMouseMove = (e: MouseEvent) => {
startDate: props.task.startDate, // 保持原来的开始日期
endDate: formatDateToLocalString(newEndDate),
}
// 更新拖拽提示框内容
dragTooltipContent.value = {
startDate: props.task.startDate || '',
endDate: formatDateToLocalString(newEndDate),
}
}
}
}
const handleMouseUp = () => {
// 隐藏拖拽提示框
dragTooltipVisible.value = false
// 清除季度视图位置覆盖
quarterDragOverride.value = null
// 停止边界检测
window.dispatchEvent(
new CustomEvent('drag-boundary-check', {
@@ -1313,7 +1528,16 @@ onUnmounted(() => {
@dblclick="handleTaskBarDoubleClick"
>
<!-- 父级任务的标签 -->
<div v-if="isParent" class="parent-label">{{ task.name }} ({{ task.progress || 0 }}%)</div>
<div v-if="isParent" class="parent-label">
<slot
v-if="hasContentSlot"
name="custom-task-content"
v-bind="slotPayload"
/>
<template v-else>
{{ task.name }} ({{ task.progress || 0 }}%)
</template>
</div>
<!-- 完成进度条非父级任务 -->
<div
@@ -1340,7 +1564,12 @@ onUnmounted(() => {
@mousedown="e => (isInteractionDisabled ? null : handleMouseDown(e, 'drag'))"
>
<!-- 任务名称 -->
<div class="task-name" :style="getNameStyles()">
<slot
v-if="hasContentSlot"
name="custom-task-content"
v-bind="slotPayload"
/>
<div v-else class="task-name" :style="getNameStyles()">
{{ task.name }}
</div>
@@ -1425,6 +1654,29 @@ onUnmounted(() => {
</div>
</div>
</Teleport>
<!-- 拖拽实时反馈提示框 -->
<Teleport to="body">
<div
v-if="dragTooltipVisible"
class="drag-tooltip"
:style="{
left: `${dragTooltipPosition.x}px`,
top: `${dragTooltipPosition.y}px`,
}"
>
<div class="drag-tooltip-content">
<div class="tooltip-row">
<span class="tooltip-label">{{ t('startDate') }}:</span>
<span class="tooltip-value">{{ formatDisplayDate(dragTooltipContent.startDate) }}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">{{ t('endDate') }}:</span>
<span class="tooltip-value">{{ formatDisplayDate(dragTooltipContent.endDate) }}</span>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
@@ -1864,6 +2116,46 @@ onUnmounted(() => {
color: #ffffff;
}
/* === 拖拽实时反馈提示框样式 === */
.drag-tooltip {
position: fixed;
background: rgba(0, 123, 255, 0.95);
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
z-index: 10001;
box-shadow: 0 2px 12px rgba(0, 123, 255, 0.4);
pointer-events: none;
border: 1px solid rgba(255, 255, 255, 0.2);
backdrop-filter: blur(2px);
}
.drag-tooltip .tooltip-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2px;
}
.drag-tooltip .tooltip-row:last-child {
margin-bottom: 0;
}
.drag-tooltip .tooltip-label {
opacity: 0.9;
min-width: 55px;
font-size: 11px;
}
.drag-tooltip .tooltip-value {
font-weight: 600;
text-align: right;
font-size: 11px;
margin-left: 8px;
}
.sticky-text {
position: absolute;
white-space: nowrap;

View File

@@ -1,14 +1,18 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch, useSlots, computed } from 'vue'
import type { Component } from 'vue'
import TaskRow from './TaskRow.vue'
import { useI18n } from '../composables/useI18n'
import type { Task } from '../models/classes/Task'
import type { TaskListConfig } from '../models/configs/TaskListConfig'
import { DEFAULT_TASK_LIST_COLUMNS } from '../models/configs/TaskListConfig'
interface Props {
tasks?: Task[]
onTaskDoubleClick?: (task: Task) => void
editComponent?: any
editComponent?: Component
useDefaultDrawer?: boolean
taskListConfig?: TaskListConfig
}
const props = defineProps<Props>()
@@ -26,10 +30,20 @@ const emit = defineEmits<{
'add-successor': [task: Task] // 新增:添加后置任务事件
delete: [task: Task, deleteChildren?: boolean]
}>()
const slots = useSlots()
const hasRowSlot = computed(() => Boolean(slots['custom-task-content']))
// 多语言支持
const { t } = useI18n()
// 计算可见的列配置
const visibleColumns = computed(() => {
const columns = props.taskListConfig?.columns || DEFAULT_TASK_LIST_COLUMNS
// 过滤出可见的列visible !== false
return columns.filter(col => col.visible !== false)
})
// 内部响应式任务列表
const localTasks = ref<Task[]>([])
@@ -299,14 +313,12 @@ const handleTaskListScroll = (event: Event) => {
detail: { scrollTop },
}),
)
}
// 处理Timeline垂直滚动同步
}// 处理Timeline垂直滚动同步
const handleTimelineVerticalScroll = (event: CustomEvent) => {
const { scrollTop } = event.detail
const taskListBodyElement = document.querySelector('.task-list-body') as HTMLElement
if (taskListBodyElement && taskListBodyElement.scrollTop !== scrollTop) {
// 避免循环触发只在scrollTop不同时才设置
if (taskListBodyElement && Math.abs(taskListBodyElement.scrollTop - scrollTop) > 1) {
// 使用更精确的比较避免1px以内的细微差异导致的循环触发
taskListBodyElement.scrollTop = scrollTop
}
}
@@ -382,14 +394,20 @@ onUnmounted(() => {
<template>
<div class="task-list">
<div class="task-list-header">
<div class="col col-name">{{ t.taskName }}</div>
<div class="col col-pre">{{ t.predecessor }}</div>
<div class="col col-assignee">{{ t.assignee }}</div>
<div class="col col-date">{{ t.startDate }}</div>
<div class="col col-date">{{ t.endDate }}</div>
<div class="col col-hours">{{ t.estimatedHours }}</div>
<div class="col col-hours">{{ t.actualHours }}</div>
<div class="col col-progress">{{ t.progress }}</div>
<!-- 任务名称列始终显示 -->
<div class="col col-name">
{{ (t as any).taskName || '任务名称' }}
</div>
<!-- 可配置的其他列 -->
<div
v-for="column in visibleColumns"
:key="column.key"
class="col"
:class="column.cssClass || `col-${column.key}`"
:style="column.width ? { width: column.width + 'px' } : undefined"
>
{{ column.label || (t as any)[column.key] }}
</div>
</div>
<div class="task-list-body" @scroll="handleTaskListScroll">
<TaskRow
@@ -401,6 +419,7 @@ onUnmounted(() => {
:hovered-task-id="hoveredTaskId"
:on-double-click="props.onTaskDoubleClick"
:on-hover="handleTaskRowHover"
:columns="visibleColumns"
@toggle="toggleCollapse"
@dblclick="handleTaskRowDoubleClick"
@contextmenu="handleTaskRowContextMenu"
@@ -409,13 +428,18 @@ onUnmounted(() => {
@add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor"
@delete="handleTaskDelete"
/>
>
<template v-if="hasRowSlot" #custom-task-content="rowScope">
<slot name="custom-task-content" v-bind="rowScope" />
</template>
</TaskRow>
</div>
</div>
</template>
<style scoped>
@import '../styles/theme-variables.css';
@import '../styles/list.css';
.task-list {
width: 100%;
@@ -448,17 +472,6 @@ onUnmounted(() => {
z-index: 10; /* 确保在滚动时保持在最上层 */
}
.col {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
border-right: 1px solid var(--gantt-border-light);
box-sizing: border-box;
overflow: hidden;
font-weight: 400;
}
.task-list-header .col {
justify-content: center;
font-weight: 700;
@@ -467,41 +480,6 @@ onUnmounted(() => {
border-right-color: var(--gantt-border-medium);
}
.col:last-child {
border-right: none;
}
.col-name {
flex: 2 0 300px;
max-width: 300px;
justify-content: flex-start;
}
.col-pre {
flex: 1 0 120px;
max-width: 120px;
}
.col-assignee {
flex: 1 0 200px;
max-width: 200px;
}
.col-date {
flex: 1.2 0 140px;
max-width: 140px;
}
.col-hours {
flex: 1 0 100px;
max-width: 100px;
}
.col-progress {
flex: 1 0 100px;
max-width: 100px;
}
.task-list-body {
width: max-content;
background: var(--gantt-bg-primary);

View File

@@ -1,10 +1,33 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { ref, onMounted, onUnmounted, computed, watch, useSlots } from 'vue'
import { useI18n } from '../composables/useI18n'
import { formatPredecessorDisplay } from '../utils/predecessorUtils'
import type { Task } from '../models/classes/Task'
import type { TaskListColumnConfig } from '../models/configs/TaskListConfig'
import TaskContextMenu from './TaskContextMenu.vue'
interface TaskRowSlotProps {
isRowContent: boolean
task: Task
level: number
indent: string
isHovered: boolean
hoveredTaskId: number | null
isParent: boolean
hasChildren: boolean
collapsed: boolean
formattedTimer: string
timerRunning: boolean
timerElapsed: number
isOvertime: number | boolean | undefined
overdueDays: number
overtimeText: string
overdueText: string
daysText: string
progressClass: string
type?: string
}
interface Props {
task: Task
level: number
@@ -12,6 +35,7 @@ interface Props {
isHovered?: boolean
hoveredTaskId?: number | null
onHover?: (taskId: number | null) => void
columns: TaskListColumnConfig[]
}
const props = defineProps<Props>()
const emit = defineEmits([
@@ -24,13 +48,27 @@ const emit = defineEmits([
'add-successor',
'delete',
])
defineSlots<{
'custom-task-content'(props: TaskRowSlotProps): unknown
}>()
const { t } = useI18n()
const overtimeText = computed(() => t.value?.overtime ?? '')
const overdueText = computed(() => t.value?.overdue ?? '')
const daysText = computed(() => t.value?.days ?? '')
const slots = useSlots()
const hasContentSlot = computed(() => Boolean(slots['custom-task-content']))
const baseIndent = 10
const indent = `${baseIndent + props.level * 20}px`
const indent = computed(() => `${baseIndent + props.level * 20}px`)
const hasChildren = computed(() => !!(props.task.children && props.task.children.length > 0))
const isStoryTask = computed(() => props.task.type === 'story')
const isMilestoneGroup = computed(() => props.task.type === 'milestone-group')
const isMilestoneTask = computed(() => props.task.type === 'milestone')
const isParentTask = computed(
() => isStoryTask.value || hasChildren.value || isMilestoneGroup.value,
)
function handleToggle() {
emit('toggle', props.task)
}
@@ -38,8 +76,8 @@ function handleToggle() {
function handleRowClick() {
// 如果是普通父级任务story类型或有子任务的任务非里程碑分组点击行也可以展开/收起
if (
(props.task.type === 'story' || (props.task.children && props.task.children.length > 0)) &&
props.task.type !== 'milestone-group'
(isStoryTask.value || hasChildren.value) &&
!isMilestoneGroup.value
) {
emit('toggle', props.task)
}
@@ -220,6 +258,29 @@ const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
closeContextMenu()
}
const progressClass = computed(() => getProgressClass())
const slotPayload = computed(() => ({
isRowContent: true,
task: props.task,
level: props.level,
indent: indent.value,
isHovered: props.isHovered ?? false,
hoveredTaskId: props.hoveredTaskId ?? null,
isParent: isParentTask.value,
hasChildren: hasChildren.value,
collapsed: !!props.task.collapsed,
formattedTimer: formattedTimer.value,
timerRunning: !!props.task.isTimerRunning,
timerElapsed: timerElapsed.value,
isOvertime: isOvertime(),
overdueDays: overdueDays(),
overtimeText: overtimeText.value,
overdueText: overdueText.value,
daysText: daysText.value,
progressClass: progressClass.value,
}))
// 生命周期钩子 - 注册事件监听器
onMounted(() => {
window.addEventListener('splitter-drag-start', handleSplitterDragStart)
@@ -243,14 +304,11 @@ onUnmounted(() => {
class="task-row"
:class="{
'task-row-hovered': isHovered,
'parent-task':
props.task.type === 'story' ||
(props.task.children && props.task.children.length > 0) ||
props.task.type === 'milestone-group',
'milestone-group-row': props.task.type === 'milestone-group',
'task-type-story': props.task.type === 'story',
'parent-task': isParentTask,
'milestone-group-row': isMilestoneGroup,
'task-type-story': isStoryTask,
'task-type-task': props.task.type === 'task',
'task-type-milestone': props.task.type === 'milestone',
'task-type-milestone': isMilestoneTask
}"
@click="handleRowClick"
@dblclick="handleTaskRowDoubleClick"
@@ -260,11 +318,7 @@ onUnmounted(() => {
>
<div class="col col-name" :style="{ paddingLeft: indent }">
<span
v-if="
(props.task.type === 'story' ||
(props.task.children && props.task.children.length > 0)) &&
props.task.type !== 'milestone-group'
"
v-if="(isStoryTask || hasChildren) && !isMilestoneGroup"
class="collapse-btn"
@click.stop="handleToggle"
>
@@ -282,13 +336,13 @@ onUnmounted(() => {
</span>
<!-- 里程碑分组的占位空间用于与有折叠按钮的任务对齐 -->
<span v-if="props.task.type === 'milestone-group'" class="milestone-spacer"></span>
<span v-if="isMilestoneGroup" class="milestone-spacer"></span>
<!-- 任务图标 -->
<span class="task-icon">
<!-- 里程碑分组图标 - 使用菱形图标 -->
<svg
v-if="props.task.type === 'milestone-group'"
v-if="isMilestoneGroup"
width="16"
height="16"
viewBox="0 0 24 24"
@@ -301,9 +355,7 @@ onUnmounted(() => {
</svg>
<!-- 父级任务图标 -->
<svg
v-else-if="
props.task.type === 'story' || (props.task.children && props.task.children.length > 0)
"
v-else-if="isStoryTask || hasChildren"
width="16"
height="16"
viewBox="0 0 24 24"
@@ -333,15 +385,18 @@ onUnmounted(() => {
<span
class="task-name-text"
:class="{
'parent-task':
props.task.type === 'story' ||
(props.task.children && props.task.children.length > 0) ||
props.task.type === 'milestone-group',
}"
:class="{ 'parent-task': isParentTask }"
:title="props.task.name"
>
{{ props.task.name }}
<slot
v-if="hasContentSlot"
name="custom-task-content"
v-bind="slotPayload"
type="task-row"
/>
<template v-else>
{{ props.task.name }}
</template>
<!-- 计时器显示 -->
<span
v-if="props.task.isTimerRunning || props.task.timerElapsedTime"
@@ -358,41 +413,65 @@ onUnmounted(() => {
</span>
</div>
<!-- 里程碑分组不显示详细信息 -->
<template v-if="props.task.type === 'milestone-group'">
<div class="col col-pre milestone-empty-col"></div>
<div class="col col-assignee milestone-empty-col"></div>
<div class="col col-date milestone-empty-col"></div>
<div class="col col-date milestone-empty-col"></div>
<div class="col col-hours milestone-empty-col"></div>
<div class="col col-hours milestone-empty-col"></div>
<div class="col col-progress milestone-empty-col"></div>
</template>
<!-- 动态渲染列 -->
<div
v-for="column in columns"
:key="column.key"
class="col"
:class="column.cssClass || `col-${column.key}`"
>
<!-- 里程碑分组显示空列 -->
<template v-if="isMilestoneGroup">
<div class="milestone-empty-col"></div>
</template>
<!-- 普通任务显示具体内容 -->
<template v-else>
<!-- 前置任务列 -->
<template v-if="column.key === 'predecessor'">
{{ formatPredecessorDisplay(props.task.predecessor) }}
</template>
<!-- 普通任务显示详细信息 -->
<template v-else>
<div class="col col-pre">{{ formatPredecessorDisplay(props.task.predecessor) }}</div>
<div class="col col-assignee">
<div class="assignee-info">
<div class="avatar">
{{ props.task.assignee ? props.task.assignee.charAt(0) : '-' }}
<!-- 负责人列 -->
<template v-else-if="column.key === 'assignee'">
<div class="assignee-info">
<div class="avatar">
{{ props.task.assignee ? props.task.assignee.charAt(0) : '-' }}
</div>
<span class="assignee-name">{{ props.task.assignee || '-' }}</span>
</div>
<span class="assignee-name">{{ props.task.assignee || '-' }}</span>
</div>
</div>
<div class="col col-date">{{ props.task.startDate || '-' }}</div>
<div class="col col-date">{{ props.task.endDate || '-' }}</div>
<div class="col col-hours">{{ props.task.estimatedHours || '-' }}</div>
<div class="col col-hours">{{ props.task.actualHours || '-' }}</div>
<div class="col col-progress">
<span class="progress-value" :class="getProgressClass()">
{{ props.task.progress != null ? props.task.progress + '%' : '-' }}
</span>
</div>
</template>
</template>
<!-- 开始日期列 -->
<template v-else-if="column.key === 'startDate'">
{{ props.task.startDate || '-' }}
</template>
<!-- 结束日期列 -->
<template v-else-if="column.key === 'endDate'">
{{ props.task.endDate || '-' }}
</template>
<!-- 预估工时列 -->
<template v-else-if="column.key === 'estimatedHours'">
{{ props.task.estimatedHours || '-' }}
</template>
<!-- 实际工时列 -->
<template v-else-if="column.key === 'actualHours'">
{{ props.task.actualHours || '-' }}
</template>
<!-- 进度列 -->
<template v-else-if="column.key === 'progress'">
<span class="progress-value" :class="progressClass">
{{ props.task.progress != null ? props.task.progress + '%' : '-' }}
</span>
</template>
</template>
</div>
</div>
<template
v-if="props.task.children && !props.task.collapsed && props.task.type !== 'milestone-group'"
v-if="hasChildren && !props.task.collapsed && !isMilestoneGroup"
>
<TaskRow
v-for="child in props.task.children"
@@ -403,6 +482,7 @@ onUnmounted(() => {
:hovered-task-id="props.hoveredTaskId"
:on-double-click="props.onDoubleClick"
:on-hover="props.onHover"
:columns="props.columns"
@toggle="emit('toggle', $event)"
@dblclick="emit('dblclick', $event)"
@start-timer="emit('start-timer', $event)"
@@ -410,7 +490,11 @@ onUnmounted(() => {
@add-predecessor="emit('add-predecessor', $event)"
@add-successor="emit('add-successor', $event)"
@delete="handleTaskDelete"
/>
>
<template v-if="hasContentSlot" #custom-task-content="slotProps">
<slot name="custom-task-content" v-bind="(slotProps as TaskRowSlotProps)" />
</template>
</TaskRow>
</template>
<TaskContextMenu
@@ -429,11 +513,13 @@ onUnmounted(() => {
<style scoped>
@import '../styles/theme-variables.css';
@import '../styles/list.css';
.task-row {
display: flex;
border-bottom: 1px solid var(--gantt-border-light);
height: 50px;
height: 51px; /* 修改为51px与Timeline中的task-row高度保持一致包含border-bottom 1px */
box-sizing: border-box; /* 确保border包含在高度计算中 */
background: var(--gantt-bg-primary);
align-items: center;
color: var(--gantt-text-secondary);
@@ -580,51 +666,6 @@ onUnmounted(() => {
margin-right: 4px;
}
.col {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
border-right: 1px solid var(--gantt-border-light);
box-sizing: border-box;
overflow: hidden;
}
.col:last-child {
border-right: none;
}
.col-name {
flex: 2 0 300px;
max-width: 300px;
justify-content: flex-start;
}
.col-pre {
flex: 1 0 120px;
max-width: 120px;
}
.col-assignee {
flex: 1 0 200px;
max-width: 200px;
}
.col-date {
flex: 1.2 0 140px;
max-width: 140px;
}
.col-hours {
flex: 1 0 100px;
max-width: 100px;
}
.col-progress {
flex: 1 0 100px;
max-width: 100px;
}
.task-name-text {
display: inline-block;
max-width: calc(100% - 24px);

View File

@@ -913,6 +913,26 @@ const isWeekContainsToday = (weekStart: Date, weekEnd: Date) => {
return today >= weekStart && today <= weekEnd
}
// 计算周在全局时间轴中的位置(用于旗帜定位)
const getGlobalWeekPosition = (monthIndex: number, weekIndex: number) => {
let position = 0
// 累加前面月份的宽度
for (let i = 0; i < monthIndex; i++) {
const month = timelineData.value[i]
if (month && month.isWeekView && month.weeks) {
position += month.weeks.length * 60
} else if (month && month.days) {
position += month.days.length * 30
}
}
// 加上当前月份内的周位置
position += weekIndex * 60
return position
}
// 更新时间刻度方法 - 供外部调用
const updateTimeScale = (scale: TimelineScale) => {
currentTimeScale.value = scale
@@ -1543,8 +1563,8 @@ onMounted(() => {
const handleTaskListVerticalScroll = (event: CustomEvent) => {
const { scrollTop } = event.detail
const timelineBody = document.querySelector('.timeline-body') as HTMLElement
if (timelineBody && timelineBody.scrollTop !== scrollTop) {
// 避免循环触发只在scrollTop不同时才设置
if (timelineBody && Math.abs(timelineBody.scrollTop - scrollTop) > 1) {
// 使用更精确的比较避免1px以内的细微差异导致的循环触发
timelineBody.scrollTop = scrollTop
}
}
@@ -1612,7 +1632,7 @@ const handleMouseDown = (event: MouseEvent) => {
'input',
'select',
'textarea',
'.task-bar-content',
'.custom-task-content',
'.progress-bar',
'.task-name',
'.task-controls',
@@ -2380,6 +2400,36 @@ const handleAddSuccessor = (task: Task) => {
>
<div class="year-month-label">{{ month.yearMonthLabel }}</div>
</div>
<!-- 月份1号标记旗帜 - 统一放在外层容器 -->
<template
v-for="(month, monthIndex) in timelineData"
:key="`flags-${month.year}-${month.month}`"
>
<template v-if="month.isWeekView && month.weeks">
<template
v-for="(week, weekIndex) in month.weeks"
:key="`flag-${month.year}-${month.month}-${weekIndex}`"
>
<template
v-for="(subDay, dayIndex) in week.subDays || []"
:key="`flagday-${monthIndex}-${weekIndex}-${dayIndex}`"
>
<div
v-if="subDay.date && subDay.date.getDate() === 1"
class="month-first-flag"
:style="{
left: `${getGlobalWeekPosition(monthIndex, weekIndex) + dayIndex * (60/7)}px`,
transform: 'translateX(-50%)' // 使旗帜中心(杆子)对齐日期位置
}"
>
<div class="flag-pole"></div>
<div class="flag-content">{{ subDay.date.getDate() }}</div>
</div>
</template>
</template>
</template>
</template>
</div>
<!-- 第二行:周/日期 -->
@@ -2481,6 +2531,36 @@ const handleAddSuccessor = (task: Task) => {
height: `${contentHeight}px`,
}"
></div>
<!-- 月份1号竖直线周视图 -->
<template v-if="currentTimeScale === TimelineScale.WEEK">
<template
v-for="(month, monthIndex) in timelineData"
:key="`vlines-${month.year}-${month.month}`"
>
<template v-if="month.isWeekView && month.weeks">
<template
v-for="(week, weekIndex) in month.weeks"
:key="`vline-${month.year}-${month.month}-${weekIndex}`"
>
<template
v-for="(subDay, dayIndex) in week.subDays || []"
:key="`vlineday-${monthIndex}-${weekIndex}-${dayIndex}`"
>
<div
v-if="subDay.date && subDay.date.getDate() === 1"
class="month-first-vertical-line"
:style="{
left: `${getGlobalWeekPosition(monthIndex, weekIndex) + dayIndex * (60/7)}px`,
height: `${contentHeight}px`
}"
></div>
</template>
</template>
</template>
</template>
</template>
<!-- 背景列 -->
<div class="day-columns" :style="{ height: `${contentHeight}px` }">
<!-- 小时视图背景列 -->
@@ -2611,7 +2691,7 @@ const handleAddSuccessor = (task: Task) => {
class="sub-day-column"
:class="{
weekend: subDay.dayOfWeek === 0 || subDay.dayOfWeek === 6,
today: isToday(subDay.date),
today: isToday(subDay.date)
}"
:style="{ height: `${contentHeight}px`, width: '8.57px' }"
></div>
@@ -2749,7 +2829,11 @@ const handleAddSuccessor = (task: Task) => {
@add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor"
@delete="handleTaskDelete"
/>
>
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
<slot name="custom-task-content" v-bind="barScope" />
</template>
</TaskBar>
</div>
</div>
</div>
@@ -2834,6 +2918,7 @@ const handleAddSuccessor = (task: Task) => {
.year-month-row {
align-items: center;
position: relative; /* 为旗帜提供定位上下文 */
}
.days-row {
@@ -2978,6 +3063,64 @@ const handleAddSuccessor = (task: Task) => {
/* 不显示边框,仅用于定位计算 */
}
/* 月份1号标记旗帜样式 */
.month-first-flag {
position: absolute;
bottom: -40px;
z-index: 1;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
}
.flag-content {
background-color: var(--gantt-primary, #409eff);
color: white;
font-size: 10px;
font-weight: 600;
padding: 1px 4px;
border-radius: 2px;
text-align: center;
min-width: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
order: 1; /* 旗帜内容在上 */
}
.flag-pole {
width: 1px;
height: 50px;
background-color: var(--gantt-primary, #409eff);
order: 2; /* 旗杆在下 */
}
/* 暗色主题下的旗帜样式 */
:global(html[data-theme='dark']) .flag-pole {
background-color: var(--gantt-primary-light, #66b1ff);
}
:global(html[data-theme='dark']) .flag-content {
background-color: var(--gantt-primary-light, #66b1ff);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
/* 月份1号竖直线样式 */
.month-first-vertical-line {
position: absolute;
top: 0;
width: 1px;
background-color: var(--gantt-primary, #409eff);
opacity: 0.6;
z-index: 5;
pointer-events: none;
}
/* 暗色主题下的竖直线 */
:global(html[data-theme='dark']) .month-first-vertical-line {
background-color: var(--gantt-primary-light, #66b1ff);
opacity: 0.7;
}
/* 周视图背景列样式 */
.month-week-columns {
display: flex;

View File

@@ -84,6 +84,8 @@ const messages = {
todayLocateTooltip: '定位到今天',
exportCsv: '导出 CSV',
exportPdf: '导出 PDF',
expandAll: '全部展开',
collapseAll: '全部折叠',
language: '中文',
languageTooltip: '选择语言',
lightMode: '明亮模式',
@@ -276,6 +278,8 @@ const messages = {
todayLocateTooltip: 'Locate to today',
exportCsv: 'Export CSV',
exportPdf: 'Export PDF',
expandAll: 'Expand All',
collapseAll: 'Collapse All',
language: 'English',
languageTooltip: 'Select language',
lightMode: 'Light Mode',

View File

@@ -10,6 +10,10 @@ export { default as TaskRow } from './components/TaskRow.vue'
export type { Task } from './models/classes/Task.ts' // 导出Task类型
export { useMessage } from './composables/useMessage.ts' // 导出useMessage组合式函数
// 导出配置类型
export type { TaskListConfig, TaskListColumnConfig, TaskListColumnType } from './models/configs/TaskListConfig'
export type { ToolbarConfig } from './models/configs/ToolbarConfig'
// 导出样式文件
import './styles/theme-variables.css'

View File

@@ -0,0 +1,93 @@
// TaskList 列配置类型定义
export type TaskListColumnType =
| 'name'
| 'predecessor'
| 'assignee'
| 'startDate'
| 'endDate'
| 'estimatedHours'
| 'actualHours'
| 'progress'
export interface TaskListColumnConfig {
type?: TaskListColumnType
key: string // 用于国际化的key也可以作为识别符
label?: string // 显示标签
cssClass?: string // CSS类名
width?: number // 可选的列宽度
visible?: boolean // 是否显示默认true
}
export interface TaskListConfig {
columns?: TaskListColumnConfig[]
showAllColumns?: boolean // 是否显示所有列默认true
defaultWidth?: number // 默认展开宽度单位像素默认320px
minWidth?: number // 最小宽度单位像素默认280px不能小于280px
maxWidth?: number // 最大宽度单位像素默认1160px
}
// 默认宽度配置
export const DEFAULT_TASK_LIST_WIDTH = 320 // 默认展开宽度
export const DEFAULT_TASK_LIST_MIN_WIDTH = 280 // 最小宽度
export const DEFAULT_TASK_LIST_MAX_WIDTH = 1160 // 最大宽度
// 默认列配置
export const DEFAULT_TASK_LIST_COLUMNS: TaskListColumnConfig[] = [
{
type: 'name',
key: 'taskName',
label: '任务名称',
cssClass: 'col-name',
visible: true,
},
{
type: 'predecessor',
key: 'predecessor',
label: '前置任务',
cssClass: 'col-pre',
visible: true,
},
{
type: 'assignee',
key: 'assignee',
label: '负责人',
cssClass: 'col-assignee',
visible: true,
},
{
type: 'startDate',
key: 'startDate',
label: '开始日期',
cssClass: 'col-date',
visible: true,
},
{
type: 'endDate',
key: 'endDate',
label: '结束日期',
cssClass: 'col-date',
visible: true,
},
{
type: 'estimatedHours',
key: 'estimatedHours',
label: '预估工时',
cssClass: 'col-hours',
visible: true,
},
{
type: 'actualHours',
key: 'actualHours',
label: '实际工时',
cssClass: 'col-hours',
visible: true,
},
{
type: 'progress',
key: 'progress',
label: '进度',
cssClass: 'col-progress',
visible: true,
},
]

View File

@@ -1,3 +1,5 @@
import type { TimelineScale } from '../types/TimelineScale'
// ToolbarConfig 类型定义
export interface ToolbarConfig {
showAddTask?: boolean
@@ -9,5 +11,7 @@ export interface ToolbarConfig {
showTheme?: boolean
showFullscreen?: boolean
showTimeScale?: boolean // 显示时间刻度按钮组
timeScaleDimensions?: ('hour' | 'day' | 'week' | 'month' | 'year')[] // 设置时间刻度按钮的展示维度
timeScaleDimensions?: TimelineScale[] // 设置时间刻度按钮的展示维度
defaultTimeScale?: TimelineScale // 默认选中的时间刻度
showExpandCollapse?: boolean // 显示全部展开/折叠按钮
}

84
src/styles/list.css Normal file
View File

@@ -0,0 +1,84 @@
@import './theme-variables.css';
.col {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
border-right: 1px solid var(--gantt-border-light);
box-sizing: border-box;
overflow: hidden;
}
.col:last-child {
border-right: none;
}
.col-name {
flex: 2 0 300px;
min-width: 300px;
max-width: 300px;
justify-content: flex-start;
}
.col-pre {
flex: 1 0 120px;
min-width: 120px;
max-width: 120px;
}
.col-assignee {
flex: 1 0 120px;
min-width: 120px;
max-width: 120px;
}
.col-date {
flex: 1.2 0 140px;
min-width: 140px;
max-width: 140px;
}
.col-hours {
flex: 1 0 100px;
min-width: 100px;
max-width: 100px;
}
.col-progress {
flex: 1 0 100px;
min-width: 100px;
max-width: 100px;
}
/* 基于 key 的 CSS 类名(备用) */
.col-taskName {
flex: 2 0 300px;
min-width: 300px;
max-width: 300px;
justify-content: flex-start;
}
.col-predecessor {
flex: 1 0 120px;
min-width: 120px;
max-width: 120px;
}
.col-startDate,
.col-endDate {
flex: 1.2 0 140px;
min-width: 140px;
max-width: 140px;
}
.col-estimatedHours,
.col-actualHours {
flex: 1 0 100px;
min-width: 100px;
max-width: 100px;
}
.col:last-child {
border-right: none;
}

View File

@@ -1,9 +1,19 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// 生成CSS导出文件的插件
const generateCssExportPlugin = () => {
return {
name: 'generate-css-export',
generateBundle() {
// 此插件将在构建后脚本中处理
}
}
}
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
plugins: [vue(), generateCssExportPlugin()],
build: {
outDir: './npm-package/dist',
emptyOutDir: true,
@@ -21,6 +31,13 @@ export default defineConfig({
globals: {
vue: 'Vue',
},
// 禁用文件名哈希,生成固定文件名
entryFileNames: 'jordium-gantt-vue3.[format].js',
chunkFileNames: 'chunks/[name].js',
assetFileNames: 'assets/[name].[ext]',
// 禁用代码分割,将所有代码打包到一个文件中
manualChunks: undefined,
inlineDynamicImports: true
},
},
},

View File

@@ -19,4 +19,7 @@ export default defineConfig(({ command }) => ({
'@': '../src',
},
},
server: {
port: 13000,
}
}))