Files
suanming/server/services/generators/pngGenerator.cjs
patdelphi 9231651ae1 feat: 完成分析结果下载功能实现
- 新增DownloadButton组件,支持Markdown、PDF、PNG三种格式下载
- 实现后端下载API接口(/api/download)
- 添加Markdown、PDF、PNG三种格式生成器
- 集成下载按钮到所有分析结果页面
- 修复API路径配置问题,确保开发环境正确访问后端
- 添加下载历史记录功能和数据库表结构
- 完善错误处理和用户反馈机制
2025-08-21 12:44:40 +08:00

588 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* PNG图片生成器
* 将分析结果转换为PNG图片格式
* 使用canvas或html-to-image技术
*/
const generatePNG = async (analysisData, analysisType, userName) => {
try {
// 生成图片内容
const imageData = await generateImageData(analysisData, analysisType, userName);
// 由于canvas库需要额外安装这里先返回占位符
// 在实际部署时需要安装 canvas 或 puppeteer
// 临时解决方案返回SVG内容作为PNG实际应该转换为PNG
const Buffer = require('buffer').Buffer;
return Buffer.from(imageData, 'utf8');
// 正式实现应该是:
// const { createCanvas, loadImage } = require('canvas');
// const canvas = createCanvas(800, 1200);
// const ctx = canvas.getContext('2d');
//
// // 绘制内容到canvas
// drawContent(ctx, analysisData, analysisType, userName);
//
// return canvas.toBuffer('image/png');
} catch (error) {
console.error('生成PNG失败:', error);
throw error;
}
};
/**
* 生成图片数据SVG格式
*/
const generateImageData = async (analysisData, analysisType, userName) => {
const timestamp = new Date().toLocaleString('zh-CN');
const analysisTypeLabel = getAnalysisTypeLabel(analysisType);
// 生成SVG内容
let svg = `
<svg width="800" height="1200" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
${getSVGStyles()}
</style>
<linearGradient id="headerGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#dc2626;stop-opacity:1" />
<stop offset="100%" style="stop-color:#b91c1c;stop-opacity:1" />
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="2" dy="2" stdDeviation="3" flood-color="rgba(0,0,0,0.3)"/>
</filter>
</defs>
<!-- 背景 -->
<rect width="800" height="1200" fill="#f9f9f9"/>
<!-- 头部 -->
<rect width="800" height="200" fill="url(#headerGradient)"/>
<!-- 标题 -->
<text x="400" y="60" class="main-title" text-anchor="middle" fill="white" filter="url(#shadow)">神机阁</text>
<text x="400" y="90" class="subtitle" text-anchor="middle" fill="white">专业命理分析平台</text>
<!-- 分割线 -->
<line x1="100" y1="110" x2="700" y2="110" stroke="rgba(255,255,255,0.3)" stroke-width="1"/>
<!-- 报告信息 -->
<text x="400" y="140" class="report-title" text-anchor="middle" fill="white">${analysisTypeLabel}分析报告</text>
<text x="200" y="170" class="info-text" fill="white">姓名:${userName || '用户'}</text>
<text x="500" y="170" class="info-text" fill="white">生成时间:${timestamp.split(' ')[0]}</text>
<!-- 内容区域背景 -->
<rect x="50" y="220" width="700" height="900" fill="white" rx="10" ry="10" filter="url(#shadow)"/>
`;
// 根据分析类型添加不同内容
let yOffset = 260;
switch (analysisType) {
case 'bazi':
yOffset = addBaziContent(svg, analysisData, yOffset);
break;
case 'ziwei':
yOffset = addZiweiContent(svg, analysisData, yOffset);
break;
case 'yijing':
yOffset = addYijingContent(svg, analysisData, yOffset);
break;
}
// 页脚
svg += `
<!-- 页脚 -->
<rect x="50" y="1140" width="700" height="50" fill="#f8f9fa" rx="0" ry="0"/>
<text x="400" y="1160" class="footer-text" text-anchor="middle" fill="#666">本报告由神机阁AI命理分析平台生成仅供参考</text>
<text x="400" y="1180" class="footer-text" text-anchor="middle" fill="#666">© 2025 神机阁 - AI命理分析平台</text>
</svg>
`;
return svg;
};
/**
* 添加八字命理内容
*/
const addBaziContent = (svg, analysisData, yOffset) => {
let content = '';
// 基本信息
if (analysisData.basic_info) {
content += `
<!-- 基本信息 -->
<text x="80" y="${yOffset}" class="section-title" fill="#dc2626">📋 基本信息</text>
`;
yOffset += 40;
if (analysisData.basic_info.personal_data) {
const personal = analysisData.basic_info.personal_data;
const genderText = personal.gender === 'male' ? '男' : personal.gender === 'female' ? '女' : personal.gender || '未提供';
content += `
<text x="100" y="${yOffset}" class="info-label" fill="#333">姓名:</text>
<text x="160" y="${yOffset}" class="info-value" fill="#666">${personal.name || '未提供'}</text>
<text x="400" y="${yOffset}" class="info-label" fill="#333">性别:</text>
<text x="460" y="${yOffset}" class="info-value" fill="#666">${genderText}</text>
`;
yOffset += 30;
content += `
<text x="100" y="${yOffset}" class="info-label" fill="#333">出生日期:</text>
<text x="180" y="${yOffset}" class="info-value" fill="#666">${personal.birth_date || '未提供'}</text>
`;
yOffset += 30;
content += `
<text x="100" y="${yOffset}" class="info-label" fill="#333">出生时间:</text>
<text x="180" y="${yOffset}" class="info-value" fill="#666">${personal.birth_time || '未提供'}</text>
`;
yOffset += 40;
}
// 八字信息
if (analysisData.basic_info.bazi_info) {
const bazi = analysisData.basic_info.bazi_info;
content += `
<text x="100" y="${yOffset}" class="subsection-title" fill="#b91c1c">🔮 八字信息</text>
`;
yOffset += 30;
// 表格头
content += `
<rect x="100" y="${yOffset}" width="600" height="25" fill="#dc2626" rx="3"/>
<text x="130" y="${yOffset + 17}" class="table-header" fill="white">柱位</text>
<text x="230" y="${yOffset + 17}" class="table-header" fill="white">天干</text>
<text x="330" y="${yOffset + 17}" class="table-header" fill="white">地支</text>
<text x="430" y="${yOffset + 17}" class="table-header" fill="white">纳音</text>
`;
yOffset += 25;
// 表格内容
const pillars = [
{ name: '年柱', data: bazi.year, nayin: bazi.year_nayin },
{ name: '月柱', data: bazi.month, nayin: bazi.month_nayin },
{ name: '日柱', data: bazi.day, nayin: bazi.day_nayin },
{ name: '时柱', data: bazi.hour, nayin: bazi.hour_nayin }
];
pillars.forEach((pillar, index) => {
const bgColor = index % 2 === 0 ? '#f8f9fa' : 'white';
content += `
<rect x="100" y="${yOffset}" width="600" height="25" fill="${bgColor}" stroke="#ddd" stroke-width="0.5"/>
<text x="130" y="${yOffset + 17}" class="table-cell" fill="#333">${pillar.name}</text>
<text x="230" y="${yOffset + 17}" class="table-cell" fill="#333">${pillar.data?.split('')[0] || '-'}</text>
<text x="330" y="${yOffset + 17}" class="table-cell" fill="#333">${pillar.data?.split('')[1] || '-'}</text>
<text x="430" y="${yOffset + 17}" class="table-cell" fill="#333">${pillar.nayin || '-'}</text>
`;
yOffset += 25;
});
yOffset += 20;
}
}
// 五行分析
if (analysisData.wuxing_analysis && yOffset < 1000) {
content += `
<text x="80" y="${yOffset}" class="section-title" fill="#dc2626">🌟 五行分析</text>
`;
yOffset += 40;
if (analysisData.wuxing_analysis.element_distribution) {
const elements = analysisData.wuxing_analysis.element_distribution;
const total = Object.values(elements).reduce((sum, count) => sum + (typeof count === 'number' ? count : 0), 0);
content += `
<text x="100" y="${yOffset}" class="subsection-title" fill="#b91c1c">五行分布</text>
`;
yOffset += 30;
// 五行分布图表
let xOffset = 120;
Object.entries(elements).forEach(([element, count]) => {
const numCount = typeof count === 'number' ? count : 0;
const percentage = total > 0 ? Math.round((numCount / total) * 100) : 0;
const barHeight = Math.max(numCount * 20, 5);
const elementColor = getElementColor(element);
// 柱状图
content += `
<rect x="${xOffset}" y="${yOffset + 80 - barHeight}" width="30" height="${barHeight}" fill="${elementColor}" rx="2"/>
<text x="${xOffset + 15}" y="${yOffset + 100}" class="element-label" text-anchor="middle" fill="#333">${element}</text>
<text x="${xOffset + 15}" y="${yOffset + 115}" class="element-count" text-anchor="middle" fill="#666">${numCount}</text>
<text x="${xOffset + 15}" y="${yOffset + 130}" class="element-percent" text-anchor="middle" fill="#666">${percentage}%</text>
`;
xOffset += 100;
});
yOffset += 150;
}
if (analysisData.wuxing_analysis.balance_analysis && yOffset < 1000) {
content += `
<text x="100" y="${yOffset}" class="subsection-title" fill="#b91c1c">五行平衡分析</text>
`;
yOffset += 25;
// 分析内容截取前200字符
const analysisText = analysisData.wuxing_analysis.balance_analysis.substring(0, 200) + (analysisData.wuxing_analysis.balance_analysis.length > 200 ? '...' : '');
const lines = wrapText(analysisText, 50);
lines.forEach(line => {
if (yOffset < 1000) {
content += `
<text x="120" y="${yOffset}" class="analysis-text" fill="#555">${line}</text>
`;
yOffset += 20;
}
});
yOffset += 20;
}
}
svg += content;
return yOffset;
};
/**
* 添加紫微斗数内容
*/
const addZiweiContent = (svg, analysisData, yOffset) => {
let content = '';
// 基本信息
if (analysisData.basic_info) {
content += `
<text x="80" y="${yOffset}" class="section-title" fill="#dc2626">📋 基本信息</text>
`;
yOffset += 40;
if (analysisData.basic_info.ziwei_info) {
const ziwei = analysisData.basic_info.ziwei_info;
if (ziwei.ming_gong) {
content += `
<text x="100" y="${yOffset}" class="info-label" fill="#333">命宫:</text>
<text x="160" y="${yOffset}" class="info-highlight" fill="#dc2626">${ziwei.ming_gong}</text>
`;
yOffset += 30;
}
if (ziwei.wuxing_ju) {
content += `
<text x="100" y="${yOffset}" class="info-label" fill="#333">五行局:</text>
<text x="180" y="${yOffset}" class="info-highlight" fill="#dc2626">${ziwei.wuxing_ju}</text>
`;
yOffset += 30;
}
if (ziwei.main_stars) {
const starsText = Array.isArray(ziwei.main_stars) ? ziwei.main_stars.join('、') : ziwei.main_stars;
content += `
<text x="100" y="${yOffset}" class="info-label" fill="#333">主星:</text>
<text x="160" y="${yOffset}" class="info-highlight" fill="#dc2626">${starsText}</text>
`;
yOffset += 40;
}
}
}
// 星曜分析
if (analysisData.star_analysis && yOffset < 1000) {
content += `
<text x="80" y="${yOffset}" class="section-title" fill="#dc2626">⭐ 星曜分析</text>
`;
yOffset += 40;
if (analysisData.star_analysis.main_stars) {
content += `
<text x="100" y="${yOffset}" class="subsection-title" fill="#b91c1c">主星分析</text>
`;
yOffset += 30;
if (Array.isArray(analysisData.star_analysis.main_stars)) {
analysisData.star_analysis.main_stars.slice(0, 3).forEach(star => {
if (typeof star === 'object' && yOffset < 1000) {
content += `
<rect x="100" y="${yOffset - 15}" width="600" height="60" fill="#f1f5f9" rx="5" stroke="#3b82f6" stroke-width="2"/>
<text x="120" y="${yOffset + 5}" class="star-name" fill="#1e40af">${star.name || star.star}</text>
`;
if (star.brightness) {
content += `
<text x="120" y="${yOffset + 25}" class="star-detail" fill="#333">亮度:${star.brightness}</text>
`;
}
if (star.influence) {
const influenceText = star.influence.substring(0, 60) + (star.influence.length > 60 ? '...' : '');
content += `
<text x="120" y="${yOffset + 40}" class="star-detail" fill="#555">影响:${influenceText}</text>
`;
}
yOffset += 80;
}
});
}
}
}
svg += content;
return yOffset;
};
/**
* 添加易经占卜内容
*/
const addYijingContent = (svg, analysisData, yOffset) => {
let content = '';
// 占卜问题
if (analysisData.question_analysis) {
content += `
<text x="80" y="${yOffset}" class="section-title" fill="#dc2626">❓ 占卜问题</text>
`;
yOffset += 40;
if (analysisData.question_analysis.original_question) {
content += `
<text x="100" y="${yOffset}" class="info-label" fill="#333">问题:</text>
`;
const questionText = analysisData.question_analysis.original_question;
const questionLines = wrapText(questionText, 45);
questionLines.forEach((line, index) => {
content += `
<text x="${index === 0 ? 160 : 120}" y="${yOffset}" class="info-highlight" fill="#dc2626">${line}</text>
`;
yOffset += 20;
});
yOffset += 10;
}
if (analysisData.question_analysis.question_type) {
content += `
<text x="100" y="${yOffset}" class="info-label" fill="#333">问题类型:</text>
<text x="180" y="${yOffset}" class="info-value" fill="#666">${analysisData.question_analysis.question_type}</text>
`;
yOffset += 40;
}
}
// 卦象信息
if (analysisData.hexagram_info && yOffset < 1000) {
content += `
<text x="80" y="${yOffset}" class="section-title" fill="#dc2626">🔮 卦象信息</text>
`;
yOffset += 40;
if (analysisData.hexagram_info.main_hexagram) {
const main = analysisData.hexagram_info.main_hexagram;
content += `
<rect x="100" y="${yOffset - 15}" width="600" height="100" fill="#fef3c7" rx="8" stroke="#fbbf24" stroke-width="2"/>
<text x="120" y="${yOffset + 10}" class="subsection-title" fill="#92400e">主卦</text>
<text x="120" y="${yOffset + 35}" class="info-label" fill="#333">卦名:</text>
<text x="180" y="${yOffset + 35}" class="hexagram-name" fill="#dc2626">${main.name || '未知'}</text>
<text x="400" y="${yOffset + 35}" class="info-label" fill="#333">卦象:</text>
<text x="460" y="${yOffset + 35}" class="hexagram-symbol" fill="#92400e">${main.symbol || ''}</text>
`;
if (main.meaning) {
const meaningText = main.meaning.substring(0, 50) + (main.meaning.length > 50 ? '...' : '');
content += `
<text x="120" y="${yOffset + 60}" class="info-label" fill="#333">含义:</text>
<text x="180" y="${yOffset + 60}" class="info-value" fill="#666">${meaningText}</text>
`;
}
yOffset += 120;
}
}
svg += content;
return yOffset;
};
/**
* 获取五行颜色
*/
const getElementColor = (element) => {
const colors = {
'木': '#22c55e',
'火': '#ef4444',
'土': '#eab308',
'金': '#64748b',
'水': '#3b82f6'
};
return colors[element] || '#666';
};
/**
* 文本换行处理
*/
const wrapText = (text, maxLength) => {
const lines = [];
let currentLine = '';
for (let i = 0; i < text.length; i++) {
currentLine += text[i];
if (currentLine.length >= maxLength || text[i] === '\n') {
lines.push(currentLine.trim());
currentLine = '';
}
}
if (currentLine.trim()) {
lines.push(currentLine.trim());
}
return lines;
};
/**
* 获取分析类型标签
*/
const getAnalysisTypeLabel = (analysisType) => {
switch (analysisType) {
case 'bazi': return '八字命理';
case 'ziwei': return '紫微斗数';
case 'yijing': return '易经占卜';
default: return '命理';
}
};
/**
* 获取SVG样式
*/
const getSVGStyles = () => {
return `
.main-title {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 36px;
font-weight: bold;
}
.subtitle {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 16px;
}
.report-title {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 24px;
font-weight: bold;
}
.info-text {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 14px;
}
.section-title {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 20px;
font-weight: bold;
}
.subsection-title {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 16px;
font-weight: bold;
}
.info-label {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 14px;
font-weight: bold;
}
.info-value {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 14px;
}
.info-highlight {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 14px;
font-weight: bold;
}
.table-header {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 12px;
font-weight: bold;
}
.table-cell {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 12px;
}
.element-label {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 12px;
font-weight: bold;
}
.element-count {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 11px;
}
.element-percent {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 10px;
}
.analysis-text {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 12px;
}
.star-name {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 14px;
font-weight: bold;
}
.star-detail {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 11px;
}
.hexagram-name {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 16px;
font-weight: bold;
}
.hexagram-symbol {
font-family: monospace;
font-size: 14px;
font-weight: bold;
}
.footer-text {
font-family: 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 10px;
}
`;
};
module.exports = {
generatePNG
};