# 後端

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

# 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)

UserProfileuser 欄位是 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'),

# 前端

  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: 控制上傳狀態
// 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 狀態更新,讓此頁面立即顯示最新頭像

// 選擇新圖片時即觸發
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.pyurls.py 設定好的 media/ 目錄下看到上傳的新頭像

media_image

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

user_profilePic_done