From 3108a7b903b1da957125f145a61eace03cf56f0f Mon Sep 17 00:00:00 2001 From: taiyi Date: Fri, 17 Apr 2026 19:26:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=B4=E5=83=8F=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + backend/app/main.py | 7 + backend/app/schemas/user.py | 2 + backend/app/services/upload_service.py | 32 ++- backend/app/services/user_service.py | 2 + .../pages/profile-edit/profile-edit.js | 191 +++++++++++++++++- .../pages/profile-edit/profile-edit.wxml | 6 + .../pages/profile-edit/profile-edit.wxss | 29 +++ miniprogram/pages/profile/profile.js | 54 +++-- 9 files changed, 300 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 7f56dcb..74963ed 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ vendor/ packages/ backend/.venv + # 编译/打包产物 dist/ build/ @@ -49,6 +50,7 @@ coverage/ # 上传文件 /uploads /backend/uploads +/backend/static # python环境 diff --git a/backend/app/main.py b/backend/app/main.py index 19e18e1..7ca97c4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,10 @@ +from pathlib import Path + from fastapi import FastAPI, HTTPException, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles from app.config import settings from app.routers import api_router @@ -78,6 +81,10 @@ def create_app() -> FastAPI: }, ) + static_dir = Path(__file__).resolve().parent / "static" + static_dir.mkdir(parents=True, exist_ok=True) + app.mount("/static", StaticFiles(directory=static_dir), name="static") + app.include_router(api_router, prefix=settings.api_v1_prefix) return app diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index aeacb25..4c0ea0e 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -64,6 +64,8 @@ class UserUpdateRequest(BaseModel): hobbies: list[str] | None = None wechat_id: str | None = Field(default=None, max_length=PROFILE_LIMITS["wechat_id"]) phone: str | None = Field(default=None, max_length=PROFILE_LIMITS["phone"]) + avatar_url: str | None = Field(default=None, max_length=500) + avatar_blur_url: str | None = Field(default=None, max_length=500) value_answers: dict[str, Any] | None = None prefer_age_min: int | None = None prefer_age_max: int | None = None diff --git a/backend/app/services/upload_service.py b/backend/app/services/upload_service.py index b2ca4ff..162246f 100644 --- a/backend/app/services/upload_service.py +++ b/backend/app/services/upload_service.py @@ -11,10 +11,10 @@ from PIL import Image, ImageFilter from sqlalchemy.ext.asyncio import AsyncSession from app.models.user import User -from app.services.cos_service import build_cos_url ALLOWED_IMAGE_TYPES = {"jpeg", "png"} +UPLOAD_ROOT = Path(__file__).resolve().parents[2] / "static" / "uploads" def _validate_image(content: bytes) -> str: @@ -44,6 +44,21 @@ def _save_optimized_variants(content: bytes) -> tuple[str, str, str]: return main_path, blur_path, timestamp +def _persist_avatar_files(user_id: int, timestamp: str, main_path: str, blur_path: str) -> tuple[str, str]: + user_dir = UPLOAD_ROOT / "avatars" / str(user_id) + user_dir.mkdir(parents=True, exist_ok=True) + + avatar_name = f"{timestamp}.jpg" + blur_name = f"{timestamp}_blur.jpg" + avatar_path = user_dir / avatar_name + blur_target_path = user_dir / blur_name + + avatar_path.write_bytes(Path(main_path).read_bytes()) + blur_target_path.write_bytes(Path(blur_path).read_bytes()) + + return avatar_name, blur_name + + async def upload_user_avatar(session: AsyncSession, user: User, file: UploadFile) -> dict: content = await file.read() if not content: @@ -52,14 +67,15 @@ async def upload_user_avatar(session: AsyncSession, user: User, file: UploadFile raise ValueError("图片大小不能超过 5MB") main_path, blur_path, timestamp = _save_optimized_variants(content) - base_key = f"avatars/{user.id}/{timestamp}" try: - avatar_key = f"{base_key}.jpg" - blur_key = f"{base_key}_blur.jpg" + avatar_name, blur_name = _persist_avatar_files(user.id, timestamp, main_path, blur_path) + avatar_url = f"/static/uploads/avatars/{user.id}/{avatar_name}" + blur_url = f"/static/uploads/avatars/{user.id}/{blur_name}" + + user.avatar_url = avatar_url + user.avatar_blur_url = blur_url - user.avatar_url = build_cos_url(avatar_key) - user.avatar_blur_url = build_cos_url(blur_key) session.add(user) await session.commit() await session.refresh(user) @@ -68,8 +84,8 @@ async def upload_user_avatar(session: AsyncSession, user: User, file: UploadFile "avatar_url": user.avatar_url, "avatar_blur_url": user.avatar_blur_url, "local_files": { - "avatar": Path(main_path).name, - "blur": Path(blur_path).name, + "avatar": avatar_name, + "blur": blur_name, }, } finally: diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 7c3bb8c..9dca70a 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -107,6 +107,8 @@ def _sanitize_update_value(field: str, value): "phone", "prefer_city", "self_intro", + "avatar_url", + "avatar_blur_url", } and isinstance(value, str): value = value.strip() return value or None diff --git a/miniprogram/pages/profile-edit/profile-edit.js b/miniprogram/pages/profile-edit/profile-edit.js index d825e5d..eca6dcb 100644 --- a/miniprogram/pages/profile-edit/profile-edit.js +++ b/miniprogram/pages/profile-edit/profile-edit.js @@ -1,4 +1,21 @@ const request = require('../../utils/request') +const { getApiBaseUrl } = require('../../utils/constants') + +function addCacheVersion(url, version) { + if (!url) { + return '' + } + const joiner = url.includes('?') ? '&' : '?' + return `${url}${joiner}v=${encodeURIComponent(version)}` +} + +function getProfileCache() { + return wx.getStorageSync('profile_page_cache') || null +} + +function setProfileCache(userInfo) { + wx.setStorageSync('profile_page_cache', userInfo) +} const AUDIT_REQUIRED_FIELDS = ['personality_tags', 'hobbies', 'wechat_id', 'phone'] const PROFILE_LIMITS = { @@ -15,6 +32,16 @@ const PROFILE_LIMITS = { listItems: 20 } +const MAX_AVATAR_SIZE = 5 * 1024 * 1024 + +function cloneProfileCache() { + return wx.getStorageSync('profile_page_cache') || null +} + +function saveProfileCache(userInfo) { + wx.setStorageSync('profile_page_cache', userInfo) +} + Page({ data: { form: { @@ -37,6 +64,8 @@ Page({ }, tagsText: '', hobbiesText: '', + avatarUrl: '', + avatarVersion: '', genderOptions: [ { label: '男', value: 1 }, { label: '女', value: 2 } @@ -96,9 +125,15 @@ Page({ personality_tags: data.personality_tags || [], hobbies: data.hobbies || [], wechat_id: data.wechat_id || '', - phone: data.phone || '' + phone: data.phone || '', + avatar_url: data.avatar_url || '' } - this.setData({ form }) + const avatarVersion = Date.now().toString() + this.setData({ + form, + avatarUrl: addCacheVersion(form.avatar_url, avatarVersion), + avatarVersion + }) this.syncPickerTexts(form) this.syncExtraTexts(form) } catch (err) { @@ -277,6 +312,130 @@ Page({ return errors }, + chooseAvatar() { + wx.chooseMedia({ + count: 1, + mediaType: ['image'], + sourceType: ['album', 'camera'], + success: (res) => { + const tempFilePath = res.tempFiles && res.tempFiles[0] && res.tempFiles[0].tempFilePath + if (tempFilePath) { + this.uploadAvatar(tempFilePath) + } + }, + fail: (err) => { + if (err && !String(err.errMsg || '').includes('cancel')) { + wx.showToast({ title: '请选择头像图片', icon: 'none' }) + } + } + }) + }, + + uploadAvatar(tempFilePath) { + wx.getFileInfo({ + filePath: tempFilePath, + success: (info) => { + if (info.size > MAX_AVATAR_SIZE) { + wx.showToast({ title: '头像不能超过 5MB', icon: 'none' }) + return + } + wx.uploadFile({ + url: `${getApiBaseUrl()}/upload/avatar`, + filePath: tempFilePath, + name: 'file', + header: { + Authorization: wx.getStorageSync('token') ? `Bearer ${wx.getStorageSync('token')}` : '' + }, + success: (res) => { + try { + const body = JSON.parse(res.data) + if (body.code !== 0) { + throw new Error(body.message || '上传失败') + } + const rawAvatarUrl = body.data.avatar_url || '' + const avatarUrl = rawAvatarUrl.startsWith('http') + ? rawAvatarUrl + : `${getApiBaseUrl()}${rawAvatarUrl}` + const avatarVersion = String(Date.now()) + const form = { ...this.data.form, avatar_url: avatarUrl } + const cachedProfile = cloneProfileCache() || {} + const nextProfile = { + ...cachedProfile, + ...form, + avatar_url: avatarUrl, + avatar_blur_url: body.data.avatar_blur_url || cachedProfile.avatar_blur_url || '', + nicknameText: form.nickname || cachedProfile.nicknameText || '未设置昵称', + auditStatusText: cachedProfile.auditStatusText || '待审核' + } + saveProfileCache(nextProfile) + this.setData({ form, avatarUrl: addCacheVersion(avatarUrl, avatarVersion), avatarVersion }) + const pages = getCurrentPages() + const prevPage = pages[pages.length - 2] + if (prevPage && typeof prevPage.applyUserInfo === 'function') { + prevPage.applyUserInfo(nextProfile) + } + wx.showToast({ title: '头像已更新', icon: 'success' }) + } catch (err) { + wx.showToast({ title: err.message || '上传失败', icon: 'none' }) + } + }, + fail: () => { + wx.showToast({ title: '上传失败,请重试', icon: 'none' }) + } + }) + }, + fail: () => { + wx.showToast({ title: '无法读取图片大小', icon: 'none' }) + } + }) + }, + + validateProfilePayload(payload) { + const errors = [] + const checkTextLength = (field, maxLength) => { + const value = payload[field] + if (typeof value === 'string' && value.length > maxLength) { + errors.push(`${this.getValidationLabel(field)}不能超过${maxLength}个字符`) + } + } + + checkTextLength('nickname', PROFILE_LIMITS.nickname) + checkTextLength('real_name', PROFILE_LIMITS.real_name) + checkTextLength('city', PROFILE_LIMITS.city) + checkTextLength('job_industry', PROFILE_LIMITS.job_industry) + checkTextLength('job_company', PROFILE_LIMITS.job_company) + checkTextLength('prefer_city', PROFILE_LIMITS.prefer_city) + checkTextLength('self_intro', PROFILE_LIMITS.self_intro) + checkTextLength('wechat_id', PROFILE_LIMITS.wechat_id) + checkTextLength('phone', PROFILE_LIMITS.phone) + + ;['personality_tags', 'hobbies'].forEach((field) => { + const list = payload[field] + const label = this.getValidationLabel(field) + if (!Array.isArray(list)) { + errors.push(`${label}格式不正确`) + return + } + if (list.length > PROFILE_LIMITS.listItems) { + errors.push(`${label}最多${PROFILE_LIMITS.listItems}项`) + } + list.forEach((item) => { + if (item.length > PROFILE_LIMITS.tagItem) { + errors.push(`${label}单项不能超过${PROFILE_LIMITS.tagItem}个字符`) + } + }) + }) + + ;['gender', 'birth_year', 'height', 'education', 'income_range'].forEach((field) => { + const value = payload[field] + if (value !== null && value !== undefined && !Number.isFinite(value)) { + errors.push(`${this.getValidationLabel(field)}格式不正确`) + } + }) + + return errors + }, + async saveProfile() { this.setData({ saving: true }) try { @@ -296,10 +455,27 @@ Page({ method: 'PUT', data: payload }) + const nextForm = { ...this.data.form, ...payload } + const avatarVersion = this.data.avatarVersion || Date.now().toString() + const cachedProfile = cloneProfileCache() || {} + const nextProfile = { + ...cachedProfile, + ...nextForm, + avatar_url: nextForm.avatar_url || cachedProfile.avatar_url || '', + avatar_blur_url: nextForm.avatar_blur_url || cachedProfile.avatar_blur_url || '' + } + saveProfileCache(nextProfile) this.setData({ - form: { ...this.data.form, ...payload } + form: nextForm, + avatarUrl: addCacheVersion(nextForm.avatar_url || '', avatarVersion), + avatarVersion }) - this.syncPickerTexts(this.data.form) + const pages = getCurrentPages() + const prevPage = pages[pages.length - 2] + if (prevPage && typeof prevPage.applyUserInfo === 'function') { + prevPage.applyUserInfo(nextProfile) + } + this.syncPickerTexts(nextForm) wx.showToast({ title: '保存成功', icon: 'success' }) return true } catch (err) { @@ -360,8 +536,13 @@ Page({ method: 'POST' }) wx.showToast({ title: '已提交审核', icon: 'success' }) + const pages = getCurrentPages() + const prevPage = pages[pages.length - 2] + if (prevPage && typeof prevPage.loadProfile === 'function') { + prevPage.loadProfile() + } setTimeout(() => { - wx.navigateTo({ url: '/pages/audit-status/audit-status' }) + wx.navigateBack({ delta: 1 }) }, 300) } catch (err) { console.error('Submit audit failed:', err) diff --git a/miniprogram/pages/profile-edit/profile-edit.wxml b/miniprogram/pages/profile-edit/profile-edit.wxml index 85b555c..0d7d571 100644 --- a/miniprogram/pages/profile-edit/profile-edit.wxml +++ b/miniprogram/pages/profile-edit/profile-edit.wxml @@ -1,5 +1,11 @@ + + + 点此上传 + 点击更换头像 + + 昵称 diff --git a/miniprogram/pages/profile-edit/profile-edit.wxss b/miniprogram/pages/profile-edit/profile-edit.wxss index d0a025e..706daa8 100644 --- a/miniprogram/pages/profile-edit/profile-edit.wxss +++ b/miniprogram/pages/profile-edit/profile-edit.wxss @@ -8,6 +8,35 @@ padding: 24rpx; } +.avatar-section { + display: flex; + flex-direction: column; + align-items: center; + padding: 16rpx 0 32rpx; +} + +.avatar-image { + width: 160rpx; + height: 160rpx; + border-radius: 50%; + background: #f2f3f5; + display: flex; + align-items: center; + justify-content: center; + color: #999999; + font-size: 24rpx; +} + +.avatar-placeholder { + border: 2rpx dashed #d9d9d9; +} + +.avatar-hint { + margin-top: 16rpx; + color: #999999; + font-size: 24rpx; +} + .form-item { padding: 20rpx 0; border-bottom: 1rpx solid #f0f0f0; diff --git a/miniprogram/pages/profile/profile.js b/miniprogram/pages/profile/profile.js index 30dad2d..99bd423 100644 --- a/miniprogram/pages/profile/profile.js +++ b/miniprogram/pages/profile/profile.js @@ -1,5 +1,13 @@ const request = require('../../utils/request') +function addCacheVersion(url, version) { + if (!url) { + return '' + } + const joiner = url.includes('?') ? '&' : '?' + return `${url}${joiner}v=${encodeURIComponent(version)}` +} + Page({ data: { loading: false, @@ -13,25 +21,47 @@ Page({ }, onShow() { - this.loadProfile() + this.refreshFromCache() + this.loadProfile(true) }, - async loadProfile() { - this.setData({ loading: true }) + async loadProfile(silent = false) { + if (!silent) { + this.setData({ loading: true }) + } try { const userInfo = await request({ url: '/users/me' }) - this.setData({ - userInfo: { - ...userInfo, - nicknameText: userInfo.nickname || '未设置昵称', - auditStatusText: this.data.auditTextMap[userInfo.audit_status] || '未知' - } - }) + this.applyUserInfo(userInfo) + wx.setStorageSync('profile_page_cache', userInfo) } catch (err) { console.error('Load profile failed:', err) - this.setData({ userInfo: null }) + if (!this.data.userInfo) { + this.setData({ userInfo: null }) + } } finally { - this.setData({ loading: false }) + if (!silent) { + this.setData({ loading: false }) + } + } + }, + + applyUserInfo(userInfo) { + const avatarVersion = Date.now().toString() + this.setData({ + userInfo: { + ...userInfo, + avatar_url: addCacheVersion(userInfo.avatar_url || '', avatarVersion), + avatar_blur_url: addCacheVersion(userInfo.avatar_blur_url || '', avatarVersion), + nicknameText: userInfo.nickname || '未设置昵称', + auditStatusText: this.data.auditTextMap[userInfo.audit_status] || '未知' + } + }) + }, + + refreshFromCache() { + const cached = wx.getStorageSync('profile_page_cache') + if (cached) { + this.applyUserInfo(cached) } },