feat(manage): 实现部门负责人视角,支持只读查看部门成员项目与任务

部门负责人/部门管理员可通过系统配置开启,选择管理部门后只读查看
本部门及下级部门成员的全部项目和任务。前端自动根据 department_readonly
标记禁用编辑操作,后端统一注入负责人视角上下文控制数据访问边界。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-05-21 00:14:36 +00:00
parent e0ad8ce6c1
commit 0863e5529a
27 changed files with 1359 additions and 94 deletions

View File

@ -46,6 +46,7 @@ use App\Models\ProjectTaskTemplate;
use App\Models\ProjectTag; use App\Models\ProjectTag;
use App\Models\ProjectTaskRelation; use App\Models\ProjectTaskRelation;
use App\Models\ProjectTaskAiEvent; use App\Models\ProjectTaskAiEvent;
use App\Models\UserDepartment;
use App\Module\AiTaskSuggestion; use App\Module\AiTaskSuggestion;
use App\Observers\ProjectTaskObserver; use App\Observers\ProjectTaskObserver;
@ -128,6 +129,7 @@ class ProjectController extends AbstractController
public function lists() public function lists()
{ {
$user = User::auth(); $user = User::auth();
$departmentView = UserDepartment::ownerViewContext($user);
// //
$all = Request::input('all'); $all = Request::input('all');
$type = Request::input('type', 'all'); $type = Request::input('type', 'all');
@ -141,6 +143,9 @@ class ProjectController extends AbstractController
if ($all) { if ($all) {
$user->identity('admin'); $user->identity('admin');
$builder = Project::allData(); $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 { } else {
$builder = Project::authData(); $builder = Project::authData();
} }
@ -180,8 +185,9 @@ class ProjectController extends AbstractController
->orderBy('project_users.sort') ->orderBy('project_users.sort')
->orderByDesc('projects.id') ->orderByDesc('projects.id')
->paginate(Base::getPaginate(100, 50)); ->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 = $project->toArray();
$array = UserDepartment::appendDepartmentReadonlyProject($array, $departmentView);
if ($getuserid == 'yes') { if ($getuserid == 'yes') {
$array['userid_list'] = ProjectUser::whereProjectId($project->id)->pluck('userid')->toArray(); $array['userid_list'] = ProjectUser::whereProjectId($project->id)->pluck('userid')->toArray();
} }
@ -250,13 +256,15 @@ class ProjectController extends AbstractController
public function one() public function one()
{ {
$user = User::auth(); $user = User::auth();
$departmentView = UserDepartment::ownerViewContext($user, true);
// //
$project_id = intval(Request::input('project_id')); $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), [ $data = array_merge($project->toArray(), $project->getTaskStatistics($user->userid), [
'project_user' => $project->projectUser, 'project_user' => $project->projectUser,
]); ]);
$data = UserDepartment::appendDepartmentReadonlyProject($data, $departmentView);
// //
return Base::retSuccess('success', $data); return Base::retSuccess('success', $data);
} }
@ -999,7 +1007,7 @@ class ProjectController extends AbstractController
// //
$project_id = intval(Request::input('project_id')); $project_id = intval(Request::input('project_id'));
// 项目 // 项目
$project = Project::userProject($project_id); $project = Project::findForDepartmentView($project_id);
// //
$list = ProjectColumn::whereProjectId($project->id) $list = ProjectColumn::whereProjectId($project->id)
->orderBy('sort') ->orderBy('sort')
@ -1230,6 +1238,7 @@ class ProjectController extends AbstractController
{ {
$user = User::auth(); $user = User::auth();
$userid = $user->userid; $userid = $user->userid;
$departmentView = UserDepartment::ownerViewContext($user, true);
// //
$parent_id = intval(Request::input('parent_id')); $parent_id = intval(Request::input('parent_id'));
$project_id = intval(Request::input('project_id')); $project_id = intval(Request::input('project_id'));
@ -1294,7 +1303,7 @@ class ProjectController extends AbstractController
if ($parent_id > 0) { if ($parent_id > 0) {
$isArchived = str_replace(['all', 'yes', 'no'], [null, false, true], $archived); $isArchived = str_replace(['all', 'yes', 'no'], [null, false, true], $archived);
$isDeleted = str_replace(['all', 'yes', 'no'], [null, false, true], $deleted); $isDeleted = str_replace(['all', 'yes', 'no'], [null, false, true], $deleted);
ProjectTask::userTask($parent_id, $isArchived, $isDeleted); ProjectTask::findForDepartmentView($parent_id, $isArchived, $isDeleted);
$scopeAll = true; $scopeAll = true;
$archived = 'all'; $archived = 'all';
$builder->where('project_tasks.parent_id', $parent_id); $builder->where('project_tasks.parent_id', $parent_id);
@ -1302,17 +1311,23 @@ class ProjectController extends AbstractController
$builder->where('project_tasks.parent_id', 0); $builder->where('project_tasks.parent_id', 0);
} }
if ($project_id > 0) { if ($project_id > 0) {
Project::userProject($project_id); if (!UserDepartment::isDepartmentReadonlyProject($departmentView, $project_id)) {
Project::userProject($project_id);
}
$scopeAll = true; $scopeAll = true;
$builder->where('project_tasks.project_id', $project_id); $builder->where('project_tasks.project_id', $project_id);
} }
if (!$scopeAll && $scope === 'all_project') { if (!$scopeAll && $scope === 'all_project') {
$scopeAll = true; $scopeAll = true;
$builder->whereIn('project_tasks.project_id', function ($query) use ($userid) { if ($departmentView['enabled']) {
$query->select('project_id') $builder->whereIn('project_tasks.project_id', array_values(array_unique(array_merge($departmentView['own_project_ids'], $departmentView['project_ids']))));
->from('project_users') } else {
->where('userid', $userid); $builder->whereIn('project_tasks.project_id', function ($query) use ($userid) {
}); $query->select('project_id')
->from('project_users')
->where('userid', $userid);
});
}
} }
if ($scopeAll) { if ($scopeAll) {
$builder->allData(); $builder->allData();
@ -1391,12 +1406,15 @@ class ProjectController extends AbstractController
$query->on('project_sub_task_visibility_users.task_id', '=', 'project_tasks.parent_id'); $query->on('project_sub_task_visibility_users.task_id', '=', 'project_tasks.parent_id');
$query->where('project_sub_task_visibility_users.userid', $userid); $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->where("project_tasks.visibility", 1);
$query->orWhere("project_users.userid", $userid); $query->orWhere("project_users.userid", $userid);
$query->orWhere("project_task_users.userid", $userid); $query->orWhere("project_task_users.userid", $userid);
$query->orWhere("project_task_visibility_users.userid", $userid); $query->orWhere("project_task_visibility_users.userid", $userid);
$query->orWhere("project_sub_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) { $builder->leftJoinSub(function ($query) {
@ -1439,6 +1457,7 @@ class ProjectController extends AbstractController
$data = $list->toArray(); $data = $list->toArray();
// 还原字段 // 还原字段
foreach($data['data'] as &$item){ foreach($data['data'] as &$item){
$item['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($item['project_id']));
$item['file_num'] = $item['_file_num'] ?: 0; $item['file_num'] = $item['_file_num'] ?: 0;
$item['msg_num'] = $item['_msg_num'] ?: 0; $item['msg_num'] = $item['_msg_num'] ?: 0;
$item['sub_num'] = $item['_sub_num'] ?: 0; $item['sub_num'] = $item['_sub_num'] ?: 0;
@ -2018,17 +2037,18 @@ class ProjectController extends AbstractController
public function task__one() public function task__one()
{ {
$user = User::auth(); $user = User::auth();
$departmentView = UserDepartment::ownerViewContext($user, true);
// //
$task_id = intval(Request::input('task_id')); $task_id = intval(Request::input('task_id'));
$archived = Request::input('archived', 'no'); $archived = Request::input('archived', 'no');
// //
$isArchived = str_replace(['all', 'yes', 'no'], [null, false, true], $archived); $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) $projectOwnerids = ProjectUser::whereProjectId($task->project_id)
->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY]) ->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY])
->pluck('userid')->map(fn($v) => (int)$v)->toArray(); // 项目负责人(含项目管理员) ->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(); //任务负责人、协助人 $taskUserids = ProjectTaskUser::whereTaskId($task_id)->pluck('userid')->toArray(); //任务负责人、协助人
$subTaskUserids = ProjectTaskUser::whereTaskPid($task_id)->pluck('userid')->toArray(); //子任务负责人、协助人 $subTaskUserids = ProjectTaskUser::whereTaskPid($task_id)->pluck('userid')->toArray(); //子任务负责人、协助人
$visibleUserids = ProjectTaskVisibilityUser::whereTaskId($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 = $task->toArray();
$data['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id));
$data['project_name'] = $task->project?->name; $data['project_name'] = $task->project?->name;
$data['column_name'] = $task->projectColumn?->name; $data['column_name'] = $task->projectColumn?->name;
$data['visibility_appointor'] = $task->visibility == 1 ? [0] : ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid'); $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]); return Base::retError('参数错误', ['task_id' => $task_id]);
} }
// //
$task = ProjectTask::userTask($task_id); $task = ProjectTask::findForDepartmentView($task_id);
// //
return Base::retSuccess('success', [ return Base::retSuccess('success', [
'id' => $task->id, 'id' => $task->id,
@ -2099,7 +2120,7 @@ class ProjectController extends AbstractController
return Base::retError('参数错误', ['task_id' => $task_id]); return Base::retError('参数错误', ['task_id' => $task_id]);
} }
$task = ProjectTask::userTask($task_id, null); $task = ProjectTask::findForDepartmentView($task_id, null);
$relations = ProjectTaskRelation::whereTaskId($task->id) $relations = ProjectTaskRelation::whereTaskId($task->id)
->orderByDesc('updated_at') ->orderByDesc('updated_at')
@ -2117,7 +2138,7 @@ class ProjectController extends AbstractController
$relatedTasks = []; $relatedTasks = [];
foreach ($relatedTaskIds as $relatedId) { foreach ($relatedTaskIds as $relatedId) {
try { try {
$relatedTask = ProjectTask::userTask($relatedId, null, true, ['project', 'projectColumn']); $relatedTask = ProjectTask::findForDepartmentView($relatedId, null, true, ['project', 'projectColumn']);
$flowItemParts = explode('|', $relatedTask->flow_item_name ?: ''); $flowItemParts = explode('|', $relatedTask->flow_item_name ?: '');
$flowItemStatus = $flowItemParts[0] ?? ''; $flowItemStatus = $flowItemParts[0] ?? '';
@ -2243,7 +2264,7 @@ class ProjectController extends AbstractController
$task_id = intval(Request::input('task_id')); $task_id = intval(Request::input('task_id'));
$history_id = intval(Request::input('history_id')); $history_id = intval(Request::input('history_id'));
// //
$task = ProjectTask::userTask($task_id, null); $task = ProjectTask::findForDepartmentView($task_id, null);
// //
if ($history_id > 0) { if ($history_id > 0) {
$taskContent = ProjectTaskContent::whereTaskId($task->id)->whereId($history_id)->first(); $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_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']) $data = ProjectTaskContent::select(['id', 'task_id', 'desc', 'userid', 'created_at'])
->whereTaskId($task->id) ->whereTaskId($task->id)
@ -2312,7 +2333,7 @@ class ProjectController extends AbstractController
// //
$task_id = intval(Request::input('task_id')); $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); return Base::retSuccess('success', $task->taskFile);
} }
@ -2401,7 +2422,7 @@ class ProjectController extends AbstractController
$data = $file->toArray(); $data = $file->toArray();
$data['path'] = $file->getRawOriginal('path'); $data['path'] = $file->getRawOriginal('path');
// //
ProjectTask::userTask($file->task_id, null); ProjectTask::findForDepartmentView($file->task_id, null);
// //
UserRecentItem::record( UserRecentItem::record(
$user->userid, $user->userid,
@ -2442,7 +2463,7 @@ class ProjectController extends AbstractController
abort_if(empty($file), 403, "This file not exist."); abort_if(empty($file), 403, "This file not exist.");
// //
try { try {
ProjectTask::userTask($file->task_id, null); ProjectTask::findForDepartmentView($file->task_id, null);
} catch (\Throwable $e) { } catch (\Throwable $e) {
abort(403, $e->getMessage() ?: "This file not support download."); 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_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(); $list = ProjectFlow::with(['ProjectFlowItem'])->whereProjectId($project->id)->get();
return Base::retSuccess('success', $list); return Base::retSuccess('success', $list);
@ -3488,10 +3509,10 @@ class ProjectController extends AbstractController
// //
$builder = ProjectLog::select(["*"]); $builder = ProjectLog::select(["*"]);
if ($task_id > 0) { if ($task_id > 0) {
$task = ProjectTask::userTask($task_id, null); $task = ProjectTask::findForDepartmentView($task_id, null);
$builder->whereTaskId($task->id); $builder->whereTaskId($task->id);
} else { } else {
$project = Project::userProject($project_id); $project = Project::findForDepartmentView($project_id);
$builder->with(['projectTask:id,parent_id,name'])->whereProjectId($project->id)->whereTaskOnly(0); $builder->with(['projectTask:id,parent_id,name'])->whereProjectId($project->id)->whereTaskOnly(0);
} }
// //

View File

@ -94,6 +94,7 @@ class SystemController extends AbstractController
'unclaimed_task_reminder', 'unclaimed_task_reminder',
'unclaimed_task_reminder_time', 'unclaimed_task_reminder_time',
'task_ai_auto_analyze', 'task_ai_auto_analyze',
'department_owner_project_view',
])) { ])) {
unset($all[$key]); unset($all[$key]);
} }
@ -148,6 +149,7 @@ class SystemController extends AbstractController
$setting['unclaimed_task_reminder'] = $setting['unclaimed_task_reminder'] ?: 'close'; $setting['unclaimed_task_reminder'] = $setting['unclaimed_task_reminder'] ?: 'close';
$setting['unclaimed_task_reminder_time'] = $setting['unclaimed_task_reminder_time'] ?: ''; $setting['unclaimed_task_reminder_time'] = $setting['unclaimed_task_reminder_time'] ?: '';
$setting['task_ai_auto_analyze'] = $setting['task_ai_auto_analyze'] ?: 'open'; $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_timezone'] = config('app.timezone');
$setting['server_version'] = Base::getVersion(); $setting['server_version'] = Base::getVersion();
// //

View File

@ -404,9 +404,22 @@ class UsersController extends AbstractController
$data['nickname_original'] = $user->getRawOriginal('nickname'); $data['nickname_original'] = $user->getRawOriginal('nickname');
$data['department_name'] = $user->getDepartmentName(); $data['department_name'] = $user->getDepartmentName();
$data['department_owner'] = UserDepartment::where('parent_id',0)->where('owner_userid', $user->userid)->exists(); // 适用默认部门下第1级负责人才能添加部门OKR $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); 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 获取我的部门列表 * @api {get} api/users/info/departments 获取我的部门列表
* *
@ -3272,7 +3285,7 @@ class UsersController extends AbstractController
return Base::retError('参数错误'); return Base::retError('参数错误');
} }
// //
ProjectTask::userTask($task_id, null, null); ProjectTask::findForDepartmentView($task_id, null, null);
// //
UserTaskBrowse::recordBrowse($user->userid, $task_id); UserTaskBrowse::recordBrowse($user->userid, $task_id);
// //

View File

@ -723,4 +723,31 @@ class Project extends AbstractModel
} }
return $project; 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);
}
} }

View File

@ -2258,6 +2258,39 @@ class ProjectTask extends AbstractModel
return $task; 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 * @param int $userid

View File

@ -3,7 +3,9 @@
namespace App\Models; namespace App\Models;
use App\Exceptions\ApiException; use App\Exceptions\ApiException;
use App\Module\Base;
use Cache; use Cache;
use Request;
/** /**
* App\Models\UserDepartment * App\Models\UserDepartment
@ -411,6 +413,93 @@ class UserDepartment extends AbstractModel
return array_unique($subIds); 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小时 * 获取部门基本信息缓存时间1小时
* @param int|array $ids * @param int|array $ids
@ -453,4 +542,70 @@ class UserDepartment extends AbstractModel
return is_array($ids) ? $result : $result->first(); 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;
}
} }

View File

@ -912,6 +912,12 @@ class WebSocketDialog extends AbstractModel
if ($projectId > 0 && ProjectUser::whereProjectId($projectId)->whereUserid($userid)->exists()) { if ($projectId > 0 && ProjectUser::whereProjectId($projectId)->whereUserid($userid)->exists()) {
return $dialog; return $dialog;
} }
if ($projectId > 0 && $checkOwner === false) {
$departmentView = UserDepartment::ownerViewContext(User::auth(), true);
if (UserDepartment::isDepartmentReadonlyProject($departmentView, $projectId)) {
return $dialog;
}
}
break; break;
case 'okr': case 'okr':

View File

@ -73,6 +73,10 @@ export default {
itemWidth: { itemWidth: {
type: Number, type: Number,
default: 100 default: 100
},
readonly: {
type: Boolean,
default: false
} }
}, },
data() { data() {
@ -328,6 +332,12 @@ export default {
this.onDateMove(e.clientX); this.onDateMove(e.clientX);
}, },
itemMouseDown(e, item) { itemMouseDown(e, item) {
if (this.readonly) {
if (e.target.classList.contains('timeline-title')) {
this.clickItem(item);
}
return;
}
e.preventDefault(); e.preventDefault();
this.onItemMove(item, e.target, e.clientX); this.onItemMove(item, e.target, e.clientX);
}, },
@ -347,6 +357,9 @@ export default {
}; };
}, },
onItemMove(item, target, clientX) { onItemMove(item, target, clientX) {
if (this.readonly) {
return;
}
let type = 'moveX'; let type = 'moveX';
if (target.classList.contains('timeline-resizer')) { if (target.classList.contains('timeline-resizer')) {
type = 'moveW'; type = 'moveW';
@ -384,6 +397,11 @@ export default {
} }
}, },
onMoveOver(target) { onMoveOver(target) {
if (this.readonly) {
this.mouseItem = null;
this.dateMove = null;
return;
}
if (this.mouseItem != null) { if (this.mouseItem != null) {
const {start, end} = this.mouseItem.time; const {start, end} = this.mouseItem.time;
let isM = false; let isM = false;

View File

@ -8,7 +8,7 @@
:option-full="optionFull" :option-full="optionFull"
:placeholder="placeholder" :placeholder="placeholder"
:placeholderFull="placeholderFull" :placeholderFull="placeholderFull"
:readOnly="windowTouch" :readOnly="readonly || windowTouch"
:readOnlyFull="false" :readOnlyFull="false"
:readOnlyImagePreview="false" :readOnlyImagePreview="false"
@on-blur="onBlur" @on-blur="onBlur"
@ -24,10 +24,10 @@
transfer> transfer>
<div :style="{userSelect:operateVisible ? 'none' : 'auto', height: operateStyles.height}"></div> <div :style="{userSelect:operateVisible ? 'none' : 'auto', height: operateStyles.height}"></div>
<DropdownMenu slot="list"> <DropdownMenu slot="list">
<DropdownItem v-if="operateMenu.checked" @click.native="onLiPreview">{{ $L(operateMenu.checked === 'checked' ? '标记未选' : '标记已选') }}</DropdownItem> <DropdownItem v-if="operateMenu.checked && !readonly" @click.native="onLiPreview">{{ $L(operateMenu.checked === 'checked' ? '标记未选' : '标记已选') }}</DropdownItem>
<DropdownItem v-if="operateMenu.link" @click.native="onLinkPreview">{{ $L('打开链接') }}</DropdownItem> <DropdownItem v-if="operateMenu.link" @click.native="onLinkPreview">{{ $L('打开链接') }}</DropdownItem>
<DropdownItem v-if="operateMenu.img" @click.native="onImagePreview">{{ $L('查看图片') }}</DropdownItem> <DropdownItem v-if="operateMenu.img" @click.native="onImagePreview">{{ $L('查看图片') }}</DropdownItem>
<DropdownItem @click.native="onEditing">{{ $L('编辑描述') }}</DropdownItem> <DropdownItem v-if="!readonly" @click.native="onEditing">{{ $L('编辑描述') }}</DropdownItem>
<DropdownItem v-if="operateMenu.history" @click.native="onHistory">{{ $L('历史记录') }}</DropdownItem> <DropdownItem v-if="operateMenu.history" @click.native="onHistory">{{ $L('历史记录') }}</DropdownItem>
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
@ -99,6 +99,10 @@ export default {
placeholderFull: { placeholderFull: {
default: '' default: ''
}, },
readonly: {
type: Boolean,
default: false
},
}, },
data() { data() {
@ -202,6 +206,9 @@ export default {
}, },
onEditing() { onEditing() {
if (this.readonly) {
return;
}
this.$refs.desc.onFull() this.$refs.desc.onFull()
}, },
@ -210,6 +217,9 @@ export default {
}, },
onBlur() { onBlur() {
if (this.readonly) {
return;
}
this.$emit('on-blur'); this.$emit('on-blur');
}, },
@ -319,6 +329,9 @@ export default {
}, },
onLiPreview() { onLiPreview() {
if (this.readonly) {
return;
}
if (!this.operateMenu.checked) { if (!this.operateMenu.checked) {
return; return;
} }

View File

@ -82,6 +82,24 @@
<DropdownItem name="exportCheckin">{{$L('导出签到数据')}}</DropdownItem> <DropdownItem name="exportCheckin">{{$L('导出签到数据')}}</DropdownItem>
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
<!-- 部门负责人视角 -->
<DropdownItem
v-else-if="item.path === 'departmentOwnerView'"
:key="`menu-${index}`"
:divided="!!item.divided"
:name="item.path"
:style="item.style || {}">
<div class="manage-menu-flex">
<div class="manage-menu-title">
{{$L(item.name)}}
</div>
<Badge
v-if="item.selectedCount > 0"
class="manage-menu-report-badge"
:overflow-count="999"
:count="item.selectedCount"/>
</div>
</DropdownItem>
<!-- 其他菜单 --> <!-- 其他菜单 -->
<DropdownItem <DropdownItem
v-else-if="item.visible !== false" v-else-if="item.visible !== false"
@ -146,12 +164,23 @@
<div class="menu-title">{{item.label}}</div> <div class="menu-title">{{item.label}}</div>
</li> </li>
</ul> </ul>
<div v-if="ownerProjectTabsVisible" class="owner-project-tabs">
<div
v-for="item in ownerProjectTabs"
:key="item.type"
:class="['owner-project-tab', ownerProjectTab === item.type ? 'active' : '']"
:title="$L(item.name)"
@click="ownerProjectTab = item.type">
<span>{{$L(item.name)}}</span>
<Badge :overflow-count="999" :count="item.count"/>
</div>
</div>
</div> </div>
<div ref="menuProject" class="menu-project"> <div ref="menuProject" class="menu-project">
<Draggable <Draggable
:list="projectDraggableList" :list="projectDraggableList"
:animation="150" :animation="150"
:disabled="$isEEUIApp || windowTouch || !!projectKeyValue" :disabled="$isEEUIApp || windowTouch || !!projectKeyValue || ownerProjectTabsVisible"
tag="ul" tag="ul"
item-key="id" item-key="id"
draggable="li:not(.pinned)" draggable="li:not(.pinned)"
@ -170,6 +199,12 @@
<div class="project-h1"> <div class="project-h1">
<em @click.stop="toggleOpenMenu(item.id)"></em> <em @click.stop="toggleOpenMenu(item.id)"></em>
<div class="title" v-html="transformEmojiToHtml(item.name)"></div> <div class="title" v-html="transformEmojiToHtml(item.name)"></div>
<ETooltip v-if="item.department_readonly && item.personal" :content="$L('个人项目,只读查看')" placement="right">
<UserAvatar class="readonly-owner-avatar" :userid="item.userid" :size="18"/>
</ETooltip>
<ETooltip v-else-if="item.department_readonly" :content="$L('负责人视角,只读查看')" placement="right">
<i class="taskfont readonly-project-avatar">&#xe75c;</i>
</ETooltip>
<div v-if="item.top_at" class="icon-top"></div> <div v-if="item.top_at" class="icon-top"></div>
<div v-if="item.task_my_num - item.task_my_complete > 0" class="num">{{item.task_my_num - item.task_my_complete}}</div> <div v-if="item.task_my_num - item.task_my_complete > 0" class="num">{{item.task_my_num - item.task_my_complete}}</div>
</div> </div>
@ -186,7 +221,10 @@
</p> </p>
</div> </div>
</li> </li>
<li v-if="projectKeyLoading > 0" class="loading"><Loading/></li> <li v-if="projectKeyLoading > 0 || departmentOwnerProjectsRefreshing" class="loading"><Loading/></li>
<li v-else-if="projectLists.length === 0" class="nothing">
{{$L(projectKeyValue ? `没有任何与"${projectKeyValue}"相关的结果` : `没有任何项目`)}}
</li>
</Draggable> </Draggable>
</div> </div>
</Scrollbar> </Scrollbar>
@ -330,6 +368,9 @@
<!--弹出 MCP 服务器信息--> <!--弹出 MCP 服务器信息-->
<MCPHelper v-model="mcpHelperShow"/> <MCPHelper v-model="mcpHelperShow"/>
<!--负责人视角-->
<DepartmentOwnerView v-model="departmentOwnerViewShow"/>
<!--导出任务统计--> <!--导出任务统计-->
<TaskExport v-model="exportTaskShow"/> <TaskExport v-model="exportTaskShow"/>
@ -456,6 +497,7 @@ import transformEmojiToHtml from "../utils/emoji";
import {languageName} from "../language"; import {languageName} from "../language";
import {AINormalizeJsonContent, PROJECT_AI_SYSTEM_PROMPT, withLanguagePreferencePrompt} from "../utils/ai"; import {AINormalizeJsonContent, PROJECT_AI_SYSTEM_PROMPT, withLanguagePreferencePrompt} from "../utils/ai";
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import DepartmentOwnerView from "./manage/components/DepartmentOwnerView.vue";
export default { export default {
components: { components: {
@ -481,7 +523,8 @@ export default {
ProjectArchived, ProjectArchived,
MicroApps, MicroApps,
ComplaintManagement, ComplaintManagement,
Draggable Draggable,
DepartmentOwnerView
}, },
directives: {longpress, TransferDom}, directives: {longpress, TransferDom},
data() { data() {
@ -519,6 +562,7 @@ export default {
projectDraggableList: [], projectDraggableList: [],
projectDragging: false, projectDragging: false,
ownerProjectTab: 'mine',
openMenu: {}, openMenu: {},
visibleMenu: false, visibleMenu: false,
@ -550,6 +594,7 @@ export default {
taskBrowseHistory: [], taskBrowseHistory: [],
mcpHelperShow: false, mcpHelperShow: false,
departmentOwnerViewShow: false,
} }
}, },
@ -613,8 +658,10 @@ export default {
'dialogIns', 'dialogIns',
'formOptions', 'formOptions',
'systemConfig',
'mobileTabbar', 'mobileTabbar',
'longpressData', 'longpressData',
'departmentOwnerProjectsRefreshing',
'mcpServerStatus', 'mcpServerStatus',
'microAppsIds' 'microAppsIds'
@ -626,6 +673,14 @@ export default {
return this.microAppsIds?.includes('ai'); 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 * page className
* @param mobileTabbar * @param mobileTabbar
@ -763,6 +818,16 @@ export default {
{path: 'archivedProject', name: '已归档的项目'}, {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(...[ array.push(...[
{path: 'clearCache', name: '清除缓存', divided: true}, {path: 'clearCache', name: '清除缓存', divided: true},
{path: 'logout', name: '退出登录', style: {color: '#f40'}} {path: 'logout', name: '退出登录', style: {color: '#f40'}}
@ -787,7 +852,7 @@ export default {
* 项目列表 * 项目列表
* @returns {Array} * @returns {Array}
*/ */
projectLists() { projectBaseLists() {
const {projectKeyValue, cacheProjects} = this; const {projectKeyValue, cacheProjects} = this;
const data = $A.cloneJSON(cacheProjects).sort((a, b) => { const data = $A.cloneJSON(cacheProjects).sort((a, b) => {
// //
@ -807,6 +872,36 @@ export default {
return data; 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} * @returns {Array}
@ -884,6 +979,31 @@ export default {
immediate: true 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: { projectLists: {
handler(val) { handler(val) {
if (!this.projectDragging) { if (!this.projectDragging) {
@ -915,6 +1035,12 @@ export default {
methods: { methods: {
transformEmojiToHtml, transformEmojiToHtml,
syncOwnerProjectTabByRoute() {
if (!this.ownerProjectTabsVisible || !this.routeProject) {
return;
}
this.ownerProjectTab = this.routeProject.department_readonly ? 'readonly' : 'mine';
},
chackPass() { chackPass() {
if (this.userInfo.changepass === 1) { if (this.userInfo.changepass === 1) {
this.goForward({name: 'manage-setting-password'}); this.goForward({name: 'manage-setting-password'});
@ -936,6 +1062,9 @@ export default {
settingRoute(path) { settingRoute(path) {
switch (path) { switch (path) {
case 'departmentOwnerView':
this.departmentOwnerViewShow = true;
return;
case 'allUser': case 'allUser':
this.allUserShow = true; this.allUserShow = true;
return; return;

View File

@ -0,0 +1,144 @@
<template>
<Modal
:value="value"
:title="$L('负责人视角')"
:mask-closable="false"
width="520"
@input="$emit('input', $event)">
<div class="department-owner-view-modal">
<Alert type="info" show-icon>
{{$L('可查看所选部门及所有下级部门成员参与的项目和任务,仅支持只读查看。')}}
</Alert>
<div v-if="managedDepartments.length > 1" class="department-owner-view-actions">
<a href="javascript:void(0)" @click="draftIds=[]">{{$L('清空')}}</a>
<a href="javascript:void(0)" @click="draftIds=managedDepartments.map(item => item.id)">{{$L('全选')}}</a>
<a href="javascript:void(0)" @click="reverseDraft">{{$L('反选')}}</a>
</div>
<CheckboxGroup v-model="draftIds" class="department-owner-view-list">
<div
v-for="dept in managedDepartments"
:key="dept.id"
:class="['department-owner-view-item', draftIds.includes(dept.id) ? 'active' : '']"
@click="toggleDraft(dept.id)">
<div class="department-owner-view-icon">
<i class="taskfont">&#xe75c;</i>
</div>
<div class="department-owner-view-name">{{dept.name}}</div>
<Checkbox class="department-owner-view-checkbox" :label="dept.id" @click.native.stop><span></span></Checkbox>
</div>
</CheckboxGroup>
</div>
<div slot="footer" class="adaption">
<Button type="default" :disabled="applyLoading" @click="$emit('input', false)">{{$L('取消')}}</Button>
<Button type="primary" :loading="applyLoading" @click="apply">{{$L('确定')}}</Button>
</div>
</Modal>
</template>
<script>
import {mapState} from "vuex";
export default {
name: "DepartmentOwnerView",
props: {
value: Boolean,
},
data() {
return {
draftIds: [],
applyLoading: false,
}
},
computed: {
...mapState(['userInfo', 'cacheDepartmentOwnerIds']),
managedDepartments() {
return (this.userInfo.managed_departments || []).map(item => ({
...item,
id: parseInt(item.id)
}));
},
},
watch: {
value: {
immediate: true,
handler(show) {
if (show) {
this.draftIds = (this.cacheDepartmentOwnerIds || []).map(id => parseInt(id));
} else {
this.applyLoading = false;
}
}
}
},
methods: {
toggleDraft(id) {
id = parseInt(id);
const index = this.draftIds.indexOf(id);
if (index > -1) {
this.draftIds.splice(index, 1);
} else {
this.draftIds.push(id);
}
},
reverseDraft() {
const selected = this.draftIds.map(id => parseInt(id));
this.draftIds = this.managedDepartments
.map(item => item.id)
.filter(id => !selected.includes(id));
},
async apply() {
if (this.applyLoading) {
return;
}
this.applyLoading = true;
try {
await this.$store.dispatch("setDepartmentOwnerIds", this.draftIds);
this.$emit('input', false);
} catch (e) {
$A.modalError(e?.msg || this.$L('切换失败'));
} finally {
this.applyLoading = false;
}
},
}
}
</script>
<style lang="scss" scoped>
.department-owner-view-modal {
.department-owner-view-actions {
display: flex;
justify-content: flex-end;
gap: 14px;
margin: 12px 8px 0;
}
.department-owner-view-list {
display: flex;
flex-direction: column;
margin-top: 10px;
}
.department-owner-view-item {
display: flex;
align-items: center;
padding: 10px 12px;
cursor: pointer;
}
.department-owner-view-icon {
width: 28px;
height: 28px;
border-radius: 50%;
background-color: #5BC7B0;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
}
.department-owner-view-name {
flex: 1;
}
.department-owner-view-checkbox {
margin-right: 0;
}
}
</style>

View File

@ -5,7 +5,8 @@
:menuWidth="menuWidth" :menuWidth="menuWidth"
:itemWidth="80" :itemWidth="80"
@on-change="onChange" @on-change="onChange"
@on-click="onClick"> @on-click="onClick"
:readonly="readonly">
<template #titleTool> <template #titleTool>
<Dropdown class="project-gstc-dropdown-filtr" trigger="click" @on-click="onSwitchColumn"> <Dropdown class="project-gstc-dropdown-filtr" trigger="click" @on-click="onSwitchColumn">
<Icon class="project-gstc-dropdown-icon" :class="{filtr:filtrProjectId > 0}" type="md-funnel" /> <Icon class="project-gstc-dropdown-icon" :class="{filtr:filtrProjectId > 0}" type="md-funnel" />
@ -55,6 +56,10 @@ export default {
flowInfo: { flowInfo: {
default: {} default: {}
}, },
readonly: {
type: Boolean,
default: false
},
}, },
data() { data() {
@ -215,6 +220,9 @@ export default {
}, },
onChange(item) { onChange(item) {
if (this.readonly) {
return;
}
const {time, baktime} = item; const {time, baktime} = item;
if (Math.abs(baktime.end - time.end) > 1000 || Math.abs(baktime.start - time.start) > 1000) { if (Math.abs(baktime.end - time.end) > 1000 || Math.abs(baktime.start - time.start) > 1000) {
//1) //1)
@ -238,6 +246,10 @@ export default {
}, },
editSubmit(save) { editSubmit(save) {
if (this.readonly) {
this.editData = [];
return;
}
this.editData && this.editData.forEach(item => { this.editData && this.editData.forEach(item => {
let task = this.lists.find(({id}) => id == item.id) let task = this.lists.find(({id}) => id == item.id)
if (save) { if (save) {

View File

@ -11,11 +11,31 @@
<Input type="search" v-model="projectKeyValue" :placeholder="$L(loadProjects > 0 ? '更新中...' : '搜索')" clearable/> <Input type="search" v-model="projectKeyValue" :placeholder="$L(loadProjects > 0 ? '更新中...' : '搜索')" clearable/>
</Form> </Form>
</div> </div>
<div
v-if="ownerViewAvailable"
class="owner-view-button"
@click="departmentOwnerViewShow=true">
<i class="taskfont">&#xe75c;</i>
<em v-if="ownerDepartmentIds.length > 0">{{ownerDepartmentIds.length}}</em>
</div>
</div>
<div class="owner-project-wrapper">
<div v-if="ownerProjectTabsVisible" class="owner-project-tabs">
<div
v-for="item in ownerProjectTabs"
:key="item.type"
:class="['owner-project-tab', ownerProjectTab === item.type ? 'active' : '']"
:title="$L(item.name)"
@click="ownerProjectTab = item.type">
<span>{{$L(item.name)}}</span>
<Badge :overflow-count="999" :count="item.count"/>
</div>
</div>
</div> </div>
<Draggable <Draggable
:list="projectDraggableList" :list="projectDraggableList"
:animation="150" :animation="150"
:disabled="!(isDragging && !projectKeyValue)" :disabled="!(isDragging && !projectKeyValue) || ownerProjectTabsVisible"
tag="ul" tag="ul"
item-key="id" item-key="id"
draggable="li:not(.pinned)" draggable="li:not(.pinned)"
@ -36,6 +56,12 @@
<div class="item-left"> <div class="item-left">
<div class="project-h1"> <div class="project-h1">
<div class="project-name" v-html="transformEmojiToHtml(item.name)"></div> <div class="project-name" v-html="transformEmojiToHtml(item.name)"></div>
<ETooltip v-if="item.department_readonly && item.personal" :content="$L('个人项目,只读查看')" placement="right">
<UserAvatar class="readonly-owner-avatar" :userid="item.userid" :size="18"/>
</ETooltip>
<ETooltip v-else-if="item.department_readonly" :content="$L('负责人视角,只读查看')" placement="right">
<i class="taskfont readonly-project-avatar">&#xe75c;</i>
</ETooltip>
<div v-if="item.top_at" class="icon-top"></div> <div v-if="item.top_at" class="icon-top"></div>
<div v-if="item.task_my_num - item.task_my_complete > 0" class="num">{{item.task_my_num - item.task_my_complete}}</div> <div v-if="item.task_my_num - item.task_my_complete > 0" class="num">{{item.task_my_num - item.task_my_complete}}</div>
</div> </div>
@ -62,7 +88,7 @@
</div> </div>
</li> </li>
<template v-if="projectLists.length === 0"> <template v-if="projectLists.length === 0">
<li v-if="projectKeyLoading > 0" class="loading"><Loading/></li> <li v-if="projectKeyLoading > 0 || departmentOwnerProjectsRefreshing" class="loading"><Loading/></li>
<li v-else class="nothing"> <li v-else class="nothing">
{{$L(projectKeyValue ? `没有任何与"${projectKeyValue}"相关的结果` : `没有任何项目`)}} {{$L(projectKeyValue ? `没有任何与"${projectKeyValue}"相关的结果` : `没有任何项目`)}}
</li> </li>
@ -88,12 +114,14 @@
<DropdownItem @click.native="handleChatClick"> <DropdownItem @click.native="handleChatClick">
{{ $L('项目讨论') }} {{ $L('项目讨论') }}
</DropdownItem> </DropdownItem>
<DropdownItem v-if="!projectKeyValue && !operateItem.top_at" @click.native="isDragging=!isDragging"> <DropdownItem v-if="!projectKeyValue && !operateItem.top_at && !ownerProjectTabsVisible" @click.native="isDragging=!isDragging">
{{ $L(isDragging ? '退出排序' : '调整排序') }} {{ $L(isDragging ? '退出排序' : '调整排序') }}
</DropdownItem> </DropdownItem>
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
</div> </div>
<DepartmentOwnerView v-model="departmentOwnerViewShow"/>
</div> </div>
</template> </template>
@ -103,10 +131,11 @@ import Draggable from 'vuedraggable'
import longpress from "../../../directives/longpress"; import longpress from "../../../directives/longpress";
import TransferDom from "../../../directives/transfer-dom"; import TransferDom from "../../../directives/transfer-dom";
import transformEmojiToHtml from "../../../utils/emoji"; import transformEmojiToHtml from "../../../utils/emoji";
import DepartmentOwnerView from "./DepartmentOwnerView.vue";
export default { export default {
name: "ProjectList", name: "ProjectList",
components: {Draggable}, components: {Draggable, DepartmentOwnerView},
directives: {longpress, TransferDom}, directives: {longpress, TransferDom},
data() { data() {
return { return {
@ -120,13 +149,30 @@ export default {
isDragging: false, isDragging: false,
projectDraggableList: [], projectDraggableList: [],
projectDragging: false, projectDragging: false,
ownerProjectTab: 'mine',
departmentOwnerViewShow: false,
} }
}, },
computed: { 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 {projectKeyValue, cacheProjects} = this;
const data = $A.cloneJSON(cacheProjects).sort((a, b) => { const data = $A.cloneJSON(cacheProjects).sort((a, b) => {
// //
@ -145,9 +191,44 @@ export default {
} }
return data; 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: { 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: { projectLists: {
handler(val) { handler(val) {
if (!this.projectDragging) { if (!this.projectDragging) {

View File

@ -10,7 +10,7 @@
<div v-if="loading" class="project-load"><Loading/></div> <div v-if="loading" class="project-load"><Loading/></div>
</div> </div>
<ul class="project-icons"> <ul class="project-icons">
<li class="project-avatar" :class="{'cursor-default': !isOwnerOrDeputy}" @click="projectDropdown('user')"> <li class="project-avatar" :class="{'cursor-default': !isOwnerOrDeputy || isDepartmentReadonly}" @click="projectDropdown('user')">
<ul> <ul>
<li> <li>
<UserAvatarTip :userid="projectData.owner_userid" :size="36" :borderWidth="2" :openDelay="0"> <UserAvatarTip :userid="projectData.owner_userid" :size="36" :borderWidth="2" :openDelay="0">
@ -32,7 +32,7 @@
</template> </template>
</ul> </ul>
</li> </li>
<li class="project-icon" @click="addTaskOpen(0)"> <li v-if="!projectData.department_readonly" class="project-icon" @click="addTaskOpen(0)">
<ETooltip :disabled="$isEEUIApp || windowTouch" :content="$L('添加任务')"> <ETooltip :disabled="$isEEUIApp || windowTouch" :content="$L('添加任务')">
<Icon class="menu-icon" type="md-add" /> <Icon class="menu-icon" type="md-add" />
</ETooltip> </ETooltip>
@ -52,7 +52,13 @@
<li class="project-icon"> <li class="project-icon">
<EDropdown @command="projectDropdown" trigger="click" transfer> <EDropdown @command="projectDropdown" trigger="click" transfer>
<Icon class="menu-icon" type="ios-more" /> <Icon class="menu-icon" type="ios-more" />
<EDropdownMenu v-if="isOwnerOrDeputy" slot="dropdown" class="project-panel-project-menu-dropdown"> <EDropdownMenu v-if="isDepartmentReadonly" slot="dropdown" class="project-panel-project-menu-dropdown">
<EDropdownItem command="favorite">{{$L(projectData.favorited ? '取消收藏' : '收藏项目')}}</EDropdownItem>
<EDropdownItem command="log" divided>{{$L('项目动态')}}</EDropdownItem>
<EDropdownItem command="archived_task">{{$L('已归档任务')}}</EDropdownItem>
<EDropdownItem command="deleted_task">{{$L('已删除任务')}}</EDropdownItem>
</EDropdownMenu>
<EDropdownMenu v-else-if="isOwnerOrDeputy" slot="dropdown" class="project-panel-project-menu-dropdown">
<EDropdownItem command="setting">{{$L('项目设置')}}</EDropdownItem> <EDropdownItem command="setting">{{$L('项目设置')}}</EDropdownItem>
<EDropdownItem command="permissions">{{$L('权限设置')}}</EDropdownItem> <EDropdownItem command="permissions">{{$L('权限设置')}}</EDropdownItem>
<EDropdownItem command="task_template">{{$L('任务模板')}}</EDropdownItem> <EDropdownItem command="task_template">{{$L('任务模板')}}</EDropdownItem>
@ -83,6 +89,9 @@
</li> </li>
</ul> </ul>
</div> </div>
<Alert v-if="projectData.department_readonly" class="project-readonly-alert" type="info" show-icon>
{{$L('当前为负责人视角:你可查看项目和任务,并参与讨论,但不能编辑项目或任务。')}}
</Alert>
<div class="project-subbox"> <div class="project-subbox">
<div class="project-subtitle user-select-auto" @click="showDesc"> <div class="project-subtitle user-select-auto" @click="showDesc">
<VMPreviewNostyle ref="descPreview" :value="projectData.desc"/> <VMPreviewNostyle ref="descPreview" :value="projectData.desc"/>
@ -112,7 +121,7 @@
<Draggable <Draggable
:list="columnList" :list="columnList"
:animation="150" :animation="150"
:disabled="sortDisabled || $isEEUIApp || windowTouch" :disabled="sortDisabled || isDepartmentReadonly || $isEEUIApp || windowTouch"
class="column-list" class="column-list"
tag="ul" tag="ul"
draggable=".column-item" draggable=".column-item"
@ -133,7 +142,7 @@
<div class="column-head-icon"> <div class="column-head-icon">
<div v-if="columnLoad[column.id] === true" class="loading"><Loading /></div> <div v-if="columnLoad[column.id] === true" class="loading"><Loading /></div>
<EDropdown <EDropdown
v-else v-else-if="!isDepartmentReadonly"
trigger="click" trigger="click"
size="medium" size="medium"
@command="dropColumn(column, $event)"> @command="dropColumn(column, $event)">
@ -165,14 +174,14 @@
</li> </li>
</EDropdownMenu> </EDropdownMenu>
</EDropdown> </EDropdown>
<Icon class="last" type="md-add" @click="addTopShow(column.id, true)" /> <Icon v-if="!isDepartmentReadonly" class="last" type="md-add" @click="addTopShow(column.id, true)" />
</div> </div>
</div> </div>
<Scrollbar <Scrollbar
class="column-task" class="column-task"
class-name="task-scrollbar" class-name="task-scrollbar"
@on-scroll="handleTaskScroll"> @on-scroll="handleTaskScroll">
<div v-if="!!columnTopShow[column.id]" class="task-item additem"> <div v-if="!isDepartmentReadonly && !!columnTopShow[column.id]" class="task-item additem">
<TaskAddSimple <TaskAddSimple
:column-id="column.id" :column-id="column.id"
:project-id="projectId" :project-id="projectId"
@ -184,7 +193,7 @@
<Draggable <Draggable
:list="column.tasks" :list="column.tasks"
:animation="150" :animation="150"
:disabled="sortDisabled || $isEEUIApp || windowTouch" :disabled="sortDisabled || isDepartmentReadonly || $isEEUIApp || windowTouch"
class="task-list" class="task-list"
draggable=".task-draggable" draggable=".task-draggable"
filter=".complete" filter=".complete"
@ -207,7 +216,7 @@
<pre>{{item.name}}</pre> <pre>{{item.name}}</pre>
</div> </div>
<div class="task-menu" @click.stop=""> <div class="task-menu" @click.stop="">
<TaskMenu :ref="`taskMenu_${item.id}`" :task="item" icon="ios-more"/> <TaskMenu v-if="!isDepartmentReadonly" :ref="`taskMenu_${item.id}`" :task="item" icon="ios-more"/>
</div> </div>
</div> </div>
<template v-if="!item.complete_at"> <template v-if="!item.complete_at">
@ -241,7 +250,7 @@
</template> </template>
</template> </template>
</div> </div>
<div class="task-item additem"> <div v-if="!isDepartmentReadonly" class="task-item additem">
<TaskAddSimple <TaskAddSimple
:column-id="column.id" :column-id="column.id"
:project-id="projectId" :project-id="projectId"
@ -250,7 +259,7 @@
</Draggable> </Draggable>
</Scrollbar> </Scrollbar>
</li> </li>
<li :class="['add-column', addColumnShow ? 'show-input' : '']"> <li v-if="!isDepartmentReadonly" :class="['add-column', addColumnShow ? 'show-input' : '']">
<div class="add-column-text" @click="addColumnOpen"> <div class="add-column-text" @click="addColumnOpen">
<Icon type="md-add" />{{$L('添加列表')}} <Icon type="md-add" />{{$L('添加列表')}}
</div> </div>
@ -321,6 +330,7 @@
v-if="projectData.cacheParameter.showMy" v-if="projectData.cacheParameter.showMy"
:list="transforTasks(myList)" :list="transforTasks(myList)"
:task-visibilitys="taskRowVisibilitys" :task-visibilitys="taskRowVisibilitys"
:readonly="isDepartmentReadonly"
open-key="my" open-key="my"
@on-priority="addTaskOpen" @on-priority="addTaskOpen"
fast-add-task/> fast-add-task/>
@ -342,6 +352,7 @@
v-if="projectData.cacheParameter.showHelp" v-if="projectData.cacheParameter.showHelp"
:list="helpList" :list="helpList"
:task-visibilitys="taskRowVisibilitys" :task-visibilitys="taskRowVisibilitys"
:readonly="isDepartmentReadonly"
open-key="help" open-key="help"
@on-priority="addTaskOpen"/> @on-priority="addTaskOpen"/>
</div> </div>
@ -362,6 +373,7 @@
v-if="projectData.cacheParameter.showUndone" v-if="projectData.cacheParameter.showUndone"
:list="unList" :list="unList"
:task-visibilitys="taskRowVisibilitys" :task-visibilitys="taskRowVisibilitys"
:readonly="isDepartmentReadonly"
open-key="undone" open-key="undone"
@on-priority="addTaskOpen"/> @on-priority="addTaskOpen"/>
</div> </div>
@ -384,6 +396,7 @@
v-if="projectData.cacheParameter.showCompleted" v-if="projectData.cacheParameter.showCompleted"
:list="completedList" :list="completedList"
:task-visibilitys="taskRowVisibilitys" :task-visibilitys="taskRowVisibilitys"
:readonly="isDepartmentReadonly"
open-key="completed" open-key="completed"
@on-priority="addTaskOpen" @on-priority="addTaskOpen"
showCompleteAt/> showCompleteAt/>
@ -391,7 +404,7 @@
</Scrollbar> </Scrollbar>
<div v-else-if="tabTypeActive === 'gantt'" class="project-gantt"> <div v-else-if="tabTypeActive === 'gantt'" class="project-gantt">
<!--甘特图--> <!--甘特图-->
<ProjectGantt :projectColumn="columnList" :flowInfo="flowInfo"/> <ProjectGantt :projectColumn="columnList" :flowInfo="flowInfo" :readonly="isDepartmentReadonly"/>
</div> </div>
<!--项目设置--> <!--项目设置-->
@ -824,7 +837,12 @@ export default {
return this.projectData?.owner_userid === this.userId; return this.projectData?.owner_userid === this.userId;
}, },
isDepartmentReadonly() {
return !!this.projectData?.department_readonly;
},
isOwnerOrDeputy() { isOwnerOrDeputy() {
if (this.isDepartmentReadonly) return false;
if (!this.projectData) return false; if (!this.projectData) return false;
if (this.projectData.owner_userid === this.userId) return true; if (this.projectData.owner_userid === this.userId) return true;
return (this.projectData.deputy_userids || []).includes(this.userId); return (this.projectData.deputy_userids || []).includes(this.userId);
@ -1249,6 +1267,9 @@ export default {
}, },
sortUpdate(only_column) { sortUpdate(only_column) {
if (this.isDepartmentReadonly) {
return;
}
const oldSort = this.sortData; const oldSort = this.sortData;
const newSort = this.getSort(); const newSort = this.getSort();
if (JSON.stringify(oldSort) === JSON.stringify(newSort)) { if (JSON.stringify(oldSort) === JSON.stringify(newSort)) {
@ -1315,14 +1336,23 @@ export default {
}, },
addTopShow(id, show) { addTopShow(id, show) {
if (this.isDepartmentReadonly) {
return;
}
this.$set(this.columnTopShow, id, show); this.$set(this.columnTopShow, id, show);
}, },
addTaskOpen(params) { addTaskOpen(params) {
if (this.isDepartmentReadonly) {
return;
}
emitter.emit('addTask', params); emitter.emit('addTask', params);
}, },
addColumnOpen() { addColumnOpen() {
if (this.isDepartmentReadonly) {
return;
}
this.addColumnShow = true; this.addColumnShow = true;
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.addColumnName.focus(); this.$refs.addColumnName.focus();
@ -1360,6 +1390,9 @@ export default {
}, },
dropColumn(column, command) { dropColumn(column, command) {
if (this.isDepartmentReadonly) {
return;
}
if (command === 'title') { if (command === 'title') {
this.titleColumn(column); this.titleColumn(column);
} }
@ -1617,6 +1650,9 @@ export default {
}, },
projectDropdown(name) { projectDropdown(name) {
if (this.isDepartmentReadonly && !['favorite', 'log', 'archived_task', 'deleted_task'].includes(name)) {
return;
}
switch (name) { switch (name) {
case "favorite": case "favorite":
this.toggleProjectFavorite(); this.toggleProjectFavorite();
@ -1643,7 +1679,7 @@ export default {
break; break;
case "user": case "user":
if (!this.isOwnerOrDeputy) { if (this.isDepartmentReadonly || !this.isOwnerOrDeputy) {
return; return;
} }
const userids = this.projectData.project_user.map(({userid}) => userid); const userids = this.projectData.project_user.map(({userid}) => userid);
@ -1714,6 +1750,9 @@ export default {
openTask(task, receive) { openTask(task, receive) {
this.$store.dispatch("openTask", task) this.$store.dispatch("openTask", task)
if (this.isDepartmentReadonly) {
return;
}
if (receive === true) { if (receive === true) {
// //
setTimeout(() => { setTimeout(() => {
@ -1777,7 +1816,7 @@ export default {
this.$store.dispatch("call", { this.$store.dispatch("call", {
url: 'project/flow/list', url: 'project/flow/list',
data: { data: {
project_id: this.projectId, project_id: this.projectId
}, },
}).then(({data}) => { }).then(({data}) => {
this.flowList = data; this.flowList = data;

View File

@ -365,7 +365,7 @@ export default {
this.$store.dispatch("call", { this.$store.dispatch("call", {
url: 'project/flow/list', url: 'project/flow/list',
data: { data: {
project_id: this.projectId, project_id: this.projectId
}, },
}).then(({data}) => { }).then(({data}) => {
this.list = data.map(item => { this.list = data.map(item => {

View File

@ -138,7 +138,7 @@ export default {
data: { data: {
task_id: this.taskId, task_id: this.taskId,
page: Math.max(this.page, 1), page: Math.max(this.page, 1),
pagesize: Math.max($A.runNum(this.pageSize), 10), pagesize: Math.max($A.runNum(this.pageSize), 10)
}, },
}).then(({data}) => { }).then(({data}) => {
this.page = data.current_page; this.page = data.current_page;

View File

@ -1,7 +1,7 @@
<template> <template>
<!--子任务--> <!--子任务-->
<li v-if="ready && isSubTask"> <li v-if="ready && isSubTask">
<div class="subtask-icon"> <div v-if="!isDepartmentReadonly" class="subtask-icon">
<TaskMenu <TaskMenu
:ref="`taskMenu_${taskDetail.id}`" :ref="`taskMenu_${taskDetail.id}`"
:disabled="taskId === 0" :disabled="taskId === 0"
@ -13,7 +13,7 @@
v-if="taskDetail.flow_item_name" v-if="taskDetail.flow_item_name"
class="subtask-flow" class="subtask-flow"
:style="$A.generateColorVarStyle(taskDetail.flow_item_color, [10], 'flow-item-custom-color')"> :style="$A.generateColorVarStyle(taskDetail.flow_item_color, [10], 'flow-item-custom-color')">
<span :class="taskDetail.flow_item_status" @click.stop="openMenu($event, taskDetail)">{{taskDetail.flow_item_name}}</span> <span :class="taskDetail.flow_item_status" @click.stop="!isDepartmentReadonly && openMenu($event, taskDetail)">{{taskDetail.flow_item_name}}</span>
</div> </div>
<div class="subtask-name"> <div class="subtask-name">
<Input <Input
@ -24,11 +24,13 @@
:autosize="{ minRows: 1, maxRows: 8 }" :autosize="{ minRows: 1, maxRows: 8 }"
:maxlength="255" :maxlength="255"
enterkeyhint="done" enterkeyhint="done"
:readonly="isDepartmentReadonly"
@on-blur="updateBlur('name')" @on-blur="updateBlur('name')"
@on-keydown="onNameKeydown" @on-keydown="onNameKeydown"
/> />
</div> </div>
<DatePicker <DatePicker
v-if="!isDepartmentReadonly"
v-model="timeValue" v-model="timeValue"
:open="timeOpen" :open="timeOpen"
:options="timeOptions" :options="timeOptions"
@ -46,7 +48,11 @@
</div> </div>
<Icon v-else class="clock" type="ios-clock-outline" @click="openTime" /> <Icon v-else class="clock" type="ios-clock-outline" @click="openTime" />
</DatePicker> </DatePicker>
<div v-else-if="showSubTime" :class="['subtask-time readonly-time', taskDetail.today ? 'today' : '', taskDetail.overdue ? 'overdue' : '']">
{{expiresFormat(taskDetail.end_at)}}
</div>
<UserSelect <UserSelect
v-if="!isDepartmentReadonly"
class="subtask-avatar" class="subtask-avatar"
v-model="ownerData.owner_userid" v-model="ownerData.owner_userid"
:multiple-max="10" :multiple-max="10"
@ -55,6 +61,11 @@
:add-icon="false" :add-icon="false"
:project-id="taskDetail.project_id" :project-id="taskDetail.project_id"
:before-submit="onOwner"/> :before-submit="onOwner"/>
<UserAvatar
v-else-if="ownerData.owner_userid && ownerData.owner_userid.length > 0"
class="subtask-avatar readonly-avatar"
:userid="ownerData.owner_userid[0]"
:size="20"/>
</li> </li>
<!--主任务--> <!--主任务-->
<div <div
@ -65,6 +76,7 @@
<div v-show="taskDetail.id > 0" class="task-info" v-resize-observer="scrollIntoInput"> <div v-show="taskDetail.id > 0" class="task-info" v-resize-observer="scrollIntoInput">
<div class="head"> <div class="head">
<TaskMenu <TaskMenu
v-if="!isDepartmentReadonly"
:ref="`taskMenu_${taskDetail.id}`" :ref="`taskMenu_${taskDetail.id}`"
:disabled="taskId === 0" :disabled="taskId === 0"
:task="taskDetail" :task="taskDetail"
@ -76,10 +88,10 @@
v-if="taskDetail.flow_item_name" v-if="taskDetail.flow_item_name"
class="flow" class="flow"
:style="$A.generateColorVarStyle(taskDetail.flow_item_color, [10], 'flow-item-custom-color')"> :style="$A.generateColorVarStyle(taskDetail.flow_item_color, [10], 'flow-item-custom-color')">
<span :class="taskDetail.flow_item_status" @click.stop="openMenu($event, taskDetail)">{{taskDetail.flow_item_name}}</span> <span :class="taskDetail.flow_item_status" @click.stop="!isDepartmentReadonly && openMenu($event, taskDetail)">{{taskDetail.flow_item_name}}</span>
</div> </div>
<div v-if="taskDetail.archived_at" class="flow"> <div v-if="taskDetail.archived_at" class="flow">
<span class="archived" @click.stop="openMenu($event, taskDetail)">{{$L('已归档')}}</span> <span class="archived" @click.stop="!isDepartmentReadonly && openMenu($event, taskDetail)">{{$L('已归档')}}</span>
</div> </div>
<div class="nav user-select-auto"> <div class="nav user-select-auto">
<p v-if="projectName"><span>{{projectName}}</span></p> <p v-if="projectName"><span>{{projectName}}</span></p>
@ -90,7 +102,7 @@
<ETooltip v-if="$Electron" :disabled="$isEEUIApp || windowTouch" :content="$L('独立窗口显示')"> <ETooltip v-if="$Electron" :disabled="$isEEUIApp || windowTouch" :content="$L('独立窗口显示')">
<i class="taskfont open" @click="openNewWin">&#xe776;</i> <i class="taskfont open" @click="openNewWin">&#xe776;</i>
</ETooltip> </ETooltip>
<div class="menu"> <div v-if="!isDepartmentReadonly" class="menu">
<TaskMenu <TaskMenu
:disabled="taskId === 0" :disabled="taskId === 0"
:task="taskDetail" :task="taskDetail"
@ -104,7 +116,10 @@
</div> </div>
</div> </div>
<Scrollbar ref="scroller" class="scroller" :touch-content-blur="false"> <Scrollbar ref="scroller" class="scroller" :touch-content-blur="false">
<Alert v-if="taskDetail.task_user !== undefined && getOwner.length === 0" class="receive-box" type="warning"> <Alert v-if="taskDetail.department_readonly" class="task-readonly-alert" type="info" show-icon>
{{$L('当前为负责人视角:你可查看任务内容、动态和附件,并参与讨论,但不能编辑任务。')}}
</Alert>
<Alert v-if="!isDepartmentReadonly && taskDetail.task_user !== undefined && getOwner.length === 0" class="receive-box" type="warning">
<span class="receive-text">{{$L('该任务尚未被领取,点击这里')}}</span> <span class="receive-text">{{$L('该任务尚未被领取,点击这里')}}</span>
<EPopover <EPopover
v-model="receiveShow" v-model="receiveShow"
@ -143,6 +158,7 @@
:autosize="{ minRows: 1, maxRows: 8 }" :autosize="{ minRows: 1, maxRows: 8 }"
:maxlength="255" :maxlength="255"
enterkeyhint="done" enterkeyhint="done"
:readonly="isDepartmentReadonly"
@on-blur="updateBlur('name')" @on-blur="updateBlur('name')"
@on-keydown="onNameKeydown"/> @on-keydown="onNameKeydown"/>
</div> </div>
@ -151,6 +167,7 @@
class="desc" class="desc"
:value="taskContent" :value="taskContent"
:placeholder="$L('详细描述...')" :placeholder="$L('详细描述...')"
:readonly="isDepartmentReadonly"
@on-history="onHistory" @on-history="onHistory"
@on-blur="updateBlur('content', $event)"/> @on-blur="updateBlur('content', $event)"/>
<Form class="items" label-position="left" label-width="auto" @submit.native.prevent> <Form class="items" label-position="left" label-width="auto" @submit.native.prevent>
@ -159,7 +176,8 @@
<i class="taskfont">&#xe61e;</i>{{$L('标签')}} <i class="taskfont">&#xe61e;</i>{{$L('标签')}}
</div> </div>
<div class="item-content tags"> <div class="item-content tags">
<EPopover v-model="tagShow" class="tags-select" placement="bottom"> <TaskTag v-if="isDepartmentReadonly" :tags="getTag"/>
<EPopover v-else v-model="tagShow" class="tags-select" placement="bottom">
<TaskTagSelect <TaskTagSelect
ref="tagSelect" ref="tagSelect"
v-model="tagValue" v-model="tagValue"
@ -181,7 +199,7 @@
</div> </div>
<ul class="item-content priority"> <ul class="item-content priority">
<li> <li>
<TaskPriority :backgroundColor="taskDetail.p_color"><span ref="priorityText" @click="onPriority">{{taskDetail.p_name}}</span></TaskPriority> <TaskPriority :backgroundColor="taskDetail.p_color"><span ref="priorityText" @click="!isDepartmentReadonly && onPriority($event)">{{taskDetail.p_name}}</span></TaskPriority>
</li> </li>
</ul> </ul>
</FormItem> </FormItem>
@ -189,7 +207,11 @@
<div class="item-label" slot="label"> <div class="item-label" slot="label">
<i class="taskfont">&#xe6e4;</i>{{$L('负责人')}} <i class="taskfont">&#xe6e4;</i>{{$L('负责人')}}
</div> </div>
<div v-if="isDepartmentReadonly" class="item-content user readonly-users">
<UserAvatar v-for="item in getOwner" :key="item.userid" :userid="item.userid" :size="28" showName/>
</div>
<UserSelect <UserSelect
v-else
class="item-content user" class="item-content user"
v-model="ownerData.owner_userid" v-model="ownerData.owner_userid"
:multiple-max="10" :multiple-max="10"
@ -203,7 +225,11 @@
<div class="item-label" slot="label"> <div class="item-label" slot="label">
<i class="taskfont">&#xe63f;</i>{{$L('协助人员')}} <i class="taskfont">&#xe63f;</i>{{$L('协助人员')}}
</div> </div>
<div v-if="isDepartmentReadonly" class="item-content user readonly-users">
<UserAvatar v-for="item in getAssist" :key="item.userid" :userid="item.userid" :size="28" showName/>
</div>
<UserSelect <UserSelect
v-else
ref="assist" ref="assist"
class="item-content user" class="item-content user"
v-model="assistData.assist_userid" v-model="assistData.assist_userid"
@ -218,11 +244,11 @@
<FormItem v-if="taskDetail.visibility > 1 || visibleForce || visibleKeep"> <FormItem v-if="taskDetail.visibility > 1 || visibleForce || visibleKeep">
<div class="item-label" slot="label"> <div class="item-label" slot="label">
<i class="taskfont">&#xe77b;</i> <i class="taskfont">&#xe77b;</i>
<span class="visibility-text color" @click="showCisibleDropdown">{{$L('可见性')}} <i class="taskfont">&#xe740;</i></span> <span class="visibility-text color" @click="!isDepartmentReadonly && showCisibleDropdown($event)">{{$L('可见性')}} <i class="taskfont">&#xe740;</i></span>
</div> </div>
<div class="item-content user"> <div class="item-content user">
<span v-if="taskDetail.visibility == 1 || taskDetail.visibility == 2" ref="visibilityText" class="visibility-text" @click="showCisibleDropdown">{{ taskDetail.visibility == 1 ? $L('项目人员可见') : $L('任务人员可见') }}</span> <span v-if="taskDetail.visibility == 1 || taskDetail.visibility == 2" ref="visibilityText" class="visibility-text" @click="!isDepartmentReadonly && showCisibleDropdown($event)">{{ taskDetail.visibility == 1 ? $L('项目人员可见') : $L('任务人员可见') }}</span>
<UserSelect v-else <UserSelect v-else-if="!isDepartmentReadonly"
ref="visibleUserSelectRef" ref="visibleUserSelectRef"
v-model="taskDetail.visibility_appointor" v-model="taskDetail.visibility_appointor"
:avatar-size="28" :avatar-size="28"
@ -230,13 +256,16 @@
:project-id="taskDetail.project_id" :project-id="taskDetail.project_id"
:add-icon="false" :add-icon="false"
@on-show-change="visibleUserSelectShowChange"/> @on-show-change="visibleUserSelectShowChange"/>
<div v-else class="readonly-users">
<UserAvatar v-for="userid in taskDetail.visibility_appointor" :key="userid" :userid="userid" :size="28" showName/>
</div>
</div> </div>
</FormItem> </FormItem>
<FormItem v-if="taskDetail.end_at || timeForce"> <FormItem v-if="taskDetail.end_at || timeForce">
<div class="item-label" slot="label"> <div class="item-label" slot="label">
<i class="taskfont">&#xe6e8;</i> <i class="taskfont">&#xe6e8;</i>
<span v-if="!taskDetail.end_at" @click="timeOpen = true" class="visibility-text color">{{$L('截止时间')}}</span> <span v-if="!taskDetail.end_at" @click="!isDepartmentReadonly && (timeOpen = true)" class="visibility-text color">{{$L('截止时间')}}</span>
<span v-else class="visibility-text color" @click="showAtDropdown">{{$L('截止时间')}}</span> <span v-else class="visibility-text color" @click="!isDepartmentReadonly && showAtDropdown($event)">{{$L('截止时间')}}</span>
</div> </div>
<ul class="item-content"> <ul class="item-content">
<li> <li>
@ -253,13 +282,13 @@
@on-ok="timeOk" @on-ok="timeOk"
transfer> transfer>
<div class="picker-time"> <div class="picker-time">
<div v-if="!taskDetail.end_at" @click="timeOpen = true" class="time">{{taskDetail.end_at ? cutTime : '--'}}</div> <div v-if="!taskDetail.end_at" @click="!isDepartmentReadonly && (timeOpen = true)" class="time">{{taskDetail.end_at ? cutTime : '--'}}</div>
<div v-else @click="showAtDropdown" class="time">{{taskDetail.end_at ? cutTime : '--'}}</div> <div v-else @click="!isDepartmentReadonly && showAtDropdown($event)" class="time">{{taskDetail.end_at ? cutTime : '--'}}</div>
<template v-if="!taskDetail.complete_at && taskDetail.end_at"> <template v-if="!taskDetail.complete_at && taskDetail.end_at">
<Tag v-if="within24Hours(taskDetail.end_at)" :color="tagColor(taskDetail)" @on-click="showAtDropdown"> <Tag v-if="within24Hours(taskDetail.end_at)" :color="tagColor(taskDetail)" @on-click="!isDepartmentReadonly && showAtDropdown($event)">
<i class="taskfont">&#xe71d;</i>{{expiresFormat(taskDetail.end_at)}} <i class="taskfont">&#xe71d;</i>{{expiresFormat(taskDetail.end_at)}}
</Tag> </Tag>
<Tag v-if="taskDetail.overdue" color="red" @on-click="showAtDropdown">{{$L('超期未完成')}}</Tag> <Tag v-if="taskDetail.overdue" color="red" @on-click="!isDepartmentReadonly && showAtDropdown($event)">{{$L('超期未完成')}}</Tag>
</template> </template>
</div> </div>
</DatePicker> </DatePicker>
@ -273,7 +302,7 @@
<ul class="item-content loop"> <ul class="item-content loop">
<li> <li>
<ETooltip :disabled="$isEEUIApp || windowTouch || !taskDetail.loop_at" :content="`${$L('下个周期')}: ${taskDetail.loop_at}`" placement="right"> <ETooltip :disabled="$isEEUIApp || windowTouch || !taskDetail.loop_at" :content="`${$L('下个周期')}: ${taskDetail.loop_at}`" placement="right">
<span ref="loopText" @click="onLoop">{{$L(loopLabel(taskDetail.loop))}}</span> <span ref="loopText" @click="!isDepartmentReadonly && onLoop($event)">{{$L(loopLabel(taskDetail.loop))}}</span>
</ETooltip> </ETooltip>
</li> </li>
</ul> </ul>
@ -291,7 +320,7 @@
<div class="file-size">{{$A.bytesToSize(file.size)}}</div> <div class="file-size">{{$A.bytesToSize(file.size)}}</div>
</li> </li>
</ul> </ul>
<ul class="item-content file-up"> <ul v-if="!isDepartmentReadonly" class="item-content file-up">
<li> <li>
<div class="add-button" @click="onUploadClick(true)"> <div class="add-button" @click="onUploadClick(true)">
<i class="taskfont">&#xe6f2;</i> <i class="taskfont">&#xe6f2;</i>
@ -314,7 +343,7 @@
:main-end-at="taskDetail.end_at" :main-end-at="taskDetail.end_at"
:can-update-blur="canUpdateBlur"/> :can-update-blur="canUpdateBlur"/>
</ul> </ul>
<ul class="item-content subtask-add"> <ul v-if="!isDepartmentReadonly" class="item-content subtask-add">
<li> <li>
<Input <Input
v-if="addsubShow" v-if="addsubShow"
@ -377,6 +406,7 @@
{{$L('已归档')}} {{$L('已归档')}}
</span> </span>
<Icon <Icon
v-if="!isDepartmentReadonly"
type="md-close" type="md-close"
class="related-remove" class="related-remove"
@click.native.stop="removeRelatedTask(item)"/> @click.native.stop="removeRelatedTask(item)"/>
@ -384,7 +414,7 @@
</ul> </ul>
</FormItem> </FormItem>
</Form> </Form>
<div v-if="menuList.length > 0" class="add"> <div v-if="!isDepartmentReadonly && menuList.length > 0" class="add">
<div class="add-wrap"> <div class="add-wrap">
<div class="add-button" @click="onAddItem"> <div class="add-button" @click="onAddItem">
<i class="taskfont">&#xe6f2;</i> <i class="taskfont">&#xe6f2;</i>
@ -484,6 +514,7 @@
<div class="drag-text">{{$L('拖动到这里发送')}}</div> <div class="drag-text">{{$L('拖动到这里发送')}}</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div v-if="!taskDetail.id" class="task-load"><Loading/></div> <div v-if="!taskDetail.id" class="task-load"><Loading/></div>
@ -884,6 +915,9 @@ export default {
}, },
menuList() { menuList() {
if (this.isDepartmentReadonly) {
return [];
}
const {taskDetail} = this; const {taskDetail} = this;
const list = []; const list = [];
if ($A.arrayLength(taskDetail.task_tag) === 0) { if ($A.arrayLength(taskDetail.task_tag) === 0) {
@ -963,6 +997,10 @@ export default {
return this.systemConfig.task_visible === 'open' // return this.systemConfig.task_visible === 'open' //
}, },
isDepartmentReadonly() {
return !!this.taskDetail?.department_readonly;
},
isSubTask({taskDetail}) { isSubTask({taskDetail}) {
return taskDetail.parent_id > 0 return taskDetail.parent_id > 0
}, },
@ -1137,6 +1175,9 @@ export default {
}, },
onNameKeydown(e) { onNameKeydown(e) {
if (this.isDepartmentReadonly) {
return;
}
if (e.keyCode === 13) { if (e.keyCode === 13) {
if (!e.shiftKey) { if (!e.shiftKey) {
e.preventDefault(); e.preventDefault();
@ -1146,6 +1187,9 @@ export default {
}, },
checkUpdate(action) { checkUpdate(action) {
if (this.isDepartmentReadonly) {
return false;
}
let isModify = false; let isModify = false;
if (this.openTask.name != this.taskDetail.name) { if (this.openTask.name != this.taskDetail.name) {
isModify = true; isModify = true;
@ -1187,12 +1231,20 @@ export default {
}, },
updateBlur(action, params) { updateBlur(action, params) {
if (this.isDepartmentReadonly) {
return;
}
if (this.canUpdateBlur) { if (this.canUpdateBlur) {
this.updateData(action, params) this.updateData(action, params)
} }
}, },
updateData(action, params) { updateData(action, params) {
if (this.isDepartmentReadonly) {
return;
}
let successCallback = null; let successCallback = null;
switch (action) { switch (action) {
case 'priority': case 'priority':
@ -1396,6 +1448,10 @@ export default {
}, },
async onOwner(pick) { async onOwner(pick) {
if (this.isDepartmentReadonly) {
return;
}
let data = { let data = {
task_id: this.taskDetail.id, task_id: this.taskDetail.id,
owner: this.ownerData.owner_userid owner: this.ownerData.owner_userid
@ -1440,6 +1496,10 @@ export default {
}, },
onAssist() { onAssist() {
if (this.isDepartmentReadonly) {
return;
}
if ($A.jsonStringify(this.taskDetail.assist_userid) === $A.jsonStringify(this.assistData.assist_userid)) { if ($A.jsonStringify(this.taskDetail.assist_userid) === $A.jsonStringify(this.assistData.assist_userid)) {
return; return;
} }
@ -1484,6 +1544,10 @@ export default {
}, },
openTime() { openTime() {
if (this.isDepartmentReadonly) {
return;
}
this.timeOpen = !this.timeOpen; this.timeOpen = !this.timeOpen;
if (this.timeOpen) { if (this.timeOpen) {
this.timeValue = this.taskDetail.end_at ? [this.taskDetail.start_at, this.taskDetail.end_at] : []; this.timeValue = this.taskDetail.end_at ? [this.taskDetail.start_at, this.taskDetail.end_at] : [];
@ -1497,6 +1561,10 @@ export default {
}, },
timeClear() { timeClear() {
if (this.isDepartmentReadonly) {
return;
}
this.updateData('times', { this.updateData('times', {
start_at: false, start_at: false,
end_at: false, end_at: false,
@ -1505,6 +1573,10 @@ export default {
}, },
timeOk() { timeOk() {
if (this.isDepartmentReadonly) {
return;
}
const times = $A.newDateString(this.timeValue, "YYYY-MM-DD HH:mm"); const times = $A.newDateString(this.timeValue, "YYYY-MM-DD HH:mm");
this.updateData('times', { this.updateData('times', {
start_at: times[0], start_at: times[0],
@ -1514,6 +1586,10 @@ export default {
}, },
addsubOpen() { addsubOpen() {
if (this.isDepartmentReadonly) {
return;
}
this.addsubShow = true; this.addsubShow = true;
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.addsub.focus() this.$refs.addsub.focus()
@ -1537,6 +1613,10 @@ export default {
}, },
onAddsub() { onAddsub() {
if (this.isDepartmentReadonly) {
return;
}
if (this.addsubName == '') { if (this.addsubName == '') {
$A.messageError('任务描述不能为空'); $A.messageError('任务描述不能为空');
return; return;
@ -1602,6 +1682,10 @@ export default {
}, },
removeRelatedTask(item) { removeRelatedTask(item) {
if (this.isDepartmentReadonly) {
return;
}
if (!item || !item.related_task_id) { if (!item || !item.related_task_id) {
return; return;
} }
@ -1636,6 +1720,10 @@ export default {
}, },
onPriority(event) { onPriority(event) {
if (this.isDepartmentReadonly) {
return;
}
const list = this.taskPriority.map(item => { const list = this.taskPriority.map(item => {
return { return {
label: item.name, label: item.name,
@ -1655,6 +1743,10 @@ export default {
}, },
onLoop(event) { onLoop(event) {
if (this.isDepartmentReadonly) {
return;
}
const list = this.loops.map(item => { const list = this.loops.map(item => {
return { return {
label: item.label, label: item.label,
@ -1684,6 +1776,10 @@ export default {
}, },
onAddItem(event) { onAddItem(event) {
if (this.isDepartmentReadonly) {
return;
}
const list = this.menuList.map(item => { const list = this.menuList.map(item => {
return { return {
label: item.name, label: item.name,
@ -1702,6 +1798,10 @@ export default {
}, },
dropAddItem(command) { dropAddItem(command) {
if (this.isDepartmentReadonly) {
return;
}
switch (command) { switch (command) {
case 'tag': case 'tag':
this.tagForce = true; this.tagForce = true;
@ -1765,6 +1865,7 @@ export default {
}, },
onEventMore(e) { onEventMore(e) {
if (['image', 'file'].includes(e)) { if (['image', 'file'].includes(e)) {
this.onUploadClick(false) this.onUploadClick(false)
} }
@ -1776,6 +1877,7 @@ export default {
}, },
msgDialog(sendType = null) { msgDialog(sendType = null) {
if (this.sendLoad > 0 || this.openLoad > 0) { if (this.sendLoad > 0 || this.openLoad > 0) {
return; return;
} }
@ -1895,6 +1997,10 @@ export default {
}, },
deleteFile(file) { deleteFile(file) {
if (this.isDepartmentReadonly) {
return;
}
this.$set(file, '_show_menu', false); this.$set(file, '_show_menu', false);
this.$store.dispatch("forgetTaskFile", file.id) this.$store.dispatch("forgetTaskFile", file.id)
// //
@ -1910,6 +2016,9 @@ export default {
}, },
openMenu(event, task) { openMenu(event, task) {
if (this.isDepartmentReadonly) {
return;
}
const el = this.$refs[`taskMenu_${task.id}`]; const el = this.$refs[`taskMenu_${task.id}`];
el && el.handleClick(event) el && el.handleClick(event)
}, },
@ -2010,12 +2119,18 @@ export default {
okText: this.$L('立即下载'), okText: this.$L('立即下载'),
content: `${file.name} (${$A.bytesToSize(file.size)})`, content: `${file.name} (${$A.bytesToSize(file.size)})`,
onOk: () => { onOk: () => {
this.$store.dispatch('downUrl', $A.apiUrl(`project/task/filedown?file_id=${file.id}`)) const departmentOwnerIds = (this.$store.state.cacheDepartmentOwnerIds || []).join(',')
const url = $A.urlAddParams(`project/task/filedown?file_id=${file.id}`, departmentOwnerIds ? {department_owner_ids: departmentOwnerIds} : {})
this.$store.dispatch('downUrl', $A.apiUrl(url))
} }
}); });
}, },
showCisibleDropdown(event){ showCisibleDropdown(event){
if (this.isDepartmentReadonly) {
return;
}
const list = [ const list = [
{label: '项目人员', value: 1}, {label: '项目人员', value: 1},
{label: '任务人员', value: 2}, {label: '任务人员', value: 2},
@ -2033,6 +2148,10 @@ export default {
}, },
showAtDropdown(event){ showAtDropdown(event){
if (this.isDepartmentReadonly) {
return;
}
this.timeOpen = false this.timeOpen = false
const list = [ const list = [
{label: '任务延期', value: 1}, {label: '任务延期', value: 1},
@ -2060,6 +2179,10 @@ export default {
}, },
dropVisible(command) { dropVisible(command) {
if (this.isDepartmentReadonly) {
return;
}
switch (command) { switch (command) {
case 1: case 1:
case 2: case 2:
@ -2077,6 +2200,10 @@ export default {
}, },
dropDeadline(command) { dropDeadline(command) {
if (this.isDepartmentReadonly) {
return;
}
switch (command) { switch (command) {
case 1: case 1:
this.delayTaskQuicks = [ this.delayTaskQuicks = [
@ -2108,6 +2235,10 @@ export default {
}, },
onDelay(){ onDelay(){
if (this.isDepartmentReadonly) {
return;
}
this.$refs.formDelayTaskRef.validate((valid) => { this.$refs.formDelayTaskRef.validate((valid) => {
if (!valid) { if (!valid) {
return return
@ -2137,8 +2268,10 @@ export default {
const list = [ const list = [
{label: '查看附件', value: 1}, {label: '查看附件', value: 1},
{label: '下载附件', value: 2}, {label: '下载附件', value: 2},
{label: '删除附件', value: 3, style: {color:'#FF7070'}},
]; ];
if (!this.isDepartmentReadonly) {
list.push({label: '删除附件', value: 3, style: {color:'#FF7070'}});
}
this.$store.commit('menu/operation', { this.$store.commit('menu/operation', {
event, event,
list, list,
@ -2179,6 +2312,10 @@ export default {
}, },
onTagAdd(tagName) { onTagAdd(tagName) {
if (this.isDepartmentReadonly) {
return;
}
// //
this.tagValue = this.getTag; this.tagValue = this.getTag;
this.tagBakValue = $A.cloneJSON(this.tagValue); this.tagBakValue = $A.cloneJSON(this.tagValue);
@ -2188,6 +2325,10 @@ export default {
}, },
onTagAddSave(result) { onTagAddSave(result) {
if (this.isDepartmentReadonly) {
return;
}
const current = this.tagValue; const current = this.tagValue;
const addData = result.filter(({data}) => data && data.id > 0).map(({data}) => data); const addData = result.filter(({data}) => data && data.id > 0).map(({data}) => data);
// 使 // 使

View File

@ -17,10 +17,10 @@
:class="['sub-icon', taskOpen[item.id] ? 'active' : '']" :class="['sub-icon', taskOpen[item.id] ? 'active' : '']"
type="ios-arrow-forward" type="ios-arrow-forward"
@click="getSublist(item)"/> @click="getSublist(item)"/>
<TaskMenu :ref="`taskMenu_${item.id}`" :task="item"/> <TaskMenu v-if="!readonly" :ref="`taskMenu_${item.id}`" :task="item"/>
<div class="item-title" @click="openTask(item)"> <div class="item-title" @click="openTask(item)">
<!--工作流状态--> <!--工作流状态-->
<span v-if="item.flow_item_name" :class="item.flow_item_status" @click.stop="openMenu($event, item)">{{item.flow_item_name}}</span> <span v-if="item.flow_item_name" :class="item.flow_item_status" @click.stop="!readonly && openMenu($event, item)">{{item.flow_item_name}}</span>
<!--是否子任务--> <!--是否子任务-->
<span v-if="item.sub_top === true">{{$L('子任务')}}</span> <span v-if="item.sub_top === true">{{$L('子任务')}}</span>
<!--有多少个子任务--> <!--有多少个子任务-->
@ -51,7 +51,7 @@
trigger="click" trigger="click"
size="small" size="small"
placement="bottom" placement="bottom"
:disabled="item.sub_top === true" :disabled="readonly || item.sub_top === true"
@command="dropTask(item, $event)"> @command="dropTask(item, $event)">
<div class="task-column">{{columnName(item.column_id)}}</div> <div class="task-column">{{columnName(item.column_id)}}</div>
<EDropdownMenu slot="dropdown"> <EDropdownMenu slot="dropdown">
@ -66,7 +66,7 @@
trigger="click" trigger="click"
size="small" size="small"
placement="bottom" placement="bottom"
:disabled="item.sub_top === true" :disabled="readonly || item.sub_top === true"
@command="dropTask(item, $event)"> @command="dropTask(item, $event)">
<TaskPriority :backgroundColor="item.p_color">{{item.p_name || $L('未设置')}}</TaskPriority> <TaskPriority :backgroundColor="item.p_color">{{item.p_name || $L('未设置')}}</TaskPriority>
<EDropdownMenu slot="dropdown"> <EDropdownMenu slot="dropdown">
@ -85,7 +85,7 @@
<li v-for="(user, keyu) in ownerUser(item.task_user)" :key="keyu" v-if="keyu < 3"> <li v-for="(user, keyu) in ownerUser(item.task_user)" :key="keyu" v-if="keyu < 3">
<UserAvatar :userid="user.userid" size="32" :borderWidth="2" :borderColor="item.color" :showName="ownerUser(item.task_user).length === 1"/> <UserAvatar :userid="user.userid" size="32" :borderWidth="2" :borderColor="item.color" :showName="ownerUser(item.task_user).length === 1"/>
</li> </li>
<li v-if="ownerUser(item.task_user).length === 0" class="no-owner"> <li v-if="!readonly && ownerUser(item.task_user).length === 0" class="no-owner">
<Button type="primary" size="small" @click.stop="openTask(item, true)">{{$L('领取任务')}}</Button> <Button type="primary" size="small" @click.stop="openTask(item, true)">{{$L('领取任务')}}</Button>
</li> </li>
</ul> </ul>
@ -107,11 +107,12 @@
v-if="taskOpen[item.id]===true" v-if="taskOpen[item.id]===true"
:list="subTask(item.id)" :list="subTask(item.id)"
:parent-id="item.id" :parent-id="item.id"
:fast-add-task="item.parent_id===0 && fastAddTask" :fast-add-task="!readonly && item.parent_id===0 && fastAddTask"
:open-key="openKey" :open-key="openKey"
@command="dropTask"/> @command="dropTask"
:readonly="readonly"/>
</div> </div>
<TaskAddSimple v-if="fastAddTask || parentId > 0" :parent-id="parentId" row-mode @on-priority="onPriority"/> <TaskAddSimple v-if="!readonly && (fastAddTask || parentId > 0)" :parent-id="parentId" row-mode @on-priority="onPriority"/>
</div> </div>
</template> </template>
@ -151,6 +152,10 @@ export default {
taskVisibilitys: { taskVisibilitys: {
type: Object, type: Object,
default: () => ({}) default: () => ({})
},
readonly: {
type: Boolean,
default: false
} }
}, },
data() { data() {
@ -202,6 +207,9 @@ export default {
}, },
dropTask(task, command) { dropTask(task, command) {
if (this.readonly) {
return;
}
const el = this.$refs[`taskMenu_${task.id}`]; const el = this.$refs[`taskMenu_${task.id}`];
if (!el) { if (!el) {
return; return;
@ -228,6 +236,9 @@ export default {
}, },
onPriority(data) { onPriority(data) {
if (this.readonly) {
return;
}
this.$emit("on-priority", data) this.$emit("on-priority", data)
}, },
@ -260,6 +271,9 @@ export default {
openTask(task, receive) { openTask(task, receive) {
this.$store.dispatch("openTask", task) this.$store.dispatch("openTask", task)
if (this.readonly) {
return;
}
if (receive === true) { if (receive === true) {
// //
setTimeout(() => { setTimeout(() => {
@ -269,6 +283,9 @@ export default {
}, },
openMenu(event, task) { openMenu(event, task) {
if (this.readonly) {
return;
}
const el = this.$refs[`taskMenu_${task.id}`]; const el = this.$refs[`taskMenu_${task.id}`];
if (el) { if (el) {
el[0].handleClick(event) el[0].handleClick(event)

View File

@ -75,6 +75,13 @@
</RadioGroup> </RadioGroup>
<div v-if="formDatum.project_invite == 'open'" class="form-tip">{{$L('开启项目管理员可生成链接邀请成员加入项目')}}</div> <div v-if="formDatum.project_invite == 'open'" class="form-tip">{{$L('开启项目管理员可生成链接邀请成员加入项目')}}</div>
</FormItem> </FormItem>
<FormItem :label="$L('部门负责人视角')" prop="department_owner_project_view">
<RadioGroup v-model="formDatum.department_owner_project_view">
<Radio label="open">{{$L('开启')}}</Radio>
<Radio label="close">{{$L('关闭')}}</Radio>
</RadioGroup>
<div v-if="formDatum.department_owner_project_view == 'open'" class="form-tip">{{$L('开启后部门负责人/部门管理员可只读查看本部门及下级部门成员参与的项目和项目内全部任务')}}</div>
</FormItem>
</div> </div>
</div> </div>
<div class="block-setting-box"> <div class="block-setting-box">
@ -351,6 +358,7 @@ export default {
}).then(({data}) => { }).then(({data}) => {
if (save) { if (save) {
$A.messageSuccess('修改成功'); $A.messageSuccess('修改成功');
this.$store.dispatch("getUserInfo").catch(() => {});
} }
this.formDatum = data; this.formDatum = data;
this.formDatum_bak = $A.cloneJSON(this.formDatum); this.formDatum_bak = $A.cloneJSON(this.formDatum);

View File

@ -232,6 +232,31 @@ export default {
], true)) { ], true)) {
params.encrypt = 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) { if (params.encrypt) {
const userAgent = window.navigator.userAgent; const userAgent = window.navigator.userAgent;
if (window.systemInfo.debug === "yes" if (window.systemInfo.debug === "yes"
@ -620,7 +645,7 @@ export default {
* @param dispatch * @param dispatch
* @param timeout * @param timeout
*/ */
getBasicData({state, dispatch}, timeout) { async getBasicData({state, dispatch}, timeout) {
if (typeof timeout === "number") { if (typeof timeout === "number") {
window.__getBasicDataTimer && clearTimeout(window.__getBasicDataTimer) window.__getBasicDataTimer && clearTimeout(window.__getBasicDataTimer)
if (timeout > -1) { if (timeout > -1) {
@ -640,7 +665,7 @@ export default {
dispatch("getTaskPriority", 1000); dispatch("getTaskPriority", 1000);
dispatch("getReportUnread", 1000); dispatch("getReportUnread", 1000);
dispatch("getApproveUnread", 1000); dispatch("getApproveUnread", 1000);
dispatch("getProjectByQueue"); dispatch("getProjectsForDepartmentOwnerView").catch(() => {});
dispatch("getTaskForDashboard"); dispatch("getTaskForDashboard");
dispatch("dialogMsgRead"); dispatch("dialogMsgRead");
dispatch("updateMicroAppsStatus"); dispatch("updateMicroAppsStatus");
@ -1181,6 +1206,7 @@ export default {
array: [ array: [
'cacheUserBasic', 'cacheUserBasic',
'cacheProjects', 'cacheProjects',
'cacheDepartmentOwnerIds',
'cacheColumns', 'cacheColumns',
'cacheTasks', 'cacheTasks',
'cacheProjectParameter', 'cacheProjectParameter',
@ -1551,6 +1577,146 @@ export default {
}); });
}, },
/**
* 获取有效的部门负责人视角部门ID
* @param state
* @param ids
* @returns {Array<number>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<unknown>} * @returns {Promise<unknown>}
*/ */
getProjects({state, dispatch}, requestData) { 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) { return new Promise(function (resolve, reject) {
if (state.userId === 0) { if (state.userId === 0) {
state.cacheProjects = []; state.cacheProjects = [];
@ -1657,6 +1841,9 @@ export default {
url: 'project/lists', url: 'project/lists',
data: callData.get() data: callData.get()
}).then(({data}) => { }).then(({data}) => {
if (replace) {
state.cacheProjects = [];
}
dispatch("saveProject", data.data); dispatch("saveProject", data.data);
callData.save(data).then(ids => dispatch("forgetProject", {id: ids})) callData.save(data).then(ids => dispatch("forgetProject", {id: ids}))
state.projectTotal = data.total_all; state.projectTotal = data.total_all;
@ -1666,6 +1853,9 @@ export default {
console.warn(e); console.warn(e);
reject(e) reject(e)
}).finally(_ => { }).finally(_ => {
if (replace) {
state.departmentOwnerProjectsRefreshing = false;
}
state.loadProjects--; state.loadProjects--;
}); });
}); });
@ -1702,7 +1892,7 @@ export default {
dispatch("call", { dispatch("call", {
url: 'project/one', url: 'project/one',
data: { data: {
project_id, project_id
}, },
}).then(result => { }).then(result => {
setTimeout(() => { setTimeout(() => {
@ -2133,9 +2323,14 @@ export default {
* @returns {Promise<unknown>} * @returns {Promise<unknown>}
*/ */
getTasks({state, dispatch}, requestData) { getTasks({state, dispatch}, requestData) {
if (requestData === null) { if (!$A.isJson(requestData)) {
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) const callData = $callData('tasks', requestData, state)
// //
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
@ -2435,7 +2630,7 @@ export default {
dispatch("call", { dispatch("call", {
url: 'project/task/content', url: 'project/task/content',
data: { data: {
task_id, task_id
}, },
}).then(result => { }).then(result => {
dispatch("saveTaskContent", result.data) dispatch("saveTaskContent", result.data)
@ -2483,7 +2678,7 @@ export default {
dispatch("call", { dispatch("call", {
url: 'project/task/files', url: 'project/task/files',
data: { data: {
task_id, task_id
}, },
}).then(result => { }).then(result => {
result.data.forEach((data) => { result.data.forEach((data) => {

View File

@ -103,6 +103,9 @@ export default {
cacheColumns: [], cacheColumns: [],
cacheTasks: [], cacheTasks: [],
cacheProjectParameter: [], cacheProjectParameter: [],
cacheDepartmentOwnerIds: [],
departmentOwnerViewRestored: false,
departmentOwnerProjectsRefreshing: false,
// Emoji // Emoji
cacheEmojis: [], cacheEmojis: [],

View File

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

View File

@ -531,6 +531,14 @@ body {
padding-top: 3px; padding-top: 3px;
} }
.ivu-alert-icon {
top: 10px;
}
.ivu-alert-message {
line-height: 20px;
}
.vuepress-markdown-body { .vuepress-markdown-body {
h1, h1,
h2 { h2 {

View File

@ -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 { > ul {
@ -202,6 +276,25 @@
pointer-events: none; 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;
}
} }
} }
} }

View File

@ -1,6 +1,9 @@
.project-panel { .project-panel {
display: flex !important; display: flex !important;
flex-direction: column; flex-direction: column;
.project-readonly-alert {
margin: 0 32px 8px;
}
.project-titbox { .project-titbox {
width: 100%; width: 100%;
padding: 32px 32px 4px; padding: 32px 32px 4px;
@ -1050,6 +1053,9 @@
body.window-portrait { body.window-portrait {
.project-panel { .project-panel {
.project-readonly-alert {
margin: 0 16px 8px;
}
.project-titbox { .project-titbox {
position: sticky; position: sticky;
top: 0; top: 0;

View File

@ -227,6 +227,9 @@
.scrollbar-content { .scrollbar-content {
padding: 0 5px; padding: 0 5px;
} }
.task-readonly-alert {
margin-top: 18px;
}
.receive-box { .receive-box {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@ -29,7 +29,7 @@
.menu-base { .menu-base {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 2;
margin: 0 auto; margin: 0 auto;
width: 80%; width: 80%;
background: #F4F5F7; 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 { .menu-project {
flex: 1; flex: 1;
@ -148,6 +186,24 @@
padding-left: 8px; padding-left: 8px;
font-size: 12px; 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 { .project-h2 {
display: none; display: none;
@ -205,6 +261,12 @@
height: 22px; 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 { .page-manage {
.manage-box-menu { .manage-box-menu {
.menu-base { .menu-base {