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,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