From feec1ca0bf73d18581ba95c7503bb925459c686d Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Thu, 30 Jul 2026 18:16:27 +0800 Subject: [PATCH] =?UTF-8?q?docs(oa):=20add=20trip=20kit=20(=E5=87=BA?= =?UTF-8?q?=E5=B7=AE=E5=A5=97=E4=BB=B6)=20design=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../specs/2026-07-30-oa-trip-kit-design.md | 373 ++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-oa-trip-kit-design.md diff --git a/docs/superpowers/specs/2026-07-30-oa-trip-kit-design.md b/docs/superpowers/specs/2026-07-30-oa-trip-kit-design.md new file mode 100644 index 00000000..7010f992 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-oa-trip-kit-design.md @@ -0,0 +1,373 @@ +# OA 表单设计 · 出差套件(Trip Kit)设计文档 + +**日期**: 2026-07-30 +**状态**: 待实现 +**范围**: `web-admin` 审批设计器「套件」Tab + 发起/详情/打印运行时;`backend` 发布白名单、提交时长重算与行程时间校验 +**界面模式**: 对齐请假/加班套件;视觉 Fresh Mint +**前置**: +- `docs/superpowers/specs/2026-07-30-oa-leave-kit-design.md`(套件容器、手改断开) +- `docs/superpowers/specs/2026-07-30-oa-overtime-kit-design.md`(扁平套件注册模式) + +--- + +## 1. 背景与目标 + +套件 Tab 增加 **出差套件**:事由 + **可增删多行程** + 汇总天数 / 备注 / 同行人 / 外部人员。行程时长与出差天数按钉钉半日制自动计算,支持手改断开。 + +### 1.1 目标 + +- 一键插入出差套件(容器 `tripKit`)。 +- 发起页可「新增行程」,至少保留 1 条;可删至不少于 1 条。 +- 行程时长、出差天数:未手改时自动算;手改后断开;可「恢复自动计算」。 +- Schema / form_data 符合双端契约;子字段(非行程数组内嵌项)可参与条件分支。 +- 审批通过后 **不** 回写用户或其它业务表。 + +### 1.2 非目标(首期) + +- 回写 `sys_user` / 考勤 / 出差台账表。 +- 多行程时间重叠校验。 +- 城市高德/行政区选择器(城市为手填文本)。 +- 工作日历、节假日扣减。 +- 「单程往返」参与时长计算。 + +--- + +## 2. 产品决策(已确认) + +| 项 | 决策 | +|----|------| +| 实现方案 | 方案 1:套件容器 + 子字段拼装;行程为自定义列表控件(数组值) | +| 容器类型 | `tripKit`(布局类,不计可填字段数) | +| 行程时长算法 | 钉钉半日制(上午/下午) | +| 出差天数 | 默认 = Σ 各行程时长;可手改断开 + 恢复自动 | +| 交通工具默认 | 飞机 / 火车 / 汽车 / 其他(设计器可维护 options) | +| 单程往返默认 | 单程 / 往返(设计器可维护;不参与时长) | +| 行程条数 | ≥ 1;可增删;至少保留 1 条 | +| 落库 / 回写 | 仅审批 form 数据;不回写业务表 | +| 时间校验 | 单行程结束须晚于开始(`endPoint > startPoint`,时长 > 0);不校验行程间重叠 | +| 时长精度 | **0.5** 天步进 | + +--- + +## 3. 信息模型 + +### 3.1 心智模型(三部分) + +``` +┌─ 出差套件(tripKit)─────────────────────────────────────┐ +│ ① 出差事由(textarea) │ +│ ② 行程列表(自定义 tripList,≥1 条) │ +│ 每条:交通工具 / 单程往返 / 出发城市 / 目的城市 │ +│ 开始(日期+上午|下午) / 结束(日期+上午|下午) │ +│ 时长(天) │ +│ ③ 出差天数 / 出差备注 / 同行人(person 多选) / 外部人员 │ +└──────────────────────────────────────────────────────────┘ +``` + +- 选中**套件容器**:设计器右侧可配默认交通工具 / 单程往返 options(或挂在对应子字段上维护,与请假类型一致)。 +- 运行时/发起页 **不** 额外渲染套件边框或标题;行程列表自带分区标题与「新增行程」按钮。 +- 行程卡片内字段为列表行内控件,**不是**画布上可单独拖拽的独立 FormCreate 节点(避免 N×M field 爆炸)。 + +### 3.2 顶层子字段 + +| 语义 key | title | type | 必填默认 | 说明 | +|----------|-------|------|----------|------| +| `tripReason` | 出差事由 | textarea | 是 | 第一部分 | +| `tripList` | 行程 | 自定义 `tripItinerary`(或等价 type) | 是(≥1 条) | 值形态见 §3.4 | +| `tripDays` | 出差天数 | inputNumber | 是 | Σ 行程时长;手改标记 | +| `tripRemark` | 出差备注 | textarea | 否 | | +| `tripCompanions` | 同行人 | `person` multiple | 否 | 复用人员组件 | +| `tripExternal` | 外部人员 | input | 否 | 手填文本 | + +语义识别:`props._fc_kit_field`;容器 `props` 可挂各 `*Field` id。 + +### 3.3 单条行程字段 + +| key | title | 控件 | 必填默认 | +|-----|-------|------|----------| +| `transport` | 交通工具 | select | 是 | +| `tripType` | 单程往返 | select | 是 | +| `fromCity` | 出发城市 | input | 是 | +| `toCity` | 目的城市 | input | 是 | +| `startDate` | 开始日期 | datePicker 仅日期 `YYYY-MM-DD` | 是 | +| `startHalf` | 开始时段 | select:上午 / 下午 | 是 | +| `endDate` | 结束日期 | datePicker 仅日期 | 是 | +| `endHalf` | 结束时段 | select:上午 / 下午 | 是 | +| `duration` | 时长(天) | inputNumber step 0.5 | 是 | +| `durationManual` | (内部) | boolean | — | 手改标记,可不单独展示 | + +默认 options: + +- 交通工具:`飞机`、`火车`、`汽车`、`其他` +- 单程往返:`单程`、`往返` +- 上下午:`上午`、`下午` + +### 3.4 Schema 示意 + +```json +{ + "type": "tripKit", + "props": { + "tripReasonField": "...", + "tripListField": "...", + "tripDaysField": "...", + "tripRemarkField": "...", + "tripCompanionsField": "...", + "tripExternalField": "..." + }, + "children": [ + { + "type": "input", + "field": "...", + "title": "出差事由", + "props": { "type": "textarea", "_fc_kit_field": "tripReason", "rows": 3 }, + "$required": true + }, + { + "type": "tripItinerary", + "field": "...", + "title": "行程", + "props": { + "_fc_kit_field": "tripList", + "transportOptions": [ + { "label": "飞机", "value": "飞机" }, + { "label": "火车", "value": "火车" }, + { "label": "汽车", "value": "汽车" }, + { "label": "其他", "value": "其他" } + ], + "tripTypeOptions": [ + { "label": "单程", "value": "单程" }, + { "label": "往返", "value": "往返" } + ] + }, + "$required": true + }, + { + "type": "inputNumber", + "field": "...", + "title": "出差天数", + "props": { + "_fc_kit_field": "tripDays", + "precision": 1, + "step": 0.5, + "min": 0, + "daysManual": false + }, + "$required": true + }, + { + "type": "input", + "field": "...", + "title": "出差备注", + "props": { "type": "textarea", "_fc_kit_field": "tripRemark", "rows": 2 }, + "$required": false + }, + { + "type": "person", + "field": "...", + "title": "同行人", + "props": { "_fc_kit_field": "tripCompanions", "multiple": true }, + "$required": false + }, + { + "type": "input", + "field": "...", + "title": "外部人员", + "props": { "_fc_kit_field": "tripExternal", "placeholder": "请输入外部人员" }, + "$required": false + } + ] +} +``` + +说明: + +- 子字段 `field` 插入时生成唯一 ID(同表单可插多套出差套件)。 +- `tripItinerary` 为项目自定义 FormCreate 控件(注册方式对齐 `person` / kit 子控件)。 +- 若实现时发现 `input`+`props.type=textarea` 与现网约定不一致,改用项目已有 textarea 等价 type,语义不变。 + +### 3.5 form_data + +```json +{ + "FxxxReason": "客户拜访", + "FxxxList": [ + { + "transport": "飞机", + "tripType": "往返", + "fromCity": "合肥", + "toCity": "上海", + "startDate": "2026-08-01", + "startHalf": "上午", + "endDate": "2026-08-02", + "endHalf": "下午", + "duration": 2, + "durationManual": false + } + ], + "FxxxDays": 2, + "FxxxRemark": "", + "FxxxCompanions": [{ "id": "...", "name": "张三" }], + "FxxxExternal": "李四(客户)" +} +``` + +| 字段 | JSON 值形态 | +|------|-------------| +| 出差事由 / 备注 / 外部人员 | `string` | +| 行程列表 | `array` of objects(§3.3) | +| 开始/结束日期 | `string`,`YYYY-MM-DD` | +| 上下午 / 交通 / 单程往返 | `string`(选项 value) | +| 行程时长 / 出差天数 | `number`,0.5 步进 | +| 同行人 | 与 `person` 多选一致(`{id,name}[]`) | + +手改状态: + +- 行程行:`durationManual`(可存行内,或并行约定;前后端一致即可)。 +- 出差天数:时长字段 props `daysManual` 或并行隐藏字段;**实现计划择一**并写测试。 + +--- + +## 4. 时长算法(钉钉半日制) + +### 4.1 点值映射 + +| 位置 | 上午 | 下午 | +|------|------|------| +| 开始 | 日 + **0.0** | 日 + **0.5** | +| 结束 | 日 + **0.5** | 日 + **1.0** | + +将日期转为「日序号」`D`(相对任意 epoch 的整天差即可),则: + +``` +startPoint = D(startDate) + (startHalf == "下午" ? 0.5 : 0) +endPoint = D(endDate) + (endHalf == "下午" ? 1.0 : 0.5) +duration = endPoint - startPoint +``` + +### 4.2 示例 + +| 开始 | 结束 | 时长(天) | +|------|------|------------| +| 同日 上午 → 上午 | 0.5 | +| 同日 上午 → 下午 | 1.0 | +| 同日 下午 → 下午 | 0.5 | +| 同日 下午 → 上午 | **非法**(≤0) | +| D1 上午 → D2 下午 | 2.0 | +| D1 下午 → D2 上午 | 1.0 | + +### 4.3 手改与汇总 + +| 状态 | 行为 | +|------|------| +| 行程 `durationManual = false` | 改起止日期/时段时重算该行 `duration` | +| 行程 `durationManual = true` | 保留用户输入;提供「恢复自动计算」 | +| 出差天数 `daysManual = false` | = Σ 各行程 `duration`(行程增删/时长变化时更新) | +| 出差天数 `daysManual = true` | 不再自动汇总;可「恢复自动计算」 | + +「单程往返」**不**进入公式。 + +### 4.4 前后端一致性 + +- 前端发起页:即时预览(未手改时)。 +- 后端提交: + - 行程未手改:按同一算法覆盖该行 `duration`。 + - 行程已手改:信任提交值,校验 `number ≥ 0` 且为 0.5 倍数(允许实现为 `×2` 后为整数)。 + - 出差天数未手改:覆盖为 Σ(覆盖后的)行程时长。 + - 出差天数已手改:信任提交值(合法前提下)。 +- 算法放在独立纯函数:`oa-trip-days.ts` ↔ `oa_trip_days.py`(命名以实现计划为准)。 + +--- + +## 5. 设计器与运行时交互 + +### 5.1 套件 Tab + +- 注册拖拽规则「出差」;卡片样式与其它套件一致。 +- 容器加入 `LAYOUT_TYPES` / 打印 unwrap(与 `leaveKit` 等相同)。 + +### 5.2 拖入与编辑 + +| 操作 | 行为 | +|------|------| +| 拖入出差 | 插入 `tripKit` + §3.2 子字段;行程列表默认 **1** 条空行程模板(设计器预览可显示占位) | +| 选中子字段 | 常规属性;交通/单程往返 options 可维护 | +| 删除顶层子字段 | 允许;套件不完整时发布 **警告可发**(同请假) | +| 再拖一套 | **允许**;field 自动唯一 | + +### 5.3 发起 / 详情 / 打印 + +- 「新增行程」:追加一条空行程(或带默认上下午)。 +- 「删除」:当前 >1 条时可删;剩 1 条时禁用删除或提示。 +- 详情/打印:行程按卡片或表格展开各字段;套件容器无独立 form 值。 +- 历史单不因算法变更重算。 + +### 5.4 条件分支 + +- 顶层:事由、出差天数、备注、同行人、外部人员——与普通字段相同,可勾选参与条件。 +- 行程数组内字段:**首期不要求**进入条件分支(避免数组下标语义);若引擎已支持「数组聚合」可后续扩展。 + +--- + +## 6. 校验清单 + +### 6.1 设计器 / 发布 + +- `tripKit` ∈ 布局类白名单。 +- `tripItinerary`(或最终 type 名)∈ 允许控件白名单。 +- `person` / `inputNumber` / textarea 等价 type 合法。 + +### 6.2 提交 + +- 行程条数 ≥ 1。 +- 每条必填项非空;`transport` / `tripType` / `startHalf` / `endHalf` ∈ 当前 options。 +- `endPoint > startPoint`(严格大于;等于 0 亦拒绝)。 +- 时长 / 出差天数规则见 §4.4。 +- 同行人格式走现有 `person` 校验。 + +--- + +## 7. 实现落点(指引,非任务拆解) + +| 层 | 落点 | +|----|------| +| 设计器宿主 | `web-admin/src/oa-fc-host/`:拖拽规则、画布刷新 | +| 主应用注册 | `setupOaTripKit` + `main.ts` / `oa-fc-host/main.ts` | +| 行程列表控件 | `OaTripItinerary.vue`(或挂在套件 runtime 内) | +| 算法前端 | `web-admin/src/utils/oa-trip-days.ts` | +| 算法后端 | `backend/app/services/oa_trip_days.py`;发起提交路径调用 | +| 发布白名单 | `oa_publish.py` → `LAYOUT_TYPES` + 控件 type | +| 打印 | `oa-approval-print.ts` unwrap `tripKit`;行程数组格式化 | + +--- + +## 8. 验收标准 + +1. 套件 Tab 可插入出差套件;画布出现事由、行程、天数、备注、同行人、外部人员。 +2. 发起页可新增/删除行程,且至少保留 1 条。 +3. 交通工具默认四项含火车(非货车);单程往返两项;均可在设计器改 options。 +4. 未手改时,改起止日期/上下午后行程时长按半日制更新;出差天数等于各行程时长之和。 +5. 手改行程时长或出差天数后断开联动;「恢复自动计算」后恢复。 +6. 同日下午→上午提交失败;跨天示例 D1 下午→D2 上午 = 1.0。 +7. 审批通过不回写用户表。 +8. 详情/打印可读展示多行程。 + +--- + +## 9. 开放问题(实现计划可定) + +- 手改标记存行内字段 vs props/隐藏字段:择一并写测试。 +- 设计器画布上行程列表是「示意一张卡片」还是完整可编辑预览:建议示意 + 说明「发起时可增删」。 +- `tripItinerary` 最终 type 字符串命名(保持 kebab/camel 与现网 FormCreate 登记一致)。 + +--- + +## 10. 决策记录(对话摘要) + +1. 时长算法:钉钉半日制。 +2. 出差天数:Σ 行程时长 + 手改断开。 +3. 交通工具:飞机 / 火车 / 汽车 / 其他。 +4. 行程条数:≥1。 +5. 不回写业务表。 +6. 单行程结束 ≥ 开始;不校验行程重叠。 +7. 实现方案:套件容器 + 行程数组自定义控件。