feat(template): 添加跨项目任务模板支持,增加使用统计和搜索功能

This commit is contained in:
kuaifan 2026-05-11 01:13:54 +00:00
parent 18336c870e
commit 7dc641e69e
11 changed files with 776 additions and 17 deletions

View File

@ -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 保存任务模板
*

View File

@ -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(),
]);
}
}

View File

@ -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']);
});
}
}

View File

@ -992,3 +992,4 @@ LDAP 用户缺少邮箱属性,请联系管理员配置
不能将负责人任命为项目管理员
不能将部门负责人任命为部门管理员
该用户不存在
无权操作此模板

View File

@ -2384,3 +2384,7 @@ AI任务分析
即将罢免项目管理员
请确认以下操作,注意此操作不可逆!
移除成员负责的任务将变成无负责人。
搜索模板
来自(*)
暂无可用模板
加载中

View File

@ -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();

View File

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

View File

@ -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 : []
},
/** *****************************************************************************************/

View File

@ -176,6 +176,7 @@ export default {
taskOperation: {},
taskRelatedCache: {},
taskArchiveView: 0,
// 当前用户跨项目可见的全部任务模板(按 use_count 排序时机由消费者决定)
taskTemplates: [],
taskLatestId: 0,

View File

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

View 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);
}
}