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

2903 lines
93 KiB
Vue

<template>
<div class="task-content">
<a-spin :spinning="props.loading" tip="加载中...">
<div class="content-header">
<template v-if="showListGroupView">
<div class="content-header-row">
<div class="content-header-row-left">
<h2 class="content-title">计划列表</h2>
<a-tooltip title="新建清单">
<a-button type="text" size="small" class="add-list-btn" @click="onCreateListClick">
<Icon icon="ant-design:plus-outlined" />
</a-button>
</a-tooltip>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索计划名称"
class="list-search-input"
allow-clear
/>
</div>
<a-tooltip title="使用帮助">
<a-button type="text" size="small" class="help-btn" @click="helpModalOpen = true">
<Icon icon="ant-design:question-circle-outlined" />
</a-button>
</a-tooltip>
</div>
</template>
<template v-else>
<div class="content-header-left">
<div v-if="headerRenaming" class="content-header-rename-row">
<a-input
ref="headerRenameInputRef"
v-model:value="headerRenameValue"
size="small"
style="width: 200px"
@press-enter="confirmHeaderRename"
@blur="confirmHeaderRename"
/>
</div>
<h2 v-else class="content-title">{{ currentList?.name || '请选择计划' }}</h2>
<a-dropdown v-if="currentList && canShowHeaderMore" :trigger="['click']">
<a-button type="text" size="small" class="header-more-btn">
<Icon icon="ant-design:ellipsis-outlined" />
</a-button>
<template #overlay>
<a-menu @click="onHeaderMenuClick">
<a-menu-item v-if="canEdit" key="rename">
<Icon icon="ant-design:edit-outlined" class="mr-1" />
重命名
</a-menu-item>
<a-menu-item v-if="_isOwner" key="delete" danger>
<Icon icon="ant-design:delete-outlined" class="mr-1" />
删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div v-if="currentList" class="content-header-actions">
<span v-if="currentList.secretText" class="header-secret-badge" :style="{ color: SECRET_LEVEL_COLOR[currentList.secretLevel || 1] || '#8c8c8c', borderColor: SECRET_LEVEL_COLOR[currentList.secretLevel || 1] || '#8c8c8c' }">
{{ currentList.secretText }}
</span>
<div class="collaborator-bar" :class="{ disabled: !_isOwner }" @click="_isOwner && emit('open-collaborator')">
<AvatarDisplay
v-if="collaboratorDisplayNames"
:names="collaboratorDisplayNames"
:size="24"
:show-name="showCollaboratorName"
:max-show="5"
/>
<a-button size="small" type="text" class="collab-btn">
<Icon icon="ant-design:user-add-outlined" style="margin-right: 2px" />
<span>协作人</span>
</a-button>
</div>
</div>
</template>
</div>
<div v-if="!showListGroupView" class="content-toolbar">
<a-dropdown>
<template #overlay>
<a-menu @click="onStatusFilterChange">
<a-menu-item key="all" :class="{ 'ant-dropdown-menu-item-selected': statusFilter === 'all' }">全部</a-menu-item>
<a-menu-item key="completed" :class="{ 'ant-dropdown-menu-item-selected': statusFilter === 'completed' }">已完成</a-menu-item>
<a-menu-item key="uncompleted" :class="{ 'ant-dropdown-menu-item-selected': statusFilter === 'uncompleted' }">未完成</a-menu-item>
</a-menu>
</template>
<a-button size="small">
<Icon icon="ant-design:check-circle-outlined" class="mr-1" />
{{ statusFilterLabel }}
<Icon icon="ant-design:down-outlined" class="ml-1" />
</a-button>
</a-dropdown>
<a-popover v-model:open="filterVisible" placement="bottomLeft" trigger="click" :width="480">
<template #content>
<div class="filter-panel">
<div class="filter-panel-header">
<span class="filter-panel-title">筛选条件</span>
<a-button v-if="filterConditions.length > 0" type="link" size="small" @click="clearAllFilters">清空全部</a-button>
</div>
<div v-for="(cond, idx) in filterConditions" :key="cond.id" class="filter-condition-row">
<a-select
:value="cond.field"
size="small"
class="filter-field-select"
placeholder="选择字段"
@change="(v: FilterFieldType) => onConditionFieldChange(idx, v)"
>
<a-select-option v-for="fc in FILTER_FIELD_CONFIGS" :key="fc.value" :value="fc.value">{{ fc.label }}</a-select-option>
</a-select>
<a-select
v-if="cond.field"
:value="cond.operator"
size="small"
class="filter-op-select"
@change="(v: FilterOperator) => onConditionOperatorChange(idx, v)"
>
<a-select-option v-for="op in getOperatorsForField(cond.field)" :key="op" :value="op">
{{ FILTER_OPERATOR_LABELS[op] }}
</a-select-option>
</a-select>
<template v-if="cond.field && needsValueInput(cond)">
<a-input
v-if="getFieldType(cond.field) === 'text'"
:value="cond.value"
size="small"
class="filter-value-input"
placeholder="输入关键词"
@change="(e: any) => onConditionValueChange(idx, e.target.value)"
/>
<a-select
v-if="getFieldType(cond.field) === 'select'"
:value="cond.value"
size="small"
class="filter-value-input"
placeholder="请选择"
@change="(v: string) => onConditionValueChange(idx, v)"
>
<a-select-option value="1"></a-select-option>
<a-select-option value="2"></a-select-option>
<a-select-option value="3"></a-select-option>
</a-select>
<a-date-picker
v-if="getFieldType(cond.field) === 'date' && cond.operator !== 'between'"
:value="cond.value"
size="small"
class="filter-value-input"
placeholder="选择日期"
@change="(v: any) => onConditionValueChange(idx, v)"
/>
<template v-if="getFieldType(cond.field) === 'date' && cond.operator === 'between'">
<a-date-picker
:value="cond.value"
size="small"
class="filter-value-input-short"
placeholder="开始日期"
@change="(v: any) => onConditionValueChange(idx, v)"
/>
<span class="filter-range-sep">~</span>
<a-date-picker
:value="cond.valueEnd"
size="small"
class="filter-value-input-short"
placeholder="结束日期"
@change="(v: any) => onConditionValueEndChange(idx, v)"
/>
</template>
</template>
<span class="filter-condition-remove" @click="removeCondition(idx)">
<Icon icon="ant-design:close-outlined" />
</span>
</div>
<a-button type="dashed" size="small" block class="filter-add-btn" @click="addCondition">
<Icon icon="ant-design:plus-outlined" class="mr-1" />
添加条件
</a-button>
<div class="filter-panel-footer">
<a-button size="small" @click="filterVisible = false">取消</a-button>
<a-button type="primary" size="small" @click="applyConditions">筛选</a-button>
</div>
</div>
</template>
<a-button size="small" :type="activeFilterCount > 0 ? 'primary' : 'default'" :ghost="activeFilterCount > 0">
<Icon icon="ant-design:filter-outlined" class="mr-1" />
筛选
<span v-if="activeFilterCount > 0" class="filter-badge">{{ activeFilterCount }}</span>
</a-button>
</a-popover>
<a-dropdown>
<template #overlay>
<a-menu @click="onSortChange">
<a-menu-item v-for="opt in SORT_OPTIONS" :key="opt.value" :class="{ 'ant-dropdown-menu-item-selected': sortField === opt.value }">
<div class="sort-menu-item">
<span>{{ opt.label }}</span>
<span v-if="opt.value !== 'sortOrder'" class="sort-arrows-row">
<span
class="sort-arrow-btn"
:class="{ active: sortField === opt.value && sortDirection === 'asc' }"
@click.stop="onSortDirectionChange(opt.value, 'asc')"
>
<Icon icon="ant-design:arrow-up-outlined" />
</span>
<span
class="sort-arrow-btn"
:class="{ active: sortField === opt.value && sortDirection === 'desc' }"
@click.stop="onSortDirectionChange(opt.value, 'desc')"
>
<Icon icon="ant-design:arrow-down-outlined" />
</span>
</span>
</div>
</a-menu-item>
</a-menu>
</template>
<a-button size="small">
<Icon icon="ant-design:sort-ascending-outlined" class="mr-1" />
排序: {{ currentSortLabel }}
<Icon icon="ant-design:down-outlined" class="ml-1" />
</a-button>
</a-dropdown>
<a-dropdown>
<template #overlay>
<a-menu @click="onGroupChange">
<a-menu-item v-for="opt in GROUP_OPTIONS" :key="opt.value" :class="{ 'ant-dropdown-menu-item-selected': groupDimension === opt.value }">
{{ opt.label }}
</a-menu-item>
</a-menu>
</template>
<a-button size="small">
<Icon icon="ant-design:group-outlined" class="mr-1" />
分组: {{ currentGroupLabel }}
<Icon icon="ant-design:down-outlined" class="ml-1" />
</a-button>
</a-dropdown>
<a-popover v-model:open="fieldConfigVisible" placement="bottomRight" trigger="click">
<template #content>
<div class="field-config-panel">
<div class="field-config-title">字段配置</div>
<div v-for="field in taskContentFieldConfigs" :key="field.key" class="field-config-item" @click="toggleField(field.key)">
<span>{{ field.label }}</span>
<Icon
:icon="visibleFields.includes(field.key) ? 'ant-design:eye-outlined' : 'ant-design:eye-invisible-outlined'"
:class="visibleFields.includes(field.key) ? 'field-visible' : 'field-hidden'"
/>
</div>
</div>
</template>
<a-button size="small">
<Icon icon="ant-design:setting-outlined" class="mr-1" />
字段配置
</a-button>
</a-popover>
</div>
<div class="content-body">
<template v-if="showListGroupView">
<div class="list-group-view">
<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>
<div class="list-group-col list-group-col-collaborators">协作者</div>
<div class="list-group-col list-group-col-secret">密级</div>
<div class="list-group-col list-group-col-time">创建时间</div>
</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="ant-design:file-text-outlined" class="list-icon" size="16" />
<span class="list-name-text">{{ list.name }}</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)">
<a-tooltip :title="getFirstAssignee(list)" placement="top"><AvatarDisplay :names="getFirstAssignee(list)" :size="24" /></a-tooltip>
</template>
<span v-else class="field-placeholder">-</span>
</div>
<div class="list-group-col list-group-col-collaborators clickable" @click.stop="emit('open-collaborator-for-list', list.id)">
<template v-if="getAllCollaborators(list)">
<a-tooltip title="点击管理协作者" placement="top">
<AvatarDisplay :names="getAllCollaborators(list)" :size="24" :show-name="getCollaboratorCount(list) === 1" />
</a-tooltip>
</template>
<Icon v-else icon="tasklist-add-user|svg" class="collab-add-icon" />
</div>
<div class="list-group-col list-group-col-secret">
<span
v-if="list.secretText"
class="secret-badge-inline"
:style="{ color: SECRET_LEVEL_COLOR[(list as any).secretLevel || 1] || '#8c8c8c', borderColor: SECRET_LEVEL_COLOR[(list as any).secretLevel || 1] || '#8c8c8c' }"
>
{{ list.secretText }}
</span>
<span v-else class="field-placeholder">-</span>
</div>
<div class="list-group-col list-group-col-time">
<span class="field-value">{{ getFormattedCreateTime(list) }}</span>
</div>
</div>
<div v-if="allTaskLists.length === 0" class="empty-state">
<Icon icon="ant-design:inbox-outlined" class="empty-icon" />
<p>暂无可见计划</p>
</div>
</div>
</template>
<template v-else>
<div class="content-table-header" :style="{ minWidth: totalFieldsWidth + 'px' }">
<div class="table-col table-col-drag-handle"></div>
<div class="table-col table-col-title">
<Icon icon="tasklist-title|svg" class="header-icon" />
<span class="header-label">事项标题</span>
</div>
<template v-for="field in visibleFieldConfigs" :key="field.key">
<div class="table-col header-col" :style="{ width: fieldWidthMap[field.key] || '100px' }">
<Icon v-if="field.key === 'priority'" icon="tasklist-priority|svg" class="header-icon" />
<Icon
v-else-if="
field.key === 'assigneeName' ||
field.key === 'assignName' ||
field.key === 'participantName' ||
field.key === 'followersName' ||
field.key === 'createBy'
"
icon="tasklist-user|svg"
class="header-icon"
/>
<Icon v-else-if="field.key === 'startTime' || field.key === 'endTime'" icon="tasklist-time|svg" class="header-icon" />
<Icon v-else :icon="fieldIconMap[field.key]" class="header-icon" />
<span class="header-label">{{ field.label }}</span>
</div>
</template>
</div>
<template v-if="currentList">
<draggable
:list="localGroups"
item-key="id"
group="taskGroups"
handle=".group-drag-handle"
:animation="200"
:disabled="groupDimension !== 'custom' || !canEdit"
@end="onGroupDragEnd"
>
<template #item="{ element: group }">
<div :data-group-id="group.id">
<div
v-if="groupDimension !== 'none'"
class="task-group-header"
:style="{ minWidth: totalFieldsWidth + 'px' }"
@click="toggleGroupCollapse(group.id)"
>
<span v-if="canEdit && groupDimension === 'custom'" class="group-drag-handle table-col-drag-handle">
<Icon icon="tasklist-drag-handle|svg" />
</span>
<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>
<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: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>
<div v-show="!group.collapsed">
<draggable
:model-value="flattenGroupTasks(group.tasks)"
:item-key="(ft: any) => ft.task.id"
group="tasks"
handle=".drag-handle"
:animation="200"
ghost-class="sortable-ghost"
chosen-class="sortable-chosen"
@start="onTaskDragStart"
@end="(evt: any) => onTaskDragEnd(evt)"
@move="onTaskMove"
>
<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="_canEditTask(ft.task)">
<span class="drag-handle">
<Icon icon="tasklist-drag-handle|svg" />
</span>
</div>
<div class="table-col table-col-drag-handle" v-else></div>
<div class="table-col table-col-title" @click.stop="emit('task-click', ft.task.id)">
<div class="task-indent" :style="{ marginLeft: Math.min(ft.level, 4) * 20 + 'px' }">
<span v-if="ft.task.childCount > 0" class="expand-arrow" @click.stop="toggleTaskExpand(ft.task.id)">
<Icon :icon="expandedTaskIds.has(ft.task.id) ? 'ant-design:caret-down-outlined' : 'ant-design:caret-right-outlined'" />
</span>
<span v-else class="expand-arrow-placeholder"></span>
<button
class="task-checkbox"
:class="{ checked: ft.task.completed }"
:disabled="!_canEditTask(ft.task)"
@click.stop="_canEditTask(ft.task) && emit('toggle-task', ft.task.id)"
>
<Icon v-if="ft.task.completed" icon="ant-design:check-outlined" />
</button>
</div>
<span class="task-title" :class="{ completed: ft.task.completed }">{{ ft.task.taskName }}</span>
<span v-if="ft.task.childCount > 0" class="task-child-count">
<Icon icon="tasklist-subtask|svg" class="child-count-icon" />
{{ ft.task.completedChildCount }}/{{ ft.task.childCount }}
</span>
</div>
<template v-for="field in visibleFieldConfigs" :key="field.key">
<div class="table-col task-field" :style="{ width: fieldWidthMap[field.key] || '100px' }">
<template v-if="field.key === 'subTaskProgress'">
<div class="cell-inner readonly-cell">
<div class="progress-cell" v-if="ft.task.childCount > 0">
<div class="progress-bar">
<div
class="progress-bar-fill"
:style="{
width: (ft.task.completedChildCount / ft.task.childCount) * 100 + '%',
background: ft.task.completedChildCount > 0 ? '#52c41a' : '#1677ff',
}"
></div>
</div>
</div>
<span v-else :class="{ 'task-field-completed': ft.task.completed }">-</span>
</div>
</template>
<template v-else-if="field.key === 'priority'">
<a-dropdown
v-if="_canEditTask(ft.task)"
:trigger="['click']"
:get-popup-container="getPopupContainer"
:overlay-style="{ minWidth: '100px' }"
>
<div class="cell-inner editable-cell dropdown-cell">
<span
v-if="ft.task.priority"
class="priority-tag"
:class="{ 'task-field-completed': ft.task.completed }"
:style="{ backgroundColor: getPriorityBg(ft.task.priority), color: '#1f2329' }"
>{{ getPriorityLabel(ft.task.priority) }}</span
>
<span v-else class="field-placeholder">-</span>
</div>
<template #overlay>
<a-menu @click="({ key }) => onPriorityChange(ft.task.id, key)">
<a-menu-item v-for="opt in priorityOptions" :key="opt.value" class="priority-menu-item">
<span class="priority-tag" :style="{ backgroundColor: getPriorityBg(opt.value), color: '#1f2329' }">{{
opt.label
}}</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<div v-else class="cell-inner">
<span
v-if="ft.task.priority"
class="priority-tag"
:class="{ 'task-field-completed': ft.task.completed }"
:style="{ backgroundColor: getPriorityBg(ft.task.priority), color: '#1f2329' }"
>{{ getPriorityLabel(ft.task.priority) }}</span
>
<span v-else class="field-placeholder">-</span>
</div>
</template>
<template v-else-if="field.key === 'assigneeName'">
<div v-if="_canEditTask(ft.task)" class="cell-inner editable-cell" @click.stop="openUserSelectModal('assignee', ft.task.id)">
<template v-if="ft.task.assigneeName">
<a-tooltip :title="ft.task.assigneeName" placement="top"
><AvatarDisplay :names="ft.task.assigneeName" :size="24" :completed="ft.task.completed"
/></a-tooltip>
</template>
<template v-else>
<span class="field-icon-placeholder" :class="{ 'task-field-completed': ft.task.completed }">
<Icon icon="tasklist-add-user|svg" />
</span>
</template>
</div>
<div v-else class="cell-inner">
<template v-if="ft.task.assigneeName">
<a-tooltip :title="ft.task.assigneeName" placement="top"
><AvatarDisplay :names="ft.task.assigneeName" :size="24" :completed="ft.task.completed"
/></a-tooltip>
</template>
<span v-else class="field-placeholder">-</span>
</div>
</template>
<template v-else-if="field.key === 'startTime' || field.key === 'endTime'">
<div
class="cell-inner date-cell-wrapper"
:class="{ 'editable-cell': _canEditTask(ft.task) }"
@click.stop="_canEditTask(ft.task) && !datePickerVisible && startEditCell(ft.task.id, field.key)"
>
<a-date-picker
v-if="editingCell?.taskId === ft.task.id && editingCell?.field === field.key"
:value="field.key === 'startTime' ? editingDateDayjs[0] : editingDateDayjs[1]"
:open="datePickerVisible"
placeholder="选择日期"
format="YYYY-MM-DD"
:allow-clear="true"
:disabled-date="field.key === 'endTime' ? getDisabledEndDate(ft.task.id) : undefined"
class="inline-date-picker"
@change="(date) => onDateCellChange(date, field.key)"
@open-change="onCellPickerOpenChange"
>
<template #renderExtraFooter>
<div style="text-align: right">
<a-button type="link" size="small" danger @click.stop="clearDateCell(field.key)">清除</a-button>
</div>
</template>
</a-date-picker>
<template v-else>
<template v-if="(field.key === 'startTime' && ft.task.startTime) || (field.key === 'endTime' && ft.task.endTime)">
<span
class="field-value"
:class="{
'task-field-completed': ft.task.completed,
'overdue-date': field.key === 'endTime' && isOverdue(ft.task),
}"
>
{{ field.key === 'startTime' ? ft.task.startTime : ft.task.endTime }}
</span>
</template>
<template v-else>
<span class="field-icon-placeholder" :class="{ 'task-field-completed': ft.task.completed }">
<Icon icon="tasklist-calendar|svg" />
</span>
</template>
</template>
</div>
</template>
<template v-else-if="field.key === 'followersName'">
<div
v-if="_canEditTask(ft.task)"
class="cell-inner editable-cell"
@click.stop="openUserSelectModal('followers', ft.task.id)"
>
<template v-if="ft.task.followersName">
<a-tooltip :title="ft.task.followersName" placement="top"
><AvatarDisplay :names="ft.task.followersName" :size="24" :completed="ft.task.completed"
/></a-tooltip>
</template>
<template v-else>
<span class="field-icon-placeholder" :class="{ 'task-field-completed': ft.task.completed }">
<Icon icon="tasklist-add-user|svg" />
</span>
</template>
</div>
<div v-else class="cell-inner">
<template v-if="ft.task.followersName">
<a-tooltip :title="ft.task.followersName" placement="top"
><AvatarDisplay :names="ft.task.followersName" :size="24" :completed="ft.task.completed"
/></a-tooltip>
</template>
<span v-else class="field-placeholder">-</span>
</div>
</template>
<template v-else-if="field.key === 'participantName'">
<div
v-if="_canEditTask(ft.task)"
class="cell-inner editable-cell"
@click.stop="openUserSelectModal('participant', ft.task.id)"
>
<template v-if="ft.task.participantName">
<a-tooltip :title="ft.task.participantName" placement="top"
><AvatarDisplay :names="ft.task.participantName" :size="24" :completed="ft.task.completed"
/></a-tooltip>
</template>
<template v-else>
<span class="field-icon-placeholder" :class="{ 'task-field-completed': ft.task.completed }">
<Icon icon="tasklist-add-user|svg" />
</span>
</template>
</div>
<div v-else class="cell-inner">
<template v-if="ft.task.participantName">
<a-tooltip :title="ft.task.participantName" placement="top"
><AvatarDisplay :names="ft.task.participantName" :size="24" :completed="ft.task.completed"
/></a-tooltip>
</template>
<span v-else class="field-placeholder">-</span>
</div>
</template>
<template v-else-if="field.key === 'completeTime'">
<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) }}
</span>
</template>
<span v-else class="field-placeholder">-</span>
</div>
</template>
<template v-else-if="field.key === 'remark'">
<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 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
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>
</template>
<template v-else>
<div class="cell-inner">
<span :class="{ 'task-field-completed': ft.task.completed }">{{ getFieldValue(ft.task, field.key) }}</span>
</div>
</template>
</div>
</template>
</div>
</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"
:style="{ minWidth: totalFieldsWidth + 'px' }"
@click="showNewTaskInput = group.id"
>
<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>
<Icon icon="ant-design:plus-outlined" class="new-task-plus" />
<span class="new-task-text">新建事项</span>
</div>
</div>
</div>
</div>
</template>
</draggable>
<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="输入分组名称,按回车确认..."
autofocus
style="flex: 1; max-width: 300px"
@press-enter="submitNewGroup"
@blur="submitNewGroup"
/>
</div>
<div v-else-if="canEdit" class="new-group-row new-group-btn" @click="showNewGroupInput = true">
<Icon icon="ant-design:plus-outlined" class="new-task-plus" />
<span class="new-task-text">新建分组</span>
</div>
</template>
<div v-else class="empty-state">
<Icon icon="ant-design:inbox-outlined" class="empty-icon" />
<p>请从左侧选择一个计划</p>
</div>
</template>
</div>
<SzDeptUserModal
ref="userSelectModalRef"
hide-trigger
:title="userSelectModalType === 'assignee' ? '选择负责人' : userSelectModalType === 'participant' ? '选择参与人' : '选择关注人'"
:value="userSelectModalCurrentIds"
value-key="id"
:multi="true"
:sec-level="currentList?.secretLevel || 1"
:role="1"
@confirm="onUserSelectConfirm"
/>
<HelpModal v-model:open="helpModalOpen" />
</a-spin>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick } from 'vue';
import HelpModal from './HelpModal.vue';
import dayjs from 'dayjs';
import Icon from '/@/components/Icon/index';
import AvatarDisplay from './AvatarDisplay.vue';
import draggable from 'vuedraggable';
import SzDeptUserModal from '/@/components/semri/deptUserComponent/SzDeptUserModal.vue';
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, SECRET_LEVEL_COLOR } from '../types';
import { PRIORITY_OPTIONS, getPriorityBg, getPriorityLabel, isOverdue } from '../utils/taskUtils';
import type {
TaskList,
TaskGroup,
TaskItem,
StatusFilter,
GroupDimension,
SortField,
SortDirection,
FieldKey,
FlatTaskItem,
TaskListGroup,
FilterCondition,
FilterFieldType,
FilterOperator,
} from '../types';
const props = defineProps<{
currentList: TaskList | null;
taskListGroups: TaskListGroup[];
statusFilter: StatusFilter;
groupDimension: GroupDimension;
sortField: SortField;
sortDirection: SortDirection;
visibleFields: FieldKey[];
showListGroupView?: boolean;
favoriteIdMap?: Map<string, string>;
loading?: boolean;
}>();
const emit = defineEmits<{
'create-task': [groupId: string, taskName: string];
'toggle-task': [taskId: string];
'task-click': [taskId: string];
'add-group': [listId: string, groupName: string];
'update-task-field': [taskId: string, field: string, value: any];
'update-task-fields': [taskId: string, fields: Record<string, any>];
'update:statusFilter': [value: StatusFilter];
'update:groupDimension': [value: GroupDimension];
'update:sortField': [value: SortField];
'update:sortDirection': [value: SortDirection];
'update:visibleFields': [value: FieldKey[]];
'update:list-group-tab': [value: string];
'list-select': [listId: string];
'open-collaborator': [];
'open-collaborator-for-list': [listId: string];
'add-to-favorites': [taskListId: string];
'remove-from-favorites': [favoriteId: string];
'move-task': [taskId: string, targetPid: string, targetSortOrder: number];
'move-task-group': [taskGroupId: string, targetSortOrder: number];
'rename-list': [listId: string, newName: string];
'delete-list': [listId: string];
'rename-group': [groupId: string, newName: string];
'delete-group': [groupId: string];
'create-list': [];
}>();
const userStore = useUserStore();
const { createConfirm } = useMessage();
const helpModalOpen = ref(false);
function onCreateListClick() {
emit('create-list');
}
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 searchKeyword = ref('');
const filterVisible = ref(false);
const fieldConfigVisible = ref(false);
const collapsedGroups = ref<Set<string>>(new Set());
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 }) => {
message.warning('子事项只能在当前父事项下移动');
});
}
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(() => {
if (!editingCell.value) return [null, null] as [any, any];
const task = findTaskById(editingCell.value.taskId);
return [task?.startTime ? dayjs(task.startTime.substring(0, 10)) : null, task?.endTime ? dayjs(task.endTime.substring(0, 10)) : null] as [
any,
any,
];
});
const userSelectModalRef = ref();
const userSelectModalType = ref<'assignee' | 'followers' | 'participant'>('assignee');
const userSelectModalTaskId = ref('');
const userSelectModalCurrentIds = ref('');
const userSelectModalCurrentNames = ref('');
function getPopupContainer() {
return document.body;
}
function getFirstAssignee(list: TaskList): string {
if (list.ownerName) {
return list.ownerName;
}
for (const group of list.groups) {
for (const task of group.tasks) {
if (task.assigneeName) return task.assigneeName.split(/[,,、]/)[0].trim();
}
}
return '';
}
function getAllCollaborators(list: TaskList): string {
if (list.collaboratorNames) {
return list.collaboratorNames;
}
const names: string[] = [];
for (const group of list.groups) {
for (const task of group.tasks) {
if (task.assigneeName) {
for (const n of task.assigneeName
.split(/[,,、]/)
.map((s) => s.trim())
.filter(Boolean)) {
if (!names.includes(n)) names.push(n);
}
}
}
}
return names.join(',');
}
function getCollaboratorCount(list: TaskList): number {
const names = getAllCollaborators(list);
if (!names) return 0;
return names.split(/[,,、]/).filter(Boolean).length;
}
function getFormattedCreateTime(list: TaskList): string {
if (list.createTimeStr) {
return list.createTimeStr;
}
if (!list.groups.length || !list.groups[0].tasks.length) return '-';
const task = list.groups[0].tasks[0];
if (task.createTime) {
const d = new Date(task.createTime);
const month = d.getMonth() + 1;
const day = d.getDate();
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
return `${month}月${day}日 ${hours}:${minutes}`;
}
return '-';
}
const filterConditions = ref<FilterCondition[]>([]);
const appliedConditions = ref<FilterCondition[]>([]);
const activeFilterCount = computed(() => {
return appliedConditions.value.filter((c) => {
if (c.operator === 'isEmpty' || c.operator === 'isNotEmpty') return true;
return !!c.value;
}).length;
});
let condIdCounter = 0;
function addCondition() {
const fieldConfig = FILTER_FIELD_CONFIGS[0];
filterConditions.value.push({
id: `cond-${++condIdCounter}`,
field: fieldConfig.value,
operator: fieldConfig.operators[0],
value: '',
valueEnd: undefined,
});
}
function removeCondition(idx: number) {
filterConditions.value.splice(idx, 1);
}
function getOperatorsForField(field: FilterFieldType): FilterOperator[] {
const config = FILTER_FIELD_CONFIGS.find((c) => c.value === field);
return config ? config.operators : [];
}
function getFieldType(field: FilterFieldType): 'text' | 'select' | 'date' {
const config = FILTER_FIELD_CONFIGS.find((c) => c.value === field);
return config ? config.type : 'text';
}
function needsValueInput(cond: FilterCondition): boolean {
return cond.operator !== 'isEmpty' && cond.operator !== 'isNotEmpty';
}
function onConditionFieldChange(idx: number, field: FilterFieldType) {
const config = FILTER_FIELD_CONFIGS.find((c) => c.value === field);
const cond = filterConditions.value[idx];
if (cond && config) {
cond.field = field;
cond.operator = config.operators[0];
cond.value = '';
cond.valueEnd = undefined;
}
}
function onConditionOperatorChange(idx: number, operator: FilterOperator) {
const cond = filterConditions.value[idx];
if (cond) {
cond.operator = operator;
cond.value = '';
cond.valueEnd = undefined;
}
}
function onConditionValueChange(idx: number, value: any) {
const cond = filterConditions.value[idx];
if (cond) {
cond.value = value;
}
}
function onConditionValueEndChange(idx: number, value: any) {
const cond = filterConditions.value[idx];
if (cond) {
cond.valueEnd = value;
}
}
function applyConditions() {
appliedConditions.value = filterConditions.value.map((c) => ({ ...c }));
filterVisible.value = false;
}
function clearAllFilters() {
filterConditions.value = [];
appliedConditions.value = [];
}
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;
});
const collaboratorDisplayNames = computed(() => {
if (!props.currentList) return '';
const names: string[] = [];
if (props.currentList.ownerName) {
names.push(props.currentList.ownerName);
}
if (props.currentList.collaboratorNames) {
const collabNames = props.currentList.collaboratorNames
.split(/[,,、]/)
.map((s) => s.trim())
.filter(Boolean);
for (const n of collabNames) {
if (!names.includes(n)) names.push(n);
}
}
return names.join(',');
});
const collaboratorCount = computed(() => {
if (!collaboratorDisplayNames.value) return 0;
return collaboratorDisplayNames.value.split(/[,,、]/).filter(Boolean).length;
});
const showCollaboratorName = computed(() => collaboratorCount.value === 1);
function isFavorited(listId: string): boolean {
return !!props.favoriteIdMap?.has(listId);
}
function toggleFavorite(listId: string) {
if (isFavorited(listId)) {
const favId = props.favoriteIdMap!.get(listId)!;
emit('remove-from-favorites', favId);
} else {
emit('add-to-favorites', listId);
}
}
const _isOwner = computed(() => props.currentList?.myPermission === '1');
const canEdit = computed(() => props.currentList?.myPermission === '1' || props.currentList?.myPermission === '2');
const isReadOnly = computed(() => props.currentList?.myPermission === '3');
function isListOwner(list: TaskList): boolean {
return list.myPermission === '1';
}
const canShowHeaderMore = computed(() => {
if (!props.currentList) return false;
const perm = props.currentList.myPermission;
return perm === '1' || perm === '2';
});
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') {
headerRenaming.value = true;
headerRenameValue.value = props.currentList?.name || '';
} else if (key === 'delete') {
createConfirm({
iconType: 'warning',
title: '确认删除',
content: '删除计划后,其下所有事项也将被删除,确定要删除吗?',
okText: '确认',
cancelText: '取消',
onOk: () => {
if (props.currentList) {
emit('delete-list', props.currentList.id);
}
},
});
}
}
function confirmHeaderRename() {
const name = headerRenameValue.value.trim();
if (name && props.currentList && name !== props.currentList.name) {
emit('rename-list', props.currentList.id, name);
}
headerRenaming.value = false;
}
function _canEditTask(task: TaskItem): boolean {
if (canEdit.value) return true;
if (isReadOnly.value && task.assigneeId) {
const userId = userStore.getUserInfo?.id || '';
return task.assigneeId.includes(userId);
}
return false;
}
const taskContentFieldConfigs = computed(() => {
return FIELD_CONFIG.filter((f) => f.key !== 'listName');
});
const visibleFieldConfigs = computed(() => {
return taskContentFieldConfigs.value.filter((f) => props.visibleFields.includes(f.key));
});
const totalFieldsWidth = computed(() => {
let w = 400 + 24;
for (const f of visibleFieldConfigs.value) {
w += parseInt(fieldWidthMap[f.key] || '100', 10);
}
return w + 48;
});
const fieldWidthMap: Record<string, string> = {
taskDesc: '140px',
priority: '120px',
assigneeName: '120px',
assignName: '120px',
participantName: '120px',
followersName: '120px',
createBy: '120px',
startTime: '180px',
endTime: '180px',
completeTime: '140px',
createTime: '160px',
updateTime: '160px',
remark: '160px',
subTaskProgress: '110px',
};
const fieldIconMap: Record<string, string> = {
taskDesc: 'ant-design:align-left-outlined',
priority: 'ant-design:flag-outlined',
assigneeName: 'inline-user',
assignName: 'inline-user',
participantName: 'inline-user',
followersName: 'inline-user',
createBy: 'inline-user',
startTime: 'ant-design:calendar-outlined',
endTime: 'ant-design:clock-circle-outlined',
completeTime: 'ant-design:check-circle-outlined',
createTime: 'ant-design:plus-circle-outlined',
updateTime: 'ant-design:sync-outlined',
remark: 'ant-design:file-text-outlined',
subTaskProgress: 'ant-design:pie-chart-outlined',
};
const statusFilterLabel = computed(() => {
const map: Record<StatusFilter, string> = { all: '全部', completed: '已完成', uncompleted: '未完成' };
return map[props.statusFilter];
});
const currentSortLabel = computed(() => {
const opt = SORT_OPTIONS.find((o) => o.value === props.sortField);
return opt ? opt.label : '';
});
const currentGroupLabel = computed(() => {
const opt = GROUP_OPTIONS.find((o) => o.value === props.groupDimension);
return opt ? opt.label : '';
});
function startEditCell(taskId: string, field: string) {
if (field === 'startTime' || field === 'endTime') {
pickerEpoch.value++;
editingCell.value = { taskId, field };
datePickerVisible.value = true;
return;
}
cancelEdit();
editingCell.value = { taskId, field };
}
function cancelEdit() {
editingCell.value = null;
editingValue.value = '';
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);
}
nextTick(() => cancelEdit());
}
function findTaskById(taskId: string): TaskItem | undefined {
for (const group of props.currentList.groups) {
for (const t of group.tasks) {
if (t.id === taskId) return t;
}
}
return undefined;
}
function openUserSelectModal(type: 'assignee' | 'followers' | 'participant', taskId: string) {
userSelectModalType.value = type;
userSelectModalTaskId.value = taskId;
const task = findTaskById(taskId);
if (type === 'assignee') {
userSelectModalCurrentIds.value = task?.assigneeId || '';
userSelectModalCurrentNames.value = task?.assigneeName || '';
} else if (type === 'participant') {
userSelectModalCurrentIds.value = task?.participantId || '';
userSelectModalCurrentNames.value = task?.participantName || '';
} else {
userSelectModalCurrentIds.value = task?.followersId || '';
userSelectModalCurrentNames.value = task?.followersName || '';
}
nextTick(() => {
userSelectModalRef.value?.open();
});
}
function onUserSelectConfirm(userIds: string, userNames: string) {
const type = userSelectModalType.value;
const taskId = userSelectModalTaskId.value;
if (taskId) {
if (type === 'assignee') {
emit('update-task-fields', taskId, { assigneeId: userIds, assigneeName: userNames });
} else if (type === 'participant') {
emit('update-task-fields', taskId, { participantId: userIds, participantName: userNames });
} else {
emit('update-task-fields', taskId, { followersId: userIds, followersName: userNames });
}
}
}
function getDisabledEndDate(taskId: string) {
return (current: any) => {
if (!current) return false;
const task = findTaskById(taskId);
if (!task?.startTime) return false;
const startDate = dayjs(task.startTime.substring(0, 10));
return current.isBefore(startDate, 'day');
};
}
function clearDateCell(field: string) {
const taskId = editingCell.value?.taskId;
if (!taskId) return;
const task = findTaskById(taskId);
const updates: Record<string, string> = {};
if (field === 'startTime') {
updates.startTime = '';
updates.endTime = normalizeDate(task?.endTime);
} else if (field === 'endTime') {
updates.startTime = normalizeDate(task?.startTime);
updates.endTime = '';
}
emit('update-task-fields', taskId, updates);
pickerEpoch.value++;
datePickerVisible.value = false;
editingCell.value = null;
}
function onDateCellChange(date: any, field: string) {
const taskId = editingCell.value?.taskId;
if (!taskId) return;
const task = findTaskById(taskId);
const value = date ? dayjs(date).format('YYYY-MM-DD') + ' 00:00:00' : '';
const updates: Record<string, string> = {};
if (field === 'startTime') {
updates.startTime = value;
updates.endTime = normalizeDate(task?.endTime);
} else if (field === 'endTime') {
updates.startTime = normalizeDate(task?.startTime);
updates.endTime = value;
}
emit('update-task-fields', taskId, updates);
cancelEdit();
}
function onCellPickerOpenChange(open: boolean) {
if (!open) {
const epoch = pickerEpoch.value;
setTimeout(() => {
if (pickerEpoch.value === epoch && editingCell.value) {
cancelEdit();
}
}, 100);
}
}
function normalizeDate(val: string | null | undefined): string {
if (!val) return '';
if (val.length >= 19) return val.substring(0, 19);
return val.substring(0, 10) + ' 00:00:00';
}
function onTaskDragStart(evt: any) {
dragBlockedOnce.value = false;
const taskId = evt.item?.dataset?.taskId;
if (taskId) draggingTaskId.value = taskId;
const task = findTaskById(taskId);
if (task && task.childCount > 0) {
expandedTaskIds.value.delete(taskId);
}
}
function onTaskDragEnd(evt: any) {
const { oldIndex, newIndex } = evt;
draggingTaskId.value = '';
if (dragBlockedOnce.value) {
dragBlockedOnce.value = false;
showDragBlockedMessage();
return;
}
if (oldIndex === newIndex && evt.from === evt.to) {
return;
}
const movedElement = evt.item;
const taskId = movedElement?.dataset?.taskId;
if (!taskId) {
return;
}
const toContainer = evt.to;
const targetGroupEl = toContainer?.closest('[data-group-id]');
const targetGroupId = targetGroupEl?.dataset?.groupId || '';
const targetGroup = computedGroups.value.find((g) => g.id === targetGroupId);
if (!targetGroup) {
return;
}
const rawFlatList = flattenGroupTasks(targetGroup.tasks);
const groupIds = new Set(computedGroups.value.map((g) => g.id));
const draggedIdx = rawFlatList.findIndex((f) => f.task.id === taskId);
let flatList: FlatTaskItem[];
if (draggedIdx !== -1) {
flatList = [...rawFlatList];
const [item] = flatList.splice(draggedIdx, 1);
flatList.splice(newIndex, 0, item);
} else {
flatList = rawFlatList;
}
let draggedTask: TaskItem | null = null;
if (draggedIdx !== -1) {
draggedTask = flatList[newIndex]?.task || null;
} else {
for (const g of computedGroups.value) {
const found = flattenGroupTasks(g.tasks).find((f) => f.task.id === taskId);
if (found) {
draggedTask = found.task;
break;
}
}
}
if (!draggedTask) {
return;
}
const isSubtask = !!(draggedTask.pid && !groupIds.has(draggedTask.pid));
let targetPid: string;
let targetSortOrder = 0;
if (isSubtask) {
targetPid = draggedTask.pid;
for (let i = 0; i < newIndex; i++) {
if (flatList[i].task.pid === targetPid) {
targetSortOrder++;
}
}
targetSortOrder += 1;
} else {
targetPid = targetGroupId && targetGroupId !== '__default__' ? targetGroupId : '';
for (let i = 0; i < newIndex; i++) {
const ft = flatList[i];
const isTopLevel = !ft.task.pid || ft.task.pid === ft.task.groupId || groupIds.has(ft.task.pid);
if (isTopLevel) {
targetSortOrder++;
}
}
targetSortOrder += 1;
}
emit('move-task', taskId, targetPid, targetSortOrder);
}
function onTaskMove(evt: any) {
const relatedEl = evt.related as HTMLElement;
const draggedTaskId = draggingTaskId.value;
if (!draggedTaskId) {
return true;
}
const draggedTask = findTaskById(draggedTaskId);
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 (!relatedEl) {
dragBlockedOnce.value = true;
return false;
}
const relatedTaskId = relatedEl.dataset?.taskId;
if (!relatedTaskId) {
dragBlockedOnce.value = true;
return false;
}
const relatedTask = findTaskById(relatedTaskId);
if (!relatedTask) {
dragBlockedOnce.value = true;
return false;
}
if (relatedTask.pid !== draggedTask.pid) {
dragBlockedOnce.value = true;
return false;
}
return true;
}
function isChildHidden(ft: FlatTaskItem): boolean {
if (!draggingTaskId.value) return false;
return ft.task.pid === draggingTaskId.value;
}
function onGroupDragEnd(evt: any) {
const { oldIndex, newIndex } = evt;
if (oldIndex === newIndex) return;
const groupId = localGroups.value[newIndex]?.id;
if (!groupId) return;
// 拖拽结束后收起分组,防止展开跳动
collapsedGroups.value.add(groupId);
emit('move-task-group', groupId, newIndex + 1);
}
const computedGroups = computed(() => {
if (!props.currentList || !Array.isArray(props.currentList.groups)) return [];
let allTasks: TaskItem[] = [];
for (const g of props.currentList.groups) {
allTasks = allTasks.concat(g.tasks || []);
}
if (props.statusFilter === 'completed') {
allTasks = allTasks.filter((t) => t.completed);
} else if (props.statusFilter === 'uncompleted') {
allTasks = allTasks.filter((t) => !t.completed);
}
allTasks = applyFilterToTasks(allTasks);
if (props.groupDimension === 'custom') {
const groups: TaskGroup[] = props.currentList.groups.map((g) => ({
...g,
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) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
return groups;
}
allTasks = applySortToTasks(allTasks);
if (props.groupDimension === 'none') {
return [
{
id: 'none-group',
name: '',
taskListId: props.currentList.id,
collapsed: false,
tasks: allTasks,
},
];
}
return computeDynamicGroups(allTasks);
});
watch(
computedGroups,
(newGroups) => {
localGroups.value = [...newGroups];
},
{ immediate: true }
);
function flattenGroupTasks(tasks?: TaskItem[]): FlatTaskItem[] {
const sourceTasks = tasks || [];
const result: FlatTaskItem[] = [];
const topTasks = sourceTasks.filter((t) => !t.pid || t.pid === t.groupId).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
for (const task of topTasks) {
result.push({ task, level: 0 });
if (expandedTaskIds.value.has(task.id)) {
collectChildren(sourceTasks, task.id, 1, result);
}
}
return result;
}
function collectChildren(allTasks: TaskItem[], parentId: string, level: number, result: FlatTaskItem[]) {
if (level >= MAX_TASK_DEPTH) return;
const children = allTasks.filter((t) => t.pid === parentId).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
for (const child of children) {
result.push({ task: child, level });
if (expandedTaskIds.value.has(child.id)) {
collectChildren(allTasks, child.id, level + 1, result);
}
}
}
function toggleTaskExpand(taskId: string) {
if (expandedTaskIds.value.has(taskId)) {
expandedTaskIds.value.delete(taskId);
} else {
expandedTaskIds.value.add(taskId);
}
}
function countTopLevelTasks(tasks: TaskItem[]): number {
return tasks.filter((t) => !t.pid || t.pid === t.groupId).length;
}
function applyFilterToTasks(tasks: TaskItem[]): TaskItem[] {
let result = tasks;
for (const cond of appliedConditions.value) {
if (cond.operator !== 'isEmpty' && cond.operator !== 'isNotEmpty' && !cond.value) continue;
const fieldName = cond.field as keyof TaskItem;
if (cond.operator === 'isEmpty') {
result = result.filter((t) => !t[fieldName]);
} else if (cond.operator === 'isNotEmpty') {
result = result.filter((t) => !!t[fieldName]);
} else if (cond.operator === 'contains') {
result = result.filter((t) => String(t[fieldName] || '').includes(String(cond.value)));
} else if (cond.operator === 'equals') {
result = result.filter((t) => String(t[fieldName] || '') === String(cond.value));
} else if (cond.operator === 'before') {
const target = cond.value ? cond.value.format('YYYY-MM-DD') : '';
result = result.filter((t) => {
const v = String(t[fieldName] || '').substring(0, 10);
return v && v < target;
});
} else if (cond.operator === 'after') {
const target = cond.value ? cond.value.format('YYYY-MM-DD') : '';
result = result.filter((t) => {
const v = String(t[fieldName] || '').substring(0, 10);
return v && v > target;
});
} else if (cond.operator === 'between') {
const start = cond.value ? cond.value.format('YYYY-MM-DD') : '';
const end = cond.valueEnd ? cond.valueEnd.format('YYYY-MM-DD') : '';
if (start && end) {
result = result.filter((t) => {
const v = String(t[fieldName] || '').substring(0, 10);
return v && v >= start && v <= end;
});
}
}
}
return result;
}
function applySortToTasks(tasks: TaskItem[]): TaskItem[] {
const field = props.sortField;
if (field === 'sortOrder') {
return [...tasks].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
const dir = props.sortDirection === 'asc' ? 1 : -1;
return [...tasks].sort((a, b) => {
const va = (a as any)[field] || '';
const vb = (b as any)[field] || '';
if (va < vb) return -1 * dir;
if (va > vb) return 1 * dir;
return 0;
});
}
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 groups: TaskGroup[] = [];
groupMap.forEach((groupTasks, key) => {
groups.push({
id: `dynamic-${key}`,
name: key || '未分组',
taskListId: props.currentList!.id,
collapsed: collapsedGroups.value.has(`dynamic-${key}`),
tasks: groupTasks,
});
});
return groups;
}
function getGroupKey(task: TaskItem, dimension: GroupDimension): string {
switch (dimension) {
case 'assignee':
return task.assigneeName || '未分配';
case 'startTime':
return task.startTime ? task.startTime.substring(0, 10) : '无日期';
case 'endTime':
return task.endTime ? task.endTime.substring(0, 10) : '无日期';
case 'createBy':
return task.createBy || '未知';
case 'priority':
return task.priority || '无优先级';
default:
return '默认';
}
}
function getFieldValue(task: TaskItem, key: FieldKey): string {
return (task as any)[key] || '';
}
function toggleGroupCollapse(groupId: string) {
if (collapsedGroups.value.has(groupId)) {
collapsedGroups.value.delete(groupId);
} else {
collapsedGroups.value.add(groupId);
}
}
function submitNewTask(groupId: string) {
const title = newTaskTitle.value.trim();
newTaskTitle.value = '';
showNewTaskInput.value = null;
if (title) {
emit('create-task', groupId, title);
}
}
function submitNewGroup() {
if (submittingGroup.value) return;
submittingGroup.value = true;
const name = newGroupName.value.trim();
newGroupName.value = '';
showNewGroupInput.value = false;
if (name && props.currentList) {
emit('add-group', props.currentList.id, name);
}
setTimeout(() => {
submittingGroup.value = false;
}, 300);
}
function onGroupMenuClick(key: string, groupId: string, groupName: string) {
if (key === 'rename') {
renamingGroupId.value = groupId;
renamingGroupName.value = groupName;
} else if (key === 'delete') {
createConfirm({
iconType: 'warning',
title: '确认删除',
content: '删除分组后,分组内的事项将合并到默认分组,确定要删除吗?',
okText: '确认',
cancelText: '取消',
onOk: () => {
emit('delete-group', groupId);
},
});
}
}
function confirmRenameGroup() {
if (submittingRename.value) return;
submittingRename.value = true;
const name = renamingGroupName.value.trim();
const groupId = renamingGroupId.value;
renamingGroupId.value = null;
renamingGroupName.value = '';
if (name && groupId) {
emit('rename-group', groupId, name);
}
setTimeout(() => {
submittingRename.value = false;
}, 300);
}
function onStatusFilterChange({ key }: { key: string }) {
emit('update:statusFilter', key as StatusFilter);
}
function onSortChange({ key }: { key: string }) {
if (props.sortField === key) {
if (key !== 'sortOrder') {
emit('update:sortDirection', props.sortDirection === 'asc' ? 'desc' : 'asc');
}
} else {
emit('update:sortField', key as SortField);
emit('update:sortDirection', 'desc');
}
}
function onSortDirectionChange(field: string, dir: string) {
emit('update:sortField', field as SortField);
emit('update:sortDirection', dir as SortDirection);
}
function onGroupChange({ key }: { key: string }) {
emit('update:groupDimension', key as GroupDimension);
}
function toggleField(fieldKey: FieldKey) {
const fields = [...props.visibleFields];
const idx = fields.indexOf(fieldKey);
if (idx >= 0) {
fields.splice(idx, 1);
} else {
fields.push(fieldKey);
}
emit('update:visibleFields', fields);
}
</script>
<style lang="less" scoped>
.task-content {
position: relative;
flex: 1;
height: 100%;
background: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content-header {
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
justify-content: space-between;
}
.content-header-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.content-header-row-left {
display: flex;
align-items: center;
gap: 4px;
}
.add-list-btn {
color: #888;
font-size: 16px;
padding: 0 4px;
&:hover {
color: #1677ff;
}
}
.list-search-input {
width: 220px;
margin-left: 16px;
}
.help-btn {
color: #888;
font-size: 18px;
&:hover {
color: #1677ff;
}
}
.content-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.header-secret-badge {
font-size: 11px;
padding: 0 6px;
border-radius: 3px;
border: 1px solid;
line-height: 18px;
flex-shrink: 0;
}
.collaborator-bar {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 2px 4px 2px 8px;
border-radius: 6px;
border: 1px solid #e8e8e8;
transition: all 0.15s;
&:hover {
border-color: #d0d0d0;
background: #fafafa;
}
&.disabled {
cursor: not-allowed;
opacity: 0.5;
&:hover {
border-color: #e8e8e8;
background: transparent;
}
}
}
.collab-btn {
color: #595959 !important;
font-size: 13px;
padding: 0 4px;
height: 28px;
}
.content-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1f1f1f;
}
.header-more-btn {
color: #1f1f1f;
font-size: 18px;
margin-left: 6px;
&:hover {
color: #3370ff;
background: #f0f0f0;
}
}
.content-header-left {
display: flex;
align-items: center;
gap: 0;
}
.content-header-rename-row {
flex-shrink: 0;
}
.content-toolbar {
padding: 8px 24px;
border-bottom: 1px solid #f0f0f0;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.filter-badge {
display: inline-block;
margin-left: 4px;
padding: 0 6px;
font-size: 12px;
line-height: 18px;
background: #1677ff;
color: #fff;
border-radius: 9px;
}
.filter-panel {
.filter-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.filter-panel-title {
font-weight: 600;
font-size: 14px;
}
.filter-panel-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
}
.filter-condition-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.filter-field-select {
width: 100px;
flex-shrink: 0;
}
.filter-op-select {
width: 80px;
flex-shrink: 0;
}
.filter-value-input {
flex: 1;
min-width: 100px;
}
.filter-value-input-short {
width: 100px;
}
.filter-range-sep {
color: #999;
font-size: 12px;
flex-shrink: 0;
}
.filter-condition-remove {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #999;
border-radius: 4px;
flex-shrink: 0;
transition:
color 0.15s,
background 0.15s;
&:hover {
color: #ff4d4f;
background: #fff1f0;
}
}
.filter-add-btn {
margin-bottom: 4px;
color: #1677ff;
border-color: #1677ff;
}
.sort-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 160px;
}
.sort-arrows-row {
display: flex;
align-items: center;
gap: 4px;
}
.sort-arrow-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
color: #bfbfbf;
font-size: 14px;
transition:
color 0.15s,
background 0.15s;
&.active {
color: #1677ff;
background: #e6f4ff;
}
&:hover {
color: #1677ff;
}
}
.field-config-panel {
.field-config-title {
font-weight: 600;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.field-config-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 4px;
cursor: pointer;
border-radius: 4px;
&:hover {
background: #f5f5f5;
}
}
.field-visible {
color: #1677ff;
}
.field-hidden {
color: #d9d9d9;
}
}
.content-table-header {
padding: 0 8px;
height: 40px;
background: #fafafa;
border-bottom: 1px solid #e5e6eb;
display: flex;
align-items: stretch;
font-size: 14px;
color: #1f2329;
font-weight: 500;
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 10;
.header-col {
padding: 0 12px;
gap: 4px;
box-sizing: border-box;
}
.header-icon {
color: #8f959e;
font-size: 14px;
flex-shrink: 0;
}
.header-label {
white-space: nowrap;
overflow: visible;
}
}
.table-col {
flex-shrink: 0;
display: flex;
align-items: center;
height: 100%;
}
.table-col-drag-handle {
width: 24px;
min-width: 24px;
justify-content: center;
}
.drag-handle {
cursor: grab;
color: transparent;
transition: color 0.15s;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
&:active {
cursor: grabbing;
}
}
.task-row:hover .drag-handle {
color: #c0c4cc;
}
.drag-handle:hover {
color: #3370ff;
}
.sortable-chosen {
background: #e6f4ff !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.sortable-ghost {
opacity: 0.3;
}
.table-col-title {
width: 400px;
min-width: 400px;
height: 100%;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.content-body {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.task-group-header {
display: flex;
align-items: center;
padding: 6px 8px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
font-weight: 600;
color: #1f2329;
&:hover {
background: #f5f6f7;
}
.group-drag-handle {
cursor: grab;
font-size: 14px;
color: transparent;
transition: color 0.15s;
display: flex;
align-items: center;
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: #1f2329;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 1;
min-width: 0;
}
.group-count {
color: #8f959e;
font-weight: 400;
font-size: 13px;
flex-shrink: 0;
margin-left: 4px;
}
.group-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
&:hover .group-actions {
opacity: 1;
}
.group-action-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
color: #8f959e;
font-size: 14px;
transition: all 0.15s;
&:hover {
background: #e8e8e8;
color: #595959;
}
}
}
.task-row {
display: flex;
align-items: stretch;
height: 46px;
padding: 0 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
box-sizing: border-box;
&:hover {
background: #f5f6f7;
}
}
.task-indent {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.expand-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 11px;
color: #999;
cursor: pointer;
border-radius: 2px;
&:hover {
color: #1677ff;
background: #e6f4ff;
}
}
.expand-arrow-placeholder {
width: 16px;
height: 16px;
}
.task-checkbox {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #d9d9d9;
background: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
padding: 0;
flex-shrink: 0;
&:hover {
border-color: #52c41a;
}
&.checked {
background: #52c41a;
border-color: #52c41a;
color: #fff;
font-size: 11px;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
&:hover {
border-color: #d9d9d9;
}
}
}
.task-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #1f2329;
flex-shrink: 1;
min-width: 0;
padding: 2px 6px;
border-radius: 4px;
transition: background 0.15s;
&.completed {
text-decoration: line-through;
color: #c0c4cc;
}
}
.table-col-title:hover .task-title {
background: #fff;
}
.task-child-count {
font-size: 11px;
color: #8c8c8c;
margin-left: 6px;
white-space: nowrap;
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 2px;
}
.child-count-icon {
font-size: 12px;
color: #8f959e;
}
.task-field {
color: #646a73;
overflow: visible;
padding: 0;
box-sizing: border-box;
}
.task-field-completed {
color: #c0c4cc;
}
.overdue-date {
color: #f5222d !important;
font-weight: 500;
}
.cell-inner {
display: flex;
align-items: center;
width: 100%;
height: 100%;
padding: 0 12px;
border: 1px solid transparent;
border-radius: 4px;
transition: border-color 0.15s;
box-sizing: border-box;
&:hover {
border-color: #d0d3d6;
}
}
.date-cell-wrapper {
position: relative;
}
.inline-date-picker {
width: 100% !important;
:deep(.ant-picker) {
width: 100%;
}
:deep(.ant-picker-footer-extra) {
display: flex;
justify-content: flex-end;
}
}
.editable-cell {
cursor: pointer;
&:hover {
border-color: #d0d3d6;
}
.inline-editor {
width: 100%;
:deep(.ant-select) {
min-width: 80px;
}
}
.inline-text-editor {
width: 100%;
:deep(.ant-input) {
font-size: 13px;
}
}
}
.dropdown-cell {
position: relative;
&::after {
content: '';
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
border: 4px solid transparent;
border-top-color: #999;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
&:hover::after {
opacity: 1;
}
}
.readonly-cell {
&:hover {
border-color: transparent;
}
}
.date-picker-footer {
display: flex;
justify-content: flex-end;
padding: 0 4px;
}
.field-value {
color: #646a73;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.field-placeholder {
color: #c0c4cc;
font-size: 14px;
}
.priority-tag {
display: inline-block;
padding: 1px 8px;
border-radius: 4px;
font-size: 14px;
line-height: 20px;
white-space: nowrap;
font-weight: 500;
&.task-field-completed {
opacity: 0.5;
}
}
.priority-menu-item {
display: flex;
align-items: center;
min-height: 32px;
}
.field-icon-placeholder {
color: #8f959e;
font-size: 16px;
display: flex;
align-items: center;
transition: color 0.15s;
&:hover {
color: #3370ff;
}
}
.assignee-single-wrap {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px 2px 2px;
background: #f5f6f7;
border-radius: 14px;
max-width: 100%;
overflow: hidden;
&.task-field-completed {
opacity: 0.5;
}
}
.assignee-display {
display: flex;
align-items: center;
gap: 0;
overflow: hidden;
}
.assignee-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: #3370ff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
box-shadow: 0 0 0 2px #fff;
.avatar-text {
font-size: 12px;
color: #fff;
font-weight: 500;
line-height: 1;
}
&.task-field-completed {
opacity: 0.5;
}
}
.assignee-name {
color: #646a73;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.assignee-overflow {
width: 24px;
height: 24px;
border-radius: 50%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 10px;
color: #8c8c8c;
font-weight: 500;
margin-left: -8px;
position: relative;
z-index: 0;
box-shadow: 0 0 0 2px #fff;
}
.progress-cell {
display: flex;
align-items: center;
}
.progress-bar {
width: 60px;
height: 6px;
background: #f0f0f0;
border-radius: 3px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.new-task-btn,
.new-task-input-row {
cursor: pointer;
color: #999;
}
.new-task-plus {
color: #bbb;
margin-right: 8px;
font-size: 12px;
}
.new-task-text {
font-size: 14px;
}
.new-group-row {
display: flex;
align-items: center;
padding: 12px 8px;
font-size: 14px;
&.new-group-btn {
cursor: pointer;
color: #999;
&:hover {
background: #fafafa;
}
}
}
.list-group-view {
flex: 1;
overflow: auto;
}
.list-group-table-header {
display: flex;
align-items: center;
height: 40px;
padding: 0 24px;
background: #fafafa;
border-bottom: 1px solid #e5e6eb;
font-size: 14px;
color: #1f2329;
font-weight: 500;
position: sticky;
top: 0;
z-index: 10;
}
.list-group-col {
flex-shrink: 0;
display: flex;
align-items: center;
height: 100%;
overflow: hidden;
}
.list-group-col-name {
flex: 1;
min-width: 200px;
gap: 8px;
}
.list-group-col-owner {
width: 120px;
padding: 0 12px;
}
.list-group-col-collaborators {
width: 120px;
padding: 0 12px;
display: flex;
align-items: center;
gap: 4px;
&.clickable {
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
&:hover {
background: #f0f5ff;
}
}
}
.collab-add-icon {
font-size: 20px;
color: #bfbfbf;
transition: color 0.15s;
.clickable:hover & {
color: #3370ff;
}
}
.list-group-col-secret {
width: 80px;
padding: 0 12px;
text-align: center;
}
.secret-badge-inline {
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
border: 1px solid;
line-height: 16px;
white-space: nowrap;
}
.list-group-col-time {
width: 160px;
padding: 0 12px;
}
.pin-btn-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 2px;
border-radius: 4px;
margin-left: 4px;
flex-shrink: 0;
transition: background 0.12s;
&:hover {
background: #e8e8e8;
}
}
.pin-svg {
font-size: 14px;
transition: color 0.12s;
}
.pin-svg-outlined {
color: #bfbfbf;
opacity: 0;
transition: all 0.15s;
.pin-btn-wrap:hover & {
color: #3370ff;
}
}
.pin-svg-filled {
color: #3370ff;
}
.list-group-row:hover .pin-svg-outlined {
opacity: 1;
}
.list-group-row {
display: flex;
align-items: center;
height: 46px;
padding: 0 24px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
cursor: pointer;
&:hover {
background: #f5f6f7;
}
}
.list-icon {
color: #8f959e;
flex-shrink: 0;
}
.list-name-text {
color: #1f2329;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
color: #bbb;
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
p {
margin: 0;
font-size: 14px;
}
}
</style>