Initial commit: HealthCarePregnant project documentation and platform

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
selfrelease
2026-06-18 09:48:05 +08:00
commit eab91174db
301 changed files with 42491 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
.env
.env.local
*.log
.git
.DS_Store
Dockerfile
.dockerignore
+16
View File
@@ -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: '^_' }],
},
};
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
dist-ssr
*.local
.DS_Store
.vite
+12
View File
@@ -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
+71
View File
@@ -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`
+12
View File
@@ -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>
+20
View File
@@ -0,0 +1,20 @@
# 医护/运营端 nginxSPA 客户端路由回退 + /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;
}
}
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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"
}
}
+82
View File
@@ -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>
);
}
+221
View File
@@ -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}`),
};
+355
View File
@@ -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);
}
}
+131
View File
@@ -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) ?? '';
}
+44
View File
@@ -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]);
}
+10
View File
@@ -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
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+21
View File
@@ -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"}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+15
View File
@@ -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"}
+16
View File
@@ -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,
},
},
},
});