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

821 lines
23 KiB
Vue

<template>
<div class="task-sidebar">
<div class="sidebar-section">
<div class="sidebar-quick-header" @click="planExpanded = !planExpanded">
<Icon icon="tasklist-arrow-down|svg" class="quick-access-arrow" :class="{ expanded: planExpanded }" />
<span class="sidebar-quick-title">计划总览</span>
</div>
<template v-if="planExpanded">
<div
v-for="item in QUICK_ACCESS_ITEMS"
:key="item.id"
class="sidebar-item sidebar-quick-access-sub"
:class="{ active: currentView === item.id }"
@click="onQuickAccessClick(item)"
>
<Icon :icon="item.icon" class="sidebar-item-icon" />
<span>{{ item.label }}</span>
</div>
</template>
</div>
<div class="sidebar-divider"></div>
<div class="sidebar-section">
<div class="sidebar-quick-header" @click="taskExpanded = !taskExpanded">
<Icon icon="tasklist-arrow-down|svg" class="quick-access-arrow" :class="{ expanded: taskExpanded }" />
<span class="sidebar-quick-title">事项总览</span>
</div>
<template v-if="taskExpanded">
<div
v-for="item in NAV_ITEMS"
:key="item.id"
class="sidebar-item sidebar-quick-access-sub"
:class="{ active: currentView === item.id }"
@click="emit('view-select', item.id)"
>
<Icon :icon="item.icon" class="sidebar-item-icon" />
<span>{{ item.label }}</span>
</div>
</template>
</div>
<div class="sidebar-divider"></div>
<div class="sidebar-section sidebar-lists-section">
<div class="sidebar-favorites-header">
<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 />
<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">
<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"
class="sidebar-item"
:class="{ active: currentListId === taskList.id }"
:data-list-id="taskList.id"
@click="emit('list-select', taskList.id)"
@mouseenter="hoveredListId = taskList.id"
@mouseleave="hoveredListId = ''"
>
<span class="list-drag-handle">
<Icon icon="tasklist-list-drag-handle|svg" />
</span>
<Icon icon="ant-design:file-text-outlined" class="sidebar-item-icon" />
<span class="sidebar-item-name">{{ taskList.name }}</span>
<a-dropdown :trigger="['click']">
<Icon v-show="hoveredListId === taskList.id" icon="ant-design:ellipsis-outlined" class="sidebar-item-more" @click.stop />
<template #overlay>
<a-menu @click="onListMenuClick($event, taskList.id, taskList.name)">
<a-menu-item v-if="canEditList(taskList.id)" key="rename">
<Icon icon="ant-design:edit-outlined" class="mr-1" />
重命名
</a-menu-item>
<a-menu-item key="remove">
<Icon icon="ant-design:minus-circle-outlined" class="mr-1" />
移除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<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 ref="renameInputRef" v-model:value="renameValue" size="small" style="flex: 1" @press-enter="confirmInlineRename" @blur="confirmInlineRename" />
</div>
</template>
</draggable>
</div>
<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
v-if="group.delFlag !== 1 && renamingId !== group.id"
class="sidebar-group-header"
@mousedown="onGroupMouseDown($event, group.id)"
@mousemove="onGroupMouseMove($event)"
@mouseup="onGroupMouseUp($event, group.id)"
>
<Icon icon="tasklist-arrow-down|svg" class="group-collapse-arrow" :class="{ collapsed: collapsedGroups[group.id] || draggingGroupId === group.id }" />
<span class="sidebar-group-name">{{ group.name }}</span>
<a-dropdown :trigger="['click']" @click.stop>
<Icon icon="ant-design:ellipsis-outlined" class="sidebar-group-more" @click.stop />
<template #overlay>
<a-menu @click="onGroupMenuClick($event, group.id, group.name)">
<a-menu-item key="rename">
<Icon icon="ant-design:edit-outlined" class="mr-1" />
重命名
</a-menu-item>
<a-menu-item key="delete" danger>
<Icon icon="ant-design:delete-outlined" class="mr-1" />
删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<Icon icon="ant-design:plus-outlined" class="sidebar-group-add" @click.stop="startNewList(group.id)" />
</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 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">
<draggable
:list="getLocalGroupTaskLists(group)"
group="tasklists"
item-key="id"
handle=".list-drag-handle"
:animation="200"
@end="onDragEnd"
>
<template #item="{ element: taskList }">
<div
v-if="renamingId !== taskList.id"
class="sidebar-item"
:class="{ active: currentListId === taskList.id }"
:data-list-id="taskList.id"
@click="emit('list-select', taskList.id)"
@mouseenter="hoveredListId = taskList.id"
@mouseleave="hoveredListId = ''"
>
<span class="list-drag-handle">
<Icon icon="tasklist-list-drag-handle|svg" />
</span>
<Icon icon="ant-design:file-text-outlined" class="sidebar-item-icon" />
<span class="sidebar-item-name">{{ taskList.name }}</span>
<a-dropdown :trigger="['click']">
<Icon v-show="hoveredListId === taskList.id" icon="ant-design:ellipsis-outlined" class="sidebar-item-more" @click.stop />
<template #overlay>
<a-menu @click="onListMenuClick($event, taskList.id, taskList.name)">
<a-menu-item v-if="canEditList(taskList.id)" key="rename">
<Icon icon="ant-design:edit-outlined" class="mr-1" />
重命名
</a-menu-item>
<a-menu-item key="remove">
<Icon icon="ant-design:minus-circle-outlined" class="mr-1" />
移除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<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 ref="renameInputRef" v-model:value="renameValue" size="small" style="flex: 1" @press-enter="confirmInlineRename" @blur="confirmInlineRename" />
</div>
</template>
</draggable>
<div
v-if="getLocalGroupTaskLists(group).length === 0"
class="sidebar-empty-hint"
>
可拖拽计划加入该分组
</div>
</div>
</template>
</div>
</template>
</draggable>
<div v-if="showNewGroupInput" class="sidebar-new-group-row">
<Icon icon="ant-design:plus-outlined" class="sidebar-new-icon" />
<a-input
v-model:value="newGroupName"
size="small"
placeholder="输入分组名称..."
autofocus
style="flex: 1"
@press-enter="submitNewGroup"
@blur="showNewGroupInput = false"
/>
</div>
<div v-else class="sidebar-new-group-btn" @click="showNewGroupInput = true">
<Icon icon="ant-design:plus-outlined" class="sidebar-new-icon" />
<span>新建分组</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick } from 'vue';
import Icon from '/@/components/Icon/index';
import draggable from 'vuedraggable';
import { useMessage } from '/@/hooks/web/useMessage';
import { NAV_ITEMS, QUICK_ACCESS_ITEMS, QUICK_ACCESS_TAB_MAP } from '../types';
import type { TaskListGroup, ViewType } from '../types';
const { createConfirm } = useMessage();
const props = defineProps<{
taskListGroups: TaskListGroup[];
currentView: ViewType;
currentListId: string;
favoriteIdMap: Map<string, string>;
permissionMap: Map<string, string>;
}>();
const emit = defineEmits<{
'view-select': [view: ViewType];
'list-select': [listId: string];
'create-list-modal': [groupId: string];
'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];
'move-list': [favoriteId: string, targetGroupId: string, sortOrder: number];
'move-group': [groupId: string, targetSortOrder: number];
}>();
function onQuickAccessClick(item: { id: string }) {
const tabKey = QUICK_ACCESS_TAB_MAP[item.id];
if (tabKey) {
emit('quick-access-tab-click', tabKey);
}
}
const planExpanded = ref(true);
const taskExpanded = ref(true);
const ungroupedTaskLists = computed(() => {
const group = props.taskListGroups.find((g) => g.id === '__ungrouped__');
if (group) {
return [...group.taskLists].filter((l) => l.delFlag !== 1).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
return [];
});
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);
const newGroupName = ref('');
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);
const dragStartY = ref(0);
const isDragging = ref(false);
const DRAG_THRESHOLD = 5;
function toggleGroup(groupId: string) {
collapsedGroups.value = {
...collapsedGroups.value,
[groupId]: !collapsedGroups.value[groupId],
};
}
function onDragEnd(evt: any) {
const itemEl = evt.item;
const listId = itemEl?.dataset?.listId;
if (!listId) return;
const favoriteId = props.favoriteIdMap.get(listId);
if (!favoriteId) return;
const toContainer = evt.to;
const targetGroupId = toContainer?.closest('[data-group-id]')?.dataset?.groupId || '';
const newIndex = evt.newIndex ?? 0;
emit('move-list', favoriteId, targetGroupId, newIndex + 1);
}
function onGroupMouseDown(evt: MouseEvent, groupId: string) {
dragStartX.value = evt.clientX;
dragStartY.value = evt.clientY;
isDragging.value = false;
}
function onGroupMouseMove(evt: MouseEvent) {
const dx = Math.abs(evt.clientX - dragStartX.value);
const dy = Math.abs(evt.clientY - dragStartY.value);
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) {
isDragging.value = true;
}
}
function onGroupMouseUp(evt: MouseEvent, groupId: string) {
if (!isDragging.value) {
toggleGroup(groupId);
}
isDragging.value = false;
dragStartX.value = 0;
dragStartY.value = 0;
}
function onGroupDragStart(evt: any) {
const itemEl = evt.item;
const groupId = itemEl?.dataset?.groupId;
if (groupId) {
draggingGroupId.value = groupId;
}
}
function onGroupDragEnd(evt: any) {
draggingGroupId.value = '';
isDragging.value = false;
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);
}
function canEditList(listId: string): boolean {
const perm = props.permissionMap.get(listId);
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) {
emit('create-list-modal', groupId);
}
function onListMenuClick({ key }: { key: string }, listId: string, currentName: string) {
if (key === 'rename') {
renamingId.value = listId;
renamingType.value = 'list';
renameValue.value = currentName;
} else if (key === 'remove') {
createConfirm({
iconType: 'warning',
title: '确认移除',
content: '确定从常用计划移除吗?',
okText: '确认',
cancelText: '取消',
onOk: () => {
emit('remove-favorite', listId);
},
});
}
}
function onGroupMenuClick({ key }: { key: string }, groupId: string, currentName: string) {
if (key === 'rename') {
renamingId.value = groupId;
renamingType.value = 'group';
renameValue.value = currentName;
} else if (key === 'delete') {
emit('delete-group', groupId);
}
}
function confirmInlineRename() {
const name = renameValue.value.trim();
if (name && renamingId.value) {
if (renamingType.value === 'list') {
emit('rename-list', renamingId.value, name);
} else {
emit('rename-group', renamingId.value, name);
}
}
renamingId.value = '';
renameValue.value = '';
}
function submitNewGroup() {
if (newGroupName.value.trim()) {
emit('create-group', newGroupName.value.trim());
newGroupName.value = '';
showNewGroupInput.value = false;
}
}
</script>
<style lang="less" scoped>
.task-sidebar {
width: 240px;
min-width: 240px;
height: 100%;
background: #fff;
border-right: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
}
.sidebar-header {
padding: 14px 16px 10px;
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar-title {
font-size: 16px;
font-weight: 600;
color: #1f1f1f;
display: flex;
align-items: center;
}
.sidebar-nav {
padding: 4px 8px;
}
.sidebar-divider {
height: 1px;
background: #f0f0f0;
margin: 4px 12px;
}
.sidebar-section {
padding: 4px 8px;
}
.sidebar-quick-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
cursor: pointer;
color: #8c8c8c;
font-size: 14px;
user-select: none;
&:hover {
color: #595959;
}
}
.sidebar-quick-title {
font-size: 14px;
font-weight: 600;
color: #595959;
}
.quick-access-arrow {
font-size: 12px;
color: #8c8c8c;
transition: transform 0.2s ease;
transform: rotate(-90deg);
&.expanded {
transform: rotate(0deg);
}
}
.sidebar-item.sidebar-quick-access-sub {
padding-left: 30px;
font-size: 14px;
}
.sidebar-section-header {
padding: 6px 8px 2px;
}
.sidebar-section-title {
font-size: 14px;
font-weight: 600;
color: #8c8c8c;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sidebar-lists-section {
flex: 1;
overflow-y: auto;
padding-bottom: 0;
}
.sidebar-favorites-header {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px 2px;
position: relative;
}
.sidebar-favorites-icon {
color: #8f959e;
flex-shrink: 0;
font-size: 14px;
}
.sidebar-favorites-name {
font-size: 14px;
font-weight: 600;
color: #595959;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-favorites-items {
.sidebar-item {
padding-left: 8px;
}
}
.sidebar-group-items {
.sidebar-item {
padding-left: 8px;
}
}
.sidebar-group-header {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px 2px;
cursor: pointer;
position: relative;
&:hover .sidebar-group-name {
color: #333;
}
.sidebar-group-more,
.sidebar-group-add {
opacity: 0;
transition: opacity 0.15s;
}
&:hover .sidebar-group-more,
&:hover .sidebar-group-add {
opacity: 1;
}
}
.group-collapse-arrow {
font-size: 12px;
color: #8f959e;
transition: transform 0.2s ease;
flex-shrink: 0;
width: 16px;
text-align: center;
&.collapsed {
transform: rotate(-90deg);
}
}
.sidebar-group-name {
font-size: 14px;
font-weight: 600;
color: #595959;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-group-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 10px;
color: #999;
}
.sidebar-group-add {
margin-left: auto;
font-size: 14px;
color: #999;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
&:hover {
color: #1677ff;
background: #e6f4ff;
}
}
.sidebar-group-more {
font-size: 16px;
color: #999;
padding: 2px;
border-radius: 4px;
flex-shrink: 0;
&:hover {
color: #1677ff;
background: #e6f4ff;
}
}
.sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
cursor: pointer;
border-radius: 6px;
margin: 1px 0;
font-size: 14px;
color: #595959;
transition: all 0.15s ease;
position: relative;
&:hover {
background: #f5f5f5;
color: #333;
}
&.active {
background: #e6f4ff;
color: #1677ff;
font-weight: 500;
}
}
.sidebar-item-icon {
font-size: 14px;
flex-shrink: 0;
}
.sidebar-item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.sidebar-item-more {
font-size: 14px;
color: #999;
padding: 2px;
border-radius: 4px;
flex-shrink: 0;
&:hover {
color: #1677ff;
background: #e6f4ff;
}
}
.list-drag-handle {
cursor: grab;
font-size: 12px;
color: transparent;
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex-shrink: 0;
transition: color 0.15s;
&:active {
cursor: grabbing;
}
}
.sidebar-item:hover .list-drag-handle {
color: #c0c4cc;
&:hover {
color: #8c8c8c;
}
}
.list-drag-handle-placeholder {
width: 14px;
flex-shrink: 0;
}
.sidebar-empty-hint {
padding: 8px 10px 4px 38px;
font-size: 12px;
color: #bfbfbf;
font-style: italic;
}
.sidebar-new-group-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
margin: 4px 0;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #8c8c8c;
&:hover {
background: #f5f5f5;
color: #333;
}
}
.sidebar-new-group-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
margin: 4px 0;
}
.sidebar-new-item-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px 4px 8px;
margin: 2px 0;
}
.group-being-dragged > .sidebar-group-items {
display: none !important;
}
.group-drag-ghost {
opacity: 0.6;
}
.sidebar-item-renaming {
background: #f5f6f7;
}
.sidebar-group-renaming {
background: #f5f6f7;
}
.sidebar-new-icon {
font-size: 12px;
color: #8c8c8c;
}
</style>