1147 lines
35 KiB
Vue
1147 lines
35 KiB
Vue
<template>
|
|
<div class="task-flat-view">
|
|
<div class="flat-view-header">
|
|
<h2 class="flat-view-title">{{ title }}</h2>
|
|
<span class="task-count">({{ flattenedTasks.length }})</span>
|
|
</div>
|
|
|
|
<div class="flat-view-toolbar">
|
|
<a-dropdown>
|
|
<template #overlay>
|
|
<a-menu @click="onStatusFilterChange">
|
|
<a-menu-item key="all" :class="{ 'ant-dropdown-menu-item-selected': statusFilter === 'all' }">全部事项</a-menu-item>
|
|
<a-menu-item key="completed" :class="{ 'ant-dropdown-menu-item-selected': statusFilter === 'completed' }">已完成</a-menu-item>
|
|
<a-menu-item key="uncompleted" :class="{ 'ant-dropdown-menu-item-selected': statusFilter === 'uncompleted' }">未完成</a-menu-item>
|
|
</a-menu>
|
|
</template>
|
|
<a-button size="small">
|
|
<Icon icon="ant-design:check-circle-outlined" class="mr-1" />
|
|
{{ statusFilterLabel }}
|
|
<Icon icon="ant-design:down-outlined" class="ml-1" />
|
|
</a-button>
|
|
</a-dropdown>
|
|
|
|
<a-dropdown>
|
|
<template #overlay>
|
|
<a-menu @click="onSortChange">
|
|
<a-menu-item v-for="opt in flatSortOptions" :key="opt.value" :class="{ 'ant-dropdown-menu-item-selected': sortField === opt.value }">
|
|
<div class="sort-menu-item">
|
|
<span>{{ opt.label }}</span>
|
|
<span class="sort-arrows-row">
|
|
<span
|
|
class="sort-arrow-btn"
|
|
:class="{ active: sortField === opt.value && sortDirection === 'asc' }"
|
|
@click.stop="onSortDirectionChange(opt.value, 'asc')"
|
|
>
|
|
<Icon icon="ant-design:arrow-up-outlined" />
|
|
</span>
|
|
<span
|
|
class="sort-arrow-btn"
|
|
:class="{ active: sortField === opt.value && sortDirection === 'desc' }"
|
|
@click.stop="onSortDirectionChange(opt.value, 'desc')"
|
|
>
|
|
<Icon icon="ant-design:arrow-down-outlined" />
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</a-menu-item>
|
|
</a-menu>
|
|
</template>
|
|
<a-button size="small">
|
|
<Icon icon="ant-design:sort-ascending-outlined" class="mr-1" />
|
|
排序: {{ currentSortLabel }}
|
|
<Icon icon="ant-design:down-outlined" class="ml-1" />
|
|
</a-button>
|
|
</a-dropdown>
|
|
|
|
<a-popover v-model:open="fieldConfigVisible" placement="bottomRight" trigger="click">
|
|
<template #content>
|
|
<div class="field-config-panel">
|
|
<div class="field-config-title">字段配置</div>
|
|
<div v-for="field in FIELD_CONFIG" :key="field.key" class="field-config-item" @click="toggleField(field.key)">
|
|
<span>{{ field.label }}</span>
|
|
<Icon
|
|
:icon="visibleFields.includes(field.key) ? 'ant-design:eye-outlined' : 'ant-design:eye-invisible-outlined'"
|
|
:class="visibleFields.includes(field.key) ? 'field-visible' : 'field-hidden'"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<a-button size="small">
|
|
<Icon icon="ant-design:setting-outlined" class="mr-1" />
|
|
字段配置
|
|
</a-button>
|
|
</a-popover>
|
|
</div>
|
|
|
|
<div class="flat-view-body">
|
|
<div class="flat-view-table-header" :style="{ minWidth: totalFieldsWidth + 'px' }">
|
|
<div class="table-col table-col-drag-handle"></div>
|
|
<div class="table-col table-col-title">
|
|
<Icon icon="tasklist-title|svg" class="header-icon" />
|
|
<span class="header-label">事项标题</span>
|
|
</div>
|
|
<template v-for="field in visibleFieldConfigs" :key="field.key">
|
|
<div class="table-col header-col" :style="{ width: fieldWidthMap[field.key] || '100px' }">
|
|
<Icon v-if="field.key === 'priority'" icon="tasklist-priority|svg" class="header-icon" />
|
|
<Icon
|
|
v-else-if="
|
|
field.key === 'assigneeName' ||
|
|
field.key === 'assignName' ||
|
|
field.key === 'participantName' ||
|
|
field.key === 'followersName' ||
|
|
field.key === 'createBy'
|
|
"
|
|
icon="tasklist-user|svg"
|
|
class="header-icon"
|
|
/>
|
|
<Icon v-else-if="field.key === 'startTime' || field.key === 'endTime'" icon="tasklist-time|svg" class="header-icon" />
|
|
<Icon v-else :icon="fieldIconMap[field.key]" class="header-icon" />
|
|
<span class="header-label">{{ field.label }}</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-for="ft in flattenedTasks" :key="ft.task.id" class="task-row" :style="{ minWidth: totalFieldsWidth + 'px' }">
|
|
<div class="table-col table-col-drag-handle"></div>
|
|
<div class="table-col table-col-title" @click.stop="emit('task-click', ft.task.id)">
|
|
<div class="task-indent" :style="{ marginLeft: Math.min(ft.level, 4) * 20 + 'px' }">
|
|
<span v-if="ft.task.childCount > 0" class="expand-arrow" @click.stop="toggleTaskExpand(ft.task.id)">
|
|
<Icon :icon="expandedTaskIds.has(ft.task.id) ? 'ant-design:caret-down-outlined' : 'ant-design:caret-right-outlined'" />
|
|
</span>
|
|
<span v-else class="expand-arrow-placeholder"></span>
|
|
<button
|
|
class="task-checkbox"
|
|
:class="{ checked: ft.task.completed }"
|
|
:disabled="!canEditTask(ft.task)"
|
|
@click.stop="canEditTask(ft.task) && emit('toggle-task', ft.task.id)"
|
|
>
|
|
<Icon v-if="ft.task.completed" icon="ant-design:check-outlined" />
|
|
</button>
|
|
</div>
|
|
<span class="task-title" :class="{ completed: ft.task.completed }">{{ ft.task.taskName }}</span>
|
|
<span v-if="ft.task.childCount > 0" class="task-child-count">
|
|
<Icon icon="tasklist-subtask|svg" class="child-count-icon" />
|
|
{{ ft.task.completedChildCount }}/{{ ft.task.childCount }}
|
|
</span>
|
|
</div>
|
|
|
|
<template v-for="field in visibleFieldConfigs" :key="field.key">
|
|
<div class="table-col task-field" :style="{ width: fieldWidthMap[field.key] || '100px' }">
|
|
<template v-if="field.key === 'priority'">
|
|
<a-dropdown
|
|
v-if="canEditTask(ft.task)"
|
|
:trigger="['click']"
|
|
:get-popup-container="getPopupContainer"
|
|
:overlay-style="{ minWidth: '100px' }"
|
|
>
|
|
<div class="cell-inner editable-cell dropdown-cell">
|
|
<span
|
|
v-if="ft.task.priority"
|
|
class="priority-tag"
|
|
:class="{ 'task-field-completed': ft.task.completed }"
|
|
:style="{ backgroundColor: getPriorityBg(ft.task.priority), color: '#1f2329' }"
|
|
>{{ getPriorityLabel(ft.task.priority) }}</span
|
|
>
|
|
<span v-else class="field-placeholder">-</span>
|
|
</div>
|
|
<template #overlay>
|
|
<a-menu @click="({ key }) => onPriorityChange(ft.task.id, key)">
|
|
<a-menu-item v-for="opt in PRIORITY_OPTIONS" :key="opt.value" class="priority-menu-item">
|
|
<span class="priority-tag" :style="{ backgroundColor: getPriorityBg(opt.value), color: '#1f2329' }">
|
|
{{ opt.label }}
|
|
</span>
|
|
</a-menu-item>
|
|
</a-menu>
|
|
</template>
|
|
</a-dropdown>
|
|
<div v-else class="cell-inner">
|
|
<span
|
|
v-if="ft.task.priority"
|
|
class="priority-tag"
|
|
:class="{ 'task-field-completed': ft.task.completed }"
|
|
:style="{ backgroundColor: getPriorityBg(ft.task.priority), color: '#1f2329' }"
|
|
>{{ getPriorityLabel(ft.task.priority) }}</span
|
|
>
|
|
<span v-else class="field-placeholder">-</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="field.key === 'assigneeName'">
|
|
<div v-if="canEditTask(ft.task)" class="cell-inner editable-cell" @click.stop="openUserSelectModal('assignee', ft.task.id)">
|
|
<template v-if="ft.task.assigneeName">
|
|
<AvatarDisplay :names="ft.task.assigneeName" :size="24" :completed="ft.task.completed" />
|
|
</template>
|
|
<template v-else>
|
|
<span class="field-icon-placeholder" :class="{ 'task-field-completed': ft.task.completed }">
|
|
<Icon icon="tasklist-add-user|svg" />
|
|
</span>
|
|
</template>
|
|
</div>
|
|
<div v-else class="cell-inner">
|
|
<template v-if="ft.task.assigneeName">
|
|
<AvatarDisplay :names="ft.task.assigneeName" :size="24" :completed="ft.task.completed" />
|
|
</template>
|
|
<span v-else class="field-placeholder">-</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="field.key === 'participantName'">
|
|
<div v-if="canEditTask(ft.task)" class="cell-inner editable-cell" @click.stop="openUserSelectModal('participant', ft.task.id)">
|
|
<template v-if="ft.task.participantName">
|
|
<AvatarDisplay :names="ft.task.participantName" :size="24" :completed="ft.task.completed" />
|
|
</template>
|
|
<template v-else>
|
|
<span class="field-icon-placeholder" :class="{ 'task-field-completed': ft.task.completed }">
|
|
<Icon icon="tasklist-add-user|svg" />
|
|
</span>
|
|
</template>
|
|
</div>
|
|
<div v-else class="cell-inner">
|
|
<template v-if="ft.task.participantName">
|
|
<AvatarDisplay :names="ft.task.participantName" :size="24" :completed="ft.task.completed" />
|
|
</template>
|
|
<span v-else class="field-placeholder">-</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="field.key === 'followersName'">
|
|
<div v-if="canEditTask(ft.task)" class="cell-inner editable-cell" @click.stop="openUserSelectModal('followers', ft.task.id)">
|
|
<template v-if="ft.task.followersName">
|
|
<AvatarDisplay :names="ft.task.followersName" :size="24" :completed="ft.task.completed" />
|
|
</template>
|
|
<template v-else>
|
|
<span class="field-icon-placeholder" :class="{ 'task-field-completed': ft.task.completed }">
|
|
<Icon icon="tasklist-add-user|svg" />
|
|
</span>
|
|
</template>
|
|
</div>
|
|
<div v-else class="cell-inner">
|
|
<template v-if="ft.task.followersName">
|
|
<AvatarDisplay :names="ft.task.followersName" :size="24" :completed="ft.task.completed" />
|
|
</template>
|
|
<span v-else class="field-placeholder">-</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="field.key === 'startTime' || field.key === 'endTime'">
|
|
<div
|
|
class="cell-inner date-cell-wrapper"
|
|
:class="{ 'editable-cell': canEditTask(ft.task) }"
|
|
@click.stop="canEditTask(ft.task) && !datePickerVisible && startEditCell(ft.task.id, field.key)"
|
|
>
|
|
<a-date-picker
|
|
v-if="editingCell?.taskId === ft.task.id && editingCell?.field === field.key"
|
|
:value="getEditingDate(field.key)"
|
|
:open="datePickerVisible"
|
|
placeholder="选择日期"
|
|
format="YYYY-MM-DD"
|
|
:allow-clear="true"
|
|
:disabled-date="field.key === 'endTime' ? getDisabledEndDate(ft.task.id) : undefined"
|
|
class="inline-date-picker"
|
|
@change="(date: any) => onDateCellChange(ft.task.id, date, field.key)"
|
|
@open-change="onCellPickerOpenChange"
|
|
>
|
|
<template #renderExtraFooter>
|
|
<div style="text-align: right">
|
|
<a-button type="link" size="small" danger @click.stop="clearDateCell(ft.task.id, field.key)">清除</a-button>
|
|
</div>
|
|
</template>
|
|
</a-date-picker>
|
|
<template v-else>
|
|
<template v-if="(field.key === 'startTime' && ft.task.startTime) || (field.key === 'endTime' && ft.task.endTime)">
|
|
<span
|
|
class="field-value"
|
|
:class="{
|
|
'task-field-completed': ft.task.completed,
|
|
'overdue-date': field.key === 'endTime' && isOverdue(ft.task),
|
|
}"
|
|
>
|
|
{{ field.key === 'startTime' ? ft.task.startTime : ft.task.endTime }}
|
|
</span>
|
|
</template>
|
|
<template v-else>
|
|
<span class="field-icon-placeholder" :class="{ 'task-field-completed': ft.task.completed }">
|
|
<Icon icon="tasklist-calendar|svg" />
|
|
</span>
|
|
</template>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="field.key === 'completeTime'">
|
|
<div class="cell-inner readonly-cell">
|
|
<span v-if="ft.task.completeTime" class="field-value" :class="{ 'task-field-completed': ft.task.completed }">
|
|
{{ ft.task.completeTime.substring(0, 10) }}
|
|
</span>
|
|
<span v-else class="field-placeholder">-</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="field.key === 'subTaskProgress'">
|
|
<div class="cell-inner readonly-cell">
|
|
<div v-if="ft.task.childCount > 0" class="progress-cell">
|
|
<div class="progress-bar">
|
|
<div
|
|
class="progress-bar-fill"
|
|
:style="{
|
|
width: (ft.task.completedChildCount / ft.task.childCount) * 100 + '%',
|
|
background: ft.task.completedChildCount > 0 ? '#52c41a' : '#1677ff',
|
|
}"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
<span v-else :class="{ 'task-field-completed': ft.task.completed }">-</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="field.key === 'remark'">
|
|
<div class="cell-inner">
|
|
<span v-if="ft.task.remark" class="field-value" :class="{ 'task-field-completed': ft.task.completed }" :title="ft.task.remark">
|
|
{{ ft.task.remark.length > 20 ? ft.task.remark.substring(0, 20) + '...' : ft.task.remark }}
|
|
</span>
|
|
<span v-else class="field-placeholder">-</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="field.key === 'assignName' || field.key === 'createBy'">
|
|
<div class="cell-inner readonly-cell">
|
|
<template v-if="getFieldValue(ft.task, field.key)">
|
|
<AvatarDisplay :names="getFieldValue(ft.task, field.key)" :size="24" :completed="ft.task.completed" />
|
|
</template>
|
|
<span v-else class="field-placeholder">-</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="field.key === 'taskDesc' || field.key === 'listName'">
|
|
<div class="cell-inner readonly-cell">
|
|
<span v-if="getFieldValue(ft.task, field.key)" class="field-value">{{ getFieldValue(ft.task, field.key) }}</span>
|
|
<span v-else class="field-placeholder">-</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<div class="cell-inner">
|
|
<span :class="{ 'task-field-completed': ft.task.completed }">{{ getFieldValue(ft.task, field.key) }}</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="flattenedTasks.length === 0" class="empty-state">
|
|
<Icon icon="ant-design:inbox-outlined" class="empty-icon" />
|
|
<p>暂无事项</p>
|
|
</div>
|
|
</div>
|
|
|
|
<UserSelectModal
|
|
ref="userSelectModalRef"
|
|
:title="userSelectModalType === 'assignee' ? '选择负责人' : userSelectModalType === 'participant' ? '选择参与人' : '选择关注人'"
|
|
:user-ids="userSelectModalCurrentIds"
|
|
:user-names="userSelectModalCurrentNames"
|
|
:multiple="true"
|
|
@confirm="onUserSelectConfirm"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { ref, computed, nextTick } from 'vue';
|
|
import dayjs from 'dayjs';
|
|
import Icon from '/@/components/Icon/index';
|
|
import AvatarDisplay from './AvatarDisplay.vue';
|
|
import UserSelectModal from './UserSelectModal.vue';
|
|
import { FIELD_CONFIG, SORT_OPTIONS, MAX_TASK_DEPTH } from '../types';
|
|
import { PRIORITY_OPTIONS, getPriorityBg, getPriorityLabel, isOverdue } from '../utils/taskUtils';
|
|
import type { TaskItem, StatusFilter, SortField, SortDirection, FieldKey, FlatTaskItem } from '../types';
|
|
|
|
const props = defineProps<{
|
|
title: string;
|
|
tasks: TaskItem[];
|
|
statusFilter: StatusFilter;
|
|
sortField: SortField;
|
|
sortDirection: SortDirection;
|
|
visibleFields: FieldKey[];
|
|
editable?: boolean;
|
|
taskEditMap?: Map<string, boolean>;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
'toggle-task': [taskId: string];
|
|
'task-click': [taskId: string];
|
|
'update-task-field': [taskId: string, field: string, value: any];
|
|
'update-task-fields': [taskId: string, fields: Record<string, any>];
|
|
'update:statusFilter': [value: StatusFilter];
|
|
'update:sortField': [value: SortField];
|
|
'update:sortDirection': [value: SortDirection];
|
|
'update:visibleFields': [value: FieldKey[]];
|
|
'load-subtasks': [taskId: string, mainId: string];
|
|
}>();
|
|
|
|
const fieldConfigVisible = ref(false);
|
|
const expandedTaskIds = ref<Set<string>>(new Set());
|
|
const loadedChildrenIds = ref<Set<string>>(new Set());
|
|
|
|
const visibleFieldConfigs = computed(() => {
|
|
return FIELD_CONFIG.filter((f) => props.visibleFields.includes(f.key));
|
|
});
|
|
|
|
const flatSortOptions = computed(() => {
|
|
return SORT_OPTIONS.filter((o) => o.value !== 'sortOrder');
|
|
});
|
|
|
|
const fieldWidthMap: Record<string, string> = {
|
|
listName: '140px',
|
|
taskDesc: '140px',
|
|
priority: '120px',
|
|
assigneeName: '120px',
|
|
assignName: '120px',
|
|
participantName: '120px',
|
|
followersName: '120px',
|
|
createBy: '120px',
|
|
startTime: '180px',
|
|
endTime: '180px',
|
|
completeTime: '140px',
|
|
createTime: '160px',
|
|
updateTime: '160px',
|
|
remark: '160px',
|
|
subTaskProgress: '110px',
|
|
};
|
|
|
|
const fieldIconMap: Record<string, string> = {
|
|
taskDesc: 'ant-design:align-left-outlined',
|
|
priority: 'ant-design:flag-outlined',
|
|
assigneeName: 'inline-user',
|
|
assignName: 'inline-user',
|
|
participantName: 'inline-user',
|
|
followersName: 'inline-user',
|
|
createBy: 'inline-user',
|
|
startTime: 'ant-design:calendar-outlined',
|
|
endTime: 'ant-design:clock-circle-outlined',
|
|
completeTime: 'ant-design:check-circle-outlined',
|
|
createTime: 'ant-design:plus-circle-outlined',
|
|
updateTime: 'ant-design:sync-outlined',
|
|
remark: 'ant-design:file-text-outlined',
|
|
subTaskProgress: 'ant-design:pie-chart-outlined',
|
|
listName: 'ant-design:file-text-outlined',
|
|
};
|
|
|
|
const totalFieldsWidth = computed(() => {
|
|
let w = 400 + 40;
|
|
for (const f of visibleFieldConfigs.value) {
|
|
w += parseInt(fieldWidthMap[f.key] || '100', 10);
|
|
}
|
|
return w + 48;
|
|
});
|
|
|
|
const statusFilterLabel = computed(() => {
|
|
const map: Record<StatusFilter, string> = { all: '全部事项', completed: '已完成', uncompleted: '未完成' };
|
|
return map[props.statusFilter];
|
|
});
|
|
|
|
const currentSortLabel = computed(() => {
|
|
const opt = flatSortOptions.value.find((o) => o.value === props.sortField);
|
|
if (opt) return opt.label;
|
|
const allOpt = SORT_OPTIONS.find((o) => o.value === props.sortField);
|
|
return allOpt ? allOpt.label : '';
|
|
});
|
|
|
|
const allTaskMap = computed(() => {
|
|
const map = new Map<string, TaskItem>();
|
|
for (const t of props.tasks) {
|
|
map.set(t.id, t);
|
|
}
|
|
return map;
|
|
});
|
|
|
|
const flatSortedTasks = computed(() => {
|
|
let result = [...props.tasks];
|
|
|
|
if (props.statusFilter === 'completed') {
|
|
result = result.filter((t) => t.completed);
|
|
} else if (props.statusFilter === 'uncompleted') {
|
|
result = result.filter((t) => !t.completed);
|
|
}
|
|
|
|
const dir = props.sortDirection === 'asc' ? 1 : -1;
|
|
result.sort((a, b) => {
|
|
const va = (a as any)[props.sortField] || '';
|
|
const vb = (b as any)[props.sortField] || '';
|
|
if (va < vb) return -1 * dir;
|
|
if (va > vb) return 1 * dir;
|
|
return 0;
|
|
});
|
|
|
|
return result;
|
|
});
|
|
|
|
const flattenedTasks = computed(() => {
|
|
const result: FlatTaskItem[] = [];
|
|
const visited = new Set<string>();
|
|
|
|
for (const task of flatSortedTasks.value) {
|
|
if (visited.has(task.id)) continue;
|
|
if (!task.pid || task.pid === task.groupId || !allTaskMap.value.has(task.pid)) {
|
|
collectFlatTasks(task, 0, result, visited);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
});
|
|
|
|
function collectFlatTasks(task: TaskItem, level: number, result: FlatTaskItem[], visited: Set<string>) {
|
|
if (visited.has(task.id)) return;
|
|
if (level >= MAX_TASK_DEPTH) return;
|
|
visited.add(task.id);
|
|
result.push({ task, level });
|
|
if (task.childCount > 0 && expandedTaskIds.value.has(task.id)) {
|
|
const childTasks = props.tasks.filter((t) => t.pid === task.id);
|
|
const sorted = [...childTasks].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
|
for (const child of sorted) {
|
|
collectFlatTasks(child, level + 1, result, visited);
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleTaskExpand(taskId: string) {
|
|
const newSet = new Set(expandedTaskIds.value);
|
|
if (newSet.has(taskId)) {
|
|
newSet.delete(taskId);
|
|
expandedTaskIds.value = newSet;
|
|
} else {
|
|
newSet.add(taskId);
|
|
expandedTaskIds.value = newSet;
|
|
const task = props.tasks.find((t) => t.id === taskId);
|
|
if (task && task.childCount > 0 && !loadedChildrenIds.value.has(taskId) && task.mainId) {
|
|
loadedChildrenIds.value = new Set([...loadedChildrenIds.value, taskId]);
|
|
emit('load-subtasks', taskId, task.mainId);
|
|
}
|
|
}
|
|
}
|
|
|
|
function canEditTask(task: TaskItem): boolean {
|
|
if (!props.editable && !props.taskEditMap) return false;
|
|
if (props.editable) return true;
|
|
if (props.taskEditMap) return props.taskEditMap.has(task.id);
|
|
return false;
|
|
}
|
|
|
|
function getFieldValue(task: TaskItem, key: FieldKey): string {
|
|
return (task as any)[key] || '';
|
|
}
|
|
|
|
function getPopupContainer() {
|
|
return document.body;
|
|
}
|
|
|
|
function onStatusFilterChange({ key }: { key: string }) {
|
|
emit('update:statusFilter', key as StatusFilter);
|
|
}
|
|
|
|
function onSortChange({ key }: { key: string }) {
|
|
if (props.sortField === key) {
|
|
emit('update:sortDirection', props.sortDirection === 'asc' ? 'desc' : 'asc');
|
|
} else {
|
|
emit('update:sortField', key as SortField);
|
|
emit('update:sortDirection', 'desc');
|
|
}
|
|
}
|
|
|
|
function onSortDirectionChange(field: string, dir: string) {
|
|
emit('update:sortField', field as SortField);
|
|
emit('update:sortDirection', dir as SortDirection);
|
|
}
|
|
|
|
function toggleField(fieldKey: FieldKey) {
|
|
const fields = [...props.visibleFields];
|
|
const idx = fields.indexOf(fieldKey);
|
|
if (idx >= 0) {
|
|
fields.splice(idx, 1);
|
|
} else {
|
|
fields.push(fieldKey);
|
|
}
|
|
emit('update:visibleFields', fields);
|
|
}
|
|
|
|
function onPriorityChange(taskId: string, value: any) {
|
|
if (value !== undefined && value !== null) {
|
|
emit('update-task-field', taskId, 'priority', value);
|
|
}
|
|
}
|
|
|
|
const editingCell = ref<{ taskId: string; field: string } | null>(null);
|
|
const datePickerVisible = ref(false);
|
|
|
|
function startEditCell(taskId: string, field: string) {
|
|
editingCell.value = { taskId, field };
|
|
datePickerVisible.value = true;
|
|
}
|
|
|
|
function cancelEdit() {
|
|
editingCell.value = null;
|
|
datePickerVisible.value = false;
|
|
}
|
|
|
|
function getEditingDate(fieldKey: string) {
|
|
const taskId = editingCell.value?.taskId;
|
|
if (!taskId) return null;
|
|
const task = props.tasks.find((t) => t.id === taskId);
|
|
if (!task) return null;
|
|
const val = fieldKey === 'startTime' ? task.startTime : task.endTime;
|
|
return val ? dayjs(val.substring(0, 10)) : null;
|
|
}
|
|
|
|
function getTaskField(taskId: string, field: string): string {
|
|
const task = props.tasks.find((t) => t.id === taskId);
|
|
if (!task) return '';
|
|
return (task as any)[field] || '';
|
|
}
|
|
|
|
function getDisabledEndDate(taskId: string) {
|
|
return (current: any) => {
|
|
if (!current) return false;
|
|
const start = getTaskField(taskId, 'startTime');
|
|
if (!start) return false;
|
|
return dayjs(current).isBefore(dayjs(start), 'day');
|
|
};
|
|
}
|
|
|
|
function onDateCellChange(taskId: string, date: any, fieldKey: string) {
|
|
const task = props.tasks.find((t) => t.id === taskId);
|
|
const value = date ? dayjs(date).format('YYYY-MM-DD') + ' 00:00:00' : '';
|
|
const updates: Record<string, string> = {};
|
|
if (fieldKey === 'startTime') {
|
|
updates.startTime = value;
|
|
updates.endTime = normalizeDate(task?.endTime);
|
|
} else if (fieldKey === 'endTime') {
|
|
updates.startTime = normalizeDate(task?.startTime);
|
|
updates.endTime = value;
|
|
}
|
|
emit('update-task-fields', taskId, updates);
|
|
cancelEdit();
|
|
}
|
|
|
|
function clearDateCell(taskId: string, fieldKey: string) {
|
|
const task = props.tasks.find((t) => t.id === taskId);
|
|
const updates: Record<string, string> = {};
|
|
if (fieldKey === 'startTime') {
|
|
updates.startTime = '';
|
|
updates.endTime = normalizeDate(task?.endTime);
|
|
} else if (fieldKey === 'endTime') {
|
|
updates.startTime = normalizeDate(task?.startTime);
|
|
updates.endTime = '';
|
|
}
|
|
emit('update-task-fields', taskId, updates);
|
|
cancelEdit();
|
|
}
|
|
|
|
function onCellPickerOpenChange(open: boolean) {
|
|
if (!open) {
|
|
setTimeout(() => {
|
|
if (editingCell.value) {
|
|
cancelEdit();
|
|
}
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
function normalizeDate(val: string | null | undefined): string {
|
|
if (!val) return '';
|
|
if (val.length >= 19) return val.substring(0, 19);
|
|
return val.substring(0, 10) + ' 00:00:00';
|
|
}
|
|
|
|
const userSelectModalRef = ref();
|
|
const userSelectModalType = ref<'assignee' | 'followers' | 'participant'>('assignee');
|
|
const userSelectModalTaskId = ref('');
|
|
const userSelectModalCurrentIds = ref('');
|
|
const userSelectModalCurrentNames = ref('');
|
|
|
|
function openUserSelectModal(type: 'assignee' | 'followers' | 'participant', taskId: string) {
|
|
userSelectModalType.value = type;
|
|
userSelectModalTaskId.value = taskId;
|
|
|
|
const task = props.tasks.find((t) => t.id === taskId);
|
|
if (type === 'assignee') {
|
|
userSelectModalCurrentIds.value = task?.assigneeId || '';
|
|
userSelectModalCurrentNames.value = task?.assigneeName || '';
|
|
} else if (type === 'participant') {
|
|
userSelectModalCurrentIds.value = task?.participantId || '';
|
|
userSelectModalCurrentNames.value = task?.participantName || '';
|
|
} else {
|
|
userSelectModalCurrentIds.value = task?.followersId || '';
|
|
userSelectModalCurrentNames.value = task?.followersName || '';
|
|
}
|
|
|
|
nextTick(() => {
|
|
userSelectModalRef.value?.open();
|
|
});
|
|
}
|
|
|
|
function onUserSelectConfirm(userIds: string, userNames: string) {
|
|
const type = userSelectModalType.value;
|
|
const taskId = userSelectModalTaskId.value;
|
|
if (taskId) {
|
|
if (type === 'assignee') {
|
|
emit('update-task-fields', taskId, { assigneeId: userIds, assigneeName: userNames });
|
|
} else if (type === 'participant') {
|
|
emit('update-task-fields', taskId, { participantId: userIds, participantName: userNames });
|
|
} else {
|
|
emit('update-task-fields', taskId, { followersId: userIds, followersName: userNames });
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style lang="less" scoped>
|
|
.task-flat-view {
|
|
flex: 1;
|
|
height: 100%;
|
|
background: #fff;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.flat-view-header {
|
|
padding: 16px 24px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.flat-view-title {
|
|
margin: 0;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: #1f1f1f;
|
|
}
|
|
|
|
.task-count {
|
|
font-size: 14px;
|
|
color: #8f959e;
|
|
}
|
|
|
|
.flat-view-toolbar {
|
|
padding: 8px 24px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.flat-view-body {
|
|
flex: 1;
|
|
overflow: auto;
|
|
}
|
|
|
|
.flat-view-table-header {
|
|
padding: 0 8px;
|
|
height: 40px;
|
|
background: #fafafa;
|
|
border-bottom: 1px solid #e5e6eb;
|
|
display: flex;
|
|
align-items: stretch;
|
|
font-size: 14px;
|
|
color: #1f2329;
|
|
font-weight: 500;
|
|
flex-shrink: 0;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
|
|
.header-col {
|
|
padding: 0 12px;
|
|
gap: 4px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.header-icon {
|
|
color: #8f959e;
|
|
font-size: 14px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.header-label {
|
|
white-space: nowrap;
|
|
overflow: visible;
|
|
}
|
|
}
|
|
|
|
.table-col {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
height: 100%;
|
|
}
|
|
|
|
.table-col-drag-handle {
|
|
width: 24px;
|
|
min-width: 24px;
|
|
justify-content: center;
|
|
}
|
|
|
|
.table-col-title {
|
|
width: 400px;
|
|
min-width: 400px;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.task-indent {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.expand-arrow {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 16px;
|
|
height: 16px;
|
|
font-size: 11px;
|
|
color: #999;
|
|
cursor: pointer;
|
|
border-radius: 2px;
|
|
flex-shrink: 0;
|
|
|
|
&:hover {
|
|
color: #1677ff;
|
|
background: #e6f4ff;
|
|
}
|
|
}
|
|
|
|
.expand-arrow-placeholder {
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
|
|
.task-row {
|
|
display: flex;
|
|
align-items: stretch;
|
|
height: 46px;
|
|
padding: 0 8px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
font-size: 14px;
|
|
box-sizing: border-box;
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
background: #f5f6f7;
|
|
}
|
|
}
|
|
|
|
.task-checkbox {
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 50%;
|
|
border: 2px solid #d9d9d9;
|
|
background: none;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.2s;
|
|
padding: 0;
|
|
flex-shrink: 0;
|
|
|
|
&:hover {
|
|
border-color: #52c41a;
|
|
}
|
|
|
|
&.checked {
|
|
background: #52c41a;
|
|
border-color: #52c41a;
|
|
color: #fff;
|
|
font-size: 11px;
|
|
}
|
|
|
|
&:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
|
|
&:hover {
|
|
border-color: #d9d9d9;
|
|
}
|
|
}
|
|
}
|
|
|
|
.task-title {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: #1f2329;
|
|
flex-shrink: 1;
|
|
min-width: 0;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
transition: background 0.15s;
|
|
|
|
&.completed {
|
|
text-decoration: line-through;
|
|
color: #c0c4cc;
|
|
}
|
|
}
|
|
|
|
.task-row:hover .task-title {
|
|
background: #fff;
|
|
}
|
|
|
|
.task-child-count {
|
|
font-size: 11px;
|
|
color: #8c8c8c;
|
|
margin-left: 6px;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
}
|
|
|
|
.child-count-icon {
|
|
font-size: 12px;
|
|
color: #8f959e;
|
|
}
|
|
|
|
.task-field {
|
|
color: #646a73;
|
|
overflow: visible;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.task-field-completed {
|
|
color: #c0c4cc;
|
|
}
|
|
|
|
.overdue-date {
|
|
color: #f5222d !important;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.cell-inner {
|
|
display: flex;
|
|
align-items: center;
|
|
width: 100%;
|
|
height: 100%;
|
|
padding: 0 12px;
|
|
border: 1px solid transparent;
|
|
border-radius: 4px;
|
|
transition: border-color 0.15s;
|
|
box-sizing: border-box;
|
|
|
|
&:hover {
|
|
border-color: #d0d3d6;
|
|
}
|
|
}
|
|
|
|
.date-cell-wrapper {
|
|
position: relative;
|
|
}
|
|
|
|
.inline-date-picker {
|
|
width: 100% !important;
|
|
|
|
:deep(.ant-picker) {
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
.editable-cell {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.dropdown-cell {
|
|
position: relative;
|
|
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
right: 6px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
border: 4px solid transparent;
|
|
border-top-color: #999;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
pointer-events: none;
|
|
}
|
|
|
|
&:hover::after {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.readonly-cell {
|
|
&:hover {
|
|
border-color: transparent;
|
|
}
|
|
}
|
|
|
|
.field-value {
|
|
color: #646a73;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.field-placeholder {
|
|
color: #c0c4cc;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.field-icon-placeholder {
|
|
color: #8f959e;
|
|
font-size: 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
transition: color 0.15s;
|
|
|
|
&:hover {
|
|
color: #3370ff;
|
|
}
|
|
}
|
|
|
|
.priority-tag {
|
|
display: inline-block;
|
|
padding: 1px 8px;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
line-height: 20px;
|
|
white-space: nowrap;
|
|
font-weight: 500;
|
|
|
|
&.task-field-completed {
|
|
opacity: 0.5;
|
|
}
|
|
}
|
|
|
|
.priority-menu-item {
|
|
display: flex;
|
|
align-items: center;
|
|
min-height: 32px;
|
|
}
|
|
|
|
.progress-cell {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.progress-bar {
|
|
width: 60px;
|
|
height: 6px;
|
|
background: #f0f0f0;
|
|
border-radius: 3px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-bar-fill {
|
|
height: 100%;
|
|
border-radius: 3px;
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
.sort-menu-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
width: 160px;
|
|
}
|
|
|
|
.sort-arrows-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.sort-arrow-btn {
|
|
width: 24px;
|
|
height: 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
color: #bfbfbf;
|
|
font-size: 14px;
|
|
transition:
|
|
color 0.15s,
|
|
background 0.15s;
|
|
|
|
&.active {
|
|
color: #1677ff;
|
|
background: #e6f4ff;
|
|
}
|
|
|
|
&:hover {
|
|
color: #1677ff;
|
|
}
|
|
}
|
|
|
|
.field-config-panel {
|
|
.field-config-title {
|
|
font-weight: 600;
|
|
margin-bottom: 8px;
|
|
padding-bottom: 8px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.field-config-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 6px 4px;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
|
|
&:hover {
|
|
background: #f5f5f5;
|
|
}
|
|
}
|
|
|
|
.field-visible {
|
|
color: #1677ff;
|
|
}
|
|
|
|
.field-hidden {
|
|
color: #d9d9d9;
|
|
}
|
|
}
|
|
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 60px 24px;
|
|
color: #bbb;
|
|
|
|
.empty-icon {
|
|
font-size: 48px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
p {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
}
|
|
}
|
|
|
|
.mr-1 {
|
|
margin-right: 4px;
|
|
}
|
|
|
|
.ml-1 {
|
|
margin-left: 4px;
|
|
}
|
|
</style>
|