Dev #73
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,兼容微信小程序) */
|
/** 格式化为 Asia/Shanghai:2026-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 '—';
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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* Hook:Taro 编译会把它们折进 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">
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user