ae7c63c08d
CI / verify (pull_request) Has been cancelled
Share PullToRefresh for partner/shop H5; enable Taro pull-down on mini-user lists. Co-authored-by: Cursor <cursoragent@cursor.com>
521 lines
18 KiB
TypeScript
521 lines
18 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { View, Text, Image, Button, Input } from '@tarojs/components';
|
||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||
import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types';
|
||
import PageShell from '../../components/PageShell';
|
||
import TabMainHeader from '../../components/TabMainHeader';
|
||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||
import { BRAND_LOGO_MARK_URL } from '@dukang/shared-types';
|
||
import { goLogin } from '../../lib/auth-nav';
|
||
import { bindWechatForUser } from '../../lib/wechat-auth';
|
||
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';
|
||
|
||
const ORDER_SHORTCUTS = [
|
||
{ tab: 'pending_pay', icon: '付', label: '待付款' },
|
||
{ tab: 'paid', icon: '包', label: '已付款' },
|
||
{ tab: 'completed', icon: '成', label: '已完成' },
|
||
] as const;
|
||
|
||
const SERVICES = [
|
||
{ icon: '址', label: '地址管理', url: '/pages/addresses/index' },
|
||
{ icon: '店', label: '可用门店', tab: '/pages/stores/index' },
|
||
{ icon: '服', label: '联系客服', url: '/pages/customer-service/index' },
|
||
{ 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 });
|
||
}
|
||
|
||
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('');
|
||
|
||
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)))
|
||
.catch(() => setWxAuthorize(true));
|
||
}, []);
|
||
|
||
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) 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 === '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={BRAND_LOGO_MARK_URL} mode="aspectFit" />;
|
||
}
|
||
|
||
if (!authed) {
|
||
return (
|
||
<PageShell variant="tab" className="mine-page no-tab-header">
|
||
<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={BRAND_LOGO_MARK_URL} 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 memberLabel = needProfileFill
|
||
? '点击头像完善资料'
|
||
: isWeapp || hasWechat
|
||
? '好客会员'
|
||
: canWxAuth
|
||
? '点击头像授权'
|
||
: '好客会员';
|
||
const avatarClickable = isWeapp || (!hasWechat && canWxAuth);
|
||
const previewAvatar = draftAvatarUrl || display.avatarUrl;
|
||
|
||
return (
|
||
<PageShell variant="tab" className="mine-page no-tab-header">
|
||
<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.switchTab({ url: '/pages/benefit/index' })}
|
||
>
|
||
查看明细 ›
|
||
</Text>
|
||
</View>
|
||
<View className="mine-asset-panel">
|
||
<View>
|
||
<Text className="mine-asset-label">好客权益余额</Text>
|
||
<View className="mine-asset-amount">
|
||
<Text className="mine-asset-currency">¥</Text>
|
||
<Text className="mine-asset-value">{formatMoney(benefitBalance)}</Text>
|
||
</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">
|
||
<Text>{item.icon}</Text>
|
||
</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">
|
||
<Text>{item.icon}</Text>
|
||
</View>
|
||
<Text className="mine-service-label">{item.label}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
|
||
<View className="mine-footer">
|
||
<Text className="mine-version">杜康好客</Text>
|
||
<Text className="mine-logout" onClick={() => logout()}>
|
||
退出登录
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
|
||
|
||
{profileSheetOpen ? (
|
||
<View className="mine-profile-sheet-mask" onClick={() => !savingProfile && setProfileSheetOpen(false)}>
|
||
<View className="mine-profile-sheet" onClick={(e) => e.stopPropagation()}>
|
||
<Text className="mine-profile-sheet-title">完善头像与昵称</Text>
|
||
<Text className="mine-profile-sheet-hint">
|
||
点击头像选择,并填写昵称后保存(用于个人中心展示)
|
||
</Text>
|
||
<Button
|
||
className="mine-profile-avatar-btn"
|
||
openType="chooseAvatar"
|
||
hoverClass="none"
|
||
onChooseAvatar={onChooseAvatar}
|
||
>
|
||
<View className="mine-profile-avatar-preview">
|
||
{previewAvatar ? (
|
||
<Image className="mine-avatar-img" src={previewAvatar} mode="aspectFill" />
|
||
) : (
|
||
<Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} 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={savingProfile ? undefined : () => void saveWxProfile()}
|
||
>
|
||
<Text>{savingProfile ? '保存中...' : '保存'}</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
) : null}
|
||
</PageShell>
|
||
);
|
||
}
|