feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代
This commit is contained in:
@@ -1,159 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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';
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user