Files
zkxw/web-admin/src/components/oa-approval/OaRelatedApproval.vue
T
2026-08-11 11:13:01 +08:00

831 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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">
<!-- 原生 button:避免 form.disabled 时 el-button 继承禁用 -->
<button
v-if="!item.unavailable && (item.fields?.length || 0) > COLLAPSE_FIELD_COUNT"
type="button"
class="oa-related-approval__toggle"
@click.stop="toggleExpand(item.id)"
>
{{ expanded.has(item.id) ? '收起' : '展开' }}
</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">
<template v-if="splitUrls(f.value).length">
<a
v-for="(u, ui) in splitUrls(f.value)"
:key="`${item.id}-${idx}-${ui}`"
class="oa-related-approval__link"
:href="toFullUrl(u)"
target="_blank"
rel="noopener noreferrer"
@click.stop
>
{{ displayUrlLabel(u, ui, splitUrls(f.value).length) }}
</a>
</template>
<template v-else-if="(f.relatedIds?.length || 0) > 0">
<button
type="button"
class="oa-related-approval__link-btn"
@click.stop="goDetail(f.relatedIds![0])"
>
{{ f.value }}
</button>
</template>
<template v-else>{{ f.value }}</template>
</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">
<div class="oa-related-picker__tabs" role="tablist">
<button
v-for="t in tabOptions"
:key="t.value"
type="button"
class="oa-related-picker__tab"
:class="{ 'is-active': activeTab === t.value }"
role="tab"
@click="onTabChange(t.value)"
>
{{ t.label }}
</button>
</div>
<div class="oa-related-picker__filters">
<el-input
v-model="keyword"
clearable
placeholder="搜索流程名 / 标题"
@keyup.enter="onSearch"
>
<template #append>
<el-button @click="onSearch">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</div>
<div v-loading="pickerLoading" class="oa-related-picker__list">
<div
v-for="row in candidates"
:key="row.id"
class="oa-related-picker__card"
:class="{
'is-selected': pendingIds.includes(row.id),
'is-disabled': !rowSelectable(row)
}"
@click="togglePending(row)"
>
<el-checkbox
:model-value="pendingIds.includes(row.id)"
:disabled="!rowSelectable(row)"
@click.stop
@change="() => togglePending(row)"
/>
<div
class="oa-related-picker__avatar"
:style="{ background: avatarColor(row.initiatorUserName) }"
>
{{ shortName(row.initiatorUserName) }}
</div>
<div class="oa-related-picker__main">
<div class="oa-related-picker__title">
{{ row.displayTitle || row.appName || '审批单' }}
</div>
<div v-if="row.initiatorDeptName" class="oa-related-picker__meta">
部门:{{ row.initiatorDeptName }}
</div>
<div
v-for="(f, i) in (row.previewFields || []).slice(0, 2)"
:key="i"
class="oa-related-picker__meta"
>
{{ f.label }}:{{ f.value }}
</div>
<div class="oa-related-picker__time">{{ row.applyTime }}</div>
</div>
<div class="oa-related-picker__right" :class="statusClass(row.status)">
<template v-if="row.status === 'running'">
<div v-if="row.currentApproverNames">
{{ row.currentApproverNames }}(审批人)
</div>
<div>{{ row.statusText || '处理中' }}</div>
</template>
<template v-else>{{ row.statusText || row.status }}</template>
</div>
</div>
<el-empty
v-if="!pickerLoading && !candidates.length"
description="暂无审批单"
:image-size="64"
/>
</div>
<div class="oa-related-picker__pager">
<div class="oa-related-picker__selected">
已选 <em>{{ pendingIds.length }}</em> 项
</div>
<el-pagination
v-if="pickerTotal > pageSize"
: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>
<OaInstanceDetailDialog
v-model="detailVisible"
:instance-id="detailInstanceId"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Check, Close, Plus, Search } from '@element-plus/icons-vue'
import {
fetchOaRelatedCandidates,
fetchOaRelatedSummaries,
type OaRelatedCandidate,
type OaRelatedSummary
} from '@/api/oaApproval'
import { attachmentFullUrl } from '@/composables/useAttachmentActions'
import OaInstanceDetailDialog from '@/components/oa-approval/OaInstanceDetailDialog.vue'
defineOptions({ name: 'relatedApproval' })
const COLLAPSE_FIELD_COUNT = 3
const AVATAR_COLORS = ['#3b82f6', '#10b981', '#8b5cf6', '#f59e0b', '#06b6d4', '#ef4444']
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 expanded = ref(new Set<string>())
const summaries = ref<OaRelatedSummary[]>([])
const isStack = ref(false)
let mql: MediaQueryList | null = null
const detailVisible = ref(false)
const detailInstanceId = ref('')
const pickerVisible = ref(false)
const pickerLoading = ref(false)
const keyword = ref('')
const page = ref(1)
const pageSize = 10
const candidates = ref<OaRelatedCandidate[]>([])
const pickerTotal = ref(0)
const pendingIds = ref<string[]>([])
const activeTab = ref<'mine' | 'done' | 'cc'>('mine')
const tabOptions = [
{ value: 'mine' as const, label: '我发起的' },
{ value: 'done' as const, label: '我审批的' },
{ value: 'cc' as const, label: '抄送我的' }
]
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)
}
/** 顿号/逗号分隔的 URL 列表;非 URL 文本返回空数组走纯文本展示 */
function splitUrls(value: string): string[] {
const raw = String(value || '').trim()
if (!raw) return []
const parts = raw
.split(/[、,,\s]+/)
.map((s) => s.trim())
.filter(Boolean)
if (!parts.length) return []
const looksLikeUrl = parts.every((p) => {
if (/^https?:\/\//i.test(p) || p.startsWith('/')) return true
// 纯数字/金额小数(如 100.00)不是附件 URL
if (/^\d+(\.\d+)?$/.test(p)) return false
// 扩展名须含字母,避免把 .00/.56 误判为文件后缀
return /\.[a-z][a-z0-9]{0,7}(\?|$)/i.test(p)
})
return looksLikeUrl ? parts : []
}
function toFullUrl(u: string) {
return attachmentFullUrl(u) || u
}
function displayUrlLabel(u: string, index: number, total: number) {
const name = u.split('?')[0].split('/').filter(Boolean).pop() || u
const label = decodeURIComponent(name)
return total > 1 ? `${index + 1}. ${label}` : label
}
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
detailInstanceId.value = id
detailVisible.value = true
}
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
activeTab.value = 'mine'
keyword.value = ''
page.value = 1
pendingIds.value = []
pickerVisible.value = true
}
async function loadCandidates() {
pickerLoading.value = true
try {
const res: any = await fetchOaRelatedCandidates({
tab: activeTab.value,
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 onTabChange(tab: 'mine' | 'done' | 'cc') {
if (activeTab.value === tab) return
activeTab.value = tab
page.value = 1
pendingIds.value = []
void loadCandidates()
}
function onSearch() {
page.value = 1
void loadCandidates()
}
function rowSelectable(row: OaRelatedCandidate) {
return !ids.value.includes(String(row?.id || ''))
}
function togglePending(row: OaRelatedCandidate) {
if (!rowSelectable(row)) return
const id = String(row.id || '')
if (!id) return
const idx = pendingIds.value.indexOf(id)
if (idx >= 0) pendingIds.value = pendingIds.value.filter((x) => x !== id)
else pendingIds.value = [...pendingIds.value, id]
}
function shortName(name?: string) {
const s = String(name || '').trim()
if (!s) return '?'
return s.length <= 2 ? s : s.slice(-2)
}
function avatarColor(name?: string) {
const s = String(name || '')
let h = 0
for (let i = 0; i < s.length; i++) h = (h + s.charCodeAt(i) * 17) % AVATAR_COLORS.length
return AVATAR_COLORS[h]
}
function statusClass(status?: string) {
const s = String(status || '').trim()
if (s === 'completed') return 'is-success'
if (s === 'rejected') return 'is-danger'
if (s === 'cancelled') return 'is-muted'
return 'is-warning'
}
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__toggle {
padding: 0;
border: 0;
background: transparent;
color: var(--primary, #10b981);
font-size: 13px;
line-height: 1.5;
cursor: pointer;
}
.oa-related-approval__toggle:hover {
color: var(--primary-dark, #059669);
}
.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 {
display: inline-flex;
flex-wrap: wrap;
gap: 4px 10px;
color: var(--mint-text, #334155);
word-break: break-all;
}
.oa-related-approval__link {
color: var(--primary, #10b981);
text-decoration: underline;
}
.oa-related-approval__link:hover {
color: var(--primary-dark, #059669);
}
.oa-related-approval__link-btn {
padding: 0;
border: 0;
background: transparent;
color: var(--primary, #10b981);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.oa-related-approval__link-btn:hover {
color: var(--primary-dark, #059669);
}
.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: 0;
}
.oa-related-picker__tabs {
display: flex;
gap: 0;
margin: 0 -4px 0;
border-bottom: 1px solid var(--mint-border, #e2e8f0);
}
.oa-related-picker__tab {
position: relative;
height: 40px;
padding: 0 14px;
border: 0;
background: transparent;
color: var(--mint-text-muted, #64748b);
font-size: 14px;
cursor: pointer;
}
.oa-related-picker__tab:hover {
color: var(--mint-text, #1e293b);
}
.oa-related-picker__tab.is-active {
color: var(--primary-dark, #059669);
font-weight: 600;
}
.oa-related-picker__tab.is-active::after {
content: '';
position: absolute;
right: 10px;
bottom: 0;
left: 10px;
height: 2px;
border-radius: 1px;
background: var(--primary, #10b981);
}
.oa-related-picker__filters {
padding: 12px 0;
}
.oa-related-picker__list {
max-height: 360px;
overflow: auto;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 8px;
}
.oa-related-picker__card {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 12px 14px;
border-bottom: 1px solid #f1f5f9;
cursor: pointer;
transition: background 0.12s;
}
.oa-related-picker__card:last-child {
border-bottom: 0;
}
.oa-related-picker__card:hover {
background: #f8fafc;
}
.oa-related-picker__card.is-selected {
background: var(--primary-bg, #ecfdf5);
}
.oa-related-picker__card.is-disabled {
opacity: 0.55;
cursor: not-allowed;
}
.oa-related-picker__avatar {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin-top: 2px;
border-radius: 8px;
color: #fff;
font-weight: 600;
font-size: 13px;
letter-spacing: 0.5px;
}
.oa-related-picker__main {
flex: 1;
min-width: 0;
}
.oa-related-picker__title {
margin: 0 0 4px;
color: var(--mint-text, #1e293b);
font-weight: 600;
font-size: 14px;
line-height: 1.4;
}
.oa-related-picker__meta {
overflow: hidden;
color: var(--mint-text-muted, #64748b);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.oa-related-picker__time {
margin-top: 6px;
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
}
.oa-related-picker__right {
flex-shrink: 0;
min-width: 96px;
padding-top: 2px;
font-size: 12.5px;
font-weight: 500;
text-align: right;
line-height: 1.4;
}
.oa-related-picker__right.is-warning {
color: #d97706;
}
.oa-related-picker__right.is-success {
color: #059669;
}
.oa-related-picker__right.is-danger {
color: #ef4444;
}
.oa-related-picker__right.is-muted {
color: #64748b;
}
.oa-related-picker__pager {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
}
.oa-related-picker__selected {
color: var(--mint-text-muted, #64748b);
font-size: 13px;
}
.oa-related-picker__selected em {
font-style: normal;
font-weight: 600;
color: var(--primary-dark, #059669);
}
</style>