feat(v3.4.14): mini-user store UX, brand config, client settings

Store list/detail package flow, configurable WeChat mini brand assets and customer service, shop H5 home refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 15:21:59 +08:00
parent 53a79d1255
commit a2abbb6169
29 changed files with 1408 additions and 513 deletions
@@ -1,19 +1,26 @@
import { useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import ContactCsButton from '../../components/ContactCsButton';
import { toast } from '../../lib/api';
import { getBrandAssetsSync, loadBrandAssets } from '../../lib/brand-assets';
const isWeapp = process.env.TARO_ENV === 'weapp';
const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
function dialPhone() {
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() => toast('无法拨打电话'));
function dialPhone(phone: string) {
const tel = phone.replace(/-/g, '');
Taro.makePhoneCall({ phoneNumber: tel }).catch(() => toast('无法拨打电话'));
}
export default function CustomerServicePage() {
const [phone, setPhone] = useState(() => getBrandAssetsSync().customerServicePhone);
useEffect(() => {
void loadBrandAssets().then((b) => setPhone(b.customerServicePhone));
}, []);
return (
<PageShell variant="sub" className="cs-page">
<SubPageHeader title="联系客服" />
@@ -30,14 +37,14 @@ export default function CustomerServicePage() {
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
) : null}
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={dialPhone}>
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={() => dialPhone(phone)}>
<Text>
{isWeapp ? `或拨打客服电话 ${CUSTOMER_SERVICE_PHONE}` : '拨打客服电话'}
{isWeapp ? `或拨打客服电话 ${phone}` : '拨打客服电话'}
</Text>
</View>
{!isWeapp ? (
<Text className="cs-phone-display">{CUSTOMER_SERVICE_PHONE}</Text>
<Text className="cs-phone-display">{phone}</Text>
) : null}
</View>
</PageShell>
+15 -4
View File
@@ -10,7 +10,11 @@ import {
import PageShell from '../../components/PageShell';
import WechatLoginButton from '../../components/WechatLoginButton';
import PhoneQuickLoginButton from '../../components/PhoneQuickLoginButton';
import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types';
import {
applyBrandFromClientConfig,
getBrandAssetsSync,
loadBrandAssets,
} from '../../lib/brand-assets';
import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav';
import {
bindWechatForUser,
@@ -98,11 +102,18 @@ export default function LoginPage() {
const [wxAuthorize, setWxAuthorize] = useState(true);
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP);
const [logoWideUrl, setLogoWideUrl] = useState(() => getBrandAssetsSync().brandLogoWideUrl);
useEffect(() => {
request<ClientRuntimeConfig>('/common/client-config')
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(true));
.then((config) => {
setWxAuthorize(isWxAuthorizeEnabled(config));
setLogoWideUrl(applyBrandFromClientConfig(config).brandLogoWideUrl);
})
.catch(() => {
setWxAuthorize(true);
void loadBrandAssets().then((b) => setLogoWideUrl(b.brandLogoWideUrl));
});
}, []);
useEffect(() => {
@@ -394,7 +405,7 @@ export default function LoginPage() {
<View className="login-header">
<View className="login-logo-wrap">
<View className="login-logo">
<Image className="login-logo-img" src={BRAND_LOGO_WIDE_URL} mode="aspectFit" />
<Image className="login-logo-img" src={logoWideUrl} mode="aspectFit" />
</View>
<Text className="login-logo-badge"></Text>
</View>
+23 -8
View File
@@ -2,8 +2,6 @@ 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';
@@ -13,6 +11,11 @@ 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 {
applyBrandFromClientConfig,
getBrandAssetsSync,
loadBrandAssets,
} from '../../lib/brand-assets';
import {
fetchMiniWechatUserInfo,
isDefaultMiniNickname,
@@ -70,6 +73,10 @@ export default function MinePage() {
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,
);
function resetGuestState() {
setProfile(null);
@@ -141,8 +148,16 @@ export default function MinePage() {
useEffect(() => {
request<ClientRuntimeConfig>('/common/client-config')
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(true));
.then((config) => {
setWxAuthorize(isWxAuthorizeEnabled(config));
const brand = applyBrandFromClientConfig(config);
setBrandMarkUrl(brand.brandLogoMarkUrl);
setQualificationUrl(brand.qualificationDisclosureUrl);
})
.catch(() => {
setWxAuthorize(true);
void loadBrandAssets();
});
}, []);
const sharePayload = useMemo(
@@ -304,7 +319,7 @@ export default function MinePage() {
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" />;
return <Image className="mine-avatar-img" src={brandMarkUrl} mode="aspectFit" />;
}
if (!authed) {
@@ -317,7 +332,7 @@ export default function MinePage() {
<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" />
<Image className="mine-avatar-img" src={brandMarkUrl} mode="aspectFit" />
</View>
</View>
<View>
@@ -526,7 +541,7 @@ export default function MinePage() {
{previewAvatar ? (
<Image className="mine-avatar-img" src={previewAvatar} mode="aspectFill" />
) : (
<Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />
<Image className="mine-avatar-img" src={brandMarkUrl} mode="aspectFit" />
)}
</View>
<Text className="mine-profile-avatar-tip"></Text>
@@ -579,7 +594,7 @@ export default function MinePage() {
<View className="mine-qualification-body">
<Image
className="mine-qualification-img"
src={QUALIFICATION_DISCLOSURE_URL}
src={qualificationUrl}
mode="widthFix"
/>
</View>
+24 -47
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image, ScrollView } from '@tarojs/components';
import { View, Text, Image } from '@tarojs/components';
import Taro, {
useDidShow,
useLoad,
@@ -156,14 +156,9 @@ export default function StoreDetailPage() {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [headerSolid, setHeaderSolid] = useState(false);
const [activePackageIndex, setActivePackageIndex] = useState(0);
const storeRef = useRef<Store | null>(null);
storeRef.current = store;
useEffect(() => {
setActivePackageIndex(0);
}, [store?.id]);
usePageScroll(({ scrollTop }) => {
setHeaderSolid(scrollTop > 100);
});
@@ -326,13 +321,18 @@ export default function StoreDetailPage() {
const envPhotos = envPhotoUrls(store);
const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]);
const packages = store.packages ?? [];
const activePackage = packages[activePackageIndex] ?? packages[0];
const intro = store.intro?.trim() || '';
const benefitRuleRaw = store.benefitUsageRule?.trim() || '';
const benefitRule =
benefitRuleRaw && !/^null$/i.test(benefitRuleRaw) ? benefitRuleRaw : '';
function openPackageDetail(index: number) {
Taro.navigateTo({
url: `/pages/store-package-detail/index?storeId=${storeId}&index=${index}`,
});
}
function previewEnv(index: number) {
if (!envPhotos.length) return;
Taro.previewImage({
@@ -353,7 +353,13 @@ export default function StoreDetailPage() {
/>
<View className="store-detail-hero full-bleed">
<ProductCarousel images={heroImages} alt={store.name} variant="store" previewable />
<ProductCarousel
images={heroImages}
alt={store.name}
variant="store"
previewable
imageFit="contain"
/>
</View>
<View className="store-detail-info-card">
@@ -408,48 +414,19 @@ export default function StoreDetailPage() {
</View>
) : null}
{packages.length > 0 && activePackage ? (
{packages.length > 0 ? (
<View className="store-detail-section store-detail-section--packages">
<Text className="store-detail-section-title"></Text>
{packages.length > 1 ? (
<ScrollView className="store-detail-package-tabs" scrollX showScrollbar={false} enhanced>
<View className="store-detail-package-tabs-inner">
{packages.map((pkg, index) => (
<View
key={`${pkg.name}-${index}`}
className={`store-detail-package-tab${
index === activePackageIndex ? ' store-detail-package-tab--active' : ''
}`}
onClick={() => setActivePackageIndex(index)}
>
<Text className="store-detail-package-tab-text">{pkg.name}</Text>
</View>
))}
<View className="store-detail-package-list">
{packages.map((pkg, index) => (
<View
key={`${pkg.name}-${index}`}
className="store-detail-package-list-item"
onClick={() => openPackageDetail(index)}
>
<Text className="store-detail-package-list-title">{pkg.name}</Text>
</View>
</ScrollView>
) : null}
<View className="store-detail-package-panel">
{activePackage.imageUrl ? (
<Image
className="store-detail-package-thumb"
src={activePackage.imageUrl}
mode="aspectFill"
/>
) : null}
<View className="store-detail-package-panel-body">
{packages.length === 1 ? (
<Text className="store-detail-package-name">{activePackage.name}</Text>
) : null}
<Text className="store-detail-package-body">
{formatRedeemAmountYuan(activePackage.price)} · {activePackage.dishes}
</Text>
{activePackage.usableTime ? (
<Text className="store-detail-package-meta">使{activePackage.usableTime}</Text>
) : null}
{activePackage.otherNotes ? (
<Text className="store-detail-package-meta">{activePackage.otherNotes}</Text>
) : null}
</View>
))}
</View>
</View>
) : null}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '套餐详情',
});
@@ -0,0 +1,175 @@
import { useCallback, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useLoad, useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar';
import { request, toast } from '../../lib/api';
type StorePackage = {
name: string;
price: string | number;
dishes: string;
usableTime?: string | null;
otherNotes?: string | null;
imageUrl?: string | null;
};
type Store = {
id: string;
name: string;
packages?: StorePackage[] | null;
};
function pickStoreId(raw?: string | null) {
return String(raw || '')
.trim()
.replace(/[^\d]/g, '');
}
function parsePackageIndex(raw?: string | null) {
const n = Number.parseInt(String(raw ?? ''), 10);
return Number.isFinite(n) && n >= 0 ? n : -1;
}
function formatPriceYuan(price: string | number) {
const n = typeof price === 'number' ? price : Number(price);
if (!Number.isFinite(n)) return '0';
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
return n.toFixed(2).replace(/\.?0+$/, '');
}
export default function StorePackageDetailPage() {
const router = useRouter();
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.storeId));
const [storeName, setStoreName] = useState('');
const [pkg, setPkg] = useState<StorePackage | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const loadPackage = useCallback(async (sid: string, index: number) => {
if (!sid) {
setLoading(false);
setLoadError('缺少门店参数');
return;
}
if (index < 0) {
setLoading(false);
setLoadError('套餐不存在');
return;
}
setLoadError('');
setLoading(true);
try {
const data = await request<Store>(`/stores/${sid}`);
const packages = data?.packages ?? [];
const item = packages[index];
if (!data?.id || !item) {
setPkg(null);
setLoadError('套餐不存在或已下架');
toast('套餐不存在或已下架');
return;
}
setStoreName(data.name);
setPkg(item);
} catch (e) {
const msg = e instanceof Error ? e.message : '加载失败';
setLoadError(msg);
toast(msg);
} finally {
setLoading(false);
}
}, []);
useLoad((options) => {
const sid = pickStoreId(options?.storeId || router.params.storeId);
const index = parsePackageIndex(options?.index ?? router.params.index);
setStoreId(sid);
void loadPackage(sid, index);
});
function goBack() {
const pages = Taro.getCurrentPages();
if (pages.length > 1) Taro.navigateBack();
else if (storeId) {
Taro.redirectTo({ url: `/pages/store-detail/index?id=${storeId}` });
} else {
Taro.switchTab({ url: '/pages/stores/index' });
}
}
function previewImage(url: string) {
Taro.previewImage({ urls: [url], current: url }).catch(() => toast('无法预览图片'));
}
if (loading) {
return (
<PageShell variant="scroll" className="store-package-detail-page">
<PageNavBar title="套餐详情" solid onBack={goBack} />
<View className="page-with-nav-bar u-empty"></View>
</PageShell>
);
}
if (!pkg) {
return (
<PageShell variant="scroll" className="store-package-detail-page">
<PageNavBar title="套餐详情" solid onBack={goBack} />
<View className="page-with-nav-bar u-empty">{loadError || '套餐不存在'}</View>
</PageShell>
);
}
const imageUrl = (pkg.imageUrl || '').trim();
return (
<PageShell variant="scroll" className="store-package-detail-page">
<PageNavBar title={pkg.name} solid titleVisible onBack={goBack} />
<View className="store-package-detail-body">
<View className="store-package-detail-inner">
<View className="store-package-detail-header">
<View className="store-package-detail-title-row">
<Text className="store-package-detail-title">{pkg.name}</Text>
<Text className="store-package-detail-price">¥{formatPriceYuan(pkg.price)}</Text>
</View>
{storeName ? (
<Text className="store-package-detail-store">{storeName}</Text>
) : null}
</View>
{imageUrl ? (
<View
className="store-package-detail-photo"
onClick={() => previewImage(imageUrl)}
>
<Image
className="store-package-detail-photo-image"
src={imageUrl}
mode="widthFix"
/>
</View>
) : null}
<View className="store-package-detail-content">
<View className="store-detail-package-field">
<Text className="store-detail-package-field-label"></Text>
<Text className="store-detail-package-field-value">{pkg.dishes || '—'}</Text>
</View>
{pkg.usableTime ? (
<View className="store-detail-package-field">
<Text className="store-detail-package-field-label">使</Text>
<Text className="store-detail-package-field-value">{pkg.usableTime}</Text>
</View>
) : null}
{pkg.otherNotes ? (
<View className="store-detail-package-field">
<Text className="store-detail-package-field-label"></Text>
<Text className="store-detail-package-field-value">{pkg.otherNotes}</Text>
</View>
) : null}
</View>
</View>
</View>
</PageShell>
);
}
+12 -15
View File
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image, Input } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import { StoreStatus, STORE_STATUS_LABELS } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
import WechatShareReady from '../../components/WechatShareReady';
@@ -342,17 +341,12 @@ export default function StoresPage() {
}
}
function formatHours(store: Store) {
function hoursLines(store: Store): string[] {
const parts: string[] = [];
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
return parts.length ? `营业时间: ${parts.join('')}` : '营业时间: 10:00-22:00';
}
function formatStatus(store: Store) {
const status = store.status as StoreStatus | undefined;
if (status && STORE_STATUS_LABELS[status]) return STORE_STATUS_LABELS[status];
return STORE_STATUS_LABELS[StoreStatus.OPEN];
if (!parts.length) parts.push('10:00-22:00');
return parts.map((p, i) => (i === 0 ? `营业时间: ${p}` : p));
}
const sharePayload = useMemo(
@@ -446,14 +440,17 @@ export default function StoresPage() {
{formatDistanceMeters(s.distanceMeters)}
</Text>
</View>
{/* 第2行:状态 + 营业时间(含第二段 */}
<View className="store-card-row store-card-row--meta">
<Text className="store-card-status">{formatStatus(s)}</Text>
<Text className="store-card-hours">{formatHours(s)}</Text>
{/* 第2行:营业时间(多段各占一行,居左 */}
<View className="store-card-hours">
{hoursLines(s).map((line) => (
<Text key={line} className="store-card-hours-line">
{line}
</Text>
))}
</View>
{/* 第3行:地址 + 去核销 */}
{/* 第3行:地址(最多两行)+ 去核销 */}
<View className="store-card-row store-card-row--foot">
<Text className="store-card-address" numberOfLines={1}>
<Text className="store-card-address" numberOfLines={2}>
{s.address || (s.district ? `${s.district}` : '地址待完善')}
</Text>
<View