Files
dootask/resources/assets/js/pages/manage/components/ProjectWorkflow.vue
kuaifan 0863e5529a feat(manage): 实现部门负责人视角,支持只读查看部门成员项目与任务
部门负责人/部门管理员可通过系统配置开启,选择管理部门后只读查看
本部门及下级部门成员的全部项目和任务。前端自动根据 department_readonly
标记禁用编辑操作,后端统一注入负责人视角上下文控制数据访问边界。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:14:36 +00:00

656 lines
32 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="project-workflow">
<div class="workflow-title">
{{$L('工作流设置')}}
<div class="title-icon">
<Loading v-if="loadIng > 0"/>
<Icon v-else type="ios-refresh" @click="getData"/>
</div>
</div>
<div v-if="list.length > 0" class="workflow-content">
<Collapse v-model="openIndex" accordion>
<Panel v-for="data in list" :key="data.id" :name="'index_' + data.id">
<div class="workflow-item">
<div class="workflow-name">{{data.name}}</div>
<div class="workflow-status">
<div
v-for="item in data.project_flow_item"
:key="item.id"
:class="item.status"
:style="$A.generateColorVarStyle(item.color, [10], 'flow-item-custom-color')">
{{item.name}}
</div>
</div>
<div class="workflow-save" @click.stop="">
<template v-if="contrast(data.project_flow_item, data.project_flow_bak)">
<Button :loading="loadIng > 0" type="primary" @click="onSave(data)">{{$L('保存')}}</Button>
<Button v-if="data.id > 0" :disabled="loadIng > 0" type="primary" ghost @click="onReduction(data, $event)">{{$L('还原')}}</Button>
</template>
<Button :disabled="loadIng > 0" type="error" ghost @click="onDelete(data)">{{$L('删除')}}</Button>
</div>
</div>
<div slot="content" class="taskflow-config">
<div v-if="flowRulesMap[data.id] && flowRulesMap[data.id].length > 0" class="taskflow-config-rules">
<div class="rules-title">
<Icon type="md-list-box" />
<span>{{$L('工作流规则')}}</span>
</div>
<div class="rules-list">
<div v-for="(rule, ruleIndex) in flowRulesMap[data.id]" :key="ruleIndex" class="rules-item">
<template v-if="rule.type === 'owner'">
<span>{{$L('流转到')}}</span>
<span class="rule-status" :class="rule.status">{{rule.name}}</span>
<span v-if="rule.usertype === 'add'">{{$L('时添加')}}</span>
<span v-else>{{$L('时改变任务负责人为')}}</span>
<UserAvatar v-for="(uid, uidx) in rule.userids" :key="`${ruleIndex}_${uidx}`" :userid="uid" :size="20" :borderWidth="1" showName/>
<span v-if="rule.usertype === 'add'">{{$L('至任务负责人')}}</span>
<span v-else-if="rule.usertype === 'merge'">{{$L('(并保留操作人),原负责人移至协助人员')}}</span>
<span v-else>{{$L(',原负责人移至协助人员')}}</span>
</template>
<template v-else-if="rule.type === 'limit'">
<span class="rule-status" :class="rule.status">{{rule.name}}</span>
<span>{{$L('仅限任务负责人和项目管理员修改状态')}}</span>
</template>
<template v-else-if="rule.type === 'column'">
<span>{{$L('流转到')}}</span>
<span class="rule-status" :class="rule.status">{{rule.name}}</span>
<span>{{$L('时自动将任务移动至列表')}}</span>
<span class="rule-column">{{rule.columnName}}</span>
</template>
</div>
</div>
</div>
<div class="taskflow-config-table">
<div class="taskflow-config-table-left-container">
<div class="taskflow-config-table-column-header left-header">{{$L('配置项')}}</div>
<div :ref="`overlay_${data.id}`" class="taskflow-config-table-column-body">
<div class="taskflow-config-table-block">
<div class="taskflow-config-table-block-title">{{$L('设置状态为')}}</div>
<div class="taskflow-config-table-block-item">
<div>
<div class="title">{{$L('开始状态')}}</div>
<div class="subtitle">{{$L('新建任务默认状态')}}</div>
</div>
</div>
<div class="taskflow-config-table-block-item">
<div>
<div class="title">{{$L('进行中')}}</div>
<div class="subtitle">{{$L('可设置多个状态为进行中')}}</div>
</div>
</div>
<div class="taskflow-config-table-block-item">
<div>
<div class="title">{{$L('验收/测试')}}</div>
<div class="subtitle">{{$L('只能设置单个状态为验收/测试')}}</div>
</div>
</div>
<div class="taskflow-config-table-block-item">
<div>
<div class="title">{{$L('结束状态')}}</div>
<div class="subtitle">{{$L('该状态下任务自动标记完成')}}</div>
</div>
</div>
</div>
<div class="taskflow-config-table-block hr">
<div class="taskflow-config-table-block-title">{{$L('可流转到')}}</div>
<div v-for="item in data.project_flow_item" :key="item.id" class="taskflow-config-table-block-item">
<span class="transform-status-name">{{item.name}}</span>
</div>
</div>
</div>
</div>
<div class="taskflow-config-table-right-container">
<Draggable
:list="data.project_flow_item"
:animation="150"
:disabled="$isEEUIApp || windowTouch"
class="taskflow-config-table-list-wrapper"
tag="div"
draggable=".column-border"
@sort="() => {}">
<div
v-for="(item, index) in data.project_flow_item"
:key="index"
:style="$A.generateColorVarStyle(item.color, [10, 20, 70], 'flow-item-custom-color')"
class="taskflow-config-table-status-column column-border"
:class="item.status">
<div
class="taskflow-config-table-status-item taskflow-config-table-column-header">
<div class="status-label-with-menu" :class="item.status">
<div class="name">{{item.name}}</div>
<EDropdown
trigger="click"
class="more"
:class="{opacity: item.userids.length > 0 || item.columnid > 0}"
@command="onMore($event, item)">
<div class="more-icon">
<Badge :dot="item.userids.length > 0 || item.columnid > 0">
<Icon type="ios-more" />
</Badge>
</div>
<EDropdownMenu slot="dropdown" class="taskflow-config-more-dropdown-menu">
<li class="taskflow-config-more-dropdown-warp">
<ul>
<EDropdownItem v-if="item.userids.length > 0" command="user">
<div class="users">
<UserAvatar v-for="(uid, ukey) in item.userids" :key="ukey" :userid="uid" :size="28" :borderWidth="1" :showName="item.userids.length === 1"/>
</div>
</EDropdownItem>
<EDropdownItem command="user">
<div class="item">
<Icon type="md-settings" />
<Badge :dot="item.userids.length > 0 || item.columnid > 0">
{{$L('状态设置')}}
</Badge>
</div>
</EDropdownItem>
<EDropdownItem command="name">
<div class="item">
<Icon type="md-create" />{{$L('修改名称')}}
</div>
</EDropdownItem>
<EDropdownItem command="remove">
<div class="item delete">
<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 == item.color ? '&#xe61d;' : '&#xe61c;'"></i>{{$L(c.name)}}
</div>
</EDropdownItem>
</ul>
</li>
</EDropdownMenu>
</EDropdown>
</div>
</div>
<div :ref="`overlay_${data.id}`" class="taskflow-config-table-column-body">
<div class="taskflow-config-table-block">
<div class="taskflow-config-table-block-title"></div>
<RadioGroup v-model="item.status">
<Radio label="start"><span></span></Radio>
<Radio label="progress"><span></span></Radio>
<Radio label="test"><span></span></Radio>
<Radio label="end"><span></span></Radio>
</RadioGroup>
</div>
<div class="taskflow-config-table-block">
<div class="taskflow-config-table-block-title"></div>
<CheckboxGroup v-model="item.turns" @on-change="onTurns(item)">
<Checkbox v-for="v in data.project_flow_item" :key="v.id" :label="v.id" :disabled="v.id==item.id"><span></span></Checkbox>
</CheckboxGroup>
</div>
</div>
</div>
<div class="taskflow-config-table-status-column addnew" @click="onAdd(data)">{{$L('添加状态')}}</div>
</Draggable>
</div>
</div>
</div>
</Panel>
</Collapse>
</div>
<div v-else-if="loadIng == 0" class="workflow-no">
{{$L('当前项目还没有创建工作流')}}
<Button type="primary" icon="md-add" @click="onCreate">{{$L('创建工作流')}}</Button>
</div>
<!--状态设置-->
<Modal
v-model="userShow"
:styles="{
width: '90%',
maxWidth: '640px'
}"
:title="`${$L('状态设置')} (${settingData.name})`"
:mask-closable="false">
<Form :model="settingData" v-bind="formOptions" @submit.native.prevent>
<div class="workflow-setting-box">
<h3>{{ $L('状态负责人') }}</h3>
<div class="form-box">
<FormItem prop="userids" :label="$L('状态负责人')">
<UserSelect v-model="settingData.userids" :project-id="projectId" :multiple-max="5" :title="$L('选择状态负责人')"/>
</FormItem>
<FormItem prop="usertype" :label="$L('流转模式')">
<RadioGroup v-model="settingData.usertype">
<Radio label="add">{{$L('添加模式')}}</Radio>
<Radio label="replace">{{$L('流转模式')}}</Radio>
<Radio label="merge">{{$L('剔除模式')}}</Radio>
</RadioGroup>
<div v-if="settingData.usertype=='replace'" class="form-tip">{{$L(`流转到【${settingData.name}】时改变任务负责人为状态负责人,原本的任务负责人移至协助人员。`)}}</div>
<div v-else-if="settingData.usertype=='merge'" class="form-tip">{{$L(`流转到【${settingData.name}】时改变任务负责人为状态负责人(并保留操作状态的人员),原本的任务负责人移至协助人员。`)}}</div>
<div v-else class="form-tip">{{$L(`流转到【${settingData.name}】时添加状态负责人至任务负责人。`)}}</div>
</FormItem>
<FormItem prop="userlimit" :label="$L('限制负责人')">
<iSwitch v-model="settingData.userlimit" :true-value="1" :false-value="0"/>
<div v-if="settingData.userlimit===1" class="form-tip">{{$L(`流转到【${settingData.name}】时,[任务负责人] 和 [项目管理员] 可以修改状态。`)}}</div>
<div v-else class="form-tip">{{$L(`流转到【${settingData.name}】时,[任务负责人] 和 [项目管理员] 可以修改状态。`)}}</div>
</FormItem>
</div>
</div>
<div class="workflow-setting-box">
<h3>{{ $L('关联列表') }}</h3>
<div class="form-box">
<FormItem prop="usertype" :label="$L('关联列表')">
<Select v-model="settingData.columnid" :placeholder="$L('选择关联列表')" transfer>
<Option v-for="(item, index) in columnList" :value="item.id" :key="index">{{ item.name }}</Option>
</Select>
<div class="form-tip">
{{$L(`流转到【${settingData.name}】时自动将任务移动至关联列表。`)}}
<a v-if="settingData.columnid" href="javascript:void(0)" @click="settingData.columnid=0">{{$L('取消关联')}}</a>
</div>
</FormItem>
</div>
</div>
</Form>
<div slot="footer" class="adaption">
<Button type="default" @click="userShow=false">{{$L('取消')}}</Button>
<Button type="primary" @click="onUser">{{$L('保存')}}</Button>
</div>
</Modal>
</div>
</template>
<script>
import Draggable from "vuedraggable";
import UserSelect from "../../../components/UserSelect.vue";
import {mapState} from "vuex";
export default {
name: "ProjectWorkflow",
components: {UserSelect, Draggable},
props: {
projectId: {
type: Number,
default: 0
},
},
data() {
return {
loadIng: 0,
list: [],
openIndex: "",
userShow: false,
settingData: {},
}
},
mounted() {
},
computed: {
...mapState(['cacheColumns', 'formOptions']),
columnList({projectId, cacheColumns}) {
return cacheColumns.filter(({project_id}) => {
return project_id == projectId
}).sort((a, b) => {
if (a.sort != b.sort) {
return $A.sortFloat(a.sort, b.sort);
}
return a.id - b.id;
}).map(item => {
return {
id: item.id,
name: item.name,
}
});
},
flowRulesMap() {
const map = {};
const columnMap = {};
this.columnList.forEach(col => {
columnMap[col.id] = col.name;
});
this.list.forEach(data => {
const rules = [];
data.project_flow_item.forEach(item => {
// 状态负责人规则
if (item.userids && item.userids.length > 0) {
rules.push({
type: 'owner',
name: item.name,
status: item.status,
userids: item.userids,
usertype: item.usertype
});
}
// 限制负责人规则(不依赖状态负责人)
if (item.userlimit === 1) {
rules.push({
type: 'limit',
name: item.name,
status: item.status
});
}
// 关联列表规则
if (item.columnid && columnMap[item.columnid]) {
rules.push({
type: 'column',
name: item.name,
status: item.status,
columnName: columnMap[item.columnid]
});
}
});
map[data.id] = rules;
});
return map;
}
},
watch: {
projectId: {
handler(val) {
if (val) {
this.getData()
}
},
immediate: true
},
},
methods: {
getData() {
this.loadIng++;
this.$store.dispatch("call", {
url: 'project/flow/list',
data: {
project_id: this.projectId
},
}).then(({data}) => {
this.list = data.map(item => {
item.project_flow_bak = JSON.stringify(item.project_flow_item)
return item;
});
this.openIndex = this.list.length === 1 ? ("index_" + this.list[0].id) : ""
this.$nextTick(this.syncScroller);
}).catch(({msg}) => {
$A.modalError(msg);
}).finally(_ => {
this.loadIng--;
});
},
syncScroller() {
this.list.some(data => {
this.$refs[`overlay_${data.id}`] && this.$refs[`overlay_${data.id}`].some(el => {
if (!Object.keys(el.attributes).includes("sync-scroller")) {
el.setAttribute("sync-scroller", true);
el.addEventListener('scroll', ({target}) => {
let top = target.scrollTop;
let left = target.scrollLeft;
this.$nextTick(() => {
this.$refs[`overlay_${data.id}`].some(node => {
if (node != el) {
node.scrollTo(left, top)
}
})
})
});
}
});
})
},
contrast(project_flow_item, project_flow_bak) {
return JSON.stringify(project_flow_item) != project_flow_bak
},
existDiff() {
return !!this.list.find(data => {
return this.contrast(data.project_flow_item, data.project_flow_bak)
});
},
onCreate() {
let id = -1 * $A.randNum(1000, 10000);
this.list.push({
"id": id,
"name": "Default",
"project_flow_item": [
{
"id": -10,
"name": this.$L("待处理"),
"status": "start",
"turns": [-10, -11, -12, -13, -14],
"userids": [],
"usertype": 'add',
"userlimit": 0,
"columnid": 0,
},
{
"id": -11,
"name": this.$L("进行中"),
"status": "progress",
"turns": [-10, -11, -12, -13, -14],
"userids": [],
"usertype": 'add',
"userlimit": 0,
"columnid": 0,
},
{
"id": -12,
"name": this.$L("待测试"),
"status": "test",
"turns": [-10, -11, -12, -13, -14],
"userids": [],
"usertype": 'add',
"userlimit": 0,
"columnid": 0,
},
{
"id": -13,
"name": this.$L("已完成"),
"status": "end",
"turns": [-10, -11, -12, -13, -14],
"userids": [],
"usertype": 'add',
"userlimit": 0,
"columnid": 0,
},
{
"id": -14,
"name": this.$L("已取消"),
"status": "end",
"color": "#999999",
"turns": [-10, -11, -12, -13, -14],
"userids": [],
"usertype": 'add',
"userlimit": 0,
"columnid": 0,
}
]
})
this.openIndex = "index_" + id;
this.$nextTick(this.syncScroller);
},
onDelete(data) {
$A.modalConfirm({
title: '删除工作流',
content: '你确定要删除工作流吗?',
loading: true,
onOk: () => {
if (data.id > 0) {
return new Promise((resolve, reject) => {
this.loadIng++;
this.$store.dispatch("call", {
url: 'project/flow/delete',
data: {
project_id: this.projectId,
},
}).then(({msg}) => {
resolve(msg);
//
let index = this.list.findIndex(({id}) => id == data.id)
if (index > -1) {
this.list.splice(index, 1)
}
}).catch(({msg}) => {
reject(msg);
}).finally(_ => {
this.loadIng--;
});
})
}
const index = this.list.findIndex(({id}) => id == data.id)
if (index > -1) {
this.list.splice(index, 1)
}
}
});
},
onMore(command, item) {
switch (command) {
case "user":
this.$set(this.settingData, 'id', item.id);
this.$set(this.settingData, 'name', item.name);
this.$set(this.settingData, 'userids', item.userids);
this.$set(this.settingData, 'usertype', item.usertype);
this.$set(this.settingData, 'userlimit', item.userlimit);
this.$set(this.settingData, 'columnid', item.columnid);
this.userShow = true;
break;
case "name":
this.onName(item);
break;
case "remove":
this.onRemove(item);
break;
default:
if (command.name) {
this.$set(item, 'color', command.color);
}
}
},
onUser() {
this.userShow = false;
this.list.some(data => {
let item = data.project_flow_item.find(item => item.id == this.settingData.id)
if (item) {
this.$set(item, 'userids', this.settingData.userids)
this.$set(item, 'usertype', this.settingData.usertype)
this.$set(item, 'userlimit', this.settingData.userlimit)
this.$set(item, 'columnid', this.settingData.columnid)
}
})
},
onName(item) {
$A.modalInput({
value: item.name,
title: "修改名称",
placeholder: "请输入流程名称",
onOk: (name) => {
if (!name) {
return '请输入流程名称';
}
this.$set(item, 'name', name);
}
});
},
onRemove(item) {
this.list.some(data => {
let index = data.project_flow_item.findIndex(({id}) => id == item.id)
if (index > -1) {
data.project_flow_item.splice(index, 1)
}
})
},
onTurns(item) {
this.$set(item, 'turns', item.turns.sort())
},
onAdd(data) {
$A.modalInput({
title: "添加状态",
placeholder: "请输入状态名称",
onOk: (name) => {
if (!name) {
return '请输入状态名称'
}
const id = $A.randNum(100000, 999999) * -1;
const turns = data.project_flow_item.map(({id}) => id)
data.project_flow_item.push({
id,
name,
status: 'end',
turns,
userids: [],
usertype: 'add',
userlimit: 0,
columnid: 0,
})
data.project_flow_item.some(item => {
item.turns.push(id)
})
}
});
},
onReduction(data) {
this.$set(data, 'project_flow_item', JSON.parse(data.project_flow_bak))
},
onSave(formData) {
let sort = 0;
formData.project_flow_item.some(item => {
item.sort = sort++
})
this.loadIng++;
this.$store.dispatch("call", {
url: 'project/flow/save',
data: {
project_id: this.projectId,
flows: formData.project_flow_item,
},
method: 'post',
}).then(({data, msg}) => {
$.messageSuccess(msg)
//
data.project_flow_bak = JSON.stringify(data.project_flow_item)
let index = this.list.findIndex(({id}) => id == formData.id)
if (index > -1) {
this.list.splice(index, 1, data)
} else {
this.list.push(data)
}
this.openIndex = "index_" + data.id;
this.$nextTick(this.syncScroller);
}).catch(({msg}) => {
$A.modalError(msg);
}).finally(_ => {
this.loadIng--;
});
},
saveAll() {
this.list.some(data => {
if (this.contrast(data.project_flow_item, data.project_flow_bak)) {
this.onSave(data)
}
});
},
}
}
</script>