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