perf: 添加项目任务标签功能

This commit is contained in:
kuaifan 2024-12-08 17:13:17 +08:00
parent 248b0ce196
commit 8e108e2d38
6 changed files with 425 additions and 19 deletions

View File

@ -1995,6 +1995,7 @@ class ProjectController extends AbstractController
* @apiParam {String} [content] 任务详情(子任务不支持) * @apiParam {String} [content] 任务详情(子任务不支持)
* @apiParam {String} [color] 背景色(子任务不支持) * @apiParam {String} [color] 背景色(子任务不支持)
* @apiParam {Array} [assist] 修改协助人员(子任务不支持) * @apiParam {Array} [assist] 修改协助人员(子任务不支持)
* @apiParam {Array} [task_tag] 任务标签(子任务不支持)
* @apiParam {Number} [visibility] 修改可见性 * @apiParam {Number} [visibility] 修改可见性
* @apiParam {Array} [visibility_appointor] 修改可见性人员 * @apiParam {Array} [visibility_appointor] 修改可见性人员
* *

View File

@ -944,6 +944,60 @@ class ProjectTask extends AbstractModel
$this->addLog("修改{任务}详细描述", $logRecord); $this->addLog("修改{任务}详细描述", $logRecord);
$updateMarking['is_update_content'] = true; $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; $p = false;
$oldPName = $this->p_name; $oldPName = $this->p_name;

View File

@ -180,9 +180,7 @@
</div> </div>
<template v-if="!item.complete_at"> <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.desc" class="task-desc"><pre v-html="item.desc"></pre></div>
<div v-if="item.task_tag.length > 0" class="task-tags"> <TaskTag v-if="item.task_tag.length > 0" class="task-tags" :tags="item.task_tag"/>
<Tag v-for="(tag, keyt) in item.task_tag" :key="keyt" :color="tag.color">{{tag.name}}</Tag>
</div>
<div class="task-users"> <div class="task-users">
<ul> <ul>
<li v-for="(user, keyu) in ownerUser(item.task_user)" :key="keyu"> <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 ProjectPermission from "./ProjectPermission";
import TaskMenu from "./TaskMenu"; import TaskMenu from "./TaskMenu";
import TaskDeleted from "./TaskDeleted"; import TaskDeleted from "./TaskDeleted";
import TaskTag from "./ProjectTaskTag/tags.vue";
import ProjectGantt from "./ProjectGantt"; import ProjectGantt from "./ProjectGantt";
import UserSelect from "../../../components/UserSelect.vue"; import UserSelect from "../../../components/UserSelect.vue";
import UserAvatarTip from "../../../components/UserAvatar/tip.vue"; import UserAvatarTip from "../../../components/UserAvatar/tip.vue";
@ -546,7 +545,16 @@ export default {
ProjectWorkflow, ProjectWorkflow,
ProjectPermission, ProjectPermission,
DrawerOverlay, DrawerOverlay,
ProjectLog, TaskArchived, TaskRow, Draggable, TaskAddSimple, TaskPriority, TaskDeleted, ProjectGantt}, ProjectLog,
TaskArchived,
TaskRow,
Draggable,
TaskAddSimple,
TaskPriority,
TaskDeleted,
TaskTag,
ProjectGantt,
},
data() { data() {
return { return {
loading: false, loading: false,

View File

@ -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>

View File

@ -147,21 +147,30 @@
@on-history="onHistory" @on-history="onHistory"
@on-blur="updateBlur('content', $event)"/> @on-blur="updateBlur('content', $event)"/>
<Form class="items" label-position="left" label-width="auto" @submit.native.prevent> <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"> <div class="item-label" slot="label">
<i class="taskfont">&#xe61e;</i>{{$L('标签')}} <i class="taskfont">&#xe61e;</i>{{$L('标签')}}
</div> </div>
<ul class="item-content"> <div class="item-content tags">
<li> <EPopover v-model="tagShow" class="tags-select" placement="bottom">
<TagSelect
</li> v-model="tagValue"
</ul> :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>
<FormItem v-if="taskDetail.p_name"> <FormItem v-if="taskDetail.p_name">
<div class="item-label" slot="label"> <div class="item-label" slot="label">
<i class="taskfont">&#xe6ec;</i>{{$L('优先级')}} <i class="taskfont">&#xe6ec;</i>{{$L('优先级')}}
</div> </div>
<ul class="item-content"> <ul class="item-content priority">
<li> <li>
<EDropdown <EDropdown
ref="priority" ref="priority"
@ -300,7 +309,7 @@
<div class="file-size">{{$A.bytesToSize(file.size)}}</div> <div class="file-size">{{$A.bytesToSize(file.size)}}</div>
</li> </li>
</ul> </ul>
<ul class="item-content"> <ul class="item-content file-up">
<li> <li>
<div class="add-button" @click="onUploadClick(true)"> <div class="add-button" @click="onUploadClick(true)">
<i class="taskfont">&#xe6f2;</i> <i class="taskfont">&#xe6f2;</i>
@ -313,7 +322,7 @@
<div class="item-label" slot="label"> <div class="item-label" slot="label">
<i class="taskfont">&#xe6f0;</i>{{$L('子任务')}} <i class="taskfont">&#xe6f0;</i>{{$L('子任务')}}
</div> </div>
<ul class="item-content subtask"> <ul v-if="subList.length > 0" class="item-content subtask">
<TaskDetail <TaskDetail
v-for="(task, key) in subList" v-for="(task, key) in subList"
:ref="`subTask_${task.id}`" :ref="`subTask_${task.id}`"
@ -323,7 +332,7 @@
:main-end-at="taskDetail.end_at" :main-end-at="taskDetail.end_at"
:can-update-blur="canUpdateBlur"/> :can-update-blur="canUpdateBlur"/>
</ul> </ul>
<ul :class="['item-content', subList.length === 0 ? 'nosub' : '']"> <ul class="item-content subtask-add">
<li> <li>
<Input <Input
v-if="addsubShow" v-if="addsubShow"
@ -551,6 +560,8 @@ import {Store} from "le5le-store";
import TaskMenu from "./TaskMenu"; import TaskMenu from "./TaskMenu";
import ChatInput from "./ChatInput"; import ChatInput from "./ChatInput";
import UserSelect from "../../../components/UserSelect.vue"; import UserSelect from "../../../components/UserSelect.vue";
import TaskTag from "./ProjectTaskTag/tags.vue";
import TagSelect from "./ProjectTaskTag/select.vue";
import TaskExistTips from "./TaskExistTips.vue"; import TaskExistTips from "./TaskExistTips.vue";
import TEditorTask from "../../../components/TEditorTask.vue"; import TEditorTask from "../../../components/TEditorTask.vue";
import TaskContentHistory from "./TaskContentHistory.vue"; import TaskContentHistory from "./TaskContentHistory.vue";
@ -561,6 +572,8 @@ export default {
TaskContentHistory, TaskContentHistory,
TEditorTask, TEditorTask,
UserSelect, UserSelect,
TaskTag,
TagSelect,
TaskExistTips, TaskExistTips,
ChatInput, ChatInput,
TaskMenu, TaskMenu,
@ -605,6 +618,13 @@ export default {
receiveShow: false, receiveShow: false,
tagForce: false,
tagShow: false,
tagValue: [],
tagBakValue: [],
tagData: [],
tagLoad: 0,
assistForce: false, assistForce: false,
assistData: {}, assistData: {},
assistLoad: 0, assistLoad: 0,
@ -819,6 +839,14 @@ export default {
return string return string
}, },
getTag() {
const {taskDetail} = this;
if (!$A.isArray(taskDetail.task_tag)) {
return [];
}
return taskDetail.task_tag;
},
getOwner() { getOwner() {
const {taskDetail} = this; const {taskDetail} = this;
if (!$A.isArray(taskDetail.task_user)) { if (!$A.isArray(taskDetail.task_user)) {
@ -842,6 +870,13 @@ export default {
menuList() { menuList() {
const {taskDetail} = this; const {taskDetail} = this;
const list = []; const list = [];
if ($A.arrayLength(taskDetail.task_tag) === 0) {
list.push({
command: 'tag',
icon: '&#xe61e;',
name: '标签',
});
}
if (!taskDetail.p_name) { if (!taskDetail.p_name) {
list.push({ list.push({
command: 'priority', command: 'priority',
@ -934,6 +969,7 @@ export default {
this.timeOpen = false; this.timeOpen = false;
this.timeForce = false; this.timeForce = false;
this.loopForce = false; this.loopForce = false;
this.tagForce = false;
this.assistForce = false; this.assistForce = false;
this.visibleForce = false; this.visibleForce = false;
this.addsubForce = false; this.addsubForce = false;
@ -974,6 +1010,36 @@ export default {
}, },
immediate: true 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: { methods: {
@ -1158,10 +1224,14 @@ export default {
}) })
} }
break; break;
case 'tag':
this.$set(this.taskDetail, 'task_tag', params)
action = 'task_tag'
break;
} }
// //
const dataJson = {task_id: this.taskDetail.id};
let dataJson = {task_id: this.taskDetail.id};
($A.isArray(action) ? action : [action]).forEach(key => { ($A.isArray(action) ? action : [action]).forEach(key => {
let newData = this.taskDetail[key]; let newData = this.taskDetail[key];
let originalData = this.openTask[key]; let originalData = this.openTask[key];
@ -1426,6 +1496,13 @@ export default {
dropAdd(command) { dropAdd(command) {
switch (command) { switch (command) {
case 'tag':
this.tagForce = true;
this.$nextTick(() => {
this.tagShow = true;
});
break;
case 'priority': case 'priority':
this.$set(this.taskDetail, 'p_name', this.$L('未设置')); this.$set(this.taskDetail, 'p_name', this.$L('未设置'));
this.$nextTick(() => { this.$nextTick(() => {

View File

@ -480,9 +480,6 @@
} }
.task-tags { .task-tags {
margin-top: 10px; margin-top: 10px;
.ivu-tag {
}
} }
.task-users { .task-users {
margin-top: 10px; margin-top: 10px;