feat: 积分商城子页面 + 日常监测 + 统计报表 (Chunk 6)
小程序 — 积分商城 (3 新页面): - mall/exchange: 兑换确认 (余额校验/QR码生成) - mall/orders: 我的订单 (状态筛选/分页/QR展示) - mall/detail: 积分明细 (余额卡片/收入支出筛选/流水列表) 小程序 — 上报 Tab 改造: - health/daily-monitoring: 日常监测表单 (血压/体重/血糖/出入量) - health/index: 增加快捷操作/打卡状态/近期监测卡片 - consultation: 替换占位为咨询列表 (会话/状态/未读) - profile: 新增积分余额/打卡天数/我的订单/积分明细入口 小程序 — 新增服务: - services/consultation.ts: 咨询会话 API - services/points.ts: 扩展兑换/订单/流水 API - services/health.ts: 扩展日常监测 API PC 管理端: - StatisticsDashboard: 统计报表仪表盘 (患者/咨询/随访/积分卡片 + Top10排行 + 快速链接) - 侧边栏新增统计报表入口 (健康模块首页)
This commit is contained in:
219
apps/miniprogram/src/pages/mall/detail/index.scss
Normal file
219
apps/miniprogram/src/pages/mall/detail/index.scss
Normal file
@@ -0,0 +1,219 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
.detail-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
/* ===== 余额卡片 ===== */
|
||||
.balance-card {
|
||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
||||
padding: 32px;
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.balance-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.balance-label {
|
||||
font-size: 26px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.balance-value {
|
||||
font-size: 56px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.balance-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: $r;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&.green {
|
||||
color: #A7F3D0;
|
||||
}
|
||||
|
||||
&.orange {
|
||||
color: #FDE68A;
|
||||
}
|
||||
|
||||
&.gray {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 22px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* ===== 类型筛选标签 ===== */
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 20px 24px 0;
|
||||
background: $card;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.type-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
position: relative;
|
||||
|
||||
&.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 48px;
|
||||
height: 4px;
|
||||
background: $pri;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.type-tab-text {
|
||||
font-size: 28px;
|
||||
color: $tx2;
|
||||
|
||||
&.active {
|
||||
color: $pri;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 交易列表 ===== */
|
||||
.transaction-list {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.transaction-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.tx-icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.type-earn {
|
||||
background: $acc-l;
|
||||
}
|
||||
|
||||
&.type-spend {
|
||||
background: $dan-l;
|
||||
}
|
||||
|
||||
&.type-expired {
|
||||
background: $bd-l;
|
||||
}
|
||||
}
|
||||
|
||||
.tx-icon-text {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
|
||||
.type-earn & {
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
.type-spend & {
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
.type-expired & {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.tx-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tx-desc {
|
||||
font-size: 28px;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tx-date {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tx-amount-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
margin-left: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tx-amount {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&.positive {
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&.negative {
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
.tx-remaining {
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
}
|
||||
199
apps/miniprogram/src/pages/mall/detail/index.tsx
Normal file
199
apps/miniprogram/src/pages/mall/detail/index.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, useReachBottom, usePullDownRefresh } from '@tarojs/taro';
|
||||
import { getAccount, listMyTransactions } from '../../../services/points';
|
||||
import type { PointsAccount, PointsTransaction } from '../../../services/points';
|
||||
import EmptyState from '../../../components/EmptyState';
|
||||
import Loading from '../../../components/Loading';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'earn', label: '收入' },
|
||||
{ key: 'spend', label: '支出' },
|
||||
];
|
||||
|
||||
const TYPE_ICONS: Record<string, { icon: string; className: string }> = {
|
||||
earn: { icon: '↑', className: 'type-earn' },
|
||||
spend: { icon: '↓', className: 'type-spend' },
|
||||
expired: { icon: '⏰', className: 'type-expired' },
|
||||
};
|
||||
|
||||
export default function PointsDetail() {
|
||||
const [account, setAccount] = useState<PointsAccount | null>(null);
|
||||
const [transactions, setTransactions] = useState<PointsTransaction[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const fetchAccount = useCallback(async () => {
|
||||
try {
|
||||
const acct = await getAccount();
|
||||
setAccount(acct);
|
||||
} catch {
|
||||
// 账户可能尚未创建
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchTransactions = useCallback(
|
||||
async (pageNum: number, type: string, isRefresh = false) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listMyTransactions({
|
||||
page: pageNum,
|
||||
page_size: 10,
|
||||
});
|
||||
let list = res.data || [];
|
||||
// 前端按类型过滤(后端暂不支持 type 参数)
|
||||
if (type) {
|
||||
list = list.filter((t) => t.type === type);
|
||||
}
|
||||
if (isRefresh) {
|
||||
setTransactions(list);
|
||||
} else {
|
||||
setTransactions((prev) => [...prev, ...list]);
|
||||
}
|
||||
setTotal(res.total);
|
||||
setPage(pageNum);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadAll = useCallback(
|
||||
async (type?: string) => {
|
||||
const t = type !== undefined ? type : activeTab;
|
||||
await Promise.all([fetchAccount(), fetchTransactions(1, t, true)]);
|
||||
},
|
||||
[fetchAccount, fetchTransactions, activeTab],
|
||||
);
|
||||
|
||||
useDidShow(() => {
|
||||
Taro.setNavigationBarTitle({ title: '积分明细' });
|
||||
loadAll();
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
loadAll().finally(() => {
|
||||
Taro.stopPullDownRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && transactions.length < total) {
|
||||
fetchTransactions(page + 1, activeTab);
|
||||
}
|
||||
});
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setActiveTab(key);
|
||||
fetchTransactions(1, key, true);
|
||||
};
|
||||
|
||||
const getTypeConfig = (type: string) => {
|
||||
return TYPE_ICONS[type] || { icon: '?', className: 'type-earn' };
|
||||
};
|
||||
|
||||
const formatAmount = (tx: PointsTransaction) => {
|
||||
const amt = tx.amount;
|
||||
if (tx.type === 'earn') return `+${amt.toLocaleString()}`;
|
||||
if (tx.type === 'spend') return `-${amt.toLocaleString()}`;
|
||||
return `-${amt.toLocaleString()}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const balance = account?.balance ?? 0;
|
||||
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
{/* 余额卡片 */}
|
||||
<View className='balance-card'>
|
||||
<View className='balance-row'>
|
||||
<Text className='balance-label'>当前积分</Text>
|
||||
<Text className='balance-value'>{balance.toLocaleString()}</Text>
|
||||
</View>
|
||||
<View className='balance-stats'>
|
||||
<View className='stat-item'>
|
||||
<Text className='stat-value green'>{(account?.total_earned ?? 0).toLocaleString()}</Text>
|
||||
<Text className='stat-label'>累计获得</Text>
|
||||
</View>
|
||||
<View className='stat-divider' />
|
||||
<View className='stat-item'>
|
||||
<Text className='stat-value orange'>{(account?.total_spent ?? 0).toLocaleString()}</Text>
|
||||
<Text className='stat-label'>累计消费</Text>
|
||||
</View>
|
||||
<View className='stat-divider' />
|
||||
<View className='stat-item'>
|
||||
<Text className='stat-value gray'>{(account?.total_expired ?? 0).toLocaleString()}</Text>
|
||||
<Text className='stat-label'>已过期</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 类型筛选标签 */}
|
||||
<View className='type-tabs'>
|
||||
{TYPE_TABS.map((tab) => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`type-tab ${activeTab === tab.key ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.key)}
|
||||
>
|
||||
<Text
|
||||
className={`type-tab-text ${activeTab === tab.key ? 'active' : ''}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 交易列表 */}
|
||||
{transactions.length === 0 && !loading ? (
|
||||
<EmptyState icon='📊' text='暂无积分记录' hint='签到或兑换后将显示记录' />
|
||||
) : (
|
||||
<View className='transaction-list'>
|
||||
{transactions.map((tx) => {
|
||||
const typeCfg = getTypeConfig(tx.type);
|
||||
return (
|
||||
<View className='transaction-item' key={tx.id}>
|
||||
<View className={`tx-icon ${typeCfg.className}`}>
|
||||
<Text className='tx-icon-text'>{typeCfg.icon}</Text>
|
||||
</View>
|
||||
<View className='tx-info'>
|
||||
<Text className='tx-desc'>
|
||||
{tx.description || (tx.type === 'earn' ? '积分收入' : tx.type === 'spend' ? '积分消费' : '积分过期')}
|
||||
</Text>
|
||||
<Text className='tx-date'>{formatDate(tx.created_at)}</Text>
|
||||
</View>
|
||||
<View className='tx-amount-col'>
|
||||
<Text className={`tx-amount ${tx.type === 'earn' ? 'positive' : 'negative'}`}>
|
||||
{formatAmount(tx)}
|
||||
</Text>
|
||||
<Text className='tx-remaining'>余额 {tx.balance_after.toLocaleString()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{loading && <Loading />}
|
||||
{!loading && transactions.length >= total && total > 0 && (
|
||||
<Loading text='没有更多了' />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
178
apps/miniprogram/src/pages/mall/exchange/index.scss
Normal file
178
apps/miniprogram/src/pages/mall/exchange/index.scss
Normal file
@@ -0,0 +1,178 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
.exchange-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: 140px;
|
||||
}
|
||||
|
||||
/* ===== 商品预览 ===== */
|
||||
.product-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 32px 24px;
|
||||
background: $card;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: $r;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-icon {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-name {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-type {
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ===== 兑换详情 ===== */
|
||||
.exchange-detail {
|
||||
background: $card;
|
||||
padding: 0 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 28px;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 28px;
|
||||
color: $tx;
|
||||
font-weight: bold;
|
||||
|
||||
&.cost {
|
||||
color: $wrn;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
&.sufficient {
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&.insufficient {
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 温馨提示 ===== */
|
||||
.exchange-notice {
|
||||
background: $card;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.notice-text {
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
display: block;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ===== 底部操作栏 ===== */
|
||||
.exchange-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
background: $card;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.footer-cost {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.footer-cost-label {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.footer-cost-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.footer-cost-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.footer-cost-num {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: $pri;
|
||||
padding: 20px 48px;
|
||||
border-radius: $r;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&.disabled {
|
||||
background: $tx3;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-btn-text {
|
||||
font-size: 30px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
207
apps/miniprogram/src/pages/mall/exchange/index.tsx
Normal file
207
apps/miniprogram/src/pages/mall/exchange/index.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import {
|
||||
getAccount,
|
||||
listProducts,
|
||||
exchangeProduct,
|
||||
} from '../../../services/points';
|
||||
import type { PointsAccount, PointsProduct } from '../../../services/points';
|
||||
import Loading from '../../../components/Loading';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
physical: '📦',
|
||||
service: '🎫',
|
||||
privilege: '👑',
|
||||
};
|
||||
|
||||
export default function ExchangeConfirm() {
|
||||
const [product, setProduct] = useState<PointsProduct | null>(null);
|
||||
const [account, setAccount] = useState<PointsAccount | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useDidShow(() => {
|
||||
Taro.setNavigationBarTitle({ title: '确认兑换' });
|
||||
loadData();
|
||||
});
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const instance = Taro.getCurrentInstance();
|
||||
const productId = instance.router?.params?.product_id;
|
||||
if (!productId) {
|
||||
Taro.showToast({ title: '参数错误', icon: 'none' });
|
||||
setTimeout(() => Taro.navigateBack(), 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [productRes, accountRes] = await Promise.all([
|
||||
listProducts({ page: 1, page_size: 100 }),
|
||||
getAccount(),
|
||||
]);
|
||||
const found = productRes.data.find((p) => p.id === productId);
|
||||
if (!found) {
|
||||
Taro.showToast({ title: '商品不存在', icon: 'none' });
|
||||
setTimeout(() => Taro.navigateBack(), 1500);
|
||||
return;
|
||||
}
|
||||
setProduct(found);
|
||||
setAccount(accountRes);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
setTimeout(() => Taro.navigateBack(), 1500);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const balance = account?.balance ?? 0;
|
||||
const cost = product?.points_cost ?? 0;
|
||||
const insufficient = balance < cost;
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!product || submitting) return;
|
||||
|
||||
if (insufficient) {
|
||||
Taro.showToast({ title: '积分不足', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const modalRes = await Taro.showModal({
|
||||
title: '确认兑换',
|
||||
content: `确定花费 ${cost} 积分兑换「${product.name}」吗?`,
|
||||
});
|
||||
if (!modalRes.confirm) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const order = await exchangeProduct(product.id);
|
||||
Taro.showToast({ title: '兑换成功', icon: 'success', duration: 2000 });
|
||||
|
||||
// 展示核销码弹窗
|
||||
setTimeout(() => {
|
||||
Taro.showModal({
|
||||
title: '兑换成功',
|
||||
content: `核销码: ${order.qr_code}\n请凭此码到前台核销`,
|
||||
showCancel: false,
|
||||
confirmText: '查看订单',
|
||||
success: () => {
|
||||
Taro.navigateTo({
|
||||
url: `/pages/mall/orders/index`,
|
||||
});
|
||||
},
|
||||
});
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '兑换失败';
|
||||
if (msg.includes('余额不足') || msg.includes('insufficient')) {
|
||||
Taro.showToast({ title: '积分不足', icon: 'none' });
|
||||
} else {
|
||||
Taro.showToast({ title: msg, icon: 'none' });
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [product, submitting, insufficient, cost]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className='exchange-page'>
|
||||
<Loading />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='exchange-page'>
|
||||
{/* 商品信息卡片 */}
|
||||
<View className='product-preview'>
|
||||
<View
|
||||
className='preview-image'
|
||||
style={{ backgroundColor: '#0891B2' }}
|
||||
>
|
||||
<Text className='preview-icon'>
|
||||
{product ? TYPE_ICONS[product.product_type] || '🎁' : '🎁'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='preview-info'>
|
||||
<Text className='preview-name'>{product?.name || ''}</Text>
|
||||
<Text className='preview-type'>
|
||||
{product?.product_type === 'physical'
|
||||
? '实物商品'
|
||||
: product?.product_type === 'service'
|
||||
? '服务券'
|
||||
: '权益卡'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 兑换详情 */}
|
||||
<View className='exchange-detail'>
|
||||
<View className='detail-row'>
|
||||
<Text className='detail-label'>所需积分</Text>
|
||||
<Text className='detail-value cost'>{cost.toLocaleString()}</Text>
|
||||
</View>
|
||||
<View className='detail-row'>
|
||||
<Text className='detail-label'>当前余额</Text>
|
||||
<Text
|
||||
className={`detail-value ${insufficient ? 'insufficient' : 'sufficient'}`}
|
||||
>
|
||||
{balance.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
{insufficient && (
|
||||
<View className='detail-row'>
|
||||
<Text className='detail-label'>差额</Text>
|
||||
<Text className='detail-value insufficient'>
|
||||
-{(cost - balance).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='detail-row'>
|
||||
<Text className='detail-label'>库存</Text>
|
||||
<Text className='detail-value'>
|
||||
{product && product.stock > 0 ? `剩余 ${product.stock} 件` : '已兑完'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 温馨提示 */}
|
||||
<View className='exchange-notice'>
|
||||
<Text className='notice-title'>温馨提示</Text>
|
||||
<Text className='notice-text'>
|
||||
兑换成功后将生成核销码,请凭核销码到前台核销领取。
|
||||
</Text>
|
||||
<Text className='notice-text'>积分一经兑换不可退回。</Text>
|
||||
</View>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<View className='exchange-footer'>
|
||||
<View className='footer-cost'>
|
||||
<Text className='footer-cost-label'>合计</Text>
|
||||
<View className='footer-cost-value'>
|
||||
<Text className='footer-cost-icon'>🪙</Text>
|
||||
<Text className='footer-cost-num'>{cost.toLocaleString()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
className={`confirm-btn ${insufficient || (product?.stock ?? 0) <= 0 || submitting ? 'disabled' : ''}`}
|
||||
onClick={insufficient || (product?.stock ?? 0) <= 0 || submitting ? undefined : handleConfirm}
|
||||
>
|
||||
<Text className='confirm-btn-text'>
|
||||
{submitting
|
||||
? '兑换中...'
|
||||
: insufficient
|
||||
? '积分不足'
|
||||
: (product?.stock ?? 0) <= 0
|
||||
? '已兑完'
|
||||
: '确认兑换'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -132,6 +132,14 @@ export default function Mall() {
|
||||
fetchProducts(1, key, true);
|
||||
};
|
||||
|
||||
const handleProductClick = (item: PointsProduct) => {
|
||||
if (item.stock <= 0) {
|
||||
Taro.showToast({ title: '已兑完', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
Taro.navigateTo({ url: `/pages/mall/exchange/index?product_id=${item.id}` });
|
||||
};
|
||||
|
||||
const balance = account?.balance ?? 0;
|
||||
|
||||
return (
|
||||
@@ -194,7 +202,7 @@ export default function Mall() {
|
||||
) : (
|
||||
<View className='product-grid'>
|
||||
{products.map((item) => (
|
||||
<View className='product-card' key={item.id}>
|
||||
<View className='product-card' key={item.id} onClick={() => handleProductClick(item)}>
|
||||
<View
|
||||
className='product-image'
|
||||
style={{ backgroundColor: TYPE_COLORS[item.product_type] || '#94A3B8' }}
|
||||
|
||||
178
apps/miniprogram/src/pages/mall/orders/index.scss
Normal file
178
apps/miniprogram/src/pages/mall/orders/index.scss
Normal file
@@ -0,0 +1,178 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
.orders-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
/* ===== 状态筛选标签 ===== */
|
||||
.status-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 20px 24px 0;
|
||||
background: $card;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
position: relative;
|
||||
|
||||
&.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 48px;
|
||||
height: 4px;
|
||||
background: $pri;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-tab-text {
|
||||
font-size: 28px;
|
||||
color: $tx2;
|
||||
|
||||
&.active {
|
||||
color: $pri;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 订单列表 ===== */
|
||||
.order-list {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 24px 16px;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
}
|
||||
|
||||
.order-product {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.order-status {
|
||||
padding: 4px 16px;
|
||||
border-radius: 20px;
|
||||
margin-left: 12px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.status-pending {
|
||||
background: $wrn-l;
|
||||
|
||||
.order-status-text {
|
||||
color: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
&.status-verified {
|
||||
background: $acc-l;
|
||||
|
||||
.order-status-text {
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
&.status-cancelled {
|
||||
background: $dan-l;
|
||||
|
||||
.order-status-text {
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
&.status-expired {
|
||||
background: $bd-l;
|
||||
|
||||
.order-status-text {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.order-status-text {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.order-body {
|
||||
padding: 16px 24px 20px;
|
||||
}
|
||||
|
||||
.order-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.order-row-label {
|
||||
font-size: 26px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.order-row-value {
|
||||
font-size: 26px;
|
||||
color: $tx;
|
||||
|
||||
&.cost {
|
||||
color: $wrn;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 核销码 ===== */
|
||||
.order-qrcode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
margin-top: 12px;
|
||||
background: $pri-l;
|
||||
border-radius: $r-sm;
|
||||
}
|
||||
|
||||
.qrcode-label {
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.qrcode-value {
|
||||
font-size: 24px;
|
||||
color: $pri;
|
||||
font-weight: bold;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.qrcode-tap {
|
||||
font-size: 22px;
|
||||
color: $pri;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
186
apps/miniprogram/src/pages/mall/orders/index.tsx
Normal file
186
apps/miniprogram/src/pages/mall/orders/index.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, useReachBottom, usePullDownRefresh } from '@tarojs/taro';
|
||||
import { listMyOrders } from '../../../services/points';
|
||||
import type { PointsOrder } from '../../../services/points';
|
||||
import EmptyState from '../../../components/EmptyState';
|
||||
import Loading from '../../../components/Loading';
|
||||
import './index.scss';
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待核销' },
|
||||
{ key: 'verified', label: '已核销' },
|
||||
{ key: 'expired', label: '已过期' },
|
||||
];
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
|
||||
pending: { label: '待核销', className: 'status-pending' },
|
||||
verified: { label: '已核销', className: 'status-verified' },
|
||||
cancelled: { label: '已取消', className: 'status-cancelled' },
|
||||
expired: { label: '已过期', className: 'status-expired' },
|
||||
};
|
||||
|
||||
export default function MallOrders() {
|
||||
const [orders, setOrders] = useState<PointsOrder[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const fetchOrders = useCallback(
|
||||
async (pageNum: number, status: string, isRefresh = false) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listMyOrders({
|
||||
page: pageNum,
|
||||
page_size: 10,
|
||||
});
|
||||
let list = res.data || [];
|
||||
// 前端按状态过滤(后端暂不支持 status 参数)
|
||||
if (status) {
|
||||
list = list.filter((o) => o.status === status);
|
||||
}
|
||||
if (isRefresh) {
|
||||
setOrders(list);
|
||||
} else {
|
||||
setOrders((prev) => [...prev, ...list]);
|
||||
}
|
||||
setTotal(res.total);
|
||||
setPage(pageNum);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadAll = useCallback(
|
||||
async (status?: string) => {
|
||||
const s = status !== undefined ? status : activeTab;
|
||||
await fetchOrders(1, s, true);
|
||||
},
|
||||
[fetchOrders, activeTab],
|
||||
);
|
||||
|
||||
useDidShow(() => {
|
||||
Taro.setNavigationBarTitle({ title: '我的订单' });
|
||||
loadAll();
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
loadAll().finally(() => {
|
||||
Taro.stopPullDownRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && orders.length < total) {
|
||||
fetchOrders(page + 1, activeTab);
|
||||
}
|
||||
});
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setActiveTab(key);
|
||||
fetchOrders(1, key, true);
|
||||
};
|
||||
|
||||
const handleShowQrCode = (qrCode: string) => {
|
||||
Taro.showModal({
|
||||
title: '核销码',
|
||||
content: qrCode,
|
||||
showCancel: false,
|
||||
confirmText: '知道了',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
return STATUS_CONFIG[status] || { label: status, className: 'status-pending' };
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='orders-page'>
|
||||
{/* 状态筛选标签 */}
|
||||
<View className='status-tabs'>
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`status-tab ${activeTab === tab.key ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.key)}
|
||||
>
|
||||
<Text
|
||||
className={`status-tab-text ${activeTab === tab.key ? 'active' : ''}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 订单列表 */}
|
||||
{orders.length === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon='📋'
|
||||
text='暂无订单'
|
||||
hint='去商城兑换心仪商品吧'
|
||||
actionText='去商城'
|
||||
onAction={() => Taro.switchTab({ url: '/pages/mall/index' })}
|
||||
/>
|
||||
) : (
|
||||
<View className='order-list'>
|
||||
{orders.map((order) => {
|
||||
const statusCfg = getStatusConfig(order.status);
|
||||
return (
|
||||
<View className='order-card' key={order.id}>
|
||||
<View className='order-header'>
|
||||
<Text className='order-product'>商品 {order.product_id.slice(0, 8)}</Text>
|
||||
<View className={`order-status ${statusCfg.className}`}>
|
||||
<Text className='order-status-text'>{statusCfg.label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='order-body'>
|
||||
<View className='order-row'>
|
||||
<Text className='order-row-label'>消耗积分</Text>
|
||||
<Text className='order-row-value cost'>
|
||||
🪙 {order.points_cost.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='order-row'>
|
||||
<Text className='order-row-label'>兑换时间</Text>
|
||||
<Text className='order-row-value'>
|
||||
{formatDate(order.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
{order.status === 'pending' && (
|
||||
<View className='order-qrcode' onClick={() => handleShowQrCode(order.qr_code)}>
|
||||
<Text className='qrcode-label'>核销码: </Text>
|
||||
<Text className='qrcode-value'>{order.qr_code}</Text>
|
||||
<Text className='qrcode-tap'>点击查看</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{loading && <Loading />}
|
||||
{!loading && orders.length >= total && total > 0 && (
|
||||
<Loading text='没有更多了' />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user