This commit is contained in:
LINING-PC\lining
2025-06-28 21:20:50 +08:00
commit 5ba84d5309
57 changed files with 15687 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue'
import '../styles/app.css'
const props = defineProps({
visible: Boolean,
title: { type: String, default: '确认' },
message: { type: String, default: '' },
confirmText: { type: String, default: '确认' },
cancelText: { type: String, default: '取消' },
})
const emit = defineEmits(['confirm', 'cancel'])
const onConfirm = () => emit('confirm')
const onCancel = () => emit('cancel')
</script>
<template>
<div v-if="visible" class="gantt-confirm-overlay" @click="onCancel">
<div class="gantt-confirm-dialog" @click.stop>
<div class="gantt-confirm-header">
<h4 class="gantt-confirm-title">{{ props.title }}</h4>
</div>
<div class="gantt-confirm-content">
<p>{{ props.message }}</p>
</div>
<div class="gantt-confirm-footer">
<button type="button" class="btn btn-default" @click="onCancel">
{{ props.cancelText }}
</button>
<button type="button" class="btn btn-danger" @click="onConfirm">
{{ props.confirmText }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.gantt-confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.25);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.gantt-confirm-dialog {
background: var(--gantt-bg-primary, #fff);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18);
min-width: 320px;
max-width: 90vw;
padding: 24px 28px 18px 28px;
display: flex;
flex-direction: column;
}
.gantt-confirm-header {
margin-bottom: 12px;
}
.gantt-confirm-title {
font-size: 18px;
font-weight: 600;
color: var(--gantt-text-primary, #303133);
margin: 0;
}
.gantt-confirm-content {
font-size: 15px;
color: var(--gantt-text-secondary, #606266);
margin-bottom: 18px;
}
.gantt-confirm-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
:global(html[data-theme='dark']) .gantt-confirm-dialog {
background: var(--gantt-bg-secondary, #f8f9fa) !important;
border-color: var(--gantt-border-dark, #999999) !important;
}
</style>
File diff suppressed because it is too large Load Diff
+773
View File
@@ -0,0 +1,773 @@
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue'
import { useI18n } from '../composables/useI18n'
import DatePicker from './DatePicker.vue'
import GanttConfirmDialog from './GanttConfirmDialog.vue'
import type { Milestone } from '../models/classes/Milestone'
import '../styles/app.css'
interface Props {
visible: boolean
milestone?: Milestone | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:visible': [visible: boolean]
close: []
save: [milestone: Milestone]
delete: [milestoneId: number]
}>()
// 表单数据
const formData = reactive<Milestone>({
name: '',
startDate: '',
assignee: '',
type: 'milestone',
icon: 'diamond',
description: '',
})
// 表单验证错误
const errors = reactive({
name: '',
startDate: '',
})
// 下拉菜单状态
const dropdownOpen = ref(false)
// 删除确认状态
const showDeleteConfirm = ref(false)
// 描述文本框引用
const descriptionTextarea = ref<HTMLTextAreaElement | null>(null)
// 自动调整文本框高度
const adjustTextareaHeight = () => {
const textarea = descriptionTextarea.value
if (textarea) {
textarea.style.height = 'auto'
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`
}
}
// 监听里程碑变化,初始化表单数据
watch(
() => props.milestone,
newMilestone => {
if (newMilestone) {
Object.assign(formData, {
id: newMilestone.id,
name: newMilestone.name || '',
startDate: newMilestone.startDate || '',
assignee: newMilestone.assignee || '',
type: newMilestone.type || 'milestone',
icon: newMilestone.icon || 'diamond',
description: newMilestone.description || '',
})
} else {
// 新建里程碑时重置表单
Object.assign(formData, {
id: undefined,
name: '',
startDate: '',
assignee: '',
type: 'milestone',
icon: 'diamond',
description: '',
})
}
// 清空错误
errors.name = ''
errors.startDate = ''
},
{ immediate: true },
)
// 表单验证
const validateForm = () => {
errors.name = ''
errors.startDate = ''
let isValid = true
if (!formData.name.trim()) {
errors.name = t('milestoneNameRequired')
isValid = false
}
if (!formData.startDate) {
errors.startDate = t('milestoneDateRequired')
isValid = false
}
return isValid
}
// 表单是否有效
const isFormValid = computed(() => {
return formData.name.trim() && formData.startDate
})
// 选择图标
const selectIcon = (icon: string) => {
formData.icon = icon
dropdownOpen.value = false
}
// 保存处理
const handleSave = () => {
if (validateForm()) {
emit('save', { ...formData })
closeDialog()
}
}
// 删除处理
const handleDelete = () => {
if (formData.id) {
showDeleteConfirm.value = true
}
}
// 确认删除
const confirmDelete = () => {
if (formData.id) {
emit('delete', formData.id)
showDeleteConfirm.value = false
closeDialog()
}
}
// 取消删除
const cancelDelete = () => {
showDeleteConfirm.value = false
}
// 是否为编辑模式
const isEditMode = computed(() => {
return props.milestone && props.milestone.id
})
// 关闭对话框
const closeDialog = () => {
dropdownOpen.value = false
emit('update:visible', false)
emit('close')
}
// 点击遮罩层关闭
const handleOverlayClick = () => {
closeDialog()
}
// 多语言
const { t: globalT } = useI18n()
// 直接用全局 t 获取翻译
const t = (key: string) => {
const globalValue = (globalT.value as any)[key]
return globalValue || key
}
</script>
<template>
<div v-if="visible" class="milestone-dialog-overlay" @click="handleOverlayClick">
<div class="milestone-dialog" @click.stop>
<div class="milestone-dialog-header">
<h3 class="milestone-dialog-title">
<svg
class="milestone-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<g transform="rotate(45 12 12)">
<rect
x="4"
y="4"
width="16"
height="16"
rx="4"
ry="4"
fill="currentColor"
opacity="0.1"
/>
<rect
x="4"
y="4"
width="16"
height="16"
rx="4"
ry="4"
stroke="currentColor"
fill="none"
/>
</g>
</svg>
{{ isEditMode ? globalT.editMilestone : globalT.newMilestone }}
</h3>
<button class="milestone-dialog-close" :title="t('close')" @click="closeDialog">×</button>
</div>
<div class="milestone-dialog-content">
<form class="milestone-form" @submit.prevent="handleSave">
<!-- 第一行里程碑名称 -->
<div class="milestone-form-row">
<div class="milestone-form-item milestone-form-item-full">
<label class="milestone-form-label required" for="milestone-name">{{
t('milestoneName')
}}</label>
<input
id="milestone-name"
v-model="formData.name"
type="text"
class="milestone-form-input"
:class="{ error: errors.name }"
:placeholder="t('enterMilestoneName')"
required
/>
<span v-if="errors.name" class="milestone-form-error">{{ errors.name }}</span>
</div>
</div>
<!-- 第二行里程碑日期和图标 -->
<div class="milestone-form-row">
<div class="milestone-form-item">
<label class="milestone-form-label required" for="milestone-date">{{
t('milestoneDate')
}}</label>
<DatePicker
id="milestone-date"
v-model="formData.startDate"
type="date"
placeholder="请选择里程碑日期"
:class="{ error: errors.startDate }"
/>
<span v-if="errors.startDate" class="milestone-form-error">{{
errors.startDate
}}</span>
</div>
<div class="milestone-form-item">
<label class="milestone-form-label" for="milestone-icon">{{
t('milestoneIcon')
}}</label>
<div class="milestone-icon-dropdown" :class="{ active: dropdownOpen }">
<button
id="milestone-icon"
type="button"
class="milestone-icon-trigger"
:aria-expanded="dropdownOpen"
aria-haspopup="listbox"
@click="dropdownOpen = !dropdownOpen"
>
<div class="selected-icon">
<svg
v-if="formData.icon === 'diamond'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<g transform="rotate(45 12 12)">
<rect
x="6"
y="6"
width="12"
height="12"
rx="3"
ry="3"
fill="currentColor"
/>
</g>
</svg>
<div v-else-if="formData.icon === 'rocket'" class="rocket-emoji-mini">🚀</div>
<span>{{ t(formData.icon || 'diamond') }}</span>
</div>
<svg
class="dropdown-arrow"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6,9 12,15 18,9"></polyline>
</svg>
</button>
<div v-if="dropdownOpen" class="milestone-icon-options">
<div
class="icon-option"
:class="{ selected: formData.icon === 'diamond' }"
@click="selectIcon('diamond')"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<g transform="rotate(45 12 12)">
<rect
x="6"
y="6"
width="12"
height="12"
rx="3"
ry="3"
fill="currentColor"
/>
</g>
</svg>
<span>{{ t('diamond') }}</span>
</div>
<div
class="icon-option"
:class="{ selected: formData.icon === 'rocket' }"
@click="selectIcon('rocket')"
>
<div class="rocket-emoji-option">🚀</div>
<span>{{ t('rocket') }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 第三行描述 -->
<div class="milestone-form-row">
<div class="milestone-form-item milestone-form-item-full">
<label class="milestone-form-label" for="milestone-description">{{
t('description')
}}</label>
<div class="textarea-wrapper">
<textarea
id="milestone-description"
ref="descriptionTextarea"
v-model="formData.description"
class="milestone-form-textarea"
:placeholder="t('enterDescription')"
rows="4"
maxlength="500"
@input="adjustTextareaHeight"
></textarea>
<div class="textarea-footer">
<span class="char-count">{{ formData.description?.length || 0 }}/500</span>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="milestone-dialog-footer">
<div class="milestone-dialog-footer-left">
<button v-if="isEditMode" type="button" class="btn btn-danger" @click="handleDelete">
{{ globalT.delete }}
</button>
</div>
<div class="milestone-dialog-footer-right">
<button type="button" class="btn btn-default" @click="closeDialog">
{{ globalT.cancel }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!isFormValid"
@click="handleSave"
>
{{ globalT.confirm }}
</button>
</div>
</div>
</div>
<GanttConfirmDialog
:visible="showDeleteConfirm"
:title="globalT.delete"
:message="globalT.confirmDelete"
:confirm-text="globalT.confirm"
:cancel-text="globalT.cancel"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</div>
</template>
<style scoped>
@import '../styles/theme-variables.css';
.milestone-dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000; /* 确保在全屏模式下也能正常显示 */
}
.milestone-dialog {
background: var(--gantt-bg-primary, #ffffff);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow: hidden;
border: 1px solid var(--gantt-border-color, #dcdfe6);
}
.milestone-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--gantt-border-color, #dcdfe6);
background: var(--gantt-bg-secondary, #f8f9fa);
}
.milestone-dialog-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--gantt-text-primary, #303133);
}
.milestone-icon {
width: 20px;
height: 20px;
color: var(--gantt-danger, #f56c6c);
filter: drop-shadow(0 0 4px var(--gantt-danger, #f56c6c));
}
.milestone-dialog-close {
width: 32px;
height: 32px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: var(--gantt-text-secondary, #909399);
transition: all 0.2s ease;
font-size: 24px;
font-weight: bold;
line-height: 1;
}
.milestone-dialog-content {
padding: 24px;
max-height: 60vh;
overflow-y: auto;
}
/* 表单样式 */
.milestone-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.milestone-form-row {
display: flex;
gap: 16px;
align-items: flex-start;
}
.milestone-form-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.milestone-form-item-full {
flex: 1 1 100%;
}
.milestone-form-label {
font-size: 14px;
font-weight: 500;
color: var(--gantt-text-secondary, #606266);
line-height: 1.4;
margin: 0;
}
.milestone-form-label.required::after {
content: '*';
color: var(--gantt-danger, #f56c6c);
margin-left: 4px;
}
.milestone-form-input {
padding: 12px 16px;
border: 1px solid var(--gantt-border-color, #dcdfe6);
border-radius: 4px;
font-size: 14px;
color: var(--gantt-text-primary, #303133);
background: var(--gantt-bg-primary, #ffffff);
transition: all 0.2s ease;
box-sizing: border-box;
height: 44px;
}
.milestone-form-input:focus {
outline: none;
border-color: var(--gantt-primary, #409eff);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
.milestone-form-input.error {
border-color: var(--gantt-danger, #f56c6c);
box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.1);
}
.milestone-form-input::placeholder {
color: var(--gantt-text-placeholder, #c0c4cc);
}
.milestone-form-textarea {
width: 100%;
box-sizing: border-box;
padding: 12px 16px;
border: 1px solid var(--gantt-border-color, #dcdfe6);
border-radius: 4px;
font-size: 14px;
color: var(--gantt-text-primary, #303133);
background: var(--gantt-bg-primary, #ffffff);
transition: all 0.2s ease;
resize: none;
min-height: 80px;
max-height: 120px;
font-family: inherit;
line-height: 1.5;
overflow-y: auto;
}
.milestone-form-textarea:focus {
outline: none;
border-color: var(--gantt-primary, #409eff);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
.milestone-form-textarea::placeholder {
color: var(--gantt-text-placeholder, #c0c4cc);
}
.textarea-wrapper {
position: relative;
}
.textarea-footer {
display: flex;
justify-content: flex-end;
margin-top: 4px;
}
.char-count {
font-size: 12px;
color: var(--gantt-text-secondary, #909399);
}
.milestone-form-error {
font-size: 12px;
color: var(--gantt-danger, #f56c6c);
margin-top: 4px;
}
/* 图标下拉菜单样式 */
.milestone-icon-dropdown {
position: relative;
}
.milestone-icon-trigger {
width: 100%;
height: 44px; /* 固定高度 */
padding: 12px 16px;
border: 1px solid var(--gantt-border-color, #dcdfe6);
border-radius: 4px;
background: var(--gantt-bg-primary, #ffffff);
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.2s ease;
box-sizing: border-box;
}
.milestone-icon-trigger:hover {
border-color: var(--gantt-primary, #409eff);
}
.milestone-icon-dropdown.active .milestone-icon-trigger {
border-color: var(--gantt-primary, #409eff);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
.selected-icon {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--gantt-text-primary, #303133);
}
.selected-icon svg {
width: 16px;
height: 16px;
color: var(--gantt-danger, #f56c6c);
}
.rocket-emoji-mini {
font-size: 16px;
transform: rotate(-45deg);
display: inline-block;
}
.dropdown-arrow {
width: 16px;
height: 16px;
color: var(--gantt-text-secondary, #909399);
transition: transform 0.2s ease;
}
.milestone-icon-dropdown.active .dropdown-arrow {
transform: rotate(180deg);
}
.milestone-icon-options {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--gantt-bg-primary, #ffffff);
border: 1px solid var(--gantt-border-color, #dcdfe6);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
margin-top: 4px;
}
.icon-option {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid var(--gantt-border-light, #e4e7ed);
}
.icon-option:last-child {
border-bottom: none;
}
.icon-option:hover {
background: var(--gantt-bg-light, #f5f7fa);
}
.icon-option.selected {
background: var(--gantt-primary-lightest, #ecf5ff);
color: var(--gantt-primary, #409eff);
}
.icon-option svg {
width: 16px;
height: 16px;
color: var(--gantt-danger, #f56c6c);
}
.rocket-emoji-option {
font-size: 16px;
transform: rotate(-45deg);
display: inline-block;
}
.icon-option span {
font-size: 14px;
}
/* 对话框底部 */
.milestone-dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid var(--gantt-border-color, #dcdfe6);
background: var(--gantt-bg-secondary, #f8f9fa);
}
.milestone-dialog-footer-left {
display: flex;
align-items: center;
}
.milestone-dialog-footer-right {
display: flex;
align-items: center;
gap: 12px;
}
/* 删除确认弹窗样式 */
.milestone-confirm-dialog {
background: var(--gantt-bg-primary, #ffffff);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 400px;
border: 1px solid var(--gantt-border-color, #dcdfe6);
}
.milestone-confirm-header {
padding: 20px 24px 0;
}
.milestone-confirm-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--gantt-text-primary, #303133);
}
.milestone-confirm-content {
padding: 16px 24px;
}
.milestone-confirm-content p {
margin: 0;
font-size: 14px;
color: var(--gantt-text-secondary, #606266);
line-height: 1.5;
}
.milestone-confirm-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--gantt-border-color, #dcdfe6);
background: var(--gantt-bg-secondary, #f8f9fa);
}
/* 暗黑模式下的确认弹窗 */
:global(html[data-theme='dark']) .milestone-confirm-dialog {
background: var(--gantt-bg-dark, #1d1e1f);
border-color: var(--gantt-border-dark, #3c3e40);
}
:global(html[data-theme='dark']) .milestone-confirm-footer {
background: var(--gantt-bg-darker, #141414);
border-color: var(--gantt-border-dark, #3c3e40);
}
</style>
+343
View File
@@ -0,0 +1,343 @@
<script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue'
import type { Milestone } from '../models/classes/Milestone'
interface Props {
date: string // 里程碑日期
rowHeight: number
dayWidth: number
startDate: Date
name?: string
milestone?: Milestone // 完整的里程碑数据
}
const props = defineProps<Props>()
// 添加事件定义
const emit = defineEmits<{
'milestone-double-click': [milestone: Milestone]
'update:milestone': [milestone: Milestone] // 新增里程碑更新事件
}>()
// 拖拽相关状态
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartLeft = ref(0)
const tempMilestoneData = ref<{ startDate?: string } | null>(null)
// 双击事件处理
const handleDoubleClick = () => {
if (props.milestone) {
emit('milestone-double-click', props.milestone)
} else {
// 如果没有完整数据,构造基本的里程碑对象
const basicMilestone: Milestone = {
name: props.name || '里程碑',
startDate: props.date,
type: 'milestone',
}
emit('milestone-double-click', basicMilestone)
}
}
// 日期工具函数
const addDaysToLocalDate = (date: Date, days: number): Date => {
const result = new Date(date)
result.setDate(result.getDate() + days)
return result
}
const formatDateToLocalString = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 拖拽事件处理
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
isDragging.value = true
dragStartX.value = e.clientX
dragStartLeft.value = parseInt(milestoneStyle.value.left)
tempMilestoneData.value = null
// 添加全局事件监听器
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
const handleMouseMove = (e: MouseEvent) => {
if (isDragging.value) {
const deltaX = e.clientX - dragStartX.value
const newLeft = Math.max(0, dragStartLeft.value + deltaX)
const newStartDate = addDaysToLocalDate(props.startDate, newLeft / props.dayWidth)
// 只更新临时数据,不触发事件
tempMilestoneData.value = {
startDate: formatDateToLocalString(newStartDate),
}
}
}
const handleMouseUp = () => {
// 如果有临时数据,说明发生了拖拽,提交数据更新
if (tempMilestoneData.value && props.milestone) {
const updatedMilestone = {
...props.milestone,
...tempMilestoneData.value,
}
console.log('里程碑拖拽完成,提交数据更新:', updatedMilestone)
emit('update:milestone', updatedMilestone)
// 清空临时数据
tempMilestoneData.value = null
}
isDragging.value = false
// 移除全局事件监听器
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
// 计算菱形位置 - 考虑拖拽临时数据
const milestoneStyle = computed(() => {
const milestoneDate = tempMilestoneData.value?.startDate
? new Date(tempMilestoneData.value.startDate)
: new Date(props.date)
// 修正:props.startDate 可能为 undefined,需防御性处理
if (!props.startDate || isNaN(new Date(props.date).getTime())) {
return {
left: '0px',
top: '0px',
width: 'auto',
height: 'auto',
}
}
const startDiff = Math.floor(
(milestoneDate.getTime() - props.startDate.getTime()) / (1000 * 60 * 60 * 24),
)
const size = Math.min(props.rowHeight, props.dayWidth * 1.2, 24)
return {
left: `${startDiff * props.dayWidth + props.dayWidth / 2 - size / 2}px`,
top: `${(props.rowHeight - size) / 2}px`,
width: 'auto',
height: 'auto',
}
})
// 里程碑统一使用红色配色
const milestoneColor = computed(() => {
// 使用危险色(红色)统一里程碑配色
return 'var(--gantt-danger, #f56c6c)'
})
const milestoneBorder = computed(() => {
// 稍浅的红色作为边框
return 'var(--gantt-danger-light, #fab6b6)'
})
// 计算里程碑图标类型
const milestoneIcon = computed(() => {
return props.milestone?.icon || 'diamond' // 默认为菱形
})
// 组件销毁时清理事件监听器
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
})
</script>
<template>
<div
class="milestone"
:style="milestoneStyle"
:title="props.name || '里程碑'"
:class="{ dragging: isDragging }"
@dblclick="handleDoubleClick"
@mousedown="handleMouseDown"
>
<svg :width="24" :height="24" :viewBox="`0 0 24 24`">
<!-- 菱形图标 -->
<g v-if="milestoneIcon === 'diamond'" transform="rotate(45 16 16)">
<rect
x="4"
y="8"
width="15"
height="15"
rx="6"
ry="6"
:fill="milestoneColor"
:stroke="milestoneBorder"
stroke-width="2"
/>
</g>
<!-- 火箭图标 -->
<g v-else-if="milestoneIcon === 'rocket'">
<foreignObject x="0" y="0" width="24" height="24">
<div class="rocket-emoji">🚀</div>
</foreignObject>
</g>
<!-- 默认菱形图标 -->
<g v-else transform="rotate(45 16 16)">
<rect
x="4"
y="8"
width="15"
height="15"
rx="6"
ry="6"
:fill="milestoneColor"
:stroke="milestoneBorder"
stroke-width="2"
/>
</g>
</svg>
<span v-if="props.name" class="milestone-label milestone-label-right">{{ props.name }}</span>
</div>
</template>
<style scoped>
@import '../styles/theme-variables.css';
.milestone {
position: absolute;
z-index: 120;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
cursor: pointer;
user-select: none;
}
/* 里程碑SVG发光效果 */
.milestone svg {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f56c6c));
animation: milestone-glow 2s ease-in-out infinite alternate;
}
/* 里程碑发光动画 */
@keyframes milestone-glow {
from {
filter: drop-shadow(0 0 4px var(--gantt-danger, #f56c6c));
}
to {
filter: drop-shadow(0 0 12px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 20px rgba(245, 108, 108, 0.3));
}
}
/* 悬停时增强发光效果 */
.milestone:hover svg {
filter: drop-shadow(0 0 16px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 24px rgba(245, 108, 108, 0.4));
animation: milestone-glow-intense 1.5s ease-in-out infinite alternate;
}
@keyframes milestone-glow-intense {
from {
filter: drop-shadow(0 0 12px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 20px rgba(245, 108, 108, 0.4));
}
to {
filter: drop-shadow(0 0 20px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 32px rgba(245, 108, 108, 0.6));
}
}
.milestone-label {
font-size: 12px;
font-weight: bold;
color: var(--gantt-text-primary, #222);
white-space: nowrap;
}
.milestone-label-right {
margin-left: 5px;
align-self: center;
}
/* 火箭emoji样式 */
.rocket-emoji {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
line-height: 1;
transform: rotate(-45deg);
transition: transform 0.3s ease;
}
/* 火箭emoji悬停效果 */
.milestone:hover .rocket-emoji {
transform: rotate(-45deg) scale(1.1);
}
/* 暗黑模式下的适配 */
:global(html[data-theme='dark']) .milestone-label {
color: var(--gantt-text-white, #ffffff) !important;
}
:global(html[data-theme='dark']) .milestone svg {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f67c7c));
animation: milestone-glow-dark 2s ease-in-out infinite alternate;
}
:global(html[data-theme='dark']) .milestone:hover svg {
filter: drop-shadow(0 0 16px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 24px rgba(246, 124, 124, 0.4));
animation: milestone-glow-intense-dark 1.5s ease-in-out infinite alternate;
}
/* 暗黑模式发光动画 */
@keyframes milestone-glow-dark {
from {
filter: drop-shadow(0 0 4px var(--gantt-danger, #f67c7c));
}
to {
filter: drop-shadow(0 0 12px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 20px rgba(246, 124, 124, 0.3));
}
}
@keyframes milestone-glow-intense-dark {
from {
filter: drop-shadow(0 0 12px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 20px rgba(246, 124, 124, 0.4));
}
to {
filter: drop-shadow(0 0 20px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 32px rgba(246, 124, 124, 0.6));
}
}
/* 拖拽状态样式 */
.milestone.dragging {
z-index: 1000;
opacity: 0.8;
transform: scale(1.1);
cursor: grabbing;
}
.milestone.dragging svg {
filter: drop-shadow(0 0 20px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 32px rgba(245, 108, 108, 0.6));
animation: none;
}
:global(html[data-theme='dark']) .milestone.dragging svg {
filter: drop-shadow(0 0 20px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 32px rgba(246, 124, 124, 0.6));
}
</style>
+596
View File
@@ -0,0 +1,596 @@
<script setup lang="ts">
import { ref, computed, onUnmounted, onMounted, nextTick, watch } from 'vue'
import type { Task } from '../models/classes/Task'
interface Props {
task: Task
rowHeight: number
dayWidth: number
startDate: Date
isParent?: boolean
onDoubleClick?: (task: Task) => void
}
const props = defineProps<Props>()
const emit = defineEmits(['update:task', 'bar-mounted', 'dblclick'])
// 日期工具函数 - 处理时区安全的日期创建和操作
const createLocalDate = (dateString: string | Date | undefined | null): Date | null => {
if (!dateString) return null
if (dateString instanceof Date) {
return dateString
}
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
const [year, month, day] = dateString.split('-').map(Number)
return new Date(year, month - 1, day)
}
const d = new Date(dateString)
return isNaN(d.getTime()) ? null : d
}
const createLocalToday = (): Date => {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), now.getDate())
}
const formatDateToLocalString = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const addDaysToLocalDate = (date: Date, days: number): Date => {
const result = new Date(date)
result.setDate(result.getDate() + days)
return result
}
// 拖拽状态
const isDragging = ref(false)
const isResizingLeft = ref(false)
const isResizingRight = ref(false)
const dragStartX = ref(0)
const dragStartLeft = ref(0)
const dragStartWidth = ref(0)
const resizeStartX = ref(0)
const resizeStartWidth = ref(0)
const resizeStartLeft = ref(0)
// 缓存拖拽/拉伸过程中的临时数据,只在鼠标抬起时提交更新
const tempTaskData = ref<{
startDate?: string
endDate?: string
} | null>(null)
const barRef = ref<HTMLElement | null>(null)
// 计算任务条位置和宽度
const taskBarStyle = computed(() => {
const currentStartDate = tempTaskData.value?.startDate || props.task.startDate
const currentEndDate = tempTaskData.value?.endDate || props.task.endDate
const startDate = createLocalDate(currentStartDate)
const endDate = createLocalDate(currentEndDate)
const baseStart = createLocalDate(props.startDate)
if (!startDate || !endDate || !baseStart) {
return {
left: '0px',
width: '0px',
height: `${props.rowHeight - 10}px`,
top: '4px',
}
}
const startDiff = Math.floor((startDate.getTime() - baseStart.getTime()) / (1000 * 60 * 60 * 24))
const duration = Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1
return {
left: `${startDiff * props.dayWidth}px`,
width: `${duration * props.dayWidth}px`,
height: `${props.rowHeight - 10}px`,
top: '4px',
}
})
// 计算任务状态和颜色
const taskStatus = computed(() => {
// 父级任务(Story类型)使用与新建按钮一致的配色
if (props.isParent) {
return {
type: 'parent',
color: '#409eff', // 与新建按钮一致的蓝色
bgColor: '#409eff',
borderColor: '#409eff',
}
}
const today = createLocalToday()
const endDate = createLocalDate(props.task.endDate || '')
const progress = props.task.progress || 0
// 已完成
if (progress >= 100) {
return {
type: 'completed',
color: '#909399', // info color
bgColor: '#f4f4f5',
borderColor: '#d3d4d6',
}
}
// 已延迟(结束日期早于今天且未完成)
if (endDate && endDate < today && progress < 100) {
return {
type: 'delayed',
color: '#f56c6c', // danger color
bgColor: '#fef0f0',
borderColor: '#fbc4c4',
}
}
// 进行中(结束日期晚于今天且进度>0)
if (endDate && endDate >= today && progress > 0) {
return {
type: 'in-progress',
color: '#e6a23c', // warning color
bgColor: '#fdf6ec',
borderColor: '#f5dab1',
}
}
// 未开始(进度为0且未延迟)
return {
type: 'not-started',
color: '#409eff', // primary color
bgColor: '#ecf5ff',
borderColor: '#b3d8ff',
}
})
// 判断是否已完成
const isCompleted = computed(() => {
return (props.task.progress || 0) >= 100
})
// 计算完成部分的宽度
const progressWidth = computed(() => {
const progress = props.task.progress || 0
const totalWidth = parseInt(taskBarStyle.value.width)
return `${(progress / 100) * totalWidth}px`
})
// 鼠标事件处理
const handleMouseDown = (e: MouseEvent, type: 'drag' | 'resize-left' | 'resize-right') => {
// 如果已完成或是父级任务,禁用所有交互
if (isCompleted.value || props.isParent) {
return
}
e.preventDefault()
e.stopPropagation()
// 清空之前的临时数据
tempTaskData.value = null
if (type === 'drag') {
isDragging.value = true
dragStartX.value = e.clientX
dragStartLeft.value = parseInt(taskBarStyle.value.left)
dragStartWidth.value = parseInt(taskBarStyle.value.width)
} else if (type === 'resize-left') {
isResizingLeft.value = true
resizeStartX.value = e.clientX
resizeStartWidth.value = parseInt(taskBarStyle.value.width)
resizeStartLeft.value = parseInt(taskBarStyle.value.left)
} else if (type === 'resize-right') {
isResizingRight.value = true
resizeStartX.value = e.clientX
resizeStartWidth.value = parseInt(taskBarStyle.value.width)
resizeStartLeft.value = parseInt(taskBarStyle.value.left)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
function reportBarPosition() {
if (barRef.value) {
const rect = barRef.value.getBoundingClientRect()
emit('bar-mounted', {
id: props.task.id,
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
})
}
}
const handleMouseMove = (e: MouseEvent) => {
if (isDragging.value) {
const deltaX = e.clientX - dragStartX.value
const newLeft = Math.max(0, dragStartLeft.value + deltaX)
const newStartDate = addDaysToLocalDate(props.startDate, newLeft / props.dayWidth)
const duration = dragStartWidth.value / props.dayWidth
const newEndDate = addDaysToLocalDate(newStartDate, duration - 1)
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: formatDateToLocalString(newEndDate),
}
} else if (isResizingLeft.value) {
const deltaX = e.clientX - resizeStartX.value
const newLeft = Math.max(0, resizeStartLeft.value + deltaX)
const newStartDate = addDaysToLocalDate(props.startDate, newLeft / props.dayWidth)
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: formatDateToLocalString(newStartDate),
endDate: props.task.endDate, // 保持原来的结束日期
}
} else if (isResizingRight.value) {
const deltaX = e.clientX - resizeStartX.value
const newWidth = Math.max(props.dayWidth, resizeStartWidth.value + deltaX)
const newDurationDays = newWidth / props.dayWidth
const newEndDate = addDaysToLocalDate(
props.startDate,
resizeStartLeft.value / props.dayWidth + newDurationDays - 1,
)
// 只更新临时数据,不触发事件
tempTaskData.value = {
startDate: props.task.startDate, // 保持原来的开始日期
endDate: formatDateToLocalString(newEndDate),
}
}
}
const handleMouseUp = () => {
// 如果有临时数据,说明发生了拖拽或拉伸,提交数据更新
if (tempTaskData.value) {
const updatedTask = {
...props.task,
...tempTaskData.value,
}
console.log('TaskBar操作完成,提交数据更新:', updatedTask)
emit('update:task', updatedTask)
// 清空临时数据
tempTaskData.value = null
// 下一帧报告新位置
nextTick(() => {
reportBarPosition()
})
}
isDragging.value = false
isResizingLeft.value = false
isResizingRight.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
onMounted(() => {
nextTick(() => {
reportBarPosition()
})
})
// 监听任务数据变化,重新报告位置
watch(
() => [props.task.startDate, props.task.endDate],
() => {
nextTick(() => {
reportBarPosition()
})
},
{ deep: true },
)
// 处理TaskBar双击事件
const handleTaskBarDoubleClick = (e: MouseEvent) => {
// 阻止事件冒泡,避免触发拖拽等其他事件
e.stopPropagation()
// 如果正在拖拽或调整大小,不触发双击事件
if (isDragging.value || isResizingLeft.value || isResizingRight.value) {
return
}
// 优先调用外部传入的双击处理器
if (props.onDoubleClick && typeof props.onDoubleClick === 'function') {
props.onDoubleClick(props.task)
} else {
// 默认行为:发出双击事件给父组件
emit('dblclick', props.task)
}
}
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
})
</script>
<template>
<div
ref="barRef"
class="task-bar"
:style="{
...taskBarStyle,
backgroundColor: taskStatus.bgColor,
borderColor: taskStatus.borderColor,
color: taskStatus.color,
cursor: isCompleted || isParent ? 'default' : 'move',
}"
:class="{
dragging: isDragging,
resizing: isResizingLeft || isResizingRight,
completed: isCompleted,
'parent-task': isParent,
}"
@dblclick="handleTaskBarDoubleClick"
>
<!-- 父级任务的标签 -->
<div v-if="isParent" class="parent-label">{{ task.name }} ({{ task.progress || 0 }}%)</div>
<!-- 完成进度条非父级任务 -->
<div
v-if="!isParent && task.progress && task.progress > 0"
class="progress-bar"
:style="{
width: progressWidth,
backgroundColor: taskStatus.color,
}"
></div>
<!-- 左侧调整把手 -->
<div
v-if="!isCompleted && !isParent"
class="resize-handle resize-handle-left"
@mousedown="e => handleMouseDown(e, 'resize-left')"
></div>
<!-- 任务条主体非父级任务 -->
<div v-if="!isParent" class="task-bar-content" @mousedown="e => handleMouseDown(e, 'drag')">
<div class="task-name">{{ task.name }}</div>
<div v-if="task.progress !== undefined" class="task-progress">{{ task.progress }}%</div>
</div>
<!-- 右侧调整把手 -->
<div
v-if="!isCompleted && !isParent"
class="resize-handle resize-handle-right"
@mousedown="e => handleMouseDown(e, 'resize-right')"
></div>
</div>
</template>
<style scoped>
.task-bar {
position: absolute;
border-radius: 4px;
user-select: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: box-shadow 0.2s;
min-width: 60px;
z-index: 100;
border: 2px solid;
overflow: hidden;
}
.task-bar:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.task-bar.completed {
cursor: pointer !important;
}
.task-bar.completed:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
cursor: pointer;
}
.task-bar.dragging {
opacity: 0.8;
z-index: 1000;
}
.task-bar.resizing {
z-index: 1000;
}
.task-bar.parent-task {
position: relative;
border-radius: 0; /* 移除圆角,使用线性设计 */
margin-bottom: 20px; /* 为标签和垂直线留出空间 */
height: 10px !important; /* 降低高度,让条更细 */
border: none; /* 移除边框 */
background: #409eff !important; /* 与新建按钮一致的蓝色 */
box-shadow: none; /* 移除阴影 */
top: 50% !important; /* 上下居中 */
transform: translateY(-50%); /* 上下居中 */
cursor: pointer !important; /* 允许双击编辑 */
overflow: visible; /* 确保伪元素可见 */
}
/* 父级任务的标签 */
.task-bar.parent-task .parent-label {
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
background: #409eff; /* 与新建按钮一致的蓝色 */
color: white;
padding: 4px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
z-index: 20;
}
/* 左侧向下箭头 - 更尖 */
.task-bar.parent-task::before {
content: '';
position: absolute;
top: 10px; /* 位于进度条下方 */
left: 0;
width: 0;
height: 0;
border-right: 6px solid transparent; /* 减小宽度,让箭头更尖 */
border-top: 10px solid #409eff; /* 与新建按钮一致的蓝色 */
z-index: 15;
}
/* 右侧向下箭头 - 更尖 */
.task-bar.parent-task::after {
content: '';
position: absolute;
top: 10px; /* 位于进度条下方 */
right: 0;
width: 0;
height: 0;
border-left: 6px solid transparent; /* 减小宽度,让箭头更尖 */
border-top: 10px solid #409eff; /* 与新建按钮一致的蓝色 */
z-index: 15;
}
.progress-bar {
position: absolute;
top: 0;
left: 0;
height: 100%;
opacity: 0.3;
transition: width 0.3s ease;
}
.task-bar-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
padding: 0 8px;
font-size: 12px;
font-weight: 500;
text-align: center;
overflow: hidden;
position: relative;
z-index: 1;
}
.task-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
line-height: 1.2;
}
.task-progress {
opacity: 0.9;
margin-top: 2px;
}
.resize-handle {
position: absolute;
top: 0;
width: 6px;
height: 100%;
cursor: ew-resize;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
transition: background 0.2s;
z-index: 2;
}
.resize-handle:hover {
background: rgba(0, 0, 0, 0.2);
}
.resize-handle-left {
left: 0;
}
.resize-handle-right {
right: 0;
}
/* 暗色主题支持 */
:global(html[data-theme='dark']) .task-bar {
border-color: #111827 !important;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.7),
0 2px 4px rgba(0, 0, 0, 0.3) !important;
}
:global(html[data-theme='dark']) .task-bar:hover {
box-shadow:
0 6px 20px rgba(0, 0, 0, 0.8),
0 4px 8px rgba(0, 0, 0, 0.4) !important;
transform: translateY(-2px);
transition: all 0.2s ease;
}
:global(html[data-theme='dark']) .task-bar:hover::after {
background: rgba(7, 10, 15, 0.98) !important;
color: #f9fafb !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6) !important;
}
:global(html[data-theme='dark']) .task-bar.normal {
background: linear-gradient(135deg, #1e40af, #1e3a8a) !important;
border-color: #1e3a8a !important;
}
:global(html[data-theme='dark']) .task-bar.milestone {
background: linear-gradient(135deg, #c2410c, #9a3412) !important;
border-color: #9a3412 !important;
}
:global(html[data-theme='dark']) .task-bar.completed {
background: linear-gradient(135deg, #14532d, #16a34a) !important;
border-color: #14532d !important;
}
:global(html[data-theme='dark']) .task-bar.delayed {
background: linear-gradient(135deg, #991b1b, #dc2626) !important;
border-color: #991b1b !important;
}
:global(html[data-theme='dark']) .task-bar.parent {
background: linear-gradient(135deg, #581c87, #7c3aed) !important;
border-color: #581c87 !important;
}
:global(html[data-theme='dark']) .task-content {
color: #ffffff !important;
}
:global(html[data-theme='dark']) .task-name {
color: #ffffff !important;
}
:global(html[data-theme='dark']) .progress-bar {
background: rgba(255, 255, 255, 0.2) !important;
}
:global(html[data-theme='dark']) .progress-fill {
background: rgba(255, 255, 255, 0.8) !important;
}
:global(html[data-theme='dark']) .resize-handle {
background: rgba(255, 255, 255, 0.1) !important;
}
:global(html[data-theme='dark']) .resize-handle:hover {
background: rgba(255, 255, 255, 0.3) !important;
}
</style>
+761
View File
@@ -0,0 +1,761 @@
<script setup lang="ts">
import { ref, reactive, watch, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from '../composables/useI18n'
import DatePicker from './DatePicker.vue'
import GanttConfirmDialog from './GanttConfirmDialog.vue'
import type { Task } from '../models/classes/Task'
import '../styles/app.css'
interface Props {
visible: boolean
task?: Task | null
isEdit?: boolean
onDelete?: (task: Task) => void
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
task: null,
isEdit: false,
onDelete: undefined,
})
const emit = defineEmits<{
'update:visible': [value: boolean]
submit: [task: Task]
close: []
delete: [task: Task]
}>()
const { t } = useI18n()
const submitting = ref(false)
const isVisible = ref(props.visible)
const showDeleteConfirm = ref(false)
// 表单数据
const formData = reactive<Task>({
id: 0, // 默认值,创建时会被重新分配
name: '',
type: 'task',
assignee: '',
startDate: '',
endDate: '',
predecessor: '',
estimatedHours: 0,
actualHours: 0,
progress: 0,
description: '',
parentId: undefined, // 上级任务ID
})
// 任务列表数据
const allTasks = ref<Task[]>([])
// 获取可作为前置任务的任务列表(只包含type="task"的任务,且不包含当前任务)
const availablePredecessorTasks = computed(() => {
return allTasks.value.filter(
task => task.type === 'task' && task.id !== props.task?.id, // 排除当前任务自己
)
})
// 获取可作为上级任务的任务列表(只显示story和task类型,排除当前任务自己)
const availableParentTasks = computed(() => {
return allTasks.value
.filter(
task =>
task.id !== props.task?.id && // 排除当前任务自己
(task.type === 'story' || task.type === 'task'), // 只显示story和task类型
)
.map(task => ({
...task,
displayName: `${task.name} (${getTaskTypeDisplay(task.type || 'task')})`,
}))
})
// 获取任务类型的显示文本
const getTaskTypeDisplay = (type: string): string => {
return (t.value.taskTypeMap as Record<string, string>)?.[type] || type
}
// 错误信息
const errors = reactive({
name: '',
type: '',
startDate: '',
endDate: '',
})
// 监听 visible 属性变化
watch(
() => props.visible,
newVal => {
isVisible.value = newVal
if (newVal) {
resetForm()
if (props.task && props.isEdit) {
// 编辑模式,填充表单数据
Object.assign(formData, props.task)
}
// 抽屉显示时重新请求任务数据,确保前置任务列表是最新的
window.dispatchEvent(new CustomEvent('request-task-list'))
}
},
)
// 监听 isVisible 变化,同步到父组件
watch(isVisible, newVal => {
emit('update:visible', newVal)
})
// 重置表单
const resetForm = () => {
Object.assign(formData, {
name: '',
type: 'task',
assignee: '',
startDate: '',
endDate: '',
predecessor: '',
estimatedHours: 0,
actualHours: 0,
progress: 0,
description: '',
parentId: undefined,
})
// 清除错误信息
Object.keys(errors).forEach(key => {
errors[key as keyof typeof errors] = ''
})
}
// 表单验证
const validateForm = (): boolean => {
let isValid = true
Object.keys(errors).forEach(key => {
errors[key as keyof typeof errors] = ''
})
if (!formData.name?.trim()) {
errors.name = t.value.taskNameRequired
isValid = false
} else if (formData.name.length > 50) {
errors.name = t.value.taskNameTooLong
isValid = false
}
if (!formData.type) {
errors.type = t.value.taskTypeRequired
isValid = false
}
if (!formData.startDate) {
errors.startDate = t.value.startDateRequired
isValid = false
}
if (!formData.endDate) {
errors.endDate = t.value.endDateRequired
isValid = false
} else if (formData.startDate && new Date(formData.endDate) < new Date(formData.startDate)) {
errors.endDate = t.value.endDateInvalid
isValid = false
}
return isValid
}
// 关闭抽屉
const handleClose = () => {
isVisible.value = false
emit('close')
}
// 点击遮罩层关闭
const handleOverlayClick = () => {
handleClose()
}
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) {
return
}
try {
submitting.value = true
const taskData: Task = {
...formData,
id: props.isEdit && props.task ? props.task.id : Date.now(),
}
emit('submit', taskData)
showMessage(props.isEdit ? t.value.taskUpdateSuccess : t.value.taskCreateSuccess, 'success')
handleClose()
} catch (error) {
// 记录异常,保证 lint 通过
console.error(error)
showMessage(t.value.operationFailed, 'error')
} finally {
submitting.value = false
}
}
// 删除任务
const handleDelete = () => {
showDeleteConfirm.value = true
}
const confirmDelete = () => {
showDeleteConfirm.value = false
if (props.task && props.isEdit) {
try {
submitting.value = true
emit('delete', props.task)
handleClose()
} catch (error) {
showMessage(t.value.taskDeleteFailed, 'error')
} finally {
submitting.value = false
}
}
}
const cancelDelete = () => {
showDeleteConfirm.value = false
}
// 获取任务数据的事件处理器
const handleTasksChanged = (event: CustomEvent) => {
allTasks.value = event.detail || []
}
// 组件挂载时添加事件监听器
onMounted(() => {
window.addEventListener('task-list-updated', handleTasksChanged as EventListener)
// 请求初始任务数据
window.dispatchEvent(new CustomEvent('request-task-list'))
})
// 组件卸载时移除事件监听器
onUnmounted(() => {
window.removeEventListener('task-list-updated', handleTasksChanged as EventListener)
})
// 简单的消息提示函数
const showMessage = (message: string, type: 'success' | 'error') => {
// 创建消息元素
const messageEl = document.createElement('div')
messageEl.className = `message ${type}`
messageEl.textContent = message
messageEl.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 4px;
color: white;
font-size: 14px;
z-index: 9999;
background: ${type === 'success' ? '#67c23a' : '#f56c6c'};
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
`
document.body.appendChild(messageEl)
// 3秒后自动移除
setTimeout(() => {
if (messageEl.parentNode) {
document.body.removeChild(messageEl)
}
}, 3000)
}
</script>
<template>
<div v-if="isVisible" class="drawer-overlay" @click="handleOverlayClick">
<div class="drawer-container" @click.stop>
<!-- Drawer Header -->
<div class="drawer-header">
<h3 class="drawer-title">{{ isEdit ? t.editTask : t.newTask }}</h3>
<button class="drawer-close-btn" type="button" @click="handleClose">
<svg class="close-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<!-- Drawer Body -->
<div class="drawer-body">
<form class="task-form" @submit.prevent="handleSubmit">
<div class="form-group">
<label class="form-label" for="task-name">
{{ t.taskName }} <span class="required">*</span></label
>
<input
id="task-name"
v-model="formData.name"
type="text"
class="form-input"
:class="{ error: errors.name }"
:placeholder="t.taskNamePlaceholder"
/>
<span v-if="errors.name" class="error-text">{{ errors.name }}</span>
</div>
<div class="form-group">
<label class="form-label" for="task-type">
{{ t.taskType }} <span class="required">*</span></label
>
<select
id="task-type"
v-model="formData.type"
class="form-select"
:class="{ error: errors.type }"
>
<option value="story">{{ t.taskTypeMap.story }}</option>
<option value="task">{{ t.taskTypeMap.task }}</option>
<option value="bug">{{ t.taskTypeMap.bug }}</option>
</select>
<span v-if="errors.type" class="error-text">{{ errors.type }}</span>
</div>
<div class="form-group">
<label class="form-label" for="task-assignee">{{ t.assignee }}</label>
<select id="task-assignee" v-model="formData.assignee" class="form-select">
<option value="">请选择负责人</option>
<option value="张三">张三</option>
<option value="李四">李四</option>
<option value="王五">王五</option>
<option value="赵六">赵六</option>
<option value="钱七">钱七</option>
</select>
</div>
<!-- 上级任务选择 -->
<div class="form-group">
<label class="form-label" for="task-parent">{{ t.parentTask }}</label>
<select id="task-parent" v-model="formData.parentId" class="form-select">
<option :value="undefined">{{ t.noParentTask }}</option>
<option
v-for="parentTask in availableParentTasks"
:key="parentTask.id"
:value="parentTask.id"
>
{{ parentTask.displayName }}
</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="task-start-date">
{{ t.startDate }} <span class="required">*</span></label
>
<DatePicker
id="task-start-date"
v-model="formData.startDate"
type="date"
:placeholder="t.startDateRequired"
:class="{ error: errors.startDate }"
/>
<span v-if="errors.startDate" class="error-text">{{ errors.startDate }}</span>
</div>
<div class="form-group">
<label class="form-label" for="task-end-date">
{{ t.endDate }} <span class="required">*</span></label
>
<DatePicker
id="task-end-date"
v-model="formData.endDate"
type="date"
:placeholder="t.endDateRequired"
:class="{ error: errors.endDate }"
/>
<span v-if="errors.endDate" class="error-text">{{ errors.endDate }}</span>
</div>
</div>
<div class="form-group">
<label class="form-label" for="task-predecessor">{{ t.predecessor }}</label>
<select id="task-predecessor" v-model="formData.predecessor" class="form-select">
<option value="">{{ t.predecessorPlaceholder }}</option>
<option
v-for="predTask in availablePredecessorTasks"
:key="predTask.id"
:value="predTask.id"
>
{{ predTask.name }} (ID: {{ predTask.id }})
</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="task-estimated-hours">{{ t.estimatedHours }}</label>
<input
id="task-estimated-hours"
v-model.number="formData.estimatedHours"
type="number"
class="form-input"
placeholder="0"
min="0"
max="999"
/>
</div>
<div class="form-group">
<label class="form-label" for="task-actual-hours">{{ t.actualHours }}</label>
<input
id="task-actual-hours"
v-model.number="formData.actualHours"
type="number"
class="form-input"
placeholder="0"
min="0"
max="999"
/>
</div>
</div>
<div class="form-group">
<label class="form-label" for="task-progress">{{ t.progress }}</label>
<div class="progress-container">
<input
id="task-progress"
v-model.number="formData.progress"
type="range"
class="progress-slider"
min="0"
max="100"
step="5"
/>
<div class="progress-value">{{ formData.progress }}%</div>
</div>
</div>
<div class="form-group">
<label class="form-label" for="task-description">{{ t.description }}</label>
<textarea
id="task-description"
v-model="formData.description"
class="form-textarea"
:placeholder="t.descriptionPlaceholder"
rows="3"
></textarea>
</div>
</form>
</div>
<!-- Drawer Footer -->
<div class="drawer-footer">
<div class="footer-left">
<!-- 删除按钮仅在编辑模式下显示 -->
<button
v-if="isEdit && task"
type="button"
class="btn btn-danger"
:disabled="submitting"
@click="handleDelete"
>
<span v-if="submitting" class="loading-spinner"></span>
{{ t.delete }}
</button>
<GanttConfirmDialog
:visible="showDeleteConfirm"
:title="t.delete"
:message="t.confirmDeleteTask.replace('{name}', task?.name || '')"
:confirm-text="t.confirm"
:cancel-text="t.cancel"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</div>
<div class="footer-right">
<button type="button" class="btn btn-default" @click="handleClose">{{ t.cancel }}</button>
<button
type="button"
class="btn btn-primary"
:disabled="submitting"
@click="handleSubmit"
>
<span v-if="submitting" class="loading-spinner"></span>
{{ isEdit ? t.update : t.create }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@import '../styles/theme-variables.css';
/* 抽屉遮罩层 */
.drawer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 10000; /* 确保在全屏模式下也能正常显示 */
display: flex;
justify-content: flex-end;
align-items: stretch;
}
/* 抽屉容器 */
.drawer-container {
width: 500px;
background: var(--gantt-bg-primary, white);
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease-out;
color: var(--gantt-text-primary, #303133);
}
@keyframes slideIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
/* 抽屉头部 */
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--gantt-border-light, #ebeef5);
background: var(--gantt-bg-secondary, #f5f7fa);
}
.drawer-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--gantt-text-primary, #303133);
}
.drawer-close-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: var(--gantt-text-muted, #909399);
transition: color 0.2s;
}
.drawer-close-btn:hover {
color: var(--gantt-text-secondary, #606266);
}
.close-icon {
width: 16px;
height: 16px;
stroke-width: 2;
}
/* 抽屉主体 */
.drawer-body {
flex: 1;
padding: 24px;
overflow-y: auto;
}
/* 表单样式 */
.task-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-row {
display: flex;
gap: 16px;
}
.form-row .form-group {
flex: 1;
}
.form-label {
font-size: 14px;
font-weight: 500;
color: var(--gantt-text-secondary, #606266);
line-height: 1.4;
}
.required {
color: var(--gantt-danger, #f56c6c);
margin-left: 2px;
}
.form-input,
.form-select,
.form-textarea {
padding: 12px 16px;
border: 1px solid var(--gantt-border-medium, #dcdfe6);
border-radius: 4px;
font-size: 14px;
color: var(--gantt-text-primary, #303133); /* 录入后为黑色,与 MilestoneDialog 保持一致 */
background: var(--gantt-bg-primary, white);
transition: border-color 0.2s;
outline: none;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
border-color: var(--gantt-primary, #409eff);
}
.form-input.error,
.form-select.error {
border-color: var(--gantt-danger, #f56c6c);
}
.form-input::placeholder,
.form-select::placeholder,
.form-textarea::placeholder {
color: var(--gantt-text-placeholder, #c0c4cc);
}
.form-textarea {
resize: vertical;
min-height: 80px;
}
.error-text {
color: var(--gantt-danger, #f56c6c);
font-size: 12px;
line-height: 1.4;
}
/* 进度条容器 */
.progress-container {
display: flex;
align-items: center;
gap: 12px;
}
.progress-slider {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--gantt-border-light, #e4e7ed);
outline: none;
appearance: none;
cursor: pointer;
}
.progress-slider::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--gantt-primary, #409eff);
cursor: pointer;
}
.progress-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--gantt-primary, #409eff);
cursor: pointer;
border: none;
}
.progress-value {
font-size: 14px;
font-weight: 500;
color: var(--gantt-text-secondary, #606266);
min-width: 40px;
text-align: right;
}
/* 抽屉底部 */
.drawer-footer {
padding: 16px 24px;
border-top: 1px solid var(--gantt-border-light, #ebeef5);
background: var(--gantt-bg-toolbar, #fafafa);
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-left {
display: flex;
align-items: center;
}
.footer-right {
display: flex;
align-items: center;
gap: 12px;
}
/* 加载动画 */
.loading-spinner {
width: 12px;
height: 12px;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 消息提示样式 */
.message {
animation: messageSlideIn 0.3s ease-out;
}
@keyframes messageSlideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* 暗黑模式样式优化 */
:global(html[data-theme='dark']) .drawer-overlay {
background: rgba(0, 0, 0, 0.7) !important;
}
:global(html[data-theme='dark']) .drawer-container {
box-shadow: -4px 0 15px rgba(0, 0, 0, 0.4) !important;
}
:global(html[data-theme='dark']) .drawer-close-btn:hover {
background: var(--gantt-bg-hover, rgba(255, 255, 255, 0.1)) !important;
border-radius: 4px;
}
:global(html[data-theme='dark']) .form-input:focus,
:global(html[data-theme='dark']) .form-select:focus,
:global(html[data-theme='dark']) .form-textarea:focus {
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2) !important;
}
:global(html[data-theme='dark']) .form-input::placeholder,
:global(html[data-theme='dark']) .form-textarea::placeholder {
color: var(--gantt-text-muted, #9e9e9e) !important;
}
</style>
+485
View File
@@ -0,0 +1,485 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import TaskRow from './TaskRow.vue'
import { useI18n } from '../composables/useI18n'
import type { Task } from '../models/classes/Task'
interface Props {
tasks?: Task[]
onTaskDoubleClick?: (task: Task) => void
editComponent?: any
useDefaultDrawer?: boolean
}
const props = defineProps<Props>()
// 定义emit事件
const emit = defineEmits(['task-collapse-change'])
// 多语言支持
const { t } = useI18n()
// 内部响应式任务列表
const localTasks = ref<Task[]>([])
// 悬停状态管理
const hoveredTaskId = ref<number | null>(null)
// 拖拽状态管理
const isSplitterDragging = ref(false)
// 处理拖拽开始事件
const handleSplitterDragStart = () => {
isSplitterDragging.value = true
}
// 处理拖拽结束事件
const handleSplitterDragEnd = () => {
isSplitterDragging.value = false
}
// 处理任务行悬停事件
const handleTaskRowHover = (taskId: number | null) => {
// 如果正在拖拽Splitter,则不响应悬停事件
if (isSplitterDragging.value) {
return
}
hoveredTaskId.value = taskId
// 发送事件通知Timeline组件
window.dispatchEvent(
new CustomEvent('task-list-hover', {
detail: taskId,
}),
)
}
// 监听Timeline的悬停事件
const handleTimelineHover = (event: CustomEvent) => {
hoveredTaskId.value = event.detail
}
// 处理TaskRow双击事件 (与Timeline的逻辑保持一致)
const handleTaskRowDoubleClick = (task: Task) => {
// 如果是里程碑类型,则不响应双击事件
if (task.type === 'milestone' || task.type === 'milestone-group') {
return
}
// 优先调用外部传入的双击处理器
if (props.onTaskDoubleClick && typeof props.onTaskDoubleClick === 'function') {
props.onTaskDoubleClick(task)
} else if (props.useDefaultDrawer) {
// 默认行为:发送到Timeline处理(通过全局事件)
window.dispatchEvent(
new CustomEvent('task-row-double-click', {
detail: task,
}),
)
}
}
// 计算父级任务的进度和日期范围
const calculateParentTaskData = (
task: Task,
): { progress: number; startDate: string; endDate: string } => {
if (!task.children || task.children.length === 0) {
return {
progress: task.progress || 0,
startDate: task.startDate || '',
endDate: task.endDate || '',
}
}
// 递归计算所有子任务(包括折叠的子任务)
const allChildTasks: Task[] = []
const collectChildTasks = (tasks: Task[]) => {
tasks.forEach(childTask => {
allChildTasks.push(childTask)
if (childTask.children && childTask.children.length > 0) {
collectChildTasks(childTask.children)
}
})
}
collectChildTasks(task.children)
// 计算进度:所有子任务进度的平均值
const totalProgress = allChildTasks.reduce((sum, childTask) => {
return sum + (childTask.progress || 0)
}, 0)
const averageProgress =
allChildTasks.length > 0 ? Math.round(totalProgress / allChildTasks.length) : 0
// 计算日期范围:最早开始日期和最晚结束日期
const validTasks = allChildTasks.filter(childTask => childTask.startDate && childTask.endDate)
if (validTasks.length === 0) {
return {
progress: averageProgress,
startDate: task.startDate || '',
endDate: task.endDate || '',
}
}
const startDates = validTasks.map(childTask => new Date(childTask.startDate!))
const endDates = validTasks.map(childTask => new Date(childTask.endDate!))
const earliestStart = new Date(Math.min(...startDates.map(date => date.getTime())))
const latestEnd = new Date(Math.max(...endDates.map(date => date.getTime())))
return {
progress: averageProgress,
startDate: earliestStart.toISOString().split('T')[0],
endDate: latestEnd.toISOString().split('T')[0],
}
}
// 更新所有父级任务的进度和日期范围
const updateParentTasksData = () => {
const updateParentTask = (taskList: Task[]): Task[] => {
return taskList.map(task => {
if (task.children && task.children.length > 0) {
// 先更新子任务
const updatedChildren = updateParentTask(task.children)
// 计算父级任务的进度和日期范围
const parentData = calculateParentTaskData({
...task,
children: updatedChildren,
})
return {
...task,
progress: parentData.progress,
startDate: parentData.startDate,
endDate: parentData.endDate,
children: updatedChildren,
}
}
return task
})
}
localTasks.value = updateParentTask(localTasks.value)
}
// 获取所有任务的扁平化列表(包括子任务)
const getAllTasks = (taskList: Task[]): Task[] => {
const allTasks: Task[] = []
const collectTasks = (tasks: Task[]) => {
tasks.forEach(task => {
allTasks.push(task)
if (task.children && task.children.length > 0) {
collectTasks(task.children)
}
})
}
collectTasks(taskList)
return allTasks
}
// 监听外部传入的 tasks 数据变化
watch(
() => props.tasks,
newTasks => {
localTasks.value = newTasks || []
// 更新父级任务数据
updateParentTasksData()
},
{ immediate: true, deep: true },
)
function toggleCollapse(task: Task) {
task.collapsed = !task.collapsed
// 触发自定义事件,通知父组件任务折叠状态变化
emit('task-collapse-change', task)
}
// 更新任务数据
const updateTaskData = (updatedTask: Task) => {
const updateTaskInTree = (taskList: Task[]): Task[] => {
return taskList.map(task => {
if (task.id === updatedTask.id) {
return {
...task,
...updatedTask, // 完整更新所有字段
children: task.children, // 保留子任务结构
}
}
if (task.children) {
return {
...task,
children: updateTaskInTree(task.children),
}
}
return task
})
}
localTasks.value = updateTaskInTree(localTasks.value)
// 更新父级任务的进度和日期范围
updateParentTasksData()
}
// 监听TaskBar更新事件
const handleTaskUpdated = (event: CustomEvent) => {
const updatedTask = event.detail
updateTaskData(updatedTask)
}
// 监听Timeline的任务新增事件
const handleTaskAdded = (event: CustomEvent) => {
const newTask = event.detail
localTasks.value.push(newTask)
// 更新父级任务数据
updateParentTasksData()
}
// 监听 TaskDrawer 请求任务列表事件
const handleRequestTaskList = () => {
// 获取所有任务的扁平化列表
const allTasks = getAllTasks(localTasks.value)
// 发送任务列表给 TaskDrawer
window.dispatchEvent(
new CustomEvent('task-list-updated', {
detail: allTasks,
}),
)
}
// 处理里程碑图标变更事件
const handleMilestoneIconChange = (event: CustomEvent) => {
const { milestoneId, icon } = event.detail
// 递归更新里程碑图标
const updateMilestoneIcon = (taskList: Task[]) => {
for (const task of taskList) {
if (task.type === 'milestone-group' && task.children) {
const milestone = task.children.find(m => m.id === milestoneId)
if (milestone) {
milestone.icon = icon
return true
}
}
if (task.children && updateMilestoneIcon(task.children)) {
return true
}
}
return false
}
updateMilestoneIcon(localTasks.value)
}
// 垂直滚动同步处理
const handleTaskListScroll = (event: Event) => {
const target = event.target as HTMLElement
if (!target) return
const scrollTop = target.scrollTop
// 同步垂直滚动到Timeline
window.dispatchEvent(
new CustomEvent('task-list-vertical-scroll', {
detail: { scrollTop },
}),
)
}
// 处理Timeline垂直滚动同步
const handleTimelineVerticalScroll = (event: CustomEvent) => {
const { scrollTop } = event.detail
const taskListBodyElement = document.querySelector('.task-list-body') as HTMLElement
if (taskListBodyElement && taskListBodyElement.scrollTop !== scrollTop) {
// 避免循环触发,只在scrollTop不同时才设置
taskListBodyElement.scrollTop = scrollTop
}
}
onMounted(async () => {
window.addEventListener('task-updated', handleTaskUpdated as EventListener)
window.addEventListener('task-added', handleTaskAdded as EventListener)
window.addEventListener('request-task-list', handleRequestTaskList as EventListener)
window.addEventListener('timeline-task-hover', handleTimelineHover as EventListener)
window.addEventListener('timeline-vertical-scroll', handleTimelineVerticalScroll as EventListener)
window.addEventListener('milestone-icon-changed', handleMilestoneIconChange as EventListener)
// 监听Splitter拖拽事件
window.addEventListener('splitter-drag-start', handleSplitterDragStart as EventListener)
window.addEventListener('splitter-drag-end', handleSplitterDragEnd as EventListener)
// 初始化时计算父级任务的进度和日期范围
updateParentTasksData()
})
onUnmounted(() => {
window.removeEventListener('task-updated', handleTaskUpdated as EventListener)
window.removeEventListener('task-added', handleTaskAdded as EventListener)
window.removeEventListener('request-task-list', handleRequestTaskList as EventListener)
window.removeEventListener('timeline-task-hover', handleTimelineHover as EventListener)
window.removeEventListener(
'timeline-vertical-scroll',
handleTimelineVerticalScroll as EventListener,
)
window.removeEventListener('milestone-icon-changed', handleMilestoneIconChange as EventListener)
window.removeEventListener('splitter-drag-start', handleSplitterDragStart as EventListener)
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd as EventListener)
})
</script>
<template>
<div class="task-list">
<div class="task-list-header">
<div class="col col-name">{{ t.taskName }}</div>
<div class="col col-pre">{{ t.predecessor }}</div>
<div class="col col-assignee">{{ t.assignee }}</div>
<div class="col col-date">{{ t.startDate }}</div>
<div class="col col-date">{{ t.endDate }}</div>
<div class="col col-hours">{{ t.estimatedHours }}</div>
<div class="col col-hours">{{ t.actualHours }}</div>
<div class="col col-progress">{{ t.progress }}</div>
</div>
<div class="task-list-body" @scroll="handleTaskListScroll">
<TaskRow
v-for="task in localTasks"
:key="task.id"
:task="task"
:level="0"
:is-hovered="hoveredTaskId === task.id"
:hovered-task-id="hoveredTaskId"
:on-double-click="props.onTaskDoubleClick"
:on-hover="handleTaskRowHover"
@toggle="toggleCollapse"
@dblclick="handleTaskRowDoubleClick"
/>
</div>
</div>
</template>
<style scoped>
@import '../styles/theme-variables.css';
.task-list {
width: 100%;
height: 100%;
font-size: 15px;
color: var(--gantt-text-primary);
background: var(--gantt-bg-primary);
display: flex;
flex-direction: column;
overflow-x: auto; /* 防止内容溢出 */
/* Webkit浏览器滚动条样式 */
scrollbar-width: thin;
scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
}
.task-list-header {
display: flex;
background: var(--gantt-bg-secondary);
border-bottom: 1px solid var(--gantt-border-medium);
border-left: 3px solid transparent; /* 添加3px透明左边框保持对齐 */
font-weight: 700;
padding: 0;
height: 80px;
align-items: center;
width: max-content;
flex-shrink: 0; /* 防止header被压缩 */
position: sticky; /* 使header固定 */
top: 0;
z-index: 10; /* 确保在滚动时保持在最上层 */
}
.col {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
border-right: 1px solid var(--gantt-border-light);
box-sizing: border-box;
overflow: hidden;
font-weight: 400;
}
.task-list-header .col {
justify-content: center;
font-weight: 700;
background: var(--gantt-bg-secondary);
color: var(--gantt-text-header);
border-right-color: var(--gantt-border-medium);
}
.col:last-child {
border-right: none;
}
.col-name {
flex: 2 0 300px;
min-width: 300px;
justify-content: flex-start;
}
.col-pre {
flex: 1 0 120px;
min-width: 120px;
}
.col-assignee {
flex: 1 0 120px;
min-width: 120px;
}
.col-date {
flex: 1.2 0 140px;
min-width: 140px;
}
.col-hours {
flex: 1 0 100px;
min-width: 100px;
}
.col-progress {
flex: 1 0 100px;
min-width: 100px;
}
.task-list-body {
width: max-content;
background: var(--gantt-bg-primary);
flex: 1;
overflow-x: hidden; /* 让body部分可以滚动 */
overflow-y: auto; /* 允许垂直滚动 */
/* Webkit浏览器滚动条样式 */
scrollbar-width: thin;
scrollbar-color: var(--gantt-scrollbar-thumb) transparent;
}
.task-list-body::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.task-list-body::-webkit-scrollbar-track {
background: transparent;
}
.task-list-body::-webkit-scrollbar-thumb {
background-color: var(--gantt-scrollbar-thumb);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
.task-list-body::-webkit-scrollbar-thumb:hover {
background-color: var(--gantt-scrollbar-thumb-hover);
}
.task-list-body::-webkit-scrollbar-corner {
background: transparent;
}
</style>
+722
View File
@@ -0,0 +1,722 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useI18n } from '../composables/useI18n'
import type { Task } from '../models/classes/Task'
interface Props {
task: Task
level: number
onDoubleClick?: (task: Task) => void
isHovered?: boolean
hoveredTaskId?: number | null
onHover?: (taskId: number | null) => void
}
const props = defineProps<Props>()
const emit = defineEmits(['toggle', 'dblclick'])
const { t } = useI18n()
const overtimeText = computed(() => t.value?.overtime ?? '')
const overdueText = computed(() => t.value?.overdue ?? '')
const daysText = computed(() => t.value?.days ?? '')
const baseIndent = 10
const indent = `${baseIndent + props.level * 20}px`
function handleToggle() {
emit('toggle', props.task)
}
function handleRowClick() {
// 如果是普通父级任务(story类型或有子任务的任务,非里程碑分组),点击行也可以展开/收起
if (
(props.task.type === 'story' || (props.task.children && props.task.children.length > 0)) &&
props.task.type !== 'milestone-group'
) {
emit('toggle', props.task)
}
}
// 处理TaskRow双击事件 (与TaskBar逻辑保持一致)
const handleTaskRowDoubleClick = (e: MouseEvent) => {
// 阻止事件冒泡
e.stopPropagation()
// 优先调用外部传入的双击处理器
if (props.onDoubleClick && typeof props.onDoubleClick === 'function') {
props.onDoubleClick(props.task)
} else {
// 默认行为:发出双击事件给父组件
emit('dblclick', props.task)
}
}
// 处理悬停事件
const handleMouseEnter = () => {
// 如果正在拖拽Splitter,忽略悬停事件
if (isSplitterDragging.value) return
if (props.onHover) {
props.onHover(props.task.id)
}
}
const handleMouseLeave = () => {
// 如果正在拖拽Splitter,忽略悬停事件
if (isSplitterDragging.value) return
if (props.onHover) {
props.onHover(null)
}
}
// 获取进度值的样式类
function getProgressClass() {
const progress = props.task.progress || 0
const today = new Date()
const endDate = props.task.endDate ? new Date(props.task.endDate) : null
// 超期且未完成
if (endDate && today > endDate && progress < 100) {
return 'progress-danger'
}
// 已完成
if (progress >= 100) {
return 'progress-success'
}
// 进行中
if (progress > 0) {
return 'progress-warning'
}
return ''
}
// 检查是否超时
function isOvertime() {
return (
props.task.actualHours &&
props.task.estimatedHours &&
props.task.actualHours > props.task.estimatedHours
)
}
// 检查是否逾期,返回天数
function overdueDays() {
const today = new Date()
const endDate = props.task.endDate ? new Date(props.task.endDate) : null
const progress = props.task.progress || 0
if (endDate && today > endDate && progress < 100) {
// 只计算日期部分
const t = new Date(today.getFullYear(), today.getMonth(), today.getDate())
const e = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
const diff = Math.floor((t.getTime() - e.getTime()) / (1000 * 60 * 60 * 24))
return diff
}
return 0
}
// 拖拽状态管理
const isSplitterDragging = ref(false)
// 处理拖拽开始事件
const handleSplitterDragStart = () => {
isSplitterDragging.value = true
}
// 处理拖拽结束事件
const handleSplitterDragEnd = () => {
isSplitterDragging.value = false
}
// 生命周期钩子 - 注册事件监听器
onMounted(() => {
window.addEventListener('splitter-drag-start', handleSplitterDragStart)
window.addEventListener('splitter-drag-end', handleSplitterDragEnd)
})
onUnmounted(() => {
window.removeEventListener('splitter-drag-start', handleSplitterDragStart)
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd)
})
</script>
<template>
<div>
<div
class="task-row"
:class="{
'task-row-hovered': isHovered,
'parent-task':
props.task.type === 'story' ||
(props.task.children && props.task.children.length > 0) ||
props.task.type === 'milestone-group',
'milestone-group-row': props.task.type === 'milestone-group',
'task-type-story': props.task.type === 'story',
'task-type-task': props.task.type === 'task',
'task-type-milestone': props.task.type === 'milestone',
}"
@click="handleRowClick"
@dblclick="handleTaskRowDoubleClick"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div class="col col-name" :style="{ paddingLeft: indent }">
<span
v-if="
(props.task.type === 'story' ||
(props.task.children && props.task.children.length > 0)) &&
props.task.type !== 'milestone-group'
"
class="collapse-btn"
@click.stop="handleToggle"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline v-if="props.task.collapsed" points="9,18 15,12 9,6" />
<polyline v-else points="18,15 12,9 6,15" />
</svg>
</span>
<!-- 里程碑分组的占位空间,用于与有折叠按钮的任务对齐 -->
<span v-if="props.task.type === 'milestone-group'" class="milestone-spacer"></span>
<!-- 任务图标 -->
<span class="task-icon">
<!-- 里程碑分组图标 - 使用菱形图标 -->
<svg
v-if="props.task.type === 'milestone-group'"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="milestone-group-icon"
>
<polygon points="12,2 22,12 12,22 2,12" />
</svg>
<!-- 父级任务图标 -->
<svg
v-else-if="
props.task.type === 'story' || (props.task.children && props.task.children.length > 0)
"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
</svg>
<!-- 普通任务图标 -->
<svg
v-else
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14,2 14,8 20,8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10,9 9,9 8,9" />
</svg>
</span>
<span
class="task-name-text"
:class="{
'parent-task':
props.task.type === 'story' ||
(props.task.children && props.task.children.length > 0) ||
props.task.type === 'milestone-group',
}"
:title="props.task.name"
>
{{ props.task.name }}
<span v-if="isOvertime()" class="status-badge overtime">{{ overtimeText }}</span>
<span v-if="overdueDays() > 0" class="status-badge overdue">
{{ overdueText }}{{ overdueDays() > 0 ? overdueDays() + daysText : '' }}
</span>
</span>
</div>
<!-- 里程碑分组不显示详细信息 -->
<template v-if="props.task.type === 'milestone-group'">
<div class="col col-pre milestone-empty-col"></div>
<div class="col col-assignee milestone-empty-col"></div>
<div class="col col-date milestone-empty-col"></div>
<div class="col col-date milestone-empty-col"></div>
<div class="col col-hours milestone-empty-col"></div>
<div class="col col-hours milestone-empty-col"></div>
<div class="col col-progress milestone-empty-col"></div>
</template>
<!-- 普通任务显示详细信息 -->
<template v-else>
<div class="col col-pre">{{ props.task.predecessor || '-' }}</div>
<div class="col col-assignee">
<div class="assignee-info">
<div class="avatar">
{{ props.task.assignee ? props.task.assignee.charAt(0) : '-' }}
</div>
<span class="assignee-name">{{ props.task.assignee || '-' }}</span>
</div>
</div>
<div class="col col-date">{{ props.task.startDate || '-' }}</div>
<div class="col col-date">{{ props.task.endDate || '-' }}</div>
<div class="col col-hours">{{ props.task.estimatedHours || '-' }}</div>
<div class="col col-hours">{{ props.task.actualHours || '-' }}</div>
<div class="col col-progress">
<span class="progress-value" :class="getProgressClass()">
{{ props.task.progress != null ? props.task.progress + '%' : '-' }}
</span>
</div>
</template>
</div>
<template
v-if="props.task.children && !props.task.collapsed && props.task.type !== 'milestone-group'"
>
<TaskRow
v-for="child in props.task.children"
:key="child.id"
:task="child"
:level="props.level + 1"
:is-hovered="props.hoveredTaskId === child.id"
:hovered-task-id="props.hoveredTaskId"
:on-double-click="props.onDoubleClick"
:on-hover="props.onHover"
@toggle="emit('toggle', $event)"
@dblclick="emit('dblclick', $event)"
/>
</template>
</div>
</template>
<style scoped>
@import '../styles/theme-variables.css';
.task-row {
display: flex;
border-bottom: 1px solid var(--gantt-border-light);
height: 50px;
background: var(--gantt-bg-primary);
align-items: center;
color: var(--gantt-text-secondary);
cursor: pointer;
transition: all 0.3s ease;
transform: scale(1);
transform-origin: 5px center; /* 从左侧偏右5px的位置作为放大中心 */
z-index: 1;
position: relative;
}
.task-row:hover {
background-color: var(--gantt-bg-hover);
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10;
}
.task-row-hovered {
background-color: var(--gantt-bg-hover) !important;
transform: scale(1.02) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
z-index: 10 !important;
}
.task-row.parent-task {
background: var(--gantt-bg-tertiary);
font-weight: 600;
}
.task-row.parent-task:hover {
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover));
transform: scale(1.02);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
z-index: 10;
}
.task-row.parent-task.task-row-hovered {
background: var(--gantt-bg-hover-parent, var(--gantt-bg-hover)) !important;
transform: scale(1.02) !important;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2) !important;
z-index: 10 !important;
}
/* 里程碑分组行特殊样式 - 使用红色边框 */
.milestone-group-row {
border-left: 3px solid var(--gantt-danger, #f56c6c);
background: linear-gradient(90deg, var(--gantt-bg-tertiary) 0%, var(--gantt-bg-primary) 100%);
}
.milestone-group-row:hover {
background: linear-gradient(90deg, var(--gantt-bg-hover-parent) 0%, var(--gantt-bg-hover) 100%);
transform: scale(1.02);
box-shadow:
0 6px 16px rgba(245, 108, 108, 0.3),
0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 10;
border-left-color: var(--gantt-danger, #f56c6c);
border-left-width: 4px; /* 悬停时边框稍微加粗 */
}
/* 任务类型左边框颜色 */
.task-type-story {
border-left: 3px solid var(--gantt-primary, #409eff);
}
.task-type-task {
border-left: 3px solid var(--gantt-warning, #e6a23c);
}
.task-type-milestone {
border-left: 3px solid var(--gantt-danger, #f56c6c);
}
/* 任务类型悬停时保持并增强左边框 */
.task-type-story:hover {
border-left: 5px solid var(--gantt-primary, #409eff);
}
.task-type-task:hover {
border-left: 5px solid var(--gantt-warning, #e6a23c);
}
.task-type-milestone:hover {
border-left: 5px solid var(--gantt-danger, #f56c6c);
}
/* 悬停状态下的左边框保持 */
.task-row-hovered.task-type-story {
border-left: 5px solid var(--gantt-primary, #409eff) !important;
}
.task-row-hovered.task-type-task {
border-left: 5px solid var(--gantt-warning, #e6a23c) !important;
}
.task-row-hovered.task-type-milestone {
border-left: 5px solid var(--gantt-danger, #f56c6c) !important;
}
:global(html[data-theme='dark']) .milestone-group-row {
border-left-color: var(--gantt-danger, #f67c7c);
}
/* 暗黑模式下的任务类型左边框颜色 */
:global(html[data-theme='dark']) .task-type-story {
border-left-color: var(--gantt-primary, #7db4f0);
}
:global(html[data-theme='dark']) .task-type-task {
border-left-color: var(--gantt-warning, #f0b83c);
}
:global(html[data-theme='dark']) .task-type-milestone {
border-left-color: var(--gantt-danger, #f67c7c);
}
.collapse-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
cursor: pointer;
margin-right: 4px;
color: var(--gantt-primary);
border-radius: 2px;
transition: background-color 0.2s ease;
}
.collapse-btn:hover {
background-color: var(--gantt-primary-light);
}
.collapse-btn svg {
transition: transform 0.2s ease;
}
/* 里程碑分组占位空间 - 与折叠按钮对齐 */
.milestone-spacer {
display: inline-flex;
width: 18px;
height: 18px;
margin-right: 4px;
}
.col {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
border-right: 1px solid var(--gantt-border-light);
box-sizing: border-box;
overflow: hidden;
}
.col:last-child {
border-right: none;
}
.col-name {
flex: 2 0 300px;
min-width: 300px;
justify-content: flex-start;
}
.col-pre {
flex: 1 0 120px;
min-width: 120px;
}
.col-assignee {
flex: 1 0 120px;
min-width: 120px;
}
.col-date {
flex: 1.2 0 140px;
min-width: 140px;
}
.col-hours {
flex: 1 0 100px;
min-width: 100px;
}
.col-progress {
flex: 1 0 100px;
min-width: 100px;
}
.task-name-text {
display: inline-block;
max-width: calc(100% - 24px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.task-name-text.parent-task {
font-weight: bold;
color: var(--gantt-text-parent, var(--gantt-text-primary));
}
.task-icon {
margin-right: 4px;
color: var(--gantt-text-muted);
}
.task-icon svg {
vertical-align: middle;
}
.assignee-info {
display: flex;
align-items: center;
gap: 8px;
}
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--gantt-primary);
color: var(--gantt-text-white);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 500;
border: 2px solid var(--gantt-border-medium);
box-sizing: border-box;
}
.assignee-name {
font-size: 14px;
color: var(--gantt-text-secondary);
}
.progress-value {
font-weight: 500;
color: var(--gantt-text-secondary);
}
.progress-success {
color: var(--gantt-success);
}
.progress-warning {
color: var(--gantt-warning);
}
.progress-danger {
color: var(--gantt-danger);
}
.status-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 2px;
font-size: 10px;
font-weight: bold;
margin-left: 6px;
color: white;
}
.status-badge.overtime {
background-color: transparent;
border: 1px solid var(--gantt-danger);
color: var(--gantt-danger);
}
.status-badge.overdue {
background-color: var(--gantt-danger);
}
/* 里程碑分组图标样式 - 统一使用红色并添加发光效果 */
.milestone-group-icon {
color: var(--gantt-danger, #f56c6c);
fill: var(--gantt-danger, #f56c6c);
opacity: 0.9;
filter: drop-shadow(0 0 6px var(--gantt-danger, #f56c6c));
animation: milestone-icon-glow 2.5s ease-in-out infinite alternate;
}
/* 里程碑图标悬停效果 */
.task-row:hover .milestone-group-icon {
filter: drop-shadow(0 0 10px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 16px rgba(245, 108, 108, 0.4));
animation: milestone-icon-glow-intense 1.8s ease-in-out infinite alternate;
}
/* 里程碑图标发光动画 */
@keyframes milestone-icon-glow {
from {
filter: drop-shadow(0 0 3px var(--gantt-danger, #f56c6c));
}
to {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 12px rgba(245, 108, 108, 0.3));
}
}
@keyframes milestone-icon-glow-intense {
from {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 12px rgba(245, 108, 108, 0.3));
}
to {
filter: drop-shadow(0 0 12px var(--gantt-danger, #f56c6c))
drop-shadow(0 0 20px rgba(245, 108, 108, 0.5));
}
}
/* 里程碑行样式 - 统一使用红色 */
.milestone-item-icon {
color: var(--gantt-danger, #f56c6c);
}
.milestone-empty-col {
color: var(--gantt-text-disabled, #c0c4cc);
/* 确保边框颜色与普通数据行一致 */
border-right-color: var(--gantt-border-light) !important;
}
.milestone-empty-col::after {
content: '-';
}
/* 暗黑模式适配 */
:global(html[data-theme='dark']) .milestone-row-icon {
color: var(--gantt-danger, #f67c7c);
}
/* 暗黑模式下的里程碑图标发光效果 */
:global(html[data-theme='dark']) .milestone-group-icon {
color: var(--gantt-danger, #f67c7c);
fill: var(--gantt-danger, #f67c7c);
filter: drop-shadow(0 0 6px var(--gantt-danger, #f67c7c));
animation: milestone-icon-glow-dark 2.5s ease-in-out infinite alternate;
}
:global(html[data-theme='dark']) .task-row:hover .milestone-group-icon {
filter: drop-shadow(0 0 10px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 16px rgba(246, 124, 124, 0.4));
animation: milestone-icon-glow-intense-dark 1.8s ease-in-out infinite alternate;
}
/* 暗黑模式发光动画 */
@keyframes milestone-icon-glow-dark {
from {
filter: drop-shadow(0 0 3px var(--gantt-danger, #f67c7c));
}
to {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 12px rgba(246, 124, 124, 0.3));
}
}
@keyframes milestone-icon-glow-intense-dark {
from {
filter: drop-shadow(0 0 8px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 12px rgba(246, 124, 124, 0.3));
}
to {
filter: drop-shadow(0 0 12px var(--gantt-danger, #f67c7c))
drop-shadow(0 0 20px rgba(246, 124, 124, 0.5));
}
}
:global(html[data-theme='dark']) .milestone-empty-col {
color: var(--gantt-text-disabled, #606266);
/* 确保暗黑模式下边框颜色与普通数据行一致 */
border-right-color: var(--gantt-border-light) !important;
}
/* 暗黑模式的悬停效果 */
:global(html[data-theme='dark']) .task-row:hover {
box-shadow:
0 4px 12px rgba(255, 255, 255, 0.1),
0 2px 8px rgba(0, 0, 0, 0.3);
}
:global(html[data-theme='dark']) .task-row.task-row-hovered {
background-color: var(--gantt-bg-hover) !important;
box-shadow:
0 4px 12px rgba(255, 255, 255, 0.1),
0 2px 8px rgba(0, 0, 0, 0.3) !important;
}
:global(html[data-theme='dark']) .task-row.parent-task:hover {
box-shadow:
0 6px 16px rgba(255, 255, 255, 0.15),
0 2px 8px rgba(0, 0, 0, 0.4);
}
:global(html[data-theme='dark']) .task-row.parent-task.task-row-hovered {
background: var(--gantt-bg-hover-parent) !important;
box-shadow:
0 6px 16px rgba(255, 255, 255, 0.15),
0 2px 8px rgba(0, 0, 0, 0.4) !important;
}
:global(html[data-theme='dark']) .milestone-group-row:hover {
box-shadow:
0 6px 16px rgba(246, 124, 124, 0.4),
0 2px 8px rgba(255, 255, 255, 0.1);
}
</style>
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
export { default as GanttChart } from './GanttChart.vue'
export { default as GanttToolbar } from './GanttToolbar.vue'
export { default as TaskList } from './TaskList.vue'
export { default as TaskRow } from './TaskRow.vue'
export { default as Timeline } from './Timeline.vue'
export { default as TaskBar } from './TaskBar.vue'
export { default as MilestonePoint } from './MilestonePoint.vue'
export { default as MilestoneDialog } from './MilestoneDialog.vue'
export { default as TaskDrawer } from './TaskDrawer.vue'
export { default as DatePicker } from './DatePicker.vue'