feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理
订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Image, Text, View } from '@tarojs/components';
|
||||
import iconStoreBenefit from '../assets/icons/store-benefit.png';
|
||||
import iconStoreBenefit from '../assets/icons/store-benefit-y.png';
|
||||
|
||||
type BenefitFigureSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import {
|
||||
BENEFIT_INTRO,
|
||||
BENEFIT_INTRO_EMPHASIS,
|
||||
BENEFIT_RULES_PATH,
|
||||
BENEFIT_SLOGAN,
|
||||
} from '../lib/benefit-copy';
|
||||
|
||||
type BenefitIntroCardProps = {
|
||||
showLink?: boolean;
|
||||
className?: string;
|
||||
/** 左侧金色粗条(门店详情等强调卡) */
|
||||
accent?: boolean;
|
||||
};
|
||||
|
||||
export default function BenefitIntroCard({
|
||||
showLink = false,
|
||||
className,
|
||||
accent = false,
|
||||
}: BenefitIntroCardProps) {
|
||||
const prefix = BENEFIT_INTRO.endsWith(BENEFIT_INTRO_EMPHASIS)
|
||||
? BENEFIT_INTRO.slice(0, -BENEFIT_INTRO_EMPHASIS.length)
|
||||
: BENEFIT_INTRO;
|
||||
|
||||
function goRules() {
|
||||
Taro.navigateTo({ url: BENEFIT_RULES_PATH });
|
||||
}
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<Text className="benefit-intro-card-title">{BENEFIT_SLOGAN}</Text>
|
||||
<Text className="benefit-intro-card-body">
|
||||
{prefix}
|
||||
<Text className="benefit-intro-card-em">{BENEFIT_INTRO_EMPHASIS}</Text>
|
||||
</Text>
|
||||
{showLink ? (
|
||||
<Text className="benefit-intro-card-link" onClick={goRules}>
|
||||
查看完整说明 ›
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`benefit-intro-card${accent ? ' benefit-intro-card--accent' : ''}${className ? ` ${className}` : ''}`}
|
||||
>
|
||||
{accent ? <View className="benefit-intro-card-bar" /> : null}
|
||||
{accent ? <View className="benefit-intro-card-main">{inner}</View> : inner}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { BENEFIT_SLOGAN } from '../lib/benefit-copy';
|
||||
|
||||
type BenefitSloganBarProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function BenefitSloganBar({ className }: BenefitSloganBarProps) {
|
||||
return (
|
||||
<View className={`benefit-slogan-bar${className ? ` ${className}` : ''}`}>
|
||||
<View className="benefit-slogan-bar-accent" />
|
||||
<Text className="benefit-slogan-bar-text">{BENEFIT_SLOGAN}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export const EMPTY_CATEGORY: CategorySelection = {
|
||||
export function formatCategoryLabel(sel: CategorySelection): string {
|
||||
if (sel.childName) return sel.childName;
|
||||
if (sel.parentName) return sel.parentName;
|
||||
return '全部分类';
|
||||
return '全部菜系';
|
||||
}
|
||||
|
||||
type CategoryPickerProps = {
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/* 酒祖杜康开场:黑红底 → GIF 播完消失 → 四字上移 → 副标题跟上 */
|
||||
|
||||
.jiuzu-splash {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10010;
|
||||
overflow: hidden;
|
||||
background-color: #140808;
|
||||
background-image: radial-gradient(ellipse at 50% 42%, #5a1014 0%, #2a080a 48%, #140808 100%);
|
||||
animation: jiuzu-bg-in 0.35s ease-out both;
|
||||
}
|
||||
|
||||
.jiuzu-splash--out {
|
||||
animation: jiuzu-bg-out 0.8s ease-in forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jiuzu-splash-mist {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
filter: blur(48px);
|
||||
}
|
||||
|
||||
.jiuzu-splash-mist--a {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
left: -72px;
|
||||
top: 12%;
|
||||
background: rgba(166, 29, 36, 0.38);
|
||||
}
|
||||
|
||||
.jiuzu-splash-mist--b {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
right: -56px;
|
||||
bottom: 16%;
|
||||
background: rgba(90, 16, 20, 0.5);
|
||||
}
|
||||
|
||||
.jiuzu-splash-gif {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jiuzu-splash-gif--out {
|
||||
animation: jiuzu-bg-out 0.35s ease-in forwards;
|
||||
}
|
||||
|
||||
.jiuzu-splash-gif img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.jiuzu-splash-copy {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 26%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.jiuzu-splash-lockup {
|
||||
position: relative;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.jiuzu-splash-title {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transform: translateY(48vh);
|
||||
}
|
||||
|
||||
.jiuzu-splash--after-gif .jiuzu-splash-title {
|
||||
animation: jiuzu-title-rise 2.4s cubic-bezier(0.22, 1, 0.32, 1) forwards;
|
||||
}
|
||||
|
||||
.jiuzu-splash-mark {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jiuzu-splash--after-gif .jiuzu-splash-mark {
|
||||
animation: fadeInTo4 1s ease-out both;
|
||||
}
|
||||
|
||||
.jiuzu-splash-mark img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.jiuzu-splash-chars {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.jiuzu-splash-char {
|
||||
width: 52px;
|
||||
}
|
||||
|
||||
.jiuzu-splash-char-text {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-family: 'Songti SC', 'STSong', 'Noto Serif SC', 'PingFang SC', serif;
|
||||
font-size: 46px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
color: #f5d76e;
|
||||
text-shadow: 0 0 12px rgba(255, 191, 0, 0.85), 0 0 28px rgba(20, 8, 8, 0.65);
|
||||
}
|
||||
|
||||
.jiuzu-splash-shimmer {
|
||||
position: absolute;
|
||||
top: -10%;
|
||||
bottom: -10%;
|
||||
width: 36px;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 248, 210, 0.55) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
transform: translateX(-80px) skewX(-18deg);
|
||||
}
|
||||
|
||||
.jiuzu-splash--after-gif .jiuzu-splash-shimmer {
|
||||
animation: jiuzu-shimmer 0.7s 2.2s ease-out both;
|
||||
}
|
||||
|
||||
.jiuzu-splash-sub {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: 4px;
|
||||
opacity: 0;
|
||||
transform: translateY(36vh);
|
||||
}
|
||||
|
||||
.jiuzu-splash--after-gif .jiuzu-splash-sub {
|
||||
animation: jiuzu-sub-rise 0.4s 1.3s cubic-bezier(0.22, 1, 0.32, 1) forwards;
|
||||
}
|
||||
|
||||
.jiuzu-splash-sub-text {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.28em;
|
||||
color: rgba(245, 215, 110, 0.88);
|
||||
}
|
||||
|
||||
.jiuzu-splash-skip {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 6;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(245, 215, 110, 0.45);
|
||||
background: rgba(20, 8, 8, 0.35);
|
||||
}
|
||||
|
||||
.jiuzu-splash-skip-text {
|
||||
color: rgba(255, 248, 210, 0.92);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
@keyframes fadeInTo4 {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@keyframes jiuzu-bg-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jiuzu-bg-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jiuzu-title-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(48vh);
|
||||
}
|
||||
14% {
|
||||
opacity: 1;
|
||||
transform: translateY(48vh);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jiuzu-sub-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(36vh);
|
||||
}
|
||||
18% {
|
||||
opacity: 1;
|
||||
transform: translateY(36vh);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jiuzu-shimmer {
|
||||
from {
|
||||
transform: translateX(-80px) skewX(-18deg);
|
||||
opacity: 0.2;
|
||||
}
|
||||
to {
|
||||
transform: translateX(280px) skewX(-18deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Image, Text, View } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { JIUZU_SPLASH_GIF_URL, JIUZU_SPLASH_MARK_URL } from '@dukang/shared-types';
|
||||
import { markJiuzuSplashPlayed } from '../lib/jiuzu-splash';
|
||||
|
||||
const CHARS = ['酒', '祖', '杜', '康'] as const;
|
||||
|
||||
const SPLASH_NAV_BG = '#140808';
|
||||
const HOME_NAV_BG = '#FAF9F7';
|
||||
/** GIF 21 帧 × 80ms,略提前淡出避免循环 */
|
||||
const GIF_MS = 1650;
|
||||
const GIF_FADE_MS = 350;
|
||||
/** 四字显现并升到偏上位置 */
|
||||
const TITLE_MS = 2400;
|
||||
/** 副标题在四字到位后再升起 */
|
||||
const SUB_DELAY_MS = 2300;
|
||||
const SUB_MS = 1400;
|
||||
const HOLD_MS = 900;
|
||||
const FADE_MS = 800;
|
||||
const FADE_AT_MS = GIF_MS + SUB_DELAY_MS + SUB_MS + HOLD_MS;
|
||||
|
||||
type JiuzuSplashProps = {
|
||||
onDone: () => void;
|
||||
};
|
||||
|
||||
function applySplashChrome() {
|
||||
try {
|
||||
void Taro.hideTabBar({ animation: false });
|
||||
} catch {
|
||||
/* H5 无原生 TabBar */
|
||||
}
|
||||
void Taro.setNavigationBarColor({
|
||||
frontColor: '#ffffff',
|
||||
backgroundColor: SPLASH_NAV_BG,
|
||||
animation: { duration: 200, timingFunc: 'easeIn' },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function restoreChrome() {
|
||||
try {
|
||||
void Taro.showTabBar({ animation: false });
|
||||
} catch {
|
||||
/* H5 无原生 TabBar */
|
||||
}
|
||||
void Taro.setNavigationBarColor({
|
||||
frontColor: '#000000',
|
||||
backgroundColor: HOME_NAV_BG,
|
||||
animation: { duration: 200, timingFunc: 'easeOut' },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
export default function JiuzuSplash({ onDone }: JiuzuSplashProps) {
|
||||
const finishedRef = useRef(false);
|
||||
const fadingRef = useRef(false);
|
||||
const onDoneRef = useRef(onDone);
|
||||
onDoneRef.current = onDone;
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const [gifDone, setGifDone] = useState(false);
|
||||
const [gifGone, setGifGone] = useState(false);
|
||||
|
||||
const finish = useCallback(() => {
|
||||
if (finishedRef.current) return;
|
||||
finishedRef.current = true;
|
||||
restoreChrome();
|
||||
onDoneRef.current();
|
||||
}, []);
|
||||
|
||||
const beginExit = useCallback(() => {
|
||||
if (fadingRef.current || finishedRef.current) return;
|
||||
fadingRef.current = true;
|
||||
setLeaving(true);
|
||||
setTimeout(finish, FADE_MS);
|
||||
}, [finish]);
|
||||
|
||||
useEffect(() => {
|
||||
markJiuzuSplashPlayed();
|
||||
applySplashChrome();
|
||||
const gifTimer = setTimeout(() => setGifDone(true), GIF_MS);
|
||||
const gifGoneTimer = setTimeout(() => setGifGone(true), GIF_MS + GIF_FADE_MS);
|
||||
const exitTimer = setTimeout(beginExit, FADE_AT_MS);
|
||||
return () => {
|
||||
clearTimeout(gifTimer);
|
||||
clearTimeout(gifGoneTimer);
|
||||
clearTimeout(exitTimer);
|
||||
if (!finishedRef.current) restoreChrome();
|
||||
};
|
||||
}, [beginExit]);
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`jiuzu-splash${gifDone ? ' jiuzu-splash--after-gif' : ''}${leaving ? ' jiuzu-splash--out' : ''}`}
|
||||
catchMove
|
||||
onTouchMove={(e) => {
|
||||
e.stopPropagation?.();
|
||||
}}
|
||||
>
|
||||
<View className="jiuzu-splash-mist jiuzu-splash-mist--a" />
|
||||
<View className="jiuzu-splash-mist jiuzu-splash-mist--b" />
|
||||
|
||||
{gifGone ? null : (
|
||||
<Image
|
||||
className={`jiuzu-splash-gif${gifDone ? ' jiuzu-splash-gif--out' : ''}`}
|
||||
src={JIUZU_SPLASH_GIF_URL}
|
||||
mode="aspectFill"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View className="jiuzu-splash-copy">
|
||||
<View className="jiuzu-splash-lockup">
|
||||
<Image
|
||||
className="jiuzu-splash-mark"
|
||||
src={JIUZU_SPLASH_MARK_URL}
|
||||
mode="aspectFit"
|
||||
style={{ width: '320px', height: '180px' }}
|
||||
/>
|
||||
<View className="jiuzu-splash-title">
|
||||
<View className="jiuzu-splash-chars">
|
||||
<View className="jiuzu-splash-shimmer" />
|
||||
{CHARS.map((ch) => (
|
||||
<View key={ch} className="jiuzu-splash-char">
|
||||
<Text className="jiuzu-splash-char-text">{ch}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="jiuzu-splash-sub">
|
||||
<Text className="jiuzu-splash-sub-text">千年酒祖 · 杜康好客</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="jiuzu-splash-skip" onClick={finish}>
|
||||
<Text className="jiuzu-splash-skip-text">跳过</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Input } from '@tarojs/components';
|
||||
|
||||
type OrderQtyControlsProps = {
|
||||
value: number;
|
||||
unitLabel: string;
|
||||
onChange: (next: number) => void;
|
||||
};
|
||||
|
||||
const MAX_QTY = 999;
|
||||
|
||||
function parseQty(raw: string): number | null {
|
||||
const n = parseInt(String(raw).replace(/\D/g, ''), 10);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return Math.min(MAX_QTY, Math.max(1, n));
|
||||
}
|
||||
|
||||
/** 下单数量:加减 + 手动输入,旁注单位(瓶/箱) */
|
||||
export default function OrderQtyControls({ value, unitLabel, onChange }: OrderQtyControlsProps) {
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(String(value));
|
||||
}, [value]);
|
||||
|
||||
function current(): number {
|
||||
return parseQty(draft) ?? value;
|
||||
}
|
||||
|
||||
function commit() {
|
||||
const next = parseQty(draft);
|
||||
if (next == null) {
|
||||
setDraft(String(value));
|
||||
return;
|
||||
}
|
||||
setDraft(String(next));
|
||||
if (next !== value) onChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="order-qty-row">
|
||||
<Text>购买数量</Text>
|
||||
<View className="order-qty-controls">
|
||||
<View className="order-qty-btn" onClick={() => onChange(Math.max(1, current() - 1))}>
|
||||
<Text>−</Text>
|
||||
</View>
|
||||
<Input
|
||||
className="order-qty-input"
|
||||
type="number"
|
||||
maxlength={3}
|
||||
value={draft}
|
||||
onInput={(e) => setDraft(String(e.detail.value ?? ''))}
|
||||
onBlur={commit}
|
||||
onConfirm={commit}
|
||||
/>
|
||||
<Text className="order-qty-unit">{unitLabel}</Text>
|
||||
<View className="order-qty-btn" onClick={() => onChange(Math.min(MAX_QTY, current() + 1))}>
|
||||
<Text>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,215 +1,67 @@
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { useMemo } from 'react';
|
||||
import { View, Text, Swiper, SwiperItem } from '@tarojs/components';
|
||||
|
||||
type StoreRedeemMarqueeProps = {
|
||||
lines: string[];
|
||||
export type StoreRedeemMarqueeItem = {
|
||||
userLabel: string;
|
||||
amount: 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;
|
||||
type StoreRedeemMarqueeProps = {
|
||||
items: StoreRedeemMarqueeItem[];
|
||||
};
|
||||
|
||||
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` };
|
||||
const STAY_MS = 20000;
|
||||
|
||||
function MarqueeRow({ item }: { item: StoreRedeemMarqueeItem }) {
|
||||
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 className="store-detail-marquee-inner">
|
||||
<View className="store-detail-marquee-dot" />
|
||||
<Text className="store-detail-marquee-text">{item.userLabel} 刚刚到店核销</Text>
|
||||
<Text className="store-detail-marquee-amount">{item.amount}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** 核销记录:单条静止;多条竖向循环,每条停留 20 秒 */
|
||||
export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
|
||||
const list = useMemo(
|
||||
() =>
|
||||
items
|
||||
.map((row) => ({
|
||||
userLabel: String(row.userLabel || '用户***').trim() || '用户***',
|
||||
amount: String(row.amount || '').trim(),
|
||||
}))
|
||||
.filter((row) => row.amount),
|
||||
[items],
|
||||
);
|
||||
|
||||
if (!list.length) return null;
|
||||
|
||||
if (list.length === 1) {
|
||||
return (
|
||||
<View className="store-detail-marquee">
|
||||
<MarqueeRow item={list[0]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="store-detail-marquee">
|
||||
<Swiper
|
||||
className="store-detail-marquee-swiper"
|
||||
vertical
|
||||
circular
|
||||
autoplay
|
||||
interval={STAY_MS}
|
||||
duration={400}
|
||||
indicatorDots={false}
|
||||
>
|
||||
{list.map((row, index) => (
|
||||
<SwiperItem key={`${row.userLabel}|${row.amount}|${index}`}>
|
||||
<MarqueeRow item={row} />
|
||||
</SwiperItem>
|
||||
))}
|
||||
</Swiper>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 iconBenefitActive from '../assets/icons/store-benefit-y.png';
|
||||
import iconMine from '../assets/tabbar/mine.png';
|
||||
import iconMineActive from '../assets/tabbar/mine-active.png';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user