# 用户与组织架构管理 - UI交互规范

> **版本**: v2.4  
> **最后更新**: 2026-05-19  
> **维护者**: FFOA 前端团队  
> **完成度**: 100%

> **参考标准**: 飞书（Lark）设计风格

---

## 📋 文档变更记录

| 版本    | 日期       | 修改人    | 修改内容                                                                           |
| ------- | ---------- | --------- | ---------------------------------------------------------------------------------- |
| v2.4    | 2026-05-19 | FFOA Team | Entra ID SSO 登录：登录页双按钮规范（微软/密码并列）+ 5 个 SSO 错误码 i18n + 用户详情页新增「SSO 绑定信息」只读分块（issue #334） |
| v2.1.44 | 2026-03-13 | FFOA Team | 钉钉年假页面支持点击员工姓名编辑本地释放计划参数，并明确额度扣减与计划偏移字段说明 |
| v2.1.43 | 2026-03-11 | FFOA Team | 钉钉年假释放计划页补充无计划员工展示，并直接标注未生成原因                         |
| v2.1.42 | 2026-03-11 | FFOA Team | 钉钉年假释放计划页姓名悬停卡支持延迟隐藏和一键复制编号                             |
| v2.1.41 | 2026-03-11 | FFOA Team | 钉钉年假释放计划页移除年度列，员工姓名悬停显示 userId 和工号                       |
| v2.1.40 | 2026-03-11 | FFOA Team | 钉钉年假释放计划页仅保留底部同步滚动条，隐藏表格与顶部原生横向滚动条               |
| v2.1.39 | 2026-03-11 | FFOA Team | 钉钉年假释放计划页新增独立底部同步滚动条，避免系统覆盖式滚动条隐藏                 |
| v2.1.38 | 2026-03-11 | FFOA Team | 钉钉年假释放计划页底部横向滚动条默认可见，便于发现左右滚动                         |
| v2.1.36 | 2026-03-11 | FFOA Team | 钉钉年假释放计划页矩阵占满右侧内容区，释放日期单元格仅展示月日                     |
| v2.1.35 | 2026-03-11 | FFOA Team | 钉钉年假释放计划页改为按员工展示第 N 天年假的释放日期矩阵                          |
| v2.1.34 | 2026-03-11 | FFOA Team | 钉钉年假释放计划页新增“更新中间表”按钮，支持手动重算本地计划                       |
| v2.1.33 | 2026-03-11 | FFOA Team | 年假释放计划页与假期余额详情统一读取本地数据库中的年假释放计划，不再依赖宜搭中间表 |
| v2.1.32 | 2026-03-11 | FFOA Team | 钉钉假期余额页支持点击具体余额单元格查看释放记录与使用记录                         |
| v2.1.31 | 2026-03-11 | FFOA Team | 钉钉假期余额页展示快照更新时间，并支持手动刷新快照                                 |
| v2.1.30 | 2026-03-11 | FFOA Team | 调整钉钉假期余额页为按员工汇总、假期类型做列的矩阵展示                             |
| v2.1.29 | 2026-03-11 | FFOA Team | 补充钉钉考勤类定时同步按固定半小时整点窗口执行的说明                               |
| v2.1.28 | 2026-03-10 | FFOA Team | 新增钉钉审批撤销修复面板                                                           |
| v2.1.27 | 2026-02-25 | FFOA Team | 登录回跳规则明确：支持 redirect 参数并在登录后回原页面                             |
| v2.1.26 | 2026-01-25 | FFOA Team | 预定义角色 code 对齐 Administrator/Employee                                        |
| v1.0    | 2024-11-01 | FFOA Team | 初始版本                                                                           |
| v2.0    | 2025-12-20 | FFOA Team | 新增组织管理、区域管理页面                                                         |
| v2.1    | 2025-12-26 | FFOA Team | 按模板重构，标准化交互规范                                                         |
| v2.1.1  | 2025-12-26 | FFOA Team | 补充身份源管理、密码修改、登录安全的UI规范                                         |
| v2.1.25 | 2026-01-05 | FFOA Team | 同步 PRD v2.1.25：简化身份源选择、移除锁定提示                                     |

---

## 📄 页面清单

| 页面             | 路由                                               | 说明                                   | 权限                | 状态 |
| ---------------- | -------------------------------------------------- | -------------------------------------- | ------------------- | ---- |
| **用户管理**     |                                                    |                                        |                     |
| 用户列表         | `/organization/members`                            | 用户列表和搜索                         | `user:read`         | ✅   |
| 用户详情         | `/organization/members/:id`                        | 用户基本信息、部门归属、角色权限       | `user:read`         | ✅   |
| 用户编辑         | `/organization/members/:id/edit`                   | 编辑用户信息                           | `user:update`       | ✅   |
| 新建用户         | `/organization/members/new`                        | 创建新用户                             | `user:create`       | ✅   |
| **部门管理**     |                                                    |                                        |                     |
| 组织架构树       | `/organization/departments`                        | 树形展示组织架构                       | `department:read`   | ✅   |
| 部门详情         | `/organization/departments/:id`                    | 部门信息和成员列表                     | `department:read`   | ✅   |
| **角色管理**     |                                                    |                                        |                     |
| 角色列表         | `/organization/roles`                              | 系统角色列表                           | `role:read`         | ✅   |
| 权限配置         | `/organization/roles/:id/permissions`              | 配置角色权限                           | `role:update`       | ✅   |
| **组织管理**     |                                                    |                                        |                     |
| 组织列表         | `/organization/organizations`                      | 组织实体列表                           | `organization:read` | ✅   |
| 组织详情         | `/organization/organizations/:id`                  | 组织信息、区域配置                     | `organization:read` | ✅   |
| **区域管理**     |                                                    |                                        |                     |
| 区域列表         | `/organization/regions`                            | 地理区域列表                           | `region:read`       | ✅   |
| **岗位管理**     |                                                    |                                        |                     |
| 岗位列表         | `/organization/positions`                          | 岗位职级列表                           | `position:read`     | ✅   |
| **身份认证**     |                                                    |                                        |                     |
| 用户登录         | `/login`                                           | 用户登录（支持LOCAL/LDAP）             | 公开                | ✅   |
| 修改密码         | `/settings/security/change-password`               | 修改密码（仅本地用户）                 | 已登录              | ✅   |
| **外部同步**     |                                                    |                                        |                     |
| Entra ID 同步    | `/organization/sync`                               | 同步状态和日志                         | `organization:sync` | ✅   |
| 钉钉数据同步     | `/organization/dingtalk`                           | 钉钉同步任务和审批撤销修复             | `organization:sync` | ✅   |
| 钉钉假期余额总览 | `/organization/dingtalk/annual-leave/quotas`       | 查看当前钉钉系统内各员工各假期类型余额 | `organization:sync` | ✅   |
| 钉钉年假释放计划 | `/organization/dingtalk/annual-leave/release-plan` | 查看并手动更新本地年假释放计划表       | `organization:sync` | ✅   |

---

## 钉钉数据同步页补充规范

钉钉相关 UI 规范已迁移至独立模块文档维护，见 [docs/modules/dingtalk/05-ui-interaction-spec.md](../dingtalk/05-ui-interaction-spec.md)。

---

## 🎨 设计系统

### 颜色规范（飞书配色）

```typescript
const colors = {
  // 主色
  primary: "#3370ff", // 品牌蓝
  primaryHover: "#1e5eff", // 悬浮蓝
  primaryActive: "#0e4fcb", // 按下蓝

  // 文字
  textPrimary: "#1f2329", // 主要文字
  textSecondary: "#646a73", // 次要文字
  textTertiary: "#8f959e", // 辅助文字
  textDisabled: "#bbbfc4", // 禁用文字

  // 背景
  bgPrimary: "#ffffff", // 白色背景
  bgSecondary: "#f7f8fa", // 浅灰背景
  bgTertiary: "#eff0f1", // 灰色背景
  bgHover: "#f7f8fa", // 悬浮背景

  // 边框
  border: "#e5e6eb", // 默认边框
  borderHover: "#c9cdd4", // 悬浮边框

  // 状态色
  success: "#00b42a", // 成功（绿色）
  warning: "#ff7d00", // 警告（橙色）
  error: "#f53f3f", // 错误（红色）
  info: "#3370ff", // 信息（蓝色）

  // 用户状态
  statusActive: "#00b42a", // 激活
  statusInactive: "#8f959e", // 停用
  statusSuspended: "#ff7d00", // 暂停
  statusTerminated: "#f53f3f", // 离职
};
```

### 排版规范

```typescript
const typography = {
  h1: "text-2xl font-semibold text-[#1f2329]", // 24px
  h2: "text-xl font-semibold text-[#1f2329]", // 20px
  h3: "text-lg font-medium text-[#1f2329]", // 18px
  h4: "text-base font-medium text-[#1f2329]", // 16px
  body: "text-sm text-[#1f2329]", // 14px
  bodySecondary: "text-sm text-[#646a73]", // 14px
  caption: "text-xs text-[#8f959e]", // 12px
  link: "text-sm text-[#3370ff] hover:text-[#1e5eff] cursor-pointer",
};
```

### 间距规范

| 名称 | 值   | Tailwind | 用途     |
| ---- | ---- | -------- | -------- |
| xs   | 4px  | 1        | 极小间距 |
| sm   | 8px  | 2        | 小间距   |
| md   | 12px | 3        | 中间距   |
| lg   | 16px | 4        | 大间距   |
| xl   | 24px | 6        | 特大间距 |
| 2xl  | 32px | 8        | 超大间距 |

---

## 🖥️ 页面 1: 用户列表页

### 基本信息

- **页面标题**: 用户管理
- **URL**: `/organization/members`
- **权限要求**: `user:read`
- **布局模式**: 标准列表页

### 页面元素清单

| 元素名称 | 选择器                                   | 类型   | 必填 | 说明                       |
| -------- | ---------------------------------------- | ------ | ---- | -------------------------- |
| 页面标题 | `[data-testid="page-title"]`             | 文本   | -    | 显示"用户管理"             |
| 导入按钮 | `button[data-action="import"]`           | 按钮   | -    | 批量导入用户               |
| 新建按钮 | `button[data-action="create-user"]`      | 按钮   | -    | 跳转到新建页               |
| 搜索框   | `input[data-testid="user-search-input"]` | 输入框 | ❌   | 支持姓名/邮箱/员工编号搜索 |
| 部门筛选 | `select[name="department"]`              | 下拉框 | ❌   | 按部门筛选                 |
| 状态筛选 | `select[name="status"]`                  | 下拉框 | ❌   | 按状态筛选                 |
| 角色筛选 | `select[name="role"]`                    | 下拉框 | ❌   | 按角色筛选                 |
| 数据表格 | `[data-testid="user-table"]`             | 表格   | -    | 显示用户列表               |
| 分页器   | `[data-testid="pagination"]`             | 分页   | -    | 分页控制                   |

### 表格列定义

| 列名     | 字段名        | 选择器                        | 类型   | 宽度  | 排序 | 说明           |
| -------- | ------------- | ----------------------------- | ------ | ----- | ---- | -------------- |
| 复选框   | -             | `input[type="checkbox"]`      | 复选框 | 40px  | ❌   | 批量选择       |
| 显示名称 | `displayName` | `[data-column="displayName"]` | 文本   | 100px | ✅   | 真实姓名       |
| 用户名   | `username`    | `[data-column="username"]`    | 文本   | 120px | ✅   | 登录账号       |
| 邮箱     | `email`       | `[data-column="email"]`       | 文本   | 200px | ❌   | 电子邮箱       |
| 部门     | `department`  | `[data-column="department"]`  | 文本   | 150px | ❌   | 主部门名称     |
| 状态     | `status`      | `[data-column="status"]`      | 徽章   | 80px  | ✅   | 状态徽章       |
| 操作     | -             | `[data-column="actions"]`     | 按钮组 | 120px | ❌   | 查看/编辑/更多 |

### 筛选器定义

| 筛选项     | 字段名         | 选择器                                   | 类型   | 选项                      |
| ---------- | -------------- | ---------------------------------------- | ------ | ------------------------- |
| 关键词搜索 | `search`       | `input[data-testid="user-search-input"]` | 文本   | -                         |
| 部门       | `departmentId` | `select[name="department"]`              | 下拉框 | 全部部门/技术部/产品部... |
| 状态       | `status`       | `select[name="status"]`                  | 下拉框 | 全部/激活/停用/暂停/离职  |
| 角色       | `roleId`       | `select[name="role"]`                    | 下拉框 | 全部角色/管理员/HR...     |

### 交互行为

#### 1. 页面加载

**触发**: 用户访问用户列表页

**前端行为**:

1. 显示骨架屏（Loading）
2. 调用列表 API：`GET /api/v1/users?page=1&pageSize=20&status=ACTIVE`
3. 渲染数据表格
4. 更新 URL 参数

**显示规则**:

- 有数据：显示表格和分页
- 无数据：显示空状态，引导用户创建第一个用户
- 加载失败：显示错误提示和重试按钮

**选择器映射**:

```typescript
{
  loadingState: '[data-testid="loading-skeleton"]',
  emptyState: '[data-testid="empty-state"]',
  errorState: '[data-testid="error-state"]',
  tableContainer: '[data-testid="user-table"]',
  retryButton: 'button[data-action="retry"]'
}
```

---

#### 2. 搜索操作

**触发**: 用户输入搜索关键词并回车或点击搜索图标

**前端行为**:

1. 获取搜索关键词（去除首尾空格）
2. 调用 API：`GET /api/v1/users?search={keyword}&page=1`
3. 更新表格数据
4. 重置页码为 1
5. 更新 URL 参数：`?search={keyword}`

**显示规则**:

- 搜索中：输入框显示 Loading 图标
- 无结果：显示"未找到匹配结果，请尝试其他关键词"
- 成功：高亮匹配的文字

**选择器映射**:

```typescript
{
  searchInput: 'input[data-testid="user-search-input"]',
  searchIcon: '[data-testid="search-icon"]',
  loadingIcon: '[data-testid="search-loading"]',
  noResultsMessage: '[data-testid="no-results"]',
  clearButton: 'button[data-action="clear-search"]'
}
```

**边界处理**:

- 关键词为空：恢复到默认列表
- 关键词过短（<2字符）：不触发搜索
- 特殊字符：自动转义

---

#### 3. 状态筛选

**触发**: 用户选择状态筛选项

**前端行为**:

1. 获取选中的状态值
2. 调用 API：`GET /api/v1/users?status={status}&page=1`
3. 更新表格数据
4. 更新 URL 参数

**筛选选项**:

```typescript
const statusOptions = [
  { label: "全部状态", value: "" },
  { label: "激活", value: "ACTIVE", badge: "success" },
  { label: "停用", value: "INACTIVE", badge: "neutral" },
  { label: "暂停", value: "SUSPENDED", badge: "warning" },
  { label: "已离职", value: "TERMINATED", badge: "error" },
];
```

**默认行为**:

- 默认仅显示 ACTIVE 用户
- 查看离职用户需要明确选择 TERMINATED

---

#### 4. 新建操作

**触发**: 用户点击"新建用户"按钮

**前置条件**:

- 用户拥有 `user:create` 权限

**前端行为**:

1. 路由跳转：`router.push('/organization/members/new')`

**按钮状态**:

- 有权限：按钮可点击，显示主要样式
- 无权限：按钮隐藏

**选择器映射**:

```typescript
{
  createButton: 'button[data-action="create-user"]';
}
```

---

#### 5. 查看详情

**触发**: 用户点击表格行或"查看"按钮

**前端行为**:

1. 获取用户 ID
2. 路由跳转：`router.push('/organization/members/${id}')`

**选择器映射**:

```typescript
{
  tableRow: 'tr[data-testid="user-row"]',
  viewButton: 'button[data-action="view"]'
}
```

---

#### 6. 编辑操作

**触发**: 用户点击"编辑"按钮

**前置条件**:

- 用户拥有 `user:update` 权限
- 目标用户在权限范围内

**前端行为**:

1. 获取用户 ID
2. 路由跳转：`router.push('/organization/members/${id}/edit')`

**按钮显示规则**:

- 普通员工：不显示
- 部门经理：显示（仅本部门用户可编辑）
- HR 管理员：显示（本组织用户可编辑）
- 系统管理员：始终显示

**选择器映射**:

```typescript
{
  editButton: 'button[data-action="edit"]';
}
```

---

#### 7. 更多操作（下拉菜单）

**触发**: 用户点击"更多"按钮（⋯）

**前端行为**:

1. 显示下拉菜单
2. 根据用户状态和权限动态显示菜单项

**菜单项定义**:

| 菜单项   | 选择器                         | 显示条件              | 权限要求         |
| -------- | ------------------------------ | --------------------- | ---------------- |
| 分配角色 | `[data-action="assign-roles"]` | 用户状态为 ACTIVE     | `role:assign`    |
| 停用     | `[data-action="deactivate"]`   | 用户状态为 ACTIVE     | `user:update`    |
| 激活     | `[data-action="activate"]`     | 用户状态为 INACTIVE   | `user:update`    |
| 恢复     | `[data-action="unsuspend"]`    | 用户状态为 SUSPENDED  | `user:suspend`   |
| 暂停     | `[data-action="suspend"]`      | 用户状态为 ACTIVE     | `user:suspend`   |
| 离职     | `[data-action="terminate"]`    | 用户状态非 TERMINATED | `user:terminate` |

**选择器映射**:

```typescript
{
  moreButton: 'button[data-action="more"]',
  dropdown: '[data-testid="user-actions-dropdown"]',
  menuItem: '[data-menu-item]'
}
```

---

### 状态徽章样式

| 状态       | CSS 类名            | 背景色    | 文字颜色  | 图标 | 文案 |
| ---------- | ------------------- | --------- | --------- | ---- | ---- |
| ACTIVE     | `status-active`     | `#e6f7ed` | `#00b42a` | 🟢   | 激活 |
| INACTIVE   | `status-inactive`   | `#f7f8fa` | `#8f959e` | 🟡   | 停用 |
| SUSPENDED  | `status-suspended`  | `#fff7e6` | `#ff7d00` | 🟠   | 暂停 |
| TERMINATED | `status-terminated` | `#ffece8` | `#f53f3f` | 🔴   | 离职 |

**React 组件示例**:

```tsx
const UserStatusBadge = ({ status }: { status: UserStatus }) => {
  const config = {
    ACTIVE: {
      bg: "bg-[#e6f7ed]",
      text: "text-[#00b42a]",
      icon: "🟢",
      label: "激活",
    },
    INACTIVE: {
      bg: "bg-[#f7f8fa]",
      text: "text-[#8f959e]",
      icon: "🟡",
      label: "停用",
    },
    SUSPENDED: {
      bg: "bg-[#fff7e6]",
      text: "text-[#ff7d00]",
      icon: "🟠",
      label: "暂停",
    },
    TERMINATED: {
      bg: "bg-[#ffece8]",
      text: "text-[#f53f3f]",
      icon: "🔴",
      label: "离职",
    },
  };

  const { bg, text, icon, label } = config[status];

  return (
    <span
      className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded ${bg} ${text}`}
      data-testid={`status-badge-${status}`}
    >
      <span>{icon}</span>
      <span>{label}</span>
    </span>
  );
};
```

---

## 🖥️ 页面 2: 用户详情页

### 基本信息

- **页面标题**: 用户详情 - {用户名}
- **URL**: `/organization/users/:id`
- **权限要求**: `user:read`
- **布局模式**: 详情页 + 标签页

### 页面元素清单

| 元素名称   | 选择器                        | 类型   | 说明                                |
| ---------- | ----------------------------- | ------ | ----------------------------------- |
| 返回按钮   | `button[data-action="back"]`  | 按钮   | 返回列表                            |
| 用户头像   | `[data-testid="user-avatar"]` | 图片   | 用户头像                            |
| 用户名标题 | `[data-testid="user-title"]`  | 文本   | 显示名称(用户名)                    |
| 编辑按钮   | `button[data-action="edit"]`  | 按钮   | 跳转编辑页                          |
| 更多按钮   | `button[data-action="more"]`  | 按钮   | 操作菜单                            |
| 标签页导航 | `[data-testid="tabs"]`        | 标签页 | 基本信息/部门归属/角色权限/操作日志 |

### 标签页定义

| 标签页   | 选择器                     | 默认 | 说明           |
| -------- | -------------------------- | ---- | -------------- |
| 基本信息 | `[data-tab="basic"]`       | ✅   | 用户基本信息   |
| 部门归属 | `[data-tab="departments"]` | ❌   | 多部门归属列表 |
| 角色权限 | `[data-tab="roles"]`       | ❌   | 角色和权限列表 |
| 操作日志 | `[data-tab="logs"]`        | ❌   | 审计日志       |

### 基本信息字段展示

| 字段名   | 选择器                         | 类型 | 格式                |
| -------- | ------------------------------ | ---- | ------------------- |
| 显示名称 | `[data-field="displayName"]`   | 文本 | -                   |
| 用户名   | `[data-field="username"]`      | 文本 | -                   |
| 邮箱     | `[data-field="email"]`         | 文本 | 带复制按钮          |
| 电话     | `[data-field="phone"]`         | 文本 | -                   |
| 员工编号 | `[data-field="employeeId"]`    | 文本 | -                   |
| 状态     | `[data-field="status"]`        | 徽章 | 带颜色              |
| 来源     | `[data-field="source"]`        | 文本 | 本地/LDAP           |
| 默认区域 | `[data-field="defaultRegion"]` | 文本 | 中国(CN)            |
| 入职时间 | `[data-field="joinedAt"]`      | 文本 | YYYY-MM-DD          |
| 创建时间 | `[data-field="createdAt"]`     | 文本 | YYYY-MM-DD HH:mm:ss |

### SSO 绑定信息（v2.4 新增 · 只读分块）

**位置**: 「基本信息」段之下，独立分块标题「SSO 绑定信息」。

**可见性约束**:

- 仅 `itadmin` 角色或拥有 `user:read:all` 权限的角色可见。
- 普通员工 / 部门主管视角下该分块整体不渲染（不是隐藏字段，而是连标题都不显示）。

**字段定义**（**全部只读**，本期不做修改入口）:

| 字段名             | 选择器                              | 类型      | 格式 / 说明                                                                                            |
| ------------------ | ----------------------------------- | --------- | ------------------------------------------------------------------------------------------------------ |
| 外部身份 ID        | `[data-field="externalId"]`         | 文本      | monospace 字体显示；带「复制」按钮（hover 出现）；NULL 时显示「—」                                     |
| 外部身份来源       | `[data-field="externalSource"]`     | 徽章      | `entra` = 蓝色徽章（文案「Microsoft Entra」）/ `ldap` = 灰色徽章（文案「LDAP」，且 `externalId` 非空时附加「待 SSO 升级」灰色 sub-badge） / NULL = 文案「未绑定」 |
| 最近 SSO 登录时间  | `[data-field="lastSsoLoginAt"]`     | 文本      | YYYY-MM-DD HH:mm:ss；**数据来源**：从 `AuditLog` 表反查 `userId=用户ID` 且 `action=SSO_LOGIN_SUCCESS` 的最近一条 `when`（走现有复合索引 `(tenantId, userId, when)`）；无记录时显示「从未通过 SSO 登录」 |

**externalSource 三态显示规则（v2.4 LDAP 升级路径要求）**：

| `externalSource` | `externalId` 状态 | 徽章 | 用户文案 |
|---|---|---|---|
| `entra` | 非空 | 蓝色 | "Microsoft Entra"（zh）/ "Microsoft Entra"（en） |
| `ldap` | 非空 | 灰色 + 灰色 sub-badge | "LDAP / 待 SSO 升级"（zh）/ "LDAP / Pending SSO Upgrade"（en） |
| NULL | NULL | 无色边框 | "未绑定"（zh）/ "Not bound"（en） |

> **数据来源备注**：本期**不新增** `lastSsoLoginAt` 字段到 User 表，走 AuditLog 反查（复用 `@@index([tenantId, userId, when])`，PostgreSQL B-tree 可降序扫描，无需新增索引）。如果实现期发现反查性能 / UX 不便，可在 PRD review 阶段决定加 `lastSsoLoginAt` 物化字段，data-field 选择器保持不变。

**国际化文案**（项目 i18n 用嵌套 TS 对象结构 `frontend/src/locales/auth/{zh,en}.ts`，下表 dotted key 表示属性路径，`zh` / `en` 是 locale 标识）:

| i18n key                              | zh                 | en                      |
| ------------------------------------- | ------------------ | ----------------------- |
| `user.detail.sso.sectionTitle`        | SSO 绑定信息       | SSO Binding             |
| `user.detail.sso.externalId`          | 外部身份 ID        | External ID             |
| `user.detail.sso.externalSource`      | 外部身份来源       | Identity Source         |
| `user.detail.sso.lastLoginAt`         | 最近 SSO 登录时间  | Last SSO Sign-in        |
| `user.detail.sso.source.entra`        | Microsoft Entra    | Microsoft Entra         |
| `user.detail.sso.source.ldap`         | LDAP               | LDAP                    |
| `user.detail.sso.source.ldapPending`  | LDAP / 待 SSO 升级 | LDAP / Pending SSO Upgrade |
| `user.detail.sso.source.none`         | 未绑定             | Not bound               |
| `user.detail.sso.neverLoggedIn`       | 从未通过 SSO 登录  | Never signed in via SSO |
| `user.detail.sso.copyId`              | 复制 ID            | Copy ID                 |

**二期承诺**:

- 「解绑 SSO」/「重置本地密码」按钮在本期**不实现**，独立工单跟进。
- 「Entra 禁用员工后立即失效」、SCIM provisioning、管理员通知邮件均归二期。

**选择器映射**:

```typescript
{
  ssoSection: '[data-testid="user-sso-section"]',
  externalId: '[data-field="externalId"]',
  externalSource: '[data-field="externalSource"]',
  lastSsoLoginAt: '[data-field="lastSsoLoginAt"]',
  copyIdButton: 'button[data-action="copy-external-id"]'
}
```

### 交互行为

#### 1. 页面加载

**触发**: 用户访问用户详情页

**前端行为**:

1. 显示骨架屏
2. 调用详情 API：`GET /api/v1/users/:id`
3. 调用部门归属 API：`GET /api/v1/users/:id/departments`
4. 调用角色 API：`GET /api/v1/users/:id/roles`
5. 渲染详情页面

**错误处理**:

- 404: 显示"用户不存在"，提供返回按钮
- 403: 显示"无权限查看"
- 500: 显示"加载失败"，提供重试按钮

**选择器映射**:

```typescript
{
  loadingSkeleton: '[data-testid="loading-skeleton"]',
  detailCard: '[data-testid="user-detail-card"]',
  errorMessage: '[data-testid="error-message"]',
  retryButton: 'button[data-action="retry"]'
}
```

---

#### 2. 标签页切换

**触发**: 用户点击标签页

**前端行为**:

1. 切换显示内容
2. 更新 URL hash：`#tab=departments`
3. 懒加载数据（如果未加载）

**选择器映射**:

```typescript
{
  tab: '[data-tab]',
  tabPanel: '[data-tab-panel]'
}
```

---

#### 3. 添加部门归属

**触发**: 用户点击"添加部门归属"按钮（在部门归属标签页）

**前端行为**:

1. 弹出对话框
2. 显示部门选择器、岗位选择器、上级选择器
3. 用户填写并提交
4. 调用 API：`POST /api/v1/users/:id/departments`
5. 成功后刷新部门归属列表

**对话框字段**:
| 字段 | 选择器 | 类型 | 必填 |
|------|--------|------|------|
| 部门 | `select[name="departmentId"]` | 下拉框 | ✅ |
| 岗位 | `select[name="positionId"]` | 下拉框 | ✅ |
| 直属上级 | `select[name="managerId"]` | 下拉框 | ❌ |
| 是否主部门 | `checkbox[name="isPrimary"]` | 复选框 | ❌ |

**选择器映射**:

```typescript
{
  addButton: 'button[data-action="add-department"]',
  dialog: '[data-testid="add-department-dialog"]',
  form: 'form[data-form="user-department"]',
  submitButton: 'button[type="submit"]',
  cancelButton: 'button[data-action="cancel"]'
}
```

---

## 🖥️ 页面 3: 新建/编辑用户页

### 基本信息

- **页面标题**: 新建用户 / 编辑用户
- **URL**: `/organization/members/new` 或 `/organization/members/:id/edit`
- **权限要求**: `user:create` 或 `user:update`
- **布局模式**: 标准表单页

### 表单字段定义

| 字段名     | name 属性         | 选择器                          | 类型   | 必填          | 验证规则                 | 说明                  |
| ---------- | ----------------- | ------------------------------- | ------ | ------------- | ------------------------ | --------------------- |
| 身份源     | `source`          | `select[name="source"]`         | 下拉框 | ✅(新建)      | LOCAL/LDAP               | 身份来源              |
| 用户名     | `username`        | `input[name="username"]`        | 文本   | ✅            | 3-50字符，字母数字下划线 | 登录账号              |
| 显示名称   | `displayName`     | `input[name="displayName"]`     | 文本   | ✅            | 1-100字符                | 真实姓名              |
| 邮箱       | `email`           | `input[name="email"]`           | 邮箱   | ✅            | 邮箱格式                 | 电子邮箱              |
| 电话       | `phone`           | `input[name="phone"]`           | 文本   | ❌            | 11位手机号               | 联系电话              |
| 员工编号   | `employeeId`      | `input[name="employeeId"]`      | 文本   | ❌            | 唯一性                   | 工号                  |
| 密码       | `password`        | `input[name="password"]`        | 密码   | ✅(LOCAL新建) | 8-50字符,2种字符类型     | 仅LOCAL用户新建时必填 |
| 确认密码   | `confirmPassword` | `input[name="confirmPassword"]` | 密码   | ✅(LOCAL新建) | 与密码一致               | 仅LOCAL用户新建时显示 |
| LDAP用户名 | `ldapUsername`    | `input[name="ldapUsername"]`    | 文本   | ✅(LDAP新建)  | sAMAccountName           | 仅LDAP用户新建时必填  |
| 部门       | `departmentId`    | `select[name="departmentId"]`   | 下拉框 | ✅            | UUID                     | 主部门                |
| 岗位       | `positionId`      | `select[name="positionId"]`     | 下拉框 | ✅            | UUID                     | 岗位                  |
| 直属上级   | `managerId`       | `select[name="managerId"]`      | 下拉框 | ❌            | UUID                     | 直属上级              |
| 默认区域   | `defaultRegion`   | `select[name="defaultRegion"]`  | 下拉框 | ✅            | 区域代码                 | 默认区域              |

### 交互行为

#### 1. 页面加载（编辑模式）

**触发**: 访问编辑页

**前端行为**:

1. 显示骨架屏
2. 调用详情 API：`GET /api/v1/users/:id`
3. 填充表单数据
4. 密码字段不显示原密码

**选择器映射**:

```typescript
{
  form: 'form[data-form="user"]',
  loadingSkeleton: '[data-testid="form-skeleton"]'
}
```

---

#### 2. 身份源选择（v2.1.1 新增）

**触发**: 用户在新建用户表单中选择身份源

**前端行为**:

1. 显示身份源下拉框（LOCAL/LDAP）
2. 默认选中 LOCAL
3. 根据选择的身份源动态显示/隐藏相关字段:
   - **LOCAL 用户**:
     - 显示"密码"和"确认密码"字段（必填）
     - 隐藏"LDAP用户名"字段
   - **LDAP 用户**:
     - 隐藏"密码"和"确认密码"字段
     - 显示"LDAP用户名"字段（必填）
     - 显示说明："LDAP用户将使用企业LDAP/AD账号登录，无需设置密码"
4. Entra 同步用户不在此表单创建（仅通过同步功能）

**选择器映射**:

```typescript
{
  sourceSelect: 'select[name="source"]',
  passwordInput: 'input[name="password"]',
  confirmPasswordInput: 'input[name="confirmPassword"]',
  ldapUsernameInput: 'input[name="ldapUsername"]',
  ldapHint: '[data-testid="ldap-hint"]'
}
```

**表单验证规则**:

```typescript
const userFormSchema = z.discriminatedUnion("source", [
  // LOCAL 用户
  z
    .object({
      source: z.literal("LOCAL"),
      username: z.string().min(3),
      email: z.string().email(),
      password: z
        .string()
        .min(8)
        .regex(/至少包含2种字符类型/),
      confirmPassword: z.string(),
      // ...
    })
    .refine((data) => data.password === data.confirmPassword, {
      message: "两次密码输入不一致",
      path: ["confirmPassword"],
    }),

  // LDAP 用户
  z.object({
    source: z.literal("LDAP"),
    username: z.string().min(3),
    email: z.string().email(),
    ldapUsername: z.string().min(1, "LDAP用户名不能为空"),
    // 密码字段不需要
  }),
]);
```

**UI 示例**:

```tsx
function CreateUserForm() {
  const [source, setSource] = useState<"LOCAL" | "LDAP">("LOCAL");

  return (
    <form>
      {/* 身份源选择 */}
      <Select
        name="source"
        label="身份源"
        value={source}
        onChange={setSource}
        required
      >
        <Option value="LOCAL">本地用户</Option>
        <Option value="LDAP">LDAP用户</Option>
      </Select>

      {/* 根据身份源显示不同字段 */}
      {source === "LOCAL" && (
        <>
          <PasswordInput
            name="password"
            label="密码"
            required
            hint="至少8位，包含大小写字母、数字或特殊字符中的至少2种"
          />
          <PasswordInput name="confirmPassword" label="确认密码" required />
        </>
      )}

      {source === "LDAP" && (
        <>
          <Input
            name="ldapUsername"
            label="LDAP用户名"
            placeholder="sAMAccountName"
            required
          />
          <Alert type="info" data-testid="ldap-hint">
            LDAP用户将使用企业LDAP/AD账号登录，无需设置密码
          </Alert>
        </>
      )}

      {/* 其他公共字段 */}
      {/* ... */}
    </form>
  );
}
```

---

#### 3. 表单提交

**触发**: 用户点击"提交"按钮

**前端行为**:

1. 验证表单（前端验证）
2. 新建模式：`POST /api/v1/users`
3. 编辑模式：`PATCH /api/v1/users/:id`
4. 成功：
   - 显示成功 Toast
   - 跳转到用户详情页
5. 失败：
   - 显示错误 Toast
   - 高亮错误字段
   - 显示错误提示

**按钮状态**:

- 提交中：按钮禁用，显示"提交中..."
- 验证失败：按钮可用
- 成功/失败后：按钮恢复可用

**选择器映射**:

```typescript
{
  submitButton: 'button[type="submit"]',
  cancelButton: 'button[data-action="cancel"]',
  errorMessage: '[data-testid="error-message"]',
  fieldError: '[data-field-error]'
}
```

**错误响应示例**:

```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "表单验证失败",
    "fields": {
      "email": "邮箱已被使用",
      "username": "用户名不能包含特殊字符"
    }
  }
}
```

---

#### 4. 表单取消

**触发**: 用户点击"取消"按钮

**前端行为**:

1. 检查表单是否已修改
2. 如果已修改：弹出确认对话框
   - 标题：确认离开
   - 内容：未保存的更改将丢失，确定离开吗？
   - 按钮：留在此页、确认离开
3. 路由返回：`router.back()`

**选择器映射**:

```typescript
{
  cancelButton: 'button[data-action="cancel"]',
  confirmDialog: '[data-testid="confirm-leave-dialog"]',
  stayButton: 'button[data-action="stay"]',
  leaveButton: 'button[data-action="leave"]'
}
```

---

### 字段显示规则（v2.1.1 更新）

| 字段       | 新建模式             | 编辑模式               | 说明                 |
| ---------- | -------------------- | ---------------------- | -------------------- |
| 身份源     | 可选择 (LOCAL/LDAP)  | 禁用（不可修改）       | 身份源创建后不可修改 |
| 用户名     | 可编辑               | 禁用（不可修改）       | 用户名创建后不可修改 |
| 显示名称   | 可编辑               | 可编辑                 | -                    |
| 邮箱       | 可编辑               | 可编辑                 | -                    |
| 密码       | 显示并必填 (仅LOCAL) | 隐藏                   | LOCAL用户新建时必填  |
| LDAP用户名 | 显示并必填 (仅LDAP)  | 禁用（显示但不可修改） | LDAP用户新建时必填   |
| 员工编号   | 可编辑               | 可编辑                 | -                    |
| 部门       | 可编辑               | 可编辑                 | -                    |

---

## 🖥️ 页面 4: 组织架构树页

### 基本信息

- **页面标题**: 组织架构
- **URL**: `/organization/departments`
- **权限要求**: `department:read`
- **布局模式**: 双栏布局（左侧树 + 右侧详情）

### 页面元素清单

| 元素名称     | 选择器                               | 类型 | 说明         |
| ------------ | ------------------------------------ | ---- | ------------ |
| 页面标题     | `[data-testid="page-title"]`         | 文本 | 组织架构     |
| 展开全部按钮 | `button[data-action="expand-all"]`   | 按钮 | 展开所有节点 |
| 收起全部按钮 | `button[data-action="collapse-all"]` | 按钮 | 收起所有节点 |
| 部门树容器   | `[data-testid="department-tree"]`    | 容器 | 左侧树形结构 |
| 部门详情卡片 | `[data-testid="department-detail"]`  | 卡片 | 右侧详情     |

### 部门树节点元素

| 元素名称 | 选择器                             | 类型 | 说明            |
| -------- | ---------------------------------- | ---- | --------------- |
| 树节点   | `[data-testid="dept-node-{code}"]` | 容器 | 部门节点        |
| 展开按钮 | `button[data-action="toggle"]`     | 按钮 | 展开/收起子节点 |
| 部门图标 | `[data-icon="department"]`         | 图标 | 🏢或💼          |
| 部门名称 | `[data-field="name"]`              | 文本 | 部门名称        |
| 成员数   | `[data-field="memberCount"]`       | 文本 | (45人)          |

### 交互行为

#### 1. 页面加载

**触发**: 用户访问组织架构页

**前端行为**:

1. 显示骨架屏
2. 调用部门树 API：`GET /api/v1/departments/tree?organizationId={orgId}`
3. 渲染树形结构
4. 默认展开第一层

**选择器映射**:

```typescript
{
  loadingSkeleton: '[data-testid="tree-skeleton"]',
  treeContainer: '[data-testid="department-tree"]',
  errorState: '[data-testid="error-state"]'
}
```

---

#### 2. 节点展开/收起

**触发**: 用户点击展开按钮

**前端行为**:

1. 切换节点展开状态
2. 显示/隐藏子节点
3. 更新展开图标（▶ ↔ ▼）

**选择器映射**:

```typescript
{
  toggleButton: 'button[data-action="toggle"]',
  childrenContainer: '[data-children]'
}
```

---

#### 3. 选择部门

**触发**: 用户点击部门节点

**前端行为**:

1. 高亮选中节点
2. 调用部门详情 API：`GET /api/v1/departments/:id`
3. 调用成员列表 API：`GET /api/v1/departments/:id/members`
4. 更新右侧详情卡片

**选择器映射**:

```typescript
{
  node: '[data-testid="dept-node"]',
  selectedNode: '[data-selected="true"]',
  detailCard: '[data-testid="department-detail"]'
}
```

---

## 🖥️ 页面 4b: 组织架构网格视图（暗色 · 只读）

> Phase 1：只读浏览视图，与页面 4「组织架构树（Canvas）」**共存**。  
> Phase 2 将在此基础上接入 AI Chat 操纵层（独立 PR）。

### 基本信息

- **页面标题**: 组织架构（网格） / Org Chart (Grid)
- **URL**: `/organization/structure/grid`
- **权限要求**: `department:read`（沿用页面 4，不新增权限点）
- **布局模式**: 双栏（左 sidebar 部门列表 · 右主区 home grid 或部门详情）
- **主题**: 暗色（区别于其他亮色页面，由本页 CSS Module 局部接管）
- **数据源**: `GET /api/v1/departments/tree` + `GET /api/v1/departments/:id/members`，**不新增 API**

### 数据 → 视觉映射

| 后端字段 | 视觉位置 | 备注 |
| --- | --- | --- |
| `Department.name` | 卡片名称 / sidebar 项 / 详情 topbar | — |
| `Department.code`（DB 真实 code）| 不在卡片上直接显示（保留供调试） | 卡片"编号"另算 |
| 前端生成"编号" | 卡片左上角 mono 字 `O0` / `1.1` / `1.1.1` | T1=`O{i}`（0-based）/ T2=`{i}.{j+1}` / T3=`{i}.{j+1}.{k+1}` |
| `Department.head.displayName` | 卡片 lead 行 / 详情 topbar 主管 chip | 空值显示 `TBD` |
| `Department.employeeCount` | sidebar 项尾部 badge | 0 时不显示 |
| `Department.description` | T3 卡片 `scope` 行（灰色细字） | 空时不显示该行 |
| `Department.metadata.category` | 决定 6 类配色（exec/eng/ops/biz/corp/fin）| 缺失 fallback 为 `corp`（紫） |
| `Department.children` | 递归构造层级 | `>T3` 深度通过抽屉浏览 |

### Category 颜色与图标

| key | 中文 | 英文 | 主色 | 图标 |
| --- | --- | --- | --- | --- |
| `exec` | 执行层 | Executive | `#e84b3b` | 👑 |
| `eng` | 工程与产品 | Engineering & Product | `#3b8de8` | ⚙️ |
| `ops` | 运营 | Operations | `#2dc88a` | 🏭 |
| `biz` | 商业 | Commercial | `#e8703b` | 📊 |
| `corp` | 公司治理 | Corporate | `#9b7de8` | 🏛️ |
| `fin` | 财务与资本 | Finance & Capital | `#e8b43b` | 💰 |

### 页面元素清单（选择器供 MCP 测试用）

> `{id}` 一律是 `Department.id`（DB UUID）。不用 `{code}`，因为 demo 部分部门
> code 为 `—`（em-dash 占位），tree 内 code 不全局唯一；id 是稳定主键。

| 元素名称 | 选择器 | 类型 | 说明 |
| --- | --- | --- | --- |
| 页面根容器 | `[data-testid="org-grid-page"]` | 容器 | 暗色主题作用域根 |
| Sidebar 容器 | `[data-testid="grid-sidebar"]` | 容器 | 左侧部门列表 |
| Sidebar T1 项 | `[data-testid="grid-sb-item-{id}"]` | 容器 | T1 项；点击展开 / 选中 |
| Sidebar T2 项 | `[data-testid="grid-sb-child-{t1-id}-{idx}"]` | 容器 | T2 项；`t1-id` 为父 T1 id，`idx` 是 T2 在 children 内的索引 |
| Sidebar general | `[data-testid="grid-sb-general-{id}"]` | 容器 | T1 无 children 时显示的「概览」频道行 |
| 主区容器 | `[data-testid="grid-main"]` | 容器 | 右侧主内容 |
| Home grid 卡片 | `[data-testid="grid-home-card-{id}"]` | 卡片 | T1 卡片，点击进部门详情 |
| 详情 topbar | `[data-testid="grid-topbar"]` | 容器 | 部门详情顶栏 |
| 统计卡片 | `[data-testid="grid-stat-{name}"]` | 卡片 | `subDepartments` / `confirmedLeads` / `openTbd` |
| T2 卡片 | `[data-testid="grid-t2-card-{t1-id}-{idx}"]` | 卡片 | 子部门卡片；`t1-id` 为父 T1 id，`idx` 是 T2 在 children 内的索引 |
| T3 卡片 | `[data-testid="grid-t3-card-{id}"]` | 卡片 | 子子部门卡片，点击有更深子树时打开抽屉 |
| 成员行 | `[data-testid="grid-member-row-{idx}"]` | 行 | T2 无 T3 时按成员展示，`idx` 为成员在该 T2 members 数组中的索引 |
| 抽屉容器 | `[data-testid="grid-deep-drawer"]` | 容器 | T4+ 子树抽屉 |
| 抽屉关闭按钮 | `button[data-action="grid-drawer-close"]` | 按钮 | 关抽屉 |

### 交互行为

#### 1. 页面加载

- 显示骨架屏（暗色）
- 调用 `GET /api/v1/departments/tree?organizationId={orgId}`，组织 id 取自首选组织（与页面 4 一致）
- 渲染 sidebar + home grid
- 默认进入 Home 视图（未选中任何部门）

#### 2. 点击 sidebar T1 项

- 第一次点：展开 + 选中（state.t1 设为该 code，state.open[code]=true）
- 已选中再点：仅切换展开状态
- 选中时主区切换为该部门详情视图

#### 3. 点击 T2 项（sidebar 或主区卡片）

- 设置 state.t1 + state.t2idx
- 平滑滚到主区被点 T2 卡片
- 若该 T2 有 children（即 T3）→ 主区下方渲染 T3 grid
- 若该 T2 无 children → 调 `GET /api/v1/departments/:id/members` 渲染成员行

#### 4. 点击 T3 卡片

- 若 T3 自身还有 children（即 T4+）→ 打开右侧抽屉展示其子树
- 若 T3 无 children → 仅视觉态变化（不导航）

#### 5. 点击品牌 / Home

- state 清空，主区切回 home grid

### 边界条件

| 场景 | 处理 |
| --- | --- |
| 当前组织无任何部门 | 主区显示空态："暂无部门数据 / No departments yet" |
| 部门无 head | 卡片 lead 行显示 `TBD`，圆点用灰色 `--text3` |
| 部门 head.displayName 含 "Acting" | 显示 `代理 / Acting` 标签（琥珀色） |
| 部门无 description | 不渲染 scope 行 |
| `metadata.category` 缺失 | fallback 到 `corp`（紫） |
| 树深 >3 层 | T4+ 通过点击 T3 卡片以抽屉展示 |
| 成员请求失败 | 主区显示 inline 错误条 + 重试按钮 |

### 国际化

- 所有文案走 `t.organization.structureGrid.*`
- 类别名走 `t.organization.structureGrid.category.{exec|eng|ops|biz|corp|fin}`
- nav 入口名为 `t.organization.organizationStructureGrid`

### 性能考虑

- 预期 org 部门量级 **20–50**，sidebar 不需要虚拟化
- `/departments/tree` 只调一次，缓存到 page state；切换部门不重复请求
- 成员列表按需 fetch（点 T2 时才调，无 T3 children 才需要）

### 与页面 4 的关系

- **共存**：页面 4（`/structure`，Canvas 亮色）保留
- **入口**：`OrganizationLayout` nav 在「架构图」下方加新项「架构图（网格）」
- **数据**：两者读相同 API，互不影响
- Phase 2 引入 AI Chat 后，会扩展本页面 UI spec，**不影响页面 4**

---

## 🖥️ 页面 5: 组织管理页面（v2.0 新增）

### 基本信息

- **页面标题**: 组织管理
- **URL**: `/organization/organizations`
- **权限要求**: `organization:read`
- **布局模式**: 标准列表页

### 页面元素清单

| 元素名称 | 选择器                                           | 类型     | 说明              |
| -------- | ------------------------------------------------ | -------- | ----------------- |
| 页面标题 | `[data-testid="page-title"]`                     | 文本     | 组织管理          |
| 搜索框   | `input[data-testid="organization-search-input"]` | 输入框   | 搜索组织名称/代码 |
| 区域筛选 | `select[data-testid="region-filter"]`            | 下拉选择 | 按区域筛选        |
| 状态筛选 | `select[data-testid="status-filter"]`            | 下拉选择 | 按状态筛选        |
| 新建按钮 | `button[data-action="create-organization"]`      | 按钮     | 创建新组织        |
| 组织表格 | `table[data-testid="organization-table"]`        | 表格     | 组织列表          |
| 分页组件 | `[data-testid="pagination"]`                     | 分页     | 分页导航          |

### 表格列定义

| 列名     | 字段                 | 宽度  | 排序 | 说明                                |
| -------- | -------------------- | ----- | ---- | ----------------------------------- |
| 组织代码 | `code`               | 120px | ✅   | 唯一标识                            |
| 组织名称 | `name`               | 200px | ✅   | 显示名称                            |
| 主要区域 | `primaryRegion.name` | 120px | ❌   | 主要运营区域                        |
| 部门数   | `departmentCount`    | 100px | ✅   | 部门总数                            |
| 员工数   | `employeeCount`      | 100px | ✅   | 员工总数                            |
| 状态     | `status`             | 100px | ❌   | ACTIVE/INACTIVE/SUSPENDED/DISSOLVED |
| 成立日期 | `establishedAt`      | 120px | ✅   | ISO 8601 Date                       |
| 操作     | -                    | 150px | ❌   | 查看/编辑/删除                      |

### 交互行为

#### 1. 页面加载

**触发**: 用户访问组织管理页

**前端行为**:

1. 显示骨架屏
2. 调用 API：`GET /api/v1/organizations?page=1&limit=20`
3. 渲染表格数据
4. 加载区域列表：`GET /api/v1/regions`（用于筛选）

**选择器映射**:

```typescript
{
  loadingSkeleton: '[data-testid="table-skeleton"]',
  tableContainer: 'table[data-testid="organization-table"]',
  errorState: '[data-testid="error-state"]'
}
```

---

#### 2. 搜索组织

**触发**: 用户在搜索框输入关键词

**前端行为**:

1. 防抖处理（300ms）
2. 调用 API：`GET /api/v1/organizations?search={keyword}&page=1`
3. 更新表格数据
4. 重置页码为 1
5. 更新 URL 参数：`?search={keyword}`

**显示规则**:

- 搜索中：输入框显示 Loading 图标
- 无结果：显示"未找到匹配结果，请尝试其他关键词"
- 成功：高亮匹配的文字

**选择器映射**:

```typescript
{
  searchInput: 'input[data-testid="organization-search-input"]',
  searchIcon: '[data-testid="search-icon"]',
  loadingIcon: '[data-testid="search-loading"]',
  noResultsMessage: '[data-testid="no-results"]',
  clearButton: 'button[data-action="clear-search"]'
}
```

---

#### 3. 区域筛选

**触发**: 用户选择区域筛选项

**前端行为**:

1. 获取选中的区域 ID
2. 调用 API：`GET /api/v1/organizations?regionId={regionId}&page=1`
3. 更新表格数据
4. 更新 URL 参数

**筛选选项**:

```typescript
const regionOptions = [
  { label: "全部区域", value: "" },
  { label: "中国", value: "region-cn" },
  { label: "美国", value: "region-us" },
  { label: "阿联酋", value: "region-uae" },
];
```

---

#### 4. 状态筛选

**触发**: 用户选择状态筛选项

**前端行为**:

1. 获取选中的状态值
2. 调用 API：`GET /api/v1/organizations?status={status}&page=1`
3. 更新表格数据

**筛选选项**:

```typescript
const statusOptions = [
  { label: "全部状态", value: "" },
  { label: "激活", value: "ACTIVE", badge: "success" },
  { label: "停用", value: "INACTIVE", badge: "neutral" },
  { label: "暂停", value: "SUSPENDED", badge: "warning" },
  { label: "已解散", value: "DISSOLVED", badge: "error" },
];
```

---

#### 5. 新建组织

**触发**: 用户点击"新建组织"按钮

**前置条件**:

- 用户拥有 `organization:create` 权限

**前端行为**:

1. 路由跳转：`router.push('/organization/organizations/new')`

**按钮状态**:

- 有权限：按钮可点击，显示主要样式
- 无权限：按钮隐藏

**选择器映射**:

```typescript
{
  createButton: 'button[data-action="create-organization"]';
}
```

---

#### 6. 查看详情

**触发**: 用户点击表格行或"查看"按钮

**前端行为**:

1. 获取组织 ID
2. 路由跳转：`router.push('/organization/organizations/${id}')`

**选择器映射**:

```typescript
{
  tableRow: 'tr[data-testid="organization-row"]',
  viewButton: 'button[data-action="view"]'
}
```

---

#### 7. 编辑组织

**触发**: 用户点击"编辑"按钮

**前置条件**:

- 用户拥有 `organization:update` 权限

**前端行为**:

1. 获取组织 ID
2. 路由跳转：`router.push('/organization/organizations/${id}/edit')`

**选择器映射**:

```typescript
{
  editButton: 'button[data-action="edit"]';
}
```

---

#### 8. 删除组织

**触发**: 用户点击"删除"按钮

**前置条件**:

- 用户拥有 `organization:delete` 权限
- 组织状态允许删除（无部门、无员工）

**前端行为**:

1. 显示确认对话框："确定要删除组织 {name} 吗？此操作不可恢复。"
2. 用户确认后调用 API：`DELETE /api/v1/organizations/:id`
3. 删除成功后刷新列表
4. 显示成功提示："组织删除成功"

**错误处理**:

- 组织下有部门：显示错误提示"无法删除：组织下还有部门，请先删除所有部门"
- 组织下有员工：显示错误提示"无法删除：组织下还有员工，请先移除所有员工"

**选择器映射**:

```typescript
{
  deleteButton: 'button[data-action="delete"]',
  confirmDialog: '[data-testid="delete-confirm-dialog"]',
  confirmButton: 'button[data-action="confirm-delete"]',
  cancelButton: 'button[data-action="cancel-delete"]'
}
```

---

## 🖥️ 页面 6: 组织详情页（v2.0 新增）

### 基本信息

- **页面标题**: 组织详情 - {组织名称}
- **URL**: `/organization/organizations/:id`
- **权限要求**: `organization:read`
- **布局模式**: 详情页 + 标签页

### 页面元素清单

| 元素名称         | 选择器                           | 类型 | 说明                     |
| ---------------- | -------------------------------- | ---- | ------------------------ |
| 返回按钮         | `button[data-action="back"]`     | 按钮 | 返回列表                 |
| 编辑按钮         | `button[data-action="edit"]`     | 按钮 | 编辑组织                 |
| 组织基本信息卡片 | `[data-testid="org-basic-info"]` | 卡片 | 基本信息                 |
| 组织区域配置卡片 | `[data-testid="org-regions"]`    | 卡片 | 运营区域                 |
| 组织统计卡片     | `[data-testid="org-stats"]`      | 卡片 | 部门数、员工数           |
| 标签页导航       | `[data-testid="tabs"]`           | 导航 | 基本信息/部门树/员工列表 |

### 标签页定义

| 标签页   | 内容                               | 权限要求            |
| -------- | ---------------------------------- | ------------------- |
| 基本信息 | 组织代码、名称、法人信息、联系方式 | `organization:read` |
| 部门树   | 组织下的完整部门树                 | `department:read`   |
| 员工列表 | 组织下的所有员工                   | `user:read`         |

### 交互行为

#### 1. 页面加载

**触发**: 用户访问组织详情页

**前端行为**:

1. 显示骨架屏
2. 调用 API：`GET /api/v1/organizations/:id`
3. 渲染基本信息
4. 默认显示"基本信息"标签页

**选择器映射**:

```typescript
{
  loadingSkeleton: '[data-testid="detail-skeleton"]',
  detailContainer: '[data-testid="organization-detail"]',
  errorState: '[data-testid="error-state"]'
}
```

---

#### 2. 切换标签页

**触发**: 用户点击标签页

**前端行为**:

1. 切换标签页高亮状态
2. 根据标签页加载对应内容：
   - 基本信息：已加载，直接显示
   - 部门树：调用 `GET /api/v1/organizations/:id/departments`
   - 员工列表：调用 `GET /api/v1/users?organizationId={id}`

**选择器映射**:

```typescript
{
  tabNav: '[data-testid="tabs"]',
  tabItem: '[data-tab="basic-info"]',
  tabContent: '[data-testid="tab-content"]'
}
```

---

#### 3. 编辑组织

**触发**: 用户点击"编辑"按钮

**前端行为**:

1. 路由跳转：`router.push('/organization/organizations/${id}/edit')`

---

## 🖥️ 页面 7: 区域管理页面

### 基本信息

- **页面标题**: 区域管理
- **URL**: `/organization/regions`
- **权限要求**: `region:read`
- **布局模式**: 标准列表页

### 页面元素清单

| 元素名称 | 选择器                                     | 类型   | 说明              |
| -------- | ------------------------------------------ | ------ | ----------------- |
| 页面标题 | `[data-testid="page-title"]`               | 文本   | 区域管理          |
| 搜索框   | `input[data-testid="region-search-input"]` | 输入框 | 搜索区域名称/代码 |
| 新建按钮 | `button[data-action="create-region"]`      | 按钮   | 创建新区域        |
| 区域表格 | `table[data-testid="region-table"]`        | 表格   | 区域列表          |
| 分页组件 | `[data-testid="pagination"]`               | 分页   | 分页导航          |

### 表格列定义

| 列名       | 字段                | 宽度  | 排序 | 说明               |
| ---------- | ------------------- | ----- | ---- | ------------------ |
| 区域代码   | `code`              | 100px | ✅   | CN/US/UAE          |
| 区域名称   | `name`              | 150px | ✅   | 中国/美国/阿联酋   |
| 时区       | `timezone`          | 150px | ❌   | Asia/Shanghai      |
| 货币       | `currency`          | 100px | ❌   | CNY/USD/AED        |
| 语言       | `language`          | 100px | ❌   | zh-CN/en-US/ar-AE  |
| 关联组织数 | `organizationCount` | 120px | ✅   | 使用该区域的组织数 |
| 操作       | -                   | 150px | ❌   | 查看/编辑/删除     |

### 交互行为

#### 1. 页面加载

**触发**: 用户访问区域管理页

**前端行为**:

1. 显示骨架屏
2. 调用 API：`GET /api/v1/regions?page=1&limit=20`
3. 渲染表格数据

**选择器映射**:

```typescript
{
  loadingSkeleton: '[data-testid="table-skeleton"]',
  tableContainer: 'table[data-testid="region-table"]',
  errorState: '[data-testid="error-state"]'
}
```

---

#### 2. 搜索区域

**触发**: 用户在搜索框输入关键词

**前端行为**:

1. 防抖处理（300ms）
2. 调用 API：`GET /api/v1/regions?search={keyword}&page=1`
3. 更新表格数据
4. 重置页码为 1

**选择器映射**:

```typescript
{
  searchInput: 'input[data-testid="region-search-input"]',
  searchIcon: '[data-testid="search-icon"]',
  loadingIcon: '[data-testid="search-loading"]',
  noResultsMessage: '[data-testid="no-results"]'
}
```

---

#### 3. 新建区域

**触发**: 用户点击"新建区域"按钮

**前置条件**:

- 用户拥有 `region:create` 权限

**前端行为**:

1. 路由跳转：`router.push('/organization/regions/new')`

**选择器映射**:

```typescript
{
  createButton: 'button[data-action="create-region"]';
}
```

---

#### 4. 编辑区域

**触发**: 用户点击"编辑"按钮

**前置条件**:

- 用户拥有 `region:update` 权限

**前端行为**:

1. 获取区域 ID
2. 路由跳转：`router.push('/organization/regions/${id}/edit')`

**选择器映射**:

```typescript
{
  editButton: 'button[data-action="edit"]';
}
```

---

#### 5. 删除区域

**触发**: 用户点击"删除"按钮

**前置条件**:

- 用户拥有 `region:delete` 权限
- 区域未被组织使用（organizationCount = 0）

**前端行为**:

1. 显示确认对话框："确定要删除区域 {name} 吗？"
2. 用户确认后调用 API：`DELETE /api/v1/regions/:id`
3. 删除成功后刷新列表

**错误处理**:

- 区域被组织使用：显示错误提示"无法删除：该区域正在被组织使用"

**选择器映射**:

```typescript
{
  deleteButton: 'button[data-action="delete"]',
  confirmDialog: '[data-testid="delete-confirm-dialog"]',
  confirmButton: 'button[data-action="confirm-delete"]'
}
```

---

## 🖥️ 页面 8: 岗位管理页面

### 基本信息

- **页面标题**: 岗位管理
- **URL**: `/organization/positions`
- **权限要求**: `position:read`
- **布局模式**: 标准列表页

### 页面元素清单

| 元素名称 | 选择器                                       | 类型   | 说明              |
| -------- | -------------------------------------------- | ------ | ----------------- |
| 页面标题 | `[data-testid="page-title"]`                 | 文本   | 岗位管理          |
| 搜索框   | `input[data-testid="position-search-input"]` | 输入框 | 搜索岗位名称/代码 |
| 新建按钮 | `button[data-action="create-position"]`      | 按钮   | 创建新岗位        |
| 岗位表格 | `table[data-testid="position-table"]`        | 表格   | 岗位列表          |
| 分页组件 | `[data-testid="pagination"]`                 | 分页   | 分页导航          |

### 表格列定义

| 列名       | 字段          | 宽度  | 排序 | 说明               |
| ---------- | ------------- | ----- | ---- | ------------------ |
| 岗位代码   | `code`        | 120px | ✅   | 唯一标识           |
| 岗位名称   | `name`        | 200px | ✅   | 显示名称           |
| 职级       | `level`       | 100px | ✅   | P1-P10             |
| 描述       | `description` | 300px | ❌   | 岗位描述           |
| 关联用户数 | `userCount`   | 120px | ✅   | 使用该岗位的用户数 |
| 操作       | -             | 150px | ❌   | 查看/编辑/删除     |

### 交互行为

#### 1. 页面加载

**触发**: 用户访问岗位管理页

**前端行为**:

1. 显示骨架屏
2. 调用 API：`GET /api/v1/positions?page=1&limit=20`
3. 渲染表格数据

**选择器映射**:

```typescript
{
  loadingSkeleton: '[data-testid="table-skeleton"]',
  tableContainer: 'table[data-testid="position-table"]',
  errorState: '[data-testid="error-state"]'
}
```

---

#### 2. 搜索岗位

**触发**: 用户在搜索框输入关键词

**前端行为**:

1. 防抖处理（300ms）
2. 调用 API：`GET /api/v1/positions?search={keyword}&page=1`
3. 更新表格数据
4. 重置页码为 1

**选择器映射**:

```typescript
{
  searchInput: 'input[data-testid="position-search-input"]',
  searchIcon: '[data-testid="search-icon"]',
  loadingIcon: '[data-testid="search-loading"]',
  noResultsMessage: '[data-testid="no-results"]'
}
```

---

#### 3. 新建岗位

**触发**: 用户点击"新建岗位"按钮

**前置条件**:

- 用户拥有 `position:create` 权限

**前端行为**:

1. 路由跳转：`router.push('/organization/positions/new')`

**选择器映射**:

```typescript
{
  createButton: 'button[data-action="create-position"]';
}
```

---

#### 4. 编辑岗位

**触发**: 用户点击"编辑"按钮

**前置条件**:

- 用户拥有 `position:update` 权限

**前端行为**:

1. 获取岗位 ID
2. 路由跳转：`router.push('/organization/positions/${id}/edit')`

**选择器映射**:

```typescript
{
  editButton: 'button[data-action="edit"]';
}
```

---

#### 5. 删除岗位

**触发**: 用户点击"删除"按钮

**前置条件**:

- 用户拥有 `position:delete` 权限
- 岗位未被用户使用（userCount = 0）

**前端行为**:

1. 显示确认对话框："确定要删除岗位 {name} 吗？"
2. 用户确认后调用 API：`DELETE /api/v1/positions/:id`
3. 删除成功后刷新列表

**错误处理**:

- 岗位被用户使用：显示错误提示"无法删除：该岗位正在被用户使用"

**选择器映射**:

```typescript
{
  deleteButton: 'button[data-action="delete"]',
  confirmDialog: '[data-testid="delete-confirm-dialog"]',
  confirmButton: 'button[data-action="confirm-delete"]'
}
```

---

## 🖥️ 页面 9: 角色管理页面

### 基本信息

- **页面标题**: 角色管理
- **URL**: `/organization/roles`
- **权限要求**: `role:read`
- **布局模式**: 标准列表页

### 页面元素清单

| 元素名称 | 选择器                                   | 类型   | 说明              |
| -------- | ---------------------------------------- | ------ | ----------------- |
| 页面标题 | `[data-testid="page-title"]`             | 文本   | 角色管理          |
| 搜索框   | `input[data-testid="role-search-input"]` | 输入框 | 搜索角色名称/代码 |
| 新建按钮 | `button[data-action="create-role"]`      | 按钮   | 创建新角色        |
| 角色表格 | `table[data-testid="role-table"]`        | 表格   | 角色列表          |
| 分页组件 | `[data-testid="pagination"]`             | 分页   | 分页导航          |

### 表格列定义

| 列名     | 字段              | 宽度  | 排序 | 说明                                     |
| -------- | ----------------- | ----- | ---- | ---------------------------------------- |
| 角色名称 | `name`            | 200px | ✅   | 显示名称                                 |
| 角色代码 | `code`            | 150px | ✅   | Administrator/Employee/DepartmentManager |
| 描述     | `description`     | 300px | ❌   | 角色描述                                 |
| 类型     | `isBuiltIn`       | 100px | ✅   | 内置/自定义（徽章）                      |
| 状态     | `enabled`         | 80px  | ✅   | 启用/禁用（徽章）                        |
| 用户数   | `userCount`       | 100px | ✅   | 使用该角色的用户数                       |
| 权限数   | `permissionCount` | 100px | ✅   | 角色拥有的权限数                         |
| 操作     | -                 | 200px | ❌   | 查看/编辑权限/删除                       |

### 交互行为

#### 1. 页面加载

**触发**: 用户访问角色管理页

**前端行为**:

1. 显示骨架屏
2. 调用 API：`GET /api/v1/roles?page=1&limit=20`
3. 渲染表格数据

**选择器映射**:

```typescript
{
  loadingSkeleton: '[data-testid="table-skeleton"]',
  tableContainer: 'table[data-testid="role-table"]',
  errorState: '[data-testid="error-state"]'
}
```

---

#### 2. 搜索角色

**触发**: 用户在搜索框输入关键词

**前端行为**:

1. 防抖处理（300ms）
2. 调用 API：`GET /api/v1/roles?search={keyword}&page=1`
3. 更新表格数据
4. 重置页码为 1

**选择器映射**:

```typescript
{
  searchInput: 'input[data-testid="role-search-input"]',
  searchIcon: '[data-testid="search-icon"]',
  loadingIcon: '[data-testid="search-loading"]',
  noResultsMessage: '[data-testid="no-results"]'
}
```

---

#### 3. 新建角色

**触发**: 用户点击"新建角色"按钮

**前置条件**:

- 用户拥有 `role:create` 权限

**前端行为**:

1. 路由跳转：`router.push('/organization/roles/new')`

**选择器映射**:

```typescript
{
  createButton: 'button[data-action="create-role"]';
}
```

---

#### 4. 编辑权限

**触发**: 用户点击"编辑权限"按钮

**前置条件**:

- 用户拥有 `role:manage` 权限

**前端行为**:

1. 获取角色 ID
2. 路由跳转：`router.push('/organization/roles/${id}/permissions')`

**选择器映射**:

```typescript
{
  editPermissionsButton: 'button[data-action="edit-permissions"]';
}
```

---

#### 5. 删除角色

**触发**: 用户点击"删除"按钮

**前置条件**:

- 用户拥有 `role:delete` 权限
- 角色不是内置角色（`isBuiltIn = false`）
- 角色未被用户使用（`userCount = 0`）

**前端行为**:

1. 显示确认对话框："确定要删除角色 {name} 吗？"
2. 用户确认后调用 API：`DELETE /api/v1/roles/:id`
3. 删除成功后刷新列表

**错误处理**:

- 内置角色：显示错误提示"无法删除：内置角色不可删除"
- 角色被用户使用：显示错误提示"无法删除：该角色正在被用户使用"

**选择器映射**:

```typescript
{
  deleteButton: 'button[data-action="delete"]',
  confirmDialog: '[data-testid="delete-confirm-dialog"]',
  confirmButton: 'button[data-action="confirm-delete"]'
}
```

---

## 🖥️ 页面 10: 权限配置页面

### 基本信息

- **页面标题**: 配置角色权限
- **URL**: `/organization/roles/:id/permissions`
- **权限要求**: `role:manage`
- **布局模式**: 表单页（权限树形选择器）

### 页面元素清单

| 元素名称      | 选择器                             | 类型     | 说明                      |
| ------------- | ---------------------------------- | -------- | ------------------------- |
| 页面标题      | `[data-testid="page-title"]`       | 文本     | 配置角色权限 - {角色名称} |
| 返回按钮      | `button[data-action="back"]`       | 按钮     | 返回角色列表              |
| 角色信息卡片  | `[data-testid="role-info-card"]`   | 卡片     | 显示角色基本信息          |
| 权限树        | `[data-testid="permission-tree"]`  | 树形组件 | 权限分组树形选择器        |
| 全选/取消全选 | `button[data-action="select-all"]` | 按钮     | 全选/取消全选             |
| 保存按钮      | `button[data-action="save"]`       | 按钮     | 保存权限配置              |
| 取消按钮      | `button[data-action="cancel"]`     | 按钮     | 取消编辑                  |

### 权限树结构

权限按资源分组显示，每个资源下包含多个操作权限：

```
用户管理 (user)
  ├── user:read:own
  ├── user:read:department
  ├── user:read:organization
  ├── user:read
  ├── user:create
  ├── user:update
  └── user:delete

部门管理 (department)
  ├── department:read
  ├── department:create
  ├── department:update
  └── department:delete

角色管理 (role)
  ├── role:read
  ├── role:create
  ├── role:update
  ├── role:delete
  └── role:manage
```

### 交互行为

#### 1. 页面加载

**触发**: 用户访问权限配置页

**前端行为**:

1. 显示骨架屏
2. 调用角色详情 API：`GET /api/v1/roles/:id`
3. 调用权限列表 API：`GET /api/v1/permissions`
4. 调用角色权限 API：`GET /api/v1/roles/:id/permissions`
5. 渲染权限树，已选权限显示为选中状态

**选择器映射**:

```typescript
{
  loadingSkeleton: '[data-testid="form-skeleton"]',
  roleInfoCard: '[data-testid="role-info-card"]',
  permissionTree: '[data-testid="permission-tree"]',
  errorState: '[data-testid="error-state"]'
}
```

---

#### 2. 权限选择

**触发**: 用户点击权限复选框

**前端行为**:

1. 切换复选框状态
2. 如果选择父节点，自动选中所有子节点
3. 如果取消父节点，自动取消所有子节点
4. 如果所有子节点都被选中，自动选中父节点
5. 更新已选权限计数显示

**选择器映射**:

```typescript
{
  permissionCheckbox: 'input[type="checkbox"][data-permission-id]',
  resourceGroup: '[data-resource-group]',
  selectAllButton: 'button[data-action="select-all"]'
}
```

---

#### 3. 保存权限配置

**触发**: 用户点击"保存"按钮

**前端行为**:

1. 收集所有选中的权限 ID
2. 调用 API：`PUT /api/v1/roles/:id/permissions`
3. 请求体：`{ permissionIds: [...] }`
4. 成功：
   - 显示成功 Toast："权限配置已保存"
   - 延迟 1 秒后返回角色列表页
5. 失败：
   - 显示错误 Toast
   - 保持当前页面

**按钮状态**:

- 提交中：按钮禁用，显示"保存中..."
- 验证失败：按钮可用
- 成功/失败后：按钮恢复可用

**选择器映射**:

```typescript
{
  saveButton: 'button[data-action="save"]',
  cancelButton: 'button[data-action="cancel"]',
  errorMessage: '[data-testid="error-message"]'
}
```

---

#### 4. 取消编辑

**触发**: 用户点击"取消"按钮

**前端行为**:

1. 检查是否有未保存的更改
2. 如果有更改：弹出确认对话框
   - 标题：确认离开
   - 内容：未保存的更改将丢失，确定离开吗？
   - 按钮：留在此页、确认离开
3. 路由返回：`router.back()`

**选择器映射**:

```typescript
{
  cancelButton: 'button[data-action="cancel"]',
  confirmDialog: '[data-testid="unsaved-changes-dialog"]'
}
```

---

## 🖥️ 页面 11: 登录页面（v2.4 升级：SSO + 密码双通道）

> **v2.4 升级（含 2026-05-20 UI 调整）**：登录页保留 v2.1.1 的密码登录主页面**操作习惯零变化**，在密码表单下方加分隔线 + 次要按钮「Sign in with Microsoft」走 Entra ID OIDC。两条通道完全并存，互不影响。
>
> **设计理由**：业界标准（GitHub / Stripe / Linear / Vercel / Notion 等）SSO 全部是次要按钮，避免老用户被迫学新操作。SSO 改主按钮反而是反模式。

### 基本信息

- **页面标题**: 用户登录
- **URL**: `/login`
- **权限要求**: 公开访问
- **布局模式**: 居中卡片式登录页（密码表单始终展开 + 下方分隔线 + SSO 次按钮）

### 布局决策

- **密码登录始终主位**（2026-05-20 调整，原 v2.4「双按钮并列」已废）：username + password + 登录主按钮始终展开渲染，沿用 v2.1.1 全部行为。
- 密码表单下方分隔线「── 或 / or ──」隔开次按钮区。
- 次按钮「Sign in with Microsoft」: outline 样式（透明背景 + 白色边框 + hover 微软四色 logo），点击发起 OIDC 跳转。
- 不采用「统一邮箱框 + Continue」型自动嗅探 UI（产品决策否决）。
- itadmin / 外部员工 / 应急场景默认走密码登录入口，**与 SSO 通道完全独立，不受 Entra 可用性影响**。

### 页面元素清单

| 元素名称           | 选择器                                  | 类型     | 必填 | 说明                                          |
| ------------------ | --------------------------------------- | -------- | ---- | --------------------------------------------- |
| 系统Logo           | `[data-testid="app-logo"]`              | 图片     | -    | 系统品牌标识                                  |
| 页面标题           | `[data-testid="page-title"]`            | 文本     | -    | "欢迎登录 FFOA"                               |
| Microsoft 登录按钮 | `button[data-action="sso-microsoft"]`   | 按钮     | -    | **次按钮**，密码表单下方分隔线之后，跳转 SSO authorize（v2.4 新增，2026-05-20 改次） |
| ~~密码登录入口按钮~~ | ~~`button[data-action="show-password"]`~~ | ~~按钮~~  | ~~-~~ | ~~v2.4 初版"双按钮并列"已废（2026-05-20 改回密码主页面默认展开），此按钮删除~~ |
| SSO 跳转中遮罩     | `[data-testid="sso-redirecting"]`       | 加载态   | -    | 跳转 Microsoft 时的全屏 spinner（v2.4 新增）  |
| 回调完成中遮罩     | `[data-testid="sso-completing"]`        | 加载态   | -    | callback 阶段的 spinner（v2.4 新增）          |
| 用户名输入框       | `input[name="username"]`                | 文本     | ✅   | 用户名或邮箱（折叠在密码表单内）              |
| 密码输入框         | `input[name="password"]`                | 密码     | ✅   | 登录密码                                      |
| 记住我复选框       | `input[name="rememberMe"]`              | 复选框   | ❌   | 7天免登录                                     |
| 忘记密码链接       | `a[data-action="forgot-password"]`      | 链接     | -    | 跳转密码重置                                  |
| 密码登录提交按钮   | `button[type="submit"]`                 | 按钮     | -    | 提交密码登录                                  |
| 错误提示区域       | `[data-testid="login-error"]`           | 文本     | -    | 显示登录错误信息                              |
| SSO 错误 Toast     | `[data-testid="sso-error-toast"]`       | Toast    | -    | callback 返回错误码时的提示（v2.4 新增）      |

### 表单字段定义

| 字段名 | name 属性    | 选择器                     | 类型   | 必填 | 验证规则 | 说明         |
| ------ | ------------ | -------------------------- | ------ | ---- | -------- | ------------ |
| 用户名 | `username`   | `input[name="username"]`   | 文本   | ✅   | 非空     | 用户名或邮箱 |
| 密码   | `password`   | `input[name="password"]`   | 密码   | ✅   | 非空     | 登录密码     |
| 记住我 | `rememberMe` | `input[name="rememberMe"]` | 复选框 | ❌   | -        | 7天免登录    |

> **注**：表单字段仅在用户点击「用密码登录」展开后显示并参与提交，初始折叠状态不参与校验。

### 国际化文案（v2.4 新增）

> **i18n 结构说明**：本项目 i18n 用**嵌套 TS 对象**（见 `frontend/src/lib/i18n.ts:18` `Translations = {[key: string]: string | Translations}`，访问形如 `t.auth.sso.button.microsoft`）；下表 dotted key 实际为**属性路径**，文件位于 `frontend/src/locales/auth/{zh,en}.ts`，**locale 标识为 `zh` / `en`**（不是 `zh-CN` / `en-US`）。

| i18n key（属性路径）              | zh                                 | en                                     |
| --------------------------------- | ---------------------------------- | -------------------------------------- |
| `auth.sso.button.microsoft`       | 用微软账号登录                     | Sign in with Microsoft                 |
| `auth.sso.button.password`        | 用密码登录                         | Sign in with password                  |
| `auth.sso.status.redirecting`     | 正在跳转到 Microsoft 登录..        | Redirecting to Microsoft sign-in...    |
| `auth.sso.status.completing`      | 正在完成登录..                     | Completing sign-in...                  |
| `auth.sso.error.domainNotAllowed` | 你的邮箱域名暂未授权使用 SSO 登录，请联系 IT 或改用密码登录 | Your email domain is not authorized for SSO sign-in. Contact IT or use password sign-in. |
| `auth.sso.error.tokenInvalid`     | SSO 登录校验失败，请重试           | SSO verification failed. Please try again. |
| `auth.sso.error.emailMissing`     | 你的微软账号未配置邮箱，请联系 IT  | Your Microsoft account is missing an email. Contact IT. |
| `auth.sso.error.bindingConflict`  | SSO 账号绑定冲突，请联系 itadmin   | SSO binding conflict. Contact itadmin. |
| `auth.sso.error.providerUnavailable` | Microsoft 登录暂时不可用，请改用密码登录 | Microsoft sign-in unavailable. Please use password sign-in. |
| `auth.sso.error.userCancelled`    | 你已取消 Microsoft 登录            | You cancelled Microsoft sign-in.       |
| `auth.sso.error.consentRequired`  | Microsoft 要求重新授权，请重试登录 | Microsoft requires re-consent. Please sign in again. |
| `auth.sso.error.providerRejected` | Microsoft 登录被拒绝，请联系 IT    | Microsoft sign-in was rejected. Contact IT. |

> 所有新增文案必须 `zh` / `en` 双语；E2E 测试切换 locale 时验证 missing key warning 为零。

### 交互行为

#### 1. 页面加载

**触发**: 用户访问登录页

**前端行为**:

1. 检查是否已登录（存在有效Token）
2. 如果已登录：跳转到首页
3. 如果未登录：显示登录页（两个按钮 + Logo），密码表单**默认折叠**
4. 从 localStorage 恢复"记住我"状态（如果有）
5. 读取 URL 参数 `redirect`（如 `/login?redirect=%2Forganization%2Fmembers`），用于登录成功后回跳（**SSO 与密码通道共用同一 redirect 语义**）
6. 读取 URL 参数 `ssoError`（callback 失败回跳时携带），若存在则映射到对应 toast 文案展示（见错误状态段）

**选择器映射**:

```typescript
{
  loginPage: '[data-testid="login-page"]',
  ssoButton: 'button[data-action="sso-microsoft"]',
  showPasswordButton: 'button[data-action="show-password"]',
  loginForm: 'form[data-form="login"]',
  usernameInput: 'input[name="username"]',
  passwordInput: 'input[name="password"]',
  submitButton: 'button[type="submit"]'
}
```

---

#### 2. SSO 登录（v2.4 新增 · 主路径）

**触发**: 用户点击「用微软账号登录」按钮

**前端行为**:

1. **按钮立即 disabled**（防止用户点第二次触发并发 OIDC start），直到跳转中遮罩消失（即页面已被浏览器跳走，按钮 disabled 状态对用户不可见）
2. 显示全屏遮罩 + spinner，文案 `auth.sso.status.redirecting`
3. 浏览器导航跳转：`GET /api/v1/auth/sso/start?redirect=<encodeURIComponent(当前 redirect 参数或 /overview)>`
4. 后端 302 → Microsoft authorize endpoint，浏览器进入 Microsoft 域，前端控制权移交
5. **成功回调**：后端 callback 302 至 `${sso_redirect}#accessToken=<jwt>&refreshToken=<jwt>`（URL fragment 注入 token，**不**经服务器日志）：
   - 前端 `/sso/landing` 路由（或 `sso_redirect` 对应业务页的 `useEffect` 前置）显示遮罩 + 文案 `auth.sso.status.completing`
   - 读 `location.hash` 解析出 `accessToken` / `refreshToken`
   - 写入 localStorage（key 与现有 password 登录一致：`accessToken` / `refreshToken`）
   - `history.replaceState('', '', <sso_redirect 的 pathname+query，去 hash>)` 清掉 hash，防止 token 通过 referrer 泄漏 / browser history 残留
   - `useRouter().push(<sso_redirect>)` 进入业务页（或 `/overview`，依据 `sso_redirect` 解析结果）
6. **失败回调**：后端 callback 302 至 `/login?ssoError=<CODE>`；登录页 `useEffect` 读 `?ssoError=` query → showToast → `history.replaceState('', '', '/login')` 清 query。详见「错误状态」段

**约束**:

- 跳转中态必须可见（不要瞬时切走），便于用户感知；超时 30s 内未回则视为 provider 不可用走错误流
- 不在前端做 MFA / Conditional Access 校验，**完全相信 Entra**（关键产品决策）
- `sso_redirect` 仅允许同源相对路径（`/` 开头且不以 `//` 开头），与 `frontend/src/lib/auth-redirect.ts` `isSafeRelativePath` 一致；非法 redirect 回退到 `DEFAULT_POST_LOGIN_PATH = '/overview'`
- **fragment 注入安全性**：URL fragment 不进入服务器日志 / referrer / network request；写 localStorage 后立即 `history.replaceState` 清掉，防止用户分享 URL 时泄漏 token

**`/sso/landing` 路由规格**:

- 路径：`frontend/src/app/(auth)/sso/landing/page.tsx`（与 `(auth)` 布局组同层，复用登录页布局壳）
- 渲染：全屏 spinner + 文案 `auth.sso.status.completing`，无任何用户交互入口
- 生命周期（`useEffect` 一次性）：
  1. 读 `window.location.hash`，正则解析出 `accessToken` / `refreshToken`（缺一不可，否则视为非法访问 → `router.replace('/login?ssoError=SSO_TOKEN_INVALID')`）
  2. 写 localStorage
  3. `history.replaceState('', '', <pathname + search>)` 清 hash
  4. `router.replace(<sso_redirect 或 /overview>)`

**选择器映射**:

```typescript
{
  ssoButton: 'button[data-action="sso-microsoft"]',
  redirectingMask: '[data-testid="sso-redirecting"]',
  completingMask: '[data-testid="sso-completing"]',
  ssoLandingPage: '[data-testid="sso-landing"]'
}
```

---

#### 3. 用户登录（密码通道，沿用 v2.1.1）

**触发**: 用户点击「用密码登录」展开表单后，填写用户名和密码并点击"登录"按钮

**前端行为**:

1. 点「用密码登录」→ 展开 username/password 表单（折叠态切换为展开态）
2. 验证表单字段（用户名和密码不能为空）
3. 显示加载状态，禁用登录按钮
4. 调用登录 API：`POST /api/v1/auth/login`
   ```json
   {
     "username": "zhangsan",
     "password": "********"
   }
   ```
5. **成功情况**：
   - 保存 JWT Token 到 localStorage/sessionStorage
   - 保存用户信息到 AuthStore
   - 如果选择"记住我"，Token 有效期 7 天
   - 显示成功 Toast："登录成功"
   - 若存在合法 `redirect` 参数，优先跳转到该目标页面（保留 query/hash）
   - 若 `redirect` 缺失或非法，跳转首页
6. **失败情况**：
   - 显示错误提示（根据错误类型）
   - 启用登录按钮

**错误响应处理** (v2.1.1 登录安全策略):

| 错误码                    | 错误信息                     | 前端行为                   |
| ------------------------- | ---------------------------- | -------------------------- |
| `IAM_INVALID_CREDENTIALS` | "用户名或密码错误"           | 显示错误提示，清空密码字段 |
| `IAM_USER_SUSPENDED`      | "账号已被停用，请联系管理员" | 显示错误提示               |
| `IAM_USER_TERMINATED`     | "账号已注销"                 | 显示错误提示               |

**选择器映射**:

```typescript
{
  submitButton: 'button[type="submit"]',
  errorMessage: '[data-testid="login-error"]'
}
```

---

### 错误状态（v2.4 新增 · SSO 错误码）

SSO callback 失败时，后端 302 回 `/login?ssoError=<CODE>`，前端登录页 `useEffect` 读 `?ssoError=` query → 依据错误码展示对应文案的错误 Toast → `history.replaceState('', '', '/login')` 清 query 防重复触发；并保持密码登录入口可用（用户可立即改走密码通道）。

| 错误码                      | 触发场景                                                       | i18n key                              | 用户文案（zh）                                                       | 用户文案（en）                                                                       |
| --------------------------- | -------------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `SSO_DOMAIN_NOT_ALLOWED`    | callback 拿到的 email 域名不在 `SSO_ALLOWED_DOMAINS` 白名单     | `auth.sso.error.domainNotAllowed`     | 你的邮箱域名暂未授权使用 SSO 登录，请联系 IT 或改用密码登录          | Your email domain is not authorized for SSO sign-in. Contact IT or use password sign-in. |
| `SSO_TOKEN_INVALID`         | id_token 签名/过期/audience/issuer 校验失败；或 Entra `invalid_grant`（code 已使用 / 已过期）；或 state/nonce/code_verifier cookie 缺失或不匹配 | `auth.sso.error.tokenInvalid`         | SSO 登录校验失败，请重试                                             | SSO verification failed. Please try again.                                              |
| `SSO_EMAIL_MISSING`         | id_token 中无 email claim（Entra 账号未配置邮箱 / B2B guest）  | `auth.sso.error.emailMissing`         | 你的微软账号未配置邮箱，请联系 IT                                    | Your Microsoft account is missing an email. Contact IT.                                 |
| `SSO_BINDING_CONFLICT`      | `externalSource='entra'` 且 externalId ≠ 当前 oid（LDAP 走自动升级路径，不触发此错误） | `auth.sso.error.bindingConflict`      | SSO 账号绑定冲突，请联系 itadmin                                     | SSO binding conflict. Contact itadmin.                                                  |
| `SSO_PROVIDER_UNAVAILABLE`  | Entra discovery / token endpoint 不可达或返回 5xx；或 DB 事务失败；或运行时 `SSO_JIT_DEFAULT_ORG_ID` 缺失 | `auth.sso.error.providerUnavailable`  | Microsoft 登录暂时不可用，请改用密码登录                             | Microsoft sign-in unavailable. Please use password sign-in.                             |
| `SSO_USER_CANCELLED`        | Entra callback `query.error=access_denied`（用户在 Microsoft 页主动取消） | `auth.sso.error.userCancelled`        | 你已取消 Microsoft 登录                                              | You cancelled Microsoft sign-in.                                                        |
| `SSO_CONSENT_REQUIRED`      | Entra callback `query.error=consent_required` 或 `interaction_required` | `auth.sso.error.consentRequired`      | Microsoft 要求重新授权，请重试登录                                   | Microsoft requires re-consent. Please sign in again.                                    |
| `SSO_PROVIDER_REJECTED`     | Entra callback `query.error` 为其它值（provider 端拒绝） | `auth.sso.error.providerRejected`     | Microsoft 登录被拒绝，请联系 IT                                      | Microsoft sign-in was rejected. Contact IT.                                             |

**前端统一行为**:

1. 隐藏跳转/完成中遮罩
2. 展示错误 Toast（红色 + 错误图标 + 上表对应 i18n 文案，**持续 6s 不自动消失，可手动关**）
3. **`history.replaceState('', '', '/login')` 清 query**（保留登录页原 `?redirect=...` 时则改写为 `/login?redirect=...`），防止刷新页面重复触发
4. 回到登录页默认态，密码登录按钮保持可点击
5. **保留**用户原 `redirect`（如有），让密码通道仍能回原页面

**选择器映射**:

```typescript
{
  ssoErrorToast: '[data-testid="sso-error-toast"]',
  ssoErrorCode: '[data-error-code]'  // attribute 上挂错误码，便于 E2E 断言
}
```

---

## 批量导入功能 UI 规范

### 入口位置

| 功能 | 入口页面 | 按钮位置 | 按钮样式 |
|------|----------|----------|----------|
| 部门批量导入 | 组织与部门 Tab | 操作栏 | outline |
| 成员关系批量导入 | 成员 Tab / 组织与部门 Tab | 操作栏（"从LDAP同步"与"新建成员"之间） | outline + FileDown 图标 |

### 对话框交互流程

两个批量导入功能共享统一交互模式：

1. **下载模板** — 提供含示例数据的 Excel 模板
2. **上传文件** — 支持 xlsx/xls/csv，拖拽或点击上传
3. **数据预览** — 有效数据（绿色边框）与无效数据（红色边框 + 错误原因）分区展示
4. **确认导入** — 仅提交有效数据，结果以 Toast 反馈（成功/失败统计）

### 部门导入模板字段

| 字段 | 必填 | 说明 |
|------|------|------|
| 部门名称 | 是 | 部门的显示名称 |
| 部门编码 | 是 | 唯一标识，格式：大写字母 + 数字 + 下划线/连字符（`/^[A-Z0-9_-]+$/`） |
| 上级编码 | 否 | 父部门编码（留空表示顶级部门，可引用同批次其他部门） |
| 描述 | 否 | 部门描述 |
| 排序 | 否 | 显示顺序 |

### 成员关系导入模板字段

| 字段 | 必填 | 说明 |
|------|------|------|
| 用户邮箱 | 是 | 用于查找已有用户，格式：邮箱 |
| 部门编码 | 是 | 目标部门编码，格式：`/^[A-Z0-9_-]+$/` |
| 岗位 ID | 否 | UUID |
| 直属主管邮箱 | 否 | 邮箱格式 |
| 部门内头衔 | 否 | 自由文本 |
| 是否主部门 | 否 | true / false |

### 前端验证规则

| 规则 | 部门导入 | 成员导入 |
|------|----------|----------|
| 必填字段缺失 → 标记无效 | 部门名称、部门编码 | 用户邮箱、部门编码 |
| 邮箱格式 `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` | — | 用户邮箱、主管邮箱 |
| 部门编码格式 `/^[A-Z0-9_-]+$/` | 部门编码 | 部门编码 |
| 同批次父部门引用 | 支持（两遍扫描） | — |

### 组织选择器（成员关系导入）

- 从成员列表页打开：显示组织选择下拉框（单组织时自动选中）
- 从部门页面打开：自动使用当前组织上下文，不显示选择器

### 约束

- 成员关系导入不创建新用户，用户必须已通过 Entra ID 同步存在于系统中
- 部门导入支持中文文件名和中文内容（使用 ArrayBuffer 解析）

---

## 🖥️ 页面 12: AI 工具授权管理页面（v2.3 全量重写）

> **版本**: v2.3（2026-04-16 整体重写）
> 
> **变更重点**：从"数据库行直接展示"升级为"一行一主体 + drawer 编辑"，新增按组织/部门/角色过滤，生效预览显示每个工具的来源，审计日志走现有 `platform_audit` 不做独立 tab。

### 路径

`/organization/ai-tools`

### 权限

- 进入页面：`ai_tool:read`
- 编辑授权（drawer 保存 + API 调用）：`ai_tool:manage`

### 入口

- 组织架构模块左侧导航「AI 工具」（`Sparkles` 图标），位于「角色与权限」之后

### 页面结构

```
┌──────────────────────────────────────────────────────────────┐
│ AI 工具授权                                         [更多 ▾] │
│ 为角色和用户管理 AI 工具访问权限。核心对话工具（4 项）       │
│ 系统默认保留不可取消，其余工具可按角色/用户独立授权。        │
├──────────────────────────────────────────────────────────────┤
│ [角色授权]  [用户授权]  [生效预览]                           │
├──────────────────────────────────────────────────────────────┤
│ Tab 内容区                                                   │
└──────────────────────────────────────────────────────────────┘
```

**右上角「更多 ▾」下拉菜单项**：
- `查看授权变更审计` → 跳转 `/audit-logs?module=ai-tools`（新窗口）
- `导出当前授权 JSON` → 下载完整 grants dump（debug 用）

### Tab 1：角色授权

**顶部操作栏**

```
[搜索角色名/code ...]                    [+ 新建角色授权]
```

- 搜索框支持 role name 和 role code 的 ilike 匹配
- 过滤自动隐藏 `SyncBot` 系统服务账号角色（后端也过滤）
- `+ 新建角色授权`：未授权过的角色列表选一个 → 自动填充 Employee 默认基线 → 打开 drawer 编辑

**列表表格**

| 列 | 宽度 | 说明 |
|---|---|---|
| 角色 | 24% | `{roleName}` 大字 + `{roleCode}` 灰色小字 |
| 已授权工具 | 42% | chip 列表，超过 6 个折叠为 `8 项 ▾`，点击展开完整 |
| 工具数 | 10% | 数字，按数字排序 |
| 最后更新 | 12% | 相对时间 + hover 显示绝对时间 + updatedBy |
| 操作 | 12% | `编辑` 按钮（`ai_tool:manage`） + `继承 Employee 基线` 快捷 |

- 每行可点击整行展开/收起 chip 列表
- 默认按 `createdAt desc` 排（与「角色与权限」页一致，新建角色置顶）
- 即使无 AIToolGrant 记录的角色（`toolCount=0`）也会展示，便于管理员察觉漏配；不再依赖"先有 grant 才能看见角色"的隐式约定
- 空状态："系统中暂无可授权角色（除 SyncBot 服务账号外）"

**Drawer：编辑角色授权**（右侧滑出，宽 560px）

```
┌─ 编辑角色授权: Employee ──────────────────── × ─┐
│                                                  │
│ [搜索工具...]           [展开全部] [折叠全部]    │
│                                                  │
│ ▶ 核心对话工具  🔒 4/4  (不可取消)               │
│                                                  │
│ ▼ 文件（fs）                [全选] [清空] 4/4   │
│   ☑ read            ☑ write                      │
│   ☑ edit            ☑ apply_patch                │
│                                                  │
│ ▼ 运行时（runtime）         [全选] [清空] 2/2   │
│   ☑ exec            ☑ process                    │
│                                                  │
│ ▼ 会话管理（sessions）      [全选] [清空] 3/3   │
│   ☑ sessions_spawn  ☑ sessions_yield             │
│   ☑ subagents                                    │
│                                                  │
│ ▼ 记忆（memory）            [全选] [清空] 2/2   │
│   ☑ memory_search   ☑ memory_get                 │
│                                                  │
│ ▼ 网页（web）               [全选] [清空] 2/2   │
│   ☑ web_search      ☑ web_fetch                  │
│                                                  │
│ ▼ 媒体（media）             [全选] [清空] 2/2   │
│   ☑ image           ☑ tts                        │
│                                                  │
│ ▼ 自动化（automation）      [全选] [清空] 1/1   │
│   ☑ cron                                         │
│                                                  │
│ ▼ 浏览器（browser）         [全选] [清空] 1/1   │
│   ☑ browser                                      │
│                                                  │
│ ▼ M365（productivity）      [全选] [清空] 3/3   │
│   ☑ m365_mail  ☑ m365_calendar  ☑ m365_files    │
│                                                  │
├──────────────────────────────────────────────────┤
│ 变更摘要：+0 / -0（无变更）                      │
│                                                  │
│        [取消]                        [保存]      │
└──────────────────────────────────────────────────┘
```

**Drawer 要点**：
- **核心对话工具默认折叠**，展开显示 `session_status / sessions_history / sessions_list / sessions_send` 四个 locked checkbox + 锁图标 + tooltip"系统基础对话工具，不可取消"
- 各类别默认展开（核心除外）
- 每个类别右上角显示 `N/M` 当前选中数/总数
- 顶部"搜索工具"支持按 name/label 即时过滤 checkbox
- 底部"变更摘要"实时计算 `+N / -N`（相对 drawer 打开时的快照）
- 保存时：
  1. 前端 diff 目标集合
  2. 调用 `PUT /ai-tools/grants/role/:roleId` body: `{tools: string[]}`
  3. 后端强制合入 LOCKED_SET（前端是否传都无所谓）
  4. 后端事务 upsert/delete
  5. 返回最新的聚合 grant 对象，前端替换列表行
- Drawer 有未保存变更时关闭（点 X / Esc / 点击遮罩）触发二次确认

### Tab 2：用户授权

**顶部过滤栏**

```
┌──────────────────────────────────────────────────────────────────┐
│ [搜索用户名/邮箱/显示名 ...] (debounce 300ms)                    │
│                                                                  │
│ 组织 [全部 ▾]  部门 [全部 ▾]  角色 [全部 ▾ (多选)]              │
│                                                                  │
│ ☐ 只看有额外授权的    ☐ 只看有工具被收回的          [重置过滤] │
└──────────────────────────────────────────────────────────────────┘
```

- **级联行为**：
  - 组织下拉（单选）→ 选中后部门下拉自动收敛到该组织下的部门
  - 部门下拉（单选）→ 进一步收窄用户
  - 角色下拉（**多选 chip**）→ 返回任一选中角色的用户（OR 语义）
- **两个开关**：
  - "只看有额外授权的"：用户在角色基线之上**额外添加**了工具
  - "只看有工具被收回的"：用户在角色基线之上**减掉**了工具
  - 可同时勾选（= 有任意调整）
- 过滤状态反映在 URL query string，支持刷新保持 + 分享链接
- 搜索用 debounce 300ms + 后端 ilike 匹配 username/email/displayName

**列表表格**

| 列 | 宽度 | 说明 |
|---|---|---|
| 用户 | 20% | displayName 大字 + email 灰色小字 + 头像 |
| 组织 / 部门 | 15% | 两行，组织上部门下 |
| 角色链 | 15% | 一组角色 chip（从角色模块来） |
| 继承工具 | 11% | `18 项` 数字，点击弹浮层显示完整清单（只读）|
| 额外 +/- | 15% | `+2 / -1` 或 `无`，颜色：绿加 / 红减 / 灰无 |
| 最终 | 8% | `19 项` 数字，点击跳 Tab 3「生效预览」带用户参数 |
| 操作 | 16% | `编辑` 按钮 |

- 默认按 username 字母序
- 分页 20 条/页
- 空状态："没有找到符合条件的用户 — 尝试调整过滤条件"

**Drawer：编辑用户授权**（右侧滑出，宽 620px）

```
┌─ 编辑用户授权: Hongwei Zhang ──────────────── × ─┐
│ Administrator ← Employee                          │
│                                                   │
│ 继承自角色链的基线（只读背景）:                   │
│ ┌─────────────────────────────────────────────┐ │
│ │ [read] [write] [edit] [exec] [cron] [browser]│ │
│ │ [m365_mail] [m365_calendar] ... 18 项        │ │
│ └─────────────────────────────────────────────┘ │
│                                                   │
│ 用户级调整（可加可减）：                          │
│                                                   │
│ [搜索工具...]                                     │
│                                                   │
│ ▶ 核心对话工具  🔒 4/4  (不可取消)                │
│ ▼ 文件                                            │
│   ☑ read (继承) ← 显示三态                       │
│   ☑ write (继承)                                  │
│   ...                                             │
│                                                   │
│ 三态 checkbox:                                    │
│ - ☑ (实心)   = 已授权 (继承 OR 用户显式添加)      │
│ - ◐ (半选)  = 该用户显式减掉了一个基线工具       │
│ - ☐ (空)    = 角色基线里就没有，用户也没开        │
│                                                   │
├───────────────────────────────────────────────────┤
│ 变更摘要：+1 / -2                                 │
│ - 添加：image                                     │
│ - 取消：m365_mail, cron                           │
│                                                   │
│     [取消]                             [保存]     │
└───────────────────────────────────────────────────┘
```

**用户级 Drawer 特殊点**：
- 顶部显示用户的角色链（面包屑样式）
- "继承自角色链的基线"作为**只读背景卡片**，admin 能一眼看到基线是什么
- 主编辑区使用**三态 checkbox**：
  - 实心 = 最终生效（继承或显式添加）
  - 半选 = 基线里有但用户显式减掉
  - 空 = 基线没有且未添加
- 保存时后端 `PUT /ai-tools/user-grants/:userId` body `{added: [...], removed: [...]}` 两组差集

### Tab 3：生效预览

两个子视图：按用户 / 按工具

#### 子视图 1：按用户

```
┌─────────────────┬──────────────────────────────────────────────┐
│ [搜索用户...]   │ 当前用户: Hongwei Zhang                      │
│                 │ hongwei.zhang@ff.com                          │
│ Katherine Wang  │ 角色链: Administrator → Employee             │
│ Hongwei Zhang ✓ │                                               │
│ Jevy Luo        │ 生效工具清单 (19 项):                         │
│ ...             │                                               │
│                 │ ▼ 核心对话工具 (4)   [来自: LOCKED_SET]       │
│                 │   session_status (系统锁定)                   │
│                 │   sessions_history (系统锁定)                 │
│                 │   sessions_list (系统锁定)                    │
│                 │   sessions_send (系统锁定)                    │
│                 │                                               │
│                 │ ▼ 文件 (4)            [来自: Employee 角色]   │
│                 │   read, write, edit, apply_patch              │
│                 │                                               │
│                 │ ▼ M365 (2)            [来自: Administrator]   │
│                 │   m365_mail ⬅ 来自 Admin 角色                 │
│                 │   m365_files ⬅ 来自 Admin 角色                │
│                 │                                               │
│                 │ ▼ 被显式取消 (1)                              │
│                 │   ✘ cron ⬅ 用户级手动取消                     │
│                 │                                               │
│                 │ [导出 JSON]  [复制工具清单]  [对应 agent config]│
└─────────────────┴──────────────────────────────────────────────┘
```

- 左栏用户列表支持搜索 + 组织/部门/角色过滤（复用 Tab 2 过滤器）
- 右栏"对应 agent config"按钮展示用户的 agent 在 openclaw.json 里的 `tools.allow` 字段预览（用于和 sync 后验证）

#### 子视图 2：按工具

- 顶部：工具下拉（分类分组，来自 `/available-tools`）
- 选中后展示生效用户表格：按角色分组 + 用户级调整标记

| 列 | 说明 |
|---|---|
| 用户 | displayName + email |
| 组织 / 部门 | 级联显示 |
| 来源 | `继承自 {角色}` chip / `用户显式添加` chip |

- 支持反向搜索："为什么 Alice 没有 m365_mail？" → 显示 Alice 的角色链、每个角色是否开该工具、Alice 是否显式减掉

### 错误状态

| 场景 | UI 反馈 |
|---|---|
| 列表加载失败 | 错误 banner + 重试按钮 |
| Drawer 保存失败 (500) | 保留 drawer 状态 + toast 错误信息 |
| Drawer 保存冲突 (409) | toast: 「该角色已被其他管理员修改，请刷新后重试」 |
| 未授权工具名 (400) | toast + 工具 chip 红色高亮 |
| 用户无 `ai_tool:read` 权限 | 路由层 PermissionGuard 跳转 403 |
| 用户无 `ai_tool:manage` 权限 | 编辑按钮和 drawer 保存按钮隐藏 |

### 设计要点 / 边界

- **所有工具都在 Workspace 可控制范围**（除 LOCKED_SET 4 个），包括 fs/runtime/memory 等核心能力
- LOCKED_SET 前端 disabled，后端强制合入，两道保险
- Drawer 一次事务保存，不做"每勾一个工具调一次 API"的细粒度调用
- 过滤状态 URL 持久化，审计和分享友好
- 审计日志不做页内独立 tab，走现有 `/audit-logs?module=ai-tools`
- SyncBot 系统服务账号在所有角色选择列表中隐藏

### 与方案文档的关联

- 总体方案：openclaw 仓 `docs/enterprise-plan/solution/governance/permissions-mvp-plan.md`
- API 文档：[07-api.md 11. AI 工具授权管理 API](./07-api.md#11-ai-工具授权管理-api)
- PRD：[01-prd.md 功能11](./01-prd.md#功能11-ai-工具授权管理v23-全量-per-user-控制)
