Просмотр исходного кода

feat(web-admin): extract OaStartFormDialog for shared start/draft edit.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 месяцев назад
Родитель
Сommit
0dcc05dca7
1 измененных файлов с 605 добавлено и 0 удалено
  1. 605 0
      web-admin/src/components/oa-approval/OaStartFormDialog.vue

+ 605 - 0
web-admin/src/components/oa-approval/OaStartFormDialog.vue

@@ -0,0 +1,605 @@
+<!-- 发起审批 / 草稿编辑共用弹窗 -->
+<template>
+  <el-dialog
+    v-model="dlgVisible"
+    :title="picked?.appName || '发起审批'"
+    width="920px"
+    class="dialog-form-layout"
+    align-center
+    append-to-body
+    modal-class="protocol-dialog-mask"
+    destroy-on-close
+    :close-on-click-modal="false"
+    @closed="onDialogClosed"
+  >
+    <div v-loading="detailLoading">
+      <form-create
+        v-if="formRule.length"
+        v-model="formData"
+        v-model:api="fapi"
+        :rule="formRule"
+        :option="formOption"
+      />
+      <p v-if="confirmationDateManualFields.length" class="oa-start__tip oa-start__confirm-relink">
+        <el-button link type="primary" @click="restoreConfirmationDateAuto">
+          恢复转正日期自动计算
+        </el-button>
+      </p>
+      <p v-else-if="!formRule.length && !detailLoading" class="oa-start__tip">
+        该模板暂无表单字段,仍可直接提交。
+      </p>
+      <OaFlowPreview
+        v-if="!detailLoading"
+        v-model:select-by-node="initiatorSelectByNode"
+        :steps="flowSteps"
+        :loading="previewLoading"
+        :resolved="flowResolved"
+        :pending-message="flowPendingMessage"
+        :user-options="userOptions"
+        @select-change="scheduleFlowPreview"
+      />
+    </div>
+    <template #footer>
+      <el-button class="btn-ghost" @click="closeDialog">
+        <el-icon><Close /></el-icon>
+        取消
+      </el-button>
+      <el-button class="btn-ghost" :loading="savingDraft" @click="saveDraft">
+        <el-icon><Document /></el-icon>
+        {{ editingDraftId ? '保存草稿' : '暂存草稿' }}
+      </el-button>
+      <el-button class="btn-main" type="primary" :loading="submitting" @click="submit">
+        <el-icon><Check /></el-icon>
+        提交
+      </el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+  import { computed, ref, watch } from 'vue'
+  import { Check, Close, Document } from '@element-plus/icons-vue'
+  import { ElMessage } from 'element-plus'
+  import { fetchUserList } from '@/api/admin'
+  import {
+    fetchCreateOaInstanceDraft,
+    fetchOaFlowPreview,
+    fetchOaInstanceDetail,
+    fetchOaInstanceDraftDetail,
+    fetchOaTemplateDetail,
+    fetchStartOaInstance,
+    fetchSubmitOaInstanceDraft,
+    fetchUpdateOaInstanceDraft,
+    type OaFlowPreviewStep
+  } from '@/api/oaApproval'
+  import OaFlowPreview from '@/components/oa-approval/OaFlowPreview.vue'
+  import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
+  import { OA_FC_DEFAULT_GUTTER, ensureOaUploadRuleProps } from '@/utils/oa-form-create-upload'
+  import {
+    applyLeaveKitDurationsToFormData,
+    ensureLeaveKitRuleProps,
+    LEAVE_MANUAL_PREFIX,
+    listLeaveKitBindings,
+    markLeaveDurationManual
+  } from '@/utils/oa-leave-kit-runtime'
+  import {
+    applyOvertimeKitDefaults,
+    ensureOvertimeKitRuleProps
+  } from '@/utils/oa-overtime-kit-runtime'
+  import { applyResignationKitDefaults } from '@/utils/oa-resignation-kit-runtime'
+  import {
+    applyConfirmationKitDateLinkage,
+    applyConfirmationKitDefaults,
+    clearConfirmationDateManualAndRelink,
+    CONFIRMATION_MANUAL_PREFIX,
+    ensureConfirmationKitVisibility,
+    listConfirmationKitBindings,
+    listManualConfirmationDateFields,
+    markConfirmationDateManual
+  } from '@/utils/oa-confirmation-kit-runtime'
+  import {
+    applyOwnerFieldDefaults,
+    ensureOwnerFieldRuleProps
+  } from '@/utils/oa-owner-field-defaults'
+  import {
+    applyTripKitDaysToFormData,
+    listTripKits,
+    TRIP_MANUAL_PREFIX
+  } from '@/utils/oa-trip-kit-runtime'
+  import { useUserStore } from '@/store/modules/user'
+
+  defineOptions({ name: 'OaStartFormDialog' })
+
+  const emit = defineEmits<{
+    saved: []
+    submitted: [instance: any]
+    closed: []
+  }>()
+
+  const userStore = useUserStore()
+  const dlgVisible = ref(false)
+  const detailLoading = ref(false)
+  const picked = ref<any>(null)
+  const formData = ref<Record<string, any>>({})
+  const formRule = ref<any[]>([])
+  const formOption = ref<Record<string, any>>({
+    submitBtn: false,
+    resetBtn: false,
+    row: { gutter: OA_FC_DEFAULT_GUTTER }
+  })
+  const fapi = ref<any>(null)
+  const submitting = ref(false)
+  const savingDraft = ref(false)
+  const editingDraftId = ref('')
+  const needInitiatorSelect = ref(false)
+  const initiatorSelectByNode = ref<Record<string, string[]>>({})
+  const userOptions = ref<Array<{ id: string; nickName: string; userNo: string }>>([])
+  const flowSteps = ref<OaFlowPreviewStep[]>([])
+  const flowResolved = ref(true)
+  const flowPendingMessage = ref('必填信息填写后,流程将自动显示')
+  const previewLoading = ref(false)
+  let previewTimer: ReturnType<typeof setTimeout> | null = null
+  /** 避免时长自动回写再次触发手改判定 */
+  let leaveKitSyncing = false
+  let leaveKitPrevSnapshot: Record<string, unknown> = {}
+  let tripKitSyncing = false
+  let tripKitPrevSnapshot: Record<string, unknown> = {}
+  let confirmationKitSyncing = false
+  let confirmationKitPrevSnapshot: Record<string, unknown> = {}
+
+  const confirmationDateManualFields = computed(() =>
+    listManualConfirmationDateFields({ rule: formRule.value }, formData.value)
+  )
+
+  function flowNeedsInitiatorSelect(node: any): boolean {
+    if (!node || typeof node !== 'object') return false
+    const cfg = node.approverConfig || {}
+    if ((cfg.assigneeType || '') === 'initiator_select') return true
+    if (node.child && flowNeedsInitiatorSelect(node.child)) return true
+    for (const g of node.conditionGroups || []) {
+      if (g?.child && flowNeedsInitiatorSelect(g.child)) return true
+    }
+    return false
+  }
+
+  async function loadUsers() {
+    try {
+      const pageSize = MAX_ADMIN_LIST_PAGE_SIZE
+      const raw: any[] = []
+      let page = 1
+      for (;;) {
+        const res = await fetchUserList({ page, pageSize, status: '1' })
+        const list = res?.list || []
+        raw.push(...list)
+        if (list.length < pageSize) break
+        if (typeof res?.total === 'number' && raw.length >= res.total) break
+        page += 1
+      }
+      userOptions.value = raw.map((u) => ({
+        id: u.id,
+        nickName: u.nickName || '',
+        userNo: u.userNo || ''
+      }))
+    } catch {
+      userOptions.value = []
+    }
+  }
+
+  async function loadFlowPreview(appId: string) {
+    previewLoading.value = true
+    try {
+      const preview = await fetchOaFlowPreview(appId, {
+        formData: { ...formData.value },
+        initiatorSelectByNode: { ...initiatorSelectByNode.value }
+      })
+      flowResolved.value = preview?.resolved !== false
+      flowPendingMessage.value = preview?.message || '必填信息填写后,流程将自动显示'
+      flowSteps.value = flowResolved.value ? preview?.steps || [] : []
+    } catch {
+      flowSteps.value = []
+      flowResolved.value = true
+    } finally {
+      previewLoading.value = false
+    }
+  }
+
+  function scheduleFlowPreview() {
+    if (!dlgVisible.value || !picked.value?.id) return
+    if (previewTimer) clearTimeout(previewTimer)
+    previewTimer = setTimeout(() => {
+      loadFlowPreview(picked.value.id)
+    }, 300)
+  }
+
+  async function onPick(t: any) {
+    editingDraftId.value = ''
+    picked.value = t
+    formData.value = {}
+    formRule.value = []
+    leaveKitPrevSnapshot = {}
+    tripKitPrevSnapshot = {}
+    confirmationKitPrevSnapshot = {}
+    needInitiatorSelect.value = false
+    initiatorSelectByNode.value = {}
+    flowSteps.value = []
+    flowResolved.value = false
+    flowPendingMessage.value = '必填信息填写后,流程将自动显示'
+    dlgVisible.value = true
+    detailLoading.value = true
+    previewLoading.value = true
+    try {
+      const detail = await fetchOaTemplateDetail(t.id)
+      const schema = detail?.formSchemaJson || {}
+      const rawRule = Array.isArray(schema.rule) ? schema.rule : Array.isArray(schema) ? schema : []
+      formRule.value = ensureConfirmationKitVisibility(
+        ensureOwnerFieldRuleProps(
+          ensureOvertimeKitRuleProps(ensureLeaveKitRuleProps(ensureOaUploadRuleProps(rawRule)))
+        )
+      )
+      const savedGutter = Number(schema.option?.row?.gutter)
+      formOption.value = {
+        ...(schema.option || {}),
+        form: {
+          ...(schema.option?.form || {}),
+          labelPosition: 'top'
+        },
+        // 顶层字段 col.span 布局用;栅格组件 fcRow 由 ensureOaUploadRuleProps 处理
+        row: {
+          ...(schema.option?.row || {}),
+          gutter: savedGutter > 0 ? savedGutter : OA_FC_DEFAULT_GUTTER
+        },
+        submitBtn: false,
+        resetBtn: false
+      }
+      let data = await applyOwnerFieldDefaults(
+        formRule.value,
+        {},
+        (userStore.info || userStore.getUserInfo) as any
+      )
+      data = await applyConfirmationKitDefaults(
+        { rule: formRule.value },
+        data,
+        (userStore.info || userStore.getUserInfo) as any
+      )
+      data = applyResignationKitDefaults(
+        { rule: formRule.value },
+        data,
+        (userStore.info || userStore.getUserInfo) as any
+      )
+      data = applyOvertimeKitDefaults(
+        { rule: formRule.value },
+        data,
+        (userStore.info || userStore.getUserInfo) as any
+      )
+      const linked = applyConfirmationKitDateLinkage({ rule: formRule.value }, data)
+      formData.value = linked.data
+      confirmationKitPrevSnapshot = { ...linked.data }
+      const flow = detail?.flowTreeJson || {}
+      const root = flow.root || flow
+      needInitiatorSelect.value = flowNeedsInitiatorSelect(root)
+      if (needInitiatorSelect.value && !userOptions.value.length) {
+        await loadUsers()
+      }
+      await loadFlowPreview(t.id)
+    } catch {
+      formRule.value = []
+      flowSteps.value = []
+      flowResolved.value = true
+      previewLoading.value = false
+    } finally {
+      detailLoading.value = false
+    }
+  }
+
+  function closeDialog() {
+    dlgVisible.value = false
+    editingDraftId.value = ''
+  }
+
+  function onDialogClosed() {
+    editingDraftId.value = ''
+    emit('closed')
+  }
+
+  async function openForApp(appRow: any) {
+    await onPick(appRow)
+  }
+
+  async function openForDraft(draftId: string, templatesOptional?: any[]) {
+    const detail = await fetchOaInstanceDraftDetail(draftId)
+    const appId = String(detail?.appId || '').trim()
+    if (!appId) {
+      ElMessage.error('草稿缺少审批模板')
+      return
+    }
+    const list = templatesOptional || []
+    let row = list.find((t: any) => t.id === appId)
+    if (!row) {
+      row = {
+        id: appId,
+        appName: detail?.appName || '发起审批'
+      }
+    }
+    await onPick(row)
+    editingDraftId.value = draftId
+    const saved = { ...(detail?.formData || {}) }
+    formData.value = await applyOwnerFieldDefaults(
+      formRule.value,
+      saved,
+      (userStore.info || userStore.getUserInfo) as any
+    )
+    formData.value = applyOvertimeKitDefaults(
+      { rule: formRule.value },
+      formData.value,
+      (userStore.info || userStore.getUserInfo) as any
+    )
+    const linked = applyConfirmationKitDateLinkage({ rule: formRule.value }, formData.value)
+    formData.value = linked.data
+    confirmationKitPrevSnapshot = { ...linked.data }
+    leaveKitPrevSnapshot = { ...formData.value }
+    tripKitPrevSnapshot = { ...formData.value }
+    const sel = detail?.initiatorSelectByNode
+    initiatorSelectByNode.value =
+      sel && typeof sel === 'object' && !Array.isArray(sel) ? { ...sel } : {}
+    await loadFlowPreview(appId)
+  }
+
+  async function openForAppWithCopy(appRow: any, copyFromInstanceId: string) {
+    await openForApp(appRow)
+    const copyId = String(copyFromInstanceId || '').trim()
+    if (!copyId) return
+    try {
+      const old = await fetchOaInstanceDetail(copyId)
+      formData.value = await applyOwnerFieldDefaults(
+        formRule.value,
+        { ...(old?.formDataJson || {}) },
+        (userStore.info || userStore.getUserInfo) as any
+      )
+      formData.value = applyOvertimeKitDefaults(
+        { rule: formRule.value },
+        formData.value,
+        (userStore.info || userStore.getUserInfo) as any
+      )
+    } catch {
+      /* 预填失败仍可手工填 */
+    }
+  }
+
+  async function saveDraft() {
+    if (!picked.value?.id) return
+    savingDraft.value = true
+    try {
+      const payload = {
+        formData: { ...formData.value },
+        initiatorSelectByNode: { ...initiatorSelectByNode.value },
+        title: picked.value.appName || ''
+      }
+      if (editingDraftId.value) {
+        await fetchUpdateOaInstanceDraft(editingDraftId.value, payload)
+      } else {
+        const created: any = await fetchCreateOaInstanceDraft({
+          appId: picked.value.id,
+          ...payload
+        })
+        if (created?.id) editingDraftId.value = created.id
+      }
+      emit('saved')
+      closeDialog()
+    } finally {
+      savingDraft.value = false
+    }
+  }
+
+  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])
+    const listFieldChanged = kits.some(
+      (k) => JSON.stringify(data[k.listField]) !== JSON.stringify(formData.value[k.listField])
+    )
+    if (changed || manualKeysChanged || daysFieldChanged || listFieldChanged) {
+      tripKitSyncing = true
+      formData.value = data
+      queueMicrotask(() => {
+        tripKitSyncing = false
+      })
+    }
+  }
+
+  function syncConfirmationKitDates() {
+    if (confirmationKitSyncing) return
+    if (!formRule.value.length) return
+    const schema = { rule: formRule.value }
+    const kits = listConfirmationKitBindings(schema)
+    if (!kits.length) return
+
+    let curr = { ...formData.value }
+    for (const kit of kits) {
+      const confField = kit.confirmationDateField
+      const hireField = kit.hireDateField
+      const probField = kit.probationMonthsField
+      if (!confField || !hireField || !probField) continue
+      const confChanged = confirmationKitPrevSnapshot[confField] !== curr[confField]
+      const hireChanged = confirmationKitPrevSnapshot[hireField] !== curr[hireField]
+      const probChanged = confirmationKitPrevSnapshot[probField] !== curr[probField]
+      if (
+        confChanged &&
+        !hireChanged &&
+        !probChanged &&
+        curr[confField] !== undefined &&
+        curr[confField] !== ''
+      ) {
+        curr = markConfirmationDateManual(curr, confField, true)
+      }
+    }
+
+    const { data, changed } = applyConfirmationKitDateLinkage(schema, curr)
+    confirmationKitPrevSnapshot = { ...data }
+    const anyDiff =
+      changed ||
+      kits.some((k) => {
+        const f = k.confirmationDateField
+        if (!f) return false
+        return (
+          data[f] !== formData.value[f] ||
+          !!data[CONFIRMATION_MANUAL_PREFIX + f] !==
+            !!formData.value[CONFIRMATION_MANUAL_PREFIX + f]
+        )
+      })
+    if (anyDiff) {
+      confirmationKitSyncing = true
+      formData.value = data
+      queueMicrotask(() => {
+        confirmationKitSyncing = false
+      })
+    }
+  }
+
+  function restoreConfirmationDateAuto() {
+    const schema = { rule: formRule.value }
+    let data = { ...formData.value }
+    for (const field of listManualConfirmationDateFields(schema, data)) {
+      data = clearConfirmationDateManualAndRelink(schema, data, field)
+    }
+    confirmationKitSyncing = true
+    formData.value = data
+    confirmationKitPrevSnapshot = { ...data }
+    queueMicrotask(() => {
+      confirmationKitSyncing = false
+    })
+  }
+
+  watch(
+    formData,
+    () => {
+      syncLeaveKitDurations()
+      syncTripKitDurations()
+      syncConfirmationKitDates()
+      scheduleFlowPreview()
+    },
+    { deep: true }
+  )
+
+  async function submit() {
+    if (!picked.value?.id) return
+    for (const s of flowSteps.value) {
+      if (s.type === 'approval') {
+        const types =
+          Array.isArray(s.assigneeTypes) && s.assigneeTypes.length
+            ? s.assigneeTypes
+            : s.assigneeType
+              ? [s.assigneeType]
+              : []
+        if (types.includes('initiator_select')) {
+          const ids = initiatorSelectByNode.value[s.id] || []
+          if (!ids.length) {
+            ElMessage.warning(`请为「${s.name || '审批人'}」选择审批人`)
+            return
+          }
+        }
+      }
+    }
+    if (fapi.value?.validate) {
+      try {
+        await fapi.value.validate()
+      } catch {
+        ElMessage.warning('请完善表单必填项')
+        return
+      }
+    }
+    submitting.value = true
+    try {
+      const payload = {
+        formData: { ...formData.value },
+        initiatorSelectByNode: { ...initiatorSelectByNode.value }
+      }
+      let data: any
+      if (editingDraftId.value) {
+        data = await fetchSubmitOaInstanceDraft(editingDraftId.value, {
+          ...payload,
+          title: picked.value.appName || ''
+        })
+      } else {
+        data = await fetchStartOaInstance({
+          appId: picked.value.id,
+          ...payload
+        })
+      }
+      emit('submitted', data)
+      closeDialog()
+    } finally {
+      submitting.value = false
+    }
+  }
+
+  defineExpose({ openForApp, openForDraft, openForAppWithCopy })
+</script>
+
+<style scoped lang="scss">
+  .oa-start__tip {
+    margin: 8px 0 0;
+    color: var(--mint-text-muted, #94a3b8);
+    font-size: 13px;
+  }
+</style>