feat: multi-module iteration

This commit is contained in:
2026-08-04 21:38:49 +08:00
parent 9d96c73246
commit 71f508e02b
1366 changed files with 202004 additions and 0 deletions
+591
View File
@@ -0,0 +1,591 @@
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 {
BRAND_LOGO_MARK_URL,
QUALIFICATION_DISCLOSURE_URL,
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 { 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';
import { maskPhone } from '../../lib/phone';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} 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 },
] 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);
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));
}, []);
const sharePayload = useMemo(
() => ({
title: '杜康好客 · 我的',
desc: DEFAULT_SHARE_DESC,
path: '/pages/mine/index',
}),
[],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: '',
}));
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={BRAND_LOGO_MARK_URL} 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={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 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">
<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">
<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"></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={() => {
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={QUALIFICATION_DISCLOSURE_URL}
mode="widthFix"
/>
</View>
</ScrollView>
<Text className="mine-qualification-hint"></Text>
</View>
) : null}
</PageShell>
);
}