Files
jordium-gantt-vue3/src/components/TaskDrawer.vue
T
2025-12-09 14:18:04 +08:00

1433 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, reactive, watch, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from '../composables/useI18n'
import { useMessage } from '../composables/useMessage'
import DatePicker from './DatePicker.vue'
import GanttConfirmDialog from './GanttConfirmDialog.vue'
import MultiSelectPredecessor from './MultiSelectPredecessor.vue'
import ConfirmTimerDialog from './ConfirmTimerDialog.vue'
import type { Task } from '../models/classes/Task'
import '../styles/app.css'
interface AssigneeOption {
key?: string | number
value: string | number
label: string
}
interface Props {
visible: boolean
task?: Task | null
isEdit?: boolean
onDelete?: (task: Task) => void
assigneeOptions?: AssigneeOption[]
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
task: null,
isEdit: false,
onDelete: undefined,
assigneeOptions: () => [
{ value: 'zhangsan', label: '张三' },
{ value: 'lisi', label: '李四' },
{ value: 'wangwu', label: '王五' },
{ value: 'zhaoliu', label: '赵六' },
{ value: 'qianqi', label: '钱七' },
],
})
const emit = defineEmits<{
'update:visible': [value: boolean]
submit: [task: Task]
close: []
delete: [task: Task, deleteChildren?: boolean]
'start-timer': [task: Task]
'stop-timer': [task: Task]
}>()
const { t } = useI18n()
const { showMessage } = useMessage()
const submitting = ref(false)
const isVisible = ref(props.visible)
const showDeleteConfirm = ref(false)
// 计时器相关
const timerElapsed = ref(0)
const timerInterval = ref<number | null>(null)
const isTimerRunning = computed(() => props.task?.isTimerRunning)
const timerStartTime = computed(() => props.task?.timerStartTime)
const timerElapsedTime = computed(() => props.task?.timerElapsedTime || 0)
const formattedTimer = computed(() => {
const totalSeconds = Math.floor(timerElapsed.value / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
})
const updateTimer = () => {
if (isTimerRunning.value && timerStartTime.value) {
timerElapsed.value = Date.now() - timerStartTime.value + timerElapsedTime.value
} else {
timerElapsed.value = timerElapsedTime.value
}
}
// 新增:监听 props.task 的 isTimerRunning、timerStartTime、timerElapsedTime 变化,确保 header 区域按钮和计时器展示实时同步
watch(
() => [props.task?.isTimerRunning, props.task?.timerStartTime, props.task?.timerElapsedTime],
() => {
updateTimer()
},
)
// 计时器本地状态,保证点击后UI立即切换
const localTimerRunning = ref(false)
// 只要 props.task 变化或全局事件变化,立即同步本地状态
watch(
() => props.task?.isTimerRunning,
val => {
localTimerRunning.value = !!val
if (!val && timerInterval.value) {
clearInterval(timerInterval.value)
timerInterval.value = null
}
},
{ immediate: true },
)
// 修正计时器每秒递增逻辑,保证计时器正常跳动
watch(
[localTimerRunning, timerStartTime, timerElapsedTime],
() => {
if (timerInterval.value) {
clearInterval(timerInterval.value)
timerInterval.value = null
}
if (localTimerRunning.value && timerStartTime.value) {
updateTimer()
timerInterval.value = window.setInterval(updateTimer, 1000)
} else {
updateTimer()
}
},
{ immediate: true },
)
onUnmounted(() => {
if (timerInterval.value) {
clearInterval(timerInterval.value)
}
})
// 根据任务类型确定dialog类型
const dialogType = computed(() => {
return props.task?.type === 'story' ? 'yes-no-cancel' : 'confirm-cancel'
})
// 根据任务类型确定dialog消息
const dialogMessage = computed(() => {
if (props.task?.type === 'story') {
return t.value.confirmDeleteStory.replace('{name}', props.task?.name || '')
}
return t.value.confirmDeleteTask.replace('{name}', props.task?.name || '')
})
// 表单数据
const formData = reactive<Task>({
id: 0, // 默认值,创建时会被重新分配
name: '',
type: 'task',
assignee: '',
startDate: '',
endDate: '',
predecessor: [],
estimatedHours: 0,
actualHours: 0,
progress: 0,
description: '',
parentId: undefined, // 上级任务ID
isTimerRunning: false, // 是否正在计时
timerStartTime: undefined, // 计时器开始时间
timerEndTime: undefined, // 计时器结束时间
timerElapsedTime: 0, // 计时器已用时间
children: undefined, // 子任务列表
})
// 任务列表数据
const allTasks = ref<Task[]>([])
// 获取可作为上级任务的任务列表(只显示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 progressSliderStyle = computed(() => {
const progressPercent = formData.progress || 0
return {
'--progress-percent': `${progressPercent}%`,
}
})
// 进度输入框的显示值
const progressDisplayValue = ref('0')
// 处理进度输入
const handleProgressInput = (event: Event) => {
const target = event.target as HTMLInputElement
let value = target.value
// 只保留数字
value = value.replace(/[^\d]/g, '')
// 更新输入框显示
target.value = value
progressDisplayValue.value = value
}
// 处理进度输入失焦或回车
const handleProgressInputBlur = () => {
const value = progressDisplayValue.value
// 转换为数字
let progress = parseInt(value) || 0
// 数据验证:小于0或非数字显示0,大于100显示100
if (progress < 0 || isNaN(progress)) {
progress = 0
} else if (progress > 100) {
progress = 100
}
// 更新数据和显示
formData.progress = progress
progressDisplayValue.value = progress.toString()
}
// 处理预计工时输入
const handleEstimatedHoursInput = (event: Event) => {
const target = event.target as HTMLInputElement
let value = parseFloat(target.value)
// 数据验证
if (isNaN(value) || value < 0) {
value = 0
} else if (value > 99999) {
value = 99999
}
// 保留两位小数
formData.estimatedHours = Math.round(value * 100) / 100
}
// 处理实际工时输入
const handleActualHoursInput = (event: Event) => {
const target = event.target as HTMLInputElement
let value = parseFloat(target.value)
// 数据验证
if (isNaN(value) || value < 0) {
value = 0
} else if (value > 99999) {
value = 99999
}
// 保留两位小数
formData.actualHours = Math.round(value * 100) / 100
}
// 处理进度输入框聚焦(选中全部文本)
const handleProgressInputFocus = (event: Event) => {
const target = event.target as HTMLInputElement
// 延迟选中,确保聚焦完成
setTimeout(() => {
target.select()
}, 0)
}
// 处理键盘事件
const handleProgressKeydown = (event: KeyboardEvent) => {
const key = event.key
// 允许的按键:数字、退格、删除、方向键、Tab等
const allowedKeys = [
'Backspace',
'Delete',
'Tab',
'Escape',
'Enter',
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Home',
'End',
'Control',
'Meta',
'Alt',
'Shift',
]
// 允许数字
const isNumber = /^\d$/.test(key)
const isAllowedKey = allowedKeys.includes(key)
// 如果是Ctrl+A、Ctrl+C、Ctrl+V、Ctrl+X等组合键,允许通过
if (event.ctrlKey || event.metaKey) {
return
}
// 如果不是允许的按键或数字,阻止输入
if (!isNumber && !isAllowedKey) {
event.preventDefault()
}
}
// 处理小时视图中的日期格式
const processDateForHourView = (dateStr: string | undefined, type: 'start' | 'end'): string => {
if (!dateStr) return ''
// 检查是否只有日期部分(YYYY-MM-DD格式)
if (typeof dateStr === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateStr.trim())) {
// 如果是开始日期,设置为当日00:00
if (type === 'start') {
return `${dateStr} 00:00`
}
// 如果是结束日期,设置为次日00:00
if (type === 'end') {
const date = new Date(dateStr)
date.setDate(date.getDate() + 1)
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} 00:00`
}
}
// 已经包含时间部分或其他格式,直接返回
return dateStr
}
// 错误信息
const errors = reactive({
name: '',
type: '',
startDate: '',
endDate: '',
})
// 监听 visible 属性变化
watch(
() => props.visible,
newVal => {
isVisible.value = newVal
if (newVal) {
if (props.task && props.isEdit) {
// 编辑模式,先重置表单再填充表单数据
resetForm()
const taskData = { ...props.task }
// 处理日期格式:如果是只有日期部分的数据,在小时视图编辑时需要特殊处理
taskData.startDate = processDateForHourView(taskData.startDate, 'start')
taskData.endDate = processDateForHourView(taskData.endDate, 'end')
// 使用 nextTick 确保 resetForm 的响应式更新完成后再赋值
nextTick(() => {
Object.assign(formData, taskData)
})
} else if (props.task && !props.isEdit) {
// 新建模式,重置表单
resetForm()
// 新建模式,自动绑定上级任务
formData.parentId = props.task.parentId ?? undefined
// 新建模式,自动绑定前置任务
formData.predecessor = props.task.predecessor ?? []
} else {
// 没有 task,也重置表单
resetForm()
}
// 抽屉显示时重新请求任务数据,确保前置任务列表是最新的
window.dispatchEvent(new CustomEvent('request-task-list'))
}
},
)
// 监听 isVisible 变化,同步到父组件
watch(isVisible, newVal => {
emit('update:visible', newVal)
})
// 监听 task 变化,同步更新 parentId
watch(
() => props.task,
newTask => {
if (newTask && !props.isEdit) {
// 新建时,确保 parentId 与传入的 parentId 同步
formData.parentId = newTask.parentId ?? undefined
}
},
{ immediate: true },
)
// 重置表单
const resetForm = () => {
Object.assign(formData, {
name: '',
type: 'task',
assignee: '',
startDate: '',
endDate: '',
predecessor: [],
estimatedHours: 0,
actualHours: 0,
progress: 0,
description: '',
parentId: undefined,
isTimerRunning: false,
timerStartTime: undefined,
timerEndTime: undefined,
timerElapsedTime: 0,
children: 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)
handleClose()
} catch (error) {
// 处理错误但不在控制台输出
} finally {
submitting.value = false
}
}
// 删除任务
const handleDelete = () => {
showDeleteConfirm.value = true
}
const handleError = (error: unknown) => {
let msg = ''
if (
error &&
typeof error === 'object' &&
'message' in error &&
typeof (error as { message?: unknown }).message === 'string'
) {
msg = (error as { message: string }).message
} else {
msg = String(error)
}
showMessage(msg, 'error', { closable: true })
}
const confirmDelete = () => {
showDeleteConfirm.value = false
if (props.task && props.isEdit) {
try {
submitting.value = true
emit('delete', props.task)
handleClose()
} catch (error) {
handleError(error)
} finally {
submitting.value = false
}
}
}
const cancelDelete = () => {
showDeleteConfirm.value = false
}
// Story删除:选择"是" - 删除story及其所有子任务
const handleDeleteYes = () => {
showDeleteConfirm.value = false
if (props.task && props.isEdit) {
try {
submitting.value = true
emit('delete', props.task, true) // 传递true表示删除所有子任务
handleClose()
} catch (error) {
handleError(error)
} finally {
submitting.value = false
}
}
}
// Story删除:选择"否" - 仅删除story,保留子任务
const handleDeleteNo = () => {
showDeleteConfirm.value = false
if (props.task && props.isEdit) {
try {
submitting.value = true
emit('delete', props.task, false) // 传递false表示仅删除story
handleClose()
} catch (error) {
handleError(error)
} finally {
submitting.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)
})
// 监听 formData.progress 变化,同步更新显示值
watch(
() => formData.progress,
newValue => {
progressDisplayValue.value = (newValue || 0).toString()
},
{ immediate: true },
)
// 修正计时器首次启动不跳动问题:每次打开抽屉时重置 timerElapsed,且 timerStartTime 为空时立即赋值
watch(
() => props.visible,
visible => {
if (visible && props.task && props.task.type !== 'story') {
timerElapsed.value = props.task.timerElapsedTime || 0
// 若计时器未启动,重置本地interval
if (!props.task.isTimerRunning) {
if (timerInterval.value) {
clearInterval(timerInterval.value)
timerInterval.value = null
}
}
}
},
{ immediate: true },
)
const handleStartTimer = (desc?: string) => {
const now = Date.now()
if (props.task && typeof props.task === 'object') {
Object.assign(props.task, {
timerStartTime: now,
isTimerRunning: true,
timerElapsedTime: 0, // 每次从0秒开始
timerStartDesc: desc || '',
})
emit('start-timer', props.task)
}
timerElapsed.value = 0 // 启动时重置本地计时器
localTimerRunning.value = true
updateTimer()
if (timerInterval.value) {
clearInterval(timerInterval.value)
timerInterval.value = null
}
window.dispatchEvent(new CustomEvent('start-timer', { detail: props.task }))
}
const handleStopTimer = () => {
if (props.task && typeof props.task === 'object') {
emit('stop-timer', props.task)
}
localTimerRunning.value = false
if (timerInterval.value) {
clearInterval(timerInterval.value)
timerInterval.value = null
}
updateTimer()
window.dispatchEvent(new CustomEvent('stop-timer', { detail: props.task }))
}
// 计时器确认弹窗
const showTimerConfirm = ref(false)
const timerDesc = ref('')
function openTimerConfirm() {
timerDesc.value = props.task?.name || ''
showTimerConfirm.value = true
}
function cancelTimerConfirm() {
showTimerConfirm.value = false
}
function confirmTimer(desc: string) {
showTimerConfirm.value = false
// desc 可用于后续业务
handleStartTimer(desc)
}
// 处理负责人变更
const handleAssigneeChanged = (event: Event) => {
const value = (event.target as HTMLSelectElement).value
// 通过value过滤props.assigneeOptions获取对应的label
const selected = props.assigneeOptions?.find(option => option.value === value)
if (selected) {
// 这里可以根据需要处理选中的负责人信息
// 例如,可以将负责人名称存储在formData中
formData.assigneeName = selected.label
}
}
</script>
<template>
<div v-if="isVisible" class="drawer-overlay" @click="handleOverlayClick">
<div class="drawer-container" @click.stop>
<!-- Drawer Header -->
<div
class="drawer-header"
style="display: flex; align-items: center; justify-content: flex-start; gap: 8px"
>
<h3 class="drawer-title" style="margin: 0">{{ isEdit ? t.editTask : t.newTask }}</h3>
<div
v-if="props.task?.type !== 'story' && isEdit"
class="drawer-timer"
style="display: flex; align-items: center; gap: 6px; margin-left: 8px"
>
<button
v-if="!localTimerRunning"
class="timer-btn start minimal"
title="开始计时"
style="
width: 24px;
height: 24px;
background: #4caf50;
border: none;
padding: 0;
margin: 0;
box-shadow: none;
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
"
@click.stop="openTimerConfirm"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
style="display: block; margin: 0 auto"
>
<circle cx="12" cy="12" r="11" stroke="#4caf50" stroke-width="2" fill="#4caf50" />
<polygon points="9,7 18,12 9,17" fill="#fff" />
</svg>
</button>
<button
v-else
class="timer-btn stop minimal"
title="停止计时"
style="
width: 24px;
height: 24px;
background: #f44336;
border: none;
padding: 0;
margin: 0;
box-shadow: none;
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
"
@click.stop="handleStopTimer"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" stroke="#f44336" stroke-width="2" fill="#f44336" />
<rect x="7" y="7" width="10" height="10" fill="#fff" rx="1.5" />
</svg>
</button>
<span
v-if="localTimerRunning"
class="timer-badge"
:class="{ 'timer-active': localTimerRunning }"
style="
margin-left: 8px;
font-size: 13px;
font-weight: 700;
padding: 2px 10px;
border-radius: 10px;
background: #fffbe6;
color: #e6a23c;
box-shadow: 0 0 0 1px #ffe58f;
display: inline-flex;
align-items: center;
min-width: 80px;
justify-content: center;
"
>
<span
v-if="localTimerRunning"
class="timer-dot"
style="
background: #67c23a;
width: 7px;
height: 7px;
border-radius: 50%;
margin-right: 5px;
animation: pulse 1s infinite;
"
></span>
{{ formattedTimer }}
</span>
</div>
<div style="flex: 1"></div>
<button class="drawer-close-btn" type="button" @click="handleClose">
<svg
class="close-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
style="vertical-align: middle"
>
<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" @change="handleAssigneeChanged">
<option value="">{{ t.selectAssignee }}</option>
<option
v-for="assignee in props.assigneeOptions"
:key="assignee.key ?? assignee.value"
:value="assignee.value"
>
{{ assignee.label }}
</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="'datetime' as any"
value-format="YYYY-MM-DD HH:mm"
: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="'datetime' as any"
value-format="YYYY-MM-DD HH:mm"
:placeholder="t.endDateRequired"
:class="{ error: errors.endDate }"
/>
<span v-if="errors.endDate" class="error-text">{{ errors.endDate }}</span>
</div>
</div>
<MultiSelectPredecessor
v-model="formData.predecessor"
:tasks="allTasks"
:current-task-id="props.task?.id"
:label="t.predecessor"
:placeholder="t.predecessorPlaceholder"
/>
<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="formData.estimatedHours"
type="number"
class="form-input"
placeholder="0.00"
min="0"
max="99999"
step="0.01"
@input="handleEstimatedHoursInput"
/>
</div>
<div class="form-group">
<label class="form-label" for="task-actual-hours">{{ t.actualHours }}</label>
<input
id="task-actual-hours"
v-model="formData.actualHours"
type="number"
class="form-input"
placeholder="0.00"
min="0"
max="99999"
step="0.01"
@input="handleActualHoursInput"
/>
</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"
:style="progressSliderStyle"
min="0"
max="100"
step="5"
/>
<div class="progress-input-group">
<input
v-model="progressDisplayValue"
type="text"
class="progress-input"
placeholder="0"
maxlength="3"
@blur="handleProgressInputBlur"
@keyup.enter="handleProgressInputBlur"
@input="handleProgressInput"
@focus="handleProgressInputFocus"
@keydown="handleProgressKeydown"
/>
<span class="progress-unit">%</span>
</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"
:type="dialogType"
:message="dialogMessage"
:confirm-text="t.confirm"
:cancel-text="t.cancel"
:yes-text="t.storyDeleteYes"
:no-text="t.storyDeleteNo"
@confirm="confirmDelete"
@yes="handleDeleteYes"
@no="handleDeleteNo"
@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>
<!-- 计时器确认弹窗 -->
<ConfirmTimerDialog
v-if="showTimerConfirm"
:visible="showTimerConfirm"
:title="'确认开始计时'"
:message="`即将为任务${props.task?.name}计时,若有特殊说明请完善下面的描述`"
:default-desc="props.task?.name || ''"
@confirm="confirmTimer"
@cancel="cancelTimerConfirm"
/>
</template>
<style scoped>
@import '../styles/theme-variables.css';
/* 抽屉遮罩层 */
.drawer-overlay {
height: 100%;
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;
position: relative;
/* 确保input元素本身垂直居中 */
vertical-align: middle;
}
/* 移除悬停时的高度变化动画,避免抖动 */
/* WebKit 浏览器的已完成部分样式 */
.progress-slider::-webkit-slider-runnable-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(
to right,
var(--gantt-primary, #409eff) 0%,
var(--gantt-primary, #409eff) var(--progress-percent, 0%),
var(--gantt-border-light, #e4e7ed) var(--progress-percent, 0%),
var(--gantt-border-light, #e4e7ed) 100%
);
}
/* Firefox 浏览器的已完成部分样式 */
.progress-slider::-moz-range-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(
to right,
var(--gantt-primary, #409eff) 0%,
var(--gantt-primary, #409eff) var(--progress-percent, 0%),
var(--gantt-border-light, #e4e7ed) var(--progress-percent, 0%),
var(--gantt-border-light, #e4e7ed) 100%
);
border: none;
}
/* Firefox 需要额外重置,确保轨道居中 */
.progress-slider::-moz-range-progress {
height: 6px;
border-radius: 3px;
background: var(--gantt-primary, #409eff);
}
/* 为更好的兼容性,添加一个伪元素来显示已完成部分 */
.progress-slider::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: 6px;
width: var(--progress-percent, 0%);
background: var(--gantt-primary, #409eff);
border-radius: 3px;
pointer-events: none;
transition: width 0.2s ease; /* 只保留宽度变化的过渡效果 */
}
/* 移除悬停时的高度变化,避免抖动 */
.progress-slider::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--gantt-primary, #409eff);
cursor: pointer;
position: relative;
z-index: 2;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s ease; /* 只保留阴影过渡效果 */
margin-top: -5px; /* 将滑块向上偏移,使其垂直居中 */
}
.progress-slider::-webkit-slider-thumb:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.progress-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--gantt-primary, #409eff);
cursor: pointer;
border: none;
position: relative;
z-index: 2;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s ease; /* 只保留阴影过渡效果 */
margin-top: -5px; /* 将滑块向上偏移,使其垂直居中 */
}
.progress-slider::-moz-range-thumb:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
/* 移除悬停和聚焦状态下的滑块位置调整,保持一致 */
/* 进度输入组样式 */
.progress-input-group {
display: flex;
align-items: center;
border: 1px solid var(--gantt-border-medium, #dcdfe6);
border-radius: 4px;
background: var(--gantt-bg-primary, white);
overflow: hidden;
transition: border-color 0.2s ease;
width: 70px; /* 固定宽度,保持紧凑 */
}
.progress-input-group:hover {
border-color: var(--gantt-primary, #409eff);
}
.progress-input-group:focus-within {
border-color: var(--gantt-primary, #409eff);
}
/* 进度输入框样式 - 与其他input保持一致 */
.progress-input {
flex: 1;
font-size: 14px;
color: var(--gantt-text-primary, #303133);
text-align: center;
border: none;
outline: none;
background: transparent;
padding: 12px 8px; /* 与其他input的padding保持一致 */
min-width: 0; /* 允许收缩 */
}
.progress-input::placeholder {
color: var(--gantt-text-placeholder, #c0c4cc);
}
/* 百分号单位样式 */
.progress-unit {
font-size: 14px;
color: var(--gantt-text-secondary, #606266);
padding: 12px 12px 12px 4px; /* 与input的padding匹配 */
user-select: none;
flex-shrink: 0; /* 防止%号被压缩 */
}
/* 暗黑模式样式 */
:global(html[data-theme='dark']) .progress-input-group {
border-color: var(--gantt-border-medium, #4c4d4f);
background: var(--gantt-bg-primary, #2b2b2b);
}
:global(html[data-theme='dark']) .progress-input-group:hover {
border-color: var(--gantt-primary, #409eff);
}
:global(html[data-theme='dark']) .progress-input-group:focus-within {
border-color: var(--gantt-primary, #409eff);
}
:global(html[data-theme='dark']) .progress-input {
color: var(--gantt-text-primary, #e5eaf3);
background: transparent;
}
:global(html[data-theme='dark']) .progress-input::placeholder {
color: var(--gantt-text-placeholder, #8b949e);
}
:global(html[data-theme='dark']) .progress-unit {
color: var(--gantt-text-secondary, #a8a8a8);
}
/* 抽屉底部 */
.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>