feat(health): Web 管理端设备数据集成补全 — Phase 2
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

- 新增告警三页面(仪表盘/列表/规则)+ 设备管理菜单种子数据
- 新增设备管理后端 API(GET /devices + DELETE /devices/{id})
- 新增设备数据查看组件 DeviceReadingsTab(原始数据 + 小时聚合)
- 新增设备管理页面 DeviceManage(列表/筛选/解绑)
- 患者详情页新增设备数据 Tab
This commit is contained in:
iven
2026-04-29 06:28:30 +08:00
parent f6ccb8a35c
commit cac61637ce
14 changed files with 784 additions and 1 deletions

View File

@@ -47,6 +47,7 @@ const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
const AlertList = lazy(() => import('./pages/health/AlertList'));
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList'));
const DeviceManage = lazy(() => import('./pages/health/DeviceManage'));
// 内容管理
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
@@ -252,6 +253,7 @@ export default function App() {
<Route path="/health/alerts" element={<AlertList />} />
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
<Route path="/health/alert-rules" element={<AlertRuleList />} />
<Route path="/health/devices" element={<DeviceManage />} />
{/* 内容管理 */}
<Route path="/health/articles" element={<ArticleManageList />} />
<Route path="/health/articles/new" element={<ArticleEditor />} />

View File

@@ -0,0 +1,32 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface DeviceItem {
id: string;
patient_id: string;
device_id: string;
device_model: string;
device_type: string;
bound_at: string;
last_sync_at: string;
version: number;
}
// --- API ---
export const deviceApi = {
listDevices: (params?: {
patient_id?: string;
device_type?: string;
page?: number;
page_size?: number;
}) =>
client
.get('/health/devices', { params })
.then((r) => r.data.data as PaginatedResponse<DeviceItem>),
unbindDevice: (id: string, version: number) =>
client
.delete(`/health/devices/${id}`, { data: { version } })
.then((r) => r.data.data as DeviceItem),
};

View File

@@ -94,6 +94,7 @@ const routeTitleFallback: Record<string, string> = {
'/health/alerts': '告警列表',
'/health/alert-dashboard': '告警仪表盘',
'/health/alert-rules': '告警规则',
'/health/devices': '设备管理',
};
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {

View File

@@ -0,0 +1,164 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, Input, message, Popconfirm, Select, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { deviceApi, type DeviceItem } from '../../api/health/devices';
const DEVICE_TYPE_OPTIONS = [
{ label: '血压', value: 'blood_pressure' },
{ label: '血糖', value: 'blood_glucose' },
{ label: '心率', value: 'heart_rate' },
{ label: '血氧', value: 'blood_oxygen' },
{ label: '步数', value: 'steps' },
{ label: '体温', value: 'temperature' },
];
const DEVICE_TYPE_COLOR: Record<string, string> = {
blood_pressure: 'red',
blood_glucose: 'purple',
heart_rate: 'volcano',
blood_oxygen: 'blue',
steps: 'green',
temperature: 'orange',
};
function formatTime(val?: string | null): string {
if (!val) return '-';
return dayjs(val).format('YYYY-MM-DD HH:mm');
}
export default function DeviceManage() {
const [data, setData] = useState<DeviceItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [filterPatientId, setFilterPatientId] = useState('');
const [filterDeviceType, setFilterDeviceType] = useState<string | undefined>(undefined);
const fetchDevices = useCallback(async () => {
setLoading(true);
try {
const res = await deviceApi.listDevices({
page,
page_size: 20,
...(filterPatientId ? { patient_id: filterPatientId } : {}),
...(filterDeviceType ? { device_type: filterDeviceType } : {}),
});
setData(res.data);
setTotal(res.total);
} catch {
message.error('加载设备列表失败');
} finally {
setLoading(false);
}
}, [page, filterPatientId, filterDeviceType]);
useEffect(() => {
fetchDevices();
}, [fetchDevices]);
const handleUnbind = async (record: DeviceItem) => {
try {
await deviceApi.unbindDevice(record.id, record.version);
message.success('设备已解绑');
fetchDevices();
} catch {
message.error('解绑失败');
}
};
const columns: ColumnsType<DeviceItem> = [
{
title: '设备 ID',
dataIndex: 'device_id',
width: 120,
render: (v: string) => v.slice(0, 8),
},
{
title: '设备型号',
dataIndex: 'device_model',
width: 160,
},
{
title: '设备类型',
dataIndex: 'device_type',
width: 100,
render: (v: string) => {
const label = DEVICE_TYPE_OPTIONS.find((d) => d.value === v)?.label || v;
return <Tag color={DEVICE_TYPE_COLOR[v] || 'default'}>{label}</Tag>;
},
},
{
title: '绑定时间',
dataIndex: 'bound_at',
width: 170,
render: (v: string) => formatTime(v),
},
{
title: '最后同步',
dataIndex: 'last_sync_at',
width: 170,
render: (v: string) => formatTime(v),
},
{
title: '操作',
width: 80,
render: (_, record) => (
<Popconfirm
title="确认解绑"
description="解绑后设备将不再关联该患者,确定继续?"
onConfirm={() => handleUnbind(record)}
okText="确定"
cancelText="取消"
>
<Button size="small" type="link" danger>
</Button>
</Popconfirm>
),
},
];
return (
<div style={{ padding: 24 }}>
<h2 style={{ marginBottom: 16 }}></h2>
<Space style={{ marginBottom: 16 }} wrap>
<Input
placeholder="患者 ID"
value={filterPatientId}
onChange={(e) => setFilterPatientId(e.target.value)}
style={{ width: 200 }}
allowClear
/>
<Select
placeholder="设备类型"
value={filterDeviceType}
onChange={setFilterDeviceType}
options={DEVICE_TYPE_OPTIONS}
style={{ width: 140 }}
allowClear
/>
<Button type="primary" onClick={() => setPage(1)}>
</Button>
</Space>
<Table<DeviceItem>
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
showTotal: (t) => `${t}`,
onChange: (p) => setPage(p),
}}
/>
</div>
);
}

View File

@@ -26,6 +26,7 @@ import { VitalSignsTab } from './components/VitalSignsTab';
import { LabReportsTab } from './components/LabReportsTab';
import { HealthRecordsTab } from './components/HealthRecordsTab';
import { FollowUpTab } from './components/FollowUpTab';
import { DeviceReadingsTab } from './components/DeviceReadingsTab';
import { GENDER_OPTIONS, BLOOD_TYPE_OPTIONS } from '../../constants/health';
import { useThemeMode } from '../../hooks/useThemeMode';
@@ -276,6 +277,7 @@ export default function PatientDetail() {
size="small"
items={[
{ key: 'vital', label: '体征数据', children: <VitalSignsTab patientId={id} /> },
{ key: 'device', label: '设备数据', children: <DeviceReadingsTab patientId={id} /> },
{ key: 'lab', label: '化验报告', children: <LabReportsTab patientId={id} /> },
{ key: 'records', label: '健康档案', children: <HealthRecordsTab patientId={id} /> },
]}

View File

@@ -0,0 +1,312 @@
import { useCallback, useMemo, useState } from 'react';
import { Table, Select, Tabs, Card, Typography } from 'antd';
import { deviceReadingApi } from '../../../api/health/deviceReadings';
import type { DeviceReading, HourlyReading } from '../../../api/health/deviceReadings';
import { usePaginatedData } from '../../../hooks/usePaginatedData';
const { Text } = Typography;
/* ---------- 常量 ---------- */
const DEVICE_TYPE_OPTIONS = [
{ value: 'heart_rate', label: '心率' },
{ value: 'blood_oxygen', label: '血氧' },
{ value: 'blood_pressure', label: '血压' },
{ value: 'blood_glucose', label: '血糖' },
{ value: 'steps', label: '步数' },
{ value: 'temperature', label: '体温' },
] as const;
const TIME_RANGE_OPTIONS = [
{ value: 1, label: '最近 1 小时' },
{ value: 6, label: '最近 6 小时' },
{ value: 24, label: '最近 24 小时' },
{ value: 168, label: '最近 7 天' },
] as const;
const DAYS_RANGE_OPTIONS = [
{ value: 1, label: '1 天' },
{ value: 3, label: '3 天' },
{ value: 7, label: '7 天' },
{ value: 30, label: '30 天' },
] as const;
const DEVICE_TYPE_MAP: Record<string, string> = Object.fromEntries(
DEVICE_TYPE_OPTIONS.map((o) => [o.value, o.label]),
);
const PAGE_SIZE = 10;
/* ---------- Props ---------- */
interface Props {
patientId: string;
}
/* ---------- 原始数据 Tab ---------- */
interface RawFilters {
deviceType: string | undefined;
hours: number;
}
function RawDataTab({ patientId }: Props) {
const [deviceType, setDeviceType] = useState<string | undefined>(undefined);
const [hours, setHours] = useState<number>(24);
const fetcher = useCallback(
async (page: number, pageSize: number) => {
return deviceReadingApi.query({
patient_id: patientId,
device_type: deviceType,
hours,
page,
page_size: pageSize,
});
},
[patientId, deviceType, hours],
);
const { data, total, page, loading, refresh } = usePaginatedData<DeviceReading>(
fetcher,
PAGE_SIZE,
false,
);
const columns = useMemo(
() => [
{
title: '测量时间',
dataIndex: 'measured_at',
key: 'measured_at',
width: 180,
render: (v: string) => v ?? '-',
},
{
title: '设备类型',
dataIndex: 'device_type',
key: 'device_type',
width: 100,
render: (v: string) => DEVICE_TYPE_MAP[v] ?? v,
},
{
title: '指标',
dataIndex: 'raw_value',
key: 'metric',
width: 120,
render: (v: Record<string, unknown>) => {
const keys = Object.keys(v);
return keys.length > 0 ? keys.join(', ') : '-';
},
},
{
title: '原始值',
dataIndex: 'raw_value',
key: 'raw_value',
render: (v: Record<string, unknown>) => JSON.stringify(v),
},
{
title: '设备型号',
dataIndex: 'device_model',
key: 'device_model',
width: 120,
render: (v?: string) => v ?? '-',
},
],
[],
);
return (
<>
<div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
<Select
placeholder="设备类型"
allowClear
style={{ width: 140 }}
options={[...DEVICE_TYPE_OPTIONS]}
value={deviceType}
onChange={(v) => {
setDeviceType(v);
refresh(1);
}}
/>
<Select
style={{ width: 140 }}
options={[...TIME_RANGE_OPTIONS]}
value={hours}
onChange={(v) => {
setHours(v);
refresh(1);
}}
/>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: page,
total,
pageSize: PAGE_SIZE,
onChange: (p) => refresh(p),
showTotal: (t) => `${t}`,
size: 'small',
}}
/>
</>
);
}
/* ---------- 小时聚合 Tab ---------- */
interface HourlyFilters {
deviceType: string | undefined;
days: number;
}
function HourlyAggTab({ patientId }: Props) {
const [deviceType, setDeviceType] = useState<string | undefined>(undefined);
const [days, setDays] = useState<number>(7);
const fetcher = useCallback(
async (page: number, pageSize: number) => {
if (!deviceType) {
return { data: [] as HourlyReading[], total: 0 };
}
return deviceReadingApi.queryHourly({
patient_id: patientId,
device_type: deviceType,
days,
page,
page_size: pageSize,
});
},
[patientId, deviceType, days],
);
const { data, total, page, loading, refresh } = usePaginatedData<HourlyReading>(
fetcher,
PAGE_SIZE,
false,
);
const columns = useMemo(
() => [
{
title: '小时起始时间',
dataIndex: 'hour_start',
key: 'hour_start',
width: 180,
render: (v: string) => v ?? '-',
},
{
title: '设备类型',
dataIndex: 'device_type',
key: 'device_type',
width: 100,
render: (v: string) => DEVICE_TYPE_MAP[v] ?? v,
},
{
title: '最小值',
dataIndex: 'min_val',
key: 'min_val',
width: 100,
render: (v?: number) => (v != null ? v.toFixed(2) : '-'),
},
{
title: '最大值',
dataIndex: 'max_val',
key: 'max_val',
width: 100,
render: (v?: number) => (v != null ? v.toFixed(2) : '-'),
},
{
title: '平均值',
dataIndex: 'avg_val',
key: 'avg_val',
width: 100,
render: (v: number) => v.toFixed(2),
},
{
title: '采样数',
dataIndex: 'sample_count',
key: 'sample_count',
width: 80,
},
],
[],
);
return (
<>
<div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
<Select
placeholder="设备类型"
allowClear
style={{ width: 140 }}
options={[...DEVICE_TYPE_OPTIONS]}
value={deviceType}
onChange={(v) => {
setDeviceType(v);
refresh(1);
}}
/>
<Select
style={{ width: 140 }}
options={[...DAYS_RANGE_OPTIONS]}
value={days}
onChange={(v) => {
setDays(v);
refresh(1);
}}
/>
</div>
{!deviceType ? (
<Text type="secondary"></Text>
) : (
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: page,
total,
pageSize: PAGE_SIZE,
onChange: (p) => refresh(p),
showTotal: (t) => `${t}`,
size: 'small',
}}
/>
)}
</>
);
}
/* ---------- 主组件 ---------- */
export function DeviceReadingsTab({ patientId }: Props) {
return (
<Card size="small">
<Tabs
items={[
{
key: 'raw',
label: '原始数据',
children: <RawDataTab patientId={patientId} />,
},
{
key: 'hourly',
label: '小时聚合',
children: <HourlyAggTab patientId={patientId} />,
},
]}
/>
</Card>
);
}

View File

@@ -68,6 +68,9 @@ pub enum HealthError {
#[error("知情同意记录不存在")]
ConsentNotFound,
#[error("设备绑定不存在")]
DeviceNotFound,
#[error("告警规则不存在")]
AlertRuleNotFound,
@@ -118,6 +121,7 @@ impl From<HealthError> for AppError {
| HealthError::ThresholdNotFound
| HealthError::ConsentNotFound
| HealthError::AlertRuleNotFound
| HealthError::DeviceNotFound
| HealthError::AlertNotFound
| HealthError::DialysisPrescriptionNotFound
| HealthError::FollowUpTemplateNotFound

View File

@@ -0,0 +1,85 @@
//! 设备管理 API — 设备列表查询与解绑
use axum::extract::{FromRef, Path, Query, State};
use axum::response::IntoResponse;
use axum::Extension;
use serde::Deserialize;
use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::DeleteWithVersion;
use crate::service::device_service;
use crate::state::HealthState;
/// 设备列表查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct DeviceListQuery {
/// 按患者 ID 筛选
pub patient_id: Option<Uuid>,
/// 按设备类型筛选
pub device_type: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
/// GET /api/v1/health/devices — 设备绑定列表
pub async fn list_devices<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(query): Query<DeviceListQuery>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.devices.list")?;
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let (items, total) = device_service::list_devices(
&state,
ctx.tenant_id,
query.patient_id,
query.device_type.as_deref(),
page,
page_size,
)
.await?;
Ok(axum::Json(ApiResponse::ok(PaginatedResponse {
data: items,
total,
page,
page_size,
total_pages: total.div_ceil(page_size.max(1)),
})))
}
/// DELETE /api/v1/health/devices/{id} — 解绑设备(软删除)
pub async fn unbind_device<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
axum::Json(body): axum::Json<DeleteWithVersion>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.devices.manage")?;
let device = device_service::unbind_device(
&state,
ctx.tenant_id,
id,
ctx.user_id,
body.version,
)
.await?;
Ok(axum::Json(ApiResponse::ok(device)))
}

View File

@@ -9,6 +9,7 @@ pub mod consent_handler;
pub mod critical_alert_handler;
pub mod critical_value_threshold_handler;
pub mod daily_monitoring_handler;
pub mod device_handler;
pub mod device_reading_handler;
pub mod diagnosis_handler;
pub mod medication_record_handler;

View File

@@ -7,7 +7,7 @@ use erp_core::module::{ErpModule, PermissionDescriptor};
use crate::handler::{
alert_handler, alert_rule_handler,
appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_alert_handler, critical_value_threshold_handler, daily_monitoring_handler, device_reading_handler, diagnosis_handler, doctor_handler, follow_up_handler, follow_up_template_handler,
appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_alert_handler, critical_value_threshold_handler, daily_monitoring_handler, device_handler, device_reading_handler, diagnosis_handler, doctor_handler, follow_up_handler, follow_up_template_handler,
health_data_handler, medication_record_handler, patient_handler, points_handler, stats_handler,
};
@@ -653,6 +653,15 @@ impl HealthModule {
"/health/alert-rules/{id}/deactivate",
axum::routing::put(alert_rule_handler::deactivate),
)
// 设备管理
.route(
"/health/devices",
axum::routing::get(device_handler::list_devices),
)
.route(
"/health/devices/{id}",
axum::routing::delete(device_handler::unbind_device),
)
}
}
@@ -884,6 +893,18 @@ impl ErpModule for HealthModule {
description: "提交设备采集数据".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.devices.list".into(),
name: "查看设备绑定".into(),
description: "查看设备绑定记录列表".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.devices.manage".into(),
name: "管理设备绑定".into(),
description: "解绑设备".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.alerts.list".into(),
name: "查看告警".into(),

View File

@@ -0,0 +1,72 @@
//! 设备管理服务 — 设备绑定记录的查询与解绑
use chrono::Utc;
use sea_orm::entity::prelude::*;
use sea_orm::ActiveValue::Set;
use sea_orm::{QueryOrder, QuerySelect};
use uuid::Uuid;
use crate::entity::patient_devices;
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
/// 查询设备绑定记录(分页),支持按 patient_id / device_type 筛选
pub async fn list_devices(
state: &HealthState,
tenant_id: Uuid,
patient_id: Option<Uuid>,
device_type: Option<&str>,
page: u64,
page_size: u64,
) -> HealthResult<(Vec<patient_devices::Model>, u64)> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = patient_devices::Entity::find()
.filter(patient_devices::Column::TenantId.eq(tenant_id))
.filter(patient_devices::Column::DeletedAt.is_null());
if let Some(pid) = patient_id {
query = query.filter(patient_devices::Column::PatientId.eq(pid));
}
if let Some(dt) = device_type {
query = query.filter(patient_devices::Column::DeviceType.eq(dt));
}
let total = query.clone().count(&state.db).await?;
let items = query
.order_by_desc(patient_devices::Column::CreatedAt)
.limit(limit)
.offset(offset)
.all(&state.db)
.await?;
Ok((items, total))
}
/// 解绑设备 — 设置 deleted_at 实现软删除,递增 version
pub async fn unbind_device(
state: &HealthState,
tenant_id: Uuid,
device_id: Uuid,
user_id: Uuid,
version: i32,
) -> HealthResult<patient_devices::Model> {
let device = patient_devices::Entity::find_by_id(device_id)
.filter(patient_devices::Column::TenantId.eq(tenant_id))
.filter(patient_devices::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::DeviceNotFound)?;
// 乐观锁校验
erp_core::error::check_version(device.version, version)?;
let mut active: patient_devices::ActiveModel = device.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(Some(user_id));
active.version = Set(version + 1);
Ok(active.update(&state.db).await?)
}

View File

@@ -11,6 +11,7 @@ pub mod critical_alert_service;
pub mod critical_value_threshold_service;
pub mod daily_monitoring_service;
pub mod device_reading_service;
pub mod device_service;
pub mod diagnosis_service;
pub mod medication_record_service;
pub mod doctor_service;

View File

@@ -94,6 +94,7 @@ mod m20260428_000091_dead_letter_events;
mod m20260429_000092_device_readings_metric;
mod m20260429_000093_trend_analysis_prompt_v2;
mod m20260429_000094_device_readings_unique_constraint;
mod m20260429_000095_seed_alert_device_menus;
pub struct Migrator;
@@ -195,6 +196,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260429_000092_device_readings_metric::Migration),
Box::new(m20260429_000093_trend_analysis_prompt_v2::Migration),
Box::new(m20260429_000094_device_readings_unique_constraint::Migration),
Box::new(m20260429_000095_seed_alert_device_menus::Migration),
]
}
}

View File

@@ -0,0 +1,84 @@
//! 补充告警和设备管理菜单种子数据
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();
// 获取默认租户 ID
let result = db.query_one(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
"SELECT id::text FROM tenant LIMIT 1".to_string(),
))
.await?;
let tid = match result {
Some(row) => row.try_get_by_index::<String>(0).unwrap_or_default(),
None => return Ok(()),
};
let sys = "00000000-0000-0000-0000-000000000000";
let d3 = "a0000000-0000-0000-0000-000000000003"; // 健康管理目录
// 告警相关菜单(排在 AI 用量统计之后)
insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000016", "告警仪表盘", "/health/alert-dashboard", "AlertOutlined", 15, sys).await?;
insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000017", "告警列表", "/health/alerts", "BellOutlined", 16, sys).await?;
insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000018", "告警规则", "/health/alert-rules", "ControlOutlined", 17, sys).await?;
// 设备管理菜单
insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000019", "设备管理", "/health/devices", "ApiOutlined", 18, sys).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
let ids = [
"b0000003-0000-0000-0000-000000000016",
"b0000003-0000-0000-0000-000000000017",
"b0000003-0000-0000-0000-000000000018",
"b0000003-0000-0000-0000-000000000019",
];
for id in &ids {
db.execute(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
format!("DELETE FROM menus WHERE id = '{id}'"),
))
.await
.ok();
}
Ok(())
}
}
async fn insert_menu(
db: &sea_orm_migration::SchemaManagerConnection<'_>,
tenant_id: &str,
parent_id: &str,
id: &str,
title: &str,
path: &str,
icon: &str,
sort: i32,
sys: &str,
) -> Result<(), DbErr> {
let esc_title = title.replace('\'', "''");
db.execute(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
format!(
"INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order, visible, menu_type, permission, created_at, updated_at, created_by, updated_by, deleted_at, version) \
VALUES ('{id}', '{tenant_id}', '{parent_id}', '{esc_title}', '{path}', '{icon}', {sort}, true, 'menu', NULL, NOW(), NOW(), '{sys}', '{sys}', NULL, 1) \
ON CONFLICT (id) DO NOTHING"
),
))
.await?;
Ok(())
}