From 24710289e1801d22f7289a7ffaeb32776092d2c1 Mon Sep 17 00:00:00 2001 From: kuaifan Date: Sun, 3 May 2026 00:05:31 +0000 Subject: [PATCH] =?UTF-8?q?feat(multi-owner):=20=E7=BE=A4/=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE/=E9=83=A8=E9=97=A8=E6=94=AF=E6=8C=81=E4=B8=BB+?= =?UTF-8?q?=E5=89=AF=E5=8F=8C=E8=B4=9F=E8=B4=A3=E4=BA=BA=E4=BD=93=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 群组:新增 web_socket_dialog_users.role(1=主、2=副),主可任命/罢免副群主,副可邀请/移出普通成员 - 项目:project_users.owner 扩展为 0/1/2(成员/主/副),主独占转让和删除,副共享日常管理;任务可见性、通知、分配等下游逻辑统一用「主+副」 - 部门:新增 user_department_owners 表存储副负责人;部门群同步副群主,赋予群管理员权限 - 转移用户时副身份不替补、降级为普通成员 - 配套 migration/backfill、API、前端 UI、i18n 词条与三项 Feature 测试 - .gitignore 忽略 .playwright-mcp/ --- .gitignore | 3 + app/Http/Controllers/Api/DialogController.php | 107 ++- .../Controllers/Api/ProjectController.php | 192 +++++- app/Http/Controllers/Api/UsersController.php | 59 ++ app/Models/Project.php | 91 ++- app/Models/ProjectTask.php | 4 +- app/Models/ProjectUser.php | 43 +- app/Models/UserDepartment.php | 201 ++++++ app/Models/WebSocketDialog.php | 95 ++- app/Observers/ProjectTaskObserver.php | 4 +- ...01_add_role_to_web_socket_dialog_users.php | 39 ++ ...4_30_000002_backfill_dialog_owner_role.php | 39 ++ ...01_create_user_department_owners_table.php | 39 ++ ..._backfill_project_dialog_primary_owner.php | 67 ++ language/original-api.txt | 18 + language/original-web.txt | 23 + .../assets/js/components/UserAvatar/index.vue | 1 + .../manage/components/DialogGroupInfo.vue | 144 +++- .../pages/manage/components/ProjectPanel.vue | 159 ++++- .../manage/components/TeamManagement.vue | 118 +++- resources/assets/sass/dark.scss | 3 +- .../pages/components/dialog-group-info.scss | 26 +- .../pages/components/team-management.scss | 17 + tests/Feature/MultiOwnerDepartmentTest.php | 373 ++++++++++ tests/Feature/MultiOwnerGroupTest.php | 513 ++++++++++++++ tests/Feature/MultiOwnerProjectTest.php | 638 ++++++++++++++++++ 26 files changed, 2895 insertions(+), 121 deletions(-) create mode 100644 database/migrations/2026_04_30_000001_add_role_to_web_socket_dialog_users.php create mode 100644 database/migrations/2026_04_30_000002_backfill_dialog_owner_role.php create mode 100644 database/migrations/2026_05_01_000001_create_user_department_owners_table.php create mode 100644 database/migrations/2026_05_02_000001_backfill_project_dialog_primary_owner.php create mode 100644 tests/Feature/MultiOwnerDepartmentTest.php create mode 100644 tests/Feature/MultiOwnerGroupTest.php create mode 100644 tests/Feature/MultiOwnerProjectTest.php diff --git a/.gitignore b/.gitignore index f77f20448..f4a60ce4a 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ laravels.pid # Documentation README_LOCAL.md + +# playwright +.playwright-mcp/ diff --git a/app/Http/Controllers/Api/DialogController.php b/app/Http/Controllers/Api/DialogController.php index 29fd2d3ab..1260120ca 100755 --- a/app/Http/Controllers/Api/DialogController.php +++ b/app/Http/Controllers/Api/DialogController.php @@ -1755,8 +1755,10 @@ class DialogController extends AbstractController } // 任务可见性校验(与 task__one 一致) if ($task->visibility != 1) { - $project_userid = ProjectUser::whereProjectId($task->project_id)->whereOwner(1)->value('userid'); - if ($user->userid != $project_userid) { + $projectOwnerids = ProjectUser::whereProjectId($task->project_id) + ->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY]) + ->pluck('userid')->map(fn($v) => (int)$v)->toArray(); + if (!in_array($user->userid, $projectOwnerids)) { $visibleUserids = array_merge( ProjectTaskUser::whereTaskId($task_id)->pluck('userid')->toArray(), ProjectTaskUser::whereTaskPid($task_id)->pluck('userid')->toArray(), @@ -2832,7 +2834,10 @@ class DialogController extends AbstractController return Base::retError('对话不存在或已被删除', ['dialog_id' => $dialog_id], -4003); } } else { - $dialog = WebSocketDialog::checkDialog($dialog_id, true); + $dialog = WebSocketDialog::checkDialog($dialog_id); + if (!$dialog->isOwner(User::userid())) { + throw new \App\Exceptions\ApiException('仅群主或群管理员可操作'); + } } // $data = ['id' => $dialog->id]; @@ -2891,7 +2896,11 @@ class DialogController extends AbstractController return Base::retError('请选择群成员'); } // - $dialog = WebSocketDialog::checkDialog($dialog_id, "auto"); + $dialog = WebSocketDialog::checkDialog($dialog_id); + // 有群主(主或副)时,仅群主/副群主可邀请;无群主时,任意成员可邀请 + if ($dialog->owner_id > 0 && !$dialog->isOwner($user->userid)) { + throw new \App\Exceptions\ApiException('仅限群主或群管理员操作'); + } // $dialog->checkGroup(); $dialog->joinGroup($userids, $user->userid); @@ -2981,17 +2990,107 @@ class DialogController extends AbstractController $dialog = WebSocketDialog::checkDialog($dialog_id, $check_owner); // $dialog->checkGroup($check_owner ? 'user' : null); + $oldOwnerId = (int)$dialog->owner_id; $dialog->owner_id = $userid; if ($dialog->save()) { $dialog->joinGroup($userid, 0); + // 同步 role:原主 role=0、新主 role=1(覆盖即可) + if ($oldOwnerId > 0 && $oldOwnerId !== (int)$userid) { + WebSocketDialogUser::where('dialog_id', $dialog->id) + ->where('userid', $oldOwnerId) + ->update(['role' => 0]); + } + WebSocketDialogUser::where('dialog_id', $dialog->id) + ->where('userid', $userid) + ->update(['role' => 1]); $dialog->pushMsg("groupUpdate", [ 'id' => $dialog->id, 'owner_id' => $dialog->owner_id, + 'deputy_ids' => $dialog->deputy_ids, ]); } return Base::retSuccess('转让成功'); } + /** + * 任命副群主(仅主群主可操作) + * + * @apiParam {Number} dialog_id 群对话ID + * @apiParam {Number} userid 要任命的群成员 userid + */ + public function group__adddeputy() + { + $user = User::auth(); + $dialog_id = intval(Request::input('dialog_id')); + $userid = intval(Request::input('userid')); + + if ($userid <= 0) { + return Base::retError('请选择有效的成员'); + } + + $dialog = WebSocketDialog::checkDialog($dialog_id, true); // checkOwner=true:仅主群主 + $dialog->checkGroup('user'); // 仅普通群 + + $member = WebSocketDialogUser::where('dialog_id', $dialog->id) + ->where('userid', $userid) + ->first(); + if (empty($member)) { + return Base::retError('该用户不是群成员'); + } + + if ((int)$member->role === 1) { + return Base::retError('不能将群主任命为群管理员'); + } + if ((int)$member->role !== 2) { + $member->role = 2; + $member->save(); + $dialog->pushMsg('groupUpdate', [ + 'id' => $dialog->id, + 'deputy_ids' => $dialog->fresh()->deputy_ids, + ]); + } + + return Base::retSuccess('任命成功'); + } + + /** + * 罢免副群主(仅主群主可操作) + * + * @apiParam {Number} dialog_id 群对话ID + * @apiParam {Number} userid 要罢免的副群主 userid + */ + public function group__deldeputy() + { + $user = User::auth(); + $dialog_id = intval(Request::input('dialog_id')); + $userid = intval(Request::input('userid')); + + if ($userid <= 0) { + return Base::retError('请选择有效的成员'); + } + + $dialog = WebSocketDialog::checkDialog($dialog_id, true); + $dialog->checkGroup('user'); + + $member = WebSocketDialogUser::where('dialog_id', $dialog->id) + ->where('userid', $userid) + ->first(); + if (empty($member)) { + return Base::retSuccess('罢免成功'); // 幂等:本来就不是成员 + } + + if ((int)$member->role === 2) { + $member->role = 0; + $member->save(); + $dialog->pushMsg('groupUpdate', [ + 'id' => $dialog->id, + 'deputy_ids' => $dialog->fresh()->deputy_ids, + ]); + } + + return Base::retSuccess('罢免成功'); + } + /** * @api {get} api/dialog/group/disband 解散群组 * diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 37e36890b..5424cf018 100755 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -372,15 +372,16 @@ class ProjectController extends AbstractController } /** - * @api {get} api/project/user 修改项目成员 + * @api {post} api/project/user 修改项目成员 * * @apiDescription 需要token身份(限:项目负责人) * @apiVersion 1.0.0 * @apiGroup project * @apiName user * - * @apiParam {Number} project_id 项目ID - * @apiParam {Number} userid 成员ID 或 成员ID组 + * @apiParam {Number} project_id 项目ID + * @apiParam {Number[]} userid 成员userid数组(最终完整列表) + * @apiParam {Number[]} [deputy_userid] 副负责人userid数组(可选,仅主负责人有效;必须是 userid 子集) * * @apiSuccess {Number} ret 返回状态码(1正确、0错误) * @apiSuccess {String} msg 返回信息(错误描述) @@ -393,6 +394,13 @@ class ProjectController extends AbstractController $project_id = intval(Request::input('project_id')); $userid = Request::input('userid'); $userid = is_array($userid) ? $userid : [$userid]; + $userid = array_values(array_unique(array_map('intval', $userid))); + // + $deputy_userid = Request::input('deputy_userid'); + if ($deputy_userid !== null) { + $deputy_userid = is_array($deputy_userid) ? $deputy_userid : [$deputy_userid]; + $deputy_userid = array_values(array_unique(array_map('intval', $deputy_userid))); + } // if (count($userid) > 100) { return Base::retError('项目人数最多100个'); @@ -400,7 +408,20 @@ class ProjectController extends AbstractController // $project = Project::userProject($project_id, true, true); // - $deleteUser = AbstractModel::transaction(function() use ($project, $userid) { + // 仅主负责人可设置副负责人;副负责人/其他角色提交 deputy_userid 一律忽略 + $isPrimary = (int)$project->owner === ProjectUser::OWNER_PRIMARY; + $applyDeputy = $isPrimary && $deputy_userid !== null; + // + if ($applyDeputy) { + if (!empty(array_diff($deputy_userid, $userid))) { + return Base::retError('项目管理员必须是项目成员'); + } + if (in_array((int)$project->owner_userid, $deputy_userid, true)) { + return Base::retError('负责人不能任命为项目管理员'); + } + } + // + $deleteUser = AbstractModel::transaction(function() use ($project, $userid, $applyDeputy, $deputy_userid) { $array = []; foreach ($userid as $uid) { if ($project->joinProject($uid)) { @@ -408,15 +429,37 @@ class ProjectController extends AbstractController } } $deleteRows = ProjectUser::whereProjectId($project->id)->whereNotIn('userid', $array)->get(); - $deleteUser = $deleteRows->pluck('userid'); + $deleteUserids = $deleteRows->pluck('userid'); foreach ($deleteRows as $row) { $row->exitProject(); } + // + // 副负责人 diff(仅主负责人有效) + if ($applyDeputy) { + $currentDeputies = ProjectUser::whereProjectId($project->id) + ->where('owner', ProjectUser::OWNER_DEPUTY) + ->pluck('userid')->toArray(); + $toPromote = array_values(array_diff($deputy_userid, $currentDeputies)); + $toDemote = array_values(array_diff($currentDeputies, $deputy_userid)); + if (!empty($toPromote)) { + ProjectUser::whereProjectId($project->id) + ->whereIn('userid', $toPromote) + ->where('owner', ProjectUser::OWNER_MEMBER) + ->change(['owner' => ProjectUser::OWNER_DEPUTY]); + } + if (!empty($toDemote)) { + ProjectUser::whereProjectId($project->id) + ->whereIn('userid', $toDemote) + ->where('owner', ProjectUser::OWNER_DEPUTY) + ->change(['owner' => ProjectUser::OWNER_MEMBER]); + } + } + // $project->syncDialogUser(); $project->addLog("修改项目成员"); $project->user_simple = count($array) . "|" . implode(",", array_slice($array, 0, 3)); $project->save(); - return $deleteUser->toArray(); + return $deleteUserids->toArray(); }); // $project->pushMsg('delete', null, $deleteUser); @@ -574,28 +617,138 @@ class ProjectController extends AbstractController $project_id = intval(Request::input('project_id')); $owner_userid = intval(Request::input('owner_userid')); // - $project = Project::userProject($project_id, true, true); + $project = Project::userProject($project_id, true, 'primary'); // if (!User::whereUserid($owner_userid)->exists()) { return Base::retError('成员不存在'); } // AbstractModel::transaction(function() use ($owner_userid, $project) { - ProjectUser::whereProjectId($project->id)->change(['owner' => 0]); + // 仅清除原主 owner=1(副 owner=2 保留) + ProjectUser::whereProjectId($project->id) + ->whereOwner(ProjectUser::OWNER_PRIMARY) + ->change(['owner' => 0]); + // 设新主 owner=1(如新主原本是副,从 2 升为 1) ProjectUser::updateInsert([ 'project_id' => $project->id, 'userid' => $owner_userid, ], [ - 'owner' => 1, + 'owner' => ProjectUser::OWNER_PRIMARY, ]); + // 同步项目群 owner_id + if ($project->dialog_id > 0) { + $dialog = WebSocketDialog::find($project->dialog_id); + if ($dialog) { + $dialog->owner_id = $owner_userid; + $dialog->save(); + } + } + // 同步成员 + role(syncDialogUser 已根据 owner 设置 role) $project->syncDialogUser(); $project->addLog("移交项目给", ['userid' => $owner_userid]); }); // - $project->pushMsg('detail'); + // pushMsg 带 deputy_userids,前端可直接更新副列表无需重拉 + $project->pushMsg('detail', [ + 'owner_userid' => $project->fresh()->owner_userid, + 'deputy_userids' => $project->fresh()->deputy_userids, + ]); return Base::retSuccess('移交成功', ['id' => $project->id]); } + /** + * @api {post} api/project/adddeputy 任命副负责人(仅主负责人可操作) + * + * @apiDescription 需要token身份 + * @apiVersion 1.0.0 + * @apiGroup project + * @apiName adddeputy + * + * @apiParam {Number} project_id 项目ID + * @apiParam {Number} userid 要任命的项目成员 userid + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息 + * @apiSuccess {Object} data 返回数据 + */ + public function adddeputy() + { + User::auth(); + $project_id = intval(Request::input('project_id')); + $userid = intval(Request::input('userid')); + + if ($userid <= 0) { + return Base::retError('请选择有效的成员'); + } + + $project = Project::userProject($project_id, true, 'primary'); + + $member = ProjectUser::where('project_id', $project->id) + ->where('userid', $userid)->first(); + if (!$member) { + return Base::retError('该用户不是项目成员'); + } + if ((int)$member->owner === ProjectUser::OWNER_PRIMARY) { + return Base::retError('不能将负责人任命为项目管理员'); + } + if ((int)$member->owner !== ProjectUser::OWNER_DEPUTY) { + AbstractModel::transaction(function() use ($project, $member) { + $member->owner = ProjectUser::OWNER_DEPUTY; + $member->save(); + $project->syncDialogUser(); // 同步群 role + $project->addLog('任命项目管理员', ['userid' => $member->userid]); + }); + $project->pushMsg('detail', [ + 'deputy_userids' => $project->fresh()->deputy_userids, + ]); + } + + return Base::retSuccess('任命成功'); + } + + /** + * @api {post} api/project/deldeputy 罢免副负责人(仅主负责人可操作) + * + * @apiDescription 需要token身份 + * @apiVersion 1.0.0 + * @apiGroup project + * @apiName deldeputy + * + * @apiParam {Number} project_id 项目ID + * @apiParam {Number} userid 要罢免的副负责人 userid + */ + public function deldeputy() + { + User::auth(); + $project_id = intval(Request::input('project_id')); + $userid = intval(Request::input('userid')); + + if ($userid <= 0) { + return Base::retError('请选择有效的成员'); + } + + $project = Project::userProject($project_id, true, 'primary'); + + $member = ProjectUser::where('project_id', $project->id) + ->where('userid', $userid)->first(); + if (!$member) { + return Base::retSuccess('罢免成功'); // 幂等:本来就不是成员 + } + if ((int)$member->owner === ProjectUser::OWNER_DEPUTY) { + AbstractModel::transaction(function() use ($project, $member) { + $member->owner = ProjectUser::OWNER_MEMBER; + $member->save(); + $project->syncDialogUser(); + $project->addLog('罢免项目管理员', ['userid' => $member->userid]); + }); + $project->pushMsg('detail', [ + 'deputy_userids' => $project->fresh()->deputy_userids, + ]); + } + + return Base::retSuccess('罢免成功'); + } + /** * @api {post} api/project/sort 排序任务 * @@ -784,7 +937,7 @@ class ProjectController extends AbstractController // $project_id = intval(Request::input('project_id')); // - $project = Project::userProject($project_id, null, true); + $project = Project::userProject($project_id, null, 'primary'); // $project->deleteProject(); return Base::retSuccess('删除成功', ['id' => $project->id]); @@ -1194,7 +1347,7 @@ class ProjectController extends AbstractController // 任务可见性条件 $builder->leftJoin('project_users', function ($query) use($userid) { $query->on('project_tasks.project_id', '=', 'project_users.project_id'); - $query->where('project_users.owner', 1); + $query->whereIn('project_users.owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY]); $query->where('project_users.userid', $userid); }); $builder->leftJoin('project_task_visibility_users', function ($query) use($userid) { @@ -1839,8 +1992,10 @@ class ProjectController extends AbstractController $isArchived = str_replace(['all', 'yes', 'no'], [null, false, true], $archived); $task = ProjectTask::userTask($task_id, $isArchived, true, ['taskUser', 'taskTag']); // 项目可见性 - $project_userid = ProjectUser::whereProjectId($task->project_id)->whereOwner(1)->value('userid'); // 项目负责人 - if ($task->visibility != 1 && $user->userid != $project_userid) { + $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)) { $taskUserids = ProjectTaskUser::whereTaskId($task_id)->pluck('userid')->toArray(); //任务负责人、协助人 $subTaskUserids = ProjectTaskUser::whereTaskPid($task_id)->pluck('userid')->toArray(); //子任务负责人、协助人 $visibleUserids = ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid')->toArray(); //可见人 @@ -2347,7 +2502,9 @@ class ProjectController extends AbstractController if ($data['visibility'] == 1) { $data['is_visible'] = 1; } else { - $projectOwner = ProjectUser::whereProjectId($task->project_id)->whereOwner(1)->pluck('userid')->toArray(); // 项目负责人 + $projectOwner = ProjectUser::whereProjectId($task->project_id) + ->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY]) + ->pluck('userid')->toArray(); // 项目负责人(主+副) $taskOwnerAndAssists = ProjectTaskUser::select(['userid', 'owner'])->whereTaskId($data['id'])->pluck('userid')->toArray(); $visibleIds = array_merge($projectOwner, $taskOwnerAndAssists); $data['is_visible'] = in_array($user->userid, $visibleIds) ? 1 : 0; @@ -2398,7 +2555,10 @@ class ProjectController extends AbstractController ]); $data = ProjectTask::oneTask($task->id); $pushUserIds = ProjectTaskUser::whereTaskId($task->id)->pluck('userid')->toArray(); - $pushUserIds[] = ProjectUser::whereProjectId($task->project_id)->whereOwner(1)->value('userid'); + $ownerids = ProjectUser::whereProjectId($task->project_id) + ->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY]) + ->pluck('userid')->toArray(); + $pushUserIds = array_merge($pushUserIds, $ownerids); foreach ($pushUserIds as $userId) { $task->pushMsg('add', $data, $userId); } diff --git a/app/Http/Controllers/Api/UsersController.php b/app/Http/Controllers/Api/UsersController.php index 888f87f04..ede4bd66a 100755 --- a/app/Http/Controllers/Api/UsersController.php +++ b/app/Http/Controllers/Api/UsersController.php @@ -2145,6 +2145,65 @@ class UsersController extends AbstractController return Base::retSuccess($id > 0 ? '保存成功' : '新建成功'); } + /** + * @api {post} api/users/department/adddeputy 任命副负责人(限管理员) + * + * @apiDescription 需要token身份 + * @apiVersion 1.0.0 + * @apiGroup users + * @apiName department__adddeputy + * + * @apiParam {Number} id 部门 id + * @apiParam {Number} userid 副负责人 userid + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + */ + public function department__adddeputy() + { + User::auth('admin'); + $id = intval(Request::input('id')); + $userid = intval(Request::input('userid')); + + $dept = UserDepartment::find($id); + if (empty($dept)) { + return Base::retError('部门不存在或已被删除'); + } + + // ApiException 由框架统一捕获并 retError 转换 + $dept->addDeputy($userid); + + Cache::forever("UserDepartment::rand", Base::generatePassword()); + return Base::retSuccess('任命成功'); + } + + /** + * @api {post} api/users/department/deldeputy 罢免副负责人(限管理员) + * + * @apiDescription 需要token身份 + * @apiVersion 1.0.0 + * @apiGroup users + * @apiName department__deldeputy + * + * @apiParam {Number} id 部门 id + * @apiParam {Number} userid 要罢免的副负责人 userid + */ + public function department__deldeputy() + { + User::auth('admin'); + $id = intval(Request::input('id')); + $userid = intval(Request::input('userid')); + + $dept = UserDepartment::find($id); + if (empty($dept)) { + return Base::retError('部门不存在或已被删除'); + } + + $dept->delDeputy($userid); + Cache::forever("UserDepartment::rand", Base::generatePassword()); + return Base::retSuccess('罢免成功'); + } + /** * @api {get} api/users/department/del 删除部门(限管理员) * diff --git a/app/Models/Project.php b/app/Models/Project.php index 3b19fb741..c983801ab 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -77,6 +77,7 @@ class Project extends AbstractModel protected $appends = [ 'owner_userid', + 'deputy_userids', ]; /** @@ -92,6 +93,58 @@ class Project extends AbstractModel return $this->appendattrs['owner_userid']; } + /** + * 副负责人 userid 列表 + * @return array + */ + public function getDeputyUseridsAttribute(): array + { + if (empty($this->id)) { + return []; + } + return ProjectUser::whereProjectId($this->id) + ->whereOwner(ProjectUser::OWNER_DEPUTY) + ->pluck('userid') + ->map(fn($v) => (int)$v) + ->toArray(); + } + + /** + * 是否主负责人(与 project_users.owner=1 一致) + */ + public function isPrimaryOwner($userid): bool + { + if (empty($this->id) || $userid <= 0) { + return false; + } + return ProjectUser::whereProjectId($this->id) + ->whereUserid($userid) + ->whereOwner(ProjectUser::OWNER_PRIMARY) + ->exists(); + } + + /** + * 是否副负责人(与 project_users.owner=2 一致) + */ + public function isDeputyOwner($userid): bool + { + if (empty($this->id) || $userid <= 0) { + return false; + } + return ProjectUser::whereProjectId($this->id) + ->whereUserid($userid) + ->whereOwner(ProjectUser::OWNER_DEPUTY) + ->exists(); + } + + /** + * 是否负责人(主或副) + */ + public function isOwner($userid): bool + { + return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid); + } + /** * @return \Illuminate\Database\Eloquent\Relations\HasMany */ @@ -227,21 +280,40 @@ class Project extends AbstractModel return; } AbstractModel::transaction(function() { - $userids = $this->relationUserids(); + // 拉所有项目成员 + 各自 owner 值 + $userOwnerMap = ProjectUser::whereProjectId($this->id) + ->pluck('owner', 'userid'); + $userids = $userOwnerMap->keys()->map(fn($v) => (int)$v)->toArray(); foreach ($userids as $userid) { + $owner = (int)$userOwnerMap[$userid]; + // 巧合:编码完全一致 owner 0/1/2 → role 0/1/2 + $role = $owner; WebSocketDialogUser::updateInsert([ 'dialog_id' => $this->dialog_id, 'userid' => $userid, ], [ - 'important' => 1 - ], function () use ($userid) { + 'important' => 1, + 'role' => $role, + ], function () use ($userid, $role) { return [ 'important' => 1, + 'role' => $role, 'bot' => User::isBot($userid) ? 1 : 0, ]; }); } - WebSocketDialogUser::whereDialogId($this->dialog_id)->whereNotIn('userid', $userids)->whereImportant(1)->remove(); + WebSocketDialogUser::whereDialogId($this->dialog_id) + ->whereNotIn('userid', $userids) + ->whereImportant(1) + ->remove(); + // 同步 dialog.owner_id 到主负责人(owner=1):前端「群主」标签依赖此字段, + // 必须随项目主负责人变更(含用户离职转移)一起刷新,否则会显示已离职用户 + $primaryUserid = $userOwnerMap->search(ProjectUser::OWNER_PRIMARY); + if ($primaryUserid !== false && (int)$primaryUserid > 0) { + WebSocketDialog::whereId($this->dialog_id) + ->where('owner_id', '!=', (int)$primaryUserid) + ->update(['owner_id' => (int)$primaryUserid]); + } }); } @@ -378,7 +450,7 @@ class Project extends AbstractModel // 处理所有者权限 if (isset($data['owner'])) { $owners = ProjectUser::whereProjectId($data['id']) - ->whereOwner(1) + ->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY]) ->pluck('userid') ->toArray(); $recipients = [ @@ -599,7 +671,7 @@ class Project extends AbstractModel $column['project_id'] = $project->id; ProjectColumn::createInstance($column)->save(); } - $dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project'); + $dialog = WebSocketDialog::createGroup($project->name, $project->userid, 'project', $project->userid); if (empty($dialog)) { throw new ApiException('创建项目聊天室失败'); } @@ -621,7 +693,9 @@ class Project extends AbstractModel * 获取项目信息(用于判断会员是否存在项目内) * @param int $project_id * @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制 - * @param null|bool $mustOwner true:仅限项目负责人, false:仅限非项目负责人, null:不限制 + * @param null|bool|string $mustOwner true:主或副都可(主+副共享操作); + * 'primary':仅主(转让/删除/任命副等主独占操作); + * false:仅限非负责人;null:不限制 * @return self */ public static function userProject($project_id, $archived = true, $mustOwner = null) @@ -639,6 +713,9 @@ class Project extends AbstractModel if ($mustOwner === true && !$project->owner) { throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]); } + if ($mustOwner === 'primary' && (int)$project->owner !== 1) { + throw new ApiException('仅限项目负责人操作', [ 'project_id' => $project_id ]); + } if ($mustOwner === false && $project->owner) { throw new ApiException('禁止项目负责人操作', [ 'project_id' => $project_id ]); } diff --git a/app/Models/ProjectTask.php b/app/Models/ProjectTask.php index 0573f4293..3fe5d7634 100644 --- a/app/Models/ProjectTask.php +++ b/app/Models/ProjectTask.php @@ -1991,7 +1991,9 @@ class ProjectTask extends AbstractModel 'dialog_id' => $this->dialog_id, ]; // - $projectOwnerids = ProjectUser::whereProjectId($this->project_id)->whereOwner(1)->pluck('userid')->toArray(); // 项目负责人 + $projectOwnerids = ProjectUser::whereProjectId($this->project_id) + ->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY]) + ->pluck('userid')->toArray(); // 项目负责人(主+副) // $array = []; if (empty($userids)) { diff --git a/app/Models/ProjectUser.php b/app/Models/ProjectUser.php index e296625fb..9cb830be4 100644 --- a/app/Models/ProjectUser.php +++ b/app/Models/ProjectUser.php @@ -37,6 +37,36 @@ use App\Module\Base; */ class ProjectUser extends AbstractModel { + /** @var int 普通成员编码 */ + const OWNER_MEMBER = 0; + /** @var int 主负责人编码 */ + const OWNER_PRIMARY = 1; + /** @var int 副负责人编码 */ + const OWNER_DEPUTY = 2; + + /** + * 是否主负责人(owner=1) + */ + public function isPrimaryOwner(): bool + { + return (int)$this->owner === self::OWNER_PRIMARY; + } + + /** + * 是否副负责人(owner=2) + */ + public function isDeputyOwner(): bool + { + return (int)$this->owner === self::OWNER_DEPUTY; + } + + /** + * 是否负责人(主或副) + */ + public function isOwner(): bool + { + return $this->isPrimaryOwner() || $this->isDeputyOwner(); + } /** * @return \Illuminate\Database\Eloquent\Relations\HasOne @@ -61,12 +91,19 @@ class ProjectUser extends AbstractModel foreach ($list as $item) { $row = self::whereProjectId($item->project_id)->whereUserid($newUserid)->first(); if ($row) { - // 已存在则删除原数据,判断改变已存在的数据 - $row->owner = max($row->owner, $item->owner); + // 已存在:仅当离职用户是主(owner=1)时把接收人升为主; + // 离职用户是副(owner=2)时不传副给接收人(spec:副不替补) + if ((int)$item->owner === self::OWNER_PRIMARY) { + $row->owner = self::OWNER_PRIMARY; + } + // owner=2/0:保留接收人原有 owner 值不变 $row->save(); $item->delete(); } else { - // 不存在则改变原数据 + // 不存在:转移时如果离职用户是副,降级为普通成员(不带副身份过户给接收人) + if ((int)$item->owner === self::OWNER_DEPUTY) { + $item->owner = self::OWNER_MEMBER; + } $item->userid = $newUserid; $item->save(); } diff --git a/app/Models/UserDepartment.php b/app/Models/UserDepartment.php index cdead0c40..40c2445e8 100644 --- a/app/Models/UserDepartment.php +++ b/app/Models/UserDepartment.php @@ -35,6 +35,10 @@ use Cache; */ class UserDepartment extends AbstractModel { + protected $appends = [ + 'deputy_userids', + ]; + /** * 获取所有父级部门 * @return array @@ -50,6 +54,55 @@ class UserDepartment extends AbstractModel return $parents; } + /** + * 副负责人 userid 列表 + * @return array + */ + public function getDeputyUseridsAttribute(): array + { + if (empty($this->id)) { + return []; + } + return \DB::table('user_department_owners') + ->where('department_id', $this->id) + ->pluck('userid') + ->map(fn($v) => (int)$v) + ->toArray(); + } + + /** + * 是否主负责人(与 owner_userid 一致) + */ + public function isPrimaryOwner($userid): bool + { + if (empty($this->id) || $userid <= 0) { + return false; + } + return (int)$this->owner_userid === (int)$userid; + } + + /** + * 是否副负责人(在 user_department_owners 表里) + */ + public function isDeputyOwner($userid): bool + { + if (empty($this->id) || $userid <= 0) { + return false; + } + return \DB::table('user_department_owners') + ->where('department_id', $this->id) + ->where('userid', $userid) + ->exists(); + } + + /** + * 是否负责人(主或副) + */ + public function isOwner($userid): bool + { + return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid); + } + /** * 保存部门 * @param $data @@ -69,14 +122,25 @@ class UserDepartment extends AbstractModel // 已有群 $dialog = WebSocketDialog::find($this->dialog_id); if ($dialog) { + $oldOwnerId = (int)$dialog->owner_id; $dialog->name = $this->name; $dialog->owner_id = $this->owner_userid; if ($dialog->save()) { $dialog->joinGroup($this->owner_userid, 0, true); + // 同步 role:原主 role=0、新主 role=1(副 role=2 保留不动) + if ($oldOwnerId > 0 && $oldOwnerId !== (int)$this->owner_userid) { + WebSocketDialogUser::where('dialog_id', $dialog->id) + ->where('userid', $oldOwnerId) + ->update(['role' => 0]); + } + WebSocketDialogUser::where('dialog_id', $dialog->id) + ->where('userid', $this->owner_userid) + ->update(['role' => 1]); $dialog->pushMsg("groupUpdate", [ 'id' => $dialog->id, 'name' => $dialog->name, 'owner_id' => $dialog->owner_id, + 'deputy_ids' => $dialog->fresh()->deputy_ids, ]); } } @@ -86,16 +150,33 @@ class UserDepartment extends AbstractModel if (empty($dialog)) { throw new ApiException("选择现有聊天群不存在"); } + $oldOwnerId = (int)$dialog->owner_id; $dialog->name = $this->name; $dialog->owner_id = $this->owner_userid; $dialog->group_type = 'department'; if ($dialog->save()) { $dialog->joinGroup($this->owner_userid, 0, true); + // 同步 role:原主 role=0、新主 role=1、原副 role=0 + // 原副清零:避免 dialog_users.role=2 与 user_department_owners 不一致 + // (副关系不带过来,须通过 addDeputy 显式重新任命) + if ($oldOwnerId > 0 && $oldOwnerId !== (int)$this->owner_userid) { + WebSocketDialogUser::where('dialog_id', $dialog->id) + ->where('userid', $oldOwnerId) + ->update(['role' => 0]); + } + WebSocketDialogUser::where('dialog_id', $dialog->id) + ->where('userid', '!=', $this->owner_userid) + ->where('role', 2) + ->update(['role' => 0]); + WebSocketDialogUser::where('dialog_id', $dialog->id) + ->where('userid', $this->owner_userid) + ->update(['role' => 1]); $dialog->pushMsg("groupUpdate", [ 'id' => $dialog->id, 'name' => $dialog->name, 'owner_id' => $dialog->owner_id, 'group_type' => $dialog->group_type, + 'deputy_ids' => $dialog->fresh()->deputy_ids, ]); WebSocketDialogMsg::sendMsg(null, $dialog->id, 'notice', [ 'notice' => User::nickname() . " 将此群改为部门群" @@ -116,6 +197,12 @@ class UserDepartment extends AbstractModel $oldUser->department = array_diff($oldUser->department, [$this->id]); $oldUser->department = "," . implode(",", $oldUser->department) . ","; $oldUser->save(); + // 原主从 users.department 移除后也要退出部门群(保持成员关系=群关系一致) + // checkDelete=false:业务流程跳过 owner_id/important 校验 + if ($this->dialog_id > 0) { + $dialog = WebSocketDialog::find($this->dialog_id); + $dialog?->exitGroup($oldUser->userid, 'remove', false, true); + } } if ($newUser) { $newUser->department = array_diff($newUser->department, [$this->id]); @@ -126,6 +213,112 @@ class UserDepartment extends AbstractModel }); } + /** + * 任命副负责人 + * - 副自动加入 users.department(成为部门成员,与主对齐) + * - 副自动加入部门群 + 设 role=2 + * - 幂等(已是副不报错) + * + * @param int $userid + * @return void + * @throws ApiException + */ + public function addDeputy($userid) + { + if ($userid <= 0) { + throw new ApiException('请选择有效的成员'); + } + $user = User::whereUserid($userid)->first(); + if (!$user) { + throw new ApiException('该用户不存在'); + } + if ((int)$this->owner_userid === (int)$userid) { + throw new ApiException('不能将部门负责人任命为部门管理员'); + } + + AbstractModel::transaction(function () use ($userid, $user) { + // 写副表(unique key 自动幂等) + \DB::table('user_department_owners')->insertOrIgnore([ + 'department_id' => $this->id, + 'userid' => $userid, + ]); + + // 加入 users.department(成为部门成员,与主对齐) + $userDeptIds = $user->department; // accessor 返回数组 + if (!in_array($this->id, $userDeptIds)) { + $userDeptIds = array_merge($userDeptIds, [$this->id]); + $user->department = "," . implode(",", $userDeptIds) . ","; + $user->save(); + } + + // 加副入部门群 + 设 role=2 + if ($this->dialog_id > 0) { + $dialog = WebSocketDialog::find($this->dialog_id); + if ($dialog) { + // joinGroup($userid, $inviter, $important=null, $pushMsg=true) + $dialog->joinGroup($userid, 0, null, true); + WebSocketDialogUser::where('dialog_id', $dialog->id) + ->where('userid', $userid) + ->update(['role' => 2]); + $dialog->pushMsg('groupUpdate', [ + 'id' => $dialog->id, + 'deputy_ids' => $dialog->fresh()->deputy_ids, + ]); + } + } + }); + } + + /** + * 罢免副负责人 + * - 删副表记录 + * - 从 users.department 移除该部门 ID(与主"离开部门"对齐) + * - 退出部门群(成员关系=群关系一致) + * - 幂等 + * + * @param int $userid + * @return void + */ + public function delDeputy($userid) + { + if ($userid <= 0) { + return; + } + + AbstractModel::transaction(function () use ($userid) { + $deleted = \DB::table('user_department_owners') + ->where('department_id', $this->id) + ->where('userid', $userid) + ->delete(); + + if ($deleted > 0) { + // 从 users.department 移除该部门 ID + $user = User::whereUserid($userid)->first(); + if ($user) { + $userDeptIds = $user->department; + if (in_array($this->id, $userDeptIds)) { + $userDeptIds = array_diff($userDeptIds, [$this->id]); + $user->department = "," . implode(",", $userDeptIds) . ","; + $user->save(); + } + } + + // 退出部门群(exitGroup 会清除 dialog_users 记录,role 随之消失) + if ($this->dialog_id > 0) { + $dialog = WebSocketDialog::find($this->dialog_id); + if ($dialog) { + // checkDelete=false:业务流程跳过 owner_id/important 校验 + $dialog->exitGroup($userid, 'remove', false, true); + $dialog->pushMsg('groupUpdate', [ + 'id' => $dialog->id, + 'deputy_ids' => $dialog->fresh()->deputy_ids, + ]); + } + } + } + }); + } + /** * 删除部门 * @return void @@ -148,6 +341,8 @@ class UserDepartment extends AbstractModel // 解散群组 $dialog = WebSocketDialog::find($this->dialog_id); $dialog?->deleteDialog(); + // 清理副负责人记录(防悬挂) + \DB::table('user_department_owners')->where('department_id', $this->id)->delete(); // $this->delete(); } @@ -160,6 +355,7 @@ class UserDepartment extends AbstractModel */ public static function transfer($originalUserid, $newUserid) { + // 主转让(保持现有逻辑) self::whereOwnerUserid($originalUserid)->chunkById(100, function ($list) use ($originalUserid, $newUserid) { /** @var self $item */ foreach ($list as $item) { @@ -168,6 +364,11 @@ class UserDepartment extends AbstractModel ]); } }); + // 副离职清理(新增):直接删除离职用户的所有副记录 + // 不需要清群 role —— UserTransfer::exitDialog 会把人踢出所有群,role 随成员关系一起消失 + \DB::table('user_department_owners') + ->where('userid', $originalUserid) + ->delete(); } /** diff --git a/app/Models/WebSocketDialog.php b/app/Models/WebSocketDialog.php index fd5923a26..3f1b0e20c 100644 --- a/app/Models/WebSocketDialog.php +++ b/app/Models/WebSocketDialog.php @@ -62,6 +62,8 @@ class WebSocketDialog extends AbstractModel { use SoftDeletes; + protected $appends = ['deputy_ids']; + /** * 头像地址 * @param $value @@ -457,11 +459,12 @@ class WebSocketDialog extends AbstractModel * @param int|array $userid 加入的会员ID或会员ID组 * @param int $inviter 邀请人 * @param bool|null $important 重要人员(null不修改、bool修改) + * @param bool $pushMsg 是否推送消息 * @return bool */ - public function joinGroup($userid, $inviter, $important = null) + public function joinGroup($userid, $inviter, $important = null, $pushMsg = true) { - AbstractModel::transaction(function () use ($important, $inviter, $userid) { + AbstractModel::transaction(function () use ($important, $inviter, $userid, $pushMsg) { foreach (is_array($userid) ? $userid : [$userid] as $value) { if ($value > 0) { $updateData = [ @@ -479,7 +482,7 @@ class WebSocketDialog extends AbstractModel 'bot' => User::isBot($value) ? 1 : 0 ]); }, $isInsert); - if ($isInsert) { + if ($isInsert && $pushMsg) { WebSocketDialogMsg::sendMsg(null, $this->id, 'notice', [ 'notice' => User::userid2nickname($value) . " 已加入群组" ], $inviter, true, true); @@ -487,9 +490,11 @@ class WebSocketDialog extends AbstractModel } } }); - $data = WebSocketDialog::generatePeople($this->id); - $data['id'] = $this->id; - $this->pushMsg("groupUpdate", $data); + if ($pushMsg) { + $data = WebSocketDialog::generatePeople($this->id); + $data['id'] = $this->id; + $this->pushMsg("groupUpdate", $data); + } return true; } @@ -515,11 +520,27 @@ class WebSocketDialog extends AbstractModel foreach ($list as $item) { if ($checkDelete) { if ($type === 'remove') { - // 移出时:如果是全员群仅允许管理员操作,其他群仅群主或邀请人可以操作 + // 移出时:如果是全员群仅允许管理员操作,其他群主/副群主/邀请人可以操作 if ($this->group_type === 'all') { User::auth("admin"); - } elseif (!in_array(User::userid(), [$this->owner_id, $item->inviter])) { - throw new ApiException('只有群主或邀请人可以移出成员'); + } else { + $actor = User::userid(); + // 未认证时拒绝 + if ($actor <= 0) { + throw new ApiException('只有群主或邀请人可以移出成员'); + } + // 主群主、副群主、邀请人可移出 + $allowedActor = $this->isOwner($actor) || $actor === (int)$item->inviter; + if (!$allowedActor) { + throw new ApiException('只有群主或邀请人可以移出成员'); + } + // 副群主不能移出主群主或其他副群主 + if ($this->isDeputyOwner($actor)) { + $targetIsOwner = $this->isPrimaryOwner($item->userid) || $this->isDeputyOwner($item->userid); + if ($targetIsOwner) { + throw new ApiException('群管理员不能移出群主或其他群管理员'); + } + } } } if ($item->userid == $this->owner_id) { @@ -547,9 +568,11 @@ class WebSocketDialog extends AbstractModel }); }); // - $data = WebSocketDialog::generatePeople($this->id); - $data['id'] = $this->id; - $this->pushMsg("groupUpdate", $data); + if ($pushMsg) { + $data = WebSocketDialog::generatePeople($this->id); + $data['id'] = $this->id; + $this->pushMsg("groupUpdate", $data); + } } /** @@ -635,6 +658,53 @@ class WebSocketDialog extends AbstractModel } } + /** + * 是否主群主(与 owner_id 一致) + */ + public function isPrimaryOwner($userid): bool + { + return $userid > 0 && (int)$this->owner_id === (int)$userid; + } + + /** + * 是否副群主(仅 web_socket_dialog_users.role=2) + */ + public function isDeputyOwner($userid): bool + { + if ($userid <= 0) { + return false; + } + return WebSocketDialogUser::where('dialog_id', $this->id) + ->where('userid', $userid) + ->where('role', 2) + ->exists(); + } + + /** + * 是否群主(主或副) + */ + public function isOwner($userid): bool + { + return $this->isPrimaryOwner($userid) || $this->isDeputyOwner($userid); + } + + /** + * 副群主 userid 列表 + * + * @return array + */ + public function getDeputyIdsAttribute(): array + { + if (!$this->id) { + return []; + } + return WebSocketDialogUser::where('dialog_id', $this->id) + ->where('role', 2) + ->pluck('userid') + ->map(fn($v) => (int)$v) + ->toArray(); + } + /** * 检查禁言 * @param $userid @@ -857,6 +927,7 @@ class WebSocketDialog extends AbstractModel WebSocketDialogUser::createInstance([ 'dialog_id' => $dialog->id, 'userid' => $value, + 'role' => ($owner_id > 0 && (int)$value === (int)$owner_id) ? 1 : 0, 'bot' => User::isBot($value) ? 1 : 0, 'important' => !in_array($group_type, ['user', 'all']), 'last_at' => in_array($group_type, ['user', 'department', 'all']) ? Carbon::now() : null, diff --git a/app/Observers/ProjectTaskObserver.php b/app/Observers/ProjectTaskObserver.php index e5655d2aa..c2937cc04 100644 --- a/app/Observers/ProjectTaskObserver.php +++ b/app/Observers/ProjectTaskObserver.php @@ -113,7 +113,9 @@ class ProjectTaskObserver extends AbstractObserver return ProjectUser::whereProjectId($projectTask->project_id)->pluck('userid')->toArray(); } if (in_array('projectOwnerUser', $dataType)) { - return ProjectUser::whereProjectId($projectTask->project_id)->where('owner', 1)->pluck('userid')->toArray(); + return ProjectUser::whereProjectId($projectTask->project_id) + ->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY]) + ->pluck('userid')->toArray(); } $array = []; if (in_array('task', $dataType)) { diff --git a/database/migrations/2026_04_30_000001_add_role_to_web_socket_dialog_users.php b/database/migrations/2026_04_30_000001_add_role_to_web_socket_dialog_users.php new file mode 100644 index 000000000..ce0d91c44 --- /dev/null +++ b/database/migrations/2026_04_30_000001_add_role_to_web_socket_dialog_users.php @@ -0,0 +1,39 @@ +tinyInteger('role')->default(0)->after('userid') + ->comment('0=普通成员 1=主群主 2=副群主'); + $table->index(['dialog_id', 'role'], 'idx_dialog_role'); + } + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('web_socket_dialog_users', function (Blueprint $table) { + if (Schema::hasColumn('web_socket_dialog_users', 'role')) { + $table->dropIndex('idx_dialog_role'); + $table->dropColumn('role'); + } + }); + } +} diff --git a/database/migrations/2026_04_30_000002_backfill_dialog_owner_role.php b/database/migrations/2026_04_30_000002_backfill_dialog_owner_role.php new file mode 100644 index 000000000..90f13382f --- /dev/null +++ b/database/migrations/2026_04_30_000002_backfill_dialog_owner_role.php @@ -0,0 +1,39 @@ + 0 + AND du.userid = d.owner_id + AND du.role = 0 + "); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + $prefix = DB::getTablePrefix(); + // 回滚:把 role=1 的记录全部回到 role=0 + DB::statement("UPDATE {$prefix}web_socket_dialog_users SET role = 0 WHERE role = 1"); + } +} diff --git a/database/migrations/2026_05_01_000001_create_user_department_owners_table.php b/database/migrations/2026_05_01_000001_create_user_department_owners_table.php new file mode 100644 index 000000000..b3bbfc3eb --- /dev/null +++ b/database/migrations/2026_05_01_000001_create_user_department_owners_table.php @@ -0,0 +1,39 @@ +bigIncrements('id'); + $table->unsignedBigInteger('department_id')->comment('部门ID'); + $table->unsignedBigInteger('userid')->comment('副负责人 userid'); + $table->timestamp('created_at')->useCurrent(); + $table->unique(['department_id', 'userid'], 'uniq_dept_user'); + $table->index('userid', 'idx_userid'); + }); + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + if (Schema::hasTable('user_department_owners')) { + Schema::dropIfExists('user_department_owners'); + } + } +} diff --git a/database/migrations/2026_05_02_000001_backfill_project_dialog_primary_owner.php b/database/migrations/2026_05_02_000001_backfill_project_dialog_primary_owner.php new file mode 100644 index 000000000..b59cf4f29 --- /dev/null +++ b/database/migrations/2026_05_02_000001_backfill_project_dialog_primary_owner.php @@ -0,0 +1,67 @@ + 0),主负责人那条 dialog_users.role 仍为 0 + * + * 本迁移仅处理 group_type = 'project' 且未软删的项目: + * (a) dialogs.owner_id = 0 → 按 project_users.owner=1 回填 + * (b) 同一批群里主负责人那条 dialog_users.role = 0 → 设为 1 + * + * 全部带幂等条件,可重跑。 + * + * @return void + */ + public function up() + { + $prefix = DB::getTablePrefix(); + + // (a) 回填 dialogs.owner_id + DB::statement(" + UPDATE {$prefix}web_socket_dialogs d + INNER JOIN {$prefix}projects p ON p.dialog_id = d.id + INNER JOIN {$prefix}project_users pu ON pu.project_id = p.id AND pu.owner = 1 + SET d.owner_id = pu.userid + WHERE d.owner_id = 0 + AND d.group_type = 'project' + AND p.deleted_at IS NULL + "); + + // (b) 把这些项目群里主负责人那条 dialog_users.role 设为 1 + // 不依赖 (a) 的结果,直接按 project_users.owner=1 反查,幂等条件 du.role=0 + DB::statement(" + UPDATE {$prefix}web_socket_dialog_users du + INNER JOIN {$prefix}projects p ON p.dialog_id = du.dialog_id + INNER JOIN {$prefix}project_users pu + ON pu.project_id = p.id + AND pu.userid = du.userid + AND pu.owner = 1 + SET du.role = 1 + WHERE du.role = 0 + AND p.deleted_at IS NULL + "); + } + + /** + * Reverse the migrations. + * + * 数据回填类迁移不提供精确回滚——回滚会丢失原本就正确的数据。 + * 如需重置,请手动操作。 + * + * @return void + */ + public function down() + { + // no-op + } +} diff --git a/language/original-api.txt b/language/original-api.txt index efe364f7e..e31cfeeb2 100644 --- a/language/original-api.txt +++ b/language/original-api.txt @@ -974,3 +974,21 @@ AI 返回内容为空 没有权限操作此任务 请选择要转发的消息 LDAP 用户缺少邮箱属性,请联系管理员配置 +群管理员 +任命群管理员 +罢免群管理员 +该用户不是群成员 +不能将群主任命为群管理员 +仅群主或群管理员可操作 +仅限群主或群管理员操作 +群管理员不能移出群主或其他群管理员 +请选择有效的成员 +任命成功 +罢免成功 +项目管理员 +任命项目管理员 +罢免项目管理员 +该用户不是项目成员 +不能将负责人任命为项目管理员 +不能将部门负责人任命为部门管理员 +该用户不存在 diff --git a/language/original-web.txt b/language/original-web.txt index db088c189..6372ca108 100644 --- a/language/original-web.txt +++ b/language/original-web.txt @@ -2361,3 +2361,26 @@ AI任务分析 登录属性 用于匹配登录用户名的 LDAP 属性,Active Directory 请选择 sAMAccountName 请输入帐号 +群管理员 +任命群管理员 +罢免群管理员 +确定要罢免该群管理员吗? +还没有群管理员 +添加群管理员 +确定将 (*) 任命为群管理员吗? +项目管理员 +任命项目管理员 +罢免项目管理员 +确定要罢免该项目管理员吗? +还没有项目管理员 +添加项目管理员 +确定将 (*) 任命为项目管理员吗? +部门管理员 +任命部门管理员 +罢免部门管理员 +请选择部门管理员 +确定将 (*) 任命为部门管理员吗? +部门管理员享有部门群的群管理员权限 +即将罢免项目管理员 +请确认以下操作,注意此操作不可逆! +移除成员负责的任务将变成无负责人。 diff --git a/resources/assets/js/components/UserAvatar/index.vue b/resources/assets/js/components/UserAvatar/index.vue index 6c2658f17..2b3aef595 100755 --- a/resources/assets/js/components/UserAvatar/index.vue +++ b/resources/assets/js/components/UserAvatar/index.vue @@ -19,6 +19,7 @@
+ {{nameText || user.nickname}}
diff --git a/resources/assets/js/pages/manage/components/DialogGroupInfo.vue b/resources/assets/js/pages/manage/components/DialogGroupInfo.vue index dd4d56299..a7515fb7f 100644 --- a/resources/assets/js/pages/manage/components/DialogGroupInfo.vue +++ b/resources/assets/js/pages/manage/components/DialogGroupInfo.vue @@ -6,7 +6,7 @@
{{dialogData.name}}
@@ -39,31 +39,46 @@ {{$L('群机器人')}}
  • - -
    {{ $L("群主") }}
    -
    + + + + +
  • {{$L(`群成员 (${userList.length}人)`)}}
  • -
  • - -
    {{ $L("群主") }}
    -
    -
  • - - +
  • + + + + +
    +
    +
    +
  • - +
    @@ -152,10 +167,15 @@ export default { } return true; }) + const deputyIds = dialogData.deputy_ids || []; + const rank = uid => { + if (uid === dialogData.owner_id) return 0; + if (deputyIds.includes(uid)) return 1; + return 2; + }; return list.sort((a, b) => { - if (a.userid === dialogData.owner_id || b.userid === dialogData.owner_id) { - return (a.userid === dialogData.owner_id ? 0 : 1) - (b.userid === dialogData.owner_id ? 0 : 1); - } + const ra = rank(a.userid), rb = rank(b.userid); + if (ra !== rb) return ra - rb; return $A.sortDay(a.created_at, b.created_at); }) }, @@ -168,6 +188,17 @@ export default { userList({allList}) { return allList.filter(item => !item.bot) }, + + canManageDeputy() { + // Only the primary owner can manage deputies + return this.dialogData?.owner_id === this.userId; + }, + + isOwnerOrDeputy() { + if (!this.dialogData) return false; + if (this.dialogData.owner_id === this.userId) return true; + return (this.dialogData.deputy_ids || []).includes(this.userId); + }, }, watch: { @@ -214,7 +245,7 @@ export default { if (group_type == 'all') { return this.userIsAdmin } - return [0, this.userId].includes(owner_id) + return [0, this.userId].includes(owner_id) || this.isOwnerOrDeputy }, openAdd() { @@ -243,12 +274,25 @@ export default { }); }, - operableExit(item) { - const {owner_id, group_type} = this.dialogData - if (group_type == 'all') { - return this.userIsAdmin - } - return owner_id == this.userId || item.inviter == this.userId + isPrimaryOwner(item) { + return item.userid === this.dialogData.owner_id; + }, + + isDeputy(item) { + return (this.dialogData.deputy_ids || []).includes(item.userid); + }, + + canKickMember(item) { + if (!this.dialogData) return false; + if (item.userid === this.userId) return false; // can't kick self via this button + const ownerId = this.dialogData.owner_id; + const deputyIds = this.dialogData.deputy_ids || []; + const isPrimary = ownerId === this.userId; + const isDeputy = deputyIds.includes(this.userId); + if (!isPrimary && !isDeputy) return false; // not a manager + if (isPrimary) return item.userid !== ownerId; // primary can kick anyone except self + // deputy: can't kick primary or other deputies + return item.userid !== ownerId && !deputyIds.includes(item.userid); }, onExit(item) { @@ -285,6 +329,52 @@ export default { }); }, + addDeputy(item) { + $A.modalConfirm({ + language: false, + title: this.$L('任命群管理员'), + content: this.$L('确定将 (*) 任命为群管理员吗?', item.nickname || item.email), + onOk: () => { + this.$store.dispatch('call', { + url: 'dialog/group/adddeputy', + data: { + dialog_id: this.dialogData.id, + userid: item.userid, + }, + method: 'post', + }).then(({msg}) => { + $A.messageSuccess(msg); + this.getDialogUser(); + }).catch(({msg}) => { + $A.messageError(msg); + }); + }, + }); + }, + + delDeputy(item) { + $A.modalConfirm({ + title: '罢免群管理员', + content: '确定要罢免该群管理员吗?', + // title/content auto-translated by modalConfig + onOk: () => { + this.$store.dispatch('call', { + url: 'dialog/group/deldeputy', + data: { + dialog_id: this.dialogData.id, + userid: item.userid, + }, + method: 'post', + }).then(({msg}) => { + $A.messageSuccess(msg); + this.getDialogUser(); + }).catch(({msg}) => { + $A.messageError(msg); + }); + }, + }); + }, + openUser(userid) { if (this.openIng) { return diff --git a/resources/assets/js/pages/manage/components/ProjectPanel.vue b/resources/assets/js/pages/manage/components/ProjectPanel.vue index 4dbe8b786..0eef480a8 100644 --- a/resources/assets/js/pages/manage/components/ProjectPanel.vue +++ b/resources/assets/js/pages/manage/components/ProjectPanel.vue @@ -10,7 +10,7 @@