feat: 添加任务模板排序功能

- 在 ProjectController 中新增 task__template_sort 方法,支持项目任务模板的排序
- 更新前端组件以支持拖拽调整任务模板顺序
- 新增数据库迁移以填充任务模板的排序字段
- 优化样式以提升用户体验
This commit is contained in:
kuaifan 2025-09-24 20:49:09 +08:00
parent 03860a6dce
commit 652dc0953b
4 changed files with 325 additions and 107 deletions

View File

@ -2997,6 +2997,7 @@ class ProjectController extends AbstractController
return Base::retError('参数错误'); return Base::retError('参数错误');
} }
$templates = ProjectTaskTemplate::where('project_id', $projectId) $templates = ProjectTaskTemplate::where('project_id', $projectId)
->orderBy('sort')
->orderByDesc('id') ->orderByDesc('id')
->get(); ->get();
return Base::retSuccess('success', $templates); return Base::retSuccess('success', $templates);
@ -3060,11 +3061,66 @@ class ProjectController extends AbstractController
if ($templateCount >= 50) { if ($templateCount >= 50) {
return Base::retError('每个项目最多添加50个模板'); return Base::retError('每个项目最多添加50个模板');
} }
$template = ProjectTaskTemplate::create($data); $maxSort = ProjectTaskTemplate::where('project_id', $projectId)->max('sort');
$template = ProjectTaskTemplate::create(array_merge($data, [
'sort' => is_numeric($maxSort) ? intval($maxSort) + 1 : 0
]));
} }
return Base::retSuccess('保存成功', $template); return Base::retSuccess('保存成功', $template);
} }
/**
* @api {post} api/project/task/template_sort 48.1 排序任务模板
*
* @apiDescription 需要token身份项目负责人
* @apiVersion 1.0.0
* @apiGroup project
* @apiName task__template_sort
*
* @apiParam {Number} project_id 项目ID
* @apiParam {Array} list 模板ID列表按新顺序排列
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function task__template_sort()
{
User::auth();
$projectId = intval(Request::input('project_id'));
$list = Base::json2array(Request::input('list'));
if ($projectId <= 0 || !is_array($list)) {
return Base::retError('参数错误');
}
$project = Project::userProject($projectId, true, true);
$index = 0;
$handled = [];
foreach ($list as $templateId) {
$templateId = intval($templateId);
if ($templateId <= 0) continue;
$updated = ProjectTaskTemplate::where('project_id', $projectId)
->where('id', $templateId)
->update(['sort' => $index]);
if ($updated) {
$handled[] = $templateId;
$index++;
}
}
$others = ProjectTaskTemplate::where('project_id', $projectId)
->when(!empty($handled), function ($query) use ($handled) {
$query->whereNotIn('id', $handled);
})
->orderBy('sort')
->orderByDesc('id')
->pluck('id');
foreach ($others as $templateId) {
ProjectTaskTemplate::where('id', $templateId)->update(['sort' => $index]);
$index++;
}
$project->addLog('调整模板排序');
return Base::retSuccess('排序已保存');
}
/** /**
* @api {get} api/project/task/template_delete 49. 删除任务模板 * @api {get} api/project/task/template_delete 49. 删除任务模板
* *

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
class BackfillSortProjectTaskTemplates extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (!Schema::hasTable('project_task_templates') || !Schema::hasColumn('project_task_templates', 'sort')) {
return;
}
\App\Models\ProjectTaskTemplate::query()
->select('project_id')
->distinct()
->orderBy('project_id')
->chunk(100, function ($projects) {
foreach ($projects as $project) {
$templates = \App\Models\ProjectTaskTemplate::query()
->where('project_id', $project->project_id)
->orderByDesc('id')
->get(['id']);
$index = 0;
foreach ($templates as $template) {
\App\Models\ProjectTaskTemplate::where('id', $template->id)->update(['sort' => $index++]);
}
}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
// no-op
}
}

View File

@ -7,6 +7,14 @@
<Loading v-if="loadIng > 0"/> <Loading v-if="loadIng > 0"/>
</div> </div>
<div class="actions"> <div class="actions">
<Button
v-if="templates.length"
:type="sortMode ? 'primary' : 'default'"
:loading="sortLoading"
icon="md-move"
@click="toggleSortMode">
{{$L(sortMode ? '完成排序' : '调整排序')}}
</Button>
<Button type="primary" icon="md-add" @click="handleAdd"> <Button type="primary" icon="md-add" @click="handleAdd">
{{$L('新建模板')}} {{$L('新建模板')}}
</Button> </Button>
@ -18,31 +26,54 @@
<div class="empty-text">{{$L('当前项目暂无任务模板')}}</div> <div class="empty-text">{{$L('当前项目暂无任务模板')}}</div>
<Button type="primary" icon="md-add" @click="handleAdd">{{$L('新建模板')}}</Button> <Button type="primary" icon="md-add" @click="handleAdd">{{$L('新建模板')}}</Button>
</div> </div>
<div v-else class="template-list"> <Draggable
<div v-for="item in templates" :key="item.id" class="template-item"> v-else
<div class="template-title"> class="template-list"
<span>{{ item.name }}</span> tag="div"
<span v-if="item.is_default" class="default-tag">{{$L('默认')}}</span> :list="templates"
</div> :animation="150"
<div class="template-content"> :disabled="!sortMode || sortLoading"
<div v-if="item.title" class="task-title">{{ item.title }}</div> item-key="id"
<div v-if="item.content" class="task-content"> handle=".template-drag-handle"
<VMPreviewNostyle ref="descPreview" :value="item.content"/> @end="handleSortEnd">
<div
v-for="item in templates"
:key="item.id"
class="template-item">
<div
:class="['template-item-inner', {'is-sorting': sortMode}]">
<div
v-if="sortMode"
class="template-drag-handle"
:title="$L('拖拽调整排序')">
<Icon type="md-menu" />
</div>
<div class="template-main">
<div class="template-title">
<span>{{ item.name }}</span>
<span v-if="item.is_default" class="default-tag">{{$L('默认')}}</span>
</div>
<div class="template-content">
<div v-if="item.title" class="task-title">{{ item.title }}</div>
<div v-if="item.content" class="task-content">
<VMPreviewNostyle ref="descPreview" :value="item.content"/>
</div>
</div>
<div class="template-actions">
<Button :disabled="sortMode" @click="handleSetDefault(item)" type="primary" :icon="item.is_default ? 'md-checkmark' : ''">
{{$L(item.is_default ? '取消默认' : '设为默认')}}
</Button>
<Button :disabled="sortMode" @click="handleEdit(item)" type="primary">
{{$L('编辑')}}
</Button>
<Button :disabled="sortMode" @click="handleDelete(item)" type="error">
{{$L('删除')}}
</Button>
</div>
</div> </div>
</div> </div>
<div class="template-actions">
<Button @click="handleSetDefault(item)" type="primary" :icon="item.is_default ? 'md-checkmark' : ''">
{{$L(item.is_default ? '取消默认' : '设为默认')}}
</Button>
<Button @click="handleEdit(item)" type="primary">
{{$L('编辑')}}
</Button>
<Button @click="handleDelete(item)" type="error">
{{$L('删除')}}
</Button>
</div>
</div> </div>
</div> </Draggable>
</div> </div>
<!-- 编辑模板弹窗 --> <!-- 编辑模板弹窗 -->
@ -102,13 +133,14 @@
<script> <script>
import {mapState} from 'vuex' import {mapState} from 'vuex'
import Draggable from 'vuedraggable';
import VMPreviewNostyle from "../../../../components/VMEditor/nostyle.vue"; import VMPreviewNostyle from "../../../../components/VMEditor/nostyle.vue";
import AllTaskTemplates from "./templates"; import AllTaskTemplates from "./templates";
import {languageName} from "../../../../language"; import {languageName} from "../../../../language";
export default { export default {
name: 'ProjectTaskTemplate', name: 'ProjectTaskTemplate',
components: {VMPreviewNostyle}, components: {VMPreviewNostyle, Draggable},
props: { props: {
projectId: { projectId: {
type: [Number, String], type: [Number, String],
@ -119,6 +151,8 @@ export default {
return { return {
loadIng: 0, loadIng: 0,
templates: [], templates: [],
sortMode: false,
sortLoading: false,
showEditModal: false, showEditModal: false,
editingTemplate: this.getEmptyTemplate(), editingTemplate: this.getEmptyTemplate(),
formRules: { formRules: {
@ -161,6 +195,45 @@ export default {
} }
}, },
// /
toggleSortMode() {
if (this.sortLoading) return
this.sortMode = !this.sortMode
},
//
async handleSortEnd(event) {
if (!this.sortMode) {
return
}
if (event && event.oldIndex === event.newIndex) {
return
}
const list = this.templates.map(template => template.id)
if (!list.length) {
return
}
this.sortLoading = true
try {
const {msg} = await this.$store.dispatch('call', {
url: 'project/task/template_sort',
method: 'post',
data: {
project_id: this.projectId,
list
},
spinner: 2000
})
$A.messageSuccess(msg || '排序已保存')
await this.loadTemplates()
} catch ({msg}) {
$A.messageError(msg || '排序保存失败')
await this.loadTemplates()
} finally {
this.sortLoading = false
}
},
// //
async loadTemplates() { async loadTemplates() {
this.loadIng++ this.loadIng++
@ -173,6 +246,9 @@ export default {
spinner: 3000 spinner: 3000
}) })
this.templates = data || [] this.templates = data || []
if (!this.templates.length) {
this.sortMode = false
}
} catch ({msg}) { } catch ({msg}) {
$A.messageError(msg || '加载模板失败') $A.messageError(msg || '加载模板失败')
} finally { } finally {

View File

@ -69,8 +69,42 @@
.template-list { .template-list {
.template-item { .template-item {
padding: 16px 0;
border-top: 1px solid #F4F4F5; border-top: 1px solid #F4F4F5;
padding: 16px 0;
.template-item-inner {
display: flex;
align-items: flex-start;
gap: 12px;
&.is-sorting {
cursor: grab;
}
.template-drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin-top: 4px;
color: #9aa5b1;
cursor: grab;
transition: color 0.2s ease;
&:hover {
color: $primary-color;
}
.ivu-icon {
font-size: 20px;
}
}
.template-main {
flex: 1;
}
}
.template-title { .template-title {
font-weight: 500; font-weight: 500;
@ -116,97 +150,103 @@
> i { > i {
margin: 0 -2px; margin: 0 -2px;
} }
}
}
}
.tag-item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
padding: 16px 0;
border-top: 1px solid #F4F4F5;
&.is-sorting { &[disabled] {
cursor: grab; cursor: not-allowed;
}
.tag-drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: #b0b3bd;
cursor: grab;
flex-shrink: 0;
.ivu-icon {
font-size: 18px;
}
}
.tag-contents {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 100%;
.tag-title {
height: 22px;
display: flex;
align-items: center;
color: $primary-title-color;
}
.tag-desc {
color: $primary-text-color;
font-size: 13px;
word-break: break-all;
}
}
.tag-actions {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
.tag-actions-btns {
display: flex;
align-items: center;
gap: 8px;
> button {
margin: 0;
height: 28px;
padding: 0 12px;
font-size: 13px;
> i {
margin: 0 -2px;
}
&[disabled] {
cursor: not-allowed;
}
} }
} }
.tag-actions-owner {
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
opacity: 0.5;
}
} }
} }
.sortable-drag { .sortable-drag {
border-top-color: transparent; border-top-color: transparent;
} }
}
.tag-item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
padding: 16px 0;
border-top: 1px solid #F4F4F5;
&.is-sorting {
cursor: grab;
}
.tag-drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: #b0b3bd;
cursor: grab;
flex-shrink: 0;
.ivu-icon {
font-size: 18px;
}
}
.tag-contents {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 100%;
.tag-title {
height: 22px;
display: flex;
align-items: center;
color: $primary-title-color;
}
.tag-desc {
color: $primary-text-color;
font-size: 13px;
word-break: break-all;
}
}
.tag-actions {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
.tag-actions-btns {
display: flex;
align-items: center;
gap: 8px;
> button {
margin: 0;
height: 28px;
padding: 0 12px;
font-size: 13px;
> i {
margin: 0 -2px;
}
&[disabled] {
cursor: not-allowed;
}
}
}
.tag-actions-owner {
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
opacity: 0.5;
}
}
}
.sortable-drag {
border-top-color: transparent;
} }
} }