fix: QA 第二轮修复 — PatientDetail 重构/测试覆盖/id_number 列宽/小程序 URL 规范化
- refactor(web): PatientDetail.tsx 拆分为 4 个子组件(737→334行) - refactor(web): 提取 usePaginatedData hook 消除重复分页状态 - feat(db): patient.id_number varchar(20)→varchar(255) 容纳加密值 - test(health): 添加预约模块集成测试(创建/列表/租户隔离) - test(plugin): 添加 6 个 SQL 注入 sanitize 测试 - fix(miniprogram): 7 个 service 文件 URL 构建规范化(params 对象) - fix(miniprogram): 跨平台字段名对齐(birth_date/start_time/end_time)
This commit is contained in:
@@ -1,8 +1,22 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { useEffect, PropsWithChildren } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import { flushEvents } from './services/analytics';
|
||||
import './app.scss';
|
||||
|
||||
function App({ children }: PropsWithChildren<Record<string, unknown>>) {
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
flushEvents();
|
||||
}, 30000);
|
||||
const onHide = () => { flushEvents(); };
|
||||
Taro.eventCenter.on('appHide', onHide);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
Taro.eventCenter.off('appHide', onHide);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <ErrorBoundary>{children}</ErrorBoundary>;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function ArticleList() {
|
||||
|
||||
useDidShow(() => {
|
||||
fetchData(1);
|
||||
}, [fetchData]);
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
fetchData(1).finally(() => {
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function FollowUpDetail() {
|
||||
trackEvent('followup_submit', { task_id: id });
|
||||
const tmplId = TEMPLATE_IDS.FOLLOWUP_REMINDER;
|
||||
if (tmplId) {
|
||||
try { await Taro.requestSubscribeMessage({ tmplIds: [tmplId] }); } catch { /* 用户拒绝 */ }
|
||||
try { await (Taro.requestSubscribeMessage as any)({ tmplIds: [tmplId] }); } catch { /* 用户拒绝 */ }
|
||||
}
|
||||
setContent('');
|
||||
} catch {
|
||||
@@ -91,7 +91,7 @@ export default function FollowUpDetail() {
|
||||
if (error || !task) {
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<ErrorState message='任务不存在' />
|
||||
<ErrorState text='任务不存在' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { useRouter } from '@tarojs/taro';
|
||||
import { useHealthStore } from '@/stores/health';
|
||||
import TrendChart from '@/components/TrendChart';
|
||||
import './index.scss';
|
||||
|
||||
@@ -29,7 +29,14 @@ export default function Index() {
|
||||
];
|
||||
|
||||
const handleServiceClick = (path: string) => {
|
||||
Taro.navigateTo({ url: path });
|
||||
// tabBar 页面必须使用 switchTab,其他页面用 navigateTo
|
||||
const isTabBar = ['pages/index/index', 'pages/health/index', 'pages/appointment/index', 'pages/article/index', 'pages/profile/index']
|
||||
.some((p) => path.includes(p));
|
||||
if (isTabBar) {
|
||||
Taro.switchTab({ url: path });
|
||||
} else {
|
||||
Taro.navigateTo({ url: path });
|
||||
}
|
||||
};
|
||||
|
||||
const healthItems = [
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.login-scroll {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 60px;
|
||||
padding: 120px 60px 60px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
@@ -69,6 +72,7 @@
|
||||
align-items: flex-start;
|
||||
margin-top: 32px;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, Button, Image } from '@tarojs/components';
|
||||
import { View, Text, Button, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import './index.scss';
|
||||
@@ -16,15 +16,16 @@ export default function Login() {
|
||||
}
|
||||
try {
|
||||
const { code } = await Taro.login();
|
||||
const success = await login(code);
|
||||
if (success) {
|
||||
const result = await login(code);
|
||||
if (result) {
|
||||
Taro.switchTab({ url: '/pages/index/index' });
|
||||
} else {
|
||||
// 未绑定,需要获取手机号(openid 已由 store 缓存到 Storage)
|
||||
setNeedBind(true);
|
||||
Taro.showToast({ title: '请授权手机号完成绑定', icon: 'none' });
|
||||
}
|
||||
} catch {
|
||||
Taro.showToast({ title: '登录失败', icon: 'none' });
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || '登录失败,请重试';
|
||||
Taro.showToast({ title: msg.substring(0, 20), icon: 'none', duration: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,48 +43,50 @@ export default function Login() {
|
||||
if (success) {
|
||||
Taro.switchTab({ url: '/pages/index/index' });
|
||||
} else {
|
||||
Taro.showToast({ title: '绑定失败', icon: 'none' });
|
||||
Taro.showToast({ title: '绑定失败,请重试', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='login-page'>
|
||||
<View className='login-header'>
|
||||
<View className='login-logo'>
|
||||
<Text className='login-logo-text'>+</Text>
|
||||
<ScrollView scrollY className='login-scroll'>
|
||||
<View className='login-page'>
|
||||
<View className='login-header'>
|
||||
<View className='login-logo'>
|
||||
<Text className='login-logo-text'>+</Text>
|
||||
</View>
|
||||
<Text className='login-title'>健康管理</Text>
|
||||
<Text className='login-subtitle'>您的专属健康管家</Text>
|
||||
</View>
|
||||
<Text className='login-title'>健康管理</Text>
|
||||
<Text className='login-subtitle'>您的专属健康管家</Text>
|
||||
</View>
|
||||
|
||||
<View className='login-body'>
|
||||
{!needBind ? (
|
||||
<Button className='login-btn' onClick={handleWechatLogin} loading={loading}>
|
||||
微信一键登录
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className='login-btn'
|
||||
openType='getPhoneNumber'
|
||||
onGetPhoneNumber={handleGetPhone}
|
||||
loading={loading}
|
||||
>
|
||||
授权手机号完成绑定
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='agreement-row'>
|
||||
<View className={`checkbox ${agreed ? 'checked' : ''}`} onClick={() => setAgreed(!agreed)}>
|
||||
{agreed && <Text className='check-mark'>✓</Text>}
|
||||
<View className='login-body'>
|
||||
{!needBind ? (
|
||||
<Button className='login-btn' onClick={handleWechatLogin} loading={loading}>
|
||||
微信一键登录
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className='login-btn'
|
||||
openType='getPhoneNumber'
|
||||
onGetPhoneNumber={handleGetPhone}
|
||||
loading={loading}
|
||||
>
|
||||
授权手机号完成绑定
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='agreement-row'>
|
||||
<View className={`checkbox ${agreed ? 'checked' : ''}`} onClick={() => setAgreed(!agreed)}>
|
||||
{agreed && <Text className='check-mark'>✓</Text>}
|
||||
</View>
|
||||
<Text className='agreement-text'>
|
||||
我已阅读并同意
|
||||
<Text className='agreement-link' onClick={() => Taro.navigateTo({ url: '/pages/legal/user-agreement' })}>《用户服务协议》</Text>
|
||||
和
|
||||
<Text className='agreement-link' onClick={() => Taro.navigateTo({ url: '/pages/legal/privacy-policy' })}>《隐私政策》</Text>
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='agreement-text'>
|
||||
我已阅读并同意
|
||||
<Text className='agreement-link' onClick={() => Taro.navigateTo({ url: '/pages/legal/user-agreement' })}>《用户服务协议》</Text>
|
||||
和
|
||||
<Text className='agreement-link' onClick={() => Taro.navigateTo({ url: '/pages/legal/privacy-policy' })}>《隐私政策》</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useAuthStore } from '../../stores/auth';
|
||||
import './index.scss';
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{ label: '消息中心', icon: '🔔', path: '/pages/profile/messages/index', badge: true },
|
||||
{ label: '就诊人管理', icon: '👥', path: '/pages/profile/family/index' },
|
||||
{ label: '我的报告', icon: '📋', path: '/pages/profile/reports/index' },
|
||||
{ label: '我的随访', icon: '💬', path: '/pages/profile/followups/index' },
|
||||
@@ -14,7 +13,6 @@ const MENU_ITEMS = [
|
||||
|
||||
export default function Profile() {
|
||||
const { user, restore: restoreAuth, logout } = useAuthStore();
|
||||
const unreadCount = 0; // MVP 占位,后续对接 erp-message API
|
||||
|
||||
useDidShow(() => {
|
||||
restoreAuth();
|
||||
@@ -56,11 +54,6 @@ export default function Profile() {
|
||||
>
|
||||
<Text className='menu-icon'>{item.icon}</Text>
|
||||
<Text className='menu-label'>{item.label}</Text>
|
||||
{item.badge && unreadCount > 0 && (
|
||||
<View className='menu-badge'>
|
||||
<Text className='menu-badge-text'>{unreadCount > 99 ? '99+' : unreadCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text className='menu-arrow'>›</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
@@ -29,7 +29,10 @@ export interface DoctorSchedule {
|
||||
}
|
||||
|
||||
export async function listAppointments(page = 1) {
|
||||
return api.get<{ data: Appointment[]; total: number }>(`/health/appointments?page=${page}&page_size=20`);
|
||||
return api.get<{ data: Appointment[]; total: number }>('/health/appointments', {
|
||||
page,
|
||||
page_size: 20,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAppointment(id: string) {
|
||||
@@ -56,17 +59,25 @@ export async function cancelAppointment(id: string, version: number) {
|
||||
}
|
||||
|
||||
export async function getDoctorSchedules(doctorId: string, startDate: string, endDate: string) {
|
||||
return api.get<{ data: DoctorSchedule[]; total: number }>(
|
||||
`/health/doctor-schedules?doctor_id=${doctorId}&start_date=${startDate}&end_date=${endDate}&page_size=50`
|
||||
);
|
||||
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: Doctor[]; total: number }>(`/health/doctors?page_size=100${deptParam}`);
|
||||
return api.get<{ data: Doctor[]; total: number }>('/health/doctors', {
|
||||
page_size: 100,
|
||||
...(department && { department }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function calendarView(startDate: string, endDate: string, doctorId?: string) {
|
||||
const docParam = doctorId ? `&doctor_id=${doctorId}` : '';
|
||||
return api.get<DoctorSchedule[]>(`/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,
|
||||
...(doctorId && { doctor_id: doctorId }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ export interface Article {
|
||||
}
|
||||
|
||||
export async function listArticles(page = 1) {
|
||||
return api.get<{ data: Article[]; total: number }>(`/health/articles?page=${page}&page_size=20`);
|
||||
return api.get<{ data: Article[]; total: number }>('/health/articles', {
|
||||
page,
|
||||
page_size: 20,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getArticleDetail(id: string) {
|
||||
|
||||
@@ -23,10 +23,11 @@ export interface FollowUpRecord {
|
||||
}
|
||||
|
||||
export async function listTasks(status?: string) {
|
||||
const statusParam = status ? `&status=${status}` : '';
|
||||
return api.get<{ data: FollowUpTask[]; total: number }>(
|
||||
`/health/follow-up-tasks?page=1&page_size=50${statusParam}`
|
||||
);
|
||||
return api.get<{ data: FollowUpTask[]; total: number }>('/health/follow-up-tasks', {
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
...(status && { status }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTaskDetail(id: string) {
|
||||
@@ -38,8 +39,9 @@ export async function submitRecord(data: { task_id: string; content: FollowUpCon
|
||||
}
|
||||
|
||||
export async function listRecords(taskId?: string) {
|
||||
const taskParam = taskId ? `&task_id=${taskId}` : '';
|
||||
return api.get<{ data: FollowUpRecord[]; total: number }>(
|
||||
`/health/follow-up-records?page=1&page_size=50${taskParam}`
|
||||
);
|
||||
return api.get<{ data: FollowUpRecord[]; total: number }>('/health/follow-up-records', {
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
...(taskId && { task_id: taskId }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export async function inputVitalSign(patientId: string, data: VitalSignInput) {
|
||||
|
||||
export async function getTrend(indicator: string, range: string) {
|
||||
return api.get<{ indicator: string; data_points: { date: string; value: number }[] }>(
|
||||
`/health/vital-signs/trend?indicator=${indicator}&range=${range}`
|
||||
'/health/vital-signs/trend',
|
||||
{ indicator, range },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ export interface Patient {
|
||||
}
|
||||
|
||||
export async function listPatients() {
|
||||
return api.get<{ data: Patient[]; total: number }>('/health/patients?page=1&page_size=100');
|
||||
return api.get<{ data: Patient[]; total: number }>('/health/patients', {
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPatient(data: {
|
||||
|
||||
@@ -20,7 +20,8 @@ export interface LabReport {
|
||||
|
||||
export async function listReports(patientId: string, page = 1) {
|
||||
return api.get<{ data: LabReport[]; total: number }>(
|
||||
`/health/patients/${patientId}/lab-reports?page=${page}&page_size=20`
|
||||
`/health/patients/${patientId}/lab-reports`,
|
||||
{ page, page_size: 20 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import Taro from '@tarojs/taro';
|
||||
import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage';
|
||||
|
||||
const BASE_URL = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1';
|
||||
const IS_DEV = process.env.NODE_ENV !== 'production';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
@@ -44,12 +45,23 @@ async function tryRefreshToken(): Promise<boolean> {
|
||||
|
||||
export async function request<T>(method: string, path: string, data?: unknown): Promise<T> {
|
||||
const headers = await getHeaders();
|
||||
const res = await Taro.request({ url: `${BASE_URL}${path}`, method, data, header: headers });
|
||||
const url = `${BASE_URL}${path}`;
|
||||
if (IS_DEV) {
|
||||
console.log(`[API] ${method} ${path}`, data ?? '');
|
||||
}
|
||||
const res = await Taro.request({ url, method: method as any, data, header: headers, timeout: 30000 });
|
||||
if (IS_DEV) {
|
||||
console.log(`[API] ${method} ${path} → ${res.statusCode}`);
|
||||
}
|
||||
|
||||
if (res.statusCode === 401) {
|
||||
const refreshed = await tryRefreshToken();
|
||||
if (refreshed) return request<T>(method, path, data);
|
||||
Taro.redirectTo({ url: '/pages/login/index' });
|
||||
const pages = Taro.getCurrentPages();
|
||||
const currentPath = pages[pages.length - 1]?.path || '';
|
||||
if (!currentPath.includes('pages/login')) {
|
||||
Taro.redirectTo({ url: '/pages/login/index' });
|
||||
}
|
||||
throw new Error('登录已过期');
|
||||
}
|
||||
|
||||
@@ -58,8 +70,20 @@ export async function request<T>(method: string, path: string, data?: unknown):
|
||||
return body.data as T;
|
||||
}
|
||||
|
||||
function buildQuery(params?: Record<string, string | number | undefined>): string {
|
||||
if (!params) return '';
|
||||
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== '');
|
||||
return entries.length > 0
|
||||
? '?' +
|
||||
entries
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||
.join('&')
|
||||
: '';
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
get: <T>(path: string, params?: Record<string, string | number | undefined>) =>
|
||||
request<T>('GET', `${path}${buildQuery(params)}`),
|
||||
post: <T>(path: string, data?: unknown) => request<T>('POST', path, data),
|
||||
put: <T>(path: string, data?: unknown) => request<T>('PUT', path, data),
|
||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||
|
||||
Reference in New Issue
Block a user