🎯 一个iframe解决AI病历生成的样式冲突:从过度设计到简单之美

📝 引言

在开发医疗AI助手时,我们遇到了一个棘手的问题:AI生成并返回到前端的病历HTML,总是导致页面布局混乱。经过几轮方案讨论,最终用一个简单的iframe解决了问题。这个经历让我深刻体会到:最简单的方案往往是最好的

🚨 问题背景

系统工作流程

  1. 前端:选择模板、患者、日期范围
  2. 后端:获取患者数据,调用AI大模型
  3. AI:返回带HTML格式的病历内容
  4. 前端:将内容渲染到页面

问题现象

AI返回的HTML中包含了自己的CSS样式,这些样式与页面原有的样式冲突,导致布局完全混乱。

<!-- AI返回的HTML示例 -->
<div class="medical-record">
    <style>
        .medical-record { padding: 20px; background: #f0f0f0; }
        table { width: 100% !important; border: 1px solid #000; }
        h3 { text-align: center !important; color: red; }
    </style>
    <h3>1、透析总结</h3>
    <table>
        <tr><th>项目</th><th>结果</th></tr>
        <tr><td>Kt/V</td><td class="indicator">1.45</td></tr>
    </table>
</div>

页面原有样式冲突

.result-content .medical-record { padding: 15px; background: white; }
.result-content table { width: 80%; margin: 0 auto; }
.result-content h3 { text-align: left; border-left: 4px solid #667eea; }

💡 四种解决方案的演进

方案1:后端修改Prompt类

修改 stage_summary_prompt.py,移除样式或使用独立类名。

def _enhance_html_structure(self, html: str) -> str:
    import re
    html = re.sub(r'<style[^>]*>[\s\S]*?<\/style>', '', html)
    html = html.replace('class="medical-record"', 'class="ai-medical-record"')
    return html

优点:单一修改点,全局生效
缺点:需要修改后端代码,侵入性强,需要重新部署

方案2:使用类名隔离

在Prompt中修改所有CSS类名,添加 ai- 前缀。

def _get_default_html_structure(self) -> str:
    return """
    <div class="ai-medical-record">
        <div class="ai-patient-info">姓名:{{patient_name}} 性别:{{sex}}</div>
        <h3 class="ai-section-title">1、透析总结</h3>
    </div>
    """

优点:样式隔离
缺点:仍需修改后端,维护成本高

方案3:前端清理HTML

在每个调用AI的页面,对返回的HTML进行清理。

function cleanAIHtml(html) {
    html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
    html = html.replace(/\s*!important\s*/g, ' ');
    html = html.replace(/class="medical-record"/g, 'class="cleaned-record"');
    return html;
}

优点:不修改后端
缺点:违反DRY原则,每个页面都要改

方案4:iframe隔离(最终选择)

使用iframe作为容器,完全隔离样式。

🔧 iframe技术详解

什么是iframe?

iframe(内联框架)是HTML的一个元素,用于在当前页面中嵌入另一个HTML页面。每个iframe都拥有独立的文档对象模型(DOM)、JavaScript执行环境和CSS渲染上下文。

iframe的核心特性

  1. 样式隔离:iframe内部的CSS完全独立于父页面
  2. JavaScript隔离:JS执行环境独立,不会相互影响
  3. 文档流独立:有自己的历史记录、滚动条和焦点管理

🎯 最终实现代码

1. 修改HTML结构

<!-- templates/ai_assistant/index.html -->
<div class="result-content">
    <!-- 查看模式 - 使用iframe -->
    <div id="viewMode" style="display: none; width: 100%; height: 700px;">
        <iframe id="aiContentFrame" 
                style="width:100%; height:100%; border:none; border-radius:8px; background:white;"
                sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
                title="AI生成病历内容">
        </iframe>
    </div>
    
    <!-- 编辑模式保持不变 -->
    <div id="editMode" style="display: none;">
        <div id="summernoteContainer"></div>
    </div>
    
    <!-- 初始提示保持不变 -->
    <div id="initialPrompt" class="initial-prompt">
        <i class="fas fa-file-medical fa-3x mb-3"></i>
        <p>请选择模板并点击"AI生成"按钮开始创建病历</p>
    </div>
</div>

2. iframe渲染函数

// 在 <script> 标签中添加

// 在iframe中显示AI生成的HTML
function displayInIframe(html) {
    const iframe = document.getElementById('aiContentFrame');
    if (!iframe) return;
    
    const doc = iframe.contentDocument || iframe.contentWindow.document;
    doc.open();
    doc.write(`
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style>
        body { margin: 20px; font-family: 'Microsoft YaHei', sans-serif; line-height: 1.6; }
        table { width: 100%; border-collapse: collapse; margin: 15px 0; }
        th { background: #667eea; color: white; padding: 10px; }
        td { padding: 8px; border: 1px solid #dee2e6; }
        .indicator { color: #28a745; font-weight: 600; background: #f0fff4; padding: 2px 8px; border-radius: 4px; }
        h3 { border-left: 4px solid #667eea; padding-left: 12px; }
    </style>
</head>
<body>${html}</body>
</html>
    `);
    doc.close();
}

// 从iframe获取内容
function getIframeContent() {
    const iframe = document.getElementById('aiContentFrame');
    if (iframe && iframe.contentDocument) {
        return iframe.contentDocument.body.innerHTML;
    }
    return '';
}

// 自适应高度
function adjustIframeHeight() {
    const iframe = document.getElementById('aiContentFrame');
    if (iframe && iframe.contentDocument) {
        const height = iframe.contentDocument.body.scrollHeight;
        iframe.style.height = (height + 20) + 'px';
    }
}

3. 修改 loadRecord 函数

function loadRecord(recordId) {
    $("#loadingOverlay").css("display", "flex");

    $.get(`/ai_assistant/api/record/${String(recordId)}`, function(response) {
        $("#loadingOverlay").hide();

        if (response.status === "success") {
            currentGeneratedContent = response.data.generated_content;
            currentRecordId = String(response.data.id);
            currentTemplateType = response.data.template_type;

            $("#initialPrompt").hide();
            $("#viewMode").show();
            $("#editMode").hide();
            $("#editToggleBtn").show();
            $("#deleteBtn").show();

            // 在iframe中显示内容
            displayInIframe(currentGeneratedContent);
            setTimeout(adjustIframeHeight, 100);

            $("#recordDateTime").text(response.data.created_at || '未知时间');
            $("#recordInfoBar").show();

            if (isEditMode) {
                isEditMode = false;
                $('#editToggleBtn').removeClass('btn-warning').addClass('btn-outline-primary');
                $('#editBtnText').text('编辑');
                $('#saveBtn').hide();
                $('#editModeIndicator').fadeOut();
                destroySummernote();
            }
        }
    });
}

4. 修改 generateMedicalRecord 的成功回调

success: function(response) {
    $("#loadingOverlay").hide();
    if (response.status === "success") {
        currentGeneratedContent = response.data.content;
        currentRecordId = response.data.id;
        currentTemplateType = templateType;

        $("#initialPrompt").hide();
        $("#viewMode").show();
        $("#editMode").hide();
        $("#editToggleBtn").show();
        $("#deleteBtn").hide();
        
        displayInIframe(currentGeneratedContent);
        setTimeout(adjustIframeHeight, 100);

        if (isEditMode) {
            isEditMode = false;
            $('#editToggleBtn').removeClass('btn-warning').addClass('btn-outline-primary');
            $('#editBtnText').text('编辑');
            $('#saveBtn').hide();
            $('#editModeIndicator').fadeOut();
            destroySummernote();
        }
    }
}

5. 修改 toggleEditMode 函数

function toggleEditMode() {
    isEditMode = !isEditMode;

    if (isEditMode) {
        $('#viewMode').hide();
        $('#editMode').show();
        $('#editToggleBtn').removeClass('btn-outline-primary').addClass('btn-warning');
        $('#editBtnText').text('取消编辑');
        $('#saveBtn').show();
        $('#editModeIndicator').fadeIn();

        const iframeContent = getIframeContent();
        const contentToEdit = iframeContent || currentGeneratedContent;

        setTimeout(function() {
            initSummernote(contentToEdit);
        }, 50);
    } else {
        $('#viewMode').show();
        $('#editMode').hide();
        $('#editToggleBtn').removeClass('btn-warning').addClass('btn-outline-primary');
        $('#editBtnText').text('编辑');
        $('#saveBtn').hide();
        $('#editModeIndicator').fadeOut();

        if (summernoteInitialized) {
            const editedContent = $('#summernoteContainer').summernote('code');
            if (editedContent !== currentGeneratedContent) {
                if (confirm('有未保存的修改,是否保存?')) {
                    saveEditedRecord();
                } else {
                    displayInIframe(currentGeneratedContent);
                }
            }
            destroySummernote();
        }
    }
}

6. 修改 saveEditedRecord 函数

function saveEditedRecord() {
    if (!summernoteInitialized) return;

    const editedContent = $('#summernoteContainer').summernote('code');
    if (!editedContent || editedContent.trim() === '') {
        alert('内容不能为空');
        return;
    }

    $('#saveBtn').html('<i class="fas fa-spinner fa-spin me-1"></i>保存中...').prop('disabled', true);

    const isNewRecord = currentRecordId && currentRecordId.startsWith('temp_');
    const url = isNewRecord ? "/ai_assistant/api/save" : "/ai_assistant/api/save_edited";
    const data = isNewRecord 
        ? { content: editedContent, template_type: currentTemplateType }
        : { record_id: currentRecordId, content: editedContent };

    $.ajax({
        url: url,
        type: "POST",
        contentType: "application/json",
        data: JSON.stringify(data),
        success: function(response) {
            if (response.status === "success") {
                if (isNewRecord) {
                    currentRecordId = response.data.id;
                }
                currentGeneratedContent = editedContent;
                displayInIframe(editedContent);
                
                if (typeof toastr !== 'undefined') {
                    toastr.success('保存成功');
                }

                loadHistory();

                if (isEditMode) {
                    toggleEditMode();
                }
            }
        },
        complete: function() {
            $('#saveBtn').html('<i class="fas fa-save me-1"></i>保存修改').prop('disabled', false);
        }
    });
}

📊 方案对比

维度 方案1 方案2 方案3 方案4 (iframe)
代码修改量 1个文件 1个文件 多个文件 几行代码
维护成本
样式隔离 部分 部分 部分 完全
侵入性
部署要求 需重启 需重启 无需重启 无需重启

🎯 总结

为什么iframe是最佳方案?

  1. 真正的样式隔离:iframe创建独立的渲染上下文
  2. 零侵入性:不需要修改任何现有代码
  3. 即插即用:几分钟就能解决问题
  4. 维护成本低:设置好就不用管

经验教训

  • 先想最简单的方案:不要一上来就想改代码
  • KISS原则永不过时:简单的方案往往是最好的
  • 技术没有高低贵贱:iframe不是过时,是合适

💭 结语

一个iframe,几行代码,就解决了复杂的样式冲突问题,维护成本几乎为零。在开始复杂的设计之前,先想想有没有更简单的方案。因为简单,才是终极的复杂

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐