feat(project): 项目页筛选下拉支持多选组合

- 顶部筛选由 Cascader 单选改为分组复选下拉:状态(含未计划)/ 标签 / 负责人,同组为「或」、跨组为「且」
- 勾选时下拉不关闭;顶部显示已选数量与「清空」,每组可单独清除,按钮上的 × 一键清空
- 按钮显示:未筛选「全部 (n)」、单选显示该项(状态带颜色)、多选按组汇总如「状态·2 / 负责人·1 (n)」
- 弹层宽度随内容自适应(180–320px),内容区内部滚动;负责人超过 10 人时显示搜索框
- 抽出 utils/projectFlowFilter 统一看板、列表、甘特图的过滤逻辑,修复甘特图未按标签和未计划过滤
- 勾选「已完成」类状态仍自动开启显示已完成,切换项目重置筛选
- 同步 ai-kb 视图筛选相关说明
This commit is contained in:
kuaifan 2026-10-01 01:21:30 +00:00
parent c06d61a11f
commit d4c7cdba6f
13 changed files with 436 additions and 212 deletions

View File

@ -2784,3 +2784,7 @@ WebDAV 路径冲突
部门总览
全部部门
团队成员 (*) 人
已选(*)项
未筛选
清除
无匹配成员

View File

@ -19,7 +19,7 @@ negative:
- 移动端 / 触屏端不支持拖拽(看板、甘特图的拖动改状态在 touch 设备被禁用)
- 没有「日历视图」「卡片墙视图」等,4 种为全集
- 工作流视图(workflow)不是顶部切换的项目视图,是设置项目工作流时的可视化弹窗
last_verified: v1.7.90
last_verified: v1.9.36
---
# 项目视图概览(4 种)
@ -42,5 +42,5 @@ DooTask 的项目详情页提供 4 种任务视图,用同一份任务数据按
## 与"筛选"的关系
- 视图:决定数据怎么排版
- 筛选(顶部 Cascader):决定显示哪些任务
- 筛选(顶部筛选下拉,可多选组合):决定显示哪些任务
- 两者正交:任意视图都能叠加筛选

View File

@ -18,7 +18,7 @@ negative:
- 移动端 / 触屏端拖拽全部禁用(列、任务、甘特图都不能拖)
- 工作流的「待办 → 完成」必须从任务详情页改字段
- 移动端甘特图体验受限(屏宽不够),适合临时查看而非编辑
last_verified: v1.7.90
last_verified: v1.9.36
---
# 移动端能用哪些视图
@ -33,7 +33,7 @@ DooTask 的项目视图组件在前端检测 `$isEEUIApp`(移动端壳)和 `
- **看板视图**:可正常查看每列任务;点击卡片进入详情;不能拖任务也不能拖列
- **列表视图**:完整可用;点击表头排序、点击行进入详情都可用
- **甘特图**:可查看时间轴和悬停信息;不能拖动改时间,需进任务详情页改字段
- **工作流筛选**:顶部 Cascader 可用,与桌面一致
- **工作流筛选**:顶部筛选下拉可用(点击展开、可多选),与桌面一致
## 状态变更怎么办
1. 点开任务详情

View File

@ -12,49 +12,66 @@ aliases:
- 按标签筛选
- 怎么过滤任务
- 项目筛选条件
- 多选筛选
- 组合筛选
related_tools: [list_tasks]
related_pages: [project_detail]
prerequisites: []
negative:
- 筛选条件不持久化(刷新或换项目会重置为「全部」)
- 一次只能选一个筛选条件(不支持「负责人 A + 标签 B」组合)
- 移动端的筛选 Cascader 与桌面端共用,但下拉空间有限
- 同一组内多选是「或」,不是「且」(选两个负责人 = 任一人负责的任务,不是两人共同负责)
- 没有「保存常用筛选」功能
last_verified: v1.9.18
last_verified: v1.9.36
---
# 视图筛选条件
## 入口
- 桌面端 Web:项目详情页顶部,视图切换条左侧的 Cascader(默认显示「全部 (n)」)
- 桌面端 Web:项目详情页顶部,视图切换条左侧的筛选按钮(未筛选时显示「全部 (n)」)
- 桌面非触屏端:鼠标悬停展开,移入下拉菜单保持展开,移出入口与菜单后延迟关闭
- 触屏设备和 App:项目详情页顶部同位置,点击展开
- 勾选选项时下拉不关闭,可以连续多选;点击外部关闭
## 可用筛选维度
- **全部**:不筛选
- **未计划**:start_at 为空且未完成
- **工作流状态**:选某个工作流的某个具体状态(flow_item_id 命中)
- **标签**:选某个项目标签(前缀 `tag:` 内部标识)
- **负责人**:选项目内某个成员(前缀 `user:`,显示其在该项目的任务数)
下拉按三组排列,每组都可多选:
筛选项含人数计数(如「张三 (5)」表示张三作为负责人有 5 个任务)。
- **状态**:「未计划」(start_at 为空的任务)+ 各工作流状态(flow_item_id 命中);项目有多条工作流时按工作流分小节
- **标签**:项目内任务用到的标签
- **负责人**:项目内有负责任务的成员,当前用户排第一;负责人超过 10 人时组内显示「搜索成员」输入框
每项后面显示任务数(如「张三 5」表示张三作为负责人有 5 个任务)。
## 组合规则
- **同组内为「或」**:状态选「进行中」「待测试」= 处于任一状态的任务
- **不同组之间为「且」**:状态选「进行中」+ 负责人选「张三」= 张三负责且进行中的任务
- 例:状态 (待处理 或 进行中) 且 标签 (前端) 且 负责人 (张三 或 李四)
## 清除筛选
- 下拉顶部显示「已选 n 项」,点「清空」清除全部条件
- 每组标题旁的「清除」只清除该组
- 筛选按钮上的 × 图标一键清空
## 筛选按钮的显示
- 未筛选:「全部 (n)」
- 只选一项:显示该项名称和数量,选的是工作流状态时带状态颜色
- 选了多项:按组汇总,如「状态·2 / 负责人·1 (n)」,n 为命中任务数
## 与"显示已完成"的关系
- 顶部「显示已完成」复选框控制是否展示 complete_at 非空的任务
- 当选工作流的「结束色」状态(end)时,「显示已完成」会被自动勾上
- 勾选工作流的「结束色」状态(end)时,「显示已完成」会被自动勾上
- 在不显示已完成的情况下,负责人筛选只统计未完成任务
## 与"搜索"的关系
- 顶部还有搜索输入框,按任务名 / 描述 / ID 模糊匹配
- 搜索与 Cascader 筛选会叠加(先按 Cascader 过滤,再按搜索词二次过滤)
- 搜索与筛选会叠加(先按筛选条件过滤,再按搜索词二次过滤)
## 应用范围
- 看板视图:每列只显示命中任务(列本身仍全部展示,空列也保留)
- 列表视图:每个分段都按筛选过滤
- 甘特图:未命中的任务隐藏
- 甘特图:未命中的任务隐藏(状态、标签、负责人、未计划均生效)
## 不支持
- 不支持多条件组合筛选(如「负责人=A AND 标签=B」)
- 不支持同组内「且」组合(如「必须同时带标签 A 和标签 B」)
- 不支持保存自定义筛选 / 我的筛选
- 不支持按截止时间区间筛选
- 不支持按创建时间筛选

View File

@ -17,42 +17,38 @@ related_pages: [project_detail]
prerequisites:
- 项目已配置工作流(至少一条 flow)
negative:
- 工作流视图不是独立视图按钮,是通过顶部「工作流」Cascader 在看板视图上叠加的筛选
- 一个项目可以有多条工作流,但筛选 Cascader 一次只能选一个工作流的某个状态
- 项目未配置工作流时筛选下拉只显示「全部」与「未计划」
- 工作流视图不是独立视图按钮,是通过顶部筛选下拉在看板视图上叠加的筛选
- 项目未配置工作流时筛选下拉的「状态」组只有「未计划」
- 工作流状态(flow_item)不等于看板列(column),两者独立
last_verified: v1.9.18
last_verified: v1.9.36
---
# 按工作流状态分栏查看任务
## 入口
- 桌面端 Web:项目详情页顶部「工作流」Cascader(默认显示「全部 (n)」)
- 桌面非触屏端悬停展开下拉;触屏设备和 App 点击展开,再选择某个工作流的某个状态(如「评审 → 评审中」)
- 桌面端 Web:项目详情页顶部筛选按钮(默认显示「全部 (n)」)
- 桌面非触屏端悬停展开下拉;触屏设备和 App 点击展开,在「状态」组勾选一个或多个工作流状态;项目有多条工作流时按工作流分小节(如「评审」小节下的「评审中」)
## 操作步骤
1. 确保项目已配置工作流(项目设置 → 工作流设置;详见 [[project.flow.howto.create]])
2. 在顶部「工作流」Cascader 中选择目标状态
2. 在顶部筛选下拉的「状态」组勾选目标状态(可多选,多个状态之间为「或」)
3. 当前视图(看板 / 列表 / 甘特图)立即过滤,只展示 flow_item_id 命中的任务
4. 选择「全部」回到无筛选状态
4. 点下拉顶部「清空」或按钮上的 × 回到无筛选状态
## 可选筛选维度
工作流 Cascader 下拉里除了工作流状态,还混合了:
筛选下拉分三组,组内多选为「或」,跨组为「且」(详见 [[view.filter.howto]]):
- **全部 (n)**:取消筛选,显示所有任务
- **未计划 (n)**:start_at 为空且未完成的任务
- **某工作流的某状态**:按 `flow_item_id` 过滤
- **某标签**:按任务标签筛选(`tag:xxx`)
- **某负责人**:按 owner 过滤(`user:userid`)
- **状态**:「未计划」(start_at 为空)+ 各工作流状态(按 `flow_item_id` 过滤)
- **标签**:按任务标签筛选
- **负责人**:按 owner 过滤
## 状态着色
工作流状态有三档着色(status 字段):
- `start`(起步色):常用作「待办」「待评审」
- 中间状态:自定义颜色
- `end`(结束色):常用作「已完成」「已通过」;选这种状态时若未开启「显示已完成」会自动打开
- `end`(结束色):常用作「已完成」「已通过」;勾选这种状态时若未开启「显示已完成」会自动打开
## 不支持
- 不支持同时选择多个状态进行联合筛选
- 不支持跨工作流的状态合并
- 不支持跨工作流的状态合并(可以同时勾选多条工作流里的状态,但它们仍是不同状态)
- 不支持把工作流分栏当成视图独立保存(每次进入仍是 column / table / gantt 视图)

View File

@ -20,7 +20,7 @@ negative:
- 子任务在甘特图中独立显示,不会自动归并到父任务
- 不支持任务依赖关系(A 完成才能开始 B 这种)
- 移动端 / 触屏端不支持拖拽改时间,只能查看
last_verified: v1.7.90
last_verified: v1.9.36
---
# 使用甘特图视图
@ -43,7 +43,7 @@ last_verified: v1.7.90
- **缩放**:顶部按钮切换日 / 周 / 月粒度
## 与筛选的联动
顶部 Cascader 筛选条件(按工作流状态 / 负责人 / 标签)在甘特图同样生效,未命中的任务隐藏。
顶部筛选条件(状态 / 标签 / 负责人,支持多选组合)在甘特图同样生效,未命中的任务隐藏。
## 不显示的任务
- 没有 start_at 的任务(未排期)

View File

@ -24,7 +24,7 @@ negative:
- 没有 Excel 式选区批量操作
- 行内排序仅 优先级 / 到期时间 两列可点
- 至少保留一个分组,不能全部取消
last_verified: v1.9.18
last_verified: v1.9.36
---
# 使用列表视图查看任务
@ -34,7 +34,7 @@ last_verified: v1.9.18
- 视图切换记忆在 `cacheParameter.menuType='table'`,下次进入同一项目仍是列表视图
## 列表分段
先切换到表格模式,鼠标悬停视图切换组前的设置齿轮图标,下拉菜单中可复选分组。菜单与项目筛选使用一致的弹层外观,无箭头和标题栏。触屏先切换表格,再点击齿轮打开选择。表格图标仅切换视图,悬停或点击都不会打开分组菜单。看板和甘特图模式不显示设置齿轮;切走表格时菜单关闭。
先切换到表格模式,鼠标悬停视图切换组前的设置齿轮图标,下拉菜单中可复选分组。菜单为无箭头、无标题栏的弹层。触屏先切换表格,再点击齿轮打开选择。表格图标仅切换视图,悬停或点击都不会打开分组菜单。看板和甘特图模式不显示设置齿轮;切走表格时菜单关闭。
| 分组 | 默认显示 | 范围 |
|---|---|---|
@ -75,4 +75,4 @@ last_verified: v1.9.18
- 不能自定义列(增删 / 改顺序 / 改宽度)
- 不能多选批量改字段(如批量改负责人)
- 表头排序只对优先级 / 到期时间生效
- 没有筛选器(用顶部 Cascader 筛选)
- 没有独立筛选器(用顶部筛选下拉筛选)

View File

@ -18,8 +18,8 @@ negative:
- 视图选择按项目持久化(cacheParameter.menuType),换项目不会自动跟随
- 没有快捷键切换视图
- 移动端视图切换按钮位置与桌面一致,但小屏更靠右
- 工作流视图不是这里切换,是通过工作流筛选 Cascader 叠加
last_verified: v1.9.18
- 工作流视图不是这里切换,是通过顶部筛选下拉的「状态」组叠加
last_verified: v1.9.36
---
# 切换看板 / 列表 / 甘特图视图

View File

@ -0,0 +1,94 @@
<template>
<Dropdown
:visible="open"
@on-visible-change="open = $event"
:trigger="touch ? 'click' : 'hover'"
:placement="placement"
transfer
transfer-class-name="project-flow-filter-dropdown">
<div
@keydown.enter.prevent="open = true"
@keydown.space.prevent="open = true"
@keydown.esc="open = false">
<slot/>
</div>
<div slot="list" class="project-flow-filter" @click.stop @keydown.esc="open = false">
<div class="flow-filter-head">
<span class="flow-filter-count">{{total > 0 ? $L('已选(*)项', total) : $L('未筛选')}}</span>
<span :class="['flow-filter-clear', {disabled: total === 0}]" @click="total > 0 && $emit('clear')">{{$L('清空')}}</span>
</div>
<div class="flow-filter-body">
<div v-for="group in groups" :key="group.key" class="flow-filter-group">
<div class="flow-filter-group-title">
<span>{{$L(group.title)}}<template v-if="selected[group.key].length > 0"> · {{selected[group.key].length}}</template></span>
<span v-if="selected[group.key].length > 0" class="flow-filter-group-clear" @click="$emit('clear', group.key)">{{$L('清除')}}</span>
</div>
<div v-if="group.searchable" class="flow-filter-search">
<Input v-model="keyword" size="small" clearable :placeholder="$L('搜索成员')"/>
</div>
<div v-for="(section, index) in filterSections(group)" :key="index">
<div v-if="section.title" class="flow-filter-section-title">{{section.title}}</div>
<div
v-for="item in section.items"
:key="item.value"
:class="['flow-filter-item', item.status]"
:style="item.style"
@click="$emit('change', group.key, item, !isSelected(group.key, item.value))">
<Checkbox :value="isSelected(group.key, item.value)" class="flow-filter-checkbox"/>
<span v-if="item.color" class="flow-filter-dot" :style="{backgroundColor: item.color}"></span>
<UserAvatar v-else-if="item.userid" :userid="item.userid" :size="20"/>
<span class="flow-filter-name">{{item.label}}</span>
<span class="flow-filter-num">{{item.count}}</span>
</div>
</div>
<div v-if="group.searchable && filterSections(group).length === 0" class="flow-filter-empty">{{$L('无匹配成员')}}</div>
</div>
</div>
</div>
</Dropdown>
</template>
<script>
export default {
name: 'ProjectFlowFilterDropdown',
props: {
value: {type: Boolean, default: false},
touch: {type: Boolean, default: false},
groups: {type: Array, required: true},
selected: {type: Object, required: true},
total: {type: Number, default: 0},
placement: {type: String, default: 'bottom-end'},
},
data() {
return {
keyword: '',
};
},
computed: {
open: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
},
},
},
methods: {
isSelected(key, value) {
return this.selected[key].includes(value);
},
filterSections(group) {
// 搜索框隐藏时(人数降到阈值以下)忽略残留关键词
const keyword = this.keyword.trim();
if (!group.searchable || !keyword) {
return group.sections;
}
return group.sections.map(section => ({
...section,
items: section.items.filter(item => $A.strExists(item.label, keyword)),
})).filter(section => section.items.length > 0);
},
},
};
</script>

View File

@ -45,6 +45,7 @@
<script>
import {mapGetters, mapState} from "vuex";
import GanttView from "../../../components/GanttView";
import {taskMatchesProjectFlowFilter} from "../../../utils/projectFlowFilter";
export default {
name: 'ProjectGantt',
@ -53,8 +54,9 @@ export default {
projectColumn: {
default: []
},
flowInfo: {
default: {}
flowFilter: {
type: Object,
default: () => ({})
},
readonly: {
type: Boolean,
@ -134,7 +136,7 @@ export default {
},
deep: true,
},
flowInfo: {
flowFilter: {
handler() {
this.initData();
},
@ -152,12 +154,7 @@ export default {
},
flowTask(task) {
if ($A.leftExists(this.flowInfo.value, "user:") && !task.task_user.find(({userid, owner}) => userid === this.flowInfo.userid && owner)) {
return true;
} else if (this.flowInfo.value > 0 && task.flow_item_id !== this.flowInfo.value) {
return true;
}
return false;
return !taskMatchesProjectFlowFilter(task, this.flowFilter);
},
filtrLength(list) {

View File

@ -101,15 +101,19 @@
<Checkbox :value="projectData.cacheParameter.completedTask" @on-change="toggleParameter('completedTask')">{{$L('显示已完成')}}</Checkbox>
</div>
<div class="project-select">
<Cascader
ref="flow"
:data="flowData"
:open-trigger="$isEEUIApp || windowTouch ? 'click' : 'hover'"
@on-change="flowChange"
transfer-class-name="project-panel-flow-cascader"
transfer>
<span :class="`project-flow ${flowInfo.status || ''}`" :style="flowInfo.style">{{ flowTitle }}</span>
</Cascader>
<ProjectFlowFilterDropdown
v-model="flowFilterOpen"
:touch="$isEEUIApp || windowTouch"
:groups="flowFilterGroups"
:selected="flowFilter"
:total="flowFilterTotal"
@change="flowFilterChange"
@clear="flowFilterClear">
<span :class="['project-flow', flowTrigger.class]" :style="flowTrigger.style">
<span class="project-flow-text">{{ flowTrigger.title }}</span>
<Icon v-if="flowFilterTotal > 0" type="ios-close-circle" class="project-flow-clear" @click.native.stop="flowFilterClear()"/>
</span>
</ProjectFlowFilterDropdown>
</div>
<ProjectTableGroupDropdown
v-if="tabTypeActive === 'table'"
@ -450,7 +454,7 @@
</Scrollbar>
<div v-else-if="tabTypeActive === 'gantt'" class="project-gantt">
<!--甘特图-->
<ProjectGantt :projectColumn="columnList" :flowInfo="flowInfo" :readonly="isDepartmentReadonly"/>
<ProjectGantt :projectColumn="columnList" :flowFilter="flowFilter" :readonly="isDepartmentReadonly"/>
</div>
<!--项目设置-->
@ -699,6 +703,7 @@ import TaskDeleted from "./TaskDeleted";
import TaskTag from "./ProjectTaskTag/tags.vue";
import ProjectGantt from "./ProjectGantt";
import ProjectTableGroupDropdown from "./ProjectTableGroupDropdown.vue";
import ProjectFlowFilterDropdown from "./ProjectFlowFilterDropdown.vue";
import UserSelect from "../../../components/UserSelect.vue";
import UserAvatarTip from "../../../components/UserAvatar/tip.vue";
import VMPreviewNostyle from "../../../components/VMEditor/nostyle.vue";
@ -706,6 +711,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 {emptyProjectFlowFilter, projectFlowFilterCount, taskMatchesProjectFlowFilter} from "../../../utils/projectFlowFilter";
export default {
name: "ProjectPanel",
@ -729,6 +735,7 @@ export default {
TaskTag,
ProjectGantt,
ProjectTableGroupDropdown,
ProjectFlowFilterDropdown,
},
data() {
return {
@ -782,7 +789,8 @@ export default {
archivedTaskShow: false,
deletedTaskShow: false,
flowInfo: {},
flowFilter: emptyProjectFlowFilter(),
flowFilterOpen: false,
flowList: [],
columnVisibilitys: {},
@ -901,22 +909,14 @@ export default {
},
panelTask() {
const {searchText, flowInfo} = this;
const {searchText, flowFilter} = this;
return function (list) {
if (!this.projectData.cacheParameter.completedTask) {
list = list.filter(({complete_at}) => {
return !complete_at;
});
}
if ($A.leftExists(flowInfo.value, "user:")) {
list = list.filter(({task_user}) => task_user.find(({userid, owner}) => userid === flowInfo.userid && owner));
} else if ($A.leftExists(flowInfo.value, "tag:")) {
list = list.filter(({task_tag}) => task_tag.find(({name}) => name === flowInfo.tag_name));
} else if (flowInfo.value > 0) {
list = list.filter(({flow_item_id}) => flow_item_id === flowInfo.value);
} else if (flowInfo.value == -1) {
list = list.filter(({start_at}) => !start_at);
}
list = list.filter(task => taskMatchesProjectFlowFilter(task, flowFilter));
if (searchText) {
list = list.filter(({id, name, desc}) => {
return id == searchText || $A.strExists(`${name} ${desc}`, searchText)
@ -1158,111 +1158,100 @@ export default {
return tags;
},
flowTitle() {
const {flowInfo, flowData, allTask} = this;
if (flowInfo.value==-1) {
return flowInfo.label;
}
if (flowInfo.value) {
const item = flowData.find(item => item.value === flowInfo.value);
return item ? item.label : flowInfo.label;
}
return `${this.$L('全部')} (${allTask.length})`
flowFilterTotal() {
return projectFlowFilterCount(this.flowFilter);
},
flowData() {
const {flowList, allTask, cacheUserBasic} = this;
const list = [{
value: 0,
label: `${this.$L('全部')} (${allTask.length})`,
children: []
flowFilterGroups() {
const {flowList, allTask, cacheUserBasic, flowFilter} = this;
const groups = [];
// 状态(含未计划)
const flowSections = [{
items: [{
value: -1,
label: this.$L('未计划'),
count: allTask.filter(({start_at, complete_at}) => !start_at && !complete_at).length,
}]
}];
list.push({
value: -1,
label: `${this.$L('未计划')} (${allTask.filter(({start_at, complete_at}) => !start_at && !complete_at).length})`,
children: []
flowList.forEach(flow => {
flowSections.push({
title: flowList.length > 1 ? flow.name : '',
items: flow.project_flow_item.map(item => ({
value: item.id,
label: item.name,
count: allTask.filter(({flow_item_id}) => flow_item_id == item.id).length,
status: item.status,
style: $A.generateColorVarStyle(item.color, [10], 'flow-item-custom-color'),
}))
});
});
const flows = flowList.map(item1 => {
return {
value: item1.id,
label: item1.name,
status: item1.status,
children: item1.project_flow_item.map(item2 => {
const length = allTask.filter(({flow_item_id}) => {
return flow_item_id == item2.id;
}).length
return {
value: item2.id,
label: `${item2.name} (${length})`,
status: item2.status,
class: item2.status,
style: $A.generateColorVarStyle(item2.color, [10], 'flow-item-custom-color'),
}
})
}
});
if (flows.length === 1) {
list.push(...flows[0].children)
} else if (flows.length > 0) {
list.push(...flows)
}
groups.push({key: 'flows', title: '状态', sections: flowSections});
// 标签
if (this.tagList.length > 0) {
const tagItems = this.tagList.map(({name, color}) => {
const length = allTask.filter(({task_tag}) => {
return task_tag.find(tag => tag.name === name);
}).length
return {
value: `tag:${name}`,
label: `${name} (${length})`,
status: 'tag-dot',
style: {
'--bg-color': color,
},
tag_name: name,
length,
}
})
if (tagItems.length > 0) {
list.push(...tagItems.map((item, index)=>{
item.class = `tag-dot tag-${index}`
return item;
}))
}
groups.push({
key: 'tags',
title: '标签',
sections: [{
items: this.tagList.map(({name, color}) => ({
value: name,
label: name,
count: allTask.filter(({task_tag}) => task_tag.find(tag => tag.name === name)).length,
color,
}))
}]
});
}
// 用户
// 负责人
const {project_user} = this.projectData;
if ($A.isArray(project_user)) {
const userItems = project_user.map((item) => {
const userItems = project_user.map(item => {
const userInfo = cacheUserBasic.find(({userid}) => userid === item.userid) || {}
const length = allTask.filter(({task_user, complete_at}) => {
if (!this.projectData.cacheParameter.completedTask) {
if (complete_at) {
return false;
}
const count = allTask.filter(({task_user, complete_at}) => {
if (!this.projectData.cacheParameter.completedTask && complete_at) {
return false;
}
return task_user.find(({userid, owner}) => userid === item.userid && owner);
}).length
return {
value: `user:${userInfo.userid}`,
label: `${userInfo.nickname} (${length})`,
status: 'user',
value: userInfo.userid || 0,
label: userInfo.nickname,
count,
userid: userInfo.userid || 0,
length,
}
}).filter(({userid, length}) => userid > 0 && length > 0)
}).filter(({userid, count}) => userid > 0 && (count > 0 || flowFilter.users.includes(userid)))
if (userItems.length > 0) {
userItems.sort((a) => {
return a.userid == this.userId ? -1 : 1
})
list.push(...userItems.map((item, index)=>{
item.class = `user-${index}`
return item;
}))
userItems.sort((a, b) => (b.userid == this.userId) - (a.userid == this.userId))
// 负责人超过 10 人时显示搜索框
groups.push({key: 'users', title: '负责人', searchable: userItems.length > 10, sections: [{items: userItems}]});
}
}
//
return list
return groups;
},
flowTrigger() {
const {flowFilter, flowFilterTotal, flowFilterGroups, allTask} = this;
if (flowFilterTotal === 0) {
return {title: `${this.$L('全部')} (${allTask.length})`};
}
if (flowFilterTotal === 1) {
const group = flowFilterGroups.find(({key}) => flowFilter[key].length > 0);
const item = group?.sections.flatMap(({items}) => items).find(({value}) => value === flowFilter[group.key][0]);
if (item) {
const title = `${item.label} (${item.count})`;
if (item.status) {
return {title, class: item.status, style: item.style};
}
if (item.color) {
return {title, class: 'active tag-dot', style: {'--bg-color': item.color}};
}
return {title, class: 'active'};
}
}
const parts = flowFilterGroups
.filter(({key}) => flowFilter[key].length > 0)
.map(({key, title}) => `${this.$L(title)}·${flowFilter[key].length}`);
const count = allTask.filter(task => taskMatchesProjectFlowFilter(task, flowFilter)).length;
return {title: `${parts.join(' / ')} (${count})`, class: 'active'};
},
},
@ -1930,7 +1919,8 @@ export default {
},
getFlowData() {
this.flowInfo = {}
this.flowFilter = emptyProjectFlowFilter();
this.flowFilterOpen = false;
this.$store.dispatch("call", {
url: 'project/flow/list',
data: {
@ -1938,23 +1928,31 @@ export default {
},
}).then(({data}) => {
this.flowList = data;
this.$refs.flow?.clearSelect();
}).catch(() => {
this.flowList = [];
});
},
flowChange(_, data) {
this.flowInfo = data.pop() || {};
flowFilterChange(key, item, checked) {
const values = this.flowFilter[key].filter(value => value !== item.value);
if (checked) {
values.push(item.value);
}
this.flowFilter = {...this.flowFilter, [key]: values};
// 判断查看 "已完成流程" 但是没有显示已完成时切换显示已完成
if (this.flowInfo.status === 'end' && !this.projectData.cacheParameter.completedTask) {
if (checked && item.status === 'end' && !this.projectData.cacheParameter.completedTask) {
this.toggleParameter('completedTask')
}
this.handleColumnDebounce(true);
},
flowFilterClear(key) {
this.flowFilter = key ? {...this.flowFilter, [key]: []} : emptyProjectFlowFilter();
this.handleColumnDebounce(true);
},
inviteCopy() {
if (!this.inviteData.url) {
return;
@ -2032,16 +2030,7 @@ export default {
},
flowTask(task) {
if ($A.leftExists(this.flowInfo.value, "user:") && !task.task_user.find(({userid, owner}) => userid === this.flowInfo.userid && owner)) {
return true;
} else if ($A.leftExists(this.flowInfo.value, "tag:") && !task.task_tag.find(({name}) => name === this.flowInfo.tag_name)) {
return true;
} else if (this.flowInfo.value > 0 && task.flow_item_id !== this.flowInfo.value) {
return true;
}else if (this.flowInfo.value == -1 && task.start_at) {
return true;
}
return false;
return !taskMatchesProjectFlowFilter(task, this.flowFilter);
},
expiresFormat(date) {

View File

@ -0,0 +1,32 @@
// 项目任务筛选:同组内为「或」,跨组为「且」
// flows: 工作流状态 id(-1 表示未计划);tags: 标签名;users: 负责人 userid
export const projectFlowFilterKeys = ['flows', 'tags', 'users'];
export function emptyProjectFlowFilter() {
return {flows: [], tags: [], users: []};
}
export function projectFlowFilterCount(filter) {
return projectFlowFilterKeys.reduce((total, key) => total + (filter?.[key]?.length || 0), 0);
}
export function taskMatchesProjectFlowFilter(task, filter) {
if (!filter) {
return true;
}
const {flows = [], tags = [], users = []} = filter;
if (flows.length > 0) {
const matchFlow = flows.includes(task.flow_item_id);
const matchUnplanned = flows.includes(-1) && !task.start_at;
if (!matchFlow && !matchUnplanned) {
return false;
}
}
if (tags.length > 0 && !(task.task_tag || []).some(({name}) => tags.includes(name))) {
return false;
}
if (users.length > 0 && !(task.task_user || []).some(({userid, owner}) => owner && users.includes(userid))) {
return false;
}
return true;
}

View File

@ -161,6 +161,7 @@
opacity: 0.9;
height: 30px;
.project-flow {
max-width: 240px;
font-size: 13px;
height: 28px;
line-height: 26px;
@ -202,6 +203,26 @@
left: 8px;
}
}
&.active {
background-color: rgba($primary-color, 0.12);
border-color: rgba($primary-color, 0.35);
color: darken($primary-color, 15%);
}
.project-flow-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.project-flow-clear {
flex-shrink: 0;
margin-left: 4px;
margin-right: -2px;
font-size: 15px;
opacity: 0.6;
&:hover {
opacity: 1;
}
}
}
}
.project-table-settings {
@ -1051,50 +1072,124 @@
}
}
.project-panel-flow-cascader {
max-height: none !important;
.ivu-cascader-menu {
height: auto;
max-height: 360px;
overflow: auto;
}
.ivu-cascader-menu-item {
color: $primary-text-color !important;
&.start {
color: var(--flow-item-custom-color-100, $flow-status-start-color) !important;
}
&.progress {
color: var(--flow-item-custom-color-100, $flow-status-progress-color) !important;
}
&.test {
color: var(--flow-item-custom-color-100, $flow-status-test-color) !important;
}
&.end {
color: var(--flow-item-custom-color-100, $flow-status-end-color) !important;
}
&.tag-dot {
position: relative;
padding-left: 34px;
&:after {
left: 16px;
// 提高优先级,覆盖 common.scss 中 body .ivu-select-dropdown 的 max-height,由内部 body 区域滚动
.ivu-select-dropdown.project-flow-filter-dropdown {
max-width: calc(100vw - 24px);
max-height: none;
overflow: visible;
padding: 0;
.project-flow-filter {
display: flex;
flex-direction: column;
min-width: 180px;
max-width: min(320px, calc(100vw - 24px));
max-height: min(440px, calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 120px));
.flow-filter-head {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px 8px;
border-bottom: 1px solid #EBEEF5;
.flow-filter-count {
color: $primary-title-color;
font-weight: 500;
}
.flow-filter-clear {
color: $primary-color;
cursor: pointer;
&.disabled {
color: #c0c4cc;
cursor: not-allowed;
}
}
}
&.tag-0,
&.user-0 {
margin-top: 12px;
position: relative;
&:before {
content: "";
position: absolute;
top: -6px;
left: 0;
right: 0;
.flow-filter-body {
flex: 1;
overflow-y: auto;
padding-bottom: 6px;
}
.flow-filter-group {
& + .flow-filter-group {
margin-top: 6px;
border-top: 1px solid #EBEEF5;
}
}
.flow-filter-group-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px 4px;
font-size: 12px;
color: #909399;
.flow-filter-group-clear {
color: #b0b3b8;
cursor: pointer;
&:hover {
color: $primary-color;
}
}
}
.flow-filter-section-title {
padding: 6px 16px 2px;
font-size: 12px;
color: #b0b3b8;
}
.flow-filter-search {
padding: 4px 16px;
}
.flow-filter-empty {
padding: 6px 16px;
font-size: 12px;
color: #c0c4cc;
}
.flow-filter-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
color: $primary-text-color;
cursor: pointer;
user-select: none;
&:hover {
background-color: #f3f3f3;
}
&.start .flow-filter-name {
color: var(--flow-item-custom-color-100, $flow-status-start-color);
}
&.progress .flow-filter-name {
color: var(--flow-item-custom-color-100, $flow-status-progress-color);
}
&.test .flow-filter-name {
color: var(--flow-item-custom-color-100, $flow-status-test-color);
}
&.end .flow-filter-name {
color: var(--flow-item-custom-color-100, $flow-status-end-color);
}
.flow-filter-checkbox {
margin-right: 0;
pointer-events: none;
}
.flow-filter-dot {
flex-shrink: 0;
width: 10px;
height: 10px;
border-radius: 50%;
}
.flow-filter-name {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.flow-filter-num {
flex-shrink: 0;
margin-left: 8px;
font-size: 12px;
color: #b0b3b8;
}
}
}
}