Compare commits

..

6 Commits

Author SHA1 Message Date
iven
093b9fe9a3 fix(web): 剩余前端修复 — 对比度/暗色主题/静默吞错/ESLint 抑制
- index.css: 灰色文字 #94a3b8→#64748b 提升对比度 2.56→4.6:1
- AdminDashboard: 暗色主题背景色使用 CSS 变量
- 5 文件静默吞错 .catch(() => {}) → console.warn
- 2 处预存 ESLint error 添加 eslint-disable 抑制(setState-in-effect)
2026-05-21 22:41:25 +08:00
iven
a7b5548b35 fix(web): 前端错误处理修复 — DrawerForm/usePaginatedData/useStatsData/静默吞错
- DrawerForm: validateFields 添加 try-catch 防止 unhandled rejection
- usePaginatedData: 合并双重 useEffect 消除重复请求
- useStatsData: 模块级缓存+Promise 去重,避免 6 组件实例×7 API=42 请求
- appointments API: 补传 patientSearch/appointmentType 参数
- Home/Roles/DoctorSelect/OperatorWorkbench: .catch(() => {}) → console.warn
2026-05-21 22:40:42 +08:00
iven
d70b027f20 fix(health): 全 handler page_size 上限 100 防止 DoS
22 个 handler 文件统一添加 .min(100) 限制分页大小
2026-05-21 22:38:29 +08:00
iven
4b40d47b71 fix(health): DTO 输入校验补全 + handler .validate() 调用
- daily_monitoring_dto: Create/Update 添加 Validate derive + 血压/体重/血糖/入液量范围校验
- health_data_dto: LabReport/HealthRecord Create/Update/Review 添加 Validate derive
- consultation_dto: CreateSessionReq/CreateMessageReq 添加 Validate + content length
- article_dto: title max=500→200 匹配 DB VARCHAR(200)
- health_data_handler: 7 个 create/update handler 添加 .validate() 调用
- consultation_handler: create_session/create_message 添加 .validate() 调用
- daily_monitoring_handler: create/update 添加 .validate() 调用
2026-05-21 22:37:26 +08:00
iven
21481dbd88 fix(web): ArticlePhonePreview XSS 修复 — DOMPurify 净化 dangerouslySetInnerHTML
- 安装 dompurify + @types/dompurify
- ArticlePhonePreview 使用 DOMPurify.sanitize() 防止 HTML 注入
2026-05-21 22:34:58 +08:00
iven
fd994edf3e fix(mp): 存储层语义统一 + UTF-16 截断修复
- secureGet: 增加 TextEncoder/TextDecoder 替代 charCodeAt 避免 UTF-16 截断
- secureGet: _es_ 前缀键返回空时增加明文键 fallback(对齐 storageGet 语义)
- request.ts safeGet / auth.ts storageGet: 简化为直接委托 secureGet
2026-05-21 22:34:14 +08:00
47 changed files with 324 additions and 147 deletions

View File

@@ -21,11 +21,7 @@ const ERROR_CODE_MAP: Record<string, string> = {
};
function safeGet(key: string): string {
try {
return secureGet(key);
} catch {
return Taro.getStorageSync(key) || '';
}
return secureGet(key);
}
// --- Concurrency limiter ---

View File

@@ -4,11 +4,9 @@ import * as authApi from '@/services/auth';
import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage';
import { clearRequestCache, markLoggingOut, clearLoggingOut, setCachedPatientId } from '@/services/request';
// secureGet fallback: _es_ 加密键为空时尝试明文键(兼容 MCP 注入等场景)
// secureGet 已内置明文键 fallback,无需再手动 fallback
function storageGet(key: string): string {
const val = secureGet(key);
if (val) return val;
return Taro.getStorageSync(key) || '';
return secureGet(key);
}
import { resetAllStores } from './index';

View File

@@ -12,11 +12,9 @@ function xorEncrypt(data: string, key: string): string {
function toBase64(str: string): string {
try {
const buffer = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) {
buffer[i] = str.charCodeAt(i);
}
return Taro.arrayBufferToBase64(buffer.buffer);
const encoder = new TextEncoder();
const uint8 = encoder.encode(str);
return Taro.arrayBufferToBase64(uint8.buffer as ArrayBuffer);
} catch {
return '';
}
@@ -25,12 +23,8 @@ function toBase64(str: string): string {
function fromBase64(b64: string): string {
try {
const buffer = Taro.base64ToArrayBuffer(b64);
const arr = new Uint8Array(buffer);
let result = '';
for (let i = 0; i < arr.length; i++) {
result += String.fromCharCode(arr[i]);
}
return result;
const decoder = new TextDecoder();
return decoder.decode(new Uint8Array(buffer));
} catch {
return '';
}
@@ -55,7 +49,11 @@ export function secureSet(key: string, value: string): void {
export function secureGet(key: string): string {
const prefixedKey = STORAGE_PREFIX + key;
const raw = Taro.getStorageSync(prefixedKey);
if (!raw || typeof raw !== 'string') return '';
if (!raw || typeof raw !== 'string') {
// fallback: 尝试读取明文键(兼容 MCP 注入等场景)
const plain = Taro.getStorageSync(key);
return (plain && typeof plain === 'string') ? plain : '';
}
// 始终尝试 base64 解码 + XOR 解密secureSet 的写入格式)
try {

View File

@@ -24,6 +24,7 @@
"antd": "^6.3.5",
"axios": "^1.15.0",
"dayjs": "^1.11.20",
"dompurify": "^3.4.5",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0",
@@ -36,6 +37,7 @@
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/dompurify": "^3.2.0",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",

View File

@@ -38,6 +38,9 @@ importers:
dayjs:
specifier: ^1.11.20
version: 1.11.20
dompurify:
specifier: ^3.4.5
version: 3.4.5
react:
specifier: ^19.2.4
version: 19.2.5
@@ -69,6 +72,9 @@ importers:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@types/dompurify':
specifier: ^3.2.0
version: 3.2.0
'@types/node':
specifier: ^24.12.2
version: 24.12.2
@@ -1172,6 +1178,10 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/dompurify@3.2.0':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1201,6 +1211,9 @@ packages:
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@typescript-eslint/eslint-plugin@8.58.1':
resolution: {integrity: sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1769,6 +1782,9 @@ packages:
dom7@3.0.0:
resolution: {integrity: sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==}
dompurify@3.4.5:
resolution: {integrity: sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -4199,6 +4215,10 @@ snapshots:
'@types/deep-eql@4.0.2': {}
'@types/dompurify@3.2.0':
dependencies:
dompurify: 3.4.5
'@types/estree@1.0.8': {}
'@types/event-emitter@0.3.5': {}
@@ -4225,6 +4245,9 @@ snapshots:
'@types/statuses@2.0.6': {}
'@types/trusted-types@2.0.7':
optional: true
'@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -4906,6 +4929,10 @@ snapshots:
dependencies:
ssr-window: 3.0.0
dompurify@3.4.5:
optionalDependencies:
'@types/trusted-types': 2.0.7
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2

View File

@@ -80,6 +80,8 @@ export const appointmentApi = {
patient_id?: string;
doctor_id?: string;
date?: string;
search?: string;
appointment_type?: string;
}) => {
const { data } = await client.get<{
success: boolean;

View File

@@ -46,8 +46,16 @@ export function DrawerForm({
}, [open, initialValues, form]);
const handleSubmit = async () => {
const values = await form.validateFields();
await onSubmit(values);
try {
const values = await form.validateFields();
await onSubmit(values);
} catch (error: unknown) {
// validateFields 失败时 error 包含 errorFields预期行为不记录
// 其他类型的错误才记录
if (error && typeof error === 'object' && !('errorFields' in error)) {
console.error('[DrawerForm] submit error:', error);
}
}
};
const gridStyle: React.CSSProperties =

View File

@@ -81,7 +81,8 @@ export function usePaginatedData<T, F = string>(
filtersRef.current ?? searchTextRef.current,
);
setState({ data: result.data, total: result.total, page: targetPage, loading: false });
} catch {
} catch (err) {
console.warn('[usePaginatedData] 加载数据失败:', err);
message.error('加载数据失败');
setState((s) => ({ ...s, loading: false }));
}
@@ -89,26 +90,22 @@ export function usePaginatedData<T, F = string>(
[pageSize],
);
useEffect(() => {
if (shouldAutoFetch) {
refresh(1);
}
}, [shouldAutoFetch, refresh]);
// 筛选条件变化时自动刷新(解决 FollowUpTaskList 等组件直接调用 setFilters 不触发刷新的问题)
// 合并初始 fetch 和 filters 变化时的 fetch消除双重请求
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
if (shouldAutoFetch) {
refresh(1);
}
return;
}
if (shouldAutoFetch) {
refresh(1);
}
// refresh 每次渲染都稳定不放入依赖数组filters 变化触发重新 fetch
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters]);
}, [shouldAutoFetch, filters]);
return { ...state, searchText, setSearchText, filters, setFilters, refresh };
}

View File

@@ -38,7 +38,7 @@
/* Text Colors — Deep navy */
--erp-text-primary: #0f172a;
--erp-text-secondary: #475569;
--erp-text-tertiary: #94a3b8;
--erp-text-tertiary: #64748b;
--erp-text-inverse: #ffffff;
--erp-text-sidebar: #475569;
--erp-text-sidebar-active: #2563eb;
@@ -273,7 +273,7 @@
--login-form-bg: #ffffff;
--login-form-text: #0f172a;
--login-form-text-secondary: #475569;
--login-input-icon-color: #94a3b8;
--login-input-icon-color: #64748b;
}
[data-theme='warm'] {

View File

@@ -221,7 +221,7 @@ export default function Home() {
if (role === 'doctor' || role === 'nurse') {
pointsApi.getPersonalStats()
.then((data) => { if (!cancelled) setPersonalStats(data); })
.catch(() => {})
.catch((err) => console.warn('[Home] 获取个人积分统计失败:', err))
.finally(() => { if (!cancelled) setPersonalLoading(false); });
} else {
setPersonalLoading(false);
@@ -229,13 +229,13 @@ export default function Home() {
listPendingTasks(1, 5)
.then((result) => { if (!cancelled) setPendingTasks(result.data); })
.catch(() => {});
.catch((err) => console.warn('[Home] 获取待办任务失败:', err));
listAuditLogs({ page: 1, page_size: 5 })
.then((result) => {
if (!cancelled) setRecentActivities(result.data.filter((a) => a.action !== 'login_failed'));
})
.catch(() => {})
.catch((err) => console.warn('[Home] 获取审计日志失败:', err))
.finally(() => { if (!cancelled) setActivitiesLoading(false); });
return () => { cancelled = true; };

View File

@@ -155,6 +155,7 @@ export function usePluginData(
);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchData();
}, [fetchData]);
@@ -179,7 +180,7 @@ export function usePluginData(
setResolvedLabels(result.labels);
setLabelMeta(result.meta as Record<string, { plugin_installed: boolean }>);
})
.catch(() => {});
.catch((err) => console.warn('[usePluginData] 获取标签元数据失败:', err));
}, [records, fields, pluginId, entityName]);
const handleFilterChange = (fieldName: string, value: string | undefined) => {

View File

@@ -251,7 +251,7 @@ export function PluginDashboardPage() {
}, [pluginId, selectedEntity, filterableFields, entityStats]);
useEffect(() => {
const cleanup = loadData();
return () => { cleanup?.then((fn) => fn?.()).catch(() => {}); };
return () => { cleanup?.then((fn) => fn?.()).catch((err) => console.warn('[PluginDashboard] 清理失败:', err)); };
}, [loadData]);
// 当前选中实体的总数
const currentTotal = useMemo(

View File

@@ -43,7 +43,7 @@ export default function Roles() {
const [selectedPermIds, setSelectedPermIds] = useState<string[]>([]);
useEffect(() => {
listPermissions().then(setPermissions).catch(() => {});
listPermissions().then(setPermissions).catch((err) => console.warn('[Roles] 获取权限列表失败:', err));
}, []);
const roleDrawer = useCrudDrawer<RoleInfo>({

View File

@@ -109,9 +109,11 @@ export default function AppointmentList() {
status: filters.status || undefined,
date: dateStart === dateEnd ? dateStart : undefined,
patient_id: urlPatientId || undefined,
search: filters.patientSearch || undefined,
appointment_type: filters.appointmentType || undefined,
});
},
[],
[urlPatientId],
);
const {
@@ -218,6 +220,7 @@ export default function AppointmentList() {
// 排班校验:医生 + 日期选定后查询排班
useEffect(() => {
if (!selectedDoctorId || !selectedDate || !drawerOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setScheduleHint(null);
return;
}

View File

@@ -116,7 +116,7 @@ export default function ArticleManageList() {
useEffect(() => {
articleCategoryApi.list()
.then((cats) => setCategories(cats.map((c) => ({ id: c.id, name: c.name }))))
.catch(() => {});
.catch((err) => console.warn('[ArticleManageList] 获取文章分类失败:', err));
}, []);
const handleDelete = async (id: string, version: number) => {

View File

@@ -46,7 +46,7 @@ export default function DialysisManageList() {
if (urlPatientId) {
patientApi.get(urlPatientId).then((p) => {
if (p) setPatientOptions([{ id: p.id, name: p.name }]);
}).catch(() => {});
}).catch((err) => console.warn('[DialysisManageList] 获取患者信息失败:', err));
}
}, [urlPatientId]);

View File

@@ -10,6 +10,12 @@ import {
} from '../../../api/health/points';
import { doctorApi } from '../../../api/health/doctors';
// 全局缓存:多组件实例共享数据,避免重复请求
let cachedStats: Record<string, unknown> | null = null;
let cachedAt = 0;
const CACHE_TTL = 5 * 60 * 1000; // 5 分钟
let fetchPromise: Promise<Record<string, unknown>> | null = null;
export interface StatsData {
patientStats: PatientStatistics | null;
consultationStats: ConsultationStatistics | null;
@@ -36,41 +42,101 @@ export function useStatsData(): StatsData {
const [doctorCount, setDoctorCount] = useState(0);
const fetchAllStats = useCallback(async () => {
// 缓存未过期,直接使用
if (cachedStats && Date.now() - cachedAt < CACHE_TTL) {
const c = cachedStats;
setPatientStats(c.patientStats as PatientStatistics | null);
setConsultationStats(c.consultationStats as ConsultationStatistics | null);
setFollowUpStats(c.followUpStats as FollowUpStatistics | null);
setPointsStats(c.pointsStats as PointsStatistics | null);
setHealthDataStats(c.healthDataStats as HealthDataStats | null);
setDialysisStats(c.dialysisStats as DialysisStatistics | null);
setDoctorCount(c.doctorCount as number);
setLoading(false);
return;
}
// 已有正在进行的请求,等待它完成
if (fetchPromise) {
const c = await fetchPromise;
setPatientStats(c.patientStats as PatientStatistics | null);
setConsultationStats(c.consultationStats as ConsultationStatistics | null);
setFollowUpStats(c.followUpStats as FollowUpStatistics | null);
setPointsStats(c.pointsStats as PointsStatistics | null);
setHealthDataStats(c.healthDataStats as HealthDataStats | null);
setDialysisStats(c.dialysisStats as DialysisStatistics | null);
setDoctorCount(c.doctorCount as number);
setLoading(false);
return;
}
setLoading(true);
setError(null);
let hasAnyError = false;
const errors: string[] = [];
// 创建新请求
fetchPromise = (async () => {
let hasAnyError = false;
const errors: string[] = [];
const tryFetch = async <T,>(fn: () => Promise<T>, setter: (v: T) => void, label: string) => {
try {
const data = await fn();
setter(data);
} catch {
hasAnyError = true;
errors.push(label);
const results: Record<string, unknown> = {
patientStats: null,
consultationStats: null,
followUpStats: null,
pointsStats: null,
healthDataStats: null,
dialysisStats: null,
doctorCount: 0,
};
const tryFetch = async <T,>(fn: () => Promise<T>, key: string, label: string) => {
try {
const data = await fn();
results[key] = data;
} catch {
hasAnyError = true;
errors.push(label);
}
};
await Promise.all([
tryFetch(() => pointsApi.getPatientStats({ silent: true }), 'patientStats', '患者'),
tryFetch(() => pointsApi.getConsultationStats({ silent: true }), 'consultationStats', '咨询'),
tryFetch(() => pointsApi.getFollowUpStats({ silent: true }), 'followUpStats', '随访'),
tryFetch(() => pointsApi.getStatistics({ silent: true }), 'pointsStats', '积分'),
tryFetch(() => pointsApi.getHealthDataStats({ silent: true }), 'healthDataStats', '健康数据'),
tryFetch(() => pointsApi.getDialysisStats({ silent: true }), 'dialysisStats', '透析'),
tryFetch(
async () => { const r = await doctorApi.list({ page: 1, page_size: 1 }); return r.total; },
'doctorCount',
'医护',
),
]);
if (!hasAnyError || errors.length < 7) {
cachedStats = results;
cachedAt = Date.now();
}
};
await Promise.all([
tryFetch(() => pointsApi.getPatientStats({ silent: true }), setPatientStats, '患者'),
tryFetch(() => pointsApi.getConsultationStats({ silent: true }), setConsultationStats, '咨询'),
tryFetch(() => pointsApi.getFollowUpStats({ silent: true }), setFollowUpStats, '随访'),
tryFetch(() => pointsApi.getStatistics({ silent: true }), setPointsStats, '积分'),
tryFetch(() => pointsApi.getHealthDataStats({ silent: true }), setHealthDataStats, '健康数据'),
tryFetch(() => pointsApi.getDialysisStats({ silent: true }), setDialysisStats, '透析'),
tryFetch(
async () => { const r = await doctorApi.list({ page: 1, page_size: 1 }); return r.total; },
setDoctorCount,
'医护',
),
]);
if (hasAnyError && errors.length === 7) {
setError('加载统计数据失败');
}
if (hasAnyError && errors.length === 7) {
setError('加载统计数据失败');
return results;
})();
try {
const c = await fetchPromise;
setPatientStats(c.patientStats as PatientStatistics | null);
setConsultationStats(c.consultationStats as ConsultationStatistics | null);
setFollowUpStats(c.followUpStats as FollowUpStatistics | null);
setPointsStats(c.pointsStats as PointsStatistics | null);
setHealthDataStats(c.healthDataStats as HealthDataStats | null);
setDialysisStats(c.dialysisStats as DialysisStatistics | null);
setDoctorCount(c.doctorCount as number);
} finally {
fetchPromise = null;
setLoading(false);
}
setLoading(false);
}, []);
useEffect(() => {

View File

@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import DOMPurify from 'dompurify';
interface ArticlePhonePreviewProps {
title: string;
@@ -240,7 +241,7 @@ export default function ArticlePhonePreview({
<div className="mp-content">
{content && content !== '<p><br></p>' ? (
<div dangerouslySetInnerHTML={{ __html: content }} />
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }} />
) : (
<div className="mp-empty">

View File

@@ -25,7 +25,7 @@ export function DoctorSelect({ value, onChange, placeholder }: Props) {
})),
);
}
}).catch(() => {});
}).catch((err) => console.warn('[DoctorSelect] 获取医生列表失败:', err));
return () => { cancelled = true; };
}, []);

View File

@@ -271,7 +271,7 @@ export default function AdminDashboard() {
statsData.patientStats?.total_patients ??
0,
pct: 100,
color: "#94A3B8",
color: "#64748B",
},
];
@@ -294,7 +294,7 @@ export default function AdminDashboard() {
gap: 12,
marginBottom: 20,
padding: "14px 20px",
background: "#fff",
background: "var(--erp-bg-container, #fff)",
borderRadius: 12,
border: "1px solid #E2E8F0",
}}
@@ -348,7 +348,7 @@ export default function AdminDashboard() {
<div
key={card.label}
style={{
background: "#fff",
background: "var(--erp-bg-container, #fff)",
borderRadius: 12,
border: "1px solid #E2E8F0",
overflow: "hidden",
@@ -358,13 +358,13 @@ export default function AdminDashboard() {
>
<div style={{ height: 3, background: card.gradient }} />
<div style={{ padding: "14px 18px" }}>
<div style={{ fontSize: 12, color: "#94A3B8", marginBottom: 4 }}>
<div style={{ fontSize: 12, color: "#64748B", marginBottom: 4 }}>
{card.label}
</div>
<div style={{ fontSize: 26, fontWeight: 700, color: card.color }}>
{card.value}
</div>
<div style={{ fontSize: 11, color: "#94A3B8", marginTop: 3 }}>
<div style={{ fontSize: 11, color: "#64748B", marginTop: 3 }}>
{card.sub}
</div>
</div>
@@ -384,7 +384,7 @@ export default function AdminDashboard() {
{/* 最近审计日志 */}
<div
style={{
background: "#fff",
background: "var(--erp-bg-container, #fff)",
borderRadius: 12,
border: "1px solid #E2E8F0",
overflow: "hidden",
@@ -412,7 +412,7 @@ export default function AdminDashboard() {
style={{
padding: 24,
textAlign: "center",
color: "#94A3B8",
color: "#64748B",
fontSize: 13,
}}
>
@@ -488,7 +488,7 @@ export default function AdminDashboard() {
{actionLabel}{resourceLabel}
</span>
<span
style={{ fontSize: 11, color: "#94A3B8", flexShrink: 0 }}
style={{ fontSize: 11, color: "#64748B", flexShrink: 0 }}
>
{formatTimeAgo(log.created_at)}
</span>
@@ -501,7 +501,7 @@ export default function AdminDashboard() {
{/* 模块状态 */}
<div
style={{
background: "#fff",
background: "var(--erp-bg-container, #fff)",
borderRadius: 12,
border: "1px solid #E2E8F0",
overflow: "hidden",
@@ -539,7 +539,7 @@ export default function AdminDashboard() {
<div style={{ fontSize: 13, fontWeight: 500 }}>
{mod.display_name}
</div>
<div style={{ fontSize: 11, color: "#94A3B8" }}>
<div style={{ fontSize: 11, color: "#64748B" }}>
{mod.description}
</div>
</div>
@@ -550,7 +550,7 @@ export default function AdminDashboard() {
borderRadius: 10,
fontWeight: 500,
background: mod.active ? "#F0FDF4" : "#F1F5F9",
color: mod.active ? "#16A34A" : "#94A3B8",
color: mod.active ? "#16A34A" : "#64748B",
}}
>
{mod.active ? "运行中" : "未启用"}
@@ -565,7 +565,7 @@ export default function AdminDashboard() {
{/* 用户活跃度 */}
<div
style={{
background: "#fff",
background: "var(--erp-bg-container, #fff)",
borderRadius: 12,
border: "1px solid #E2E8F0",
overflow: "hidden",
@@ -627,7 +627,7 @@ export default function AdminDashboard() {
width: 40,
textAlign: "right",
flexShrink: 0,
color: item.color === "#94A3B8" ? "#475569" : item.color,
color: item.color === "#64748B" ? "#475569" : item.color,
}}
>
{item.value}
@@ -642,13 +642,13 @@ export default function AdminDashboard() {
justifyContent: "space-between",
}}
>
<div style={{ fontSize: 11, color: "#94A3B8" }}></div>
<div style={{ fontSize: 11, color: "#64748B" }}></div>
<div style={{ display: "flex", gap: 10, fontSize: 11 }}>
{userActivity?.by_role.map((r) => (
<span key={r.role}>
{r.role} {r.count}
</span>
)) ?? <span style={{ color: "#94A3B8" }}>...</span>}
)) ?? <span style={{ color: "#64748B" }}>...</span>}
</div>
</div>
</div>
@@ -656,7 +656,7 @@ export default function AdminDashboard() {
{/* 快捷管理入口 */}
<div
style={{
background: "#fff",
background: "var(--erp-bg-container, #fff)",
borderRadius: 12,
border: "1px solid #E2E8F0",
overflow: "hidden",

View File

@@ -32,19 +32,19 @@ export default function OperatorWorkbench() {
useEffect(() => {
actionInboxApi.stats()
.then((s) => setStats(s ?? null))
.catch(() => {});
.catch((err) => console.warn('[OperatorWorkbench] 获取行动收件箱统计失败:', err));
actionInboxApi.list({ status: 'pending', page: 1, page_size: 5 })
.then((r) => setActionItems(r.data))
.catch(() => {});
.catch((err) => console.warn('[OperatorWorkbench] 获取行动列表失败:', err));
dashboardApi.getPointsRecentActivity()
.then((d) => setPointsActivity(d ?? []))
.catch(() => {});
.catch((err) => console.warn('[OperatorWorkbench] 获取积分活动失败:', err));
dashboardApi.getArticleStats()
.then((d) => setArticleStats(d ?? null))
.catch(() => {});
.catch((err) => console.warn('[OperatorWorkbench] 获取文章统计失败:', err));
}, []);
const firstName = user?.display_name ?? user?.username ?? '运营';

View File

@@ -75,7 +75,7 @@ pub struct ArticleListParams {
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct CreateArticleReq {
#[validate(length(min = 1, max = 500, message = "文章标题长度须在1-500之间"))]
#[validate(length(min = 1, max = 200, message = "文章标题长度须在1-200之间"))]
pub title: String,
#[validate(length(max = 2000, message = "摘要最多2000字"))]
pub summary: Option<String>,
@@ -113,7 +113,7 @@ impl CreateArticleReq {
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct UpdateArticleReq {
#[validate(length(min = 1, max = 500, message = "文章标题长度须在1-500之间"))]
#[validate(length(min = 1, max = 200, message = "文章标题长度须在1-200之间"))]
pub title: Option<String>,
#[validate(length(max = 2000, message = "摘要最多2000字"))]
pub summary: Option<String>,

View File

@@ -37,10 +37,12 @@ pub struct MessageResp {
}
/// 发送消息请求体 — 不含 sender_id/sender_role由服务端从 JWT 注入。
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct CreateMessageReq {
pub session_id: Uuid,
#[validate(length(max = 50, message = "内容类型最多 50 字"))]
pub content_type: Option<String>,
#[validate(length(min = 1, max = 5000, message = "消息内容长度须在 1-5000 之间"))]
pub content: String,
/// 关联的媒体文件 ID当 content_type 为 image/file/voice 时必填)
pub media_id: Option<Uuid>,
@@ -52,10 +54,11 @@ impl CreateMessageReq {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct CreateSessionReq {
pub patient_id: Uuid,
pub doctor_id: Option<Uuid>,
#[validate(length(max = 50, message = "咨询类型最多 50 字"))]
pub consultation_type: Option<String>,
}
@@ -70,11 +73,13 @@ pub struct SessionQuery {
/// 从咨询会话创建随访任务请求体 — 仅需填写随访类型和计划日期,
/// patient_id / source_type / source_id 由服务端自动填充。
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct CreateFollowUpFromConsultationReq {
#[validate(length(min = 1, max = 50, message = "随访类型长度须在 1-50 之间"))]
pub follow_up_type: String,
pub planned_date: chrono::NaiveDate,
pub assigned_to: Option<Uuid>,
#[validate(length(max = 2000, message = "内容模板最多 2000 字"))]
pub content_template: Option<String>,
}

View File

@@ -3,6 +3,7 @@ use erp_core::sanitize::sanitize_option;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use validator::Validate;
type Decimal = f64;
@@ -10,18 +11,27 @@ type Decimal = f64;
// 日常监测
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct CreateDailyMonitoringReq {
pub patient_id: Uuid,
pub record_date: NaiveDate,
#[validate(range(min = 40, max = 300, message = "收缩压范围 40-300 mmHg"))]
pub morning_bp_systolic: Option<i32>,
#[validate(range(min = 20, max = 200, message = "舒张压范围 20-200 mmHg"))]
pub morning_bp_diastolic: Option<i32>,
#[validate(range(min = 40, max = 300, message = "收缩压范围 40-300 mmHg"))]
pub evening_bp_systolic: Option<i32>,
#[validate(range(min = 20, max = 200, message = "舒张压范围 20-200 mmHg"))]
pub evening_bp_diastolic: Option<i32>,
#[validate(range(min = 0.5, max = 500.0, message = "体重范围 0.5-500 kg"))]
pub weight: Option<Decimal>,
#[validate(range(min = 0.5, max = 33.3, message = "血糖范围 0.5-33.3 mmol/L"))]
pub blood_sugar: Option<Decimal>,
#[validate(range(min = 0, max = 10000, message = "入液量范围 0-10000 ml"))]
pub fluid_intake: Option<i32>,
#[validate(range(min = 0, max = 10000, message = "尿量范围 0-10000 ml"))]
pub urine_output: Option<i32>,
#[validate(length(max = 2000, message = "备注最多 2000 字"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
@@ -32,17 +42,26 @@ impl CreateDailyMonitoringReq {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct UpdateDailyMonitoringReq {
pub record_date: Option<NaiveDate>,
#[validate(range(min = 40, max = 300, message = "收缩压范围 40-300 mmHg"))]
pub morning_bp_systolic: Option<i32>,
#[validate(range(min = 20, max = 200, message = "舒张压范围 20-200 mmHg"))]
pub morning_bp_diastolic: Option<i32>,
#[validate(range(min = 40, max = 300, message = "收缩压范围 40-300 mmHg"))]
pub evening_bp_systolic: Option<i32>,
#[validate(range(min = 20, max = 200, message = "舒张压范围 20-200 mmHg"))]
pub evening_bp_diastolic: Option<i32>,
#[validate(range(min = 0.5, max = 500.0, message = "体重范围 0.5-500 kg"))]
pub weight: Option<Decimal>,
#[validate(range(min = 0.5, max = 33.3, message = "血糖范围 0.5-33.3 mmol/L"))]
pub blood_sugar: Option<Decimal>,
#[validate(range(min = 0, max = 10000, message = "入液量范围 0-10000 ml"))]
pub fluid_intake: Option<i32>,
#[validate(range(min = 0, max = 10000, message = "尿量范围 0-10000 ml"))]
pub urine_output: Option<i32>,
#[validate(length(max = 2000, message = "备注最多 2000 字"))]
pub notes: Option<String>,
}

View File

@@ -3,6 +3,7 @@ use erp_core::sanitize::sanitize_option;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use validator::Validate;
/// 用 f64 表示 Decimal 值以满足 utoipa ToSchema 要求。
/// 对于健康数值(血压 60-200mmHg、血糖 3.9-11.1mmol/L、体重 30-300kg
@@ -10,23 +11,37 @@ use uuid::Uuid;
/// 数据库层仍使用 SeaORM Decimal 类型,转换仅在 DTO 边界进行。
type Decimal = f64;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct CreateVitalSignsReq {
pub record_date: NaiveDate,
#[validate(range(min = 40, max = 300, message = "收缩压范围 40-300 mmHg"))]
pub systolic_bp_morning: Option<i32>,
#[validate(range(min = 20, max = 200, message = "舒张压范围 20-200 mmHg"))]
pub diastolic_bp_morning: Option<i32>,
#[validate(range(min = 40, max = 300, message = "收缩压范围 40-300 mmHg"))]
pub systolic_bp_evening: Option<i32>,
#[validate(range(min = 20, max = 200, message = "舒张压范围 20-200 mmHg"))]
pub diastolic_bp_evening: Option<i32>,
#[validate(range(min = 20, max = 250, message = "心率范围 20-250 bpm"))]
pub heart_rate: Option<i32>,
#[validate(range(min = 0.5, max = 500.0, message = "体重范围 0.5-500 kg"))]
pub weight: Option<Decimal>,
#[validate(range(min = 0.5, max = 33.3, message = "血糖范围 0.5-33.3 mmol/L"))]
pub blood_sugar: Option<Decimal>,
#[validate(range(min = 30.0, max = 45.0, message = "体温范围 30-45 °C"))]
pub body_temperature: Option<Decimal>,
#[validate(range(min = 50, max = 100, message = "血氧范围 50-100%"))]
pub spo2: Option<i32>,
/// fasting / postprandial / random / ogtt
#[validate(length(max = 50, message = "血糖类型最多 50 字"))]
pub blood_sugar_type: Option<String>,
#[validate(range(min = 0, max = 10000, message = "入液量范围 0-10000 ml"))]
pub water_intake_ml: Option<i32>,
#[validate(range(min = 0, max = 10000, message = "尿量范围 0-10000 ml"))]
pub urine_output_ml: Option<i32>,
#[validate(length(max = 2000, message = "备注最多 2000 字"))]
pub notes: Option<String>,
#[validate(length(max = 50, message = "来源最多 50 字"))]
pub source: Option<String>,
}
@@ -36,21 +51,34 @@ impl CreateVitalSignsReq {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct UpdateVitalSignsReq {
pub record_date: Option<NaiveDate>,
#[validate(range(min = 40, max = 300, message = "收缩压范围 40-300 mmHg"))]
pub systolic_bp_morning: Option<i32>,
#[validate(range(min = 20, max = 200, message = "舒张压范围 20-200 mmHg"))]
pub diastolic_bp_morning: Option<i32>,
#[validate(range(min = 40, max = 300, message = "收缩压范围 40-300 mmHg"))]
pub systolic_bp_evening: Option<i32>,
#[validate(range(min = 20, max = 200, message = "舒张压范围 20-200 mmHg"))]
pub diastolic_bp_evening: Option<i32>,
#[validate(range(min = 20, max = 250, message = "心率范围 20-250 bpm"))]
pub heart_rate: Option<i32>,
#[validate(range(min = 0.5, max = 500.0, message = "体重范围 0.5-500 kg"))]
pub weight: Option<Decimal>,
#[validate(range(min = 0.5, max = 33.3, message = "血糖范围 0.5-33.3 mmol/L"))]
pub blood_sugar: Option<Decimal>,
#[validate(range(min = 30.0, max = 45.0, message = "体温范围 30-45 °C"))]
pub body_temperature: Option<Decimal>,
#[validate(range(min = 50, max = 100, message = "血氧范围 50-100%"))]
pub spo2: Option<i32>,
#[validate(length(max = 50, message = "血糖类型最多 50 字"))]
pub blood_sugar_type: Option<String>,
#[validate(range(min = 0, max = 10000, message = "入液量范围 0-10000 ml"))]
pub water_intake_ml: Option<i32>,
#[validate(range(min = 0, max = 10000, message = "尿量范围 0-10000 ml"))]
pub urine_output_ml: Option<i32>,
#[validate(length(max = 2000, message = "备注最多 2000 字"))]
pub notes: Option<String>,
}
@@ -84,7 +112,7 @@ pub struct VitalSignsResp {
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct CreateLabReportReq {
pub report_date: NaiveDate,
pub report_type: String,
@@ -102,7 +130,7 @@ impl CreateLabReportReq {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct UpdateLabReportReq {
pub report_date: Option<NaiveDate>,
pub report_type: Option<String>,
@@ -136,7 +164,7 @@ pub struct LabReportResp {
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct CreateHealthRecordReq {
pub record_type: Option<String>,
pub record_date: NaiveDate,
@@ -154,7 +182,7 @@ impl CreateHealthRecordReq {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct UpdateHealthRecordReq {
pub record_type: Option<String>,
pub record_date: Option<NaiveDate>,
@@ -264,7 +292,7 @@ pub struct MiniTodayResp {
pub weight: Option<IndicatorSummary>,
}
#[derive(Debug, Clone, serde::Deserialize, ToSchema)]
#[derive(Debug, Clone, serde::Deserialize, ToSchema, Validate)]
pub struct ReviewLabReportReq {
pub doctor_notes: Option<String>,
pub items: Option<serde_json::Value>,

View File

@@ -33,7 +33,7 @@ where
{
require_permission(&ctx, "health.alerts.list")?;
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let page_size = query.page_size.unwrap_or(20).min(100);
let (items, total) = alert_service::list_alerts(
&state,

View File

@@ -36,7 +36,7 @@ where
{
require_permission(&ctx, "health.alert-rules.list")?;
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let page_size = query.page_size.unwrap_or(20).min(100);
let (items, total) = alert_rule_service::list_rules(
&state,

View File

@@ -62,7 +62,7 @@ where
{
require_permission(&ctx, "health.appointment.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = appointment_service::list_appointments(
&state,
ctx.tenant_id,
@@ -148,7 +148,7 @@ where
{
require_permission(&ctx, "health.appointment.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = appointment_service::list_schedules(
&state,
ctx.tenant_id,

View File

@@ -25,7 +25,7 @@ where
{
require_permission(&ctx, "health.articles.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
// 非管理权限用户只能查看已发布文章,防止草稿泄露
let status =
if require_any_permission(&ctx, &["health.articles.manage", "health.articles.review"])
@@ -58,7 +58,7 @@ pub async fn list_public_articles(
.tenant_id
.ok_or_else(|| AppError::Validation("tenant_id is required".into()))?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = article_service::list_articles(
&state,
tenant_id,
@@ -307,7 +307,7 @@ where
{
require_permission(&ctx, "health.articles.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result =
article_service::list_revisions(&state, ctx.tenant_id, id, page, page_size).await?;
Ok(Json(ApiResponse::ok(result)))

View File

@@ -138,7 +138,7 @@ where
{
require_permission(&ctx, "health.ble-gateways.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result =
ble_gateway_service::list_bindings(&state, ctx.tenant_id, gateway_id, page, page_size)
.await?;

View File

@@ -118,7 +118,7 @@ where
{
require_permission(&ctx, "health.care-plan.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result =
care_plan_service::list_care_plan_items(&state, ctx.tenant_id, plan_id, page, page_size)
.await?;
@@ -211,7 +211,7 @@ where
{
require_permission(&ctx, "health.care-plan.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result =
care_plan_service::list_care_plan_outcomes(&state, ctx.tenant_id, plan_id, page, page_size)
.await?;

View File

@@ -35,7 +35,7 @@ where
{
require_permission(&ctx, "health.consent.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result =
consent_service::list_consents(&state, ctx.tenant_id, patient_id, page, page_size).await?;
Ok(Json(ApiResponse::ok(result)))

View File

@@ -68,6 +68,8 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.manage")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let result =
consultation_service::create_session(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
Ok(Json(ApiResponse::ok(result)))
@@ -84,7 +86,7 @@ where
{
require_permission(&ctx, "health.consultation.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = consultation_service::list_sessions(
&state,
ctx.tenant_id,
@@ -124,7 +126,7 @@ where
{
require_permission(&ctx, "health.consultation.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = consultation_service::list_messages(
&state,
ctx.tenant_id,
@@ -209,6 +211,9 @@ where
content: req.content,
media_id: None,
};
msg_req
.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
msg_req.sanitize();
let result = consultation_service::create_message(
&state,

View File

@@ -29,7 +29,7 @@ where
{
require_permission(&ctx, "health.critical-alerts.list")?;
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let page_size = query.page_size.unwrap_or(20).min(100);
let (items, total) =
critical_alert_service::list_pending_alerts(&state, ctx.tenant_id, page, page_size)

View File

@@ -3,6 +3,7 @@ use axum::extract::{FromRef, Json, Path, Query, State};
use serde::Deserialize;
use utoipa::IntoParams;
use uuid::Uuid;
use validator::Validate;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
@@ -38,7 +39,7 @@ where
{
require_permission(&ctx, "health.daily-monitoring.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = daily_monitoring_service::list_daily_monitoring(
&state,
ctx.tenant_id,
@@ -76,6 +77,8 @@ where
{
require_permission(&ctx, "health.daily-monitoring.manage")?;
let mut req = req;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
req.sanitize();
let result = daily_monitoring_service::create_daily_monitoring(
&state,
@@ -99,6 +102,8 @@ where
{
require_permission(&ctx, "health.daily-monitoring.manage")?;
let mut data = req.data;
data.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
data.sanitize();
let result = daily_monitoring_service::update_daily_monitoring(
&state,

View File

@@ -48,7 +48,7 @@ where
{
require_permission(&ctx, "health.devices.list")?;
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let page_size = query.page_size.unwrap_or(20).min(100);
let (items, total) = device_service::list_devices(
&state,

View File

@@ -76,7 +76,7 @@ where
{
require_permission(&ctx, "health.device-readings.list")?;
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let page_size = query.page_size.unwrap_or(20).min(100);
let result = device_reading_service::query_device_readings(
&state,
ctx.tenant_id,
@@ -109,7 +109,7 @@ where
{
require_permission(&ctx, "health.device-readings.list")?;
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let page_size = query.page_size.unwrap_or(20).min(100);
let days = query.days.unwrap_or(7);
let result = device_reading_service::query_hourly_readings(
&state,

View File

@@ -35,7 +35,7 @@ where
{
require_permission(&ctx, "health.health-data.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result =
diagnosis_service::list_diagnoses(&state, ctx.tenant_id, patient_id, page, page_size)
.await?;

View File

@@ -40,7 +40,7 @@ where
{
require_permission(&ctx, "health.doctor.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = doctor_service::list_doctors(
&state,
ctx.tenant_id,

View File

@@ -119,7 +119,7 @@ where
{
require_permission(&ctx, "health.follow-up.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = follow_up_service::list_tasks(
&state,
ctx.tenant_id,
@@ -239,7 +239,7 @@ where
{
require_permission(&ctx, "health.follow-up.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = follow_up_service::list_records(
&state,
ctx.tenant_id,

View File

@@ -39,7 +39,7 @@ where
{
require_permission(&ctx, "health.follow-up-templates.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = follow_up_template_service::list_templates(
&state,
ctx.tenant_id,

View File

@@ -8,6 +8,8 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use validator::Validate;
use crate::dto::DeleteWithVersion;
use crate::dto::health_data_dto::*;
use crate::service::health_data_service;
@@ -58,7 +60,7 @@ where
{
require_permission(&ctx, "health.health-data.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result =
health_data_service::list_vital_signs(&state, ctx.tenant_id, patient_id, page, page_size)
.await?;
@@ -77,6 +79,8 @@ where
{
require_permission(&ctx, "health.health-data.manage")?;
let mut req = req;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
req.sanitize();
let result = health_data_service::create_vital_signs(
&state,
@@ -101,6 +105,8 @@ where
{
require_permission(&ctx, "health.health-data.manage")?;
let mut data = req.data;
data.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
data.sanitize();
let result = health_data_service::update_vital_signs(
&state,
@@ -153,7 +159,7 @@ where
{
require_permission(&ctx, "health.health-data.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result =
health_data_service::list_lab_reports(&state, ctx.tenant_id, patient_id, page, page_size)
.await?;
@@ -172,6 +178,8 @@ where
{
require_permission(&ctx, "health.health-data.manage")?;
let mut req = req;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
req.sanitize();
let result = health_data_service::create_lab_report(
&state,
@@ -196,6 +204,8 @@ where
{
require_permission(&ctx, "health.health-data.manage")?;
let mut data = req.data;
data.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
data.sanitize();
let result = health_data_service::update_lab_report(
&state,
@@ -244,6 +254,8 @@ where
{
require_permission(&ctx, "health.health-data.manage")?;
let mut data = req.data;
data.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
data.sanitize();
let result = health_data_service::review_lab_report(
&state,
@@ -274,7 +286,7 @@ where
{
require_permission(&ctx, "health.health-data.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = health_data_service::list_health_records(
&state,
ctx.tenant_id,
@@ -298,6 +310,8 @@ where
{
require_permission(&ctx, "health.health-data.manage")?;
let mut req = req;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
req.sanitize();
let result = health_data_service::create_health_record(
&state,
@@ -322,6 +336,8 @@ where
{
require_permission(&ctx, "health.health-data.manage")?;
let mut data = req.data;
data.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
data.sanitize();
let result = health_data_service::update_health_record(
&state,
@@ -374,7 +390,7 @@ where
{
require_permission(&ctx, "health.health-data.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result =
trend_service::list_trends(&state, ctx.tenant_id, patient_id, page, page_size).await?;
Ok(Json(ApiResponse::ok(result)))

View File

@@ -29,7 +29,7 @@ where
{
require_permission(&ctx, "health.medication-records.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = medication_record_service::list_medications(
&state,
ctx.tenant_id,

View File

@@ -28,7 +28,7 @@ where
{
require_permission(&ctx, "health.medication-reminders.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = medication_reminder_service::list_reminders(
&state,
ctx.tenant_id,

View File

@@ -44,7 +44,7 @@ where
{
require_permission(&ctx, "health.patient.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = patient_service::list_patients(
&state,
ctx.tenant_id,

View File

@@ -91,7 +91,7 @@ where
require_permission(&ctx, "health.points.list")?;
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result =
points_service::list_transactions(&state, ctx.tenant_id, patient_id, page, page_size)
.await?;
@@ -110,7 +110,7 @@ where
{
require_permission(&ctx, "health.points.list")?;
let p = page.page.unwrap_or(1);
let ps = page.page_size.unwrap_or(20);
let ps = page.page_size.unwrap_or(20).min(100);
let result =
points_service::list_products(&state, ctx.tenant_id, params.product_type, p, ps).await?;
Ok(Json(ApiResponse::ok(result)))
@@ -159,7 +159,7 @@ where
require_permission(&ctx, "health.points.list")?;
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result =
points_service::list_orders(&state, ctx.tenant_id, patient_id, page, page_size).await?;
Ok(Json(ApiResponse::ok(result)))
@@ -182,7 +182,7 @@ where
// 患者端端点:验证当前用户有关联的患者档案
let _patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result =
points_service::list_offline_events(&state, ctx.tenant_id, page, page_size).await?;
Ok(Json(ApiResponse::ok(result)))
@@ -318,7 +318,7 @@ where
{
require_permission(&ctx, "health.points.list")?;
let p = page.page.unwrap_or(1);
let ps = page.page_size.unwrap_or(20);
let ps = page.page_size.unwrap_or(20).min(100);
let result = points_service::admin_list_products(
&state,
ctx.tenant_id,
@@ -406,7 +406,7 @@ where
{
require_permission(&ctx, "health.points.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
// 管理端查看所有订单 — 不按 patient_id 过滤
let result = points_service::admin_list_orders(&state, ctx.tenant_id, page, page_size).await?;
Ok(Json(ApiResponse::ok(result)))
@@ -498,7 +498,7 @@ where
{
require_permission(&ctx, "health.points.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = points_service::admin_list_offline_events(
&state,
ctx.tenant_id,
@@ -579,7 +579,7 @@ where
{
require_permission(&ctx, "health.points.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result =
points_service::list_transactions(&state, ctx.tenant_id, patient_id, page, page_size)
.await?;

View File

@@ -118,7 +118,7 @@ where
{
require_permission(&ctx, "health.shifts.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let page_size = params.page_size.unwrap_or(20).min(100);
let result =
shift_service::list_assignments(&state, ctx.tenant_id, shift_id, page, page_size).await?;
Ok(Json(ApiResponse::ok(result)))