小马不起床头像

Django 后端实战指南_18:Vue3 用户扩展档案页面

阶段 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. 阶段目标与文件清单
  2. 档案接口封装
  3. 我的档案页面
  4. 路由与首页菜单扩展
  5. 访问控制设计说明
  6. 启动与测试
  7. 阶段小结
  8. 常见问题与排查
  9. 合辑收尾与进阶方向

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 关键字段设计

字段输入控件提交前处理
levelnumber空值置 null,否则转 Number
genderselect(0 未知 / 1 男 / 2 女)空值置 null,否则转 Number
height_cm / weight_kgnumber(step 0.01)空值置 null,否则转 String
join_timedatetime-local经 inputValueToBackendDatetime 转换,空值置 null
expire_date / birthdaydate空值置 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

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--