Initial commit: HealthManager full-stack health management platform

Backend: .NET 10 + PostgreSQL + EF Core + JWT + SignalR
Frontend patient: React 19 + TypeScript + Vite (mobile H5)
Frontend doctor: React 19 + TypeScript + Vite (desktop web)
This commit is contained in:
MingNian
2026-05-20 16:18:56 +08:00
commit 435af55c4a
215 changed files with 18595 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
/**
* Real HTTP API client — replaces mockApiResponse with actual fetch calls.
* Backend base: http://localhost:5000
*/
interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
const BASE_URL = 'http://localhost:5000';
// Endpoints that should NEVER include auth token
const PUBLIC_ENDPOINTS = ['/api/auth/login', '/api/auth/register', '/api/auth/send-sms', '/api/auth/refresh'];
function getToken(): string | null {
try {
const raw = localStorage.getItem('hrt_auth');
if (!raw) return null;
const state = JSON.parse(raw);
return state?.state?.token ?? null;
} catch {
return null;
}
}
function clearAuth() {
localStorage.removeItem('hrt_auth');
}
async function request<T>(
method: string,
path: string,
body?: unknown,
): Promise<ApiResponse<T>> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Only attach token for non-public endpoints
if (!PUBLIC_ENDPOINTS.includes(path)) {
const token = getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
const response = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
// Handle 401 — clear stored token and redirect to login
if (response.status === 401) {
clearAuth();
// Only redirect if not already on login page
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login';
}
throw new Error('登录已过期,请重新登录');
}
const text = await response.text();
let data: T;
try {
const json = JSON.parse(text);
if (json.code !== undefined && json.data !== undefined) {
return json as ApiResponse<T>;
}
data = json as T;
} catch {
data = text as unknown as T;
}
if (!response.ok) {
const msg = (data as Record<string, unknown>)?.message || response.statusText;
throw new Error(String(msg));
}
return { code: response.status, data, message: 'success' };
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
del: <T>(path: string) => request<T>('DELETE', path),
};
export type { ApiResponse };

View File

@@ -0,0 +1 @@
export { api as apiRequest, type ApiResponse } from './api-client';

View File

@@ -0,0 +1,90 @@
import { api, type ApiResponse } from './api-client';
import type { User } from '@/types';
interface AuthResponseData {
userId: string;
name: string;
role: string;
accessToken: string;
refreshToken: string;
}
function mapUser(data: AuthResponseData): User {
return {
id: data.userId,
phone: '',
nickname: data.name,
avatar: '',
gender: 'unknown',
birthday: '',
height: 0,
weight: 0,
medicalHistory: [],
stentImplantDate: '',
createdAt: new Date().toISOString(),
};
}
export async function login(phone: string, smsCode: string): Promise<{ token: string; user: User }> {
const res: ApiResponse<AuthResponseData> = await api.post('/api/auth/login', {
phone,
smsCode,
});
return { token: res.data.accessToken, user: mapUser(res.data) };
}
export async function register(phone: string, smsCode: string, nickname: string): Promise<{ token: string; user: User }> {
const res: ApiResponse<AuthResponseData> = await api.post('/api/auth/register', {
phone,
smsCode,
name: nickname,
});
return { token: res.data.accessToken, user: mapUser(res.data) };
}
export async function sendSmsCode(phone: string): Promise<boolean> {
await api.post('/api/auth/send-sms', { phone });
return true;
}
export async function getProfile(): Promise<User> {
const res = await api.get<{
id: string; name: string; phone: string; role: string;
gender: string; birthday: string; heightCm: number; weightKg: number;
medicalHistory: string[]; stentDate: string; stentType: string;
department: string; title: string; specialty: string[]; introduction: string;
}>('/api/auth/me');
// Update stored user info
try {
const raw = localStorage.getItem('hrt_auth');
if (raw) {
const state = JSON.parse(raw);
if (state?.state) {
state.state.user = {
...state.state.user,
id: res.data.id,
phone: res.data.phone,
nickname: res.data.name,
gender: res.data.gender || 'unknown',
birthday: res.data.birthday || '',
height: res.data.heightCm || 0,
weight: res.data.weightKg || 0,
medicalHistory: res.data.medicalHistory || [],
stentImplantDate: res.data.stentDate || '',
};
localStorage.setItem('hrt_auth', JSON.stringify(state));
}
}
} catch { /* ignore */ }
return res.data as unknown as User;
}
export async function updateProfile(data: Record<string, unknown>): Promise<void> {
await api.put('/api/auth/me', data);
}
export async function logout(): Promise<void> {
localStorage.removeItem('hrt_auth');
}

View File

@@ -0,0 +1,114 @@
import { api } from './api-client';
import type { Doctor, Consultation, ConsultationMessage } from '@/types';
interface RawDoctor {
id: string;
name: string;
department: string;
title: string;
specialty: string[];
introduction: string;
isAvailable: boolean;
avatarUrl?: string | null;
}
interface RawConsultation {
id: string;
patientId: string;
doctorId: string;
subject?: string | null;
status: string;
startedAt: string;
patientName?: string;
doctorName?: string | null;
closedAt?: string | null;
summary?: string | null;
}
interface RawMessage {
id: string;
senderId: string;
senderRole: string;
content: string;
contentType: string;
imageUrl?: string | null;
isRead: boolean;
createdAt: string;
senderName?: string;
}
function mapDoctor(d: RawDoctor): Doctor {
return {
id: d.id,
name: d.name,
department: d.department,
title: d.title,
specialty: d.specialty,
introduction: d.introduction,
isAvailable: d.isAvailable,
avatarUrl: d.avatarUrl,
};
}
function mapMessage(m: RawMessage): ConsultationMessage {
return {
id: m.id,
senderId: m.senderId,
senderRole: m.senderRole,
content: m.content,
contentType: m.contentType,
imageUrl: m.imageUrl,
isRead: m.isRead,
createdAt: m.createdAt,
senderName: m.senderName,
};
}
export async function getDoctors(_department?: string): Promise<Doctor[]> {
const res = await api.get<RawDoctor[]>('/api/consultations/doctors');
return res.data.map(mapDoctor);
}
export async function getDoctor(id: string): Promise<Doctor | undefined> {
const res = await api.get<RawDoctor[]>('/api/consultations/doctors');
const d = res.data.find((d) => d.id === id);
return d ? mapDoctor(d) : undefined;
}
export async function getConsultation(doctorId: string): Promise<Consultation | undefined> {
const res = await api.get<RawConsultation[]>('/api/consultations');
const c = res.data.find((c) => c.doctorId === doctorId && c.status === 'active');
return c ?? undefined;
}
export async function startConsultation(doctorId: string, subject?: string): Promise<Consultation> {
const existing = await getConsultation(doctorId);
if (existing) return existing;
const res = await api.post<RawConsultation>('/api/consultations', {
doctorId,
subject: subject || '在线咨询',
});
return res.data;
}
export async function sendMessage(
consultationId: string,
text: string,
): Promise<ConsultationMessage> {
const res = await api.post<RawMessage>(`/api/consultations/${consultationId}/messages`, {
content: text,
});
return mapMessage(res.data);
}
export async function getDoctorReply(consultationId: string): Promise<ConsultationMessage | null> {
const res = await api.get<RawMessage[]>(`/api/consultations/${consultationId}/messages`);
const msgs = res.data;
if (msgs.length === 0) return null;
const lastMsg = msgs[msgs.length - 1];
if (lastMsg.senderRole === 'doctor') {
return mapMessage(lastMsg);
}
return null;
}

View File

@@ -0,0 +1,19 @@
import { api } from './api-client';
import type { Device } from '@/types';
// Backend doesn't have a dedicated device API yet — return empty for now
export async function getBoundDevices(): Promise<Device[]> {
return [];
}
export async function scanDevices(): Promise<Device[]> {
return [];
}
export async function bindDevice(_deviceId: string): Promise<Device> {
return {} as Device;
}
export async function unbindDevice(_deviceId: string): Promise<Device> {
return {} as Device;
}

View File

@@ -0,0 +1,102 @@
import { api } from './api-client';
import type { ExerciseRecord, DietRecord } from '@/types';
import { EXERCISE_RECOMMENDATIONS, DIET_RECOMMENDATIONS } from '@/utils/constants';
interface RawRecord {
id: string;
type: string;
value: string;
unit: string;
recordedAt: string;
notes?: string;
}
export async function getExerciseLogs(): Promise<ExerciseRecord[]> {
try {
const res = await api.get<RawRecord[]>('/api/health-records?type=exercise&days=30');
return res.data.map((r) => {
const val = JSON.parse(r.value || '{}');
return {
id: r.id,
userId: '',
type: val.type || 'walking',
duration: val.duration || 0,
intensity: val.intensity,
caloriesBurned: val.caloriesBurned || val.calories,
date: r.recordedAt.split('T')[0],
notes: r.notes,
};
});
} catch {
return [];
}
}
export async function addExerciseLog(data: Omit<ExerciseRecord, 'id' | 'userId'>): Promise<ExerciseRecord> {
const valueJson = JSON.stringify({
type: data.type,
duration: data.duration,
intensity: data.intensity,
caloriesBurned: data.caloriesBurned || data.calories,
});
const res = await api.post<RawRecord>('/api/health-records', {
type: 'exercise',
valueJson,
unit: 'min',
recordedAt: data.date,
notes: data.notes,
});
return {
id: res.data.id,
userId: '',
...data,
};
}
export async function getDietLogs(): Promise<DietRecord[]> {
try {
const res = await api.get<RawRecord[]>('/api/health-records?type=diet&days=30');
return res.data.map((r) => {
const val = JSON.parse(r.value || '{}');
return {
id: r.id,
userId: '',
mealType: val.mealType || val.meal,
foods: val.foods || [],
totalCalories: val.totalCalories,
date: r.recordedAt.split('T')[0],
notes: r.notes,
};
});
} catch {
return [];
}
}
export async function addDietLog(data: Omit<DietRecord, 'id' | 'userId'>): Promise<DietRecord> {
const valueJson = JSON.stringify({
mealType: data.mealType || data.meal,
foods: data.foods,
totalCalories: data.totalCalories,
});
const res = await api.post<RawRecord>('/api/health-records', {
type: 'diet',
valueJson,
unit: '',
recordedAt: data.date,
notes: data.notes,
});
return {
id: res.data.id,
userId: '',
...data,
};
}
export function getExerciseRecommendations() {
return EXERCISE_RECOMMENDATIONS;
}
export function getDietRecommendations() {
return DIET_RECOMMENDATIONS;
}

View File

@@ -0,0 +1,65 @@
import { api } from './api-client';
import type { FollowUp } from '@/types';
interface RawFollowUp {
id: string;
patientId: string;
doctorId?: string | null;
title: string;
description?: string | null;
scheduledAt: string;
status: string;
notes?: string | null;
reminderEnabled: boolean;
createdAt: string;
patientName?: string;
doctorName?: string | null;
}
function mapFollowUp(f: RawFollowUp): FollowUp {
return {
id: f.id,
patientId: f.patientId,
doctorId: f.doctorId,
title: f.title,
description: f.description,
scheduledAt: f.scheduledAt,
status: f.status as FollowUp['status'],
notes: f.notes,
reminderEnabled: f.reminderEnabled,
createdAt: f.createdAt,
patientName: f.patientName,
doctorName: f.doctorName,
};
}
export async function getFollowUps(): Promise<FollowUp[]> {
const res = await api.get<RawFollowUp[]>('/api/follow-ups');
return res.data.map(mapFollowUp);
}
export async function addFollowUp(data: Omit<FollowUp, 'id' | 'patientId' | 'createdAt'>): Promise<FollowUp> {
const res = await api.post<RawFollowUp>('/api/follow-ups', {
title: data.title,
description: data.description,
scheduledAt: data.scheduledAt,
reminderEnabled: data.reminderEnabled,
notes: data.notes,
});
return mapFollowUp(res.data);
}
export async function updateFollowUp(id: string, data: Partial<FollowUp>): Promise<FollowUp> {
const res = await api.put<RawFollowUp>(`/api/follow-ups/${id}`, {
title: data.title,
description: data.description,
scheduledAt: data.scheduledAt,
status: data.status,
notes: data.notes,
});
return mapFollowUp(res.data);
}
export async function deleteFollowUp(id: string): Promise<void> {
await api.del(`/api/follow-ups/${id}`);
}

View File

@@ -0,0 +1,123 @@
import { api } from './api-client';
import type { HealthRecord, HealthStats, MeasurementType } from '@/types';
interface RawRecord {
id: string;
type: string;
value: string; // JSON string from JSONB
unit: string;
recordedAt: string;
source: string;
notes?: string;
createdAt: string;
}
function parseValue(type: string, raw: string): number | { systolic: number; diastolic: number } {
try {
const obj = JSON.parse(raw);
if (type === 'blood_pressure') {
return { systolic: obj.systolic ?? 0, diastolic: obj.diastolic ?? 0 };
}
return obj.value ?? obj;
} catch {
return Number(raw) || 0;
}
}
function mapRecord(r: RawRecord): HealthRecord {
return {
id: r.id,
userId: '',
type: r.type as MeasurementType,
value: parseValue(r.type, r.value),
unit: r.unit,
recordedAt: r.recordedAt,
recordedDate: r.recordedAt.split('T')[0],
note: r.notes,
source: r.source as 'manual' | 'device',
};
}
export async function getRecords(params: {
type?: MeasurementType;
startDate?: string;
endDate?: string;
}): Promise<HealthRecord[]> {
const query = new URLSearchParams();
if (params.type) query.set('type', params.type);
query.set('days', '90');
const res = await api.get<RawRecord[]>(`/api/health-records?${query}`);
let records = res.data.map(mapRecord);
if (params.startDate) records = records.filter((r) => r.recordedDate >= params.startDate!);
if (params.endDate) records = records.filter((r) => r.recordedDate <= params.endDate!);
records.sort((a, b) => b.recordedAt.localeCompare(a.recordedAt));
return records;
}
export async function addRecord(record: Omit<HealthRecord, 'id' | 'userId'>): Promise<HealthRecord> {
// Build JSON value to match backend
let valueJson: string;
if (typeof record.value === 'object') {
valueJson = JSON.stringify(record.value);
} else {
valueJson = JSON.stringify({ value: record.value });
}
const res = await api.post<RawRecord>('/api/health-records', {
type: record.type,
valueJson,
unit: record.unit,
recordedAt: record.recordedAt,
notes: record.note,
});
return mapRecord(res.data);
}
export async function getLatestStats(): Promise<HealthStats[]> {
const res = await api.get<RawRecord[]>('/api/health-records?days=7');
const records = res.data.map(mapRecord);
const types: MeasurementType[] = ['blood_pressure', 'heart_rate', 'blood_sugar', 'spo2', 'weight', 'steps'];
const statsList: HealthStats[] = [];
for (const type of types) {
const typeRecords = records.filter((r) => r.type === type);
const latest = typeRecords[0] || null;
const values = typeRecords.map((r) =>
typeof r.value === 'object' ? (r.value.systolic + r.value.diastolic) / 2 : r.value,
);
const avg7Days = values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0;
const min7Days = values.length ? Math.min(...values) : 0;
const max7Days = values.length ? Math.max(...values) : 0;
const mid = Math.floor(values.length / 2);
const olderAvg = values.slice(0, mid).reduce((a, b) => a + b, 0) / (mid || 1);
const newerAvg = values.slice(mid).reduce((a, b) => a + b, 0) / (values.length - mid || 1);
let trend: 'up' | 'down' | 'stable' = 'stable';
if (newerAvg > olderAvg * 1.03) trend = 'up';
else if (newerAvg < olderAvg * 0.97) trend = 'down';
const unitMap: Record<string, string> = {
blood_pressure: 'mmHg', heart_rate: 'bpm', blood_sugar: 'mmol/L',
spo2: '%', weight: 'kg', steps: '步',
};
statsList.push({
type, latest,
avg7Days: +avg7Days.toFixed(1),
min7Days: +min7Days.toFixed(1),
max7Days: +max7Days.toFixed(1),
trend,
unit: unitMap[type],
});
}
return statsList;
}
export async function getTrendData(type: MeasurementType, days: number): Promise<HealthRecord[]> {
const res = await api.get<RawRecord[]>(`/api/health-records?type=${type}&days=${days}`);
return res.data.map(mapRecord).sort((a, b) => a.recordedAt.localeCompare(b.recordedAt));
}

View File

@@ -0,0 +1,96 @@
import { api } from './api-client';
import type { Medication, MedicationAdherence, MedicationRecord } from '@/types';
interface RawMedication {
id: string;
userId: string;
drugName: string;
dosage: string;
frequency: string;
timeSlots: string[];
startDate: string;
endDate?: string | null;
notes?: string | null;
status: string;
createdAt: string;
}
interface RawMedRecord {
id: string;
medicationId: string;
timeSlot: string;
takenAt?: string | null;
isTaken: boolean;
skippedReason?: string | null;
}
function mapMedication(m: RawMedication): Medication {
return {
id: m.id,
userId: m.userId,
drugName: m.drugName,
dosage: m.dosage,
frequency: m.frequency,
timeSlots: m.timeSlots,
startDate: m.startDate,
endDate: m.endDate ?? undefined,
notes: m.notes ?? undefined,
status: m.status as Medication['status'],
createdAt: m.createdAt,
};
}
export async function getMedications(): Promise<Medication[]> {
const res = await api.get<RawMedication[]>('/api/medications');
return res.data.map(mapMedication);
}
export async function addMedication(data: Omit<Medication, 'id' | 'userId' | 'createdAt'>): Promise<Medication> {
const res = await api.post<RawMedication>('/api/medications', {
drugName: data.drugName,
dosage: data.dosage,
frequency: data.frequency,
timeSlots: data.timeSlots,
startDate: data.startDate,
endDate: data.endDate ?? null,
notes: data.notes ?? null,
});
return mapMedication(res.data);
}
export async function updateMedication(id: string, data: Partial<Medication>): Promise<Medication> {
const res = await api.put<RawMedication>(`/api/medications/${id}`, {
drugName: data.drugName,
dosage: data.dosage,
frequency: data.frequency,
timeSlots: data.timeSlots,
notes: data.notes ?? null,
status: data.status,
});
return mapMedication(res.data);
}
export async function deleteMedication(id: string): Promise<void> {
await api.del(`/api/medications/${id}`);
}
export async function markTaken(medicationId: string, slot: string): Promise<void> {
await api.post(`/api/medications/${medicationId}/take`, { timeSlot: slot });
}
export async function getMedicationRecords(medicationId: string): Promise<MedicationRecord[]> {
const res = await api.get<RawMedRecord[]>(`/api/medications/${medicationId}/records`);
return res.data.map((r) => ({
id: r.id,
medicationId: r.medicationId,
timeSlot: r.timeSlot,
takenAt: r.takenAt,
isTaken: r.isTaken,
skippedReason: r.skippedReason,
}));
}
export async function getAdherence(medicationId: string): Promise<MedicationAdherence> {
const res = await api.get<MedicationAdherence>(`/api/medications/${medicationId}/adherence`);
return res.data;
}

View File

@@ -0,0 +1,46 @@
import { api } from './api-client';
import type { Notification } from '@/types';
interface RawNotification {
id: string;
userId: string;
title: string;
content: string;
type: string;
isRead: boolean;
readAt?: string | null;
createdAt: string;
relatedId?: string | null;
}
function mapNotification(n: RawNotification): Notification {
return {
id: n.id,
userId: n.userId,
title: n.title,
content: n.content,
type: n.type as Notification['type'],
isRead: n.isRead,
readAt: n.readAt,
createdAt: n.createdAt,
relatedId: n.relatedId,
};
}
export async function getNotifications(): Promise<Notification[]> {
const res = await api.get<RawNotification[]>('/api/notifications');
return res.data.map(mapNotification).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export async function getUnreadCount(): Promise<number> {
const res = await api.get<{ count: number }>('/api/notifications/unread-count');
return res.data.count;
}
export async function markAsRead(id: string): Promise<void> {
await api.put(`/api/notifications/${id}/read`);
}
export async function markAllAsRead(): Promise<void> {
await api.put('/api/notifications/read-all');
}

View File

@@ -0,0 +1,67 @@
import { api } from './api-client';
import type { Report } from '@/types';
interface RawReport {
id: string;
patientId: string;
title: string;
category: string;
imageUrls: string[];
status: string;
result?: string;
createdAt: string;
interpretedAt?: string;
interpretedBy?: string;
}
function mapReport(r: RawReport): Report {
let result: Report['result'] | undefined;
if (r.result) {
try {
const parsed = JSON.parse(r.result);
result = {
summary: parsed.summary || '',
findings: parsed.findings || [],
suggestions: parsed.suggestions || [],
interpretedAt: r.interpretedAt || '',
interpretedBy: r.interpretedBy || '',
riskLevel: parsed.riskLevel || 'normal',
};
} catch { /* ignore */ }
}
return {
id: r.id,
userId: r.patientId,
title: r.title,
imageUrls: r.imageUrls,
uploadAt: r.createdAt,
status: r.status as Report['status'],
category: r.category,
result,
};
}
export async function getReports(): Promise<Report[]> {
const res = await api.get<RawReport[]>('/api/reports');
return res.data.map(mapReport);
}
export async function uploadReport(data: { title: string; category: string }): Promise<Report> {
const res = await api.post<RawReport>('/api/reports', {
title: data.title,
category: data.category,
});
return mapReport(res.data);
}
export async function getReport(id: string): Promise<Report | undefined> {
const res = await api.get<RawReport>(`/api/reports/${id}`);
return mapReport(res.data);
}
export async function completeInterpretation(id: string): Promise<Report> {
// Backend handles interpretation; we just fetch the updated report
const res = await api.get<RawReport>(`/api/reports/${id}`);
return mapReport(res.data);
}