From 0863e5529a6a829ec9de141ac6b23b27f80ab1ee Mon Sep 17 00:00:00 2001
From: kuaifan
Date: Thu, 21 May 2026 00:14:36 +0000
Subject: [PATCH] =?UTF-8?q?feat(manage):=20=E5=AE=9E=E7=8E=B0=E9=83=A8?=
=?UTF-8?q?=E9=97=A8=E8=B4=9F=E8=B4=A3=E4=BA=BA=E8=A7=86=E8=A7=92=EF=BC=8C?=
=?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8F=AA=E8=AF=BB=E6=9F=A5=E7=9C=8B=E9=83=A8?=
=?UTF-8?q?=E9=97=A8=E6=88=90=E5=91=98=E9=A1=B9=E7=9B=AE=E4=B8=8E=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
部门负责人/部门管理员可通过系统配置开启,选择管理部门后只读查看
本部门及下级部门成员的全部项目和任务。前端自动根据 department_readonly
标记禁用编辑操作,后端统一注入负责人视角上下文控制数据访问边界。
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../Controllers/Api/ProjectController.php | 69 ++++--
app/Http/Controllers/Api/SystemController.php | 2 +
app/Http/Controllers/Api/UsersController.php | 15 +-
app/Models/Project.php | 27 +++
app/Models/ProjectTask.php | 33 +++
app/Models/UserDepartment.php | 155 +++++++++++++
app/Models/WebSocketDialog.php | 6 +
resources/assets/js/components/GanttView.vue | 18 ++
.../assets/js/components/TEditorTask.vue | 19 +-
resources/assets/js/pages/manage.vue | 137 +++++++++++-
.../manage/components/DepartmentOwnerView.vue | 144 ++++++++++++
.../pages/manage/components/ProjectGantt.vue | 14 +-
.../pages/manage/components/ProjectList.vue | 93 +++++++-
.../pages/manage/components/ProjectPanel.vue | 67 ++++--
.../manage/components/ProjectWorkflow.vue | 2 +-
.../manage/components/TaskContentHistory.vue | 2 +-
.../js/pages/manage/components/TaskDetail.vue | 187 ++++++++++++++--
.../js/pages/manage/components/TaskRow.vue | 33 ++-
.../setting/components/SystemSetting.vue | 8 +
resources/assets/js/store/actions.js | 207 +++++++++++++++++-
resources/assets/js/store/state.js | 3 +
resources/assets/sass/dark.scss | 36 +++
resources/assets/sass/pages/common.scss | 8 +
.../sass/pages/components/project-list.scss | 93 ++++++++
.../sass/pages/components/project-panel.scss | 6 +
.../sass/pages/components/task-detail.scss | 3 +
resources/assets/sass/pages/page-manage.scss | 66 +++++-
27 files changed, 1359 insertions(+), 94 deletions(-)
create mode 100644 resources/assets/js/pages/manage/components/DepartmentOwnerView.vue
diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php
index 3e97444d7..178beca24 100755
--- a/app/Http/Controllers/Api/ProjectController.php
+++ b/app/Http/Controllers/Api/ProjectController.php
@@ -46,6 +46,7 @@ use App\Models\ProjectTaskTemplate;
use App\Models\ProjectTag;
use App\Models\ProjectTaskRelation;
use App\Models\ProjectTaskAiEvent;
+use App\Models\UserDepartment;
use App\Module\AiTaskSuggestion;
use App\Observers\ProjectTaskObserver;
@@ -128,6 +129,7 @@ class ProjectController extends AbstractController
public function lists()
{
$user = User::auth();
+ $departmentView = UserDepartment::ownerViewContext($user);
//
$all = Request::input('all');
$type = Request::input('type', 'all');
@@ -141,6 +143,9 @@ class ProjectController extends AbstractController
if ($all) {
$user->identity('admin');
$builder = Project::allData();
+ } elseif ($departmentView['enabled']) {
+ $projectIds = array_values(array_unique(array_merge($departmentView['own_project_ids'], $departmentView['project_ids'])));
+ $builder = Project::allData()->whereIn('projects.id', $projectIds);
} else {
$builder = Project::authData();
}
@@ -180,8 +185,9 @@ class ProjectController extends AbstractController
->orderBy('project_users.sort')
->orderByDesc('projects.id')
->paginate(Base::getPaginate(100, 50));
- $list->transform(function (Project $project) use ($getstatistics, $getuserid, $user) {
+ $list->transform(function (Project $project) use ($getstatistics, $getuserid, $user, $departmentView) {
$array = $project->toArray();
+ $array = UserDepartment::appendDepartmentReadonlyProject($array, $departmentView);
if ($getuserid == 'yes') {
$array['userid_list'] = ProjectUser::whereProjectId($project->id)->pluck('userid')->toArray();
}
@@ -250,13 +256,15 @@ class ProjectController extends AbstractController
public function one()
{
$user = User::auth();
+ $departmentView = UserDepartment::ownerViewContext($user, true);
//
$project_id = intval(Request::input('project_id'));
//
- $project = Project::userProject($project_id);
+ $project = Project::findForDepartmentView($project_id);
$data = array_merge($project->toArray(), $project->getTaskStatistics($user->userid), [
'project_user' => $project->projectUser,
]);
+ $data = UserDepartment::appendDepartmentReadonlyProject($data, $departmentView);
//
return Base::retSuccess('success', $data);
}
@@ -999,7 +1007,7 @@ class ProjectController extends AbstractController
//
$project_id = intval(Request::input('project_id'));
// 项目
- $project = Project::userProject($project_id);
+ $project = Project::findForDepartmentView($project_id);
//
$list = ProjectColumn::whereProjectId($project->id)
->orderBy('sort')
@@ -1230,6 +1238,7 @@ class ProjectController extends AbstractController
{
$user = User::auth();
$userid = $user->userid;
+ $departmentView = UserDepartment::ownerViewContext($user, true);
//
$parent_id = intval(Request::input('parent_id'));
$project_id = intval(Request::input('project_id'));
@@ -1294,7 +1303,7 @@ class ProjectController extends AbstractController
if ($parent_id > 0) {
$isArchived = str_replace(['all', 'yes', 'no'], [null, false, true], $archived);
$isDeleted = str_replace(['all', 'yes', 'no'], [null, false, true], $deleted);
- ProjectTask::userTask($parent_id, $isArchived, $isDeleted);
+ ProjectTask::findForDepartmentView($parent_id, $isArchived, $isDeleted);
$scopeAll = true;
$archived = 'all';
$builder->where('project_tasks.parent_id', $parent_id);
@@ -1302,17 +1311,23 @@ class ProjectController extends AbstractController
$builder->where('project_tasks.parent_id', 0);
}
if ($project_id > 0) {
- Project::userProject($project_id);
+ if (!UserDepartment::isDepartmentReadonlyProject($departmentView, $project_id)) {
+ Project::userProject($project_id);
+ }
$scopeAll = true;
$builder->where('project_tasks.project_id', $project_id);
}
if (!$scopeAll && $scope === 'all_project') {
$scopeAll = true;
- $builder->whereIn('project_tasks.project_id', function ($query) use ($userid) {
- $query->select('project_id')
- ->from('project_users')
- ->where('userid', $userid);
- });
+ if ($departmentView['enabled']) {
+ $builder->whereIn('project_tasks.project_id', array_values(array_unique(array_merge($departmentView['own_project_ids'], $departmentView['project_ids']))));
+ } else {
+ $builder->whereIn('project_tasks.project_id', function ($query) use ($userid) {
+ $query->select('project_id')
+ ->from('project_users')
+ ->where('userid', $userid);
+ });
+ }
}
if ($scopeAll) {
$builder->allData();
@@ -1391,12 +1406,15 @@ class ProjectController extends AbstractController
$query->on('project_sub_task_visibility_users.task_id', '=', 'project_tasks.parent_id');
$query->where('project_sub_task_visibility_users.userid', $userid);
});
- $builder->where(function ($query) use ($userid) {
+ $builder->where(function ($query) use ($userid, $departmentView) {
$query->where("project_tasks.visibility", 1);
$query->orWhere("project_users.userid", $userid);
$query->orWhere("project_task_users.userid", $userid);
$query->orWhere("project_task_visibility_users.userid", $userid);
$query->orWhere("project_sub_task_visibility_users.userid", $userid);
+ if ($departmentView['enabled']) {
+ $query->orWhereIn('project_tasks.project_id', $departmentView['project_ids']);
+ }
});
// 优化子查询汇总
$builder->leftJoinSub(function ($query) {
@@ -1439,6 +1457,7 @@ class ProjectController extends AbstractController
$data = $list->toArray();
// 还原字段
foreach($data['data'] as &$item){
+ $item['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($item['project_id']));
$item['file_num'] = $item['_file_num'] ?: 0;
$item['msg_num'] = $item['_msg_num'] ?: 0;
$item['sub_num'] = $item['_sub_num'] ?: 0;
@@ -2018,17 +2037,18 @@ class ProjectController extends AbstractController
public function task__one()
{
$user = User::auth();
+ $departmentView = UserDepartment::ownerViewContext($user, true);
//
$task_id = intval(Request::input('task_id'));
$archived = Request::input('archived', 'no');
//
$isArchived = str_replace(['all', 'yes', 'no'], [null, false, true], $archived);
- $task = ProjectTask::userTask($task_id, $isArchived, true, ['taskUser', 'taskTag']);
+ $task = ProjectTask::findForDepartmentView($task_id, $isArchived, true, ['taskUser', 'taskTag']);
// 项目可见性
$projectOwnerids = ProjectUser::whereProjectId($task->project_id)
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
->pluck('userid')->map(fn($v) => (int)$v)->toArray(); // 项目负责人(含项目管理员)
- if ($task->visibility != 1 && !in_array($user->userid, $projectOwnerids)) {
+ if (!UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id)) && $task->visibility != 1 && !in_array($user->userid, $projectOwnerids)) {
$taskUserids = ProjectTaskUser::whereTaskId($task_id)->pluck('userid')->toArray(); //任务负责人、协助人
$subTaskUserids = ProjectTaskUser::whereTaskPid($task_id)->pluck('userid')->toArray(); //子任务负责人、协助人
$visibleUserids = ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid')->toArray(); //可见人
@@ -2039,6 +2059,7 @@ class ProjectController extends AbstractController
}
//
$data = $task->toArray();
+ $data['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id));
$data['project_name'] = $task->project?->name;
$data['column_name'] = $task->projectColumn?->name;
$data['visibility_appointor'] = $task->visibility == 1 ? [0] : ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid');
@@ -2067,7 +2088,7 @@ class ProjectController extends AbstractController
return Base::retError('参数错误', ['task_id' => $task_id]);
}
//
- $task = ProjectTask::userTask($task_id);
+ $task = ProjectTask::findForDepartmentView($task_id);
//
return Base::retSuccess('success', [
'id' => $task->id,
@@ -2099,7 +2120,7 @@ class ProjectController extends AbstractController
return Base::retError('参数错误', ['task_id' => $task_id]);
}
- $task = ProjectTask::userTask($task_id, null);
+ $task = ProjectTask::findForDepartmentView($task_id, null);
$relations = ProjectTaskRelation::whereTaskId($task->id)
->orderByDesc('updated_at')
@@ -2117,7 +2138,7 @@ class ProjectController extends AbstractController
$relatedTasks = [];
foreach ($relatedTaskIds as $relatedId) {
try {
- $relatedTask = ProjectTask::userTask($relatedId, null, true, ['project', 'projectColumn']);
+ $relatedTask = ProjectTask::findForDepartmentView($relatedId, null, true, ['project', 'projectColumn']);
$flowItemParts = explode('|', $relatedTask->flow_item_name ?: '');
$flowItemStatus = $flowItemParts[0] ?? '';
@@ -2243,7 +2264,7 @@ class ProjectController extends AbstractController
$task_id = intval(Request::input('task_id'));
$history_id = intval(Request::input('history_id'));
//
- $task = ProjectTask::userTask($task_id, null);
+ $task = ProjectTask::findForDepartmentView($task_id, null);
//
if ($history_id > 0) {
$taskContent = ProjectTaskContent::whereTaskId($task->id)->whereId($history_id)->first();
@@ -2283,7 +2304,7 @@ class ProjectController extends AbstractController
//
$task_id = intval(Request::input('task_id'));
//
- $task = ProjectTask::userTask($task_id, null);
+ $task = ProjectTask::findForDepartmentView($task_id, null);
//
$data = ProjectTaskContent::select(['id', 'task_id', 'desc', 'userid', 'created_at'])
->whereTaskId($task->id)
@@ -2312,7 +2333,7 @@ class ProjectController extends AbstractController
//
$task_id = intval(Request::input('task_id'));
//
- $task = ProjectTask::userTask($task_id, null);
+ $task = ProjectTask::findForDepartmentView($task_id, null);
//
return Base::retSuccess('success', $task->taskFile);
}
@@ -2401,7 +2422,7 @@ class ProjectController extends AbstractController
$data = $file->toArray();
$data['path'] = $file->getRawOriginal('path');
//
- ProjectTask::userTask($file->task_id, null);
+ ProjectTask::findForDepartmentView($file->task_id, null);
//
UserRecentItem::record(
$user->userid,
@@ -2442,7 +2463,7 @@ class ProjectController extends AbstractController
abort_if(empty($file), 403, "This file not exist.");
//
try {
- ProjectTask::userTask($file->task_id, null);
+ ProjectTask::findForDepartmentView($file->task_id, null);
} catch (\Throwable $e) {
abort(403, $e->getMessage() ?: "This file not support download.");
}
@@ -3389,7 +3410,7 @@ class ProjectController extends AbstractController
//
$project_id = intval(Request::input('project_id'));
//
- $project = Project::userProject($project_id, true);
+ $project = Project::findForDepartmentView($project_id, true);
//
$list = ProjectFlow::with(['ProjectFlowItem'])->whereProjectId($project->id)->get();
return Base::retSuccess('success', $list);
@@ -3488,10 +3509,10 @@ class ProjectController extends AbstractController
//
$builder = ProjectLog::select(["*"]);
if ($task_id > 0) {
- $task = ProjectTask::userTask($task_id, null);
+ $task = ProjectTask::findForDepartmentView($task_id, null);
$builder->whereTaskId($task->id);
} else {
- $project = Project::userProject($project_id);
+ $project = Project::findForDepartmentView($project_id);
$builder->with(['projectTask:id,parent_id,name'])->whereProjectId($project->id)->whereTaskOnly(0);
}
//
diff --git a/app/Http/Controllers/Api/SystemController.php b/app/Http/Controllers/Api/SystemController.php
index 53870bfff..586e1f006 100755
--- a/app/Http/Controllers/Api/SystemController.php
+++ b/app/Http/Controllers/Api/SystemController.php
@@ -94,6 +94,7 @@ class SystemController extends AbstractController
'unclaimed_task_reminder',
'unclaimed_task_reminder_time',
'task_ai_auto_analyze',
+ 'department_owner_project_view',
])) {
unset($all[$key]);
}
@@ -148,6 +149,7 @@ class SystemController extends AbstractController
$setting['unclaimed_task_reminder'] = $setting['unclaimed_task_reminder'] ?: 'close';
$setting['unclaimed_task_reminder_time'] = $setting['unclaimed_task_reminder_time'] ?: '';
$setting['task_ai_auto_analyze'] = $setting['task_ai_auto_analyze'] ?: 'open';
+ $setting['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'close';
$setting['server_timezone'] = config('app.timezone');
$setting['server_version'] = Base::getVersion();
//
diff --git a/app/Http/Controllers/Api/UsersController.php b/app/Http/Controllers/Api/UsersController.php
index c7aaed184..8c670b3ed 100755
--- a/app/Http/Controllers/Api/UsersController.php
+++ b/app/Http/Controllers/Api/UsersController.php
@@ -404,9 +404,22 @@ class UsersController extends AbstractController
$data['nickname_original'] = $user->getRawOriginal('nickname');
$data['department_name'] = $user->getDepartmentName();
$data['department_owner'] = UserDepartment::where('parent_id',0)->where('owner_userid', $user->userid)->exists(); // 适用默认部门下第1级负责人才能添加部门OKR
+ $data['managed_departments'] = UserDepartment::getManagedDepartments($user->userid)->toArray();
return Base::retSuccess('success', $data);
}
+ /**
+ * @api {get} api/users/info/managed_departments 获取我可切换负责人视角的部门列表
+ */
+ public function info__managed_departments()
+ {
+ $user = User::auth();
+ if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
+ return Base::retSuccess('success', []);
+ }
+ return Base::retSuccess('success', UserDepartment::getManagedDepartments($user->userid));
+ }
+
/**
* @api {get} api/users/info/departments 获取我的部门列表
*
@@ -3272,7 +3285,7 @@ class UsersController extends AbstractController
return Base::retError('参数错误');
}
//
- ProjectTask::userTask($task_id, null, null);
+ ProjectTask::findForDepartmentView($task_id, null, null);
//
UserTaskBrowse::recordBrowse($user->userid, $task_id);
//
diff --git a/app/Models/Project.php b/app/Models/Project.php
index 8f4937b08..b1c6fb20b 100644
--- a/app/Models/Project.php
+++ b/app/Models/Project.php
@@ -723,4 +723,31 @@ class Project extends AbstractModel
}
return $project;
}
+
+ /**
+ * 获取项目(含部门负责人只读视角兜底)
+ * @param int $project_id
+ * @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
+ * @param null|bool|string $mustOwner 仅限 null 时尝试部门只读视角
+ * @return self
+ */
+ public static function findForDepartmentView($project_id, $archived = true, $mustOwner = null)
+ {
+ $user = User::auth();
+ $departmentView = UserDepartment::ownerViewContext($user, true);
+ if (UserDepartment::isDepartmentReadonlyProject($departmentView, intval($project_id)) && $mustOwner === null) {
+ $project = self::allData()->where('projects.id', intval($project_id))->first();
+ if (empty($project)) {
+ throw new ApiException('项目不存在或已被删除', [ 'project_id' => $project_id ], -4001);
+ }
+ if ($archived === true && $project->archived_at != null) {
+ throw new ApiException('项目已归档', [ 'project_id' => $project_id ], -4001);
+ }
+ if ($archived === false && $project->archived_at == null) {
+ throw new ApiException('项目未归档', [ 'project_id' => $project_id ]);
+ }
+ return $project;
+ }
+ return self::userProject($project_id, $archived, $mustOwner);
+ }
}
diff --git a/app/Models/ProjectTask.php b/app/Models/ProjectTask.php
index 3c99a9552..001274475 100644
--- a/app/Models/ProjectTask.php
+++ b/app/Models/ProjectTask.php
@@ -2258,6 +2258,39 @@ class ProjectTask extends AbstractModel
return $task;
}
+ /**
+ * 获取任务(含部门负责人只读视角兜底)
+ * @param int $task_id
+ * @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
+ * @param null|bool $trashed true:仅限未删除, false:仅限已删除, null:不限制
+ * @param array $with
+ * @return self
+ */
+ public static function findForDepartmentView($task_id, $archived = true, $trashed = true, $with = [])
+ {
+ $user = User::auth();
+ $departmentView = UserDepartment::ownerViewContext($user, true);
+ if ($departmentView['enabled']) {
+ $builder = self::with($with)->allData()->where('project_tasks.id', intval($task_id));
+ if ($trashed === false) {
+ $builder->onlyTrashed();
+ } elseif ($trashed === null) {
+ $builder->withTrashed();
+ }
+ $task = $builder->first();
+ if (!empty($task) && UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id))) {
+ if ($archived === true && $task->archived_at != null) {
+ throw new ApiException('任务已归档', ['task_id' => $task_id]);
+ }
+ if ($archived === false && $task->archived_at == null) {
+ throw new ApiException('任务未归档', ['task_id' => $task_id]);
+ }
+ return $task;
+ }
+ }
+ return self::userTask($task_id, $archived, $trashed, $with);
+ }
+
/**
* 构建指定周期内的未完成任务查询(用于周报/日报等)
* @param int $userid
diff --git a/app/Models/UserDepartment.php b/app/Models/UserDepartment.php
index 944dc2f25..77f708633 100644
--- a/app/Models/UserDepartment.php
+++ b/app/Models/UserDepartment.php
@@ -3,7 +3,9 @@
namespace App\Models;
use App\Exceptions\ApiException;
+use App\Module\Base;
use Cache;
+use Request;
/**
* App\Models\UserDepartment
@@ -411,6 +413,93 @@ class UserDepartment extends AbstractModel
return array_unique($subIds);
}
+ /**
+ * 获取用户可切换负责人视角的部门(正负责人 + 部门管理员)
+ * @param int $userid
+ * @return \Illuminate\Support\Collection
+ */
+ public static function getManagedDepartments($userid)
+ {
+ $userid = intval($userid);
+ if ($userid <= 0) {
+ return collect();
+ }
+ $deputyDepartmentIds = \DB::table('user_department_owners')
+ ->where('userid', $userid)
+ ->pluck('department_id')
+ ->map(fn($v) => intval($v))
+ ->toArray();
+
+ return self::select(['id', 'name', 'parent_id', 'owner_userid'])
+ ->where(function ($query) use ($userid, $deputyDepartmentIds) {
+ $query->where('owner_userid', $userid);
+ if ($deputyDepartmentIds) {
+ $query->orWhereIn('id', $deputyDepartmentIds);
+ }
+ })
+ ->orderBy('id')
+ ->get();
+ }
+
+ /**
+ * 获取用户选择的负责人视角部门范围(含所有下级部门)
+ * @param int $userid
+ * @param array|string|null $selectedIds all/空表示全部可管理部门
+ * @return array
+ */
+ public static function getManagedDepartmentScopeIds($userid, $selectedIds = null): array
+ {
+ $managedIds = self::getManagedDepartments($userid)->pluck('id')->map(fn($v) => intval($v))->toArray();
+ if (empty($managedIds)) {
+ return [];
+ }
+ if ($selectedIds === 'all' || $selectedIds === null || $selectedIds === '' || $selectedIds === []) {
+ $selected = $managedIds;
+ } else {
+ if (!is_array($selectedIds)) {
+ $selectedIds = explode(',', (string)$selectedIds);
+ }
+ $selected = array_values(array_intersect(
+ array_map('intval', $selectedIds),
+ $managedIds
+ ));
+ }
+ if (empty($selected)) {
+ return [];
+ }
+ $scopeIds = [];
+ foreach ($selected as $departmentId) {
+ $scopeIds[] = $departmentId;
+ $scopeIds = array_merge($scopeIds, self::getAllSubDepartmentIds($departmentId));
+ }
+ return array_values(array_unique(array_map('intval', $scopeIds)));
+ }
+
+ /**
+ * 获取负责人视角可管理的成员 userid
+ * @param int $userid
+ * @param array|string|null $selectedIds
+ * @return array
+ */
+ public static function getManagedMemberUserids($userid, $selectedIds = null): array
+ {
+ $departmentIds = self::getManagedDepartmentScopeIds($userid, $selectedIds);
+ if (empty($departmentIds)) {
+ return [];
+ }
+ return User::select(['userid'])
+ ->where(function ($query) use ($departmentIds) {
+ foreach ($departmentIds as $departmentId) {
+ $query->orWhere('department', 'like', "%,{$departmentId},%");
+ }
+ })
+ ->pluck('userid')
+ ->map(fn($v) => intval($v))
+ ->unique()
+ ->values()
+ ->toArray();
+ }
+
/**
* 获取部门基本信息(缓存时间1小时)
* @param int|array $ids
@@ -453,4 +542,70 @@ class UserDepartment extends AbstractModel
return is_array($ids) ? $result : $result->first();
}
+ /**
+ * 部门负责人视角上下文(只读)。
+ * $defaultAll=true 用于项目内只读辅助接口兜底:前端漏传部门选择时按全部可管理部门判断。
+ */
+ public static function ownerViewContext(User $user, bool $defaultAll = false): array
+ {
+ $ids = Request::input('department_owner_ids', Request::input('department_ids'));
+ if (($ids === null || $ids === '') && $defaultAll) {
+ $ids = 'all';
+ }
+ $empty = [
+ 'enabled' => false,
+ 'member_userids' => [],
+ 'project_ids' => [],
+ 'project_id_map' => [],
+ 'own_project_ids' => [],
+ 'own_project_id_map' => [],
+ ];
+ if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
+ return $empty;
+ }
+ $memberUserids = self::getManagedMemberUserids($user->userid, $ids);
+ if (empty($memberUserids)) {
+ return $empty;
+ }
+ $projectIds = ProjectUser::whereIn('userid', $memberUserids)
+ ->pluck('project_id')
+ ->map(fn($v) => intval($v))
+ ->unique()
+ ->values()
+ ->toArray();
+ $ownProjectIds = ProjectUser::whereUserid($user->userid)
+ ->pluck('project_id')
+ ->map(fn($v) => intval($v))
+ ->unique()
+ ->values()
+ ->toArray();
+ return [
+ 'enabled' => !empty($projectIds),
+ 'member_userids' => $memberUserids,
+ 'project_ids' => $projectIds,
+ 'project_id_map' => array_fill_keys($projectIds, true),
+ 'own_project_ids' => $ownProjectIds,
+ 'own_project_id_map' => array_fill_keys($ownProjectIds, true),
+ ];
+ }
+
+ /**
+ * 判断项目是否属于部门只读范围(非本人项目)
+ */
+ public static function isDepartmentReadonlyProject(array $context, int $projectId): bool
+ {
+ return !empty($context['enabled'])
+ && isset($context['project_id_map'][$projectId])
+ && !isset($context['own_project_id_map'][$projectId]);
+ }
+
+ /**
+ * 为项目数据附加部门只读标记
+ */
+ public static function appendDepartmentReadonlyProject(array $project, array $context): array
+ {
+ $project['department_readonly'] = self::isDepartmentReadonlyProject($context, intval($project['id']));
+ return $project;
+ }
+
}
diff --git a/app/Models/WebSocketDialog.php b/app/Models/WebSocketDialog.php
index 96413d368..1af0f0c22 100644
--- a/app/Models/WebSocketDialog.php
+++ b/app/Models/WebSocketDialog.php
@@ -912,6 +912,12 @@ class WebSocketDialog extends AbstractModel
if ($projectId > 0 && ProjectUser::whereProjectId($projectId)->whereUserid($userid)->exists()) {
return $dialog;
}
+ if ($projectId > 0 && $checkOwner === false) {
+ $departmentView = UserDepartment::ownerViewContext(User::auth(), true);
+ if (UserDepartment::isDepartmentReadonlyProject($departmentView, $projectId)) {
+ return $dialog;
+ }
+ }
break;
case 'okr':
diff --git a/resources/assets/js/components/GanttView.vue b/resources/assets/js/components/GanttView.vue
index 4fcb64fdb..4191e3b1c 100644
--- a/resources/assets/js/components/GanttView.vue
+++ b/resources/assets/js/components/GanttView.vue
@@ -73,6 +73,10 @@ export default {
itemWidth: {
type: Number,
default: 100
+ },
+ readonly: {
+ type: Boolean,
+ default: false
}
},
data() {
@@ -328,6 +332,12 @@ export default {
this.onDateMove(e.clientX);
},
itemMouseDown(e, item) {
+ if (this.readonly) {
+ if (e.target.classList.contains('timeline-title')) {
+ this.clickItem(item);
+ }
+ return;
+ }
e.preventDefault();
this.onItemMove(item, e.target, e.clientX);
},
@@ -347,6 +357,9 @@ export default {
};
},
onItemMove(item, target, clientX) {
+ if (this.readonly) {
+ return;
+ }
let type = 'moveX';
if (target.classList.contains('timeline-resizer')) {
type = 'moveW';
@@ -384,6 +397,11 @@ export default {
}
},
onMoveOver(target) {
+ if (this.readonly) {
+ this.mouseItem = null;
+ this.dateMove = null;
+ return;
+ }
if (this.mouseItem != null) {
const {start, end} = this.mouseItem.time;
let isM = false;
diff --git a/resources/assets/js/components/TEditorTask.vue b/resources/assets/js/components/TEditorTask.vue
index 27985ddf8..42989d8bc 100755
--- a/resources/assets/js/components/TEditorTask.vue
+++ b/resources/assets/js/components/TEditorTask.vue
@@ -8,7 +8,7 @@
:option-full="optionFull"
:placeholder="placeholder"
:placeholderFull="placeholderFull"
- :readOnly="windowTouch"
+ :readOnly="readonly || windowTouch"
:readOnlyFull="false"
:readOnlyImagePreview="false"
@on-blur="onBlur"
@@ -24,10 +24,10 @@
transfer>
- {{ $L(operateMenu.checked === 'checked' ? '标记未选' : '标记已选') }}
+ {{ $L(operateMenu.checked === 'checked' ? '标记未选' : '标记已选') }}
{{ $L('打开链接') }}
{{ $L('查看图片') }}
- {{ $L('编辑描述') }}
+ {{ $L('编辑描述') }}
{{ $L('历史记录') }}
@@ -99,6 +99,10 @@ export default {
placeholderFull: {
default: ''
},
+ readonly: {
+ type: Boolean,
+ default: false
+ },
},
data() {
@@ -202,6 +206,9 @@ export default {
},
onEditing() {
+ if (this.readonly) {
+ return;
+ }
this.$refs.desc.onFull()
},
@@ -210,6 +217,9 @@ export default {
},
onBlur() {
+ if (this.readonly) {
+ return;
+ }
this.$emit('on-blur');
},
@@ -319,6 +329,9 @@ export default {
},
onLiPreview() {
+ if (this.readonly) {
+ return;
+ }
if (!this.operateMenu.checked) {
return;
}
diff --git a/resources/assets/js/pages/manage.vue b/resources/assets/js/pages/manage.vue
index 877319fac..924706123 100644
--- a/resources/assets/js/pages/manage.vue
+++ b/resources/assets/js/pages/manage.vue
@@ -82,6 +82,24 @@
{{$L('导出签到数据')}}
+
+
+
+
-
+
+
+ {{$L(projectKeyValue ? `没有任何与"${projectKeyValue}"相关的结果` : `没有任何项目`)}}
+
@@ -330,6 +368,9 @@
+
+
+
@@ -456,6 +497,7 @@ import transformEmojiToHtml from "../utils/emoji";
import {languageName} from "../language";
import {AINormalizeJsonContent, PROJECT_AI_SYSTEM_PROMPT, withLanguagePreferencePrompt} from "../utils/ai";
import Draggable from 'vuedraggable'
+import DepartmentOwnerView from "./manage/components/DepartmentOwnerView.vue";
export default {
components: {
@@ -481,7 +523,8 @@ export default {
ProjectArchived,
MicroApps,
ComplaintManagement,
- Draggable
+ Draggable,
+ DepartmentOwnerView
},
directives: {longpress, TransferDom},
data() {
@@ -519,6 +562,7 @@ export default {
projectDraggableList: [],
projectDragging: false,
+ ownerProjectTab: 'mine',
openMenu: {},
visibleMenu: false,
@@ -550,6 +594,7 @@ export default {
taskBrowseHistory: [],
mcpHelperShow: false,
+ departmentOwnerViewShow: false,
}
},
@@ -613,8 +658,10 @@ export default {
'dialogIns',
'formOptions',
+ 'systemConfig',
'mobileTabbar',
'longpressData',
+ 'departmentOwnerProjectsRefreshing',
'mcpServerStatus',
'microAppsIds'
@@ -626,6 +673,14 @@ export default {
return this.microAppsIds?.includes('ai');
},
+ departmentOwnerViewAvailable() {
+ return this.systemConfig.department_owner_project_view === 'open' && (this.userInfo.managed_departments || []).length > 0;
+ },
+
+ cacheDepartmentOwnerIds() {
+ return (this.$store.state.cacheDepartmentOwnerIds || []).map(id => parseInt(id));
+ },
+
/**
* page className
* @param mobileTabbar
@@ -763,6 +818,16 @@ export default {
{path: 'archivedProject', name: '已归档的项目'},
])
}
+ if (this.departmentOwnerViewAvailable) {
+ array.push({
+ path: 'departmentOwnerView',
+ name: '负责人视角',
+ divided: !userIsAdmin,
+ visible: true,
+ selected: this.cacheDepartmentOwnerIds.length > 0,
+ selectedCount: this.cacheDepartmentOwnerIds.length,
+ });
+ }
array.push(...[
{path: 'clearCache', name: '清除缓存', divided: true},
{path: 'logout', name: '退出登录', style: {color: '#f40'}}
@@ -787,7 +852,7 @@ export default {
* 项目列表
* @returns {Array}
*/
- projectLists() {
+ projectBaseLists() {
const {projectKeyValue, cacheProjects} = this;
const data = $A.cloneJSON(cacheProjects).sort((a, b) => {
// 置顶优先
@@ -807,6 +872,36 @@ export default {
return data;
},
+ ownerProjectTabsVisible() {
+ return this.departmentOwnerViewAvailable && this.cacheDepartmentOwnerIds.length > 0;
+ },
+
+ ownerProjectTabs() {
+ return [
+ {type: 'mine', name: '我的项目', count: this.projectBaseLists.filter(item => !item.department_readonly).length},
+ {type: 'readonly', name: '负责人视角', count: this.projectBaseLists.filter(item => item.department_readonly).length},
+ ];
+ },
+
+ routeProjectId() {
+ const {projectId} = this.$route.params;
+ return parseInt(/^\d+$/.test(projectId) ? projectId : 0);
+ },
+
+ routeProject() {
+ if (this.routeProjectId <= 0) {
+ return null;
+ }
+ return this.cacheProjects.find(({id}) => id == this.routeProjectId) || null;
+ },
+
+ projectLists() {
+ if (!this.ownerProjectTabsVisible) {
+ return this.projectBaseLists;
+ }
+ return this.projectBaseLists.filter(item => this.ownerProjectTab === 'readonly' ? item.department_readonly : !item.department_readonly);
+ },
+
/**
* 最近打开的任务列表
* @returns {Array}
@@ -884,6 +979,31 @@ export default {
immediate: true
},
+ ownerProjectTabs: {
+ handler(tabs) {
+ if (!this.ownerProjectTabsVisible) {
+ this.ownerProjectTab = 'mine';
+ return;
+ }
+ const active = tabs.find(item => item.type === this.ownerProjectTab);
+ if (!active || active.count === 0) {
+ const first = tabs.find(item => item.count > 0);
+ if (first) {
+ this.ownerProjectTab = first.type;
+ }
+ }
+ this.syncOwnerProjectTabByRoute();
+ },
+ immediate: true
+ },
+
+ routeProject: {
+ handler() {
+ this.syncOwnerProjectTabByRoute();
+ },
+ immediate: true
+ },
+
projectLists: {
handler(val) {
if (!this.projectDragging) {
@@ -915,6 +1035,12 @@ export default {
methods: {
transformEmojiToHtml,
+ syncOwnerProjectTabByRoute() {
+ if (!this.ownerProjectTabsVisible || !this.routeProject) {
+ return;
+ }
+ this.ownerProjectTab = this.routeProject.department_readonly ? 'readonly' : 'mine';
+ },
chackPass() {
if (this.userInfo.changepass === 1) {
this.goForward({name: 'manage-setting-password'});
@@ -936,6 +1062,9 @@ export default {
settingRoute(path) {
switch (path) {
+ case 'departmentOwnerView':
+ this.departmentOwnerViewShow = true;
+ return;
case 'allUser':
this.allUserShow = true;
return;
diff --git a/resources/assets/js/pages/manage/components/DepartmentOwnerView.vue b/resources/assets/js/pages/manage/components/DepartmentOwnerView.vue
new file mode 100644
index 000000000..fa553eef0
--- /dev/null
+++ b/resources/assets/js/pages/manage/components/DepartmentOwnerView.vue
@@ -0,0 +1,144 @@
+
+
+
+
+ {{$L('可查看所选部门及所有下级部门成员参与的项目和任务,仅支持只读查看。')}}
+
+
+
+
+
+
+
+
{{dept.name}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/assets/js/pages/manage/components/ProjectGantt.vue b/resources/assets/js/pages/manage/components/ProjectGantt.vue
index b1690b9b5..c47c7f06e 100644
--- a/resources/assets/js/pages/manage/components/ProjectGantt.vue
+++ b/resources/assets/js/pages/manage/components/ProjectGantt.vue
@@ -5,7 +5,8 @@
:menuWidth="menuWidth"
:itemWidth="80"
@on-change="onChange"
- @on-click="onClick">
+ @on-click="onClick"
+ :readonly="readonly">
@@ -55,6 +56,10 @@ export default {
flowInfo: {
default: {}
},
+ readonly: {
+ type: Boolean,
+ default: false
+ },
},
data() {
@@ -215,6 +220,9 @@ export default {
},
onChange(item) {
+ if (this.readonly) {
+ return;
+ }
const {time, baktime} = item;
if (Math.abs(baktime.end - time.end) > 1000 || Math.abs(baktime.start - time.start) > 1000) {
//修改时间(变化超过1秒钟)
@@ -238,6 +246,10 @@ export default {
},
editSubmit(save) {
+ if (this.readonly) {
+ this.editData = [];
+ return;
+ }
this.editData && this.editData.forEach(item => {
let task = this.lists.find(({id}) => id == item.id)
if (save) {
diff --git a/resources/assets/js/pages/manage/components/ProjectList.vue b/resources/assets/js/pages/manage/components/ProjectList.vue
index 45237c743..d8b5e9e1b 100644
--- a/resources/assets/js/pages/manage/components/ProjectList.vue
+++ b/resources/assets/js/pages/manage/components/ProjectList.vue
@@ -11,11 +11,31 @@
+
+
+ {{ownerDepartmentIds.length}}
+
+
+
+
+
+ {{$L(item.name)}}
+
+
+
+
+
+
+
+
+
{{item.task_my_num - item.task_my_complete}}
@@ -62,7 +88,7 @@
-
+
{{$L(projectKeyValue ? `没有任何与"${projectKeyValue}"相关的结果` : `没有任何项目`)}}
@@ -88,12 +114,14 @@
{{ $L('项目讨论') }}
-
+
{{ $L(isDragging ? '退出排序' : '调整排序') }}
+
+
@@ -103,10 +131,11 @@ import Draggable from 'vuedraggable'
import longpress from "../../../directives/longpress";
import TransferDom from "../../../directives/transfer-dom";
import transformEmojiToHtml from "../../../utils/emoji";
+import DepartmentOwnerView from "./DepartmentOwnerView.vue";
export default {
name: "ProjectList",
- components: {Draggable},
+ components: {Draggable, DepartmentOwnerView},
directives: {longpress, TransferDom},
data() {
return {
@@ -120,13 +149,30 @@ export default {
isDragging: false,
projectDraggableList: [],
projectDragging: false,
+ ownerProjectTab: 'mine',
+ departmentOwnerViewShow: false,
}
},
computed: {
- ...mapState(['cacheProjects', 'loadProjects', 'longpressData']),
+ ...mapState(['cacheProjects', 'loadProjects', 'longpressData', 'userInfo', 'systemConfig', 'cacheDepartmentOwnerIds', 'departmentOwnerProjectsRefreshing']),
- projectLists() {
+ managedDepartments() {
+ return (this.userInfo.managed_departments || []).map(item => ({
+ ...item,
+ id: parseInt(item.id)
+ }));
+ },
+
+ ownerViewAvailable() {
+ return this.systemConfig.department_owner_project_view === 'open' && this.managedDepartments.length > 0;
+ },
+
+ ownerDepartmentIds() {
+ return (this.cacheDepartmentOwnerIds || []).map(id => parseInt(id));
+ },
+
+ projectBaseLists() {
const {projectKeyValue, cacheProjects} = this;
const data = $A.cloneJSON(cacheProjects).sort((a, b) => {
// 置顶优先
@@ -145,9 +191,44 @@ export default {
}
return data;
},
+
+ ownerProjectTabsVisible() {
+ return this.ownerViewAvailable && this.ownerDepartmentIds.length > 0;
+ },
+
+ ownerProjectTabs() {
+ return [
+ {type: 'mine', name: '我的项目', count: this.projectBaseLists.filter(item => !item.department_readonly).length},
+ {type: 'readonly', name: '负责人视角', count: this.projectBaseLists.filter(item => item.department_readonly).length},
+ ];
+ },
+
+ projectLists() {
+ if (!this.ownerProjectTabsVisible) {
+ return this.projectBaseLists;
+ }
+ return this.projectBaseLists.filter(item => this.ownerProjectTab === 'readonly' ? item.department_readonly : !item.department_readonly);
+ },
},
watch: {
+ ownerProjectTabs: {
+ handler(tabs) {
+ if (!this.ownerProjectTabsVisible) {
+ this.ownerProjectTab = 'mine';
+ return;
+ }
+ const active = tabs.find(item => item.type === this.ownerProjectTab);
+ if (!active || active.count === 0) {
+ const first = tabs.find(item => item.count > 0);
+ if (first) {
+ this.ownerProjectTab = first.type;
+ }
+ }
+ },
+ immediate: true
+ },
+
projectLists: {
handler(val) {
if (!this.projectDragging) {
diff --git a/resources/assets/js/pages/manage/components/ProjectPanel.vue b/resources/assets/js/pages/manage/components/ProjectPanel.vue
index 8c4a17078..707a91325 100644
--- a/resources/assets/js/pages/manage/components/ProjectPanel.vue
+++ b/resources/assets/js/pages/manage/components/ProjectPanel.vue
@@ -10,7 +10,7 @@
- -
+
-
- -
+
-
@@ -52,7 +52,13 @@
-
-
+
+ {{$L('当前为负责人视角:你可查看项目和任务,并参与讨论,但不能编辑项目或任务。')}}
+
@@ -112,7 +121,7 @@
@@ -165,14 +174,14 @@
-
+
-
@@ -241,7 +250,7 @@
-
+
-
+
{{$L('添加列表')}}
@@ -321,6 +330,7 @@
v-if="projectData.cacheParameter.showMy"
:list="transforTasks(myList)"
:task-visibilitys="taskRowVisibilitys"
+ :readonly="isDepartmentReadonly"
open-key="my"
@on-priority="addTaskOpen"
fast-add-task/>
@@ -342,6 +352,7 @@
v-if="projectData.cacheParameter.showHelp"
:list="helpList"
:task-visibilitys="taskRowVisibilitys"
+ :readonly="isDepartmentReadonly"
open-key="help"
@on-priority="addTaskOpen"/>
@@ -362,6 +373,7 @@
v-if="projectData.cacheParameter.showUndone"
:list="unList"
:task-visibilitys="taskRowVisibilitys"
+ :readonly="isDepartmentReadonly"
open-key="undone"
@on-priority="addTaskOpen"/>
@@ -384,6 +396,7 @@
v-if="projectData.cacheParameter.showCompleted"
:list="completedList"
:task-visibilitys="taskRowVisibilitys"
+ :readonly="isDepartmentReadonly"
open-key="completed"
@on-priority="addTaskOpen"
showCompleteAt/>
@@ -391,7 +404,7 @@
@@ -824,7 +837,12 @@ export default {
return this.projectData?.owner_userid === this.userId;
},
+ isDepartmentReadonly() {
+ return !!this.projectData?.department_readonly;
+ },
+
isOwnerOrDeputy() {
+ if (this.isDepartmentReadonly) return false;
if (!this.projectData) return false;
if (this.projectData.owner_userid === this.userId) return true;
return (this.projectData.deputy_userids || []).includes(this.userId);
@@ -1249,6 +1267,9 @@ export default {
},
sortUpdate(only_column) {
+ if (this.isDepartmentReadonly) {
+ return;
+ }
const oldSort = this.sortData;
const newSort = this.getSort();
if (JSON.stringify(oldSort) === JSON.stringify(newSort)) {
@@ -1315,14 +1336,23 @@ export default {
},
addTopShow(id, show) {
+ if (this.isDepartmentReadonly) {
+ return;
+ }
this.$set(this.columnTopShow, id, show);
},
addTaskOpen(params) {
+ if (this.isDepartmentReadonly) {
+ return;
+ }
emitter.emit('addTask', params);
},
addColumnOpen() {
+ if (this.isDepartmentReadonly) {
+ return;
+ }
this.addColumnShow = true;
this.$nextTick(() => {
this.$refs.addColumnName.focus();
@@ -1360,6 +1390,9 @@ export default {
},
dropColumn(column, command) {
+ if (this.isDepartmentReadonly) {
+ return;
+ }
if (command === 'title') {
this.titleColumn(column);
}
@@ -1617,6 +1650,9 @@ export default {
},
projectDropdown(name) {
+ if (this.isDepartmentReadonly && !['favorite', 'log', 'archived_task', 'deleted_task'].includes(name)) {
+ return;
+ }
switch (name) {
case "favorite":
this.toggleProjectFavorite();
@@ -1643,7 +1679,7 @@ export default {
break;
case "user":
- if (!this.isOwnerOrDeputy) {
+ if (this.isDepartmentReadonly || !this.isOwnerOrDeputy) {
return;
}
const userids = this.projectData.project_user.map(({userid}) => userid);
@@ -1714,6 +1750,9 @@ export default {
openTask(task, receive) {
this.$store.dispatch("openTask", task)
+ if (this.isDepartmentReadonly) {
+ return;
+ }
if (receive === true) {
// 向任务窗口发送领取任务请求
setTimeout(() => {
@@ -1777,7 +1816,7 @@ export default {
this.$store.dispatch("call", {
url: 'project/flow/list',
data: {
- project_id: this.projectId,
+ project_id: this.projectId
},
}).then(({data}) => {
this.flowList = data;
diff --git a/resources/assets/js/pages/manage/components/ProjectWorkflow.vue b/resources/assets/js/pages/manage/components/ProjectWorkflow.vue
index a70c272f4..a7864265e 100644
--- a/resources/assets/js/pages/manage/components/ProjectWorkflow.vue
+++ b/resources/assets/js/pages/manage/components/ProjectWorkflow.vue
@@ -365,7 +365,7 @@ export default {
this.$store.dispatch("call", {
url: 'project/flow/list',
data: {
- project_id: this.projectId,
+ project_id: this.projectId
},
}).then(({data}) => {
this.list = data.map(item => {
diff --git a/resources/assets/js/pages/manage/components/TaskContentHistory.vue b/resources/assets/js/pages/manage/components/TaskContentHistory.vue
index 10bea75bc..30f31a5c2 100644
--- a/resources/assets/js/pages/manage/components/TaskContentHistory.vue
+++ b/resources/assets/js/pages/manage/components/TaskContentHistory.vue
@@ -138,7 +138,7 @@ export default {
data: {
task_id: this.taskId,
page: Math.max(this.page, 1),
- pagesize: Math.max($A.runNum(this.pageSize), 10),
+ pagesize: Math.max($A.runNum(this.pageSize), 10)
},
}).then(({data}) => {
this.page = data.current_page;
diff --git a/resources/assets/js/pages/manage/components/TaskDetail.vue b/resources/assets/js/pages/manage/components/TaskDetail.vue
index aa2684da7..df8a2b0e1 100755
--- a/resources/assets/js/pages/manage/components/TaskDetail.vue
+++ b/resources/assets/js/pages/manage/components/TaskDetail.vue
@@ -1,7 +1,7 @@
-
+
- {{taskDetail.flow_item_name}}
+ {{taskDetail.flow_item_name}}
+
+ {{expiresFormat(taskDetail.end_at)}}
+
+
- {{taskDetail.flow_item_name}}
+ {{taskDetail.flow_item_name}}
- {{$L('已归档')}}
+ {{$L('已归档')}}
{{projectName}}
@@ -90,7 +102,7 @@
-
@@ -151,6 +167,7 @@
class="desc"
:value="taskContent"
:placeholder="$L('详细描述...')"
+ :readonly="isDepartmentReadonly"
@on-history="onHistory"
@on-blur="updateBlur('content', $event)"/>
-
+
+
-
- {{taskDetail.p_name}}
+ {{taskDetail.p_name}}
@@ -189,7 +207,11 @@
{{$L('负责人')}}
+
+
+
{{$L('协助人员')}}
+
+
+
- {{$L('可见性')}}
+ {{$L('可见性')}}
-
{{ taskDetail.visibility == 1 ? $L('项目人员可见') : $L('任务人员可见') }}
-
{{ taskDetail.visibility == 1 ? $L('项目人员可见') : $L('任务人员可见') }}
+
+
+
+
- {{$L('截止时间')}}
- {{$L('截止时间')}}
+ {{$L('截止时间')}}
+ {{$L('截止时间')}}
-
@@ -253,13 +282,13 @@
@on-ok="timeOk"
transfer>
-
{{taskDetail.end_at ? cutTime : '--'}}
-
{{taskDetail.end_at ? cutTime : '--'}}
+
{{taskDetail.end_at ? cutTime : '--'}}
+
{{taskDetail.end_at ? cutTime : '--'}}
-
+
{{expiresFormat(taskDetail.end_at)}}
- {{$L('超期未完成')}}
+ {{$L('超期未完成')}}
@@ -273,7 +302,7 @@
-
- {{$L(loopLabel(taskDetail.loop))}}
+ {{$L(loopLabel(taskDetail.loop))}}
@@ -291,7 +320,7 @@
{{$A.bytesToSize(file.size)}}
-
+
-
@@ -151,6 +152,10 @@ export default {
taskVisibilitys: {
type: Object,
default: () => ({})
+ },
+ readonly: {
+ type: Boolean,
+ default: false
}
},
data() {
@@ -202,6 +207,9 @@ export default {
},
dropTask(task, command) {
+ if (this.readonly) {
+ return;
+ }
const el = this.$refs[`taskMenu_${task.id}`];
if (!el) {
return;
@@ -228,6 +236,9 @@ export default {
},
onPriority(data) {
+ if (this.readonly) {
+ return;
+ }
this.$emit("on-priority", data)
},
@@ -260,6 +271,9 @@ export default {
openTask(task, receive) {
this.$store.dispatch("openTask", task)
+ if (this.readonly) {
+ return;
+ }
if (receive === true) {
// 向任务窗口发送领取任务请求
setTimeout(() => {
@@ -269,6 +283,9 @@ export default {
},
openMenu(event, task) {
+ if (this.readonly) {
+ return;
+ }
const el = this.$refs[`taskMenu_${task.id}`];
if (el) {
el[0].handleClick(event)
diff --git a/resources/assets/js/pages/manage/setting/components/SystemSetting.vue b/resources/assets/js/pages/manage/setting/components/SystemSetting.vue
index 377cecfef..cbe75596f 100644
--- a/resources/assets/js/pages/manage/setting/components/SystemSetting.vue
+++ b/resources/assets/js/pages/manage/setting/components/SystemSetting.vue
@@ -75,6 +75,13 @@
{{$L('开启:项目管理员可生成链接邀请成员加入项目。')}}
+
+
+ {{$L('开启')}}
+ {{$L('关闭')}}
+
+ {{$L('开启后,部门负责人/部门管理员可只读查看本部门及下级部门成员参与的项目和项目内全部任务。')}}
+
@@ -351,6 +358,7 @@ export default {
}).then(({data}) => {
if (save) {
$A.messageSuccess('修改成功');
+ this.$store.dispatch("getUserInfo").catch(() => {});
}
this.formDatum = data;
this.formDatum_bak = $A.cloneJSON(this.formDatum);
diff --git a/resources/assets/js/store/actions.js b/resources/assets/js/store/actions.js
index 12596ea60..acd313414 100644
--- a/resources/assets/js/store/actions.js
+++ b/resources/assets/js/store/actions.js
@@ -232,6 +232,31 @@ export default {
], true)) {
params.encrypt = true
}
+ const departmentOwnerReadonlyUrls = [
+ 'project/lists',
+ 'project/one',
+ 'project/column/lists',
+ 'project/task/lists',
+ 'project/task/one',
+ 'project/task/content',
+ 'project/task/content_history',
+ 'project/task/files',
+ 'project/task/fileinfo',
+ 'project/task/subdata',
+ 'project/task/related',
+ 'project/flow/list',
+ 'project/log/lists',
+ 'project/tag/list',
+ ]
+ if (params.departmentOwner !== false
+ && state.systemConfig.department_owner_project_view === 'open'
+ && departmentOwnerReadonlyUrls.includes(params.url)
+ && (state.cacheDepartmentOwnerIds || []).length > 0) {
+ if (!$A.isJson(params.data)) params.data = {}
+ if (params.data.department_owner_ids === undefined) {
+ params.data.department_owner_ids = state.cacheDepartmentOwnerIds.join(',')
+ }
+ }
if (params.encrypt) {
const userAgent = window.navigator.userAgent;
if (window.systemInfo.debug === "yes"
@@ -620,7 +645,7 @@ export default {
* @param dispatch
* @param timeout
*/
- getBasicData({state, dispatch}, timeout) {
+ async getBasicData({state, dispatch}, timeout) {
if (typeof timeout === "number") {
window.__getBasicDataTimer && clearTimeout(window.__getBasicDataTimer)
if (timeout > -1) {
@@ -640,7 +665,7 @@ export default {
dispatch("getTaskPriority", 1000);
dispatch("getReportUnread", 1000);
dispatch("getApproveUnread", 1000);
- dispatch("getProjectByQueue");
+ dispatch("getProjectsForDepartmentOwnerView").catch(() => {});
dispatch("getTaskForDashboard");
dispatch("dialogMsgRead");
dispatch("updateMicroAppsStatus");
@@ -1181,6 +1206,7 @@ export default {
array: [
'cacheUserBasic',
'cacheProjects',
+ 'cacheDepartmentOwnerIds',
'cacheColumns',
'cacheTasks',
'cacheProjectParameter',
@@ -1551,6 +1577,146 @@ export default {
});
},
+ /**
+ * 获取有效的部门负责人视角部门ID
+ * @param state
+ * @param ids
+ * @returns {Array}
+ */
+ normalizeDepartmentOwnerIds({state}, ids) {
+ const validIds = (state.userInfo.managed_departments || []).map(item => parseInt(item.id));
+ if (!$A.isArray(ids)) ids = [];
+ return ids
+ .map(id => parseInt(id))
+ .filter(id => id > 0 && (validIds.length === 0 || validIds.includes(id)));
+ },
+
+ /**
+ * 加载负责人视角下的项目列表
+ * @param state
+ * @param dispatch
+ * @returns {Promise}
+ */
+ async getProjectsForDepartmentOwnerView({state, dispatch}) {
+ await dispatch("systemSetting").catch(() => {});
+ if (state.systemConfig.department_owner_project_view !== 'open') {
+ state.cacheDepartmentOwnerIds = [];
+ await $A.IDBSet("cacheDepartmentOwnerIds", []).catch(() => {});
+ dispatch("getProjectByQueue");
+ return;
+ }
+ const restoredDepartmentOwnerIds = await dispatch("restoreDepartmentOwnerView");
+ if ((restoredDepartmentOwnerIds || []).length > 0) {
+ await dispatch("getProjects", {
+ __replace: true,
+ department_owner_ids: restoredDepartmentOwnerIds.join(',')
+ });
+ state.cacheDepartmentOwnerIds = restoredDepartmentOwnerIds;
+ await $A.IDBSet("cacheDepartmentOwnerIds", restoredDepartmentOwnerIds).catch(() => {});
+ return;
+ }
+ dispatch("getProjectByQueue");
+ },
+
+ /**
+ * 恢复部门负责人视角
+ * @param state
+ * @param dispatch
+ * @returns {Promise}
+ */
+ async restoreDepartmentOwnerView({state, dispatch}) {
+ if (state.departmentOwnerViewRestored) {
+ return [];
+ }
+ if (state.systemConfig.department_owner_project_view !== 'open') {
+ state.cacheDepartmentOwnerIds = [];
+ await $A.IDBSet("cacheDepartmentOwnerIds", []).catch(() => {});
+ return [];
+ }
+ state.departmentOwnerViewRestored = true;
+ const ids = await $A.IDBArray("cacheDepartmentOwnerIds", []);
+ if (!ids.length) {
+ return [];
+ }
+ const restored = await dispatch("normalizeDepartmentOwnerIds", ids);
+ if (restored.length > 0) {
+ state.departmentOwnerProjectsRefreshing = true;
+ }
+ state.cacheDepartmentOwnerIds = restored;
+ await $A.IDBSet("cacheDepartmentOwnerIds", restored).catch(() => {});
+ return restored;
+ },
+
+ /**
+ * 设置部门负责人视角
+ * @param state
+ * @param dispatch
+ * @param ids
+ * @returns {Promise}
+ */
+ async setDepartmentOwnerIds({state, dispatch}, ids) {
+ if (state.systemConfig.department_owner_project_view !== 'open') {
+ ids = [];
+ }
+ const normalized = await dispatch("normalizeDepartmentOwnerIds", ids);
+ const oldValue = (state.cacheDepartmentOwnerIds || []).map(id => parseInt(id)).sort().join(',');
+ const newValue = normalized.slice().sort().join(',');
+ if (oldValue === newValue) {
+ return;
+ }
+ state.departmentOwnerProjectsRefreshing = true;
+ await dispatch("refreshDepartmentOwnerProjects", normalized);
+ state.cacheDepartmentOwnerIds = normalized;
+ await $A.IDBSet("cacheDepartmentOwnerIds", normalized).catch(() => {});
+ },
+
+ /**
+ * 切换部门负责人视角后刷新项目数据
+ * @param state
+ * @param dispatch
+ * @returns {Promise}
+ */
+ async refreshDepartmentOwnerProjects({state, dispatch}, ownerIds = state.cacheDepartmentOwnerIds) {
+ const currentProjectId = state.projectId;
+ ownerIds = (ownerIds || []).map(id => parseInt(id)).filter(id => id > 0);
+ state.departmentOwnerProjectsRefreshing = true;
+ state.callAt = state.callAt.filter(item => {
+ const key = String(item.key);
+ return !key.startsWith('projects::') && !key.startsWith('tasks::');
+ });
+ try {
+ await dispatch("getProjects", {
+ __replace: true,
+ department_owner_ids: ownerIds.join(',')
+ });
+ if (currentProjectId > 0) {
+ const exists = state.cacheProjects.find(({id}) => id == currentProjectId);
+ if (!exists) {
+ const project = $A.cloneJSON(state.cacheProjects).sort((a, b) => {
+ if (a.top_at || b.top_at) {
+ return $A.sortDay(b.top_at, a.top_at);
+ }
+ return b.id - a.id;
+ }).find(({id}) => id);
+ if (project) {
+ $A.goForward({name: 'manage-project', params: {projectId: project.id}});
+ } else {
+ $A.goForward({name: 'manage-dashboard'});
+ }
+ return;
+ }
+ await dispatch("getProjectOne", currentProjectId).catch(() => {});
+ await dispatch("getTaskForProject", currentProjectId).catch(() => {});
+ }
+ } catch (e) {
+ if ((state.cacheDepartmentOwnerIds || []).length === 0 && currentProjectId > 0) {
+ $A.goForward({name: 'manage-dashboard'});
+ }
+ } finally {
+ state.departmentOwnerProjectsRefreshing = false;
+ }
+ },
+
/** *****************************************************************************************/
/** ************************************** 项目 **********************************************/
/** *****************************************************************************************/
@@ -1642,6 +1808,24 @@ export default {
* @returns {Promise}
*/
getProjects({state, dispatch}, requestData) {
+ if (!$A.isJson(requestData)) {
+ requestData = {}
+ }
+ const replace = requestData.__replace === true;
+ delete requestData.__replace;
+ if (state.systemConfig.department_owner_project_view !== 'open') {
+ delete requestData.department_owner_ids
+ } else if (requestData.department_owner_ids === undefined) {
+ if ((state.cacheDepartmentOwnerIds || []).length > 0) {
+ requestData.department_owner_ids = state.cacheDepartmentOwnerIds.join(',')
+ } else {
+ delete requestData.department_owner_ids
+ }
+ }
+ if (replace) {
+ state.callAt = state.callAt.filter(item => !String(item.key).startsWith('projects::'))
+ $A.IDBSet("callAt", state.callAt).catch(() => {})
+ }
return new Promise(function (resolve, reject) {
if (state.userId === 0) {
state.cacheProjects = [];
@@ -1657,6 +1841,9 @@ export default {
url: 'project/lists',
data: callData.get()
}).then(({data}) => {
+ if (replace) {
+ state.cacheProjects = [];
+ }
dispatch("saveProject", data.data);
callData.save(data).then(ids => dispatch("forgetProject", {id: ids}))
state.projectTotal = data.total_all;
@@ -1666,6 +1853,9 @@ export default {
console.warn(e);
reject(e)
}).finally(_ => {
+ if (replace) {
+ state.departmentOwnerProjectsRefreshing = false;
+ }
state.loadProjects--;
});
});
@@ -1702,7 +1892,7 @@ export default {
dispatch("call", {
url: 'project/one',
data: {
- project_id,
+ project_id
},
}).then(result => {
setTimeout(() => {
@@ -2133,9 +2323,14 @@ export default {
* @returns {Promise}
*/
getTasks({state, dispatch}, requestData) {
- if (requestData === null) {
+ if (!$A.isJson(requestData)) {
requestData = {}
}
+ if ((state.cacheDepartmentOwnerIds || []).length > 0) {
+ requestData.department_owner_ids = state.cacheDepartmentOwnerIds.join(',')
+ } else {
+ delete requestData.department_owner_ids
+ }
const callData = $callData('tasks', requestData, state)
//
return new Promise(function (resolve, reject) {
@@ -2435,7 +2630,7 @@ export default {
dispatch("call", {
url: 'project/task/content',
data: {
- task_id,
+ task_id
},
}).then(result => {
dispatch("saveTaskContent", result.data)
@@ -2483,7 +2678,7 @@ export default {
dispatch("call", {
url: 'project/task/files',
data: {
- task_id,
+ task_id
},
}).then(result => {
result.data.forEach((data) => {
diff --git a/resources/assets/js/store/state.js b/resources/assets/js/store/state.js
index 27306455c..dc8a7fb06 100644
--- a/resources/assets/js/store/state.js
+++ b/resources/assets/js/store/state.js
@@ -103,6 +103,9 @@ export default {
cacheColumns: [],
cacheTasks: [],
cacheProjectParameter: [],
+ cacheDepartmentOwnerIds: [],
+ departmentOwnerViewRestored: false,
+ departmentOwnerProjectsRefreshing: false,
// Emoji
cacheEmojis: [],
diff --git a/resources/assets/sass/dark.scss b/resources/assets/sass/dark.scss
index 421519be8..036160900 100644
--- a/resources/assets/sass/dark.scss
+++ b/resources/assets/sass/dark.scss
@@ -467,6 +467,17 @@ body.dark-mode-reverse {
}
}
}
+ .menu-project {
+ > ul {
+ > li {
+ .project-h1 {
+ .readonly-project-avatar {
+ color: #1c1917;
+ }
+ }
+ }
+ }
+ }
}
}
@@ -777,4 +788,29 @@ body.dark-mode-reverse {
filter: invert(100%);
}
}
+
+ .department-owner-view-modal {
+ .department-owner-view-icon {
+ color: #000;
+ }
+ }
+ .project-list {
+ .list-search {
+ .owner-view-button {
+ > em {
+ box-shadow: 0 0 0 2px #1c1917;
+ color: #000;
+ }
+ }
+ }
+ > ul {
+ > li {
+ .project-item {
+ .readonly-project-avatar {
+ color: #1c1917;
+ }
+ }
+ }
+ }
+ }
}
diff --git a/resources/assets/sass/pages/common.scss b/resources/assets/sass/pages/common.scss
index 3c3fe9bc6..c17c1ce17 100755
--- a/resources/assets/sass/pages/common.scss
+++ b/resources/assets/sass/pages/common.scss
@@ -531,6 +531,14 @@ body {
padding-top: 3px;
}
+ .ivu-alert-icon {
+ top: 10px;
+ }
+
+ .ivu-alert-message {
+ line-height: 20px;
+ }
+
.vuepress-markdown-body {
h1,
h2 {
diff --git a/resources/assets/sass/pages/components/project-list.scss b/resources/assets/sass/pages/components/project-list.scss
index cb6ffcad2..5721fcc72 100644
--- a/resources/assets/sass/pages/components/project-list.scss
+++ b/resources/assets/sass/pages/components/project-list.scss
@@ -57,6 +57,80 @@
}
}
}
+
+ .owner-view-button {
+ position: relative;
+ width: 32px;
+ height: 32px;
+ margin-left: 8px;
+ flex-shrink: 0;
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(47, 128, 237, 0.08);
+ color: #808695;
+ > i {
+ font-size: 18px;
+ }
+ > em {
+ position: absolute;
+ top: -5px;
+ right: -5px;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ border-radius: 8px;
+ background: #ff4d4f;
+ color: #ffffff;
+ font-style: normal;
+ font-size: 10px;
+ line-height: 16px;
+ text-align: center;
+ box-shadow: 0 0 0 2px #ffffff;
+ }
+ }
+ }
+
+ .owner-project-wrapper {
+ width: 100%;
+ flex-shrink: 0;
+ }
+ .owner-project-tabs {
+ display: flex;
+ align-items: center;
+ margin: 8px 12px;
+ padding: 3px;
+ border-radius: 12px;
+ background: #f7f7f7;
+ .owner-project-tab {
+ flex: 1;
+ min-width: 0;
+ height: 30px;
+ padding: 0 12px;
+ border-radius: 9px;
+ color: #808695;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ cursor: pointer;
+ > span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .ivu-badge {
+ flex-shrink: 0;
+ transform: scale(0.8);
+ transform-origin: left center;
+ }
+ &.active {
+ background: #ffffff;
+ color: #17233d;
+ font-weight: 500;
+ }
+ }
}
> ul {
@@ -202,6 +276,25 @@
pointer-events: none;
}
}
+ .readonly-owner-avatar {
+ margin-left: 6px;
+ flex-shrink: 0;
+ }
+ .readonly-project-avatar {
+ width: 18px;
+ height: 18px;
+ margin-top: 2px;
+ margin-left: 6px;
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ font-size: 12px;
+ line-height: 18px;
+ background-color: #5BC7B0;
+ color: #ffffff;
+ }
}
}
}
diff --git a/resources/assets/sass/pages/components/project-panel.scss b/resources/assets/sass/pages/components/project-panel.scss
index ef5e8729a..653ac29e6 100644
--- a/resources/assets/sass/pages/components/project-panel.scss
+++ b/resources/assets/sass/pages/components/project-panel.scss
@@ -1,6 +1,9 @@
.project-panel {
display: flex !important;
flex-direction: column;
+ .project-readonly-alert {
+ margin: 0 32px 8px;
+ }
.project-titbox {
width: 100%;
padding: 32px 32px 4px;
@@ -1050,6 +1053,9 @@
body.window-portrait {
.project-panel {
+ .project-readonly-alert {
+ margin: 0 16px 8px;
+ }
.project-titbox {
position: sticky;
top: 0;
diff --git a/resources/assets/sass/pages/components/task-detail.scss b/resources/assets/sass/pages/components/task-detail.scss
index 65f7bd663..c48b6548c 100644
--- a/resources/assets/sass/pages/components/task-detail.scss
+++ b/resources/assets/sass/pages/components/task-detail.scss
@@ -227,6 +227,9 @@
.scrollbar-content {
padding: 0 5px;
}
+ .task-readonly-alert {
+ margin-top: 18px;
+ }
.receive-box {
display: flex;
justify-content: center;
diff --git a/resources/assets/sass/pages/page-manage.scss b/resources/assets/sass/pages/page-manage.scss
index 4492955cf..e138e5918 100644
--- a/resources/assets/sass/pages/page-manage.scss
+++ b/resources/assets/sass/pages/page-manage.scss
@@ -29,7 +29,7 @@
.menu-base {
position: sticky;
top: 0;
- z-index: 1;
+ z-index: 2;
margin: 0 auto;
width: 80%;
background: #F4F5F7;
@@ -81,6 +81,44 @@
}
}
}
+ .owner-project-tabs {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ margin: 8px 0 0;
+ padding: 3px;
+ border-radius: 12px;
+ background: #eeeeee;
+ .owner-project-tab {
+ flex: 1;
+ min-width: 0;
+ height: 30px;
+ padding: 0 10px;
+ border-radius: 9px;
+ color: #808695;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ cursor: pointer;
+ > span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .ivu-badge {
+ flex-shrink: 0;
+ transform: scale(0.8);
+ transform-origin: left center;
+ }
+ &.active {
+ background: #ffffff;
+ color: #17233d;
+ font-weight: 500;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
+ }
+ }
+ }
}
.menu-project {
flex: 1;
@@ -148,6 +186,24 @@
padding-left: 8px;
font-size: 12px;
}
+ .readonly-owner-avatar {
+ margin-left: 4px;
+ flex-shrink: 0;
+ }
+ .readonly-project-avatar {
+ width: 18px;
+ height: 18px;
+ margin-left: 4px;
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ font-size: 12px;
+ line-height: 18px;
+ background-color: #5BC7B0;
+ color: #ffffff;
+ }
}
.project-h2 {
display: none;
@@ -205,6 +261,12 @@
height: 22px;
}
}
+ &.nothing {
+ padding: 18px 8px;
+ color: #999999;
+ text-align: center;
+ cursor: default;
+ }
}
}
}
@@ -515,7 +577,7 @@
}
}
-@media (height <= 640px) {
+@media (height <= 800px) {
.page-manage {
.manage-box-menu {
.menu-base {