Files
supervision-test-frontend-repo/src/views/tasklist/components/TaskFlatView.vue
T

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>