编辑资料与编辑角色模块 一、目标 本节完成两个核心能力:
用户编辑资料:修改头像、用户名、个人简介;
AI 角色管理:创建、更新、删除、获取单个角色。
涉及的重要知识点:
FormData 文件上传;
图片裁剪 Croppie;
Base64 转 File;
Django ImageField;
删除旧媒体文件;
Django ORM 外键查询;
前端父子组件通信;
角色 CRUD 接口设计。
二、编辑资料模块 2.1 后端接口设计 编辑资料接口通常为:
1 POST /api/user/profile/update/
请求字段:
字段
来源
说明
username
request.data
新用户名
profile
request.data
新简介
photo
request.FILES
新头像文件,可选
接口权限:
1 permission_classes = [IsAuthenticated]
表示只有登录用户才能修改自己的资料。
2.2 删除旧头像工具函数 1 2 3 4 5 6 7 8 9 import osfrom django.conf import settingsdef remove_old_photo (photo ): if photo and photo.name != 'user/photos/default.png' : old_path = settings.MEDIA_ROOT / photo.name if os.path.exists(old_path): os.remove(old_path)
重点:
不能删除默认头像;
photo.name 是相对于 MEDIA_ROOT 的路径;
删除前必须判断文件是否存在。
2.3 更新资料接口核心逻辑 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 from django.contrib.auth.models import Userfrom django.utils.timezone import nowfrom rest_framework.views import APIViewfrom rest_framework.response import Responsefrom rest_framework.permissions import IsAuthenticatedfrom web.models.user import UserProfilefrom web.views.utils.photo import remove_old_photoclass UpdateProfileView (APIView ): permission_classes = [IsAuthenticated] def post (self, request ): user = request.user user_profile = UserProfile.objects.filter (user=user).first() if not user_profile: return Response({'result' : '用户资料不存在' }) username = request.data.get('username' , '' ).strip() profile = request.data.get('profile' , '' ).strip()[:500 ] photo = request.FILES.get('photo' ) if not username: return Response({'result' : '用户名不得为空' }) if not profile: return Response({'result' : '简介不得为空' }) if username != user.username and User.objects.filter (username=username).exists(): return Response({'result' : '该用户名已存在' }) if photo: remove_old_photo(user_profile.photo) user_profile.photo = photo user.username = username user.save() user_profile.profile = profile user_profile.update_time = now() user_profile.save() return Response({ 'result' : 'success' , 'user_id' : user.id , 'username' : user.username, 'profile' : user_profile.profile, 'photo' : user_profile.photo.url, })
2.4 路由 1 path('api/user/profile/update/' , UpdateProfileView.as_view()),
三、前端编辑资料页面 3.1 组件拆分 建议拆成三个子组件:
1 2 3 4 frontend/src/views/user/profile/components/ ├── Photo.vue # 头像选择与裁剪 ├── Username.vue # 用户名输入 └── Profile.vue # 简介输入
父组件:
1 frontend/src/views/user/profile/ProfileIndex.vue
3.2 为什么要拆组件?
头像裁剪逻辑较复杂,单独维护更清晰;
用户名、简介都是表单项,可独立复用;
父组件只负责收集数据并提交。
3.3 子组件接收 props 与暴露数据 Username.vue 示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <script setup> import { ref, watch } from 'vue' const props = defineProps(['username']) const myUsername = ref(props.username) watch(() => props.username, value => { myUsername.value = value }) defineExpose({ myUsername }) </script> <template> <fieldset class="fieldset"> <label class="label text-base">用户名</label> <input v-model="myUsername" type="text" class="input w-108" /> </fieldset> </template>
watch 的作用:刷新页面后,用户信息是异步从后端加载的,props 后变化时子组件也要同步更新。
3.4 Croppie 裁剪头像 安装:
1 2 cd frontend npm install croppie
使用:
1 2 import Croppie from 'croppie' import 'croppie/croppie.css'
裁剪结果:
1 2 3 4 myPhoto.value = await croppie.result ({ type : 'base64' , size : 'viewport' , })
注意事项:
vue-croppie 不支持 Vue 3 时,直接使用原生 croppie;
每次打开弹窗前要避免重复初始化;
组件卸载时执行 croppie?.destroy();
modal-box 与 Croppie 样式冲突时,可加 transition-none。
3.5 Base64 转 File frontend/src/js/utils/base64_to_file.js:
1 2 3 4 5 6 7 8 9 export function base64ToFile (base64, filename ) { const arr = base64.split (',' ) const mime = arr[0 ].match (/:(.*?);/ )[1 ] const bstr = atob (arr[1 ]) let n = bstr.length const u8arr = new Uint8Array (n) while (n--) u8arr[n] = bstr.charCodeAt (n) return new File ([u8arr], filename, { type : mime }) }
该函数只适用于:
1 data:image/png;base64,...
这种 data URL。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 async function handleUpdate ( ) { const photo = photoRef.value .myPhoto const username = usernameRef.value .myUsername .trim () const profile = profileRef.value .myProfile .trim () const formData = new FormData () formData.append ('username' , username) formData.append ('profile' , profile) if (photo && photo.startsWith ('data:' )) { formData.append ('photo' , base64ToFile (photo, 'photo.png' )) } const res = await api.post ('/api/user/profile/update/' , formData) const data = res.data if (data.result === 'success' ) { user.setUserInfo (data) } }
重点:
上传文件必须使用 FormData;
新头像是 Base64,需要转成 File;
旧头像一般是 URL,不应传给 base64ToFile。
四、角色模型 Character 4.1 字段设计 角色模型用于保存 AI 角色信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import uuidfrom django.db import modelsfrom django.utils.timezone import now, localtimefrom web.models.user import UserProfiledef photo_upload_to (instance, filename ): ext = filename.split('.' )[-1 ] filename = f'{uuid.uuid4().hex [:10 ]} .{ext} ' return f'character/photos/{instance.author.user_id} /{filename} ' def background_image_upload_to (instance, filename ): ext = filename.split('.' )[-1 ] filename = f'{uuid.uuid4().hex [:10 ]} .{ext} ' return f'character/background_images/{instance.author.user_id} /{filename} ' class Character (models.Model): author = models.ForeignKey(UserProfile, on_delete=models.CASCADE) name = models.CharField(max_length=50 ) photo = models.ImageField(upload_to=photo_upload_to) profile = models.TextField(max_length=10000 ) background_image = models.ImageField(upload_to=background_image_upload_to) voice = models.CharField(max_length=50 , default='longanyang' ) create_time = models.DateTimeField(default=now) update_time = models.DateTimeField(default=now) def __str__ (self ): return f"{self.author.user.username} - {self.name} - {localtime(self.create_time).strftime('%Y-%m-%d %H:%M:%S' )} "
4.2 字段说明
字段
说明
author
创建者,指向 UserProfile
name
角色名称
photo
角色头像
profile
角色设定、人设、简介,也是后续提示词重要来源
background_image
聊天背景图
voice
TTS 语音音色
create_time
创建时间
update_time
更新时间
4.3 Admin 注册 1 2 3 @admin.register(Character ) class CharacterAdmin (admin.ModelAdmin): raw_id_fields = ('author' ,)
五、角色后端 CRUD 5.1 创建角色 接口:
1 POST /api/create/character/create/
核心逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 class CreateCharacterView (APIView ): permission_classes = [IsAuthenticated] def post (self, request ): user_profile = UserProfile.objects.get(user=request.user) name = request.data.get('name' , '' ).strip() profile = request.data.get('profile' , '' ).strip()[:10000 ] photo = request.FILES.get('photo' ) background_image = request.FILES.get('background_image' ) if not name: return Response({'result' : '名字不得为空' }) if not profile: return Response({'result' : '角色介绍不得为空' }) if not photo: return Response({'result' : '头像不得为空' }) if not background_image: return Response({'result' : '聊天背景不得为空' }) Character.objects.create( author=user_profile, name=name, profile=profile, photo=photo, background_image=background_image, ) return Response({'result' : 'success' })
5.2 更新角色 接口:
1 POST /api/create/character/update/
关键点:
必须确保只能修改自己的角色;
图片可选,有新图才替换;
替换前删除旧图。
1 character = Character.objects.get(id =character_id, author__user=request.user)
author__user=request.user 是跨表查询:
1 Character.author -> UserProfile.user -> User
5.3 删除角色 接口:
1 POST /api/create/character/remove/
建议删除前清理图片:
1 2 3 4 character = Character.objects.get(pk=character_id, author__user=request.user) remove_old_photo(character.photo) remove_old_photo(character.background_image) character.delete()
不要写成:
1 character = Character.objects.get(...).delete()
因为 .delete() 返回删除结果,不是模型实例。
5.4 获取单个角色 接口:
1 GET /api/create/character/get_single/?character_id=1
1 2 character_id = request.query_params.get('character_id' ) character = Character.objects.get(id =character_id, author__user=request.user)
返回:
1 2 3 4 5 6 7 8 9 10 { 'result' : 'success' , 'character' : { 'id' : character.id , 'name' : character.name, 'profile' : character.profile, 'photo' : character.photo.url, 'background_image' : character.background_image.url, } }
六、角色前端页面 6.1 组件拆分 1 2 3 4 5 6 7 8 frontend/src/views/create/character/ ├── CreateCharacter.vue ├── UpdateCharacter.vue └── components/ ├── Photo.vue ├── Name.vue ├── Profile.vue └── BackgroundImage.vue
6.2 创建角色页面流程 1 2 3 4 5 6 7 8 9 10 11 用户选择头像、背景图并填写信息 ↓ Croppie 裁剪图片为 Base64 ↓ 父组件读取子组件暴露的数据 ↓ base64ToFile 转为 File ↓ FormData 提交到后端 ↓ 创建成功后跳转个人空间
1 2 3 4 5 6 7 const formData = new FormData ()formData.append ('name' , name) formData.append ('profile' , profile) formData.append ('photo' , base64ToFile (photo, 'photo.png' )) formData.append ('background_image' , base64ToFile (backgroundImage, 'background.png' )) const res = await api.post ('/api/create/character/create/' , formData)
6.4 更新角色页面流程 1 2 3 4 5 6 7 8 9 10 11 进入 /create/character/update/:character_id/ ↓ 从 route.params.character_id 读取角色 ID ↓ GET get_single 拉取角色详情 ↓ 将角色数据传给子组件回显 ↓ 用户修改后提交 FormData ↓ 只有图片变化且是 data URL 时才上传新文件
6.5 更新时判断图片是否变化 1 2 3 4 5 6 7 if (photo !== character.value .photo && photo.startsWith ('data:' )) { formData.append ('photo' , base64ToFile (photo, 'photo.png' )) } if (backgroundImage !== character.value .background_image && backgroundImage.startsWith ('data:' )) { formData.append ('background_image' , base64ToFile (backgroundImage, 'background.png' )) }
七、路由汇总 7.1 后端路由 1 2 3 4 5 path('api/user/profile/update/' , UpdateProfileView.as_view()), path('api/create/character/create/' , CreateCharacterView.as_view()), path('api/create/character/update/' , UpdateCharacterView.as_view()), path('api/create/character/remove/' , RemoveCharacterView.as_view()), path('api/create/character/get_single/' , GetSingleCharacterView.as_view()),
7.2 前端路由 1 2 3 4 5 6 7 8 9 10 11 12 { path : '/user/profile/' , component : ProfileIndex , name : 'user-profile-index' , meta : { needLogin : true }, }, { path : '/create/character/update/:character_id/' , component : UpdateCharacter , name : 'update-character' , meta : { needLogin : true }, }
八、核心知识点补充 8.1 request.data、request.FILES、request.query_params
对象
使用场景
request.data
POST、PUT 请求中的 JSON 或表单字段
request.FILES
上传文件
request.query_params
GET URL 查询参数
示例:
1 2 3 name = request.data.get('name' ) photo = request.FILES.get('photo' ) character_id = request.query_params.get('character_id' )
8.2 Django 双下划线查询 1 Character.objects.get(id =character_id, author__user=request.user)
含义:
查询 Character;
它的 author 字段指向 UserProfile;
UserProfile 的 user 字段必须等于当前登录用户。
FormData 可同时提交普通字段和文件:
1 2 formData.append ('name' , name) formData.append ('photo' , file)
浏览器会自动设置合适的 Content-Type: multipart/form-data。
九、易错点总结
默认头像路径要写对:user/photos/default.png;
删除旧头像时不能删除默认头像;
后端返回图片要使用 .url;
Base64 转 File 前要确认是 data: 开头;
get() 返回对象,filter() 返回 QuerySet;
修改角色时必须用 author__user=request.user 限制权限;
删除角色时先获取实例再删图片,最后再 delete();
Croppie 组件卸载时要 destroy();
前端组件 props 异步更新时要用 watch 同步本地 ref。
十、小结 本节实现了用户资料编辑和角色 CRUD。核心是:
前端用组件化方式管理复杂表单;
图片使用 Croppie 裁剪并以 Base64 暂存;
提交前转为 File 并放入 FormData;
后端通过 request.FILES 接收文件;
更新文件时删除旧文件,避免媒体目录膨胀;
角色所有权通过 ORM 跨表查询保证安全。
十一、核心接口完整请求 / 响应示例 11.1 获取用户资料接口 1 2 GET /api/user/profile/getinfo/ Authorization : Bearer <access_token>
成功响应:
1 2 3 4 5 6 7 8 { "result" : "success" , "id" : 1 , "username" : "alice" , "photo" : "/media/user/photo/alice.png" , "background" : "/media/user/background/bg.png" , "description" : "正在学习 AI 应用开发" }
11.2 修改用户资料接口 1 2 3 POST /api/user/profile/update/ Authorization : Bearer <access_token>Content-Type : multipart/form-data
表单字段:
字段
类型
说明
username
string
用户名
description
string
个人简介
photo
file
新头像,可选
background
file
新背景图,可选
成功响应:
1 2 3 4 5 6 { "result" : "success" , "username" : "alice" , "photo" : "/media/user/photo/new.png" , "background" : "/media/user/background/new-bg.png" }
11.3 创建角色接口 1 2 3 POST /api/character/create/ Authorization : Bearer <access_token>Content-Type : multipart/form-data
表单字段:
字段
类型
说明
name
string
角色名
description
string
角色简介
profile
string
角色人设提示词
photo
file
角色头像
background
file
角色背景
成功响应:
1 2 3 4 5 { "result" : "success" , "id" : 12 , "name" : "小助手" }
11.4 删除角色接口 1 2 3 POST /api/character/remove/ Authorization : Bearer <access_token>Content-Type : application/json
请求体:
成功响应:
1 2 3 { "result" : "success" }
十二、关键代码逐行解释示例 12.1 Base64 转 File 1 2 3 4 5 6 7 8 9 10 11 function dataURLtoFile (dataUrl, filename ) { const arr = dataUrl.split (',' ) const mime = arr[0 ].match (/:(.*?);/ )[1 ] const bstr = atob (arr[1 ]) let n = bstr.length const u8arr = new Uint8Array (n) while (n--) { u8arr[n] = bstr.charCodeAt (n) } return new File ([u8arr], filename, { type : mime }) }
逐行解释:
代码
新手解释
dataUrl.split(',')
把 data:image/png;base64,... 分成头部和真实数据
match(/:(.*?);/)
从头部提取图片 MIME 类型,例如 image/png
atob(arr[1])
把 base64 字符串解码成二进制字符串
new Uint8Array(n)
创建一个字节数组,用来存图片二进制
bstr.charCodeAt(n)
把每个字符转成对应字节
new File(...)
把字节数组包装成浏览器能上传的 File 对象
12.2 后端保存上传文件 1 2 3 4 photo = request.FILES.get('photo' ) if photo: profile.photo = photo profile.save()
逐行解释:
代码
新手解释
request.FILES.get('photo')
从上传表单中取出名为 photo 的文件
if photo:
只有用户真的上传新头像时才更新
profile.photo = photo
把新文件赋值给模型字段
profile.save()
保存到数据库,同时 Django 处理文件存储
十三、Vue 组件数据传递图 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 ProfileEditView.vue │ 保存用户资料表单状态 ├── photoUrl 传给 │ ↓ │ ImageCropper.vue │ │ 用户裁剪头像 │ └── emit cropped-image │ ↓ │ ProfileEditView.vue 更新 photoFile │ └── 点击保存 ↓ FormData ↓ profileApi.js ↓ 后端 ProfileUpdateView
角色编辑类似:
1 2 3 4 5 6 7 8 9 10 11 12 CharacterEditView.vue │ name / description / profile / photo / background ↓ CharacterForm.vue │ 管理表单输入 ↓ ImageCropper.vue │ 裁剪图片 ↓ FormData ↓ CharacterCreateView 或 CharacterUpdateView
十四、后端视图处理流程 14.1 修改资料流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 收到 POST /profile/update/ ↓ IsAuthenticated 检查用户是否登录 ↓ 从 request.user 找到当前用户 ↓ 读取 username、description ↓ 从 request.FILES 读取 photo、background ↓ 如果上传了新图片,删除旧图片 ↓ 保存 User 和 UserProfile ↓ 返回最新用户资料
14.2 创建角色流程 1 2 3 4 5 6 7 8 9 10 11 12 13 收到 POST /character/create/ ↓ 检查登录状态 ↓ 读取 name、description、profile ↓ 读取 photo、background ↓ 通过 request.user.userprofile 找到作者 ↓ 创建 Character ↓ 返回角色 id 和成功信息
14.3 删除角色流程 1 2 3 4 5 6 7 8 9 10 11 12 13 收到 POST /character/remove/ ↓ 读取 character_id ↓ 查询 author__user=request.user 且 id=character_id 的角色 ↓ 如果找不到,返回无权限或角色不存在 ↓ 删除头像和背景文件 ↓ 删除数据库记录 ↓ 返回 success
十五、小练习与自测答案 练习 1 问题:为什么上传图片时不能直接用普通 JSON?
答案:JSON 适合传文本结构,不适合直接传二进制文件。图片文件通常使用 multipart/form-data,前端用 FormData 组织,后端从 request.FILES 读取。
练习 2 问题:删除角色时为什么查询条件要包含 author__user=request.user?
答案:这是权限校验。它保证用户只能删除自己创建的角色,不能通过猜测别人的 character_id 删除别人的角色。
练习 3 问题:为什么更新头像时要删除旧文件?
答案:如果不删除旧文件,媒体目录会不断堆积无用图片,占用磁盘空间。
十一、新手版:为什么图片上传比普通文本复杂 普通文本提交很简单:
1 2 3 4 { "username" : "alice" , "profile" : "你好" }
但是图片不是普通字符串,而是二进制文件。浏览器不能直接把图片像普通 JSON 一样轻松传给后端,所以需要 FormData。
可以把 FormData 想象成一个快递箱:
可以放文字纸条,例如用户名;
也可以放真实文件,例如头像;
后端收到后,从 request.data 拿文字,从 request.FILES 拿文件。
1 2 3 4 5 6 7 8 9 前端 FormData ├── username: alice ├── profile: hello └── photo: photo.png ↓ 后端 ├── request.data['username'] ├── request.data['profile'] └── request.FILES['photo']
十二、跟做步骤:编辑资料功能 12.1 后端要做什么
创建或确认 UserProfile 模型;
写删除旧图片的工具函数;
写 UpdateProfileView;
在 urls.py 中添加路由;
启动后端测试接口。
12.2 前端要做什么
创建编辑资料页面 ProfileIndex.vue;
创建头像、用户名、简介三个子组件;
头像组件中使用 Croppie;
父组件读取子组件暴露的数据;
使用 FormData 提交;
成功后更新 Pinia 中的用户信息。
12.3 如何验证 编辑资料后检查三处:
页面导航栏头像和用户名是否变化;
数据库中 User 和 UserProfile 是否变化;
backend/media/user/photos/ 中是否出现新头像。
如果上传了新头像,还要检查旧头像是否被删除。
十三、跟做步骤:创建角色功能 13.1 后端流程 1 2 3 4 5 6 7 8 9 10 11 创建 Character 模型 ↓ 执行 makemigrations ↓ 执行 migrate ↓ 注册 Admin ↓ 写 create / update / remove / get_single 接口 ↓ 配置路由
13.2 前端流程 1 2 3 4 5 6 7 8 9 10 11 12 13 创建 CreateCharacter.vue ↓ 拆分 Name / Profile / Photo / BackgroundImage 组件 ↓ 用户填写表单 ↓ 图片裁剪成 Base64 ↓ Base64 转 File ↓ FormData 上传 ↓ 创建成功后跳转个人空间
13.3 验证角色创建 成功创建角色后检查:
数据库 Character 表是否新增一条记录;
media 目录中是否有头像和背景图;
个人空间是否能展示角色卡片;
后台 Admin 是否能看到角色。
十四、新手重点:父子组件通信 14.1 props 是父传子 父组件:
1 <Username :username="user.username" />
子组件:
1 const props = defineProps (['username' ])
这叫父组件把数据传给子组件。
14.2 defineExpose 是子暴露给父 子组件:
1 2 3 const myUsername = ref (props.username )defineExpose ({ myUsername })
父组件:
1 2 const usernameRef = useTemplateRef ('username-ref' )const username = usernameRef.value .myUsername
这表示父组件可以读取子组件内部的数据。
14.3 emit 是子通知父 如果子组件要告诉父组件“我完成了某个动作”,就用 emit。
例如删除角色成功后:
1 emit ('remove' , props.character .id )
父组件监听:
1 <Character @remove="removeCharacter" />
十五、常见报错排查 15.1 后端提示 photo 为空 检查:
前端是否使用了 FormData;
是否执行了 formData.append('photo', file);
字段名是否和后端 request.FILES.get('photo') 一致;
Base64 是否成功转成 File。
15.2 图片显示不出来 检查:
后端返回的是不是 .url;
MEDIA_URL 和 MEDIA_ROOT 是否配置;
开发环境是否在根路由中加了 static('/media/', ...);
前端如果在 Vite 开发环境运行,是否需要拼接后端 BASE_URL。
15.3 修改别人的角色成功了 这是严重权限问题。检查后端查询是否限制了当前用户:
1 Character.objects.get(id =character_id, author__user=request.user)
不能只写:
1 Character.objects.get(id =character_id)
15.4 删除角色后图片还在 检查删除接口是否在 character.delete() 之前调用了:
1 2 remove_old_photo(character.photo) remove_old_photo(character.background_image)
15.5 Croppie 报重复初始化 通常是同一个 DOM 上重复 new Croppie。解决:
每次打开弹窗前销毁旧实例;
或重新创建一个 wrapper 容器;
组件卸载时 croppie?.destroy()。
十六、本节复习问题
为什么上传图片要用 FormData?
request.data 和 request.FILES 有什么区别?
Base64 图片为什么要转成 File?
ImageField 保存的是图片本身还是图片路径?
为什么更新头像时要删除旧头像?
author__user=request.user 这句查询是什么意思?
父组件如何读取子组件的数据?
子组件如何通知父组件删除某条数据?