mirror of
https://github.com/kuaifan/dootask.git
synced 2026-02-06 13:15:35 +00:00
perf: 添加项目任务标签功能
This commit is contained in:
parent
248b0ce196
commit
8e108e2d38
@ -1995,6 +1995,7 @@ class ProjectController extends AbstractController
|
||||
* @apiParam {String} [content] 任务详情(子任务不支持)
|
||||
* @apiParam {String} [color] 背景色(子任务不支持)
|
||||
* @apiParam {Array} [assist] 修改协助人员(子任务不支持)
|
||||
* @apiParam {Array} [task_tag] 任务标签(子任务不支持)
|
||||
* @apiParam {Number} [visibility] 修改可见性
|
||||
* @apiParam {Array} [visibility_appointor] 修改可见性人员
|
||||
*
|
||||
|
||||
@ -944,6 +944,60 @@ class ProjectTask extends AbstractModel
|
||||
$this->addLog("修改{任务}详细描述", $logRecord);
|
||||
$updateMarking['is_update_content'] = true;
|
||||
}
|
||||
// 标签
|
||||
if (Arr::exists($data, 'task_tag')) {
|
||||
$oldTags = collect($this->taskTag);
|
||||
$newTags = collect($data['task_tag']);
|
||||
|
||||
// 找出需要删除的标签(在旧数据中存在,但在新数据中不存在)
|
||||
$deletedTags = $oldTags->filter(function ($oldTag) use ($newTags) {
|
||||
return !$newTags->contains('name', $oldTag['name']);
|
||||
});
|
||||
if ($deletedTags->isNotEmpty()) {
|
||||
$this->addLog("删除{任务}标签", [
|
||||
'tags' => $deletedTags->values()->all()
|
||||
]);
|
||||
ProjectTaskTag::whereProjectId($this->project_id)
|
||||
->whereTaskId($this->id)
|
||||
->whereIn('name', $deletedTags->pluck('name'))
|
||||
->delete();
|
||||
}
|
||||
|
||||
// 找出需要新增的标签(在新数据中存在,但在旧数据中不存在)
|
||||
$addedTags = $newTags->filter(function ($newTag) use ($oldTags) {
|
||||
return !$oldTags->contains('name', $newTag['name']);
|
||||
});
|
||||
if ($addedTags->isNotEmpty()) {
|
||||
$this->addLog("新增{任务}标签", [
|
||||
'tags' => $addedTags->values()->all()
|
||||
]);
|
||||
$addedTags->each(function ($tag) {
|
||||
ProjectTaskTag::createInstance([
|
||||
'project_id' => $this->project_id,
|
||||
'task_id' => $this->id,
|
||||
'name' => $tag['name'],
|
||||
'color' => $tag['color'],
|
||||
])->save();
|
||||
});
|
||||
}
|
||||
|
||||
// 找出需要更新的标签(标签名相同,但其他属性可能变化)
|
||||
$updatedTags = $newTags->filter(function ($newTag) use ($oldTags) {
|
||||
$oldTag = $oldTags->firstWhere('name', $newTag['name']);
|
||||
return $oldTag && ($oldTag['color'] !== $newTag['color']);
|
||||
});
|
||||
if ($updatedTags->isNotEmpty()) {
|
||||
$this->addLog("更新{任务}标签", [
|
||||
'tags' => $updatedTags->values()->all()
|
||||
]);
|
||||
$updatedTags->each(function ($tag) {
|
||||
ProjectTaskTag::whereProjectId($this->project_id)
|
||||
->whereTaskId($this->id)
|
||||
->whereName($tag['name'])
|
||||
->update(['color' => $tag['color']]);
|
||||
});
|
||||
}
|
||||
}
|
||||
// 优先级
|
||||
$p = false;
|
||||
$oldPName = $this->p_name;
|
||||
|
||||
@ -180,9 +180,7 @@
|
||||
</div>
|
||||
<template v-if="!item.complete_at">
|
||||
<div v-if="item.desc" class="task-desc"><pre v-html="item.desc"></pre></div>
|
||||
<div v-if="item.task_tag.length > 0" class="task-tags">
|
||||
<Tag v-for="(tag, keyt) in item.task_tag" :key="keyt" :color="tag.color">{{tag.name}}</Tag>
|
||||
</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">
|
||||
@ -529,6 +527,7 @@ 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 UserSelect from "../../../components/UserSelect.vue";
|
||||
import UserAvatarTip from "../../../components/UserAvatar/tip.vue";
|
||||
@ -546,7 +545,16 @@ export default {
|
||||
ProjectWorkflow,
|
||||
ProjectPermission,
|
||||
DrawerOverlay,
|
||||
ProjectLog, TaskArchived, TaskRow, Draggable, TaskAddSimple, TaskPriority, TaskDeleted, ProjectGantt},
|
||||
ProjectLog,
|
||||
TaskArchived,
|
||||
TaskRow,
|
||||
Draggable,
|
||||
TaskAddSimple,
|
||||
TaskPriority,
|
||||
TaskDeleted,
|
||||
TaskTag,
|
||||
ProjectGantt,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
|
||||
@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="task-tag-select" :class="{'no-search': filteredTags.length <= 5}">
|
||||
<!-- Search Box -->
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$L('搜索标签')"
|
||||
class="search-input"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Tag List -->
|
||||
<div class="tag-list">
|
||||
<template v-if="filteredTags.length">
|
||||
<div
|
||||
v-for="tag in filteredTags"
|
||||
:key="tag.name"
|
||||
class="tag-item"
|
||||
:class="{ 'is-selected': isSelected(tag) }"
|
||||
@click="toggleTag(tag)"
|
||||
>
|
||||
<div class="tag-color" :style="{ backgroundColor: tag.color }"></div>
|
||||
<div class="tag-info">
|
||||
<div class="tag-name">{{ tag.name }}</div>
|
||||
<div class="tag-desc" v-if="tag.desc">{{ tag.desc }}</div>
|
||||
</div>
|
||||
<div class="tag-check" v-if="isSelected(tag)">
|
||||
<i class="el-icon-check"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="!loading" class="no-data">{{ $L('暂无标签') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Button -->
|
||||
<div class="footer-box">
|
||||
<div class="add-button" @click="$emit('add')">
|
||||
<i class="el-icon-plus"></i>
|
||||
<span>{{ $L('添加标签') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<Spin v-if="loading" fix/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "TaskSelect",
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
dataSources: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchQuery: '',
|
||||
internalDataSources: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.syncValueToDataSources();
|
||||
}
|
||||
},
|
||||
dataSources: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.internalDataSources = [...newVal];
|
||||
this.syncValueToDataSources();
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredTags() {
|
||||
return this.internalDataSources.filter(tag =>
|
||||
tag.name.toLowerCase().includes(this.searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isSelected(tag) {
|
||||
return this.value.some(item => item.name === tag.name);
|
||||
},
|
||||
toggleTag(tag) {
|
||||
const isSelected = this.isSelected(tag);
|
||||
let newValue;
|
||||
if (isSelected) {
|
||||
newValue = this.value.filter(item => item.name !== tag.name);
|
||||
} else {
|
||||
if (this.max > 0 && this.value.length >= this.max) {
|
||||
$A.messageWarning(this.$L('最多只能选择 (*) 个标签', this.max));
|
||||
return;
|
||||
}
|
||||
newValue = [...this.value, { name: tag.name, color: tag.color }];
|
||||
}
|
||||
this.$emit('input', newValue);
|
||||
},
|
||||
syncValueToDataSources() {
|
||||
if (!this.value || !this.internalDataSources) return;
|
||||
|
||||
const missingTags = this.value.filter(valueTag =>
|
||||
!this.internalDataSources.some(sourceTag => sourceTag.name === valueTag.name)
|
||||
);
|
||||
|
||||
if (missingTags.length) {
|
||||
this.internalDataSources = [
|
||||
...missingTags.map(tag => ({
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
desc: ''
|
||||
})),
|
||||
...this.internalDataSources
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.task-tag-select {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.no-search {
|
||||
.search-box {
|
||||
display: none;
|
||||
}
|
||||
.tag-list {
|
||||
.tag-item {
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: #84C56A;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 300px;
|
||||
|
||||
.tag-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
|
||||
.tag-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tag-info {
|
||||
flex: 1;
|
||||
|
||||
.tag-name {
|
||||
line-height: 20px;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.tag-desc {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-check {
|
||||
color: #84C56A;
|
||||
margin-left: 12px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
padding: 24px 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-box {
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 8px;
|
||||
|
||||
.add-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 0 2px;
|
||||
cursor: pointer;
|
||||
color: #84C56A;
|
||||
border-radius: 6px;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #a2d98d;
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -147,21 +147,30 @@
|
||||
@on-history="onHistory"
|
||||
@on-blur="updateBlur('content', $event)"/>
|
||||
<Form class="items" label-position="left" label-width="auto" @submit.native.prevent>
|
||||
<FormItem v-if="taskDetail.p_name">
|
||||
<FormItem v-if="getTag.length > 0 || tagForce">
|
||||
<div class="item-label" slot="label">
|
||||
<i class="taskfont"></i>{{$L('标签')}}
|
||||
</div>
|
||||
<ul class="item-content">
|
||||
<li>
|
||||
|
||||
</li>
|
||||
</ul>
|
||||
<div class="item-content tags">
|
||||
<EPopover v-model="tagShow" class="tags-select" placement="bottom">
|
||||
<TagSelect
|
||||
v-model="tagValue"
|
||||
:data-sources="tagData"
|
||||
:loading="tagLoad > 0"
|
||||
:max="10"/>
|
||||
<div slot="reference">
|
||||
<TaskTag :tags="getTag">
|
||||
<li v-if="getTag.length === 0" slot="end" class="add-icon"></li>
|
||||
</TaskTag>
|
||||
</div>
|
||||
</EPopover>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem v-if="taskDetail.p_name">
|
||||
<div class="item-label" slot="label">
|
||||
<i class="taskfont"></i>{{$L('优先级')}}
|
||||
</div>
|
||||
<ul class="item-content">
|
||||
<ul class="item-content priority">
|
||||
<li>
|
||||
<EDropdown
|
||||
ref="priority"
|
||||
@ -300,7 +309,7 @@
|
||||
<div class="file-size">{{$A.bytesToSize(file.size)}}</div>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="item-content">
|
||||
<ul class="item-content file-up">
|
||||
<li>
|
||||
<div class="add-button" @click="onUploadClick(true)">
|
||||
<i class="taskfont"></i>
|
||||
@ -313,7 +322,7 @@
|
||||
<div class="item-label" slot="label">
|
||||
<i class="taskfont"></i>{{$L('子任务')}}
|
||||
</div>
|
||||
<ul class="item-content subtask">
|
||||
<ul v-if="subList.length > 0" class="item-content subtask">
|
||||
<TaskDetail
|
||||
v-for="(task, key) in subList"
|
||||
:ref="`subTask_${task.id}`"
|
||||
@ -323,7 +332,7 @@
|
||||
:main-end-at="taskDetail.end_at"
|
||||
:can-update-blur="canUpdateBlur"/>
|
||||
</ul>
|
||||
<ul :class="['item-content', subList.length === 0 ? 'nosub' : '']">
|
||||
<ul class="item-content subtask-add">
|
||||
<li>
|
||||
<Input
|
||||
v-if="addsubShow"
|
||||
@ -551,6 +560,8 @@ import {Store} from "le5le-store";
|
||||
import TaskMenu from "./TaskMenu";
|
||||
import ChatInput from "./ChatInput";
|
||||
import UserSelect from "../../../components/UserSelect.vue";
|
||||
import TaskTag from "./ProjectTaskTag/tags.vue";
|
||||
import TagSelect from "./ProjectTaskTag/select.vue";
|
||||
import TaskExistTips from "./TaskExistTips.vue";
|
||||
import TEditorTask from "../../../components/TEditorTask.vue";
|
||||
import TaskContentHistory from "./TaskContentHistory.vue";
|
||||
@ -561,6 +572,8 @@ export default {
|
||||
TaskContentHistory,
|
||||
TEditorTask,
|
||||
UserSelect,
|
||||
TaskTag,
|
||||
TagSelect,
|
||||
TaskExistTips,
|
||||
ChatInput,
|
||||
TaskMenu,
|
||||
@ -605,6 +618,13 @@ export default {
|
||||
|
||||
receiveShow: false,
|
||||
|
||||
tagForce: false,
|
||||
tagShow: false,
|
||||
tagValue: [],
|
||||
tagBakValue: [],
|
||||
tagData: [],
|
||||
tagLoad: 0,
|
||||
|
||||
assistForce: false,
|
||||
assistData: {},
|
||||
assistLoad: 0,
|
||||
@ -819,6 +839,14 @@ export default {
|
||||
return string
|
||||
},
|
||||
|
||||
getTag() {
|
||||
const {taskDetail} = this;
|
||||
if (!$A.isArray(taskDetail.task_tag)) {
|
||||
return [];
|
||||
}
|
||||
return taskDetail.task_tag;
|
||||
},
|
||||
|
||||
getOwner() {
|
||||
const {taskDetail} = this;
|
||||
if (!$A.isArray(taskDetail.task_user)) {
|
||||
@ -842,6 +870,13 @@ export default {
|
||||
menuList() {
|
||||
const {taskDetail} = this;
|
||||
const list = [];
|
||||
if ($A.arrayLength(taskDetail.task_tag) === 0) {
|
||||
list.push({
|
||||
command: 'tag',
|
||||
icon: '',
|
||||
name: '标签',
|
||||
});
|
||||
}
|
||||
if (!taskDetail.p_name) {
|
||||
list.push({
|
||||
command: 'priority',
|
||||
@ -934,6 +969,7 @@ export default {
|
||||
this.timeOpen = false;
|
||||
this.timeForce = false;
|
||||
this.loopForce = false;
|
||||
this.tagForce = false;
|
||||
this.assistForce = false;
|
||||
this.visibleForce = false;
|
||||
this.addsubForce = false;
|
||||
@ -974,6 +1010,36 @@ export default {
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
tagShow(val) {
|
||||
if (val) {
|
||||
this.tagValue = this.getTag;
|
||||
this.tagBakValue = $A.cloneJSON(this.tagValue);
|
||||
//
|
||||
const isLoad = this.tagValue.length === 0 && this.tagData.length === 0;
|
||||
isLoad && this.tagLoad++;
|
||||
this.$store.dispatch("call", {
|
||||
url: "project/tag/list",
|
||||
data: {
|
||||
project_id: this.taskDetail.project_id
|
||||
}
|
||||
}).then(res => {
|
||||
this.tagData = res.data;
|
||||
}).finally(_ => {
|
||||
isLoad && this.tagLoad--;
|
||||
})
|
||||
} else {
|
||||
const isChanged = (() => {
|
||||
if (this.tagValue.length !== this.tagBakValue.length) return true;
|
||||
const sortValue = arr => [...arr].map(({name, color}) => ({name, color})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const sortedValue = sortValue(this.tagValue);
|
||||
const sortedBakValue = sortValue(this.tagBakValue);
|
||||
return JSON.stringify(sortedValue) !== JSON.stringify(sortedBakValue);
|
||||
})();
|
||||
if (isChanged) {
|
||||
this.updateData('tag', this.tagValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
@ -1158,10 +1224,14 @@ export default {
|
||||
})
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tag':
|
||||
this.$set(this.taskDetail, 'task_tag', params)
|
||||
action = 'task_tag'
|
||||
break;
|
||||
}
|
||||
//
|
||||
|
||||
let dataJson = {task_id: this.taskDetail.id};
|
||||
const dataJson = {task_id: this.taskDetail.id};
|
||||
($A.isArray(action) ? action : [action]).forEach(key => {
|
||||
let newData = this.taskDetail[key];
|
||||
let originalData = this.openTask[key];
|
||||
@ -1426,6 +1496,13 @@ export default {
|
||||
|
||||
dropAdd(command) {
|
||||
switch (command) {
|
||||
case 'tag':
|
||||
this.tagForce = true;
|
||||
this.$nextTick(() => {
|
||||
this.tagShow = true;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'priority':
|
||||
this.$set(this.taskDetail, 'p_name', this.$L('未设置'));
|
||||
this.$nextTick(() => {
|
||||
|
||||
@ -480,9 +480,6 @@
|
||||
}
|
||||
.task-tags {
|
||||
margin-top: 10px;
|
||||
.ivu-tag {
|
||||
|
||||
}
|
||||
}
|
||||
.task-users {
|
||||
margin-top: 10px;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user