feat: 病历模块优化、AI辅助问诊

This commit is contained in:
李琦
2026-08-06 08:14:33 +08:00
parent 0331325929
commit dc9f9c5c13
11 changed files with 2440 additions and 83 deletions

View 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>

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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';

View File

@@ -0,0 +1,406 @@
<script lang="ts" setup>
/**
* PCAI 写病历
* 主抽屉:生成历史 + 预览 + 确认导入
* 二级弹窗:确认主诉后点生成 → 关弹窗,回抽屉等待结果
* 无历史时打开抽屉后自动弹出生成框
*/
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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>;
}

View File

@@ -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 };
}