feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理

订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 14:35:39 +08:00
parent 3b669f7e38
commit 9c8d5f2cad
125 changed files with 6355 additions and 1436 deletions
@@ -1,4 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '核销成功',
navigationBarTitleText: '评价门店',
});
+277 -96
View File
@@ -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>
);