| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513 |
- <!-- 发起审批 · 表单底部审批流预览(对齐 docs/mockups/oa-approval-start-flow-preview-demo.html) -->
- <template>
- <div v-loading="loading" class="oa-flow-preview">
- <div class="oa-flow-preview__title">
- <span>审批流程</span>
- <span v-if="resolved && hint" class="oa-flow-preview__hint">{{ hint }}</span>
- </div>
- <div v-if="!resolved" class="oa-flow-preview__pending">
- {{ pendingMessage || '必填信息填写后,流程将自动显示' }}
- </div>
- <div v-else class="oa-flow-preview__chain">
- <div v-if="!loading && !steps.length" class="oa-flow-preview__empty">暂无流程配置</div>
- <div v-for="(s, idx) in steps" :key="s.id || idx" class="oa-flow-step">
- <div class="oa-flow-rail">
- <div class="oa-flow-dot" :class="{ end: s.type === 'end' }" />
- <div v-if="idx < steps.length - 1" class="oa-flow-line" />
- </div>
- <div class="oa-flow-card">
- <div class="oa-flow-card__row">
- <span class="oa-flow-card__name">{{ s.name || '-' }}</span>
- <span v-if="signBadge(s)" class="oa-flow-badge" :class="signBadge(s)">
- {{ signBadgeText(s) }}
- </span>
- <template v-if="s.meta">
- <span class="oa-flow-card__sep">·</span>
- <span class="oa-flow-card__meta" :title="s.meta">{{ s.meta }}</span>
- </template>
- <span v-if="s.resolveError" class="oa-flow-card__err" :title="s.resolveError">
- {{ s.resolveError }}
- </span>
- <template v-if="isInitiatorSelect(s)">
- <span class="oa-flow-card__select">
- <span class="oa-flow-card__people">
- <template v-if="localPeople(s).length">
- <span v-for="p in localPeople(s)" :key="p.id" class="oa-person-chip">
- <span class="oa-person-chip__dot">{{ avatarChar(p.nickName) }}</span>
- {{ p.nickName }}
- <button
- type="button"
- class="oa-person-chip__x"
- title="移除"
- @click="removeUser(s.id, p.id)"
- >
- ×
- </button>
- </span>
- </template>
- <span v-else class="oa-flow-card__placeholder">
- {{ s.type === 'cc' ? '请选择抄送人' : '请选择审批人' }}
- </span>
- </span>
- <button type="button" class="oa-flow-add-btn" title="添加" @click="openPicker(s)">
- <el-icon><Plus /></el-icon>
- </button>
- </span>
- </template>
- <span v-else-if="(s.people || []).length" class="oa-flow-card__people">
- <span v-for="p in s.people" :key="p.id" class="oa-person-chip">
- <span class="oa-person-chip__dot">{{ avatarChar(p.nickName) }}</span>
- {{ p.nickName || p.id }}
- </span>
- </span>
- </div>
- </div>
- </div>
- </div>
- <el-dialog
- v-model="pickerVisible"
- :title="pickerTitle"
- width="520px"
- align-center
- append-to-body
- destroy-on-close
- class="dialog-form-layout"
- modal-class="protocol-dialog-mask"
- @closed="onPickerClosed"
- >
- <el-select
- v-model="pickerIds"
- multiple
- filterable
- clearable
- collapse-tags
- collapse-tags-tooltip
- placeholder="请选择人员"
- style="width: 100%"
- >
- <el-option
- v-for="u in userOptions"
- :key="u.id"
- :label="userOptionLabel(u)"
- :value="u.id"
- />
- </el-select>
- <template #footer>
- <div class="dialog-footer">
- <el-button @click="pickerVisible = false">
- <el-icon><Close /></el-icon>
- <span>取消</span>
- </el-button>
- <el-button type="primary" @click="confirmPicker">
- <el-icon><Check /></el-icon>
- <span>确定</span>
- </el-button>
- </div>
- </template>
- </el-dialog>
- </div>
- </template>
- <script setup lang="ts">
- import { computed, ref } from 'vue'
- import { Check, Close, Plus } from '@element-plus/icons-vue'
- import type { OaFlowPreviewStep } from '@/api/oaApproval'
- defineOptions({ name: 'OaFlowPreview' })
- type UserOption = { id: string; nickName: string; userNo?: string }
- const props = withDefaults(
- defineProps<{
- steps?: OaFlowPreviewStep[]
- loading?: boolean
- hint?: string
- resolved?: boolean
- pendingMessage?: string
- selectByNode?: Record<string, string[]>
- userOptions?: UserOption[]
- }>(),
- {
- steps: () => [],
- loading: false,
- hint: '按当前发起人解析审批人',
- resolved: true,
- pendingMessage: '必填信息填写后,流程将自动显示',
- selectByNode: () => ({}),
- userOptions: () => []
- }
- )
- const emit = defineEmits<{
- 'update:selectByNode': [Record<string, string[]>]
- 'select-change': []
- }>()
- const steps = computed(() => props.steps || [])
- const resolved = computed(() => props.resolved !== false)
- const userOptions = computed(() => props.userOptions || [])
- const pickerVisible = ref(false)
- const pickerNodeId = ref('')
- const pickerNodeType = ref('')
- const pickerIds = ref<string[]>([])
- const pickerTitle = computed(() =>
- pickerNodeType.value === 'cc' ? '选择抄送人' : '选择审批人'
- )
- function isInitiatorSelect(s: OaFlowPreviewStep) {
- return (
- s.assigneeType === 'initiator_select' && (s.type === 'approval' || s.type === 'cc')
- )
- }
- function localPeople(s: OaFlowPreviewStep) {
- const ids = (props.selectByNode || {})[s.id] || []
- const byId = new Map(userOptions.value.map((u) => [u.id, u]))
- return ids.map((id) => {
- const u = byId.get(id)
- return { id, nickName: u?.nickName || id }
- })
- }
- function userOptionLabel(u: UserOption) {
- const name = u.nickName || u.id
- return u.userNo ? `${name}(${u.userNo})` : name
- }
- function updateSelect(nodeId: string, ids: string[]) {
- const next: Record<string, string[]> = { ...(props.selectByNode || {}) }
- const uniq: string[] = []
- const seen = new Set<string>()
- for (const id of ids) {
- if (!id || seen.has(id)) continue
- seen.add(id)
- uniq.push(id)
- }
- if (uniq.length) next[nodeId] = uniq
- else delete next[nodeId]
- emit('update:selectByNode', next)
- emit('select-change')
- }
- function removeUser(nodeId: string, userId: string) {
- const cur = (props.selectByNode || {})[nodeId] || []
- updateSelect(
- nodeId,
- cur.filter((id) => id !== userId)
- )
- }
- function openPicker(s: OaFlowPreviewStep) {
- pickerNodeId.value = s.id
- pickerNodeType.value = s.type || ''
- pickerIds.value = [...((props.selectByNode || {})[s.id] || [])]
- pickerVisible.value = true
- }
- function confirmPicker() {
- if (pickerNodeId.value) {
- updateSelect(pickerNodeId.value, [...pickerIds.value])
- }
- pickerVisible.value = false
- }
- function onPickerClosed() {
- pickerNodeId.value = ''
- pickerNodeType.value = ''
- pickerIds.value = []
- }
- function signBadge(s: OaFlowPreviewStep) {
- if (s.type === 'cc' || s.signMode === 'cc') return 'cc'
- if (s.signMode === 'and') return 'and'
- if (s.signMode === 'or') return 'or'
- return ''
- }
- function signBadgeText(s: OaFlowPreviewStep) {
- const k = signBadge(s)
- if (k === 'cc') return '抄送'
- if (k === 'and') return '会签'
- if (k === 'or') return '或签'
- return ''
- }
- function avatarChar(name: string) {
- const n = (name || '').trim()
- return n ? n.slice(0, 1) : '?'
- }
- </script>
- <style scoped lang="scss">
- .oa-flow-preview {
- margin: 8px 0 12px;
- padding: 12px 12px 8px;
- background: #f8fafc;
- border: 1px solid var(--mint-border, #e2e8f0);
- border-radius: 10px;
- }
- .oa-flow-preview__title {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 10px;
- color: var(--mint-text, #334155);
- font-size: 13px;
- font-weight: 600;
- }
- .oa-flow-preview__hint {
- color: var(--mint-text-muted, #94a3b8);
- font-size: 12px;
- font-weight: 400;
- }
- .oa-flow-preview__pending {
- padding: 16px 8px 20px;
- color: var(--mint-text-muted, #94a3b8);
- font-size: 13px;
- line-height: 1.5;
- text-align: center;
- }
- .oa-flow-preview__empty {
- padding: 8px 0;
- color: var(--mint-text-muted, #94a3b8);
- font-size: 12px;
- }
- .oa-flow-preview__chain {
- min-height: 24px;
- }
- .oa-flow-step {
- display: flex;
- gap: 8px;
- align-items: stretch;
- }
- .oa-flow-rail {
- display: flex;
- flex-direction: column;
- align-items: center;
- width: 16px;
- flex-shrink: 0;
- }
- .oa-flow-dot {
- width: 10px;
- height: 10px;
- margin-top: 12px;
- background: #94a3b8;
- border-radius: 50%;
- box-shadow: 0 0 0 3px #e2e8f0;
- flex-shrink: 0;
- &.end {
- background: #cbd5e1;
- box-shadow: 0 0 0 3px #f1f5f9;
- }
- }
- .oa-flow-line {
- width: 2px;
- flex: 1;
- min-height: 10px;
- margin: 4px 0;
- background: #e2e8f0;
- }
- .oa-flow-card {
- flex: 1;
- min-width: 0;
- margin-bottom: 6px;
- padding: 9px 12px;
- background: #fff;
- border: 1px solid var(--mint-border, #e2e8f0);
- border-radius: 6px;
- }
- .oa-flow-card__row {
- display: flex;
- flex-wrap: wrap;
- gap: 6px 8px;
- align-items: center;
- min-width: 0;
- }
- .oa-flow-card__name {
- flex-shrink: 0;
- color: var(--mint-text-strong, #0f172a);
- font-size: 12px;
- font-weight: 600;
- line-height: 1.2;
- white-space: nowrap;
- }
- .oa-flow-card__meta {
- flex-shrink: 1;
- min-width: 0;
- overflow: hidden;
- color: var(--mint-text-muted, #94a3b8);
- font-size: 11px;
- line-height: 1.2;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .oa-flow-card__sep {
- flex-shrink: 0;
- color: #cbd5e1;
- font-size: 11px;
- }
- .oa-flow-card__err {
- flex-shrink: 1;
- min-width: 0;
- overflow: hidden;
- color: #d97706;
- font-size: 11px;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .oa-flow-card__select {
- display: inline-flex;
- flex: 1 1 140px;
- flex-wrap: wrap;
- gap: 6px;
- align-items: center;
- justify-content: flex-end;
- min-width: 0;
- margin-left: auto;
- }
- .oa-flow-card__people {
- display: inline-flex;
- flex: 1 1 140px;
- flex-wrap: wrap;
- gap: 4px;
- align-items: center;
- justify-content: flex-end;
- min-width: 0;
- margin-left: auto;
- }
- .oa-flow-card__select .oa-flow-card__people {
- flex: 0 1 auto;
- margin-left: 0;
- }
- .oa-flow-card__placeholder {
- color: var(--mint-text-muted, #94a3b8);
- font-size: 12px;
- line-height: 1.2;
- }
- .oa-flow-add-btn {
- display: inline-flex;
- flex-shrink: 0;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- margin-left: 0;
- padding: 0;
- color: var(--mint-text, #334155);
- font-size: 14px;
- line-height: 1;
- cursor: pointer;
- background: #fff;
- border: 1px solid #e2e8f0;
- border-radius: 6px;
- &:hover {
- color: var(--primary, #10b981);
- border-color: #a7f3d0;
- background: var(--primary-bg, #ecfdf5);
- }
- }
- .oa-flow-card__select .oa-flow-add-btn {
- margin-left: 0;
- }
- .oa-person-chip {
- display: inline-flex;
- gap: 3px;
- align-items: center;
- padding: 1px 6px 1px 2px;
- color: var(--mint-text, #334155);
- font-size: 11px;
- line-height: 1.2;
- white-space: nowrap;
- background: #f1f5f9;
- border-radius: 999px;
- }
- .oa-person-chip__dot {
- display: grid;
- place-items: center;
- width: 16px;
- height: 16px;
- color: #fff;
- font-size: 9px;
- font-weight: 700;
- background: var(--primary, #10b981);
- border-radius: 50%;
- }
- .oa-person-chip__x {
- margin: 0;
- padding: 0 2px;
- color: var(--mint-text-muted, #94a3b8);
- font-size: 12px;
- line-height: 1;
- cursor: pointer;
- background: transparent;
- border: none;
- &:hover {
- color: #dc2626;
- }
- }
- .oa-flow-badge {
- flex-shrink: 0;
- padding: 0 5px;
- font-size: 10px;
- font-weight: 500;
- line-height: 16px;
- white-space: nowrap;
- border-radius: 4px;
- &.or {
- color: #d97706;
- background: #fff7ed;
- }
- &.and {
- color: #2563eb;
- background: #eff6ff;
- }
- &.cc {
- color: #0891b2;
- background: #ecfeff;
- }
- }
- .dialog-footer {
- display: flex;
- gap: 8px;
- justify-content: flex-end;
- .el-button .el-icon {
- margin-right: 6px;
- }
- }
- </style>
|