feat(oa): add leave kit with auto duration calculation

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-30 17:54:48 +08:00
1 parent b3d267e9ca
commit 3b46b10604
10 files changed
+1337

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
"""请假套件:双段工时重叠小时(与前端 oa-leave-hours.ts 对齐)"""
from __future__ import annotations
import re
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
_DT_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$")
_HM_RE = re.compile(r"^(\d{1,2}):(\d{2})$")
DEFAULT_LEAVE_WORK_HOURS = {
"morningStart": "08:30",
"morningEnd": "12:00",
"afternoonStart": "13:00",
"afternoonEnd": "17:30",
}
MANUAL_PREFIX = "__manual_"
def _parse_hm(hm: Any) -> Optional[int]:
m = _HM_RE.match(str(hm or "").strip())
if not m:
return None
h, mi = int(m.group(1)), int(m.group(2))
if h < 0 or h > 23 or mi < 0 or mi > 59:
return None
return h * 60 + mi
def _parse_dt(s: Any) -> Optional[datetime]:
m = _DT_RE.match(str(s or "").strip())
if not m:
return None
try:
return datetime(
int(m.group(1)),
int(m.group(2)),
int(m.group(3)),
int(m.group(4)),
int(m.group(5)),
)
except ValueError:
return None
def _overlap_minutes_on_day(
day: datetime,
range_start: datetime,
range_end: datetime,
work_hours: Dict[str, str],
) -> float:
day0 = datetime(day.year, day.month, day.day)
am_a = _parse_hm(work_hours.get("morningStart"))
am_b = _parse_hm(work_hours.get("morningEnd"))
pm_a = _parse_hm(work_hours.get("afternoonStart"))
pm_b = _parse_hm(work_hours.get("afternoonEnd"))
if None in (am_a, am_b, pm_a, pm_b):
return 0.0
if am_a >= am_b or pm_a >= pm_b:
return 0.0
segments = [
(day0 + timedelta(minutes=am_a), day0 + timedelta(minutes=am_b)),
(day0 + timedelta(minutes=pm_a), day0 + timedelta(minutes=pm_b)),
]
total = 0.0
for a, b in segments:
lo = max(range_start, a)
hi = min(range_end, b)
if hi > lo:
total += (hi - lo).total_seconds() / 60.0
return total
def calc_leave_hours(
start_str: Any,
end_str: Any,
work_hours: Optional[Dict[str, str]] = None,
) -> Optional[float]:
"""返回 1 位小数小时;非法或 end<=start 返回 None。"""
wh = dict(DEFAULT_LEAVE_WORK_HOURS)
if isinstance(work_hours, dict):
wh.update({k: str(work_hours.get(k) or wh[k]) for k in DEFAULT_LEAVE_WORK_HOURS})
start = _parse_dt(start_str)
end = _parse_dt(end_str)
if not start or not end or end <= start:
return None
minutes = 0.0
cursor = datetime(start.year, start.month, start.day)
last = datetime(end.year, end.month, end.day)
while cursor <= last:
minutes += _overlap_minutes_on_day(cursor, start, end, wh)
cursor += timedelta(days=1)
return round(minutes / 60.0, 1)
def _extract_rules(form_schema: Any) -> List[Dict[str, Any]]:
if isinstance(form_schema, list):
return [r for r in form_schema if isinstance(r, dict)]
if isinstance(form_schema, dict):
rules = form_schema.get("rule") or form_schema.get("rules") or form_schema.get("fields")
if isinstance(rules, list):
return [r for r in rules if isinstance(r, dict)]
if form_schema.get("type") or form_schema.get("field"):
return [form_schema]
return []
def _walk(rules: List[Dict[str, Any]], out: List[Dict[str, Any]]) -> None:
for r in rules:
if not isinstance(r, dict):
continue
out.append(r)
children = r.get("children")
if isinstance(children, list):
_walk([c for c in children if isinstance(c, dict)], out)
props = r.get("props") if isinstance(r.get("props"), dict) else {}
nested = props.get("rule")
if isinstance(nested, list):
_walk([c for c in nested if isinstance(c, dict)], out)
def _kit_child_fields(kit: Dict[str, Any]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""返回 (start_field, end_field, duration_field)。"""
props = kit.get("props") if isinstance(kit.get("props"), dict) else {}
start_f = props.get("startField")
end_f = props.get("endField")
dur_f = props.get("durationField")
if start_f and end_f and dur_f:
return str(start_f), str(end_f), str(dur_f)
children = kit.get("children")
if not isinstance(children, list):
children = []
p_rule = props.get("rule")
if isinstance(p_rule, list):
children = p_rule
date_fields: List[str] = []
duration_field: Optional[str] = None
for c in children:
if not isinstance(c, dict):
continue
field = c.get("field")
if not field:
continue
t = str(c.get("type") or "")
if t in ("datePicker", "date", "timePicker", "time"):
date_fields.append(str(field))
elif t in ("inputNumber", "number") and duration_field is None:
duration_field = str(field)
elif t in ("inputNumber", "number") and "时长" in str(c.get("title") or ""):
duration_field = str(field)
start_field = date_fields[0] if len(date_fields) >= 1 else None
end_field = date_fields[1] if len(date_fields) >= 2 else None
return start_field, end_field, duration_field
def _round1(v: Any) -> Optional[float]:
try:
n = float(v)
except (TypeError, ValueError):
return None
if n < 0:
return None
return round(n, 1)
def apply_leave_kit_hours(form_schema: Any, form_data: Dict[str, Any]) -> Dict[str, Any]:
"""
遍历 leaveKit:未手改则覆盖时长;已手改(form_data[__manual_<durationField>])则仅规范化一位小数。
"""
data = dict(form_data or {})
flat: List[Dict[str, Any]] = []
_walk(_extract_rules(form_schema), flat)
for rule in flat:
if str(rule.get("type") or "") != "leaveKit":
continue
start_f, end_f, dur_f = _kit_child_fields(rule)
if not start_f or not end_f or not dur_f:
continue
props = rule.get("props") if isinstance(rule.get("props"), dict) else {}
wh = {
"morningStart": str(props.get("morningStart") or DEFAULT_LEAVE_WORK_HOURS["morningStart"]),
"morningEnd": str(props.get("morningEnd") or DEFAULT_LEAVE_WORK_HOURS["morningEnd"]),
"afternoonStart": str(
props.get("afternoonStart") or DEFAULT_LEAVE_WORK_HOURS["afternoonStart"]
),
"afternoonEnd": str(props.get("afternoonEnd") or DEFAULT_LEAVE_WORK_HOURS["afternoonEnd"]),
}
manual_key = MANUAL_PREFIX + dur_f
is_manual = bool(data.get(manual_key))
if is_manual:
rounded = _round1(data.get(dur_f))
if rounded is not None:
data[dur_f] = rounded
continue
hours = calc_leave_hours(data.get(start_f), data.get(end_f), wh)
if hours is not None:
data[dur_f] = hours
return data
def validate_leave_kit_form_values(form_schema: Any, form_data: Dict[str, Any]) -> List[str]:
"""提交校验:结束>=开始;手改时长合法。"""
errors: List[str] = []
data = form_data or {}
flat: List[Dict[str, Any]] = []
_walk(_extract_rules(form_schema), flat)
for rule in flat:
if str(rule.get("type") or "") != "leaveKit":
continue
start_f, end_f, dur_f = _kit_child_fields(rule)
if not start_f or not end_f or not dur_f:
continue
start = _parse_dt(data.get(start_f))
end = _parse_dt(data.get(end_f))
if data.get(start_f) and data.get(end_f):
if not start or not end:
errors.append("请假起止时间格式须为 YYYY-MM-DD HH:mm")
elif end <= start:
errors.append("请假结束时间须晚于开始时间")
if MANUAL_PREFIX + dur_f in data or data.get(MANUAL_PREFIX + dur_f):
if data.get(dur_f) is not None and data.get(dur_f) != "":
if _round1(data.get(dur_f)) is None:
errors.append("请假时长须为非负数字(1 位小数)")
return errors
+96
View File
@@ -0,0 +1,96 @@
"""oa_leave_hours unit tests"""
from app.services.oa_leave_hours import (
apply_leave_kit_hours,
calc_leave_hours,
validate_leave_kit_form_values,
)
def test_calc_same_day_sample():
assert calc_leave_hours("2026-07-30 09:00", "2026-07-30 15:00") == 5.0
def test_calc_morning_only():
assert calc_leave_hours("2026-07-30 08:30", "2026-07-30 12:00") == 3.5
def test_calc_lunch_gap():
assert calc_leave_hours("2026-07-30 11:00", "2026-07-30 14:00") == 2.0
def test_calc_invalid():
assert calc_leave_hours("2026-07-30 15:00", "2026-07-30 09:00") is None
assert calc_leave_hours("", "2026-07-30 09:00") is None
def test_apply_overwrites_when_not_manual():
schema = {
"rule": [
{
"type": "leaveKit",
"props": {
"morningStart": "08:30",
"morningEnd": "12:00",
"afternoonStart": "13:00",
"afternoonEnd": "17:30",
},
"children": [
{"type": "select", "field": "leaveType", "title": "请假类型"},
{"type": "datePicker", "field": "leaveStart", "title": "开始时间"},
{"type": "datePicker", "field": "leaveEnd", "title": "结束时间"},
{"type": "inputNumber", "field": "leaveDuration", "title": "时长(h)"},
],
}
]
}
data = {
"leaveType": "事假",
"leaveStart": "2026-07-30 09:00",
"leaveEnd": "2026-07-30 15:00",
"leaveDuration": 1,
}
out = apply_leave_kit_hours(schema, data)
assert out["leaveDuration"] == 5.0
def test_apply_respects_manual():
schema = {
"rule": [
{
"type": "leaveKit",
"props": {},
"children": [
{"type": "datePicker", "field": "leaveStart"},
{"type": "datePicker", "field": "leaveEnd"},
{"type": "inputNumber", "field": "leaveDuration"},
],
}
]
}
data = {
"leaveStart": "2026-07-30 09:00",
"leaveEnd": "2026-07-30 15:00",
"leaveDuration": 8,
"__manual_leaveDuration": True,
}
out = apply_leave_kit_hours(schema, data)
assert out["leaveDuration"] == 8.0
def test_validate_end_before_start():
schema = {
"rule": [
{
"type": "leaveKit",
"children": [
{"type": "datePicker", "field": "s"},
{"type": "datePicker", "field": "e"},
{"type": "inputNumber", "field": "d"},
],
}
]
}
errs = validate_leave_kit_form_values(
schema, {"s": "2026-07-30 15:00", "e": "2026-07-30 09:00", "d": 1}
)
assert any("结束" in e for e in errs)
@@ -0,0 +1,202 @@
# OA Leave Kit(请假套件)Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在审批「2 表单设计」增加「套件」菜单与请假套件:拖入后得到套件容器(工时属性)+ 四个基础子字段;发起时按双段工时自动算时长(可手改);提交时后端权威重算/校验。
**Architecture:** FormCreate 扩展 `leaveKit` 布局容器(`input: false`,可拖入子组件,默认带 4 子字段)+ `menu: 'kit'`。工时在容器 `props`。纯函数 `calcLeaveHours` 前后端各一份。发起页对 schema 中 leaveKit 绑定 watch;`start_instance` 调用 `apply_leave_kit_hours`。
**Tech Stack:** Vue 3 · `@form-create/designer` · Element Plus · FastAPI · pytest · Node `node:test`(前端算法单测)
**Spec:** `docs/superpowers/specs/2026-07-30-oa-leave-kit-design.md`
**Demo:** `docs/mockups/oa-form-kit-tab-demo.html`
---
## File map
| Path | Responsibility |
|------|----------------|
| Create: `web-admin/src/utils/oa-leave-hours.ts` | 重叠工时计算纯函数 |
| Create: `web-admin/src/utils/oa-leave-hours.test.ts` | 算法单测 |
| Create: `web-admin/src/components/oa-approval/OaLeaveKit.vue` | 套件容器 UI(设计器/运行时) |
| Create: `web-admin/src/utils/oa-leave-kit-rule.ts` | DragRule:menu=kit,默认 4 子字段 + 工时 props |
| Create: `web-admin/src/utils/oa-form-create-leave-kit.ts` | `addMenu('kit')` + `setupOaLeaveKit` |
| Create: `web-admin/src/utils/oa-leave-kit-runtime.ts` | 从 schema 解析 leaveKit、应用自动时长、手改标记 |
| Modify: `web-admin/src/main.ts` | 注册 leaveKit |
| Modify: `web-admin/src/oa-fc-host/main.ts` | 同上 |
| Modify: `web-admin/src/views/oa-approval/start/index.vue` | 发起页联动时长 |
| Create: `backend/app/services/oa_leave_hours.py` | 同算法 + `apply_leave_kit_hours` |
| Create: `backend/tests/test_oa_leave_hours.py` | pytest |
| Modify: `backend/app/services/oa_publish.py` | `LAYOUT_TYPES` 加 `leaveKit` |
| Modify: `backend/app/services/oa_engine.py` | `start_instance` 调用 apply |
| Modify: `docs/superpowers/specs/2026-07-30-oa-leave-kit-design.md` | 状态 → 实现中/已实现 |
---
### Task 1: Leave hours algorithm (TDD, frontend)
**Files:**
- Create: `web-admin/src/utils/oa-leave-hours.ts`
- Create: `web-admin/src/utils/oa-leave-hours.test.ts`
- [ ] **Step 1: Write failing tests**
```ts
// web-admin/src/utils/oa-leave-hours.test.ts
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { calcLeaveHours, DEFAULT_LEAVE_WORK_HOURS } from './oa-leave-hours'
describe('calcLeaveHours', () => {
const wh = DEFAULT_LEAVE_WORK_HOURS // 08:30-12:00, 13:00-17:30
it('same day morning+afternoon sample 09:00-15:00 => 5.0', () => {
assert.equal(calcLeaveHours('2026-07-30 09:00', '2026-07-30 15:00', wh), 5.0)
})
it('only morning', () => {
assert.equal(calcLeaveHours('2026-07-30 08:30', '2026-07-30 12:00', wh), 3.5)
})
it('gap across lunch not counted', () => {
assert.equal(calcLeaveHours('2026-07-30 11:00', '2026-07-30 14:00', wh), 2.0)
})
it('end before start => null', () => {
assert.equal(calcLeaveHours('2026-07-30 15:00', '2026-07-30 09:00', wh), null)
})
it('invalid => null', () => {
assert.equal(calcLeaveHours('', '2026-07-30 09:00', wh), null)
})
})
```
- [ ] **Step 2: Run tests — expect FAIL**
```bash
cd web-admin && pnpm exec tsx --test src/utils/oa-leave-hours.test.ts
```
- [ ] **Step 3: Implement `oa-leave-hours.ts`**
Export:
- `DEFAULT_LEAVE_WORK_HOURS = { morningStart, morningEnd, afternoonStart, afternoonEnd }`
- `calcLeaveHours(start, end, workHours): number | null` — 1 decimal, null if invalid/end≤start
- Overlap minutes with morning+afternoon segments per calendar day
- [ ] **Step 4: Run tests — expect PASS**
- [ ] **Step 5: Commit**(仅当用户要求 commit 时执行;本会话默认可暂缓)
---
### Task 2: Backend leave hours + publish whitelist
**Files:**
- Create: `backend/app/services/oa_leave_hours.py`
- Create: `backend/tests/test_oa_leave_hours.py`
- Modify: `backend/app/services/oa_publish.py` — add `leaveKit` to `LAYOUT_TYPES`
- Modify: `backend/app/services/oa_engine.py` — after formulas, call `apply_leave_kit_hours`
- [ ] **Step 1: pytest failing** for `calc_leave_hours` 09:00–15:00 → 5.0
- [ ] **Step 2: Implement** `calc_leave_hours` + `apply_leave_kit_hours(schema, data)`:
- Walk rules for `type == leaveKit`
- Find children fields: prefer props `startField`/`endField`/`durationField` if set, else by title/order (select, datePicker, datePicker, inputNumber)
- If duration props `durationManual` truthy: validate ≥0 one decimal only
- Else overwrite duration with calc result
- [ ] **Step 3: Wire `start_instance`**
- [ ] **Step 4: pytest PASS**
---
### Task 3: FormCreate leaveKit container + kit menu
**Files:**
- Create: `OaLeaveKit.vue` — 虚线框 + 标题「请假套件」+ `<slot />`
- Create: `oa-leave-kit-rule.ts` — DragRule
- Create: `oa-form-create-leave-kit.ts` — addMenu + component + addDragRule
- Modify: `main.ts`, `oa-fc-host/main.ts`
DragRule essentials:
```ts
{
menu: 'kit',
icon: 'icon-date',
label: '请假',
name: 'leaveKit',
input: false,
drag: true,
mask: false,
inside: true,
rule() {
return {
type: 'leaveKit',
props: { ...DEFAULT_LEAVE_WORK_HOURS },
children: [ /* select, 2x datePicker datetime, inputNumber duration */ ]
}
},
props() {
return [ /* 4x time inputs for morning/afternoon */ ]
}
}
```
`setupOaLeaveKit`:
```ts
FcDesigner.addMenu({ name: 'kit', title: '套件', list: [] })
FcDesigner.component('leaveKit', OaLeaveKit)
formCreate.component('leaveKit', OaLeaveKit)
FcDesigner.addDragRule(leaveKitDragRule)
```
子字段 field 用 uniqueId;时长 `props.precision=1`, `props.durationManual=false`;日期 `props.type='datetime'`, format/valueFormat=`YYYY-MM-DD HH:mm`。
---
### Task 4: Start form runtime linkage
**Files:**
- Create: `oa-leave-kit-runtime.ts` — `listLeaveKits(schema)`, `applyLeaveKitDurations(schema, formData, { respectManual })`
- Modify: `start/index.vue` — on form-create change / watch formData,未手改时写回时长;用户改时长字段时设 `durationManual`(若无法写回 schema,用并行隐藏字段或 formData 旁路 `__leaveDurationManual_<field>`;优先改 rule props 若 api 允许)
手改标记落地(择一,实现时选可工作的):
1. 时长字段 `props.durationManual`(设计器可读)+ 提交时由前端把 manual 状态塞进 formData 特殊 key,后端读取;或
2. formData 增加 `__manual_<durationField>: true`,后端识别后不覆盖。
**采用 2(更简单跑通)**:发起人手改时长 → 设 `formData['__manual_' + durationField] = true`;后端 `apply_leave_kit_hours` 检查该 key。
「恢复自动计算」:首期发起页可不做按钮,手改后仅断开;设计器 Demo 已有示意。Spec 中的恢复按钮可作为二期(或 start 页时长旁小链,有则加)。
---
### Task 5: Spec status + graphify + smoke checklist
- Update design doc status
- `./scripts/update-graph.sh frontend`(若改了 web-admin)
- Manual: designer 套件 Tab → 拖请假 → 配工时 → 发起算时长
---
## Spec coverage
| Spec 项 | Task |
|---------|------|
| 套件 Tab 双列卡片 | T3 |
| 4 基础字段 + 套件工时 | T3 |
| 双段重叠算法 1 位小数 | T1/T2 |
| 手改后不联动 | T4 + T2 |
| 发布白名单 leaveKit | T2 |
| 提交权威 | T2 |
| 多套 field 唯一 | T3 uniqueId |
| 恢复自动计算 | T4 可选/二期 |
---
## Execution
用户已要求继续开发:本会话采用 **Inline Execution**(executing-plans)。
@@ -0,0 +1,268 @@
# OA 表单设计 · 请假套件(Leave Kit)设计文档
**日期**: 2026-07-30
**状态**: 已实现(计划见 `docs/superpowers/plans/2026-07-30-oa-leave-kit.md`)
**范围**: `web-admin` 审批设计器「2 表单设计」套件 Tab + 发起/详情运行时;`backend` 时长重算与发布/提交校验
**界面模式**: 对齐 FormCreate 左侧控件库;视觉 Fresh Mint
**Demo**: `docs/mockups/oa-form-kit-tab-demo.html`
**前置**: `docs/superpowers/specs/2026-07-14-oa-approval-dingtalk-design.md` §4.5 / §4.6 / §5.4
---
## 1. 背景与目标
客户希望在「2 表单设计」左侧「组件」旁增加 **套件** Tab:将常用业务字段组合为一键插入的预制包。首个套件为 **请假套件**。
### 1.1 目标
- 左侧增加 **套件** Tab(位于「组件」之后),展示模式与「组件」一致(双列小卡片)。
- **请假套件** = 4 个基础组件拼装 + 套件级属性(上午/下午上班时段)。
- 时长按工时时段重叠自动计算(小时,1 位小数);支持手改后断开联动。
- Schema / form_data 仍符合双端契约;子字段可参与条件分支。
### 1.2 非目标(首期)
- 午休单独配置、节假日/工作日历、按工作日扣减。
- 租户全局考勤设置(工时挂在**套件实例**属性)。
- 出差 / 加班 / 报销等其它套件(可灰显占位)。
- 一体复合控件(单 type 包四字段对象值)——已否决,采用方案 1。
---
## 2. 产品决策(已确认)
| 项 | 决策 |
|----|------|
| 左侧 UI | Tab:`组件` → `套件` → `大纲`;套件独立 Tab(不出现在「组件」里);双列小卡片 |
| 组成 | 4 个**基础组件**拼装;套件容器上配置工时属性 |
| 字段 | 请假类型、开始时间、结束时间、时长(h) |
| 类型选项 | 内置:事假、调休、病假、年假、产假、陪产假、婚假、例假、丧假、哺乳假;设计器可维护 |
| 计量单位 | 全部按**小时** |
| 时间精度 | 开始/结束:年月日时分(`YYYY-MM-DD HH:mm`) |
| 工时模型 | 上午时段 + 下午时段;间隙(午休)自然不计 |
| 默认时段 | 上午 `08:30–12:00`,下午 `13:00–17:30` |
| 配置位置 | **套件实例属性**(每个审批应用各自配置) |
| 时长手改 | 默认可改;手改后不再随起止/工时自动重算;提供「恢复自动计算」 |
| 小数 | 保留 **1** 位小数 |
| 实现方案 | 方案 1:套件容器 + 展开/内嵌基础子字段(非一体 `leaveKit` 值对象) |
---
## 3. 信息模型
### 3.1 心智模型
```
┌─ 请假套件(组装体 / 容器)─────────────────────┐
│ 套件属性:上午时段、下午时段 │
│ │
│ · 基础·下拉 → 请假类型 │
│ · 基础·日期时间 → 开始时间 │
│ · 基础·日期时间 → 结束时间 │
│ · 基础·数字 → 时长(h) │
└─────────────────────────────────────────────────┘
```
- 选中**套件容器**:配置工时时段等套件属性(设计器右侧);运行时/发起页**不**额外渲染套件边框或标题,子字段与单独拖入的标准组件样式一致。
- 选中**子字段**:配置标题、必填、选项等(与普通组件相同)。
### 3.2 Schema 结构(示意)
容器类型建议登记为 `leaveKit`(布局类,不计可填字段数),或复用 `group` 并带 `_fc_kit: 'leave'` 标记。推荐独立 `leaveKit` 便于发布校验与选中态属性面板。
```json
{
"type": "leaveKit",
"props": {
"morningStart": "08:30",
"morningEnd": "12:00",
"afternoonStart": "13:00",
"afternoonEnd": "17:30"
},
"children": [
{
"type": "select",
"field": "leaveType",
"title": "请假类型",
"options": [
{ "label": "事假", "value": "事假" },
{ "label": "调休", "value": "调休" },
{ "label": "病假", "value": "病假" },
{ "label": "年假", "value": "年假" },
{ "label": "产假", "value": "产假" },
{ "label": "陪产假", "value": "陪产假" },
{ "label": "婚假", "value": "婚假" },
{ "label": "例假", "value": "例假" },
{ "label": "丧假", "value": "丧假" },
{ "label": "哺乳假", "value": "哺乳假" }
],
"$required": true
},
{
"type": "datePicker",
"field": "leaveStart",
"title": "开始时间",
"props": { "type": "datetime", "format": "YYYY-MM-DD HH:mm", "valueFormat": "YYYY-MM-DD HH:mm" },
"$required": true
},
{
"type": "datePicker",
"field": "leaveEnd",
"title": "结束时间",
"props": { "type": "datetime", "format": "YYYY-MM-DD HH:mm", "valueFormat": "YYYY-MM-DD HH:mm" },
"$required": true
},
{
"type": "inputNumber",
"field": "leaveDuration",
"title": "时长(h)",
"props": {
"precision": 1,
"min": 0,
"durationManual": false
},
"$required": true
}
]
}
```
说明:
- 子字段 `field` 插入时生成唯一 ID(同表单可插入多套请假套件,自动去重后缀)。
- 子字段通过 `props.kitRef` 或父 `leaveKit` 关联,用于运行时读取工时配置。
- `durationManual` 存在时长字段 props;发起人手改后置 `true`。
### 3.3 form_data
```json
{
"leaveType": "事假",
"leaveStart": "2026-07-30 09:00",
"leaveEnd": "2026-07-30 15:00",
"leaveDuration": 5.5
}
```
| 字段 | JSON 值形态 |
|------|-------------|
| 请假类型 | `string`(选项 value) |
| 开始/结束 | `string`,`YYYY-MM-DD HH:mm` |
| 时长 | `number`,一位小数 |
工时时段与 `durationManual` **不**作为业务展示主字段写入 form_data 的独立 key(配置在 schema);若运行时需回传手改状态,可在时长字段旁用约定 props 或并行隐藏字段,实现计划中二选一并保持双端一致。
---
## 4. 时长算法
### 4.1 规则
对闭开或闭闭区间,实现统一为:将 `[start, end]` 与每一天的上午段、下午段求交集,累加分钟数,再 `/ 60`,**四舍五入到 1 位小数**。
- 若 `end <= start`:时长视为无效(前端提示;后端拒绝或置 0 并报错)。
- 与任一时段无重叠:结果为 `0.0`。
- 跨天:按自然日切分后分别与两段求交再求和。
示例(默认工时):
- 开始 `09:00`,结束 `15:00`(同日)
→ 上午 `09:00–12:00`(3h)+ 下午 `13:00–15:00`(2h)= **5.0h**
(`12:00–13:00` 不计)
### 4.2 手改
| 状态 | 行为 |
|------|------|
| `durationManual = false` | 起止或套件工时变化时自动重算并写回时长 |
| `durationManual = true` | 不再自动重算;保留用户输入 |
| 「恢复自动计算」 | 清 `durationManual`,按当前起止与工时重算 |
### 4.3 前后端一致性
- 前端发起页:即时预览(未手改时)。
- 后端提交:
- **未手改**:按同一算法重算并**覆盖**提交时长后落库。
- **已手改**:信任提交时长,校验 `number ≥ 0` 且一位小数。
- 禁止任意 `eval`;算法放在独立纯函数模块(可与 `oa_formula` 并列,如 `oa_leave_hours.py` + 前端 `oa-leave-hours.ts`)。
---
## 5. 设计器与运行时交互
### 5.1 套件 Tab
- `FcDesigner.addMenu({ name: 'kit', title: '套件' })`(或等价配置),保证顺序在「组件」相关菜单之后。
- `addDragRule` 注册请假套件;视觉与基础组件卡片一致。
- 其它套件可 `coming` 占位,不注册拖拽或禁用。
### 5.2 拖入与编辑
| 操作 | 行为 |
|------|------|
| 拖入/点击请假 | 插入 `leaveKit` + 4 子字段 |
| 选中容器 | 右侧:上午/下午四个时间;校验段内 start &lt; end |
| 选中子字段 | 常规属性;类型字段可维护 options |
| 删除子字段 | 允许;套件不完整时发布 **警告可发**(不强制阻断) |
| 再拖一套 | **允许**;field 自动唯一 |
### 5.3 发起 / 详情 / 打印
- 四个子字段按基础组件渲染与打印。
- 套件容器不输出独立 form 值。
- 详情只读展示已保存时长;不再因工时变更重算历史单。
### 5.4 条件分支
子字段与普通字段相同:勾选「参与流程条件分支」后可在流程设计中引用(类型、时长等)。
---
## 6. 校验清单
### 6.1 设计器 / 发布
- `leaveKit`(或约定 group 标记)加入布局类白名单。
- 子 type 须为已允许的 `select` / `datePicker` / `inputNumber`(或项目现用等价 type)。
- 工时四段格式 `HH:mm`;上午/下午各自 start &lt; end;下午起点 &lt; 上午终点时仅 **提示**(不强制)。
### 6.2 提交
- 结束 ≥ 开始。
- 类型 ∈ 当前 options。
- 时长规则见 §4.3。
- 必填子字段非空。
---
## 7. 实现落点(指引,非任务拆解)
| 层 | 落点 |
|----|------|
| 设计器宿主 | `web-admin/src/oa-fc-host/`:菜单「套件」、拖拽规则、容器组件与属性 |
| 主应用注册 | 与金额/人员同类:`setupOaLeaveKit` + `main.ts` / `oa-fc-host/main.ts` |
| 算法前端 | `web-admin/src/utils/oa-leave-hours.ts` |
| 算法后端 | `backend/app/services/oa_leave_hours.py`;发起提交路径调用 |
| 发布白名单 | `backend/app/services/oa_publish.py` → `LAYOUT_TYPES` 增加 `leaveKit` |
| Demo | `docs/mockups/oa-form-kit-tab-demo.html` |
---
## 8. 验收标准
1. 「2 表单设计」左侧出现 **套件** Tab,样式与组件双列卡片一致;可插入请假套件。
2. 画布出现类型 / 开始 / 结束 / 时长四个基础字段;选中套件可配默认双段工时。
3. 类型含约定 10 项且可在设计器维护选项。
4. 未手改时,改起止后时长按重叠工时更新为 1 位小数;示例 09:00–15:00 → 5.0。
5. 手改时长后改起止不再变;「恢复自动计算」后恢复联动。
6. 提交后后端对未手改单据重算覆盖;已手改保留用户值(合法前提下)。
7. 手机预览可用;打印/详情展示四个字段值。
8. 子字段可勾选参与条件分支。
---
## 9. 开放问题(实现时可定)
- 手改状态是仅存 schema props,还是提交时附带隐藏字段:实现计划择一并写测试。
- Element Plus `datePicker` datetime 的 `valueFormat` 与后端解析严格对齐。
- 同一套件内删除「开始」后,时长字段是否自动禁用编辑提示:建议弱提示即可。
@@ -0,0 +1,29 @@
<!-- 请假套件:仅组合容器,不改变子字段标准展示样式;工时在 props -->
<template>
<div class="oa-leave-kit" v-bind="$attrs">
<slot />
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'OaLeaveKit', inheritAttrs: false })
/** 工时属性由 FormCreate 写入 props,供 schema / 运行时读取,本组件不展示 */
defineProps<{
morningStart?: string
morningEnd?: string
afternoonStart?: string
afternoonEnd?: string
startField?: string
endField?: string
durationField?: string
typeField?: string
}>()
</script>
<style scoped lang="scss">
/* 无边框/底色/标题:子字段视觉与标准组件一致 */
.oa-leave-kit {
width: 100%;
}
</style>
@@ -0,0 +1,22 @@
import FcDesigner from '@form-create/designer'
import OaLeaveKit from '@/components/oa-approval/OaLeaveKit.vue'
import { leaveKitDragRule } from '@/utils/oa-leave-kit-rule'
const FLAG = '__oaLeaveKitConfigured'
export function setupOaLeaveKit(formCreate?: { component?: (name: string, c: unknown) => void }) {
if ((FcDesigner as any)[FLAG]) return
if (typeof FcDesigner.addMenu === 'function') {
FcDesigner.addMenu({ name: 'kit', title: '套件', list: [] })
}
FcDesigner.component('leaveKit', OaLeaveKit)
if (typeof formCreate?.component === 'function') {
formCreate.component('leaveKit', OaLeaveKit)
}
if (typeof FcDesigner.addDragRule === 'function') {
FcDesigner.addDragRule(leaveKitDragRule)
}
;(FcDesigner as any)[FLAG] = true
}
@@ -0,0 +1,27 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { calcLeaveHours, DEFAULT_LEAVE_WORK_HOURS } from './oa-leave-hours'
describe('calcLeaveHours', () => {
const wh = DEFAULT_LEAVE_WORK_HOURS
it('same day morning+afternoon sample 09:00-15:00 => 5.0', () => {
assert.equal(calcLeaveHours('2026-07-30 09:00', '2026-07-30 15:00', wh), 5.0)
})
it('only morning', () => {
assert.equal(calcLeaveHours('2026-07-30 08:30', '2026-07-30 12:00', wh), 3.5)
})
it('gap across lunch not counted', () => {
assert.equal(calcLeaveHours('2026-07-30 11:00', '2026-07-30 14:00', wh), 2.0)
})
it('end before start => null', () => {
assert.equal(calcLeaveHours('2026-07-30 15:00', '2026-07-30 09:00', wh), null)
})
it('invalid => null', () => {
assert.equal(calcLeaveHours('', '2026-07-30 09:00', wh), null)
})
})
+100
View File
@@ -0,0 +1,100 @@
/** 请假套件:双段工时重叠小时数(与 backend oa_leave_hours 对齐) */
export type LeaveWorkHours = {
morningStart: string
morningEnd: string
afternoonStart: string
afternoonEnd: string
}
export const DEFAULT_LEAVE_WORK_HOURS: LeaveWorkHours = {
morningStart: '08:30',
morningEnd: '12:00',
afternoonStart: '13:00',
afternoonEnd: '17:30'
}
const DT_RE = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$/
function parseHm(hm: string): number | null {
const m = String(hm || '')
.trim()
.match(/^(\d{1,2}):(\d{2})$/)
if (!m) return null
const h = Number(m[1])
const min = Number(m[2])
if (h < 0 || h > 23 || min < 0 || min > 59) return null
return h * 60 + min
}
function parseDt(s: string): Date | null {
const m = String(s || '')
.trim()
.match(DT_RE)
if (!m) return null
const d = new Date(
Number(m[1]),
Number(m[2]) - 1,
Number(m[3]),
Number(m[4]),
Number(m[5]),
0,
0
)
return Number.isNaN(d.getTime()) ? null : d
}
function overlapMinutesOnDay(
day: Date,
rangeStart: Date,
rangeEnd: Date,
workHours: LeaveWorkHours
): number {
const y = day.getFullYear()
const mo = day.getMonth()
const da = day.getDate()
const day0 = new Date(y, mo, da, 0, 0, 0, 0).getTime()
const amA = parseHm(workHours.morningStart)
const amB = parseHm(workHours.morningEnd)
const pmA = parseHm(workHours.afternoonStart)
const pmB = parseHm(workHours.afternoonEnd)
if (amA == null || amB == null || pmA == null || pmB == null) return 0
if (amA >= amB || pmA >= pmB) return 0
const segments: [number, number][] = [
[day0 + amA * 60000, day0 + amB * 60000],
[day0 + pmA * 60000, day0 + pmB * 60000]
]
const rs = rangeStart.getTime()
const re = rangeEnd.getTime()
let total = 0
for (const [a, b] of segments) {
const lo = Math.max(rs, a)
const hi = Math.min(re, b)
if (hi > lo) total += (hi - lo) / 60000
}
return total
}
/**
* 计算请假时长(小时,1 位小数)。
* end <= start 或非法输入返回 null。
*/
export function calcLeaveHours(
startStr: string,
endStr: string,
workHours: LeaveWorkHours = DEFAULT_LEAVE_WORK_HOURS
): number | null {
const start = parseDt(startStr)
const end = parseDt(endStr)
if (!start || !end || end.getTime() <= start.getTime()) return null
let minutes = 0
const cursor = new Date(start.getFullYear(), start.getMonth(), start.getDate())
const last = new Date(end.getFullYear(), end.getMonth(), end.getDate())
while (cursor.getTime() <= last.getTime()) {
minutes += overlapMinutesOnDay(cursor, start, end, workHours)
cursor.setDate(cursor.getDate() + 1)
}
return Math.round((minutes / 60) * 10) / 10
}
+153
View File
@@ -0,0 +1,153 @@
import { DEFAULT_LEAVE_WORK_HOURS } from '@/utils/oa-leave-hours'
/** 与 FormCreate 官方 unique 同类 */
let _uidSeq = 0
function uniqueId() {
const num = 370 + ++_uidSeq
return (
'F' +
Math.random().toString(36).slice(3, 6) +
Number(`${Date.now()}`).toString(36) +
num.toString(36) +
'k'
)
}
export const LEAVE_TYPE_OPTIONS = [
{ label: '事假', value: '事假' },
{ label: '调休', value: '调休' },
{ label: '病假', value: '病假' },
{ label: '年假', value: '年假' },
{ label: '产假', value: '产假' },
{ label: '陪产假', value: '陪产假' },
{ label: '婚假', value: '婚假' },
{ label: '例假', value: '例假' },
{ label: '丧假', value: '丧假' },
{ label: '哺乳假', value: '哺乳假' }
]
function buildLeaveKitChildren() {
const typeField = uniqueId()
const startField = uniqueId()
const endField = uniqueId()
const durationField = uniqueId()
return {
startField,
endField,
durationField,
typeField,
children: [
{
type: 'select',
field: typeField,
title: '请假类型',
info: '',
$required: true,
options: LEAVE_TYPE_OPTIONS.map((o) => ({ ...o })),
props: {
placeholder: '请选择请假类型',
clearable: true
}
},
{
type: 'datePicker',
field: startField,
title: '开始时间',
info: '',
$required: true,
props: {
type: 'datetime',
format: 'YYYY-MM-DD HH:mm',
valueFormat: 'YYYY-MM-DD HH:mm',
placeholder: '请选择开始时间'
}
},
{
type: 'datePicker',
field: endField,
title: '结束时间',
info: '',
$required: true,
props: {
type: 'datetime',
format: 'YYYY-MM-DD HH:mm',
valueFormat: 'YYYY-MM-DD HH:mm',
placeholder: '请选择结束时间'
}
},
{
type: 'inputNumber',
field: durationField,
title: '时长(h)',
info: '可按工时自动计算;手改后不再自动联动',
$required: true,
props: {
precision: 1,
min: 0,
controlsPosition: 'right',
align: 'left'
},
class: 'oa-leave-duration'
}
]
}
}
/** FormCreate Designer DragRule:请假套件 */
export const leaveKitDragRule = {
menu: 'kit' as const,
icon: 'icon-date',
label: '请假',
name: 'leaveKit',
input: false,
drag: true,
dragBtn: true,
mask: false,
inside: true,
rule() {
const built = buildLeaveKitChildren()
return {
type: 'leaveKit',
props: {
...DEFAULT_LEAVE_WORK_HOURS,
startField: built.startField,
endField: built.endField,
durationField: built.durationField,
typeField: built.typeField
},
children: built.children
}
},
props() {
return [
{
type: 'input',
field: 'morningStart',
title: '上午开始',
value: DEFAULT_LEAVE_WORK_HOURS.morningStart,
props: { placeholder: 'HH:mm' }
},
{
type: 'input',
field: 'morningEnd',
title: '上午结束',
value: DEFAULT_LEAVE_WORK_HOURS.morningEnd,
props: { placeholder: 'HH:mm' }
},
{
type: 'input',
field: 'afternoonStart',
title: '下午开始',
value: DEFAULT_LEAVE_WORK_HOURS.afternoonStart,
props: { placeholder: 'HH:mm' }
},
{
type: 'input',
field: 'afternoonEnd',
title: '下午结束',
value: DEFAULT_LEAVE_WORK_HOURS.afternoonEnd,
props: { placeholder: 'HH:mm' }
}
]
}
}
+212
View File
@@ -0,0 +1,212 @@
import {
calcLeaveHours,
DEFAULT_LEAVE_WORK_HOURS,
type LeaveWorkHours
} from '@/utils/oa-leave-hours'
export const LEAVE_MANUAL_PREFIX = '__manual_'
export type LeaveKitBinding = {
startField: string
endField: string
durationField: string
workHours: LeaveWorkHours
}
function asRules(schema: unknown): Record<string, unknown>[] {
if (Array.isArray(schema)) return schema.filter((r) => r && typeof r === 'object') as any
if (schema && typeof schema === 'object') {
const s = schema as any
const rules = s.rule || s.rules || s.fields
if (Array.isArray(rules)) return rules.filter((r: any) => r && typeof r === 'object')
if (s.type || s.field) return [s]
}
return []
}
function walk(rules: Record<string, unknown>[], out: Record<string, unknown>[]) {
for (const r of rules) {
out.push(r)
const children = r.children
if (Array.isArray(children)) {
walk(
children.filter((c) => c && typeof c === 'object') as Record<string, unknown>[],
out
)
}
const props = r.props as Record<string, unknown> | undefined
if (props && Array.isArray(props.rule)) {
walk(
props.rule.filter((c) => c && typeof c === 'object') as Record<string, unknown>[],
out
)
}
}
}
function resolveFields(kit: Record<string, unknown>): LeaveKitBinding | null {
const props = (kit.props && typeof kit.props === 'object' ? kit.props : {}) as Record<
string,
any
>
let startField = props.startField as string | undefined
let endField = props.endField as string | undefined
let durationField = props.durationField as string | undefined
const children = (
Array.isArray(kit.children)
? kit.children
: Array.isArray(props.rule)
? props.rule
: []
) as Record<string, any>[]
if (!startField || !endField || !durationField) {
const dateFields: string[] = []
let dur: string | undefined
for (const c of children) {
if (!c?.field) continue
const t = String(c.type || '')
if (t === 'datePicker' || t === 'date' || t === 'timePicker' || t === 'time') {
dateFields.push(String(c.field))
} else if ((t === 'inputNumber' || t === 'number') && !dur) {
dur = String(c.field)
}
}
startField = startField || dateFields[0]
endField = endField || dateFields[1]
durationField = durationField || dur
}
if (!startField || !endField || !durationField) return null
return {
startField,
endField,
durationField,
workHours: {
morningStart: String(props.morningStart || DEFAULT_LEAVE_WORK_HOURS.morningStart),
morningEnd: String(props.morningEnd || DEFAULT_LEAVE_WORK_HOURS.morningEnd),
afternoonStart: String(props.afternoonStart || DEFAULT_LEAVE_WORK_HOURS.afternoonStart),
afternoonEnd: String(props.afternoonEnd || DEFAULT_LEAVE_WORK_HOURS.afternoonEnd)
}
}
}
export function listLeaveKitBindings(schema: unknown): LeaveKitBinding[] {
const flat: Record<string, unknown>[] = []
walk(asRules(schema), flat)
const out: LeaveKitBinding[] = []
for (const r of flat) {
if (String(r.type || '') !== 'leaveKit') continue
const b = resolveFields(r)
if (b) out.push(b)
}
return out
}
/** 未手改时写回时长;返回是否有变更 */
export function applyLeaveKitDurationsToFormData(
schema: unknown,
formData: Record<string, unknown>
): { data: Record<string, unknown>; changed: boolean } {
const data = { ...formData }
let changed = false
for (const kit of listLeaveKitBindings(schema)) {
const manualKey = LEAVE_MANUAL_PREFIX + kit.durationField
if (data[manualKey]) continue
const hours = calcLeaveHours(
String(data[kit.startField] || ''),
String(data[kit.endField] || ''),
kit.workHours
)
if (hours == null) continue
if (data[kit.durationField] !== hours) {
data[kit.durationField] = hours
changed = true
}
}
return { data, changed }
}
export function markLeaveDurationManual(
formData: Record<string, unknown>,
durationField: string,
manual = true
) {
const data = { ...formData }
const key = LEAVE_MANUAL_PREFIX + durationField
if (manual) data[key] = true
else delete data[key]
return data
}
export function isLeaveDurationField(schema: unknown, field: string): boolean {
return listLeaveKitBindings(schema).some((k) => k.durationField === field)
}
export function findLeaveKitByStartOrEnd(
schema: unknown,
field: string
): LeaveKitBinding | null {
return (
listLeaveKitBindings(schema).find(
(k) => k.startField === field || k.endField === field
) || null
)
}
/**
* 运行时补齐请假套件子规则:时长数字框默认左对齐(覆盖 Element Plus 默认居中)。
* 对已发布且未带 align 的旧 schema 同样生效。
*/
export function ensureLeaveKitRuleProps(rules: unknown): any[] {
if (!Array.isArray(rules)) return []
return rules.map((raw) => {
if (!raw || typeof raw !== 'object') return raw
const rule: any = { ...(raw as object) }
if (String(rule.type || '') === 'leaveKit') {
const binding = resolveFields(rule)
const patchChildren = (list: any[]): any[] =>
list.map((c) => {
if (!c || typeof c !== 'object') return c
const child: any = { ...c }
const t = String(child.type || '')
const isDuration =
(binding && child.field === binding.durationField) ||
((t === 'inputNumber' || t === 'number') &&
String(child.title || '').includes('时长'))
if (isDuration) {
child.props = { ...(child.props || {}), align: 'left' }
const cls = String(child.class || child.className || '')
if (!cls.includes('oa-leave-duration')) {
child.class = (cls ? cls + ' ' : '') + 'oa-leave-duration'
}
}
if (Array.isArray(child.children)) {
child.children = patchChildren(child.children)
}
return child
})
if (Array.isArray(rule.children)) {
rule.children = patchChildren(rule.children)
}
if (Array.isArray(rule.props?.rule)) {
rule.props = {
...rule.props,
rule: patchChildren(rule.props.rule)
}
}
}
if (Array.isArray(rule.children)) {
rule.children = ensureLeaveKitRuleProps(rule.children)
}
if (Array.isArray(rule.props?.rule)) {
rule.props = {
...rule.props,
rule: ensureLeaveKitRuleProps(rule.props.rule)
}
}
return rule
})
}