Files
dukang/apps/mini-user/src/pages/redeem-success/index.tsx
T
jacy 0e711be6c6
CI / verify (pull_request) Has been cancelled
v4.0.17 小程序版本优化
2026-09-03 23:28:37 +08:00

368 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from 'react';
import { View, Text, Image, Textarea } from '@tarojs/components';
import '../../styles/redeem.css';
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 { 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;
amount: number;
storeId: string;
storeName: string;
createdAt: string;
rating?: StoreRatingDto | null;
};
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 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 [fromHistory, setFromHistory] = useState(false);
const [fromStore, setFromStore] = useState(false);
const [record, setRecord] = useState<RedeemRecord | null>(null);
const [serviceScore, setServiceScore] = useState(5);
const [envScore, setEnvScore] = 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 applyRating = useCallback((rating: StoreRatingDto) => {
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 : []);
}, []);
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');
setFromStore(from === 'store');
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 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 {
Taro.removeStorageSync(LAST_REDEEM_RESULT_KEY);
} catch {
/* ignore */
}
}
function leave() {
clearCache();
const pages = Taro.getCurrentPages();
if ((fromHistory || fromStore) && pages.length > 1) {
Taro.navigateBack();
return;
}
Taro.switchTab({ url: '/pages/benefit/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 {
await request('/redeem/ratings', {
method: 'POST',
data: {
redeemRecordId: record.id,
serviceScore,
envScore,
comment: comment.trim(),
tags,
imageUrls,
},
});
toast('评价已提交', 'success');
setRecord((prev) =>
prev
? {
...prev,
rating: { serviceScore, envScore, comment, tags, imageUrls },
}
: prev,
);
setTimeout(() => leave(), 400);
} catch (e) {
toast(e instanceof Error ? e.message : '评价失败');
} finally {
setLoading(false);
}
}
return (
<PageShell variant="sub" className="redeem-success-page">
<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="review-card">
<Text className="review-card-title">服务评分</Text>
<View className="review-star-row">
{[1, 2, 3, 4, 5].map((value) => (
<Text
key={`service-${value}`}
className={`review-star${value <= serviceScore ? ' review-star--active' : ''}`}
onClick={() => {
if (!rated) setServiceScore(value);
}}
>
</Text>
))}
</View>
<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">
<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="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>
);
}