mirror of
https://github.com/kuaifan/dootask.git
synced 2026-10-30 06:20:44 +00:00
feat(project): 项目页筛选下拉支持多选组合
- 顶部筛选由 Cascader 单选改为分组复选下拉:状态(含未计划)/ 标签 / 负责人,同组为「或」、跨组为「且」 - 勾选时下拉不关闭;顶部显示已选数量与「清空」,每组可单独清除,按钮上的 × 一键清空 - 按钮显示:未筛选「全部 (n)」、单选显示该项(状态带颜色)、多选按组汇总如「状态·2 / 负责人·1 (n)」 - 弹层宽度随内容自适应(180–320px),内容区内部滚动;负责人超过 10 人时显示搜索框 - 抽出 utils/projectFlowFilter 统一看板、列表、甘特图的过滤逻辑,修复甘特图未按标签和未计划过滤 - 勾选「已完成」类状态仍自动开启显示已完成,切换项目重置筛选 - 同步 ai-kb 视图筛选相关说明
This commit is contained in:
parent
c06d61a11f
commit
d4c7cdba6f
@ -2784,3 +2784,7 @@ WebDAV 路径冲突
|
||||
部门总览
|
||||
全部部门
|
||||
团队成员 (*) 人
|
||||
已选(*)项
|
||||
未筛选
|
||||
清除
|
||||
无匹配成员
|
||||
|
||||
@ -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):决定显示哪些任务
|
||||
- 筛选(顶部筛选下拉,可多选组合):决定显示哪些任务
|
||||
- 两者正交:任意视图都能叠加筛选
|
||||
|
||||
@ -18,7 +18,7 @@ negative:
|
||||
- 移动端 / 触屏端拖拽全部禁用(列、任务、甘特图都不能拖)
|
||||
- 工作流的「待办 → 完成」必须从任务详情页改字段
|
||||
- 移动端甘特图体验受限(屏宽不够),适合临时查看而非编辑
|
||||
last_verified: v1.7.90
|
||||
last_verified: v1.9.36
|
||||
---
|
||||
|
||||
# 移动端能用哪些视图
|
||||
@ -33,7 +33,7 @@ DooTask 的项目视图组件在前端检测 `$isEEUIApp`(移动端壳)和 `
|
||||
- **看板视图**:可正常查看每列任务;点击卡片进入详情;不能拖任务也不能拖列
|
||||
- **列表视图**:完整可用;点击表头排序、点击行进入详情都可用
|
||||
- **甘特图**:可查看时间轴和悬停信息;不能拖动改时间,需进任务详情页改字段
|
||||
- **工作流筛选**:顶部 Cascader 可用,与桌面一致
|
||||
- **工作流筛选**:顶部筛选下拉可用(点击展开、可多选),与桌面一致
|
||||
|
||||
## 状态变更怎么办
|
||||
1. 点开任务详情
|
||||
|
||||
@ -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」)
|
||||
- 不支持保存自定义筛选 / 我的筛选
|
||||
- 不支持按截止时间区间筛选
|
||||
- 不支持按创建时间筛选
|
||||
|
||||
@ -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 视图)
|
||||
|
||||
@ -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 的任务(未排期)
|
||||
|
||||
@ -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 筛选)
|
||||
- 没有独立筛选器(用顶部筛选下拉筛选)
|
||||
|
||||
@ -18,8 +18,8 @@ negative:
|
||||
- 视图选择按项目持久化(cacheParameter.menuType),换项目不会自动跟随
|
||||
- 没有快捷键切换视图
|
||||
- 移动端视图切换按钮位置与桌面一致,但小屏更靠右
|
||||
- 工作流视图不是这里切换,是通过工作流筛选 Cascader 叠加
|
||||
last_verified: v1.9.18
|
||||
- 工作流视图不是这里切换,是通过顶部筛选下拉的「状态」组叠加
|
||||
last_verified: v1.9.36
|
||||
---
|
||||
|
||||
# 切换看板 / 列表 / 甘特图视图
|
||||
|
||||
@ -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>
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
32
resources/assets/js/utils/projectFlowFilter.js
vendored
Normal file
32
resources/assets/js/utils/projectFlowFilter.js
vendored
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user