一个弹窗组件
上篇提到,封装不光是重用,更多考虑降低单文件的复杂度
下面的这个业务组件将穿梭框、筛选、请求、确认的功能全部封装在一个文件中
调用非常简单,父组件无需关注子组件内部,实现了整个局部业务的拆离
dialog.open({
defaultSelected,
context,
onConfirm(selected) {}
})
这里嵌入了交互演示
交互演示仅在客户端加载…
<script setup lang="ts">
import { getSubjectBySchoolIdList, getTeacherBySchoolIdList } from '@/api/grading'
import { useUserStore } from '@/store'
export type TeacherItem = {
tenantUserId: string
userName?: string
userPhone?: string
schoolName?: string
}
type SubjectOption = {
id: string
name: string
}
const userStore = useUserStore()
// 弹窗显示状态
const visible = ref(false)
// 当前操作的上下文数据(由调用方传入)
const context = ref<any>(null)
// 确认回调
let confirmCallback: ((selected: TeacherItem[], ctx: any) => void) | null = null
// 筛选条件
const subjectFilter = ref('')
const searchKeyword = ref('')
// 科目选项
const subjectOptions = ref<SubjectOption[]>([])
async function fetchSubjectList() {
const { data } = await getSubjectBySchoolIdList({
schoolId: userStore.schoolInfo.id,
})
subjectOptions.value = data
}
// 左侧所有教师数据
const allTeachers = ref<TeacherItem[]>([])
// 右侧已选教师
const selectedTeachers = ref<TeacherItem[]>([])
// 左侧当前选中的行
const leftSelection = ref<TeacherItem[]>([])
// 右侧当前选中的行
const rightSelection = ref<TeacherItem[]>([])
// 左侧表格ref
const leftTableRef = ref()
// 已选教师的id集合,用于快速判断
const selectedIds = computed(() => new Set(selectedTeachers.value.map(t => t.tenantUserId)))
// 检查行是否可选
function checkSelectable(row: TeacherItem) {
return !selectedIds.value.has(row.tenantUserId)
}
// 左侧选择变化
function handleLeftSelectionChange(selection: TeacherItem[]) {
leftSelection.value = selection
// 将选中的添加到右侧
selection.forEach((teacher) => {
if (!selectedIds.value.has(teacher.tenantUserId)) {
selectedTeachers.value.push({ ...teacher })
}
})
// 清空左侧选择状态
nextTick(() => {
leftTableRef.value?.clearSelection()
})
}
// 右侧选择变化
function handleRightSelectionChange(selection: TeacherItem[]) {
rightSelection.value = selection
}
// 删除单个
function handleRemove(row: TeacherItem) {
const index = selectedTeachers.value.findIndex(t => t.tenantUserId === row.tenantUserId)
if (index > -1) {
selectedTeachers.value.splice(index, 1)
}
}
// 批量删除右侧选中的
function handleBatchRemove() {
if (rightSelection.value.length === 0) {
ElMessage.warning('请先选择要删除的教师')
return
}
const removeIds = new Set(rightSelection.value.map(t => t.tenantUserId))
selectedTeachers.value = selectedTeachers.value.filter(t => !removeIds.has(t.tenantUserId))
rightSelection.value = []
}
// 取消
function handleCancel() {
visible.value = false
}
// 确定
function handleConfirm() {
if (confirmCallback) {
confirmCallback([...selectedTeachers.value], context.value)
}
visible.value = false
}
// 打开弹窗的方法
interface OpenOptions {
defaultSelected?: TeacherItem[]
context?: any
onConfirm?: (selected: TeacherItem[], ctx: any) => void
}
async function open(options: OpenOptions = {}) {
const { defaultSelected = [], context: ctx = null, onConfirm } = options
context.value = ctx
confirmCallback = onConfirm || null
selectedTeachers.value = [...defaultSelected]
visible.value = true
await fetchAllTeachers()
await fetchSubjectList()
}
// 获取所有教师列表
async function fetchAllTeachers() {
const { data } = await getTeacherBySchoolIdList({
schoolId: userStore.schoolInfo.id,
classType: 'admin',
userNameOrPhone: searchKeyword.value || undefined,
subjectId: subjectFilter.value,
})
allTeachers.value = data as TeacherItem[]
}
function onDialogClosed() {
searchKeyword.value = ''
subjectFilter.value = ''
rightSelection.value = []
leftSelection.value = []
selectedTeachers.value = []
allTeachers.value = []
context.value = null
confirmCallback = null
}
// 暴露 open 方法供外部调用
defineExpose({
open,
})
</script>
<template>
<el-dialog
v-model="visible"
title="设置阅卷教师"
width="1000"
:close-on-click-modal="false"
:close-on-press-escape="false"
@closed="onDialogClosed"
>
<div class="grid grid-cols-[1fr_1fr] gap-[20px] h-[550px]">
<!-- 左侧:全部教师 -->
<div class="">
<div class="flex items-center mb-[10px] gap-[10px]">
<el-select v-model="subjectFilter" placeholder="科目选择" clearable class="w-[200px]!" @change="fetchAllTeachers">
<el-option
v-for="item in subjectOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
<el-input
v-model="searchKeyword"
placeholder="请输入姓名或账号进行搜索"
clearable
@change="fetchAllTeachers"
/>
</div>
<el-table
ref="leftTableRef"
:data="allTeachers"
border
max-height="500"
@selection-change="handleLeftSelectionChange"
>
<el-table-column
type="selection"
width="55"
:selectable="checkSelectable"
/>
<el-table-column prop="userName" label="姓名" />
<el-table-column prop="userPhone" label="联系方式" />
<el-table-column prop="schoolName" label="学校" />
</el-table>
</div>
<!-- 右侧:已选教师 -->
<div class="">
<div class="mb-[10px] font-bold text-[#027AFF] flex h-[32px] leading-[32px]">
已选教师
<el-button type="primary" link class="ml-auto" @click="handleBatchRemove">
删除选中教师
</el-button>
</div>
<el-table
:data="selectedTeachers"
border
max-height="500"
@selection-change="handleRightSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="userName" label="姓名" />
<el-table-column prop="userPhone" label="联系方式" />
<el-table-column prop="schoolName" label="学校" />
<el-table-column label="操作" width="60" align="center">
<template #default="{ row }">
<el-button type="danger" link @click="handleRemove(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<template #footer>
<div class="flex gap-4 justify-end">
<el-button @click="handleCancel">
取消
</el-button>
<el-button type="primary" @click="handleConfirm">
确定
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
:deep(.el-table .el-table__row) {
.el-checkbox.is-disabled {
.el-checkbox__inner {
background-color: #f5f7fa;
border-color: #e4e7ed;
}
}
}
</style>
但我想这正是组件封装容易走向的误区,不是封装不够,而是封装过度:当组件开始隐藏数据来源、主动处理副作用时,复杂度就开始变得不可控
首先组件的输入不够明确,除了defaultSelected, context,还依赖userStore
当然最重要的,组件承担了UI、数据请求、结果处理。这些组件内部的内容,让组件的可理解性变差,致使整个业务的复杂度并没有降低(我认为降低复杂度,旨在将大块业务拆离为可理解的部分,局部可理解后,才方便理解整体业务,整体复杂度随之降低)
所以,我进行了修改:让组件只负责UI,数据请求和确认逻辑还给外部
<TeacherSelector
:teachers="allTeachers"
:subjects="subjectOptions"
:selected="selectedTeachers"
@search="handleSearch"
@change="handleChange"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
子组件 TeacherSelector.vue(只负责展示与交互)
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { computed, nextTick, ref } from 'vue'
export type TeacherItem = {
tenantUserId: string
userName?: string
userPhone?: string
schoolName?: string
}
export type SubjectOption = { id: string, name: string }
const props = defineProps<{
teachers: TeacherItem[]
subjects: SubjectOption[]
selected: TeacherItem[]
}>()
const emit = defineEmits<{
search: [payload: { subjectId: string, keyword: string }]
change: [selected: TeacherItem[]]
confirm: []
cancel: []
}>()
const subjectId = ref('')
const keyword = ref('')
const leftTableRef = ref()
const rightSelection = ref<TeacherItem[]>([])
const selectedIds = computed(() => new Set(props.selected.map(t => t.tenantUserId)))
function checkSelectable(row: TeacherItem) {
return !selectedIds.value.has(row.tenantUserId)
}
function emitSearch() {
emit('search', { subjectId: subjectId.value, keyword: keyword.value })
}
function handleLeftSelectionChange(selection: TeacherItem[]) {
const next = [...props.selected]
for (const teacher of selection) {
if (!selectedIds.value.has(teacher.tenantUserId)) {
next.push({ ...teacher })
}
}
emit('change', next)
nextTick(() => leftTableRef.value?.clearSelection())
}
function handleRightSelectionChange(selection: TeacherItem[]) {
rightSelection.value = selection
}
function handleRemove(row: TeacherItem) {
emit(
'change',
props.selected.filter(t => t.tenantUserId !== row.tenantUserId),
)
rightSelection.value = rightSelection.value.filter(t => t.tenantUserId !== row.tenantUserId)
}
function handleBatchRemove() {
if (rightSelection.value.length === 0) {
ElMessage.warning('请先选择要删除的教师')
return
}
const removeIds = new Set(rightSelection.value.map(t => t.tenantUserId))
emit('change', props.selected.filter(t => !removeIds.has(t.tenantUserId)))
rightSelection.value = []
}
</script>
<template>
<div class="grid grid-cols-[1fr_1fr] gap-[20px] h-[550px]">
<div>
<div class="flex items-center mb-[10px] gap-[10px]">
<el-select
v-model="subjectId"
placeholder="科目选择"
clearable
class="w-[200px]!"
@change="emitSearch"
>
<el-option
v-for="item in subjects"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
<el-input
v-model="keyword"
placeholder="请输入姓名或账号进行搜索"
clearable
@change="emitSearch"
/>
</div>
<el-table
ref="leftTableRef"
:data="teachers"
border
max-height="500"
@selection-change="handleLeftSelectionChange"
>
<el-table-column type="selection" width="55" :selectable="checkSelectable" />
<el-table-column prop="userName" label="姓名" />
<el-table-column prop="userPhone" label="联系方式" />
<el-table-column prop="schoolName" label="学校" />
</el-table>
</div>
<div>
<div class="mb-[10px] font-bold text-[#027AFF] flex h-[32px] leading-[32px]">
已选教师
<el-button type="primary" link class="ml-auto" @click="handleBatchRemove">
删除选中教师
</el-button>
</div>
<el-table
:data="selected"
border
max-height="500"
@selection-change="handleRightSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="userName" label="姓名" />
<el-table-column prop="userPhone" label="联系方式" />
<el-table-column prop="schoolName" label="学校" />
<el-table-column label="操作" width="60" align="center">
<template #default="{ row }">
<el-button type="danger" link @click="handleRemove(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<div class="flex gap-4 justify-end mt-4">
<el-button @click="emit('cancel')">
取消
</el-button>
<el-button type="primary" @click="emit('confirm')">
确定
</el-button>
</div>
</template>
父组件(请求、弹窗、确认逻辑)
<script setup lang="ts">
import type { TeacherItem } from './TeacherSelector.vue'
import { getSubjectBySchoolIdList, getTeacherBySchoolIdList } from '@/api/grading'
import { useUserStore } from '@/store'
import TeacherSelector from './TeacherSelector.vue'
const userStore = useUserStore()
const visible = ref(false)
const subjectOptions = ref<{ id: string, name: string }[]>([])
const allTeachers = ref<TeacherItem[]>([])
const selectedTeachers = ref<TeacherItem[]>([])
const currentPaperId = ref<string | null>(null)
const searchParams = ref({ subjectId: '', keyword: '' })
async function loadSubjects() {
const { data } = await getSubjectBySchoolIdList({
schoolId: userStore.schoolInfo.id,
})
subjectOptions.value = data
}
async function loadTeachers() {
const { data } = await getTeacherBySchoolIdList({
schoolId: userStore.schoolInfo.id,
classType: 'admin',
userNameOrPhone: searchParams.value.keyword || undefined,
subjectId: searchParams.value.subjectId,
})
allTeachers.value = data as TeacherItem[]
}
function handleSearch(payload: { subjectId: string, keyword: string }) {
searchParams.value = payload
loadTeachers()
}
function handleChange(next: TeacherItem[]) {
selectedTeachers.value = next
}
async function openForPaper(paperId: string, defaultSelected: TeacherItem[]) {
currentPaperId.value = paperId
selectedTeachers.value = [...defaultSelected]
visible.value = true
await loadSubjects()
await loadTeachers()
}
function handleCancel() {
visible.value = false
}
async function handleConfirm() {
if (!currentPaperId.value) {
return
}
await saveGradersApi(currentPaperId.value, selectedTeachers.value)
visible.value = false
}
async function saveGradersApi(_paperId: string, _teachers: TeacherItem[]) {
// await api.save(...)
}
</script>
<template>
<el-dialog
v-model="visible"
title="设置阅卷教师"
width="1000"
:close-on-click-modal="false"
:close-on-press-escape="false"
@closed="selectedTeachers = []; currentPaperId = null"
>
<TeacherSelector
:teachers="allTeachers"
:subjects="subjectOptions"
:selected="selectedTeachers"
@search="handleSearch"
@change="handleChange"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
</el-dialog>
</template>