feat(oa): add leave kit with auto duration calculation
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
b3d267e9ca
commit
3b46b10604
10 files changed
+1337
No files matched your search
@@ -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
|
||||
@@ -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 < 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` 与后端解析严格对齐。
|
||||
- 同一套件内删除「开始」后,时长字段是否自动禁用编辑提示:建议弱提示即可。
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user