feat: 优化web

This commit is contained in:
2026-04-23 18:58:13 +08:00
commit 544a2f3428
160 changed files with 27327 additions and 0 deletions
@@ -0,0 +1,568 @@
---
name: Next 全量对接 Go API
overview: 全栈对接 Go APIOAuth2 Code + PKCE**401/403 与 200+`code` 分工**(**含:密码错=401,只看状态**);JSON 信封;login 的 401 不 refreshdev 跨域;登出多 Tabauth/login 改造 §3;租户关 Tabs;路由守卫。
todos:
- id: init-next-env
content: 初始化 Next+TS+Prettier,配置 NEXT_PUBLIC_API_ORIGIN 与目录结构(api/、stores/
status: completed
- id: api-client-auth
content: 实现 apiClientJSON + BearerOAuth token 用 form);auth/login 取 code 再 oauth/tokenlogoutZustand useAuthStore
status: completed
- id: go-auth-login-pkce
content: Go:改造 POST /api/v1/auth/login 密码通过后签发 PKCE 绑定 code,与 CreateAuthorizationCode+token 复用;文档 oauth-v2 对齐
status: completed
- id: api-iam-system
content: 按 http_register 补齐 iamtenant/dept/role/user/menu)与 system/param 全部封装
status: completed
- id: oauth-pkce-ui
content: 实现 OAuth2 PKCE 授权链接、callback 页、换 token 与 token 刷新策略
status: completed
- id: cors-cookie
content: 与 Go 对齐 CORS、Cookie SameSite/credentials;生产 HTTPS 检查清单
status: completed
- id: layout-shell-nav
content: AppShell;顶栏用户下拉;侧栏后端 nav;经典/图标+localStoragepath 映射路由(首版 web 为顶栏+入口,侧栏与双模式待迭代)
status: completed
- id: workbench-tabs-content
content: 主工作区 Tabs+页签状态+内容区(首版 TabStrip 骨架;右键/滚动钮/标准列表/左树右表待迭代)
status: completed
isProject: false
---
# Next.js 前端全量对接 Go 后端方案
## 后端路由清单(对接范围)
以下均基于当前仓库中的注册代码([`internal/auth/http_register.go`](internal/auth/http_register.go)、[`internal/iam/http_register.go`](internal/iam/http_register.go)、[`internal/system/http_register.go`](internal/system/http_register.go)、[`internal/server/http.go`](internal/server/http.go))。
| 前缀 | 说明 |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `GET /health` | 探活 |
| **根路径 OAuth** | `GET /oauth/authorize``POST /oauth/token``POST /oauth/introspect`**无** `/api/v1` 前缀) |
| **`/api/v1`** | 全组挂载 Bearer 中间件(无 token 仍放行,见 [`internal/auth/middleware/bearer.go`](internal/auth/middleware/bearer.go) |
**`/api/v1` 下具体接口:**
- **Auth**`POST /api/v1/auth/login``POST /api/v1/auth/logout`
- **IAM**[`internal/iam/http_register.go`](internal/iam/http_register.go)):`/api/v1/iam/tenant/*``/dept/*``/role/*``/user/*``/menu/*`(含 `tree``nav``perms` 等)
- **System**`/api/v1/system/param/*`create/update/delete-batch/get/list
前端需要为上述路径提供 **baseURL 配置**(例如 `NEXT_PUBLIC_API_ORIGIN=http://127.0.0.1:8000`),并区分两类调用:
- **业务 JSON API**`fetch(\`${origin}/api/v1/...\`)`
- **OAuth**`fetch(\`${origin}/oauth/token\`, …)`等(表单`application/x-www-form-urlencoded`
```mermaid
flowchart LR
subgraph next [Nextjs]
Pages[页面与Radix组件]
Store[Zustand状态]
Client[api客户端层]
end
subgraph go [Go]
Health["/health"]
OAuth["/oauth/*"]
API["/api/v1/*"]
end
Pages --> Store
Store --> Client
Client --> Health
Client --> OAuth
Client --> API
```
---
## 架构建议
### 1. 环境与请求基址
- 使用 `NEXT_PUBLIC_API_ORIGIN`(仅 scheme+host+port**不要**带尾部 `/api/v1`,便于拼 `/api/v1``/oauth`)。
- **部署****上线后** Next 与 Go **同一站点/同域**(或反代为同源),Cookie(若仍用于 authorize 兼容)与 CSRF 更简单。**开发阶段** 常为 **前端 localhost:3000 + 后端 :8000 跨域**Go 必须配置 **CORS 白名单**(具体 `http://localhost:3000` 等),`Access-Control-Allow-Origin` **禁止 `*`** 若带 `credentials`Bearer 主路径下跨域以 **`Authorization`** 为主,但仍需为 **预检 OPTIONS****错误 JSON** 配好 CORS。
### 2. HTTP 客户端层(对接「所有接口」的核心)
- 封装单一 **`apiClient`**`fetch``ky`/`axios` 二选一),职责:
- 统一 `baseURL``Content-Type: application/json`OAuth token 端点单独走 **form-urlencoded**)。
- 从 Zustand(或内存)读取 **access_token**,对 `/api/v1/**` 自动加 `Authorization: Bearer <opaque>`
- **HTTP 401****先 refresh**(见 §3),失败再 **弹窗****HTTP 200** 时读 **`body.code`** 判断业务成败(见 §「JSON 统一响应」)。
- 可选:`X-Tenant-ID` 等与后端 [`internal/iam/handler/helpers.go`](internal/iam/handler/helpers.go) 兼容。
- **按领域拆模块**(与后端包对齐,便于维护):
- `api/auth.ts` — login/logout
- `api/oauth.ts` — token、(若前端自己做 PKCEauthorize URL 构造
- `api/iam/tenant.ts``dept.ts``role.ts``user.ts``menu.ts`
- `api/system/param.ts`
这样「对接所有接口」= **模块方法覆盖上表每一条路由**,类型用 TypeScript 手写 DTO(后端暂无统一 OpenAPI 时可从 handler 结构体对齐,后续可加 swagger 生成)。
**JSON 统一响应(`/api/v1/**`业务接口,含改造后的`auth/login`\*\*
**HTTP 状态码(已定)**
| HTTP | 含义 |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **`401`** | **认证失败**:未登录、token 无效/过期、**以及 `POST /api/v1/auth/login` 账号密码错误(凭据不成立)**等。此类场景 **只看 HTTP 状态即可**;**不必**再依赖响应体 `code` 区分是否密码错误(体可仍带统一信封或极简 JSON,供文案时选用)。 |
| **`403`** | **已认证但无权限访问**该资源或操作(**无全权限/禁止访问**)。 |
| **`200`** | **其余业务层响应**(含非认证类校验失败、参数错误、业务规则不满足等)。**具体失败类型看响应体 `code`**。**认证类失败(含密码错误)不归入此类**,见上 **`401`**。 |
| **`5xx`** | **服务端异常**(可选)仍用 **5xx**,可与信封并存或单独约定;**不**要求强行改成 200。 |
**响应体信封**
| 字段 | 说明 |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`code`** | **业务码****`200` 表示业务成功**;非 `200` 表示**在 HTTP=200 前提下**的业务失败(参数错、业务规则不满足等)。**凭据/认证问题已用 HTTP `401` 表达时,前端以 HTTP 为准**,可不解析 `code`。 |
| **`msg`** | 文案,供 toast。 |
| **`data`** | 成功时为载荷;失败时 **`null` 或省略**。 |
- 请求可传 **`state`**,成功时经 **`data` 回显**。
- **`/oauth/token`**、**`/oauth/introspect`** 等 OAuth 端点可仍按 **RFC****以 `docs/oauth-v2.md` 为准**(可与业务信封并存)。
**前端 `apiClient`**:先读 **HTTP**;若为 **`401`** → **若请求不是「正在调 `/auth/login`」**则 **先 refresh**,失败再弹窗;**若 401 来自 `POST /auth/login`(含密码错误)** → **不 refresh**,直接 **弹窗或提示**(与 token 无关)。**`403`** → toast。**`200`** → 解析 **`body.code`**。
### 3. 登录与鉴权策略
**Cookie 登录 vs OAuth2 Code + PKCE(概念)**
- **Cookie 会话**`Set-Cookie` 会话 id,后续请求自动带 Cookie。
- **OAuth2 Code + PKCE**:用 **`code_verifier` / `code_challenge(S256)`** 绑定授权码,再通过 **`POST /oauth/token`**`grant_type=authorization_code`)换 **opaque `access_token`**;业务请求 **`Authorization: Bearer`**。见 [`docs/oauth-v2.md`](docs/oauth-v2.md)。
**现状与目标(后端)**
- **现状(偏差)**[`internal/auth/handler/login.go`](internal/auth/handler/login.go) 中 `POST /api/v1/auth/login` 仅校验账号密码并下发 **Session Cookie**,返回 `{"ok":true}`**未**纳入 PKCE,也**未**与 [`internal/auth/oauth2/service.go`](internal/auth/oauth2/service.go) 的授权码/token 管线统一。
- **目标(已定需求)****`/api/v1/auth/login` 也必须走 OAuth2 Code + PKCE 语义**——即:账号密码验证成功后,**签发可与现有 `/oauth/token` 交换的 authorization_code(且绑定 PKCE**,前端仍用 **同一套 `POST /oauth/token`** 换 Bearer**禁止**仅靠 Session Cookie 作为 SPA 主鉴权路径(Session 若保留,仅作与 `/oauth/authorize` 浏览器跳转兼容的可选项,可逐步弱化)。
**`client_id` / `redirect_uri` 是什么?为何常和「种子 SPA」绑在一起?**
- **`client_id`**OAuth2 里标识 **「哪一个客户端应用」**(例如浏览器里的 SPA)。授权服务器(Go)在库里 **登记过** 的 `client_id` 才合法。
- **`redirect_uri`**:换发 **`authorization_code` 之后**,用户(或纯 API 流程)**最终要把 `code` 送到的回调地址**。为防止劫持,**必须与该 `client_id` 在服务端登记的允许列表一致**`ParseRedirectURIs` / `RedirectURIMatch`)。
- **「种子 SPA」**:指迁移/安装时 **预置的一条 OAuth 客户端**(如 `client_id=spa``redirect_uri` 含 `http://localhost:3000/oauth/callback` 与生产 `https://app.example.com/oauth/callback`)。**前端在 dev/prod 可共用同一 `client_id`**,只要 **各环境 `redirect_uri` 都已登记**;若种子只含一条 URI,开发跨域时需在 **后端配置/数据库** 里 **补登记** `localhost` 回调,否则 token 交换会报 `invalid_redirect_uri`。
**推荐后端形态(与现有 Token 端复用)**
1. 扩展 `POST /api/v1/auth/login` 请求体:除 `user_name`、`password`、`tenant_id` 外,携带 **`code_challenge`、`code_challenge_method=S256`、`client_id`、`redirect_uri`****可选 `state`**(成功时在响应 `data` 中原样返回)。须通过 **与 `/oauth/authorize` 相同**的 `client_id` / `redirect_uri` 校验。
2. 校验通过后,写入 **与 authorize 相同**的 authorization_code(同一 `oauth2.Store`、PKCE 绑定)。**HTTP 200**body`{ "code": 200, "msg": "操作成功", "data": { "authorization_code": "<code>", "state": "<echo>" } }`**`authorization_code` 与 `/oauth/token` 的 `code` 同义**)。
3. **`/api/v1/auth/login` 密码错误****`HTTP 401`**(认证失败),前端 **只认状态码** 走统一 401 处理链(与 token 失效同类;**不在此用 `HTTP 200` + `body.code` 表示密码错**)。**参数不合法等非认证问题**:**`HTTP 200`** + `code`≠200 等业务码(与上表一致)。
4. 前端拿到 `authorization_code` 后 **`POST /oauth/token`**form)换 **access_token / refresh_token****前端主路径不依赖 Session Cookie**`Set-Cookie` 可选保留)。
**Go 改造落点(备忘)**:在 `LoginHandler` 或抽取的 OAuth 服务方法中复用 `oauth2.Service` / `Store` 的 **授权码创建**能力;注意 **rate limit**、**redirect_uri 校验**、**client_id** 与种子 SPA 配置一致;更新 [`docs/oauth-v2.md`](docs/oauth-v2.md) 中 **「JSON 登录」** 小节与示例请求/响应。
| 步骤 | 前端要点 |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 登录 | 生成 PKCE → **`POST /api/v1/auth/login`**body 含挑战 + client + redirect_uri + 可选 **state**)→ 信封取 **`data.authorization_code`**。 |
| 换 token | **`POST /oauth/token`**form`authorization_code` + `code_verifier`)→ **access_token / refresh_token**;与全站 OAuth 流程一致。 |
| 调 API | `/api/v1/**` 带 **`Authorization: Bearer`**。 |
| **静默刷新** | `access_token` 将过期或 API 返回 **401** 时,**先**用 **`refresh_token`** 调 `grant_type=refresh_token` **无感换票****仅当 refresh 也失败**(无 refresh、过期、服务端拒绝)再 **弹登录框**(再走 login→code→token)。`apiClient` 内建议 **单飞刷新队列**,避免并发请求重复 refresh。 |
**401 时的交互(已定)****先静默 refresh** → 仍 401 再弹窗;弹窗 **仅账号 + 密码** + PKCE**`/api/v1/auth/login` → authorization_code → `/oauth/token`**;成功后 **关弹窗并重试原请求**。**用户主动「退出」**:`POST /api/v1/auth/logout`、清本地 token、**多 Tab** 可用 **`BroadcastChannel` / `localStorage` 事件** 同步登出(与 §4 一致)。
### 4. Zustand
- **`useAuthStore`**`accessToken`、`refreshToken`(存前端或安全存储策略团队定)、`userId`/`tenantId`、`setTokens`、**`logout`**(清状态 + 调 `auth/logout` + 广播多 Tab)。
- 可选:**`useTenantStore`** 与菜单/权限缓存,与 `iam/menu` 的 `nav`、`perms` 联动。
### 5. Next.js 注意点
- **App Router**OAuth **callback**、首次引导授权等放在 `app/(auth)/...`;业务页在 `app/(dashboard)/...`。layout 可对「冷启动无 token」做跳转授权等策略;**业务 API 返回 401 时优先用全局弹窗登录**(见 §3),避免打断用户操作。
- **服务端组件默认不能带浏览器 Cookie 调浏览器域下的私有 API** unless 使用 **Route Handler** 作 BFF;若希望「全 CSR + 直连 Go」,以 **Client Component + `apiClient`** 为主即可。
- **Prettier**:与 ESLint 一起在仓库根配置,与现有 Go 仓库分属不同目录时各自一份配置即可。
### 6. Radix UI
- 用于表单、Dialog、Dropdown、**Navigation Menu / Collapsible**、**Tooltip**(图标模式下图标旁展示完整菜单名)等;与业务页面解耦。
---
## 导航数据:后端驱动(强制约定)
**目标**:左侧导航(含经典树状 / 图标模式下的浮层或抽屉)所展示的 **层级、顺序、可见项、展示名、图标字段(若后端提供)** 均以 **后端返回的菜单树为准**;前端 **不在生产环境写死业务菜单列表**(开发环境可保留极少量占位路由仅用于 Storybook/演示,与正式壳分离)。
**主数据源(与当前 Go 能力对齐)**
- **`GET /api/v1/iam/menu/nav`**:面向当前用户/租户的 **导航菜单树**(侧栏渲染首选)。
- 必要时配合 **`GET /api/v1/iam/menu/perms`** 等做 **按钮级/路由级权限**;与 [`iam_menu`](migrations/postgres/001_iam.sql) 及角色绑定一致。
- 若需全量配置态菜单(管理端「菜单管理」页),可用 **`/api/v1/iam/menu/tree`** 等接口;**运行时侧栏**仍以 **nav** 为主,避免混用两套源。
**前端职责**
- 登录成功且具备 token 后 **拉取 nav**(及 perms),存入 Zustand/React Query 等,带 **SWR/失效策略**(切换租户、重新登录时 **重新请求**)。
- 将后端节点字段(如 `path`、`component`、`perms`、`children`**映射到 Next `Link`/`router` 路径**;若后端 `path` 与前端路由表不一致,维护 **一层显式映射表**(仍由后端数据驱动「显示哪些项」,映射只解决 URL 形状)。
- **经典模式**:直接渲染树组件;**图标模式**:同一棵树做遍历,父节点走浮层/抽屉(见上文「图标模式下的二级、三级导航」)。
**反模式(避免)**
- 侧栏 `MenuItem` 写死在 `layout.tsx` 内且与数据库菜单两套真相。
- 仅首屏拉一次菜单后永不刷新(租户切换、权限变更会不同步)。
---
## 整体页面与布局(管理端壳层)
目标:登录后进入 **统一壳**(**顶栏** + **左侧导航** + **主内容区**),业务模块(租户/部门/角色/用户/菜单/系统参数等)均在主内容区切换;登录/回调页 **不使用** 该壳,避免多余导航。
| 区域 | 职责 |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **顶栏** | 左侧:Logo/产品名;右侧:**用户信息区**(见下「顶栏右侧:用户信息下拉」,内含侧栏经典/图标切换)、租户等全局摘要(按需);**本阶段不在顶栏提供全局搜索框** |
| **左导航** | 见下文「双模式」;**数据见上一节「导航数据:后端驱动」** |
| **主工作区** | 见下文「主工作区:Tabs 区 + 内容区」;**非简单单页 children**,而是 **多页签 + 卡片式内容区**(列表/表单等在内层渲染) |
### 顶栏右侧:用户信息下拉(产品约定)
- **位置**Header **最右侧**(或紧挨全局操作区)展示当前用户摘要(建议:**头像/占位图 + 显示名或账号**)。
- **交互**:**鼠标悬停**在用户区域上时,展开 **下拉列表**(可用 Radix `DropdownMenu` 配合 `onOpenChange` / 延迟关闭,或 `HoverCard`+菜单组合;实现时注意 **触控设备无悬停**,需 **点击同样可打开**,并支持 **Esc 关闭、键盘方向键**,避免纯悬停导致无障碍与移动端不可用)。
- **下拉项(固定项,顺序建议如下)**:
1. **个人中心** — 跳转前端路由(如 `/account` 或 `/profile`),展示当前用户资料;**若后端暂无专用接口**,可先读已有用户接口(如按 `user_id` `GET`)或占位页,后续与 IAM 对齐。
2. **修改密码** — 跳转 `/account/password` 或 **弹窗表单**;提交时调用后端修改密码接口(**若当前 Go 未暴露**,计划中单列为「需补接口」或与 `iam/user` 更新密码能力对齐)。
3. **侧栏布局** — **经典模式** / **图标模式** 二选一(可用分段控件、单选行或两项可点菜单项),与 `useShellStore` 的 `sidebarMode` + `localStorage` 一致;**勿再单独放在顶栏**。
4. **退出** — 调用 `POST /api/v1/auth/logout`,清除前端 token/状态并跳转登录页。
```text
+------------------------------------------------------------------------+
| [Logo] Smart Admin [租户…] [○ 张三 ▼] ← 悬停/点击展开 |
+------------------------------------------------------------------------+
+---------------------------+
| 个人中心 |
| 修改密码 |
| 侧栏:[ 经典 | 图标 ] |
| 退出 |
+---------------------------+
```
---
## 主工作区:Tabs 区 + 内容区(产品约定)
导航栏 **右侧**为 **主工作区**,纵向分为两层:**上部 Tabs 页签区** + **下部内容区**。左侧菜单点击路由时,**优先在 Tabs 中打开/激活对应页签**(与常见「多标签后台」一致;实现可用 Zustand 维护页签列表与当前激活项)。
### A. Tabs 页签区
| 项 | 约定 |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **结构** | 顶部一条 **Tabs 栏**;每个 Tab 对应一个已打开页面(路由 + 关键参数可序列化进 tab id)。 |
| **固定首页签** | **最左侧**固定 **「概览」** 页签(路由如 `/dashboard` 或 `/overview`),**不可关闭**、不可被「关闭全部」关闭。 |
| **右键菜单** | 在 **页签条区域**(建议在 **某个 tab 标签上**)**点击右键**弹出菜单(Radix **Context Menu**),包含:**关闭**、**关闭左侧**、**关闭右侧**、**关闭全部**(关闭全部时保留「概览」)。 |
| **横向溢出** | 当 Tab 过多超出可视宽度时:**左侧、右侧各一个常驻小按钮**(如 `` ``),用于将 Tab 条 **向左/向右滚动**;按钮**始终占位可见**(禁用态亦可,避免布局跳动)。Tab 容器使用 `overflow-x: auto` + `scrollBy` 或等效实现。 |
**线框示意**
```text
| ◀ | [ 概览 | 用户管理 × | 角色管理 × | ... ] | ▶ |
^固定不可关^ ^右键关闭等^
```
### B. 内容区(包裹在 Tabs 下方)
| 项 | 约定 |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **外层边距** | 内容区相对主工作区容器:**左、上、右** 外边距均为 **12px**;外层背景为 **浅灰**(具体色值用设计 token,如 `hsl` 中性灰,勿写死纯黑字对比不足)。 |
| **内层卡片** | 实际承载页面的是 **白色背景** 容器;**内边距 12px**(与外边距统一节奏)。内容很短时,**底部**仍保持 **至少 12px** 外边距(可用 `min-height` + `padding-bottom` 或 flex 布局保证)。 |
| **圆角** | 白色主工作卡片 **四角圆角**(建议 `8px``12px` 或 Tailwind `rounded-lg`/`rounded-xl`,全局一致)。 |
### C. 一般列表页(内容区内「标准模板」)
适用于租户/用户/角色等 **表格类** 页面,内容区内再分 **上 / 中 / 下** 三块:
| 区域 | 内容 | 布局 |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
| **上部 · 查询条** | **左侧:操作区**(常见:**新增**、**批量删除** 等,随业务增减);**右侧:条件查询区**(输入框、下拉等)。左右 **两端对齐**(`justify-content: space-between` 或 grid 两列)。 |
| **中部 · 表格** | 数据表格,列随实体定义;**数据随查询条件变化**(前端驱动请求参数,或由 Zustand/React Query 绑定 filter state)。 |
| **下部 · 分页** | 分页器;**每页条数** 可选 **10 / 20 / 50**(默认可先 20);**允许按业务页单独配置**(通过 props 或页面级常量覆盖默认值)。 |
**表格行内操作(约定)**
| 操作 | 说明 |
| -------- | ------------------------------------------------------------- |
| **修改** | 行级入口,跳转编辑页、侧滑/抽屉表单或行内编辑(按模块选型)。 |
| **删除** | 行级删除。 |
**删除与批量删除(接口约定)**
- **单条删除**与**批量删除(工具栏)**共用 **同一后端批量删除能力**(例如 `POST .../batch-delete` 或 `DELETE` + body 为 `ids: []`);单条删除时传 **仅含一个 id 的列表**,避免维护两套删除路径与权限点。
- 前端:表格多选 + 「批量删除」与行内「删除」最终都走上述接口;确认弹窗文案区分「删除所选 N 条」与「删除本条」即可。
非列表页(表单页、详情页)可只使用白色卡片容器 + 内边距,不强制三区块,但 **边距与圆角** 与上表保持一致。
### D. 内容区布局变体(除「纯表格式列表」外)
| 形态 | 适用场景 | 结构要点 |
| ------------------------ | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **左树 + 右表 + 分页** | 数据依赖**树形上下文**的列表(如 **用户管理**:左侧 **部门/组织机构树**,右侧用户表 + 查询条 + 分页) | 左侧树与右侧表 **同一张灰底白卡片容器内**或左右分栏两卡片;**选中树节点**作为查询条件(如 `dept_id` / `org_id`),切换节点时刷新右侧表格与分页回到第 1 页。 |
| **整页树(可带工具栏)** | **层级数据本身即主对象**(如 **菜单管理**、部分 **目录/分类**) | 主区为 **可编辑树**(拖拽排序、增删改节点等按后端能力);若需与列表混用,可树下方再挂从表(按产品)。 |
**线框示意(左树右表,如用户管理)**
```text
灰底 12px
+-----------------------------------------------------------------------+
| +-- 白卡片 ----------------------------------------------------------------+ |
| | +------------------+ +------------------------------------------------+ | |
| | | 部门 / 组织树 | | [+新增] [批量删除] [条件…] [查询] [重置] | | |
| | | ▾ 公司 | +------------------------------------------------+ | |
| | | ▾ 研发部 | | 表头 | 操作(修改/删除) | … | | |
| | | ▾ 市场部 | | 数据 | [改][删] | … | | |
| | | … | +------------------------------------------------+ | |
| | | (可选宽 240280) | | 共 N 条 [10|20|50/页] < 1 2 3 > | | |
| | +------------------+ +------------------------------------------------+ | |
| +-------------------------------------------------------------------------+ |
+-----------------------------------------------------------------------+
```
### E. 组织机构与用户管理是否合一(计划约定)
- **常见做法(推荐默认)**:**组织机构(部门树)**作为 **用户管理** 的左侧上下文,与 **用户列表** 放在 **同一功能页 / 同一菜单入口**(左树右表);用户的新增/编辑表单里 **归属部门** 与树联动。这样避免「组织」与「用户」两处维护同一棵部门树时的割裂感。
- **何时拆页**:若产品要求 **组织机构** 单独做 **编制、合并、禁用** 等重操作,且与用户列表 **强解耦**,可另开 **「部门管理」** 子菜单;数据上仍与用户的 `dept_id` 同源,前端避免重复实现两套树数据源(宜共用 hook / 同一 `dept` API)。
- **结论**:计划层面 **默认采用「用户管理 = 部门树 + 用户表」一体化**;是否再单列「组织机构」顶级菜单由产品命名决定(可做成 **同路由别名** 或 **子 Tab:用户 | 部门**),实现上不强制两套壳。
**推荐实现(一种可落地方案)**
| 层次 | 做法 |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **路由** | **一个主路由**承载左树右表,例如 `/iam/users`(与后端菜单 `path` 对齐)。部门相关「重操作」优先做 **同页 Modal / 右侧抽屉 / 全屏子路由**(如 `/iam/users/dept`),避免先复制一套独立壳再维护两份树状态。 |
| **部门树数据源** | 封装 **`useDeptTree`**React Query`queryKey: ['dept', 'tree', tenantId]`),全应用 **仅此一处拉树**;用户页、(若有的)部门管理页、用户表单里的部门选择器 **都复用同一 query**,树更新后 **`invalidateQueries(['dept', 'tree', …])` 一处失效即可**。 |
| **选中部门** | 用 **`useState` + 可选 URL `?deptId=`**`nuqs` 或 Next `useSearchParams`):刷新、分享链接可恢复上下文;切换节点时 **用户列表页码重置为 1**。 |
| **用户列表** | **`useUserList({ deptId, page, pageSize, …filters })`**`deptId` 来自选中节点(根节点可表示「全部」或 `undefined` 由后端约定);查询条与树筛选 **合并为同一请求参数**。 |
| **行内 / 批量删** | 与上文约定一致,走 **同一 batch-delete**;表格 `rowSelection` 与批量按钮共用 `ids`。 |
| **用户表单与树联动** | 新增/编辑用 **受控的部门选择**(下拉树、`TreeSelect` 或内嵌窄树);`dept_id` 初始值 = 当前左侧选中节点或行数据;若用户在表单里改部门,**不必**自动改左侧选中(避免抢焦点),保存成功后 **刷新列表** 即可。 |
| **部门 CRUD(轻)** | 在树工具条放「新增子部门」「编辑」「禁用」→ **Modal**;成功后 **invalidate 部门树 + 用户列表**(若影响可见范围)。 |
| **部门 CRUD(重)** | 若合并、批量迁移等交互很重,可 **另开菜单** 指向 **独立页面**,该页仍 import **同一套 `features/dept` 模块**hooks + api),禁止复制 `DeptTree` 组件实现。 |
**小结**:用 **「单一路由 + 单一 dept tree query + 可选 searchParam 记录选中节点」** 最省事;重功能用 **子路由或抽屉** 消化,数据层仍 **一套 dept API、一套 React Query key 前缀**。
**计划采纳**:上述 **「推荐实现」表格 + 小结** 为本方案 **用户/部门一体化页的默认实现**;后续除非产品另有要求,按此执行。
**线框示意(列表页)**
```text
灰色背景 (外层 12px 边距)
+------------------------------------------------------------------+
| +-- 白色圆角卡片 (padding 12px) --------------------------------+ |
| | [+新增][批量删除] [条件A][条件B][查询][重置] | |
| | ---------------------------------------------------------------- | |
| | | 表头 | 表头 | ... | |
| | | 数据 | 数据 | ... | |
| | ---------------------------------------------------------------- | |
| | 共 N 条 [10|20|50/页] < 1 2 3 > | |
| +----------------------------------------------------------------+ |
+------------------------------------------------------------------+
```
### 整页综合线框图(Header + 左导航 + Tabs + 内容区标准列表)
```text
+======================================================================================+
|| Header ||
|| [Logo] Smart [租户…] [○ 用户 ▼] ← 展开含侧栏经典/图标、退出等 ||
+===+==================================================================================+
|| || Tabs 栏(右键:关闭 / 关左 / 关右 / 关全部;概览不可关) ||
|| || +---+ +--------------------------------------------------------------------+ ||
|| || | ◀ | | 概览 | 用户× | 角色× | 租户× | …overflow… | ▶ | ||
|| || +---+ +--------------------------------------------------------------------+ ||
|| || ^固定^ ^滚动钮常显^ ||
|| || +-----------------------------------------------------------------------------+|
|| || | 灰底 12px 边距(左/上/右;底同) | ||
|| || | +-------------------------------------------------------------------------+ | ||
|| || | | 白底圆角卡片 (内边距 12px) | | ||
|| || | | [+新增] [批量删除] [条件…] [查询] [重置] ←上:左右对齐 | | ||
|| || | |-------------------------------------------------------------------------| | ||
|| || | | | 列1 | 列2 | 列3 | ... ←中:表格 | | ||
|| || | | | 数据| 数据| 数据| | | ||
|| || | |-------------------------------------------------------------------------| | ||
|| || | | 共 N 条 每页 [10▼] [20] [50] < 1 2 3 > ←下:分页 | | ||
|| || | +-------------------------------------------------------------------------+ | ||
|| || +-----------------------------------------------------------------------------+|
|| || ||
||左|| ||
||侧|| ||
||导|| ||
||航|| ||
|| || ||
||树|| ||
||状|| ||
|| || ||
+===+==================================================================================+
```
**实现提示**:Tabs 状态与路由 **可双向同步**(新开 tab 推 history 或仅内存,按团队选择);右键菜单项需 **禁用态**(例如当前 tab 左侧无 tab 时「关闭左侧」禁用)。
---
## 左导航:经典模式 vs 图标模式(约定含义)
业内常见两种叫法,与你描述的「经典 / 图标」一般对应如下(若你希望另一种交互,可再改一版文案):
| 模式 | 典型形态 | 体验要点 |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| **经典模式** | 侧栏 **较宽**(约 220260px),每项 **图标 + 文字** 并排展示;多级菜单可展开/折叠,**当前选中态**清晰 | 信息密度高,适合首次使用与菜单项较多的管理后台 |
| **图标模式** | 侧栏 **收窄**(约 56–72px),**仅显示图标**;文字通过 **Tooltip** 或 **悬停浮层** 展示;子菜单可用 **Popover / 侧滑面板** 或点击展开窄条下的二级 | 主内容区更宽,适合熟练用户;类似 VS Code 活动栏、许多 SaaS 的「收起侧边栏」 |
**实现要点(计划内约定):**
- 使用 **Zustand**(如 `useShellStore`)保存 `sidebarMode: 'classic' | 'icon'`,并用 **`localStorage` 持久化**(键名如 `smart_sidebar_mode`),刷新后保持用户选择。
- 布局用 **CSS 变量或 Tailwind** 控制侧栏宽度;`transition` 做宽度切换动画(可选)。
- **无障碍**:图标模式下每个图标按钮必须带 **`aria-label`**,与 Tooltip 文案一致。
- 与 **Radix**`Tooltip` + `NavigationMenu` 或自研侧栏;避免纯 div 堆叠导致键盘无法操作。
```mermaid
flowchart TB
subgraph shell [AppShell]
Top[顶栏]
subgraph left [左导航]
Classic[经典: 宽栏+图文]
IconOnly[图标: 窄栏+Tooltip]
end
Main[内容区]
end
Top --> left
left --> Main
```
### 线框图(ASCII
**经典模式(宽侧栏:图标 + 文字)**
```text
+----------------------------------------------------------------------------------+
| [Logo] Smart Admin [租户: 平台] [用户 ▼] [退出] ←「用户」下拉内含侧栏经典/图标 |
+----------+-----------------------------------------------------------------------+
| [i] 首页 | |
| [i] 系统 | 主内容区(列表 / 表单 / 详情) |
| 参数 | |
| [i] IAM | +---------------------------------------------------------------+ |
| 用户 | | 表格 / 卡片 / 步骤条 … | |
| 角色 | +---------------------------------------------------------------+ |
| ... | |
| | |
+----------+-----------------------------------------------------------------------+
^约 220260px^
```
**图标模式(窄侧栏:仅图标,文字用 Tooltip)**
```text
+----------------------------------------------------------------------------------+
| [Logo] Smart Admin [租户] [用户 ▼] [退出] ←「用户」下拉内含侧栏经典/图标 |
+--+-------------------------------------------------------------------------------+
|[]| |
|[]| 主内容区(更宽) |
|[]| |
|[]| +---------------------------------------------------------------+ |
|[]| | | |
|[]| +---------------------------------------------------------------+ |
| | |
+--+-------------------------------------------------------------------------------+
^约 5672px^
悬停图标 → Tooltip「用户管理」;子菜单可 Popover 或右侧滑出
```
**两种模式对比(同一壳,仅侧栏宽度与是否显示标签变化)**
```text
经典 图标
+--------------------+ +--+---------------+
| [≡] 用户管理 | |👤| 用户列表… |
| [≡] 角色管理 | ↔ |👥| |
| [≡] … | |⚙ | Tooltip |
+--------------------+ +--+---------------+
```
### 图标模式下的二级、三级导航(常见交互)
窄栏里**无法像经典模式那样纵向展开整棵树**,多级菜单通常用下面几类方式之一(可混用,按菜单深度与数量选型):
| 方案 | 行为 | 适用 |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| **A. 右侧飞出级联面板** | 点击一级图标 → 在侧栏右侧弹出 **浮层 1** 列出二级;在某项上悬停或点击 → 再向右弹出 **浮层 2** 列三级(「手风琴式」级联,类似旧版 Windows 开始菜单多级) | 二、三级都多、需要快速扫视 |
| **B. 单层面板 + 树/分组** | 点击一级图标 → 一个较宽的 **Popover / DropdownMenu** 内用 **可折叠分组** 或 **缩进树** 展示二、三级(可滚动) | 总项数中等、希望少移动鼠标 |
| **C. 抽屉** | 点击带子女的图标 → 从左侧 **滑出抽屉**,内部为完整树(与经典侧栏同结构,只是按需出现) | 层级很深或名称很长 |
| **D. 临时加宽** | 点击某父级后,侧栏在图标列旁 **临时展开一条「迷你文字列」** 仅显示该支路的二、三级 | 想兼顾窄栏与可读性 |
**线框示意(方案 A:向右级联)**
```text
侧栏(窄) 浮层1(二级) 浮层2(三级)
+--+ +-----------+ +-----------+
|👤| 点击 → | 用户管理 | | 列表用户 |
| | | 角色管理 | hover| 导入用户 |
| | | 部门 ─────┼────→| 导出 |
+--+ +-----------+ +-----------+
```
**线框示意(方案 B:单 Popover 内多级)**
```text
+--+
|👤| 点击 → +-------------------------+
+--+ | ▼ IAM |
| 用户管理 |
| 角色管理 |
| ▼ 系统 |
| 参数配置 ← 三级作为子项 |
+-------------------------+
```
**计划约定**:实现时 **同一套菜单树数据**(如 `menu/nav`)驱动经典树与图标模式;图标模式需为 **带 `children` 的节点** 绑定上述一种交互,并在设计稿中统一 **键盘操作**(Esc 关闭、方向键在级联中移动,可与 Radix DropdownMenu / NavigationMenu 能力对齐)。
---
## 实施顺序建议
1. 初始化 Next 项目(TS + Prettier),配置 `NEXT_PUBLIC_API_ORIGIN`。
2. **Go** 按 §3 改造 **`POST /api/v1/auth/login`**:密码通过后签发 **PKCE 绑定 `code`**,与 **`/oauth/token`** 复用;同步更新 **`docs/oauth-v2.md`**。
3. 实现 **`apiClient` + `api/auth`**,跑通 **login → code → token**、logout 与一条 IAM 只读接口(如 `menu/nav`)。
4. 搭建 **`app/(dashboard)/layout`**(或等价)实现 **AppShell + 左导航双模式** 与顶栏,菜单对接 `menu/nav`。
5. 按模块把 **IAM、System** 全部方法补齐(与 [`http_register`](internal/iam/http_register.go) 路径一一对应),页面放入内容区。
6. 接入 **OAuth2**PKCE + callback + token 存储),与现有 Go 的 `client_id`/`redirect_uri` 配置一致(种子 `spa` 与 [`configs/local.yml`](configs/local.yml) 中 `frontend_login_url`)。
7. Go 侧确认 **CORS + Cookie 策略** 与生产 HTTPS。
8. (可选)后端增加 OpenAPI/Swagger 后,用 codegen 替换手写 DTO。
---
## 产品口径与待确认项
### 已定口径(按当前共识写入计划)
| 项 | 决定 |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **鉴权** | **仅 OAuth2 Code + PKCE + Bearer****`/api/v1/auth/login` 须改造为签发 PKCE 绑定 `code` 并与 `/oauth/token` 同一套逻辑**(当前 Go 仅为 Session,属待改偏差)。 |
| **租户切换** | **必须关闭所有 Tabs**(页签状态清空),并 **重新拉** `menu/nav`、`dept/tree` 及当前内容区数据(与 `tenantId` / token 声明一致)。 |
| **权限 · 第一期** | **仅做路由级守卫**(未登录或整页无权限 → 拦截/提示);**不做**表格内每颗按钮与 `perms` 的细粒度联动(后续迭代再加)。 |
| **批量删除** | 与 Go 路由对齐;若某资源 **没有 batch 接口**,**实现侧先通知负责人,由负责人决定**补接口或临时方案,**不擅自拍板**。 |
| **HTTP 与 body.code** | **`401`** = 认证失败(**含登录密码错误**),**前端以 HTTP 为准****`403`** = 无权限;**其余业务** **`HTTP 200`** + 体 `code`。**密码错误不单独用 `200`+业务码表达**。 |
| **JSON 与 state** | 信封 **`{ code, msg, data }`****`auth/login` 可带 `state`**,成功 **`data` 回显**。 |
| **401 / refresh** | **一般 API 的 401** → **先 refresh**,失败再弹窗;**`POST /auth/login` 的 401**(密码错等)→ **不 refresh**,直接按需弹窗(§3)。 |
| **403** | **HTTP 403** → **toast**,留在当前页,**不弹登录框**。 |
| **环境** | **生产同域****开发跨域** → Go **CORS 白名单** dev origin;种子 SPA 的 **`redirect_uri` 须含各环境回调**(见 §3 `client_id` 说明)。 |
| **登出** | **`POST /auth/logout`** + 清前端 token**多 Tab** 用 **BroadcastChannel / storage 事件** 同步登出。 |
### 术语说明(白话)
**部门树:根节点 =「全部」**
- 含义:左侧树**最顶层**(或「未选中具体部门」)表示 **不按部门过滤**,用户列表查 **「全部」**(具体 query 与后端约定)。
- **是否含停用**:部门可能被 **停用**,需约定树里 **是否仍显示**这些节点(显示则可选中;不显示则界面更干净)。
- **是否懒加载**:**懒加载** = 先只拉根,**展开再拉子节点**;**非懒加载** = **一次拉整棵树**。部门特别多时常用懒加载;树小可一次拉齐。
**Tabs 要不要写进 URL**
- **只存在内存**:开了多个页签,**一刷新浏览器页签全没**——可接受则实现简单。
- **写进 URL**:刷新或 **分享链接** 能恢复多页签状态;实现更复杂,第一期**不强制**。
**部门「轻」vs「重」/ 独立页**
- **轻**:在用户管理页用 **Modal** 做 **加子部门、改名** 等简单维护。
- **重**:**整页**做 **合并部门、批量迁移、复杂拖拽** 等,才需要 **单独「部门管理」菜单**。**MVP 建议先轻后重**,复用同一套 `dept` API。
### 仍待与后端核对
| 项 | 说明 |
| ------------------------- | ------------------------------------------------------ |
| **批量删除契约** | 路径、`ids`、软删以 Gin 为准;**缺接口时通知负责人**。 |
| **部门树 · 停用与懒加载** | 与后端行为对齐;未约定则联调时定一版。 |
| **Tabs 是否进 URL** | 可第一期 **仅内存**,后续再加。 |
---
## 风险与约束
- **「所有接口」** 以当前 Gin 注册为准;若后续新增路由,前端需同步增加 `api/*` 方法。
- Bearer 中间件对无 token 请求**不 401**;**业务上需登录**由路由守卫与 **`apiClient`:先 refresh、失败再弹登录框** 协同处理。
- 工作区若与 Go 仓库分离,请用 **同一文档** 维护 baseURL 与 `client_id`/redirect 列表。