feat: 病历模块优化、AI辅助问诊
This commit is contained in:
714
apps/web-antd/src/components/loading/PrescriptionLoading.vue
Normal file
714
apps/web-antd/src/components/loading/PrescriptionLoading.vue
Normal file
@@ -0,0 +1,714 @@
|
||||
<template>
|
||||
<!--
|
||||
AI 加载动画:处方=药丸;病历=病历本
|
||||
type + text 由调用方传入;嵌入抽屉时默认不显示主题切换
|
||||
-->
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="loading-wrapper"
|
||||
:class="{ 'loading-wrapper--dark': isDark }"
|
||||
:style="{ width: '100%', height: '100%' }"
|
||||
>
|
||||
<canvas ref="canvasRef" class="loading-canvas"></canvas>
|
||||
<button
|
||||
v-if="showToggle"
|
||||
class="theme-toggle-btn"
|
||||
:title="isDark ? '切换到亮色模式' : '切换到暗色模式'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<span class="theme-icon">
|
||||
<svg
|
||||
v-if="!isDark"
|
||||
class="sun"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="moon"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
</span>
|
||||
<!-- <span class="theme-label">{{ isDark ? '亮色' : '暗色' }}</span>-->
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AI 生成加载动画
|
||||
* - type=prescription:中心画胶囊药丸
|
||||
* - type=medical_record:中心画病历本
|
||||
* - text:底部文案(可动态传)
|
||||
*/
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
export type AiLoadingType = 'prescription' | 'medical_record';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 动画类型:处方 / 病历 */
|
||||
type?: AiLoadingType;
|
||||
/** 底部文案 */
|
||||
text?: string;
|
||||
/** 是否显示主题切换(嵌入业务抽屉建议关闭) */
|
||||
showToggle?: boolean;
|
||||
}>(),
|
||||
{
|
||||
type: 'prescription',
|
||||
text: '',
|
||||
showToggle: false,
|
||||
},
|
||||
);
|
||||
|
||||
/** 未传文案时按 type 给默认值 */
|
||||
const displayText = computed(() => {
|
||||
const t = String(props.text || '').trim();
|
||||
if (t) return t;
|
||||
return props.type === 'medical_record'
|
||||
? 'AI正在生成病历'
|
||||
: 'AI正在生成处方';
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const isDark = ref(false);
|
||||
/** 画布循环里读取最新文案/类型,避免闭包过期 */
|
||||
const liveText = ref(displayText.value);
|
||||
const liveType = ref<AiLoadingType>(props.type);
|
||||
|
||||
watch(
|
||||
displayText,
|
||||
(v) => {
|
||||
liveText.value = v;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(
|
||||
() => props.type,
|
||||
(v) => {
|
||||
liveType.value = v || 'prescription';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function detectAppDark(): boolean {
|
||||
return (
|
||||
document.documentElement.classList.contains('dark') ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
document.documentElement.getAttribute('class')?.includes('dark') === true
|
||||
);
|
||||
}
|
||||
|
||||
function syncThemeFromApp() {
|
||||
if (localStorage.getItem('prescription-loading-theme')) {
|
||||
isDark.value = localStorage.getItem('prescription-loading-theme') === 'dark';
|
||||
return;
|
||||
}
|
||||
isDark.value = detectAppDark();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value;
|
||||
localStorage.setItem(
|
||||
'prescription-loading-theme',
|
||||
isDark.value ? 'dark' : 'light',
|
||||
);
|
||||
resetParticles?.();
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
maxLife: number;
|
||||
size: number;
|
||||
color: { r: number; g: number; b: number };
|
||||
type: 'circle' | 'cross';
|
||||
}
|
||||
|
||||
interface ThemeColors {
|
||||
textColor: string;
|
||||
ringColor: string;
|
||||
glowInner: string;
|
||||
glowMiddle: string;
|
||||
glowOuter: string;
|
||||
particleBright: number;
|
||||
iconShadow: string;
|
||||
}
|
||||
|
||||
let animationId = 0;
|
||||
let resetParticles: (() => void) | null = null;
|
||||
let themeObserver: MutationObserver | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let removeWindowResize: (() => void) | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
syncThemeFromApp();
|
||||
if (!containerRef.value || !canvasRef.value) return;
|
||||
|
||||
const canvas = canvasRef.value;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const container = containerRef.value;
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
let cssWidth = 0;
|
||||
let cssHeight = 0;
|
||||
let prevW = 0;
|
||||
let prevH = 0;
|
||||
let startTime: number | null = null;
|
||||
let lastTime: number | null = null;
|
||||
let particles: Particle[] = [];
|
||||
const MAX_PARTICLES = 28;
|
||||
|
||||
resetParticles = () => {
|
||||
particles = [];
|
||||
};
|
||||
|
||||
function updateSize() {
|
||||
const rect = container.getBoundingClientRect();
|
||||
cssWidth = rect.width || 300;
|
||||
cssHeight = rect.height || 280;
|
||||
const pw = Math.round(cssWidth * dpr);
|
||||
const ph = Math.round(cssHeight * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) {
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
if (
|
||||
prevW &&
|
||||
(Math.abs(cssWidth - prevW) > 2 || Math.abs(cssHeight - prevH) > 2)
|
||||
) {
|
||||
particles = [];
|
||||
}
|
||||
prevW = cssWidth;
|
||||
prevH = cssHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function createParticle(cx: number, cy: number, s: number): Particle {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = s * (0.15 + Math.random() * 0.45);
|
||||
const speed = s * (0.03 + Math.random() * 0.08);
|
||||
const colors = [
|
||||
{ r: 61, g: 214, b: 208 },
|
||||
{ r: 74, g: 195, b: 190 },
|
||||
{ r: 93, g: 173, b: 226 },
|
||||
{ r: 46, g: 180, b: 170 },
|
||||
{ r: 100, g: 200, b: 215 },
|
||||
];
|
||||
const color = colors[Math.floor(Math.random() * colors.length)]!;
|
||||
return {
|
||||
x: cx + Math.cos(angle) * distance,
|
||||
y: cy + Math.sin(angle) * distance,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
maxLife: 1.8 + Math.random() * 2.5,
|
||||
size: 1.2 + Math.random() * 2.8,
|
||||
color,
|
||||
type: Math.random() < 0.25 ? 'cross' : 'circle',
|
||||
};
|
||||
}
|
||||
|
||||
function updateParticles(dt: number, cx: number, cy: number, s: number) {
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
const p = particles[i]!;
|
||||
p.life -= dt / p.maxLife;
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.vx *= 0.997;
|
||||
p.vy *= 0.997;
|
||||
if (p.life <= 0) particles.splice(i, 1);
|
||||
}
|
||||
while (particles.length < MAX_PARTICLES) {
|
||||
const p = createParticle(cx, cy, s);
|
||||
const ang = Math.random() * Math.PI * 2;
|
||||
p.x = cx + Math.cos(ang) * s * 0.12 * Math.random();
|
||||
p.y = cy + Math.sin(ang) * s * 0.12 * Math.random();
|
||||
particles.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
function drawParticle(
|
||||
c: CanvasRenderingContext2D,
|
||||
p: Particle,
|
||||
bright: number,
|
||||
) {
|
||||
const alpha = p.life;
|
||||
const { r, g, b } = p.color;
|
||||
const size = p.size * (0.7 + 0.3 * p.life);
|
||||
const br = Math.min(255, Math.round(r * bright));
|
||||
const bg = Math.min(255, Math.round(g * bright));
|
||||
const bb = Math.min(255, Math.round(b * bright));
|
||||
c.save();
|
||||
c.globalAlpha = alpha;
|
||||
if (p.type === 'cross') {
|
||||
const len = size * 1.8;
|
||||
const w = size * 0.55;
|
||||
c.strokeStyle = `rgba(${br},${bg},${bb},${alpha})`;
|
||||
c.lineWidth = w;
|
||||
c.lineCap = 'round';
|
||||
c.beginPath();
|
||||
c.moveTo(p.x - len, p.y);
|
||||
c.lineTo(p.x + len, p.y);
|
||||
c.stroke();
|
||||
c.beginPath();
|
||||
c.moveTo(p.x, p.y - len);
|
||||
c.lineTo(p.x, p.y + len);
|
||||
c.stroke();
|
||||
c.fillStyle = `rgba(255,255,255,${alpha * 0.9})`;
|
||||
c.beginPath();
|
||||
c.arc(p.x, p.y, size * 0.35, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
} else {
|
||||
c.shadowColor = `rgba(${br},${bg},${bb},${alpha * 0.7})`;
|
||||
c.shadowBlur = size * 2.5;
|
||||
c.fillStyle = `rgba(${br},${bg},${bb},${alpha})`;
|
||||
c.beginPath();
|
||||
c.arc(p.x, p.y, size, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
c.shadowBlur = 0;
|
||||
c.fillStyle = `rgba(255,255,255,${alpha * 0.6})`;
|
||||
c.beginPath();
|
||||
c.arc(p.x, p.y, size * 0.4, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/** 处方药丸(处方) */
|
||||
function drawPill(
|
||||
c: CanvasRenderingContext2D,
|
||||
px: number,
|
||||
py: number,
|
||||
pw: number,
|
||||
ph: number,
|
||||
floatOff: number,
|
||||
pulse: number,
|
||||
shadowCol: string,
|
||||
) {
|
||||
c.save();
|
||||
const cx = px + pw / 2;
|
||||
const cy = py + ph / 2;
|
||||
const hw = (pw / 2) * pulse;
|
||||
const hh = (ph / 2) * pulse;
|
||||
const adjY = cy + floatOff;
|
||||
const dx = cx - hw;
|
||||
const dy = adjY - hh;
|
||||
const dw = hw * 2;
|
||||
const dh = hh * 2;
|
||||
const rad = hh;
|
||||
const path = new Path2D();
|
||||
path.roundRect(dx, dy, dw, dh, rad);
|
||||
|
||||
c.shadowColor = shadowCol;
|
||||
c.shadowBlur = hh * 0.7;
|
||||
c.shadowOffsetY = hh * 0.25;
|
||||
c.fillStyle = '#ffffff';
|
||||
c.fill(path);
|
||||
c.shadowColor = 'transparent';
|
||||
c.shadowBlur = 0;
|
||||
c.shadowOffsetY = 0;
|
||||
|
||||
c.save();
|
||||
c.clip(path);
|
||||
const leftGrad = c.createLinearGradient(dx, 0, dx + dw * 0.5, 0);
|
||||
leftGrad.addColorStop(0, '#FDFBF7');
|
||||
leftGrad.addColorStop(0.7, '#F5F1EA');
|
||||
leftGrad.addColorStop(1, '#EDE8DF');
|
||||
c.fillStyle = leftGrad;
|
||||
c.fillRect(dx, dy, dw * 0.5, dh);
|
||||
|
||||
const rightGrad = c.createLinearGradient(dx + dw * 0.5, 0, dx + dw, 0);
|
||||
rightGrad.addColorStop(0, '#5DD9D1');
|
||||
rightGrad.addColorStop(0.5, '#3DD6D0');
|
||||
rightGrad.addColorStop(1, '#2CB8B0');
|
||||
c.fillStyle = rightGrad;
|
||||
c.fillRect(dx + dw * 0.5, dy, dw * 0.5, dh);
|
||||
|
||||
c.strokeStyle = 'rgba(180,180,180,0.5)';
|
||||
c.lineWidth = Math.max(0.6, hh * 0.06);
|
||||
c.beginPath();
|
||||
c.moveTo(dx + dw * 0.5, dy + 1);
|
||||
c.lineTo(dx + dw * 0.5, dy + dh - 1);
|
||||
c.stroke();
|
||||
|
||||
const hy = dy + dh * 0.15;
|
||||
const hh2 = dh * 0.38;
|
||||
const hp = new Path2D();
|
||||
hp.roundRect(dx + dw * 0.06, hy, dw * 0.88, hh2, hh2 / 2);
|
||||
c.fillStyle = 'rgba(255,255,255,0.45)';
|
||||
c.fill(hp);
|
||||
c.restore();
|
||||
|
||||
c.strokeStyle = 'rgba(200,200,200,0.5)';
|
||||
c.lineWidth = Math.max(0.8, hh * 0.08);
|
||||
c.stroke(path);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/** 病历本(病历) */
|
||||
function drawMedicalRecord(
|
||||
c: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
scale: number,
|
||||
floatOff: number,
|
||||
pulse: number,
|
||||
shadowCol: string,
|
||||
) {
|
||||
c.save();
|
||||
const w = scale * 0.28 * pulse;
|
||||
const h = scale * 0.34 * pulse;
|
||||
const x = cx - w / 2;
|
||||
const y = cy - h / 2 + floatOff;
|
||||
const r = Math.max(4, scale * 0.018);
|
||||
const path = new Path2D();
|
||||
path.roundRect(x, y, w, h, r);
|
||||
|
||||
c.shadowColor = shadowCol;
|
||||
c.shadowBlur = scale * 0.035;
|
||||
c.shadowOffsetY = scale * 0.012;
|
||||
c.fillStyle = '#ffffff';
|
||||
c.fill(path);
|
||||
c.shadowColor = 'transparent';
|
||||
c.shadowBlur = 0;
|
||||
c.shadowOffsetY = 0;
|
||||
|
||||
// 左侧装订条
|
||||
c.fillStyle = '#3DD6D0';
|
||||
c.fillRect(x, y + r * 0.2, w * 0.12, h - r * 0.4);
|
||||
|
||||
// 横线(像病历行)
|
||||
c.strokeStyle = 'rgba(61,214,208,0.35)';
|
||||
c.lineWidth = Math.max(1, scale * 0.004);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const ly = y + h * (0.32 + i * 0.14);
|
||||
c.beginPath();
|
||||
c.moveTo(x + w * 0.22, ly);
|
||||
c.lineTo(x + w * 0.88, ly);
|
||||
c.stroke();
|
||||
}
|
||||
// 顶部标题条
|
||||
c.fillStyle = 'rgba(61,214,208,0.2)';
|
||||
c.beginPath();
|
||||
c.roundRect(x + w * 0.22, y + h * 0.12, w * 0.5, h * 0.08, 2);
|
||||
c.fill();
|
||||
|
||||
c.strokeStyle = 'rgba(180,200,200,0.55)';
|
||||
c.lineWidth = Math.max(0.8, scale * 0.005);
|
||||
c.stroke(path);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function drawCross(
|
||||
c: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
alpha: number,
|
||||
rot: number,
|
||||
) {
|
||||
c.save();
|
||||
c.translate(x, y);
|
||||
c.rotate(rot);
|
||||
c.globalAlpha = alpha;
|
||||
const len = size;
|
||||
const w = size * 0.3;
|
||||
c.strokeStyle = '#3DD6D0';
|
||||
c.lineWidth = w;
|
||||
c.lineCap = 'round';
|
||||
c.beginPath();
|
||||
c.moveTo(-len, 0);
|
||||
c.lineTo(len, 0);
|
||||
c.stroke();
|
||||
c.beginPath();
|
||||
c.moveTo(0, -len);
|
||||
c.lineTo(0, len);
|
||||
c.stroke();
|
||||
c.fillStyle = '#ffffff';
|
||||
c.beginPath();
|
||||
c.arc(0, 0, size * 0.25, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function getTheme(): ThemeColors {
|
||||
const dark = isDark.value;
|
||||
return {
|
||||
textColor: dark ? '#d1d5db' : '#4a5568',
|
||||
ringColor: dark ? 'rgba(255,255,255,0.25)' : 'rgba(180,195,200,0.45)',
|
||||
glowInner: dark ? 'rgba(61,214,208,0.15)' : 'rgba(61,214,208,0.13)',
|
||||
glowMiddle: dark ? 'rgba(61,214,208,0.05)' : 'rgba(61,214,208,0.04)',
|
||||
glowOuter: 'rgba(61,214,208,0)',
|
||||
particleBright: dark ? 1.4 : 1.0,
|
||||
iconShadow: dark ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.12)',
|
||||
};
|
||||
}
|
||||
|
||||
function draw(timestamp: number) {
|
||||
updateSize();
|
||||
if (cssWidth <= 0 || cssHeight <= 0) {
|
||||
animationId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
if (startTime === null) {
|
||||
startTime = timestamp;
|
||||
lastTime = timestamp;
|
||||
}
|
||||
const elapsed = (timestamp - startTime) / 1000;
|
||||
let dt = (timestamp - (lastTime ?? timestamp)) / 1000;
|
||||
if (dt <= 0) dt = 0.016;
|
||||
if (dt > 0.2) dt = 0.2;
|
||||
lastTime = timestamp;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const cx = cssWidth / 2;
|
||||
const cy = cssHeight / 2 - cssHeight * 0.04;
|
||||
const scale = Math.min(cssWidth, cssHeight);
|
||||
const orbitR = scale * 0.26;
|
||||
const fontSize = Math.max(12, scale * 0.055);
|
||||
const textY = cy + orbitR + fontSize * 2.4;
|
||||
const text = liveText.value;
|
||||
const animType = liveType.value;
|
||||
|
||||
const dashOff = elapsed * scale * 0.025;
|
||||
const arcRot = elapsed * 3.5;
|
||||
const arcLen = (100 * Math.PI) / 180;
|
||||
const floatOff = Math.sin(elapsed * 2.5) * scale * 0.025;
|
||||
const pulseS = 1 + Math.sin(elapsed * 3.7) * 0.04;
|
||||
const textAlpha = 0.55 + 0.45 * Math.sin(elapsed * 1.6);
|
||||
const theme = getTheme();
|
||||
|
||||
const grad = ctx.createRadialGradient(
|
||||
cx,
|
||||
cy,
|
||||
orbitR * 0.15,
|
||||
cx,
|
||||
cy,
|
||||
orbitR * 1.6,
|
||||
);
|
||||
grad.addColorStop(0, theme.glowInner);
|
||||
grad.addColorStop(0.5, theme.glowMiddle);
|
||||
grad.addColorStop(1, theme.glowOuter);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, cssWidth, cssHeight);
|
||||
|
||||
ctx.save();
|
||||
ctx.setLineDash([scale * 0.018, scale * 0.04]);
|
||||
ctx.lineDashOffset = -dashOff;
|
||||
ctx.strokeStyle = theme.ringColor;
|
||||
ctx.lineWidth = Math.max(1, scale * 0.006);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, orbitR, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
|
||||
const arcS = arcRot;
|
||||
const arcE = arcRot + arcLen;
|
||||
const fx = cx + orbitR * Math.cos(arcE);
|
||||
const fy = cy + orbitR * Math.sin(arcE);
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(61,214,208,0.8)';
|
||||
ctx.shadowBlur = scale * 0.04;
|
||||
ctx.strokeStyle = '#3DD6D0';
|
||||
ctx.lineWidth = Math.max(2, scale * 0.014);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, orbitR, arcS, arcE);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(255,255,255,0.9)';
|
||||
ctx.shadowBlur = scale * 0.03;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(fx, fy, Math.max(2.5, scale * 0.014), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// 中心图标:按 type 切换
|
||||
if (animType === 'medical_record') {
|
||||
drawMedicalRecord(ctx, cx, cy, scale, floatOff, pulseS, theme.iconShadow);
|
||||
} else {
|
||||
const pillW = scale * 0.34;
|
||||
const pillH = scale * 0.1;
|
||||
drawPill(
|
||||
ctx,
|
||||
cx - pillW / 2,
|
||||
cy - pillH / 2,
|
||||
pillW,
|
||||
pillH,
|
||||
floatOff,
|
||||
pulseS,
|
||||
theme.iconShadow,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ang = elapsed * 1.8 + (i * Math.PI * 2) / 3;
|
||||
const crossOrbitR = orbitR * 1.02;
|
||||
const rx = cx + crossOrbitR * Math.cos(ang);
|
||||
const ry = cy + crossOrbitR * Math.sin(ang);
|
||||
const rs = Math.max(4, scale * 0.022);
|
||||
const ra = 0.4 + 0.35 * Math.sin(elapsed * 3.2 + i * 1.7);
|
||||
drawCross(ctx, rx, ry, rs, ra, elapsed * 0.9 + i);
|
||||
}
|
||||
|
||||
updateParticles(dt, cx, cy, scale);
|
||||
for (const p of particles) {
|
||||
drawParticle(ctx, p, theme.particleBright);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = textAlpha;
|
||||
ctx.fillStyle = theme.textColor;
|
||||
ctx.font = `${fontSize}px "PingFang SC","Microsoft YaHei","Noto Sans SC","Helvetica Neue",sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.shadowColor = 'rgba(61,214,208,0.25)';
|
||||
ctx.shadowBlur = fontSize * 0.6;
|
||||
ctx.fillText(text, cx, textY);
|
||||
ctx.restore();
|
||||
|
||||
const charW = fontSize * 0.55;
|
||||
const dotsStartX = cx + (text.length / 2) * charW + fontSize * 0.5;
|
||||
const dotR = Math.max(2, fontSize * 0.16);
|
||||
const dotSpacing = fontSize * 0.7;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const phase = (elapsed * 2.8 + i * 1.0) % 3;
|
||||
let brightness = phase < 1 ? phase : phase < 2 ? 2 - phase : 0;
|
||||
brightness = brightness * brightness * (3 - 2 * brightness);
|
||||
const alpha = 0.2 + brightness * 0.75;
|
||||
const dx = dotsStartX + i * dotSpacing * 2.2;
|
||||
ctx.save();
|
||||
ctx.shadowColor = `rgba(61,214,208,${alpha * 0.8})`;
|
||||
ctx.shadowBlur = dotR * 3;
|
||||
ctx.fillStyle = `rgba(61,214,208,${alpha})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, textY, dotR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
animationId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
updateSize();
|
||||
animationId = requestAnimationFrame(draw);
|
||||
|
||||
if (window.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => updateSize());
|
||||
resizeObserver.observe(container);
|
||||
} else {
|
||||
const onResize = () => updateSize();
|
||||
window.addEventListener('resize', onResize);
|
||||
removeWindowResize = () => window.removeEventListener('resize', onResize);
|
||||
}
|
||||
|
||||
// 跟随应用 dark class(未手动锁定主题时)
|
||||
themeObserver = new MutationObserver(() => {
|
||||
if (!localStorage.getItem('prescription-loading-theme')) {
|
||||
syncThemeFromApp();
|
||||
}
|
||||
});
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-theme'],
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(animationId);
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
removeWindowResize?.();
|
||||
removeWindowResize = null;
|
||||
themeObserver?.disconnect();
|
||||
themeObserver = null;
|
||||
resetParticles = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 220px;
|
||||
//background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.loading-wrapper--dark {
|
||||
//background: #1e2227;
|
||||
}
|
||||
.loading-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.theme-toggle-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(128, 128, 128, 0.3);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #4a5568;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: all 0.25s;
|
||||
}
|
||||
.loading-wrapper--dark .theme-toggle-btn {
|
||||
background: rgba(30, 34, 39, 0.9);
|
||||
color: #d1d5db;
|
||||
}
|
||||
.theme-toggle-btn:hover {
|
||||
border-color: #3dd6d0;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
.theme-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -428,3 +428,41 @@ export async function saveGranularCommonPrescriptionApi(data: {
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** AI 给处方 */
|
||||
export async function aiGeneratePrescriptionApi(data: {
|
||||
register_id: number;
|
||||
store_id?: number;
|
||||
prescription_type: number;
|
||||
/** 前端确认过的主诉(可编辑) */
|
||||
chief_complaint?: string;
|
||||
/** 当前病历表单快照(可未保存),供 AI 综合参考 */
|
||||
medical_record?: Record<string, any>;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-generate-prescription`, data);
|
||||
}
|
||||
|
||||
/** 本挂号 AI 生成历史(轻量列表) */
|
||||
export async function aiListGenerationsApi(data: {
|
||||
register_id: number;
|
||||
scene?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}ai-list-generations`, { params: data });
|
||||
}
|
||||
|
||||
/** AI 生成详情(点选历史) */
|
||||
export async function aiGenerationDetailApi(data: { id: number }) {
|
||||
return requestClient.get<any>(`${prefix}ai-generation-detail`, { params: data });
|
||||
}
|
||||
|
||||
/** AI 处方药名模糊对照 */
|
||||
export async function aiMatchPrescriptionDrugsApi(data: {
|
||||
generation_id?: number;
|
||||
register_id?: number;
|
||||
store_id?: number;
|
||||
prescription_type?: number;
|
||||
items?: Array<Record<string, any>>;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-match-prescription-drugs`, data);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* PC 接诊:AI 辅助出方
|
||||
* 主抽屉:历史列表 + 详情对照 + 确认导入
|
||||
* 二级弹窗:紧凑可编辑病历预览 → 生成处方(编辑同步回病历面板)
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { Button, Descriptions, Input, Switch, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
aiGeneratePrescriptionApi,
|
||||
aiListGenerationsApi,
|
||||
aiMatchPrescriptionDrugsApi,
|
||||
} from '../api';
|
||||
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
|
||||
|
||||
/** 二级弹窗通栏字段(Descriptions span=2) */
|
||||
const FULL_SPAN_KEYS = new Set([
|
||||
'chief_complaint',
|
||||
'present_illness',
|
||||
'tcm_case',
|
||||
'treatment_advice',
|
||||
'doctor_order',
|
||||
'auxiliary_exam',
|
||||
'physical_exam',
|
||||
]);
|
||||
|
||||
/** 二级弹窗可编辑的病历字段(主诉单独置顶) */
|
||||
const MR_EDIT_FIELDS: Array<{ key: string; label: string }> = [
|
||||
{ key: 'present_illness', label: '现病史' },
|
||||
{ key: 'tongue', label: '舌象' },
|
||||
{ key: 'pulse', label: '脉象' },
|
||||
{ key: 'tcm_case', label: '中医病案' },
|
||||
{ key: 'tcm_disease', label: '中医疾病' },
|
||||
{ key: 'tcm_syndrome', label: '中医证候' },
|
||||
{ key: 'tcm_method', label: '中医治法' },
|
||||
{ key: 'diagnosis', label: '临床诊断' },
|
||||
{ key: 'treatment_advice', label: '治疗意见' },
|
||||
{ key: 'doctor_order', label: '医嘱' },
|
||||
{ key: 'allergy_history', label: '过敏史' },
|
||||
{ key: 'past_history', label: '既往史' },
|
||||
{ key: 'family_history', label: '家族史' },
|
||||
{ key: 'epidemic_history', label: '流行病学史' },
|
||||
{ key: 'personal_history', label: '个人史' },
|
||||
{ key: 'menstrual_history', label: '月经史' },
|
||||
{ key: 'marital_history', label: '婚育史' },
|
||||
{ key: 'physical_exam', label: '体征检查' },
|
||||
{ key: 'auxiliary_exam', label: '辅助检查' },
|
||||
];
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'import',
|
||||
payload: {
|
||||
rows: Array<Record<string, any>>;
|
||||
dosage?: number;
|
||||
day_dosage?: number;
|
||||
},
|
||||
): void;
|
||||
(e: 'search-drug', keyword: string): void;
|
||||
/** 预览里改病历字段时回写父页病历面板 */
|
||||
(e: 'sync-medical-record', payload: Record<string, any>): void;
|
||||
}>();
|
||||
|
||||
const registerId = ref(0);
|
||||
const storeId = ref(0);
|
||||
const prescriptionType = ref(0);
|
||||
const patientName = ref('');
|
||||
const patientSex = ref(0);
|
||||
const patientAge = ref(0);
|
||||
const chiefComplaint = ref('');
|
||||
const medicalRecord = ref<Record<string, any>>({});
|
||||
const histLoading = ref(false);
|
||||
const generating = ref(false);
|
||||
const matchLoading = ref(false);
|
||||
const historyList = ref<any[]>([]);
|
||||
const activeId = ref(0);
|
||||
const matchedList = ref<any[]>([]);
|
||||
const unmatchedList = ref<any[]>([]);
|
||||
const conflictMessages = ref<string[]>([]);
|
||||
const rxDosage = ref(0);
|
||||
const rxDayDosage = ref(0);
|
||||
const rxReason = ref('');
|
||||
const rxBasis = ref('');
|
||||
const rxPrescriptionName = ref('');
|
||||
/** 二级弹窗:当前正在编辑的字段 key,空表示浏览态 */
|
||||
const editingKey = ref('');
|
||||
|
||||
const sexLabel = computed(() => {
|
||||
const s = Number(patientSex.value);
|
||||
if (s === 1) return '男';
|
||||
if (s === 2) return '女';
|
||||
return '未知';
|
||||
});
|
||||
|
||||
const typeLabel = computed(() => {
|
||||
const t = Number(prescriptionType.value);
|
||||
if (t === 1) return '中药';
|
||||
if (t === 2) return '西(中成)药';
|
||||
return `类型 ${t || '—'}`;
|
||||
});
|
||||
|
||||
const isTcmRx = computed(() => Number(prescriptionType.value) === 1);
|
||||
|
||||
/** 二级弹窗紧凑展示:有内容的字段 + 主诉 */
|
||||
const genPreviewRows = computed(() => {
|
||||
const mr = medicalRecord.value || {};
|
||||
const rows: Array<{ key: string; label: string; value: string }> = [
|
||||
{
|
||||
key: 'chief_complaint',
|
||||
label: '主诉',
|
||||
value: String(chiefComplaint.value || mr.chief_complaint || '').trim(),
|
||||
},
|
||||
];
|
||||
for (const f of MR_EDIT_FIELDS) {
|
||||
const val = String(mr[f.key] ?? '').trim();
|
||||
if (val) rows.push({ key: f.key, label: f.label, value: val });
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: 'AI辅助出方',
|
||||
class: 'w-[920px]',
|
||||
// 按钮放内容区,关掉默认 footer,避免底部留白占高
|
||||
footer: false,
|
||||
showCancelButton: false,
|
||||
showConfirmButton: false,
|
||||
// 内容区改 flex 铺满,由内部滚动,避免双重裁切
|
||||
contentClass: '!flex !flex-col !overflow-hidden',
|
||||
onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
resetState();
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<{
|
||||
registerId: number;
|
||||
storeId?: number;
|
||||
prescriptionType: number;
|
||||
patientName?: string;
|
||||
patientSex?: number;
|
||||
patientAge?: number;
|
||||
chiefComplaint?: string;
|
||||
medicalRecord?: Record<string, any>;
|
||||
}>();
|
||||
registerId.value = Number(data?.registerId || 0);
|
||||
storeId.value = Number(data?.storeId || 0);
|
||||
prescriptionType.value = Number(data?.prescriptionType || 0);
|
||||
patientName.value = String(data?.patientName || '');
|
||||
patientSex.value = Number(data?.patientSex || 0);
|
||||
patientAge.value = Number(data?.patientAge || 0);
|
||||
chiefComplaint.value = String(data?.chiefComplaint || '');
|
||||
medicalRecord.value =
|
||||
data?.medicalRecord && typeof data.medicalRecord === 'object'
|
||||
? { ...data.medicalRecord }
|
||||
: {};
|
||||
resetPreview();
|
||||
// 打开即进历史,并尽量选中第一条
|
||||
void bootstrapHistory();
|
||||
},
|
||||
});
|
||||
|
||||
const [GenModal, genModalApi] = useVbenModal({
|
||||
title: '生成处方',
|
||||
class: 'w-[920px]',
|
||||
confirmText: '开始生成',
|
||||
cancelText: '取消',
|
||||
// 校验失败不关;通过后立刻关弹窗,生成在抽屉内进行
|
||||
onConfirm: async () => confirmGenerateAndClose(),
|
||||
});
|
||||
|
||||
function resetPreview() {
|
||||
matchedList.value = [];
|
||||
unmatchedList.value = [];
|
||||
conflictMessages.value = [];
|
||||
rxDosage.value = 0;
|
||||
rxDayDosage.value = 0;
|
||||
rxReason.value = '';
|
||||
rxBasis.value = '';
|
||||
rxPrescriptionName.value = '';
|
||||
activeId.value = 0;
|
||||
editingKey.value = '';
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
generating.value = false;
|
||||
matchLoading.value = false;
|
||||
histLoading.value = false;
|
||||
historyList.value = [];
|
||||
medicalRecord.value = {};
|
||||
chiefComplaint.value = '';
|
||||
resetPreview();
|
||||
genModalApi.close();
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
const n = Number(ts || 0);
|
||||
if (!n) return '—';
|
||||
const d = new Date(n * 1000);
|
||||
const pad = (x: number) => (x < 10 ? `0${x}` : `${x}`);
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function typeLabelOf(t: number | string) {
|
||||
const n = Number(t || 0);
|
||||
if (n === 1) return '中药';
|
||||
if (n === 2) return '西药(中成药)';
|
||||
return n ? `类型 ${n}` : '—';
|
||||
}
|
||||
|
||||
function applyRxMeta(data: any) {
|
||||
rxDosage.value = Number(data?.dosage || 0);
|
||||
rxDayDosage.value = Number(data?.day_dosage || 0);
|
||||
rxReason.value = String(data?.reason || '').trim();
|
||||
rxBasis.value = String(data?.basis || '').trim();
|
||||
rxPrescriptionName.value = String(data?.prescription_name || '').trim();
|
||||
}
|
||||
|
||||
function applyConflict(conflict: any) {
|
||||
const msgs = Array.isArray(conflict?.messages) ? conflict.messages : [];
|
||||
conflictMessages.value = msgs
|
||||
.map((m: any) => String(m || '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function applyMatch(match: any) {
|
||||
const matched = Array.isArray(match?.matched) ? match.matched : [];
|
||||
matchedList.value = matched.map((m: any) => ({
|
||||
...m,
|
||||
_import: true,
|
||||
}));
|
||||
unmatchedList.value = Array.isArray(match?.unmatched) ? match.unmatched : [];
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
histLoading.value = true;
|
||||
try {
|
||||
const list = await aiListGenerationsApi({
|
||||
register_id: registerId.value,
|
||||
scene: 'prescription',
|
||||
limit: 30,
|
||||
});
|
||||
historyList.value = Array.isArray(list) ? list : [];
|
||||
} catch (e: any) {
|
||||
historyList.value = [];
|
||||
message.error(e?.message || e?.msg || '加载历史失败');
|
||||
} finally {
|
||||
histLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开抽屉后加载历史;有则选最新,无则自动打开生成框 */
|
||||
async function bootstrapHistory() {
|
||||
if (!registerId.value) return;
|
||||
await loadHistory();
|
||||
if (historyList.value.length) {
|
||||
await onSelectHistory(historyList.value[0]);
|
||||
} else {
|
||||
openGenerateModal();
|
||||
}
|
||||
}
|
||||
|
||||
function openGenerateModal() {
|
||||
if (!prescriptionType.value) {
|
||||
message.warning('请先选择处方类型');
|
||||
return;
|
||||
}
|
||||
editingKey.value = '';
|
||||
genModalApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 点字段进入编辑;改完 blur 后同步病历
|
||||
*/
|
||||
function startEdit(key: string) {
|
||||
editingKey.value = key;
|
||||
}
|
||||
|
||||
function fieldValue(key: string) {
|
||||
if (key === 'chief_complaint') return chiefComplaint.value;
|
||||
return String(medicalRecord.value?.[key] ?? '');
|
||||
}
|
||||
|
||||
function onFieldInput(key: string, val: string) {
|
||||
if (key === 'chief_complaint') {
|
||||
chiefComplaint.value = val;
|
||||
medicalRecord.value = {
|
||||
...(medicalRecord.value || {}),
|
||||
chief_complaint: val,
|
||||
};
|
||||
} else {
|
||||
medicalRecord.value = {
|
||||
...(medicalRecord.value || {}),
|
||||
[key]: val,
|
||||
};
|
||||
}
|
||||
emit('sync-medical-record', { [key]: val });
|
||||
}
|
||||
|
||||
function endEdit() {
|
||||
editingKey.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级弹窗点「开始生成」:先关弹窗,回抽屉等待 AI
|
||||
*/
|
||||
async function confirmGenerateAndClose(): Promise<boolean> {
|
||||
if (generating.value) return false;
|
||||
const chief = String(chiefComplaint.value || '').trim();
|
||||
if (!chief) {
|
||||
message.warning('请先填写主诉后再生成');
|
||||
return false;
|
||||
}
|
||||
if (!prescriptionType.value) {
|
||||
message.warning('请先选择处方类型');
|
||||
return false;
|
||||
}
|
||||
if (!registerId.value) {
|
||||
message.warning('挂号无效');
|
||||
return false;
|
||||
}
|
||||
// 先关模态,在抽屉内展示「AI 处理中」
|
||||
genModalApi.close();
|
||||
void runGenerateInDrawer(chief);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runGenerateInDrawer(chief: string) {
|
||||
generating.value = true;
|
||||
try {
|
||||
const data = await aiGeneratePrescriptionApi({
|
||||
register_id: registerId.value,
|
||||
store_id: storeId.value || undefined,
|
||||
prescription_type: prescriptionType.value,
|
||||
chief_complaint: chief,
|
||||
medical_record: {
|
||||
...(medicalRecord.value || {}),
|
||||
chief_complaint: chief,
|
||||
},
|
||||
});
|
||||
await loadHistory();
|
||||
activeId.value = Number(data?.generation_id || 0);
|
||||
applyMatch(data?.match || null);
|
||||
applyConflict(data?.conflict || null);
|
||||
applyRxMeta(data);
|
||||
message.success('已生成,请确认导入');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || e?.msg || '生成失败');
|
||||
} finally {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelectHistory(row: any) {
|
||||
if (!row?.id) return;
|
||||
activeId.value = Number(row.id);
|
||||
matchLoading.value = true;
|
||||
try {
|
||||
const data = await aiMatchPrescriptionDrugsApi({
|
||||
generation_id: Number(row.id),
|
||||
register_id: registerId.value,
|
||||
store_id: storeId.value || undefined,
|
||||
prescription_type: Number(
|
||||
prescriptionType.value || row.prescription_type || 0,
|
||||
),
|
||||
});
|
||||
applyMatch(data);
|
||||
applyConflict(data?.conflict || null);
|
||||
applyRxMeta({
|
||||
...data,
|
||||
prescription_name:
|
||||
data?.prescription_name || row?.name || row?.prescription_name || '',
|
||||
});
|
||||
} catch (e: any) {
|
||||
matchedList.value = [];
|
||||
unmatchedList.value = [];
|
||||
conflictMessages.value = [];
|
||||
message.error(e?.message || e?.msg || '对照失败');
|
||||
} finally {
|
||||
matchLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onConfirmImport() {
|
||||
const drugs: any[] = [];
|
||||
for (const m of matchedList.value) {
|
||||
if (!m || m._import === false) continue;
|
||||
const sid = Number(m.selected_drug_id || 0);
|
||||
if (!sid) continue;
|
||||
const picked = (m.candidates || []).find(
|
||||
(c: any) => Number(c.drug_id) === sid,
|
||||
);
|
||||
if (!picked) continue;
|
||||
drugs.push({
|
||||
candidate: picked,
|
||||
dose: m.dose || '',
|
||||
unit: m.unit || '',
|
||||
usage: m.usage || '',
|
||||
ai_name: m.ai_name || '',
|
||||
});
|
||||
}
|
||||
if (!drugs.length) {
|
||||
message.warning('请先勾选已对照药品');
|
||||
return;
|
||||
}
|
||||
emit('import', {
|
||||
rows: drugs,
|
||||
dosage: rxDosage.value || undefined,
|
||||
day_dosage: rxDayDosage.value || undefined,
|
||||
});
|
||||
drawerApi.close();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open(payload: {
|
||||
registerId: number;
|
||||
storeId?: number;
|
||||
prescriptionType: number;
|
||||
patientName?: string;
|
||||
patientSex?: number;
|
||||
patientAge?: number;
|
||||
chiefComplaint?: string;
|
||||
medicalRecord?: Record<string, any>;
|
||||
}) {
|
||||
drawerApi.setData(payload);
|
||||
drawerApi.open();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer>
|
||||
<!-- 铺满抽屉内容区:头部/禁忌/按钮固定,中间历史+对照可滚动 -->
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden text-foreground">
|
||||
<div class="shrink-0 text-sm text-muted-foreground">
|
||||
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁 ·
|
||||
{{ typeLabel }}
|
||||
</div>
|
||||
<div
|
||||
v-if="conflictMessages.length"
|
||||
class="shrink-0 rounded-lg border border-orange-500/35 bg-orange-500/10 px-3 py-2 text-xs"
|
||||
>
|
||||
<div class="mb-1 font-medium">配伍禁忌提示</div>
|
||||
<div v-for="(line, i) in conflictMessages" :key="i" class="text-muted-foreground">
|
||||
· {{ line }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 gap-3 overflow-hidden">
|
||||
<div class="w-44 shrink-0 overflow-y-auto border-r border-border pr-2">
|
||||
<div class="mb-2 text-sm font-medium">历史记录</div>
|
||||
<div v-if="histLoading" class="text-xs text-muted-foreground">加载中…</div>
|
||||
<div v-else-if="!historyList.length" class="text-xs text-muted-foreground">
|
||||
暂无记录,可点击下方「生成处方」
|
||||
</div>
|
||||
<div
|
||||
v-for="row in historyList"
|
||||
:key="row.id"
|
||||
class="mb-1 cursor-pointer rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-accent"
|
||||
:class="{
|
||||
'bg-primary/10 ring-1 ring-primary/20': activeId === Number(row.id),
|
||||
}"
|
||||
@click="onSelectHistory(row)"
|
||||
>
|
||||
<div class="truncate font-medium">
|
||||
{{ row.name || row.prescription_name || `方案 #${row.id}` }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-muted-foreground">
|
||||
{{ row.prescription_type_label || typeLabelOf(row.prescription_type) }}
|
||||
· {{ row.item_count || 0 }}味 · {{ formatTime(row.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 min-w-0 flex-1 overflow-y-auto pl-1 pb-2">
|
||||
<div
|
||||
v-if="generating || matchLoading"
|
||||
class="flex h-full min-h-[260px] items-center justify-center"
|
||||
>
|
||||
<PrescriptionLoading
|
||||
type="prescription"
|
||||
:text="
|
||||
generating ? 'AI正在生成处方' : 'AI正在对照药品'
|
||||
"
|
||||
class="h-[280px] w-full max-w-[420px]"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="!matchedList.length && !unmatchedList.length"
|
||||
class="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
请选择左侧历史,或点击「生成处方」
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="mb-1.5 text-xs font-medium text-muted-foreground">
|
||||
已对照({{ matchedList.length }})
|
||||
</div>
|
||||
<!-- 中药:一行多味网格;西药:仍用较完整卡片 -->
|
||||
<div
|
||||
v-if="isTcmRx"
|
||||
class="grid grid-cols-2 gap-1.5 xl:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="(m, mi) in matchedList"
|
||||
:key="mi"
|
||||
class="rounded border border-border bg-card px-1.5 py-1"
|
||||
>
|
||||
<div class="flex items-start gap-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-xs font-medium">{{ m.ai_name }}</div>
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
{{ m.dose }}{{ m.unit }}{{ m.usage ? `·${m.usage}` : '' }}
|
||||
</div>
|
||||
</div>
|
||||
<Switch v-model:checked="m._import" size="small" />
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-0.5">
|
||||
<Tag
|
||||
v-for="c in m.candidates || []"
|
||||
:key="c.drug_id"
|
||||
class="cursor-pointer !m-0 !px-1 !text-[10px] !leading-4"
|
||||
:color="
|
||||
Number(m.selected_drug_id) === Number(c.drug_id)
|
||||
? 'success'
|
||||
: undefined
|
||||
"
|
||||
@click="m.selected_drug_id = Number(c.drug_id)"
|
||||
>
|
||||
{{ c.drug_name }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(m, mi) in matchedList"
|
||||
:key="mi"
|
||||
class="rounded-lg border border-border bg-card p-2.5"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ m.ai_name }}</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ m.dose }}{{ m.unit }}{{ m.usage ? `·${m.usage}` : '' }}
|
||||
</span>
|
||||
<Switch v-model:checked="m._import" size="small" />
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<Tag
|
||||
v-for="c in m.candidates || []"
|
||||
:key="c.drug_id"
|
||||
class="cursor-pointer !m-0 !text-[11px]"
|
||||
:color="
|
||||
Number(m.selected_drug_id) === Number(c.drug_id)
|
||||
? 'success'
|
||||
: undefined
|
||||
"
|
||||
@click="m.selected_drug_id = Number(c.drug_id)"
|
||||
>
|
||||
{{ c.drug_name }} ¥{{ Number(c.price || 0).toFixed(2) }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="unmatchedList.length"
|
||||
class="mb-1.5 mt-3 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
未对照({{ unmatchedList.length }})
|
||||
</div>
|
||||
<div
|
||||
v-for="(u, ui) in unmatchedList"
|
||||
:key="ui"
|
||||
class="mb-1.5 rounded border border-orange-500/30 bg-orange-500/10 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<div class="font-medium">{{ u.ai_name }}</div>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
class="!h-auto !px-0"
|
||||
@click="
|
||||
emit('search-drug', u.ai_name || '');
|
||||
drawerApi.close();
|
||||
"
|
||||
>
|
||||
去手动搜索
|
||||
</Button>
|
||||
</div>
|
||||
<!-- 药方摘要:Descriptions 双列展示名称/剂数/出方理由 -->
|
||||
<Descriptions
|
||||
v-if="
|
||||
rxPrescriptionName ||
|
||||
rxReason ||
|
||||
rxBasis ||
|
||||
rxDosage ||
|
||||
rxDayDosage
|
||||
"
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
class="ai-rx-meta-desc mt-3"
|
||||
title="出方说明"
|
||||
>
|
||||
<Descriptions.Item label="药方名" :span="2">
|
||||
{{ rxPrescriptionName || '未命名药方' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="推荐剂数">
|
||||
{{ rxDosage || '—' }}剂
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="每天几次">
|
||||
{{ rxDayDosage || '—' }}次
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-if="rxReason"
|
||||
label="为什么出这个方"
|
||||
:span="2"
|
||||
>
|
||||
<div class="max-h-28 overflow-y-auto whitespace-pre-wrap leading-relaxed">
|
||||
{{ rxReason }}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-if="rxBasis"
|
||||
label="根据哪些"
|
||||
:span="2"
|
||||
>
|
||||
<div class="max-h-28 overflow-y-auto whitespace-pre-wrap leading-relaxed">
|
||||
{{ rxBasis }}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 justify-end gap-2 border-t border-border pt-3">
|
||||
<Button @click="drawerApi.close()">关闭</Button>
|
||||
<Button type="default" @click="openGenerateModal">生成处方</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:disabled="generating || matchLoading || !matchedList.length"
|
||||
@click="onConfirmImport"
|
||||
>
|
||||
确认导入
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<!-- 二级:病历预览(可点改)+ 生成;Descriptions 双列 -->
|
||||
<GenModal>
|
||||
<div class="space-y-2 text-sm text-foreground">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁 ·
|
||||
{{ typeLabel }} · 点击文字可编辑,将同步到病历
|
||||
</div>
|
||||
<div class="max-h-[58vh] overflow-y-auto">
|
||||
<Descriptions
|
||||
v-if="genPreviewRows.length"
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
class="ai-rx-gen-desc"
|
||||
>
|
||||
<Descriptions.Item
|
||||
v-for="row in genPreviewRows"
|
||||
:key="row.key"
|
||||
:label="row.label"
|
||||
:span="FULL_SPAN_KEYS.has(row.key) ? 2 : 1"
|
||||
>
|
||||
<Input.TextArea
|
||||
v-if="editingKey === row.key"
|
||||
:value="fieldValue(row.key)"
|
||||
:rows="row.key === 'chief_complaint' ? 3 : 2"
|
||||
autofocus
|
||||
@update:value="(v) => onFieldInput(row.key, String(v ?? ''))"
|
||||
@blur="endEdit"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="max-h-24 cursor-text overflow-y-auto whitespace-pre-wrap leading-relaxed"
|
||||
title="点击编辑"
|
||||
@click="startEdit(row.key)"
|
||||
>
|
||||
{{ fieldValue(row.key) || '(空,点击填写)' }}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div
|
||||
v-if="genPreviewRows.length <= 1"
|
||||
class="px-2 py-3 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
仅有主诉。建议先在病历区补充现病史、舌脉等,再生成。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GenModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-rx-gen-desc :deep(.ant-descriptions-item-label),
|
||||
.ai-rx-meta-desc :deep(.ant-descriptions-item-label) {
|
||||
width: 110px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-rx-gen-desc :deep(.ant-descriptions-item-content),
|
||||
.ai-rx-meta-desc :deep(.ant-descriptions-item-content) {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
PlusOutlined,
|
||||
RobotOutlined,
|
||||
SaveOutlined,
|
||||
StopOutlined,
|
||||
SwapOutlined,
|
||||
@@ -85,6 +86,7 @@ import RefusalOfTreatmentModal
|
||||
from "#/views/doctor/doctor-reception/components/RefusalOfTreatmentModal.vue";
|
||||
// 常用方选择弹窗组件
|
||||
import CommonPrescriptionModal from './components/CommonPrescriptionModal.vue';
|
||||
import AiPrescriptionDrawer from './components/AiPrescriptionDrawer.vue';
|
||||
// 确认模态框组件
|
||||
import ConfirmModal from './components/ConfirmModal.vue';
|
||||
// 信息提示模态框组件
|
||||
@@ -1264,6 +1266,177 @@ function openCommonPrescriptionModal() {
|
||||
CommonPrescriptionModalApi.open();
|
||||
}
|
||||
|
||||
const aiPrescriptionDrawerRef = ref<InstanceType<typeof AiPrescriptionDrawer> | null>(null);
|
||||
|
||||
/** 打开 AI 给处方抽屉:先确认患者与主诉,再生成预览 */
|
||||
function openAiPrescriptionDrawer() {
|
||||
if (guardSpecialPrescriptionCartEdit()) return;
|
||||
const registerId = Number.parseInt(
|
||||
localStorage.getItem(`doctorReception-id`) || '0',
|
||||
);
|
||||
if (!registerId) {
|
||||
message.warning('请先选择患者');
|
||||
return;
|
||||
}
|
||||
if (!activeCategory.value) {
|
||||
message.warning('请先选择处方类型');
|
||||
return;
|
||||
}
|
||||
const payload = medicalRecordPanelRef.value?.getPayload?.() || {};
|
||||
aiPrescriptionDrawerRef.value?.open({
|
||||
registerId,
|
||||
storeId: Number(myStoreId.value || 0) || undefined,
|
||||
prescriptionType: Number(activeCategory.value),
|
||||
patientName: String(activePatient.value?.name || ''),
|
||||
patientSex: Number(activePatient.value?.sex || 0),
|
||||
patientAge: Number(activePatient.value?.age || 0),
|
||||
chiefComplaint: String(payload.chief_complaint || ''),
|
||||
// 带入当前病历表单(含未保存内容),出方时综合参考而非只看主诉
|
||||
medicalRecord: { ...payload },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 出方预览里改病历字段时,回写到当前病历面板(不强制保存)
|
||||
*/
|
||||
function handleSyncAiMedicalRecord(partial: Record<string, any>) {
|
||||
medicalRecordPanelRef.value?.patchFields?.(partial || {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 AI 对照结果覆盖导入当前处方(非追加)
|
||||
* 中药会同步剂数/每日次数,并按 usage 匹配先煎后下 way_id
|
||||
*/
|
||||
function handleImportAiPrescription(payload: {
|
||||
rows?: any[];
|
||||
dosage?: number;
|
||||
day_dosage?: number;
|
||||
}) {
|
||||
if (guardSpecialPrescriptionCartEdit()) return;
|
||||
const list = Array.isArray(payload?.rows) ? payload.rows : [];
|
||||
if (!list.length) {
|
||||
message.warning('没有可导入的药品');
|
||||
return;
|
||||
}
|
||||
const resolveWayId = (usage: string) => {
|
||||
const u = String(usage || '').trim();
|
||||
if (!u) return 0;
|
||||
const found = (drugUseWay.value || []).find(
|
||||
(w: any) =>
|
||||
String(w.name || '') === u || String(w.name || '').includes(u),
|
||||
);
|
||||
return found ? Number(found.id || 0) : 0;
|
||||
};
|
||||
const doOverwrite = () => {
|
||||
clearAppliedSpecialPrescription();
|
||||
const next: any[] = [];
|
||||
for (const row of list) {
|
||||
const c = row?.candidate || {};
|
||||
const drugId = Number(c.drug_id || 0);
|
||||
if (!drugId) continue;
|
||||
if (next.find((item: any) => Number(item.id) === drugId)) continue;
|
||||
const doseNum = parseFloat(row.dose);
|
||||
const qty = !Number.isNaN(doseNum) && doseNum > 0 ? doseNum : 1;
|
||||
const drug = c.drug || {};
|
||||
if (activeCategory.value === 1) {
|
||||
const wayId = resolveWayId(row.usage || '');
|
||||
next.push({
|
||||
index_id: c.id || drugId,
|
||||
id: drugId,
|
||||
drug_name: c.drug_name || row.ai_name,
|
||||
number: qty,
|
||||
price: parseFloat(c.price) || 0,
|
||||
way_id: wayId,
|
||||
use_ways: drugUseWay.value.find((item: any) => item.id === wayId),
|
||||
select_number: 1,
|
||||
unit: c.unit || { id: 0, name: row.unit || 'g' },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (activeCategory.value === 2) {
|
||||
next.push({
|
||||
index_id: c.id || drugId,
|
||||
id: drugId,
|
||||
drug_name: c.drug_name || drug.drug_name || row.ai_name,
|
||||
number: qty,
|
||||
select_number: qty,
|
||||
price: parseFloat(c.price) || 0,
|
||||
image: c.image || drug.image || '',
|
||||
instruction: drug.instruction || '',
|
||||
time_id: c.time_id || drug.time_id || 0,
|
||||
type_id: c.type_id || drug.type_id || 0,
|
||||
frequency_id: c.frequency_id || drug.frequency_id || 0,
|
||||
unit_id: c.unit_id || drug.unit_id || 0,
|
||||
use_num: drugTime.value.find(
|
||||
(item) => item.id === (c.time_id || drug.time_id || 0),
|
||||
),
|
||||
use_type: drugUseType.value.find(
|
||||
(item) => item.id === (c.type_id || drug.type_id || 0),
|
||||
),
|
||||
use_frequency: drugUseFrequency.value.find(
|
||||
(item) => item.id === (c.frequency_id || drug.frequency_id || 0),
|
||||
),
|
||||
unit:
|
||||
c.unit ||
|
||||
drugUnit.value.find(
|
||||
(item) => item.id === (c.unit_id || drug.unit_id || 0),
|
||||
),
|
||||
type: 2,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
next.push({
|
||||
index_id: c.id || drugId,
|
||||
id: drugId,
|
||||
drug_name: c.drug_name || row.ai_name,
|
||||
number: 1,
|
||||
select_number: 1,
|
||||
price: parseFloat(c.price) || 0,
|
||||
image: c.image || '',
|
||||
type: activeCategory.value,
|
||||
});
|
||||
}
|
||||
if (!next.length) {
|
||||
message.warning('没有可导入的药品');
|
||||
return;
|
||||
}
|
||||
currentDrugs.value = next;
|
||||
if (activeCategory.value === 1) {
|
||||
if (payload.dosage != null && Number(payload.dosage) > 0) {
|
||||
dosage.value = Number(payload.dosage);
|
||||
}
|
||||
if (payload.day_dosage != null && Number(payload.day_dosage) > 0) {
|
||||
dayDosage.value = Number(payload.day_dosage);
|
||||
}
|
||||
}
|
||||
updateLocalStorage();
|
||||
message.success(`已覆盖导入 ${next.length} 个药品`);
|
||||
};
|
||||
if (currentDrugs.value.length > 0) {
|
||||
AntModal.confirm({
|
||||
title: '覆盖当前处方',
|
||||
content: '导入将清空并覆盖现有处方药品,是否继续?',
|
||||
okText: '继续覆盖',
|
||||
cancelText: '取消',
|
||||
onOk: () => doOverwrite(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
doOverwrite();
|
||||
}
|
||||
|
||||
/** 未对照药名:提示手动搜索 */
|
||||
function handleAiSearchUnmatchedDrug(keyword: string) {
|
||||
const key = String(keyword || '');
|
||||
if (activeCategory.value === 2) {
|
||||
openWesternModal();
|
||||
return;
|
||||
}
|
||||
if (key) {
|
||||
message.info(`请手动搜索:${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理选择常用方
|
||||
* @param data 常用方数据,包含prescription和recipes
|
||||
@@ -2947,6 +3120,15 @@ function onStoreSelectOpenChange(open: boolean) {
|
||||
<BookOutlined />
|
||||
选择常用方
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isSpecialPrescriptionCartLocked"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="openAiPrescriptionDrawer"
|
||||
>
|
||||
<RobotOutlined />
|
||||
AI辅助出方
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canUseCommonPrescription"
|
||||
type="link"
|
||||
@@ -3007,6 +3189,14 @@ function onStoreSelectOpenChange(open: boolean) {
|
||||
<HistoryOutlined />
|
||||
引用历史病历
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="medicalRecordPanelRef?.aiGenerate?.()"
|
||||
>
|
||||
<RobotOutlined />
|
||||
AI写病历
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -3745,6 +3935,7 @@ function onStoreSelectOpenChange(open: boolean) {
|
||||
:user-patient-id="Number(activePatient?.id || 0)"
|
||||
:patient-sex="Number(activePatient?.sex || 0)"
|
||||
:patient-age="Number(activePatient?.age || 0)"
|
||||
:patient-name="String(activePatient?.name || '')"
|
||||
:show-toolbar="false"
|
||||
/>
|
||||
</div>
|
||||
@@ -3767,6 +3958,12 @@ function onStoreSelectOpenChange(open: boolean) {
|
||||
<PrescriptionDetailModal/>
|
||||
<!-- 常用方选择弹窗 -->
|
||||
<CommonPrescriptionModals/>
|
||||
<AiPrescriptionDrawer
|
||||
ref="aiPrescriptionDrawerRef"
|
||||
@import="handleImportAiPrescription"
|
||||
@search-drug="handleAiSearchUnmatchedDrug"
|
||||
@sync-medical-record="handleSyncAiMedicalRecord"
|
||||
/>
|
||||
<!-- 确认添加药品弹窗 -->
|
||||
<ConfirmModalComponent />
|
||||
<PriceAdjustDrawer />
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import {
|
||||
ClearOutlined,
|
||||
HistoryOutlined,
|
||||
RobotOutlined,
|
||||
SaveOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
@@ -35,6 +36,7 @@ import EntryKeywordBubble, {
|
||||
import CommonDxOrderChips from './components/CommonDxOrderChips.vue';
|
||||
import PatientMedicalRecordDrawer from './components/PatientMedicalRecordDrawer.vue';
|
||||
import TextareaExpandModal from './components/TextareaExpandModal.vue';
|
||||
import AiMedicalRecordModal from './components/AiMedicalRecordModal.vue';
|
||||
import { emptyMedicalRecord, isHistoryFieldVisible } from './config/constants';
|
||||
import {
|
||||
clearMedicalRecordDraft,
|
||||
@@ -62,6 +64,8 @@ const props = withDefaults(
|
||||
patientSex?: number;
|
||||
/** 就诊人年龄,用于婚育史显隐 */
|
||||
patientAge?: number;
|
||||
/** 就诊人姓名(AI 写病历确认用) */
|
||||
patientName?: string;
|
||||
/**
|
||||
* 是否在面板内展示操作工具栏
|
||||
* 接诊台/弹窗已把按钮提到一级 Tab 下吸顶工具栏时传 false
|
||||
@@ -75,6 +79,7 @@ const props = withDefaults(
|
||||
storagePrefix: '',
|
||||
patientSex: 0,
|
||||
patientAge: 0,
|
||||
patientName: '',
|
||||
showToolbar: true,
|
||||
},
|
||||
);
|
||||
@@ -579,6 +584,54 @@ async function handleSave() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开 AI 写病历抽屉:先进历史;无历史自动打开生成框
|
||||
*/
|
||||
function handleAiGenerate() {
|
||||
if (!props.registerId) {
|
||||
message.warning('无挂号信息');
|
||||
return;
|
||||
}
|
||||
aiMedicalRecordModalRef.value?.open({
|
||||
registerId: props.registerId,
|
||||
storeId: props.storeId || undefined,
|
||||
patientName: props.patientName || '',
|
||||
patientSex: Number(props.patientSex || 0),
|
||||
patientAge: Number(props.patientAge || 0),
|
||||
chiefComplaint: String(form.chief_complaint || ''),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 AI 预览结果导入病历表单(不自动保存)
|
||||
*/
|
||||
function handleImportAiMedicalRecord(payload: {
|
||||
chief_complaint: string;
|
||||
fields: Record<string, string>;
|
||||
}) {
|
||||
const fields = payload?.fields || {};
|
||||
Object.keys(fields).forEach((k) => {
|
||||
if (k === 'chief_complaint' || k === 'name') return;
|
||||
// 诊断与处方同源,走双向绑定而不是仅写 form
|
||||
if (k === 'diagnosis') {
|
||||
const dx = fields[k] == null ? '' : String(fields[k]);
|
||||
form.diagnosis = dx;
|
||||
emit('update:diagnosis', dx);
|
||||
return;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(form, k)) {
|
||||
(form as any)[k] = fields[k] == null ? '' : String(fields[k]);
|
||||
}
|
||||
});
|
||||
if (payload?.chief_complaint) {
|
||||
form.chief_complaint = payload.chief_complaint;
|
||||
}
|
||||
persistLocalDraft();
|
||||
message.success('已导入,请检查后保存');
|
||||
}
|
||||
|
||||
const aiMedicalRecordModalRef = ref<InstanceType<typeof AiMedicalRecordModal> | null>(null);
|
||||
|
||||
async function handleClear() {
|
||||
if (!props.registerId) return;
|
||||
await clearMedicalRecord({
|
||||
@@ -637,6 +690,7 @@ defineExpose({
|
||||
loadRecord,
|
||||
applyCitedRecord,
|
||||
openCiteHistory: handleCiteHistory,
|
||||
aiGenerate: handleAiGenerate,
|
||||
loading,
|
||||
getPayload: () => ({
|
||||
...form,
|
||||
@@ -644,6 +698,30 @@ defineExpose({
|
||||
diagnosis: diagnosisModel.value,
|
||||
doctor_order: medicalAdviceModel.value,
|
||||
}),
|
||||
/**
|
||||
* 外部局部回写病历字段(如 AI 出方预览里点改),同步表单与本地草稿,不自动落库
|
||||
*/
|
||||
patchFields: (partial: Record<string, any>) => {
|
||||
if (!partial || typeof partial !== 'object') return;
|
||||
Object.keys(partial).forEach((k) => {
|
||||
const raw = partial[k];
|
||||
const val = raw == null ? '' : String(raw);
|
||||
if (k === 'diagnosis') {
|
||||
form.diagnosis = val;
|
||||
emit('update:diagnosis', val);
|
||||
return;
|
||||
}
|
||||
if (k === 'doctor_order' || k === 'medicalAdvice') {
|
||||
form.doctor_order = val;
|
||||
emit('update:medicalAdvice', val);
|
||||
return;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(form, k)) {
|
||||
(form as any)[k] = val;
|
||||
}
|
||||
});
|
||||
persistLocalDraft();
|
||||
},
|
||||
});
|
||||
|
||||
/** source:走中医三典气泡;默认 entry 走病历词条 */
|
||||
@@ -692,6 +770,8 @@ const allNormalFields = [
|
||||
fieldBlock('personal_history', '个人史'),
|
||||
fieldBlock('allergy_history', '过敏史'),
|
||||
fieldBlock('treatment_advice', '治疗意见'),
|
||||
// 诊断/医嘱与处方 Tab 同源双向同步(此前网格漏渲染导致病历侧「诊断」消失)
|
||||
fieldBlock('diagnosis', '临床诊断'),
|
||||
fieldBlock('doctor_order', '医嘱'),
|
||||
fieldBlock('physical_exam', '体征检查'),
|
||||
fieldBlock('auxiliary_exam', '辅助检查'),
|
||||
@@ -724,6 +804,10 @@ const visibleNormalFields = computed(() =>
|
||||
<HistoryOutlined />
|
||||
引用历史病历
|
||||
</Button>
|
||||
<Button type="link" size="small" :loading="loading" @click="handleAiGenerate">
|
||||
<RobotOutlined />
|
||||
AI写病历
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -747,8 +831,25 @@ const visibleNormalFields = computed(() =>
|
||||
>
|
||||
<div class="mr-field-head mb-1">
|
||||
<span class="mr-field-label">{{ f.label }}</span>
|
||||
<!-- 诊断/医嘱走处方同源气泡;其余走病历词条 -->
|
||||
<div
|
||||
v-if="f.fieldCode === 'doctor_order'"
|
||||
v-if="f.fieldCode === 'diagnosis'"
|
||||
class="mr-field-search"
|
||||
data-mr-module="diagnosis"
|
||||
data-mr-role="search"
|
||||
>
|
||||
<EntryKeywordBubble
|
||||
:ref="(el) => setSearchRef('diagnosis', el)"
|
||||
source="diagnosis"
|
||||
placeholder="诊断关键字"
|
||||
compact
|
||||
:show-common-in-bubble="showCommonInBubble"
|
||||
@select="(item) => onEntryPick('diagnosis', item)"
|
||||
@common-changed="commonChipsTick += 1"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="f.fieldCode === 'doctor_order'"
|
||||
class="mr-field-search"
|
||||
data-mr-module="doctor_order"
|
||||
data-mr-role="search"
|
||||
@@ -848,7 +949,34 @@ const visibleNormalFields = computed(() =>
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="f.fieldCode === 'doctor_order'">
|
||||
<template v-if="f.fieldCode === 'diagnosis'">
|
||||
<div data-mr-module="diagnosis" data-mr-role="textarea">
|
||||
<Textarea
|
||||
:ref="(el) => setTextareaRef('diagnosis', el)"
|
||||
v-model:value="diagnosisModel"
|
||||
:rows="2"
|
||||
:readonly="diagnosisReadonly"
|
||||
placeholder="临床诊断(与处方同步,双击放大)"
|
||||
@dblclick="openDiagnosisExpand"
|
||||
/>
|
||||
</div>
|
||||
<CommonDxOrderChips
|
||||
v-if="showCommonTags && !diagnosisReadonly"
|
||||
:key="`dx_chips_${commonChipsTick}`"
|
||||
source="diagnosis"
|
||||
:field-value="diagnosisModel"
|
||||
@select="
|
||||
(text) =>
|
||||
onEntryPick('diagnosis', {
|
||||
id: text,
|
||||
title: text,
|
||||
content: text,
|
||||
remark: '',
|
||||
})
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="f.fieldCode === 'doctor_order'">
|
||||
<div data-mr-module="doctor_order" data-mr-role="textarea">
|
||||
<Textarea
|
||||
:ref="(el) => setTextareaRef('doctor_order', el)"
|
||||
@@ -1075,6 +1203,10 @@ const visibleNormalFields = computed(() =>
|
||||
|
||||
<ExpandModal />
|
||||
<CiteHistoryDrawer />
|
||||
<AiMedicalRecordModal
|
||||
ref="aiMedicalRecordModalRef"
|
||||
@import="handleImportAiMedicalRecord"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -84,6 +84,33 @@ export async function getMedicalRecordListByPatient(data: {
|
||||
return requestClient.get<any>(`${prefix}list-by-patient`, { params: data });
|
||||
}
|
||||
|
||||
/** AI 写病历(需先填主诉;返回草稿字段) */
|
||||
export async function aiGenerateMedicalRecord(data: {
|
||||
register_id: number;
|
||||
store_id?: number;
|
||||
chief_complaint?: string;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-generate-medical-record`, data);
|
||||
}
|
||||
|
||||
/** 本挂号 AI 生成历史(与接诊共用 doctor-reception 接口) */
|
||||
export async function aiListGenerations(data: {
|
||||
register_id: number;
|
||||
scene?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
return requestClient.get<any>('doctor-reception/ai-list-generations', {
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
/** AI 生成详情(点选历史) */
|
||||
export async function aiGenerationDetail(data: { id: number }) {
|
||||
return requestClient.get<any>('doctor-reception/ai-generation-detail', {
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
/** Excel 解析后批量导入词条 */
|
||||
export async function importMedicalRecordEntry(data: {
|
||||
scope: 'public' | 'store';
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* PC:AI 写病历
|
||||
* 主抽屉:生成历史 + 预览 + 确认导入
|
||||
* 二级弹窗:确认主诉后点生成 → 关弹窗,回抽屉等待结果
|
||||
* 无历史时打开抽屉后自动弹出生成框
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { Button, Descriptions, Input, message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
aiGenerateMedicalRecord,
|
||||
aiGenerationDetail,
|
||||
aiListGenerations,
|
||||
} from '../api';
|
||||
import { MEDICAL_RECORD_FIELD_OPTIONS } from '../config/constants';
|
||||
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
|
||||
|
||||
/** Descriptions 通栏字段 */
|
||||
const FULL_SPAN_KEYS = new Set([
|
||||
'present_illness',
|
||||
'tcm_case',
|
||||
'treatment_advice',
|
||||
'auxiliary_exam',
|
||||
'physical_exam',
|
||||
'chief_complaint',
|
||||
]);
|
||||
/** 预览字段顺序(不含主诉) */
|
||||
const PREVIEW_FIELDS = [
|
||||
{ label: '现病史', value: 'present_illness' },
|
||||
{ label: '舌象', value: 'tongue' },
|
||||
{ label: '脉象', value: 'pulse' },
|
||||
{ label: '中医病案', value: 'tcm_case' },
|
||||
{ label: '中医证候', value: 'tcm_syndrome' },
|
||||
{ label: '中医疾病', value: 'tcm_disease' },
|
||||
{ label: '中医治法', value: 'tcm_method' },
|
||||
{ label: '临床诊断', value: 'diagnosis' },
|
||||
{ label: '治疗意见', value: 'treatment_advice' },
|
||||
...MEDICAL_RECORD_FIELD_OPTIONS.filter((f) =>
|
||||
[
|
||||
'family_history',
|
||||
'epidemic_history',
|
||||
'past_history',
|
||||
'menstrual_history',
|
||||
'marital_history',
|
||||
'personal_history',
|
||||
'allergy_history',
|
||||
'physical_exam',
|
||||
'auxiliary_exam',
|
||||
].includes(f.value),
|
||||
),
|
||||
];
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'import',
|
||||
payload: { chief_complaint: string; fields: Record<string, string> },
|
||||
): void;
|
||||
}>();
|
||||
|
||||
const registerId = ref(0);
|
||||
const storeId = ref(0);
|
||||
const patientName = ref('');
|
||||
const patientSex = ref(0);
|
||||
const patientAge = ref(0);
|
||||
const chiefComplaint = ref('');
|
||||
const histLoading = ref(false);
|
||||
const generating = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const historyList = ref<any[]>([]);
|
||||
const activeId = ref(0);
|
||||
const previewFields = ref<Record<string, string>>({});
|
||||
const previewChief = ref('');
|
||||
|
||||
const sexLabel = computed(() => {
|
||||
const s = Number(patientSex.value);
|
||||
if (s === 1) return '男';
|
||||
if (s === 2) return '女';
|
||||
return '未知';
|
||||
});
|
||||
|
||||
const previewRows = computed(() =>
|
||||
PREVIEW_FIELDS.map((f) => ({
|
||||
label: f.label,
|
||||
value: String(previewFields.value[f.value] || '').trim(),
|
||||
code: f.value,
|
||||
})).filter((r) => r.value !== ''),
|
||||
);
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: 'AI写病历',
|
||||
class: 'w-[860px]',
|
||||
footer: false,
|
||||
showCancelButton: false,
|
||||
showConfirmButton: false,
|
||||
contentClass: '!flex !flex-col !overflow-hidden',
|
||||
onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
resetState();
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<{
|
||||
registerId: number;
|
||||
storeId?: number;
|
||||
patientName?: string;
|
||||
patientSex?: number;
|
||||
patientAge?: number;
|
||||
chiefComplaint?: string;
|
||||
}>();
|
||||
registerId.value = Number(data?.registerId || 0);
|
||||
storeId.value = Number(data?.storeId || 0);
|
||||
patientName.value = String(data?.patientName || '');
|
||||
patientSex.value = Number(data?.patientSex || 0);
|
||||
patientAge.value = Number(data?.patientAge || 0);
|
||||
chiefComplaint.value = String(data?.chiefComplaint || '');
|
||||
previewFields.value = {};
|
||||
previewChief.value = '';
|
||||
activeId.value = 0;
|
||||
void bootstrapHistory();
|
||||
},
|
||||
});
|
||||
|
||||
const [GenModal, genModalApi] = useVbenModal({
|
||||
title: '生成病历',
|
||||
class: 'w-[560px]',
|
||||
confirmText: '开始生成',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => confirmGenerateAndClose(),
|
||||
});
|
||||
|
||||
function resetState() {
|
||||
histLoading.value = false;
|
||||
generating.value = false;
|
||||
detailLoading.value = false;
|
||||
historyList.value = [];
|
||||
activeId.value = 0;
|
||||
previewFields.value = {};
|
||||
previewChief.value = '';
|
||||
chiefComplaint.value = '';
|
||||
genModalApi.close();
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
const n = Number(ts || 0);
|
||||
if (!n) return '—';
|
||||
const d = new Date(n * 1000);
|
||||
const pad = (x: number) => (x < 10 ? `0${x}` : `${x}`);
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
histLoading.value = true;
|
||||
try {
|
||||
const list = await aiListGenerations({
|
||||
register_id: registerId.value,
|
||||
scene: 'medical_record',
|
||||
limit: 30,
|
||||
});
|
||||
historyList.value = Array.isArray(list) ? list : [];
|
||||
} catch (e: any) {
|
||||
historyList.value = [];
|
||||
message.error(e?.message || e?.msg || '加载历史失败');
|
||||
} finally {
|
||||
histLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 有历史选最新;无历史自动打开生成框 */
|
||||
async function bootstrapHistory() {
|
||||
if (!registerId.value) return;
|
||||
await loadHistory();
|
||||
if (historyList.value.length) {
|
||||
await onSelectHistory(historyList.value[0]);
|
||||
} else {
|
||||
openGenerateModal();
|
||||
}
|
||||
}
|
||||
|
||||
function openGenerateModal() {
|
||||
genModalApi.open();
|
||||
}
|
||||
|
||||
async function onSelectHistory(row: any) {
|
||||
if (!row?.id) return;
|
||||
activeId.value = Number(row.id);
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
const data = await aiGenerationDetail({ id: Number(row.id) });
|
||||
const fields = (data?.fields || {}) as Record<string, string>;
|
||||
previewFields.value = { ...fields };
|
||||
const snap = data?.input_snapshot || {};
|
||||
previewChief.value = String(
|
||||
snap.chief_complaint || chiefComplaint.value || '',
|
||||
).trim();
|
||||
} catch (e: any) {
|
||||
previewFields.value = {};
|
||||
previewChief.value = '';
|
||||
message.error(e?.message || e?.msg || '加载详情失败');
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 先关弹窗,回抽屉等待生成 */
|
||||
async function confirmGenerateAndClose(): Promise<boolean> {
|
||||
if (generating.value) return false;
|
||||
const chief = chiefComplaint.value.trim();
|
||||
if (!chief) {
|
||||
message.warning('请先填写主诉后再生成');
|
||||
return false;
|
||||
}
|
||||
if (!registerId.value) {
|
||||
message.warning('挂号无效');
|
||||
return false;
|
||||
}
|
||||
genModalApi.close();
|
||||
void runGenerateInDrawer(chief);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runGenerateInDrawer(chief: string) {
|
||||
generating.value = true;
|
||||
previewFields.value = {};
|
||||
previewChief.value = chief;
|
||||
try {
|
||||
const data = await aiGenerateMedicalRecord({
|
||||
register_id: registerId.value,
|
||||
store_id: storeId.value || undefined,
|
||||
chief_complaint: chief,
|
||||
});
|
||||
previewFields.value = (data?.fields || {}) as Record<string, string>;
|
||||
await loadHistory();
|
||||
activeId.value = Number(data?.generation_id || 0);
|
||||
message.success('已生成,请预览后确认导入');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || e?.msg || 'AI写病历失败');
|
||||
} finally {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onConfirmImport() {
|
||||
if (!Object.keys(previewFields.value || {}).length && !previewChief.value) {
|
||||
message.warning('暂无可导入内容');
|
||||
return;
|
||||
}
|
||||
emit('import', {
|
||||
chief_complaint: String(previewChief.value || chiefComplaint.value || '').trim(),
|
||||
fields: { ...previewFields.value },
|
||||
});
|
||||
drawerApi.close();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open(payload: {
|
||||
registerId: number;
|
||||
storeId?: number;
|
||||
patientName?: string;
|
||||
patientSex?: number;
|
||||
patientAge?: number;
|
||||
chiefComplaint?: string;
|
||||
}) {
|
||||
drawerApi.setData(payload);
|
||||
drawerApi.open();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden text-foreground">
|
||||
<div class="shrink-0 text-sm text-muted-foreground">
|
||||
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 gap-3 overflow-hidden">
|
||||
<div class="w-44 shrink-0 overflow-y-auto border-r border-border pr-2">
|
||||
<div class="mb-2 text-sm font-medium">历史记录</div>
|
||||
<div v-if="histLoading" class="text-xs text-muted-foreground">加载中…</div>
|
||||
<div v-else-if="!historyList.length" class="text-xs text-muted-foreground">
|
||||
暂无记录,可点击下方「生成病历」
|
||||
</div>
|
||||
<div
|
||||
v-for="row in historyList"
|
||||
:key="row.id"
|
||||
class="mb-1 cursor-pointer rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-accent"
|
||||
:class="{
|
||||
'bg-primary/10 ring-1 ring-primary/20': activeId === Number(row.id),
|
||||
}"
|
||||
@click="onSelectHistory(row)"
|
||||
>
|
||||
<div class="truncate font-medium">
|
||||
{{ row.name || `病历 #${row.id}` }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-muted-foreground">
|
||||
{{ formatTime(row.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 min-w-0 flex-1 overflow-y-auto pl-1 pb-2">
|
||||
<div
|
||||
v-if="generating || detailLoading"
|
||||
class="flex h-full min-h-[260px] items-center justify-center"
|
||||
>
|
||||
<PrescriptionLoading
|
||||
type="medical_record"
|
||||
:text="
|
||||
generating ? 'AI正在生成病历' : 'AI正在加载病历'
|
||||
"
|
||||
class="h-[280px] w-full max-w-[420px]"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="!previewRows.length && !previewChief"
|
||||
class="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
请选择左侧历史,或点击「生成病历」
|
||||
</div>
|
||||
<template v-else>
|
||||
<Descriptions
|
||||
v-if="previewChief || previewRows.length"
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
class="ai-mr-preview-desc"
|
||||
>
|
||||
<Descriptions.Item
|
||||
v-if="previewChief"
|
||||
label="主诉"
|
||||
:span="2"
|
||||
>
|
||||
<div class="max-h-20 overflow-y-auto whitespace-pre-wrap leading-relaxed">
|
||||
{{ previewChief }}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-for="row in previewRows"
|
||||
:key="row.code"
|
||||
:label="row.label"
|
||||
:span="FULL_SPAN_KEYS.has(row.code) ? 2 : 1"
|
||||
>
|
||||
<div class="max-h-20 overflow-y-auto whitespace-pre-wrap leading-relaxed">
|
||||
{{ row.value }}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div
|
||||
v-else
|
||||
class="py-4 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
暂无生成字段
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 justify-end gap-2 border-t border-border pt-3">
|
||||
<Button @click="drawerApi.close()">关闭</Button>
|
||||
<Button type="default" @click="openGenerateModal">生成病历</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:disabled="generating || detailLoading || (!previewRows.length && !previewChief)"
|
||||
@click="onConfirmImport"
|
||||
>
|
||||
确认导入
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<GenModal>
|
||||
<div class="space-y-3 text-sm text-foreground">
|
||||
<div class="rounded-lg border border-border bg-muted/40 p-3">
|
||||
<div class="mb-1 font-medium">患者信息确认</div>
|
||||
<div class="text-muted-foreground">
|
||||
姓名:{{ patientName || '—' }} 性别:{{ sexLabel }} 年龄:{{
|
||||
patientAge || '—'
|
||||
}}岁
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-2 font-medium">主诉(可编辑)</div>
|
||||
<Input.TextArea
|
||||
v-model:value="chiefComplaint"
|
||||
:rows="4"
|
||||
placeholder="请确认或补充主诉后再生成"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
点击「开始生成」后将关闭本弹窗,请在抽屉中等待结果。
|
||||
</div>
|
||||
</div>
|
||||
</GenModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-mr-preview-desc :deep(.ant-descriptions-item-label) {
|
||||
width: 88px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-mr-preview-desc :deep(.ant-descriptions-item-content) {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -175,7 +175,10 @@ async function handleImportFile(file: File) {
|
||||
importing.value = true;
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const parsed = await parseMedicalRecordEntryExcelBuffer(buffer);
|
||||
// 按字段拆分页传入 defaultFieldCode:允许 Excel 字段编码留空 / 填中文名
|
||||
const parsed = await parseMedicalRecordEntryExcelBuffer(buffer, {
|
||||
defaultFieldCode: props.fixedFieldCode || '',
|
||||
});
|
||||
let rows = parsed.items;
|
||||
if (props.fixedFieldCode) {
|
||||
rows = rows
|
||||
@@ -183,10 +186,13 @@ async function handleImportFile(file: File) {
|
||||
.filter((r) => r.title);
|
||||
}
|
||||
if (!rows.length) {
|
||||
const detail = parsed.firstInvalidReason
|
||||
? `:${parsed.firstInvalidReason}`
|
||||
: '';
|
||||
message.warning(
|
||||
parsed.invalidCount
|
||||
? `没有可导入的有效行(无效 ${parsed.invalidCount} 行)`
|
||||
: '文件无有效数据',
|
||||
? `没有可导入的有效行(无效 ${parsed.invalidCount} 行)${detail}`
|
||||
: '文件无有效数据,请确认填写的是「词条导入」工作表',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
* 患者历史病历抽屉
|
||||
* - view:只读查看
|
||||
* - cite:选择一条后点「引用此病历」回填到当前接诊病历
|
||||
* (修复:原 latest 接口会排除当前挂号,仅有本次病历时会误报「暂无」)
|
||||
* 右侧预览用 Descriptions 双列,跳过空字段
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { Empty, Spin, Tag, message } from 'ant-design-vue';
|
||||
import { Descriptions, Empty, Spin, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import { getMedicalRecordListByPatient } from '../api';
|
||||
import { MEDICAL_RECORD_VIEW_FIELDS } from '../config/constants';
|
||||
@@ -19,14 +19,22 @@ type DrawerData = {
|
||||
userPatientId?: number;
|
||||
storeId?: number;
|
||||
patientName?: string;
|
||||
/** view=查看;cite=引用到当前病历 */
|
||||
mode?: 'view' | 'cite';
|
||||
/** 引用模式下排除当前挂号(仍展示,但默认选中其他挂号) */
|
||||
excludeRegisterId?: number;
|
||||
/** 引用确认回调 */
|
||||
onCite?: (record: Record<string, any>) => void;
|
||||
};
|
||||
|
||||
/** Descriptions 通栏字段 */
|
||||
const FULL_SPAN_KEYS = new Set([
|
||||
'chief_complaint',
|
||||
'present_illness',
|
||||
'tcm_case',
|
||||
'treatment_advice',
|
||||
'doctor_order',
|
||||
'auxiliary_exam',
|
||||
'physical_exam',
|
||||
]);
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<Record<string, any>[]>([]);
|
||||
const activeId = ref(0);
|
||||
@@ -38,7 +46,6 @@ const activeRecord = computed(() => {
|
||||
return list.value.find((r) => Number(r.id) === activeId.value) || null;
|
||||
});
|
||||
|
||||
/** 体征摘要文案 */
|
||||
function vitalsText(row: Record<string, any>) {
|
||||
const parts: string[] = [];
|
||||
if (row.temperature) parts.push(`体温 ${row.temperature}℃`);
|
||||
@@ -56,13 +63,25 @@ function fieldDisplay(row: Record<string, any>, key: string) {
|
||||
const body = String(row.physical_exam || '').trim();
|
||||
const vitals = vitalsText(row);
|
||||
if (body && vitals) return `${vitals}\n${body}`;
|
||||
return body || vitals || '—';
|
||||
return body || vitals || '';
|
||||
}
|
||||
const v = row[key];
|
||||
if (v === null || v === undefined || v === '' || v === 0) return '—';
|
||||
return String(v);
|
||||
if (v === null || v === undefined || v === '' || v === 0) return '';
|
||||
return String(v).trim();
|
||||
}
|
||||
|
||||
/** 仅有内容的字段,供 Descriptions 渲染 */
|
||||
const filledViewFields = computed(() => {
|
||||
const row = activeRecord.value;
|
||||
if (!row) return [];
|
||||
return MEDICAL_RECORD_VIEW_FIELDS.map((f) => ({
|
||||
label: f.label,
|
||||
value: f.value,
|
||||
text: fieldDisplay(row, f.value),
|
||||
span: FULL_SPAN_KEYS.has(f.value) ? 2 : 1,
|
||||
})).filter((f) => !!f.text);
|
||||
});
|
||||
|
||||
function listTitle(row: Record<string, any>) {
|
||||
const dx = String(row.diagnosis || '').trim();
|
||||
if (dx) return dx.length > 28 ? `${dx.slice(0, 28)}…` : dx;
|
||||
@@ -74,9 +93,6 @@ function isCurrentRegister(row: Record<string, any>) {
|
||||
return exclude > 0 && Number(row.register_id) === exclude;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认选中:优先「非当前挂号」的最近一条,否则选第一条
|
||||
*/
|
||||
function pickDefaultActiveId(rows: Record<string, any>[]) {
|
||||
if (!rows.length) return 0;
|
||||
const exclude = Number(meta.value.excludeRegisterId || 0);
|
||||
@@ -113,7 +129,7 @@ async function loadList() {
|
||||
}
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
class: 'w-[860px]',
|
||||
class: 'w-[900px]',
|
||||
title: '查看患者病历',
|
||||
cancelText: '关闭',
|
||||
confirmText: '引用此病历',
|
||||
@@ -177,27 +193,50 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
</div>
|
||||
<div class="mr-view-detail">
|
||||
<template v-if="activeRecord">
|
||||
<div class="mr-view-detail__head">
|
||||
<span>挂号 ID:{{ activeRecord.register_id || '-' }}</span>
|
||||
<span>创建:{{ activeRecord.created_at || '—' }}</span>
|
||||
<span>更新:{{ activeRecord.updated_at || '—' }}</span>
|
||||
</div>
|
||||
<Descriptions
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
class="mb-3 mr-view-meta-desc"
|
||||
>
|
||||
<Descriptions.Item label="挂号 ID">
|
||||
{{ activeRecord.register_id || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建">
|
||||
{{ activeRecord.created_at || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新" :span="2">
|
||||
{{ activeRecord.updated_at || '—' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div
|
||||
v-if="isCiteMode && isCurrentRegister(activeRecord)"
|
||||
class="mr-view-tip"
|
||||
>
|
||||
当前选中的是本次挂号病历,引用后会覆盖编辑区内容(仍可继续改)。
|
||||
</div>
|
||||
<div
|
||||
v-for="f in MEDICAL_RECORD_VIEW_FIELDS"
|
||||
:key="f.value"
|
||||
class="mr-view-field"
|
||||
<Empty
|
||||
v-if="!filledViewFields.length"
|
||||
description="该病历暂无填写内容"
|
||||
class="py-6"
|
||||
/>
|
||||
<Descriptions
|
||||
v-else
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
title="病历内容"
|
||||
class="mr-view-content-desc"
|
||||
>
|
||||
<div class="mr-view-field__label">{{ f.label }}</div>
|
||||
<div class="mr-view-field__value">
|
||||
{{ fieldDisplay(activeRecord, f.value) }}
|
||||
</div>
|
||||
</div>
|
||||
<Descriptions.Item
|
||||
v-for="f in filledViewFields"
|
||||
:key="f.value"
|
||||
:label="f.label"
|
||||
:span="f.span"
|
||||
>
|
||||
<div class="mr-view-content-text">{{ f.text }}</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</template>
|
||||
<Empty v-else description="请选择左侧病历" />
|
||||
</div>
|
||||
@@ -208,22 +247,22 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
<style scoped>
|
||||
.mr-view-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 16px;
|
||||
min-height: 520px;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 12px;
|
||||
min-height: 480px;
|
||||
}
|
||||
.mr-view-list {
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
padding-right: 12px;
|
||||
padding-right: 10px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mr-view-item {
|
||||
padding: 10px 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
@@ -237,56 +276,44 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
.mr-view-item__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
}
|
||||
.mr-view-item__meta {
|
||||
margin-top: 6px;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.mr-view-detail {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.mr-view-detail__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
padding-right: 2px;
|
||||
}
|
||||
.mr-view-tip {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
color: #ad6800;
|
||||
background: #fff7e6;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.mr-view-field {
|
||||
margin-bottom: 12px;
|
||||
.mr-view-meta-desc :deep(.ant-descriptions-item-label),
|
||||
.mr-view-content-desc :deep(.ant-descriptions-item-label) {
|
||||
width: 88px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mr-view-field__label {
|
||||
.mr-view-content-desc :deep(.ant-descriptions-item-content) {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.mr-view-field__value {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
.mr-view-content-text {
|
||||
max-height: 88px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--muted) / 0.4);
|
||||
min-height: 36px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,8 +78,28 @@ export async function exportMedicalRecordEntryTemplate(): Promise<ArrayBuffer> {
|
||||
});
|
||||
});
|
||||
tip.addRow([]);
|
||||
tip.addRow([
|
||||
'填写说明',
|
||||
'「字段编码」可填右侧英文编码,也可直接填左侧中文名(如:主诉)',
|
||||
]);
|
||||
tip.addRow(['状态说明', '1=启用,0=禁用;空默认启用']);
|
||||
tip.addRow(['拼音首拼', '由系统根据词条名称自动生成,无需填写']);
|
||||
tip.addRow([
|
||||
'按字段菜单导入',
|
||||
'在「主诉/既往史」等拆分页导入时,字段编码列可留空,系统自动归属当前字段',
|
||||
]);
|
||||
|
||||
// 给「字段编码」列加下拉,减少填错中文/编码混用导致整表无效
|
||||
const codeList = MEDICAL_RECORD_FIELD_OPTIONS.map((o) => o.value).join(',');
|
||||
// Excel 数据验证公式长度有限;字段不多时可直接列枚举
|
||||
sheet.dataValidations.add('A2:A2001', {
|
||||
type: 'list',
|
||||
allowBlank: true,
|
||||
formulae: [`"${codeList}"`],
|
||||
showErrorMessage: true,
|
||||
errorTitle: '字段编码',
|
||||
error: '请从下拉选择英文编码,或手动填写中文名(如:主诉)',
|
||||
});
|
||||
|
||||
return workbook.xlsx.writeBuffer() as Promise<ArrayBuffer>;
|
||||
}
|
||||
|
||||
@@ -17,18 +17,45 @@ export type ParseMedicalRecordEntryResult = {
|
||||
items: ParsedMedicalRecordEntryRow[];
|
||||
invalidCount: number;
|
||||
rowCount: number;
|
||||
/** 首条无效原因,便于前端提示(如填了中文名未识别等) */
|
||||
firstInvalidReason?: string;
|
||||
};
|
||||
|
||||
export type ParseMedicalRecordEntryOptions = {
|
||||
/**
|
||||
* 按字段拆分菜单导入时传入:字段编码列可留空,自动落到当前字段
|
||||
* 为什么需要:运营在「主诉词条」页导入时常不填字段编码,旧逻辑会整行判无效
|
||||
*/
|
||||
defaultFieldCode?: string;
|
||||
};
|
||||
|
||||
function normalizeCell(value: ExcelJS.CellValue): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'object' && 'text' in value) {
|
||||
return String((value as any).text ?? '').trim();
|
||||
if (typeof value === 'object') {
|
||||
// 富文本:Excel/WPS 局部加粗后常见 { richText: [...] }
|
||||
if ('richText' in value && Array.isArray((value as any).richText)) {
|
||||
return (value as any).richText
|
||||
.map((p: { text?: string }) => p?.text ?? '')
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
||||
// 超链接 / 带 text 的复合值
|
||||
if ('text' in value) {
|
||||
return String((value as any).text ?? '').trim();
|
||||
}
|
||||
// 公式结果
|
||||
if ('result' in value) {
|
||||
return normalizeCell((value as any).result as ExcelJS.CellValue);
|
||||
}
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isInteger(value) ? String(value) : String(value).trim();
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? '1' : '0';
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
@@ -45,6 +72,23 @@ const STATUS_ALIASES = ['状态', 'status'];
|
||||
|
||||
const VALID_FIELDS = new Set(MEDICAL_RECORD_FIELD_OPTIONS.map((o) => o.value));
|
||||
|
||||
/** 中文名 / 英文编码 → 标准 field_code(运营常按「字段编码说明」里的中文名填写) */
|
||||
const FIELD_LABEL_TO_CODE = new Map<string, string>(
|
||||
MEDICAL_RECORD_FIELD_OPTIONS.flatMap((o) => [
|
||||
[normalizeHeader(o.value), o.value],
|
||||
[normalizeHeader(o.label), o.value],
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* 把单元格里的字段编码或中文名解析成标准 code;无法识别则返回空串
|
||||
*/
|
||||
function resolveFieldCode(raw: string): string {
|
||||
const key = normalizeHeader(raw);
|
||||
if (!key) return '';
|
||||
return FIELD_LABEL_TO_CODE.get(key) || (VALID_FIELDS.has(raw) ? raw : '');
|
||||
}
|
||||
|
||||
function buildHeaderIndexMap(headerRow: ExcelJS.Row): Record<string, number> {
|
||||
const map: Record<string, number> = {};
|
||||
headerRow.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
||||
@@ -81,25 +125,38 @@ function getCellInt(row: ExcelJS.Row, col?: number, fallback = 0): number {
|
||||
}
|
||||
|
||||
function rowIsEmpty(row: ExcelJS.Row): boolean {
|
||||
let empty = true;
|
||||
row.eachCell({ includeEmpty: false }, () => {
|
||||
empty = false;
|
||||
let hasValue = false;
|
||||
row.eachCell({ includeEmpty: false }, (cell) => {
|
||||
if (normalizeCell(cell.value) !== '') {
|
||||
hasValue = true;
|
||||
}
|
||||
});
|
||||
return empty;
|
||||
return !hasValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 优先取「词条导入」sheet;避免用户改完后激活了「字段编码说明」导致读错表
|
||||
*/
|
||||
function pickImportSheet(workbook: ExcelJS.Workbook): ExcelJS.Worksheet | undefined {
|
||||
const byName = workbook.worksheets.find((s) => s.name === '词条导入');
|
||||
return byName || workbook.worksheets[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析词条导入 Excel(首行为表头)
|
||||
* 字段编码列支持英文 code 或中文名;按字段页可传 defaultFieldCode 允许留空
|
||||
*/
|
||||
export async function parseMedicalRecordEntryExcelBuffer(
|
||||
buffer: ArrayBuffer,
|
||||
options: ParseMedicalRecordEntryOptions = {},
|
||||
): Promise<ParseMedicalRecordEntryResult> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer);
|
||||
const sheet = workbook.worksheets[0];
|
||||
const sheet = pickImportSheet(workbook);
|
||||
if (!sheet) {
|
||||
return { items: [], invalidCount: 0, rowCount: 0 };
|
||||
}
|
||||
const defaultFieldCode = resolveFieldCode(options.defaultFieldCode || '');
|
||||
const headerRow = sheet.getRow(1);
|
||||
const headerMap = buildHeaderIndexMap(headerRow);
|
||||
const fieldCol = pickColumn(headerMap, FIELD_ALIASES);
|
||||
@@ -108,21 +165,44 @@ export async function parseMedicalRecordEntryExcelBuffer(
|
||||
const remarkCol = pickColumn(headerMap, REMARK_ALIASES);
|
||||
const sortCol = pickColumn(headerMap, SORT_ALIASES);
|
||||
const statusCol = pickColumn(headerMap, STATUS_ALIASES);
|
||||
if (!fieldCol || !titleCol) {
|
||||
throw new Error('模板表头缺少「字段编码」或「词条名称」列');
|
||||
// 无默认字段时必须有「字段编码」列;有默认字段时仅要求「词条名称」
|
||||
if (!titleCol) {
|
||||
throw new Error('模板表头缺少「词条名称」列,请重新下载模板');
|
||||
}
|
||||
if (!fieldCol && !defaultFieldCode) {
|
||||
throw new Error('模板表头缺少「字段编码」列,请重新下载模板');
|
||||
}
|
||||
const items: ParsedMedicalRecordEntryRow[] = [];
|
||||
let invalidCount = 0;
|
||||
let rowCount = 0;
|
||||
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
||||
if (rowNumber === 1 || rowIsEmpty(row)) return;
|
||||
let firstInvalidReason: string | undefined;
|
||||
const maxRow = Math.min(sheet.rowCount || 0, MAX_ROWS + 1);
|
||||
for (let rowNumber = 2; rowNumber <= maxRow; rowNumber++) {
|
||||
const row = sheet.getRow(rowNumber);
|
||||
if (rowIsEmpty(row)) continue;
|
||||
rowCount++;
|
||||
if (items.length >= MAX_ROWS) return;
|
||||
const field_code = getCellText(row, fieldCol);
|
||||
if (items.length >= MAX_ROWS) break;
|
||||
const rawField = getCellText(row, fieldCol);
|
||||
const title = getCellText(row, titleCol);
|
||||
if (!field_code || !title || !VALID_FIELDS.has(field_code)) {
|
||||
let field_code = resolveFieldCode(rawField);
|
||||
if (!field_code && defaultFieldCode) {
|
||||
field_code = defaultFieldCode;
|
||||
}
|
||||
if (!title) {
|
||||
invalidCount++;
|
||||
return;
|
||||
if (!firstInvalidReason) {
|
||||
firstInvalidReason = `第${rowNumber}行:词条名称为空`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!field_code) {
|
||||
invalidCount++;
|
||||
if (!firstInvalidReason) {
|
||||
firstInvalidReason = rawField
|
||||
? `第${rowNumber}行:字段「${rawField}」无法识别(请填英文编码如 chief_complaint,或中文名如「主诉」)`
|
||||
: `第${rowNumber}行:字段编码为空(请对照「字段编码说明」填写,或从对应字段菜单页导入)`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const statusRaw = getCellInt(row, statusCol, 1);
|
||||
items.push({
|
||||
@@ -133,6 +213,6 @@ export async function parseMedicalRecordEntryExcelBuffer(
|
||||
sort: getCellInt(row, sortCol, 0),
|
||||
status: statusRaw === 0 ? 0 : 1,
|
||||
});
|
||||
});
|
||||
return { items, invalidCount, rowCount };
|
||||
}
|
||||
return { items, invalidCount, rowCount, firstInvalidReason };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user