From fd6a8a3650e5ead65f82b9b2c378db740dc811fc Mon Sep 17 00:00:00 2001 From: kuaifan Date: Thu, 4 Jun 2026 03:24:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(user):=20=E4=BC=9A=E5=91=98=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=94=AF=E6=8C=81=E6=9F=A5=E7=9C=8B=E8=AF=A5=E4=BC=9A?= =?UTF-8?q?=E5=91=98=E5=8F=82=E4=B8=8E=E7=9A=84=E9=A1=B9=E7=9B=AE=E5=92=8C?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增权限闸门 UserDepartment::userWorksContext(本人/管理员/部门负责人只读,排除机器人与系统账号) - 新增接口 project/user/projects、project/user/tasks、project/user/counts - users/extra 返回 works_visible 标记控制入口显隐 - 会员卡片新增「项目与任务」入口,弹出 UserWorksModal(项目/待办/已完成三 Tab、角标计数、工作流状态徽章、懒加载) - 部门只读视角下任务仅展示全员可见(visibility=1),与 findForDepartmentView 对齐 - 补充 i18n 文案与暗色样式 Co-Authored-By: Claude Opus 4.8 --- .../Controllers/Api/ProjectController.php | 208 +++++++++++ app/Http/Controllers/Api/UsersController.php | 6 +- app/Models/UserDepartment.php | 65 ++++ language/original-api.txt | 1 + language/original-web.txt | 9 + .../js/pages/manage/components/UserDetail.vue | 48 ++- .../manage/components/UserWorksModal.vue | 328 ++++++++++++++++++ resources/assets/sass/dark.scss | 14 + .../sass/pages/components/user-detail.scss | 226 ++++++++++++ 9 files changed, 902 insertions(+), 3 deletions(-) create mode 100644 resources/assets/js/pages/manage/components/UserWorksModal.vue diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 59ba74b57..d870af84c 100755 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -1490,6 +1490,214 @@ class ProjectController extends AbstractController return Base::retSuccess('success', $data); } + /** + * @api {get} api/project/user/projects 会员参与的项目列表 + * + * @apiDescription 需要token身份。用于会员卡片查看「该会员参与的项目」。 + * 权限:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。 + * @apiVersion 1.0.0 + * @apiGroup project + * @apiName user__projects + * + * @apiParam {Number} userid 目标会员ID + * @apiParam {String} [archived] 是否归档(all/yes/no),默认no + * @apiParam {Object} [keys] 搜索条件(keys.name 项目名称) + * @apiParam {Number} [page] 当前页,默认1 + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据 + */ + public function user__projects() + { + $viewer = User::auth(); + $targetId = intval(Request::input('userid')); + $context = UserDepartment::userWorksContext($viewer, $targetId); + if (!$context['allowed']) { + return Base::retError('没有查看权限'); + } + $readonly = !$context['is_self'] && !$context['is_admin']; + // + $archived = Request::input('archived', 'no'); + $keys = Request::input('keys'); + // + $builder = Project::select(['projects.*', 'project_users.owner', 'project_users.top_at', 'project_users.sort']) + ->join('project_users', function ($join) use ($targetId) { + $join->on('projects.id', '=', 'project_users.project_id') + ->where('project_users.userid', '=', $targetId); + }); + // 部门负责人视角:限定在允许可见的项目集合内 + if ($readonly) { + $builder->whereIn('projects.id', $context['project_ids'] ?: [0]); + } + // + if ($archived == 'yes') { + $builder->whereNotNull('projects.archived_at'); + } elseif ($archived == 'no') { + $builder->whereNull('projects.archived_at'); + } + if (is_array($keys) && !empty($keys['name'])) { + $builder->where('projects.name', 'like', "%{$keys['name']}%"); + } + // + $list = $builder + ->orderByDesc('project_users.top_at') + ->orderBy('project_users.sort') + ->orderByDesc('projects.id') + ->paginate(Base::getPaginate(100, 50)); + $list->transform(function (Project $project) use ($targetId, $readonly) { + $array = $project->toArray(); + $array['department_readonly'] = $readonly; + $array = array_merge($array, $project->getTaskStatistics($targetId)); + return $array; + }); + // + return Base::retSuccess('success', $list); + } + + /** + * @api {get} api/project/user/tasks 会员参与的任务列表 + * + * @apiDescription 需要token身份。用于会员卡片查看「该会员参与的任务」(负责的 / 协作的)。 + * 权限:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。 + * @apiVersion 1.0.0 + * @apiGroup project + * @apiName user__tasks + * + * @apiParam {Number} userid 目标会员ID + * @apiParam {Number} [owner] 任务身份筛选:1=负责的,0=协作的,不传=全部 + * @apiParam {Number} [project_id] 仅查询指定项目 + * @apiParam {Object} [keys] 搜索条件(keys.name 任务名称,keys.status completed/uncompleted) + * @apiParam {Number} [page] 当前页,默认1 + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据 + */ + public function user__tasks() + { + $viewer = User::auth(); + $targetId = intval(Request::input('userid')); + $context = UserDepartment::userWorksContext($viewer, $targetId); + if (!$context['allowed']) { + return Base::retError('没有查看权限'); + } + $readonly = !$context['is_self'] && !$context['is_admin']; + // + $owner = Request::input('owner'); + $owner = is_numeric($owner) ? intval($owner) : null; + $project_id = intval(Request::input('project_id')); + $keys = Request::input('keys'); + $keys = is_array($keys) ? $keys : []; + // + $builder = ProjectTask::with(['taskUser', 'taskTag', 'project:id,name']) + ->select(['project_tasks.*', 'project_task_users.owner']) + ->join('project_task_users', function ($join) use ($targetId) { + $join->on('project_tasks.id', '=', 'project_task_users.task_id') + ->where('project_task_users.userid', '=', $targetId); + }); + if ($owner !== null) { + $builder->where('project_task_users.owner', $owner); + } + // 部门负责人视角:限定可见项目集合,且仅"全员可见"(visibility=1)的任务(与 findForDepartmentView 一致,避免列出打不开的任务) + if ($readonly) { + $builder->whereIn('project_tasks.project_id', $context['project_ids'] ?: [0]); + $builder->where('project_tasks.visibility', 1); + } + if ($project_id > 0) { + $builder->where('project_tasks.project_id', $project_id); + } + if (!empty($keys['name'])) { + $builder->where(function ($query) use ($keys) { + $query->where('project_tasks.name', 'like', "%{$keys['name']}%") + ->orWhere('project_tasks.desc', 'like', "%{$keys['name']}%"); + }); + } + if (!empty($keys['status'])) { + if ($keys['status'] == 'completed') { + $builder->whereNotNull('project_tasks.complete_at'); + } elseif ($keys['status'] == 'uncompleted') { + $builder->whereNull('project_tasks.complete_at'); + } + } + $builder->whereNull('project_tasks.archived_at'); + // + $list = $builder->orderByDesc('project_tasks.id')->paginate(Base::getPaginate(100, 50)); + $list->transform(function (ProjectTask $task) use ($readonly) { + $task->setAppends(['today', 'overdue']); + $array = $task->toArray(); + $array['project_name'] = $array['project']['name'] ?? ''; + $array['department_readonly'] = $readonly; + unset($array['project']); + return $array; + }); + // + return Base::retSuccess('success', $list); + } + + /** + * @api {get} api/project/user/counts 会员参与的项目/任务数量 + * + * @apiDescription 需要token身份。用于会员卡片「项目与任务」弹窗的 Tab 角标,仅返回数量(轻量)。 + * 权限:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。 + * @apiVersion 1.0.0 + * @apiGroup project + * @apiName user__counts + * + * @apiParam {Number} userid 目标会员ID + * @apiParam {Number} [owner] 任务身份筛选:1=负责的,0=协作的,不传=全部(仅影响任务数量) + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data {project, todo, done} + */ + public function user__counts() + { + $viewer = User::auth(); + $targetId = intval(Request::input('userid')); + $context = UserDepartment::userWorksContext($viewer, $targetId); + if (!$context['allowed']) { + return Base::retError('没有查看权限'); + } + $readonly = !$context['is_self'] && !$context['is_admin']; + $owner = Request::input('owner'); + $owner = is_numeric($owner) ? intval($owner) : null; + // + $projectBuilder = Project::join('project_users', function ($join) use ($targetId) { + $join->on('projects.id', '=', 'project_users.project_id') + ->where('project_users.userid', '=', $targetId); + }) + ->whereNull('projects.archived_at'); + if ($readonly) { + $projectBuilder->whereIn('projects.id', $context['project_ids'] ?: [0]); + } + $projectCount = $projectBuilder->distinct()->count('projects.id'); + // + $taskBuilder = function () use ($targetId, $owner, $readonly, $context) { + $builder = ProjectTask::join('project_task_users', function ($join) use ($targetId) { + $join->on('project_tasks.id', '=', 'project_task_users.task_id') + ->where('project_task_users.userid', '=', $targetId); + }) + ->whereNull('project_tasks.archived_at'); + if ($owner !== null) { + $builder->where('project_task_users.owner', $owner); + } + if ($readonly) { + $builder->whereIn('project_tasks.project_id', $context['project_ids'] ?: [0]); + $builder->where('project_tasks.visibility', 1); + } + return $builder; + }; + $todoCount = $taskBuilder()->whereNull('project_tasks.complete_at')->count(); + $doneCount = $taskBuilder()->whereNotNull('project_tasks.complete_at')->count(); + // + return Base::retSuccess('success', [ + 'project' => $projectCount, + 'todo' => $todoCount, + 'done' => $doneCount, + ]); + } + /** * @api {get} api/project/task/easylists 任务列表-简单的 * diff --git a/app/Http/Controllers/Api/UsersController.php b/app/Http/Controllers/Api/UsersController.php index 9228d27e0..85f06e81f 100755 --- a/app/Http/Controllers/Api/UsersController.php +++ b/app/Http/Controllers/Api/UsersController.php @@ -884,7 +884,8 @@ class UsersController extends AbstractController */ public function extra() { - $user = User::auth(); + $viewer = User::auth(); + $user = $viewer; // $userid = intval(Request::input('userid')); if ($userid <= 0) { @@ -919,6 +920,8 @@ class UsersController extends AbstractController $tagMeta = UserTag::listWithMeta($userid, $user); + $worksContext = UserDepartment::userWorksContext($viewer, $userid); + $data = [ 'userid' => $userid, 'birthday' => $birthday, @@ -926,6 +929,7 @@ class UsersController extends AbstractController 'introduction' => $introduction, 'personal_tags' => $tagMeta['top'], 'personal_tags_total' => $tagMeta['total'], + 'works_visible' => $worksContext['allowed'], ]; return Base::retSuccess('success', $data); diff --git a/app/Models/UserDepartment.php b/app/Models/UserDepartment.php index 6ff9d57ec..a529e757d 100644 --- a/app/Models/UserDepartment.php +++ b/app/Models/UserDepartment.php @@ -615,4 +615,69 @@ class UserDepartment extends AbstractModel return $project; } + /** + * 会员卡片「查看该会员项目/任务」的权限上下文。 + * 允许条件:本人 / 系统管理员 / 对该会员具有部门负责人只读视角。 + * @param User $viewer 当前登录用户 + * @param int $targetUserid 目标会员 + * @return array ['allowed'=>bool, 'is_self'=>bool, 'is_admin'=>bool, 'project_ids'=>int[]] + * project_ids 仅在部门负责人视角下有意义(限定可见项目集合);本人/管理员为空数组表示不限制 + */ + public static function userWorksContext(User $viewer, int $targetUserid): array + { + $result = [ + 'allowed' => false, + 'is_self' => false, + 'is_admin' => false, + 'project_ids' => [], + ]; + if ($targetUserid <= 0) { + return $result; + } + // 机器人/系统账号(或不存在)不展示项目与任务 + $target = User::select(['userid', 'bot'])->whereUserid($targetUserid)->first(); + if (empty($target) || $target->bot) { + return $result; + } + // 本人 + if ($viewer->userid === $targetUserid) { + $result['allowed'] = true; + $result['is_self'] = true; + return $result; + } + // 系统管理员 + if ($viewer->isAdmin()) { + $result['allowed'] = true; + $result['is_admin'] = true; + return $result; + } + // 部门负责人只读视角 + if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') { + return $result; + } + $memberUserids = self::getManagedMemberUserids($viewer->userid, 'all'); + if (!in_array($targetUserid, $memberUserids, true)) { + return $result; + } + // 目标会员参与、且未关闭「部门负责人视角可见」的项目 + $projectIds = ProjectUser::where('project_users.userid', $targetUserid) + ->join('projects', 'projects.id', '=', 'project_users.project_id') + ->whereNull('projects.deleted_at') + ->where(function ($query) { + $query->where('projects.department_owner_view', '<>', 'close') + ->orWhereNull('projects.department_owner_view'); + }) + ->distinct() + ->pluck('projects.id') + ->map(fn($v) => intval($v)) + ->values() + ->toArray(); + if (empty($projectIds)) { + return $result; + } + $result['allowed'] = true; + $result['project_ids'] = $projectIds; + return $result; + } + } diff --git a/language/original-api.txt b/language/original-api.txt index 3ca53e9b9..7a6d88e92 100644 --- a/language/original-api.txt +++ b/language/original-api.txt @@ -994,3 +994,4 @@ LDAP 用户缺少邮箱属性,请联系管理员配置 你有一条待办到提醒时间啦 发送者昵称最多不能超过20字 AI 助手 +没有查看权限 diff --git a/language/original-web.txt b/language/original-web.txt index e9629988b..cf770d82d 100644 --- a/language/original-web.txt +++ b/language/original-web.txt @@ -2440,3 +2440,12 @@ AI任务分析 暂无完成 取消提醒 确定取消该成员的提醒时间吗? +项目与任务 +暂无项目 +暂无任务 +负责 +协作 +成员 +(*)分钟前 +(*)小时前 +(*)天前 diff --git a/resources/assets/js/pages/manage/components/UserDetail.vue b/resources/assets/js/pages/manage/components/UserDetail.vue index 4d06ac3e6..78272d25f 100755 --- a/resources/assets/js/pages/manage/components/UserDetail.vue +++ b/resources/assets/js/pages/manage/components/UserDetail.vue @@ -26,8 +26,12 @@
{{ $L(userId == userData.userid ? "我的群组" : "共同群组") }}:{{ $L("(*)个", commonDialog.total) }} + | - {{ $L("最后在线") }}: {{$A.newDateString( userData.line_at, "YYYY-MM-DD HH:mm") || "-"}} + {{ $L("最后在线") }}: {{ lineAtDisplay.text }}
@@ -115,6 +119,13 @@ :total-count="commonDialog.total || 0" @open-chat="onOpenCommonDialogChat" /> + + @@ -124,11 +135,12 @@ import { mapState } from "vuex"; import transformEmojiToHtml from "../../../utils/emoji"; import UserTagsModal from "./UserTagsModal.vue"; import CommonDialogModal from "./CommonDialogModal.vue"; +import UserWorksModal from "./UserWorksModal.vue"; export default { name: "UserDetail", - components: { UserTagsModal, CommonDialogModal }, + components: { UserTagsModal, CommonDialogModal, UserWorksModal }, data() { return { @@ -146,6 +158,7 @@ export default { }, commonDialogShow: false, commonDialogLoading: 0, + worksModalShow: false, }; }, @@ -189,6 +202,36 @@ export default { commonDialogList() { return this.commonDialog.list || []; }, + + worksVisible() { + return !!this.userData.works_visible; + }, + + lineAtDisplay({ userData }) { + const value = userData.line_at; + if (!value) { + return { text: "-", title: "" }; + } + const now = $A.daytz(); + const line = $A.dayjs(value); + const title = line.format("YYYY-MM-DD HH:mm"); + const seconds = now.unix() - line.unix(); + let text; + if (seconds < 60) { + text = this.$L("刚刚"); + } else if (seconds < 3600) { + text = this.$L("(*)分钟前", Math.floor(seconds / 60)); + } else if (seconds < 3600 * 24) { + text = this.$L("(*)小时前", Math.floor(seconds / 3600)); + } else if (seconds < 3600 * 24 * 7) { + text = this.$L("(*)天前", Math.floor(seconds / 86400)); + } else if (line.isAfter(now.clone().subtract(1, "month"))) { + text = line.format("MM-DD HH:mm"); + } else { + text = line.format("YYYY-MM-DD"); + } + return { text, title }; + }, }, methods: { @@ -219,6 +262,7 @@ export default { this.showModal = false; this.tagModalVisible = false; this.commonDialogShow = false; + this.worksModalShow = false; }, onOpenAvatar() { diff --git a/resources/assets/js/pages/manage/components/UserWorksModal.vue b/resources/assets/js/pages/manage/components/UserWorksModal.vue new file mode 100644 index 000000000..41882e71d --- /dev/null +++ b/resources/assets/js/pages/manage/components/UserWorksModal.vue @@ -0,0 +1,328 @@ + + + + + diff --git a/resources/assets/sass/dark.scss b/resources/assets/sass/dark.scss index 036160900..495002873 100644 --- a/resources/assets/sass/dark.scss +++ b/resources/assets/sass/dark.scss @@ -813,4 +813,18 @@ body.dark-mode-reverse { } } } + + .user-works-modal { + .user-works-content { + .works-list { + .works-item { + .works-icon { + > i { + color: #000000; + } + } + } + } + } + } } diff --git a/resources/assets/sass/pages/components/user-detail.scss b/resources/assets/sass/pages/components/user-detail.scss index a48e22194..bad0a16a9 100755 --- a/resources/assets/sass/pages/components/user-detail.scss +++ b/resources/assets/sass/pages/components/user-detail.scss @@ -360,3 +360,229 @@ padding: 12px 0; } } + +// 会员项目与任务弹窗样式 +.user-works-modal { + .ivu-modal-body { + padding: 0 !important; + position: relative; + } + + + .task-owner-filter { + position: absolute; + top: 48px; + right: 12px; + z-index: 10; + } + + .user-works-tabs { + .ivu-tabs-bar { + margin-bottom: 8px; + } + } + + .user-works-content { + + &.tasks { + .works-list { + > div:first-child { + margin-top: 28px; + } + } + } + + .loading-wrapper { + display: flex; + justify-content: center; + align-items: center; + text-align: center; + padding-top: 60px; + padding-bottom: 100px; + } + + .empty-wrapper { + display: flex; + justify-content: center; + align-items: center; + padding-top: 40px; + padding-bottom: 80px; + + .empty-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: 100%; + color: #999; + > i { + opacity: 0.3; + } + } + } + + .works-list { + overflow-y: auto; + max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 310px); + + @media (height <= 900px) { + max-height: calc(var(--window-height) - var(--status-bar-height) - var(--navigation-bar-height) - 180px); + } + + .works-item { + display: flex; + align-items: center; + padding: 12px; + cursor: pointer; + border-radius: 6px; + margin: 4px 0; + transition: background-color 0.2s; + + &:hover { + background-color: #f5f7fa; + } + + .works-icon { + flex-shrink: 0; + margin-right: 12px; + width: 42px; + height: 42px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + color: #ffffff; + + > i { + font-size: 24px; + } + + &.project { + background-color: #6E99EB; + } + + &.task { + background-color: #9B96DF; + + &.completed { + background-color: #c5c8ce; + } + } + } + + .works-info { + flex: 1; + min-width: 0; + + .works-name { + font-size: 14px; + font-weight: 500; + color: #17233d; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-bottom: 4px; + + &.completed { + color: #808695; + text-decoration: line-through; + } + } + + .works-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #808695; + + .role-badge { + flex-shrink: 0; + padding: 0 6px; + line-height: 18px; + border-radius: 3px; + font-size: 12px; + background-color: #f0f2f5; + color: #808695; + + &.owner { + background-color: #e8f4ff; + color: #2d8cf0; + } + + &.deputy { + background-color: #fff3e0; + color: #ff9900; + } + } + + // 工作流状态徽章(与项目面板/收藏列表保持一致) + .flow-name { + flex-shrink: 0; + padding: 0 6px; + line-height: 18px; + border-radius: 3px; + font-size: 12px; + border: 1px solid transparent; + + &.start { + background-color: var(--flow-item-custom-color-10, rgba($flow-status-start-color, 0.1)); + border-color: var(--flow-item-custom-color-10, rgba($flow-status-start-color, 0.1)); + color: var(--flow-item-custom-color-100, $flow-status-start-color); + } + &.progress { + background-color: var(--flow-item-custom-color-10, rgba($flow-status-progress-color, 0.1)); + border-color: var(--flow-item-custom-color-10, rgba($flow-status-progress-color, 0.1)); + color: var(--flow-item-custom-color-100, $flow-status-progress-color); + } + &.test { + background-color: var(--flow-item-custom-color-10, rgba($flow-status-test-color, 0.1)); + border-color: var(--flow-item-custom-color-10, rgba($flow-status-test-color, 0.1)); + color: var(--flow-item-custom-color-100, $flow-status-test-color); + } + &.end { + background-color: var(--flow-item-custom-color-10, rgba($flow-status-end-color, 0.1)); + border-color: var(--flow-item-custom-color-10, rgba($flow-status-end-color, 0.1)); + color: var(--flow-item-custom-color-100, $flow-status-end-color); + } + &.archived { + background-color: var(--flow-item-custom-color-10, rgba($flow-status-archived-color, 0.1)); + border-color: var(--flow-item-custom-color-10, rgba($flow-status-archived-color, 0.1)); + color: var(--flow-item-custom-color-100, $flow-status-archived-color); + } + } + + .works-project, + .works-stat { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .works-time { + flex-shrink: 0; + + &.overdue { + color: #ed4014; + } + } + } + } + + .enter-icon { + flex-shrink: 0; + color: #c5c8ce; + font-size: 16px; + margin-left: 8px; + } + } + } + + .load-more-wrapper { + display: flex; + justify-content: center; + align-items: center; + padding: 12px 0; + } + } +}