1. 页面概览

1.1 是什么

「项目管理」页(对应前端源码 action/web/src/views/apollo/ProjectsPage.vue,页面标题「Apollo 项目管理」,TAD-11 批次 2 平台管理组)是 LightApollo 的项目治理与成员归属管理页:创建项目(如 core-pay)、浏览全量项目列表,并在每行内联展开「项目成员」子表,完成添加成员(从用户下拉 + 角色下拉中选择)与移除成员。项目(projects 表)是 Apollo 各业务资源的归属边界——环境、Git 仓库、同步目标、制品、流水线、监控、告警、安全、配置等模块的资源全部注册在 /api/v1/projects/:id/... 路径下;项目成员(project_members 表)则是「谁能访问该项目名下资源」的数据范围载体。因此本页治理的对象不是单个资源本身,而是「资源往哪个项目里装、谁能进出这个项目」。

本页在整个 Apollo 运维闭环中处于第一层「组织与范围」位置:在使用任意资源类页面之前,通常需要先有一个项目承载,并需要把操作用户登记为该项目的成员。后端对项目作用域资源的访问采用「权限点 + 成员归属」双重门槛(详见 5.4):先由 RequirePerm 校验登录主体具备读/写该类资源的权限点(由平台角色授予,如 project:read/project:write),再经 projectScopeAccessGuard 中间件校验「该主体是此项目的成员(或 super admin 直通)」——两个条件任一不满足即拒绝。而本页的「项目列表/创建项目」自身不带项目作用域(/projects 路径),因此凡具备 project:read/project:write 权限点的用户都能看到全部项目并创建新项目;但要看某个项目的成员、往里加人,就必须先成为该项目的成员。这就形成了一个清晰的第一性约定:项目的第一个成员只能由 super admin(或其他已是成员的操作者)添加,之后成员可以继续拉人

典型使用链路(从零到有、完整闭环):① 平台管理员(super)登录后在本页点「新建项目」,填项目名(如 core-pay)与描述,创建成功提示 项目已创建 id=1;② 列表出现该项目(此时「成员」列显示 0 人);③ 点该项目行的「添加成员」,弹窗中从「用户(user_id)」下拉选一个已存在用户、从「角色(role_id)」下拉选该项目内角色(如 developer),点「添加成员」提交,提示 成员已添加;④ 点项目行展开成员子表,可看到该成员的用户 ID、角色名(role_name(#id))、添加人与加入时间,并可在子表内点「移除」把它请出项目(有确认框);⑤ 此后该成员登录 Apollo 时,即可访问该项目名下带权限点对应的资源(环境/制品/流水线等模块会对它放行成员校验),而非本项目成员则只能看到项目名、不能触碰项目内任何资源。

从「项目作用域」看,本页的治理覆盖面实际上很大:后端把环境管理(/environments)、Git 仓库(/git-repos)、同步目标(/sync-targets)、制品与清理策略(/artifacts/cleanup-policies)、流水线(/pipelines)、监控配置与健康检查(/monitoring-config/health-checks)、告警与事件单(/alert-rules/alert-events/incidents/silences)、安全策略与漏洞(/security-policies/vulnerabilities)、配置模板与密钥(/config-templates/secrets)等大类、几十条端点全部注册在 /api/v1/projects/:id/... 前缀之下(见 server.go 794-981 行路由注册),并统一接受 projectScopeAccessGuard 的成员校验;而期望状态、发布渠道、部署策略、签名者白名单、Spoke Agent 等属于「全局资源」,注册在 /projects 前缀之外,只受权限点约束、不参与项目成员隔离。所以本页「添加成员」这个动作的真实含义,是一次性打开该用户对目标项目下全部模块的数据访问范围——它是高权限操作,源码也在移除时用 confirm 二次确认来降低误操作风险,日常应只交给可信平台管理员与项目负责人执行。

本页只读数据(项目列表、用户下拉、角色下拉、成员列表)全部在同一批 Promise 中并行发起(onMounted 同时触发 fetchProjectsfetchLookupsfetchLookups 内部再串行两个请求),任一失败只在提示条报错、不影响其余部分渲染(如用户下拉失败但项目列表正常)。也就是说页面并不依赖「先有项目才能拉用户」的时序,二者数据各自独立成环,展示层仅在打开弹窗那一刻才把两组下拉与当前行数据拼装到一起。

1.2 核心价值/能力表

能力 说明 对应页面操作
项目列表总览 全量项目按 id 升序展示:ID/名称/描述/创建人/创建时间/成员数/操作七列 页面主体项目列表
一键新建项目 填 name(唯一)+ description(可选)即建项目,名称重复后端 409 拦截 页头「新建项目」按钮
行内展开成员 点击项目行懒加载并内联展开成员子表(用户/角色/添加人/加入时间) 点击项目行
成员选择下拉 添加成员时从 GET /usersGET /roles 取下拉选项,避免手输错误 ID 添加成员弹窗两个下拉
幂等添加成员 同一项目同一用户同一角色重复添加不报错(唯一索引冲突 DoNothing) 「添加成员」提交
移除成员 确认后按 project_members.id 删除成员记录,该用户即失去项目内数据范围 成员子表「移除」按钮
权限点隔离 页面读写分别要求 project:read/project:write,无权限 403 全部操作
全局资源对照 项目作用域资源(环境/制品/流水线/告警/配置等)与全局资源(期望状态/渠道/签名者)在路径上分开,成员隔离只作用于前者 在对应资源页操作观察
成员身份可视化 展开即见「谁 + 什么角色 + 谁拉进来的 + 何时加入」,便于审计项目组成 点击项目行展开子表
独立成员记录 每一条成员登记拥有自增主键,可按记录精确移除,不误伤同项目同名用户的其他角色 成员子表「移除」

1.3 一句话总结

本页是 Apollo 资源归属与数据范围的第一道闸门——建项目、登记成员、指派项目内角色,谁在哪个项目里、担任什么角色在此统一维护,资源级权限由各业务模块在此基础上自理。

2. 访问入口

2.1 路由与菜单

路由 path /apollo/admin/projects
路由 name ApolloAdminProjects
meta.title Apollo 项目管理
父路由 /apollo(组件 ApolloLayoutmeta.title: 'Apollo'
侧边栏入口 ApolloLayout 侧边栏「平台管理」分组下的「项目管理」,位于「API Key 管理」之后、「审计日志」之前
前端源码 action/web/src/views/apollo/ProjectsPage.vue
API 客户端 action/web/src/api/apolloClient.js
路由注册 action/web/src/router/index.js/apollo 子路由 children 下(约 500-505 行),component: ApolloProjectsPage,挂 ApolloLayout

访问方式:登录 Apollo 后从左侧菜单「平台管理 → 项目管理」进入,或直接访问 /apollo/admin/projects。相邻页(侧边栏顺序):API Key 管理 admin-api-keys.md(前)、审计日志 admin-audit-logs.md(后);概念对照:用户管理 admin-users.md(页面用户下拉的数据来源)、角色管理 admin-roles.md(页面角色下拉的数据来源与平台角色语义)。

2.2 认证与权限

2.3 端口与 API 前缀

3. 界面布局

┌────────────────────────────────────────────────────────────────┐
│ Apollo 项目管理                                  [新建项目]    │
│ [alert 操作结果提示条(v-if alert.message,含「关闭」)]        │
├────────────────────────────────────────────────────────────────┤
│ ┌ 新建项目弹窗(modal-overlay + modal-card,@click.self 可关)┐ │
│ │ 新建项目  [关闭(link-btn)]                                 │ │
│ │ 项目名(name)*   <input placeholder「如 core-pay」>        │ │
│ │ 描述(description)<textarea rows=3「项目用途、边界等(可选)」│ │
│ │ [创建项目/创建中...] [取消]                                │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ┌ 项目列表(加载中… / 空态 / 表格三态)────────────────────────┐ │
│ │ 表头:ID | 名称 | 描述 | 创建人 | 创建时间 | 成员 | 操作      │ │
│ │ 行(clickable-row,展开高亮 row-selected):                 │ │
│ │   id  name  描述或-   created_by或-  时间   成员(N人) [添加成员]│ │
│ │ ╞══ 展开行(detail-row, colspan=7)═════════════════════════╡ │ │
│ │ │ 项目成员(N)[inner-table]                               │ │
│ │ │   用户(user_id)| 角色 | 添加人 | 加入时间 | 操作          │ │
│ │ │   (行内 [移除],空态「暂无成员,点击"添加成员"邀请。」)     │ │
│ │ ╞══════════════════════════════════════════════════════════╡ │ │
│ │ ┌ 添加成员弹窗(modal,@click.self 可关)─────────────────┐ │ │
│ │ │ 添加成员到项目 "{name}"  [关闭(link-btn)]               │ │
│ │ │ 用户(user_id)*  <select 请选择用户>                   │ │
│ │ │ 角色(role_id)*   <select 请选择角色>                  │ │
│ │ │ [添加成员/添加中...] [取消]                             │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘

各板块职责:

板块 职责
页头 + 提示条 标题「Apollo 项目管理」、右上「新建项目」主按钮;页头下方为单槽操作结果提示(info/success/error 三型,可「关闭」)
新建项目弹窗 收集 name/description,提交 POST /projects 创建项目
项目列表卡片 加载中/空态/表格三态;每行可点击展开/收起成员明细,行内带「添加成员」按钮
成员展开行 行内嵌成员子表,逐条展示用户/角色/添加人/加入时间,带「移除」按钮与空态文案
添加成员弹窗 从用户/角色下拉选值,提交 POST /projects/:id/members 登记成员

4. 交互元素

4.1 页头、操作结果提示条

控件 位置 含义 必填/默认/可用条件 操作效果 触发后端调用 边界与细节
页面标题 页头 h2 页面身份标识 常驻 Apollo 项目管理 与源码 h2 逐字一致
「新建项目」 页头右上(btn-primary) 打开新建项目弹窗 始终可点(不受 busy 约束) openCreate()createModal.visible=true 仅打开弹窗,不发请求
操作结果提示条 页头下方 展示最近一次操作结果 alert.message 才渲染;类型默认 alert-info success/error 按场景着色展示消息 单槽:新操作结果覆盖旧提示,无历史
提示条「关闭」 提示条右侧(link-btn) 清空当前提示 提示条可见时可用 alert.message='' 立即消失 纯本地状态

页面用一组 ref 维护状态:projects(项目数组)、membersMap{项目id: 成员数组} 字典,只缓存已展开过的项目)、users/roles(两个下拉选项数据)、expandedId(当前展开行项目 id)、loading(列表初次加载位)、busy(全部写操作共享的互斥位)、alert(单槽提示)、createModal/createFormmemberModal/memberForm。所有写操作(创建项目/添加成员/移除成员)共用 busy:任一进行中,两个弹窗的提交按钮与「移除」按钮同时禁用。

4.2 「新建项目」按钮与新建项目弹窗

控件 位置 含义 必填/默认/可用条件 操作效果 触发后端调用 边界与细节
「新建项目」 页头右上 打开新建弹窗 始终可点 弹窗显示,createForm 保持上次值或空 打开时不清空表单(源码未在 openCreate 重置)
弹窗标题/「关闭」 modal-header 标识与关闭弹窗 busy=false 才可用(:disabled="busy" closeModal(createModal)——busy 时拒绝收起并提示「请求进行中,请稍候」 点遮罩 @click.self、头部「关闭」、表单「取消」三处入口统一走 closeModalProjectsPage.vue:211-224),「关闭」按钮带 :disabled="busy":30);已修复(本批):busy 期间弹窗不再能被遮罩/关闭按钮收起(见 8 章)
项目名(name)* 弹窗 form-group(form-input) 项目名称 必填;required + 前端 trim 判空 输入即绑定 createForm.name placeholder 如 core-pay;提交时 trim
描述(description) 弹窗 form-group(textarea rows=3) 项目用途、边界等 选填 输入即绑定 createForm.description placeholder 项目用途、边界等(可选);留空提交为空串
「创建项目」 弹窗(btn-primary,type=submit) 提交创建 busy=false 才可用;提交中按钮文字变 创建中... 成功:提示 项目已创建 id={data.id}(success)、关弹窗、清空表单并刷新项目列表;失败:提示 创建失败:{msg} POST /projects 前端空名拦截提示 项目名必填(error);后端 trim 空名返回 400 项目名不能为空;名称重复返回 409 项目已存在(见 7.1)
「取消」 弹窗(btn-outline) 关弹窗 busy=false 才可用(:disabled="busy" closeModal(createModal) 与「关闭」等价,同样经 closeModal 守卫(ProjectsPage.vue:46

表单字段表(新建项目弹窗):

字段 类型 必填 校验规则 默认 保存逻辑
name 文本 input 前端 trim 判空(提示 项目名必填);后端 trim 空 → 400 项目名不能为空projects.name 唯一索引重复 → 409 项目已存在 POST /projects body {name, description};服务端 CreatedBy=currentUsername(当前登录用户名),CreatedAt 自动写入
description 文本 textarea 空串 同 body 传递;可空

4.3 项目列表与行点击展开

控件 位置 含义 必填/默认/可用条件 操作效果 触发后端调用 边界与细节
列表卡片 页头下方 项目全量列表 loading 时显示 加载中...projects.length===0 显示空态 暂无项目,点击"新建项目"创建。;有数据渲染表格 表格列:ID/名称/描述/创建人/创建时间/成员/操作 首次挂载 GET /projects projectsdata 裸数组(envelope 解包后);行按 p.id 排序展示
行点击 任一项目行(clickable-row) 展开/收起该行成员明细 始终可点 已展开则收起;未展开则 expandedId=p.id,若 membersMap 无该项目缓存则懒加载成员 首次展开才 GET /projects/:id/members 展开行高亮 row-selected#e8f0fe 背景);再次展开同一行不再重复请求(缓存于 membersMap
名称/描述/创建人/创建时间 行内列 项目元信息 只读 描述空显示 -;创建人为 p.created_by(后端写入用户名) 创建时间经 formatTime 展示
「成员」列 行内列 该项目成员数 只读 memberCount(p.id) = membersMap[p.id] 长度,展示 {N} 人 未展开过的项目恒显示 0 人(数据未加载,见 8 章);展开后为真实计数
「添加成员」 操作列(btn-sm btn-outline) 打开该项目的添加成员弹窗 始终可点;@click.stop 阻止触发行展开 openMemberModal(p)memberModal={visible,project:p}memberForm 重置为空 弹窗标题带项目名 添加成员到项目 "{p.name}"

4.4 添加成员弹窗(用户/角色下拉)

控件 位置 含义 必填/默认/可用条件 操作效果 触发后端调用 边界与细节
弹窗标题 modal-header 添加成员到项目 "{name}" 打开时确定 标识目标项目 与源码逐字一致
用户(user_id)* 弹窗 form-group(select) 选择要加入项目的用户 必选(required);首项 请选择用户(disabled,value 空串) memberForm.user_id=选项值 下拉数据来自挂载时的 GET /users 选项文案 {userName(u)}({userId(u)})userId(u) 依次兼容 u.ID || u.id || u.user_iduserName(u) 依次兼容 u.Username || u.username || userId(u)(见 5.4);空选提交前端拦截提示 请选择用户
角色(role_id)* 弹窗 form-group(select) 选择该项目内角色 必选;首项 请选择角色(disabled,value=0) memberForm.role_idv-model.number 数字) 下拉数据来自挂载时的 GET /roles 选项文案 {role_name || name}(#{id})role_id=0(未选)提交前端拦截提示 请选择角色;后端再校验 user_id/role_id 不能为空(见 5.4)
「添加成员」 弹窗(btn-primary,type=submit) 提交成员登记 busy=false;进行中按钮文字变 添加中... 成功:提示 成员已添加(success)、关弹窗、刷新该行成员(fetchMembers(projectId));失败:提示 添加成员失败:{msg} POST /projects/:id/members body {user_id, role_id} 后端幂等(同项目同用户同角色重复添加 DoNothing 不报错);项目/用户/角色任一不存在 → 404(项目不存在/用户不存在/角色不存在
「取消」/「关闭」/遮罩 弹窗 关弹窗 busy=false 才可用(按钮 :disabled="busy" closeModal(memberModal)——busy 时拒绝收起并提示「请求进行中,请稍候」 遮罩 @click.self、头部「关闭」、表单「取消」统一走 closeModalProjectsPage.vue:53/57/90

4.5 成员展开行与「移除」按钮

控件 位置 含义 必填/默认/可用条件 操作效果 触发后端调用 边界与细节
子表标题 展开行 detail-panel 顶部 项目成员({N}) 展开即渲染 标识成员计数 N 与行内「成员」列同源
成员空态 子表内 无成员提示 membersMap[p.id].length===0 显示 暂无成员,点击"添加成员"邀请。 该文案内双引号为源码原样
子表行 inner-table 成员明细 表头:用户(user_id)/角色/添加人/加入时间/操作 逐行展示 用户列为 m.user_id(加粗);added_by 空显示 -
角色列 子表行内 角色展示 只读 roleName(m.role_id) 反查 命中 roles 下拉数据 → {role_name || name}(#{id});未命中(角色已删/数据未载)→ 裸 #{role_id}
加入时间 子表行内 成员登记时间 只读 formatTime(m.created_at) 与项目创建时间同款格式化
「移除」 子表行操作列(btn-sm btn-danger) 移除该项目成员 busy=false 才可用;busy 期间按钮置灰但不触发任何确认(先禁后点无效) 弹原生 confirm('确定移除成员 "{m.user_id}" 吗?');确认后 DELETE,成功提示 成员已移除(success)、失败提示 移除成员失败:{msg}(error),成功/失败均刷新该行成员 DELETE /projects/:id/members/{m.id} 路径传的是 project_members.id(成员记录 id),不是 user_id;记录不存在后端 404 项目成员不存在

4.6 数据加载与会话级细节

页面挂载顺序(onMounted):fetchProjects()(项目列表)与 fetchLookups()(用户/角色下拉)先后同批启动。loading 只控制项目列表卡片的三态切换(加载中.../空态/表格);用户/角色下拉没有自己的加载态,首次打开「添加成员」弹窗时若两个请求尚未返回,下拉只会显示一个「请选择用户/请选择角色」占位项——此时直接提交会被前端守卫拦下(请选择用户/请选择角色)。fetchLookups 内部是串行 await:先 GET /usersGET /roles,且两个请求分别用 try/catch 包裹,谁失败只提示谁,不影响另一个。

busy 是页面唯一的写互斥位:创建项目、添加成员、移除成员三处共用。请求进行中提交按钮置灰并显示进行中文案;由于 Vue 的响应式赋值发生在 await 前后,同一弹窗内连点不会发出两个请求。要留意两个异步窗口:其一,请求在途时弹窗已被 closeModal 守卫锁住(「关闭/取消/遮罩」三处入口在 busy 时直接 return 并提示「请求进行中,请稍候」,且按钮 :disabled="busy",见 8 章)——本批修复后不再出现「弹窗已关但请求仍在提交」以及重开时的「假忙」观感;其二,expandedId 高亮、membersMap 缓存与 busy 互不干扰——移除成员进行中仍可点击其它行展开,只是该行「移除」按钮与「添加成员」提交按钮因 busy 置灰。

另外「成员」列的计数与子表标题、空态文案都取自同一个 membersMap[p.id]:展开过的项目在移除最后一人后,空态文案 暂无成员,点击"添加成员"邀请。 会立即出现;收起重开后计数回到 0,逻辑一致。全部时间列(创建时间/加入时间)展示格式为去掉 T、截取前 19 位的本地化弱化格式(如 2026-08-30 02:15:00),若后端返回空字符串则整格显示 -

4.7 表单提交前校验与通用失败分支

两个表单提交在发请求前各自有前端守卫(handleCreate 检查 name trim 非空否则 项目名必填handleAddMember 检查 user_id 非空否则 请选择用户、role_id 非 0 否则 请选择角色)。所有写操作成功后都会刷新受影响的列表数据:创建项目刷新项目列表、添加/移除成员刷新对应 membersMap[projectId](展开行就地更新,无需收起重开)。所有失败统一走 catch → 提示条 ...失败:{errMsg}errMsg 优先取 err.response.data.message || err.response.data.error || err.message)。由于 busy 是共享互斥位,提交按钮在请求期间变灰且文字切换为 创建中.../添加中...,防止连点重复提交;两个弹窗的「关闭/取消/遮罩」也已由 closeModal 在 busy 时一并锁住(ProjectsPage.vue:211-224,见 8 章)。

5. 后端关联

5.1 API 客户端

action/web/src/api/apolloClient.js:axios 实例 baseURL /apollo-api/v1、timeout 30000ms、请求拦截器对所有请求附加 Authorization: Bearer <token>(token 仅取 apollo_token,不回退 aip_token)。响应拦截器:HTTP 2xx 且 code===0 时把 response.data 解包为业务数据(页面 const { data } = ... 直接得到业务对象/数组);HTTP 2xx 但 code!==0 拒绝;4xx/5xx 提取 body.message || body.error 并把 message 别名写入 error.response.data.error 后 reject——401 清 token 并跳 /apollo/login(登录页自身 401 不跳,避免死循环),403 alert('无权限执行该操作')。页面 catch 统一以 err.response?.data?.error || err.message 兜底取错。

5.2 端点表

方法 路径 权限点 请求体/Query 页面触发点
GET /projects project:read - 加载项目列表(onMounted)
POST /projects project:write body {name, description?} 「创建项目」
GET /projects/:id/members project:read - 首次展开项目行(懒加载)
POST /projects/:id/members project:write body {user_id, role_id} 「添加成员」
DELETE /projects/:id/members/:memberId project:write - 成员子表「移除」
GET /users user:read ?page=&page_size= 挂载加载用户下拉(fetchLookups)
GET /roles role:read - 挂载加载角色下拉(fetchLookups)

注册位置:action/products/apollo/server/server.go protected 组(828-832 行 projects 五条、811/818 行 users/roles);handler 在 handlers.go(handleListProjects/handleCreateProject/handleListProjectMembers/handleAddProjectMember/handleRemoveProjectMember,1625-1725 行)。/projects/:id/... 路径经 protected 组的 projectScopeAccessGuard(718-722 行注册)。

5.3 响应结构示例

GET /projects(envelope 解包后为裸数组)

[
  {
    "id": 1,
    "name": "core-pay",
    "description": "核心支付域:订单/账务/清算",
    "created_by": "admin",
    "created_at": "2026-08-30T02:15:00.123+08:00",
    "updated_at": "2026-08-30T02:15:00.123+08:00"
  }
]

字段含义(access.Projectaccess/models.go):id 自增主键;name 项目名(uniqueIndex,唯一);description 描述(可空);created_by 创建人用户名(后端写 currentUsername);created_at/updated_at 时间戳。列表按 id 升序。

POST /projects(201,envelope 解包后)

{ "id": 1, "name": "core-pay" }

页面据此提示 项目已创建 id=1。创建类端点统一 HTTP 201;重名返回 409(code: 40901,message 项目已存在);空名返回 400(message 项目名不能为空)。

GET /projects/:id/members(envelope 解包后为裸数组)

[
  {
    "id": 5,
    "project_id": 1,
    "user_id": "2a3f9b7c-1c6e-4d4a-8f20-3e5c2d1a0b99",
    "role_id": 3,
    "added_by": "admin",
    "created_at": "2026-08-30T03:00:00+08:00"
  }
]

字段含义(access.ProjectMember):id 成员记录主键(移除时用这个 id);project_id 所属项目;user_id 用户主键字符串(用户表主键为 UUID 字符串);role_id 角色 ID(引用 roles 表,不存角色名);added_by 添加人用户名;created_at 加入时间。表有 (project_id, user_id, role_id) 复合唯一索引,同项目同用户同角色只允许一条。

GET /users(envelope 解包后为分页对象)

{
  "items": [
    {
      "id": "2a3f9b7c-1c6e-4d4a-8f20-3e5c2d1a0b99",
      "username": "alice",
      "is_locked": false,
      "created_at": "2026-08-25T10:00:00+08:00",
      "roles": [ { "id": 3, "role_name": "developer", "permissions": [] } ]
    }
  ],
  "total": 3,
  "page": 1,
  "page_size": 20
}

字段含义(userViewserver/handlers.go 1405 行):id 用户主键字符串;username 用户名;name/email 个人姓名/邮箱(omitempty,可缺省);is_locked 是否锁定;created_atroles 角色数组(omitempty)。注意/users 虽套 {items,total,page,page_size} 分页壳,后端 handleListUsers 始终把全量用户放入 items 并令 total=len(views)page/page_size 只是回显——即无论传不传分页参数,下拉选项都是全量(前端只取 data.items,不做前端分页)。

GET /roles(envelope 解包后为裸数组)

[
  { "id": 1, "role_name": "platform_admin", "permissions": [] },
  { "id": 2, "role_name": "project_admin", "permissions": [] },
  { "id": 3, "role_name": "developer", "permissions": [] }
]

字段含义(model.Role):JSON 键为 role_name(非 name;前端 r.role_name || r.name 双兼容);permissions 权限点数组。角色主键 idProjectMember.role_id 的取值来源。

5.4 关键机制

项目成员归属隔离(projectScopeAccessGuard)。这是「项目作为数据范围边界」的后端执行点:中间件挂在 protected 组(server.go 718-722 行),对命中 /api/v1/projects/:id[/...] 前缀的全部路径解析出项目 id 后执行 checkProjectAccessible——登录主体为 super admin 直接放行;否则查 project_members 表是否登记 (project_id, user_id),未登记返回 403 无权访问项目 {id} 的资源(非项目成员)。因此本页 GET/POST /projects/:id/members 也会被该守卫拦截:非 super 用户想查看/添加某项目成员,自己必须先在该项目里/projects(列表/创建,无 /:id)与 /users/roles 不在该前缀下,不受成员校验,只走权限点。

成员记录主键语义ProjectMember.id(自增)与 user_id(UUID 字符串)是两回事:移除成员时 DELETE /projects/:id/members/:memberId:memberId 是成员记录 id(前端遍历 m.id 传入);后端 RemoveProjectMember 按主键查删,无记录返回 404 项目成员不存在。因此同一用户在不同项目、甚至同项目不同角色都有独立的成员记录 id,删除互不影响。

添加成员的三段校验与幂等。前端先拦截空选(请选择用户/请选择角色)→ handler 校验 body 非空(400 user_id/role_id 不能为空)→ accessSvc.AddProjectMember 依次校验项目/用户/角色存在(任一不存在 404 项目不存在/用户不存在/角色不存在)→ 写入时用 (project_id,user_id,role_id) 复合唯一键 OnConflict DoNothing 幂等(重复添加静默成功、不报错、不新增行)。AddedBy 记录添加人用户名,形成可审计的「谁拉谁进哪个角色」轨迹。

用户下拉的数据兼容(大写/小写键)。源码注释沿用了旧版「/users 返回无 json tag 的 model.User、键为大写」的假设,故 userId()/userName() 分别兼容 u.ID || u.id || u.user_idu.Username || u.username || userId(u)。实测当前后端经 toUserView 输出的小写键 id/username 已被前端读取——双兼容代码保证无论后端将来回到大写结构也不破。

角色下拉与角色反查。成员表只存 role_id;页面挂载时拉一次 GET /roles 全量角色(roles 数组),下拉与子表 roleName() 都从这个缓存反查显示 role_name(#id)。若 role_id 在缓存中找不到(角色已被删除或列表未加载完),子表回退显示裸 #id——页面上「角色变裸 id」通常意味着角色数据源缺失,而非成员数据异常。

懒加载成员与成员计数。列表初次只加载项目;首次点击行才 GET /projects/:id/members 并缓存进 membersMap[projectId],重复展开不再请求。成员计数列与子表标题的 N 都直接取 (membersMap[p.id] || []).length——未展开过行的项目显示 0 人;一旦展开过就显示缓存值(此后增删成员会刷新该缓存)。由于新增/移除都发生在展开态,页面会自动刷新对应项目缓存,计数即时正确。

时间与空值展示约定formatTime(t) 对 RFC3339/ISO 串做 replace('T',' ').replace('Z','').slice(0,19) 展示(如 2026-08-30 02:15:00),不转浏览器本地时区;空值统一显示 -。描述/创建人/添加人空值分别显示 -

角色 id 与全局角色库(跨项目共享)role_id 引用的是平台级角色库(platform/modelmodel.Role,即「角色管理」页维护的那批平台角色,如 platform_admin/project_admin/developer/operator/viewer),项目之间共享同一套角色主键,不存在「每个项目各自一套角色」的概念。因此在本页添加成员时,角色下拉出现的是平台全部角色;给不同项目成员选同一 role_id,展示出来的角色名完全一致。从后端执行看,projectScopeAccessGuard 目前只校验「是否为成员」而不校验成员记录的 role_id——真正决定某成员能读还是能写某类资源的是该用户经 user_roles 全局授予的权限点(由「用户管理」页分配)。也就是说本页下拉里选的「项目内角色」此刻更多是组织语义标签(告诉读者该成员在项目内承担 project_admin/developer 等职责),并未参与按项目细分的读写判定;若产品后续要做「同一资源对不同项目成员按 role 分级放行」,需要在 guard 与各资源 handler 增加 role 维度判定,当前尚未实现(见 8 章)。对照这一机制,日常给成员派角色时建议与实际平台角色保持一致,避免标签与真实权限错位造成误解。

错误码速查。本页涉及的失败统一走 envelope(HTTP 状态码 + code/message),速查如下:

HTTP code message 场景(后端逐字)
400 40001 项目名不能为空(创建空名)、user_id/role_id 不能为空(成员请求体缺字段)、invalid project id(路径 id 非法)
403 40301 无权限执行该操作(权限点不足,拦截器再 alert 同文案)、无权访问项目 N 的资源(非项目成员)(成员校验不过)
404 40401 项目不存在 / 用户不存在 / 角色不存在(添加成员三段校验)、项目成员不存在(移除不存在的成员记录)
409 40901 项目已存在projects.name 唯一冲突)

前端 errMsg() 优先读 err.response.data.message,其次 err.response.data.error(拦截器写入别名),再退 err.message,最后 未知错误

6. 权限与安全

7. 常见问题与排错

以下现象均由 ProjectsPage.vue 的提示分支、后端 handler/accessService 代码与既有测试归纳,均可按步骤复现。

1. 现象:创建项目提示 项目已存在 原因:projects.name 唯一索引,重名时 handler 预检命中返回 409(code 40901,message 项目已存在)。处理:换一个项目名;先在列表确认是否有同名项目。

2. 现象:点「创建项目」提示 项目名必填 原因:name 输入为空或全空格,前端 createForm.name.trim() 判空拦截。处理:填非空项目名(placeholder 示例 如 core-pay);若填了仍报,检查是否误输全角空格——后端 trim 空同样 400 项目名不能为空

3. 现象:点「添加成员」没反应或提示 添加成员失败:... 原因:常见于用户/角色下拉未加载(GET /users/GET /roles 失败、缺 user:read/role:read 权限被 403)、或后端校验项目/用户/角色不存在(404)。处理:先确认 Network 面板 users/roles 两个请求是否 200;确认目标用户已在用户管理页存在、角色在角色管理页存在;若为非 super 账号,还需确认自己已是该项目成员(成员校验 403 无权访问项目 ... 的资源)。

4. 现象:点「移除」后成员还在列表里。 原因:若提示 移除成员失败 通常是 :memberId 传错(应传成员记录 id m.id)或记录已被他人删除(404 项目成员不存在)。处理:核对源码 handleRemoveMemberm.id;刷新展开行确认后端是否真的删除成功。特别提醒confirm 弹窗里展示的是 user_id,但实际删除按成员记录 id 生效——同用户在同一项目有多条角色记录时,删除只去掉被点的那条。

5. 现象:子表「角色」列显示裸 #3 而不是角色名。 原因:roles 下拉数据里找不到该 role_id——角色被删除,或 GET /roles 未加载成功(权限 403 / 接口失败被 catch)。处理:刷新页面重载 roles;到角色管理页确认该角色仍存在;角色删除后遗留成员会一直显示裸 id(见 8 章)。

6. 现象:未展开的项目行「成员」列恒显示 0 人,展开后数量才对。 原因:成员数是前端 membersMap 缓存的长度,行未展开时从未请求成员接口。处理:点击行展开即懒加载显示真实数量;这是页面既有的展示边界(见 8 章),不是数据丢失——到 GET /projects/:id/members 可核对真实成员。

7. 现象:下拉里找不到刚在用户管理页新建的用户。 原因:fetchLookups 只在页面挂载(onMounted)时拉一次 users/roles;新建用户后本页的下拉还是旧快照。处理:刷新页面重新进入;无手动「刷新下拉」按钮(见 8 章)。

8. 现象:明明有 project:write 权限,操作某个项目仍被 403。 原因:命中项目成员归属校验——非 super 用户对 /:id 项目资源还必须是该项目成员。处理:由 super 或现有成员先把你加进该项目;或确认当前是否有成员校验 403 的 message(无权访问项目 N 的资源(非项目成员))。

9. 现象:页面只能看到项目列表,点任何写按钮都弹 无权限执行该操作 原因:当前登录主体缺 project:write(或 user:read/role:read)权限点,RequirePerm 返回 403,拦截器统一弹窗提示。处理:到「用户管理」页给该用户授予含对应权限点的平台角色(如 project_admin 通常含项目读写),刷新页面重试。

10. 现象:另一位管理员刚加了成员,本页展开后计数对不上。 原因:membersMap 是前端会话级缓存,只在首次展开与本次增删操作后刷新;他人通过另一会话/另一入口(如直接调 API)改动成员时本页不会感知。处理:收起再重新展开该行会重新请求一次;或整页刷新(见 8 章「缓存过期」)。

8. 已知缺陷与边界

缺陷/边界 说明
无项目编辑/删除入口 页面只有创建与成员管理,不支持重命名、改描述、删除项目(后端 DELETE /projects/:idPUT /projects/:id 有路由但页面无入口)
成员角色不可改 改角色需先移除、再按新角色重新添加(无「编辑成员」弹窗)
成员数初始为 0 未展开行不拉成员接口,「成员」列先显示 0 人、展开后才是真实值;列表刷新不会自动预热所有行
用户下拉全量无搜索 /users 实为全量返回(分页参数仅回显),用户量大时下拉项多、无搜索框;/users 返回全量是后端既有的分页形制问题
角色遗留显示裸 id role_idroles 数据缺失(角色删除/接口失败)时只显示 #id,无兜底提示
弹窗 busy 期可关闭 本批已修复:新增统一关闭函数 closeModal(m)(busy 时直接 return 并提示「请求进行中,请稍候」),新建项目/添加成员两个弹窗的遮罩、头部「关闭」、表单「取消」三处入口全部改调该函数,「关闭」「取消」按钮加 :disabled="busy"action/web/src/views/apollo/ProjectsPage.vue:211-224 定义、:26/30/46/53/57/90 入口)——写请求进行中弹窗不可再收起,消除「弹窗已关但仍在提交」的误解
行展开与添加无互斥 展开成员行时若成员接口慢,期间点击其它行会把 expandedId 切走,先前懒加载结果仍会写回 membersMap(无竞态但列表高亮与缓存更新时机不同步)
时间展示为 UTC 截断 创建时间/加入时间按 slice(0,19) 截断展示,不转本地时区
成员身份即数据范围 项目成员登记只影响后端成员隔离判定,具体资源能否读写仍取决于权限点与各业务模块自己的行级逻辑
role_id 不参与读写判定 checkProjectAccessible 只统计成员存在性,不看成员记录的 role_id;「项目内角色」当前为组织语义标签,读写仍按用户全局权限点放行,项目内按角色分级尚未落地
成员缓存会话级过期 membersMap 只在本会话的展开/增删后刷新,其它会话或直调 API 的成员变动在本页不自动感知,计数可能陈旧(重开行或整页刷新可更新)
项目列表无分页/搜索 GET /projects 一次返回全量(id 升序),项目多时列表无搜索与分页;/users 下拉同样全量无搜索
并发写无行级乐观锁 两人同时移除同一成员:先到者成功,后者收到 404 项目成员不存在(前端提示移除失败),属预期并发语义,非数据损坏
关闭弹窗不清表单 openCreate/openMemberModal 打开时未显式重置全部历史输入(创建成功路径会重置,但取消后残留上一次值),再次打开可见旧值

附录 A:速查——页面文案与关键常量清单

场景 源码取值/文案(逐字)
页面标题 Apollo 项目管理
页头按钮 新建项目
新建弹窗标题/主按钮 新建项目 / 创建项目(提交中 创建中...
项目名 label/placeholder 项目名(name)* / 如 core-pay
描述 label/placeholder 描述(description) / 项目用途、边界等(可选)
空名前端提示 项目名必填
创建成功提示 项目已创建 id={id}
创建失败提示 创建失败:{msg}
列表空态 暂无项目,点击"新建项目"创建。
成员空态 暂无成员,点击"添加成员"邀请。
成员计数格式 {N} 人(子表标题 项目成员({N})
添加成员弹窗标题 添加成员到项目 "{name}"
用户下拉 label/占位 用户(user_id)* / 请选择用户
角色下拉 label/占位 角色(role_id)* / 请选择角色
空选提示 请选择用户 / 请选择角色
添加按钮 添加成员(提交中 添加中...
添加成功提示 成员已添加
添加失败提示 添加成员失败:{msg}
移除确认文案 确定移除成员 "{user_id}" 吗?
移除成功提示 成员已移除
移除失败提示 移除成员失败:{msg}
成员加载失败提示 加载项目 #{id} 成员失败:{msg}
列表加载失败提示 加载项目列表失败:{msg}
下拉加载失败提示 加载用户列表失败:{msg} / 加载角色列表失败:{msg}
权限点 project:read(列表/成员读)、project:write(创建/成员写)、user:read(用户下拉)、role:read(角色下拉)