阶段 11:前端后台管理页面
合辑 :FastAPI × Vue3 JoyFit 系统阶段开发计划 | 端 :前端 | 阶段 :11 / 12 | 前置阶段 :阶段 10:前端登录权限与布局
大家好,我是小马不起床。
目录
- 一、阶段目标
- 二、本阶段开发顺序
- 三、本阶段接口依赖
- 四、本阶段文件清单
- [五、步骤 1:创建系统权限 API](#五、步骤 1:创建系统权限 API)
- [六、步骤 2:创建用户 API](#六、步骤 2:创建用户 API)
- [七、步骤 3:创建门店 API](#七、步骤 3:创建门店 API)
- [八、步骤 4:创建课程 API](#八、步骤 4:创建课程 API)
- [九、步骤 5:创建动作库 API](#九、步骤 5:创建动作库 API)
- [十、步骤 6:创建内容 API](#十、步骤 6:创建内容 API)
- [十一、步骤 7:创建用户管理页面](#十一、步骤 7:创建用户管理页面)
- [十二、步骤 8:创建门店管理页面](#十二、步骤 8:创建门店管理页面)
- [十三、步骤 9:创建课程管理页面](#十三、步骤 9:创建课程管理页面)
- [十四、步骤 10:创建动作库管理页面](#十四、步骤 10:创建动作库管理页面)
- [十五、步骤 11:创建活动管理页面](#十五、步骤 11:创建活动管理页面)
- [十六、步骤 12:创建 Slogan 管理页面](#十六、步骤 12:创建 Slogan 管理页面)
- [十七、步骤 13:创建角色管理页面](#十七、步骤 13:创建角色管理页面)
- [十八、步骤 14:创建权限管理页面](#十八、步骤 14:创建权限管理页面)
- [十九、步骤 15:修改路由,把占位页替换为真实页面](#十九、步骤 15:修改路由,把占位页替换为真实页面)
- [二十、步骤 16:启动和验证](#二十、步骤 16:启动和验证)
- 二十一、本阶段验收清单
- 二十二、常见问题
- 二十三、进入下一阶段的条件
- 二十四、问题记录与修复方案
说明:文中尖括号
<>内容为占位符,使用时按实际环境替换。
一、阶段目标
本阶段目标是在登录、Token、权限菜单、后台基础布局已经完成的基础上,将上一阶段的 PlaceholderView.vue 占位页替换为真实后台管理页面。
完成本阶段后,前端应具备以下能力:
- 用户管理页面可以查询、新增、编辑、启停用户。
- 门店管理页面可以维护省份和门店。
- 课程管理页面可以维护课程分类和课程。
- 动作库页面可以维护动作分类和动作条目。
- 活动管理页面可以维护赛事活动。
- Slogan 管理页面可以维护首页标语。
- 角色管理页面可以维护角色基础信息。
- 权限管理页面可以查看后端初始化的权限列表。
- 路由不再进入占位页,而是进入对应业务管理页。
说明:当前后端已提供 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. 删除分类失败
若某个课程分类、动作分类、省份下已存在关联数据,数据库外键会阻止删除。
处理方式:
- 先删除或修改依赖该分类、省份的数据。
- 再删除分类或省份。
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]
}
在该接口补充完成后,再接入角色权限分配弹窗。
二十三、进入下一阶段的条件
满足以下条件后,进入第十二阶段:
必须满足:
- 用户、门店、课程、动作库、活动、Slogan、角色、权限八个管理页面均可从菜单进入并完成联调。
- 路由已不再指向
PlaceholderView.vue占位页,各业务路由均绑定真实管理页面并受权限路由守卫控制。 - 各页面可完成对应的查询、新增、编辑、删除或启停操作,分类、省份等前置数据可先于业务数据维护。
- 没有权限的账号访问受限页面时会被路由守卫拦截回首页。
- 浏览器控制台无 Vue 编译错误与 TS 模块解析错误。
以上条件全部满足后,进入下一阶段:阶段 12:前端详情增强与角色权限分配。
二十四、问题记录与修复方案
后续在第十一阶段实践中遇到问题,可完整记录报错原文,便于检索与定位。
问题按以下两个步骤处理:
- 判断错误属于 API 封装、页面组件、路由守卫、权限校验、Element Plus 表单还是启动配置问题。
- 将修复办法追加到本文件底部。
问题追加格式
text
### 问题 N:问题标题
#### 报错现象
按下方模板记录报错信息。
#### 原因分析
这里说明为什么会报错。
#### 修复办法
这里给出具体修复步骤。
#### 需要修改的文件
- 相对路径:xxx
- 修改说明:xxx
- 完整代码或补丁:xxx