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

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