From 7e5b31cfb29a4a031f1a24aa90ecd5234f96d469 Mon Sep 17 00:00:00 2001 From: kuaifan Date: Mon, 11 May 2026 03:26:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(template):=20=E6=B7=BB=E5=8A=A0=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E6=A8=A1=E6=9D=BF=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E9=A1=B9=E7=9B=AE=E9=97=B4=E6=A8=A1=E6=9D=BF=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Api/ProjectController.php | 25 ++++- app/Models/Project.php | 2 + ..._task_template_share_to_projects_table.php | 26 +++++ language/original-api.txt | 1 + language/original-web.txt | 3 + .../pages/manage/components/ProjectPanel.vue | 11 ++- .../js/pages/manage/components/TaskAdd.vue | 27 ++++- .../manage/components/TaskTemplateBrowser.vue | 1 + resources/assets/js/store/actions.js | 11 +++ .../Feature/CrossProjectTaskTemplateTest.php | 98 ++++++++++++++++++- 10 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2026_05_11_000001_add_task_template_share_to_projects_table.php diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 302659ab3..3e97444d7 100755 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -302,6 +302,7 @@ class ProjectController extends AbstractController * @apiParam {String} [archive_method] 归档方式 * @apiParam {Number} [archive_days] 自动归档天数 * @apiParam {String} [ai_auto_analyze] AI自动分析(open|close) + * @apiParam {String} [task_template_share] 共享模板(open|close) * * @apiSuccess {Number} ret 返回状态码(1正确、0错误) * @apiSuccess {String} msg 返回信息(错误描述) @@ -317,6 +318,7 @@ class ProjectController extends AbstractController $archive_method = Request::input('archive_method'); $archive_days = intval(Request::input('archive_days')); $ai_auto_analyze = Request::input('ai_auto_analyze'); + $task_template_share = Request::input('task_template_share'); if (mb_strlen($name) < 2) { return Base::retError('项目名称不可以少于2个字'); } elseif (mb_strlen($name) > 32) { @@ -332,7 +334,7 @@ class ProjectController extends AbstractController } // $project = Project::userProject($project_id, true, true); - AbstractModel::transaction(function () use ($archive_days, $archive_method, $ai_auto_analyze, $desc, $name, $project) { + AbstractModel::transaction(function () use ($archive_days, $archive_method, $ai_auto_analyze, $task_template_share, $desc, $name, $project) { if ($project->name != $name) { $project->addLog("修改项目名称", [ 'change' => [$project->name, $name] @@ -364,6 +366,12 @@ class ProjectController extends AbstractController ]); $project->ai_auto_analyze = $ai_auto_analyze; } + if (in_array($task_template_share, ['open', 'close']) && $project->task_template_share != $task_template_share) { + $project->addLog("修改共享模板", [ + 'change' => [$project->task_template_share, $task_template_share] + ]); + $project->task_template_share = $task_template_share; + } $project->save(); }); $project->pushMsg('update'); @@ -2538,14 +2546,15 @@ 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) { + $shareEnabled = ($project->task_template_share ?: 'open') === 'open'; + if ($isMember && ($tpl->project_id == $project->id || $shareEnabled)) { $tpl->incrementUsage(); } } @@ -3674,6 +3683,10 @@ class ProjectController extends AbstractController $currentProjectId = intval(Request::input('current_project_id', 0)); $projectIds = ProjectUser::where('userid', $user->userid)->pluck('project_id'); + $currentProject = $currentProjectId > 0 ? Project::find($currentProjectId) : null; + if ($currentProject && ($currentProject->task_template_share ?: 'open') === 'close') { + $projectIds = collect($projectIds)->filter(fn($id) => intval($id) === $currentProjectId)->values(); + } $rows = ProjectTaskTemplate::with(['project:id,name']) ->whereIn('project_id', $projectIds) @@ -3709,6 +3722,7 @@ class ProjectController extends AbstractController * @apiName task__template_search * * @apiParam {String} [keyword] 关键字(在 name/title/content 上模糊匹配) + * @apiParam {Number} [current_project_id] 当前项目 ID(共享模板关闭时仅返回本项目模板) * @apiParam {Number} [page=1] 页码 * @apiParam {Number} [page_size=20] 每页条数(最大 50) * @@ -3719,10 +3733,15 @@ class ProjectController extends AbstractController { $user = User::auth(); $keyword = trim((string) Request::input('keyword', '')); + $currentProjectId = intval(Request::input('current_project_id', 0)); $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'); + $currentProject = $currentProjectId > 0 ? Project::find($currentProjectId) : null; + if ($currentProject && ($currentProject->task_template_share ?: 'open') === 'close') { + $projectIds = collect($projectIds)->filter(fn($id) => intval($id) === $currentProjectId)->values(); + } $q = ProjectTaskTemplate::with(['project:id,name', 'user:userid,nickname']) ->whereIn('project_id', $projectIds); diff --git a/app/Models/Project.php b/app/Models/Project.php index bf4b891c9..8f4937b08 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -22,6 +22,8 @@ use Request; * @property int|null $personal 是否个人项目 * @property string|null $archive_method 自动归档方式 * @property int|null $archive_days 自动归档天数 + * @property string|null $ai_auto_analyze AI自动分析 + * @property string|null $task_template_share 共享模板开关 * @property string|null $user_simple 成员总数|1,2,3 * @property int|null $dialog_id 聊天会话ID * @property \Illuminate\Support\Carbon|null $archived_at 归档时间 diff --git a/database/migrations/2026_05_11_000001_add_task_template_share_to_projects_table.php b/database/migrations/2026_05_11_000001_add_task_template_share_to_projects_table.php new file mode 100644 index 000000000..2889498fe --- /dev/null +++ b/database/migrations/2026_05_11_000001_add_task_template_share_to_projects_table.php @@ -0,0 +1,26 @@ +string('task_template_share', 20)->default('open')->after('ai_auto_analyze')->comment('共享模板开关'); + } + }); + } + + public function down() + { + Schema::table('projects', function (Blueprint $table) { + if (Schema::hasColumn('projects', 'task_template_share')) { + $table->dropColumn('task_template_share'); + } + }); + } +} diff --git a/language/original-api.txt b/language/original-api.txt index 508a80fb3..f87439055 100644 --- a/language/original-api.txt +++ b/language/original-api.txt @@ -993,3 +993,4 @@ LDAP 用户缺少邮箱属性,请联系管理员配置 不能将部门负责人任命为部门管理员 该用户不存在 无权操作此模板 +修改共享模板 diff --git a/language/original-web.txt b/language/original-web.txt index c5683268c..e4856531c 100644 --- a/language/original-web.txt +++ b/language/original-web.txt @@ -2388,3 +2388,6 @@ AI任务分析 来自(*) 暂无可用模板 加载中 +共享模板 +开启后,添加任务时可使用其他项目共享的任务模板。 +关闭后,添加任务时仅加载本项目模板,不显示其他项目共享模板。 diff --git a/resources/assets/js/pages/manage/components/ProjectPanel.vue b/resources/assets/js/pages/manage/components/ProjectPanel.vue index e11e4d200..3e7c7affe 100644 --- a/resources/assets/js/pages/manage/components/ProjectPanel.vue +++ b/resources/assets/js/pages/manage/components/ProjectPanel.vue @@ -430,6 +430,14 @@
{{$L('新建任务后AI自动分析并给出建议。')}}
{{$L('关闭后本项目将不再自动分析任务。')}}
+ + + {{$L('开启')}} + {{$L('关闭')}} + +
{{$L('开启后,添加任务时可使用其他项目共享的任务模板。')}}
+
{{$L('关闭后,添加任务时仅加载本项目模板,不显示其他项目共享模板。')}}
+
@@ -1619,7 +1627,8 @@ export default { desc: this.projectData.desc, archive_method: this.projectData.archive_method, archive_days: this.projectData.archive_days, - ai_auto_analyze: this.projectData.ai_auto_analyze || 'open' + ai_auto_analyze: this.projectData.ai_auto_analyze || 'open', + task_template_share: this.projectData.task_template_share || 'open' }); this.settingShow = true; this.$nextTick(() => { diff --git a/resources/assets/js/pages/manage/components/TaskAdd.vue b/resources/assets/js/pages/manage/components/TaskAdd.vue index f4533bba7..ab3c93a27 100644 --- a/resources/assets/js/pages/manage/components/TaskAdd.vue +++ b/resources/assets/js/pages/manage/components/TaskAdd.vue @@ -196,7 +196,7 @@
- + @@ -294,6 +294,11 @@ export default { computed: { ...mapState(['cacheProjects', 'projectId', 'cacheColumns', 'taskPriority', 'taskTemplates', 'formOptions']), + taskTemplateShareEnabled() { + const project = (this.cacheProjects || []).find(({id}) => id == this.addData.project_id) + return !project || project.task_template_share !== 'close' + }, + taskDays() { const {times} = this.addData; const temp = $A.newDateString(times, "YYYY-MM-DD HH:mm"); @@ -316,6 +321,9 @@ export default { const all = this.taskTemplates || [] const currentId = this.addData.project_id const ownTemplates = all.filter(t => t.project_id == currentId) + if (!this.taskTemplateShareEnabled) { + return [...ownTemplates].sort((a, b) => (a.sort || 0) - (b.sort || 0) || a.id - b.id) + } if (ownTemplates.length > 0) { return [...ownTemplates].sort((a, b) => (a.sort || 0) - (b.sort || 0) || a.id - b.id) } @@ -329,6 +337,9 @@ export default { * 是否存在"未在 chip 区展示的可见模板"——决定"更多"按钮显隐。 */ hasMoreTemplates() { + if (!this.taskTemplateShareEnabled) { + return false + } const all = this.taskTemplates || [] const currentId = this.addData.project_id const ownCount = all.filter(t => t.project_id == currentId).length @@ -576,7 +587,13 @@ export default { } this.loadIng++; - this.$store.dispatch("taskAdd", Object.assign({}, this.addData, {template_id: this.templateActiveID || 0})).then(({msg}) => { + const currentTemplate = this.templateActiveID + ? (this.taskTemplates || []).find(item => item.id === this.templateActiveID) + : null; + const templateId = currentTemplate && (this.taskTemplateShareEnabled || currentTemplate.project_id == this.addData.project_id) + ? this.templateActiveID + : 0; + this.$store.dispatch("taskAdd", Object.assign({}, this.addData, {template_id: templateId})).then(({msg}) => { $A.messageSuccess(msg); if (continued === true) { this.addData = Object.assign({}, this.addData, this.templateCompareData, {subtasks: []}); @@ -639,6 +656,9 @@ export default { }, openTemplateBrowser() { + if (!this.taskTemplateShareEnabled) { + return + } this.templateBrowserVisible = true }, @@ -647,6 +667,9 @@ export default { }, setTaskTemplate(item, force = false) { + if (!this.taskTemplateShareEnabled && item.project_id != this.addData.project_id) { + return; + } if (force) { this.templateActiveID = item.id; this.addData.name = item.title; diff --git a/resources/assets/js/pages/manage/components/TaskTemplateBrowser.vue b/resources/assets/js/pages/manage/components/TaskTemplateBrowser.vue index 73ebb2d8f..da8609248 100644 --- a/resources/assets/js/pages/manage/components/TaskTemplateBrowser.vue +++ b/resources/assets/js/pages/manage/components/TaskTemplateBrowser.vue @@ -109,6 +109,7 @@ export default { url: 'project/task/template_search', data: { keyword: this.keyword, + current_project_id: this.currentProjectId || 0, page: this.page, page_size: this.pageSize, }, diff --git a/resources/assets/js/store/actions.js b/resources/assets/js/store/actions.js index 8f873a94c..12596ea60 100644 --- a/resources/assets/js/store/actions.js +++ b/resources/assets/js/store/actions.js @@ -3055,6 +3055,17 @@ export default { * @returns {Promise} */ async updateTaskTemplates({state, dispatch}, currentProjectId) { + const project = (state.cacheProjects || []).find(({id}) => id == currentProjectId) + if (project && project.task_template_share === 'close') { + const {data} = await dispatch("call", { + url: 'project/task/template_list', + data: { + project_id: currentProjectId || 0, + }, + }) + state.taskTemplates = Array.isArray(data) ? data : [] + return + } const {data} = await dispatch("call", { url: 'project/task/template_visible', data: { diff --git a/tests/Feature/CrossProjectTaskTemplateTest.php b/tests/Feature/CrossProjectTaskTemplateTest.php index 26badfa18..bb6a167fe 100644 --- a/tests/Feature/CrossProjectTaskTemplateTest.php +++ b/tests/Feature/CrossProjectTaskTemplateTest.php @@ -111,11 +111,45 @@ class CrossProjectTaskTemplateTest extends TestCase return ['total' => $total, 'items' => $items, 'page' => $page, 'page_size' => $pageSize]; } + /** + * 复刻共享模板关闭后的搜索范围:目标项目关闭共享模板时,仅返回目标项目自己的模板。 + */ + private function callTemplateSearchForProject(int $userid, int $currentProjectId, string $keyword = '', int $page = 1, int $pageSize = 20): array + { + $projectIds = ProjectUser::where('userid', $userid)->pluck('project_id'); + $currentProject = Project::find($currentProjectId); + if ($currentProject && ($currentProject->task_template_share ?: 'open') === 'close') { + $projectIds = collect($projectIds)->filter(fn($id) => intval($id) === $currentProjectId)->values(); + } + $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, + 'name' => $tpl->name, + ])->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 + private function simulateUseTemplate(int $userid, int $templateId, ?int $targetProjectId = null): void { if ($templateId <= 0) return; $tpl = ProjectTaskTemplate::find($templateId); @@ -123,6 +157,13 @@ class CrossProjectTaskTemplateTest extends TestCase $isMember = ProjectUser::where('project_id', $tpl->project_id) ->where('userid', $userid)->exists(); if (!$isMember) return; + if ($targetProjectId) { + $targetProject = Project::find($targetProjectId); + $shareEnabled = !$targetProject || ($targetProject->task_template_share ?: 'open') === 'open'; + if ($tpl->project_id != $targetProjectId && !$shareEnabled) { + return; + } + } $tpl->incrementUsage(); } @@ -132,6 +173,10 @@ class CrossProjectTaskTemplateTest extends TestCase private function callTemplateVisible(int $userid, int $currentProjectId): array { $projectIds = ProjectUser::where('userid', $userid)->pluck('project_id'); + $currentProject = Project::find($currentProjectId); + if ($currentProject && ($currentProject->task_template_share ?: 'open') === 'close') { + $projectIds = collect($projectIds)->filter(fn($id) => intval($id) === $currentProjectId)->values(); + } return ProjectTaskTemplate::with(['project:id,name']) ->whereIn('project_id', $projectIds) ->orderByRaw('project_id = ? DESC', [$currentProjectId]) @@ -324,4 +369,55 @@ class CrossProjectTaskTemplateTest extends TestCase $this->simulateUseTemplate($alice->userid, 99999999); $this->assertTrue(true); } + + public function test_visible_returns_only_current_project_templates_when_share_closed() + { + $alice = $this->makeUser('alice-' . uniqid() . '@test.com'); + $projectA = $this->makeProject($alice->userid); + $projectB = $this->makeProject($alice->userid); + $projectB->task_template_share = 'close'; + $projectB->save(); + $this->makeTemplate($projectA, $alice->userid, ['name' => 'A1']); + $this->makeTemplate($projectB, $alice->userid, ['name' => 'B1']); + + $result = $this->callTemplateVisible($alice->userid, $projectB->id); + + $names = array_column($result, 'name'); + $this->assertNotContains('A1', $names); + $this->assertContains('B1', $names); + } + + public function test_search_returns_only_current_project_templates_when_share_closed() + { + $alice = $this->makeUser('alice-' . uniqid() . '@test.com'); + $projectA = $this->makeProject($alice->userid); + $projectB = $this->makeProject($alice->userid); + $projectB->task_template_share = 'close'; + $projectB->save(); + $this->makeTemplate($projectA, $alice->userid, ['name' => 'shared']); + $this->makeTemplate($projectB, $alice->userid, ['name' => 'own']); + + $result = $this->callTemplateSearchForProject($alice->userid, $projectB->id); + + $names = array_column($result['items'], 'name'); + $this->assertNotContains('shared', $names); + $this->assertContains('own', $names); + } + + public function test_cross_project_template_usage_ignored_when_target_project_share_closed() + { + $alice = $this->makeUser('alice-' . uniqid() . '@test.com'); + $projectA = $this->makeProject($alice->userid); + $projectB = $this->makeProject($alice->userid); + $projectB->task_template_share = 'close'; + $projectB->save(); + $tplA = $this->makeTemplate($projectA, $alice->userid, ['use_count' => 7]); + $tplB = $this->makeTemplate($projectB, $alice->userid, ['use_count' => 3]); + + $this->simulateUseTemplate($alice->userid, $tplA->id, $projectB->id); + $this->simulateUseTemplate($alice->userid, $tplB->id, $projectB->id); + + $this->assertSame(7, (int) $tplA->fresh()->use_count); + $this->assertSame(4, (int) $tplB->fresh()->use_count); + } }