fix(oa): align initiator-select picker with flow designer member chips

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-23 16:27:43 +08:00
1 parent 4641d333e6
commit 91b8dd1aaa
1 file changed
+347 -25
@@ -76,23 +76,72 @@
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>
<div class="member-picker">
<el-select
v-model="memberPickBuffer"
filterable
clearable
placeholder="搜索姓名 / 工号,点击添加"
style="width: 100%"
popper-class="oa-flow-member-select-popper"
@change="onMemberPick"
>
<el-option
v-for="u in userOptions"
:key="u.id"
:label="memberOptionLabel(u)"
:value="u.id"
:disabled="pickerIds.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="pickerIds.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': !pickerIds.length }">
<template v-if="pickerIds.length">
<span v-for="id in pickerIds" :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="removePickerMember(id)"
>
×
</button>
</span>
</template>
<template v-else>暂未选择成员,请从上方搜索添加</template>
</div>
<p class="member-picked-count">已选 {{ pickerIds.length }} 人</p>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="pickerVisible = false">
@@ -110,7 +159,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { Check, Close, Plus } from '@element-plus/icons-vue'
import type { OaFlowPreviewStep } from '@/api/oaApproval'
@@ -118,6 +167,17 @@
type UserOption = { id: string; nickName: string; userNo?: string }
const AVATAR_COLORS = [
'#059669',
'#0ea5e9',
'#8b5cf6',
'#d97706',
'#ef4444',
'#14b8a6',
'#6366f1',
'#ec4899'
]
const props = withDefaults(
defineProps<{
steps?: OaFlowPreviewStep[]
@@ -152,6 +212,7 @@
const pickerNodeId = ref('')
const pickerNodeType = ref('')
const pickerIds = ref<string[]>([])
const memberPickBuffer = ref('')
const pickerTitle = computed(() =>
pickerNodeType.value === 'cc' ? '选择抄送人' : '选择审批人'
@@ -172,19 +233,62 @@
})
}
function userOptionLabel(u: UserOption) {
const name = u.nickName || u.id
return u.userNo ? `${name}(${u.userNo})` : name
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 (!pickerIds.value.includes(id)) {
pickerIds.value = [...pickerIds.value, id]
}
nextTick(() => {
memberPickBuffer.value = ''
})
}
function removePickerMember(id: string) {
pickerIds.value = pickerIds.value.filter((x) => x !== id)
}
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)
for (const x of ids) {
if (!x || seen.has(x)) continue
seen.add(x)
uniq.push(x)
}
if (uniq.length) next[nodeId] = uniq
else delete next[nodeId]
@@ -204,6 +308,7 @@
pickerNodeId.value = s.id
pickerNodeType.value = s.type || ''
pickerIds.value = [...((props.selectByNode || {})[s.id] || [])]
memberPickBuffer.value = ''
pickerVisible.value = true
}
@@ -218,6 +323,7 @@
pickerNodeId.value = ''
pickerNodeType.value = ''
pickerIds.value = []
memberPickBuffer.value = ''
}
function signBadge(s: OaFlowPreviewStep) {
@@ -510,4 +616,220 @@
margin-right: 6px;
}
}
.member-picker {
width: 100%;
}
.member-option {
display: flex;
gap: 12px;
align-items: center;
width: 100%;
min-height: 40px;
padding: 4px 0;
}
.member-option__meta {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
justify-content: center;
min-width: 0;
line-height: 1.35;
}
.member-option__name {
overflow: hidden;
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%;
&--sm {
width: 28px;
height: 28px;
font-size: 11px;
}
}
.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;
cursor: pointer;
background: #f1f5f9;
border: 0;
border-radius: 50%;
&:hover {
color: #dc2626;
background: #fee2e2;
}
}
.member-picked-count {
margin: 8px 0 0;
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
}
</style>
<!-- 下拉挂 body,须非 scoped -->
<style lang="scss">
.oa-flow-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>