368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|