3.编辑资料与编辑角色模块

3.编辑资料与编辑角色模块

Lionlnuc
Lionlnuc Lv4

编辑资料与编辑角色模块

一、目标

本节完成两个核心能力:

  • 用户编辑资料:修改头像、用户名、个人简介;
  • 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 os
from django.conf import settings


def 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 User
from django.utils.timezone import now
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated

from web.models.user import UserProfile
from web.views.utils.photo import remove_old_photo


class 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。

3.6 父组件提交 FormData

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 uuid
from django.db import models
from django.utils.timezone import now, localtime
from web.models.user import UserProfile


def 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 提交到后端
↓
创建成功后跳转个人空间

6.3 创建角色 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 字段必须等于当前登录用户。

8.3 FormData 的特点

FormData 可同时提交普通字段和文件:

1
2
formData.append('name', name)
formData.append('photo', file)

浏览器会自动设置合适的 Content-Type: multipart/form-data。


九、易错点总结

  1. 默认头像路径要写对:user/photos/default.png;
  2. 删除旧头像时不能删除默认头像;
  3. 后端返回图片要使用 .url;
  4. Base64 转 File 前要确认是 data: 开头;
  5. get() 返回对象,filter() 返回 QuerySet;
  6. 修改角色时必须用 author__user=request.user 限制权限;
  7. 删除角色时先获取实例再删图片,最后再 delete();
  8. Croppie 组件卸载时要 destroy();
  9. 前端组件 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
{
"character_id": 12
}

成功响应:

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 后端要做什么

  1. 创建或确认 UserProfile 模型;
  2. 写删除旧图片的工具函数;
  3. 写 UpdateProfileView;
  4. 在 urls.py 中添加路由;
  5. 启动后端测试接口。

12.2 前端要做什么

  1. 创建编辑资料页面 ProfileIndex.vue;
  2. 创建头像、用户名、简介三个子组件;
  3. 头像组件中使用 Croppie;
  4. 父组件读取子组件暴露的数据;
  5. 使用 FormData 提交;
  6. 成功后更新 Pinia 中的用户信息。

12.3 如何验证

编辑资料后检查三处:

  1. 页面导航栏头像和用户名是否变化;
  2. 数据库中 User 和 UserProfile 是否变化;
  3. 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 验证角色创建

成功创建角色后检查:

  1. 数据库 Character 表是否新增一条记录;
  2. media 目录中是否有头像和背景图;
  3. 个人空间是否能展示角色卡片;
  4. 后台 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 为空

检查:

  1. 前端是否使用了 FormData;
  2. 是否执行了 formData.append('photo', file);
  3. 字段名是否和后端 request.FILES.get('photo') 一致;
  4. Base64 是否成功转成 File。

15.2 图片显示不出来

检查:

  1. 后端返回的是不是 .url;
  2. MEDIA_URL 和 MEDIA_ROOT 是否配置;
  3. 开发环境是否在根路由中加了 static('/media/', ...);
  4. 前端如果在 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()。

十六、本节复习问题

  1. 为什么上传图片要用 FormData?
  2. request.data 和 request.FILES 有什么区别?
  3. Base64 图片为什么要转成 File?
  4. ImageField 保存的是图片本身还是图片路径?
  5. 为什么更新头像时要删除旧头像?
  6. author__user=request.user 这句查询是什么意思?
  7. 父组件如何读取子组件的数据?
  8. 子组件如何通知父组件删除某条数据?
  • 标题: 3.编辑资料与编辑角色模块
  • 作者: Lionlnuc
  • 创建于 : 2026-07-12 00:00:00
  • 更新于 : 2026-07-12 00:00:00
  • 链接: https://lionlnuc.github.io/2026/07/12/AgentTalk/3. 编辑资料、编辑角色模块/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论
目录
3.编辑资料与编辑角色模块