|
@@ -0,0 +1,521 @@
|
|
|
+<template>
|
|
|
+ <div class="profile-container">
|
|
|
+ <h2>我的资料</h2>
|
|
|
+ <div v-if="isLoading && !profileData && !fetchError" class="loading-message">正在加载用户资料...</div>
|
|
|
+ <div v-if="fetchError && !profileData" class="error-message">{{ fetchError }}</div>
|
|
|
+
|
|
|
+ <div v-if="profileData && !isEditing" class="profile-details">
|
|
|
+ <div class="back-link" v-if="!isOwnProfile && viewingUserId">
|
|
|
+ <router-link :to="{ name: 'UserList' }">« 返回用户列表</router-link> <!-- 假设有UserList路由 -->
|
|
|
+ </div>
|
|
|
+ <div class="avatar-section">
|
|
|
+ <img v-if="profileData.avatar_url" :src="profileData.avatar_url" alt="User Avatar" class="avatar-preview">
|
|
|
+ <div v-else class="avatar-placeholder">无头像</div>
|
|
|
+ </div>
|
|
|
+ <h2 class="profile-nickname">{{ profileData.nickname || profileData.email }}</h2>
|
|
|
+
|
|
|
+ <div class="profile-stats" v-if="!isOwnProfile"> <!-- 仅在查看他人资料时显示关注信息 -->
|
|
|
+ <span><strong>{{ profileData.followers_count || 0 }}</strong> 关注者</span>
|
|
|
+ <span><strong>{{ profileData.following_count || 0 }}</strong> 正在关注</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <p><strong>邮箱:</strong> {{ profileData.email }}</p>
|
|
|
+ <p><strong>手机号:</strong> {{ profileData.phone_number }}</p>
|
|
|
+ <p v-if="profileData.school"><strong>学校:</strong> {{ profileData.school }}</p>
|
|
|
+ <p v-if="profileData.bio"><strong>简介:</strong> {{ profileData.bio }}</p>
|
|
|
+
|
|
|
+ <p><strong>兴趣标签:</strong></p>
|
|
|
+ <ul v-if="profileInterestsDetails.length > 0" class="interest-tags">
|
|
|
+ <li v-for="tag in profileInterestsDetails" :key="tag.id">
|
|
|
+ {{ tag.name }}
|
|
|
+ </li>
|
|
|
+ </ul>
|
|
|
+ <p v-else>{{ isOwnProfile ? '您' : '该用户' }}还没有选择兴趣标签。</p>
|
|
|
+
|
|
|
+ <div class="profile-actions">
|
|
|
+ <button v-if="isOwnProfile" @click="startEditMode" class="button-primary edit-profile-button">编辑我的资料</button>
|
|
|
+ <button
|
|
|
+ v-if="!isOwnProfile && authStore.isAuthenticated"
|
|
|
+ @click="toggleFollowUser"
|
|
|
+ :disabled="isProcessingFollow"
|
|
|
+ :class="{
|
|
|
+ 'button-follow': !isCurrentlyFollowed,
|
|
|
+ 'button-unfollow': isCurrentlyFollowed
|
|
|
+ }"
|
|
|
+ >
|
|
|
+ {{ isProcessingFollow ? '处理中...' : (isCurrentlyFollowed ? '取消关注' : '关注TA') }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <p v-if="followError" class="error-message small-error">{{ followError }}</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <form v-if="isOwnProfile && isEditing" @submit.prevent="handleProfileUpdate" class="profile-form">
|
|
|
+ <h3>编辑我的资料</h3>
|
|
|
+ <div class="form-group">
|
|
|
+ <label for="edit-nickname">昵称:</label>
|
|
|
+ <input type="text" id="edit-nickname" v-model="editableUser.nickname" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label for="edit-school">学校:</label>
|
|
|
+ <input type="text" id="edit-school" v-model="editableUser.school" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label for="edit-bio">简介:</label>
|
|
|
+ <textarea id="edit-bio" v-model="editableUser.bio"></textarea>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label for="edit-avatar">更换头像 (可选):</label>
|
|
|
+ <input type="file" id="edit-avatar" @change="handleAvatarFileChange" accept="image/*" />
|
|
|
+ <div v-if="avatarPreviewUrl" class="avatar-preview-container">
|
|
|
+ <p>新头像预览:</p>
|
|
|
+ <img :src="avatarPreviewUrl" alt="New Avatar Preview" class="avatar-preview-small">
|
|
|
+ </div>
|
|
|
+ <div v-else-if="profileData && profileData.avatar_url && !editableUser.avatar" class="avatar-preview-container">
|
|
|
+ <p>当前头像:</p>
|
|
|
+ <img :src="profileData.avatar_url" alt="Current Avatar" class="avatar-preview-small">
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>兴趣标签:</label>
|
|
|
+ <div v-if="availableTags.length > 0" class="tags-selection">
|
|
|
+ <div v-for="tag in availableTags" :key="tag.id" class="tag-item">
|
|
|
+ <input type="checkbox" :id="'tag-edit-' + tag.id" :value="tag.id" v-model="editableUser.interests" />
|
|
|
+ <label :for="'tag-edit-' + tag.id">{{ tag.name }}</label>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <p v-else-if="isLoadingTags" class="loading-message-small">正在加载可选标签...</p>
|
|
|
+ <p v-else>暂无可选标签。</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="form-actions">
|
|
|
+ <button type="submit" :disabled="isUpdating">
|
|
|
+ {{ isUpdating ? '更新中...' : '保存更改' }}
|
|
|
+ </button>
|
|
|
+ <button type="button" @click="cancelEditMode" class="cancel-button">取消</button>
|
|
|
+ </div>
|
|
|
+ <p v-if="updateErrorMessage" class="error-message">{{ updateErrorMessage }}</p>
|
|
|
+ <p v-if="updateSuccessMessage" class="success-message">{{ updateSuccessMessage }}</p>
|
|
|
+ </form>
|
|
|
+ <div v-if="!profileData && !isLoading && !fetchError" class="not-found-message">
|
|
|
+ <p>无法找到该用户,或者用户不存在。</p>
|
|
|
+ <router-link :to="{ name: 'UserList' }">返回用户列表</router-link> <!-- 假设有UserList路由 -->
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, reactive, onMounted, computed, watch } from 'vue';
|
|
|
+import apiClient from '../services/api';
|
|
|
+import { useAuthStore } from '../store/auth';
|
|
|
+import { useRoute, useRouter } from 'vue-router';
|
|
|
+
|
|
|
+const authStore = useAuthStore();
|
|
|
+const route = useRoute();
|
|
|
+const router = useRouter();
|
|
|
+
|
|
|
+const profileData = ref(null); // 存储当前正在查看的用户的资料
|
|
|
+const isLoading = ref(true);
|
|
|
+const fetchError = ref('');
|
|
|
+
|
|
|
+const isEditing = ref(false);
|
|
|
+const editableUser = reactive({
|
|
|
+ nickname: '',
|
|
|
+ school: '',
|
|
|
+ bio: '',
|
|
|
+ avatar: null,
|
|
|
+ interests: [],
|
|
|
+});
|
|
|
+const avatarPreviewUrl = ref(null);
|
|
|
+
|
|
|
+const availableTags = ref([]);
|
|
|
+const isLoadingTags = ref(false);
|
|
|
+
|
|
|
+const isUpdating = ref(false);
|
|
|
+const updateErrorMessage = ref('');
|
|
|
+const updateSuccessMessage = ref('');
|
|
|
+
|
|
|
+// 关注相关状态
|
|
|
+const isCurrentlyFollowed = ref(false); // 当前登录用户是否关注了profileData对应的用户
|
|
|
+const isProcessingFollow = ref(false);
|
|
|
+const followError = ref('');
|
|
|
+
|
|
|
+// 当前查看的profile的用户ID
|
|
|
+const viewingUserId = computed(() => route.params.id ? parseInt(route.params.id) : null);
|
|
|
+// 是否是查看自己的profile
|
|
|
+const isOwnProfile = computed(() => {
|
|
|
+ if (!authStore.isAuthenticated || !authStore.currentUser) return false;
|
|
|
+ // 如果路由有ID,则比较ID;如果没有ID (即访问 /profile),则认为是自己的
|
|
|
+ return viewingUserId.value ? viewingUserId.value === authStore.currentUser.id : true;
|
|
|
+});
|
|
|
+
|
|
|
+
|
|
|
+const profileInterestsDetails = computed(() => {
|
|
|
+ if (profileData.value && profileData.value.interests && Array.isArray(profileData.value.interests) && availableTags.value.length > 0) {
|
|
|
+ return profileData.value.interests
|
|
|
+ .map(tagId => availableTags.value.find(t => t.id === tagId))
|
|
|
+ .filter(tag => tag !== undefined);
|
|
|
+ }
|
|
|
+ return [];
|
|
|
+});
|
|
|
+
|
|
|
+const fetchAvailableTags = async () => {
|
|
|
+ isLoadingTags.value = true;
|
|
|
+ try {
|
|
|
+ const response = await apiClient.get('accounts/tags/');
|
|
|
+ availableTags.value = response.data.results || response.data;
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取兴趣标签失败:', error);
|
|
|
+ // fetchError.value = '无法加载可选兴趣标签。'; // 这个错误可能覆盖主profile的加载错误
|
|
|
+ } finally {
|
|
|
+ isLoadingTags.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const fetchUserProfileData = async (userIdToFetch) => {
|
|
|
+ isLoading.value = true;
|
|
|
+ fetchError.value = '';
|
|
|
+ profileData.value = null; // 清空旧数据
|
|
|
+ isCurrentlyFollowed.value = false; // 重置关注状态
|
|
|
+
|
|
|
+ try {
|
|
|
+ let url;
|
|
|
+ if (userIdToFetch && userIdToFetch !== authStore.currentUser?.id) { // 查看他人
|
|
|
+ url = `accounts/users/${userIdToFetch}/profile/`; // 假设获取他人profile的API
|
|
|
+ } else if (authStore.isAuthenticated) { // 查看自己
|
|
|
+ url = `accounts/profile/`;
|
|
|
+ } else { // 未登录不能查看profile
|
|
|
+ fetchError.value = '请先登录查看用户资料。';
|
|
|
+ isLoading.value = false;
|
|
|
+ if (route.name !== 'Login') router.push({ name: 'Login' }); // 如果不在登录页,则跳转
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const response = await apiClient.get(url);
|
|
|
+ profileData.value = response.data;
|
|
|
+
|
|
|
+ if (isOwnProfile.value && authStore.currentUser?.id === profileData.value.id) {
|
|
|
+ authStore.user = profileData.value;
|
|
|
+ localStorage.setItem('user', JSON.stringify(profileData.value));
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新关注按钮状态 (假设后端返回了 is_followed_by_current_user)
|
|
|
+ if (profileData.value && typeof profileData.value.is_followed_by_current_user === 'boolean') {
|
|
|
+ isCurrentlyFollowed.value = profileData.value.is_followed_by_current_user;
|
|
|
+ } else if (!isOwnProfile.value && authStore.isAuthenticated) {
|
|
|
+ isCurrentlyFollowed.value = false; // 如果API没返回,先假设未关注
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error(`获取用户ID ${userIdToFetch} 资料失败:`, error.response || error);
|
|
|
+ if (error.response && error.response.status === 404) {
|
|
|
+ fetchError.value = '未找到该用户。';
|
|
|
+ } else if (error.response && error.response.status === 401) {
|
|
|
+ fetchError.value = '请先登录。';
|
|
|
+ authStore.logout(); // 清除可能无效的token
|
|
|
+ router.push({ name: 'Login', query: { redirect: route.fullPath } });
|
|
|
+ } else {
|
|
|
+ fetchError.value = '无法加载用户资料,请稍后重试。';
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ isLoading.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+
|
|
|
+const startEditMode = () => {
|
|
|
+ if (profileData.value && isOwnProfile.value) {
|
|
|
+ editableUser.nickname = profileData.value.nickname || '';
|
|
|
+ editableUser.school = profileData.value.school || '';
|
|
|
+ editableUser.bio = profileData.value.bio || '';
|
|
|
+ editableUser.avatar = null;
|
|
|
+ avatarPreviewUrl.value = null;
|
|
|
+ editableUser.interests = profileData.value.interests && Array.isArray(profileData.value.interests) ? [...profileData.value.interests] : [];
|
|
|
+ isEditing.value = true;
|
|
|
+ updateErrorMessage.value = '';
|
|
|
+ updateSuccessMessage.value = '';
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const cancelEditMode = () => {
|
|
|
+ isEditing.value = false;
|
|
|
+ avatarPreviewUrl.value = null;
|
|
|
+ editableUser.avatar = null;
|
|
|
+};
|
|
|
+
|
|
|
+const handleAvatarFileChange = (event) => {
|
|
|
+ const file = event.target.files[0];
|
|
|
+ if (file) {
|
|
|
+ editableUser.avatar = file;
|
|
|
+ const reader = new FileReader();
|
|
|
+ reader.onload = (e) => {
|
|
|
+ avatarPreviewUrl.value = e.target.result;
|
|
|
+ };
|
|
|
+ reader.readAsDataURL(file);
|
|
|
+ } else {
|
|
|
+ editableUser.avatar = null;
|
|
|
+ avatarPreviewUrl.value = null;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const handleProfileUpdate = async () => {
|
|
|
+ if (!isOwnProfile.value) return; // 安全检查
|
|
|
+
|
|
|
+ isUpdating.value = true;
|
|
|
+ updateErrorMessage.value = '';
|
|
|
+ updateSuccessMessage.value = '';
|
|
|
+
|
|
|
+ const formDataToSubmit = new FormData();
|
|
|
+ let hasActualChanges = false; // 标记是否有实际更改
|
|
|
+
|
|
|
+ // 比较并添加已更改的字段
|
|
|
+ if (editableUser.nickname !== (profileData.value?.nickname || '')) {
|
|
|
+ formDataToSubmit.append('nickname', editableUser.nickname);
|
|
|
+ hasActualChanges = true;
|
|
|
+ }
|
|
|
+ if (editableUser.school !== (profileData.value?.school || '')) {
|
|
|
+ formDataToSubmit.append('school', editableUser.school);
|
|
|
+ hasActualChanges = true;
|
|
|
+ }
|
|
|
+ if (editableUser.bio !== (profileData.value?.bio || '')) {
|
|
|
+ formDataToSubmit.append('bio', editableUser.bio);
|
|
|
+ hasActualChanges = true;
|
|
|
+ }
|
|
|
+ if (editableUser.avatar) {
|
|
|
+ formDataToSubmit.append('avatar', editableUser.avatar);
|
|
|
+ hasActualChanges = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ const originalInterestsStr = profileData.value?.interests?.slice().sort().join(',') || '';
|
|
|
+ const newInterestsStr = editableUser.interests?.slice().sort().join(',') || '';
|
|
|
+
|
|
|
+ if (originalInterestsStr !== newInterestsStr) {
|
|
|
+ hasActualChanges = true;
|
|
|
+ // 对于FormData,如果interests是空数组,DRF的PrimaryKeyRelatedField(many=True, allow_empty=True)
|
|
|
+ // 在解析时,如果请求中没有名为'interests'的参数,它会认为这个字段没有被发送,
|
|
|
+ // PATCH操作时就不会修改它。如果想清空,客户端必须发送一个能被解析为空列表的'interests'参数。
|
|
|
+ // 一种hacky方式是发送一个空的'interests'参数 `formDataToSubmit.append('interests', '');`
|
|
|
+ // 然后后端序列化器需要特殊处理这个空字符串为清空操作。
|
|
|
+ // 更标准的做法是,如果前端要清空,就发送一个空的列表。
|
|
|
+ // 当 editableUser.interests 是 [] 时,下面的 forEach 不会执行。
|
|
|
+ if (editableUser.interests && Array.isArray(editableUser.interests)) {
|
|
|
+ if (editableUser.interests.length > 0) {
|
|
|
+ editableUser.interests.forEach(tagId => {
|
|
|
+ formDataToSubmit.append('interests', String(tagId));
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ // 如果列表为空,并且我们想明确告诉后端清空,可以发送一个特殊的空值
|
|
|
+ // 但这依赖于后端API对空M2M字段的处理。
|
|
|
+ // UserProfileSerializer的update方法中,如果interests_data是[],会执行set([])
|
|
|
+ // 所以我们需要确保后端能收到空列表。对于FormData,这意味着不发送任何interests参数,
|
|
|
+ // 或者发送一个空的interests参数,例如`formDataToSubmit.append('interests', '');`
|
|
|
+ // 这需要后端序列化器能识别这个空字符串为清空。
|
|
|
+ // 最简单的方式:如果interests为空,就不发送。这意味着不能通过此接口清空。
|
|
|
+ // **修改:如果想清空,我们必须发送一个'interests'参数,使其在validated_data中出现。**
|
|
|
+ // **即使是空列表,也要确保'interests'这个key在。对于FormData,发送一个空值的同名参数。**
|
|
|
+ formDataToSubmit.append('interests', ''); // 发送一个空参数表示意图清空或设置为空列表
|
|
|
+ // 后端 ListField (PrimaryKeyRelatedField是其子类)
|
|
|
+ // 在处理表单数据时,如果参数值是空字符串,
|
|
|
+ // 并且 allow_empty=True,它会将其视为空列表。
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果没有任何实际更改的字段被添加到FormData,则不发送请求
|
|
|
+ let formHasData = false;
|
|
|
+ for (let pair of formDataToSubmit.entries()) { // entries() 返回 [key, value] 对
|
|
|
+ formHasData = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!formHasData && !hasActualChanges) { // 双重检查,确保真的有东西要更新
|
|
|
+ updateSuccessMessage.value = '没有需要更新的内容。';
|
|
|
+ isEditing.value = false;
|
|
|
+ isUpdating.value = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await apiClient.patch('accounts/profile/', formDataToSubmit);
|
|
|
+ console.log('资料更新成功:', response.data);
|
|
|
+ updateSuccessMessage.value = '资料更新成功!';
|
|
|
+ authStore.user = response.data;
|
|
|
+ profileData.value = { ...response.data }; // 更新页面显示的profileData
|
|
|
+ localStorage.setItem('user', JSON.stringify(response.data));
|
|
|
+ isEditing.value = false;
|
|
|
+ setTimeout(() => updateSuccessMessage.value = '', 3000); // 3秒后清除成功消息
|
|
|
+ } catch (error) {
|
|
|
+ console.error('资料更新失败:', error.response ? error.response.data : error.message);
|
|
|
+ updateErrorMessage.value = error.response?.data ?
|
|
|
+ (Object.values(error.response.data).flat().join('; ') || '资料更新失败,请检查您的输入。')
|
|
|
+ : '更新时发生网络错误或服务器无响应。';
|
|
|
+ } finally {
|
|
|
+ isUpdating.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const toggleFollowUser = async () => {
|
|
|
+ if (!authStore.isAuthenticated || isOwnProfile.value || !profileData.value) return;
|
|
|
+ isProcessingFollow.value = true;
|
|
|
+ followError.value = '';
|
|
|
+ try {
|
|
|
+ // 后端API应该是 POST /api/v1/accounts/users/<id_to_follow_or_unfollow>/toggle-follow/
|
|
|
+ // 并且后端应该返回更新后的目标用户信息,包含新的 followers_count 和 is_followed_by_current_user
|
|
|
+ const response = await apiClient.post(`accounts/users/${viewingUserId.value}/toggle-follow/`);
|
|
|
+ profileData.value = response.data; // 用后端返回的完整数据更新profileData
|
|
|
+ isCurrentlyFollowed.value = profileData.value.is_followed_by_current_user; // 更新关注状态
|
|
|
+ } catch (error) {
|
|
|
+ console.error('关注/取消关注失败:', error);
|
|
|
+ followError.value = error.response?.data?.detail || '操作失败,请重试。';
|
|
|
+ } finally {
|
|
|
+ isProcessingFollow.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+
|
|
|
+// 初始化加载逻辑
|
|
|
+const loadInitialData = async () => {
|
|
|
+ isLoading.value = true;
|
|
|
+ fetchError.value = '';
|
|
|
+ await fetchAvailableTags(); // 先加载可选标签
|
|
|
+
|
|
|
+ const idToFetch = viewingUserId.value || authStore.currentUser?.id;
|
|
|
+
|
|
|
+ if (idToFetch) {
|
|
|
+ await fetchUserProfileData(idToFetch);
|
|
|
+ } else if (route.meta.requiresAuth) { // 如果是受保护路由但无法确定用户ID (例如直接访问/profile但未登录)
|
|
|
+ fetchError.value = "请先登录以查看此页面。";
|
|
|
+ // 路由守卫应该已经处理了跳转,但以防万一
|
|
|
+ if (authStore.isAuthenticated) { // 已认证但currentUser还未加载
|
|
|
+ await authStore.fetchUserProfile(); // 尝试加载当前用户信息
|
|
|
+ if (authStore.currentUser) {
|
|
|
+ await fetchUserProfileData(authStore.currentUser.id);
|
|
|
+ } else {
|
|
|
+ fetchError.value = "无法加载您的用户资料,请尝试重新登录。";
|
|
|
+ }
|
|
|
+ } else if (route.name !== 'Login') {
|
|
|
+ router.push({ name: 'Login', query: { redirect: route.fullPath }});
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ fetchError.value = "无法确定要加载的用户资料。";
|
|
|
+ }
|
|
|
+ isLoading.value = false;
|
|
|
+};
|
|
|
+
|
|
|
+onMounted(loadInitialData);
|
|
|
+
|
|
|
+watch(() => route.params.id, (newId) => {
|
|
|
+ // 当路由参数变化时 (例如从一个用户资料页跳转到另一个)
|
|
|
+ const idToFetch = newId ? parseInt(newId) : (isOwnProfile.value ? authStore.currentUser?.id : null);
|
|
|
+ if (idToFetch && idToFetch !== profileData.value?.id) { // 只有当ID真的变了才重新加载
|
|
|
+ loadInitialData(); // 重新加载所有数据
|
|
|
+ } else if (!newId && route.name === 'UserProfile' && authStore.currentUser && profileData.value?.id !== authStore.currentUser.id) {
|
|
|
+ // 从他人profile页导航到自己的/profile页
|
|
|
+ loadInitialData();
|
|
|
+ }
|
|
|
+}, { immediate: true }); // immediate: true 确保组件挂载时如果已有params.id就立即执行一次
|
|
|
+
|
|
|
+watch(() => authStore.currentUser, (newUser, oldUser) => {
|
|
|
+ // 当Pinia store中的currentUser变化时(例如登录、登出、或token刷新后重新获取了profile)
|
|
|
+ if (isOwnProfile.value && newUser && (!oldUser || newUser.id !== oldUser.id || JSON.stringify(newUser) !== JSON.stringify(profileData.value))) {
|
|
|
+ // 如果是自己的profile页,并且store中的用户数据更新了,同步到profileData
|
|
|
+ // (避免在编辑模式下覆盖用户输入)
|
|
|
+ if (!isEditing.value) {
|
|
|
+ profileData.value = { ...newUser };
|
|
|
+ }
|
|
|
+ } else if (isOwnProfile.value && !newUser && oldUser) {
|
|
|
+ // 用户登出了,但还在自己的profile页(虽然路由守卫应该会跳转)
|
|
|
+ profileData.value = null;
|
|
|
+ fetchError.value = "您已退出登录。";
|
|
|
+ }
|
|
|
+}, { deep: true });
|
|
|
+
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+/* 样式与之前版本类似,你可以根据需要调整 */
|
|
|
+.profile-container { max-width: 700px; margin: 30px auto; padding: 20px; background-color: #fff; border-radius: 12px; box-shadow: 0 6px 20px rgba(0,0,0,0.08); }
|
|
|
+.loading-message { text-align: center; padding: 20px; color: #555; }
|
|
|
+.loading-message-small { font-size: 0.9em; color: #777; }
|
|
|
+.error-message { background-color: #ffebee; color: #c62828; padding: 12px; margin-bottom: 15px; border-radius: 4px; font-size: 0.9em; border: 1px solid #ef9a9a; }
|
|
|
+.success-message { background-color: #e8f5e9; color: #2e7d32; padding: 12px; margin-bottom: 15px; border-radius: 4px; font-size: 0.9em; border: 1px solid #a5d6a7;}
|
|
|
+
|
|
|
+.profile-details { text-align: left; padding: 10px; }
|
|
|
+.back-link { margin-bottom: 20px; font-size: 0.9em; }
|
|
|
+.back-link a { color: #007bff; text-decoration: none; }
|
|
|
+.back-link a:hover { text-decoration: underline; }
|
|
|
+
|
|
|
+.profile-details .avatar-section { text-align: center; margin-bottom: 20px; }
|
|
|
+.profile-details h2.profile-nickname { text-align: center; font-size: 1.8em; margin-bottom: 10px; color: #333;}
|
|
|
+.profile-details p { margin: 12px 0; line-height: 1.7; color: #454545; }
|
|
|
+.profile-details strong { display: inline-block; min-width: 80px; color: #333; font-weight: 600; }
|
|
|
+.avatar-preview { width: 140px; height: 140px; border-radius: 50%; object-fit: cover; border: 4px solid #fff; box-shadow: 0 4px 8px rgba(0,0,0,0.15); }
|
|
|
+.avatar-placeholder { width: 140px; height: 140px; border-radius: 50%; background-color: #e9ecef; display: flex; align-items: center; justify-content: center; color: #adb5bd; border: 4px solid #fff; box-shadow: 0 4px 8px rgba(0,0,0,0.1); font-size: 2.5em; font-weight: bold;}
|
|
|
+.interest-tags { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px;}
|
|
|
+.interest-tags li { background-color: #e0e0e0; color: #333; padding: 5px 12px; border-radius: 15px; font-size: 0.9em; }
|
|
|
+.edit-profile-button { /* 重命名以区分 */
|
|
|
+ display: inline-block; /* 改为inline-block以便与其他按钮并排 */
|
|
|
+ padding: 10px 20px;
|
|
|
+ background-color: #6c757d; /* 中性色 */
|
|
|
+ color: white;
|
|
|
+ border: none;
|
|
|
+ border-radius: 5px;
|
|
|
+ cursor: pointer;
|
|
|
+ font-size: 0.95em;
|
|
|
+ margin-top: 10px;
|
|
|
+}
|
|
|
+.edit-profile-button:hover { background-color: #5a6268; }
|
|
|
+
|
|
|
+.profile-stats { display: flex; gap: 25px; margin: 20px 0; justify-content: center; font-size: 0.95em; color: #555; padding-bottom: 20px; border-bottom: 1px solid #eee;}
|
|
|
+.profile-stats span { text-align: center;}
|
|
|
+.profile-stats span strong { font-size: 1.4em; color: #333; display: block; margin-bottom: 3px; }
|
|
|
+
|
|
|
+.profile-actions { margin-top: 20px; text-align: center; }
|
|
|
+.profile-actions button {
|
|
|
+ margin: 0 8px;
|
|
|
+ padding: 10px 20px;
|
|
|
+ border-radius: 5px;
|
|
|
+ font-weight: 500;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background-color 0.2s, color 0.2s, border-color 0.2s;
|
|
|
+}
|
|
|
+.button-follow { background-color: #007bff; color: white; border: 1px solid #007bff;}
|
|
|
+.button-follow:hover { background-color: #0056b3; border-color: #0056b3;}
|
|
|
+.button-unfollow { background-color: #f8f9fa; color: #343a40; border: 1px solid #ced4da;}
|
|
|
+.button-unfollow:hover { background-color: #e2e6ea; border-color: #b1bac1;}
|
|
|
+.button-follow:disabled, .button-unfollow:disabled { opacity: 0.7; cursor: not-allowed; }
|
|
|
+
|
|
|
+
|
|
|
+.profile-form h3 { margin-top: 0; margin-bottom: 25px; border-bottom: 1px solid #eee; padding-bottom: 10px; color: #333; }
|
|
|
+.profile-form .form-group { margin-bottom: 20px; text-align: left; }
|
|
|
+.profile-form label { display: block; margin-bottom: 8px; font-weight: 600; color: #495057; }
|
|
|
+.profile-form input[type="text"],
|
|
|
+.profile-form input[type="file"],
|
|
|
+.profile-form textarea {
|
|
|
+ width: 100%;
|
|
|
+ padding: 12px;
|
|
|
+ border: 1px solid #ced4da;
|
|
|
+ border-radius: 4px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ font-size: 1em;
|
|
|
+ transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
|
|
+}
|
|
|
+.profile-form input:focus, .profile-form textarea:focus {
|
|
|
+ border-color: #80bdff;
|
|
|
+ outline: 0;
|
|
|
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
|
|
+}
|
|
|
+.profile-form input[type="file"] { padding: 9px; }
|
|
|
+.profile-form textarea { min-height: 100px; resize: vertical; }
|
|
|
+.avatar-preview-container { margin-top: 10px; text-align: left; }
|
|
|
+.avatar-preview-small { width: 80px; height: 80px; border-radius: 50%; object-fit: cover; border: 1px solid #ddd; margin-top: 5px; display: block; }
|
|
|
+.tags-selection { display: flex; flex-wrap: wrap; gap: 10px 15px; padding: 10px; border: 1px solid #eee; border-radius: 4px; margin-top: 5px; background-color: #fdfdfd;}
|
|
|
+.tag-item { display: flex; align-items: center; }
|
|
|
+.tag-item input[type="checkbox"] { margin-right: 6px; transform: scale(1.1); cursor: pointer; }
|
|
|
+.tag-item label { font-weight: normal; cursor: pointer; color: #333; }
|
|
|
+.form-actions { margin-top: 30px; display: flex; gap: 10px; justify-content: flex-start; }
|
|
|
+.form-actions button { padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; font-size: 1em; font-weight: 500; }
|
|
|
+.form-actions button[type="submit"] { background-color: #28a745; color: white; }
|
|
|
+.form-actions button[type="submit"]:disabled { background-color: #aaa; }
|
|
|
+.form-actions .cancel-button { background-color: #6c757d; color: white; }
|
|
|
+.form-actions .cancel-button:hover { background-color: #5a6268; }
|
|
|
+</style>
|