瀏覽代碼

feat(web-admin): add relatedApproval FormCreate control with picker UI

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 月之前
父節點
當前提交
f68927ce70

+ 37 - 0
web-admin/src/api/oaApproval.ts

@@ -294,3 +294,40 @@ export function fetchWithdrawOaInstance(instanceId: string) {
     showSuccessMessage: true
   })
 }
+
+// ---------------------------------------------------------------------------
+// 关联审批单
+// ---------------------------------------------------------------------------
+
+export type OaRelatedSummaryField = {
+  label: string
+  value: string
+  fullWidth?: boolean
+}
+
+export type OaRelatedSummary = {
+  id: string
+  unavailable?: boolean
+  appName: string
+  applyTime: string
+  status: string
+  fields: OaRelatedSummaryField[]
+}
+
+export function fetchOaRelatedCandidates(params?: {
+  keyword?: string
+  page?: number
+  pageSize?: number
+}) {
+  return request.get<{ list: any[]; total: number; page: number; pageSize: number }>({
+    url: `${BASE}/related-candidates`,
+    params
+  })
+}
+
+export function fetchOaRelatedSummaries(ids: string[], mode: 'picker' | 'view' = 'view') {
+  return request.get<{ list: OaRelatedSummary[] }>({
+    url: `${BASE}/related-summaries`,
+    params: { ids: ids.filter(Boolean).join(','), mode }
+  })
+}

+ 470 - 0
web-admin/src/components/oa-approval/OaRelatedApproval.vue

@@ -0,0 +1,470 @@
+<!-- FormCreate 自定义控件:关联审批单(Fresh Mint) -->
+<template>
+  <div class="oa-related-approval" :class="{ 'oa-related-approval--stack': isStack }">
+    <div v-if="!ids.length" class="oa-related-approval__empty">
+      <span class="oa-related-approval__empty-text">暂未关联审批单</span>
+      <el-button
+        v-if="!disabled"
+        type="primary"
+        link
+        class="oa-related-approval__add"
+        @click="openPicker"
+      >
+        <el-icon><Plus /></el-icon>
+        添加关联
+      </el-button>
+    </div>
+
+    <div v-else class="oa-related-approval__list">
+      <div
+        v-for="item in displayItems"
+        :key="item.id"
+        class="oa-related-approval__card"
+      >
+        <div class="oa-related-approval__head">
+          <button
+            type="button"
+            class="oa-related-approval__title"
+            :disabled="item.unavailable"
+            @click="goDetail(item.id)"
+          >
+            {{ item.unavailable ? '无法查看' : item.appName || '审批单' }}
+            <template v-if="!item.unavailable && item.applyTime">
+              · {{ formatShortTime(item.applyTime) }}
+            </template>
+          </button>
+          <div class="oa-related-approval__actions">
+            <el-button
+              v-if="!item.unavailable && (item.fields?.length || 0) > COLLAPSE_FIELD_COUNT"
+              link
+              type="primary"
+              @click="toggleExpand(item.id)"
+            >
+              {{ expanded.has(item.id) ? '收起' : '展开' }}
+            </el-button>
+            <el-button
+              v-if="!disabled"
+              link
+              type="danger"
+              @click="removeId(item.id)"
+            >
+              删除
+            </el-button>
+          </div>
+        </div>
+        <div
+          v-if="!item.unavailable && visibleFields(item).length"
+          class="oa-related-approval__fields"
+        >
+          <span
+            v-for="(f, idx) in visibleFields(item)"
+            :key="`${item.id}-${idx}`"
+            class="oa-related-approval__field"
+            :class="{ 'oa-related-approval__field--full': f.fullWidth && expanded.has(item.id) }"
+          >
+            <span class="oa-related-approval__field-label">{{ f.label }}</span>
+            <span class="oa-related-approval__field-value">{{ f.value }}</span>
+          </span>
+        </div>
+      </div>
+
+      <el-button
+        v-if="!disabled"
+        class="oa-related-approval__add-more"
+        @click="openPicker"
+      >
+        <el-icon><Plus /></el-icon>
+        添加关联
+      </el-button>
+    </div>
+
+    <el-dialog
+      v-model="pickerVisible"
+      title="选择关联审批单"
+      width="720px"
+      append-to-body
+      align-center
+      class="dialog-form-layout"
+      modal-class="protocol-dialog-mask"
+      destroy-on-close
+      @opened="loadCandidates"
+    >
+      <div class="oa-related-picker">
+        <el-input
+          v-model="keyword"
+          clearable
+          placeholder="搜索应用名 / 标题"
+          @keyup.enter="loadCandidates"
+        >
+          <template #append>
+            <el-button @click="loadCandidates">
+              <el-icon><Search /></el-icon>
+            </el-button>
+          </template>
+        </el-input>
+        <el-table
+          v-loading="pickerLoading"
+          class="list-table"
+          :data="candidates"
+          stripe
+          max-height="360"
+          @selection-change="onSelectionChange"
+        >
+          <el-table-column type="selection" width="48" :selectable="rowSelectable" />
+          <el-table-column prop="appName" label="流程名称" min-width="140" show-overflow-tooltip />
+          <el-table-column prop="title" label="标题" min-width="120" show-overflow-tooltip />
+          <el-table-column prop="applyTime" label="申请时间" min-width="160" />
+        </el-table>
+        <el-empty
+          v-if="!pickerLoading && !candidates.length"
+          description="暂无已通过的申请"
+          :image-size="64"
+        />
+        <div v-if="pickerTotal > pageSize" class="oa-related-picker__pager">
+          <el-pagination
+            :current-page="page"
+            :page-size="pageSize"
+            layout="prev, pager, next"
+            :total="pickerTotal"
+            @current-change="onPageChange"
+          />
+        </div>
+      </div>
+      <template #footer>
+        <el-button @click="pickerVisible = false">
+          <el-icon><Close /></el-icon>
+          取消
+        </el-button>
+        <el-button type="primary" :disabled="!pendingIds.length" @click="confirmPicker">
+          <el-icon><Check /></el-icon>
+          确定
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+  import { useRouter } from 'vue-router'
+  import { Check, Close, Plus, Search } from '@element-plus/icons-vue'
+  import {
+    fetchOaRelatedCandidates,
+    fetchOaRelatedSummaries,
+    type OaRelatedSummary
+  } from '@/api/oaApproval'
+
+  defineOptions({ name: 'relatedApproval' })
+
+  const COLLAPSE_FIELD_COUNT = 3
+
+  const props = defineProps<{
+    modelValue?: string[] | string | null
+    disabled?: boolean
+    formCreateInject?: Record<string, unknown>
+  }>()
+
+  const emit = defineEmits<{
+    (e: 'update:modelValue', v: string[]): void
+    (e: 'change', v: string[]): void
+  }>()
+
+  const router = useRouter()
+  const expanded = ref(new Set<string>())
+  const summaries = ref<OaRelatedSummary[]>([])
+  const isStack = ref(false)
+  let mql: MediaQueryList | null = null
+
+  const pickerVisible = ref(false)
+  const pickerLoading = ref(false)
+  const keyword = ref('')
+  const page = ref(1)
+  const pageSize = 10
+  const candidates = ref<any[]>([])
+  const pickerTotal = ref(0)
+  const pendingIds = ref<string[]>([])
+
+  const ids = computed(() => {
+    const raw = props.modelValue
+    if (Array.isArray(raw)) return raw.map((x) => String(x || '').trim()).filter(Boolean)
+    if (raw == null || raw === '') return []
+    return [String(raw).trim()].filter(Boolean)
+  })
+
+  const displayItems = computed(() => {
+    const map = new Map(summaries.value.map((s) => [s.id, s]))
+    return ids.value.map((id) => {
+      const s = map.get(id)
+      if (s) return s
+      return {
+        id,
+        unavailable: false,
+        appName: '加载中…',
+        applyTime: '',
+        status: '',
+        fields: []
+      } as OaRelatedSummary
+    })
+  })
+
+  function emitValue(next: string[]) {
+    const uniq: string[] = []
+    const seen = new Set<string>()
+    for (const id of next) {
+      const t = String(id || '').trim()
+      if (!t || seen.has(t)) continue
+      seen.add(t)
+      uniq.push(t)
+    }
+    emit('update:modelValue', uniq)
+    emit('change', uniq)
+  }
+
+  function visibleFields(item: OaRelatedSummary) {
+    const fields = item.fields || []
+    if (expanded.value.has(item.id)) return fields
+    return fields.slice(0, COLLAPSE_FIELD_COUNT)
+  }
+
+  function toggleExpand(id: string) {
+    const next = new Set(expanded.value)
+    if (next.has(id)) next.delete(id)
+    else next.add(id)
+    expanded.value = next
+  }
+
+  function removeId(id: string) {
+    emitValue(ids.value.filter((x) => x !== id))
+  }
+
+  function formatShortTime(t: string) {
+    if (!t) return ''
+    // YYYY-MM-DD HH:mm:ss → MM-DD
+    const m = t.match(/(\d{4})-(\d{2})-(\d{2})/)
+    if (m) return `${m[2]}-${m[3]}`
+    return t
+  }
+
+  function goDetail(id: string) {
+    if (!id) return
+    try {
+      router.push({ path: `/system-manage/oa-approval/instances/${id}` })
+    } catch {
+      /* designer iframe may lack router */
+    }
+  }
+
+  async function loadSummaries() {
+    const list = ids.value
+    if (!list.length) {
+      summaries.value = []
+      return
+    }
+    try {
+      const mode = props.disabled ? 'view' : 'view'
+      const res: any = await fetchOaRelatedSummaries(list, mode)
+      summaries.value = Array.isArray(res?.list) ? res.list : Array.isArray(res) ? res : []
+    } catch {
+      summaries.value = list.map((id) => ({
+        id,
+        unavailable: true,
+        appName: '',
+        applyTime: '',
+        status: '',
+        fields: []
+      }))
+    }
+  }
+
+  function openPicker() {
+    if (props.disabled) return
+    keyword.value = ''
+    page.value = 1
+    pendingIds.value = []
+    pickerVisible.value = true
+  }
+
+  async function loadCandidates() {
+    pickerLoading.value = true
+    try {
+      const res: any = await fetchOaRelatedCandidates({
+        keyword: keyword.value.trim() || undefined,
+        page: page.value,
+        pageSize
+      })
+      candidates.value = Array.isArray(res?.list) ? res.list : []
+      pickerTotal.value = Number(res?.total || 0)
+    } catch {
+      candidates.value = []
+      pickerTotal.value = 0
+    } finally {
+      pickerLoading.value = false
+    }
+  }
+
+  function rowSelectable(row: any) {
+    return !ids.value.includes(String(row?.id || ''))
+  }
+
+  function onSelectionChange(rows: any[]) {
+    pendingIds.value = (rows || []).map((r) => String(r.id || '')).filter(Boolean)
+  }
+
+  function onPageChange(p: number) {
+    page.value = p
+    void loadCandidates()
+  }
+
+  function confirmPicker() {
+    emitValue([...ids.value, ...pendingIds.value])
+    pickerVisible.value = false
+  }
+
+  function syncStack(e?: MediaQueryList | MediaQueryListEvent) {
+    const q = e && 'matches' in e ? e : mql
+    isStack.value = !!(q && q.matches)
+  }
+
+  watch(
+    () => ids.value.join(','),
+    () => {
+      void loadSummaries()
+    },
+    { immediate: true }
+  )
+
+  onMounted(() => {
+    if (typeof window !== 'undefined' && window.matchMedia) {
+      mql = window.matchMedia('(max-width: 768px)')
+      syncStack(mql)
+      mql.addEventListener?.('change', syncStack)
+    }
+  })
+
+  onBeforeUnmount(() => {
+    mql?.removeEventListener?.('change', syncStack)
+  })
+</script>
+
+<style scoped lang="scss">
+  .oa-related-approval {
+    width: 100%;
+  }
+
+  .oa-related-approval__empty {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    align-items: center;
+    padding: 10px 12px;
+    border: 1px dashed #a7f3d0;
+    border-radius: 8px;
+    background: #ecfdf5;
+  }
+
+  .oa-related-approval__empty-text {
+    color: var(--mint-text-muted, #94a3b8);
+    font-size: 13px;
+  }
+
+  .oa-related-approval__list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+  }
+
+  .oa-related-approval__card {
+    padding: 10px 12px;
+    border: 1px solid #a7f3d0;
+    border-radius: 8px;
+    background: #ecfdf5;
+  }
+
+  .oa-related-approval__head {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    justify-content: space-between;
+  }
+
+  .oa-related-approval__title {
+    padding: 0;
+    border: 0;
+    background: transparent;
+    color: var(--primary, #10b981);
+    font-weight: 600;
+    font-size: 13px;
+    text-align: left;
+    text-decoration: underline;
+    cursor: pointer;
+  }
+
+  .oa-related-approval__title:disabled {
+    color: var(--mint-text-muted, #94a3b8);
+    text-decoration: none;
+    cursor: default;
+  }
+
+  .oa-related-approval__actions {
+    display: flex;
+    flex-shrink: 0;
+    gap: 4px;
+    align-items: center;
+  }
+
+  .oa-related-approval__fields {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px 16px;
+    margin-top: 8px;
+    line-height: 1.6;
+  }
+
+  .oa-related-approval__field {
+    display: inline-flex;
+    gap: 6px;
+    max-width: 100%;
+    font-size: 12.5px;
+  }
+
+  .oa-related-approval__field--full {
+    flex: 1 1 100%;
+  }
+
+  .oa-related-approval__field-label {
+    color: var(--mint-text-muted, #94a3b8);
+  }
+
+  .oa-related-approval__field-value {
+    color: var(--mint-text, #334155);
+    word-break: break-all;
+  }
+
+  .oa-related-approval--stack .oa-related-approval__fields {
+    flex-direction: column;
+    gap: 4px;
+  }
+
+  .oa-related-approval--stack .oa-related-approval__field {
+    display: flex;
+  }
+
+  .oa-related-approval--stack .oa-related-approval__field-label::after {
+    content: ':';
+  }
+
+  .oa-related-approval__add-more {
+    align-self: flex-start;
+  }
+
+  .oa-related-picker {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  .oa-related-picker__pager {
+    display: flex;
+    justify-content: flex-end;
+  }
+</style>

+ 2 - 0
web-admin/src/main.ts

@@ -16,12 +16,14 @@ import { setupErrorHandle } from './utils/sys/error-handle'
 import FcDesigner from '@form-create/designer'
 import formCreateAutoImport from '@form-create/element-ui/auto-import'
 import { setupFormCreateUpload } from '@/utils/oa-form-create-upload'
+import { setupOaRelatedApproval } from '@/utils/oa-form-create-related-approval'
 
 // FormCreate 按字符串解析 el-input / fc-radio 等,主应用是按需自动导入,不会全局注册;
 // 必须挂上官方 auto-import,否则发起审批/详情里动态表单控件会残缺(如单选项只剩文字)。
 FcDesigner.formCreate.use(formCreateAutoImport)
 // 内置「上传」默认 action=/,改为走附件上传 API,避免 POST 前端原点 404
 setupFormCreateUpload(FcDesigner.formCreate)
+setupOaRelatedApproval(FcDesigner.formCreate)
 
 function maskSecret(value?: string) {
   if (!value) return '(empty)'

+ 2 - 0
web-admin/src/oa-fc-host/main.ts

@@ -8,6 +8,7 @@ import * as ElementPlusIconsVue from '@element-plus/icons-vue'
 import 'element-plus/dist/index.css'
 import FcDesigner from '@form-create/designer'
 import { setupFormCreateUpload } from '@/utils/oa-form-create-upload'
+import { setupOaRelatedApproval } from '@/utils/oa-form-create-related-approval'
 import App from './App.vue'
 
 const app = createApp(App)
@@ -20,4 +21,5 @@ app.use(ElementPlus, { locale: zhCn })
 app.use(FcDesigner)
 app.use(FcDesigner.formCreate)
 setupFormCreateUpload(FcDesigner.formCreate)
+setupOaRelatedApproval(FcDesigner.formCreate)
 app.mount('#app')

+ 17 - 0
web-admin/src/utils/oa-form-create-related-approval.ts

@@ -0,0 +1,17 @@
+import FcDesigner from '@form-create/designer'
+import OaRelatedApproval from '@/components/oa-approval/OaRelatedApproval.vue'
+import { relatedApprovalDragRule } from '@/utils/oa-related-approval-rule'
+
+const FLAG = '__oaRelatedApprovalConfigured'
+
+export function setupOaRelatedApproval(formCreate?: { component?: (name: string, c: unknown) => void }) {
+  if ((FcDesigner as any)[FLAG]) return
+  FcDesigner.component('relatedApproval', OaRelatedApproval)
+  if (typeof formCreate?.component === 'function') {
+    formCreate.component('relatedApproval', OaRelatedApproval)
+  }
+  if (typeof FcDesigner.addDragRule === 'function') {
+    FcDesigner.addDragRule(relatedApprovalDragRule)
+  }
+  ;(FcDesigner as any)[FLAG] = true
+}

+ 32 - 0
web-admin/src/utils/oa-related-approval-rule.ts

@@ -0,0 +1,32 @@
+import uniqueId from '@form-create/utils/lib/unique'
+
+/** FormCreate Designer DragRule:关联审批单 */
+export const relatedApprovalDragRule = {
+  menu: 'main' as const,
+  icon: 'icon-select',
+  label: '关联审批单',
+  name: 'relatedApproval',
+  input: true,
+  mask: true,
+  validate: ['array'],
+  event: ['change'],
+  rule() {
+    return {
+      type: 'relatedApproval',
+      field: uniqueId(),
+      title: '关联审批单',
+      info: '',
+      $required: false,
+      props: {}
+    }
+  },
+  props() {
+    return [
+      {
+        type: 'input',
+        field: 'formCreateInfo',
+        title: '提示信息'
+      }
+    ]
+  }
+}