Files
dukang/apps/mini-user/src/pages/mine/index.tsx
T
jacy fc2e5b65de
CI / verify (pull_request) Has been cancelled
v3.5.3版本更新1
2026-08-20 18:54:15 +08:00

614 lines
22 KiB
TypeScript
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.
import { useEffect, useMemo, useState } from 'react';
import { View, Text, Image, Button, Input, ScrollView } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import {
isWxAuthorizeEnabled,
type ClientRuntimeConfig,
} from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import BenefitFigure from '../../components/BenefitFigure';
import { goLogin } from '../../lib/auth-nav';
import { bindWechatForUser } from '../../lib/wechat-auth';
import {
applyBrandFromClientConfig,
getBrandAssetsSync,
loadBrandAssets,
} from '../../lib/brand-assets';
import {
fetchMiniWechatUserInfo,
isDefaultMiniNickname,
mergeWxDisplayProfile,
needsWxProfileFill,
uploadAvatarTempFile,
uploadMiniWechatProfile,
} from '../../lib/mini-wechat-profile';
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
import { isWechatEnv } from '../../lib/weixin';
import { APP_VERSION_LABEL } from '../../lib/client-version';
import { maskPhone } from '../../lib/phone';
import {
applyShareFromClientConfig,
buildSceneSharePayload,
toWeappShareMessage,
toWeappShareTimeline,
} from '../../lib/wechat-share';
import iconPendingPay from '../../assets/icons/待付款.png';
import iconPaid from '../../assets/icons/已付款.png';
import iconCompleted from '../../assets/icons/已完成.png';
import iconAddress from '../../assets/icons/地址管理.png';
import iconStores from '../../assets/icons/可用门店.png';
import iconCs from '../../assets/icons/联系客服.png';
import iconQualification from '../../assets/icons/资质公示.png';
import iconAbout from '../../assets/icons/关于我们.png';
import { formatMoney } from '../../lib/money';
const ORDER_SHORTCUTS = [
{ tab: 'pending_pay', icon: iconPendingPay, label: '待付款' },
{ tab: 'paid', icon: iconPaid, label: '已付款' },
{ tab: 'completed', icon: iconCompleted, label: '已完成' },
] as const;
const SERVICES = [
{ icon: iconAddress, label: '地址管理', url: '/pages/addresses/index' },
{ icon: iconStores, label: '可用门店', tab: '/pages/stores/index' },
{ icon: iconCs, label: '联系客服', url: '/pages/customer-service/index' },
{ icon: iconQualification, label: '资质公示', action: 'qualification' as const },
{ icon: iconAbout, label: '关于我们', action: 'about' as const },
{ icon: iconAbout, label: '发票管理', url: '/pages/invoice-titles/index' },
] as const;
const isWeapp = process.env.TARO_ENV === 'weapp';
export default function MinePage() {
const [authed, setAuthed] = useState(() => isLoggedIn());
const [profile, setProfile] = useState<UserProfile | null>(null);
const [benefitBalance, setBenefitBalance] = useState(0);
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
const [wxAuthorize, setWxAuthorize] = useState(true);
const [bindingWx, setBindingWx] = useState(false);
const [profileSheetOpen, setProfileSheetOpen] = useState(false);
const [draftAvatarTemp, setDraftAvatarTemp] = useState('');
const [draftAvatarUrl, setDraftAvatarUrl] = useState('');
const [draftNickname, setDraftNickname] = useState('');
const [savingProfile, setSavingProfile] = useState(false);
const [profileLoadError, setProfileLoadError] = useState('');
const [qualificationOpen, setQualificationOpen] = useState(false);
const [brandMarkUrl, setBrandMarkUrl] = useState(() => getBrandAssetsSync().brandLogoMarkUrl);
const [qualificationUrl, setQualificationUrl] = useState(
() => getBrandAssetsSync().qualificationDisclosureUrl,
);
const [shareTick, setShareTick] = useState(0);
function resetGuestState() {
setProfile(null);
setBenefitBalance(0);
setOrderCounts({});
setProfileLoadError('');
}
function applyProfile(me: UserProfile) {
setProfile(mergeWxDisplayProfile(me));
}
function loadProfile() {
if (!isLoggedIn()) return Promise.resolve();
setProfileLoadError('');
return Promise.all([
request<UserProfile>('/auth/me'),
request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []),
...ORDER_SHORTCUTS.map((s) =>
request<{ total: number }>(`/trade/orders?tab=${s.tab}&pageSize=1`).catch(() => ({ total: 0 })),
),
])
.then(([me, coupons, ...totals]) => {
applyProfile(me);
const balance = (coupons as Array<Record<string, unknown>>).reduce((sum, c) => {
if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0);
return sum;
}, 0);
setBenefitBalance(balance);
const counts: Record<string, number> = {};
ORDER_SHORTCUTS.forEach((s, i) => {
counts[s.tab] = (totals[i] as { total: number })?.total ?? 0;
});
setOrderCounts(counts);
})
.catch((error) => {
if (!isLoggedIn()) {
setAuthed(false);
resetGuestState();
return;
}
const message = error instanceof Error ? error.message : '个人资料加载失败';
setProfileLoadError(message);
toast('个人资料加载失败,请点击重试');
});
}
useDidShow(() => {
syncTabBarSelected(3);
const loggedInNow = isLoggedIn();
setAuthed(loggedInNow);
if (loggedInNow) {
loadProfile();
} else {
resetGuestState();
}
});
usePullDownRefresh(() => {
const loggedInNow = isLoggedIn();
setAuthed(loggedInNow);
if (!loggedInNow) {
resetGuestState();
Taro.stopPullDownRefresh();
return;
}
void loadProfile().finally(() => Taro.stopPullDownRefresh());
});
useEffect(() => {
request<ClientRuntimeConfig>('/common/client-config')
.then((config) => {
setWxAuthorize(isWxAuthorizeEnabled(config));
const brand = applyBrandFromClientConfig(config);
applyShareFromClientConfig(config);
setBrandMarkUrl(brand.brandLogoMarkUrl);
setQualificationUrl(brand.qualificationDisclosureUrl);
setShareTick((n) => n + 1);
})
.catch(() => {
setWxAuthorize(true);
void loadBrandAssets();
});
}, []);
const sharePayload = useMemo(
() =>
buildSceneSharePayload('mine', {
path: '/pages/mine/index',
}),
[shareTick],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => toWeappShareTimeline(sharePayload));
async function ensureWechatBound(): Promise<boolean> {
if (profile?.hasWechat) return true;
if (!wxAuthorize) {
toast('当前环境未开启微信授权');
return false;
}
setBindingWx(true);
try {
if (!isWeapp) {
if (!isWechatEnv()) {
toast('请在微信内打开后授权');
return false;
}
const result = await bindWechatForUser();
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 false;
}
if (result.ok && result.profile) {
applyProfile({ ...result.profile, hasWechat: true });
return true;
}
return false;
}
const result = await bindWechatForUser(null);
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey });
return false;
}
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);
}
}
function openProfileSheet(me?: UserProfile | null) {
const base = mergeWxDisplayProfile(
me || profile || { id: '', nickname: null, avatarUrl: null, hasWechat: false },
);
setDraftAvatarTemp('');
setDraftAvatarUrl(base.avatarUrl || '');
setDraftNickname(isDefaultMiniNickname(base.nickname) ? '' : base.nickname || '');
setProfileSheetOpen(true);
}
/** 点击头像:绑定账号后用 chooseAvatar + nickname 获取头像昵称 */
async function handleAvatarTap() {
if (bindingWx || savingProfile) {
toast(savingProfile ? '资料保存中…' : '请稍候…');
return;
}
if (!isLoggedIn()) {
goLogin('/pages/mine/index');
return;
}
if (isWeapp) {
openProfileSheet();
return;
}
if (!profile?.hasWechat) {
const ok = await ensureWechatBound();
if (ok) loadProfile();
}
return;
}
async function onChooseAvatar(e: { detail?: { avatarUrl?: string } }) {
const tempPath = e.detail?.avatarUrl?.trim();
if (!tempPath) {
toast('未获取到头像,请重试');
return;
}
setDraftAvatarTemp(tempPath);
setDraftAvatarUrl(tempPath);
}
async function saveWxProfile() {
const nickname = draftNickname.trim();
if (!nickname) {
toast('请填写昵称');
return;
}
if (!draftAvatarTemp && !draftAvatarUrl) {
toast('请选择头像');
return;
}
setSavingProfile(true);
try {
let avatarUrl = draftAvatarUrl;
let avatarResourceId: string | undefined;
if (draftAvatarTemp) {
const uploaded = await uploadAvatarTempFile(draftAvatarTemp);
avatarUrl = uploaded.url;
avatarResourceId = uploaded.resourceId;
}
const updated = await uploadMiniWechatProfile({
nickname,
...(avatarResourceId ? { avatarUrl, avatarResourceId } : {}),
});
if (updated) applyProfile(updated);
setProfileSheetOpen(false);
toast('头像昵称已更新', 'success');
loadProfile();
} catch (err) {
toast(err instanceof Error ? err.message : '保存失败');
} finally {
setSavingProfile(false);
}
}
function handleService(item: (typeof SERVICES)[number]) {
if ('url' in item && item.url) {
Taro.navigateTo({ url: item.url });
return;
}
if ('tab' in item && item.tab) {
Taro.switchTab({ url: item.tab });
return;
}
if ('action' in item && item.action === 'qualification') {
setQualificationOpen(true);
return;
}
if ('action' in item && item.action === 'about') {
toast('杜康好客 · 传承千年酒文化');
}
}
function renderAvatarContent(displayAvatarUrl: string | null) {
if (displayAvatarUrl) {
return <Image className="mine-avatar-img" src={displayAvatarUrl} mode="aspectFill" />;
}
return <Image className="mine-avatar-img" src={brandMarkUrl} mode="aspectFit" />;
}
if (!authed) {
return (
<PageShell variant="tab" className="mine-page no-tab-header">
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="我的" />
<View className="mine-header">
<View className="mine-header-texture" />
<View className="mine-profile">
<View className="mine-avatar-wrap mine-avatar-wrap--action" onClick={() => goLogin('/pages/mine/index')}>
<View className="mine-avatar mine-avatar--wx-pending">
<Image className="mine-avatar-img" src={brandMarkUrl} mode="aspectFit" />
</View>
</View>
<View>
<Text className="mine-profile-name">未登录</Text>
<Text className="mine-member-tag">点击头像登录</Text>
</View>
</View>
</View>
<View className="mine-login-gate">
<View className="mine-login-gate-hint">
登录后管理订单与个人信息;无需登录也可浏览商品和门店
</View>
<View className="mine-login-btn" onClick={() => goLogin('/pages/mine/index')}>
<Text>去登录</Text>
</View>
</View>
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
</PageShell>
);
}
const hasWechat = !!profile?.hasWechat;
const canWxAuth = wxAuthorize && (isWeapp || isWechatEnv());
const display = mergeWxDisplayProfile(
profile || { id: '', nickname: null, avatarUrl: null, hasWechat: false },
);
// 强制保留 common 导出,避免开发者工具「旧页 + 新 common」混用时报 is not a function
if (typeof needsWxProfileFill !== 'function' || typeof fetchMiniWechatUserInfo !== 'function') {
throw new Error('wx profile helpers missing');
}
const nickname = display.nickname || '用户';
const needProfileFill = isWeapp && needsWxProfileFill(display);
const avatarProfileReady = isWeapp ? !needProfileFill : hasWechat;
const maskedPhone = profile?.phone ? maskPhone(String(profile.phone)) : '';
// 昵称下优先展示脱敏手机号;无手机号时再提示完善资料/授权
const memberLabel =
maskedPhone ||
(needProfileFill
? '点击头像完善资料'
: !isWeapp && !hasWechat && canWxAuth
? '点击头像授权'
: '未绑定手机');
const avatarClickable = isWeapp || (!hasWechat && canWxAuth);
const previewAvatar = draftAvatarUrl || display.avatarUrl;
return (
<PageShell variant="tab" className="mine-page no-tab-header">
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="我的" />
<View className="mine-header">
<View className="mine-header-texture" />
<View className="mine-profile">
<View
className={`mine-avatar-wrap${avatarClickable ? ' mine-avatar-wrap--action' : ''}`}
onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
>
<View
className={`mine-avatar${
avatarProfileReady ? ' mine-avatar--wx-ok' : ' mine-avatar--wx-pending'
}`}
>
{renderAvatarContent(display.avatarUrl)}
</View>
{avatarClickable ? (
<View
className={`mine-avatar-status${
avatarProfileReady ? ' mine-avatar-status--ok' : ' mine-avatar-status--pending'
}`}
>
<Text>
{bindingWx ? '授权中' : avatarProfileReady ? '更换' : '去完善'}
</Text>
</View>
) : null}
</View>
<View
className="mine-profile-meta"
onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
>
<Text className="mine-profile-name">{nickname}</Text>
<Text className={`mine-member-tag${avatarProfileReady ? ' mine-member-tag--wechat' : ''}`}>
{memberLabel}
</Text>
{profileLoadError ? (
<Text
className="mine-profile-retry"
onClick={(event) => {
event.stopPropagation();
loadProfile();
}}
>
资料加载失败,点击重试
</Text>
) : null}
</View>
</View>
</View>
<View className="mine-main">
<View className="mine-card">
<View className="mine-card-head">
<Text className="mine-card-title">我的资产</Text>
<Text
className="mine-card-link"
onClick={() => Taro.navigateTo({ url: '/pages/benefit-detail/index' })}
>
查看明细
</Text>
</View>
<View className="mine-asset-panel">
<View>
<Text className="mine-asset-label">好客权益余额</Text>
<View className="mine-asset-amount">
<BenefitFigure value={formatMoney(benefitBalance)} size="lg" className="mine-asset-value" />
</View>
</View>
<Text
className="mine-asset-cta"
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
>
去使用
</Text>
</View>
</View>
<View className="mine-card">
<View className="mine-card-head">
<Text className="mine-card-title">我的订单</Text>
<Text
className="mine-card-link"
onClick={() => Taro.navigateTo({ url: '/pages/orders/index' })}
>
全部订单
</Text>
</View>
<View className="mine-order-grid">
{ORDER_SHORTCUTS.map((item) => {
const count = orderCounts[item.tab] ?? 0;
return (
<View
key={item.tab}
className="mine-order-item"
onClick={() =>
Taro.navigateTo({ url: `/pages/orders/index?tab=${item.tab}` })
}
>
<View className="mine-order-icon">
<Image className="mine-order-icon-img" src={item.icon} mode="aspectFit" />
</View>
{count > 0 ? (
<Text className="mine-order-badge">{count > 99 ? '99+' : count}</Text>
) : null}
<Text className="mine-order-label">{item.label}</Text>
</View>
);
})}
</View>
</View>
<View className="mine-card">
<View className="mine-card-head">
<Text className="mine-card-title">常用服务</Text>
</View>
<View className="mine-service-grid">
{SERVICES.map((item) => (
<View
key={item.label}
className="mine-service-item"
onClick={() => handleService(item)}
>
<View className="mine-service-icon">
<Image className="mine-service-icon-img" src={item.icon} mode="aspectFit" />
</View>
<Text className="mine-service-label">{item.label}</Text>
</View>
))}
</View>
</View>
<View className="mine-footer">
<Text className="mine-version">杜康好客 {APP_VERSION_LABEL}</Text>
<Text className="mine-logout" onClick={() => logout()}>
退出登录
</Text>
</View>
</View>
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
{profileSheetOpen ? (
<View className="mine-profile-sheet-mask">
{/* 遮罩单独绑 tap,勿在含 chooseAvatar 的祖先上用 stopPropagation(会编译成 catchtap 导致选头像无反应) */}
<View
className="mine-profile-sheet-backdrop"
onClick={() => !savingProfile && setProfileSheetOpen(false)}
/>
<View className="mine-profile-sheet">
<Text className="mine-profile-sheet-title">完善头像与昵称</Text>
<Text className="mine-profile-sheet-hint">
点击头像选择,并填写昵称后保存(用于个人中心展示)
</Text>
<Button
className="mine-profile-avatar-btn"
openType="chooseAvatar"
hoverClass="none"
plain
onChooseAvatar={onChooseAvatar}
>
<View className="mine-profile-avatar-preview">
{previewAvatar ? (
<Image className="mine-avatar-img" src={previewAvatar} mode="aspectFill" />
) : (
<Image className="mine-avatar-img" src={brandMarkUrl} mode="aspectFit" />
)}
</View>
<Text className="mine-profile-avatar-tip">点击选择头像</Text>
</Button>
<View className="mine-profile-nickname-wrap">
<Text className="mine-profile-nickname-label">昵称</Text>
<Input
className="mine-profile-nickname-input"
type="nickname"
maxlength={32}
placeholder="点击填写昵称"
value={draftNickname}
onInput={(e) => setDraftNickname(e.detail.value)}
onBlur={(e) => setDraftNickname(e.detail.value.trim())}
/>
</View>
<View className="mine-profile-sheet-actions">
<View
className="mine-profile-sheet-cancel"
onClick={() => !savingProfile && setProfileSheetOpen(false)}
>
<Text>取消</Text>
</View>
<View
className={`mine-profile-sheet-save${savingProfile ? ' is-disabled' : ''}`}
onClick={() => {
if (!savingProfile) void saveWxProfile();
}}
>
<Text>{savingProfile ? '保存中…' : '保存'}</Text>
</View>
</View>
</View>
</View>
) : null}
{qualificationOpen ? (
<View
className="mine-qualification-mask"
onClick={() => setQualificationOpen(false)}
>
<ScrollView
scrollY
enableFlex
className="mine-qualification-scroll"
style={{ height: '100%' }}
enhanced
showScrollbar
>
<View className="mine-qualification-body">
<Image
className="mine-qualification-img"
src={qualificationUrl}
mode="widthFix"
/>
</View>
</ScrollView>
<Text className="mine-qualification-hint">点击任意处关闭</Text>
</View>
) : null}
</PageShell>
);
}