feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理
订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '核销成功',
|
||||
navigationBarTitleText: '评价门店',
|
||||
});
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { View, Text, Image, Textarea } from '@tarojs/components';
|
||||
import '../../styles/redeem.css';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import Taro, { useLoad, useRouter } from '@tarojs/taro';
|
||||
import {
|
||||
STORE_RATING_MAX_COMMENT,
|
||||
STORE_RATING_MAX_IMAGES,
|
||||
STORE_RATING_QUICK_TAGS,
|
||||
type StoreRatingDto,
|
||||
} from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { formatShanghaiDateTime } from '../../lib/datetime';
|
||||
import { formatMoney, toMoneyNumber } from '../../lib/money';
|
||||
import BenefitFigure from '../../components/BenefitFigure';
|
||||
import { toMoneyNumber } from '../../lib/money';
|
||||
import { chooseAndUploadRatingImages } from '../../lib/upload-rating-image';
|
||||
|
||||
const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult';
|
||||
|
||||
const SCORE_LABELS = ['', '较差', '一般', '还行', '很好', '非常好'] as const;
|
||||
|
||||
type RedeemRecord = {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
@@ -18,59 +26,106 @@ type RedeemRecord = {
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
createdAt: string;
|
||||
rating?: StoreRatingDto | null;
|
||||
};
|
||||
|
||||
/** 与权益「历史记录」一致:2026-08-03 13:53:03(Asia/Shanghai) */
|
||||
function formatChinaDateTime(input?: string | null) {
|
||||
return formatShanghaiDateTime(input ?? new Date());
|
||||
function formatAmountYuan(amount: unknown) {
|
||||
const n = toMoneyNumber(amount);
|
||||
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
|
||||
return n.toFixed(2).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
function StarRating({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (score: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<View className="redeem-rating-row">
|
||||
<Text className="redeem-rating-label">{label}</Text>
|
||||
<View className="redeem-star-row">
|
||||
{[1, 2, 3, 4, 5].map((score) => (
|
||||
<Text
|
||||
key={score}
|
||||
className={`redeem-star-btn${score <= value ? ' redeem-star-btn--active' : ''}`}
|
||||
onClick={() => onChange(score)}
|
||||
>
|
||||
★
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
function formatVisitLine(createdAt?: string | null, amount?: unknown) {
|
||||
const full = formatShanghaiDateTime(createdAt ?? new Date());
|
||||
if (full === '—') return `核销用餐权益 ${formatAmountYuan(amount)}`;
|
||||
const datePart = full.slice(0, 10);
|
||||
const time = full.slice(11, 16);
|
||||
const today = formatShanghaiDateTime(new Date()).slice(0, 10);
|
||||
const prefix = datePart === today ? '今日' : datePart.slice(5);
|
||||
return `${prefix} ${time} · 核销用餐权益 ${formatAmountYuan(amount)}`;
|
||||
}
|
||||
|
||||
function readCachedRecord(): RedeemRecord | null {
|
||||
try {
|
||||
const cached = Taro.getStorageSync(LAST_REDEEM_RESULT_KEY);
|
||||
return cached ? (JSON.parse(String(cached)) as RedeemRecord) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function RedeemSuccessPage() {
|
||||
const router = useRouter();
|
||||
const [serviceScore, setServiceScore] = useState(5);
|
||||
const [envScore, setEnvScore] = useState(5);
|
||||
const [fromHistory, setFromHistory] = useState(false);
|
||||
const [record, setRecord] = useState<RedeemRecord | null>(null);
|
||||
const [score, setScore] = useState(5);
|
||||
const [tags, setTags] = useState<string[]>(['菜品好', '环境佳', '服务周到']);
|
||||
const [comment, setComment] = useState('');
|
||||
const [imageUrls, setImageUrls] = useState<string[]>([]);
|
||||
const [coverUrl, setCoverUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const rated = Boolean(record?.rating);
|
||||
|
||||
const record = useMemo<RedeemRecord | null>(() => {
|
||||
try {
|
||||
const cached = Taro.getStorageSync(LAST_REDEEM_RESULT_KEY);
|
||||
return cached ? (JSON.parse(String(cached)) as RedeemRecord) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const applyRating = useCallback((rating: StoreRatingDto) => {
|
||||
const nextScore = Number(rating.serviceScore || rating.envScore || 5);
|
||||
setScore(Number.isFinite(nextScore) && nextScore > 0 ? Math.min(5, Math.round(nextScore)) : 5);
|
||||
setTags(Array.isArray(rating.tags) ? rating.tags : []);
|
||||
setComment(String(rating.comment || ''));
|
||||
setImageUrls(Array.isArray(rating.imageUrls) ? rating.imageUrls : []);
|
||||
}, []);
|
||||
|
||||
const loadRecord = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
let data: RedeemRecord | null = null;
|
||||
try {
|
||||
data = await request<RedeemRecord>(`/redeem/records/${id}`);
|
||||
} catch {
|
||||
const records = await request<{ list?: RedeemRecord[] } | RedeemRecord[]>(
|
||||
'/redeem/records?page=1&pageSize=50',
|
||||
);
|
||||
const list = Array.isArray(records) ? records : records?.list ?? [];
|
||||
data = list.find((item) => String(item.id) === id) ?? null;
|
||||
}
|
||||
if (!data) {
|
||||
toast('核销记录不存在');
|
||||
return;
|
||||
}
|
||||
setRecord(data);
|
||||
if (data.rating) applyRating(data.rating);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '加载失败');
|
||||
}
|
||||
},
|
||||
[applyRating],
|
||||
);
|
||||
|
||||
useLoad((options) => {
|
||||
const id = String(options?.id || router.params.id || '').trim();
|
||||
const from = String(options?.from || router.params.from || '');
|
||||
setFromHistory(from === 'history');
|
||||
if (id) {
|
||||
void loadRecord(id);
|
||||
return;
|
||||
}
|
||||
const cached = readCachedRecord();
|
||||
if (cached) setRecord(cached);
|
||||
});
|
||||
|
||||
const amount = toMoneyNumber(record?.amount ?? router.params.amount);
|
||||
const storeName = record?.storeName || '门店';
|
||||
const redeemNo = record?.redeemNo || '—';
|
||||
const redeemedAt = formatChinaDateTime(record?.createdAt);
|
||||
const visitLine = formatVisitLine(record?.createdAt, amount);
|
||||
|
||||
useEffect(() => {
|
||||
if (!record?.storeId) return;
|
||||
void request<{ coverUrl?: string | null }>(`/stores/${record.storeId}`)
|
||||
.then((store) => {
|
||||
const url = String(store?.coverUrl || '').trim();
|
||||
if (url) setCoverUrl(url);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [record?.storeId]);
|
||||
|
||||
function clearCache() {
|
||||
try {
|
||||
@@ -80,81 +135,207 @@ export default function RedeemSuccessPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function goBenefit() {
|
||||
function leave() {
|
||||
clearCache();
|
||||
const pages = Taro.getCurrentPages();
|
||||
if (fromHistory && pages.length > 1) {
|
||||
Taro.navigateBack();
|
||||
return;
|
||||
}
|
||||
Taro.switchTab({ url: '/pages/benefit/index' });
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
clearCache();
|
||||
Taro.switchTab({ url: '/pages/home/index' });
|
||||
function toggleTag(tag: string) {
|
||||
if (rated) return;
|
||||
setTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
|
||||
}
|
||||
|
||||
async function addPhotos() {
|
||||
if (rated || uploading) return;
|
||||
if (imageUrls.length >= STORE_RATING_MAX_IMAGES) {
|
||||
toast(`最多上传${STORE_RATING_MAX_IMAGES}张图片`);
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const urls = await chooseAndUploadRatingImages(imageUrls.length);
|
||||
if (urls.length) setImageUrls((prev) => [...prev, ...urls].slice(0, STORE_RATING_MAX_IMAGES));
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function removePhoto(url: string) {
|
||||
if (rated) return;
|
||||
setImageUrls((prev) => prev.filter((item) => item !== url));
|
||||
}
|
||||
|
||||
async function submitRatingAndFinish() {
|
||||
if (!record?.id) {
|
||||
toast('找不到核销记录');
|
||||
return;
|
||||
}
|
||||
if (rated) {
|
||||
leave();
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
if (record?.id) {
|
||||
await request('/redeem/ratings', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
redeemRecordId: record.id,
|
||||
serviceScore,
|
||||
envScore,
|
||||
},
|
||||
});
|
||||
toast('评价已提交', 'success');
|
||||
}
|
||||
} catch {
|
||||
/* 评价失败不阻塞返回 */
|
||||
await request('/redeem/ratings', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
redeemRecordId: record.id,
|
||||
serviceScore: score,
|
||||
envScore: score,
|
||||
comment: comment.trim(),
|
||||
tags,
|
||||
imageUrls,
|
||||
},
|
||||
});
|
||||
toast('评价已提交', 'success');
|
||||
setRecord((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
rating: { serviceScore: score, envScore: score, comment, tags, imageUrls },
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setTimeout(() => leave(), 400);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '评价失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
goBenefit();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="redeem-success-page">
|
||||
<SubPageHeader title="核销成功" onBack={goBenefit} />
|
||||
<View className="sub-page-body">
|
||||
<View className="redeem-success-icon">
|
||||
<Text>✓</Text>
|
||||
</View>
|
||||
<Text className="redeem-success-title">核销成功</Text>
|
||||
<BenefitFigure value={formatMoney(amount)} size="lg" className="redeem-success-amount" />
|
||||
<Text className="redeem-success-desc">已在 {storeName} 完成核销</Text>
|
||||
|
||||
<View className="redeem-success-details">
|
||||
<View className="redeem-success-detail-row">
|
||||
<Text>核销门店</Text>
|
||||
<Text>{storeName}</Text>
|
||||
</View>
|
||||
<View className="redeem-success-detail-row">
|
||||
<Text>核销时间</Text>
|
||||
<Text>{redeemedAt}</Text>
|
||||
</View>
|
||||
<View className="redeem-success-detail-row">
|
||||
<Text>核销单号</Text>
|
||||
<Text className="redeem-success-mono">{redeemNo}</Text>
|
||||
<SubPageHeader title={rated ? '查看评价' : '评价门店'} onBack={leave} />
|
||||
<View className="sub-page-body review-page-body">
|
||||
<View className="review-store-card">
|
||||
{coverUrl ? (
|
||||
<Image className="review-store-cover" src={coverUrl} mode="aspectFill" />
|
||||
) : (
|
||||
<View className="review-store-cover review-store-cover--empty" />
|
||||
)}
|
||||
<View className="review-store-meta">
|
||||
<Text className="review-store-name">{storeName}</Text>
|
||||
<Text className="review-store-visit">{visitLine}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="redeem-success-rating">
|
||||
<Text className="redeem-success-rating-title">为门店服务评分</Text>
|
||||
<StarRating label="服务态度" value={serviceScore} onChange={setServiceScore} />
|
||||
<StarRating label="用餐环境" value={envScore} onChange={setEnvScore} />
|
||||
<View className="review-card">
|
||||
<Text className="review-card-title">本次到店体验</Text>
|
||||
<View className="review-star-row">
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<Text
|
||||
key={value}
|
||||
className={`review-star${value <= score ? ' review-star--active' : ''}`}
|
||||
onClick={() => {
|
||||
if (!rated) setScore(value);
|
||||
}}
|
||||
>
|
||||
★
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<Text className="review-score-label">{SCORE_LABELS[score]}</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`redeem-submit${loading ? ' redeem-submit--disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (!loading) void submitRatingAndFinish();
|
||||
}}
|
||||
>
|
||||
<Text>{loading ? '提交中…' : '提交评价并返回'}</Text>
|
||||
<View className="review-card">
|
||||
<View className="review-section-head">
|
||||
<View className="review-section-bar" />
|
||||
<Text className="review-section-title">快捷标签</Text>
|
||||
</View>
|
||||
<View className="review-tag-list">
|
||||
{STORE_RATING_QUICK_TAGS.map((tag) => (
|
||||
<Text
|
||||
key={tag}
|
||||
className={`review-tag${tags.includes(tag) ? ' review-tag--active' : ''}`}
|
||||
onClick={() => toggleTag(tag)}
|
||||
>
|
||||
{tag}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<View className="redeem-cancel-btn" onClick={goHome}>
|
||||
<Text>回到首页</Text>
|
||||
|
||||
<View className="review-card">
|
||||
<View className="review-section-head">
|
||||
<View className="review-section-bar" />
|
||||
<Text className="review-section-title">补充说明</Text>
|
||||
</View>
|
||||
{process.env.TARO_ENV === 'h5' ? (
|
||||
<textarea
|
||||
className="review-comment review-comment--native"
|
||||
placeholder="口味、环境、服务都可以写,选填"
|
||||
rows={4}
|
||||
maxLength={STORE_RATING_MAX_COMMENT}
|
||||
value={comment}
|
||||
disabled={rated}
|
||||
onChange={(e) => setComment(e.currentTarget.value)}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
className="review-comment"
|
||||
placeholder="口味、环境、服务都可以写,选填"
|
||||
maxlength={STORE_RATING_MAX_COMMENT}
|
||||
value={comment}
|
||||
disabled={rated}
|
||||
onInput={(e) => setComment(e.detail.value)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="review-card">
|
||||
<View className="review-section-head">
|
||||
<View className="review-section-bar" />
|
||||
<Text className="review-section-title">上传图片</Text>
|
||||
</View>
|
||||
<View className="review-photos">
|
||||
{imageUrls.map((url) => (
|
||||
<View key={url} className="review-photo">
|
||||
<Image
|
||||
className="review-photo-img"
|
||||
src={url}
|
||||
mode="aspectFill"
|
||||
onClick={() => Taro.previewImage({ current: url, urls: imageUrls })}
|
||||
/>
|
||||
{!rated ? (
|
||||
<Text className="review-photo-remove" onClick={() => removePhoto(url)}>
|
||||
×
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
{!rated && imageUrls.length < STORE_RATING_MAX_IMAGES ? (
|
||||
<View className="review-photo-add" onClick={() => void addPhotos()}>
|
||||
<Text className="review-photo-add-plus">{uploading ? '…' : '+'}</Text>
|
||||
<Text className="review-photo-add-text">{uploading ? '上传中' : '添加'}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!rated ? (
|
||||
<View
|
||||
className={`review-submit${loading || uploading ? ' review-submit--disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (!loading && !uploading) void submitRatingAndFinish();
|
||||
}}
|
||||
>
|
||||
<Text>{loading ? '提交中…' : '提交评价'}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{!rated && !fromHistory ? (
|
||||
<Text className="review-skip" onClick={leave}>
|
||||
暂不评价
|
||||
</Text>
|
||||
) : null}
|
||||
<Text className="review-disclaimer">评价用于帮助其他到店客人选择门店,不赠送用餐权益。</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user