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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-22 17:03:29 +08:00
1 parent 14f00d5d3a
commit f68927ce70
6 files changed
+560

No files matched your search

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