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