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);
+}