mirror of
https://github.com/kuaifan/dootask.git
synced 2026-05-24 01:14:06 +00:00
feat(template): 添加跨项目任务模板支持,增加使用统计和搜索功能
This commit is contained in:
parent
18336c870e
commit
7dc641e69e
@ -2537,6 +2537,20 @@ class ProjectController extends AbstractController
|
||||
|
||||
$task->pushMsg('add', $data);
|
||||
$task->taskPush(null, 0);
|
||||
|
||||
// 应用任务模板使用统计(不影响主流程;非成员或模板已删除时静默忽略)
|
||||
$templateId = intval(Request::input('template_id', 0));
|
||||
if ($templateId > 0) {
|
||||
$tpl = ProjectTaskTemplate::find($templateId);
|
||||
if ($tpl) {
|
||||
$isMember = ProjectUser::where('project_id', $tpl->project_id)
|
||||
->where('userid', $user->userid)->exists();
|
||||
if ($isMember) {
|
||||
$tpl->incrementUsage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Base::retSuccess('添加成功', $data);
|
||||
}
|
||||
|
||||
@ -3640,6 +3654,117 @@ class ProjectController extends AbstractController
|
||||
return Base::retSuccess('success', $templates);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/project/task/template_visible 02. 当前用户跨项目可见的全部任务模板
|
||||
*
|
||||
* @apiDescription 返回当前用户加入的所有项目下的任务模板。当前项目的模板优先排序。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName task__template_visible
|
||||
*
|
||||
* @apiParam {Number} [current_project_id] 当前项目 ID(用于排序优先;可空)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1 正确、0 错误)
|
||||
* @apiSuccess {String} msg 返回信息
|
||||
* @apiSuccess {Object[]} data 模板列表,每条包含 project_id, project_name, name, title, content, sort, is_default, userid, use_count, last_used_at
|
||||
*/
|
||||
public function task__template_visible()
|
||||
{
|
||||
$user = User::auth();
|
||||
$currentProjectId = intval(Request::input('current_project_id', 0));
|
||||
|
||||
$projectIds = ProjectUser::where('userid', $user->userid)->pluck('project_id');
|
||||
|
||||
$rows = ProjectTaskTemplate::with(['project:id,name'])
|
||||
->whereIn('project_id', $projectIds)
|
||||
->orderByRaw('project_id = ? DESC', [$currentProjectId])
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(function ($tpl) {
|
||||
return [
|
||||
'id' => $tpl->id,
|
||||
'project_id' => $tpl->project_id,
|
||||
'project_name' => $tpl->project->name ?? '',
|
||||
'name' => $tpl->name,
|
||||
'title' => $tpl->title,
|
||||
'content' => $tpl->content,
|
||||
'sort' => $tpl->sort,
|
||||
'is_default' => $tpl->is_default,
|
||||
'userid' => $tpl->userid,
|
||||
'use_count' => $tpl->use_count,
|
||||
'last_used_at' => $tpl->last_used_at,
|
||||
];
|
||||
});
|
||||
|
||||
return Base::retSuccess('success', $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/project/task/template_search 03. 跨项目模板搜索分页
|
||||
*
|
||||
* @apiDescription "更多"弹层用。返回当前用户跨项目可见模板,支持关键字 + 分页。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName task__template_search
|
||||
*
|
||||
* @apiParam {String} [keyword] 关键字(在 name/title/content 上模糊匹配)
|
||||
* @apiParam {Number} [page=1] 页码
|
||||
* @apiParam {Number} [page_size=20] 每页条数(最大 50)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码
|
||||
* @apiSuccess {Object} data 含 total / page / page_size / items
|
||||
*/
|
||||
public function task__template_search()
|
||||
{
|
||||
$user = User::auth();
|
||||
$keyword = trim((string) Request::input('keyword', ''));
|
||||
$page = max(1, intval(Request::input('page', 1)));
|
||||
$pageSize = min(50, max(1, intval(Request::input('page_size', 20))));
|
||||
|
||||
$projectIds = ProjectUser::where('userid', $user->userid)->pluck('project_id');
|
||||
|
||||
$q = ProjectTaskTemplate::with(['project:id,name', 'user:userid,nickname'])
|
||||
->whereIn('project_id', $projectIds);
|
||||
|
||||
if ($keyword !== '') {
|
||||
$like = '%' . $keyword . '%';
|
||||
$q->where(function ($qq) use ($like) {
|
||||
$qq->where('name', 'like', $like)
|
||||
->orWhere('title', 'like', $like)
|
||||
->orWhere('content', 'like', $like);
|
||||
});
|
||||
}
|
||||
|
||||
$total = (clone $q)->count();
|
||||
$items = $q->orderByDesc('use_count')
|
||||
->orderByDesc('last_used_at')
|
||||
->orderByDesc('created_at')
|
||||
->forPage($page, $pageSize)
|
||||
->get()
|
||||
->map(function ($tpl) {
|
||||
return [
|
||||
'id' => $tpl->id,
|
||||
'project_id' => $tpl->project_id,
|
||||
'project_name' => $tpl->project->name ?? '',
|
||||
'name' => $tpl->name,
|
||||
'title' => $tpl->title,
|
||||
'content' => $tpl->content,
|
||||
'use_count' => $tpl->use_count,
|
||||
'userid' => $tpl->userid,
|
||||
'user_name' => $tpl->user->nickname ?? '',
|
||||
'last_used_at' => $tpl->last_used_at,
|
||||
];
|
||||
});
|
||||
|
||||
return Base::retSuccess('success', [
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
'items' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/project/task/template_save 保存任务模板
|
||||
*
|
||||
|
||||
@ -13,6 +13,8 @@ namespace App\Models;
|
||||
* @property int $sort 排序
|
||||
* @property int $is_default 是否默认模板
|
||||
* @property int $userid 创建人
|
||||
* @property int $use_count 累计使用次数
|
||||
* @property \Illuminate\Support\Carbon|null $last_used_at 最近一次使用时间
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\Project $project
|
||||
@ -52,7 +54,18 @@ class ProjectTaskTemplate extends AbstractModel
|
||||
'content',
|
||||
'sort',
|
||||
'is_default',
|
||||
'userid'
|
||||
'userid',
|
||||
'use_count',
|
||||
'last_used_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $casts = [
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
@ -74,4 +87,17 @@ class ProjectTaskTemplate extends AbstractModel
|
||||
{
|
||||
return $this->belongsTo(User::class, 'userid');
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子递增使用次数并刷新最近使用时间。
|
||||
*/
|
||||
public function incrementUsage(): void
|
||||
{
|
||||
$this->newQuery()
|
||||
->where('id', $this->id)
|
||||
->update([
|
||||
'use_count' => \DB::raw('use_count + 1'),
|
||||
'last_used_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddUseCountToProjectTaskTemplates extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('project_task_templates', function (Blueprint $table) {
|
||||
$table->unsignedInteger('use_count')->default(0)->after('is_default')->comment('累计使用次数');
|
||||
$table->timestamp('last_used_at')->nullable()->after('use_count')->comment('最近一次使用时间');
|
||||
$table->index(['use_count', 'last_used_at'], 'idx_template_usage');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('project_task_templates', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_template_usage');
|
||||
$table->dropColumn(['use_count', 'last_used_at']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -992,3 +992,4 @@ LDAP 用户缺少邮箱属性,请联系管理员配置
|
||||
不能将负责人任命为项目管理员
|
||||
不能将部门负责人任命为部门管理员
|
||||
该用户不存在
|
||||
无权操作此模板
|
||||
|
||||
@ -2384,3 +2384,7 @@ AI任务分析
|
||||
即将罢免项目管理员
|
||||
请确认以下操作,注意此操作不可逆!
|
||||
移除成员负责的任务将变成无负责人。
|
||||
搜索模板
|
||||
来自(*)
|
||||
暂无可用模板
|
||||
加载中
|
||||
|
||||
@ -12,14 +12,21 @@
|
||||
@on-visible-change="cascaderShow=!cascaderShow"
|
||||
filterable/>
|
||||
</div>
|
||||
<ul v-if="taskTemplateList.length > 0" class="task-add-template">
|
||||
<ul v-if="taskTemplateList.length > 0 || hasMoreTemplates" class="task-add-template">
|
||||
<li
|
||||
v-for="item in taskTemplateList"
|
||||
:key="item.id"
|
||||
:class="{active:templateActiveID === item.id}"
|
||||
:class="{active: templateActiveID === item.id, cross: item.project_id != addData.project_id}"
|
||||
:title="item.project_id != addData.project_id ? $L('来自(*)', item.project_name || '') : ''"
|
||||
@click="setTaskTemplate(item)">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
<li
|
||||
v-if="hasMoreTemplates"
|
||||
class="more"
|
||||
@click="openTemplateBrowser">
|
||||
{{ $L('更多') }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="task-add-form">
|
||||
<div class="title">
|
||||
@ -190,6 +197,10 @@
|
||||
</div>
|
||||
|
||||
<TaskExistTips ref="taskExistTipsRef" @onContinue="onAdd(addContinue, true)"/>
|
||||
<TaskTemplateBrowser
|
||||
v-model="templateBrowserVisible"
|
||||
:current-project-id="addData.project_id"
|
||||
@pick="onPickFromBrowser" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -203,10 +214,11 @@ import nostyle from "../../../components/VMEditor/engine/nostyle";
|
||||
import {MarkdownConver} from "../../../utils/markdown";
|
||||
import {extractPlainText} from "../../../utils/text";
|
||||
import {AINormalizeJsonContent, TASK_AI_SYSTEM_PROMPT, withLanguagePreferencePrompt} from "../../../utils/ai";
|
||||
import TaskTemplateBrowser from './TaskTemplateBrowser.vue'
|
||||
|
||||
export default {
|
||||
name: "TaskAdd",
|
||||
components: {TEditorTask, UserSelect, TaskExistTips},
|
||||
components: {TEditorTask, UserSelect, TaskExistTips, TaskTemplateBrowser},
|
||||
props: {
|
||||
value: {
|
||||
type: Boolean,
|
||||
@ -258,6 +270,7 @@ export default {
|
||||
|
||||
templateActiveID: 0,
|
||||
templateCompareData: {name: '', content: ''},
|
||||
templateBrowserVisible: false,
|
||||
}
|
||||
},
|
||||
|
||||
@ -297,9 +310,38 @@ export default {
|
||||
return 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Chip 区显示规则:
|
||||
* - 情况 A:本项目有模板 → 显示本项目全部模板(按 sort)
|
||||
* - 情况 B:本项目无模板 → 显示其他项目前 5 个(按 use_count desc)
|
||||
* - 完全无可见模板 → 空数组(外层 v-if 隐藏整块)
|
||||
*/
|
||||
taskTemplateList() {
|
||||
return this.taskTemplates.filter(({project_id}) => project_id == this.addData.project_id) || []
|
||||
}
|
||||
const all = this.taskTemplates || []
|
||||
const currentId = this.addData.project_id
|
||||
const ownTemplates = all.filter(t => t.project_id == currentId)
|
||||
if (ownTemplates.length > 0) {
|
||||
return [...ownTemplates].sort((a, b) => (a.sort || 0) - (b.sort || 0) || a.id - b.id)
|
||||
}
|
||||
const others = all.filter(t => t.project_id != currentId)
|
||||
return [...others]
|
||||
.sort((a, b) => (b.use_count || 0) - (a.use_count || 0))
|
||||
.slice(0, 5)
|
||||
},
|
||||
|
||||
/**
|
||||
* 是否存在"未在 chip 区展示的可见模板"——决定"更多"按钮显隐。
|
||||
*/
|
||||
hasMoreTemplates() {
|
||||
const all = this.taskTemplates || []
|
||||
const currentId = this.addData.project_id
|
||||
const ownCount = all.filter(t => t.project_id == currentId).length
|
||||
const otherCount = all.filter(t => t.project_id != currentId).length
|
||||
if (ownCount > 0) {
|
||||
return otherCount > 0
|
||||
}
|
||||
return otherCount > 5
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
@ -538,7 +580,7 @@ export default {
|
||||
}
|
||||
|
||||
this.loadIng++;
|
||||
this.$store.dispatch("taskAdd", this.addData).then(({msg}) => {
|
||||
this.$store.dispatch("taskAdd", Object.assign({}, this.addData, {template_id: this.templateActiveID || 0})).then(({msg}) => {
|
||||
$A.messageSuccess(msg);
|
||||
if (continued === true) {
|
||||
this.addData = Object.assign({}, this.addData, this.templateCompareData, {subtasks: []});
|
||||
@ -600,6 +642,14 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
openTemplateBrowser() {
|
||||
this.templateBrowserVisible = true
|
||||
},
|
||||
|
||||
onPickFromBrowser(item) {
|
||||
this.setTaskTemplate(item)
|
||||
},
|
||||
|
||||
setTaskTemplate(item, force = false) {
|
||||
if (force) {
|
||||
this.templateActiveID = item.id;
|
||||
@ -623,7 +673,8 @@ export default {
|
||||
},
|
||||
|
||||
setTaskDefaultTemplate() {
|
||||
const defaultTemplate = this.taskTemplateList.find(({is_default}) => is_default);
|
||||
// 默认模板仅取本项目的,避免跨项目模板抢占本项目新任务
|
||||
const defaultTemplate = (this.taskTemplates || []).find(t => t.is_default && t.project_id == this.addData.project_id);
|
||||
if (defaultTemplate) {
|
||||
this.setTaskTemplate(defaultTemplate);
|
||||
}
|
||||
@ -656,7 +707,7 @@ export default {
|
||||
}
|
||||
|
||||
const currentTemplate = this.templateActiveID
|
||||
? this.taskTemplateList.find(item => item.id === this.templateActiveID)
|
||||
? (this.taskTemplates || []).find(item => item.id === this.templateActiveID)
|
||||
: null;
|
||||
if (currentTemplate) {
|
||||
const templateName = (currentTemplate.name || currentTemplate.title || '').trim();
|
||||
|
||||
@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<Modal
|
||||
v-model="show"
|
||||
:title="$L('搜索模板')"
|
||||
:footer-hide="true"
|
||||
:width="640"
|
||||
class="task-template-browser">
|
||||
<div class="search-wrap">
|
||||
<Input
|
||||
ref="search"
|
||||
v-model="keyword"
|
||||
:placeholder="$L('搜索模板')"
|
||||
clearable
|
||||
@on-change="onKeywordChange"
|
||||
@keydown.native.up.prevent="moveSelection(-1)"
|
||||
@keydown.native.down.prevent="moveSelection(1)"
|
||||
@keydown.native.enter.prevent="confirmSelection" />
|
||||
</div>
|
||||
<div ref="listWrap" class="list-wrap" @scroll="onScroll">
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="item.id"
|
||||
:class="['item', {selected: idx === selectedIndex}]"
|
||||
@click="pick(item)"
|
||||
@mouseenter="selectedIndex = idx">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<div class="item-meta">
|
||||
<span class="origin">{{ $L('来自(*)', item.project_name || '') }}</span>
|
||||
<span v-if="item.user_name" class="creator">· @{{ item.user_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="loading">{{ $L('加载中') }}</div>
|
||||
<div v-if="!loading && items.length === 0" class="empty">{{ $L('暂无可用模板') }}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TaskTemplateBrowser',
|
||||
props: {
|
||||
value: { type: Boolean, default: false },
|
||||
currentProjectId: { type: Number, default: 0 },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
keyword: '',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
items: [],
|
||||
loading: false,
|
||||
selectedIndex: 0,
|
||||
keywordTimer: null,
|
||||
fetchSeq: 0,
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
clearTimeout(this.keywordTimer)
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() { return this.value },
|
||||
set(v) { this.$emit('input', v) },
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
show(v) {
|
||||
if (v) {
|
||||
this.keyword = ''
|
||||
this.page = 1
|
||||
this.items = []
|
||||
this.selectedIndex = 0
|
||||
this.fetch()
|
||||
this.$nextTick(() => {
|
||||
this.$refs.search && this.$refs.search.focus && this.$refs.search.focus()
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onKeywordChange() {
|
||||
clearTimeout(this.keywordTimer)
|
||||
this.keywordTimer = setTimeout(() => {
|
||||
this.page = 1
|
||||
this.items = []
|
||||
this.selectedIndex = 0
|
||||
this.fetch()
|
||||
}, 200)
|
||||
},
|
||||
onScroll() {
|
||||
const el = this.$refs.listWrap
|
||||
if (!el || this.loading) return
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40) {
|
||||
if (this.items.length < this.total) {
|
||||
this.page += 1
|
||||
this.fetch(true)
|
||||
}
|
||||
}
|
||||
},
|
||||
async fetch(append = false) {
|
||||
const seq = ++this.fetchSeq
|
||||
this.loading = true
|
||||
try {
|
||||
const {data} = await this.$store.dispatch('call', {
|
||||
url: 'project/task/template_search',
|
||||
data: {
|
||||
keyword: this.keyword,
|
||||
page: this.page,
|
||||
page_size: this.pageSize,
|
||||
},
|
||||
})
|
||||
if (seq !== this.fetchSeq) return // 后续请求已发起,丢弃此次结果
|
||||
this.total = data.total || 0
|
||||
this.items = append ? [...this.items, ...(data.items || [])] : (data.items || [])
|
||||
} finally {
|
||||
if (seq === this.fetchSeq) this.loading = false
|
||||
}
|
||||
},
|
||||
moveSelection(delta) {
|
||||
if (this.items.length === 0) return
|
||||
const next = this.selectedIndex + delta
|
||||
this.selectedIndex = Math.max(0, Math.min(this.items.length - 1, next))
|
||||
},
|
||||
confirmSelection() {
|
||||
const item = this.items[this.selectedIndex]
|
||||
if (item) this.pick(item)
|
||||
},
|
||||
pick(item) {
|
||||
this.$emit('pick', item)
|
||||
this.show = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.task-template-browser {
|
||||
.search-wrap {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.list-wrap {
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
&.selected {
|
||||
background: rgba(64, 158, 255, 0.1);
|
||||
}
|
||||
.item-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
.item-meta {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
.creator {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
17
resources/assets/js/store/actions.js
vendored
17
resources/assets/js/store/actions.js
vendored
@ -3047,20 +3047,21 @@ export default {
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新任务模板
|
||||
* @param state
|
||||
* @param dispatch
|
||||
* @param projectId
|
||||
* 拉取当前用户跨项目可见的全部任务模板。
|
||||
* 替代旧版按项目隔离取数。state.taskTemplates 现在存"我所有可见模板"(全量)。
|
||||
*
|
||||
* @param {Object} ctx
|
||||
* @param {Number|null} currentProjectId 当前所在项目 ID(用于排序优先;可空)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateTaskTemplates({state, dispatch}, projectId) {
|
||||
async updateTaskTemplates({state, dispatch}, currentProjectId) {
|
||||
const {data} = await dispatch("call", {
|
||||
url: 'project/task/template_list',
|
||||
url: 'project/task/template_visible',
|
||||
data: {
|
||||
project_id: projectId
|
||||
current_project_id: currentProjectId || 0,
|
||||
},
|
||||
})
|
||||
state.taskTemplates = state.taskTemplates.filter(template => template.project_id !== projectId).concat(data || [])
|
||||
state.taskTemplates = Array.isArray(data) ? data : []
|
||||
},
|
||||
|
||||
/** *****************************************************************************************/
|
||||
|
||||
1
resources/assets/js/store/state.js
vendored
1
resources/assets/js/store/state.js
vendored
@ -176,6 +176,7 @@ export default {
|
||||
taskOperation: {},
|
||||
taskRelatedCache: {},
|
||||
taskArchiveView: 0,
|
||||
// 当前用户跨项目可见的全部任务模板(按 use_count 排序时机由消费者决定)
|
||||
taskTemplates: [],
|
||||
taskLatestId: 0,
|
||||
|
||||
|
||||
@ -91,6 +91,33 @@
|
||||
background-color: $primary-color;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.cross {
|
||||
// 跨项目 chip:左侧添加视觉 marker(小圆点)
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: $primary-desc-color;
|
||||
}
|
||||
}
|
||||
|
||||
&.more {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task-add-form,
|
||||
|
||||
327
tests/Feature/CrossProjectTaskTemplateTest.php
Normal file
327
tests/Feature/CrossProjectTaskTemplateTest.php
Normal file
@ -0,0 +1,327 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskTemplate;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CrossProjectTaskTemplateTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* 创建测试用户。
|
||||
*/
|
||||
private function makeUser(string $email): User
|
||||
{
|
||||
$user = User::createInstance([
|
||||
'email' => $email,
|
||||
'userimg' => '',
|
||||
'nickname' => 'TestUser_' . substr(md5($email), 0, 6),
|
||||
'profession' => '',
|
||||
'password' => md5('123456'),
|
||||
]);
|
||||
$user->save();
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建项目,自动把 owner 加为主负责人,把 members 加为普通成员。
|
||||
*/
|
||||
private function makeProject(int $ownerUserid, array $memberUserids = []): Project
|
||||
{
|
||||
$project = Project::createInstance([
|
||||
'name' => 'p-' . uniqid(),
|
||||
'desc' => '',
|
||||
'userid' => $ownerUserid,
|
||||
'personal' => 0,
|
||||
]);
|
||||
$project->save();
|
||||
ProjectUser::updateInsert([
|
||||
'project_id' => $project->id,
|
||||
'userid' => $ownerUserid,
|
||||
], ['owner' => 1]);
|
||||
foreach ($memberUserids as $uid) {
|
||||
if ($uid === $ownerUserid) continue;
|
||||
ProjectUser::updateInsert([
|
||||
'project_id' => $project->id,
|
||||
'userid' => $uid,
|
||||
], ['owner' => 0]);
|
||||
}
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个任务模板。
|
||||
*/
|
||||
private function makeTemplate(Project $project, int $userid, array $overrides = []): ProjectTaskTemplate
|
||||
{
|
||||
$tpl = ProjectTaskTemplate::createInstance(array_merge([
|
||||
'project_id' => $project->id,
|
||||
'name' => 'tpl-' . uniqid(),
|
||||
'title' => 'title-' . uniqid(),
|
||||
'content' => 'content',
|
||||
'sort' => 0,
|
||||
'is_default' => 0,
|
||||
'userid' => $userid,
|
||||
'use_count' => 0,
|
||||
], $overrides));
|
||||
$tpl->save();
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复刻 task__template_search 业务逻辑。
|
||||
*/
|
||||
private function callTemplateSearch(int $userid, string $keyword = '', int $page = 1, int $pageSize = 20): array
|
||||
{
|
||||
$projectIds = ProjectUser::where('userid', $userid)->pluck('project_id');
|
||||
$q = ProjectTaskTemplate::with(['project:id,name'])
|
||||
->whereIn('project_id', $projectIds);
|
||||
if ($keyword !== '') {
|
||||
$q->where(function ($q2) use ($keyword) {
|
||||
$like = '%' . $keyword . '%';
|
||||
$q2->where('name', 'like', $like)
|
||||
->orWhere('title', 'like', $like)
|
||||
->orWhere('content', 'like', $like);
|
||||
});
|
||||
}
|
||||
$total = $q->count();
|
||||
$items = $q->orderByDesc('use_count')
|
||||
->orderByDesc('last_used_at')
|
||||
->orderByDesc('created_at')
|
||||
->forPage($page, $pageSize)
|
||||
->get()
|
||||
->map(fn($tpl) => [
|
||||
'id' => $tpl->id,
|
||||
'project_id' => $tpl->project_id,
|
||||
'project_name' => $tpl->project->name ?? '',
|
||||
'name' => $tpl->name,
|
||||
'title' => $tpl->title,
|
||||
'content' => $tpl->content,
|
||||
'use_count' => $tpl->use_count,
|
||||
])->toArray();
|
||||
return ['total' => $total, 'items' => $items, 'page' => $page, 'page_size' => $pageSize];
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟 task__add 的"使用模板"副作用:检查 template_id 可见性,原子递增 use_count + 更新 last_used_at。
|
||||
* 不实际创建任务,只验证副作用。
|
||||
*/
|
||||
private function simulateUseTemplate(int $userid, int $templateId): void
|
||||
{
|
||||
if ($templateId <= 0) return;
|
||||
$tpl = ProjectTaskTemplate::find($templateId);
|
||||
if (!$tpl) return;
|
||||
$isMember = ProjectUser::where('project_id', $tpl->project_id)
|
||||
->where('userid', $userid)->exists();
|
||||
if (!$isMember) return;
|
||||
$tpl->incrementUsage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 task__template_visible 端点(绕过 HTTP 层,直接复刻 controller 业务逻辑)。
|
||||
*/
|
||||
private function callTemplateVisible(int $userid, int $currentProjectId): array
|
||||
{
|
||||
$projectIds = ProjectUser::where('userid', $userid)->pluck('project_id');
|
||||
return ProjectTaskTemplate::with(['project:id,name'])
|
||||
->whereIn('project_id', $projectIds)
|
||||
->orderByRaw('project_id = ? DESC', [$currentProjectId])
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn($tpl) => [
|
||||
'id' => $tpl->id,
|
||||
'project_id' => $tpl->project_id,
|
||||
'project_name' => $tpl->project->name ?? '',
|
||||
'name' => $tpl->name,
|
||||
'title' => $tpl->title,
|
||||
'content' => $tpl->content,
|
||||
'sort' => $tpl->sort,
|
||||
'is_default' => $tpl->is_default,
|
||||
'userid' => $tpl->userid,
|
||||
'use_count' => $tpl->use_count,
|
||||
'last_used_at' => $tpl->last_used_at,
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
public function test_search_paginates_visible_templates_by_use_count_desc()
|
||||
{
|
||||
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
||||
$projectA = $this->makeProject($alice->userid);
|
||||
foreach (range(1, 25) as $i) {
|
||||
$this->makeTemplate($projectA, $alice->userid, [
|
||||
'name' => 'tpl-' . str_pad($i, 2, '0', STR_PAD_LEFT),
|
||||
'use_count' => $i,
|
||||
]);
|
||||
}
|
||||
|
||||
$page1 = $this->callTemplateSearch($alice->userid, '', 1, 20);
|
||||
$page2 = $this->callTemplateSearch($alice->userid, '', 2, 20);
|
||||
|
||||
$this->assertSame(25, $page1['total']);
|
||||
$this->assertCount(20, $page1['items']);
|
||||
$this->assertCount(5, $page2['items']);
|
||||
$this->assertSame('tpl-25', $page1['items'][0]['name']);
|
||||
$this->assertSame('tpl-06', $page1['items'][19]['name']);
|
||||
$this->assertSame('tpl-05', $page2['items'][0]['name']);
|
||||
}
|
||||
|
||||
public function test_search_filters_by_keyword_in_name_title_content()
|
||||
{
|
||||
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
||||
$projectA = $this->makeProject($alice->userid);
|
||||
$this->makeTemplate($projectA, $alice->userid, ['name' => '需求评审', 'title' => 't1', 'content' => 'c1']);
|
||||
$this->makeTemplate($projectA, $alice->userid, ['name' => 'tpl2', 'title' => '周报', 'content' => 'c2']);
|
||||
$this->makeTemplate($projectA, $alice->userid, ['name' => 'tpl3', 'title' => 't3', 'content' => '故障复盘']);
|
||||
$this->makeTemplate($projectA, $alice->userid, ['name' => 'noise', 'title' => 'noise', 'content' => 'noise']);
|
||||
|
||||
$r1 = $this->callTemplateSearch($alice->userid, '需求');
|
||||
$r2 = $this->callTemplateSearch($alice->userid, '周报');
|
||||
$r3 = $this->callTemplateSearch($alice->userid, '故障');
|
||||
|
||||
$this->assertSame(1, $r1['total']);
|
||||
$this->assertSame('需求评审', $r1['items'][0]['name']);
|
||||
$this->assertSame(1, $r2['total']);
|
||||
$this->assertSame('tpl2', $r2['items'][0]['name']);
|
||||
$this->assertSame(1, $r3['total']);
|
||||
$this->assertSame('tpl3', $r3['items'][0]['name']);
|
||||
}
|
||||
|
||||
public function test_search_excludes_non_member_project_templates()
|
||||
{
|
||||
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
||||
$bob = $this->makeUser('bob-' . uniqid() . '@test.com');
|
||||
$projectA = $this->makeProject($alice->userid);
|
||||
$projectC = $this->makeProject($bob->userid);
|
||||
$this->makeTemplate($projectA, $alice->userid, ['name' => 'visible']);
|
||||
$this->makeTemplate($projectC, $bob->userid, ['name' => 'hidden']);
|
||||
|
||||
$r = $this->callTemplateSearch($alice->userid, '');
|
||||
|
||||
$names = array_column($r['items'], 'name');
|
||||
$this->assertContains('visible', $names);
|
||||
$this->assertNotContains('hidden', $names);
|
||||
}
|
||||
|
||||
public function test_search_sort_falls_back_when_use_count_equal()
|
||||
{
|
||||
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
||||
$projectA = $this->makeProject($alice->userid);
|
||||
$t1 = $this->makeTemplate($projectA, $alice->userid, ['name' => 'older', 'use_count' => 5]);
|
||||
sleep(1);
|
||||
$t2 = $this->makeTemplate($projectA, $alice->userid, ['name' => 'newer', 'use_count' => 5]);
|
||||
$t2->last_used_at = now();
|
||||
$t2->save();
|
||||
|
||||
$r = $this->callTemplateSearch($alice->userid, '');
|
||||
|
||||
$this->assertSame('newer', $r['items'][0]['name']);
|
||||
}
|
||||
|
||||
public function test_search_endpoint_returns_expected_shape()
|
||||
{
|
||||
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
||||
$projectA = $this->makeProject($alice->userid);
|
||||
$this->makeTemplate($projectA, $alice->userid, ['name' => 'sanity', 'use_count' => 1]);
|
||||
|
||||
$projectIds = ProjectUser::where('userid', $alice->userid)->pluck('project_id');
|
||||
$expected = ProjectTaskTemplate::whereIn('project_id', $projectIds)
|
||||
->orderByDesc('use_count')->orderByDesc('last_used_at')->orderByDesc('created_at')
|
||||
->forPage(1, 20)->get()->pluck('name')->toArray();
|
||||
|
||||
$actual = array_column($this->callTemplateSearch($alice->userid, '')['items'], 'name');
|
||||
|
||||
$this->assertSame($expected, $actual);
|
||||
}
|
||||
|
||||
public function test_visible_returns_templates_from_all_user_projects()
|
||||
{
|
||||
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
||||
$projectA = $this->makeProject($alice->userid);
|
||||
$projectB = $this->makeProject($alice->userid);
|
||||
$tplA = $this->makeTemplate($projectA, $alice->userid, ['name' => 'A1']);
|
||||
$tplB = $this->makeTemplate($projectB, $alice->userid, ['name' => 'B1']);
|
||||
|
||||
$result = $this->callTemplateVisible($alice->userid, $projectB->id);
|
||||
|
||||
$names = array_column($result, 'name');
|
||||
$this->assertContains('A1', $names);
|
||||
$this->assertContains('B1', $names);
|
||||
}
|
||||
|
||||
public function test_visible_excludes_templates_from_non_member_projects()
|
||||
{
|
||||
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
||||
$bob = $this->makeUser('bob-' . uniqid() . '@test.com');
|
||||
$projectA = $this->makeProject($alice->userid);
|
||||
$projectC = $this->makeProject($bob->userid);
|
||||
$tplA = $this->makeTemplate($projectA, $alice->userid, ['name' => 'A1']);
|
||||
$tplC = $this->makeTemplate($projectC, $bob->userid, ['name' => 'C1']);
|
||||
|
||||
$result = $this->callTemplateVisible($alice->userid, $projectA->id);
|
||||
|
||||
$names = array_column($result, 'name');
|
||||
$this->assertContains('A1', $names);
|
||||
$this->assertNotContains('C1', $names);
|
||||
}
|
||||
|
||||
public function test_visible_orders_current_project_first()
|
||||
{
|
||||
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
||||
$projectA = $this->makeProject($alice->userid);
|
||||
$projectB = $this->makeProject($alice->userid);
|
||||
$this->makeTemplate($projectA, $alice->userid, ['name' => 'A1', 'sort' => 0]);
|
||||
$this->makeTemplate($projectB, $alice->userid, ['name' => 'B1', 'sort' => 0]);
|
||||
|
||||
$result = $this->callTemplateVisible($alice->userid, $projectB->id);
|
||||
|
||||
// B 项目模板应该排在前面
|
||||
$this->assertSame('B1', $result[0]['name']);
|
||||
}
|
||||
|
||||
public function test_use_template_increments_use_count_and_updates_last_used()
|
||||
{
|
||||
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
||||
$projectA = $this->makeProject($alice->userid);
|
||||
$tpl = $this->makeTemplate($projectA, $alice->userid, ['use_count' => 3]);
|
||||
|
||||
$before = $tpl->fresh();
|
||||
$this->assertSame(3, (int) $before->use_count);
|
||||
$this->assertNull($before->last_used_at);
|
||||
|
||||
$this->simulateUseTemplate($alice->userid, $tpl->id);
|
||||
|
||||
$after = $tpl->fresh();
|
||||
$this->assertSame(4, (int) $after->use_count);
|
||||
$this->assertNotNull($after->last_used_at);
|
||||
}
|
||||
|
||||
public function test_use_template_silently_ignores_non_member()
|
||||
{
|
||||
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
||||
$bob = $this->makeUser('bob-' . uniqid() . '@test.com');
|
||||
$projectA = $this->makeProject($alice->userid);
|
||||
$tpl = $this->makeTemplate($projectA, $alice->userid, ['use_count' => 3]);
|
||||
|
||||
$this->simulateUseTemplate($bob->userid, $tpl->id);
|
||||
|
||||
$this->assertSame(3, (int) $tpl->fresh()->use_count);
|
||||
}
|
||||
|
||||
public function test_use_template_handles_invalid_template_id()
|
||||
{
|
||||
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
||||
$this->simulateUseTemplate($alice->userid, 0);
|
||||
$this->simulateUseTemplate($alice->userid, 99999999);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user