跳到主要内容

核心技术实现拆解


目录

  1. Excel 文件上传功能实现
  2. Google Sheet 链接处理实现
  3. 数据解析与规范化
  4. 浏览器本地存储机制
  5. 幻灯片式 HTML 渲染与图片导出
  6. 会话 ZIP 导出与导入

Excel 文件上传功能实现

功能概述

Excel 文件上传是系统三大核心功能的基础数据输入方式之一。用户可以通过浏览器界面选择并上传 .xlsx.xls 格式的 Excel 文件,系统会自动解析文件内容,提取所需的数据工作表。

技术实现流程

1. 前端文件选择组件

系统使用 FileUpload 组件(位于 src/components/FileUpload.tsx)来处理文件选择。该组件基于 Semi Design 的 Upload 组件,但通过 customRequest 函数拦截了默认的网络上传行为,改为直接在前端处理文件。

核心代码逻辑

// 自定义上传请求,不实际发送网络请求
const customRequest = ({ file, fileInstance, onProgress, onSuccess, onError }) => {
try {
// 获取真实的 File 对象
const realFile = fileInstance || (file as any)?.originFile || (file as any);

// 直接调用父组件传入的回调函数,传递文件对象
onFileSelect(realFile as File);

// 模拟上传进度(用于 UI 反馈)
let loaded = 0;
const total = 100;
timerRef.current = window.setInterval(() => {
loaded = Math.min(loaded + 20, total);
onProgress && onProgress({ total, loaded });
if (loaded === total) {
clearInterval(timerRef.current);
onSuccess && onSuccess({});
}
}, 200);
} catch (e) {
onError && onError({ status: 500 }, e as Event);
}
};

设计要点

  • 无网络上传:文件不会实际发送到服务器,所有处理都在浏览器端完成
  • 进度模拟:为了提供良好的用户体验,模拟了上传进度条
  • 直接传递:文件对象直接传递给父组件,由父组件决定如何处理

2. 文件读取与解析

当文件选择完成后,父组件(如 GeoAnalysisPageAudienceSignalPage)会调用相应的解析函数。系统使用 XLSX 库(SheetJS)来解析 Excel 文件。

解析流程

  1. 文件读取:使用浏览器的 FileReader API 将文件读取为 ArrayBuffer
  2. 工作簿解析:使用 XLSX.read()ArrayBuffer 解析为工作簿对象
  3. 工作表提取:根据功能需求,提取特定的工作表(如 sublocationsinput 等)
  4. 数据转换:使用 XLSX.utils.sheet_to_json() 将工作表数据转换为 JSON 格式

核心代码示例(以市场机会分析为例):

export const parseExcelFile = (file: File, onProgress: LogCallback): Promise<RawData> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();

reader.onload = (event: ProgressEvent<FileReader>) => {
if (!event.target?.result) {
return reject(new Error("文件读取失败。"));
}

try {
// 1. 将文件内容转换为 Uint8Array
const data = new Uint8Array(event.target.result as ArrayBuffer);

// 2. 使用 XLSX 库解析工作簿
const workbook = XLSX.read(data, { type: 'array', cellDates: true });

// 3. 提取行业名称(从 input 工作表的 B4 单元格)
const inputSheet = workbook.Sheets[workbook.SheetNames[0]];
const industryName = inputSheet['B4']?.v || '';

// 4. 查找目标工作表(如 'sublocations')
const targetSheetName = workbook.SheetNames.find(name =>
name.toLowerCase().includes('sublocation')
) || workbook.SheetNames[1];

// 5. 将工作表数据转换为 JSON
const dataSheet = workbook.Sheets[targetSheetName];
const jsonData: any[] = XLSX.utils.sheet_to_json(dataSheet);

// 6. 数据规范化处理
const locationSheet = jsonData.map(row => ({
// 清理和转换数据字段
category: normalizeCategory(row['品类名称'] || row['Category']),
country: row['国家名称'] || row['Country'],
cpc: parseFloat(String(row['CPC'] || 0)),
// ... 其他字段
}));

resolve({
industryName,
popPeriod: extractPopPeriod(jsonData),
locationSheet,
});
} catch (error) {
reject(new Error(`解析 Excel 文件失败: ${error.message}`));
}
};

reader.onerror = () => {
reject(new Error('文件读取失败'));
};

// 开始读取文件
reader.readAsArrayBuffer(file);
});
};

错误处理

  • 文件格式验证:检查文件扩展名和 MIME 类型
  • 工作表存在性检查:确保必需的工作表存在
  • 数据完整性验证:检查必需字段是否存在
  • 异常捕获:使用 try-catch 捕获所有可能的解析错误

Google Sheet 链接处理实现

功能概述

除了上传本地 Excel 文件,系统还支持用户粘贴 Google Sheet 链接,自动读取云端数据。这个功能需要与 Google OAuth 2.0 认证和 Google Sheets API 集成。

技术实现流程

1. URL 验证与 Spreadsheet ID 提取

系统使用 GoogleSheetInput 组件(位于 src/components/GoogleSheetInput.tsx)来处理用户输入的 Google Sheet 链接。

URL 格式验证

// 验证 Google Sheet URL 格式
export function isValidGoogleSheetsUrl(url: string): boolean {
const pattern = /^https?:\/\/docs\.google\.com\/spreadsheets\/d\/[a-zA-Z0-9-_]+/;
return pattern.test(url);
}

// 从 URL 中提取 Spreadsheet ID
export function extractSpreadsheetId(url: string): string | null {
const match = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
return match ? match[1] : null;
}

支持的 URL 格式

  • https://docs.google.com/spreadsheets/d/SPREADSHEET_ID/edit
  • https://docs.google.com/spreadsheets/d/SPREADSHEET_ID/edit#gid=0
  • https://docs.google.com/spreadsheets/d/SPREADSHEET_ID

2. 用户认证与权限检查

在读取 Google Sheet 数据之前,系统需要:

  1. 检查用户登录状态:确保用户已通过 Google OAuth 登录
  2. 验证访问令牌:检查 OAuth token 是否有效且未过期
  3. 检查 Sheet 访问权限:验证用户是否有权限访问该 Sheet

权限检查实现src/services/googleSheetsService.ts):

export async function checkSheetAccess(
spreadsheetId: string,
accessToken: string
): Promise<{ hasAccess: boolean; error?: string; metadata?: any; isScopeIssue?: boolean }> {
try {
// 调用 Google Sheets API v4 获取 Sheet 元数据
const response = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}?fields=properties,sheets`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
},
}
);

if (response.status === 404) {
return { hasAccess: false, error: 'Sheet 不存在或已被删除' };
}

if (response.status === 403) {
// 检查是否是权限范围问题
const errorData = await response.json();
const isScopeIssue = errorData.error?.message?.includes('insufficient authentication scopes');
return {
hasAccess: false,
error: '没有访问权限',
isScopeIssue
};
}

if (response.status === 401) {
return {
hasAccess: false,
error: '访问令牌已过期,请重新登录',
isScopeIssue: false
};
}

if (!response.ok) {
return { hasAccess: false, error: `访问失败: ${response.statusText}` };
}

const metadata = await response.json();
return { hasAccess: true, metadata };
} catch (error) {
return { hasAccess: false, error: `网络错误: ${error.message}` };
}
}

3. 数据读取与转换

如果权限检查通过,系统会读取 Sheet 数据并转换为 Excel 兼容格式。

数据读取流程

export async function readSheetAsFile(
spreadsheetId: string,
accessToken: string,
onProgress?: (message: string) => void
): Promise<File> {
// 1. 获取 Sheet 元数据(所有工作表列表)
onProgress?.('正在获取工作表列表...');
const metadataResponse = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}`,
{ headers: { 'Authorization': `Bearer ${accessToken}` } }
);
const metadata = await metadataResponse.json();

// 2. 使用 XLSX 库创建工作簿
const workbook = XLSX.utils.book_new();

// 3. 遍历每个工作表,读取数据
for (const sheet of metadata.sheets) {
const sheetName = sheet.properties.title;
onProgress?.(`正在读取工作表: ${sheetName}...`);

// 调用 Google Sheets API 读取工作表数据
const dataResponse = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(sheetName)}`,
{ headers: { 'Authorization': `Bearer ${accessToken}` } }
);
const data = await dataResponse.json();

// 4. 将数据转换为 XLSX 格式
const worksheet = XLSX.utils.aoa_to_sheet(data.values || []);

// 5. 清理工作表名称(Excel 限制:31 字符,不能包含特殊字符)
const safeSheetName = sanitizeExcelSheetName(sheetName);
XLSX.utils.book_append_sheet(workbook, worksheet, safeSheetName);
}

// 6. 将工作簿转换为二进制数据
const excelBuffer = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' });

// 7. 创建 File 对象(与上传的 Excel 文件格式一致)
const blob = new Blob([excelBuffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const fileName = `${metadata.properties.title || 'sheet'}.xlsx`;

return new File([blob], fileName, {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
}

关键设计点

  • 工作表名称清理:Excel 工作表名称有长度限制(31 字符)和字符限制,系统会自动清理
  • 数据格式统一:最终转换为标准的 Excel 文件格式,与本地文件上传的处理流程完全一致
  • 进度反馈:通过 onProgress 回调函数实时反馈读取进度

4. 自动重新认证机制

如果用户在输入 Sheet 链接时尚未登录,系统会:

  1. 保存待处理的请求:将 Sheet URL 保存到 sessionStorage
  2. 触发登录流程:引导用户完成 Google OAuth 登录
  3. 自动恢复处理:登录完成后,自动从 sessionStorage 恢复并继续处理

实现代码

// 保存待处理的 Sheet 请求
const rememberPendingValidationHelen = useCallback((url: string) => {
const payload = {
sheetUrl: url,
timestamp: Date.now(),
};
sessionStorage.setItem(PENDING_SHEET_KEY_HELEN, JSON.stringify(payload));
}, []);

// 登录完成后自动恢复
useEffect(() => {
if (user?.access_token && shouldAutoValidateHelen) {
handleValidateAndLoad();
setShouldAutoValidateHelen(false);
}
}, [user?.access_token, shouldAutoValidateHelen]);

数据解析与规范化

功能概述

所有输入 InsightHub 的数据,无论是通过 Excel 文件上传还是通过 Google Sheet 链接加载,都源自 Google 内部的 Connect Benchmark (CBX) 系统。CBX 导出的数据具有固定的结构和全英文的列名,因此,系统内置了一套强大而智能的解析与规范化流程,以确保后续的 AI 分析能够准确、高效地进行。

本章节将详细拆解三大核心功能在处理 CBX 数据时的具体实现逻辑。


市场机会分析的数据解析

此功能需要处理两种来自 CBX 的数据结构:单品类报告和多品类报告。系统被设计为能够自动识别这两种结构并作相应处理。

1. 关键信息提取

无论单品类还是多品类,系统都会首先从第一个工作表(通常名为 Report Definition and Compliance)中提取基础信息:

  • 品类/行业名称:从 B4 单元格读取。在单品类报告中,这直接作为分析的主品类;在多品类报告中,它作为一个总体的行业名称。
  • 分析周期 (PoP Period):从数据工作表(Sublocations)的第一行数据中,提取 Start Date, End Date, Comparison Start DateComparison End Date 这几列的值,并将其格式化为标准的周期描述字符串。

2. 数据工作表定位

系统会智能地定位包含核心数据的工作表:

  1. 优先策略:首先,系统会查找工作表名称中包含关键词 Sublocations 的工作表。
  2. 备用策略:如果未找到,系统会默认使用文件中的第二个工作表作为数据源。

这种双重策略确保了即使工作表名称被轻微修改,系统也能大概率找到正确的数据。

3. 单品类 vs. 多品类智能识别

这是该解析流程中最核心的设计之一。在读取了 Sublocations 工作表的数据后,系统会检查数据的第一行是否存在名为 Query Set Name 的列:

  • 如果存在 Query Set Name
    • 系统判定这是一个多品类报告。
    • 每个数据行的品类归属将由该行 Query Set Name 列的值决定。
    • 系统会自动忽略 Query Set NameAll selected query entities 的汇总行,只处理具体的品类数据。
  • 如果不存在 Query Set Name
    • 系统判定这是一个单品类报告。
    • 所有数据行的品类归属,都将统一使用之前从第一个工作表 B4 单元格读取的名称。

这个自动化识别机制,使得用户无需关心 CBX 导出的具体报告类型,系统都能正确解析,极大地提升了用户体验。

4. 数据规范化

最后,系统会将所有相关列(如 Geo Name, Clicks, CPC 等)的数据进行类型转换和清理,形成一个标准化的数据集,供后续 AI 分析使用。


人群信号分析与受众画像的数据解析

这两个功能共享同一种源自 CBX 的复杂数据文件,这种文件通常包含数十个工作表。为了应对这种复杂性,系统设计了一套更为智能和鲁棒的“白名单”式的解析策略。

1. “白名单”筛选机制

系统并不需要处理文件中的所有工作表,而是只关心一个预定义的“必需工作表清单”。以人群信号分析为例,这个清单包括:

  • input
  • user_behavior_Prod_Affinity_Search
  • user_behavior_Prod_InMarket_Search
  • user_behavior_AppAffinities
  • AffinityGeminiOutput

所有不在此清单上的工作表都会被自动忽略

2. 智能工作表匹配 (findSheetMatch)

由于 CBX 导出的文件名可能超长,导致在 Excel 中被自动截断(最长31个字符),或者因为包含特殊符号(如 :)而在从 Google Sheet 转换时被重命名,简单的名称匹配是不可靠的。

为此,我们实现了一个强大的 findSheetMatch 函数,它会按以下顺序和策略来查找每一个必需的工作表:

  1. 精确匹配:首先尝试不区分大小写的精确名称匹配。
  2. 清理后精确匹配:将期望名称和实际名称都按照 Excel 的命名规范进行清理(例如,替换特殊字符),然后再进行精确匹配。这解决了 Google Sheet 中特殊字符导致的问题。
  3. 前缀匹配:如果期望的名称长度超过31个字符,系统会用其前31个字符与实际工作表名进行匹配,以应对 Excel 的自动截断问题。
  4. 关键词匹配:如果以上都失败,系统会使用正则表达式,根据每个工作表名称的核心关键词(如 Prod_AffinityAppAffinities)进行模糊匹配。

这一系列“降级”匹配策略,确保了即使 CBX 导出的文件名有各种预期内或外的变化,系统也能极大概率地定位到正确的工作表。

3. 拆分工作表自动合并

在某些情况下,CBX 会将一个大的数据表(如 ...Affinity_Search)拆分为两个独立的工作表(例如,...Affinity_Vir...Affinity_Sea)。

系统的解析逻辑能够智能地识别这种情况:如果在必需清单中找不到 ..._Search 表,它会自动去查找是否存在对应的 ..._Vir..._Sea 表。如果两者都存在,系统会将这两个表的数据在内存中自动合并,再进行后续处理。

4. 数据提取

  • 对于人群信号分析:在找齐所有必需的工作表后,系统将它们的数据转换为 JSON 格式,分别存储,供后续 AI 生成 UAC、PMax 和 Demand Gen 的推荐方案。
  • 对于受众画像幻灯片:该功能只关心 AffinityGeminiOutput 工作表。找到该表后,系统会逐行读取 A 列的所有单元格内容,直到遇到空单元格为止。每个单元格的完整文本块被视为一个独立的 Persona 数据,等待 AI 的进一步深度解析。

通过以上这些精心设计的解析策略,InsightHub 能够稳定、可靠地处理结构复杂的 CBX 数据,将数据准备阶段的复杂性完全对用户屏蔽,体现了项目在技术细节上的深度思考和卓越实现。


浏览器本地存储机制

功能概述

系统使用浏览器的 IndexedDB(通过 Dexie.js 库)来存储用户的分析结果和工作流历史。这是一个重要的设计决策,避免了使用中央数据库的复杂性。

为什么选择浏览器本地存储?

1. 开发复杂度对比

使用中央数据库(如 PostgreSQL)的复杂度

  • 数据库设计:需要设计表结构、字段类型、索引、关联关系等
  • 数据迁移:每次数据结构变化都需要编写迁移脚本
  • API 开发:需要开发完整的 CRUD(创建、读取、更新、删除)API
  • 认证授权:需要实现用户认证、权限控制、数据隔离
  • 备份恢复:需要定期备份数据库,处理数据恢复
  • 性能优化:需要优化查询性能、处理并发访问
  • 运维成本:需要维护数据库服务器、监控、扩容等

使用浏览器本地存储的优势

  • 零服务器成本:不需要数据库服务器,降低运维成本
  • 数据隐私:用户数据完全存储在本地,提高隐私保护
  • 离线可用:即使网络断开,用户仍可查看历史结果
  • 开发简单:无需设计复杂的数据库结构和 API
  • 快速迭代:数据结构变化时,只需更新前端代码

2. 技术实现

系统使用 Dexie.js 作为 IndexedDB 的封装库,提供了更友好的 API。

数据库结构定义src/services/workflowStorage.ts):

import Dexie, { Table } from 'dexie';

// 定义数据库结构
class WorkflowDatabase extends Dexie {
workflows!: Table<UnifiedWorkflow, string>;

constructor() {
super('InsightHubWorkflows');
this.version(1).stores({
workflows: 'id, workflowType, creatorEmail, createdAt, dataSourceType',
});
}
}

const db = new WorkflowDatabase();

数据存储流程

  1. 图像提取:从结果数据中提取 Base64 编码的图像,转换为 Blob 对象单独存储
  2. 数据清理:移除结果数据中的 Base64 图像数据,减小存储体积
  3. 元数据构建:构建包含工作流类型、创建时间、数据源等信息的元数据对象
  4. 存储执行:将清理后的数据和元数据存储到 IndexedDB

核心代码

export async function saveWorkflow(params: SaveWorkflowParams): Promise<string> {
const workflowId = nanoid();

// 1. 提取图像(Base64 -> Blob)
const { images, imageMetadata, cleanedData } = extractImagesFromResultData(
params.workflowType,
params.resultData
);

// 2. 构建工作流对象
const workflow: UnifiedWorkflow = {
id: workflowId,
workflowType: params.workflowType,
resultData: cleanedData,
outputLanguage: params.outputLanguage,
// ... 其他元数据
imageMetadata, // 图像元数据(不包含实际图像数据)
};

// 3. 存储到 IndexedDB
await db.workflows.add(workflow);

// 4. 单独存储图像(使用 IndexedDB 的 Blob 支持)
for (let i = 0; i < images.length; i++) {
const imageKey = `${workflowId}_image_${i}`;
await db.workflows.update(workflowId, {
[`image_${i}`]: images[i], // Dexie 自动处理 Blob
});
}

return workflowId;
}

数据加载流程

  1. 加载工作流:从 IndexedDB 加载工作流对象
  2. 恢复图像:将存储的 Blob 图像转换为 Object URL,重新插入到结果数据中
  3. 返回完整数据:返回包含恢复图像的结果数据

核心代码

export async function loadWorkflowWithParsedData(
workflowId: string
): Promise<{
workflow: UnifiedWorkflow;
resultData: any;
metadata?: Record<string, any>;
} | undefined> {
// 1. 加载工作流
const workflow = await db.workflows.get(workflowId);
if (!workflow) {
return undefined;
}

// 2. 恢复图像(Blob -> Object URL)
const resultDataWithImages = restoreImagesToResultData(workflow, workflow.resultData);

return {
workflow,
resultData: resultDataWithImages,
metadata: {
// ... 其他元数据
},
};
}

存储限制与注意事项

  • 存储配额:浏览器通常为每个域名分配 5-10 GB 的存储空间
  • 数据持久性:清除浏览器数据会删除所有存储的工作流
  • 跨设备同步:本地存储不会自动同步到其他设备
  • 备份建议:建议用户定期导出 ZIP 文件进行备份

幻灯片式 HTML 渲染与图片导出

功能概述

系统将分析结果渲染为"幻灯片式"的 HTML 页面,然后可以导出为图片或 PPTX 文件。这个设计避免了直接生成可编辑的 Google Slides 的复杂性。

为什么选择 HTML 渲染?

设计权衡

直接生成 Google Slides 的挑战

  • 模板复杂性:需要精确控制每个元素的位置、样式、字体等
  • API 限制:Google Slides API 的功能和灵活性有限
  • 编辑需求:生成的幻灯片不需要可编辑,只需要展示和导出
  • 样式控制:HTML/CSS 提供了更灵活的样式控制能力

HTML 渲染的优势

  • 样式灵活:可以使用完整的 CSS 功能,精确控制样式
  • 开发效率:使用熟悉的 Web 技术栈,开发速度快
  • 易于调整:修改样式只需调整 CSS,无需重新生成幻灯片
  • 导出简单:可以轻松导出为图片或转换为 PPTX

技术实现

1. HTML 结构设计

系统使用 React 组件来构建幻灯片式的 HTML 结构。

示例(市场机会分析的气泡图幻灯片):

<div ref={slideContainerRef} className="slide-container">
{/* 副标题 */}
<div className="slide-subtitle">{subtitle}</div>

{/* 主标题 */}
<h1 className="slide-title">{title}</h1>

{/* 气泡图 */}
<div ref={chartContainerRef} className="chart-container">
{/* ECharts 图表渲染 */}
</div>

{/* 优先级摘要 */}
<div className="priority-section">
<div className="priority-1">
<h3>{priority1Title}</h3>
<p>{priority1Description}</p>
{/* ... 更多内容 */}
</div>
<div className="priority-2">
{/* ... */}
</div>
</div>

{/* 页脚 */}
<div className="slide-footer">
<span>分析周期: {popPeriod}</span>
</div>
</div>

2. CSS 样式设计

系统使用 CSS 来模拟 Google Slides 的视觉效果。

关键样式

.slide-container {
width: 1920px; /* 标准幻灯片宽度 */
height: 1080px; /* 标准幻灯片高度 */
background: #ffffff;
padding: 80px 120px;
box-sizing: border-box;
font-family: 'Roboto', sans-serif;
position: relative;
}

.slide-title {
font-size: 48px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 40px;
}

.chart-container {
width: 100%;
height: 600px;
margin: 40px 0;
}

3. 图片导出实现

系统使用 html-to-image 库将 HTML 元素转换为图片。

核心代码src/components/BubbleChart.tsx):

import { toPng, toBlob } from 'html-to-image';

// 导出图表为 DataURL(用于复制到剪贴板)
getChartAsDataURL: () => {
if (chartContainerRef.current) {
return toPng(chartContainerRef.current, {
quality: 1.0,
pixelRatio: 2, // 提高分辨率
backgroundColor: '#ffffff',
});
}
return Promise.resolve(null);
},

// 导出图表为 Blob(用于下载或 PPTX 生成)
getChartAsBlob: async () => {
if (chartContainerRef.current) {
const dataUrl = await toPng(chartContainerRef.current, {
quality: 1.0,
pixelRatio: 2,
backgroundColor: '#ffffff',
});
if (dataUrl) {
const response = await fetch(dataUrl);
return response.blob();
}
}
return null;
},

导出参数说明

  • quality:图片质量(0-1),1.0 为最高质量
  • pixelRatio:像素比,2 表示生成 2 倍分辨率的图片(更清晰)
  • backgroundColor:背景颜色,确保透明区域有正确的背景

4. PPTX 生成实现

系统使用 pptxgenjs 库将 HTML 内容转换为 PowerPoint 文件。

核心代码src/hooks/usePptxExport.ts):

import PptxGenJS from 'pptxgenjs';

export async function generatePptxAsBlob(
chartsData: ChartCategoryData[],
chartRefs: (BubbleChartHandles | null)[],
popPeriod: string,
outputLanguage: OutputLanguage
): Promise<Blob> {
const pptx = new PptxGenJS();

// 设置幻灯片尺寸(16:9)
pptx.layout = 'LAYOUT_WIDE';
pptx.defineLayout({ name: 'LAYOUT_WIDE', width: 10, height: 5.625 });

// 为每个品类生成一张幻灯片
for (let i = 0; i < chartsData.length; i++) {
const chartData = chartsData[i];
const chartRef = chartRefs[i];

// 获取图表图片(Blob)
const chartBlob = await chartRef?.getChartAsBlob();
if (!chartBlob) continue;

// 将 Blob 转换为 Base64(pptxgenjs 需要)
const chartBase64 = await blobToBase64(chartBlob);

// 创建新幻灯片
const slide = pptx.addSlide();

// 添加副标题
slide.addText(chartData.subtitle, {
x: 0.5,
y: 0.3,
w: 9,
h: 0.4,
fontSize: 14,
color: '666666',
});

// 添加主标题
slide.addText(chartData.title, {
x: 0.5,
y: 0.7,
w: 9,
h: 0.6,
fontSize: 36,
bold: true,
color: '1a1a1a',
});

// 添加图表图片
slide.addImage({
data: chartBase64,
x: 0.5,
y: 1.5,
w: 9,
h: 5,
});

// 添加优先级摘要文本
// ... 更多内容
}

// 生成 PPTX 文件
const blob = await pptx.write({ outputType: 'blob' });
return blob as Blob;
}

会话 ZIP 导出与导入

功能概述

系统支持将完整的分析会话导出为 ZIP 文件,包含所有结果数据、图像、元数据等。用户也可以导入之前导出的 ZIP 文件,恢复分析结果。

导出功能实现

1. ZIP 文件结构

导出的 ZIP 文件包含以下内容:

InsightHub-YYYYMMDD-HHmmss-{UUID}-session-{语言}.zip
├── metadata.yaml # 会话元数据(YAML 格式)
├── detailed-data.xlsx # 详细数据表(XLSX 格式)
├── presentation.pptx # 演示文稿(PPTX 格式,可选)
├── images/ # 图像文件夹
│ ├── chart-{category}-{index}.png
│ ├── slide-{category}-{index}.png
│ └── ...
└── original-data.xlsx # 原始数据文件(如果上传了 Excel)

2. 导出实现

系统使用 JSZip 库来创建 ZIP 文件。

核心代码src/hooks/useSessionExport.ts):

import JSZip from 'jszip';

async function generateSessionZipAsBlob(
result: AnalysisResult,
chartRefs: React.MutableRefObject<(BubbleChartHandles | null)[]>,
user: UserProfile | null,
outputLanguage: string,
originalFile: File | null
): Promise<Blob> {
const zip = new JSZip();
const timestamp = formatTimestamp();

// 1. 添加 YAML 元数据文件
const metadata = {
uuid: result.uuid,
workflowType: 'geo-analysis',
industryName: result.industryName,
outputLanguage: result.output_language || outputLanguage,
createdAt: new Date().toISOString(),
creatorName: user?.name || 'Unknown',
creatorEmail: user?.email || 'unknown@example.com',
// ... 更多元数据
};
zip.file('metadata.yaml', yaml.dump(metadata));

// 2. 添加 XLSX 详细数据文件
const workbook = XLSX.utils.book_new();
for (const categoryData of result.chartsData) {
const worksheet = XLSX.utils.json_to_sheet(categoryData.detailedData);
XLSX.utils.book_append_sheet(workbook, worksheet, categoryData.categoryName);
}
const xlsxBuffer = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' });
zip.file('detailed-data.xlsx', xlsxBuffer);

// 3. 添加 PPTX 文件(如果生成成功)
try {
const pptxBlob = await generatePptxAsBlob(
result.chartsData,
chartRefs.current,
result.popPeriod,
outputLanguage as OutputLanguage
);
zip.file('presentation.pptx', pptxBlob);
} catch (error) {
console.warn('PPTX 生成失败,跳过', error);
}

// 4. 添加所有图像文件
const imagesFolder = zip.folder('images');
for (let i = 0; i < result.chartsData.length; i++) {
const chartRef = chartRefs.current[i];
if (chartRef) {
// 导出图表图片
const chartBlob = await chartRef.getChartAsBlob();
if (chartBlob) {
imagesFolder?.file(`chart-${result.chartsData[i].categoryName}-${i}.png`, chartBlob);
}

// 导出幻灯片图片
const slideBlob = await chartRef.getSlideAsBlob();
if (slideBlob) {
imagesFolder?.file(`slide-${result.chartsData[i].categoryName}-${i}.png`, slideBlob);
}
}
}

// 5. 添加原始数据文件(如果提供)
if (originalFile) {
zip.file(`original-data.${originalFile.name.split('.').pop()}`, originalFile);
}

// 6. 生成 ZIP 文件
return zip.generateAsync({ type: 'blob' });
}

导入功能实现

1. ZIP 文件解析

系统使用 JSZip 库来解析 ZIP 文件。

核心代码

import JSZip from 'jszip';

async function importSessionFromZip(zipFile: File): Promise<{
metadata: any;
resultData: any;
originalFile?: File;
}> {
const zip = await JSZip.loadAsync(zipFile);

// 1. 读取元数据
const metadataFile = zip.file('metadata.yaml');
if (!metadataFile) {
throw new Error('ZIP 文件中缺少 metadata.yaml');
}
const metadataContent = await metadataFile.async('string');
const metadata = yaml.load(metadataContent);

// 2. 读取详细数据(可选)
const dataFile = zip.file('detailed-data.xlsx');
let resultData = null;
if (dataFile) {
const dataBuffer = await dataFile.async('arraybuffer');
const workbook = XLSX.read(dataBuffer, { type: 'array' });
// 解析工作簿数据...
}

// 3. 读取原始数据文件(如果存在)
const originalFile = zip.file(/^original-data\./)?.[0];
let originalFileBlob: Blob | null = null;
if (originalFile) {
originalFileBlob = await originalFile.async('blob');
}

return {
metadata,
resultData,
originalFile: originalFileBlob ? new File([originalFileBlob], 'original-data.xlsx') : undefined,
};
}

2. 数据恢复

导入 ZIP 文件后,系统会:

  1. 验证元数据:检查元数据格式和必需字段
  2. 恢复结果数据:将导入的数据恢复到结果页面
  3. 恢复原始文件:如果 ZIP 包含原始文件,恢复文件对象
  4. 保存到本地存储:将恢复的数据保存到 IndexedDB,以便后续查看

总结

本章节详细介绍了系统的核心技术实现,包括:

  1. Excel 文件上传:使用 FileReaderXLSX 库在浏览器端解析文件
  2. Google Sheet 链接处理:通过 Google OAuth 和 Sheets API 读取云端数据
  3. 数据规范化:统一字段名、数据类型、处理空值等
  4. 浏览器本地存储:使用 IndexedDB 存储分析结果,避免中央数据库的复杂性
  5. HTML 渲染与导出:使用 HTML/CSS 渲染幻灯片,导出为图片或 PPTX
  6. 会话 ZIP 导出导入:完整的会话备份和恢复功能

这些技术实现体现了系统的设计理念:在保证功能完整性的前提下,尽可能降低开发复杂度和运维成本


相关文档