Prechádzať zdrojové kódy

feat(mobile-oa-h5): port OA kit runtime and wire duration sync on oa-start

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 mesiacov pred
rodič
commit
e83d925994

+ 2 - 1
docs/superpowers/specs/2026-08-11-mobile-oa-form-sink-spike-notes.md

@@ -52,4 +52,5 @@
 - [x] `GET /api/mini/menus` + SQL 种子 `docs/sql/mobile_menu_oa_start.sql` + 首页拉菜单(失败时 fallback)
 - [ ] 测试库执行 SQL 并在角色中勾选「发起审批」
 - [ ] 真机:列表 → 填写 → 草稿/提交 全链路
-- [ ] Task 8 套件运行时(请假/加班时长等)与 Web 对齐(分期)
+- [x] Task 8 套件运行时(请假/加班/出差/转正/离职/owner 默认值)已移植并接入 `/oa-start`(节假日走 `/cn-holiday/range`)
+- [ ] 真机:含套件模板与 Web 对照回归

+ 8 - 0
mobile-oa-h5/src/api/oa.js

@@ -79,4 +79,12 @@ export function fetchStartOaInstance(data) {
   return post('/web/admin/oa-approval/instances', data)
 }
 
+export function fetchCurrentUser() {
+  return get('/web/auth/current-user')
+}
+
+export function fetchCnHolidayRange(start, end) {
+  return get('/cn-holiday/range', { start, end })
+}
+
 export { post, put }

+ 40 - 0
mobile-oa-h5/src/oa/kit-runtime.js

@@ -0,0 +1,40 @@
+/** 套件运行时 re-export(自 web-admin 移植) */
+export {
+  applyLeaveKitDurationsToFormData,
+  ensureLeaveKitRuleProps,
+  LEAVE_MANUAL_PREFIX,
+  listLeaveKitBindings,
+  markLeaveDurationManual
+} from './kit/oa-leave-kit-runtime.js'
+
+export {
+  applyOvertimeKitDefaults,
+  applyOvertimeKitDurationsToFormData,
+  ensureOvertimeKitRuleProps,
+  listOvertimeKitBindings,
+  markOvertimeDurationManual,
+  OVERTIME_MANUAL_PREFIX
+} from './kit/oa-overtime-kit-runtime.js'
+
+export {
+  applyTripKitDaysToFormData,
+  listTripKits,
+  TRIP_MANUAL_PREFIX
+} from './kit/oa-trip-kit-runtime.js'
+
+export {
+  applyConfirmationKitDateLinkage,
+  applyConfirmationKitDefaults,
+  ensureConfirmationKitVisibility,
+  listConfirmationKitBindings,
+  listManualConfirmationDateFields,
+  markConfirmationDateManual,
+  CONFIRMATION_MANUAL_PREFIX
+} from './kit/oa-confirmation-kit-runtime.js'
+
+export { applyResignationKitDefaults } from './kit/oa-resignation-kit-runtime.js'
+
+export {
+  applyOwnerFieldDefaults,
+  ensureOwnerFieldRuleProps
+} from './kit/oa-owner-field-defaults.js'

+ 16 - 0
mobile-oa-h5/src/oa/kit/oa-confirmation-date.js

@@ -0,0 +1,16 @@
+function addMonthsYmd(hireYmd, months) {
+  const s = String(hireYmd || "").trim();
+  if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
+  if (!Number.isInteger(months) || months < 0) return null;
+  const [y, m, d] = s.split("-").map((x) => Number(x));
+  if (!y || !m || !d) return null;
+  const total = y * 12 + (m - 1) + months;
+  const ny = Math.floor(total / 12);
+  const nm = total % 12 + 1;
+  const lastDay = new Date(ny, nm, 0).getDate();
+  const nd = Math.min(d, lastDay);
+  return `${ny}-${String(nm).padStart(2, "0")}-${String(nd).padStart(2, "0")}`;
+}
+export {
+  addMonthsYmd
+};

+ 208 - 0
mobile-oa-h5/src/oa/kit/oa-confirmation-kit-rule.js

@@ -0,0 +1,208 @@
+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) + "r";
+}
+const CONFIRMATION_KIT_FIELD_DEFS = [
+  { key: "hireDate", title: "\u5165\u804C\u65E5\u671F", showProp: "showHireDate" },
+  { key: "probationMonths", title: "\u8BD5\u7528\u671F\uFF08\u6708\uFF09", showProp: "showProbationMonths" },
+  { key: "confirmationDate", title: "\u8F6C\u6B63\u65E5\u671F", showProp: "showConfirmationDate" },
+  { key: "post", title: "\u804C\u4F4D", showProp: "showPost" },
+  { key: "jobLevel", title: "\u5C97\u4F4D\u804C\u7EA7", showProp: "showJobLevel" },
+  { key: "name", title: "\u59D3\u540D", showProp: "showName" },
+  { key: "email", title: "\u90AE\u7BB1", showProp: "showEmail" },
+  { key: "dept", title: "\u90E8\u95E8", showProp: "showDept" },
+  { key: "phone", title: "\u624B\u673A\u53F7", showProp: "showPhone" }
+];
+function buildConfirmationKitChildren() {
+  const hireDateField = uniqueId();
+  const probationMonthsField = uniqueId();
+  const confirmationDateField = uniqueId();
+  const postField = uniqueId();
+  const jobLevelField = uniqueId();
+  const nameField = uniqueId();
+  const emailField = uniqueId();
+  const deptField = uniqueId();
+  const phoneField = uniqueId();
+  return {
+    hireDateField,
+    probationMonthsField,
+    confirmationDateField,
+    postField,
+    jobLevelField,
+    nameField,
+    emailField,
+    deptField,
+    phoneField,
+    children: [
+      {
+        type: "datePicker",
+        field: hireDateField,
+        title: "\u5165\u804C\u65E5\u671F",
+        info: "",
+        $required: true,
+        props: {
+          _fc_kit_field: "hireDate",
+          type: "date",
+          format: "YYYY-MM-DD",
+          valueFormat: "YYYY-MM-DD",
+          placeholder: "\u8BF7\u9009\u62E9\u5165\u804C\u65E5\u671F"
+        }
+      },
+      {
+        type: "inputNumber",
+        field: probationMonthsField,
+        title: "\u8BD5\u7528\u671F\uFF08\u6708\uFF09",
+        info: "",
+        $required: true,
+        props: {
+          _fc_kit_field: "probationMonths",
+          min: 0,
+          precision: 0,
+          controlsPosition: "right"
+        }
+      },
+      {
+        type: "datePicker",
+        field: confirmationDateField,
+        title: "\u8F6C\u6B63\u65E5\u671F",
+        info: "\u53EF\u6839\u636E\u5165\u804C\u65E5\u671F\u4E0E\u8BD5\u7528\u671F\u81EA\u52A8\u5EFA\u8BAE\uFF1B\u624B\u6539\u540E\u4E0D\u518D\u81EA\u52A8\u8054\u52A8",
+        $required: false,
+        props: {
+          _fc_kit_field: "confirmationDate",
+          type: "date",
+          format: "YYYY-MM-DD",
+          valueFormat: "YYYY-MM-DD",
+          placeholder: "\u8BF7\u9009\u62E9\u8F6C\u6B63\u65E5\u671F"
+        }
+      },
+      {
+        type: "post",
+        field: postField,
+        title: "\u804C\u4F4D",
+        info: "",
+        $required: false,
+        props: {
+          _fc_kit_field: "post",
+          multiple: true
+        }
+      },
+      {
+        type: "input",
+        field: jobLevelField,
+        title: "\u5C97\u4F4D\u804C\u7EA7",
+        info: "",
+        $required: false,
+        props: {
+          _fc_kit_field: "jobLevel",
+          placeholder: "\u8BF7\u8F93\u5165\u5C97\u4F4D\u804C\u7EA7"
+        }
+      },
+      {
+        type: "input",
+        field: nameField,
+        title: "\u59D3\u540D",
+        info: "",
+        $required: true,
+        props: {
+          _fc_kit_field: "name",
+          placeholder: "\u8BF7\u8F93\u5165\u59D3\u540D"
+        }
+      },
+      {
+        type: "input",
+        field: emailField,
+        title: "\u90AE\u7BB1",
+        info: "",
+        $required: false,
+        props: {
+          _fc_kit_field: "email",
+          placeholder: "\u8BF7\u8F93\u5165\u90AE\u7BB1"
+        }
+      },
+      {
+        type: "dept",
+        field: deptField,
+        title: "\u90E8\u95E8",
+        info: "",
+        $required: true,
+        props: {
+          _fc_kit_field: "dept",
+          multiple: false
+        }
+      },
+      {
+        type: "input",
+        field: phoneField,
+        title: "\u624B\u673A\u53F7",
+        info: "",
+        $required: false,
+        props: {
+          _fc_kit_field: "phone",
+          placeholder: "\u8BF7\u8F93\u5165\u624B\u673A\u53F7"
+        }
+      }
+    ]
+  };
+}
+const confirmationKitDragRule = {
+  menu: "kit",
+  icon: "icon-idcard",
+  label: "\u8F6C\u6B63",
+  name: "confirmationKit",
+  input: false,
+  drag: true,
+  dragBtn: true,
+  mask: false,
+  inside: true,
+  rule() {
+    const built = buildConfirmationKitChildren();
+    return {
+      type: "confirmationKit",
+      props: {
+        showHireDate: true,
+        showProbationMonths: true,
+        showConfirmationDate: true,
+        showPost: true,
+        showJobLevel: true,
+        showName: true,
+        showEmail: true,
+        showDept: true,
+        showPhone: true,
+        hireDateField: built.hireDateField,
+        probationMonthsField: built.probationMonthsField,
+        confirmationDateField: built.confirmationDateField,
+        postField: built.postField,
+        jobLevelField: built.jobLevelField,
+        nameField: built.nameField,
+        emailField: built.emailField,
+        deptField: built.deptField,
+        phoneField: built.phoneField
+      },
+      children: built.children
+    };
+  },
+  props() {
+    const switches = CONFIRMATION_KIT_FIELD_DEFS.map((d) => ({
+      type: "switch",
+      field: d.showProp,
+      title: `\u663E\u793A\uFF1A${d.title}`,
+      value: true,
+      props: { activeText: "\u663E\u793A", inactiveText: "\u9690\u85CF" }
+    }));
+    return [
+      ...switches,
+      {
+        type: "input",
+        field: "formCreateInfo",
+        title: "\u63D0\u793A",
+        value: "\u9690\u85CF\u5B57\u6BB5\u4ECD\u4F1A\u5199\u5165\u9ED8\u8BA4\u503C\uFF1B\u5BA1\u6279\u901A\u8FC7\u540E\u53EF\u80FD\u56DE\u5199\u7528\u6237\u4E3B\u6570\u636E\u3002",
+        props: { disabled: true, type: "textarea", rows: 2 }
+      }
+    ];
+  }
+};
+export {
+  CONFIRMATION_KIT_FIELD_DEFS,
+  confirmationKitDragRule
+};

+ 235 - 0
mobile-oa-h5/src/oa/kit/oa-confirmation-kit-runtime.js

@@ -0,0 +1,235 @@
+import { addMonthsYmd } from "./oa-confirmation-date.js";
+import {
+  CONFIRMATION_KIT_FIELD_DEFS
+} from "./oa-confirmation-kit-rule.js";
+import { resolveCurrentOwnerDept } from "./oa-owner-field-defaults.js";
+const CONFIRMATION_MANUAL_PREFIX = "__manual_";
+function asRules(schema) {
+  if (Array.isArray(schema)) return schema.filter((r) => r && typeof r === "object");
+  if (schema && typeof schema === "object") {
+    const s = schema;
+    const rules = s.rule || s.rules || s.fields;
+    if (Array.isArray(rules)) return rules.filter((r) => r && typeof r === "object");
+    if (s.type || s.field) return [s];
+  }
+  return [];
+}
+function walk(rules, out) {
+  for (const r of rules) {
+    out.push(r);
+    const children = r.children;
+    if (Array.isArray(children)) {
+      walk(
+        children.filter((c) => c && typeof c === "object"),
+        out
+      );
+    }
+    const props = r.props;
+    if (props && Array.isArray(props.rule)) {
+      walk(
+        props.rule.filter((c) => c && typeof c === "object"),
+        out
+      );
+    }
+  }
+}
+function kitChildren(kit) {
+  const props = kit.props && typeof kit.props === "object" ? kit.props : {};
+  if (Array.isArray(kit.children)) return kit.children;
+  if (Array.isArray(props.rule)) return props.rule;
+  return [];
+}
+function resolveBinding(kit) {
+  const props = kit.props && typeof kit.props === "object" ? kit.props : {};
+  const fields = {};
+  const propFieldMap = {
+    hireDateField: "hireDate",
+    probationMonthsField: "probationMonths",
+    confirmationDateField: "confirmationDate",
+    postField: "post",
+    jobLevelField: "jobLevel",
+    nameField: "name",
+    emailField: "email",
+    deptField: "dept",
+    phoneField: "phone"
+  };
+  for (const [propKey, sem] of Object.entries(propFieldMap)) {
+    const f = props[propKey];
+    if (f) fields[sem] = String(f);
+  }
+  for (const c of kitChildren(kit)) {
+    if (!c?.field) continue;
+    const sem = String(c.props?._fc_kit_field || "");
+    if (CONFIRMATION_KIT_FIELD_DEFS.some((d) => d.key === sem) && !fields[sem]) {
+      fields[sem] = String(c.field);
+    }
+  }
+  const visible = {};
+  for (const d of CONFIRMATION_KIT_FIELD_DEFS) {
+    const v = props[d.showProp];
+    visible[d.key] = v === void 0 || v === null ? true : !!v;
+  }
+  return {
+    fields,
+    visible,
+    confirmationDateField: fields.confirmationDate,
+    hireDateField: fields.hireDate,
+    probationMonthsField: fields.probationMonths
+  };
+}
+function listConfirmationKitBindings(schema) {
+  const flat = [];
+  walk(asRules(schema), flat);
+  const out = [];
+  for (const r of flat) {
+    if (String(r.type || "") !== "confirmationKit") continue;
+    out.push(resolveBinding(r));
+  }
+  return out;
+}
+function isEmptyValue(raw) {
+  if (raw == null || raw === "") return true;
+  if (Array.isArray(raw)) return raw.length === 0;
+  if (typeof raw === "object") {
+    const o = raw;
+    return !String(o.id || "").trim();
+  }
+  return false;
+}
+function ensureConfirmationKitVisibility(rules) {
+  if (!Array.isArray(rules)) return [];
+  return rules.map((raw) => {
+    if (!raw || typeof raw !== "object") return raw;
+    const rule = { ...raw };
+    if (String(rule.type || "") === "confirmationKit") {
+      const binding = resolveBinding(rule);
+      const patchChildren = (list) => list.map((c) => {
+        if (!c || typeof c !== "object") return c;
+        const child = { ...c };
+        const sem = String(child.props?._fc_kit_field || "");
+        const show = sem && CONFIRMATION_KIT_FIELD_DEFS.some((d) => d.key === sem) ? binding.visible[sem] !== false : true;
+        child.hidden = !show;
+        if (!show) {
+          child.$required = false;
+          if (child.validate) child.validate = [];
+        }
+        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 = ensureConfirmationKitVisibility(rule.children);
+    }
+    if (Array.isArray(rule.props?.rule)) {
+      rule.props = {
+        ...rule.props,
+        rule: ensureConfirmationKitVisibility(rule.props.rule)
+      };
+    }
+    return rule;
+  });
+}
+async function applyConfirmationKitDefaults(schema, formData, user) {
+  const data = { ...formData };
+  const kits = listConfirmationKitBindings(schema);
+  if (!kits.length || !user) return data;
+  const name = String(user.realName || user.nickName || user.userName || "").trim();
+  const email = String(user.email || "").trim();
+  const phone = String(user.phone || user.phoneNo || "").trim();
+  const hireDate = String(user.hireDate || "").trim();
+  const probationRaw = user.probationMonths;
+  const probationMonths = probationRaw === null || probationRaw === void 0 || probationRaw === "" ? null : Number(probationRaw);
+  const dept = await resolveCurrentOwnerDept(user);
+  for (const kit of kits) {
+    const setIfEmpty = (sem, value) => {
+      const field = kit.fields[sem];
+      if (!field) return;
+      if (!isEmptyValue(data[field])) return;
+      if (value === null || value === void 0 || value === "") return;
+      data[field] = value;
+    };
+    setIfEmpty("hireDate", hireDate || null);
+    if (probationMonths !== null && Number.isFinite(probationMonths) && probationMonths >= 0) {
+      setIfEmpty("probationMonths", Math.trunc(probationMonths));
+    }
+    setIfEmpty("name", name || null);
+    setIfEmpty("email", email || null);
+    setIfEmpty("phone", phone || null);
+    if (dept) setIfEmpty("dept", dept);
+    const confField = kit.confirmationDateField;
+    const hireField = kit.hireDateField;
+    const probField = kit.probationMonthsField;
+    if (confField && isEmptyValue(data[confField]) && hireField && probField) {
+      const suggested = addMonthsYmd(
+        String(data[hireField] || ""),
+        Number(data[probField])
+      );
+      if (suggested) data[confField] = suggested;
+    }
+  }
+  return data;
+}
+function applyConfirmationKitDateLinkage(schema, formData) {
+  const data = { ...formData };
+  let changed = false;
+  for (const kit of listConfirmationKitBindings(schema)) {
+    const confField = kit.confirmationDateField;
+    const hireField = kit.hireDateField;
+    const probField = kit.probationMonthsField;
+    if (!confField || !hireField || !probField) continue;
+    if (data[CONFIRMATION_MANUAL_PREFIX + confField]) continue;
+    const suggested = addMonthsYmd(String(data[hireField] || ""), Number(data[probField]));
+    if (suggested == null) continue;
+    if (data[confField] !== suggested) {
+      data[confField] = suggested;
+      changed = true;
+    }
+  }
+  return { data, changed };
+}
+function markConfirmationDateManual(formData, confirmationDateField, manual = true) {
+  const data = { ...formData };
+  const key = CONFIRMATION_MANUAL_PREFIX + confirmationDateField;
+  if (manual) data[key] = true;
+  else delete data[key];
+  return data;
+}
+function clearConfirmationDateManualAndRelink(schema, formData, confirmationDateField) {
+  let data = markConfirmationDateManual(formData, confirmationDateField, false);
+  const linked = applyConfirmationKitDateLinkage(schema, data);
+  return linked.data;
+}
+function listManualConfirmationDateFields(schema, formData) {
+  return listConfirmationKitBindings(schema).map((k) => k.confirmationDateField).filter((f) => !!f && !!formData[CONFIRMATION_MANUAL_PREFIX + f]);
+}
+function isConfirmationDateField(schema, field) {
+  return listConfirmationKitBindings(schema).some((k) => k.confirmationDateField === field);
+}
+function findConfirmationKitByHireOrProbation(schema, field) {
+  return listConfirmationKitBindings(schema).find(
+    (k) => k.hireDateField === field || k.probationMonthsField === field
+  ) || null;
+}
+export {
+  CONFIRMATION_MANUAL_PREFIX,
+  applyConfirmationKitDateLinkage,
+  applyConfirmationKitDefaults,
+  clearConfirmationDateManualAndRelink,
+  ensureConfirmationKitVisibility,
+  findConfirmationKitByHireOrProbation,
+  isConfirmationDateField,
+  listConfirmationKitBindings,
+  listManualConfirmationDateFields,
+  markConfirmationDateManual
+};

+ 38 - 0
mobile-oa-h5/src/oa/kit/oa-dept-value.js

@@ -0,0 +1,38 @@
+function isDeptValueItem(v) {
+  if (!v || typeof v !== "object") return false;
+  const o = v;
+  return String(o.id || "").trim() !== "" && String(o.name || "").trim() !== "";
+}
+function normalizeDeptModelValue(raw, multiple) {
+  if (multiple) {
+    if (Array.isArray(raw)) {
+      return raw.filter(isDeptValueItem).map((x) => ({
+        id: String(x.id).trim(),
+        name: String(x.name).trim()
+      }));
+    }
+    if (isDeptValueItem(raw)) {
+      return [{ id: String(raw.id).trim(), name: String(raw.name).trim() }];
+    }
+    return [];
+  }
+  if (isDeptValueItem(raw)) {
+    return { id: String(raw.id).trim(), name: String(raw.name).trim() };
+  }
+  if (Array.isArray(raw) && raw.length === 1 && isDeptValueItem(raw[0])) {
+    return { id: String(raw[0].id).trim(), name: String(raw[0].name).trim() };
+  }
+  return null;
+}
+function formatDeptDisplay(raw) {
+  if (Array.isArray(raw)) {
+    return raw.filter(isDeptValueItem).map((x) => x.name).filter(Boolean).join("\u3001");
+  }
+  if (isDeptValueItem(raw)) return raw.name;
+  return "";
+}
+export {
+  formatDeptDisplay,
+  isDeptValueItem,
+  normalizeDeptModelValue
+};

+ 71 - 0
mobile-oa-h5/src/oa/kit/oa-leave-hours.js

@@ -0,0 +1,71 @@
+const DEFAULT_LEAVE_WORK_HOURS = {
+  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) {
+  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) {
+  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, rangeStart, rangeEnd, workHours) {
+  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 = [
+    [day0 + amA * 6e4, day0 + amB * 6e4],
+    [day0 + pmA * 6e4, day0 + pmB * 6e4]
+  ];
+  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) / 6e4;
+  }
+  return total;
+}
+function calcLeaveHours(startStr, endStr, workHours = DEFAULT_LEAVE_WORK_HOURS) {
+  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;
+}
+export {
+  DEFAULT_LEAVE_WORK_HOURS,
+  calcLeaveHours
+};

+ 169 - 0
mobile-oa-h5/src/oa/kit/oa-leave-kit-runtime.js

@@ -0,0 +1,169 @@
+import {
+  calcLeaveHours,
+  DEFAULT_LEAVE_WORK_HOURS
+} from "./oa-leave-hours.js";
+const LEAVE_MANUAL_PREFIX = "__manual_";
+function asRules(schema) {
+  if (Array.isArray(schema)) return schema.filter((r) => r && typeof r === "object");
+  if (schema && typeof schema === "object") {
+    const s = schema;
+    const rules = s.rule || s.rules || s.fields;
+    if (Array.isArray(rules)) return rules.filter((r) => r && typeof r === "object");
+    if (s.type || s.field) return [s];
+  }
+  return [];
+}
+function walk(rules, out) {
+  for (const r of rules) {
+    out.push(r);
+    const children = r.children;
+    if (Array.isArray(children)) {
+      walk(
+        children.filter((c) => c && typeof c === "object"),
+        out
+      );
+    }
+    const props = r.props;
+    if (props && Array.isArray(props.rule)) {
+      walk(
+        props.rule.filter((c) => c && typeof c === "object"),
+        out
+      );
+    }
+  }
+}
+function resolveFields(kit) {
+  const props = kit.props && typeof kit.props === "object" ? kit.props : {};
+  let startField = props.startField;
+  let endField = props.endField;
+  let durationField = props.durationField;
+  const children = Array.isArray(kit.children) ? kit.children : Array.isArray(props.rule) ? props.rule : [];
+  if (!startField || !endField || !durationField) {
+    const dateFields = [];
+    let dur;
+    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)
+    }
+  };
+}
+function listLeaveKitBindings(schema) {
+  const flat = [];
+  walk(asRules(schema), flat);
+  const out = [];
+  for (const r of flat) {
+    if (String(r.type || "") !== "leaveKit") continue;
+    const b = resolveFields(r);
+    if (b) out.push(b);
+  }
+  return out;
+}
+function applyLeaveKitDurationsToFormData(schema, formData) {
+  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 };
+}
+function markLeaveDurationManual(formData, durationField, manual = true) {
+  const data = { ...formData };
+  const key = LEAVE_MANUAL_PREFIX + durationField;
+  if (manual) data[key] = true;
+  else delete data[key];
+  return data;
+}
+function isLeaveDurationField(schema, field) {
+  return listLeaveKitBindings(schema).some((k) => k.durationField === field);
+}
+function findLeaveKitByStartOrEnd(schema, field) {
+  return listLeaveKitBindings(schema).find(
+    (k) => k.startField === field || k.endField === field
+  ) || null;
+}
+function ensureLeaveKitRuleProps(rules) {
+  if (!Array.isArray(rules)) return [];
+  return rules.map((raw) => {
+    if (!raw || typeof raw !== "object") return raw;
+    const rule = { ...raw };
+    if (String(rule.type || "") === "leaveKit") {
+      const binding = resolveFields(rule);
+      const patchChildren = (list) => list.map((c) => {
+        if (!c || typeof c !== "object") return c;
+        const child = { ...c };
+        const t = String(child.type || "");
+        const isDuration = binding && child.field === binding.durationField || (t === "inputNumber" || t === "number") && String(child.title || "").includes("\u65F6\u957F");
+        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;
+  });
+}
+export {
+  LEAVE_MANUAL_PREFIX,
+  applyLeaveKitDurationsToFormData,
+  ensureLeaveKitRuleProps,
+  findLeaveKitByStartOrEnd,
+  isLeaveDurationField,
+  listLeaveKitBindings,
+  markLeaveDurationManual
+};

+ 153 - 0
mobile-oa-h5/src/oa/kit/oa-overtime-hours.js

@@ -0,0 +1,153 @@
+const DEFAULT_OVERTIME_WINDOWS = {
+  workdayOvertimeStart: "17:30",
+  workdayOvertimeEnd: "08:30",
+  restBreakStart: "11:30",
+  restBreakEnd: "13:30"
+};
+const DT_RE = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$/;
+function parseHm(hm) {
+  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) {
+  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 formatYmd(d) {
+  const y = d.getFullYear();
+  const mo = String(d.getMonth() + 1).padStart(2, "0");
+  const da = String(d.getDate()).padStart(2, "0");
+  return `${y}-${mo}-${da}`;
+}
+function dayStartMs(d) {
+  return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
+}
+function dayAtMinutes(d, minutes) {
+  return dayStartMs(d) + minutes * 6e4;
+}
+function mergeIntervals(intervals) {
+  if (intervals.length === 0) return [];
+  const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
+  const merged = [[sorted[0][0], sorted[0][1]]];
+  for (let i = 1; i < sorted.length; i++) {
+    const last = merged[merged.length - 1];
+    const [s, e] = sorted[i];
+    if (s <= last[1]) {
+      last[1] = Math.max(last[1], e);
+    } else {
+      merged.push([s, e]);
+    }
+  }
+  return merged;
+}
+function intersectInterval(a, b) {
+  const lo = Math.max(a[0], b[0]);
+  const hi = Math.min(a[1], b[1]);
+  return hi > lo ? [lo, hi] : null;
+}
+function subtractInterval(base, sub) {
+  const [bs, be] = base;
+  const [ss, se] = sub;
+  if (se <= bs || ss >= be) return [base];
+  const out = [];
+  if (ss > bs) out.push([bs, ss]);
+  if (se < be) out.push([se, be]);
+  return out;
+}
+function subtractIntervals(base, subs) {
+  let result = [base];
+  for (const sub of subs) {
+    const next = [];
+    for (const r of result) {
+      next.push(...subtractInterval(r, sub));
+    }
+    result = next;
+  }
+  return result;
+}
+function intervalMinutes(intervals) {
+  return intervals.reduce((sum, [a, b]) => sum + (b - a) / 6e4, 0);
+}
+function buildWorkdayWindows(anchorDay, windows) {
+  const startMin = parseHm(windows.workdayOvertimeStart);
+  const endMin = parseHm(windows.workdayOvertimeEnd);
+  if (startMin == null || endMin == null) return null;
+  const anchorStart = dayAtMinutes(anchorDay, startMin);
+  const nextDay = new Date(anchorDay);
+  nextDay.setDate(nextDay.getDate() + 1);
+  const anchorEnd = dayAtMinutes(nextDay, endMin);
+  if (anchorEnd <= anchorStart) return null;
+  return [anchorStart, anchorEnd];
+}
+function buildRestDaySegments(day, windows) {
+  const breakStart = parseHm(windows.restBreakStart);
+  const breakEnd = parseHm(windows.restBreakEnd);
+  if (breakStart == null || breakEnd == null) return [];
+  const day0 = dayStartMs(day);
+  const nextDay = new Date(day);
+  nextDay.setDate(nextDay.getDate() + 1);
+  const dayEnd = dayStartMs(nextDay);
+  const segments = [];
+  if (breakStart > 0) segments.push([day0, dayAtMinutes(day, breakStart)]);
+  if (breakEnd < 24 * 60) segments.push([dayAtMinutes(day, breakEnd), dayEnd]);
+  return segments.filter(([a, b]) => b > a);
+}
+function calcOvertimeHours(startStr, endStr, windows = DEFAULT_OVERTIME_WINDOWS, isRestDay) {
+  const start = parseDt(startStr);
+  const end = parseDt(endStr);
+  if (!start || !end || end.getTime() <= start.getTime()) return null;
+  const query = [start.getTime(), end.getTime()];
+  const startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate());
+  const endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate());
+  const workdayIntervals = [];
+  const anchor = new Date(startDay);
+  anchor.setDate(anchor.getDate() - 1);
+  const lastAnchor = new Date(endDay);
+  while (anchor.getTime() <= lastAnchor.getTime()) {
+    const ymd = formatYmd(anchor);
+    if (!isRestDay(ymd)) {
+      const win = buildWorkdayWindows(anchor, windows);
+      if (win) {
+        const hit = intersectInterval(win, query);
+        if (hit) workdayIntervals.push(hit);
+      }
+    }
+    anchor.setDate(anchor.getDate() + 1);
+  }
+  const workdayMerged = mergeIntervals(workdayIntervals);
+  const restIntervals = [];
+  const day = new Date(startDay);
+  while (day.getTime() <= endDay.getTime()) {
+    const ymd = formatYmd(day);
+    if (isRestDay(ymd)) {
+      for (const seg of buildRestDaySegments(day, windows)) {
+        const hit = intersectInterval(seg, query);
+        if (hit) {
+          const remaining = subtractIntervals(hit, workdayMerged);
+          restIntervals.push(...remaining);
+        }
+      }
+    }
+    day.setDate(day.getDate() + 1);
+  }
+  const totalMinutes = intervalMinutes(workdayMerged) + intervalMinutes(restIntervals);
+  return Math.round(totalMinutes / 60 * 10) / 10;
+}
+export {
+  DEFAULT_OVERTIME_WINDOWS,
+  calcOvertimeHours
+};

+ 214 - 0
mobile-oa-h5/src/oa/kit/oa-overtime-kit-runtime.js

@@ -0,0 +1,214 @@
+import {
+  buildCurrentOwnerPerson
+} from "./oa-owner-field-defaults.js";
+import {
+  calcOvertimeHours,
+  DEFAULT_OVERTIME_WINDOWS
+} from "./oa-overtime-hours.js";
+const OVERTIME_MANUAL_PREFIX = "__manual_";
+function asRules(schema) {
+  if (Array.isArray(schema)) return schema.filter((r) => r && typeof r === "object");
+  if (schema && typeof schema === "object") {
+    const s = schema;
+    const rules = s.rule || s.rules || s.fields;
+    if (Array.isArray(rules)) return rules.filter((r) => r && typeof r === "object");
+    if (s.type || s.field) return [s];
+  }
+  return [];
+}
+function walk(rules, out) {
+  for (const r of rules) {
+    out.push(r);
+    if (Array.isArray(r.children)) {
+      walk(r.children, out);
+    }
+    const props = r.props;
+    if (props && Array.isArray(props.rule)) walk(props.rule, out);
+  }
+}
+function resolveBinding(kit) {
+  const props = kit.props && typeof kit.props === "object" ? kit.props : {};
+  let personField = props.personField ? String(props.personField) : void 0;
+  let startField = props.startField ? String(props.startField) : void 0;
+  let endField = props.endField ? String(props.endField) : void 0;
+  let durationField = props.durationField ? String(props.durationField) : void 0;
+  const children = Array.isArray(kit.children) ? kit.children : Array.isArray(props.rule) ? props.rule : [];
+  for (const c of children) {
+    if (!c?.field) continue;
+    const sem = String(c.props?._fc_kit_field || "");
+    if (sem === "overtimePerson" && !personField) personField = String(c.field);
+    if (sem === "overtimeStart" && !startField) startField = String(c.field);
+    if (sem === "overtimeEnd" && !endField) endField = String(c.field);
+    if (sem === "overtimeDuration" && !durationField) durationField = String(c.field);
+  }
+  if (!startField || !endField || !durationField) {
+    const dateFields = [];
+    let dur;
+    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") && (String(c.title || "").includes("\u65F6\u957F") || String(c.props?._fc_kit_field || "") === "overtimeDuration")) {
+        dur = String(c.field);
+      }
+    }
+    startField = startField || dateFields[0];
+    endField = endField || dateFields[1];
+    durationField = durationField || dur;
+  }
+  if (!startField || !endField || !durationField) return null;
+  return {
+    personField,
+    startField,
+    endField,
+    durationField,
+    windows: {
+      workdayOvertimeStart: String(
+        props.workdayOvertimeStart || DEFAULT_OVERTIME_WINDOWS.workdayOvertimeStart
+      ),
+      workdayOvertimeEnd: String(
+        props.workdayOvertimeEnd || DEFAULT_OVERTIME_WINDOWS.workdayOvertimeEnd
+      ),
+      restBreakStart: String(props.restBreakStart || DEFAULT_OVERTIME_WINDOWS.restBreakStart),
+      restBreakEnd: String(props.restBreakEnd || DEFAULT_OVERTIME_WINDOWS.restBreakEnd)
+    }
+  };
+}
+function listOvertimeKitBindings(schema) {
+  const flat = [];
+  walk(asRules(schema), flat);
+  const out = [];
+  for (const r of flat) {
+    if (String(r.type || "") !== "overtimeKit") continue;
+    const b = resolveBinding(r);
+    if (b) out.push(b);
+  }
+  return out;
+}
+function isEmptyPersonValue(raw) {
+  if (raw == null || raw === "") return true;
+  if (Array.isArray(raw)) return raw.length === 0;
+  if (typeof raw === "object") {
+    const o = raw;
+    return !String(o.id || "").trim();
+  }
+  return false;
+}
+function applyOvertimeKitDefaults(schema, formData, user) {
+  const data = { ...formData };
+  const person = buildCurrentOwnerPerson(user);
+  if (!person) return data;
+  for (const kit of listOvertimeKitBindings(schema)) {
+    const f = kit.personField;
+    if (f && isEmptyPersonValue(data[f])) {
+      data[f] = [person];
+    }
+  }
+  return data;
+}
+function applyOvertimeKitDurationsToFormData(schema, formData, isRestDay) {
+  const data = { ...formData };
+  let changed = false;
+  for (const kit of listOvertimeKitBindings(schema)) {
+    const manualKey = OVERTIME_MANUAL_PREFIX + kit.durationField;
+    if (data[manualKey]) continue;
+    const hours = calcOvertimeHours(
+      String(data[kit.startField] || ""),
+      String(data[kit.endField] || ""),
+      kit.windows,
+      isRestDay
+    );
+    if (hours == null) continue;
+    if (data[kit.durationField] !== hours) {
+      data[kit.durationField] = hours;
+      changed = true;
+    }
+  }
+  return { data, changed };
+}
+function markOvertimeDurationManual(formData, durationField, manual = true) {
+  const data = { ...formData };
+  const key = OVERTIME_MANUAL_PREFIX + durationField;
+  if (manual) data[key] = true;
+  else delete data[key];
+  return data;
+}
+function isOvertimeDurationField(schema, field) {
+  return listOvertimeKitBindings(schema).some((k) => k.durationField === field);
+}
+function findOvertimeKitByStartOrEnd(schema, field) {
+  return listOvertimeKitBindings(schema).find(
+    (k) => k.startField === field || k.endField === field
+  ) || null;
+}
+function ensureOvertimeKitRuleProps(rules) {
+  if (!Array.isArray(rules)) return [];
+  return rules.map((raw) => {
+    if (!raw || typeof raw !== "object") return raw;
+    const rule = { ...raw };
+    if (String(rule.type || "") === "overtimeKit") {
+      const binding = resolveBinding(rule);
+      const patchChildren = (list) => list.map((c) => {
+        if (!c || typeof c !== "object") return c;
+        const child = { ...c };
+        const t = String(child.type || "");
+        const isDuration = binding && child.field === binding.durationField || (t === "inputNumber" || t === "number") && String(child.title || "").includes("\u65F6\u957F");
+        if (isDuration) {
+          child.props = { ...child.props || {}, align: "left" };
+          const cls = String(child.class || child.className || "");
+          if (!cls.includes("oa-overtime-duration")) {
+            child.class = (cls ? cls + " " : "") + "oa-overtime-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 = ensureOvertimeKitRuleProps(rule.children);
+    }
+    if (Array.isArray(rule.props?.rule)) {
+      rule.props = {
+        ...rule.props,
+        rule: ensureOvertimeKitRuleProps(rule.props.rule)
+      };
+    }
+    return rule;
+  });
+}
+function hasOvertimeKit(schema) {
+  const flat = [];
+  const walk2 = (rules) => {
+    for (const r of rules) {
+      flat.push(r);
+      if (Array.isArray(r.children)) walk2(r.children);
+      const props = r.props;
+      if (props && Array.isArray(props.rule)) walk2(props.rule);
+    }
+  };
+  walk2(asRules(schema));
+  return flat.some((r) => String(r.type || "") === "overtimeKit");
+}
+export {
+  OVERTIME_MANUAL_PREFIX,
+  applyOvertimeKitDefaults,
+  applyOvertimeKitDurationsToFormData,
+  ensureOvertimeKitRuleProps,
+  findOvertimeKitByStartOrEnd,
+  hasOvertimeKit,
+  isOvertimeDurationField,
+  listOvertimeKitBindings,
+  markOvertimeDurationManual
+};

+ 129 - 0
mobile-oa-h5/src/oa/kit/oa-owner-field-defaults.js

@@ -0,0 +1,129 @@
+import { fetchDeptTree } from "../../api/oa.js";
+import { isDeptValueItem } from "./oa-dept-value.js";
+import { isPersonValueItem } from "./oa-person-value.js";
+import { buildPathLabelMapFromTreeRoots } from "./tree-path.js";
+function walkRules(rules, out) {
+  for (const r of rules) {
+    if (!r || typeof r !== "object") continue;
+    const rule = r;
+    out.push(rule);
+    const children = rule.children || rule.props?.rule || [];
+    if (Array.isArray(children)) walkRules(children, out);
+  }
+}
+function flattenRules(schemaOrRules) {
+  if (Array.isArray(schemaOrRules)) {
+    const out = [];
+    walkRules(schemaOrRules, out);
+    return out;
+  }
+  if (schemaOrRules && typeof schemaOrRules === "object") {
+    const root = schemaOrRules;
+    const rules = root.rule || root.rules || [];
+    if (Array.isArray(rules)) {
+      const out = [];
+      walkRules(rules, out);
+      return out;
+    }
+  }
+  return [];
+}
+function isEmptyOwnerValue(raw) {
+  if (raw == null || raw === "") return true;
+  if (Array.isArray(raw)) return raw.length === 0;
+  if (typeof raw === "object") {
+    const o = raw;
+    return !String(o.id || "").trim();
+  }
+  return false;
+}
+function buildCurrentOwnerPerson(user) {
+  if (!user) return null;
+  const id = String(user.id || user.userId || "").trim();
+  if (!id) return null;
+  const name = String(user.realName || user.userName || user.username || "").trim() || id;
+  return { id, name };
+}
+function buildCurrentOwnerDeptSync(user) {
+  if (!user) return null;
+  const id = String(user.regionId || user.deptId || "").trim();
+  if (!id) return null;
+  const name = String(user.regionName || user.deptName || "").trim() || id;
+  return { id, name };
+}
+async function resolveCurrentOwnerDept(user) {
+  const base = buildCurrentOwnerDeptSync(user);
+  if (!base) return null;
+  try {
+    const tree = await fetchDeptTree() || [];
+    const map = buildPathLabelMapFromTreeRoots(tree, { nameKey: "deptName" });
+    const path = map.get(base.id);
+    if (path) return { id: base.id, name: path };
+  } catch {
+  }
+  return base;
+}
+async function applyOwnerFieldDefaults(schemaOrRules, formData, user) {
+  const rules = flattenRules(schemaOrRules);
+  const hasPerson = rules.some((r) => String(r.type || "") === "ownerPerson");
+  const hasDept = rules.some((r) => String(r.type || "") === "ownerDept");
+  if (!hasPerson && !hasDept) return { ...formData };
+  const person = hasPerson ? buildCurrentOwnerPerson(user) : null;
+  const dept = hasDept ? await resolveCurrentOwnerDept(user) : null;
+  const next = { ...formData };
+  for (const rule of rules) {
+    const type = String(rule.type || "");
+    const field = String(rule.field || rule.name || "").trim();
+    if (!field) continue;
+    const allowModify = rule.props?.allowModify === true;
+    if (type === "ownerPerson" && person) {
+      if (!allowModify || isEmptyOwnerValue(next[field])) {
+        next[field] = person;
+      }
+    }
+    if (type === "ownerDept" && dept) {
+      if (!allowModify || isEmptyOwnerValue(next[field])) {
+        next[field] = dept;
+      }
+    }
+  }
+  return next;
+}
+function ensureOwnerFieldRuleProps(rules) {
+  if (!Array.isArray(rules)) return [];
+  return rules.map((raw) => {
+    if (!raw || typeof raw !== "object") return raw;
+    const rule = { ...raw };
+    const type = String(rule.type || "");
+    if (type === "ownerPerson" || type === "ownerDept") {
+      const props = { ...rule.props || {} };
+      if (props.allowModify !== true) props.allowModify = false;
+      rule.props = props;
+    }
+    if (Array.isArray(rule.children)) {
+      rule.children = ensureOwnerFieldRuleProps(rule.children);
+    }
+    if (rule.props && Array.isArray(rule.props.rule)) {
+      rule.props = {
+        ...rule.props,
+        rule: ensureOwnerFieldRuleProps(rule.props.rule)
+      };
+    }
+    return rule;
+  });
+}
+function isOwnerPersonValue(v) {
+  return isPersonValueItem(v);
+}
+function isOwnerDeptValue(v) {
+  return isDeptValueItem(v);
+}
+export {
+  applyOwnerFieldDefaults,
+  buildCurrentOwnerDeptSync,
+  buildCurrentOwnerPerson,
+  ensureOwnerFieldRuleProps,
+  isOwnerDeptValue,
+  isOwnerPersonValue,
+  resolveCurrentOwnerDept
+};

+ 51 - 0
mobile-oa-h5/src/oa/kit/oa-person-value.js

@@ -0,0 +1,51 @@
+function isPersonValueItem(v) {
+  if (!v || typeof v !== "object") return false;
+  const o = v;
+  return String(o.id || "").trim() !== "" && String(o.name || "").trim() !== "";
+}
+function normalizePersonModelValue(raw, multiple) {
+  if (multiple) {
+    if (Array.isArray(raw)) {
+      return raw.filter(isPersonValueItem).map((x) => ({
+        id: String(x.id).trim(),
+        name: String(x.name).trim()
+      }));
+    }
+    if (isPersonValueItem(raw)) {
+      return [{ id: String(raw.id).trim(), name: String(raw.name).trim() }];
+    }
+    return [];
+  }
+  if (isPersonValueItem(raw)) {
+    return { id: String(raw.id).trim(), name: String(raw.name).trim() };
+  }
+  if (Array.isArray(raw) && raw.length === 1 && isPersonValueItem(raw[0])) {
+    return { id: String(raw[0].id).trim(), name: String(raw[0].name).trim() };
+  }
+  return null;
+}
+function formatPersonDisplay(raw) {
+  if (Array.isArray(raw)) {
+    return raw.filter(isPersonValueItem).map((x) => x.name).filter(Boolean).join("\u3001");
+  }
+  if (isPersonValueItem(raw)) return raw.name;
+  return "";
+}
+function extractPersonIds(raw) {
+  const n = Array.isArray(raw) ? raw.filter(isPersonValueItem) : isPersonValueItem(raw) ? [raw] : [];
+  const out = [];
+  const seen = /* @__PURE__ */ new Set();
+  for (const x of n) {
+    const id = String(x.id).trim();
+    if (!id || seen.has(id)) continue;
+    seen.add(id);
+    out.push(id);
+  }
+  return out;
+}
+export {
+  extractPersonIds,
+  formatPersonDisplay,
+  isPersonValueItem,
+  normalizePersonModelValue
+};

+ 56 - 0
mobile-oa-h5/src/oa/kit/oa-resignation-kit-runtime.js

@@ -0,0 +1,56 @@
+function asRules(schema) {
+  if (Array.isArray(schema)) return schema.filter((r) => r && typeof r === "object");
+  if (schema && typeof schema === "object") {
+    const s = schema;
+    const rules = s.rule || s.rules || s.fields;
+    if (Array.isArray(rules)) return rules.filter((r) => r && typeof r === "object");
+    if (s.type || s.field) return [s];
+  }
+  return [];
+}
+function walk(rules, out) {
+  for (const r of rules) {
+    out.push(r);
+    if (Array.isArray(r.children)) {
+      walk(r.children, out);
+    }
+    const props = r.props;
+    if (props && Array.isArray(props.rule)) walk(props.rule, out);
+  }
+}
+function resolveBinding(kit) {
+  const props = kit.props && typeof kit.props === "object" ? kit.props : {};
+  let hireDateField = props.hireDateField ? String(props.hireDateField) : void 0;
+  let lastWorkDayField = props.lastWorkDayField ? String(props.lastWorkDayField) : void 0;
+  const children = Array.isArray(kit.children) ? kit.children : Array.isArray(props.rule) ? props.rule : [];
+  for (const c of children) {
+    if (!c?.field) continue;
+    const sem = String(c.props?._fc_kit_field || "");
+    if (sem === "hireDate" && !hireDateField) hireDateField = String(c.field);
+    if (sem === "lastWorkDay" && !lastWorkDayField) lastWorkDayField = String(c.field);
+  }
+  return { hireDateField, lastWorkDayField };
+}
+function listResignationKitBindings(schema) {
+  const flat = [];
+  walk(asRules(schema), flat);
+  return flat.filter((r) => String(r.type || "") === "resignationKit").map(resolveBinding);
+}
+function isEmpty(raw) {
+  return raw == null || raw === "";
+}
+function applyResignationKitDefaults(schema, formData, user) {
+  const data = { ...formData };
+  if (!user) return data;
+  const hireDate = String(user.hireDate || "").trim();
+  if (!hireDate) return data;
+  for (const kit of listResignationKitBindings(schema)) {
+    const f = kit.hireDateField;
+    if (f && isEmpty(data[f])) data[f] = hireDate;
+  }
+  return data;
+}
+export {
+  applyResignationKitDefaults,
+  listResignationKitBindings
+};

+ 62 - 0
mobile-oa-h5/src/oa/kit/oa-trip-days.js

@@ -0,0 +1,62 @@
+const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
+function dayIndex(dateStr) {
+  const m = DATE_RE.exec(String(dateStr || "").trim());
+  if (!m) return null;
+  const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
+  const t = Date.UTC(y, mo - 1, d);
+  if (Number.isNaN(t)) return null;
+  return t / 864e5;
+}
+function startOffset(half) {
+  if (half === "\u4E0A\u5348") return 0;
+  if (half === "\u4E0B\u5348") return 0.5;
+  return null;
+}
+function endOffset(half) {
+  if (half === "\u4E0A\u5348") return 0.5;
+  if (half === "\u4E0B\u5348") return 1;
+  return null;
+}
+function calcTripLegDays(startDate, startHalf, endDate, endHalf) {
+  const ds = dayIndex(startDate);
+  const de = dayIndex(endDate);
+  const so = startOffset(String(startHalf || "").trim());
+  const eo = endOffset(String(endHalf || "").trim());
+  if (ds == null || de == null || so == null || eo == null) return null;
+  const duration = de + eo - (ds + so);
+  if (!(duration > 0)) return null;
+  return Math.round(duration * 2) / 2;
+}
+function sumTripLegDays(legs) {
+  let sum = 0;
+  for (const leg of legs || []) {
+    const n = Number(leg?.duration);
+    if (Number.isFinite(n) && n >= 0) sum += n;
+  }
+  return Math.round(sum * 2) / 2;
+}
+function isHalfDayMultiple(n) {
+  const v = Number(n);
+  if (!Number.isFinite(v) || v < 0) return false;
+  return Math.abs(v * 2 - Math.round(v * 2)) < 1e-9;
+}
+function emptyTripLeg() {
+  return {
+    transport: "",
+    tripType: "",
+    fromCity: "",
+    toCity: "",
+    startDate: "",
+    startHalf: "\u4E0A\u5348",
+    endDate: "",
+    endHalf: "\u4E0B\u5348",
+    duration: null,
+    durationManual: false
+  };
+}
+export {
+  calcTripLegDays,
+  emptyTripLeg,
+  isHalfDayMultiple,
+  sumTripLegDays
+};

+ 105 - 0
mobile-oa-h5/src/oa/kit/oa-trip-kit-runtime.js

@@ -0,0 +1,105 @@
+import { calcTripLegDays, sumTripLegDays } from "./oa-trip-days.js";
+const TRIP_MANUAL_PREFIX = "__manual_";
+function asRules(schema) {
+  if (Array.isArray(schema)) return schema.filter((r) => r && typeof r === "object");
+  if (schema && typeof schema === "object") {
+    const s = schema;
+    const rules = s.rule || s.rules || s.fields;
+    if (Array.isArray(rules)) return rules.filter((r) => r && typeof r === "object");
+    if (s.type || s.field) return [s];
+  }
+  return [];
+}
+function walk(rules, out) {
+  for (const r of rules) {
+    out.push(r);
+    const children = r.children;
+    if (Array.isArray(children)) {
+      walk(
+        children.filter((c) => c && typeof c === "object"),
+        out
+      );
+    }
+    const props = r.props;
+    if (props && Array.isArray(props.rule)) {
+      walk(
+        props.rule.filter((c) => c && typeof c === "object"),
+        out
+      );
+    }
+  }
+}
+function resolveTripKitFields(kit) {
+  const props = kit.props && typeof kit.props === "object" ? kit.props : {};
+  let listField = props.tripListField;
+  let daysField = props.tripDaysField;
+  if (listField && daysField) {
+    return { listField: String(listField), daysField: String(daysField) };
+  }
+  const children = Array.isArray(kit.children) ? kit.children : Array.isArray(props.rule) ? props.rule : [];
+  for (const c of children) {
+    if (!c?.field) continue;
+    const kitKey = String(c.props?._fc_kit_field || "");
+    if (kitKey === "tripList") listField = String(c.field);
+    else if (kitKey === "tripDays") daysField = String(c.field);
+  }
+  if (!listField || !daysField) return null;
+  return { listField, daysField };
+}
+function listTripKits(schema) {
+  const flat = [];
+  walk(asRules(schema), flat);
+  const out = [];
+  for (const r of flat) {
+    if (String(r.type || "") !== "tripKit") continue;
+    const ref = resolveTripKitFields(r);
+    if (ref) out.push(ref);
+  }
+  return out;
+}
+function applyTripKitDaysToFormData(schema, formData) {
+  const data = { ...formData };
+  let changed = false;
+  for (const kit of listTripKits(schema)) {
+    const legsRaw = data[kit.listField];
+    if (!Array.isArray(legsRaw)) continue;
+    const prevLegsJson = JSON.stringify(data[kit.listField]);
+    const legs = [];
+    for (const leg of legsRaw) {
+      if (!leg || typeof leg !== "object") {
+        legs.push(leg);
+        continue;
+      }
+      const row = { ...leg };
+      if (!row.durationManual) {
+        const days = calcTripLegDays(
+          String(row.startDate || ""),
+          String(row.startHalf || ""),
+          String(row.endDate || ""),
+          String(row.endHalf || "")
+        );
+        if (days != null && row.duration !== days) {
+          row.duration = days;
+        }
+      }
+      legs.push(row);
+    }
+    data[kit.listField] = legs;
+    if (JSON.stringify(legs) !== prevLegsJson) {
+      changed = true;
+    }
+    const manualKey = TRIP_MANUAL_PREFIX + kit.daysField;
+    if (data[manualKey]) continue;
+    const sum = sumTripLegDays(legs);
+    if (data[kit.daysField] !== sum) {
+      data[kit.daysField] = sum;
+      changed = true;
+    }
+  }
+  return { data, changed };
+}
+export {
+  TRIP_MANUAL_PREFIX,
+  applyTripKitDaysToFormData,
+  listTripKits
+};

+ 75 - 0
mobile-oa-h5/src/oa/kit/tree-path.js

@@ -0,0 +1,75 @@
+const DEFAULT_TREE_PATH_SEPARATOR = " / ";
+function buildTreePathLabelFromFlat(nodeId, flatList, separatorOrOpts) {
+  const separator = typeof separatorOrOpts === "string" ? separatorOrOpts : separatorOrOpts?.separator ?? DEFAULT_TREE_PATH_SEPARATOR;
+  const nameKey = typeof separatorOrOpts === "string" ? "name" : separatorOrOpts?.nameKey ?? "name";
+  const cid = String(nodeId ?? "").trim();
+  if (!cid) return "";
+  const byId = /* @__PURE__ */ new Map();
+  for (const c of flatList || []) {
+    const id = String(c?.id ?? "").trim();
+    if (id) byId.set(id, c);
+  }
+  const names = [];
+  let cur = cid;
+  const seen = /* @__PURE__ */ new Set();
+  while (cur && byId.has(cur) && !seen.has(cur)) {
+    seen.add(cur);
+    const cat = byId.get(cur);
+    const nm = String(cat?.[nameKey] ?? "").trim();
+    if (nm) names.unshift(nm);
+    const pid = String(cat?.parentId ?? cat?.parent_id ?? "").trim();
+    cur = pid || null;
+  }
+  return names.length ? names.join(separator) : "";
+}
+function buildPathLabelMapFromTreeRoots(roots, options) {
+  const idKey = options?.idKey ?? "id";
+  const nameKey = options?.nameKey ?? "name";
+  const childrenKey = options?.childrenKey ?? "children";
+  const sep = options?.separator ?? DEFAULT_TREE_PATH_SEPARATOR;
+  const map = /* @__PURE__ */ new Map();
+  const walk = (nodes, pathNames) => {
+    for (const raw of nodes || []) {
+      const n = raw;
+      const id = n?.[idKey] != null ? String(n[idKey]).trim() : "";
+      const curName = String(n?.[nameKey] ?? "").trim();
+      const nextPath = curName ? [...pathNames, curName] : [...pathNames];
+      if (id) map.set(id, nextPath.join(sep));
+      const ch = n?.[childrenKey];
+      if (Array.isArray(ch) && ch.length) walk(ch, nextPath);
+    }
+  };
+  walk(roots || [], []);
+  return map;
+}
+function resolveTreePathDisplay(nodeId, flatList, fallbackName, separator = DEFAULT_TREE_PATH_SEPARATOR, emptyPlaceholder = "\u2014", nameKey) {
+  const path = buildTreePathLabelFromFlat(String(nodeId ?? "").trim() || void 0, flatList, {
+    separator,
+    nameKey
+  });
+  if (path) return path;
+  const fb = String(fallbackName ?? "").trim();
+  if (fb) return fb;
+  return emptyPlaceholder;
+}
+function findTreeSelectShortLabel(nodes, nodeId) {
+  const id = String(nodeId ?? "").trim();
+  if (!id) return "";
+  const walk = (arr) => {
+    for (const n of arr || []) {
+      if (!n) continue;
+      if (String(n.value ?? "") === id) return String(n.label ?? "").trim();
+      const hit = walk(n.children);
+      if (hit) return hit;
+    }
+    return "";
+  };
+  return walk(nodes);
+}
+export {
+  DEFAULT_TREE_PATH_SEPARATOR,
+  buildPathLabelMapFromTreeRoots,
+  buildTreePathLabelFromFlat,
+  findTreeSelectShortLabel,
+  resolveTreePathDisplay
+};

+ 11 - 1
mobile-oa-h5/src/oa/schema-prep.js

@@ -1,4 +1,10 @@
 import { oaFormOption } from './form-option.js'
+import {
+  ensureLeaveKitRuleProps,
+  ensureOvertimeKitRuleProps,
+  ensureOwnerFieldRuleProps,
+  ensureConfirmationKitVisibility
+} from './kit-runtime.js'
 
 function walkRules(rules, fn) {
   const list = Array.isArray(rules) ? rules : []
@@ -28,7 +34,11 @@ export function prepareMobileFormSchema(formSchemaJson) {
     : Array.isArray(schema)
       ? schema
       : []
-  const rule = ensureOaUploadRuleProps(rawRule)
+  const rule = ensureConfirmationKitVisibility(
+    ensureOwnerFieldRuleProps(
+      ensureOvertimeKitRuleProps(ensureLeaveKitRuleProps(ensureOaUploadRuleProps(rawRule)))
+    )
+  )
   const option = {
     ...oaFormOption,
     ...(schema.option || {}),

+ 298 - 12
mobile-oa-h5/src/views/OaStart.vue

@@ -49,9 +49,30 @@ import {
   fetchOaInstanceDraftDetail,
   fetchSubmitOaInstanceDraft,
   fetchStartOaInstance,
-  fetchUserList
+  fetchUserList,
+  fetchCurrentUser,
+  fetchCnHolidayRange
 } from '../api/oa.js'
 import { prepareMobileFormSchema } from '../oa/schema-prep.js'
+import {
+  applyLeaveKitDurationsToFormData,
+  LEAVE_MANUAL_PREFIX,
+  listLeaveKitBindings,
+  markLeaveDurationManual,
+  applyOvertimeKitDefaults,
+  applyOvertimeKitDurationsToFormData,
+  listOvertimeKitBindings,
+  markOvertimeDurationManual,
+  OVERTIME_MANUAL_PREFIX,
+  applyTripKitDaysToFormData,
+  listTripKits,
+  TRIP_MANUAL_PREFIX,
+  applyConfirmationKitDateLinkage,
+  applyConfirmationKitDefaults,
+  listConfirmationKitBindings,
+  applyResignationKitDefaults,
+  applyOwnerFieldDefaults
+} from '../oa/kit-runtime.js'
 import OaFlowPreview from '../oa/components/OaFlowPreview.vue'
 
 const route = useRoute()
@@ -70,9 +91,19 @@ const flowResolved = ref(false)
 const flowPendingMessage = ref('必填信息填写后,流程将自动显示')
 const initiatorSelectByNode = ref({})
 const userOptions = ref([])
+const currentUser = ref(null)
 
 let previewTimer = null
 let skipPreviewWatch = false
+let leaveKitSyncing = false
+let leaveKitPrevSnapshot = {}
+let tripKitSyncing = false
+let tripKitPrevSnapshot = {}
+let confirmationKitSyncing = false
+let confirmationKitPrevSnapshot = {}
+let overtimeKitSyncing = false
+let overtimeKitPrevSnapshot = {}
+const overtimeRestMap = {}
 
 function closeToMini(payload) {
   try {
@@ -87,6 +118,14 @@ function closeToMini(payload) {
   showToast((payload && payload.message) || '已完成(非小程序环境)')
 }
 
+async function loadCurrentUser() {
+  try {
+    currentUser.value = (await fetchCurrentUser()) || null
+  } catch {
+    currentUser.value = null
+  }
+}
+
 async function loadUsers() {
   try {
     const pageSize = 100
@@ -101,11 +140,13 @@ async function loadUsers() {
       page += 1
       if (page > 50) break
     }
-    userOptions.value = raw.map((u) => ({
-      id: String(u.id || ''),
-      nickName: u.nickName || '',
-      userNo: u.userNo || ''
-    })).filter((u) => u.id)
+    userOptions.value = raw
+      .map((u) => ({
+        id: String(u.id || ''),
+        nickName: u.nickName || '',
+        userNo: u.userNo || ''
+      }))
+      .filter((u) => u.id)
   } catch {
     userOptions.value = []
   }
@@ -155,7 +196,227 @@ function scheduleFlowPreview() {
   }, 300)
 }
 
-async function loadByAppId(appId) {
+function syncLeaveKitDurations() {
+  if (leaveKitSyncing) return
+  if (!formRule.value.length) return
+  const schema = { rule: formRule.value }
+  const bindings = listLeaveKitBindings(schema)
+  if (!bindings.length) return
+
+  const curr = { ...formData.value }
+  for (const kit of bindings) {
+    const prevDur = leaveKitPrevSnapshot[kit.durationField]
+    const currDur = curr[kit.durationField]
+    const startChanged = leaveKitPrevSnapshot[kit.startField] !== curr[kit.startField]
+    const endChanged = leaveKitPrevSnapshot[kit.endField] !== curr[kit.endField]
+    const durChanged = prevDur !== currDur
+    if (durChanged && !startChanged && !endChanged && currDur !== undefined && currDur !== '') {
+      Object.assign(curr, markLeaveDurationManual(curr, kit.durationField, true))
+    }
+  }
+
+  const { data, changed } = applyLeaveKitDurationsToFormData(schema, curr)
+  leaveKitPrevSnapshot = { ...data }
+  const manualKeysChanged = bindings.some(
+    (k) =>
+      !!data[LEAVE_MANUAL_PREFIX + k.durationField] !==
+      !!formData.value[LEAVE_MANUAL_PREFIX + k.durationField]
+  )
+  const durationChanged = bindings.some(
+    (k) => data[k.durationField] !== formData.value[k.durationField]
+  )
+  if (changed || manualKeysChanged || durationChanged) {
+    leaveKitSyncing = true
+    formData.value = data
+    queueMicrotask(() => {
+      leaveKitSyncing = false
+    })
+  }
+}
+
+function syncTripKitDurations() {
+  if (tripKitSyncing) return
+  if (!formRule.value.length) return
+  const schema = { rule: formRule.value }
+  const kits = listTripKits(schema)
+  if (!kits.length) return
+
+  const curr = { ...formData.value }
+  for (const kit of kits) {
+    const prevDays = tripKitPrevSnapshot[kit.daysField]
+    const currDays = curr[kit.daysField]
+    const daysChanged = prevDays !== currDays
+    const listChanged =
+      JSON.stringify(tripKitPrevSnapshot[kit.listField]) !== JSON.stringify(curr[kit.listField])
+    if (daysChanged && !listChanged && currDays !== undefined && currDays !== '') {
+      curr[TRIP_MANUAL_PREFIX + kit.daysField] = true
+    }
+  }
+
+  const { data, changed } = applyTripKitDaysToFormData(schema, curr)
+  tripKitPrevSnapshot = { ...data }
+  const manualKeysChanged = kits.some(
+    (k) =>
+      !!data[TRIP_MANUAL_PREFIX + k.daysField] !==
+      !!formData.value[TRIP_MANUAL_PREFIX + k.daysField]
+  )
+  const daysFieldChanged = kits.some((k) => data[k.daysField] !== formData.value[k.daysField])
+  if (changed || manualKeysChanged || daysFieldChanged) {
+    tripKitSyncing = true
+    formData.value = data
+    queueMicrotask(() => {
+      tripKitSyncing = false
+    })
+  }
+}
+
+function syncConfirmationKitDates() {
+  if (confirmationKitSyncing) return
+  if (!formRule.value.length) return
+  const schema = { rule: formRule.value }
+  if (!listConfirmationKitBindings(schema).length) return
+  const curr = { ...formData.value }
+  const { data, changed } = applyConfirmationKitDateLinkage(schema, curr)
+  confirmationKitPrevSnapshot = { ...data }
+  if (changed) {
+    confirmationKitSyncing = true
+    formData.value = data
+    queueMicrotask(() => {
+      confirmationKitSyncing = false
+    })
+  }
+}
+
+async function ensureOvertimeHolidayRange(startYmd, endYmd) {
+  const start = String(startYmd || '').trim().slice(0, 10)
+  const end = String(endYmd || '').trim().slice(0, 10)
+  if (!start || !end) return
+  let lo = start
+  let hi = end
+  if (lo > hi) {
+    const t = lo
+    lo = hi
+    hi = t
+  }
+  const begin = new Date(lo + 'T00:00:00')
+  const finish = new Date(hi + 'T00:00:00')
+  if (Number.isNaN(begin.getTime()) || Number.isNaN(finish.getTime())) return
+
+  let allPresent = true
+  for (let d = new Date(begin); d <= finish; d.setDate(d.getDate() + 1)) {
+    const y = d.getFullYear()
+    const m = String(d.getMonth() + 1).padStart(2, '0')
+    const day = String(d.getDate()).padStart(2, '0')
+    const key = `${y}-${m}-${day}`
+    if (!(key in overtimeRestMap)) {
+      allPresent = false
+      break
+    }
+  }
+  if (allPresent) return
+  try {
+    const res = await fetchCnHolidayRange(lo, hi)
+    const days = res?.days || []
+    for (const day of days) {
+      const key = String(day.dayDate || '').slice(0, 10)
+      if (key) overtimeRestMap[key] = !!day.isRestDay
+    }
+  } catch {
+    /* 失败保留已有 map,计算回退周末规则 */
+  }
+}
+
+function isRestDayResolver(ymd) {
+  if (ymd in overtimeRestMap) return overtimeRestMap[ymd]
+  const d = new Date(ymd + 'T00:00:00')
+  const w = d.getDay()
+  return w === 0 || w === 6
+}
+
+async function syncOvertimeKitDurations() {
+  if (overtimeKitSyncing) return
+  if (!formRule.value.length) return
+  const schema = { rule: formRule.value }
+  const bindings = listOvertimeKitBindings(schema)
+  if (!bindings.length) return
+
+  overtimeKitSyncing = true
+  try {
+    let minYmd = ''
+    let maxYmd = ''
+    for (const kit of bindings) {
+      for (const field of [kit.startField, kit.endField]) {
+        const ymd = String(formData.value[field] || '')
+          .trim()
+          .slice(0, 10)
+        if (ymd.length < 10) continue
+        if (!minYmd || ymd < minYmd) minYmd = ymd
+        if (!maxYmd || ymd > maxYmd) maxYmd = ymd
+      }
+    }
+    if (minYmd && maxYmd) {
+      await ensureOvertimeHolidayRange(minYmd, maxYmd)
+    }
+
+    const curr = { ...formData.value }
+    for (const kit of bindings) {
+      const prevDur = overtimeKitPrevSnapshot[kit.durationField]
+      const currDur = curr[kit.durationField]
+      const startChanged = overtimeKitPrevSnapshot[kit.startField] !== curr[kit.startField]
+      const endChanged = overtimeKitPrevSnapshot[kit.endField] !== curr[kit.endField]
+      const durChanged = prevDur !== currDur
+      if (
+        durChanged &&
+        !startChanged &&
+        !endChanged &&
+        currDur !== undefined &&
+        currDur !== ''
+      ) {
+        Object.assign(curr, markOvertimeDurationManual(curr, kit.durationField, true))
+      }
+    }
+
+    const { data, changed } = applyOvertimeKitDurationsToFormData(
+      schema,
+      curr,
+      isRestDayResolver
+    )
+    overtimeKitPrevSnapshot = { ...data }
+    const manualKeysChanged = bindings.some(
+      (k) =>
+        !!data[OVERTIME_MANUAL_PREFIX + k.durationField] !==
+        !!formData.value[OVERTIME_MANUAL_PREFIX + k.durationField]
+    )
+    const durationChanged = bindings.some(
+      (k) => data[k.durationField] !== formData.value[k.durationField]
+    )
+    if (changed || manualKeysChanged || durationChanged) {
+      formData.value = data
+    }
+  } finally {
+    queueMicrotask(() => {
+      overtimeKitSyncing = false
+    })
+  }
+}
+
+async function applyKitDefaults(baseData) {
+  let data = { ...(baseData || {}) }
+  const user = currentUser.value
+  data = await applyOwnerFieldDefaults(formRule.value, data, user)
+  data = await applyConfirmationKitDefaults({ rule: formRule.value }, data, user)
+  data = applyResignationKitDefaults({ rule: formRule.value }, data, user)
+  data = applyOvertimeKitDefaults({ rule: formRule.value }, data, user)
+  const linked = applyConfirmationKitDateLinkage({ rule: formRule.value }, data)
+  data = linked.data
+  confirmationKitPrevSnapshot = { ...data }
+  overtimeKitPrevSnapshot = { ...data }
+  leaveKitPrevSnapshot = { ...data }
+  tripKitPrevSnapshot = { ...data }
+  return data
+}
+
+async function loadByAppId(appId, { seedData = null, applyDefaults = true } = {}) {
   const detail = await fetchOaTemplateDetail(appId)
   appMeta.value = {
     id: appId,
@@ -164,6 +425,16 @@ async function loadByAppId(appId) {
   const prepared = prepareMobileFormSchema(detail?.formSchemaJson)
   formRule.value = prepared.rule
   formOption.value = prepared.option
+  if (applyDefaults) {
+    formData.value = await applyKitDefaults(seedData || {})
+    await syncOvertimeKitDurations()
+  } else if (seedData) {
+    formData.value = { ...seedData }
+    leaveKitPrevSnapshot = { ...formData.value }
+    tripKitPrevSnapshot = { ...formData.value }
+    confirmationKitPrevSnapshot = { ...formData.value }
+    overtimeKitPrevSnapshot = { ...formData.value }
+  }
   await loadFlowPreview()
 }
 
@@ -171,6 +442,7 @@ async function bootstrap() {
   detailLoading.value = true
   skipPreviewWatch = true
   try {
+    await loadCurrentUser()
     const appId = String(route.query.appId || '').trim()
     const draftId = String(route.query.draftId || '').trim()
     if (draftId) {
@@ -178,17 +450,18 @@ async function bootstrap() {
       editingDraftId.value = draftId
       const aid = String(d?.appId || '').trim()
       if (!aid) throw new Error('草稿缺少 appId')
-      formData.value = d.formData && typeof d.formData === 'object' ? { ...d.formData } : {}
       initiatorSelectByNode.value =
         d.initiatorSelectByNode && typeof d.initiatorSelectByNode === 'object'
           ? { ...d.initiatorSelectByNode }
           : {}
-      await loadByAppId(aid)
+      const seed =
+        d.formData && typeof d.formData === 'object' ? { ...d.formData } : {}
+      // 草稿:保留已填值,仅补空的 owner 等默认
+      await loadByAppId(aid, { seedData: seed, applyDefaults: true })
     } else if (appId) {
-      formData.value = {}
       initiatorSelectByNode.value = {}
       editingDraftId.value = ''
-      await loadByAppId(appId)
+      await loadByAppId(appId, { seedData: {}, applyDefaults: true })
     } else {
       showToast('缺少 appId')
     }
@@ -288,7 +561,20 @@ async function submit() {
 }
 
 watch(
-  [formData, initiatorSelectByNode],
+  formData,
+  () => {
+    if (skipPreviewWatch) return
+    syncLeaveKitDurations()
+    syncTripKitDurations()
+    syncConfirmationKitDates()
+    void syncOvertimeKitDurations()
+    scheduleFlowPreview()
+  },
+  { deep: true }
+)
+
+watch(
+  initiatorSelectByNode,
   () => {
     if (skipPreviewWatch) return
     scheduleFlowPreview()