chore: 设计规格文档 + 销售数据 + 脚本工具 + 根目录 monorepo 配置
- docs/: 设计规格、讨论记录、销售数据、健康管理文档 - scripts/: 辅助脚本 - package.json + pnpm-lock.yaml: monorepo 根配置
This commit is contained in:
317
docs/design/miniprogram/design-system.html
Normal file
317
docs/design/miniprogram/design-system.html
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>HMS 设计系统 · 温润东方风</title>
|
||||||
|
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, 'PingFang SC', 'Helvetica Neue', sans-serif; background: #F5F0EB; color: #2D2A26; line-height: 1.6; }
|
||||||
|
:root {
|
||||||
|
--bg: #F5F0EB;
|
||||||
|
--surface: #FFFFFF;
|
||||||
|
--surface-alt: #EDE8E2;
|
||||||
|
--text-primary: #2D2A26;
|
||||||
|
--text-secondary: #7A756E;
|
||||||
|
--text-tertiary: #A8A29E;
|
||||||
|
--accent: #C4623A;
|
||||||
|
--accent-light: #F0DDD4;
|
||||||
|
--accent-dark: #8B3E1F;
|
||||||
|
--success: #5B7A5E;
|
||||||
|
--success-light: #E8F0E8;
|
||||||
|
--warning: #C4873A;
|
||||||
|
--warning-light: #FFF3E0;
|
||||||
|
--danger: #B54A4A;
|
||||||
|
--danger-light: #FDEAEA;
|
||||||
|
--border: #E8E2DC;
|
||||||
|
--border-light: #F0EBE5;
|
||||||
|
--shadow: 0 1px 4px rgba(45,42,38,0.06);
|
||||||
|
--shadow-md: 0 4px 16px rgba(45,42,38,0.08);
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
const dsStyles = {
|
||||||
|
page: { maxWidth: 1200, margin: '0 auto', padding: '60px 40px' },
|
||||||
|
title: { fontFamily: '"Noto Serif SC", serif', fontSize: 36, fontWeight: 700, color: '#2D2A26', marginBottom: 8 },
|
||||||
|
subtitle: { fontSize: 16, color: '#7A756E', marginBottom: 48, letterSpacing: '0.02em' },
|
||||||
|
section: { marginBottom: 56 },
|
||||||
|
sectionTitle: { fontFamily: '"Noto Serif SC", serif', fontSize: 22, fontWeight: 600, color: '#2D2A26', marginBottom: 24, paddingBottom: 12, borderBottom: '1px solid #E8E2DC' },
|
||||||
|
row: { display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 16 },
|
||||||
|
card: { background: '#fff', borderRadius: 12, padding: 24, boxShadow: '0 1px 4px rgba(45,42,38,0.06)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ─── 色彩系统 ─── */
|
||||||
|
const ColorSwatch = ({ name, hex, desc, light }) => (
|
||||||
|
<div style={{ flex: '0 0 160px' }}>
|
||||||
|
<div style={{
|
||||||
|
height: 80, borderRadius: 12, background: hex,
|
||||||
|
border: light ? '1px solid #E8E2DC' : 'none',
|
||||||
|
marginBottom: 8, boxShadow: hex === '#FFFFFF' ? '0 1px 4px rgba(0,0,0,0.08)' : 'none',
|
||||||
|
}} />
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: '#2D2A26' }}>{name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#7A756E', fontFamily: 'monospace' }}>{hex}</div>
|
||||||
|
{desc && <div style={{ fontSize: 11, color: '#A8A29E', marginTop: 2 }}>{desc}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ColorSection = () => {
|
||||||
|
const colors = [
|
||||||
|
{ name: '背景', hex: '#F5F0EB', desc: 'warm cream', light: true },
|
||||||
|
{ name: '卡片', hex: '#FFFFFF', desc: 'surface', light: true },
|
||||||
|
{ name: '辅助底', hex: '#EDE8E2', desc: 'surface-alt', light: true },
|
||||||
|
{ name: '主文字', hex: '#2D2A26', desc: 'warm black' },
|
||||||
|
{ name: '次文字', hex: '#7A756E', desc: 'warm gray' },
|
||||||
|
{ name: '淡文字', hex: '#A8A29E', desc: 'tertiary' },
|
||||||
|
{ name: '边框', hex: '#E8E2DC', desc: 'border', light: true },
|
||||||
|
];
|
||||||
|
const accent = [
|
||||||
|
{ name: '强调色', hex: '#C4623A', desc: 'terracotta' },
|
||||||
|
{ name: '强调浅', hex: '#F0DDD4', desc: 'accent-light', light: true },
|
||||||
|
{ name: '强调深', hex: '#8B3E1F', desc: 'accent-dark' },
|
||||||
|
];
|
||||||
|
const status = [
|
||||||
|
{ name: '成功', hex: '#5B7A5E', desc: 'sage green' },
|
||||||
|
{ name: '警告', hex: '#C4873A', desc: 'warm amber' },
|
||||||
|
{ name: '危险', hex: '#B54A4A', desc: 'muted red' },
|
||||||
|
{ name: '成功浅', hex: '#E8F0E8', desc: 'success-light', light: true },
|
||||||
|
{ name: '警告浅', hex: '#FFF3E0', desc: 'warning-light', light: true },
|
||||||
|
{ name: '危险浅', hex: '#FDEAEA', desc: 'danger-light', light: true },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div style={dsStyles.section}>
|
||||||
|
<div style={dsStyles.sectionTitle}>色彩</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#7A756E', marginBottom: 16 }}>
|
||||||
|
温润米底 + 赤土橙贯穿全场。单一 accent 不多色。色值从 oklch 定义保证和谐。
|
||||||
|
</div>
|
||||||
|
<div style={dsStyles.row}>
|
||||||
|
{colors.map(c => <ColorSwatch key={c.hex} {...c} />)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#2D2A26', margin: '20px 0 12px' }}>强调色</div>
|
||||||
|
<div style={dsStyles.row}>
|
||||||
|
{accent.map(c => <ColorSwatch key={c.hex} {...c} />)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#2D2A26', margin: '20px 0 12px' }}>状态色</div>
|
||||||
|
<div style={dsStyles.row}>
|
||||||
|
{status.map(c => <ColorSwatch key={c.hex} {...c} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ─── 字体系统 ─── */
|
||||||
|
const TypographySection = () => {
|
||||||
|
const sizes = [
|
||||||
|
{ name: '大标题', style: { fontFamily: '"Noto Serif SC", serif', fontSize: 28, fontWeight: 700 } },
|
||||||
|
{ name: '标题', style: { fontFamily: '"Noto Serif SC", serif', fontSize: 22, fontWeight: 600 } },
|
||||||
|
{ name: '小标题', style: { fontFamily: '"Noto Serif SC", serif', fontSize: 17, fontWeight: 600 } },
|
||||||
|
{ name: '正文', style: { fontFamily: '-apple-system, "PingFang SC", sans-serif', fontSize: 15, fontWeight: 400 } },
|
||||||
|
{ name: '辅助文字', style: { fontFamily: '-apple-system, "PingFang SC", sans-serif', fontSize: 13, fontWeight: 400, color: '#7A756E' } },
|
||||||
|
{ name: '数据大号', style: { fontFamily: '"Noto Serif SC", serif', fontSize: 36, fontWeight: 700, color: '#C4623A' } },
|
||||||
|
{ name: '数据中号', style: { fontFamily: '"Noto Serif SC", serif', fontSize: 24, fontWeight: 600 } },
|
||||||
|
{ name: '数据单位', style: { fontFamily: '-apple-system, "PingFang SC", sans-serif', fontSize: 13, fontWeight: 400, color: '#A8A29E' } },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div style={dsStyles.section}>
|
||||||
|
<div style={dsStyles.sectionTitle}>字体</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#7A756E', marginBottom: 20 }}>
|
||||||
|
衬线 display (Noto Serif SC) 用于标题和数据数字,系统无衬线用于正文。字重对比鲜明。
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#fff', borderRadius: 12, padding: 28, boxShadow: '0 1px 4px rgba(45,42,38,0.06)' }}>
|
||||||
|
{sizes.map(s => (
|
||||||
|
<div key={s.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', padding: '12px 0', borderBottom: '1px solid #F0EBE5' }}>
|
||||||
|
<div style={s.style}>{s.name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#A8A29E', fontFamily: 'monospace' }}>
|
||||||
|
{s.style.fontSize}px / {s.style.fontWeight} {s.style.fontFamily?.includes('Serif') ? '· serif' : '· sans'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ─── 间距与圆角 ─── */
|
||||||
|
const SpacingSection = () => {
|
||||||
|
const spacings = [4, 8, 12, 16, 20, 24, 32, 40, 48];
|
||||||
|
const radii = [
|
||||||
|
{ name: 'sm', value: 8 },
|
||||||
|
{ name: 'md', value: 12 },
|
||||||
|
{ name: 'lg', value: 16 },
|
||||||
|
{ name: 'xl', value: 20 },
|
||||||
|
{ name: 'pill', value: 999 },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div style={dsStyles.section}>
|
||||||
|
<div style={dsStyles.sectionTitle}>间距与圆角</div>
|
||||||
|
<div style={{ display: 'flex', gap: 32, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 13, color: '#7A756E', marginBottom: 12 }}>间距基准 4px</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
|
||||||
|
{spacings.map(s => (
|
||||||
|
<div key={s} style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ width: s, height: s, background: '#C4623A', borderRadius: 2, opacity: 0.6 }} />
|
||||||
|
<div style={{ fontSize: 10, color: '#A8A29E', marginTop: 4 }}>{s}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 13, color: '#7A756E', marginBottom: 12 }}>圆角</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||||
|
{radii.map(r => (
|
||||||
|
<div key={r.name} style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ width: 48, height: 48, border: '2px solid #C4623A', borderRadius: r.value === 999 ? 24 : r.value }} />
|
||||||
|
<div style={{ fontSize: 10, color: '#A8A29E', marginTop: 4 }}>{r.name} ({r.value})</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ─── 组件库 ─── */
|
||||||
|
const ComponentSection = () => {
|
||||||
|
const buttonStyles = {
|
||||||
|
row: { display: 'flex', gap: 12, alignItems: 'center', marginBottom: 16, flexWrap: 'wrap' },
|
||||||
|
primary: { padding: '10px 28px', background: '#C4623A', color: '#fff', border: 'none', borderRadius: 12, fontSize: 15, fontWeight: 600, cursor: 'pointer' },
|
||||||
|
secondary: { padding: '10px 28px', background: 'transparent', color: '#C4623A', border: '1.5px solid #C4623A', borderRadius: 12, fontSize: 15, fontWeight: 600, cursor: 'pointer' },
|
||||||
|
ghost: { padding: '10px 28px', background: 'transparent', color: '#7A756E', border: '1.5px solid #E8E2DC', borderRadius: 12, fontSize: 15, fontWeight: 500, cursor: 'pointer' },
|
||||||
|
disabled: { padding: '10px 28px', background: '#EDE8E2', color: '#A8A29E', border: 'none', borderRadius: 12, fontSize: 15, fontWeight: 500 },
|
||||||
|
small: { padding: '6px 16px', background: '#C4623A', color: '#fff', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagStyles = {
|
||||||
|
success: { display: 'inline-block', padding: '3px 10px', borderRadius: 6, fontSize: 12, fontWeight: 500, background: '#E8F0E8', color: '#5B7A5E' },
|
||||||
|
warning: { display: 'inline-block', padding: '3px 10px', borderRadius: 6, fontSize: 12, fontWeight: 500, background: '#FFF3E0', color: '#C4873A' },
|
||||||
|
danger: { display: 'inline-block', padding: '3px 10px', borderRadius: 6, fontSize: 12, fontWeight: 500, background: '#FDEAEA', color: '#B54A4A' },
|
||||||
|
default: { display: 'inline-block', padding: '3px 10px', borderRadius: 6, fontSize: 12, fontWeight: 500, background: '#F0EBE5', color: '#7A756E' },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={dsStyles.section}>
|
||||||
|
<div style={dsStyles.sectionTitle}>组件</div>
|
||||||
|
<div style={{ display: 'flex', gap: 24, flexDirection: 'column' }}>
|
||||||
|
|
||||||
|
{/* 按钮 */}
|
||||||
|
<div style={dsStyles.card}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: '#2D2A26', marginBottom: 12 }}>按钮</div>
|
||||||
|
<div style={buttonStyles.row}>
|
||||||
|
<button style={buttonStyles.primary}>主要操作</button>
|
||||||
|
<button style={buttonStyles.secondary}>次要操作</button>
|
||||||
|
<button style={buttonStyles.ghost}>取消</button>
|
||||||
|
<button style={buttonStyles.disabled}>禁用</button>
|
||||||
|
<button style={buttonStyles.small}>小按钮</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标签 */}
|
||||||
|
<div style={dsStyles.card}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: '#2D2A26', marginBottom: 12 }}>状态标签</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<span style={tagStyles.success}>正常</span>
|
||||||
|
<span style={tagStyles.warning}>偏高</span>
|
||||||
|
<span style={tagStyles.danger}>异常</span>
|
||||||
|
<span style={tagStyles.default}>待处理</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 数据卡片 */}
|
||||||
|
<div style={dsStyles.card}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: '#2D2A26', marginBottom: 12 }}>健康数据卡片</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
{[
|
||||||
|
{ label: '血压', value: '120/80', unit: 'mmHg', status: '正常', statusType: 'success' },
|
||||||
|
{ label: '心率', value: '72', unit: 'bpm', status: '正常', statusType: 'success' },
|
||||||
|
{ label: '血糖', value: '6.8', unit: 'mmol/L', status: '偏高', statusType: 'warning' },
|
||||||
|
].map(item => (
|
||||||
|
<div key={item.label} style={{ flex: 1, background: '#F5F0EB', borderRadius: 12, padding: 16, textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 13, color: '#7A756E', marginBottom: 8 }}>{item.label}</div>
|
||||||
|
<div style={{ fontFamily: '"Noto Serif SC", serif', fontSize: 28, fontWeight: 700, color: '#2D2A26' }}>{item.value}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#A8A29E', margin: '4px 0 8px' }}>{item.unit}</div>
|
||||||
|
<span style={tagStyles[item.statusType]}>{item.status}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 列表项 */}
|
||||||
|
<div style={dsStyles.card}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: '#2D2A26', marginBottom: 12 }}>列表项</div>
|
||||||
|
<div style={{ borderRadius: 12, border: '1px solid #E8E2DC', overflow: 'hidden' }}>
|
||||||
|
{[
|
||||||
|
{ title: '预约:2026-04-28 上午', sub: '王医生 · 心内科 · 待确认' },
|
||||||
|
{ title: '随访:血压监测', sub: '每日记录血压数据 · 截止 05-01' },
|
||||||
|
{ title: '报告:血常规检验', sub: '2026-04-25 · 已出结果' },
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: 'flex', alignItems: 'center', padding: '16px 20px',
|
||||||
|
borderBottom: i < 2 ? '1px solid #F0EBE5' : 'none',
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 500, color: '#2D2A26', marginBottom: 2 }}>{item.title}</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#7A756E' }}>{item.sub}</div>
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style={{ color: '#A8A29E', flexShrink: 0 }}>
|
||||||
|
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图标风格 */}
|
||||||
|
<div style={dsStyles.card}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: '#2D2A26', marginBottom: 12 }}>图标风格 · 线性 1.5px</div>
|
||||||
|
<div style={{ display: 'flex', gap: 24 }}>
|
||||||
|
{[
|
||||||
|
{ label: '首页', path: 'M8 2l6 5v7a1 1 0 01-1 1H3a1 1 0 01-1-1V7l6-5z' },
|
||||||
|
{ label: '心率', path: 'M2 8h3l2-4 3 8 2-4h2' },
|
||||||
|
{ label: '预约', path: 'M3 4h10v9a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 4V2M13 4V2M6 7h4M6 10h2' },
|
||||||
|
{ label: '消息', path: 'M2 4a1 1 0 011-1h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 3V4z' },
|
||||||
|
{ label: '我的', path: 'M8 8a3 3 0 100-6 3 3 0 000 6zM3 14a5 5 0 0110 0' },
|
||||||
|
].map(ico => (
|
||||||
|
<div key={ico.label} style={{ textAlign: 'center' }}>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 16 16" fill="none" stroke="#7A756E" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d={ico.path} />
|
||||||
|
</svg>
|
||||||
|
<div style={{ fontSize: 11, color: '#A8A29E', marginTop: 4 }}>{ico.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ─── 渲染 ─── */
|
||||||
|
const App = () => (
|
||||||
|
<div style={dsStyles.page}>
|
||||||
|
<div style={dsStyles.title}>HMS 健康管理 · 设计系统</div>
|
||||||
|
<div style={dsStyles.subtitle}>温润东方风 — Kenya Hara 式克制留白 · 赤土橙 #C4623A 贯穿全场</div>
|
||||||
|
<ColorSection />
|
||||||
|
<TypographySection />
|
||||||
|
<SpacingSection />
|
||||||
|
<ComponentSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
346
docs/design/miniprogram/preview.html
Normal file
346
docs/design/miniprogram/preview.html
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>HMS 小程序设计预览 · 温润东方风</title>
|
||||||
|
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: #E8E2DC; font-family: -apple-system, 'PingFang SC', sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
/* ─── Design Tokens ─── */
|
||||||
|
const C = {
|
||||||
|
bg: '#F5F0EB', surface: '#FFFFFF', surfaceAlt: '#EDE8E2',
|
||||||
|
tx1: '#2D2A26', tx2: '#7A756E', tx3: '#A8A29E',
|
||||||
|
accent: '#C4623A', accentLt: '#F0DDD4', accentDk: '#8B3E1F',
|
||||||
|
ok: '#5B7A5E', okLt: '#E8F0E8',
|
||||||
|
warn: '#C4873A', warnLt: '#FFF3E0',
|
||||||
|
err: '#B54A4A', errLt: '#FDEAEA',
|
||||||
|
bd: '#E8E2DC', bdLt: '#F0EBE5',
|
||||||
|
};
|
||||||
|
const R = { sm: 8, md: 12, lg: 16 };
|
||||||
|
const serif = '"Noto Serif SC", serif';
|
||||||
|
|
||||||
|
/* ─── iOS Frame ─── */
|
||||||
|
function IosFrame({ children, darkMode }) {
|
||||||
|
const bg = darkMode ? '#1A1816' : C.bg;
|
||||||
|
const tc = darkMode ? '#E8E2DC' : C.tx1;
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'inline-block', padding: 10, background: '#1A1816', borderRadius: 48, boxShadow: '0 0 0 1px #333, 0 20px 60px rgba(0,0,0,0.25)' }}>
|
||||||
|
<div style={{ width: 393, height: 852, borderRadius: 40, overflow: 'hidden', background: bg, position: 'relative' }}>
|
||||||
|
{/* Dynamic Island */}
|
||||||
|
<div style={{ position: 'absolute', top: 12, left: '50%', transform: 'translateX(-50%)', width: 124, height: 36, background: '#000', borderRadius: 999, zIndex: 30 }} />
|
||||||
|
{/* Status Bar */}
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 54, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 32px', fontSize: 15, fontWeight: 600, color: tc, zIndex: 20, pointerEvents: 'none' }}>
|
||||||
|
<span>9:41</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 12 }}>
|
||||||
|
{[4,6,9,11].map(h => <div key={h} style={{ width: 3, height: h, background: tc, borderRadius: 1, opacity: 0.8 }} />)}
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="12" viewBox="0 0 16 12" fill="none"><path d="M8 11.5a1 1 0 100-2 1 1 0 000 2z" fill={tc}/><path d="M3 7.5a7 7 0 0110 0" stroke={tc} strokeWidth="1.3" strokeLinecap="round"/><path d="M1 4.5a11 11 0 0114 0" stroke={tc} strokeWidth="1.3" strokeLinecap="round" opacity="0.7"/></svg>
|
||||||
|
<div style={{ width: 26, height: 12, border: `1.5px solid ${tc}`, borderRadius: 3, padding: 1, opacity: 0.8 }}>
|
||||||
|
<div style={{ width: '80%', height: '100%', background: tc, borderRadius: 1 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ position: 'absolute', top: 54, left: 0, right: 0, bottom: 34, overflow: 'auto' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{/* Home Indicator */}
|
||||||
|
<div style={{ position: 'absolute', bottom: 10, left: '50%', transform: 'translateX(-50%)', width: 140, height: 5, background: darkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)', borderRadius: 999, zIndex: 10 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Shared Components ─── */
|
||||||
|
function Tag({ type, children }) {
|
||||||
|
const map = { ok: [C.ok, C.okLt], warn: [C.warn, C.warnLt], err: [C.err, C.errLt], default: [C.tx2, C.bdLt] };
|
||||||
|
const [fg, bg] = map[type] || map.default;
|
||||||
|
return <span style={{ display: 'inline-block', padding: '2px 8px', borderRadius: 6, fontSize: 11, fontWeight: 500, background: bg, color: fg }}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({ children }) {
|
||||||
|
return <div style={{ fontFamily: serif, fontSize: 18, fontWeight: 600, color: C.tx1, marginBottom: 14 }}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListItem({ title, sub, right }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', padding: '14px 0', borderBottom: `1px solid ${C.bdLt}` }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 500, color: C.tx1, marginBottom: 2 }}>{title}</div>
|
||||||
|
<div style={{ fontSize: 12, color: C.tx2 }}>{sub}</div>
|
||||||
|
</div>
|
||||||
|
{right}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" style={{ color: C.tx3, marginLeft: 8, flexShrink: 0 }}>
|
||||||
|
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Patient TabBar ─── */
|
||||||
|
const patientTabs = [
|
||||||
|
{ label: '首页', d: 'M4 3h8v8a1 1 0 01-1 1H5a1 1 0 01-1-1V3zM4 3V1M12 3V1' },
|
||||||
|
{ label: '健康', d: 'M2 6h2l1.5-3 2.5 6 1.5-3H8' },
|
||||||
|
{ label: '咨询', d: 'M2 3h12v8a1 1 0 01-1 1H5l-3 3V3z' },
|
||||||
|
{ label: '商城', d: 'M2 4h12l-1 8H3L2 4zM5 4V3a3 3 0 016 0v1' },
|
||||||
|
{ label: '我的', d: 'M8 7a2.5 2.5 0 100-5 2.5 2.5 0 000 5zM3 14a5 5 0 0110 0' },
|
||||||
|
];
|
||||||
|
function PatientTabBar({ active }) {
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'absolute', bottom: 34, left: 0, right: 0, height: 56, background: C.surface, borderTop: `1px solid ${C.bd}`, display: 'flex', alignItems: 'center', justifyContent: 'space-around' }}>
|
||||||
|
{patientTabs.map((t, i) => (
|
||||||
|
<div key={t.label} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3 }}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 16 16" fill="none" stroke={i === active ? C.accent : C.tx3} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d={t.d}/></svg>
|
||||||
|
<span style={{ fontSize: 10, color: i === active ? C.accent : C.tx3, fontWeight: i === active ? 600 : 400 }}>{t.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Doctor TabBar ─── */
|
||||||
|
const doctorTabs = [
|
||||||
|
{ label: '工作台', d: 'M3 2h4v5H3V2zM9 2h4v5H9V2zM3 9h4v5H3V9zM9 9h4v5H9V9z' },
|
||||||
|
{ label: '患者', d: 'M8 7a2.5 2.5 0 100-5 2.5 2.5 0 000 5zM3 14a5 5 0 0110 0' },
|
||||||
|
{ label: '咨询', d: 'M2 3h12v8a1 1 0 01-1 1H5l-3 3V3z' },
|
||||||
|
{ label: '随访', d: 'M4 3h8v8a1 1 0 01-1 1H5a1 1 0 01-1-1V3z' },
|
||||||
|
{ label: '报告', d: 'M3 2h10v12H3V2zM5 5h6M5 8h4' },
|
||||||
|
];
|
||||||
|
function DoctorTabBar({ active }) {
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'absolute', bottom: 34, left: 0, right: 0, height: 56, background: C.surface, borderTop: `1px solid ${C.bd}`, display: 'flex', alignItems: 'center', justifyContent: 'space-around' }}>
|
||||||
|
{doctorTabs.map((t, i) => (
|
||||||
|
<div key={t.label} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3 }}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 16 16" fill="none" stroke={i === active ? C.accent : C.tx3} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d={t.d}/></svg>
|
||||||
|
<span style={{ fontSize: 10, color: i === active ? C.accent : C.tx3, fontWeight: i === active ? 600 : 400 }}>{t.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════
|
||||||
|
患者端首页
|
||||||
|
═══════════════════════════════════════════════ */
|
||||||
|
function PatientHome() {
|
||||||
|
const healthItems = [
|
||||||
|
{ label: '血压', value: '120/80', unit: 'mmHg', status: '正常', type: 'ok' },
|
||||||
|
{ label: '心率', value: '72', unit: 'bpm', status: '正常', type: 'ok' },
|
||||||
|
{ label: '血糖', value: '6.8', unit: 'mmol/L', status: '偏高', type: 'warn' },
|
||||||
|
{ label: '体重', value: '68.5', unit: 'kg', status: '', type: 'default' },
|
||||||
|
];
|
||||||
|
const quickServices = [
|
||||||
|
{ label: '预约挂号', d: 'M4 3h8v8a1 1 0 01-1 1H5a1 1 0 01-1-1V3zM4 3V1M12 3V1M6 7h4' },
|
||||||
|
{ label: '健康录入', d: 'M2 6h2l1.5-3 2.5 6 1.5-3H8' },
|
||||||
|
{ label: '健康趋势', d: 'M2 12l4-5 3 3 5-7' },
|
||||||
|
{ label: '资讯文章', d: 'M3 2h10v12H3V2zM5 5h6M5 8h4' },
|
||||||
|
{ label: 'AI 报告', d: 'M8 2l6 5-6 5-6-5 6-5z' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 问候区 */}
|
||||||
|
<div style={{ background: `linear-gradient(135deg, ${C.accent} 0%, ${C.accentDk} 100%)`, padding: '20px 24px 48px', color: '#fff' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 15, opacity: 0.85 }}>上午好</div>
|
||||||
|
<div style={{ fontFamily: serif, fontSize: 24, fontWeight: 700 }}>张明远</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, opacity: 0.7 }}>2026年4月27日</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 今日健康卡片 */}
|
||||||
|
<div style={{ margin: '-28px 16px 16px', background: C.surface, borderRadius: R.md, padding: 20, boxShadow: '0 2px 12px rgba(45,42,38,0.08)' }}>
|
||||||
|
<SectionTitle>今日健康</SectionTitle>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||||
|
{healthItems.map(h => (
|
||||||
|
<div key={h.label} style={{ background: C.bg, borderRadius: R.sm, padding: 14, textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 12, color: C.tx2, marginBottom: 6 }}>{h.label}</div>
|
||||||
|
<div style={{ fontFamily: serif, fontSize: 26, fontWeight: 700, color: C.tx1 }}>{h.value}</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 4, marginTop: 4 }}>
|
||||||
|
<span style={{ fontSize: 11, color: C.tx3 }}>{h.unit}</span>
|
||||||
|
{h.status && <Tag type={h.type}>{h.status}</Tag>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷服务 */}
|
||||||
|
<div style={{ margin: '0 16px 16px' }}>
|
||||||
|
<SectionTitle>快捷服务</SectionTitle>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
{quickServices.map(s => (
|
||||||
|
<div key={s.label} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, flex: 1 }}>
|
||||||
|
<div style={{ width: 44, height: 44, borderRadius: 12, background: C.accentLt, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke={C.accent} strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"><path d={s.d}/></svg>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: C.tx2 }}>{s.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 待办事项 */}
|
||||||
|
<div style={{ margin: '0 16px', paddingBottom: 80 }}>
|
||||||
|
<SectionTitle>待办事项</SectionTitle>
|
||||||
|
<div style={{ background: C.surface, borderRadius: R.md, overflow: 'hidden', boxShadow: '0 1px 4px rgba(45,42,38,0.04)' }}>
|
||||||
|
<div style={{ padding: '0 16px' }}>
|
||||||
|
<ListItem title="预约:2026-04-28 上午" sub="王医生 · 心内科 · 待确认" right={<Tag type="warn">待确认</Tag>} />
|
||||||
|
<ListItem title="随访:血压监测" sub="每日记录血压数据 · 截止 05-01" right={<Tag type="default">进行中</Tag>} />
|
||||||
|
<ListItem title="报告:血常规检验" sub="2026-04-25 · 已出结果" right={<Tag type="ok">已完成</Tag>} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PatientTabBar active={0} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════
|
||||||
|
医护端首页
|
||||||
|
═══════════════════════════════════════════════ */
|
||||||
|
function DoctorHome() {
|
||||||
|
const stats = [
|
||||||
|
{ label: '待诊', value: '8', color: C.accent },
|
||||||
|
{ label: '已诊', value: '12', color: C.ok },
|
||||||
|
{ label: '随访', value: '5', color: C.warn },
|
||||||
|
{ label: '报告', value: '3', color: C.tx2 },
|
||||||
|
];
|
||||||
|
const quickActions = [
|
||||||
|
{ label: '患者查询', d: 'M11 7a4 4 0 11-8 0 4 4 0 018 0zM2 14a7 7 0 0112 0' },
|
||||||
|
{ label: '新增随访', d: 'M8 3v10M3 8h10' },
|
||||||
|
{ label: '写报告', d: 'M3 2h10v12H3V2zM5 5h6M5 8h4M5 11h2' },
|
||||||
|
];
|
||||||
|
const patients = [
|
||||||
|
{ name: '李小红', age: 56, dept: '心内科', time: '09:30', status: '候诊中', type: 'warn' },
|
||||||
|
{ name: '王建国', age: 63, dept: '内分泌', time: '10:00', status: '检查中', type: 'default' },
|
||||||
|
{ name: '陈美玲', age: 45, dept: '心内科', time: '10:30', status: '已诊', type: 'ok' },
|
||||||
|
{ name: '赵大伟', age: 71, dept: '肾内科', time: '11:00', status: '候诊中', type: 'warn' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div style={{ background: `linear-gradient(135deg, ${C.accent} 0%, ${C.accentDk} 100%)`, padding: '20px 24px 36px', color: '#fff' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, opacity: 0.8 }}>李明医生 · 心内科</div>
|
||||||
|
<div style={{ fontFamily: serif, fontSize: 22, fontWeight: 700 }}>工作台</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 20, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round"><path d="M2 8h12M8 2v12"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 统计 */}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{stats.map(s => (
|
||||||
|
<div key={s.label} style={{ flex: 1, background: 'rgba(255,255,255,0.15)', borderRadius: R.sm, padding: '10px 0', textAlign: 'center', backdropFilter: 'blur(4px)' }}>
|
||||||
|
<div style={{ fontFamily: serif, fontSize: 24, fontWeight: 700, color: '#fff' }}>{s.value}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.8)' }}>{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷操作 */}
|
||||||
|
<div style={{ margin: '16px 16px 12px', display: 'flex', gap: 10 }}>
|
||||||
|
{quickActions.map(a => (
|
||||||
|
<div key={a.label} style={{ flex: 1, background: C.surface, borderRadius: R.md, padding: '14px 0', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, boxShadow: '0 1px 4px rgba(45,42,38,0.04)' }}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke={C.accent} strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"><path d={a.d}/></svg>
|
||||||
|
<span style={{ fontSize: 12, color: C.tx2 }}>{a.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 今日排班 */}
|
||||||
|
<div style={{ margin: '0 16px 12px', background: C.surface, borderRadius: R.md, padding: 16, boxShadow: '0 1px 4px rgba(45,42,38,0.04)' }}>
|
||||||
|
<SectionTitle>今日排班</SectionTitle>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<div style={{ flex: 1, background: C.accentLt, borderRadius: R.sm, padding: '10px 12px' }}>
|
||||||
|
<div style={{ fontSize: 12, color: C.accent, fontWeight: 600 }}>上午</div>
|
||||||
|
<div style={{ fontSize: 13, color: C.tx1, marginTop: 2 }}>08:00 - 12:00</div>
|
||||||
|
<div style={{ fontSize: 11, color: C.tx2, marginTop: 2 }}>心内科门诊 · 8 位患者</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, background: C.bdLt, borderRadius: R.sm, padding: '10px 12px' }}>
|
||||||
|
<div style={{ fontSize: 12, color: C.tx2, fontWeight: 600 }}>下午</div>
|
||||||
|
<div style={{ fontSize: 13, color: C.tx1, marginTop: 2 }}>14:00 - 17:00</div>
|
||||||
|
<div style={{ fontSize: 11, color: C.tx2, marginTop: 2 }}>心内科门诊 · 4 位患者</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 候诊列表 */}
|
||||||
|
<div style={{ margin: '0 16px', paddingBottom: 80 }}>
|
||||||
|
<SectionTitle>候诊队列</SectionTitle>
|
||||||
|
<div style={{ background: C.surface, borderRadius: R.md, overflow: 'hidden', boxShadow: '0 1px 4px rgba(45,42,38,0.04)' }}>
|
||||||
|
<div style={{ padding: '0 16px' }}>
|
||||||
|
{patients.map((p, i) => (
|
||||||
|
<div key={p.name} style={{ display: 'flex', alignItems: 'center', padding: '12px 0', borderBottom: i < patients.length - 1 ? `1px solid ${C.bdLt}` : 'none' }}>
|
||||||
|
<div style={{ width: 36, height: 36, borderRadius: 18, background: i === 0 ? C.accentLt : C.bdLt, display: 'flex', alignItems: 'center', justifyContent: 'center', marginRight: 12, flexShrink: 0 }}>
|
||||||
|
<span style={{ fontFamily: serif, fontSize: 14, fontWeight: 600, color: i === 0 ? C.accent : C.tx2 }}>{p.name[0]}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 500, color: C.tx1 }}>{p.name}</span>
|
||||||
|
<span style={{ fontSize: 11, color: C.tx3 }}>{p.age}岁 · {p.dept}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: C.tx2, marginTop: 2 }}>预约 {p.time}</div>
|
||||||
|
</div>
|
||||||
|
<Tag type={p.type}>{p.status}</Tag>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DoctorTabBar active={0} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Page Layout ─── */
|
||||||
|
function Page() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 48, display: 'flex', gap: 64, justifyContent: 'center', flexWrap: 'wrap', minHeight: '100vh', alignItems: 'flex-start' }}>
|
||||||
|
{/* 患者端首页 */}
|
||||||
|
<div>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||||
|
<div style={{ fontFamily: serif, fontSize: 20, fontWeight: 700, color: C.tx1 }}>患者端 · 首页</div>
|
||||||
|
<div style={{ fontSize: 13, color: C.tx2, marginTop: 4 }}>pages/index/index</div>
|
||||||
|
</div>
|
||||||
|
<IosFrame>
|
||||||
|
<PatientHome />
|
||||||
|
</IosFrame>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 医护端首页 */}
|
||||||
|
<div>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||||
|
<div style={{ fontFamily: serif, fontSize: 20, fontWeight: 700, color: C.tx1 }}>医护端 · 工作台</div>
|
||||||
|
<div style={{ fontSize: 13, color: C.tx2, marginTop: 4 }}>pages/doctor/index</div>
|
||||||
|
</div>
|
||||||
|
<IosFrame>
|
||||||
|
<DoctorHome />
|
||||||
|
</IosFrame>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<Page />);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
319
docs/design/web/preview.html
Normal file
319
docs/design/web/preview.html
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>HMS Web 端重设计 · 温润东方风</title>
|
||||||
|
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Serif+SC:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: #E8E2DC; font-family: 'Noto Sans SC', -apple-system, sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
/* ─── Design Tokens(从小程序端映射) ─── */
|
||||||
|
const C = {
|
||||||
|
primary: '#C4623A', primaryHover: '#B55A33', primaryActive: '#8B3E1F',
|
||||||
|
primaryLight: '#F0DDD4', primaryBg: '#FAF5F0',
|
||||||
|
bg: '#F5F0EB', surface: '#FFFFFF', surfaceAlt: '#EDE8E2',
|
||||||
|
tx1: '#2D2A26', tx2: '#7A756E', tx3: '#A8A29E', txInv: '#FFFFFF',
|
||||||
|
ok: '#5B7A5E', okBg: '#E8F0E8',
|
||||||
|
warn: '#C4873A', warnBg: '#FFF3E0',
|
||||||
|
err: '#B54A4A', errBg: '#FDEAEA',
|
||||||
|
bd: '#E8E2DC', bdLt: '#F0EBE5',
|
||||||
|
shadow: 'rgba(45,42,38,0.08)',
|
||||||
|
};
|
||||||
|
const serif = '"Noto Serif SC", serif';
|
||||||
|
const sans = '"Noto Sans SC", -apple-system, sans-serif';
|
||||||
|
|
||||||
|
/* ─── 浏览器窗口框 ─── */
|
||||||
|
function BrowserFrame({ title, children }) {
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#1A1816', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
|
||||||
|
<div style={{ height: 38, background: '#2D2A26', display: 'flex', alignItems: 'center', padding: '0 14px', gap: 8 }}>
|
||||||
|
{['#E85C4A','#E8B44A','#5BB85B'].map(c => <div key={c} style={{ width: 12, height: 12, borderRadius: 6, background: c }} />)}
|
||||||
|
<div style={{ flex: 1, marginLeft: 16, background: 'rgba(255,255,255,0.08)', borderRadius: 6, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, color: '#A8A29E' }}>{title}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ overflow: 'auto', background: C.bg }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Dashboard 页面 ─── */
|
||||||
|
function DashboardPage() {
|
||||||
|
const stats = [
|
||||||
|
{ label: '今日预约', value: '24', trend: '+12%', icon: '📅', color: C.primary },
|
||||||
|
{ label: '活跃患者', value: '1,847', trend: '+3.2%', icon: '👥', color: C.ok },
|
||||||
|
{ label: '待随访', value: '38', trend: '-5%', icon: '📋', color: C.warn },
|
||||||
|
{ label: 'AI 报告', value: '156', trend: '+28%', icon: '🤖', color: '#7A6E5E' },
|
||||||
|
];
|
||||||
|
const tasks = [
|
||||||
|
{ title: '王建国 · 血压异常预警', meta: '心内科 · 10分钟前', priority: 'high' },
|
||||||
|
{ title: '李小红 · 随访到期提醒', meta: '内分泌 · 今日截止', priority: 'medium' },
|
||||||
|
{ title: '批量体检报告待审核', meta: '共12份 · 队列处理中', priority: 'low' },
|
||||||
|
];
|
||||||
|
const quickActions = [
|
||||||
|
{ label: '新增患者', desc: '建档登记' },
|
||||||
|
{ label: '预约挂号', desc: '排班管理' },
|
||||||
|
{ label: '健康录入', desc: '体征数据' },
|
||||||
|
{ label: 'AI 分析', desc: '智能报告' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ fontFamily: serif, fontSize: 24, fontWeight: 700, color: C.tx1 }}>工作台</div>
|
||||||
|
<div style={{ fontSize: 13, color: C.tx2, marginTop: 4 }}>2026年4月27日 · 欢迎回来,李明医生</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
|
||||||
|
{stats.map(s => (
|
||||||
|
<div key={s.label} style={{ background: C.surface, borderRadius: 12, padding: '20px 20px 16px', border: `1px solid ${C.bdLt}`, position: 'relative', overflow: 'hidden', cursor: 'pointer', transition: 'box-shadow 0.15s', boxShadow: `0 1px 3px ${C.shadow}` }}>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 3, background: s.color }} />
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, color: C.tx2, marginBottom: 8 }}>{s.label}</div>
|
||||||
|
<div style={{ fontFamily: serif, fontSize: 32, fontWeight: 700, color: C.tx1, letterSpacing: -1 }}>{s.value}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 28, opacity: 0.7 }}>{s.icon}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: s.trend.startsWith('+') ? C.ok : s.trend.startsWith('-') ? C.tx3 : C.tx2, marginTop: 8 }}>
|
||||||
|
{s.trend} 较上周
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
|
{/* 待办事项 */}
|
||||||
|
<div style={{ background: C.surface, borderRadius: 12, padding: 20, border: `1px solid ${C.bdLt}`, boxShadow: `0 1px 3px ${C.shadow}` }}>
|
||||||
|
<div style={{ fontFamily: serif, fontSize: 16, fontWeight: 600, color: C.tx1, marginBottom: 16 }}>待办事项</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{tasks.map(t => {
|
||||||
|
const colors = { high: [C.err, C.errBg], medium: [C.warn, C.warnBg], low: [C.tx2, C.surfaceAlt] };
|
||||||
|
const [tc, tb] = colors[t.priority];
|
||||||
|
return (
|
||||||
|
<div key={t.title} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', borderRadius: 10, background: C.bg, cursor: 'pointer', transition: 'background 0.15s', borderLeft: `3px solid ${tc}` }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500, color: C.tx1 }}>{t.title}</div>
|
||||||
|
<div style={{ fontSize: 12, color: C.tx3, marginTop: 2 }}>{t.meta}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷操作 */}
|
||||||
|
<div style={{ background: C.surface, borderRadius: 12, padding: 20, border: `1px solid ${C.bdLt}`, boxShadow: `0 1px 3px ${C.shadow}` }}>
|
||||||
|
<div style={{ fontFamily: serif, fontSize: 16, fontWeight: 600, color: C.tx1, marginBottom: 16 }}>快捷操作</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||||
|
{quickActions.map(a => (
|
||||||
|
<div key={a.label} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', borderRadius: 10, background: C.bg, cursor: 'pointer', transition: 'all 0.15s', border: `1px solid ${C.bdLt}` }}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: C.primaryLight, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<div style={{ width: 18, height: 18, borderRadius: 4, background: C.primary }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500, color: C.tx1 }}>{a.label}</div>
|
||||||
|
<div style={{ fontSize: 12, color: C.tx3 }}>{a.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 患者列表页 ─── */
|
||||||
|
function PatientListPage() {
|
||||||
|
const patients = [
|
||||||
|
{ name: '李小红', age: 56, dept: '心内科', status: '治疗中', phone: '138****2345', lastVisit: '2026-04-25' },
|
||||||
|
{ name: '王建国', age: 63, dept: '内分泌', status: '随访中', phone: '139****8901', lastVisit: '2026-04-24' },
|
||||||
|
{ name: '陈美玲', age: 45, dept: '心内科', status: '待诊', phone: '136****5678', lastVisit: '2026-04-23' },
|
||||||
|
{ name: '赵大伟', age: 71, dept: '肾内科', status: '治疗中', phone: '137****3456', lastVisit: '2026-04-22' },
|
||||||
|
{ name: '孙丽华', age: 58, dept: '内分泌', status: '已完成', phone: '135****7890', lastVisit: '2026-04-21' },
|
||||||
|
];
|
||||||
|
const statusMap = { '治疗中': [C.primary, C.primaryLight], '随访中': [C.ok, C.okBg], '待诊': [C.warn, C.warnBg], '已完成': [C.tx3, C.surfaceAlt] };
|
||||||
|
const columns = ['姓名', '年龄', '科室', '联系电话', '最近就诊', '状态', '操作'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20, paddingBottom: 16, borderBottom: `1px solid ${C.bd}` }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: serif, fontSize: 20, fontWeight: 700, color: C.tx1 }}>患者管理</div>
|
||||||
|
<div style={{ fontSize: 13, color: C.tx3, marginTop: 2 }}>共 1,847 位患者</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<div style={{ padding: '8px 16px', borderRadius: 8, border: `1px solid ${C.bd}`, background: C.surface, fontSize: 13, color: C.tx2, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke={C.tx2} strokeWidth="1.5"><circle cx="7" cy="7" r="4"/><path d="M14 14l-3-3"/></svg>
|
||||||
|
搜索患者...
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '8px 16px', borderRadius: 8, background: C.primary, color: '#fff', fontSize: 13, fontWeight: 500, cursor: 'pointer' }}>+ 新增患者</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
<div style={{ background: C.surface, borderRadius: 12, overflow: 'hidden', border: `1px solid ${C.bd}`, boxShadow: `0 1px 3px ${C.shadow}` }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: C.surfaceAlt }}>
|
||||||
|
{columns.map(col => <th key={col} style={{ padding: '12px 16px', fontSize: 12, fontWeight: 600, color: C.tx2, textAlign: 'left', borderBottom: `1px solid ${C.bd}` }}>{col}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{patients.map((p, i) => {
|
||||||
|
const [sc, sb] = statusMap[p.status] || statusMap['已完成'];
|
||||||
|
return (
|
||||||
|
<tr key={p.name} style={{ borderBottom: i < patients.length - 1 ? `1px solid ${C.bdLt}` : 'none', transition: 'background 0.1s' }}>
|
||||||
|
<td style={{ padding: '14px 16px', fontSize: 14, fontWeight: 500, color: C.tx1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{ width: 32, height: 32, borderRadius: 16, background: i === 0 ? C.primaryLight : C.surfaceAlt, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<span style={{ fontFamily: serif, fontSize: 13, fontWeight: 600, color: i === 0 ? C.primary : C.tx2 }}>{p.name[0]}</span>
|
||||||
|
</div>
|
||||||
|
{p.name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '14px 16px', fontSize: 13, color: C.tx2 }}>{p.age}岁</td>
|
||||||
|
<td style={{ padding: '14px 16px', fontSize: 13, color: C.tx2 }}>{p.dept}</td>
|
||||||
|
<td style={{ padding: '14px 16px', fontSize: 13, color: C.tx2 }}>{p.phone}</td>
|
||||||
|
<td style={{ padding: '14px 16px', fontSize: 13, color: C.tx3 }}>{p.lastVisit}</td>
|
||||||
|
<td style={{ padding: '14px 16px' }}>
|
||||||
|
<span style={{ display: 'inline-block', padding: '2px 10px', borderRadius: 6, fontSize: 12, fontWeight: 500, background: sb, color: sc }}>{p.status}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '14px 16px', fontSize: 13, color: C.primary, cursor: 'pointer' }}>查看详情</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 侧边栏 ─── */
|
||||||
|
function Sidebar({ active = 'dashboard' }) {
|
||||||
|
const menuItems = [
|
||||||
|
{ group: '概览', items: [{ key: 'dashboard', label: '工作台' }] },
|
||||||
|
{ group: '健康管理', items: [
|
||||||
|
{ key: 'patients', label: '患者管理' },
|
||||||
|
{ key: 'appointments', label: '预约排班' },
|
||||||
|
{ key: 'followup', label: '随访管理' },
|
||||||
|
{ key: 'consultation', label: '咨询管理' },
|
||||||
|
{ key: 'articles', label: '内容管理' },
|
||||||
|
]},
|
||||||
|
{ group: '数据中心', items: [
|
||||||
|
{ key: 'ai', label: 'AI 分析' },
|
||||||
|
{ key: 'statistics', label: '统计报表' },
|
||||||
|
{ key: 'alerts', label: '预警管理' },
|
||||||
|
]},
|
||||||
|
{ group: '系统', items: [
|
||||||
|
{ key: 'users', label: '用户权限' },
|
||||||
|
{ key: 'settings', label: '系统设置' },
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 240, height: '100%', background: C.surface, borderRight: `1px solid ${C.bd}`, display: 'flex', flexDirection: 'column', flexShrink: 0 }}>
|
||||||
|
{/* Logo */}
|
||||||
|
<div style={{ height: 56, display: 'flex', alignItems: 'center', padding: '0 20px', borderBottom: `1px solid ${C.bd}` }}>
|
||||||
|
<div style={{ width: 28, height: 28, borderRadius: 6, background: C.primary, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 13, fontWeight: 800 }}>H</div>
|
||||||
|
<span style={{ marginLeft: 10, fontFamily: serif, fontSize: 15, fontWeight: 700, color: C.tx1 }}>HMS 健康</span>
|
||||||
|
</div>
|
||||||
|
{/* 菜单 */}
|
||||||
|
<div style={{ flex: 1, padding: '8px 0', overflow: 'auto' }}>
|
||||||
|
{menuItems.map(g => (
|
||||||
|
<div key={g.group}>
|
||||||
|
<div style={{ padding: '16px 20px 6px', fontSize: 11, fontWeight: 600, color: C.tx3, letterSpacing: '0.5px', textTransform: 'uppercase' }}>{g.group}</div>
|
||||||
|
{g.items.map(item => (
|
||||||
|
<div key={item.key} style={{
|
||||||
|
display: 'flex', alignItems: 'center', height: 36, margin: '1px 8px', padding: '0 12px',
|
||||||
|
borderRadius: 8, cursor: 'pointer', fontSize: 14,
|
||||||
|
background: active === item.key ? C.primaryLight : 'transparent',
|
||||||
|
color: active === item.key ? C.primary : C.tx2,
|
||||||
|
fontWeight: active === item.key ? 500 : 400,
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}>{item.label}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 顶部栏 ─── */
|
||||||
|
function Header({ title }) {
|
||||||
|
return (
|
||||||
|
<div style={{ height: 56, padding: '0 24px', background: C.surface, borderBottom: `1px solid ${C.bd}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, color: C.tx1 }}>{title}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{ width: 32, height: 32, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: C.tx2 }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M8 2a5 5 0 015 5c0 4-5 7-5 7S3 11 3 7a5 5 0 015-5z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 32, height: 32, borderRadius: 16, background: C.primary, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 13, fontWeight: 600 }}>李</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 完整布局 ─── */
|
||||||
|
function FullLayout({ children, title }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', height: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
<Header title={title} />
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', background: C.bg }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 页面路由 ─── */
|
||||||
|
function App() {
|
||||||
|
const [page, setPage] = React.useState('both');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 40, display: 'flex', gap: 48, justifyContent: 'center', flexWrap: 'wrap', minHeight: '100vh', alignItems: 'flex-start' }}>
|
||||||
|
{/* Dashboard */}
|
||||||
|
<div>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||||
|
<div style={{ fontFamily: serif, fontSize: 18, fontWeight: 700, color: C.tx1 }}>Dashboard · 工作台</div>
|
||||||
|
<div style={{ fontSize: 12, color: C.tx2, marginTop: 4 }}>Home.tsx → 新设计</div>
|
||||||
|
</div>
|
||||||
|
<BrowserFrame title="localhost:5174/dashboard">
|
||||||
|
<div style={{ width: 1100, height: 700 }}>
|
||||||
|
<FullLayout title="工作台"><DashboardPage /></FullLayout>
|
||||||
|
</div>
|
||||||
|
</BrowserFrame>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 患者列表 */}
|
||||||
|
<div>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||||
|
<div style={{ fontFamily: serif, fontSize: 18, fontWeight: 700, color: C.tx1 }}>患者管理 · 列表</div>
|
||||||
|
<div style={{ fontSize: 12, color: C.tx2, marginTop: 4 }}>PatientList.tsx → 新设计</div>
|
||||||
|
</div>
|
||||||
|
<BrowserFrame title="localhost:5174/health/patients">
|
||||||
|
<div style={{ width: 1100, height: 700 }}>
|
||||||
|
<FullLayout title="患者管理"><PatientListPage /></FullLayout>
|
||||||
|
</div>
|
||||||
|
</BrowserFrame>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
732
docs/designs/hms-miniprogram-mockup.html
Normal file
732
docs/designs/hms-miniprogram-mockup.html
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>HMS 健康管理小程序原型</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||||||
|
:root{
|
||||||
|
--pri:#0891B2;--pri-l:#E0F7FA;--pri-d:#065A73;--pri-surface:#ECFEFF;
|
||||||
|
--acc:#059669;--acc-l:#D1FAE5;
|
||||||
|
--bg:#F0FDFA;--card:#FFFFFF;--tx:#134E4A;--tx2:#6B7280;--tx3:#94A3B8;
|
||||||
|
--bd:#E5E7EB;--bd-l:#F3F4F6;
|
||||||
|
--dan:#DC2626;--dan-l:#FEE2E2;--wrn:#D97706;--wrn-l:#FEF3C7;
|
||||||
|
--r:12px;--r-sm:8px;--r-lg:16px;
|
||||||
|
--sh-sm:0 1px 3px rgba(0,0,0,.04);
|
||||||
|
--sh:0 2px 8px rgba(0,0,0,.06);
|
||||||
|
--sh-md:0 4px 16px rgba(0,0,0,.08);
|
||||||
|
--sh-lg:0 8px 32px rgba(0,0,0,.12);
|
||||||
|
--ease:cubic-bezier(.4,0,.2,1)
|
||||||
|
}
|
||||||
|
body{font-family:'PingFang SC',-apple-system,BlinkMacSystemFont,'Segoe UI','Hiragino Sans GB','Microsoft YaHei',sans-serif;background:#0f172a;display:flex;flex-direction:column;align-items:center;padding:24px;color:var(--tx);font-size:14px;line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||||
|
/* 模式切换 */
|
||||||
|
.mode-switch{display:flex;gap:8px;margin-bottom:16px}
|
||||||
|
.mode-btn{padding:10px 24px;border-radius:24px;border:1.5px solid rgba(255,255,255,.15);background:rgba(255,255,255,.06);color:rgba(255,255,255,.7);cursor:pointer;font-size:13px;font-weight:600;transition:all .25s var(--ease);backdrop-filter:blur(8px);min-height:44px;display:flex;align-items:center}
|
||||||
|
.mode-btn:hover{background:rgba(255,255,255,.12);color:#fff}
|
||||||
|
.mode-btn.active{background:var(--pri);border-color:var(--pri);color:#fff;box-shadow:0 2px 12px rgba(8,145,178,.35)}
|
||||||
|
/* 手机壳 */
|
||||||
|
.app{width:375px;height:812px;background:var(--bg);border-radius:44px;overflow:hidden;position:relative;box-shadow:0 0 0 8px #1e293b,0 24px 80px rgba(0,0,0,.5);display:flex;flex-direction:column}
|
||||||
|
/* 页面切换 */
|
||||||
|
.page{display:none;flex-direction:column;height:100%;opacity:0;transition:opacity .2s var(--ease)}
|
||||||
|
.page.active{display:flex;opacity:1}
|
||||||
|
/* 状态栏 */
|
||||||
|
.status-bar{height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 28px;color:rgba(255,255,255,.9);font-size:13px;font-weight:600;flex-shrink:0}
|
||||||
|
/* 导航栏 */
|
||||||
|
.nav-bar{height:48px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:17px;font-weight:600;flex-shrink:0;position:relative;letter-spacing:.3px}
|
||||||
|
.nav-bar .back{position:absolute;left:14px;cursor:pointer;display:flex;align-items:center;padding:8px;border-radius:50%;transition:background .15s}
|
||||||
|
.nav-bar .back:active{background:rgba(255,255,255,.15)}
|
||||||
|
/* 内容区 */
|
||||||
|
.content{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain}
|
||||||
|
.content::-webkit-scrollbar{display:none}
|
||||||
|
/* Tab 栏 */
|
||||||
|
.tab-bar{height:82px;background:var(--card);border-top:1px solid var(--bd-l);display:flex;align-items:flex-start;padding-top:6px;padding-bottom:env(safe-area-inset-bottom,0);flex-shrink:0}
|
||||||
|
.tab-item{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;cursor:pointer;color:var(--tx3);transition:color .2s var(--ease);padding:6px 0;min-height:44px;justify-content:center}
|
||||||
|
.tab-item:active{opacity:.7}
|
||||||
|
.tab-item.active{color:var(--pri)}
|
||||||
|
.tab-item svg{width:24px;height:24px;stroke-width:1.8}
|
||||||
|
.tab-item span{font-size:10px;font-weight:500;letter-spacing:.2px}
|
||||||
|
/* 卡片 */
|
||||||
|
.card{background:var(--card);border-radius:var(--r);padding:16px;margin:0 16px 12px;box-shadow:var(--sh);border:1px solid rgba(0,0,0,.03);transition:box-shadow .2s}
|
||||||
|
.card-title{font-size:15px;font-weight:600;margin-bottom:12px;display:flex;align-items:center;gap:8px;color:var(--tx)}
|
||||||
|
.card-title svg{flex-shrink:0}
|
||||||
|
/* 标签 */
|
||||||
|
.tag{display:inline-flex;align-items:center;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:500;letter-spacing:.2px;line-height:1.6}
|
||||||
|
.tag-g{background:#D1FAE5;color:#065F46}.tag-y{background:#FEF3C7;color:#92400E}
|
||||||
|
.tag-r{background:#FEE2E2;color:#991B1B}.tag-b{background:#DBEAFE;color:#1E40AF}
|
||||||
|
.tag-gray{background:#F3F4F6;color:#4B5563}
|
||||||
|
/* 按钮 */
|
||||||
|
.btn{border:none;border-radius:var(--r-sm);padding:12px 20px;font-size:14px;font-weight:600;cursor:pointer;transition:all .2s var(--ease);min-height:44px;display:inline-flex;align-items:center;justify-content:center;letter-spacing:.3px}
|
||||||
|
.btn:active{transform:scale(.97)}
|
||||||
|
.btn-pri{background:var(--pri);color:#fff;box-shadow:0 2px 8px rgba(8,145,178,.25)}
|
||||||
|
.btn-pri:active{background:var(--pri-d)}
|
||||||
|
.btn-acc{background:var(--acc);color:#fff;box-shadow:0 2px 8px rgba(5,150,105,.25)}
|
||||||
|
.btn-out{background:transparent;border:1.5px solid var(--pri);color:var(--pri)}
|
||||||
|
.btn-out:active{background:var(--pri-l)}
|
||||||
|
/* 工具类 */
|
||||||
|
.flex{display:flex}.aic{align-items:center}.jcsb{justify-content:space-between}
|
||||||
|
.gap6{gap:6px}.gap8{gap:8px}.gap12{gap:12px}.gap16{gap:16px}
|
||||||
|
/* 分割线 */
|
||||||
|
.sep{height:1px;background:var(--bd-l);margin:0}
|
||||||
|
.sep-v{width:1px;background:var(--bd);align-self:stretch}
|
||||||
|
/* 输入框 */
|
||||||
|
.form-input{width:100%;padding:10px 14px;border:1.5px solid var(--bd);border-radius:var(--r-sm);font-size:14px;background:var(--card);outline:none;transition:border-color .2s;font-family:inherit;color:var(--tx);min-height:44px}
|
||||||
|
.form-input:focus{border-color:var(--pri);box-shadow:0 0 0 3px rgba(8,145,178,.1)}
|
||||||
|
.form-input::placeholder{color:var(--tx3)}
|
||||||
|
.form-label{font-size:12px;font-weight:500;color:var(--tx2);display:block;margin-bottom:6px;letter-spacing:.2px}
|
||||||
|
/* 节标题 */
|
||||||
|
.section-title{font-size:13px;font-weight:600;color:var(--tx2);margin-bottom:10px;padding:0 16px;letter-spacing:.3px;text-transform:uppercase}
|
||||||
|
/* 无障碍 */
|
||||||
|
@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="mode-switch">
|
||||||
|
<button class="mode-btn active" onclick="switchMode('patient')">患者端</button>
|
||||||
|
<button class="mode-btn" onclick="switchMode('doctor')">医护端</button>
|
||||||
|
</div>
|
||||||
|
<div class="app">
|
||||||
|
|
||||||
|
<!-- ==================== 患者端页面 ==================== -->
|
||||||
|
|
||||||
|
<!-- P1: 首页 -->
|
||||||
|
<div id="page-home" class="page active" data-mode="patient">
|
||||||
|
<div class="status-bar" style="background:var(--pri)">
|
||||||
|
<span>9:41</span><span style="display:flex;gap:4px"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12h2v-2H1zm4-3h2V7H5zm4-3h2V4H9zm4-3h2V1h-2z"/></svg><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="4" width="14" height="10" rx="2"/><path d="M4 4V2a4 4 0 018 0"/></svg></span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-bar" style="background:var(--pri)">健康管理</div>
|
||||||
|
<div class="content" style="padding-bottom:80px">
|
||||||
|
<!-- 问候区 -->
|
||||||
|
<div style="background:linear-gradient(135deg,var(--pri),var(--pri-d));padding:20px 16px;color:#fff">
|
||||||
|
<div class="flex aic jcsb">
|
||||||
|
<div class="flex aic gap12">
|
||||||
|
<svg width="44" height="44" viewBox="0 0 44 44"><circle cx="22" cy="22" r="22" fill="rgba(255,255,255,.2)"/><circle cx="22" cy="18" r="7" fill="rgba(255,255,255,.5)"/><ellipse cx="22" cy="34" rx="12" ry="8" fill="rgba(255,255,255,.3)"/></svg>
|
||||||
|
<div><div style="font-size:18px;font-weight:700">早上好,张明</div><div style="font-size:12px;opacity:.8">2026年4月23日 星期四</div></div>
|
||||||
|
</div>
|
||||||
|
<svg width="24" height="24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><circle cx="18" cy="3" r="3" fill="#EF4444" stroke="none"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 今日健康 -->
|
||||||
|
<div class="card" style="margin-top:-20px;position:relative;z-index:1">
|
||||||
|
<div class="card-title"><svg width="16" height="16" fill="none" stroke="var(--dan)" stroke-width="2"><path d="M20.8 4.6a5.5 5.5 0 00-7.8 0L12 5.7l-1-1.1A5.5 5.5 0 003.2 13.6l1 1.1L12 22l7.8-7.3 1-1.1a5.5 5.5 0 000-7.8z" transform="scale(.65) translate(2,2)"/></svg>今日健康</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||||
|
<div style="background:linear-gradient(135deg,#FEF2F2,#FEE2E2);padding:14px 12px;border-radius:var(--r-sm);display:flex;flex-direction:column;gap:2px"><div style="font-size:11px;font-weight:500;color:var(--tx2);letter-spacing:.3px">血压</div><div style="font-size:22px;font-weight:700;color:#B91C1C;letter-spacing:-.5px;line-height:1.2">128/82</div><div style="font-size:10px;color:var(--tx3)">mmHg · 偏高</div></div>
|
||||||
|
<div style="background:linear-gradient(135deg,#FFFBEB,#FEF3C7);padding:14px 12px;border-radius:var(--r-sm);display:flex;flex-direction:column;gap:2px"><div style="font-size:11px;font-weight:500;color:var(--tx2);letter-spacing:.3px">心率</div><div style="font-size:22px;font-weight:700;color:#B45309;letter-spacing:-.5px;line-height:1.2">72</div><div style="font-size:10px;color:var(--tx3)">bpm · 正常</div></div>
|
||||||
|
<div style="background:linear-gradient(135deg,var(--pri-l),var(--pri-surface));padding:14px 12px;border-radius:var(--r-sm);display:flex;flex-direction:column;gap:2px"><div style="font-size:11px;font-weight:500;color:var(--tx2);letter-spacing:.3px">血糖</div><div style="font-size:22px;font-weight:700;color:#0E7490;letter-spacing:-.5px;line-height:1.2">5.6</div><div style="font-size:10px;color:var(--tx3)">mmol/L · 正常</div></div>
|
||||||
|
<div style="background:linear-gradient(135deg,var(--acc-l),#ECFDF5);padding:14px 12px;border-radius:var(--r-sm);display:flex;flex-direction:column;gap:2px"><div style="font-size:11px;font-weight:500;color:var(--tx2);letter-spacing:.3px">体重</div><div style="font-size:22px;font-weight:700;color:#047857;letter-spacing:-.5px;line-height:1.2">68.5</div><div style="font-size:10px;color:var(--tx3)">kg · 稳定</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 快捷入口 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">快捷服务</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:4px;text-align:center">
|
||||||
|
<div style="cursor:pointer;padding:4px"><div style="width:48px;height:48px;border-radius:14px;background:linear-gradient(135deg,#0891B2,#06B6D4);margin:0 auto 8px;display:flex;align-items:center;justify-content:center;box-shadow:0 3px 10px rgba(8,145,178,.3)"><svg width="22" height="22" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M12 20V10M6 20V4M18 20v-6"/></svg></div><div style="font-size:12px;font-weight:500;color:var(--tx)">录数据</div></div>
|
||||||
|
<div style="cursor:pointer;padding:4px"><div style="width:48px;height:48px;border-radius:14px;background:linear-gradient(135deg,#059669,#10B981);margin:0 auto 8px;display:flex;align-items:center;justify-content:center;box-shadow:0 3px 10px rgba(5,150,105,.3)"><svg width="22" height="22" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg></div><div style="font-size:12px;font-weight:500;color:var(--tx)">预约</div></div>
|
||||||
|
<div style="cursor:pointer;padding:4px"><div style="width:48px;height:48px;border-radius:14px;background:linear-gradient(135deg,#7C3AED,#A78BFA);margin:0 auto 8px;display:flex;align-items:center;justify-content:center;box-shadow:0 3px 10px rgba(124,58,237,.25)"><svg width="22" height="22" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></svg></div><div style="font-size:12px;font-weight:500;color:var(--tx)">报告</div></div>
|
||||||
|
<div style="cursor:pointer;padding:4px"><div style="width:48px;height:48px;border-radius:14px;background:linear-gradient(135deg,#D97706,#FBBF24);margin:0 auto 8px;display:flex;align-items:center;justify-content:center;box-shadow:0 3px 10px rgba(217,119,6,.25)"><svg width="22" height="22" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg></div><div style="font-size:12px;font-weight:500;color:var(--tx)">咨询</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 即将预约 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title"><svg width="16" height="16" fill="none" stroke="var(--pri)" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>明日值班</div>
|
||||||
|
<div class="flex aic gap12" style="padding:10px;background:var(--pri-surface);border-radius:var(--r-sm)">
|
||||||
|
<svg width="44" height="44" viewBox="0 0 44 44"><circle cx="22" cy="22" r="22" fill="var(--pri-l)"/><circle cx="22" cy="17" r="7" fill="var(--pri)"/><ellipse cx="22" cy="32" rx="11" ry="8" fill="var(--pri)" opacity=".4"/></svg>
|
||||||
|
<div style="flex:1;min-width:0"><div style="font-weight:600;font-size:14px">王建国 主任医师</div><div style="font-size:12px;color:var(--tx2);margin-top:2px">肾内科 · 明天 09:00</div></div>
|
||||||
|
<span class="tag tag-g">已确认</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 待办随访 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title"><svg width="16" height="16" fill="none" stroke="var(--acc)" stroke-width="2"><path d="M9 11l3 3L22 4"/><rect x="3" y="4" width="18" height="18" rx="2" transform="scale(.7) translate(3,3)"/></svg>待办随访</div>
|
||||||
|
<div class="flex aic jcsb" style="padding:10px 0;border-bottom:1px solid var(--bd-l)">
|
||||||
|
<div class="flex aic gap8">
|
||||||
|
<div style="width:22px;height:22px;border:2px solid var(--acc);border-radius:6px;flex-shrink:0"></div>
|
||||||
|
<div><div style="font-size:14px;font-weight:500">血压监测记录</div><div style="font-size:11px;color:var(--tx3)">今日到期</div></div>
|
||||||
|
</div>
|
||||||
|
<span class="tag tag-y">今日</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex aic jcsb" style="padding:10px 0">
|
||||||
|
<div class="flex aic gap8">
|
||||||
|
<div style="width:22px;height:22px;border:2px solid var(--bd);border-radius:6px;flex-shrink:0"></div>
|
||||||
|
<div><div style="font-size:14px;font-weight:500">健康问卷调查</div><div style="font-size:11px;color:var(--tx3)">3天后到期</div></div>
|
||||||
|
</div>
|
||||||
|
<span class="tag tag-b">3天后</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 患者端Tab -->
|
||||||
|
<div class="tab-bar" data-mode="patient">
|
||||||
|
<div class="tab-item active" onclick="showPage('home','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><span>首页</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('data','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20V10M6 20V4M18 20v-6"/></svg><span>数据</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('appointment','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg><span>预约</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('profile','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span>我的</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- P2: 健康数据 -->
|
||||||
|
<div id="page-data" class="page" data-mode="patient">
|
||||||
|
<div class="status-bar" style="background:var(--pri)"><span>9:41</span><span></span></div>
|
||||||
|
<div class="nav-bar" style="background:var(--pri)">健康数据</div>
|
||||||
|
<div class="content" style="padding-bottom:80px">
|
||||||
|
<!-- 指标切换 -->
|
||||||
|
<div class="flex" style="padding:16px 16px 0;gap:0">
|
||||||
|
<div style="flex:1;text-align:center;padding:10px;font-size:14px;font-weight:600;color:var(--pri);border-bottom:2.5px solid var(--pri);cursor:pointer">血压</div>
|
||||||
|
<div style="flex:1;text-align:center;padding:10px;font-size:14px;color:var(--tx3);border-bottom:2.5px solid transparent;cursor:pointer">血糖</div>
|
||||||
|
<div style="flex:1;text-align:center;padding:10px;font-size:14px;color:var(--tx3);border-bottom:2.5px solid transparent;cursor:pointer">体重</div>
|
||||||
|
</div>
|
||||||
|
<!-- SVG趋势图 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">7日趋势</div>
|
||||||
|
<svg viewBox="0 0 320 180" style="width:100%;height:auto">
|
||||||
|
<!-- 网格 -->
|
||||||
|
<line x1="40" y1="20" x2="310" y2="20" stroke="#E5E7EB" stroke-dasharray="4"/><line x1="40" y1="60" x2="310" y2="60" stroke="#E5E7EB" stroke-dasharray="4"/><line x1="40" y1="100" x2="310" y2="100" stroke="#E5E7EB" stroke-dasharray="4"/><line x1="40" y1="140" x2="310" y2="140" stroke="#E5E7EB" stroke-dasharray="4"/>
|
||||||
|
<!-- Y轴标签 -->
|
||||||
|
<text x="35" y="24" font-size="10" fill="#6B7280" text-anchor="end">145</text><text x="35" y="64" font-size="10" fill="#6B7280" text-anchor="end">125</text><text x="35" y="104" font-size="10" fill="#6B7280" text-anchor="end">105</text><text x="35" y="144" font-size="10" fill="#6B7280" text-anchor="end">85</text>
|
||||||
|
<!-- 正常范围 -->
|
||||||
|
<rect x="40" y="36" width="270" height="64" fill="#D1FAE5" opacity=".3"/>
|
||||||
|
<!-- X轴日期 -->
|
||||||
|
<text x="58" y="170" font-size="10" fill="#6B7280" text-anchor="middle">17日</text><text x="101" y="170" font-size="10" fill="#6B7280" text-anchor="middle">18日</text><text x="144" y="170" font-size="10" fill="#6B7280" text-anchor="middle">19日</text><text x="187" y="170" font-size="10" fill="#6B7280" text-anchor="middle">20日</text><text x="230" y="170" font-size="10" fill="#6B7280" text-anchor="middle">21日</text><text x="273" y="170" font-size="10" fill="#6B7280" text-anchor="middle">22日</text><text x="305" y="170" font-size="10" fill="#6B7280" text-anchor="middle">23日</text>
|
||||||
|
<!-- 收缩压(红) -->
|
||||||
|
<polyline points="58,68 101,56 144,72 187,44 230,60 273,48 305,52" fill="none" stroke="#EF4444" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="305" cy="52" r="4" fill="#EF4444"/>
|
||||||
|
<!-- 舒张压(蓝) -->
|
||||||
|
<polyline points="58,112 101,104 144,116 187,96 230,108 273,100 305,104" fill="none" stroke="#0891B2" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="305" cy="104" r="4" fill="#0891B2"/>
|
||||||
|
<!-- 最新值标注 -->
|
||||||
|
<rect x="268" y="32" width="42" height="16" rx="4" fill="#EF4444"/><text x="289" y="43" font-size="9" fill="#fff" text-anchor="middle">128</text>
|
||||||
|
<rect x="268" y="86" width="42" height="16" rx="4" fill="#0891B2"/><text x="289" y="97" font-size="9" fill="#fff" text-anchor="middle">82</text>
|
||||||
|
</svg>
|
||||||
|
<div class="flex aic gap8" style="margin-top:8px;font-size:11px;color:var(--tx2)"><span style="display:flex;align-items:center;gap:4px"><span style="width:10px;height:3px;background:#EF4444;display:inline-block;border-radius:2px"></span>收缩压</span><span style="display:flex;align-items:center;gap:4px"><span style="width:10px;height:3px;background:var(--pri);display:inline-block;border-radius:2px"></span>舒张压</span><span style="display:flex;align-items:center;gap:4px"><span style="width:10px;height:10px;background:#D1FAE5;display:inline-block;border-radius:2px;opacity:.5"></span>正常范围</span></div>
|
||||||
|
</div>
|
||||||
|
<!-- 录入表单 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">记录数据</div>
|
||||||
|
<div style="margin-bottom:12px"><label class="form-label">日期</label><input type="date" value="2026-04-23" class="form-input"></div>
|
||||||
|
<div class="flex gap8">
|
||||||
|
<div style="flex:1"><label class="form-label">收缩压</label><input type="number" inputmode="numeric" placeholder="120" class="form-input"></div>
|
||||||
|
<div style="flex:1"><label class="form-label">舒张压</label><input type="number" inputmode="numeric" placeholder="80" class="form-input"></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px"><label class="form-label">心率</label><input type="number" inputmode="numeric" placeholder="72" class="form-input"></div>
|
||||||
|
<button class="btn btn-pri" style="width:100%;margin-top:16px">保存记录</button>
|
||||||
|
</div>
|
||||||
|
<!-- 历史记录 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">历史记录</div>
|
||||||
|
<div style="font-size:13px">
|
||||||
|
<div class="flex jcsb aic" style="padding:8px 0;border-bottom:1px solid var(--bd)"><div><strong>128/82</strong> mmHg · 72 bpm</div><div style="color:var(--tx2);font-size:11px">今天 08:30</div></div>
|
||||||
|
<div class="flex jcsb aic" style="padding:8px 0;border-bottom:1px solid var(--bd)"><div><strong>125/78</strong> mmHg · 70 bpm</div><div style="color:var(--tx2);font-size:11px">昨天 07:45</div></div>
|
||||||
|
<div class="flex jcsb aic" style="padding:8px 0;border-bottom:1px solid var(--bd)"><div><strong>132/85</strong> mmHg · 75 bpm</div><div style="color:var(--tx2);font-size:11px">4/21 08:15</div></div>
|
||||||
|
<div class="flex jcsb aic" style="padding:8px 0;border-bottom:1px solid var(--bd)"><div><strong>118/76</strong> mmHg · 68 bpm</div><div style="color:var(--tx2);font-size:11px">4/20 07:50</div></div>
|
||||||
|
<div class="flex jcsb aic" style="padding:8px 0"><div><strong>130/84</strong> mmHg · 74 bpm</div><div style="color:var(--tx2);font-size:11px">4/19 08:00</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-bar" data-mode="patient">
|
||||||
|
<div class="tab-item" onclick="showPage('home','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><span>首页</span></div>
|
||||||
|
<div class="tab-item active" onclick="showPage('data','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20V10M6 20V4M18 20v-6"/></svg><span>数据</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('appointment','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg><span>预约</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('profile','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span>我的</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- P3: 预约挂号 -->
|
||||||
|
<div id="page-appointment" class="page" data-mode="patient">
|
||||||
|
<div class="status-bar" style="background:var(--pri)"><span>9:41</span><span></span></div>
|
||||||
|
<div class="nav-bar" style="background:var(--pri)">预约挂号</div>
|
||||||
|
<div class="content" style="padding-bottom:80px">
|
||||||
|
<!-- 搜索 -->
|
||||||
|
<div style="padding:12px 16px"><div style="background:var(--card);border-radius:24px;padding:10px 16px;display:flex;align-items:center;gap:10px;box-shadow:var(--sh);border:1px solid rgba(0,0,0,.04)"><svg width="16" height="16" fill="none" stroke="var(--tx3)" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input placeholder="搜索医生/科室" style="border:none;outline:none;font-size:14px;flex:1;background:transparent;color:var(--tx);min-height:24px"></div></div>
|
||||||
|
<!-- 科室筛选 -->
|
||||||
|
<div style="padding:0 16px 12px;display:flex;gap:6px;overflow-x:auto;-webkit-overflow-scrolling:touch">
|
||||||
|
<span style="padding:6px 16px;border-radius:20px;font-size:12px;font-weight:600;background:var(--pri);color:#fff;white-space:nowrap;cursor:pointer">全部</span>
|
||||||
|
<span style="padding:6px 16px;border-radius:20px;font-size:12px;background:var(--card);color:var(--tx);border:1px solid var(--bd);white-space:nowrap;cursor:pointer">肾内科</span>
|
||||||
|
<span style="padding:6px 16px;border-radius:20px;font-size:12px;background:var(--card);color:var(--tx);border:1px solid var(--bd);white-space:nowrap;cursor:pointer">心内科</span>
|
||||||
|
<span style="padding:6px 16px;border-radius:20px;font-size:12px;background:var(--card);color:var(--tx);border:1px solid var(--bd);white-space:nowrap;cursor:pointer">内分泌</span>
|
||||||
|
<span style="padding:6px 16px;border-radius:20px;font-size:12px;background:var(--card);color:var(--tx);border:1px solid var(--bd);white-space:nowrap;cursor:pointer">全科</span>
|
||||||
|
</div>
|
||||||
|
<!-- 医生列表 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex gap12">
|
||||||
|
<svg width="52" height="52" viewBox="0 0 52 52"><circle cx="26" cy="26" r="26" fill="var(--pri-l)"/><circle cx="26" cy="21" r="8" fill="var(--pri)"/><ellipse cx="26" cy="38" rx="13" ry="9" fill="var(--pri)" opacity=".5"/></svg>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="flex jcsb aic"><div><strong>王建国</strong> <span style="font-size:12px;color:var(--tx2)">主任医师</span></div><button class="btn btn-pri" style="padding:6px 16px;font-size:12px">预约</button></div>
|
||||||
|
<div style="font-size:12px;color:var(--tx2);margin:4px 0">肾内科</div>
|
||||||
|
<div class="flex gap8" style="margin:4px 0"><span class="tag tag-b">慢性肾病</span><span class="tag tag-g">透析管理</span></div>
|
||||||
|
<div style="font-size:11px;color:var(--tx2);margin-top:4px">★ 4.9 · 接诊 1,286 人</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex gap12">
|
||||||
|
<svg width="52" height="52" viewBox="0 0 52 52"><circle cx="26" cy="26" r="26" fill="#FEF3C7"/><circle cx="26" cy="21" r="8" fill="#F59E0B"/><ellipse cx="26" cy="38" rx="13" ry="9" fill="#F59E0B" opacity=".5"/></svg>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="flex jcsb aic"><div><strong>李芳</strong> <span style="font-size:12px;color:var(--tx2)">副主任医师</span></div><button class="btn btn-pri" style="padding:6px 16px;font-size:12px">预约</button></div>
|
||||||
|
<div style="font-size:12px;color:var(--tx2);margin:4px 0">心内科</div>
|
||||||
|
<div class="flex gap8" style="margin:4px 0"><span class="tag tag-r">高血压</span><span class="tag tag-b">冠心病</span></div>
|
||||||
|
<div style="font-size:11px;color:var(--tx2);margin-top:4px">★ 4.8 · 接诊 986 人</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex gap12">
|
||||||
|
<svg width="52" height="52" viewBox="0 0 52 52"><circle cx="26" cy="26" r="26" fill="var(--acc-l)"/><circle cx="26" cy="21" r="8" fill="var(--acc)"/><ellipse cx="26" cy="38" rx="13" ry="9" fill="var(--acc)" opacity=".5"/></svg>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="flex jcsb aic"><div><strong>赵明辉</strong> <span style="font-size:12px;color:var(--tx2)">主治医师</span></div><button class="btn btn-pri" style="padding:6px 16px;font-size:12px">预约</button></div>
|
||||||
|
<div style="font-size:12px;color:var(--tx2);margin:4px 0">内分泌科</div>
|
||||||
|
<div class="flex gap8" style="margin:4px 0"><span class="tag tag-y">糖尿病</span><span class="tag tag-g">甲状腺</span></div>
|
||||||
|
<div style="font-size:11px;color:var(--tx2);margin-top:4px">★ 4.7 · 接诊 756 人</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 预约弹窗 -->
|
||||||
|
<div style="background:var(--card);border-radius:var(--r-lg) var(--r-lg) 0 0;padding:20px 16px;border-top:3px solid var(--pri);margin-top:8px;box-shadow:var(--sh-md)">
|
||||||
|
<div style="font-weight:600;font-size:15px;margin-bottom:16px;color:var(--tx)">选择预约时间 · 王建国</div>
|
||||||
|
<div class="flex jcsb" style="margin-bottom:16px">
|
||||||
|
<div style="text-align:center;padding:10px 8px;border-radius:8px;background:var(--bg);min-width:44px"><div style="font-size:11px;color:var(--tx2)">周四</div><div style="font-size:15px;font-weight:700">24</div><div style="width:6px;height:6px;border-radius:50%;background:var(--acc);margin:4px auto 0"></div></div>
|
||||||
|
<div style="text-align:center;padding:10px 8px;border-radius:8px;background:var(--pri-l);min-width:44px;border:2px solid var(--pri)"><div style="font-size:11px;color:var(--pri)">周五</div><div style="font-size:15px;font-weight:700;color:var(--pri)">25</div><div style="width:6px;height:6px;border-radius:50%;background:var(--pri);margin:4px auto 0"></div></div>
|
||||||
|
<div style="text-align:center;padding:10px 8px;border-radius:8px;background:var(--bg);min-width:44px"><div style="font-size:11px;color:var(--tx2)">周六</div><div style="font-size:15px;font-weight:700">26</div><div style="width:6px;height:6px;border-radius:50%;background:var(--bd);margin:4px auto 0"></div></div>
|
||||||
|
<div style="text-align:center;padding:10px 8px;border-radius:8px;background:var(--bg);min-width:44px"><div style="font-size:11px;color:var(--tx2)">周日</div><div style="font-size:15px;font-weight:700">27</div></div>
|
||||||
|
<div style="text-align:center;padding:10px 8px;border-radius:8px;background:var(--bg);min-width:44px"><div style="font-size:11px;color:var(--tx2)">周一</div><div style="font-size:15px;font-weight:700">28</div><div style="width:6px;height:6px;border-radius:50%;background:var(--acc);margin:4px auto 0"></div></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;color:var(--tx2);margin-bottom:8px">选择时段</div>
|
||||||
|
<div class="flex gap8" style="margin-bottom:16px">
|
||||||
|
<div style="flex:1;padding:10px;border-radius:8px;background:var(--pri);color:#fff;text-align:center;font-size:13px;font-weight:600">上午 09:00</div>
|
||||||
|
<div style="flex:1;padding:10px;border-radius:8px;background:var(--bg);text-align:center;font-size:13px;border:1px solid var(--bd)">下午 14:00</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-pri" style="width:100%">确认预约</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-bar" data-mode="patient">
|
||||||
|
<div class="tab-item" onclick="showPage('home','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><span>首页</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('data','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20V10M6 20V4M18 20v-6"/></svg><span>数据</span></div>
|
||||||
|
<div class="tab-item active" onclick="showPage('appointment','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg><span>预约</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('profile','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span>我的</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- P4: 我的报告 -->
|
||||||
|
<div id="page-report" class="page" data-mode="patient">
|
||||||
|
<div class="status-bar" style="background:var(--pri)"><span>9:41</span><span></span></div>
|
||||||
|
<div class="nav-bar" style="background:var(--pri)">
|
||||||
|
<span class="back" onclick="showPage('home','patient')"><svg width="20" height="20" fill="none" stroke="#fff" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg></span>
|
||||||
|
我的报告
|
||||||
|
</div>
|
||||||
|
<div class="content" style="padding-bottom:80px">
|
||||||
|
<div class="flex" style="padding:12px 16px;gap:0">
|
||||||
|
<div style="flex:1;text-align:center;padding:8px;font-size:13px;font-weight:600;color:var(--pri);border-bottom:2px solid var(--pri)">化验报告</div>
|
||||||
|
<div style="flex:1;text-align:center;padding:8px;font-size:13px;color:var(--tx2);border-bottom:2px solid transparent">体检记录</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0 16px 8px"><select style="width:100%;padding:8px 12px;border:1px solid var(--bd);border-radius:8px;font-size:13px;background:var(--card)"><option>全部类型</option><option>肾功能</option><option>血常规</option><option>尿常规</option></select></div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex jcsb aic"><div><strong style="font-size:14px">肾功能检查</strong><div style="font-size:11px;color:var(--tx2);margin-top:2px">2026-04-20 · 市第一医院</div></div><span class="tag tag-r">2项异常</span></div>
|
||||||
|
<div style="margin-top:8px;font-size:12px;color:var(--tx2)">正常 8 项,<span style="color:var(--dan)">异常 2 项</span></div>
|
||||||
|
<div style="margin-top:8px;font-size:12px;background:#FEF2F2;padding:8px;border-radius:6px"><span style="color:var(--dan)">肌酐 156 μmol/L (参考 44-133)</span><br><span style="color:var(--dan)">尿素氮 9.8 mmol/L (参考 2.5-7.1)</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex jcsb aic"><div><strong style="font-size:14px">血常规</strong><div style="font-size:11px;color:var(--tx2);margin-top:2px">2026-04-15 · 仁爱体检中心</div></div><span class="tag tag-g">全部正常</span></div>
|
||||||
|
<div style="margin-top:8px;font-size:12px;color:var(--tx2)">正常 12 项,异常 0 项</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex jcsb aic"><div><strong style="font-size:14px">尿常规</strong><div style="font-size:11px;color:var(--tx2);margin-top:2px">2026-04-10 · 市第一医院</div></div><span class="tag tag-y">1项异常</span></div>
|
||||||
|
<div style="margin-top:8px;font-size:12px;color:var(--tx2)">正常 9 项,<span style="color:var(--wrn)">异常 1 项</span></div>
|
||||||
|
</div>
|
||||||
|
<!-- 报告详情(模拟展开) -->
|
||||||
|
<div class="card" style="border:2px solid var(--pri)">
|
||||||
|
<div class="flex jcsb aic" style="margin-bottom:8px"><strong style="font-size:15px">肾功能检查详情</strong><span class="tag tag-g">已出</span></div>
|
||||||
|
<div style="font-size:12px;color:var(--tx2);margin-bottom:12px">2026-04-20 · 市第一医院检验科</div>
|
||||||
|
<div style="font-size:12px;border-collapse:collapse;width:100%">
|
||||||
|
<div class="flex" style="padding:6px 0;border-bottom:1px solid var(--bd);font-weight:600;color:var(--tx2)"><div style="flex:2">指标</div><div style="flex:1">结果</div><div style="flex:1">单位</div><div style="flex:1.2">参考值</div></div>
|
||||||
|
<div class="flex" style="padding:6px 0;border-bottom:1px solid var(--bd)"><div style="flex:2">肌酐</div><div style="flex:1;color:var(--dan);font-weight:600">156</div><div style="flex:1;font-size:11px">μmol/L</div><div style="flex:1.2;font-size:11px">44-133</div></div>
|
||||||
|
<div class="flex" style="padding:6px 0;border-bottom:1px solid var(--bd)"><div style="flex:2">尿素氮</div><div style="flex:1;color:var(--dan);font-weight:600">9.8</div><div style="flex:1;font-size:11px">mmol/L</div><div style="flex:1.2;font-size:11px">2.5-7.1</div></div>
|
||||||
|
<div class="flex" style="padding:6px 0;border-bottom:1px solid var(--bd)"><div style="flex:2">尿酸</div><div style="flex:1">386</div><div style="flex:1;font-size:11px">μmol/L</div><div style="flex:1.2;font-size:11px">150-420</div></div>
|
||||||
|
<div class="flex" style="padding:6px 0"><div style="flex:2">eGFR</div><div style="flex:1;color:var(--acc)">72</div><div style="flex:1;font-size:11px">ml/min</div><div style="flex:1.2;font-size:11px">>60</div></div>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--bg);border-radius:8px;padding:12px;margin-top:12px">
|
||||||
|
<div style="font-size:12px;font-weight:600;margin-bottom:4px">医生解读</div>
|
||||||
|
<div style="font-size:12px;color:var(--tx2);line-height:1.6">肌酐和尿素氮轻度升高,建议控制蛋白质摄入,两周后复查肾功能。eGFR 72 处于轻度下降阶段,需持续关注。</div>
|
||||||
|
<div style="font-size:11px;color:var(--tx2);margin-top:4px">—— 王建国 主任医师</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-bar" data-mode="patient">
|
||||||
|
<div class="tab-item" onclick="showPage('home','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><span>首页</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('data','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20V10M6 20V4M18 20v-6"/></svg><span>数据</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('appointment','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg><span>预约</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('profile','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span>我的</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- P5: 在线咨询 -->
|
||||||
|
<div id="page-consult" class="page" data-mode="patient">
|
||||||
|
<div class="status-bar" style="background:var(--pri)"><span>9:41</span><span></span></div>
|
||||||
|
<div class="nav-bar" style="background:var(--pri)">在线咨询</div>
|
||||||
|
<div class="content" style="padding-bottom:80px">
|
||||||
|
<!-- 会话列表 -->
|
||||||
|
<div style="padding:12px 16px"><div style="background:var(--card);border-radius:20px;padding:8px 14px;display:flex;align-items:center;gap:8px;box-shadow:var(--sh)"><svg width="16" height="16" fill="none" stroke="var(--tx2)" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input placeholder="搜索医生" style="border:none;outline:none;font-size:14px;flex:1;background:transparent"></div></div>
|
||||||
|
<div class="card" style="cursor:pointer">
|
||||||
|
<div class="flex gap12">
|
||||||
|
<div style="position:relative"><svg width="48" height="48" viewBox="0 0 48 48"><circle cx="24" cy="24" r="24" fill="var(--pri-l)"/><circle cx="24" cy="19" r="7" fill="var(--pri)"/><ellipse cx="24" cy="35" rx="11" ry="8" fill="var(--pri)" opacity=".5"/></svg><div style="position:absolute;bottom:2px;right:2px;width:10px;height:10px;border-radius:50%;background:var(--acc);border:2px solid #fff"></div></div>
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div class="flex jcsb"><div><strong>王建国</strong> <span style="font-size:11px;color:var(--tx2)">肾内科</span></div><span style="font-size:11px;color:var(--tx2)">09:30</span></div>
|
||||||
|
<div style="font-size:13px;color:var(--tx2);margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">您好,我最近血压波动比较大,想咨询一下...</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--dan);color:#fff;border-radius:10px;min-width:18px;height:18px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;margin-top:16px">3</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="cursor:pointer">
|
||||||
|
<div class="flex gap12">
|
||||||
|
<div style="position:relative"><svg width="48" height="48" viewBox="0 0 48 48"><circle cx="24" cy="24" r="24" fill="#FEF3C7"/><circle cx="24" cy="19" r="7" fill="#F59E0B"/><ellipse cx="24" cy="35" rx="11" ry="8" fill="#F59E0B" opacity=".5"/></svg><div style="position:absolute;bottom:2px;right:2px;width:10px;height:10px;border-radius:50%;background:#9CA3AF;border:2px solid #fff"></div></div>
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div class="flex jcsb"><div><strong>李芳</strong> <span style="font-size:11px;color:var(--tx2)">心内科</span></div><span style="font-size:11px;color:var(--tx2)">昨天</span></div>
|
||||||
|
<div style="font-size:13px;color:var(--tx2);margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">好的,我明白了。建议您按时服药并...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 聊天界面(内嵌展示) -->
|
||||||
|
<div style="margin:8px 0;padding:12px 16px;border-top:3px solid var(--bd)">
|
||||||
|
<div style="text-align:center;font-size:11px;color:var(--tx2);margin-bottom:16px;background:var(--bg);padding:8px;border-radius:8px">以下为与王建国医生的对话预览</div>
|
||||||
|
<!-- 对方消息 -->
|
||||||
|
<div class="flex gap8" style="margin-bottom:16px">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 36 36"><circle cx="18" cy="18" r="18" fill="var(--pri-l)"/><circle cx="18" cy="14" r="5" fill="var(--pri)"/></svg>
|
||||||
|
<div style="max-width:70%"><div style="background:var(--card);padding:10px 14px;border-radius:12px 12px 12px 2px;font-size:13px;line-height:1.5;box-shadow:var(--sh)">您好张先生,请问最近血压控制得怎么样?有按时服药吗?</div><div style="font-size:10px;color:var(--tx2);margin-top:2px">09:30</div></div>
|
||||||
|
</div>
|
||||||
|
<!-- 我方消息 -->
|
||||||
|
<div class="flex gap8" style="margin-bottom:16px;flex-direction:row-reverse">
|
||||||
|
<div style="max-width:70%"><div style="background:#DCF5F7;padding:10px 14px;border-radius:12px 12px 2px 12px;font-size:13px;line-height:1.5">王医生好,最近血压波动比较大,早上量了一次128/82,有时会到140/90</div><div style="font-size:10px;color:var(--tx2);margin-top:2px;text-align:right">09:32</div></div>
|
||||||
|
</div>
|
||||||
|
<!-- 对方消息 -->
|
||||||
|
<div class="flex gap8" style="margin-bottom:16px">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 36 36"><circle cx="18" cy="18" r="18" fill="var(--pri-l)"/><circle cx="18" cy="14" r="5" fill="var(--pri)"/></svg>
|
||||||
|
<div style="max-width:70%"><div style="background:var(--card);padding:10px 14px;border-radius:12px 12px 12px 2px;font-size:13px;line-height:1.5;box-shadow:var(--sh)">了解,您的血压确实有些波动。建议:1. 每天固定时间测血压并记录 2. 低盐饮食 3. 我帮您调整一下用药方案,请明天来门诊</div><div style="font-size:10px;color:var(--tx2);margin-top:2px">09:35</div></div>
|
||||||
|
</div>
|
||||||
|
<!-- 系统消息 -->
|
||||||
|
<div style="text-align:center;font-size:11px;color:var(--tx2);margin:12px 0;padding:4px 12px;background:var(--bg);display:inline-block;border-radius:10px">系统提示:已为您预约明天 09:00 王建国医生门诊</div>
|
||||||
|
<!-- 输入栏 -->
|
||||||
|
<div class="flex aic gap8" style="padding:10px 16px;background:var(--card);border-top:1px solid var(--bd);margin-top:auto;position:sticky;bottom:0">
|
||||||
|
<svg width="22" height="22" fill="none" stroke="var(--tx2)" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M12 8v8M8 12h8"/></svg>
|
||||||
|
<input placeholder="输入消息..." style="flex:1;border:none;outline:none;font-size:14px;padding:8px 12px;background:var(--bg);border-radius:20px">
|
||||||
|
<div style="width:36px;height:36px;border-radius:50%;background:var(--pri);display:flex;align-items:center;justify-content:center"><svg width="18" height="18" fill="none" stroke="#fff" stroke-width="2"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4z"/></svg></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-bar" data-mode="patient">
|
||||||
|
<div class="tab-item" onclick="showPage('home','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><span>首页</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('data','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20V10M6 20V4M18 20v-6"/></svg><span>数据</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('appointment','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg><span>预约</span></div>
|
||||||
|
<div class="tab-item active" onclick="showPage('profile','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span>我的</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== 医护端页面 ==================== -->
|
||||||
|
|
||||||
|
<!-- P6: 工作台 -->
|
||||||
|
<div id="page-workspace" class="page" data-mode="doctor">
|
||||||
|
<div class="status-bar" style="background:var(--pri)"><span>9:41</span><span></span></div>
|
||||||
|
<div class="nav-bar" style="background:var(--pri)">工作台</div>
|
||||||
|
<div class="content" style="padding-bottom:80px">
|
||||||
|
<!-- 医护信息 -->
|
||||||
|
<div style="background:linear-gradient(135deg,var(--pri),var(--pri-d));padding:16px;color:#fff">
|
||||||
|
<div class="flex aic jcsb">
|
||||||
|
<div class="flex aic gap12">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 48 48"><circle cx="24" cy="24" r="24" fill="rgba(255,255,255,.2)"/><circle cx="24" cy="19" r="7" fill="rgba(255,255,255,.6)"/><ellipse cx="24" cy="35" rx="11" ry="8" fill="rgba(255,255,255,.4)"/></svg>
|
||||||
|
<div><div style="font-size:16px;font-weight:700">王建国</div><div style="font-size:12px;opacity:.8">肾内科 · 主任医师</div></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;background:rgba(255,255,255,.2);padding:4px 12px;border-radius:20px;font-size:12px"><div style="width:8px;height:8px;border-radius:50%;background:#10B981"></div>在线</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 今日统计 -->
|
||||||
|
<div style="padding:0 16px;margin-top:-12px;position:relative;z-index:1">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||||
|
<div style="background:var(--card);padding:14px 12px;border-radius:var(--r);box-shadow:var(--sh);text-align:center;border:1px solid rgba(0,0,0,.03)"><div style="width:40px;height:40px;border-radius:12px;background:var(--acc-l);display:flex;align-items:center;justify-content:center;margin:0 auto 8px"><svg width="18" height="18" fill="none" stroke="var(--acc)" stroke-width="2"><path d="M22 16.92h-4.56a2 2 0 01-1.9-1.38l-.68-2.04a2 2 0 00-1.9-1.38h-2.92a2 2 0 00-1.9 1.38l-.68 2.04a2 2 0 01-1.9 1.38H2"/><circle cx="12" cy="8" r="5" transform="scale(.6) translate(6,2)"/></svg></div><div style="font-size:26px;font-weight:700;color:var(--acc);letter-spacing:-.5px">12</div><div style="font-size:11px;color:var(--tx2);margin-top:2px">待随访</div></div>
|
||||||
|
<div style="background:var(--card);padding:14px 12px;border-radius:var(--r);box-shadow:var(--sh);text-align:center;border:1px solid rgba(0,0,0,.03)"><div style="width:40px;height:40px;border-radius:12px;background:var(--pri-l);display:flex;align-items:center;justify-content:center;margin:0 auto 8px"><svg width="18" height="18" fill="none" stroke="var(--pri)" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg></div><div style="font-size:26px;font-weight:700;color:var(--pri);letter-spacing:-.5px">8</div><div style="font-size:11px;color:var(--tx2);margin-top:2px">今日预约</div></div>
|
||||||
|
<div style="background:var(--card);padding:14px 12px;border-radius:var(--r);box-shadow:var(--sh);text-align:center;border:1px solid rgba(0,0,0,.03)"><div style="width:40px;height:40px;border-radius:12px;background:#FEF3C7;display:flex;align-items:center;justify-content:center;margin:0 auto 8px"><svg width="18" height="18" fill="none" stroke="var(--wrn)" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg></div><div style="font-size:26px;font-weight:700;color:var(--wrn);letter-spacing:-.5px">5</div><div style="font-size:11px;color:var(--tx2);margin-top:2px">待回复咨询</div></div>
|
||||||
|
<div style="background:var(--card);padding:14px 12px;border-radius:var(--r);box-shadow:var(--sh);text-align:center;border:1px solid rgba(0,0,0,.03)"><div style="width:40px;height:40px;border-radius:12px;background:var(--dan-l);display:flex;align-items:center;justify-content:center;margin:0 auto 8px"><svg width="18" height="18" fill="none" stroke="var(--dan)" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><path d="M12 9v4M12 17h.01" transform="scale(.7) translate(4,1)"/></svg></div><div style="font-size:26px;font-weight:700;color:var(--dan);letter-spacing:-.5px">3</div><div style="font-size:11px;color:var(--tx2);margin-top:2px">逾期任务</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 今日任务 -->
|
||||||
|
<div style="padding:12px 16px"><div style="font-size:15px;font-weight:600;margin-bottom:8px">今日任务</div>
|
||||||
|
<div class="card" style="margin:0 0 8px">
|
||||||
|
<div class="flex aic gap12">
|
||||||
|
<div style="width:36px;height:36px;border-radius:8px;background:var(--acc-l);display:flex;align-items:center;justify-content:center"><svg width="16" height="16" fill="none" stroke="var(--acc)" stroke-width="2"><path d="M22 16.92h-4.56l-.68-2.04a2 2 0 00-1.9-1.38" transform="scale(.6) translate(4,2)"/></svg></div>
|
||||||
|
<div style="flex:1"><div style="font-size:13px;font-weight:600">张明 · 电话随访</div><div style="font-size:11px;color:var(--tx2)">血压监测随访 · 10:00</div></div>
|
||||||
|
<span class="tag tag-y">待处理</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin:0 0 8px">
|
||||||
|
<div class="flex aic gap12">
|
||||||
|
<div style="width:36px;height:36px;border-radius:8px;background:var(--pri-l);display:flex;align-items:center;justify-content:center"><svg width="16" height="16" fill="none" stroke="var(--pri)" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/></svg></div>
|
||||||
|
<div style="flex:1"><div style="font-size:13px;font-weight:600">刘芳 · 门诊预约</div><div style="font-size:11px;color:var(--tx2)">肾功能复查 · 11:00</div></div>
|
||||||
|
<span class="tag tag-g">已确认</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin:0 0 8px">
|
||||||
|
<div class="flex aic gap12">
|
||||||
|
<div style="width:36px;height:36px;border-radius:8px;background:#FEF3C7;display:flex;align-items:center;justify-content:center"><svg width="16" height="16" fill="none" stroke="#F59E0B" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg></div>
|
||||||
|
<div style="flex:1"><div style="font-size:13px;font-weight:600">李强 · 在线咨询</div><div style="font-size:11px;color:var(--tx2)">用药咨询 · 14:00</div></div>
|
||||||
|
<span class="tag tag-y">待回复</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin:0 0 8px">
|
||||||
|
<div class="flex aic gap12">
|
||||||
|
<div style="width:36px;height:36px;border-radius:8px;background:var(--acc-l);display:flex;align-items:center;justify-content:center"><svg width="16" height="16" fill="none" stroke="var(--acc)" stroke-width="2"><path d="M22 16.92h-4.56l-.68-2.04" transform="scale(.6) translate(4,2)"/></svg></div>
|
||||||
|
<div style="flex:1"><div style="font-size:13px;font-weight:600">赵婷 · 面访随访</div><div style="font-size:11px;color:var(--tx2)">术后随访 · 16:00</div></div>
|
||||||
|
<span class="tag tag-g">已完成</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-bar" data-mode="doctor">
|
||||||
|
<div class="tab-item active" onclick="showPage('workspace','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>工作台</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('patients','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg><span>患者</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('schedule','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg><span>排班</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('me','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span>我的</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- P7: 患者管理 -->
|
||||||
|
<div id="page-patients" class="page" data-mode="doctor">
|
||||||
|
<div class="status-bar" style="background:var(--pri)"><span>9:41</span><span></span></div>
|
||||||
|
<div class="nav-bar" style="background:var(--pri)">患者管理</div>
|
||||||
|
<div class="content" style="padding-bottom:80px">
|
||||||
|
<div style="padding:12px 16px"><div style="background:var(--card);border-radius:20px;padding:8px 14px;display:flex;align-items:center;gap:8px;box-shadow:var(--sh)"><svg width="16" height="16" fill="none" stroke="var(--tx2)" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input placeholder="搜索患者姓名/手机号" style="border:none;outline:none;font-size:14px;flex:1;background:transparent"></div></div>
|
||||||
|
<div style="padding:0 16px 12px;display:flex;gap:6px;overflow-x:auto">
|
||||||
|
<span style="padding:6px 14px;border-radius:20px;font-size:12px;font-weight:600;background:var(--pri);color:#fff;white-space:nowrap">全部</span>
|
||||||
|
<span style="padding:6px 14px;border-radius:20px;font-size:12px;background:var(--card);color:var(--dan);border:1px solid #FEE2E2;white-space:nowrap">高血压</span>
|
||||||
|
<span style="padding:6px 14px;border-radius:20px;font-size:12px;background:var(--card);color:var(--pri);border:1px solid var(--pri-l);white-space:nowrap">糖尿病</span>
|
||||||
|
<span style="padding:6px 14px;border-radius:20px;font-size:12px;background:var(--card);color:var(--acc);border:1px solid var(--acc-l);white-space:nowrap">慢性肾病</span>
|
||||||
|
<span style="padding:6px 14px;border-radius:20px;font-size:12px;background:var(--card);color:var(--wrn);border:1px solid #FEF3C7;white-space:nowrap">术后随访</span>
|
||||||
|
</div>
|
||||||
|
<!-- 患者列表 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex aic gap12">
|
||||||
|
<div style="width:40px;height:40px;border-radius:50%;background:var(--pri-l);display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--pri);font-size:14px">张</div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="flex aic gap8"><strong style="font-size:14px">张明</strong><svg width="14" height="14" fill="none" stroke="var(--pri)" stroke-width="2"><circle cx="7" cy="7" r="5"/><path d="M7 5v4M5 7h4" transform="translate(0,-1)"/></svg><span style="font-size:12px;color:var(--tx2)">52岁</span><div style="width:8px;height:8px;border-radius:50%;background:var(--acc)"></div></div>
|
||||||
|
<div class="flex gap6" style="margin-top:4px"><span class="tag tag-r" style="font-size:10px;padding:2px 8px">高血压</span><span class="tag tag-b" style="font-size:10px;padding:2px 8px">慢性肾病</span></div>
|
||||||
|
<div style="font-size:11px;color:var(--tx2);margin-top:2px">主治:王建国 · 上次随访 4/18</div>
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="16" fill="none" stroke="var(--tx2)" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex aic gap12">
|
||||||
|
<div style="width:40px;height:40px;border-radius:50%;background:#FEF3C7;display:flex;align-items:center;justify-content:center;font-weight:700;color:#F59E0B;font-size:14px">刘</div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="flex aic gap8"><strong style="font-size:14px">刘芳</strong><svg width="14" height="14" fill="none" stroke="#F59E0B" stroke-width="2"><circle cx="7" cy="7" r="5"/></svg><span style="font-size:12px;color:var(--tx2)">45岁</span><div style="width:8px;height:8px;border-radius:50%;background:var(--acc)"></div></div>
|
||||||
|
<div class="flex gap6" style="margin-top:4px"><span class="tag tag-b" style="font-size:10px;padding:2px 8px">糖尿病</span><span class="tag tag-y" style="font-size:10px;padding:2px 8px">术后随访</span></div>
|
||||||
|
<div style="font-size:11px;color:var(--tx2);margin-top:2px">主治:王建国 · 上次随访 4/20</div>
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="16" fill="none" stroke="var(--tx2)" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex aic gap12">
|
||||||
|
<div style="width:40px;height:40px;border-radius:50%;background:var(--acc-l);display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--acc);font-size:14px">李</div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="flex aic gap8"><strong style="font-size:14px">李强</strong><svg width="14" height="14" fill="none" stroke="var(--pri)" stroke-width="2"><circle cx="7" cy="7" r="5"/></svg><span style="font-size:12px;color:var(--tx2)">68岁</span><div style="width:8px;height:8px;border-radius:50%;background:var(--acc)"></div></div>
|
||||||
|
<div class="flex gap6" style="margin-top:4px"><span class="tag tag-r" style="font-size:10px;padding:2px 8px">高血压</span><span class="tag tag-b" style="font-size:10px;padding:2px 8px">冠心病</span></div>
|
||||||
|
<div style="font-size:11px;color:var(--tx2);margin-top:2px">主治:李芳 · 上次随访 4/15</div>
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="16" fill="none" stroke="var(--tx2)" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex aic gap12">
|
||||||
|
<div style="width:40px;height:40px;border-radius:50%;background:#FEE2E2;display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--dan);font-size:14px">赵</div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="flex aic gap8"><strong style="font-size:14px">赵婷</strong><svg width="14" height="14" fill="none" stroke="var(--dan)" stroke-width="2"><circle cx="7" cy="7" r="5"/></svg><span style="font-size:12px;color:var(--tx2)">38岁</span><div style="width:8px;height:8px;border-radius:50%;background:var(--acc)"></div></div>
|
||||||
|
<div class="flex gap6" style="margin-top:4px"><span class="tag tag-y" style="font-size:10px;padding:2px 8px">术后随访</span></div>
|
||||||
|
<div style="font-size:11px;color:var(--tx2);margin-top:2px">主治:王建国 · 上次随访 4/22</div>
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="16" fill="none" stroke="var(--tx2)" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex aic gap12">
|
||||||
|
<div style="width:40px;height:40px;border-radius:50%;background:#DBEAFE;display:flex;align-items:center;justify-content:center;font-weight:700;color:#3B82F6;font-size:14px">陈</div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="flex aic gap8"><strong style="font-size:14px">陈华</strong><svg width="14" height="14" fill="none" stroke="#3B82F6" stroke-width="2"><circle cx="7" cy="7" r="5"/></svg><span style="font-size:12px;color:var(--tx2)">55岁</span><div style="width:8px;height:8px;border-radius:50%;background:#9CA3AF"></div></div>
|
||||||
|
<div class="flex gap6" style="margin-top:4px"><span class="tag tag-b" style="font-size:10px;padding:2px 8px">慢性肾病</span><span class="tag tag-r" style="font-size:10px;padding:2px 8px">高血压</span></div>
|
||||||
|
<div style="font-size:11px;color:var(--tx2);margin-top:2px">主治:王建国 · 已停用</div>
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="16" fill="none" stroke="var(--tx2)" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 添加患者按钮 -->
|
||||||
|
<div style="position:sticky;bottom:90px;right:16px;display:flex;justify-content:flex-end;pointer-events:none">
|
||||||
|
<div style="width:52px;height:52px;border-radius:50%;background:var(--pri);display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(8,145,178,.4);pointer-events:auto;cursor:pointer"><svg width="24" height="24" fill="none" stroke="#fff" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg></div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-bar" data-mode="doctor">
|
||||||
|
<div class="tab-item" onclick="showPage('workspace','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>工作台</span></div>
|
||||||
|
<div class="tab-item active" onclick="showPage('patients','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg><span>患者</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('schedule','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg><span>排班</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('me','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span>我的</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- P8: 随访执行 -->
|
||||||
|
<div id="page-followup" class="page" data-mode="doctor">
|
||||||
|
<div class="status-bar" style="background:var(--pri)"><span>9:41</span><span></span></div>
|
||||||
|
<div class="nav-bar" style="background:var(--pri)">
|
||||||
|
<span class="back"><svg width="20" height="20" fill="none" stroke="#fff" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg></span>
|
||||||
|
随访执行
|
||||||
|
</div>
|
||||||
|
<div class="content" style="padding-bottom:16px">
|
||||||
|
<!-- 患者信息条 -->
|
||||||
|
<div style="background:var(--card);padding:14px 16px;display:flex;align-items:center;gap:12px;box-shadow:var(--sh)">
|
||||||
|
<div style="width:44px;height:44px;border-radius:50%;background:var(--pri-l);display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--pri)">张</div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="flex aic gap8"><strong>张明</strong><span style="font-size:12px;color:var(--tx2)">男 · 52岁</span></div>
|
||||||
|
<div class="flex gap6" style="margin-top:4px"><span class="tag tag-r" style="font-size:10px;padding:2px 8px">高血压</span><span class="tag tag-b" style="font-size:10px;padding:2px 8px">慢性肾病</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 随访任务信息 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title"><svg width="16" height="16" fill="none" stroke="var(--pri)" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>任务信息</div>
|
||||||
|
<div style="font-size:13px;line-height:2">
|
||||||
|
<div class="flex jcsb"><span style="color:var(--tx2)">随访类型</span><strong>电话随访</strong></div>
|
||||||
|
<div class="flex jcsb"><span style="color:var(--tx2)">计划日期</span><strong>2026-04-23</strong></div>
|
||||||
|
<div class="flex jcsb"><span style="color:var(--tx2)">状态</span><span class="tag tag-y">进行中</span></div>
|
||||||
|
<div class="flex jcsb"><span style="color:var(--tx2)">关联预约</span><span style="color:var(--pri)">4/24 王建国门诊</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 随访表单 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title"><svg width="16" height="16" fill="none" stroke="var(--acc)" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>随访记录</div>
|
||||||
|
<!-- 随访结果 -->
|
||||||
|
<div style="margin-bottom:16px">
|
||||||
|
<div style="font-size:13px;font-weight:600;margin-bottom:8px">随访结果</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;padding:8px 14px;border-radius:8px;border:2px solid var(--acc);background:var(--acc-l);cursor:pointer;font-size:13px"><input type="radio" name="result" checked style="accent-color:var(--acc)">已随访</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;padding:8px 14px;border-radius:8px;border:1.5px solid var(--bd);cursor:pointer;font-size:13px"><input type="radio" name="result" style="accent-color:var(--acc)">无法联系</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;padding:8px 14px;border-radius:8px;border:1.5px solid var(--bd);cursor:pointer;font-size:13px"><input type="radio" name="result" style="accent-color:var(--acc)">拒绝</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;padding:8px 14px;border-radius:8px;border:1.5px solid var(--bd);cursor:pointer;font-size:13px"><input type="radio" name="result" style="accent-color:var(--acc)">其他</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 患者状况 -->
|
||||||
|
<div style="margin-bottom:16px">
|
||||||
|
<label class="form-label">患者状况</label>
|
||||||
|
<textarea placeholder="请描述患者当前状况..." style="width:100%;height:80px;padding:10px 14px;border:1.5px solid var(--bd);border-radius:var(--r-sm);font-size:13px;background:var(--card);resize:none;outline:none;font-family:inherit;color:var(--tx);line-height:1.5;transition:border-color .2s" onfocus="this.style.borderColor='var(--pri)'" onblur="this.style.borderColor='var(--bd)'"></textarea>
|
||||||
|
</div>
|
||||||
|
<!-- 医嘱建议 -->
|
||||||
|
<div style="margin-bottom:16px">
|
||||||
|
<label class="form-label">医嘱建议</label>
|
||||||
|
<textarea placeholder="请输入医嘱建议..." style="width:100%;height:80px;padding:10px 14px;border:1.5px solid var(--bd);border-radius:var(--r-sm);font-size:13px;background:var(--card);resize:none;outline:none;font-family:inherit;color:var(--tx);line-height:1.5;transition:border-color .2s" onfocus="this.style.borderColor='var(--pri)'" onblur="this.style.borderColor='var(--bd)'"></textarea>
|
||||||
|
</div>
|
||||||
|
<!-- 下次随访日期 -->
|
||||||
|
<div style="margin-bottom:16px">
|
||||||
|
<label class="form-label">下次随访日期</label>
|
||||||
|
<input type="date" value="2026-05-23" class="form-input">
|
||||||
|
</div>
|
||||||
|
<!-- 附件 -->
|
||||||
|
<div style="margin-bottom:16px">
|
||||||
|
<div style="font-size:13px;font-weight:600;margin-bottom:6px">附件</div>
|
||||||
|
<div style="border:2px dashed var(--bd);border-radius:8px;padding:20px;text-align:center;cursor:pointer">
|
||||||
|
<svg width="28" height="28" fill="none" stroke="var(--tx2)" stroke-width="2" style="margin-bottom:6px"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
|
||||||
|
<div style="font-size:12px;color:var(--tx2)">点击添加附件</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 底部操作栏 -->
|
||||||
|
<div style="padding:12px 16px;display:flex;gap:12px;background:var(--card);border-top:1px solid var(--bd)">
|
||||||
|
<button class="btn btn-out" style="flex:1">保存草稿</button>
|
||||||
|
<button class="btn btn-pri" style="flex:2">提交随访</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 患者端"我的"占位页 -->
|
||||||
|
<div id="page-profile" class="page" data-mode="patient">
|
||||||
|
<div class="status-bar" style="background:var(--pri)"><span>9:41</span><span></span></div>
|
||||||
|
<div class="nav-bar" style="background:var(--pri)">我的</div>
|
||||||
|
<div class="content" style="padding-bottom:80px">
|
||||||
|
<div style="background:linear-gradient(135deg,var(--pri),var(--pri-d));padding:30px 16px;color:#fff;text-align:center">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" style="margin-bottom:10px"><circle cx="32" cy="32" r="32" fill="rgba(255,255,255,.2)"/><circle cx="32" cy="26" r="10" fill="rgba(255,255,255,.6)"/><ellipse cx="32" cy="48" rx="16" ry="11" fill="rgba(255,255,255,.35)"/></svg>
|
||||||
|
<div style="font-size:18px;font-weight:700">张明</div>
|
||||||
|
<div style="font-size:12px;opacity:.8;margin-top:4px">ID: HMS20260001 · 已实名</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top:-12px;position:relative;z-index:1">
|
||||||
|
<div class="flex aic jcsb" style="padding:12px 0;border-bottom:1px solid var(--bd)"><div class="flex aic gap8"><svg width="18" height="18" fill="none" stroke="var(--pri)" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>健康档案</div><svg width="16" height="16" fill="none" stroke="var(--tx2)" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></div>
|
||||||
|
<div class="flex aic jcsb" style="padding:12px 0;border-bottom:1px solid var(--bd)"><div class="flex aic gap8"><svg width="18" height="18" fill="none" stroke="var(--pri)" stroke-width="2"><path d="M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6M23 11h-6"/></svg>家庭成员</div><svg width="16" height="16" fill="none" stroke="var(--tx2)" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></div>
|
||||||
|
<div class="flex aic jcsb" style="padding:12px 0;border-bottom:1px solid var(--bd)"><div class="flex aic gap8"><svg width="18" height="18" fill="none" stroke="var(--pri)" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>隐私设置</div><svg width="16" height="16" fill="none" stroke="var(--tx2)" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></div>
|
||||||
|
<div class="flex aic jcsb" style="padding:12px 0"><div class="flex aic gap8"><svg width="18" height="18" fill="none" stroke="var(--pri)" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>关于我们</div><svg width="16" height="16" fill="none" stroke="var(--tx2)" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-bar" data-mode="patient">
|
||||||
|
<div class="tab-item" onclick="showPage('home','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><span>首页</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('data','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20V10M6 20V4M18 20v-6"/></svg><span>数据</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('appointment','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg><span>预约</span></div>
|
||||||
|
<div class="tab-item active" onclick="showPage('profile','patient')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span>我的</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 医护端"排班"和"我的"占位页 -->
|
||||||
|
<div id="page-schedule" class="page" data-mode="doctor">
|
||||||
|
<div class="status-bar" style="background:var(--pri)"><span>9:41</span><span></span></div>
|
||||||
|
<div class="nav-bar" style="background:var(--pri)">排班管理</div>
|
||||||
|
<div class="content" style="padding:16px;padding-bottom:80px">
|
||||||
|
<div class="card" style="text-align:center;padding:40px"><svg width="48" height="48" fill="none" stroke="var(--pri)" stroke-width="1.5" style="margin-bottom:12px"><rect x="6" y="8" width="36" height="32" rx="4"/><path d="M32 4v8M16 4v8M6 16h36"/></svg><div style="font-size:14px;color:var(--tx2)">排班管理页面</div><div style="font-size:12px;color:var(--tx2);margin-top:4px">功能开发中</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-bar" data-mode="doctor">
|
||||||
|
<div class="tab-item" onclick="showPage('workspace','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>工作台</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('patients','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg><span>患者</span></div>
|
||||||
|
<div class="tab-item active" onclick="showPage('schedule','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg><span>排班</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('me','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span>我的</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="page-me" class="page" data-mode="doctor">
|
||||||
|
<div class="status-bar" style="background:var(--pri)"><span>9:41</span><span></span></div>
|
||||||
|
<div class="nav-bar" style="background:var(--pri)">我的</div>
|
||||||
|
<div class="content" style="padding-bottom:80px">
|
||||||
|
<div style="background:linear-gradient(135deg,var(--pri),var(--pri-d));padding:30px 16px;color:#fff;text-align:center">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" style="margin-bottom:10px"><circle cx="32" cy="32" r="32" fill="rgba(255,255,255,.2)"/><circle cx="32" cy="26" r="10" fill="rgba(255,255,255,.6)"/><ellipse cx="32" cy="48" rx="16" ry="11" fill="rgba(255,255,255,.35)"/></svg>
|
||||||
|
<div style="font-size:18px;font-weight:700">王建国</div>
|
||||||
|
<div style="font-size:12px;opacity:.8;margin-top:4px">肾内科 · 主任医师</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top:-12px;position:relative;z-index:1">
|
||||||
|
<div class="flex aic jcsb" style="padding:12px 0;border-bottom:1px solid var(--bd)"><div class="flex aic gap8"><svg width="18" height="18" fill="none" stroke="var(--pri)" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>个人信息</div><svg width="16" height="16" fill="none" stroke="var(--tx2)" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></div>
|
||||||
|
<div class="flex aic jcsb" style="padding:12px 0;border-bottom:1px solid var(--bd)"><div class="flex aic gap8"><svg width="18" height="18" fill="none" stroke="var(--pri)" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>设置</div><svg width="16" height="16" fill="none" stroke="var(--tx2)" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></div>
|
||||||
|
<div class="flex aic jcsb" style="padding:12px 0"><div class="flex aic gap8"><svg width="18" height="18" fill="none" stroke="var(--dan)" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/></svg>退出登录</div><svg width="16" height="16" fill="none" stroke="var(--tx2)" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-bar" data-mode="doctor">
|
||||||
|
<div class="tab-item" onclick="showPage('workspace','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>工作台</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('patients','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg><span>患者</span></div>
|
||||||
|
<div class="tab-item" onclick="showPage('schedule','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg><span>排班</span></div>
|
||||||
|
<div class="tab-item active" onclick="showPage('me','doctor')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span>我的</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const patientPages=['home','data','appointment','report','consult','profile'];
|
||||||
|
const doctorPages=['workspace','patients','schedule','me'];
|
||||||
|
function showPage(id,mode){
|
||||||
|
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
|
||||||
|
const page=document.getElementById('page-'+id);
|
||||||
|
if(page){page.classList.add('active')}
|
||||||
|
// Tab bar visibility
|
||||||
|
document.querySelectorAll('.tab-bar').forEach(t=>{t.style.display=t.dataset.mode===mode?'flex':'none'});
|
||||||
|
// Update tab active states
|
||||||
|
const tabs=document.querySelectorAll('.tab-bar[data-mode="'+mode+'"] .tab-item');
|
||||||
|
const pages=mode==='patient'?patientPages:doctorPages;
|
||||||
|
tabs.forEach((t,i)=>{t.classList.toggle('active',pages[i]===id)});
|
||||||
|
}
|
||||||
|
function switchMode(mode){
|
||||||
|
document.querySelectorAll('.mode-btn').forEach(b=>b.classList.toggle('active',b.textContent.includes(mode==='patient'?'患者':'医护')));
|
||||||
|
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
|
||||||
|
if(mode==='patient'){
|
||||||
|
document.getElementById('page-home').classList.add('active');
|
||||||
|
}else{
|
||||||
|
document.getElementById('page-workspace').classList.add('active');
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.tab-bar').forEach(t=>{t.style.display=t.dataset.mode===mode?'flex':'none'});
|
||||||
|
// Set first tab active
|
||||||
|
const tabs=document.querySelectorAll('.tab-bar[data-mode="'+mode+'"] .tab-item');
|
||||||
|
tabs.forEach((t,i)=>{t.classList.toggle('active',i===0)});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# HMS 平台基座回顾与演进 — 多专家评审讨论
|
||||||
|
|
||||||
|
> 日期: 2026-04-26 | 参与者: 用户 + AI (三专家视角评审)
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
HMS 健康管理平台经过 17 天密集开发(4/10-4/26),从 ERP 底座演进到包含 16 个 Rust crate、62 个前端页面、27 个小程序页面的综合医疗 SaaS 平台。用户希望对项目的迭代开发过程进行回顾总结,验证基座设计是否合理,并讨论后续演进方向。
|
||||||
|
|
||||||
|
## 讨论要点
|
||||||
|
|
||||||
|
### 1. 项目演进脉络
|
||||||
|
|
||||||
|
- **Phase 1-6(4/10-4/16)**:ERP 底座搭建 — core/auth/config/workflow/message 全部原生 Rust 模块
|
||||||
|
- **WASM 插件实验(4/13-4/18)**:设计并实现插件系统,落地 CRM/Inventory/Freelance/ITOps 4 个插件
|
||||||
|
- **HMS 分叉(4/23-4/26)**:健康模块因 5 个硬限制(强类型/加密/文件/后台任务/外部 API)选择原生开发
|
||||||
|
- **快速迭代**:18+ 健康实体、AI 模块、微信小程序、按钮级权限控制
|
||||||
|
|
||||||
|
### 2. 基座设计验证
|
||||||
|
|
||||||
|
**验证通过的设计:**
|
||||||
|
- 星形依赖拓扑(零循环依赖)
|
||||||
|
- ErpModule trait 统一接口(生命周期/权限/事件/健康检查)
|
||||||
|
- 事件总线基础设施(broadcast + outbox 持久化 + 前缀过滤)
|
||||||
|
- JWT → TenantContext 多租户全链路贯通
|
||||||
|
- ModuleRegistry 拓扑排序(Kahn 算法 + 循环检测)
|
||||||
|
|
||||||
|
**存在问题的设计:**
|
||||||
|
- 事件消费侧不完整(13 个事件只有 3 个被消费)
|
||||||
|
- 路由手动合并(不在 trait 中,每个新模块需手动接线)
|
||||||
|
- erp-message 全量订阅(性能隐患)
|
||||||
|
- 事件注册双路径(`register_event_handlers` vs `on_startup`)
|
||||||
|
|
||||||
|
### 3. 四个关键张力
|
||||||
|
|
||||||
|
#### 张力 1:双轨并行 — 插件 vs 原生
|
||||||
|
|
||||||
|
两条截然不同的模块开发范式并存,没有明确的判断框架。
|
||||||
|
|
||||||
|
**插件路径**:plugin.toml → Guest trait → WASM → 自动路由/动态表/沙盒隔离
|
||||||
|
**原生路径**:Rust crate → ErpModule trait → 手动路由/强类型/完全能力
|
||||||
|
|
||||||
|
结论:HMS 核心业务(健康/AI/透析/积分)全部需要原生,插件只适用于 CRUD 密集型通用 ERP 模块。
|
||||||
|
|
||||||
|
#### 张力 2:事件消费缺口
|
||||||
|
|
||||||
|
erp-health 发布 13 种事件,消费侧严重不足:
|
||||||
|
- `health_data.critical_alert` 无消费者(危急体征无人响应)
|
||||||
|
- `follow_up.overdue` 无消费者(逾期随访无催办)
|
||||||
|
- `patient.created/updated`、`lab_report.uploaded`、`consultation.opened/closed` 等全部空转
|
||||||
|
|
||||||
|
#### 张力 3:租户隔离最后一公里
|
||||||
|
|
||||||
|
应用层隔离完整但缺兜底:
|
||||||
|
- 无 PostgreSQL RLS policy
|
||||||
|
- 无强制 tenant_id 过滤机制
|
||||||
|
- 微信登录硬编码 default_tenant_id
|
||||||
|
|
||||||
|
#### 张力 4:健康模块复杂性
|
||||||
|
|
||||||
|
erp-health 已成为子平台:加密子系统、脱敏管道、积分体系、AI 数据提供者、后台调度、小程序 API。积分系统(8 实体/12+ 路由)不属于健康模块。
|
||||||
|
|
||||||
|
### 4. 三专家评审
|
||||||
|
|
||||||
|
#### 专家 1:高级系统架构师
|
||||||
|
|
||||||
|
- 诊断准确度 7/10,优先级有偏差
|
||||||
|
- 发现 EventBus 无重放机制(服务重启丢事件)、overdue 事件无幂等保护
|
||||||
|
- RLS 不是 P0,多租户集成测试才是
|
||||||
|
- 积分系统不应在 health 内
|
||||||
|
- 核心原则:先补测试再重构,先修事件再上功能,先验证再加固
|
||||||
|
|
||||||
|
#### 专家 2:医疗信息化专家
|
||||||
|
|
||||||
|
发现比原始诊断更深层的风险:
|
||||||
|
- 危急值阈值全部硬编码(不可配置,无法适应不同科室)
|
||||||
|
- `daily_monitoring` 表体征数据不经过危急值检测(合并遗留问题)
|
||||||
|
- 过敏史更新直接覆盖,无变更历史
|
||||||
|
- 知情同意完全缺失(搜索 consent/同意/授权/隐私 零结果,违反 PIPL 第29条)
|
||||||
|
- 只有身份证号加密,姓名/过敏史/诊断/咨询内容明文
|
||||||
|
- 审计日志不完整(只有预约状态变更记录前后值)
|
||||||
|
- ip_address 和 user_agent 从未被填充
|
||||||
|
- 读操作完全没有审计记录
|
||||||
|
|
||||||
|
#### 专家 3:产品策略专家
|
||||||
|
|
||||||
|
- 17 天 237 提交不可持续但不必恐慌,fix 提交占 21.6%
|
||||||
|
- 41% Rust 代码在插件系统,对核心业务贡献接近零(最大 ROI 失衡)
|
||||||
|
- 单人+AI 的"速度幻觉":68 提交/天 = 审查不足
|
||||||
|
- 测试正确水位:关键路径 50-80 用例,3-4 天投入
|
||||||
|
- V2 血透路线图:技术储备够,但缺市场验证,建议先做 3-5 家客户调研
|
||||||
|
|
||||||
|
### 5. 三专家共识
|
||||||
|
|
||||||
|
| 共识 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 危急值告警闭环是 P0 | 三方一致,错误的血糖阈值可致患者昏迷 |
|
||||||
|
| 知情同意缺失是法律红线 | PIPL 违规可罚 5000 万 |
|
||||||
|
| 积分系统不属于 health | 三方独立得出相同结论 |
|
||||||
|
| 测试覆盖是所有后续工作的前提 | 先有测试才能放心重构 |
|
||||||
|
| 插件系统应冻结 | 保留代码,不再投入 |
|
||||||
|
| EventBus 可靠性需增强 | 无重放 + 无幂等 |
|
||||||
|
|
||||||
|
### 6. 重新排序的优先级
|
||||||
|
|
||||||
|
**P0(2-3 周,上线前必修):**
|
||||||
|
1. 危急值告警消费者(1天)
|
||||||
|
2. 危急值阈值可配置化(2天)
|
||||||
|
3. daily_monitoring 合并后告警验证(1天)
|
||||||
|
4. 随访逾期通知 + 幂等保护(1天)
|
||||||
|
5. 知情同意记录(3天)
|
||||||
|
6. 审计日志补全(3天)
|
||||||
|
7. EventBus 持久化增强(2天)
|
||||||
|
|
||||||
|
**P1(2-4 周,治理):**
|
||||||
|
8. 积分系统剥离(5天)
|
||||||
|
9. 关键路径测试 50-80 用例(4天)
|
||||||
|
10. 插件系统冻结声明(0.5天)
|
||||||
|
11. erp-message 改用 subscribe_filtered(1天)
|
||||||
|
12. 统一事件消费模式(2天)
|
||||||
|
13. 过敏史变更历史(1天)
|
||||||
|
|
||||||
|
**P2(后续迭代,扩展):**
|
||||||
|
14. PostgreSQL RLS
|
||||||
|
15. 血透专科(先客户调研)
|
||||||
|
16. OCR / IM(血透验证后)
|
||||||
|
17. health 模块按子域重组
|
||||||
|
18. 动态菜单系统
|
||||||
|
|
||||||
|
## 结论 / 待定
|
||||||
|
|
||||||
|
### 达成的共识
|
||||||
|
|
||||||
|
1. **基座设计方向正确** — 星形依赖、trait 抽象、事件总线经受住了实践检验
|
||||||
|
2. **插件系统从核心战略降级为实验性功能** — 保留但冻结
|
||||||
|
3. **临床安全和合规是最高优先级** — 危急值闭环和知情同意必须先于功能扩展
|
||||||
|
4. **积分系统应从 health 模块拆出** — 降低合规复杂度
|
||||||
|
5. **单人+AI 开发需要节奏控制** — 每日提交上限、ADR 强制化、医疗安全代码外部 review
|
||||||
|
|
||||||
|
### 遗留问题
|
||||||
|
|
||||||
|
1. **知情同意的具体实现方案** — 需要单独讨论:同意类型(数据收集/共享/研究使用)、获取时机(建档时/首次使用时)、存储结构
|
||||||
|
2. **积分系统拆分的接口设计** — 事件总线通信还是共享 trait?
|
||||||
|
3. **血透专科的市场验证** — 需要用户确认是否已做客户调研
|
||||||
|
4. **PostgreSQL RLS 的实施策略** — 全量 RLS 还是只覆盖敏感表?
|
||||||
|
5. **合规审计的准备** — 是否有外部合规审计计划?
|
||||||
|
|
||||||
|
### 关联文档
|
||||||
|
|
||||||
|
- 设计规格:`docs/superpowers/specs/2026-04-26-platform-retrospective-and-evolution-design.md`
|
||||||
|
- 插件系统设计:`docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md`
|
||||||
|
- 健康模块设计:`docs/superpowers/specs/2026-04-23-health-management-module-design.md`
|
||||||
|
- 插件平台讨论:`docs/discussions/2026-04-18-plugin-platform-brainstorm.md`
|
||||||
330
docs/discussions/2026-04-27-miniprogram-audit-report.md
Normal file
330
docs/discussions/2026-04-27-miniprogram-audit-report.md
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
# HMS 小程序端审计报告
|
||||||
|
|
||||||
|
> 日期: 2026-04-27 | 审计范围: 代码审查 + API 链路实测 | 审计人: Claude
|
||||||
|
|
||||||
|
## 执行摘要
|
||||||
|
|
||||||
|
对 HMS 健康管理平台微信小程序端进行了全面审计,覆盖 **40 个页面、10+ 服务模块、2 个 Store**。通过代码静态分析 + 后端 API 实测,发现 **6 个严重问题、8 个中等问题、5 个低级问题**,以及 **3 个功能链路断链**。
|
||||||
|
|
||||||
|
### 关键数字
|
||||||
|
|
||||||
|
| 指标 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 审计页面 | 40 个(含 8 个医护端) |
|
||||||
|
| 审计服务 | 12 个 API 服务文件 |
|
||||||
|
| 审计组件 | 6 个 |
|
||||||
|
| 严重问题 (HIGH) | 6 个 |
|
||||||
|
| 中等问题 (MEDIUM) | 8 个 + 3 个功能断链 |
|
||||||
|
| 低级问题 (LOW) | 5 个 |
|
||||||
|
| 正面发现 | 8 项 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、功能链路断链(实测发现)
|
||||||
|
|
||||||
|
### F1. [HIGH] 今日体征数据未反映刚录入的数据
|
||||||
|
|
||||||
|
**实测过程:**
|
||||||
|
1. `POST /health/patients/{id}/vital-signs` 成功创建记录(`heart_rate: 72`)
|
||||||
|
2. `GET /health/vital-signs/today` 仍返回所有字段 `null`
|
||||||
|
|
||||||
|
**根因分析:**
|
||||||
|
- `today` 端点依赖 JWT 中的 `user_id` 反查 `patient` 表的 `user_id` 字段
|
||||||
|
- 测试患者(张三)的 `user_id` 为 `null`(API 返回 `"user_id":null`),即该患者未关联登录账号
|
||||||
|
- 今日体征接口无法定位到正确的患者
|
||||||
|
|
||||||
|
**前端影响:**
|
||||||
|
- 用户在健康录入页提交数据后,返回首页或健康 Tab,"今日体征概览"区域仍然为空
|
||||||
|
- 用户体验严重断裂:**数据明明录入了,但看不到**
|
||||||
|
|
||||||
|
**修复建议:**
|
||||||
|
1. 前端:`today` 接口改用 `X-Patient-Id` header 传递当前选中患者的 ID(代码已实现 header 注入,但后端 `today` 端点未读取)
|
||||||
|
2. 后端:`vital_signs_today` handler 应优先使用 `X-Patient-Id` header 中的 patient_id
|
||||||
|
|
||||||
|
### F2. [HIGH] 积分/签到功能对未关联患者的用户完全不可用
|
||||||
|
|
||||||
|
**实测过程:**
|
||||||
|
- `GET /health/points/account` → 404 `"当前用户未关联患者档案"`
|
||||||
|
- `GET /health/points/checkin/status` → 404
|
||||||
|
|
||||||
|
**根因:** 积分、签到、兑换等患者端功能都依赖 `user_id → patient` 的关联查询。管理员账号没有对应的患者档案。
|
||||||
|
|
||||||
|
**前端影响:**
|
||||||
|
- 积分商城 Tab 页(5 个 Tab 之一)对未关联患者的用户显示为空白或报错
|
||||||
|
- **没有友好的降级提示**(如"请先完善个人档案")
|
||||||
|
|
||||||
|
**修复建议:**
|
||||||
|
1. 前端:积分商城/签到页面需增加"未关联患者档案"的降级 UI
|
||||||
|
2. Auth store `restore()` 时检查是否有 `currentPatient`,无则引导用户建档
|
||||||
|
|
||||||
|
### F3. [MEDIUM] 文章列表返回草稿状态的文章
|
||||||
|
|
||||||
|
**实测过程:**
|
||||||
|
- `GET /health/articles` 返回 4 篇文章,其中 `status: "draft"` 的文章也被返回
|
||||||
|
|
||||||
|
**前端影响:** 患者端文章列表可能显示未发布的草稿文章。
|
||||||
|
|
||||||
|
**修复建议:**
|
||||||
|
1. 前端:`article.ts` 服务请求时添加 `status=published` 过滤参数
|
||||||
|
2. 后端:患者端文章列表 API 应默认只返回 `published` 状态的文章
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、安全审计发现
|
||||||
|
|
||||||
|
### 2.1 严重 (HIGH)
|
||||||
|
|
||||||
|
| # | 问题 | 文件 | 影响 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| H1 | **Token 刷新竞态条件** | `services/request.ts:57-65` | 多个 API 同时 401 时,各自独立调用 `tryRefreshToken()`,可能导致 refresh token 被消耗多次,锁死用户 |
|
||||||
|
| H2 | **静态加密密钥无密钥派生** | `utils/secure-storage.ts:4` | 所有用户共享同一编译时烘焙的密钥,反编译小程序包即可解密全部本地存储数据 |
|
||||||
|
| H3 | **非生产环境明文回退** | `utils/secure-storage.ts:11-33` | 开发模式下 token/PII 明文存储在 localStorage |
|
||||||
|
| H4 | **Token 冗余暴露在 React State** | `stores/auth.ts:31-32` | Zustand store 持有 `token`/`refreshToken` 副本,与 secure storage 冗余,增加攻击面 |
|
||||||
|
| H5 | **登录无防重复点击保护** | `stores/auth.ts:53-75` | `login()` 函数无 `if (loading) return` 守卫,快速双击可触发两次登录请求 |
|
||||||
|
| H6 | **Analytics 绕过请求层** | `services/analytics.ts:67-73` | 直接调用 `Taro.request()`,无 Authorization header,无 token 刷新,BASE_URL 硬编码重复 |
|
||||||
|
|
||||||
|
### 2.2 中等 (MEDIUM)
|
||||||
|
|
||||||
|
| # | 问题 | 文件 | 影响 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| M1 | **Logout 清理不完整** | `stores/auth.ts:120-129` | 残留 `wechat_openid`、`tenant_id`、`analytics_queue`、`edit_patient` 在 storage 中 |
|
||||||
|
| M2 | **PII 未加密存储** | `stores/auth.ts:62-64` | `user` 对象(含 id/phone)通过 `Taro.setStorageSync` 明文存储 |
|
||||||
|
| M3 | **开发日志含敏感数据** | `services/request.ts:50` | `console.log` 输出完整请求 body(可能含血压、血糖等健康数据) |
|
||||||
|
| M4 | **解密失败静默返回空串** | `utils/secure-storage.ts:31-33` | 无法区分"未登录"和"数据被篡改"两种情况 |
|
||||||
|
| M5 | **聊天轮询闭包过时** | `consultation/detail` 两个页面 | `pollNewMessages` 捕获的 `session` 状态可能过时,session 关闭后轮询可能继续 |
|
||||||
|
| M6 | **daily-monitoring 缺输入验证** | `health/daily-monitoring/index.tsx` | 无 Zod 验证,`parseFloat('999999')` 会被接受(对比 health/input 有完整 Zod) |
|
||||||
|
| M7 | **OpenID 明文存储** | `stores/auth.ts:68` | WeChat OpenID 敏感标识符通过明文 storage 存储 |
|
||||||
|
| M8 | **身份证号明文传递** | `profile/family/index.tsx:46` | 编辑患者时 `edit_patient`(含 `id_number`)通过明文 storage 传递给编辑页 |
|
||||||
|
|
||||||
|
### 2.3 低 (LOW)
|
||||||
|
|
||||||
|
| # | 问题 | 说明 |
|
||||||
|
|---|------|------|
|
||||||
|
| L1 | 18 处 `any` 类型 | auth 流程最集中(5 处 `as any`),绕过类型检查 |
|
||||||
|
| L2 | 聊天无指数退避 | 8s 固定间隔轮询,网络异常时产生大量无效请求 |
|
||||||
|
| L3 | 无客户端消息排序 | 假设服务端返回有序,无 `created_at` 排序 |
|
||||||
|
| L4 | devLogin 残留在产物中 | `services/auth.ts:47` 的开发登录函数未从生产构建中移除 |
|
||||||
|
| L5 | 大量静默 catch | 多处 `catch { }` 隐藏错误,影响生产问题排查 |
|
||||||
|
|
||||||
|
### 2.4 正面安全发现
|
||||||
|
|
||||||
|
- **XSS 防护完善**:零 `dangerouslySetInnerHTML`、零 `innerHTML`、零 `eval()`,所有用户内容通过 Taro `<Text>` 渲染
|
||||||
|
- **无硬编码密钥**:所有敏感配置通过环境变量注入
|
||||||
|
- **后端多租户隔离一致**:所有查询均含 `tenant_id` + `deleted_at IS NULL` 过滤
|
||||||
|
- **后端 CAS 并发控制**:预约、积分余额、库存、未读计数等关键操作使用乐观锁
|
||||||
|
- **后端 PII 加密存储**:身份证号、手机号、咨询消息等使用 AES + HMAC 可搜索加密
|
||||||
|
- **表单防重复提交**:所有表单使用 `submitting`/`loading` state + 按钮禁用
|
||||||
|
- **健康录入 Zod 验证**:`health/input` 页面使用完整的 Zod schema + 阈值警告
|
||||||
|
- **URL 参数编码**:`buildQuery` 正确过滤 undefined 并 `encodeURIComponent`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、后端 API 数据结构实测
|
||||||
|
|
||||||
|
### 3.1 分页响应结构
|
||||||
|
|
||||||
|
后端统一返回结构(所有分页列表 API):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"data": [...], // 实际数据数组
|
||||||
|
"total": 6,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 10,
|
||||||
|
"total_pages": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端 `request.ts` 提取 `body.data` 后得到 `{ data: [...], total, page, ... }`。
|
||||||
|
前端各 service 的泛型声明基本匹配此结构(如 `{ data: Patient[], total: number }`)。
|
||||||
|
|
||||||
|
**实测结论:前端 service 层的字段映射基本正确。** 但需注意:
|
||||||
|
- 部分页面可能直接用 `resp.items`(错误)而非 `resp.data`(正确)来访问列表数据
|
||||||
|
- 需要逐页验证页面层的消费代码
|
||||||
|
|
||||||
|
### 3.2 关键实体字段映射
|
||||||
|
|
||||||
|
| 实体 | 后端字段 | 前端期望 | 匹配状态 |
|
||||||
|
|------|----------|----------|----------|
|
||||||
|
| 商品积分价 | `points_cost` | `points_cost` | ✅ 匹配 |
|
||||||
|
| 咨询主题 | **无 `subject`/`title` 字段** | `subject` | ❌ 前端引用了不存在的字段 |
|
||||||
|
| 文章分类 | `category` (string) | `category` | ✅ 匹配 |
|
||||||
|
| 医生姓名 | `name` | `name` | ✅ 匹配 |
|
||||||
|
| 体征日期 | `record_date` | `record_date` | ✅ 匹配 |
|
||||||
|
|
||||||
|
### 3.3 咨询会话无 subject 字段
|
||||||
|
|
||||||
|
**实测发现:** 咨询会话实体后端字段为:
|
||||||
|
`id, patient_id, doctor_id, consultation_type, status, last_message_at, unread_count_patient, unread_count_doctor, created_at, updated_at, version`
|
||||||
|
|
||||||
|
**没有 `subject` 字段!** 前端会话列表页如果显示 `session.subject`,将渲染为 `undefined`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、代码质量与优化
|
||||||
|
|
||||||
|
### 4.1 包体积问题
|
||||||
|
|
||||||
|
**实测发现:** `pages/health/trend/index.js` 体积 **455 KiB**,原因是全量引入 ECharts。
|
||||||
|
|
||||||
|
**优化建议:**
|
||||||
|
1. 使用 `echarts/charts` 按需引入(仅 LineChart)
|
||||||
|
2. 或考虑小程序原生图表库(如 wx-charts)
|
||||||
|
3. 预计可减少 ~80% 的趋势页体积
|
||||||
|
|
||||||
|
### 4.2 架构优化建议
|
||||||
|
|
||||||
|
| 优先级 | 建议 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| P0 | Token 刷新加锁 | 实现单例 Promise 模式避免并发刷新 |
|
||||||
|
| P0 | 积分商城降级 UI | 未关联患者时显示引导而非空白 |
|
||||||
|
| P1 | daily-monitoring 加 Zod | 与 health/input 对齐验证标准 |
|
||||||
|
| P1 | Analytics 复用 request.ts | 消除独立的 `Taro.request` 调用 |
|
||||||
|
| P1 | 文章列表过滤草稿 | 患者端只展示 published 文章 |
|
||||||
|
| P2 | 聊天轮询 → WebSocket | 后端 SSE 基础设施已规划 |
|
||||||
|
| P2 | ECharts 按需引入 | 趋势页 455KiB → ~90KiB |
|
||||||
|
| P2 | 类型安全强化 | auth store 消除 `as any` |
|
||||||
|
| P3 | 统一错误处理 | 静默 catch → console.warn + 上报 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、缺失功能 / TODO 清单
|
||||||
|
|
||||||
|
| 功能 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 模板消息 | TODO | `wechat-templates.ts` 模板 ID 全部为空 |
|
||||||
|
| 用药提醒 | 仅本地 | 无后端同步,换设备即丢失 |
|
||||||
|
| 文章 Tab | 注册但未使用 | `pages/article/index` 在 pages 列表但不在 tabBar |
|
||||||
|
| 医生排班日历 | API 存在 | 后端 `doctor-schedules/calendar` 已实现,前端调用需验证 |
|
||||||
|
| AI 报告 | 页面存在 | 需验证 erp-ai 模块集成后的实际数据渲染 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、修复优先级建议
|
||||||
|
|
||||||
|
### 立即修复(P0 — 影响用户体验/安全)
|
||||||
|
|
||||||
|
1. **F1: 今日体征数据不刷新** — 核心健康功能链路断裂
|
||||||
|
2. **H1: Token 刷新竞态** — 可能导致用户被锁死
|
||||||
|
3. **F2: 积分商城降级 UI** — Tab 页空白影响用户信任
|
||||||
|
|
||||||
|
### 短期修复(P1 — 1-2 周内)
|
||||||
|
|
||||||
|
4. **F3: 文章列表过滤草稿**
|
||||||
|
5. **H5: 登录防重复点击**
|
||||||
|
6. **H6: Analytics 复用请求层**
|
||||||
|
7. **M6: daily-monitoring 加 Zod 验证**
|
||||||
|
8. **咨询会话 subject 字段缺失处理**
|
||||||
|
|
||||||
|
### 中期优化(P2 — 迭代规划)
|
||||||
|
|
||||||
|
9. H2/H3: 存储加密加强
|
||||||
|
10. M1/M2/M7/M8: 存储清理 + PII 加密统一
|
||||||
|
11. 包体积优化(ECharts 按需引入)
|
||||||
|
12. 类型安全强化(消除 `any`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、附录:API 实测数据
|
||||||
|
|
||||||
|
### 数据库当前状态
|
||||||
|
|
||||||
|
| 实体 | 记录数 |
|
||||||
|
|------|--------|
|
||||||
|
| 患者 | 6 |
|
||||||
|
| 医生 | 2 |
|
||||||
|
| 预约 | 1(已完成) |
|
||||||
|
| 咨询会话 | 2(1 active, 1 waiting) |
|
||||||
|
| 随访任务 | 5(3 pending, 2 completed) |
|
||||||
|
| 文章 | 4(1 draft, 3 published) |
|
||||||
|
| 文章分类 | 2+ |
|
||||||
|
| 商品 | 1(Health Kit, 50 积分, 库存 100) |
|
||||||
|
| 医生排班 | 1(2026-04-28 AM, max 10, current 1) |
|
||||||
|
| 化验报告 | 0 |
|
||||||
|
| 线下活动 | 0 |
|
||||||
|
|
||||||
|
### 关键 ID(用于后续测试)
|
||||||
|
|
||||||
|
- 患者张三: `019dca49-1a88-7280-b44d-3ee5162b61ee` (user_id: null)
|
||||||
|
- 活跃咨询会话: `019dc2ac-235f-7550-a64c-3d66edfbf1ae`
|
||||||
|
- 已完成预约: doctor `019dc29b...`, date 2026-04-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、MCP 自动化页面渲染审计(实测补充)
|
||||||
|
|
||||||
|
> 使用 miniprogram-automator 通过 MCP 协议连接微信开发者工具,逐页导航验证渲染。
|
||||||
|
|
||||||
|
### 8.1 TabBar 页面(5/5 通过)
|
||||||
|
|
||||||
|
| 页面路径 | 状态 |
|
||||||
|
|----------|------|
|
||||||
|
| pages/index/index | OK |
|
||||||
|
| pages/health/index | OK |
|
||||||
|
| pages/consultation/index | OK |
|
||||||
|
| pages/mall/index | OK |
|
||||||
|
| pages/profile/index | OK |
|
||||||
|
|
||||||
|
### 8.2 患者端 + 医护端子页面(24/24 通过)
|
||||||
|
|
||||||
|
全部使用 `reLaunch` 逐页导航,无崩溃、无重定向到登录页:
|
||||||
|
|
||||||
|
| 页面路径 | 状态 |
|
||||||
|
|----------|------|
|
||||||
|
| pages/health/input/index | OK |
|
||||||
|
| pages/health/trend/index | OK |
|
||||||
|
| pages/health/daily-monitoring/index | OK |
|
||||||
|
| pages/appointment/index | OK |
|
||||||
|
| pages/appointment/create/index | OK |
|
||||||
|
| pages/article/index | OK |
|
||||||
|
| pages/ai-report/list/index | OK |
|
||||||
|
| pages/followup/detail/index | OK |
|
||||||
|
| pages/consultation/detail/index | OK |
|
||||||
|
| pages/mall/orders/index | OK |
|
||||||
|
| pages/profile/family/index | OK |
|
||||||
|
| pages/profile/reports/index | OK |
|
||||||
|
| pages/profile/followups/index | OK |
|
||||||
|
| pages/profile/medication/index | OK |
|
||||||
|
| pages/profile/settings/index | OK |
|
||||||
|
| pages/legal/user-agreement | OK |
|
||||||
|
| pages/legal/privacy-policy | OK |
|
||||||
|
| pages/doctor/index | OK |
|
||||||
|
| pages/doctor/patients/index | OK |
|
||||||
|
| pages/doctor/consultation/index | OK |
|
||||||
|
| pages/doctor/followup/index | OK |
|
||||||
|
| pages/doctor/report/index | OK |
|
||||||
|
| pages/events/index | OK |
|
||||||
|
| pages/device-sync/index | OK |
|
||||||
|
|
||||||
|
### 8.3 详情页(假 ID 优雅降级,11/11 通过)
|
||||||
|
|
||||||
|
使用 UUID `00000000-0000-0000-0000-000000000000` 测试,所有页面不崩溃、显示空状态或加载中:
|
||||||
|
|
||||||
|
| 页面路径 | 状态 |
|
||||||
|
|----------|------|
|
||||||
|
| pages/appointment/detail/index?id=... | OK |
|
||||||
|
| pages/article/detail/index?id=... | OK |
|
||||||
|
| pages/report/detail/index?id=... | OK |
|
||||||
|
| pages/ai-report/detail/index?id=... | OK |
|
||||||
|
| pages/mall/detail/index?id=... | OK |
|
||||||
|
| pages/mall/exchange/index?id=... | OK |
|
||||||
|
| pages/profile/family-add/index | OK |
|
||||||
|
| pages/doctor/patients/detail/index?id=... | OK |
|
||||||
|
| pages/doctor/consultation/detail/index?id=... | OK |
|
||||||
|
| pages/doctor/followup/detail/index?id=... | OK |
|
||||||
|
| pages/doctor/report/detail/index?id=... | OK |
|
||||||
|
|
||||||
|
### 8.4 MCP 审计结论
|
||||||
|
|
||||||
|
**40/40 页面全部正常渲染**,无白屏、无崩溃、无意外重定向。详情页对无效 ID 参数均能优雅降级。
|
||||||
|
|
||||||
|
### 8.5 关于积分 API 404 的补充说明
|
||||||
|
|
||||||
|
上一轮审计中 `GET /health/points/account` 等 4 个端点返回 404,经查:
|
||||||
|
- 路由已正确注册在 `crates/erp-health/src/module.rs:454-484`
|
||||||
|
- 404 原因是 `resolve_patient_id()` 查找 admin 用户的关联患者档案失败
|
||||||
|
- 这是应用层 404("当前用户未关联患者档案"),不是路由缺失
|
||||||
|
- 属于预期行为:积分端点设计为患者端使用,管理员账号无患者档案
|
||||||
431
docs/sales/2026-04-27-hms-health-management-sales-pitch.md
Normal file
431
docs/sales/2026-04-27-hms-health-management-sales-pitch.md
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
# HMS 健康管理平台 — 让健康人群成为你的患者
|
||||||
|
|
||||||
|
> **从「管理已就诊患者」到「主动获取未来患者」的思维转变**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 你的诊所只覆盖了患者生命的 0.02%
|
||||||
|
|
||||||
|
一个患者每年在你诊所停留的时间大约是 **2 小时**。
|
||||||
|
|
||||||
|
而他一年有 **8,760 小时** 在诊所之外。
|
||||||
|
|
||||||
|
你的 HIS(医院信息系统)完美覆盖了那 2 小时——挂号、诊疗、开药、收费。但剩下的 8,760 小时里,你几乎一无所知:
|
||||||
|
|
||||||
|
- 他回家后按时吃药了吗?
|
||||||
|
- 血压血糖控制得怎么样?
|
||||||
|
- 上次说"下周来复查",来了吗?
|
||||||
|
- 那些从来没挂过号的「健康人」,你怎么让他们在需要的时候想到你?
|
||||||
|
|
||||||
|
**这不是 HIS 的问题——HIS 天生就是为院内流程设计的。**
|
||||||
|
|
||||||
|
你需要的是另一套系统:一套 **从健康人群出发,构建完整健康管理闭环的平台**。
|
||||||
|
|
||||||
|
### 你在漏掉什么?
|
||||||
|
|
||||||
|
| 你在漏掉的 | 这意味着什么 |
|
||||||
|
|-----------|-------------|
|
||||||
|
| **健康人群** | 从未就诊的人完全没有渠道接触你,你只能等他们生病上门 |
|
||||||
|
| **院外数据** | 患者在家测的血压、血糖、体重你完全不知道,直到下次就诊才发现异常 |
|
||||||
|
| **随访机会** | 出院时说"记得复查",3 个月后双方都忘了,复诊率靠运气 |
|
||||||
|
| **早期干预** | 慢病早期指标变化缓慢,等患者感觉不适才来就诊,往往已经加重 |
|
||||||
|
| **持续关系** | 治疗结束 = 关系结束,没有持续的触点维持医患连接 |
|
||||||
|
|
||||||
|
### 我们的方法:把「漏斗」翻过来
|
||||||
|
|
||||||
|
传统模式下,诊所是**被动等待**患者上门。
|
||||||
|
|
||||||
|
HMS 健康管理平台提供一种全新的路径——**主动经营健康人群,在问题出现时自然转化为就诊**:
|
||||||
|
|
||||||
|
```
|
||||||
|
健康人群 ──→ 建立粘性 ──→ 早期预警 ──→ 主动干预 ──→ 就诊转化
|
||||||
|
↑ │
|
||||||
|
└──────────── 康复管理 · 持续随访 · 长期粘性 ─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心逻辑:健康管理系统不是「售后工具」,而是「患者获取引擎」。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 六阶段用户旅程:从陌生人到长期患者
|
||||||
|
|
||||||
|
HMS 平台围绕用户的完整健康生命周期设计,每一个阶段都有对应的产品能力支撑。
|
||||||
|
|
||||||
|
### 第一阶段:吸引 — 让健康人群认识你
|
||||||
|
|
||||||
|
在用户还是「健康人」的时候就开始建立连接。
|
||||||
|
|
||||||
|
**积分签到体系**
|
||||||
|
|
||||||
|
- 每日打开小程序签到获得积分
|
||||||
|
- 连续打卡 7/14/30 天获得阶梯奖励
|
||||||
|
- 让用户养成每天打开的习惯——**日活就是一切增长的起点**
|
||||||
|
|
||||||
|
**健康内容推送**
|
||||||
|
|
||||||
|
- 科普文章、饮食建议、运动指导、疾病预防知识
|
||||||
|
- 按用户标签精准推送(如:50 岁以上推送心血管内容)
|
||||||
|
- **内容即广告**——每一次阅读都是品牌心智的建立
|
||||||
|
|
||||||
|
**线下活动运营**
|
||||||
|
|
||||||
|
- 健康讲座、义诊、社区筛查活动
|
||||||
|
- 活动报名、现场扫码签到、自动发放积分奖励
|
||||||
|
- 线下触达 → 线上留存,O2O 闭环
|
||||||
|
|
||||||
|
### 第二阶段:感知 — 让用户看见自己的健康数据
|
||||||
|
|
||||||
|
当用户开始关注自己的身体,系统提供专业级的数据采集能力。
|
||||||
|
|
||||||
|
**每日体征上报(小程序端)**
|
||||||
|
|
||||||
|
用户在家即可记录:
|
||||||
|
- 血压(收缩压/舒张压,晨起/晚间)
|
||||||
|
- 心率
|
||||||
|
- 体重
|
||||||
|
- 血糖(支持空腹/餐后类型标记)
|
||||||
|
- 体温
|
||||||
|
- 血氧饱和度(SpO₂)
|
||||||
|
- 入水量 / 出尿量
|
||||||
|
|
||||||
|
**趋势可视化**
|
||||||
|
|
||||||
|
- 7 天 / 30 天 / 90 天趋势图表
|
||||||
|
- 单一指标时间趋势 + 正常范围参考线
|
||||||
|
- 异常数据点高亮标注——**从数字到图形,异常一目了然**
|
||||||
|
|
||||||
|
**日常监测**
|
||||||
|
|
||||||
|
- 结构化的早晚血压记录
|
||||||
|
- 体重变化追踪
|
||||||
|
- 出入量管理(肾病/心衰患者关键指标)
|
||||||
|
|
||||||
|
### 第三阶段:预警 — 数据驱动,主动干预
|
||||||
|
|
||||||
|
系统替你盯着每一个用户的数据,异常时自动通知医护团队。
|
||||||
|
|
||||||
|
**智能告警引擎**
|
||||||
|
|
||||||
|
- 按指标、科室、年龄段灵活配置告警阈值
|
||||||
|
- 支持多种条件类型(超限、趋势异常、连续偏高)
|
||||||
|
- 按严重级别分级处理:低级记录、中级通知、高级紧急
|
||||||
|
|
||||||
|
**危急值通知**
|
||||||
|
|
||||||
|
- 血压严重偏离、血糖危险值、血氧过低等即时触发
|
||||||
|
- 自动通知对应科室的负责医生
|
||||||
|
- **从"等患者来看"变成"主动找患者"**
|
||||||
|
|
||||||
|
**趋势分析报告**
|
||||||
|
|
||||||
|
- 按时间区间生成指标摘要
|
||||||
|
- 自动识别异常项和变化趋势
|
||||||
|
- 为医生提供数据驱动的干预建议,不是拍脑袋
|
||||||
|
|
||||||
|
### 第四阶段:转化 — 从健康用户到就诊患者
|
||||||
|
|
||||||
|
当预警触发或用户主动寻求帮助时,系统提供无缝的转化通道。
|
||||||
|
|
||||||
|
**在线咨询**
|
||||||
|
|
||||||
|
- 用户有疑问 → 发消息 → 医生回复
|
||||||
|
- 支持文本、图片、处方建议
|
||||||
|
- 降低就医门槛——**一次线上咨询可能就是一次到诊**
|
||||||
|
|
||||||
|
**预约排班**
|
||||||
|
|
||||||
|
- 日历视图展示医生排班
|
||||||
|
- 患者一键预约,名额原子级 CAS 保护(不超额)
|
||||||
|
- 预约状态全流程追踪:待确认 → 已确认 → 已完成/已取消
|
||||||
|
|
||||||
|
**医生主页**
|
||||||
|
|
||||||
|
- 科室、职称、专长、简介展示
|
||||||
|
- 在线状态实时显示
|
||||||
|
- 建立医患信任——选医生不是盲选
|
||||||
|
|
||||||
|
### 第五阶段:粘性 — 就诊后不流失
|
||||||
|
|
||||||
|
患者完成就诊后,系统确保关系不断裂。
|
||||||
|
|
||||||
|
**智能随访管理**
|
||||||
|
|
||||||
|
- 就诊/出院后自动创建随访任务
|
||||||
|
- 5 种随访方式:电话、门诊、上门、线上、微信
|
||||||
|
- 逾期自动标记并通知医护
|
||||||
|
- 批量创建、批量指派,高效执行
|
||||||
|
|
||||||
|
**用药管理**
|
||||||
|
|
||||||
|
- 当前/历史用药完整记录
|
||||||
|
- 用药频率(每日/BID/TID/QID/按需)和给药途径
|
||||||
|
- 跨科室用药安全审查的基础
|
||||||
|
|
||||||
|
**检验报告管理**
|
||||||
|
|
||||||
|
- 上传化验单 → 结构化提取 → 医生审阅
|
||||||
|
- 支持拍照识别和手工录入
|
||||||
|
- 异常项自动标注
|
||||||
|
- 报告类型:肾功能、血常规、电解质、肝功能等
|
||||||
|
|
||||||
|
**全生命周期健康档案**
|
||||||
|
|
||||||
|
- 体检记录 + 体征数据 + 化验报告 + 诊断记录 + 用药记录
|
||||||
|
- 360° 患者画像,时间线展示
|
||||||
|
- 每次就诊都有完整历史上下文——**医患信任因数据而加深**
|
||||||
|
|
||||||
|
**专科深度能力**
|
||||||
|
|
||||||
|
| 专科领域 | 系统专项能力 |
|
||||||
|
|---------|-------------|
|
||||||
|
| 肾内科 | 透析记录(HD/HDF/HF)、干体重、出入量、超滤量、并发症追踪 |
|
||||||
|
| 糖尿病 | 血糖类型标记(空腹/餐后)、用药频率管理、长期趋势监控 |
|
||||||
|
| 心血管 | 早晚血压分离、心率/血氧综合监测、心血管风险评估 |
|
||||||
|
| 体检中心 | 报告结构化入库、异常项追踪、自动生成随访任务 |
|
||||||
|
|
||||||
|
### 第六阶段:运营 — 数据驱动持续增长
|
||||||
|
|
||||||
|
全面的数据看板,让你对经营状况了如指掌。
|
||||||
|
|
||||||
|
**运营统计看板**
|
||||||
|
|
||||||
|
- 患者统计:总数、新增、活跃、留存
|
||||||
|
- 咨询统计:会话量、响应时间、满意度
|
||||||
|
- 随访统计:完成率、逾期率
|
||||||
|
- 体征统计:上报率、7 天趋势
|
||||||
|
- 透析/化验:数量、类型分布、待审核
|
||||||
|
|
||||||
|
**积分商城**
|
||||||
|
|
||||||
|
- 积分兑换服务(免费复查、健康评估)
|
||||||
|
- 积分兑换实物(健康设备、保健品)
|
||||||
|
- 核销扫码——积分有出口,用户有动力
|
||||||
|
|
||||||
|
**隐私合规**
|
||||||
|
|
||||||
|
- 知情同意管理:患者授权/撤销有据可查
|
||||||
|
- PII 数据加密存储:身份证、手机号、过敏史等敏感信息
|
||||||
|
- 完整审计日志——合规无忧
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 为什么选择 HMS — 三个差异化优势
|
||||||
|
|
||||||
|
市面上有随访工具、健康小程序、积分平台。但 HMS 不是工具拼凑,是 **一体化健康管理平台**。
|
||||||
|
|
||||||
|
### 差异化一:一体化 vs 拼凑
|
||||||
|
|
||||||
|
**别家的方案:**
|
||||||
|
|
||||||
|
> 随访用 A 系统 + 问诊用 B 小程序 + 健康数据用 C App + 积分用 D 平台
|
||||||
|
> → 数据不互通,患者要注册 4 个账号,医护要开 4 个后台
|
||||||
|
|
||||||
|
**HMS 的方案:**
|
||||||
|
|
||||||
|
> 一套平台,一套数据,一个后台,一个小程序
|
||||||
|
> → 患者的体征、化验、随访、咨询、积分全在一条时间线上
|
||||||
|
|
||||||
|
**这意味着什么?**
|
||||||
|
- 患者数据之间产生联动:体征异常 → 触发告警 → 自动创建随访 → 医生发起咨询
|
||||||
|
- 运营数据统一看板:不需要在 4 个系统之间切换
|
||||||
|
- 患者只装一个小程序,使用门槛极低
|
||||||
|
|
||||||
|
### 差异化二:专科级深度 vs 通用工具
|
||||||
|
|
||||||
|
通用健康工具只能记个血压血糖。HMS 为专科场景提供深度支持:
|
||||||
|
|
||||||
|
| 能力维度 | 通用健康工具 | HMS |
|
||||||
|
|---------|-------------|-----|
|
||||||
|
| 体征采集 | 血压、心率 | 血压(晨/晚)、心率、体重、血糖(空腹/餐后)、体温、血氧、入水量、出尿量 |
|
||||||
|
| 透析管理 | 无 | HD/HDF/HF 三种透析类型、干体重、超滤量、并发症追踪、审阅流程 |
|
||||||
|
| 化验报告 | 上传 PDF | 结构化提取 + 异常标注 + 医生审阅 + 趋势对比 |
|
||||||
|
| 告警规则 | 固定阈值 | 按指标/科室/年龄段/严重级别灵活配置 |
|
||||||
|
| 随访管理 | 提醒功能 | 任务创建 → 执行 → 记录 → 后续任务自动流转,支持 5 种随访方式 |
|
||||||
|
| 诊断编码 | 无 | 支持 ICD 编码,主诊断/次诊断/合并症分类 |
|
||||||
|
|
||||||
|
**通用工具只能做「记录」,HMS 能做「管理」。**
|
||||||
|
|
||||||
|
### 差异化三:企业级安全 vs 小作坊
|
||||||
|
|
||||||
|
医疗数据的安全不是可选项,是底线。
|
||||||
|
|
||||||
|
| 安全维度 | HMS 的做法 |
|
||||||
|
|---------|-----------|
|
||||||
|
| **数据加密** | PII 敏感信息(身份证、手机号、过敏史、病史、咨询内容)AES-256 加密存储 + HMAC 索引 |
|
||||||
|
| **数据隔离** | 多租户架构,每家机构的数据完全独立,SQL 级别隔离 |
|
||||||
|
| **并发安全** | 乐观锁 + CAS 原子操作——积分不超发、预约不超额、库存不超卖 |
|
||||||
|
| **审计追溯** | 所有写操作记录审计日志(谁、何时、改了什么) |
|
||||||
|
| **隐私合规** | 知情同意管理(授权/撤销/有效期/见证人) |
|
||||||
|
| **部署灵活** | 支持 SaaS 和私有化部署——数据可以不出机构 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 从专科诊所到健康管理中心的升级路径
|
||||||
|
|
||||||
|
**你不需要一步到位。HMS 支持你按需扩展,同一套平台,随时升级。**
|
||||||
|
|
||||||
|
### 三步走战略
|
||||||
|
|
||||||
|
**第一步:专科诊所(今天)**
|
||||||
|
|
||||||
|
选择你最擅长的专科领域,先用起来:
|
||||||
|
- 肾内科 → 透析记录 + 出入量 + 化验追踪
|
||||||
|
- 糖尿病 → 血糖监测 + 用药管理 + 并发症预警
|
||||||
|
- 心血管 → 血压监测 + 心率/血氧 + 趋势分析
|
||||||
|
|
||||||
|
核心能力:患者管理 + 体征监测 + 随访 + 在线咨询
|
||||||
|
|
||||||
|
**第二步:多科室健康管理中心(明天)**
|
||||||
|
|
||||||
|
在一个平台上开通多个科室,数据互通:
|
||||||
|
- 患者在不同科室的就诊记录统一归档
|
||||||
|
- 跨科室用药安全审查
|
||||||
|
- 统一运营看板,全院经营数据一目了然
|
||||||
|
|
||||||
|
新增能力:告警引擎 + 积分运营 + 内容管理 + 统计报表
|
||||||
|
|
||||||
|
**第三步:区域健康管理平台(后天)**
|
||||||
|
|
||||||
|
从治疗走向预防,从院内走向社区:
|
||||||
|
- 体检中心对接:团检报告结构化入库 + 异常追踪
|
||||||
|
- 企业健康管理:员工健康档案 + 年度体检对比
|
||||||
|
- 社区健康站:日常监测 + 居家养老 + 紧急告警
|
||||||
|
- IoT 设备对接:血压计、血糖仪、血氧仪数据自动上传
|
||||||
|
|
||||||
|
新增能力:私有化部署 + IoT 设备对接 + 定制开发
|
||||||
|
|
||||||
|
### 关键承诺
|
||||||
|
|
||||||
|
> **你今天投入的每一分钱,都不会因为规模扩大而浪费。**
|
||||||
|
> 同一套平台,从 1 个科室到 100 个机构,数据架构和安全体系完全一致。
|
||||||
|
> 不需要换系统,不需要迁移数据,只需要开通新的模块。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 一个真实场景:王先生的 45 天健康管理旅程
|
||||||
|
|
||||||
|
> 王先生,52 岁,既往体健,从未到过我们的诊所。
|
||||||
|
|
||||||
|
**Day 1 — 相遇**
|
||||||
|
|
||||||
|
王先生在社区看到一场「心血管健康讲座」的海报,扫码报名(线下活动)。讲座当天签到获得 50 积分,关注了诊所的小程序。
|
||||||
|
|
||||||
|
**Day 1–7 — 建立习惯**
|
||||||
|
|
||||||
|
每天打开小程序签到领积分(积分签到)。连续 7 天打卡,获得阶梯奖励。期间看了 3 篇心血管科普文章,学会了在家测血压的重要性(健康内容)。
|
||||||
|
|
||||||
|
**Day 8 — 开始记录**
|
||||||
|
|
||||||
|
王先生翻出家里的血压计,开始每天在小程序记录血压和心率(体征上报)。系统展示 7 天趋势图,一切正常。
|
||||||
|
|
||||||
|
**Day 15 — 系统发出预警**
|
||||||
|
|
||||||
|
连续 3 天晨起收缩压超过 140mmHg,系统触发告警(智能告警引擎)。医护团队在后台看到预警信息。
|
||||||
|
|
||||||
|
**Day 16 — 主动触达**
|
||||||
|
|
||||||
|
系统自动推送消息:「王先生您好,您近 3 天的血压偏高,建议咨询医生。」王先生点击消息,直接进入在线咨询页面,向心内科医生描述了自己的情况(在线咨询)。
|
||||||
|
|
||||||
|
**Day 16 — 医生建议就诊**
|
||||||
|
|
||||||
|
医生查看王先生的历史体征数据,建议来院做进一步检查。王先生当场完成预约(预约排班),挂号心内科。
|
||||||
|
|
||||||
|
**Day 18 — 确诊就诊**
|
||||||
|
|
||||||
|
王先生来院检查,确诊轻度高血压。医生开具处方,建立健康档案(诊断管理 + 用药管理)。出院时,系统自动创建随访任务:2 周后电话随访(智能随访)。
|
||||||
|
|
||||||
|
**Day 18–45 — 持续管理**
|
||||||
|
|
||||||
|
- 每日血压继续上报,系统自动监测趋势
|
||||||
|
- 第 4 周电话随访执行,血压控制良好
|
||||||
|
- 用药记录持续更新
|
||||||
|
- 积分兑换了一次免费复查(积分商城)
|
||||||
|
|
||||||
|
**Day 45 — 数据说话**
|
||||||
|
|
||||||
|
血压控制稳定,王先生在平台上已经活跃了 45 天。他不仅是一名患者,更是一个长期健康管理的忠实用户。
|
||||||
|
|
||||||
|
**回顾这条路径:**
|
||||||
|
|
||||||
|
| 阶段 | 平台能力 | 业务价值 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 吸引 | 线下活动 + 积分 | 获客:一个「路人」变成「关注者」 |
|
||||||
|
| 感知 | 体征上报 + 趋势 | 数据积累:开始看见自己的健康 |
|
||||||
|
| 预警 | 智能告警 | 主动干预:在患者感觉不适前介入 |
|
||||||
|
| 转化 | 在线咨询 + 预约 | 获诊:从「想看病」到「挂上号」一步到位 |
|
||||||
|
| 粘性 | 随访 + 用药 + 档案 | 留存:就诊后不流失 |
|
||||||
|
| 运营 | 积分商城 | 复购:积分驱动复查复诊 |
|
||||||
|
|
||||||
|
> **一个从未踏入过诊所的「健康人」,在 45 天内被转化为一名长期管理的患者。**
|
||||||
|
> **全程无断裂,数据驱动每一个决策。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 合作方案
|
||||||
|
|
||||||
|
我们提供三种合作方式,按需选择,按需升级。
|
||||||
|
|
||||||
|
### 标准版 — 适合单科室/单诊所
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 患者管理 | 患者档案、家庭成员、标签分组、医生关联 |
|
||||||
|
| 体征监测 | 每日体征上报、趋势图表、日常监测 |
|
||||||
|
| 随访管理 | 智能任务创建、5 种随访方式、逾期提醒 |
|
||||||
|
| 在线咨询 | 患-医实时消息、会话管理 |
|
||||||
|
| 预约排班 | 医生排班、患者预约、状态追踪 |
|
||||||
|
| 小程序 | 患者端微信小程序(数据上报 + 咨询 + 预约) |
|
||||||
|
| Web 管理后台 | 医护管理端,浏览器即用 |
|
||||||
|
|
||||||
|
### 专业版 — 适合多科室机构
|
||||||
|
|
||||||
|
在标准版基础上增加:
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 智能告警引擎 | 可配置阈值、分级通知、危急值预警 |
|
||||||
|
| 积分运营体系 | 签到积分、连续打卡、积分商城、核销扫码 |
|
||||||
|
| 内容管理系统 | 科普文章、分类标签、审核发布、阅读统计 |
|
||||||
|
| 线下活动 | 活动发布、报名管理、签到积分 |
|
||||||
|
| 运营统计看板 | 患者增长、咨询量、随访完成率、体征上报率 |
|
||||||
|
| 专科深度模块 | 透析管理 / 糖尿病管理 / 心血管管理(按需开通) |
|
||||||
|
| 检验报告管理 | 结构化入库、异常标注、医生审阅 |
|
||||||
|
|
||||||
|
### 旗舰版 — 适合体检中心/区域平台
|
||||||
|
|
||||||
|
在专业版基础上增加:
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 私有化部署 | 数据不出机构,完全自主可控 |
|
||||||
|
| IoT 设备对接 | 血压计、血糖仪、血氧仪数据自动上传 |
|
||||||
|
| 企业团检 | 企业员工健康管理、年度体检对比 |
|
||||||
|
| 定制开发 | 按业务需求定制功能模块 |
|
||||||
|
| 多机构管理 | 统一平台管理多个科室/分院/合作点 |
|
||||||
|
| 专属客户成功经理 | 1 对 1 服务,确保平台成功上线运营 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 立即开始
|
||||||
|
|
||||||
|
**你的患者正在院外度过 8,760 小时。**
|
||||||
|
|
||||||
|
他们中有多少人正在默默发展成慢病患者?
|
||||||
|
有多少人会在下次体检时才被发现?
|
||||||
|
有多少人本来可以更早干预?
|
||||||
|
|
||||||
|
HMS 健康管理平台帮你:
|
||||||
|
|
||||||
|
- **触达** 那些从未走进你诊所的健康人群
|
||||||
|
- **看见** 患者在院外的真实健康数据
|
||||||
|
- **干预** 在问题变严重之前主动介入
|
||||||
|
- **留存** 就诊后持续管理,不复失
|
||||||
|
- **增长** 数据驱动的运营,持续获客
|
||||||
|
|
||||||
|
**从今天开始,让健康管理成为你的增长引擎。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> HMS 健康管理平台 — 从健康到医疗的完整闭环
|
||||||
|
>
|
||||||
|
> 联系我们获取演示账号或预约产品演示
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
# PII 分级加密扩展设计
|
||||||
|
|
||||||
|
> 日期: 2026-04-26 | 状态: 草案 | 作者: iven + Claude
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
1. [背景与动机](#1-背景与动机)
|
||||||
|
2. [设计目标](#2-设计目标)
|
||||||
|
3. [当前状态分析](#3-当前状态分析)
|
||||||
|
4. [分级加密策略](#4-分级加密策略)
|
||||||
|
5. [加密基础设施提升 (erp-core)](#5-加密基础设施提升)
|
||||||
|
6. [每租户独立密钥管理](#6-每租户独立密钥管理)
|
||||||
|
7. [Entity 层改造](#7-entity-层改造)
|
||||||
|
8. [Service 层集成](#8-service-层集成)
|
||||||
|
9. [数据迁移](#9-数据迁移)
|
||||||
|
10. [边界场景处理](#10-边界场景处理)
|
||||||
|
11. [分步实施计划](#11-分步实施计划)
|
||||||
|
12. [测试策略](#12-测试策略)
|
||||||
|
13. [风险与缓解](#13-风险与缓解)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与动机
|
||||||
|
|
||||||
|
HMS 健康管理平台当前仅加密了 `patient.id_number`(身份证号)一个字段,使用 AES-256-GCM + HMAC-SHA256 搜索索引。其余 30+ 个 PII 字段(患者姓名、电话、过敏史、病史摘要、咨询内容等)均为明文存储。
|
||||||
|
|
||||||
|
**驱动力:**
|
||||||
|
- 体检中心/医疗机构在采购前会进行安全审计,明文 PII 是 dealbreaker
|
||||||
|
- 《个人信息保护法》对医疗健康信息有特殊保护要求
|
||||||
|
- 多租户 SaaS 架构下,数据隔离需要从访问控制扩展到存储加密
|
||||||
|
- 三专家评审一致指出 PII 加密范围不足是高优合规风险
|
||||||
|
|
||||||
|
**约束:**
|
||||||
|
- 系统前期不涉及实际医疗行为,PII 加密是合规门槛而非实时临床安全
|
||||||
|
- 保持可搜索性:患者姓名是高频搜索字段,不能因加密丧失搜索能力
|
||||||
|
- 性能可接受:加密/解密开销不能显著影响 API 响应时间
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 设计目标
|
||||||
|
|
||||||
|
| 目标 | 指标 |
|
||||||
|
|------|------|
|
||||||
|
| PII 字段加密覆盖 | 从 1 个字段扩展到 15+ 个高风险字段 |
|
||||||
|
| 多租户密钥隔离 | 每租户独立 DEK,泄漏不影响其他租户 |
|
||||||
|
| 搜索能力保持 | HMAC 精确搜索 + 姓名明文模糊搜索 |
|
||||||
|
| 性能影响 | 单条解密 < 0.1ms,列表页不触发解密 |
|
||||||
|
| 零停机迁移 | 现有明文数据逐步加密,不锁表 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 当前状态分析
|
||||||
|
|
||||||
|
### 已有加密能力
|
||||||
|
|
||||||
|
| 组件 | 位置 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| `HealthCrypto` | `crates/erp-health/src/crypto.rs` | AES-256-GCM + HMAC-SHA256 |
|
||||||
|
| 身份证号加密 | `patient_service.rs` | 已上线 |
|
||||||
|
| 脱敏管道 | `crates/erp-health/src/service/masking.rs` | `mask_id_number`, `mask_phone` |
|
||||||
|
| AI 脱敏层 | `crates/erp-ai/src/sanitization/mod.rs` | DTO 去标识化 |
|
||||||
|
| 密钥配置 | `crates/erp-server/config/default.toml` | 环境变量注入 |
|
||||||
|
|
||||||
|
### 明文高风险字段清单
|
||||||
|
|
||||||
|
**患者 (patient) — 8 个未加密 PII 字段:**
|
||||||
|
- `name` — 姓名(决定不加密,保持搜索性)
|
||||||
|
- `emergency_contact_phone` — 紧急联系人电话
|
||||||
|
- `allergy_history` — 过敏史
|
||||||
|
- `medical_history_summary` — 病史摘要
|
||||||
|
- `emergency_contact_name` — 紧急联系人姓名
|
||||||
|
- `birth_date` — 出生日期
|
||||||
|
- `blood_type` — 血型
|
||||||
|
- `notes` — 备注
|
||||||
|
|
||||||
|
**家庭成员 (patient_family_member) — 2 个:**
|
||||||
|
- `phone` — 电话
|
||||||
|
- `name` — 姓名
|
||||||
|
|
||||||
|
**咨询消息 (consultation_message) — 1 个:**
|
||||||
|
- `content` — 咨询/问诊内容
|
||||||
|
|
||||||
|
**随访记录 (follow_up_record) — 3 个:**
|
||||||
|
- `result` — 随访结果
|
||||||
|
- `patient_condition` — 患者状况
|
||||||
|
- `medical_advice` — 医嘱
|
||||||
|
|
||||||
|
**诊断 (diagnosis) — 1 个:**
|
||||||
|
- `notes` — 诊断备注
|
||||||
|
|
||||||
|
**医生档案 (doctor_profile) — 1 个:**
|
||||||
|
- `license_number` — 执业证号
|
||||||
|
|
||||||
|
**透析记录 (dialysis_record) — 2 个:**
|
||||||
|
- `symptoms` — 症状 JSON
|
||||||
|
- `complication_notes` — 并发症记录
|
||||||
|
|
||||||
|
**化验报告 (lab_report) — 1 个:**
|
||||||
|
- `doctor_notes` — 医生备注
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 分级加密策略
|
||||||
|
|
||||||
|
### Tier 定义
|
||||||
|
|
||||||
|
| 级别 | 存储方式 | 搜索方式 | 展示方式 | 适用字段 |
|
||||||
|
|------|---------|---------|---------|---------|
|
||||||
|
| **Tier 1** | AES-256-GCM 加密 | HMAC-SHA256 精确匹配 | 解密后展示/脱敏 | 高敏感、可枚举的字段 |
|
||||||
|
| **Tier 2** | 明文 | 直接 SQL LIKE/模糊 | API 脱敏展示 | 中敏感、高频搜索的字段 |
|
||||||
|
| **Tier 3** | 明文 | 直接 SQL | 原样展示 | 非敏感或非 PII 字段 |
|
||||||
|
|
||||||
|
### Tier 1 — 加密存储字段
|
||||||
|
|
||||||
|
| Entity | 字段 | HMAC 搜索 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| patient | `emergency_contact_phone` | `emergency_contact_phone_hash` |
|
||||||
|
| patient | `allergy_history` | — |
|
||||||
|
| patient | `medical_history_summary` | — |
|
||||||
|
| patient_family_member | `phone` | `phone_hash` |
|
||||||
|
| consultation_message | `content` | — |
|
||||||
|
|
||||||
|
**咨询内容搜索说明:** `consultation_message.content` 加密后无法做全文搜索。替代方案:按咨询会话(consultation)维度搜索和过滤(通过状态、时间、医生等非加密字段),进入详情后查看具体消息内容。如未来需要全文搜索,可引入加密索引(如 Order-Preserving Encryption 或专用密文搜索引擎),但 V1 不实施。
|
||||||
|
| follow_up_record | `result` | — |
|
||||||
|
| follow_up_record | `patient_condition` | — |
|
||||||
|
| follow_up_record | `medical_advice` | — |
|
||||||
|
| doctor_profile | `license_number` | `license_number_hash` |
|
||||||
|
| diagnosis | `notes` | — |
|
||||||
|
| dialysis_record | `complication_notes` | — |
|
||||||
|
| dialysis_record | `symptoms` (JSON) | — |
|
||||||
|
| lab_report | `doctor_notes` | — |
|
||||||
|
|
||||||
|
**JSON 字段加密说明:** `dialysis_record.symptoms` 类型为 `serde_json::Value`,加密时先序列化为 JSON 字符串再加密,解密后反序列化回 `Value`。
|
||||||
|
|
||||||
|
**注:** `patient.id_number` 和 `id_number_hash` 已存在,无需改动。
|
||||||
|
|
||||||
|
### Tier 2 — 明文 + API 脱敏
|
||||||
|
|
||||||
|
| Entity | 字段 | 脱敏规则 |
|
||||||
|
|--------|------|---------|
|
||||||
|
| patient | `emergency_contact_name` | 保留首字 + `**`(如 `张**`),两字姓名也适用(`张*`) |
|
||||||
|
| patient | `birth_date` | 仅显示年份 |
|
||||||
|
| patient_family_member | `name` | 保留首字 + `**`,同上 |
|
||||||
|
|
||||||
|
### Tier 3 — 明文
|
||||||
|
|
||||||
|
`name`, `gender`, `blood_type`, 数值型体征数据, 日期, 状态字段, `icd_code`, `diagnosis_name`, `notes`(patient 和 health_record 的备注字段,内容不可预测且非临床核心)等。
|
||||||
|
|
||||||
|
**分类澄清:** `notes` 字段在不同 Entity 中分级不同。`patient.notes` 归入 Tier 3(一般备注),`diagnosis.notes` 归入 Tier 1(临床诊断备注),`follow_up_record` 的 `result`/`patient_condition`/`medical_advice` 归入 Tier 1。具体分级以上面 Tier 1/2/3 表格为准。
|
||||||
|
|
||||||
|
**设计决策:患者姓名不加密。** 原因:医疗场景下姓名不是真正意义上的隐私(前台叫号、医生喊名),保持明文可搜索对运营效率至关重要。加密姓名会强制改变搜索交互模式(从"先搜索"变成"先选择"),代价过大。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 加密基础设施提升
|
||||||
|
|
||||||
|
### 5.1 从 HealthCrypto 到 PiiCrypto
|
||||||
|
|
||||||
|
将 `crates/erp-health/src/crypto.rs` 中的 `HealthCrypto` 提升到 `crates/erp-core/src/crypto/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/erp-core/src/crypto/
|
||||||
|
├── mod.rs # PiiCrypto 公开接口
|
||||||
|
├── engine.rs # AES-256-GCM 加密/解密引擎
|
||||||
|
├── hmac.rs # HMAC-SHA256 搜索索引
|
||||||
|
├── key_manager.rs # 每租户 DEK 管理
|
||||||
|
└── masking.rs # 脱敏管道(从 health/service/masking.rs 提升)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 PiiCrypto 接口设计
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PiiCrypto {
|
||||||
|
kek: [u8; 32], // Master KEK
|
||||||
|
dek_cache: DashMap<Uuid, CachedDek>, // 租户 DEK 缓存
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CachedDek {
|
||||||
|
dek: [u8; 32],
|
||||||
|
version: u32,
|
||||||
|
loaded_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PiiCrypto {
|
||||||
|
/// 加密单个字段,返回 Base64(nonce + ciphertext + tag)
|
||||||
|
pub fn encrypt(&self, dek: &[u8; 32], plaintext: &str) -> Result<String>;
|
||||||
|
|
||||||
|
/// 解密单个字段
|
||||||
|
pub fn decrypt(&self, dek: &[u8; 32], ciphertext: &str) -> Result<String>;
|
||||||
|
|
||||||
|
/// 生成 HMAC-SHA256 搜索索引
|
||||||
|
pub fn hmac_hash(&self, dek: &[u8; 32], value: &str) -> String;
|
||||||
|
|
||||||
|
/// 批量解密(避免重复 DEK 加载)
|
||||||
|
pub fn decrypt_batch(&self, dek: &[u8; 32], ciphertexts: &[String]) -> Result<Vec<String>>;
|
||||||
|
|
||||||
|
/// 获取/创建指定租户的 DEK
|
||||||
|
pub async fn get_dek(&self, tenant_id: Uuid, db: &DatabaseConnection) -> Result<DekWithVersion>;
|
||||||
|
|
||||||
|
/// 轮换指定租户的 DEK
|
||||||
|
pub async fn rotate_dek(&self, tenant_id: Uuid, db: &DatabaseConnection) -> Result<()>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 依赖关系变化
|
||||||
|
|
||||||
|
```
|
||||||
|
Before: erp-health → (内部 crypto.rs)
|
||||||
|
After: erp-core (crypto/) ← erp-health (调用 PiiCrypto)
|
||||||
|
← 未来其他模块
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Cargo.toml 依赖变更
|
||||||
|
|
||||||
|
`erp-core/Cargo.toml` 需要新增:
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
hex = "0.4"
|
||||||
|
dashmap = "6"
|
||||||
|
```
|
||||||
|
|
||||||
|
`erp-health/Cargo.toml` 可移除 `aes-gcm`, `hmac`, `sha2`, `base64`, `hex`(已提升到 erp-core),改为依赖 `erp-core` 的 crypto 模块。
|
||||||
|
|
||||||
|
### 5.5 erp-ai 依赖关系澄清
|
||||||
|
|
||||||
|
当前 erp-ai 通过 `HealthDataProvider` trait 获取**已脱敏**的 DTO(不含 name/phone/id_number),**不需要**直接访问加密数据。PiiCrypto 提升到 erp-core 后,erp-ai 不需要调用 PiiCrypto 解密。AI 脱敏层继续作为独立防线运作。
|
||||||
|
|
||||||
|
如果未来 AI 需要原始 PII 数据(如精确匹配患者),可通过新增 `HealthDataProviderWithPii` trait 提供,此时才需要 erp-ai 调用 PiiCrypto。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 每租户独立密钥管理
|
||||||
|
|
||||||
|
### 6.1 密钥层级
|
||||||
|
|
||||||
|
```
|
||||||
|
Master KEK (Key Encryption Key)
|
||||||
|
来源: 环境变量 ERP__CRYPTO__KEK
|
||||||
|
用途: 加密保护所有租户的 DEK
|
||||||
|
格式: 32 字节 hex-encoded
|
||||||
|
|
||||||
|
└── Per-Tenant DEK (Data Encryption Key)
|
||||||
|
来源: 首次使用时随机生成,用 KEK 加密后存入 DB
|
||||||
|
用途: 加密该租户的 PII 数据
|
||||||
|
格式: 32 字节随机
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 数据库表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE tenant_crypto_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||||
|
encrypted_dek VARCHAR(128) NOT NULL, -- AES-256-GCM 加密的 DEK
|
||||||
|
key_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_by UUID,
|
||||||
|
updated_by UUID,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE(tenant_id, key_version)
|
||||||
|
);
|
||||||
|
-- 项目规范要求所有表包含: id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 DEK 缓存策略
|
||||||
|
|
||||||
|
| 参数 | 值 | 原因 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 最大缓存数 | 100 个租户 | 典型 SaaS 实例租户数 < 100 |
|
||||||
|
| TTL | 5 分钟 | 平衡安全性和性能 |
|
||||||
|
| 淘汰策略 | LRU | 简单有效 |
|
||||||
|
| 强制刷新 | 密钥轮换后 | 确保新密钥立即生效 |
|
||||||
|
|
||||||
|
使用 `DashMap`(并发安全 HashMap)实现缓存,避免每次请求查询数据库。
|
||||||
|
|
||||||
|
### 6.4 密钥轮换流程
|
||||||
|
|
||||||
|
1. 管理员调用 `POST /api/v1/admin/tenants/{id}/rotate-key`
|
||||||
|
2. 生成新 DEK,用 KEK 加密,写入 `tenant_crypto_keys` 表(version +1)
|
||||||
|
3. 后台任务启动:
|
||||||
|
- 读取该租户所有加密数据
|
||||||
|
- 用旧 DEK 解密 → 用新 DEK 加密
|
||||||
|
- 更新 `key_version` 字段
|
||||||
|
- 分批处理(每批 100 条),每批提交一次
|
||||||
|
4. 轮换期间,读取侧根据每条记录的 `key_version` 字段精确选择对应 DEK,不做"尝试解密 + 回退"(避免掩盖数据篡改或密钥管理错误)
|
||||||
|
5. 全部完成后,标记旧 DEK `is_active = false`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Entity 层改造
|
||||||
|
|
||||||
|
### 7.1 新增伴生字段
|
||||||
|
|
||||||
|
每个需要 HMAC 搜索的字段,在 SeaORM Entity 中新增 `_hash` 列:
|
||||||
|
|
||||||
|
| Entity | 新增字段 | 迁移操作 |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| patient | `emergency_contact_phone_hash: Option<String>` | ALTER TABLE ADD COLUMN |
|
||||||
|
| patient_family_member | `phone_hash: Option<String>` | ALTER TABLE ADD COLUMN |
|
||||||
|
| doctor_profile | `license_number_hash: Option<String>` | ALTER TABLE ADD COLUMN |
|
||||||
|
|
||||||
|
**HMAC hash 列索引:** 每个 `_hash` 列创建普通 B-tree 索引(`CREATE INDEX idx_{table}_{field}_hash ON {table}({field}_hash) WHERE {field}_hash IS NOT NULL`),确保 HMAC 搜索不会退化为全表扫描。
|
||||||
|
|
||||||
|
### 7.2 Entity Model 变化示例
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// crates/erp-health/src/entity/patient.rs
|
||||||
|
// 改造前:
|
||||||
|
pub struct Model {
|
||||||
|
pub id_number: Option<String>, // 已加密
|
||||||
|
pub id_number_hash: Option<String>, // 已有
|
||||||
|
pub emergency_contact_phone: Option<String>, // 明文
|
||||||
|
pub allergy_history: Option<String>, // 明文
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改造后:
|
||||||
|
pub struct Model {
|
||||||
|
pub id_number: Option<String>, // 已加密 (不变)
|
||||||
|
pub id_number_hash: Option<String>, // 已有 (不变)
|
||||||
|
pub emergency_contact_phone: Option<String>, // 现在存储加密值
|
||||||
|
pub emergency_contact_phone_hash: Option<String>, // 新增: HMAC 搜索索引
|
||||||
|
pub allergy_history: Option<String>, // 现在存储加密值
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**重要:** Entity 字段类型不变(仍为 `Option<String>`),只是存储内容从明文变为密文。这确保 SeaORM 查询层无需改动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Service 层集成
|
||||||
|
|
||||||
|
### 8.1 加密/解密注入点
|
||||||
|
|
||||||
|
Service 层在每个 CRUD 操作中透明处理加密:
|
||||||
|
|
||||||
|
| 操作 | 加密处理 | 解密处理 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Create | 加密 Tier 1 字段 + 生成 HMAC | — |
|
||||||
|
| Update | 重新加密变更的 Tier 1 字段 + 更新 HMAC | — |
|
||||||
|
| Get by ID | — | 解密 Tier 1 字段 |
|
||||||
|
| List | — | 列表不展示加密字段(返回 None) |
|
||||||
|
| Search | HMAC hash 替代明文搜索条件 | — |
|
||||||
|
|
||||||
|
### 8.2 PiiCrypto 注入方式
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// erp-server/src/state.rs
|
||||||
|
pub struct HealthState {
|
||||||
|
pub pii_crypto: PiiCrypto, // 替换原来的 HealthCrypto
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// patient_service.rs
|
||||||
|
pub async fn create_patient(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
crypto: &PiiCrypto,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
input: CreatePatientRequest,
|
||||||
|
) -> Result<PatientResponse> {
|
||||||
|
let dek = crypto.get_dek(tenant_id, db).await?;
|
||||||
|
|
||||||
|
// 加密 Tier 1 字段
|
||||||
|
let encrypted_phone = input.emergency_contact_phone
|
||||||
|
.map(|p| crypto.encrypt(&dek.key, &p))
|
||||||
|
.transpose()?;
|
||||||
|
let phone_hash = input.emergency_contact_phone
|
||||||
|
.map(|p| crypto.hmac_hash(&dek.key, &p));
|
||||||
|
|
||||||
|
// ... 插入数据库
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 数据迁移
|
||||||
|
|
||||||
|
### 9.1 迁移策略
|
||||||
|
|
||||||
|
采用**渐进式迁移**,不锁表,不中断服务:
|
||||||
|
|
||||||
|
1. **部署新代码** — 新代码根据 `key_version` 字段判断:`NULL` = 明文(直接返回),有值 = 密文(解密后返回)。不依赖 Base64 格式检测(自由文本字段本身可能是合法 Base64)。
|
||||||
|
2. **后台迁移任务** — 逐条加密现有明文数据
|
||||||
|
3. **完成切换** — 所有数据加密后,移除明文兼容路径
|
||||||
|
|
||||||
|
### 9.2 迁移任务设计
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn migrate_pii_encryption(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
crypto: &PiiCrypto,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
) -> Result<MigrationReport> {
|
||||||
|
let dek = crypto.get_dek(tenant_id, db).await?;
|
||||||
|
let batch_size = 100;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// 读取一批未迁移的记录(key_version < current 或 key_version IS NULL)
|
||||||
|
let batch = find_unencrypted_patients(db, tenant_id, batch_size).await?;
|
||||||
|
if batch.is_empty() { break; }
|
||||||
|
|
||||||
|
for patient in &batch {
|
||||||
|
// 加密明文字段
|
||||||
|
let encrypted_phone = patient.emergency_contact_phone.as_ref()
|
||||||
|
.map(|p| crypto.encrypt(&dek.key, p))
|
||||||
|
.transpose()?;
|
||||||
|
let phone_hash = patient.emergency_contact_phone.as_ref()
|
||||||
|
.map(|p| crypto.hmac_hash(&dek.key, p));
|
||||||
|
|
||||||
|
// 更新记录
|
||||||
|
update_patient_encrypted(db, patient.id, encrypted_phone, phone_hash, dek.version).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(Duration::from_millis(50)).await; // 避免压垮数据库
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Entity 新增 key_version 字段
|
||||||
|
|
||||||
|
所有包含 Tier 1 字段的 Entity 新增 `key_version: Option<i32>` 列,用于:
|
||||||
|
- 跟踪每条记录使用的 DEK 版本
|
||||||
|
- 支持渐进式迁移和轮换
|
||||||
|
- NULL 表示未加密(明文)
|
||||||
|
|
||||||
|
### 9.4 回滚计划
|
||||||
|
|
||||||
|
部署后如果需要回滚到旧代码:
|
||||||
|
- **新创建的加密数据:** 旧代码无法读取(`key_version` 非空的记录)。回滚前需要运行"解密迁移"脚本,将加密数据还原为明文。
|
||||||
|
- **未迁移的明文数据:** 旧代码可正常读取。
|
||||||
|
- **回滚脚本:** 提供与加密迁移对称的解密迁移脚本,从最新 `key_version` 的 DEK 解密所有字段,清除 `key_version`(设为 NULL)。
|
||||||
|
- **回滚窗口:** 建议在 Phase A 部署后 24 小时内保持回滚能力,超过 24 小时确认无问题后可移除明文兼容路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 边界场景处理
|
||||||
|
|
||||||
|
### 10.1 列表页性能
|
||||||
|
|
||||||
|
**策略:** 列表页不返回 Tier 1 加密字段(设为 `None`),仅展示 Tier 2/3 字段。
|
||||||
|
|
||||||
|
这与当前 `model_to_resp` 的设计一致(列表中 `id_number` 已经返回 `None`),无需改变交互模式。
|
||||||
|
|
||||||
|
**前端影响评估:** 当前 `model_to_resp` 已经将 `emergency_contact_phone` 做脱敏处理(`mask_phone`)后返回。加密后,列表页的 `model_to_resp` 需要将该字段设为 `None`(因为不解密),详情页的 `model_to_resp_decrypted` 需要解密后再脱敏。前端列表页如果展示了 `emergency_contact_phone`,需要适配为"详情中查看"。`patient_family_member` 和 `doctor_profile` 同理。
|
||||||
|
|
||||||
|
### 10.2 AI 分析管道
|
||||||
|
|
||||||
|
erp-ai **不直接调用 PiiCrypto**。它通过 `HealthDataProvider` trait(在 erp-core 中)获取已脱敏的 DTO,不含 name/phone/id_number 等原始 PII。PiiCrypto 对 erp-ai 完全透明——erp-health 在提供数据给 erp-ai 之前已经在 Service 层完成了解密+脱敏。AI 脱敏层(`SanitizationService`)作为二次防护,确保发送给 LLM 的数据不包含原始 PII。
|
||||||
|
|
||||||
|
### 10.3 数据导出
|
||||||
|
|
||||||
|
导出操作需要独立的权限码(如 `patient.export`),Service 层解密后输出。审计日志记录所有导出操作。
|
||||||
|
|
||||||
|
### 10.4 小程序端
|
||||||
|
|
||||||
|
小程序不直接处理加密数据。所有加密/解密在服务端完成,API 响应中的 Tier 2 字段做脱敏处理。
|
||||||
|
|
||||||
|
### 10.5 跨租户数据泄漏
|
||||||
|
|
||||||
|
即使数据库层面发生泄漏(如 SQL 注入),攻击者没有 KEK 就无法解密 DEK,没有 DEK 就无法解密 PII 数据。HMAC hash 字段不可逆,不泄漏原始值。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 分步实施计划
|
||||||
|
|
||||||
|
### Phase A — 基础设施 + Patient 实体(2-3 天)
|
||||||
|
|
||||||
|
**目标:** 加密基础设施落地,patient 实体全面加密。
|
||||||
|
|
||||||
|
| 步骤 | 内容 | 预估 |
|
||||||
|
|------|------|------|
|
||||||
|
| A1 | 提升加密基础设施到 erp-core/src/crypto/ | 0.5 天 |
|
||||||
|
| A2 | 实现 `tenant_crypto_keys` 表 + DEK 管理 | 0.5 天 |
|
||||||
|
| A3 | 修改 patient Entity(新增 hash 字段 + key_version) | 0.5 天 |
|
||||||
|
| A4 | 修改 patient_service 集成 PiiCrypto | 0.5 天 |
|
||||||
|
| A5 | 数据迁移脚本(patient 表现有数据) | 0.5 天 |
|
||||||
|
| A6 | 单元测试 + 集成测试 | 0.5 天 |
|
||||||
|
|
||||||
|
**验证标准:**
|
||||||
|
- `cargo check` + `cargo test` 全部通过
|
||||||
|
- 创建新患者 → 数据库中 phone/allergy 为密文,hash 正确
|
||||||
|
- 查询患者详情 → API 返回解密后的明文
|
||||||
|
- 搜索电话号码 → 通过 HMAC hash 匹配
|
||||||
|
|
||||||
|
### Phase B — 全 Entity 扩展 + 密钥轮换(2-3 天)
|
||||||
|
|
||||||
|
**目标:** 所有 Tier 1 字段加密,管理端支持密钥轮换。
|
||||||
|
|
||||||
|
| 步骤 | 内容 | 预估 |
|
||||||
|
|------|------|------|
|
||||||
|
| B1 | consultation_message, follow_up_record 加密 | 0.5 天 |
|
||||||
|
| B2 | patient_family_member, doctor_profile 加密 | 0.5 天 |
|
||||||
|
| B3 | dialysis_record, lab_report 加密 | 0.5 天 |
|
||||||
|
| B4 | 密钥轮换管理端点 + 后台任务 | 0.5 天 |
|
||||||
|
| B5 | DEK 缓存(DashMap LRU) | 0.5 天 |
|
||||||
|
| B6 | 全量集成测试 + 性能基准 | 0.5 天 |
|
||||||
|
|
||||||
|
**验证标准:**
|
||||||
|
- 所有 Tier 1 字段在数据库中为密文
|
||||||
|
- 所有现有 API 功能正常(透明加密/解密)
|
||||||
|
- 密钥轮换后数据可正常读写
|
||||||
|
- 批量解密 50 条记录 < 10ms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 测试策略
|
||||||
|
|
||||||
|
### 12.1 单元测试
|
||||||
|
|
||||||
|
| 测试类别 | 覆盖内容 | 数量 |
|
||||||
|
|----------|---------|------|
|
||||||
|
| 加密引擎 | encrypt/decrypt 正确性、随机 nonce 不可预测 | 5 |
|
||||||
|
| HMAC 索引 | 相同输入相同输出、不同输入不同输出 | 3 |
|
||||||
|
| 密钥管理 | DEK 生成/缓存/轮换 | 5 |
|
||||||
|
| 脱敏管道 | 各脱敏规则正确性 | 4 |
|
||||||
|
|
||||||
|
### 12.2 集成测试
|
||||||
|
|
||||||
|
| 测试类别 | 覆盖内容 | 数量 |
|
||||||
|
|----------|---------|------|
|
||||||
|
| CRUD 加密流 | 创建→加密存储→查询→解密返回 | 8 |
|
||||||
|
| HMAC 搜索 | 精确匹配搜索正确性 | 3 |
|
||||||
|
| 多租户隔离 | 租户 A 的 DEK 无法解密租户 B 的数据 | 2 |
|
||||||
|
| 密钥轮换 | 轮换前后数据可正常访问 | 2 |
|
||||||
|
|
||||||
|
### 12.3 性能测试
|
||||||
|
|
||||||
|
| 指标 | 基准 | 目标 |
|
||||||
|
|------|------|------|
|
||||||
|
| 单字段加密 | — | < 0.05ms |
|
||||||
|
| 单字段解密 | — | < 0.05ms |
|
||||||
|
| 50 条批量解密 | — | < 10ms |
|
||||||
|
| HMAC 生成 | — | < 0.01ms |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| 加密后搜索性能下降 | 低 | 中 | HMAC 索引 + B-tree 索引 |
|
||||||
|
| 迁移过程中服务中断 | 低 | 高 | 渐进式迁移 + 兼容读取 |
|
||||||
|
| DEK 缓存一致性 | 中 | 中 | TTL + 轮换后强制刷新 |
|
||||||
|
| 密钥泄漏 | 低 | 极高 | KEK 不存数据库 + 环境变量注入 + 泄漏恢复流程(见下) |
|
||||||
|
| 加密字段误漏 | 中 | 中 | 代码审查 + 单元测试覆盖 |
|
||||||
|
| KEK 泄漏灾难恢复 | 极低 | 极高 | 见 §14 灾难恢复 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. KEK 泄漏灾难恢复
|
||||||
|
|
||||||
|
**场景:** Master KEK 泄露(如环境变量配置文件意外提交到 Git)。
|
||||||
|
|
||||||
|
**恢复流程:**
|
||||||
|
|
||||||
|
1. **立即轮换 KEK** — 生成新 KEK,更新环境变量
|
||||||
|
2. **重新包装所有 DEK** — 用新 KEK 解密并重新加密每个租户的 DEK
|
||||||
|
```sql
|
||||||
|
-- 不需要重新加密 PII 数据,只需重新加密 DEK
|
||||||
|
UPDATE tenant_crypto_keys SET encrypted_dek = new_kek_encrypt(dek) WHERE is_active = true;
|
||||||
|
```
|
||||||
|
3. **清除 DEK 缓存** — 强制所有实例从数据库重新加载
|
||||||
|
4. **轮换泄漏渠道** — 轮换所有可能暴露 KEK 的凭据(Git tokens、CI secrets、部署脚本)
|
||||||
|
5. **审计追溯** — 检查访问日志,确认是否有未授权的数据访问
|
||||||
|
|
||||||
|
**关键优势:** KEK 泄漏只需要重新包装 DEK(N 次轻量操作),不需要重新加密所有 PII 数据(可能数百万条记录)。这是 KEK/DEK 两层架构的核心价值。
|
||||||
|
|
||||||
|
**预防措施:**
|
||||||
|
- KEK 通过 secrets manager 注入,不写入文件系统
|
||||||
|
- CI/CD 管道中扫描 KEK 值,防止意外提交
|
||||||
|
- `.gitignore` 排除 `.env` 文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:决策记录
|
||||||
|
|
||||||
|
| 决策 | 选项 | 选择 | 原因 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 加密范围 | 全面/分级/TDE | 分级 | 成本与收益平衡 |
|
||||||
|
| 姓名是否加密 | 加密/不加密 | 不加密 | 医疗场景下姓名不属隐私,搜索性更重要 |
|
||||||
|
| 基础设施位置 | health/core/独立 crate | erp-core | 通用性 + 简洁性平衡 |
|
||||||
|
| 密钥策略 | 全局/per-tenant/渐进 | 每租户独立 | 安全隔离,泄漏影响面最小 |
|
||||||
|
| 实施节奏 | 一次完成/分步/最小可用 | 分步 (A+B) | 风险分散,每步可验证 |
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
# HMS 平台基座回顾与演进设计
|
||||||
|
|
||||||
|
> 日期: 2026-04-26 | 状态: Draft | 方法: 三专家多视角评审
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
### 1.1 回顾目的
|
||||||
|
|
||||||
|
HMS 健康管理平台经过 17 天密集开发(2026-04-10 ~ 2026-04-26),从 ERP 底座演进到包含 16 个 Rust crate、62 个前端页面、27 个小程序页面的综合医疗 SaaS 平台。本次回顾旨在:
|
||||||
|
|
||||||
|
- **验证基座设计** — 星形依赖拓扑、ErpModule trait、事件总线、多租户策略是否经得起实践检验
|
||||||
|
- **评估演进路径** — 从插件开发模式到原生模块开发的决策是否正确
|
||||||
|
- **识别缺口与风险** — 通过多专家视角发现盲点
|
||||||
|
- **制定演进路线** — 基于 P0/P1/P2 优先级指导后续迭代
|
||||||
|
|
||||||
|
### 1.2 评审方法
|
||||||
|
|
||||||
|
采用三专家独立评审,每个专家从不同视角分析相同的诊断和建议:
|
||||||
|
|
||||||
|
| 专家 | 视角 | 关注点 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 高级系统架构师 | 架构可持续性 | 模块边界、事件可靠性、技术债 |
|
||||||
|
| 医疗信息化专家 | 临床安全与合规 | 患者安全、PIPL 合规、领域模型 |
|
||||||
|
| 产品策略专家 | ROI 与开发节奏 | 优先级、技术债量化、路线图现实性 |
|
||||||
|
|
||||||
|
### 1.3 核心结论
|
||||||
|
|
||||||
|
**基座设计方向正确,但深度不足。** 星形依赖、trait 抽象、事件总线等基础架构经受住了实践检验。但在临床安全(危急值告警未闭环)、合规(知情同意缺失)、事件可靠性(无重放机制)方面存在需立即修复的缺口。插件系统已验证可行性但对 HMS 核心业务贡献有限,建议有条件冻结。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 基座设计验证
|
||||||
|
|
||||||
|
### 2.1 评分总览
|
||||||
|
|
||||||
|
| 维度 | 评分 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 模块边界 | ★★★★ | 星形拓扑零循环依赖,trait 契约清晰 |
|
||||||
|
| ErpModule trait | ★★★★ | 生命周期/权限/事件/健康检查统一接口 |
|
||||||
|
| 事件总线 | ★★★☆ | 基础设施扎实(broadcast+outbox),但无重放机制,消费侧不完整 |
|
||||||
|
| 多租户 | ★★★☆ | JWT→TenantContext 全链路贯通,但缺 RLS 兜底和集成测试 |
|
||||||
|
| 权限体系 | ★★★★ | RBAC + 行级数据权限 + 按钮级控制 |
|
||||||
|
| 插件系统 | ★★★☆ | CRUD 场景验证通过,医疗场景天花板明显 |
|
||||||
|
| API 一致性 | ★★★★ | 统一 envelope、分页、OpenAPI 自动文档 |
|
||||||
|
| 数据库迁移 | ★★★★ | 59 个迁移,幂等、可回滚、fixup 模式健康 |
|
||||||
|
| 测试覆盖 | ★☆☆☆ | 36 后端 + 3 前端,覆盖率 < 5% |
|
||||||
|
| 合规性 | ★☆☆☆ | 知情同意缺失,审计不完整,PIE 加密范围不足 |
|
||||||
|
|
||||||
|
### 2.2 星形依赖拓扑
|
||||||
|
|
||||||
|
```
|
||||||
|
erp-core (L1)
|
||||||
|
/ | \ \ \ \
|
||||||
|
erp-auth workflow message config erp-health erp-plugin erp-ai
|
||||||
|
\ | / / / / /
|
||||||
|
erp-server (L3, 组装入口)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `erp-core`:零业务依赖,纯净基础层
|
||||||
|
- 7 个业务 crate:各只依赖 `erp-core`,兄弟间无横向依赖
|
||||||
|
- `erp-server`:唯一组装点,负责路由合并和模块初始化
|
||||||
|
- **无循环依赖** — 架构师验证通过
|
||||||
|
|
||||||
|
### 2.3 ErpModule trait
|
||||||
|
|
||||||
|
当前 trait 提供统一的模块接口:
|
||||||
|
|
||||||
|
- **身份**:`name()` / `id()` / `version()`
|
||||||
|
- **依赖声明**:`dependencies()` — 用于拓扑排序启动顺序
|
||||||
|
- **生命周期**:`on_startup()` / `on_shutdown()` / `health_check()`
|
||||||
|
- **多租户**:`on_tenant_created()` / `on_tenant_deleted()`
|
||||||
|
- **权限自描述**:`permissions()` — 模块声明自己需要的权限码
|
||||||
|
- **事件订阅**:`register_event_handlers()` / `as_any()`
|
||||||
|
|
||||||
|
**已知张力**:路由注册不在 trait 中,而是通过各模块的 inherent method (`public_routes()` / `protected_routes()`) 手动在 `main.rs` 中合并。原因是 Axum 的 `Router<S>` 泛型约束不适合 trait object。这是务实的妥协,但在添加新模块时有 boilerplate 成本。
|
||||||
|
|
||||||
|
### 2.4 事件总线
|
||||||
|
|
||||||
|
**实现机制**:`tokio::sync::broadcast` (容量 1024) + `domain_events` 表持久化(best-effort)+ Outbox relay (5秒轮询,3次重试)
|
||||||
|
|
||||||
|
**发布侧**(已识别的事件类型):
|
||||||
|
|
||||||
|
| 模块 | 事件类型数 | 示例 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| erp-auth | 10 | `user.login`, `user.created`, `role.created` |
|
||||||
|
| erp-workflow | 4 | `process_instance.started`, `task.completed` |
|
||||||
|
| erp-message | 1 | `message.sent` |
|
||||||
|
| erp-health | 13 | `patient.created`, `health_data.critical_alert`, `follow_up.overdue` |
|
||||||
|
| erp-plugin | 2+ | `plugin.config.updated`, `plugin.trigger.*` |
|
||||||
|
|
||||||
|
**消费侧**(已识别的订阅者):
|
||||||
|
|
||||||
|
| 订阅者 | 订阅方式 | 处理的事件 |
|
||||||
|
|--------|---------|-----------|
|
||||||
|
| erp-message | `subscribe()` 全量 | `appointment.*`, `process_instance.*`, `task.*` |
|
||||||
|
| erp-health | `register_handlers_with_state` | `workflow.task.completed` |
|
||||||
|
| erp-plugin 通知 | `subscribe_filtered("plugin.trigger.*")` | 插件触发通知 |
|
||||||
|
| outbox relay | 轮询 DB | 重发 pending 事件 |
|
||||||
|
|
||||||
|
**已识别缺陷**:
|
||||||
|
1. **无重放机制** — 内存 broadcast,服务重启后未消费的事件丢失
|
||||||
|
2. **无幂等保护** — `follow_up.overdue` 每 6 小时检查会重复发布同一条逾期事件
|
||||||
|
3. **全量订阅** — erp-message 使用 `subscribe()` 而非 `subscribe_filtered()`,所有事件都经过消息模块
|
||||||
|
|
||||||
|
### 2.5 多租户
|
||||||
|
|
||||||
|
**已实现**:
|
||||||
|
- JWT claims 提取 `tenant_id` → `TenantContext` 注入请求扩展
|
||||||
|
- 所有 Entity 含 `tenant_id` 字段,BaseFields 统一
|
||||||
|
- 所有 DomainEvent 携带 `tenant_id`
|
||||||
|
- `on_tenant_created()` / `on_tenant_deleted()` 钩子(auth 和 health 已实现)
|
||||||
|
- 部门级数据范围(`department_ids` 在 TenantContext 中)
|
||||||
|
|
||||||
|
**缺失**:
|
||||||
|
- 无 PostgreSQL RLS policy 作为兜底层
|
||||||
|
- 无强制 tenant_id 过滤的查询层机制 — 依赖每个 service 手动 `.filter()`
|
||||||
|
- 当前实际只有 default_tenant,微信登录硬编码使用 `default_tenant_id`
|
||||||
|
- 无多租户管理 API(创建/配置/迁移)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 演进路径回顾
|
||||||
|
|
||||||
|
### 3.1 时间线
|
||||||
|
|
||||||
|
```
|
||||||
|
4/10-4/16 基座搭建 (Phase 1-6)
|
||||||
|
→ core → auth → config → workflow → message
|
||||||
|
→ 全部原生 Rust 模块,30+ 数据库表
|
||||||
|
|
||||||
|
4/13-4/18 WASM 插件实验
|
||||||
|
→ 插件系统设计与实现 (Wasmtime + WIT bindgen)
|
||||||
|
→ CRM (5实体) → Inventory (6实体) → Freelance → ITOps
|
||||||
|
→ 证明:CRUD 密集型领域可行,沙盒隔离有效
|
||||||
|
→ 跨插件数据引用未解决
|
||||||
|
|
||||||
|
4/23-4/26 HMS 分叉 — 健康模块原生开发
|
||||||
|
→ 18+ 强类型实体 (患者/家属/医生/预约/排班/随访/咨询/体征/化验/透析/诊断/积分...)
|
||||||
|
→ PII 加密 (AES-256-GCM)、脱敏管道
|
||||||
|
→ AI 模块 (4 SSE 流式端点 + 3 REST 端点)
|
||||||
|
→ 微信小程序 (27 页面)
|
||||||
|
→ 按钮级权限控制
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 从插件到原生的决策链
|
||||||
|
|
||||||
|
**原始插件愿景**(设计规格 2026-04-13):
|
||||||
|
|
||||||
|
- 平台模块原生,行业模块 WASM 插件
|
||||||
|
- 插件通过 9 个 Host API 函数通信(db_insert/query/update/delete、event_publish、config_get 等)
|
||||||
|
- 数据存 JSONB 动态表,路由自动生成
|
||||||
|
- UI 配置驱动,通用 PluginCRUDPage 组件
|
||||||
|
|
||||||
|
**健康模块原生的 5 个硬限制**(设计规格 2026-04-23 §1.3):
|
||||||
|
|
||||||
|
| 限制 | 影响 | 不可妥协原因 |
|
||||||
|
|------|------|-------------|
|
||||||
|
| 20 实体上限 | 健康平台轻松超过 | 18+ 实体已是最低合理粒度 |
|
||||||
|
| JSONB 存储 | 无强类型、无外键约束 | 医疗数据需要引用完整性和精确索引 |
|
||||||
|
| 无自定义 API | 只有自动 CRUD | 趋势分析/统计报表/日历视图无法实现 |
|
||||||
|
| 无文件上传 | 沙盒阻止文件系统访问 | 化验单/体检报告需要文件存储 |
|
||||||
|
| WASM 沙盒限制 | 无 native crypto/外部 API/后台任务 | PII 加密、微信集成、定时任务全部需要 |
|
||||||
|
|
||||||
|
### 3.3 得失评估
|
||||||
|
|
||||||
|
**得 — 正确的决策:**
|
||||||
|
|
||||||
|
| 决策 | 收益 |
|
||||||
|
|------|------|
|
||||||
|
| 星形依赖拓扑 | 模块独立性强,可独立测试和替换 |
|
||||||
|
| ErpModule 统一接口 | 新模块注册流程标准化 |
|
||||||
|
| 事件总线 | 跨模块解耦通信的基础设施已就绪 |
|
||||||
|
| JWT→TenantContext | 多租户全链路贯通 |
|
||||||
|
| 健康模块原生 | 不受沙盒限制,加密/文件/后台任务全部可用 |
|
||||||
|
| 插件实验 | 验证了平台灵活性,CRM/库存可正常使用 |
|
||||||
|
|
||||||
|
**失 — 需要修正的问题:**
|
||||||
|
|
||||||
|
| 决策 | 代价 |
|
||||||
|
|------|------|
|
||||||
|
| 插件系统投入过大 | 22,000 行代码(41% Rust 总量),对 HMS 核心业务贡献接近零 |
|
||||||
|
| 积分系统混入 health | 8 实体/12+ 路由,增加合规复杂度和数据泄露面 |
|
||||||
|
| 事件消费侧忽视 | 13 个事件只有 3 个被消费,危急告警和逾期通知空转 |
|
||||||
|
| 测试覆盖极薄 | 36 后端 + 3 前端测试,覆盖率 < 5% |
|
||||||
|
| 合规意识不足 | 知情同意缺失、审计不完整、PIE 加密范围不足 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 三专家评审摘要
|
||||||
|
|
||||||
|
### 4.1 高级系统架构师
|
||||||
|
|
||||||
|
**诊断准确度:7/10** — 四个张力都真实存在,但优先级和细节有偏差。
|
||||||
|
|
||||||
|
关键补充:
|
||||||
|
|
||||||
|
| 发现 | 严重程度 |
|
||||||
|
|------|---------|
|
||||||
|
| WIT 接口是同步调用(阻塞),WASM 运行时嵌入主进程(故障隔离不足) | 架构隐患 |
|
||||||
|
| EventBus 内存 broadcast 无重放机制,服务重启丢事件 | P1 |
|
||||||
|
| `follow_up.overdue` 无幂等保护,每 6h 检查重复发布 | P0 |
|
||||||
|
| erp-message 用 `subscribe()` 全量订阅,性能隐患 | P1 |
|
||||||
|
| RLS 不是 P0,多租户集成测试才是 | 观点 |
|
||||||
|
| 积分系统(8 独立实体、12+ 路由)不应在 erp-health 内 | 共识 |
|
||||||
|
| 缺监控/可观测性、数据备份策略、API 版本升级路线图 | 盲点 |
|
||||||
|
|
||||||
|
核心原则:**先补测试再重构,先修事件再上功能,先验证再加固。**
|
||||||
|
|
||||||
|
### 4.2 医疗信息化专家
|
||||||
|
|
||||||
|
**发现了比原始诊断更深层的临床安全风险。**
|
||||||
|
|
||||||
|
| 新发现 | 严重程度 |
|
||||||
|
|--------|---------|
|
||||||
|
| 危急值阈值全部硬编码(收缩压 180/80、心率 150/40),不可配置 | P0 |
|
||||||
|
| `daily_monitoring` 表体征数据不经过危急值检测(合并前遗留) | P0 |
|
||||||
|
| 过敏史更新直接覆盖,无变更历史 | P0 |
|
||||||
|
| 知情同意完全缺失(搜索 consent/同意/授权/隐私 零结果) | P0 — PIPL 违规 |
|
||||||
|
| 只有身份证号存储加密,姓名/过敏史/诊断/咨询内容明文 | P1 |
|
||||||
|
| 审计日志不完整 — 只有预约状态变更记录前后值 | P1 |
|
||||||
|
| `ip_address` 和 `user_agent` 从未被填充 | P1 |
|
||||||
|
| 读操作(查看患者详情/化验报告)完全没有审计记录 | P1 |
|
||||||
|
| 诊断记录 `icd_code` 只做字符串约束,无格式校验,无同行审核 | P1 |
|
||||||
|
|
||||||
|
合规评估:PIPL 第 29 条要求处理敏感个人信息须取得单独同意。医疗数据属于敏感个人信息。知情同意缺失是法律红线。
|
||||||
|
|
||||||
|
领域模型建议:积分系统(6 实体 + 2 线下活动实体)应拆分为独立 `erp-points` 或 `erp-engagement` 模块,与健康数据分离以降低合规复杂度。
|
||||||
|
|
||||||
|
### 4.3 产品策略专家
|
||||||
|
|
||||||
|
**开发节奏不可持续但不必恐慌。**
|
||||||
|
|
||||||
|
| 分析 | 结论 |
|
||||||
|
|------|------|
|
||||||
|
| 峰值 68 提交/天,fix 提交占 21.6% | 短期冲刺可以,长期人会耗竭 |
|
||||||
|
| 41% Rust 代码在插件系统,对核心业务贡献接近零 | 最大 ROI 失衡 |
|
||||||
|
| 单人 + AI 的"速度幻觉" | 68 提交/天 = 审查不足,积分混入 health 就是例证 |
|
||||||
|
| 测试覆盖 < 5% | 正确水位不是 80%,而是关键路径不回退(目标 50-80 用例,3-4 天) |
|
||||||
|
|
||||||
|
关键风险缓解建议:
|
||||||
|
- ADR(架构决策记录)强制化
|
||||||
|
- 医疗安全代码双人外部 review
|
||||||
|
- 每日提交上限 15 次
|
||||||
|
- 每月需求裁剪
|
||||||
|
|
||||||
|
V2 血透路线图评估:技术储备已够(`dialysis_service` 286 行骨架在),但缺市场验证。建议先做 3-5 家目标客户调研,确认需求后再做 2 周 MVP 试运行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 共识优先级
|
||||||
|
|
||||||
|
### 5.1 三专家加权共识矩阵
|
||||||
|
|
||||||
|
| 议题 | 架构师 | 医疗专家 | 产品策略 | 共识等级 |
|
||||||
|
|------|--------|---------|---------|---------|
|
||||||
|
| 危急值告警闭环 | P0 | P0 + 硬编码 | P0 | 三方一致 |
|
||||||
|
| 知情同意 (PIPL) | 未涉及 | P0 | P0 | 两方一致 |
|
||||||
|
| 审计日志补全 | 未涉及 | P1 | P0 | P0-P1 |
|
||||||
|
| EventBus 可靠性 | P1 | 未涉及 | P0 | P0-P1 |
|
||||||
|
| 随访逾期通知 | P0 | P0 | P0 | 三方一致 |
|
||||||
|
| 积分系统拆分 | 应拆 | 应拆(合规) | 占 19.5% | 三方一致 |
|
||||||
|
| RLS | 不是 P0 | P1 | P0 | 有分歧 |
|
||||||
|
| 插件系统 | 有条件冻结 | 未涉及 | 冻结 | 两方一致 |
|
||||||
|
| 测试覆盖 | 先补测试 | 上线前必修 | 50-80 用例 | 三方一致 |
|
||||||
|
| V2 血透 | 未涉及 | 缺标准流程 | 先调研 | 两方一致 |
|
||||||
|
|
||||||
|
### 5.2 P0 — 上线前必修(估计 2-3 周)
|
||||||
|
|
||||||
|
| 序号 | 项 | 工作量 | 负责 crate | 说明 |
|
||||||
|
|------|---|--------|-----------|------|
|
||||||
|
| 1 | 危急值告警消费者 | 1 天 | erp-health + erp-message | `health_data.critical_alert` → 推送通知给责任医护 |
|
||||||
|
| 2 | 危急值阈值可配置化 | 2 天 | erp-health | 硬编码阈值改为数据库配置,支持科室/年龄差异化 |
|
||||||
|
| 3 | daily_monitoring 合并后告警验证 | 1 天 | erp-health | 确认合并到 vital_signs 后所有体征数据都经过告警检测 |
|
||||||
|
| 4 | 随访逾期通知 | 1 天 | erp-health + erp-message | `follow_up.overdue` → 催办通知 + 幂等保护 |
|
||||||
|
| 5 | 知情同意记录 | 3 天 | erp-health | 患者数据处理同意获取和记录机制 |
|
||||||
|
| 6 | 审计日志补全 | 3 天 | erp-core + erp-health | 临床数据变更记录前后值、读操作审计、IP/UA 填充 |
|
||||||
|
| 7 | EventBus 持久化增强 | 2 天 | erp-core | 服务重启不丢事件 + overdue 事件幂等 |
|
||||||
|
|
||||||
|
### 5.3 P1 — 治理(2-4 周)
|
||||||
|
|
||||||
|
| 序号 | 项 | 工作量 | 说明 |
|
||||||
|
|------|---|--------|------|
|
||||||
|
| 8 | 积分系统剥离 | 5 天 | 从 erp-health 拆分为独立 erp-engagement crate |
|
||||||
|
| 9 | 关键路径测试 | 4 天 | 多租户隔离、患者安全路径、预约并发(50-80 用例) |
|
||||||
|
| 10 | 插件系统冻结声明 | 0.5 天 | 保留代码,README 声明实验性,不再投入 |
|
||||||
|
| 11 | erp-message 改用 `subscribe_filtered` | 1 天 | 减少无效事件传递 |
|
||||||
|
| 12 | 统一事件消费模式 | 2 天 | 消除 `register_event_handlers` vs `on_startup` 双路径 |
|
||||||
|
| 13 | 过敏史变更历史 | 1 天 | 更新时记录旧值 |
|
||||||
|
|
||||||
|
### 5.4 P2 — 扩展(后续迭代)
|
||||||
|
|
||||||
|
| 序号 | 项 | 前置条件 |
|
||||||
|
|------|---|---------|
|
||||||
|
| 14 | PostgreSQL RLS | P1 测试覆盖完成 |
|
||||||
|
| 15 | 血透专科 | 3-5 家客户调研完成 |
|
||||||
|
| 16 | OCR 化验单提取 | 血透验证后 |
|
||||||
|
| 17 | IM 咨询 | 血透验证后 |
|
||||||
|
| 18 | health 模块按子域重组目录 | P1 测试覆盖完成 |
|
||||||
|
| 19 | 前端测试覆盖提升 | P1 后端测试完成 |
|
||||||
|
| 20 | 动态菜单系统 | 现有计划可用 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 风险与缓解
|
||||||
|
|
||||||
|
### 6.1 开发模式风险
|
||||||
|
|
||||||
|
| 风险 | 影响 | 缓解措施 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 单人认知单点 | 一人理解 16 个 crate,bus factor = 1 | ADR 强制化,关键决策留文档 |
|
||||||
|
| AI 生成"编译对但逻辑错" | 危急值阈值硬编码、积分混入 health 就是例证 | 医疗安全代码双人外部 review |
|
||||||
|
| 速度幻觉 | 68 提交/天 = 审查不足 | 每日提交上限 15 次 |
|
||||||
|
| AI 回音壁 | AI 不质疑需求合理性 | 每月需求裁剪,引入真实用户反馈 |
|
||||||
|
|
||||||
|
### 6.2 临床安全风险
|
||||||
|
|
||||||
|
| 风险 | 影响 | 缓解措施 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 危急值告警未闭环 | 危急体征值无人响应,可致患者安全事故 | P0-1:实现消费者 + 阈值可配置 |
|
||||||
|
| 逾期随访无催办 | 患者失访,影响医疗质量指标 | P0-4:实现通知 + 幂等保护 |
|
||||||
|
| 过敏史无变更记录 | 无法追溯过敏史变更,用药风险 | P1-13:添加变更历史 |
|
||||||
|
| 告警阈值硬编码 | 无法适应儿科/老年科/血透科不同范围 | P0-2:数据库配置 |
|
||||||
|
|
||||||
|
### 6.3 合规风险
|
||||||
|
|
||||||
|
| 风险 | 法规依据 | 缓解措施 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 知情同意缺失 | PIPL 第 29 条 | P0-5:实现同意记录机制 |
|
||||||
|
| 审计不完整 | 医疗机构信息化建设要求 | P0-6:补全审计日志 |
|
||||||
|
| PIE 加密范围不足 | PIPL 第 51 条 | P1:扩展加密到姓名/过敏史/诊断 |
|
||||||
|
| 数据删除权缺失 | PIPL 第 47 条 | P2:实现患者数据导出/删除 |
|
||||||
|
|
||||||
|
### 6.4 架构风险
|
||||||
|
|
||||||
|
| 风险 | 影响 | 缓解措施 |
|
||||||
|
|------|------|---------|
|
||||||
|
| EventBus 无重放 | 服务重启丢事件 | P0-7:增强持久化 |
|
||||||
|
| 全量订阅 | 性能隐患,所有事件经消息模块 | P1-11:改用过滤订阅 |
|
||||||
|
| 路由手动合并 | 新模块 boilerplate 成本 | 长期:ErpModule trait v2 |
|
||||||
|
| erp-health 过大 | 18+ 实体,维护复杂度上升 | P2-18:按子域重组 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 附录
|
||||||
|
|
||||||
|
### 7.1 关键文件索引
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `crates/erp-core/src/module.rs` | ErpModule trait + ModuleRegistry (拓扑排序) |
|
||||||
|
| `crates/erp-core/src/events.rs` | EventBus 实现 (broadcast + outbox) |
|
||||||
|
| `crates/erp-core/src/types.rs` | TenantContext, BaseFields, Pagination |
|
||||||
|
| `crates/erp-core/src/rbac.rs` | 权限/角色检查 |
|
||||||
|
| `crates/erp-server/src/main.rs` | 服务组装和手动路由合并 |
|
||||||
|
| `crates/erp-server/src/state.rs` | AppState + FromRef 桥接 |
|
||||||
|
| `crates/erp-server/src/outbox.rs` | Outbox relay (5s 轮询, 3 次重试) |
|
||||||
|
| `crates/erp-auth/src/middleware/jwt_auth.rs` | JWT 认证 + TenantContext 注入 |
|
||||||
|
| `crates/erp-health/src/module.rs` | HealthModule (ErpModule 实现 + 后台任务) |
|
||||||
|
| `crates/erp-health/src/event.rs` | 健康模块事件订阅 |
|
||||||
|
| `crates/erp-health/src/crypto.rs` | AES-256-GCM 加密 |
|
||||||
|
| `crates/erp-health/src/service/masking.rs` | PII 脱敏管道 |
|
||||||
|
| `crates/erp-plugin/src/engine.rs` | WASM 插件引擎 |
|
||||||
|
| `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | 插件系统设计规格 |
|
||||||
|
| `docs/superpowers/specs/2026-04-23-health-management-module-design.md` | 健康模块设计规格 |
|
||||||
|
| `docs/discussions/2026-04-18-plugin-platform-brainstorm.md` | 插件平台演进讨论 |
|
||||||
|
|
||||||
|
### 7.2 迁移历史时间线
|
||||||
|
|
||||||
|
| 日期 | 迁移范围 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 4/10-11 | 核心平台 | 租户、用户、凭证、角色、权限、组织、部门、岗位 |
|
||||||
|
| 4/12 | 配置 + 工作流 | 字典、菜单、设置、编号规则 + 流程定义/实例/令牌/任务 |
|
||||||
|
| 4/13 | 消息 + 审计 | 模板、消息、订阅 + 审计日志 |
|
||||||
|
| 4/14 | 修复 | 唯一索引与软删除冲突、标准字段补全 |
|
||||||
|
| 4/16 | 领域事件 | domain_events 表 |
|
||||||
|
| 4/17 | 插件系统 | 插件表、动态表 |
|
||||||
|
| 4/18 | 搜索 + 权限 | pg_trgm、实体注册表、数据范围 |
|
||||||
|
| 4/19 | 关联修复 | 用户部门、CRM 修复、插件市场 |
|
||||||
|
| 4/23 | 健康表 | 患者、微信用户、文章 |
|
||||||
|
| 4/24 | 索引修复 | 3 个 fixup 迁移 |
|
||||||
|
| 4/25 | 健康扩展 | 患者ID哈希、医生名、透析/化验增强、AI 表、积分 |
|
||||||
|
| 4/26 | 业务改进 | 诊断、列重命名、daily_monitoring 合并、菜单种子 |
|
||||||
|
|
||||||
|
**总计:59 个迁移,17 天内。** fixup 迁移模式健康(不编辑旧迁移,单独修复)。
|
||||||
|
|
||||||
|
### 7.3 项目统计快照 (2026-04-26)
|
||||||
|
|
||||||
|
| 指标 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| Rust crate 数 | 16 |
|
||||||
|
| Rust 代码行 | ~57,000 |
|
||||||
|
| 前端文件数 | 174 (TSX/TS) |
|
||||||
|
| 前端页面 | 62 |
|
||||||
|
| 小程序页面 | 27 |
|
||||||
|
| 数据库迁移 | 59 |
|
||||||
|
| 数据库表 | 30 基础 + 18 健康 + 3 AI |
|
||||||
|
| 后端测试 | 36 |
|
||||||
|
| 前端单元测试 | 3 |
|
||||||
|
| Git 提交 | 237 |
|
||||||
|
| 开发周期 | 17 天 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本文档由三专家多视角评审生成,作为 HMS 平台基座演进的参考基准。后续实施计划将基于本文档的优先级排序展开。*
|
||||||
360
docs/wiki-methodology.md
Normal file
360
docs/wiki-methodology.md
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
# 项目 Wiki 知识库编制方法论
|
||||||
|
|
||||||
|
> 基于 ZCLAW 项目实战经验(10 crates + React 前端,~155KB wiki 重构)提炼。
|
||||||
|
> 适用于任何有 AI 辅助开发参与的中大型项目。
|
||||||
|
> **一句话总结**:Wiki 只记录代码无法告诉你的东西。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、设计原则
|
||||||
|
|
||||||
|
### 原则 1:Wiki 记录"代码不能告诉你的"
|
||||||
|
|
||||||
|
| 记录在 Wiki ✅ | 不记录在 Wiki ❌ |
|
||||||
|
|---------------|-----------------|
|
||||||
|
| 为什么这样设计(WHY) | 字段列表、函数签名 |
|
||||||
|
| 跨模块数据流走向 | 单文件内的代码逻辑 |
|
||||||
|
| 历史踩坑和教训 | 可用 `grep` 直接查到的信息 |
|
||||||
|
| 必须始终成立的约束(不变量) | CRUD 操作、getter/setter |
|
||||||
|
| 模块间调用接口(集成契约) | 具体的行号、变量名 |
|
||||||
|
|
||||||
|
**判断标准**:如果 `git log` 或 `grep` 能在 30 秒内回答这个问题,就不需要写在 wiki 里。
|
||||||
|
|
||||||
|
### 原则 2:每个模块页统一 5 节结构
|
||||||
|
|
||||||
|
按**阅读优先级**排列(先给最重要的信息):
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 设计决策 (WHY) — 为什么这样设计、历史背景、权衡取舍
|
||||||
|
2. 关键文件 + 数据流 — 3-7 个核心文件 + 跨模块接口
|
||||||
|
3. 代码逻辑 — 数据流走向 + 不变量 + 非显而易见的算法
|
||||||
|
4. 活跃问题 + 陷阱 — 当前未解决 + 历史教训
|
||||||
|
5. 变更记录 — 最近 5 条,超出的归入全局日志
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么是这个顺序**:新的 AI 会话(或开发者)首先需要知道"这个模块为什么存在"和"文件在哪",然后才是"怎么工作的",最后是"有什么问题"。
|
||||||
|
|
||||||
|
### 原则 3:页面大小必须有预算
|
||||||
|
|
||||||
|
| 页面类型 | 行数预算 | 原因 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| 首页/索引 | ≤ 120 行 | 需要快速扫描,AI 一次加载 |
|
||||||
|
| 模块页 | 100-200 行 | AI 一次加载 2-3 个模块不爆 context |
|
||||||
|
| 全局日志 | ≤ 50 条活跃 | 防止无限膨胀,旧条目归档 |
|
||||||
|
|
||||||
|
**超过预算怎么办**:把详细内容归档到 `archive/` 目录,模块页只保留摘要 + 链接。
|
||||||
|
|
||||||
|
### 原则 4:单一真相源
|
||||||
|
|
||||||
|
同一信息只出现在一个页面。其他需要该信息的地方只放引用。
|
||||||
|
|
||||||
|
```
|
||||||
|
错误:安全认证流程同时写在 saas.md、security.md、middleware.md
|
||||||
|
正确:security.md 拥有完整描述,saas.md 只写"详见 [[security]]"
|
||||||
|
```
|
||||||
|
|
||||||
|
**检查方法**:`grep` 关键内容,如果出现在 ≥ 3 个页面,就需要去重。
|
||||||
|
|
||||||
|
### 原则 5:Append-only 内容必须封顶
|
||||||
|
|
||||||
|
日志、问题列表等只增不减的内容,必须设置上限并定期归档。
|
||||||
|
|
||||||
|
```
|
||||||
|
活跃日志 ≤ 50 条 → 旧条目归入 archive/log-{YYYY-MM}.md
|
||||||
|
活跃问题 ≤ 5 条/模块 → 修复后立即移除
|
||||||
|
变更记录 ≤ 5 条/模块 → 旧记录在全局 log.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 原则 6:用症状导航补充模块导航
|
||||||
|
|
||||||
|
模块导航解决"这个模块是什么"的问题。但实际开发中,人们更多是在解决"出了问题该看哪里"。
|
||||||
|
|
||||||
|
**症状导航表**格式:
|
||||||
|
|
||||||
|
| 症状 | 先查 | 再查 | 常见根因 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| 流式响应卡住 | routing | chat → middleware | 连接断开 / 超时 |
|
||||||
|
| 数据没持久化 | data-model | 对应模块 | 表结构 / 迁移缺失 |
|
||||||
|
|
||||||
|
放在首页/索引页,让新来的人(或 AI 会话)0 跳就能定位排查方向。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、结构模板
|
||||||
|
|
||||||
|
### 2.1 三级层级
|
||||||
|
|
||||||
|
```
|
||||||
|
项目 Wiki
|
||||||
|
├── Level 1: index.md — 纯导航 + 症状索引(≤ 120 行)
|
||||||
|
├── Level 2: {module}.md — 每个功能模块一个页面(100-200 行)
|
||||||
|
├── Level 3: archive/ — 历史内容归档
|
||||||
|
└── (可选) known-issues.md — 活跃问题全局索引
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 首页模板 (index.md)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# {项目名} 知识库
|
||||||
|
|
||||||
|
> 一句话定位。使用方式说明。
|
||||||
|
|
||||||
|
## 关键数字
|
||||||
|
| 指标 | 值 | 验证方式 |
|
||||||
|
|------|-----|---------|
|
||||||
|
|
||||||
|
## 系统数据流
|
||||||
|
{ASCII 全景图}
|
||||||
|
|
||||||
|
## 模块导航
|
||||||
|
- [[module-a]] — 一句话说明
|
||||||
|
- [[module-b]] — 一句话说明
|
||||||
|
|
||||||
|
## 症状导航
|
||||||
|
| 症状 | 先查 | 再查 | 常见根因 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 模块页模板 ({module}.md)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: {模块名}
|
||||||
|
updated: {YYYY-MM-DD}
|
||||||
|
status: active | stable | developing
|
||||||
|
tags: [{tags}]
|
||||||
|
---
|
||||||
|
|
||||||
|
# {模块名}
|
||||||
|
|
||||||
|
> 从 [[index]] 导航。关联: [[related-1]] [[related-2]]
|
||||||
|
|
||||||
|
## 1. 设计决策
|
||||||
|
|
||||||
|
{为什么这样设计、历史背景、权衡取舍}
|
||||||
|
{用 Q&A 格式记录关键架构决策}
|
||||||
|
|
||||||
|
## 2. 关键文件 + 数据流
|
||||||
|
|
||||||
|
### 核心文件
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `path/to/file` | 一句话说明 |
|
||||||
|
|
||||||
|
### 数据流
|
||||||
|
{ASCII 流程图}
|
||||||
|
|
||||||
|
### 集成契约
|
||||||
|
| 方向 | 模块 | 接口 | 触发时机 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| 调用 → | {module} | `{function/API}` | {when} |
|
||||||
|
| 被调用 ← | {module} | `{function/API}` | {when} |
|
||||||
|
|
||||||
|
## 3. 代码逻辑
|
||||||
|
|
||||||
|
### 关键数据流
|
||||||
|
{跨函数/跨文件的完整路径,附意图说明}
|
||||||
|
|
||||||
|
### 不变量
|
||||||
|
⚡ {不变量 1}: {必须始终成立的约束}
|
||||||
|
⚡ {不变量 2}: {描述}
|
||||||
|
|
||||||
|
### 非显而易见的算法
|
||||||
|
{读代码难以理解的逻辑}
|
||||||
|
|
||||||
|
## 4. 活跃问题 + 陷阱
|
||||||
|
|
||||||
|
### 活跃问题
|
||||||
|
| 问题 | 级别 | 状态 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
{0-5 条,修复后移除}
|
||||||
|
|
||||||
|
### 历史教训
|
||||||
|
- {教训}: {一句话描述}
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
⚠️ {易出错的地方}
|
||||||
|
|
||||||
|
## 5. 变更记录
|
||||||
|
| 日期 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
{最近 5 条}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、关键机制详解
|
||||||
|
|
||||||
|
### 3.1 集成契约
|
||||||
|
|
||||||
|
**问题**:跨模块边界的信息(谁调谁、接口形状)是最难从代码中获取的知识,也是 wiki 最大的结构性缺口。
|
||||||
|
|
||||||
|
**做法**:每个模块页的"关键文件"节下增加一个"集成契约"小表,回答四个问题:
|
||||||
|
|
||||||
|
| 问题 | 对应列 |
|
||||||
|
|------|--------|
|
||||||
|
| 这个模块调用了谁? | 调用 → |
|
||||||
|
| 这个模块被谁调用? | 被调用 ← |
|
||||||
|
| 通过什么接口? | 接口(函数名/API路径) |
|
||||||
|
| 什么时候触发? | 触发时机 |
|
||||||
|
|
||||||
|
**示例**(中间件模块):
|
||||||
|
|
||||||
|
| 方向 | 模块 | 接口 | 触发时机 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| 被调用 ← | kernel | `create_middleware_chain()` | 内核启动 |
|
||||||
|
| 调用 → | runtime | `run_before_completion()` | 每次聊天请求 |
|
||||||
|
| 提供 → | 所有模块 | `AgentMiddleware` trait | 14 个实现 |
|
||||||
|
|
||||||
|
### 3.2 不变量标记
|
||||||
|
|
||||||
|
**问题**:系统中有一些"必须始终成立的约束",它们不像代码那样显式存在,但一旦被违反就会产生隐蔽的 bug。
|
||||||
|
|
||||||
|
**做法**:用 ⚡ 标记不变量,放在"代码逻辑"节下。
|
||||||
|
|
||||||
|
```
|
||||||
|
⚡ Priority 是升序排列:0-999,数值越小越先执行
|
||||||
|
⚡ memories.db 和 data.db 是独立数据库,跨库查询需确认目标库
|
||||||
|
⚡ 记忆注入在中间件@150,在管家路由@80之后,技能索引@200之前
|
||||||
|
```
|
||||||
|
|
||||||
|
**判断什么是好的不变量**:
|
||||||
|
- 它描述的是一种**关系或顺序**,不是单个组件的行为
|
||||||
|
- 如果有人不知道这个约束,修改代码时很可能无意中违反它
|
||||||
|
- 违反的后果不会立即显现,而是演化几轮后变成隐性 bug
|
||||||
|
|
||||||
|
### 3.3 去重规则
|
||||||
|
|
||||||
|
| 重复类型 | 处理方式 |
|
||||||
|
|---------|---------|
|
||||||
|
| 完整描述出现在 A 和 B | 选择一个为真相源,另一个只引用 |
|
||||||
|
| 相同信息出现在 ≥ 3 页 | 必须去重,指定唯一归属 |
|
||||||
|
| 概述 vs 详情 | 概述页保留一句话 + 链接,详情页拥有完整描述 |
|
||||||
|
|
||||||
|
**去重检查命令**:
|
||||||
|
```bash
|
||||||
|
grep -l '关键内容' wiki/*.md | wc -l
|
||||||
|
# 结果 ≥ 3 → 需要去重
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 症状导航
|
||||||
|
|
||||||
|
**为什么需要**:模块导航是"模块→功能"方向,但排查问题时需要的是"症状→模块"方向。
|
||||||
|
|
||||||
|
**编制方法**:
|
||||||
|
1. 收集团队/AI 会话中反复出现的调试场景(8-12 个)
|
||||||
|
2. 每个场景记录:症状、先查哪个页面、再查哪个、最常见根因
|
||||||
|
3. 放在首页,新会话/新人 0 跳可达
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
|
||||||
|
| 症状 | 先查 | 再查 | 常见根因 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| API 返回 502 | saas | routing | Token 耗尽 / 服务超时 |
|
||||||
|
| 数据不持久 | data-model | 对应模块 | 表缺失 / 字段不匹配 |
|
||||||
|
| 流式中断 | chat | middleware | 连接断开 / 超时守护 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、维护工作流
|
||||||
|
|
||||||
|
### 4.1 什么时候更新 Wiki
|
||||||
|
|
||||||
|
| 触发事件 | 更新什么 |
|
||||||
|
|---------|---------|
|
||||||
|
| 修复 bug | 对应模块页"活跃问题" + 全局 known-issues 索引 |
|
||||||
|
| 架构变更 | 对应模块页"设计决策" + 集成契约 |
|
||||||
|
| 文件结构变化 | 对应模块页"核心文件"表 |
|
||||||
|
| 跨模块接口变化 | 涉及双方的"集成契约"表 |
|
||||||
|
| 发现新不变量 | 对应模块页"代码逻辑"节的 ⚡ 项 |
|
||||||
|
| 每次更新 | 模块页"变更记录"(保持5条) + 全局 log.md |
|
||||||
|
|
||||||
|
### 4.2 防止 drift 的策略
|
||||||
|
|
||||||
|
| 策略 | 做法 |
|
||||||
|
|------|------|
|
||||||
|
| 页面大小预算 | 超过 200 行强制裁剪,移入 archive/ |
|
||||||
|
| 活跃问题生命周期 | 修复后立即移除,不保留已修复项 |
|
||||||
|
| 变更记录滑动窗口 | 只保留最近 5 条,旧的自然滚入全局日志 |
|
||||||
|
| 数字验证 | 关键数字标注验证命令,定期执行确认 |
|
||||||
|
| "最后验证"日期 | 在 frontmatter 的 `updated` 字段记录,超过 30 天需要复查 |
|
||||||
|
|
||||||
|
### 4.3 重构 Wiki 的执行顺序
|
||||||
|
|
||||||
|
如果需要对已有 wiki 进行重构,按依赖关系分阶段:
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1: 归档/封顶 — 压缩日志、归档旧内容(无依赖)
|
||||||
|
Phase 2: 确立真相源 — 最被其他页面引用的模块优先重构
|
||||||
|
Phase 3: 依赖页面 — 引用 Phase 2 模块的页面去重
|
||||||
|
Phase 4: 剩余模块 — 独立页面逐一重构
|
||||||
|
Phase 5: 首页/索引 — 最后改(依赖所有模块页完成)
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键约束**:每个模块页独立提交,可安全 `git revert` 回滚单个页面。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、AI 辅助开发的特殊考量
|
||||||
|
|
||||||
|
### 5.1 Wiki 的主要读者可能是 AI
|
||||||
|
|
||||||
|
在 AI 辅助开发中,wiki 的主要读者是每次新会话的 AI 实例(context 从零开始)。这改变了 wiki 的设计优先级:
|
||||||
|
|
||||||
|
| 传统 wiki | AI 辅助 wiki |
|
||||||
|
|-----------|-------------|
|
||||||
|
| 详细、全面 | 精炼、可快速加载 |
|
||||||
|
| 按主题组织 | 按任务场景导航 |
|
||||||
|
| 历史记录丰富 | 只保留活跃信息 |
|
||||||
|
| 人工索引 | 症状→页面直接映射 |
|
||||||
|
|
||||||
|
### 5.2 Context 预算思维
|
||||||
|
|
||||||
|
AI 的 context window 是有限资源。wiki 的每个字节都在消耗这个预算。
|
||||||
|
|
||||||
|
**优化策略**:
|
||||||
|
- 首页只放导航,不放内容(让 AI 按需读取模块页)
|
||||||
|
- 模块页控制在 100-200 行(一次加载 2-3 个不爆 context)
|
||||||
|
- 代码逻辑只写流向和不变量,不写可从代码读取的细节
|
||||||
|
- 使用 `archive/` 存放低频需要的历史内容
|
||||||
|
|
||||||
|
### 5.3 Wiki 作为新会话的启动燃料
|
||||||
|
|
||||||
|
设计 wiki 时要问:**一个全新的 AI 会话,读完首页后能定位问题吗?读完 2 个模块页后能开始工作吗?**
|
||||||
|
|
||||||
|
如果答案是"不能",说明 wiki 的导航层不够好(首页缺症状导航)或模块页的结构不对(信息不在前两节)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、检查清单
|
||||||
|
|
||||||
|
### 创建 Wiki 时
|
||||||
|
|
||||||
|
- [ ] 首页 ≤ 120 行,包含:项目一句话定位、关键数字、模块导航、症状导航
|
||||||
|
- [ ] 每个模块页统一 5 节结构
|
||||||
|
- [ ] 每个模块页有集成契约表
|
||||||
|
- [ ] 每个模块页有 ⚡ 不变量
|
||||||
|
- [ ] 每个模块页 100-200 行
|
||||||
|
- [ ] 无内容重复出现在 ≥ 3 个页面
|
||||||
|
- [ ] 全局日志封顶 50 条,有归档机制
|
||||||
|
|
||||||
|
### 维护 Wiki 时
|
||||||
|
|
||||||
|
- [ ] 修复 bug 后更新对应模块"活跃问题"
|
||||||
|
- [ ] 架构变更后更新对应模块"设计决策"+ 集成契约
|
||||||
|
- [ ] 每次更新追加全局 log.md 条目
|
||||||
|
- [ ] 每次更新模块页变更记录(保持 5 条)
|
||||||
|
- [ ] 定期检查页面是否超过大小预算
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:ZCLAW 重构效果
|
||||||
|
|
||||||
|
| 指标 | 重构前 | 重构后 | 变化 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| 模块页总行数 | ~2,800 | ~1,547 | -45% |
|
||||||
|
| 重复内容 | 安全×3, 进化×3 | 各×1 | 消除 |
|
||||||
|
| 集成契约覆盖 | 0/10 页 | 10/10 页 | 全覆盖 |
|
||||||
|
| 症状导航 | 无 | 8 条路径 | 新增 |
|
||||||
|
| 首页 | 144 行 | 101 行 | +症状导航 |
|
||||||
|
| 最大单页 | 424 行 | 199 行 | 控住 |
|
||||||
353
docs/健康管理/客户需求整理.md
Normal file
353
docs/健康管理/客户需求整理.md
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
# 健康管理系统 — 客户初步设想整理
|
||||||
|
|
||||||
|
> 资料来源:客户提供的 Excel 功能文档(`管理系统功能文档(1).xlsx`)及 3 张微信图片(系统功能思维导图)。
|
||||||
|
> 整理日期:2026-04-26
|
||||||
|
> 备注:以下为客户原始需求,尚未经过技术评审和优先级排序。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、系统概述
|
||||||
|
|
||||||
|
系统面向**血透/肾病专科**体检中心或医疗机构,包含三个端:
|
||||||
|
|
||||||
|
| 端 | 形式 | 用户 |
|
||||||
|
|----|------|------|
|
||||||
|
| 患者端 | 微信小程序 | 透析患者、慢病患者及家属 |
|
||||||
|
| 医护端 | 微信小程序 | 医生、护士 |
|
||||||
|
| 管理后台 | PC Web | 管理员、运营人员 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、患者端(微信小程序)
|
||||||
|
|
||||||
|
### 2.1 首页
|
||||||
|
|
||||||
|
- 医院/中心公告轮播展示,点击可查看详情
|
||||||
|
- 功能入口快捷方式:数据上报、我的医生、在线咨询、血透预约、耗材商城(支持自定义快捷方式)
|
||||||
|
- 健康数据概览卡片(今日血压/体重/最近一次透析记录),可视化图表展示
|
||||||
|
- 每日健康打卡提醒
|
||||||
|
- 待办提醒:提示即将到期的检查或治疗
|
||||||
|
|
||||||
|
### 2.2 数据上报(核心功能)
|
||||||
|
|
||||||
|
**透析记录**
|
||||||
|
- 记录透析治疗的详细信息:透析日期、时间、干体重、透析前/后血压、心率、不适症状
|
||||||
|
- 包括透析类型、频率等
|
||||||
|
- 数据同步至医生端
|
||||||
|
|
||||||
|
**化验报告**
|
||||||
|
- 化验指标手动录入 + 拍照上传化验单
|
||||||
|
- 上传肌酐、尿酸、血钾、血磷等化验单照片
|
||||||
|
- 自动提取指标
|
||||||
|
|
||||||
|
**日常监测**
|
||||||
|
- 血压(早/晚)、体重、血糖、饮水量、尿量
|
||||||
|
- 如血糖、血压等慢性病相关数据
|
||||||
|
- 提供日常监测建议
|
||||||
|
|
||||||
|
**AI 分析**
|
||||||
|
- 自动生成趋势图
|
||||||
|
- 异常指标标红
|
||||||
|
- 生成《健康报告》
|
||||||
|
- 基于数据分析预测潜在健康风险,提供预防措施建议
|
||||||
|
- 定期生成健康周期报告,分析健康趋势,提供改善建议
|
||||||
|
|
||||||
|
### 2.3 预约与随访
|
||||||
|
|
||||||
|
- **透析预约**:支持透析治疗预约,提供预约时间选择,自动推荐预约时间
|
||||||
|
- **复查/抽血预约**:提供预约流程指导
|
||||||
|
- **护士随访任务接收**:护士定期随访服务,提供随访任务提醒,支持随访结果记录
|
||||||
|
- **智能提醒**:多场景消息提醒
|
||||||
|
- 透析提醒
|
||||||
|
- 服药时间提醒(含药物相互作用提示)
|
||||||
|
- 测血压/体重提醒
|
||||||
|
- 复查提醒
|
||||||
|
- 支持自定义提醒时间、提醒事项
|
||||||
|
|
||||||
|
### 2.4 在线咨询
|
||||||
|
|
||||||
|
**客服咨询**
|
||||||
|
- 24 小时在线客服
|
||||||
|
- 解答非医疗问题(订单、物流、使用问题)
|
||||||
|
- 提供预约协助
|
||||||
|
|
||||||
|
**医生咨询**
|
||||||
|
- 选择医生
|
||||||
|
- 图文/语音沟通,支持语音消息
|
||||||
|
- 上传报告
|
||||||
|
- 适用于非紧急医疗咨询
|
||||||
|
- 留言功能:医生离线时留存问题
|
||||||
|
- 历史对话归档:保存历史咨询记录,支持搜索历史信息
|
||||||
|
|
||||||
|
**报告解读**
|
||||||
|
- 提供详细报告分析
|
||||||
|
- 解读专业术语
|
||||||
|
|
||||||
|
**饮食/护理指导**
|
||||||
|
- 提供日常护理指导
|
||||||
|
- 基于健康状况定制个性化饮食方案
|
||||||
|
|
||||||
|
### 2.5 医疗耗材商城(改为积分商城)
|
||||||
|
|
||||||
|
**商品浏览**
|
||||||
|
- 商品分类:血透护理/瘘管保护/肾病食品/慢病器械
|
||||||
|
- 提供详细分类筛选
|
||||||
|
- 支持多商品同时结算,提供结算清单
|
||||||
|
|
||||||
|
**购物与支付**
|
||||||
|
- 购物车/结算
|
||||||
|
- 微信支付(安全便捷)
|
||||||
|
- 支持修改商品数量和选页
|
||||||
|
|
||||||
|
**配送**
|
||||||
|
- 快递送货
|
||||||
|
- 到院自提
|
||||||
|
- 明确配送时间和费用
|
||||||
|
|
||||||
|
**订单管理**
|
||||||
|
- 订单/物流/售后管理
|
||||||
|
- 支持售后服务申请
|
||||||
|
- 电子发票下载
|
||||||
|
|
||||||
|
### 2.6 我的中心
|
||||||
|
|
||||||
|
- **个人信息 & 实名认证**:确保个人信息安全,提高服务质量和安全性
|
||||||
|
- **多就诊人管理**:方便管理家庭成员健康(家属代绑)
|
||||||
|
- **健康档案**:自动收集并整理健康数据,形成个人健康档案,支持档案导出和分享,数据同步至健康档案
|
||||||
|
- **我的医生**:管理个人医生信息
|
||||||
|
- **我的订单/报告**:查看和管理订单记录,查看和下载健康报告
|
||||||
|
- **系统设置**:包括通知设置、隐私设置
|
||||||
|
- **健康打卡**:打卡功能记录个人健康数据,数据同步至健康档案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、医护端(微信小程序)
|
||||||
|
|
||||||
|
### 3.1 数据概览
|
||||||
|
|
||||||
|
- 按时间顺序显示管理的患者咨询
|
||||||
|
- 每日待回复消息统计(回复消息数量)
|
||||||
|
- 异常指标预警
|
||||||
|
- 按疾病类型分类
|
||||||
|
- 按严重程度分级
|
||||||
|
- 患者数据统计/病情分布
|
||||||
|
- 统计患者数量
|
||||||
|
- 绘制健康数据图表
|
||||||
|
- 分析患者健康的趋势
|
||||||
|
|
||||||
|
### 3.2 患者管理
|
||||||
|
|
||||||
|
**患者列表**
|
||||||
|
- 按透析/慢病/高危筛选
|
||||||
|
- 按疾病类型筛选
|
||||||
|
- 按治疗阶段筛选
|
||||||
|
- 多维度筛选:根据用户需求设置筛选项
|
||||||
|
- 自定义筛选条件:保存常用筛选组合
|
||||||
|
|
||||||
|
**患者标签管理**
|
||||||
|
- 根据病情添加标签(高钾/高磷/体重超标/糖肾)
|
||||||
|
- 根据治疗反应添加标签
|
||||||
|
- 标签分类管理:现有标签、编辑、删除不再适用的标签
|
||||||
|
|
||||||
|
**健康档案 & 数据趋势图**
|
||||||
|
- 详细记录病历信息
|
||||||
|
- 查看报告和化验单
|
||||||
|
- 绘制生命体征变化图
|
||||||
|
- 展示治疗效果对比图
|
||||||
|
- 患者在线状态显示
|
||||||
|
- 最近一次咨询时间
|
||||||
|
|
||||||
|
### 3.3 咨询回复
|
||||||
|
|
||||||
|
- **未读消息列表**:新消息提醒
|
||||||
|
- **回复方式**:
|
||||||
|
- 图文/语音回复
|
||||||
|
- 语音输入快速回复
|
||||||
|
- 加文字消息
|
||||||
|
- **预设回复模板**:保存常用回复内容,分类管理回复模板
|
||||||
|
- **科普内容发布**:
|
||||||
|
- 按疾病类型发布
|
||||||
|
- 按季节性健康问题发布
|
||||||
|
- 统计科普阅读量
|
||||||
|
- 收集患者反馈信息
|
||||||
|
|
||||||
|
### 3.4 随访管理
|
||||||
|
|
||||||
|
**随访任务列表**
|
||||||
|
- 指定随访内容和方式
|
||||||
|
- 修改随访计划
|
||||||
|
- 管理随访任务和记录
|
||||||
|
- 删除已完成的随访任务
|
||||||
|
|
||||||
|
**随访记录填写**
|
||||||
|
- 详细信息记录
|
||||||
|
- 医生建议记录
|
||||||
|
- 随访完成情况分析
|
||||||
|
- 分析随访效果
|
||||||
|
|
||||||
|
**随访台账导出**
|
||||||
|
- 选择导出时间范围
|
||||||
|
- 选择导出数据类型
|
||||||
|
- 导出为 Excel 或 PDF 格式
|
||||||
|
- 提供数据统计图表导出
|
||||||
|
|
||||||
|
### 3.5 报告查看与解读
|
||||||
|
|
||||||
|
- 化验单批量查看(按检查日期排序)
|
||||||
|
- 高亮显示异常指标
|
||||||
|
- 快速查看化验结果
|
||||||
|
- 提供历史化验结果对比
|
||||||
|
- 报告标注备注(标注重要发现,添加医生个人备注)
|
||||||
|
- 编辑或删除已有备注
|
||||||
|
- 管理报告备注信息
|
||||||
|
- 搜索特定备注内容
|
||||||
|
- 随访状态标记
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、PC 管理后台
|
||||||
|
|
||||||
|
### 4.1 系统管理
|
||||||
|
|
||||||
|
**账号权限配置**
|
||||||
|
- 管理员/医生/护士/客服角色
|
||||||
|
- 创建角色、定义权限
|
||||||
|
- 审核权限变更请求
|
||||||
|
- 调整角色权限设置
|
||||||
|
- 根据职责分配角色
|
||||||
|
- 更新用户角色信息
|
||||||
|
- 审核角色权限分配
|
||||||
|
- 确认权限分配合理性
|
||||||
|
|
||||||
|
**操作日志**
|
||||||
|
- 记录用户操作
|
||||||
|
- 审查操作日志
|
||||||
|
- 分析异常操作模式
|
||||||
|
|
||||||
|
### 4.2 患者管理
|
||||||
|
|
||||||
|
- **患者列表**:患者基本信息展示
|
||||||
|
- **档案查看/编辑**:
|
||||||
|
- 查看患者信息
|
||||||
|
- 患者状态追踪
|
||||||
|
- 纠正错误信息
|
||||||
|
- 查看患者健康记录
|
||||||
|
- 编辑患者健康信息
|
||||||
|
- 设置档案访问权限
|
||||||
|
- 审核档案访问请求
|
||||||
|
- **数据导出 Excel**:
|
||||||
|
- 选择导出字段
|
||||||
|
- 设置导出格式
|
||||||
|
- 分析导出数据
|
||||||
|
- 生成报告
|
||||||
|
|
||||||
|
### 4.3 健康数据中心
|
||||||
|
|
||||||
|
- **透析数据统计**:
|
||||||
|
- 计算透析次数
|
||||||
|
- 分析透析完成率
|
||||||
|
- 评估透析效果
|
||||||
|
- **异常指标排行**:
|
||||||
|
- 异常健康指标统计
|
||||||
|
- 指标预警排行
|
||||||
|
- 排序异常指标
|
||||||
|
- 展示排行结果
|
||||||
|
- **上报率统计**:
|
||||||
|
- 统计数据上报完成度
|
||||||
|
- 计算上报率
|
||||||
|
|
||||||
|
### 4.4 咨询管理
|
||||||
|
|
||||||
|
- **医生排班**:
|
||||||
|
- 设置医生工作时间
|
||||||
|
- 创建排班计划
|
||||||
|
- 安排医生轮班
|
||||||
|
- 更新排班信息
|
||||||
|
- **对话记录查看/导出**:
|
||||||
|
- 导出对话内容
|
||||||
|
- 保存对话记录
|
||||||
|
|
||||||
|
### 4.5 商城管理
|
||||||
|
|
||||||
|
**商品/分类/资质管理**
|
||||||
|
- 添加新商品、更新商品信息
|
||||||
|
- 创建商品分类
|
||||||
|
- 审核商家资质
|
||||||
|
- 确认资质有效性
|
||||||
|
|
||||||
|
**订单/发货/退款处理**
|
||||||
|
- 确认订单信息
|
||||||
|
- 安排发货信息
|
||||||
|
- 审核退款原因
|
||||||
|
- 处理退款
|
||||||
|
|
||||||
|
**库存管理**
|
||||||
|
- 处理库存不足情况
|
||||||
|
- 调整库存预警
|
||||||
|
- 库存设置
|
||||||
|
|
||||||
|
**营收统计**
|
||||||
|
- 计算销售总额
|
||||||
|
- 分析收入趋势
|
||||||
|
- 计算商品成本
|
||||||
|
- 分析成本结构
|
||||||
|
|
||||||
|
### 4.6 内容管理
|
||||||
|
|
||||||
|
**公告管理**
|
||||||
|
- 编写公告内容
|
||||||
|
- 审核公告信息
|
||||||
|
- 发布/下架公告
|
||||||
|
- 管理公告展示
|
||||||
|
|
||||||
|
**科普文章**
|
||||||
|
- 编写科普内容
|
||||||
|
- 审核科普文章
|
||||||
|
- 发布到指定栏目
|
||||||
|
|
||||||
|
**首页轮播配置**
|
||||||
|
- 设计轮播图内容
|
||||||
|
- 设置轮播时间间隔
|
||||||
|
|
||||||
|
### 4.7 统计报表
|
||||||
|
|
||||||
|
- **患者增长**:计算新增患者数、患者数量,分析患者流失率,制作增长趋势图
|
||||||
|
- **咨询量**:计算咨询总次数,分析咨询高峰时段,分析咨询主题,提炼常见问题
|
||||||
|
- **随访完成率**:记录随访任务完成情况,计算随访完成率,提出改进建议
|
||||||
|
- **商城销售数据**:计算各商品销售量,分析销售额和趋势,分析利润变化原因
|
||||||
|
|
||||||
|
### 4.8 系统设置
|
||||||
|
|
||||||
|
- **消息推送配置**:编辑推送消息内容,设置推送时间和方式
|
||||||
|
- **数据备份**:设定备份周期,确认备份完整性
|
||||||
|
- **合规声明配置**:编写合规声明内容,更新合规声明版本,在系统中展示声明,确保用户阅读并同意声明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、与现有 HMS 系统的差异点(初步分析)
|
||||||
|
|
||||||
|
> 以下为客户需求与当前已实现 HMS 健康模块的对比,供后续需求评审参考。
|
||||||
|
|
||||||
|
| 客户需求 | 现有 HMS | 差异 |
|
||||||
|
|----------|----------|------|
|
||||||
|
| 肾病/血透专科方向 | 通用健康管理 | 需增加血透相关实体(透析记录、干体重等) |
|
||||||
|
| 医疗耗材商城 | 无 | 全新模块 |
|
||||||
|
| 微信支付集成 | 无 | 需对接微信支付 |
|
||||||
|
| 患者端微信小程序 | Taro 小程序(通用健康) | 需适配专科场景 |
|
||||||
|
| 医护端微信小程序 | 无(仅有 PC 管理后台) | 全新端 |
|
||||||
|
| AI 趋势分析/预警 | erp-ai 模块(基础) | 需增强专科 AI 分析能力 |
|
||||||
|
| 多就诊人管理(家属代绑) | 无 | 需新增 |
|
||||||
|
| 快递/到院自提配送 | 无 | 需新增 |
|
||||||
|
| 科普文章/内容管理 | 无 | 需新增 CMS 功能 |
|
||||||
|
| 合规声明 | 无 | 需新增 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、原始资料清单
|
||||||
|
|
||||||
|
| 文件 | 类型 | 内容说明 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `管理系统功能文档(1).xlsx` | Excel | 三端功能清单(系统模块/一级功能/二级功能描述) |
|
||||||
|
| `微信图片_20260424100721_255_611.png` | 图片 | 医护端功能思维导图(数据概览/患者管理/咨询回复/随访管理/报告解读) |
|
||||||
|
| `微信图片_20260424100722_256_611.png` | 图片 | 患者端功能思维导图(首页/数据上报/预约随访/商城/咨询/我的中心) |
|
||||||
|
| `微信图片_20260424100723_257_611.png` | 图片 | PC管理后台功能思维导图(系统管理/患者管理/数据中心/咨询管理/商城管理/内容管理/统计报表/系统设置) |
|
||||||
BIN
docs/健康管理/微信图片_20260424100721_255_611.png
Normal file
BIN
docs/健康管理/微信图片_20260424100721_255_611.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 359 KiB |
BIN
docs/健康管理/微信图片_20260424100722_256_611.png
Normal file
BIN
docs/健康管理/微信图片_20260424100722_256_611.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 427 KiB |
BIN
docs/健康管理/微信图片_20260424100723_257_611.png
Normal file
BIN
docs/健康管理/微信图片_20260424100723_257_611.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 528 KiB |
BIN
docs/健康管理/管理系统功能文档(1).xlsx
Normal file
BIN
docs/健康管理/管理系统功能文档(1).xlsx
Normal file
Binary file not shown.
10
package.json
Normal file
10
package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "erp",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start:dev": "powershell -ExecutionPolicy Bypass -File ./dev.ps1",
|
||||||
|
"start:stop": "powershell -ExecutionPolicy Bypass -File ./dev.ps1 -Stop",
|
||||||
|
"start:restart": "powershell -ExecutionPolicy Bypass -File ./dev.ps1 -Restart",
|
||||||
|
"start:status": "powershell -ExecutionPolicy Bypass -File ./dev.ps1 -Status"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
pnpm-lock.yaml
generated
Normal file
9
pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.: {}
|
||||||
255
scripts/api_test_mp.py
Normal file
255
scripts/api_test_mp.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Miniprogram API comprehensive test - 6 endpoints."""
|
||||||
|
import json, urllib.request, urllib.error, sys
|
||||||
|
|
||||||
|
BASE = 'http://localhost:3000/api/v1'
|
||||||
|
# Read fresh token from file
|
||||||
|
with open('g:/hms/.test_token_fresh.txt') as f:
|
||||||
|
TOKEN = f.read().strip()
|
||||||
|
PATIENT_ID = '019dcd34-bc4d-72c1-8c19-77ce1f4839d6'
|
||||||
|
TENANT_ID = '019d80da-7a2c-7820-b0a3-3d5266a3a324'
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {TOKEN}',
|
||||||
|
'X-Tenant-Id': TENANT_ID,
|
||||||
|
'X-Patient-Id': PATIENT_ID,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def api_call(method, path, data=None):
|
||||||
|
url = f'{BASE}{path}'
|
||||||
|
if data:
|
||||||
|
req = urllib.request.Request(url, data=json.dumps(data).encode(), headers=headers, method=method)
|
||||||
|
else:
|
||||||
|
req = urllib.request.Request(url, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
body = json.loads(resp.read().decode())
|
||||||
|
return resp.status, body
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode()
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
return e.code, body
|
||||||
|
except Exception as e:
|
||||||
|
return 0, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def get_keys(obj, prefix=''):
|
||||||
|
"""Recursively extract field names from a dict."""
|
||||||
|
fields = []
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for k, v in obj.items():
|
||||||
|
full = f'{prefix}{k}' if not prefix else f'{prefix}.{k}'
|
||||||
|
if isinstance(v, dict):
|
||||||
|
fields.extend(get_keys(v, full))
|
||||||
|
else:
|
||||||
|
fields.append(full)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def print_header(title, api_path):
|
||||||
|
print()
|
||||||
|
print('=' * 70)
|
||||||
|
print(f'{title}')
|
||||||
|
print(f'Endpoint: {api_path}')
|
||||||
|
print('=' * 70)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_fields(name, data, expected_fields):
|
||||||
|
"""Compare actual vs expected fields."""
|
||||||
|
actual_keys = set(data.keys()) if isinstance(data, dict) else set()
|
||||||
|
expected_set = set(expected_fields)
|
||||||
|
matched = actual_keys & expected_set
|
||||||
|
missing = expected_set - actual_keys
|
||||||
|
extra = actual_keys - expected_set
|
||||||
|
|
||||||
|
match_status = "MATCH" if not missing else "MISMATCH"
|
||||||
|
print(f'[{name}] status=200 | {match_status}')
|
||||||
|
print(f' Actual fields: {sorted(actual_keys)}')
|
||||||
|
print(f' Expected fields: {sorted(expected_set)}')
|
||||||
|
if matched:
|
||||||
|
print(f' Matched: {sorted(matched)}')
|
||||||
|
if missing:
|
||||||
|
print(f' ** MISSING: {sorted(missing)} **')
|
||||||
|
if extra:
|
||||||
|
print(f' Extra: {sorted(extra)}')
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# API 1: GET /health/vital-signs/today
|
||||||
|
# =====================================================
|
||||||
|
print_header('API 1: Today Summary', f'GET /health/vital-signs/today?patient_id={PATIENT_ID}')
|
||||||
|
status, body = api_call('GET', f'/health/vital-signs/today?patient_id={PATIENT_ID}')
|
||||||
|
print(f'status={status}')
|
||||||
|
print(f'Response: {json.dumps(body, indent=2, ensure_ascii=False)}')
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
data = body.get('data', body)
|
||||||
|
expected = ['blood_pressure', 'heart_rate', 'blood_sugar', 'weight']
|
||||||
|
missing = analyze_fields('Today Summary', data, expected)
|
||||||
|
# Check nested structure
|
||||||
|
for key in ['blood_pressure', 'heart_rate', 'blood_sugar', 'weight']:
|
||||||
|
if key in data and isinstance(data[key], dict):
|
||||||
|
print(f' {key} sub-fields: {sorted(data[key].keys())}')
|
||||||
|
else:
|
||||||
|
print(f' FAILED: {status}')
|
||||||
|
# Try without patient_id
|
||||||
|
print(' Retrying without patient_id param...')
|
||||||
|
status2, body2 = api_call('GET', '/health/vital-signs/today')
|
||||||
|
print(f' status={status2}')
|
||||||
|
print(f' Response: {json.dumps(body2, indent=2, ensure_ascii=False)}')
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# API 2: POST /health/patients/{id}/vital-signs
|
||||||
|
# =====================================================
|
||||||
|
print_header('API 2: Create Vital Signs', f'POST /health/patients/{PATIENT_ID}/vital-signs')
|
||||||
|
create_data = {
|
||||||
|
'record_date': '2026-04-27',
|
||||||
|
'systolic_bp_morning': 130,
|
||||||
|
'diastolic_bp_morning': 85,
|
||||||
|
'heart_rate': 75,
|
||||||
|
'weight': 69.0,
|
||||||
|
'blood_sugar': 5.5,
|
||||||
|
}
|
||||||
|
status, body = api_call('POST', f'/health/patients/{PATIENT_ID}/vital-signs', create_data)
|
||||||
|
print(f'status={status}')
|
||||||
|
print(f'Response: {json.dumps(body, indent=2, ensure_ascii=False)}')
|
||||||
|
|
||||||
|
if status in (200, 201):
|
||||||
|
data = body.get('data', body)
|
||||||
|
actual_keys = set(data.keys()) if isinstance(data, dict) else set()
|
||||||
|
expected_keys = {'id', 'patient_id', 'record_date', 'systolic_bp_morning', 'diastolic_bp_morning',
|
||||||
|
'heart_rate', 'weight', 'blood_sugar', 'created_at', 'updated_at', 'version', 'tenant_id'}
|
||||||
|
analyze_fields('Create Vital Signs', data, expected_keys)
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# API 3: GET /health/patients/{id}/vital-signs (paginated)
|
||||||
|
# =====================================================
|
||||||
|
print_header('API 3: Vital Signs History', f'GET /health/patients/{PATIENT_ID}/vital-signs?page=1&page_size=5')
|
||||||
|
status, body = api_call('GET', f'/health/patients/{PATIENT_ID}/vital-signs?page=1&page_size=5')
|
||||||
|
print(f'status={status}')
|
||||||
|
print(f'Response: {json.dumps(body, indent=2, ensure_ascii=False)}')
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
data = body.get('data', body)
|
||||||
|
# Check pagination structure
|
||||||
|
if isinstance(body.get('data'), dict) and 'data' in body['data']:
|
||||||
|
# wrapped: {data: {data: [...], total: N}}
|
||||||
|
paginated = body['data']
|
||||||
|
print(f' Pagination structure: data={type(paginated.get("data")).__name__}, total={paginated.get("total")}')
|
||||||
|
items = paginated.get('data', [])
|
||||||
|
elif isinstance(body.get('data'), list):
|
||||||
|
items = body['data']
|
||||||
|
print(f' Response is direct array, len={len(items)}')
|
||||||
|
elif isinstance(body.get('data'), dict) and 'items' in body.get('data', {}):
|
||||||
|
paginated = body['data']
|
||||||
|
items = paginated.get('items', [])
|
||||||
|
print(f' Pagination via items: total={paginated.get("total")}')
|
||||||
|
else:
|
||||||
|
items = []
|
||||||
|
print(f' Unknown pagination structure')
|
||||||
|
|
||||||
|
if items and isinstance(items[0], dict):
|
||||||
|
actual_keys = set(items[0].keys())
|
||||||
|
expected_keys = {'id', 'record_date', 'systolic_bp_morning', 'diastolic_bp_morning',
|
||||||
|
'heart_rate', 'weight', 'blood_sugar', 'created_at', 'updated_at', 'patient_id'}
|
||||||
|
analyze_fields('Vital Signs Item', items[0], expected_keys)
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# API 4: GET /health/vital-signs/trend (mini trend)
|
||||||
|
# =====================================================
|
||||||
|
print_header('API 4: Vital Signs Trend', 'GET /health/vital-signs/trend?indicator=blood_pressure&range=7d')
|
||||||
|
status, body = api_call('GET', '/health/vital-signs/trend?indicator=blood_pressure&range=7d')
|
||||||
|
print(f'status={status}')
|
||||||
|
print(f'Response: {json.dumps(body, indent=2, ensure_ascii=False)}')
|
||||||
|
|
||||||
|
# Also try the task-specified path
|
||||||
|
print()
|
||||||
|
print(' Also testing: GET /health/patients/{id}/vital-signs/trend')
|
||||||
|
status2, body2 = api_call('GET', f'/health/patients/{PATIENT_ID}/vital-signs/trend?start_date=2026-04-20&end_date=2026-04-27')
|
||||||
|
print(f' status={status2}')
|
||||||
|
print(f' Response: {json.dumps(body2, indent=2, ensure_ascii=False)}')
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
data = body.get('data', body)
|
||||||
|
print(f' Top-level type: {type(data).__name__}')
|
||||||
|
if isinstance(data, dict):
|
||||||
|
print(f' Keys: {sorted(data.keys())}')
|
||||||
|
elif isinstance(data, list):
|
||||||
|
print(f' Array length: {len(data)}')
|
||||||
|
if data:
|
||||||
|
print(f' First item keys: {sorted(data[0].keys()) if isinstance(data[0], dict) else data[0]}')
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# API 5: GET /health/appointments
|
||||||
|
# =====================================================
|
||||||
|
print_header('API 5: Appointments List', f'GET /health/appointments?patient_id={PATIENT_ID}&page=1&page_size=5')
|
||||||
|
status, body = api_call('GET', f'/health/appointments?patient_id={PATIENT_ID}&page=1&page_size=5')
|
||||||
|
print(f'status={status}')
|
||||||
|
print(f'Response: {json.dumps(body, indent=2, ensure_ascii=False)}')
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
data = body.get('data', body)
|
||||||
|
if isinstance(data, dict) and 'data' in data:
|
||||||
|
items = data.get('data', [])
|
||||||
|
total = data.get('total', 'N/A')
|
||||||
|
print(f' Pagination: total={total}, items={len(items)}')
|
||||||
|
elif isinstance(data, list):
|
||||||
|
items = data
|
||||||
|
else:
|
||||||
|
items = []
|
||||||
|
|
||||||
|
if items and isinstance(items[0], dict):
|
||||||
|
actual_keys = set(items[0].keys())
|
||||||
|
# From miniprogram appointment.ts Appointment interface
|
||||||
|
expected_keys = {'id', 'patient_name', 'doctor_name', 'department',
|
||||||
|
'appointment_date', 'start_time', 'end_time', 'status', 'version'}
|
||||||
|
analyze_fields('Appointment', items[0], expected_keys)
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# API 6: GET /health/follow-up-tasks
|
||||||
|
# =====================================================
|
||||||
|
print_header('API 6: Follow-Up Tasks', f'GET /health/follow-up-tasks?patient_id={PATIENT_ID}&status=pending&page=1&page_size=5')
|
||||||
|
status, body = api_call('GET', f'/health/follow-up-tasks?patient_id={PATIENT_ID}&status=pending&page=1&page_size=5')
|
||||||
|
print(f'status={status}')
|
||||||
|
print(f'Response: {json.dumps(body, indent=2, ensure_ascii=False)}')
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
data = body.get('data', body)
|
||||||
|
if isinstance(data, dict) and 'data' in data:
|
||||||
|
items = data.get('data', [])
|
||||||
|
total = data.get('total', 'N/A')
|
||||||
|
print(f' Pagination: total={total}, items={len(items)}')
|
||||||
|
elif isinstance(data, list):
|
||||||
|
items = data
|
||||||
|
else:
|
||||||
|
items = []
|
||||||
|
|
||||||
|
if items and isinstance(items[0], dict):
|
||||||
|
actual_keys = set(items[0].keys())
|
||||||
|
# From miniprogram followup.ts FollowUpTask interface
|
||||||
|
expected_keys = {'id', 'patient_id', 'patient_name', 'follow_up_type',
|
||||||
|
'content_template', 'status', 'planned_date', 'version'}
|
||||||
|
analyze_fields('FollowUpTask', items[0], expected_keys)
|
||||||
|
elif not items:
|
||||||
|
print(' No pending tasks. Retrying without status filter...')
|
||||||
|
status2, body2 = api_call('GET', f'/health/follow-up-tasks?patient_id={PATIENT_ID}&page=1&page_size=5')
|
||||||
|
print(f' status={status2}')
|
||||||
|
data2 = body2.get('data', body2) if status2 == 200 else {}
|
||||||
|
if isinstance(data2, dict) and 'data' in data2:
|
||||||
|
items2 = data2.get('data', [])
|
||||||
|
print(f' Items without status filter: {len(items2)}')
|
||||||
|
if items2 and isinstance(items2[0], dict):
|
||||||
|
actual_keys = set(items2[0].keys())
|
||||||
|
expected_keys = {'id', 'patient_id', 'patient_name', 'follow_up_type',
|
||||||
|
'content_template', 'status', 'planned_date', 'version'}
|
||||||
|
analyze_fields('FollowUpTask (all)', items2[0], expected_keys)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('=' * 70)
|
||||||
|
print('TEST COMPLETE')
|
||||||
|
print('=' * 70)
|
||||||
10
skills-lock.json
Normal file
10
skills-lock.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"huashu-design": {
|
||||||
|
"source": "C:\\Users\\szend\\AppData\\Local\\Temp\\huashu-design",
|
||||||
|
"sourceType": "local",
|
||||||
|
"computedHash": "aedfa8eedd540a46aaa74a8c113bef80c81c8ead52c6714fbc261b74c0aebf2e"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
snapshot-menu-config.txt
Normal file
97
snapshot-menu-config.txt
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
uid=83_0 RootWebArea "ERP Platform" url="http://localhost:5174/#/settings"
|
||||||
|
uid=83_1 link "跳转到主要内容" url="http://localhost:5174/#root"
|
||||||
|
uid=83_2 StaticText "跳转到主要内容"
|
||||||
|
uid=83_3 complementary
|
||||||
|
uid=83_4 StaticText "E"
|
||||||
|
uid=83_5 StaticText "ERP Platform"
|
||||||
|
uid=83_6 StaticText "基础模块"
|
||||||
|
uid=83_7 image "home"
|
||||||
|
uid=83_8 StaticText "工作台"
|
||||||
|
uid=83_9 image "user"
|
||||||
|
uid=83_10 StaticText "用户管理"
|
||||||
|
uid=83_11 image "safety"
|
||||||
|
uid=83_12 StaticText "权限管理"
|
||||||
|
uid=83_13 image "apartment"
|
||||||
|
uid=83_14 StaticText "组织架构"
|
||||||
|
uid=83_15 StaticText "业务模块"
|
||||||
|
uid=83_16 image "partition"
|
||||||
|
uid=83_17 StaticText "工作流"
|
||||||
|
uid=83_18 image "message"
|
||||||
|
uid=83_19 StaticText "消息中心"
|
||||||
|
uid=83_20 StaticText "健康管理"
|
||||||
|
uid=83_21 image "dashboard"
|
||||||
|
uid=83_22 StaticText "统计报表"
|
||||||
|
uid=83_23 image "team"
|
||||||
|
uid=83_24 StaticText "患者管理"
|
||||||
|
uid=83_25 image "medicine-box"
|
||||||
|
uid=83_26 StaticText "医护管理"
|
||||||
|
uid=83_27 image "calendar"
|
||||||
|
uid=83_28 StaticText "预约排班"
|
||||||
|
uid=83_29 image "heart"
|
||||||
|
uid=83_30 StaticText "排班管理"
|
||||||
|
uid=83_31 image "phone"
|
||||||
|
uid=83_32 StaticText "随访管理"
|
||||||
|
uid=83_33 image "comment"
|
||||||
|
uid=83_34 StaticText "咨询管理"
|
||||||
|
uid=83_35 image "tags"
|
||||||
|
uid=83_36 StaticText "标签管理"
|
||||||
|
uid=83_37 image "trophy"
|
||||||
|
uid=83_38 StaticText "积分规则"
|
||||||
|
uid=83_39 image "shop"
|
||||||
|
uid=83_40 StaticText "商品管理"
|
||||||
|
uid=83_41 image "file-text"
|
||||||
|
uid=83_42 StaticText "订单管理"
|
||||||
|
uid=83_43 image "calendar"
|
||||||
|
uid=83_44 StaticText "线下活动"
|
||||||
|
uid=83_45 image "robot"
|
||||||
|
uid=83_46 StaticText "AI Prompt 管理"
|
||||||
|
uid=83_47 image "history"
|
||||||
|
uid=83_48 StaticText "AI 分析历史"
|
||||||
|
uid=83_49 image "bar-chart"
|
||||||
|
uid=83_50 StaticText "AI 用量统计"
|
||||||
|
uid=83_51 StaticText "系统"
|
||||||
|
uid=83_52 image "setting"
|
||||||
|
uid=83_53 StaticText "系统设置"
|
||||||
|
uid=83_54 image "appstore"
|
||||||
|
uid=83_55 StaticText "插件管理"
|
||||||
|
uid=83_56 banner
|
||||||
|
uid=83_57 image "menu-fold"
|
||||||
|
uid=83_58 StaticText "系统设置"
|
||||||
|
uid=83_59 image "search"
|
||||||
|
uid=83_60 image "bulb"
|
||||||
|
uid=83_61 image "bell"
|
||||||
|
uid=83_62 StaticText "4"
|
||||||
|
uid=83_63 StaticText "系"
|
||||||
|
uid=83_64 StaticText "系统管理员"
|
||||||
|
uid=83_65 main
|
||||||
|
uid=85_0 heading "系统设置" level="4"
|
||||||
|
uid=85_1 StaticText "管理系统参数、字典、菜单、主题等配置"
|
||||||
|
uid=85_2 tab "book 数据字典" selectable selected
|
||||||
|
uid=85_3 tab "global 语言管理" selectable
|
||||||
|
uid=85_4 tab "menu 菜单配置" selectable
|
||||||
|
uid=85_5 tab "number 编号规则" selectable
|
||||||
|
uid=85_6 tab "setting 系统参数" selectable
|
||||||
|
uid=85_7 tab "bg-colors 主题设置" selectable
|
||||||
|
uid=85_8 tab "audit 审计日志" selectable
|
||||||
|
uid=85_9 tab "lock 修改密码" selectable
|
||||||
|
uid=85_10 tabpanel "book 数据字典"
|
||||||
|
uid=85_11 heading "数据字典管理" level="5"
|
||||||
|
uid=85_12 button "plus 新建字典"
|
||||||
|
uid=85_13 generic live="polite" relevant="additions text"
|
||||||
|
uid=85_14 StaticText "名称"
|
||||||
|
uid=85_15 StaticText "编码"
|
||||||
|
uid=85_16 StaticText "说明"
|
||||||
|
uid=85_17 StaticText "操作"
|
||||||
|
uid=85_18 button expandable
|
||||||
|
uid=85_19 StaticText "测试字典-已修改"
|
||||||
|
uid=85_20 StaticText "test_dict"
|
||||||
|
uid=85_21 StaticText "验证用"
|
||||||
|
uid=85_22 button "添加项"
|
||||||
|
uid=85_23 button "编 辑"
|
||||||
|
uid=85_24 button "删 除"
|
||||||
|
uid=85_25 button "left" disableable disabled
|
||||||
|
uid=85_26 listitem level="1"
|
||||||
|
uid=85_27 StaticText "1"
|
||||||
|
uid=85_28 button "right" disableable disabled
|
||||||
|
uid=83_141 contentinfo
|
||||||
|
uid=83_142 StaticText "HMS 健康管理平台"
|
||||||
Reference in New Issue
Block a user