零基础玩转 FastAPI_12:前端详情增强与角色权限分配

阶段 12:前端详情增强与角色权限分配

合辑 :FastAPI × Vue3 JoyFit 系统阶段开发计划 | 端 :前端 | 阶段 :12 / 12 | 前置阶段 :阶段 11:前端后台管理页面

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

目录

  1. 一、阶段目标
  2. 二、本阶段开发顺序
  3. 三、本阶段文件清单
  4. 四、本阶段新增接口
  5. [五、步骤 1:修改系统权限 Service](#五、步骤 1:修改系统权限 Service)
  6. [六、步骤 2:修改系统权限路由](#六、步骤 2:修改系统权限路由)
  7. [七、步骤 3:修改 OpenAPI 测试](#七、步骤 3:修改 OpenAPI 测试)
  8. [八、步骤 4:修改系统权限前端 API](#八、步骤 4:修改系统权限前端 API)
  9. [九、步骤 5:修改角色管理页面](#九、步骤 5:修改角色管理页面)
  10. [十、步骤 6:修改用户管理页面](#十、步骤 6:修改用户管理页面)
  11. [十一、步骤 7:运行后端测试](#十一、步骤 7:运行后端测试)
  12. [十二、步骤 8:运行前端检查](#十二、步骤 8:运行前端检查)
  13. 十三、本阶段验收清单
  14. 合辑收尾与后续方向
  15. 十四、问题记录与修复方案

一、阶段目标

本阶段目标是在后台基础 CRUD 页面已经完成的基础上,补齐上一阶段暂未覆盖的增强能力。

完成本阶段后,系统应具备以下能力:

  1. 后端可以查询某个角色当前绑定的权限 ID。
  2. 前端角色管理页面可以打开权限分配弹窗。
  3. 角色权限分配弹窗可以回显当前权限。
  4. 用户管理页面可以查看和编辑用户扩展档案。
  5. 用户管理页面可以绑定用户关联门店。
  6. 前端 API 封装覆盖用户档案、用户门店、角色权限分配。
  7. OpenAPI 测试覆盖新增的角色权限查询接口。

本阶段不覆盖会员端收藏页面。课程收藏、动作收藏属于会员端体验,规划至后续「前台门户与会员中心」阶段处理。

二、本阶段开发顺序

text 复制代码
1. 后端 service 增加角色权限 ID 查询方法
2. 后端 system 路由增加 GET /system/roles/{role_id}/permissions
3. 后端 OpenAPI 测试补充新路径
4. 前端 system API 增加 getRolePermissionIdsApi
5. 前端角色管理页面增加权限分配弹窗
6. 前端用户管理页面增加档案弹窗
7. 前端用户管理页面增加门店绑定弹窗
8. 启动后端测试
9. 启动前端联调

三、本阶段文件清单

序号 相对路径 类型 说明
1 backend/app/services/system.py 修改 增加查询角色权限 ID 方法
2 backend/app/api/v1/system.py 修改 增加角色权限查询接口
3 backend/tests/test_openapi.py 修改 补充 OpenAPI 路径测试
4 frontend/src/api/system.ts 修改 增加角色权限查询 API
5 frontend/src/views/admin/RoleManageView.vue 修改 增加权限分配弹窗
6 frontend/src/views/admin/UserManageView.vue 修改 增加档案编辑和门店绑定

四、本阶段新增接口

方法 路径 权限 说明
GET /api/system/roles/{role_id}/permissions role:list 查询某个角色当前拥有的权限 ID 列表

返回示例:

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

五、步骤 1:修改系统权限 Service

相对路径

text 复制代码
backend/app/services/system.py

完整代码

python 复制代码
from sqlalchemy import delete, select
from sqlalchemy.orm import Session

from app.core.exceptions import BusinessException
from app.models.system import SysPermission, SysRole, SysRolePermission
from app.schemas.system import RoleCreate, RoleUpdate


def list_roles(db: Session) -> list[SysRole]:
    return list(db.scalars(select(SysRole).order_by(SysRole.id.asc())).all())


def create_role(db: Session, role_in: RoleCreate) -> SysRole:
    exists = db.scalar(select(SysRole).where(SysRole.role_code == role_in.role_code))
    if exists:
        raise BusinessException(message="role_code already exists", code=400)

    role = SysRole(**role_in.model_dump())
    db.add(role)
    db.commit()
    db.refresh(role)
    return role


def update_role(db: Session, role_id: int, role_in: RoleUpdate) -> SysRole:
    role = db.get(SysRole, role_id)
    if not role:
        raise BusinessException(message="role not found", code=404)

    update_data = role_in.model_dump(exclude_unset=True)
    for field, value in update_data.items():
        setattr(role, field, value)

    db.add(role)
    db.commit()
    db.refresh(role)
    return role


def delete_role(db: Session, role_id: int) -> None:
    role = db.get(SysRole, role_id)
    if not role:
        raise BusinessException(message="role not found", code=404)
    if role.role_code == "admin":
        raise BusinessException(message="admin role cannot be deleted", code=400)

    db.delete(role)
    db.commit()


def list_permissions(db: Session) -> list[SysPermission]:
    return list(db.scalars(select(SysPermission).order_by(SysPermission.id.asc())).all())


def list_role_permission_ids(db: Session, role_id: int) -> list[int]:
    role = db.get(SysRole, role_id)
    if not role:
        raise BusinessException(message="role not found", code=404)

    return list(
        db.scalars(
            select(SysRolePermission.permission_id)
            .where(SysRolePermission.role_id == role_id)
            .order_by(SysRolePermission.permission_id.asc())
        ).all()
    )


def update_role_permissions(
    db: Session,
    role_id: int,
    permission_ids: list[int],
) -> None:
    role = db.get(SysRole, role_id)
    if not role:
        raise BusinessException(message="role not found", code=404)

    db.execute(delete(SysRolePermission).where(SysRolePermission.role_id == role_id))

    for permission_id in permission_ids:
        permission = db.get(SysPermission, permission_id)
        if not permission:
            raise BusinessException(message=f"permission not found: {permission_id}", code=404)
        db.add(
            SysRolePermission(
                role_id=role_id,
                permission_id=permission_id,
            )
        )

    db.commit()

注意事项

  1. list_role_permission_ids 只返回权限 ID,不返回权限对象。
  2. 查询不存在的角色时返回业务异常。
  3. 排序按权限 ID 升序,方便前端稳定回显。

六、步骤 2:修改系统权限路由

相对路径

text 复制代码
backend/app/api/v1/system.py

完整代码

python 复制代码
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from app.api.deps import require_permission
from app.db.session import get_db
from app.models.user import SysUser
from app.schemas.system import RoleCreate, RolePermissionUpdate, RoleUpdate
from app.services.system import (
    create_role,
    delete_role,
    list_permissions,
    list_role_permission_ids,
    list_roles,
    update_role,
    update_role_permissions,
)
from app.utils.response import success


router = APIRouter(prefix="/system", tags=["系统权限"])


@router.get("/roles")
def get_roles(
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("role:list")),
) -> dict:
    roles = list_roles(db)
    return success(
        data=[
            {
                "id": item.id,
                "role_code": item.role_code,
                "role_name": item.role_name,
            }
            for item in roles
        ]
    )


@router.post("/roles")
def add_role(
    payload: RoleCreate,
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("role:create")),
) -> dict:
    role = create_role(db, payload)
    return success(
        data={
            "id": role.id,
            "role_code": role.role_code,
            "role_name": role.role_name,
        }
    )


@router.put("/roles/{role_id}")
def edit_role(
    role_id: int,
    payload: RoleUpdate,
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("role:update")),
) -> dict:
    role = update_role(db, role_id, payload)
    return success(
        data={
            "id": role.id,
            "role_code": role.role_code,
            "role_name": role.role_name,
        }
    )


@router.delete("/roles/{role_id}")
def remove_role(
    role_id: int,
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("role:delete")),
) -> dict:
    delete_role(db, role_id)
    return success(message="deleted")


@router.get("/permissions")
def get_permissions(
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("permission:list")),
) -> dict:
    permissions = list_permissions(db)
    return success(
        data=[
            {
                "id": item.id,
                "permission_code": item.permission_code,
                "permission_name": item.permission_name,
                "menu_path": item.menu_path,
            }
            for item in permissions
        ]
    )


@router.get("/roles/{role_id}/permissions")
def get_role_permissions(
    role_id: int,
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("role:list")),
) -> dict:
    permission_ids = list_role_permission_ids(db, role_id)
    return success(data=permission_ids)


@router.put("/roles/{role_id}/permissions")
def assign_role_permissions(
    role_id: int,
    payload: RolePermissionUpdate,
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("role:update")),
) -> dict:
    update_role_permissions(db, role_id, payload.permission_ids)
    return success(message="updated")

注意事项

  1. 查询角色权限使用 role:list 权限。
  2. 修改角色权限继续使用 role:update 权限。
  3. 新接口必须放在 system.py 中,路由前缀仍然是 /system。

七、步骤 3:修改 OpenAPI 测试

相对路径

text 复制代码
backend/tests/test_openapi.py

完整代码

python 复制代码
from app.main import app


def test_openapi_contains_core_paths() -> None:
    schema = app.openapi()
    paths = set(schema["paths"].keys())

    expected_paths = {
        "/api/health",
        "/api/auth/token",
        "/api/auth/login",
        "/api/auth/me",
        "/api/auth/permissions",
        "/api/auth/menus",
        "/api/system/roles",
        "/api/system/roles/{role_id}/permissions",
        "/api/system/permissions",
        "/api/users",
        "/api/stores",
        "/api/course-categories",
        "/api/courses",
        "/api/action-categories",
        "/api/actions",
        "/api/content/slogans",
        "/api/content/events",
        "/api/home",
    }

    missing_paths = expected_paths - paths
    assert not missing_paths, f"missing openapi paths: {sorted(missing_paths)}"


def test_openapi_has_security_scheme() -> None:
    schema = app.openapi()
    components = schema.get("components", {})
    security_schemes = components.get("securitySchemes", {})

    assert security_schemes
    assert any(
        scheme.get("type") == "oauth2"
        for scheme in security_schemes.values()
    )

注意事项

  1. 新增接口后,必须同步更新 OpenAPI 路径测试。
  2. 如果测试失败,优先检查 app.api.router 是否注册了 system.router。

八、步骤 4:修改系统权限前端 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 getRolePermissionIdsApi(roleId: number) {
  return request<number[]>({
    url: `/system/roles/${roleId}/permissions`,
    method: 'get',
  })
}

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

九、步骤 5:修改角色管理页面

相对路径

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, Setting } from '@element-plus/icons-vue'

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

const loading = ref(false)
const roles = ref<Role[]>([])
const permissions = ref<Permission[]>([])
const dialogVisible = ref(false)
const permissionDialogVisible = ref(false)
const editingId = ref<number | null>(null)
const currentRole = ref<Role | null>(null)
const checkedPermissionIds = ref<number[]>([])
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
  }
}

async function fetchPermissions() {
  permissions.value = await listPermissionsApi()
}

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()
}

async function openPermissionDialog(row: Role) {
  currentRole.value = row

  if (permissions.value.length === 0) {
    await fetchPermissions()
  }

  checkedPermissionIds.value = await getRolePermissionIdsApi(row.id)
  permissionDialogVisible.value = true
}

async function submitRolePermissions() {
  if (!currentRole.value) {
    return
  }

  await updateRolePermissionsApi(currentRole.value.id, checkedPermissionIds.value)
  ElMessage.success('角色权限已更新')
  permissionDialogVisible.value = false
}

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

<template>
  <section class="page-panel">
    <div class="table-actions">
      <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="220" fixed="right">
        <template #default="{ row }">
          <el-button link type="primary" @click="openEdit(row)">编辑</el-button>
          <el-button link type="success" :icon="Setting" @click="openPermissionDialog(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" maxlength="64" />
        </el-form-item>
        <el-form-item label="角色名称" prop="role_name" required>
          <el-input v-model="form.role_name" maxlength="64" />
        </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>

    <el-dialog
      v-model="permissionDialogVisible"
      :title="`分配权限:${currentRole?.role_name || ''}`"
      width="760px"
    >
      <el-checkbox-group v-model="checkedPermissionIds" class="permission-grid">
        <el-checkbox
          v-for="item in permissions"
          :key="item.id"
          :label="item.id"
          border
        >
          <span class="permission-name">{{ item.permission_name }}</span>
          <span class="permission-code">{{ item.permission_code }}</span>
        </el-checkbox>
      </el-checkbox-group>
      <template #footer>
        <el-button @click="permissionDialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitRolePermissions">保存权限</el-button>
      </template>
    </el-dialog>
  </section>
</template>

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

.table-actions {
  display: flex;
  justify-content: flex-end;
}

.permission-grid {
  display: grid;
  max-height: 520px;
  gap: 10px;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  overflow-y: auto;
}

.permission-grid :deep(.el-checkbox) {
  height: auto;
  margin-right: 0;
  padding: 10px 12px;
}

.permission-name,
.permission-code {
  display: block;
  line-height: 1.5;
}

.permission-code {
  color: #6b7280;
  font-size: 12px;
}

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

注意事项

  1. 打开权限弹窗时,会先请求全部权限,再请求当前角色的权限 ID。
  2. 保存时会覆盖该角色原有权限,所以必须确保 checkedPermissionIds 已经正确回显。
  3. 管理员角色可以分配权限,但不建议删除管理员角色;后端已经禁止删除 admin 角色。

十、步骤 6:修改用户管理页面

相对路径

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

完整代码

vue 复制代码
<script setup lang="ts">
import { computed, 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 { listStoresApi, type Store } from '../../api/store'
import {
  bindUserStoresApi,
  createUserApi,
  getUserProfileApi,
  listUserStoresApi,
  listUsersApi,
  updateUserApi,
  updateUserProfileApi,
  updateUserStatusApi,
  type User,
  type UserForm,
  type UserProfile,
} from '../../api/user'

const loading = ref(false)
const users = ref<User[]>([])
const roles = ref<Role[]>([])
const stores = ref<Store[]>([])
const total = ref(0)
const formRef = ref<FormInstance>()
const profileFormRef = ref<FormInstance>()
const dialogVisible = ref(false)
const profileDialogVisible = ref(false)
const storeDialogVisible = ref(false)
const editingId = ref<number | null>(null)
const currentUser = ref<User | null>(null)
const selectedStoreIds = ref<number[]>([])
const storeBindRoleId = ref<number | undefined>()

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,
})

const profileForm = reactive<Partial<UserProfile>>({
  level: null,
  gender: null,
  birthday: null,
  height_cm: null,
  weight_kg: null,
  avatar_url: '',
  intro: '',
  join_time: null,
  expire_date: null,
})

const storeNameMap = computed(() => {
  return new Map(stores.value.map((item) => [item.store_id, item.store_name]))
})

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

function storeName(storeId: number) {
  return storeNameMap.value.get(storeId) || `门店 ${storeId}`
}

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

function resetProfileForm() {
  profileForm.level = null
  profileForm.gender = null
  profileForm.birthday = null
  profileForm.height_cm = null
  profileForm.weight_kg = null
  profileForm.avatar_url = ''
  profileForm.intro = ''
  profileForm.join_time = null
  profileForm.expire_date = null
}

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

async function fetchStores() {
  const result = await listStoresApi({
    page: 1,
    page_size: 100,
  })
  stores.value = result.items
}

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 ? '用户已启用' : '用户已禁用')
}

async function openProfile(row: User) {
  currentUser.value = row
  resetProfileForm()

  const profile = await getUserProfileApi(row.user_id)
  profileForm.level = profile.level
  profileForm.gender = profile.gender
  profileForm.birthday = profile.birthday
  profileForm.height_cm = profile.height_cm
  profileForm.weight_kg = profile.weight_kg
  profileForm.avatar_url = profile.avatar_url || ''
  profileForm.intro = profile.intro || ''
  profileForm.join_time = profile.join_time
  profileForm.expire_date = profile.expire_date

  profileDialogVisible.value = true
}

async function submitProfile() {
  await profileFormRef.value?.validate()

  if (!currentUser.value) {
    return
  }

  await updateUserProfileApi(currentUser.value.user_id, {
    level: profileForm.level ?? null,
    gender: profileForm.gender ?? null,
    birthday: profileForm.birthday || null,
    height_cm: profileForm.height_cm ?? null,
    weight_kg: profileForm.weight_kg ?? null,
    avatar_url: profileForm.avatar_url || null,
    intro: profileForm.intro || null,
    join_time: profileForm.join_time || null,
    expire_date: profileForm.expire_date || null,
  })

  ElMessage.success('用户档案已更新')
  profileDialogVisible.value = false
}

async function openStoreBind(row: User) {
  currentUser.value = row

  if (stores.value.length === 0) {
    await fetchStores()
  }

  const records = await listUserStoresApi(row.user_id)
  selectedStoreIds.value = records.map((item) => item.store_id)
  storeBindRoleId.value = records[0]?.role_id || row.role_id
  storeDialogVisible.value = true
}

async function submitStoreBind() {
  if (!currentUser.value) {
    return
  }

  await bindUserStoresApi(
    currentUser.value.user_id,
    selectedStoreIds.value,
    storeBindRoleId.value,
  )

  ElMessage.success('关联门店已更新')
  storeDialogVisible.value = false
}

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="240" fixed="right">
        <template #default="{ row }">
          <el-button link type="primary" @click="openEdit(row)">编辑</el-button>
          <el-button link type="success" @click="openProfile(row)">档案</el-button>
          <el-button link type="warning" @click="openStoreBind(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" maxlength="64" />
        </el-form-item>
        <el-form-item :label="editingId ? '新密码' : '密码'" prop="password">
          <el-input v-model="form.password" maxlength="72" 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" maxlength="64" />
        </el-form-item>
        <el-form-item label="手机号">
          <el-input v-model="form.phone" maxlength="20" />
        </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>

    <el-dialog
      v-model="profileDialogVisible"
      :title="`用户档案:${currentUser?.username || ''}`"
      width="640px"
    >
      <el-form ref="profileFormRef" :model="profileForm" label-width="100px">
        <el-row :gutter="16">
          <el-col :span="12">
            <el-form-item label="等级">
              <el-input-number v-model="profileForm.level" :min="0" class="full-width" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="性别">
              <el-select v-model="profileForm.gender" clearable class="full-width">
                <el-option label="未知" :value="0" />
                <el-option label="男" :value="1" />
                <el-option label="女" :value="2" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="生日">
              <el-date-picker v-model="profileForm.birthday" type="date" value-format="YYYY-MM-DD" class="full-width" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="身高 cm">
              <el-input-number v-model="profileForm.height_cm" :min="0" :precision="2" class="full-width" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="体重 kg">
              <el-input-number v-model="profileForm.weight_kg" :min="0" :precision="2" class="full-width" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="开卡/入职">
              <el-date-picker
                v-model="profileForm.join_time"
                type="datetime"
                value-format="YYYY-MM-DDTHH:mm:ss"
                class="full-width"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="到期日期">
              <el-date-picker v-model="profileForm.expire_date" type="date" value-format="YYYY-MM-DD" class="full-width" />
            </el-form-item>
          </el-col>
          <el-col :span="24">
            <el-form-item label="头像 URL">
              <el-input v-model="profileForm.avatar_url" maxlength="255" />
            </el-form-item>
          </el-col>
          <el-col :span="24">
            <el-form-item label="简介">
              <el-input v-model="profileForm.intro" type="textarea" :rows="4" />
            </el-form-item>
          </el-col>
        </el-row>
      </el-form>
      <template #footer>
        <el-button @click="profileDialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitProfile">保存档案</el-button>
      </template>
    </el-dialog>

    <el-dialog
      v-model="storeDialogVisible"
      :title="`绑定门店:${currentUser?.username || ''}`"
      width="620px"
    >
      <el-form label-width="100px">
        <el-form-item label="绑定角色">
          <el-select v-model="storeBindRoleId" 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-checkbox-group v-model="selectedStoreIds" class="store-grid">
            <el-checkbox
              v-for="store in stores"
              :key="store.store_id"
              :label="store.store_id"
              border
            >
              {{ storeName(store.store_id) }}
            </el-checkbox>
          </el-checkbox-group>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="storeDialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitStoreBind">保存绑定</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%;
}

.store-grid {
  display: grid;
  width: 100%;
  max-height: 360px;
  gap: 10px;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  overflow-y: auto;
}

.store-grid :deep(.el-checkbox) {
  height: auto;
  margin-right: 0;
  padding: 10px 12px;
}

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

  .store-grid {
    grid-template-columns: 1fr;
  }
}
</style>

注意事项

  1. 用户档案接口在没有档案时会自动创建档案记录。
  2. 门店绑定弹窗会请求门店列表,当前写法一次取 page_size=100。
  3. 如果后续门店数量超过 100,需要把门店选择改成远程搜索或分页选择。
  4. join_time 使用 YYYY-MM-DDTHH:mm:ss,可以被后端 datetime 字段正常解析。

十一、步骤 7:运行后端测试

执行位置

text 复制代码
FastAPI_Vue3_JoyFit/backend

执行命令

bash 复制代码
source .venv/bin/activate
pytest

期望结果

text 复制代码
passed

如果 test_openapi.py 运行结果提示缺少 /api/system/roles/{role_id}/permissions,按顺序检查以下各点:

  1. backend/app/api/v1/system.py 是否增加了 @router.get("/roles/{role_id}/permissions")。
  2. backend/app/services/system.py 是否增加并正确导入 list_role_permission_ids。
  3. backend/app/api/router.py 是否仍然注册了 system.router。

十二、步骤 8:运行前端检查

执行位置

text 复制代码
FastAPI_Vue3_JoyFit/frontend

执行命令

bash 复制代码
npm run build

常见问题

如果出现以下报错:

text 复制代码
TS6133: 'error' is declared but its value is never read.

将未使用的:

typescript 复制代码
catch (error) {

调整为:

typescript 复制代码
catch {

如果出现以下报错:

text 复制代码
Cannot find module '../views/admin/xxx.vue'

检查 frontend/src/views/admin/ 目录下对应页面文件是否存在,并确认文件名大小写和路由中的导入路径完全一致。

十三、本阶段验收清单

  • 后端 pytest 通过。
  • 前端 npm run build 通过。
  • 角色管理页面可以点击"权限"按钮。
  • 权限弹窗可以正确回显当前角色已有权限。
  • 勾选或取消权限后保存,刷新再打开仍能看到最新权限。
  • 用户管理页面可以点击"档案"按钮。
  • 用户档案保存后,再次打开能看到最新数据。
  • 用户管理页面可以点击"门店"按钮。
  • 勾选门店后保存,再次打开能看到已绑定门店。
  • 普通没有权限的账号不能访问角色权限分配相关页面。

合辑收尾与后续方向

本阶段为 FastAPI × Vue3 JoyFit 系统阶段开发计划的最后一个阶段。全部 12 个阶段完成后,需要逐项确认以下收尾验收陈述:

  • 后端已完成工程构建、数据库模型与通用能力、登录认证与 RBAC 权限、用户门店、课程、动作库、活动首页、测试与接口文档共 8 个阶段,pytest 全部通过,OpenAPI 文档覆盖所有已交付接口。
  • 前端已完成基础工程构建、登录权限与布局、后台管理页面、详情增强与角色权限分配共 4 个阶段,npm run build 通过,后台管理各页面可正常联调。
  • 角色权限分配链路完整:后端提供角色权限 ID 查询与更新接口,前端弹窗可回显、修改并持久化角色权限。
  • 用户档案与用户关联门店功能可用:档案编辑、门店绑定均可保存并正确回显。
  • 无权限账号访问受保护接口与页面时被正确拦截,RBAC 权限体系在前后端两侧均生效。

后续进阶方向可按以下顺序规划:数据看板(经营指标统计与图表化展示)、文件上传(头像与课程素材接入对象存储,替换 avatar_url 手工填写方式)、权限粒度细化到数据级(按门店、按记录范围的行级权限控制)、部署上线与 CI(容器化部署、自动化测试流水线与发布流程)。这些方向不属于本合辑范围,可作为独立专题另行展开。

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

本阶段后续实践中遇到的问题,按下方模板记录报错信息:完整记录报错原文,便于检索与定位。每条记录包含报错现象、原因、修复方式与修复后的代码片段。

1. npm run build 报 TS7006:value implicitly has an any type

报错现象

执行:

bash 复制代码
npm run build

出现:

text 复制代码
src/views/admin/UserManageView.vue:318:23 - error TS7006: Parameter 'value' implicitly has an 'any' type.

318             @change="(value) => changeStatus(row, Number(value))"
                          ~~~~~
原因

vue-tsc 会检查 Vue 模板里的 TypeScript 表达式。模板中存在如下内联箭头函数:

vue 复制代码
@change="(value) => changeStatus(row, Number(value))"

参数 value 没有显式类型,在当前 TypeScript 严格检查规则下会被认为是隐式 any。

修复方式

将:

vue 复制代码
@change="(value) => changeStatus(row, Number(value))"

调整为:

vue 复制代码
@change="changeStatus(row, Number($event))"
修复后的代码片段
vue 复制代码
<el-switch
  :model-value="row.status"
  :active-value="1"
  :inactive-value="0"
  @change="changeStatus(row, Number($event))"
/>
相关推荐
前端snow1 小时前
ai agent --- 切割文件 RecursiveCharacterTextSplitter
前端
松就是我902981 小时前
可编辑 PPT:调研、设计与实施方案
前端
前端snow1 小时前
ai agent --- Elasticsearch 混合检索
前端
java1234_小锋1 小时前
【技术专题】HTML5 网页技术 - HTML5 文本与排版标签
前端·html·html5
Wokoo71 小时前
Selenium:元素定位、动态适配与性能优化(二)
前端·selenium·自动化
用户2788607285671 小时前
把 restore 当撤销会发生什么?长任务助手的会话与业务状态
前端
℘团子এ1 小时前
vue3中自定义指令的使用
前端·javascript·vue.js
般逗1 小时前
FastAPI 难点解惑 | Python语言的后端框架
fastapi
用户2788607285672 小时前
别把 AI 查询结果只塞进聊天框:SessionView 与业务页面接线
前端