2903 lines
93 KiB
Vue
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>
|