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:
@@ -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`),
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user