Merge branch 'master' of https://gitee.com/jordium/jordium-gantt-vue3
This commit is contained in:
34
README-EN.md
34
README-EN.md
@@ -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>
|
||||
|
||||
34
README.md
34
README.md
@@ -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>
|
||||
|
||||
467
demo/App.vue
467
demo/App.vue
@@ -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
44
demo/HtmlContent.vue
Normal 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
239
demo/TaskListConfigDemo.vue
Normal 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>
|
||||
@@ -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
78
demo/test-result.html
Normal 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 组件
|
||||
<GanttChart :task-list-config="taskListConfig" /></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>
|
||||
307
docs/周视图跨月问题.md
Normal file
307
docs/周视图跨月问题.md
Normal 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()`:像素位置反向计算日期
|
||||
- [ ] 跨月拖拽的平滑过渡处理
|
||||
- [ ] 边界条件的鲁棒性处理
|
||||
|
||||
## 已尝试的解决方案
|
||||
|
||||
### 方案1:Timeline数据结构改造 ❌ (已废弃)
|
||||
**实施状态**:部分完成,后续回滚
|
||||
|
||||
**具体改动**:
|
||||
```javascript
|
||||
// 修改 generateWeekTimelineData 函数
|
||||
// 允许跨月周在多个月份中显示
|
||||
for (const month of monthsToDisplay) {
|
||||
results.push({
|
||||
...week,
|
||||
displayMonth: month,
|
||||
displayYear: year
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**遇到的问题**:
|
||||
- TypeScript类型错误
|
||||
- spread操作符类型兼容性问题
|
||||
- 可能的性能影响
|
||||
- 过度工程化,增加了系统复杂性
|
||||
|
||||
### 方案2:TaskBar位置计算增强 ❌ (已废弃)
|
||||
**实施状态**:部分完成,后续回滚
|
||||
|
||||
**具体改动**:
|
||||
- 新增 `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号位置有蓝色旗帜标记,不遮挡文字
|
||||
- 实施结果:✅ 已实现旗帜方案,构建成功,视觉效果优秀
|
||||
|
||||
### 场景2:10月边界显示测试
|
||||
- 时间: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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
39
scripts/generate-css-export.js
Normal file
39
scripts/generate-css-export.js
Normal 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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
import '../styles/app.css'
|
||||
const props = defineProps({
|
||||
visible: Boolean,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
93
src/models/configs/TaskListConfig.ts
Normal file
93
src/models/configs/TaskListConfig.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
@@ -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
84
src/styles/list.css
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,4 +19,7 @@ export default defineConfig(({ command }) => ({
|
||||
'@': '../src',
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 13000,
|
||||
}
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user