Files
soul-yongping/miniprogram/pages/profile-edit/profile-edit.js
2026-03-24 01:22:50 +08:00

528 lines
19 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.

/**
* 卡若创业派对 - 资料编辑完整版comprehensive_profile_editor_v1_1
* 温馨提示、头像、基本信息、核心联系方式、个人故事、互助需求、项目介绍
*
* 接口约定(/api/miniprogram/user/profile
* - GET ?userId= 返回 data: { avatar, nickname, mbti, region, industry, businessScale, position, skills, phone, wechatId, ... },空值统一为 ""
* - POST body普通用户提交基础字段VIP 提交全部字段(含 skills、个人故事、互助需求、项目介绍
*
* 表单展示:普通用户仅展示 温馨提示、头像、昵称、MBTI、地区、行业、业务体量、职位、核心联系方式VIP 展示全部
*/
const app = getApp()
const { toAvatarPath } = require('../../utils/util.js')
const MBTI_OPTIONS = ['INTJ', 'INFP', 'INTP', 'ENTP', 'ENFP', 'ENTJ', 'ENFJ', 'INFJ', 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', 'ISTP', 'ISFP', 'ESTP', 'ESFP']
/** 首次分步完善完成后写入;与手机号+昵称齐全时自动写入,老用户免向导 */
const PROFILE_WIZARD_DONE_KEY = 'profile_wizard_v1_done'
Page({
data: {
statusBarHeight: 44,
isVip: false,
avatar: '',
nickname: '',
shareCardPath: '', // 分享名片封面图(预生成)
mbti: '',
mbtiIndex: 0,
region: '',
industry: '',
businessScale: '',
position: '',
skills: '',
phone: '',
wechatId: '',
storyBestMonth: '',
storyAchievement: '',
storyTurning: '',
helpOffer: '',
helpNeed: '',
projectIntro: '',
mbtiOptions: MBTI_OPTIONS,
showMbtiPicker: false,
saving: false,
loading: true,
showPrivacyModal: false,
nicknameInputFocus: false,
/** 首次完善:分 3 步full=1 或未达标资料时为单页 */
wizardMode: false,
wizardStep: 1,
totalWizardSteps: 3,
/** 头像区展示:含 MBTI 默认图 */
avatarPreviewUrl: '',
},
onLoad(options) {
this._wizardRouteOpts = {
full: options?.full === '1',
wizardOff: options?.wizard === '0',
}
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44,
fromVip: options?.from === 'vip',
})
wx.showShareMenu({ withShareTimeline: true })
// 从朋友圈/分享打开且带 id跳转到名片详情member-detail
if (options?.id) {
const ref = options.ref ? `&ref=${options.ref}` : ''
wx.redirectTo({ url: `/pages/member-detail/member-detail?id=${options.id}${ref}` })
return
}
this.loadProfile()
},
async loadProfile() {
const userInfo = app.globalData.userInfo
if (!app.globalData.isLoggedIn || !userInfo?.id) {
this.setData({ loading: false })
wx.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => getApp().goBackOrToHome(), 1500)
return
}
try {
const userId = userInfo.id
const [profileRes, vipRes] = await Promise.all([
app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true }),
app.request({ url: `/api/miniprogram/vip/status?userId=${userId}`, silent: true }),
])
try {
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
} catch (_) {}
this.setData({ isVip: vipRes?.data?.isVip || false })
const res = profileRes
if (res?.success && res.data) {
const d = res.data
const v = (k) => (d[k] != null && d[k] !== '') ? String(d[k]) : ''
const mbtiIndex = MBTI_OPTIONS.indexOf(v('mbti')) >= 0 ? MBTI_OPTIONS.indexOf(v('mbti')) : 0
this.setData({
avatar: v('avatar'),
nickname: v('nickname'),
mbti: v('mbti'),
mbtiIndex,
region: v('region'),
industry: v('industry'),
businessScale: v('businessScale'),
position: v('position'),
skills: v('skills'),
phone: v('phone'),
wechatId: v('wechatId') || wx.getStorageSync('user_wechat') || '',
storyBestMonth: v('storyBestMonth'),
storyAchievement: v('storyAchievement'),
storyTurning: v('storyTurning'),
helpOffer: v('helpOffer'),
helpNeed: v('helpNeed'),
projectIntro: v('projectIntro'),
loading: false,
})
this._applyWizardModeFromProfile(d)
this._syncAvatarPreview()
setTimeout(() => this.generateShareCard(), 200)
} else {
this.setData({ loading: false })
}
} catch (e) {
this.setData({ loading: false })
}
},
goBack() { getApp().goBackOrToHome() },
/**
* 是否走三步向导:资料未「手机号+昵称」齐全且未标记完成,且非 full=1、非 VIP 开通页强制单页。
* 老用户已齐全则自动写 DONE避免重复向导。
*/
_applyWizardModeFromProfile(d) {
const ro = this._wizardRouteOpts || {}
const forceFull = ro.full === true
const forceNoWizard = ro.wizardOff === true
const phoneNum = String(d.phone || '').replace(/\D/g, '')
const phoneOk = /^1[3-9]\d{9}$/.test(phoneNum)
const nickOk = !!(d.nickname && String(d.nickname).trim())
if (phoneOk && nickOk && !wx.getStorageSync(PROFILE_WIZARD_DONE_KEY)) {
wx.setStorageSync(PROFILE_WIZARD_DONE_KEY, '1')
}
const wizardMode = !forceFull && !forceNoWizard && !wx.getStorageSync(PROFILE_WIZARD_DONE_KEY)
this.setData({ wizardMode, wizardStep: 1 })
},
onWizardPrev() {
if (this.data.wizardStep > 1) {
this.setData({ wizardStep: this.data.wizardStep - 1 })
}
},
onWizardNext() {
if (this.data.saving) return
const { wizardMode, wizardStep } = this.data
if (!wizardMode) return
if (wizardStep === 1) {
if (!(this.data.nickname || '').trim()) {
wx.showToast({ title: '请填写昵称', icon: 'none' })
return
}
this.setData({ wizardStep: 2 })
return
}
if (wizardStep === 2) {
this.setData({ wizardStep: 3 })
return
}
this._doSaveProfile({ wizardComplete: true })
},
onNicknameAreaTouch() {
if (typeof wx.requirePrivacyAuthorize !== 'function') return
wx.requirePrivacyAuthorize({
success: () => { this.setData({ nicknameInputFocus: true }) },
fail: () => {},
})
},
onNicknameBlur() { this.setData({ nicknameInputFocus: false }) },
preventMove() {},
handleAgreePrivacy() {
if (app._privacyResolve) {
app._privacyResolve({ buttonId: 'agree-btn', event: 'agree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false, nicknameInputFocus: true })
},
handleDisagreePrivacy() {
if (app._privacyResolve) {
app._privacyResolve({ event: 'disagree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false })
},
// 生成分享名片封面图(参考:头像左+昵称右,分隔线,四栏信息 5:4
async generateShareCard() {
const { avatar, nickname, region, mbti, industry, position } = this.data
const userId = app.globalData.userInfo?.id
if (!userId) return
try {
const ctx = wx.createCanvasContext('shareCardCanvas', this)
const w = 500
const h = 400
const pad = 32
// 背景(深灰卡片感)
const grd = ctx.createLinearGradient(0, 0, w, h)
grd.addColorStop(0, '#1E293B')
grd.addColorStop(1, '#0F172A')
ctx.setFillStyle(grd)
ctx.fillRect(0, 0, w, h)
// 顶部区域:左头像 + 右昵称
const avatarSize = 100
const avatarX = pad + 10
const avatarY = 50
const avatarRadius = avatarSize / 2
const rightStart = avatarX + avatarSize + 28
const drawAvatar = () => new Promise((resolve) => {
if (avatar && avatar.startsWith('http')) {
wx.downloadFile({
url: avatar,
success: (res) => {
if (res.statusCode === 200) {
ctx.save()
ctx.beginPath()
ctx.arc(avatarX + avatarRadius, avatarY + avatarRadius, avatarRadius, 0, Math.PI * 2)
ctx.clip()
ctx.drawImage(res.tempFilePath, avatarX, avatarY, avatarSize, avatarSize)
ctx.restore()
} else {
this.drawAvatarPlaceholder(ctx, avatarX, avatarY, avatarSize, nickname)
}
resolve()
},
fail: () => {
this.drawAvatarPlaceholder(ctx, avatarX, avatarY, avatarSize, nickname)
resolve()
},
})
} else {
this.drawAvatarPlaceholder(ctx, avatarX, avatarY, avatarSize, nickname)
resolve()
}
})
await drawAvatar()
ctx.setStrokeStyle('rgba(94,234,212,0.5)')
ctx.setLineWidth(2)
ctx.beginPath()
ctx.arc(avatarX + avatarRadius, avatarY + avatarRadius, avatarRadius, 0, Math.PI * 2)
ctx.stroke()
// 右侧:昵称 + 个人名片
const displayName = (nickname || '').trim() || '创业者'
ctx.setFillStyle('#ffffff')
ctx.setFontSize(26)
ctx.setTextAlign('left')
ctx.fillText(displayName, rightStart, avatarY + 36)
ctx.setFillStyle('#94A3B8')
ctx.setFontSize(13)
ctx.fillText('个人名片', rightStart, avatarY + 62)
// 分隔线
const divY = 168
ctx.setStrokeStyle('rgba(255,255,255,0.08)')
ctx.setLineWidth(1)
ctx.beginPath()
ctx.moveTo(pad, divY)
ctx.lineTo(w - pad, divY)
ctx.stroke()
// 底部四栏:地区 | MBTI行业 | 职位
const labelGray = '#64748B'
const valueWhite = '#F1F5F9'
const rowH = 52
const colW = (w - pad * 2) / 2
const truncate = (text, maxW) => {
if (!text) return ''
ctx.setFontSize(15)
let m = ctx.measureText(text)
if (m.width <= maxW) return text
for (let i = text.length - 1; i > 0; i--) {
const t = text.slice(0, i) + '…'
if (ctx.measureText(t).width <= maxW) return t
}
return text[0] + '…'
}
const maxValW = colW - 16
const items = [
{ label: '地区', value: truncate((region || '').trim() || '未填写', maxValW), x: pad },
{ label: 'MBTI', value: (mbti || '').trim() || '未填写', x: pad + colW },
{ label: '行业', value: truncate((industry || '').trim() || '未填写', maxValW), x: pad },
{ label: '职位', value: truncate((position || '').trim() || '未填写', maxValW), x: pad + colW },
]
items.forEach((item, i) => {
const row = Math.floor(i / 2)
const baseY = divY + 36 + row * rowH
ctx.setFillStyle(labelGray)
ctx.setFontSize(12)
ctx.fillText(item.label, item.x, baseY - 8)
ctx.setFillStyle(valueWhite)
ctx.setFontSize(15)
ctx.fillText(item.value, item.x, baseY + 14)
})
ctx.draw(true, () => {
wx.canvasToTempFilePath({
canvasId: 'shareCardCanvas',
destWidth: 500,
destHeight: 400,
success: (res) => {
this.setData({ shareCardPath: res.tempFilePath })
},
}, this)
})
} catch (e) {
console.warn('[ShareCard] 生成失败:', e)
}
},
drawAvatarPlaceholder(ctx, x, y, size, nickname) {
ctx.setFillStyle('rgba(94,234,212,0.2)')
ctx.beginPath()
ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2)
ctx.fill()
ctx.setFillStyle('#5EEAD4')
ctx.setFontSize(size * 0.42)
ctx.setTextAlign('center')
ctx.fillText((nickname || '?')[0], x + size / 2, y + size / 2 + size * 0.14)
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
const userId = app.globalData.userInfo?.id
const nickname = (this.data.nickname || '').trim() || '我'
const path = userId
? (ref ? `/pages/member-detail/member-detail?id=${userId}&ref=${ref}` : `/pages/member-detail/member-detail?id=${userId}`)
: (ref ? `/pages/profile-edit/profile-edit?ref=${ref}` : '/pages/profile-edit/profile-edit')
const result = {
title: `${nickname}为您分享名片`,
path,
}
if (this.data.shareCardPath) result.imageUrl = this.data.shareCardPath
return result
},
onShareTimeline() {
const ref = app.getMyReferralCode()
const userId = app.globalData.userInfo?.id
const nickname = (this.data.nickname || '').trim() || '我'
const query = userId
? (ref ? `id=${userId}&ref=${ref}` : `id=${userId}`)
: (ref ? `ref=${ref}` : '')
const result = {
title: `${nickname}为您分享名片`,
query: query || '',
}
if (this.data.shareCardPath) result.imageUrl = this.data.shareCardPath
return result
},
onNicknameInput(e) { this.setData({ nickname: e.detail.value }) },
onNicknameChange(e) { this.setData({ nickname: e.detail.value }) },
onRegionInput(e) { this.setData({ region: e.detail.value }) },
onIndustryInput(e) { this.setData({ industry: e.detail.value }) },
onBusinessScaleInput(e) { this.setData({ businessScale: e.detail.value }) },
onPositionInput(e) { this.setData({ position: e.detail.value }) },
onSkillsInput(e) { this.setData({ skills: e.detail.value }) },
onPhoneInput(e) { this.setData({ phone: e.detail.value }) },
onWechatInput(e) { this.setData({ wechatId: e.detail.value }) },
onStoryBestMonthInput(e) { this.setData({ storyBestMonth: e.detail.value }) },
onStoryAchievementInput(e) { this.setData({ storyAchievement: e.detail.value }) },
onStoryTurningInput(e) { this.setData({ storyTurning: e.detail.value }) },
onHelpOfferInput(e) { this.setData({ helpOffer: e.detail.value }) },
onHelpNeedInput(e) { this.setData({ helpNeed: e.detail.value }) },
onProjectIntroInput(e) { this.setData({ projectIntro: e.detail.value }) },
onMbtiPickerChange(e) {
const i = parseInt(e.detail.value, 10)
this.setData({ mbtiIndex: i, mbti: MBTI_OPTIONS[i] }, () => this._syncAvatarPreview())
},
_syncAvatarPreview() {
try {
const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js')
const url = resolveAvatarWithMbti(
this.data.avatar,
this.data.mbti,
app.globalData.mbtiAvatarsMap || {},
app.globalData.baseUrl || ''
)
this.setData({ avatarPreviewUrl: url || '' })
} catch (_) {
this.setData({ avatarPreviewUrl: (this.data.avatar || '').trim() })
}
},
// 微信原生 chooseAvatar 回调(点击头像直接弹出原生选择器:用微信头像/从相册选择/拍照)
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
if (!tempAvatarUrl) return
wx.showLoading({ title: '上传中...', mask: true })
try {
const uploadRes = await new Promise((resolve, reject) => {
wx.uploadFile({
url: app.globalData.baseUrl + '/api/miniprogram/upload',
filePath: tempAvatarUrl,
name: 'file',
formData: { folder: 'avatars' },
success: (r) => {
try {
const data = JSON.parse(r.data)
if (data.success) resolve(data)
else reject(new Error(data.error || '上传失败'))
} catch {
reject(new Error('解析失败'))
}
},
fail: reject,
})
})
let avatarUrl = uploadRes.data?.url || uploadRes.url
if (avatarUrl && !avatarUrl.startsWith('http')) {
avatarUrl = app.globalData.baseUrl + avatarUrl
}
this.setData({ avatar: avatarUrl }, () => this._syncAvatarPreview())
const avatarToSave = toAvatarPath(avatarUrl)
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)
}
wx.hideLoading()
wx.showToast({ title: '头像已更新', icon: 'success' })
setTimeout(() => this.generateShareCard(), 200)
} catch (err) {
wx.hideLoading()
wx.showToast({ title: err.message || '上传失败,请重试', icon: 'none' })
}
},
saveProfile() {
this._doSaveProfile({ wizardComplete: false })
},
async _doSaveProfile({ wizardComplete }) {
const userId = app.globalData.userInfo?.id
if (!userId) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
const s = (v) => (v || '').toString().trim()
const isVip = this.data.isVip
const phoneRaw = s(this.data.phone)
if (!phoneRaw) {
wx.showToast({ title: '请输入手机号', icon: 'none' })
return
}
const phoneNum = phoneRaw.replace(/\D/g, '')
if (!/^1[3-9]\d{9}$/.test(phoneNum)) {
wx.showToast({ title: '请输入正确的11位手机号', icon: 'none' })
return
}
const phoneToSave = phoneNum
this.setData({ saving: true })
try {
const payload = {
userId,
avatar: toAvatarPath(s(this.data.avatar)),
nickname: s(this.data.nickname),
mbti: s(this.data.mbti),
region: s(this.data.region),
industry: s(this.data.industry),
businessScale: s(this.data.businessScale),
position: s(this.data.position),
phone: phoneToSave,
wechatId: s(this.data.wechatId),
}
const showHelp = isVip || this.data.helpOffer || this.data.helpNeed
if (isVip) {
payload.skills = s(this.data.skills)
payload.storyBestMonth = s(this.data.storyBestMonth)
payload.storyAchievement = s(this.data.storyAchievement)
payload.storyTurning = s(this.data.storyTurning)
payload.projectIntro = s(this.data.projectIntro)
}
if (showHelp) {
payload.helpOffer = s(this.data.helpOffer)
payload.helpNeed = s(this.data.helpNeed)
}
if (payload.wechatId) wx.setStorageSync('user_wechat', payload.wechatId)
if (payload.phone) wx.setStorageSync('user_phone', payload.phone)
const hasUpdate = Object.keys(payload).some(k => k !== 'userId' && payload[k] !== '')
if (!hasUpdate) {
wx.showToast({ title: '无变更', icon: 'none' })
this.setData({ saving: false })
return
}
const res = await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
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 (wizardComplete) {
wx.setStorageSync(PROFILE_WIZARD_DONE_KEY, '1')
this.setData({ wizardMode: false, saving: false })
setTimeout(() => {
wx.redirectTo({ url: `/pages/member-detail/member-detail?id=${userId}` })
}, 400)
return
}
setTimeout(() => getApp().goBackOrToHome(), 800)
} catch (e) {
wx.showToast({ title: e.message || '保存失败', icon: 'none' })
}
this.setData({ saving: false })
},
})