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
+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>