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

@@ -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
})
}