一个弹窗组件

上篇提到,封装不光是重用,更多考虑降低单文件的复杂度

下面的这个业务组件将穿梭框、筛选、请求、确认的功能全部封装在一个文件中

调用非常简单,父组件无需关注子组件内部,实现了整个局部业务的拆离

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>