客服电话 → 13203801799(CUSTOMER_SERVICE_PHONE,协议文案走常量自动同步)
权益页去掉分享按钮,菜单分享保留 支付成功改 reLaunch 清栈;订单列表返回首页 减数量低于起购时 toast「同城/跨城/现场提货至少购买 N 瓶」 核销成功可评星并 POST /redeem/ratings;HQ 新增「门店评价」页 /store-ratings 成功页「回到首页」改用 .redeem-cancel-btn,不再撑破布局 历史记录改为核销流水:GET /redeem/records;生产库已有 2 笔(用户 13073729990)可验证。权益明细字段映射也一并修了 我的页头像提高点击层级,避免被卡片遮挡
This commit is contained in:
@@ -6,6 +6,7 @@ import DashboardPage from './pages/DashboardPage';
|
||||
import UsersPage from './pages/UsersPage';
|
||||
import OrdersPage from './pages/OrdersPage';
|
||||
import StoresPage from './pages/StoresPage';
|
||||
import StoreRatingsPage from './pages/StoreRatingsPage';
|
||||
import StoreAccountsPage from './pages/StoreAccountsPage';
|
||||
import BenefitCouponsPage from './pages/BenefitCouponsPage';
|
||||
import BenefitLedgersPage from './pages/BenefitLedgersPage';
|
||||
@@ -78,6 +79,7 @@ export default function App() {
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||
<Route path="/stores" element={<StoresPage />} />
|
||||
<Route path="/store-ratings" element={<StoreRatingsPage />} />
|
||||
<Route path="/store-categories" element={<StoreCategoriesPage />} />
|
||||
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
||||
<Route path="/store-media" element={<StoreMediaPage />} />
|
||||
|
||||
@@ -51,6 +51,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
label: '门店',
|
||||
children: [
|
||||
{ key: '/stores', label: '门店列表' },
|
||||
{ key: '/store-ratings', label: '门店评价' },
|
||||
{ key: '/store-categories', label: '门店分类' },
|
||||
{ key: '/store-accounts', label: '门店账户' },
|
||||
{ key: '/store-media', label: '门店资源' },
|
||||
@@ -145,6 +146,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
'/knowledge-bases': 'knowledge_bases',
|
||||
'stores-group': 'stores',
|
||||
'/stores': 'stores',
|
||||
'/store-ratings': 'stores',
|
||||
'/store-categories': 'stores',
|
||||
'/store-accounts': 'stores',
|
||||
'/store-media': 'stores',
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Button, Form, Input, Table, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
serviceScore: number;
|
||||
envScore: number;
|
||||
createdAt: string;
|
||||
redeemNo: string;
|
||||
redeemAmount: number;
|
||||
store?: { id: string; name: string; cityName?: string };
|
||||
user?: { userNo: string; phone: string | null; nickname: string | null };
|
||||
};
|
||||
|
||||
export default function StoreRatingsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialStoreId = searchParams.get('storeId') || '';
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({
|
||||
...(initialStoreId ? { storeId: initialStoreId } : {}),
|
||||
});
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/store-ratings',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: ['store', 'name'],
|
||||
render: (v, row) => (row.store?.cityName ? `${v}(${row.store.cityName})` : v),
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
width: 140,
|
||||
render: (_, row) => row.user?.phone || row.user?.userNo || '-',
|
||||
},
|
||||
{ title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '服务分', dataIndex: 'serviceScore', width: 80 },
|
||||
{ title: '环境分', dataIndex: 'envScore', width: 80 },
|
||||
{ title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
门店评价
|
||||
</Typography.Title>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
initialValues={filters}
|
||||
onFinish={(v) => {
|
||||
setFilters({
|
||||
storeId: v.storeId || '',
|
||||
redeemNo: v.redeemNo || '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="storeId" label="门店ID">
|
||||
<Input allowClear placeholder="storeId" />
|
||||
</Form.Item>
|
||||
<Form.Item name="redeemNo" label="核销号">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -770,9 +770,12 @@ export default function StoresPage() {
|
||||
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
title: '操作', width: 140,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}>评价</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import ShareNavButton from '../../components/ShareNavButton';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
@@ -31,6 +30,14 @@ type CouponItem = {
|
||||
sourceProduct: string;
|
||||
};
|
||||
|
||||
type RedeemHistoryItem = {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
storeName: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
@@ -46,11 +53,13 @@ export default function BenefitPage() {
|
||||
const [loggedIn, setLoggedIn] = useState(() => isLoggedIn());
|
||||
const [summary, setSummary] = useState<BenefitSummary | null>(null);
|
||||
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
||||
const [redeemHistory, setRedeemHistory] = useState<RedeemHistoryItem[]>([]);
|
||||
const [tab, setTab] = useState<'available' | 'history'>('available');
|
||||
|
||||
const resetGuestState = useCallback(() => {
|
||||
setSummary(null);
|
||||
setCoupons([]);
|
||||
setRedeemHistory([]);
|
||||
}, []);
|
||||
|
||||
const loadBenefit = useCallback(() => {
|
||||
@@ -58,10 +67,17 @@ export default function BenefitPage() {
|
||||
return Promise.all([
|
||||
request<BenefitSummary>('/benefit/summary'),
|
||||
request<CouponItem[]>('/benefit/coupons'),
|
||||
request<{ list?: RedeemHistoryItem[] } | RedeemHistoryItem[]>('/redeem/records?page=1&pageSize=50'),
|
||||
])
|
||||
.then(([s, list]) => {
|
||||
.then(([s, list, records]) => {
|
||||
setSummary(s);
|
||||
setCoupons(Array.isArray(list) ? list : []);
|
||||
const hist = Array.isArray(records)
|
||||
? records
|
||||
: Array.isArray(records?.list)
|
||||
? records.list
|
||||
: [];
|
||||
setRedeemHistory(hist);
|
||||
})
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, []);
|
||||
@@ -89,8 +105,6 @@ export default function BenefitPage() {
|
||||
});
|
||||
|
||||
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
|
||||
const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID');
|
||||
const visible = tab === 'available' ? available : history;
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() => ({
|
||||
@@ -123,9 +137,6 @@ export default function BenefitPage() {
|
||||
<View className="benefit-header-city-pin" />
|
||||
<Text>郑州市</Text>
|
||||
</View>
|
||||
<View className="benefit-header__share page-nav-bar__right-slot">
|
||||
<ShareNavButton payload={sharePayload} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -180,26 +191,26 @@ export default function BenefitPage() {
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<View className="u-empty">{tab === 'available' ? '暂无可用权益' : '暂无历史记录'}</View>
|
||||
) : (
|
||||
visible.map((c) => (
|
||||
<View key={c.id} className="benefit-coupon">
|
||||
<View className="benefit-coupon-notch" />
|
||||
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
|
||||
<View className="benefit-coupon-head">
|
||||
<Text className="benefit-coupon-name">{c.sourceProduct || '好客权益'}</Text>
|
||||
<Text className="benefit-coupon-balance">¥{formatMoney(c.balance)}</Text>
|
||||
</View>
|
||||
<Text className="benefit-coupon-no">NO. {c.couponNo}</Text>
|
||||
<View className="benefit-progress">
|
||||
<View className="benefit-progress-bar" style={{ width: `${usagePercent(c)}%` }} />
|
||||
</View>
|
||||
<View className="benefit-coupon-footer">
|
||||
<Text className="benefit-coupon-meta">
|
||||
已用 ¥{formatMoney(c.usedAmount)} / 总额 ¥{formatMoney(c.totalAmount)}
|
||||
</Text>
|
||||
{tab === 'available' ? (
|
||||
{tab === 'available' ? (
|
||||
available.length === 0 ? (
|
||||
<View className="u-empty">暂无可用权益</View>
|
||||
) : (
|
||||
available.map((c) => (
|
||||
<View key={c.id} className="benefit-coupon">
|
||||
<View className="benefit-coupon-notch" />
|
||||
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
|
||||
<View className="benefit-coupon-head">
|
||||
<Text className="benefit-coupon-name">{c.sourceProduct || '好客权益'}</Text>
|
||||
<Text className="benefit-coupon-balance">¥{formatMoney(c.balance)}</Text>
|
||||
</View>
|
||||
<Text className="benefit-coupon-no">NO. {c.couponNo}</Text>
|
||||
<View className="benefit-progress">
|
||||
<View className="benefit-progress-bar" style={{ width: `${usagePercent(c)}%` }} />
|
||||
</View>
|
||||
<View className="benefit-coupon-footer">
|
||||
<Text className="benefit-coupon-meta">
|
||||
已用 ¥{formatMoney(c.usedAmount)} / 总额 ¥{formatMoney(c.totalAmount)}
|
||||
</Text>
|
||||
<Text
|
||||
className="benefit-coupon-btn"
|
||||
onClick={() =>
|
||||
@@ -210,7 +221,26 @@ export default function BenefitPage() {
|
||||
>
|
||||
立即核销
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)
|
||||
) : redeemHistory.length === 0 ? (
|
||||
<View className="u-empty">暂无核销记录</View>
|
||||
) : (
|
||||
redeemHistory.map((r) => (
|
||||
<View key={r.id} className="benefit-coupon">
|
||||
<View className="benefit-coupon-notch" />
|
||||
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
|
||||
<View className="benefit-coupon-head">
|
||||
<Text className="benefit-coupon-name">{r.storeName || '门店核销'}</Text>
|
||||
<Text className="benefit-coupon-balance">-¥{formatMoney(Number(r.amount))}</Text>
|
||||
</View>
|
||||
<Text className="benefit-coupon-no">NO. {r.redeemNo}</Text>
|
||||
<View className="benefit-coupon-footer">
|
||||
<Text className="benefit-coupon-meta">
|
||||
{r.createdAt ? String(r.createdAt).slice(0, 19).replace('T', ' ') : '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
|
||||
@@ -221,7 +221,10 @@ export default function MinePage() {
|
||||
|
||||
/** 点击头像:绑定账号后用 chooseAvatar + nickname 获取头像昵称 */
|
||||
async function handleAvatarTap() {
|
||||
if (bindingWx || savingProfile) return;
|
||||
if (bindingWx || savingProfile) {
|
||||
toast(savingProfile ? '资料保存中…' : '请稍候…');
|
||||
return;
|
||||
}
|
||||
if (!isLoggedIn()) {
|
||||
goLogin('/pages/mine/index');
|
||||
return;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { goLogin } from '../../lib/auth-nav';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import { request } from '../../lib/api';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
|
||||
type PreviewProduct = {
|
||||
@@ -75,7 +75,13 @@ export default function OrderConfirmPickupPage() {
|
||||
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
|
||||
|
||||
function updateQuantity(next: number) {
|
||||
if (next < minQty) return;
|
||||
if (next < 1) return;
|
||||
if (next < minQty) {
|
||||
const tip = `现场提货至少购买 ${minQty} 瓶`;
|
||||
toast(tip);
|
||||
setMsg(tip);
|
||||
return;
|
||||
}
|
||||
setQuantity(next);
|
||||
}
|
||||
|
||||
|
||||
@@ -168,6 +168,15 @@ export default function OrderConfirmPage() {
|
||||
|
||||
function updateQuantity(next: number) {
|
||||
if (next < 1) return;
|
||||
if (next < minQty) {
|
||||
const tip = isCross
|
||||
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
|
||||
: `同城配送至少购买 ${minQty} 瓶`;
|
||||
toast(tip);
|
||||
setMsg(tip);
|
||||
setQuantity(Math.max(1, next));
|
||||
return;
|
||||
}
|
||||
setQuantity(next);
|
||||
}
|
||||
|
||||
@@ -211,11 +220,11 @@ export default function OrderConfirmPage() {
|
||||
return;
|
||||
}
|
||||
if (!quantityOk) {
|
||||
setMsg(
|
||||
isCross
|
||||
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
|
||||
: `同城配送至少购买 ${minQty} 瓶`,
|
||||
);
|
||||
const tip = isCross
|
||||
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
|
||||
: `同城配送至少购买 ${minQty} 瓶`;
|
||||
setMsg(tip);
|
||||
toast(tip);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -180,6 +180,14 @@ export default function OrderDetailPage() {
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
<SubPageHeader
|
||||
title="订单详情"
|
||||
onBack={() => {
|
||||
const pages = Taro.getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
Taro.navigateBack();
|
||||
return;
|
||||
}
|
||||
Taro.switchTab({ url: '/pages/home/index' });
|
||||
}}
|
||||
right={<ShareNavButton payload={sharePayload} />}
|
||||
/>
|
||||
<View className="sub-page-body">
|
||||
|
||||
@@ -93,7 +93,10 @@ export default function OrdersPage() {
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="orders-page">
|
||||
<SubPageHeader title="我的订单" />
|
||||
<SubPageHeader
|
||||
title="我的订单"
|
||||
onBack={() => Taro.switchTab({ url: '/pages/home/index' })}
|
||||
/>
|
||||
<View className="order-tabs">
|
||||
{TABS.map((t) => (
|
||||
<Text
|
||||
|
||||
@@ -144,10 +144,10 @@ export default function PayPage() {
|
||||
toast('支付成功', 'success');
|
||||
}
|
||||
if (deliveryType === 'ON_SITE_PICKUP') {
|
||||
// 现场提货支付即完成 → 订单详情(已完成)
|
||||
Taro.redirectTo({ url: `/pages/order-detail/index?id=${orderId}` });
|
||||
// 现场提货支付即完成 → 订单详情(已完成);reLaunch 清掉商品详情栈
|
||||
Taro.reLaunch({ url: `/pages/order-detail/index?id=${orderId}` });
|
||||
} else {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=paid' });
|
||||
Taro.reLaunch({ url: '/pages/orders/index?tab=paid' });
|
||||
}
|
||||
} catch (e) {
|
||||
if (isWechatAuthRequiredError(e)) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
|
||||
const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult';
|
||||
|
||||
@@ -19,8 +20,38 @@ function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RedeemSuccessPage() {
|
||||
const router = useRouter();
|
||||
const [serviceScore, setServiceScore] = useState(5);
|
||||
const [envScore, setEnvScore] = useState(5);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const record = useMemo<RedeemRecord | null>(() => {
|
||||
try {
|
||||
@@ -38,14 +69,49 @@ export default function RedeemSuccessPage() {
|
||||
? new Date(record.createdAt).toLocaleString('zh-CN')
|
||||
: new Date().toLocaleString('zh-CN');
|
||||
|
||||
function clearCache() {
|
||||
try {
|
||||
Taro.removeStorageSync(LAST_REDEEM_RESULT_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function goBenefit() {
|
||||
Taro.removeStorageSync(LAST_REDEEM_RESULT_KEY);
|
||||
clearCache();
|
||||
Taro.switchTab({ url: '/pages/benefit/index' });
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
clearCache();
|
||||
Taro.switchTab({ url: '/pages/home/index' });
|
||||
}
|
||||
|
||||
async function submitRatingAndFinish() {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (record?.id) {
|
||||
await request('/redeem/ratings', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
redeemRecordId: record.id,
|
||||
serviceScore,
|
||||
envScore,
|
||||
},
|
||||
});
|
||||
toast('评价已提交', 'success');
|
||||
}
|
||||
} catch {
|
||||
/* 评价失败不阻塞返回 */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
goBenefit();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="redeem-success-page">
|
||||
<SubPageHeader title="核销成功" />
|
||||
<SubPageHeader title="核销成功" onBack={goBenefit} />
|
||||
<View className="sub-page-body">
|
||||
<View className="redeem-success-icon">
|
||||
<Text>✓</Text>
|
||||
@@ -69,14 +135,21 @@ export default function RedeemSuccessPage() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="redeem-submit" onClick={goBenefit}>
|
||||
<Text>返回权益</Text>
|
||||
<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>
|
||||
|
||||
<View
|
||||
className="u-btn u-btn--ghost u-btn--block"
|
||||
style={{ margin: '0 20px', boxSizing: 'border-box' }}
|
||||
onClick={() => Taro.switchTab({ url: '/pages/home/index' })}
|
||||
className={`redeem-submit${loading ? ' redeem-submit--disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (!loading) void submitRatingAndFinish();
|
||||
}}
|
||||
>
|
||||
<Text>{loading ? '提交中…' : '提交评价并返回'}</Text>
|
||||
</View>
|
||||
<View className="redeem-cancel-btn" onClick={goHome}>
|
||||
<Text>回到首页</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
|
||||
.mine-header {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 16px var(--space-page) 44px;
|
||||
background: linear-gradient(135deg, #820012 0%, var(--color-heritage-red) 40%, #d4a373 100%);
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.mine-header-texture {
|
||||
@@ -33,11 +34,12 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.mine-avatar-wrap {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
@@ -160,7 +162,7 @@
|
||||
.mine-main {
|
||||
margin-top: -28px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
z-index: 1;
|
||||
padding: 0 var(--space-page) 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -378,6 +378,49 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.redeem-success-rating {
|
||||
margin: 16px var(--space-page) 0;
|
||||
padding: 16px;
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.redeem-success-rating-title {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--color-on-surface);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.redeem-rating-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.redeem-rating-label {
|
||||
font-size: 14px;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.redeem-star-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.redeem-star-btn {
|
||||
font-size: 22px;
|
||||
color: #d0d0d0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.redeem-star-btn--active {
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
.ledger-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -55,7 +55,7 @@ export const MINI_USER_STATIC_OSS_BASE =
|
||||
export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualification-disclosure.png`;
|
||||
|
||||
/** 总部客服电话(C 端联系客服) */
|
||||
export const CUSTOMER_SERVICE_PHONE = '400-888-1234';
|
||||
export const CUSTOMER_SERVICE_PHONE = '13203801799';
|
||||
|
||||
/**
|
||||
* 企业微信「微信客服」链接(C 端「在线客服」;微信内网页点击后进入原生客服会话)
|
||||
|
||||
@@ -119,7 +119,29 @@ export class BenefitService {
|
||||
where: benefitLedgerWhere(userId, couponId),
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(list);
|
||||
return serializeBigInt(
|
||||
list.map((e) => {
|
||||
const type = e.param1 || '';
|
||||
const amount = Number(e.amount1 ?? 0);
|
||||
const title =
|
||||
e.remark ||
|
||||
(type === 'REDEEM'
|
||||
? '门店核销'
|
||||
: type === 'GRANT'
|
||||
? '购酒入账'
|
||||
: type === 'REFUND_VOID'
|
||||
? '退款作废'
|
||||
: '权益变动');
|
||||
return {
|
||||
id: e.id,
|
||||
type,
|
||||
amount,
|
||||
title,
|
||||
createdAt: e.createdAt,
|
||||
balanceAfter: e.amount2 != null ? Number(e.amount2) : null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getCoupon(userId: bigint, couponId: bigint) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { AdminStoreRatingsService } from './admin-store-ratings.service';
|
||||
import { AdminStoreRatingsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Controller('admin/store-ratings')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStoreRatingsController {
|
||||
constructor(private readonly service: AdminStoreRatingsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminStoreRatingsQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { AdminStoreRatingsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminStoreRatingsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: AdminStoreRatingsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.StoreRatingWhereInput = {};
|
||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||
if (query.redeemNo) {
|
||||
where.redeemRecord = { redeemNo: { contains: query.redeemNo } };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storeRating.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
store: { select: { id: true, name: true, cityName: true } },
|
||||
redeemRecord: {
|
||||
select: {
|
||||
id: true,
|
||||
redeemNo: true,
|
||||
amount: true,
|
||||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.storeRating.count({ where }),
|
||||
]);
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((r) => ({
|
||||
id: r.id,
|
||||
serviceScore: r.serviceScore,
|
||||
envScore: r.envScore,
|
||||
createdAt: r.createdAt,
|
||||
store: r.store,
|
||||
redeemRecordId: r.redeemRecordId,
|
||||
redeemNo: r.redeemRecord.redeemNo,
|
||||
redeemAmount: Number(r.redeemRecord.amount),
|
||||
user: r.redeemRecord.user,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,16 @@ export class AdminRedeemRecordsQueryDto extends PaginationQueryDto {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class AdminStoreRatingsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redeemNo?: string;
|
||||
}
|
||||
|
||||
export class AdminDeliveriesQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -23,6 +23,8 @@ import { AdminBenefitCouponsController, AdminBenefitLedgersController } from './
|
||||
import { AdminBenefitService } from './admin-benefit.service';
|
||||
import { AdminRedeemRecordsController, AdminDeliveriesController } from './admin-redeem.controller';
|
||||
import { AdminRedeemService, AdminDeliveriesService } from './admin-redeem.service';
|
||||
import { AdminStoreRatingsController } from './admin-store-ratings.controller';
|
||||
import { AdminStoreRatingsService } from './admin-store-ratings.service';
|
||||
import { AdminHqAccountsController } from './admin-hq-accounts.controller';
|
||||
import { AdminHqAccountsService } from './admin-hq-accounts.service';
|
||||
import { AdminProductsController } from './admin-products.controller';
|
||||
@@ -90,6 +92,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
||||
AdminBenefitCouponsController,
|
||||
AdminBenefitLedgersController,
|
||||
AdminRedeemRecordsController,
|
||||
AdminStoreRatingsController,
|
||||
AdminDeliveriesController,
|
||||
AdminHqAccountsController,
|
||||
AdminProductsController,
|
||||
@@ -122,6 +125,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
||||
AdminCitiesService,
|
||||
AdminBenefitService,
|
||||
AdminRedeemService,
|
||||
AdminStoreRatingsService,
|
||||
AdminDeliveriesService,
|
||||
AdminHqAccountsService,
|
||||
AdminProductsService,
|
||||
|
||||
@@ -36,6 +36,19 @@ export class UserRedeemController {
|
||||
rating(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.redeemService.submitRating(user.actorId, body as never);
|
||||
}
|
||||
|
||||
@Get('records')
|
||||
records(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.redeemService.listUserRecords(
|
||||
user.actorId,
|
||||
Number(page),
|
||||
Number(pageSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/redeem')
|
||||
|
||||
@@ -1095,11 +1095,51 @@ export class RedeemService {
|
||||
});
|
||||
}
|
||||
|
||||
async listUserRecords(userId: bigint, page = 1, pageSize = 20) {
|
||||
const take = Math.min(Math.max(pageSize, 1), 50);
|
||||
const skip = (Math.max(page, 1) - 1) * take;
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.redeemRecord.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
store: { select: { id: true, name: true } },
|
||||
rating: { select: { serviceScore: true, envScore: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.redeemRecord.count({ where: { userId } }),
|
||||
]);
|
||||
return {
|
||||
list: serializeBigInt(
|
||||
list.map((r) => ({
|
||||
id: r.id,
|
||||
redeemNo: r.redeemNo,
|
||||
amount: Number(r.amount),
|
||||
storeId: r.storeId,
|
||||
storeName: r.store?.name ?? '门店',
|
||||
createdAt: r.createdAt,
|
||||
rating: r.rating
|
||||
? { serviceScore: r.rating.serviceScore, envScore: r.rating.envScore }
|
||||
: null,
|
||||
})),
|
||||
),
|
||||
total,
|
||||
page: Math.max(page, 1),
|
||||
pageSize: take,
|
||||
};
|
||||
}
|
||||
|
||||
async submitRating(userId: bigint, body: { redeemRecordId: string; serviceScore: number; envScore: number }) {
|
||||
const record = await this.prisma.redeemRecord.findFirst({
|
||||
where: { id: BigInt(body.redeemRecordId), userId },
|
||||
});
|
||||
if (!record) throw new NotFoundException('核销记录不存在');
|
||||
const existing = await this.prisma.storeRating.findUnique({
|
||||
where: { redeemRecordId: record.id },
|
||||
});
|
||||
if (existing) return serializeBigInt(existing);
|
||||
const rating = await this.prisma.storeRating.create({
|
||||
data: {
|
||||
redeemRecordId: record.id,
|
||||
|
||||
Reference in New Issue
Block a user