Django + React 電商專案練習 [19] - 用戶頭貼更換/上傳功能

Posted by Young on 2022-12-09
Estimated Reading Time 9 Minutes
Words 2k In Total

後端

  1. 在 UserProfile 模型中加入 avatar 欄位
  2. 建立 API 讓用戶能上傳/更新頭像
  3. 設定 Django 的 MEDIA_URL 讓圖片可存取
  4. 確保未登入用戶無法上傳頭像

model(新增用戶頭貼欄位)

透過擴展 User 模型(資料表) 加入 avatar 欄位,這樣可以不影響現有的 User 模型(資料表),而是額外增加一個 UserProfile 模型(資料表)來存放用戶的頭像圖片

在 Django ORM 物件關聯映射(Object-Relational Mapping)中,在 UserProfile 模型(資料表)內這樣定義:

1
2
3
4
5
6
7
8
9
10
11
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE) # 當 User 被刪除,Profile 也一併刪除
avatar = models.ImageField(
upload_to='avatars/', # 圖片上傳的資料夾
null=True,
blank=True,
default='avatars/default_avatar.png' # 預設頭像
)

def __str__(self):
return str(self.name)

📌 OneToOneField 會自動在 User 模型產生 userprofile 屬性

  • OneToOneField(User, on_delete=models.CASCADE) 表示 每個 UserProfile 只對應一個 User
  • Django 自動在 User 物件上 建立一個 userprofile 屬性,讓我們可以這樣存取 request.user.userprofile,這樣會返回一個 UserProfile 物件

建立完 UserProfile 模型後,記得一樣要 migrate

1
2
python manage.py makemigrations
python manage.py migrate

signals(新增用戶時自動建立 Profile)

signals.py 內加入一個信號(signals) 來確保每次建立 User 時都會自動建立對應的 UserProfile

1
2
3
4
# signals.py
from django.db.models.signals import post_save #
from django.dispatch import receiver
...

apps.py 註冊 signals

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# apps.py
from django.apps import AppConfig

class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api'

def ready(self):
import api.signals

class UsersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'users'

def ready(self):
import api.signals # 確保 signals 被載入

寫測試檔

此步驟是為了測試 signals 是否有正常觸發,確保每次建立 User 時都朋自動建立對應的 UserProfile

1
2
3
4
5
6
7
8
from django.test import TestCase
from django.contrib.auth.models import User
from .models import UserProfile

class UserProfileSignalTest(TestCase):
def test_user_profile_creation_signal(self):
user = User.objects.create(username="testuser", email="test@example.com", password="password123")
self.assertTrue(UserProfile.objects.filter(user=user).exists(), "UserProfile 未正確建立")

終端機執行測試,api 為目錄名稱:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
python manage.py test api
...
# 結果
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
Signal triggered
User Created
User saved
.
----------------------------------------------------------------------
Ran 1 test in 0.013s

OK

serializer(用戶資料序列化)

由於 Django 內部使用 Python 物件 (即 UserProfile 模型),但 API 需要回傳 JSON 格式的資料。所以建立相應的 serializer 將 Django ORM 模型轉換為 JSON 格式,

比如說:

Django 模型

1
2
user_profile = UserProfile.objects.get(user=request.user)
print(user_profile.avatar) # /media/avatars/user123.png

透過 Serializer 轉換為 JSON

1
2
serializer = UserProfileSerializer(user_profile)
print(serializer.data) # {'avatar': '/media/avatars/user123.png'}
1
2
3
4
5
6
# serializers.py
# 用戶更換頭像
class UserProfileSerializer(serializers.ModelSerializer):
class Meta:
model = UserProfile
fields = ['avatar']

然後為了之後能讓用戶多了 avatar 欄位的資料能夠被序列化,在 UserSerializer 中加入 avatar 欄位,這樣就能在用戶註冊或重設密碼時一併返回頭像:

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
class UserSerializer(serializers.ModelSerializer):
...
avatar = serializers.SerializerMethodField(read_only=True)
class Meta:
model = User
fields = [...'', 'avatar']

def get_avatar(self, obj): # obj = User 的 instance
try:
if obj.userprofile.avatar: # 確保 avatar 存在
return obj.userprofile.avatar.url
except AttributeError:
pass # 若 userprofile 不存在,則直接返回預設值
return '/media/avatars/default_avatar.png' # 預設頭像
...
class UserSerializerWithToken(UserSerializer): # 繼承 UserSerializer 就不用重複寫欄位,分兩個 class 寫,這個 class 是負責產生第一次 "註冊 "或 "重設密碼 "的新 token
token = serializers.SerializerMethodField(read_only=True)

class Meta:
model = User
fields = [...,'avatar', 'token']

def get_token(self, obj): # 命名就是一定得 get_(跟欄位名稱一樣)
token = RefreshToken.for_user(obj) # 當使用者註冊或重設密碼,就會產生新的token
return str(token.access_token) # 因為token是物件,所以要轉成字串

view(用戶更新/上傳頭像 API)

UserProfileuser 欄位是 OneToOneField 連結到 User 資料表(Django 預設模型) ,Django 會自動在 User 模型上自動產生一個反向關聯,名稱是 userprofile(Django 預設會取小寫類名)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# user_views.py
@api_view(['POST'])
# @permission_classes([IsAuthenticated])
def updateAvatar(request):

# 檢查是否為匿名使用者
if isinstance(request.user, AnonymousUser):
return Response({"error": "未登入的使用者"}, status=401)

# 嘗試取得 UserProfile
try:
user_profile = request.user.userprofile
except UserProfile.DoesNotExist:
return Response({"error": "找不到 UserProfile,請確保已登入"}, status=400)

serializer = UserProfileSerializer(instance=user_profile, data=request.data, partial=True)

if serializer.is_valid():
serializer.save()
return Response({"message": "頭像更新成功", "avatar": serializer.data['avatar']})

return Response(serializer.errors, status=400)

url

1
2
# urls.py
path('profile/avatar/', views.updateAvatar, name='users_profile_avatar'),

前端

  1. 允許用戶選擇圖片並預覽
  2. 使用 axios 上傳頭像
  3. Redux 即時更新用戶資訊
  4. 確保瀏覽器不快取舊圖片

傳統方式實作方式:

  1. 後端提供 POST /avatar API,負責上傳照片並回傳其網址,確保不覆蓋舊照片。
  2. 前端發送 /avatar requset,成功後獲取照片網址,設置 img 的 src 屬性以顯示預覽。

作法缺點:

  • 耗時:每次預覽都需重新上傳照片,等待時間長、影響使用體驗
  • 處理預覽照片:預覽照片清除問題,不管?或用 cron job 定期處理?

因此以下將使用更為節省資源、簡單的方式

用戶編輯個人資料前端頁面

總共會有 3 個 State:

  • avatarPic: 存放選擇的圖片 File 物件,確保上傳時 FormData 包含正確的圖片
  • preview: 存放 URL.createObjectURL(file) 預覽圖片,即時預覽選擇到圖片,不用等上傳
  • profilePicUploading: 控制上傳狀態
1
2
3
4
5
6
7
8
// ProfilePage.js
import axios from "axios";
...

const [avatarPic, setAvatarPic] = useState("");
const [preview, setPreview] = useState("");
const [profilePicUploading, setProfilePicUploading] = useState(false);
...
  1. handleFileChange - 選擇圖片並預覽,選擇檔案按鈕
  2. updateAvatar - 上傳新頭像,更新頭像按鈕

handleFileChange 用戶不需上傳才能看到結果,透過 URL.createObjectURL(file) 來預覽圖片,這樣用戶就能即時看到選擇的圖片

URL.createObjectURL() 為瀏覽器內建 API,能為選擇的檔案聲成一個短暫的本地 URL(類似 blob:http://localhost/xxxx):

image_blob

在上傳頭像按鈕處,上傳成功後,dispatch(getUserDetails("profile")) 立即重新獲取用戶資訊,確保 Redux 狀態更新,讓此頁面立即顯示最新頭像

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
// 選擇新圖片時即觸發
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
setAvatarPic(file);
setPreview(URL.createObjectURL(file)); // 預覽圖片
}
};

// 上傳新頭像
const updateAvatar = async (file) => {
if (!preview) {
setMessage({ text: "請選擇檔案!沒檔案就不要按了==", variant: "danger" });
return;
}

const formData = new FormData();
formData.append("avatar", avatarPic);

try {
setProfilePicUploading(true);
const { data } = await axios.post("/api/users/profile/avatar/", formData, {
headers: {
"Content-Type": "multipart/form-data",
Authorization: `Bearer ${userInfo.token}`, // 確保用戶已登入
},
});
console.log("頭像更新成功", data);
setAvatarPic(""); // 清空已選圖片
dispatch(getUserDetails("profile")); // 馬上重新獲取用戶資料,更新頭像
} catch (error) {
console.error("更新失敗", error.response?.data || error.message);
} finally {
setProfilePicUploading(false);
}
};

<Image> 邏輯為若有選擇新圖檔 preview 就顯示 preview,若無則顯示此使用者的舊頭像 avatarPic 或預設頭像

<Form.Control> 負責讓使用者選擇圖片 onChange={handleFileChange}<Button> 則負責真正的與後端交互控制上傳 onClick={updateAvatar}

1
2
3
4
5
6
7
8
9
10
11
<Form.Group controlId='profilePic' className='m-3'>
<Form.Label>個人照片</Form.Label>
<div className='d-col align-items-center'>
<Image src={preview || avatarPic || "/media/avatars/default_avatar.png"} roundedCircle className='mb-2' style={{ width: "100px", height: "100px", objectFit: "cover", marginRight: "10px" }} />
<Form.Control type='file' accept='image/*' onChange={handleFileChange} disabled={profilePicUploading} />
</div>
<Button className='mt-2' onClick={updateAvatar} disabled={profilePicUploading}>
{profilePicUploading ? "上傳中..." : "上傳頭像"}
</Button>
{profilePicUploading && <Loader />}
</Form.Group>

成果

上傳頭像完成後,應就能在先前在 settings.pyurls.py 設定好的 media/ 目錄下看到上傳的新頭像

media_image

最終成功畫面,Header 及 Profile 頁面頭像皆能同步即時更新:

user_profilePic_done


若您覺得這篇文章對您有幫助,歡迎分享出去讓更多人看到⊂◉‿◉つ~


留言版