czh-20260630-优化计划,增加分页、滚动加载等

This commit is contained in:
zhihao
2026-06-30 16:15:06 +08:00
parent e20fa25000
commit fd3bfea809
8 changed files with 599 additions and 123 deletions
+41
View File
@@ -0,0 +1,41 @@
import { ref } from 'vue';
import { message } from 'ant-design-vue';
interface OptimisticOptions {
/** 调用前乐观修改本地数据 */
onOptimistic: () => void;
/** 失败时回滚本地数据 */
onRollback: () => void;
/** 失败提示消息 */
errorMsg: string;
/** 成功回调 */
onSuccess?: () => void;
}
export function useOptimisticMutation() {
const loading = ref(false);
async function execute<TResult>(
apiCall: () => Promise<TResult>,
options: OptimisticOptions
): Promise<TResult | null> {
const { onOptimistic, onRollback, errorMsg, onSuccess } = options;
onOptimistic();
try {
const result = await apiCall();
onSuccess?.();
return result;
} catch (e: any) {
console.error('[OptimisticMutation] 操作失败', e);
message.error(errorMsg);
onRollback();
throw e;
} finally {
loading.value = false;
}
}
return { execute, loading };
}
+67
View File
@@ -0,0 +1,67 @@
import { ref, computed, type Ref } from 'vue';
export interface VirtualScrollOptions {
items: Ref<any[]>;
itemHeight: number;
buffer: number;
containerRef: Ref<HTMLElement | null>;
}
export function useVirtualScroll(options: VirtualScrollOptions) {
const { items, itemHeight, buffer, containerRef } = options;
const scrollTop = ref(0);
const containerHeight = ref(0);
const totalHeight = computed(() => items.value.length * itemHeight);
const startIndex = computed(() => {
const idx = Math.floor(scrollTop.value / itemHeight) - buffer;
return Math.max(0, idx);
});
const endIndex = computed(() => {
const visible = Math.ceil(containerHeight.value / itemHeight);
const idx = Math.floor(scrollTop.value / itemHeight) + visible + buffer;
return Math.min(items.value.length, idx);
});
const visibleItems = computed(() => {
return items.value.slice(startIndex.value, endIndex.value).map((item, idx) => ({
item,
index: startIndex.value + idx,
style: {
position: 'absolute' as const,
top: `${(startIndex.value + idx) * itemHeight}px`,
width: '100%',
height: `${itemHeight}px`,
},
}));
});
const containerStyle = computed(() => ({
height: `${totalHeight.value}px`,
position: 'relative' as const,
overflow: 'hidden',
}));
function onScroll(event: Event) {
const target = event.target as HTMLElement;
scrollTop.value = target.scrollTop;
}
function updateContainer() {
if (containerRef.value) {
containerHeight.value = containerRef.value.clientHeight;
}
}
return {
visibleItems,
containerStyle,
onScroll,
updateContainer,
totalHeight,
scrollTop,
};
}
+8
View File
@@ -16,6 +16,7 @@
:class="[prefixCls, `${prefixCls}--${getHeaderTheme}`, 'headerIntroductionClass']"
>
<img src="../../../assets/images/header-brand.png" class="header-logo" />
<span class="header-secrecy-level">机密级</span>
{{ t('layout.header.welcomeIn') }} {{ title }}
</span>
</div>
@@ -256,6 +257,13 @@
gap: 10px;
}
.header-secrecy-level {
color: #ffffff;
font-weight: bold;
font-size: 20px;
letter-spacing: 2px;
}
.header-logo {
height: 32px;
width: auto;
+12 -6
View File
@@ -106,11 +106,14 @@ export const renameGroup = (params) => defHttp.post({ url: Api.renameGroup, para
export const renameTaskList = (params) => defHttp.post({ url: Api.renameTaskList, params });
export const getMyOwnLists = () => defHttp.get({ url: Api.myOwnLists });
export const getMyOwnLists = (params?: { pageNo?: number; pageSize?: number; keyword?: string }) =>
defHttp.get({ url: Api.myOwnLists, params });
export const getAllLists = () => defHttp.get({ url: Api.allLists });
export const getAllLists = (params?: { pageNo?: number; pageSize?: number; keyword?: string }) =>
defHttp.get({ url: Api.allLists, params });
export const getMyCollabLists = () => defHttp.get({ url: Api.myCollabLists });
export const getMyCollabLists = (params?: { pageNo?: number; pageSize?: number; keyword?: string }) =>
defHttp.get({ url: Api.myCollabLists, params });
export const addTask = (params) => defHttp.post({ url: Api.addTask, params });
@@ -146,10 +149,13 @@ export const loadSubTasks = (params) => defHttp.get({ url: Api.loadSubTasks, par
export const listByMainId = (params) => defHttp.get({ url: Api.listByMainId, params });
export const listAllByMainId = (params) => defHttp.get({ url: Api.listAllByMainId, params });
export const listAllByMainId = (params: { mainId: string }) =>
defHttp.get({ url: Api.listAllByMainId, params });
export const myResponsibleTasks = () => defHttp.get({ url: Api.myResponsibleTasks });
export const myResponsibleTasks = (params?: { pageNo?: number; pageSize?: number }) =>
defHttp.get({ url: Api.myResponsibleTasks, params });
export const myFollowedTasks = () => defHttp.get({ url: Api.myFollowedTasks });
export const myFollowedTasks = (params?: { pageNo?: number; pageSize?: number }) =>
defHttp.get({ url: Api.myFollowedTasks, params });
export const getCurrentUserSecurityLevel = () => defHttp.get({ url: Api.getCurrentUserSecurityLevel });
+167 -40
View File
@@ -16,6 +16,7 @@
placeholder="搜索计划名称"
class="list-search-input"
allow-clear
@search="onSearch"
/>
</div>
<a-tooltip title="使用帮助">
@@ -265,7 +266,7 @@
<div class="content-body">
<template v-if="showListGroupView">
<div class="list-group-view">
<div ref="listGroupViewRef" class="list-group-view" @scroll="onListGroupScroll">
<div class="list-group-table-header">
<div class="list-group-col list-group-col-name">名称</div>
<div class="list-group-col list-group-col-owner">所有者</div>
@@ -316,6 +317,13 @@
<Icon icon="ant-design:inbox-outlined" class="empty-icon" />
<p>暂无可见计划</p>
</div>
<div v-if="props.hasMore && allTaskLists.length > 0" class="load-more-trigger">
<template v-if="props.loadingMore">
<a-spin size="small" />
<span class="load-more-text">加载中...</span>
</template>
<a-button v-else type="link" size="small" @click="emit('load-more')">加载更多</a-button>
</div>
</div>
</template>
@@ -435,14 +443,14 @@
<draggable
:list="flatListCache[group.id]"
:item-key="(ft: any) => ft.task.id"
group="tasks"
:group="dragGroupConfig"
handle=".drag-handle"
:animation="200"
ghost-class="sortable-ghost"
chosen-class="sortable-chosen"
@start="onTaskDragStart"
@end="(evt: any) => onTaskDragEnd(evt)"
@move="onTaskMove"
:move="onTaskMove"
>
<template #item="{ element: ft }">
<div class="task-row" :data-task-id="ft.task.id" :style="{ minWidth: totalFieldsWidth + 'px' }" v-show="!isChildHidden(ft)">
@@ -863,6 +871,8 @@
FilterOperator,
} from '../types';
const { createMessage } = useMessage();
const props = defineProps<{
currentList: TaskList | null;
taskListGroups: TaskListGroup[];
@@ -874,6 +884,8 @@
showListGroupView?: boolean;
favoriteIdMap?: Map<string, string>;
loading?: boolean;
hasMore?: boolean;
loadingMore?: boolean;
}>();
const emit = defineEmits<{
@@ -901,6 +913,8 @@
'rename-group': [groupId: string, newName: string];
'delete-group': [groupId: string];
'create-list': [];
'load-more': [];
'search-keyword': [keyword: string];
}>();
const userStore = useUserStore();
@@ -919,6 +933,15 @@
const renamingGroupId = ref<string | null>(null);
const renamingGroupName = ref('');
const renamingGroupInputRef = ref<HTMLInputElement | null>(null);
const listGroupViewRef = ref<HTMLElement | null>(null);
function onListGroupScroll(event: Event) {
const target = event.target as HTMLElement;
const nearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 200;
if (nearBottom && props.hasMore) {
emit('load-more');
}
}
watch(renamingGroupId, async (val) => {
if (val) {
@@ -935,6 +958,29 @@
const expandedTaskIds = ref<Set<string>>(new Set());
const draggingTaskId = ref('');
const dragBlockedOnce = ref(false);
const dragGroupConfig = computed(() => ({
name: 'tasks',
put(to: HTMLElement, _from: HTMLElement, dragEl: HTMLElement) {
const taskId = dragEl.dataset?.taskId;
if (!taskId) return true;
const task = findTaskById(taskId);
if (!task) return true;
const groupIds = new Set(computedGroups.value.map((g) => g.id));
const isSubtask = !!(task.pid && !groupIds.has(task.pid));
if (!isSubtask) return true;
const toContainer = to.closest('[data-group-id]') as HTMLElement;
const targetGroupId = toContainer?.dataset?.groupId;
if (!targetGroupId) return false;
const parentGroupId = findGroupContainingTask(task.pid);
return targetGroupId === parentGroupId;
},
}));
const newTaskInputRef = ref<HTMLInputElement | null>(null);
const newGroupInputRef = ref<HTMLInputElement | null>(null);
@@ -1148,19 +1194,29 @@
const allTaskLists = computed(() => {
const lists: TaskList[] = [];
const keyword = searchKeyword.value.trim().toLowerCase();
for (const group of props.taskListGroups) {
for (const list of group.taskLists) {
if (list.delFlag !== 1) {
if (!keyword || list.name.toLowerCase().includes(keyword)) {
lists.push(list);
}
}
}
}
return lists;
});
function onSearch(keyword: string) {
if (props.showListGroupView) {
emit('search-keyword', keyword || '');
}
}
// 用户点击清除按钮时触发搜索重置
watch(searchKeyword, (val) => {
if (props.showListGroupView && !val) {
emit('search-keyword', '');
}
});
const collaboratorDisplayNames = computed(() => {
if (!props.currentList) return '';
const names: string[] = [];
@@ -1378,6 +1434,13 @@
return undefined;
}
function findGroupContainingTask(taskId: string): string | undefined {
for (const group of props.currentList.groups) {
if (group.tasks.some((t) => t.id === taskId)) return group.id;
}
return undefined;
}
function openUserSelectModal(type: 'assignee' | 'followers' | 'participant', taskId: string) {
userSelectModalType.value = type;
userSelectModalTaskId.value = taskId;
@@ -1546,6 +1609,23 @@
const isSubtask = !!(draggedTask.pid && !groupIds.has(draggedTask.pid));
// 子任务不允许跨父级移动:检测是否被拖到了原父级所在分组之外
if (isSubtask) {
const parentGroupId = findGroupContainingTask(draggedTask.pid);
if (parentGroupId && parentGroupId !== targetGroupId) {
// 回退:从目标分组移除,放回原分组末尾
const targetFlatList = flatListCache.value[targetGroupId] || [];
const movedIdx = targetFlatList.findIndex((f) => f.task.id === taskId);
if (movedIdx >= 0) {
const [moved] = targetFlatList.splice(movedIdx, 1);
const originalFlatList = flatListCache.value[parentGroupId] || [];
originalFlatList.push(moved);
}
createMessage.warning('子事项不能移动到其他分组');
return;
}
}
let targetPid: string;
let targetSortOrder = 0;
@@ -1573,47 +1653,47 @@
}
function onTaskMove(evt: any) {
const relatedEl = evt.related as HTMLElement;
const draggedTaskId = draggingTaskId.value;
if (!draggedTaskId) {
return true;
}
if (!draggedTaskId) return true;
const draggedTask = findTaskById(draggedTaskId);
if (!draggedTask) {
return true;
}
if (!draggedTask) return true;
const groupIds = new Set(computedGroups.value.map((g) => g.id));
const isSubtask = !!(draggedTask.pid && !groupIds.has(draggedTask.pid));
if (!isSubtask) return true;
if (!isSubtask) {
return true;
}
// 子事项必须在原父级所在分组内移动
const toContainer = evt.to.closest('[data-group-id]') as HTMLElement;
const targetGroupId = toContainer?.dataset?.groupId;
if (!targetGroupId) return false;
if (!relatedEl) {
dragBlockedOnce.value = true;
return false;
const parentGroupId = findGroupContainingTask(draggedTask.pid);
if (targetGroupId !== parentGroupId) return false;
// 在 flatList 中定位有效插入范围(仅允许在同父级子节点区间内)
const flatList = flatListCache.value[targetGroupId] || [];
let minIdx = -1;
let maxIdx = -1;
for (let i = 0; i < flatList.length; i++) {
if (flatList[i].task.pid === draggedTask.pid) {
if (minIdx === -1) minIdx = i;
maxIdx = i;
}
}
if (minIdx === -1) return false;
const relatedEl = evt.related as HTMLElement;
if (!relatedEl) return false;
const relatedTaskId = relatedEl.dataset?.taskId;
if (!relatedTaskId) {
dragBlockedOnce.value = true;
return false;
}
if (!relatedTaskId) return false;
const relatedTask = findTaskById(relatedTaskId);
if (!relatedTask) {
dragBlockedOnce.value = true;
return false;
}
const relatedIdx = flatList.findIndex((f) => f.task.id === relatedTaskId);
if (relatedIdx === -1) return false;
if (relatedTask.pid !== draggedTask.pid) {
dragBlockedOnce.value = true;
return false;
}
return true;
const insertIdx = evt.willInsertAfter ? relatedIdx + 1 : relatedIdx;
return insertIdx >= minIdx && insertIdx <= maxIdx + 1;
}
function isChildHidden(ft: FlatTaskItem): boolean {
@@ -1776,14 +1856,38 @@
}
function computeDynamicGroups(tasks: TaskItem[]): TaskGroup[] {
const groupMap = new Map<string, TaskItem[]>();
for (const task of tasks) {
const key = getGroupKey(task, props.groupDimension);
if (!groupMap.has(key)) groupMap.set(key, []);
groupMap.get(key)!.push(task);
const taskMap = new Map<string, TaskItem>();
for (const t of tasks) {
taskMap.set(t.id, t);
}
// 找出所有一级节点(子节点跟随父节点分组)
const groupIds = new Set((props.currentList?.groups || []).map((g) => g.id));
const topTasks = tasks.filter((t) => !t.pid || groupIds.has(t.pid));
// 收集每个一级节点及其所有子孙
const topWithDescendants = new Map<string, TaskItem[]>();
for (const top of topTasks) {
const key = getGroupKey(top, props.groupDimension);
if (!topWithDescendants.has(key)) topWithDescendants.set(key, []);
topWithDescendants.get(key)!.push(top);
collectDescendants(top.id, tasks, topWithDescendants.get(key)!);
}
// 处理没有父节点归属的游离节点(直接按自身分组)
const groupedIds = new Set<string>();
for (const groupTasks of topWithDescendants.values()) {
for (const t of groupTasks) groupedIds.add(t.id);
}
const orphans = tasks.filter((t) => !groupedIds.has(t.id));
for (const orphan of orphans) {
const key = getGroupKey(orphan, props.groupDimension);
if (!topWithDescendants.has(key)) topWithDescendants.set(key, []);
topWithDescendants.get(key)!.push(orphan);
}
const groups: TaskGroup[] = [];
groupMap.forEach((groupTasks, key) => {
topWithDescendants.forEach((groupTasks, key) => {
groups.push({
id: `dynamic-${key}`,
name: key || '未分组',
@@ -1795,6 +1899,14 @@
return groups;
}
function collectDescendants(parentId: string, allTasks: TaskItem[], result: TaskItem[]) {
const children = allTasks.filter((t) => t.pid === parentId);
for (const child of children) {
result.push(child);
collectDescendants(child.id, allTasks, result);
}
}
function getGroupKey(task: TaskItem, dimension: GroupDimension): string {
switch (dimension) {
case 'assignee':
@@ -2408,6 +2520,7 @@
display: flex;
align-items: stretch;
height: 46px;
user-select: none;
padding: 0 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
@@ -2941,4 +3054,18 @@
font-size: 14px;
}
}
.load-more-trigger {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
padding: 12px 0;
border-top: 1px solid #f0f0f0;
}
.load-more-text {
color: #8f959e;
font-size: 13px;
}
</style>
+74 -5
View File
@@ -3,7 +3,7 @@
<a-spin :spinning="props.loading" tip="加载中...">
<div class="flat-view-header">
<h2 class="flat-view-title">{{ title }}</h2>
<span class="task-count">({{ flattenedTasks.length }})</span>
<span class="task-count">({{ props.totalCount ?? flattenedTasks.length }})</span>
</div>
<div class="flat-view-toolbar">
@@ -75,7 +75,7 @@
</a-popover>
</div>
<div class="flat-view-body">
<div ref="flatBodyRef" class="flat-view-body" @scroll="onFlatScroll">
<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">
@@ -103,7 +103,13 @@
</template>
</div>
<div v-for="ft in flattenedTasks" :key="ft.task.id" class="task-row" :style="{ minWidth: totalFieldsWidth + 'px' }">
<div :style="virtualContainerStyle" class="virtual-container">
<div
v-for="{ item: ft, index, style } in visibleTasks"
:key="ft.task.id"
class="task-row"
:style="{ ...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' }">
@@ -258,7 +264,7 @@
'overdue-date': field.key === 'endTime' && isOverdue(ft.task),
}"
>
{{ field.key === 'startTime' ? ft.task.startTime : ft.task.endTime }}
{{ (field.key === 'startTime' ? ft.task.startTime : ft.task.endTime).substring(0, 10) }}
</span>
</template>
<template v-else>
@@ -330,10 +336,18 @@
</template>
</div>
</div>
<div v-if="flattenedTasks.length === 0" class="empty-state">
<Icon icon="ant-design:inbox-outlined" class="empty-icon" />
<p>暂无事项</p>
</div>
<div v-if="props.hasMore && flattenedTasks.length > 0" class="load-more-trigger">
<template v-if="props.loadingMore">
<a-spin size="small" />
<span class="load-more-text">加载中</span>
</template>
<a-button v-else type="link" size="small" @click="emit('load-more')">加载更多</a-button>
</div>
</div>
<SzDeptUserModal
@@ -351,13 +365,14 @@
</template>
<script lang="ts" setup>
import { ref, computed, nextTick } from 'vue';
import { ref, computed, nextTick, watch, onMounted } from 'vue';
import dayjs from 'dayjs';
import Icon from '/@/components/Icon/index';
import AvatarDisplay from './AvatarDisplay.vue';
import SzDeptUserModal from '/@/components/semri/deptUserComponent/SzDeptUserModal.vue';
import { FIELD_CONFIG, SORT_OPTIONS, MAX_TASK_DEPTH } from '../types';
import { PRIORITY_OPTIONS, getPriorityBg, getPriorityLabel, isOverdue } from '../utils/taskUtils';
import { useVirtualScroll } from '/@/hooks/useVirtualScroll';
import type { TaskItem, StatusFilter, SortField, SortDirection, FieldKey, FlatTaskItem } from '../types';
const props = defineProps<{
@@ -370,6 +385,9 @@
editable?: boolean;
taskEditMap?: Map<string, boolean>;
loading?: boolean;
hasMore?: boolean;
loadingMore?: boolean;
totalCount?: number;
}>();
const emit = defineEmits<{
@@ -382,8 +400,12 @@
'update:sortDirection': [value: SortDirection];
'update:visibleFields': [value: FieldKey[]];
'load-subtasks': [taskId: string, mainId: string];
'load-more': [];
}>();
const ITEM_HEIGHT = 46;
const flatBodyRef = ref<HTMLElement | null>(null);
const fieldConfigVisible = ref(false);
const expandedTaskIds = ref<Set<string>>(new Set());
const loadedChildrenIds = ref<Set<string>>(new Set());
@@ -495,6 +517,23 @@
return result;
});
const { visibleItems: allVisibleTasks, containerStyle: virtualContainerStyle, updateContainer, scrollTop } = useVirtualScroll({
items: flattenedTasks,
itemHeight: ITEM_HEIGHT,
buffer: 5,
containerRef: flatBodyRef,
});
const visibleTasks = computed(() => allVisibleTasks.value as { item: FlatTaskItem; index: number; style: Record<string, string> }[]);
watch(() => [flattenedTasks.value.length, flattenedTasks.value], () => {
nextTick(() => updateContainer());
}, { deep: true });
onMounted(() => {
nextTick(() => updateContainer());
});
function collectFlatTasks(task: TaskItem, level: number, result: FlatTaskItem[], visited: Set<string>) {
if (visited.has(task.id)) return;
if (level >= MAX_TASK_DEPTH) return;
@@ -540,6 +579,17 @@
return document.body;
}
function onFlatScroll(event: Event) {
// 先更新虚拟滚动位置
scrollTop.value = (event.target as HTMLElement).scrollTop;
// 触底检测
const target = event.target as HTMLElement;
const nearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 200;
if (nearBottom && props.hasMore) {
emit('load-more');
}
}
function onStatusFilterChange({ key }: { key: string }) {
emit('update:statusFilter', key as StatusFilter);
}
@@ -845,6 +895,7 @@
display: flex;
align-items: stretch;
height: 46px;
user-select: none;
padding: 0 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
@@ -1133,6 +1184,24 @@
}
}
.virtual-container {
overflow: hidden;
}
.load-more-trigger {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
padding: 12px 0;
border-top: 1px solid #f0f0f0;
}
.load-more-text {
color: #8f959e;
font-size: 13px;
}
.empty-state {
display: flex;
flex-direction: column;
+12 -3
View File
@@ -47,7 +47,7 @@
<Icon icon="tasklist-task-list|svg" class="sidebar-favorites-icon" />
<span class="sidebar-favorites-name">常用计划</span>
<a-dropdown :trigger="['click']">
<Icon icon="ant-design:plus-outlined" class="sidebar-group-add" @click.stop />
<Icon icon="ant-design:plus-outlined" class="sidebar-group-add header-add-btn" @click.stop />
<template #overlay>
<a-menu @click="onAddMenuClick">
<a-menu-item key="new-list">
@@ -62,6 +62,7 @@
</template>
</a-dropdown>
</div>
<div class="sidebar-favorites-scroll">
<div class="sidebar-favorites-items">
<draggable :list="localUngroupedTaskLists" group="tasklists" item-key="id" handle=".list-drag-handle" :animation="200" @end="onDragEnd">
<template #item="{ element: taskList }">
@@ -215,6 +216,7 @@
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
@@ -559,7 +561,9 @@
.sidebar-lists-section {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
overflow: hidden;
padding-bottom: 0;
}
@@ -569,6 +573,12 @@
gap: 4px;
padding: 6px 8px 2px;
position: relative;
flex-shrink: 0;
}
.sidebar-favorites-scroll {
flex: 1;
overflow-y: auto;
}
.sidebar-favorites-icon {
@@ -657,7 +667,6 @@
}
.sidebar-group-add {
margin-left: auto;
font-size: 14px;
color: #999;
cursor: pointer;
+212 -63
View File
@@ -32,6 +32,8 @@
:show-list-group-view="showListGroupView"
:favorite-id-map="favoriteIdMap"
:loading="pageLoading"
:has-more="showListGroupView ? listViewHasMore : false"
:loading-more="showListGroupView ? loadingMore : false"
@create-task="onCreateTask"
@toggle-task="onToggleTask"
@task-click="onTaskClick"
@@ -55,6 +57,8 @@
@rename-group="onRenameTaskGroup"
@delete-group="onDeleteTaskGroup"
@create-list="onCreateListModal('')"
@load-more="onLoadMoreListViews"
@search-keyword="onSearchKeywordChange"
/>
<TaskFlatView
v-else
@@ -67,6 +71,9 @@
:editable="flatViewEditable"
:task-edit-map="flatTaskEditMap"
:loading="pageLoading"
:has-more="hasMore"
:loading-more="loadingMore"
:total-count="totalCount"
@toggle-task="onToggleTask"
@task-click="onTaskClick"
@update-task-field="onUpdateTaskField"
@@ -76,6 +83,7 @@
@update:sort-direction="(v) => (sortDirection = v)"
@update:visible-fields="(v) => (visibleFields = v)"
@load-subtasks="onLoadSubtasks"
@load-more="loadMoreFlatTasks"
/>
<CreateTaskModal @register="registerTaskModal" @success="onTaskCreated" />
<CreateListModal @register="registerListModal" @success="onListCreated" />
@@ -168,6 +176,19 @@
const visibleFields = ref<FieldKey[]>([...DEFAULT_VISIBLE_FIELDS]);
const currentDrawerTaskId = ref<string>('');
const pageLoading = ref(false);
const loadingMore = ref(false);
const pageNo = ref(1);
const pageSize = 50;
const totalCount = ref(0);
const hasMore = computed(() => flatTasks.value.length < totalCount.value);
const searchKeyword = ref('');
const listViewPageNo = ref(1);
const listViewPageSize = 30;
const listViewTotalCount = ref(0);
const listViewHasMore = computed(() => {
const allLists = quickAccessGroups.value.flatMap((g) => g.taskLists);
return allLists.length < listViewTotalCount.value;
});
const [registerTaskModal, { openModal: openTaskModal }] = useModal();
const [registerListModal, { openModal: openListModal }] = useModal();
@@ -366,8 +387,7 @@
async function loadTasks(listId: string) {
try {
const res = await listAllByMainId({ mainId: listId });
const data: any[] = Array.isArray(res) ? res : Array.isArray(res?.result) ? res.result : [];
const data: any[] = (await listAllByMainId({ mainId: listId })) || [];
const list = findListById(listId);
if (!list) return;
@@ -393,58 +413,72 @@
defaultGroupRealIdMap.value[listId] = gr.id;
}
}
if (!defaultGroup) {
defaultGroup = {
id: '__default__',
name: '默认分组',
taskListId: listId,
collapsed: false,
tasks: [],
sortOrder: 0,
};
defaultGroup = { id: '__default__', name: '默认分组', taskListId: listId, collapsed: false, tasks: [], sortOrder: 0 };
}
const groupIds = new Set(groupRecords.map((g: any) => g.id));
const newTasks = taskRecords.map((tr: any) => buildTaskItem(tr));
const allTaskItems = new Map<string, TaskItem>();
for (const tr of taskRecords) {
allTaskItems.set(tr.id, buildTaskItem(tr));
}
function getGroupForTask(taskId: string): { groupId: string; groupName: string } {
const item = allTaskItems.get(taskId);
if (!item) return { groupId: defaultGroup!.id, groupName: defaultGroup!.name };
if (!item.pid) return { groupId: defaultGroup!.id, groupName: defaultGroup!.name };
if (groupIds.has(item.pid)) {
const tg = groupMap.get(item.pid);
return { groupId: tg!.id, groupName: tg!.name };
}
return getGroupForTask(item.pid);
}
for (const [id, taskItem] of allTaskItems) {
const { groupId, groupName } = getGroupForTask(id);
taskItem.groupId = groupId;
taskItem.groupName = groupName;
const targetGroup = groupMap.get(groupId) || defaultGroup!;
for (const taskItem of newTasks) {
taskItem.groupId = resolveGroupId(taskItem, groupIds, groupMap, defaultGroup!);
taskItem.groupName = groupMap.get(taskItem.groupId)?.name || defaultGroup!.name;
const targetGroup = groupMap.get(taskItem.groupId) || defaultGroup!;
targetGroup.tasks = targetGroup.tasks.filter((t) => t.id !== taskItem.id);
targetGroup.tasks.push(taskItem);
}
const resultGroups = Array.from(groupMap.values());
resultGroups.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
list.groups = resultGroups;
list.groups = Array.from(groupMap.values()).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
} catch (e) {
console.error('[TaskList] 加载任务失败', e);
}
}
function resolveGroupId(taskItem: TaskItem, groupIds: Set<string>, groupMap: Map<string, TaskGroup>, defaultGroup: TaskGroup): string {
if (!taskItem.pid) return defaultGroup.id;
if (groupIds.has(taskItem.pid)) return taskItem.pid;
return defaultGroup.id;
}
async function loadMoreFlatTasks() {
if (!hasMore.value || loadingMore.value) return;
loadingMore.value = true;
try {
pageNo.value++;
if (currentView.value === 'my-tasks') {
const res: any = await myResponsibleTasks({ pageNo: pageNo.value, pageSize });
const data: any[] = Array.isArray(res?.records) ? res.records : Array.isArray(res?.result?.records) ? res.result.records : Array.isArray(res?.result) ? res.result : [];
totalCount.value = res?.total || res?.result?.total || data.length;
flatTasks.value = [...flatTasks.value, ...data.map(buildFlatTaskItem)];
} else if (currentView.value === 'followed') {
const res: any = await myFollowedTasks({ pageNo: pageNo.value, pageSize });
const data: any[] = Array.isArray(res?.records) ? res.records : Array.isArray(res?.result?.records) ? res.result.records : Array.isArray(res?.result) ? res.result : [];
totalCount.value = res?.total || res?.result?.total || data.length;
const items = data.map(buildFlatTaskItem);
flatTasks.value = [...flatTasks.value, ...items];
const editMap = new Map(flatTaskEditMap.value);
const userId = userStore.getUserInfo?.id || '';
for (const item of items) {
const isAssignee = item.assigneeId?.includes(userId);
const hasListPerm = item.myPermission === '1' || item.myPermission === '2';
editMap.set(item.id, !!isAssignee || hasListPerm);
}
flatTaskEditMap.value = editMap;
}
} catch (e) {
console.error('[TaskList] 加载更多任务失败', e);
} finally {
loadingMore.value = false;
}
}
async function onViewSelect(view: ViewType) {
currentView.value = view;
showListGroupView.value = false;
currentListId.value = '';
flatTasks.value = [];
pageNo.value = 1;
totalCount.value = 0;
groupDimension.value = 'none';
sortField.value = 'createTime';
sortDirection.value = 'asc';
@@ -455,13 +489,17 @@
flatViewTitle.value = '我的事项';
flatViewEditable.value = true;
flatTaskEditMap.value = new Map();
const data: any[] = await myResponsibleTasks();
flatTasks.value = (data || []).map(buildFlatTaskItem);
const res: any = await myResponsibleTasks({ pageNo: 1, pageSize });
const data: any[] = Array.isArray(res?.records) ? res.records : Array.isArray(res?.result?.records) ? res.result.records : Array.isArray(res?.result) ? res.result : [];
totalCount.value = res?.total || res?.result?.total || data.length;
flatTasks.value = data.map(buildFlatTaskItem);
} else if (view === 'followed') {
flatViewTitle.value = '关注事项';
flatViewEditable.value = false;
const data: any[] = await myFollowedTasks();
const items = (data || []).map(buildFlatTaskItem);
const res: any = await myFollowedTasks({ pageNo: 1, pageSize });
const data: any[] = Array.isArray(res?.records) ? res.records : Array.isArray(res?.result?.records) ? res.result.records : Array.isArray(res?.result) ? res.result : [];
totalCount.value = res?.total || res?.result?.total || data.length;
const items = data.map(buildFlatTaskItem);
const editMap = new Map<string, boolean>();
const userId = userStore.getUserInfo?.id || '';
for (const item of items) {
@@ -604,18 +642,23 @@
currentListId.value = '';
groupDimension.value = 'custom';
flatTasks.value = [];
listViewPageNo.value = 1;
pageLoading.value = true;
try {
const kw = searchKeyword.value || undefined;
if (tabKey === 'all') {
const data: TaskList[] = await getAllLists();
quickAccessGroups.value = wrapAsVirtualGroupsWithDetails(data);
const res: any = await getAllLists({ pageNo: 1, pageSize: listViewPageSize, keyword: kw });
quickAccessGroups.value = wrapAsVirtualGroupsWithDetails(res?.records || []);
listViewTotalCount.value = res?.total || 0;
} else if (tabKey === 'own') {
const data: TaskList[] = await getMyOwnLists();
quickAccessGroups.value = wrapAsVirtualGroupsWithDetails(data);
const res: any = await getMyOwnLists({ pageNo: 1, pageSize: listViewPageSize, keyword: kw });
quickAccessGroups.value = wrapAsVirtualGroupsWithDetails(res?.records || []);
listViewTotalCount.value = res?.total || 0;
} else if (tabKey === 'collab') {
const data: TaskList[] = await getMyCollabLists();
quickAccessGroups.value = wrapAsVirtualGroupsWithDetails(data);
const res: any = await getMyCollabLists({ pageNo: 1, pageSize: listViewPageSize, keyword: kw });
quickAccessGroups.value = wrapAsVirtualGroupsWithDetails(res?.records || []);
listViewTotalCount.value = res?.total || 0;
}
} catch (e) {
console.error('[TaskList] 加载清单失败', e);
@@ -624,6 +667,60 @@
}
}
let searchTimer: ReturnType<typeof setTimeout> | null = null;
function onSearchKeywordChange(keyword: string) {
searchKeyword.value = keyword;
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
listViewPageNo.value = 1;
onQuickAccessTabClick(getCurrentTabKey());
}, 300);
}
function getCurrentTabKey(): string {
const tabKeyMap: Record<string, string> = {
'all-tasks': 'all',
created: 'own',
assigned: 'collab',
};
return tabKeyMap[currentView.value] || 'all';
}
async function onLoadMoreListViews() {
if (!listViewHasMore.value || loadingMore.value) return;
loadingMore.value = true;
listViewPageNo.value++;
const tabKeyMap: Record<string, string> = {
'all-tasks': 'all',
created: 'own',
assigned: 'collab',
};
const tabKey = tabKeyMap[currentView.value] || '';
try {
const kw = searchKeyword.value || undefined;
let res: any;
if (tabKey === 'all') {
res = await getAllLists({ pageNo: listViewPageNo.value, pageSize: listViewPageSize, keyword: kw });
} else if (tabKey === 'own') {
res = await getMyOwnLists({ pageNo: listViewPageNo.value, pageSize: listViewPageSize, keyword: kw });
} else if (tabKey === 'collab') {
res = await getMyCollabLists({ pageNo: listViewPageNo.value, pageSize: listViewPageSize, keyword: kw });
}
if (res?.records) {
const existingIds = new Set(quickAccessGroups.value.flatMap((g) => g.taskLists).map((l) => l.id));
const newLists = (res.records as TaskList[]).filter((item) => !existingIds.has(item.id));
if (newLists.length > 0) {
quickAccessGroups.value = [...quickAccessGroups.value, ...wrapAsVirtualGroupsWithDetails(newLists)];
}
listViewTotalCount.value = res?.total || 0;
}
} catch (e) {
console.error('[TaskList] 加载更多清单失败', e);
} finally {
loadingMore.value = false;
}
}
function wrapAsVirtualGroupsWithDetails(lists: TaskList[]): TaskListGroup[] {
return lists.map((item: TaskList) => ({
id: `_virtual_${item.id}`,
@@ -663,9 +760,21 @@
type: '1',
pid: groupId && groupId !== '__default__' ? groupId : '',
};
await addTask(params);
const created: any = await addTask(params);
if (currentListId.value) {
await loadTasks(currentListId.value);
const newTask = buildTaskItem(created?.result || created || {});
if (newTask.id) {
newTask.groupId = groupId || '__default__';
newTask.groupName = groupId && groupId !== '__default__'
? (currentList.value?.groups?.find((g: TaskGroup) => g.id === groupId)?.name || '')
: (currentList.value?.groups?.[0]?.name || '');
const list = findListById(currentListId.value);
if (list) {
const targetGroup = list.groups.find((g: TaskGroup) => g.id === (groupId || list.groups[0]?.id));
if (targetGroup) targetGroup.tasks.unshift(newTask);
else if (list.groups.length > 0) list.groups[0].tasks.unshift(newTask);
}
}
}
} catch (e) {
console.error('[TaskList] 创建任务失败', e);
@@ -697,15 +806,16 @@
async function refreshQuickAccessView() {
try {
const viewMap: Record<string, () => Promise<TaskList[]>> = {
const viewMap: Record<string, (params?: any) => Promise<any>> = {
'all-tasks': getAllLists,
created: getMyOwnLists,
assigned: getMyCollabLists,
};
const fetcher = viewMap[currentView.value];
if (fetcher) {
const data = await fetcher();
quickAccessGroups.value = wrapAsVirtualGroupsWithDetails(data);
const res = await fetcher({ pageNo: 1, pageSize: listViewPageSize });
quickAccessGroups.value = wrapAsVirtualGroupsWithDetails(res?.records || []);
listViewTotalCount.value = res?.total || 0;
}
} catch (e) {
console.error('[TaskList] 刷新计划列表失败', e);
@@ -981,18 +1091,33 @@
return;
}
}
try {
await editTask({ id: taskId, [field]: value });
// 乐观更新
let oldValue: any = undefined;
if (isFlatView.value) {
const idx = flatTasks.value.findIndex((t) => t.id === taskId);
if (idx >= 0) {
flatTasks.value[idx] = { ...flatTasks.value[idx], [field]: value };
oldValue = (flatTasks.value[idx] as any)[field];
(flatTasks.value[idx] as any)[field] = value;
}
} else if (currentListId.value) {
await loadTasks(currentListId.value);
} else {
const found = findTaskInGroups(taskId);
if (found) {
oldValue = (found.task as any)[field];
(found.task as any)[field] = value;
}
}
try {
await editTask({ id: taskId, [field]: value });
} catch (e) {
console.error('[TaskList] 更新任务字段失败', e);
// 回滚
if (isFlatView.value) {
const idx = flatTasks.value.findIndex((t) => t.id === taskId);
if (idx >= 0) (flatTasks.value[idx] as any)[field] = oldValue;
} else {
const found = findTaskInGroups(taskId);
if (found) (found.task as any)[field] = oldValue;
}
}
}
@@ -1008,18 +1133,33 @@
}
}
}
try {
await editTask({ id: taskId, ...fields });
const oldFields: Record<string, any> = {};
if (isFlatView.value) {
const idx = flatTasks.value.findIndex((t) => t.id === taskId);
if (idx >= 0) {
for (const f of Object.keys(fields)) oldFields[f] = (flatTasks.value[idx] as any)[f];
flatTasks.value[idx] = { ...flatTasks.value[idx], ...fields };
}
} else if (currentListId.value) {
await loadTasks(currentListId.value);
} else {
const found = findTaskInGroups(taskId);
if (found) {
for (const f of Object.keys(fields)) oldFields[f] = (found.task as any)[f];
Object.assign(found.task, fields);
}
}
try {
await editTask({ id: taskId, ...fields });
} catch (e) {
console.error('[TaskList] 批量更新任务字段失败', e);
if (isFlatView.value) {
const idx = flatTasks.value.findIndex((t) => t.id === taskId);
if (idx >= 0) {
for (const f of Object.keys(oldFields)) (flatTasks.value[idx] as any)[f] = oldFields[f];
}
} else {
const found = findTaskInGroups(taskId);
if (found) Object.assign(found.task, oldFields);
}
}
}
@@ -1134,15 +1274,24 @@
}
async function onDeleteTask(taskId: string) {
let removed: { idx: number; item: TaskItem } | null = null;
if (isFlatView.value) {
const idx = flatTasks.value.findIndex((t) => t.id === taskId);
if (idx >= 0) {
removed = { idx, item: flatTasks.value[idx] };
flatTasks.value.splice(idx, 1);
}
}
try {
await deleteTaskApi({ id: taskId }, () => {});
if (isFlatView.value) {
flatTasks.value = flatTasks.value.filter((t) => t.id !== taskId);
} else if (currentListId.value) {
if (!isFlatView.value && currentListId.value) {
await loadTasks(currentListId.value);
}
} catch (e) {
console.error('[TaskList] 删除任务失败', e);
if (removed) {
flatTasks.value.splice(removed.idx, 0, removed.item);
}
}
}