feat: enhance profile editing and image upload functionality
- Introduced a utility function to ensure absolute media URLs for user profile images. - Updated the user cache synchronization method to handle profile data more effectively, including avatar, nickname, phone, and MBTI. - Refactored the avatar upload process to utilize the new URL handling function, improving reliability. - Modified the super article editor to focus on image uploads instead of file uploads, enhancing user experience with clearer labeling and prompts. This update aims to streamline user profile management and improve the image upload experience in the application.
This commit is contained in:
@@ -14,6 +14,15 @@ const { uploadByApi } = require('../../utils/miniprogramUpload.js')
|
||||
|
||||
const MBTI_OPTIONS = ['INTJ', 'INFP', 'INTP', 'ENTP', 'ENFP', 'ENTJ', 'ENFJ', 'INFJ', 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', 'ISTP', 'ISFP', 'ESTP', 'ESFP']
|
||||
|
||||
/** 补齐小程序可用的绝对 URL(本地/OSS 相对路径拼 baseUrl) */
|
||||
function ensureAbsMediaUrl(u, base) {
|
||||
const s = String(u || '').trim()
|
||||
if (!s) return ''
|
||||
if (/^https?:\/\//i.test(s)) return s
|
||||
const b = String(base || '').replace(/\/$/, '')
|
||||
return b ? b + (s.startsWith('/') ? s : '/' + s) : s
|
||||
}
|
||||
|
||||
/** 首次分步完善完成后写入;与手机号+昵称齐全时自动写入,老用户免向导 */
|
||||
const PROFILE_WIZARD_DONE_KEY = 'profile_wizard_v1_done'
|
||||
|
||||
@@ -409,6 +418,49 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
/** 将接口返回的 profile data 写入 globalData.userInfo、storage;可选同步本页展示字段(头像上传时用) */
|
||||
_syncUserCacheFromProfileData(d, patchPage) {
|
||||
if (!d || typeof d !== 'object') return
|
||||
const base = app.globalData.baseUrl || ''
|
||||
const ensureAbs = (u) => ensureAbsMediaUrl(u, base)
|
||||
|
||||
if (patchPage) {
|
||||
const patch = {}
|
||||
if (d.avatar != null && String(d.avatar).trim() !== '') patch.avatar = ensureAbs(d.avatar)
|
||||
if (d.nickname != null) patch.nickname = String(d.nickname)
|
||||
if (d.phone != null) patch.phone = String(d.phone)
|
||||
if (d.wechatId != null) patch.wechatId = String(d.wechatId)
|
||||
if (d.mbti != null) {
|
||||
patch.mbti = String(d.mbti)
|
||||
const idx = MBTI_OPTIONS.indexOf(patch.mbti)
|
||||
if (idx >= 0) patch.mbtiIndex = idx
|
||||
}
|
||||
if (Object.keys(patch).length) {
|
||||
this.setData(patch, () => this._syncAvatarPreview())
|
||||
} else {
|
||||
this._syncAvatarPreview()
|
||||
}
|
||||
}
|
||||
|
||||
const u = app.globalData.userInfo
|
||||
if (!u) return
|
||||
if (d.nickname != null && String(d.nickname).trim() !== '') u.nickname = String(d.nickname).trim()
|
||||
if (d.avatar != null && String(d.avatar).trim() !== '') u.avatar = ensureAbs(d.avatar)
|
||||
if (d.phone != null && String(d.phone).trim() !== '') {
|
||||
u.phone = String(d.phone).trim()
|
||||
wx.setStorageSync('user_phone', u.phone)
|
||||
}
|
||||
if (d.wechatId != null) {
|
||||
const w = String(d.wechatId).trim()
|
||||
u.wechatId = w
|
||||
u.wechat = w
|
||||
u.wechat_id = w
|
||||
if (w) wx.setStorageSync('user_wechat', w)
|
||||
}
|
||||
if (d.mbti != null && String(d.mbti).trim() !== '') u.mbti = String(d.mbti).trim()
|
||||
wx.setStorageSync('userInfo', u)
|
||||
},
|
||||
|
||||
/** 点击头像:相册/相机选图(不使用 open-type=chooseAvatar,避免 Windows 开发者工具 http://tmp 渲染报错) */
|
||||
onTapPickAvatar() {
|
||||
const run = () => {
|
||||
@@ -437,20 +489,20 @@ Page({
|
||||
const uploadRes = await uploadByApi({ filePath, folder: 'avatars' })
|
||||
|
||||
let avatarUrl = uploadRes.data?.url || uploadRes.url
|
||||
if (avatarUrl && !avatarUrl.startsWith('http')) {
|
||||
avatarUrl = app.globalData.baseUrl + avatarUrl
|
||||
if (avatarUrl && !/^https?:\/\//i.test(avatarUrl)) {
|
||||
avatarUrl = ensureAbsMediaUrl(avatarUrl, app.globalData.baseUrl)
|
||||
}
|
||||
this.setData({ avatar: avatarUrl }, () => this._syncAvatarPreview())
|
||||
const avatarToSave = toAvatarPath(avatarUrl)
|
||||
await app.request({
|
||||
const profileRes = await app.request({
|
||||
url: '/api/miniprogram/user/profile',
|
||||
method: 'POST',
|
||||
data: { userId: app.globalData.userInfo?.id, avatar: avatarToSave },
|
||||
})
|
||||
if (app.globalData.userInfo) {
|
||||
app.globalData.userInfo.avatar = avatarUrl
|
||||
wx.setStorageSync('userInfo', app.globalData.userInfo)
|
||||
if (!profileRes?.success) {
|
||||
throw new Error(profileRes?.error || profileRes?.message || '保存头像失败')
|
||||
}
|
||||
this._syncUserCacheFromProfileData(profileRes.data, true)
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '头像已更新', icon: 'success' })
|
||||
setTimeout(() => this.generateShareCard(), 200)
|
||||
@@ -523,12 +575,7 @@ Page({
|
||||
data: payload,
|
||||
})
|
||||
wx.showToast({ title: '保存成功', icon: 'success' })
|
||||
if (app.globalData.userInfo) {
|
||||
if (payload.nickname) app.globalData.userInfo.nickname = payload.nickname
|
||||
if (res?.data?.avatar) app.globalData.userInfo.avatar = res.data.avatar
|
||||
if (payload.phone) app.globalData.userInfo.phone = payload.phone
|
||||
wx.setStorageSync('userInfo', app.globalData.userInfo)
|
||||
}
|
||||
if (res?.data) this._syncUserCacheFromProfileData(res.data, false)
|
||||
if (wizardComplete) {
|
||||
wx.setStorageSync(PROFILE_WIZARD_DONE_KEY, '1')
|
||||
this.setData({ wizardMode: false, saving: false })
|
||||
|
||||
@@ -99,49 +99,16 @@ Page({
|
||||
this.setData({ content: `${this.data.content}#链接(https://)` })
|
||||
},
|
||||
|
||||
chooseReferenceFiles() {
|
||||
/** 参考素材:仅从相册/相机选图片并上传(AI 侧按图理解) */
|
||||
chooseReferenceImages() {
|
||||
this.syncAuditMode()
|
||||
if (this.data.auditMode || app.globalData.auditMode) {
|
||||
wx.showToast({ title: '审核模式下不可用', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (typeof wx.chooseMessageFile !== 'function') {
|
||||
wx.showModal({
|
||||
title: '添加参考',
|
||||
content: '当前环境不支持从聊天记录选文件,可从相册选择图片作为参考。',
|
||||
confirmText: '选图片',
|
||||
cancelText: '取消',
|
||||
success: (r) => {
|
||||
if (r.confirm) this._pickReferenceImagesFromAlbum()
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
wx.chooseMessageFile({
|
||||
count: 15,
|
||||
type: 'all',
|
||||
success: (res) => {
|
||||
this.handlePickedFiles(res.tempFiles || [])
|
||||
},
|
||||
fail: (err) => {
|
||||
const em = String((err && err.errMsg) || '')
|
||||
if (/cancel|取消/i.test(em)) return
|
||||
console.warn('[super-article-editor] chooseMessageFile fail', err)
|
||||
wx.showModal({
|
||||
title: '无法打开会话文件',
|
||||
content:
|
||||
'开发者工具常不支持此能力,请用真机重试;或改用相册图片作为参考。',
|
||||
confirmText: '相册图片',
|
||||
cancelText: '取消',
|
||||
success: (r) => {
|
||||
if (r.confirm) this._pickReferenceImagesFromAlbum()
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
this._pickReferenceImagesFromAlbum()
|
||||
},
|
||||
|
||||
/** 相册图片兜底(路径结构与 chooseMessageFile.tempFiles 对齐) */
|
||||
_pickReferenceImagesFromAlbum() {
|
||||
const mapFromChooseMedia = (res) => {
|
||||
const files = res.tempFiles || []
|
||||
@@ -296,7 +263,7 @@ Page({
|
||||
}
|
||||
|
||||
if (this.data.attachedFiles.some((x) => x.uploading)) {
|
||||
wx.showToast({ title: '请等待文件上传完成', icon: 'none' })
|
||||
wx.showToast({ title: '请等待图片上传完成', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -322,7 +289,7 @@ Page({
|
||||
.map((x) => x.url)
|
||||
|
||||
if (!prompt && !referenceText && materialUrls.length === 0 && imageUrls.length === 0) {
|
||||
wx.showToast({ title: '请填写提示词或添加参考文件', icon: 'none' })
|
||||
wx.showToast({ title: '请填写提示词或添加参考图', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
</view>
|
||||
<view style="height: {{statusBarHeight + 44}}px;"></view>
|
||||
|
||||
<!-- AI:参考文件 + 提示词 -->
|
||||
<!-- AI:参考图片 + 提示词 -->
|
||||
<view class="card">
|
||||
<text class="section-title">AI 写文章</text>
|
||||
<text class="label">参考文件(可多选)</text>
|
||||
<text class="label">参考图片(可多选)</text>
|
||||
<view class="file-actions">
|
||||
<view class="add-file-btn" bindtap="chooseReferenceFiles">+ 添加文件</view>
|
||||
<view class="add-file-btn" bindtap="chooseReferenceImages">+ 添加图片</view>
|
||||
</view>
|
||||
<view wx:for="{{attachedFiles}}" wx:key="id" class="file-row">
|
||||
<view class="file-main">
|
||||
@@ -25,7 +25,7 @@
|
||||
</view>
|
||||
<text class="file-remove" data-id="{{item.id}}" bindtap="removeAttachedFile">移除</text>
|
||||
</view>
|
||||
<text class="hint">可选图片、TXT/MD/JSON 等文本;PDF/Office 将作为附件上传,服务端尽量抽取纯文本。单文件不超 30MB。会话文件请在真机微信内选取;开发者工具若无效可改用相册图片。</text>
|
||||
<text class="hint">从相册或相机添加参考图,可多选(单次最多 9 张),上传后参与 AI 生成。单张建议不超 30MB。</text>
|
||||
|
||||
<text class="label">提示词</text>
|
||||
<view class="textarea-wrap">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 必须调用 soul-api:`POST /api/miniprogram/upload`(multipart:file + folder)。
|
||||
* 禁止在小程序内配置 OSS AK/SK 或直传阿里云;对象存储由服务端按
|
||||
* 「管理端 → 系统设置 → OSS」中的 oss_config 写入(未配置或失败时可落本地,以后端为准)。
|
||||
* 「管理端 → 系统设置 → OSS」中的 oss_config 优先写 OSS;未配置或失败时服务端自动落本地 uploads/。
|
||||
*
|
||||
* @param {{ filePath: string, folder?: string, fileFieldName?: string }} opts
|
||||
* @returns {Promise<{ success: boolean, url?: string, data?: { url?: string } }>}
|
||||
|
||||
@@ -58,7 +58,7 @@ type Config struct {
|
||||
|
||||
// 上传目录(绝对路径,air 运行时避免相对路径解析错误)
|
||||
UploadDir string
|
||||
// UploadAllowLocalFallback 为 true 时,OSS 未配置或上传失败则落盘并用 API_BASE_URL 拼接 URL(仅建议本机调试)
|
||||
// UploadAllowLocalFallback 历史环境变量 UPLOAD_ALLOW_LOCAL_FALLBACK:上传路由已默认在未配置/失败时落本地,该字段不再参与 handler 分支(保留读取以免外部依赖 cfg 字段缺失)。
|
||||
UploadAllowLocalFallback bool
|
||||
|
||||
// 阿里云 OSS(可选):当库中 oss_config 的密钥为空或为占位符时兜底;与 LoadConfig 合并后再连 OSS、并在 GET /api/admin/settings 中回显生效值
|
||||
|
||||
@@ -73,8 +73,7 @@ func isAllowedAttachment(file *multipart.FileHeader, ct string) bool {
|
||||
|
||||
// UploadPost POST /api/upload 通用上传(multipart:file + folder)
|
||||
// 同源挂载:POST /api/miniprogram/upload、POST /api/admin/upload(管理端须 Admin JWT)
|
||||
// 默认仅写入阿里云 OSS 并返回 OSS 公网完整 URL(见 system_config.oss_config)。
|
||||
// 若需本机无 OSS 调试,可在环境变量设置 UPLOAD_ALLOW_LOCAL_FALLBACK=true,此时未配置或失败才落盘并用 API_BASE_URL 拼接。
|
||||
// 优先写入 OSS(system_config.oss_config);未配置或上传失败时自动落本地 uploads/ 并由 BaseURL 拼接可访问 URL(不再向前端返回 OSS 配置类错误)。
|
||||
func UploadPost(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -131,16 +130,7 @@ func UploadPost(c *gin.Context) {
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
|
||||
objectKey := filepath.ToSlash(filepath.Join("uploads", folder, name))
|
||||
|
||||
allowLocal := config.Get() != nil && config.Get().UploadAllowLocalFallback
|
||||
|
||||
if !oss.IsEnabled() {
|
||||
if !allowLocal {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "未配置阿里云 OSS:请在管理端「系统设置 → OSS」保存完整 oss_config(AccessKey Secret 不得为占位符)。默认仅支持上传到 OSS 并返回 OSS 公网地址。",
|
||||
})
|
||||
return
|
||||
}
|
||||
uploadPostSaveLocal(c, file, folder, name, ct)
|
||||
return
|
||||
}
|
||||
@@ -169,17 +159,6 @@ func UploadPost(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !allowLocal {
|
||||
msg := "上传至阿里云 OSS 失败,请检查 oss_config、Bucket 权限与网络"
|
||||
if uploadErr != nil {
|
||||
msg = "上传至 OSS 失败: " + uploadErr.Error()
|
||||
} else if url == "" {
|
||||
msg = "上传至 OSS 未返回公网 URL,请检查 endpoint、bucket 与可选 publicBaseUrl"
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"success": false, "error": msg})
|
||||
return
|
||||
}
|
||||
|
||||
if uploadErr != nil {
|
||||
log.Printf("upload: OSS failed objectKey=%s (local fallback): %v", objectKey, uploadErr)
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/oss"
|
||||
@@ -42,7 +41,7 @@ var (
|
||||
)
|
||||
|
||||
// UploadImagePost POST /api/miniprogram/upload/image 小程序-图片上传(支持压缩)
|
||||
// 默认仅写入 OSS 并返回公网完整 URL;环境变量 UPLOAD_ALLOW_LOCAL_FALLBACK=true 时未配置或失败才落盘。
|
||||
// 优先 OSS;未配置或失败时自动落本地 uploads/。
|
||||
// 表单:file(必填), folder(可选,默认 images), quality(可选 1-100,默认 85)
|
||||
func UploadImagePost(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
@@ -116,7 +115,6 @@ func UploadImagePost(c *gin.Context) {
|
||||
finalData = data
|
||||
}
|
||||
|
||||
allowLocal := config.Get() != nil && config.Get().UploadAllowLocalFallback
|
||||
var ossURL string
|
||||
var ossErr error
|
||||
if oss.IsEnabled() {
|
||||
@@ -135,18 +133,6 @@ func UploadImagePost(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if !allowLocal {
|
||||
msg := "未配置阿里云 OSS 或上传失败"
|
||||
if oss.IsEnabled() && ossErr != nil {
|
||||
msg = "上传至 OSS 失败: " + ossErr.Error()
|
||||
} else if !oss.IsEnabled() {
|
||||
msg = "未配置阿里云 OSS:请在管理端「系统设置 → OSS」保存完整 oss_config"
|
||||
} else {
|
||||
msg = "上传至 OSS 未返回公网地址"
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"success": false, "error": msg})
|
||||
return
|
||||
}
|
||||
if ossErr != nil {
|
||||
log.Printf("upload/image: OSS failed folder=%s name=%s (local fallback): %v", folder, name, ossErr)
|
||||
} else {
|
||||
@@ -169,7 +155,7 @@ func UploadImagePost(c *gin.Context) {
|
||||
}
|
||||
|
||||
// UploadVideoPost POST /api/miniprogram/upload/video 小程序-视频上传
|
||||
// 默认仅 OSS;UPLOAD_ALLOW_LOCAL_FALLBACK=true 时未配置或失败才落盘。
|
||||
// 优先 OSS;未配置或失败时自动落本地。
|
||||
// 表单:file(必填), folder(可选,默认 videos)
|
||||
func UploadVideoPost(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
@@ -196,30 +182,18 @@ func UploadVideoPost(c *gin.Context) {
|
||||
}
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(8), ext)
|
||||
|
||||
allowLocal := config.Get() != nil && config.Get().UploadAllowLocalFallback
|
||||
var ossURL string
|
||||
var ossErr error
|
||||
|
||||
if oss.IsEnabled() {
|
||||
src, openErr := file.Open()
|
||||
if openErr != nil {
|
||||
if !allowLocal {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "打开文件失败"})
|
||||
return
|
||||
}
|
||||
ossErr = openErr
|
||||
} else {
|
||||
ossURL, ossErr = ossUploadFile(src, folder, name)
|
||||
_ = src.Close()
|
||||
}
|
||||
} else {
|
||||
if !allowLocal {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "未配置阿里云 OSS:请在管理端「系统设置 → OSS」保存完整 oss_config",
|
||||
})
|
||||
return
|
||||
}
|
||||
ossErr = fmt.Errorf("OSS 未配置")
|
||||
}
|
||||
|
||||
@@ -235,17 +209,6 @@ func UploadVideoPost(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !allowLocal {
|
||||
msg := "上传至 OSS 失败"
|
||||
if ossErr != nil {
|
||||
msg = msg + ": " + ossErr.Error()
|
||||
} else {
|
||||
msg = "上传至 OSS 未返回公网地址"
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"success": false, "error": msg})
|
||||
return
|
||||
}
|
||||
|
||||
if ossErr != nil {
|
||||
log.Printf("upload/video: OSS skipped or failed (local fallback) folder=%s name=%s: %v", folder, name, ossErr)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user