feat(workbench): 全面优化个案工作台页面体验

性能与加载:
- 分阶段加载:档案头、个案数据、档案数据各自独立加载,骨架屏替代 spinner
- PatientHeadSkeleton / WorkbenchSkeleton / ArchiveSkeleton 三套骨架
- 档案库懒加载(切换 Tab 时才请求)

交互增强:
- Alert 卡片增加「标记已读」和「关闭」按钮,调用 acknowledgeAlert / resolveAlert API
- 态势感知 Detail 区支持同时展开多个类型(状态独立)
- StatCard 点击可折叠/展开,带 chevron 指示
- 待办列表增加分页(每页 8 条)

AI 建议:
- 从单一方案扩展为 3 套方案(保守 A / 常规 B / 积极 C)
- 支持切换标签,卡片内动画过渡
- 每套方案可一键生成对应处置单
- 预置空腹血糖、血压、体重等多套模板

指标卡片:
- Sparkline 增加阈值参考虚线(min/max)
- 超限高亮点 + 「超限」角标
- 档案库扩展为 8 个指标卡(新增舒张压、OGTT 1h/2h、餐后血糖)
- 阈值常量统一管理(THRESHOLDS Map)

分页:
- 档案库观测历史分页(每页 8 条)
- 待办列表分页

样式完善:
- badge-emerald / badge-rose / badge-indigo / badge-calm
- btn-xs / btn-success / btn-rose
- grid-4 / align-center / border-l-*
- 脉冲危急点动画、Tab 未读计数角标
- Alert 支持 updatedAt,severity 别名兼容后端字段

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
selfrelease
2026-06-18 10:13:57 +08:00
parent eab91174db
commit 8d1a535a63
7 changed files with 1653 additions and 906 deletions
+4
View File
@@ -93,6 +93,10 @@ export const api = {
listObservations: (patientId: string): Promise<Observation[]> =>
request(`/patients/${patientId}/observations`),
listAlerts: (patientId: string): Promise<Alert[]> => request(`/patients/${patientId}/alerts`),
acknowledgeAlert: (patientId: string, alertId: string): Promise<Alert> =>
request(`/patients/${patientId}/alerts/${alertId}/acknowledge`, { method: 'POST' }),
resolveAlert: (patientId: string, alertId: string): Promise<Alert> =>
request(`/patients/${patientId}/alerts/${alertId}/resolve`, { method: 'POST' }),
listReminders: (patientId: string): Promise<Reminder[]> =>
request(`/patients/${patientId}/reminders`),
+3
View File
@@ -64,10 +64,13 @@ export interface Alert {
indicator: string;
value: number;
level: RiskLevel;
/** severity 为 level 的别名,后端字段对齐 */
severity: RiskLevel;
ruleIds: string[];
messages: string[];
status: AlertStatus;
createdAt: string;
updatedAt: string;
}
// 个案
@@ -0,0 +1,22 @@
/* 骨架屏动画 */
@keyframes shimmer {
0% { background-position: -400px 0; }
100% { background-position: 400px 0; }
}
.skeleton-line {
display: inline-block;
background: linear-gradient(
90deg,
var(--color-border) 25%,
var(--color-bg) 50%,
var(--color-border) 75%
);
background-size: 800px 100%;
animation: shimmer 1.5s infinite linear;
vertical-align: middle;
}
.skeleton-root {
pointer-events: none;
}
@@ -0,0 +1,180 @@
/**
* 骨架屏组件 — 真实布局骨架,提升感知加载速度
* 替代整页 loading spinner,让用户"看到"即将呈现的内容结构
*/
import './Skeleton.css';
interface SkeletonLineProps {
width?: string;
height?: string;
radius?: string;
}
function SkeletonLine({ width = '100%', height = '14px', radius = '4px' }: SkeletonLineProps): JSX.Element {
return (
<span
className="skeleton-line"
style={{ width, height, borderRadius: radius }}
aria-hidden="true"
/>
);
}
function SkeletonBlock({ width = '100%', height = '80px' }: { width?: string; height?: string }): JSX.Element {
return (
<span
className="skeleton-line"
style={{ width, height, borderRadius: '8px' }}
aria-hidden="true"
/>
);
}
function SkeletonCircle({ size = '48px' }: { size?: string }): JSX.Element {
return (
<span
className="skeleton-line"
style={{ width: size, height: size, borderRadius: '50%' }}
aria-hidden="true"
/>
);
}
export function PatientHeadSkeleton(): JSX.Element {
return (
<div className="card patient-head skeleton-root">
<div className="patient-head__main">
<SkeletonCircle size="48px" />
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', alignItems: 'center' }}>
<SkeletonLine width="120px" height="18px" />
<SkeletonLine width="60px" height="18px" radius="12px" />
<SkeletonLine width="60px" height="18px" radius="12px" />
</div>
<SkeletonLine width="280px" height="12px" />
<div style={{ marginTop: '6px' }}>
<SkeletonLine width="200px" height="10px" />
</div>
</div>
</div>
</div>
);
}
export function WorkbenchSkeleton(): JSX.Element {
return (
<div className="stack">
{/* Tab 骨架 */}
<div className="skeleton-root" style={{ display: 'flex', gap: '4px', padding: '4px', borderRadius: '8px', background: 'var(--color-bg)', border: '1px solid var(--color-border)' }}>
<SkeletonLine width="160px" height="36px" radius="6px" />
<SkeletonLine width="180px" height="36px" radius="6px" />
</div>
{/* 态势感知双栏 */}
<div className="workbench-layout-container">
<div className="card shadow-sm workbench-master skeleton-root">
<SkeletonLine width="80px" height="14px" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', marginTop: '12px' }}>
{[1, 2, 3, 4].map((i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 12px', border: '1px solid var(--color-border)', borderRadius: '6px' }}>
<SkeletonLine width="80px" height="12px" />
<SkeletonLine width="40px" height="20px" />
</div>
))}
</div>
</div>
<div className="card shadow-sm workbench-detail skeleton-root">
<SkeletonLine width="100px" height="14px" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '12px' }}>
{[1, 2, 3].map((i) => (
<div key={i} style={{ padding: '8px 12px', border: '1px solid var(--color-border)', borderRadius: '6px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '6px' }}>
<SkeletonLine width="120px" height="12px" />
<SkeletonLine width="40px" height="18px" radius="10px" />
</div>
<SkeletonLine width="200px" height="10px" />
<SkeletonLine width="160px" height="10px" />
</div>
))}
</div>
</div>
</div>
{/* 流程卡片 */}
<div className="card skeleton-root">
<SkeletonLine width="80px" height="14px" />
<div style={{ display: 'flex', gap: '8px', marginTop: '12px', flexWrap: 'wrap' }}>
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<SkeletonLine key={i} width="60px" height="28px" radius="14px" />
))}
</div>
<SkeletonLine width="200px" height="12px" />
</div>
{/* 指标卡片网格 */}
<div className="grid-4" style={{ gap: 'var(--space-3)' }}>
{[1, 2, 3, 4].map((i) => (
<div key={i} className="card skeleton-root">
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<SkeletonLine width="70px" height="12px" />
<SkeletonLine width="40px" height="18px" radius="8px" />
</div>
<SkeletonLine width="100px" height="24px" />
<div style={{ marginTop: '12px' }}>
<SkeletonLine width="160px" height="40px" />
</div>
</div>
))}
</div>
{/* 双栏 */}
<div className="grid-2 workbench-grid">
<div className="card skeleton-root">
<SkeletonLine width="120px" height="14px" />
<SkeletonBlock height="120px" />
</div>
<div className="card skeleton-root">
<SkeletonLine width="120px" height="14px" />
<SkeletonBlock height="120px" />
</div>
</div>
</div>
);
}
export function ArchiveSkeleton(): JSX.Element {
return (
<div className="stack">
<div className="grid-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="card skeleton-root">
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<SkeletonLine width="80px" height="12px" />
<SkeletonLine width="36px" height="16px" radius="8px" />
</div>
<SkeletonLine width="60px" height="22px" />
<SkeletonLine width="160px" height="36px" />
</div>
))}
</div>
<div className="grid-2">
<div className="card skeleton-root">
<SkeletonLine width="140px" height="14px" />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px', marginTop: '12px' }}>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i}>
<SkeletonLine width="60px" height="10px" />
<SkeletonLine width="80px" height="12px" />
</div>
))}
</div>
</div>
<div className="card skeleton-root">
<SkeletonLine width="180px" height="14px" />
<SkeletonBlock height="100px" />
</div>
</div>
</div>
);
}
@@ -592,8 +592,118 @@
}
}
@media (max-width: 980px) {
.workbench-grid {
grid-template-columns: 1fr;
}
/* AI 建议多方案切换 */
.ai-variant-tabs {
display: flex;
gap: 6px;
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.ai-variant-tab {
padding: 5px 12px;
border: 1.5px solid var(--color-border-strong);
border-radius: var(--radius-pill);
background: var(--color-bg);
font-size: var(--font-xs);
font-weight: 600;
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.ai-variant-tab:hover {
border-color: var(--color-primary);
color: var(--color-primary-strong);
background: var(--color-primary-soft);
}
.ai-variant-tab.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
font-weight: 700;
box-shadow: 0 2px 8px rgba(220, 86, 138, 0.3);
}
.ai-variant-detail {
animation: fadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* 分页 */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
margin-top: var(--space-3);
font-size: var(--font-xs);
color: var(--color-text-muted);
}
.pagination button {
display: flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 6px;
}
/* 脉冲危急点 */
@keyframes pulse-alert {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(0.85); }
}
.pulse-dot {
animation: pulse-alert 1.5s ease-in-out infinite;
}
/* 告警卡操作按钮 */
.alert-item-card .btn-xs {
padding: 2px 4px;
min-width: 22px;
height: 22px;
color: var(--color-text-muted);
}
.alert-item-card .btn-xs:hover {
color: var(--color-primary-strong);
background: var(--color-primary-soft);
}
/* 舒张压卡片单独显示 */
.diagostic-indicator-row {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--space-3);
}
/* Tab badge */
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
margin-left: 4px;
background: var(--color-danger);
color: #fff;
font-size: 10px;
font-weight: 700;
border-radius: 9px;
animation: pulse-alert 2s ease-in-out infinite;
}
/* 细化 detail-section 动画 */
.detail-section {
animation: fadeIn 0.2s ease both;
}
/* dispose-status badge 扩展 */
.disp-status-badge.pending_confirmation { background: rgba(201,130,31,0.12); color: var(--color-warn); border: 1px solid rgba(201,130,31,0.3); padding: 2px 8px; border-radius: var(--radius-sm); font-size: var(--font-xs); font-weight: 700; }
.disp-status-badge.executing { background: rgba(47,111,176,0.1); color: var(--color-calm); border: 1px solid rgba(47,111,176,0.3); padding: 2px 8px; border-radius: var(--radius-sm); font-size: var(--font-xs); font-weight: 700; }
.disp-status-badge.closed { background: var(--color-ok-soft); color: var(--color-ok); border: 1px solid rgba(46,158,107,0.3); padding: 2px 8px; border-radius: var(--radius-sm); font-size: var(--font-xs); font-weight: 700; }
/* justify-center */
.justify-center { justify-content: center; }
@media (max-width: 980px) {
.grid-4 { grid-template-columns: repeat(2, 1fr) !important; }
.indicators-summary-grid { grid-template-columns: repeat(2, 1fr) !important; }
}
File diff suppressed because it is too large Load Diff
@@ -100,6 +100,19 @@ h3 {
.btn-block {
width: 100%;
}
.btn-xs {
padding: 3px 8px;
font-size: 11px;
border-radius: var(--radius-sm);
}
.btn-success {
background: var(--color-ok);
color: #fff;
}
.btn-rose {
background: var(--color-danger);
color: #fff;
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
@@ -187,6 +200,22 @@ h3 {
background: var(--color-calm-soft);
color: var(--color-calm);
}
.badge-emerald {
background: var(--color-calm-soft);
color: var(--color-calm);
}
.badge-rose {
background: var(--color-danger-soft);
color: var(--color-danger);
}
.badge-indigo {
background: var(--color-primary-soft);
color: var(--color-primary-strong);
}
.badge-calm {
background: var(--color-calm-soft);
color: var(--color-calm);
}
/* 辅助 */
.muted {
@@ -212,6 +241,14 @@ h3 {
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
.grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-3);
}
.align-center {
align-items: center;
}
.empty {
color: var(--color-text-soft);
font-size: var(--font-sm);
@@ -247,3 +284,49 @@ h3 {
.pager .btn-sm {
gap: 4px;
}
/* 实用工具 */
.text-right { text-align: right; }
.shadow-sm { box-shadow: 0 1px 3px rgba(31, 39, 51, 0.08); }
.shadow { box-shadow: var(--shadow-card); }
.text-center { text-align: center; }
.text-left { text-align: left; }
.border-l-indigo { border-left: 3px solid var(--color-primary); }
.border-l-emerald { border-left: 3px solid var(--color-calm); }
.border-l-rose { border-left: 3px solid var(--color-danger); }
.bg-light-gradient { background: linear-gradient(135deg, #fff 0%, #faf7fc 100%); }
.bg-light { background: var(--color-surface-2); }
.shadow-pop { box-shadow: var(--shadow-pop); }
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(31, 39, 51, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-panel {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: var(--space-6);
min-width: 360px;
max-width: 560px;
box-shadow: var(--shadow-pop);
}
/* Input variants */
.textarea {
width: 100%;
padding: 9px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-strong);
background: var(--color-surface);
resize: vertical;
font-family: inherit;
font-size: var(--font-md);
}
.textarea:focus {
outline: none;
border-color: var(--color-calm);
}