# 後端
- 在 UserProfile 模型中加入 avatar 欄位
- 建立 API 讓用戶能上傳/更新頭像
- 設定 Django 的 MEDIA_URL 讓圖片可存取
- 確保未登入用戶無法上傳頭像
# model(新增用戶頭貼欄位)
透過擴展 User 模型(資料表) 加入 avatar 欄位,這樣可以不影響現有的 User 模型(資料表),而是額外增加一個 UserProfile 模型(資料表)來存放用戶的頭像圖片
在 Django ORM 物件關聯映射(Object-Relational Mapping)中,在 UserProfile 模型(資料表)內這樣定義:
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:
python manage.py makemigrations
python manage.py migrate
# signals(新增用戶時自動建立 Profile)
在 signals.py 內加入一個信號(signals) 來確保每次建立 User 時都會自動建立對應的 UserProfile
# signals.py
from django.db.models.signals import post_save #
from django.dispatch import receiver
...
# 在 apps.py 註冊 signals
# 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
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 為目錄名稱:
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 格式,
serializer = UserProfileSerializer(user_profile)
print(serializer.data) # {'avatar': '/media/avatars/user123.png'}
# serializers.py
# 用戶更換頭像
class UserProfileSerializer(serializers.ModelSerializer):
class Meta:
model = UserProfile
fields = ['avatar']
然後為了之後能讓用戶多了 avatar 欄位的資料能夠被序列化,在 UserSerializer 中加入 avatar 欄位,這樣就能在用戶註冊或重設密碼時一併返回頭像:
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)
UserProfile 的 user 欄位是 OneToOneField 連結到 User 資料表(Django 預設模型) ,Django 會自動在 User 模型上自動產生一個反向關聯,名稱是 userprofile(Django 預設會取小寫類名)
# 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
# urls.py
path('profile/avatar/', views.updateAvatar, name='users_profile_avatar'),
# 前端
- 允許用戶選擇圖片並預覽
- 使用 axios 上傳頭像
- Redux 即時更新用戶資訊
- 確保瀏覽器不快取舊圖片
傳統方式實作方式:
- 後端提供 POST /avatar API,負責上傳照片並回傳其網址,確保不覆蓋舊照片。
- 前端發送 /avatar requset,成功後獲取照片網址,設置
img的 src 屬性以顯示預覽。
作法缺點:
- 耗時:每次預覽都需重新上傳照片,等待時間長、影響使用體驗
- 處理預覽照片:預覽照片清除問題,不管?或用 cron job 定期處理?
因此以下將使用更為節省資源、簡單的方式
# 用戶編輯個人資料前端頁面
總共會有 3 個 State:
- avatarPic: 存放選擇的圖片 File 物件,確保上傳時
FormData包含正確的圖片 - preview: 存放 URL.createObjectURL(file) 預覽圖片,即時預覽選擇到圖片,不用等上傳
- profilePicUploading: 控制上傳狀態
// ProfilePage.js
import axios from "axios";
...
const [avatarPic, setAvatarPic] = useState("");
const [preview, setPreview] = useState("");
const [profilePicUploading, setProfilePicUploading] = useState(false);
...
handleFileChange- 選擇圖片並預覽,選擇檔案按鈕updateAvatar- 上傳新頭像,更新頭像按鈕
handleFileChange 用戶不需上傳才能看到結果,透過 URL.createObjectURL(file) 來預覽圖片,這樣用戶就能即時看到選擇的圖片
URL.createObjectURL() 為瀏覽器內建 API,能為選擇的檔案聲成一個短暫的本地 URL(類似 blob:http://localhost/xxxx):

在上傳頭像按鈕處,上傳成功後,dispatch(getUserDetails("profile")) 立即重新獲取用戶資訊,確保 Redux 狀態更新,讓此頁面立即顯示最新頭像
// 選擇新圖片時即觸發
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}
<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=<!--swig0--> />
<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.py 及 urls.py 設定好的 media/ 目錄下看到上傳的新頭像

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

