2026-07-30-oa-trip-kit-design.md 14 KB

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 示意

{
  "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

{
  "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. 实现方案:套件容器 + 行程数组自定义控件。