Fix SSA data-context generation for Excel uploads by parsing xlsx/xls via extension-aware paths instead of UTF-8 fallback. Add on-demand overview rebuild in Agent flow, align xls friendly prompts on frontend/backend, and surface backend upload errors to users. Made-with: Cursor
591 lines
21 KiB
TypeScript
591 lines
21 KiB
TypeScript
/**
|
|
* SSA 分析相关的自定义 Hook (Updated for Unified Record Architecture)
|
|
*
|
|
* uploadData / generatePlan 仍由 SSAChatPane 使用。
|
|
* executeAnalysis / exportReport 保留但已切换到 record-based 存储。
|
|
*/
|
|
import { useCallback, useState } from 'react';
|
|
import apiClient from '@/common/api/axios';
|
|
import { getAccessToken } from '@/framework/auth/api';
|
|
import { useSSAStore } from '../stores/ssaStore';
|
|
import type { AnalysisPlan, ExecutionResult, SSAMessage } from '../types';
|
|
import {
|
|
Document,
|
|
Packer,
|
|
Paragraph,
|
|
Table,
|
|
TableRow,
|
|
TableCell,
|
|
TextRun,
|
|
HeadingLevel,
|
|
WidthType,
|
|
BorderStyle,
|
|
AlignmentType,
|
|
ImageRun,
|
|
} from 'docx';
|
|
|
|
const API_BASE = '/api/v1/ssa';
|
|
|
|
interface UploadResult {
|
|
sessionId: string;
|
|
schema: {
|
|
columns: Array<{
|
|
name: string;
|
|
type: string;
|
|
uniqueValues?: number;
|
|
nullCount?: number;
|
|
}>;
|
|
rowCount: number;
|
|
};
|
|
}
|
|
|
|
interface DownloadResult {
|
|
blob: Blob;
|
|
filename: string;
|
|
}
|
|
|
|
interface UseAnalysisReturn {
|
|
uploadData: (file: File) => Promise<UploadResult>;
|
|
generatePlan: (query: string) => Promise<AnalysisPlan>;
|
|
executePlan: (planId: string) => Promise<ExecutionResult>;
|
|
executeAnalysis: () => Promise<ExecutionResult>;
|
|
downloadCode: () => Promise<DownloadResult>;
|
|
exportReport: () => void;
|
|
isUploading: boolean;
|
|
isExecuting: boolean;
|
|
uploadProgress: number;
|
|
}
|
|
|
|
export function useAnalysis(): UseAnalysisReturn {
|
|
const {
|
|
currentSession,
|
|
addMessage,
|
|
setLoading,
|
|
setExecuting,
|
|
isExecuting,
|
|
setError,
|
|
addRecord,
|
|
updateRecord,
|
|
} = useSSAStore();
|
|
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [uploadProgress, setUploadProgress] = useState(0);
|
|
|
|
const uploadData = useCallback(async (file: File): Promise<UploadResult> => {
|
|
setIsUploading(true);
|
|
setUploadProgress(0);
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
try {
|
|
const token = getAccessToken();
|
|
const headers: HeadersInit = {};
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE}/sessions`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let errMsg = '上传失败';
|
|
try {
|
|
const errData = await response.json();
|
|
errMsg = errData?.error || errData?.message || errMsg;
|
|
} catch {
|
|
// ignore parse error
|
|
}
|
|
throw new Error(errMsg);
|
|
}
|
|
const result = await response.json();
|
|
setUploadProgress(100);
|
|
return result;
|
|
} catch (error) {
|
|
setError(error instanceof Error ? error.message : '上传出错');
|
|
throw error;
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
}, [setError]);
|
|
|
|
const generatePlan = useCallback(
|
|
async (query: string): Promise<AnalysisPlan> => {
|
|
if (!currentSession) throw new Error('请先上传数据');
|
|
setLoading(true);
|
|
|
|
try {
|
|
const userMessage: SSAMessage = {
|
|
id: crypto.randomUUID(),
|
|
role: 'user',
|
|
content: query,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
addMessage(userMessage);
|
|
|
|
const response = await apiClient.post(
|
|
`${API_BASE}/sessions/${currentSession.id}/plan`,
|
|
{ query }
|
|
);
|
|
const plan: AnalysisPlan = response.data;
|
|
|
|
const recordId = addRecord(query, {
|
|
workflow_id: plan.id || `legacy_${Date.now()}`,
|
|
title: plan.title || plan.toolName || '统计分析',
|
|
total_steps: 1,
|
|
steps: [{
|
|
step_number: 1,
|
|
tool_code: plan.toolCode || 'unknown',
|
|
tool_name: plan.toolName || '统计分析',
|
|
description: plan.description || '',
|
|
params: plan.parameters || {},
|
|
}],
|
|
} as any);
|
|
|
|
const planMessage: SSAMessage = {
|
|
id: crypto.randomUUID(),
|
|
role: 'assistant',
|
|
content: `已生成分析方案:${plan.toolName}\n\n${plan.description}`,
|
|
artifactType: 'sap',
|
|
recordId,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
addMessage(planMessage);
|
|
|
|
addMessage({
|
|
id: crypto.randomUUID(),
|
|
role: 'assistant',
|
|
content: '请确认数据映射并执行分析。',
|
|
artifactType: 'confirm',
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
|
|
return plan;
|
|
} catch (error) {
|
|
setError(error instanceof Error ? error.message : '生成计划出错');
|
|
throw error;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[currentSession, addMessage, setLoading, setError, addRecord]
|
|
);
|
|
|
|
const executePlan = useCallback(
|
|
async (_planId: string): Promise<ExecutionResult> => {
|
|
if (!currentSession) throw new Error('请先上传数据');
|
|
|
|
const record = (() => {
|
|
const s = useSSAStore.getState();
|
|
return s.currentRecordId
|
|
? s.analysisHistory.find((r) => r.id === s.currentRecordId) ?? null
|
|
: null;
|
|
})();
|
|
const planStep = record?.plan?.steps?.[0];
|
|
if (!planStep) throw new Error('请先生成分析计划');
|
|
|
|
setExecuting(true);
|
|
const rid = record!.id;
|
|
updateRecord(rid, { status: 'executing', steps: [], progress: 0 });
|
|
|
|
try {
|
|
const response = await apiClient.post(
|
|
`${API_BASE}/sessions/${currentSession.id}/execute`,
|
|
{
|
|
plan: {
|
|
tool_code: planStep.tool_code,
|
|
tool_name: planStep.tool_name,
|
|
params: planStep.params,
|
|
},
|
|
}
|
|
);
|
|
const result: ExecutionResult = response.data;
|
|
|
|
updateRecord(rid, {
|
|
status: 'completed',
|
|
progress: 100,
|
|
steps: [{
|
|
step_number: 1,
|
|
tool_code: planStep.tool_code,
|
|
tool_name: planStep.tool_name,
|
|
status: 'success',
|
|
result: result as any,
|
|
started_at: new Date().toISOString(),
|
|
completed_at: new Date().toISOString(),
|
|
duration_ms: result.executionMs,
|
|
logs: [],
|
|
}],
|
|
});
|
|
|
|
addMessage({
|
|
id: crypto.randomUUID(),
|
|
role: 'assistant',
|
|
content: result.interpretation || '分析完成,请查看右侧结果面板。',
|
|
artifactType: 'result',
|
|
recordId: rid,
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
|
|
return result;
|
|
} catch (error: any) {
|
|
updateRecord(rid, { status: 'error' });
|
|
const errorData = error.response?.data;
|
|
const errorMessage = errorData?.user_hint || errorData?.error ||
|
|
(error instanceof Error ? error.message : '执行出错');
|
|
setError(errorMessage);
|
|
throw new Error(errorMessage);
|
|
} finally {
|
|
setExecuting(false);
|
|
}
|
|
},
|
|
[currentSession, addMessage, setExecuting, setError, updateRecord]
|
|
);
|
|
|
|
const executeAnalysis = useCallback(async (): Promise<ExecutionResult> => {
|
|
return executePlan('current');
|
|
}, [executePlan]);
|
|
|
|
const downloadCode = useCallback(async (): Promise<DownloadResult> => {
|
|
if (!currentSession) throw new Error('请先上传数据');
|
|
const response = await apiClient.get(
|
|
`${API_BASE}/sessions/${currentSession.id}/download-code`,
|
|
{ responseType: 'blob' }
|
|
);
|
|
const contentDisposition = response.headers['content-disposition'];
|
|
let filename = `analysis_${currentSession.id}.R`;
|
|
if (contentDisposition) {
|
|
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
|
if (filenameMatch) {
|
|
let extractedName = filenameMatch[1].replace(/['"]/g, '');
|
|
try { extractedName = decodeURIComponent(extractedName); } catch { /* ok */ }
|
|
if (extractedName) filename = extractedName;
|
|
}
|
|
}
|
|
return { blob: response.data, filename };
|
|
}, [currentSession]);
|
|
|
|
const exportReport = useCallback(async () => {
|
|
const state = useSSAStore.getState();
|
|
const record = state.currentRecordId
|
|
? state.analysisHistory.find((r) => r.id === state.currentRecordId) ?? null
|
|
: null;
|
|
if (!record) {
|
|
setError('暂无分析结果可导出');
|
|
return;
|
|
}
|
|
|
|
const steps = record.steps.filter(
|
|
(s) => (s.status === 'success' || s.status === 'warning') && s.result
|
|
);
|
|
if (steps.length === 0) {
|
|
setError('暂无分析结果可导出');
|
|
return;
|
|
}
|
|
|
|
const session = state.currentSession;
|
|
const mountedFile = state.mountedFile;
|
|
const conclusion = record.conclusionReport;
|
|
|
|
await exportWorkflowReport(steps, record.plan, conclusion, session, mountedFile);
|
|
}, [setError]);
|
|
|
|
const exportWorkflowReport = async (
|
|
steps: any[],
|
|
wfPlan: any,
|
|
conclusion: any,
|
|
session: any,
|
|
mountedFile: any
|
|
) => {
|
|
const now = new Date();
|
|
const dateStr = now.toLocaleString('zh-CN');
|
|
const dataFileName = mountedFile?.name || session?.title || '数据文件';
|
|
|
|
const createTableRow = (cells: string[], isHeader = false) => {
|
|
return new TableRow({
|
|
children: cells.map(text => new TableCell({
|
|
children: [new Paragraph({
|
|
children: [new TextRun({ text: String(text ?? '-'), bold: isHeader })],
|
|
})],
|
|
width: { size: 100 / cells.length, type: WidthType.PERCENTAGE },
|
|
})),
|
|
});
|
|
};
|
|
|
|
const tableBorders = {
|
|
top: { style: BorderStyle.SINGLE, size: 1 },
|
|
bottom: { style: BorderStyle.SINGLE, size: 1 },
|
|
left: { style: BorderStyle.SINGLE, size: 1 },
|
|
right: { style: BorderStyle.SINGLE, size: 1 },
|
|
insideHorizontal: { style: BorderStyle.SINGLE, size: 1 },
|
|
insideVertical: { style: BorderStyle.SINGLE, size: 1 },
|
|
};
|
|
|
|
const sections: (Paragraph | Table)[] = [];
|
|
let sectionNum = 1;
|
|
|
|
sections.push(
|
|
new Paragraph({ text: '统计分析报告', heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }),
|
|
new Paragraph({ text: '' }),
|
|
new Paragraph({ children: [
|
|
new TextRun({ text: '研究课题:', bold: true }),
|
|
new TextRun(wfPlan?.title || session?.title || '统计分析'),
|
|
]}),
|
|
new Paragraph({ children: [
|
|
new TextRun({ text: '数据文件:', bold: true }),
|
|
new TextRun(dataFileName),
|
|
]}),
|
|
new Paragraph({ children: [
|
|
new TextRun({ text: '生成时间:', bold: true }),
|
|
new TextRun(dateStr),
|
|
]}),
|
|
new Paragraph({ children: [
|
|
new TextRun({ text: '分析步骤:', bold: true }),
|
|
new TextRun(`共 ${steps.length} 个步骤`),
|
|
]}),
|
|
new Paragraph({ text: '' }),
|
|
);
|
|
|
|
if (conclusion?.executive_summary) {
|
|
sections.push(
|
|
new Paragraph({ text: `${sectionNum++}. 摘要`, heading: HeadingLevel.HEADING_1 }),
|
|
new Paragraph({ text: conclusion.executive_summary }),
|
|
new Paragraph({ text: '' }),
|
|
);
|
|
}
|
|
|
|
for (const step of steps) {
|
|
const r = step.result as any;
|
|
sections.push(
|
|
new Paragraph({ text: `${sectionNum++}. 步骤 ${step.step_number}: ${step.tool_name}`, heading: HeadingLevel.HEADING_1 }),
|
|
);
|
|
|
|
if (step.duration_ms) {
|
|
sections.push(new Paragraph({ children: [
|
|
new TextRun({ text: `执行耗时:${step.duration_ms}ms`, italics: true, color: '666666' }),
|
|
]}));
|
|
}
|
|
|
|
const isDescStep = step.tool_code === 'ST_DESCRIPTIVE' || r?.summary;
|
|
|
|
if (isDescStep && (r?.summary || r?.variables)) {
|
|
if (r?.summary) {
|
|
sections.push(
|
|
new Paragraph({ text: `总观测数: ${r.summary.n_total ?? '-'}, 分析变量数: ${r.summary.n_variables ?? '-'}, 数值变量: ${r.summary.n_numeric ?? '-'}, 分类变量: ${r.summary.n_categorical ?? '-'}` }),
|
|
new Paragraph({ text: '' }),
|
|
);
|
|
}
|
|
if (r?.variables && typeof r.variables === 'object') {
|
|
const classifyExportVar = (v: any): 'numeric' | 'categorical' | 'unknown' => {
|
|
if (!v) return 'unknown';
|
|
if (v.type === 'numeric') return 'numeric';
|
|
if (v.type === 'categorical') return 'categorical';
|
|
if (v.mean !== undefined) return 'numeric';
|
|
if (v.levels && Array.isArray(v.levels)) return 'categorical';
|
|
if (v.by_group) {
|
|
const first = Object.values(v.by_group)[0] as any;
|
|
if (first?.mean !== undefined) return 'numeric';
|
|
if (first?.levels) return 'categorical';
|
|
}
|
|
return 'unknown';
|
|
};
|
|
const varEntries = Object.entries(r.variables);
|
|
const numericVars = varEntries.filter(([, v]) => classifyExportVar(v) === 'numeric');
|
|
const catVars = varEntries.filter(([, v]) => classifyExportVar(v) === 'categorical');
|
|
if (numericVars.length > 0) {
|
|
const numRows: TableRow[] = [createTableRow(['变量', 'N', '均值 ± 标准差', '中位数', 'Q1', 'Q3', '最小值', '最大值'], true)];
|
|
for (const [varName, rawVs] of numericVars) {
|
|
const vs = rawVs as any;
|
|
if (vs.by_group) {
|
|
for (const [gName, gStats] of Object.entries(vs.by_group)) {
|
|
const g = gStats as any;
|
|
numRows.push(createTableRow([
|
|
`${vs.variable || varName} [${gName}]`, String(g.n ?? '-'),
|
|
g.formatted || (g.mean !== undefined ? `${g.mean} ± ${g.sd}` : '-'),
|
|
String(g.median ?? '-'), String(g.q1 ?? '-'), String(g.q3 ?? '-'),
|
|
String(g.min ?? '-'), String(g.max ?? '-'),
|
|
]));
|
|
}
|
|
} else {
|
|
numRows.push(createTableRow([
|
|
vs.variable || varName, String(vs.n ?? '-'),
|
|
vs.formatted || (vs.mean !== undefined ? `${vs.mean} ± ${vs.sd}` : '-'),
|
|
String(vs.median ?? '-'), String(vs.q1 ?? '-'), String(vs.q3 ?? '-'),
|
|
String(vs.min ?? '-'), String(vs.max ?? '-'),
|
|
]));
|
|
}
|
|
}
|
|
sections.push(
|
|
new Paragraph({ text: '数值变量统计', heading: HeadingLevel.HEADING_2 }),
|
|
new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: numRows }),
|
|
new Paragraph({ text: '' }),
|
|
);
|
|
}
|
|
if (catVars.length > 0) {
|
|
sections.push(new Paragraph({ text: '分类变量统计', heading: HeadingLevel.HEADING_2 }));
|
|
for (const [varName, rawVs] of catVars) {
|
|
const vs = rawVs as any;
|
|
if (vs.by_group) {
|
|
for (const [gName, gStats] of Object.entries(vs.by_group)) {
|
|
const g = gStats as any;
|
|
const levels = g.levels || [];
|
|
if (levels.length > 0) {
|
|
sections.push(
|
|
new Paragraph({ children: [new TextRun({ text: `${vs.variable || varName} [${gName}]`, bold: true }), new TextRun(` (N=${g.n ?? '-'})`)] }),
|
|
new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: [
|
|
createTableRow(['类别', '频数', '百分比'], true),
|
|
...levels.map((lv: any) => createTableRow([String(lv.level), String(lv.n), `${lv.pct}%`])),
|
|
]}),
|
|
new Paragraph({ text: '' }),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
const levels = vs.levels || [];
|
|
if (levels.length > 0) {
|
|
sections.push(
|
|
new Paragraph({ children: [new TextRun({ text: `${vs.variable || varName}`, bold: true }), new TextRun(` (N=${vs.n ?? '-'}, 缺失=${vs.missing ?? 0})`)] }),
|
|
new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: [
|
|
createTableRow(['类别', '频数', '百分比'], true),
|
|
...levels.map((lv: any) => createTableRow([String(lv.level), String(lv.n), `${lv.pct}%`])),
|
|
]}),
|
|
new Paragraph({ text: '' }),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
const statsRows = [
|
|
createTableRow(['指标', '值'], true),
|
|
createTableRow(['统计方法', r?.method || step.tool_name]),
|
|
];
|
|
if (r?.statistic !== undefined) statsRows.push(createTableRow(['统计量', Number(r.statistic).toFixed(4)]));
|
|
if (r?.p_value !== undefined) statsRows.push(createTableRow(['P 值', r.p_value_fmt || (r.p_value < 0.001 ? '< 0.001' : Number(r.p_value).toFixed(4))]));
|
|
if (r?.effect_size !== undefined) {
|
|
const esStr = typeof r.effect_size === 'object'
|
|
? Object.entries(r.effect_size).map(([k, v]) => `${k}=${Number(v).toFixed(3)}`).join(', ')
|
|
: Number(r.effect_size).toFixed(3);
|
|
statsRows.push(createTableRow(['效应量', esStr]));
|
|
}
|
|
if (r?.conf_int) statsRows.push(createTableRow(['95% CI', `[${r.conf_int.map((v: number) => v.toFixed(4)).join(', ')}]`]));
|
|
sections.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: statsRows }));
|
|
sections.push(new Paragraph({ text: '' }));
|
|
}
|
|
|
|
if (r?.group_stats?.length > 0) {
|
|
sections.push(
|
|
new Paragraph({ text: '分组统计', heading: HeadingLevel.HEADING_2 }),
|
|
new Table({
|
|
width: { size: 100, type: WidthType.PERCENTAGE },
|
|
borders: tableBorders,
|
|
rows: [
|
|
createTableRow(['分组', 'N', '均值', '标准差'], true),
|
|
...r.group_stats.map((g: any) => createTableRow([
|
|
String(g.group), String(g.n),
|
|
g.mean !== undefined ? Number(g.mean).toFixed(4) : '-',
|
|
g.sd !== undefined ? Number(g.sd).toFixed(4) : '-',
|
|
])),
|
|
],
|
|
}),
|
|
new Paragraph({ text: '' }),
|
|
);
|
|
}
|
|
|
|
if (r?.coefficients && Array.isArray(r.coefficients) && r.coefficients.length > 0) {
|
|
sections.push(
|
|
new Paragraph({ text: '回归系数', heading: HeadingLevel.HEADING_2 }),
|
|
new Table({
|
|
width: { size: 100, type: WidthType.PERCENTAGE },
|
|
borders: tableBorders,
|
|
rows: [
|
|
createTableRow(['变量', '估计值', 'OR', '95% CI', 'P 值'], true),
|
|
...r.coefficients.map((c: any) => createTableRow([
|
|
c.variable || c.term || '-',
|
|
Number(c.estimate || c.coef || 0).toFixed(4),
|
|
c.OR !== undefined ? Number(c.OR).toFixed(4) : '-',
|
|
c.ci_lower !== undefined ? `[${Number(c.ci_lower).toFixed(3)}, ${Number(c.ci_upper).toFixed(3)}]` : '-',
|
|
c.p_value_fmt || (c.p_value !== undefined ? (c.p_value < 0.001 ? '< 0.001' : Number(c.p_value).toFixed(4)) : '-'),
|
|
])),
|
|
],
|
|
}),
|
|
new Paragraph({ text: '' }),
|
|
);
|
|
}
|
|
|
|
if (r?.plots?.length > 0) {
|
|
for (const plot of r.plots) {
|
|
const imageBase64 = typeof plot === 'string' ? plot : plot.imageBase64;
|
|
if (imageBase64) {
|
|
try {
|
|
const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, '');
|
|
const imageBuffer = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
|
|
sections.push(
|
|
new Paragraph({
|
|
children: [new ImageRun({ data: imageBuffer, transformation: { width: 450, height: 300 }, type: 'png' })],
|
|
alignment: AlignmentType.CENTER,
|
|
}),
|
|
new Paragraph({ text: '' }),
|
|
);
|
|
} catch { /* skip */ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (conclusion?.key_findings?.length > 0) {
|
|
sections.push(
|
|
new Paragraph({ text: `${sectionNum++}. 主要发现`, heading: HeadingLevel.HEADING_1 }),
|
|
...conclusion.key_findings.map((f: string) => new Paragraph({ text: `• ${f}` })),
|
|
new Paragraph({ text: '' }),
|
|
);
|
|
}
|
|
if (conclusion?.recommendations?.length > 0) {
|
|
sections.push(
|
|
new Paragraph({ text: `${sectionNum++}. 建议`, heading: HeadingLevel.HEADING_1 }),
|
|
...conclusion.recommendations.map((rec: string) => new Paragraph({ text: `• ${rec}` })),
|
|
new Paragraph({ text: '' }),
|
|
);
|
|
}
|
|
|
|
sections.push(
|
|
new Paragraph({ text: '' }),
|
|
new Paragraph({ children: [
|
|
new TextRun({ text: '本报告由智能统计分析系统自动生成', italics: true, color: '666666' }),
|
|
]}),
|
|
new Paragraph({ children: [
|
|
new TextRun({ text: `总执行耗时: ${steps.reduce((s: number, st: any) => s + (st.duration_ms || 0), 0)}ms`, italics: true, color: '666666' }),
|
|
]}),
|
|
);
|
|
|
|
const doc = new Document({ sections: [{ children: sections }] });
|
|
const dateTimeStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`;
|
|
const safeFileName = dataFileName.replace(/\.(csv|xlsx|xls)$/i, '').replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_');
|
|
|
|
const blob = await Packer.toBlob(doc);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `统计分析报告_${safeFileName}_${dateTimeStr}.docx`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
return {
|
|
uploadData,
|
|
generatePlan,
|
|
executePlan,
|
|
executeAnalysis,
|
|
downloadCode,
|
|
exportReport,
|
|
isUploading,
|
|
isExecuting,
|
|
uploadProgress,
|
|
};
|
|
}
|
|
|
|
export default useAnalysis;
|