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