feat(upload): 头像直传COS + 修复用户资料接口404
后端: - Model层新增UploadSetting配置模型 - Core层新增IUploadConfigService/UploadConfigService,从Admin库读取COS配置生成预签名URL - Api层新增UploadController,提供POST /api/upload/presignedUrl接口 - ServiceModule注册UploadConfigService服务 前端: - api/user.js修复接口路径:updateProfileupdate_userinfo,upload/imageupload/presignedUrl - 新增utils/upload.js COS直传工具(获取预签名URL直传COS返回文件URL) - 个人资料页改为:选图直传COS保存时提交headimg URL到update_userinfo
This commit is contained in:
parent
5454ac5f64
commit
3f179e5682
|
|
@ -0,0 +1,76 @@
|
|||
using System.Security.Claims;
|
||||
using MiAssessment.Core.Interfaces;
|
||||
using MiAssessment.Model.Base;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MiAssessment.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 上传控制器 - 提供COS预签名URL供小程序直传
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api")]
|
||||
public class UploadController : ControllerBase
|
||||
{
|
||||
private readonly IUploadConfigService _uploadConfigService;
|
||||
private readonly ILogger<UploadController> _logger;
|
||||
|
||||
public UploadController(
|
||||
IUploadConfigService uploadConfigService,
|
||||
ILogger<UploadController> logger)
|
||||
{
|
||||
_uploadConfigService = uploadConfigService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取COS预签名上传URL
|
||||
/// POST /api/upload/presignedUrl
|
||||
/// 小程序端拿到URL后直传COS,上传完成后将fileUrl提交给update_userinfo
|
||||
/// </summary>
|
||||
[HttpPost("upload/presignedUrl")]
|
||||
[Authorize]
|
||||
public async Task<ApiResponse<PresignedUploadInfo>> GetPresignedUrl([FromBody] GetPresignedUrlRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.FileName))
|
||||
{
|
||||
return ApiResponse<PresignedUploadInfo>.Fail("文件名不能为空");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _uploadConfigService.GetPresignedUploadUrlAsync(
|
||||
request.FileName,
|
||||
request.ContentType ?? "image/png");
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return ApiResponse<PresignedUploadInfo>.Fail("当前不支持COS直传,请联系管理员配置上传设置");
|
||||
}
|
||||
|
||||
return ApiResponse<PresignedUploadInfo>.Success(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "获取预签名URL失败");
|
||||
return ApiResponse<PresignedUploadInfo>.Fail("获取上传地址失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取预签名URL请求
|
||||
/// </summary>
|
||||
public class GetPresignedUrlRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 原始文件名
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件MIME类型
|
||||
/// </summary>
|
||||
public string? ContentType { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
namespace MiAssessment.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// 上传配置服务接口
|
||||
/// 从Admin库读取上传配置,生成COS预签名URL
|
||||
/// </summary>
|
||||
public interface IUploadConfigService
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取COS预签名上传URL
|
||||
/// </summary>
|
||||
/// <param name="fileName">原始文件名</param>
|
||||
/// <param name="contentType">文件MIME类型</param>
|
||||
/// <returns>预签名URL信息,null表示不支持COS直传</returns>
|
||||
Task<PresignedUploadInfo?> GetPresignedUploadUrlAsync(string fileName, string contentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预签名上传信息
|
||||
/// </summary>
|
||||
public class PresignedUploadInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 预签名上传URL
|
||||
/// </summary>
|
||||
public string UploadUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件最终访问URL
|
||||
/// </summary>
|
||||
public string FileUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// URL过期时间(秒)
|
||||
/// </summary>
|
||||
public int ExpiresIn { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using MiAssessment.Core.Interfaces;
|
||||
using MiAssessment.Model.Data;
|
||||
using MiAssessment.Model.Models.Config;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MiAssessment.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 上传配置服务实现
|
||||
/// 从Admin库读取COS配置,生成预签名URL供小程序直传
|
||||
/// </summary>
|
||||
public class UploadConfigService : IUploadConfigService
|
||||
{
|
||||
private readonly AdminConfigReadDbContext _adminConfigDbContext;
|
||||
private readonly IRedisService _redisService;
|
||||
private readonly ILogger<UploadConfigService> _logger;
|
||||
|
||||
private const string CacheKey = "upload:setting";
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
private const string UploadBasePath = "uploads";
|
||||
private const int DefaultExpiresInSeconds = 600;
|
||||
|
||||
public UploadConfigService(
|
||||
AdminConfigReadDbContext adminConfigDbContext,
|
||||
IRedisService redisService,
|
||||
ILogger<UploadConfigService> logger)
|
||||
{
|
||||
_adminConfigDbContext = adminConfigDbContext;
|
||||
_redisService = redisService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PresignedUploadInfo?> GetPresignedUploadUrlAsync(string fileName, string contentType)
|
||||
{
|
||||
var setting = await GetUploadSettingAsync();
|
||||
if (setting == null || setting.Type != "3")
|
||||
{
|
||||
_logger.LogWarning("上传配置不支持COS直传,当前类型: {Type}", setting?.Type);
|
||||
return null;
|
||||
}
|
||||
|
||||
var validationError = ValidateConfig(setting);
|
||||
if (validationError != null)
|
||||
{
|
||||
_logger.LogWarning("COS配置验证失败: {Error}", validationError);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 生成日期目录和唯一文件名
|
||||
var now = DateTime.Now;
|
||||
var datePath = $"{now.Year}/{now.Month:D2}/{now.Day:D2}";
|
||||
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
var timestamp = now.ToString("yyyyMMddHHmmssfff");
|
||||
var guid = Guid.NewGuid().ToString("N")[..8];
|
||||
var uniqueFileName = $"{timestamp}_{guid}{extension}";
|
||||
var objectKey = $"{UploadBasePath}/{datePath}/{uniqueFileName}";
|
||||
|
||||
// 生成预签名URL
|
||||
var presignedUrl = GeneratePresignedUrl(setting, objectKey, "PUT", contentType, DefaultExpiresInSeconds);
|
||||
var fileUrl = GenerateAccessUrl(setting.Domain!, objectKey);
|
||||
|
||||
_logger.LogInformation("生成预签名URL成功: {ObjectKey}", objectKey);
|
||||
|
||||
return new PresignedUploadInfo
|
||||
{
|
||||
UploadUrl = presignedUrl,
|
||||
FileUrl = fileUrl,
|
||||
ExpiresIn = DefaultExpiresInSeconds
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从数据库读取上传配置(带缓存)
|
||||
/// </summary>
|
||||
private async Task<UploadSetting?> GetUploadSettingAsync()
|
||||
{
|
||||
// 尝试从缓存读取
|
||||
var cachedJson = await _redisService.GetStringAsync(CacheKey);
|
||||
if (!string.IsNullOrEmpty(cachedJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<UploadSetting>(cachedJson, JsonOptions);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var configValue = await _adminConfigDbContext.AdminConfigs
|
||||
.Where(c => c.ConfigKey == "upload_setting")
|
||||
.Select(c => c.ConfigValue)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (string.IsNullOrEmpty(configValue))
|
||||
{
|
||||
_logger.LogWarning("未找到upload_setting配置");
|
||||
return null;
|
||||
}
|
||||
|
||||
var setting = JsonSerializer.Deserialize<UploadSetting>(configValue, JsonOptions);
|
||||
if (setting != null)
|
||||
{
|
||||
await _redisService.SetStringAsync(CacheKey, configValue, CacheDuration);
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "读取上传配置失败");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成腾讯云COS预签名URL
|
||||
/// </summary>
|
||||
private static string GeneratePresignedUrl(UploadSetting setting, string objectKey, string httpMethod, string contentType, int expiresInSeconds)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var endTime = startTime + expiresInSeconds;
|
||||
var keyTime = $"{startTime};{endTime}";
|
||||
|
||||
var host = $"{setting.Bucket}.cos.{setting.Region}.myqcloud.com";
|
||||
var urlPath = $"/{objectKey}";
|
||||
|
||||
// 1. SignKey
|
||||
var signKey = HmacSha1(setting.AccessKeySecret!, keyTime);
|
||||
|
||||
// 2. HttpString
|
||||
var httpString = $"{httpMethod.ToLowerInvariant()}\n{urlPath}\n\nhost={host.ToLowerInvariant()}\n";
|
||||
|
||||
// 3. StringToSign
|
||||
var sha1HttpString = Sha1Hash(httpString);
|
||||
var stringToSign = $"sha1\n{keyTime}\n{sha1HttpString}\n";
|
||||
|
||||
// 4. Signature
|
||||
var signature = HmacSha1(signKey, stringToSign);
|
||||
|
||||
// 5. Authorization
|
||||
var authorization = $"q-sign-algorithm=sha1&q-ak={setting.AccessKeyId}&q-sign-time={keyTime}&q-key-time={keyTime}&q-header-list=host&q-url-param-list=&q-signature={signature}";
|
||||
|
||||
return $"https://{host}{urlPath}?{authorization}";
|
||||
}
|
||||
|
||||
private static string GenerateAccessUrl(string domain, string objectKey)
|
||||
{
|
||||
var normalizedDomain = domain.TrimEnd('/');
|
||||
if (!normalizedDomain.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!normalizedDomain.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalizedDomain = $"https://{normalizedDomain}";
|
||||
}
|
||||
var normalizedKey = objectKey.StartsWith('/') ? objectKey : $"/{objectKey}";
|
||||
return $"{normalizedDomain}{normalizedKey}";
|
||||
}
|
||||
|
||||
private static string HmacSha1(string key, string data)
|
||||
{
|
||||
using var hmac = new HMACSHA1(Encoding.UTF8.GetBytes(key));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data));
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Sha1Hash(string data)
|
||||
{
|
||||
var hash = SHA1.HashData(Encoding.UTF8.GetBytes(data));
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? ValidateConfig(UploadSetting setting)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(setting.Bucket)) return "Bucket不能为空";
|
||||
if (string.IsNullOrWhiteSpace(setting.Region)) return "Region不能为空";
|
||||
if (string.IsNullOrWhiteSpace(setting.AccessKeyId)) return "AccessKeyId不能为空";
|
||||
if (string.IsNullOrWhiteSpace(setting.AccessKeySecret)) return "AccessKeySecret不能为空";
|
||||
if (string.IsNullOrWhiteSpace(setting.Domain)) return "Domain不能为空";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
}
|
||||
|
|
@ -191,6 +191,17 @@ public class ServiceModule : Module
|
|||
return new SystemService(configService, logger);
|
||||
}).As<ISystemService>().InstancePerLifetimeScope();
|
||||
|
||||
// ========== 上传模块服务注册 ==========
|
||||
|
||||
// 注册上传配置服务(从Admin库读取COS配置,生成预签名URL)
|
||||
builder.Register(c =>
|
||||
{
|
||||
var adminConfigDbContext = c.Resolve<AdminConfigReadDbContext>();
|
||||
var redisService = c.Resolve<IRedisService>();
|
||||
var logger = c.Resolve<ILogger<UploadConfigService>>();
|
||||
return new UploadConfigService(adminConfigDbContext, redisService, logger);
|
||||
}).As<IUploadConfigService>().InstancePerLifetimeScope();
|
||||
|
||||
// ========== 小程序团队模块服务注册 ==========
|
||||
|
||||
// 注册团队服务
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
namespace MiAssessment.Model.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 上传配置(Model层,供Core/Api项目使用)
|
||||
/// </summary>
|
||||
public class UploadSetting
|
||||
{
|
||||
/// <summary>
|
||||
/// 存储类型 1本地 2阿里云 3腾讯云
|
||||
/// </summary>
|
||||
public string Type { get; set; } = "1";
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯云AppId
|
||||
/// </summary>
|
||||
public string? AppId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 存储桶名称
|
||||
/// </summary>
|
||||
public string? Bucket { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地域
|
||||
/// </summary>
|
||||
public string? Region { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SecretId
|
||||
/// </summary>
|
||||
public string? AccessKeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SecretKey
|
||||
/// </summary>
|
||||
public string? AccessKeySecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问域名
|
||||
/// </summary>
|
||||
public string? Domain { get; set; }
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { get, post } from './request'
|
|||
|
||||
/**
|
||||
* 获取当前登录用户信息
|
||||
* GET /api/userInfo
|
||||
* @returns {Promise<Object>} 用户信息
|
||||
*/
|
||||
export function getUserInfo() {
|
||||
|
|
@ -13,55 +14,30 @@ export function getUserInfo() {
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
*/
|
||||
export async function getUserDetail(userId) {
|
||||
const response = await post('/users/detail', { userId })
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户资料
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getProfile() {
|
||||
const response = await get('/user/getProfile')
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户资料
|
||||
* @param {Object} data - 用户资料
|
||||
* 更新用户信息(昵称、头像等)
|
||||
* POST /api/update_userinfo
|
||||
* @param {Object} data - 更新数据
|
||||
* @param {string} [data.nickname] - 昵称
|
||||
* @param {string} [data.headimg] - 头像URL(COS地址)
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function updateProfile(data) {
|
||||
const response = await post('/user/updateProfile', data)
|
||||
return response
|
||||
export function updateUserInfo(data) {
|
||||
return post('/update_userinfo', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户头像
|
||||
* @param {string} avatar - 头像URL
|
||||
* @returns {Promise<Object>}
|
||||
* 获取COS预签名上传URL
|
||||
* POST /api/upload/presignedUrl
|
||||
* @param {string} fileName - 文件名
|
||||
* @param {string} [contentType] - MIME类型
|
||||
* @returns {Promise<Object>} { uploadUrl, fileUrl, expiresIn }
|
||||
*/
|
||||
export async function updateAvatar(avatar) {
|
||||
const response = await post('/user/updateAvatar', { avatar })
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户昵称
|
||||
*/
|
||||
export async function updateNickname(nickname) {
|
||||
const response = await post('/users/nickname', { nickname })
|
||||
return response
|
||||
export function getPresignedUploadUrl(fileName, contentType = 'image/png') {
|
||||
return post('/upload/presignedUrl', { fileName, contentType })
|
||||
}
|
||||
|
||||
export default {
|
||||
getUserDetail,
|
||||
getProfile,
|
||||
updateProfile,
|
||||
updateAvatar,
|
||||
updateNickname
|
||||
getUserInfo,
|
||||
updateUserInfo,
|
||||
getPresignedUploadUrl
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<view class="avatar-wrapper">
|
||||
<image
|
||||
class="avatar"
|
||||
:src="userInfo.avatar || '/static/mine/icon-user.png'"
|
||||
:src="avatarUrl || '/static/mine/icon-user.png'"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<image class="edit-icon" src="/static/mine/icon-user-icon-edit.png" mode="aspectFit" />
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
<input
|
||||
class="form-input readonly"
|
||||
type="text"
|
||||
:value="userInfo.uid || '--'"
|
||||
:value="userStore.uid || '--'"
|
||||
disabled
|
||||
/>
|
||||
</view>
|
||||
|
|
@ -56,14 +56,14 @@
|
|||
<script setup>
|
||||
/**
|
||||
* 个人资料页面
|
||||
* 展示和修改用户头像、昵称
|
||||
* 头像直传COS,昵称通过update_userinfo接口保存
|
||||
* UID 仅展示不可修改
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useUserStore } from '@/store/user.js'
|
||||
import { updateProfile, updateAvatar } from '@/api/user.js'
|
||||
import config from '@/config/index.js'
|
||||
import { updateUserInfo } from '@/api/user.js'
|
||||
import { chooseAndUploadImage } from '@/utils/upload.js'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
|
|
@ -72,112 +72,66 @@ const loading = ref(false)
|
|||
const saving = ref(false)
|
||||
const loadingText = ref('加载中...')
|
||||
|
||||
// 头像URL(本地临时展示 + 上传后的COS地址)
|
||||
const avatarUrl = ref('')
|
||||
// 待提交的COS头像地址(上传成功后赋值)
|
||||
const pendingAvatarUrl = ref('')
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
nickname: ''
|
||||
})
|
||||
|
||||
// 用户信息
|
||||
const userInfo = computed(() => ({
|
||||
userId: userStore.userId,
|
||||
uid: userStore.uid,
|
||||
nickname: userStore.nickname,
|
||||
avatar: userStore.avatar
|
||||
}))
|
||||
|
||||
/**
|
||||
* 初始化表单数据
|
||||
*/
|
||||
function initFormData() {
|
||||
formData.nickname = userStore.nickname || ''
|
||||
avatarUrl.value = userStore.avatar || ''
|
||||
pendingAvatarUrl.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择并上传头像
|
||||
* 选择并上传头像到COS
|
||||
*/
|
||||
function handleChangeAvatar() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: async (res) => {
|
||||
const tempFilePath = res.tempFilePaths[0]
|
||||
await uploadAvatar(tempFilePath)
|
||||
},
|
||||
fail: (err) => {
|
||||
if (err.errMsg && !err.errMsg.includes('cancel')) {
|
||||
uni.showToast({ title: '选择图片失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传头像
|
||||
* @param {string} filePath - 图片临时路径
|
||||
*/
|
||||
async function uploadAvatar(filePath) {
|
||||
async function handleChangeAvatar() {
|
||||
try {
|
||||
loading.value = true
|
||||
loadingText.value = '上传中...'
|
||||
|
||||
const uploadRes = await new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: `${config.API_BASE_URL}/upload/image`,
|
||||
filePath: filePath,
|
||||
name: 'file',
|
||||
header: {
|
||||
'Authorization': `Bearer ${userStore.token}`
|
||||
},
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
try {
|
||||
resolve(JSON.parse(res.data))
|
||||
} catch (e) {
|
||||
reject(new Error('解析响应失败'))
|
||||
}
|
||||
} else {
|
||||
reject(new Error('上传失败'))
|
||||
}
|
||||
},
|
||||
fail: (err) => reject(err)
|
||||
})
|
||||
})
|
||||
const fileUrl = await chooseAndUploadImage()
|
||||
|
||||
if (uploadRes.code === 0 && uploadRes.data) {
|
||||
const avatarUrl = uploadRes.data.url || uploadRes.data
|
||||
const updateRes = await updateAvatar(avatarUrl)
|
||||
// 上传成功,更新预览和待提交地址
|
||||
avatarUrl.value = fileUrl
|
||||
pendingAvatarUrl.value = fileUrl
|
||||
|
||||
if (updateRes.code === 0) {
|
||||
userStore.updateUserInfo({ avatar: avatarUrl })
|
||||
uni.showToast({ title: '头像更新成功', icon: 'success' })
|
||||
} else {
|
||||
throw new Error(updateRes.message || '更新头像失败')
|
||||
}
|
||||
} else {
|
||||
throw new Error(uploadRes.message || '上传失败')
|
||||
}
|
||||
uni.showToast({ title: '头像上传成功', icon: 'success' })
|
||||
} catch (error) {
|
||||
console.error('上传头像失败:', error)
|
||||
uni.showToast({ title: error.message || '上传失败', icon: 'none' })
|
||||
if (error.message !== '用户取消选择') {
|
||||
console.error('上传头像失败:', error)
|
||||
uni.showToast({ title: error.message || '上传失败', icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存资料(昵称)
|
||||
* 保存资料
|
||||
* 提交昵称和头像URL到 POST /api/update_userinfo
|
||||
*/
|
||||
async function handleSave() {
|
||||
const nickname = formData.nickname.trim()
|
||||
const hasNicknameChange = nickname && nickname !== userStore.nickname
|
||||
const hasAvatarChange = !!pendingAvatarUrl.value
|
||||
|
||||
if (!nickname) {
|
||||
uni.showToast({ title: '请输入昵称', icon: 'none' })
|
||||
if (!hasNicknameChange && !hasAvatarChange) {
|
||||
uni.showToast({ title: '资料未修改', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
if (nickname === userStore.nickname) {
|
||||
uni.showToast({ title: '资料未修改', icon: 'none' })
|
||||
if (!nickname) {
|
||||
uni.showToast({ title: '请输入昵称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -186,10 +140,21 @@ async function handleSave() {
|
|||
loading.value = true
|
||||
loadingText.value = '保存中...'
|
||||
|
||||
const res = await updateProfile({ nickname })
|
||||
// 构建请求参数(对应后端 UpdateUserInfoRequest)
|
||||
const params = {}
|
||||
if (hasNicknameChange) params.nickname = nickname
|
||||
if (hasAvatarChange) params.headimg = pendingAvatarUrl.value
|
||||
|
||||
const res = await updateUserInfo(params)
|
||||
|
||||
if (res.code === 0) {
|
||||
userStore.updateUserInfo({ nickname })
|
||||
// 更新本地store
|
||||
const updateData = {}
|
||||
if (hasNicknameChange) updateData.nickname = nickname
|
||||
if (hasAvatarChange) updateData.avatar = pendingAvatarUrl.value
|
||||
userStore.updateUserInfo(updateData)
|
||||
|
||||
pendingAvatarUrl.value = ''
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
} else {
|
||||
throw new Error(res.message || '保存失败')
|
||||
|
|
@ -217,7 +182,6 @@ onMounted(() => {
|
|||
<style lang="scss" scoped>
|
||||
@import '@/styles/variables.scss';
|
||||
|
||||
// 保存按钮橙色(设计图配色)
|
||||
$save-btn-color: #F5A623;
|
||||
$save-btn-active: #E09518;
|
||||
|
||||
|
|
@ -230,7 +194,7 @@ $save-btn-active: #E09518;
|
|||
padding: $spacing-lg $spacing-xl;
|
||||
}
|
||||
|
||||
// 头像区域 - 居中
|
||||
// 头像区域
|
||||
.avatar-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
|||
74
uniapp/utils/upload.js
Normal file
74
uniapp/utils/upload.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* COS直传工具
|
||||
* 通过预签名URL将文件直传到腾讯云COS
|
||||
*/
|
||||
|
||||
import { getPresignedUploadUrl } from '@/api/user.js'
|
||||
|
||||
/**
|
||||
* 选择图片并上传到COS
|
||||
* @param {Object} [options] - 选项
|
||||
* @param {number} [options.count=1] - 选择数量
|
||||
* @param {string[]} [options.sourceType] - 来源类型
|
||||
* @returns {Promise<string>} 上传后的文件URL
|
||||
*/
|
||||
export async function chooseAndUploadImage(options = {}) {
|
||||
const { count = 1, sourceType = ['album', 'camera'] } = options
|
||||
|
||||
// 1. 选择图片
|
||||
const chooseRes = await new Promise((resolve, reject) => {
|
||||
uni.chooseImage({
|
||||
count,
|
||||
sizeType: ['compressed'],
|
||||
sourceType,
|
||||
success: resolve,
|
||||
fail: (err) => {
|
||||
if (err.errMsg && err.errMsg.includes('cancel')) {
|
||||
reject(new Error('用户取消选择'))
|
||||
} else {
|
||||
reject(new Error('选择图片失败'))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const tempFilePath = chooseRes.tempFilePaths[0]
|
||||
const fileName = tempFilePath.split('/').pop() || 'image.png'
|
||||
|
||||
// 判断文件类型
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || 'png'
|
||||
const mimeMap = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' }
|
||||
const contentType = mimeMap[ext] || 'image/png'
|
||||
|
||||
// 2. 获取预签名URL
|
||||
const presignedRes = await getPresignedUploadUrl(fileName, contentType)
|
||||
if (!presignedRes || presignedRes.code !== 0 || !presignedRes.data) {
|
||||
throw new Error(presignedRes?.message || '获取上传地址失败')
|
||||
}
|
||||
|
||||
const { uploadUrl, fileUrl } = presignedRes.data
|
||||
|
||||
// 3. 直传COS(使用PUT请求)
|
||||
await new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: uploadUrl,
|
||||
filePath: tempFilePath,
|
||||
name: 'file',
|
||||
header: {
|
||||
'Content-Type': contentType
|
||||
},
|
||||
success: (res) => {
|
||||
// COS PUT上传成功返回200
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(res)
|
||||
} else {
|
||||
reject(new Error(`上传失败,状态码: ${res.statusCode}`))
|
||||
}
|
||||
},
|
||||
fail: (err) => reject(new Error(err.errMsg || '上传失败'))
|
||||
})
|
||||
})
|
||||
|
||||
// 4. 返回文件访问URL
|
||||
return fileUrl
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user