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
@@ -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;
}