@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
}"
|
}"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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') {
|
||||||
|
// predecessor模式:从B的左触点拖拽到A,要建立 A→B 的关系
|
||||||
|
// 检查:如果B已经(直接或间接)依赖A,会形成循环
|
||||||
|
if (hasCircularDependency(targetTask.id, sourceTask.id)) {
|
||||||
|
return { valid: false, error: '会产生循环依赖' }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// successor模式:从A的右触点拖拽到B,要建立 A→B 的关系
|
||||||
|
// 检查:如果A已经(直接或间接)依赖B,会形成循环
|
||||||
|
if (hasCircularDependency(sourceTask.id, targetTask.id)) {
|
||||||
|
return { valid: false, error: '会产生循环依赖' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 检查是否已存在该连接
|
||||||
|
// 注意:A→B 的关系存储在 B.predecessor 中,值为 [A.id]
|
||||||
|
if (mode === 'predecessor') {
|
||||||
|
// predecessor模式:从B的左触点拖拽到A,要建立 A→B 的关系
|
||||||
|
// 检查 B.predecessor 中是否已包含 A.id
|
||||||
|
if (sourceTask.predecessor) {
|
||||||
|
const predecessorIds = getPredecessorIds(sourceTask.predecessor)
|
||||||
|
if (predecessorIds.includes(targetTask.id)) {
|
||||||
|
return { valid: false, error: '关系已存在' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// successor模式:从A的右触点拖拽到B,要建立 A→B 的关系
|
||||||
|
// 检查 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 ID(uni-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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user