diff --git a/apps/mini-user/src/lib/mini-wechat-profile.ts b/apps/mini-user/src/lib/mini-wechat-profile.ts index 0821958..a401a97 100644 --- a/apps/mini-user/src/lib/mini-wechat-profile.ts +++ b/apps/mini-user/src/lib/mini-wechat-profile.ts @@ -1,7 +1,5 @@ import Taro from '@tarojs/taro'; -import { request } from './api'; - -import type { UserProfile } from './api'; +import { API_BASE, CLIENT_APP, getToken, request, type UserProfile } from './api'; export type MiniWechatProfile = { nickname?: string; @@ -32,26 +30,81 @@ export function getCachedWxProfile(): MiniWechatProfile | null { } export function isDefaultMiniNickname(nickname?: string | null): boolean { - if (!nickname || nickname === '访客') return true; + if (!nickname || nickname === '访客' || nickname === '微信用户' || nickname === '用户') return true; return /^用户\d{4}$/.test(nickname); } +/** 是否缺少可展示的微信头像/昵称(需走 chooseAvatar + nickname 填写) */ +export function needsWxProfileFill(profile: UserProfile | null | undefined): boolean { + if (!profile) return true; + return !profile.avatarUrl || isDefaultMiniNickname(profile.nickname); +} + export function mergeWxDisplayProfile(profile: UserProfile): UserProfile { - if (!profile.hasWechat) return profile; const cached = getCachedWxProfile(); - if (!cached) return profile; + if (!cached && !profile.hasWechat) return profile; + const nickname = + (!isDefaultMiniNickname(profile.nickname) ? profile.nickname : undefined) || + cached?.nickname || + profile.nickname || + '微信用户'; return { ...profile, - nickname: - cached.nickname || - (!isDefaultMiniNickname(profile.nickname) ? profile.nickname : undefined) || - profile.nickname || - '微信用户', - avatarUrl: profile.avatarUrl || cached.avatarUrl || null, + nickname, + avatarUrl: profile.avatarUrl || cached?.avatarUrl || null, }; } -/** 用户点击触发:拉取微信昵称/头像 */ +/** 上传 chooseAvatar 临时文件到 OSS,返回永久 URL */ +export async function uploadAvatarTempFile(tempFilePath: string): Promise { + const token = getToken(); + if (!token) throw new Error('请先登录'); + + const res = await Taro.uploadFile({ + url: `${API_BASE}/common/resources/upload`, + filePath: tempFilePath, + name: 'file', + formData: { + bizType: 'AVATAR', + mediaType: 'IMAGE', + }, + header: { + Authorization: `Bearer ${token}`, + 'X-Client-App': CLIENT_APP, + }, + }); + + let body: { code?: number; message?: string; data?: { url?: string } } = {}; + try { + body = JSON.parse(String(res.data || '{}')) as typeof body; + } catch { + throw new Error('头像上传响应异常'); + } + if (res.statusCode === 401 || body.code === 401) { + throw new Error(body.message || '登录已过期,请重新登录'); + } + if (res.statusCode >= 400 || body.code !== 0 || !body.data?.url) { + throw new Error(body.message || '头像上传失败'); + } + return body.data.url; +} + +export async function uploadMiniWechatProfile(info: MiniWechatProfile): Promise { + if (!info.nickname && !info.avatarUrl) return null; + const updated = await request('/auth/wechat/mini-profile', { + method: 'POST', + data: info, + }); + cacheWxProfile({ + nickname: updated?.nickname ?? info.nickname, + avatarUrl: updated?.avatarUrl ?? info.avatarUrl, + }); + return updated; +} + +/** + * @deprecated getUserProfile 已收回真实头像昵称,仅作兼容;小程序请用 chooseAvatar + nickname + */ export async function fetchMiniWechatUserInfo(): Promise { if (process.env.TARO_ENV !== 'weapp') { throw new Error('请在微信小程序中授权'); @@ -64,45 +117,22 @@ export async function fetchMiniWechatUserInfo(): Promise { if (!info.nickname && !info.avatarUrl) { throw new Error('未获取到微信头像或昵称'); } + // 灰色默认头像 / 「微信用户」视为无效,需走填写能力 + if (info.nickname === '微信用户' || !info.avatarUrl) { + throw new Error('请使用头像昵称填写能力完善资料'); + } cacheWxProfile(info); return info; } -export async function uploadMiniWechatProfile(info: MiniWechatProfile): Promise { - if (!info.nickname && !info.avatarUrl) return null; - try { - const updated = await request<{ - nickname?: string | null; - avatarUrl?: string | null; - }>('/auth/wechat/mini-profile', { - method: 'POST', - data: info, - }); - if (updated?.nickname || updated?.avatarUrl) { - cacheWxProfile({ - nickname: updated.nickname ?? info.nickname, - avatarUrl: updated.avatarUrl ?? info.avatarUrl, - }); - } - return info; - } catch { - return null; - } -} - -/** 绑定后上报微信资料(优先使用已拉取的信息,避免重复弹窗) */ -export async function syncMiniWechatProfile(prefetched?: MiniWechatProfile | null): Promise { +/** 绑定后上报微信资料(优先使用已拉取的信息) */ +export async function syncMiniWechatProfile( + prefetched?: MiniWechatProfile | null, +): Promise { if (process.env.TARO_ENV !== 'weapp') return null; - - let info = prefetched ?? null; - if (!info) { - try { - info = await fetchMiniWechatUserInfo(); - } catch { - return getCachedWxProfile(); - } + if (!prefetched?.nickname && !prefetched?.avatarUrl) { + return getCachedWxProfile(); } - - await uploadMiniWechatProfile(info); - return info; + await uploadMiniWechatProfile(prefetched); + return prefetched; } diff --git a/apps/mini-user/src/pages/login/index.tsx b/apps/mini-user/src/pages/login/index.tsx index 8b3a959..8940aaa 100644 --- a/apps/mini-user/src/pages/login/index.tsx +++ b/apps/mini-user/src/pages/login/index.tsx @@ -18,7 +18,6 @@ import { import { fetchUserProfile } from '../../lib/pay-wechat'; import { saveUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone'; import { - fetchMiniWechatUserInfo, getCachedWxProfile, syncMiniWechatProfile, type MiniWechatProfile, @@ -235,15 +234,8 @@ export default function LoginPage() { setSentHint(''); setWxLoading(true); try { - let wxInfo: MiniWechatProfile | null = null; - if (process.env.TARO_ENV === 'weapp') { - try { - wxInfo = await fetchMiniWechatUserInfo(); - } catch (e) { - toast(e instanceof Error ? e.message : '需要授权微信头像和昵称'); - return; - } - } + // 头像昵称改由「我的」页 chooseAvatar / nickname 填写;登录仅换 openId + const wxInfo = getCachedWxProfile(); if (completeMode === 'wechat' && isLoggedIn()) { const result = await bindWechatForUser(wxInfo); @@ -258,7 +250,7 @@ export default function LoginPage() { return; } if (result.ok) { - await syncMiniWechatProfile(wxInfo); + if (wxInfo) await syncMiniWechatProfile(wxInfo); toast('微信授权成功', 'success'); finishLoginNavigate(returnTo); return; diff --git a/apps/mini-user/src/pages/mine/index.tsx b/apps/mini-user/src/pages/mine/index.tsx index 6f86af0..31e60a5 100644 --- a/apps/mini-user/src/pages/mine/index.tsx +++ b/apps/mini-user/src/pages/mine/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { View, Text, Image } from '@tarojs/components'; +import { View, Text, Image, Button, Input } from '@tarojs/components'; import Taro, { useDidShow } from '@tarojs/taro'; import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types'; import PageShell from '../../components/PageShell'; @@ -10,8 +10,10 @@ import { goLogin } from '../../lib/auth-nav'; import { bindWechatForUser } from '../../lib/wechat-auth'; import { fetchUserProfile } from '../../lib/pay-wechat'; import { - fetchMiniWechatUserInfo, mergeWxDisplayProfile, + needsWxProfileFill, + uploadAvatarTempFile, + uploadMiniWechatProfile, } from '../../lib/mini-wechat-profile'; import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api'; import { isWechatEnv } from '../../lib/weixin'; @@ -29,6 +31,8 @@ const SERVICES = [ { icon: '关', label: '关于我们', action: 'about' as const }, ] as const; +const isWeapp = process.env.TARO_ENV === 'weapp'; + function formatMoney(amount: number) { return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } @@ -40,11 +44,30 @@ export default function MinePage() { const [orderCounts, setOrderCounts] = useState>({}); const [wxAuthorize, setWxAuthorize] = useState(true); const [bindingWx, setBindingWx] = useState(false); + const [savingProfile, setSavingProfile] = useState(false); + const [editingProfile, setEditingProfile] = useState(false); + const [draftNickname, setDraftNickname] = useState(''); function resetGuestState() { setProfile(null); setBenefitBalance(0); setOrderCounts({}); + setDraftNickname(''); + setEditingProfile(false); + } + + function applyProfile(me: UserProfile) { + const merged = mergeWxDisplayProfile(me); + setProfile(merged); + if (!needsWxProfileFill(merged)) { + setDraftNickname(merged.nickname || ''); + setEditingProfile(false); + return; + } + // 缺头像但有真实昵称时预填 + if (merged.nickname && !/^用户\d{4}$/.test(merged.nickname) && merged.nickname !== '微信用户') { + setDraftNickname(merged.nickname); + } } function loadProfile() { @@ -57,7 +80,7 @@ export default function MinePage() { ), ]) .then(([me, coupons, ...totals]) => { - setProfile(mergeWxDisplayProfile(me)); + applyProfile(me); const balance = (coupons as Array>).reduce((sum, c) => { if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0); return sum; @@ -89,93 +112,134 @@ export default function MinePage() { .catch(() => setWxAuthorize(true)); }, []); - async function handleAvatarTap() { - if (!isLoggedIn()) { - goLogin('/pages/mine/index'); - return; - } - if (bindingWx) return; - - let current = profile; - if (!current) { - try { - current = await fetchUserProfile(); - setProfile(current); - } catch { - /* ignore */ - } - } - if (current?.hasWechat) return; - + async function ensureWechatBound(): Promise { + if (profile?.hasWechat) return true; if (!wxAuthorize) { toast('当前环境未开启微信授权'); - return; + return false; } - setBindingWx(true); try { - if (process.env.TARO_ENV === 'h5') { + if (!isWeapp) { if (!isWechatEnv()) { toast('请在微信内打开后授权'); - return; + return false; } const result = await bindWechatForUser(); - if (!result.ok && 'redirecting' in result && result.redirecting) { - return; - } + if (!result.ok && 'redirecting' in result && result.redirecting) return false; if (!result.ok && 'needBindPhone' in result && result.needBindPhone) { goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey, }); - return; + return false; } - if (result.ok) { - setProfile( - mergeWxDisplayProfile({ - ...(result.profile ?? {}), - id: result.profile?.id ?? profile?.id ?? '', - hasWechat: true, - }), - ); - loadProfile(); - toast('微信授权成功', 'success'); + if (result.ok && result.profile) { + applyProfile({ ...result.profile, hasWechat: true }); + return true; } - return; + return false; } - let wxInfo = null; - try { - wxInfo = await fetchMiniWechatUserInfo(); - } catch (e) { - toast(e instanceof Error ? e.message : '需要授权微信头像和昵称'); - return; - } - - const result = await bindWechatForUser(wxInfo); + const result = await bindWechatForUser(null); if (!result.ok && 'needBindPhone' in result && result.needBindPhone) { goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey }); - return; + return false; } - if (result.ok) { - const merged = mergeWxDisplayProfile({ - ...(result.profile ?? {}), - id: result.profile?.id ?? profile?.id ?? '', - hasWechat: true, - nickname: result.profile?.nickname || wxInfo.nickname || profile?.nickname, - avatarUrl: result.profile?.avatarUrl || wxInfo.avatarUrl || profile?.avatarUrl, - }); - setProfile(merged); - loadProfile(); - toast('微信授权成功', 'success'); + if (result.ok && result.profile) { + applyProfile({ ...result.profile, hasWechat: true }); + return true; } + return false; } catch (e) { toast(e instanceof Error ? e.message : '微信授权失败'); + return false; } finally { setBindingWx(false); } } + async function persistProfile(patch: { nickname?: string; avatarUrl?: string }, opts?: { silent?: boolean }) { + setSavingProfile(true); + try { + const bound = await ensureWechatBound(); + if (!bound) return false; + const updated = await uploadMiniWechatProfile(patch); + if (updated) { + applyProfile(updated); + } else { + const me = await fetchUserProfile(); + applyProfile(me); + } + if (!opts?.silent) toast('资料已更新', 'success'); + return true; + } catch (e) { + toast(e instanceof Error ? e.message : '保存失败'); + return false; + } finally { + setSavingProfile(false); + } + } + + async function handleChooseAvatar(e: { detail?: { avatarUrl?: string } }) { + const tempPath = e.detail?.avatarUrl?.trim(); + if (!tempPath) { + toast('未获取到头像'); + return; + } + if (!isLoggedIn()) { + goLogin('/pages/mine/index'); + return; + } + try { + setSavingProfile(true); + const bound = await ensureWechatBound(); + if (!bound) return; + const url = await uploadAvatarTempFile(tempPath); + setProfile((prev) => + prev + ? mergeWxDisplayProfile({ ...prev, hasWechat: true, avatarUrl: url }) + : { id: '', hasWechat: true, avatarUrl: url, nickname: draftNickname || null }, + ); + setSavingProfile(false); + const ok = await persistProfile( + { + avatarUrl: url, + nickname: draftNickname.trim() || undefined, + }, + { silent: true }, + ); + if (ok) toast('头像已更新', 'success'); + } catch (err) { + toast(err instanceof Error ? err.message : '头像更新失败'); + setSavingProfile(false); + } + } + + async function handleNicknameConfirm() { + const nickname = draftNickname.trim(); + if (!nickname) { + toast('请填写微信昵称'); + return; + } + if (!isLoggedIn()) { + goLogin('/pages/mine/index'); + return; + } + await persistProfile({ nickname }); + } + + /** H5:点击头像走公众号 OAuth */ + async function handleH5AvatarTap() { + if (!isLoggedIn()) { + goLogin('/pages/mine/index'); + return; + } + if (profile?.hasWechat && !needsWxProfileFill(profile)) return; + await ensureWechatBound(); + loadProfile(); + } + function handleService(item: (typeof SERVICES)[number]) { if ('url' in item && item.url) { Taro.navigateTo({ url: item.url }); @@ -190,17 +254,6 @@ export default function MinePage() { } } - function resolveDisplayProfile(profile: UserProfile | null, hasWechat: boolean) { - if (!profile) { - return { nickname: '用户', avatarUrl: null as string | null }; - } - const merged = hasWechat ? mergeWxDisplayProfile(profile) : profile; - return { - nickname: merged.nickname || '用户', - avatarUrl: merged.avatarUrl || null, - }; - } - function renderAvatarContent(displayAvatarUrl: string | null) { if (displayAvatarUrl) { return ; @@ -228,10 +281,7 @@ export default function MinePage() { 登录后管理订单与个人信息 - goLogin('/pages/mine/index')} - > + goLogin('/pages/mine/index')}> 去登录 @@ -241,10 +291,19 @@ export default function MinePage() { } const hasWechat = !!profile?.hasWechat; - const canWxBind = wxAuthorize && process.env.TARO_ENV === 'weapp'; - const display = resolveDisplayProfile(profile, hasWechat); - const nickname = display.nickname; - const memberLabel = hasWechat ? '好客会员' : canWxBind ? '微信未授权' : '未授权微信'; + const needFill = needsWxProfileFill(profile) || editingProfile; + const canWxProfile = wxAuthorize && isWeapp; + const display = mergeWxDisplayProfile( + profile || { id: '', nickname: null, avatarUrl: null, hasWechat: false }, + ); + const nickname = display.nickname || '用户'; + const memberLabel = hasWechat + ? needFill + ? '完善头像昵称' + : '好客会员' + : canWxProfile + ? '微信未授权' + : '未授权微信'; return ( @@ -252,29 +311,77 @@ export default function MinePage() { - void handleAvatarTap()} - > - void handleChooseAvatar(e)} + disabled={bindingWx || savingProfile} > - {renderAvatarContent(display.avatarUrl)} - - {!hasWechat && canWxBind ? ( - - {bindingWx ? '授权中' : '去授权'} + + {renderAvatarContent(display.avatarUrl)} - ) : null} - - - {nickname} - - {memberLabel} - - {!hasWechat && canWxBind ? ( - 点击头像完成微信授权 - ) : null} + {needFill || !hasWechat ? ( + + {bindingWx || savingProfile ? '处理中' : hasWechat ? '选头像' : '去授权'} + + ) : null} + + ) : ( + void handleH5AvatarTap()} + > + + {renderAvatarContent(display.avatarUrl)} + + {!hasWechat ? ( + + {bindingWx ? '授权中' : '去授权'} + + ) : null} + + )} + + + {canWxProfile && needFill ? ( + <> + setDraftNickname(e.detail.value)} + onBlur={() => void handleNicknameConfirm()} + onConfirm={() => void handleNicknameConfirm()} + /> + {memberLabel} + 点击头像选微信头像,并填写昵称 + + ) : ( + <> + {nickname} + + {memberLabel} + + {canWxProfile ? ( + { + setDraftNickname(needsWxProfileFill(profile) ? '' : nickname); + setEditingProfile(true); + }} + > + 更新头像昵称 + + ) : null} + + )} diff --git a/apps/mini-user/src/styles/mine.css b/apps/mini-user/src/styles/mine.css index 7111fe8..a9809c1 100644 --- a/apps/mini-user/src/styles/mine.css +++ b/apps/mini-user/src/styles/mine.css @@ -46,6 +46,42 @@ cursor: pointer; } +/* 小程序 chooseAvatar:去掉 Button 默认样式,保留圆形头像 */ +.mine-avatar-btn { + position: relative; + flex-shrink: 0; + margin: 0 12px 0 0 !important; + padding: 0 !important; + background: transparent !important; + border: none !important; + border-radius: 0 !important; + line-height: 1 !important; + overflow: visible !important; +} + +.mine-avatar-btn::after { + border: none !important; +} + +.mine-profile-meta { + min-width: 0; + flex: 1; +} + +.mine-nickname-input { + width: 100%; + max-width: 200px; + height: 32px; + padding: 0 10px; + margin-bottom: 4px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.95); + color: var(--color-ink-black); + font-size: 15px; + font-weight: 600; + box-sizing: border-box; +} + .mine-avatar-status { position: absolute; left: 50%; @@ -93,6 +129,11 @@ color: rgba(255, 255, 255, 0.75); } +.mine-wechat-hint--link { + text-decoration: underline; + text-underline-offset: 2px; +} + .mine-avatar { width: 64px; height: 64px;