# 运营中心 - UI 交互规范（v1：M365 休眠账号）

> **版本**: v0.1
> **最后更新**: 2026-04-30
> **维护者**: 前端团队

---

## ✅ 机器读取区（必填）

### 通用字段

| 字段 | 内容 |
|------|------|
| 模块 | 运营中心 / M365 休眠账号 |
| 文档类型 | UI 交互规范 |
| 路由 | `/ops-center/m365-dormant` |
| 一级菜单 | 运营中心（zh-CN）/ Ops Center（en-US） |
| 二级菜单 | M365 休眠账号 / M365 Dormant Accounts |
| 设计系统 | 项目 Lark 设计系统（与现有模块一致） |
| 关联文档 | `01-prd.md` / `07-api.md` |

### 页面清单

| 路径 | 页面 | 权限 |
|------|------|------|
| `/ops-center/m365-dormant` | M365 休眠账号列表页 | `m365-dormant:read` |

v1 仅一个页面（列表 + 行内详情抽屉）。

---

## 🧭 人类阅读区

### 页面：M365 休眠账号

#### 整体布局

```
+----------------------------------------------------------------+
| 面包屑：运营中心 / M365 休眠账号                                |
+----------------------------------------------------------------+
| 顶部状态条                                                      |
|  上次同步：2026-04-30 11:02 · 成功 · 共 4321 人 · 休眠 312      |
|  [立即同步] （右侧按钮）                                        |
+----------------------------------------------------------------+
| 筛选条                                                          |
|  休眠天数 ≥ [180] 天 · 关键字 [____] · 启用 [全部▾] · License [全部▾] |
|  [导出 CSV] （右侧按钮）                                        |
+----------------------------------------------------------------+
| 列表表格（默认按 daysInactive desc）                            |
|  邮箱 | 显示名 | 部门 | 启用 | License | 最后登录 | 最后活动 | 休眠天数 |
|  ...                                                            |
+----------------------------------------------------------------+
| 分页                                                            |
+----------------------------------------------------------------+
```

#### 顶部状态条

- 数据来自 `GET /m365-dormant/sync/latest`。
- 状态色：
  - `success` → 绿色 dot + "成功"
  - `running` → 蓝色 dot + "同步中…" + 旋转 icon
  - `failed` → 红色 dot + "失败" + Tooltip 显示 `errorMessage`
  - `null`（从未同步）→ 灰色 + "尚未同步，请点击右侧按钮触发"
- "立即同步"按钮：
  - 默认态：可点。
  - 点击 → 调 `POST /m365-dormant/sync` → 成功 → 顶部条切换为 `running` 态、按钮 disable。
  - 收到 `409 SYNC_IN_PROGRESS` → 顶部条同样切到 `running`，按钮 disable。
  - 收到 `400 AZURE_CREDENTIAL_MISSING` → Toast：`请联系运维在 .env 中配置 AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET`。
  - 收到 `502 GRAPH_INSUFFICIENT_SCOPE` → Toast：`Azure App Registration 缺少权限（AuditLog.Read.All 或 Reports.Read.All），请联系 IT 在 Azure Portal 补齐并 admin consent`。
- 同步中：每 10s 轮询 `latest`，状态切到 success/failed 后停止轮询并刷新列表。
- **数据获取技术**：项目使用 `@tanstack/react-query` + `axios`。
  - 列表 / 状态条 / 历史趋势用 `useQuery`（`queryKey: ['m365-dormant', ...]`）
  - `latest` 轮询：`useQuery({ queryKey: ['m365-dormant', 'latest'], refetchInterval: status === 'running' ? 10_000 : false })`，仅 running 时启用轮询
  - 触发同步用 `useMutation`，`onSuccess` 调 `queryClient.invalidateQueries({ queryKey: ['m365-dormant'] })`
  - CSV 导出走 `axios.get(..., { responseType: 'blob' })` 触发浏览器下载，不通过 react-query 缓存

#### 同步进行中的列表行为

- 列表**继续展示上一次 `success` 批次的快照**（来源 `M365User` 当前态，但当前态可能正在被部分写入）。
- 列表上方加 banner：`正在同步新数据，已运行 X 秒。当前展示截至上次同步（{snapshotAt}）的数据。`
- 不锁屏不阻塞操作（用户可继续筛选 / 排序 / 导出）。
- 同步完成后自动刷新一次列表。
- 失败时 banner 切换为 `本次同步失败：{errorCode}。当前展示上次成功同步（{snapshotAt}）的数据。`

#### 筛选条

| 控件 | 默认 | 说明 |
|------|------|------|
| 休眠天数 ≥ | 180 | 数字输入，单位"天"；改值 → 防抖 400ms → 重新拉列表 |
| 关键字 | 空 | 邮箱 / 显示名 ILIKE；防抖 400ms |
| 启用 | 全部 | 全部 / 启用 / 禁用 |
| License | 全部 | 全部 / 有 / 无 |

筛选改变 → URL query 同步更新（可分享链接、可刷新还原）。

#### 列表

| 列 | 说明 | 排序 |
|----|------|------|
| 邮箱 (UPN) | `userPrincipalName` | ✅ |
| 显示名 | `displayName` | - |
| 部门 | `department` | - |
| 启用 | `accountEnabled` 转 ✓/✗ | - |
| License | 紧凑展示前 2 个 SKU 简称 + 数量后缀（如 `E3 · EMS · +3`），无 license 显示灰色"无"；hover 弹 Tooltip 显示完整 `displayName` 列表（每行一个） | - |
| 最后登录 | `lastSignInDateTime`，null 显示 "—" | - |
| 最后活动 | `lastAnyActivity`，null 显示 "无记录" + Tooltip "可能 license 不足或从未登录" | ✅ |
| 休眠天数 | `daysInactive`，null 显示 "—"；≥180 标红 | ✅（默认 desc） |

行点击 → 右侧抽屉，分两个 Tab：

- **概览**：完整 6 个时间字段 + Graph metadata（部门、岗位、`firstSeenAt`、`missingFromLatestSync` 标签）+ **License 列表**（每个 SKU 一个徽章，展示 `displayName`，hover 显示 `skuPartNumber` 和 `skuId`）。
  - 6 个时间字段分为两组展示：
    - **登录活动**（接近实时，≤ 1 小时延迟）：`lastSignInDateTime`、`lastNonInteractiveSignInDateTime`
    - **服务活动**（Microsoft 离线统计，**24-48 小时延迟**）：`lastEmailActivity`、`lastOneDriveActivity`、`lastTeamsActivity`、`lastSharePointActivity`
  - 服务活动一组上方加一行小字提示："Microsoft 服务活动报告存在 24-48 小时延迟"。
- **活动时间线**：调 `GET /m365-dormant/users/:userId/timeline`，按时间倒序展示该用户的字段变更事件（"2026-04-30: lastEmailActivity 从 2025-09-15 → 2025-10-01"），让 IT 看清这个账号何时开始休眠 / 是否有过短暂复活。可按字段类型筛选。

空列表：
- 有 batch 但筛选无结果 → "当前条件下没有休眠账号"。
- 无 batch（NO_SUCCESSFUL_SYNC）→ "尚未同步过 M365 数据，点击右上角'立即同步'开始"。

#### UI 状态规格（5 类）

每个区块（顶部状态条 / 趋势 / 列表 / 抽屉）都需明确 5 类状态，L2 E2E 必须 cover：

| 状态 | 列表 | 趋势 | 详情抽屉 |
|------|------|------|---------|
| Loading | Skeleton 行 × 5 | 折线图区域骨架占位 | Tab 内骨架文字行 |
| Success | 数据展示 | 折线 | 数据展示 |
| Empty | "当前条件下没有休眠账号" / "尚未同步" 引导 | "尚无历史同步记录" + 灰色虚线占位 | "该用户暂无活动变更事件" |
| Error (列表/趋势接口 5xx/4xx) | 红色 banner "加载失败：{错误码}" + Retry 按钮 | 同左 | 同左 |
| Partial (running) | 上次 success 数据 + 黄色 banner "正在同步…" | 历史数据正常 + 当前批次未入图 | 正常展示（不阻塞） |

CSV 导出失败：Toast `导出失败：{errorCode}（{errorMessage}）`。

#### 导出 CSV

- 调 `GET /m365-dormant/users?...&format=csv`，使用当前筛选参数。
- 浏览器直接下载，文件名 `m365-dormant-{YYYYMMDD}.csv`。
- 进行中显示按钮 loading；完成后恢复。

### 交互细节

- 同步按钮在 `running` 态显示倒计时（已运行 X 秒），让用户知道还没卡死。
- 同步完成后弹出 Toast："同步完成，共 4321 人，休眠 312 人（按 180 天阈值）"。
- 同步失败 Toast：`同步失败：{errorCode} {errorMessage}`，并保留按钮可点重试。

### i18n

- 所有可见文案、占位符、Toast 必须双语（zh-CN / en-US）。
- 列表日期使用 `Intl.DateTimeFormat` 按当前 locale 渲染。
- "天数"使用 `Intl.NumberFormat`。
- 不允许硬编码中文字符串。
- **i18n key 命名约定**：嵌套结构，前缀 `opsCenter.m365Dormant.*`。例：
  - `opsCenter.m365Dormant.title`
  - `opsCenter.m365Dormant.list.column.email`
  - `opsCenter.m365Dormant.list.column.licenseColumn`
  - `opsCenter.m365Dormant.sync.button`
  - `opsCenter.m365Dormant.sync.toast.success`
  - `opsCenter.m365Dormant.error.AZURE_CREDENTIAL_MISSING`
  - 错误码 i18n key 与后端 errorCode 一一对应（直接用 errorCode 作为 key 段），便于映射。
  - 如项目现有 i18n 文件用扁平 key（如 `m365_dormant_title`），按现有结构对齐。

### URL 状态持久化范围

- 进 URL（用 `useSearchParams` 同步）：`inactiveDays` / `keyword` / `accountEnabled` / `hasLicense` / `sortBy` / `sortOrder` / `page` / `pageSize` / 抽屉打开的 `userId`
- 不进 URL（页面级临时状态）：趋势区块阈值 / 趋势区块展开折叠 / 抽屉 Tab
- 刷新页面 / 分享链接应能完整还原列表筛选 + 排序 + 分页 + 当前打开的抽屉

### 无障碍

- 表格使用语义化 `<table>` + `scope="col"`。
- 同步按钮 `aria-busy="true"` 在 running 态。
- 状态色都有文字对应（不只靠颜色区分）。

### 趋势图（页面顶部可折叠区块）

页面顶部"上次同步状态条"下方加一个**可折叠**的趋势区块（默认折叠，避免抢列表注意力）：

```
+----------------------------------------------------------------+
| ▾ 休眠趋势  阈值 [滑块: 30 -- 180 -- 365 天]   ☐ 包含从未登录   |
|                                                                |
|   📈 折线图：横轴=同步时间，纵轴=休眠用户数（按当前阈值）        |
|                                                                |
+----------------------------------------------------------------+
```

- 数据源：`GET /m365-dormant/sync/history?limit=30`，前端用每个 batch 的 `inactiveDistribution` 直方图按用户选定阈值累加。
- 阈值滑块拖动 → 纯前端重算，不发请求。
- **滑块控件设计**：预设档位（30/60/90/180/365）+ 数字输入框双控件并存——拖滑块只能落在预设档位（吻合直方图桶边界，无近似误差）；数字输入框接受任意整数（落在桶中间时按"整桶纳入"近似，输入框右侧显示"近似值"小字）。
- 阈值精度：直方图分桶为 30/60/90/180/365 天。落在桶边界的阈值精确；落在桶中间的阈值（如 120 天）按"整桶纳入"近似，前端 Tooltip 提示"近似值，按 30 天分桶聚合"。
- 失败批次不画点，仅 success 批次入图。
- 趋势区块的阈值与列表筛选的"休眠天数"是**独立的两个值**，因为业务上常见"我看 90 天阈值的趋势，但我现在想筛 180 天的人来处置"——强制联动反而别扭。

### 路由与权限

- 用户无 `m365-dormant:read` 权限：
  - 导航不展示一级菜单"运营中心"（layout 用 `usePermissions().hasPermission('m365-dormant:read')` 过滤）。
  - 直接访问 URL：页面顶部 inline 渲染 `<NoAccess />` 占位组件（与项目其他 module 一致，**不重定向**——项目无 `/403` 页面）。
- 权限点格式 `m365-dormant:read` / `:sync` / `:export`，与 `@RequirePermissions` 装饰器一致。
