feat(template): 添加共享模板功能,支持项目间模板使用控制

This commit is contained in:
kuaifan 2026-05-11 03:26:59 +00:00
parent d81b4ed273
commit 7e5b31cfb2
10 changed files with 198 additions and 7 deletions

View File

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

View File

@ -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 归档时间

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddTaskTemplateShareToProjectsTable extends Migration
{
public function up()
{
Schema::table('projects', function (Blueprint $table) {
if (!Schema::hasColumn('projects', 'task_template_share')) {
$table->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');
}
});
}
}

View File

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

View File

@ -2388,3 +2388,6 @@ AI任务分析
来自(*)
暂无可用模板
加载中
共享模板
开启后,添加任务时可使用其他项目共享的任务模板。
关闭后,添加任务时仅加载本项目模板,不显示其他项目共享模板。

View File

@ -430,6 +430,14 @@
<div v-else-if="settingData.ai_auto_analyze === 'open'" class="form-tip">{{$L('新建任务后AI自动分析并给出建议。')}}</div>
<div v-else class="form-tip">{{$L('关闭后本项目将不再自动分析任务。')}}</div>
</FormItem>
<FormItem :label="$L('共享模板')" prop="task_template_share">
<RadioGroup v-model="settingData.task_template_share">
<Radio label="open">{{$L('开启')}}</Radio>
<Radio label="close">{{$L('关闭')}}</Radio>
</RadioGroup>
<div v-if="settingData.task_template_share === 'open'" class="form-tip">{{$L('开启后添加任务时可使用其他项目共享的任务模板')}}</div>
<div v-else class="form-tip">{{$L('关闭后,添加任务时仅加载本项目模板,不显示其他项目共享模板。')}}</div>
</FormItem>
</Form>
<div slot="footer" class="adaption">
<Button type="default" @click="settingShow=false">{{$L('取消')}}</Button>
@ -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(() => {

View File

@ -196,7 +196,7 @@
</div>
<TaskExistTips ref="taskExistTipsRef" @onContinue="onAdd(addContinue, true)"/>
<TaskTemplateBrowser v-model="templateBrowserVisible" :current-project-id="addData.project_id" @pick="onPickFromBrowser" />
<TaskTemplateBrowser v-if="taskTemplateShareEnabled" v-model="templateBrowserVisible" :current-project-id="addData.project_id" @pick="onPickFromBrowser" />
</div>
</template>
@ -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;

View File

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

View File

@ -3055,6 +3055,17 @@ export default {
* @returns {Promise<void>}
*/
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: {

View File

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