# 固定地点签到（Site Attendance）- UI 交互规范

> **module**: site-attendance
> **doc_type**: UISpec
> **status**: Draft
> **owner**: 待定
> **upstream_docs**: `01-prd.md`, `07-api.md`, `08-error-codes.md`
> **last_verified**: 2026-04-17

---

## 页面清单

| 页面 | 路由 | 说明 | 权限 |
|------|------|------|------|
| 签到页 | `/siteattendance/c/:code` | 员工扫码进入的签到/签退页面 | 匿名（公开，SIGNED 模式下需 token/ticket） |
| 签到点管理 | `/siteattendance/admin/checkpoints` | 管理员签到点列表 | Administrator |
| 签到点创建 | `/siteattendance/admin/checkpoints/create` | 创建签到点 | Administrator |
| 签到点编辑 | `/siteattendance/admin/checkpoints/:id/edit` | 编辑签到点（含 v1.5 新增区块） | Administrator |
| 签到点详情 | `/siteattendance/admin/checkpoints/:id` | 签到点详情 + 今日签到概览 + QR 码 + v1.5 访问地址区块 | Administrator |
| **分诊页** | `/siteattendance/shared/:code` | **v1.5** 共享签到入口，选公司后跳转签到页 | 匿名（需 QR token） |
| **大屏页** | `/siteattendance/display/:code` | **v1.5** 全屏展示轮换 QR，整点对齐刷新 | 匿名 |

---

## 页面详情

### 1. 签到页（`/siteattendance/c/:code`）

手机优先的公开页面，员工扫码后进入。浅色干净风格，不走后台管理台样式，无侧边栏。

#### 布局结构

```
┌─────────────────────────────┐
│  [📍 签到点名称 / 描述]  [定位状态] [EN/中文] │  ← 顶栏
│                             │
│         03:14               │  ← 当前时间（大字）
│    Tuesday, March 31        │  ← 当前日期
│                             │
│  ┌─────────────────────────┐│
│  │  今日状态                ││
│  │  签到: 09:02   签退: -- ││  ← 左右分栏
│  └─────────────────────────┘│
│                             │
│  ┌───────────┐ ┌───────────┐│
│  │   签 到   │ │   签 退   ││  ← 已签到时变 disabled + ✓
│  └───────────┘ └───────────┘│
│                             │
│  ┌─────────────────────────┐│
│  │  今日记录               ││  ← 事件列表
│  │  ● 签到  09:02          ││
│  │  ● 签退  17:30          ││
│  └─────────────────────────┘│
└─────────────────────────────┘
```

#### 页面元素

| 元素 | 类型 | 说明 |
|------|------|------|
| 顶栏 | 导航栏 | 签到点名称+地址（左侧截断），定位状态+语言切换（右侧固定宽度） |
| 当前时间 | 大字显示 | 签到点时区下的当前时间和日期（实时更新） |
| 圆形签到/签退按钮 | 主按钮 | 钉钉风格大圆按钮：蓝色=签到、橙色=签退，成功后绿色打勾动画 2 秒 |
| 当前位置 | 文字 | 反向编码后的地址（走后端代理），下方有"重新定位"按钮 |
| 当前用户 | 卡片 | 头像+姓名+邮箱，右侧"退出"按钮 |
| 今日状态卡片 | 信息卡片 | 左右分栏：签到时间 → 箭头 → 签退时间 |
| 今日记录 | 列表 | 当天签到/签退事件，绿点/橙点 + 时间 |

#### 交互流程

| 交互 | 触发 | 行为 |
|------|------|------|
| 页面加载 | 扫码进入 | 1. **v1.5 准入校验分支**：看 URL 是否带 `?ticket=` 或 `?t=`；按分支校验（见下方"准入分支"小节） 2. 校验通过才调用 [006] 获取签到点信息 3. 请求浏览器定位权限（预热 GPS） 4. 如已登录，调用 [008] 获取当天签到状态 |
| 点击签到/签退 | 按钮点击 | **已登录**: 直接调用 [009] 提交 → 显示成功/失败 → 刷新状态卡片 |
| 点击签到/签退（未登录） | 按钮点击 | 弹出底部弹窗，显示两个选项 |
| 选择"登录后继续" | 弹窗按钮 | 跳转登录页，登录成功后返回签到页 |
| 选择"免登录继续" | 弹窗按钮 | 进入用户搜索选择流程（见下方） |
| 签到/签退成功 | API 返回 201 | 按钮显示成功动画 → 状态卡片更新 |
| 定位校验失败 (BLOCK) | API 返回 403 | 显示错误提示："请在签到点附近重试" |
| 定位校验失败 (FLAG) | API 返回 201 | 正常签到成功，状态卡片标记"定位异常" |
| **点击"切换公司"**（v1.5） | 右上角按钮 | 从 URL 读 `dispatchOrigin`，`window.location.href = dispatchOrigin + '?switch=1'` |

#### 准入分支（v1.5 新增）

签到页 `/siteattendance/c/:code` 加载时按以下顺序判定：

```
1. URL 有 ?ticket=xxx
   → 调用 [019] POST /shared-checkin/validate-ticket { ticket, targetCheckpointCode: code }
     通过 → 记录 sessionStorage "siteAttendance.sessionValid.{code}=true"（刷新不再重验）
     失败 → 根据错误码显示提示（见下方"准入错误态"）
   
2. URL 有 ?t=xxx
   → 前端不直接验，交给 [006] /public 统一校验（服务端把 t 作为请求头或 query 接收，校验失败返回 401）
     通过 → 渲染签到页
     失败 → 显示"二维码已过期，请扫描前台最新二维码"
   
3. 都没带 且 sessionStorage 里没 validSession
   → 调用 [006] /public
     若 checkpoint.accessMode=PUBLIC → 正常渲染（兼容现状）
     若 checkpoint.accessMode=SIGNED → [006] 返回 401 QR_TOKEN_MISSING → 显示"请通过前台二维码签到"
   
4. sessionStorage 里有 validSession（刷新场景）
   → 跳过准入校验，直接调 [006] /public 渲染
```

#### 准入错误态

| 错误码 | 文案 | 操作按钮 |
|--------|------|---------|
| QR_TOKEN_EXPIRED | 二维码已过期，请扫描前台最新二维码 | 重试 / 返回首页 |
| QR_TOKEN_INVALID | 二维码无效 | 返回首页 |
| QR_TOKEN_MISSING | 请通过前台二维码签到 | 返回首页 |
| TICKET_EXPIRED | 签到链接已过期，请重新扫码 | 返回分诊页（dispatchOrigin） / 返回首页 |
| TICKET_ALREADY_USED | 签到链接已被使用，请重新扫码 | 返回分诊页 |
| TICKET_TARGET_MISMATCH | 签到链接与当前签到点不匹配 | 返回首页 |
| TICKET_ORIGIN_NOT_ALLOWED | 签到链接来源不可信 | 返回首页 |

错误页使用**现有 site-attendance 签到页的 Lark 设计系统风格**：浅色背景、居中卡片、清晰的标题 + 副标题 + 按钮组。

#### 免登录用户搜索流程

弹出全屏或半屏面板：

```
┌─────────────────────────────┐
│  ← 返回                     │
│                             │
│  选择你的身份               │
│  ┌─────────────────────────┐│
│  │ 🔍 输入姓名/邮箱/工号    ││
│  └─────────────────────────┘│
│                             │
│  搜索结果：                  │
│  ┌─────────────────────────┐│
│  │ 张三                     ││
│  │ zhangsan@company.com     ││
│  │ 技术部                   ││
│  ├─────────────────────────┤│
│  │ 张三丰                   ││
│  │ zhangsf@company.com      ││
│  │ 产品部                   ││
│  └─────────────────────────┘│
└─────────────────────────────┘
```

| 交互 | 触发 | 行为 |
|------|------|------|
| 输入搜索词 | 输入 ≥3 字符 | 调用 [007] 搜索用户，显示匹配列表 |
| 选择用户 | 点击列表项 | 1. 调用 [008] 获取该用户当天状态 2. 返回签到主页，显示该用户名称和状态 3. 点击签到/签退调用 [010] 提交 |
| 签到完成 | API 返回 201 | 显示成功 → 3 秒后清除选择状态，回到初始页面（支持下一个人使用） |

#### 页面状态规范

| 状态 | 显示 |
|------|------|
| 加载中 | 骨架屏 |
| 签到点不存在/已停用 | 全屏提示"签到点不可用" |
| 未签到 | 状态卡片显示 --:--，签到按钮可点击，签退按钮 disabled |
| 已签到未签退 | 签到按钮变"今日已签到 ✓" disabled，签退按钮可点击 |
| 已签到已签退 | 两个按钮均变为已完成态 disabled |
| 定位中 | 定位指示器显示旋转动画 |
| 定位失败 | 定位指示器显示警告，签到按钮根据 geoFailurePolicy 决定是否可点击 |
| 无网络 | 全屏提示"请检查网络连接" |

---

### 2. 签到点管理（`/siteattendance/admin/checkpoints`）

后台管理页面，Lark 设计系统风格。

#### 页面元素

| 元素 | 类型 | 说明 |
|------|------|------|
| 签到点列表 | 表格 | 列：名称、时区、定位校验开关、免登录开关、状态、操作 |
| 新建按钮 | 主按钮 | 跳转创建页面 |
| 操作列 | 按钮组 | 编辑、查看详情、删除 |

---

### 3. 签到点创建/编辑（`/siteattendance/admin/checkpoints/create` | `/:id/edit`）

#### 表单字段

##### 基础配置

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 名称 | 文本输入 | ✅ | 签到点名称 |
| 描述 | 文本域 | ❌ | 签到点描述 |
| 时区 | 下拉选择 | ✅ | IANA 时区列表 |
| 经度 | 数字输入 | ✅ | 支持地图选点 |
| 纬度 | 数字输入 | ✅ | 支持地图选点 |
| 定位策略 | 下拉选择 | ✅ | SKIP（默认）/ ALLOW_WITH_FLAG / STRICT_BLOCK |
| 校验半径（米） | 数字输入 | 条件 | 策略非 SKIP 时显示，默认 200，最小 100 |
| 精度阈值（米） | 数字输入 | 条件 | 策略非 SKIP 时显示，默认 100 |
| 允许免登录签到 | Switch | ✅ | 默认关闭 |

##### 签到页准入（v1.5 新增区块）

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 访问模式 | 单选 | ✅ | PUBLIC（公开，兼容静态 QR） / SIGNED（需签名访问） |
| 轮换策略 | 单选 | 条件 | 访问模式 = SIGNED 时显示：永久 / 定时轮换 |
| 轮换周期 | 下拉 + 自定义 | 条件 | 选"定时轮换"时显示：10 分钟 / 30 分钟 / 1 小时（默认）/ 4 小时 / 1 天 / 自定义秒（最小 60） |
| 宽限期（秒） | 数字输入 | 条件 | 选"定时轮换"时显示，默认 120 |

##### 共享签到（v1.5 新增区块）

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 启用共享签到 | Switch | ✅ | 默认关闭；开启后分诊页生效 |
| 本签到点代表公司 ID | 文本输入 | 条件 | 启用共享时必填，只允许小写字母/数字/横线/下划线（如 `ff` / `aixc`） |
| 本签到点代表公司名称 | 文本输入 | 条件 | 启用共享时必填（如 `Faraday Future` / `AIxCrypto`） |

##### 合作伙伴（v1.5 新增区块）

启用共享签到后展示；关闭共享时隐藏。

```
┌──────────────────────────────────────────────────────────────────┐
│ 公司ID   公司名称      显示名         签到页URL              操作  │
├──────────────────────────────────────────────────────────────────┤
│ aixc    AIxCrypto    上海总部        https://aixc.../c/AIXC-HQ   [编辑][删]│
│ aixc    AIxCrypto    北京办公室      https://aixc.../c/AIXC-BJ   [编辑][删]│
│                                                     [+ 添加伙伴]  │
└──────────────────────────────────────────────────────────────────┘
```

添加/编辑 Partner 的弹窗字段：

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 公司 ID | 文本输入 | ✅ | `[a-z0-9_-]+` |
| 公司名称 | 文本输入 | ✅ | — |
| 显示名 | 文本输入 | ❌ | 可选，签到点级说明 |
| 签到页 URL | 文本输入 | ✅ | 完整 URL；保存时 hostname 必须在白名单，否则显示错误 `PARTNER_TARGETURL_HOST_NOT_ALLOWED` |
| 启用 | Switch | ✅ | 默认 true |
| 排序 | 数字输入 | ❌ | 默认 0 |

---

### 4. 签到点详情（`/siteattendance/admin/checkpoints/:id`）

#### 页面元素

| 元素 | 类型 | 说明 |
|------|------|------|
| 签到点信息卡片 | 信息展示 | 名称、地址、配置项（含 v1.5 新增字段展示） |
| **访问地址区块（v1.5 新增）** | 信息展示 | 独立签到页 URL / 大屏地址 / 共享分诊页 URL（按配置条件显示），每行带"复制"+"打开"按钮 |
| QR 码展示 | 图片+操作 | 按 `accessMode` + `qrRotationSeconds` 显示：PUBLIC 或永久 = 静态预览；SIGNED 轮换 = 显示"大屏地址"链接（静态预览无意义） |
| 今日签到概览 | 统计卡片 | 今日签到人数、签退人数、未签退人数 |
| 签到事件列表 | 表格 | 签到事件，含用户/类型/时间/认证方式/定位状态/签到位置（反向编码地址） |
| 筛选栏 | 输入+下拉+日期 | 按用户名筛选 + 事件类型下拉 + 日期选择器 |
| 导出按钮 | 按钮 | CSV 导出当日签到记录，含签到位置地址 |
| 分页 | 导航 | 上一页/下一页 + 总数 |

#### 访问地址区块细节（v1.5 新增）

```
┌──────────────────────────────────────────────────────────────┐
│ 访问地址                                                       │
├──────────────────────────────────────────────────────────────┤
│ 独立签到页：  https://.../siteattendance/c/FF-LOBBY          │
│               [SIGNED 模式下需附 token，直接访问会被拒]        │
│               [复制] [打开]                                   │
│                                                              │
│ 大屏地址：    https://.../siteattendance/display/FF-LOBBY    │
│               [复制] [打开]                                   │
│                                                              │
│ 共享分诊页：  https://.../siteattendance/shared/FF-LOBBY     │
│               [仅 sharedCheckinEnabled=true 时显示]          │
│               [复制] [打开]                                   │
└──────────────────────────────────────────────────────────────┘
```

#### 交互

| 交互 | 触发 | API |
|------|------|-----|
| 页面加载 | 进入页面 | [003] + [012] + [011] |
| 切换日期/筛选 | 选择器变化 | [011]（含 userName/eventType/lang 参数） |
| 导出 | 点击导出按钮 | [015]（fetch + blob 下载，带 Authorization header） |
| 下载 QR 码（PUBLIC / 永久模式） | 点击下载按钮 | 前端生成，不调 API |
| 编辑签到点 | 点击编辑按钮 | 跳转编辑页 |
| 复制访问地址 | 点击"复制"按钮 | 前端 clipboard |
| 打开访问地址 | 点击"打开"按钮 | `window.open` 新 tab |

---

### 5. 分诊页（`/siteattendance/shared/:code`，v1.5 新增）

手机优先的公开页面，风格沿用现有签到页：浅色、手机优先、无侧边栏。

#### 布局

```
┌─────────────────────────────┐
│  [📍 办公地名称]   [EN/中文]  │  ← 顶栏
│                             │
│  请选择你的公司               │  ← 主标题
│                             │
│  ┌─────────────────────────┐│
│  │  Faraday Future         ││  ← 公司卡片（大按钮）
│  │  FF-LOBBY 打卡           ││
│  └─────────────────────────┘│
│  ┌─────────────────────────┐│
│  │  AIxCrypto              ││
│  │  上海总部                 ││
│  └─────────────────────────┘│
│  ┌─────────────────────────┐│
│  │  AIxCrypto              ││
│  │  北京办公室               ││
│  └─────────────────────────┘│
└─────────────────────────────┘
```

#### 交互流程

| 交互 | 触发 | 行为 |
|------|------|------|
| 页面加载 | 扫 QR 进入 | 1. 读 URL `?t=` 和 `?switch=` 2. 若 `switch=1` → 清除 localStorage 后继续 3. 读 `localStorage["sharedCheckin.lastChoice.{code}"]`：有记忆且未"switch" → 跳步骤 5 4. 展示公司选项（调用 [017] 获取 options） 5. 用户选择（或直接从记忆走）→ 调用 [018] 签发 ticket 6. `window.location.replace(redirectUrl)` |
| QR token 无效/过期 | [017] 返回 4xx | 显示"二维码已过期，请扫描前台最新二维码"，不提供重试（必须换新码） |
| 分诊未启用 | [017] 返回 `DISPATCH_NOT_ENABLED` | 显示"该签到点未启用共享签到" |
| 选择公司卡片 | 点击卡片 | 写 `localStorage["sharedCheckin.lastChoice.{code}"] = { companyId, partnerId? }`；调 [018]；跳转 |

#### localStorage schema

```
Key:   "sharedCheckin.lastChoice.{dispatchCheckpointCode}"
Value: {
  companyId: string;
  partnerId?: string;
  savedAt: number;   // ms timestamp
}
```

**设计选择**：按 `dispatchCheckpointCode` 分区的 key，允许同一域名下多个分诊入口互不干扰。

#### 切换公司流程（跨域）

```
[签到页 (AIxC 域)]
  用户点"切换公司"按钮
  读 query 里的 dispatchOrigin
  window.location.href = dispatchOrigin + "?switch=1"

[跳回 FF 分诊页]
  读 switch=1 → 清除 localStorage → 刷新选项展示
```

**前提**：ticket 载荷含 `dispatchOrigin`，跳转 URL 已由 [018] 生成时附带。

---

### 6. 大屏页（`/siteattendance/display/:code`，v1.5 新增）

纯展示页，无交互。手机 / iPad / 大屏电视都能用。

#### 布局

```
┌─────────────────────────────────────┐
│                                     │
│      Faraday Future - 前台           │  ← 顶部大字
│      Tuesday, April 17 · 09:32      │
│                                     │
│                                     │
│           ┌──────────────┐          │
│           │              │          │
│           │     [QR]     │          │  ← 居中大 QR 码
│           │              │          │
│           └──────────────┘          │
│                                     │
│       下次刷新：09:59:30             │  ← 可选，轮换时显示
│                                     │
└─────────────────────────────────────┘
```

#### 交互流程

| 交互 | 触发 | 行为 |
|------|------|------|
| 页面加载 | 运维在大屏打开 | 1. 调用 [016] 拉 token + expiresAt + scope 2. 按 scope 拼 URL：`scope=checkpoint` → `/c/:code?t=...`；`scope=shared` → `/shared/:code?t=...` 3. 渲染 QR（npm `qrcode`） 4. 若 `expiresAt` 非 null，`setTimeout(fetchAndRender, expiresAt + 1000 - Date.now())` 5. 否则不设定时器（永久模式） |
| Token 刷新失败 | [016] 网络错误 | 显示"二维码服务暂不可用，请重新打开本页"，带人工刷新按钮 |
| checkpoint PUBLIC 且无轮换 | [016] 返回 `token=''` | 前端降级为静态 URL 拼接（无 `?t=`），不设定时器 |

#### 样式

- 全屏黑底 + 居中白色 QR 卡片
- QR 尺寸：min(viewport.width, viewport.height) * 0.5（自适应）
- 顶部 checkpoint 名 + 时区时钟（每秒更新，复用 site-attendance 签到页的时间组件）
- 字体、颜色沿用 Lark 设计系统（primary / neutral 色板）
