277 lines
8.8 KiB
Vue
277 lines
8.8 KiB
Vue
<template>
|
||
<el-dialog
|
||
v-model="visible"
|
||
title="选择灰测会员"
|
||
width="min(1000px, 94vw)"
|
||
top="5vh"
|
||
append-to-body
|
||
:close-on-click-modal="false"
|
||
destroy-on-close
|
||
>
|
||
<div class="member-picker-scroll">
|
||
<div class="member-picker">
|
||
<section class="min-w-0">
|
||
<el-form class="mb-16px flex gap-8px" @submit.prevent="search">
|
||
<el-input
|
||
v-model.trim="keyword"
|
||
clearable
|
||
placeholder="昵称、手机号、App 用户ID"
|
||
aria-label="搜索灰测会员"
|
||
@clear="search"
|
||
/>
|
||
<el-button type="primary" native-type="submit" :loading="loading">搜索</el-button>
|
||
</el-form>
|
||
<el-alert
|
||
v-if="loadError"
|
||
:title="loadError"
|
||
type="error"
|
||
:closable="false"
|
||
show-icon
|
||
class="mb-12px"
|
||
>
|
||
<el-button link type="primary" @click="loadMembers">重新加载</el-button>
|
||
</el-alert>
|
||
<el-table v-loading="loading" :data="rows" row-key="id" height="390">
|
||
<el-table-column width="44" align="center">
|
||
<template #header>
|
||
<el-checkbox
|
||
:model-value="allPageSelected"
|
||
:indeterminate="pagePartlySelected"
|
||
aria-label="选择当前页所有会员"
|
||
@change="togglePageSelection"
|
||
/>
|
||
</template>
|
||
<template #default="{ row }">
|
||
<el-checkbox
|
||
:model-value="selectedIds.has(row.id)"
|
||
:aria-label="`选择会员${row.nickname || '未设置昵称'},ID ${row.userCode || row.id}`"
|
||
@change="(checked) => toggleMemberSelection(row, checked)"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="会员" min-width="180">
|
||
<template #default="{ row }">
|
||
<div class="flex items-center gap-10px">
|
||
<el-avatar :size="32" :src="row.avatar || undefined" aria-hidden="true"
|
||
><Icon icon="ep:user"
|
||
/></el-avatar>
|
||
<div class="min-w-0">
|
||
<div class="truncate">{{ row.nickname || '未设置昵称' }}</div>
|
||
<div class="member-id">ID {{ row.userCode || row.id }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="mobile" label="手机号" min-width="124" />
|
||
<template #empty><el-empty description="没有找到可选会员" :image-size="64" /></template>
|
||
</el-table>
|
||
<Pagination
|
||
v-model:page="pageNo"
|
||
v-model:limit="pageSize"
|
||
:total="total"
|
||
@pagination="loadMembers"
|
||
/>
|
||
</section>
|
||
<aside class="selected-members">
|
||
<div class="flex items-center justify-between mb-12px">
|
||
<span class="font-600">已选 {{ selected.length }} 人</span>
|
||
<el-button link type="primary" :disabled="!selected.length" @click="clearSelected"
|
||
>清空</el-button
|
||
>
|
||
</div>
|
||
<p class="member-picker-hint mb-12px">支持跨页选择,确认后加入当前版本。</p>
|
||
<el-scrollbar max-height="360px">
|
||
<div v-for="member in selected" :key="member.id" class="selected-member">
|
||
<div class="min-w-0">
|
||
<div class="truncate">{{
|
||
member.nickname || `会员 ${member.userCode || member.id}`
|
||
}}</div>
|
||
<div class="member-id">ID {{ member.userCode || member.id }}</div>
|
||
</div>
|
||
<el-button
|
||
text
|
||
circle
|
||
:aria-label="`移除会员${member.nickname || member.userCode || member.id}`"
|
||
@click="removeMember(member.id)"
|
||
><Icon icon="ep:close"
|
||
/></el-button>
|
||
</div>
|
||
<el-empty v-if="!selected.length" description="暂未选择会员" :image-size="56" />
|
||
</el-scrollbar>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="visible = false">取消</el-button>
|
||
<el-button type="primary" @click="confirmSelection"
|
||
>确认选择 {{ selected.length ? `(${selected.length})` : '' }}</el-button
|
||
>
|
||
</template>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { getMemberPage, type GrayMember } from '@/api/app/version'
|
||
|
||
/** 灰测会员分页选择器,确认前的选择仅属于当前弹窗。 */
|
||
defineOptions({ name: 'VersionMemberSelector' })
|
||
const emit = defineEmits<{ confirm: [members: GrayMember[]] }>()
|
||
const visible = ref(false)
|
||
const loading = ref(false)
|
||
const loadError = ref('')
|
||
const keyword = ref('')
|
||
const pageNo = ref(1)
|
||
const pageSize = ref(10)
|
||
const total = ref(0)
|
||
const rows = ref<GrayMember[]>([])
|
||
const selected = ref<GrayMember[]>([])
|
||
const selectedIds = computed(() => new Set(selected.value.map((member) => member.id)))
|
||
const allPageSelected = computed(
|
||
() => rows.value.length > 0 && rows.value.every((member) => selectedIds.value.has(member.id))
|
||
)
|
||
const pagePartlySelected = computed(
|
||
() => !allPageSelected.value && rows.value.some((member) => selectedIds.value.has(member.id))
|
||
)
|
||
/** 丢弃已被新搜索或新一轮打开覆盖的异步响应。 */
|
||
let requestSequence = 0
|
||
|
||
/** 查询当前页,并保留全部已选会员。 */
|
||
const loadMembers = async () => {
|
||
const sequence = ++requestSequence
|
||
loading.value = true
|
||
loadError.value = ''
|
||
try {
|
||
const data = await getMemberPage({
|
||
pageNo: pageNo.value,
|
||
pageSize: pageSize.value,
|
||
keyword: keyword.value || undefined
|
||
})
|
||
if (sequence !== requestSequence) return
|
||
rows.value = data.list
|
||
total.value = data.total
|
||
} catch {
|
||
if (sequence === requestSequence) loadError.value = '会员列表加载失败,请重试'
|
||
} finally {
|
||
if (sequence === requestSequence) loading.value = false
|
||
}
|
||
}
|
||
|
||
/** 单个会员的选择直接写入跨页名单,复选框由名单驱动。 */
|
||
const toggleMemberSelection = (member: GrayMember, checked: string | number | boolean) => {
|
||
if (!checked) {
|
||
removeMember(member.id)
|
||
return
|
||
}
|
||
if (selectedIds.value.has(member.id)) return
|
||
selected.value = [...selected.value, member]
|
||
}
|
||
|
||
/** 页内全选只影响当前页,保留其余页的选择。 */
|
||
const togglePageSelection = (checked: string | number | boolean) => {
|
||
const pageIds = new Set(rows.value.map((member) => member.id))
|
||
if (!checked) {
|
||
selected.value = selected.value.filter((member) => !pageIds.has(member.id))
|
||
return
|
||
}
|
||
const next = [
|
||
...selected.value,
|
||
...rows.value.filter((member) => !selectedIds.value.has(member.id))
|
||
]
|
||
selected.value = next
|
||
}
|
||
|
||
/** 从已选名单移除会员,并同步当前页勾选。 */
|
||
const removeMember = (id: number) => {
|
||
selected.value = selected.value.filter((member) => member.id !== id)
|
||
}
|
||
|
||
/** 清空全部页已选会员。 */
|
||
const clearSelected = () => {
|
||
selected.value = []
|
||
}
|
||
|
||
/** 更改关键字后从第一页重新查询。 */
|
||
const search = () => {
|
||
pageNo.value = 1
|
||
loadMembers()
|
||
}
|
||
|
||
/** 使用父表单的当前名单开启独立的选择事务。 */
|
||
const open = (members: GrayMember[]) => {
|
||
requestSequence++
|
||
selected.value = members.map((member) => ({ ...member }))
|
||
keyword.value = ''
|
||
pageNo.value = 1
|
||
rows.value = []
|
||
total.value = 0
|
||
visible.value = true
|
||
loadMembers()
|
||
}
|
||
|
||
/** 确认后一次性回传名单,取消不会改变父表单。 */
|
||
const confirmSelection = () => {
|
||
emit(
|
||
'confirm',
|
||
selected.value.map((member) => ({ ...member }))
|
||
)
|
||
visible.value = false
|
||
}
|
||
|
||
watch(visible, (value) => {
|
||
if (!value) requestSequence++
|
||
})
|
||
defineExpose({ open })
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.member-picker-scroll {
|
||
max-height: calc(90vh - 130px);
|
||
overflow: auto;
|
||
}
|
||
.member-picker :deep(.el-pagination) {
|
||
float: none;
|
||
justify-content: flex-end;
|
||
max-width: 100%;
|
||
}
|
||
.member-picker {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 260px;
|
||
gap: 24px;
|
||
}
|
||
.selected-members {
|
||
padding: 16px;
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
border-radius: 12px;
|
||
background: var(--el-fill-color-extra-light);
|
||
}
|
||
.member-id,
|
||
.member-picker-hint {
|
||
font-size: 12px;
|
||
line-height: 20px;
|
||
color: var(--el-text-color-regular);
|
||
}
|
||
.selected-member {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
padding: 8px 0;
|
||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||
}
|
||
@media (max-width: 720px) {
|
||
.member-picker :deep(.el-pagination__total),
|
||
.member-picker :deep(.el-pagination__sizes),
|
||
.member-picker :deep(.el-pagination__jump) {
|
||
display: none;
|
||
}
|
||
|
||
.member-picker {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
.selected-members {
|
||
max-height: 210px;
|
||
overflow: auto;
|
||
}
|
||
}
|
||
</style>
|