阶段 18:Vue3 用户扩展档案页面
合辑:Django 系统阶段开发手册 | 阶段:18 / 18 | 定位:为当前登录用户扩展档案提供读取与保存页面,完成前后端全链路收尾 | 建议学时:5–7 小时
大家好,我是小马不起床。
阶段导语
本阶段是合辑的收尾篇。阶段 17 已将门店、课程、动作及我的收藏接入前端,本阶段沿用同一套 API 封装与单文件组件(Single-File Component,SFC)范式,新增「我的档案」页面,对接后端的 GET/PUT /api/profile/me/,使当前登录用户可查看并修改自身的扩展档案信息。
至此,项目从后端的数据模型、迁移与 RBAC 权限体系,到 JWT 登录、业务接口,再到前端的登录、权限路由与业务页面全部贯通。本阶段仅涉及四个文件的增改,范围收敛,重点在于表单读写与日期时间格式的双向转换。
学习目标
完成本阶段后,应能够:
- 为档案接口建立
profile.js封装,映射GET与PUT两个后端接口 - 以
<script setup>组织档案表单,用ref管理加载与保存状态、reactive管理表单字段 - 实现后端
datetime与前端datetime-local输入值之间的相互转换 - 在保存请求中按字段类型规整空值,避免向前端提交非法数据
- 在路由与首页菜单中追加
/profile,理解其仅受登录保护、不设权限码的设计依据
前置知识
本阶段依赖阶段 17 建立的前端页面骨架:
| 前置条目 | 具体要求 |
|---|---|
| 阶段 17:Vue3 对接门店、课程、动作接口 | router/index.js 已含 /store、/course、/exercise、/favorites,Home.vue 已有业务菜单 |
| 前端请求封装 | frontend/src/utils/request.js 自动附加 Authorization |
| 后端档案接口 | GET/PUT /api/profile/me/ 已就绪并受登录保护 |
| 组合式 API | 掌握 ref、reactive、onMounted 的基本用法 |
目录
1. 阶段目标与文件清单
本阶段仅新增和修改以下四个文件:
frontend/src/api/profile.js 新增
frontend/src/views/Profile.vue 新增
frontend/src/router/index.js 在阶段 17 基础上追加 /profile
frontend/src/views/Home.vue 在阶段 17 菜单基础上追加"我的档案"
要点:
- 要点:四个文件延续「API 封装层 + 页面组件 + 路由 + 首页菜单」的四层结构。
- 要点:
router/index.js与Home.vue为增量修改,其余为新建。
2. 档案接口封装
2.1 新增 frontend/src/api/profile.js
新建文件:
frontend/src/api/profile.js
写入:
import request from "../utils/request"
export function getMyProfileApi() {
return request.get("/profile/me/")
}
export function updateMyProfileApi(data) {
return request.put("/profile/me/", data)
}
该文件对应的后端接口:
| 方法 | 路径 | 参数 | 返回说明 |
|---|---|---|---|
| GET | /api/profile/me/ | 无 | 当前登录用户的扩展档案 |
| PUT | /api/profile/me/ | 请求体 data | 更新并返回当前登录用户的扩展档案 |
要点:
- 要点:读写接口共用
/profile/me/路径,以请求方法区分语义。 - 要点:接口不带用户 ID 参数,用户身份由后端依据 token 解析。
3. 我的档案页面
新建文件:
frontend/src/views/Profile.vue
写入:
<template>
<div class="page">
<div class="header">
<div>
<h2>我的档案</h2>
<p>查看和修改当前登录用户的扩展档案信息</p>
</div>
<router-link to="/">
返回首页
</router-link>
</div>
<div v-if="loading" class="card">
加载中...
</div>
<div v-else class="card">
<h3>基础信息</h3>
<div class="info-grid">
<p>用户ID:{{ profile.user_id }}</p>
<p>用户名:{{ profile.username }}</p>
<p>真实姓名:{{ profile.real_name || "-" }}</p>
<p>档案ID:{{ profile.profile_id }}</p>
</div>
<h3>档案信息</h3>
<div class="form-grid">
<div class="form-item">
<label>等级</label>
<input
v-model="form.level"
type="number"
placeholder="请输入等级"
/>
</div>
<div class="form-item">
<label>性别</label>
<select v-model="form.gender">
<option value="">请选择</option>
<option value="0">未知</option>
<option value="1">男</option>
<option value="2">女</option>
</select>
</div>
<div class="form-item">
<label>生日</label>
<input
v-model="form.birthday"
type="date"
/>
</div>
<div class="form-item">
<label>身高(cm)</label>
<input
v-model="form.height_cm"
type="number"
step="0.01"
placeholder="例如 175.50"
/>
</div>
<div class="form-item">
<label>体重(kg)</label>
<input
v-model="form.weight_kg"
type="number"
step="0.01"
placeholder="例如 70.25"
/>
</div>
<div class="form-item">
<label>头像地址</label>
<input
v-model="form.avatar_url"
placeholder="请输入头像 URL"
/>
</div>
<div class="form-item">
<label>入职/开卡时间</label>
<input
v-model="form.join_time"
type="datetime-local"
/>
</div>
<div class="form-item">
<label>到期/停卡时间</label>
<input
v-model="form.expire_date"
type="date"
/>
</div>
</div>
<div class="form-item full">
<label>简介</label>
<textarea
v-model="form.intro"
rows="5"
placeholder="请输入个人简介"
/>
</div>
<div v-if="form.avatar_url" class="avatar-preview">
<p>头像预览:</p>
<img
:src="form.avatar_url"
alt="头像"
/>
</div>
<div class="actions">
<button
:disabled="saving"
@click="handleSave"
>
{{ saving ? "保存中..." : "保存档案" }}
</button>
<button
class="secondary"
@click="loadProfile"
>
重新加载
</button>
</div>
<p
v-if="message"
class="message"
>
{{ message }}
</p>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue"
import {
getMyProfileApi,
updateMyProfileApi
} from "../api/profile"
const loading = ref(false)
const saving = ref(false)
const message = ref("")
const profile = ref({})
const form = reactive({
level: "",
gender: "",
birthday: "",
height_cm: "",
weight_kg: "",
avatar_url: "",
intro: "",
join_time: "",
expire_date: ""
})
function datetimeToInputValue(value) {
if (!value) {
return ""
}
return value.replace(" ", "T").slice(0, 16)
}
function inputValueToBackendDatetime(value) {
if (!value) {
return null
}
return value.replace("T", " ") + ":00"
}
function fillForm(data) {
profile.value = data
form.level = data.level ?? ""
form.gender = data.gender ?? ""
form.birthday = data.birthday ?? ""
form.height_cm = data.height_cm ?? ""
form.weight_kg = data.weight_kg ?? ""
form.avatar_url = data.avatar_url ?? ""
form.intro = data.intro ?? ""
form.join_time = datetimeToInputValue(data.join_time)
form.expire_date = data.expire_date ?? ""
}
async function loadProfile() {
try {
loading.value = true
message.value = ""
const res = await getMyProfileApi()
if (res.code === 200) {
fillForm(res.data)
} else {
message.value = res.message || "获取档案失败"
}
} catch (error) {
if (error.response && error.response.data) {
message.value = error.response.data.message || "获取档案失败"
} else {
message.value = "获取档案失败"
}
} finally {
loading.value = false
}
}
async function handleSave() {
try {
saving.value = true
message.value = ""
const payload = {
level: form.level === "" ? null : Number(form.level),
gender: form.gender === "" ? null : Number(form.gender),
birthday: form.birthday || null,
height_cm: form.height_cm === "" ? null : String(form.height_cm),
weight_kg: form.weight_kg === "" ? null : String(form.weight_kg),
avatar_url: form.avatar_url || "",
intro: form.intro || "",
join_time: form.join_time
? inputValueToBackendDatetime(form.join_time)
: null,
expire_date: form.expire_date || null
}
const res = await updateMyProfileApi(payload)
if (res.code === 200) {
fillForm(res.data)
message.value = "保存成功"
} else {
message.value = res.message || "保存失败"
}
} catch (error) {
if (error.response && error.response.data) {
message.value = error.response.data.message || "保存失败"
} else {
message.value = "保存失败"
}
} finally {
saving.value = false
}
}
onMounted(() => {
loadProfile()
})
</script>
<style scoped>
.page {
padding: 32px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.card {
padding: 20px;
margin-bottom: 20px;
border: 1px solid #eee;
border-radius: 8px;
background: #fff;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 24px;
margin-bottom: 24px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px 24px;
}
.form-item {
display: flex;
flex-direction: column;
}
.form-item.full {
margin-top: 16px;
}
label {
margin-bottom: 6px;
color: #333;
}
input,
select,
textarea {
padding: 9px;
border: 1px solid #d1d5db;
border-radius: 6px;
box-sizing: border-box;
}
textarea {
resize: vertical;
}
.avatar-preview {
margin-top: 20px;
}
.avatar-preview img {
width: 100px;
height: 100px;
object-fit: cover;
border: 1px solid #e5e7eb;
border-radius: 50%;
}
.actions {
display: flex;
gap: 12px;
margin-top: 24px;
}
button {
padding: 8px 14px;
cursor: pointer;
}
.secondary {
background: white;
color: #2563eb;
border: 1px solid #2563eb;
border-radius: 6px;
}
.message {
margin-top: 16px;
color: #2563eb;
}
</style>
3.1 关键字段设计
| 字段 | 输入控件 | 提交前处理 |
|---|---|---|
level | number | 空值置 null,否则转 Number |
gender | select(0 未知 / 1 男 / 2 女) | 空值置 null,否则转 Number |
height_cm / weight_kg | number(step 0.01) | 空值置 null,否则转 String |
join_time | datetime-local | 经 inputValueToBackendDatetime 转换,空值置 null |
expire_date / birthday | date | 空值置 null |
avatar_url / intro | 文本 / 多行文本 | 空值置空字符串 |
3.2 日期时间转换
后端 join_time 以 "YYYY-MM-DD HH:mm:ss" 形式返回,前端 datetime-local 需要 "YYYY-MM-DDTHH:mm" 形式;两者由 datetimeToInputValue(读取时)与 inputValueToBackendDatetime(保存时)互转。height_cm、weight_kg 以后端所需的字符串形式提交,level、gender 以数字形式提交。
要点:
- 要点:读取与保存分别经过
datetimeToInputValue与inputValueToBackendDatetime,保证前后端日期时间格式对齐。 - 要点:
fillForm使用??为空字段回填初始值,防止受控组件绑定undefined触发告警。 - 要点:保存成功后以返回数据再次
fillForm,使界面与后端记录保持一致。 - 要点:
loading与saving分别驱动加载态展示与按钮禁用,避免重复提交。
4. 路由与首页菜单扩展
4.1 修改路由文件
打开:
frontend/src/router/index.js
阶段 17 的路由中已包含:
StoreList
CourseList
ExerciseList
MyFavorites
本阶段仅追加 Profile。修改后的完整代码为:
import { createRouter, createWebHistory } from "vue-router"
import { useAuthStore } from "../stores/auth"
import Login from "../views/Login.vue"
import Home from "../views/Home.vue"
import UserList from "../views/UserList.vue"
import StoreList from "../views/StoreList.vue"
import CourseList from "../views/CourseList.vue"
import ExerciseList from "../views/ExerciseList.vue"
import MyFavorites from "../views/MyFavorites.vue"
import Profile from "../views/Profile.vue"
import Forbidden from "../views/Forbidden.vue"
const routes = [
{
path: "/login",
component: Login
},
{
path: "/",
component: Home,
meta: {
requiresAuth: true
}
},
{
path: "/users",
component: UserList,
meta: {
requiresAuth: true,
permission: "user:list"
}
},
{
path: "/store",
component: StoreList,
meta: {
requiresAuth: true
}
},
{
path: "/course",
component: CourseList,
meta: {
requiresAuth: true
}
},
{
path: "/exercise",
component: ExerciseList,
meta: {
requiresAuth: true
}
},
{
path: "/favorites",
component: MyFavorites,
meta: {
requiresAuth: true
}
},
{
path: "/profile",
component: Profile,
meta: {
requiresAuth: true
}
},
{
path: "/403",
component: Forbidden
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLogin) {
next("/login")
return
}
if (to.path === "/login" && authStore.isLogin) {
next("/")
return
}
if (to.meta.permission && !authStore.hasPermission(to.meta.permission)) {
next("/403")
return
}
next()
})
export default router
4.2 修改首页菜单
打开:
frontend/src/views/Home.vue
阶段 17 的首页菜单为:
用户管理
门店列表
课程列表
动作库
我的收藏
角色管理
本阶段仅新增一项:
我的档案
建议将其放在「我的收藏」之前。菜单区域改为:
<div class="menu-list">
<router-link
v-if="authStore.hasPermission('user:list')"
class="menu-item"
to="/users"
>
用户管理
</router-link>
<router-link
class="menu-item"
to="/store"
>
门店列表
</router-link>
<router-link
class="menu-item"
to="/course"
>
课程列表
</router-link>
<router-link
class="menu-item"
to="/exercise"
>
动作库
</router-link>
<router-link
class="menu-item"
to="/profile"
>
我的档案
</router-link>
<router-link
class="menu-item"
to="/favorites"
>
我的收藏
</router-link>
<span
v-if="authStore.hasPermission('role:list')"
class="menu-item disabled"
>
角色管理(后续阶段再做页面)
</span>
</div>
其余代码不作改动。
4.3 阶段 18 完成后的首页菜单
阶段 17 完成后的菜单为:
用户管理
门店列表
课程列表
动作库
我的收藏
角色管理
阶段 18 补充后变为:
用户管理
门店列表
课程列表
动作库
我的档案
我的收藏
角色管理
要点:
- 要点:路由仅在阶段 17 基础上新增一条
/profile记录及其对应的import,其余守卫逻辑不变。 - 要点:菜单项「我的档案」无权限判断,所有登录用户均可见。
5. 访问控制设计说明
「我的档案」菜单项不添加权限判断,即不写:
v-if="authStore.hasPermission('profile:me')"
原因在于当前设计中:
/profile 是当前登录用户自己的档案
该页面仅需登录即可访问,因此路由中声明以下内容即足够:
meta: {
requiresAuth: true
}
| 页面 | 访问条件 | 设计依据 |
|---|---|---|
/profile | 仅需登录 | 档案归属当前用户本人,无跨用户读写 |
/users | 登录 + user:list 权限 | 用户管理涉及他人数据,需权限控制 |
要点:
- 要点:
/profile属于「本人数据」类接口,登录态足以界定访问范围,无需权限码。 - 要点:需要访问他人数据的功能(如用户管理)才叠加
permission判断,二者形成对照。 - 要点:后端
GET/PUT /api/profile/me/同样依据 token 定位用户,前端与后端的用户隔离一致。
6. 启动与测试
6.1 启动项目
启动后端:
cd rbac_project/backend
python manage.py runserver
启动前端:
cd rbac_project/frontend
npm run dev
6.2 测试 admin
登录:
admin / 123456
按以下步骤验证:
1. 首页能看到"我的档案"
2. 点击进入 /profile
3. 能加载当前用户档案
4. 修改身高、体重、简介等字段
5. 点击保存档案
6. 页面提示保存成功
7. 刷新页面后数据还在
6.3 测试 zhangsan
再测试:
zhangsan / 123456
确认:
zhangsan 只能看到和修改自己的档案
要点:
- 要点:验收核心为「保存后刷新数据仍在」,即 PUT 结果真实落库。
- 要点:切换账号访问
/profile应呈现各自档案,体现前后端一致的用户隔离。
阶段小结
| 环节 | 涉及文件 | 核心职责 |
|---|---|---|
| 接口封装 | api/profile.js | 映射 GET/PUT /profile/me/ |
| 页面组件 | views/Profile.vue | 档案读写表单、日期时间与空值转换 |
| 路由 | router/index.js | 追加 /profile,仅设 requiresAuth |
| 首页菜单 | views/Home.vue | 追加「我的档案」入口 |
| 访问控制 | 路由 meta | 本人数据仅登录即可访问 |
常见问题与排查
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 「我的档案」菜单点击后跳转登录页 | 登录态失效或 access_token 缺失 | 重新登录;核对 request.js 是否附加 Authorization |
| 入职/开卡时间无法回填或保存异常 | 后端 datetime 与前端 datetime-local 格式不一致 | 经 datetimeToInputValue(读取)与 inputValueToBackendDatetime(保存)互转 |
| 身高、体重保存后类型不符 | 提交值未转换为后端期望的字符串 | 保存前对非空值执行 String(...),空值置 null |
| 保存成功但界面未更新 | 未以保存响应回填表单 | 保存成功后调用 fillForm(res.data) 刷新展示 |
合辑收尾与进阶方向
本阶段完成后,Django 系统阶段开发计划的十八个阶段构成一条贯通的完整链路:
| 层次 | 覆盖阶段 | 交付内容 |
|---|---|---|
| 项目与数据底座 | 阶段 1–13 | 项目初始化、数据模型设计、迁移建表、业务测试数据初始化 |
| RBAC 权限体系 | 阶段 4–11 前后段 | 用户、角色、权限三要素,权限拦截与 login_required |
| JWT 登录 | 阶段 11 前后段 | 签发与校验 access_token,统一响应封装 |
| 业务查询接口 | 阶段 14–16 | 门店、课程、动作三大领域的查询与收藏接口 |
| 前端权限基座 | 阶段 11、14 前后段 | 登录页、stores/auth、路由守卫与权限渲染 |
| 前端业务页面 | 阶段 17–18 | 门店、课程、动作、我的收藏、我的档案五个页面 |
系统至此具备「后端数据 + 权限 + 接口」与「前端登录 + 权限路由 + 业务页面」的完整闭环。在此之上,可沿以下方向继续演进:
- 权限缓存与会话优化:将用户权限集合在服务端缓存,减少每次请求的权限查询开销,并处理 token 续期与登出失效。
- 接口版本化:为路由引入版本前缀(如
/api/v1/),在不破坏既有客户端的前提下迭代接口契约。 - 前端工程化:引入 UI 组件库与状态管理分层,补齐列表分页、加载骨架、错误边界等交互细节。
- 部署与运维:配置生产级 Web 服务器与反向代理、静态资源收集、数据库连接与环境变量管理。
- 自动化测试:为视图与序列化逻辑补充单元测试,为关键接口补充集成测试,保障重构安全。
上述方向均属能力扩展建议,具体实现与指标需依据项目实际条件另行评估。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_63421405/article/details/168444523



