feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理

订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 14:35:39 +08:00
parent 3b669f7e38
commit 9c8d5f2cad
125 changed files with 6355 additions and 1436 deletions
@@ -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>
);
}
+1 -1
View File
@@ -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';