fix(miniprogram): 审计修复 — P0/P1 共 16 个问题
P0 功能阻断: - 修复 login→bindPhone openid 状态传递断裂 - 首页健康卡片对接 useHealthStore 真实数据 - 血压录入改为收缩压/舒张压双输入 - 快捷服务路径修正(报告→/pages/report、随访→/pages/followup) P1 类型安全 + 组件: - 替换所有 <input>/<image>/<textarea> 为 Taro 组件 - service 层 any 类型全部替换(Doctor/DoctorSchedule/IndicatorDetail/FollowUpContent/PatientUpdateInput) - 预约详情数据传递简化为纯 Storage 缓存 - Article 接口添加 author 字段
This commit is contained in:
@@ -17,28 +17,9 @@ export default function AppointmentDetail() {
|
||||
const id = router.params.id || '';
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
|
||||
// 从页面参数或全局缓存获取预约数据
|
||||
const encodedData = router.params.data || '';
|
||||
let appointment: Appointment | null = null;
|
||||
try {
|
||||
if (encodedData) {
|
||||
appointment = JSON.parse(decodeURIComponent(encodedData));
|
||||
}
|
||||
} catch {
|
||||
// 解析失败则尝试从 Storage 获取
|
||||
const cached = Taro.getStorageSync('appointment_detail_cache');
|
||||
if (cached && cached.id === id) {
|
||||
appointment = cached;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有传数据,尝试从缓存获取
|
||||
if (!appointment) {
|
||||
const cached = Taro.getStorageSync('appointment_detail_cache');
|
||||
if (cached && cached.id === id) {
|
||||
appointment = cached;
|
||||
}
|
||||
}
|
||||
// 从缓存获取预约数据
|
||||
const cached = Taro.getStorageSync('appointment_detail_cache');
|
||||
const appointment: Appointment | null = (cached && cached.id === id) ? cached : null;
|
||||
|
||||
const status = appointment ? (STATUS_MAP[appointment.status] || { label: appointment.status, className: 'tag-pending' }) : { label: '未知', className: 'tag-pending' };
|
||||
const canCancel = appointment && (appointment.status === 'pending' || appointment.status === 'confirmed');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro';
|
||||
import { listArticles, Article } from '../../services/article';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
@@ -74,7 +74,7 @@ export default function ArticleList() {
|
||||
</View>
|
||||
{a.cover_image && (
|
||||
<View className='article-card-cover'>
|
||||
<image className='cover-img' src={a.cover_image} mode='aspectFill' />
|
||||
<Image className='cover-img' src={a.cover_image} mode='aspectFill' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { View, Text, Textarea } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { listTasks, submitRecord, FollowUpTask } from '../../../services/followup';
|
||||
import './index.scss';
|
||||
@@ -97,7 +97,7 @@ export default function FollowUpDetail() {
|
||||
{!isCompleted && (
|
||||
<View className='submit-card'>
|
||||
<Text className='section-title'>填写随访记录</Text>
|
||||
<textarea
|
||||
<Textarea
|
||||
className='submit-textarea'
|
||||
placeholder='请输入随访内容...'
|
||||
value={content}
|
||||
|
||||
@@ -17,27 +17,46 @@ const INDICATORS = [
|
||||
export default function HealthInput() {
|
||||
const [indicatorIdx, setIndicatorIdx] = useState(0);
|
||||
const [value, setValue] = useState('');
|
||||
const [systolic, setSystolic] = useState('');
|
||||
const [diastolic, setDiastolic] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { currentPatient } = useAuthStore();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!value) {
|
||||
Taro.showToast({ title: '请输入数值', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!currentPatient) {
|
||||
Taro.showToast({ title: '请先选择就诊人', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndicator = INDICATORS[indicatorIdx].value;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await inputVitalSign(currentPatient.id, {
|
||||
indicator_type: INDICATORS[indicatorIdx].value,
|
||||
value: parseFloat(value),
|
||||
note: note || undefined,
|
||||
});
|
||||
if (currentIndicator === 'blood_pressure') {
|
||||
if (!systolic || !diastolic) {
|
||||
Taro.showToast({ title: '请输入收缩压和舒张压', icon: 'none' });
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
await inputVitalSign(currentPatient.id, {
|
||||
indicator_type: 'blood_pressure',
|
||||
value: parseFloat(systolic),
|
||||
extra: { systolic: parseFloat(systolic), diastolic: parseFloat(diastolic) },
|
||||
note: note || undefined,
|
||||
});
|
||||
} else {
|
||||
if (!value) {
|
||||
Taro.showToast({ title: '请输入数值', icon: 'none' });
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
await inputVitalSign(currentPatient.id, {
|
||||
indicator_type: currentIndicator,
|
||||
value: parseFloat(value),
|
||||
note: note || undefined,
|
||||
});
|
||||
}
|
||||
Taro.showToast({ title: '录入成功', icon: 'success' });
|
||||
setTimeout(() => Taro.navigateBack(), 1000);
|
||||
} catch (e: any) {
|
||||
@@ -64,16 +83,41 @@ export default function HealthInput() {
|
||||
</Picker>
|
||||
</View>
|
||||
|
||||
<View className='input-section'>
|
||||
<Text className='input-label'>数值</Text>
|
||||
<Input
|
||||
type='digit'
|
||||
className='input-field'
|
||||
placeholder='请输入数值'
|
||||
value={value}
|
||||
onInput={(e) => setValue(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
{INDICATORS[indicatorIdx].value === 'blood_pressure' ? (
|
||||
<>
|
||||
<View className='input-section'>
|
||||
<Text className='input-label'>收缩压</Text>
|
||||
<Input
|
||||
type='digit'
|
||||
className='input-field'
|
||||
placeholder='如 120'
|
||||
value={systolic}
|
||||
onInput={(e) => setSystolic(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className='input-section'>
|
||||
<Text className='input-label'>舒张压</Text>
|
||||
<Input
|
||||
type='digit'
|
||||
className='input-field'
|
||||
placeholder='如 80'
|
||||
value={diastolic}
|
||||
onInput={(e) => setDiastolic(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View className='input-section'>
|
||||
<Text className='input-label'>数值</Text>
|
||||
<Input
|
||||
type='digit'
|
||||
className='input-field'
|
||||
placeholder='请输入数值'
|
||||
value={value}
|
||||
onInput={(e) => setValue(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='input-section'>
|
||||
<Text className='input-label'>备注(可选)</Text>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useHealthStore } from '../../stores/health';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import './index.scss';
|
||||
|
||||
export default function Index() {
|
||||
const { user, restore } = useAuthStore();
|
||||
const { todaySummary, refreshToday } = useHealthStore();
|
||||
|
||||
useDidShow(() => {
|
||||
restore();
|
||||
refreshToday();
|
||||
});
|
||||
|
||||
const s = todaySummary || {};
|
||||
|
||||
const greeting = () => {
|
||||
const h = new Date().getHours();
|
||||
if (h < 6) return '凌晨好';
|
||||
@@ -38,10 +43,10 @@ export default function Index() {
|
||||
<Text className='section-title'>今日健康</Text>
|
||||
<View className='health-grid'>
|
||||
{[
|
||||
{ label: '血压', value: '--/--', unit: 'mmHg', status: '' },
|
||||
{ label: '心率', value: '--', unit: 'bpm', status: '' },
|
||||
{ label: '血糖', value: '--', unit: 'mmol/L', status: '' },
|
||||
{ label: '体重', value: '--', unit: 'kg', status: '' },
|
||||
{ label: '血压', value: s.blood_pressure ? `${s.blood_pressure.systolic}/${s.blood_pressure.diastolic}` : '--/--', unit: 'mmHg' },
|
||||
{ label: '心率', value: s.heart_rate ? `${s.heart_rate.value}` : '--', unit: 'bpm' },
|
||||
{ label: '血糖', value: s.blood_sugar ? `${s.blood_sugar.value}` : '--', unit: 'mmol/L' },
|
||||
{ label: '体重', value: s.weight ? `${s.weight.value}` : '--', unit: 'kg' },
|
||||
].map((item) => (
|
||||
<View className='health-item' key={item.label}>
|
||||
<Text className='health-label'>{item.label}</Text>
|
||||
@@ -57,15 +62,21 @@ export default function Index() {
|
||||
<Text className='section-title'>快捷服务</Text>
|
||||
<View className='service-grid'>
|
||||
{[
|
||||
{ label: '录数据', icon: '📝', path: '/pages/health/index' },
|
||||
{ label: '预约', icon: '📅', path: '/pages/appointment/index' },
|
||||
{ label: '报告', icon: '📋', path: '/pages/profile/index' },
|
||||
{ label: '随访', icon: '💬', path: '/pages/profile/index' },
|
||||
{ label: '录数据', icon: '📝', path: '/pages/health/index', isTab: true },
|
||||
{ label: '预约', icon: '📅', path: '/pages/appointment/index', isTab: true },
|
||||
{ label: '报告', icon: '📋', path: '/pages/report/index', isTab: false },
|
||||
{ label: '随访', icon: '💬', path: '/pages/followup/index', isTab: false },
|
||||
].map((item) => (
|
||||
<View
|
||||
className='service-item'
|
||||
key={item.label}
|
||||
onClick={() => Taro.switchTab({ url: item.path })}
|
||||
onClick={() => {
|
||||
if (item.isTab) {
|
||||
Taro.switchTab({ url: item.path });
|
||||
} else {
|
||||
Taro.navigateTo({ url: item.path });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text className='service-icon'>{item.icon}</Text>
|
||||
<Text className='service-label'>{item.label}</Text>
|
||||
|
||||
@@ -6,7 +6,6 @@ import './index.scss';
|
||||
|
||||
export default function Login() {
|
||||
const [needBind, setNeedBind] = useState(false);
|
||||
const [openid, setOpenid] = useState('');
|
||||
const { login, bindPhone, loading } = useAuthStore();
|
||||
|
||||
const handleWechatLogin = async () => {
|
||||
@@ -16,11 +15,10 @@ export default function Login() {
|
||||
if (success) {
|
||||
Taro.switchTab({ url: '/pages/index/index' });
|
||||
} else {
|
||||
// 未绑定,需要获取手机号
|
||||
// 未绑定,需要获取手机号(openid 已由 store 缓存到 Storage)
|
||||
setNeedBind(true);
|
||||
// 从最近的登录响应获取 openid(简化处理)
|
||||
}
|
||||
} catch (e: any) {
|
||||
} catch {
|
||||
Taro.showToast({ title: '登录失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
@@ -31,7 +29,7 @@ export default function Login() {
|
||||
return;
|
||||
}
|
||||
const { encryptedData, iv } = e.detail;
|
||||
const success = await bindPhone(openid, encryptedData, iv);
|
||||
const success = await bindPhone(encryptedData, iv);
|
||||
if (success) {
|
||||
Taro.switchTab({ url: '/pages/index/index' });
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { View, Text, Input, Picker } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Picker } from '@tarojs/components';
|
||||
import { createPatient } from '../../../services/patient';
|
||||
import './index.scss';
|
||||
|
||||
@@ -44,7 +43,7 @@ export default function FamilyAdd() {
|
||||
{/* 姓名 */}
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>姓名</Text>
|
||||
<input
|
||||
<Input
|
||||
className='form-input'
|
||||
placeholder='请输入姓名'
|
||||
value={name}
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useState, useCallback } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { listTasks, FollowUpTask } from '../../../services/followup';
|
||||
import EmptyState from '../../../components/EmptyState';
|
||||
import Loading from '../../../components/Loading';
|
||||
import './index.scss';
|
||||
|
||||
const TABS = [
|
||||
@@ -87,18 +89,14 @@ export default function MyFollowUps() {
|
||||
</View>
|
||||
|
||||
{tasks.length === 0 && !loading && (
|
||||
<View className='empty-state'>
|
||||
<Text className='empty-text'>暂无{(() => {
|
||||
const tab = TABS.find((t) => t.key === activeTab);
|
||||
return tab ? tab.label : '';
|
||||
})()}任务</Text>
|
||||
</View>
|
||||
<EmptyState text={`暂无${(() => {
|
||||
const tab = TABS.find((t) => t.key === activeTab);
|
||||
return tab ? tab.label : '';
|
||||
})()}任务`} />
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<View className='loading-hint'>
|
||||
<Text className='loading-text'>加载中...</Text>
|
||||
</View>
|
||||
<Loading />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { View, Text, Input } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import EmptyState from '../../../components/EmptyState';
|
||||
import './index.scss';
|
||||
|
||||
interface MedicationReminder {
|
||||
@@ -105,9 +106,7 @@ export default function MedicationReminder() {
|
||||
</View>
|
||||
|
||||
{reminders.length === 0 && (
|
||||
<View className='empty-state'>
|
||||
<Text className='empty-text'>暂无用药提醒</Text>
|
||||
</View>
|
||||
<EmptyState text='暂无用药提醒' />
|
||||
)}
|
||||
|
||||
{/* 添加表单 */}
|
||||
@@ -115,7 +114,7 @@ export default function MedicationReminder() {
|
||||
<View className='form-card'>
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>药品名称</Text>
|
||||
<input
|
||||
<Input
|
||||
className='form-input'
|
||||
placeholder='请输入药品名称'
|
||||
value={formName}
|
||||
@@ -124,7 +123,7 @@ export default function MedicationReminder() {
|
||||
</View>
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>剂量</Text>
|
||||
<input
|
||||
<Input
|
||||
className='form-input'
|
||||
placeholder='如:1片、10ml'
|
||||
value={formDosage}
|
||||
|
||||
@@ -11,6 +11,21 @@ export interface Appointment {
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface Doctor {
|
||||
id: string;
|
||||
name: string;
|
||||
department: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface DoctorSchedule {
|
||||
id: string;
|
||||
doctor_id: string;
|
||||
date: string;
|
||||
time_slot: string;
|
||||
available_count: number;
|
||||
}
|
||||
|
||||
export async function listAppointments(page = 1) {
|
||||
return api.get<{ data: Appointment[]; total: number }>(`/health/appointments?page=${page}&page_size=20`);
|
||||
}
|
||||
@@ -34,17 +49,17 @@ export async function cancelAppointment(id: string, version: number) {
|
||||
}
|
||||
|
||||
export async function getDoctorSchedules(doctorId: string, startDate: string, endDate: string) {
|
||||
return api.get<{ data: any[]; total: number }>(
|
||||
return api.get<{ data: DoctorSchedule[]; total: number }>(
|
||||
`/health/doctor-schedules?doctor_id=${doctorId}&start_date=${startDate}&end_date=${endDate}&page_size=50`
|
||||
);
|
||||
}
|
||||
|
||||
export async function listDoctors(department?: string) {
|
||||
const deptParam = department ? `&department=${department}` : '';
|
||||
return api.get<{ data: any[]; total: number }>(`/health/doctors?page_size=100${deptParam}`);
|
||||
return api.get<{ data: Doctor[]; total: number }>(`/health/doctors?page_size=100${deptParam}`);
|
||||
}
|
||||
|
||||
export async function calendarView(startDate: string, endDate: string, doctorId?: string) {
|
||||
const docParam = doctorId ? `&doctor_id=${doctorId}` : '';
|
||||
return api.get<any[]>(`/health/doctor-schedules/calendar?start_date=${startDate}&end_date=${endDate}${docParam}`);
|
||||
return api.get<DoctorSchedule[]>(`/health/doctor-schedules/calendar?start_date=${startDate}&end_date=${endDate}${docParam}`);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Article {
|
||||
cover_image?: string;
|
||||
category?: string;
|
||||
published_at?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
export async function listArticles(page = 1) {
|
||||
|
||||
@@ -10,10 +10,15 @@ export interface FollowUpTask {
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface FollowUpContent {
|
||||
text: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface FollowUpRecord {
|
||||
id: string;
|
||||
task_id: string;
|
||||
content: any;
|
||||
content: FollowUpContent;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -24,7 +29,7 @@ export async function listTasks(status?: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitRecord(data: { task_id: string; content: any }) {
|
||||
export async function submitRecord(data: { task_id: string; content: FollowUpContent }) {
|
||||
return api.post<FollowUpRecord>('/health/follow-up-records', data);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface VitalSignInput {
|
||||
value: number;
|
||||
measured_at?: string;
|
||||
note?: string;
|
||||
extra?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface TodaySummary {
|
||||
|
||||
@@ -25,6 +25,15 @@ export async function createPatient(data: {
|
||||
return api.post<Patient>('/health/patients', data);
|
||||
}
|
||||
|
||||
export async function updatePatient(id: string, data: any, version: number) {
|
||||
export interface PatientUpdateInput {
|
||||
name?: string;
|
||||
gender?: string;
|
||||
birthday?: string;
|
||||
phone?: string;
|
||||
id_number?: string;
|
||||
relation?: string;
|
||||
}
|
||||
|
||||
export async function updatePatient(id: string, data: PatientUpdateInput, version: number) {
|
||||
return api.put<Patient>(`/health/patients/${id}`, { ...data, version });
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { api } from './request';
|
||||
|
||||
export interface IndicatorDetail {
|
||||
value: number;
|
||||
unit?: string;
|
||||
reference_min?: number;
|
||||
reference_max?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface LabReport {
|
||||
id: string;
|
||||
report_date: string;
|
||||
report_type: string;
|
||||
indicators: any;
|
||||
indicators: Record<string, IndicatorDetail>;
|
||||
doctor_interpretation?: string;
|
||||
image_urls?: string[];
|
||||
version: number;
|
||||
|
||||
@@ -2,6 +2,12 @@ import { create } from 'zustand';
|
||||
import Taro from '@tarojs/taro';
|
||||
import * as authApi from '../services/auth';
|
||||
|
||||
interface BindPhoneResp {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
user: { id: string; username: string; display_name?: string; phone?: string; tenant_id?: string };
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
@@ -11,7 +17,7 @@ interface AuthState {
|
||||
loading: boolean;
|
||||
|
||||
login: (code: string) => Promise<boolean>;
|
||||
bindPhone: (openid: string, encryptedData: string, iv: string) => Promise<boolean>;
|
||||
bindPhone: (encryptedData: string, iv: string) => Promise<boolean>;
|
||||
setCurrentPatient: (patient: authApi.PatientInfo) => void;
|
||||
loadPatients: () => Promise<void>;
|
||||
logout: () => void;
|
||||
@@ -47,6 +53,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
set({ token: access_token, refreshToken: refresh_token, user, loading: false });
|
||||
return true;
|
||||
}
|
||||
// 未绑定手机号,缓存 openid 供后续 bindPhone 使用
|
||||
Taro.setStorageSync('wechat_openid', resp.openid);
|
||||
set({ loading: false });
|
||||
return false;
|
||||
} catch {
|
||||
@@ -55,14 +63,21 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
bindPhone: async (openid: string, encryptedData: string, iv: string) => {
|
||||
bindPhone: async (encryptedData: string, iv: string) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const resp: any = await authApi.wechatBindPhone(openid, encryptedData, iv);
|
||||
const openid = Taro.getStorageSync('wechat_openid') || '';
|
||||
if (!openid) {
|
||||
set({ loading: false });
|
||||
return false;
|
||||
}
|
||||
const resp = await authApi.wechatBindPhone(openid, encryptedData, iv) as BindPhoneResp;
|
||||
const { access_token, refresh_token, user } = resp;
|
||||
Taro.setStorageSync('access_token', access_token);
|
||||
Taro.setStorageSync('refresh_token', refresh_token);
|
||||
Taro.setStorageSync('user', user);
|
||||
Taro.setStorageSync('tenant_id', user.tenant_id || '');
|
||||
Taro.removeStorageSync('wechat_openid');
|
||||
set({ token: access_token, refreshToken: refresh_token, user, loading: false });
|
||||
return true;
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user