perf(project): 看板列拆分为 ProjectColumn 组件,列内变化不再触发整个面板重渲染

列与任务卡片原先写在 ProjectPanel 的同一个渲染函数里,卡片可见状态、倒计时等任一变化都会带动全部列的 draggable、Scrollbar 等插槽组件重渲染:
- 新增 ProjectColumn,每列自行维护列与任务的可见状态,面板只通知其检查可视区
- 任务可见性计算抽成 utils/taskVisibility.js,看板与表格共用
- 跨列拖入的任务落下即标记可见,避免先显示空白占位
- 搜索、筛选、打开任务等面板级逻辑通过 provide 的 projectPanel 读取
This commit is contained in:
kuaifan 2026-10-28 14:26:19 +00:00
parent db66b7b215
commit c75d7a95f5
3 changed files with 324 additions and 251 deletions

View File

@ -0,0 +1,260 @@
<template>
<li :data-id="column.id" class="column-item">
<div
:class="['column-head', column.color ? 'custom-color' : '']"
:style="column.color ? {backgroundColor: column.color} : {}">
<div class="column-head-title">
<AutoTip v-html="transformEmojiToHtml(column.name)"></AutoTip>
<em>({{projectPanel.panelTask(column.tasks).length}})</em>
</div>
<div class="column-head-icon">
<div v-if="loading" class="loading"><Loading /></div>
<EDropdown
v-else-if="!readonly"
trigger="click"
size="medium"
@visible-change="onMenuVisible"
@command="$emit('on-command', $event)">
<Icon type="ios-more" />
<EDropdownMenu slot="dropdown" class="project-panel-more-dropdown-menu">
<li v-if="menuMounted" class="project-panel-more-dropdown-warp">
<ul>
<EDropdownItem command="title">
<div class="item">
<Icon type="md-create" />{{$L('修改')}}
</div>
</EDropdownItem>
<EDropdownItem command="archive_completed">
<div class="item">
<Icon type="ios-filing" />{{$L('归档')}}
</div>
</EDropdownItem>
<EDropdownItem command="remove">
<div class="item">
<Icon type="md-trash" />{{$L('删除')}}
</div>
</EDropdownItem>
<EDropdownItem v-for="(c, k) in $store.state.columnColorList" :key="k" :divided="k==0" :command="c">
<div class="item">
<i class="taskfont" :style="{color:c.color||'#ddd'}" v-html="c.color == column.color ? '&#xe61d;' : '&#xe61c;'"></i>{{$L(c.name)}}
</div>
</EDropdownItem>
</ul>
</li>
</EDropdownMenu>
</EDropdown>
<Icon v-if="!readonly" class="last" type="md-add" @click="$emit('on-top-show', true)" />
</div>
</div>
<Scrollbar
class="column-task"
class-name="task-scrollbar"
@on-scroll="handleTaskScroll">
<div v-if="!readonly && topShow" class="task-item additem">
<TaskAddSimple
:column-id="column.id"
:project-id="projectId"
:add-top="true"
@on-close="$emit('on-top-show', false)"
@on-priority="$emit('on-priority', $event)"
auto-active/>
</div>
<Draggable
:list="column.tasks"
:animation="150"
:disabled="dragDisabled"
class="task-list"
draggable=".task-draggable"
filter=".complete"
group="task"
@add="onTaskAdd"
@sort="$emit('on-sort')"
@remove="$emit('on-sort')">
<div
v-for="item in column.tasks"
:key="`${column.id}_${item.id}`"
:data-id="item.id"
:class="['task-item task-draggable', item.complete_at ? 'complete' : '', projectPanel.taskIsHidden(item) ? 'hidden' : '']"
:style="$A.generateColorVarStyle(item.flow_item_color, [10], 'flow-item-custom-color', taskItemStyle(item))"
@click="projectPanel.openTask(item)">
<template v-if="taskItemVisible(item)">
<div :class="['task-head', item.desc ? 'has-desc' : '']">
<div class="task-title">
<!--工作流状态-->
<span v-if="item.flow_item_name" :class="item.flow_item_status" @click.stop="openMenu($event, item)">{{item.flow_item_name}}</span>
<!--任务描述-->
<pre>{{item.name}}</pre>
</div>
<div class="task-menu" @click.stop="">
<TaskMenu v-if="!readonly" :ref="`taskMenu_${item.id}`" :task="item" icon="ios-more"/>
</div>
</div>
<template v-if="!item.complete_at">
<div v-if="item.desc" class="task-desc"><pre v-html="item.desc"></pre></div>
<TaskTag v-if="item.task_tag.length > 0" class="task-tags" :tags="item.task_tag"/>
<div class="task-users">
<ul>
<li v-for="(user, keyu) in ownerUser(item.task_user)" :key="keyu">
<UserAvatar :userid="user.userid" size="32" :borderWidth="2" :borderColor="item.color"/>
</li>
<li v-if="ownerUser(item.task_user).length === 0" class="no-owner">
<Button type="primary" size="small" ghost @click.stop="projectPanel.openTask(item, true)">{{$L('领取任务')}}</Button>
</li>
</ul>
<div v-if="item.file_num > 0" class="task-icon">{{item.file_num}}<Icon type="ios-link-outline" /></div>
<div v-if="item.msg_num > 0" class="task-icon">{{item.msg_num}}<Icon type="ios-chatbubbles-outline" /></div>
</div>
<div class="task-progress">
<div v-if="item.sub_num > 0" class="task-sub-num">{{item.sub_complete}}/{{item.sub_num}}</div>
<Progress :percent="item.percent" :stroke-width="6" />
<ETooltip
v-if="item.end_at"
:class="['task-time', item.today ? 'today' : '', item.overdue ? 'overdue' : '']"
:disabled="$isEEUIApp || windowTouch"
:open-delay="600"
:content="item.end_at">
<div v-if="!item.complete_at"><i class="taskfont">&#xe71d;</i>{{ expiresFormat(item.end_at) }}</div>
</ETooltip>
</div>
<em v-if="item.p_name" class="priority-color" :style="{backgroundColor:item.p_color}"></em>
</template>
</template>
</div>
<div v-if="!readonly" class="task-item additem">
<TaskAddSimple
:column-id="column.id"
:project-id="projectId"
@on-priority="$emit('on-priority', $event)"/>
</div>
</Draggable>
</Scrollbar>
</li>
</template>
<script>
import Draggable from 'vuedraggable'
import TaskAddSimple from "./TaskAddSimple";
import TaskMenu from "./TaskMenu";
import TaskTag from "./ProjectTaskTag/tags.vue";
import transformEmojiToHtml from "../../../utils/emoji";
import {updateTaskVisibilitys} from "../../../utils/taskVisibility";
/**
* 项目看板的一列,独立成组件,使任务可见状态等列内变化只重渲染本列,不牵动整个面板
* 筛选、搜索、打开任务等面板级逻辑通过注入的 projectPanel 读取
*/
export default {
name: "ProjectColumn",
components: {
Draggable,
TaskAddSimple,
TaskMenu,
TaskTag,
},
inject: ['projectPanel'],
props: {
column: {
type: Object,
required: true
},
projectId: {
type: Number,
default: 0
},
readonly: {
type: Boolean,
default: false
},
dragDisabled: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
topShow: {
type: Boolean,
default: false
},
},
data() {
return {
visible: false, // 列是否在看板可视区内
taskVisibilitys: {}, // 列内任务的可见状态与高度
menuMounted: false, // 菜单首次展开时才渲染菜单项
}
},
methods: {
transformEmojiToHtml,
onMenuVisible(visible) {
if (visible) {
this.menuMounted = true
}
},
openMenu(event, task) {
const el = this.$refs[`taskMenu_${task.id}`];
if (el) {
el[0].handleClick(event)
}
},
// 从其他列拖入的任务落在可视位置,直接标记可见,避免先显示空白占位
onTaskAdd({item}) {
const dataId = item?.getAttribute('data-id');
if (dataId) {
this.$set(this.taskVisibilitys, dataId, {visible: true, height: 0});
}
},
ownerUser(list) {
return list.filter(({owner}) => owner == 1).sort((a, b) => {
return a.id - b.id;
});
},
expiresFormat(date) {
return $A.countDownFormat(this.projectPanel.nowTime, date)
},
taskItemVisible({id}) {
return this.visible && this.taskVisibilitys[id]?.visible
},
taskItemStyle({id, complete_at, color}) {
const style = {}
if (color) {
style.backgroundColor = color;
}
if (!this.taskItemVisible({id})) {
style.height = (this.taskVisibilitys[id]?.height || (complete_at ? 46 :146)) + 'px';
}
return style;
},
/**
* 检查列是否在看板可视区内,可见时再更新列内任务的可见状态
* @param mainRect 看板容器的边界
*/
checkVisibility(mainRect) {
const columnRect = this.$el.getBoundingClientRect();
const visible = (
columnRect.left < mainRect.right &&
columnRect.right > mainRect.left &&
columnRect.top < mainRect.bottom &&
columnRect.bottom > mainRect.top
)
if (visible) {
this.handleTaskScroll({target: this.$el.querySelector('.task-scrollbar')})
}
this.visible = visible
},
handleTaskScroll({target}) {
return updateTaskVisibilitys(this, target, this.taskVisibilitys)
},
}
}
</script>

View File

@ -148,140 +148,20 @@
tag="ul"
draggable=".column-item"
@sort="sortUpdate(true)">
<li
<ProjectColumn
v-for="column in columnList"
:ref="`column_${column.id}`"
:key="column.id"
:data-id="column.id"
class="column-item">
<div
:class="['column-head', column.color ? 'custom-color' : '']"
:style="column.color ? {backgroundColor: column.color} : {}">
<div class="column-head-title">
<AutoTip v-html="transformEmojiToHtml(column.name)"></AutoTip>
<em>({{panelTask(column.tasks).length}})</em>
</div>
<div class="column-head-icon">
<div v-if="columnLoad[column.id] === true" class="loading"><Loading /></div>
<EDropdown
v-else-if="!isDepartmentReadonly"
trigger="click"
size="medium"
@visible-change="onColumnMenuVisible(column.id, $event)"
@command="dropColumn(column, $event)">
<Icon type="ios-more" />
<EDropdownMenu slot="dropdown" class="project-panel-more-dropdown-menu">
<li v-if="columnMenuIds.includes(column.id)" class="project-panel-more-dropdown-warp">
<ul>
<EDropdownItem command="title">
<div class="item">
<Icon type="md-create" />{{$L('修改')}}
</div>
</EDropdownItem>
<EDropdownItem command="archive_completed">
<div class="item">
<Icon type="ios-filing" />{{$L('归档')}}
</div>
</EDropdownItem>
<EDropdownItem command="remove">
<div class="item">
<Icon type="md-trash" />{{$L('删除')}}
</div>
</EDropdownItem>
<EDropdownItem v-for="(c, k) in $store.state.columnColorList" :key="k" :divided="k==0" :command="c">
<div class="item">
<i class="taskfont" :style="{color:c.color||'#ddd'}" v-html="c.color == column.color ? '&#xe61d;' : '&#xe61c;'"></i>{{$L(c.name)}}
</div>
</EDropdownItem>
</ul>
</li>
</EDropdownMenu>
</EDropdown>
<Icon v-if="!isDepartmentReadonly" class="last" type="md-add" @click="addTopShow(column.id, true)" />
</div>
</div>
<Scrollbar
class="column-task"
class-name="task-scrollbar"
@on-scroll="handleTaskScroll">
<div v-if="!isDepartmentReadonly && !!columnTopShow[column.id]" class="task-item additem">
<TaskAddSimple
:column-id="column.id"
:project-id="projectId"
:add-top="true"
@on-close="addTopShow(column.id, false)"
@on-priority="addTaskOpen"
auto-active/>
</div>
<Draggable
:list="column.tasks"
:animation="150"
:disabled="sortDisabled || isDepartmentReadonly || $isEEUIApp || windowTouch"
class="task-list"
draggable=".task-draggable"
filter=".complete"
group="task"
@sort="sortUpdate"
@remove="sortUpdate">
<div
v-for="item in column.tasks"
:key="`${column.id}_${item.id}`"
:data-id="item.id"
:class="['task-item task-draggable', item.complete_at ? 'complete' : '', taskIsHidden(item) ? 'hidden' : '']"
:style="$A.generateColorVarStyle(item.flow_item_color, [10], 'flow-item-custom-color', taskItemStyle(item))"
@click="openTask(item)">
<template v-if="taskItemVisible(item)">
<div :class="['task-head', item.desc ? 'has-desc' : '']">
<div class="task-title">
<!--工作流状态-->
<span v-if="item.flow_item_name" :class="item.flow_item_status" @click.stop="openMenu($event, item)">{{item.flow_item_name}}</span>
<!--任务描述-->
<pre>{{item.name}}</pre>
</div>
<div class="task-menu" @click.stop="">
<TaskMenu v-if="!isDepartmentReadonly" :ref="`taskMenu_${item.id}`" :task="item" icon="ios-more"/>
</div>
</div>
<template v-if="!item.complete_at">
<div v-if="item.desc" class="task-desc"><pre v-html="item.desc"></pre></div>
<TaskTag v-if="item.task_tag.length > 0" class="task-tags" :tags="item.task_tag"/>
<div class="task-users">
<ul>
<li v-for="(user, keyu) in ownerUser(item.task_user)" :key="keyu">
<UserAvatar :userid="user.userid" size="32" :borderWidth="2" :borderColor="item.color"/>
</li>
<li v-if="ownerUser(item.task_user).length === 0" class="no-owner">
<Button type="primary" size="small" ghost @click.stop="openTask(item, true)">{{$L('领取任务')}}</Button>
</li>
</ul>
<div v-if="item.file_num > 0" class="task-icon">{{item.file_num}}<Icon type="ios-link-outline" /></div>
<div v-if="item.msg_num > 0" class="task-icon">{{item.msg_num}}<Icon type="ios-chatbubbles-outline" /></div>
</div>
<div class="task-progress">
<div v-if="item.sub_num > 0" class="task-sub-num">{{item.sub_complete}}/{{item.sub_num}}</div>
<Progress :percent="item.percent" :stroke-width="6" />
<ETooltip
v-if="item.end_at"
:class="['task-time', item.today ? 'today' : '', item.overdue ? 'overdue' : '']"
:disabled="$isEEUIApp || windowTouch"
:open-delay="600"
:content="item.end_at">
<div v-if="!item.complete_at"><i class="taskfont">&#xe71d;</i>{{ expiresFormat(item.end_at) }}</div>
</ETooltip>
</div>
<em v-if="item.p_name" class="priority-color" :style="{backgroundColor:item.p_color}"></em>
</template>
</template>
</div>
<div v-if="!isDepartmentReadonly" class="task-item additem">
<TaskAddSimple
:column-id="column.id"
:project-id="projectId"
@on-priority="addTaskOpen"/>
</div>
</Draggable>
</Scrollbar>
</li>
:column="column"
:project-id="projectId"
:readonly="isDepartmentReadonly"
:drag-disabled="sortDisabled || isDepartmentReadonly || $isEEUIApp || windowTouch"
:loading="columnLoad[column.id] === true"
:top-show="!!columnTopShow[column.id]"
@on-command="dropColumn(column, $event)"
@on-top-show="addTopShow(column.id, $event)"
@on-priority="addTaskOpen"
@on-sort="sortUpdate"/>
<li v-if="!isDepartmentReadonly" :class="['add-column', addColumnShow ? 'show-input' : '']">
<div class="add-column-text" @click="addColumnOpen">
<Icon type="md-add" />{{$L('添加列表')}}
@ -691,8 +571,8 @@ import {debounce} from "lodash";
import Draggable from 'vuedraggable'
import TaskPriority from "./TaskPriority";
import {mapGetters, mapState} from "vuex";
import TaskAddSimple from "./TaskAddSimple";
import TaskRow from "./TaskRow";
import ProjectColumn from "./ProjectColumn.vue";
import TaskArchived from "./TaskArchived";
import ProjectLog from "./ProjectLog";
import DrawerOverlay from "../../../components/DrawerOverlay";
@ -700,9 +580,7 @@ import ProjectTaskTag from "./ProjectTaskTag";
import ProjectTaskTemplate from "./ProjectTaskTemplate";
import ProjectWorkflow from "./ProjectWorkflow";
import ProjectPermission from "./ProjectPermission";
import TaskMenu from "./TaskMenu";
import TaskDeleted from "./TaskDeleted";
import TaskTag from "./ProjectTaskTag/tags.vue";
import ProjectGantt from "./ProjectGantt";
import ProjectTableGroupDropdown from "./ProjectTableGroupDropdown.vue";
import ProjectFlowFilterDropdown from "./ProjectFlowFilterDropdown.vue";
@ -713,6 +591,7 @@ import emitter from "../../../store/events";
import transformEmojiToHtml from "../../../utils/emoji";
import {projectTableGroups, normalizeProjectTableGroups, projectWeekRanges, taskMatchesProjectGroup} from "../../../utils/projectTableGroups";
import {scrollToStableTarget} from "../../../utils/scrollToStableTarget";
import {updateTaskVisibilitys} from "../../../utils/taskVisibility";
import {emptyProjectFlowFilter, projectFlowFilterCount, taskMatchesProjectFlowFilter} from "../../../utils/projectFlowFilter";
export default {
@ -721,7 +600,6 @@ export default {
VMPreviewNostyle,
UserAvatarTip,
UserSelect,
TaskMenu,
ProjectTaskTag,
ProjectTaskTemplate,
ProjectWorkflow,
@ -730,11 +608,10 @@ export default {
ProjectLog,
TaskArchived,
TaskRow,
ProjectColumn,
Draggable,
TaskAddSimple,
TaskPriority,
TaskDeleted,
TaskTag,
ProjectGantt,
ProjectTableGroupDropdown,
ProjectFlowFilterDropdown,
@ -743,8 +620,6 @@ export default {
return {
loading: false,
columnMenuIds: [],
nowTime: $A.dayjs().unix(),
nowInterval: null,
tableToday: $A.daytz().format('YYYY-MM-DD'),
@ -797,12 +672,16 @@ export default {
flowFilterOpen: false,
flowList: [],
columnVisibilitys: {},
taskVisibilitys: {},
taskRowVisibilitys: {},
}
},
provide() {
return {
projectPanel: this,
}
},
mounted() {
this.nowInterval = setInterval(() => {
this.nowTime = $A.dayjs().unix();
@ -1517,12 +1396,6 @@ export default {
});
},
onColumnMenuVisible(columnId, visible) {
if (visible && !this.columnMenuIds.includes(columnId)) {
this.columnMenuIds.push(columnId)
}
},
dropColumn(column, command) {
if (this.isDepartmentReadonly) {
return;
@ -1895,13 +1768,6 @@ export default {
}
},
openMenu(event, task) {
const el = this.$refs[`taskMenu_${task.id}`];
if (el) {
el[0].handleClick(event)
}
},
taskIsHidden(task) {
const {id, name, desc, complete_at} = task;
const {searchText} = this;
@ -1921,12 +1787,6 @@ export default {
return false;
},
ownerUser(list) {
return list.filter(({owner}) => owner == 1).sort((a, b) => {
return a.id - b.id;
});
},
inviteGet(refresh) {
this.inviteLoad++;
this.$store.dispatch("call", {
@ -2060,10 +1920,6 @@ export default {
return !taskMatchesProjectFlowFilter(task, this.flowFilter);
},
expiresFormat(date) {
return $A.countDownFormat(this.nowTime, date)
},
setTableGroupVisible(group, visible) {
const selected = this.visibleTableGroups;
if (!visible && selected.length === 1 && selected.includes(group.key)) return;
@ -2156,21 +2012,6 @@ export default {
this.settingData = { ...this.settingData, archive_method: value };
},
taskItemVisible({id, column_id}) {
return this.columnVisibilitys[column_id] && this.taskVisibilitys[id]?.visible
},
taskItemStyle({id, column_id, complete_at, color}) {
const style = {}
if (color) {
style.backgroundColor = color;
}
if (!this.taskItemVisible({id, column_id})) {
style.height = (this.taskVisibilitys[id]?.height || (complete_at ? 46 :146)) + 'px';
}
return style;
},
handleColumnDebounce(wait = 10, needView = false) {
if (typeof wait === "boolean") {
needView = wait
@ -2210,85 +2051,22 @@ export default {
},
handleColumnScroll() {
const mainContainer = this.$refs.projectColumn;
if (!mainContainer) {
return
}
const mainRect = mainContainer.getBoundingClientRect();
this.columnList.forEach(({id}) => {
const ref = this.$refs[`column_${id}`];
ref && this.checkColumnVisibility(ref[0])
ref && ref[0] && ref[0].checkVisibility(mainRect)
})
},
checkColumnVisibility(columnContainer = null) {
if (!columnContainer) {
handleTaskScroll({target}) {
if (this.tabTypeActive !== 'table') {
return
}
const dataId = columnContainer.getAttribute('data-id')
if (!dataId) {
return
}
const mainContainer = this.$refs.projectColumn;
if (!mainContainer) {
return false;
}
// 获取容器的边界信息
const mainRect = mainContainer.getBoundingClientRect();
const columnRect = columnContainer.getBoundingClientRect();
// 检查元素是否在容器的可视区域内
const visible = (
columnRect.left < mainRect.right &&
columnRect.right > mainRect.left &&
columnRect.top < mainRect.bottom &&
columnRect.bottom > mainRect.top
)
if (visible) {
this.handleTaskScroll({target: columnContainer.querySelector('.task-scrollbar')})
}
this.$set(this.columnVisibilitys, dataId, visible)
},
async handleTaskScroll({target}) {
if (!target) {
return
}
const targetItem = target.querySelectorAll('.task-item');
if (!targetItem.length) {
return
}
let visibleType = null;
switch (this.tabTypeActive) {
case 'column':
visibleType = 'taskVisibilitys'
break;
case 'table':
visibleType = 'taskRowVisibilitys'
break;
default:
return
}
const targetRect = target.getBoundingClientRect();
for (const item of targetItem) {
const dataId = item.getAttribute('data-id');
if (!dataId) {
continue;
}
const taskRect = item.getBoundingClientRect();
const originalVisible = this[visibleType][dataId]?.visible || false;
const currentVisible = (
taskRect.top >= targetRect.top - taskRect.height &&
taskRect.bottom <= targetRect.bottom + taskRect.height
);
if (currentVisible === originalVisible) {
continue;
}
const firstVisible = this[visibleType][dataId] === undefined && currentVisible;
this.$set(this[visibleType], dataId, {
visible: currentVisible,
height: taskRect.height
});
if (firstVisible) {
await this.$nextTick();
}
}
return updateTaskVisibilitys(this, target, this.taskRowVisibilitys)
},
/**

View File

@ -0,0 +1,35 @@
// 按滚动容器可视区(上下各多算一个任务高度)更新任务的可见状态,未可见的任务只渲染占位
// 首次可见的任务逐个等待渲染,让后续任务按真实高度判断
export async function updateTaskVisibilitys(vm, target, visibilitys) {
if (!target) {
return
}
const targetItem = target.querySelectorAll('.task-item');
if (!targetItem.length) {
return
}
const targetRect = target.getBoundingClientRect();
for (const item of targetItem) {
const dataId = item.getAttribute('data-id');
if (!dataId) {
continue;
}
const taskRect = item.getBoundingClientRect();
const originalVisible = visibilitys[dataId]?.visible || false;
const currentVisible = (
taskRect.top >= targetRect.top - taskRect.height &&
taskRect.bottom <= targetRect.bottom + taskRect.height
);
if (currentVisible === originalVisible) {
continue;
}
const firstVisible = visibilitys[dataId] === undefined && currentVisible;
vm.$set(visibilitys, dataId, {
visible: currentVisible,
height: taskRect.height
});
if (firstVisible) {
await vm.$nextTick();
}
}
}