阶段 12:前端详情增强与角色权限分配
合辑 :FastAPI × Vue3 JoyFit 系统阶段开发计划 | 端 :前端 | 阶段 :12 / 12 | 前置阶段 :阶段 11:前端后台管理页面
大家好,我是小马不起床。
目录
- 一、阶段目标
- 二、本阶段开发顺序
- 三、本阶段文件清单
- 四、本阶段新增接口
- [五、步骤 1:修改系统权限 Service](#五、步骤 1:修改系统权限 Service)
- [六、步骤 2:修改系统权限路由](#六、步骤 2:修改系统权限路由)
- [七、步骤 3:修改 OpenAPI 测试](#七、步骤 3:修改 OpenAPI 测试)
- [八、步骤 4:修改系统权限前端 API](#八、步骤 4:修改系统权限前端 API)
- [九、步骤 5:修改角色管理页面](#九、步骤 5:修改角色管理页面)
- [十、步骤 6:修改用户管理页面](#十、步骤 6:修改用户管理页面)
- [十一、步骤 7:运行后端测试](#十一、步骤 7:运行后端测试)
- [十二、步骤 8:运行前端检查](#十二、步骤 8:运行前端检查)
- 十三、本阶段验收清单
- 合辑收尾与后续方向
- 十四、问题记录与修复方案
一、阶段目标
本阶段目标是在后台基础 CRUD 页面已经完成的基础上,补齐上一阶段暂未覆盖的增强能力。
完成本阶段后,系统应具备以下能力:
- 后端可以查询某个角色当前绑定的权限 ID。
- 前端角色管理页面可以打开权限分配弹窗。
- 角色权限分配弹窗可以回显当前权限。
- 用户管理页面可以查看和编辑用户扩展档案。
- 用户管理页面可以绑定用户关联门店。
- 前端 API 封装覆盖用户档案、用户门店、角色权限分配。
- 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()
注意事项
list_role_permission_ids只返回权限 ID,不返回权限对象。- 查询不存在的角色时返回业务异常。
- 排序按权限 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")
注意事项
- 查询角色权限使用
role:list权限。 - 修改角色权限继续使用
role:update权限。 - 新接口必须放在
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()
)
注意事项
- 新增接口后,必须同步更新 OpenAPI 路径测试。
- 如果测试失败,优先检查
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>
注意事项
- 打开权限弹窗时,会先请求全部权限,再请求当前角色的权限 ID。
- 保存时会覆盖该角色原有权限,所以必须确保
checkedPermissionIds已经正确回显。 - 管理员角色可以分配权限,但不建议删除管理员角色;后端已经禁止删除
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>
注意事项
- 用户档案接口在没有档案时会自动创建档案记录。
- 门店绑定弹窗会请求门店列表,当前写法一次取
page_size=100。 - 如果后续门店数量超过 100,需要把门店选择改成远程搜索或分页选择。
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,按顺序检查以下各点:
backend/app/api/v1/system.py是否增加了@router.get("/roles/{role_id}/permissions")。backend/app/services/system.py是否增加并正确导入list_role_permission_ids。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))"
/>