# 用户与组织模块 - E2E测试详细规范

> **版本**: v2.4  
> **创建日期**: 2025-12-27  
> **最后更新**: 2026-05-19  
> **维护者**: FFOA QA Team  
> **参考文档**: `05-ui-interaction-spec.md`, `07-api.md`

---

## 📋 目录

- [测试覆盖范围](#测试覆盖范围)
- [测试准备](#测试准备)
- [1. 用户列表页](#1-用户列表页)
- [2. 用户详情页](#2-用户详情页)
- [3. 新建/编辑用户页](#3-新建编辑用户页)
- [4. 角色管理](#4-角色管理)
- [5. 部门管理](#5-部门管理)
- [6. 组织管理](#6-组织管理)
- [7. 区域管理](#7-区域管理)
- [8. 登录页 Entra ID SSO 流程（v2.4 新增 ⭐）](#8-登录页-entra-id-sso-流程v24-新增-)
- [通用测试场景](#通用测试场景)
- [测试数据与工具](#测试数据与工具)

---

## 📊 测试覆盖范围

| 模块 | 页面数 | 场景数 | 优先级 | 状态 |
|------|-------|-------|--------|------|
| 用户管理 | 3 | 80+ | P0 | ✅ 完成 |
| 部门管理 | 2 | 30+ | P0 | ✅ 完成 |
| 角色管理 | 2 | 25+ | P0 | ✅ 完成 |
| 组织管理 | 2 | 20+ | P1 | ✅ 完成 |
| 区域管理 | 1 | 26 | P1 | ✅ 完成 |
| Entra ID SSO 登录 | 1 | 10 | P0 | 🚧 v2.4 新增（含 fragment 注入 + ssoError query 处理 + 8 错误码双语） |

**总计**: 11 个页面，190+ 个测试场景

---

## 🧪 测试准备

### 测试账号

```typescript
const TEST_ACCOUNTS = {
  admin: {
    username: 'itadmin',
    password: 'Admin@2024',
    permissions: ['*'], // 所有权限
    description: '系统管理员'
  },
  hr: {
    username: 'hr_manager',
    password: 'HR@2024',
    permissions: ['user:*', 'department:read', 'role:assign'],
    description: 'HR管理员'
  },
  manager: {
    username: 'dept_manager',
    password: 'Manager@2024',
    permissions: ['user:read', 'user:update'],
    description: '部门经理'
  },
  employee: {
    username: 'employee',
    password: 'Employee@2024',
    permissions: ['user:read'],
    description: '普通员工'
  }
};
```

### 测试环境

| 环境 | URL | 说明 |
|------|-----|------|
| 开发环境 | `http://localhost:6010` | 本地开发（如有自定义端口，以环境配置为准） |
| 测试环境 | `https://test.ff.com` | E2E 测试 |
| 预发布环境 | `https://staging.ff.com` | 发布前验证 |

### 前置条件

- [ ] 数据库已初始化测试数据
- [ ] 测试账号已创建并激活
- [ ] 测试组织、部门、角色已配置
- [ ] 必要的权限已分配
- [ ] 语言环境已固定（如 `zh`），或断言优先使用 `data-testid` 避免文案依赖

**最小可执行准备（建议）**：
1. `cd backend && npm run prisma:migrate && npm run db:seed`（初始化权限/角色/岗位基础数据）
2. `cd backend && npm run init:itadmin`（创建全局管理员账号：`itadmin` / `Admin@2024`）
3. 组织/部门/角色可在 UI 中创建，或使用现有测试工厂/脚本准备数据

---

## 📄 页面测试规范

---

## 1. 用户列表页

### 路由
`/organization/members`

### 权限要求
`user:read`

---

### 1.0 页面内容清单 ⭐

#### 📌 静态文本

**页面标题与导航**
| 位置 | 内容 | 选择器 | 说明 |
|------|------|-------|------|
| 页面标题 | "用户管理" | `[data-testid="page-title"]` | h1 标题 |
| 面包屑 | "组织架构 / 用户管理" | `[data-testid="breadcrumb"]` | 导航路径 |

**按钮与操作文本**
| 按钮 | 文字 | 选择器 | 状态变化 |
|------|------|-------|---------|
| 新建按钮 | "新建用户" | `button[data-action="create-user"]` | - |
| 批量导入 | "批量导入" | `button[data-action="import"]` | - |
| 导出按钮 | "导出" | `button[data-action="export"]` | - |
| 下载模板 | "下载模板" | `button[data-action="download-template"]` | 导入对话框内 |
| 清除搜索 | "✕" | `button[data-action="clear-search"]` | 搜索框内 |
| 批量删除 | "删除" | `button[data-action="batch-delete"]` | 批量操作栏 |

**表格列标题**
| 列 | 标题文字 | 选择器 | 功能 |
|----|---------|-------|------|
| 复选框列 | - | `th:first-child` | 全选复选框 |
| 第1列 | "用户名" | `th[data-column="username"]` | 支持排序 |
| 第2列 | "显示名称" | `th[data-column="displayName"]` | 支持排序 |
| 第3列 | "邮箱" | `th[data-column="email"]` | - |
| 第4列 | "部门" | `th[data-column="department"]` | - |
| 第5列 | "状态" | `th[data-column="status"]` | 支持排序 |
| 第6列 | "操作" | `th[data-column="actions"]` | - |

**筛选器**
| 筛选器 | 标签 | Placeholder | 选择器 |
|--------|------|------------|--------|
| 搜索框 | - | "搜索用户名、邮箱或员工编号" | `input[data-testid="user-search-input"]` |
| 部门筛选 | "部门" | "选择部门" | `select[name="department"]` |
| 状态筛选 | "状态" | "全部状态" | `select[name="status"]` |
| 角色筛选 | "角色" | "选择角色" | `select[name="role"]` |

**操作按钮（表格行内）**
| 按钮 | 文字 | 选择器 | 说明 |
|------|------|-------|------|
| 查看 | "查看" | `button[data-action="view"]` | 查看详情 |
| 编辑 | "编辑" | `button[data-action="edit"]` | 编辑用户 |
| 更多 | "⋯" | `button[data-action="more"]` | 下拉菜单 |

**下拉菜单项**
| 菜单项 | 文字 | 选择器 | 显示条件 |
|--------|------|-------|---------|
| 分配角色 | "分配角色" | `[data-action="assign-roles"]` | 用户激活时 |
| 停用 | "停用" | `[data-action="deactivate"]` | 用户激活时 |
| 激活 | "激活" | `[data-action="activate"]` | 用户停用时 |
| 暂停 | "暂停" | `[data-action="suspend"]` | 用户激活时 |
| 恢复 | "恢复" | `[data-action="unsuspend"]` | 用户暂停时 |
| 离职 | "离职" | `[data-action="terminate"]` | 用户非离职时 |

#### 🎨 动态内容格式

**状态徽章显示**
| 状态值 | 显示文字 | 颜色 | CSS类 | 图标 |
|-------|---------|------|-------|------|
| ACTIVE | "激活" | 绿色 | `bg-[#e6f7ed] text-[#00b42a]` | 🟢 |
| INACTIVE | "停用" | 灰色 | `bg-[#f7f8fa] text-[#8f959e]` | 🟡 |
| SUSPENDED | "暂停" | 橙色 | `bg-[#fff7e6] text-[#ff7d00]` | 🟠 |
| TERMINATED | "离职" | 红色 | `bg-[#ffece8] text-[#f53f3f]` | 🔴 |

**日期时间格式**
| 字段类型 | 格式 | 示例 |
|---------|------|------|
| 创建时间 | `YYYY-MM-DD HH:mm:ss` | "2025-12-27 10:30:00" |
| 入职时间 | `YYYY-MM-DD` | "2025-01-15" |

**分页信息**
| 位置 | 内容格式 | 示例 |
|------|---------|------|
| 总数显示 | "共 {total} 条记录" | "共 100 条记录" |
| 当前页显示 | "第 {current}/{total} 页" | "第 1/5 页" |
| 每页显示选项 | "每页显示：20 / 50 / 100" | 下拉选项 |

**筛选器选项**
| 筛选器 | 选项值 | 选项文字 |
|--------|-------|---------|
| 状态筛选 | `''` | "全部状态" |
| 状态筛选 | `ACTIVE` | "激活" |
| 状态筛选 | `INACTIVE` | "停用" |
| 状态筛选 | `SUSPENDED` | "暂停" |
| 状态筛选 | `TERMINATED` | "已离职" |

#### 💬 提示信息

**成功提示（Toast）**
| 操作 | 提示文字 | 类型 | 持续时间 | 选择器 |
|------|---------|------|---------|-------|
| 创建成功 | "用户创建成功" | success | 3秒 | `[data-testid="toast-success"]` |
| 更新成功 | "用户信息已更新" | success | 3秒 | `[data-testid="toast-success"]` |
| 删除成功 | "用户已删除" | success | 3秒 | `[data-testid="toast-success"]` |
| 批量删除成功 | "已删除 {count} 个用户" | success | 3秒 | `[data-testid="toast-success"]` |
| 导入成功 | "成功导入 {count} 个用户" | success | 3秒 | `[data-testid="toast-success"]` |
| 导出成功 | "导出成功" | success | 3秒 | `[data-testid="toast-success"]` |
| 复制成功 | "已复制" | success | 2秒 | `[data-testid="toast-success"]` |
| 停用成功 | "用户已停用" | success | 3秒 | `[data-testid="toast-success"]` |
| 激活成功 | "用户已激活" | success | 3秒 | `[data-testid="toast-success"]` |

**错误提示（Toast）**
| 场景 | 提示文字 | 位置 | 颜色 | 选择器 |
|------|---------|------|------|-------|
| 网络错误 | "网络连接失败，请稍后重试" | Toast | 红色 | `[data-testid="toast-error"]` |
| 权限错误 | "您没有权限执行此操作" | Toast | 红色 | `[data-testid="toast-error"]` |
| 删除失败 | "删除失败：{原因}" | Toast | 红色 | `[data-testid="toast-error"]` |
| 导入失败 | "导入失败：{原因}" | Toast | 红色 | `[data-testid="toast-error"]` |
| 服务器错误 | "服务器错误，请联系管理员" | Toast | 红色 | `[data-testid="toast-error"]` |

**确认对话框**
| 操作 | 标题 | 内容 | 按钮 | 选择器 |
|------|------|------|------|-------|
| 删除用户 | "删除确认" | "确定要删除用户"{username}"吗？此操作不可恢复。" | "取消" / "确定删除" | `[data-testid="confirm-dialog"]` |
| 停用用户 | "停用确认" | "确定要停用用户"{username}"吗？" | "取消" / "确定" | `[data-testid="confirm-dialog"]` |
| 批量删除 | "批量删除确认" | "确定要删除选中的 {count} 个用户吗？此操作不可恢复。" | "取消" / "确定删除" | `[data-testid="confirm-dialog"]` |
| 暂停用户 | "暂停确认" | "确定要暂停用户"{username}"吗？" | "取消" / "确定" | `[data-testid="confirm-dialog"]` |
| 离职处理 | "离职确认" | "确定要将用户"{username}"设为离职状态吗？" | "取消" / "确定" | `[data-testid="confirm-dialog"]` |

**空状态**
| 场景 | 图标 | 主文字 | 副文字 | 操作按钮 | 选择器 |
|------|------|-------|-------|---------|-------|
| 无数据 | EmptyIcon | "暂无用户数据" | "点击按钮创建第一个用户" | "创建第一个用户" | `[data-testid="empty-state"]` |
| 搜索无结果 | SearchIcon | "未找到匹配的用户" | "尝试调整搜索条件" | "清除搜索" | `[data-testid="empty-search"]` |
| 加载失败 | ErrorIcon | "加载失败" | "请稍后重试或联系管理员" | "重试" | `[data-testid="load-error"]` |
| 无权限 | LockIcon | "您没有权限查看用户列表" | "请联系管理员申请权限" | "返回" | `[data-testid="no-permission"]` |

**加载状态**
| 位置 | 显示内容 | 选择器 | 说明 |
|------|---------|-------|------|
| 页面加载 | "加载中..." + Spinner | `[data-testid="page-loading"]` | 全屏遮罩 |
| 搜索加载 | 旋转图标 | `[data-testid="search-loading"]` | 搜索框内 |
| 按钮加载 | "导入中..." / "导出中..." / "删除中..." | `button[disabled]` | 按钮内 |
| 表格加载 | Skeleton 占位 | `[data-testid="skeleton"]` | 骨架屏 |

---

### 1.1 页面加载与元素验证

#### 测试场景: 应该正确加载并显示所有页面元素

**验证点**:
- ✅ 页面标题显示"用户管理"
- ✅ 搜索框可见且可用
- ✅ 新建用户按钮可见
- ✅ 批量导入按钮可见
- ✅ 部门筛选器可见
- ✅ 状态筛选器可见
- ✅ 角色筛选器可见
- ✅ 用户表格可见
- ✅ 表格至少有一行数据
- ✅ 分页器可见（如果数据超过一页）

**选择器**:
```typescript
{
  pageTitle: '[data-testid="page-title"]',
  searchInput: 'input[data-testid="user-search-input"]',
  createButton: 'button[data-action="create-user"]',
  importButton: 'button[data-action="import"]',
  deptFilter: 'select[name="department"]',
  statusFilter: 'select[name="status"]',
  roleFilter: 'select[name="role"]',
  userTable: '[data-testid="user-table"]',
  userRow: '[data-testid="user-row"]',
  pagination: '[data-testid="pagination"]'
}
```

---

#### 测试场景: 应该显示正确的表格列

**验证点**:
- ✅ 复选框列
- ✅ 用户名列 `[data-column="username"]`
- ✅ 显示名称列 `[data-column="displayName"]`
- ✅ 邮箱列 `[data-column="email"]`
- ✅ 部门列 `[data-column="department"]`
- ✅ 状态列 `[data-column="status"]`（徽章显示）
- ✅ 操作列 `[data-column="actions"]`（查看/编辑/更多按钮）

**验证方法**:
```typescript
// 验证表头
const headers = page.locator('thead th');
await expect(headers).toHaveCount(7);

// 验证每一列的存在
await expect(page.locator('[data-column="username"]').first()).toBeVisible();
```

---

#### 测试场景: 应该正确显示用户状态徽章

**验证点**:
- ✅ ACTIVE 状态：绿色徽章，文字"激活"
- ✅ INACTIVE 状态：灰色徽章，文字"停用"
- ✅ SUSPENDED 状态：橙色徽章，文字"暂停"
- ✅ TERMINATED 状态：红色徽章，文字"离职"

**验证方法**:
```typescript
const statusBadge = page.locator('[data-testid="user-row"]').first()
  .locator('[data-column="status"]');
  
// 验证包含状态文字
await expect(statusBadge).toContainText(/激活|停用|暂停|离职/);

// 验证CSS类
const classes = await statusBadge.getAttribute('class');
expect(classes).toMatch(/status-(active|inactive|suspended|terminated)/);
```

---

### 1.2 搜索功能

#### 测试场景: 应该能够通过关键词搜索用户

**操作步骤**:
1. 在搜索框输入关键词 "admin"
2. 等待防抖（500ms）
3. 验证搜索结果

**验证点**:
- ✅ URL参数更新：`?search=admin`
- ✅ 搜索结果包含关键词
- ✅ 搜索结果数量 > 0
- ✅ 至少一个用户的用户名或邮箱包含关键词

**边界测试**:
- ✅ 输入空字符串：恢复完整列表
- ✅ 输入1个字符：不触发搜索（<2字符）
- ✅ 输入特殊字符：正确转义
- ✅ 输入不存在的关键词：显示"未找到匹配结果"消息

---

#### 测试场景: 应该显示搜索加载状态

**操作步骤**:
1. 输入搜索关键词
2. 立即检查加载图标

**验证点**:
- ✅ 搜索图标变为加载图标 `[data-testid="search-loading"]`
- ✅ 加载完成后恢复搜索图标

---

#### 测试场景: 应该能够清除搜索

**操作步骤**:
1. 输入搜索关键词
2. 等待搜索结果
3. 点击清除按钮

**验证点**:
- ✅ 搜索框清空
- ✅ 显示完整用户列表
- ✅ URL参数移除search参数

**选择器**:
```typescript
clearButton: 'button[data-action="clear-search"]'
```

---

### 1.3 筛选功能

#### 测试场景: 应该能够按状态筛选用户

**操作步骤**:
1. 选择状态筛选器
2. 选择"激活"
3. 验证所有显示的用户都是激活状态

**验证点**:
- ✅ URL参数更新：`?status=ACTIVE`
- ✅ 所有用户行的状态列显示"激活"
- ✅ 用户数量变化

**测试所有状态**:
```typescript
const statuses = ['ACTIVE', 'INACTIVE', 'SUSPENDED', 'TERMINATED'];
for (const status of statuses) {
  await page.selectOption('select[name="status"]', status);
  await expect(page).toHaveURL(new RegExp(`status=${status}`));

  const rows = page.locator('[data-testid="user-row"]');
  const count = await rows.count();
  
  for (let i = 0; i < count; i++) {
    const statusBadge = rows.nth(i).locator('[data-column="status"]');
    // 验证状态匹配
  }
}
```

---

#### 测试场景: 应该能够按部门筛选用户

**操作步骤**:
1. 选择部门筛选器
2. 选择特定部门
3. 验证所有显示的用户都属于该部门

**验证点**:
- ✅ URL参数更新：`?departmentId={id}`
- ✅ 所有用户的部门列显示该部门名称

---

#### 测试场景: 应该能够按角色筛选用户

**操作步骤**:
1. 选择角色筛选器
2. 选择特定角色
3. 验证筛选结果

**验证点**:
- ✅ URL参数更新：`?roleId={id}`
- ✅ 用户列表更新

---

#### 测试场景: 应该能够组合多个筛选条件

**操作步骤**:
1. 输入搜索关键词
2. 选择状态
3. 选择部门
4. 验证组合筛选结果

**验证点**:
- ✅ URL参数包含所有筛选条件
- ✅ 结果同时满足所有条件
- ✅ 清除任一条件，结果相应更新

---

### 1.4 排序功能

#### 测试场景: 应该能够按用户名排序

**操作步骤**:
1. 点击用户名列的表头
2. 验证升序排序
3. 再次点击
4. 验证降序排序

**验证点**:
- ✅ 第一次点击：升序图标显示
- ✅ 数据按字母升序排列
- ✅ 第二次点击：降序图标显示
- ✅ 数据按字母降序排列

**选择器**:
```typescript
usernameHeader: 'th[data-column="username"]',
sortIcon: '[data-testid="sort-icon"]'
```

---

### 1.5 表格交互

#### 测试场景: 应该能够选择单个用户

**操作步骤**:
1. 点击某一行的复选框
2. 验证选中状态

**验证点**:
- ✅ 复选框被选中
- ✅ 行背景色变化（高亮）
- ✅ 批量操作栏显示（显示选中数量）

---

#### 测试场景: 应该能够全选/取消全选

**操作步骤**:
1. 点击表头的全选复选框
2. 验证所有行被选中
3. 再次点击
4. 验证所有行取消选中

**验证点**:
- ✅ 所有行的复选框状态同步
- ✅ 批量操作栏显示正确的选中数量

---

#### 测试场景: 应该能够点击行查看详情

**操作步骤**:
1. 点击表格行（非按钮区域）
2. 验证跳转到详情页

**验证点**:
- ✅ URL变为 `/organization/members/{id}`
- ✅ 详情页加载

---

#### 测试场景: 应该显示并响应操作按钮

**操作步骤**:
1. 悬停在操作列
2. 验证按钮显示
3. 点击各个按钮

**验证点**:
- ✅ "查看"按钮可见且可点击
- ✅ "编辑"按钮可见且可点击（有权限时）
- ✅ "更多"按钮可见且可点击

**选择器**:
```typescript
viewButton: 'button[data-action="view"]',
editButton: 'button[data-action="edit"]',
moreButton: 'button[data-action="more"]'
```

---

#### 测试场景: 应该显示更多操作菜单

**操作步骤**:
1. 点击"更多"按钮
2. 验证下拉菜单显示
3. 验证菜单项

**验证点**:
- ✅ 下拉菜单显示
- ✅ "分配角色"菜单项（用户激活时）
- ✅ "停用"菜单项（用户激活时）
- ✅ "激活"菜单项（用户停用时）
- ✅ "暂停"菜单项（用户激活时）
- ✅ "恢复"菜单项（用户暂停时）
- ✅ "离职"菜单项（用户非离职时）

**选择器**:
```typescript
dropdown: '[data-testid="user-actions-dropdown"]',
assignRoles: '[data-action="assign-roles"]',
deactivate: '[data-action="deactivate"]',
activate: '[data-action="activate"]',
suspend: '[data-action="suspend"]',
unsuspend: '[data-action="unsuspend"]',
terminate: '[data-action="terminate"]'
```

---

### 1.6 分页功能

#### 测试场景: 应该正确显示分页器

**验证点**:
- ✅ 分页器可见（数据超过一页时）
- ✅ 显示总页数
- ✅ 显示当前页码
- ✅ 显示总记录数
- ✅ "上一页"按钮状态正确（第一页时禁用）
- ✅ "下一页"按钮状态正确（最后一页时禁用）

---

#### 测试场景: 应该能够切换页码

**操作步骤**:
1. 点击"下一页"
2. 验证页面更新
3. 点击特定页码
4. 验证跳转

**验证点**:
- ✅ URL参数更新：`?page=2`
- ✅ 当前页码高亮显示
- ✅ 表格数据刷新
- ✅ 滚动到页面顶部

---

#### 测试场景: 应该能够更改每页显示数量

**操作步骤**:
1. 选择每页显示数量（20/50/100）
2. 验证列表更新

**验证点**:
- ✅ URL参数更新：`?pageSize=50`
- ✅ 显示的行数变化
- ✅ 总页数重新计算

---

### 1.7 批量操作

#### 测试场景: 应该能够批量删除用户

**操作步骤**:
1. 选择多个用户
2. 点击批量操作栏的"删除"按钮
3. 确认删除

**验证点**:
- ✅ 确认对话框显示
- ✅ 显示将要删除的用户数量
- ✅ 确认后，用户从列表移除
- ✅ 显示成功提示

---

#### 测试场景: 应该能够批量导出用户

**操作步骤**:
1. 选择多个用户（或不选择=导出全部）
2. 点击"导出"按钮
3. 验证下载

**验证点**:
- ✅ 文件下载开始
- ✅ 文件名格式正确：`users_YYYYMMDD_HHmmss.xlsx`
- ✅ 文件包含选中的用户数据

---

### 1.8 空状态与错误处理

#### 测试场景: 应该显示空状态（无数据）

**操作步骤**:
1. 筛选条件设置为无结果
2. 验证空状态显示

**验证点**:
- ✅ 空状态图标显示
- ✅ 空状态文字："暂无数据"
- ✅ 引导按钮："创建第一个用户"

**选择器**:
```typescript
emptyState: '[data-testid="empty-state"]',
emptyIcon: '[data-testid="empty-icon"]',
emptyText: '[data-testid="empty-text"]',
createFirstButton: 'button[data-action="create-first"]'
```

---

#### 测试场景: 应该处理加载错误

**操作步骤**:
1. 模拟API错误（通过MSW）
2. 刷新页面
3. 验证错误状态

**验证点**:
- ✅ 错误图标显示
- ✅ 错误消息显示
- ✅ "重试"按钮可见
- ✅ 点击重试，重新加载数据

---

### 1.9 批量导入

#### 测试场景: 应该能够打开批量导入对话框

**操作步骤**:
1. 点击"批量导入"按钮
2. 验证对话框显示

**验证点**:
- ✅ 对话框标题："批量导入用户"
- ✅ 下载模板链接可见
- ✅ 文件上传区域可见
- ✅ "确定"和"取消"按钮

---

#### 测试场景: 应该能够下载导入模板

**操作步骤**:
1. 在导入对话框中点击"下载模板"
2. 验证下载

**验证点**:
- ✅ 文件下载开始
- ✅ 文件名：`user_import_template.xlsx`
- ✅ 文件包含正确的列标题

---

#### 测试场景: 应该能够上传并预览导入文件

**操作步骤**:
1. 选择导入文件
2. 验证预览

**验证点**:
- ✅ 文件上传成功
- ✅ 显示预览表格
- ✅ 显示导入统计（总数、成功、失败）
- ✅ 显示验证错误（如果有）

---

## 2. 用户详情页

### 路由
`/organization/members/:id`

### 权限要求
`user:read`

---

### 2.0 页面内容清单 ⭐

#### 📌 静态文本

**页面标题与导航**
| 位置 | 内容 | 选择器 | 说明 |
|------|------|-------|------|
| 返回按钮 | "← 返回" | `button[data-action="back"]` | 返回列表 |
| 用户名标题 | "{displayName}({username})" | `[data-testid="user-title"]` | h1 标题 |
| 面包屑 | "组织架构 / 用户管理 / {username}" | `[data-testid="breadcrumb"]` | 导航路径 |

**操作按钮**
| 按钮 | 文字 | 选择器 | 显示条件 |
|------|------|-------|---------|
| 编辑按钮 | "编辑" | `button[data-action="edit"]` | 有编辑权限时 |
| 更多按钮 | "⋯" | `button[data-action="more"]` | - |

**标签页导航**
| 标签页 | 文字 | 选择器 | 默认 |
|--------|------|-------|------|
| 基本信息 | "基本信息" | `[data-tab="basic"]` | ✅ |
| 部门归属 | "部门归属" | `[data-tab="departments"]` | ❌ |
| 角色权限 | "角色权限" | `[data-tab="roles"]` | ❌ |
| 操作日志 | "操作日志" | `[data-tab="logs"]` | ❌ |

**基本信息字段标签**
| 字段 | 标签文字 | 选择器 |
|------|---------|-------|
| 显示名称 | "显示名称" | `label[for="displayName"]` |
| 用户名 | "用户名" | `label[for="username"]` |
| 邮箱 | "邮箱" | `label[for="email"]` |
| 电话 | "电话" | `label[for="phone"]` |
| 员工编号 | "员工编号" | `label[for="employeeId"]` |
| 状态 | "状态" | `label[for="status"]` |
| 来源 | "来源" | `label[for="source"]` |
| 默认区域 | "默认区域" | `label[for="defaultRegion"]` |
| 入职时间 | "入职时间" | `label[for="joinedAt"]` |
| 创建时间 | "创建时间" | `label[for="createdAt"]` |

**部门归属标签页按钮**
| 按钮 | 文字 | 选择器 |
|------|------|-------|
| 添加部门归属 | "添加部门归属" | `button[data-action="add-department"]` |
| 设为主部门 | "设为主部门" | `button[data-action="set-primary"]` |
| 移除归属 | "移除" | `button[data-action="remove-department"]` |

**角色权限标签页按钮**
| 按钮 | 文字 | 选择器 |
|------|------|-------|
| 分配角色 | "分配角色" | `button[data-action="assign-role"]` |
| 移除角色 | "移除" | `button[data-action="remove-role"]` |

#### 🎨 动态内容格式

**状态徽章**（同用户列表页）

**来源显示**
| 来源值 | 显示文字 | 说明 |
|-------|---------|------|
| LOCAL | "本地账号" | 本地创建 |
| LDAP | "LDAP" | LDAP（含 Entra 同步） |

**日期时间格式**
| 字段类型 | 格式 | 示例 |
|---------|------|------|
| 创建时间 | `YYYY-MM-DD HH:mm:ss` | "2025-12-27 10:30:00" |
| 入职时间 | `YYYY-MM-DD` | "2025-01-15" |
| 更新时间 | `YYYY-MM-DD HH:mm:ss` | "2025-12-27 14:20:00" |

**部门归属显示格式**
| 信息 | 格式 | 示例 |
|------|------|------|
| 部门名称 | "{部门名}" | "技术部" |
| 岗位 | "{岗位名}" | "高级工程师" |
| 上级 | "{上级姓名}" | "张经理" |
| 主部门标记 | "主部门" 徽章 | 蓝色徽章 |

**角色显示格式**
| 信息 | 格式 | 示例 |
|------|------|------|
| 角色名称 | "{角色名}" | "系统管理员" |
| 角色代码 | "{代码}" | "SYSTEM_ADMIN" |
| 组织 | "{组织名}" 或 "全局" | "中国 / 全局" |
| 全局标记 | "全局" 徽章 | 紫色徽章 |

#### 💬 提示信息

**成功提示（Toast）**
| 操作 | 提示文字 | 持续时间 |
|------|---------|---------|
| 添加部门成功 | "部门归属已添加" | 3秒 |
| 设置主部门成功 | "主部门已更新" | 3秒 |
| 移除部门成功 | "部门归属已移除" | 3秒 |
| 分配角色成功 | "角色已分配" | 3秒 |
| 移除角色成功 | "角色已移除" | 3秒 |
| 邮箱复制成功 | "已复制到剪贴板" | 2秒 |

**错误提示**
| 场景 | 提示文字 | 位置 |
|------|---------|------|
| 用户不存在 | "用户不存在或已被删除" | Toast |
| 无权限查看 | "您没有权限查看此用户" | Toast |
| 不能移除主部门 | "无法移除主部门，请先设置其他主部门" | Toast |
| 不能移除唯一部门 | "用户至少需要一个部门归属" | Toast |
| 加载失败 | "加载失败，请稍后重试" | Toast |

**确认对话框**
| 操作 | 标题 | 内容 | 按钮 |
|------|------|------|------|
| 移除部门归属 | "移除确认" | "确定要移除此部门归属吗？" | "取消" / "确定移除" |
| 移除角色 | "移除确认" | "确定要移除角色"{roleName}"吗？" | "取消" / "确定移除" |

**空状态**
| 场景 | 主文字 | 副文字 | 操作按钮 |
|------|-------|-------|---------|
| 无部门归属 | "暂无部门归属" | "用户至少需要一个部门归属" | "添加部门归属" |
| 无角色 | "暂无角色" | "为用户分配角色以授予权限" | "分配角色" |
| 无操作日志 | "暂无操作日志" | - | - |

**加载状态**
| 位置 | 显示内容 | 选择器 |
|------|---------|-------|
| 页面加载 | "加载中..." + Spinner | `[data-testid="detail-loading"]` |
| 标签页切换 | Skeleton 占位 | `[data-testid="tab-skeleton"]` |

#### 📝 表单内容（添加部门归属对话框）

**表单字段**
| 字段 | 标签 | 必填 | Placeholder | 选择器 |
|------|------|------|------------|--------|
| 部门 | "部门" | ✅ | "选择部门" | `select[name="departmentId"]` |
| 岗位 | "岗位" | ✅ | "选择岗位" | `select[name="positionId"]` |
| 直属上级 | "直属上级" | ❌ | "选择直属上级（可选）" | `select[name="managerId"]` |
| 设为主部门 | "设为主部门" | - | - | `input[type="checkbox"][name="isPrimary"]` |

#### 📝 表单内容（分配角色对话框）

**表单字段**
| 字段 | 标签 | 必填 | Placeholder | 选择器 |
|------|------|------|------------|--------|
| 角色 | "角色" | ✅ | "选择角色" | `select[name="roleId"]` |
| 组织 | "组织" | ❌ | "选择组织（全局角色不需要）" | `select[name="organizationId"]` |

---

### 2.1 页面加载与基本元素

#### 测试场景: 应该正确加载并显示用户详情

**验证点**:
- ✅ 返回按钮可见
- ✅ 用户头像显示
- ✅ 用户名标题显示
- ✅ 编辑按钮可见（有权限时）
- ✅ 更多按钮可见
- ✅ 标签页导航显示

**选择器**:
```typescript
{
  backButton: 'button[data-action="back"]',
  userAvatar: '[data-testid="user-avatar"]',
  userTitle: '[data-testid="user-title"]',
  editButton: 'button[data-action="edit"]',
  moreButton: 'button[data-action="more"]',
  tabs: '[data-testid="tabs"]'
}
```

---

#### 测试场景: 应该显示所有基本信息字段

**验证点**:
- ✅ 显示名称 `[data-field="displayName"]`
- ✅ 用户名 `[data-field="username"]`
- ✅ 邮箱 `[data-field="email"]`（带复制按钮）
- ✅ 电话 `[data-field="phone"]`
- ✅ 员工编号 `[data-field="employeeId"]`
- ✅ 状态徽章 `[data-field="status"]`
- ✅ 来源 `[data-field="source"]` (LOCAL/LDAP)
- ✅ 默认区域 `[data-field="defaultRegion"]`
- ✅ 入职时间 `[data-field="joinedAt"]`
- ✅ 创建时间 `[data-field="createdAt"]`

---

#### 测试场景: 应该能够复制邮箱

**操作步骤**:
1. 找到邮箱字段
2. 点击复制按钮
3. 验证复制成功

**验证点**:
- ✅ 复制按钮可见
- ✅ 点击后显示"已复制"提示
- ✅ 剪贴板包含正确的邮箱地址

**选择器**:
```typescript
emailCopyButton: 'button[data-action="copy-email"]',
copyToast: '[data-testid="copy-toast"]'
```

---

### 2.2 标签页交互

#### 测试场景: 应该正确显示所有标签页

**验证点**:
- ✅ "基本信息"标签 `[data-tab="basic"]` - 默认选中
- ✅ "部门归属"标签 `[data-tab="departments"]`
- ✅ "角色权限"标签 `[data-tab="roles"]`
- ✅ "操作日志"标签 `[data-tab="logs"]`

---

#### 测试场景: 应该能够切换标签页

**操作步骤**:
1. 点击"部门归属"标签
2. 验证内容切换
3. 验证URL更新

**验证点**:
- ✅ 标签页高亮状态切换
- ✅ 面板内容切换
- ✅ URL hash更新：`#tab=departments`
- ✅ 数据懒加载（首次切换时）

**验证方法**:
```typescript
// 点击标签
await page.click('[data-tab="departments"]');

// 验证URL
await expect(page).toHaveURL(/.*#tab=departments/);

// 验证面板显示
await expect(page.locator('[data-tab-panel="departments"]')).toBeVisible();

// 验证基本信息面板隐藏
await expect(page.locator('[data-tab-panel="basic"]')).not.toBeVisible();
```

---

### 2.3 部门归属标签页

#### 测试场景: 应该显示用户的所有部门归属

**验证点**:
- ✅ 部门归属列表可见
- ✅ 每个归属显示：部门名称、岗位、上级、是否主部门
- ✅ 主部门有特殊标记
- ✅ "添加部门归属"按钮可见

**选择器**:
```typescript
departmentList: '[data-testid="department-list"]',
departmentItem: '[data-testid="department-item"]',
primaryBadge: '[data-testid="primary-badge"]',
addDeptButton: 'button[data-action="add-department"]'
```

---

#### 测试场景: 应该能够添加部门归属

**操作步骤**:
1. 点击"添加部门归属"
2. 填写对话框表单
3. 提交

**验证点**:
- ✅ 对话框显示
- ✅ 部门下拉框可选择
- ✅ 岗位下拉框可选择
- ✅ 上级下拉框可选择（可选）
- ✅ 主部门复选框可勾选
- ✅ 提交后列表更新
- ✅ 显示成功提示

---

#### 测试场景: 应该能够设置主部门

**操作步骤**:
1. 找到非主部门的归属项
2. 点击"设为主部门"
3. 确认

**验证点**:
- ✅ 原主部门标记移除
- ✅ 新选择的部门标记为主部门
- ✅ 显示成功提示

---

#### 测试场景: 应该能够移除部门归属

**操作步骤**:
1. 点击某个部门归属的删除按钮
2. 确认删除

**验证点**:
- ✅ 确认对话框显示
- ✅ 确认后，归属从列表移除
- ✅ 不能删除唯一的部门归属（至少保留一个）
- ✅ 不能删除主部门（需先设置其他主部门）

---

### 2.4 角色权限标签页

#### 测试场景: 应该显示用户的所有角色

**验证点**:
- ✅ 角色列表可见
- ✅ 每个角色显示：角色名称、角色代码、所属组织
- ✅ 全局角色和组织级角色有区分标记
- ✅ "分配角色"按钮可见

---

#### 测试场景: 应该能够分配角色

**操作步骤**:
1. 点击"分配角色"
2. 选择角色和组织
3. 提交

**验证点**:
- ✅ 对话框显示
- ✅ 角色下拉框列出所有可用角色
- ✅ 组织下拉框列出用户可访问的组织
- ✅ 可以选择"全局角色"（organizationId = null）
- ✅ 提交后角色列表更新

---

#### 测试场景: 应该显示角色的权限列表

**验证点**:
- ✅ 展开角色，显示权限详情
- ✅ 权限按资源分组显示
- ✅ 显示权限代码和描述

---

### 2.5 操作日志标签页

#### 测试场景: 应该显示用户相关的操作日志

**验证点**:
- ✅ 日志列表可见
- ✅ 每条日志显示：操作时间、操作人、操作类型、操作描述
- ✅ 最新日志在最上面
- ✅ 支持分页（如果日志很多）

---

### 2.6 错误处理

#### 测试场景: 应该处理用户不存在

**操作步骤**:
1. 访问不存在的用户ID
2. 验证404页面

**验证点**:
- ✅ 显示"用户不存在"消息
- ✅ 返回按钮可见
- ✅ 点击返回，跳转到列表页

---

#### 测试场景: 应该处理权限不足

**操作步骤**:
1. 使用无权限的账号访问
2. 验证403提示

**验证点**:
- ✅ 显示"无权限查看"消息
- ✅ 返回按钮可见

---

## 3. 新建/编辑用户页

### 路由
- 新建：`/organization/members/new`
- 编辑：`/organization/members/:id/edit`

### 权限要求
- 新建：`user:create`
- 编辑：`user:update`

---

### 3.0 页面内容清单 ⭐

#### 📌 静态文本

**页面标题与导航**
| 页面模式 | 标题文字 | 选择器 |
|---------|---------|-------|
| 新建模式 | "新建用户" | `[data-testid="page-title"]` |
| 编辑模式 | "编辑用户" | `[data-testid="page-title"]` |
| 面包屑（新建） | "组织架构 / 用户管理 / 新建用户" | `[data-testid="breadcrumb"]` |
| 面包屑（编辑） | "组织架构 / 用户管理 / {username} / 编辑" | `[data-testid="breadcrumb"]` |

**操作按钮**
| 按钮 | 文字（新建） | 文字（编辑） | 选择器 | 状态变化 |
|------|-----------|-----------|-------|---------|
| 提交按钮 | "创建用户" | "保存更改" | `button[type="submit"]` | 加载中: "创建中..." / "保存中..." |
| 取消按钮 | "取消" | "取消" | `button[data-action="cancel"]` | - |
| 返回按钮 | "← 返回" | "← 返回" | `button[data-action="back"]` | - |

#### 📝 表单内容

**基本信息分组**
| 字段 | 标签 | 必填 | Placeholder | 选择器 | 说明 |
|------|------|------|------------|--------|------|
| 用户名 | "用户名" | ✅ | "请输入用户名（3-50位字母数字）" | `input[name="username"]` | 编辑时禁用 |
| 显示名称 | "显示名称" | ✅ | "请输入显示名称" | `input[name="displayName"]` | - |
| 邮箱 | "邮箱" | ✅ | "请输入邮箱地址" | `input[name="email"]` | - |
| 电话 | "电话" | ❌ | "请输入电话号码" | `input[name="phone"]` | - |
| 员工编号 | "员工编号" | ❌ | "请输入员工编号" | `input[name="employeeId"]` | - |

**身份源配置**
| 字段 | 标签 | 必填 | 选项/Placeholder | 选择器 | 说明 |
|------|------|------|-----------------|--------|------|
| 身份源 | "身份源" | ✅ | LOCAL / LDAP | `select[name="source"]` | 编辑时禁用 |
| 密码 | "密码" | ✅* | "请输入密码（至少8位）" | `input[name="password"]` | *仅LOCAL且新建时必填 |
| 确认密码 | "确认密码" | ✅* | "请再次输入密码" | `input[name="confirmPassword"]` | *仅LOCAL且新建时必填 |
| LDAP用户名 | "LDAP用户名" | ✅* | "请输入LDAP用户名" | `input[name="ldapUsername"]` | *仅LDAP时显示 |

**组织信息**
| 字段 | 标签 | 必填 | Placeholder | 选择器 |
|------|------|------|------------|--------|
| 默认区域 | "默认区域" | ✅ | "选择默认区域" | `select[name="defaultRegionId"]` |
| 部门 | "部门" | ✅ | "选择部门" | `select[name="departmentId"]` |
| 岗位 | "岗位" | ✅ | "选择岗位" | `select[name="positionId"]` |
| 直属上级 | "直属上级" | ❌ | "选择直属上级（可选）" | `select[name="managerId"]` |
| 入职时间 | "入职时间" | ❌ | "选择入职时间" | `input[type="date"][name="joinedAt"]` |

**身份源说明文本**
| 身份源 | 说明文字 | 位置 |
|-------|---------|------|
| LOCAL | "本地用户使用系统密码登录" | 身份源选择器下方 |
| LDAP | "LDAP用户将使用企业LDAP/AD账号登录，无需设置密码（含 Entra 同步用户）" | 身份源选择器下方 |

**下拉选项文字**
| 字段 | 选项值 | 选项文字 |
|------|-------|---------|
| 身份源 | `LOCAL` | "本地账号" |
| 身份源 | `LDAP` | "LDAP" |
| 默认区域 | `CN` | "中国" |
| 默认区域 | `US` | "美国" |
| 默认区域 | `UAE` | "阿联酋" |

#### 💬 提示信息

**成功提示**
| 操作 | 提示文字 | 跳转 |
|------|---------|------|
| 创建成功 | "用户创建成功" | 跳转到详情页 |
| 更新成功 | "用户信息已更新" | 跳转到详情页 |

**验证错误提示**
| 字段 | 错误情况 | 提示文字 | 选择器 |
|------|---------|---------|-------|
| 用户名 | 为空 | "用户名不能为空" | `[data-field="username"] + .error-message` |
| 用户名 | 格式错误 | "用户名只能包含字母、数字和下划线" | `[data-field="username"] + .error-message` |
| 用户名 | 长度不符 | "用户名长度应为3-50位" | `[data-field="username"] + .error-message` |
| 用户名 | 已存在 | "该用户名已被使用" | `[data-field="username"] + .error-message` |
| 邮箱 | 为空 | "邮箱不能为空" | `[data-field="email"] + .error-message` |
| 邮箱 | 格式错误 | "请输入有效的邮箱地址" | `[data-field="email"] + .error-message` |
| 邮箱 | 已存在 | "该邮箱已被使用" | `[data-field="email"] + .error-message` |
| 显示名称 | 为空 | "显示名称不能为空" | `[data-field="displayName"] + .error-message` |
| 密码 | 为空 | "密码不能为空" | `[data-field="password"] + .error-message` |
| 密码 | 长度不符 | "密码长度至少8位" | `[data-field="password"] + .error-message` |
| 密码 | 强度不足 | "密码需包含至少2种字符类型（字母/数字/特殊字符）" | `[data-field="password"] + .error-message` |
| 确认密码 | 不匹配 | "两次密码输入不一致" | `[data-field="confirmPassword"] + .error-message` |
| 部门 | 未选择 | "请选择部门" | `[data-field="departmentId"] + .error-message` |
| 岗位 | 未选择 | "请选择岗位" | `[data-field="positionId"] + .error-message` |

**确认对话框**
| 操作 | 标题 | 内容 | 按钮 |
|------|------|------|------|
| 取消编辑（有修改） | "未保存的更改" | "未保存的更改将丢失，确定离开吗？" | "留在此页" / "确认离开" |
| 取消新建（有输入） | "未保存的更改" | "未保存的更改将丢失，确定离开吗？" | "留在此页" / "确认离开" |

**加载状态**
| 位置 | 显示内容 | 选择器 |
|------|---------|-------|
| 页面加载（编辑模式） | "加载中..." + Spinner | `[data-testid="form-loading"]` |
| 提交按钮 | "创建中..." / "保存中..." | `button[type="submit"][disabled]` |
| 异步验证 | 旋转图标 | 字段右侧 |

**帮助文本**
| 字段 | 帮助文本 | 显示位置 |
|------|---------|---------|
| 用户名 | "用户名用于登录，创建后不可修改" | 字段下方 |
| 密码 | "密码长度至少8位，需包含至少2种字符类型" | 字段下方 |
| LDAP用户名 | "LDAP用户名用于匹配企业目录中的账号" | 字段下方 |

---

### 3.1 页面加载（新建模式）

#### 测试场景: 应该显示新建用户表单

**验证点**:
- ✅ 页面标题："新建用户"
- ✅ 返回按钮可见
- ✅ 表单可见 `form[data-form="user"]`
- ✅ 所有必填字段可见
- ✅ 身份源默认选择LOCAL
- ✅ 提交按钮："创建用户"
- ✅ 取消按钮可见

---

### 3.2 身份源选择

#### 测试场景: 应该根据身份源显示不同字段（LOCAL）

**操作步骤**:
1. 确认身份源为LOCAL

**验证点**:
- ✅ 密码字段可见且必填 `input[name="password"]`
- ✅ 确认密码字段可见且必填 `input[name="confirmPassword"]`
- ✅ LDAP用户名字段不可见

---

#### 测试场景: 应该根据身份源显示不同字段（LDAP）

**操作步骤**:
1. 选择身份源为LDAP
2. 验证字段变化

**验证点**:
- ✅ 密码字段隐藏
- ✅ 确认密码字段隐藏
- ✅ LDAP用户名字段可见且必填 `input[name="ldapUsername"]`
- ✅ 提示信息显示："LDAP用户将使用企业LDAP/AD账号登录，无需设置密码"

---

#### 测试场景: 应该能够切换身份源

**操作步骤**:
1. 选择LDAP
2. 填写LDAP用户名
3. 切换回LOCAL
4. 验证字段重置

**验证点**:
- ✅ LDAP字段值被清空
- ✅ 密码字段恢复显示
- ✅ 表单重新验证

---

### 3.3 表单验证

#### 测试场景: 应该验证必填字段

**操作步骤**:
1. 不填写任何字段
2. 点击提交
3. 验证错误提示

**验证点**:
- ✅ 用户名：显示"用户名不能为空"
- ✅ 邮箱：显示"邮箱不能为空"
- ✅ 显示名称：显示"显示名称不能为空"
- ✅ 密码：显示"密码不能为空"（LOCAL用户）
- ✅ 部门：显示"请选择部门"
- ✅ 岗位：显示"请选择岗位"
- ✅ 表单不提交

---

#### 测试场景: 应该验证用户名格式

**操作步骤**:
1. 输入无效用户名
2. 离开焦点
3. 验证错误提示

**测试用例**:
- ✅ 少于3个字符：错误
- ✅ 超过50个字符：错误
- ✅ 包含特殊字符（除下划线）：错误
- ✅ 包含空格：错误
- ✅ 包含中文：错误
- ✅ 有效格式（3-50字符，字母数字下划线）：通过

---

#### 测试场景: 应该验证邮箱格式

**测试用例**:
- ✅ "invalid"：错误
- ✅ "invalid@"：错误
- ✅ "@example.com"：错误
- ✅ "user@example.com"：通过

---

#### 测试场景: 应该验证密码强度（LOCAL用户）

**测试用例**:
- ✅ 少于8个字符：错误
- ✅ 超过50个字符：错误
- ✅ 只有字母：错误（需要至少2种字符类型）
- ✅ 只有数字：错误
- ✅ 字母+数字：通过
- ✅ 字母+特殊字符：通过
- ✅ 数字+特殊字符：通过

---

#### 测试场景: 应该验证确认密码匹配

**操作步骤**:
1. 输入密码："Password123"
2. 输入确认密码："Password456"
3. 验证错误提示

**验证点**:
- ✅ 显示"两次密码输入不一致"
- ✅ 表单不提交

---

#### 测试场景: 应该验证邮箱唯一性（异步验证）

**操作步骤**:
1. 输入已存在的邮箱
2. 离开焦点
3. 等待异步验证

**验证点**:
- ✅ 显示加载指示器
- ✅ 验证完成后显示错误："该邮箱已被使用"
- ✅ 表单不提交

---

### 3.4 表单提交

#### 测试场景: 应该成功创建LOCAL用户

**操作步骤**:
1. 选择身份源：LOCAL
2. 填写所有必填字段
3. 点击提交

**验证点**:
- ✅ 提交按钮变为"创建中..."且禁用
- ✅ API调用：`POST /api/v1/users`
- ✅ 请求体包含所有字段
- ✅ 成功后显示成功提示
- ✅ 跳转到用户详情页
- ✅ URL为新用户的ID

---

#### 测试场景: 应该成功创建LDAP用户

**操作步骤**:
1. 选择身份源：LDAP
2. 填写必填字段（不含密码）
3. 点击提交

**验证点**:
- ✅ 请求体不包含password字段
- ✅ 请求体包含ldapUsername字段
- ✅ 创建成功

---

#### 测试场景: 应该处理创建失败

**操作步骤**:
1. 填写表单
2. 模拟API错误
3. 点击提交

**验证点**:
- ✅ 显示错误提示
- ✅ 提交按钮恢复可用
- ✅ 表单数据保留
- ✅ 特定字段错误高亮（如果有）

---

### 3.5 编辑模式

#### 测试场景: 应该加载并填充现有用户数据

**操作步骤**:
1. 访问编辑页面
2. 验证数据加载

**验证点**:
- ✅ 页面标题："编辑用户"
- ✅ 所有字段填充现有数据
- ✅ 身份源字段禁用（不可更改）
- ✅ 密码字段不显示（编辑模式）
- ✅ 提交按钮："保存更改"

---

#### 测试场景: 应该能够修改用户信息

**操作步骤**:
1. 修改显示名称
2. 修改邮箱
3. 修改部门
4. 提交

**验证点**:
- ✅ API调用：`PATCH /api/v1/users/:id`
- ✅ 请求体只包含修改的字段
- ✅ 成功后跳转到详情页
- ✅ 详情页显示更新后的数据

---

### 3.6 取消操作

#### 测试场景: 应该提示未保存的更改

**操作步骤**:
1. 填写部分表单
2. 点击取消按钮（或返回按钮）

**验证点**:
- ✅ 确认对话框显示："未保存的更改将丢失，确定离开吗？"
- ✅ "留在此页"按钮
- ✅ "确认离开"按钮
- ✅ 点击"留在此页"，对话框关闭
- ✅ 点击"确认离开"，返回上一页

---

#### 测试场景: 无更改时直接返回

**操作步骤**:
1. 不做任何修改
2. 点击取消

**验证点**:
- ✅ 不显示确认对话框
- ✅ 直接返回上一页

---

## 4. 角色管理

### 路由
`/organization/roles`

### 权限要求
`role:read`

---

### 4.0 页面内容清单 ⭐

#### 📌 静态文本

**页面标题与导航**
| 位置 | 内容 | 选择器 | 说明 |
|------|------|-------|------|
| 页面标题 | "角色管理" | `[data-testid="page-title"]` | h1 标题 |
| 面包屑 | "组织架构 / 角色管理" | `[data-testid="breadcrumb"]` | 导航路径 |

**操作按钮**
| 按钮 | 文字 | 选择器 | 权限要求 |
|------|------|-------|---------|
| 新建按钮 | "新建角色" | `button[data-action="create-role"]` | `role:create` |

**筛选器**
| 筛选器 | 标签 | Placeholder | 选择器 |
|--------|------|------------|--------|
| 搜索框 | - | "搜索角色名称或代码" | `input[data-testid="role-search-input"]` |
| 类型筛选 | "类型" | "全部类型" | `select[name="type"]` |
| 组织筛选 | "组织" | "全部组织" | `select[name="organization"]` |

**表格列标题**
| 列 | 标题文字 | 选择器 | 功能 |
|----|---------|-------|------|
| 第1列 | "角色名称" | `th[data-column="name"]` | 支持排序 |
| 第2列 | "代码" | `th[data-column="code"]` | - |
| 第3列 | "类型" | `th[data-column="type"]` | - |
| 第4列 | "组织" | `th[data-column="organization"]` | - |
| 第5列 | "用户数" | `th[data-column="userCount"]` | 支持排序 |
| 第6列 | "状态" | `th[data-column="status"]` | - |
| 第7列 | "操作" | `th[data-column="actions"]` | - |

**操作按钮（表格行内）**
| 按钮 | 文字 | 选择器 | 说明 |
|------|------|-------|------|
| 查看 | "查看" | `button[data-action="view"]` | 查看详情 |
| 编辑 | "编辑" | `button[data-action="edit"]` | 编辑角色 |
| 删除 | "删除" | `button[data-action="delete"]` | 删除角色 |

#### 🎨 动态内容格式

**类型徽章显示**
| 类型值 | 显示文字 | 颜色 | CSS类 |
|-------|---------|------|-------|
| SYSTEM | "系统角色" | 蓝色 | `bg-blue-100 text-blue-700` |
| CUSTOM | "自定义角色" | 绿色 | `bg-green-100 text-green-700` |

**状态徽章显示**
| 状态值 | 显示文字 | 颜色 | CSS类 |
|-------|---------|------|-------|
| ACTIVE | "启用" | 绿色 | `bg-[#e6f7ed] text-[#00b42a]` |
| INACTIVE | "禁用" | 灰色 | `bg-[#f7f8fa] text-[#8f959e]` |

**组织显示格式**
| 情况 | 显示文字 | 徽章 |
|------|---------|------|
| 全局角色 | "全局" | 紫色徽章 |
| 组织角色 | "{组织名}" | 默认样式 |

**用户数显示**
| 情况 | 显示格式 | 可点击 |
|------|---------|--------|
| 0个用户 | "0" | ❌ |
| >0个用户 | "{count}" | ✅（点击查看用户列表） |

**筛选器选项**
| 筛选器 | 选项值 | 选项文字 |
|--------|-------|---------|
| 类型筛选 | `''` | "全部类型" |
| 类型筛选 | `SYSTEM` | "系统角色" |
| 类型筛选 | `CUSTOM` | "自定义角色" |
| 组织筛选 | `''` | "全部组织" |
| 组织筛选 | `global` | "全局" |
| 组织筛选 | `{orgId}` | "{组织名}" |

#### 💬 提示信息

**成功提示（Toast）**
| 操作 | 提示文字 | 持续时间 |
|------|---------|---------|
| 创建成功 | "角色创建成功" | 3秒 |
| 更新成功 | "角色已更新" | 3秒 |
| 删除成功 | "角色已删除" | 3秒 |
| 复制成功 | "已复制" | 2秒 |
| 启用成功 | "角色已启用" | 3秒 |
| 禁用成功 | "角色已禁用" | 3秒 |

**错误提示（Toast）**
| 场景 | 提示文字 | 位置 |
|------|---------|------|
| 网络错误 | "网络连接失败，请稍后重试" | Toast |
| 权限错误 | "您没有权限执行此操作" | Toast |
| 删除失败（有用户） | "该角色下还有用户，无法删除" | Toast |
| 删除失败（系统角色） | "系统角色不能删除" | Toast |
| 服务器错误 | "服务器错误，请联系管理员" | Toast |

**确认对话框**
| 操作 | 标题 | 内容 | 按钮 |
|------|------|------|------|
| 删除角色 | "删除确认" | "确定要删除角色"{roleName}"吗？此操作不可恢复。" | "取消" / "确定删除" |
| 禁用角色 | "禁用确认" | "禁用后，该角色的用户将失去相应权限，确定要禁用吗？" | "取消" / "确定" |

**空状态**
| 场景 | 图标 | 主文字 | 副文字 | 操作按钮 | 选择器 |
|------|------|-------|-------|---------|-------|
| 无数据 | EmptyIcon | "暂无角色数据" | "点击按钮创建第一个角色" | "创建第一个角色" | `[data-testid="empty-state"]` |
| 搜索无结果 | SearchIcon | "未找到匹配的角色" | "尝试调整搜索条件" | "清除搜索" | `[data-testid="empty-search"]` |
| 无权限 | LockIcon | "您没有权限查看角色列表" | "请联系管理员申请权限" | "返回" | `[data-testid="no-permission"]` |

**加载状态**
| 位置 | 显示内容 | 选择器 |
|------|---------|-------|
| 页面加载 | "加载中..." + Spinner | `[data-testid="page-loading"]` |
| 搜索加载 | 旋转图标 | `[data-testid="search-loading"]` |
| 删除按钮 | "删除中..." | `button[data-action="delete"][disabled]` |
| 表格加载 | Skeleton 占位 | `[data-testid="skeleton"]` |

#### 📝 表单内容（新建/编辑角色对话框）

**基本信息**
| 字段 | 标签 | 必填 | Placeholder | 选择器 | 说明 |
|------|------|------|------------|--------|------|
| 角色名称 | "角色名称" | ✅ | "请输入角色名称" | `input[name="name"]` | - |
| 角色代码 | "角色代码" | ✅ | "请输入角色代码（英文大写+下划线）" | `input[name="code"]` | 编辑时禁用 |
| 描述 | "描述" | ❌ | "请输入角色描述" | `textarea[name="description"]` | - |
| 类型 | "类型" | ✅ | - | `select[name="type"]` | 编辑时禁用 |
| 组织 | "组织" | ❌ | "选择组织（可选，全局角色不需要）" | `select[name="organizationId"]` | 仅自定义角色 |

**权限配置**
| 字段 | 标签 | 选择器 | 说明 |
|------|------|-------|------|
| 权限树 | "权限配置" | `[data-testid="permission-tree"]` | 树形结构 |
| 全选复选框 | "全选" | `input[data-action="select-all"]` | - |
| 模块展开 | "▼" | `button[data-action="expand"]` | 展开/收起 |

**对话框按钮**
| 按钮 | 文字（新建） | 文字（编辑） | 选择器 |
|------|-----------|-----------|-------|
| 提交 | "创建角色" | "保存更改" | `button[type="submit"]` |
| 取消 | "取消" | "取消" | `button[data-action="cancel"]` |

---

### 4.1 角色列表页

#### 测试场景: 应该显示角色列表

**验证点**:
- ✅ 页面标题："角色管理"
- ✅ 角色表格可见
- ✅ 至少显示内置角色
- ✅ 新建角色按钮可见（有权限时）

---

#### 测试场景: 应该显示角色信息

**验证点**（每个角色）:
- ✅ 角色名称
- ✅ 角色代码
- ✅ 描述
- ✅ 类型标记（内置/自定义）
- ✅ 状态（启用/禁用）
- ✅ 用户数量
- ✅ 权限数量
- ✅ 操作按钮

---

#### 测试场景: 应该能够点击编辑权限

**操作步骤**:
1. 点击某角色的"编辑权限"按钮
2. 验证跳转

**验证点**:
- ✅ URL变为 `/organization/roles/{id}/permissions`
- ✅ 权限配置页加载

---

### 4.2 权限配置页

#### 测试场景: 应该显示权限树

**验证点**:
- ✅ 角色信息卡可见
- ✅ 权限树可见
- ✅ 权限按资源分组
- ✅ 已分配的权限被选中
- ✅ 保存按钮可见

---

#### 测试场景: 应该能够选择/取消权限

**操作步骤**:
1. 点击权限复选框
2. 验证状态变化

**验证点**:
- ✅ 复选框状态切换
- ✅ 已选权限计数更新

---

#### 测试场景: 应该支持父子节点联动

**操作步骤**:
1. 选择父节点
2. 验证子节点自动选中
3. 取消父节点
4. 验证子节点自动取消

**验证点**:
- ✅ 选择父节点→所有子节点选中
- ✅ 取消父节点→所有子节点取消
- ✅ 所有子节点选中→父节点自动选中
- ✅ 任一子节点取消→父节点半选状态

---

#### 测试场景: 应该能够保存权限配置

**操作步骤**:
1. 修改权限选择
2. 点击保存
3. 验证

**验证点**:
- ✅ 保存按钮变为"保存中..."
- ✅ API调用：`PUT /api/v1/roles/:id/permissions`
- ✅ 请求体包含所有选中的权限ID
- ✅ 成功后显示提示
- ✅ 1秒后返回角色列表

---

## 5. 部门管理

### 路由
`/organization/departments`

### 权限要求
`department:read`

---

### 5.0 页面内容清单 ⭐

#### 📌 静态文本

**页面标题与导航**
| 位置 | 内容 | 选择器 | 说明 |
|------|------|-------|------|
| 页面标题 | "组织架构" | `[data-testid="page-title"]` | h1 标题 |
| 面包屑 | "组织架构 / 部门管理" | `[data-testid="breadcrumb"]` | 导航路径 |

**操作按钮**
| 按钮 | 文字 | 选择器 | 权限要求 |
|------|------|-------|---------|
| 新建部门 | "新建部门" | `button[data-action="create-dept"]` | `department:create` |
| 刷新 | "刷新" | `button[data-action="refresh"]` | - |
| 展开全部 | "展开全部" | `button[data-action="expand-all"]` | - |
| 收起全部 | "收起全部" | `button[data-action="collapse-all"]` | - |

**树节点操作**
| 按钮 | 图标/文字 | 选择器 | 显示条件 |
|------|---------|-------|---------|
| 展开按钮 | "▶" | `button[data-action="expand"]` | 有子节点时 |
| 收起按钮 | "▼" | `button[data-action="collapse"]` | 已展开且有子节点时 |
| 添加子部门 | "+" | `button[data-action="add-child"]` | 悬停时显示 |
| 编辑 | "✏" | `button[data-action="edit"]` | 悬停时显示 |
| 删除 | "🗑" | `button[data-action="delete"]` | 悬停时显示 |

**搜索框**
| 组件 | Placeholder | 选择器 |
|------|------------|--------|
| 搜索框 | "搜索部门名称或代码" | `input[data-testid="dept-search-input"]` |
| 清除按钮 | "✕" | `button[data-action="clear-search"]` |

#### 🎨 动态内容格式

**部门节点信息**
| 字段 | 格式 | 示例 |
|------|------|------|
| 部门名称 | "{名称}" | "技术部" |
| 部门代码 | "({代码})" | "(TECH)" |
| 员工数量 | "{人数}人" | "25人" |
| 层级缩进 | 每层 24px | - |

**节点状态**
| 状态 | 图标 | 样式 |
|------|------|------|
| 收起 | "▶" | 默认 |
| 展开 | "▼" | 默认 |
| 叶子节点 | "-" | 灰色 |
| 加载中 | Spinner | 旋转动画 |

**节点高亮**
| 状态 | 背景颜色 | 说明 |
|------|---------|------|
| 普通 | 透明 | - |
| 悬停 | `bg-gray-50` | hover状态 |
| 选中 | `bg-blue-50` | 当前选中 |
| 搜索匹配 | `bg-yellow-50` | 搜索结果 |

#### 💬 提示信息

**成功提示（Toast）**
| 操作 | 提示文字 | 持续时间 |
|------|---------|---------|
| 创建成功 | "部门创建成功" | 3秒 |
| 更新成功 | "部门已更新" | 3秒 |
| 删除成功 | "部门已删除" | 3秒 |
| 移动成功 | "部门已移动" | 3秒 |

**错误提示（Toast）**
| 场景 | 提示文字 | 位置 |
|------|---------|------|
| 网络错误 | "网络连接失败，请稍后重试" | Toast |
| 权限错误 | "您没有权限执行此操作" | Toast |
| 删除失败（有员工） | "该部门下还有员工，无法删除" | Toast |
| 删除失败（有子部门） | "该部门下还有子部门，无法删除" | Toast |
| 代码重复 | "部门代码已存在" | Toast |

**确认对话框**
| 操作 | 标题 | 内容 | 按钮 |
|------|------|------|------|
| 删除部门 | "删除确认" | "确定要删除部门"{deptName}"吗？此操作不可恢复。" | "取消" / "确定删除" |
| 移动部门 | "移动确认" | "确定要将"{deptName}"移动到"{targetName}"下吗？" | "取消" / "确定" |

**空状态**
| 场景 | 图标 | 主文字 | 副文字 | 操作按钮 | 选择器 |
|------|------|-------|-------|---------|-------|
| 无数据 | EmptyIcon | "暂无部门数据" | "点击按钮创建第一个部门" | "创建第一个部门" | `[data-testid="empty-state"]` |
| 搜索无结果 | SearchIcon | "未找到匹配的部门" | "尝试调整搜索条件" | "清除搜索" | `[data-testid="empty-search"]` |
| 无权限 | LockIcon | "您没有权限查看组织架构" | "请联系管理员申请权限" | "返回" | `[data-testid="no-permission"]` |

**加载状态**
| 位置 | 显示内容 | 选择器 |
|------|---------|-------|
| 页面加载 | "加载中..." + Spinner | `[data-testid="tree-loading"]` |
| 节点展开 | 小型 Spinner | 节点图标位置 |
| 删除按钮 | "删除中..." | `button[data-action="delete"][disabled]` |

#### 📝 表单内容（新建/编辑部门对话框）

**基本信息**
| 字段 | 标签 | 必填 | Placeholder | 选择器 | 说明 |
|------|------|------|------------|--------|------|
| 部门名称 | "部门名称" | ✅ | "请输入部门名称" | `input[name="name"]` | - |
| 部门代码 | "部门代码" | ✅ | "请输入部门代码（英文大写）" | `input[name="code"]` | 编辑时禁用 |
| 上级部门 | "上级部门" | ❌ | "选择上级部门（可选，默认为根级）" | `select[name="parentId"]` | - |
| 排序 | "排序" | ❌ | "输入排序数字（越小越靠前）" | `input[type="number"][name="order"]` | - |
| 描述 | "描述" | ❌ | "请输入部门描述" | `textarea[name="description"]` | - |

**组织归属**
| 字段 | 标签 | 必填 | Placeholder | 选择器 |
|------|------|------|------------|--------|
| 所属组织 | "所属组织" | ✅ | "选择所属组织" | `select[name="organizationId"]` |

**对话框按钮**
| 按钮 | 文字（新建） | 文字（编辑） | 选择器 |
|------|-----------|-----------|-------|
| 提交 | "创建部门" | "保存更改" | `button[type="submit"]` |
| 取消 | "取消" | "取消" | `button[data-action="cancel"]` |

---

### 5.1 组织架构树页

#### 测试场景: 应该显示组织架构树

**验证点**:
- ✅ 页面标题："组织架构"
- ✅ 树形结构可见
- ✅ 至少显示根节点
- ✅ 新建部门按钮可见

---

#### 测试场景: 应该显示部门节点信息

**验证点**（每个节点）:
- ✅ 部门名称
- ✅ 部门代码
- ✅ 员工数量
- ✅ 展开/收起按钮（有子部门时）
- ✅ 操作按钮（查看/编辑/删除）

---

#### 测试场景: 应该能够展开/收起部门

**操作步骤**:
1. 点击展开按钮
2. 验证子部门显示
3. 再次点击
4. 验证子部门隐藏

**验证点**:
- ✅ 展开图标旋转
- ✅ 子节点显示/隐藏
- ✅ 动画流畅

---

#### 测试场景: 应该支持搜索部门

**操作步骤**:
1. 输入部门名称
2. 验证筛选结果

**验证点**:
- ✅ 匹配的部门高亮
- ✅ 不匹配的部门变灰或隐藏
- ✅ 匹配部门的父级自动展开

---

## 6. 组织管理

### 路由
`/organization/organizations`

### 权限要求
`organization:read`

---

### 6.0 页面内容清单 ⭐

#### 📌 静态文本

**页面标题与导航**
| 位置 | 内容 | 选择器 | 说明 |
|------|------|-------|------|
| 页面标题 | "组织管理" | `[data-testid="page-title"]` | h1 标题 |
| 面包屑 | "组织架构 / 组织管理" | `[data-testid="breadcrumb"]` | 导航路径 |

**操作按钮**
| 按钮 | 文字 | 选择器 | 权限要求 |
|------|------|-------|---------|
| 新建按钮 | "新建组织" | `button[data-action="create-org"]` | `organization:create` |

**搜索框**
| 组件 | Placeholder | 选择器 |
|------|------------|--------|
| 搜索框 | "搜索组织名称或代码" | `input[data-testid="org-search-input"]` |
| 清除按钮 | "✕" | `button[data-action="clear-search"]` |

**表格列标题**
| 列 | 标题文字 | 选择器 | 功能 |
|----|---------|-------|------|
| 第1列 | "组织名称" | `th[data-column="name"]` | 支持排序 |
| 第2列 | "代码" | `th[data-column="code"]` | - |
| 第3列 | "类型" | `th[data-column="type"]` | - |
| 第4列 | "区域" | `th[data-column="region"]` | - |
| 第5列 | "员工数" | `th[data-column="employeeCount"]` | 支持排序 |
| 第6列 | "状态" | `th[data-column="status"]` | - |
| 第7列 | "操作" | `th[data-column="actions"]` | - |

**操作按钮（表格行内）**
| 按钮 | 文字 | 选择器 | 说明 |
|------|------|-------|------|
| 查看 | "查看" | `button[data-action="view"]` | 查看详情 |
| 编辑 | "编辑" | `button[data-action="edit"]` | 编辑组织 |
| 删除 | "删除" | `button[data-action="delete"]` | 删除组织 |

#### 🎨 动态内容格式

**类型徽章显示**
| 类型值 | 显示文字 | 颜色 | CSS类 |
|-------|---------|------|-------|
| COMPANY | "公司" | 蓝色 | `bg-blue-100 text-blue-700` |
| SUBSIDIARY | "子公司" | 绿色 | `bg-green-100 text-green-700` |
| BRANCH | "分公司" | 黄色 | `bg-yellow-100 text-yellow-700` |
| DEPARTMENT | "部门" | 灰色 | `bg-gray-100 text-gray-700` |

**区域徽章显示**
| 区域值 | 显示文字 | 图标 |
|-------|---------|------|
| CN | "中国" | 🇨🇳 |
| US | "美国" | 🇺🇸 |
| UAE | "阿联酋" | 🇦🇪 |
| GLOBAL | "全球" | 🌐 |

**状态徽章显示**
| 状态值 | 显示文字 | 颜色 | CSS类 |
|-------|---------|------|-------|
| ACTIVE | "启用" | 绿色 | `bg-[#e6f7ed] text-[#00b42a]` |
| INACTIVE | "禁用" | 灰色 | `bg-[#f7f8fa] text-[#8f959e]` |

**员工数显示**
| 情况 | 显示格式 | 可点击 |
|------|---------|--------|
| 0个员工 | "0" | ❌ |
| >0个员工 | "{count}" | ✅（点击查看员工列表） |

#### 💬 提示信息

**成功提示（Toast）**
| 操作 | 提示文字 | 持续时间 |
|------|---------|---------|
| 创建成功 | "组织创建成功" | 3秒 |
| 更新成功 | "组织已更新" | 3秒 |
| 删除成功 | "组织已删除" | 3秒 |
| 启用成功 | "组织已启用" | 3秒 |
| 禁用成功 | "组织已禁用" | 3秒 |

**错误提示（Toast）**
| 场景 | 提示文字 | 位置 |
|------|---------|------|
| 网络错误 | "网络连接失败，请稍后重试" | Toast |
| 权限错误 | "您没有权限执行此操作" | Toast |
| 删除失败（有员工） | "该组织下还有员工，无法删除" | Toast |
| 删除失败（有子组织） | "该组织下还有子组织，无法删除" | Toast |
| 代码重复 | "组织代码已存在" | Toast |
| 服务器错误 | "服务器错误，请联系管理员" | Toast |

**确认对话框**
| 操作 | 标题 | 内容 | 按钮 |
|------|------|------|------|
| 删除组织 | "删除确认" | "确定要删除组织"{orgName}"吗？此操作不可恢复。" | "取消" / "确定删除" |
| 禁用组织 | "禁用确认" | "禁用后，该组织下的所有用户将无法访问系统，确定要禁用吗？" | "取消" / "确定" |

**空状态**
| 场景 | 图标 | 主文字 | 副文字 | 操作按钮 | 选择器 |
|------|------|-------|-------|---------|-------|
| 无数据 | EmptyIcon | "暂无组织数据" | "点击按钮创建第一个组织" | "创建第一个组织" | `[data-testid="empty-state"]` |
| 搜索无结果 | SearchIcon | "未找到匹配的组织" | "尝试调整搜索条件" | "清除搜索" | `[data-testid="empty-search"]` |
| 无权限 | LockIcon | "您没有权限查看组织列表" | "请联系管理员申请权限" | "返回" | `[data-testid="no-permission"]` |

**加载状态**
| 位置 | 显示内容 | 选择器 |
|------|---------|-------|
| 页面加载 | "加载中..." + Spinner | `[data-testid="page-loading"]` |
| 搜索加载 | 旋转图标 | `[data-testid="search-loading"]` |
| 删除按钮 | "删除中..." | `button[data-action="delete"][disabled]` |
| 表格加载 | Skeleton 占位 | `[data-testid="skeleton"]` |

#### 📝 表单内容（新建/编辑组织对话框）

**基本信息**
| 字段 | 标签 | 必填 | Placeholder | 选择器 | 说明 |
|------|------|------|------------|--------|------|
| 组织名称 | "组织名称" | ✅ | "请输入组织名称" | `input[name="name"]` | - |
| 组织代码 | "组织代码" | ✅ | "请输入组织代码（英文大写）" | `input[name="code"]` | 编辑时禁用 |
| 类型 | "类型" | ✅ | "选择组织类型" | `select[name="type"]` | - |
| 区域 | "区域" | ✅ | "选择所属区域" | `select[name="region"]` | - |
| 上级组织 | "上级组织" | ❌ | "选择上级组织（可选）" | `select[name="parentId"]` | - |
| 描述 | "描述" | ❌ | "请输入组织描述" | `textarea[name="description"]` | - |

**联系信息**
| 字段 | 标签 | 必填 | Placeholder | 选择器 |
|------|------|------|------------|--------|
| 联系人 | "联系人" | ❌ | "请输入联系人姓名" | `input[name="contactName"]` |
| 联系电话 | "联系电话" | ❌ | "请输入联系电话" | `input[name="contactPhone"]` |
| 联系邮箱 | "联系邮箱" | ❌ | "请输入联系邮箱" | `input[name="contactEmail"]` |
| 办公地址 | "办公地址" | ❌ | "请输入办公地址" | `textarea[name="address"]` |

**下拉选项文字**
| 字段 | 选项值 | 选项文字 |
|------|-------|---------|
| 类型 | `COMPANY` | "公司" |
| 类型 | `SUBSIDIARY` | "子公司" |
| 类型 | `BRANCH` | "分公司" |
| 类型 | `DEPARTMENT` | "部门" |
| 区域 | `CN` | "中国" |
| 区域 | `US` | "美国" |
| 区域 | `UAE` | "阿联酋" |
| 区域 | `GLOBAL` | "全球" |

**对话框按钮**
| 按钮 | 文字（新建） | 文字（编辑） | 选择器 |
|------|-----------|-----------|-------|
| 提交 | "创建组织" | "保存更改" | `button[type="submit"]` |
| 取消 | "取消" | "取消" | `button[data-action="cancel"]` |

---

### 6.1 组织列表页

#### 测试场景: 应该显示组织列表

**验证点**:
- ✅ 页面标题："组织管理"
- ✅ 组织表格可见
- ✅ 显示所有组织记录
- ✅ 新建组织按钮可见（有权限时）
- ✅ 搜索框可见

---

#### 测试场景: 应该显示组织信息

**验证点**（每个组织）:
- ✅ 组织名称
- ✅ 组织代码
- ✅ 类型徽章
- ✅ 区域标识
- ✅ 员工数量
- ✅ 状态徽章
- ✅ 操作按钮（查看/编辑/删除）

---

#### 测试场景: 应该能够搜索组织

**操作步骤**:
1. 在搜索框输入组织名称
2. 等待搜索结果更新
3. 验证结果匹配搜索条件

**验证点**:
- ✅ 搜索结果包含匹配的组织
- ✅ 不匹配的组织被过滤
- ✅ 清除按钮可用

---

### 6.2 新建组织

#### 测试场景: 应该能够成功创建组织

**操作步骤**:
1. 点击"新建组织"按钮
2. 填写必填字段（名称、代码、类型、区域）
3. 填写可选字段（上级组织、联系信息等）
4. 点击"创建组织"

**验证点**:
- ✅ 表单验证通过
- ✅ 显示成功提示
- ✅ 跳转到组织详情页或列表页
- ✅ 新组织出现在列表中

---

### 6.3 编辑组织

#### 测试场景: 应该能够编辑组织信息

**操作步骤**:
1. 点击某个组织的"编辑"按钮
2. 修改可编辑字段
3. 点击"保存更改"

**验证点**:
- ✅ 表单预填充现有数据
- ✅ 组织代码字段禁用
- ✅ 显示更新成功提示
- ✅ 列表中数据已更新

---

### 6.4 删除组织

#### 测试场景: 应该能够删除空组织

**前提条件**: 组织下无员工和子组织

**操作步骤**:
1. 点击"删除"按钮
2. 确认删除对话框
3. 点击"确定删除"

**验证点**:
- ✅ 显示确认对话框
- ✅ 显示删除成功提示
- ✅ 组织从列表中移除

---

#### 测试场景: 不应该能够删除有员工的组织

**前提条件**: 组织下有员工

**操作步骤**:
1. 点击"删除"按钮
2. 确认删除

**验证点**:
- ✅ 显示错误提示："该组织下还有员工，无法删除"
- ✅ 组织仍在列表中

---

### 6.5 选择器索引表

**组织列表页**
```typescript
const SELECTORS = {
  // 页面元素
  pageTitle: '[data-testid="page-title"]',
  breadcrumb: '[data-testid="breadcrumb"]',
  
  // 操作按钮
  createButton: 'button[data-action="create-org"]',
  searchInput: 'input[data-testid="org-search-input"]',
  clearSearch: 'button[data-action="clear-search"]',
  
  // 表格
  table: '[data-testid="org-table"]',
  tableRow: '[data-testid="org-row"]',
  columnName: '[data-column="name"]',
  columnCode: '[data-column="code"]',
  columnType: '[data-column="type"]',
  columnRegion: '[data-column="region"]',
  columnEmployeeCount: '[data-column="employeeCount"]',
  columnStatus: '[data-column="status"]',
  
  // 行操作
  viewButton: 'button[data-action="view"]',
  editButton: 'button[data-action="edit"]',
  deleteButton: 'button[data-action="delete"]',
  
  // 对话框
  confirmDialog: '[data-testid="confirm-dialog"]',
  confirmButton: 'button[data-action="confirm"]',
  cancelButton: 'button[data-action="cancel"]',
  
  // 表单
  formDialog: '[data-testid="org-form-dialog"]',
  nameInput: 'input[name="name"]',
  codeInput: 'input[name="code"]',
  typeSelect: 'select[name="type"]',
  regionSelect: 'select[name="region"]',
  parentSelect: 'select[name="parentId"]',
  descriptionTextarea: 'textarea[name="description"]',
  contactNameInput: 'input[name="contactName"]',
  contactPhoneInput: 'input[name="contactPhone"]',
  contactEmailInput: 'input[name="contactEmail"]',
  addressTextarea: 'textarea[name="address"]',
  submitButton: 'button[type="submit"]',
  
  // 状态
  emptyState: '[data-testid="empty-state"]',
  emptySearch: '[data-testid="empty-search"]',
  loadingState: '[data-testid="page-loading"]',
  toast: '[data-testid="toast"]',
  toastSuccess: '[data-testid="toast-success"]',
  toastError: '[data-testid="toast-error"]'
};
```

---

## 7. 区域管理

### 路由
`/organization/regions`

### 权限要求
`region:read` (查看)  
`region:create` (创建)  
`region:update` (编辑)  
`region:delete` (删除)

---

### 7.1 区域列表页面

#### 场景 1: 页面加载与显示

**测试步骤**:
1. 以管理员身份登录
2. 导航到区域管理页面
3. 等待页面加载完成

**预期结果**:
- ✅ 页面标题显示"区域管理"
- ✅ 显示搜索框
- ✅ 显示"新建区域"按钮（有权限时）
- ✅ 显示区域表格
- ✅ 表格包含：区域代码、区域名称、时区、货币、语言、关联组织数、操作列

**选择器**:
```typescript
'[data-testid="page-title"]'           // 页面标题
'input[data-testid="region-search-input"]' // 搜索框
'button[data-action="create-region"]'  // 新建按钮
'table[data-testid="region-table"]'    // 区域表格
'[data-testid="region-row"]'           // 区域行
```

---

#### 场景 2: 搜索区域

**测试步骤**:
1. 在搜索框输入区域代码或名称（如"CN"或"中国"）
2. 观察表格更新

**预期结果**:
- ✅ 表格只显示匹配的区域
- ✅ 搜索框显示清除按钮
- ✅ 未匹配时显示"未找到匹配的区域"

**选择器**:
```typescript
'input[data-testid="region-search-input"]'
'button[data-action="clear-search"]'
'[data-testid="empty-search"]'
```

---

#### 场景 3: 清除搜索

**测试步骤**:
1. 搜索区域后
2. 点击清除按钮

**预期结果**:
- ✅ 搜索框内容清空
- ✅ 显示所有区域
- ✅ 清除按钮消失

---

#### 场景 4: 空状态

**测试步骤**:
1. 在没有任何区域的新环境中访问页面

**预期结果**:
- ✅ 显示空状态插图
- ✅ 显示提示文字"暂无区域，点击新建创建第一个区域"
- ✅ 显示"新建区域"按钮

**选择器**:
```typescript
'[data-testid="empty-state"]'
'button[data-action="create-first-region"]'
```

---

### 7.2 新建区域

#### 场景 5: 打开新建区域对话框

**测试步骤**:
1. 点击"新建区域"按钮

**预期结果**:
- ✅ 打开新建区域对话框
- ✅ 对话框标题为"新建区域"
- ✅ 显示所有必填字段：区域代码、区域名称、时区、货币、语言

**选择器**:
```typescript
'button[data-action="create-region"]'
'[data-testid="region-form-dialog"]'
'input[name="code"]'
'input[name="name"]'
'select[name="timezone"]'
'select[name="currency"]'
'select[name="language"]'
```

---

#### 场景 6: 创建区域 - 成功

**测试步骤**:
1. 打开新建区域对话框
2. 填写表单：
   - 区域代码：CN
   - 区域名称：中国
   - 时区：Asia/Shanghai
   - 货币：CNY
   - 语言：zh-CN
3. 点击"确认创建"

**预期结果**:
- ✅ 显示成功提示"区域创建成功"
- ✅ 对话框关闭
- ✅ 新区域出现在列表中
- ✅ 表格显示正确的区域信息

**选择器**:
```typescript
'button[data-action="submit"]'
'text=/区域创建成功|创建成功/i'
```

---

#### 场景 7: 创建区域 - 必填验证

**测试步骤**:
1. 打开新建区域对话框
2. 不填写任何字段，直接提交

**预期结果**:
- ✅ 显示错误提示"请输入区域代码"
- ✅ 显示错误提示"请输入区域名称"
- ✅ 显示错误提示"请选择时区"
- ✅ 对话框保持打开
- ✅ 不创建任何记录

**选择器**:
```typescript
'text=/请输入区域代码|区域代码不能为空/i'
'text=/请输入区域名称|区域名称不能为空/i'
```

---

#### 场景 8: 创建区域 - 代码唯一性验证

**测试步骤**:
1. 尝试创建与已有区域代码相同的区域

**预期结果**:
- ✅ 显示错误提示"区域代码已存在"
- ✅ 不创建新记录

**选择器**:
```typescript
'text=/区域代码已存在|代码重复/i'
```

---

#### 场景 9: 创建区域 - 代码格式验证

**测试步骤**:
1. 输入无效的区域代码（如小写字母、特殊字符）

**预期结果**:
- ✅ 显示错误提示"区域代码格式不正确"
- ✅ 提示使用标准国家/地区代码（2-3位大写字母）

**选择器**:
```typescript
'text=/区域代码格式不正确|请使用标准代码/i'
```

---

### 7.3 编辑区域

#### 场景 10: 打开编辑区域对话框

**测试步骤**:
1. 在区域列表中找到一个区域
2. 点击"编辑"按钮

**预期结果**:
- ✅ 打开编辑区域对话框
- ✅ 对话框标题为"编辑区域"
- ✅ 表单已填充当前区域的数据
- ✅ 区域代码字段禁用（不可修改）

**选择器**:
```typescript
'button[data-action="edit-region"]'
'[data-testid="region-form-dialog"]'
'input[name="code"]:disabled'
```

---

#### 场景 11: 修改区域信息 - 成功

**测试步骤**:
1. 打开编辑对话框
2. 修改区域名称
3. 修改时区
4. 点击"保存更改"

**预期结果**:
- ✅ 显示成功提示"区域更新成功"
- ✅ 对话框关闭
- ✅ 列表中显示更新后的信息

**选择器**:
```typescript
'button[data-action="submit"]'
'text=/区域更新成功|更新成功/i'
```

---

#### 场景 12: 取消编辑

**测试步骤**:
1. 打开编辑对话框
2. 修改部分字段
3. 点击"取消"按钮

**预期结果**:
- ✅ 对话框关闭
- ✅ 数据未保存
- ✅ 列表中仍显示原始数据

**选择器**:
```typescript
'button[data-action="cancel"]'
```

---

### 7.4 删除区域

#### 场景 13: 打开删除确认对话框

**测试步骤**:
1. 点击某个区域的"删除"按钮

**预期结果**:
- ✅ 显示确认对话框
- ✅ 对话框标题为"删除确认"
- ✅ 显示警告信息"确定要删除该区域吗？此操作不可恢复"
- ✅ 显示区域名称
- ✅ 显示"取消"和"确定删除"按钮

**选择器**:
```typescript
'button[data-action="delete-region"]'
'[data-testid="confirm-dialog"]'
'text=/确定要删除|此操作不可恢复/i'
```

---

#### 场景 14: 删除区域 - 成功

**测试步骤**:
1. 选择一个没有关联组织的区域
2. 点击"删除"
3. 在确认对话框中点击"确定删除"

**预期结果**:
- ✅ 显示成功提示"区域删除成功"
- ✅ 对话框关闭
- ✅ 该区域从列表中消失

**选择器**:
```typescript
'button[data-action="confirm"]'
'text=/区域删除成功|删除成功/i'
```

---

#### 场景 15: 删除区域 - 有关联组织阻止删除

**测试步骤**:
1. 选择一个有关联组织的区域
2. 点击"删除"
3. 在确认对话框中点击"确定删除"

**预期结果**:
- ✅ 显示错误提示"该区域下还有组织，无法删除"
- ✅ 区域仍然保留在列表中

**选择器**:
```typescript
'text=/该区域下还有组织|无法删除|请先移除关联/i'
```

---

#### 场景 16: 取消删除

**测试步骤**:
1. 点击"删除"按钮
2. 在确认对话框中点击"取消"

**预期结果**:
- ✅ 对话框关闭
- ✅ 区域未被删除
- ✅ 列表保持不变

**选择器**:
```typescript
'button[data-action="cancel"]'
```

---

### 7.5 查看区域详情

#### 场景 17: 查看关联组织数

**测试步骤**:
1. 观察区域列表中的"关联组织数"列

**预期结果**:
- ✅ 显示该区域关联的组织数量
- ✅ 数量为数字格式
- ✅ 没有关联时显示"0"

**选择器**:
```typescript
'[data-column="organizationCount"]'
```

---

#### 场景 18: 点击关联组织数跳转

**测试步骤**:
1. 点击关联组织数（如果大于0）

**预期结果**:
- ✅ 跳转到组织列表页
- ✅ 自动筛选该区域的组织

**选择器**:
```typescript
'[data-column="organizationCount"] button, [data-column="organizationCount"] a'
```

---

### 7.6 排序功能

#### 场景 19: 按区域代码排序

**测试步骤**:
1. 点击"区域代码"列标题

**预期结果**:
- ✅ 表格按区域代码升序排列
- ✅ 再次点击切换为降序
- ✅ 列标题显示排序图标

**选择器**:
```typescript
'th[data-column="code"]'
'[data-testid="sort-icon"]'
```

---

#### 场景 20: 按关联组织数排序

**测试步骤**:
1. 点击"关联组织数"列标题

**预期结果**:
- ✅ 表格按组织数升序排列
- ✅ 再次点击切换为降序

---

### 7.7 权限控制

#### 场景 21: 无创建权限

**测试步骤**:
1. 以无`region:create`权限的用户登录
2. 访问区域管理页面

**预期结果**:
- ✅ 不显示"新建区域"按钮
- ✅ 可以查看区域列表

---

#### 场景 22: 无编辑权限

**测试步骤**:
1. 以无`region:update`权限的用户登录
2. 查看区域列表

**预期结果**:
- ✅ 不显示"编辑"按钮
- ✅ 可以查看区域信息

---

#### 场景 23: 无删除权限

**测试步骤**:
1. 以无`region:delete`权限的用户登录
2. 查看区域列表

**预期结果**:
- ✅ 不显示"删除"按钮

---

### 7.8 数据验证

#### 场景 24: 时区选择

**测试步骤**:
1. 打开新建/编辑对话框
2. 点击时区下拉框

**预期结果**:
- ✅ 显示标准时区列表
- ✅ 包含常用时区（Asia/Shanghai、America/New_York、Europe/London等）
- ✅ 时区显示为标准IANA格式

**选择器**:
```typescript
'select[name="timezone"]'
'select[name="timezone"] option'
```

---

#### 场景 25: 货币选择

**测试步骤**:
1. 打开新建/编辑对话框
2. 点击货币下拉框

**预期结果**:
- ✅ 显示标准货币列表
- ✅ 包含常用货币（CNY、USD、EUR、GBP、AED等）
- ✅ 货币显示为ISO 4217代码

**选择器**:
```typescript
'select[name="currency"]'
'select[name="currency"] option'
```

---

#### 场景 26: 语言选择

**测试步骤**:
1. 打开新建/编辑对话框
2. 点击语言下拉框

**预期结果**:
- ✅ 显示支持的语言列表
- ✅ 包含常用语言（zh-CN、en-US、ar-AE等）
- ✅ 语言显示为BCP 47格式

**选择器**:
```typescript
'select[name="language"]'
'select[name="language"] option'
```

---

### 7.9 选择器索引表

**区域列表页**
```typescript
const REGION_SELECTORS = {
  // 页面元素
  pageTitle: '[data-testid="page-title"]',
  searchInput: 'input[data-testid="region-search-input"]',
  clearSearch: 'button[data-action="clear-search"]',
  
  // 操作按钮
  createButton: 'button[data-action="create-region"]',
  createFirstButton: 'button[data-action="create-first-region"]',
  
  // 表格
  table: 'table[data-testid="region-table"]',
  tableRow: '[data-testid="region-row"]',
  columnCode: '[data-column="code"]',
  columnName: '[data-column="name"]',
  columnTimezone: '[data-column="timezone"]',
  columnCurrency: '[data-column="currency"]',
  columnLanguage: '[data-column="language"]',
  columnOrgCount: '[data-column="organizationCount"]',
  
  // 行操作
  editButton: 'button[data-action="edit-region"]',
  deleteButton: 'button[data-action="delete-region"]',
  
  // 对话框
  formDialog: '[data-testid="region-form-dialog"]',
  confirmDialog: '[data-testid="confirm-dialog"]',
  
  // 表单字段
  codeInput: 'input[name="code"]',
  nameInput: 'input[name="name"]',
  timezoneSelect: 'select[name="timezone"]',
  currencySelect: 'select[name="currency"]',
  languageSelect: 'select[name="language"]',
  descriptionTextarea: 'textarea[name="description"]',
  
  // 按钮
  submitButton: 'button[data-action="submit"]',
  cancelButton: 'button[data-action="cancel"]',
  confirmButton: 'button[data-action="confirm"]',
  
  // 状态
  emptyState: '[data-testid="empty-state"]',
  emptySearch: '[data-testid="empty-search"]',
  loadingState: '[data-testid="page-loading"]',
  
  // Toast提示
  toast: '[data-testid="toast"]',
  toastSuccess: 'text=/区域创建成功|区域更新成功|区域删除成功|创建成功|更新成功|删除成功/i',
  toastError: 'text=/区域代码已存在|该区域下还有组织|无法删除/i'
};
```

---

## 8. 登录页 Entra ID SSO 流程（v2.4 新增 ⭐）

> **范围**：覆盖登录页「用 Microsoft 登录」按钮 + OIDC 跳转 + 回跳 + 密码通道并存 + 用户详情页 SSO 信息块。
>
> **关联工单**：#334；**关联文档**：`01-prd.md` v2.4、`05-ui-interaction-spec.md` v2.4、`07-api.md` v2.4、`08-error-codes.md` v2.4。

### 路由

- `/login` —— 登录页
- `/auth/sso/callback`（前端）→ 后端 `GET /api/v1/auth/sso/callback`

### 权限要求

- 登录页：匿名可访问
- 用户详情页 SSO 信息块：`user:read`（普通员工角色看不到 SSO 段，仅 itadmin / HR 可见）

### 前置条件

- L1 集成测试场景 5.3.x 全部通过
- Entra app registration 已配置完成（dev 环境拿到 `client_id` / `client_secret` / `tenant_id`）
- 后端 env 已配置：
  - `AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET`
  - `AZURE_REDIRECT_URI`（指向后端 callback）
  - `SSO_ALLOWED_DOMAINS=ff.com,...`
- 前端构建已注入 Microsoft 登录按钮文案 i18n key（`auth.signInWithMicrosoft` / `auth.signInWithPassword`）

### 选择器约定

> MCP 用例**统一通过 accessibility tree (role/name) 定位**，遵循 CLAUDE.md「MCP 使用 accessibility tree 定位元素」要求，不依赖 data-testid。

| 元素 | role | accessible name (`zh` / `en` locale) |
|---|---|---|
| Microsoft 登录主按钮 | `button` | `用微软账号登录` / `Sign in with Microsoft` |
| 密码登录次按钮 | `button` | `用密码登录` / `Sign in with password` |
| 用户名输入框 | `textbox` | `用户名` / `Username` |
| 密码输入框 | `textbox` | `密码` / `Password` |
| 提交登录 | `button` | `登录` / `Sign in` |
| 顶栏用户头像 | `button` | `当前用户` / `Current user` |
| 用户详情 SSO 信息块标题 | `heading` | `SSO 绑定信息` / `SSO Binding` |
| externalSource badge | `text` | `entra` |

---

### 8.1 登录页双按钮可见性

#### 场景 1: 登录页双按钮可见 - zh

**测试步骤**:

1. 设置 locale = `zh`
2. MCP `browser_navigate` 到 `/login`
3. MCP `browser_snapshot` 抓 accessibility tree

**预期结果**:

- ✅ 存在 `button[name="用微软账号登录"]`（主按钮，visible）
- ✅ 存在 `button[name="用密码登录"]`（次按钮，visible，默认 collapsed 状态）
- ✅ 主按钮视觉位置在密码按钮上方（snapshot 顺序）
- ✅ 用户名/密码输入框默认**不**可见（次按钮未展开前）

---

#### 场景 2: 登录页双按钮可见 - en

**测试步骤**:

1. 切 locale = `en`（通过右上角语言切换或 `?lang=en` 参数）
2. MCP `browser_navigate` 到 `/login`
3. MCP `browser_snapshot`

**预期结果**:

- ✅ 存在 `button[name="Sign in with Microsoft"]`
- ✅ 存在 `button[name="Sign in with password"]`
- ✅ 主/次按钮顺序与 zh 一致

---

### 8.2 SSO 跳转链路

#### 场景 3: SSO 跳转 - 已登录 Microsoft（含 fragment 注入 + history.replaceState 清 hash 验证）

**前提**：浏览器 session 中 Microsoft 账号已登录（同一 Edge / Chrome profile 此前登过任意 M365 服务）。

**测试步骤**:

1. MCP `browser_navigate` 到 `/login`
2. MCP `browser_click` `button[name="用微软账号登录"]`
3. MCP `browser_wait_for` URL 包含 `login.microsoftonline.com`
4. MCP `browser_wait_for` URL 回到本系统域名（已登 Microsoft 应秒回）
5. **拦截一刻**：在 `/sso/landing` 落地时 MCP `browser_evaluate` 抓 `window.location.hash`（注意：landing 路由 `useEffect` 跑得很快，可能需要在 landing 出现前先 `browser_wait_for` 该 URL）
6. MCP `browser_wait_for` URL 跳到业务页（默认 `/overview`）
7. MCP `browser_evaluate` 检查 `window.location.hash === ''` 且 `localStorage.getItem('accessToken')` 非空
8. MCP `browser_snapshot` 验证业务页元素

**预期结果**:

- ✅ 浏览器先跳到 `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize?...code_challenge=...&code_challenge_method=S256`（PKCE 必须）
- ✅ 已登 Microsoft 时跳过用户密码输入，秒回 `/api/v1/auth/sso/callback?code=...&state=...`
- ✅ 后端 callback 302 到 `${sso_redirect}#accessToken=...&refreshToken=...`（URL fragment 注入；默认 `sso_redirect='/overview'`）
- ✅ 前端 `/sso/landing` 路由读 hash 完成后立即 `history.replaceState('','', '/overview')`：**最终 URL 不含 hash**（`window.location.hash === ''`）
- ✅ localStorage `accessToken` / `refreshToken` 已写入
- ✅ 顶栏出现 `button[name="当前用户"]`（已登录态）
- ✅ 4 个 `sso_*` cookie 已被清除（`Max-Age=0`）

---

#### 场景 4: SSO 跳转 - 未登录 Microsoft（手工配合）

**前提**：incognito / 隐身窗口 或清除 Microsoft cookie。

**测试步骤**:

1. 打开 incognito 窗口
2. MCP `browser_navigate` 到 `/login`
3. MCP `browser_click` `button[name="用微软账号登录"]`
4. **手工配合**：在 Microsoft 登录页输入 dev tenant 的测试账号、密码、MFA（MCP 不模拟 Microsoft 端表单）
5. MCP `browser_wait_for` URL 回到本系统域

**预期结果**:

- ✅ 完整看到 Microsoft 登录 → MFA → 同意页（如有）→ 回跳本系统
- ✅ 最终落到本系统首页，顶栏已登录态

---

### 8.3 密码通道并存

#### 场景 5: 密码登录回退仍可用

**测试步骤**:

1. MCP `browser_navigate` 到 `/login`
2. MCP `browser_click` `button[name="用密码登录"]`
3. MCP `browser_snapshot` 确认 `textbox[name="用户名"]` 与 `textbox[name="密码"]` 已展开
4. MCP `browser_type` 用户名 `itadmin`、密码 `Admin@2024`
5. MCP `browser_click` `button[name="登录"]`

**预期结果**:

- ✅ 点「用密码登录」后表单展开，用户名/密码输入框可见
- ✅ 提交后进入系统首页
- ✅ 验证密码通道不受 SSO 改动影响（双通道并存）

---

### 8.4 错误路径 i18n 双语文案

#### 场景 6: 全 8 个 SSO 错误码双语文案（覆盖核心 5 个 + Entra error query 映射 3 个）

**测试步骤**：

针对 8 个错误码逐一发起场景：

| 错误码 | 触发方式 |
|---|---|
| `SSO_DOMAIN_NOT_ALLOWED` | mock Entra 返回域名不在白名单的 email |
| `SSO_TOKEN_INVALID` | mock Entra 返回签名错误的 id_token |
| `SSO_EMAIL_MISSING` | mock Entra 返回 id_token 不含 email claim |
| `SSO_BINDING_CONFLICT` | 预置 DB User `externalSource='entra'` + 不同 oid，mock Entra 返新 oid |
| `SSO_PROVIDER_UNAVAILABLE` | 断 Entra token endpoint 网络 |
| `SSO_USER_CANCELLED` | 直接 `browser_navigate` 到 `/api/v1/auth/sso/callback?error=access_denied` |
| `SSO_CONSENT_REQUIRED` | `?error=consent_required` |
| `SSO_PROVIDER_REJECTED` | `?error=invalid_request` |

每个错误码在 `zh` 与 `en` 两套 locale 下分别 navigate → 触发 → wait_for toast 出现 → snapshot 验证文案。

**预期结果**：

- ✅ 全 8 个错误码均能命中、能 302 至 `/login?ssoError=<CODE>`
- ✅ 登录页 `useEffect` 读到 `?ssoError=<CODE>` 后展示 toast，并立即 `history.replaceState` 清掉 query（验证 `window.location.search === ''`）
- ✅ **zh 文案**（与 05-ui-interaction-spec 一致，作为真相源）：
  - `SSO_DOMAIN_NOT_ALLOWED`: "你的邮箱域名暂未授权使用 SSO 登录，请联系 IT 或改用密码登录"
  - `SSO_TOKEN_INVALID`: "SSO 登录校验失败，请重试"
  - `SSO_EMAIL_MISSING`: "你的微软账号未配置邮箱，请联系 IT"
  - `SSO_BINDING_CONFLICT`: "SSO 账号绑定冲突，请联系 itadmin"
  - `SSO_PROVIDER_UNAVAILABLE`: "Microsoft 登录暂时不可用，请改用密码登录"
  - `SSO_USER_CANCELLED`: "你已取消 Microsoft 登录"
  - `SSO_CONSENT_REQUIRED`: "Microsoft 要求重新授权，请重试登录"
  - `SSO_PROVIDER_REJECTED`: "Microsoft 登录被拒绝，请联系 IT"
- ✅ **en 文案**：
  - `SSO_DOMAIN_NOT_ALLOWED`: "Your email domain is not authorized for SSO sign-in. Contact IT or use password sign-in."
  - `SSO_TOKEN_INVALID`: "SSO verification failed. Please try again."
  - `SSO_EMAIL_MISSING`: "Your Microsoft account is missing an email. Contact IT."
  - `SSO_BINDING_CONFLICT`: "SSO binding conflict. Contact itadmin."
  - `SSO_PROVIDER_UNAVAILABLE`: "Microsoft sign-in unavailable. Please use password sign-in."
  - `SSO_USER_CANCELLED`: "You cancelled Microsoft sign-in."
  - `SSO_CONSENT_REQUIRED`: "Microsoft requires re-consent. Please sign in again."
  - `SSO_PROVIDER_REJECTED`: "Microsoft sign-in was rejected. Contact IT."
- ✅ 错误信息内联在 toast，**不**裸露后端错误码字符串
- ✅ 密码登录按钮仍可点（fallback 通道始终可用）

---

#### 场景 6b: 登录页 ssoError query 处理（history.replaceState 清 query）

**测试步骤**：

1. MCP `browser_navigate` 直接到 `/login?ssoError=SSO_TOKEN_INVALID`（模拟后端 302 回跳）
2. MCP `browser_wait_for` toast 出现
3. MCP `browser_evaluate` 取 `window.location.search`

**预期结果**：

- ✅ Toast 出现并展示对应错误码文案
- ✅ `window.location.search === ''`（或保留 `?redirect=...`，**不**保留 `ssoError`）
- ✅ 浏览器历史 URL 已被 replaceState 改写，刷新页面不会重复触发 toast

---

### 8.5 用户详情页 SSO 信息块可见性

#### 场景 7: itadmin 看到 SSO 绑定信息段

**前提**：场景 3 已经让一个测试用户经 SSO 登录过，DB 已回填 `externalId` / `externalSource='entra'`，并已在 `platform_audit.audit_log` 表写入 `SSO_LOGIN_SUCCESS` 记录（`lastSsoLoginAt` **不是** User 字段，详情页通过 AuditLog 反查 `userId=用户ID AND action='SSO_LOGIN_SUCCESS' ORDER BY when DESC LIMIT 1` 取最近一条 `when`）。

**测试步骤**:

1. itadmin 登录系统
2. MCP `browser_navigate` 到 `/organization/members`
3. MCP `browser_click` 找到刚才那位 SSO 登录过的用户行（按 name accessibility）
4. MCP `browser_snapshot` 抓用户详情页

**预期结果**:

- ✅ 详情页存在 `heading[name="SSO 绑定信息"]`（zh）/ `heading[name="SSO Binding"]`（en）
- ✅ 段内可见：`externalId` 值（脱敏或完整，按 05-ui-interaction-spec 规定）
- ✅ 段内可见 `externalSource = 'entra'` 的 badge
- ✅ 段内可见「最近 SSO 登录时间」（从 AuditLog 反查 `SSO_LOGIN_SUCCESS` 最近一条 `when`，格式化为 locale 友好的时间）
- ✅ 切到一位**没**经 SSO 登过的用户，该段不显示（或显示「未绑定」占位）

---

#### 场景 8: 普通员工角色看不到 SSO 段

**测试步骤**:

1. 用普通 Employee 角色账号登录（仅 `user:read:own`）
2. MCP `browser_navigate` 到自己的详情页 `/organization/members/me`
3. MCP `browser_snapshot`

**预期结果**:

- ✅ 不存在 `heading[name="SSO 绑定信息"]` / `heading[name="SSO Binding"]`
- ✅ 不泄露其他用户的 externalId

---

### 8.6 i18n 完整性

#### 场景 9: 控制台无 i18n missing key warning

**测试步骤**:

1. 启用 MCP `browser_console_messages` 监听
2. 在 zh 与 en 两套 locale 下，依次跑场景 1 / 2 / 5 / 7（覆盖登录页 + 详情页 SSO 段）
3. 每次跑完读取 console messages

**预期结果**:

- ✅ DevTools console 无 `missing translation key` 类警告
- ✅ 无 i18next 缺 key 的 fallback 提示
- ✅ 所有 SSO 相关文案在两套 locale 下均完整翻译，无裸露的 key 字符串（如 `auth.signInWithMicrosoft` 直接显示）

---

### 8.7 不在 E2E 范围（明确写）

> 以下场景**本期不做** E2E 验证，避免误以为"漏测"。

- ❌ **Entra 端账号禁用后的立即失效**：本期接受 ≤ 24h 同步窗口（PRD v2.4 决策），二期 SCIM 工单解决
- ❌ **`amr` / `acr` claim 校验**：本期完全相信 Entra 策略，本系统不校验 MFA 等级
- ❌ **强制下线 / 强制解绑**：后台运维本期仅做查看，不做强制操作
- ❌ **Microsoft 登录页的 UI 细节**：跳到 Microsoft 之后的所有交互（密码框 / MFA / 同意页）属 Microsoft 域，不属于本系统 E2E 范围

---

## 🧪 测试数据准备

### 测试账号
```typescript
const TEST_ACCOUNTS = {
  admin: {
    username: 'itadmin',
    password: 'Admin@2024',
    permissions: ['*']
  },
  hr: {
    username: 'hr_manager',
    password: 'HR@2024',
    permissions: ['user', 'department:read']
  },
  employee: {
    username: 'employee',
    password: 'Employee@2024',
    permissions: ['user:read:own']
  }
};
```

### 测试用户数据
```typescript
const TEST_USERS = {
  local: {
    username: 'test_local_user',
    displayName: 'Local Test User',
    email: 'local@test.ff.com',
    source: 'LOCAL',
    password: 'TestPass123!'
  },
  ldap: {
    username: 'test_ldap_user',
    displayName: 'LDAP Test User',
    email: 'ldap@test.ff.com',
    source: 'LDAP',
    ldapUsername: 'testuser'
  }
};
```

---

## 📊 测试覆盖率目标

| 模块 | 目标覆盖率 | 场景数量 |
|------|-----------|---------|
| 用户列表页 | 100% | 30+ |
| 用户详情页 | 100% | 20+ |
| 新建/编辑用户 | 100% | 25+ |
| 角色管理 | 100% | 15+ |
| 部门管理 | 100% | 10+ |

**总计**: 100+ 个详细测试场景
