feat(store): 门店套餐 v3.4.10 全端实现

新增 StorePackage 与变更审核流,覆盖总部直存、合伙/门店提审、C 端展示与套餐异议工单;同步 PRD/开发文档与 REQ 索引。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 19:30:59 +08:00
parent 7e3dc131ad
commit 3c19b7dd97
35 changed files with 2128 additions and 28 deletions
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import { View, Text, Image, Textarea } from '@tarojs/components';
import Taro, {
useDidShow,
useLoad,
@@ -28,6 +28,15 @@ type StoreMedia = {
mediaType?: string | null;
};
type StorePackage = {
name: string;
price: string | number;
dishes: string;
usableTime?: string | null;
otherNotes?: string | null;
sortOrder?: number;
};
type Store = {
id: string;
name: string;
@@ -42,6 +51,7 @@ type Store = {
coverUrl?: string | null;
carouselUrls?: string[] | null;
media?: StoreMedia[] | null;
packages?: StorePackage[] | null;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
@@ -142,6 +152,9 @@ export default function StoreDetailPage() {
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [disputeOpen, setDisputeOpen] = useState(false);
const [disputeRemark, setDisputeRemark] = useState('');
const [disputeSubmitting, setDisputeSubmitting] = useState(false);
const [headerSolid, setHeaderSolid] = useState(false);
const storeRef = useRef<Store | null>(null);
storeRef.current = store;
@@ -263,6 +276,27 @@ export default function StoreDetailPage() {
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
}
async function submitPackageDispute() {
if (!storeId) return;
setDisputeSubmitting(true);
try {
await request('/trade/package-disputes', {
method: 'POST',
body: JSON.stringify({
storeId,
remark: disputeRemark.trim() || '用户对门店套餐有异议',
}),
});
toast('已提交套餐异议');
setDisputeOpen(false);
setDisputeRemark('');
} catch (e) {
toast(e instanceof Error ? e.message : '提交失败');
} finally {
setDisputeSubmitting(false);
}
}
function openMap() {
if (!store) return;
const lat = store.latitude != null ? Number(store.latitude) : NaN;
@@ -389,6 +423,29 @@ export default function StoreDetailPage() {
</View>
) : null}
{store.packages && store.packages.length > 0 ? (
<View className="store-detail-section">
<Text className="store-detail-section-title"></Text>
{store.packages.map((pkg, index) => (
<View key={`${pkg.name}-${index}`} className="store-detail-package-card">
<Text className="store-detail-package-name">{pkg.name}</Text>
<Text className="store-detail-package-body">
{formatRedeemAmountYuan(pkg.price)} · {pkg.dishes}
</Text>
{pkg.usableTime ? (
<Text className="store-detail-package-meta">使{pkg.usableTime}</Text>
) : null}
{pkg.otherNotes ? (
<Text className="store-detail-package-meta">{pkg.otherNotes}</Text>
) : null}
</View>
))}
<Text className="store-detail-package-dispute" onClick={() => setDisputeOpen(true)}>
</Text>
</View>
) : null}
{intro ? (
<View className="store-detail-section">
<Text className="store-detail-section-title"></Text>
@@ -420,6 +477,33 @@ export default function StoreDetailPage() {
</View>
) : null}
{disputeOpen ? (
<View className="store-detail-dispute-mask" onClick={() => !disputeSubmitting && setDisputeOpen(false)}>
<View className="store-detail-dispute-panel" onClick={(e) => e.stopPropagation()}>
<Text className="store-detail-section-title"></Text>
<Text className="store-detail-package-meta"></Text>
<Textarea
className="store-detail-dispute-input"
value={disputeRemark}
maxlength={500}
placeholder="请填写异议说明(选填)"
onInput={(e) => setDisputeRemark(e.detail.value)}
/>
<View className="store-detail-dispute-actions">
<View className="u-btn u-btn--ghost" onClick={() => setDisputeOpen(false)}>
<Text></Text>
</View>
<View
className="u-btn"
onClick={() => !disputeSubmitting && void submitPackageDispute()}
>
<Text>{disputeSubmitting ? '提交中…' : '提交'}</Text>
</View>
</View>
</View>
</View>
) : null}
<View className="store-detail-bar">
<View
className="u-btn u-btn--block"
@@ -234,3 +234,85 @@
flex: 1;
margin-right: 0;
}
.store-detail-package-card {
padding: 12px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.store-detail-package-card:last-of-type {
border-bottom: none;
}
.store-detail-package-name {
display: block;
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary, #1a1a1a);
margin-bottom: 6px;
}
.store-detail-package-body {
display: block;
font-size: 14px;
color: var(--color-text-secondary, #666);
line-height: 1.5;
}
.store-detail-package-meta {
display: block;
font-size: 13px;
color: var(--color-text-tertiary, #999);
margin-top: 4px;
}
.store-detail-package-dispute {
display: block;
margin-top: 12px;
font-size: 13px;
color: var(--color-heritage-red);
}
.store-detail-dispute-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1000;
display: flex;
align-items: flex-end;
justify-content: center;
}
.store-detail-dispute-panel {
width: 100%;
max-width: 480px;
background: #fff;
border-radius: 16px 16px 0 0;
padding: 20px 16px calc(20px + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.store-detail-dispute-input {
width: 100%;
min-height: 96px;
margin: 12px 0 16px;
padding: 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
box-sizing: border-box;
font-size: 14px;
}
.store-detail-dispute-actions {
display: flex;
gap: 12px;
}
.store-detail-dispute-actions .u-btn {
flex: 1;
}
.store-detail-dispute-actions .u-btn--ghost {
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
}