Remove PNG server generation functionality

- Remove PNG server generation option from DownloadButton component
- Remove PNG generation logic from download route
- Delete pngGenerator.cjs and related test files
- Simplify download options to focus on frontend PNG export
- Reduce server complexity and resource usage
This commit is contained in:
patdelphi
2025-08-21 21:20:14 +08:00
parent 5c776c8086
commit d2e48bf80d
23 changed files with 1539 additions and 1028 deletions

211
FRONTEND_EXPORT_SETUP.md Normal file
View File

@@ -0,0 +1,211 @@
# 前端页面导出功能设置说明
## 功能概述
新增了前端页面直接导出为PDF和PNG的功能用户可以直接从分析结果页面生成
- **PDF文档**:支持分页的专业格式文档
- **PNG长图**:完整页面截图,适合社交媒体分享
## 依赖安装
需要安装以下依赖包:
```bash
npm install html2canvas jspdf
```
或使用yarn
```bash
yarn add html2canvas jspdf
```
## 功能特性
### PDF导出
- ✅ A4纸张格式
- ✅ 自动分页处理
- ✅ 高质量输出
- ✅ 保持原有样式和布局
- ✅ 自动隐藏导出按钮等UI元素
### PNG导出
- ✅ 高分辨率长图2倍缩放
- ✅ 完整页面内容
- ✅ 保持颜色和样式
- ✅ 适合移动端查看和分享
## 已集成的组件
1. **CompleteYijingAnalysis** - 易经占卜分析页面
2. **CompleteBaziAnalysis** - 八字命理分析页面
3. **CompleteZiweiAnalysis** - 紫微斗数分析页面
## 使用方法
### 在组件中使用
```tsx
import PageExportButton from './ui/PageExportButton';
// 在JSX中使用
<PageExportButton
targetElementId="analysis-content" // 要导出的元素ID
fileName="分析结果" // 文件名前缀
title="页面导出" // 按钮显示文本
className="sticky top-4 z-10" // 样式类名
/>
```
### 标记导出内容
为要导出的内容容器添加ID和data属性
```tsx
<div id="analysis-content" data-export-content>
{/* 要导出的内容 */}
</div>
```
### 隐藏不需要导出的元素
为不需要出现在导出文件中的元素添加类名或属性:
```tsx
<div className="no-export" data-no-export>
{/* 下载按钮等UI元素 */}
</div>
```
## 技术实现
### 核心技术栈
- **html2canvas**: 将DOM元素转换为Canvas
- **jsPDF**: 生成PDF文档
- **React**: 组件化开发
- **TypeScript**: 类型安全
### 导出流程
1. **获取目标元素**: 根据ID或选择器定位要导出的DOM元素
2. **生成Canvas**: 使用html2canvas将DOM转换为高质量Canvas
3. **处理样式**: 自动处理CSS样式、字体、图片等
4. **生成文件**:
- PNG: 直接从Canvas生成图片
- PDF: 将Canvas图片嵌入PDF文档支持分页
5. **自动下载**: 创建下载链接并触发下载
### 配置选项
```typescript
// html2canvas配置
{
scale: 2, // 提高分辨率
useCORS: true, // 支持跨域图片
allowTaint: true, // 允许跨域内容
backgroundColor: '#ffffff', // 背景色
onclone: (clonedDoc) => {
// 在克隆文档中隐藏不需要的元素
}
}
// jsPDF配置
{
orientation: 'portrait', // 纵向
unit: 'mm', // 单位毫米
format: 'a4' // A4格式
}
```
## 样式优化
### CSS类名约定
- `.no-export`: 不导出的元素
- `[data-no-export]`: 不导出的元素(属性方式)
- `[data-export-content]`: 主要导出内容
- `.fixed`, `.sticky`, `.floating`: 自动隐藏的浮动元素
### 打印样式优化
可以添加打印专用样式:
```css
@media print {
.no-print {
display: none !important;
}
.print-break {
page-break-before: always;
}
}
```
## 错误处理
- ✅ 网络错误处理
- ✅ 图片加载失败处理
- ✅ 浏览器兼容性检查
- ✅ 用户友好的错误提示
- ✅ Toast通知集成
## 浏览器兼容性
- ✅ Chrome 60+
- ✅ Firefox 55+
- ✅ Safari 12+
- ✅ Edge 79+
- ⚠️ IE不支持需要polyfill
## 性能优化
- 🚀 按需加载依赖
- 🚀 Canvas复用
- 🚀 内存管理
- 🚀 大文件分块处理
- 🚀 用户体验优化(加载状态、进度提示)
## 未来扩展
- [ ] 支持更多导出格式DOCX、Excel等
- [ ] 批量导出功能
- [ ] 云端存储集成
- [ ] 自定义模板支持
- [ ] 水印和签名功能
## 注意事项
1. **图片跨域**: 确保所有图片资源支持CORS
2. **字体加载**: 确保自定义字体已完全加载
3. **内容大小**: 超大内容可能影响性能
4. **移动端**: 在移动设备上可能需要额外优化
5. **隐私**: 导出功能完全在客户端执行,不会上传数据
## 故障排除
### 常见问题
1. **图片不显示**: 检查图片CORS设置
2. **样式丢失**: 确保CSS已完全加载
3. **字体异常**: 检查字体文件加载状态
4. **内容截断**: 调整Canvas尺寸设置
5. **下载失败**: 检查浏览器下载权限
### 调试方法
```javascript
// 开启调试模式
const canvas = await html2canvas(element, {
logging: true, // 开启日志
debug: true // 调试模式
});
```
## 更新日志
### v1.0.0 (2025-01-21)
- ✅ 初始版本发布
- ✅ 支持PDF和PNG导出
- ✅ 集成到三个主要分析组件
- ✅ 完整的错误处理和用户体验

227
comparison-yijing.md Normal file
View File

@@ -0,0 +1,227 @@
# 易经占卜分析报告
**占卜者:** 午饭
**生成时间:** 2025/8/21 19:09:13
**分析类型:** 易经占卜
---
## ❓ 占卜问题
**问题:** 午饭
**起卦方法:** 梅花易数时间起卦法
**占卜时间:** 2025/8/21 19:09:13
**问题类型:** 综合运势
**关注重点:** 整体发展、综合状况、全面分析
## 🔮 卦象信息
### 主卦
**卦名:**
**卦象:**
_ _
___
_ _
_ _
_ _
___
**卦序:** 第47卦
### 变卦
**卦名:**
**卦象:**
_ _
___
___
_ _
___
___
### 八卦结构
**上卦:** 兑 (泽)
**下卦:** 坎 (水)
### 动爻
**动爻位置:** 1爻
## 📜 卦辞分析
### 卦象含义
【困卦】第47卦 - 困穷,困境,困顿
### 彖传
> 【彖传】曰:亨,贞,大人吉,无咎。有言不信。
### 象传
> 【象传】曰:泽无水,困。君子以致命遂志。
### 八卦组合分析
上卦兑(泽)代表悦,下卦坎(水)代表陷。泽在上,水在下,形成兑坎的组合,象征着特殊的能量组合,需要深入分析。
### 五行分析
**upper_element**
**lower_element**
**relationship** 金生水,相生有利
**balance** 五行相生,和谐发展,有利于事物的成长
## 🔄 动爻分析
**动爻数量:** 1爻
## 🔀 变卦分析
### 变卦含义
兑悦,喜悦,和悦
### 转化洞察
从【困】到【兑】的变化,预示着事态将从困穷,困境,困顿转向兑悦,喜悦,和悦,这是一个重要的转折点。需要适应这种变化,调整策略和心态。
### 变化指导
变卦指示:充满喜悦和和谐的氛围。保持和悦态度,增进人际和谐。
### 时机把握
变化的速度适中,需要保持关注
## 🔍 高级分析
### 互卦 - 蹇
**卦象:**
_ _
___
_ _
_ _
_ _
___
**含义:** 蹇难,困难,险阻
**分析:** 互卦【蹇】揭示了事物的内在发展趋势和隐藏因素。面临重重困难的时期。反省自身,修德养性,寻求贵人帮助。
### 错卦 - 坤
**卦象:**
_ _
_ _
_ _
_ _
_ _
_ _
**含义:** 接受,滋养,顺从
**分析:** 错卦【坤】代表了相对立的状态和需要避免的方向。以柔顺和包容的态度面对挑战。通过支持他人和耐心等待,将获得成功。
### 综卦 - 涣
**卦象:**
_ _
___
_ _
_ _
___
___
**含义:** 涣散,离散,化解
**分析:** 综卦【涣】显示了事物的另一面和可能的转化方向。化解涣散,重建秩序的时期。凝聚人心,重获团结。
### 四卦综合洞察
通过四卦分析:本卦【困】显示当前状态,互卦【蹇】揭示内在动力,错卦【坤】提醒对立面,综卦【涣】指示转化方向。综合来看,需要在困穷,困境,困顿的基础上,注意蹇难,困难,险阻的内在发展,避免接受,滋养,顺从的极端,向涣散,离散,化解的方向转化。
## 🔢 象数分析
### 上卦数理
**数字:** 2
**含义:** 上卦数字2对应兑卦泽象。在您的问题"午饭"中,这表示外在环境充满喜悦和交流的机会。泽象主悦,预示着通过良好的沟通和人际关系能够获得成功。
**影响:** 外在环境呈现泽的特质,需要以悦的方式应对
### 下卦数理
**数字:** 6
**含义:** 下卦数字6对应坎卦水象。在您的问题"午饭"中,这表示您内心深沉而有智慧。内在动力来自于对深层真理的探索。
### 组合能量
**总数:** 8
**解释:** 总数8在您的问题"午饭"中代表丰盛收获,是收获成果的时机。这个数字预示着您的努力将得到回报。
**和谐度:** 上下卦差异很大,需要深度调整和耐心化解
### 时间共振
**共振等级:** 需要调和
**时间能量:** 阴气渐盛,适合休息调养
**最佳时机:** 对于"午饭"建议在午时11:00-13:00把握收获时机
## 🧭 五行分析
### 五行属性
**上卦五行:**
**下卦五行:**
### 五行关系
**相互作用:** 金生水,相生有利
**平衡状态:** 五行相生,和谐发展,有利于事物的成长
## ⏰ 时间分析
### 月相影响
**月相能量:** 圆满充实
**月相建议:** 适合收获和庆祝
### 能量状态
**整体状态:** 旺盛之气与阴气渐盛相结合
**能量建议:** 在夏季的戌时,适合积极行动,同时休息调整
## 🎯 针对性指导
### 专业分析
针对您关于综合运势的问题,本卦【困】在整体发展、综合状况、全面分析方面的指示是:处于困境的时期。虽处困境,但保持正道,终将脱困。。 变卦【兑】预示着在整体发展、综合状况、全面分析方面将会有所转变。 结合当前的时间因素(夏季,戌时),建议您适合积极行动。
## 🎯 动态指导
### 实用建议
综合来看,本卦【困】的总体指导是:"处于困境的时期。虽处困境,但保持正道,终将脱困。"。 变卦【兑】提示未来趋势:"充满喜悦和和谐的氛围。保持和悦态度,增进人际和谐。"。
## 🌟 易经智慧
### 核心信息
坚持正道。变化在即,喜悦和谐。
### 行动建议
保持信念,寻求突破 当前正值夏季,适合积极行动。 现在是戌时,休息调整。 考虑到即将到来的变化,建议:保持和悦,增进和谐。
### 时机把握
时机分析:晚间时光,阴气渐盛,适合思考和规划。 夏季适合积极行动。
## 📖 哲学洞察
《易经》困卦的核心智慧在于:泽中无水,象征困穷。君子应舍命达成志向。。 而变卦兑则提醒我们:两泽相连,象征喜悦。君子应与朋友讲习道义。。 《易经》告诉我们,万事万物都在变化之中,智者应该顺应这种变化,在变化中寻找机遇,在稳定中积蓄力量。
---
*本报告由神机阁AI命理分析平台生成*
*生成时间2025/8/21 19:09:13*
*仅供参考,请理性对待*

0
debug-pdf.cjs Normal file
View File

622
dist/assets/index-CGu5zB0q.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-CW1NqJRG.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

18
dist/assets/index.es-DFUQDuH3.js vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/assets/purify.es-CQJ0hv7W.js vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -4,8 +4,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script type="module" crossorigin src="/assets/index-CegexIGf.js"></script> <script type="module" crossorigin src="/assets/index-CGu5zB0q.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D2qhzDKn.css"> <link rel="stylesheet" crossorigin href="/assets/index-CW1NqJRG.css">
</head> </head>
<body> <body>

209
package-lock.json generated
View File

@@ -47,8 +47,10 @@
"embla-carousel-react": "^8.5.2", "embla-carousel-react": "^8.5.2",
"express": "^4.18.2", "express": "^4.18.2",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"html2canvas": "^1.4.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jspdf": "^3.0.1",
"lucide-react": "^0.364.0", "lucide-react": "^0.364.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"nodemon": "^3.0.2", "nodemon": "^3.0.2",
@@ -3175,6 +3177,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/range-parser": { "node_modules/@types/range-parser": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
@@ -3249,6 +3258,13 @@
"@types/send": "*" "@types/send": "*"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/yauzl": { "node_modules/@types/yauzl": {
"version": "2.10.3", "version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@@ -3733,6 +3749,18 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/atob": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
"license": "(MIT OR Apache-2.0)",
"bin": {
"atob": "bin/atob.js"
},
"engines": {
"node": ">= 4.5.0"
}
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.20", "version": "10.4.20",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
@@ -3855,6 +3883,15 @@
} }
} }
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -4030,6 +4067,18 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/btoa": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
"integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
"license": "(MIT OR Apache-2.0)",
"bin": {
"btoa": "bin/btoa.js"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/buffer": { "node_modules/buffer": {
"version": "5.7.1", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@@ -4146,6 +4195,26 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -4876,6 +4945,18 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-js": {
"version": "3.45.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.1.tgz",
"integrity": "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cors": { "node_modules/cors": {
"version": "2.8.5", "version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
@@ -4929,6 +5010,15 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/cssesc": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -5217,6 +5307,16 @@
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
}, },
"node_modules/dompurify": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -5846,6 +5946,12 @@
"pend": "~1.2.0" "pend": "~1.2.0"
} }
}, },
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -6255,6 +6361,19 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -6627,6 +6746,24 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/jspdf": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.1.tgz",
"integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.7",
"atob": "^2.1.2",
"btoa": "^1.2.1",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.2.4",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jwa": { "node_modules/jwa": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
@@ -7392,6 +7529,13 @@
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7779,6 +7923,16 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -8107,6 +8261,13 @@
"decimal.js-light": "^2.4.1" "decimal.js-light": "^2.4.1"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -8155,6 +8316,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.46.2", "version": "4.46.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz",
@@ -8599,6 +8770,16 @@
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==" "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ=="
}, },
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -8785,6 +8966,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tailwind-merge": { "node_modules/tailwind-merge": {
"version": "2.6.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
@@ -8878,6 +9069,15 @@
"b4a": "^1.6.4" "b4a": "^1.6.4"
} }
}, },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/thenify": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -9236,6 +9436,15 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@@ -57,8 +57,10 @@
"embla-carousel-react": "^8.5.2", "embla-carousel-react": "^8.5.2",
"express": "^4.18.2", "express": "^4.18.2",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"html2canvas": "^1.4.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jspdf": "^3.0.1",
"lucide-react": "^0.364.0", "lucide-react": "^0.364.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"nodemon": "^3.0.2", "nodemon": "^3.0.2",

View File

@@ -4,7 +4,6 @@ const { dbManager } = require('../database/index.cjs');
const { generateMarkdown } = require('../services/generators/markdownGenerator.cjs'); const { generateMarkdown } = require('../services/generators/markdownGenerator.cjs');
const { generatePDF } = require('../services/generators/pdfGenerator.cjs'); const { generatePDF } = require('../services/generators/pdfGenerator.cjs');
const { generatePNG } = require('../services/generators/pngGenerator.cjs');
const router = express.Router(); const router = express.Router();
@@ -68,7 +67,7 @@ router.post('/', authenticate, async (req, res) => {
const minute = String(analysisDate.getMinutes()).padStart(2, '0'); const minute = String(analysisDate.getMinutes()).padStart(2, '0');
const second = String(analysisDate.getSeconds()).padStart(2, '0'); const second = String(analysisDate.getSeconds()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`; const dateStr = `${year}${month}${day}`;
const timeStr = `${hour}${minute}${second}`; const timeStr = `${hour}${minute}${second}`;
// 分析类型映射 // 分析类型映射
@@ -79,8 +78,9 @@ router.post('/', authenticate, async (req, res) => {
}; };
const analysisTypeName = analysisTypeMap[analysisType] || analysisType; const analysisTypeName = analysisTypeMap[analysisType] || analysisType;
const baseFilename = `${analysisTypeName}_${userName || 'user'}_${dateStr}_${timeStr}`; const exportMode = '服务器导出';
// 文件名格式: 八字命理_午饭_2025-08-21_133105 const baseFilename = `${analysisTypeName}_${userName || 'user'}_${exportMode}_${dateStr}_${timeStr}`;
// 文件名格式: 八字命理_午饭_服务器导出_20250821_133105
try { try {
switch (format) { switch (format) {
@@ -98,12 +98,7 @@ router.post('/', authenticate, async (req, res) => {
filename = `${baseFilename}.pdf`; filename = `${baseFilename}.pdf`;
break; break;
case 'png':
fileBuffer = await generatePNG(analysisData, analysisType, userName);
contentType = 'image/png';
fileExtension = 'png';
filename = `${baseFilename}.png`;
break;
} }
} catch (generationError) { } catch (generationError) {
console.error(`生成${format}文件失败:`, generationError); console.error(`生成${format}文件失败:`, generationError);

View File

@@ -1,634 +0,0 @@
/**
* PNG图片生成器
* 将分析结果转换为PNG图片格式
* 使用Puppeteer将SVG转换为PNG
*/
const puppeteer = require('puppeteer');
const generatePNG = async (analysisData, analysisType, userName) => {
let browser;
try {
// 生成SVG内容
const svgContent = await generateImageData(analysisData, analysisType, userName);
// 创建包含SVG的HTML页面
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { margin: 0; padding: 0; }
svg { display: block; }
</style>
</head>
<body>
${svgContent}
</body>
</html>`;
// 启动puppeteer浏览器
browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--no-first-run',
'--disable-extensions',
'--disable-plugins'
],
timeout: 30000
});
const page = await browser.newPage();
// 设置页面内容
await page.setContent(htmlContent, {
waitUntil: 'networkidle0'
});
// 设置视口大小
await page.setViewport({ width: 800, height: 1200 });
// 截图生成PNG
const pngBuffer = await page.screenshot({
type: 'png',
fullPage: true,
omitBackground: false
});
// 确保返回的是Buffer对象
if (!Buffer.isBuffer(pngBuffer)) {
console.warn('Puppeteer返回的不是Buffer正在转换:', typeof pngBuffer);
return Buffer.from(pngBuffer);
}
return pngBuffer;
} catch (error) {
console.error('生成PNG失败:', error);
throw error;
} finally {
if (browser) {
await browser.close();
}
}
};
/**
* 生成图片数据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
};

View File

@@ -276,14 +276,15 @@ const CompleteBaziAnalysis: React.FC<CompleteBaziAnalysisProps> = ({ birthDate,
return ( return (
<div className="min-h-screen bg-gradient-to-br from-red-50 to-yellow-50 py-8"> <div className="min-h-screen bg-gradient-to-br from-red-50 to-yellow-50 py-8">
<div className="max-w-7xl mx-auto px-4 space-y-8"> <div className="max-w-7xl mx-auto px-4 space-y-8" id="bazi-analysis-content" data-export-content>
{/* 下载按钮 */} {/* 下载按钮 */}
<div className="flex justify-end"> <div className="flex justify-end no-export" data-no-export>
<DownloadButton <DownloadButton
analysisData={analysisData} analysisData={analysisData}
analysisType="bazi" analysisType="bazi"
userName={birthDate.name} userName={birthDate.name}
targetElementId="bazi-analysis-content"
className="sticky top-4 z-10" className="sticky top-4 z-10"
/> />
</div> </div>

View File

@@ -264,14 +264,15 @@ const CompleteYijingAnalysis: React.FC<CompleteYijingAnalysisProps> = ({
return ( return (
<div className="min-h-screen bg-gradient-to-br from-red-50 to-yellow-50 py-8"> <div className="min-h-screen bg-gradient-to-br from-red-50 to-yellow-50 py-8">
<div className="max-w-7xl mx-auto px-4 space-y-8"> <div className="max-w-7xl mx-auto px-4 space-y-8" id="yijing-analysis-content" data-export-content>
{/* 下载按钮 */} {/* 下载按钮 */}
<div className="flex justify-end"> <div className="flex justify-end no-export" data-no-export>
<DownloadButton <DownloadButton
analysisData={analysisData} analysisData={analysisData}
analysisType="yijing" analysisType="yijing"
userName={question ? `占卜_${question.substring(0, 10)}` : 'user'} userName={question ? `占卜_${question.substring(0, 10)}` : 'user'}
targetElementId="yijing-analysis-content"
className="sticky top-4 z-10" className="sticky top-4 z-10"
/> />
</div> </div>

View File

@@ -579,14 +579,15 @@ const CompleteZiweiAnalysis: React.FC<CompleteZiweiAnalysisProps> = ({ birthDate
return ( return (
<div className="min-h-screen bg-gradient-to-br from-purple-50 to-indigo-50 py-8"> <div className="min-h-screen bg-gradient-to-br from-purple-50 to-indigo-50 py-8">
<div className="max-w-7xl mx-auto px-4 space-y-8"> <div className="max-w-7xl mx-auto px-4 space-y-8" id="ziwei-analysis-content" data-export-content>
{/* 下载按钮 */} {/* 下载按钮 */}
<div className="flex justify-end"> <div className="flex justify-end no-export" data-no-export>
<DownloadButton <DownloadButton
analysisData={analysisData} analysisData={analysisData}
analysisType="ziwei" analysisType="ziwei"
userName={birthDate.name} userName={birthDate.name}
targetElementId="ziwei-analysis-content"
className="sticky top-4 z-10" className="sticky top-4 z-10"
/> />
</div> </div>

View File

@@ -1,10 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Download, FileText, FileImage, File, Loader2, ChevronDown } from 'lucide-react'; import { Download, FileText, FileImage, File, Loader2, ChevronDown, Printer, Camera } from 'lucide-react';
import { ChineseButton } from './ChineseButton'; import { ChineseButton } from './ChineseButton';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
export type DownloadFormat = 'markdown' | 'pdf' | 'png'; export type DownloadFormat = 'markdown' | 'pdf' | 'png';
export type ExportMode = 'server' | 'frontend';
interface DownloadButtonProps { interface DownloadButtonProps {
analysisData: any; analysisData: any;
@@ -13,6 +16,7 @@ interface DownloadButtonProps {
onDownload?: (format: DownloadFormat) => Promise<void>; onDownload?: (format: DownloadFormat) => Promise<void>;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
targetElementId?: string; // 用于前端导出的目标元素ID
} }
const DownloadButton: React.FC<DownloadButtonProps> = ({ const DownloadButton: React.FC<DownloadButtonProps> = ({
@@ -21,40 +25,71 @@ const DownloadButton: React.FC<DownloadButtonProps> = ({
userName, userName,
onDownload, onDownload,
className, className,
disabled = false disabled = false,
targetElementId
}) => { }) => {
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const [downloadingFormat, setDownloadingFormat] = useState<DownloadFormat | null>(null); const [downloadingFormat, setDownloadingFormat] = useState<DownloadFormat | null>(null);
const [showDropdown, setShowDropdown] = useState(false); const [showDropdown, setShowDropdown] = useState(false);
const formatOptions = [ const allFormatOptions = [
{ {
format: 'markdown' as DownloadFormat, format: 'markdown' as DownloadFormat,
label: 'Markdown文档', label: 'Markdown文档',
icon: FileText, icon: FileText,
description: '结构化文本格式,便于编辑', description: '结构化文本格式,便于编辑',
color: 'text-blue-600', color: 'text-blue-600',
bgColor: 'bg-blue-50 hover:bg-blue-100' bgColor: 'bg-blue-50 hover:bg-blue-100',
mode: 'server' as ExportMode
}, },
{ {
format: 'pdf' as DownloadFormat, format: 'pdf' as DownloadFormat,
label: 'PDF文档', label: 'PDF文档(服务器生成)',
icon: File, icon: File,
description: '专业格式,便于打印和分享', description: '服务器生成的PDF文档',
color: 'text-red-600', color: 'text-red-600',
bgColor: 'bg-red-50 hover:bg-red-100' bgColor: 'bg-red-50 hover:bg-red-100',
mode: 'server' as ExportMode
}, },
{
format: 'pdf' as DownloadFormat,
label: 'PDF文档页面导出',
icon: Printer,
description: '直接从页面生成PDF分页格式',
color: 'text-purple-600',
bgColor: 'bg-purple-50 hover:bg-purple-100',
mode: 'frontend' as ExportMode
},
{ {
format: 'png' as DownloadFormat, format: 'png' as DownloadFormat,
label: 'PNG图片', label: 'PNG长图(页面导出)',
icon: FileImage, icon: Camera,
description: '高清图片格式,便于保存', description: '直接从页面生成PNG长图',
color: 'text-green-600', color: 'text-teal-600',
bgColor: 'bg-green-50 hover:bg-green-100' bgColor: 'bg-teal-50 hover:bg-teal-100',
mode: 'frontend' as ExportMode
} }
]; ];
const handleDownload = async (format: DownloadFormat) => { // 根据是否有targetElementId来过滤选项
const formatOptions = allFormatOptions.filter(option => {
// 如果是前端导出模式需要有targetElementId才显示
if (option.mode === 'frontend') {
return !!targetElementId;
}
// 服务器模式总是显示
return true;
});
console.log('DownloadButton配置:', {
targetElementId,
totalOptions: allFormatOptions.length,
availableOptions: formatOptions.length,
frontendOptionsAvailable: formatOptions.filter(o => o.mode === 'frontend').length
});
const handleDownload = async (format: DownloadFormat, mode: ExportMode = 'server') => {
if (disabled || isDownloading) return; if (disabled || isDownloading) return;
try { try {
@@ -62,21 +97,193 @@ const DownloadButton: React.FC<DownloadButtonProps> = ({
setDownloadingFormat(format); setDownloadingFormat(format);
setShowDropdown(false); setShowDropdown(false);
if (onDownload) { if (mode === 'frontend') {
// 前端导出逻辑
await frontendExport(format);
} else if (onDownload) {
await onDownload(format); await onDownload(format);
} else { } else {
// 默认下载逻辑 // 默认服务器下载逻辑
await defaultDownload(format); await defaultDownload(format);
} }
} catch (error) { } catch (error) {
console.error('下载失败:', error); console.error('下载失败:', error);
// 这里可以添加错误提示 // 显示错误提示
if (typeof window !== 'undefined' && (window as any).toast) {
(window as any).toast.error(`下载失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
} finally { } finally {
setIsDownloading(false); setIsDownloading(false);
setDownloadingFormat(null); setDownloadingFormat(null);
} }
}; };
// 前端导出功能
const frontendExport = async (format: DownloadFormat) => {
console.log('开始前端导出,格式:', format, '目标元素ID:', targetElementId);
if (!targetElementId) {
const error = '未指定导出目标元素ID无法使用前端导出功能';
console.error(error);
throw new Error(error);
}
const element = document.getElementById(targetElementId);
console.log('查找目标元素:', targetElementId, '找到元素:', element);
if (!element) {
const error = `未找到ID为"${targetElementId}"的元素,请确认页面已完全加载`;
console.error(error);
throw new Error(error);
}
console.log('目标元素尺寸:', {
width: element.offsetWidth,
height: element.offsetHeight,
scrollWidth: element.scrollWidth,
scrollHeight: element.scrollHeight
});
if (format === 'png') {
await exportToPNG(element);
} else if (format === 'pdf') {
await exportToPDF(element);
}
};
// 导出为PNG
const exportToPNG = async (element: HTMLElement): Promise<void> => {
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
scrollX: 0,
scrollY: 0,
logging: false,
onclone: (clonedDoc) => {
const elementsToHide = clonedDoc.querySelectorAll(
'.no-export, [data-no-export], .fixed, .sticky, .floating'
);
elementsToHide.forEach(el => {
(el as HTMLElement).style.display = 'none';
});
}
});
const link = document.createElement('a');
const fileName = getFileName('png', 'frontend');
link.download = fileName;
link.href = canvas.toDataURL('image/png', 1.0);
link.click();
// 显示成功提示
if (typeof window !== 'undefined' && (window as any).toast) {
(window as any).toast.success('PNG长图导出成功');
}
};
// 导出为PDF
const exportToPDF = async (element: HTMLElement): Promise<void> => {
const canvas = await html2canvas(element, {
scale: 1.5,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
scrollX: 0,
scrollY: 0,
logging: false,
onclone: (clonedDoc) => {
const elementsToHide = clonedDoc.querySelectorAll(
'.no-export, [data-no-export], .fixed, .sticky, .floating'
);
elementsToHide.forEach(el => {
(el as HTMLElement).style.display = 'none';
});
}
});
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4'
});
const pdfWidth = 210;
const pdfHeight = 297;
const margin = 10;
const contentWidth = pdfWidth - 2 * margin;
const contentHeight = pdfHeight - 2 * margin;
const imgWidth = canvas.width;
const imgHeight = canvas.height;
// 优先填满宽度,让内容宽度占满页面
const widthRatio = contentWidth / (imgWidth * 0.264583);
const scaledWidth = contentWidth; // 直接使用全部可用宽度
const scaledHeight = imgHeight * 0.264583 * widthRatio;
const pageHeight = contentHeight;
const totalPages = Math.ceil(scaledHeight / pageHeight);
for (let i = 0; i < totalPages; i++) {
if (i > 0) {
pdf.addPage();
}
const yOffset = -i * pageHeight;
pdf.addImage(
imgData,
'PNG',
margin,
margin + yOffset,
scaledWidth,
scaledHeight
);
}
const fileName = getFileName('pdf', 'frontend');
pdf.save(fileName);
// 显示成功提示
if (typeof window !== 'undefined' && (window as any).toast) {
(window as any).toast.success('PDF文档导出成功');
}
};
// 生成文件名
const getFileName = (format: string, mode: ExportMode = 'server') => {
const typeLabel = getAnalysisTypeLabel();
const userPart = userName || 'user';
const exportMode = mode === 'frontend' ? '页面导出' : '服务器导出';
// 获取分析报告生成时间
let analysisDate;
if (analysisData?.created_at) {
analysisDate = new Date(analysisData.created_at);
} else if (analysisData?.basic_info?.created_at) {
analysisDate = new Date(analysisData.basic_info.created_at);
} else if (analysisData?.metadata?.analysis_time) {
analysisDate = new Date(analysisData.metadata.analysis_time);
} else {
// 如果没有分析时间,使用当前时间作为备用
analysisDate = new Date();
}
const year = analysisDate.getFullYear();
const month = String(analysisDate.getMonth() + 1).padStart(2, '0');
const day = String(analysisDate.getDate()).padStart(2, '0');
const hour = String(analysisDate.getHours()).padStart(2, '0');
const minute = String(analysisDate.getMinutes()).padStart(2, '0');
const second = String(analysisDate.getSeconds()).padStart(2, '0');
const dateStr = `${year}${month}${day}`;
const timeStr = `${hour}${minute}${second}`;
return `${typeLabel}_${userPart}_${exportMode}_${dateStr}_${timeStr}.${format}`;
};
const defaultDownload = async (format: DownloadFormat) => { const defaultDownload = async (format: DownloadFormat) => {
try { try {
// 获取认证token // 获取认证token
@@ -131,9 +338,10 @@ const DownloadButton: React.FC<DownloadButtonProps> = ({
const minute = String(analysisDate.getMinutes()).padStart(2, '0'); const minute = String(analysisDate.getMinutes()).padStart(2, '0');
const second = String(analysisDate.getSeconds()).padStart(2, '0'); const second = String(analysisDate.getSeconds()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`; const dateStr = `${year}${month}${day}`;
const timeStr = `${hour}${minute}${second}`; const timeStr = `${hour}${minute}${second}`;
let filename = `${getAnalysisTypeLabel()}_${userName || 'user'}_${dateStr}_${timeStr}.${format === 'markdown' ? 'md' : format}`; const exportMode = '服务器导出';
let filename = `${getAnalysisTypeLabel()}_${userName || 'user'}_${exportMode}_${dateStr}_${timeStr}.${format === 'markdown' ? 'md' : format}`;
if (contentDisposition) { if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=(['"]?)([^'"\n]*?)\1/); const filenameMatch = contentDisposition.match(/filename[^;=\n]*=(['"]?)([^'"\n]*?)\1/);
@@ -189,7 +397,7 @@ const DownloadButton: React.FC<DownloadButtonProps> = ({
case 'markdown': return 'Markdown'; case 'markdown': return 'Markdown';
case 'pdf': return 'PDF'; case 'pdf': return 'PDF';
case 'png': return 'PNG'; case 'png': return 'PNG';
default: return format.toUpperCase(); default: return '';
} }
}; };
@@ -241,8 +449,8 @@ const DownloadButton: React.FC<DownloadButtonProps> = ({
return ( return (
<button <button
key={option.format} key={`${option.format}-${option.mode}`}
onClick={() => handleDownload(option.format)} onClick={() => handleDownload(option.format, option.mode)}
disabled={disabled || isDownloading} disabled={disabled || isDownloading}
className={cn( className={cn(
'w-full flex items-center space-x-3 p-3 rounded-lg transition-all duration-200', 'w-full flex items-center space-x-3 p-3 rounded-lg transition-all duration-200',

View File

@@ -156,8 +156,8 @@ const HistoryPage: React.FC = () => {
if (viewingResult && selectedReading) { if (viewingResult && selectedReading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6" id="history-analysis-content" data-export-content>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between no-export" data-no-export>
<ChineseButton <ChineseButton
variant="outline" variant="outline"
onClick={() => setViewingResult(false)} onClick={() => setViewingResult(false)}

0
test-download-flow.cjs Normal file
View File

0
test-pdf.cjs Normal file
View File

0
test-pdf.js Normal file
View File

0
test-yijing-pdf.cjs Normal file
View File