利用AI生成数据报告

89 阅读7分钟

1.1 需求

报表里的数据太多,用户很难一眼看出重点,也不方便快速做分析。

为了提升效率,在报表页面加入 AI 分析功能,让用户点击一下就能生成数据概览报告,快速了解当前筛选条件下的核心结论和趋势。

1.2 方案

前端只负责展示,后端调用大模型生成分析结果

流程:

  1. 前端只传递报表筛选参数给后端
  2. 后端查询数据,拼接提示词后调用大模型(要求结果以 markdown 格式返回)
  3. 前端将 markdown 结果转换成 html,最终渲染出来

优点:安全,Prompt 不会被篡改

缺点:接口响应较慢(未来改成流式输出的形式解决)

为什么 markdown 要转换成 html 再处理?

因为 html 能自定义结构和样式,和组件结合,方便扩展功能。

1.3 代码

(实际开发左侧输入就是后端返回的内容,可以调试样式和功能)

<template>
  <div class="markdown-container">
    <div class="editor-pane">
      <h3>Markdown 输入</h3>
      <textarea v-model="rawMarkdown" class="markdown-textarea"></textarea>
    </div>

    <div class="preview-pane">
      <div class="controls">
        <h3>渲染输出</h3>
        <div>
          <button @click="handleCopy">复制为富文本</button>
          <button @click="handleExportPdf">下载为 PDF</button>
        </div>
      </div>
      <div ref="mdContentRef" class="markdown-body" v-html="safeHtml"></div>
    </div>
  </div>
</template>

<script setup lang="ts">
// --- 1. 导入依赖 ---
import { ref, computed } from 'vue';

// 将 Markdown 解析为 HTML 的核心库
import MarkdownIt from 'markdown-it';

// 用于净化 HTML 的库,可防止在渲染用户生成内容时发生 XSS 攻击
import sanitizeHtml from 'sanitize-html';

// 用于代码块语法高亮的库
import hljs from 'highlight.js';

// 用于将 HTML 元素导出为 PDF 文件的库
import html2pdf from 'html2pdf.js';

// 导入代码高亮和 Markdown 渲染所需的样式表
import 'highlight.js/styles/github.css';
import 'github-markdown-css/github-markdown.css';

// --- 2. 状态管理 (响应式变量) ---

/**
 * 一个 ref, 用于存储原始的 Markdown 字符串。
 * 它可以通过文本域进行编辑,任何更改都会触发预览区的重新渲染。
 * 包含多种 Markdown 元素以展示所有功能。
 */
const rawMarkdown = ref<string>(
  `# 医院业务运营分析报告

## 1. 执行摘要

本报告概述了上一季度的关键运营指标。我们的患者就诊量增长了 **15%**,但每次就诊的平均收入下降了 **5%**。

---

## 2. 关键指标

| 指标 | 2025年第二季度 | 2025年第三季度 | 变化 |
| :--- | :---: | :---: | :---: |
| 总患者就诊量 | 12,500 | 14,375 | +15% |
| 平均每次就诊收入 | ¥1,050 | ¥997.50 | -5% |
| 床位占用率 | 85% | 92% | +7% |

## 3. 科室表现

### 3.1. 门诊部

门诊部的就诊量增长最为显著。

- **心脏科**: 增长 20%
- **儿科**: 增长 12%
- *骨科*: 增长 8%

### 3.2. 住院服务

床位占用率很高,表明有扩建需求。更多信息请访问 [https://google.com](https://google.com)。

## 4. 数据处理代码示例

这是用于本次分析的 Python 代码片段:
\`\`\`python
import pandas as pd

def analyze_data(file_path):
    df = pd.read_csv(file_path)
    # 计算关键指标
    total_visits = df['visits'].sum()
    print(f"总就诊量: {total_visits}")
    return total_visits

analyze_data('hospital_data.csv')
\`\`\`
`
);

/**
 * 一个模板 ref,用于获取对渲染后 HTML 容器 DOM 元素的直接引用。
 * 这对于 PDF 导出和复制功能是必需的,因为它们需要操作真实的 DOM。
 */
const mdContentRef = ref<HTMLDivElement | null>(null);

// --- 3. MARKDOWN 解析器配置 ---

/**
 * markdown-it 解析器的实例。
 * 它在组件 setup 阶段只定义一次,因为其配置是静态的,
 * 无需将其设为响应式的 'ref'。
 */
const mdParser = new MarkdownIt({
  html: true, // 启用源文件中的 HTML 标签。后续会进行安全净化。
  linkify: true, // 将类似 URL 的文本自动转换为链接。
  typographer: true, // 启用智能引号和其他排版优化。
  breaks: true, // 将段落中的 '\n' 转换成 <br> 标签。

  /**
   * 用于代码块的语法高亮函数。
   * 它集成了 highlight.js 来应用样式。
   * @param str 代码块内的代码字符串。
   * @param lang 指定的语言 (例如, \`\`\`javascript)。
   */
  highlight: (str: string, lang: string) => {
    if (lang && hljs.getLanguage(lang)) {
      try {
        // 返回高亮后的 HTML,并用 <pre><code> 块包裹
        return `<pre class="hljs"><code>${
          hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
        }</code></pre>`;
      } catch (err) {
        console.error('代码高亮失败:', err);
      }
    }
    // 如果没有指定语言或发生错误,则将代码作为纯文本返回,
    // 并进行正确的 HTML 转义,以防止其被解析为 HTML。
    return `<pre class="hljs"><code>${mdParser.utils.escapeHtml(str)}</code></pre>`;
  },
});

// --- 4. 用于安全渲染 HTML 的计算属性 ---

/**
 * 一个计算属性,它能安全地将原始 Markdown 转换为经过净化的 HTML。
 * 每当 'rawMarkdown' 的值发生变化时,它都会自动重新计算。
 */
const safeHtml = computed<string>(() => {
  if (!rawMarkdown.value) return '';

  // 步骤 1: 将 Markdown 字符串解析为原始 HTML 字符串。
  const rawHtml = mdParser.render(rawMarkdown.value);

  // 步骤 2: 净化原始 HTML 以防止 XSS 跨站脚本攻击。
  // 如果 Markdown 输入可能来自不受信任的源,这一步至关重要。
  return sanitizeHtml(rawHtml, {
    // 定义允许的 HTML 标签白名单。
    allowedTags: [
      'h1',
      'h2',
      'h3',
      'h4',
      'h5',
      'h6',
      'p',
      'ul',
      'ol',
      'li',
      'strong',
      'em',
      'del',
      'table',
      'thead',
      'tbody',
      'tr',
      'th',
      'td',
      'pre',
      'code',
      'a',
      'br',
      'hr',
      'blockquote',
    ],
    // 为特定标签定义允许的属性白名单。
    allowedAttributes: {
      a: ['href', 'target', 'rel'],
      // highlight.js 需要 'class' 属性来为代码块设置样式。
      pre: ['class'],
      code: ['class'],
      th: ['align'], // 允许表格头部对齐。
      td: ['align'], // 允许表格单元格对齐。
    },
    // 定义允许的 URL 协议,以防止 'javascript:...' 等恶意链接。
    allowedSchemes: ['http', 'https', 'mailto'],

    /**
     * 一个在标签净化后对其进行转换的函数。
     * 在这里,我们用它来为所有外部链接添加安全属性。
     */
    transformTags: {
      a: (tagName, attribs) => {
        // 检查链接是否为外部链接 (以 http 开头)。
        if (attribs.href && attribs.href.startsWith('http')) {
          attribs.target = '_blank'; // 在新标签页中打开。
          attribs.rel = 'noopener noreferrer'; // 防止安全漏洞。
        }
        return { tagName, attribs };
      },
    },
  });
});

// --- 5. 事件处理器 (用户操作) ---

/**
 * 处理"复制为富文本"按钮的点击事件。
 * 使用现代 Clipboard API 将渲染后的 HTML 复制到剪贴板,同时保留其格式。
 */
const handleCopy = async () => {
  if (!mdContentRef.value) {
    alert('渲染内容未找到。');
    return;
  }

  try {
    const html = mdContentRef.value.innerHTML;
    const plainText = mdContentRef.value.textContent || '';

    // 使用现代 Clipboard API 进行复制
    if (navigator.clipboard && window.ClipboardItem) {
      // 创建包含富文本和纯文本的 ClipboardItem
      const clipboardItem = new ClipboardItem({
        'text/html': new Blob([html], { type: 'text/html' }),
        'text/plain': new Blob([plainText], { type: 'text/plain' }),
      });

      await navigator.clipboard.write([clipboardItem]);
    } else {
      // 降级方案:使用传统的 execCommand 方法
      const tempDiv = document.createElement('div');
      tempDiv.style.position = 'fixed';
      tempDiv.style.left = '-9999px';
      tempDiv.style.top = '0';

      tempDiv.style.width = '800px';
      tempDiv.innerHTML = html;
      document.body.appendChild(tempDiv);

      const range = document.createRange();
      range.selectNodeContents(tempDiv);
      const selection = window.getSelection();
      if (selection) {
        selection.removeAllRanges();
        selection.addRange(range);
      }

      const success = document.execCommand('copy');
      if (!success) throw new Error('复制命令失败。');

      document.body.removeChild(tempDiv);
      if (selection) {
        selection.removeAllRanges();
      }
    }

    alert('内容复制成功!');
  } catch (err) {
    console.error('复制内容失败:', err);
    alert('复制失败,请重试。');
  }
};

/**
 * 处理"下载为 PDF"按钮的点击事件。
 * 使用 html2pdf.js 将渲染后的内容转换为 PDF 文件并触发下载。
 */
const handleExportPdf = () => {
  const contentElement = mdContentRef.value;
  if (!contentElement) {
    alert('渲染内容未找到。');
    return;
  }

  try {
    const filename = 'AI分析报告.pdf';

    // html2pdf 配置选项
    const options = {
      margin: 15, // 统一边距 (单位: 毫米)
      filename: filename,
      image: {
        type: 'jpeg' as const,
        quality: 0.95, // 提高图片质量
      },
      html2canvas: {
        scale: 3, // 更高的缩放比例以获得更好的分辨率
        useCORS: true,
        backgroundColor: '#ffffff', // 设置白色背景
      },
      jsPDF: {
        unit: 'mm',
        format: 'a4',
        orientation: 'portrait' as const,
      },
    };

    // 链式调用来设置选项、指定源元素并保存 PDF。
    html2pdf().set(options).from(contentElement).save();

    // 注意:下载会在处理后开始,这可能需要一点时间。
    // 在实际应用中,可以用一个更复杂的“加载中”状态来代替。
    // alert('PDF 导出流程已开始,您的下载稍后会自动开始。');
  } catch (err) {
    console.error('PDF 导出失败:', err);
    alert('PDF 导出失败,请重试。');
  }
};
</script>

<style scoped>
/* 主容器 Flexbox 布局 */
.markdown-container {
  display: flex;
  gap: 20px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
  height: 90vh; /* 使其占据大部分视口高度 */
  padding: 20px;
}

/* 编辑区和预览区样式 */
.editor-pane,
.preview-pane {
  flex: 1; /* 每个区域占据一半宽度 */
  display: flex;
  flex-direction: column;
  overflow: hidden; /* 隐藏溢出内容 */
  border: 1px solid #d0d7de;
  border-radius: 6px;
}

.editor-pane h3,
.preview-pane .controls {
  padding: 10px 16px;
  margin: 0;
  background-color: #f6f8fa;
  border-bottom: 1px solid #d0d7de;
  font-size: 16px;
}

.preview-pane .controls {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-shrink: 0; /* 防止控制栏收缩 */
}

/* Markdown 输入文本域样式 */
.markdown-textarea {
  flex-grow: 1;
  padding: 16px;
  border: none;
  resize: none;
  outline: none;
  font-family: 'Courier New', Courier, monospace;
  font-size: 14px;
  line-height: 1.5;
}

/* 渲染输出区域样式 */
.markdown-body {
  padding: 20px;
  overflow-y: auto; /* 允许长内容滚动 */
  flex-grow: 1;
}

/* 操作按钮样式 */
.controls button {
  margin-left: 10px;
  padding: 5px 12px;
  font-size: 14px;
  cursor: pointer;
  border: 1px solid #d0d7de;
  border-radius: 6px;
  background-color: #f6f8fa;
}

.controls button:hover {
  background-color: #e9edf7;
}
</style>

1.4 结束

2025/10/03:发布文章