Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/73
This commit was merged in pull request #73.
This commit is contained in:
@@ -128,8 +128,12 @@ function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] })
|
||||
<p className="label-md text-muted">累计拓店: {entry.totalStores} 间</p>
|
||||
</div>
|
||||
<div className="partner-leaderboard-stat">
|
||||
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
||||
<p className="label-md text-muted">本月新增</p>
|
||||
{entry.periodStores > 0 ? (
|
||||
<>
|
||||
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
||||
<p className="label-md text-muted">本月新增</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -117,8 +117,12 @@ export default function LeaderboardPage() {
|
||||
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
||||
</div>
|
||||
<div className="partner-leaderboard-stat">
|
||||
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
||||
<p className="label-md text-muted">{periodSubLabel(period)}</p>
|
||||
{entry.periodStores > 0 ? (
|
||||
<>
|
||||
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
||||
<p className="label-md text-muted">{periodSubLabel(period)}</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
import { useMemo } from 'react';
|
||||
import { View, Text, Swiper, SwiperItem } from '@tarojs/components';
|
||||
import { formatRedeemRelativeTime } from '../lib/datetime';
|
||||
|
||||
export type StoreRedeemMarqueeItem = {
|
||||
userLabel: string;
|
||||
amount: string;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
type StoreRedeemMarqueeProps = {
|
||||
items: StoreRedeemMarqueeItem[];
|
||||
};
|
||||
|
||||
const STAY_MS = 20000;
|
||||
const STAY_MS = 5000;
|
||||
|
||||
function MarqueeRow({ item }: { item: StoreRedeemMarqueeItem }) {
|
||||
const timeLabel = formatRedeemRelativeTime(item.createdAt);
|
||||
return (
|
||||
<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-text">
|
||||
{item.userLabel} {timeLabel}到店核销
|
||||
</Text>
|
||||
<Text className="store-detail-marquee-amount">{item.amount}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** 核销记录:单条静止;多条竖向循环,每条停留 20 秒 */
|
||||
/** 核销记录:单条静止;多条竖向循环,支持手动滑动,每条停留 5 秒 */
|
||||
export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
|
||||
const list = useMemo(
|
||||
() =>
|
||||
@@ -30,6 +35,7 @@ export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
|
||||
.map((row) => ({
|
||||
userLabel: String(row.userLabel || '用户***').trim() || '用户***',
|
||||
amount: String(row.amount || '').trim(),
|
||||
createdAt: row.createdAt ?? null,
|
||||
}))
|
||||
.filter((row) => row.amount),
|
||||
[items],
|
||||
@@ -57,7 +63,7 @@ export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
|
||||
indicatorDots={false}
|
||||
>
|
||||
{list.map((row, index) => (
|
||||
<SwiperItem key={`${row.userLabel}|${row.amount}|${index}`}>
|
||||
<SwiperItem key={`${row.userLabel}|${row.amount}|${row.createdAt}|${index}`}>
|
||||
<MarqueeRow item={row} />
|
||||
</SwiperItem>
|
||||
))}
|
||||
|
||||
@@ -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/Shanghai:2026-08-03 15:14:30(不依赖 Intl,兼容微信小程序) */
|
||||
export function formatShanghaiDateTime(input?: string | Date | null): string {
|
||||
if (input == null || input === '') return '—';
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
isHomeCatalogBootstrapped,
|
||||
setHomeCatalogCache,
|
||||
} from '../../lib/home-catalog-session';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
|
||||
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
import {
|
||||
@@ -202,8 +202,6 @@ export default function HomePage() {
|
||||
goLogin(returnPath);
|
||||
return;
|
||||
}
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
Taro.navigateTo({ url: returnPath });
|
||||
}
|
||||
|
||||
@@ -213,8 +211,6 @@ export default function HomePage() {
|
||||
goLogin(returnPath);
|
||||
return;
|
||||
}
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
Taro.navigateTo({ url: returnPath });
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function LoginPage() {
|
||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
|
||||
const [wxAuthorize, setWxAuthorize] = useState(true);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -393,7 +393,7 @@ export default function LoginPage() {
|
||||
? '绑定手机号'
|
||||
: completeMode === 'wechat'
|
||||
? '授权登录'
|
||||
: '手机号快捷登录';
|
||||
: '验证码登录';
|
||||
|
||||
return (
|
||||
<PageShell variant="plain" className="login-page">
|
||||
@@ -451,32 +451,9 @@ export default function LoginPage() {
|
||||
|
||||
<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) && (
|
||||
<>
|
||||
<View className="login-field" style={showPhoneQuick ? { marginTop: 8 } : undefined}>
|
||||
<View className="login-field">
|
||||
<Text className="login-field-prefix">+86</Text>
|
||||
<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 ? (
|
||||
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||||
{displayMsg}
|
||||
|
||||
@@ -6,7 +6,6 @@ import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import { request, toast } from '../../lib/api';
|
||||
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({
|
||||
title: '确认提交订单',
|
||||
content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家。`,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { goLogin } from '../../lib/auth-nav';
|
||||
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
|
||||
import { tryGetClientGpsLocation } from '../../lib/client-location';
|
||||
import { maskPhone } from '../../lib/phone';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
|
||||
@@ -291,9 +290,6 @@ export default function OrderConfirmPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,6 @@ import ProductCarousel from '../../components/ProductCarousel';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import BenefitIntroCard from '../../components/BenefitIntroCard';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||
import {
|
||||
getProductCarouselImages,
|
||||
@@ -236,8 +235,6 @@ export default function ProductDetailPage() {
|
||||
goLogin(returnPath);
|
||||
return;
|
||||
}
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
Taro.navigateTo({ url: returnPath });
|
||||
}
|
||||
|
||||
@@ -253,8 +250,6 @@ export default function ProductDetailPage() {
|
||||
goLogin(returnPath);
|
||||
return;
|
||||
}
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
Taro.navigateTo({ url: returnPath });
|
||||
}
|
||||
|
||||
|
||||
@@ -57,8 +57,10 @@ function readCachedRecord(): RedeemRecord | null {
|
||||
export default function RedeemSuccessPage() {
|
||||
const router = useRouter();
|
||||
const [fromHistory, setFromHistory] = useState(false);
|
||||
const [fromStore, setFromStore] = useState(false);
|
||||
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 [comment, setComment] = useState('');
|
||||
const [imageUrls, setImageUrls] = useState<string[]>([]);
|
||||
@@ -68,8 +70,12 @@ export default function RedeemSuccessPage() {
|
||||
const rated = Boolean(record?.rating);
|
||||
|
||||
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);
|
||||
const nextService = Number(rating.serviceScore || 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 : []);
|
||||
setComment(String(rating.comment || ''));
|
||||
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 from = String(options?.from || router.params.from || '');
|
||||
setFromHistory(from === 'history');
|
||||
setFromStore(from === 'store');
|
||||
if (id) {
|
||||
void loadRecord(id);
|
||||
return;
|
||||
@@ -138,7 +145,7 @@ export default function RedeemSuccessPage() {
|
||||
function leave() {
|
||||
clearCache();
|
||||
const pages = Taro.getCurrentPages();
|
||||
if (fromHistory && pages.length > 1) {
|
||||
if ((fromHistory || fromStore) && pages.length > 1) {
|
||||
Taro.navigateBack();
|
||||
return;
|
||||
}
|
||||
@@ -187,8 +194,8 @@ export default function RedeemSuccessPage() {
|
||||
method: 'POST',
|
||||
data: {
|
||||
redeemRecordId: record.id,
|
||||
serviceScore: score,
|
||||
envScore: score,
|
||||
serviceScore,
|
||||
envScore,
|
||||
comment: comment.trim(),
|
||||
tags,
|
||||
imageUrls,
|
||||
@@ -199,7 +206,7 @@ export default function RedeemSuccessPage() {
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
rating: { serviceScore: score, envScore: score, comment, tags, imageUrls },
|
||||
rating: { serviceScore, envScore, comment, tags, imageUrls },
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
@@ -228,21 +235,39 @@ export default function RedeemSuccessPage() {
|
||||
</View>
|
||||
|
||||
<View className="review-card">
|
||||
<Text className="review-card-title">本次到店体验</Text>
|
||||
<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' : ''}`}
|
||||
key={`service-${value}`}
|
||||
className={`review-star${value <= serviceScore ? ' review-star--active' : ''}`}
|
||||
onClick={() => {
|
||||
if (!rated) setScore(value);
|
||||
if (!rated) setServiceScore(value);
|
||||
}}
|
||||
>
|
||||
★
|
||||
</Text>
|
||||
))}
|
||||
</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 className="review-card">
|
||||
|
||||
@@ -16,7 +16,7 @@ import ProductCarousel from '../../components/ProductCarousel';
|
||||
import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../components/StoreRedeemMarquee';
|
||||
import BenefitIntroCard from '../../components/BenefitIntroCard';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { request, toast, isLoggedIn } from '../../lib/api';
|
||||
import { toMoneyNumber } from '../../lib/money';
|
||||
import { maskPhone, toDialablePhone } from '../../lib/phone';
|
||||
import { track } from '../../lib/analytics';
|
||||
@@ -158,7 +158,7 @@ function toMarqueeItem(row: RecentRedeem): StoreRedeemMarqueeItem | null {
|
||||
const userLabel = String(row.userLabel || '用户***').trim() || '用户***';
|
||||
const amount = formatRedeemAmountYuan(row.amount);
|
||||
if (!amount) return null;
|
||||
return { userLabel, amount };
|
||||
return { userLabel, amount, createdAt: row.createdAt };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -189,6 +189,7 @@ export default function StoreDetailPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
const [pendingRatingId, setPendingRatingId] = useState<string | null>(null);
|
||||
const storeRef = useRef<Store | null>(null);
|
||||
storeRef.current = store;
|
||||
|
||||
@@ -274,6 +275,13 @@ export default function StoreDetailPage() {
|
||||
if (!id) return;
|
||||
void loadStore(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(() => {
|
||||
@@ -530,8 +538,20 @@ export default function StoreDetailPage() {
|
||||
) : null}
|
||||
|
||||
<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
|
||||
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' })}
|
||||
>
|
||||
<Text>到店核销</Text>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
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 '../../styles/store-detail.css';
|
||||
import '../../styles/product-detail.css';
|
||||
@@ -7,7 +7,13 @@ import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import PageNavBar from '../../components/PageNavBar';
|
||||
import ProductCarousel from '../../components/ProductCarousel';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import {
|
||||
buildSceneSharePayload,
|
||||
toWeappShareMessage,
|
||||
toWeappShareTimeline,
|
||||
} from '../../lib/wechat-share';
|
||||
|
||||
type StorePackage = {
|
||||
name: string;
|
||||
@@ -46,6 +52,7 @@ function formatPriceYuan(price: string | number) {
|
||||
export default function StorePackageDetailPage() {
|
||||
const router = useRouter();
|
||||
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.storeId));
|
||||
const [packageIndex, setPackageIndex] = useState(() => parsePackageIndex(router.params.index));
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [pkg, setPkg] = useState<StorePackage | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -89,10 +96,34 @@ export default function StorePackageDetailPage() {
|
||||
const sid = pickStoreId(options?.storeId || router.params.storeId);
|
||||
const index = parsePackageIndex(options?.index ?? router.params.index);
|
||||
setStoreId(sid);
|
||||
setPackageIndex(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();
|
||||
if (pages.length > 1) Taro.navigateBack();
|
||||
else if (storeId) {
|
||||
@@ -100,30 +131,36 @@ export default function StorePackageDetailPage() {
|
||||
} else {
|
||||
Taro.switchTab({ url: '/pages/stores/index' });
|
||||
}
|
||||
}
|
||||
}, [storeId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-package-detail-page">
|
||||
<PageNavBar title="套餐详情" solid onBack={goBack} />
|
||||
<View className="page-with-nav-bar u-empty">加载中…</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
// 不用 useShare* Hook:Taro 编译会把它们折进 return,触发 React #310
|
||||
useEffect(() => {
|
||||
const page = Taro.getCurrentInstance().page as
|
||||
| {
|
||||
onShareAppMessage?: () => ReturnType<typeof toWeappShareMessage>;
|
||||
onShareTimeline?: () => ReturnType<typeof toWeappShareTimeline>;
|
||||
}
|
||||
| undefined;
|
||||
if (!page) return;
|
||||
page.onShareAppMessage = () => toWeappShareMessage(sharePayloadRef.current);
|
||||
page.onShareTimeline = () => toWeappShareTimeline(sharePayloadRef.current, shareQuery);
|
||||
}, [shareQuery]);
|
||||
|
||||
if (!pkg) {
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-package-detail-page">
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const imageUrls = normalizeStorePackageImageUrls(pkg);
|
||||
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-package-detail-page">
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
<PageNavBar title={pkg.name} solid titleVisible onBack={goBack} />
|
||||
|
||||
<View className="store-package-detail-body">
|
||||
|
||||
@@ -492,7 +492,9 @@ export default function StoresPage() {
|
||||
</Text>
|
||||
))}
|
||||
</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 className="store-card-row store-card-row--mid">
|
||||
<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')
|
||||
record(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.redeemService.getUserRecord(user.actorId, id);
|
||||
|
||||
@@ -3,11 +3,18 @@ import { AnalyticsModule } from '../analytics/analytics.module';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { SettlementModule } from '../settlement/settlement.module';
|
||||
import { StoreModule } from '../store/store.module';
|
||||
import { RedeemService } from './redeem.service';
|
||||
import { ShopRedeemController, UserRedeemController } from './redeem.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, AnalyticsModule, BenefitModule, forwardRef(() => SettlementModule)],
|
||||
imports: [
|
||||
IamModule,
|
||||
AnalyticsModule,
|
||||
BenefitModule,
|
||||
forwardRef(() => SettlementModule),
|
||||
forwardRef(() => StoreModule),
|
||||
],
|
||||
controllers: [UserRedeemController, ShopRedeemController],
|
||||
providers: [RedeemService],
|
||||
exports: [RedeemService],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import {
|
||||
@@ -33,6 +35,7 @@ import { BenefitService } from '../benefit/benefit.service';
|
||||
import { AuthService } from '../iam/auth.service';
|
||||
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
|
||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||
import { StoreService } from '../store/store.service';
|
||||
|
||||
type TokenPayload = {
|
||||
userId: string;
|
||||
@@ -123,6 +126,8 @@ export class RedeemService {
|
||||
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
|
||||
private readonly wecomPush: WecomMessagePushService,
|
||||
private readonly systemConfig: SystemConfigService,
|
||||
@Inject(forwardRef(() => StoreService))
|
||||
private readonly storeService: StoreService,
|
||||
) {}
|
||||
|
||||
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(
|
||||
userId: bigint,
|
||||
body: {
|
||||
@@ -1421,6 +1442,7 @@ export class RedeemService {
|
||||
imageUrls,
|
||||
},
|
||||
});
|
||||
await this.storeService.refreshStoreRatingFromReviews(record.storeId);
|
||||
return serializeBigInt(mapStoreRating(rating));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1668,4 +1668,19 @@ export class StoreService {
|
||||
await this.partnerGetStore(partnerAccountId, storeId);
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user