diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php
index 262cd862a..e35639780 100755
--- a/app/Http/Controllers/Api/ProjectController.php
+++ b/app/Http/Controllers/Api/ProjectController.php
@@ -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 保存任务模板
*
diff --git a/app/Models/ProjectTaskTemplate.php b/app/Models/ProjectTaskTemplate.php
index 7c3de6920..43d63619c 100644
--- a/app/Models/ProjectTaskTemplate.php
+++ b/app/Models/ProjectTaskTemplate.php
@@ -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(),
+ ]);
+ }
}
diff --git a/database/migrations/2026_05_10_100000_add_use_count_to_project_task_templates.php b/database/migrations/2026_05_10_100000_add_use_count_to_project_task_templates.php
new file mode 100644
index 000000000..ad7d2558f
--- /dev/null
+++ b/database/migrations/2026_05_10_100000_add_use_count_to_project_task_templates.php
@@ -0,0 +1,25 @@
+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']);
+ });
+ }
+}
diff --git a/language/original-api.txt b/language/original-api.txt
index e31cfeeb2..508a80fb3 100644
--- a/language/original-api.txt
+++ b/language/original-api.txt
@@ -992,3 +992,4 @@ LDAP 用户缺少邮箱属性,请联系管理员配置
不能将负责人任命为项目管理员
不能将部门负责人任命为部门管理员
该用户不存在
+无权操作此模板
diff --git a/language/original-web.txt b/language/original-web.txt
index 6372ca108..c5683268c 100644
--- a/language/original-web.txt
+++ b/language/original-web.txt
@@ -2384,3 +2384,7 @@ AI任务分析
即将罢免项目管理员
请确认以下操作,注意此操作不可逆!
移除成员负责的任务将变成无负责人。
+搜索模板
+来自(*)
+暂无可用模板
+加载中
diff --git a/resources/assets/js/pages/manage/components/TaskAdd.vue b/resources/assets/js/pages/manage/components/TaskAdd.vue
index cf6e9a09b..4c9300b3f 100644
--- a/resources/assets/js/pages/manage/components/TaskAdd.vue
+++ b/resources/assets/js/pages/manage/components/TaskAdd.vue
@@ -12,14 +12,21 @@
@on-visible-change="cascaderShow=!cascaderShow"
filterable/>
-
+
-
{{ item.name }}
+ -
+ {{ $L('更多') }}
+
@@ -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();
diff --git a/resources/assets/js/pages/manage/components/TaskTemplateBrowser.vue b/resources/assets/js/pages/manage/components/TaskTemplateBrowser.vue
new file mode 100644
index 000000000..9ee00add2
--- /dev/null
+++ b/resources/assets/js/pages/manage/components/TaskTemplateBrowser.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
+
{{ item.name }}
+
+ {{ $L('来自(*)', item.project_name || '') }}
+ · @{{ item.user_name }}
+
+
+
{{ $L('加载中') }}
+
{{ $L('暂无可用模板') }}
+
+
+
+
+
+
+
diff --git a/resources/assets/js/store/actions.js b/resources/assets/js/store/actions.js
index ef91bd062..8f873a94c 100644
--- a/resources/assets/js/store/actions.js
+++ b/resources/assets/js/store/actions.js
@@ -3047,20 +3047,21 @@ export default {
},
/**
- * 更新任务模板
- * @param state
- * @param dispatch
- * @param projectId
+ * 拉取当前用户跨项目可见的全部任务模板。
+ * 替代旧版按项目隔离取数。state.taskTemplates 现在存"我所有可见模板"(全量)。
+ *
+ * @param {Object} ctx
+ * @param {Number|null} currentProjectId 当前所在项目 ID(用于排序优先;可空)
* @returns {Promise}
*/
- 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 : []
},
/** *****************************************************************************************/
diff --git a/resources/assets/js/store/state.js b/resources/assets/js/store/state.js
index f05a36172..27306455c 100644
--- a/resources/assets/js/store/state.js
+++ b/resources/assets/js/store/state.js
@@ -176,6 +176,7 @@ export default {
taskOperation: {},
taskRelatedCache: {},
taskArchiveView: 0,
+ // 当前用户跨项目可见的全部任务模板(按 use_count 排序时机由消费者决定)
taskTemplates: [],
taskLatestId: 0,
diff --git a/resources/assets/sass/pages/components/task-add.scss b/resources/assets/sass/pages/components/task-add.scss
index 454a6a42e..ddd03ee00 100644
--- a/resources/assets/sass/pages/components/task-add.scss
+++ b/resources/assets/sass/pages/components/task-add.scss
@@ -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,
diff --git a/tests/Feature/CrossProjectTaskTemplateTest.php b/tests/Feature/CrossProjectTaskTemplateTest.php
new file mode 100644
index 000000000..26badfa18
--- /dev/null
+++ b/tests/Feature/CrossProjectTaskTemplateTest.php
@@ -0,0 +1,327 @@
+ $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);
+ }
+}