Initial commit: HealthCarePregnant project documentation and platform
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user