!4 #IDAA4Y 支持task可视化连接

Merge pull request !4 from 秋成/master
This commit is contained in:
nelson820125
2025-12-13 04:24:46 +00:00
committed by Gitee
25 changed files with 2112 additions and 153 deletions
+6 -6
View File
@@ -251,8 +251,8 @@ const onTaskAdded = (res) => {
</div> </div>
<div class="drawer-footer"> <div class="drawer-footer">
<button class="btn btn-primary" @click="addTask">确定</button> <button class="gantt-btn gantt-btn-primary" @click="addTask">确定</button>
<button class="btn btn-default" @click="showAddTaskDrawer = false">取消</button> <button class="gantt-btn gantt-btn-default" @click="showAddTaskDrawer = false">取消</button>
</div> </div>
</div> </div>
</div> </div>
@@ -400,23 +400,23 @@ const onTaskAdded = (res) => {
transition: all 0.3s; transition: all 0.3s;
} }
.btn-primary { .gantt-btn-primary {
background: #409eff; background: #409eff;
color: white; color: white;
border-color: #409eff; border-color: #409eff;
} }
.btn-primary:hover { .gantt-btn-primary:hover {
background: #66b1ff; background: #66b1ff;
border-color: #66b1ff; border-color: #66b1ff;
} }
.btn-default { .gantt-btn-default {
background: white; background: white;
color: #606266; color: #606266;
} }
.btn-default:hover { .gantt-btn-default:hover {
color: #409eff; color: #409eff;
border-color: #409eff; border-color: #409eff;
} }
+8 -8
View File
@@ -24,8 +24,8 @@
</div> </div>
<!-- 自定义添加任务按钮 --> <!-- 自定义添加任务按钮 -->
<div> <div>
<button class="btn btn-primary" @click="showAddTaskDrawer = true">添加任务</button> <button class="gantt-btn gantt-btn-primary" @click="showAddTaskDrawer = true">添加任务</button>
<button class="btn btn-primary" @click="showAddMilestoneDialog = true">添加里程碑</button> <button class="gantt-btn gantt-btn-primary" @click="showAddMilestoneDialog = true">添加里程碑</button>
</div> </div>
<!-- 自定义抽屉组件 (原生HTML替代 el-drawer) --> <!-- 自定义抽屉组件 (原生HTML替代 el-drawer) -->
@@ -54,8 +54,8 @@
</div> </div>
<div class="drawer-footer"> <div class="drawer-footer">
<button class="btn btn-primary" @click="addTask">确定</button> <button class="gantt-btn gantt-btn-primary" @click="addTask">确定</button>
<button class="btn btn-default" @click="showAddTaskDrawer = false">取消</button> <button class="gantt-btn gantt-btn-default" @click="showAddTaskDrawer = false">取消</button>
</div> </div>
</div> </div>
</div> </div>
@@ -409,23 +409,23 @@ const onTaskAdded = (res) => {
transition: all 0.3s; transition: all 0.3s;
} }
.btn-primary { .gantt-btn-primary {
background: #409eff; background: #409eff;
color: white; color: white;
border-color: #409eff; border-color: #409eff;
} }
.btn-primary:hover { .gantt-btn-primary:hover {
background: #66b1ff; background: #66b1ff;
border-color: #66b1ff; border-color: #66b1ff;
} }
.btn-default { .gantt-btn-default {
background: white; background: white;
color: #606266; color: #606266;
} }
.btn-default:hover { .gantt-btn-default:hover {
color: #409eff; color: #409eff;
border-color: #409eff; border-color: #409eff;
} }
+15 -4
View File
@@ -2,11 +2,22 @@
"name": "jordium-gantt-vue3", "name": "jordium-gantt-vue3",
"version": "1.4.6-patch.1", "version": "1.4.6-patch.1",
"type": "module", "type": "module",
"main": "dist/jordium-gantt-vue3.cjs.js", "main": "npm-package/dist/jordium-gantt-vue3.cjs.js",
"module": "dist/jordium-gantt-vue3.es.js", "module": "npm-package/dist/jordium-gantt-vue3.es.js",
"types": "dist/jordium-gantt-vue3.d.ts", "types": "npm-package/dist/index.d.ts",
"exports": {
".": {
"types": "./npm-package/dist/index.d.ts",
"require": "./npm-package/dist/jordium-gantt-vue3.cjs.js",
"import": "./npm-package/dist/jordium-gantt-vue3.es.js"
},
"./styles": "./npm-package/dist/jordium-gantt-vue3-styles.js",
"./styles.js": "./npm-package/dist/jordium-gantt-vue3-styles.js",
"./index.css": "./npm-package/dist/assets/jordium-gantt-vue3.css",
"./npm-package/*": "./npm-package/*"
},
"files": [ "files": [
"dist" "npm-package"
], ],
"license": "MIT", "license": "MIT",
"repository": { "repository": {
+1 -1
View File
@@ -9,7 +9,7 @@ const __dirname = path.dirname(__filename)
// 构建后生成CSS导出文件 // 构建后生成CSS导出文件
function generateCssExport() { function generateCssExport() {
const distDir = path.resolve(__dirname, '../../npm-package/dist') const distDir = path.resolve(__dirname, '../npm-package/dist')
const assetsDir = path.join(distDir, 'assets') const assetsDir = path.join(distDir, 'assets')
const expectedCssFile = path.join(assetsDir, 'jordium-gantt-vue3.css') const expectedCssFile = path.join(assetsDir, 'jordium-gantt-vue3.css')
const outputFile = path.join(distDir, 'jordium-gantt-vue3-styles.js') const outputFile = path.join(distDir, 'jordium-gantt-vue3-styles.js')
+5 -5
View File
@@ -46,8 +46,8 @@ const messageTaskName = computed(() => {
rows="3" rows="3"
></textarea> ></textarea>
<div class="dialog-actions"> <div class="dialog-actions">
<button class="btn btn-default" @click="$emit('cancel')">{{ t('cancel') }}</button> <button class="gantt-btn gantt-btn-default" @click="$emit('cancel')">{{ t('cancel') }}</button>
<button class="btn btn-confirm" @click="onConfirm">{{ t('startTimer') }}</button> <button class="gantt-btn gantt-btn-confirm" @click="onConfirm">{{ t('startTimer') }}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -108,7 +108,7 @@ const messageTaskName = computed(() => {
gap: 12px; gap: 12px;
margin-top: 8px; margin-top: 8px;
} }
.btn { .gantt-btn {
min-width: 96px; min-width: 96px;
padding: 10px 0; padding: 10px 0;
border: none; border: none;
@@ -122,11 +122,11 @@ const messageTaskName = computed(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.btn-confirm { .gantt-btn-confirm {
background: #4caf50; background: #4caf50;
color: #fff; color: #fff;
} }
.btn-confirm:hover { .gantt-btn-confirm:hover {
background: #43a047; background: #43a047;
} }
</style> </style>
+68
View File
@@ -65,6 +65,8 @@ const emit = defineEmits([
'task-deleted', 'task-deleted',
'task-added', 'task-added',
'task-updated', 'task-updated',
'task-collapse-change', // 任务折叠状态变化事件
'link-deleted', // 链接删除事件
// 工具栏事件 // 工具栏事件
'add-task', 'add-task',
'add-milestone', 'add-milestone',
@@ -72,6 +74,9 @@ const emit = defineEmits([
'milestone-saved', 'milestone-saved',
'milestone-deleted', 'milestone-deleted',
'milestone-icon-changed', 'milestone-icon-changed',
// 任务关系事件
'add-predecessor',
'add-successor',
// TaskRow拖拽事件 // TaskRow拖拽事件
'task-row-moved', 'task-row-moved',
]) ])
@@ -584,6 +589,9 @@ const handleTaskCollapseChange = (task: Task) => {
// 触发Timeline重新计算 // 触发Timeline重新计算
updateTaskTrigger.value++ updateTaskTrigger.value++
// 向外发出折叠状态变化事件
emit('task-collapse-change', task)
} }
// 全部展开任务 // 全部展开任务
@@ -744,6 +752,11 @@ const expandTaskListText = computed(() => t.value.expandTaskList)
// 为TaskList提供层级数据(保持原始层级结构,只扁平化里程碑) // 为TaskList提供层级数据(保持原始层级结构,只扁平化里程碑)
const tasksForTaskList = computed(() => { const tasksForTaskList = computed(() => {
// 通过条件判断访问触发器,确保折叠状态变化时重新计算
if (updateTaskTrigger.value >= 0) {
// 触发器起作用,继续执行计算逻辑
}
const result: Task[] = [] const result: Task[] = []
// 如果有里程碑,创建里程碑分组行(扁平化显示) // 如果有里程碑,创建里程碑分组行(扁平化显示)
@@ -2004,6 +2017,9 @@ function handleTimelineEditTask(task: Task) {
function handleAddPredecessor(targetTask: Task) { function handleAddPredecessor(targetTask: Task) {
if (!targetTask) return if (!targetTask) return
// 触发事件供外部使用
emit('add-predecessor', targetTask)
// 1. 记录要添加前置任务的目标任务 // 1. 记录要添加前置任务的目标任务
taskToAddPredecessorTo.value = targetTask taskToAddPredecessorTo.value = targetTask
@@ -2036,6 +2052,10 @@ function handleAddPredecessor(targetTask: Task) {
// 处理添加后置任务事件 // 处理添加后置任务事件
function handleAddSuccessor(targetTask: Task) { function handleAddSuccessor(targetTask: Task) {
if (!targetTask) return if (!targetTask) return
// 触发事件供外部使用
emit('add-successor', targetTask)
// 记录要添加后置任务的目标任务 // 记录要添加后置任务的目标任务
taskToAddSuccessorTo.value = targetTask taskToAddSuccessorTo.value = targetTask
// 构造新任务,parentId 与目标任务一致,predecessor 仅包含目标任务 id // 构造新任务,parentId 与目标任务一致,predecessor 仅包含目标任务 id
@@ -2063,6 +2083,18 @@ function handleAddSuccessor(targetTask: Task) {
taskDrawerVisible.value = true taskDrawerVisible.value = true
} }
// 处理前置任务已添加事件(拖拽连接完成)
function handlePredecessorAdded(event: { targetTask: Task; newTask: Task }) {
// 直接转发事件给外部
emit('predecessor-added', event)
}
// 处理后置任务已添加事件(拖拽连接完成)
function handleSuccessorAdded(event: { targetTask: Task; newTask: Task }) {
// 直接转发事件给外部
emit('successor-added', event)
}
// 新增Task插入到任务树中 // 新增Task插入到任务树中
// 插入新任务到任务树(parentId 已在打开 TaskDrawer 时预设好) // 插入新任务到任务树(parentId 已在打开 TaskDrawer 时预设好)
const insertTask = (tasks: Task[], newTask: Task) => { const insertTask = (tasks: Task[], newTask: Task) => {
@@ -2161,6 +2193,39 @@ function handleTaskDelete(task: Task, deleteChildren?: boolean) {
emit('task-deleted', { task }) emit('task-deleted', { task })
} }
// 处理链接删除事件
function handleLinkDeleted(event: {
sourceTaskId: number
targetTaskId: number
updatedTask: Task
}) {
if (props.tasks) {
// 在 tasks 树中找到目标任务并更新
const updateTaskInTree = (taskList: Task[]): boolean => {
for (let i = 0; i < taskList.length; i++) {
if (taskList[i].id === event.targetTaskId) {
// 使用新对象替换,确保响应式更新
taskList[i] = { ...taskList[i], predecessor: event.updatedTask.predecessor }
return true
}
if (taskList[i].children && taskList[i].children!.length > 0) {
if (updateTaskInTree(taskList[i].children!)) {
return true
}
}
}
return false
}
updateTaskInTree(props.tasks)
}
// 触发任务更新事件
emit('task-updated', { task: event.updatedTask })
// 向外部发送链接删除事件
emit('link-deleted', event)
}
// 处理里程碑双击事件 // 处理里程碑双击事件
function handleMilestoneDoubleClick(milestone: Milestone) { function handleMilestoneDoubleClick(milestone: Milestone) {
// 先触发外部事件,让外部可以自定义处理 // 先触发外部事件,让外部可以自定义处理
@@ -2295,7 +2360,10 @@ function handleMilestoneDialogDelete(milestoneId: number) {
@stop-timer="handleStopTimer" @stop-timer="handleStopTimer"
@add-predecessor="handleAddPredecessor" @add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor" @add-successor="handleAddSuccessor"
@predecessor-added="handlePredecessorAdded"
@successor-added="handleSuccessorAdded"
@delete="handleTaskDelete" @delete="handleTaskDelete"
@link-deleted="handleLinkDeleted"
> >
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope"> <template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
<slot name="custom-task-content" v-bind="barScope" /> <slot name="custom-task-content" v-bind="barScope" />
+5 -5
View File
@@ -28,23 +28,23 @@ const onNo = () => emit('no')
</div> </div>
<div class="gantt-confirm-footer"> <div class="gantt-confirm-footer">
<template v-if="props.type === 'yes-no-cancel'"> <template v-if="props.type === 'yes-no-cancel'">
<button type="button" class="btn btn-default" @click="onCancel"> <button type="button" class="gantt-btn gantt-btn-default" @click="onCancel">
{{ props.cancelText }} {{ props.cancelText }}
</button> </button>
<div class="gantt-confirm-footer-right"> <div class="gantt-confirm-footer-right">
<button type="button" class="btn btn-warning" @click="onNo"> <button type="button" class="gantt-btn gantt-btn-warning" @click="onNo">
{{ props.noText }} {{ props.noText }}
</button> </button>
<button type="button" class="btn btn-danger" @click="onYes"> <button type="button" class="gantt-btn gantt-btn-danger" @click="onYes">
{{ props.yesText }} {{ props.yesText }}
</button> </button>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<button type="button" class="btn btn-default" @click="onCancel"> <button type="button" class="gantt-btn gantt-btn-default" @click="onCancel">
{{ props.cancelText }} {{ props.cancelText }}
</button> </button>
<button type="button" class="btn btn-danger" @click="onConfirm"> <button type="button" class="gantt-btn gantt-btn-danger" @click="onConfirm">
{{ props.confirmText }} {{ props.confirmText }}
</button> </button>
</template> </template>
+91 -23
View File
@@ -2,6 +2,7 @@
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue' import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import type { Task } from '../models/classes/Task' import type { Task } from '../models/classes/Task'
import { getPredecessorIds } from '../utils/predecessorUtils' import { getPredecessorIds } from '../utils/predecessorUtils'
import { CanvasContextManager } from '../utils/canvasUtils'
// 定义 TaskBar 位置信息类型 // 定义 TaskBar 位置信息类型
interface TaskBarPosition { interface TaskBarPosition {
@@ -43,6 +44,9 @@ const props = withDefaults(defineProps<Props>(), {
// Canvas 引用 // Canvas 引用
const canvasRef = ref<HTMLCanvasElement | null>(null) const canvasRef = ref<HTMLCanvasElement | null>(null)
// Canvas 上下文管理器
const canvasManager = new CanvasContextManager()
// requestAnimationFrame 防抖控制 // requestAnimationFrame 防抖控制
let rafId: number | null = null let rafId: number | null = null
let pendingRedraw = false let pendingRedraw = false
@@ -62,30 +66,23 @@ const updateTheme = () => {
* 性能优势:相比 SVG 提升 18 倍渲染性能 * 性能优势:相比 SVG 提升 18 倍渲染性能
*/ */
const drawLinks = () => { const drawLinks = () => {
const canvas = canvasRef.value const ctx = canvasManager.getContext({
if (!canvas) return canvas: canvasRef.value,
canvasId: 'gantt-link-canvas',
width: props.width,
height: props.height,
contextOptions: { alpha: true },
})
const ctx = canvas.getContext('2d', { alpha: true })
if (!ctx) { if (!ctx) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('❌ Canvas context 获取失败,可能是尺寸超限') console.error('❌ Canvas context 获取失败,可能是尺寸超限')
return return
} }
// 适配高清屏(Retina
const dpr = window.devicePixelRatio || 1
const displayWidth = props.width const displayWidth = props.width
const displayHeight = props.height const displayHeight = props.height
// 只在尺寸变化时更新 canvas 尺寸
const pixelWidth = displayWidth * dpr
const pixelHeight = displayHeight * dpr
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth
canvas.height = pixelHeight
ctx.scale(dpr, dpr)
}
// 清空画布(透明) // 清空画布(透明)
ctx.clearRect(0, 0, displayWidth, displayHeight) ctx.clearRect(0, 0, displayWidth, displayHeight)
@@ -204,12 +201,78 @@ const drawLinks = () => {
const x2 = globalX2 - props.offsetLeft const x2 = globalX2 - props.offsetLeft
const y2 = globalY2 - props.offsetTop const y2 = globalY2 - props.offsetTop
const c1x = x1 + 40 // 计算水平和垂直距离
const c1y = y1 const dx = x2 - x1
const c2x = x2 - 40 const dy = y2 - y1
const c2y = y2 const distance = Math.sqrt(dx * dx + dy * dy)
// 预计算箭头角度 // 动态计算控制点,优化连接线的显示效果
// 核心策略:避免链接线被 TaskBar 的直角遮挡
// 1. 出点和入点需要有足够的"间距",确保不被 TaskBar 直角遮挡
// 2. 根据 TaskBar 的尺寸(宽度、高度)动态调整水平和垂直偏移
// TaskBar 尺寸(用于计算绕过直角所需的偏移)
const barWidth = fromBar.width
const barHeight = fromBar.height
// 计算水平距离占总距离的比例
const horizontalRatio = Math.abs(dx) / (distance + 1) // +1 避免除零
// ===== 水平偏移计算 =====
// 策略:确保出点和入点的控制点都在 TaskBar 外侧,不会被角落遮挡
// 从 A 出发:至少要超过 A 的右边界 + 安全距离
// 进入 B:至少要在 B 的左边界 - 安全距离
const horizontalSafetyMargin = 20 // TaskBar 角落安全距离
let horizontalOffset = barHeight / 2 + horizontalSafetyMargin // 基础偏移
if (horizontalRatio < 0.3) {
// 接近垂直的情况:需要更大的水平偏移,确保出入角度清晰
horizontalOffset = Math.max(barHeight / 2 + 25, 45)
} else if (horizontalRatio < 0.6) {
// 中等角度:适度调整
horizontalOffset = barHeight / 2 + horizontalSafetyMargin
} else {
// 水平情况:偏移可以相对较小
horizontalOffset = barWidth / 3 + 10
}
// ===== 垂直偏移计算 =====
// 策略:根据垂直距离动态调整,确保线条绕过 TaskBar 的上下边界
let verticalOffset = 0
if (Math.abs(dy) > 0) {
// 垂直偏移的最小值:必须大于 TaskBar 半高 + 安全距离,才能绕过直角
const verticalSafetyMargin = 12 // TaskBar 角落安全距离
const minVerticalOffset = barHeight / 2 + verticalSafetyMargin // 通常是 32-35px
// 动态计算:根据两个 TaskBar 之间的垂直距离
// 如果距离较近,偏移量应该相对较小(形成更陡峭的曲线)
// 如果距离较远,偏移量应该相对较大(形成更平缓的曲线)
const verticalGap = Math.abs(dy) - barHeight // 两个 TaskBar 之间的实际距离
const dynamicOffset = Math.min(verticalGap * 0.5, 100) // 最大 100px
verticalOffset = Math.max(minVerticalOffset, dynamicOffset)
} else {
// 完全水平的连接:垂直偏移为 0(线条直接通过中心)
verticalOffset = 0
}
// ===== 控制点垂直比例调整 =====
// 根据连接类型调整控制点的垂直位置
// 接近垂直时:控制点垂直偏移更小,形成平缓的 S 形曲线
// 接近水平时:控制点垂直偏移接近最大值,形成明显的进出角度
const isNearVertical = horizontalRatio < 0.3
const verticalControlRatio = isNearVertical ? 0.4 : 0.8 // 接近垂直时控制点垂直偏移更小
// 起点控制点:水平向右延伸,垂直偏移较小(形成平缓的出线角度)
const c1x = x1 + horizontalOffset
const c1y = y1 + (dy > 0 ? verticalOffset : -verticalOffset) * verticalControlRatio
// 终点控制点:水平向左延伸,垂直偏移较小(形成平缓的入线角度)
const c2x = x2 - horizontalOffset
const c2y = y2 - (dy > 0 ? verticalOffset : -verticalOffset) * verticalControlRatio
// 预计算箭头角度(使用控制点到终点的角度,更准确)
const arrowAngle = Math.atan2(y2 - c2y, x2 - c2x) const arrowAngle = Math.atan2(y2 - c2y, x2 - c2x)
const lineData: LineData = { x1, y1, x2, y2, c1x, c1y, c2x, c2y, arrowAngle } const lineData: LineData = { x1, y1, x2, y2, c1x, c1y, c2x, c2y, arrowAngle }
@@ -311,6 +374,9 @@ const drawLinks = () => {
// 恢复透明度 // 恢复透明度
ctx.globalAlpha = 1 ctx.globalAlpha = 1
} }
// uni-app 环境需要调用 draw() 方法将绘制内容渲染到画布
canvasManager.draw()
} }
/** /**
@@ -390,7 +456,7 @@ watch(
// 组件挂载后初始化绘制 // 组件挂载后初始化绘制
onMounted(() => { onMounted(() => {
// 监听主题变化 // 监听主题变化
themeObserver = new MutationObserver((mutations) => { themeObserver = new MutationObserver(mutations => {
for (const mutation of mutations) { for (const mutation of mutations) {
if (mutation.attributeName === 'data-theme') { if (mutation.attributeName === 'data-theme') {
updateTheme() updateTheme()
@@ -433,14 +499,16 @@ defineExpose({
<template> <template>
<canvas <canvas
ref="canvasRef" ref="canvasRef"
canvas-id="gantt-link-canvas"
class="gantt-links-canvas" class="gantt-links-canvas"
type="2d"
:hidpi="true"
:style="{ :style="{
position: 'absolute', position: 'absolute',
left: 0, left: `${offsetLeft}px`,
top: 0, top: `${offsetTop}px`,
width: `${width}px`, width: `${width}px`,
height: `${height}px`, height: `${height}px`,
transform: `translate(${offsetLeft}px, ${offsetTop}px)`,
zIndex: highlightedTaskId !== null ? 1001 : 25, zIndex: highlightedTaskId !== null ? 1001 : 25,
pointerEvents: 'none', pointerEvents: 'none',
}" }"
+54 -54
View File
@@ -432,16 +432,16 @@ onUnmounted(() => {
<!-- 新增按钮组 --> <!-- 新增按钮组 -->
<div <div
v-if="config.showAddTask !== false || config.showAddMilestone !== false" v-if="config.showAddTask !== false || config.showAddMilestone !== false"
class="btn-group add-btn-group" class="gantt-btn-group gantt-add-btn-group"
> >
<button <button
v-if="config.showAddTask !== false" v-if="config.showAddTask !== false"
class="btn-group-item" class="gantt-btn-group-item"
:title="t('addTask')" :title="t('addTask')"
@click="handleAddTask" @click="handleAddTask"
> >
<svg <svg
class="btn-icon" class="gantt-btn-icon"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -454,12 +454,12 @@ onUnmounted(() => {
</button> </button>
<button <button
v-if="config.showAddMilestone !== false" v-if="config.showAddMilestone !== false"
class="btn-group-item" class="gantt-btn-group-item"
:title="t('addMilestone')" :title="t('addMilestone')"
@click="handleAddMilestone" @click="handleAddMilestone"
> >
<svg <svg
class="btn-icon" class="gantt-btn-icon"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -487,15 +487,15 @@ onUnmounted(() => {
<!-- 展开/折叠按钮组 --> <!-- 展开/折叠按钮组 -->
<div <div
v-if="config.showExpandCollapse !== false" v-if="config.showExpandCollapse !== false"
class="btn-group expand-collapse-btn-group" class="gantt-btn-group gantt-expand-collapse-btn-group"
> >
<button <button
class="btn-group-item" class="gantt-btn-group-item"
:title="t('expandAll')" :title="t('expandAll')"
@click="handleExpandAll" @click="handleExpandAll"
> >
<svg <svg
class="btn-icon" class="gantt-btn-icon"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -506,12 +506,12 @@ onUnmounted(() => {
{{ t('expandAll') }} {{ t('expandAll') }}
</button> </button>
<button <button
class="btn-group-item" class="gantt-btn-group-item"
:title="t('collapseAll')" :title="t('collapseAll')"
@click="handleCollapseAll" @click="handleCollapseAll"
> >
<svg <svg
class="btn-icon" class="gantt-btn-icon"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -526,16 +526,16 @@ onUnmounted(() => {
<!-- 导出按钮组 --> <!-- 导出按钮组 -->
<div <div
v-if="config.showExportCsv !== false || config.showExportPdf !== false" v-if="config.showExportCsv !== false || config.showExportPdf !== false"
class="btn-group" class="gantt-btn-group"
> >
<button <button
v-if="config.showExportCsv !== false" v-if="config.showExportCsv !== false"
class="btn-group-item" class="gantt-btn-group-item"
:title="t('exportCsv')" :title="t('exportCsv')"
@click="handleExportCsv" @click="handleExportCsv"
> >
<svg <svg
class="btn-icon" class="gantt-btn-icon"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -552,12 +552,12 @@ onUnmounted(() => {
<button <button
v-if="config.showExportPdf !== false" v-if="config.showExportPdf !== false"
class="btn-group-item" class="gantt-btn-group-item"
:title="t('exportPdf')" :title="t('exportPdf')"
@click="handleExportPdf" @click="handleExportPdf"
> >
<svg <svg
class="btn-icon" class="gantt-btn-icon"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -600,7 +600,7 @@ onUnmounted(() => {
@click="toggleLanguageDropdown" @click="toggleLanguageDropdown"
> >
<svg <svg
class="btn-icon" class="gantt-btn-icon"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -656,7 +656,7 @@ onUnmounted(() => {
@click="handleTodayLocate" @click="handleTodayLocate"
> >
<svg <svg
class="btn-icon" class="gantt-btn-icon"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -678,7 +678,7 @@ onUnmounted(() => {
> >
<svg <svg
v-if="isDarkMode" v-if="isDarkMode"
class="btn-icon" class="gantt-btn-icon"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -696,7 +696,7 @@ onUnmounted(() => {
</svg> </svg>
<svg <svg
v-else v-else
class="btn-icon" class="gantt-btn-icon"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -716,7 +716,7 @@ onUnmounted(() => {
> >
<svg <svg
v-if="isFullscreen" v-if="isFullscreen"
class="btn-icon" class="gantt-btn-icon"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -728,7 +728,7 @@ onUnmounted(() => {
</svg> </svg>
<svg <svg
v-else v-else
class="btn-icon" class="gantt-btn-icon"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -748,8 +748,8 @@ onUnmounted(() => {
<div class="dialog-content"> <div class="dialog-content">
<p class="dialog-message">{{ t('confirmDialogMessage') }}</p> <p class="dialog-message">{{ t('confirmDialogMessage') }}</p>
<div class="dialog-actions"> <div class="dialog-actions">
<button class="btn btn-default" @click="cancelSaveSettings">{{ t('cancel') }}</button> <button class="gantt-btn gantt-btn-default" @click="cancelSaveSettings">{{ t('cancel') }}</button>
<button class="btn btn-primary" @click="confirmSaveSettings">{{ t('confirm') }}</button> <button class="gantt-btn gantt-btn-primary" @click="confirmSaveSettings">{{ t('confirm') }}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -812,13 +812,13 @@ onUnmounted(() => {
background: var(--gantt-bg-active, rgba(0, 0, 0, 0.3)); background: var(--gantt-bg-active, rgba(0, 0, 0, 0.3));
} }
.toolbar-icon-btn .btn-icon { .toolbar-icon-btn .gantt-btn-icon {
width: 18px; width: 18px;
height: 18px; height: 18px;
} }
/* 图标样式 */ /* 图标样式 */
.btn-icon { .gantt-btn-icon {
width: 16px; width: 16px;
height: 16px; height: 16px;
stroke-width: 2; stroke-width: 2;
@@ -882,7 +882,7 @@ onUnmounted(() => {
background: var(--gantt-bg-active, rgba(64, 158, 255, 0.1)); background: var(--gantt-bg-active, rgba(64, 158, 255, 0.1));
} }
.toolbar-lang-btn .btn-icon { .toolbar-lang-btn .gantt-btn-icon {
width: 16px; width: 16px;
height: 16px; height: 16px;
stroke-width: 2; stroke-width: 2;
@@ -1089,27 +1089,27 @@ onUnmounted(() => {
font-size: 14px; font-size: 14px;
} }
.btn { .gantt-btn {
padding: 8px 12px; padding: 8px 12px;
font-size: 13px; font-size: 13px;
} }
} }
/* 按钮组样式 - Element Plus primary button group 风格 */ /* 按钮组样式 - Element Plus primary button group 风格 */
.btn-group { .gantt-btn-group {
display: inline-flex; display: inline-flex;
margin-right: 8px; margin-right: 8px;
overflow: hidden; overflow: hidden;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.btn-group:hover, .gantt-btn-group:hover,
.btn-group:focus-within { .gantt-btn-group:focus-within {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.15);
} }
.btn-group-item { .gantt-btn-group-item {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -1130,19 +1130,19 @@ onUnmounted(() => {
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.btn-group-item:first-child { .gantt-btn-group-item:first-child {
border-left: 1px solid; border-left: 1px solid;
border-right: none; border-right: none;
border-top-left-radius: 4px; border-top-left-radius: 4px;
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
} }
.btn-group-item:last-child { .gantt-btn-group-item:last-child {
border-top-right-radius: 4px; border-top-right-radius: 4px;
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
} }
.btn-group-item:not(:last-child)::after { .gantt-btn-group-item:not(:last-child)::after {
content: ''; content: '';
position: absolute; position: absolute;
right: 0; right: 0;
@@ -1154,53 +1154,53 @@ onUnmounted(() => {
} }
/* Primary 按钮组样式 */ /* Primary 按钮组样式 */
.add-btn-group .btn-group-item { .gantt-add-btn-group .gantt-btn-group-item {
background: #409eff; background: #409eff;
border-color: #409eff; border-color: #409eff;
color: #ffffff; color: #ffffff;
} }
.add-btn-group .btn-group-item::after { .gantt-add-btn-group .gantt-btn-group-item::after {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
.add-btn-group .btn-group-item:hover { .gantt-add-btn-group .gantt-btn-group-item:hover {
background: #66b1ff; background: #66b1ff;
border-color: #66b1ff; border-color: #66b1ff;
z-index: 1; z-index: 1;
} }
.add-btn-group .btn-group-item:focus { .gantt-add-btn-group .gantt-btn-group-item:focus {
background: #3a8ee6; background: #3a8ee6;
border-color: #3a8ee6; border-color: #3a8ee6;
z-index: 1; z-index: 1;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3);
} }
.add-btn-group .btn-group-item:active { .gantt-add-btn-group .gantt-btn-group-item:active {
background: #337ecc; background: #337ecc;
border-color: #337ecc; border-color: #337ecc;
} }
/* 悬浮和焦点状态下隐藏分割线 */ /* 悬浮和焦点状态下隐藏分割线 */
.btn-group:hover .btn-group-item::after, .gantt-btn-group:hover .gantt-btn-group-item::after,
.btn-group:focus-within .btn-group-item::after { .gantt-btn-group:focus-within .gantt-btn-group-item::after {
opacity: 0; opacity: 0;
} }
/* 普通按钮组样式 */ /* 普通按钮组样式 */
.btn-group:not(.add-btn-group) .btn-group-item { .gantt-btn-group:not(.gantt-add-btn-group) .gantt-btn-group-item {
border-color: #dcdfe6; border-color: #dcdfe6;
} }
.btn-group:not(.add-btn-group) .btn-group-item:hover { .gantt-btn-group:not(.gantt-add-btn-group) .gantt-btn-group-item:hover {
background: #ecf5ff; background: #ecf5ff;
border-color: #b3d8ff; border-color: #b3d8ff;
color: #409eff; color: #409eff;
z-index: 1; z-index: 1;
} }
.btn-group:not(.add-btn-group) .btn-group-item:focus { .gantt-btn-group:not(.gantt-add-btn-group) .gantt-btn-group-item:focus {
background: #ecf5ff; background: #ecf5ff;
border-color: #409eff; border-color: #409eff;
color: #409eff; color: #409eff;
@@ -1208,54 +1208,54 @@ onUnmounted(() => {
box-shadow: inset 0 0 0 1px #409eff; box-shadow: inset 0 0 0 1px #409eff;
} }
.btn-group:not(.add-btn-group) .btn-group-item:active { .gantt-btn-group:not(.gantt-add-btn-group) .gantt-btn-group-item:active {
background: #d9ecff; background: #d9ecff;
border-color: #409eff; border-color: #409eff;
color: #409eff; color: #409eff;
} }
/* 按钮组图标样式 */ /* 按钮组图标样式 */
.btn-group-item .btn-icon { .gantt-btn-group-item .gantt-btn-icon {
width: 16px; width: 16px;
height: 16px; height: 16px;
stroke-width: 2; stroke-width: 2;
} }
/* 暗黑模式下的按钮组样式 */ /* 暗黑模式下的按钮组样式 */
:global(html[data-theme='dark']) .btn-group { :global(html[data-theme='dark']) .gantt-btn-group {
box-shadow: box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 3px 0 rgba(0, 0, 0, 0.3),
0 1px 2px -1px rgba(0, 0, 0, 0.3); 0 1px 2px -1px rgba(0, 0, 0, 0.3);
} }
:global(html[data-theme='dark']) .btn-group:hover, :global(html[data-theme='dark']) .gantt-btn-group:hover,
:global(html[data-theme='dark']) .btn-group:focus-within { :global(html[data-theme='dark']) .gantt-btn-group:focus-within {
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.5); box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.5);
} }
:global(html[data-theme='dark']) .add-btn-group .btn-group-item { :global(html[data-theme='dark']) .add-btn-group .gantt-btn-group-item {
background: #337ecc; background: #337ecc;
border-color: #337ecc; border-color: #337ecc;
color: #ffffff; color: #ffffff;
} }
:global(html[data-theme='dark']) .add-btn-group .btn-group-item:hover { :global(html[data-theme='dark']) .add-btn-group .gantt-btn-group-item:hover {
background: #4d94d4; background: #4d94d4;
border-color: #4d94d4; border-color: #4d94d4;
} }
:global(html[data-theme='dark']) .add-btn-group .btn-group-item:focus { :global(html[data-theme='dark']) .add-btn-group .gantt-btn-group-item:focus {
background: #2c5aa0; background: #2c5aa0;
border-color: #2c5aa0; border-color: #2c5aa0;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3);
} }
:global(html[data-theme='dark']) .add-btn-group .btn-group-item:active { :global(html[data-theme='dark']) .add-btn-group .gantt-btn-group-item:active {
background: #1f4872; background: #1f4872;
border-color: #1f4872; border-color: #1f4872;
} }
:global(html[data-theme='dark']) .btn-group:not(.add-btn-group) .btn-group-item { :global(html[data-theme='dark']) .gantt-btn-group:not(.gantt-add-btn-group) .gantt-btn-group-item {
background: #2c2c2c; background: #2c2c2c;
border-color: #4c4c4c; border-color: #4c4c4c;
color: #e5e5e5; color: #e5e5e5;
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue'
interface Props {
//
type: 'predecessor' | 'successor' // left=, right=
taskId: number
//
visible?: boolean // hover
//
isDragSource?: boolean //
isDragTarget?: boolean //
isValidTarget?: boolean //
//
enabled?: boolean // true
//
globalDragging?: boolean //
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
isDragSource: false,
isDragTarget: false,
isValidTarget: true,
enabled: true,
globalDragging: false,
})
const emit = defineEmits<{
'drag-start': [{ taskId: number; type: 'predecessor' | 'successor'; x: number; y: number }]
'drag-move': [{ x: number; y: number }]
'drag-end': [{ taskId: number; type: 'predecessor' | 'successor' }]
}>()
//
const isHoveredAnchor = ref(false)
//
const shouldShow = computed(() => {
//
// 1. TaskBar
// 2. 线
// 3.
// 4.
// 5.
return (
props.visible || // TaskBar
props.globalDragging || //
props.isDragSource || //
props.isDragTarget || //
isHoveredAnchor.value //
)
})
//
let globalMouseMoveListener: ((e: MouseEvent) => void) | null = null
let globalMouseUpListener: ((e: MouseEvent) => void) | null = null
let isDragging = false
//
function handleMouseDown(event: MouseEvent) {
if (!props.enabled) return
event.stopPropagation()
event.preventDefault()
isDragging = true
//
emit('drag-start', {
taskId: props.taskId,
type: props.type,
x: event.clientX,
y: event.clientY,
})
//
globalMouseMoveListener = (e: MouseEvent) => {
if (!isDragging) return
//
emit('drag-move', {
x: e.clientX,
y: e.clientY,
})
}
globalMouseUpListener = () => {
if (!isDragging) return
//
emit('drag-end', {
taskId: props.taskId,
type: props.type,
})
cleanup()
}
document.addEventListener('mousemove', globalMouseMoveListener)
document.addEventListener('mouseup', globalMouseUpListener)
}
function handleMouseEnter() {
if (!props.enabled) return
isHoveredAnchor.value = true
}
function handleMouseLeave() {
if (!props.enabled) return
isHoveredAnchor.value = false
}
//
function cleanup() {
isDragging = false
if (globalMouseMoveListener) {
document.removeEventListener('mousemove', globalMouseMoveListener)
globalMouseMoveListener = null
}
if (globalMouseUpListener) {
document.removeEventListener('mouseup', globalMouseUpListener)
globalMouseUpListener = null
}
}
//
onUnmounted(() => {
cleanup()
})
</script>
<template>
<div
v-if="enabled && shouldShow"
class="link-anchor"
:class="{
'anchor-predecessor': type === 'predecessor',
'anchor-successor': type === 'successor',
'drag-source': isDragSource,
'drag-target': isDragTarget,
'valid-target': isDragTarget && isValidTarget,
'invalid-target': isDragTarget && !isValidTarget,
'visible': shouldShow,
}"
@mousedown="handleMouseDown"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<!-- Tooltip -->
<div v-if="!isDragSource && !isDragTarget && !globalDragging" class="anchor-tooltip">
{{ type === 'predecessor' ? '添加前置任务' : '添加后置任务' }}
</div>
</div>
</template>
<style scoped>
.link-anchor {
position: absolute;
top: 50%;
width: 8px;
height: 8px;
border-radius: 50%;
background: #409eff;
border: 2px solid #fff;
cursor: pointer;
opacity: 0;
transition:
opacity 0.2s,
transform 0.2s;
z-index: 200;
/* 默认状态:垂直居中 */
transform: translateY(-50%) scale(1);
}
/* 前置任务触点(左侧) */
.link-anchor.anchor-predecessor {
left: -4px;
}
/* 后置任务触点(右侧) */
.link-anchor.anchor-successor {
right: -4px;
}
/* 当应该显示时,设置可见 */
.link-anchor.visible {
opacity: 1;
}
.link-anchor:hover {
/* 保持垂直居中的同时放大 */
transform: translateY(-50%) scale(1.3);
box-shadow: 0 0 8px rgba(64, 158, 255, 0.6);
opacity: 1 !important;
}
.link-anchor.drag-source {
opacity: 1;
/* 拖拽源也保持垂直居中 */
transform: translateY(-50%) scale(1);
}
.link-anchor.drag-target {
opacity: 1;
/* 拖拽目标:保持垂直居中并放大 */
transform: translateY(-50%) scale(1.5);
animation: pulse 0.8s infinite;
}
.link-anchor.valid-target {
background: #67c23a;
border-color: #fff;
}
.link-anchor.invalid-target {
background: #f56c6c;
border-color: #fff;
}
.anchor-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(-4px);
padding: 4px 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
font-size: 12px;
white-space: nowrap;
border-radius: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.link-anchor:hover .anchor-tooltip {
opacity: 1;
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 8px rgba(64, 158, 255, 0.6);
}
50% {
box-shadow: 0 0 16px rgba(64, 158, 255, 1);
}
}
/* 暗色主题支持 */
:global(html[data-theme='dark']) .link-anchor {
border-color: #1a1a1a;
}
:global(html[data-theme='dark']) .link-anchor.valid-target,
:global(html[data-theme='dark']) .link-anchor.invalid-target {
border-color: #1a1a1a;
}
:global(html[data-theme='dark']) .anchor-tooltip {
background: rgba(255, 255, 255, 0.9);
color: #1a1a1a;
}
</style>
+226
View File
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue'
import { CanvasContextManager } from '../utils/canvasUtils'
interface Props {
active: boolean
//
width: number
height: number
offsetLeft?: number
offsetTop?: number
}
const props = withDefaults(defineProps<Props>(), {
offsetLeft: 0,
offsetTop: 0,
})
const canvasRef = ref<HTMLCanvasElement | null>(null)
// Canvas
const canvasManager = new CanvasContextManager()
/**
* 初始化或更新 canvas 尺寸
*/
const initCanvas = () => {
return canvasManager.getContext({
canvas: canvasRef.value,
canvasId: 'link-drag-guide-canvas',
width: props.width,
height: props.height,
contextOptions: { alpha: true, willReadFrequently: false },
})
}
/**
* 🚀 命令式绘制方法 - 由父组件直接调用避免 Vue 响应式开销
* @param startX 起始点X坐标
* @param startY 起始点Y坐标
* @param endX 结束点X坐标
* @param endY 结束点Y坐标
* @param isValidTarget 是否是合法的连接目标
* @param errorMessage 错误提示消息
*/
const USE_SIMPLE_LINE = true // true = 使线线
const draw = (
startX: number,
startY: number,
endX: number,
endY: number,
isValidTarget = true,
errorMessage = '',
) => {
const ctx = initCanvas()
if (!ctx) {
// eslint-disable-next-line no-console
console.error('[LinkDragGuide] initCanvas returned null')
return
}
const displayWidth = props.width
const displayHeight = props.height
//
ctx.clearRect(0, 0, displayWidth, displayHeight)
// Canvas
const localX1 = startX - props.offsetLeft
const localY1 = startY - props.offsetTop
const localX2 = endX - props.offsetLeft
const localY2 = endY - props.offsetTop
// Canvas
const buffer = 100
const isInBounds =
(localX1 >= -buffer || localX2 >= -buffer) &&
(localX1 <= displayWidth + buffer || localX2 <= displayWidth + buffer) &&
(localY1 >= -buffer || localY2 >= -buffer) &&
(localY1 <= displayHeight + buffer || localY2 <= displayHeight + buffer)
if (!isInBounds) return
// 线
const c1x = localX1 + 40
const c1y = localY1
const c2x = localX2 - 40
const c2y = localY2
ctx.save()
//
const color = isValidTarget ? '#67c23a' : '#f56c6c'
ctx.strokeStyle = color
ctx.lineWidth = 3
// 线
if (!USE_SIMPLE_LINE) {
ctx.setLineDash([8, 4])
}
ctx.globalAlpha = 0.8
// 线
ctx.beginPath()
ctx.moveTo(localX1, localY1)
if (USE_SIMPLE_LINE) {
// 🚀 使线
ctx.lineTo(localX2, localY2)
} else {
// 使线
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, localX2, localY2)
}
ctx.stroke()
//
const arrowAngle = Math.atan2(localY2 - localY1, localX2 - localX1)
const arrowLength = 8
const arrowWidth = 4
ctx.fillStyle = color
ctx.globalAlpha = 1
ctx.beginPath()
ctx.moveTo(localX2, localY2)
ctx.lineTo(
localX2 - arrowLength * Math.cos(arrowAngle) - arrowWidth * Math.sin(arrowAngle),
localY2 - arrowLength * Math.sin(arrowAngle) + arrowWidth * Math.cos(arrowAngle),
)
ctx.lineTo(
localX2 - arrowLength * Math.cos(arrowAngle) + arrowWidth * Math.sin(arrowAngle),
localY2 - arrowLength * Math.sin(arrowAngle) - arrowWidth * Math.cos(arrowAngle),
)
ctx.closePath()
ctx.fill()
//
if (!isValidTarget && errorMessage) {
const textX = (localX1 + localX2) / 2
const textY = (localY1 + localY2) / 2 - 10
ctx.font = '12px Arial, sans-serif'
const textMetrics = ctx.measureText(errorMessage)
const textWidth = textMetrics.width
const padding = 8
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)'
ctx.fillRect(textX - textWidth / 2 - padding, textY - 12, textWidth + padding * 2, 24)
ctx.fillStyle = '#ffffff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(errorMessage, textX, textY)
}
ctx.restore()
// uni-app draw()
canvasManager.draw()
}
/**
* 清空画布
*/
const clear = () => {
canvasManager.clear(props.width, props.height)
}
// canvas
watch(
[() => props.width, () => props.height],
() => {
canvasManager.reset() //
},
)
// active v-if / canvas
watch(
() => props.active,
(newActive) => {
if (!newActive) {
// canvas
canvasManager.reset()
}
},
)
//
onUnmounted(() => {
canvasManager.reset()
})
// 🚀 API
defineExpose({
draw,
clear,
})
</script>
<template>
<canvas
v-if="active"
ref="canvasRef"
canvas-id="link-drag-guide-canvas"
class="link-drag-guide-canvas"
type="2d"
:hidpi="true"
:style="{
position: 'absolute',
left: `${offsetLeft}px`,
top: `${offsetTop}px`,
width: `${width}px`,
height: `${height}px`,
zIndex: 1002,
pointerEvents: 'none',
}"
/>
</template>
<style scoped>
.link-drag-guide-canvas {
display: block;
background: transparent;
opacity: 1;
}
</style>
+3 -3
View File
@@ -394,17 +394,17 @@ const t = (key: string) => {
<div class="milestone-dialog-footer"> <div class="milestone-dialog-footer">
<div class="milestone-dialog-footer-left"> <div class="milestone-dialog-footer-left">
<button v-if="isEditMode" type="button" class="btn btn-danger" @click="handleDelete"> <button v-if="isEditMode" type="button" class="gantt-btn gantt-btn-danger" @click="handleDelete">
{{ globalT.delete }} {{ globalT.delete }}
</button> </button>
</div> </div>
<div class="milestone-dialog-footer-right"> <div class="milestone-dialog-footer-right">
<button type="button" class="btn btn-default" @click="closeDialog"> <button type="button" class="gantt-btn gantt-btn-default" @click="closeDialog">
{{ globalT.cancel }} {{ globalT.cancel }}
</button> </button>
<button <button
type="button" type="button"
class="btn btn-primary" class="gantt-btn gantt-btn-primary"
:disabled="!isFormValid" :disabled="!isFormValid"
@click="handleSave" @click="handleSave"
> >
+113 -9
View File
@@ -4,6 +4,7 @@ import { ref, computed, onUnmounted, onMounted, nextTick, watch, useSlots } from
import type { Task } from '../models/classes/Task' import type { Task } from '../models/classes/Task'
import { TimelineScale } from '../models/types/TimelineScale' import { TimelineScale } from '../models/types/TimelineScale'
import TaskContextMenu from './TaskContextMenu.vue' import TaskContextMenu from './TaskContextMenu.vue'
import LinkAnchor from './LinkAnchor.vue'
import { useI18n } from '../composables/useI18n' import { useI18n } from '../composables/useI18n'
import type { TaskBarConfig } from '../models/configs/TaskBarConfig' import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
@@ -35,6 +36,16 @@ interface Props {
isPrimaryHighlight?: boolean isPrimaryHighlight?: boolean
// //
isInHighlightMode?: boolean isInHighlightMode?: boolean
// 线'predecessor' | 'successor' | null
dragLinkMode?: 'predecessor' | 'successor' | null
// 线
isLinkDragSource?: boolean
//
isValidLinkTarget?: boolean
//
isInvalidLinkTarget?: boolean
//
allTasks?: Task[]
} }
interface TaskStatus { interface TaskStatus {
@@ -71,8 +82,12 @@ const emit = defineEmits([
'add-predecessor', 'add-predecessor',
'add-successor', 'add-successor',
'delete', 'delete',
'delete-link',
'context-menu', 'context-menu',
'long-press', 'long-press',
'link-drag-start',
'link-drag-move',
'link-drag-end',
]) ])
defineSlots<{ defineSlots<{
@@ -198,6 +213,9 @@ const longPressTimer = ref<number | null>(null)
const longPressTriggered = ref(false) const longPressTriggered = ref(false)
const LONG_PRESS_DURATION = 1000 // 1 const LONG_PRESS_DURATION = 1000 // 1
// TaskBar LinkAnchor
const isTaskBarHovered = ref(false)
// - 使 // - 使
const dragThreshold = computed(() => barConfig.value.dragThreshold ?? 5) const dragThreshold = computed(() => barConfig.value.dragThreshold ?? 5)
const isDragThresholdMet = ref(false) // const isDragThresholdMet = ref(false) //
@@ -1232,23 +1250,21 @@ const handleMouseUp = () => {
document.removeEventListener('mouseup', handleMouseUp) document.removeEventListener('mouseup', handleMouseUp)
} }
// ResizeObserver
let nameResizeObserver: ResizeObserver | null = null
onMounted(() => { onMounted(() => {
nextTick(() => { nextTick(() => {
reportBarPosition() reportBarPosition()
// 使 ResizeObserver // 使 ResizeObserver
if (taskBarNameRef.value) { if (taskBarNameRef.value) {
const nameResizeObserver = new ResizeObserver((entries) => { nameResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) { for (const entry of entries) {
nameTextWidth.value = entry.contentRect.width nameTextWidth.value = entry.contentRect.width
} }
}) })
nameResizeObserver.observe(taskBarNameRef.value) nameResizeObserver.observe(taskBarNameRef.value)
//
onUnmounted(() => {
nameResizeObserver.disconnect()
})
} }
}) })
@@ -1277,6 +1293,12 @@ onMounted(() => {
// //
onUnmounted(() => { onUnmounted(() => {
// ResizeObserver
if (nameResizeObserver) {
nameResizeObserver.disconnect()
nameResizeObserver = null
}
window.removeEventListener('timeline-scale-updated', handleTimelineScaleUpdate) window.removeEventListener('timeline-scale-updated', handleTimelineScaleUpdate)
window.removeEventListener('timeline-force-recalculate', handleForceRecalculate) window.removeEventListener('timeline-force-recalculate', handleForceRecalculate)
window.removeEventListener('close-all-taskbar-menus', closeContextMenu) window.removeEventListener('close-all-taskbar-menus', closeContextMenu)
@@ -2255,7 +2277,54 @@ const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
closeContextMenu() closeContextMenu()
} }
// //
const handleDeleteLink = (event: { sourceTaskId: number; targetTaskId: number }) => {
emit('delete-link', event)
closeContextMenu()
}
// 线
const handleLinkDragStart = (event: { task: Task; type: 'predecessor' | 'successor'; mouseEvent: MouseEvent }) => {
emit('link-drag-start', event)
}
const handleLinkDragMove = (event: { mouseX: number; mouseY: number }) => {
emit('link-drag-move', event)
}
const handleLinkDragEnd = (event: { task: Task; type: 'predecessor' | 'successor' }) => {
emit('link-drag-end', event)
}
// LinkAnchor drag-start
const handleAnchorDragStart = (anchorEvent: { taskId: number; type: 'predecessor' | 'successor'; x: number; y: number }) => {
const mouseEvent = {
clientX: anchorEvent.x,
clientY: anchorEvent.y,
} as MouseEvent
handleLinkDragStart({
task: props.task,
type: anchorEvent.type,
mouseEvent,
})
}
// LinkAnchor drag-move
const handleAnchorDragMove = (anchorEvent: { x: number; y: number }) => {
handleLinkDragMove({
mouseX: anchorEvent.x,
mouseY: anchorEvent.y,
})
}
// LinkAnchor drag-end
const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor' | 'successor' }) => {
handleLinkDragEnd({
task: props.task,
type: anchorEvent.type,
})
}//
onMounted(() => { onMounted(() => {
window.addEventListener('close-all-taskbar-menus', closeContextMenu) window.addEventListener('close-all-taskbar-menus', closeContextMenu)
}) })
@@ -2275,6 +2344,7 @@ onUnmounted(() => {
v-if="shouldRenderTaskBar" v-if="shouldRenderTaskBar"
ref="barRef" ref="barRef"
class="task-bar" class="task-bar"
:data-task-id="task.id"
:style="{ :style="{
...taskBarStyle, ...taskBarStyle,
backgroundColor: taskStatus.bgColor, backgroundColor: taskStatus.bgColor,
@@ -2299,6 +2369,8 @@ onUnmounted(() => {
@click="handleTaskBarClick" @click="handleTaskBarClick"
@contextmenu="handleContextMenu" @contextmenu="handleContextMenu"
@dblclick="handleTaskBarDoubleClick" @dblclick="handleTaskBarDoubleClick"
@mouseenter="isTaskBarHovered = true"
@mouseleave="isTaskBarHovered = false"
> >
<!-- 父级任务的标签 --> <!-- 父级任务的标签 -->
<div v-if="isParent" class="parent-label"> <div v-if="isParent" class="parent-label">
@@ -2411,6 +2483,36 @@ onUnmounted(() => {
@mousedown="e => handleMouseDown(e, 'resize-right')" @mousedown="e => handleMouseDown(e, 'resize-right')"
></div> ></div>
<!-- 连接线触点 - 只在非高亮模式且非父级任务时显示 -->
<!-- 前置任务触点左侧 -->
<LinkAnchor
v-if="!isParent && !isInHighlightMode"
type="predecessor"
:task-id="task.id"
:visible="isTaskBarHovered"
:is-drag-source="isLinkDragSource && dragLinkMode === 'predecessor'"
:is-drag-target="isValidLinkTarget || isInvalidLinkTarget"
:is-valid-target="isValidLinkTarget"
:global-dragging="!!dragLinkMode"
@drag-start="handleAnchorDragStart"
@drag-move="handleAnchorDragMove"
@drag-end="handleAnchorDragEnd"
/>
<!-- 后置任务触点右侧 -->
<LinkAnchor
v-if="!isParent && !isInHighlightMode"
type="successor"
:task-id="task.id"
:visible="isTaskBarHovered"
:is-drag-source="isLinkDragSource && dragLinkMode === 'successor'"
:is-drag-target="isValidLinkTarget || isInvalidLinkTarget"
:is-valid-target="isValidLinkTarget"
:global-dragging="!!dragLinkMode"
@drag-start="handleAnchorDragStart"
@drag-move="handleAnchorDragMove"
@drag-end="handleAnchorDragEnd"
/>
<!-- 半圆气泡指示器 - 只在 TaskBar 完全消失时显示 --> <!-- 半圆气泡指示器 - 只在 TaskBar 完全消失时显示 -->
<div <div
v-if="bubbleIndicator.show && !isParent" v-if="bubbleIndicator.show && !isParent"
@@ -2434,12 +2536,14 @@ onUnmounted(() => {
:visible="contextMenuVisible" :visible="contextMenuVisible"
:task="contextMenuTask" :task="contextMenuTask"
:position="contextMenuPosition" :position="contextMenuPosition"
:all-tasks="allTasks"
@close="closeContextMenu" @close="closeContextMenu"
@start-timer="$emit('start-timer', props.task)" @start-timer="$emit('start-timer', props.task)"
@stop-timer="$emit('stop-timer', props.task)" @stop-timer="$emit('stop-timer', props.task)"
@add-predecessor="$emit('add-predecessor', props.task)" @add-predecessor="$emit('add-predecessor', props.task)"
@add-successor="$emit('add-successor', props.task)" @add-successor="$emit('add-successor', props.task)"
@delete="handleTaskDelete" @delete="handleTaskDelete"
@delete-link="handleDeleteLink"
/> />
</div> </div>
@@ -3029,7 +3133,7 @@ onUnmounted(() => {
padding: 12px; padding: 12px;
border-radius: 8px; border-radius: 8px;
font-size: 12px; font-size: 12px;
z-index: 10000; /* 确保在最上层 */ z-index: 9999999999; /* 确保在最上层 */
max-width: 250px; max-width: 250px;
box-shadow: box-shadow:
0 8px 24px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4),
@@ -3082,7 +3186,7 @@ onUnmounted(() => {
border-radius: 6px; border-radius: 6px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
z-index: 10001; z-index: 999999999;
box-shadow: 0 2px 12px rgba(0, 123, 255, 0.4); box-shadow: 0 2px 12px rgba(0, 123, 255, 0.4);
pointer-events: none; pointer-events: none;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
+206
View File
@@ -4,6 +4,7 @@ import { useI18n } from '../composables/useI18n'
import type { Task } from '../models/classes/Task' import type { Task } from '../models/classes/Task'
import ConfirmTimerDialog from './ConfirmTimerDialog.vue' import ConfirmTimerDialog from './ConfirmTimerDialog.vue'
import GanttConfirmDialog from './GanttConfirmDialog.vue' import GanttConfirmDialog from './GanttConfirmDialog.vue'
import { getPredecessorIds } from '../utils/predecessorUtils'
// Props // Props
interface Props { interface Props {
@@ -13,6 +14,7 @@ interface Props {
y: number y: number
} }
task: Task | null task: Task | null
allTasks?: Task[] //
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -23,6 +25,7 @@ const emit = defineEmits([
'add-predecessor', 'add-predecessor',
'add-successor', 'add-successor',
'delete', // delete 'delete', // delete
'delete-link', //
'close', 'close',
]) ])
@@ -134,6 +137,51 @@ const handleDeleteNo = () => {
// story // story
const showTimerMenu = computed(() => props.task?.type !== 'story') const showTimerMenu = computed(() => props.task?.type !== 'story')
//
const showDeleteLinksSubmenu = ref(false)
let submenuHideTimer: number | null = null
//
const taskLinks = computed(() => {
const links: Array<{ type: 'predecessor' | 'successor'; taskId: number; taskName: string }> = []
if (!props.task || !props.allTasks) return links
//
if (props.task.predecessor) {
const predecessorIds = getPredecessorIds(props.task.predecessor)
for (const id of predecessorIds) {
const task = props.allTasks.find(t => t.id === id)
if (task) {
links.push({
type: 'predecessor',
taskId: id,
taskName: task.name || `任务 ${id}`,
})
}
}
}
//
for (const task of props.allTasks) {
if (task.predecessor && task.id !== props.task.id) {
const predecessorIds = getPredecessorIds(task.predecessor)
if (predecessorIds.includes(props.task.id)) {
links.push({
type: 'successor',
taskId: task.id,
taskName: task.name || `任务 ${task.id}`,
})
}
}
}
return links
})
//
const hasLinks = computed(() => taskLinks.value.length > 0)
// //
watch( watch(
[() => props.visible, () => props.position], [() => props.visible, () => props.position],
@@ -243,6 +291,42 @@ const handleAddSuccessor = () => {
} }
} }
//
const handleDeleteLink = (link: { type: 'predecessor' | 'successor'; taskId: number }) => {
//
if (submenuHideTimer) {
clearTimeout(submenuHideTimer)
submenuHideTimer = null
}
//
showDeleteLinksSubmenu.value = false
//
emit('delete-link', {
sourceTaskId: link.type === 'predecessor' ? link.taskId : props.task!.id,
targetTaskId: link.type === 'predecessor' ? props.task!.id : link.taskId,
})
//
emit('close')
}
// /
const handleSubmenuMouseEnter = () => {
if (submenuHideTimer) {
clearTimeout(submenuHideTimer)
submenuHideTimer = null
}
showDeleteLinksSubmenu.value = true
}
const handleSubmenuMouseLeave = () => {
submenuHideTimer = window.setTimeout(() => {
showDeleteLinksSubmenu.value = false
}, 200)
}
// //
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (menuRef.value && !menuRef.value.contains(event.target as Node)) { if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
@@ -267,6 +351,10 @@ onUnmounted(() => {
// //
document.removeEventListener('mousedown', handleClickOutside) document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown) document.removeEventListener('keydown', handleKeyDown)
//
if (submenuHideTimer) {
clearTimeout(submenuHideTimer)
}
}) })
</script> </script>
@@ -311,6 +399,43 @@ onUnmounted(() => {
</div> </div>
{{ t.addSuccessor }} {{ t.addSuccessor }}
</div> </div>
<!-- 删除链接菜单带子菜单 -->
<div
v-if="hasLinks"
class="menu-item menu-item-with-submenu"
@mouseenter="handleSubmenuMouseEnter"
@mouseleave="handleSubmenuMouseLeave"
>
<div class="icon-wrapper">
<i class="menu-icon link-delete-icon"></i>
</div>
{{ t.deleteLinks }}
<i class="submenu-arrow"></i>
<!-- 子菜单 -->
<div
v-if="showDeleteLinksSubmenu"
class="submenu"
@mouseenter="handleSubmenuMouseEnter"
@mouseleave="handleSubmenuMouseLeave"
>
<!-- eslint-disable vue/no-v-html -->
<div
v-for="link in taskLinks"
:key="`${link.type}-${link.taskId}`"
class="menu-item submenu-item"
@click.stop="handleDeleteLink(link)"
>
<span
v-html="
link.type === 'predecessor'
? t.predecessorLink.replace('{name}', link.taskName)
: t.successorLink.replace('{name}', link.taskName)
"
></span>
</div>
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
<div class="menu-divider"></div> <div class="menu-divider"></div>
<div class="menu-item menu-item-danger" @click="handleDeleteClick"> <div class="menu-item menu-item-danger" @click="handleDeleteClick">
<div class="icon-wrapper"> <div class="icon-wrapper">
@@ -588,4 +713,85 @@ onUnmounted(() => {
width: 92%; width: 92%;
margin-left: 4%; margin-left: 4%;
} }
/* 带子菜单的菜单项 */
.menu-item-with-submenu {
position: relative;
padding-right: 30px;
}
.submenu-arrow {
position: absolute;
right: 12px;
font-style: normal;
font-size: 16px;
color: #999;
}
.submenu {
position: absolute;
left: 100%;
top: 0;
margin-left: 4px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
min-width: 200px;
z-index: 10001;
border: 1px solid #e4e7ed;
animation: fadeIn 0.15s ease-out;
}
.submenu-item {
padding: 8px 16px;
font-size: 13px;
white-space: nowrap;
}
.submenu-item:hover {
background-color: #fef0f0;
color: #f56c6c;
}
/* 链接删除图标 */
.link-delete-icon {
width: 20px;
height: 20px;
position: relative;
}
.link-delete-icon::before {
content: '';
position: absolute;
width: 14px;
height: 2px;
background-color: currentColor;
top: 50%;
left: 3px;
transform: translateY(-50%) rotate(45deg);
}
.link-delete-icon::after {
content: '';
position: absolute;
width: 14px;
height: 2px;
background-color: currentColor;
top: 50%;
left: 3px;
transform: translateY(-50%) rotate(-45deg);
}
/* 暗色主题下的子菜单 */
:global(html[data-theme='dark']) .submenu {
background-color: #2c2c2c;
border-color: #444444;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
}
:global(html[data-theme='dark']) .submenu-item:hover {
background-color: #3a1f1f;
color: #f56c6c;
}
</style> </style>
+3 -3
View File
@@ -963,7 +963,7 @@ const handleAssigneeChanged = (event: Event) => {
<button <button
v-if="isEdit && task" v-if="isEdit && task"
type="button" type="button"
class="btn btn-danger" class="gantt-btn gantt-btn-danger"
:disabled="submitting" :disabled="submitting"
@click="handleDelete" @click="handleDelete"
> >
@@ -986,10 +986,10 @@ const handleAssigneeChanged = (event: Event) => {
/> />
</div> </div>
<div class="footer-right"> <div class="footer-right">
<button type="button" class="btn btn-default" @click="handleClose">{{ t.cancel }}</button> <button type="button" class="gantt-btn gantt-btn-default" @click="handleClose">{{ t.cancel }}</button>
<button <button
type="button" type="button"
class="btn btn-primary" class="gantt-btn gantt-btn-primary"
:disabled="submitting" :disabled="submitting"
@click="handleSubmit" @click="handleSubmit"
> >
+1
View File
@@ -621,6 +621,7 @@ onUnmounted(() => {
:columns="visibleColumns" :columns="visibleColumns"
:get-column-width-style="getColumnWidthStyle" :get-column-width-style="getColumnWidthStyle"
:disable-children-render="true" :disable-children-render="true"
:show-task-icon="props.taskListConfig?.showTaskIcon"
:enable-drag="props.enableTaskRowMove" :enable-drag="props.enableTaskRowMove"
:drag-start="startDrag" :drag-start="startDrag"
:drag-over="handleDragOver" :drag-over="handleDragOver"
+21 -2
View File
@@ -38,6 +38,7 @@ interface Props {
columns: TaskListColumnConfig[] columns: TaskListColumnConfig[]
getColumnWidthStyle?: (column: { width?: number | string }) => StyleValue getColumnWidthStyle?: (column: { width?: number | string }) => StyleValue
disableChildrenRender?: boolean disableChildrenRender?: boolean
showTaskIcon?: boolean // true
enableDrag?: boolean enableDrag?: boolean
dragStart?: (task: Task, element: HTMLElement, event: MouseEvent) => void dragStart?: (task: Task, element: HTMLElement, event: MouseEvent) => void
dragOver?: (task: Task, element: HTMLElement, event: MouseEvent) => void dragOver?: (task: Task, element: HTMLElement, event: MouseEvent) => void
@@ -375,8 +376,14 @@ onUnmounted(() => {
<!-- 里程碑分组的占位空间用于与有折叠按钮的任务对齐 --> <!-- 里程碑分组的占位空间用于与有折叠按钮的任务对齐 -->
<span v-if="isMilestoneGroup" class="milestone-spacer"></span> <span v-if="isMilestoneGroup" class="milestone-spacer"></span>
<!-- 叶子节点的占位空间无折叠按钮且无图标显示时 -->
<span
v-if="!isParentTask && !isMilestoneGroup && showTaskIcon === false"
class="leaf-spacer"
></span>
<!-- 任务图标 --> <!-- 任务图标 -->
<span class="task-icon"> <span v-if="showTaskIcon !== false" class="task-icon">
<!-- 里程碑分组图标 - 使用菱形图标 --> <!-- 里程碑分组图标 - 使用菱形图标 -->
<svg <svg
v-if="isMilestoneGroup" v-if="isMilestoneGroup"
@@ -513,7 +520,11 @@ onUnmounted(() => {
</template> </template>
</div> </div>
</div> </div>
<template v-if="!props.disableChildrenRender && hasChildren && !props.task.collapsed && !isMilestoneGroup"> <template
v-if="
!props.disableChildrenRender && hasChildren && !props.task.collapsed && !isMilestoneGroup
"
>
<TaskRow <TaskRow
v-for="child in props.task.children" v-for="child in props.task.children"
:key="child.id" :key="child.id"
@@ -525,6 +536,7 @@ onUnmounted(() => {
:columns="props.columns" :columns="props.columns"
:get-column-width-style="props.getColumnWidthStyle" :get-column-width-style="props.getColumnWidthStyle"
:disable-children-render="props.disableChildrenRender" :disable-children-render="props.disableChildrenRender"
:show-task-icon="props.showTaskIcon"
:enable-drag="props.enableDrag" :enable-drag="props.enableDrag"
:drag-start="props.dragStart" :drag-start="props.dragStart"
:drag-over="props.dragOver" :drag-over="props.dragOver"
@@ -711,6 +723,13 @@ onUnmounted(() => {
margin-right: 4px; margin-right: 4px;
} }
/* 叶子节点占位空间 - 当不显示图标时保持缩进层级 */
.leaf-spacer {
display: inline-flex;
width: 20px; /* 18px (折叠按钮宽度) + 2px (额外间距) */
height: 18px;
}
.task-name-text { .task-name-text {
display: inline-block; display: inline-block;
max-width: calc(100% - 24px); max-width: calc(100% - 24px);
+659 -11
View File
@@ -3,10 +3,12 @@ import { ref, onMounted, onUnmounted, computed, watch, nextTick, shallowRef } fr
import TaskBar from './TaskBar.vue' import TaskBar from './TaskBar.vue'
import MilestonePoint from './MilestonePoint.vue' import MilestonePoint from './MilestonePoint.vue'
import GanttLinks from './GanttLinks.vue' import GanttLinks from './GanttLinks.vue'
import LinkDragGuide from './LinkDragGuide.vue'
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { useI18n } from '../composables/useI18n' import { useI18n } from '../composables/useI18n'
import type { TaskBarConfig } from '../models/configs/TaskBarConfig' import type { TaskBarConfig } from '../models/configs/TaskBarConfig'
import { getPredecessorIds } from '../utils/predecessorUtils' import { getPredecessorIds } from '../utils/predecessorUtils'
import { perfMonitor } from '../utils/perfMonitor'
import type { Task } from '../models/classes/Task' import type { Task } from '../models/classes/Task'
import type { Milestone } from '../models/classes/Milestone' import type { Milestone } from '../models/classes/Milestone'
import type { TimelineConfig } from '../models/configs/TimelineConfig' import type { TimelineConfig } from '../models/configs/TimelineConfig'
@@ -65,7 +67,10 @@ const emit = defineEmits<{
'stop-timer': [task: Task] 'stop-timer': [task: Task]
'add-predecessor': [task: Task] // 'add-predecessor': [task: Task] //
'add-successor': [task: Task] // 'add-successor': [task: Task] //
'predecessor-added': [{ targetTask: Task; newTask: Task }] //
'successor-added': [{ targetTask: Task; newTask: Task }] //
delete: [task: Task, deleteChildren?: boolean] delete: [task: Task, deleteChildren?: boolean]
'link-deleted': [{ sourceTaskId: number; targetTaskId: number; updatedTask: Task }] //
}>() }>()
// //
@@ -646,6 +651,571 @@ const setHighlightTask = (taskId: number) => {
} }
} }
// ==================== 线 ====================
const dragLinkMode = ref<'predecessor' | 'successor' | null>(null) //
const linkDragSourceTask = shallowRef<Task | null>(null) // 使 shallowRef
const linkDragTargetTask = shallowRef<Task | null>(null) // 使 shallowRef
const isValidLinkTarget = ref(false) // handleLinkDragEnd
const linkValidationError = ref<string>('') //
const linkAutoScrollInterval = ref<number | null>(null) //
// 🚀 Vue
let nonReactiveIsValidTarget = false
let nonReactiveErrorMessage = ''
// Map
const taskIdMap = new Map<number, Task>()
//
const ENABLE_PERF_MONITOR = false
// 线
const handleLinkDragStart = (event: { task: Task; type: 'predecessor' | 'successor'; mouseEvent: MouseEvent }) => {
dragLinkMode.value = event.type
linkDragSourceTask.value = event.task
//
if (ENABLE_PERF_MONITOR) {
perfMonitor.startFrameMonitor()
}
// 使
updateLinkDragCoordinatesNonReactive(event.mouseEvent.clientX, event.mouseEvent.clientY)
linkDragTargetTask.value = null
isValidLinkTarget.value = false
linkValidationError.value = ''
// 🚀
nonReactiveTargetTask = null
nonReactiveIsValidTarget = false
nonReactiveErrorMessage = ''
//
startLinkAutoScroll()
// Timeline LinkAnchor
document.addEventListener('mousemove', handleGlobalMouseMove)
document.addEventListener('mouseup', handleGlobalMouseUp)
}
// 🚀 RAF +
let linkDragRafId: number | null = null
let pendingMouseX = 0
let pendingMouseY = 0
// 🚀 Vue
let currentDragX = 0
let currentDragY = 0
// 🚀 RAF
const processLinkDragFrame = () => {
linkDragRafId = null
if (ENABLE_PERF_MONITOR) {
const startTime = performance.now()
updateLinkDragCoordinatesNonReactive(pendingMouseX, pendingMouseY)
perfMonitor.recordLinkDragCoordUpdate(performance.now() - startTime)
} else {
updateLinkDragCoordinatesNonReactive(pendingMouseX, pendingMouseY)
}
if (ENABLE_PERF_MONITOR) {
const startTime = performance.now()
detectLinkTargetNonReactive(pendingMouseX, pendingMouseY)
perfMonitor.recordLinkDragTargetDetect(performance.now() - startTime)
} else {
detectLinkTargetNonReactive(pendingMouseX, pendingMouseY)
}
if (linkDragGuideRef.value && linkDragSourceTask.value) {
linkDragGuideRef.value.draw(
getLinkDragStartX(),
getLinkDragStartY(),
currentDragX,
currentDragY,
nonReactiveIsValidTarget,
nonReactiveErrorMessage,
)
}
}
// 🚀 使 RAF mousemove
const handleGlobalMouseMove = (e: MouseEvent) => {
if (!dragLinkMode.value) return
// 🚀
pendingMouseX = e.clientX
pendingMouseY = e.clientY
// 🚀 RAF 使
if (linkDragRafId !== null) {
return
}
// 🚀
linkDragRafId = requestAnimationFrame(processLinkDragFrame)
}
//
const handleGlobalMouseUp = () => {
if (!dragLinkMode.value) return
//
if (ENABLE_PERF_MONITOR) {
perfMonitor.stopFrameMonitor()
}
// 🚀 RAF
if (linkDragRafId !== null) {
cancelAnimationFrame(linkDragRafId)
linkDragRafId = null
}
//
if (linkDragSourceTask.value) {
handleLinkDragEnd({
task: linkDragSourceTask.value,
type: dragLinkMode.value,
})
}
}
// bodyContent getBoundingClientRect
let cachedBodyRect: DOMRect | null = null
let bodyRectCacheTime = 0
const BODY_RECT_CACHE_DURATION = 200 // 200ms
let bodyRectInvalidated = false //
// 🚀
const updateCoordinates = (mouseX: number, mouseY: number): void => {
if (!bodyContentRef.value) {
currentDragX = mouseX
currentDragY = mouseY
return
}
const now = Date.now()
const shouldRefreshRect =
!cachedBodyRect || bodyRectInvalidated || now - bodyRectCacheTime > BODY_RECT_CACHE_DURATION
if (shouldRefreshRect) {
cachedBodyRect = bodyContentRef.value.getBoundingClientRect()
bodyRectCacheTime = now
bodyRectInvalidated = false
}
currentDragX = mouseX - cachedBodyRect!.left
currentDragY = mouseY - cachedBodyRect!.top
}
// 🚀 Vue
const updateLinkDragCoordinatesNonReactive = (mouseX: number, mouseY: number) => {
updateCoordinates(mouseX, mouseY)
}
// 🚀 使 RAF
const handleLinkDragMove = (event: { mouseX: number; mouseY: number }) => {
// 🚀
pendingMouseX = event.mouseX
pendingMouseY = event.mouseY
// 🚀 RAF
if (linkDragRafId !== null) {
return
}
// 🚀
linkDragRafId = requestAnimationFrame(processLinkDragFrame)
}
// 线
const handleLinkDragEnd = (event: { task: Task; type: 'predecessor' | 'successor' }) => {
// rect
cachedBodyRect = null
//
document.removeEventListener('mousemove', handleGlobalMouseMove)
document.removeEventListener('mouseup', handleGlobalMouseUp)
//
stopLinkAutoScroll()
// 🚀 LinkDragGuide
linkDragGuideRef.value?.clear()
//
if (linkDragTargetTask.value && isValidLinkTarget.value) {
createLink(event.task, linkDragTargetTask.value, event.type)
}
//
dragLinkMode.value = null
linkDragSourceTask.value = null
linkDragTargetTask.value = null
isValidLinkTarget.value = false
}
//
const ANCHOR_SIZE = 8 // px
const ANCHOR_TOLERANCE = 4 // px
// 🚀
// { taskId: number, anchorType: 'left' | 'right' } null
const detectTargetTaskId = (mouseX: number, mouseY: number): { taskId: number; anchorType: 'left' | 'right' } | null => {
// 使 rect
if (!cachedBodyRect) {
cachedBodyRect = bodyContentRef.value!.getBoundingClientRect()
bodyRectCacheTime = Date.now()
}
const relativeX = mouseX - cachedBodyRect.left
const relativeY = mouseY - cachedBodyRect.top
const halfSize = (ANCHOR_SIZE + ANCHOR_TOLERANCE) / 2
const expandedHalfSize = halfSize + 10
for (const taskIdStr in taskBarPositions.value) {
const pos = taskBarPositions.value[taskIdStr]
const taskId = Number(taskIdStr)
if (
relativeY < pos.top - expandedHalfSize ||
relativeY > pos.top + pos.height + expandedHalfSize
) {
continue
}
const anchorY = pos.top + pos.height / 2
// predecessor
const leftAnchorX = pos.left
if (
relativeX >= leftAnchorX - halfSize &&
relativeX <= leftAnchorX + halfSize &&
relativeY >= anchorY - halfSize &&
relativeY <= anchorY + halfSize
) {
return { taskId, anchorType: 'left' }
}
// successor
const rightAnchorX = pos.left + pos.width
if (
relativeX >= rightAnchorX - halfSize &&
relativeX <= rightAnchorX + halfSize &&
relativeY >= anchorY - halfSize &&
relativeY <= anchorY + halfSize
) {
return { taskId, anchorType: 'right' }
}
}
return null
}
// 🚀
const updateTargetTaskState = (foundTarget: Task | null, anchorType?: 'left' | 'right'): void => {
const currentTargetId = nonReactiveTargetTask?.id ?? null
const newTargetId = foundTarget?.id ?? null
if (currentTargetId !== newTargetId) {
nonReactiveTargetTask = foundTarget
if (foundTarget && linkDragSourceTask.value) {
const validation = validateLink(
linkDragSourceTask.value,
foundTarget,
dragLinkMode.value!,
anchorType,
)
nonReactiveIsValidTarget = validation.valid
nonReactiveErrorMessage = validation.error || ''
isValidLinkTarget.value = validation.valid
linkDragTargetTask.value = foundTarget
} else {
nonReactiveIsValidTarget = false
nonReactiveErrorMessage = ''
isValidLinkTarget.value = false
linkDragTargetTask.value = null
}
}
}
// 🚀 Vue
// handleLinkDragEnd
let nonReactiveTargetTask: Task | null = null
const detectLinkTargetNonReactive = (mouseX: number, mouseY: number) => {
if (!linkDragSourceTask.value || !bodyContentRef.value) return
const result = detectTargetTaskId(mouseX, mouseY)
const foundTarget = result !== null ? taskIdMap.get(result.taskId) || null : null
const anchorType = result?.anchorType
updateTargetTaskState(foundTarget, anchorType)
}
// { valid: boolean, error?: string }
const validateLink = (
sourceTask: Task,
targetTask: Task,
mode: 'predecessor' | 'successor',
targetAnchorType?: 'left' | 'right',
): { valid: boolean; error?: string } => {
// 0.
if (targetAnchorType) {
if (mode === 'predecessor' && targetAnchorType !== 'right') {
return { valid: false, error: '前置任务应连接到右侧触点' }
}
if (mode === 'successor' && targetAnchorType !== 'left') {
return { valid: false, error: '后置任务应连接到左侧触点' }
}
}
// 1.
if (sourceTask.id === targetTask.id) {
return { valid: false, error: '不能连接到自己' }
}
// 2.
if (targetTask.isParent || targetTask.type === 'milestone') {
return { valid: false, error: '不能连接到父级任务或里程碑' }
}
// 3.
if (mode === 'predecessor') {
// predecessorBA AB
// BA
if (hasCircularDependency(targetTask.id, sourceTask.id)) {
return { valid: false, error: '会产生循环依赖' }
}
} else {
// successorAB AB
// AB
if (hasCircularDependency(sourceTask.id, targetTask.id)) {
return { valid: false, error: '会产生循环依赖' }
}
}
// 4.
// AB B.predecessor [A.id]
if (mode === 'predecessor') {
// predecessorBA AB
// B.predecessor A.id
if (sourceTask.predecessor) {
const predecessorIds = getPredecessorIds(sourceTask.predecessor)
if (predecessorIds.includes(targetTask.id)) {
return { valid: false, error: '关系已存在' }
}
}
} else {
// successorAB AB
// B.predecessor A.id
if (targetTask.predecessor) {
const predecessorIds = getPredecessorIds(targetTask.predecessor)
if (predecessorIds.includes(sourceTask.id)) {
return { valid: false, error: '关系已存在' }
}
}
}
return { valid: true }
}
//
const hasCircularDependency = (taskId: number, targetId: number): boolean => {
const visited = new Set<number>()
const queue: number[] = [taskId]
while (queue.length > 0) {
const currentId = queue.shift()!
if (currentId === targetId) {
return true //
}
if (visited.has(currentId)) {
continue
}
visited.add(currentId)
//
const currentTask = tasks.value.find(t => t.id === currentId)
if (currentTask && currentTask.predecessor) {
const predecessorIds = getPredecessorIds(currentTask.predecessor)
queue.push(...predecessorIds)
}
}
return false
}
//
const createLink = (sourceTask: Task, targetTask: Task, mode: 'predecessor' | 'successor') => {
console.log("----> hello:", {
mode,
sourceTask,
targetTask,
})
if (mode === 'predecessor') {
// mode='predecessor' anchor
// anchor sourceTask targetTask
// targetTask.id sourceTask.predecessor
const predecessorIds = sourceTask.predecessor ? getPredecessorIds(sourceTask.predecessor) : []
if (!predecessorIds.includes(targetTask.id)) {
predecessorIds.push(targetTask.id)
sourceTask.predecessor = predecessorIds
//
updateTask(sourceTask)
// predecessor-added
emit('predecessor-added', { targetTask: sourceTask, newTask: targetTask })
}
} else {
// mode='successor' anchor
// anchor sourceTask targetTask
// sourceTask.id targetTask.predecessor
const predecessorIds = targetTask.predecessor ? getPredecessorIds(targetTask.predecessor) : []
if (!predecessorIds.includes(sourceTask.id)) {
predecessorIds.push(sourceTask.id)
targetTask.predecessor = predecessorIds
//
updateTask(targetTask)
// successor-added
emit('successor-added', { targetTask, newTask: sourceTask })
}
}
}
//
const startLinkAutoScroll = () => {
linkAutoScrollInterval.value = window.setInterval(() => {
if (!timelineContainerElement.value || !bodyContentRef.value) return
const horizontalContainer = timelineContainerElement.value // .timeline
const verticalContainer = timelineBodyElement.value // .timeline-body
if (!verticalContainer) return
const rect = horizontalContainer.getBoundingClientRect()
const SCROLL_ZONE = 80 //
const SCROLL_SPEED = 15 //
// 使
const mouseX = pendingMouseX // 使X
const mouseY = pendingMouseY // 使Y
let scrolled = false
// 使 horizontalContainer
if (mouseX < rect.left + SCROLL_ZONE && horizontalContainer.scrollLeft > 0) {
//
horizontalContainer.scrollLeft -= SCROLL_SPEED
scrolled = true
} else if (mouseX > rect.right - SCROLL_ZONE) {
//
const maxScrollLeft = horizontalContainer.scrollWidth - horizontalContainer.clientWidth
if (horizontalContainer.scrollLeft < maxScrollLeft) {
horizontalContainer.scrollLeft += SCROLL_SPEED
scrolled = true
}
}
// 使 verticalContainer
if (mouseY < rect.top + SCROLL_ZONE && verticalContainer.scrollTop > 0) {
//
verticalContainer.scrollTop -= SCROLL_SPEED
scrolled = true
} else if (mouseY > rect.bottom - SCROLL_ZONE) {
//
const maxScrollTop = verticalContainer.scrollHeight - verticalContainer.clientHeight
if (verticalContainer.scrollTop < maxScrollTop) {
verticalContainer.scrollTop += SCROLL_SPEED
scrolled = true
}
}
// 线
if (scrolled) {
//
bodyRectInvalidated = true
//
detectLinkTargetNonReactive(mouseX, mouseY)
// 线
if (linkDragGuideRef.value && linkDragSourceTask.value) {
linkDragGuideRef.value.draw(
getLinkDragStartX(),
getLinkDragStartY(),
currentDragX,
currentDragY,
nonReactiveIsValidTarget,
nonReactiveErrorMessage,
)
}
}
}, 30) //
}
//
const stopLinkAutoScroll = () => {
if (linkAutoScrollInterval.value !== null) {
clearInterval(linkAutoScrollInterval.value)
linkAutoScrollInterval.value = null
}
}
// 线
const cleanupLinkDrag = () => {
stopLinkAutoScroll()
//
cachedBodyRect = null
// RAF
if (linkDragRafId !== null) {
cancelAnimationFrame(linkDragRafId)
linkDragRafId = null
}
// 🚀 LinkDragGuide
linkDragGuideRef.value?.clear()
//
document.removeEventListener('mousemove', handleGlobalMouseMove)
document.removeEventListener('mouseup', handleGlobalMouseUp)
dragLinkMode.value = null
linkDragSourceTask.value = null
linkDragTargetTask.value = null
isValidLinkTarget.value = false
}
// 线X
const getLinkDragStartX = (): number => {
if (!linkDragSourceTask.value) return 0
const position = taskBarPositions.value[linkDragSourceTask.value.id]
if (!position) return 0
//
if (dragLinkMode.value === 'predecessor') {
//
return position.left
} else {
//
return position.left + position.width
}
}
// 线Y
const getLinkDragStartY = (): number => {
if (!linkDragSourceTask.value) return 0
const position = taskBarPositions.value[linkDragSourceTask.value.id]
if (!position) return 0
//
return position.top + position.height / 2
}
// //
const isSplitterDragging = ref(false) const isSplitterDragging = ref(false)
@@ -1972,6 +2542,8 @@ const taskBarPositions = shallowRef<
const taskBarRenderKey = ref(0) const taskBarRenderKey = ref(0)
const bodyContentRef = ref<HTMLElement | null>(null) const bodyContentRef = ref<HTMLElement | null>(null)
// 🚀 LinkDragGuide API
const linkDragGuideRef = ref<InstanceType<typeof LinkDragGuide> | null>(null)
const svgWidth = ref(0) const svgWidth = ref(0)
const svgHeight = ref(0) const svgHeight = ref(0)
@@ -1997,6 +2569,7 @@ function updateSvgSize() {
// 使 timelineScrollLeft DOM // 使 timelineScrollLeft DOM
// handleTimelineScroll // handleTimelineScroll
const scrollLeft = timelineScrollLeft.value const scrollLeft = timelineScrollLeft.value
const scrollTop = timelineBodyScrollTop.value
// //
// Canvas 使 offsetLeft // Canvas 使 offsetLeft
@@ -2024,7 +2597,6 @@ function updateSvgSize() {
svgWidth.value = canvasWidth.value svgWidth.value = canvasWidth.value
svgHeight.value = clampedHeight svgHeight.value = clampedHeight
const scrollTop = timelineBodyScrollTop.value
const bufferTop = clampedHeight / 3 const bufferTop = clampedHeight / 3
let idealOffsetTop = Math.max(0, scrollTop - bufferTop) let idealOffsetTop = Math.max(0, scrollTop - bufferTop)
@@ -2244,6 +2816,9 @@ const handleTimelineBodyScroll = (event: Event) => {
// //
timelineBodyScrollTop.value = scrollTop timelineBodyScrollTop.value = scrollTop
// bodyRect 线
bodyRectInvalidated = true
debouncedUpdateCanvasPosition() debouncedUpdateCanvasPosition()
// //
@@ -2270,6 +2845,22 @@ watch(
{ immediate: true }, { immediate: true },
) )
// ID Map
watch(
tasks,
newTasks => {
taskIdMap.clear()
const addTaskToMap = (task: Task) => {
taskIdMap.set(task.id, task)
if (task.children && task.children.length > 0) {
task.children.forEach(addTaskToMap)
}
}
newTasks.forEach(addTaskToMap)
},
{ immediate: true },
)
// //
const isDragging = ref(false) const isDragging = ref(false)
const startX = ref(0) const startX = ref(0)
@@ -2278,7 +2869,7 @@ const startScrollLeft = ref(0)
const startScrollTop = ref(0) const startScrollTop = ref(0)
const timelineContainer = ref<HTMLElement | null>(null) const timelineContainer = ref<HTMLElement | null>(null)
const timelineBodyElement = ref<HTMLElement | null>(null) // timeline-body const timelineBodyElement = ref<HTMLElement | null>(null) // timeline-body
let rafId: number | null = null // requestAnimationFrame ID let scrollRafId: number | null = null // RAF ID
// //
const isAutoScrolling = ref(false) const isAutoScrolling = ref(false)
@@ -2404,12 +2995,12 @@ const handleMouseMove = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
// RAF // RAF
if (rafId !== null) { if (scrollRafId !== null) {
cancelAnimationFrame(rafId) cancelAnimationFrame(scrollRafId)
} }
// 使 requestAnimationFrame // 使 requestAnimationFrame
rafId = requestAnimationFrame(() => { scrollRafId = requestAnimationFrame(() => {
if (!timelineContainer.value) return if (!timelineContainer.value) return
// //
@@ -2433,7 +3024,7 @@ const handleMouseMove = (event: MouseEvent) => {
} }
} }
rafId = null scrollRafId = null
}) })
} }
@@ -2442,9 +3033,9 @@ const handleMouseUp = () => {
isDragging.value = false isDragging.value = false
// RAF // RAF
if (rafId !== null) { if (scrollRafId !== null) {
cancelAnimationFrame(rafId) cancelAnimationFrame(scrollRafId)
rafId = null scrollRafId = null
} }
if (timelineContainer.value) { if (timelineContainer.value) {
@@ -2470,6 +3061,9 @@ const handleTimelineScroll = (event: Event) => {
// //
timelineScrollLeft.value = scrollLeft timelineScrollLeft.value = scrollLeft
// bodyRect 线
bodyRectInvalidated = true
// //
if (timelineContainerWidth.value === 0) { if (timelineContainerWidth.value === 0) {
timelineContainerWidth.value = target.clientWidth timelineContainerWidth.value = target.clientWidth
@@ -2597,6 +3191,9 @@ onUnmounted(() => {
// //
stopAutoScroll() stopAutoScroll()
// 线
cleanupLinkDrag()
// //
window.removeEventListener('task-row-double-click', handleTaskListDoubleClick as EventListener) window.removeEventListener('task-row-double-click', handleTaskListDoubleClick as EventListener)
window.removeEventListener('task-list-hover', handleTaskListHover as EventListener) window.removeEventListener('task-list-hover', handleTaskListHover as EventListener)
@@ -2632,6 +3229,34 @@ const handleTaskDelete = (task: Task, deleteChildren?: boolean) => {
emit('delete', task, deleteChildren) emit('delete', task, deleteChildren)
} }
//
const handleDeleteLink = (event: { sourceTaskId: number; targetTaskId: number }) => {
const targetTask = tasks.value.find(t => t.id === event.targetTaskId)
if (!targetTask || !targetTask.predecessor) return
const predecessorIds = getPredecessorIds(targetTask.predecessor)
const newIds = predecessorIds.filter(id => id !== event.sourceTaskId)
//
const updatedTask = {
...targetTask,
predecessor: newIds.length > 0 ? (newIds as any) : undefined,
}
// GanttChart tasks
emit('link-deleted', {
sourceTaskId: event.sourceTaskId,
targetTaskId: event.targetTaskId,
updatedTask,
})
//
updateTask(updatedTask)
//
clearTimelineCache()
}
// //
const groupMonthsByYear = computed(() => { const groupMonthsByYear = computed(() => {
if (currentTimeScale.value !== TimelineScale.MONTH) { if (currentTimeScale.value !== TimelineScale.MONTH) {
@@ -3411,6 +4036,16 @@ const handleAddSuccessor = (task: Task) => {
:show-vertical-lines="currentTimeScale === TimelineScale.WEEK" :show-vertical-lines="currentTimeScale === TimelineScale.WEEK"
/> />
<!-- 连接线拖拽引导线 - 🚀 优化使用命令式 API RAF 直接调用 draw() -->
<LinkDragGuide
ref="linkDragGuideRef"
:active="!!dragLinkMode && !!linkDragSourceTask"
:width="canvasWidth"
:height="canvasHeight"
:offset-left="canvasOffsetLeft"
:offset-top="canvasOffsetTop"
/>
<!-- 年度视图今日标记线 --> <!-- 年度视图今日标记线 -->
<div <div
v-if="isTodayVisibleInYearView && getTodayLinePositionInYearView >= 0" v-if="isTodayVisibleInYearView && getTodayLinePositionInYearView >= 0"
@@ -3693,6 +4328,15 @@ const handleAddSuccessor = (task: Task) => {
:is-highlighted="highlightedTaskIds.has(task.id)" :is-highlighted="highlightedTaskIds.has(task.id)"
:is-primary-highlight="highlightedTaskId === task.id" :is-primary-highlight="highlightedTaskId === task.id"
:is-in-highlight-mode="isInHighlightMode" :is-in-highlight-mode="isInHighlightMode"
:drag-link-mode="dragLinkMode"
:is-link-drag-source="linkDragSourceTask?.id === task.id"
:is-valid-link-target="
linkDragTargetTask?.id === task.id && isValidLinkTarget === true
"
:is-invalid-link-target="
linkDragTargetTask?.id === task.id && isValidLinkTarget === false
"
:all-tasks="tasks"
@update:task="updateTask" @update:task="updateTask"
@bar-mounted="handleBarMounted" @bar-mounted="handleBarMounted"
@click="handleTaskBarClick(task, $event)" @click="handleTaskBarClick(task, $event)"
@@ -3706,7 +4350,11 @@ const handleAddSuccessor = (task: Task) => {
@add-predecessor="handleAddPredecessor" @add-predecessor="handleAddPredecessor"
@add-successor="handleAddSuccessor" @add-successor="handleAddSuccessor"
@delete="handleTaskDelete" @delete="handleTaskDelete"
@delete-link="handleDeleteLink"
@long-press="setHighlightTask" @long-press="setHighlightTask"
@link-drag-start="handleLinkDragStart"
@link-drag-move="handleLinkDragMove"
@link-drag-end="handleLinkDragEnd"
> >
<template v-if="$slots['custom-task-content']" #custom-task-content="barScope"> <template v-if="$slots['custom-task-content']" #custom-task-content="barScope">
<slot name="custom-task-content" v-bind="barScope" /> <slot name="custom-task-content" v-bind="barScope" />
@@ -3729,13 +4377,13 @@ const handleAddSuccessor = (task: Task) => {
flex-direction: column; flex-direction: column;
background: var(--gantt-bg-primary, #ffffff); background: var(--gantt-bg-primary, #ffffff);
overflow-x: auto; /* 横向滚动,显示滚动条 */ overflow-x: auto; /* 横向滚动,显示滚动条 */
overflow-y: hidden; /* 纵向滚动,但不显示滚动条 */ overflow-y: auto; /* 纵向滚动,显示滚动条 */
width: 100%; width: 100%;
cursor: grab; cursor: grab;
transition: background-color 0.3s ease; transition: background-color 0.3s ease;
position: relative; /* 为覆盖层定位 */ position: relative; /* 为覆盖层定位 */
/* Webkit浏览器滚动条样式 - 只显示横向滚动条 */ /* Webkit浏览器滚动条样式 */
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--gantt-scrollbar-thumb) transparent; scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
} }
+8
View File
@@ -163,6 +163,10 @@ const messages = {
predecessorPlaceholder: '请选择前置任务', predecessorPlaceholder: '请选择前置任务',
selectPredecessor: '选择前置任务', selectPredecessor: '选择前置任务',
removePredecessor: '移除前置任务', removePredecessor: '移除前置任务',
deleteLinks: '删除链接',
noLinks: '无链接',
predecessorLink: '前置: {name}',
successorLink: '后置: {name}',
operationFailed: '操作失败,请重试', operationFailed: '操作失败,请重试',
taskUpdateSuccess: '任务更新成功', taskUpdateSuccess: '任务更新成功',
taskCreateSuccess: '任务创建成功', taskCreateSuccess: '任务创建成功',
@@ -421,6 +425,10 @@ const messages = {
predecessorPlaceholder: 'Select predecessor', predecessorPlaceholder: 'Select predecessor',
selectPredecessor: 'Select Predecessor', selectPredecessor: 'Select Predecessor',
removePredecessor: 'Remove Predecessor', removePredecessor: 'Remove Predecessor',
deleteLinks: 'Delete Links',
noLinks: 'No Links',
predecessorLink: 'Predecessor: {name}',
successorLink: 'Successor: {name}',
operationFailed: 'Operation failed, please try again', operationFailed: 'Operation failed, please try again',
taskUpdateSuccess: 'Task updated successfully', taskUpdateSuccess: 'Task updated successfully',
taskCreateSuccess: 'Task created successfully', taskCreateSuccess: 'Task created successfully',
+1
View File
@@ -22,6 +22,7 @@ export interface TaskListColumnConfig {
export interface TaskListConfig { export interface TaskListConfig {
columns?: TaskListColumnConfig[] columns?: TaskListColumnConfig[]
showAllColumns?: boolean // 是否显示所有列,默认true showAllColumns?: boolean // 是否显示所有列,默认true
showTaskIcon?: boolean // 是否显示任务图标,默认true
defaultWidth?: number | string // 默认展开宽度,支持像素数字(如 320)或百分比字符串(如 '30%'),默认320px defaultWidth?: number | string // 默认展开宽度,支持像素数字(如 320)或百分比字符串(如 '30%'),默认320px
minWidth?: number | string // 最小宽度,支持像素数字(如 280)或百分比字符串(如 '20%'),默认280px,不能小于280px minWidth?: number | string // 最小宽度,支持像素数字(如 280)或百分比字符串(如 '20%'),默认280px,不能小于280px
maxWidth?: number | string // 最大宽度,支持像素数字(如 1160)或百分比字符串(如 '80%'),默认1160px maxWidth?: number | string // 最大宽度,支持像素数字(如 1160)或百分比字符串(如 '80%'),默认1160px
+18 -18
View File
@@ -1,5 +1,5 @@
/* 通用 .btn 基础按钮样式 */ /* 通用 .gantt-btn 基础按钮样式 */
.btn { .gantt-btn {
padding: 10px 20px; padding: 10px 20px;
border-radius: 4px; border-radius: 4px;
font-size: 14px; font-size: 14px;
@@ -17,66 +17,66 @@
outline: none; outline: none;
} }
.btn:disabled { .gantt-btn:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.6; opacity: 0.6;
} }
/* 通用 .btn-default 按钮样式(亮色+暗黑模式) */ /* 通用 .gantt-btn-default 按钮样式(亮色+暗黑模式) */
.btn-default { .gantt-btn-default {
background: var(--gantt-bg-secondary, #f5f7fa); background: var(--gantt-bg-secondary, #f5f7fa);
border-color: var(--gantt-border-medium, #dcdfe6); border-color: var(--gantt-border-medium, #dcdfe6);
color: var(--gantt-text-secondary, #8d9095); color: var(--gantt-text-secondary, #8d9095);
} }
.btn-default:hover:not(:disabled) { .gantt-btn-default:hover:not(:disabled) {
border-color: var(--gantt-border-dark, #c0c4cc); border-color: var(--gantt-border-dark, #c0c4cc);
color: var(--gantt-primary, #409eff); color: var(--gantt-primary, #409eff);
background: var(--gantt-bg-hover, #f0f1f3); background: var(--gantt-bg-hover, #f0f1f3);
} }
/* 暗黑主题下 .btn-default 全局样式 */ /* 暗黑主题下 .gantt-btn-default 全局样式 */
:global(html[data-theme='dark']) .btn-default { :global(html[data-theme='dark']) .gantt-btn-default {
background: var(--gantt-bg-tertiary, #454545) !important; background: var(--gantt-bg-tertiary, #454545) !important;
border-color: var(--gantt-border-color, #dcdfe6) !important; border-color: var(--gantt-border-color, #dcdfe6) !important;
color: var(--gantt-text-white, #ffffff) !important; color: var(--gantt-text-white, #ffffff) !important;
} }
:global(html[data-theme='dark']) .btn-default:hover:not(:disabled) { :global(html[data-theme='dark']) .gantt-btn-default:hover:not(:disabled) {
background: var(--gantt-bg-hover, rgba(255, 255, 255, 0.1)) !important; background: var(--gantt-bg-hover, rgba(255, 255, 255, 0.1)) !important;
border-color: var(--gantt-primary, #409eff) !important; border-color: var(--gantt-primary, #409eff) !important;
color: var(--gantt-primary, #409eff) !important; color: var(--gantt-primary, #409eff) !important;
} }
/* 通用 .btn-primary 按钮样式 */ /* 通用 .gantt-btn-primary 按钮样式 */
.btn-primary { .gantt-btn-primary {
background: var(--gantt-primary, #409eff); background: var(--gantt-primary, #409eff);
border-color: var(--gantt-primary, #409eff); border-color: var(--gantt-primary, #409eff);
color: #fff; color: #fff;
} }
.btn-primary:hover:not(:disabled) { .gantt-btn-primary:hover:not(:disabled) {
background: #66b1ff; background: #66b1ff;
border-color: #66b1ff; border-color: #66b1ff;
} }
/* 通用 .btn-danger 按钮样式 */ /* 通用 .gantt-btn-danger 按钮样式 */
.btn-danger { .gantt-btn-danger {
background: var(--gantt-danger, #f56c6c); background: var(--gantt-danger, #f56c6c);
border-color: var(--gantt-danger, #f56c6c); border-color: var(--gantt-danger, #f56c6c);
color: #fff; color: #fff;
} }
.btn-danger:hover:not(:disabled) { .gantt-btn-danger:hover:not(:disabled) {
background: #f78989; background: #f78989;
border-color: #f78989; border-color: #f78989;
} }
/* 通用 .btn-warning 按钮样式 */ /* 通用 .gantt-btn-warning 按钮样式 */
.btn-warning { .gantt-btn-warning {
background: var(--gantt-warning, #e6a23c); background: var(--gantt-warning, #e6a23c);
border-color: var(--gantt-warning, #e6a23c); border-color: var(--gantt-warning, #e6a23c);
color: #fff; color: #fff;
} }
.btn-warning:hover:not(:disabled) { .gantt-btn-warning:hover:not(:disabled) {
background: #ebb563; background: #ebb563;
border-color: #ebb563; border-color: #ebb563;
} }
+9
View File
@@ -0,0 +1,9 @@
// 导出所有样式为字符串(用于运行时动态注入)
import themeVariables from './theme-variables.css?inline'
import listStyles from './list.css?inline'
import appStyles from './app.css?inline'
// 合并所有样式
const allStyles = `${themeVariables}\n${listStyles}\n${appStyles}`
export default allStyles
+165
View File
@@ -0,0 +1,165 @@
/**
* Canvas - uni-app Web
*/
// uni-app 类型声明
declare global {
interface Window {
uni?: {
createCanvasContext: (canvasId: string) => CanvasRenderingContext2D & { draw?: () => void }
}
}
}
/**
* Canvas uni-app Web
*/
export type CanvasContext = CanvasRenderingContext2D & { draw?: () => void }
/**
* Canvas
*/
export interface CanvasInitOptions {
/** Canvas 元素引用(Web 环境) */
canvas: HTMLCanvasElement | null
/** Canvas IDuni-app 环境) */
canvasId: string
/** 显示宽度 */
width: number
/** 显示高度 */
height: number
/** Canvas 上下文配置(仅 Web 环境) */
contextOptions?: CanvasRenderingContext2DSettings
}
/**
* Canvas
*/
export class CanvasContextManager {
private cachedCtx: CanvasContext | null = null
private cachedWidth = 0
private cachedHeight = 0
private isUniApp = false
constructor() {
// 判断是否在 uni-app 环境中
this.isUniApp = typeof window !== 'undefined' && !!window.uni
}
/**
* uni-app
*/
get isUniAppEnvironment(): boolean {
return this.isUniApp
}
/**
* Canvas
* @param options
* @returns Canvas null
*/
getContext(options: CanvasInitOptions): CanvasContext | null {
const { canvas, canvasId, width, height, contextOptions } = options
// 只在尺寸变化时重新初始化
if (!this.cachedCtx || this.cachedWidth !== width || this.cachedHeight !== height) {
let ctx: CanvasContext | null = null
if (this.isUniApp && window.uni) {
// uni-app 环境:使用 uni.createCanvasContext
ctx = window.uni.createCanvasContext(canvasId)
} else {
// Web 环境:使用标准 Canvas API
if (!canvas) return null
const dpr = window.devicePixelRatio || 1
ctx = canvas.getContext('2d', contextOptions || { alpha: true }) as CanvasContext
if (!ctx) return null
const pixelWidth = width * dpr
const pixelHeight = height * dpr
canvas.width = pixelWidth
canvas.height = pixelHeight
ctx.scale(dpr, dpr)
}
if (!ctx) return null
this.cachedCtx = ctx
this.cachedWidth = width
this.cachedHeight = height
}
return this.cachedCtx
}
/**
*
*/
clear(width: number, height: number): void {
if (!this.cachedCtx) return
this.cachedCtx.clearRect(0, 0, width, height)
}
/**
* uni-app uni-app
*/
draw(): void {
if (this.isUniApp && this.cachedCtx?.draw) {
this.cachedCtx.draw()
}
}
/**
* Canvas
*/
reset(): void {
this.cachedCtx = null
this.cachedWidth = 0
this.cachedHeight = 0
}
}
/**
* uni-app
*/
export function isUniAppEnvironment(): boolean {
return typeof window !== 'undefined' && !!window.uni
}
/**
* Canvas API
*/
export function createCanvasContext(
canvas: HTMLCanvasElement | null,
canvasId: string,
width: number,
height: number,
contextOptions?: CanvasRenderingContext2DSettings,
): CanvasContext | null {
const isUniApp = isUniAppEnvironment()
if (isUniApp && window.uni) {
// uni-app 环境
return window.uni.createCanvasContext(canvasId)
} else {
// Web 环境
if (!canvas) return null
const dpr = window.devicePixelRatio || 1
const ctx = canvas.getContext('2d', contextOptions || { alpha: true }) as CanvasContext
if (!ctx) return null
const pixelWidth = width * dpr
const pixelHeight = height * dpr
canvas.width = pixelWidth
canvas.height = pixelHeight
ctx.scale(dpr, dpr)
return ctx
}
}
+164
View File
@@ -33,6 +33,38 @@ const drawStats: DrawStats = {
const MAX_RECENT_DRAWS = 100 // 保留最近 100 次绘制记录 const MAX_RECENT_DRAWS = 100 // 保留最近 100 次绘制记录
// ==================== 连接线拖拽性能监控 ====================
interface LinkDragStats {
coordUpdateCount: number
coordUpdateTotalTime: number
targetDetectCount: number
targetDetectTotalTime: number
lastReportTime: number
}
const linkDragStats: LinkDragStats = {
coordUpdateCount: 0,
coordUpdateTotalTime: 0,
targetDetectCount: 0,
targetDetectTotalTime: 0,
lastReportTime: 0,
}
// ==================== 帧时间监控 ====================
interface FrameMonitorStats {
frameMonitorId: number | null
lastFrameTime: number
longFrameCount: number
frameCount: number
}
const frameMonitorStats: FrameMonitorStats = {
frameMonitorId: null,
lastFrameTime: 0,
longFrameCount: 0,
frameCount: 0,
}
export const perfMonitor = { export const perfMonitor = {
/** /**
* *
@@ -260,4 +292,136 @@ export const perfMonitor = {
) )
}, durationMs) }, durationMs)
}, },
// ==================== 连接线拖拽性能监控 API ====================
/**
*
* @param duration
* @returns
*/
recordLinkDragCoordUpdate(duration: number): boolean {
linkDragStats.coordUpdateCount++
linkDragStats.coordUpdateTotalTime += duration
const now = Date.now()
if (now - linkDragStats.lastReportTime > 1000) {
const avgCoordTime =
linkDragStats.coordUpdateCount > 0
? (linkDragStats.coordUpdateTotalTime / linkDragStats.coordUpdateCount).toFixed(3)
: 0
const avgTargetTime =
linkDragStats.targetDetectCount > 0
? (linkDragStats.targetDetectTotalTime / linkDragStats.targetDetectCount).toFixed(3)
: 0
// eslint-disable-next-line no-console
console.log(
`[LinkDrag Perf] 坐标更新: ${linkDragStats.coordUpdateCount}次, 平均${avgCoordTime}ms | ` +
`目标检测: ${linkDragStats.targetDetectCount}次, 平均${avgTargetTime}ms`,
)
this.resetLinkDragStats()
linkDragStats.lastReportTime = now
return true
}
return false
},
/**
*
* @param duration
*/
recordLinkDragTargetDetect(duration: number): void {
linkDragStats.targetDetectCount++
linkDragStats.targetDetectTotalTime += duration
},
/**
* 线
*/
resetLinkDragStats(): void {
linkDragStats.coordUpdateCount = 0
linkDragStats.coordUpdateTotalTime = 0
linkDragStats.targetDetectCount = 0
linkDragStats.targetDetectTotalTime = 0
},
/**
* 线
*/
getLinkDragStats() {
return {
coordUpdateCount: linkDragStats.coordUpdateCount,
coordUpdateTotalTime: linkDragStats.coordUpdateTotalTime,
targetDetectCount: linkDragStats.targetDetectCount,
targetDetectTotalTime: linkDragStats.targetDetectTotalTime,
}
},
// ==================== 帧时间监控 API ====================
/**
* 线
* @param longFrameThreshold 30ms
*/
startFrameMonitor(longFrameThreshold = 30): void {
frameMonitorStats.lastFrameTime = performance.now()
frameMonitorStats.longFrameCount = 0
frameMonitorStats.frameCount = 0
const checkFrame = () => {
const now = performance.now()
const frameTime = now - frameMonitorStats.lastFrameTime
frameMonitorStats.frameCount++
// 超过阈值的帧被认为是"长帧"
if (frameTime > longFrameThreshold) {
frameMonitorStats.longFrameCount++
// eslint-disable-next-line no-console
console.warn(`[Frame Monitor] 长帧检测: ${frameTime.toFixed(1)}ms`)
}
frameMonitorStats.lastFrameTime = now
frameMonitorStats.frameMonitorId = requestAnimationFrame(checkFrame)
}
frameMonitorStats.frameMonitorId = requestAnimationFrame(checkFrame)
},
/**
*
*/
stopFrameMonitor(): void {
if (frameMonitorStats.frameMonitorId !== null) {
cancelAnimationFrame(frameMonitorStats.frameMonitorId)
frameMonitorStats.frameMonitorId = null
const percentage =
frameMonitorStats.frameCount > 0
? (frameMonitorStats.longFrameCount / frameMonitorStats.frameCount * 100).toFixed(1)
: 0
// eslint-disable-next-line no-console
console.log(
`[Frame Monitor] 统计: ${frameMonitorStats.frameCount}帧, ` +
`长帧数: ${frameMonitorStats.longFrameCount} (${percentage}%)`,
)
}
},
/**
*
*/
getFrameMonitorStats() {
return {
frameCount: frameMonitorStats.frameCount,
longFrameCount: frameMonitorStats.longFrameCount,
percentage:
frameMonitorStats.frameCount > 0
? (frameMonitorStats.longFrameCount / frameMonitorStats.frameCount * 100).toFixed(1)
: 0,
}
},
} }
+1 -1
View File
@@ -15,7 +15,7 @@ const generateCssExportPlugin = () => {
export default defineConfig({ export default defineConfig({
plugins: [vue(), generateCssExportPlugin()], plugins: [vue(), generateCssExportPlugin()],
build: { build: {
outDir: '../npm-package/dist', outDir: './npm-package/dist',
emptyOutDir: true, emptyOutDir: true,
lib: { lib: {
entry: './src/index.ts', entry: './src/index.ts',