This commit is contained in:
zlwu committed 2026-07-21 15:23:59 +08:00
1 parent 417d6c9c51
commit c8fe0cdc20
102 files changed
+19847 -12224

No files matched your search

@@ -260,7 +260,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { UploadRequestOptions } from 'element-plus'
@@ -536,10 +536,14 @@
{ debounce: 200 }
)
onMounted(async () => {
async function refresh() {
await loadGroups()
await loadData()
})
}
onMounted(refresh)
// KeepAlive 下从设计器返回时不会再次 onMounted,需在激活时刷新列表
onActivated(refresh)
</script>
<style scoped lang="scss">
@@ -70,6 +70,7 @@
<div v-show="activeStep === 'form'" class="oa-designer__form-step">
<OaFormDesignerEmbed
v-if="formDesignerMounted"
:key="formDesignerEpoch"
ref="formEmbedRef"
v-model="formSchema"
:flow-condition-fields="flowConditionFields"
@@ -108,7 +109,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { UploadRequestOptions } from 'element-plus'
@@ -137,10 +138,13 @@
const activeStep = ref('basic')
const draft = ref<any>(null)
/** iframe 表单设计器(与主应用 Tailwind 隔离) */
const formEmbedRef = ref<{ fetchSchema: () => Promise<{ rule: unknown; option: unknown }> } | null>(
null
)
const formEmbedRef = ref<{
fetchSchema: () => Promise<{ rule: unknown; option: unknown }>
reapplySchema?: () => void
} | null>(null)
const formDesignerMounted = ref(false)
/** 每次进入表单 Tab 递增,强制重建 iframe,避免 display:none 后画布丢控件 */
const formDesignerEpoch = ref(0)
const formSchema = ref<{ rule: unknown; option: unknown }>({ rule: [], option: {} })
const basic = reactive({
@@ -268,6 +272,19 @@
advancedSettingsJson: { ...advanced }
})
draft.value = data
// 以服务端回包为准同步本地,避免与 iframe 内容漂移
const fs = data?.formSchemaJson
if (fs) {
try {
const plain = JSON.parse(JSON.stringify(fs))
formSchema.value = {
rule: Array.isArray(plain?.rule) ? plain.rule : Array.isArray(plain) ? plain : [],
option: plain?.option && typeof plain.option === 'object' ? plain.option : {}
}
} catch {
/* keep collected schema */
}
}
} finally {
saving.value = false
}
@@ -307,13 +324,20 @@
}
}
if (name === 'form') {
// 先卸载再挂载:隐藏态 iframe 内 FormCreate 画布易丢控件,重建后按 formSchema 回显
formDesignerMounted.value = false
formDesignerEpoch.value += 1
await nextTick()
formDesignerMounted.value = true
await nextTick()
formEmbedRef.value?.reapplySchema?.()
}
}
onMounted(async () => {
await loadDraft()
if (activeStep.value === 'form') {
formDesignerEpoch.value += 1
formDesignerMounted.value = true
}
})
@@ -153,6 +153,10 @@
import OaProgressChain from '@/components/oa-approval/OaProgressChain.vue'
import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
import { useUserStore } from '@/store/modules/user'
import {
OA_FC_DEFAULT_GUTTER,
ensureOaUploadRuleProps
} from '@/utils/oa-form-create-upload'
defineOptions({ name: 'OaApprovalInstanceDetail' })
@@ -166,6 +170,7 @@
const viewRule = ref<any[]>([])
const viewOption = ref({
form: { disabled: true, labelPosition: 'top' },
row: { gutter: OA_FC_DEFAULT_GUTTER },
submitBtn: false,
resetBtn: false
})
@@ -182,7 +187,7 @@
const list = detail.value?.progress
if (!Array.isArray(list) || !list.length) return []
return list.map((p: any, idx: number) => ({
key: `${p.kind}-${p.taskId || p.nodeKey || idx}-${p.roundNo || 1}`,
key: `${p.kind}-${p.nodeKey || p.taskId || idx}-${p.roundNo || 1}`,
title: p.title || '-',
status: p.status || 'info',
timestamp: p.timestamp || '',
@@ -191,7 +196,18 @@
commentText: p.commentText || '',
taskType: p.taskType || '',
kind: p.kind || '',
roundNo: p.roundNo || 1
roundNo: p.roundNo || 1,
signMode: p.signMode || '',
people: Array.isArray(p.people)
? p.people.map((x: any) => ({
id: x.id || '',
nickName: x.nickName || x.id || '',
status: x.status || '',
timestamp: x.timestamp || '',
commentText: x.commentText || '',
taskId: x.taskId || ''
}))
: []
}))
})
@@ -246,10 +262,16 @@
function applySchema(schema: any, data: any) {
viewFormData.value = { ...(data || {}) }
const rule = Array.isArray(schema?.rule) ? schema.rule : Array.isArray(schema) ? schema : []
viewRule.value = rule
viewRule.value = ensureOaUploadRuleProps(rule)
const savedGutter = Number(schema?.option?.row?.gutter)
viewOption.value = {
...(schema?.option || {}),
form: { ...(schema?.option?.form || {}), disabled: true, labelPosition: 'top' },
// 顶层字段 col.span 布局用;栅格组件 fcRow 由 ensureOaUploadRuleProps 处理
row: {
...(schema?.option?.row || {}),
gutter: savedGutter > 0 ? savedGutter : OA_FC_DEFAULT_GUTTER
},
submitBtn: false,
resetBtn: false
}
+317 -21
View File
@@ -218,25 +218,81 @@
:close-on-click-modal="false"
>
<el-form label-position="top">
<el-form-item label="绑定用户">
<el-select
v-model="memberUserIds"
multiple
filterable
clearable
collapse-tags
collapse-tags-tooltip
placeholder="请选择用户"
style="width: 100%"
:loading="memberLoading"
>
<el-option
v-for="u in userOptions"
:key="u.id"
:label="`${u.nickName}(${u.userNo})`"
:value="u.id"
/>
</el-select>
<el-form-item>
<template #label>
绑定用户
<span class="form-label-tip">可多选,已选成员将展示在下方列表</span>
</template>
<div v-loading="memberLoading" class="member-picker">
<el-select
v-model="memberPickBuffer"
filterable
clearable
:loading="memberLoading"
placeholder="搜索姓名 / 工号,点击添加"
style="width: 100%"
popper-class="oa-role-member-select-popper"
@change="onMemberPick"
>
<el-option
v-for="u in userOptions"
:key="u.id"
:label="memberOptionLabel(u)"
:value="u.id"
:disabled="memberUserIds.includes(u.id)"
>
<div class="member-option">
<span class="member-avatar" :style="{ background: avatarColor(u.id) }">
{{ avatarInitial(u.nickName) }}
</span>
<span class="member-option__meta">
<span class="member-option__name">{{ u.nickName || '未命名' }}</span>
<span v-if="memberOptionSub(u)" class="member-option__sub">
{{ memberOptionSub(u) }}
</span>
</span>
<el-tag
v-if="memberUserIds.includes(u.id)"
size="small"
type="info"
effect="plain"
class="member-option__tag"
>
已选
</el-tag>
</div>
</el-option>
</el-select>
<div
class="member-chips"
:class="{ 'is-empty': !memberUserIds.length }"
>
<template v-if="memberUserIds.length">
<span v-for="id in memberUserIds" :key="id" class="member-chip">
<span
class="member-avatar member-avatar--sm"
:style="{ background: avatarColor(id) }"
>
{{ avatarInitial(resolveUser(id)?.nickName) }}
</span>
<span class="member-chip__name" :title="chipTitle(id)">
{{ resolveUser(id)?.nickName || id }}
</span>
<button
type="button"
class="member-chip__rm"
:aria-label="`移除 ${resolveUser(id)?.nickName || id}`"
@click="removeMember(id)"
>
×
</button>
</span>
</template>
<template v-else>暂未选择成员,请从上方搜索添加</template>
</div>
<p class="member-picked-count">已选 {{ memberUserIds.length }} 人</p>
</div>
</el-form-item>
</el-form>
<template #footer>
@@ -261,7 +317,7 @@
<script setup lang="ts">
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { onMounted, reactive, ref } from 'vue'
import { nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
@@ -310,12 +366,74 @@
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
type UserOption = { id: string; nickName: string; userNo: string }
const AVATAR_COLORS = [
'#059669',
'#0ea5e9',
'#8b5cf6',
'#d97706',
'#ef4444',
'#14b8a6',
'#6366f1',
'#ec4899'
]
const memberVisible = ref(false)
const memberRole = ref<any>(null)
const memberUserIds = ref<string[]>([])
const memberPickBuffer = ref('')
const memberLoading = ref(false)
const memberSubmitLoading = ref(false)
const userOptions = ref<Array<{ id: string; nickName: string; userNo: string }>>([])
const userOptions = ref<UserOption[]>([])
function avatarColor(id: string) {
let hash = 0
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0
return AVATAR_COLORS[hash % AVATAR_COLORS.length]
}
function avatarInitial(name?: string) {
const t = (name || '?').trim()
return t ? t.slice(0, 1) : '?'
}
function resolveUser(id: string) {
return userOptions.value.find((u) => u.id === id)
}
function memberOptionLabel(u: UserOption) {
const name = (u.nickName || '').trim() || '未命名'
const no = (u.userNo || '').trim()
if (no && no !== name) return `${name}(${no})`
return name
}
function memberOptionSub(u: UserOption) {
const name = (u.nickName || '').trim()
const no = (u.userNo || '').trim()
if (!no || no === name) return ''
return `账号 ${no}`
}
function chipTitle(id: string) {
const u = resolveUser(id)
return u ? memberOptionLabel(u) : id
}
function onMemberPick(id: string) {
if (!id) return
if (!memberUserIds.value.includes(id)) {
memberUserIds.value = [...memberUserIds.value, id]
}
nextTick(() => {
memberPickBuffer.value = ''
})
}
function removeMember(id: string) {
memberUserIds.value = memberUserIds.value.filter((x) => x !== id)
}
async function loadData() {
loading.value = true
@@ -426,6 +544,8 @@
async function handleMembers(row: any) {
memberRole.value = row
memberPickBuffer.value = ''
memberUserIds.value = []
memberVisible.value = true
memberLoading.value = true
try {
@@ -468,4 +588,180 @@
margin-bottom: 0;
}
}
.member-picker {
width: 100%;
}
.member-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 44px;
margin-top: 12px;
padding: 12px;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 8px;
&.is-empty {
align-items: center;
justify-content: center;
color: var(--mint-text-muted, #94a3b8);
font-size: 13px;
}
}
.member-chip {
display: inline-flex;
gap: 8px;
align-items: center;
max-width: 100%;
padding: 4px 8px 4px 4px;
background: #fff;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 999px;
}
.member-chip__name {
max-width: 120px;
overflow: hidden;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
}
.member-chip__rm {
display: grid;
flex-shrink: 0;
place-items: center;
width: 20px;
height: 20px;
padding: 0;
color: var(--mint-text-muted, #64748b);
font-size: 14px;
line-height: 1;
background: #f1f5f9;
border: 0;
border-radius: 50%;
cursor: pointer;
&:hover {
color: #dc2626;
background: #fee2e2;
}
}
.member-picked-count {
margin: 8px 0 0;
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
}
.member-avatar {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: #fff;
font-size: 13px;
font-weight: 700;
line-height: 1;
text-align: center;
border-radius: 50%;
&--sm {
width: 28px;
height: 28px;
font-size: 11px;
}
}
</style>
<!-- 下拉挂 body,须非 scoped -->
<style lang="scss">
.oa-role-member-select-popper {
.el-select-dropdown__list {
padding: 8px;
}
.el-select-dropdown__item {
height: auto !important;
min-height: 56px;
margin-bottom: 8px;
padding: 10px 12px !important;
line-height: normal !important;
border-radius: 8px;
&:last-child {
margin-bottom: 0;
}
&.is-disabled {
opacity: 0.55;
}
&.hover,
&:hover {
background: var(--primary-bg, #ecfdf5);
}
}
.member-option {
display: flex;
gap: 12px;
align-items: center;
width: 100%;
min-height: 36px;
}
.member-option__meta {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
justify-content: center;
min-width: 0;
}
.member-option__name {
overflow: hidden;
color: var(--mint-text, #0f172a);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.member-option__sub {
overflow: hidden;
color: var(--mint-text-muted, #64748b);
font-size: 12px;
white-space: nowrap;
text-overflow: ellipsis;
}
.member-option__tag {
flex-shrink: 0;
margin-left: auto;
}
.member-avatar {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: #fff;
font-size: 13px;
font-weight: 700;
line-height: 1;
text-align: center;
border-radius: 50%;
}
}
</style>
@@ -131,6 +131,8 @@
v-if="!detailLoading"
:steps="flowSteps"
:loading="previewLoading"
:resolved="flowResolved"
:pending-message="flowPendingMessage"
/>
</div>
<template #footer>
@@ -166,6 +168,10 @@
import OaFlowPreview from '@/components/oa-approval/OaFlowPreview.vue'
import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
import { resolveOaAppIconUrl } from '@/utils/oa-app-icons'
import {
OA_FC_DEFAULT_GUTTER,
ensureOaUploadRuleProps
} from '@/utils/oa-form-create-upload'
defineOptions({ name: 'OaApprovalStart' })
@@ -181,7 +187,11 @@
const picked = ref<any>(null)
const formData = ref<Record<string, any>>({})
const formRule = ref<any[]>([])
const formOption = ref<Record<string, any>>({ submitBtn: false, resetBtn: false })
const formOption = ref<Record<string, any>>({
submitBtn: false,
resetBtn: false,
row: { gutter: OA_FC_DEFAULT_GUTTER }
})
const fapi = ref<any>(null)
const submitting = ref(false)
const needInitiatorSelect = ref(false)
@@ -189,7 +199,10 @@
const userLoading = ref(false)
const userOptions = ref<Array<{ id: string; nickName: string; userNo: string }>>([])
const flowSteps = ref<OaFlowPreviewStep[]>([])
const flowResolved = ref(true)
const flowPendingMessage = ref('必填信息填写后,流程将自动显示')
const previewLoading = ref(false)
let previewTimer: ReturnType<typeof setTimeout> | null = null
const displayGroups = computed(() => {
const kw = (searchKeyword.value || '').trim().toLowerCase()
@@ -267,18 +280,32 @@
async function loadFlowPreview(appId: string) {
previewLoading.value = true
try {
const preview = await fetchOaFlowPreview(
appId,
needInitiatorSelect.value ? [...initiatorSelectUserIds.value] : undefined
)
flowSteps.value = preview?.steps || []
const preview = await fetchOaFlowPreview(appId, {
formData: { ...formData.value },
initiatorSelectUserIds: needInitiatorSelect.value
? [...initiatorSelectUserIds.value]
: undefined
})
flowResolved.value = preview?.resolved !== false
flowPendingMessage.value =
preview?.message || '必填信息填写后,流程将自动显示'
flowSteps.value = flowResolved.value ? preview?.steps || [] : []
} catch {
flowSteps.value = []
flowResolved.value = true
} finally {
previewLoading.value = false
}
}
function scheduleFlowPreview() {
if (!dlgVisible.value || !picked.value?.id) return
if (previewTimer) clearTimeout(previewTimer)
previewTimer = setTimeout(() => {
loadFlowPreview(picked.value.id)
}, 300)
}
async function onPick(t: any) {
picked.value = t
formData.value = {}
@@ -286,19 +313,32 @@
needInitiatorSelect.value = false
initiatorSelectUserIds.value = []
flowSteps.value = []
flowResolved.value = false
flowPendingMessage.value = '必填信息填写后,流程将自动显示'
dlgVisible.value = true
detailLoading.value = true
previewLoading.value = true
try {
const detail = await fetchOaTemplateDetail(t.id)
const schema = detail?.formSchemaJson || {}
formRule.value = Array.isArray(schema.rule) ? schema.rule : Array.isArray(schema) ? schema : []
const rawRule = Array.isArray(schema.rule)
? schema.rule
: Array.isArray(schema)
? schema
: []
formRule.value = ensureOaUploadRuleProps(rawRule)
const savedGutter = Number(schema.option?.row?.gutter)
formOption.value = {
...(schema.option || {}),
form: {
...(schema.option?.form || {}),
labelPosition: 'top'
},
// 顶层字段 col.span 布局用;栅格组件 fcRow 由 ensureOaUploadRuleProps 处理
row: {
...(schema.option?.row || {}),
gutter: savedGutter > 0 ? savedGutter : OA_FC_DEFAULT_GUTTER
},
submitBtn: false,
resetBtn: false
}
@@ -312,17 +352,26 @@
} catch {
formRule.value = []
flowSteps.value = []
flowResolved.value = true
previewLoading.value = false
} finally {
detailLoading.value = false
}
}
watch(
formData,
() => {
scheduleFlowPreview()
},
{ deep: true }
)
watch(
initiatorSelectUserIds,
() => {
if (dlgVisible.value && picked.value?.id && needInitiatorSelect.value) {
loadFlowPreview(picked.value.id)
scheduleFlowPreview()
}
},
{ deep: true }
@@ -104,24 +104,22 @@
</span>
</div>
</div>
<div class="protocol-card-compact__field protocol-card-compact__field--split-row">
<div class="protocol-card-compact__field-col">
<label>母血采集</label>
<span
class="protocol-card__kv-value"
:title="String(row.maternalBloodCollectTime || '')"
>
{{ formatDisplayOrDash(row.maternalBloodCollectTime) }}
</span>
</div>
<div class="protocol-card-compact__field-col protocol-card-compact__field-col--attachment">
<ProtocolAttachmentMiniReadonly
:label="attachmentMeta(row).label"
:files="attachmentMeta(row).files"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</div>
<div class="protocol-card-compact__field">
<label>母血采集</label>
<span
class="protocol-card__kv-value"
:title="String(row.maternalBloodCollectTime || '')"
>
{{ formatDisplayOrDash(row.maternalBloodCollectTime) }}
</span>
</div>
<div class="protocol-card-compact__field protocol-card-compact__field--attachment-only">
<ProtocolAttachmentMiniReadonly
:label="attachmentMeta(row).label"
:files="attachmentMeta(row).files"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</div>
</template>
<template v-else>