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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@dukang/mini-user",
"version": "3.5.10",
"version": "3.5.16",
"private": true,
"description": "杜康好客 · C 端用户微信小程序(Taro)",
"scripts": {
+2 -1
View File
@@ -26,6 +26,7 @@ export default defineAppConfig({
{ root: 'pages/login', pages: ['index'] },
{ root: 'pages/user-agreement', pages: ['index'] },
{ root: 'pages/privacy-policy', pages: ['index'] },
{ root: 'pages/benefit-rules', pages: ['index'] },
{ root: 'pages/invoice-titles', pages: ['index'] },
{ root: 'pages/invoice-apply', pages: ['index'] },
],
@@ -75,7 +76,7 @@ export default defineAppConfig({
pagePath: 'pages/benefit/index',
text: '好客权益',
iconPath: 'assets/tabbar/benefit.png',
selectedIconPath: 'assets/tabbar/benefit-active.png',
selectedIconPath: 'assets/icons/store-benefit-y.png',
},
{
pagePath: 'pages/mine/index',
+2
View File
@@ -6,6 +6,8 @@
@import './styles/benefit.css';
@import './styles/mine.css';
@import './styles/address.css';
@import './styles/benefit-promo.css';
@import './components/JiuzuSplash.css';
page,
body {
+2
View File
@@ -6,12 +6,14 @@ import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
import { installClientErrorReporting } from './lib/client-error';
import { prefetchShareBrandAssets } from './lib/wechat-share';
import { prefetchJiuzuSplashAssets } from './lib/jiuzu-splash';
import './app.css';
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
patchTaroH5Hooks();
installClientErrorReporting();
prefetchShareBrandAssets();
prefetchJiuzuSplashAssets();
function App({ children }: PropsWithChildren) {
const handlingRef = useRef(false);
+2
View File
@@ -4,12 +4,14 @@ import Taro, { useDidShow } from '@tarojs/taro';
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
import { installClientErrorReporting } from './lib/client-error';
import { prefetchShareBrandAssets } from './lib/wechat-share';
import { prefetchJiuzuSplashAssets } from './lib/jiuzu-splash';
import { capturePromoSceneAndTouchScan } from './lib/promo';
import { initClientVersionChecks } from './lib/client-version';
import './app.css';
installClientErrorReporting();
prefetchShareBrandAssets();
prefetchJiuzuSplashAssets();
function App({ children }: PropsWithChildren) {
const handlingRef = useRef(false);
Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

@@ -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';
+68
View File
@@ -0,0 +1,68 @@
export const BENEFIT_SLOGAN = '购杜康好酒,赠用餐权益';
export const BENEFIT_INTRO =
'购杜康好酒,赠用餐权益,到签约饭店核销。权益随酒赠送,仅限签约饭店到店用餐,不可兑现、不可转卖。';
export const BENEFIT_INTRO_EMPHASIS = '不可兑现、不可转卖。';
export const BENEFIT_GIFT_TAG = '买酒即赠用餐权益';
export const BENEFIT_TAG = '好客权益';
export const BENEFIT_RULES_PATH = '/pages/benefit-rules/index';
export const BENEFIT_RULES_TITLE = '好客权益使用说明';
export const BENEFIT_RULES_SUMMARY =
'购杜康好酒,赠用餐权益,到签约饭店核销。权益随酒赠送,仅限杜康好客平台签约饭店到店用餐,不可兑现、不可转卖。';
export const BENEFIT_RULES_SECTIONS = [
{
heading: '一、权益从哪来',
paragraphs: [
'好客权益是购买杜康好酒时随酒赠送的用餐权益,用于在签约饭店到店用餐,不是储值卡、预付卡,也不是现金账户。',
],
},
{
heading: '二、如何获得',
paragraphs: [
'在小程序内购买杜康好酒并支付成功后,系统按商品说明发放对应用餐权益。未支付或已取消的订单不产生权益。不支持单独购买或充值权益。',
],
},
{
heading: '三、如何使用',
paragraphs: [
'1. 在「门店」中选择签约饭店,到店用餐时出示核销码或提供手机号,由门店完成核销。',
'2. 核销码有效期为 3 分钟,过期请重新生成。',
'3. 权益可按实际消费分次核销,累计核销金额不可超过已获得的权益总额。',
],
},
{
heading: '四、使用范围',
paragraphs: [
'仅限杜康好客平台签约饭店到店用餐使用。香烟、酒水一律不可核销。其他菜品是否可核销以门店当场说明为准。',
],
emphasize: '香烟、酒水一律不可核销。',
},
{
heading: '五、使用限制',
paragraphs: ['不可兑现、不可转卖、不可提现、不可充值,不可当现金使用。'],
emphasizeAll: true,
},
{
heading: '六、退货与权益收回',
paragraphs: [
'酒水退货退款时,未使用的权益将收回;已核销部分不退回。酒款按原支付路径退回。仅换货不退款的,一般保留原权益。',
],
},
{
heading: '七、有效期',
paragraphs: ['好客权益暂无使用期限,持续有效,直至收回或全部核销完毕。'],
},
{
heading: '八、联系客服',
paragraphs: ['如有疑问,可通过小程序「联系客服」咨询,或拨打客服电话。'],
},
] as const;
export const BENEFIT_RULES_FOOTER = '请理性饮酒。未满十八周岁不得饮酒。过量饮酒有害健康。';
+1 -1
View File
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro';
import { fetchClientConfig } from './pay-wechat';
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
export const APP_VERSION = '3.5.10';
export const APP_VERSION = '3.5.16';
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
@@ -0,0 +1,316 @@
/** Canvas 金龙:盘成一圈,仿照立体金龙的鳞片、须、角、爪与光晕 */
export type DragonCanvasNode = {
width: number;
height: number;
getContext: (type: '2d') => CanvasRenderingContext2D;
requestAnimationFrame?: (cb: (time: number) => void) => number;
cancelAnimationFrame?: (id: number) => void;
};
type SpinePt = {
x: number;
y: number;
ang: number;
nx: number;
ny: number;
w: number;
};
const GOLD_HI = '#fff6c8';
const GOLD = '#ffbf00';
const GOLD_MID = '#e8a800';
const GOLD_DEEP = '#b87500';
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
function fillOval(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
rw: number,
rh: number,
rot: number,
) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(rot);
ctx.scale(Math.max(0.01, rw), Math.max(0.01, rh));
ctx.beginPath();
ctx.arc(0, 0, 1, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function easeInCubic(t: number) {
return t * t * t;
}
function buildSpine(cx: number, cy: number, r: number, phase: number, segs: number): SpinePt[] {
const pts: SpinePt[] = [];
const turns = 0.94;
for (let i = 0; i < segs; i++) {
const u = i / (segs - 1);
const ang = -Math.PI / 2 + u * Math.PI * 2 * turns;
const wobble = Math.sin(u * 14 + phase) * r * 0.042 + Math.sin(u * 5.5 - phase * 0.7) * r * 0.02;
const rr = r + wobble;
const nx = Math.cos(ang);
const ny = Math.sin(ang);
pts.push({
x: cx + nx * rr,
y: cy + ny * rr,
ang,
nx,
ny,
w: lerp(20, 6.5, u ** 0.62),
});
}
return pts;
}
function strokeRibbon(
ctx: CanvasRenderingContext2D,
pts: SpinePt[],
widthScale: number,
color: string,
alpha: number,
) {
if (pts.length < 2) return;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.lineWidth = pts[Math.floor(pts.length * 0.15)].w * widthScale;
ctx.stroke();
ctx.restore();
}
function drawScales(ctx: CanvasRenderingContext2D, pts: SpinePt[]) {
for (let i = 2; i < pts.length - 1; i += 1) {
const p = pts[i];
const u = i / (pts.length - 1);
const ox = p.x + p.nx * p.w * 0.18;
const oy = p.y + p.ny * p.w * 0.18;
ctx.save();
ctx.translate(ox, oy);
ctx.rotate(p.ang + Math.PI / 2);
ctx.fillStyle = i % 2 === 0 ? GOLD_HI : GOLD;
ctx.globalAlpha = 0.55 + (1 - u) * 0.25;
fillOval(ctx, 0, 0, p.w * 0.55, p.w * 0.38, 0);
ctx.restore();
}
}
function drawSpines(ctx: CanvasRenderingContext2D, pts: SpinePt[]) {
ctx.fillStyle = GOLD_HI;
for (let i = 3; i < pts.length - 6; i += 3) {
const p = pts[i];
const len = p.w * 1.35;
ctx.save();
ctx.globalAlpha = 0.85;
ctx.beginPath();
ctx.moveTo(p.x + p.nx * p.w * 0.2, p.y + p.ny * p.w * 0.2);
ctx.lineTo(
p.x + p.nx * (p.w + len),
p.y + p.ny * (p.w + len),
);
const tx = -p.ny;
const ty = p.nx;
ctx.lineTo(p.x + tx * 2.2, p.y + ty * 2.2);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
function drawClaw(ctx: CanvasRenderingContext2D, p: SpinePt, side: number) {
const tx = -p.ny * side;
const ty = p.nx * side;
const baseX = p.x + tx * p.w * 0.7;
const baseY = p.y + ty * p.w * 0.7;
ctx.save();
ctx.translate(baseX, baseY);
ctx.rotate(Math.atan2(ty, tx));
ctx.fillStyle = GOLD;
ctx.strokeStyle = GOLD_DEEP;
ctx.lineWidth = 0.8;
for (let k = -1; k <= 1; k++) {
ctx.beginPath();
ctx.moveTo(0, k * 4);
ctx.quadraticCurveTo(10, k * 6 - 2, 18, k * 5);
ctx.quadraticCurveTo(10, k * 4, 0, k * 3);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
function drawHead(ctx: CanvasRenderingContext2D, p: SpinePt, phase: number) {
ctx.save();
ctx.translate(p.x + p.nx * 10, p.y + p.ny * 10);
ctx.rotate(Math.atan2(p.ny, p.nx) + Math.PI / 2);
const mane = 6;
for (let i = 0; i < mane; i++) {
const a = -0.9 + (i / (mane - 1)) * 1.8;
ctx.beginPath();
ctx.strokeStyle = i % 2 ? GOLD_HI : GOLD;
ctx.globalAlpha = 0.7;
ctx.lineWidth = 2.2;
ctx.moveTo(Math.sin(a) * 6, -4);
ctx.quadraticCurveTo(Math.sin(a) * 16, -18 - Math.sin(phase + i) * 3, Math.sin(a) * 8, -28);
ctx.stroke();
}
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.moveTo(-7, -18);
ctx.quadraticCurveTo(-16, -32, -5, -38);
ctx.quadraticCurveTo(-2, -26, -3, -16);
ctx.fillStyle = GOLD_MID;
ctx.fill();
ctx.beginPath();
ctx.moveTo(7, -18);
ctx.quadraticCurveTo(16, -32, 5, -38);
ctx.quadraticCurveTo(2, -26, 3, -16);
ctx.fill();
const g = ctx.createRadialGradient(-4, -4, 2, 0, 4, 20);
g.addColorStop(0, GOLD_HI);
g.addColorStop(0.45, GOLD);
g.addColorStop(1, GOLD_DEEP);
ctx.fillStyle = g;
fillOval(ctx, 0, 2, 16, 18, 0);
ctx.fillStyle = GOLD_MID;
fillOval(ctx, 0, 10, 9, 8, 0);
for (const sx of [-6.5, 6.5]) {
ctx.fillStyle = '#3a1a00';
fillOval(ctx, sx, -2, 3.2, 3.6, 0);
ctx.fillStyle = '#ffe566';
fillOval(ctx, sx, -2.4, 1.5, 1.7, 0);
ctx.fillStyle = '#fff';
fillOval(ctx, sx - 0.5, -3, 0.6, 0.6, 0);
}
ctx.strokeStyle = GOLD_HI;
ctx.lineWidth = 1.15;
ctx.globalAlpha = 0.9;
for (const side of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(side * 12, 6);
ctx.quadraticCurveTo(side * 36, 10 + Math.sin(phase) * 2, side * 42, 22);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(side * 10, 9);
ctx.quadraticCurveTo(side * 28, 18, side * 34, 28);
ctx.stroke();
}
ctx.globalAlpha = 1;
ctx.restore();
}
function drawSparks(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
r: number,
phase: number,
) {
for (let i = 0; i < 28; i++) {
const a = (i / 28) * Math.PI * 2 + phase * 0.35;
const rr = r * (0.72 + ((i * 17) % 10) / 40);
const x = cx + Math.cos(a) * rr + Math.sin(phase * 1.4 + i) * 4;
const y = cy + Math.sin(a) * rr + Math.cos(phase * 1.1 + i) * 3;
const s = 1.1 + (i % 5) * 0.35;
ctx.beginPath();
ctx.globalAlpha = 0.25 + (Math.sin(phase * 2 + i) + 1) * 0.25;
ctx.fillStyle = i % 3 === 0 ? GOLD_HI : GOLD;
ctx.arc(x, y, s, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
export function drawJiuzuDragonFrame(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
elapsedMs: number,
) {
const cx = width / 2;
const cy = height * 0.42;
const radius = Math.min(width, height) * 0.3;
const fadeIn = Math.min(1, elapsedMs / 380);
const spinT = Math.min(1, Math.max(0, (elapsedMs - 120) / 2050));
const flyT = Math.min(1, Math.max(0, (elapsedMs - 2200) / 1200));
const spin = spinT * Math.PI * 2;
const fly = easeInCubic(flyT);
const phase = elapsedMs / 220;
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.globalAlpha = fadeIn * (1 - fly);
ctx.translate(cx, cy + fly * -height * 0.42);
ctx.scale(1 + fly * 0.55, 1 + fly * 0.55);
ctx.rotate(spin);
ctx.translate(-cx, -cy);
const pts = buildSpine(cx, cy, radius, phase, 56);
strokeRibbon(ctx, pts, 2.4, 'rgba(255, 191, 0, 0.18)', 1);
strokeRibbon(ctx, pts, 1.55, 'rgba(255, 214, 80, 0.4)', 1);
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
const bodyGrad = ctx.createLinearGradient(cx - radius, cy, cx + radius, cy);
bodyGrad.addColorStop(0, GOLD_DEEP);
bodyGrad.addColorStop(0.5, GOLD);
bodyGrad.addColorStop(1, GOLD_HI);
ctx.strokeStyle = bodyGrad;
ctx.lineWidth = pts[0].w * 1.15;
ctx.shadowColor = 'rgba(255, 191, 0, 0.7)';
ctx.shadowBlur = 16;
ctx.stroke();
ctx.shadowBlur = 0;
ctx.restore();
drawScales(ctx, pts);
drawSpines(ctx, pts);
drawClaw(ctx, pts[Math.floor(pts.length * 0.32)], 1);
drawClaw(ctx, pts[Math.floor(pts.length * 0.68)], -1);
drawHead(ctx, pts[0], phase);
drawSparks(ctx, cx, cy, radius, phase);
ctx.restore();
}
export function scheduleDragonFrame(
canvas: DragonCanvasNode,
cb: (time: number) => void,
): number {
if (typeof canvas.requestAnimationFrame === 'function') {
return canvas.requestAnimationFrame(cb);
}
return requestAnimationFrame(cb);
}
export function cancelDragonFrame(canvas: DragonCanvasNode, id: number) {
if (typeof canvas.cancelAnimationFrame === 'function') {
canvas.cancelAnimationFrame(id);
return;
}
cancelAnimationFrame(id);
}
+22
View File
@@ -0,0 +1,22 @@
import Taro from '@tarojs/taro';
import { JIUZU_SPLASH_GIF_URL, JIUZU_SPLASH_MARK_URL } from '@dukang/shared-types';
/** 冷启动会话内是否已播过「酒祖杜康」开场(进程级,切 Tab 不重播) */
let played = false;
export function hasJiuzuSplashPlayed() {
return played;
}
export function markJiuzuSplashPlayed() {
played = true;
}
/** 冷启动预拉 OSS 开场图(仅 weappH5 的 getImageInfo 会走 CORS */
export function prefetchJiuzuSplashAssets() {
if (played) return;
if (process.env.TARO_ENV !== 'weapp') return;
void Taro.getImageInfo({ src: JIUZU_SPLASH_GIF_URL }).catch(() => {});
void Taro.getImageInfo({ src: JIUZU_SPLASH_MARK_URL }).catch(() => {});
}
+74 -1
View File
@@ -1,7 +1,8 @@
import Taro from '@tarojs/taro';
import { request } from './api';
import { request, isLoggedIn, toast } from './api';
const PROMO_ID_KEY = 'dukang_promo_id';
const ASSOC_SCENE_KEY = 'dukang_partner_assoc_scene';
/** 同一次进入只 touch 一次扫码计数,避免首页反复 onShow 刷量 */
let lastScanTouchKey = '';
@@ -28,10 +29,54 @@ type EnterOptionsLike = {
path?: string;
};
function normalizeAssocScene(raw: unknown): string | null {
if (raw == null || raw === '') return null;
const s = safeDecode(String(raw)).trim();
return /^pa_\d+$/.test(s) ? s : null;
}
function extractAssocSceneFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
if (!opts) return null;
const q = opts.query ?? {};
return (
normalizeAssocScene(q.scene) ||
normalizeAssocScene(opts.scene) ||
normalizeAssocScene(q.partnerId) ||
null
);
}
export function getStoredAssocScene(): string | null {
try {
return normalizeAssocScene(Taro.getStorageSync(ASSOC_SCENE_KEY));
} catch {
return null;
}
}
export function setStoredAssocScene(scene: string) {
const id = normalizeAssocScene(scene);
if (!id) return;
try {
Taro.setStorageSync(ASSOC_SCENE_KEY, id);
} catch {
/* ignore */
}
}
export function clearStoredAssocScene() {
try {
Taro.removeStorageSync(ASSOC_SCENE_KEY);
} catch {
/* ignore */
}
}
/** 从启动/进入参数解析推广活动 ID(优先 query.scene,与 getwxacodeunlimit 一致) */
export function extractPromoIdFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
if (!opts) return null;
const q = opts.query ?? {};
if (extractAssocSceneFromEnterOptions(opts)) return null;
return (
normalizePromoId(q.scene) ||
normalizePromoId(q.promoId) ||
@@ -92,8 +137,35 @@ function readEnterOptions(): EnterOptionsLike | null {
* 主页面进入时:取出 scene(活动 ID)本地缓存,并回传 /promo/touch 累加扫码次数。
* 同一进入会话只计一次扫码。
*/
async function bindStoredAssocIfLoggedIn(): Promise<void> {
const scene = getStoredAssocScene();
if (!scene || !isLoggedIn()) return;
try {
const result = await request<{ alreadyBound?: boolean; bound?: boolean }>('/user/partner-assoc/bind', {
method: 'POST',
data: { scene },
});
clearStoredAssocScene();
if (result.alreadyBound) toast('已关联');
else if (result.bound) toast('关联成功', 'success');
} catch (e) {
const msg = e instanceof Error ? e.message : '';
if (msg.includes('已关联')) {
clearStoredAssocScene();
toast('已关联');
}
}
}
export async function capturePromoSceneAndTouchScan(): Promise<void> {
const opts = readEnterOptions();
const assocScene = extractAssocSceneFromEnterOptions(opts);
if (assocScene) {
setStoredAssocScene(assocScene);
await bindStoredAssocIfLoggedIn();
return;
}
const fromEnter = extractPromoIdFromEnterOptions(opts);
if (fromEnter) {
setStoredPromoId(fromEnter);
@@ -109,6 +181,7 @@ export async function capturePromoSceneAndTouchScan(): Promise<void> {
/** 登录成功后:用已缓存的活动 ID 做归因(不重复加扫码次数) */
export async function touchStoredPromoAfterLogin(): Promise<void> {
await bindStoredAssocIfLoggedIn();
const promoId = getStoredPromoId();
if (!promoId) return;
await touchPromo({ promoId, countScan: false });
+53
View File
@@ -0,0 +1,53 @@
export type StoreCategoryLike = {
id?: string;
name?: string;
parentId?: string | null;
parent?: { name?: string } | null;
};
export type StoreCategoryTreeNode = {
id: string;
name: string;
children?: { id: string; name: string }[];
};
export function storeStarCount(rating?: number | string | null): number {
const n = Number(rating);
if (!Number.isFinite(n) || n <= 0) return 5;
return Math.min(5, Math.max(1, Math.round(n)));
}
export function storeCategoryTags(
store: {
tags?: unknown;
categoryId?: string | null;
category?: StoreCategoryLike | null;
},
tree: StoreCategoryTreeNode[] = [],
): string[] {
const fromJson = Array.isArray(store.tags)
? store.tags.map((t) => String(t).trim()).filter(Boolean)
: [];
if (fromJson.length) return fromJson;
const names: string[] = [];
const childName = String(store.category?.name || '').trim();
const parentName = String(store.category?.parent?.name || '').trim();
if (parentName) names.push(parentName);
if (childName && childName !== parentName) names.push(childName);
const storeCatId = String(store.categoryId || store.category?.id || '');
const storeParentId = String(store.category?.parentId || '');
for (const root of tree) {
if (root.id === storeParentId || root.id === storeCatId) {
if (root.name && !names.includes(root.name)) names.unshift(root.name);
}
for (const child of root.children ?? []) {
if (child.id === storeCatId) {
if (root.name && !names.includes(root.name)) names.unshift(root.name);
if (child.name && !names.includes(child.name)) names.push(child.name);
}
}
}
return names;
}
+7 -2
View File
@@ -19,6 +19,8 @@ export type StoresSessionCategory = {
childName: string;
};
export type StoreSortKey = 'nearby' | 'rating' | 'redeem';
export type StoresListCache = {
cityKey: string;
cityCode: string;
@@ -30,6 +32,7 @@ export type StoresListCache = {
keyword: string;
keywordInput: string;
category: StoresSessionCategory;
sort?: StoreSortKey;
};
type StoresSession = {
@@ -37,7 +40,7 @@ type StoresSession = {
cache: StoresListCache | null;
};
const STORAGE_KEY = 'dukang_stores_session_v1';
const STORAGE_KEY = 'dukang_stores_session_v2';
let memory: StoresSession | null = null;
@@ -94,7 +97,7 @@ export function setStoresListCache(cache: StoresListCache | null): void {
export function patchStoresFilterCache(
patch: Partial<
Pick<StoresListCache, 'filterRegion' | 'keyword' | 'keywordInput' | 'category'>
Pick<StoresListCache, 'filterRegion' | 'keyword' | 'keywordInput' | 'category' | 'sort'>
>,
): void {
const cur = readSession();
@@ -105,6 +108,7 @@ export function patchStoresFilterCache(
cache: {
cityKey: '',
cityCode: '',
authKey: '',
listRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
items: [],
filterRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
@@ -116,6 +120,7 @@ export function patchStoresFilterCache(
childId: '',
childName: '',
},
sort: patch.sort ?? 'nearby',
},
});
return;
@@ -0,0 +1,59 @@
import Taro from '@tarojs/taro';
import { STORE_RATING_MAX_IMAGES } from '@dukang/shared-types';
export async function chooseAndUploadRatingImages(already: number): Promise<string[]> {
const remain = STORE_RATING_MAX_IMAGES - already;
if (remain <= 0) {
throw new Error(`最多上传${STORE_RATING_MAX_IMAGES}张图片`);
}
const picked = await Taro.chooseImage({
count: remain,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
});
const paths = picked.tempFilePaths || [];
if (!paths.length) return [];
const urls: string[] = [];
for (const path of paths) {
urls.push(await uploadRatingImage(path));
}
return urls;
}
export async function uploadRatingImage(tempFilePath: string): Promise<string> {
const { API_BASE, CLIENT_APP, getToken } = await import('./api');
const { compressWeappImageIfNeeded } = await import('./compress-image');
const token = getToken();
if (!token) throw new Error('请先登录');
const filePath = await compressWeappImageIfNeeded(tempFilePath);
const res = await Taro.uploadFile({
url: `${API_BASE}/common/resources/upload`,
filePath,
name: 'file',
formData: {
bizType: 'STORE_RATING',
mediaType: 'IMAGE',
},
header: {
Authorization: `Bearer ${token}`,
'X-Client-App': CLIENT_APP,
},
});
let body: { code?: number; message?: string; data?: { url?: string } } = {};
try {
body = JSON.parse(String(res.data || '{}')) as typeof body;
} catch {
throw new Error('图片上传响应异常');
}
if (res.statusCode === 401 || body.code === 401) {
throw new Error(body.message || '登录已过期,请重新登录');
}
if (res.statusCode >= 400 || body.code !== 0 || !body.data?.url) {
throw new Error(body.message || '图片上传失败');
}
return body.data.url;
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '好客权益使用说明',
});
@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components';
import '../../styles/legal.css';
import Taro from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { toast } from '../../lib/api';
import { getBrandAssetsSync, loadBrandAssets } from '../../lib/brand-assets';
import {
BENEFIT_RULES_FOOTER,
BENEFIT_RULES_SECTIONS,
BENEFIT_RULES_SUMMARY,
BENEFIT_RULES_TITLE,
} from '../../lib/benefit-copy';
export default function BenefitRulesPage() {
const [phone, setPhone] = useState(() => getBrandAssetsSync().customerServicePhone);
useEffect(() => {
void loadBrandAssets().then((brand) => setPhone(brand.customerServicePhone));
}, []);
function dial() {
const tel = phone.replace(/-/g, '');
Taro.makePhoneCall({ phoneNumber: tel }).catch(() => toast('无法拨打电话'));
}
return (
<PageShell variant="sub" className="legal-page benefit-rules-page">
<SubPageHeader title={BENEFIT_RULES_TITLE} />
<View className="sub-page-body inset-page legal-body">
<View className="benefit-rules-summary">
<Text className="benefit-rules-summary-text">
{BENEFIT_RULES_SUMMARY.replace(/不可兑现、不可转卖。$/, '')}
<Text className="benefit-rules-em"></Text>
</Text>
</View>
{BENEFIT_RULES_SECTIONS.map((section) => (
<View key={section.heading} className="benefit-rules-section">
<View className="benefit-rules-heading-row">
<View className="benefit-rules-heading-bar" />
<Text className="benefit-rules-heading">{section.heading}</Text>
</View>
{section.paragraphs.map((p, i) => {
const emphasize = 'emphasize' in section ? section.emphasize : '';
const emphasizeAll = 'emphasizeAll' in section && section.emphasizeAll;
if (emphasizeAll) {
return (
<Text key={`${section.heading}-${i}`} className="benefit-rules-paragraph benefit-rules-em">
{p}
</Text>
);
}
if (emphasize && p.includes(emphasize)) {
const [before, after] = p.split(emphasize);
return (
<Text key={`${section.heading}-${i}`} className="benefit-rules-paragraph">
{before}
<Text className="benefit-rules-em">{emphasize}</Text>
{after}
</Text>
);
}
return (
<Text key={`${section.heading}-${i}`} className="benefit-rules-paragraph">
{p}
</Text>
);
})}
{section.heading === '八、联系客服' ? (
<Text className="benefit-rules-phone" onClick={dial}>
{phone}
</Text>
) : null}
</View>
))}
<Text className="benefit-rules-footer">{BENEFIT_RULES_FOOTER}</Text>
</View>
</PageShell>
);
}
+68 -36
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import WechatShareReady from '../../components/WechatShareReady';
@@ -7,14 +7,16 @@ import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../co
import BenefitFigure from '../../components/BenefitFigure';
import { goLogin } from '../../lib/auth-nav';
import { isLoggedIn, request, toast } from '../../lib/api';
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../../lib/nav-bar';
import { navBarStyle, useNavBarMetrics } from '../../lib/nav-bar';
import {
buildSceneSharePayload,
toWeappShareMessage,
toWeappShareTimeline,
} from '../../lib/wechat-share';
import { formatMoney } from '../../lib/money';
import iconBenefit from '../../assets/tabbar/benefit-active.png';
import { BENEFIT_SLOGAN } from '../../lib/benefit-copy';
import BenefitIntroCard from '../../components/BenefitIntroCard';
import type { StoreRatingDto } from '@dukang/shared-types';
type BenefitSummary = {
totalBalance: number;
@@ -36,10 +38,30 @@ type RedeemHistoryItem = {
id: string;
redeemNo: string;
amount: number;
storeId?: string;
storeName: string;
createdAt: string;
rating?: StoreRatingDto | null;
};
const BENEFIT_TAB_KEY = 'dukang_benefit_tab';
function readBenefitTab(): 'available' | 'history' {
try {
return Taro.getStorageSync(BENEFIT_TAB_KEY) === 'history' ? 'history' : 'available';
} catch {
return 'available';
}
}
function writeBenefitTab(next: 'available' | 'history') {
try {
Taro.setStorageSync(BENEFIT_TAB_KEY, next);
} catch {
/* ignore */
}
}
function usagePercent(coupon: CouponItem) {
const total = Number(coupon.totalAmount);
if (total <= 0) return 0;
@@ -52,7 +74,7 @@ export default function BenefitPage() {
const [summary, setSummary] = useState<BenefitSummary | null>(null);
const [coupons, setCoupons] = useState<CouponItem[]>([]);
const [redeemHistory, setRedeemHistory] = useState<RedeemHistoryItem[]>([]);
const [tab, setTab] = useState<'available' | 'history'>('available');
const [tab, setTab] = useState<'available' | 'history'>(readBenefitTab);
const resetGuestState = useCallback(() => {
setSummary(null);
@@ -119,18 +141,7 @@ export default function BenefitPage() {
<PageShell variant="tab" className="benefit-page">
<WechatShareReady payload={sharePayload} />
<View className="benefit-header" style={navBarStyle(metrics)} aria-label="好客权益">
{process.env.TARO_ENV !== 'h5' ? (
<Text className="benefit-header-title"></Text>
) : null}
<View
className="benefit-header__content"
style={tabNavContentStyle(metrics)}
>
<View className="benefit-header-city">
<View className="benefit-header-city-pin" />
<Text></Text>
</View>
</View>
<Text className="benefit-header-title"></Text>
</View>
{!loggedIn ? (
@@ -146,40 +157,50 @@ export default function BenefitPage() {
</View>
) : (
<View className="benefit-main">
<BenefitIntroCard className="benefit-intro-card--page" />
<View className="benefit-hero">
<View className="benefit-hero-top">
<View>
<Text className="benefit-hero-label"></Text>
<View className="benefit-hero-amount">
<BenefitFigure
value={summary ? formatMoney(summary.totalBalance) : '--'}
size="xl"
className="benefit-hero-value"
/>
</View>
</View>
<View className="benefit-hero-logo">
<Image className="benefit-hero-logo-img" src={iconBenefit} mode="aspectFit" />
<Text className="benefit-hero-label"></Text>
<View className="benefit-hero-amount">
<BenefitFigure
value={summary ? formatMoney(summary.totalBalance) : '--'}
size="xl"
className="benefit-hero-value"
/>
</View>
</View>
<View
className="benefit-hero-cta"
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
>
<Text>使</Text>
<View className="benefit-hero-actions">
<View
className="benefit-hero-cta benefit-hero-cta--primary"
onClick={() => Taro.switchTab({ url: '/pages/home/index' })}
>
<Text></Text>
</View>
<View
className="benefit-hero-cta benefit-hero-cta--secondary"
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
>
<Text>使</Text>
</View>
</View>
</View>
<View className="benefit-tabs">
<Text
className={`benefit-tab${tab === 'available' ? ' benefit-tab--active' : ''}`}
onClick={() => setTab('available')}
onClick={() => {
setTab('available');
writeBenefitTab('available');
}}
>
</Text>
<Text
className={`benefit-tab${tab === 'history' ? ' benefit-tab--active' : ''}`}
onClick={() => setTab('history')}
onClick={() => {
setTab('history');
writeBenefitTab('history');
}}
>
</Text>
@@ -187,7 +208,7 @@ export default function BenefitPage() {
{tab === 'available' ? (
available.length === 0 ? (
<View className="u-empty"></View>
<View className="u-empty">{BENEFIT_SLOGAN}</View>
) : (
available.map((c) => (
<View key={c.id} className="benefit-coupon">
@@ -243,6 +264,17 @@ export default function BenefitPage() {
<Text className="benefit-coupon-meta">
{r.createdAt ? String(r.createdAt).slice(0, 19).replace('T', ' ') : '-'}
</Text>
<Text
className={`benefit-coupon-btn${r.rating ? ' benefit-coupon-btn--ghost' : ''}`}
onClick={() => {
writeBenefitTab('history');
Taro.navigateTo({
url: `/pages/redeem-success/index?id=${r.id}&from=history`,
});
}}
>
{r.rating ? '已评价' : '去评价'}
</Text>
</View>
</View>
))
+38 -3
View File
@@ -9,10 +9,13 @@ import Taro, {
} from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
import CouponBadge from '../../components/CouponBadge';
import BenefitSloganBar from '../../components/BenefitSloganBar';
import { BENEFIT_GIFT_TAG, BENEFIT_TAG } from '../../lib/benefit-copy';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import JiuzuSplash from '../../components/JiuzuSplash';
import { goLogin } from '../../lib/auth-nav';
import { hasJiuzuSplashPlayed } from '../../lib/jiuzu-splash';
import { getToken, isLoggedIn, request, toast } from '../../lib/api';
import {
getHomeCatalogCache,
@@ -36,6 +39,7 @@ import {
toWeappShareTimeline,
} from '../../lib/wechat-share';
import { trackPageView } from '../../lib/analytics';
import iconStoreBenefit from '../../assets/icons/store-benefit-y.png';
type Product = {
id: string;
name: string;
@@ -71,7 +75,15 @@ function aromaSectionId(key: AromaKey) {
return `aroma-section-${key}`;
}
/** 商品图左上角权益角标:权益额 = benefitDisplay ?? price */
function formatBenefitCorner(p: Product): string {
const n = Number(p.benefitDisplay ?? p.price);
if (!Number.isFinite(n) || n <= 0) return '';
return String(Math.round(n));
}
export default function HomePage() {
const [showSplash, setShowSplash] = useState(() => !hasJiuzuSplashPlayed());
const [activeAroma, setActiveAroma] = useState<AromaKey>('QINGXIANG');
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
@@ -282,6 +294,7 @@ export default function HomePage() {
function renderProductCard(p: Product) {
const thumb = getProductMainImage(p);
const spec = p.subtitle || p.spec || '';
const benefitCorner = formatBenefitCorner(p);
return (
<View key={p.id} className="home-product-card">
<View className="home-product-card-inner" onClick={() => openProductDetail(p.id)}>
@@ -291,6 +304,20 @@ export default function HomePage() {
) : (
<View className="home-product-thumb home-product-thumb--empty" />
)}
{benefitCorner ? (
<View className="home-benefit-ribbon-clip">
<View className="home-benefit-ribbon">
<View className="home-benefit-ribbon-dk">
<Image
className="home-benefit-ribbon-dk-icon"
src={iconStoreBenefit}
mode="aspectFit"
/>
</View>
<Text className="home-benefit-ribbon-num">{benefitCorner}</Text>
</View>
</View>
) : null}
</View>
<View className="home-product-main">
<View className="home-product-row">
@@ -299,7 +326,10 @@ export default function HomePage() {
</View>
{spec ? <Text className="home-product-sub">{spec}</Text> : null}
<View className="home-product-footer">
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
<View className="home-product-tags">
<Text className="home-gift-tag">{BENEFIT_GIFT_TAG}</Text>
{/* <Text className="home-benefit-tag">{BENEFIT_TAG}</Text> */}
</View>
</View>
<View className="home-product-actions">
{canPickupOnSite(p) ? (
@@ -354,6 +384,10 @@ export default function HomePage() {
</View>
) : null}
<View className="home-slogan-wrap">
<BenefitSloganBar />
</View>
<View className="home-aroma-nav">
<View className="home-aroma-tabs">
{visibleAromaTabs.map((t) => (
@@ -393,7 +427,8 @@ export default function HomePage() {
</View>
) : null}
{shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null}
{shouldRenderPageTabBar() && !showSplash ? <UserTabBar selected={0} /> : null}
{showSplash ? <JiuzuSplash onDone={() => setShowSplash(false)} /> : null}
</PageShell>
);
}
+5 -6
View File
@@ -44,6 +44,8 @@ import iconCs from '../../assets/icons/联系客服.png';
import iconQualification from '../../assets/icons/资质公示.png';
import iconAbout from '../../assets/icons/关于我们.png';
import { formatMoney } from '../../lib/money';
import BenefitSloganBar from '../../components/BenefitSloganBar';
import { BENEFIT_RULES_PATH } from '../../lib/benefit-copy';
const ORDER_SHORTCUTS = [
{ tab: 'pending_pay', icon: iconPendingPay, label: '待付款' },
@@ -56,7 +58,7 @@ const SERVICES = [
{ icon: iconStores, label: '可用门店', tab: '/pages/stores/index' },
{ icon: iconCs, label: '联系客服', url: '/pages/customer-service/index' },
{ icon: iconQualification, label: '资质公示', action: 'qualification' as const },
{ icon: iconAbout, label: '关于我们', action: 'about' as const },
{ icon: iconAbout, label: '好客权益规则', url: BENEFIT_RULES_PATH },
{ icon: iconAbout, label: '发票管理', url: '/pages/invoice-titles/index' },
] as const;
@@ -309,10 +311,6 @@ export default function MinePage() {
}
if ('action' in item && item.action === 'qualification') {
setQualificationOpen(true);
return;
}
if ('action' in item && item.action === 'about') {
toast('杜康好客 · 传承千年酒文化');
}
}
@@ -435,6 +433,7 @@ export default function MinePage() {
<View className="mine-main">
<View className="mine-card">
<BenefitSloganBar className="mine-benefit-slogan" />
<View className="mine-card-head">
<Text className="mine-card-title"></Text>
<Text
@@ -446,7 +445,7 @@ export default function MinePage() {
</View>
<View className="mine-asset-panel">
<View>
<Text className="mine-asset-label"></Text>
<Text className="mine-asset-label"></Text>
<View className="mine-asset-amount">
<BenefitFigure value={formatMoney(benefitBalance)} size="lg" className="mine-asset-value" />
</View>
@@ -11,6 +11,7 @@ import { fetchUserProfile } from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images';
import BenefitFigure from '../../components/BenefitFigure';
import OrderQtyControls from '../../components/OrderQtyControls';
type PreviewProduct = {
id: string;
@@ -211,21 +212,11 @@ export default function OrderConfirmPickupPage() {
</Text>
</View>
</View>
<View className="order-qty-row">
<Text></Text>
<View className="order-qty-controls">
<View
className="order-qty-btn"
onClick={() => updateQuantity(quantity - 1)}
>
<Text></Text>
</View>
<Text className="order-qty-value">{quantity}</Text>
<View className="order-qty-btn" onClick={() => updateQuantity(quantity + 1)}>
<Text></Text>
</View>
</View>
</View>
<OrderQtyControls
value={quantity}
unitLabel={unitLabel}
onChange={updateQuantity}
/>
{!quantityOk ? (
<Text className="order-qty-hint">
{`现场提货至少购买 ${minQty}${unitLabel},请调整数量`}
@@ -16,6 +16,7 @@ import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment'
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
import { getProductMainImage } from '../../lib/product-images';
import BenefitFigure from '../../components/BenefitFigure';
import OrderQtyControls from '../../components/OrderQtyControls';
type Address = {
id: string;
@@ -379,24 +380,11 @@ export default function OrderConfirmPage() {
<Text className="order-product-price">¥{Number(preview.product.price).toFixed(2)}</Text>
</View>
</View>
<View className="order-qty-row">
<Text></Text>
<View className="order-qty-controls">
<View
className="order-qty-btn"
onClick={() => updateQuantity(quantity - 1)}
>
<Text></Text>
</View>
<Text className="order-qty-value">{quantity}</Text>
<View
className="order-qty-btn"
onClick={() => updateQuantity(quantity + 1)}
>
<Text></Text>
</View>
</View>
</View>
<OrderQtyControls
value={quantity}
unitLabel={unitLabel}
onChange={updateQuantity}
/>
{!quantityOk ? (
<Text className="order-qty-hint">
{isCross
@@ -340,7 +340,7 @@ export default function OrderDetailPage() {
<Text className="order-card-title"></Text>
<View className="order-row">
<Text className="order-row-label">{productName}</Text>
<Text className="order-row-value">x{quantity}</Text>
<Text className="order-row-value">x{quantity}</Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
+1 -1
View File
@@ -159,7 +159,7 @@ export default function OrdersPage() {
<View style={{ flex: 1, minWidth: 0 }}>
<Text className="order-list-name">{productName}</Text>
<View className="order-list-meta-row">
<Text className="order-list-meta"> {qty}</Text>
<Text className="order-list-meta"> {qty}</Text>
<Text className="order-list-meta"> ¥{unitPrice.toFixed(2)}</Text>
</View>
</View>
+2 -2
View File
@@ -19,7 +19,7 @@ import { applyWechatLoginResult } from '../../lib/wechat-auth';
import { isWechatEnv } from '../../lib/weixin';
import { goLogin } from '../../lib/auth-nav';
import { request, toast } from '../../lib/api';
import payLogo from '../../assets/logo2.png';
import { getBrandAssetsSync } from '../../lib/brand-assets';
export default function PayPage() {
const router = useRouter();
@@ -173,7 +173,7 @@ export default function PayPage() {
<View className="sub-page-body">
<View className="pay-status">
<View className="pay-status-icon">
<Image className="pay-status-brand" src={payLogo} mode="aspectFit" />
<Image className="pay-status-brand" src={getBrandAssetsSync().brandLogoMarkUrl} mode="aspectFit" />
</View>
<Text className="pay-status-title">
{needsWechatAuth ? '需完成微信授权' : '待支付'}
@@ -135,7 +135,7 @@ export default function PickupReceivePage() {
<View style={{ flex: 1 }}>
<Text className="order-product-name">{name}</Text>
{spec ? <Text className="u-muted">{spec}</Text> : null}
<Text className="u-muted">×{order.quantity ?? 1}</Text>
<Text className="u-muted">×{order.quantity ?? 1}</Text>
</View>
</View>
</View>
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import '../../styles/product-detail.css';
import '../../styles/benefit-promo.css';
import Taro, {
useDidShow,
usePageScroll,
@@ -13,7 +14,7 @@ import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel';
import WechatShareReady from '../../components/WechatShareReady';
import BenefitFigure from '../../components/BenefitFigure';
import BenefitIntroCard from '../../components/BenefitIntroCard';
import { goLogin } from '../../lib/auth-nav';
import { ensurePayReady } from '../../lib/pay-ready';
import { isLoggedIn, request, toast } from '../../lib/api';
@@ -156,9 +157,6 @@ export default function ProductDetailPage() {
}, [product, specEnabled, skus, selected, attrs]);
const displayPrice = activeSku ? Number(activeSku.price) : Number(product?.price ?? 0);
const displayBenefit = activeSku
? Number(activeSku.benefitAmount)
: Number(product?.benefitDisplay ?? product?.benefitAmount ?? product?.price ?? 0);
const fulfillment = activeSku
? {
allowOnlinePurchase: activeSku.allowOnlinePurchase,
@@ -276,6 +274,8 @@ export default function ProductDetailPage() {
</View>
<View className="product-detail-info">
<BenefitIntroCard showLink className="product-detail-benefit-intro" />
<View className="product-detail-price">
<Text className="product-detail-price-symbol">¥</Text>
<Text className="product-detail-price-value">{displayPrice.toFixed(2)}</Text>
@@ -317,22 +317,6 @@ export default function ProductDetailPage() {
))}
</View>
) : null}
<View className="product-detail-promo">
<View className="product-detail-promo-glow" />
<View className="product-detail-promo-head">
<View className="product-detail-promo-icon">
<Text className="product-detail-promo-icon-text"></Text>
</View>
<View className="product-detail-promo-title">
<Text> · </Text>
<BenefitFigure value={String(displayBenefit)} size="sm" className="product-detail-promo-amount" />
</View>
</View>
<Text className="product-detail-promo-desc">
</Text>
</View>
</View>
<View className="product-detail-content">
@@ -1,4 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '核销成功',
navigationBarTitleText: '评价门店',
});
+277 -96
View File
@@ -1,16 +1,24 @@
import { useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components';
import { useCallback, useEffect, useState } from 'react';
import { View, Text, Image, Textarea } from '@tarojs/components';
import '../../styles/redeem.css';
import Taro, { useRouter } from '@tarojs/taro';
import Taro, { useLoad, useRouter } from '@tarojs/taro';
import {
STORE_RATING_MAX_COMMENT,
STORE_RATING_MAX_IMAGES,
STORE_RATING_QUICK_TAGS,
type StoreRatingDto,
} from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import { formatShanghaiDateTime } from '../../lib/datetime';
import { formatMoney, toMoneyNumber } from '../../lib/money';
import BenefitFigure from '../../components/BenefitFigure';
import { toMoneyNumber } from '../../lib/money';
import { chooseAndUploadRatingImages } from '../../lib/upload-rating-image';
const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult';
const SCORE_LABELS = ['', '较差', '一般', '还行', '很好', '非常好'] as const;
type RedeemRecord = {
id: string;
redeemNo: string;
@@ -18,59 +26,106 @@ type RedeemRecord = {
storeId: string;
storeName: string;
createdAt: string;
rating?: StoreRatingDto | null;
};
/** 与权益「历史记录」一致:2026-08-03 13:53:03Asia/Shanghai */
function formatChinaDateTime(input?: string | null) {
return formatShanghaiDateTime(input ?? new Date());
function formatAmountYuan(amount: unknown) {
const n = toMoneyNumber(amount);
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
return n.toFixed(2).replace(/\.?0+$/, '');
}
function StarRating({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (score: number) => void;
}) {
return (
<View className="redeem-rating-row">
<Text className="redeem-rating-label">{label}</Text>
<View className="redeem-star-row">
{[1, 2, 3, 4, 5].map((score) => (
<Text
key={score}
className={`redeem-star-btn${score <= value ? ' redeem-star-btn--active' : ''}`}
onClick={() => onChange(score)}
>
</Text>
))}
</View>
</View>
);
function formatVisitLine(createdAt?: string | null, amount?: unknown) {
const full = formatShanghaiDateTime(createdAt ?? new Date());
if (full === '—') return `核销用餐权益 ${formatAmountYuan(amount)}`;
const datePart = full.slice(0, 10);
const time = full.slice(11, 16);
const today = formatShanghaiDateTime(new Date()).slice(0, 10);
const prefix = datePart === today ? '今日' : datePart.slice(5);
return `${prefix} ${time} · 核销用餐权益 ${formatAmountYuan(amount)}`;
}
function readCachedRecord(): RedeemRecord | null {
try {
const cached = Taro.getStorageSync(LAST_REDEEM_RESULT_KEY);
return cached ? (JSON.parse(String(cached)) as RedeemRecord) : null;
} catch {
return null;
}
}
export default function RedeemSuccessPage() {
const router = useRouter();
const [serviceScore, setServiceScore] = useState(5);
const [envScore, setEnvScore] = useState(5);
const [fromHistory, setFromHistory] = useState(false);
const [record, setRecord] = useState<RedeemRecord | null>(null);
const [score, setScore] = useState(5);
const [tags, setTags] = useState<string[]>(['菜品好', '环境佳', '服务周到']);
const [comment, setComment] = useState('');
const [imageUrls, setImageUrls] = useState<string[]>([]);
const [coverUrl, setCoverUrl] = useState('');
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const rated = Boolean(record?.rating);
const record = useMemo<RedeemRecord | null>(() => {
try {
const cached = Taro.getStorageSync(LAST_REDEEM_RESULT_KEY);
return cached ? (JSON.parse(String(cached)) as RedeemRecord) : null;
} catch {
return null;
}
const applyRating = useCallback((rating: StoreRatingDto) => {
const nextScore = Number(rating.serviceScore || rating.envScore || 5);
setScore(Number.isFinite(nextScore) && nextScore > 0 ? Math.min(5, Math.round(nextScore)) : 5);
setTags(Array.isArray(rating.tags) ? rating.tags : []);
setComment(String(rating.comment || ''));
setImageUrls(Array.isArray(rating.imageUrls) ? rating.imageUrls : []);
}, []);
const loadRecord = useCallback(
async (id: string) => {
try {
let data: RedeemRecord | null = null;
try {
data = await request<RedeemRecord>(`/redeem/records/${id}`);
} catch {
const records = await request<{ list?: RedeemRecord[] } | RedeemRecord[]>(
'/redeem/records?page=1&pageSize=50',
);
const list = Array.isArray(records) ? records : records?.list ?? [];
data = list.find((item) => String(item.id) === id) ?? null;
}
if (!data) {
toast('核销记录不存在');
return;
}
setRecord(data);
if (data.rating) applyRating(data.rating);
} catch (e) {
toast(e instanceof Error ? e.message : '加载失败');
}
},
[applyRating],
);
useLoad((options) => {
const id = String(options?.id || router.params.id || '').trim();
const from = String(options?.from || router.params.from || '');
setFromHistory(from === 'history');
if (id) {
void loadRecord(id);
return;
}
const cached = readCachedRecord();
if (cached) setRecord(cached);
});
const amount = toMoneyNumber(record?.amount ?? router.params.amount);
const storeName = record?.storeName || '门店';
const redeemNo = record?.redeemNo || '—';
const redeemedAt = formatChinaDateTime(record?.createdAt);
const visitLine = formatVisitLine(record?.createdAt, amount);
useEffect(() => {
if (!record?.storeId) return;
void request<{ coverUrl?: string | null }>(`/stores/${record.storeId}`)
.then((store) => {
const url = String(store?.coverUrl || '').trim();
if (url) setCoverUrl(url);
})
.catch(() => undefined);
}, [record?.storeId]);
function clearCache() {
try {
@@ -80,81 +135,207 @@ export default function RedeemSuccessPage() {
}
}
function goBenefit() {
function leave() {
clearCache();
const pages = Taro.getCurrentPages();
if (fromHistory && pages.length > 1) {
Taro.navigateBack();
return;
}
Taro.switchTab({ url: '/pages/benefit/index' });
}
function goHome() {
clearCache();
Taro.switchTab({ url: '/pages/home/index' });
function toggleTag(tag: string) {
if (rated) return;
setTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
}
async function addPhotos() {
if (rated || uploading) return;
if (imageUrls.length >= STORE_RATING_MAX_IMAGES) {
toast(`最多上传${STORE_RATING_MAX_IMAGES}张图片`);
return;
}
setUploading(true);
try {
const urls = await chooseAndUploadRatingImages(imageUrls.length);
if (urls.length) setImageUrls((prev) => [...prev, ...urls].slice(0, STORE_RATING_MAX_IMAGES));
} catch (e) {
toast(e instanceof Error ? e.message : '上传失败');
} finally {
setUploading(false);
}
}
function removePhoto(url: string) {
if (rated) return;
setImageUrls((prev) => prev.filter((item) => item !== url));
}
async function submitRatingAndFinish() {
if (!record?.id) {
toast('找不到核销记录');
return;
}
if (rated) {
leave();
return;
}
setLoading(true);
try {
if (record?.id) {
await request('/redeem/ratings', {
method: 'POST',
data: {
redeemRecordId: record.id,
serviceScore,
envScore,
},
});
toast('评价已提交', 'success');
}
} catch {
/* 评价失败不阻塞返回 */
await request('/redeem/ratings', {
method: 'POST',
data: {
redeemRecordId: record.id,
serviceScore: score,
envScore: score,
comment: comment.trim(),
tags,
imageUrls,
},
});
toast('评价已提交', 'success');
setRecord((prev) =>
prev
? {
...prev,
rating: { serviceScore: score, envScore: score, comment, tags, imageUrls },
}
: prev,
);
setTimeout(() => leave(), 400);
} catch (e) {
toast(e instanceof Error ? e.message : '评价失败');
} finally {
setLoading(false);
goBenefit();
}
}
return (
<PageShell variant="sub" className="redeem-success-page">
<SubPageHeader title="核销成功" onBack={goBenefit} />
<View className="sub-page-body">
<View className="redeem-success-icon">
<Text></Text>
</View>
<Text className="redeem-success-title"></Text>
<BenefitFigure value={formatMoney(amount)} size="lg" className="redeem-success-amount" />
<Text className="redeem-success-desc"> {storeName} </Text>
<View className="redeem-success-details">
<View className="redeem-success-detail-row">
<Text></Text>
<Text>{storeName}</Text>
</View>
<View className="redeem-success-detail-row">
<Text></Text>
<Text>{redeemedAt}</Text>
</View>
<View className="redeem-success-detail-row">
<Text></Text>
<Text className="redeem-success-mono">{redeemNo}</Text>
<SubPageHeader title={rated ? '查看评价' : '评价门店'} onBack={leave} />
<View className="sub-page-body review-page-body">
<View className="review-store-card">
{coverUrl ? (
<Image className="review-store-cover" src={coverUrl} mode="aspectFill" />
) : (
<View className="review-store-cover review-store-cover--empty" />
)}
<View className="review-store-meta">
<Text className="review-store-name">{storeName}</Text>
<Text className="review-store-visit">{visitLine}</Text>
</View>
</View>
<View className="redeem-success-rating">
<Text className="redeem-success-rating-title"></Text>
<StarRating label="服务态度" value={serviceScore} onChange={setServiceScore} />
<StarRating label="用餐环境" value={envScore} onChange={setEnvScore} />
<View className="review-card">
<Text className="review-card-title"></Text>
<View className="review-star-row">
{[1, 2, 3, 4, 5].map((value) => (
<Text
key={value}
className={`review-star${value <= score ? ' review-star--active' : ''}`}
onClick={() => {
if (!rated) setScore(value);
}}
>
</Text>
))}
</View>
<Text className="review-score-label">{SCORE_LABELS[score]}</Text>
</View>
<View
className={`redeem-submit${loading ? ' redeem-submit--disabled' : ''}`}
onClick={() => {
if (!loading) void submitRatingAndFinish();
}}
>
<Text>{loading ? '提交中…' : '提交评价并返回'}</Text>
<View className="review-card">
<View className="review-section-head">
<View className="review-section-bar" />
<Text className="review-section-title"></Text>
</View>
<View className="review-tag-list">
{STORE_RATING_QUICK_TAGS.map((tag) => (
<Text
key={tag}
className={`review-tag${tags.includes(tag) ? ' review-tag--active' : ''}`}
onClick={() => toggleTag(tag)}
>
{tag}
</Text>
))}
</View>
</View>
<View className="redeem-cancel-btn" onClick={goHome}>
<Text></Text>
<View className="review-card">
<View className="review-section-head">
<View className="review-section-bar" />
<Text className="review-section-title"></Text>
</View>
{process.env.TARO_ENV === 'h5' ? (
<textarea
className="review-comment review-comment--native"
placeholder="口味、环境、服务都可以写,选填"
rows={4}
maxLength={STORE_RATING_MAX_COMMENT}
value={comment}
disabled={rated}
onChange={(e) => setComment(e.currentTarget.value)}
/>
) : (
<Textarea
className="review-comment"
placeholder="口味、环境、服务都可以写,选填"
maxlength={STORE_RATING_MAX_COMMENT}
value={comment}
disabled={rated}
onInput={(e) => setComment(e.detail.value)}
/>
)}
</View>
<View className="review-card">
<View className="review-section-head">
<View className="review-section-bar" />
<Text className="review-section-title"></Text>
</View>
<View className="review-photos">
{imageUrls.map((url) => (
<View key={url} className="review-photo">
<Image
className="review-photo-img"
src={url}
mode="aspectFill"
onClick={() => Taro.previewImage({ current: url, urls: imageUrls })}
/>
{!rated ? (
<Text className="review-photo-remove" onClick={() => removePhoto(url)}>
×
</Text>
) : null}
</View>
))}
{!rated && imageUrls.length < STORE_RATING_MAX_IMAGES ? (
<View className="review-photo-add" onClick={() => void addPhotos()}>
<Text className="review-photo-add-plus">{uploading ? '…' : '+'}</Text>
<Text className="review-photo-add-text">{uploading ? '上传中' : '添加'}</Text>
</View>
) : null}
</View>
</View>
{!rated ? (
<View
className={`review-submit${loading || uploading ? ' review-submit--disabled' : ''}`}
onClick={() => {
if (!loading && !uploading) void submitRatingAndFinish();
}}
>
<Text>{loading ? '提交中…' : '提交评价'}</Text>
</View>
) : null}
{!rated && !fromHistory ? (
<Text className="review-skip" onClick={leave}>
</Text>
) : null}
<Text className="review-disclaimer"></Text>
</View>
</PageShell>
);
+6 -6
View File
@@ -100,11 +100,11 @@ export default function RedeemPage() {
async function submit() {
const value = Math.round(Number(amount) * 100) / 100;
if (!Number.isFinite(value) || value < MIN_REDEEM_AMOUNT) {
toast(`核销金额不能低于 ${MIN_REDEEM_AMOUNT.toFixed(2)} `);
toast(`核销权益不能低于 ${MIN_REDEEM_AMOUNT.toFixed(2)} `);
return;
}
if (value > redeemableMax) {
toast(couponId ? '核销金额不能超过该权益可用余额' : '核销金额不能超过可用余额');
toast(couponId ? '核销权益不能超过该权益可用核销权益' : '核销权益不能超过可用核销权益');
return;
}
@@ -136,7 +136,7 @@ export default function RedeemPage() {
<View className="sub-page-body">
<View className="redeem-hero">
<Text className="redeem-hero-label">
{couponId ? '当前权益可用余额' : '可用余额'}
{couponId ? '当前可用核销权益' : '可用核销权益'}
</Text>
<BenefitFigure
value={redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
@@ -149,7 +149,7 @@ export default function RedeemPage() {
key={inputKey}
className="redeem-input"
type="digit"
placeholder="输入核销金额"
placeholder="输入核销权益"
placeholderClass="redeem-input-placeholder"
value={amount}
maxlength={12}
@@ -160,7 +160,7 @@ export default function RedeemPage() {
</View>
<View className="redeem-amount-foot">
<View className="redeem-amount-hint">
<Text></Text>
<Text></Text>
<BenefitFigure value={formatMoney(redeemableMax)} size="sm" />
</View>
<Text className="redeem-fill-max" onClick={fillMaxAmount}>
@@ -169,7 +169,7 @@ export default function RedeemPage() {
</View>
<View className="redeem-tips">
<Text className="redeem-tips-text">
0.01 3
0.01 3
</Text>
</View>
<View
+88 -41
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image, ScrollView } from '@tarojs/components';
import '../../styles/store-detail.css';
import '../../styles/benefit-promo.css';
import Taro, {
useDidShow,
useLoad,
@@ -12,13 +13,18 @@ import Taro, {
import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel';
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../components/StoreRedeemMarquee';
import BenefitIntroCard from '../../components/BenefitIntroCard';
import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api';
import { toMoneyNumber } from '../../lib/money';
import { maskPhone, toDialablePhone } from '../../lib/phone';
import { track } from '../../lib/analytics';
import { formatShanghaiDateTime } from '../../lib/datetime';
import {
storeCategoryTags,
storeStarCount,
type StoreCategoryTreeNode,
} from '../../lib/store-display';
import {
buildSceneSharePayload,
toWeappShareMessage,
@@ -63,7 +69,16 @@ type Store = {
avgPrice?: number | null;
latitude?: number | string | null;
longitude?: number | string | null;
category?: { name: string } | null;
rating?: number | string | null;
tags?: unknown;
redeemCount?: number | null;
categoryId?: string | null;
category?: {
id?: string;
name: string;
parentId?: string | null;
parent?: { name?: string } | null;
} | null;
};
type RecentRedeem = {
@@ -104,11 +119,6 @@ function pickStoreId(raw?: string | null) {
.replace(/[^\d]/g, '');
}
/** 与历史记录一致:2026-08-03 15:14:30Asia/Shanghai */
function formatRedeemTime(input?: string | null) {
return formatShanghaiDateTime(input);
}
function formatPackagePriceYuan(price: string | number) {
const n = typeof price === 'number' ? price : Number(price);
if (!Number.isFinite(n)) return '0';
@@ -122,20 +132,29 @@ function formatRedeemAmountYuan(amount: unknown) {
return n.toFixed(2).replace(/\.?0+$/, '');
}
function formatRecentRedeemLine(row: RecentRedeem) {
function SectionTitle({
children,
className,
}: {
children: string;
className?: string;
}) {
return (
<View className={`store-detail-section-title${className ? ` ${className}` : ''}`}>
<View className="store-detail-section-title-bar" />
<Text className="store-detail-section-title-text">{children}</Text>
</View>
);
}
function toMarqueeItem(row: RecentRedeem): StoreRedeemMarqueeItem | null {
try {
// 优先用服务端拼好的 text,避免客户端时区 / Intl 差异
if (row.text?.trim()) return row.text.trim();
const label = String(row.userLabel || '用户***').trim() || '用户***';
const rawTime = String(row.createdAt || '').trim();
const time =
/^\d{4}-\d{2}-\d{2}/.test(rawTime)
? rawTime.slice(0, 19).replace('T', ' ')
: formatRedeemTime(row.createdAt);
const userLabel = String(row.userLabel || '用户***').trim() || '用户***';
const amount = formatRedeemAmountYuan(row.amount);
return `${label} ${time} 核销${amount}`;
if (!amount) return null;
return { userLabel, amount };
} catch {
return '';
return null;
}
}
@@ -160,6 +179,7 @@ export default function StoreDetailPage() {
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.id));
const [store, setStore] = useState<Store | null>(null);
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
const [categoryTree, setCategoryTree] = useState<StoreCategoryTreeNode[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [headerSolid, setHeaderSolid] = useState(false);
@@ -236,6 +256,12 @@ export default function StoreDetailPage() {
}
}, [router.params.id, storeId, bootstrap]);
useEffect(() => {
void request<StoreCategoryTreeNode[]>('/store-categories')
.then((tree) => setCategoryTree(Array.isArray(tree) ? tree : []))
.catch(() => setCategoryTree([]));
}, []);
// 登录态变化后回到本页:重拉详情与走马灯
useDidShow(() => {
const id = pickStoreId(storeId || router.params.id);
@@ -254,8 +280,8 @@ export default function StoreDetailPage() {
});
}, [store, storeId]);
const marqueeLines = useMemo(
() => recentRedeems.map(formatRecentRedeemLine).filter(Boolean),
const marqueeItems = useMemo(
() => recentRedeems.map(toMarqueeItem).filter((row): row is StoreRedeemMarqueeItem => !!row),
[recentRedeems],
);
@@ -369,7 +395,38 @@ export default function StoreDetailPage() {
</View>
<View className="store-detail-info-card">
<Text className="store-detail-name">{store.name}</Text>
{marqueeItems.length > 0 ? (
<View className="store-detail-marquee-wrap">
<StoreRedeemMarquee
key={marqueeItems.map((r) => `${r.userLabel}|${r.amount}`).join('|')}
items={marqueeItems}
/>
</View>
) : null}
<View className="store-detail-title-row">
<Text className="store-detail-name">{store.name}</Text>
{storeCategoryTags(store, categoryTree).map((tag) => (
<Text key={tag} className="store-detail-tag">
{tag}
</Text>
))}
</View>
<View className="store-detail-rating-row">
<View className="store-detail-stars">
{[1, 2, 3, 4, 5].map((n) => (
<Text
key={n}
className={`store-detail-star${n <= storeStarCount(store.rating) ? ' store-detail-star--on' : ''}`}
>
</Text>
))}
</View>
{Number(store.redeemCount) > 0 ? (
<Text className="store-detail-redeem">{store.redeemCount}</Text>
) : null}
</View>
<View className="store-detail-row">
<Text className="store-detail-meta store-detail-meta--flex">
@@ -404,23 +461,20 @@ export default function StoreDetailPage() {
</Text>
</View>
) : null}
{store.category?.name ? (
<View className="store-detail-tags">
<Text className="store-detail-tag">{store.category.name}</Text>
</View>
) : null}
</View>
{marqueeLines.length > 0 ? (
<View className="store-detail-marquee-wrap">
<StoreRedeemMarquee key={marqueeLines.join('|')} lines={marqueeLines} />
<BenefitIntroCard showLink accent className="store-detail-benefit-intro" />
{benefitRule ? (
<View className="store-detail-section">
<SectionTitle className="store-detail-section-title--rule">使</SectionTitle>
<Text className="store-detail-intro">{benefitRule}</Text>
</View>
) : null}
{packages.length > 0 ? (
<View className="store-detail-section store-detail-section--packages">
<Text className="store-detail-section-title"></Text>
<SectionTitle></SectionTitle>
<View className="store-detail-package-list">
{packages.map((pkg, index) => (
<View
@@ -440,16 +494,9 @@ export default function StoreDetailPage() {
</View>
) : null}
{benefitRule ? (
<View className="store-detail-section">
<Text className="store-detail-section-title store-detail-section-title--rule">使</Text>
<Text className="store-detail-intro">{benefitRule}</Text>
</View>
) : null}
{intro ? (
<View className="store-detail-section">
<Text className="store-detail-section-title"></Text>
<SectionTitle></SectionTitle>
<ScrollView className="store-detail-intro-scroll" scrollY showScrollbar>
<Text className="store-detail-intro">{intro}</Text>
</ScrollView>
@@ -458,7 +505,7 @@ export default function StoreDetailPage() {
{envPhotos.length > 0 ? (
<View className="store-detail-section">
<Text className="store-detail-section-title"></Text>
<SectionTitle></SectionTitle>
<View className="store-detail-env-grid">
{envPhotos.map((url, index) => (
<View
+135 -63
View File
@@ -34,12 +34,15 @@ import {
markStoresSessionBootstrapped,
patchStoresFilterCache,
setStoresListCache,
type StoreSortKey,
} from '../../lib/stores-session';
import {
buildSceneSharePayload,
toWeappShareMessage,
toWeappShareTimeline,
} from '../../lib/wechat-share';
import BenefitSloganBar from '../../components/BenefitSloganBar';
import { storeCategoryTags, storeStarCount } from '../../lib/store-display';
import openBadgeImg from '../../assets/icons/store-open-badge.png';
type Store = {
@@ -57,12 +60,26 @@ type Store = {
avgPrice?: number | null;
status?: string;
categoryId?: string | null;
category?: { id?: string; name?: string; parentId?: string | null } | null;
category?: {
id?: string;
name?: string;
parentId?: string | null;
parent?: { name?: string } | null;
} | null;
tags?: unknown;
rating?: number | string | null;
latitude?: number | string | null;
longitude?: number | string | null;
distanceMeters?: number | null;
redeemCount?: number | null;
};
const STORE_SORT_OPTIONS: { key: StoreSortKey; label: string }[] = [
{ key: 'nearby', label: '附近优先' },
{ key: 'rating', label: '好评优先' },
{ key: 'redeem', label: '核销次数' },
];
function makeCityKey(region: Pick<RegionSelection, 'province' | 'city'>): string {
return `${region.province}|${region.city}`;
}
@@ -98,13 +115,15 @@ export default function StoresPage() {
);
const [categoryOpen, setCategoryOpen] = useState(false);
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const [locating, setLocating] = useState(false);
const [sort, setSort] = useState<StoreSortKey>(() => cached?.sort ?? 'nearby');
const [sortOpen, setSortOpen] = useState(false);
const fetchCityKeyRef = useRef<string | null>(cached?.cityKey ?? null);
const fetchSeqRef = useRef(0);
const regionRef = useRef(region);
regionRef.current = region;
const regionLabel = formatRegionLabel(region);
const categoryLabel = formatCategoryLabel(category);
const sortLabel = STORE_SORT_OPTIONS.find((o) => o.key === sort)?.label ?? '附近优先';
const showBootLoading = loading && stores.length === 0;
const childIdsByParent = useMemo(() => {
@@ -151,6 +170,7 @@ export default function StoresPage() {
keyword: prev?.keyword ?? keyword,
keywordInput: prev?.keywordInput ?? keywordInput,
category: prev?.category ?? category,
sort: prev?.sort ?? sort,
});
} catch (e) {
if (seq !== fetchSeqRef.current) return;
@@ -278,13 +298,29 @@ export default function StoresPage() {
return siblings.includes(storeCatId);
}
const filtered = stores.filter((s) => {
if (!matchesRegionFilter(s, region)) return false;
if (!matchesCategory(s)) return false;
if (!keyword.trim()) return true;
const q = keyword.trim();
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
});
const filtered = useMemo(() => {
const list = stores.filter((s) => {
if (!matchesRegionFilter(s, region)) return false;
if (!matchesCategory(s)) return false;
if (!keyword.trim()) return true;
const q = keyword.trim();
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
});
const next = [...list];
next.sort((a, b) => {
if (sort === 'rating') {
const diff = storeStarCount(b.rating) - storeStarCount(a.rating);
if (diff !== 0) return diff;
} else if (sort === 'redeem') {
const diff = (b.redeemCount ?? 0) - (a.redeemCount ?? 0);
if (diff !== 0) return diff;
}
const da = a.distanceMeters ?? Number.POSITIVE_INFINITY;
const db = b.distanceMeters ?? Number.POSITIVE_INFINITY;
return da - db;
});
return next;
}, [stores, region, category, keyword, sort, childIdsByParent]);
function applySearch() {
const next = keywordInput.trim();
@@ -296,52 +332,18 @@ export default function StoresPage() {
setKeywordInput('');
setKeyword('');
setCategory(EMPTY_CATEGORY);
setSort('nearby');
setRegion(DEFAULT_REGION);
regionRef.current = DEFAULT_REGION;
patchStoresFilterCache({
keyword: '',
keywordInput: '',
category: EMPTY_CATEGORY,
sort: 'nearby',
filterRegion: DEFAULT_REGION,
});
}
async function locateToUserRegion() {
if (locating) return;
const { confirm } = await Taro.showModal({
title: '获取当前位置',
content: '是否允许获取当前位置,并将筛选定位到您所在的城市与区县?',
confirmText: '允许',
cancelText: '暂不',
}).catch(() => ({ confirm: false, cancel: true }));
if (!confirm) return;
setLocating(true);
setLoading(true);
try {
const resolved = await resolveUserCity(true);
// 筛选器用真实省市+区县;拉数仍按开城 cityCode(未开城则郑州)
const filterRegion = resolved.region;
const { cityCode, region: listRegion } = regionForCatalogFetch(resolved);
const nextCityKey = makeCityKey(listRegion);
setRegion(filterRegion);
regionRef.current = filterRegion;
await fetchStores(
cityCode,
readCachedUserCoords(),
nextCityKey,
listRegion,
filterRegion,
);
toast(`已定位到${formatRegionLabel(filterRegion)}`, 'success');
} catch (e) {
toast(e instanceof Error ? e.message : '定位失败');
setLoading(false);
} finally {
setLocating(false);
}
}
function hoursText(store: Store): string {
const parts: string[] = [];
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
@@ -366,30 +368,59 @@ export default function StoresPage() {
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="门店" />
<View className="store-slogan-wrap">
<BenefitSloganBar />
</View>
<View className="store-filter">
<View className="store-search-row">
<Input
className="store-search-input"
placeholder="搜索门店名称/地址"
placeholder="搜索门店名称地址"
value={keywordInput}
confirmType="search"
onInput={(e) => setKeywordInput(e.detail.value)}
onConfirm={applySearch}
/>
<View className="store-search-btn" onClick={applySearch} aria-label="搜索">
<View className="store-search-icon" />
<View className="store-search-btn" onClick={applySearch}>
<Text className="store-search-btn-text"></Text>
</View>
</View>
<View className="store-filter-row">
<View className="store-filter-chip" onClick={() => setRegionOpen(true)}>
<View
className="store-filter-chip"
onClick={() => {
setCategoryOpen(false);
setSortOpen(false);
setRegionOpen(true);
}}
>
<Text className="store-filter-chip-text">{regionLabel}</Text>
<Text className="store-filter-chip-arrow"></Text>
</View>
<View className="store-filter-chip" onClick={() => setCategoryOpen(true)}>
<View
className="store-filter-chip"
onClick={() => {
setRegionOpen(false);
setSortOpen(false);
setCategoryOpen(true);
}}
>
<Text className="store-filter-chip-text">{categoryLabel}</Text>
<Text className="store-filter-chip-arrow"></Text>
</View>
<View
className="store-filter-chip"
onClick={() => {
setRegionOpen(false);
setCategoryOpen(false);
setSortOpen(true);
}}
>
<Text className="store-filter-chip-text">{sortLabel}</Text>
<Text className="store-filter-chip-arrow"></Text>
</View>
<View
className="store-filter-icon-btn"
onClick={resetFilters}
@@ -398,15 +429,6 @@ export default function StoresPage() {
{/* 小程序 View 伪元素不稳定,用 Text 保证真机可见 */}
<Text className="store-filter-icon-glyph"></Text>
</View>
<View
className={`store-filter-icon-btn${locating ? ' store-filter-icon-btn--busy' : ''}`}
onClick={() => {
void locateToUserRegion();
}}
aria-label="获取当前位置"
>
<Text className="store-filter-icon-glyph"></Text>
</View>
</View>
</View>
@@ -435,11 +457,34 @@ export default function StoresPage() {
/>
</View>
<View className="store-card-body">
{/* 第1行:标题(截断无省略号,顶到最右) */}
<View className="store-card-row store-card-row--head">
<Text className="store-card-name">{s.name}</Text>
</View>
{/* 第2行:地址(最多两行)+ 距离 */}
{(() => {
const tags = storeCategoryTags(s, categoryTree);
return tags.length ? (
<View className="store-card-tags">
{tags.map((tag) => (
<Text key={tag} className="store-card-tag">
{tag}
</Text>
))}
</View>
) : null;
})()}
<View className="store-card-row store-card-row--rating">
<View className="store-card-stars">
{[1, 2, 3, 4, 5].map((n) => (
<Text
key={n}
className={`store-card-star${n <= storeStarCount(s.rating) ? ' store-card-star--on' : ''}`}
>
</Text>
))}
</View>
<Text className="store-card-redeem">{s.redeemCount ?? 0}</Text>
</View>
<View className="store-card-row store-card-row--mid">
<Text className="store-card-address" numberOfLines={2}>
{s.address || (s.district ? `${s.district}` : '地址待完善')}
@@ -448,12 +493,10 @@ export default function StoresPage() {
{formatDistanceMeters(s.distanceMeters)}
</Text>
</View>
{/* 第3行:营业时间(同行) */}
<View className="store-card-row store-card-row--hours">
<Text className="store-card-hours">{hoursText(s)}</Text>
</View>
</View>
<Text className="store-card-arrow"></Text>
</View>
))}
</View>
@@ -480,6 +523,35 @@ export default function StoresPage() {
patchStoresFilterCache({ category: next });
}}
/>
{sortOpen ? (
<View className="region-picker-overlay" onClick={() => setSortOpen(false)}>
<View className="region-picker-sheet store-sort-sheet" onClick={(e) => e.stopPropagation()}>
<View className="region-picker-toolbar">
<View className="region-picker-tabs">
<Text className="region-picker-tab active"></Text>
</View>
<Text className="region-picker-confirm ready" onClick={() => setSortOpen(false)}>
</Text>
</View>
<View className="region-picker-list">
{STORE_SORT_OPTIONS.map((opt) => (
<View
key={opt.key}
className={`region-picker-option${sort === opt.key ? ' selected' : ''}`}
onClick={() => {
setSort(opt.key);
patchStoresFilterCache({ sort: opt.key });
setSortOpen(false);
}}
>
<Text>{opt.label}</Text>
</View>
))}
</View>
</View>
</View>
) : null}
</PageShell>
);
}
@@ -0,0 +1,88 @@
/* 好客权益口号条 / 说明卡(多页共用) */
.benefit-slogan-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 10px;
background: #fff8e6;
border: 1px solid rgba(201, 162, 62, 0.55);
box-sizing: border-box;
}
.benefit-slogan-bar-accent {
width: 3px;
height: 14px;
border-radius: 2px;
background: #c9a23e;
flex-shrink: 0;
}
.benefit-slogan-bar-text {
flex: 1;
min-width: 0;
text-align: center;
font-size: 13px;
font-weight: 700;
line-height: 20px;
color: #8b1a1a;
}
.benefit-intro-card {
padding: 14px;
border-radius: 12px;
background: #fff8e6;
border: 1px solid rgba(201, 162, 62, 0.55);
box-sizing: border-box;
}
.benefit-intro-card-title {
display: block;
font-family: var(--font-headline);
font-size: 15px;
font-weight: 700;
line-height: 22px;
color: #8b1a1a;
margin-bottom: 8px;
}
.benefit-intro-card-body {
display: block;
font-size: 13px;
line-height: 1.7;
color: #8b1a1a;
}
.benefit-intro-card-em {
font-weight: 700;
}
.benefit-intro-card-link {
display: block;
margin-top: 8px;
text-align: right;
font-size: 12px;
font-weight: 500;
color: #8b1a1a;
}
.benefit-intro-card--accent {
display: flex;
flex-direction: row;
align-items: stretch;
padding: 0;
overflow: hidden;
}
.benefit-intro-card-bar {
width: 5px;
flex-shrink: 0;
align-self: stretch;
background: #c9a23e;
}
.benefit-intro-card-main {
flex: 1;
min-width: 0;
padding: 14px;
}
+29 -57
View File
@@ -11,13 +11,6 @@
box-sizing: border-box;
}
.benefit-header__content {
position: relative;
display: flex;
align-items: center;
width: 100%;
}
.benefit-header-title {
position: absolute;
left: 0;
@@ -39,34 +32,6 @@
z-index: 1;
}
.benefit-header-city {
display: flex;
align-items: center;
color: var(--color-heritage-red);
font-size: 12px;
font-weight: 500;
max-width: 30vw;
position: relative;
z-index: 2;
}
.benefit-header__share .page-nav-bar__btn {
background: transparent;
}
.benefit-header__share .page-nav-bar__icon {
color: var(--color-heritage-red);
}
.benefit-header-city-pin {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-heritage-red);
margin-right: 4px;
flex-shrink: 0;
}
.benefit-main {
padding: 16px var(--space-page) 24px;
}
@@ -83,9 +48,6 @@
}
.benefit-hero-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
@@ -117,36 +79,32 @@
line-height: 1;
}
.benefit-hero-logo {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(166, 29, 36, 0.08);
.benefit-hero-actions {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-heritage-red);
font-weight: 700;
font-size: 18px;
overflow: hidden;
}
.benefit-hero-logo-img {
width: 28px;
height: 28px;
display: block;
gap: 10px;
}
.benefit-hero-cta {
flex: 1;
height: 44px;
border-radius: var(--radius-full);
background: var(--color-heritage-red);
color: #fff;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.benefit-hero-cta--primary {
background: var(--color-heritage-red);
color: #fff;
}
.benefit-hero-cta--secondary {
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
}
.benefit-tabs {
@@ -267,7 +225,21 @@
font-weight: 600;
}
.benefit-coupon-btn--ghost {
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
}
.benefit-intro-card--page {
margin-bottom: 16px;
}
.benefit-intro-card--page .benefit-intro-card-body {
font-size: 11px;
line-height: 1.65;
}
.benefit-login-gate {
padding: 48px 24px;
padding: 16px var(--space-page) 48px;
text-align: center;
}
+89
View File
@@ -51,6 +51,10 @@
display: block;
}
.home-slogan-wrap {
margin: 10px var(--space-page) 0;
}
.home-aroma-nav {
display: flex;
align-items: center;
@@ -175,6 +179,7 @@
}
.home-product-thumb-wrap {
position: relative;
flex-shrink: 0;
width: 88px;
height: 88px;
@@ -183,6 +188,60 @@
background: var(--color-surface-container);
}
.home-benefit-ribbon-clip {
position: absolute;
top: 0;
left: 0;
width: 64px;
height: 64px;
overflow: hidden;
pointer-events: none;
z-index: 1;
}
.home-benefit-ribbon {
position: absolute;
top: 14px;
left: -18px;
width: 86px;
height: 18px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 3px;
overflow: hidden;
background: #8b1a20;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
transform: rotate(-45deg);
}
.home-benefit-ribbon-dk {
flex-shrink: 0;
width: 14px;
height: 14px;
border-radius: 1px;
/* background: #fff; */
display: flex;
align-items: center;
justify-content: center;
}
.home-benefit-ribbon-dk-icon {
width: 12px;
height: 12px;
display: block;
}
.home-benefit-ribbon-num {
flex-shrink: 0;
color: #dcb46f;
font-size: 11px;
font-weight: 700;
line-height: 18px;
letter-spacing: 0.02em;
}
.home-product-thumb {
width: 100%;
height: 100%;
@@ -241,6 +300,36 @@
margin-top: 2px;
}
.home-product-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.home-gift-tag,
.home-benefit-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 2px;
font-family: var(--font-label);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 16px;
}
.home-gift-tag {
background: var(--color-aged-amber);
color: var(--color-heritage-red);
}
.home-benefit-tag {
background: rgba(166, 29, 36, 0.1);
color: var(--color-heritage-red);
}
.home-product-actions {
margin-top: auto;
padding-top: 4px;
+78
View File
@@ -42,3 +42,81 @@
color: #3d3530;
margin-bottom: 8px;
}
.benefit-rules-summary {
padding: 14px;
margin-bottom: 16px;
border-radius: 12px;
background: linear-gradient(90deg, #fff8e6 0%, #fff3cc 100%);
border: 1px solid rgba(201, 162, 62, 0.55);
box-sizing: border-box;
}
.benefit-rules-summary-text {
display: block;
font-size: 13px;
line-height: 1.75;
color: #3d3530;
}
.benefit-rules-section {
background: #fff;
border-radius: 12px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 4px 16px rgba(166, 29, 36, 0.04);
}
.benefit-rules-heading-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.benefit-rules-heading-bar {
width: 3px;
height: 14px;
border-radius: 2px;
background: var(--color-heritage-red, #a61d24);
flex-shrink: 0;
}
.benefit-rules-heading {
font-size: 15px;
font-weight: 700;
color: #1f1a17;
line-height: 1.5;
}
.benefit-rules-paragraph {
display: block;
font-size: 13px;
line-height: 1.75;
color: #3d3530;
margin-bottom: 6px;
}
.benefit-rules-em {
color: var(--color-heritage-red, #a61d24);
font-weight: 700;
}
.benefit-rules-phone {
display: block;
margin-top: 4px;
font-size: 22px;
font-weight: 700;
line-height: 1.4;
color: var(--color-heritage-red, #a61d24);
}
.benefit-rules-footer {
display: block;
margin-top: 8px;
margin-bottom: 16px;
text-align: center;
font-size: 11px;
line-height: 1.6;
color: #999;
}
+9 -5
View File
@@ -10,7 +10,7 @@
.mine-header {
position: relative;
z-index: 2;
z-index: 1;
padding: 16px var(--space-page) 44px;
background: linear-gradient(135deg, #820012 0%, var(--color-heritage-red) 40%, #d4a373 100%);
overflow: visible;
@@ -34,12 +34,10 @@
position: relative;
display: flex;
align-items: center;
z-index: 3;
}
.mine-avatar-wrap {
position: relative;
z-index: 4;
flex-shrink: 0;
margin-right: 12px;
}
@@ -162,7 +160,7 @@
.mine-main {
margin-top: -28px;
position: relative;
z-index: 1;
z-index: 5;
padding: 0 var(--space-page) 0;
}
@@ -185,6 +183,12 @@
margin-bottom: 10px;
}
.mine-benefit-slogan {
position: relative;
z-index: 6;
margin-bottom: 10px;
}
.mine-card-title {
font-family: var(--font-headline);
font-size: 15px;
@@ -356,7 +360,7 @@
margin-bottom: 8px;
padding: 16px;
position: relative;
z-index: 2;
z-index: 5;
background: var(--color-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
+21
View File
@@ -176,6 +176,27 @@
text-align: center;
}
.order-qty-input {
width: 52px;
height: 32px;
margin: 0 4px 0 8px;
padding: 0 4px;
box-sizing: border-box;
text-align: center;
font-size: 16px;
font-weight: 600;
color: var(--color-on-surface);
background: var(--color-surface-container-low);
border-radius: 8px;
}
.order-qty-unit {
margin-right: 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-on-surface);
}
.order-qty-hint {
display: block;
margin-top: 10px;
@@ -88,6 +88,10 @@
padding: var(--space-md) var(--space-page) var(--space-lg);
}
.product-detail-benefit-intro {
margin-bottom: 16px;
}
.product-detail-price {
display: flex;
align-items: baseline;
+237 -7
View File
@@ -328,17 +328,247 @@
color: var(--color-subtle-gray);
}
.redeem-success-icon {
width: 80px;
height: 80px;
.review-page-body {
padding-bottom: 32px;
}
.review-store-card,
.review-card {
margin: 0 var(--space-page) 12px;
padding: 14px;
background: var(--color-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
box-sizing: border-box;
}
.review-store-card {
display: flex;
align-items: center;
gap: 12px;
}
.review-store-cover {
width: 56px;
height: 56px;
border-radius: 8px;
flex-shrink: 0;
background: var(--color-surface-container);
display: block;
}
.review-store-cover--empty {
background: var(--color-surface-container);
}
.review-store-meta {
flex: 1;
min-width: 0;
}
.review-store-name {
display: block;
font-family: var(--font-headline);
font-size: 15px;
font-weight: 700;
line-height: 22px;
color: var(--color-on-surface);
}
.review-store-visit {
display: block;
margin-top: 4px;
font-size: 12px;
line-height: 18px;
color: var(--color-subtle-gray);
}
.review-card-title {
display: block;
text-align: center;
font-family: var(--font-headline);
font-size: 15px;
font-weight: 700;
color: var(--color-on-surface);
margin-bottom: 12px;
}
.review-star-row {
display: flex;
justify-content: center;
gap: 10px;
}
.review-star {
font-size: 32px;
line-height: 1;
color: #d8d8d8;
}
.review-star--active {
color: #f5a623;
}
.review-score-label {
display: block;
margin-top: 8px;
text-align: center;
font-size: 13px;
font-weight: 600;
color: var(--color-heritage-red);
}
.review-section-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.review-section-bar {
width: 3px;
height: 14px;
border-radius: 2px;
background: var(--color-heritage-red);
flex-shrink: 0;
}
.review-section-title {
font-size: 14px;
font-weight: 700;
color: var(--color-on-surface);
}
.review-tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.review-tag {
padding: 6px 12px;
border-radius: 999px;
background: #f3f0ee;
color: #666;
font-size: 13px;
line-height: 18px;
}
.review-tag--active {
background: rgba(166, 29, 36, 0.1);
color: var(--color-heritage-red);
font-weight: 600;
}
.review-comment {
width: 100%;
min-height: 96px;
padding: 12px;
border-radius: 10px;
background: #f7f4ef;
box-sizing: border-box;
font-size: 13px;
line-height: 1.6;
color: var(--color-on-surface);
}
.review-comment--native {
border: none;
outline: none;
resize: none;
font-family: inherit;
}
.review-photos {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.review-photo {
position: relative;
width: 72px;
height: 72px;
border-radius: 8px;
overflow: hidden;
background: #f3f0ee;
}
.review-photo-img {
width: 72px;
height: 72px;
}
.review-photo-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(45, 106, 79, 0.12);
color: var(--color-success-green);
font-size: 40px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 12px;
line-height: 18px;
text-align: center;
}
.review-photo-add {
width: 72px;
height: 72px;
border-radius: 8px;
border: 1px dashed #d0c8b8;
background: #faf7f2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
.review-photo-add-plus {
font-size: 22px;
line-height: 22px;
color: #b09a78;
}
.review-photo-add-text {
font-size: 10px;
color: #b09a78;
}
.review-skip {
display: block;
margin: 4px var(--space-page) 8px;
text-align: center;
font-size: 13px;
color: var(--color-subtle-gray);
}
.review-submit {
margin: 8px var(--space-page) 8px;
height: 48px;
border-radius: 999px;
background: var(--color-heritage-red);
color: #fff;
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin: 48px auto 16px;
}
.review-submit--disabled {
opacity: 0.6;
}
.review-disclaimer {
display: block;
margin: 0 var(--space-page) 16px;
text-align: center;
font-size: 11px;
line-height: 1.6;
color: var(--color-subtle-gray);
}
.redeem-success-title {
+95 -24
View File
@@ -95,17 +95,54 @@
z-index: 2;
background: var(--color-card);
border-radius: var(--radius-lg);
padding: 20px 16px;
padding: 16px;
box-shadow: var(--shadow-card);
}
.store-detail-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.store-detail-name {
display: block;
font-family: var(--font-headline);
font-size: 20px;
font-weight: 700;
color: var(--color-ink-black);
margin-bottom: 8px;
line-height: 1.3;
}
.store-detail-rating-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.store-detail-stars {
display: flex;
align-items: center;
gap: 1px;
}
.store-detail-star {
font-size: 14px;
line-height: 18px;
color: #ddd;
}
.store-detail-star--on {
color: #e8b84a;
}
.store-detail-redeem {
font-size: 12px;
font-weight: 600;
color: var(--color-heritage-red);
white-space: nowrap;
}
.store-detail-meta {
@@ -148,52 +185,73 @@
white-space: pre-wrap;
}
.store-detail-tags {
display: flex;
flex-wrap: wrap;
margin-top: 12px;
}
.store-detail-tag {
padding: 4px 10px;
margin-right: 8px;
margin-bottom: 8px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
font-size: 11px;
font-weight: 500;
line-height: 16px;
}
.store-detail-marquee-wrap {
margin: 0 var(--space-page) 12px;
margin: 0 0 12px;
}
.store-detail-marquee {
margin: 0;
padding: 0;
border-radius: 8px;
background: #fff7f6;
border: 1px solid rgba(166, 29, 36, 0.12);
padding: 0 10px;
border-radius: 999px;
background: #faf6ee;
border: 1px solid rgba(201, 162, 62, 0.4);
overflow: hidden;
height: 40px;
height: 32px;
box-sizing: border-box;
position: relative;
}
.store-detail-marquee-swiper {
height: 32px;
}
.store-detail-marquee-inner {
position: absolute;
top: 50%;
margin-top: -10px;
height: 32px;
white-space: nowrap;
display: flex;
align-items: center;
gap: 6px;
}
.store-detail-marquee-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #e8a317;
flex-shrink: 0;
}
.store-detail-marquee-text {
white-space: nowrap;
font-size: 12px;
line-height: 20px;
color: #5a413f;
}
.store-detail-marquee-amount {
white-space: nowrap;
font-size: 12px;
line-height: 20px;
font-weight: 700;
color: #a61d24;
}
.benefit-intro-card.store-detail-benefit-intro {
margin: 0 var(--space-page) 16px;
background: #fffaf0;
border-color: rgba(201, 162, 62, 0.4);
}
.store-detail-section {
background: var(--color-card);
margin: 0 var(--space-page) 16px;
@@ -203,15 +261,28 @@
}
.store-detail-section-title {
display: block;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
}
.store-detail-section-title-bar {
width: 3px;
height: 14px;
border-radius: 1px;
background: var(--color-heritage-red, #a61d24);
flex-shrink: 0;
}
.store-detail-section-title-text {
font-family: var(--font-headline);
font-size: 16px;
font-weight: 700;
margin-bottom: 10px;
color: var(--color-on-surface);
}
.store-detail-section-title--rule {
.store-detail-section-title--rule .store-detail-section-title-text {
color: var(--color-heritage-red, #a61d24);
}
+67 -23
View File
@@ -3,6 +3,10 @@
background: var(--color-background);
}
.store-slogan-wrap {
padding: 0 var(--space-page) 10px;
}
.store-filter {
padding: 0 var(--space-page) 12px;
}
@@ -46,8 +50,8 @@
.store-search-btn {
flex-shrink: 0;
width: 40px;
height: 40px;
padding: 0 16px;
border-radius: var(--radius-md);
background: var(--color-heritage-red);
color: #fff;
@@ -56,26 +60,11 @@
justify-content: center;
}
.store-search-icon {
position: relative;
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-radius: 50%;
box-sizing: border-box;
}
.store-search-icon::after {
content: '';
position: absolute;
right: -5px;
bottom: -4px;
width: 7px;
height: 2px;
background: currentColor;
border-radius: 1px;
transform: rotate(45deg);
transform-origin: left center;
.store-search-btn-text {
font-size: 14px;
font-weight: 600;
line-height: 1;
color: #fff;
}
.store-filter-row {
@@ -148,7 +137,7 @@
.store-card {
display: flex;
flex-direction: row;
align-items: center;
align-items: flex-start;
gap: 10px;
padding: 12px;
box-sizing: border-box;
@@ -195,7 +184,7 @@
min-height: 96px;
display: flex;
flex-direction: column;
justify-content: space-between;
justify-content: flex-start;
gap: 4px;
box-sizing: border-box;
}
@@ -226,6 +215,57 @@
white-space: nowrap;
}
.store-card-tags {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.store-card-tag {
flex-shrink: 0;
padding: 1px 6px;
border-radius: 999px;
background: rgba(166, 29, 36, 0.1);
color: var(--color-heritage-red);
font-size: 10px;
font-weight: 500;
line-height: 16px;
}
.store-card-row--rating {
align-items: center;
justify-content: space-between;
gap: 8px;
}
.store-card-stars {
display: flex;
align-items: center;
gap: 1px;
}
.store-card-star {
font-size: 12px;
line-height: 16px;
color: #ddd;
}
.store-card-star--on {
color: #e8b84a;
}
.store-card-redeem {
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
line-height: 16px;
color: var(--color-heritage-red);
white-space: nowrap;
}
/* 第2行:地址最多两行 + 右对齐距离 */
.store-card-row--mid {
gap: 8px;
@@ -289,3 +329,7 @@
color: #ccc;
text-align: center;
}
.store-sort-sheet {
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
}