feat(web-admin): add relatedApproval FormCreate control with picker UI
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
14f00d5d3a
commit
f68927ce70
6 files changed
+560
No files matched your search
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)'
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: '提示信息'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user