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 同时触发 fetchProjects 与 fetchLookups;fetchLookups 内部再串行两个请求),任一失败只在提示条报错、不影响其余部分渲染(如用户下拉失败但项目列表正常)。也就是说页面并不依赖「先有项目才能拉用户」的时序,二者数据各自独立成环,展示层仅在打开弹窗那一刻才把两组下拉与当前行数据拼装到一起。
1.2 核心价值/能力表
| 能力 | 说明 | 对应页面操作 |
|---|---|---|
| 项目列表总览 | 全量项目按 id 升序展示:ID/名称/描述/创建人/创建时间/成员数/操作七列 | 页面主体项目列表 |
| 一键新建项目 | 填 name(唯一)+ description(可选)即建项目,名称重复后端 409 拦截 | 页头「新建项目」按钮 |
| 行内展开成员 | 点击项目行懒加载并内联展开成员子表(用户/角色/添加人/加入时间) | 点击项目行 |
| 成员选择下拉 | 添加成员时从 GET /users、GET /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(组件 ApolloLayout,meta.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 认证与权限
- 路由
requiresAuth: true:未登录访问被前端路由守卫拦到/apollo/login(登录态为独立apollo_token,2026-09-12 修订后仅认apollo_token,不再回退读取旧aip_token)。 - 全部本页端点位于
server.go的 protected 组,先经access.Authn解析 Bearer JWT / X-API-Key 注入主体,再逐条RequirePerm校验权限点:GET /projects、GET /projects/:id/members→PermProjectRead(project:read);POST /projects、POST /projects/:id/members、DELETE /projects/:id/members/:memberId→PermProjectWrite(project:write);- 用户下拉
GET /users→PermUserRead(user:read);角色下拉GET /roles→PermRoleRead(role:read)。
- 成员归属二次门槛:
/projects/:id/...路径资源还会命中projectScopeAccessGuard,非 super 主体必须是该项目成员,否则 403(详见 5.4)。 - 401(apollo_token 失效)由响应拦截器清 token 并跳
/apollo/login;403 统一alert('无权限执行该操作'),页面把err.response?.data?.error || err.message展示到提示条。
2.3 端口与 API 前缀
- Apollo 后端端口 18082。Vite 开发代理把
/apollo-api前缀 rewrite 为/api转发(后端路由注册在/api/v1),客户端 baseURL/apollo-api/v1。 - 统一响应 envelope
{code,message,data,request_id}:拦截器在code===0时把response.data解包为业务数据,页面const { data } = await apiClient.get(...)直接拿业务数组/对象。 - 本页六个端点的业务数据形态:
/projects、/projects/:id/members、/roles为裸数组;/users为{items,total,page,page_size}分页对象(后端实为全量返回,见 5.3 备注);创建类端点返回{id, ...}对象。
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/createForm、memberModal/memberForm。所有写操作(创建项目/添加成员/移除成员)共用 busy:任一进行中,两个弹窗的提交按钮与「移除」按钮同时禁用。
4.2 「新建项目」按钮与新建项目弹窗
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 「新建项目」 | 页头右上 | 打开新建弹窗 | 始终可点 | 弹窗显示,createForm 保持上次值或空 | 无 | 打开时不清空表单(源码未在 openCreate 重置) |
| 弹窗标题/「关闭」 | modal-header | 标识与关闭弹窗 | busy=false 才可用(:disabled="busy") | closeModal(createModal)——busy 时拒绝收起并提示「请求进行中,请稍候」 | 无 | 点遮罩 @click.self、头部「关闭」、表单「取消」三处入口统一走 closeModal(ProjectsPage.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 | projects 为 data 裸数组(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_id,userName(u) 依次兼容 u.Username || u.username || userId(u)(见 5.4);空选提交前端拦截提示 请选择用户 |
| 角色(role_id)* | 弹窗 form-group(select) | 选择该项目内角色 | 必选;首项 请选择角色(disabled,value=0) | memberForm.role_id(v-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、头部「关闭」、表单「取消」统一走 closeModal(ProjectsPage.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 /users 再 GET /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.Project,access/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
}
字段含义(userView,server/handlers.go 1405 行):id 用户主键字符串;username 用户名;name/email 个人姓名/邮箱(omitempty,可缺省);is_locked 是否锁定;created_at;roles 角色数组(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 权限点数组。角色主键 id 即 ProjectMember.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_id 与 u.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/model 的 model.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. 权限与安全
- 认证分层:全部端点 protected,
access.Authn解析 JWT 或 API Key;无效凭据 401,前端清 token 跳登录页。 - 权限点门禁:
project:read/project:write管项目本体与成员读写,user:read/role:read管下拉数据;由内置平台角色(platform_admin/project_admin/developer/operator/viewer 等)按 RBAC 授予。 - 成员归属隔离:项目作用域资源叠加
projectScopeAccessGuard,super 直通、非成员 403——页面自身可被只读角色打开,但非项目成员的操作会被成员校验拦下。 - 写操作防护:移除成员前原生
confirm二次确认(提示将被移除的用户 id);所有写操作共享busy互斥防连点;创建项目对 name 做前后端双重非空校验。 - 数据暴露边界:项目列表与成员下拉会对具备权限点的登录用户返回用户全名/角色名等身份信息,属平台管理范畴的正常读取;
/users返回的 userView 已剔除密码哈希与登录失败计数等敏感列。
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 项目成员不存在)。处理:核对源码 handleRemoveMember 传 m.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/:id、PUT /projects/:id 有路由但页面无入口) |
| 成员角色不可改 | 改角色需先移除、再按新角色重新添加(无「编辑成员」弹窗) |
| 成员数初始为 0 | 未展开行不拉成员接口,「成员」列先显示 0 人、展开后才是真实值;列表刷新不会自动预热所有行 |
| 用户下拉全量无搜索 | /users 实为全量返回(分页参数仅回显),用户量大时下拉项多、无搜索框;/users 返回全量是后端既有的分页形制问题 |
| 角色遗留显示裸 id | role_id 在 roles 数据缺失(角色删除/接口失败)时只显示 #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(角色下拉) |