mirror of
https://github.com/kuaifan/dootask.git
synced 2026-05-24 01:14:06 +00:00
feat(template): 添加共享模板功能,支持项目间模板使用控制
This commit is contained in:
parent
d81b4ed273
commit
7e5b31cfb2
@ -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);
|
||||
|
||||
@ -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 归档时间
|
||||
|
||||
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -993,3 +993,4 @@ LDAP 用户缺少邮箱属性,请联系管理员配置
|
||||
不能将部门负责人任命为部门管理员
|
||||
该用户不存在
|
||||
无权操作此模板
|
||||
修改共享模板
|
||||
|
||||
@ -2388,3 +2388,6 @@ AI任务分析
|
||||
来自(*)
|
||||
暂无可用模板
|
||||
加载中
|
||||
共享模板
|
||||
开启后,添加任务时可使用其他项目共享的任务模板。
|
||||
关闭后,添加任务时仅加载本项目模板,不显示其他项目共享模板。
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
11
resources/assets/js/store/actions.js
vendored
11
resources/assets/js/store/actions.js
vendored
@ -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: {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user