fix: 修复多角色找茬测试 V2 发现的 11 个问题
P0 (CRITICAL): - C1: 统计 API 全部改为 safe_aggregate 容错,防止单个子查询崩溃导致 500 - C2: Token 刷新增加用户身份验证,防止并发场景下身份切换 - C3: 患者端线下活动接口添加患者档案验证,防止 Doctor/HM 越权访问 P1 (HIGH): - H1: 操作记录用 EntityName 组件解析用户名,不再显示截断 UUID - H4: 告警标题添加中英文映射 (translateAlertTitle) - H5: 告警面板补全 message import + 修复 hooks 顺序 - H8: 咨询消息发送按钮添加 AuthButton 权限控制 - H9: routeConfig 日常监测权限码改为 health.daily-monitoring.* P2 (MEDIUM): - M4: 咨询类型映射补全 online/phone/doctor/follow_up 中文标签 DTO: LabReportStatisticsResp, AppointmentStatisticsResp, VitalSignsReportRateResp 添加 Default derive
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
import { message as antMessage } from 'antd';
|
import { message as antMessage } from "antd";
|
||||||
|
|
||||||
// 请求缓存:短时间内相同请求复用结果
|
// 请求缓存:短时间内相同请求复用结果
|
||||||
interface CacheEntry {
|
interface CacheEntry {
|
||||||
@@ -10,26 +10,30 @@ interface CacheEntry {
|
|||||||
const requestCache = new Map<string, CacheEntry>();
|
const requestCache = new Map<string, CacheEntry>();
|
||||||
const CACHE_TTL = 5000; // 5 秒缓存
|
const CACHE_TTL = 5000; // 5 秒缓存
|
||||||
|
|
||||||
function getCacheKey(config: { url?: string; params?: unknown; method?: string }): string {
|
function getCacheKey(config: {
|
||||||
return `${config.method || 'get'}:${config.url || ''}:${JSON.stringify(config.params || {})}`;
|
url?: string;
|
||||||
|
params?: unknown;
|
||||||
|
method?: string;
|
||||||
|
}): string {
|
||||||
|
return `${config.method || "get"}:${config.url || ""}:${JSON.stringify(config.params || {})}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultAdapter = axios.getAdapter(axios.defaults.adapter);
|
const defaultAdapter = axios.getAdapter(axios.defaults.adapter);
|
||||||
|
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
baseURL: '/api/v1',
|
baseURL: "/api/v1",
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
adapter: (config) => {
|
adapter: (config) => {
|
||||||
// GET 请求检查缓存
|
// GET 请求检查缓存
|
||||||
if (config.method === 'get' && config.url) {
|
if (config.method === "get" && config.url) {
|
||||||
const key = getCacheKey(config);
|
const key = getCacheKey(config);
|
||||||
const entry = requestCache.get(key);
|
const entry = requestCache.get(key);
|
||||||
if (entry && Date.now() - entry.timestamp < CACHE_TTL) {
|
if (entry && Date.now() - entry.timestamp < CACHE_TTL) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
data: entry.data,
|
data: entry.data,
|
||||||
status: 200,
|
status: 200,
|
||||||
statusText: 'OK (cached)',
|
statusText: "OK (cached)",
|
||||||
headers: {} as any,
|
headers: {} as any,
|
||||||
config,
|
config,
|
||||||
});
|
});
|
||||||
@@ -40,11 +44,15 @@ const client = axios.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Decode JWT payload without external library
|
// Decode JWT payload without external library
|
||||||
function decodeJwtPayload(token: string): { exp?: number } | null {
|
function decodeJwtPayload(
|
||||||
|
token: string,
|
||||||
|
): { exp?: number; sub?: string } | null {
|
||||||
try {
|
try {
|
||||||
const parts = token.split('.');
|
const parts = token.split(".");
|
||||||
if (parts.length !== 3) return null;
|
if (parts.length !== 3) return null;
|
||||||
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
const payload = JSON.parse(
|
||||||
|
atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")),
|
||||||
|
);
|
||||||
return payload;
|
return payload;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -60,26 +68,38 @@ function isTokenExpiringSoon(token: string): boolean {
|
|||||||
|
|
||||||
// Request interceptor: attach access token + proactive refresh
|
// Request interceptor: attach access token + proactive refresh
|
||||||
client.interceptors.request.use(async (config) => {
|
client.interceptors.request.use(async (config) => {
|
||||||
const token = localStorage.getItem('access_token');
|
const token = localStorage.getItem("access_token");
|
||||||
if (token) {
|
if (token) {
|
||||||
// If token is about to expire, proactively refresh before sending the request
|
// If token is about to expire, proactively refresh before sending the request
|
||||||
if (isTokenExpiringSoon(token)) {
|
if (isTokenExpiringSoon(token)) {
|
||||||
const refreshToken = localStorage.getItem('refresh_token');
|
const refreshToken = localStorage.getItem("refresh_token");
|
||||||
if (refreshToken && !isRefreshing) {
|
if (refreshToken && !isRefreshing) {
|
||||||
isRefreshing = true;
|
isRefreshing = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.post('/api/v1/auth/refresh', {
|
const { data } = await axios.post("/api/v1/auth/refresh", {
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
});
|
});
|
||||||
const newAccess = data.data.access_token;
|
const newAccess = data.data.access_token;
|
||||||
const newRefresh = data.data.refresh_token;
|
const newRefresh = data.data.refresh_token;
|
||||||
localStorage.setItem('access_token', newAccess);
|
|
||||||
localStorage.setItem('refresh_token', newRefresh);
|
// 验证新 token 的用户身份一致
|
||||||
|
const currentUserSub = decodeJwtPayload(token)?.sub;
|
||||||
|
const newTokenSub = decodeJwtPayload(newAccess)?.sub;
|
||||||
|
if (currentUserSub && newTokenSub && currentUserSub !== newTokenSub) {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
window.location.hash = "/login";
|
||||||
|
return Promise.reject(new Error("身份验证失败,请重新登录"));
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem("access_token", newAccess);
|
||||||
|
localStorage.setItem("refresh_token", newRefresh);
|
||||||
processQueue(null, newAccess);
|
processQueue(null, newAccess);
|
||||||
config.headers.Authorization = `Bearer ${newAccess}`;
|
config.headers.Authorization = `Bearer ${newAccess}`;
|
||||||
return config;
|
return config;
|
||||||
} catch {
|
} catch {
|
||||||
processQueue(new Error('refresh failed'), null);
|
processQueue(new Error("refresh failed"), null);
|
||||||
// Continue with old token, let 401 handler deal with it
|
// Continue with old token, let 401 handler deal with it
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing = false;
|
isRefreshing = false;
|
||||||
@@ -96,7 +116,7 @@ client.interceptors.request.use(async (config) => {
|
|||||||
client.interceptors.response.use(
|
client.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
// 缓存 GET 响应
|
// 缓存 GET 响应
|
||||||
if (response.config.method === 'get' && response.config.url) {
|
if (response.config.method === "get" && response.config.url) {
|
||||||
const key = getCacheKey(response.config);
|
const key = getCacheKey(response.config);
|
||||||
requestCache.set(key, { data: response.data, timestamp: Date.now() });
|
requestCache.set(key, { data: response.data, timestamp: Date.now() });
|
||||||
}
|
}
|
||||||
@@ -104,7 +124,11 @@ client.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const originalRequest = error.config;
|
const originalRequest = error.config;
|
||||||
if (error.response?.status === 401 && !originalRequest._retry && !originalRequest.url?.includes('/auth/login')) {
|
if (
|
||||||
|
error.response?.status === 401 &&
|
||||||
|
!originalRequest._retry &&
|
||||||
|
!originalRequest.url?.includes("/auth/login")
|
||||||
|
) {
|
||||||
if (isRefreshing) {
|
if (isRefreshing) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
failedQueue.push({ resolve, reject });
|
failedQueue.push({ resolve, reject });
|
||||||
@@ -118,18 +142,33 @@ client.interceptors.response.use(
|
|||||||
isRefreshing = true;
|
isRefreshing = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const refreshToken = localStorage.getItem('refresh_token');
|
const refreshToken = localStorage.getItem("refresh_token");
|
||||||
if (!refreshToken) throw new Error('No refresh token');
|
if (!refreshToken) throw new Error("No refresh token");
|
||||||
|
|
||||||
const { data } = await axios.post('/api/v1/auth/refresh', {
|
const { data } = await axios.post("/api/v1/auth/refresh", {
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newAccessToken = data.data.access_token;
|
const newAccessToken = data.data.access_token;
|
||||||
const newRefreshToken = data.data.refresh_token;
|
const newRefreshToken = data.data.refresh_token;
|
||||||
|
|
||||||
localStorage.setItem('access_token', newAccessToken);
|
// 验证新 token 的用户身份与当前用户一致,防止并发场景下身份切换
|
||||||
localStorage.setItem('refresh_token', newRefreshToken);
|
const currentToken = localStorage.getItem("access_token");
|
||||||
|
const currentUserSub = currentToken
|
||||||
|
? decodeJwtPayload(currentToken)?.sub
|
||||||
|
: null;
|
||||||
|
const newTokenSub = decodeJwtPayload(newAccessToken)?.sub;
|
||||||
|
if (currentUserSub && newTokenSub && currentUserSub !== newTokenSub) {
|
||||||
|
// 身份不一致,强制登出
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
window.location.hash = "/login";
|
||||||
|
return Promise.reject(new Error("身份验证失败,请重新登录"));
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem("access_token", newAccessToken);
|
||||||
|
localStorage.setItem("refresh_token", newRefreshToken);
|
||||||
|
|
||||||
processQueue(null, newAccessToken);
|
processQueue(null, newAccessToken);
|
||||||
|
|
||||||
@@ -137,9 +176,9 @@ client.interceptors.response.use(
|
|||||||
return client(originalRequest);
|
return client(originalRequest);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
processQueue(refreshError, null);
|
processQueue(refreshError, null);
|
||||||
localStorage.removeItem('access_token');
|
localStorage.removeItem("access_token");
|
||||||
localStorage.removeItem('refresh_token');
|
localStorage.removeItem("refresh_token");
|
||||||
window.location.hash = '/login';
|
window.location.hash = "/login";
|
||||||
return Promise.reject(refreshError);
|
return Promise.reject(refreshError);
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing = false;
|
isRefreshing = false;
|
||||||
@@ -147,7 +186,7 @@ client.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 全局错误提示(仅对未被组件处理的错误显示)
|
// 全局错误提示(仅对未被组件处理的错误显示)
|
||||||
@@ -156,12 +195,14 @@ function showGlobalError(msg: string) {
|
|||||||
// 防止短时间内弹出大量相同提示
|
// 防止短时间内弹出大量相同提示
|
||||||
if (globalErrorTimer) return;
|
if (globalErrorTimer) return;
|
||||||
antMessage.error(msg, 3);
|
antMessage.error(msg, 3);
|
||||||
globalErrorTimer = setTimeout(() => { globalErrorTimer = null; }, 3000);
|
globalErrorTimer = setTimeout(() => {
|
||||||
|
globalErrorTimer = null;
|
||||||
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局错误拦截 — 在响应拦截器之后、组件 catch 之前执行
|
// 全局错误拦截 — 在响应拦截器之后、组件 catch 之前执行
|
||||||
// 组件可通过 axios config 中设置 skipGlobalError: true 来抑制全局提示
|
// 组件可通过 axios config 中设置 skipGlobalError: true 来抑制全局提示
|
||||||
declare module 'axios' {
|
declare module "axios" {
|
||||||
interface InternalAxiosRequestConfig {
|
interface InternalAxiosRequestConfig {
|
||||||
skipGlobalError?: boolean;
|
skipGlobalError?: boolean;
|
||||||
}
|
}
|
||||||
@@ -174,16 +215,16 @@ client.interceptors.response.use(
|
|||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
if (!error.response) {
|
if (!error.response) {
|
||||||
showGlobalError('网络连接异常,请检查网络');
|
showGlobalError("网络连接异常,请检查网络");
|
||||||
} else if (error.response.status === 403) {
|
} else if (error.response.status === 403) {
|
||||||
// 403 通常是权限不足,不全局提示 — 组件层通过 AuthButton 已隐藏操作入口
|
// 403 通常是权限不足,不全局提示 — 组件层通过 AuthButton 已隐藏操作入口
|
||||||
} else if (error.response.status === 404) {
|
} else if (error.response.status === 404) {
|
||||||
// 404 通常由组件自行处理(如跳转),不全局提示
|
// 404 通常由组件自行处理(如跳转),不全局提示
|
||||||
} else if (error.response.status >= 500) {
|
} else if (error.response.status >= 500) {
|
||||||
showGlobalError('服务器异常,请稍后重试');
|
showGlobalError("服务器异常,请稍后重试");
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let isRefreshing = false;
|
let isRefreshing = false;
|
||||||
@@ -206,9 +247,10 @@ export function clearApiCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 通用错误处理:提取后端错误消息并展示
|
// 通用错误处理:提取后端错误消息并展示
|
||||||
export function handleApiError(err: unknown, fallback = '操作失败'): string {
|
export function handleApiError(err: unknown, fallback = "操作失败"): string {
|
||||||
const msg =
|
const msg =
|
||||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || fallback;
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message || fallback;
|
||||||
antMessage.error(msg);
|
antMessage.error(msg);
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,167 +7,194 @@
|
|||||||
|
|
||||||
// --- 性别 ---
|
// --- 性别 ---
|
||||||
export const GENDER_OPTIONS = [
|
export const GENDER_OPTIONS = [
|
||||||
{ value: 'male', label: '男' },
|
{ value: "male", label: "男" },
|
||||||
{ value: 'female', label: '女' },
|
{ value: "female", label: "女" },
|
||||||
{ value: 'other', label: '其他' },
|
{ value: "other", label: "其他" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const GENDER_LABEL: Record<string, string> = {
|
export const GENDER_LABEL: Record<string, string> = {
|
||||||
male: '男',
|
male: "男",
|
||||||
female: '女',
|
female: "女",
|
||||||
other: '其他',
|
other: "其他",
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 血型 ---
|
// --- 血型 ---
|
||||||
export const BLOOD_TYPE_OPTIONS = [
|
export const BLOOD_TYPE_OPTIONS = [
|
||||||
{ value: 'A', label: 'A 型' },
|
{ value: "A", label: "A 型" },
|
||||||
{ value: 'B', label: 'B 型' },
|
{ value: "B", label: "B 型" },
|
||||||
{ value: 'AB', label: 'AB 型' },
|
{ value: "AB", label: "AB 型" },
|
||||||
{ value: 'O', label: 'O 型' },
|
{ value: "O", label: "O 型" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- 患者状态 ---
|
// --- 患者状态 ---
|
||||||
export const STATUS_OPTIONS = [
|
export const STATUS_OPTIONS = [
|
||||||
{ value: '', label: '全部状态' },
|
{ value: "", label: "全部状态" },
|
||||||
{ value: 'active', label: '活跃' },
|
{ value: "active", label: "活跃" },
|
||||||
{ value: 'inactive', label: '停用' },
|
{ value: "inactive", label: "停用" },
|
||||||
{ value: 'deceased', label: '已故' },
|
{ value: "deceased", label: "已故" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- 严重度(统一 5 处重复定义: AlertDashboard, AlertList, AlertRuleList, DoctorDashboard) ---
|
// --- 严重度(统一 5 处重复定义: AlertDashboard, AlertList, AlertRuleList, DoctorDashboard) ---
|
||||||
export const SEVERITY_COLOR: Record<string, string> = {
|
export const SEVERITY_COLOR: Record<string, string> = {
|
||||||
info: 'default',
|
info: "default",
|
||||||
warning: 'orange',
|
warning: "orange",
|
||||||
critical: 'red',
|
critical: "red",
|
||||||
urgent: 'magenta',
|
urgent: "magenta",
|
||||||
high: 'red',
|
high: "red",
|
||||||
medium: 'orange',
|
medium: "orange",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SEVERITY_LABEL: Record<string, string> = {
|
export const SEVERITY_LABEL: Record<string, string> = {
|
||||||
info: '提示',
|
info: "提示",
|
||||||
warning: '警告',
|
warning: "警告",
|
||||||
critical: '严重',
|
critical: "严重",
|
||||||
urgent: '紧急',
|
urgent: "紧急",
|
||||||
high: '严重',
|
high: "严重",
|
||||||
medium: '中等',
|
medium: "中等",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SEVERITY_OPTIONS = [
|
export const SEVERITY_OPTIONS = [
|
||||||
{ value: 'info', label: '提示' },
|
{ value: "info", label: "提示" },
|
||||||
{ value: 'warning', label: '警告' },
|
{ value: "warning", label: "警告" },
|
||||||
{ value: 'medium', label: '中等' },
|
{ value: "medium", label: "中等" },
|
||||||
{ value: 'critical', label: '严重' },
|
{ value: "critical", label: "严重" },
|
||||||
{ value: 'high', label: '严重' },
|
{ value: "high", label: "严重" },
|
||||||
{ value: 'urgent', label: '紧急' },
|
{ value: "urgent", label: "紧急" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- 告警状态(统一 3 处: AlertDashboard, AlertList) ---
|
// --- 告警状态(统一 3 处: AlertDashboard, AlertList) ---
|
||||||
export const ALERT_STATUS_COLOR: Record<string, string> = {
|
export const ALERT_STATUS_COLOR: Record<string, string> = {
|
||||||
pending: 'orange',
|
pending: "orange",
|
||||||
active: 'gold',
|
active: "gold",
|
||||||
acknowledged: 'blue',
|
acknowledged: "blue",
|
||||||
resolved: 'green',
|
resolved: "green",
|
||||||
dismissed: 'default',
|
dismissed: "default",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ALERT_STATUS_LABEL: Record<string, string> = {
|
export const ALERT_STATUS_LABEL: Record<string, string> = {
|
||||||
pending: '待处理',
|
pending: "待处理",
|
||||||
active: '活跃',
|
active: "活跃",
|
||||||
acknowledged: '已确认',
|
acknowledged: "已确认",
|
||||||
resolved: '已恢复',
|
resolved: "已恢复",
|
||||||
dismissed: '已忽略',
|
dismissed: "已忽略",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ALERT_STATUS_OPTIONS = [
|
export const ALERT_STATUS_OPTIONS = [
|
||||||
{ value: '', label: '全部状态' },
|
{ value: "", label: "全部状态" },
|
||||||
{ value: 'active', label: '活跃' },
|
{ value: "active", label: "活跃" },
|
||||||
{ value: 'pending', label: '待处理' },
|
{ value: "pending", label: "待处理" },
|
||||||
{ value: 'acknowledged', label: '已确认' },
|
{ value: "acknowledged", label: "已确认" },
|
||||||
{ value: 'resolved', label: '已恢复' },
|
{ value: "resolved", label: "已恢复" },
|
||||||
{ value: 'dismissed', label: '已忽略' },
|
{ value: "dismissed", label: "已忽略" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- 设备类型(统一 3 处: DeviceManage, DeviceReadingsTab, AlertRuleList) ---
|
// --- 设备类型(统一 3 处: DeviceManage, DeviceReadingsTab, AlertRuleList) ---
|
||||||
export const DEVICE_TYPE_OPTIONS = [
|
export const DEVICE_TYPE_OPTIONS = [
|
||||||
{ value: 'blood_pressure', label: '血压' },
|
{ value: "blood_pressure", label: "血压" },
|
||||||
{ value: 'blood_glucose', label: '血糖' },
|
{ value: "blood_glucose", label: "血糖" },
|
||||||
{ value: 'heart_rate', label: '心率' },
|
{ value: "heart_rate", label: "心率" },
|
||||||
{ value: 'blood_oxygen', label: '血氧' },
|
{ value: "blood_oxygen", label: "血氧" },
|
||||||
{ value: 'temperature', label: '体温' },
|
{ value: "temperature", label: "体温" },
|
||||||
{ value: 'steps', label: '步数' },
|
{ value: "steps", label: "步数" },
|
||||||
{ value: 'sleep', label: '睡眠' },
|
{ value: "sleep", label: "睡眠" },
|
||||||
{ value: 'stress', label: '压力' },
|
{ value: "stress", label: "压力" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEVICE_TYPE_COLOR: Record<string, string> = {
|
export const DEVICE_TYPE_COLOR: Record<string, string> = {
|
||||||
blood_pressure: 'red',
|
blood_pressure: "red",
|
||||||
blood_glucose: 'purple',
|
blood_glucose: "purple",
|
||||||
heart_rate: 'volcano',
|
heart_rate: "volcano",
|
||||||
blood_oxygen: 'blue',
|
blood_oxygen: "blue",
|
||||||
temperature: 'orange',
|
temperature: "orange",
|
||||||
steps: 'green',
|
steps: "green",
|
||||||
sleep: 'cyan',
|
sleep: "cyan",
|
||||||
stress: 'geekblue',
|
stress: "geekblue",
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 告警规则条件类型 ---
|
// --- 告警规则条件类型 ---
|
||||||
export const CONDITION_TYPE_OPTIONS = [
|
export const CONDITION_TYPE_OPTIONS = [
|
||||||
{ value: 'single_threshold', label: '单次阈值' },
|
{ value: "single_threshold", label: "单次阈值" },
|
||||||
{ value: 'consecutive', label: '连续触发' },
|
{ value: "consecutive", label: "连续触发" },
|
||||||
{ value: 'trend', label: '趋势变化' },
|
{ value: "trend", label: "趋势变化" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- 设备连接状态 ---
|
// --- 设备连接状态 ---
|
||||||
export const DEVICE_STATUS_OPTIONS = [
|
export const DEVICE_STATUS_OPTIONS = [
|
||||||
{ value: '', label: '全部状态' },
|
{ value: "", label: "全部状态" },
|
||||||
{ value: 'online', label: '在线' },
|
{ value: "online", label: "在线" },
|
||||||
{ value: 'offline', label: '离线' },
|
{ value: "offline", label: "离线" },
|
||||||
{ value: 'paired', label: '已配对' },
|
{ value: "paired", label: "已配对" },
|
||||||
{ value: 'error', label: '异常' },
|
{ value: "error", label: "异常" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEVICE_STATUS_COLOR: Record<string, string> = {
|
export const DEVICE_STATUS_COLOR: Record<string, string> = {
|
||||||
online: 'green',
|
online: "green",
|
||||||
offline: 'default',
|
offline: "default",
|
||||||
paired: 'blue',
|
paired: "blue",
|
||||||
error: 'red',
|
error: "red",
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 设备连接类型 ---
|
// --- 设备连接类型 ---
|
||||||
export const CONNECTION_TYPE_OPTIONS = [
|
export const CONNECTION_TYPE_OPTIONS = [
|
||||||
{ value: 'ble', label: '蓝牙' },
|
{ value: "ble", label: "蓝牙" },
|
||||||
{ value: 'gateway', label: '网关' },
|
{ value: "gateway", label: "网关" },
|
||||||
{ value: 'manual', label: '手动录入' },
|
{ value: "manual", label: "手动录入" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- 实时监控卡片指标 ---
|
// --- 实时监控卡片指标 ---
|
||||||
export const VITAL_CARD_METRICS = [
|
export const VITAL_CARD_METRICS = [
|
||||||
{ key: 'heart_rate', label: '心率', unit: 'bpm', color: '#ff4d4f' },
|
{ key: "heart_rate", label: "心率", unit: "bpm", color: "#ff4d4f" },
|
||||||
{ key: 'blood_oxygen', label: '血氧', unit: '%', color: '#1890ff' },
|
{ key: "blood_oxygen", label: "血氧", unit: "%", color: "#1890ff" },
|
||||||
{ key: 'blood_pressure', label: '血压', unit: 'mmHg', color: '#f5222d' },
|
{ key: "blood_pressure", label: "血压", unit: "mmHg", color: "#f5222d" },
|
||||||
{ key: 'blood_glucose', label: '血糖', unit: 'mg/dL', color: '#722ed1' },
|
{ key: "blood_glucose", label: "血糖", unit: "mg/dL", color: "#722ed1" },
|
||||||
{ key: 'temperature', label: '体温', unit: '°C', color: '#fa8c16' },
|
{ key: "temperature", label: "体温", unit: "°C", color: "#fa8c16" },
|
||||||
{ key: 'steps', label: '步数', unit: '步', color: '#52c41a' },
|
{ key: "steps", label: "步数", unit: "步", color: "#52c41a" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// --- 通用状态标签(StatusTag 组件统一引用) ---
|
// --- 告警标题中英文映射 ---
|
||||||
export const STATUS_TAG_CONFIG: Record<string, { color: string; label: string }> = {
|
export const ALERT_TITLE_MAP: Record<string, string> = {
|
||||||
// 预约状态
|
"BP Critical High": "血压严重偏高",
|
||||||
pending: { color: 'gold', label: '待确认' },
|
"BP Critical Low": "血压严重偏低",
|
||||||
confirmed: { color: 'blue', label: '已确认' },
|
"Heart Rate Abnormal": "心率异常",
|
||||||
completed: { color: 'green', label: '已完成' },
|
"Blood Sugar Elevated": "血糖偏高",
|
||||||
cancelled: { color: 'default', label: '已取消' },
|
"Blood Sugar Critical": "血糖危急值",
|
||||||
no_show: { color: 'red', label: '未到诊' },
|
"Blood Sugar Low": "血糖偏低",
|
||||||
// 随访状态
|
"Weight Gain Alert": "体重增长异常",
|
||||||
overdue: { color: 'red', label: '逾期' },
|
"Missed Medication": "漏服药物",
|
||||||
in_progress: { color: 'processing', label: '进行中' },
|
"SpO2 Low": "血氧偏低",
|
||||||
// 咨询状态
|
"Temperature High": "体温偏高",
|
||||||
waiting: { color: 'gold', label: '等待中' },
|
"Temperature Low": "体温偏低",
|
||||||
active: { color: 'green', label: '进行中' },
|
"BP Trending High": "血压趋势偏高",
|
||||||
closed: { color: 'default', label: '已关闭' },
|
"BP Trending Low": "血压趋势偏低",
|
||||||
// 患者状态
|
"Heart Rate High": "心率偏高",
|
||||||
inactive: { color: 'default', label: '停用' },
|
"Heart Rate Low": "心率偏低",
|
||||||
deceased: { color: 'default', label: '已故' },
|
};
|
||||||
verified: { color: 'green', label: '已认证' },
|
|
||||||
|
/** 翻译告警标题:优先精确匹配,其次回退原文 */
|
||||||
|
export function translateAlertTitle(title: string): string {
|
||||||
|
return ALERT_TITLE_MAP[title] ?? title;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 通用状态标签(StatusTag 组件统一引用) ---
|
||||||
|
export const STATUS_TAG_CONFIG: Record<
|
||||||
|
string,
|
||||||
|
{ color: string; label: string }
|
||||||
|
> = {
|
||||||
|
// 预约状态
|
||||||
|
pending: { color: "gold", label: "待确认" },
|
||||||
|
confirmed: { color: "blue", label: "已确认" },
|
||||||
|
completed: { color: "green", label: "已完成" },
|
||||||
|
cancelled: { color: "default", label: "已取消" },
|
||||||
|
no_show: { color: "red", label: "未到诊" },
|
||||||
|
// 随访状态
|
||||||
|
overdue: { color: "red", label: "逾期" },
|
||||||
|
in_progress: { color: "processing", label: "进行中" },
|
||||||
|
// 咨询状态
|
||||||
|
waiting: { color: "gold", label: "等待中" },
|
||||||
|
active: { color: "green", label: "进行中" },
|
||||||
|
closed: { color: "default", label: "已关闭" },
|
||||||
|
// 患者状态
|
||||||
|
inactive: { color: "default", label: "停用" },
|
||||||
|
deceased: { color: "default", label: "已故" },
|
||||||
|
verified: { color: "green", label: "已认证" },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
@@ -13,21 +13,29 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Flex,
|
Flex,
|
||||||
Result,
|
Result,
|
||||||
} from 'antd';
|
message,
|
||||||
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
AlertOutlined,
|
AlertOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
WifiOutlined,
|
WifiOutlined,
|
||||||
} from '@ant-design/icons';
|
} from "@ant-design/icons";
|
||||||
import { alertApi, type Alert } from '../../api/health/alerts';
|
import { alertApi, type Alert } from "../../api/health/alerts";
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from "../../hooks/usePermission";
|
||||||
import { SEVERITY_COLOR, SEVERITY_LABEL, ALERT_STATUS_COLOR, ALERT_STATUS_LABEL, ALERT_STATUS_OPTIONS } from '../../constants/health';
|
import {
|
||||||
import { useAlertSSE, type AlertSSEEvent } from '../../hooks/useAlertSSE';
|
SEVERITY_COLOR,
|
||||||
import { AlertDetailPanel } from './components/AlertDetailPanel';
|
SEVERITY_LABEL,
|
||||||
import { PageContainer } from '../../components/PageContainer';
|
ALERT_STATUS_COLOR,
|
||||||
import { EntityName } from '../../components/EntityName';
|
ALERT_STATUS_LABEL,
|
||||||
|
ALERT_STATUS_OPTIONS,
|
||||||
|
translateAlertTitle,
|
||||||
|
} from "../../constants/health";
|
||||||
|
import { useAlertSSE, type AlertSSEEvent } from "../../hooks/useAlertSSE";
|
||||||
|
import { AlertDetailPanel } from "./components/AlertDetailPanel";
|
||||||
|
import { PageContainer } from "../../components/PageContainer";
|
||||||
|
import { EntityName } from "../../components/EntityName";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 实时告警仪表盘 — 医生端。
|
* 实时告警仪表盘 — 医生端。
|
||||||
@@ -40,11 +48,10 @@ import { EntityName } from '../../components/EntityName';
|
|||||||
* - 确认/忽略/恢复操作
|
* - 确认/忽略/恢复操作
|
||||||
*/
|
*/
|
||||||
export default function AlertDashboard() {
|
export default function AlertDashboard() {
|
||||||
const { hasPermission } = usePermission('health.alerts.list');
|
const { hasPermission } = usePermission("health.alerts.list");
|
||||||
if (!hasPermission) return <Result status="403" title="权限不足" subTitle="您没有查看告警面板的权限" />;
|
|
||||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||||
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
|
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -76,11 +83,11 @@ export default function AlertDashboard() {
|
|||||||
const newAlert: Alert = {
|
const newAlert: Alert = {
|
||||||
id: event.alert_id,
|
id: event.alert_id,
|
||||||
patient_id: event.patient_id,
|
patient_id: event.patient_id,
|
||||||
rule_id: '',
|
rule_id: "",
|
||||||
severity: event.severity,
|
severity: event.severity,
|
||||||
title: event.rule_name ?? '新告警',
|
title: event.rule_name ?? "新告警",
|
||||||
detail: event.detail,
|
detail: event.detail,
|
||||||
status: 'pending',
|
status: "pending",
|
||||||
created_at: event.occurred_at ?? new Date().toISOString(),
|
created_at: event.occurred_at ?? new Date().toISOString(),
|
||||||
version: 1,
|
version: 1,
|
||||||
};
|
};
|
||||||
@@ -106,7 +113,7 @@ export default function AlertDashboard() {
|
|||||||
setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
||||||
setSelectedAlert((prev) => (prev?.id === id ? updated : prev));
|
setSelectedAlert((prev) => (prev?.id === id ? updated : prev));
|
||||||
} catch {
|
} catch {
|
||||||
message.error('确认告警失败,请重试');
|
message.error("确认告警失败,请重试");
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
}
|
}
|
||||||
@@ -119,7 +126,7 @@ export default function AlertDashboard() {
|
|||||||
setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
||||||
setSelectedAlert((prev) => (prev?.id === id ? updated : prev));
|
setSelectedAlert((prev) => (prev?.id === id ? updated : prev));
|
||||||
} catch {
|
} catch {
|
||||||
message.error('忽略告警失败,请重试');
|
message.error("忽略告警失败,请重试");
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
}
|
}
|
||||||
@@ -132,16 +139,28 @@ export default function AlertDashboard() {
|
|||||||
setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
||||||
setSelectedAlert((prev) => (prev?.id === id ? updated : prev));
|
setSelectedAlert((prev) => (prev?.id === id ? updated : prev));
|
||||||
} catch {
|
} catch {
|
||||||
message.error('恢复告警失败,请重试');
|
message.error("恢复告警失败,请重试");
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 统计
|
// 统计
|
||||||
const pendingCount = alerts.filter((a) => a.status === 'pending').length;
|
if (!hasPermission)
|
||||||
const acknowledgedCount = alerts.filter((a) => a.status === 'acknowledged').length;
|
return (
|
||||||
const criticalCount = alerts.filter((a) => a.severity === 'critical' || a.severity === 'urgent').length;
|
<Result
|
||||||
|
status="403"
|
||||||
|
title="权限不足"
|
||||||
|
subTitle="您没有查看告警面板的权限"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const pendingCount = alerts.filter((a) => a.status === "pending").length;
|
||||||
|
const acknowledgedCount = alerts.filter(
|
||||||
|
(a) => a.status === "acknowledged",
|
||||||
|
).length;
|
||||||
|
const criticalCount = alerts.filter(
|
||||||
|
(a) => a.severity === "critical" || a.severity === "urgent",
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
@@ -156,12 +175,15 @@ export default function AlertDashboard() {
|
|||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
placeholder="按状态筛选"
|
placeholder="按状态筛选"
|
||||||
/>
|
/>
|
||||||
<Badge status={connected ? 'success' : 'error'} text={
|
<Badge
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
status={connected ? "success" : "error"}
|
||||||
<WifiOutlined style={{ marginRight: 4 }} />
|
text={
|
||||||
{connected ? '实时连接' : '连接断开'}
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
</Typography.Text>
|
<WifiOutlined style={{ marginRight: 4 }} />
|
||||||
} />
|
{connected ? "实时连接" : "连接断开"}
|
||||||
|
</Typography.Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -174,7 +196,7 @@ export default function AlertDashboard() {
|
|||||||
title="待处理"
|
title="待处理"
|
||||||
value={pendingCount}
|
value={pendingCount}
|
||||||
prefix={<ExclamationCircleOutlined />}
|
prefix={<ExclamationCircleOutlined />}
|
||||||
valueStyle={{ color: pendingCount > 0 ? '#fa8c16' : undefined }}
|
valueStyle={{ color: pendingCount > 0 ? "#fa8c16" : undefined }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -184,7 +206,7 @@ export default function AlertDashboard() {
|
|||||||
title="已确认"
|
title="已确认"
|
||||||
value={acknowledgedCount}
|
value={acknowledgedCount}
|
||||||
prefix={<CheckCircleOutlined />}
|
prefix={<CheckCircleOutlined />}
|
||||||
valueStyle={{ color: '#1890ff' }}
|
valueStyle={{ color: "#1890ff" }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -194,7 +216,9 @@ export default function AlertDashboard() {
|
|||||||
title="危急值"
|
title="危急值"
|
||||||
value={criticalCount}
|
value={criticalCount}
|
||||||
prefix={<WarningOutlined />}
|
prefix={<WarningOutlined />}
|
||||||
valueStyle={{ color: criticalCount > 0 ? '#ff4d4f' : undefined }}
|
valueStyle={{
|
||||||
|
color: criticalCount > 0 ? "#ff4d4f" : undefined,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -212,46 +236,65 @@ export default function AlertDashboard() {
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ maxHeight: 600, overflow: 'auto' }}
|
style={{ maxHeight: 600, overflow: "auto" }}
|
||||||
>
|
>
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
<List
|
<List
|
||||||
size="small"
|
size="small"
|
||||||
dataSource={alerts}
|
dataSource={alerts}
|
||||||
locale={{ emptyText: '暂无告警' }}
|
locale={{ emptyText: "暂无告警" }}
|
||||||
renderItem={(alert) => (
|
renderItem={(alert) => (
|
||||||
<List.Item
|
<List.Item
|
||||||
onClick={() => setSelectedAlert(alert)}
|
onClick={() => setSelectedAlert(alert)}
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
background: selectedAlert?.id === alert.id ? 'var(--ant-color-primary-bg)' : undefined,
|
background:
|
||||||
padding: '8px 12px',
|
selectedAlert?.id === alert.id
|
||||||
|
? "var(--ant-color-primary-bg)"
|
||||||
|
: undefined,
|
||||||
|
padding: "8px 12px",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
transition: 'background 0.2s',
|
transition: "background 0.2s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
avatar={
|
avatar={
|
||||||
<Tag
|
<Tag
|
||||||
color={SEVERITY_COLOR[alert.severity] || 'default'}
|
color={SEVERITY_COLOR[alert.severity] || "default"}
|
||||||
style={{ margin: 0, minWidth: 48, textAlign: 'center' }}
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
minWidth: 48,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{SEVERITY_LABEL[alert.severity] ?? alert.severity}
|
{SEVERITY_LABEL[alert.severity] ?? alert.severity}
|
||||||
</Tag>
|
</Tag>
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
<Flex justify="space-between" align="center">
|
<Flex justify="space-between" align="center">
|
||||||
<span>{alert.title}</span>
|
<span>{translateAlertTitle(alert.title)}</span>
|
||||||
<Tag color={ALERT_STATUS_COLOR[alert.status] || 'default'} style={{ fontSize: 11 }}>
|
<Tag
|
||||||
|
color={
|
||||||
|
ALERT_STATUS_COLOR[alert.status] || "default"
|
||||||
|
}
|
||||||
|
style={{ fontSize: 11 }}
|
||||||
|
>
|
||||||
{ALERT_STATUS_LABEL[alert.status] ?? alert.status}
|
{ALERT_STATUS_LABEL[alert.status] ?? alert.status}
|
||||||
</Tag>
|
</Tag>
|
||||||
</Flex>
|
</Flex>
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text
|
||||||
患者: <EntityName name={alert.patient_name} id={alert.patient_id} />
|
type="secondary"
|
||||||
{' · '}
|
style={{ fontSize: 12 }}
|
||||||
{new Date(alert.created_at).toLocaleString('zh-CN')}
|
>
|
||||||
|
患者:{" "}
|
||||||
|
<EntityName
|
||||||
|
name={alert.patient_name}
|
||||||
|
id={alert.patient_id}
|
||||||
|
/>
|
||||||
|
{" · "}
|
||||||
|
{new Date(alert.created_at).toLocaleString("zh-CN")}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -272,7 +315,7 @@ export default function AlertDashboard() {
|
|||||||
loading={actionLoading}
|
loading={actionLoading}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ padding: 40, textAlign: 'center' }}>
|
<div style={{ padding: 40, textAlign: "center" }}>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
点击左侧告警查看详情
|
点击左侧告警查看详情
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { Button, Input, Spin, Popconfirm, message, Typography } from 'antd';
|
import { Button, Input, Spin, Popconfirm, message, Typography } from "antd";
|
||||||
import { SendOutlined, CloseCircleOutlined, ArrowUpOutlined } from '@ant-design/icons';
|
import {
|
||||||
import { useParams } from 'react-router-dom';
|
SendOutlined,
|
||||||
import { consultationApi, type Session, type Message } from '../../api/health/consultations';
|
CloseCircleOutlined,
|
||||||
import { StatusTag } from './components/StatusTag';
|
ArrowUpOutlined,
|
||||||
import { ImagePreview } from './components/ImagePreview';
|
} from "@ant-design/icons";
|
||||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
import { useParams } from "react-router-dom";
|
||||||
import { AuthButton } from '../../components/AuthButton';
|
import {
|
||||||
import { EntityName } from '../../components/EntityName';
|
consultationApi,
|
||||||
|
type Session,
|
||||||
|
type Message,
|
||||||
|
} from "../../api/health/consultations";
|
||||||
|
import { StatusTag } from "./components/StatusTag";
|
||||||
|
import { ImagePreview } from "./components/ImagePreview";
|
||||||
|
import { useThemeMode } from "../../hooks/useThemeMode";
|
||||||
|
import { AuthButton } from "../../components/AuthButton";
|
||||||
|
import { EntityName } from "../../components/EntityName";
|
||||||
|
|
||||||
const PAGE_SIZE = 30;
|
const PAGE_SIZE = 30;
|
||||||
const POLL_INTERVAL = 10_000;
|
const POLL_INTERVAL = 10_000;
|
||||||
|
|
||||||
function formatTime(value: string): string {
|
function formatTime(value: string): string {
|
||||||
return new Date(value).toLocaleString('zh-CN', {
|
return new Date(value).toLocaleString("zh-CN", {
|
||||||
month: '2-digit',
|
month: "2-digit",
|
||||||
day: '2-digit',
|
day: "2-digit",
|
||||||
hour: '2-digit',
|
hour: "2-digit",
|
||||||
minute: '2-digit',
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,15 +40,15 @@ function parseImageUrls(content: string): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_ALIGN: Record<string, 'flex-start' | 'flex-end' | 'center'> = {
|
const ROLE_ALIGN: Record<string, "flex-start" | "flex-end" | "center"> = {
|
||||||
patient: 'flex-start',
|
patient: "flex-start",
|
||||||
doctor: 'flex-end',
|
doctor: "flex-end",
|
||||||
system: 'center',
|
system: "center",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ConsultationDetail() {
|
export default function ConsultationDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const sessionId = id ?? '';
|
const sessionId = id ?? "";
|
||||||
|
|
||||||
// Session info
|
// Session info
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
@@ -51,7 +59,7 @@ export default function ConsultationDetail() {
|
|||||||
const [msgPage, setMsgPage] = useState(1);
|
const [msgPage, setMsgPage] = useState(1);
|
||||||
const [msgLoading, setMsgLoading] = useState(false);
|
const [msgLoading, setMsgLoading] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [inputText, setInputText] = useState('');
|
const [inputText, setInputText] = useState("");
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
|
||||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -68,7 +76,7 @@ export default function ConsultationDetail() {
|
|||||||
const result = await consultationApi.getSession(sessionId);
|
const result = await consultationApi.getSession(sessionId);
|
||||||
setSession(result);
|
setSession(result);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('加载会话信息失败');
|
message.error("加载会话信息失败");
|
||||||
}
|
}
|
||||||
setSessionLoading(false);
|
setSessionLoading(false);
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
@@ -93,7 +101,7 @@ export default function ConsultationDetail() {
|
|||||||
}
|
}
|
||||||
setHasMore(page < totalPages);
|
setHasMore(page < totalPages);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('加载消息失败');
|
message.error("加载消息失败");
|
||||||
}
|
}
|
||||||
setMsgLoading(false);
|
setMsgLoading(false);
|
||||||
},
|
},
|
||||||
@@ -108,7 +116,7 @@ export default function ConsultationDetail() {
|
|||||||
|
|
||||||
// Poll new messages while session is active
|
// Poll new messages while session is active
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session || session.status === 'closed') return;
|
if (!session || session.status === "closed") return;
|
||||||
|
|
||||||
const stopPolling = () => {
|
const stopPolling = () => {
|
||||||
if (pollRef.current) {
|
if (pollRef.current) {
|
||||||
@@ -121,8 +129,9 @@ export default function ConsultationDetail() {
|
|||||||
pollRef.current = setInterval(async () => {
|
pollRef.current = setInterval(async () => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
try {
|
try {
|
||||||
const realMsgs = messages.filter((m) => !m.id.startsWith('temp_'));
|
const realMsgs = messages.filter((m) => !m.id.startsWith("temp_"));
|
||||||
const lastId = realMsgs.length > 0 ? realMsgs[realMsgs.length - 1].id : undefined;
|
const lastId =
|
||||||
|
realMsgs.length > 0 ? realMsgs[realMsgs.length - 1].id : undefined;
|
||||||
const result = await consultationApi.listMessages(sessionId, {
|
const result = await consultationApi.listMessages(sessionId, {
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 50,
|
page_size: 50,
|
||||||
@@ -143,7 +152,7 @@ export default function ConsultationDetail() {
|
|||||||
// Auto-scroll to bottom on new messages
|
// Auto-scroll to bottom on new messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldScrollRef.current && chatEndRef.current) {
|
if (shouldScrollRef.current && chatEndRef.current) {
|
||||||
chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
chatEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||||
}
|
}
|
||||||
}, [messages.length]);
|
}, [messages.length]);
|
||||||
|
|
||||||
@@ -158,27 +167,27 @@ export default function ConsultationDetail() {
|
|||||||
const optimisticMsg: Message = {
|
const optimisticMsg: Message = {
|
||||||
id: `temp_${Date.now()}`,
|
id: `temp_${Date.now()}`,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
sender_id: '',
|
sender_id: "",
|
||||||
sender_role: 'doctor',
|
sender_role: "doctor",
|
||||||
content_type: 'text',
|
content_type: "text",
|
||||||
content: text,
|
content: text,
|
||||||
is_read: false,
|
is_read: false,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
setMessages((prev) => [...prev, optimisticMsg]);
|
setMessages((prev) => [...prev, optimisticMsg]);
|
||||||
setInputText('');
|
setInputText("");
|
||||||
shouldScrollRef.current = true;
|
shouldScrollRef.current = true;
|
||||||
|
|
||||||
await consultationApi.createMessage({
|
await consultationApi.createMessage({
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
content_type: 'text',
|
content_type: "text",
|
||||||
content: text,
|
content: text,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh to replace optimistic message with server version
|
// Refresh to replace optimistic message with server version
|
||||||
await fetchMessages(msgPage, false);
|
await fetchMessages(msgPage, false);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('发送失败');
|
message.error("发送失败");
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
@@ -200,20 +209,20 @@ export default function ConsultationDetail() {
|
|||||||
version: session.version,
|
version: session.version,
|
||||||
});
|
});
|
||||||
setSession(updated);
|
setSession(updated);
|
||||||
message.success('会话已关闭');
|
message.success("会话已关闭");
|
||||||
} catch {
|
} catch {
|
||||||
message.error('关闭会话失败');
|
message.error("关闭会话失败");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Render a single message bubble ---
|
// --- Render a single message bubble ---
|
||||||
const renderMessage = (msg: Message) => {
|
const renderMessage = (msg: Message) => {
|
||||||
const align = ROLE_ALIGN[msg.sender_role] ?? 'flex-start';
|
const align = ROLE_ALIGN[msg.sender_role] ?? "flex-start";
|
||||||
|
|
||||||
// System messages: centered plain text
|
// System messages: centered plain text
|
||||||
if (msg.sender_role === 'system') {
|
if (msg.sender_role === "system") {
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} style={{ textAlign: 'center', padding: '8px 0' }}>
|
<div key={msg.id} style={{ textAlign: "center", padding: "8px 0" }}>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{msg.content}
|
{msg.content}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
@@ -221,31 +230,32 @@ export default function ConsultationDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isImage = msg.content_type === 'image';
|
const isImage = msg.content_type === "image";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
justifyContent: align,
|
justifyContent: align,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: '70%' }}>
|
<div style={{ maxWidth: "70%" }}>
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
<ImagePreview urls={parseImageUrls(msg.content)} width={200} />
|
<ImagePreview urls={parseImageUrls(msg.content)} width={200} />
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: msg.sender_role === 'doctor' ? '#1890ff' : '#f0f0f0',
|
background:
|
||||||
color: msg.sender_role === 'doctor' ? '#fff' : '#000',
|
msg.sender_role === "doctor" ? "#1890ff" : "#f0f0f0",
|
||||||
padding: '8px 12px',
|
color: msg.sender_role === "doctor" ? "#fff" : "#000",
|
||||||
|
padding: "8px 12px",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
wordBreak: 'break-word',
|
wordBreak: "break-word",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography.Paragraph style={{ margin: 0, color: 'inherit' }}>
|
<Typography.Paragraph style={{ margin: 0, color: "inherit" }}>
|
||||||
{msg.content}
|
{msg.content}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,34 +271,34 @@ export default function ConsultationDetail() {
|
|||||||
// --- Full render ---
|
// --- Full render ---
|
||||||
if (sessionLoading && messages.length === 0) {
|
if (sessionLoading && messages.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
|
<div style={{ display: "flex", justifyContent: "center", padding: 100 }}>
|
||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isClosed = session?.status === 'closed';
|
const isClosed = session?.status === "closed";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
height: 'calc(100vh - 120px)',
|
height: "calc(100vh - 120px)",
|
||||||
background: isDark ? '#111827' : '#FFFFFF',
|
background: isDark ? "#111827" : "#FFFFFF",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
border: `1px solid ${isDark ? "#0f172a" : "#f8fafc"}`,
|
||||||
overflow: 'hidden',
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: 12,
|
gap: 12,
|
||||||
padding: '12px 20px',
|
padding: "12px 20px",
|
||||||
borderBottom: `1px solid ${isDark ? '#1e293b' : '#f1f5f9'}`,
|
borderBottom: `1px solid ${isDark ? "#1e293b" : "#f1f5f9"}`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -298,10 +308,16 @@ export default function ConsultationDetail() {
|
|||||||
{session && (
|
{session && (
|
||||||
<>
|
<>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
患者: <EntityName name={session.patient_name} id={session.patient_id} />
|
患者:{" "}
|
||||||
|
<EntityName name={session.patient_name} id={session.patient_id} />
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
医护: <EntityName name={session.doctor_name} id={session.doctor_id} fallbackLabel="未分配" />
|
医护:{" "}
|
||||||
|
<EntityName
|
||||||
|
name={session.doctor_name}
|
||||||
|
id={session.doctor_id}
|
||||||
|
fallbackLabel="未分配"
|
||||||
|
/>
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<StatusTag status={session.status} />
|
<StatusTag status={session.status} />
|
||||||
</>
|
</>
|
||||||
@@ -323,7 +339,7 @@ export default function ConsultationDetail() {
|
|||||||
size="small"
|
size="small"
|
||||||
danger
|
danger
|
||||||
icon={<CloseCircleOutlined />}
|
icon={<CloseCircleOutlined />}
|
||||||
style={{ marginLeft: 'auto' }}
|
style={{ marginLeft: "auto" }}
|
||||||
>
|
>
|
||||||
关闭会话
|
关闭会话
|
||||||
</Button>
|
</Button>
|
||||||
@@ -336,13 +352,13 @@ export default function ConsultationDetail() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: 'auto',
|
overflow: "auto",
|
||||||
padding: '16px 20px',
|
padding: "16px 20px",
|
||||||
background: isDark ? '#0f172a' : '#f8fafc',
|
background: isDark ? "#0f172a" : "#f8fafc",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
<div style={{ textAlign: "center", marginBottom: 16 }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<ArrowUpOutlined />}
|
icon={<ArrowUpOutlined />}
|
||||||
@@ -355,7 +371,7 @@ export default function ConsultationDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{msgLoading && messages.length === 0 && (
|
{msgLoading && messages.length === 0 && (
|
||||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
<div style={{ textAlign: "center", padding: 40 }}>
|
||||||
<Spin />
|
<Spin />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -368,38 +384,40 @@ export default function ConsultationDetail() {
|
|||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'flex-end',
|
alignItems: "flex-end",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
padding: '12px 20px',
|
padding: "12px 20px",
|
||||||
borderTop: `1px solid ${isDark ? '#1e293b' : '#f1f5f9'}`,
|
borderTop: `1px solid ${isDark ? "#1e293b" : "#f1f5f9"}`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
background: isDark ? '#111827' : '#FFFFFF',
|
background: isDark ? "#111827" : "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Input.TextArea
|
<AuthButton code="health.consultation.manage">
|
||||||
value={inputText}
|
<Input.TextArea
|
||||||
onChange={(e) => setInputText(e.target.value)}
|
value={inputText}
|
||||||
placeholder={isClosed ? '会话已关闭' : '输入消息...'}
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
placeholder={isClosed ? "会话已关闭" : "输入消息..."}
|
||||||
style={{ flex: 1, borderRadius: 8 }}
|
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||||
onPressEnter={(e) => {
|
style={{ flex: 1, borderRadius: 8 }}
|
||||||
if (!e.shiftKey) {
|
onPressEnter={(e) => {
|
||||||
e.preventDefault();
|
if (!e.shiftKey) {
|
||||||
handleSend();
|
e.preventDefault();
|
||||||
}
|
handleSend();
|
||||||
}}
|
}
|
||||||
disabled={isClosed}
|
}}
|
||||||
/>
|
disabled={isClosed}
|
||||||
<Button
|
/>
|
||||||
type="primary"
|
<Button
|
||||||
icon={<SendOutlined />}
|
type="primary"
|
||||||
onClick={handleSend}
|
icon={<SendOutlined />}
|
||||||
loading={sending}
|
onClick={handleSend}
|
||||||
disabled={!inputText.trim() || isClosed}
|
loading={sending}
|
||||||
>
|
disabled={!inputText.trim() || isClosed}
|
||||||
发送
|
>
|
||||||
</Button>
|
发送
|
||||||
|
</Button>
|
||||||
|
</AuthButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
Select,
|
Select,
|
||||||
@@ -9,38 +9,50 @@ import {
|
|||||||
Popconfirm,
|
Popconfirm,
|
||||||
message,
|
message,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
} from 'antd';
|
} from "antd";
|
||||||
import { PlusOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
import { PlusOutlined, CloseCircleOutlined } from "@ant-design/icons";
|
||||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
import type { ColumnsType, TablePaginationConfig } from "antd/es/table";
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { consultationApi, type Session, type CreateSessionReq } from '../../api/health/consultations';
|
import {
|
||||||
import { StatusTag } from './components/StatusTag';
|
consultationApi,
|
||||||
import { PatientSelect } from './components/PatientSelect';
|
type Session,
|
||||||
import { DoctorSelect } from './components/DoctorSelect';
|
type CreateSessionReq,
|
||||||
import { ExportButton } from './components/ExportButton';
|
} from "../../api/health/consultations";
|
||||||
import { AuthButton } from '../../components/AuthButton';
|
import { StatusTag } from "./components/StatusTag";
|
||||||
import { PageContainer } from '../../components/PageContainer';
|
import { PatientSelect } from "./components/PatientSelect";
|
||||||
import { EntityName } from '../../components/EntityName';
|
import { DoctorSelect } from "./components/DoctorSelect";
|
||||||
import { formatDateTime } from '../../utils/format';
|
import { ExportButton } from "./components/ExportButton";
|
||||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
import { AuthButton } from "../../components/AuthButton";
|
||||||
import { useDictionary } from '../../hooks/useDictionary';
|
import { PageContainer } from "../../components/PageContainer";
|
||||||
|
import { EntityName } from "../../components/EntityName";
|
||||||
|
import { formatDateTime } from "../../utils/format";
|
||||||
|
import { usePaginatedData } from "../../hooks/usePaginatedData";
|
||||||
|
import { useDictionary } from "../../hooks/useDictionary";
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ value: 'waiting', label: '等待中' },
|
{ value: "waiting", label: "等待中" },
|
||||||
{ value: 'active', label: '进行中' },
|
{ value: "active", label: "进行中" },
|
||||||
{ value: 'closed', label: '已关闭' },
|
{ value: "closed", label: "已关闭" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CONSULTATION_TYPE_FALLBACK = [
|
const CONSULTATION_TYPE_FALLBACK = [
|
||||||
{ value: 'customer_service', label: '客服咨询' },
|
{ value: "customer_service", label: "客服咨询" },
|
||||||
{ value: 'medical', label: '医疗咨询' },
|
{ value: "medical", label: "医疗咨询" },
|
||||||
{ value: 'health_consultation', label: '健康咨询' },
|
{ value: "health_consultation", label: "健康咨询" },
|
||||||
|
{ value: "online", label: "在线咨询" },
|
||||||
|
{ value: "phone", label: "电话咨询" },
|
||||||
|
{ value: "doctor", label: "医生咨询" },
|
||||||
|
{ value: "follow_up", label: "随访咨询" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CONSULTATION_TYPE_MAP: Record<string, string> = {
|
const CONSULTATION_TYPE_MAP: Record<string, string> = {
|
||||||
customer_service: '客服咨询',
|
customer_service: "客服咨询",
|
||||||
medical: '医疗咨询',
|
medical: "医疗咨询",
|
||||||
health_consultation: '健康咨询',
|
health_consultation: "健康咨询",
|
||||||
|
online: "在线咨询",
|
||||||
|
phone: "电话咨询",
|
||||||
|
doctor: "医生咨询",
|
||||||
|
follow_up: "随访咨询",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ConsultationFilters {
|
interface ConsultationFilters {
|
||||||
@@ -49,10 +61,13 @@ interface ConsultationFilters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ConsultationList() {
|
export default function ConsultationList() {
|
||||||
const { options: CONSULTATION_TYPE_OPTIONS } = useDictionary('health_consultation_type', CONSULTATION_TYPE_FALLBACK);
|
const { options: CONSULTATION_TYPE_OPTIONS } = useDictionary(
|
||||||
|
"health_consultation_type",
|
||||||
|
CONSULTATION_TYPE_FALLBACK,
|
||||||
|
);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const urlPatientId = searchParams.get('patient_id');
|
const urlPatientId = searchParams.get("patient_id");
|
||||||
|
|
||||||
// Close session
|
// Close session
|
||||||
const [closingId, setClosingId] = useState<string | null>(null);
|
const [closingId, setClosingId] = useState<string | null>(null);
|
||||||
@@ -72,7 +87,9 @@ export default function ConsultationList() {
|
|||||||
params.created_start = filters.dateRange[0];
|
params.created_start = filters.dateRange[0];
|
||||||
params.created_end = filters.dateRange[1];
|
params.created_end = filters.dateRange[1];
|
||||||
}
|
}
|
||||||
return consultationApi.listSessions(params as Parameters<typeof consultationApi.listSessions>[0]);
|
return consultationApi.listSessions(
|
||||||
|
params as Parameters<typeof consultationApi.listSessions>[0],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[urlPatientId],
|
[urlPatientId],
|
||||||
);
|
);
|
||||||
@@ -101,13 +118,13 @@ export default function ConsultationList() {
|
|||||||
const values = await createForm.validateFields();
|
const values = await createForm.validateFields();
|
||||||
setCreateLoading(true);
|
setCreateLoading(true);
|
||||||
await consultationApi.createSession(values);
|
await consultationApi.createSession(values);
|
||||||
message.success('咨询会话创建成功');
|
message.success("咨询会话创建成功");
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
refresh(page);
|
refresh(page);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err && typeof err === 'object' && 'errorFields' in err) return;
|
if (err && typeof err === "object" && "errorFields" in err) return;
|
||||||
message.error('创建咨询会话失败');
|
message.error("创建咨询会话失败");
|
||||||
} finally {
|
} finally {
|
||||||
setCreateLoading(false);
|
setCreateLoading(false);
|
||||||
}
|
}
|
||||||
@@ -117,11 +134,13 @@ export default function ConsultationList() {
|
|||||||
const handleClose = async (session: Session) => {
|
const handleClose = async (session: Session) => {
|
||||||
setClosingId(session.id);
|
setClosingId(session.id);
|
||||||
try {
|
try {
|
||||||
await consultationApi.closeSession(session.id, { version: session.version });
|
await consultationApi.closeSession(session.id, {
|
||||||
message.success('会话已关闭');
|
version: session.version,
|
||||||
|
});
|
||||||
|
message.success("会话已关闭");
|
||||||
refresh(page);
|
refresh(page);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('关闭会话失败');
|
message.error("关闭会话失败");
|
||||||
} finally {
|
} finally {
|
||||||
setClosingId(null);
|
setClosingId(null);
|
||||||
}
|
}
|
||||||
@@ -139,40 +158,44 @@ export default function ConsultationList() {
|
|||||||
// --- Columns ---
|
// --- Columns ---
|
||||||
const columns: ColumnsType<Session> = [
|
const columns: ColumnsType<Session> = [
|
||||||
{
|
{
|
||||||
title: '患者',
|
title: "患者",
|
||||||
dataIndex: 'patient_name',
|
dataIndex: "patient_name",
|
||||||
key: 'patient_name',
|
key: "patient_name",
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (_: unknown, record: Session) => (
|
render: (_: unknown, record: Session) => (
|
||||||
<EntityName name={record.patient_name} id={record.patient_id} />
|
<EntityName name={record.patient_name} id={record.patient_id} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '医护',
|
title: "医护",
|
||||||
dataIndex: 'doctor_name',
|
dataIndex: "doctor_name",
|
||||||
key: 'doctor_name',
|
key: "doctor_name",
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (_: unknown, record: Session) => (
|
render: (_: unknown, record: Session) => (
|
||||||
<EntityName name={record.doctor_name} id={record.doctor_id} fallbackLabel="未分配" />
|
<EntityName
|
||||||
|
name={record.doctor_name}
|
||||||
|
id={record.doctor_id}
|
||||||
|
fallbackLabel="未分配"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '咨询类型',
|
title: "咨询类型",
|
||||||
dataIndex: 'consultation_type',
|
dataIndex: "consultation_type",
|
||||||
key: 'consultation_type',
|
key: "consultation_type",
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v: string) => CONSULTATION_TYPE_MAP[v] || v,
|
render: (v: string) => CONSULTATION_TYPE_MAP[v] || v,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: "状态",
|
||||||
dataIndex: 'status',
|
dataIndex: "status",
|
||||||
key: 'status',
|
key: "status",
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (status: string) => <StatusTag status={status} />,
|
render: (status: string) => <StatusTag status={status} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '未读(患者/医护)',
|
title: "未读(患者/医护)",
|
||||||
key: 'unread',
|
key: "unread",
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (_: unknown, record: Session) => (
|
render: (_: unknown, record: Session) => (
|
||||||
<span>
|
<span>
|
||||||
@@ -181,27 +204,27 @@ export default function ConsultationList() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '最后消息时间',
|
title: "最后消息时间",
|
||||||
dataIndex: 'last_message_at',
|
dataIndex: "last_message_at",
|
||||||
key: 'last_message_at',
|
key: "last_message_at",
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (v: string | undefined) => formatDateTime(v),
|
render: (v: string | undefined) => formatDateTime(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '创建时间',
|
title: "创建时间",
|
||||||
dataIndex: 'created_at',
|
dataIndex: "created_at",
|
||||||
key: 'created_at',
|
key: "created_at",
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (v: string) => formatDateTime(v),
|
render: (v: string) => formatDateTime(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: "操作",
|
||||||
key: 'actions',
|
key: "actions",
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_: unknown, record: Session) => (
|
render: (_: unknown, record: Session) => (
|
||||||
<AuthButton code="health.consultation.manage">
|
<AuthButton code="health.consultation.manage">
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
{record.status !== 'closed' && (
|
{record.status !== "closed" && (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确认关闭该咨询会话?"
|
title="确认关闭该咨询会话?"
|
||||||
onConfirm={() => handleClose(record)}
|
onConfirm={() => handleClose(record)}
|
||||||
@@ -237,7 +260,9 @@ export default function ConsultationList() {
|
|||||||
style={{ width: 140 }}
|
style={{ width: 140 }}
|
||||||
options={STATUS_OPTIONS}
|
options={STATUS_OPTIONS}
|
||||||
value={filters.status}
|
value={filters.status}
|
||||||
onChange={(value) => setFilters((prev) => ({ ...prev, status: value }))}
|
onChange={(value) =>
|
||||||
|
setFilters((prev) => ({ ...prev, status: value }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<DatePicker.RangePicker
|
<DatePicker.RangePicker
|
||||||
style={{ width: 240 }}
|
style={{ width: 240 }}
|
||||||
@@ -245,7 +270,10 @@ export default function ConsultationList() {
|
|||||||
if (dates && dates[0] && dates[1]) {
|
if (dates && dates[0] && dates[1]) {
|
||||||
setFilters((prev) => ({
|
setFilters((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
dateRange: [dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')],
|
dateRange: [
|
||||||
|
dates[0]!.format("YYYY-MM-DD"),
|
||||||
|
dates[1]!.format("YYYY-MM-DD"),
|
||||||
|
],
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
setFilters((prev) => ({ ...prev, dateRange: undefined }));
|
setFilters((prev) => ({ ...prev, dateRange: undefined }));
|
||||||
@@ -285,7 +313,7 @@ export default function ConsultationList() {
|
|||||||
onChange={handleTableChange}
|
onChange={handleTableChange}
|
||||||
onRow={(record) => ({
|
onRow={(record) => ({
|
||||||
onClick: () => handleRowClick(record),
|
onClick: () => handleRowClick(record),
|
||||||
style: { cursor: 'pointer' },
|
style: { cursor: "pointer" },
|
||||||
})}
|
})}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
@@ -312,7 +340,7 @@ export default function ConsultationList() {
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name="patient_id"
|
name="patient_id"
|
||||||
label="患者"
|
label="患者"
|
||||||
rules={[{ required: true, message: '请选择患者' }]}
|
rules={[{ required: true, message: "请选择患者" }]}
|
||||||
>
|
>
|
||||||
<PatientSelect />
|
<PatientSelect />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -1,55 +1,87 @@
|
|||||||
import { Descriptions, Tag, Typography, Space, Button, Popconfirm, Tooltip } from 'antd';
|
import {
|
||||||
|
Descriptions,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Space,
|
||||||
|
Button,
|
||||||
|
Popconfirm,
|
||||||
|
Tooltip,
|
||||||
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
StopOutlined,
|
StopOutlined,
|
||||||
SafetyCertificateOutlined,
|
SafetyCertificateOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
} from '@ant-design/icons';
|
} from "@ant-design/icons";
|
||||||
import type { Alert } from '../../../api/health/alerts';
|
import type { Alert } from "../../../api/health/alerts";
|
||||||
|
import { translateAlertTitle } from "../../../constants/health";
|
||||||
|
|
||||||
const SEVERITY_CONFIG: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
|
const SEVERITY_CONFIG: Record<
|
||||||
info: { color: 'default', label: '提示', icon: <ExclamationCircleOutlined /> },
|
string,
|
||||||
warning: { color: 'orange', label: '警告', icon: <ExclamationCircleOutlined /> },
|
{ color: string; label: string; icon: React.ReactNode }
|
||||||
critical: { color: 'red', label: '严重', icon: <ExclamationCircleOutlined /> },
|
> = {
|
||||||
urgent: { color: 'magenta', label: '紧急', icon: <ExclamationCircleOutlined /> },
|
info: {
|
||||||
|
color: "default",
|
||||||
|
label: "提示",
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
color: "orange",
|
||||||
|
label: "警告",
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
},
|
||||||
|
critical: {
|
||||||
|
color: "red",
|
||||||
|
label: "严重",
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
},
|
||||||
|
urgent: {
|
||||||
|
color: "magenta",
|
||||||
|
label: "紧急",
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
|
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
|
||||||
pending: { color: 'orange', label: '待处理' },
|
pending: { color: "orange", label: "待处理" },
|
||||||
active: { color: 'orange', label: '待处理' },
|
active: { color: "orange", label: "待处理" },
|
||||||
acknowledged: { color: 'blue', label: '已确认' },
|
acknowledged: { color: "blue", label: "已确认" },
|
||||||
resolved: { color: 'green', label: '已恢复' },
|
resolved: { color: "green", label: "已恢复" },
|
||||||
dismissed: { color: 'default', label: '已忽略' },
|
dismissed: { color: "default", label: "已忽略" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DETAIL_LABEL_MAP: Record<string, string> = {
|
const DETAIL_LABEL_MAP: Record<string, string> = {
|
||||||
message: '告警描述',
|
message: "告警描述",
|
||||||
value: '监测值',
|
value: "监测值",
|
||||||
threshold: '阈值',
|
threshold: "阈值",
|
||||||
unit: '单位',
|
unit: "单位",
|
||||||
metric: '指标',
|
metric: "指标",
|
||||||
metric_name: '指标名称',
|
metric_name: "指标名称",
|
||||||
indicator_type: '体征类型',
|
indicator_type: "体征类型",
|
||||||
recorded_at: '记录时间',
|
recorded_at: "记录时间",
|
||||||
blood_pressure_systolic: '收缩压',
|
blood_pressure_systolic: "收缩压",
|
||||||
blood_pressure_diastolic: '舒张压',
|
blood_pressure_diastolic: "舒张压",
|
||||||
heart_rate: '心率',
|
heart_rate: "心率",
|
||||||
blood_glucose: '血糖',
|
blood_glucose: "血糖",
|
||||||
temperature: '体温',
|
temperature: "体温",
|
||||||
spo2: '血氧饱和度',
|
spo2: "血氧饱和度",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDetailValue(key: string, value: unknown): string {
|
function formatDetailValue(key: string, value: unknown): string {
|
||||||
if (value === null || value === undefined) return '-';
|
if (value === null || value === undefined) return "-";
|
||||||
if (typeof value === 'string') {
|
if (typeof value === "string") {
|
||||||
if (key.endsWith('_at') || key === 'recorded_at') {
|
if (key.endsWith("_at") || key === "recorded_at") {
|
||||||
try { return new Date(value).toLocaleString('zh-CN'); } catch { return value; }
|
try {
|
||||||
|
return new Date(value).toLocaleString("zh-CN");
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
if (typeof value === 'number') return String(value);
|
if (typeof value === "number") return String(value);
|
||||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
if (typeof value === "boolean") return value ? "是" : "否";
|
||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,11 +105,13 @@ export function AlertDetailPanel({
|
|||||||
}: AlertDetailPanelProps) {
|
}: AlertDetailPanelProps) {
|
||||||
const severity = SEVERITY_CONFIG[alert.severity] ?? SEVERITY_CONFIG.info;
|
const severity = SEVERITY_CONFIG[alert.severity] ?? SEVERITY_CONFIG.info;
|
||||||
const status = STATUS_CONFIG[alert.status] ?? STATUS_CONFIG.pending;
|
const status = STATUS_CONFIG[alert.status] ?? STATUS_CONFIG.pending;
|
||||||
const isPending = alert.status === 'pending' || alert.status === 'active';
|
const isPending = alert.status === "pending" || alert.status === "active";
|
||||||
const isAcknowledged = alert.status === 'acknowledged';
|
const isAcknowledged = alert.status === "acknowledged";
|
||||||
|
|
||||||
const detailEntries = alert.detail
|
const detailEntries = alert.detail
|
||||||
? Object.entries(alert.detail).filter(([, v]) => v !== null && v !== undefined)
|
? Object.entries(alert.detail).filter(
|
||||||
|
([, v]) => v !== null && v !== undefined,
|
||||||
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -85,13 +119,17 @@ export function AlertDetailPanel({
|
|||||||
{/* 顶部摘要 */}
|
{/* 顶部摘要 */}
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Space align="center" size="middle">
|
<Space align="center" size="middle">
|
||||||
<Tag color={severity.color} icon={severity.icon} style={{ fontSize: 14, padding: '4px 12px' }}>
|
<Tag
|
||||||
|
color={severity.color}
|
||||||
|
icon={severity.icon}
|
||||||
|
style={{ fontSize: 14, padding: "4px 12px" }}
|
||||||
|
>
|
||||||
{severity.label}
|
{severity.label}
|
||||||
</Tag>
|
</Tag>
|
||||||
<Tag color={status.color}>{status.label}</Tag>
|
<Tag color={status.color}>{status.label}</Tag>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
||||||
{new Date(alert.created_at).toLocaleString('zh-CN')}
|
{new Date(alert.created_at).toLocaleString("zh-CN")}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,17 +137,29 @@ export function AlertDetailPanel({
|
|||||||
{/* 详情 */}
|
{/* 详情 */}
|
||||||
<Descriptions column={2} size="small" bordered>
|
<Descriptions column={2} size="small" bordered>
|
||||||
<Descriptions.Item label="患者">
|
<Descriptions.Item label="患者">
|
||||||
<Typography.Text strong>{alert.patient_name || '未知患者'}</Typography.Text>
|
<Typography.Text strong>
|
||||||
|
{alert.patient_name || "未知患者"}
|
||||||
|
</Typography.Text>
|
||||||
<Tooltip title={alert.patient_id}>
|
<Tooltip title={alert.patient_id}>
|
||||||
<Typography.Text type="secondary" copyable style={{ fontSize: 12, marginLeft: 8, cursor: 'help' }}>
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
copyable
|
||||||
|
style={{ fontSize: 12, marginLeft: 8, cursor: "help" }}
|
||||||
|
>
|
||||||
{alert.patient_id.slice(0, 8)}...
|
{alert.patient_id.slice(0, 8)}...
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="规则">
|
<Descriptions.Item label="规则">
|
||||||
<Typography.Text>{alert.title || '未知规则'}</Typography.Text>
|
<Typography.Text>
|
||||||
|
{translateAlertTitle(alert.title) || "未知规则"}
|
||||||
|
</Typography.Text>
|
||||||
<Tooltip title={alert.rule_id}>
|
<Tooltip title={alert.rule_id}>
|
||||||
<Typography.Text type="secondary" copyable style={{ fontSize: 12, marginLeft: 8, cursor: 'help' }}>
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
copyable
|
||||||
|
style={{ fontSize: 12, marginLeft: 8, cursor: "help" }}
|
||||||
|
>
|
||||||
{alert.rule_id.slice(0, 8)}...
|
{alert.rule_id.slice(0, 8)}...
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -122,24 +172,32 @@ export function AlertDetailPanel({
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
{alert.acknowledged_by && (
|
{alert.acknowledged_by && (
|
||||||
<Descriptions.Item label="处理人" span={2}>
|
<Descriptions.Item label="处理人" span={2}>
|
||||||
<Typography.Text style={{ fontSize: 12 }}>{alert.acknowledged_by}</Typography.Text>
|
<Typography.Text style={{ fontSize: 12 }}>
|
||||||
|
{alert.acknowledged_by}
|
||||||
|
</Typography.Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
{alert.acknowledged_at && (
|
{alert.acknowledged_at && (
|
||||||
<Descriptions.Item label="确认时间">
|
<Descriptions.Item label="确认时间">
|
||||||
{new Date(alert.acknowledged_at).toLocaleString('zh-CN')}
|
{new Date(alert.acknowledged_at).toLocaleString("zh-CN")}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
{alert.resolved_at && (
|
{alert.resolved_at && (
|
||||||
<Descriptions.Item label="恢复时间">
|
<Descriptions.Item label="恢复时间">
|
||||||
{new Date(alert.resolved_at).toLocaleString('zh-CN')}
|
{new Date(alert.resolved_at).toLocaleString("zh-CN")}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
{/* 告警详情 */}
|
{/* 告警详情 */}
|
||||||
{detailEntries.length > 0 && (
|
{detailEntries.length > 0 && (
|
||||||
<Descriptions column={2} size="small" bordered style={{ marginTop: 12 }} title="告警详情">
|
<Descriptions
|
||||||
|
column={2}
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
title="告警详情"
|
||||||
|
>
|
||||||
{detailEntries.map(([key, value]) => (
|
{detailEntries.map(([key, value]) => (
|
||||||
<Descriptions.Item key={key} label={DETAIL_LABEL_MAP[key] ?? key}>
|
<Descriptions.Item key={key} label={DETAIL_LABEL_MAP[key] ?? key}>
|
||||||
<Typography.Text style={{ fontSize: 13 }}>
|
<Typography.Text style={{ fontSize: 13 }}>
|
||||||
@@ -151,7 +209,13 @@ export function AlertDetailPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div style={{ marginTop: 16, borderTop: '1px solid var(--ant-color-border)', paddingTop: 12 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
borderTop: "1px solid var(--ant-color-border)",
|
||||||
|
paddingTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Space>
|
<Space>
|
||||||
{isPending && onAcknowledge && (
|
{isPending && onAcknowledge && (
|
||||||
<Tooltip title="确认已知晓此告警">
|
<Tooltip title="确认已知晓此告警">
|
||||||
|
|||||||
@@ -1,58 +1,125 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuthStore } from '../../../../stores/auth';
|
import { useAuthStore } from "../../../../stores/auth";
|
||||||
import { listAuditLogs, type AuditLogItem } from '../../../../api/auditLogs';
|
import { listAuditLogs, type AuditLogItem } from "../../../../api/auditLogs";
|
||||||
import { useStatsData } from '../../StatisticsDashboard/useStatsData';
|
import { EntityName } from "../../../../components/EntityName";
|
||||||
|
import { useStatsData } from "../../StatisticsDashboard/useStatsData";
|
||||||
import {
|
import {
|
||||||
dashboardApi,
|
dashboardApi,
|
||||||
type SystemHealthResp,
|
type SystemHealthResp,
|
||||||
type UserActivityResp,
|
type UserActivityResp,
|
||||||
type ModuleStatusResp,
|
type ModuleStatusResp,
|
||||||
} from '../../../../api/health/dashboard';
|
} from "../../../../api/health/dashboard";
|
||||||
|
|
||||||
function formatTimeAgo(dateStr: string): string {
|
function formatTimeAgo(dateStr: string): string {
|
||||||
const diff = Date.now() - new Date(dateStr).getTime();
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
const minutes = Math.floor(diff / 60000);
|
const minutes = Math.floor(diff / 60000);
|
||||||
if (minutes < 1) return '刚刚';
|
if (minutes < 1) return "刚刚";
|
||||||
if (minutes < 60) return `${minutes} 分钟前`;
|
if (minutes < 60) return `${minutes} 分钟前`;
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
if (hours < 24) return `${hours} 小时前`;
|
if (hours < 24) return `${hours} 小时前`;
|
||||||
return `${Math.floor(hours / 24)} 天前`;
|
return `${Math.floor(hours / 24)} 天前`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_ICONS: Record<string, { icon: string; bg: string; color: string }> = {
|
const ACTION_ICONS: Record<
|
||||||
create: { icon: '✓', bg: '#F0FDF4', color: '#16A34A' },
|
string,
|
||||||
created: { icon: '✓', bg: '#F0FDF4', color: '#16A34A' },
|
{ icon: string; bg: string; color: string }
|
||||||
update: { icon: '⚙', bg: '#FFFBEB', color: '#D97706' },
|
> = {
|
||||||
updated: { icon: '⚙', bg: '#FFFBEB', color: '#D97706' },
|
create: { icon: "✓", bg: "#F0FDF4", color: "#16A34A" },
|
||||||
delete: { icon: '✕', bg: '#FEF2F2', color: '#DC2626' },
|
created: { icon: "✓", bg: "#F0FDF4", color: "#16A34A" },
|
||||||
deleted: { icon: '✕', bg: '#FEF2F2', color: '#DC2626' },
|
update: { icon: "⚙", bg: "#FFFBEB", color: "#D97706" },
|
||||||
login: { icon: '👤', bg: '#EFF6FF', color: '#2563EB' },
|
updated: { icon: "⚙", bg: "#FFFBEB", color: "#D97706" },
|
||||||
'user.create': { icon: '✓', bg: '#F0FDF4', color: '#16A34A' },
|
delete: { icon: "✕", bg: "#FEF2F2", color: "#DC2626" },
|
||||||
'user.update': { icon: '⚙', bg: '#FFFBEB', color: '#D97706' },
|
deleted: { icon: "✕", bg: "#FEF2F2", color: "#DC2626" },
|
||||||
'user.delete': { icon: '✕', bg: '#FEF2F2', color: '#DC2626' },
|
login: { icon: "👤", bg: "#EFF6FF", color: "#2563EB" },
|
||||||
|
"user.create": { icon: "✓", bg: "#F0FDF4", color: "#16A34A" },
|
||||||
|
"user.update": { icon: "⚙", bg: "#FFFBEB", color: "#D97706" },
|
||||||
|
"user.delete": { icon: "✕", bg: "#FEF2F2", color: "#DC2626" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
create: '创建', created: '创建', update: '更新', updated: '更新',
|
create: "创建",
|
||||||
delete: '删除', deleted: '删除', login: '登录', 'user.create': '创建',
|
created: "创建",
|
||||||
'user.update': '更新', 'user.delete': '删除',
|
update: "更新",
|
||||||
|
updated: "更新",
|
||||||
|
delete: "删除",
|
||||||
|
deleted: "删除",
|
||||||
|
login: "登录",
|
||||||
|
"user.create": "创建",
|
||||||
|
"user.update": "更新",
|
||||||
|
"user.delete": "删除",
|
||||||
};
|
};
|
||||||
const RESOURCE_LABELS: Record<string, string> = {
|
const RESOURCE_LABELS: Record<string, string> = {
|
||||||
user: '用户', role: '角色', patient: '患者', doctor: '医护',
|
user: "用户",
|
||||||
appointment: '预约', follow_up_task: '随访', consultation_session: '咨询',
|
role: "角色",
|
||||||
message: '消息', plugin: '插件', process_instance: '流程实例', organization: '组织',
|
patient: "患者",
|
||||||
|
doctor: "医护",
|
||||||
|
appointment: "预约",
|
||||||
|
follow_up_task: "随访",
|
||||||
|
consultation_session: "咨询",
|
||||||
|
message: "消息",
|
||||||
|
plugin: "插件",
|
||||||
|
process_instance: "流程实例",
|
||||||
|
organization: "组织",
|
||||||
};
|
};
|
||||||
|
|
||||||
const QUICK_ACTIONS = [
|
const QUICK_ACTIONS = [
|
||||||
{ icon: '👤', bg: '#EFF6FF', color: '#2563EB', text: '用户管理', path: '/users' },
|
{
|
||||||
{ icon: '🔑', bg: '#F5F3FF', color: '#7C3AED', text: '角色权限', path: '/roles' },
|
icon: "👤",
|
||||||
{ icon: '⚙', bg: '#FFFBEB', color: '#D97706', text: '系统配置', path: '/settings' },
|
bg: "#EFF6FF",
|
||||||
{ icon: '📋', bg: '#FEF2F2', color: '#DC2626', text: '审计日志', path: '/audit-logs' },
|
color: "#2563EB",
|
||||||
{ icon: '🧩', bg: '#F0FDF4', color: '#16A34A', text: '插件管理', path: '/plugins' },
|
text: "用户管理",
|
||||||
{ icon: '📖', bg: '#F0F9FF', color: '#0284C7', text: '菜单管理', path: '/menus' },
|
path: "/users",
|
||||||
{ icon: '📊', bg: '#FFF1F2', color: '#E11D48', text: '数据字典', path: '/dictionaries' },
|
},
|
||||||
{ icon: '🔔', bg: '#F8FAFC', color: '#475569', text: '消息管理', path: '/messages' },
|
{
|
||||||
|
icon: "🔑",
|
||||||
|
bg: "#F5F3FF",
|
||||||
|
color: "#7C3AED",
|
||||||
|
text: "角色权限",
|
||||||
|
path: "/roles",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "⚙",
|
||||||
|
bg: "#FFFBEB",
|
||||||
|
color: "#D97706",
|
||||||
|
text: "系统配置",
|
||||||
|
path: "/settings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "📋",
|
||||||
|
bg: "#FEF2F2",
|
||||||
|
color: "#DC2626",
|
||||||
|
text: "审计日志",
|
||||||
|
path: "/audit-logs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "🧩",
|
||||||
|
bg: "#F0FDF4",
|
||||||
|
color: "#16A34A",
|
||||||
|
text: "插件管理",
|
||||||
|
path: "/plugins",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "📖",
|
||||||
|
bg: "#F0F9FF",
|
||||||
|
color: "#0284C7",
|
||||||
|
text: "菜单管理",
|
||||||
|
path: "/menus",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "📊",
|
||||||
|
bg: "#FFF1F2",
|
||||||
|
color: "#E11D48",
|
||||||
|
text: "数据字典",
|
||||||
|
path: "/dictionaries",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "🔔",
|
||||||
|
bg: "#F8FAFC",
|
||||||
|
color: "#475569",
|
||||||
|
text: "消息管理",
|
||||||
|
path: "/messages",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
@@ -60,142 +127,367 @@ export default function AdminDashboard() {
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const statsData = useStatsData();
|
const statsData = useStatsData();
|
||||||
const [auditLogs, setAuditLogs] = useState<AuditLogItem[]>([]);
|
const [auditLogs, setAuditLogs] = useState<AuditLogItem[]>([]);
|
||||||
const [systemHealth, setSystemHealth] = useState<SystemHealthResp | null>(null);
|
const [systemHealth, setSystemHealth] = useState<SystemHealthResp | null>(
|
||||||
const [userActivity, setUserActivity] = useState<UserActivityResp | null>(null);
|
null,
|
||||||
|
);
|
||||||
|
const [userActivity, setUserActivity] = useState<UserActivityResp | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [modules, setModules] = useState<ModuleStatusResp[]>([]);
|
const [modules, setModules] = useState<ModuleStatusResp[]>([]);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
const [auditResult, healthResult, activityResult, modulesResult] = await Promise.allSettled([
|
const [auditResult, healthResult, activityResult, modulesResult] =
|
||||||
listAuditLogs({ page: 1, page_size: 6 }),
|
await Promise.allSettled([
|
||||||
dashboardApi.getSystemHealth(),
|
listAuditLogs({ page: 1, page_size: 6 }),
|
||||||
dashboardApi.getUserActivity(),
|
dashboardApi.getSystemHealth(),
|
||||||
dashboardApi.getModuleStatus(),
|
dashboardApi.getUserActivity(),
|
||||||
]);
|
dashboardApi.getModuleStatus(),
|
||||||
|
]);
|
||||||
|
|
||||||
if (auditResult.status === 'fulfilled') {
|
if (auditResult.status === "fulfilled") {
|
||||||
setAuditLogs(auditResult.value.data.filter((a) => a.action !== 'login_failed'));
|
setAuditLogs(
|
||||||
|
auditResult.value.data.filter((a) => a.action !== "login_failed"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (healthResult.status === 'fulfilled') setSystemHealth(healthResult.value);
|
if (healthResult.status === "fulfilled")
|
||||||
if (activityResult.status === 'fulfilled') setUserActivity(activityResult.value);
|
setSystemHealth(healthResult.value);
|
||||||
if (modulesResult.status === 'fulfilled') setModules(modulesResult.value);
|
if (activityResult.status === "fulfilled")
|
||||||
|
setUserActivity(activityResult.value);
|
||||||
|
if (modulesResult.status === "fulfilled") setModules(modulesResult.value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
const firstName = user?.display_name ?? user?.username ?? '管理员';
|
const firstName = user?.display_name ?? user?.username ?? "管理员";
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const greeting = now.getHours() < 12 ? '早上好' : now.getHours() < 18 ? '下午好' : '晚上好';
|
const greeting =
|
||||||
const activeModules = modules.length > 0 ? modules.filter((m) => m.active).length : 0;
|
now.getHours() < 12 ? "早上好" : now.getHours() < 18 ? "下午好" : "晚上好";
|
||||||
|
const activeModules =
|
||||||
|
modules.length > 0 ? modules.filter((m) => m.active).length : 0;
|
||||||
const totalModules = modules.length || 8;
|
const totalModules = modules.length || 8;
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{ label: '注册用户', value: userActivity?.total_registered ?? statsData.patientStats?.total_patients ?? 0, color: '#2563EB', gradient: 'linear-gradient(90deg,#2563EB,#60A5FA)', sub: `今日活跃 ${userActivity?.daily_active ?? 0}` },
|
{
|
||||||
{ label: '业务模块', value: `${activeModules} / ${totalModules}`, color: '#7C3AED', gradient: 'linear-gradient(90deg,#7C3AED,#A78BFA)', sub: totalModules > 0 ? `${totalModules - activeModules} 个插件待启用` : '加载中...' },
|
label: "注册用户",
|
||||||
{ label: '今日操作', value: userActivity?.daily_active ?? 0, color: '#16A34A', gradient: 'linear-gradient(90deg,#16A34A,#4ADE80)', sub: `近 ${auditLogs.length} 条记录` },
|
value:
|
||||||
{ label: '本周活跃', value: userActivity?.weekly_active ?? 0, color: '#EA580C', gradient: 'linear-gradient(90deg,#EA580C,#FB923C)', sub: `月活 ${userActivity?.monthly_active ?? 0}` },
|
userActivity?.total_registered ??
|
||||||
|
statsData.patientStats?.total_patients ??
|
||||||
|
0,
|
||||||
|
color: "#2563EB",
|
||||||
|
gradient: "linear-gradient(90deg,#2563EB,#60A5FA)",
|
||||||
|
sub: `今日活跃 ${userActivity?.daily_active ?? 0}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "业务模块",
|
||||||
|
value: `${activeModules} / ${totalModules}`,
|
||||||
|
color: "#7C3AED",
|
||||||
|
gradient: "linear-gradient(90deg,#7C3AED,#A78BFA)",
|
||||||
|
sub:
|
||||||
|
totalModules > 0
|
||||||
|
? `${totalModules - activeModules} 个插件待启用`
|
||||||
|
: "加载中...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "今日操作",
|
||||||
|
value: userActivity?.daily_active ?? 0,
|
||||||
|
color: "#16A34A",
|
||||||
|
gradient: "linear-gradient(90deg,#16A34A,#4ADE80)",
|
||||||
|
sub: `近 ${auditLogs.length} 条记录`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "本周活跃",
|
||||||
|
value: userActivity?.weekly_active ?? 0,
|
||||||
|
color: "#EA580C",
|
||||||
|
gradient: "linear-gradient(90deg,#EA580C,#FB923C)",
|
||||||
|
sub: `月活 ${userActivity?.monthly_active ?? 0}`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const healthServices = systemHealth?.services ?? [
|
const healthServices = systemHealth?.services ?? [
|
||||||
{ name: 'API 服务', status: 'unknown' as const, message: '数据加载中...', response_ms: null },
|
{
|
||||||
{ name: '数据库', status: 'unknown' as const, message: '数据加载中...', response_ms: null },
|
name: "API 服务",
|
||||||
{ name: '定时任务', status: 'unknown' as const, message: '数据加载中...', response_ms: null },
|
status: "unknown" as const,
|
||||||
|
message: "数据加载中...",
|
||||||
|
response_ms: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "数据库",
|
||||||
|
status: "unknown" as const,
|
||||||
|
message: "数据加载中...",
|
||||||
|
response_ms: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "定时任务",
|
||||||
|
status: "unknown" as const,
|
||||||
|
message: "数据加载中...",
|
||||||
|
response_ms: null,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const userActivityItems = [
|
const userActivityItems = [
|
||||||
{ label: '今日活跃', value: userActivity?.daily_active ?? 0, pct: userActivity ? Math.round((userActivity.daily_active / Math.max(userActivity.total_registered, 1)) * 100) : 0, color: '#2563EB' },
|
{
|
||||||
{ label: '本周活跃', value: userActivity?.weekly_active ?? 0, pct: userActivity ? Math.round((userActivity.weekly_active / Math.max(userActivity.total_registered, 1)) * 100) : 0, color: '#7C3AED' },
|
label: "今日活跃",
|
||||||
{ label: '本月活跃', value: userActivity?.monthly_active ?? 0, pct: userActivity ? Math.round((userActivity.monthly_active / Math.max(userActivity.total_registered, 1)) * 100) : 0, color: '#16A34A' },
|
value: userActivity?.daily_active ?? 0,
|
||||||
{ label: '总注册', value: userActivity?.total_registered ?? statsData.patientStats?.total_patients ?? 0, pct: 100, color: '#94A3B8' },
|
pct: userActivity
|
||||||
|
? Math.round(
|
||||||
|
(userActivity.daily_active /
|
||||||
|
Math.max(userActivity.total_registered, 1)) *
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
: 0,
|
||||||
|
color: "#2563EB",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "本周活跃",
|
||||||
|
value: userActivity?.weekly_active ?? 0,
|
||||||
|
pct: userActivity
|
||||||
|
? Math.round(
|
||||||
|
(userActivity.weekly_active /
|
||||||
|
Math.max(userActivity.total_registered, 1)) *
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
: 0,
|
||||||
|
color: "#7C3AED",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "本月活跃",
|
||||||
|
value: userActivity?.monthly_active ?? 0,
|
||||||
|
pct: userActivity
|
||||||
|
? Math.round(
|
||||||
|
(userActivity.monthly_active /
|
||||||
|
Math.max(userActivity.total_registered, 1)) *
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
: 0,
|
||||||
|
color: "#16A34A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "总注册",
|
||||||
|
value:
|
||||||
|
userActivity?.total_registered ??
|
||||||
|
statsData.patientStats?.total_patients ??
|
||||||
|
0,
|
||||||
|
pct: 100,
|
||||||
|
color: "#94A3B8",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* 欢迎栏 */}
|
{/* 欢迎栏 */}
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<h1 style={{ fontSize: 22, fontWeight: 700, margin: '0 0 4px' }}>
|
<h1 style={{ fontSize: 22, fontWeight: 700, margin: "0 0 4px" }}>
|
||||||
{greeting},{firstName.charAt(0)}主任
|
{greeting},{firstName.charAt(0)}主任
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ color: '#64748B', fontSize: 13, margin: 0 }}>
|
<p style={{ color: "#64748B", fontSize: 13, margin: 0 }}>
|
||||||
平台运行正常 · {activeModules} 个模块已激活 · 今日数据概览
|
平台运行正常 · {activeModules} 个模块已激活 · 今日数据概览
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 系统健康条 */}
|
{/* 系统健康条 */}
|
||||||
<div style={{
|
<div
|
||||||
display: 'flex', gap: 12, marginBottom: 20,
|
style={{
|
||||||
padding: '14px 20px', background: '#fff', borderRadius: 12,
|
display: "flex",
|
||||||
border: '1px solid #E2E8F0',
|
gap: 12,
|
||||||
}}>
|
marginBottom: 20,
|
||||||
|
padding: "14px 20px",
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid #E2E8F0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{healthServices.map((item, i) => (
|
{healthServices.map((item, i) => (
|
||||||
<div key={item.name} style={{
|
<div
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
key={item.name}
|
||||||
fontSize: 12, color: '#64748B', paddingRight: 12,
|
style={{
|
||||||
borderRight: i < healthServices.length - 1 ? '1px solid #F1F5F9' : undefined,
|
display: "flex",
|
||||||
}}>
|
alignItems: "center",
|
||||||
<div style={{
|
gap: 8,
|
||||||
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
|
fontSize: 12,
|
||||||
background: item.status === 'healthy' ? '#22C55E' : item.status === 'degraded' ? '#EAB308' : '#EF4444',
|
color: "#64748B",
|
||||||
}} />
|
paddingRight: 12,
|
||||||
<span style={{ fontWeight: 500, color: '#334155' }}>{item.name}</span> {item.message}
|
borderRight:
|
||||||
|
i < healthServices.length - 1 ? "1px solid #F1F5F9" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
flexShrink: 0,
|
||||||
|
background:
|
||||||
|
item.status === "healthy"
|
||||||
|
? "#22C55E"
|
||||||
|
: item.status === "degraded"
|
||||||
|
? "#EAB308"
|
||||||
|
: "#EF4444",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontWeight: 500, color: "#334155" }}>
|
||||||
|
{item.name}
|
||||||
|
</span>{" "}
|
||||||
|
{item.message}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* 统计卡片 */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14, marginBottom: 24 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(4, 1fr)",
|
||||||
|
gap: 14,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{statCards.map((card) => (
|
{statCards.map((card) => (
|
||||||
<div key={card.label} style={{
|
<div
|
||||||
background: '#fff', borderRadius: 12, border: '1px solid #E2E8F0',
|
key={card.label}
|
||||||
overflow: 'hidden', cursor: 'pointer', transition: 'all 0.2s',
|
style={{
|
||||||
}}>
|
background: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid #E2E8F0",
|
||||||
|
overflow: "hidden",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ height: 3, background: card.gradient }} />
|
<div style={{ height: 3, background: card.gradient }} />
|
||||||
<div style={{ padding: '14px 18px' }}>
|
<div style={{ padding: "14px 18px" }}>
|
||||||
<div style={{ fontSize: 12, color: '#94A3B8', marginBottom: 4 }}>{card.label}</div>
|
<div style={{ fontSize: 12, color: "#94A3B8", marginBottom: 4 }}>
|
||||||
<div style={{ fontSize: 26, fontWeight: 700, color: card.color }}>{card.value}</div>
|
{card.label}
|
||||||
<div style={{ fontSize: 11, color: '#94A3B8', marginTop: 3 }}>{card.sub}</div>
|
</div>
|
||||||
|
<div style={{ fontSize: 26, fontWeight: 700, color: card.color }}>
|
||||||
|
{card.value}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#94A3B8", marginTop: 3 }}>
|
||||||
|
{card.sub}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 双栏:审计日志 + 模块状态 */}
|
{/* 双栏:审计日志 + 模块状态 */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* 最近审计日志 */}
|
{/* 最近审计日志 */}
|
||||||
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E2E8F0', overflow: 'hidden' }}>
|
<div
|
||||||
<div style={{
|
style={{
|
||||||
padding: '12px 18px', borderBottom: '1px solid #F1F5F9',
|
background: "#fff",
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
borderRadius: 12,
|
||||||
}}>
|
border: "1px solid #E2E8F0",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 18px",
|
||||||
|
borderBottom: "1px solid #F1F5F9",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h3 style={{ fontSize: 14, fontWeight: 600 }}>最近操作记录</h3>
|
<h3 style={{ fontSize: 14, fontWeight: 600 }}>最近操作记录</h3>
|
||||||
<span style={{ fontSize: 11, color: '#2563EB', cursor: 'pointer' }} onClick={() => navigate('/audit-logs')}>审计日志 →</span>
|
<span
|
||||||
|
style={{ fontSize: 11, color: "#2563EB", cursor: "pointer" }}
|
||||||
|
onClick={() => navigate("/audit-logs")}
|
||||||
|
>
|
||||||
|
审计日志 →
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{auditLogs.length === 0 ? (
|
{auditLogs.length === 0 ? (
|
||||||
<div style={{ padding: 24, textAlign: 'center', color: '#94A3B8', fontSize: 13 }}>暂无操作记录</div>
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 24,
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#94A3B8",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
暂无操作记录
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
auditLogs.map((log) => {
|
auditLogs.map((log) => {
|
||||||
const actionKey = log.action.split('.').pop() ?? log.action;
|
const actionKey = log.action.split(".").pop() ?? log.action;
|
||||||
const iconCfg = ACTION_ICONS[log.action] ?? ACTION_ICONS[actionKey] ?? { icon: '📋', bg: '#F0F9FF', color: '#0284C7' };
|
const iconCfg = ACTION_ICONS[log.action] ??
|
||||||
const actionLabel = ACTION_LABELS[log.action] ?? ACTION_LABELS[actionKey] ?? log.action;
|
ACTION_ICONS[actionKey] ?? {
|
||||||
const resourceLabel = RESOURCE_LABELS[log.resource_type] ?? RESOURCE_LABELS[log.resource_type.split('.').pop() ?? ''] ?? log.resource_type;
|
icon: "📋",
|
||||||
|
bg: "#F0F9FF",
|
||||||
|
color: "#0284C7",
|
||||||
|
};
|
||||||
|
const actionLabel =
|
||||||
|
ACTION_LABELS[log.action] ??
|
||||||
|
ACTION_LABELS[actionKey] ??
|
||||||
|
log.action;
|
||||||
|
const resourceLabel =
|
||||||
|
RESOURCE_LABELS[log.resource_type] ??
|
||||||
|
RESOURCE_LABELS[log.resource_type.split(".").pop() ?? ""] ??
|
||||||
|
log.resource_type;
|
||||||
return (
|
return (
|
||||||
<div key={log.id} style={{
|
<div
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
key={log.id}
|
||||||
padding: '10px 18px', borderBottom: '1px solid #F1F5F9',
|
style={{
|
||||||
fontSize: 13, transition: 'background 0.15s', cursor: 'pointer',
|
display: "flex",
|
||||||
}}
|
alignItems: "center",
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#F8FAFC'; }}
|
gap: 10,
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
padding: "10px 18px",
|
||||||
|
borderBottom: "1px solid #F1F5F9",
|
||||||
|
fontSize: 13,
|
||||||
|
transition: "background 0.15s",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = "#F8FAFC";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div
|
||||||
width: 28, height: 28, borderRadius: 6, background: iconCfg.bg,
|
style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
width: 28,
|
||||||
fontSize: 12, flexShrink: 0, color: iconCfg.color,
|
height: 28,
|
||||||
}}>{iconCfg.icon}</div>
|
borderRadius: 6,
|
||||||
<span style={{ fontWeight: 500, flexShrink: 0, width: 60 }}>{log.user_id ? log.user_id.slice(0, 6) : '系统'}</span>
|
background: iconCfg.bg,
|
||||||
<span style={{ flex: 1, color: '#475569' }}>
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 12,
|
||||||
|
flexShrink: 0,
|
||||||
|
color: iconCfg.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{iconCfg.icon}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontWeight: 500, flexShrink: 0, width: 60 }}>
|
||||||
|
{log.user_id ? (
|
||||||
|
<EntityName
|
||||||
|
name={log.user_id.slice(0, 6)}
|
||||||
|
id={log.user_id}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"系统"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1, color: "#475569" }}>
|
||||||
{actionLabel}了{resourceLabel}
|
{actionLabel}了{resourceLabel}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, color: '#94A3B8', flexShrink: 0 }}>{formatTimeAgo(log.created_at)}</span>
|
<span
|
||||||
|
style={{ fontSize: 11, color: "#94A3B8", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{formatTimeAgo(log.created_at)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -203,94 +495,231 @@ export default function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 模块状态 */}
|
{/* 模块状态 */}
|
||||||
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E2E8F0', overflow: 'hidden' }}>
|
<div
|
||||||
<div style={{
|
style={{
|
||||||
padding: '12px 18px', borderBottom: '1px solid #F1F5F9',
|
background: "#fff",
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
borderRadius: 12,
|
||||||
}}>
|
border: "1px solid #E2E8F0",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 18px",
|
||||||
|
borderBottom: "1px solid #F1F5F9",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h3 style={{ fontSize: 14, fontWeight: 600 }}>模块状态</h3>
|
<h3 style={{ fontSize: 14, fontWeight: 600 }}>模块状态</h3>
|
||||||
<span style={{ fontSize: 11, color: '#2563EB', cursor: 'pointer' }} onClick={() => navigate('/plugins')}>模块管理 →</span>
|
<span
|
||||||
|
style={{ fontSize: 11, color: "#2563EB", cursor: "pointer" }}
|
||||||
|
onClick={() => navigate("/plugins")}
|
||||||
|
>
|
||||||
|
模块管理 →
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(modules.length > 0 ? modules : []).map((mod) => (
|
{(modules.length > 0 ? modules : []).map((mod) => (
|
||||||
<div key={mod.name} style={{
|
<div
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
key={mod.name}
|
||||||
padding: '10px 18px', borderBottom: '1px solid #F1F5F9',
|
style={{
|
||||||
}}>
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "10px 18px",
|
||||||
|
borderBottom: "1px solid #F1F5F9",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{mod.display_name}</div>
|
<div style={{ fontSize: 13, fontWeight: 500 }}>
|
||||||
<div style={{ fontSize: 11, color: '#94A3B8' }}>{mod.description}</div>
|
{mod.display_name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#94A3B8" }}>
|
||||||
|
{mod.description}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span style={{
|
<span
|
||||||
fontSize: 11, padding: '2px 10px', borderRadius: 10, fontWeight: 500,
|
style={{
|
||||||
background: mod.active ? '#F0FDF4' : '#F1F5F9',
|
fontSize: 11,
|
||||||
color: mod.active ? '#16A34A' : '#94A3B8',
|
padding: "2px 10px",
|
||||||
}}>{mod.active ? '运行中' : '未启用'}</span>
|
borderRadius: 10,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: mod.active ? "#F0FDF4" : "#F1F5F9",
|
||||||
|
color: mod.active ? "#16A34A" : "#94A3B8",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mod.active ? "运行中" : "未启用"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 双栏:用户活跃度 + 快捷管理 */}
|
{/* 双栏:用户活跃度 + 快捷管理 */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||||
{/* 用户活跃度 */}
|
{/* 用户活跃度 */}
|
||||||
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E2E8F0', overflow: 'hidden' }}>
|
<div
|
||||||
<div style={{
|
style={{
|
||||||
padding: '12px 18px', borderBottom: '1px solid #F1F5F9',
|
background: "#fff",
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
borderRadius: 12,
|
||||||
}}>
|
border: "1px solid #E2E8F0",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 18px",
|
||||||
|
borderBottom: "1px solid #F1F5F9",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h3 style={{ fontSize: 14, fontWeight: 600 }}>用户活跃度</h3>
|
<h3 style={{ fontSize: 14, fontWeight: 600 }}>用户活跃度</h3>
|
||||||
<span style={{ fontSize: 11, color: '#2563EB', cursor: 'pointer' }} onClick={() => navigate('/users')}>用户管理 →</span>
|
<span
|
||||||
|
style={{ fontSize: 11, color: "#2563EB", cursor: "pointer" }}
|
||||||
|
onClick={() => navigate("/users")}
|
||||||
|
>
|
||||||
|
用户管理 →
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{userActivityItems.map((item) => (
|
{userActivityItems.map((item) => (
|
||||||
<div key={item.label} style={{
|
<div
|
||||||
display: 'flex', alignItems: 'center', gap: 12,
|
key={item.label}
|
||||||
padding: '10px 18px', borderBottom: '1px solid #F1F5F9',
|
style={{
|
||||||
}}>
|
display: "flex",
|
||||||
<span style={{ fontSize: 12, width: 70, flexShrink: 0 }}>{item.label}</span>
|
alignItems: "center",
|
||||||
<div style={{ flex: 1, height: 6, background: '#F1F5F9', borderRadius: 3, overflow: 'hidden' }}>
|
gap: 12,
|
||||||
<div style={{ width: `${item.pct}%`, height: '100%', background: item.color, borderRadius: 3 }} />
|
padding: "10px 18px",
|
||||||
|
borderBottom: "1px solid #F1F5F9",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, width: 70, flexShrink: 0 }}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 6,
|
||||||
|
background: "#F1F5F9",
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${item.pct}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: item.color,
|
||||||
|
borderRadius: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, width: 40, textAlign: 'right', flexShrink: 0, color: item.color === '#94A3B8' ? '#475569' : item.color }}>{item.value}</span>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
width: 40,
|
||||||
|
textAlign: "right",
|
||||||
|
flexShrink: 0,
|
||||||
|
color: item.color === "#94A3B8" ? "#475569" : item.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div style={{
|
<div
|
||||||
padding: '12px 18px', borderTop: '1px solid #F1F5F9',
|
style={{
|
||||||
display: 'flex', justifyContent: 'space-between',
|
padding: "12px 18px",
|
||||||
}}>
|
borderTop: "1px solid #F1F5F9",
|
||||||
<div style={{ fontSize: 11, color: '#94A3B8' }}>按角色分布</div>
|
display: "flex",
|
||||||
<div style={{ display: 'flex', gap: 10, fontSize: 11 }}>
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, color: "#94A3B8" }}>按角色分布</div>
|
||||||
|
<div style={{ display: "flex", gap: 10, fontSize: 11 }}>
|
||||||
{userActivity?.by_role.map((r) => (
|
{userActivity?.by_role.map((r) => (
|
||||||
<span key={r.role}>{r.role} {r.count}</span>
|
<span key={r.role}>
|
||||||
)) ?? <span style={{ color: '#94A3B8' }}>加载中...</span>}
|
{r.role} {r.count}
|
||||||
|
</span>
|
||||||
|
)) ?? <span style={{ color: "#94A3B8" }}>加载中...</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 快捷管理入口 */}
|
{/* 快捷管理入口 */}
|
||||||
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E2E8F0', overflow: 'hidden' }}>
|
<div
|
||||||
<div style={{
|
style={{
|
||||||
padding: '12px 18px', borderBottom: '1px solid #F1F5F9',
|
background: "#fff",
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
borderRadius: 12,
|
||||||
}}>
|
border: "1px solid #E2E8F0",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 18px",
|
||||||
|
borderBottom: "1px solid #F1F5F9",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h3 style={{ fontSize: 14, fontWeight: 600 }}>系统管理</h3>
|
<h3 style={{ fontSize: 14, fontWeight: 600 }}>系统管理</h3>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, padding: '14px 18px' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(4, 1fr)",
|
||||||
|
gap: 10,
|
||||||
|
padding: "14px 18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{QUICK_ACTIONS.map((item) => (
|
{QUICK_ACTIONS.map((item) => (
|
||||||
<div key={item.path} style={{
|
<div
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
|
key={item.path}
|
||||||
padding: '14px 8px', borderRadius: 10, border: '1px solid #E2E8F0',
|
style={{
|
||||||
cursor: 'pointer', transition: 'all 0.15s', textAlign: 'center',
|
display: "flex",
|
||||||
}}
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "14px 8px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "1px solid #E2E8F0",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
onClick={() => navigate(item.path)}
|
onClick={() => navigate(item.path)}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#2563EB'; e.currentTarget.style.background = '#F8FAFC'; }}
|
onMouseEnter={(e) => {
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = '#E2E8F0'; e.currentTarget.style.background = 'transparent'; }}
|
e.currentTarget.style.borderColor = "#2563EB";
|
||||||
|
e.currentTarget.style.background = "#F8FAFC";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "#E2E8F0";
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div
|
||||||
width: 36, height: 36, borderRadius: 8, background: item.bg,
|
style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
width: 36,
|
||||||
fontSize: 16, color: item.color,
|
height: 36,
|
||||||
}}>{item.icon}</div>
|
borderRadius: 8,
|
||||||
<span style={{ fontSize: 12, fontWeight: 500 }}>{item.text}</span>
|
background: item.bg,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 16,
|
||||||
|
color: item.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 500 }}>
|
||||||
|
{item.text}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,74 +21,209 @@ interface RoutePermissionEntry {
|
|||||||
|
|
||||||
const ENTRIES: RoutePermissionEntry[] = [
|
const ENTRIES: RoutePermissionEntry[] = [
|
||||||
// ===== 基础模块 =====
|
// ===== 基础模块 =====
|
||||||
{ path: '/users', permissions: ['user.list', 'user.update'] },
|
{ path: "/users", permissions: ["user.list", "user.update"] },
|
||||||
{ path: '/roles', permissions: ['role.list', 'role.update'] },
|
{ path: "/roles", permissions: ["role.list", "role.update"] },
|
||||||
{ path: '/organizations', permissions: ['organization.list', 'organization.update'] },
|
{
|
||||||
{ path: '/workflow', permissions: ['workflow.list', 'workflow.read'] },
|
path: "/organizations",
|
||||||
{ path: '/messages', permissions: ['message.list'] },
|
permissions: ["organization.list", "organization.update"],
|
||||||
{ path: '/settings', permissions: ['setting.read', 'setting.update'] },
|
},
|
||||||
|
{ path: "/workflow", permissions: ["workflow.list", "workflow.read"] },
|
||||||
|
{ path: "/messages", permissions: ["message.list"] },
|
||||||
|
{ path: "/settings", permissions: ["setting.read", "setting.update"] },
|
||||||
|
|
||||||
// ===== 插件模块(精确路径优先于前缀通配) =====
|
// ===== 插件模块(精确路径优先于前缀通配) =====
|
||||||
{ path: '/plugins/admin', permissions: ['plugin.admin'] },
|
{ path: "/plugins/admin", permissions: ["plugin.admin"] },
|
||||||
{ path: '/plugins/market', permissions: ['plugin.admin'] },
|
{ path: "/plugins/market", permissions: ["plugin.admin"] },
|
||||||
// 动态路由 catch-all: /plugins/:pluginId/:entityName 等
|
// 动态路由 catch-all: /plugins/:pluginId/:entityName 等
|
||||||
{ path: '/plugins', permissions: ['plugin.list', 'plugin.admin'] },
|
{ path: "/plugins", permissions: ["plugin.list", "plugin.admin"] },
|
||||||
|
|
||||||
// ===== 健康管理 — 患者与医生 =====
|
// ===== 健康管理 — 患者与医生 =====
|
||||||
{ path: '/health/patients', permissions: ['health.patient.list', 'health.patient.manage'] },
|
{
|
||||||
{ path: '/health/tags', permissions: ['health.patient.list', 'health.patient.manage'] },
|
path: "/health/patients",
|
||||||
{ path: '/health/doctors', permissions: ['health.doctor.list', 'health.doctor.manage'] },
|
permissions: ["health.patient.list", "health.patient.manage"],
|
||||||
{ path: '/health/appointments', permissions: ['health.appointment.list', 'health.appointment.manage'] },
|
},
|
||||||
|
{
|
||||||
|
path: "/health/tags",
|
||||||
|
permissions: ["health.patient.list", "health.patient.manage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/doctors",
|
||||||
|
permissions: ["health.doctor.list", "health.doctor.manage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/appointments",
|
||||||
|
permissions: ["health.appointment.list", "health.appointment.manage"],
|
||||||
|
},
|
||||||
|
|
||||||
// ===== 健康管理 — 随访与咨询 =====
|
// ===== 健康管理 — 随访与咨询 =====
|
||||||
{ path: '/health/follow-up-tasks', permissions: ['health.follow-up.list', 'health.follow-up.manage'] },
|
{
|
||||||
{ path: '/health/follow-up-records', permissions: ['health.follow-up.list', 'health.follow-up.manage'] },
|
path: "/health/follow-up-tasks",
|
||||||
{ path: '/health/follow-up-templates', permissions: ['health.follow-up-templates.list', 'health.follow-up-templates.manage'] },
|
permissions: ["health.follow-up.list", "health.follow-up.manage"],
|
||||||
{ path: '/health/consultations', permissions: ['health.consultation.list', 'health.consultation.manage'] },
|
},
|
||||||
{ path: '/health/action-inbox', permissions: ['health.action-inbox.list', 'health.action-inbox.manage'] },
|
{
|
||||||
|
path: "/health/follow-up-records",
|
||||||
|
permissions: ["health.follow-up.list", "health.follow-up.manage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/follow-up-templates",
|
||||||
|
permissions: [
|
||||||
|
"health.follow-up-templates.list",
|
||||||
|
"health.follow-up-templates.manage",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/consultations",
|
||||||
|
permissions: ["health.consultation.list", "health.consultation.manage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/action-inbox",
|
||||||
|
permissions: ["health.action-inbox.list", "health.action-inbox.manage"],
|
||||||
|
},
|
||||||
|
|
||||||
// ===== 健康管理 — 告警与设备 =====
|
// ===== 健康管理 — 告警与设备 =====
|
||||||
{ path: '/health/alerts', permissions: ['health.alerts.list', 'health.alerts.manage'] },
|
{
|
||||||
{ path: '/health/alert-dashboard', permissions: ['health.alerts.list', 'health.alerts.manage'] },
|
path: "/health/alerts",
|
||||||
{ path: '/health/alert-rules', permissions: ['health.alert-rules.list', 'health.alert-rules.manage'] },
|
permissions: ["health.alerts.list", "health.alerts.manage"],
|
||||||
{ path: '/health/devices', permissions: ['health.devices.list', 'health.devices.manage'] },
|
},
|
||||||
{ path: '/health/realtime-monitor', permissions: ['health.device-readings.list', 'health.device-readings.manage'] },
|
{
|
||||||
{ path: '/health/ble-gateways', permissions: ['health.ble-gateways.list', 'health.ble-gateways.manage'] },
|
path: "/health/alert-dashboard",
|
||||||
{ path: '/health/critical-value-thresholds', permissions: ['health.critical-value-thresholds.list', 'health.critical-value-thresholds.manage'] },
|
permissions: ["health.alerts.list", "health.alerts.manage"],
|
||||||
{ path: '/health/daily-monitoring', permissions: ['health.device-readings.list', 'health.device-readings.manage'] },
|
},
|
||||||
|
{
|
||||||
|
path: "/health/alert-rules",
|
||||||
|
permissions: ["health.alert-rules.list", "health.alert-rules.manage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/devices",
|
||||||
|
permissions: ["health.devices.list", "health.devices.manage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/realtime-monitor",
|
||||||
|
permissions: [
|
||||||
|
"health.device-readings.list",
|
||||||
|
"health.device-readings.manage",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/ble-gateways",
|
||||||
|
permissions: ["health.ble-gateways.list", "health.ble-gateways.manage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/critical-value-thresholds",
|
||||||
|
permissions: [
|
||||||
|
"health.critical-value-thresholds.list",
|
||||||
|
"health.critical-value-thresholds.manage",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/daily-monitoring",
|
||||||
|
permissions: [
|
||||||
|
"health.daily-monitoring.list",
|
||||||
|
"health.daily-monitoring.manage",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
// ===== 健康管理 — 诊断与知情同意 =====
|
// ===== 健康管理 — 诊断与知情同意 =====
|
||||||
{ path: '/health/diagnoses', permissions: ['health.health-data.list', 'health.health-data.manage'] },
|
{
|
||||||
{ path: '/health/consents', permissions: ['health.consent.list', 'health.consent.manage'] },
|
path: "/health/diagnoses",
|
||||||
|
permissions: ["health.health-data.list", "health.health-data.manage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/consents",
|
||||||
|
permissions: ["health.consent.list", "health.consent.manage"],
|
||||||
|
},
|
||||||
|
|
||||||
// ===== 健康管理 — AI 模块 =====
|
// ===== 健康管理 — AI 模块 =====
|
||||||
{ path: '/health/ai-prompts', permissions: ['ai.prompt.list', 'ai.prompt.manage'] },
|
{
|
||||||
{ path: '/health/ai-analysis', permissions: ['ai.analysis.list', 'ai.analysis.manage'] },
|
path: "/health/ai-prompts",
|
||||||
{ path: '/health/ai-usage', permissions: ['ai.usage.list'] },
|
permissions: ["ai.prompt.list", "ai.prompt.manage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/ai-analysis",
|
||||||
|
permissions: ["ai.analysis.list", "ai.analysis.manage"],
|
||||||
|
},
|
||||||
|
{ path: "/health/ai-usage", permissions: ["ai.usage.list"] },
|
||||||
|
|
||||||
// ===== 健康管理 — 积分商城 =====
|
// ===== 健康管理 — 积分商城 =====
|
||||||
{ path: '/health/points-rules', permissions: ['health.points.list', 'health.points.manage'] },
|
{
|
||||||
{ path: '/health/points-products', permissions: ['health.points.list', 'health.points.manage'] },
|
path: "/health/points-rules",
|
||||||
{ path: '/health/points-orders', permissions: ['health.points.list', 'health.points.manage'] },
|
permissions: ["health.points.list", "health.points.manage"],
|
||||||
{ path: '/health/offline-events', permissions: ['health.points.list', 'health.points.manage'] },
|
},
|
||||||
|
{
|
||||||
|
path: "/health/points-products",
|
||||||
|
permissions: ["health.points.list", "health.points.manage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/points-orders",
|
||||||
|
permissions: ["health.points.list", "health.points.manage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/offline-events",
|
||||||
|
permissions: ["health.points.list", "health.points.manage"],
|
||||||
|
},
|
||||||
|
|
||||||
// ===== 健康管理 — 内容管理 =====
|
// ===== 健康管理 — 内容管理 =====
|
||||||
{ path: '/health/articles', permissions: ['health.articles.list', 'health.articles.manage'] },
|
{
|
||||||
{ path: '/health/article-categories', permissions: ['health.articles.list', 'health.articles.manage'] },
|
path: "/health/articles",
|
||||||
{ path: '/health/article-tags', permissions: ['health.articles.list', 'health.articles.manage'] },
|
permissions: ["health.articles.list", "health.articles.manage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/article-categories",
|
||||||
|
permissions: ["health.articles.list", "health.articles.manage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/article-tags",
|
||||||
|
permissions: ["health.articles.list", "health.articles.manage"],
|
||||||
|
},
|
||||||
|
|
||||||
// ===== 健康管理 — 其他 =====
|
// ===== 健康管理 — 其他 =====
|
||||||
{ path: '/health/oauth-clients', permissions: ['health.oauth.list', 'health.oauth.manage'] },
|
{
|
||||||
{ path: '/health/statistics', permissions: ['health.health-data.list', 'health.dashboard.manage'] },
|
path: "/health/oauth-clients",
|
||||||
{ path: '/health/medication-records', permissions: ['health.medication-records.manage'] },
|
permissions: ["health.oauth.list", "health.oauth.manage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/statistics",
|
||||||
|
permissions: ["health.health-data.list", "health.dashboard.manage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/medication-records",
|
||||||
|
permissions: ["health.medication-records.manage"],
|
||||||
|
},
|
||||||
|
|
||||||
// ===== 冻结路由 =====
|
// ===== 冻结路由 =====
|
||||||
{ path: '/health/care-plans', permissions: ['health.care-plan.list', 'health.care-plan.manage'], frozen: true },
|
{
|
||||||
{ path: '/health/shifts', permissions: ['health.shifts.list', 'health.shifts.manage'], frozen: true },
|
path: "/health/care-plans",
|
||||||
{ path: '/health/family-proxy', permissions: ['health.family-proxy.list', 'health.family-proxy.manage'], frozen: true },
|
permissions: ["health.care-plan.list", "health.care-plan.manage"],
|
||||||
{ path: '/health/medications', permissions: ['health.medication-records.list', 'health.medication-records.manage'], frozen: true },
|
frozen: true,
|
||||||
{ path: '/health/dialysis', permissions: ['health.dialysis.list', 'health.dialysis.manage'], frozen: true },
|
},
|
||||||
{ path: '/health/schedules', permissions: ['health.appointment.list', 'health.appointment.manage'], frozen: true },
|
{
|
||||||
|
path: "/health/shifts",
|
||||||
|
permissions: ["health.shifts.list", "health.shifts.manage"],
|
||||||
|
frozen: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/family-proxy",
|
||||||
|
permissions: ["health.family-proxy.list", "health.family-proxy.manage"],
|
||||||
|
frozen: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/medications",
|
||||||
|
permissions: [
|
||||||
|
"health.medication-records.list",
|
||||||
|
"health.medication-records.manage",
|
||||||
|
],
|
||||||
|
frozen: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/dialysis",
|
||||||
|
permissions: ["health.dialysis.list", "health.dialysis.manage"],
|
||||||
|
frozen: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/health/schedules",
|
||||||
|
permissions: ["health.appointment.list", "health.appointment.manage"],
|
||||||
|
frozen: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 活跃路由的权限映射 — 自动从配置生成,供 PrivateRoute 使用 */
|
/** 活跃路由的权限映射 — 自动从配置生成,供 PrivateRoute 使用 */
|
||||||
@@ -97,13 +232,15 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = Object.fromEntries(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** 冻结路由路径列表 — 自动从配置生成 */
|
/** 冻结路由路径列表 — 自动从配置生成 */
|
||||||
export const FROZEN_ROUTES: string[] = ENTRIES.filter((e) => e.frozen).map((e) => e.path);
|
export const FROZEN_ROUTES: string[] = ENTRIES.filter((e) => e.frozen).map(
|
||||||
|
(e) => e.path,
|
||||||
|
);
|
||||||
|
|
||||||
/** 开发模式下校验:检查是否有路由路径重复 */
|
/** 开发模式下校验:检查是否有路由路径重复 */
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
const paths = ENTRIES.map((e) => e.path);
|
const paths = ENTRIES.map((e) => e.path);
|
||||||
const dupes = paths.filter((p, i) => paths.indexOf(p) !== i);
|
const dupes = paths.filter((p, i) => paths.indexOf(p) !== i);
|
||||||
if (dupes.length > 0) {
|
if (dupes.length > 0) {
|
||||||
console.error('[routeConfig] 检测到重复路径:', dupes);
|
console.error("[routeConfig] 检测到重复路径:", dupes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ pub struct DashboardStatsResp {
|
|||||||
// 健康数据统计
|
// 健康数据统计
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct LabReportStatisticsResp {
|
pub struct LabReportStatisticsResp {
|
||||||
pub total_reports: i64,
|
pub total_reports: i64,
|
||||||
pub this_month: i64,
|
pub this_month: i64,
|
||||||
@@ -56,7 +56,7 @@ pub struct LabReportStatisticsResp {
|
|||||||
pub reviewed: i64,
|
pub reviewed: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct AppointmentStatisticsResp {
|
pub struct AppointmentStatisticsResp {
|
||||||
pub total_appointments: i64,
|
pub total_appointments: i64,
|
||||||
pub this_month: i64,
|
pub this_month: i64,
|
||||||
@@ -68,7 +68,7 @@ pub struct AppointmentStatisticsResp {
|
|||||||
pub cancel_rate: f64,
|
pub cancel_rate: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct VitalSignsReportRateResp {
|
pub struct VitalSignsReportRateResp {
|
||||||
/// 总患者数
|
/// 总患者数
|
||||||
pub total_patients: i64,
|
pub total_patients: i64,
|
||||||
@@ -131,7 +131,7 @@ pub struct PersonalStatsResp {
|
|||||||
// 通用结构
|
// 通用结构
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct NameValue {
|
pub struct NameValue {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub value: i64,
|
pub value: i64,
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "health.points.list")?;
|
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 = params.page.unwrap_or(1);
|
||||||
let page_size = params.page_size.unwrap_or(20);
|
let page_size = params.page_size.unwrap_or(20);
|
||||||
let result =
|
let result =
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "health.consultation.list")?;
|
require_permission(&ctx, "health.consultation.list")?;
|
||||||
let result = stats_service::get_consultation_statistics(&state, ctx.tenant_id).await?;
|
let result = safe_aggregate(
|
||||||
|
stats_service::get_consultation_statistics(&state, ctx.tenant_id),
|
||||||
|
"咨询统计",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +100,11 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "health.patient.list")?;
|
require_permission(&ctx, "health.patient.list")?;
|
||||||
let result = stats_service::get_lab_report_statistics(&state, ctx.tenant_id).await?;
|
let result = safe_aggregate(
|
||||||
|
stats_service::get_lab_report_statistics(&state, ctx.tenant_id),
|
||||||
|
"化验报告统计",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +117,11 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "health.patient.list")?;
|
require_permission(&ctx, "health.patient.list")?;
|
||||||
let result = stats_service::get_appointment_statistics(&state, ctx.tenant_id).await?;
|
let result = safe_aggregate(
|
||||||
|
stats_service::get_appointment_statistics(&state, ctx.tenant_id),
|
||||||
|
"预约统计",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +134,11 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "health.patient.list")?;
|
require_permission(&ctx, "health.patient.list")?;
|
||||||
let result = stats_service::get_vital_signs_report_rate(&state, ctx.tenant_id).await?;
|
let result = safe_aggregate(
|
||||||
|
stats_service::get_vital_signs_report_rate(&state, ctx.tenant_id),
|
||||||
|
"体征上报率统计",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,8 +151,26 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "health.patient.list")?;
|
require_permission(&ctx, "health.patient.list")?;
|
||||||
let result = stats_service::get_health_data_stats(&state, ctx.tenant_id).await?;
|
let lab_reports = safe_aggregate(
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
stats_service::get_lab_report_statistics(&state, ctx.tenant_id),
|
||||||
|
"化验报告统计",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let appointments = safe_aggregate(
|
||||||
|
stats_service::get_appointment_statistics(&state, ctx.tenant_id),
|
||||||
|
"预约统计",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let vital_signs_report_rate = safe_aggregate(
|
||||||
|
stats_service::get_vital_signs_report_rate(&state, ctx.tenant_id),
|
||||||
|
"体征上报率统计",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(ApiResponse::ok(HealthDataStatsResp {
|
||||||
|
lab_reports,
|
||||||
|
appointments,
|
||||||
|
vital_signs_report_rate,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user