Files
Friendship-and-chat-pc/src/views/app/version/MemberSelector.vue
T
2026-11-11 19:38:55 +08:00

277 lines
8.8 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.
<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>