零基础玩转 FastAPI_11:前端后台管理页面

阶段 11:前端后台管理页面

合辑 :FastAPI × Vue3 JoyFit 系统阶段开发计划 | 端 :前端 | 阶段 :11 / 12 | 前置阶段 :阶段 10:前端登录权限与布局

大家好,我是小马不起床。

目录

  1. 一、阶段目标
  2. 二、本阶段开发顺序
  3. 三、本阶段接口依赖
  4. 四、本阶段文件清单
  5. [五、步骤 1:创建系统权限 API](#五、步骤 1:创建系统权限 API)
  6. [六、步骤 2:创建用户 API](#六、步骤 2:创建用户 API)
  7. [七、步骤 3:创建门店 API](#七、步骤 3:创建门店 API)
  8. [八、步骤 4:创建课程 API](#八、步骤 4:创建课程 API)
  9. [九、步骤 5:创建动作库 API](#九、步骤 5:创建动作库 API)
  10. [十、步骤 6:创建内容 API](#十、步骤 6:创建内容 API)
  11. [十一、步骤 7:创建用户管理页面](#十一、步骤 7:创建用户管理页面)
  12. [十二、步骤 8:创建门店管理页面](#十二、步骤 8:创建门店管理页面)
  13. [十三、步骤 9:创建课程管理页面](#十三、步骤 9:创建课程管理页面)
  14. [十四、步骤 10:创建动作库管理页面](#十四、步骤 10:创建动作库管理页面)
  15. [十五、步骤 11:创建活动管理页面](#十五、步骤 11:创建活动管理页面)
  16. [十六、步骤 12:创建 Slogan 管理页面](#十六、步骤 12:创建 Slogan 管理页面)
  17. [十七、步骤 13:创建角色管理页面](#十七、步骤 13:创建角色管理页面)
  18. [十八、步骤 14:创建权限管理页面](#十八、步骤 14:创建权限管理页面)
  19. [十九、步骤 15:修改路由,把占位页替换为真实页面](#十九、步骤 15:修改路由,把占位页替换为真实页面)
  20. [二十、步骤 16:启动和验证](#二十、步骤 16:启动和验证)
  21. 二十一、本阶段验收清单
  22. 二十二、常见问题
  23. 二十三、进入下一阶段的条件
  24. 二十四、问题记录与修复方案

说明:文中尖括号 <> 内容为占位符,使用时按实际环境替换。

一、阶段目标

本阶段目标是在登录、Token、权限菜单、后台基础布局已经完成的基础上,将上一阶段的 PlaceholderView.vue 占位页替换为真实后台管理页面。

完成本阶段后,前端应具备以下能力:

  1. 用户管理页面可以查询、新增、编辑、启停用户。
  2. 门店管理页面可以维护省份和门店。
  3. 课程管理页面可以维护课程分类和课程。
  4. 动作库页面可以维护动作分类和动作条目。
  5. 活动管理页面可以维护赛事活动。
  6. Slogan 管理页面可以维护首页标语。
  7. 角色管理页面可以维护角色基础信息。
  8. 权限管理页面可以查看后端初始化的权限列表。
  9. 路由不再进入占位页,而是进入对应业务管理页。

说明:当前后端已提供 PUT /api/system/roles/{role_id}/permissions,但未提供"查询某个角色当前权限 ID 列表"的接口,因此本阶段暂不实现角色权限勾选分配页面,避免前端无法回显当前权限导致误覆盖。后续如补充 GET /api/system/roles/{role_id}/permissions,再单独增加角色权限分配弹窗。

二、本阶段开发顺序

text 复制代码
1. 创建后台业务 API 封装
2. 创建用户管理页面
3. 创建门店管理页面
4. 创建课程管理页面
5. 创建动作库管理页面
6. 创建活动管理页面
7. 创建 Slogan 管理页面
8. 创建角色管理页面
9. 创建权限管理页面
10. 修改路由,把占位页替换为真实页面
11. 启动前端并逐页联调

三、本阶段接口依赖

本阶段依赖后端已经完成以下接口:

模块 方法 路径
用户 GET/POST /api/users
用户 GET/PUT /api/users/{user_id}
用户 PUT /api/users/{user_id}/status
门店省份 GET/POST /api/provinces
门店省份 PUT/DELETE /api/provinces/{province_id}
门店 GET/POST /api/stores
门店 GET/PUT/DELETE /api/stores/{store_id}
课程分类 GET/POST /api/course-categories
课程分类 PUT/DELETE /api/course-categories/{category_id}
课程 GET/POST /api/courses
课程 GET/PUT/DELETE /api/courses/{course_id}
动作分类 GET/POST /api/action-categories
动作分类 PUT/DELETE /api/action-categories/{category_id}
动作 GET/POST /api/actions
动作 GET/PUT/DELETE /api/actions/{action_id}
活动 GET/POST /api/content/events
活动 GET/PUT/DELETE /api/content/events/{event_id}
Slogan GET/POST /api/content/slogans
Slogan PUT/DELETE /api/content/slogans/{slogan_id}
角色 GET/POST /api/system/roles
角色 PUT/DELETE /api/system/roles/{role_id}
权限 GET /api/system/permissions

前端 Axios 的 baseURL 为 /api,业务代码中的请求路径不再需要书写 /api 前缀。

四、本阶段文件清单

序号 相对路径 类型 说明
1 frontend/src/api/system.ts 新增 角色、权限接口
2 frontend/src/api/user.ts 新增 用户接口
3 frontend/src/api/store.ts 新增 省份、门店接口
4 frontend/src/api/course.ts 新增 课程分类、课程接口
5 frontend/src/api/action.ts 新增 动作分类、动作接口
6 frontend/src/api/content.ts 新增 活动、Slogan 接口
7 frontend/src/views/admin/UserManageView.vue 新增 用户管理页面
8 frontend/src/views/admin/StoreManageView.vue 新增 门店管理页面
9 frontend/src/views/admin/CourseManageView.vue 新增 课程管理页面
10 frontend/src/views/admin/ActionManageView.vue 新增 动作库管理页面
11 frontend/src/views/admin/EventManageView.vue 新增 活动管理页面
12 frontend/src/views/admin/SloganManageView.vue 新增 Slogan 管理页面
13 frontend/src/views/admin/RoleManageView.vue 新增 角色管理页面
14 frontend/src/views/admin/PermissionManageView.vue 新增 权限管理页面
15 frontend/src/router/index.ts 修改 替换占位页路由

五、步骤 1:创建系统权限 API

相对路径

text 复制代码
frontend/src/api/system.ts

完整代码

typescript 复制代码
import { request } from './http'

export interface Role {
  id: number
  role_code: string
  role_name: string
}

export interface RoleForm {
  role_code: string
  role_name: string
}

export interface Permission {
  id: number
  permission_code: string
  permission_name: string
  menu_path: string | null
}

export function listRolesApi() {
  return request<Role[]>({
    url: '/system/roles',
    method: 'get',
  })
}

export function createRoleApi(data: RoleForm) {
  return request<Role>({
    url: '/system/roles',
    method: 'post',
    data,
  })
}

export function updateRoleApi(roleId: number, data: Partial<RoleForm>) {
  return request<Role>({
    url: `/system/roles/${roleId}`,
    method: 'put',
    data,
  })
}

export function deleteRoleApi(roleId: number) {
  return request<void>({
    url: `/system/roles/${roleId}`,
    method: 'delete',
  })
}

export function listPermissionsApi() {
  return request<Permission[]>({
    url: '/system/permissions',
    method: 'get',
  })
}

export function updateRolePermissionsApi(roleId: number, permissionIds: number[]) {
  return request<void>({
    url: `/system/roles/${roleId}/permissions`,
    method: 'put',
    data: {
      permission_ids: permissionIds,
    },
  })
}

六、步骤 2:创建用户 API

相对路径

text 复制代码
frontend/src/api/user.ts

完整代码

typescript 复制代码
import { request } from './http'
import type { PageResult } from './types'

export interface User {
  user_id: number
  role_id: number
  username: string
  real_name: string | null
  phone: string | null
  status: number | null
}

export interface UserQuery {
  page: number
  page_size: number
  keyword?: string
  status?: number
  role_id?: number
}

export interface UserForm {
  role_id: number
  username: string
  password?: string
  real_name?: string | null
  phone?: string | null
  status: number
}

export interface UserProfile {
  profile_id: number
  user_id: number
  level: number | null
  gender: number | null
  birthday: string | null
  height_cm: number | null
  weight_kg: number | null
  avatar_url: string | null
  intro: string | null
  create_time: string | null
  join_time: string | null
  expire_date: string | null
}

export interface UserStoreRecord {
  id: number
  user_id: number
  role_id: number
  store_id: number
  created_at: string | null
}

export function listUsersApi(params: UserQuery) {
  return request<PageResult<User>>({
    url: '/users',
    method: 'get',
    params,
  })
}

export function createUserApi(data: UserForm) {
  return request<User>({
    url: '/users',
    method: 'post',
    data,
  })
}

export function updateUserApi(userId: number, data: Partial<UserForm>) {
  return request<User>({
    url: `/users/${userId}`,
    method: 'put',
    data,
  })
}

export function updateUserStatusApi(userId: number, status: number) {
  return request<User>({
    url: `/users/${userId}/status`,
    method: 'put',
    data: {
      status,
    },
  })
}

export function getUserProfileApi(userId: number) {
  return request<UserProfile>({
    url: `/users/${userId}/profile`,
    method: 'get',
  })
}

export function updateUserProfileApi(userId: number, data: Partial<UserProfile>) {
  return request<UserProfile>({
    url: `/users/${userId}/profile`,
    method: 'put',
    data,
  })
}

export function listUserStoresApi(userId: number) {
  return request<UserStoreRecord[]>({
    url: `/users/${userId}/stores`,
    method: 'get',
  })
}

export function bindUserStoresApi(userId: number, storeIds: number[], roleId?: number) {
  return request<UserStoreRecord[]>({
    url: `/users/${userId}/stores`,
    method: 'put',
    data: {
      store_ids: storeIds,
      role_id: roleId,
    },
  })
}

七、步骤 3:创建门店 API

相对路径

text 复制代码
frontend/src/api/store.ts

完整代码

typescript 复制代码
import { request } from './http'
import type { PageResult } from './types'

export interface Province {
  province_id: number
  province_name: string
  center_lng: number | null
  center_lat: number | null
}

export interface ProvinceForm {
  province_name: string
  center_lng?: number | null
  center_lat?: number | null
}

export interface Store {
  store_id: number
  store_name: string
  store_type: number
  province_id: number
  province_name: string | null
  city: string | null
  district: string | null
  address: string | null
  store_phone: string | null
  store_image_url: string | null
  store_introduction: string | null
  business_hours: string | null
  is_operating: number | null
  store_lng: number | null
  store_lat: number | null
}

export interface StoreQuery {
  page: number
  page_size: number
  keyword?: string
  province_id?: number
  store_type?: number
  is_operating?: number
}

export interface StoreForm {
  store_name: string
  store_type: number
  province_id: number
  province_name?: string | null
  city?: string | null
  district?: string | null
  address?: string | null
  store_phone?: string | null
  store_image_url?: string | null
  store_introduction?: string | null
  business_hours?: string | null
  is_operating: number
  store_lng?: number | null
  store_lat?: number | null
}

export function listProvincesApi() {
  return request<Province[]>({
    url: '/provinces',
    method: 'get',
  })
}

export function createProvinceApi(data: ProvinceForm) {
  return request<Province>({
    url: '/provinces',
    method: 'post',
    data,
  })
}

export function updateProvinceApi(provinceId: number, data: Partial<ProvinceForm>) {
  return request<Province>({
    url: `/provinces/${provinceId}`,
    method: 'put',
    data,
  })
}

export function deleteProvinceApi(provinceId: number) {
  return request<void>({
    url: `/provinces/${provinceId}`,
    method: 'delete',
  })
}

export function listStoresApi(params: StoreQuery) {
  return request<PageResult<Store>>({
    url: '/stores',
    method: 'get',
    params,
  })
}

export function createStoreApi(data: StoreForm) {
  return request<Store>({
    url: '/stores',
    method: 'post',
    data,
  })
}

export function updateStoreApi(storeId: number, data: Partial<StoreForm>) {
  return request<Store>({
    url: `/stores/${storeId}`,
    method: 'put',
    data,
  })
}

export function deleteStoreApi(storeId: number) {
  return request<void>({
    url: `/stores/${storeId}`,
    method: 'delete',
  })
}

八、步骤 4:创建课程 API

相对路径

text 复制代码
frontend/src/api/course.ts

完整代码

typescript 复制代码
import { request } from './http'
import type { PageResult } from './types'

export interface CourseCategory {
  category_id: number
  category_name: string
  category_url: string | null
  description: string | null
  status: number | null
}

export interface CourseCategoryForm {
  category_name: string
  category_url?: string | null
  description?: string | null
  status: number
}

export interface Course {
  course_id: number
  course_name: string
  category_id: number
  course_difficulty: number | null
  duration_minutes: number | null
  max_participants: number | null
  schedule_info: string | null
  description: string | null
  status: number | null
}

export interface CourseQuery {
  page: number
  page_size: number
  keyword?: string
  category_id?: number
  course_difficulty?: number
  status?: number
}

export interface CourseForm {
  course_name: string
  category_id: number
  course_difficulty?: number | null
  duration_minutes?: number | null
  max_participants?: number | null
  schedule_info?: string | null
  description?: string | null
  status: number
}

export function listCourseCategoriesApi() {
  return request<CourseCategory[]>({
    url: '/course-categories',
    method: 'get',
  })
}

export function createCourseCategoryApi(data: CourseCategoryForm) {
  return request<CourseCategory>({
    url: '/course-categories',
    method: 'post',
    data,
  })
}

export function updateCourseCategoryApi(categoryId: number, data: Partial<CourseCategoryForm>) {
  return request<CourseCategory>({
    url: `/course-categories/${categoryId}`,
    method: 'put',
    data,
  })
}

export function deleteCourseCategoryApi(categoryId: number) {
  return request<void>({
    url: `/course-categories/${categoryId}`,
    method: 'delete',
  })
}

export function listCoursesApi(params: CourseQuery) {
  return request<PageResult<Course>>({
    url: '/courses',
    method: 'get',
    params,
  })
}

export function createCourseApi(data: CourseForm) {
  return request<Course>({
    url: '/courses',
    method: 'post',
    data,
  })
}

export function updateCourseApi(courseId: number, data: Partial<CourseForm>) {
  return request<Course>({
    url: `/courses/${courseId}`,
    method: 'put',
    data,
  })
}

export function deleteCourseApi(courseId: number) {
  return request<void>({
    url: `/courses/${courseId}`,
    method: 'delete',
  })
}

九、步骤 5:创建动作库 API

相对路径

text 复制代码
frontend/src/api/action.ts

完整代码

typescript 复制代码
import { request } from './http'
import type { PageResult } from './types'

export interface ActionCategory {
  category_id: number
  category_name: string
  category_image_url: string | null
}

export interface ActionCategoryForm {
  category_name: string
  category_image_url?: string | null
}

export interface ActionItem {
  action_id: number
  action_name: string
  category_id: number
  action_difficulty: number | null
  action_image_url: string | null
  action_steps: string | null
  attention_points: string | null
  applicable_equipment: string | null
  applicable_store_type: number | null
}

export interface ActionQuery {
  page: number
  page_size: number
  keyword?: string
  category_id?: number
  action_difficulty?: number
  applicable_store_type?: number
}

export interface ActionForm {
  action_name: string
  category_id: number
  action_difficulty?: number | null
  action_image_url?: string | null
  action_steps?: string | null
  attention_points?: string | null
  applicable_equipment?: string | null
  applicable_store_type?: number | null
}

export function listActionCategoriesApi() {
  return request<ActionCategory[]>({
    url: '/action-categories',
    method: 'get',
  })
}

export function createActionCategoryApi(data: ActionCategoryForm) {
  return request<ActionCategory>({
    url: '/action-categories',
    method: 'post',
    data,
  })
}

export function updateActionCategoryApi(categoryId: number, data: Partial<ActionCategoryForm>) {
  return request<ActionCategory>({
    url: `/action-categories/${categoryId}`,
    method: 'put',
    data,
  })
}

export function deleteActionCategoryApi(categoryId: number) {
  return request<void>({
    url: `/action-categories/${categoryId}`,
    method: 'delete',
  })
}

export function listActionsApi(params: ActionQuery) {
  return request<PageResult<ActionItem>>({
    url: '/actions',
    method: 'get',
    params,
  })
}

export function createActionApi(data: ActionForm) {
  return request<ActionItem>({
    url: '/actions',
    method: 'post',
    data,
  })
}

export function updateActionApi(actionId: number, data: Partial<ActionForm>) {
  return request<ActionItem>({
    url: `/actions/${actionId}`,
    method: 'put',
    data,
  })
}

export function deleteActionApi(actionId: number) {
  return request<void>({
    url: `/actions/${actionId}`,
    method: 'delete',
  })
}

十、步骤 6:创建内容 API

相对路径

text 复制代码
frontend/src/api/content.ts

完整代码

typescript 复制代码
import { request } from './http'
import type { PageResult } from './types'

export interface Slogan {
  slogan_id: number
  slogan_name: string
  slogan_content: string
  slogan_image_url: string | null
  status: number | null
}

export interface SloganForm {
  slogan_name: string
  slogan_content: string
  slogan_image_url?: string | null
  status: number
}

export interface ActivityEvent {
  event_id: number
  title: string
  event_date: string
  location: string
  status: string
  description: string
  tags: string | null
  prize: string | null
  scale: string | null
  created_at: string | null
  updated_at: string | null
}

export interface ActivityEventQuery {
  page: number
  page_size: number
  keyword?: string
  status?: string
  date_from?: string
  date_to?: string
}

export interface ActivityEventForm {
  title: string
  event_date: string
  location: string
  status: string
  description: string
  tags?: string | null
  prize?: string | null
  scale?: string | null
}

export function listSlogansApi(status?: number) {
  return request<Slogan[]>({
    url: '/content/slogans',
    method: 'get',
    params: {
      status,
    },
  })
}

export function createSloganApi(data: SloganForm) {
  return request<Slogan>({
    url: '/content/slogans',
    method: 'post',
    data,
  })
}

export function updateSloganApi(sloganId: number, data: Partial<SloganForm>) {
  return request<Slogan>({
    url: `/content/slogans/${sloganId}`,
    method: 'put',
    data,
  })
}

export function deleteSloganApi(sloganId: number) {
  return request<void>({
    url: `/content/slogans/${sloganId}`,
    method: 'delete',
  })
}

export function listEventsApi(params: ActivityEventQuery) {
  return request<PageResult<ActivityEvent>>({
    url: '/content/events',
    method: 'get',
    params,
  })
}

export function createEventApi(data: ActivityEventForm) {
  return request<ActivityEvent>({
    url: '/content/events',
    method: 'post',
    data,
  })
}

export function updateEventApi(eventId: number, data: Partial<ActivityEventForm>) {
  return request<ActivityEvent>({
    url: `/content/events/${eventId}`,
    method: 'put',
    data,
  })
}

export function deleteEventApi(eventId: number) {
  return request<void>({
    url: `/content/events/${eventId}`,
    method: 'delete',
  })
}

十一、步骤 7:创建用户管理页面

相对路径

text 复制代码
frontend/src/views/admin/UserManageView.vue

完整代码

vue 复制代码
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'

import { listRolesApi, type Role } from '../../api/system'
import {
  createUserApi,
  listUsersApi,
  updateUserApi,
  updateUserStatusApi,
  type User,
  type UserForm,
} from '../../api/user'

const loading = ref(false)
const users = ref<User[]>([])
const roles = ref<Role[]>([])
const total = ref(0)
const formRef = ref<FormInstance>()
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)

const query = reactive({
  page: 1,
  page_size: 10,
  keyword: '',
  status: undefined as number | undefined,
  role_id: undefined as number | undefined,
})

const form = reactive<UserForm>({
  role_id: 0,
  username: '',
  password: '',
  real_name: '',
  phone: '',
  status: 1,
})

function roleName(roleId: number) {
  return roles.value.find((item) => item.id === roleId)?.role_name || `角色 ${roleId}`
}

function resetForm() {
  form.role_id = roles.value[0]?.id || 0
  form.username = ''
  form.password = ''
  form.real_name = ''
  form.phone = ''
  form.status = 1
}

async function fetchRoles() {
  roles.value = await listRolesApi()
  if (!form.role_id) {
    form.role_id = roles.value[0]?.id || 0
  }
}

async function fetchUsers() {
  loading.value = true
  try {
    const result = await listUsersApi({
      page: query.page,
      page_size: query.page_size,
      keyword: query.keyword || undefined,
      status: query.status,
      role_id: query.role_id,
    })

    users.value = result.items
    total.value = result.total
  } finally {
    loading.value = false
  }
}

function search() {
  query.page = 1
  fetchUsers()
}

function resetSearch() {
  query.page = 1
  query.keyword = ''
  query.status = undefined
  query.role_id = undefined
  fetchUsers()
}

function openCreate() {
  editingId.value = null
  resetForm()
  dialogVisible.value = true
}

function openEdit(row: User) {
  editingId.value = row.user_id
  form.role_id = row.role_id
  form.username = row.username
  form.password = ''
  form.real_name = row.real_name || ''
  form.phone = row.phone || ''
  form.status = row.status ?? 1
  dialogVisible.value = true
}

async function submitForm() {
  await formRef.value?.validate()

  if (!form.role_id) {
    ElMessage.warning('请选择角色')
    return
  }

  if (!editingId.value && !form.password) {
    ElMessage.warning('新增用户必须填写密码')
    return
  }

  const payload: Partial<UserForm> = {
    role_id: form.role_id,
    username: form.username,
    real_name: form.real_name || null,
    phone: form.phone || null,
    status: form.status,
  }

  if (form.password) {
    payload.password = form.password
  }

  if (editingId.value) {
    await updateUserApi(editingId.value, payload)
    ElMessage.success('用户已更新')
  } else {
    await createUserApi(payload as UserForm)
    ElMessage.success('用户已创建')
  }

  dialogVisible.value = false
  fetchUsers()
}

async function changeStatus(row: User, value: number) {
  await updateUserStatusApi(row.user_id, value)
  row.status = value
  ElMessage.success(value === 1 ? '用户已启用' : '用户已禁用')
}

onMounted(async () => {
  await fetchRoles()
  await fetchUsers()
})
</script>

<template>
  <section class="page-panel">
    <div class="toolbar">
      <el-input
        v-model="query.keyword"
        clearable
        placeholder="搜索用户名、姓名、手机号"
        @keyup.enter="search"
      />
      <el-select v-model="query.role_id" clearable placeholder="角色">
        <el-option v-for="role in roles" :key="role.id" :label="role.role_name" :value="role.id" />
      </el-select>
      <el-select v-model="query.status" clearable placeholder="状态">
        <el-option label="启用" :value="1" />
        <el-option label="禁用" :value="0" />
      </el-select>
      <el-button :icon="Search" type="primary" @click="search">查询</el-button>
      <el-button :icon="Refresh" @click="resetSearch">重置</el-button>
      <el-button :icon="Plus" type="success" @click="openCreate">新增用户</el-button>
    </div>

    <el-table v-loading="loading" :data="users" border>
      <el-table-column prop="user_id" label="ID" width="90" />
      <el-table-column prop="username" label="用户名" min-width="140" />
      <el-table-column prop="real_name" label="姓名" min-width="120" />
      <el-table-column prop="phone" label="手机号" min-width="140" />
      <el-table-column label="角色" min-width="130">
        <template #default="{ row }">
          {{ roleName(row.role_id) }}
        </template>
      </el-table-column>
      <el-table-column label="状态" width="110">
        <template #default="{ row }">
          <el-switch
            :model-value="row.status"
            :active-value="1"
            :inactive-value="0"
            @change="changeStatus(row, Number($event))"
          />
        </template>
      </el-table-column>
      <el-table-column label="操作" width="120" fixed="right">
        <template #default="{ row }">
          <el-button link type="primary" @click="openEdit(row)">编辑</el-button>
        </template>
      </el-table-column>
    </el-table>

    <div class="pagination">
      <el-pagination
        v-model:current-page="query.page"
        v-model:page-size="query.page_size"
        :page-sizes="[10, 20, 50]"
        :total="total"
        layout="total, sizes, prev, pager, next"
        @size-change="fetchUsers"
        @current-change="fetchUsers"
      />
    </div>

    <el-dialog
      v-model="dialogVisible"
      :title="editingId ? '编辑用户' : '新增用户'"
      width="520px"
    >
      <el-form ref="formRef" :model="form" label-width="90px">
        <el-form-item label="用户名" prop="username" required>
          <el-input v-model="form.username" />
        </el-form-item>
        <el-form-item :label="editingId ? '新密码' : '密码'" prop="password">
          <el-input v-model="form.password" show-password placeholder="编辑时留空表示不修改" />
        </el-form-item>
        <el-form-item label="角色" prop="role_id" required>
          <el-select v-model="form.role_id" class="full-width">
            <el-option v-for="role in roles" :key="role.id" :label="role.role_name" :value="role.id" />
          </el-select>
        </el-form-item>
        <el-form-item label="真实姓名">
          <el-input v-model="form.real_name" />
        </el-form-item>
        <el-form-item label="手机号">
          <el-input v-model="form.phone" />
        </el-form-item>
        <el-form-item label="状态">
          <el-radio-group v-model="form.status">
            <el-radio-button :label="1">启用</el-radio-button>
            <el-radio-button :label="0">禁用</el-radio-button>
          </el-radio-group>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitForm">保存</el-button>
      </template>
    </el-dialog>
  </section>
</template>

<style scoped>
.page-panel {
  display: grid;
  gap: 18px;
}

.toolbar {
  display: grid;
  grid-template-columns: minmax(220px, 1fr) 150px 130px auto auto auto;
  gap: 12px;
  align-items: center;
}

.pagination {
  display: flex;
  justify-content: flex-end;
}

.full-width {
  width: 100%;
}

@media (max-width: 900px) {
  .toolbar {
    grid-template-columns: 1fr;
  }
}
</style>

十二、步骤 8:创建门店管理页面

相对路径

text 复制代码
frontend/src/views/admin/StoreManageView.vue

完整代码

vue 复制代码
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'

import {
  createProvinceApi,
  createStoreApi,
  deleteProvinceApi,
  deleteStoreApi,
  listProvincesApi,
  listStoresApi,
  updateProvinceApi,
  updateStoreApi,
  type Province,
  type ProvinceForm,
  type Store,
  type StoreForm,
} from '../../api/store'

const loading = ref(false)
const stores = ref<Store[]>([])
const provinces = ref<Province[]>([])
const total = ref(0)
const storeDialogVisible = ref(false)
const provinceDialogVisible = ref(false)
const provinceDrawerVisible = ref(false)
const editingStoreId = ref<number | null>(null)
const editingProvinceId = ref<number | null>(null)
const storeFormRef = ref<FormInstance>()
const provinceFormRef = ref<FormInstance>()

const query = reactive({
  page: 1,
  page_size: 10,
  keyword: '',
  province_id: undefined as number | undefined,
  store_type: undefined as number | undefined,
  is_operating: undefined as number | undefined,
})

const storeForm = reactive<StoreForm>({
  store_name: '',
  store_type: 1,
  province_id: 0,
  province_name: '',
  city: '',
  district: '',
  address: '',
  store_phone: '',
  store_image_url: '',
  store_introduction: '',
  business_hours: '',
  is_operating: 1,
  store_lng: null,
  store_lat: null,
})

const provinceForm = reactive<ProvinceForm>({
  province_name: '',
  center_lng: null,
  center_lat: null,
})

const provinceNameMap = computed(() => {
  return new Map(provinces.value.map((item) => [item.province_id, item.province_name]))
})

function resetStoreForm() {
  storeForm.store_name = ''
  storeForm.store_type = 1
  storeForm.province_id = provinces.value[0]?.province_id || 0
  storeForm.province_name = provinces.value[0]?.province_name || ''
  storeForm.city = ''
  storeForm.district = ''
  storeForm.address = ''
  storeForm.store_phone = ''
  storeForm.store_image_url = ''
  storeForm.store_introduction = ''
  storeForm.business_hours = ''
  storeForm.is_operating = 1
  storeForm.store_lng = null
  storeForm.store_lat = null
}

function resetProvinceForm() {
  provinceForm.province_name = ''
  provinceForm.center_lng = null
  provinceForm.center_lat = null
}

async function fetchProvinces() {
  provinces.value = await listProvincesApi()
  if (!storeForm.province_id) {
    storeForm.province_id = provinces.value[0]?.province_id || 0
  }
}

async function fetchStores() {
  loading.value = true
  try {
    const result = await listStoresApi({
      page: query.page,
      page_size: query.page_size,
      keyword: query.keyword || undefined,
      province_id: query.province_id,
      store_type: query.store_type,
      is_operating: query.is_operating,
    })

    stores.value = result.items
    total.value = result.total
  } finally {
    loading.value = false
  }
}

function search() {
  query.page = 1
  fetchStores()
}

function resetSearch() {
  query.page = 1
  query.keyword = ''
  query.province_id = undefined
  query.store_type = undefined
  query.is_operating = undefined
  fetchStores()
}

function openCreateStore() {
  editingStoreId.value = null
  resetStoreForm()
  storeDialogVisible.value = true
}

function openEditStore(row: Store) {
  editingStoreId.value = row.store_id
  storeForm.store_name = row.store_name
  storeForm.store_type = row.store_type
  storeForm.province_id = row.province_id
  storeForm.province_name = row.province_name || provinceNameMap.value.get(row.province_id) || ''
  storeForm.city = row.city || ''
  storeForm.district = row.district || ''
  storeForm.address = row.address || ''
  storeForm.store_phone = row.store_phone || ''
  storeForm.store_image_url = row.store_image_url || ''
  storeForm.store_introduction = row.store_introduction || ''
  storeForm.business_hours = row.business_hours || ''
  storeForm.is_operating = row.is_operating ?? 1
  storeForm.store_lng = row.store_lng
  storeForm.store_lat = row.store_lat
  storeDialogVisible.value = true
}

async function submitStore() {
  await storeFormRef.value?.validate()

  const provinceName = provinceNameMap.value.get(storeForm.province_id)
  storeForm.province_name = provinceName || storeForm.province_name

  if (editingStoreId.value) {
    await updateStoreApi(editingStoreId.value, storeForm)
    ElMessage.success('门店已更新')
  } else {
    await createStoreApi(storeForm)
    ElMessage.success('门店已创建')
  }

  storeDialogVisible.value = false
  fetchStores()
}

async function removeStore(row: Store) {
  await ElMessageBox.confirm(`确认删除门店「${row.store_name}」吗?`, '删除确认', {
    type: 'warning',
  })
  await deleteStoreApi(row.store_id)
  ElMessage.success('门店已删除')
  fetchStores()
}

function openCreateProvince() {
  editingProvinceId.value = null
  resetProvinceForm()
  provinceDialogVisible.value = true
}

function openEditProvince(row: Province) {
  editingProvinceId.value = row.province_id
  provinceForm.province_name = row.province_name
  provinceForm.center_lng = row.center_lng
  provinceForm.center_lat = row.center_lat
  provinceDialogVisible.value = true
}

async function submitProvince() {
  await provinceFormRef.value?.validate()

  if (editingProvinceId.value) {
    await updateProvinceApi(editingProvinceId.value, provinceForm)
    ElMessage.success('省份已更新')
  } else {
    await createProvinceApi(provinceForm)
    ElMessage.success('省份已创建')
  }

  provinceDialogVisible.value = false
  await fetchProvinces()
}

async function removeProvince(row: Province) {
  await ElMessageBox.confirm(`确认删除省份「${row.province_name}」吗?`, '删除确认', {
    type: 'warning',
  })
  await deleteProvinceApi(row.province_id)
  ElMessage.success('省份已删除')
  await fetchProvinces()
}

onMounted(async () => {
  await fetchProvinces()
  await fetchStores()
})
</script>

<template>
  <section class="page-panel">
    <div class="toolbar">
      <el-input v-model="query.keyword" clearable placeholder="搜索门店名称、城市、地址" @keyup.enter="search" />
      <el-select v-model="query.province_id" clearable placeholder="省份">
        <el-option
          v-for="province in provinces"
          :key="province.province_id"
          :label="province.province_name"
          :value="province.province_id"
        />
      </el-select>
      <el-select v-model="query.store_type" clearable placeholder="门店类型">
        <el-option label="铁馆" :value="1" />
        <el-option label="商业私教馆" :value="2" />
      </el-select>
      <el-select v-model="query.is_operating" clearable placeholder="营业状态">
        <el-option label="营业" :value="1" />
        <el-option label="停业" :value="0" />
      </el-select>
      <el-button :icon="Search" type="primary" @click="search">查询</el-button>
      <el-button :icon="Refresh" @click="resetSearch">重置</el-button>
      <el-button @click="provinceDrawerVisible = true">省份维护</el-button>
      <el-button :icon="Plus" type="success" @click="openCreateStore">新增门店</el-button>
    </div>

    <el-table v-loading="loading" :data="stores" border>
      <el-table-column prop="store_id" label="ID" width="90" />
      <el-table-column prop="store_name" label="门店名称" min-width="160" />
      <el-table-column label="类型" width="120">
        <template #default="{ row }">
          {{ row.store_type === 1 ? '铁馆' : '商业私教馆' }}
        </template>
      </el-table-column>
      <el-table-column prop="province_name" label="省份" width="110" />
      <el-table-column prop="city" label="城市" width="110" />
      <el-table-column prop="address" label="地址" min-width="220" show-overflow-tooltip />
      <el-table-column prop="business_hours" label="营业时间" width="140" />
      <el-table-column label="状态" width="90">
        <template #default="{ row }">
          <el-tag :type="row.is_operating === 1 ? 'success' : 'info'">
            {{ row.is_operating === 1 ? '营业' : '停业' }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="操作" width="150" fixed="right">
        <template #default="{ row }">
          <el-button link type="primary" @click="openEditStore(row)">编辑</el-button>
          <el-button link type="danger" @click="removeStore(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>

    <div class="pagination">
      <el-pagination
        v-model:current-page="query.page"
        v-model:page-size="query.page_size"
        :page-sizes="[10, 20, 50]"
        :total="total"
        layout="total, sizes, prev, pager, next"
        @size-change="fetchStores"
        @current-change="fetchStores"
      />
    </div>

    <el-dialog v-model="storeDialogVisible" :title="editingStoreId ? '编辑门店' : '新增门店'" width="680px">
      <el-form ref="storeFormRef" :model="storeForm" label-width="100px">
        <el-form-item label="门店名称" prop="store_name" required>
          <el-input v-model="storeForm.store_name" />
        </el-form-item>
        <el-form-item label="门店类型" prop="store_type" required>
          <el-radio-group v-model="storeForm.store_type">
            <el-radio-button :label="1">铁馆</el-radio-button>
            <el-radio-button :label="2">商业私教馆</el-radio-button>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="省份" prop="province_id" required>
          <el-select v-model="storeForm.province_id" class="full-width">
            <el-option
              v-for="province in provinces"
              :key="province.province_id"
              :label="province.province_name"
              :value="province.province_id"
            />
          </el-select>
        </el-form-item>
        <el-form-item label="城市">
          <el-input v-model="storeForm.city" />
        </el-form-item>
        <el-form-item label="区县">
          <el-input v-model="storeForm.district" />
        </el-form-item>
        <el-form-item label="详细地址">
          <el-input v-model="storeForm.address" />
        </el-form-item>
        <el-form-item label="门店电话">
          <el-input v-model="storeForm.store_phone" />
        </el-form-item>
        <el-form-item label="营业时间">
          <el-input v-model="storeForm.business_hours" placeholder="例如 06:00-22:00" />
        </el-form-item>
        <el-form-item label="图片 URL">
          <el-input v-model="storeForm.store_image_url" />
        </el-form-item>
        <el-form-item label="门店介绍">
          <el-input v-model="storeForm.store_introduction" type="textarea" :rows="4" />
        </el-form-item>
        <el-form-item label="营业状态">
          <el-radio-group v-model="storeForm.is_operating">
            <el-radio-button :label="1">营业</el-radio-button>
            <el-radio-button :label="0">停业</el-radio-button>
          </el-radio-group>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="storeDialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitStore">保存</el-button>
      </template>
    </el-dialog>

    <el-drawer v-model="provinceDrawerVisible" title="省份维护" size="520px">
      <el-button :icon="Plus" type="primary" @click="openCreateProvince">新增省份</el-button>
      <el-table :data="provinces" border class="drawer-table">
        <el-table-column prop="province_name" label="省份名称" />
        <el-table-column prop="center_lng" label="中心经度" width="110" />
        <el-table-column prop="center_lat" label="中心纬度" width="110" />
        <el-table-column label="操作" width="120">
          <template #default="{ row }">
            <el-button link type="primary" @click="openEditProvince(row)">编辑</el-button>
            <el-button link type="danger" @click="removeProvince(row)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>
    </el-drawer>

    <el-dialog v-model="provinceDialogVisible" :title="editingProvinceId ? '编辑省份' : '新增省份'" width="460px">
      <el-form ref="provinceFormRef" :model="provinceForm" label-width="90px">
        <el-form-item label="省份名称" prop="province_name" required>
          <el-input v-model="provinceForm.province_name" />
        </el-form-item>
        <el-form-item label="中心经度">
          <el-input-number v-model="provinceForm.center_lng" :precision="6" class="full-width" />
        </el-form-item>
        <el-form-item label="中心纬度">
          <el-input-number v-model="provinceForm.center_lat" :precision="6" class="full-width" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="provinceDialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitProvince">保存</el-button>
      </template>
    </el-dialog>
  </section>
</template>

<style scoped>
.page-panel {
  display: grid;
  gap: 18px;
}

.toolbar {
  display: grid;
  grid-template-columns: minmax(220px, 1fr) 140px 140px 130px auto auto auto auto;
  gap: 12px;
  align-items: center;
}

.pagination {
  display: flex;
  justify-content: flex-end;
}

.drawer-table {
  margin-top: 14px;
}

.full-width {
  width: 100%;
}

@media (max-width: 1100px) {
  .toolbar {
    grid-template-columns: 1fr 1fr;
  }
}
</style>

十三、步骤 9:创建课程管理页面

相对路径

text 复制代码
frontend/src/views/admin/CourseManageView.vue

完整代码

vue 复制代码
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'

import {
  createCourseApi,
  createCourseCategoryApi,
  deleteCourseApi,
  deleteCourseCategoryApi,
  listCourseCategoriesApi,
  listCoursesApi,
  updateCourseApi,
  updateCourseCategoryApi,
  type Course,
  type CourseCategory,
  type CourseCategoryForm,
  type CourseForm,
} from '../../api/course'

const activeTab = ref('courses')
const loading = ref(false)
const courses = ref<Course[]>([])
const categories = ref<CourseCategory[]>([])
const total = ref(0)
const courseDialogVisible = ref(false)
const categoryDialogVisible = ref(false)
const editingCourseId = ref<number | null>(null)
const editingCategoryId = ref<number | null>(null)
const courseFormRef = ref<FormInstance>()
const categoryFormRef = ref<FormInstance>()

const query = reactive({
  page: 1,
  page_size: 10,
  keyword: '',
  category_id: undefined as number | undefined,
  course_difficulty: undefined as number | undefined,
  status: undefined as number | undefined,
})

const courseForm = reactive<CourseForm>({
  course_name: '',
  category_id: 0,
  course_difficulty: null,
  duration_minutes: null,
  max_participants: null,
  schedule_info: '',
  description: '',
  status: 1,
})

const categoryForm = reactive<CourseCategoryForm>({
  category_name: '',
  category_url: '',
  description: '',
  status: 1,
})

function categoryName(categoryId: number) {
  return categories.value.find((item) => item.category_id === categoryId)?.category_name || `分类 ${categoryId}`
}

function resetCourseForm() {
  courseForm.course_name = ''
  courseForm.category_id = categories.value[0]?.category_id || 0
  courseForm.course_difficulty = null
  courseForm.duration_minutes = null
  courseForm.max_participants = null
  courseForm.schedule_info = ''
  courseForm.description = ''
  courseForm.status = 1
}

function resetCategoryForm() {
  categoryForm.category_name = ''
  categoryForm.category_url = ''
  categoryForm.description = ''
  categoryForm.status = 1
}

async function fetchCategories() {
  categories.value = await listCourseCategoriesApi()
  if (!courseForm.category_id) {
    courseForm.category_id = categories.value[0]?.category_id || 0
  }
}

async function fetchCourses() {
  loading.value = true
  try {
    const result = await listCoursesApi({
      page: query.page,
      page_size: query.page_size,
      keyword: query.keyword || undefined,
      category_id: query.category_id,
      course_difficulty: query.course_difficulty,
      status: query.status,
    })
    courses.value = result.items
    total.value = result.total
  } finally {
    loading.value = false
  }
}

function search() {
  query.page = 1
  fetchCourses()
}

function resetSearch() {
  query.page = 1
  query.keyword = ''
  query.category_id = undefined
  query.course_difficulty = undefined
  query.status = undefined
  fetchCourses()
}

function openCreateCourse() {
  editingCourseId.value = null
  resetCourseForm()
  courseDialogVisible.value = true
}

function openEditCourse(row: Course) {
  editingCourseId.value = row.course_id
  courseForm.course_name = row.course_name
  courseForm.category_id = row.category_id
  courseForm.course_difficulty = row.course_difficulty
  courseForm.duration_minutes = row.duration_minutes
  courseForm.max_participants = row.max_participants
  courseForm.schedule_info = row.schedule_info || ''
  courseForm.description = row.description || ''
  courseForm.status = row.status ?? 1
  courseDialogVisible.value = true
}

async function submitCourse() {
  await courseFormRef.value?.validate()

  if (editingCourseId.value) {
    await updateCourseApi(editingCourseId.value, courseForm)
    ElMessage.success('课程已更新')
  } else {
    await createCourseApi(courseForm)
    ElMessage.success('课程已创建')
  }

  courseDialogVisible.value = false
  fetchCourses()
}

async function removeCourse(row: Course) {
  await ElMessageBox.confirm(`确认删除课程「${row.course_name}」吗?`, '删除确认', {
    type: 'warning',
  })
  await deleteCourseApi(row.course_id)
  ElMessage.success('课程已删除')
  fetchCourses()
}

function openCreateCategory() {
  editingCategoryId.value = null
  resetCategoryForm()
  categoryDialogVisible.value = true
}

function openEditCategory(row: CourseCategory) {
  editingCategoryId.value = row.category_id
  categoryForm.category_name = row.category_name
  categoryForm.category_url = row.category_url || ''
  categoryForm.description = row.description || ''
  categoryForm.status = row.status ?? 1
  categoryDialogVisible.value = true
}

async function submitCategory() {
  await categoryFormRef.value?.validate()

  if (editingCategoryId.value) {
    await updateCourseCategoryApi(editingCategoryId.value, categoryForm)
    ElMessage.success('分类已更新')
  } else {
    await createCourseCategoryApi(categoryForm)
    ElMessage.success('分类已创建')
  }

  categoryDialogVisible.value = false
  await fetchCategories()
}

async function removeCategory(row: CourseCategory) {
  await ElMessageBox.confirm(`确认删除分类「${row.category_name}」吗?`, '删除确认', {
    type: 'warning',
  })
  await deleteCourseCategoryApi(row.category_id)
  ElMessage.success('分类已删除')
  await fetchCategories()
}

onMounted(async () => {
  await fetchCategories()
  await fetchCourses()
})
</script>

<template>
  <section class="page-panel">
    <el-tabs v-model="activeTab">
      <el-tab-pane label="课程列表" name="courses">
        <div class="toolbar">
          <el-input v-model="query.keyword" clearable placeholder="搜索课程名称" @keyup.enter="search" />
          <el-select v-model="query.category_id" clearable placeholder="课程分类">
            <el-option
              v-for="category in categories"
              :key="category.category_id"
              :label="category.category_name"
              :value="category.category_id"
            />
          </el-select>
          <el-select v-model="query.course_difficulty" clearable placeholder="难度">
            <el-option v-for="level in 5" :key="level" :label="`难度 ${level}`" :value="level" />
          </el-select>
          <el-select v-model="query.status" clearable placeholder="状态">
            <el-option label="启用" :value="1" />
            <el-option label="禁用" :value="0" />
          </el-select>
          <el-button :icon="Search" type="primary" @click="search">查询</el-button>
          <el-button :icon="Refresh" @click="resetSearch">重置</el-button>
          <el-button :icon="Plus" type="success" @click="openCreateCourse">新增课程</el-button>
        </div>

        <el-table v-loading="loading" :data="courses" border>
          <el-table-column prop="course_id" label="ID" width="90" />
          <el-table-column prop="course_name" label="课程名称" min-width="160" />
          <el-table-column label="分类" min-width="130">
            <template #default="{ row }">
              {{ categoryName(row.category_id) }}
            </template>
          </el-table-column>
          <el-table-column prop="course_difficulty" label="难度" width="90" />
          <el-table-column prop="duration_minutes" label="时长" width="100" />
          <el-table-column prop="max_participants" label="人数上限" width="110" />
          <el-table-column label="状态" width="90">
            <template #default="{ row }">
              <el-tag :type="row.status === 1 ? 'success' : 'info'">
                {{ row.status === 1 ? '启用' : '禁用' }}
              </el-tag>
            </template>
          </el-table-column>
          <el-table-column label="操作" width="150" fixed="right">
            <template #default="{ row }">
              <el-button link type="primary" @click="openEditCourse(row)">编辑</el-button>
              <el-button link type="danger" @click="removeCourse(row)">删除</el-button>
            </template>
          </el-table-column>
        </el-table>

        <div class="pagination">
          <el-pagination
            v-model:current-page="query.page"
            v-model:page-size="query.page_size"
            :page-sizes="[10, 20, 50]"
            :total="total"
            layout="total, sizes, prev, pager, next"
            @size-change="fetchCourses"
            @current-change="fetchCourses"
          />
        </div>
      </el-tab-pane>

      <el-tab-pane label="课程分类" name="categories">
        <div class="table-actions">
          <el-button :icon="Plus" type="primary" @click="openCreateCategory">新增分类</el-button>
        </div>
        <el-table :data="categories" border>
          <el-table-column prop="category_id" label="ID" width="90" />
          <el-table-column prop="category_name" label="分类名称" min-width="160" />
          <el-table-column prop="description" label="描述" min-width="220" show-overflow-tooltip />
          <el-table-column label="状态" width="90">
            <template #default="{ row }">
              <el-tag :type="row.status === 1 ? 'success' : 'info'">
                {{ row.status === 1 ? '启用' : '禁用' }}
              </el-tag>
            </template>
          </el-table-column>
          <el-table-column label="操作" width="150" fixed="right">
            <template #default="{ row }">
              <el-button link type="primary" @click="openEditCategory(row)">编辑</el-button>
              <el-button link type="danger" @click="removeCategory(row)">删除</el-button>
            </template>
          </el-table-column>
        </el-table>
      </el-tab-pane>
    </el-tabs>

    <el-dialog v-model="courseDialogVisible" :title="editingCourseId ? '编辑课程' : '新增课程'" width="620px">
      <el-form ref="courseFormRef" :model="courseForm" label-width="100px">
        <el-form-item label="课程名称" prop="course_name" required>
          <el-input v-model="courseForm.course_name" />
        </el-form-item>
        <el-form-item label="课程分类" prop="category_id" required>
          <el-select v-model="courseForm.category_id" class="full-width">
            <el-option
              v-for="category in categories"
              :key="category.category_id"
              :label="category.category_name"
              :value="category.category_id"
            />
          </el-select>
        </el-form-item>
        <el-form-item label="课程难度">
          <el-input-number v-model="courseForm.course_difficulty" :min="1" :max="5" class="full-width" />
        </el-form-item>
        <el-form-item label="课程时长">
          <el-input-number v-model="courseForm.duration_minutes" :min="1" class="full-width" />
        </el-form-item>
        <el-form-item label="人数上限">
          <el-input-number v-model="courseForm.max_participants" :min="1" class="full-width" />
        </el-form-item>
        <el-form-item label="排期信息">
          <el-input v-model="courseForm.schedule_info" type="textarea" :rows="3" />
        </el-form-item>
        <el-form-item label="课程描述">
          <el-input v-model="courseForm.description" type="textarea" :rows="4" />
        </el-form-item>
        <el-form-item label="状态">
          <el-radio-group v-model="courseForm.status">
            <el-radio-button :label="1">启用</el-radio-button>
            <el-radio-button :label="0">禁用</el-radio-button>
          </el-radio-group>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="courseDialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitCourse">保存</el-button>
      </template>
    </el-dialog>

    <el-dialog v-model="categoryDialogVisible" :title="editingCategoryId ? '编辑分类' : '新增分类'" width="520px">
      <el-form ref="categoryFormRef" :model="categoryForm" label-width="90px">
        <el-form-item label="分类名称" prop="category_name" required>
          <el-input v-model="categoryForm.category_name" />
        </el-form-item>
        <el-form-item label="图片 URL">
          <el-input v-model="categoryForm.category_url" />
        </el-form-item>
        <el-form-item label="描述">
          <el-input v-model="categoryForm.description" type="textarea" :rows="3" />
        </el-form-item>
        <el-form-item label="状态">
          <el-radio-group v-model="categoryForm.status">
            <el-radio-button :label="1">启用</el-radio-button>
            <el-radio-button :label="0">禁用</el-radio-button>
          </el-radio-group>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="categoryDialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitCategory">保存</el-button>
      </template>
    </el-dialog>
  </section>
</template>

<style scoped>
.page-panel {
  display: grid;
  gap: 18px;
}

.toolbar {
  display: grid;
  grid-template-columns: minmax(220px, 1fr) 150px 120px 120px auto auto auto;
  gap: 12px;
  align-items: center;
  margin-bottom: 16px;
}

.table-actions {
  display: flex;
  justify-content: flex-end;
  margin-bottom: 16px;
}

.pagination {
  display: flex;
  justify-content: flex-end;
  margin-top: 16px;
}

.full-width {
  width: 100%;
}

@media (max-width: 1000px) {
  .toolbar {
    grid-template-columns: 1fr 1fr;
  }
}
</style>

十四、步骤 10:创建动作库管理页面

相对路径

text 复制代码
frontend/src/views/admin/ActionManageView.vue

完整代码

vue 复制代码
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'

import {
  createActionApi,
  createActionCategoryApi,
  deleteActionApi,
  deleteActionCategoryApi,
  listActionCategoriesApi,
  listActionsApi,
  updateActionApi,
  updateActionCategoryApi,
  type ActionCategory,
  type ActionCategoryForm,
  type ActionForm,
  type ActionItem,
} from '../../api/action'

const activeTab = ref('actions')
const loading = ref(false)
const actions = ref<ActionItem[]>([])
const categories = ref<ActionCategory[]>([])
const total = ref(0)
const actionDialogVisible = ref(false)
const categoryDialogVisible = ref(false)
const editingActionId = ref<number | null>(null)
const editingCategoryId = ref<number | null>(null)
const actionFormRef = ref<FormInstance>()
const categoryFormRef = ref<FormInstance>()

const query = reactive({
  page: 1,
  page_size: 10,
  keyword: '',
  category_id: undefined as number | undefined,
  action_difficulty: undefined as number | undefined,
  applicable_store_type: undefined as number | undefined,
})

const actionForm = reactive<ActionForm>({
  action_name: '',
  category_id: 0,
  action_difficulty: null,
  action_image_url: '',
  action_steps: '',
  attention_points: '',
  applicable_equipment: '',
  applicable_store_type: null,
})

const categoryForm = reactive<ActionCategoryForm>({
  category_name: '',
  category_image_url: '',
})

function categoryName(categoryId: number) {
  return categories.value.find((item) => item.category_id === categoryId)?.category_name || `分类 ${categoryId}`
}

function storeTypeName(type: number | null) {
  if (type === 1) return '铁馆'
  if (type === 2) return '商业私教馆'
  return '通用'
}

function resetActionForm() {
  actionForm.action_name = ''
  actionForm.category_id = categories.value[0]?.category_id || 0
  actionForm.action_difficulty = null
  actionForm.action_image_url = ''
  actionForm.action_steps = ''
  actionForm.attention_points = ''
  actionForm.applicable_equipment = ''
  actionForm.applicable_store_type = null
}

function resetCategoryForm() {
  categoryForm.category_name = ''
  categoryForm.category_image_url = ''
}

async function fetchCategories() {
  categories.value = await listActionCategoriesApi()
  if (!actionForm.category_id) {
    actionForm.category_id = categories.value[0]?.category_id || 0
  }
}

async function fetchActions() {
  loading.value = true
  try {
    const result = await listActionsApi({
      page: query.page,
      page_size: query.page_size,
      keyword: query.keyword || undefined,
      category_id: query.category_id,
      action_difficulty: query.action_difficulty,
      applicable_store_type: query.applicable_store_type,
    })
    actions.value = result.items
    total.value = result.total
  } finally {
    loading.value = false
  }
}

function search() {
  query.page = 1
  fetchActions()
}

function resetSearch() {
  query.page = 1
  query.keyword = ''
  query.category_id = undefined
  query.action_difficulty = undefined
  query.applicable_store_type = undefined
  fetchActions()
}

function openCreateAction() {
  editingActionId.value = null
  resetActionForm()
  actionDialogVisible.value = true
}

function openEditAction(row: ActionItem) {
  editingActionId.value = row.action_id
  actionForm.action_name = row.action_name
  actionForm.category_id = row.category_id
  actionForm.action_difficulty = row.action_difficulty
  actionForm.action_image_url = row.action_image_url || ''
  actionForm.action_steps = row.action_steps || ''
  actionForm.attention_points = row.attention_points || ''
  actionForm.applicable_equipment = row.applicable_equipment || ''
  actionForm.applicable_store_type = row.applicable_store_type
  actionDialogVisible.value = true
}

async function submitAction() {
  await actionFormRef.value?.validate()

  if (editingActionId.value) {
    await updateActionApi(editingActionId.value, actionForm)
    ElMessage.success('动作已更新')
  } else {
    await createActionApi(actionForm)
    ElMessage.success('动作已创建')
  }

  actionDialogVisible.value = false
  fetchActions()
}

async function removeAction(row: ActionItem) {
  await ElMessageBox.confirm(`确认删除动作「${row.action_name}」吗?`, '删除确认', {
    type: 'warning',
  })
  await deleteActionApi(row.action_id)
  ElMessage.success('动作已删除')
  fetchActions()
}

function openCreateCategory() {
  editingCategoryId.value = null
  resetCategoryForm()
  categoryDialogVisible.value = true
}

function openEditCategory(row: ActionCategory) {
  editingCategoryId.value = row.category_id
  categoryForm.category_name = row.category_name
  categoryForm.category_image_url = row.category_image_url || ''
  categoryDialogVisible.value = true
}

async function submitCategory() {
  await categoryFormRef.value?.validate()

  if (editingCategoryId.value) {
    await updateActionCategoryApi(editingCategoryId.value, categoryForm)
    ElMessage.success('分类已更新')
  } else {
    await createActionCategoryApi(categoryForm)
    ElMessage.success('分类已创建')
  }

  categoryDialogVisible.value = false
  await fetchCategories()
}

async function removeCategory(row: ActionCategory) {
  await ElMessageBox.confirm(`确认删除分类「${row.category_name}」吗?`, '删除确认', {
    type: 'warning',
  })
  await deleteActionCategoryApi(row.category_id)
  ElMessage.success('分类已删除')
  await fetchCategories()
}

onMounted(async () => {
  await fetchCategories()
  await fetchActions()
})
</script>

<template>
  <section class="page-panel">
    <el-tabs v-model="activeTab">
      <el-tab-pane label="动作列表" name="actions">
        <div class="toolbar">
          <el-input v-model="query.keyword" clearable placeholder="搜索动作名称" @keyup.enter="search" />
          <el-select v-model="query.category_id" clearable placeholder="动作分类">
            <el-option
              v-for="category in categories"
              :key="category.category_id"
              :label="category.category_name"
              :value="category.category_id"
            />
          </el-select>
          <el-select v-model="query.action_difficulty" clearable placeholder="难度">
            <el-option v-for="level in 5" :key="level" :label="`难度 ${level}`" :value="level" />
          </el-select>
          <el-select v-model="query.applicable_store_type" clearable placeholder="适用门店">
            <el-option label="铁馆" :value="1" />
            <el-option label="商业私教馆" :value="2" />
          </el-select>
          <el-button :icon="Search" type="primary" @click="search">查询</el-button>
          <el-button :icon="Refresh" @click="resetSearch">重置</el-button>
          <el-button :icon="Plus" type="success" @click="openCreateAction">新增动作</el-button>
        </div>

        <el-table v-loading="loading" :data="actions" border>
          <el-table-column prop="action_id" label="ID" width="90" />
          <el-table-column prop="action_name" label="动作名称" min-width="160" />
          <el-table-column label="分类" min-width="130">
            <template #default="{ row }">
              {{ categoryName(row.category_id) }}
            </template>
          </el-table-column>
          <el-table-column prop="action_difficulty" label="难度" width="90" />
          <el-table-column label="适用门店" width="130">
            <template #default="{ row }">
              {{ storeTypeName(row.applicable_store_type) }}
            </template>
          </el-table-column>
          <el-table-column prop="applicable_equipment" label="适用器械" min-width="160" show-overflow-tooltip />
          <el-table-column label="操作" width="150" fixed="right">
            <template #default="{ row }">
              <el-button link type="primary" @click="openEditAction(row)">编辑</el-button>
              <el-button link type="danger" @click="removeAction(row)">删除</el-button>
            </template>
          </el-table-column>
        </el-table>

        <div class="pagination">
          <el-pagination
            v-model:current-page="query.page"
            v-model:page-size="query.page_size"
            :page-sizes="[10, 20, 50]"
            :total="total"
            layout="total, sizes, prev, pager, next"
            @size-change="fetchActions"
            @current-change="fetchActions"
          />
        </div>
      </el-tab-pane>

      <el-tab-pane label="动作分类" name="categories">
        <div class="table-actions">
          <el-button :icon="Plus" type="primary" @click="openCreateCategory">新增分类</el-button>
        </div>
        <el-table :data="categories" border>
          <el-table-column prop="category_id" label="ID" width="90" />
          <el-table-column prop="category_name" label="分类名称" min-width="160" />
          <el-table-column prop="category_image_url" label="图片 URL" min-width="220" show-overflow-tooltip />
          <el-table-column label="操作" width="150" fixed="right">
            <template #default="{ row }">
              <el-button link type="primary" @click="openEditCategory(row)">编辑</el-button>
              <el-button link type="danger" @click="removeCategory(row)">删除</el-button>
            </template>
          </el-table-column>
        </el-table>
      </el-tab-pane>
    </el-tabs>

    <el-dialog v-model="actionDialogVisible" :title="editingActionId ? '编辑动作' : '新增动作'" width="660px">
      <el-form ref="actionFormRef" :model="actionForm" label-width="110px">
        <el-form-item label="动作名称" prop="action_name" required>
          <el-input v-model="actionForm.action_name" />
        </el-form-item>
        <el-form-item label="动作分类" prop="category_id" required>
          <el-select v-model="actionForm.category_id" class="full-width">
            <el-option
              v-for="category in categories"
              :key="category.category_id"
              :label="category.category_name"
              :value="category.category_id"
            />
          </el-select>
        </el-form-item>
        <el-form-item label="动作难度">
          <el-input-number v-model="actionForm.action_difficulty" :min="1" :max="5" class="full-width" />
        </el-form-item>
        <el-form-item label="适用门店">
          <el-select v-model="actionForm.applicable_store_type" clearable class="full-width">
            <el-option label="铁馆" :value="1" />
            <el-option label="商业私教馆" :value="2" />
          </el-select>
        </el-form-item>
        <el-form-item label="适用器械">
          <el-input v-model="actionForm.applicable_equipment" />
        </el-form-item>
        <el-form-item label="封面 URL">
          <el-input v-model="actionForm.action_image_url" />
        </el-form-item>
        <el-form-item label="动作步骤">
          <el-input v-model="actionForm.action_steps" type="textarea" :rows="4" />
        </el-form-item>
        <el-form-item label="注意事项">
          <el-input v-model="actionForm.attention_points" type="textarea" :rows="4" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="actionDialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitAction">保存</el-button>
      </template>
    </el-dialog>

    <el-dialog v-model="categoryDialogVisible" :title="editingCategoryId ? '编辑分类' : '新增分类'" width="520px">
      <el-form ref="categoryFormRef" :model="categoryForm" label-width="90px">
        <el-form-item label="分类名称" prop="category_name" required>
          <el-input v-model="categoryForm.category_name" />
        </el-form-item>
        <el-form-item label="图片 URL">
          <el-input v-model="categoryForm.category_image_url" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="categoryDialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitCategory">保存</el-button>
      </template>
    </el-dialog>
  </section>
</template>

<style scoped>
.page-panel {
  display: grid;
  gap: 18px;
}

.toolbar {
  display: grid;
  grid-template-columns: minmax(220px, 1fr) 150px 120px 140px auto auto auto;
  gap: 12px;
  align-items: center;
  margin-bottom: 16px;
}

.table-actions {
  display: flex;
  justify-content: flex-end;
  margin-bottom: 16px;
}

.pagination {
  display: flex;
  justify-content: flex-end;
  margin-top: 16px;
}

.full-width {
  width: 100%;
}

@media (max-width: 1000px) {
  .toolbar {
    grid-template-columns: 1fr 1fr;
  }
}
</style>

十五、步骤 11:创建活动管理页面

相对路径

text 复制代码
frontend/src/views/admin/EventManageView.vue

完整代码

vue 复制代码
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'

import {
  createEventApi,
  deleteEventApi,
  listEventsApi,
  updateEventApi,
  type ActivityEvent,
  type ActivityEventForm,
} from '../../api/content'

const loading = ref(false)
const events = ref<ActivityEvent[]>([])
const total = ref(0)
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref<FormInstance>()

const query = reactive({
  page: 1,
  page_size: 10,
  keyword: '',
  status: '',
  date_from: '',
  date_to: '',
})

const form = reactive<ActivityEventForm>({
  title: '',
  event_date: '',
  location: '',
  status: '',
  description: '',
  tags: '',
  prize: '',
  scale: '',
})

function resetForm() {
  form.title = ''
  form.event_date = ''
  form.location = ''
  form.status = ''
  form.description = ''
  form.tags = ''
  form.prize = ''
  form.scale = ''
}

async function fetchEvents() {
  loading.value = true
  try {
    const result = await listEventsApi({
      page: query.page,
      page_size: query.page_size,
      keyword: query.keyword || undefined,
      status: query.status || undefined,
      date_from: query.date_from || undefined,
      date_to: query.date_to || undefined,
    })
    events.value = result.items
    total.value = result.total
  } finally {
    loading.value = false
  }
}

function search() {
  query.page = 1
  fetchEvents()
}

function resetSearch() {
  query.page = 1
  query.keyword = ''
  query.status = ''
  query.date_from = ''
  query.date_to = ''
  fetchEvents()
}

function openCreate() {
  editingId.value = null
  resetForm()
  dialogVisible.value = true
}

function openEdit(row: ActivityEvent) {
  editingId.value = row.event_id
  form.title = row.title
  form.event_date = row.event_date
  form.location = row.location
  form.status = row.status
  form.description = row.description
  form.tags = row.tags || ''
  form.prize = row.prize || ''
  form.scale = row.scale || ''
  dialogVisible.value = true
}

async function submitForm() {
  await formRef.value?.validate()

  const payload: ActivityEventForm = {
    title: form.title,
    event_date: form.event_date,
    location: form.location,
    status: form.status,
    description: form.description,
    tags: form.tags || null,
    prize: form.prize || null,
    scale: form.scale || null,
  }

  if (editingId.value) {
    await updateEventApi(editingId.value, payload)
    ElMessage.success('活动已更新')
  } else {
    await createEventApi(payload)
    ElMessage.success('活动已创建')
  }

  dialogVisible.value = false
  fetchEvents()
}

async function removeEvent(row: ActivityEvent) {
  await ElMessageBox.confirm(`确认删除活动「${row.title}」吗?`, '删除确认', {
    type: 'warning',
  })
  await deleteEventApi(row.event_id)
  ElMessage.success('活动已删除')
  fetchEvents()
}

onMounted(fetchEvents)
</script>

<template>
  <section class="page-panel">
    <div class="toolbar">
      <el-input v-model="query.keyword" clearable placeholder="搜索活动标题、地点" @keyup.enter="search" />
      <el-input v-model="query.status" clearable placeholder="状态" />
      <el-date-picker v-model="query.date_from" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" />
      <el-date-picker v-model="query.date_to" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" />
      <el-button :icon="Search" type="primary" @click="search">查询</el-button>
      <el-button :icon="Refresh" @click="resetSearch">重置</el-button>
      <el-button :icon="Plus" type="success" @click="openCreate">新增活动</el-button>
    </div>

    <el-table v-loading="loading" :data="events" border>
      <el-table-column prop="event_id" label="ID" width="90" />
      <el-table-column prop="title" label="活动标题" min-width="180" />
      <el-table-column prop="event_date" label="日期" width="120" />
      <el-table-column prop="location" label="地点" min-width="160" />
      <el-table-column prop="status" label="状态" width="110" />
      <el-table-column prop="tags" label="标签" min-width="140" show-overflow-tooltip />
      <el-table-column prop="scale" label="规模" width="120" />
      <el-table-column label="操作" width="150" fixed="right">
        <template #default="{ row }">
          <el-button link type="primary" @click="openEdit(row)">编辑</el-button>
          <el-button link type="danger" @click="removeEvent(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>

    <div class="pagination">
      <el-pagination
        v-model:current-page="query.page"
        v-model:page-size="query.page_size"
        :page-sizes="[10, 20, 50]"
        :total="total"
        layout="total, sizes, prev, pager, next"
        @size-change="fetchEvents"
        @current-change="fetchEvents"
      />
    </div>

    <el-dialog v-model="dialogVisible" :title="editingId ? '编辑活动' : '新增活动'" width="620px">
      <el-form ref="formRef" :model="form" label-width="90px">
        <el-form-item label="活动标题" prop="title" required>
          <el-input v-model="form.title" />
        </el-form-item>
        <el-form-item label="活动日期" prop="event_date" required>
          <el-date-picker v-model="form.event_date" type="date" value-format="YYYY-MM-DD" class="full-width" />
        </el-form-item>
        <el-form-item label="活动地点" prop="location" required>
          <el-input v-model="form.location" />
        </el-form-item>
        <el-form-item label="状态" prop="status" required>
          <el-input v-model="form.status" placeholder="报名中 / 即将开始 / 预告" />
        </el-form-item>
        <el-form-item label="标签">
          <el-input v-model="form.tags" placeholder="多个标签用逗号分隔" />
        </el-form-item>
        <el-form-item label="奖品">
          <el-input v-model="form.prize" />
        </el-form-item>
        <el-form-item label="规模">
          <el-input v-model="form.scale" />
        </el-form-item>
        <el-form-item label="描述" prop="description" required>
          <el-input v-model="form.description" type="textarea" :rows="5" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitForm">保存</el-button>
      </template>
    </el-dialog>
  </section>
</template>

<style scoped>
.page-panel {
  display: grid;
  gap: 18px;
}

.toolbar {
  display: grid;
  grid-template-columns: minmax(220px, 1fr) 140px 150px 150px auto auto auto;
  gap: 12px;
  align-items: center;
}

.pagination {
  display: flex;
  justify-content: flex-end;
}

.full-width {
  width: 100%;
}

@media (max-width: 1000px) {
  .toolbar {
    grid-template-columns: 1fr 1fr;
  }
}
</style>

十六、步骤 12:创建 Slogan 管理页面

相对路径

text 复制代码
frontend/src/views/admin/SloganManageView.vue

完整代码

vue 复制代码
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'

import {
  createSloganApi,
  deleteSloganApi,
  listSlogansApi,
  updateSloganApi,
  type Slogan,
  type SloganForm,
} from '../../api/content'

const loading = ref(false)
const slogans = ref<Slogan[]>([])
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref<FormInstance>()
const statusFilter = ref<number | undefined>()

const form = reactive<SloganForm>({
  slogan_name: '',
  slogan_content: '',
  slogan_image_url: '',
  status: 1,
})

function resetForm() {
  form.slogan_name = ''
  form.slogan_content = ''
  form.slogan_image_url = ''
  form.status = 1
}

async function fetchSlogans() {
  loading.value = true
  try {
    slogans.value = await listSlogansApi(statusFilter.value)
  } finally {
    loading.value = false
  }
}

function resetSearch() {
  statusFilter.value = undefined
  fetchSlogans()
}

function openCreate() {
  editingId.value = null
  resetForm()
  dialogVisible.value = true
}

function openEdit(row: Slogan) {
  editingId.value = row.slogan_id
  form.slogan_name = row.slogan_name
  form.slogan_content = row.slogan_content
  form.slogan_image_url = row.slogan_image_url || ''
  form.status = row.status ?? 1
  dialogVisible.value = true
}

async function submitForm() {
  await formRef.value?.validate()

  const payload: SloganForm = {
    slogan_name: form.slogan_name,
    slogan_content: form.slogan_content,
    slogan_image_url: form.slogan_image_url || null,
    status: form.status,
  }

  if (editingId.value) {
    await updateSloganApi(editingId.value, payload)
    ElMessage.success('Slogan 已更新')
  } else {
    await createSloganApi(payload)
    ElMessage.success('Slogan 已创建')
  }

  dialogVisible.value = false
  fetchSlogans()
}

async function removeSlogan(row: Slogan) {
  await ElMessageBox.confirm(`确认删除 Slogan「${row.slogan_name}」吗?`, '删除确认', {
    type: 'warning',
  })
  await deleteSloganApi(row.slogan_id)
  ElMessage.success('Slogan 已删除')
  fetchSlogans()
}

onMounted(fetchSlogans)
</script>

<template>
  <section class="page-panel">
    <div class="toolbar">
      <el-select v-model="statusFilter" clearable placeholder="状态">
        <el-option label="启用" :value="1" />
        <el-option label="禁用" :value="0" />
      </el-select>
      <el-button :icon="Search" type="primary" @click="fetchSlogans">查询</el-button>
      <el-button :icon="Refresh" @click="resetSearch">重置</el-button>
      <el-button :icon="Plus" type="success" @click="openCreate">新增 Slogan</el-button>
    </div>

    <el-table v-loading="loading" :data="slogans" border>
      <el-table-column prop="slogan_id" label="ID" width="90" />
      <el-table-column prop="slogan_name" label="名称" min-width="150" />
      <el-table-column prop="slogan_content" label="内容" min-width="260" show-overflow-tooltip />
      <el-table-column prop="slogan_image_url" label="图片 URL" min-width="220" show-overflow-tooltip />
      <el-table-column label="状态" width="90">
        <template #default="{ row }">
          <el-tag :type="row.status === 1 ? 'success' : 'info'">
            {{ row.status === 1 ? '启用' : '禁用' }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="操作" width="150" fixed="right">
        <template #default="{ row }">
          <el-button link type="primary" @click="openEdit(row)">编辑</el-button>
          <el-button link type="danger" @click="removeSlogan(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>

    <el-dialog v-model="dialogVisible" :title="editingId ? '编辑 Slogan' : '新增 Slogan'" width="560px">
      <el-form ref="formRef" :model="form" label-width="90px">
        <el-form-item label="名称" prop="slogan_name" required>
          <el-input v-model="form.slogan_name" />
        </el-form-item>
        <el-form-item label="内容" prop="slogan_content" required>
          <el-input v-model="form.slogan_content" type="textarea" :rows="3" />
        </el-form-item>
        <el-form-item label="图片 URL">
          <el-input v-model="form.slogan_image_url" />
        </el-form-item>
        <el-form-item label="状态">
          <el-radio-group v-model="form.status">
            <el-radio-button :label="1">启用</el-radio-button>
            <el-radio-button :label="0">禁用</el-radio-button>
          </el-radio-group>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitForm">保存</el-button>
      </template>
    </el-dialog>
  </section>
</template>

<style scoped>
.page-panel {
  display: grid;
  gap: 18px;
}

.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: center;
}
</style>

十七、步骤 13:创建角色管理页面

相对路径

text 复制代码
frontend/src/views/admin/RoleManageView.vue

完整代码

vue 复制代码
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'

import {
  createRoleApi,
  deleteRoleApi,
  listRolesApi,
  updateRoleApi,
  type Role,
  type RoleForm,
} from '../../api/system'

const loading = ref(false)
const roles = ref<Role[]>([])
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref<FormInstance>()

const form = reactive<RoleForm>({
  role_code: '',
  role_name: '',
})

function resetForm() {
  form.role_code = ''
  form.role_name = ''
}

async function fetchRoles() {
  loading.value = true
  try {
    roles.value = await listRolesApi()
  } finally {
    loading.value = false
  }
}

function openCreate() {
  editingId.value = null
  resetForm()
  dialogVisible.value = true
}

function openEdit(row: Role) {
  editingId.value = row.id
  form.role_code = row.role_code
  form.role_name = row.role_name
  dialogVisible.value = true
}

async function submitForm() {
  await formRef.value?.validate()

  if (editingId.value) {
    await updateRoleApi(editingId.value, form)
    ElMessage.success('角色已更新')
  } else {
    await createRoleApi(form)
    ElMessage.success('角色已创建')
  }

  dialogVisible.value = false
  fetchRoles()
}

async function removeRole(row: Role) {
  await ElMessageBox.confirm(`确认删除角色「${row.role_name}」吗?`, '删除确认', {
    type: 'warning',
  })
  await deleteRoleApi(row.id)
  ElMessage.success('角色已删除')
  fetchRoles()
}

onMounted(fetchRoles)
</script>

<template>
  <section class="page-panel">
    <div class="table-actions">
      <el-alert
        show-icon
        type="info"
        title="当前阶段只维护角色基础信息;权限分配需要后端补充角色权限查询接口后再接入。"
      />
      <el-button :icon="Plus" type="primary" @click="openCreate">新增角色</el-button>
    </div>

    <el-table v-loading="loading" :data="roles" border>
      <el-table-column prop="id" label="ID" width="90" />
      <el-table-column prop="role_code" label="角色代码" min-width="160" />
      <el-table-column prop="role_name" label="角色名称" min-width="160" />
      <el-table-column label="操作" width="150" fixed="right">
        <template #default="{ row }">
          <el-button link type="primary" @click="openEdit(row)">编辑</el-button>
          <el-button link type="danger" @click="removeRole(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>

    <el-dialog v-model="dialogVisible" :title="editingId ? '编辑角色' : '新增角色'" width="480px">
      <el-form ref="formRef" :model="form" label-width="90px">
        <el-form-item label="角色代码" prop="role_code" required>
          <el-input v-model="form.role_code" />
        </el-form-item>
        <el-form-item label="角色名称" prop="role_name" required>
          <el-input v-model="form.role_name" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitForm">保存</el-button>
      </template>
    </el-dialog>
  </section>
</template>

<style scoped>
.page-panel {
  display: grid;
  gap: 18px;
}

.table-actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
}

@media (max-width: 760px) {
  .table-actions {
    align-items: stretch;
    flex-direction: column;
  }
}
</style>

十八、步骤 14:创建权限管理页面

相对路径

text 复制代码
frontend/src/views/admin/PermissionManageView.vue

完整代码

vue 复制代码
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Search } from '@element-plus/icons-vue'

import { listPermissionsApi, type Permission } from '../../api/system'

const loading = ref(false)
const keyword = ref('')
const permissions = ref<Permission[]>([])

const filteredPermissions = computed(() => {
  const value = keyword.value.trim().toLowerCase()

  if (!value) {
    return permissions.value
  }

  return permissions.value.filter((item) => {
    return (
      item.permission_code.toLowerCase().includes(value) ||
      item.permission_name.toLowerCase().includes(value) ||
      (item.menu_path || '').toLowerCase().includes(value)
    )
  })
})

async function fetchPermissions() {
  loading.value = true
  try {
    permissions.value = await listPermissionsApi()
  } finally {
    loading.value = false
  }
}

onMounted(fetchPermissions)
</script>

<template>
  <section class="page-panel">
    <div class="toolbar">
      <el-input v-model="keyword" clearable placeholder="搜索权限代码、名称、菜单路径">
        <template #prefix>
          <el-icon>
            <Search />
          </el-icon>
        </template>
      </el-input>
      <el-button @click="fetchPermissions">刷新</el-button>
    </div>

    <el-table v-loading="loading" :data="filteredPermissions" border>
      <el-table-column prop="id" label="ID" width="90" />
      <el-table-column prop="permission_code" label="权限代码" min-width="180" />
      <el-table-column prop="permission_name" label="权限名称" min-width="160" />
      <el-table-column prop="menu_path" label="菜单路径" min-width="180">
        <template #default="{ row }">
          <el-tag v-if="row.menu_path" type="success">{{ row.menu_path }}</el-tag>
          <span v-else class="muted">非菜单权限</span>
        </template>
      </el-table-column>
    </el-table>
  </section>
</template>

<style scoped>
.page-panel {
  display: grid;
  gap: 18px;
}

.toolbar {
  display: flex;
  gap: 12px;
  align-items: center;
}

.toolbar .el-input {
  max-width: 420px;
}

.muted {
  color: #9ca3af;
}
</style>

十九、步骤 15:修改路由,把占位页替换为真实页面

相对路径

text 复制代码
frontend/src/router/index.ts

完整代码

typescript 复制代码
import { ElMessage } from 'element-plus'
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'

import { useAuthStore } from '../stores/auth'

const routes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'login',
    component: () => import('../views/LoginView.vue'),
    meta: {
      title: '登录',
      guestOnly: true,
    },
  },
  {
    path: '/',
    component: () => import('../layouts/BasicLayout.vue'),
    meta: {
      requiresAuth: true,
    },
    children: [
      {
        path: '',
        name: 'home',
        component: () => import('../views/HomeView.vue'),
        meta: {
          title: '工作台',
          requiresAuth: true,
        },
      },
      {
        path: 'users',
        name: 'users',
        component: () => import('../views/admin/UserManageView.vue'),
        meta: {
          title: '用户管理',
          requiresAuth: true,
          permission: 'user:list',
        },
      },
      {
        path: 'stores',
        name: 'stores',
        component: () => import('../views/admin/StoreManageView.vue'),
        meta: {
          title: '门店管理',
          requiresAuth: true,
          permission: 'store:list',
        },
      },
      {
        path: 'courses',
        name: 'courses',
        component: () => import('../views/admin/CourseManageView.vue'),
        meta: {
          title: '课程管理',
          requiresAuth: true,
          permission: 'course:list',
        },
      },
      {
        path: 'actions',
        name: 'actions',
        component: () => import('../views/admin/ActionManageView.vue'),
        meta: {
          title: '动作库',
          requiresAuth: true,
          permission: 'action:list',
        },
      },
      {
        path: 'content/events',
        name: 'content-events',
        component: () => import('../views/admin/EventManageView.vue'),
        meta: {
          title: '活动管理',
          requiresAuth: true,
          permission: 'event:list',
        },
      },
      {
        path: 'content/slogans',
        name: 'content-slogans',
        component: () => import('../views/admin/SloganManageView.vue'),
        meta: {
          title: 'Slogan 管理',
          requiresAuth: true,
          permission: 'slogan:list',
        },
      },
      {
        path: 'system/roles',
        name: 'system-roles',
        component: () => import('../views/admin/RoleManageView.vue'),
        meta: {
          title: '角色管理',
          requiresAuth: true,
          permission: 'role:list',
        },
      },
      {
        path: 'system/permissions',
        name: 'system-permissions',
        component: () => import('../views/admin/PermissionManageView.vue'),
        meta: {
          title: '权限管理',
          requiresAuth: true,
          permission: 'permission:list',
        },
      },
    ],
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'not-found',
    component: () => import('../views/NotFoundView.vue'),
    meta: {
      title: '页面不存在',
    },
  },
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
})

router.beforeEach(async (to) => {
  const authStore = useAuthStore()

  if (to.meta.guestOnly && authStore.isLoggedIn) {
    return { name: 'home' }
  }

  if (to.meta.requiresAuth && !authStore.isLoggedIn) {
    return {
      name: 'login',
      query: {
        redirect: to.fullPath,
      },
    }
  }

  if (to.meta.requiresAuth && authStore.isLoggedIn && !authStore.user) {
    try {
      await authStore.loadProfile()
    } catch (error) {
      return {
        name: 'login',
        query: {
          redirect: to.fullPath,
        },
      }
    }
  }

  if (to.meta.permission && !authStore.hasPermission(to.meta.permission)) {
    ElMessage.error('没有访问权限')
    return { name: 'home' }
  }

  return true
})

router.afterEach((to) => {
  const title = to.meta.title ? `${String(to.meta.title)} - JoyFit` : 'JoyFit'
  document.title = title
})

export default router

二十、步骤 16:启动和验证

后端启动

执行位置:

text 复制代码
FastAPI_Vue3_JoyFit/backend

命令:

bash 复制代码
source .venv/bin/activate
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000

前端启动

执行位置:

text 复制代码
FastAPI_Vue3_JoyFit/frontend

命令:

bash 复制代码
npm run dev

访问:

text 复制代码
http://localhost:5173/

二十一、本阶段验收清单

  • 登录后左侧菜单可以正常进入用户、门店、课程、动作库、活动、Slogan、角色、权限页面。
  • 每个页面刷新后不会丢失登录状态。
  • 用户页面可以新增用户、编辑用户、启停用户。
  • 门店页面可以新增省份,再新增绑定该省份的门店。
  • 课程页面可以先新增分类,再新增绑定该分类的课程。
  • 动作页面可以先新增分类,再新增绑定该分类的动作。
  • 活动页面可以新增、编辑、删除活动,日期格式传给后端是 YYYY-MM-DD。
  • Slogan 页面可以新增、编辑、删除标语。
  • 角色页面可以新增、编辑、删除角色。
  • 权限页面可以查看初始化权限,并能按代码、名称、路径搜索。
  • 没有权限的账号访问对应页面,会被路由守卫拦截回首页。
  • 浏览器控制台没有 Vue 编译错误和 TS 模块找不到错误。

二十二、常见问题

1. 页面提示没有访问权限

需要确认当前登录账号拥有对应权限,例如:

页面 需要权限
用户管理 user:list
门店管理 store:list
课程管理 course:list
动作库 action:list
活动管理 event:list
Slogan 管理 slogan:list
角色管理 role:list
权限管理 permission:list

若使用初始化管理员账号,该账号应拥有全部权限;若没有,需要检查后端 seed.py 是否已为 admin 角色绑定全部权限。

2. 删除分类失败

若某个课程分类、动作分类、省份下已存在关联数据,数据库外键会阻止删除。

处理方式:

  1. 先删除或修改依赖该分类、省份的数据。
  2. 再删除分类或省份。

3. 角色权限分配为什么本阶段没做

当前后端已提供更新角色权限的接口:

text 复制代码
PUT /api/system/roles/{role_id}/permissions

但未提供查询当前角色已有权限的接口。

若前端直接实现多选框,打开弹窗时无法回显已选中的权限,保存时可能覆盖原有权限。因此本阶段仅实现角色基础信息维护与权限列表查看。

后续建议补充:

text 复制代码
GET /api/system/roles/{role_id}/permissions

返回:

json 复制代码
{
  "code": 200,
  "message": "success",
  "data": [1, 2, 3]
}

在该接口补充完成后,再接入角色权限分配弹窗。

二十三、进入下一阶段的条件

满足以下条件后,进入第十二阶段:

必须满足:

  1. 用户、门店、课程、动作库、活动、Slogan、角色、权限八个管理页面均可从菜单进入并完成联调。
  2. 路由已不再指向 PlaceholderView.vue 占位页,各业务路由均绑定真实管理页面并受权限路由守卫控制。
  3. 各页面可完成对应的查询、新增、编辑、删除或启停操作,分类、省份等前置数据可先于业务数据维护。
  4. 没有权限的账号访问受限页面时会被路由守卫拦截回首页。
  5. 浏览器控制台无 Vue 编译错误与 TS 模块解析错误。

以上条件全部满足后,进入下一阶段:阶段 12:前端详情增强与角色权限分配。

二十四、问题记录与修复方案

后续在第十一阶段实践中遇到问题,可完整记录报错原文,便于检索与定位。

问题按以下两个步骤处理:

  1. 判断错误属于 API 封装、页面组件、路由守卫、权限校验、Element Plus 表单还是启动配置问题。
  2. 将修复办法追加到本文件底部。

问题追加格式

text 复制代码
### 问题 N:问题标题

#### 报错现象

按下方模板记录报错信息。

#### 原因分析

这里说明为什么会报错。

#### 修复办法

这里给出具体修复步骤。

#### 需要修改的文件

- 相对路径:xxx
- 修改说明:xxx
- 完整代码或补丁:xxx
相关推荐
子非鱼a2 小时前
【文件上传】一个普普通通的edu16
前端
美狐美颜SDK开放平台3 小时前
直播APP如何实现美白滤镜功能?视频美颜SDK技术开发方案详解
前端·数据库·人工智能·音视频·美颜sdk·直播美颜sdk·视频美颜sdk
pride.li4 小时前
第03章 Python 教程-基础语法:注释与变量
服务器·前端·后端·python·算法·计算机视觉
屏什么录4 小时前
电脑如何录屏幕视频和声音?从工具到代码,7 种方案帮你彻底搞定
前端·音视频开发
用户360055579004 小时前
Kestrel_VLLM_CUDA -09 · full-GPU decode:单 token 全设备
前端
kyriewen4 小时前
我上手了 Vite+ 1.0:Node 不用自己装,lint 却翻车了
前端·javascript·程序员
松就是我902984 小时前
你的 PPT 不缺内容,缺一个讲它的人
前端
松就是我902984 小时前
别再熬夜做 PPT 了!输入一个主题,AI 帮你把整套幻灯片做好,还能随便改字
前端
吴凡wudianmei4 小时前
每调一次模型都花钱?如何用工程手段把调用压到最少?
前端·后端·架构