Files
campus-activity-system/server/docs/前端开发规范.md

26 KiB
Raw Blame History

校园活动组织与报名系统 - 前端开发规范

1. 命名规范

1.1 文件命名

类型 规范 示例
Vue 组件 PascalCase ActivityCard.vue, NavBar.vue
TypeScript 文件 camelCase useAuth.ts, request.ts
样式文件 kebab-case global.scss, variables.scss
常量文件 camelCase constant.ts
类型定义文件 kebab-case.d.ts user.d.ts, activity.d.ts

1.2 变量命名

// 常量:全大写下划线分隔
const API_BASE_URL = '/api/v1'
const MAX_FILE_SIZE = 1024 * 1024 * 5

// 变量:小驼峰
const userName = 'zhangsan'
const activityList = []

// 布尔值is/has/can 前缀
const isLoading = false
const hasPermission = true
const canEdit = false

// 私有属性:下划线前缀
const _privateData = {}

// 组件 props小驼峰
const props = defineProps<{
  activityId: number
  showDetail: boolean
}>()

// 事件名on 前缀
const emit = defineEmits<{
  onSubmit: [data: FormData]
  onCancel: []
}>()

1.3 函数命名

// 获取数据get 前缀
function getActivityList() {}
function getUserInfo() {}

// 设置数据set 前缀
function setToken(token: string) {}

// 处理事件handle 前缀
function handleSubmit() {}
function handleClick() {}

// 判断逻辑is/has/can 前缀
function isAdmin() {}
function hasRegistered() {}
function canCheckIn() {}

// 格式化format 前缀
function formatDate(date: Date) {}
function formatPrice(price: number) {}

// 转换to 前缀
function toArray(data: any) {}
function toString(value: any) {}

// 异步操作async/await
async function fetchActivities() {}
async function submitRegistration() {}

1.4 CSS 类命名 (BEM 规范)

// Block__Element--Modifier

// 活动卡片
.activity-card {
  // Element
  &__header {}
  &__title {}
  &__content {}
  &__footer {}
  &__image {}

  // Modifier
  &--active {}
  &--disabled {}
  &--loading {}
}

// 示例
<div class="activity-card activity-card--active">
  <div class="activity-card__header">
    <h3 class="activity-card__title">活动标题</h3>
  </div>
  <div class="activity-card__content">...</div>
</div>

1.5 路由命名

// 路由 namePascalCase
{
  path: '/activities',
  name: 'ActivityList',
  component: () => import('@/views/activity/List.vue')
}

// 路由 pathkebab-case
{
  path: '/my/registrations',
  name: 'MyRegistrations',
  component: () => import('@/views/registration/MyList.vue')
}

2. 代码风格规范

2.1 Vue 组件结构

<template>
  <!-- 模板内容 -->
</template>

<script setup lang="ts">
// 1. 类型导入
import type { Activity } from '@/types/activity'

// 2. 组件导入
import ActivityCard from '@/components/activity/ActivityCard.vue'

// 3. 工具/API 导入
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getActivityList } from '@/api/activity'

// 4. Props 定义
const props = defineProps<{
  activityId: number
}>()

// 5. Emits 定义
const emit = defineEmits<{
  onUpdate: [data: Activity]
}>()

// 6. 响应式数据
const loading = ref(false)
const activities = ref<Activity[]>([])

// 7. 计算属性
const filteredActivities = computed(() => {
  return activities.value.filter(item => item.status === 1)
})

// 8. 方法定义
function handleRefresh() {
  // ...
}

async function fetchData() {
  // ...
}

// 9. 生命周期
onMounted(() => {
  fetchData()
})

// 10. Watch如需要
watch(
  () => props.activityId,
  (newVal) => {
    // ...
  }
)
</script>

<style lang="scss" scoped>
// 样式
</style>

2.2 TypeScript 规范

// 1. 明确类型声明,避免使用 any
// Bad
const data: any = {}

// Good
interface ActivityData {
  id: number
  title: string
}
const data: ActivityData = { id: 1, title: '活动' }

// 2. 使用 interface 定义对象类型
interface User {
  id: number
  username: string
  name: string
  role: 0 | 1
}

// 3. 使用 type 定义联合类型
type ActivityStatus = 0 | 1 | 2 | 3
type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'

// 4. 使用枚举定义常量集合
enum ActivityStatusEnum {
  PENDING = 0,    // 未开始
  OPEN = 1,       // 报名中
  ONGOING = 2,    // 进行中
  ENDED = 3       // 已结束
}

// 5. 泛型使用
interface ApiResponse<T> {
  code: number
  message: string
  data: T
  timestamp: number
}

// 6. 可选属性使用 ?
interface CreateActivityParams {
  title: string
  description?: string
  coverImage?: string
}

// 7. 只读属性使用 readonly
interface Config {
  readonly apiUrl: string
  readonly timeout: number
}

2.3 API 请求规范

// src/api/activity.ts
import request from '@/utils/request'
import type { Activity, ActivityListParams, CreateActivityParams } from '@/types/activity'
import type { ApiResponse, PageResult } from '@/types/api'

/**
 * 获取活动列表
 * @param params 查询参数
 */
export function getActivityList(params: ActivityListParams) {
  return request.get<ApiResponse<PageResult<Activity>>>('/activities', { params })
}

/**
 * 获取活动详情
 * @param id 活动ID
 */
export function getActivityDetail(id: number) {
  return request.get<ApiResponse<Activity>>(`/activities/${id}`)
}

/**
 * 创建活动(管理员)
 * @param data 活动数据
 */
export function createActivity(data: CreateActivityParams) {
  return request.post<ApiResponse<{ id: number }>>('/activities', data)
}

/**
 * 更新活动(管理员)
 * @param id 活动ID
 * @param data 更新数据
 */
export function updateActivity(id: number, data: Partial<CreateActivityParams>) {
  return request.put<ApiResponse<null>>(`/activities/${id}`, data)
}

/**
 * 删除活动(管理员)
 * @param id 活动ID
 */
export function deleteActivity(id: number) {
  return request.delete<ApiResponse<null>>(`/activities/${id}`)
}

2.4 Store 规范

// src/stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo } from '@/types/user'
import { login, getUserInfo, refreshToken } from '@/api/auth'

export const useUserStore = defineStore('user', () => {
  // State
  const token = ref<string | null>(localStorage.getItem('accessToken'))
  const refreshTokenValue = ref<string | null>(localStorage.getItem('refreshToken'))
  const userInfo = ref<UserInfo | null>(null)

  // Getters
  const isLoggedIn = computed(() => !!token.value)
  const isAdmin = computed(() => userInfo.value?.role === 1)
  const userName = computed(() => userInfo.value?.name || '')

  // Actions
  async function loginAction(username: string, password: string) {
    try {
      const res = await login({ username, password })
      token.value = res.data.accessToken
      refreshTokenValue.value = res.data.refreshToken
      userInfo.value = res.data.userInfo

      localStorage.setItem('accessToken', res.data.accessToken)
      localStorage.setItem('refreshToken', res.data.refreshToken)

      return true
    } catch (error) {
      return false
    }
  }

  function logout() {
    token.value = null
    refreshTokenValue.value = null
    userInfo.value = null

    localStorage.removeItem('accessToken')
    localStorage.removeItem('refreshToken')
  }

  async function fetchUserInfo() {
    try {
      const res = await getUserInfo()
      userInfo.value = res.data
    } catch (error) {
      console.error('获取用户信息失败', error)
    }
  }

  return {
    // State
    token,
    userInfo,
    // Getters
    isLoggedIn,
    isAdmin,
    userName,
    // Actions
    loginAction,
    logout,
    fetchUserInfo
  }
})

2.5 Composable 规范

// src/composables/usePagination.ts
import { ref, computed } from 'vue'

interface PaginationOptions {
  defaultPage?: number
  defaultSize?: number
}

export function usePagination(options: PaginationOptions = {}) {
  const { defaultPage = 1, defaultSize = 10 } = options

  const currentPage = ref(defaultPage)
  const pageSize = ref(defaultSize)
  const total = ref(0)

  const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
  const hasMore = computed(() => currentPage.value < totalPages.value)

  function setTotal(value: number) {
    total.value = value
  }

  function nextPage() {
    if (hasMore.value) {
      currentPage.value++
    }
  }

  function prevPage() {
    if (currentPage.value > 1) {
      currentPage.value--
    }
  }

  function reset() {
    currentPage.value = defaultPage
    total.value = 0
  }

  return {
    currentPage,
    pageSize,
    total,
    totalPages,
    hasMore,
    setTotal,
    nextPage,
    prevPage,
    reset
  }
}

3. 组件开发规范

3.1 组件分类

类型 位置 说明
基础组件 components/common/ 通用 UI 组件,如 NavBar、TabBar
业务组件 components/[module]/ 特定业务模块组件
页面组件 views/[module]/ 路由对应的页面组件
布局组件 layouts/ 页面布局组件

3.2 组件设计原则

<!-- 1. 单一职责一个组件只做一件事 -->
<!-- BadActivityCard 既显示又能编辑 -->
<!-- Good分成 ActivityCard显示 ActivityEditForm编辑-->

<!-- 2. Props 向下Events 向上 -->
<template>
  <ActivityCard
    :activity="activity"
    @click="handleClick"
    @register="handleRegister"
  />
</template>

<!-- 3. 使用 v-model 实现双向绑定 -->
<template>
  <SearchInput v-model="keyword" />
</template>

<!-- SearchInput.vue -->
<script setup lang="ts">
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

function handleInput(e: Event) {
  emit('update:modelValue', (e.target as HTMLInputElement).value)
}
</script>

<!-- 4. 使用插槽提供扩展性 -->
<template>
  <Card>
    <template #header>
      <slot name="header" />
    </template>
    <slot />
    <template #footer>
      <slot name="footer" />
    </template>
  </Card>
</template>

3.3 组件 Props 规范

// 1. 使用 TypeScript 定义 Props
interface Props {
  // 必填属性
  activityId: number
  // 可选属性,带默认值
  showImage?: boolean
  // 复杂类型
  activity: Activity
}

const props = withDefaults(defineProps<Props>(), {
  showImage: true
})

// 2. Props 校验可选TS 已提供类型检查)
const props = defineProps({
  activityId: {
    type: Number,
    required: true
  },
  status: {
    type: Number,
    default: 0,
    validator: (value: number) => [0, 1, 2, 3].includes(value)
  }
})

3.4 组件样式规范

<style lang="scss" scoped>
// 1. 使用 scoped 避免样式污染
// 2. 使用 SCSS 变量
@import '@/assets/styles/variables.scss';

.activity-card {
  background-color: $white;
  border-radius: $border-radius-lg;

  // 3. 使用 BEM 命名
  &__title {
    font-size: $font-size-lg;
    color: $text-primary;
  }

  // 4. 响应式样式使用 mixin
  @include respond-to('mobile') {
    padding: $spacing-sm;
  }
}

// 5. 深度选择器修改子组件样式
:deep(.van-button) {
  border-radius: $border-radius-md;
}
</style>

4. 类型定义规范

4.1 API 响应类型

// src/types/api.d.ts

// 统一响应格式
interface ApiResponse<T = any> {
  code: number
  message: string
  data: T
  timestamp: number
}

// 分页响应
interface PageResult<T> {
  records: T[]
  total: number
  pages: number
  current: number
  size: number
}

// 分页请求参数
interface PageParams {
  page?: number
  size?: number
}

4.2 业务类型定义

// src/types/user.d.ts
interface UserInfo {
  id: number
  username: string
  name: string
  studentId: string | null
  email: string | null
  phone: string | null
  avatar: string | null
  role: 0 | 1  // 0-学生1-管理员
}

interface LoginRequest {
  username: string
  password: string
}

interface LoginResponse {
  accessToken: string
  refreshToken: string
  expiresIn: number
  tokenType: string
  userInfo: UserInfo
}

// src/types/activity.d.ts
interface Activity {
  id: number
  title: string
  description: string
  coverImage: string | null
  startTime: string
  endTime: string
  registrationDeadline: string | null
  location: string
  maxParticipants: number
  currentParticipants: number
  status: ActivityStatus
  category: string | null
  adminId: number
  adminName?: string
  averageRating?: number
  reviewCount?: number
  isRegistered?: boolean
  createdAt: string
}

type ActivityStatus = 0 | 1 | 2 | 3

interface ActivityListParams extends PageParams {
  status?: ActivityStatus
  keyword?: string
  category?: string
  startDate?: string
  endDate?: string
}

interface CreateActivityParams {
  title: string
  description?: string
  coverImage?: string
  startTime: string
  endTime: string
  registrationDeadline?: string
  location: string
  maxParticipants: number
  category?: string
}

// src/types/registration.d.ts
interface Registration {
  id: number
  activityId: number
  activityTitle: string
  activityStartTime: string
  activityLocation: string
  ticketCode: string
  ticketPdfUrl: string
  status: RegistrationStatus
  createdAt: string
}

type RegistrationStatus = 0 | 1 | 2  // 0-已取消1-已报名2-已签到

// src/types/review.d.ts
interface Review {
  id: number
  userId: number
  userName: string
  userAvatar: string | null
  activityId: number
  rating: 1 | 2 | 3 | 4 | 5
  content: string
  createdAt: string
}

interface CreateReviewParams {
  activityId: number
  rating: number
  content: string
}

5. 错误处理规范

5.1 API 错误处理

// src/utils/request.ts
import axios from 'axios'
import { showToast, showDialog } from 'vant'
import router from '@/router'
import { useUserStore } from '@/stores/user'

const instance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000
})

// 响应拦截器
instance.interceptors.response.use(
  response => {
    const { code, message, data } = response.data

    // 业务成功
    if (code === 200) {
      return response.data
    }

    // 业务错误
    showToast(message || '操作失败')
    return Promise.reject(new Error(message))
  },
  error => {
    const { response } = error

    if (response) {
      const { status, data } = response

      switch (status) {
        case 400:
          showToast(data.message || '请求参数错误')
          break
        case 401:
          // Token 过期,清除登录状态,跳转登录页
          const userStore = useUserStore()
          userStore.logout()
          router.push('/login')
          showToast('登录已过期,请重新登录')
          break
        case 403:
          showToast('无权限访问')
          break
        case 404:
          showToast('请求的资源不存在')
          break
        case 409:
          // 业务冲突,显示具体原因
          showToast(data.message || '操作冲突')
          break
        case 500:
          showToast('服务器异常,请稍后重试')
          break
        default:
          showToast('网络异常')
      }
    } else {
      showToast('网络连接失败')
    }

    return Promise.reject(error)
  }
)

5.2 组件错误处理

<script setup lang="ts">
import { ref } from 'vue'
import { showToast, showLoadingToast, closeToast } from 'vant'
import { registerActivity } from '@/api/registration'

const loading = ref(false)

async function handleRegister() {
  if (loading.value) return

  try {
    loading.value = true
    showLoadingToast({ message: '报名中...', forbidClick: true })

    await registerActivity({ activityId: props.activityId })

    showToast('报名成功')
    emit('success')
  } catch (error: any) {
    // 错误已在拦截器处理,这里可以做额外处理
    console.error('报名失败:', error)
  } finally {
    loading.value = false
    closeToast()
  }
}
</script>

5.3 表单校验

<template>
  <van-form @submit="handleSubmit" @failed="handleFailed">
    <van-cell-group inset>
      <van-field
        v-model="form.username"
        name="username"
        label="用户名"
        placeholder="请输入用户名"
        :rules="[
          { required: true, message: '请输入用户名' },
          { pattern: /^[a-zA-Z0-9_]{3,20}$/, message: '用户名格式不正确' }
        ]"
      />
      <van-field
        v-model="form.password"
        type="password"
        name="password"
        label="密码"
        placeholder="请输入密码"
        :rules="[
          { required: true, message: '请输入密码' },
          { validator: validatePassword, message: '密码至少6位' }
        ]"
      />
    </van-cell-group>
    <div class="submit-btn">
      <van-button round block type="primary" native-type="submit">
        提交
      </van-button>
    </div>
  </van-form>
</template>

<script setup lang="ts">
import { reactive } from 'vue'
import { showToast } from 'vant'

const form = reactive({
  username: '',
  password: ''
})

// 自定义校验函数
function validatePassword(value: string) {
  return value.length >= 6
}

function handleSubmit() {
  // 校验通过
  console.log('提交:', form)
}

function handleFailed(errorInfo: any) {
  // 校验失败
  showToast(errorInfo.errors[0].message)
}
</script>

6. 注释规范

6.1 文件头注释

/**
 * @file 活动相关 API 接口
 * @author Your Name
 * @description 包含活动的增删改查、日历视图、冲突检测等接口
 */

6.2 函数注释

/**
 * 格式化日期时间
 * @param date - 日期字符串或 Date 对象
 * @param format - 格式化模板,默认 'YYYY-MM-DD HH:mm'
 * @returns 格式化后的日期字符串
 * @example
 * formatDateTime('2025-06-01T09:00:00') // '2025-06-01 09:00'
 * formatDateTime(new Date(), 'YYYY年MM月DD日') // '2025年06月01日'
 */
export function formatDateTime(
  date: string | Date,
  format: string = 'YYYY-MM-DD HH:mm'
): string {
  return dayjs(date).format(format)
}

6.3 组件注释

<!--
  ActivityCard 活动卡片组件

  用于展示活动的基本信息包括封面标题时间地点状态等

  @prop {Activity} activity - 活动数据
  @prop {boolean} showImage - 是否显示封面图默认 true
  @emit click - 点击卡片时触发
  @emit register - 点击报名按钮时触发

  @example
  <ActivityCard
    :activity="activity"
    @click="handleClick"
    @register="handleRegister"
  />
-->

6.4 复杂逻辑注释

async function handleRegister() {
  // 1. 检查是否已登录
  if (!userStore.isLoggedIn) {
    router.push('/login')
    return
  }

  // 2. 检查报名状态
  // - 已报名:提示已报名
  // - 已满员:提示名额已满
  // - 已截止:提示报名已截止
  if (activity.value.isRegistered) {
    showToast('您已报名该活动')
    return
  }

  // 3. 发起报名请求
  // ...
}

7. Git 提交规范

7.1 提交信息格式

<type>(<scope>): <subject>

<body>

<footer>

7.2 Type 类型

Type 说明
feat 新功能
fix 修复 Bug
docs 文档更新
style 代码格式(不影响功能)
refactor 重构
perf 性能优化
test 测试相关
build 构建相关
ci CI 配置
chore 其他杂项

7.3 提交示例

# 新功能
feat(activity): 添加活动日历视图

# Bug 修复
fix(auth): 修复 Token 刷新失败的问题

# 文档
docs(readme): 更新项目启动说明

# 代码格式
style(components): 统一组件代码缩进

# 重构
refactor(api): 重构请求拦截器逻辑

7.4 分支命名

分支类型 命名格式 示例
主分支 main main
开发分支 develop develop
功能分支 feature/xxx feature/activity-calendar
修复分支 fix/xxx fix/login-bug
发布分支 release/x.x.x release/1.0.0

8. 性能优化规范

8.1 图片优化

<!-- 使用 Vant 的图片懒加载 -->
<van-image
  lazy-load
  :src="activity.coverImage"
  fit="cover"
  width="100%"
  height="150"
/>

8.2 路由懒加载

// src/router/routes.ts
export const routes = [
  {
    path: '/activities',
    name: 'ActivityList',
    component: () => import('@/views/activity/List.vue')
  },
  {
    path: '/activities/:id',
    name: 'ActivityDetail',
    component: () => import('@/views/activity/Detail.vue')
  }
]

8.3 组件按需导入

// src/main.ts
// Vant 按需导入
import {
  Button,
  Cell,
  CellGroup,
  Form,
  Field,
  NavBar,
  Tabbar,
  TabbarItem
} from 'vant'

app.use(Button)
app.use(Cell)
app.use(CellGroup)
// ...

// 或使用自动导入插件
// vite.config.ts
import Components from 'unplugin-vue-components/vite'
import { VantResolver } from 'unplugin-vue-components/resolvers'

export default {
  plugins: [
    Components({
      resolvers: [VantResolver()]
    })
  ]
}

8.4 列表性能优化

<template>
  <!-- 使用 v-for  key -->
  <div v-for="item in list" :key="item.id">
    {{ item.title }}
  </div>

  <!-- 长列表使用虚拟滚动 -->
  <van-list
    v-model:loading="loading"
    :finished="finished"
    finished-text="没有更多了"
    @load="onLoad"
  >
    <ActivityCard
      v-for="activity in activities"
      :key="activity.id"
      :activity="activity"
    />
  </van-list>
</template>

8.5 缓存优化

// 使用 keep-alive 缓存页面
<router-view v-slot="{ Component }">
  <keep-alive :include="['ActivityList', 'MyRegistrations']">
    <component :is="Component" />
  </keep-alive>
</router-view>

9. 安全规范

9.1 XSS 防护

<!-- Bad: 直接渲染 HTML -->
<div v-html="userInput"></div>

<!-- Good: 使用文本插值Vue 会自动转义 -->
<div>{{ userInput }}</div>

<!-- 如必须渲染 HTML先进行转义 -->
<div v-html="sanitizedHtml"></div>

<script setup>
import DOMPurify from 'dompurify'

const sanitizedHtml = computed(() => {
  return DOMPurify.sanitize(rawHtml.value)
})
</script>

9.2 敏感信息处理

// 不要在前端存储敏感信息明文
// Bad
localStorage.setItem('password', password)

// 只存储 Token
localStorage.setItem('accessToken', token)

// Token 过期时间较短2小时
// 使用 Refresh Token 刷新

9.3 请求安全

// 1. 所有 API 请求都要携带 Token
// 2. 敏感操作需要二次确认
showDialog({
  title: '确认删除',
  message: '删除后无法恢复,确定要删除吗?',
  showCancelButton: true
}).then(() => {
  // 用户确认后执行删除
  deleteActivity(id)
})

// 3. 防止重复提交
const submitting = ref(false)

async function handleSubmit() {
  if (submitting.value) return
  submitting.value = true
  try {
    await submitData()
  } finally {
    submitting.value = false
  }
}

10. 测试规范

10.1 单元测试

// src/utils/__tests__/format.test.ts
import { describe, it, expect } from 'vitest'
import { formatDateTime, formatActivityStatus } from '../format'

describe('formatDateTime', () => {
  it('should format date string correctly', () => {
    const result = formatDateTime('2025-06-01T09:00:00')
    expect(result).toBe('2025-06-01 09:00')
  })

  it('should support custom format', () => {
    const result = formatDateTime('2025-06-01', 'YYYY年MM月DD日')
    expect(result).toBe('2025年06月01日')
  })
})

describe('formatActivityStatus', () => {
  it('should return correct status text', () => {
    expect(formatActivityStatus(0)).toBe('未开始')
    expect(formatActivityStatus(1)).toBe('报名中')
    expect(formatActivityStatus(2)).toBe('进行中')
    expect(formatActivityStatus(3)).toBe('已结束')
  })
})

10.2 组件测试

// src/components/__tests__/ActivityCard.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ActivityCard from '../activity/ActivityCard.vue'

const mockActivity = {
  id: 1,
  title: '测试活动',
  startTime: '2025-06-01 09:00:00',
  location: '体育馆',
  status: 1
}

describe('ActivityCard', () => {
  it('should render activity title', () => {
    const wrapper = mount(ActivityCard, {
      props: { activity: mockActivity }
    })
    expect(wrapper.text()).toContain('测试活动')
  })

  it('should emit click event', async () => {
    const wrapper = mount(ActivityCard, {
      props: { activity: mockActivity }
    })
    await wrapper.trigger('click')
    expect(wrapper.emitted('click')).toBeTruthy()
  })
})

附录:常用代码片段

A. Vant 表单模板

<template>
  <van-form @submit="handleSubmit">
    <van-cell-group inset>
      <van-field
        v-model="form.title"
        name="title"
        label="标题"
        placeholder="请输入标题"
        :rules="[{ required: true, message: '请输入标题' }]"
      />
      <van-field
        v-model="form.description"
        name="description"
        label="描述"
        type="textarea"
        placeholder="请输入描述"
        rows="3"
        autosize
      />
    </van-cell-group>
    <div style="margin: 16px">
      <van-button round block type="primary" native-type="submit">
        提交
      </van-button>
    </div>
  </van-form>
</template>

B. 下拉刷新 + 加载更多

<template>
  <van-pull-refresh v-model="refreshing" @refresh="onRefresh">
    <van-list
      v-model:loading="loading"
      :finished="finished"
      finished-text="没有更多了"
      @load="onLoad"
    >
      <div v-for="item in list" :key="item.id">
        {{ item.title }}
      </div>
    </van-list>
  </van-pull-refresh>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const list = ref([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const page = ref(1)

async function onLoad() {
  const res = await fetchList({ page: page.value })
  list.value.push(...res.data.records)
  loading.value = false

  if (list.value.length >= res.data.total) {
    finished.value = true
  } else {
    page.value++
  }
}

async function onRefresh() {
  page.value = 1
  list.value = []
  finished.value = false
  await onLoad()
  refreshing.value = false
}
</script>

C. 弹出确认框

import { showConfirmDialog, showToast } from 'vant'

async function handleDelete(id: number) {
  try {
    await showConfirmDialog({
      title: '确认删除',
      message: '删除后无法恢复,确定要删除吗?'
    })

    await deleteItem(id)
    showToast('删除成功')
    fetchList()
  } catch {
    // 用户取消
  }
}