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

View File

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

View File

@ -404,9 +404,22 @@ class UsersController extends AbstractController
$data['nickname_original'] = $user->getRawOriginal('nickname');
$data['department_name'] = $user->getDepartmentName();
$data['department_owner'] = UserDepartment::where('parent_id',0)->where('owner_userid', $user->userid)->exists(); // 适用默认部门下第1级负责人才能添加部门OKR
$data['managed_departments'] = UserDepartment::getManagedDepartments($user->userid)->toArray();
return Base::retSuccess('success', $data);
}
/**
* @api {get} api/users/info/managed_departments 获取我可切换负责人视角的部门列表
*/
public function info__managed_departments()
{
$user = User::auth();
if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
return Base::retSuccess('success', []);
}
return Base::retSuccess('success', UserDepartment::getManagedDepartments($user->userid));
}
/**
* @api {get} api/users/info/departments 获取我的部门列表
*
@ -3272,7 +3285,7 @@ class UsersController extends AbstractController
return Base::retError('参数错误');
}
//
ProjectTask::userTask($task_id, null, null);
ProjectTask::findForDepartmentView($task_id, null, null);
//
UserTaskBrowse::recordBrowse($user->userid, $task_id);
//

View File

@ -723,4 +723,31 @@ class Project extends AbstractModel
}
return $project;
}
/**
* 获取项目(含部门负责人只读视角兜底)
* @param int $project_id
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
* @param null|bool|string $mustOwner 仅限 null 时尝试部门只读视角
* @return self
*/
public static function findForDepartmentView($project_id, $archived = true, $mustOwner = null)
{
$user = User::auth();
$departmentView = UserDepartment::ownerViewContext($user, true);
if (UserDepartment::isDepartmentReadonlyProject($departmentView, intval($project_id)) && $mustOwner === null) {
$project = self::allData()->where('projects.id', intval($project_id))->first();
if (empty($project)) {
throw new ApiException('项目不存在或已被删除', [ 'project_id' => $project_id ], -4001);
}
if ($archived === true && $project->archived_at != null) {
throw new ApiException('项目已归档', [ 'project_id' => $project_id ], -4001);
}
if ($archived === false && $project->archived_at == null) {
throw new ApiException('项目未归档', [ 'project_id' => $project_id ]);
}
return $project;
}
return self::userProject($project_id, $archived, $mustOwner);
}
}

View File

@ -2258,6 +2258,39 @@ class ProjectTask extends AbstractModel
return $task;
}
/**
* 获取任务(含部门负责人只读视角兜底)
* @param int $task_id
* @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制
* @param null|bool $trashed true:仅限未删除, false:仅限已删除, null:不限制
* @param array $with
* @return self
*/
public static function findForDepartmentView($task_id, $archived = true, $trashed = true, $with = [])
{
$user = User::auth();
$departmentView = UserDepartment::ownerViewContext($user, true);
if ($departmentView['enabled']) {
$builder = self::with($with)->allData()->where('project_tasks.id', intval($task_id));
if ($trashed === false) {
$builder->onlyTrashed();
} elseif ($trashed === null) {
$builder->withTrashed();
}
$task = $builder->first();
if (!empty($task) && UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id))) {
if ($archived === true && $task->archived_at != null) {
throw new ApiException('任务已归档', ['task_id' => $task_id]);
}
if ($archived === false && $task->archived_at == null) {
throw new ApiException('任务未归档', ['task_id' => $task_id]);
}
return $task;
}
}
return self::userTask($task_id, $archived, $trashed, $with);
}
/**
* 构建指定周期内的未完成任务查询(用于周报/日报等)
* @param int $userid

View File

@ -3,7 +3,9 @@
namespace App\Models;
use App\Exceptions\ApiException;
use App\Module\Base;
use Cache;
use Request;
/**
* App\Models\UserDepartment
@ -411,6 +413,93 @@ class UserDepartment extends AbstractModel
return array_unique($subIds);
}
/**
* 获取用户可切换负责人视角的部门(正负责人 + 部门管理员)
* @param int $userid
* @return \Illuminate\Support\Collection
*/
public static function getManagedDepartments($userid)
{
$userid = intval($userid);
if ($userid <= 0) {
return collect();
}
$deputyDepartmentIds = \DB::table('user_department_owners')
->where('userid', $userid)
->pluck('department_id')
->map(fn($v) => intval($v))
->toArray();
return self::select(['id', 'name', 'parent_id', 'owner_userid'])
->where(function ($query) use ($userid, $deputyDepartmentIds) {
$query->where('owner_userid', $userid);
if ($deputyDepartmentIds) {
$query->orWhereIn('id', $deputyDepartmentIds);
}
})
->orderBy('id')
->get();
}
/**
* 获取用户选择的负责人视角部门范围(含所有下级部门)
* @param int $userid
* @param array|string|null $selectedIds all/空表示全部可管理部门
* @return array
*/
public static function getManagedDepartmentScopeIds($userid, $selectedIds = null): array
{
$managedIds = self::getManagedDepartments($userid)->pluck('id')->map(fn($v) => intval($v))->toArray();
if (empty($managedIds)) {
return [];
}
if ($selectedIds === 'all' || $selectedIds === null || $selectedIds === '' || $selectedIds === []) {
$selected = $managedIds;
} else {
if (!is_array($selectedIds)) {
$selectedIds = explode(',', (string)$selectedIds);
}
$selected = array_values(array_intersect(
array_map('intval', $selectedIds),
$managedIds
));
}
if (empty($selected)) {
return [];
}
$scopeIds = [];
foreach ($selected as $departmentId) {
$scopeIds[] = $departmentId;
$scopeIds = array_merge($scopeIds, self::getAllSubDepartmentIds($departmentId));
}
return array_values(array_unique(array_map('intval', $scopeIds)));
}
/**
* 获取负责人视角可管理的成员 userid
* @param int $userid
* @param array|string|null $selectedIds
* @return array
*/
public static function getManagedMemberUserids($userid, $selectedIds = null): array
{
$departmentIds = self::getManagedDepartmentScopeIds($userid, $selectedIds);
if (empty($departmentIds)) {
return [];
}
return User::select(['userid'])
->where(function ($query) use ($departmentIds) {
foreach ($departmentIds as $departmentId) {
$query->orWhere('department', 'like', "%,{$departmentId},%");
}
})
->pluck('userid')
->map(fn($v) => intval($v))
->unique()
->values()
->toArray();
}
/**
* 获取部门基本信息缓存时间1小时
* @param int|array $ids
@ -453,4 +542,70 @@ class UserDepartment extends AbstractModel
return is_array($ids) ? $result : $result->first();
}
/**
* 部门负责人视角上下文(只读)。
* $defaultAll=true 用于项目内只读辅助接口兜底:前端漏传部门选择时按全部可管理部门判断。
*/
public static function ownerViewContext(User $user, bool $defaultAll = false): array
{
$ids = Request::input('department_owner_ids', Request::input('department_ids'));
if (($ids === null || $ids === '') && $defaultAll) {
$ids = 'all';
}
$empty = [
'enabled' => false,
'member_userids' => [],
'project_ids' => [],
'project_id_map' => [],
'own_project_ids' => [],
'own_project_id_map' => [],
];
if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') {
return $empty;
}
$memberUserids = self::getManagedMemberUserids($user->userid, $ids);
if (empty($memberUserids)) {
return $empty;
}
$projectIds = ProjectUser::whereIn('userid', $memberUserids)
->pluck('project_id')
->map(fn($v) => intval($v))
->unique()
->values()
->toArray();
$ownProjectIds = ProjectUser::whereUserid($user->userid)
->pluck('project_id')
->map(fn($v) => intval($v))
->unique()
->values()
->toArray();
return [
'enabled' => !empty($projectIds),
'member_userids' => $memberUserids,
'project_ids' => $projectIds,
'project_id_map' => array_fill_keys($projectIds, true),
'own_project_ids' => $ownProjectIds,
'own_project_id_map' => array_fill_keys($ownProjectIds, true),
];
}
/**
* 判断项目是否属于部门只读范围(非本人项目)
*/
public static function isDepartmentReadonlyProject(array $context, int $projectId): bool
{
return !empty($context['enabled'])
&& isset($context['project_id_map'][$projectId])
&& !isset($context['own_project_id_map'][$projectId]);
}
/**
* 为项目数据附加部门只读标记
*/
public static function appendDepartmentReadonlyProject(array $project, array $context): array
{
$project['department_readonly'] = self::isDepartmentReadonlyProject($context, intval($project['id']));
return $project;
}
}

View File

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

View File

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

View File

@ -8,7 +8,7 @@
:option-full="optionFull"
:placeholder="placeholder"
:placeholderFull="placeholderFull"
:readOnly="windowTouch"
:readOnly="readonly || windowTouch"
:readOnlyFull="false"
:readOnlyImagePreview="false"
@on-blur="onBlur"
@ -24,10 +24,10 @@
transfer>
<div :style="{userSelect:operateVisible ? 'none' : 'auto', height: operateStyles.height}"></div>
<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.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>
</DropdownMenu>
</Dropdown>
@ -99,6 +99,10 @@ export default {
placeholderFull: {
default: ''
},
readonly: {
type: Boolean,
default: false
},
},
data() {
@ -202,6 +206,9 @@ export default {
},
onEditing() {
if (this.readonly) {
return;
}
this.$refs.desc.onFull()
},
@ -210,6 +217,9 @@ export default {
},
onBlur() {
if (this.readonly) {
return;
}
this.$emit('on-blur');
},
@ -319,6 +329,9 @@ export default {
},
onLiPreview() {
if (this.readonly) {
return;
}
if (!this.operateMenu.checked) {
return;
}

View File

@ -82,6 +82,24 @@
<DropdownItem name="exportCheckin">{{$L('导出签到数据')}}</DropdownItem>
</DropdownMenu>
</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
v-else-if="item.visible !== false"
@ -146,12 +164,23 @@
<div class="menu-title">{{item.label}}</div>
</li>
</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 ref="menuProject" class="menu-project">
<Draggable
:list="projectDraggableList"
:animation="150"
:disabled="$isEEUIApp || windowTouch || !!projectKeyValue"
:disabled="$isEEUIApp || windowTouch || !!projectKeyValue || ownerProjectTabsVisible"
tag="ul"
item-key="id"
draggable="li:not(.pinned)"
@ -170,6 +199,12 @@
<div class="project-h1">
<em @click.stop="toggleOpenMenu(item.id)"></em>
<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.task_my_num - item.task_my_complete > 0" class="num">{{item.task_my_num - item.task_my_complete}}</div>
</div>
@ -186,7 +221,10 @@
</p>
</div>
</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>
</div>
</Scrollbar>
@ -330,6 +368,9 @@
<!--弹出 MCP 服务器信息-->
<MCPHelper v-model="mcpHelperShow"/>
<!--负责人视角-->
<DepartmentOwnerView v-model="departmentOwnerViewShow"/>
<!--导出任务统计-->
<TaskExport v-model="exportTaskShow"/>
@ -456,6 +497,7 @@ import transformEmojiToHtml from "../utils/emoji";
import {languageName} from "../language";
import {AINormalizeJsonContent, PROJECT_AI_SYSTEM_PROMPT, withLanguagePreferencePrompt} from "../utils/ai";
import Draggable from 'vuedraggable'
import DepartmentOwnerView from "./manage/components/DepartmentOwnerView.vue";
export default {
components: {
@ -481,7 +523,8 @@ export default {
ProjectArchived,
MicroApps,
ComplaintManagement,
Draggable
Draggable,
DepartmentOwnerView
},
directives: {longpress, TransferDom},
data() {
@ -519,6 +562,7 @@ export default {
projectDraggableList: [],
projectDragging: false,
ownerProjectTab: 'mine',
openMenu: {},
visibleMenu: false,
@ -550,6 +594,7 @@ export default {
taskBrowseHistory: [],
mcpHelperShow: false,
departmentOwnerViewShow: false,
}
},
@ -613,8 +658,10 @@ export default {
'dialogIns',
'formOptions',
'systemConfig',
'mobileTabbar',
'longpressData',
'departmentOwnerProjectsRefreshing',
'mcpServerStatus',
'microAppsIds'
@ -626,6 +673,14 @@ export default {
return this.microAppsIds?.includes('ai');
},
departmentOwnerViewAvailable() {
return this.systemConfig.department_owner_project_view === 'open' && (this.userInfo.managed_departments || []).length > 0;
},
cacheDepartmentOwnerIds() {
return (this.$store.state.cacheDepartmentOwnerIds || []).map(id => parseInt(id));
},
/**
* page className
* @param mobileTabbar
@ -763,6 +818,16 @@ export default {
{path: 'archivedProject', name: '已归档的项目'},
])
}
if (this.departmentOwnerViewAvailable) {
array.push({
path: 'departmentOwnerView',
name: '负责人视角',
divided: !userIsAdmin,
visible: true,
selected: this.cacheDepartmentOwnerIds.length > 0,
selectedCount: this.cacheDepartmentOwnerIds.length,
});
}
array.push(...[
{path: 'clearCache', name: '清除缓存', divided: true},
{path: 'logout', name: '退出登录', style: {color: '#f40'}}
@ -787,7 +852,7 @@ export default {
* 项目列表
* @returns {Array}
*/
projectLists() {
projectBaseLists() {
const {projectKeyValue, cacheProjects} = this;
const data = $A.cloneJSON(cacheProjects).sort((a, b) => {
//
@ -807,6 +872,36 @@ export default {
return data;
},
ownerProjectTabsVisible() {
return this.departmentOwnerViewAvailable && this.cacheDepartmentOwnerIds.length > 0;
},
ownerProjectTabs() {
return [
{type: 'mine', name: '我的项目', count: this.projectBaseLists.filter(item => !item.department_readonly).length},
{type: 'readonly', name: '负责人视角', count: this.projectBaseLists.filter(item => item.department_readonly).length},
];
},
routeProjectId() {
const {projectId} = this.$route.params;
return parseInt(/^\d+$/.test(projectId) ? projectId : 0);
},
routeProject() {
if (this.routeProjectId <= 0) {
return null;
}
return this.cacheProjects.find(({id}) => id == this.routeProjectId) || null;
},
projectLists() {
if (!this.ownerProjectTabsVisible) {
return this.projectBaseLists;
}
return this.projectBaseLists.filter(item => this.ownerProjectTab === 'readonly' ? item.department_readonly : !item.department_readonly);
},
/**
* 最近打开的任务列表
* @returns {Array}
@ -884,6 +979,31 @@ export default {
immediate: true
},
ownerProjectTabs: {
handler(tabs) {
if (!this.ownerProjectTabsVisible) {
this.ownerProjectTab = 'mine';
return;
}
const active = tabs.find(item => item.type === this.ownerProjectTab);
if (!active || active.count === 0) {
const first = tabs.find(item => item.count > 0);
if (first) {
this.ownerProjectTab = first.type;
}
}
this.syncOwnerProjectTabByRoute();
},
immediate: true
},
routeProject: {
handler() {
this.syncOwnerProjectTabByRoute();
},
immediate: true
},
projectLists: {
handler(val) {
if (!this.projectDragging) {
@ -915,6 +1035,12 @@ export default {
methods: {
transformEmojiToHtml,
syncOwnerProjectTabByRoute() {
if (!this.ownerProjectTabsVisible || !this.routeProject) {
return;
}
this.ownerProjectTab = this.routeProject.department_readonly ? 'readonly' : 'mine';
},
chackPass() {
if (this.userInfo.changepass === 1) {
this.goForward({name: 'manage-setting-password'});
@ -936,6 +1062,9 @@ export default {
settingRoute(path) {
switch (path) {
case 'departmentOwnerView':
this.departmentOwnerViewShow = true;
return;
case 'allUser':
this.allUserShow = true;
return;

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

View File

@ -11,11 +11,31 @@
<Input type="search" v-model="projectKeyValue" :placeholder="$L(loadProjects > 0 ? '更新中...' : '搜索')" clearable/>
</Form>
</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>
<Draggable
:list="projectDraggableList"
:animation="150"
:disabled="!(isDragging && !projectKeyValue)"
:disabled="!(isDragging && !projectKeyValue) || ownerProjectTabsVisible"
tag="ul"
item-key="id"
draggable="li:not(.pinned)"
@ -36,6 +56,12 @@
<div class="item-left">
<div class="project-h1">
<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.task_my_num - item.task_my_complete > 0" class="num">{{item.task_my_num - item.task_my_complete}}</div>
</div>
@ -62,7 +88,7 @@
</div>
</li>
<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">
{{$L(projectKeyValue ? `没有任何与"${projectKeyValue}"相关的结果` : `没有任何项目`)}}
</li>
@ -88,12 +114,14 @@
<DropdownItem @click.native="handleChatClick">
{{ $L('项目讨论') }}
</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 ? '退出排序' : '调整排序') }}
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
<DepartmentOwnerView v-model="departmentOwnerViewShow"/>
</div>
</template>
@ -103,10 +131,11 @@ import Draggable from 'vuedraggable'
import longpress from "../../../directives/longpress";
import TransferDom from "../../../directives/transfer-dom";
import transformEmojiToHtml from "../../../utils/emoji";
import DepartmentOwnerView from "./DepartmentOwnerView.vue";
export default {
name: "ProjectList",
components: {Draggable},
components: {Draggable, DepartmentOwnerView},
directives: {longpress, TransferDom},
data() {
return {
@ -120,13 +149,30 @@ export default {
isDragging: false,
projectDraggableList: [],
projectDragging: false,
ownerProjectTab: 'mine',
departmentOwnerViewShow: false,
}
},
computed: {
...mapState(['cacheProjects', 'loadProjects', 'longpressData']),
...mapState(['cacheProjects', 'loadProjects', 'longpressData', 'userInfo', 'systemConfig', 'cacheDepartmentOwnerIds', 'departmentOwnerProjectsRefreshing']),
projectLists() {
managedDepartments() {
return (this.userInfo.managed_departments || []).map(item => ({
...item,
id: parseInt(item.id)
}));
},
ownerViewAvailable() {
return this.systemConfig.department_owner_project_view === 'open' && this.managedDepartments.length > 0;
},
ownerDepartmentIds() {
return (this.cacheDepartmentOwnerIds || []).map(id => parseInt(id));
},
projectBaseLists() {
const {projectKeyValue, cacheProjects} = this;
const data = $A.cloneJSON(cacheProjects).sort((a, b) => {
//
@ -145,9 +191,44 @@ export default {
}
return data;
},
ownerProjectTabsVisible() {
return this.ownerViewAvailable && this.ownerDepartmentIds.length > 0;
},
ownerProjectTabs() {
return [
{type: 'mine', name: '我的项目', count: this.projectBaseLists.filter(item => !item.department_readonly).length},
{type: 'readonly', name: '负责人视角', count: this.projectBaseLists.filter(item => item.department_readonly).length},
];
},
projectLists() {
if (!this.ownerProjectTabsVisible) {
return this.projectBaseLists;
}
return this.projectBaseLists.filter(item => this.ownerProjectTab === 'readonly' ? item.department_readonly : !item.department_readonly);
},
},
watch: {
ownerProjectTabs: {
handler(tabs) {
if (!this.ownerProjectTabsVisible) {
this.ownerProjectTab = 'mine';
return;
}
const active = tabs.find(item => item.type === this.ownerProjectTab);
if (!active || active.count === 0) {
const first = tabs.find(item => item.count > 0);
if (first) {
this.ownerProjectTab = first.type;
}
}
},
immediate: true
},
projectLists: {
handler(val) {
if (!this.projectDragging) {

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<template>
<!--子任务-->
<li v-if="ready && isSubTask">
<div class="subtask-icon">
<div v-if="!isDepartmentReadonly" class="subtask-icon">
<TaskMenu
:ref="`taskMenu_${taskDetail.id}`"
:disabled="taskId === 0"
@ -13,7 +13,7 @@
v-if="taskDetail.flow_item_name"
class="subtask-flow"
: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 class="subtask-name">
<Input
@ -24,11 +24,13 @@
:autosize="{ minRows: 1, maxRows: 8 }"
:maxlength="255"
enterkeyhint="done"
:readonly="isDepartmentReadonly"
@on-blur="updateBlur('name')"
@on-keydown="onNameKeydown"
/>
</div>
<DatePicker
v-if="!isDepartmentReadonly"
v-model="timeValue"
:open="timeOpen"
:options="timeOptions"
@ -46,7 +48,11 @@
</div>
<Icon v-else class="clock" type="ios-clock-outline" @click="openTime" />
</DatePicker>
<div v-else-if="showSubTime" :class="['subtask-time readonly-time', taskDetail.today ? 'today' : '', taskDetail.overdue ? 'overdue' : '']">
{{expiresFormat(taskDetail.end_at)}}
</div>
<UserSelect
v-if="!isDepartmentReadonly"
class="subtask-avatar"
v-model="ownerData.owner_userid"
:multiple-max="10"
@ -55,6 +61,11 @@
:add-icon="false"
:project-id="taskDetail.project_id"
: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>
<!--主任务-->
<div
@ -65,6 +76,7 @@
<div v-show="taskDetail.id > 0" class="task-info" v-resize-observer="scrollIntoInput">
<div class="head">
<TaskMenu
v-if="!isDepartmentReadonly"
:ref="`taskMenu_${taskDetail.id}`"
:disabled="taskId === 0"
:task="taskDetail"
@ -76,10 +88,10 @@
v-if="taskDetail.flow_item_name"
class="flow"
: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 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 class="nav user-select-auto">
<p v-if="projectName"><span>{{projectName}}</span></p>
@ -90,7 +102,7 @@
<ETooltip v-if="$Electron" :disabled="$isEEUIApp || windowTouch" :content="$L('独立窗口显示')">
<i class="taskfont open" @click="openNewWin">&#xe776;</i>
</ETooltip>
<div class="menu">
<div v-if="!isDepartmentReadonly" class="menu">
<TaskMenu
:disabled="taskId === 0"
:task="taskDetail"
@ -104,7 +116,10 @@
</div>
</div>
<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>
<EPopover
v-model="receiveShow"
@ -143,6 +158,7 @@
:autosize="{ minRows: 1, maxRows: 8 }"
:maxlength="255"
enterkeyhint="done"
:readonly="isDepartmentReadonly"
@on-blur="updateBlur('name')"
@on-keydown="onNameKeydown"/>
</div>
@ -151,6 +167,7 @@
class="desc"
:value="taskContent"
:placeholder="$L('详细描述...')"
:readonly="isDepartmentReadonly"
@on-history="onHistory"
@on-blur="updateBlur('content', $event)"/>
<Form class="items" label-position="left" label-width="auto" @submit.native.prevent>
@ -159,7 +176,8 @@
<i class="taskfont">&#xe61e;</i>{{$L('标签')}}
</div>
<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
ref="tagSelect"
v-model="tagValue"
@ -181,7 +199,7 @@
</div>
<ul class="item-content priority">
<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>
</ul>
</FormItem>
@ -189,7 +207,11 @@
<div class="item-label" slot="label">
<i class="taskfont">&#xe6e4;</i>{{$L('负责人')}}
</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
v-else
class="item-content user"
v-model="ownerData.owner_userid"
:multiple-max="10"
@ -203,7 +225,11 @@
<div class="item-label" slot="label">
<i class="taskfont">&#xe63f;</i>{{$L('协助人员')}}
</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
v-else
ref="assist"
class="item-content user"
v-model="assistData.assist_userid"
@ -218,11 +244,11 @@
<FormItem v-if="taskDetail.visibility > 1 || visibleForce || visibleKeep">
<div class="item-label" slot="label">
<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 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>
<UserSelect v-else
<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-if="!isDepartmentReadonly"
ref="visibleUserSelectRef"
v-model="taskDetail.visibility_appointor"
:avatar-size="28"
@ -230,13 +256,16 @@
:project-id="taskDetail.project_id"
:add-icon="false"
@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>
</FormItem>
<FormItem v-if="taskDetail.end_at || timeForce">
<div class="item-label" slot="label">
<i class="taskfont">&#xe6e8;</i>
<span v-if="!taskDetail.end_at" @click="timeOpen = true" class="visibility-text color">{{$L('截止时间')}}</span>
<span v-else class="visibility-text color" @click="showAtDropdown">{{$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="!isDepartmentReadonly && showAtDropdown($event)">{{$L('截止时间')}}</span>
</div>
<ul class="item-content">
<li>
@ -253,13 +282,13 @@
@on-ok="timeOk"
transfer>
<div class="picker-time">
<div v-if="!taskDetail.end_at" @click="timeOpen = true" class="time">{{taskDetail.end_at ? cutTime : '--'}}</div>
<div v-else @click="showAtDropdown" 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="!isDepartmentReadonly && showAtDropdown($event)" class="time">{{taskDetail.end_at ? cutTime : '--'}}</div>
<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)}}
</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>
</div>
</DatePicker>
@ -273,7 +302,7 @@
<ul class="item-content loop">
<li>
<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>
</li>
</ul>
@ -291,7 +320,7 @@
<div class="file-size">{{$A.bytesToSize(file.size)}}</div>
</li>
</ul>
<ul class="item-content file-up">
<ul v-if="!isDepartmentReadonly" class="item-content file-up">
<li>
<div class="add-button" @click="onUploadClick(true)">
<i class="taskfont">&#xe6f2;</i>
@ -314,7 +343,7 @@
:main-end-at="taskDetail.end_at"
:can-update-blur="canUpdateBlur"/>
</ul>
<ul class="item-content subtask-add">
<ul v-if="!isDepartmentReadonly" class="item-content subtask-add">
<li>
<Input
v-if="addsubShow"
@ -377,6 +406,7 @@
{{$L('已归档')}}
</span>
<Icon
v-if="!isDepartmentReadonly"
type="md-close"
class="related-remove"
@click.native.stop="removeRelatedTask(item)"/>
@ -384,7 +414,7 @@
</ul>
</FormItem>
</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-button" @click="onAddItem">
<i class="taskfont">&#xe6f2;</i>
@ -484,6 +514,7 @@
<div class="drag-text">{{$L('拖动到这里发送')}}</div>
</div>
</div>
</div>
</div>
<div v-if="!taskDetail.id" class="task-load"><Loading/></div>
@ -884,6 +915,9 @@ export default {
},
menuList() {
if (this.isDepartmentReadonly) {
return [];
}
const {taskDetail} = this;
const list = [];
if ($A.arrayLength(taskDetail.task_tag) === 0) {
@ -963,6 +997,10 @@ export default {
return this.systemConfig.task_visible === 'open' //
},
isDepartmentReadonly() {
return !!this.taskDetail?.department_readonly;
},
isSubTask({taskDetail}) {
return taskDetail.parent_id > 0
},
@ -1137,6 +1175,9 @@ export default {
},
onNameKeydown(e) {
if (this.isDepartmentReadonly) {
return;
}
if (e.keyCode === 13) {
if (!e.shiftKey) {
e.preventDefault();
@ -1146,6 +1187,9 @@ export default {
},
checkUpdate(action) {
if (this.isDepartmentReadonly) {
return false;
}
let isModify = false;
if (this.openTask.name != this.taskDetail.name) {
isModify = true;
@ -1187,12 +1231,20 @@ export default {
},
updateBlur(action, params) {
if (this.isDepartmentReadonly) {
return;
}
if (this.canUpdateBlur) {
this.updateData(action, params)
}
},
updateData(action, params) {
if (this.isDepartmentReadonly) {
return;
}
let successCallback = null;
switch (action) {
case 'priority':
@ -1396,6 +1448,10 @@ export default {
},
async onOwner(pick) {
if (this.isDepartmentReadonly) {
return;
}
let data = {
task_id: this.taskDetail.id,
owner: this.ownerData.owner_userid
@ -1440,6 +1496,10 @@ export default {
},
onAssist() {
if (this.isDepartmentReadonly) {
return;
}
if ($A.jsonStringify(this.taskDetail.assist_userid) === $A.jsonStringify(this.assistData.assist_userid)) {
return;
}
@ -1484,6 +1544,10 @@ export default {
},
openTime() {
if (this.isDepartmentReadonly) {
return;
}
this.timeOpen = !this.timeOpen;
if (this.timeOpen) {
this.timeValue = this.taskDetail.end_at ? [this.taskDetail.start_at, this.taskDetail.end_at] : [];
@ -1497,6 +1561,10 @@ export default {
},
timeClear() {
if (this.isDepartmentReadonly) {
return;
}
this.updateData('times', {
start_at: false,
end_at: false,
@ -1505,6 +1573,10 @@ export default {
},
timeOk() {
if (this.isDepartmentReadonly) {
return;
}
const times = $A.newDateString(this.timeValue, "YYYY-MM-DD HH:mm");
this.updateData('times', {
start_at: times[0],
@ -1514,6 +1586,10 @@ export default {
},
addsubOpen() {
if (this.isDepartmentReadonly) {
return;
}
this.addsubShow = true;
this.$nextTick(() => {
this.$refs.addsub.focus()
@ -1537,6 +1613,10 @@ export default {
},
onAddsub() {
if (this.isDepartmentReadonly) {
return;
}
if (this.addsubName == '') {
$A.messageError('任务描述不能为空');
return;
@ -1602,6 +1682,10 @@ export default {
},
removeRelatedTask(item) {
if (this.isDepartmentReadonly) {
return;
}
if (!item || !item.related_task_id) {
return;
}
@ -1636,6 +1720,10 @@ export default {
},
onPriority(event) {
if (this.isDepartmentReadonly) {
return;
}
const list = this.taskPriority.map(item => {
return {
label: item.name,
@ -1655,6 +1743,10 @@ export default {
},
onLoop(event) {
if (this.isDepartmentReadonly) {
return;
}
const list = this.loops.map(item => {
return {
label: item.label,
@ -1684,6 +1776,10 @@ export default {
},
onAddItem(event) {
if (this.isDepartmentReadonly) {
return;
}
const list = this.menuList.map(item => {
return {
label: item.name,
@ -1702,6 +1798,10 @@ export default {
},
dropAddItem(command) {
if (this.isDepartmentReadonly) {
return;
}
switch (command) {
case 'tag':
this.tagForce = true;
@ -1765,6 +1865,7 @@ export default {
},
onEventMore(e) {
if (['image', 'file'].includes(e)) {
this.onUploadClick(false)
}
@ -1776,6 +1877,7 @@ export default {
},
msgDialog(sendType = null) {
if (this.sendLoad > 0 || this.openLoad > 0) {
return;
}
@ -1895,6 +1997,10 @@ export default {
},
deleteFile(file) {
if (this.isDepartmentReadonly) {
return;
}
this.$set(file, '_show_menu', false);
this.$store.dispatch("forgetTaskFile", file.id)
//
@ -1910,6 +2016,9 @@ export default {
},
openMenu(event, task) {
if (this.isDepartmentReadonly) {
return;
}
const el = this.$refs[`taskMenu_${task.id}`];
el && el.handleClick(event)
},
@ -2010,12 +2119,18 @@ export default {
okText: this.$L('立即下载'),
content: `${file.name} (${$A.bytesToSize(file.size)})`,
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){
if (this.isDepartmentReadonly) {
return;
}
const list = [
{label: '项目人员', value: 1},
{label: '任务人员', value: 2},
@ -2033,6 +2148,10 @@ export default {
},
showAtDropdown(event){
if (this.isDepartmentReadonly) {
return;
}
this.timeOpen = false
const list = [
{label: '任务延期', value: 1},
@ -2060,6 +2179,10 @@ export default {
},
dropVisible(command) {
if (this.isDepartmentReadonly) {
return;
}
switch (command) {
case 1:
case 2:
@ -2077,6 +2200,10 @@ export default {
},
dropDeadline(command) {
if (this.isDepartmentReadonly) {
return;
}
switch (command) {
case 1:
this.delayTaskQuicks = [
@ -2108,6 +2235,10 @@ export default {
},
onDelay(){
if (this.isDepartmentReadonly) {
return;
}
this.$refs.formDelayTaskRef.validate((valid) => {
if (!valid) {
return
@ -2137,8 +2268,10 @@ export default {
const list = [
{label: '查看附件', value: 1},
{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', {
event,
list,
@ -2179,6 +2312,10 @@ export default {
},
onTagAdd(tagName) {
if (this.isDepartmentReadonly) {
return;
}
//
this.tagValue = this.getTag;
this.tagBakValue = $A.cloneJSON(this.tagValue);
@ -2188,6 +2325,10 @@ export default {
},
onTagAddSave(result) {
if (this.isDepartmentReadonly) {
return;
}
const current = this.tagValue;
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' : '']"
type="ios-arrow-forward"
@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)">
<!--工作流状态-->
<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>
<!--有多少个子任务-->
@ -51,7 +51,7 @@
trigger="click"
size="small"
placement="bottom"
:disabled="item.sub_top === true"
:disabled="readonly || item.sub_top === true"
@command="dropTask(item, $event)">
<div class="task-column">{{columnName(item.column_id)}}</div>
<EDropdownMenu slot="dropdown">
@ -66,7 +66,7 @@
trigger="click"
size="small"
placement="bottom"
:disabled="item.sub_top === true"
:disabled="readonly || item.sub_top === true"
@command="dropTask(item, $event)">
<TaskPriority :backgroundColor="item.p_color">{{item.p_name || $L('未设置')}}</TaskPriority>
<EDropdownMenu slot="dropdown">
@ -85,7 +85,7 @@
<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"/>
</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>
</li>
</ul>
@ -107,11 +107,12 @@
v-if="taskOpen[item.id]===true"
:list="subTask(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"
@command="dropTask"/>
@command="dropTask"
:readonly="readonly"/>
</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>
</template>
@ -151,6 +152,10 @@ export default {
taskVisibilitys: {
type: Object,
default: () => ({})
},
readonly: {
type: Boolean,
default: false
}
},
data() {
@ -202,6 +207,9 @@ export default {
},
dropTask(task, command) {
if (this.readonly) {
return;
}
const el = this.$refs[`taskMenu_${task.id}`];
if (!el) {
return;
@ -228,6 +236,9 @@ export default {
},
onPriority(data) {
if (this.readonly) {
return;
}
this.$emit("on-priority", data)
},
@ -260,6 +271,9 @@ export default {
openTask(task, receive) {
this.$store.dispatch("openTask", task)
if (this.readonly) {
return;
}
if (receive === true) {
//
setTimeout(() => {
@ -269,6 +283,9 @@ export default {
},
openMenu(event, task) {
if (this.readonly) {
return;
}
const el = this.$refs[`taskMenu_${task.id}`];
if (el) {
el[0].handleClick(event)

View File

@ -75,6 +75,13 @@
</RadioGroup>
<div v-if="formDatum.project_invite == 'open'" class="form-tip">{{$L('开启项目管理员可生成链接邀请成员加入项目')}}</div>
</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 class="block-setting-box">
@ -351,6 +358,7 @@ export default {
}).then(({data}) => {
if (save) {
$A.messageSuccess('修改成功');
this.$store.dispatch("getUserInfo").catch(() => {});
}
this.formDatum = data;
this.formDatum_bak = $A.cloneJSON(this.formDatum);

View File

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

View File

@ -103,6 +103,9 @@ export default {
cacheColumns: [],
cacheTasks: [],
cacheProjectParameter: [],
cacheDepartmentOwnerIds: [],
departmentOwnerViewRestored: false,
departmentOwnerProjectsRefreshing: false,
// Emoji
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%);
}
}
.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;
}
.ivu-alert-icon {
top: 10px;
}
.ivu-alert-message {
line-height: 20px;
}
.vuepress-markdown-body {
h1,
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 {
@ -202,6 +276,25 @@
pointer-events: none;
}
}
.readonly-owner-avatar {
margin-left: 6px;
flex-shrink: 0;
}
.readonly-project-avatar {
width: 18px;
height: 18px;
margin-top: 2px;
margin-left: 6px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 12px;
line-height: 18px;
background-color: #5BC7B0;
color: #ffffff;
}
}
}
}

View File

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

View File

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

View File

@ -29,7 +29,7 @@
.menu-base {
position: sticky;
top: 0;
z-index: 1;
z-index: 2;
margin: 0 auto;
width: 80%;
background: #F4F5F7;
@ -81,6 +81,44 @@
}
}
}
.owner-project-tabs {
width: 100%;
display: flex;
align-items: center;
margin: 8px 0 0;
padding: 3px;
border-radius: 12px;
background: #eeeeee;
.owner-project-tab {
flex: 1;
min-width: 0;
height: 30px;
padding: 0 10px;
border-radius: 9px;
color: #808695;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ivu-badge {
flex-shrink: 0;
transform: scale(0.8);
transform-origin: left center;
}
&.active {
background: #ffffff;
color: #17233d;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
}
}
}
.menu-project {
flex: 1;
@ -148,6 +186,24 @@
padding-left: 8px;
font-size: 12px;
}
.readonly-owner-avatar {
margin-left: 4px;
flex-shrink: 0;
}
.readonly-project-avatar {
width: 18px;
height: 18px;
margin-left: 4px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 12px;
line-height: 18px;
background-color: #5BC7B0;
color: #ffffff;
}
}
.project-h2 {
display: none;
@ -205,6 +261,12 @@
height: 22px;
}
}
&.nothing {
padding: 18px 8px;
color: #999999;
text-align: center;
cursor: default;
}
}
}
}
@ -515,7 +577,7 @@
}
}
@media (height <= 640px) {
@media (height <= 800px) {
.page-manage {
.manage-box-menu {
.menu-base {