feat(miniprogram): TabBar 重构 + 积分商城页面 (Chunk 5)
TabBar: 首页|健康|预约|资讯|我的 → 首页|上报|咨询|商城|我的 新增页面: - 商城(mall): 积分余额卡片 + 签到 + 商品网格(分类型筛选/分页) - 咨询(consultation): 占位页(即将上线) 新增服务: - services/points.ts: 积分账户/签到/商品列表 API API: getAccount, dailyCheckin, getCheckinStatus, listProducts
This commit is contained in:
@@ -12,6 +12,8 @@ export default defineAppConfig({
|
||||
'pages/article/detail/index',
|
||||
'pages/report/detail/index',
|
||||
'pages/followup/detail/index',
|
||||
'pages/consultation/index',
|
||||
'pages/mall/index',
|
||||
'pages/profile/index',
|
||||
'pages/profile/family/index',
|
||||
'pages/profile/family-add/index',
|
||||
@@ -29,9 +31,9 @@ export default defineAppConfig({
|
||||
borderStyle: 'white',
|
||||
list: [
|
||||
{ pagePath: 'pages/index/index', text: '首页', iconPath: 'assets/tabbar/home.png', selectedIconPath: 'assets/tabbar/home-active.png' },
|
||||
{ pagePath: 'pages/health/index', text: '健康', iconPath: 'assets/tabbar/health.png', selectedIconPath: 'assets/tabbar/health-active.png' },
|
||||
{ pagePath: 'pages/appointment/index', text: '预约', iconPath: 'assets/tabbar/appointment.png', selectedIconPath: 'assets/tabbar/appointment-active.png' },
|
||||
{ pagePath: 'pages/article/index', text: '资讯', iconPath: 'assets/tabbar/article.png', selectedIconPath: 'assets/tabbar/article-active.png' },
|
||||
{ pagePath: 'pages/health/index', text: '上报', iconPath: 'assets/tabbar/health.png', selectedIconPath: 'assets/tabbar/health-active.png' },
|
||||
{ pagePath: 'pages/consultation/index', text: '咨询', iconPath: 'assets/tabbar/appointment.png', selectedIconPath: 'assets/tabbar/appointment-active.png' },
|
||||
{ pagePath: 'pages/mall/index', text: '商城', iconPath: 'assets/tabbar/article.png', selectedIconPath: 'assets/tabbar/article-active.png' },
|
||||
{ pagePath: 'pages/profile/index', text: '我的', iconPath: 'assets/tabbar/profile.png', selectedIconPath: 'assets/tabbar/profile-active.png' },
|
||||
],
|
||||
},
|
||||
|
||||
53
apps/miniprogram/src/pages/consultation/index.scss
Normal file
53
apps/miniprogram/src/pages/consultation/index.scss
Normal file
@@ -0,0 +1,53 @@
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.consultation-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.consultation-header {
|
||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
||||
padding: 32px;
|
||||
padding-top: 48px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.consultation-header-title {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.consultation-header-desc {
|
||||
font-size: 24px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.consultation-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 160px 40px;
|
||||
}
|
||||
|
||||
.consultation-placeholder-icon {
|
||||
font-size: 100px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.consultation-placeholder-text {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.consultation-placeholder-hint {
|
||||
font-size: 26px;
|
||||
color: $tx3;
|
||||
text-align: center;
|
||||
}
|
||||
24
apps/miniprogram/src/pages/consultation/index.tsx
Normal file
24
apps/miniprogram/src/pages/consultation/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import './index.scss';
|
||||
|
||||
export default function Consultation() {
|
||||
useDidShow(() => {
|
||||
Taro.setNavigationBarTitle({ title: '在线咨询' });
|
||||
});
|
||||
|
||||
return (
|
||||
<View className='consultation-page'>
|
||||
<View className='consultation-header'>
|
||||
<Text className='consultation-header-title'>在线咨询</Text>
|
||||
<Text className='consultation-header-desc'>随时随地,连接专业医生</Text>
|
||||
</View>
|
||||
|
||||
<View className='consultation-placeholder'>
|
||||
<Text className='consultation-placeholder-icon'>💬</Text>
|
||||
<Text className='consultation-placeholder-text'>即将上线</Text>
|
||||
<Text className='consultation-placeholder-hint'>在线咨询功能正在开发中,敬请期待</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
188
apps/miniprogram/src/pages/mall/index.scss
Normal file
188
apps/miniprogram/src/pages/mall/index.scss
Normal file
@@ -0,0 +1,188 @@
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.mall-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
/* ===== 积分余额卡片 ===== */
|
||||
.mall-header {
|
||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
||||
padding: 32px;
|
||||
padding-top: 48px;
|
||||
}
|
||||
|
||||
.points-card {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: $r-lg;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.points-card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: 26px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.checkin-btn {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
padding: 10px 28px;
|
||||
border-radius: 32px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.checked {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.checkin-btn-text {
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.checkin-btn.checked .checkin-btn-text {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.points-balance {
|
||||
font-size: 72px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.points-streak {
|
||||
font-size: 22px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ===== 商品类型切换 ===== */
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 20px 24px 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 商品网格 ===== */
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.product-image-icon {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 26px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.product-points {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.product-points-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.product-points-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
.product-stock {
|
||||
font-size: 20px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 8px;
|
||||
|
||||
&.out {
|
||||
color: $tx3;
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&.low {
|
||||
color: $dan;
|
||||
background: $dan-l;
|
||||
}
|
||||
}
|
||||
236
apps/miniprogram/src/pages/mall/index.tsx
Normal file
236
apps/miniprogram/src/pages/mall/index.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, useReachBottom, usePullDownRefresh } from '@tarojs/taro';
|
||||
import {
|
||||
getAccount,
|
||||
dailyCheckin,
|
||||
getCheckinStatus,
|
||||
listProducts,
|
||||
} from '../../services/points';
|
||||
import type { PointsAccount, PointsProduct, CheckinStatus } from '../../services/points';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import Loading from '../../components/Loading';
|
||||
import './index.scss';
|
||||
|
||||
const PRODUCT_TYPE_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'physical', label: '实物' },
|
||||
{ key: 'service', label: '服务券' },
|
||||
{ key: 'privilege', label: '权益' },
|
||||
];
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
physical: '#0891B2',
|
||||
service: '#059669',
|
||||
privilege: '#D97706',
|
||||
};
|
||||
|
||||
export default function Mall() {
|
||||
const [account, setAccount] = useState<PointsAccount | null>(null);
|
||||
const [checkinStatus, setCheckinStatus] = useState<CheckinStatus | null>(null);
|
||||
const [products, setProducts] = useState<PointsProduct[]>([]);
|
||||
const [productType, setProductType] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checkinLoading, setCheckinLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const fetchAccountAndCheckin = useCallback(async () => {
|
||||
try {
|
||||
const [acct, status] = await Promise.all([
|
||||
getAccount(),
|
||||
getCheckinStatus(),
|
||||
]);
|
||||
setAccount(acct);
|
||||
setCheckinStatus(status);
|
||||
} catch {
|
||||
// 账户可能尚未创建,静默处理
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchProducts = useCallback(
|
||||
async (pageNum: number, type: string, isRefresh = false) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listProducts({
|
||||
page: pageNum,
|
||||
page_size: 10,
|
||||
product_type: type || undefined,
|
||||
});
|
||||
const list = res.data || [];
|
||||
if (isRefresh) {
|
||||
setProducts(list);
|
||||
} else {
|
||||
setProducts((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 : productType;
|
||||
await Promise.all([fetchAccountAndCheckin(), fetchProducts(1, t, true)]);
|
||||
},
|
||||
[fetchAccountAndCheckin, fetchProducts, productType],
|
||||
);
|
||||
|
||||
useDidShow(() => {
|
||||
Taro.setNavigationBarTitle({ title: '积分商城' });
|
||||
loadAll();
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
loadAll().finally(() => {
|
||||
Taro.stopPullDownRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && products.length < total) {
|
||||
fetchProducts(page + 1, productType);
|
||||
}
|
||||
});
|
||||
|
||||
const handleCheckin = async () => {
|
||||
if (checkinLoading || checkinStatus?.checked_in_today) return;
|
||||
setCheckinLoading(true);
|
||||
try {
|
||||
const result = await dailyCheckin();
|
||||
setCheckinStatus(result);
|
||||
// 刷新积分余额
|
||||
const acct = await getAccount();
|
||||
setAccount(acct);
|
||||
Taro.showToast({
|
||||
title: '签到成功',
|
||||
icon: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
} catch (err) {
|
||||
Taro.showToast({
|
||||
title: err instanceof Error ? err.message : '签到失败',
|
||||
icon: 'none',
|
||||
});
|
||||
} finally {
|
||||
setCheckinLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setProductType(key);
|
||||
fetchProducts(1, key, true);
|
||||
};
|
||||
|
||||
const balance = account?.balance ?? 0;
|
||||
|
||||
return (
|
||||
<View className='mall-page'>
|
||||
{/* 积分余额卡片 */}
|
||||
<View className='mall-header'>
|
||||
<View className='points-card'>
|
||||
<View className='points-card-top'>
|
||||
<Text className='points-label'>当前积分</Text>
|
||||
<View
|
||||
className={`checkin-btn ${
|
||||
checkinStatus?.checked_in_today ? 'checked' : ''
|
||||
}`}
|
||||
onClick={handleCheckin}
|
||||
>
|
||||
<Text className='checkin-btn-text'>
|
||||
{checkinLoading
|
||||
? '...'
|
||||
: checkinStatus?.checked_in_today
|
||||
? '已签到'
|
||||
: '签到'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='points-balance'>{balance.toLocaleString()}</Text>
|
||||
{checkinStatus && checkinStatus.consecutive_days > 0 && (
|
||||
<Text className='points-streak'>
|
||||
已连续签到 {checkinStatus.consecutive_days} 天
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 商品类型切换 */}
|
||||
<View className='type-tabs'>
|
||||
{PRODUCT_TYPE_TABS.map((tab) => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`type-tab ${productType === tab.key ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.key)}
|
||||
>
|
||||
<Text
|
||||
className={`type-tab-text ${
|
||||
productType === tab.key ? 'active' : ''
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 商品列表 */}
|
||||
{products.length === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon='🎁'
|
||||
text='暂无商品'
|
||||
hint='更多好物即将上架'
|
||||
/>
|
||||
) : (
|
||||
<View className='product-grid'>
|
||||
{products.map((item) => (
|
||||
<View className='product-card' key={item.id}>
|
||||
<View
|
||||
className='product-image'
|
||||
style={{ backgroundColor: TYPE_COLORS[item.product_type] || '#94A3B8' }}
|
||||
>
|
||||
<Text className='product-image-icon'>
|
||||
{item.product_type === 'physical'
|
||||
? '📦'
|
||||
: item.product_type === 'service'
|
||||
? '🎫'
|
||||
: '👑'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='product-info'>
|
||||
<Text className='product-name'>{item.name}</Text>
|
||||
<View className='product-bottom'>
|
||||
<View className='product-points'>
|
||||
<Text className='product-points-icon'>🪙</Text>
|
||||
<Text className='product-points-value'>
|
||||
{item.points_cost}
|
||||
</Text>
|
||||
</View>
|
||||
{item.stock <= 0 ? (
|
||||
<Text className='product-stock out'>已兑完</Text>
|
||||
) : item.stock <= 10 ? (
|
||||
<Text className='product-stock low'>仅剩{item.stock}件</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{loading && <Loading />}
|
||||
{!loading && products.length >= total && total > 0 && (
|
||||
<Loading text='没有更多了' />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
57
apps/miniprogram/src/services/points.ts
Normal file
57
apps/miniprogram/src/services/points.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { api } from './request';
|
||||
|
||||
export interface PointsAccount {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
balance: number;
|
||||
total_earned: number;
|
||||
total_spent: number;
|
||||
total_expired: number;
|
||||
}
|
||||
|
||||
export interface PointsProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
product_type: string; // physical / service / privilege
|
||||
points_cost: number;
|
||||
stock: number;
|
||||
image_url: string | null;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CheckinStatus {
|
||||
checked_in_today: boolean;
|
||||
consecutive_days: number;
|
||||
next_streak_milestone: number | null;
|
||||
}
|
||||
|
||||
export interface ProductListResponse {
|
||||
data: PointsProduct[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export async function getAccount() {
|
||||
return api.get<PointsAccount>('/health/points/account');
|
||||
}
|
||||
|
||||
export async function dailyCheckin() {
|
||||
return api.post<CheckinStatus>('/health/points/checkin');
|
||||
}
|
||||
|
||||
export async function getCheckinStatus() {
|
||||
return api.get<CheckinStatus>('/health/points/checkin/status');
|
||||
}
|
||||
|
||||
export async function listProducts(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
product_type?: string;
|
||||
}) {
|
||||
return api.get<ProductListResponse>('/health/points/products', params);
|
||||
}
|
||||
Reference in New Issue
Block a user