xiangqinxiaochengxu/miniprogram/pages/profile-edit/profile-edit.js
2026-04-17 21:28:56 +08:00

534 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 isLocalDevUrl(url) {
return /^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0)(:\d+)?\//i.test(url)
}
function upgradeToHttpsIfNeeded(url) {
if (/^http:\/\//i.test(url) && !isLocalDevUrl(url)) {
return url.replace(/^http:\/\//i, 'https://')
}
return url
}
function resolveImageUrl(url) {
if (!url) {
return ''
}
if (/^https?:\/\//i.test(url)) {
return upgradeToHttpsIfNeeded(url)
}
const baseUrl = getApiBaseUrl().replace(/\/api\/v1\/?$/, '')
const resolved = url.startsWith('/') ? `${baseUrl}${url}` : `${baseUrl}/${url}`
return upgradeToHttpsIfNeeded(resolved)
}
function normalizeAvatarState(userInfo) {
const avatarUrl = resolveImageUrl(userInfo.avatar_url || '')
const avatarBlurUrl = resolveImageUrl(userInfo.avatar_blur_url || '')
return {
...userInfo,
avatar_url: avatarUrl,
avatar_blur_url: avatarBlurUrl
}
}
const AUDIT_REQUIRED_FIELDS = ['personality_tags', 'hobbies', 'wechat_id', 'phone']
const PROFILE_LIMITS = {
nickname: 50,
real_name: 20,
city: 30,
job_industry: 50,
job_company: 100,
prefer_city: 30,
self_intro: 500,
wechat_id: 64,
phone: 20,
tagItem: 20,
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', normalizeAvatarState(userInfo))
}
Page({
data: {
form: {
nickname: '',
real_name: '',
gender: null,
birth_year: null,
city: '',
height: null,
education: null,
job_industry: '',
job_company: '',
income_range: null,
prefer_city: '',
self_intro: '',
personality_tags: [],
hobbies: [],
wechat_id: '',
phone: ''
},
tagsText: '',
hobbiesText: '',
avatarUrl: '',
avatarVersion: '',
genderOptions: [
{ label: '男', value: 1 },
{ label: '女', value: 2 }
],
educationOptions: [
{ label: '高中', value: 1 },
{ label: '大专', value: 2 },
{ label: '本科', value: 3 },
{ label: '硕士', value: 4 },
{ label: '博士', value: 5 }
],
incomeOptions: [
{ label: '5k以下', value: 1 },
{ label: '5k-10k', value: 2 },
{ label: '10k-20k', value: 3 },
{ label: '20k以上', value: 4 }
],
genderText: '请选择',
educationText: '请选择',
incomeText: '请选择',
loading: false,
saving: false
},
onLoad() {
this.syncPickerTexts(this.data.form)
this.syncExtraTexts(this.data.form)
this.loadProfile()
},
syncExtraTexts(form) {
this.setData({
tagsText: (form.personality_tags || []).join(''),
hobbiesText: (form.hobbies || []).join('')
})
},
parseListText(text) {
return (text || '')
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean)
},
async loadProfile() {
this.setData({ loading: true })
try {
const data = await request({ url: '/users/me' })
const form = {
...this.data.form,
...data,
gender: data.gender == null ? null : Number(data.gender),
birth_year: data.birth_year == null ? null : Number(data.birth_year),
height: data.height == null ? null : Number(data.height),
education: data.education == null ? null : Number(data.education),
income_range: data.income_range == null ? null : Number(data.income_range),
personality_tags: data.personality_tags || [],
hobbies: data.hobbies || [],
wechat_id: data.wechat_id || '',
phone: data.phone || '',
avatar_url: resolveImageUrl(data.avatar_url || ''),
avatar_blur_url: resolveImageUrl(data.avatar_blur_url || '')
}
const avatarVersion = Date.now().toString()
this.setData({
form,
avatarUrl: addCacheVersion(form.avatar_url, avatarVersion),
avatarVersion
})
this.syncPickerTexts(form)
this.syncExtraTexts(form)
} catch (err) {
console.error('Load profile edit data failed:', err)
} finally {
this.setData({ loading: false })
}
},
handleInput(e) {
const field = e.currentTarget.dataset.field
const value = e.detail.value
if (field === 'personality_tags') {
const tags = this.parseListText(value)
this.setData({
form: { ...this.data.form, personality_tags: tags },
tagsText: value
})
return
}
if (field === 'hobbies') {
const hobbies = this.parseListText(value)
this.setData({
form: { ...this.data.form, hobbies },
hobbiesText: value
})
return
}
this.setData({ [`form.${field}`]: value })
},
handlePickerChange(e) {
const field = e.currentTarget.dataset.field
const optionKeyMap = {
income_range: 'incomeOptions'
}
const options = this.data[optionKeyMap[field] || `${field}Options`]
const index = Number(e.detail.value)
if (!options || !options[index]) {
return
}
const form = {
...this.data.form,
[field]: options[index].value
}
this.setData({ form })
this.syncPickerTexts(form)
},
syncPickerTexts(form) {
this.setData({
genderText: this.getOptionLabel(this.data.genderOptions, form.gender, '请选择'),
educationText: this.getOptionLabel(this.data.educationOptions, form.education, '请选择'),
incomeText: this.getOptionLabel(this.data.incomeOptions, form.income_range, '请选择')
})
},
getOptionLabel(options, value, fallback) {
const normalizedValue = value === null || value === undefined || value === '' ? value : Number(value)
const matched = (options || []).find((item) => item.value === normalizedValue)
return matched ? matched.label : fallback
},
sanitizeText(value, maxLength) {
if (value === null || value === undefined) {
return ''
}
return String(value).trim().slice(0, maxLength)
},
sanitizeNumber(value) {
if (value === null || value === undefined || value === '') {
return null
}
const num = Number(value)
return Number.isFinite(num) ? num : null
},
buildProfilePayload() {
const payload = {
nickname: this.sanitizeText(this.data.form.nickname, PROFILE_LIMITS.nickname) || null,
real_name: this.sanitizeText(this.data.form.real_name, PROFILE_LIMITS.real_name) || null,
gender: this.sanitizeNumber(this.data.form.gender),
birth_year: this.sanitizeNumber(this.data.form.birth_year),
city: this.sanitizeText(this.data.form.city, PROFILE_LIMITS.city) || null,
height: this.sanitizeNumber(this.data.form.height),
education: this.sanitizeNumber(this.data.form.education),
job_industry: this.sanitizeText(this.data.form.job_industry, PROFILE_LIMITS.job_industry) || null,
job_company: this.sanitizeText(this.data.form.job_company, PROFILE_LIMITS.job_company) || null,
income_range: this.sanitizeNumber(this.data.form.income_range),
prefer_city: this.sanitizeText(this.data.form.prefer_city, PROFILE_LIMITS.prefer_city) || null,
self_intro: this.sanitizeText(this.data.form.self_intro, PROFILE_LIMITS.self_intro) || null,
personality_tags: Array.isArray(this.data.form.personality_tags)
? this.data.form.personality_tags.map((item) => this.sanitizeText(item, PROFILE_LIMITS.tagItem)).filter(Boolean).slice(0, PROFILE_LIMITS.listItems)
: [],
hobbies: Array.isArray(this.data.form.hobbies)
? this.data.form.hobbies.map((item) => this.sanitizeText(item, PROFILE_LIMITS.tagItem)).filter(Boolean).slice(0, PROFILE_LIMITS.listItems)
: [],
wechat_id: this.sanitizeText(this.data.form.wechat_id, PROFILE_LIMITS.wechat_id) || null,
phone: this.sanitizeText(this.data.form.phone, PROFILE_LIMITS.phone) || null
}
return payload
},
getValidationLabel(field) {
const labels = {
nickname: '昵称',
real_name: '真实姓名',
city: '所在城市',
job_industry: '行业',
job_company: '公司',
prefer_city: '期望城市',
self_intro: '个人介绍',
wechat_id: '微信号',
phone: '手机号',
personality_tags: '性格标签',
hobbies: '兴趣爱好',
gender: '性别',
birth_year: '出生年份',
height: '身高',
education: '学历',
income_range: '收入范围'
}
return labels[field] || field
},
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 rawAvatarBlurUrl = body.data.avatar_blur_url || ''
const avatarUrl = resolveImageUrl(rawAvatarUrl)
const avatarBlurUrl = resolveImageUrl(rawAvatarBlurUrl)
const avatarVersion = String(Date.now())
const form = { ...this.data.form, avatar_url: avatarUrl, avatar_blur_url: avatarBlurUrl }
const cachedProfile = cloneProfileCache() || {}
const nextProfile = normalizeAvatarState({
...cachedProfile,
...form,
avatar_url: avatarUrl,
avatar_blur_url: avatarBlurUrl,
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 {
const payload = this.buildProfilePayload()
const validationErrors = this.validateProfilePayload(payload)
if (validationErrors.length > 0) {
wx.showModal({
title: '资料有误',
content: validationErrors.slice(0, 3).join(''),
showCancel: false
})
return false
}
await request({
url: '/users/me',
method: 'PUT',
data: payload
})
const nextForm = { ...this.data.form, ...payload }
const avatarVersion = this.data.avatarVersion || Date.now().toString()
const cachedProfile = cloneProfileCache() || {}
const nextProfile = normalizeAvatarState({
...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: nextForm,
avatarUrl: addCacheVersion(nextProfile.avatar_url || '', avatarVersion),
avatarVersion
})
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) {
console.error('Save profile failed:', err)
wx.showToast({ title: err.message || '保存失败', icon: 'none' })
return false
} finally {
this.setData({ saving: false })
}
},
async submitAudit() {
const saveOk = await this.saveProfile()
if (!saveOk) {
return
}
const payload = this.buildProfilePayload()
const validationErrors = this.validateProfilePayload(payload)
if (validationErrors.length > 0) {
wx.showModal({
title: '资料有误',
content: validationErrors.slice(0, 3).join(''),
showCancel: false
})
return
}
const missingFields = AUDIT_REQUIRED_FIELDS.filter((field) => {
const value = payload[field]
if (Array.isArray(value)) {
return value.length === 0
}
if (value && typeof value === 'object') {
return Object.keys(value).length === 0
}
return value === null || value === undefined || value === ''
})
if (missingFields.length > 0) {
const labelMap = {
personality_tags: '性格标签',
hobbies: '兴趣爱好',
wechat_id: '微信号',
phone: '手机号'
}
wx.showModal({
title: '资料未完善',
content: `提交审核前请先补充:${missingFields.map((field) => labelMap[field] || field).join('、')}`,
showCancel: false
})
return
}
try {
await request({
url: '/users/submit-audit',
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.navigateBack({ delta: 1 })
}, 300)
} catch (err) {
console.error('Submit audit failed:', err)
wx.showToast({ title: err.message || '提交审核失败', icon: 'none' })
}
}
})