From 1507ec6036cfd02200202756c7f88b0ae86fc47c Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 25 Apr 2026 17:44:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(miniprogram):=20TabBar=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20+=20=E7=A7=AF=E5=88=86=E5=95=86=E5=9F=8E=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=20(Chunk=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TabBar: 首页|健康|预约|资讯|我的 → 首页|上报|咨询|商城|我的 新增页面: - 商城(mall): 积分余额卡片 + 签到 + 商品网格(分类型筛选/分页) - 咨询(consultation): 占位页(即将上线) 新增服务: - services/points.ts: 积分账户/签到/商品列表 API API: getAccount, dailyCheckin, getCheckinStatus, listProducts --- apps/miniprogram/src/app.config.ts | 8 +- .../src/pages/consultation/index.scss | 53 ++++ .../src/pages/consultation/index.tsx | 24 ++ apps/miniprogram/src/pages/mall/index.scss | 188 ++++++++++++++ apps/miniprogram/src/pages/mall/index.tsx | 236 ++++++++++++++++++ apps/miniprogram/src/services/points.ts | 57 +++++ 6 files changed, 563 insertions(+), 3 deletions(-) create mode 100644 apps/miniprogram/src/pages/consultation/index.scss create mode 100644 apps/miniprogram/src/pages/consultation/index.tsx create mode 100644 apps/miniprogram/src/pages/mall/index.scss create mode 100644 apps/miniprogram/src/pages/mall/index.tsx create mode 100644 apps/miniprogram/src/services/points.ts diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index a9fa819..7433d87 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -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' }, ], }, diff --git a/apps/miniprogram/src/pages/consultation/index.scss b/apps/miniprogram/src/pages/consultation/index.scss new file mode 100644 index 0000000..807818f --- /dev/null +++ b/apps/miniprogram/src/pages/consultation/index.scss @@ -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; +} diff --git a/apps/miniprogram/src/pages/consultation/index.tsx b/apps/miniprogram/src/pages/consultation/index.tsx new file mode 100644 index 0000000..1d8b18e --- /dev/null +++ b/apps/miniprogram/src/pages/consultation/index.tsx @@ -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 ( + + + 在线咨询 + 随时随地,连接专业医生 + + + + 💬 + 即将上线 + 在线咨询功能正在开发中,敬请期待 + + + ); +} diff --git a/apps/miniprogram/src/pages/mall/index.scss b/apps/miniprogram/src/pages/mall/index.scss new file mode 100644 index 0000000..57566f8 --- /dev/null +++ b/apps/miniprogram/src/pages/mall/index.scss @@ -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; + } +} diff --git a/apps/miniprogram/src/pages/mall/index.tsx b/apps/miniprogram/src/pages/mall/index.tsx new file mode 100644 index 0000000..fa27f5f --- /dev/null +++ b/apps/miniprogram/src/pages/mall/index.tsx @@ -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 = { + physical: '#0891B2', + service: '#059669', + privilege: '#D97706', +}; + +export default function Mall() { + const [account, setAccount] = useState(null); + const [checkinStatus, setCheckinStatus] = useState(null); + const [products, setProducts] = useState([]); + 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 ( + + {/* 积分余额卡片 */} + + + + 当前积分 + + + {checkinLoading + ? '...' + : checkinStatus?.checked_in_today + ? '已签到' + : '签到'} + + + + {balance.toLocaleString()} + {checkinStatus && checkinStatus.consecutive_days > 0 && ( + + 已连续签到 {checkinStatus.consecutive_days} 天 + + )} + + + + {/* 商品类型切换 */} + + {PRODUCT_TYPE_TABS.map((tab) => ( + handleTabChange(tab.key)} + > + + {tab.label} + + + ))} + + + {/* 商品列表 */} + {products.length === 0 && !loading ? ( + + ) : ( + + {products.map((item) => ( + + + + {item.product_type === 'physical' + ? '📦' + : item.product_type === 'service' + ? '🎫' + : '👑'} + + + + {item.name} + + + 🪙 + + {item.points_cost} + + + {item.stock <= 0 ? ( + 已兑完 + ) : item.stock <= 10 ? ( + 仅剩{item.stock}件 + ) : null} + + + + ))} + {loading && } + {!loading && products.length >= total && total > 0 && ( + + )} + + )} + + ); +} diff --git a/apps/miniprogram/src/services/points.ts b/apps/miniprogram/src/services/points.ts new file mode 100644 index 0000000..7c882c3 --- /dev/null +++ b/apps/miniprogram/src/services/points.ts @@ -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('/health/points/account'); +} + +export async function dailyCheckin() { + return api.post('/health/points/checkin'); +} + +export async function getCheckinStatus() { + return api.get('/health/points/checkin/status'); +} + +export async function listProducts(params?: { + page?: number; + page_size?: number; + product_type?: string; +}) { + return api.get('/health/points/products', params); +}