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
@@ -0,0 +1,159 @@
import { useEffect, useMemo, useState } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
export type StoreCategoryNode = {
id: string;
name: string;
children?: StoreCategoryNode[];
};
export type CategorySelection = {
parentId: string;
parentName: string;
childId: string;
childName: string;
};
export const EMPTY_CATEGORY: CategorySelection = {
parentId: '',
parentName: '',
childId: '',
childName: '',
};
export function formatCategoryLabel(sel: CategorySelection): string {
if (sel.childName) return sel.childName;
if (sel.parentName) return sel.parentName;
return '全部分类';
}
type CategoryPickerProps = {
open: boolean;
tree: StoreCategoryNode[];
value: CategorySelection;
onClose: () => void;
onConfirm: (next: CategorySelection) => void;
};
type TabKey = 'parent' | 'child';
export default function CategoryPicker({
open,
tree,
value,
onClose,
onConfirm,
}: CategoryPickerProps) {
const [draft, setDraft] = useState<CategorySelection>(value);
const [activeTab, setActiveTab] = useState<TabKey>('parent');
useEffect(() => {
if (!open) return;
setDraft(value);
setActiveTab(value.parentId ? 'child' : 'parent');
}, [open, value]);
const children = useMemo(() => {
const parent = tree.find((n) => n.id === draft.parentId);
return parent?.children ?? [];
}, [tree, draft.parentId]);
if (!open) return null;
function selectParent(node: StoreCategoryNode | null) {
if (!node) {
setDraft(EMPTY_CATEGORY);
return;
}
setDraft({
parentId: node.id,
parentName: node.name,
childId: '',
childName: '',
});
setActiveTab('child');
}
function selectChild(node: StoreCategoryNode | null) {
if (!node) {
setDraft((prev) => ({ ...prev, childId: '', childName: '' }));
return;
}
setDraft((prev) => ({
...prev,
childId: node.id,
childName: node.name,
}));
}
function handleConfirm() {
onConfirm(draft);
onClose();
}
return (
<View className="region-picker-overlay" onClick={onClose}>
<View className="region-picker-sheet" onClick={(e) => e.stopPropagation()}>
<View className="region-picker-toolbar">
<View className="region-picker-tabs">
<Text
className={`region-picker-tab${activeTab === 'parent' ? ' active' : ''}`}
onClick={() => setActiveTab('parent')}
>
{draft.parentName || '大类'}
</Text>
<Text
className={`region-picker-tab${activeTab === 'child' ? ' active' : ''}${!draft.parentId ? ' disabled' : ''}`}
onClick={() => draft.parentId && setActiveTab('child')}
>
{draft.childName || '细类'}
</Text>
</View>
<Text className="region-picker-confirm ready" onClick={handleConfirm}>
</Text>
</View>
<ScrollView className="region-picker-list" scrollY showScrollbar={false}>
{activeTab === 'parent' ? (
<>
<View
className={`region-picker-option${!draft.parentId ? ' selected' : ''} region-picker-option--all`}
onClick={() => selectParent(null)}
>
<Text></Text>
</View>
{tree.map((item) => (
<View
key={item.id}
className={`region-picker-option${draft.parentId === item.id ? ' selected' : ''}`}
onClick={() => selectParent(item)}
>
<Text>{item.name}</Text>
</View>
))}
</>
) : (
<>
<View
className={`region-picker-option${!draft.childId ? ' selected' : ''} region-picker-option--all`}
onClick={() => selectChild(null)}
>
<Text></Text>
</View>
{children.map((item) => (
<View
key={item.id}
className={`region-picker-option${draft.childId === item.id ? ' selected' : ''}`}
onClick={() => selectChild(item)}
>
<Text>{item.name}</Text>
</View>
))}
</>
)}
</ScrollView>
</View>
</View>
);
}
@@ -0,0 +1,50 @@
import { Button, Text } from '@tarojs/components';
import type { ReactNode } from 'react';
export type ContactCsSessionContext = {
orderId?: string;
orderNo?: string;
from?: string;
};
type ContactCsButtonProps = {
className?: string;
children?: ReactNode;
/** 客服会话来源上下文,便于客服后台识别 */
session?: ContactCsSessionContext;
};
const isWeapp = process.env.TARO_ENV === 'weapp';
/** 组装 session-from(微信限制约 1000 字符) */
export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
if (!session) return 'dukang|from=mini-user';
const parts = ['dukang'];
if (session.from) parts.push(`from=${session.from}`);
if (session.orderNo) parts.push(`orderNo=${session.orderNo}`);
if (session.orderId) parts.push(`orderId=${session.orderId}`);
return parts.join('|');
}
/**
* 微信小程序客服入口(open-type=contact)。
* 非 weapp 环境不渲染,由调用方走电话等兜底。
*/
export default function ContactCsButton({
className = '',
children = '联系在线客服',
session,
}: ContactCsButtonProps) {
if (!isWeapp) return null;
return (
<Button
className={className}
openType="contact"
sessionFrom={buildCsSessionFrom(session)}
hoverClass="none"
>
{typeof children === 'string' ? <Text>{children}</Text> : children}
</Button>
);
}
@@ -0,0 +1,17 @@
import { Text } from '@tarojs/components';
type CouponBadgeProps = {
amount: number | string;
label?: string;
};
/** Taro 友好版权益角标(对齐 shared-ui CouponBadge */
export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) {
const n = Number(amount);
const display = Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(0)) : String(amount);
return (
<Text className="coupon-badge">
¥{display} {label}
</Text>
);
}
@@ -0,0 +1,56 @@
import type { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import { subPageNavBarStyle, subPageNavContentStyle, useNavBarMetrics } from '../lib/nav-bar';
type PageNavBarProps = {
title: string;
solid?: boolean;
titleVisible?: boolean;
onBack?: () => void;
right?: ReactNode;
};
const isH5 = process.env.TARO_ENV === 'h5';
/** 内页自定义导航栏(返回 + 标题 + 右侧操作),适配刘海屏 */
export default function PageNavBar({
title,
solid = false,
titleVisible = true,
onBack,
right,
}: PageNavBarProps) {
const metrics = useNavBarMetrics();
const showTitle = !isH5 && titleVisible;
return (
<View
className={`page-nav-bar${solid ? ' page-nav-bar--solid' : ''}`}
style={subPageNavBarStyle(metrics)}
aria-label={title}
>
<Text
className={`page-nav-bar__title${showTitle ? ' page-nav-bar__title--visible' : ''}`}
>
{showTitle ? title : ''}
</Text>
<View
className="page-nav-bar__content"
style={subPageNavContentStyle(metrics)}
>
{onBack ? (
<View className="page-nav-bar__btn page-nav-bar__btn--back" onClick={onBack}>
<Text className="page-nav-bar__icon"></Text>
</View>
) : (
<View className="page-nav-bar__btn page-nav-bar__btn--back page-nav-bar__btn--placeholder" />
)}
{right ? (
<View className="page-nav-bar__right-slot">{right}</View>
) : (
<View className="page-nav-bar__btn page-nav-bar__btn--right page-nav-bar__btn--placeholder" />
)}
</View>
</View>
);
}
@@ -0,0 +1,40 @@
import type { PropsWithChildren, ReactNode } from 'react';
import { View } from '@tarojs/components';
import { pageShellCssVars, useNavBarMetrics } from '../lib/nav-bar';
type PageShellVariant = 'tab' | 'scroll' | 'sub' | 'plain';
type PageShellProps = PropsWithChildren<{
variant?: PageShellVariant;
className?: string;
/** 栈页固定底栏时额外底部留白 */
hasFixedFooter?: boolean;
}>;
/**
* 页面壳:唯一注入刘海屏 CSS 变量;统一 min-height / 底栏安全区。
* 顶栏组件自行调用 navBarStyle,不要在根节点再套 navBarStyle。
*/
export default function PageShell({
variant = 'tab',
className = '',
hasFixedFooter = false,
children,
}: PageShellProps) {
const metrics = useNavBarMetrics();
const classes = [
'page-shell',
`page-shell--${variant}`,
hasFixedFooter ? 'page-shell--fixed-footer' : '',
variant === 'tab' && process.env.TARO_ENV === 'weapp' ? 'page-shell--native-tabbar' : '',
className,
]
.filter(Boolean)
.join(' ');
return (
<View className={classes} style={pageShellCssVars(metrics)}>
{children as ReactNode}
</View>
);
}
@@ -0,0 +1,63 @@
import { Button, Text, View } from '@tarojs/components';
type PhoneQuickLoginButtonProps = {
loading?: boolean;
disabled?: boolean;
/** 须已主动勾选协议后才挂载 getPhoneNumber,避免未同意即拉起授权 */
agreed: boolean;
onRequireAgree: () => void;
onGetPhoneNumber: (phoneCode: string) => void;
onFail?: (message: string) => void;
};
/**
* 小程序手机号快捷登录(open-type=getPhoneNumber)。
* 文案不得使用「微信」字样或仿官方图标,以符合审核要求。
*/
export default function PhoneQuickLoginButton({
loading = false,
disabled = false,
agreed,
onRequireAgree,
onGetPhoneNumber,
onFail,
}: PhoneQuickLoginButtonProps) {
const inactive = loading || disabled;
const className = `login-phone-quick-btn${inactive ? ' login-phone-quick-btn--disabled' : ''}`;
const label = loading ? '登录中...' : '手机号快捷登录';
if (!agreed) {
return (
<View className={className} onClick={inactive ? undefined : onRequireAgree}>
<Text className="login-phone-quick-btn__text">{label}</Text>
</View>
);
}
return (
<Button
className={className}
openType={inactive ? undefined : 'getPhoneNumber'}
hoverClass="none"
onGetPhoneNumber={(e) => {
if (inactive) return;
const detail = e.detail as {
errMsg?: string;
code?: string;
errno?: number;
};
if (!detail?.code) {
const denied =
detail?.errMsg?.includes('deny') ||
detail?.errMsg?.includes('cancel') ||
detail?.errno === 103;
onFail?.(denied ? '已取消手机号授权' : detail?.errMsg || '获取手机号失败');
return;
}
onGetPhoneNumber(detail.code);
}}
>
<Text className="login-phone-quick-btn__text">{label}</Text>
</Button>
);
}
@@ -0,0 +1,46 @@
import { useState } from 'react';
import { View, Image, Swiper, SwiperItem } from '@tarojs/components';
type ProductCarouselProps = {
images: string[];
alt: string;
variant?: 'home' | 'detail' | 'store';
};
/** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */
export default function ProductCarousel({ images, alt, variant = 'detail' }: ProductCarouselProps) {
const slides = images.length > 0 ? images : [''];
const [activeIndex, setActiveIndex] = useState(0);
const prefix =
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
return (
<View className={`${prefix}-wrap`}>
<Swiper
className={prefix}
circular={slides.length > 1}
onChange={(e) => setActiveIndex(e.detail.current)}
>
{slides.map((src, index) => (
<SwiperItem key={`${src}-${index}`} className={`${prefix}-item`}>
{src ? (
<Image className={`${prefix}-image`} src={src} mode="aspectFill" alt={alt} />
) : (
<View className={`${prefix}-placeholder`} />
)}
</SwiperItem>
))}
</Swiper>
{slides.length > 1 ? (
<View className={`${prefix}-dots`}>
{slides.map((_, index) => (
<View
key={index}
className={`${prefix}-dot${index === activeIndex ? ` ${prefix}-dot--active` : ''}`}
/>
))}
</View>
) : null}
</View>
);
}
@@ -0,0 +1,75 @@
import { useEffect, useState } from 'react';
import { View, Canvas } from '@tarojs/components';
import Taro from '@tarojs/taro';
import {
REDEEM_QR_DISPLAY_SIZE,
buildRedeemQrDataUrl,
drawRedeemQrOnCanvas,
} from '../lib/redeem-qr';
const CANVAS_ID = 'redeem-qr-canvas';
type RedeemQrCodeProps = {
token: string;
};
function drawOnWeappCanvas(token: string) {
const page = Taro.getCurrentInstance().page;
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
query
.select(`#${CANVAS_ID}`)
.fields({ node: true, size: true })
.exec((res) => {
const item = res[0] as { node?: WechatMiniprogram.Canvas; width?: number; height?: number } | undefined;
const canvas = item?.node;
if (!canvas) return;
const layoutW = item.width || REDEEM_QR_DISPLAY_SIZE;
const layoutH = item.height || REDEEM_QR_DISPLAY_SIZE;
const drawSize = Math.min(layoutW, layoutH);
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
const dpr = Taro.getSystemInfoSync().pixelRatio || 2;
canvas.width = layoutW * dpr;
canvas.height = layoutH * dpr;
ctx.scale(dpr, dpr);
drawRedeemQrOnCanvas(ctx, token, drawSize);
});
}
export default function RedeemQrCode({ token }: RedeemQrCodeProps) {
const [imgSrc, setImgSrc] = useState('');
const isWeapp = process.env.TARO_ENV === 'weapp';
useEffect(() => {
if (!token) {
setImgSrc('');
return;
}
if (isWeapp) {
const timer = setTimeout(() => drawOnWeappCanvas(token), 120);
return () => clearTimeout(timer);
}
let cancelled = false;
void buildRedeemQrDataUrl(token).then((url) => {
if (!cancelled) setImgSrc(url);
});
return () => {
cancelled = true;
};
}, [token, isWeapp]);
return (
<View className="redeem-qr-box">
<View className="redeem-qr-placeholder" />
{isWeapp ? (
<Canvas type="2d" id={CANVAS_ID} canvasId={CANVAS_ID} className="redeem-qr-canvas" />
) : imgSrc ? (
<View className="redeem-qr-img" style={{ backgroundImage: `url(${imgSrc})` }} />
) : null}
<View className="redeem-qr-scanline" />
</View>
);
}
@@ -0,0 +1,186 @@
import { useEffect, useMemo, useState } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import {
REGION_ALL,
getCities,
getCitiesForPicker,
getDistricts,
getDistrictsForPicker,
getProvincesForPicker,
normalizeRegionSelection,
toCityLevelRegion,
type RegionSelection,
} from '../lib/region-data';
type RegionPickerProps = {
open: boolean;
value: RegionSelection;
onClose: () => void;
onConfirm: (region: RegionSelection) => void;
levels?: 2 | 3;
};
type PickerLevel = 'province' | 'city' | 'district';
const ALL_TABS: Array<{ key: PickerLevel; label: string }> = [
{ key: 'province', label: '省份' },
{ key: 'city', label: '城市' },
{ key: 'district', label: '区县' },
];
function initialTab(value: RegionSelection, levels: 2 | 3): PickerLevel {
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
if (levels === 2) {
return normalized.province && normalized.province !== REGION_ALL ? 'city' : 'province';
}
if (normalized.district && normalized.district !== REGION_ALL) return 'district';
if (normalized.city && normalized.city !== REGION_ALL) return 'city';
return 'province';
}
function tabLabel(tab: PickerLevel, draft: RegionSelection, fallback: string) {
if (tab === 'province') {
return draft.province && draft.province !== REGION_ALL ? draft.province : fallback;
}
if (tab === 'city') {
return draft.city && draft.city !== REGION_ALL ? draft.city : fallback;
}
return draft.district && draft.district !== REGION_ALL ? draft.district : fallback;
}
export default function RegionPicker({
open,
value,
onClose,
onConfirm,
levels = 3,
}: RegionPickerProps) {
const [draft, setDraft] = useState<RegionSelection>(value);
const [activeTab, setActiveTab] = useState<PickerLevel>('province');
const tabs = levels === 2 ? ALL_TABS.slice(0, 2) : ALL_TABS;
useEffect(() => {
if (!open) return;
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
setDraft(normalized);
setActiveTab(initialTab(value, levels));
}, [open, value, levels]);
const listItems = useMemo(() => {
if (activeTab === 'province') return getProvincesForPicker();
if (activeTab === 'city') return getCitiesForPicker(draft.province);
return getDistrictsForPicker(draft.province, draft.city);
}, [activeTab, draft.province, draft.city]);
const selectedValue =
activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district;
const canConfirm =
levels === 2
? Boolean(draft.province && draft.city)
: Boolean(draft.province && draft.city && draft.district);
if (!open) return null;
function selectProvince(province: string) {
if (province === REGION_ALL) {
setDraft({ province: REGION_ALL, city: REGION_ALL, district: REGION_ALL });
setActiveTab('city');
return;
}
const nextCities = getCities(province);
const city = nextCities[0] ?? '';
if (levels === 2) {
setDraft({ province, city, district: REGION_ALL });
setActiveTab('city');
return;
}
const nextDistricts = getDistricts(province, city);
setDraft({ province, city, district: nextDistricts[0] ?? '' });
setActiveTab('city');
}
function selectCity(city: string) {
if (city === REGION_ALL) {
setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL });
if (levels === 3) setActiveTab('district');
return;
}
if (levels === 2) {
setDraft({ ...draft, city, district: REGION_ALL });
return;
}
const nextDistricts = getDistricts(draft.province, city);
setDraft({ ...draft, city, district: nextDistricts[0] ?? '' });
setActiveTab('district');
}
function selectDistrict(district: string) {
setDraft({ ...draft, district });
}
function onSelectItem(item: string) {
if (activeTab === 'province') selectProvince(item);
else if (activeTab === 'city') selectCity(item);
else selectDistrict(item);
}
function onTabClick(tab: PickerLevel) {
if (tab === 'city' && !draft.province) return;
if (tab === 'district' && (!draft.province || !draft.city)) return;
setActiveTab(tab);
}
function handleConfirm() {
if (!canConfirm) return;
const next = levels === 2 ? toCityLevelRegion(draft) : normalizeRegionSelection(draft);
onConfirm(next);
onClose();
}
return (
<View className="region-picker-overlay" onClick={onClose}>
<View className="region-picker-sheet" onClick={(e) => e.stopPropagation()}>
<View className="region-picker-toolbar">
<View className="region-picker-tabs">
{tabs.map((tab) => {
const disabled =
(tab.key === 'city' && !draft.province) ||
(tab.key === 'district' && (!draft.province || !draft.city));
return (
<Text
key={tab.key}
className={`region-picker-tab${activeTab === tab.key ? ' active' : ''}${disabled ? ' disabled' : ''}`}
onClick={() => !disabled && onTabClick(tab.key)}
>
{tabLabel(tab.key, draft, tab.label)}
</Text>
);
})}
</View>
<Text
className={`region-picker-confirm${canConfirm ? ' ready' : ''}`}
onClick={() => canConfirm && handleConfirm()}
>
</Text>
</View>
<ScrollView className="region-picker-list" scrollY showScrollbar={false}>
{listItems.map((item) => (
<View
key={item}
className={`region-picker-option${selectedValue === item ? ' selected' : ''}${
item === REGION_ALL ? ' region-picker-option--all' : ''
}`}
onClick={() => onSelectItem(item)}
>
<Text>{item}</Text>
</View>
))}
</ScrollView>
</View>
</View>
);
}
@@ -0,0 +1,56 @@
.share-guide {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.55);
display: flex;
flex-direction: column;
align-items: flex-end;
padding: 12px 16px;
box-sizing: border-box;
}
.share-guide__arrow {
width: 0;
height: 0;
margin-right: 18px;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 14px solid #fff;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.12));
}
.share-guide__card {
margin-top: 0;
margin-right: 8px;
max-width: 260px;
padding: 16px 18px;
border-radius: 12px;
background: #fff;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18);
text-align: left;
}
.share-guide__title {
display: block;
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 8px;
}
.share-guide__desc {
display: block;
font-size: 13px;
line-height: 1.5;
color: #666;
}
.share-guide__ok {
display: block;
margin-top: 14px;
text-align: right;
font-size: 14px;
font-weight: 600;
color: #a61d24;
}
@@ -0,0 +1,50 @@
import { useState } from 'react';
import { Button, Text, View } from '@tarojs/components';
import { handleShareButtonClick, type PageSharePayload } from '../lib/wechat-share';
import './ShareGuide.css';
type ShareNavButtonProps = {
payload?: PageSharePayload;
};
/**
* 顶栏分享:
* - 小程序:open-type=share 弹出微信分享面板
* - H5 微信:JSSDK share / 右上角引导蒙层
*/
export default function ShareNavButton({ payload }: ShareNavButtonProps) {
const [guideVisible, setGuideVisible] = useState(false);
if (process.env.TARO_ENV === 'weapp') {
return (
<Button className="page-nav-bar__btn page-nav-bar__share-btn" openType="share" hoverClass="none">
<Text className="page-nav-bar__icon page-nav-bar__icon--share"></Text>
</Button>
);
}
return (
<>
<View
className="page-nav-bar__btn"
onClick={() => {
void handleShareButtonClick(payload).then((res) => {
if (res.showGuide) setGuideVisible(true);
});
}}
>
<Text className="page-nav-bar__icon page-nav-bar__icon--share"></Text>
</View>
{guideVisible ? (
<View className="share-guide" onClick={() => setGuideVisible(false)}>
<View className="share-guide__arrow" />
<View className="share-guide__card">
<Text className="share-guide__title"></Text>
<Text className="share-guide__desc"> ··· </Text>
<Text className="share-guide__ok"></Text>
</View>
</View>
) : null}
</>
);
}
@@ -0,0 +1,215 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
type StoreRedeemMarqueeProps = {
lines: string[];
};
const FLY_SPEED = 56;
const MIN_FLY_MS = 2400;
const PAUSE_MIN_MS = 1000;
const PAUSE_MAX_MS = 5000;
const TICK_MS = 16;
/** 全文滚出视口后,再向左多走 10px */
const EXTRA_AFTER_EXIT_PX = 10;
function estimateTextWidth(text: string): number {
let w = 0;
for (const ch of text) {
w += /[^\x00-\xff]/.test(ch) ? 12 : 7;
}
return Math.max(Math.ceil(w), 80);
}
function randomPauseMs() {
return PAUSE_MIN_MS + Math.floor(Math.random() * (PAUSE_MAX_MS - PAUSE_MIN_MS + 1));
}
/** 容器宽兜底(不依赖 DOM 测量,小程序首帧即可用) */
function getBoxWidthFallback(): number {
try {
const sys = Taro.getSystemInfoSync();
const screenW = Number(sys.windowWidth || sys.screenWidth || 375);
// 与 section 同宽:左右 var(--space-page)
return Math.max(220, Math.floor(screenW - 32));
} catch {
return 300;
}
}
function measureBoxWidth(selector: string, fallback: number): Promise<number> {
return new Promise((resolve) => {
Taro.nextTick(() => {
try {
const page = Taro.getCurrentInstance().page;
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
query
.select(selector)
.boundingClientRect()
.exec((res) => {
const w = Number(res?.[0]?.width || 0);
resolve(w > 8 ? Math.ceil(w) : fallback);
});
} catch {
resolve(fallback);
}
});
});
}
function measureTextWidth(selector: string, text: string): Promise<number> {
const fallback = estimateTextWidth(text);
return new Promise((resolve) => {
Taro.nextTick(() => {
try {
const page = Taro.getCurrentInstance().page;
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
query
.select(selector)
.boundingClientRect()
.exec((res) => {
const w = Number(res?.[0]?.width || 0);
if (w > 8 && w < fallback * 3) resolve(Math.ceil(w));
else resolve(fallback);
});
} catch {
resolve(fallback);
}
});
});
}
/**
* 核销走马灯:单条从右向左位移飞出,间隔 1~5 秒随机再播下一条。
*
* 小程序注意:
* - 不用 useReady(子组件内不触发 → opacity 永远 0)
* - 不用 Text + transform(支持差),改用 View + left
* - 字宽用估算,避免屏外元素测宽失败
*/
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
const items = useMemo(
() =>
lines
.map((s) => String(s || '').trim())
.filter(Boolean),
[lines],
);
const rootIdRef = useRef(`smr${Math.random().toString(36).slice(2, 10)}`);
const textIdRef = useRef(`smt${Math.random().toString(36).slice(2, 10)}`);
const indexRef = useRef(0);
const boxWidthRef = useRef(getBoxWidthFallback());
const itemsKey = items.join('\n');
const [displayIndex, setDisplayIndex] = useState(0);
const [leftPx, setLeftPx] = useState(() => boxWidthRef.current);
useEffect(() => {
if (!items.length) return;
let cancelled = false;
const waiters = new Set<ReturnType<typeof setTimeout>>();
let tickTimer: ReturnType<typeof setInterval> | undefined;
const sleep = (ms: number) =>
new Promise<void>((resolve) => {
const id = setTimeout(() => {
waiters.delete(id);
resolve();
}, ms);
waiters.add(id);
});
const clearTick = () => {
if (tickTimer) {
clearInterval(tickTimer);
tickTimer = undefined;
}
};
const fly = (from: number, to: number, durationMs: number) =>
new Promise<void>((resolve) => {
const began = Date.now();
setLeftPx(from);
clearTick();
tickTimer = setInterval(() => {
if (cancelled) {
clearTick();
resolve();
return;
}
const t = Math.min(1, (Date.now() - began) / durationMs);
setLeftPx(from + (to - from) * t);
if (t >= 1) {
clearTick();
resolve();
}
}, TICK_MS);
});
const loop = async () => {
indexRef.current = 0;
setDisplayIndex(0);
const measured = await measureBoxWidth(`#${rootIdRef.current}`, boxWidthRef.current);
boxWidthRef.current = measured;
if (cancelled) return;
while (!cancelled && items.length) {
const idx = indexRef.current % items.length;
const text = items[idx];
const box = boxWidthRef.current;
setDisplayIndex(idx);
const from = box;
setLeftPx(from);
await sleep(48);
if (cancelled) break;
const textW = await measureTextWidth(`#${textIdRef.current}`, text);
// 全文 left 边缘移出容器左边界后再走 10px
const to = -(textW + EXTRA_AFTER_EXIT_PX);
const distance = from - to;
const durationMs = Math.max(MIN_FLY_MS, Math.round((distance / FLY_SPEED) * 1000));
await sleep(32);
if (cancelled) break;
await fly(from, to, durationMs);
if (cancelled) break;
await sleep(randomPauseMs());
if (cancelled) break;
indexRef.current = (idx + 1) % items.length;
}
};
void loop();
return () => {
cancelled = true;
clearTick();
waiters.forEach(clearTimeout);
waiters.clear();
};
}, [itemsKey, items]);
if (!items.length) return null;
const current = items[displayIndex] || items[0];
const innerStyle: CSSProperties = { left: `${leftPx}px` };
return (
<View id={rootIdRef.current} className="store-detail-marquee">
<View className="store-detail-marquee-inner" style={innerStyle}>
<Text id={textIdRef.current} className="store-detail-marquee-text">
{current}
</Text>
</View>
</View>
);
}
@@ -0,0 +1,45 @@
import type { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { subPageNavBarStyle, subPageNavContentStyle, useNavBarMetrics } from '../lib/nav-bar';
type SubPageHeaderProps = {
title: string;
onBack?: () => void;
right?: ReactNode;
};
const isH5 = process.env.TARO_ENV === 'h5';
/** 子页面固定实底顶栏(确认订单 / 地址 / 支付 / 订单列表等) */
export default function SubPageHeader({ title, onBack, right }: SubPageHeaderProps) {
const metrics = useNavBarMetrics();
function handleBack() {
if (onBack) {
onBack();
return;
}
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack();
return;
}
Taro.switchTab({ url: '/pages/home/index' });
}
return (
<View className="sub-page-header" style={subPageNavBarStyle(metrics)} aria-label={title}>
{!isH5 ? <Text className="sub-page-header__title">{title}</Text> : null}
<View
className="sub-page-header__content"
style={subPageNavContentStyle(metrics)}
>
<View className="sub-page-header__back" onClick={handleBack}>
<Text className="sub-page-header__back-icon"></Text>
</View>
{right ? <View className="sub-page-header__right">{right}</View> : null}
</View>
</View>
);
}
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../lib/nav-bar';
type TabMainHeaderProps = {
title: string;
extra?: ReactNode;
};
/**
* Tab 页顶栏:仅在有右侧扩展内容时渲染。
* 小程序 / H5 标题走系统导航栏,避免自定义顶栏造成顶部留白。
*/
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
const metrics = useNavBarMetrics();
if (!extra) {
return null;
}
return (
<View className="tab-main-header" style={navBarStyle(metrics)} aria-label={title}>
{process.env.TARO_ENV !== 'h5' ? (
<Text className="tab-main-header__title">{title}</Text>
) : null}
<View className="tab-main-header__content" style={tabNavContentStyle(metrics)}>
<View className="tab-main-header__extra">{extra}</View>
</View>
</View>
);
}
@@ -0,0 +1,44 @@
.u-tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
justify-content: space-around;
align-items: center;
padding: 8px 12px calc(8px + env(safe-area-inset-bottom, 0px));
background: var(--color-card, #fff);
border-top: 1px solid rgba(226, 190, 188, 0.3);
box-shadow: var(--shadow-tabbar, 0 -4px 20px rgba(0, 0, 0, 0.06));
box-sizing: border-box;
}
.u-tabbar__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 4px;
border-radius: 8px;
color: var(--color-subtle-gray, #999);
}
.u-tabbar__item--active {
color: var(--color-heritage-red, #a61d24);
}
.u-tabbar__icon {
width: 24px;
height: 24px;
display: block;
}
.u-tabbar__label {
font-size: 11px;
font-weight: 500;
line-height: 1.2;
letter-spacing: 0.02em;
}
@@ -0,0 +1,86 @@
import { View, Text, Image } from '@tarojs/components';
import Taro from '@tarojs/taro';
import './UserTabBar.css';
import iconHome from '../assets/tabbar/home.png';
import iconHomeActive from '../assets/tabbar/home-active.png';
import iconStore from '../assets/tabbar/store.png';
import iconStoreActive from '../assets/tabbar/store-active.png';
import iconBenefit from '../assets/tabbar/benefit.png';
import iconBenefitActive from '../assets/tabbar/benefit-active.png';
import iconMine from '../assets/tabbar/mine.png';
import iconMineActive from '../assets/tabbar/mine-active.png';
export const USER_TABS = [
{
pagePath: '/pages/home/index',
text: '首页',
icon: iconHome,
iconActive: iconHomeActive,
},
{
pagePath: '/pages/stores/index',
text: '门店',
icon: iconStore,
iconActive: iconStoreActive,
},
{
pagePath: '/pages/benefit/index',
text: '好客权益',
icon: iconBenefit,
iconActive: iconBenefitActive,
},
{
pagePath: '/pages/mine/index',
text: '我的',
icon: iconMine,
iconActive: iconMineActive,
},
] as const;
type UserTabBarProps = {
selected: number;
};
/** C 端底栏:本地 PNG 图标(weapp 无法加载 Material 字体) */
export default function UserTabBar({ selected }: UserTabBarProps) {
return (
<View className="u-tabbar">
{USER_TABS.map((tab, index) => {
const active = selected === index;
return (
<View
key={tab.pagePath}
className={`u-tabbar__item${active ? ' u-tabbar__item--active' : ''}`}
onClick={() => {
if (!active) Taro.switchTab({ url: tab.pagePath });
}}
>
<Image
className="u-tabbar__icon"
src={active ? tab.iconActive : tab.icon}
mode="aspectFit"
/>
<Text className="u-tabbar__label">{tab.text}</Text>
</View>
);
})}
</View>
);
}
/** weapp custom-tab-bar 选中态同步;H5 无 getTabBar 时忽略 */
export function syncTabBarSelected(index: number) {
try {
const page = Taro.getCurrentInstance().page as
| { getTabBar?: () => { setSelected?: (i: number) => void } }
| undefined;
page?.getTabBar?.()?.setSelected?.(index);
} catch {
/* ignore */
}
}
export function shouldRenderPageTabBar(): boolean {
return process.env.TARO_ENV === 'h5';
}
@@ -0,0 +1,28 @@
import { View, Text } from '@tarojs/components';
type WechatLoginButtonProps = {
loading?: boolean;
disabled?: boolean;
/** 默认「授权登录」,避免使用「微信」字样与官方风格图标 */
label?: string;
onClick: () => void;
};
/** 授权登录按钮(无微信品牌元素,满足小程序审核) */
export default function WechatLoginButton({
loading = false,
disabled = false,
label = '授权登录',
onClick,
}: WechatLoginButtonProps) {
const inactive = loading || disabled;
return (
<View
className={`login-wechat-btn${inactive ? ' login-wechat-btn--disabled' : ''}`}
onClick={inactive ? undefined : onClick}
>
<Text className="login-wechat-btn__text">{loading ? '授权中...' : label}</Text>
</View>
);
}
@@ -0,0 +1,143 @@
import Taro from '@tarojs/taro';
import { captureIosJssdkEntryUrl } from '@dukang/weixin-sdk';
import { useEffect, useRef } from 'react';
import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav';
import { toast } from '../lib/api';
import { capturePromoSceneAndTouchScan } from '../lib/promo';
import { saveWechatLoginResult } from '../lib/pay-wechat';
import { applyWechatShare } from '../lib/wechat-share';
import { handleWechatAuthCallback } from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
function currentPagePathWithQuery(): string {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as
| { route?: string; options?: Record<string, string | undefined> }
| undefined;
if (!cur?.route) {
if (typeof window !== 'undefined') {
return `${window.location.pathname}${window.location.search}`;
}
return '';
}
const path = cur.route.startsWith('/') ? cur.route : `/${cur.route}`;
const opts = cur.options ?? {};
const qs = Object.entries(opts)
.filter(([k, v]) => v != null && v !== '' && k !== 'code' && k !== 'state')
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&');
return qs ? `${path}?${qs}` : path;
}
/**
* H5 App 根节点:iOS 签名 URL + 默认分享 + OAuth code 回调。
* 小程序:冷启动时捕获推广码 scene 并回传扫码埋点。
*/
export default function WechatShareBootstrap() {
const handlingCode = useRef(false);
useEffect(() => {
void capturePromoSceneAndTouchScan();
}, []);
useEffect(() => {
if (process.env.TARO_ENV !== 'h5') return;
if (typeof window === 'undefined') return;
captureIosJssdkEntryUrl();
function refreshShare() {
void applyWechatShare().catch(() => {});
}
function tryHandleOAuth() {
if (!isWechatEnv()) return;
const params = new URLSearchParams(window.location.search);
if (!params.get('code')) return;
if (handlingCode.current) return;
handlingCode.current = true;
const returnFromLogin = (() => {
const path = currentPagePathWithQuery();
if (path.includes('/pages/login/')) {
try {
return decodeURIComponent(params.get('return') || '') || undefined;
} catch {
return undefined;
}
}
return undefined;
})();
handleWechatAuthCallback()
.then((result) => {
if (!result) return;
if (saveWechatLoginResult(result)) {
toast('微信授权成功', 'success');
const ret = returnFromLogin || params.get('return') || undefined;
if (result.accountMerged) {
forceReloadAfterAccountMerge(ret);
return;
}
if (
returnFromLogin !== undefined ||
currentPagePathWithQuery().includes('/pages/login/')
) {
finishLoginNavigate(ret);
}
return;
}
// 兼容旧接口:仅 needBindPhone 时引导可选绑定,不阻塞浏览
if (result.needBindPhone && result.wxSessionKey) {
goLogin(returnFromLogin, {
bindMode: '1',
wxSessionKey: result.wxSessionKey,
});
}
})
.catch((e) => {
toast(e instanceof Error ? e.message : '微信授权失败');
})
.finally(() => {
handlingCode.current = false;
});
}
refreshShare();
tryHandleOAuth();
const onVisible = () => {
if (document.visibilityState === 'visible') refreshShare();
};
const onLocation = () => {
refreshShare();
tryHandleOAuth();
};
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('popstate', onLocation);
const { pushState, replaceState } = window.history;
window.history.pushState = function (...args) {
const ret = pushState.apply(this, args);
window.dispatchEvent(new Event('dukang-h5-route'));
return ret;
};
window.history.replaceState = function (...args) {
const ret = replaceState.apply(this, args);
window.dispatchEvent(new Event('dukang-h5-route'));
return ret;
};
window.addEventListener('dukang-h5-route', onLocation);
return () => {
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('popstate', onLocation);
window.removeEventListener('dukang-h5-route', onLocation);
window.history.pushState = pushState;
window.history.replaceState = replaceState;
};
}, []);
return null;
}
@@ -0,0 +1,46 @@
import { useEffect } from 'react';
import Taro, { useDidShow } from '@tarojs/taro';
import { captureIosJssdkEntryUrl } from '@dukang/weixin-sdk';
import { applyWechatShare, type PageSharePayload } from '../lib/wechat-share';
/**
* H5:进入页面时刷新微信分享卡片;
* 小程序:开启右上角分享菜单。
*/
export default function WechatShareReady({ payload }: { payload?: PageSharePayload }) {
useEffect(() => {
if (process.env.TARO_ENV === 'h5') {
captureIosJssdkEntryUrl();
}
}, []);
useDidShow(() => {
if (process.env.TARO_ENV === 'weapp') {
void Taro.showShareMenu({
withShareTicket: true,
showShareItems: ['shareAppMessage', 'shareTimeline'],
}).catch(() => {
void Taro.showShareMenu({ withShareTicket: true }).catch(() => {});
});
return;
}
void applyWechatShare({
title: payload?.title,
desc: payload?.desc,
imgUrl: payload?.imgUrl,
link: payload?.link,
}).catch(() => {});
});
useEffect(() => {
if (process.env.TARO_ENV !== 'h5') return;
void applyWechatShare({
title: payload?.title,
desc: payload?.desc,
imgUrl: payload?.imgUrl,
link: payload?.link,
}).catch(() => {});
}, [payload?.title, payload?.desc, payload?.imgUrl, payload?.link]);
return null;
}