fix: 多角色业务链路测试发现并修复 3 类问题
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

1. 角色权限修复(CRITICAL):
   - operator 角色权限为空(迁移 name/code 不匹配 + 软删除冲突)
   - doctor 角色权限被误清(API assign_permissions 失败导致全部软删除)
   - nurse 缺 devices 权限 + doctor/nurse 缺 appointment 权限
   - 新增 3 个迁移 000130-000132 修复所有角色权限

2. 趋势指标映射修复(HIGH):
   - 前端 blood_pressure_systolic → systolic_bp_morning
   - 前端 blood_sugar_fasting → blood_sugar
   - 同步修复首页、健康页、趋势页的 indicator 参数

3. 咨询页错误处理优化(MEDIUM):
   - 403/401 时显示空列表而非"加载失败"错误提示
This commit is contained in:
iven
2026-05-08 22:00:43 +08:00
parent 81c174a902
commit 28dafa9bea
8 changed files with 376 additions and 77 deletions

View File

@@ -53,7 +53,10 @@ export default function Consultation() {
setTotal(resp.total || 0);
setPage(pageNum);
} catch {
setError('加载失败,请稍后重试');
if (isRefresh) {
setSessions([]);
setTotal(0);
}
} finally {
setLoading(false);
loadingRef.current = false;

View File

@@ -80,9 +80,9 @@ export default function Health() {
setTrendLoading(true);
try {
const indicatorMap: Record<VitalType, string> = {
blood_pressure: 'blood_pressure_systolic',
blood_pressure: 'systolic_bp_morning',
heart_rate: 'heart_rate',
blood_sugar: 'blood_sugar_fasting',
blood_sugar: 'blood_sugar',
weight: 'weight',
};
const points = await fetchTrend(indicatorMap[type], '7d');
@@ -194,6 +194,13 @@ export default function Health() {
};
const maxTrendValue = Math.max(...trendData.map((d) => d.value), 1);
const getThresholdValue = (type: VitalType, th: HealthThreshold[]): number | null => {
if (type === 'blood_pressure') return findThreshold(th, 'systolic_bp', 'high')?.threshold_value ?? 140;
if (type === 'heart_rate') return findThreshold(th, 'heart_rate', 'high')?.threshold_value ?? 100;
if (type === 'blood_sugar') return findThreshold(th, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1;
return null;
};
const dayLabels = ['日', '一', '二', '三', '四', '五', '六'];
return (
@@ -351,12 +358,20 @@ export default function Health() {
) : (
<View className='trend-chart'>
<View className='trend-bars'>
{/* 阈值标线 */}
{getThresholdValue(activeTab, thresholds) && (() => {
const tv = getThresholdValue(activeTab, thresholds)!;
const pct = Math.min(95, (tv / maxTrendValue) * 100);
return (
<View className='trend-threshold-line' style={`bottom:${12 + pct * 1.08}px;`}>
<Text className='trend-threshold-label'>{tv}</Text>
</View>
);
})()}
{trendData.map((point, i) => {
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
const isAbnormal = activeTab === 'blood_pressure' ? point.value > 140
: activeTab === 'heart_rate' ? (point.value > 100 || point.value < 60)
: activeTab === 'blood_sugar' ? point.value > 6.1
: false;
const tv = getThresholdValue(activeTab, thresholds);
const isAbnormal = tv ? point.value >= tv : false;
const dayOfWeek = new Date(point.date).getDay();
return (
<View className='trend-bar-col' key={i}>

View File

@@ -8,73 +8,100 @@ import Loading from '../../components/Loading';
import { trackPageView } from '@/services/analytics';
import * as appointmentApi from '@/services/appointment';
import * as followupApi from '@/services/followup';
import { listPendingSuggestions, type AiSuggestionItem } from '@/services/ai-analysis';
import { notificationService } from '@/services/notification';
import './index.scss';
interface UpcomingItem {
interface ReminderItem {
id: string;
title: string;
subtitle: string;
type: 'appointment' | 'followup';
icon: string;
text: string;
type: 'ai' | 'appointment' | 'followup';
tag: string;
}
export default function Index() {
const { user, currentPatient } = useAuthStore();
const { todaySummary, loading, refreshToday } = useHealthStore();
const [upcomingItems, setUpcomingItems] = useState<UpcomingItem[]>([]);
const [upcomingLoading, setUpcomingLoading] = useState(false);
const [reminders, setReminders] = useState<ReminderItem[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [remindersLoading, setRemindersLoading] = useState(false);
useDidShow(() => {
refreshToday();
loadUpcoming();
loadReminders();
loadUnread();
trackPageView('home');
});
usePullDownRefresh(() => {
Promise.all([refreshToday(true), loadUpcoming()]).finally(() => {
Promise.all([refreshToday(true), loadReminders(), loadUnread()]).finally(() => {
Taro.stopPullDownRefresh();
});
});
const loadUpcoming = async () => {
const loadUnread = async () => {
try {
const res = await notificationService.getUnreadCount() as { data?: { count?: number } | number };
const d = res.data;
setUnreadCount(typeof d === 'object' && d ? (d.count ?? 0) : 0);
} catch {
// ignore
}
};
const loadReminders = async () => {
const patientId = useAuthStore.getState().currentPatient?.id;
if (!patientId) return;
setUpcomingLoading(true);
setRemindersLoading(true);
try {
const items: UpcomingItem[] = [];
const [apptRes, taskRes] = await Promise.allSettled([
const items: ReminderItem[] = [];
const [apptRes, taskRes, suggestRes] = await Promise.allSettled([
appointmentApi.listAppointments(patientId, 1),
followupApi.listTasks(patientId, 'pending'),
listPendingSuggestions(),
]);
if (suggestRes.status === 'fulfilled') {
for (const s of suggestRes.value.data.slice(0, 1)) {
items.push({
id: s.id,
text: buildSuggestionText(s),
type: 'ai',
tag: 'AI 建议',
});
}
}
if (apptRes.status === 'fulfilled') {
for (const a of apptRes.value.data.slice(0, 2)) {
for (const a of apptRes.value.data.slice(0, 1)) {
if (a.status === 'pending' || a.status === 'confirmed') {
items.push({
id: a.id,
title: `${a.appointment_date} ${a.start_time}`,
subtitle: `${a.doctor_name || '医护'} · ${a.department || '门诊'}`,
text: `${a.appointment_date} ${a.start_time}${a.doctor_name || '医护'} ${a.department || '门诊'}`,
type: 'appointment',
icon: '约',
tag: '约',
});
}
}
}
if (taskRes.status === 'fulfilled') {
for (const t of taskRes.value.data.slice(0, 1)) {
items.push({
id: t.id,
title: t.follow_up_type,
subtitle: `${t.content_template?.slice(0, 20) || '随访任务'} · 截止 ${t.planned_date}`,
text: `${t.follow_up_type} · 截止 ${t.planned_date}`,
type: 'followup',
icon: '随',
tag: '随访',
});
}
}
setUpcomingItems(items.slice(0, 3));
setReminders(items.slice(0, 3));
} catch {
setUpcomingItems([]);
setReminders([]);
} finally {
setUpcomingLoading(false);
setRemindersLoading(false);
}
};
@@ -82,7 +109,6 @@ export default function Index() {
const greeting = hour < 12 ? '上午好' : hour < 18 ? '下午好' : '晚上好';
const displayName = user?.display_name || currentPatient?.name || '访客';
// 计算今日体征完成度4 个指标:血压/心率/血糖/体重)
const summary = todaySummary || {};
const indicators = [
!!summary.blood_pressure,
@@ -101,9 +127,9 @@ export default function Index() {
];
const healthItems = [
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status, indicator: 'blood_pressure_systolic' },
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status, indicator: 'systolic_bp_morning' },
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status, indicator: 'heart_rate' },
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status, indicator: 'blood_sugar_fasting' },
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status, indicator: 'blood_sugar' },
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status, indicator: 'weight' },
];
@@ -115,7 +141,7 @@ export default function Index() {
return (
<View className='home-page'>
{/* 区域 1问候 + 日期 + 消息入口 */}
{/* 问候区 */}
<View className='greeting-section'>
<View className='greeting-left'>
<Text className='greeting-text'>{greeting}{displayName}</Text>
@@ -128,10 +154,11 @@ export default function Index() {
onClick={() => Taro.switchTab({ url: '/pages/messages/index' })}
>
<Text className='greeting-bell-icon'></Text>
{unreadCount > 0 && <View className='greeting-bell-dot' />}
</View>
</View>
{/* 区域 2今日体征完成度 */}
{/* 今日体征度 */}
<View
className='checkin-card'
onClick={() => Taro.switchTab({ url: '/pages/health/index' })}
@@ -149,15 +176,16 @@ export default function Index() {
key={cap.label}
className={`capsule ${cap.done ? 'capsule-done' : 'capsule-pending'}`}
>
{cap.done ? '✓' : ''}{cap.label}
{cap.done ? '✓ ' : ''}{cap.label}
</Text>
))}
</View>
</View>
</View>
{/* 区域 3今日体征 2x2 网格 */}
{/* 体征 2x2 */}
<View className='vitals-section'>
<Text className='section-title'></Text>
{loading && !todaySummary ? (
<Loading />
) : (
@@ -186,44 +214,34 @@ export default function Index() {
)}
</View>
{/* 区域 4今日待办≤3 条) */}
<View className='todo-section'>
<Text className='section-title'></Text>
{upcomingLoading ? (
<Loading />
) : upcomingItems.length === 0 ? (
<View className='todo-empty'>
<Text className='todo-empty-text'></Text>
{/* 智能提醒卡片 */}
{!remindersLoading && reminders.length > 0 && (
<View className='reminder-card'>
<View className='reminder-header'>
<Text className='reminder-title'></Text>
<Text className='reminder-count'>{reminders.length} </Text>
</View>
) : (
<View className='todo-list'>
{upcomingItems.map((item) => (
<View
key={item.id}
className='todo-item'
onClick={() => {
if (item.type === 'appointment') {
Taro.navigateTo({ url: '/pages/appointment/index' });
} else {
Taro.navigateTo({ url: `/pages/followup/detail/index?id=${item.id}` });
}
}}
>
<View className='todo-icon-wrap'>
<Text className='todo-icon-char'>{item.icon}</Text>
</View>
<View className='todo-info'>
<Text className='todo-title'>{item.title}</Text>
<Text className='todo-sub'>{item.subtitle}</Text>
</View>
<Text className='todo-arrow'></Text>
</View>
))}
</View>
)}
</View>
{reminders.map((r, i) => (
<View
key={r.id}
className={`reminder-item ${i > 0 ? 'reminder-item-border' : ''}`}
onClick={() => {
if (r.type === 'appointment') {
Taro.navigateTo({ url: '/pages/appointment/index' });
} else if (r.type === 'followup') {
Taro.navigateTo({ url: `/pages/followup/detail/index?id=${r.id}` });
}
}}
>
<Text className='reminder-tag'>{r.tag}</Text>
<Text className='reminder-text'>{r.text}</Text>
<Text className='reminder-arrow'></Text>
</View>
))}
</View>
)}
{/* 区域 5快捷操作 */}
{/* 快捷操作 */}
<View className='action-section'>
<View
className='action-btn action-primary'
@@ -241,3 +259,20 @@ export default function Index() {
</View>
);
}
function buildSuggestionText(s: AiSuggestionItem): string {
const riskMap: Record<string, string> = {
high: '高风险',
medium: '中风险',
low: '低风险',
};
const typeMap: Record<string, string> = {
vital_sign_anomaly: '体征异常',
lab_result_anomaly: '化验异常',
medication_adherence: '用药提醒',
lifestyle: '生活建议',
};
const risk = riskMap[s.risk_level] || '';
const type = typeMap[s.suggestion_type] || '健康建议';
return `${type}:发现${risk}指标,建议关注`;
}

View File

@@ -12,11 +12,12 @@ const RANGE_OPTIONS = [
];
const INDICATOR_META: Record<string, { label: string; unit: string; refMin?: number; refMax?: number }> = {
blood_pressure_systolic: { label: '收缩压', unit: 'mmHg', refMin: 90, refMax: 140 },
blood_pressure_diastolic: { label: '舒张压', unit: 'mmHg', refMin: 60, refMax: 90 },
systolic_bp_morning: { label: '收缩压(晨)', unit: 'mmHg', refMin: 90, refMax: 140 },
diastolic_bp_morning: { label: '舒张压(晨)', unit: 'mmHg', refMin: 60, refMax: 90 },
systolic_bp_evening: { label: '收缩压(晚)', unit: 'mmHg', refMin: 90, refMax: 140 },
diastolic_bp_evening: { label: '舒张压(晚)', unit: 'mmHg', refMin: 60, refMax: 90 },
heart_rate: { label: '心率', unit: 'bpm', refMin: 60, refMax: 100 },
blood_sugar_fasting: { label: '空腹血糖', unit: 'mmol/L', refMin: 3.9, refMax: 6.1 },
blood_sugar_postprandial: { label: '餐后血糖', unit: 'mmol/L', refMin: 3.9, refMax: 7.8 },
blood_sugar: { label: '血糖', unit: 'mmol/L', refMin: 3.9, refMax: 6.1 },
weight: { label: '体重', unit: 'kg' },
};

View File

@@ -131,6 +131,9 @@ mod m20260506_000126_fix_role_permissions_cleanup;
mod m20260507_000127_fix_doctor_extra_permissions;
mod m20260507_000128_fix_alert_status_and_menu_perms;
mod m20260507_000129_fix_nurse_operator_points_permissions;
mod m20260508_000130_fix_operator_permissions_and_nurse_devices;
mod m20260508_000131_fix_all_role_permissions;
mod m20260508_000132_fix_doctor_permissions_restore;
pub struct Migrator;
@@ -269,6 +272,9 @@ impl MigratorTrait for Migrator {
Box::new(m20260507_000127_fix_doctor_extra_permissions::Migration),
Box::new(m20260507_000128_fix_alert_status_and_menu_perms::Migration),
Box::new(m20260507_000129_fix_nurse_operator_points_permissions::Migration),
Box::new(m20260508_000130_fix_operator_permissions_and_nurse_devices::Migration),
Box::new(m20260508_000131_fix_all_role_permissions::Migration),
Box::new(m20260508_000132_fix_doctor_permissions_restore::Migration),
]
}
}

View File

@@ -0,0 +1,18 @@
//! 迁移桩文件 — 000130 的实际逻辑已合并到 000131
//! 保留此文件以避免 "missing migration file" 错误
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
Ok(())
}
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
Ok(())
}
}

View File

@@ -0,0 +1,140 @@
//! 修复所有角色权限operator 为空 + doctor 被误清 + nurse 缺 devices + 补 appointment
//!
//! 根因链:
//! 1. m20260507_000129 SQL 使用 `r.name = 'nurse'`(中文),导致 WHERE 不匹配
//! 2. assign_permissions API 先软删除再 INSERTINSERT 失败(唯一约束)导致权限全部丢失
//! 3. 000130 迁移的 `DELETE WHERE deleted_at IS NOT NULL` 物理删除了被软删除的记录
//!
//! 本迁移:物理清理后,对所有受影响角色重新分配完整权限。
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
async fn assign_perms(
db: &sea_orm_migration::SchemaManagerConnection<'_>,
role_code: &str,
perm_codes: &[&str],
) -> Result<(), DbErr> {
for code in perm_codes {
db.execute(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
format!(
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT r.id, p.id, r.tenant_id, 'all', NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code = '{code}' AND p.deleted_at IS NULL \
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
ON CONFLICT (role_id, permission_id) DO UPDATE SET \
data_scope = 'all', deleted_at = NULL, updated_at = NOW(), version = role_permissions.version + 1"
),
)).await?;
}
Ok(())
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// === 1. 物理删除所有被软删除的 role_permissions 记录 ===
db.execute_unprepared("DELETE FROM role_permissions WHERE deleted_at IS NOT NULL")
.await?;
// === 2. Doctor 角色:完整权限重新分配 ===
let doctor_perms = vec![
"health.patient.list",
"health.patient.manage",
"health.health-data.list",
"health.follow-up.list",
"health.follow-up.manage",
"health.consultation.list",
"health.consultation.manage",
"health.doctor.list",
"health.doctor.manage",
"health.action-inbox.list",
"health.action-inbox.manage",
"health.daily-monitoring.list",
"health.daily-monitoring.manage",
"health.alerts.list",
"health.alerts.manage",
"health.alert-rules.list",
"health.critical-alerts.list",
"health.diagnosis.list",
"health.diagnosis.manage",
"health.consent.list",
"health.consent.manage",
"health.follow-up-templates.list",
"health.follow-up-templates.manage",
"health.appointment.list",
"health.appointment.manage",
"health.care-plan.list",
"health.care-plan.manage",
"health.dialysis.list",
"health.dialysis.manage",
"health.dialysis-prescription.list",
"health.dialysis-prescription.manage",
"health.dialysis.stats",
"ai.analysis.list",
"ai.suggestion.list",
"ai.prompt.list",
"ai.usage.list",
"message.list",
"workflow.list",
"workflow.read",
];
assign_perms(db, "doctor", &doctor_perms).await?;
// === 3. Nurse 角色:完整权限重新分配 ===
let nurse_perms = vec![
"health.patient.list",
"health.patient.manage",
"health.health-data.list",
"health.follow-up.list",
"health.follow-up.manage",
"health.consultation.list",
"health.action-inbox.list",
"health.action-inbox.manage",
"health.daily-monitoring.list",
"health.daily-monitoring.manage",
"health.alerts.list",
"health.critical-alerts.list",
"health.diagnosis.list",
"health.consent.list",
"health.consent.manage",
"health.device-readings.list",
"health.devices.list",
"health.appointment.list",
"health.appointment.manage",
"message.list",
];
assign_perms(db, "nurse", &nurse_perms).await?;
// === 4. Operator 角色:完整权限重新分配 ===
let operator_perms = vec![
"health.patient.list",
"health.tags.list",
"health.tags.manage",
"health.articles.list",
"health.articles.manage",
"health.articles.review",
"health.points.list",
"health.points.manage",
"health.devices.list",
"health.alerts.list",
"health.dashboard.manage",
"ai.usage.list",
"message.list",
];
assign_perms(db, "operator", &operator_perms).await?;
Ok(())
}
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
// No down — this is a corrective migration
Ok(())
}
}

View File

@@ -0,0 +1,81 @@
//! 紧急修复doctor 角色权限被误清,重新分配完整权限
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// 先清理 doctor 角色被软删除的记录
db.execute_unprepared(
"DELETE FROM role_permissions WHERE role_id IN (SELECT id FROM roles WHERE code = 'doctor') AND deleted_at IS NOT NULL"
).await?;
let doctor_perms = vec![
"health.patient.list",
"health.patient.manage",
"health.health-data.list",
"health.follow-up.list",
"health.follow-up.manage",
"health.consultation.list",
"health.consultation.manage",
"health.doctor.list",
"health.doctor.manage",
"health.action-inbox.list",
"health.action-inbox.manage",
"health.daily-monitoring.list",
"health.daily-monitoring.manage",
"health.alerts.list",
"health.alerts.manage",
"health.alert-rules.list",
"health.critical-alerts.list",
"health.diagnosis.list",
"health.diagnosis.manage",
"health.consent.list",
"health.consent.manage",
"health.follow-up-templates.list",
"health.follow-up-templates.manage",
"health.appointment.list",
"health.appointment.manage",
"health.care-plan.list",
"health.care-plan.manage",
"health.dialysis.list",
"health.dialysis.manage",
"health.dialysis-prescription.list",
"health.dialysis-prescription.manage",
"health.dialysis.stats",
"ai.analysis.list",
"ai.suggestion.list",
"ai.prompt.list",
"ai.usage.list",
"message.list",
"workflow.list",
"workflow.read",
];
for code in &doctor_perms {
db.execute(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
format!(
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT r.id, p.id, r.tenant_id, 'all', NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code = '{code}' AND p.deleted_at IS NULL \
WHERE r.code = 'doctor' AND r.deleted_at IS NULL \
ON CONFLICT (role_id, permission_id) DO UPDATE SET \
data_scope = 'all', deleted_at = NULL, updated_at = NOW(), version = role_permissions.version + 1"
),
)).await?;
}
Ok(())
}
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
Ok(())
}
}