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. 验收标准
- 套件 Tab 可插入出差套件;画布出现事由、行程、天数、备注、同行人、外部人员。
- 发起页可新增/删除行程,且至少保留 1 条。
- 交通工具默认四项含火车(非货车);单程往返两项;均可在设计器改 options。
- 未手改时,改起止日期/上下午后行程时长按半日制更新;出差天数等于各行程时长之和。
- 手改行程时长或出差天数后断开联动;「恢复自动计算」后恢复。
- 同日下午→上午提交失败;跨天示例 D1 下午→D2 上午 = 1.0。
- 审批通过不回写用户表。
- 详情/打印可读展示多行程。
9. 开放问题(实现计划可定)
- 手改标记存行内字段 vs props/隐藏字段:择一并写测试。
- 设计器画布上行程列表是「示意一张卡片」还是完整可编辑预览:建议示意 + 说明「发起时可增删」。
tripItinerary 最终 type 字符串命名(保持 kebab/camel 与现网 FormCreate 登记一致)。
10. 决策记录(对话摘要)
- 时长算法:钉钉半日制。
- 出差天数:Σ 行程时长 + 手改断开。
- 交通工具:飞机 / 火车 / 汽车 / 其他。
- 行程条数:≥1。
- 不回写业务表。
- 单行程结束 ≥ 开始;不校验行程重叠。
- 实现方案:套件容器 + 行程数组自定义控件。