Initial commit: HealthCarePregnant project documentation and platform
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.git
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs', 'vite.config.ts'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.DS_Store
|
||||
.vite
|
||||
@@ -0,0 +1,12 @@
|
||||
# PCM 医护/运营端静态镜像:Vite 构建 → nginx 托管
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,71 @@
|
||||
# PCM 医护/运营端工作台 (admin-web · T-8.3 / T-8.4)
|
||||
|
||||
孕产个案管理平台 **医护端 + 运营/管理端**(个案管理师 / 医生 / 运营 / 管理员)。React Web,
|
||||
信息密度高的专业后台,对接 `backend` 的 caseflow / analysis / ai / redflag / careplan / knowledge / audit 接口。
|
||||
按登录角色(RBAC)展示对应功能。
|
||||
|
||||
UI 依据 `../../3-ui-style-PCM.md` §8:复用品牌主色与字体,但采用中性专业风(不套用孕妇端暖萌风)。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vite + React 18 + TypeScript
|
||||
- react-router-dom 路由
|
||||
- 原生 CSS + Design Tokens(专业主题,与孕妇端共享品牌色)
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5174 ,/api 代理到后端 (默认 http://localhost:3000)
|
||||
```
|
||||
|
||||
先启动后端:在 `../backend` 执行 `npm run start`。
|
||||
|
||||
## 脚本
|
||||
|
||||
- `npm run dev` / `npm run build` / `npm run lint` / `npm run preview`
|
||||
|
||||
## 功能(T-8.3)
|
||||
|
||||
| 模块 | 说明 | 映射 |
|
||||
|------|------|------|
|
||||
| 登录 | 医护(个案管理师/医生)登录注册;非医护角色被拒绝进入 | REQ-11、T-2.1 |
|
||||
| 工作列表 | 在管孕妇列表,按风险排序、搜索、风险筛选 | REQ-2、REQ-3.2 |
|
||||
| 个案工作台·档案 | 孕妇档案摘要(孕周/预产期/BMI/风险因素/初始与当前风险) | REQ-2 |
|
||||
| 个案工作台·流程 | 状态机阶段进度,合法流转推进,指派管理师,流转记录 | REQ-6.1/6.2 |
|
||||
| 个案工作台·预警 | 预警列表,分级 + 可解释 + 规则溯源 | REQ-3.3、NFR-3 |
|
||||
| 个案工作台·观测 | 观测记录表(指标/数值/孕周/质控状态) | REQ-1 |
|
||||
| 个案工作台·AI 建议 | 基于风险与未处理预警生成处置建议 + 依据;**高/中风险须人工确认方可采纳,不自动执行** | REQ-10.2/10.3/10.4 |
|
||||
| 个案工作台·照护计划 | 制定(目标/干预/随访频率)与查看 | REQ-6.3 |
|
||||
| 个案工作台·红旗检查 | 提交症状/体征快照做红旗急症检查,命中即通知并升级个案 | REQ-5 |
|
||||
|
||||
## 运营 / 管理端功能(T-8.4)
|
||||
|
||||
| 模块 | 角色 | 说明 | 映射 |
|
||||
|------|------|------|------|
|
||||
| 知识库管理 | 运营/管理/医护 | 录入知识条目(分类/标题/内容/关键词/来源/权威级别)、检索与分类过滤、问答测试(验证溯源与不超纲) | REQ-7.1、knowledge:write |
|
||||
| 审计日志 | 管理员 | 关键操作留痕查询,按操作者/动作过滤 | NFR-1/9、audit:read |
|
||||
|
||||
知识库是孕妇端 RAG 问答的**内容来源**:运营录入条目后,孕妇端/工作台问答即可检索并附溯源作答;
|
||||
无依据时明确告知不超纲。「问答测试」面板让运营在录入后即时验证效果。
|
||||
|
||||
## 人工兜底(REQ-10.3)
|
||||
|
||||
AI 决策建议面板对高/中风险个案显式标注「需人工确认」,建议默认**不执行**;
|
||||
医护点击「确认采纳」后才记录人工确认(操作者 + 时间),确保系统不自动执行临床决策。
|
||||
|
||||
## 角色门控
|
||||
|
||||
UI 按 `backend` RBAC 矩阵门控操作:
|
||||
- 流转推进 / 照护计划:个案管理师、医生
|
||||
- 指派给我:个案管理师
|
||||
- 红旗检查:个案管理师
|
||||
|
||||
后端为最终授权边界;UI 门控用于减少误操作。
|
||||
|
||||
## 对接的接口
|
||||
|
||||
`POST /auth/login|register`、`GET /patients`、`/patients/:id`、`/patients/:id/observations`、
|
||||
`/patients/:id/alerts`、`POST/GET /patients/:id/case`、`POST /cases/:id/advance|assign`、
|
||||
`POST/GET /cases/:id/care-plans`、`GET /ai/patients/:id/recommendation`、
|
||||
`POST /patients/:id/redflag-check`、`POST/GET /knowledge`、`GET /knowledge/ask`、`GET /audit`。
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PCM 个案工作台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
# 医护/运营端 nginx:SPA 客户端路由回退 + /api 反向代理到后端
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+3570
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "pcm-admin-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "PCM 孕产个案管理平台 · 医护端 PC 工作台(个案管理师/医生)。",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --max-warnings 0",
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.456.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.12",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './auth/AuthContext';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { Layout } from './components/Layout';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { WorklistPage } from './pages/WorklistPage';
|
||||
import { CaseWorkbenchPage } from './pages/CaseWorkbenchPage';
|
||||
import { KnowledgePage } from './pages/KnowledgePage';
|
||||
import { AuditPage } from './pages/AuditPage';
|
||||
import { can, type Action } from './lib/rbac';
|
||||
|
||||
function Gate({ children }: { children: JSX.Element }): JSX.Element {
|
||||
const { user, ready } = useAuth();
|
||||
if (!ready) {
|
||||
return <div style={{ padding: 40 }}>加载中…</div>;
|
||||
}
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
function RoleRoute({ action, children }: { action: Action; children: JSX.Element }): JSX.Element {
|
||||
const { user } = useAuth();
|
||||
if (!can(user?.role, action)) return <Navigate to="/worklist" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
function PublicOnly({ children }: { children: JSX.Element }): JSX.Element {
|
||||
const { user, ready } = useAuth();
|
||||
if (!ready) {
|
||||
return <div style={{ padding: 40 }}>加载中…</div>;
|
||||
}
|
||||
if (user) return <Navigate to="/worklist" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
export function App(): JSX.Element {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicOnly>
|
||||
<LoginPage />
|
||||
</PublicOnly>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Gate>
|
||||
<Layout />
|
||||
</Gate>
|
||||
}
|
||||
>
|
||||
<Route path="/worklist" element={<WorklistPage />} />
|
||||
<Route path="/patients/:patientId" element={<CaseWorkbenchPage />} />
|
||||
<Route
|
||||
path="/knowledge"
|
||||
element={
|
||||
<RoleRoute action="knowledge:write">
|
||||
<KnowledgePage />
|
||||
</RoleRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audit"
|
||||
element={
|
||||
<RoleRoute action="audit:read">
|
||||
<AuditPage />
|
||||
</RoleRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/worklist" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// 轻量 fetch 客户端:统一前缀、令牌注入、错误处理。
|
||||
import type {
|
||||
Alert,
|
||||
AuthResult,
|
||||
CarePlan,
|
||||
CreateCarePlanInput,
|
||||
Observation,
|
||||
PatientSummary,
|
||||
PregnancyCase,
|
||||
Recommendation,
|
||||
RedFlagResult,
|
||||
RedFlagSnapshot,
|
||||
Reminder,
|
||||
RiskLevel,
|
||||
CaseStage,
|
||||
AuditEntry,
|
||||
CreateKnowledgeInput,
|
||||
KnowledgeCategory,
|
||||
KnowledgeItem,
|
||||
QaAnswer,
|
||||
Disposition,
|
||||
Referral,
|
||||
ReferralType,
|
||||
ReferralUrgency,
|
||||
EmotionCheckin,
|
||||
WorklistItem,
|
||||
IndicatorTrendResult,
|
||||
} from './types';
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api';
|
||||
|
||||
let authToken: string | null = null;
|
||||
|
||||
export function setAuthToken(token: string | null): void {
|
||||
authToken = token;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set('Content-Type', 'application/json');
|
||||
if (authToken) {
|
||||
headers.set('Authorization', `Bearer ${authToken}`);
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||
} catch {
|
||||
throw new ApiError(0, '无法连接服务器,请稍后再试');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let message = `请求失败 (${res.status})`;
|
||||
try {
|
||||
const body = (await res.json()) as { message?: string | string[] };
|
||||
if (body?.message) {
|
||||
message = Array.isArray(body.message) ? body.message.join(';') : body.message;
|
||||
}
|
||||
} catch {
|
||||
// 忽略非 JSON 响应体
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (username: string, password: string): Promise<AuthResult> =>
|
||||
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||
|
||||
register: (body: {
|
||||
username: string;
|
||||
password: string;
|
||||
role: 'case_manager' | 'physician' | 'operator' | 'admin';
|
||||
}): Promise<AuthResult> =>
|
||||
request('/auth/register', { method: 'POST', body: JSON.stringify(body) }),
|
||||
|
||||
// 孕妇 / 档案
|
||||
listPatients: (): Promise<PatientSummary[]> => request('/patients'),
|
||||
getPatient: (id: string): Promise<PatientSummary> => request(`/patients/${id}`),
|
||||
listObservations: (patientId: string): Promise<Observation[]> =>
|
||||
request(`/patients/${patientId}/observations`),
|
||||
listAlerts: (patientId: string): Promise<Alert[]> => request(`/patients/${patientId}/alerts`),
|
||||
listReminders: (patientId: string): Promise<Reminder[]> =>
|
||||
request(`/patients/${patientId}/reminders`),
|
||||
|
||||
// 个案
|
||||
openCase: (patientId: string, riskLevel?: RiskLevel): Promise<PregnancyCase> =>
|
||||
request(`/patients/${patientId}/case`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ riskLevel }),
|
||||
}),
|
||||
getCase: (patientId: string): Promise<PregnancyCase> => request(`/patients/${patientId}/case`),
|
||||
advanceCase: (caseId: string, to: CaseStage, reason?: string): Promise<PregnancyCase> =>
|
||||
request(`/cases/${caseId}/advance`, { method: 'POST', body: JSON.stringify({ to, reason }) }),
|
||||
assignManager: (caseId: string, caseManagerId: string): Promise<PregnancyCase> =>
|
||||
request(`/cases/${caseId}/assign`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ caseManagerId }),
|
||||
}),
|
||||
|
||||
// 照护计划
|
||||
createCarePlan: (caseId: string, input: CreateCarePlanInput): Promise<CarePlan> =>
|
||||
request(`/cases/${caseId}/care-plans`, { method: 'POST', body: JSON.stringify(input) }),
|
||||
listCarePlans: (caseId: string): Promise<CarePlan[]> => request(`/cases/${caseId}/care-plans`),
|
||||
|
||||
// AI 决策建议
|
||||
getRecommendation: (patientId: string): Promise<Recommendation> =>
|
||||
request(`/ai/patients/${patientId}/recommendation`),
|
||||
|
||||
// 红旗急症检查
|
||||
redflagCheck: (patientId: string, snapshot: RedFlagSnapshot): Promise<RedFlagResult> =>
|
||||
request(`/patients/${patientId}/redflag-check`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(snapshot),
|
||||
}),
|
||||
|
||||
// 知识库(运营端)
|
||||
listKnowledge: (params?: { q?: string; category?: KnowledgeCategory }): Promise<KnowledgeItem[]> => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.q) qs.set('q', params.q);
|
||||
if (params?.category) qs.set('category', params.category);
|
||||
const suffix = qs.toString();
|
||||
return request(`/knowledge${suffix ? `?${suffix}` : ''}`);
|
||||
},
|
||||
createKnowledge: (input: CreateKnowledgeInput): Promise<KnowledgeItem> =>
|
||||
request('/knowledge', { method: 'POST', body: JSON.stringify(input) }),
|
||||
askKnowledge: (q: string): Promise<QaAnswer> =>
|
||||
request(`/knowledge/ask?${new URLSearchParams({ q }).toString()}`),
|
||||
|
||||
// 审计(管理端)
|
||||
listAudit: (params?: { actorId?: string; action?: string }): Promise<AuditEntry[]> => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.actorId) qs.set('actorId', params.actorId);
|
||||
if (params?.action) qs.set('action', params.action);
|
||||
const suffix = qs.toString();
|
||||
return request(`/audit${suffix ? `?${suffix}` : ''}`);
|
||||
},
|
||||
|
||||
// 待处置队列 & 趋势 (T-D.5)
|
||||
getWorklist: (): Promise<WorklistItem[]> => request('/worklist'),
|
||||
getTrends: (patientId: string, indicator: string, limit = 10): Promise<IndicatorTrendResult> =>
|
||||
request(`/trends?${new URLSearchParams({ patientId, indicator, limit: String(limit) }).toString()}`),
|
||||
|
||||
// 处置单 (T-D.1 / T-D.2)
|
||||
listDispositions: (patientId: string): Promise<Disposition[]> =>
|
||||
request(`/patients/${patientId}/dispositions`),
|
||||
createDisposition: (
|
||||
patientId: string,
|
||||
body: {
|
||||
caseId: string;
|
||||
sourceType: string;
|
||||
sourceId?: string;
|
||||
title: string;
|
||||
riskLevelAtCreation: RiskLevel;
|
||||
actions: { kind: string; description: string }[];
|
||||
supersedesId?: string;
|
||||
},
|
||||
): Promise<Disposition> =>
|
||||
request(`/patients/${patientId}/dispositions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
confirmDisposition: (id: string): Promise<Disposition> =>
|
||||
request(`/dispositions/${id}/confirm`, { method: 'POST' }),
|
||||
executeDispositionAction: (
|
||||
dispositionId: string,
|
||||
actionId: string,
|
||||
body?: { linkedEntityId?: string; resultNote?: string },
|
||||
): Promise<Disposition> =>
|
||||
request(`/dispositions/${dispositionId}/actions/${actionId}/execute`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body ?? {}),
|
||||
}),
|
||||
closeDisposition: (id: string, outcome: string): Promise<Disposition> =>
|
||||
request(`/dispositions/${id}/close`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ outcome }),
|
||||
}),
|
||||
|
||||
// 转诊与会诊 (T-D.3)
|
||||
listReferrals: (patientId: string): Promise<Referral[]> =>
|
||||
request(`/referrals/patient/${patientId}`),
|
||||
listDoctorReferrals: (): Promise<Referral[]> => request('/referrals/doctor'),
|
||||
createReferral: (body: {
|
||||
patientId: string;
|
||||
dispositionId?: string | null;
|
||||
type: ReferralType;
|
||||
urgency: ReferralUrgency;
|
||||
toDoctorId: string;
|
||||
clinicalSummary: string;
|
||||
reason?: string;
|
||||
}): Promise<Referral> =>
|
||||
request('/referrals', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
acceptReferral: (id: string): Promise<Referral> =>
|
||||
request(`/referrals/${id}/accept`, { method: 'POST' }),
|
||||
respondReferral: (id: string, reply: string, status?: string): Promise<Referral> =>
|
||||
request(`/referrals/${id}/respond`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reply, status }),
|
||||
}),
|
||||
|
||||
// 情绪打卡 (T-D.4)
|
||||
listEmotions: (patientId: string): Promise<EmotionCheckin[]> =>
|
||||
request(`/emotions/patient/${patientId}`),
|
||||
};
|
||||
@@ -0,0 +1,355 @@
|
||||
// 与后端契约一致的类型(镜像 backend/src/modules/*)。医护端使用部分。
|
||||
|
||||
export type Role = 'patient' | 'family' | 'case_manager' | 'physician' | 'operator' | 'admin';
|
||||
export type RiskLevel = 'low' | 'medium' | 'high';
|
||||
export type Trimester = 'first' | 'second' | 'third';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: Role;
|
||||
consentSigned: boolean;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
token: string;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
export interface PatientSummary {
|
||||
id: string;
|
||||
patientNo: string;
|
||||
name: string;
|
||||
age: number;
|
||||
heightCm?: number;
|
||||
prePregnancyWeightKg?: number;
|
||||
prePregnancyBmi?: number;
|
||||
lmp: string;
|
||||
edd: string;
|
||||
multipleGestation: boolean;
|
||||
historyGdm: boolean;
|
||||
historyPih: boolean;
|
||||
adversePregnancyHistory: boolean;
|
||||
chronicConditions: boolean;
|
||||
initialRiskLevel: RiskLevel;
|
||||
initialRiskFactors: string[];
|
||||
createdAt: string;
|
||||
gestationalWeeks: number;
|
||||
gestationalDays: number;
|
||||
trimester: Trimester;
|
||||
}
|
||||
|
||||
export type QcStatus = 'accepted' | 'rejected';
|
||||
|
||||
export interface Observation {
|
||||
id: string;
|
||||
patientId: string;
|
||||
indicator: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
measuredAt: string;
|
||||
source: 'manual' | 'device';
|
||||
qcStatus: QcStatus;
|
||||
qcFlags: string[];
|
||||
gestationalWeeks: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type AlertStatus = 'open' | 'acknowledged' | 'resolved';
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
patientId: string;
|
||||
observationId: string;
|
||||
indicator: string;
|
||||
value: number;
|
||||
level: RiskLevel;
|
||||
ruleIds: string[];
|
||||
messages: string[];
|
||||
status: AlertStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 个案
|
||||
export type CaseStage =
|
||||
| 'screening'
|
||||
| 'assessment'
|
||||
| 'risk_stratification'
|
||||
| 'planning'
|
||||
| 'implementation'
|
||||
| 'monitoring'
|
||||
| 'evaluation'
|
||||
| 'transition';
|
||||
|
||||
export type CaseStatus = 'open' | 'closed';
|
||||
|
||||
export interface CaseEvent {
|
||||
at: string;
|
||||
from: CaseStage;
|
||||
to: CaseStage;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PregnancyCase {
|
||||
id: string;
|
||||
patientId: string;
|
||||
caseManagerId: string | null;
|
||||
stage: CaseStage;
|
||||
status: CaseStatus;
|
||||
riskLevel: RiskLevel;
|
||||
history: CaseEvent[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 照护计划
|
||||
export type InterventionKind = 'clinical' | 'lifestyle' | 'habit';
|
||||
|
||||
export interface Intervention {
|
||||
kind: InterventionKind;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type CarePlanStatus = 'active' | 'archived';
|
||||
|
||||
export interface CarePlan {
|
||||
id: string;
|
||||
caseId: string;
|
||||
patientId: string;
|
||||
goals: string[];
|
||||
interventions: Intervention[];
|
||||
followUpFrequency: string;
|
||||
status: CarePlanStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateCarePlanInput {
|
||||
goals: string[];
|
||||
interventions: Intervention[];
|
||||
followUpFrequency: string;
|
||||
}
|
||||
|
||||
// AI 决策建议
|
||||
export interface Recommendation {
|
||||
actions: string[];
|
||||
requiresHumanConfirmation: boolean;
|
||||
rationale: string[];
|
||||
}
|
||||
|
||||
// 红旗急症
|
||||
export type Symptom =
|
||||
| 'severe_headache'
|
||||
| 'visual_disturbance'
|
||||
| 'epigastric_pain'
|
||||
| 'reduced_fetal_movement'
|
||||
| 'vaginal_bleeding'
|
||||
| 'severe_edema';
|
||||
|
||||
export interface RedFlagSnapshot {
|
||||
systolicBp?: number;
|
||||
diastolicBp?: number;
|
||||
fastingGlucose?: number;
|
||||
symptoms?: Symptom[];
|
||||
}
|
||||
|
||||
export interface RedFlagHit {
|
||||
ruleId: string;
|
||||
message: string;
|
||||
advice: string;
|
||||
}
|
||||
|
||||
export interface RedFlagResult {
|
||||
triggered: boolean;
|
||||
hits: RedFlagHit[];
|
||||
patientAdvice: string | null;
|
||||
}
|
||||
|
||||
// 知识库
|
||||
export type KnowledgeCategory = 'guideline' | 'indicator_reference' | 'intervention' | 'tcm';
|
||||
export type AuthorityLevel = 'authoritative' | 'reference' | 'self';
|
||||
|
||||
export interface KnowledgeItem {
|
||||
id: string;
|
||||
category: KnowledgeCategory;
|
||||
title: string;
|
||||
content: string;
|
||||
keywords: string[];
|
||||
source: string;
|
||||
authority: AuthorityLevel;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateKnowledgeInput {
|
||||
category: KnowledgeCategory;
|
||||
title: string;
|
||||
content: string;
|
||||
keywords?: string[];
|
||||
source: string;
|
||||
authority?: AuthorityLevel;
|
||||
}
|
||||
|
||||
export interface KnowledgeCitation {
|
||||
id: string;
|
||||
title: string;
|
||||
source: string;
|
||||
authority: AuthorityLevel;
|
||||
}
|
||||
|
||||
export interface QaAnswer {
|
||||
grounded: boolean;
|
||||
answer: string;
|
||||
citations: KnowledgeCitation[];
|
||||
}
|
||||
|
||||
// 提醒 / 随访
|
||||
export type ReminderType =
|
||||
| 'exercise'
|
||||
| 'rest'
|
||||
| 'water'
|
||||
| 'medication'
|
||||
| 'checkup'
|
||||
| 'measurement';
|
||||
|
||||
export interface Reminder {
|
||||
id: string;
|
||||
patientId: string;
|
||||
type: ReminderType;
|
||||
/** 实际下发的类型(可能因风险调整,如 exercise→rest) */
|
||||
effectiveType: ReminderType;
|
||||
message: string;
|
||||
/** 是否因风险被调整 */
|
||||
adjustedForRisk: boolean;
|
||||
scheduledAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 审计
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
actorId: string;
|
||||
action: string;
|
||||
target?: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
// 处置单 (Disposition) 与跟进 (FollowUp)
|
||||
export type DispositionStatus = 'draft' | 'pending_confirmation' | 'executing' | 'following_up' | 'closed';
|
||||
|
||||
export interface DispositionAction {
|
||||
kind: string; // 'recheck' | 'referral' | 'medication' | 'lifestyle'
|
||||
description: string;
|
||||
linkedEntityId?: string | null;
|
||||
}
|
||||
|
||||
export interface Disposition {
|
||||
id: string;
|
||||
caseId: string;
|
||||
patientId: string;
|
||||
sourceType: 'alert' | 'redflag' | 'emotion' | 'routine';
|
||||
status: DispositionStatus;
|
||||
riskLevel: RiskLevel;
|
||||
requiresConfirmation: boolean;
|
||||
closureOutcome?: string | null;
|
||||
supersedesId?: string | null;
|
||||
createdBy: string;
|
||||
confirmedBy?: string | null;
|
||||
title: string;
|
||||
actions: DispositionAction[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
closedAt?: string | null;
|
||||
}
|
||||
|
||||
export type FollowUpStatus = 'pending' | 'due' | 'evaluated';
|
||||
export type FollowUpOutcome = 'met' | 'not_met';
|
||||
|
||||
export interface FollowUp {
|
||||
id: string;
|
||||
dispositionId: string;
|
||||
patientId: string;
|
||||
indicator: string;
|
||||
targetOperator: string;
|
||||
targetValue: number;
|
||||
windowDays: number;
|
||||
dueAt: string;
|
||||
status: FollowUpStatus;
|
||||
outcome?: FollowUpOutcome | null;
|
||||
evaluatedObservationId?: string | null;
|
||||
evaluatedAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 转诊与会诊 (Referral)
|
||||
export type ReferralType = 'referral' | 'consult';
|
||||
export type ReferralStatus = 'pending' | 'accepted' | 'responded' | 'completed' | 'declined';
|
||||
export type ReferralUrgency = 'routine' | 'urgent' | 'emergency';
|
||||
|
||||
export interface Referral {
|
||||
id: string;
|
||||
patientId: string;
|
||||
dispositionId: string | null;
|
||||
type: ReferralType;
|
||||
status: ReferralStatus;
|
||||
urgency: ReferralUrgency;
|
||||
fromManagerId: string;
|
||||
toDoctorId: string;
|
||||
clinicalSummary: string;
|
||||
doctorReply?: string | null;
|
||||
reason?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateReferralInput {
|
||||
patientId: string;
|
||||
dispositionId?: string | null;
|
||||
type: ReferralType;
|
||||
urgency: ReferralUrgency;
|
||||
toDoctorId: string;
|
||||
clinicalSummary: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// 情绪打卡 (Emotion)
|
||||
export type EmotionStatus = 'normal' | 'concerning' | 'crisis';
|
||||
|
||||
export interface EmotionCheckin {
|
||||
id: string;
|
||||
patientId: string;
|
||||
score: number;
|
||||
status: EmotionStatus;
|
||||
note: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 待处置队列项 (Worklist)
|
||||
export type WorklistItemType = 'alert' | 'followup' | 'emotion' | 'referral';
|
||||
export type PriorityLevel = 'high' | 'medium' | 'low';
|
||||
|
||||
export interface WorklistItem {
|
||||
id: string;
|
||||
patientId: string;
|
||||
patientName: string;
|
||||
type: WorklistItemType;
|
||||
title: string;
|
||||
priority: PriorityLevel;
|
||||
status: string;
|
||||
sourceId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
observationId: string;
|
||||
value: number;
|
||||
measuredAt: string;
|
||||
}
|
||||
|
||||
export interface IndicatorTrendResult {
|
||||
patientId: string;
|
||||
indicator: string;
|
||||
points: TrendPoint[];
|
||||
direction: 'up' | 'down' | 'stable';
|
||||
latestValue: number | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { api, setAuthToken } from '../api/client';
|
||||
import type { AuthUser } from '../api/types';
|
||||
import { clearSession, loadSession, saveSession, type Session } from './session';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
ready: boolean;
|
||||
login: (username: string, password: string) => Promise<AuthUser>;
|
||||
register: (input: {
|
||||
username: string;
|
||||
password: string;
|
||||
role: StaffRole;
|
||||
}) => Promise<AuthUser>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export type StaffRole = 'case_manager' | 'physician' | 'operator' | 'admin';
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
const STAFF_ROLES = ['case_manager', 'physician', 'operator', 'admin'];
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }): JSX.Element {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const existing = loadSession();
|
||||
if (existing) {
|
||||
setAuthToken(existing.token);
|
||||
setSession(existing);
|
||||
}
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
const persist = useCallback((next: Session) => {
|
||||
setAuthToken(next.token);
|
||||
saveSession(next);
|
||||
setSession(next);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
const result = await api.login(username, password);
|
||||
if (!STAFF_ROLES.includes(result.user.role)) {
|
||||
throw new Error('该账号无医护端访问权限');
|
||||
}
|
||||
persist({ token: result.token, user: result.user });
|
||||
return result.user;
|
||||
},
|
||||
[persist],
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
async (input: { username: string; password: string; role: StaffRole }) => {
|
||||
const result = await api.register(input);
|
||||
persist({ token: result.token, user: result.user });
|
||||
return result.user;
|
||||
},
|
||||
[persist],
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setAuthToken(null);
|
||||
clearSession();
|
||||
setSession(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({ user: session?.user ?? null, ready, login, register, logout }),
|
||||
[session, ready, login, register, logout],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AuthUser } from '../api/types';
|
||||
|
||||
const KEY = 'pcm.admin.session';
|
||||
|
||||
export interface Session {
|
||||
token: string;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
export function loadSession(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
return raw ? (JSON.parse(raw) as Session) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSession(session: Session): void {
|
||||
localStorage.setItem(KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
localStorage.removeItem(KEY);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 顶部导航栏(替代侧栏;2–3 个分区用 Tab 更轻) */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
height: var(--topbar-height);
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-card);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--space-5);
|
||||
padding: 0 var(--space-5);
|
||||
}
|
||||
|
||||
.topbar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar__logo {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.topbar__title {
|
||||
font-weight: 700;
|
||||
font-size: var(--font-lg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar__tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
}
|
||||
.topbar__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--font-md);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-soft);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.topbar__tab:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
.topbar__tab.is-active {
|
||||
color: var(--color-primary-strong);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
.topbar__tab svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar__user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.topbar__user-name {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.topbar__user-role {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-soft);
|
||||
}
|
||||
.topbar__logout {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
color: var(--color-text-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.topbar__logout:hover {
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbar {
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
.topbar__title {
|
||||
display: none;
|
||||
}
|
||||
.topbar__user-info {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { BookOpen, ClipboardList, FileSearch, LogOut, Stethoscope } from 'lucide-react';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { roleLabel } from '../lib/format';
|
||||
import { can } from '../lib/rbac';
|
||||
import './Layout.css';
|
||||
|
||||
export function Layout(): JSX.Element {
|
||||
const { user, logout } = useAuth();
|
||||
const role = user?.role;
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<header className="topbar">
|
||||
<div className="topbar__brand">
|
||||
<span className="topbar__logo">
|
||||
<Stethoscope size={22} strokeWidth={1.75} />
|
||||
</span>
|
||||
<span className="topbar__title">PCM 工作台</span>
|
||||
</div>
|
||||
|
||||
<nav className="topbar__tabs">
|
||||
{can(role, 'patient:read') && (
|
||||
<NavLink to="/worklist" className={tabClass}>
|
||||
<ClipboardList size={17} strokeWidth={1.75} /> 工作列表
|
||||
</NavLink>
|
||||
)}
|
||||
{can(role, 'knowledge:write') && (
|
||||
<NavLink to="/knowledge" className={tabClass}>
|
||||
<BookOpen size={17} strokeWidth={1.75} /> 知识库
|
||||
</NavLink>
|
||||
)}
|
||||
{can(role, 'audit:read') && (
|
||||
<NavLink to="/audit" className={tabClass}>
|
||||
<FileSearch size={17} strokeWidth={1.75} /> 审计日志
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="topbar__user">
|
||||
<div className="topbar__user-info">
|
||||
<span className="topbar__user-name">{user?.username}</span>
|
||||
<span className="topbar__user-role">{user ? roleLabel(user.role) : ''}</span>
|
||||
</div>
|
||||
<button className="topbar__logout" onClick={logout} type="button" aria-label="退出登录">
|
||||
<LogOut size={18} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function tabClass({ isActive }: { isActive: boolean }): string {
|
||||
return `topbar__tab${isActive ? ' is-active' : ''}`;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react';
|
||||
|
||||
interface ToastContextValue {
|
||||
show: (message: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }): JSX.Element {
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const show = useCallback((msg: string) => {
|
||||
setMessage(msg);
|
||||
window.setTimeout(() => setMessage(null), 3000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ show }}>
|
||||
{children}
|
||||
{message && <div className="toast">{message}</div>}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used within ToastProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Alert } from '../../api/types';
|
||||
import { formatTime, indicatorLabel, riskBadgeClass, riskLabel } from '../../lib/format';
|
||||
import './workbench.css';
|
||||
|
||||
export function AlertsPanel({ alerts }: { alerts: Alert[] }): JSX.Element {
|
||||
const open = alerts.filter((a) => a.status === 'open');
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span>预警</span>
|
||||
{open.length > 0 && <span className="badge badge-danger">{open.length} 条待处理</span>}
|
||||
</div>
|
||||
{alerts.length === 0 ? (
|
||||
<p className="empty">暂无预警。</p>
|
||||
) : (
|
||||
<div className="alert-list">
|
||||
{alerts.map((a) => (
|
||||
<div key={a.id} className={`alert-item alert-item--${a.level}`}>
|
||||
<div className="spread">
|
||||
<span className={`badge ${riskBadgeClass(a.level)}`}>{riskLabel(a.level)}</span>
|
||||
<span className="muted alert-item__time">{formatTime(a.createdAt)}</span>
|
||||
</div>
|
||||
<p className="alert-item__indicator">
|
||||
{indicatorLabel(a.indicator)}:<strong>{a.value}</strong>
|
||||
</p>
|
||||
{a.messages.map((m, i) => (
|
||||
<p key={i} className="alert-item__msg">
|
||||
{m}
|
||||
</p>
|
||||
))}
|
||||
<p className="alert-item__trace muted">命中规则:{a.ruleIds.join('、') || '—'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { api, ApiError } from '../../api/client';
|
||||
import { useToast } from '../Toast';
|
||||
import type { CarePlan, Intervention, InterventionKind, Role } from '../../api/types';
|
||||
import { formatTime, interventionLabel } from '../../lib/format';
|
||||
import './workbench.css';
|
||||
|
||||
interface Props {
|
||||
caseId: string;
|
||||
plans: CarePlan[];
|
||||
role?: Role;
|
||||
onCreated: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
const KINDS: InterventionKind[] = ['clinical', 'lifestyle', 'habit'];
|
||||
const FREQ_OPTIONS = [
|
||||
{ value: 'weekly', label: '每周' },
|
||||
{ value: 'biweekly', label: '每两周' },
|
||||
{ value: 'monthly', label: '每月' },
|
||||
];
|
||||
|
||||
export function CarePlanPanel({ caseId, plans, role, onCreated }: Props): JSX.Element {
|
||||
const { show } = useToast();
|
||||
const canWrite = role === 'case_manager' || role === 'physician';
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [goalsText, setGoalsText] = useState('');
|
||||
const [frequency, setFrequency] = useState('weekly');
|
||||
const [interventions, setInterventions] = useState<Intervention[]>([
|
||||
{ kind: 'lifestyle', description: '' },
|
||||
]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
function updateIntervention(idx: number, patch: Partial<Intervention>): void {
|
||||
setInterventions((prev) => prev.map((it, i) => (i === idx ? { ...it, ...patch } : it)));
|
||||
}
|
||||
function addIntervention(): void {
|
||||
setInterventions((prev) => [...prev, { kind: 'lifestyle', description: '' }]);
|
||||
}
|
||||
function removeIntervention(idx: number): void {
|
||||
setInterventions((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
const goals = goalsText
|
||||
.split('\n')
|
||||
.map((g) => g.trim())
|
||||
.filter(Boolean);
|
||||
if (goals.length === 0) {
|
||||
show('请至少填写一个目标');
|
||||
return;
|
||||
}
|
||||
const cleanInterventions = interventions.filter((it) => it.description.trim());
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.createCarePlan(caseId, {
|
||||
goals,
|
||||
interventions: cleanInterventions,
|
||||
followUpFrequency: frequency,
|
||||
});
|
||||
show('照护计划已创建');
|
||||
setGoalsText('');
|
||||
setInterventions([{ kind: 'lifestyle', description: '' }]);
|
||||
setCreating(false);
|
||||
await onCreated();
|
||||
} catch (err) {
|
||||
show(err instanceof ApiError ? err.message : '创建失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span>照护计划</span>
|
||||
{canWrite && !creating && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setCreating(true)} type="button">
|
||||
+ 新建
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<form className="careplan-form" onSubmit={submit}>
|
||||
<div className="field">
|
||||
<label>目标(每行一个)</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={goalsText}
|
||||
onChange={(e) => setGoalsText(e.target.value)}
|
||||
placeholder={'如:空腹血糖控制在 5.1 mmol/L 以下\n规律产检'}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>干预措施</label>
|
||||
{interventions.map((it, i) => (
|
||||
<div key={i} className="careplan-intervention">
|
||||
<select
|
||||
value={it.kind}
|
||||
onChange={(e) => updateIntervention(i, { kind: e.target.value as InterventionKind })}
|
||||
>
|
||||
{KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{interventionLabel(k)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={it.description}
|
||||
onChange={(e) => updateIntervention(i, { description: e.target.value })}
|
||||
placeholder="干预描述,如:饮食控制 + 餐后散步"
|
||||
/>
|
||||
{interventions.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="careplan-remove"
|
||||
onClick={() => removeIntervention(i)}
|
||||
aria-label="移除"
|
||||
>
|
||||
<X size={16} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={addIntervention}>
|
||||
+ 添加干预
|
||||
</button>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>随访频率</label>
|
||||
<select value={frequency} onChange={(e) => setFrequency(e.target.value)}>
|
||||
{FREQ_OPTIONS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" type="submit" disabled={busy}>
|
||||
{busy ? '保存中…' : '保存计划'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
onClick={() => setCreating(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{plans.length === 0 && !creating ? (
|
||||
<p className="empty">尚无照护计划。</p>
|
||||
) : (
|
||||
<div className="careplan-list">
|
||||
{plans.map((p) => (
|
||||
<div key={p.id} className="careplan-item">
|
||||
<div className="spread">
|
||||
<span className="badge badge-ok">{freqLabel(p.followUpFrequency)}</span>
|
||||
<span className="muted careplan-item__time">{formatTime(p.createdAt)}</span>
|
||||
</div>
|
||||
<p className="careplan-item__label">目标</p>
|
||||
<ul>
|
||||
{p.goals.map((g, i) => (
|
||||
<li key={i}>{g}</li>
|
||||
))}
|
||||
</ul>
|
||||
{p.interventions.length > 0 && (
|
||||
<>
|
||||
<p className="careplan-item__label">干预</p>
|
||||
<ul>
|
||||
{p.interventions.map((it, i) => (
|
||||
<li key={i}>
|
||||
<span className="badge badge-neutral">{interventionLabel(it.kind)}</span>{' '}
|
||||
{it.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function freqLabel(value: string): string {
|
||||
return FREQ_OPTIONS.find((f) => f.value === value)?.label ?? value;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState } from 'react';
|
||||
import { ArrowRight, Check } from 'lucide-react';
|
||||
import { api, ApiError } from '../../api/client';
|
||||
import { useToast } from '../Toast';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
import type { PregnancyCase, Role } from '../../api/types';
|
||||
import { CASE_STAGES, formatTime, nextStages, stageExplainer, stageLabel } from '../../lib/format';
|
||||
import './workbench.css';
|
||||
|
||||
interface Props {
|
||||
caseInfo: PregnancyCase;
|
||||
role?: Role;
|
||||
onChanged: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function CaseFlowPanel({ caseInfo, role, onChanged }: Props): JSX.Element {
|
||||
const { show } = useToast();
|
||||
const { user } = useAuth();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const canAdvance = role === 'case_manager' || role === 'physician';
|
||||
const currentIdx = CASE_STAGES.indexOf(caseInfo.stage);
|
||||
const targets = nextStages(caseInfo.stage);
|
||||
|
||||
async function advance(to: (typeof targets)[number]): Promise<void> {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.advanceCase(caseInfo.id, to, `${stageLabel(to)}(工作台推进)`);
|
||||
show(`已推进至「${stageLabel(to)}」`);
|
||||
await onChanged();
|
||||
} catch (err) {
|
||||
show(err instanceof ApiError ? err.message : '流转失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function claim(): Promise<void> {
|
||||
if (!user) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.assignManager(caseInfo.id, user.id);
|
||||
show('已指派给我');
|
||||
await onChanged();
|
||||
} catch (err) {
|
||||
show(err instanceof ApiError ? err.message : '指派失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span>个案流程</span>
|
||||
<span className={`badge ${caseInfo.status === 'open' ? 'badge-ok' : 'badge-neutral'}`}>
|
||||
{caseInfo.status === 'open' ? '进行中' : '已关闭'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 阶段进度 */}
|
||||
<div className="stepper">
|
||||
{CASE_STAGES.map((s, i) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`stepper__node${i < currentIdx ? ' is-done' : ''}${
|
||||
i === currentIdx ? ' is-current' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="stepper__dot">
|
||||
{i < currentIdx ? <Check size={13} strokeWidth={3} /> : i + 1}
|
||||
</span>
|
||||
<span className="stepper__label">{stageLabel(s)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 当前阶段说明 + 建议下一步 */}
|
||||
<div className="flow-explainer">
|
||||
<p className="flow-explainer__current">
|
||||
<span className="badge badge-info">当前:{stageLabel(caseInfo.stage)}</span>
|
||||
<span>{stageExplainer(caseInfo.stage)}</span>
|
||||
</p>
|
||||
{targets.length > 0 && (
|
||||
<p className="flow-explainer__next muted">
|
||||
建议下一步:{targets.map((t) => stageLabel(t)).join(' 或 ')} —— {stageExplainer(targets[0])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 指派 */}
|
||||
<div className="spread flow-assign">
|
||||
<span className="muted">
|
||||
负责管理师:
|
||||
{caseInfo.caseManagerId
|
||||
? caseInfo.caseManagerId === user?.id
|
||||
? '我'
|
||||
: caseInfo.caseManagerId.slice(0, 8)
|
||||
: '未指派'}
|
||||
</span>
|
||||
{role === 'case_manager' && caseInfo.caseManagerId !== user?.id && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={claim} disabled={busy} type="button">
|
||||
指派给我
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 流转操作 */}
|
||||
{canAdvance ? (
|
||||
targets.length > 0 ? (
|
||||
<div className="flow-actions">
|
||||
<span className="muted">推进到:</span>
|
||||
{targets.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className="btn btn-calm btn-sm"
|
||||
onClick={() => advance(t)}
|
||||
disabled={busy || caseInfo.status === 'closed'}
|
||||
type="button"
|
||||
>
|
||||
{stageLabel(t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted flow-actions">个案已到终态(转出)。</p>
|
||||
)
|
||||
) : (
|
||||
<p className="muted flow-actions">当前角色无流转权限。</p>
|
||||
)}
|
||||
|
||||
{/* 历史 */}
|
||||
{caseInfo.history.length > 0 && (
|
||||
<div className="flow-history">
|
||||
<p className="flow-history__title muted">流转记录</p>
|
||||
{caseInfo.history
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((h, i) => (
|
||||
<div key={i} className="flow-history__item">
|
||||
<span className="badge badge-neutral">
|
||||
{stageLabel(h.from)} <ArrowRight size={12} strokeWidth={2} /> {stageLabel(h.to)}
|
||||
</span>
|
||||
<span className="muted">{h.reason}</span>
|
||||
<span className="muted flow-history__time">{formatTime(h.at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Activity, AlertTriangle, ArrowRight, ClipboardList, History, Route } from 'lucide-react';
|
||||
import type { Alert, CarePlan, CaseStage, Observation, PregnancyCase, Reminder } from '../../api/types';
|
||||
import { riskBadgeClass, riskLabel, stageLabel } from '../../lib/format';
|
||||
import './workbench.css';
|
||||
|
||||
interface Props {
|
||||
caseInfo: PregnancyCase;
|
||||
alerts: Alert[];
|
||||
observations: Observation[];
|
||||
carePlans: CarePlan[];
|
||||
reminders: Reminder[];
|
||||
}
|
||||
|
||||
/** 依据当前阶段 + 是否已有照护计划,给出“此刻该做什么”的一句话建议 */
|
||||
function nextAction(stage: CaseStage, hasCarePlan: boolean): string {
|
||||
switch (stage) {
|
||||
case 'screening':
|
||||
return '完善基线信息与首批观测后,将个案推进到「评估」。';
|
||||
case 'assessment':
|
||||
return '结合左侧预警与观测综合评估母婴状况,确认后推进到「风险分层」。';
|
||||
case 'risk_stratification':
|
||||
return '依据评估确定风险等级,随后推进到「计划制定」并在右侧制定照护计划。';
|
||||
case 'planning':
|
||||
return hasCarePlan
|
||||
? '照护计划已制定,可推进到「实施协调」。'
|
||||
: '请在右侧「处置与计划」新建照护计划,再推进到「实施协调」。';
|
||||
case 'implementation':
|
||||
return '执行照护计划、按需下发提醒,随后推进到「监测随访」。';
|
||||
case 'monitoring':
|
||||
return '持续监测随访并关注新预警;达到复评条件后推进到「评价」。';
|
||||
case 'evaluation':
|
||||
return '评估干预效果:达标可「转出」,需调整则退回「评估」。';
|
||||
case 'transition':
|
||||
return '个案已进入转出 / 产后随访,管理闭环完成。';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{ icon: Route, num: 1, title: '看阶段', desc: '「个案流程」标明当前所处环节' },
|
||||
{ icon: Activity, num: 2, title: '看数据', desc: '左侧「监测与分析」预警与观测' },
|
||||
{ icon: ClipboardList, num: 3, title: '做处置', desc: '右侧「处置与计划」建议·计划·提醒' },
|
||||
{ icon: History, num: 4, title: '查留痕', desc: '底部「时间线」按时间回溯全过程' },
|
||||
] as const;
|
||||
|
||||
export function CaseGuidePanel({
|
||||
caseInfo,
|
||||
alerts,
|
||||
observations,
|
||||
carePlans,
|
||||
reminders,
|
||||
}: Props): JSX.Element {
|
||||
const openAlerts = alerts.filter((a) => a.status === 'open').length;
|
||||
const hasCarePlan = carePlans.length > 0;
|
||||
const action = nextAction(caseInfo.stage, hasCarePlan);
|
||||
|
||||
return (
|
||||
<div className="card guide-card">
|
||||
<div className="card-header">
|
||||
<span>处置指引 · 本页怎么用</span>
|
||||
<span className={`badge ${riskBadgeClass(caseInfo.riskLevel)}`}>
|
||||
当前 {riskLabel(caseInfo.riskLevel)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="guide-intro muted">
|
||||
本页管理 <strong>1 位孕妇的个案</strong>,按管理闭环组织——左侧“数据进来”,右侧“处置出去”,
|
||||
顶部表示个案在生命周期中的位置,底部留痕。建议按以下顺序阅读:
|
||||
</p>
|
||||
|
||||
<ol className="guide-map">
|
||||
{STEPS.map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<li key={s.num} className="guide-map__step">
|
||||
<span className="guide-map__icon">
|
||||
<Icon size={16} strokeWidth={1.75} />
|
||||
</span>
|
||||
<div className="guide-map__body">
|
||||
<p className="guide-map__title">
|
||||
<span className="guide-map__num">{s.num}</span>
|
||||
{s.title}
|
||||
</p>
|
||||
<p className="guide-map__desc muted">{s.desc}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<div className="guide-stats">
|
||||
<span className={`guide-stat${openAlerts > 0 ? ' is-alert' : ''}`}>
|
||||
<span className="guide-stat__num">{openAlerts}</span> 待处理预警
|
||||
</span>
|
||||
<span className="guide-stat">
|
||||
<span className="guide-stat__num">{observations.length}</span> 观测记录
|
||||
</span>
|
||||
<span className="guide-stat">
|
||||
<span className="guide-stat__num">{carePlans.length}</span> 照护计划
|
||||
</span>
|
||||
<span className="guide-stat">
|
||||
<span className="guide-stat__num">{reminders.length}</span> 提醒下发
|
||||
</span>
|
||||
<span className="guide-stat">
|
||||
阶段 <strong>{stageLabel(caseInfo.stage)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{openAlerts > 0 && (
|
||||
<p className="guide-warn">
|
||||
<AlertTriangle size={15} strokeWidth={2} />有 {openAlerts} 条待处理预警,建议优先核查并据此调整处置。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="guide-next">
|
||||
<ArrowRight size={16} strokeWidth={2} />
|
||||
<span>
|
||||
<strong>此刻建议:</strong>
|
||||
{action}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
ClipboardList,
|
||||
FolderPlus,
|
||||
Workflow,
|
||||
HeartHandshake,
|
||||
Smile,
|
||||
} from 'lucide-react';
|
||||
import type {
|
||||
Alert,
|
||||
CarePlan,
|
||||
Observation,
|
||||
PregnancyCase,
|
||||
Reminder,
|
||||
Disposition,
|
||||
Referral,
|
||||
EmotionCheckin,
|
||||
} from '../../api/types';
|
||||
import {
|
||||
formatTime,
|
||||
indicatorLabel,
|
||||
reminderLabel,
|
||||
riskLabel,
|
||||
stageLabel,
|
||||
} from '../../lib/format';
|
||||
import './workbench.css';
|
||||
|
||||
type EventKind = 'observation' | 'alert' | 'stage' | 'plan' | 'reminder' | 'case' | 'disposition' | 'referral' | 'emotion';
|
||||
type EventTone = 'data' | 'warn' | 'danger' | 'process' | 'care' | 'info';
|
||||
|
||||
interface TimelineEvent {
|
||||
id: string;
|
||||
at: string;
|
||||
kind: EventKind;
|
||||
tone: EventTone;
|
||||
title: string;
|
||||
detail?: string;
|
||||
ga?: number;
|
||||
}
|
||||
|
||||
const KIND_META: Record<EventKind, { icon: typeof Bell; label: string }> = {
|
||||
observation: { icon: Activity, label: '数据采集' },
|
||||
alert: { icon: AlertTriangle, label: '预警' },
|
||||
stage: { icon: Workflow, label: '流程流转' },
|
||||
plan: { icon: ClipboardList, label: '照护计划' },
|
||||
reminder: { icon: Bell, label: '提醒下发' },
|
||||
case: { icon: FolderPlus, label: '个案' },
|
||||
disposition: { icon: ClipboardList, label: '处置下发' },
|
||||
referral: { icon: HeartHandshake, label: '跨学科会诊' },
|
||||
emotion: { icon: Smile, label: '身心自评' },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
caseInfo: PregnancyCase;
|
||||
observations: Observation[];
|
||||
alerts: Alert[];
|
||||
carePlans: CarePlan[];
|
||||
reminders: Reminder[];
|
||||
dispositions?: Disposition[];
|
||||
referrals?: Referral[];
|
||||
emotions?: EmotionCheckin[];
|
||||
}
|
||||
|
||||
function buildEvents(props: Props): TimelineEvent[] {
|
||||
const { caseInfo, observations, alerts, carePlans, reminders, dispositions, referrals, emotions } = props;
|
||||
const events: TimelineEvent[] = [];
|
||||
|
||||
// 开案
|
||||
events.push({
|
||||
id: `case-${caseInfo.id}`,
|
||||
at: caseInfo.createdAt,
|
||||
kind: 'case',
|
||||
tone: 'process',
|
||||
title: '建立个案',
|
||||
detail: `初始风险分层:${riskLabel(caseInfo.riskLevel)}`,
|
||||
});
|
||||
|
||||
// 流程流转
|
||||
for (let i = 0; i < caseInfo.history.length; i += 1) {
|
||||
const h = caseInfo.history[i];
|
||||
events.push({
|
||||
id: `stage-${i}-${h.at}`,
|
||||
at: h.at,
|
||||
kind: 'stage',
|
||||
tone: 'process',
|
||||
title: `${stageLabel(h.from)} → ${stageLabel(h.to)}`,
|
||||
detail: h.reason,
|
||||
});
|
||||
}
|
||||
|
||||
// 数据采集
|
||||
for (const o of observations) {
|
||||
events.push({
|
||||
id: `obs-${o.id}`,
|
||||
at: o.measuredAt,
|
||||
kind: 'observation',
|
||||
tone: 'data',
|
||||
title: `${indicatorLabel(o.indicator)} ${o.value} ${o.unit}`,
|
||||
detail: o.qcStatus === 'rejected' ? `质控存疑:${o.qcFlags.join('、') || '—'}` : undefined,
|
||||
ga: o.gestationalWeeks,
|
||||
});
|
||||
}
|
||||
|
||||
// 预警
|
||||
for (const a of alerts) {
|
||||
events.push({
|
||||
id: `alert-${a.id}`,
|
||||
at: a.createdAt,
|
||||
kind: 'alert',
|
||||
tone: a.level === 'high' ? 'danger' : a.level === 'medium' ? 'warn' : 'info',
|
||||
title: `${riskLabel(a.level)}预警 · ${indicatorLabel(a.indicator)} ${a.value}`,
|
||||
detail: a.messages.join(';') || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// 照护计划
|
||||
for (const p of carePlans) {
|
||||
events.push({
|
||||
id: `plan-${p.id}`,
|
||||
at: p.createdAt,
|
||||
kind: 'plan',
|
||||
tone: 'care',
|
||||
title: '制定照护计划',
|
||||
detail: `目标:${p.goals.join('、') || '—'};随访频率:${p.followUpFrequency}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 提醒下发
|
||||
for (const r of reminders) {
|
||||
events.push({
|
||||
id: `rem-${r.id}`,
|
||||
at: r.scheduledAt,
|
||||
kind: 'reminder',
|
||||
tone: 'info',
|
||||
title: `提醒:${reminderLabel(r.effectiveType)}`,
|
||||
detail: r.message,
|
||||
});
|
||||
}
|
||||
|
||||
// 处置单 (T-D.10)
|
||||
for (const d of dispositions ?? []) {
|
||||
events.push({
|
||||
id: `disp-${d.id}`,
|
||||
at: d.createdAt,
|
||||
kind: 'disposition',
|
||||
tone: d.status === 'closed' ? 'info' : 'warn',
|
||||
title: `处置下发:${d.title}`,
|
||||
detail: `状态: ${d.status === 'closed' ? '已闭环' : '执行中'} · 动作明细: ${d.actions.map((a) => a.description).join('、')}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 转会诊 (T-D.10)
|
||||
for (const r of referrals ?? []) {
|
||||
events.push({
|
||||
id: `ref-${r.id}`,
|
||||
at: r.createdAt,
|
||||
kind: 'referral',
|
||||
tone: r.urgency === 'emergency' ? 'danger' : 'care',
|
||||
title: `院内协同:发起 ${r.type === 'referral' ? '转诊' : '多科会诊'}`,
|
||||
detail: `状态: ${r.status === 'completed' ? '会诊已回复并闭环' : '进行中'} · 简述: ${r.clinicalSummary}${r.doctorReply ? ` · 专家意见: ${r.doctorReply}` : ''}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 情绪自评打卡 (T-D.10)
|
||||
for (const e of emotions ?? []) {
|
||||
events.push({
|
||||
id: `emo-${e.id}`,
|
||||
at: e.createdAt,
|
||||
kind: 'emotion',
|
||||
tone: e.status === 'crisis' ? 'danger' : e.status === 'concerning' ? 'warn' : 'data',
|
||||
title: `孕妇自评打卡:今日情绪分值 ${e.score} 分`,
|
||||
detail: `状态: ${e.status === 'crisis' ? '触发重度身心危机信号' : e.status === 'concerning' ? '出现明显焦虑情绪' : '平稳'} · 打卡感言: ${e.note}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 时间倒序(最新在上)
|
||||
return events.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
|
||||
}
|
||||
|
||||
const FILTERS: { key: EventKind | 'all'; label: string }[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'observation', label: '数据' },
|
||||
{ key: 'alert', label: '预警' },
|
||||
{ key: 'stage', label: '流转' },
|
||||
{ key: 'plan', label: '计划' },
|
||||
{ key: 'reminder', label: '提醒' },
|
||||
{ key: 'disposition', label: '处置' },
|
||||
{ key: 'referral', label: '会诊' },
|
||||
{ key: 'emotion', label: '情绪' },
|
||||
];
|
||||
|
||||
export function CaseTimeline(props: Props): JSX.Element {
|
||||
const [filter, setFilter] = useState<EventKind | 'all'>('all');
|
||||
const events = useMemo(() => buildEvents(props), [props]);
|
||||
const filtered = filter === 'all' ? events : events.filter((e) => e.kind === filter);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span>个案动态时间线</span>
|
||||
<span className="muted">{events.length} 条记录</span>
|
||||
</div>
|
||||
<p className="muted timeline-hint">
|
||||
按时间汇总「数据采集 → 预警 → 流程流转 → 照护计划 → 提醒下发」的全过程,最新在上。
|
||||
</p>
|
||||
|
||||
<div className="timeline-filters">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
className={`timeline-filter${filter === f.key ? ' is-active' : ''}`}
|
||||
onClick={() => setFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="empty">暂无对应记录。</p>
|
||||
) : (
|
||||
<ol className="timeline">
|
||||
{filtered.map((e) => {
|
||||
const Icon = KIND_META[e.kind].icon;
|
||||
return (
|
||||
<li key={e.id} className={`timeline-item timeline-item--${e.tone}`}>
|
||||
<span className="timeline-item__dot">
|
||||
<Icon size={14} strokeWidth={1.75} />
|
||||
</span>
|
||||
<div className="timeline-item__content">
|
||||
<div className="timeline-item__head">
|
||||
<span className="timeline-item__kind">{KIND_META[e.kind].label}</span>
|
||||
{e.ga != null && <span className="timeline-item__ga">孕{e.ga}周</span>}
|
||||
<span className="timeline-item__time muted">{formatTime(e.at)}</span>
|
||||
</div>
|
||||
<p className="timeline-item__title">{e.title}</p>
|
||||
{e.detail && <p className="timeline-item__detail muted">{e.detail}</p>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
import type { Observation } from '../../api/types';
|
||||
import { formatTime, indicatorLabel } from '../../lib/format';
|
||||
import './workbench.css';
|
||||
|
||||
export function ObservationsPanel({ observations }: { observations: Observation[] }): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const shown = expanded ? observations : observations.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span>观测记录</span>
|
||||
<span className="muted">{observations.length} 条</span>
|
||||
</div>
|
||||
{observations.length === 0 ? (
|
||||
<p className="empty">暂无观测数据。</p>
|
||||
) : (
|
||||
<>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>指标</th>
|
||||
<th>数值</th>
|
||||
<th>孕周</th>
|
||||
<th>时间</th>
|
||||
<th>质控</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((o) => (
|
||||
<tr key={o.id}>
|
||||
<td>{indicatorLabel(o.indicator)}</td>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
{o.value} {o.unit}
|
||||
</td>
|
||||
<td>{o.gestationalWeeks}周</td>
|
||||
<td className="muted">{formatTime(o.measuredAt)}</td>
|
||||
<td>
|
||||
{o.qcStatus === 'accepted' ? (
|
||||
<span className="badge badge-ok">合格</span>
|
||||
) : (
|
||||
<span className="badge badge-warn">存疑</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{observations.length > 6 && (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ marginTop: 'var(--space-3)' }}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
{expanded ? '收起' : `展开全部 ${observations.length} 条`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { api, ApiError } from '../../api/client';
|
||||
import { useToast } from '../Toast';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
import type { PregnancyCase, Recommendation } from '../../api/types';
|
||||
import { formatTime } from '../../lib/format';
|
||||
import './workbench.css';
|
||||
|
||||
interface Props {
|
||||
patientId: string;
|
||||
caseInfo: PregnancyCase;
|
||||
}
|
||||
|
||||
interface Confirmation {
|
||||
by: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export function RecommendationPanel({ patientId, caseInfo }: Props): JSX.Element {
|
||||
const { show } = useToast();
|
||||
const { user } = useAuth();
|
||||
const [rec, setRec] = useState<Recommendation | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirmation, setConfirmation] = useState<Confirmation | null>(null);
|
||||
|
||||
async function generate(): Promise<void> {
|
||||
setLoading(true);
|
||||
setConfirmation(null);
|
||||
try {
|
||||
const r = await api.getRecommendation(patientId);
|
||||
setRec(r);
|
||||
} catch (err) {
|
||||
show(err instanceof ApiError ? err.message : '生成建议失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function confirm(): void {
|
||||
setConfirmation({ by: user?.username ?? '当前用户', at: new Date().toISOString() });
|
||||
show('已人工确认采纳该建议');
|
||||
}
|
||||
|
||||
const needsConfirm = rec?.requiresHumanConfirmation ?? false;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span>AI 决策建议</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={generate} disabled={loading} type="button">
|
||||
{loading ? '生成中…' : rec ? '重新生成' : '生成建议'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!rec ? (
|
||||
<p className="empty">点击「生成建议」基于当前个案风险与未处理预警给出处置参考。</p>
|
||||
) : (
|
||||
<>
|
||||
{needsConfirm && (
|
||||
<div className="rec-gate">
|
||||
<strong>
|
||||
<AlertTriangle size={15} strokeWidth={2} /> 需人工确认
|
||||
</strong>
|
||||
<p>
|
||||
当前为
|
||||
{caseInfo.riskLevel === 'high' ? '高风险' : '中风险'}
|
||||
个案,AI 建议<strong>不会自动执行</strong>,须由医护人工确认后方可采纳(REQ-10.3)。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="rec-section-title">建议处置</p>
|
||||
<ul className="rec-actions">
|
||||
{rec.actions.map((a, i) => (
|
||||
<li key={i}>{a}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="rec-section-title">依据</p>
|
||||
<ul className="rec-rationale">
|
||||
{rec.rationale.map((r, i) => (
|
||||
<li key={i} className="muted">
|
||||
{r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{needsConfirm ? (
|
||||
confirmation ? (
|
||||
<div className="rec-confirmed">
|
||||
<CheckCircle2 size={15} strokeWidth={2} /> 已由 <strong>{confirmation.by}</strong> 人工确认采纳 ·{' '}
|
||||
{formatTime(confirmation.at)}
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-primary btn-block" onClick={confirm} type="button">
|
||||
确认采纳(人工兜底)
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<div className="rec-auto muted">低风险建议,可作为常规关怀参考。</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, ShieldCheck } from 'lucide-react';
|
||||
import { api, ApiError } from '../../api/client';
|
||||
import { useToast } from '../Toast';
|
||||
import type { RedFlagResult, Role, Symptom } from '../../api/types';
|
||||
import './workbench.css';
|
||||
|
||||
interface Props {
|
||||
patientId: string;
|
||||
role?: Role;
|
||||
onTriggered: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
const SYMPTOMS: { value: Symptom; label: string }[] = [
|
||||
{ value: 'severe_headache', label: '剧烈头痛' },
|
||||
{ value: 'visual_disturbance', label: '视物模糊' },
|
||||
{ value: 'epigastric_pain', label: '上腹痛' },
|
||||
{ value: 'reduced_fetal_movement', label: '胎动减少/消失' },
|
||||
{ value: 'vaginal_bleeding', label: '阴道出血' },
|
||||
{ value: 'severe_edema', label: '严重水肿' },
|
||||
];
|
||||
|
||||
export function RedflagPanel({ patientId, role, onTriggered }: Props): JSX.Element {
|
||||
const { show } = useToast();
|
||||
const canCheck = role === 'case_manager';
|
||||
|
||||
const [systolic, setSystolic] = useState('');
|
||||
const [diastolic, setDiastolic] = useState('');
|
||||
const [glucose, setGlucose] = useState('');
|
||||
const [symptoms, setSymptoms] = useState<Set<Symptom>>(new Set());
|
||||
const [result, setResult] = useState<RedFlagResult | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
function toggle(s: Symptom): void {
|
||||
setSymptoms((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(s)) next.delete(s);
|
||||
else next.add(s);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function check(): Promise<void> {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.redflagCheck(patientId, {
|
||||
systolicBp: systolic ? Number(systolic) : undefined,
|
||||
diastolicBp: diastolic ? Number(diastolic) : undefined,
|
||||
fastingGlucose: glucose ? Number(glucose) : undefined,
|
||||
symptoms: [...symptoms],
|
||||
});
|
||||
setResult(res);
|
||||
if (res.triggered) {
|
||||
show('已触发红旗急症:已通知并升级个案');
|
||||
await onTriggered();
|
||||
} else {
|
||||
show('未触发红旗急症');
|
||||
}
|
||||
} catch (err) {
|
||||
show(err instanceof ApiError ? err.message : '检查失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!canCheck) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">红旗急症检查</div>
|
||||
<p className="empty">仅个案管理师可执行红旗检查。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">红旗急症检查</div>
|
||||
<p className="muted" style={{ marginBottom: 'var(--space-4)' }}>
|
||||
录入症状/体征快照,命中危急规则将立即提示就医并通知医护、升级个案为高风险。
|
||||
</p>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label>收缩压 (mmHg)</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={systolic}
|
||||
onChange={(e) => setSystolic(e.target.value)}
|
||||
placeholder="如 165"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>舒张压 (mmHg)</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={diastolic}
|
||||
onChange={(e) => setDiastolic(e.target.value)}
|
||||
placeholder="如 110"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>空腹血糖 (mmol/L,选填)</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.1"
|
||||
value={glucose}
|
||||
onChange={(e) => setGlucose(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>症状</label>
|
||||
<div className="redflag-symptoms">
|
||||
{SYMPTOMS.map((s) => (
|
||||
<label key={s.value} className={`redflag-chip${symptoms.has(s.value) ? ' is-on' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={symptoms.has(s.value)}
|
||||
onChange={() => toggle(s.value)}
|
||||
/>
|
||||
{s.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary" onClick={check} disabled={busy} type="button">
|
||||
{busy ? '检查中…' : '执行红旗检查'}
|
||||
</button>
|
||||
|
||||
{result && (
|
||||
<div className={`redflag-result${result.triggered ? ' is-danger' : ' is-ok'}`}>
|
||||
{result.triggered ? (
|
||||
<>
|
||||
<strong>
|
||||
<AlertTriangle size={15} strokeWidth={2} /> 触发红旗急症
|
||||
</strong>
|
||||
{result.hits.map((h) => (
|
||||
<div key={h.ruleId} className="redflag-hit">
|
||||
<span className="badge badge-danger">{h.ruleId}</span>
|
||||
<span>
|
||||
{h.message} — {h.advice}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{result.patientAdvice && <p className="redflag-advice">已向孕妇推送:{result.patientAdvice}</p>}
|
||||
</>
|
||||
) : (
|
||||
<span className="redflag-clear">
|
||||
<ShieldCheck size={15} strokeWidth={2} /> 未命中红旗急症规则。
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
Armchair,
|
||||
Bell,
|
||||
CalendarCheck,
|
||||
Droplet,
|
||||
Footprints,
|
||||
Pill,
|
||||
} from 'lucide-react';
|
||||
import type { ReminderType, Reminder } from '../../api/types';
|
||||
import { formatTime, reminderLabel } from '../../lib/format';
|
||||
import './workbench.css';
|
||||
|
||||
const ICONS: Record<ReminderType, typeof Bell> = {
|
||||
exercise: Footprints,
|
||||
rest: Armchair,
|
||||
water: Droplet,
|
||||
medication: Pill,
|
||||
checkup: CalendarCheck,
|
||||
measurement: Activity,
|
||||
};
|
||||
|
||||
function ReminderIcon({ type }: { type: ReminderType }): JSX.Element {
|
||||
const Icon = ICONS[type] ?? Bell;
|
||||
return <Icon size={16} strokeWidth={1.75} />;
|
||||
}
|
||||
|
||||
export function RemindersPanel({ reminders }: { reminders: Reminder[] }): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const shown = expanded ? reminders : reminders.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span>提醒与随访</span>
|
||||
<span className="muted">{reminders.length} 条</span>
|
||||
</div>
|
||||
{reminders.length === 0 ? (
|
||||
<p className="empty">暂无下发的提醒。</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="reminder-list">
|
||||
{shown.map((r) => (
|
||||
<div key={r.id} className="reminder-item">
|
||||
<span className="reminder-item__icon">
|
||||
<ReminderIcon type={r.effectiveType} />
|
||||
</span>
|
||||
<div className="reminder-item__body">
|
||||
<div className="reminder-item__head">
|
||||
<span className="reminder-item__type">{reminderLabel(r.effectiveType)}</span>
|
||||
{r.adjustedForRisk && (
|
||||
<span className="badge badge-warn" title={`原计划:${reminderLabel(r.type)}`}>
|
||||
已按风险调整
|
||||
</span>
|
||||
)}
|
||||
<span className="muted reminder-item__time">{formatTime(r.scheduledAt)}</span>
|
||||
</div>
|
||||
<p className="reminder-item__msg">{r.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{reminders.length > 6 && (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ marginTop: 'var(--space-3)' }}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
{expanded ? '收起' : `展开全部 ${reminders.length} 条`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
/* ===== 个案流程 stepper ===== */
|
||||
.stepper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.stepper__node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-bg);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-soft);
|
||||
}
|
||||
.stepper__node.is-done {
|
||||
background: var(--color-ok-soft);
|
||||
color: var(--color-ok);
|
||||
}
|
||||
.stepper__node.is-current {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stepper__dot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stepper__node.is-current .stepper__dot {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.flow-assign {
|
||||
padding: var(--space-3) 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.flow-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.flow-history {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.flow-history__title {
|
||||
font-size: var(--font-sm);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.flow-history__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
padding: 4px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.flow-history__time {
|
||||
margin-left: auto;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
/* ===== 预警 ===== */
|
||||
.alert-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.alert-item {
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 4px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.alert-item--medium {
|
||||
border-left-color: var(--color-warn);
|
||||
}
|
||||
.alert-item--high {
|
||||
border-left-color: var(--color-danger);
|
||||
}
|
||||
.alert-item__time {
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
.alert-item__indicator {
|
||||
margin: 6px 0 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.alert-item__msg {
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.alert-item__trace {
|
||||
font-size: var(--font-xs);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ===== AI 建议 ===== */
|
||||
.rec-gate {
|
||||
background: var(--color-warn-soft);
|
||||
border: 1px solid var(--color-warn);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.rec-gate strong {
|
||||
color: var(--color-warn);
|
||||
}
|
||||
.rec-section-title {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-soft);
|
||||
margin: var(--space-3) 0 var(--space-2);
|
||||
}
|
||||
.rec-actions,
|
||||
.rec-rationale {
|
||||
padding-left: 20px;
|
||||
font-size: var(--font-md);
|
||||
}
|
||||
.rec-actions li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.rec-rationale li {
|
||||
font-size: var(--font-sm);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.rec-confirmed {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-ok-soft);
|
||||
color: var(--color-ok);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.rec-auto {
|
||||
margin-top: var(--space-3);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
/* ===== 照护计划 ===== */
|
||||
.careplan-form {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.careplan-intervention {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.careplan-intervention select {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.careplan-remove {
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-soft);
|
||||
font-size: 18px;
|
||||
}
|
||||
.careplan-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.careplan-item {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.careplan-item__time {
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
.careplan-item__label {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-soft);
|
||||
margin: var(--space-2) 0 4px;
|
||||
}
|
||||
.careplan-item ul {
|
||||
padding-left: 18px;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
/* ===== 红旗急症 ===== */
|
||||
.redflag-symptoms {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.redflag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
font-size: var(--font-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.redflag-chip.is-on {
|
||||
background: var(--color-danger-soft);
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.redflag-chip input {
|
||||
display: none;
|
||||
}
|
||||
.redflag-result {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.redflag-result.is-ok {
|
||||
background: var(--color-ok-soft);
|
||||
color: var(--color-ok);
|
||||
}
|
||||
.redflag-result.is-danger {
|
||||
background: var(--color-danger-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.redflag-result.is-danger strong {
|
||||
color: var(--color-danger);
|
||||
display: block;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.redflag-hit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.redflag-advice {
|
||||
margin-top: var(--space-2);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* SVG 图标对齐 */
|
||||
.rec-gate strong,
|
||||
.rec-confirmed,
|
||||
.redflag-result strong,
|
||||
.redflag-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.rec-confirmed {
|
||||
display: flex;
|
||||
}
|
||||
.careplan-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.flow-history__item .badge svg,
|
||||
.stepper__dot svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===== 阶段说明(流程语义) ===== */
|
||||
.flow-explainer {
|
||||
margin-bottom: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.flow-explainer__current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.flow-explainer__current .badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.flow-explainer__next {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--font-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ===== 提醒与随访 ===== */
|
||||
.reminder-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.reminder-item {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.reminder-item__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--color-calm-soft);
|
||||
color: var(--color-calm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.reminder-item__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.reminder-item__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.reminder-item__type {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.reminder-item__time {
|
||||
margin-left: auto;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
.reminder-item__msg {
|
||||
margin-top: 2px;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-soft);
|
||||
}
|
||||
|
||||
/* ===== 个案动态时间线 ===== */
|
||||
.timeline-hint {
|
||||
font-size: var(--font-sm);
|
||||
margin-bottom: var(--space-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.timeline-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.timeline-filter {
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-soft);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.timeline-filter.is-active {
|
||||
background: var(--color-calm);
|
||||
border-color: var(--color-calm);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.timeline {
|
||||
list-style: none;
|
||||
position: relative;
|
||||
padding-left: 28px;
|
||||
}
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 2px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding: 0 0 var(--space-4) 0;
|
||||
}
|
||||
.timeline-item:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.timeline-item__dot {
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
top: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-soft);
|
||||
border: 2px solid var(--color-border-strong);
|
||||
}
|
||||
.timeline-item--data .timeline-item__dot {
|
||||
background: var(--color-calm-soft);
|
||||
color: var(--color-calm);
|
||||
border-color: var(--color-calm);
|
||||
}
|
||||
.timeline-item--warn .timeline-item__dot {
|
||||
background: var(--color-warn-soft);
|
||||
color: var(--color-warn);
|
||||
border-color: var(--color-warn);
|
||||
}
|
||||
.timeline-item--danger .timeline-item__dot {
|
||||
background: var(--color-danger-soft);
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.timeline-item--process .timeline-item__dot {
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary-strong);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.timeline-item--care .timeline-item__dot {
|
||||
background: var(--color-ok-soft);
|
||||
color: var(--color-ok);
|
||||
border-color: var(--color-ok);
|
||||
}
|
||||
.timeline-item--info .timeline-item__dot {
|
||||
background: var(--color-calm-soft);
|
||||
color: var(--color-calm);
|
||||
border-color: var(--color-calm);
|
||||
}
|
||||
.timeline-item__content {
|
||||
padding-top: 1px;
|
||||
}
|
||||
.timeline-item__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.timeline-item__kind {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-soft);
|
||||
}
|
||||
.timeline-item__ga {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-calm);
|
||||
background: var(--color-calm-soft);
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
.timeline-item__time {
|
||||
font-size: var(--font-xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
.timeline-item__title {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 600;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.timeline-item__detail {
|
||||
font-size: var(--font-xs);
|
||||
margin-top: 2px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.reminder-item__icon svg,
|
||||
.timeline-item__dot svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===== 处置指引(本页怎么用) ===== */
|
||||
.guide-card {
|
||||
border-top: 3px solid var(--color-calm);
|
||||
}
|
||||
.guide-intro {
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.6;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.guide-intro strong {
|
||||
color: var(--color-text);
|
||||
}
|
||||
.guide-map {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.guide-map__step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.guide-map__icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--color-calm-soft);
|
||||
color: var(--color-calm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.guide-map__body {
|
||||
min-width: 0;
|
||||
}
|
||||
.guide-map__title {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.guide-map__num {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--color-calm);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.guide-map__desc {
|
||||
font-size: var(--font-xs);
|
||||
line-height: 1.5;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.guide-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.guide-stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-bg);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-soft);
|
||||
}
|
||||
.guide-stat__num {
|
||||
font-weight: 700;
|
||||
font-size: var(--font-md);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.guide-stat.is-alert {
|
||||
background: var(--color-danger-soft);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.guide-stat.is-alert .guide-stat__num {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.guide-warn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-warn-soft);
|
||||
color: var(--color-warn);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.guide-next {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-calm-soft);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.guide-next svg {
|
||||
color: var(--color-calm);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.guide-next strong {
|
||||
color: var(--color-calm);
|
||||
}
|
||||
.guide-warn svg,
|
||||
.guide-map__icon svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.guide-map {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { CaseStage, InterventionKind, ReminderType, RiskLevel, Role } from '../api/types';
|
||||
|
||||
export function riskLabel(level: RiskLevel): string {
|
||||
return { low: '低风险', medium: '中风险', high: '高风险' }[level];
|
||||
}
|
||||
|
||||
export function riskBadgeClass(level: RiskLevel): string {
|
||||
return { low: 'badge-ok', medium: 'badge-warn', high: 'badge-danger' }[level];
|
||||
}
|
||||
|
||||
export const CASE_STAGES: CaseStage[] = [
|
||||
'screening',
|
||||
'assessment',
|
||||
'risk_stratification',
|
||||
'planning',
|
||||
'implementation',
|
||||
'monitoring',
|
||||
'evaluation',
|
||||
'transition',
|
||||
];
|
||||
|
||||
const STAGE_LABELS: Record<CaseStage, string> = {
|
||||
screening: '筛查',
|
||||
assessment: '评估',
|
||||
risk_stratification: '风险分层',
|
||||
planning: '计划制定',
|
||||
implementation: '实施协调',
|
||||
monitoring: '监测随访',
|
||||
evaluation: '评价',
|
||||
transition: '转出',
|
||||
};
|
||||
|
||||
export function stageLabel(stage: CaseStage): string {
|
||||
return STAGE_LABELS[stage] ?? stage;
|
||||
}
|
||||
|
||||
/** 各阶段含义说明(管理流程语义,帮助医护理解“现在做什么”) */
|
||||
const STAGE_EXPLAINERS: Record<CaseStage, string> = {
|
||||
screening: '收集基线信息与既往史,初步识别高危因素。',
|
||||
assessment: '结合观测数据综合评估母婴状况,明确需关注的问题。',
|
||||
risk_stratification: '依据评估结果确定风险等级,决定管理强度与频次。',
|
||||
planning: '制定个体化照护计划:设定目标与干预措施。',
|
||||
implementation: '执行照护计划、协调资源、下发健康指导与提醒。',
|
||||
monitoring: '持续监测指标与症状,按计划随访并捕捉预警。',
|
||||
evaluation: '评估干预效果,判断是否达标或需调整计划。',
|
||||
transition: '个案转出或进入产后随访,闭环结案。',
|
||||
};
|
||||
|
||||
export function stageExplainer(stage: CaseStage): string {
|
||||
return STAGE_EXPLAINERS[stage] ?? '';
|
||||
}
|
||||
|
||||
/** 各阶段允许流转到的下一阶段(与后端 case-state-machine.ts 对齐) */
|
||||
const TRANSITIONS: Record<CaseStage, CaseStage[]> = {
|
||||
screening: ['assessment'],
|
||||
assessment: ['risk_stratification'],
|
||||
risk_stratification: ['planning'],
|
||||
planning: ['implementation'],
|
||||
implementation: ['monitoring'],
|
||||
monitoring: ['evaluation', 'assessment'],
|
||||
evaluation: ['transition', 'assessment'],
|
||||
transition: [],
|
||||
};
|
||||
|
||||
export function nextStages(from: CaseStage): CaseStage[] {
|
||||
return TRANSITIONS[from] ?? [];
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
patient: '孕妇',
|
||||
family: '家属',
|
||||
case_manager: '个案管理师',
|
||||
physician: '医生',
|
||||
operator: '运营',
|
||||
admin: '管理员',
|
||||
};
|
||||
|
||||
export function roleLabel(role: Role): string {
|
||||
return ROLE_LABELS[role] ?? role;
|
||||
}
|
||||
|
||||
const INTERVENTION_LABELS: Record<InterventionKind, string> = {
|
||||
clinical: '临床',
|
||||
lifestyle: '生活方式',
|
||||
habit: '习惯养成',
|
||||
};
|
||||
|
||||
export function interventionLabel(kind: InterventionKind): string {
|
||||
return INTERVENTION_LABELS[kind] ?? kind;
|
||||
}
|
||||
|
||||
const INDICATOR_LABELS: Record<string, string> = {
|
||||
fasting_glucose: '空腹血糖',
|
||||
postprandial_glucose: '餐后血糖',
|
||||
ogtt_1h: 'OGTT 1h',
|
||||
ogtt_2h: 'OGTT 2h',
|
||||
systolic_bp: '收缩压',
|
||||
diastolic_bp: '舒张压',
|
||||
weight: '体重',
|
||||
heart_rate: '心率',
|
||||
};
|
||||
|
||||
export function indicatorLabel(indicator: string): string {
|
||||
return INDICATOR_LABELS[indicator] ?? indicator;
|
||||
}
|
||||
|
||||
const REMINDER_LABELS: Record<ReminderType, string> = {
|
||||
exercise: '运动',
|
||||
rest: '休息',
|
||||
water: '喝水',
|
||||
medication: '服药',
|
||||
checkup: '产检',
|
||||
measurement: '监测打卡',
|
||||
};
|
||||
|
||||
export function reminderLabel(type: ReminderType): string {
|
||||
return REMINDER_LABELS[type] ?? type;
|
||||
}
|
||||
|
||||
export function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const pad = (n: number): string => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(
|
||||
d.getMinutes(),
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
return iso?.slice(0, 10) ?? '';
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 与 backend/src/modules/auth/rbac.ts 对齐的能力矩阵(UI 门控用,后端为最终授权边界)。
|
||||
import type { Role } from '../api/types';
|
||||
|
||||
export type Action =
|
||||
| 'patient:read'
|
||||
| 'alert:read'
|
||||
| 'careplan:write'
|
||||
| 'case:advance'
|
||||
| 'redflag:check'
|
||||
| 'knowledge:write'
|
||||
| 'knowledge:ask'
|
||||
| 'reminder:dispatch'
|
||||
| 'admin:config'
|
||||
| 'audit:read';
|
||||
|
||||
const MATRIX: Record<Role, Action[]> = {
|
||||
patient: ['patient:read', 'knowledge:ask', 'redflag:check'],
|
||||
family: ['patient:read', 'knowledge:ask'],
|
||||
case_manager: [
|
||||
'patient:read',
|
||||
'alert:read',
|
||||
'careplan:write',
|
||||
'case:advance',
|
||||
'redflag:check',
|
||||
'knowledge:ask',
|
||||
'knowledge:write',
|
||||
'reminder:dispatch',
|
||||
],
|
||||
physician: [
|
||||
'patient:read',
|
||||
'alert:read',
|
||||
'careplan:write',
|
||||
'case:advance',
|
||||
'knowledge:write',
|
||||
'knowledge:ask',
|
||||
],
|
||||
operator: ['patient:read', 'knowledge:write', 'admin:config'],
|
||||
admin: ['patient:read', 'admin:config', 'audit:read', 'knowledge:write'],
|
||||
};
|
||||
|
||||
export function can(role: Role | undefined, action: Action): boolean {
|
||||
if (!role) return false;
|
||||
return MATRIX[role]?.includes(action) ?? false;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Options {
|
||||
/** 轮询间隔(毫秒)。默认 15s(工作台更需及时)。 */
|
||||
intervalMs?: number;
|
||||
/** 是否启用。默认 true。 */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 多端数据一致同步(T-8.5):以单一后端为真源,前端通过
|
||||
* - 窗口 focus / 标签可见(visibilitychange)
|
||||
* - 可见时定时轮询
|
||||
* 触发静默刷新,使工作台近实时收敛到孕妇/家属/其他医护端的改动。
|
||||
*
|
||||
* 注:传入的 refresh 应为"静默"刷新(不触发整页 loading),避免轮询闪烁。
|
||||
* 实时推送(WebSocket/SSE)列入 V2。
|
||||
*/
|
||||
export function useAutoRefresh(refresh: () => void, options?: Options): void {
|
||||
const intervalMs = options?.intervalMs ?? 15000;
|
||||
const enabled = options?.enabled ?? true;
|
||||
const saved = useRef(refresh);
|
||||
|
||||
useEffect(() => {
|
||||
saved.current = refresh;
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const refreshIfVisible = (): void => {
|
||||
if (document.visibilityState === 'visible') saved.current();
|
||||
};
|
||||
const onFocus = (): void => saved.current();
|
||||
window.addEventListener('focus', onFocus);
|
||||
document.addEventListener('visibilitychange', refreshIfVisible);
|
||||
const id = window.setInterval(refreshIfVisible, intervalMs);
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
document.removeEventListener('visibilitychange', refreshIfVisible);
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [intervalMs, enabled]);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './styles/global.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import { useToast } from '../components/Toast';
|
||||
import type { AuditEntry } from '../api/types';
|
||||
import { formatTime } from '../lib/format';
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
'auth:register': '注册',
|
||||
'auth:login': '登录',
|
||||
'case:advance': '个案流转',
|
||||
'careplan:write': '照护计划',
|
||||
'knowledge:write': '知识录入',
|
||||
};
|
||||
|
||||
function actionLabel(action: string): string {
|
||||
return ACTION_LABELS[action] ?? action;
|
||||
}
|
||||
|
||||
export function AuditPage(): JSX.Element {
|
||||
const { show } = useToast();
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actorId, setActorId] = useState('');
|
||||
const [action, setAction] = useState('');
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.listAudit({ actorId: actorId.trim() || undefined, action: action.trim() || undefined })
|
||||
.then(setEntries)
|
||||
.catch((err) => show(err instanceof ApiError ? err.message : '加载审计日志失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [actorId, action, show]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<div>
|
||||
<h1 style={{ fontSize: 'var(--font-xxl)' }}>审计日志</h1>
|
||||
<p className="muted">关键操作(数据访问、处置、配置)全链路留痕,支持合规与责任界定。</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="row" style={{ marginBottom: 'var(--space-4)' }}>
|
||||
<input
|
||||
placeholder="按操作者 ID 过滤"
|
||||
value={actorId}
|
||||
onChange={(e) => setActorId(e.target.value)}
|
||||
style={{ padding: '8px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
|
||||
/>
|
||||
<input
|
||||
placeholder="按动作过滤,如 auth:login"
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
style={{ padding: '8px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
|
||||
/>
|
||||
<button className="btn btn-ghost btn-sm" onClick={load} type="button">
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="empty">加载中…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="empty">暂无审计记录。</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>操作者</th>
|
||||
<th>动作</th>
|
||||
<th>对象</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td className="muted">{formatTime(e.at)}</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 'var(--font-sm)' }}>
|
||||
{e.actorId.slice(0, 12)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-neutral">{actionLabel(e.action)}</span>
|
||||
</td>
|
||||
<td className="muted">{e.target ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
/* 个案工作台详情页高级布局样式 */
|
||||
|
||||
/* 档案摘要头 - 紧凑化 */
|
||||
.patient-head__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.patient-head__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: var(--color-primary-strong);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.patient-head__name {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-strong);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.patient-head__factors {
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-destructive);
|
||||
}
|
||||
|
||||
/* 胶囊 Tab 控制器样式 - 紧凑化 */
|
||||
.tab-pill-container {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-md);
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
.tab-pill-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.tab-pill {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 8px 12px;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
text-align: center;
|
||||
}
|
||||
.tab-pill.active {
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary-strong);
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary-soft-border, rgba(0,0,0,0.05));
|
||||
}
|
||||
|
||||
/* 态势感知卡片列表(紧凑 Master 列表) */
|
||||
.stat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.stat-item {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.stat-item:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
.stat-item.active {
|
||||
background: var(--color-primary-soft);
|
||||
border-color: var(--color-primary-strong);
|
||||
}
|
||||
.stat-item__label {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat-item__val {
|
||||
font-size: var(--font-md);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
.stat-unit {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 详情列表样式 - 紧凑化 */
|
||||
.detail-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.detail-item {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.detail-item:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.text-rose { color: var(--color-destructive); }
|
||||
.text-amber { color: var(--color-warning-strong); }
|
||||
.text-indigo { color: var(--color-primary-strong); }
|
||||
.text-emerald { color: var(--color-calm); }
|
||||
|
||||
/* iPad / 平板响应式双栏工作台布局 (Master-Detail) - 紧凑化 */
|
||||
.workbench-layout-container {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: var(--space-3);
|
||||
align-items: start;
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
|
||||
/* 第一个布局容器(紧贴 Tab)移除上边距 */
|
||||
.workbench-layout-container:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Master 面板统一最小高度 - 减小高度 */
|
||||
.workbench-master {
|
||||
height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Detail 面板统一最小高度 - 减小高度 */
|
||||
.workbench-detail {
|
||||
height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 待处置项区域使用固定高度 - 减小高度 */
|
||||
.disposition-layout .workbench-master {
|
||||
height: 350px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.disposition-layout .workbench-detail {
|
||||
height: 350px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pane-title {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-strong);
|
||||
margin-bottom: var(--space-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.empty-small {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--space-4) 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Master: 待处置项列表 - 紧凑化 */
|
||||
.work-list-mini {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.work-item-mini {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.work-item-mini:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
.work-item-mini.active {
|
||||
background: var(--color-primary-soft);
|
||||
border-color: var(--color-primary-strong);
|
||||
}
|
||||
.work-item-mini__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.priority-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.priority-dot.high { background: var(--color-destructive); }
|
||||
.priority-dot.medium { background: var(--color-warning-strong); }
|
||||
.priority-dot.low { background: var(--color-calm); }
|
||||
|
||||
.work-item-mini__type {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
.work-item-mini__title {
|
||||
font-size: var(--font-xs);
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.work-item-mini__date {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Detail: 决策处置工作区 - 紧凑化 */
|
||||
.detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.priority-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
}
|
||||
.priority-tag.high { background: var(--color-destructive); }
|
||||
.priority-tag.medium { background: var(--color-warning-strong); }
|
||||
.priority-tag.low { background: var(--color-calm); }
|
||||
|
||||
.detail-title {
|
||||
font-size: var(--font-md);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.detail-content-box {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* 各类型待办细节面板 */
|
||||
.alert-desc-box, .emotion-desc-box, .referral-desc-box {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.alert-desc-box p, .emotion-desc-box p, .referral-desc-box p {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.alert-desc-box p:last-child, .emotion-desc-box p:last-child, .referral-desc-box p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.highlight-val {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-destructive);
|
||||
}
|
||||
.emotion-diary, .clinical-summary-box, .doctor-reply-box {
|
||||
background: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
font-style: italic;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-strong);
|
||||
margin-top: 6px;
|
||||
border: 1px dashed var(--color-border);
|
||||
}
|
||||
|
||||
/* AI 建议处置区 */
|
||||
.ai-advisor {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.ai-advisor__brand {
|
||||
font-weight: 700;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-primary-strong);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.ai-advisor__text {
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ai-actions-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.ai-action-item {
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
/* 门控警告 D.1 / D.2 */
|
||||
.gating-warning-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.gating-warning-box.bg-rose-soft {
|
||||
background: rgba(224, 49, 49, 0.05);
|
||||
border: 1px solid rgba(224, 49, 49, 0.15);
|
||||
}
|
||||
.warn-text {
|
||||
color: var(--color-destructive);
|
||||
}
|
||||
|
||||
/* 处置动作执行列表 */
|
||||
.actions-execute-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.execute-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.execute-row__info {
|
||||
flex: 1;
|
||||
}
|
||||
.execute-row__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.input-inline {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-sm);
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
/* 医生协同会诊板 */
|
||||
.doctor-collaboration-panel {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
/* 静态生理档案 */
|
||||
.static-archive-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
.archive-item {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
.archive-label {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* SVG 折线 Sparkline */
|
||||
.sparkline {
|
||||
display: block;
|
||||
}
|
||||
.sparkline-wrapper {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 健康档案指标概览卡片 */
|
||||
.indicators-summary-grid {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.indicator-summary-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.indicator-summary-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.indicator-label {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.direction-badge {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.direction-badge.up { background: rgba(224, 49, 49, 0.05); color: var(--color-destructive); }
|
||||
.direction-badge.down { background: rgba(59, 201, 219, 0.05); color: var(--color-calm); }
|
||||
.direction-badge.stable { background: #f1f3f5; color: var(--color-text-muted); }
|
||||
|
||||
.indicator-summary-card__val {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
.unit-label {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.huge-stat-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.huge-val {
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 会诊发起卡片美化样式 - 紧凑化 */
|
||||
.referral-create-card {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.referral-create-header {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.referral-create-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.referral-create-form {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.form-group-flex {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-strong);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.select-modern {
|
||||
padding: 8px 12px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-strong);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.select-modern:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.select-modern:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary-strong);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-modern {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-strong);
|
||||
background: #fff;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.input-modern::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.input-modern:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.input-modern:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary-strong);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 8px 16px;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
/* iPad/平板 竖屏及小屏幕适配(竖屏折叠堆叠) */
|
||||
@media (max-width: 1024px) {
|
||||
.workbench-layout-container {
|
||||
grid-template-columns: 1fr; /* 竖屏下转为堆叠布局 */
|
||||
}
|
||||
.workbench-master {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.workbench-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
.knowledge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 420px 1fr;
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.knowledge-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.knowledge-answer {
|
||||
background: var(--color-ok-soft);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.knowledge-answer.is-empty {
|
||||
background: var(--color-warn-soft);
|
||||
}
|
||||
.knowledge-answer p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.knowledge-citations {
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px dashed var(--color-border-strong);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.knowledge-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.knowledge-item {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.knowledge-item__title {
|
||||
font-weight: 700;
|
||||
}
|
||||
.knowledge-item__content {
|
||||
font-size: var(--font-sm);
|
||||
margin: var(--space-2) 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.knowledge-item__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.knowledge-kw {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-calm);
|
||||
}
|
||||
.knowledge-item__source {
|
||||
font-size: var(--font-xs);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import { useToast } from '../components/Toast';
|
||||
import type {
|
||||
AuthorityLevel,
|
||||
CreateKnowledgeInput,
|
||||
KnowledgeCategory,
|
||||
KnowledgeItem,
|
||||
QaAnswer,
|
||||
} from '../api/types';
|
||||
import { formatTime } from '../lib/format';
|
||||
import './KnowledgePage.css';
|
||||
|
||||
const CATEGORIES: { value: KnowledgeCategory; label: string }[] = [
|
||||
{ value: 'guideline', label: '临床指南' },
|
||||
{ value: 'indicator_reference', label: '指标释义' },
|
||||
{ value: 'intervention', label: '干预知识' },
|
||||
{ value: 'tcm', label: '中医调养' },
|
||||
];
|
||||
|
||||
const AUTHORITIES: { value: AuthorityLevel; label: string }[] = [
|
||||
{ value: 'authoritative', label: '权威' },
|
||||
{ value: 'reference', label: '参考' },
|
||||
{ value: 'self', label: '自建' },
|
||||
];
|
||||
|
||||
function categoryLabel(c: string): string {
|
||||
return CATEGORIES.find((x) => x.value === c)?.label ?? c;
|
||||
}
|
||||
function authorityLabel(a: string): string {
|
||||
return AUTHORITIES.find((x) => x.value === a)?.label ?? a;
|
||||
}
|
||||
function authorityBadge(a: AuthorityLevel): string {
|
||||
return a === 'authoritative' ? 'badge-ok' : a === 'reference' ? 'badge-warn' : 'badge-neutral';
|
||||
}
|
||||
|
||||
export function KnowledgePage(): JSX.Element {
|
||||
const { show } = useToast();
|
||||
|
||||
const [items, setItems] = useState<KnowledgeItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [query, setQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<'all' | KnowledgeCategory>('all');
|
||||
|
||||
// 录入表单
|
||||
const [category, setCategory] = useState<KnowledgeCategory>('guideline');
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [keywords, setKeywords] = useState('');
|
||||
const [source, setSource] = useState('');
|
||||
const [authority, setAuthority] = useState<AuthorityLevel>('reference');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 问答测试
|
||||
const [testQ, setTestQ] = useState('');
|
||||
const [testAnswer, setTestAnswer] = useState<QaAnswer | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.listKnowledge({
|
||||
q: query.trim() || undefined,
|
||||
category: categoryFilter === 'all' ? undefined : categoryFilter,
|
||||
})
|
||||
.then(setItems)
|
||||
.catch((err) => show(err instanceof ApiError ? err.message : '加载知识库失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [query, categoryFilter, show]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function create(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || !content.trim() || !source.trim()) {
|
||||
show('标题、内容、来源均为必填');
|
||||
return;
|
||||
}
|
||||
const input: CreateKnowledgeInput = {
|
||||
category,
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
keywords: keywords
|
||||
.split(/[,,\s]+/)
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean),
|
||||
source: source.trim(),
|
||||
authority,
|
||||
};
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.createKnowledge(input);
|
||||
show('知识条目已录入');
|
||||
setTitle('');
|
||||
setContent('');
|
||||
setKeywords('');
|
||||
setSource('');
|
||||
load();
|
||||
} catch (err) {
|
||||
show(err instanceof ApiError ? err.message : '录入失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTest(): Promise<void> {
|
||||
if (!testQ.trim()) return;
|
||||
setTesting(true);
|
||||
try {
|
||||
setTestAnswer(await api.askKnowledge(testQ.trim()));
|
||||
} catch (err) {
|
||||
show(err instanceof ApiError ? err.message : '测试失败');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<div>
|
||||
<h1 style={{ fontSize: 'var(--font-xxl)' }}>知识库管理</h1>
|
||||
<p className="muted">维护孕妇端问答的内容来源。每条须标注来源与权威级别,问答严格溯源、不超纲。</p>
|
||||
</div>
|
||||
|
||||
<div className="knowledge-grid">
|
||||
{/* 录入 + 测试 */}
|
||||
<div className="stack">
|
||||
<form className="card" onSubmit={create}>
|
||||
<div className="card-header">录入知识条目</div>
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label>分类</label>
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value as KnowledgeCategory)}>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>权威级别</label>
|
||||
<select value={authority} onChange={(e) => setAuthority(e.target.value as AuthorityLevel)}>
|
||||
{AUTHORITIES.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>标题</label>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="如:妊娠期糖尿病饮食管理" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>内容</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="条目正文(将作为问答依据被引用)"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>关键词(逗号或空格分隔)</label>
|
||||
<input value={keywords} onChange={(e) => setKeywords(e.target.value)} placeholder="血糖, 糖尿病, 饮食" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>来源(必填)</label>
|
||||
<input value={source} onChange={(e) => setSource(e.target.value)} placeholder="如:XX 临床指南 2024" />
|
||||
</div>
|
||||
<button className="btn btn-primary" type="submit" disabled={saving}>
|
||||
{saving ? '保存中…' : '录入条目'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">问答测试</div>
|
||||
<p className="muted" style={{ marginBottom: 'var(--space-3)' }}>
|
||||
验证知识是否能被检索并正确溯源(与孕妇端一致)。
|
||||
</p>
|
||||
<div className="row" style={{ marginBottom: 'var(--space-3)' }}>
|
||||
<input
|
||||
style={{ flex: 1, padding: '9px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
|
||||
value={testQ}
|
||||
onChange={(e) => setTestQ(e.target.value)}
|
||||
placeholder="输入一个问题,如:孕期血糖高怎么吃"
|
||||
onKeyDown={(e) => e.key === 'Enter' && runTest()}
|
||||
/>
|
||||
<button className="btn btn-calm" onClick={runTest} disabled={testing} type="button">
|
||||
{testing ? '测试中…' : '测试'}
|
||||
</button>
|
||||
</div>
|
||||
{testAnswer && (
|
||||
<div className={`knowledge-answer${testAnswer.grounded ? '' : ' is-empty'}`}>
|
||||
<p>{testAnswer.answer}</p>
|
||||
{!testAnswer.grounded && <p className="muted">(无依据,孕妇端将提示就医,不会超纲作答)</p>}
|
||||
{testAnswer.citations.length > 0 && (
|
||||
<div className="knowledge-citations">
|
||||
{testAnswer.citations.map((c) => (
|
||||
<div key={c.id} className="row">
|
||||
<span className={`badge ${authorityBadge(c.authority)}`}>
|
||||
{authorityLabel(c.authority)}
|
||||
</span>
|
||||
<span>
|
||||
{c.title} <span className="muted">· {c.source}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span>知识条目({items.length})</span>
|
||||
</div>
|
||||
<div className="row" style={{ marginBottom: 'var(--space-4)' }}>
|
||||
<input
|
||||
placeholder="搜索标题/内容/关键词…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{ flex: 1, padding: '8px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
|
||||
/>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value as 'all' | KnowledgeCategory)}
|
||||
style={{ padding: '8px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
|
||||
>
|
||||
<option value="all">全部分类</option>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="empty">加载中…</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="empty">暂无知识条目,先录入第一条吧。</p>
|
||||
) : (
|
||||
<div className="knowledge-list">
|
||||
{items.map((it) => (
|
||||
<div key={it.id} className="knowledge-item">
|
||||
<div className="spread">
|
||||
<span className="knowledge-item__title">{it.title}</span>
|
||||
<span className={`badge ${authorityBadge(it.authority)}`}>
|
||||
{authorityLabel(it.authority)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="knowledge-item__content muted">{it.content}</p>
|
||||
<div className="knowledge-item__meta">
|
||||
<span className="badge badge-neutral">{categoryLabel(it.category)}</span>
|
||||
{it.keywords.map((k) => (
|
||||
<span key={k} className="knowledge-kw">
|
||||
#{k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="knowledge-item__source muted">
|
||||
来源:{it.source} · {formatTime(it.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
.login {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #eef2f7, #f7eef3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.login__panel {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
.login__brand {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.login__logo {
|
||||
font-size: 44px;
|
||||
}
|
||||
.login__brand h1 {
|
||||
font-size: var(--font-xl);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.login__tabs {
|
||||
display: flex;
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.login__tabs button {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-soft);
|
||||
}
|
||||
.login__tabs button.is-active {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-primary-strong);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.login__hint {
|
||||
margin-top: var(--space-4);
|
||||
font-size: var(--font-xs);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login__demo-panel {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.login__demo-title {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 600;
|
||||
color: #2b6cb0;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.login__demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.login__demo-grid .btn {
|
||||
padding: 6px;
|
||||
font-size: var(--font-xs);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login__demo-desc {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
|
||||
/* SVG 图标 */
|
||||
.login__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.login__demo-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Lightbulb, Stethoscope } from 'lucide-react';
|
||||
import { useAuth, type StaffRole } from '../auth/AuthContext';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { ApiError } from '../api/client';
|
||||
import './LoginPage.css';
|
||||
|
||||
type Mode = 'login' | 'register';
|
||||
|
||||
export function LoginPage(): JSX.Element {
|
||||
const { login, register } = useAuth();
|
||||
const { show } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [mode, setMode] = useState<Mode>('login');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState<StaffRole>('case_manager');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (!username.trim() || !password) {
|
||||
show('请填写用户名和密码');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (mode === 'login') {
|
||||
await login(username.trim(), password);
|
||||
} else {
|
||||
await register({ username: username.trim(), password, role });
|
||||
}
|
||||
navigate('/worklist', { replace: true });
|
||||
} catch (err) {
|
||||
show(err instanceof ApiError ? err.message : err instanceof Error ? err.message : '操作失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function fillDemoUser(usernameVal: string, passwordVal: string, roleVal: StaffRole) {
|
||||
setUsername(usernameVal);
|
||||
setPassword(passwordVal);
|
||||
setRole(roleVal);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="login__panel">
|
||||
<div className="login__brand">
|
||||
<span className="login__logo">
|
||||
<Stethoscope size={40} strokeWidth={1.5} />
|
||||
</span>
|
||||
<h1>PCM 个案工作台</h1>
|
||||
<p className="muted">孕产个案管理 · 医护端</p>
|
||||
</div>
|
||||
|
||||
<div className="login__tabs">
|
||||
<button
|
||||
className={mode === 'login' ? 'is-active' : ''}
|
||||
onClick={() => setMode('login')}
|
||||
type="button"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
className={mode === 'register' ? 'is-active' : ''}
|
||||
onClick={() => setMode('register')}
|
||||
type="button"
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<label>用户名</label>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入工号 / 用户名"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
/>
|
||||
</div>
|
||||
{mode === 'register' && (
|
||||
<div className="field">
|
||||
<label>角色</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as StaffRole)}
|
||||
>
|
||||
<option value="case_manager">个案管理师</option>
|
||||
<option value="physician">医生</option>
|
||||
<option value="operator">运营</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-primary btn-block" type="submit" disabled={submitting}>
|
||||
{submitting ? '请稍候…' : mode === 'login' ? '登录' : '注册并进入'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login__demo-panel">
|
||||
<p className="login__demo-title">
|
||||
<Lightbulb size={16} strokeWidth={1.75} /> 测试快捷填充
|
||||
</p>
|
||||
<div className="login__demo-grid">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => fillDemoUser('test_manager_01', '12345678', 'case_manager')}
|
||||
>
|
||||
个案管理师
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => fillDemoUser('test_doctor_01', '12345678', 'physician')}
|
||||
>
|
||||
医生
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => fillDemoUser('test_operator_01', '12345678', 'operator')}
|
||||
>
|
||||
运营管理
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => fillDemoUser('test_admin_01', '12345678', 'admin')}
|
||||
>
|
||||
管理员
|
||||
</button>
|
||||
</div>
|
||||
<p className="login__demo-desc">
|
||||
* 本地内存数据库重启后将清空。若登录提示用户名不存在,请在上方先切换到「注册」页点击一键填充对应角色并注册后再进行登录。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="muted login__hint">仅限医护/运营人员使用。患者及家属请使用孕妇端应用。</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { useAutoRefresh } from '../lib/useAutoRefresh';
|
||||
import type { PatientSummary, RiskLevel } from '../api/types';
|
||||
import { riskBadgeClass, riskLabel } from '../lib/format';
|
||||
|
||||
const RISK_ORDER: Record<RiskLevel, number> = { high: 0, medium: 1, low: 2 };
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export function WorklistPage(): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const { show } = useToast();
|
||||
const [patients, setPatients] = useState<PatientSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [query, setQuery] = useState('');
|
||||
const [riskFilter, setRiskFilter] = useState<'all' | RiskLevel>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const load = useCallback(
|
||||
(silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
api
|
||||
.listPatients()
|
||||
.then(setPatients)
|
||||
.catch((err) => {
|
||||
if (!silent) show(err instanceof ApiError ? err.message : '加载孕妇列表失败');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[show],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// 多端一致:新建档/风险变化近实时反映到工作列表
|
||||
useAutoRefresh(() => load(true));
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim();
|
||||
return patients
|
||||
.filter((p) => (riskFilter === 'all' ? true : p.initialRiskLevel === riskFilter))
|
||||
.filter((p) => (q ? p.name.includes(q) || (p.patientNo ?? '').toUpperCase().includes(q.toUpperCase()) : true))
|
||||
.sort((a, b) => RISK_ORDER[a.initialRiskLevel] - RISK_ORDER[b.initialRiskLevel]);
|
||||
}, [patients, query, riskFilter]);
|
||||
|
||||
// 过滤条件变化时回到第一页
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [query, riskFilter]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageRows = rows.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return {
|
||||
total: patients.length,
|
||||
high: patients.filter((p) => p.initialRiskLevel === 'high').length,
|
||||
medium: patients.filter((p) => p.initialRiskLevel === 'medium').length,
|
||||
};
|
||||
}, [patients]);
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<div className="spread">
|
||||
<div>
|
||||
<h1 style={{ fontSize: 'var(--font-xxl)' }}>工作列表</h1>
|
||||
<p className="muted">在管孕妇 {stats.total} 人 · 高风险 {stats.high} · 中风险 {stats.medium}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="row" style={{ marginBottom: 'var(--space-4)' }}>
|
||||
<input
|
||||
placeholder="搜索姓名 / 编号…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{ maxWidth: 260, padding: '8px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
|
||||
/>
|
||||
<select
|
||||
value={riskFilter}
|
||||
onChange={(e) => setRiskFilter(e.target.value as 'all' | RiskLevel)}
|
||||
style={{ padding: '8px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
|
||||
>
|
||||
<option value="all">全部风险</option>
|
||||
<option value="high">高风险</option>
|
||||
<option value="medium">中风险</option>
|
||||
<option value="low">低风险</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="empty">加载中…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="empty">暂无匹配的孕妇。建档后将出现在此列表。</p>
|
||||
) : (
|
||||
<>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>编号</th>
|
||||
<th>姓名</th>
|
||||
<th>年龄</th>
|
||||
<th>孕周</th>
|
||||
<th>分期</th>
|
||||
<th>初始风险</th>
|
||||
<th>风险因素</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageRows.map((p) => (
|
||||
<tr key={p.id} className="clickable" onClick={() => navigate(`/patients/${p.id}`)}>
|
||||
<td style={{ fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 'var(--font-sm)' }}>
|
||||
{p.patientNo || '—'}
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>{p.name}</td>
|
||||
<td>{p.age}</td>
|
||||
<td>
|
||||
{p.gestationalWeeks}周{p.gestationalDays}天
|
||||
</td>
|
||||
<td>{trimesterLabel(p.trimester)}</td>
|
||||
<td>
|
||||
<span className={`badge ${riskBadgeClass(p.initialRiskLevel)}`}>
|
||||
{riskLabel(p.initialRiskLevel)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted">{p.initialRiskFactors.join('、') || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="pager">
|
||||
<span className="muted">
|
||||
共 {rows.length} 条 · 第 {currentPage}/{totalPages} 页
|
||||
</span>
|
||||
<div className="pager__btns">
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage <= 1}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft size={16} strokeWidth={1.9} /> 上一页
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage >= totalPages}
|
||||
type="button"
|
||||
>
|
||||
下一页 <ChevronRight size={16} strokeWidth={1.9} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function trimesterLabel(t: string): string {
|
||||
return { first: '孕早期', second: '孕中期', third: '孕晚期' }[t] ?? t;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
@import './tokens.css';
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
font-size: var(--font-md);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: var(--font-md);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-md);
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.btn:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
.btn-calm {
|
||||
background: var(--color-calm);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
.btn-ghost {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 5px 12px;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 表单 */
|
||||
.field {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-soft);
|
||||
margin-bottom: var(--space-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-calm);
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-md);
|
||||
}
|
||||
.table th {
|
||||
text-align: left;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-soft);
|
||||
font-weight: 600;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.table tr.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.table tr.clickable:hover td {
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
/* 徽章 */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-ok {
|
||||
background: var(--color-ok-soft);
|
||||
color: var(--color-ok);
|
||||
}
|
||||
.badge-warn {
|
||||
background: var(--color-warn-soft);
|
||||
color: var(--color-warn);
|
||||
}
|
||||
.badge-danger {
|
||||
background: var(--color-danger-soft);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.badge-neutral {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-soft);
|
||||
}
|
||||
.badge-info {
|
||||
background: var(--color-calm-soft);
|
||||
color: var(--color-calm);
|
||||
}
|
||||
|
||||
/* 辅助 */
|
||||
.muted {
|
||||
color: var(--color-text-soft);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.spread {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.empty {
|
||||
color: var(--color-text-soft);
|
||||
font-size: var(--font-sm);
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
right: 24px;
|
||||
background: var(--color-text);
|
||||
color: #fff;
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-sm);
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow-pop);
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
/* 列表分页 */
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: var(--space-4);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.pager__btns {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.pager .btn-sm {
|
||||
gap: 4px;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* PCM Design Tokens · 医护端(专业工作台)
|
||||
* 依据 3-ui-style-PCM.md §8:复用品牌主色与字体,但采用信息密度高的专业中性风。
|
||||
*/
|
||||
:root {
|
||||
/* 中性专业底色 */
|
||||
--color-bg: #f4f6f9;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-2: #fafbfc;
|
||||
--color-sidebar: #1f2733;
|
||||
--color-sidebar-soft: #2a3441;
|
||||
|
||||
/* 品牌主色(与孕妇端共享) */
|
||||
--color-primary: #ec6f9e;
|
||||
--color-primary-strong: #d9568a;
|
||||
--color-primary-soft: #fdeaf2;
|
||||
--color-calm: #2f6fb0; /* 专业蓝,强调交互 */
|
||||
--color-calm-soft: #e8f1fb;
|
||||
|
||||
/* 语义色(临床分级) */
|
||||
--color-ok: #2e9e6b;
|
||||
--color-ok-soft: #e4f5ec;
|
||||
--color-warn: #c9821f;
|
||||
--color-warn-soft: #fbf0d9;
|
||||
--color-danger: #d2493a;
|
||||
--color-danger-soft: #fbe4e0;
|
||||
|
||||
/* 文本 */
|
||||
--color-text: #1f2733;
|
||||
--color-text-soft: #6b7682;
|
||||
--color-text-faint: #9aa4af;
|
||||
--color-text-inverse: #ffffff;
|
||||
--color-border: #e4e8ee;
|
||||
--color-border-strong: #d2d8e0;
|
||||
|
||||
/* 圆角(专业风:中等圆角) */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* 间距 */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 24px;
|
||||
--space-6: 32px;
|
||||
|
||||
/* 字号(信息密度高) */
|
||||
--font-xs: 12px;
|
||||
--font-sm: 13px;
|
||||
--font-md: 14px;
|
||||
--font-lg: 16px;
|
||||
--font-xl: 20px;
|
||||
--font-xxl: 26px;
|
||||
|
||||
--shadow-card: 0 1px 3px rgba(31, 39, 51, 0.08);
|
||||
--shadow-pop: 0 8px 24px rgba(31, 39, 51, 0.16);
|
||||
|
||||
--sidebar-width: 220px;
|
||||
--topbar-height: 56px;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/types.ts","./src/auth/authcontext.tsx","./src/auth/session.ts","./src/components/layout.tsx","./src/components/toast.tsx","./src/components/workbench/alertspanel.tsx","./src/components/workbench/careplanpanel.tsx","./src/components/workbench/caseflowpanel.tsx","./src/components/workbench/caseguidepanel.tsx","./src/components/workbench/casetimeline.tsx","./src/components/workbench/observationspanel.tsx","./src/components/workbench/recommendationpanel.tsx","./src/components/workbench/redflagpanel.tsx","./src/components/workbench/reminderspanel.tsx","./src/lib/format.ts","./src/lib/rbac.ts","./src/lib/useautorefresh.ts","./src/pages/auditpage.tsx","./src/pages/caseworkbenchpage.tsx","./src/pages/knowledgepage.tsx","./src/pages/loginpage.tsx","./src/pages/worklistpage.tsx"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// 医护端开发服务器:将 /api 代理到本地 NestJS 后端(默认 3000)。
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.PCM_API_TARGET ?? 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user