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

feat(web-admin): register receiveAccount FormCreate control.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 месяцев назад
Родитель
Сommit
cf61ce340d

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

@@ -71,6 +71,50 @@ export function fetchReplaceOaRoleUsers(roleId: string, userIds: string[]) {
   })
 }
 
+// ---------------------------------------------------------------------------
+// 收款账户
+// ---------------------------------------------------------------------------
+
+export function fetchOaReceiveAccountList(params?: {
+  keyword?: string
+  accountType?: string
+  page?: number
+  pageSize?: number
+}) {
+  return request.get<{ list: any[]; total: number }>({
+    url: `${BASE}/receive-accounts`,
+    params
+  })
+}
+
+export function fetchOaReceiveAccountOptions() {
+  return request.get<{ list: any[] }>({ url: `${BASE}/receive-accounts/options` })
+}
+
+export function fetchOaReceiveAccountDetail(id: string) {
+  return request.get<any>({ url: `${BASE}/receive-accounts/${id}` })
+}
+
+export function fetchCreateOaReceiveAccount(data: Record<string, unknown>) {
+  return request.post({ url: `${BASE}/receive-accounts`, data, showSuccessMessage: true })
+}
+
+export function fetchUpdateOaReceiveAccount(id: string, data: Record<string, unknown>) {
+  return request.put({ url: `${BASE}/receive-accounts/${id}`, data, showSuccessMessage: true })
+}
+
+export function fetchDeleteOaReceiveAccount(id: string) {
+  return request.del({ url: `${BASE}/receive-accounts/${id}`, showSuccessMessage: true })
+}
+
+export function fetchBatchDeleteOaReceiveAccounts(ids: string[]) {
+  return request.post({
+    url: `${BASE}/receive-accounts/batch-delete`,
+    data: { ids },
+    showSuccessMessage: true
+  })
+}
+
 // ---------------------------------------------------------------------------
 // 分组 / 应用 / 草稿 / 发布
 // ---------------------------------------------------------------------------

+ 316 - 0
web-admin/src/components/oa-approval/OaReceiveAccountSelect.vue

@@ -0,0 +1,316 @@
+<!-- FormCreate 自定义控件:收款账户选择(Fresh Mint · PC 下拉 + 窄屏抽屉;预览画布全宽) -->
+<template>
+  <div class="oa-receive-account-select">
+    <template v-if="disabled">
+      <span class="oa-receive-account-select__readonly">{{ displayText || '—' }}</span>
+    </template>
+
+    <template v-else-if="isNarrow">
+      <button type="button" class="oa-receive-account-select__trigger" @click="openDrawer">
+        <span v-if="displayText" class="oa-receive-account-select__trigger-text">{{
+          displayText
+        }}</span>
+        <span v-else class="oa-receive-account-select__placeholder">请选择收款账户</span>
+        <span class="oa-receive-account-select__chev">▾</span>
+      </button>
+      <el-drawer
+        v-model="drawerVisible"
+        title="选择收款账户"
+        direction="btt"
+        size="75%"
+        append-to-body
+        :close-on-click-modal="false"
+        class="oa-receive-account-drawer"
+        modal-class="protocol-dialog-mask"
+      >
+        <el-input
+          v-model="keyword"
+          clearable
+          placeholder="搜索账户名称或账号"
+          class="oa-receive-account-select__search"
+        />
+        <div class="oa-receive-account-select__list">
+          <label
+            v-for="a in filteredOptions"
+            :key="a.id"
+            class="oa-receive-account-select__row"
+          >
+            <el-checkbox
+              :model-value="pendingIds.includes(a.id)"
+              @change="(v: boolean | string | number) => onNarrowCheck(a.id, !!v)"
+            />
+            <span class="oa-receive-account-select__row-label">{{ a.label }}</span>
+          </label>
+          <div v-if="!filteredOptions.length" class="oa-receive-account-select__empty">
+            暂无收款账户
+          </div>
+        </div>
+        <template #footer>
+          <el-button @click="drawerVisible = false">
+            <el-icon><Close /></el-icon>
+            取消
+          </el-button>
+          <el-button type="primary" @click="confirmDrawer">
+            <el-icon><Check /></el-icon>
+            确定
+          </el-button>
+        </template>
+      </el-drawer>
+    </template>
+
+    <el-select
+      v-else
+      :model-value="selectModel"
+      class="oa-receive-account-select__select"
+      filterable
+      clearable
+      multiple
+      placeholder="请选择收款账户"
+      @update:model-value="onSelectChange"
+    >
+      <el-option
+        v-for="a in accountOptions"
+        :key="a.id"
+        :label="a.label"
+        :value="a.id"
+      />
+    </el-select>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+  import { Check, Close } from '@element-plus/icons-vue'
+  import { ElMessage } from 'element-plus'
+  import { fetchOaReceiveAccountOptions } from '@/api/oaApproval'
+  import {
+    formatReceiveAccountDisplay,
+    normalizeReceiveAccountModelValue,
+    optionLabelFromRow,
+    snapshotFromRow,
+    type ReceiveAccountSnapshot
+  } from '@/utils/oa-receive-account-value'
+
+  defineOptions({ name: 'receiveAccount' })
+
+  const props = defineProps<{
+    modelValue?: unknown
+    disabled?: boolean
+    formCreateInject?: Record<string, unknown>
+  }>()
+
+  const emit = defineEmits<{
+    (e: 'update:modelValue', v: ReceiveAccountSnapshot[]): void
+    (e: 'change', v: ReceiveAccountSnapshot[]): void
+  }>()
+
+  type AccountOpt = { id: string; label: string }
+
+  const snapshotMap = ref(new Map<string, ReceiveAccountSnapshot>())
+  const accountOptions = ref<AccountOpt[]>([])
+  const isNarrow = ref(false)
+  const drawerVisible = ref(false)
+  const keyword = ref('')
+  const pendingIds = ref<string[]>([])
+  let mql: MediaQueryList | null = null
+
+  const normalized = computed(() => normalizeReceiveAccountModelValue(props.modelValue))
+
+  const displayText = computed(() => formatReceiveAccountDisplay(normalized.value))
+
+  const selectModel = computed(() => normalized.value.map((x) => x.id))
+
+  const filteredOptions = computed(() => {
+    const q = keyword.value.trim().toLowerCase()
+    if (!q) return accountOptions.value
+    return accountOptions.value.filter(
+      (a) => a.label.toLowerCase().includes(q) || a.id.toLowerCase().includes(q)
+    )
+  })
+
+  function emitFromIds(ids: string[]) {
+    const items: ReceiveAccountSnapshot[] = []
+    const seen = new Set<string>()
+    const prevMap = new Map(normalized.value.map((x) => [x.id, x]))
+    for (const id of ids) {
+      const tid = String(id || '').trim()
+      if (!tid || seen.has(tid)) continue
+      seen.add(tid)
+      const snap = snapshotMap.value.get(tid) || prevMap.get(tid)
+      if (snap) items.push(snap)
+    }
+    emit('update:modelValue', items)
+    emit('change', items)
+  }
+
+  function onSelectChange(val: string | string[] | null | undefined) {
+    emitFromIds(Array.isArray(val) ? val.map(String) : [])
+  }
+
+  function openDrawer() {
+    pendingIds.value = normalized.value.map((x) => x.id)
+    keyword.value = ''
+    drawerVisible.value = true
+  }
+
+  function onNarrowCheck(id: string, checked: boolean) {
+    const set = new Set(pendingIds.value)
+    if (checked) set.add(id)
+    else set.delete(id)
+    pendingIds.value = [...set]
+  }
+
+  function confirmDrawer() {
+    emitFromIds(pendingIds.value)
+    drawerVisible.value = false
+  }
+
+  async function loadOptions() {
+    try {
+      const res = await fetchOaReceiveAccountOptions()
+      const list = res?.list || []
+      const map = new Map<string, ReceiveAccountSnapshot>()
+      const opts: AccountOpt[] = []
+      for (const row of list) {
+        const snap = snapshotFromRow(row)
+        if (!snap.id) continue
+        map.set(snap.id, snap)
+        opts.push({ id: snap.id, label: optionLabelFromRow(snap) })
+      }
+      // 保留已选快照(主数据可能已删除该账户)
+      for (const x of normalized.value) {
+        if (!map.has(x.id)) map.set(x.id, x)
+      }
+      snapshotMap.value = map
+      accountOptions.value = opts
+    } catch {
+      snapshotMap.value = new Map()
+      accountOptions.value = []
+      ElMessage.error('加载收款账户选项失败')
+    }
+  }
+
+  function syncNarrow(e?: MediaQueryList | MediaQueryListEvent) {
+    const matches = e && 'matches' in e ? e.matches : !!mql?.matches
+    isNarrow.value = matches
+  }
+
+  onMounted(() => {
+    loadOptions()
+    mql = window.matchMedia('(max-width: 767px)')
+    syncNarrow(mql)
+    mql.addEventListener('change', syncNarrow)
+  })
+
+  onBeforeUnmount(() => {
+    mql?.removeEventListener('change', syncNarrow)
+  })
+</script>
+
+<style scoped lang="scss">
+  .oa-receive-account-select {
+    box-sizing: border-box;
+    width: 100%;
+    max-width: 100%;
+    min-width: 0;
+  }
+
+  .oa-receive-account-select__select {
+    width: 100%;
+    max-width: 100%;
+  }
+
+  .oa-receive-account-select__select :deep(.el-select),
+  .oa-receive-account-select__select :deep(.el-select__wrapper),
+  .oa-receive-account-select__select :deep(.el-tooltip__trigger) {
+    width: 100%;
+    max-width: 100%;
+    min-width: 0;
+  }
+
+  .oa-receive-account-select__select :deep(.el-select__selection) {
+    flex-wrap: wrap;
+  }
+
+  .oa-receive-account-select__select :deep(.el-select__wrapper) {
+    min-height: 40px;
+  }
+
+  .oa-receive-account-select__trigger {
+    display: flex;
+    box-sizing: border-box;
+    align-items: center;
+    justify-content: space-between;
+    width: 100%;
+    max-width: 100%;
+    min-height: 40px;
+    padding: 4px 12px;
+    border: 1px solid var(--mint-border, #cbd5e1);
+    border-radius: 8px;
+    background: #fff;
+    text-align: left;
+    cursor: pointer;
+  }
+
+  .oa-receive-account-select__readonly {
+    color: var(--mint-text, #0f172a);
+    font-size: 14px;
+    line-height: 1.5;
+    white-space: pre-line;
+    word-break: break-all;
+  }
+
+  .oa-receive-account-select__placeholder {
+    color: var(--mint-text-muted, #94a3b8);
+  }
+
+  .oa-receive-account-select__trigger-text {
+    min-width: 0;
+    color: var(--mint-text, #0f172a);
+    white-space: pre-line;
+    word-break: break-all;
+  }
+
+  .oa-receive-account-select__chev {
+    flex-shrink: 0;
+    margin-left: 8px;
+    color: var(--mint-text-muted, #94a3b8);
+    font-size: 11px;
+  }
+
+  .oa-receive-account-select__search {
+    margin-bottom: 10px;
+  }
+
+  .oa-receive-account-select__list {
+    max-height: 50vh;
+    overflow: auto;
+  }
+
+  .oa-receive-account-select__row {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    min-height: 44px;
+    padding: 6px 4px;
+    cursor: pointer;
+  }
+
+  .oa-receive-account-select__row-label {
+    min-width: 0;
+    color: var(--mint-text, #0f172a);
+  }
+
+  .oa-receive-account-select__empty {
+    padding: 16px;
+    color: var(--mint-text-muted, #94a3b8);
+    text-align: center;
+  }
+
+  @media (max-width: 767px) {
+    .oa-receive-account-select__select :deep(.el-select__wrapper),
+    .oa-receive-account-select__trigger {
+      min-height: 44px;
+    }
+  }
+</style>

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

@@ -20,6 +20,7 @@ import { setupOaRelatedApproval } from '@/utils/oa-form-create-related-approval'
 import { setupOaDeptSelect } from '@/utils/oa-form-create-dept'
 import { setupOaMoneyField } from '@/utils/oa-form-create-money'
 import { setupOaPersonSelect } from '@/utils/oa-form-create-person'
+import { setupOaReceiveAccountSelect } from '@/utils/oa-form-create-receive-account'
 
 // FormCreate 按字符串解析 el-input / fc-radio 等,主应用是按需自动导入,不会全局注册;
 // 必须挂上官方 auto-import,否则发起审批/详情里动态表单控件会残缺(如单选项只剩文字)。
@@ -30,6 +31,7 @@ setupOaRelatedApproval(FcDesigner.formCreate)
 setupOaDeptSelect(FcDesigner.formCreate)
 setupOaMoneyField(FcDesigner.formCreate)
 setupOaPersonSelect(FcDesigner.formCreate)
+setupOaReceiveAccountSelect(FcDesigner.formCreate)
 
 function maskSecret(value?: string) {
   if (!value) return '(empty)'

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

@@ -17,6 +17,7 @@ import { setupOaRelatedApproval } from '@/utils/oa-form-create-related-approval'
 import { setupOaDeptSelect } from '@/utils/oa-form-create-dept'
 import { setupOaMoneyField } from '@/utils/oa-form-create-money'
 import { setupOaPersonSelect } from '@/utils/oa-form-create-person'
+import { setupOaReceiveAccountSelect } from '@/utils/oa-form-create-receive-account'
 import App from './App.vue'
 
 const app = createApp(App)
@@ -37,4 +38,5 @@ setupOaRelatedApproval(FcDesigner.formCreate)
 setupOaDeptSelect(FcDesigner.formCreate)
 setupOaMoneyField(FcDesigner.formCreate)
 setupOaPersonSelect(FcDesigner.formCreate)
+setupOaReceiveAccountSelect(FcDesigner.formCreate)
 app.mount('#app')

+ 7 - 0
web-admin/src/utils/oa-approval-print.ts

@@ -15,6 +15,7 @@ import { formatProgressPersonLines } from '@/utils/oa-progress-person-lines'
 import { formatDeptDisplay } from '@/utils/oa-dept-value'
 import { formatPersonDisplay } from '@/utils/oa-person-value'
 import { formatMoneyDisplay } from '@/utils/money-format'
+import { formatReceiveAccountDisplay } from '@/utils/oa-receive-account-value'
 import { toRmbUpper } from '@/utils/rmb-upper'
 
 export const OA_PRINT_COMPANY_NAME = '安徽中科兴皖医学研究有限公司'
@@ -274,6 +275,12 @@ async function walkRules(
       continue
     }
 
+    if (type === 'receiveAccount') {
+      const text = formatReceiveAccountDisplay(value)
+      out.push({ label, text: text || '-' })
+      continue
+    }
+
     if (UPLOAD_TYPES.has(type) || String(rule.name || '').toLowerCase().includes('upload')) {
       const urls = extractUrls(value)
       out.push({ label, urls: urls.length ? urls : [], text: urls.length ? undefined : '-' })

+ 19 - 0
web-admin/src/utils/oa-form-create-receive-account.ts

@@ -0,0 +1,19 @@
+import FcDesigner from '@form-create/designer'
+import OaReceiveAccountSelect from '@/components/oa-approval/OaReceiveAccountSelect.vue'
+import { receiveAccountSelectDragRule } from '@/utils/oa-receive-account-rule'
+
+const FLAG = '__oaReceiveAccountSelectConfigured'
+
+export function setupOaReceiveAccountSelect(formCreate?: {
+  component?: (name: string, c: unknown) => void
+}) {
+  if ((FcDesigner as any)[FLAG]) return
+  FcDesigner.component('receiveAccount', OaReceiveAccountSelect)
+  if (typeof formCreate?.component === 'function') {
+    formCreate.component('receiveAccount', OaReceiveAccountSelect)
+  }
+  if (typeof FcDesigner.addDragRule === 'function') {
+    FcDesigner.addDragRule(receiveAccountSelectDragRule)
+  }
+  ;(FcDesigner as any)[FLAG] = true
+}

+ 45 - 0
web-admin/src/utils/oa-receive-account-rule.ts

@@ -0,0 +1,45 @@
+/** 与 FormCreate 官方 unique 同类:生成不重复 field */
+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'
+  )
+}
+
+/** FormCreate Designer DragRule:收款账户选择 */
+export const receiveAccountSelectDragRule = {
+  menu: 'main' as const,
+  icon: 'icon-input',
+  label: '收款账户',
+  name: 'receiveAccount',
+  input: true,
+  mask: true,
+  validate: ['string', 'array', 'object'],
+  event: ['change'],
+  rule() {
+    return {
+      type: 'receiveAccount',
+      field: uniqueId(),
+      title: '收款账户',
+      info: '',
+      $required: false,
+      props: {
+        multiple: true
+      }
+    }
+  },
+  props() {
+    return [
+      {
+        type: 'input',
+        field: 'formCreateInfo',
+        title: '提示信息'
+      }
+    ]
+  }
+}