feat: 积分商城子页面 + 日常监测 + 统计报表 (Chunk 6)
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

小程序 — 积分商城 (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:
iven
2026-04-25 19:17:11 +08:00
parent 1507ec6036
commit 280f65658a
23 changed files with 2819 additions and 11 deletions

View File

@@ -5,6 +5,7 @@ export default defineAppConfig({
'pages/health/index',
'pages/health/input/index',
'pages/health/trend/index',
'pages/health/daily-monitoring/index',
'pages/appointment/index',
'pages/appointment/create/index',
'pages/appointment/detail/index',
@@ -14,6 +15,9 @@ export default defineAppConfig({
'pages/followup/detail/index',
'pages/consultation/index',
'pages/mall/index',
'pages/mall/exchange/index',
'pages/mall/orders/index',
'pages/mall/detail/index',
'pages/profile/index',
'pages/profile/family/index',
'pages/profile/family-add/index',

View File

@@ -26,7 +26,24 @@
display: block;
}
.consultation-placeholder {
// ---- Loading / Error / Empty ----
.consultation-loading {
padding: 120px 0;
}
.consultation-error {
display: flex;
justify-content: center;
padding: 120px 40px;
}
.consultation-error-text {
font-size: 26px;
color: $dan;
}
.consultation-empty {
display: flex;
flex-direction: column;
align-items: center;
@@ -34,20 +51,121 @@
padding: 160px 40px;
}
.consultation-placeholder-icon {
.consultation-empty-icon {
font-size: 100px;
margin-bottom: 32px;
}
.consultation-placeholder-text {
.consultation-empty-text {
font-size: 36px;
font-weight: bold;
color: $tx;
margin-bottom: 16px;
}
.consultation-placeholder-hint {
.consultation-empty-hint {
font-size: 26px;
color: $tx3;
text-align: center;
}
// ---- Session List ----
.consultation-list {
padding: 16px 24px;
}
.consultation-session {
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);
transition: transform 0.15s;
&:active {
transform: scale(0.98);
}
}
.session-left {
flex: 1;
min-width: 0;
}
.session-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.session-subject {
font-size: 28px;
color: $tx;
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: 12px;
}
.session-status-active {
font-size: 22px;
color: $acc;
font-weight: 500;
white-space: nowrap;
}
.session-status-pending {
font-size: 22px;
color: $wrn;
font-weight: 500;
white-space: nowrap;
}
.session-status-closed {
font-size: 22px;
color: $tx3;
white-space: nowrap;
}
.session-message {
font-size: 26px;
color: $tx2;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 8px;
}
.session-time {
font-size: 22px;
color: $tx3;
display: block;
}
// ---- Unread Badge ----
.session-badge {
background: $dan;
border-radius: 999px;
min-width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
margin-left: 12px;
flex-shrink: 0;
}
.session-badge-text {
font-size: 22px;
color: white;
font-weight: bold;
}

View File

@@ -1,12 +1,79 @@
import { useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import { listConsultations, ConsultationSession } from '@/services/consultation';
import Loading from '../../components/Loading';
import './index.scss';
function getStatusLabel(status: string): string {
const map: Record<string, string> = {
pending: '等待接诊',
active: '进行中',
closed: '已结束',
cancelled: '已取消',
};
return map[status] || status;
}
function getStatusClass(status: string): string {
if (status === 'active') return 'session-status-active';
if (status === 'pending') return 'session-status-pending';
return 'session-status-closed';
}
function formatTime(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return '刚刚';
if (diffMin < 60) return `${diffMin}分钟前`;
const diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) return `${diffHour}小时前`;
const diffDay = Math.floor(diffHour / 24);
if (diffDay < 7) return `${diffDay}天前`;
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${m}-${day}`;
}
export default function Consultation() {
const [sessions, setSessions] = useState<ConsultationSession[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const loadSessions = async () => {
setLoading(true);
setError('');
try {
const resp = await listConsultations({ page: 1, page_size: 20 });
setSessions(resp.data || []);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '加载失败';
setError(msg);
} finally {
setLoading(false);
}
};
useDidShow(() => {
Taro.setNavigationBarTitle({ title: '在线咨询' });
loadSessions();
});
usePullDownRefresh(() => {
loadSessions().finally(() => {
Taro.stopPullDownRefresh();
});
});
const handleTapSession = (_session: ConsultationSession) => {
Taro.showToast({ title: '即将上线', icon: 'none' });
};
return (
<View className='consultation-page'>
<View className='consultation-header'>
@@ -14,11 +81,57 @@ export default function Consultation() {
<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>
{loading ? (
<View className='consultation-loading'>
<Loading text='加载中...' />
</View>
) : error ? (
<View className='consultation-error'>
<Text className='consultation-error-text'>{error}</Text>
</View>
) : sessions.length === 0 ? (
<View className='consultation-empty'>
<Text className='consultation-empty-icon'>💬</Text>
<Text className='consultation-empty-text'></Text>
<Text className='consultation-empty-hint'>线线</Text>
</View>
) : (
<View className='consultation-list'>
{sessions.map((session) => (
<View
key={session.id}
className='consultation-session'
onClick={() => handleTapSession(session)}
>
<View className='session-left'>
<View className='session-top'>
<Text className='session-subject'>
{session.subject || '在线咨询'}
</Text>
<Text className={getStatusClass(session.status)}>
{getStatusLabel(session.status)}
</Text>
</View>
<Text className='session-message'>
{session.last_message || '暂无消息'}
</Text>
<Text className='session-time'>
{session.last_message_at
? formatTime(session.last_message_at)
: formatTime(session.created_at)}
</Text>
</View>
{session.unread_count > 0 && (
<View className='session-badge'>
<Text className='session-badge-text'>
{session.unread_count > 99 ? '99+' : session.unread_count}
</Text>
</View>
)}
</View>
))}
</View>
)}
</View>
);
}

View File

@@ -0,0 +1,140 @@
@import '../../../styles/variables.scss';
.dm-page {
min-height: 100vh;
background: $bg;
padding: 24px;
padding-bottom: 60px;
}
.dm-section {
background: $card;
border-radius: $r;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.dm-section-title {
font-size: 28px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 16px;
padding-left: 12px;
border-left: 4px solid $pri;
}
.dm-date-picker {
display: flex;
justify-content: space-between;
align-items: center;
background: $bg;
border-radius: $r-sm;
padding: 20px 24px;
}
.dm-date-value {
font-size: 28px;
color: $pri;
font-weight: bold;
}
.dm-date-arrow {
font-size: 28px;
color: $tx3;
}
.dm-bp-row {
display: flex;
align-items: flex-end;
gap: 16px;
}
.dm-bp-field {
flex: 1;
}
.dm-field-label {
font-size: 24px;
color: $tx2;
display: block;
margin-bottom: 8px;
}
.dm-bp-sep {
font-size: 40px;
color: $tx3;
padding-bottom: 16px;
font-weight: 300;
}
.dm-field-unit {
font-size: 22px;
color: $tx3;
display: block;
margin-top: 8px;
}
.dm-single-row {
display: flex;
align-items: center;
gap: 16px;
}
.dm-field-unit-inline {
font-size: 26px;
color: $tx3;
white-space: nowrap;
flex-shrink: 0;
}
.dm-input {
background: $bg;
border-radius: $r-sm;
padding: 20px 24px;
font-size: 28px;
color: $tx;
width: 100%;
box-sizing: border-box;
}
.dm-input-full {
width: 100%;
}
.dm-submit {
background: $pri;
border-radius: $r-sm;
padding: 24px;
text-align: center;
margin-top: 40px;
box-shadow: 0 4px 12px rgba(8, 145, 178, 0.3);
transition: opacity 0.2s;
&:active {
opacity: 0.85;
}
}
.dm-submit-disabled {
opacity: 0.6;
box-shadow: none;
}
.dm-submit-text {
font-size: 32px;
color: white;
font-weight: bold;
}
.dm-reset {
text-align: center;
padding: 20px;
margin-top: 16px;
}
.dm-reset-text {
font-size: 26px;
color: $tx3;
}

View File

@@ -0,0 +1,287 @@
import { useState } from 'react';
import { View, Text, Input, Picker } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { createDailyMonitoring } from '@/services/health';
import { useAuthStore } from '@/stores/auth';
import { trackEvent } from '@/services/analytics';
import './index.scss';
function formatDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export default function DailyMonitoring() {
const { currentPatient } = useAuthStore();
const today = formatDate(new Date());
const [dateIdx, setDateIdx] = useState(0);
const [dateList] = useState(() => {
const list: string[] = [];
for (let i = 0; i < 30; i++) {
const d = new Date();
d.setDate(d.getDate() - i);
list.push(formatDate(d));
}
return list;
});
const recordDate = dateList[dateIdx];
const [morningSystolic, setMorningSystolic] = useState('');
const [morningDiastolic, setMorningDiastolic] = useState('');
const [eveningSystolic, setEveningSystolic] = useState('');
const [eveningDiastolic, setEveningDiastolic] = useState('');
const [weight, setWeight] = useState('');
const [bloodSugar, setBloodSugar] = useState('');
const [fluidIntake, setFluidIntake] = useState('');
const [urineOutput, setUrineOutput] = useState('');
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
useDidShow(() => {
Taro.setNavigationBarTitle({ title: '日常监测上报' });
});
const resetForm = () => {
setMorningSystolic('');
setMorningDiastolic('');
setEveningSystolic('');
setEveningDiastolic('');
setWeight('');
setBloodSugar('');
setFluidIntake('');
setUrineOutput('');
setNotes('');
};
const handleSubmit = async () => {
if (!currentPatient) {
Taro.showToast({ title: '请先选择就诊人', icon: 'none' });
return;
}
const hasData =
morningSystolic || morningDiastolic ||
eveningSystolic || eveningDiastolic ||
weight || bloodSugar || fluidIntake || urineOutput;
if (!hasData) {
Taro.showToast({ title: '请至少填写一项数据', icon: 'none' });
return;
}
if ((morningSystolic && !morningDiastolic) || (!morningSystolic && morningDiastolic)) {
Taro.showToast({ title: '晨起血压请同时填写收缩压和舒张压', icon: 'none' });
return;
}
if ((eveningSystolic && !eveningDiastolic) || (!eveningSystolic && eveningDiastolic)) {
Taro.showToast({ title: '晚间血压请同时填写收缩压和舒张压', icon: 'none' });
return;
}
setSubmitting(true);
try {
await createDailyMonitoring({
patient_id: currentPatient.id,
record_date: recordDate,
morning_bp_systolic: morningSystolic ? parseFloat(morningSystolic) : undefined,
morning_bp_diastolic: morningDiastolic ? parseFloat(morningDiastolic) : undefined,
evening_bp_systolic: eveningSystolic ? parseFloat(eveningSystolic) : undefined,
evening_bp_diastolic: eveningDiastolic ? parseFloat(eveningDiastolic) : undefined,
weight: weight ? parseFloat(weight) : undefined,
blood_sugar: bloodSugar ? parseFloat(bloodSugar) : undefined,
fluid_intake: fluidIntake ? parseFloat(fluidIntake) : undefined,
urine_output: urineOutput ? parseFloat(urineOutput) : undefined,
notes: notes || undefined,
});
trackEvent('daily_monitoring_submit', { date: recordDate });
Taro.showToast({ title: '上报成功', icon: 'success' });
setTimeout(() => {
Taro.showToast({ title: '+10 健康积分', icon: 'none', duration: 1500 });
}, 1600);
setTimeout(() => {
Taro.navigateBack();
}, 3200);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '上报失败';
if (msg.includes('已有记录') || msg.includes('already exists')) {
Taro.showModal({
title: '提示',
content: '该日期已有监测记录,请选择其他日期',
showCancel: false,
});
} else {
Taro.showToast({ title: msg, icon: 'none' });
}
} finally {
setSubmitting(false);
}
};
return (
<View className='dm-page'>
{/* 日期选择 */}
<View className='dm-section'>
<Text className='dm-section-title'></Text>
<Picker
mode='selector'
range={dateList}
value={dateIdx}
onChange={(e) => setDateIdx(Number(e.detail.value))}
>
<View className='dm-date-picker'>
<Text className='dm-date-value'>{recordDate}</Text>
<Text className='dm-date-arrow'></Text>
</View>
</Picker>
</View>
{/* 晨起血压 */}
<View className='dm-section'>
<Text className='dm-section-title'></Text>
<View className='dm-bp-row'>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className='dm-input'
placeholder='如 120'
value={morningSystolic}
onInput={(e) => setMorningSystolic(e.detail.value)}
/>
</View>
<Text className='dm-bp-sep'>/</Text>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className='dm-input'
placeholder='如 80'
value={morningDiastolic}
onInput={(e) => setMorningDiastolic(e.detail.value)}
/>
</View>
</View>
<Text className='dm-field-unit'>mmHg</Text>
</View>
{/* 晚间血压 */}
<View className='dm-section'>
<Text className='dm-section-title'></Text>
<View className='dm-bp-row'>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className='dm-input'
placeholder='如 120'
value={eveningSystolic}
onInput={(e) => setEveningSystolic(e.detail.value)}
/>
</View>
<Text className='dm-bp-sep'>/</Text>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className='dm-input'
placeholder='如 80'
value={eveningDiastolic}
onInput={(e) => setEveningDiastolic(e.detail.value)}
/>
</View>
</View>
<Text className='dm-field-unit'>mmHg</Text>
</View>
{/* 体重 */}
<View className='dm-section'>
<Text className='dm-section-title'></Text>
<View className='dm-single-row'>
<Input
type='digit'
className='dm-input'
placeholder='如 65.0'
value={weight}
onInput={(e) => setWeight(e.detail.value)}
/>
<Text className='dm-field-unit-inline'>kg</Text>
</View>
</View>
{/* 血糖 */}
<View className='dm-section'>
<Text className='dm-section-title'></Text>
<View className='dm-single-row'>
<Input
type='digit'
className='dm-input'
placeholder='如 5.6'
value={bloodSugar}
onInput={(e) => setBloodSugar(e.detail.value)}
/>
<Text className='dm-field-unit-inline'>mmol/L</Text>
</View>
</View>
{/* 饮水量 */}
<View className='dm-section'>
<Text className='dm-section-title'></Text>
<View className='dm-single-row'>
<Input
type='digit'
className='dm-input'
placeholder='如 2000'
value={fluidIntake}
onInput={(e) => setFluidIntake(e.detail.value)}
/>
<Text className='dm-field-unit-inline'>ml</Text>
</View>
</View>
{/* 尿量 */}
<View className='dm-section'>
<Text className='dm-section-title'>尿</Text>
<View className='dm-single-row'>
<Input
type='digit'
className='dm-input'
placeholder='如 1500'
value={urineOutput}
onInput={(e) => setUrineOutput(e.detail.value)}
/>
<Text className='dm-field-unit-inline'>ml</Text>
</View>
</View>
{/* 备注 */}
<View className='dm-section'>
<Text className='dm-section-title'></Text>
<Input
className='dm-input dm-input-full'
placeholder='如:头晕、乏力等(可选)'
value={notes}
onInput={(e) => setNotes(e.detail.value)}
/>
</View>
{/* 提交 */}
<View
className={`dm-submit ${submitting ? 'dm-submit-disabled' : ''}`}
onClick={submitting ? undefined : handleSubmit}
>
<Text className='dm-submit-text'>{submitting ? '提交中...' : '提交上报'}</Text>
</View>
{/* 重置 */}
<View className='dm-reset' onClick={resetForm}>
<Text className='dm-reset-text'></Text>
</View>
</View>
);
}

View File

@@ -31,6 +31,113 @@
font-weight: bold;
}
// ---- Quick Actions (快捷操作) ----
.quick-actions {
display: flex;
gap: 16px;
padding: 0 24px;
margin-bottom: 24px;
}
.quick-action-item {
flex: 1;
background: $card;
border-radius: $r;
padding: 24px 12px;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition: transform 0.15s;
&:active {
transform: scale(0.96);
}
}
.quick-action-icon-wrap {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.quick-action-icon-primary {
background: $pri-l;
}
.quick-action-icon-green {
background: $acc-l;
}
.quick-action-icon-orange {
background: $wrn-l;
}
.quick-action-icon {
font-size: 36px;
}
.quick-action-label {
font-size: 24px;
color: $tx;
font-weight: 500;
}
// ---- Checkin Status (打卡状态) ----
.checkin-card {
display: flex;
align-items: center;
justify-content: space-between;
background: $card;
border-radius: $r;
padding: 24px;
margin: 0 24px 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.checkin-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.checkin-done {
font-size: 28px;
color: $acc;
font-weight: bold;
}
.checkin-streak {
font-size: 22px;
color: $tx3;
}
.checkin-pending {
font-size: 28px;
color: $tx2;
font-weight: 500;
}
.checkin-go-btn {
background: $pri;
border-radius: $r-sm;
padding: 12px 24px;
}
.checkin-go-text {
font-size: 24px;
color: white;
font-weight: bold;
}
// ---- Health Grid (体征概览) ----
.health-grid {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -90,6 +197,8 @@
margin-top: 4px;
}
// ---- Trend Actions (趋势快捷入口) ----
.health-actions {
display: flex;
gap: 16px;
@@ -116,3 +225,64 @@
font-size: 24px;
color: $tx2;
}
// ---- Recent Daily Monitoring (最近日常监测) ----
.recent-section {
padding: 0 24px;
margin-top: 32px;
}
.recent-section-title {
font-size: 28px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 16px;
padding-left: 12px;
border-left: 4px solid $pri;
}
.recent-record {
background: $card;
border-radius: $r;
padding: 20px 24px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.recent-record-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.recent-record-date {
font-size: 26px;
color: $pri;
font-weight: bold;
}
.recent-record-data {
display: flex;
gap: 32px;
flex-wrap: wrap;
}
.recent-data-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.recent-data-label {
font-size: 22px;
color: $tx3;
}
.recent-data-value {
font-size: 26px;
color: $tx;
font-weight: 500;
}

View File

@@ -1,6 +1,11 @@
import { useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { useHealthStore } from '../../stores/health';
import { listDailyMonitoring, DailyMonitoring } from '../../services/health';
import { getCheckinStatus, CheckinStatus } from '../../services/points';
import { useAuthStore } from '../../stores/auth';
import { trackEvent } from '../../services/analytics';
import Loading from '../../components/Loading';
import './index.scss';
@@ -13,19 +18,53 @@ function getStatusStyle(status?: string) {
export default function Health() {
const { todaySummary, loading, refreshToday } = useHealthStore();
const { currentPatient } = useAuthStore();
const [checkinStatus, setCheckinStatus] = useState<CheckinStatus | null>(null);
const [recentRecords, setRecentRecords] = useState<DailyMonitoring[]>([]);
useDidShow(() => {
refreshToday();
loadExtraData();
});
const loadExtraData = async () => {
try {
const status = await getCheckinStatus();
setCheckinStatus(status);
} catch {
// ignore — points API may not be available
}
if (currentPatient) {
try {
const resp = await listDailyMonitoring(currentPatient.id, { page: 1, page_size: 3 });
setRecentRecords(resp.data || []);
} catch {
// ignore — daily monitoring API may not be available yet
}
}
};
const goToInput = () => {
Taro.navigateTo({ url: '/pages/health/input/index' });
};
const goToDailyMonitoring = () => {
Taro.navigateTo({ url: '/pages/health/daily-monitoring/index' });
};
const goToTrend = (indicator: string) => {
Taro.navigateTo({ url: `/pages/health/trend/index?indicator=${indicator}` });
};
const goToTrendPage = () => {
Taro.navigateTo({ url: '/pages/health/trend/index?indicator=blood_pressure_systolic' });
};
const goToMall = () => {
Taro.switchTab({ url: '/pages/mall/index' });
};
const summary = todaySummary || {};
const items = [
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '--/--', unit: 'mmHg', indicator: 'blood_pressure_systolic', status: summary.blood_pressure?.status, ref: summary.blood_pressure?.reference_range },
@@ -34,6 +73,17 @@ export default function Health() {
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '--', unit: 'kg', indicator: 'weight', status: summary.weight?.status, ref: summary.weight?.reference_range },
];
const formatBp = (record: DailyMonitoring) => {
const parts: string[] = [];
if (record.morning_bp_systolic && record.morning_bp_diastolic) {
parts.push(`${record.morning_bp_systolic}/${record.morning_bp_diastolic}`);
}
if (record.evening_bp_systolic && record.evening_bp_diastolic) {
parts.push(`${record.evening_bp_systolic}/${record.evening_bp_diastolic}`);
}
return parts.length > 0 ? parts.join(' ') : '--';
};
return (
<View className='health-page'>
<View className='health-header'>
@@ -43,6 +93,52 @@ export default function Health() {
</View>
</View>
{/* 快捷操作 */}
<View className='quick-actions'>
<View className='quick-action-item' onClick={goToDailyMonitoring}>
<View className='quick-action-icon-wrap quick-action-icon-primary'>
<Text className='quick-action-icon'>📋</Text>
</View>
<Text className='quick-action-label'></Text>
</View>
<View className='quick-action-item' onClick={goToInput}>
<View className='quick-action-icon-wrap quick-action-icon-green'>
<Text className='quick-action-icon'>💉</Text>
</View>
<Text className='quick-action-label'></Text>
</View>
<View className='quick-action-item' onClick={goToTrendPage}>
<View className='quick-action-icon-wrap quick-action-icon-orange'>
<Text className='quick-action-icon'>📈</Text>
</View>
<Text className='quick-action-label'></Text>
</View>
</View>
{/* 打卡状态 */}
{checkinStatus && (
<View className='checkin-card'>
<View className='checkin-left'>
{checkinStatus.checked_in_today ? (
<>
<Text className='checkin-done'> </Text>
{checkinStatus.consecutive_days > 0 && (
<Text className='checkin-streak'> {checkinStatus.consecutive_days} </Text>
)}
</>
) : (
<Text className='checkin-pending'></Text>
)}
</View>
{!checkinStatus.checked_in_today && (
<View className='checkin-go-btn' onClick={goToMall}>
<Text className='checkin-go-text'></Text>
</View>
)}
</View>
)}
{/* 今日体征概览 */}
{loading && !todaySummary ? (
<Loading />
) : (
@@ -64,6 +160,7 @@ export default function Health() {
</View>
)}
{/* 原有趋势快捷入口 */}
<View className='health-actions'>
<View className='action-card' onClick={() => goToTrend('blood_pressure_systolic')}>
<Text className='action-icon'>📈</Text>
@@ -78,6 +175,38 @@ export default function Health() {
<Text className='action-label'></Text>
</View>
</View>
{/* 最近日常监测记录 */}
{recentRecords.length > 0 && (
<View className='recent-section'>
<Text className='recent-section-title'></Text>
{recentRecords.map((record) => (
<View className='recent-record' key={record.id}>
<View className='recent-record-header'>
<Text className='recent-record-date'>{record.record_date}</Text>
</View>
<View className='recent-record-data'>
<View className='recent-data-item'>
<Text className='recent-data-label'></Text>
<Text className='recent-data-value'>{formatBp(record)}</Text>
</View>
{record.weight != null && (
<View className='recent-data-item'>
<Text className='recent-data-label'></Text>
<Text className='recent-data-value'>{record.weight} kg</Text>
</View>
)}
{record.blood_sugar != null && (
<View className='recent-data-item'>
<Text className='recent-data-label'></Text>
<Text className='recent-data-value'>{record.blood_sugar} mmol/L</Text>
</View>
)}
</View>
</View>
))}
</View>
)}
</View>
);
}

View 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;
}

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

View 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;
}

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

View File

@@ -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' }}

View 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;
}

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

View File

@@ -42,6 +42,45 @@
color: rgba(255, 255, 255, 0.8);
}
/* ===== 积分余额信息 ===== */
.profile-points {
display: flex;
align-items: center;
margin-top: 24px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: $r;
padding: 20px 32px;
width: 100%;
box-sizing: border-box;
}
.points-info-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.points-info-value {
font-size: 36px;
font-weight: bold;
color: white;
margin-bottom: 4px;
}
.points-info-label {
font-size: 22px;
color: rgba(255, 255, 255, 0.7);
}
.points-info-divider {
width: 1px;
height: 48px;
background: rgba(255, 255, 255, 0.25);
margin: 0 24px;
}
.profile-menu {
margin: 24px;
background: $card;

View File

@@ -1,9 +1,14 @@
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { useAuthStore } from '../../stores/auth';
import { getAccount, getCheckinStatus } from '../../services/points';
import type { PointsAccount, CheckinStatus } from '../../services/points';
import './index.scss';
const MENU_ITEMS = [
{ label: '我的订单', icon: '🛒', path: '/pages/mall/orders/index' },
{ label: '积分明细', icon: '📊', path: '/pages/mall/detail/index' },
{ label: '就诊人管理', icon: '👥', path: '/pages/profile/family/index' },
{ label: '我的报告', icon: '📋', path: '/pages/profile/reports/index' },
{ label: '我的随访', icon: '💬', path: '/pages/profile/followups/index' },
@@ -13,11 +18,27 @@ const MENU_ITEMS = [
export default function Profile() {
const { user, restore: restoreAuth, logout } = useAuthStore();
const [pointsAccount, setPointsAccount] = useState<PointsAccount | null>(null);
const [checkinInfo, setCheckinInfo] = useState<CheckinStatus | null>(null);
useDidShow(() => {
restoreAuth();
loadPointsInfo();
});
const loadPointsInfo = useCallback(async () => {
try {
const [acct, status] = await Promise.all([
getAccount(),
getCheckinStatus(),
]);
setPointsAccount(acct);
setCheckinInfo(status);
} catch {
// 账户可能尚未创建,静默处理
}
}, []);
const handleMenuClick = (path: string) => {
Taro.navigateTo({ url: path });
};
@@ -43,6 +64,26 @@ export default function Profile() {
</View>
<Text className='profile-name'>{user?.display_name || '未登录'}</Text>
<Text className='profile-phone'>{user?.phone || ''}</Text>
{/* 积分余额信息 */}
<View
className='profile-points'
onClick={() => Taro.navigateTo({ url: '/pages/mall/detail/index' })}
>
<View className='points-info-item'>
<Text className='points-info-value'>
{(pointsAccount?.balance ?? 0).toLocaleString()}
</Text>
<Text className='points-info-label'></Text>
</View>
<View className='points-info-divider' />
<View className='points-info-item'>
<Text className='points-info-value'>
{checkinInfo?.consecutive_days ?? 0}
</Text>
<Text className='points-info-label'>()</Text>
</View>
</View>
</View>
<View className='profile-menu'>

View File

@@ -0,0 +1,24 @@
import { api } from './request';
export interface ConsultationSession {
id: string;
patient_id: string;
doctor_id: string | null;
type: string;
status: string;
subject: string | null;
last_message: string | null;
last_message_at: string | null;
unread_count: number;
created_at: string;
}
export async function listConsultations(params?: {
page?: number;
page_size?: number;
}) {
return api.get<{ data: ConsultationSession[]; total: number }>(
'/health/consultation-sessions',
params,
);
}

View File

@@ -29,3 +29,51 @@ export async function getTrend(indicator: string, range: string) {
{ indicator, range },
);
}
// ---- Daily Monitoring (日常监测) ----
export interface DailyMonitoring {
id: string;
patient_id: string;
record_date: string;
morning_bp_systolic: number | null;
morning_bp_diastolic: number | null;
evening_bp_systolic: number | null;
evening_bp_diastolic: number | null;
weight: number | null;
blood_sugar: number | null;
fluid_intake: number | null;
urine_output: number | null;
notes: string | null;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateDailyMonitoringReq {
patient_id: string;
record_date: string;
morning_bp_systolic?: number;
morning_bp_diastolic?: number;
evening_bp_systolic?: number;
evening_bp_diastolic?: number;
weight?: number;
blood_sugar?: number;
fluid_intake?: number;
urine_output?: number;
notes?: string;
}
export async function createDailyMonitoring(data: CreateDailyMonitoringReq) {
return api.post<DailyMonitoring>('/health/daily-monitoring', data);
}
export async function listDailyMonitoring(
patientId: string,
params?: { page?: number; page_size?: number },
) {
return api.get<{ data: DailyMonitoring[]; total: number }>(
`/health/patients/${patientId}/daily-monitoring`,
params,
);
}

View File

@@ -55,3 +55,46 @@ export async function listProducts(params?: {
}) {
return api.get<ProductListResponse>('/health/points/products', params);
}
// ===== 兑换订单 =====
export interface PointsOrder {
id: string;
patient_id: string;
product_id: string;
points_cost: number;
status: string; // pending / verified / cancelled / expired
qr_code: string;
verified_by: string | null;
verified_at: string | null;
expires_at: string | null;
notes: string | null;
created_at: string;
updated_at: string;
version: number;
}
export interface PointsTransaction {
id: string;
account_id: string;
type: string; // earn / spend / expired
amount: number;
remaining_amount: number;
status: string;
expires_at: string | null;
balance_after: number;
description: string | null;
created_at: string;
}
export async function exchangeProduct(product_id: string) {
return api.post<PointsOrder>('/health/points/exchange', { product_id });
}
export async function listMyOrders(params?: { page?: number; page_size?: number }) {
return api.get<{ data: PointsOrder[]; total: number }>('/health/points/orders', params);
}
export async function listMyTransactions(params?: { page?: number; page_size?: number }) {
return api.get<{ data: PointsTransaction[]; total: number }>('/health/points/transactions', params);
}

View File

@@ -39,6 +39,7 @@ const PointsRuleList = lazy(() => import('./pages/health/PointsRuleList'));
const PointsProductList = lazy(() => import('./pages/health/PointsProductList'));
const PointsOrderList = lazy(() => import('./pages/health/PointsOrderList'));
const OfflineEventList = lazy(() => import('./pages/health/OfflineEventList'));
const StatisticsDashboard = lazy(() => import('./pages/health/StatisticsDashboard'));
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
@@ -170,6 +171,7 @@ export default function App() {
<Route path="/plugins/:pluginId/kanban/:entityName" element={<PluginKanbanPage />} />
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
{/* 健康管理 */}
<Route path="/health/statistics" element={<StatisticsDashboard />} />
<Route path="/health/patients" element={<PatientList />} />
<Route path="/health/patients/:id" element={<PatientDetail />} />
<Route path="/health/tags" element={<PatientTagManage />} />

View File

@@ -118,6 +118,35 @@ export interface PointsStatistics {
}>;
}
export interface PatientStatistics {
total_patients: number;
new_this_month: number;
new_this_week: number;
active_this_month: number;
}
export interface ConsultationStatistics {
total_sessions: number;
pending_reply: number;
avg_response_time_minutes: number | null;
this_month: number;
}
export interface FollowUpStatistics {
total_tasks: number;
completed: number;
pending: number;
overdue: number;
completion_rate: number;
}
export interface OverviewStatistics {
patients: PatientStatistics;
consultations: ConsultationStatistics;
follow_ups: FollowUpStatistics;
points: PointsStatistics;
}
// --- API ---
export const pointsApi = {
@@ -211,4 +240,33 @@ export const pointsApi = {
}>('/health/admin/points/statistics');
return data.data;
},
// --- Dashboard Statistics (hybrid: aggregate from list endpoints) ---
getPatientStats: async (): Promise<PatientStatistics> => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<{ id: string }>;
}>('/health/patients', { params: { page: 1, page_size: 1 } });
const total = data.data?.total || 0;
return { total_patients: total, new_this_month: 0, new_this_week: 0, active_this_month: 0 };
},
getConsultationStats: async (): Promise<ConsultationStatistics> => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<{ id: string }>;
}>('/health/consultation-sessions', { params: { page: 1, page_size: 1 } });
const total = data.data?.total || 0;
return { total_sessions: total, pending_reply: 0, avg_response_time_minutes: null, this_month: 0 };
},
getFollowUpStats: async (): Promise<FollowUpStatistics> => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<{ id: string }>;
}>('/health/follow-up-tasks', { params: { page: 1, page_size: 1 } });
const total = data.data?.total || 0;
return { total_tasks: total, completed: 0, pending: 0, overdue: 0, completion_rate: 0 };
},
};

View File

@@ -27,6 +27,7 @@ import {
TrophyOutlined,
ShopOutlined,
FileTextOutlined,
DashboardOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppStore } from '../stores/app';
@@ -56,6 +57,7 @@ const bizMenuItems: MenuItem[] = [
];
const healthMenuItems: MenuItem[] = [
{ key: '/health/statistics', icon: <DashboardOutlined />, label: '统计报表' },
{ key: '/health/patients', icon: <TeamOutlined />, label: '患者管理' },
{ key: '/health/doctors', icon: <MedicineBoxOutlined />, label: '医护管理' },
{ key: '/health/appointments', icon: <CalendarOutlined />, label: '预约排班' },
@@ -83,6 +85,7 @@ const routeTitleMap: Record<string, string> = {
'/messages': '消息中心',
'/settings': '系统设置',
'/plugins/admin': '插件管理',
'/health/statistics': '统计报表',
'/health/patients': '患者管理',
'/health/patients/:id': '患者详情',
'/health/tags': '标签管理',

View File

@@ -0,0 +1,414 @@
import { useEffect, useState, useCallback } from 'react';
import {
Row,
Col,
Card,
Statistic,
Table,
Spin,
Alert,
Button,
Typography,
Tooltip,
} from 'antd';
import {
UserOutlined,
MessageOutlined,
PhoneOutlined,
TrophyOutlined,
TeamOutlined,
CalendarOutlined,
ShoppingOutlined,
ReloadOutlined,
CommentOutlined,
MedicineBoxOutlined,
FileTextOutlined,
ClockCircleOutlined,
ArrowUpOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import {
pointsApi,
type PatientStatistics,
type ConsultationStatistics,
type FollowUpStatistics,
type PointsStatistics,
} from '../../api/health/points';
const { Title: AntTitle, Text } = Typography;
/** Top-level stat card configuration */
interface StatCardConfig {
title: string;
value: number;
suffix?: string;
precision?: number;
prefix?: React.ReactNode;
subtitle?: string;
color: string;
bgColor: string;
}
/** Quick-link card configuration */
interface QuickLinkConfig {
title: string;
icon: React.ReactNode;
path: string;
color: string;
}
/** Top earner row from points statistics */
interface TopEarnerRow {
rank: number;
patient_id: string;
total_earned: number;
}
const QUICK_LINKS: QuickLinkConfig[] = [
{ title: '患者管理', icon: <TeamOutlined />, path: '/health/patients', color: '#2563eb' },
{ title: '预约排班', icon: <CalendarOutlined />, path: '/health/appointments', color: '#059669' },
{ title: '随访管理', icon: <PhoneOutlined />, path: '/health/follow-up-tasks', color: '#d97706' },
{ title: '咨询管理', icon: <CommentOutlined />, path: '/health/consultations', color: '#7c3aed' },
{ title: '积分规则', icon: <TrophyOutlined />, path: '/health/points-rules', color: '#dc2626' },
{ title: '商品管理', icon: <ShoppingOutlined />, path: '/health/points-products', color: '#0891b2' },
{ title: '订单管理', icon: <FileTextOutlined />, path: '/health/points-orders', color: '#4f46e5' },
{ title: '线下活动', icon: <CalendarOutlined />, path: '/health/offline-events', color: '#be185d' },
];
export default function StatisticsDashboard() {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [patientStats, setPatientStats] = useState<PatientStatistics | null>(null);
const [consultationStats, setConsultationStats] = useState<ConsultationStatistics | null>(null);
const [followUpStats, setFollowUpStats] = useState<FollowUpStatistics | null>(null);
const [pointsStats, setPointsStats] = useState<PointsStatistics | null>(null);
const fetchAllStats = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [patients, consultations, followUps, points] = await Promise.all([
pointsApi.getPatientStats(),
pointsApi.getConsultationStats(),
pointsApi.getFollowUpStats(),
pointsApi.getStatistics(),
]);
setPatientStats(patients);
setConsultationStats(consultations);
setFollowUpStats(followUps);
setPointsStats(points);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '加载统计数据失败';
setError(message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAllStats();
}, [fetchAllStats]);
// ---- Derived stat cards ----
const statCards: StatCardConfig[] = [
{
title: '患者总数',
value: patientStats?.total_patients ?? 0,
prefix: <UserOutlined />,
subtitle: patientStats?.new_this_month ? `本月 +${patientStats.new_this_month}` : undefined,
color: '#2563eb',
bgColor: '#eff6ff',
},
{
title: '咨询总量',
value: consultationStats?.total_sessions ?? 0,
prefix: <MessageOutlined />,
subtitle: consultationStats?.this_month ? `本月 +${consultationStats.this_month}` : undefined,
color: '#7c3aed',
bgColor: '#f5f3ff',
},
{
title: '随访完成率',
value: followUpStats?.completion_rate ?? 0,
suffix: '%',
precision: 1,
prefix: <PhoneOutlined />,
subtitle: followUpStats?.pending ? `待处理: ${followUpStats.pending}` : undefined,
color: '#059669',
bgColor: '#ecfdf5',
},
{
title: '积分总发放',
value: pointsStats?.total_issued ?? 0,
prefix: <TrophyOutlined />,
subtitle: pointsStats?.active_accounts ? `活跃账户: ${pointsStats.active_accounts}` : undefined,
color: '#d97706',
bgColor: '#fffbeb',
},
];
// ---- Top earners table ----
const topEarnerColumns = [
{
title: '排名',
dataIndex: 'rank',
key: 'rank',
width: 70,
render: (rank: number) => {
const medalColors = ['#d97706', '#6b7280', '#b45309'];
const color = rank <= 3 ? medalColors[rank - 1] : undefined;
return (
<Text strong={rank <= 3} style={color ? { color } : undefined}>
{rank}
</Text>
);
},
},
{
title: '患者 ID',
dataIndex: 'patient_id',
key: 'patient_id',
width: 180,
render: (id: string) => (
<Tooltip title={id}>
<Text copyable={{ text: id }}>{id.length > 12 ? `${id.slice(0, 8)}...${id.slice(-4)}` : id}</Text>
</Tooltip>
),
},
{
title: '累计积分',
dataIndex: 'total_earned',
key: 'total_earned',
width: 140,
render: (val: number) => <Text strong>{val.toLocaleString()}</Text>,
},
];
const topEarnerData: TopEarnerRow[] = (pointsStats?.top_earners ?? []).map((item, idx) => ({
rank: idx + 1,
patient_id: item.patient_id,
total_earned: item.total_earned,
}));
// ---- Loading / Error states ----
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
<Spin size="large" tip="加载统计数据中..." />
</div>
);
}
if (error) {
return (
<Alert
type="error"
message="加载统计数据失败"
description={error}
showIcon
action={
<Button size="small" icon={<ReloadOutlined />} onClick={fetchAllStats}>
</Button>
}
/>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Section 1: Top Stats Cards */}
<Row gutter={[16, 16]}>
{statCards.map((card) => (
<Col xs={24} sm={12} md={6} key={card.title}>
<Card
bordered={false}
style={{ borderRadius: 12 }}
bodyStyle={{ padding: '20px 24px' }}
hoverable
>
<Statistic
title={
<span style={{ fontSize: 14, color: '#64748b' }}>{card.title}</span>
}
value={card.value}
precision={card.precision}
suffix={card.suffix}
prefix={
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: card.bgColor,
color: card.color,
fontSize: 20,
marginRight: 12,
}}
>
{card.prefix}
</span>
}
valueStyle={{ color: card.color, fontSize: 28, fontWeight: 700 }}
/>
{card.subtitle && (
<div style={{ marginTop: 8, fontSize: 13, color: '#94a3b8' }}>
<ArrowUpOutlined style={{ fontSize: 11, marginRight: 4 }} />
{card.subtitle}
</div>
)}
</Card>
</Col>
))}
</Row>
{/* Section 2: Points Statistics Details */}
<Card
title={
<span style={{ fontSize: 16, fontWeight: 600 }}>
<TrophyOutlined style={{ marginRight: 8, color: '#d97706' }} />
</span>
}
bordered={false}
style={{ borderRadius: 12 }}
extra={
<Button
type="text"
icon={<ReloadOutlined />}
onClick={fetchAllStats}
loading={loading}
>
</Button>
}
>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} sm={6}>
<Statistic
title="总发放"
value={pointsStats?.total_issued ?? 0}
valueStyle={{ color: '#059669', fontSize: 22 }}
prefix={<ArrowUpOutlined />}
/>
</Col>
<Col xs={12} sm={6}>
<Statistic
title="总消费"
value={pointsStats?.total_spent ?? 0}
valueStyle={{ color: '#dc2626', fontSize: 22 }}
/>
</Col>
<Col xs={12} sm={6}>
<Statistic
title="总过期"
value={pointsStats?.total_expired ?? 0}
valueStyle={{ color: '#6b7280', fontSize: 22 }}
/>
</Col>
<Col xs={12} sm={6}>
<Statistic
title="活跃账户"
value={pointsStats?.active_accounts ?? 0}
valueStyle={{ color: '#2563eb', fontSize: 22 }}
prefix={<TeamOutlined />}
/>
</Col>
</Row>
<AntTitle level={5} style={{ marginBottom: 16 }}>
Top 10
</AntTitle>
<Table
rowKey="rank"
columns={topEarnerColumns}
dataSource={topEarnerData}
pagination={false}
size="small"
locale={{ emptyText: '暂无数据' }}
style={{ marginTop: 8 }}
/>
</Card>
{/* Section 3: Quick Links */}
<Card
title={
<span style={{ fontSize: 16, fontWeight: 600 }}>
<MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />
</span>
}
bordered={false}
style={{ borderRadius: 12 }}
>
<Row gutter={[16, 16]}>
{QUICK_LINKS.map((link) => (
<Col xs={12} sm={8} md={6} key={link.path}>
<Card
hoverable
bordered={false}
style={{
borderRadius: 10,
cursor: 'pointer',
textAlign: 'center',
transition: 'transform 0.2s, box-shadow 0.2s',
}}
bodyStyle={{ padding: '20px 12px' }}
onClick={() => navigate(link.path)}
>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 48,
height: 48,
borderRadius: 12,
backgroundColor: `${link.color}15`,
color: link.color,
fontSize: 24,
marginBottom: 10,
}}
>
{link.icon}
</div>
<div style={{ fontSize: 14, fontWeight: 500, color: '#334155' }}>
{link.title}
</div>
</Card>
</Col>
))}
</Row>
</Card>
{/* Section 4: Recent Activity (top earners as proxy) */}
{topEarnerData.length > 0 && (
<Card
title={
<span style={{ fontSize: 16, fontWeight: 600 }}>
<ClockCircleOutlined style={{ marginRight: 8, color: '#7c3aed' }} />
</span>
}
bordered={false}
style={{ borderRadius: 12 }}
>
<Table
rowKey="rank"
columns={topEarnerColumns}
dataSource={topEarnerData}
pagination={{ pageSize: 5, size: 'small' }}
size="small"
locale={{ emptyText: '暂无活动记录' }}
/>
</Card>
)}
</div>
);
}