From 3b46b10604fb05e0699d6e43e1d2f68e73e28c77 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Thu, 30 Jul 2026 17:54:48 +0800 Subject: [PATCH] feat(oa): add leave kit with auto duration calculation Co-authored-by: Cursor --- backend/app/services/oa_leave_hours.py | 228 +++++++++++++++ backend/tests/test_oa_leave_hours.py | 96 +++++++ .../plans/2026-07-30-oa-leave-kit.md | 202 +++++++++++++ .../specs/2026-07-30-oa-leave-kit-design.md | 268 ++++++++++++++++++ .../src/components/oa-approval/OaLeaveKit.vue | 29 ++ .../src/utils/oa-form-create-leave-kit.ts | 22 ++ web-admin/src/utils/oa-leave-hours.test.ts | 27 ++ web-admin/src/utils/oa-leave-hours.ts | 100 +++++++ web-admin/src/utils/oa-leave-kit-rule.ts | 153 ++++++++++ web-admin/src/utils/oa-leave-kit-runtime.ts | 212 ++++++++++++++ 10 files changed, 1337 insertions(+) create mode 100644 backend/app/services/oa_leave_hours.py create mode 100644 backend/tests/test_oa_leave_hours.py create mode 100644 docs/superpowers/plans/2026-07-30-oa-leave-kit.md create mode 100644 docs/superpowers/specs/2026-07-30-oa-leave-kit-design.md create mode 100644 web-admin/src/components/oa-approval/OaLeaveKit.vue create mode 100644 web-admin/src/utils/oa-form-create-leave-kit.ts create mode 100644 web-admin/src/utils/oa-leave-hours.test.ts create mode 100644 web-admin/src/utils/oa-leave-hours.ts create mode 100644 web-admin/src/utils/oa-leave-kit-rule.ts create mode 100644 web-admin/src/utils/oa-leave-kit-runtime.ts diff --git a/backend/app/services/oa_leave_hours.py b/backend/app/services/oa_leave_hours.py new file mode 100644 index 00000000..9a7f2c38 --- /dev/null +++ b/backend/app/services/oa_leave_hours.py @@ -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_])则仅规范化一位小数。 + """ + 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 diff --git a/backend/tests/test_oa_leave_hours.py b/backend/tests/test_oa_leave_hours.py new file mode 100644 index 00000000..1677a4d8 --- /dev/null +++ b/backend/tests/test_oa_leave_hours.py @@ -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) diff --git a/docs/superpowers/plans/2026-07-30-oa-leave-kit.md b/docs/superpowers/plans/2026-07-30-oa-leave-kit.md new file mode 100644 index 00000000..c5a6cb23 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-oa-leave-kit.md @@ -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` — 虚线框 + 标题「请假套件」+ `` +- 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_`;优先改 rule props 若 api 允许) + +手改标记落地(择一,实现时选可工作的): +1. 时长字段 `props.durationManual`(设计器可读)+ 提交时由前端把 manual 状态塞进 formData 特殊 key,后端读取;或 +2. formData 增加 `__manual_: 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)。 diff --git a/docs/superpowers/specs/2026-07-30-oa-leave-kit-design.md b/docs/superpowers/specs/2026-07-30-oa-leave-kit-design.md new file mode 100644 index 00000000..8caf42b9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-oa-leave-kit-design.md @@ -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 < end | +| 选中子字段 | 常规属性;类型字段可维护 options | +| 删除子字段 | 允许;套件不完整时发布 **警告可发**(不强制阻断) | +| 再拖一套 | **允许**;field 自动唯一 | + +### 5.3 发起 / 详情 / 打印 + +- 四个子字段按基础组件渲染与打印。 +- 套件容器不输出独立 form 值。 +- 详情只读展示已保存时长;不再因工时变更重算历史单。 + +### 5.4 条件分支 + +子字段与普通字段相同:勾选「参与流程条件分支」后可在流程设计中引用(类型、时长等)。 + +--- + +## 6. 校验清单 + +### 6.1 设计器 / 发布 + +- `leaveKit`(或约定 group 标记)加入布局类白名单。 +- 子 type 须为已允许的 `select` / `datePicker` / `inputNumber`(或项目现用等价 type)。 +- 工时四段格式 `HH:mm`;上午/下午各自 start < end;下午起点 < 上午终点时仅 **提示**(不强制)。 + +### 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` 与后端解析严格对齐。 +- 同一套件内删除「开始」后,时长字段是否自动禁用编辑提示:建议弱提示即可。 diff --git a/web-admin/src/components/oa-approval/OaLeaveKit.vue b/web-admin/src/components/oa-approval/OaLeaveKit.vue new file mode 100644 index 00000000..6beac45d --- /dev/null +++ b/web-admin/src/components/oa-approval/OaLeaveKit.vue @@ -0,0 +1,29 @@ + + + + + + diff --git a/web-admin/src/utils/oa-form-create-leave-kit.ts b/web-admin/src/utils/oa-form-create-leave-kit.ts new file mode 100644 index 00000000..702554f0 --- /dev/null +++ b/web-admin/src/utils/oa-form-create-leave-kit.ts @@ -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 +} diff --git a/web-admin/src/utils/oa-leave-hours.test.ts b/web-admin/src/utils/oa-leave-hours.test.ts new file mode 100644 index 00000000..29349c12 --- /dev/null +++ b/web-admin/src/utils/oa-leave-hours.test.ts @@ -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) + }) +}) diff --git a/web-admin/src/utils/oa-leave-hours.ts b/web-admin/src/utils/oa-leave-hours.ts new file mode 100644 index 00000000..f4c52de8 --- /dev/null +++ b/web-admin/src/utils/oa-leave-hours.ts @@ -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 +} diff --git a/web-admin/src/utils/oa-leave-kit-rule.ts b/web-admin/src/utils/oa-leave-kit-rule.ts new file mode 100644 index 00000000..41a8a954 --- /dev/null +++ b/web-admin/src/utils/oa-leave-kit-rule.ts @@ -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' } + } + ] + } +} diff --git a/web-admin/src/utils/oa-leave-kit-runtime.ts b/web-admin/src/utils/oa-leave-kit-runtime.ts new file mode 100644 index 00000000..8624896b --- /dev/null +++ b/web-admin/src/utils/oa-leave-kit-runtime.ts @@ -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[] { + 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[], out: Record[]) { + 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[], + out + ) + } + const props = r.props as Record | undefined + if (props && Array.isArray(props.rule)) { + walk( + props.rule.filter((c) => c && typeof c === 'object') as Record[], + out + ) + } + } +} + +function resolveFields(kit: Record): 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[] + + 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[] = [] + 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 +): { data: Record; 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, + 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 + }) +}