Dev #73

Merged
jacy merged 2 commits from dev into main 2026-09-03 23:31:39 +08:00
18 changed files with 254 additions and 84 deletions
+6 -2
View File
@@ -128,8 +128,12 @@ function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] })
<p className="label-md text-muted">: {entry.totalStores} </p> <p className="label-md text-muted">: {entry.totalStores} </p>
</div> </div>
<div className="partner-leaderboard-stat"> <div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.periodStores} </p> {entry.periodStores > 0 ? (
<p className="label-md text-muted"></p> <>
<p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted"></p>
</>
) : null}
</div> </div>
</div> </div>
)) ))
@@ -117,8 +117,12 @@ export default function LeaderboardPage() {
<p className="label-md text-muted"> {entry.totalStores} </p> <p className="label-md text-muted"> {entry.totalStores} </p>
</div> </div>
<div className="partner-leaderboard-stat"> <div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.periodStores} </p> {entry.periodStores > 0 ? (
<p className="label-md text-muted">{periodSubLabel(period)}</p> <>
<p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted">{periodSubLabel(period)}</p>
</>
) : null}
</div> </div>
</div> </div>
)) ))
@@ -1,28 +1,33 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { View, Text, Swiper, SwiperItem } from '@tarojs/components'; import { View, Text, Swiper, SwiperItem } from '@tarojs/components';
import { formatRedeemRelativeTime } from '../lib/datetime';
export type StoreRedeemMarqueeItem = { export type StoreRedeemMarqueeItem = {
userLabel: string; userLabel: string;
amount: string; amount: string;
createdAt?: string | null;
}; };
type StoreRedeemMarqueeProps = { type StoreRedeemMarqueeProps = {
items: StoreRedeemMarqueeItem[]; items: StoreRedeemMarqueeItem[];
}; };
const STAY_MS = 20000; const STAY_MS = 5000;
function MarqueeRow({ item }: { item: StoreRedeemMarqueeItem }) { function MarqueeRow({ item }: { item: StoreRedeemMarqueeItem }) {
const timeLabel = formatRedeemRelativeTime(item.createdAt);
return ( return (
<View className="store-detail-marquee-inner"> <View className="store-detail-marquee-inner">
<View className="store-detail-marquee-dot" /> <View className="store-detail-marquee-dot" />
<Text className="store-detail-marquee-text">{item.userLabel} </Text> <Text className="store-detail-marquee-text">
{item.userLabel} {timeLabel}
</Text>
<Text className="store-detail-marquee-amount">{item.amount}</Text> <Text className="store-detail-marquee-amount">{item.amount}</Text>
</View> </View>
); );
} }
/** 核销记录:单条静止;多条竖向循环,每条停留 20 秒 */ /** 核销记录:单条静止;多条竖向循环,支持手动滑动,每条停留 5 秒 */
export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) { export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
const list = useMemo( const list = useMemo(
() => () =>
@@ -30,6 +35,7 @@ export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
.map((row) => ({ .map((row) => ({
userLabel: String(row.userLabel || '用户***').trim() || '用户***', userLabel: String(row.userLabel || '用户***').trim() || '用户***',
amount: String(row.amount || '').trim(), amount: String(row.amount || '').trim(),
createdAt: row.createdAt ?? null,
})) }))
.filter((row) => row.amount), .filter((row) => row.amount),
[items], [items],
@@ -57,7 +63,7 @@ export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
indicatorDots={false} indicatorDots={false}
> >
{list.map((row, index) => ( {list.map((row, index) => (
<SwiperItem key={`${row.userLabel}|${row.amount}|${index}`}> <SwiperItem key={`${row.userLabel}|${row.amount}|${row.createdAt}|${index}`}>
<MarqueeRow item={row} /> <MarqueeRow item={row} />
</SwiperItem> </SwiperItem>
))} ))}
+37
View File
@@ -1,3 +1,40 @@
const MS_MINUTE = 60_000;
const MS_HOUR = 60 * MS_MINUTE;
const MS_DAY = 24 * MS_HOUR;
function shanghaiParts(input: string | Date) {
const d = input instanceof Date ? input : new Date(input);
if (Number.isNaN(d.getTime())) return null;
const sh = new Date(d.getTime() + 8 * 60 * 60 * 1000);
const p = (n: number) => String(n).padStart(2, '0');
return {
year: sh.getUTCFullYear(),
month: p(sh.getUTCMonth() + 1),
day: p(sh.getUTCDate()),
hour: p(sh.getUTCHours()),
minute: p(sh.getUTCMinutes()),
};
}
/** 核销走马灯时间:24 小时内相对时间,超过则显示具体时间 */
export function formatRedeemRelativeTime(input?: string | Date | null): string {
if (input == null || input === '') return '刚刚';
const at = input instanceof Date ? input : new Date(input);
if (Number.isNaN(at.getTime())) return '刚刚';
const diffMs = Date.now() - at.getTime();
if (diffMs < MS_MINUTE) return '刚刚';
if (diffMs < MS_HOUR) return `${Math.floor(diffMs / MS_MINUTE)}分钟前`;
if (diffMs < MS_DAY) return `${Math.floor(diffMs / MS_HOUR)}小时前`;
const parts = shanghaiParts(at);
if (!parts) return '刚刚';
const nowParts = shanghaiParts(new Date());
const datePrefix =
nowParts && parts.year === nowParts.year
? `${parts.month}-${parts.day}`
: `${parts.year}-${parts.month}-${parts.day}`;
return `${datePrefix} ${parts.hour}:${parts.minute}`;
}
/** 格式化为 Asia/Shanghai2026-08-03 15:14:30(不依赖 Intl,兼容微信小程序) */ /** 格式化为 Asia/Shanghai2026-08-03 15:14:30(不依赖 Intl,兼容微信小程序) */
export function formatShanghaiDateTime(input?: string | Date | null): string { export function formatShanghaiDateTime(input?: string | Date | null): string {
if (input == null || input === '') return '—'; if (input == null || input === '') return '—';
+1 -5
View File
@@ -22,7 +22,7 @@ import {
isHomeCatalogBootstrapped, isHomeCatalogBootstrapped,
setHomeCatalogCache, setHomeCatalogCache,
} from '../../lib/home-catalog-session'; } from '../../lib/home-catalog-session';
import { ensurePayReady } from '../../lib/pay-ready';
import { capturePromoSceneAndTouchScan } from '../../lib/promo'; import { capturePromoSceneAndTouchScan } from '../../lib/promo';
import { getProductMainImage } from '../../lib/product-images'; import { getProductMainImage } from '../../lib/product-images';
import { import {
@@ -202,8 +202,6 @@ export default function HomePage() {
goLogin(returnPath); goLogin(returnPath);
return; return;
} }
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath }); Taro.navigateTo({ url: returnPath });
} }
@@ -213,8 +211,6 @@ export default function HomePage() {
goLogin(returnPath); goLogin(returnPath);
return; return;
} }
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath }); Taro.navigateTo({ url: returnPath });
} }
+27 -26
View File
@@ -102,7 +102,7 @@ export default function LoginPage() {
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey); const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
const [wxAuthorize, setWxAuthorize] = useState(true); const [wxAuthorize, setWxAuthorize] = useState(true);
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null); const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP); const [showSmsForm, setShowSmsForm] = useState(true);
const [logoWideUrl, setLogoWideUrl] = useState(() => getBrandAssetsSync().brandLogoWideUrl); const [logoWideUrl, setLogoWideUrl] = useState(() => getBrandAssetsSync().brandLogoWideUrl);
useEffect(() => { useEffect(() => {
@@ -393,7 +393,7 @@ export default function LoginPage() {
? '绑定手机号' ? '绑定手机号'
: completeMode === 'wechat' : completeMode === 'wechat'
? '授权登录' ? '授权登录'
: '手机号快捷登录'; : '验证码登录';
return ( return (
<PageShell variant="plain" className="login-page"> <PageShell variant="plain" className="login-page">
@@ -451,32 +451,9 @@ export default function LoginPage() {
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} /> <AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
{showPhoneQuick ? (
<PhoneQuickLoginButton
loading={phoneQuickLoading}
agreed={agreed}
onRequireAgree={() => ensureAgreed()}
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
onFail={(message) => setMsg(message)}
/>
) : null}
{showPhoneQuick ? (
<View className="login-divider" style={{ marginTop: 20 }}>
<View className="login-divider-line" />
<Text
className="login-divider-text"
onClick={() => setShowSmsForm((v) => !v)}
>
{showSmsForm ? '收起验证码登录' : '使用验证码登录'}
</Text>
<View className="login-divider-line" />
</View>
) : null}
{(showSmsForm || !showPhoneQuick) && ( {(showSmsForm || !showPhoneQuick) && (
<> <>
<View className="login-field" style={showPhoneQuick ? { marginTop: 8 } : undefined}> <View className="login-field">
<Text className="login-field-prefix">+86</Text> <Text className="login-field-prefix">+86</Text>
<Input <Input
className="login-field-input" className="login-field-input"
@@ -526,6 +503,30 @@ export default function LoginPage() {
</> </>
)} )}
{showPhoneQuick ? (
<>
<View className="login-divider" style={{ marginTop: 20 }}>
<View className="login-divider-line" />
<Text className="login-divider-text"></Text>
<View className="login-divider-line" />
</View>
<PhoneQuickLoginButton
loading={phoneQuickLoading}
agreed={agreed}
onRequireAgree={() => ensureAgreed()}
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
onFail={(message) => setMsg(message)}
/>
<Text
className="login-divider-text"
style={{ display: 'block', textAlign: 'center', marginTop: 12 }}
onClick={() => setShowSmsForm((v) => !v)}
>
{showSmsForm ? '收起验证码登录' : '使用验证码登录'}
</Text>
</>
) : null}
{displayMsg ? ( {displayMsg ? (
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}> <Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
{displayMsg} {displayMsg}
@@ -6,7 +6,6 @@ import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
import { buildPayUrl } from '../../lib/checkout-nav'; import { buildPayUrl } from '../../lib/checkout-nav';
import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat'; import { fetchUserProfile } from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images'; import { getProductMainImage } from '../../lib/product-images';
@@ -150,9 +149,6 @@ export default function OrderConfirmPickupPage() {
} }
} }
const ready = await ensurePayReady(returnPath);
if (!ready) return;
const { confirm } = await Taro.showModal({ const { confirm } = await Taro.showModal({
title: '确认提交订单', title: '确认提交订单',
content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家。`, content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家。`,
@@ -8,7 +8,6 @@ import { goLogin } from '../../lib/auth-nav';
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav'; import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
import { tryGetClientGpsLocation } from '../../lib/client-location'; import { tryGetClientGpsLocation } from '../../lib/client-location';
import { maskPhone } from '../../lib/phone'; import { maskPhone } from '../../lib/phone';
import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat'; import { fetchUserProfile } from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import DeliveryHintHtml from '../../components/DeliveryHintHtml'; import DeliveryHintHtml from '../../components/DeliveryHintHtml';
@@ -291,9 +290,6 @@ export default function OrderConfirmPage() {
} }
} }
const ready = await ensurePayReady(returnPath);
if (!ready) return;
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
@@ -16,7 +16,6 @@ import ProductCarousel from '../../components/ProductCarousel';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import BenefitIntroCard from '../../components/BenefitIntroCard'; import BenefitIntroCard from '../../components/BenefitIntroCard';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
import { ensurePayReady } from '../../lib/pay-ready';
import { isLoggedIn, request, toast } from '../../lib/api'; import { isLoggedIn, request, toast } from '../../lib/api';
import { import {
getProductCarouselImages, getProductCarouselImages,
@@ -236,8 +235,6 @@ export default function ProductDetailPage() {
goLogin(returnPath); goLogin(returnPath);
return; return;
} }
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath }); Taro.navigateTo({ url: returnPath });
} }
@@ -253,8 +250,6 @@ export default function ProductDetailPage() {
goLogin(returnPath); goLogin(returnPath);
return; return;
} }
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath }); Taro.navigateTo({ url: returnPath });
} }
@@ -57,8 +57,10 @@ function readCachedRecord(): RedeemRecord | null {
export default function RedeemSuccessPage() { export default function RedeemSuccessPage() {
const router = useRouter(); const router = useRouter();
const [fromHistory, setFromHistory] = useState(false); const [fromHistory, setFromHistory] = useState(false);
const [fromStore, setFromStore] = useState(false);
const [record, setRecord] = useState<RedeemRecord | null>(null); const [record, setRecord] = useState<RedeemRecord | null>(null);
const [score, setScore] = useState(5); const [serviceScore, setServiceScore] = useState(5);
const [envScore, setEnvScore] = useState(5);
const [tags, setTags] = useState<string[]>(['菜品好', '环境佳', '服务周到']); const [tags, setTags] = useState<string[]>(['菜品好', '环境佳', '服务周到']);
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
const [imageUrls, setImageUrls] = useState<string[]>([]); const [imageUrls, setImageUrls] = useState<string[]>([]);
@@ -68,8 +70,12 @@ export default function RedeemSuccessPage() {
const rated = Boolean(record?.rating); const rated = Boolean(record?.rating);
const applyRating = useCallback((rating: StoreRatingDto) => { const applyRating = useCallback((rating: StoreRatingDto) => {
const nextScore = Number(rating.serviceScore || rating.envScore || 5); const nextService = Number(rating.serviceScore || 5);
setScore(Number.isFinite(nextScore) && nextScore > 0 ? Math.min(5, Math.round(nextScore)) : 5); const nextEnv = Number(rating.envScore || 5);
setServiceScore(
Number.isFinite(nextService) && nextService > 0 ? Math.min(5, Math.round(nextService)) : 5,
);
setEnvScore(Number.isFinite(nextEnv) && nextEnv > 0 ? Math.min(5, Math.round(nextEnv)) : 5);
setTags(Array.isArray(rating.tags) ? rating.tags : []); setTags(Array.isArray(rating.tags) ? rating.tags : []);
setComment(String(rating.comment || '')); setComment(String(rating.comment || ''));
setImageUrls(Array.isArray(rating.imageUrls) ? rating.imageUrls : []); setImageUrls(Array.isArray(rating.imageUrls) ? rating.imageUrls : []);
@@ -105,6 +111,7 @@ export default function RedeemSuccessPage() {
const id = String(options?.id || router.params.id || '').trim(); const id = String(options?.id || router.params.id || '').trim();
const from = String(options?.from || router.params.from || ''); const from = String(options?.from || router.params.from || '');
setFromHistory(from === 'history'); setFromHistory(from === 'history');
setFromStore(from === 'store');
if (id) { if (id) {
void loadRecord(id); void loadRecord(id);
return; return;
@@ -138,7 +145,7 @@ export default function RedeemSuccessPage() {
function leave() { function leave() {
clearCache(); clearCache();
const pages = Taro.getCurrentPages(); const pages = Taro.getCurrentPages();
if (fromHistory && pages.length > 1) { if ((fromHistory || fromStore) && pages.length > 1) {
Taro.navigateBack(); Taro.navigateBack();
return; return;
} }
@@ -187,8 +194,8 @@ export default function RedeemSuccessPage() {
method: 'POST', method: 'POST',
data: { data: {
redeemRecordId: record.id, redeemRecordId: record.id,
serviceScore: score, serviceScore,
envScore: score, envScore,
comment: comment.trim(), comment: comment.trim(),
tags, tags,
imageUrls, imageUrls,
@@ -199,7 +206,7 @@ export default function RedeemSuccessPage() {
prev prev
? { ? {
...prev, ...prev,
rating: { serviceScore: score, envScore: score, comment, tags, imageUrls }, rating: { serviceScore, envScore, comment, tags, imageUrls },
} }
: prev, : prev,
); );
@@ -228,21 +235,39 @@ export default function RedeemSuccessPage() {
</View> </View>
<View className="review-card"> <View className="review-card">
<Text className="review-card-title"></Text> <Text className="review-card-title"></Text>
<View className="review-star-row"> <View className="review-star-row">
{[1, 2, 3, 4, 5].map((value) => ( {[1, 2, 3, 4, 5].map((value) => (
<Text <Text
key={value} key={`service-${value}`}
className={`review-star${value <= score ? ' review-star--active' : ''}`} className={`review-star${value <= serviceScore ? ' review-star--active' : ''}`}
onClick={() => { onClick={() => {
if (!rated) setScore(value); if (!rated) setServiceScore(value);
}} }}
> >
</Text> </Text>
))} ))}
</View> </View>
<Text className="review-score-label">{SCORE_LABELS[score]}</Text> <Text className="review-score-label">{SCORE_LABELS[serviceScore]}</Text>
</View>
<View className="review-card">
<Text className="review-card-title"></Text>
<View className="review-star-row">
{[1, 2, 3, 4, 5].map((value) => (
<Text
key={`env-${value}`}
className={`review-star${value <= envScore ? ' review-star--active' : ''}`}
onClick={() => {
if (!rated) setEnvScore(value);
}}
>
</Text>
))}
</View>
<Text className="review-score-label">{SCORE_LABELS[envScore]}</Text>
</View> </View>
<View className="review-card"> <View className="review-card">
@@ -16,7 +16,7 @@ import ProductCarousel from '../../components/ProductCarousel';
import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../components/StoreRedeemMarquee'; import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../components/StoreRedeemMarquee';
import BenefitIntroCard from '../../components/BenefitIntroCard'; import BenefitIntroCard from '../../components/BenefitIntroCard';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api'; import { request, toast, isLoggedIn } from '../../lib/api';
import { toMoneyNumber } from '../../lib/money'; import { toMoneyNumber } from '../../lib/money';
import { maskPhone, toDialablePhone } from '../../lib/phone'; import { maskPhone, toDialablePhone } from '../../lib/phone';
import { track } from '../../lib/analytics'; import { track } from '../../lib/analytics';
@@ -158,7 +158,7 @@ function toMarqueeItem(row: RecentRedeem): StoreRedeemMarqueeItem | null {
const userLabel = String(row.userLabel || '用户***').trim() || '用户***'; const userLabel = String(row.userLabel || '用户***').trim() || '用户***';
const amount = formatRedeemAmountYuan(row.amount); const amount = formatRedeemAmountYuan(row.amount);
if (!amount) return null; if (!amount) return null;
return { userLabel, amount }; return { userLabel, amount, createdAt: row.createdAt };
} catch { } catch {
return null; return null;
} }
@@ -189,6 +189,7 @@ export default function StoreDetailPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [headerSolid, setHeaderSolid] = useState(false); const [headerSolid, setHeaderSolid] = useState(false);
const [pendingRatingId, setPendingRatingId] = useState<string | null>(null);
const storeRef = useRef<Store | null>(null); const storeRef = useRef<Store | null>(null);
storeRef.current = store; storeRef.current = store;
@@ -274,6 +275,13 @@ export default function StoreDetailPage() {
if (!id) return; if (!id) return;
void loadStore(id); void loadStore(id);
void loadRecentRedeems(id); void loadRecentRedeems(id);
if (isLoggedIn()) {
void request<{ id?: string | null }>(`/redeem/records/pending-rating?storeId=${id}`)
.then((data) => setPendingRatingId(data?.id ? String(data.id) : null))
.catch(() => setPendingRatingId(null));
} else {
setPendingRatingId(null);
}
}); });
const sharePayload = useMemo(() => { const sharePayload = useMemo(() => {
@@ -530,8 +538,20 @@ export default function StoreDetailPage() {
) : null} ) : null}
<View className="store-detail-bar"> <View className="store-detail-bar">
{pendingRatingId ? (
<View
className="store-detail-bar-btn store-detail-bar-btn--ghost"
onClick={() =>
Taro.navigateTo({
url: `/pages/redeem-success/index?id=${pendingRatingId}&from=store`,
})
}
>
<Text></Text>
</View>
) : null}
<View <View
className="u-btn u-btn--block" className={`store-detail-bar-btn store-detail-bar-btn--primary${pendingRatingId ? '' : ' store-detail-bar-btn--full'}`}
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })} onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
> >
<Text></Text> <Text></Text>
@@ -1,4 +1,6 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom', navigationStyle: 'custom',
navigationBarTitleText: '套餐详情', navigationBarTitleText: '套餐详情',
}); enableShareAppMessage: true,
enableShareTimeline: true,
});
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import '../../styles/store-detail.css'; import '../../styles/store-detail.css';
import '../../styles/product-detail.css'; import '../../styles/product-detail.css';
@@ -7,7 +7,13 @@ import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar'; import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel'; import ProductCarousel from '../../components/ProductCarousel';
import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import {
buildSceneSharePayload,
toWeappShareMessage,
toWeappShareTimeline,
} from '../../lib/wechat-share';
type StorePackage = { type StorePackage = {
name: string; name: string;
@@ -46,6 +52,7 @@ function formatPriceYuan(price: string | number) {
export default function StorePackageDetailPage() { export default function StorePackageDetailPage() {
const router = useRouter(); const router = useRouter();
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.storeId)); const [storeId, setStoreId] = useState(() => pickStoreId(router.params.storeId));
const [packageIndex, setPackageIndex] = useState(() => parsePackageIndex(router.params.index));
const [storeName, setStoreName] = useState(''); const [storeName, setStoreName] = useState('');
const [pkg, setPkg] = useState<StorePackage | null>(null); const [pkg, setPkg] = useState<StorePackage | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -89,10 +96,34 @@ export default function StorePackageDetailPage() {
const sid = pickStoreId(options?.storeId || router.params.storeId); const sid = pickStoreId(options?.storeId || router.params.storeId);
const index = parsePackageIndex(options?.index ?? router.params.index); const index = parsePackageIndex(options?.index ?? router.params.index);
setStoreId(sid); setStoreId(sid);
setPackageIndex(index);
void loadPackage(sid, index); void loadPackage(sid, index);
}); });
function goBack() { const imageUrls = useMemo(
() => (pkg ? normalizeStorePackageImageUrls(pkg) : []),
[pkg],
);
const sharePayload = useMemo(
() =>
buildSceneSharePayload('storeDetail', {
path: `/pages/store-package-detail/index?storeId=${storeId}&index=${packageIndex}`,
dynamicTitle: pkg
? storeName
? `${pkg.name} · ${storeName}`
: pkg.name
: undefined,
dynamicDesc: pkg?.dishes?.trim() || undefined,
dynamicImageUrl: imageUrls[0] || undefined,
}),
[imageUrls, pkg, packageIndex, storeId, storeName],
);
const sharePayloadRef = useRef(sharePayload);
sharePayloadRef.current = sharePayload;
const shareQuery = storeId ? `storeId=${storeId}&index=${packageIndex}` : '';
const goBack = useCallback(() => {
const pages = Taro.getCurrentPages(); const pages = Taro.getCurrentPages();
if (pages.length > 1) Taro.navigateBack(); if (pages.length > 1) Taro.navigateBack();
else if (storeId) { else if (storeId) {
@@ -100,30 +131,36 @@ export default function StorePackageDetailPage() {
} else { } else {
Taro.switchTab({ url: '/pages/stores/index' }); Taro.switchTab({ url: '/pages/stores/index' });
} }
} }, [storeId]);
if (loading) { // 不用 useShare* HookTaro 编译会把它们折进 return,触发 React #310
return ( useEffect(() => {
<PageShell variant="scroll" className="store-package-detail-page"> const page = Taro.getCurrentInstance().page as
<PageNavBar title="套餐详情" solid onBack={goBack} /> | {
<View className="page-with-nav-bar u-empty"></View> onShareAppMessage?: () => ReturnType<typeof toWeappShareMessage>;
</PageShell> onShareTimeline?: () => ReturnType<typeof toWeappShareTimeline>;
); }
} | undefined;
if (!page) return;
page.onShareAppMessage = () => toWeappShareMessage(sharePayloadRef.current);
page.onShareTimeline = () => toWeappShareTimeline(sharePayloadRef.current, shareQuery);
}, [shareQuery]);
if (!pkg) { if (!pkg) {
return ( return (
<PageShell variant="scroll" className="store-package-detail-page"> <PageShell variant="scroll" className="store-package-detail-page">
<WechatShareReady payload={sharePayload} />
<PageNavBar title="套餐详情" solid onBack={goBack} /> <PageNavBar title="套餐详情" solid onBack={goBack} />
<View className="page-with-nav-bar u-empty">{loadError || '套餐不存在'}</View> <View className="page-with-nav-bar u-empty">
{loading ? '加载中…' : loadError || '套餐不存在'}
</View>
</PageShell> </PageShell>
); );
} }
const imageUrls = normalizeStorePackageImageUrls(pkg);
return ( return (
<PageShell variant="scroll" className="store-package-detail-page"> <PageShell variant="scroll" className="store-package-detail-page">
<WechatShareReady payload={sharePayload} />
<PageNavBar title={pkg.name} solid titleVisible onBack={goBack} /> <PageNavBar title={pkg.name} solid titleVisible onBack={goBack} />
<View className="store-package-detail-body"> <View className="store-package-detail-body">
+3 -1
View File
@@ -492,7 +492,9 @@ export default function StoresPage() {
</Text> </Text>
))} ))}
</View> </View>
<Text className="store-card-redeem">{s.redeemCount ?? 0}</Text> {Number(s.redeemCount) > 0 ? (
<Text className="store-card-redeem">{s.redeemCount}</Text>
) : null}
</View> </View>
<View className="store-card-row store-card-row--mid"> <View className="store-card-row store-card-row--mid">
<Text className="store-card-address" numberOfLines={2}> <Text className="store-card-address" numberOfLines={2}>
@@ -51,6 +51,11 @@ export class UserRedeemController {
); );
} }
@Get('records/pending-rating')
pendingRating(@CurrentUser() user: AuthUser, @Query('storeId') storeId: string) {
return this.redeemService.getPendingRatingRecord(user.actorId, storeId);
}
@Get('records/:id') @Get('records/:id')
record(@CurrentUser() user: AuthUser, @Param('id') id: string) { record(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.redeemService.getUserRecord(user.actorId, id); return this.redeemService.getUserRecord(user.actorId, id);
@@ -3,11 +3,18 @@ import { AnalyticsModule } from '../analytics/analytics.module';
import { IamModule } from '../iam/iam.module'; import { IamModule } from '../iam/iam.module';
import { BenefitModule } from '../benefit/benefit.module'; import { BenefitModule } from '../benefit/benefit.module';
import { SettlementModule } from '../settlement/settlement.module'; import { SettlementModule } from '../settlement/settlement.module';
import { StoreModule } from '../store/store.module';
import { RedeemService } from './redeem.service'; import { RedeemService } from './redeem.service';
import { ShopRedeemController, UserRedeemController } from './redeem.controller'; import { ShopRedeemController, UserRedeemController } from './redeem.controller';
@Module({ @Module({
imports: [IamModule, AnalyticsModule, BenefitModule, forwardRef(() => SettlementModule)], imports: [
IamModule,
AnalyticsModule,
BenefitModule,
forwardRef(() => SettlementModule),
forwardRef(() => StoreModule),
],
controllers: [UserRedeemController, ShopRedeemController], controllers: [UserRedeemController, ShopRedeemController],
providers: [RedeemService], providers: [RedeemService],
exports: [RedeemService], exports: [RedeemService],
@@ -1,7 +1,9 @@
import { import {
BadRequestException, BadRequestException,
Inject,
Injectable, Injectable,
NotFoundException, NotFoundException,
forwardRef,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { import {
@@ -33,6 +35,7 @@ import { BenefitService } from '../benefit/benefit.service';
import { AuthService } from '../iam/auth.service'; import { AuthService } from '../iam/auth.service';
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service'; import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import { StoreService } from '../store/store.service';
type TokenPayload = { type TokenPayload = {
userId: string; userId: string;
@@ -123,6 +126,8 @@ export class RedeemService {
private readonly payRedeemAnomaly: PayRedeemAnomalyService, private readonly payRedeemAnomaly: PayRedeemAnomalyService,
private readonly wecomPush: WecomMessagePushService, private readonly wecomPush: WecomMessagePushService,
private readonly systemConfig: SystemConfigService, private readonly systemConfig: SystemConfigService,
@Inject(forwardRef(() => StoreService))
private readonly storeService: StoreService,
) {} ) {}
private maskPhoneForStore(phone: string) { private maskPhoneForStore(phone: string) {
@@ -1388,6 +1393,22 @@ export class RedeemService {
}); });
} }
async getPendingRatingRecord(userId: bigint, storeId: string) {
const sid = String(storeId || '').trim();
if (!/^\d+$/.test(sid)) return null;
const record = await this.prisma.redeemRecord.findFirst({
where: {
userId,
storeId: BigInt(sid),
rating: null,
},
orderBy: { createdAt: 'desc' },
select: { id: true },
});
if (!record) return null;
return serializeBigInt({ id: record.id });
}
async submitRating( async submitRating(
userId: bigint, userId: bigint,
body: { body: {
@@ -1421,6 +1442,7 @@ export class RedeemService {
imageUrls, imageUrls,
}, },
}); });
await this.storeService.refreshStoreRatingFromReviews(record.storeId);
return serializeBigInt(mapStoreRating(rating)); return serializeBigInt(mapStoreRating(rating));
} }
} }
@@ -1668,4 +1668,19 @@ export class StoreService {
await this.partnerGetStore(partnerAccountId, storeId); await this.partnerGetStore(partnerAccountId, storeId);
return this.replaceStoreCategories(storeId, categoryIds, priorities); return this.replaceStoreCategories(storeId, categoryIds, priorities);
} }
/** 评价提交后回写门店综合评分(服务+环境均值) */
async refreshStoreRatingFromReviews(storeId: bigint) {
const agg = await this.prisma.storeRating.aggregate({
where: { storeId },
_avg: { serviceScore: true, envScore: true },
});
const avgService = Number(agg._avg.serviceScore ?? 0);
const avgEnv = Number(agg._avg.envScore ?? 0);
const rating = Math.round(((avgService + avgEnv) / 2) * 100) / 100;
await this.prisma.store.update({
where: { id: storeId },
data: { rating },
});
}
} }