czh-20260430-修复任务管理相关bug

This commit is contained in:
zhihao
2026-04-30 22:43:59 +08:00
parent 2641522dcd
commit d9d5ab70cc
8 changed files with 1925 additions and 556 deletions
@@ -30,12 +30,12 @@
try {
setModalProps({ confirmLoading: true });
const values = await validate();
await addTaskList({
const listId = await addTaskList({
tasklistName: values.tasklistName || values.name,
pid: pendingGroupId || undefined,
});
closeModal();
emit('success');
emit('success', listId);
} finally {
setModalProps({ confirmLoading: false });
}
+309 -331
View File
@@ -8,9 +8,9 @@
<div class="content-header-left">
<div v-if="headerRenaming" class="content-header-rename-row">
<a-input
ref="headerRenameInputRef"
v-model:value="headerRenameValue"
size="small"
autofocus
style="width: 200px"
@press-enter="confirmHeaderRename"
@blur="confirmHeaderRename"
@@ -53,39 +53,7 @@
</template>
</div>
<div v-if="showListGroupView" class="content-tabs">
<div
v-for="tab in LIST_TAB_ITEMS"
:key="tab.key"
class="content-tab"
:class="{ active: listGroupTab === tab.key }"
@click="emit('update:list-group-tab', tab.key)"
>
{{ tab.label }}
</div>
</div>
<div v-if="!showListGroupView" class="content-toolbar">
<div v-if="canEdit" class="feishu-btn-group">
<a-button size="small" class="feishu-btn-main" @click="handleCreateTask()">
<Icon icon="ant-design:plus-outlined" class="mr-1" />
新建任务
</a-button>
<a-dropdown>
<a-button size="small" class="feishu-btn-arrow">
<Icon icon="ant-design:down-outlined" class="feishu-arrow-icon" />
</a-button>
<template #overlay>
<a-menu @click="onCreateMenuClick">
<a-menu-item key="group">
<Icon icon="ant-design:folder-add-outlined" class="mr-1" />
新建分组
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<a-dropdown>
<template #overlay>
<a-menu @click="onStatusFilterChange">
@@ -282,12 +250,14 @@
</div>
<div v-for="list in allTaskLists" :key="list.id" class="list-group-row" @click="emit('list-select', list.id)">
<div class="list-group-col list-group-col-name">
<Icon icon="tasklist-list-file|svg" class="list-icon" size="16" />
<Icon icon="ant-design:file-text-outlined" class="list-icon" size="16" />
<span class="list-name-text">{{ list.name }}</span>
<span class="pin-btn-wrap" @click.stop="toggleFavorite(list.id)">
<Icon v-if="!isFavorited(list.id)" icon="tasklist-star|svg" class="pin-svg pin-svg-outlined" />
<Icon v-else icon="tasklist-star-filled|svg" class="pin-svg pin-svg-filled" />
</span>
<a-tooltip :title="isFavorited(list.id) ? '取消收藏' : '收藏'">
<span class="pin-btn-wrap" @click.stop="toggleFavorite(list.id)">
<Icon v-if="!isFavorited(list.id)" icon="tasklist-star|svg" class="pin-svg pin-svg-outlined" />
<Icon v-else icon="tasklist-star-filled|svg" class="pin-svg pin-svg-filled" />
</span>
</a-tooltip>
</div>
<div class="list-group-col list-group-col-owner">
<template v-if="getFirstAssignee(list)">
@@ -344,7 +314,7 @@
<template v-if="currentList">
<draggable
:model-value="computedGroups"
:list="localGroups"
item-key="id"
group="taskGroups"
handle=".group-drag-handle"
@@ -360,85 +330,73 @@
:style="{ minWidth: totalFieldsWidth + 'px' }"
@click="toggleGroupCollapse(group.id)"
>
<span v-if="canEdit && groupDimension === 'custom'" class="group-drag-handle">
<span v-if="canEdit && groupDimension === 'custom'" class="group-drag-handle table-col-drag-handle">
<Icon icon="tasklist-drag-handle|svg" />
</span>
<Icon :icon="group.collapsed ? 'ant-design:caret-right-outlined' : 'ant-design:caret-down-outlined'" class="group-arrow" />
<template v-if="groupDimension === 'assignee' || groupDimension === 'createBy'">
<template v-if="group.name && group.name !== '未分配' && group.name !== '未知'">
<AvatarDisplay :names="group.name" :size="24" :show-name="true" />
<span v-else class="table-col-drag-handle"></span>
<span class="table-col table-col-title">
<div class="task-indent" style="margin-left: 0">
<span class="expand-arrow">
<Icon :icon="group.collapsed ? 'ant-design:caret-right-outlined' : 'ant-design:caret-down-outlined'" />
</span>
</div>
<template v-if="groupDimension === 'assignee' || groupDimension === 'createBy'">
<template v-if="group.name && group.name !== '未分配' && group.name !== '未知'">
<AvatarDisplay :names="group.name" :size="24" :show-name="true" />
</template>
<span v-else class="group-name">{{ group.name }}</span>
</template>
<template v-else-if="groupDimension === 'priority'">
<span
v-if="group.name && group.name !== '无优先级'"
class="priority-tag"
:style="{ backgroundColor: getPriorityBg(group.name), color: '#1f2329' }"
>
{{ getPriorityLabel(group.name) }}
</span>
<span v-else class="group-name">{{ group.name }}</span>
</template>
<template v-else-if="renamingGroupId === group.id">
<a-input
ref="renamingGroupInputRef"
v-model:value="renamingGroupName"
size="small"
style="width: 160px"
@press-enter="confirmRenameGroup"
@blur="confirmRenameGroup"
@click.stop
/>
</template>
<span v-else class="group-name">{{ group.name }}</span>
</template>
<template v-else-if="groupDimension === 'priority'">
<span
v-if="group.name && group.name !== '无优先级'"
class="priority-tag"
:style="{ backgroundColor: getPriorityBg(group.name), color: '#1f2329' }"
>
{{ getPriorityLabel(group.name) }}
</span>
<span v-else class="group-name">{{ group.name }}</span>
</template>
<template v-else-if="renamingGroupId === group.id">
<a-input
v-model:value="renamingGroupName"
size="small"
style="width: 160px"
autofocus
@press-enter="confirmRenameGroup"
@blur="confirmRenameGroup"
@click.stop
/>
</template>
<span v-else class="group-name">{{ group.name }}</span>
<span class="group-count">({{ countTopLevelTasks(group.tasks) }})</span>
<span class="group-actions" @click.stop>
<span
v-if="canEdit && groupDimension === 'custom'"
class="group-action-btn group-action-add"
title="新建任务"
@click.stop="showNewTaskInput = group.id"
>
<Icon icon="ant-design:plus-outlined" />
</span>
<a-dropdown v-if="canEdit && groupDimension === 'custom'">
<span class="group-action-btn group-action-more" title="更多操作" @click.stop>
<Icon icon="ant-design:more-outlined" />
<span class="group-count">({{ countTopLevelTasks(group.tasks) }})</span>
<span class="group-actions" @click.stop>
<span
v-if="canEdit && groupDimension === 'custom'"
class="group-action-btn group-action-add"
title="新建任务"
@click.stop="showNewTaskInput = group.id"
>
<Icon icon="ant-design:plus-outlined" />
</span>
<template #overlay>
<a-menu @click="({ key }) => onGroupMenuClick(key, group.id, group.name)">
<a-menu-item key="rename">重命名</a-menu-item>
<a-menu-item v-if="group.id !== '__default__'" key="delete" danger>删除</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-dropdown v-if="canEdit && groupDimension === 'custom'">
<span class="group-action-btn group-action-more" title="更多操作" @click.stop>
<Icon icon="ant-design:ellipsis-outlined" />
</span>
<template #overlay>
<a-menu @click="({ key }) => onGroupMenuClick(key, group.id, group.name)">
<a-menu-item key="rename">重命名</a-menu-item>
<a-tooltip v-if="group.id === '__default__'" title="系统内置分组不可删除" placement="right">
<a-menu-item key="delete" disabled style="color: #ccc; cursor: not-allowed">删除</a-menu-item>
</a-tooltip>
<a-menu-item v-else key="delete" danger>删除</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</span>
</span>
</div>
<template v-if="!group.collapsed">
<div
v-if="showNewTaskInput === group.id && canEdit"
class="task-row new-task-input-row"
:style="{ minWidth: totalFieldsWidth + 'px' }"
>
<div class="table-col table-col-drag-handle"></div>
<div class="table-col table-col-title">
<div class="task-indent">
<span class="expand-arrow-placeholder"></span>
<div class="task-checkbox"></div>
</div>
<a-input
v-model:value="newTaskTitle"
size="small"
placeholder="输入任务标题,按回车确认..."
autofocus
@press-enter="submitNewTask(group.id)"
@blur="submitNewTask(group.id)"
/>
</div>
</div>
<div v-show="!group.collapsed">
<draggable
:model-value="flattenGroupTasks(group.tasks)"
:item-key="(ft: any) => ft.task.id"
@@ -453,7 +411,7 @@
>
<template #item="{ element: ft }">
<div class="task-row" :data-task-id="ft.task.id" :style="{ minWidth: totalFieldsWidth + 'px' }" v-show="!isChildHidden(ft)">
<div class="table-col table-col-drag-handle" v-if="canEdit">
<div class="table-col table-col-drag-handle" v-if="_canEditTask(ft.task)">
<span class="drag-handle">
<Icon icon="tasklist-drag-handle|svg" />
</span>
@@ -468,8 +426,8 @@
<button
class="task-checkbox"
:class="{ checked: ft.task.completed }"
:disabled="isReadOnly"
@click.stop="!isReadOnly && emit('toggle-task', ft.task.id)"
: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>
@@ -483,7 +441,7 @@
<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 === 'subTaskProgress'">
<div class="cell-inner">
<div class="cell-inner readonly-cell">
<div class="progress-cell" v-if="ft.task.childCount > 0">
<div class="progress-bar">
<div
@@ -500,7 +458,7 @@
</template>
<template v-else-if="field.key === 'priority'">
<a-dropdown
v-if="canEdit"
v-if="_canEditTask(ft.task)"
:trigger="['click']"
:get-popup-container="getPopupContainer"
:overlay-style="{ minWidth: '100px' }"
@@ -537,7 +495,7 @@
</div>
</template>
<template v-else-if="field.key === 'assigneeName'">
<div v-if="canEdit" class="cell-inner editable-cell" @click.stop="openUserSelectModal('assignee', ft.task.id)">
<div v-if="_canEditTask(ft.task)" class="cell-inner editable-cell" @click.stop="openUserSelectModal('assignee', ft.task.id)">
<template v-if="ft.task.assigneeName">
<a-tooltip :title="ft.task.assigneeName" placement="top"
><AvatarDisplay :names="ft.task.assigneeName" :size="24" :completed="ft.task.completed"
@@ -561,8 +519,8 @@
<template v-else-if="field.key === 'startTime' || field.key === 'endTime'">
<div
class="cell-inner date-cell-wrapper"
:class="{ 'editable-cell': canEdit }"
@click.stop="canEdit && !datePickerVisible && startEditCell(ft.task.id, field.key)"
: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"
@@ -603,7 +561,11 @@
</div>
</template>
<template v-else-if="field.key === 'followersName'">
<div v-if="canEdit" class="cell-inner editable-cell" @click.stop="openUserSelectModal('followers', ft.task.id)">
<div
v-if="_canEditTask(ft.task)"
class="cell-inner editable-cell"
@click.stop="openUserSelectModal('followers', ft.task.id)"
>
<template v-if="ft.task.followersName">
<a-tooltip :title="ft.task.followersName" placement="top"
><AvatarDisplay :names="ft.task.followersName" :size="24" :completed="ft.task.completed"
@@ -625,7 +587,11 @@
</div>
</template>
<template v-else-if="field.key === 'participantName'">
<div v-if="canEdit" class="cell-inner editable-cell" @click.stop="openUserSelectModal('participant', ft.task.id)">
<div
v-if="_canEditTask(ft.task)"
class="cell-inner editable-cell"
@click.stop="openUserSelectModal('participant', ft.task.id)"
>
<template v-if="ft.task.participantName">
<a-tooltip :title="ft.task.participantName" placement="top"
><AvatarDisplay :names="ft.task.participantName" :size="24" :completed="ft.task.completed"
@@ -647,7 +613,7 @@
</div>
</template>
<template v-else-if="field.key === 'completeTime'">
<div class="cell-inner">
<div class="cell-inner readonly-cell">
<template v-if="ft.task.completeTime">
<span class="field-value" :class="{ 'task-field-completed': ft.task.completed }">
{{ ft.task.completeTime.substring(0, 10) }}
@@ -657,28 +623,83 @@
</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
class="cell-inner"
:class="{ 'editable-cell': canEdit }"
@click.stop="canEdit && startEditTextCell(ft.task.id, field.key)"
>
<a-input
v-if="editingCell?.taskId === ft.task.id && editingCell?.field === field.key"
v-model:value="editingValue"
size="small"
:maxlength="200"
class="inline-text-editor"
:ref="
(el: any) => {
if (el) textEditInputRef = el;
}
"
@press-enter="confirmEditTextCell"
@blur="confirmEditTextCell"
@click.stop
/>
<template v-else>
<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-icon-placeholder" :class="{ 'task-field-completed': ft.task.completed }">
<Icon icon="ant-design:edit-outlined" />
</span>
</template>
</div>
</template>
<template v-else-if="field.key === 'assignName' || field.key === 'createBy'">
<div class="cell-inner">
<div class="cell-inner readonly-cell">
<template v-if="getFieldValue(ft.task, field.key)">
<a-tooltip :title="getFieldValue(ft.task, field.key)" placement="top"
><AvatarDisplay :names="getFieldValue(ft.task, field.key)" :size="24" :completed="ft.task.completed"
/></a-tooltip>
</template>
<span v-else class="field-placeholder">-</span>
</div>
</template>
<template v-else-if="field.key === 'taskDesc'">
<div
class="cell-inner"
:class="{ 'editable-cell': canEdit }"
@click.stop="canEdit && startEditTextCell(ft.task.id, field.key)"
>
<a-input
v-if="editingCell?.taskId === ft.task.id && editingCell?.field === field.key"
v-model:value="editingValue"
size="small"
:maxlength="200"
class="inline-text-editor"
:ref="
(el: any) => {
if (el) textEditInputRef = el;
}
"
@press-enter="confirmEditTextCell"
@blur="confirmEditTextCell"
@click.stop
/>
<template v-else>
<span class="field-icon-placeholder" :class="{ 'task-field-completed': ft.task.completed }">
<Icon icon="tasklist-add-user|svg" />
<span
v-if="ft.task.taskDesc"
class="field-value"
:class="{ 'task-field-completed': ft.task.completed }"
:title="ft.task.taskDesc"
>
{{ ft.task.taskDesc.length > 20 ? ft.task.taskDesc.substring(0, 20) + '...' : ft.task.taskDesc }}
</span>
<span v-else class="field-icon-placeholder" :class="{ 'task-field-completed': ft.task.completed }">
<Icon icon="ant-design:edit-outlined" />
</span>
</template>
</div>
@@ -694,6 +715,29 @@
</template>
</draggable>
<div
v-if="showNewTaskInput === group.id && canEdit"
class="task-row new-task-input-row"
:style="{ minWidth: totalFieldsWidth + 'px' }"
>
<div class="table-col table-col-drag-handle"></div>
<div class="table-col table-col-title">
<div class="task-indent">
<span class="expand-arrow-placeholder"></span>
<div class="task-checkbox"></div>
</div>
<a-input
ref="newTaskInputRef"
v-model:value="newTaskTitle"
size="small"
placeholder="输入任务标题,按回车确认..."
autofocus
@press-enter="submitNewTask(group.id)"
@blur="submitNewTask(group.id)"
/>
</div>
</div>
<div
v-if="showNewTaskInput !== group.id && canEdit"
class="task-row new-task-btn"
@@ -709,7 +753,7 @@
<span class="new-task-text">新建任务</span>
</div>
</div>
</template>
</div>
</div>
</template>
</draggable>
@@ -717,6 +761,7 @@
<div v-if="showNewGroupInput && canEdit" class="new-group-row">
<Icon icon="ant-design:plus-outlined" class="new-task-plus" />
<a-input
ref="newGroupInputRef"
v-model:value="newGroupName"
size="small"
placeholder="输入分组名称,按回车确认..."
@@ -751,7 +796,7 @@
</template>
<script lang="ts" setup>
import { ref, computed, nextTick } from 'vue';
import { ref, computed, watch, nextTick } from 'vue';
import dayjs from 'dayjs';
import Icon from '/@/components/Icon/index';
import AvatarDisplay from './AvatarDisplay.vue';
@@ -760,6 +805,7 @@
import { useUserStore } from '/@/store/modules/user';
import { useMessage } from '/@/hooks/web/useMessage';
import { FIELD_CONFIG, GROUP_OPTIONS, SORT_OPTIONS, MAX_TASK_DEPTH, FILTER_FIELD_CONFIGS, FILTER_OPERATOR_LABELS } from '../types';
import { PRIORITY_OPTIONS, getPriorityBg, getPriorityLabel, isOverdue } from '../utils/taskUtils';
import type {
TaskList,
TaskGroup,
@@ -785,12 +831,11 @@
sortDirection: SortDirection;
visibleFields: FieldKey[];
showListGroupView?: boolean;
listGroupTab?: string;
favoriteIdMap?: Map<string, string>;
}>();
const emit = defineEmits<{
'create-task': [groupId: string, taskName: string, sortOrder?: number];
'create-task': [groupId: string, taskName: string];
'toggle-task': [taskId: string];
'task-click': [taskId: string];
'add-group': [listId: string, groupName: string];
@@ -803,7 +848,6 @@
'update:visibleFields': [value: FieldKey[]];
'update:list-group-tab': [value: string];
'list-select': [listId: string];
'create-group': [name: string];
'open-collaborator': [];
'add-to-favorites': [taskListId: string];
'remove-from-favorites': [favoriteId: string];
@@ -818,18 +862,20 @@
const userStore = useUserStore();
const { createConfirm } = useMessage();
const LIST_TAB_ITEMS = [
{ key: 'all', label: '全部' },
{ key: 'own', label: '我所有的' },
{ key: 'collab', label: '我协作的' },
];
const showNewTaskInput = ref<string | null>(null);
const newTaskTitle = ref('');
const showNewGroupInput = ref(false);
const newGroupName = ref('');
const renamingGroupId = ref<string | null>(null);
const renamingGroupName = ref('');
const renamingGroupInputRef = ref<HTMLInputElement | null>(null);
watch(renamingGroupId, async (val) => {
if (val) {
await nextTick();
renamingGroupInputRef.value?.focus();
}
});
const submittingGroup = ref(false);
const submittingRename = ref(false);
const filterVisible = ref(false);
@@ -838,6 +884,24 @@
const expandedTaskIds = ref<Set<string>>(new Set());
const draggingTaskId = ref('');
const dragBlockedOnce = ref(false);
const newTaskInputRef = ref<HTMLInputElement | null>(null);
const newGroupInputRef = ref<HTMLInputElement | null>(null);
watch(showNewTaskInput, async (val) => {
if (val) {
await nextTick();
newTaskInputRef.value?.focus();
}
});
watch(showNewGroupInput, async (val) => {
if (val) {
await nextTick();
newGroupInputRef.value?.focus();
}
});
const localGroups = ref<TaskGroup[]>([]);
function showDragBlockedMessage() {
import('ant-design-vue').then(({ message }) => {
@@ -845,29 +909,11 @@
});
}
const priorityOptions = ref<{ label: string; value: string }[]>([
{ label: '高', value: 'high' },
{ label: '中', value: 'medium' },
{ label: '低', value: 'low' },
]);
const priorityColorMap: Record<string, string> = {
high: '#ffccc7',
medium: '#ffe58f',
low: '#91d5ff',
};
function getPriorityBg(value: string) {
return priorityColorMap[value] || '#f5f5f5';
}
function getPriorityLabel(value: string): string {
const opt = priorityOptions.value.find((o) => o.value === value);
return opt?.label || value;
}
const priorityOptions = PRIORITY_OPTIONS;
const editingCell = ref<{ taskId: string; field: string } | null>(null);
const editingValue = ref<any>('');
const textEditInputRef = ref<any>(null);
const datePickerVisible = ref(false);
const pickerEpoch = ref(0);
const editingDateDayjs = computed(() => {
@@ -1084,6 +1130,14 @@
const headerRenaming = ref(false);
const headerRenameValue = ref('');
const headerRenameInputRef = ref<HTMLInputElement | null>(null);
watch(headerRenaming, async (val) => {
if (val) {
await nextTick();
headerRenameInputRef.value?.focus();
}
});
function onHeaderMenuClick({ key }: { key: string }) {
if (key === 'rename') {
@@ -1113,15 +1167,6 @@
headerRenaming.value = false;
}
function isOverdue(task: TaskItem): boolean {
if (!task.endTime || task.completed) return false;
const end = new Date(task.endTime);
const now = new Date();
now.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
return now > end;
}
function _canEditTask(task: TaskItem): boolean {
if (canEdit.value) return true;
if (isReadOnly.value && task.assigneeId) {
@@ -1210,6 +1255,24 @@
datePickerVisible.value = false;
}
function startEditTextCell(taskId: string, field: string) {
cancelEdit();
const task = findTaskById(taskId);
editingValue.value = (task as any)?.[field] || '';
editingCell.value = { taskId, field };
nextTick(() => {
textEditInputRef.value?.focus();
});
}
function confirmEditTextCell() {
if (!editingCell.value) return;
const { taskId, field } = editingCell.value;
const value = editingValue.value?.trim() ?? '';
emit('update-task-field', taskId, field, value);
cancelEdit();
}
function onPriorityChange(taskId: string, value: any) {
if (value !== undefined && value !== null) {
emit('update-task-field', taskId, 'priority', value);
@@ -1472,10 +1535,12 @@
const { oldIndex, newIndex } = evt;
if (oldIndex === newIndex) return;
const movedElement = evt.item;
const groupId = movedElement?.dataset?.groupId;
const groupId = localGroups.value[newIndex]?.id;
if (!groupId) return;
// 拖拽结束后收起分组,防止展开跳动
collapsedGroups.value.add(groupId);
emit('move-task-group', groupId, newIndex + 1);
}
@@ -1501,11 +1566,7 @@
tasks: allTasks.filter((t) => t.groupId === g.id).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)),
collapsed: collapsedGroups.value.has(g.id),
}));
groups.sort((a, b) => {
if (a.id === '__default__') return -1;
if (b.id === '__default__') return 1;
return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
});
groups.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
return groups;
}
@@ -1526,6 +1587,14 @@
return computeDynamicGroups(allTasks);
});
watch(
computedGroups,
(newGroups) => {
localGroups.value = [...newGroups];
},
{ immediate: true }
);
function flattenGroupTasks(tasks?: TaskItem[]): FlatTaskItem[] {
const sourceTasks = tasks || [];
const result: FlatTaskItem[] = [];
@@ -1665,20 +1734,12 @@
}
}
function handleCreateTask(groupId?: string) {
const targetGroupId = groupId || props.currentList?.groups[0]?.id;
if (targetGroupId) {
showNewTaskInput.value = targetGroupId;
newTaskTitle.value = '';
}
}
function submitNewTask(groupId: string) {
const title = newTaskTitle.value.trim();
newTaskTitle.value = '';
showNewTaskInput.value = null;
if (title) {
emit('create-task', groupId, title, 1);
emit('create-task', groupId, title);
}
}
@@ -1696,13 +1757,6 @@
}, 300);
}
function onCreateMenuClick({ key }: { key: string }) {
if (key === 'group') {
showNewGroupInput.value = true;
newGroupName.value = '';
}
}
function onGroupMenuClick(key: string, groupId: string, groupName: string) {
if (key === 'rename') {
renamingGroupId.value = groupId;
@@ -1856,75 +1910,6 @@
flex-wrap: wrap;
}
.feishu-btn-group {
display: inline-flex;
align-items: center;
.feishu-btn-main {
border-radius: 6px 0 0 6px;
border-right: none;
padding: 0 7px;
height: 24px;
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 4px;
background: #fff;
border: 1px solid #d9d9d9;
color: rgba(0, 0, 0, 0.88);
cursor: pointer;
transition: all 0.2s;
line-height: 1;
&:hover {
color: #4096ff;
border-color: #4096ff;
}
&:active {
color: #0958d9;
border-color: #0958d9;
}
:deep(.app-iconify) {
font-size: 14px !important;
}
}
.feishu-btn-arrow {
border-radius: 0 6px 6px 0;
padding: 0 4px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #fff;
border: 1px solid #d9d9d9;
color: rgba(0, 0, 0, 0.88);
cursor: pointer;
transition: all 0.2s;
line-height: 1;
&:hover {
color: #4096ff;
border-color: #4096ff;
}
&:active {
color: #0958d9;
border-color: #0958d9;
}
:deep(.app-iconify) {
font-size: 10px !important;
}
}
.feishu-arrow-icon {
font-size: 10px;
}
}
.filter-badge {
display: inline-block;
margin-left: 4px;
@@ -2076,7 +2061,7 @@
}
.content-table-header {
padding: 0 24px;
padding: 0 8px;
height: 40px;
background: #fafafa;
border-bottom: 1px solid #e5e6eb;
@@ -2123,11 +2108,8 @@
.drag-handle {
cursor: grab;
color: #c0c4cc;
opacity: 0;
transition:
opacity 0.15s,
color 0.15s;
color: transparent;
transition: color 0.15s;
font-size: 14px;
display: flex;
align-items: center;
@@ -2139,7 +2121,7 @@
}
.task-row:hover .drag-handle {
opacity: 1;
color: #c0c4cc;
}
.drag-handle:hover {
@@ -2162,7 +2144,6 @@
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
cursor: pointer;
}
@@ -2174,46 +2155,64 @@
.task-group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 24px;
padding: 6px 8px;
cursor: pointer;
border-bottom: 1px solid #fafafa;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
font-weight: 500;
font-weight: 600;
color: #1f2329;
&:hover {
background: #fafafa;
}
.group-arrow {
font-size: 12px;
color: #999;
width: 16px;
text-align: center;
background: #f5f6f7;
}
.group-drag-handle {
cursor: grab;
font-size: 12px;
color: #c0c4cc;
font-size: 14px;
color: transparent;
transition: color 0.15s;
display: flex;
align-items: center;
&:hover {
color: #8c8c8c;
}
justify-content: center;
width: 24px;
min-width: 24px;
flex-shrink: 0;
&:active {
cursor: grabbing;
}
}
&:hover .group-drag-handle {
color: #c0c4cc;
}
.group-drag-handle:hover {
color: #8c8c8c;
}
.table-col-title {
font-weight: 600;
color: #1f2329;
.expand-arrow {
color: #8f959e;
}
}
.group-name {
color: #333;
color: #1f2329;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 1;
min-width: 0;
}
.group-count {
color: #999;
color: #8f959e;
font-weight: 400;
font-size: 13px;
flex-shrink: 0;
margin-left: 4px;
}
.group-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
gap: 2px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
@@ -2228,7 +2227,7 @@
justify-content: center;
border-radius: 4px;
cursor: pointer;
color: #8c8c8c;
color: #8f959e;
font-size: 14px;
transition: all 0.15s;
&:hover {
@@ -2242,7 +2241,7 @@
display: flex;
align-items: stretch;
height: 46px;
padding: 0 24px;
padding: 0 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
box-sizing: border-box;
@@ -2406,6 +2405,13 @@
min-width: 80px;
}
}
.inline-text-editor {
width: 100%;
:deep(.ant-input) {
font-size: 13px;
}
}
}
.dropdown-cell {
@@ -2427,6 +2433,12 @@
}
}
.readonly-cell {
&:hover {
border-color: transparent;
}
}
.date-picker-footer {
display: flex;
justify-content: flex-end;
@@ -2584,7 +2596,7 @@
.new-group-row {
display: flex;
align-items: center;
padding: 12px 24px;
padding: 12px 8px;
font-size: 14px;
&.new-group-btn {
cursor: pointer;
@@ -2595,40 +2607,6 @@
}
}
.content-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid #f0f0f0;
padding: 0 24px;
}
.content-tab {
padding: 8px 16px;
font-size: 14px;
color: #8c8c8c;
cursor: pointer;
position: relative;
transition: color 0.2s;
&:hover {
color: #333;
}
&.active {
color: #1677ff;
font-weight: 500;
&::after {
content: '';
position: absolute;
bottom: -1px;
left: 50%;
transform: translateX(-50%);
width: 24px;
height: 2px;
background: #1677ff;
border-radius: 1px;
}
}
}
.list-group-view {
flex: 1;
overflow: auto;
@@ -11,7 +11,7 @@
>
<template v-if="taskData">
<div class="detail-toolbar">
<a-button size="small" :type="taskData.completed ? 'primary' : 'default'" @click="toggleComplete">
<a-button size="small" :type="taskData.completed ? 'primary' : 'default'" :disabled="isFullReadOnly" @click="toggleComplete">
<Icon icon="ant-design:check-outlined" class="mr-1" />
{{ taskData.completed ? '已完成' : '完成任务' }}
</a-button>
@@ -22,7 +22,7 @@
<Icon :icon="isFollowed ? 'ant-design:eye-filled' : 'ant-design:eye-outlined'" />
</a-button>
</a-tooltip>
<a-tooltip title="删除">
<a-tooltip v-if="!isReadOnly" title="删除">
<a-button type="text" size="small" @click="handleDelete">
<Icon icon="ant-design:delete-outlined" />
</a-button>
@@ -63,7 +63,7 @@
@blur="onTitleBlur"
@press-enter="editingTitle = false"
/>
<div v-else class="detail-title-text" @click="editingTitle = true">
<div v-else class="detail-title-text" :class="{ 'readonly-text': isFullReadOnly }" @click="!isFullReadOnly && (editingTitle = true)">
{{ taskData.taskName }}
</div>
</div>
@@ -76,12 +76,13 @@
<div class="detail-row-content">
<div class="detail-field-row">
<span class="detail-field-label">负责人</span>
<div v-if="taskData.assigneeName" class="assignee-info" @click="openDrawerUserSelect('assignee')">
<div v-if="taskData.assigneeName" class="assignee-info" :class="{ 'readonly-field': isReadOnly }" @click="!isReadOnly && openDrawerUserSelect('assignee')">
<a-tooltip :title="taskData.assigneeName" placement="top">
<AvatarDisplay :names="taskData.assigneeName" :size="28" :show-name="true" />
</a-tooltip>
</div>
<a-button v-else type="text" class="add-field-btn" @click="openDrawerUserSelect('assignee')">添加</a-button>
<a-button v-else-if="!isReadOnly" type="text" class="add-field-btn" @click="openDrawerUserSelect('assignee')">添加</a-button>
<span v-else class="detail-field-value">-</span>
</div>
</div>
</div>
@@ -93,12 +94,13 @@
<div class="detail-row-content">
<div class="detail-field-row">
<span class="detail-field-label">参与人</span>
<div v-if="taskData.participantName" class="assignee-info" @click="openDrawerUserSelect('participant')">
<div v-if="taskData.participantName" class="assignee-info" :class="{ 'readonly-field': isFullReadOnly }" @click="!isFullReadOnly && openDrawerUserSelect('participant')">
<a-tooltip :title="taskData.participantName" placement="top">
<AvatarDisplay :names="taskData.participantName" :size="28" :show-name="true" />
</a-tooltip>
</div>
<a-button v-else type="text" class="add-field-btn" @click="openDrawerUserSelect('participant')">添加</a-button>
<a-button v-else-if="!isFullReadOnly" type="text" class="add-field-btn" @click="openDrawerUserSelect('participant')">添加</a-button>
<span v-else class="detail-field-value">-</span>
</div>
</div>
</div>
@@ -111,7 +113,7 @@
<div class="detail-field-row">
<span class="detail-field-label">开始时间</span>
<a-date-picker
v-if="drawerDateEditMode === 'start'"
v-if="drawerDateEditMode === 'start' && !isFullReadOnly"
:value="startTimeDayjs ? dayjs(startTimeDayjs) : null"
:open="drawerDatePickerVisible"
placeholder="选择开始时间"
@@ -127,7 +129,7 @@
</div>
</template>
</a-date-picker>
<span v-else class="detail-field-value detail-field-clickable" @click="openDrawerDatePicker('start')">{{
<span v-else class="detail-field-value detail-field-clickable" :class="{ 'readonly-field': isFullReadOnly }" @click="!isFullReadOnly && openDrawerDatePicker('start')">{{
startTimeDisplay || '未设置'
}}</span>
</div>
@@ -142,7 +144,7 @@
<div class="detail-field-row">
<span class="detail-field-label">截止时间</span>
<a-date-picker
v-if="drawerDateEditMode === 'end'"
v-if="drawerDateEditMode === 'end' && !isFullReadOnly"
:value="endTimeDayjs ? dayjs(endTimeDayjs) : null"
:open="drawerDatePickerVisible"
placeholder="选择截止时间"
@@ -162,8 +164,8 @@
<span
v-else
class="detail-field-value detail-field-clickable"
:class="{ 'overdue-text': isEndTimeOverdue }"
@click="openDrawerDatePicker('end')"
:class="{ 'overdue-text': isEndTimeOverdue, 'readonly-field': isFullReadOnly }"
@click="!isFullReadOnly && openDrawerDatePicker('end')"
>{{ endTimeDisplay || '未设置' }}</span
>
</div>
@@ -189,8 +191,8 @@
<Icon icon="tasklist-desc|svg" />
</span>
<div class="detail-row-content">
<a-textarea v-if="showDescEditor" v-model:value="taskData.taskDesc" :rows="3" placeholder="添加描述..." autofocus @blur="onDescBlur" />
<span v-else class="desc-trigger" @click="showDescEditor = true">
<a-textarea v-if="showDescEditor && !isFullReadOnly" v-model:value="taskData.taskDesc" :rows="3" placeholder="添加描述..." autofocus @blur="onDescBlur" />
<span v-else class="desc-trigger" :class="{ 'readonly-field': isFullReadOnly }" @click="!isFullReadOnly && (showDescEditor = true)">
{{ taskData.taskDesc || '添加描述' }}
</span>
</div>
@@ -202,14 +204,14 @@
</span>
<div class="detail-row-content">
<a-textarea
v-if="showRemarkEditor"
v-if="showRemarkEditor && !isFullReadOnly"
v-model:value="taskData.remark"
:rows="3"
placeholder="添加其他事项说明..."
autofocus
@blur="onRemarkBlur"
/>
<span v-else class="desc-trigger" @click="showRemarkEditor = true">
<span v-else class="desc-trigger" :class="{ 'readonly-field': isFullReadOnly }" @click="!isFullReadOnly && (showRemarkEditor = true)">
{{ taskData.remark || '添加其他事项说明' }}
</span>
</div>
@@ -242,7 +244,7 @@
<template v-if="subtaskExpanded">
<draggable
:model-value="subTasks"
:list="localSubTasks"
:item-key="(child: any) => child.id"
handle=".feishu-subtask-drag"
:animation="200"
@@ -252,14 +254,14 @@
>
<template #item="{ element: child }">
<div class="feishu-subtask-row" :data-task-id="child.id">
<span class="feishu-subtask-drag">
<span v-if="!isFullReadOnly" class="feishu-subtask-drag">
<Icon icon="tasklist-subtask-drag|svg" />
</span>
<button class="feishu-subtask-check" :class="{ checked: child.completed }" @click="emit('toggle-sub-task', child.id)">
<button class="feishu-subtask-check" :class="{ checked: child.completed }" :disabled="isFullReadOnly" @click="emit('toggle-sub-task', child.id)">
<Icon v-if="child.completed" icon="ant-design:check-outlined" />
</button>
<span class="feishu-subtask-name" :class="{ done: child.completed }">{{ child.taskName }}</span>
<div class="feishu-subtask-actions">
<div v-if="!isFullReadOnly" class="feishu-subtask-actions">
<a-tooltip title="设置日期">
<span class="feishu-subtask-action-btn" @click.stop="openSubTaskDateEditor(child.id)">
<Icon icon="ant-design:calendar-outlined" />
@@ -276,7 +278,10 @@
</span>
</a-tooltip>
</div>
<div v-if="child.assigneeName" class="feishu-subtask-avatar" @click.stop="openSubTaskUserSelect(child)">
<div v-if="child.assigneeName && !isFullReadOnly" class="feishu-subtask-avatar" @click.stop="openSubTaskUserSelect(child)">
<a-tooltip :title="child.assigneeName" placement="top"><AvatarDisplay :names="child.assigneeName" :size="20" /></a-tooltip>
</div>
<div v-else-if="child.assigneeName && isFullReadOnly" class="feishu-subtask-avatar">
<a-tooltip :title="child.assigneeName" placement="top"><AvatarDisplay :names="child.assigneeName" :size="20" /></a-tooltip>
</div>
</div>
@@ -293,7 +298,7 @@
/>
</div>
<div v-if="showNewSubTaskInput" class="feishu-subtask-row feishu-subtask-input-row">
<div v-if="showNewSubTaskInput && !isFullReadOnly" class="feishu-subtask-row feishu-subtask-input-row">
<span class="feishu-subtask-drag-placeholder"></span>
<span class="feishu-subtask-check feishu-subtask-check-empty"></span>
<a-input
@@ -306,7 +311,7 @@
@blur="showNewSubTaskInput = false"
/>
</div>
<div v-else v-if="canAddSubTask" class="feishu-subtask-add" @click="showNewSubTaskInput = true">
<div v-else-if="canAddSubTask && !isFullReadOnly" class="feishu-subtask-add" @click="showNewSubTaskInput = true">
<Icon icon="ant-design:plus-outlined" class="feishu-subtask-add-icon" />
<span>添加子任务</span>
</div>
@@ -322,7 +327,7 @@
<AvatarDisplay :names="taskData.followersName" :size="24" :show-name="false" />
</a-tooltip>
<span v-else class="follower-empty">将通知 0 人</span>
<a-button type="text" size="small" @click="openDrawerUserSelect('followers')">
<a-button v-if="!isFullReadOnly" type="text" size="small" @click="openDrawerUserSelect('followers')">
<Icon icon="ant-design:plus-outlined" />
</a-button>
</div>
@@ -348,7 +353,7 @@
</template>
<script lang="ts" setup>
import { ref, computed, nextTick } from 'vue';
import { ref, computed, watch, nextTick } from 'vue';
import { BasicDrawer, useDrawerInner } from '/@/components/Drawer';
import Icon from '/@/components/Icon/index';
import AvatarDisplay from './AvatarDisplay.vue';
@@ -386,6 +391,13 @@
const allTasks = ref<TaskItem[]>([]);
const taskData = ref<TaskItem | null>(null);
const isReadOnly = ref(false);
const isAssigneeEdit = computed(() => {
if (!isReadOnly.value || !taskData.value) return false;
const assigneeIds = (taskData.value.assigneeId || '').split(',').filter(Boolean);
return assigneeIds.includes(CURRENT_USER.value);
});
const isFullReadOnly = computed(() => isReadOnly.value && !isAssigneeEdit.value);
const showNewSubTaskInput = ref(false);
const newSubTaskName = ref('');
const startTimeDayjs = ref<string | null>(null);
@@ -451,6 +463,12 @@
return allTasks.value.filter((t) => t.pid === taskData.value!.id).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
});
const localSubTasks = ref<TaskItem[]>([]);
watch(subTasks, (val) => {
localSubTasks.value = [...val];
}, { immediate: true });
const subtaskProgress = computed(() => {
const total = subTasks.value.length;
if (total === 0) return { text: '', percent: 0 };
@@ -489,6 +507,7 @@
const [registerDrawer, { closeDrawer }] = useDrawerInner(async (data) => {
taskData.value = data?.task ? { ...data.task } : null;
allTasks.value = data?.allTasks ? [...data.allTasks] : [];
isReadOnly.value = data?.readOnly === true;
showNewSubTaskInput.value = false;
newSubTaskName.value = '';
editingTitle.value = false;
@@ -839,6 +858,12 @@
background: #f5f6f7;
border-radius: 4px;
}
&.readonly-text {
cursor: default;
&:hover {
background: transparent;
}
}
}
.detail-title-input {
@@ -1014,6 +1039,19 @@
&:hover {
color: #3370ff;
}
&.readonly-field {
cursor: default;
&:hover {
color: #8c8c8c;
}
}
}
.readonly-field {
cursor: default !important;
&:hover {
color: inherit !important;
}
}
.task-checkbox {
File diff suppressed because it is too large Load Diff
+94 -28
View File
@@ -47,22 +47,24 @@
<div class="sidebar-favorites-header">
<Icon icon="tasklist-task-list|svg" class="sidebar-favorites-icon" />
<span class="sidebar-favorites-name">收藏清单</span>
<Icon icon="ant-design:plus-outlined" class="sidebar-group-add" @click.stop="startNewList('')" />
<a-dropdown :trigger="['click']">
<Icon icon="ant-design:plus-outlined" class="sidebar-group-add" @click.stop />
<template #overlay>
<a-menu @click="onAddMenuClick">
<a-menu-item key="new-list">
<Icon icon="ant-design:file-add-outlined" class="mr-1" />
新建清单
</a-menu-item>
<a-menu-item key="new-group">
<Icon icon="ant-design:folder-add-outlined" class="mr-1" />
新建分组
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div class="sidebar-favorites-items">
<div v-if="showNewListInput && newListGroupId === ''" class="sidebar-new-item-row">
<Icon icon="ant-design:file-text-outlined" class="sidebar-item-icon" style="opacity: 0.4" />
<a-input
v-model:value="newListName"
size="small"
placeholder="输入清单名称..."
autofocus
style="flex: 1"
@press-enter="submitNewList"
@blur="showNewListInput = false"
/>
</div>
<draggable :model-value="ungroupedTaskLists" group="tasklists" item-key="id" handle=".list-drag-handle" :animation="200" @end="onDragEnd">
<draggable :list="localUngroupedTaskLists" group="tasklists" item-key="id" handle=".list-drag-handle" :animation="200" @end="onDragEnd">
<template #item="{ element: taskList }">
<div
v-if="renamingId !== taskList.id"
@@ -97,13 +99,25 @@
<div v-else class="sidebar-item sidebar-item-renaming">
<span class="list-drag-handle-placeholder"></span>
<Icon icon="ant-design:file-text-outlined" class="sidebar-item-icon" style="opacity: 0.4" />
<a-input v-model:value="renameValue" size="small" style="flex: 1" @press-enter="confirmInlineRename" @blur="confirmInlineRename" />
<a-input ref="renameInputRef" v-model:value="renameValue" size="small" style="flex: 1" @press-enter="confirmInlineRename" @blur="confirmInlineRename" />
</div>
</template>
</draggable>
<div v-if="showNewListInput && newListGroupId === ''" class="sidebar-new-item-row">
<Icon icon="ant-design:file-text-outlined" class="sidebar-item-icon" style="opacity: 0.4" />
<a-input
v-model:value="newListName"
size="small"
placeholder="输入清单名称..."
autofocus
style="flex: 1"
@press-enter="submitNewList"
@blur="showNewListInput = false"
/>
</div>
</div>
<draggable :model-value="userGroups" group="tasklistgroups" item-key="id" :animation="200" drag-class="group-drag-ghost" @start="onGroupDragStart" @end="onGroupDragEnd">
<draggable :list="localUserGroups" group="tasklistgroups" item-key="id" :animation="200" drag-class="group-drag-ghost" @start="onGroupDragStart" @end="onGroupDragEnd">
<template #item="{ element: group }">
<div :data-group-id="group.id" :class="{ 'group-being-dragged': draggingGroupId === group.id }">
<div
@@ -134,7 +148,7 @@
</div>
<div v-if="group.delFlag !== 1 && renamingId === group.id" class="sidebar-group-header sidebar-group-renaming" @click.stop>
<Icon icon="tasklist-arrow-down|svg" class="group-collapse-arrow" :class="{ collapsed: collapsedGroups[group.id] || draggingGroupId === group.id }" />
<a-input v-model:value="renameValue" size="small" style="flex: 1" @press-enter="confirmInlineRename" @blur="confirmInlineRename" />
<a-input ref="renameInputRef" v-model:value="renameValue" size="small" style="flex: 1" @press-enter="confirmInlineRename" @blur="confirmInlineRename" />
</div>
<template v-if="group.delFlag !== 1 && !collapsedGroups[group.id]">
<div class="sidebar-group-items" :data-group-id="group.id">
@@ -151,7 +165,7 @@
/>
</div>
<draggable
:model-value="getGroupTaskLists(group)"
:list="getLocalGroupTaskLists(group)"
group="tasklists"
item-key="id"
handle=".list-drag-handle"
@@ -192,12 +206,12 @@
<div v-else class="sidebar-item sidebar-item-renaming">
<span class="list-drag-handle-placeholder"></span>
<Icon icon="ant-design:file-text-outlined" class="sidebar-item-icon" style="opacity: 0.4" />
<a-input v-model:value="renameValue" size="small" style="flex: 1" @press-enter="confirmInlineRename" @blur="confirmInlineRename" />
<a-input ref="renameInputRef" v-model:value="renameValue" size="small" style="flex: 1" @press-enter="confirmInlineRename" @blur="confirmInlineRename" />
</div>
</template>
</draggable>
<div
v-if="group.taskLists.filter((l) => l.delFlag !== 1).length === 0 && !(showNewListInput && newListGroupId === group.id)"
v-if="getLocalGroupTaskLists(group).length === 0 && !(showNewListInput && newListGroupId === group.id)"
class="sidebar-empty-hint"
>
可拖拽清单加入该分组
@@ -229,7 +243,7 @@
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { ref, computed, watch, nextTick } from 'vue';
import Icon from '/@/components/Icon/index';
import draggable from 'vuedraggable';
import { useMessage } from '/@/hooks/web/useMessage';
@@ -253,6 +267,7 @@
'create-group': [name: string];
'rename-list': [listId: string, newName: string];
'delete-list': [listId: string];
'remove-favorite': [listId: string];
'rename-group': [groupId: string, newName: string];
'delete-group': [groupId: string];
'quick-access-tab-click': [tabKey: string];
@@ -277,14 +292,46 @@
return [];
});
function getGroupTaskLists(group: TaskListGroup) {
return [...group.taskLists].filter((l) => l.delFlag !== 1).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
const userGroups = computed(() => {
return props.taskListGroups.filter((g) => g.id !== '__ungrouped__' && g.delFlag !== 1).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
});
// 本地可变副本,供 draggable :list 模式直接修改,实现乐观更新
const localUngroupedTaskLists = ref<any[]>([]);
const localUserGroups = ref<any[]>([]);
const localGroupTaskListsMap = ref<Record<string, any[]>>({});
watch(ungroupedTaskLists, (val) => {
localUngroupedTaskLists.value = [...val];
}, { immediate: true });
watch(userGroups, (val) => {
localUserGroups.value = val.map((g) => ({
...g,
taskLists: [...(g.taskLists || [])],
}));
// 同步分组内清单到 map
const newMap: Record<string, any[]> = {};
for (const g of val) {
newMap[g.id] = [...(g.taskLists || [])].filter((l) => l.delFlag !== 1).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
localGroupTaskListsMap.value = newMap;
}, { immediate: true });
function getGroupTaskLists(group: any) {
return [...group.taskLists].filter((l) => l.delFlag !== 1).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
function getLocalGroupTaskLists(group: any) {
if (localGroupTaskListsMap.value[group.id]) {
return localGroupTaskListsMap.value[group.id];
}
// 兜底:如果没有本地缓存,从原始数据计算
const list = getGroupTaskLists(group);
localGroupTaskListsMap.value[group.id] = list;
return list;
}
const hoveredListId = ref('');
const collapsedGroups = ref<Record<string, boolean>>({});
const showNewGroupInput = ref(false);
@@ -295,6 +342,14 @@
const renamingId = ref('');
const renamingType = ref<'list' | 'group'>('list');
const renameValue = ref('');
const renameInputRef = ref<HTMLInputElement | null>(null);
watch(renamingId, async (val) => {
if (val) {
await nextTick();
renameInputRef.value?.focus();
}
});
const draggingGroupId = ref('');
const dragStartX = ref(0);
@@ -363,6 +418,8 @@
const itemEl = evt.item;
const groupId = itemEl?.dataset?.groupId || itemEl?.closest('[data-group-id]')?.dataset?.groupId;
if (!groupId) return;
// 拖拽结束后保持分组折叠状态,防止展开跳动
collapsedGroups.value[groupId] = true;
const newIndex = evt.newIndex ?? 0;
emit('move-group', groupId, newIndex + 1);
}
@@ -372,6 +429,15 @@
return perm === '1' || perm === '2';
}
function onAddMenuClick({ key }: { key: string }) {
if (key === 'new-list') {
startNewList('');
} else if (key === 'new-group') {
showNewGroupInput.value = true;
newGroupName.value = '';
}
}
function startNewList(groupId: string) {
showNewListInput.value = true;
newListGroupId.value = groupId;
@@ -397,12 +463,12 @@
} else if (key === 'remove') {
createConfirm({
iconType: 'warning',
title: '确认除',
content: '删除任务清单后,其下所有任务也将被删除,确定要删除吗?',
title: '确认除',
content: '确定从收藏清单中移除吗?',
okText: '确认',
cancelText: '取消',
onOk: () => {
emit('delete-list', listId);
emit('remove-favorite', listId);
},
});
}