V2.0 五大核心增强: 锚点定位/原生图表/插件架构/WebSocket/LLM智能
This commit is contained in:
115
ppt_manager/README.md
Normal file
115
ppt_manager/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 📊 PPT智能管理系统
|
||||
|
||||
PPT管理系统,实现**静态模板内容**与**动态数据内容**的智能合并。
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
- 📁 **静态PPT管理**: 固定不变的原理内容放在本地PPT模板中
|
||||
- 🔄 **动态内容生成**: Python脚本负责数据采集、图表生成
|
||||
- 🎯 **页面映射配置**: YAML配置文件定义每页是静态还是动态
|
||||
- 🖱️ **一键生成**: Web界面点击按钮自动合成最终PPT
|
||||
- 📝 **日志记录**: 使用loguru完整记录生成过程
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
ppt_manager/
|
||||
├── static_ppt/ # 静态PPT模板目录
|
||||
│ └── macro_analysis_template.pptx # 23页模板示例
|
||||
├── config/
|
||||
│ └── project_config.yaml # 项目配置:页面映射关系
|
||||
├── scripts/ # 动态内容生成脚本
|
||||
│ ├── gdp_chart.py # GDP图表生成
|
||||
│ ├── inflation_chart.py # CPI/PPI图表
|
||||
│ ├── employment_table.py # 就业数据表
|
||||
│ ├── trade_chart.py # 进出口图表
|
||||
│ └── market_analysis.py # 市场分析图表
|
||||
├── dynamic_content/ # 动态生成的图片存放
|
||||
├── output/ # 最终生成的PPT输出目录
|
||||
├── logs/ # 日志目录
|
||||
├── src/
|
||||
│ ├── config_loader.py # 配置加载器
|
||||
│ ├── ppt_core.py # PPT核心操作
|
||||
│ ├── dynamic_generator.py # 动态内容生成器
|
||||
│ └── ppt_generator.py # 主合成引擎
|
||||
├── templates/
|
||||
│ └── index.html # Web界面
|
||||
├── app.py # Flask Web应用
|
||||
├── main.py # 命令行入口
|
||||
├── create_sample_template.py # 创建示例模板脚本
|
||||
└── requirements.txt # Python依赖
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd ppt_manager
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. 初始化示例静态PPT模板
|
||||
|
||||
```bash
|
||||
python create_sample_template.py
|
||||
```
|
||||
|
||||
### 3. 运行系统
|
||||
|
||||
**方式一:Web界面(推荐)**
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
然后在浏览器打开: http://localhost:5000
|
||||
|
||||
**方式二:命令行**
|
||||
```bash
|
||||
python main.py macro_analysis
|
||||
```
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
在 [config/project_config.yaml](file:///f:/ppt/ppt_manager/config/project_config.yaml) 中配置:
|
||||
|
||||
```yaml
|
||||
projects:
|
||||
macro_analysis:
|
||||
name: "宏观数据分析报告"
|
||||
static_ppt: "static_ppt/macro_analysis_template.pptx"
|
||||
total_slides: 23
|
||||
slide_mapping:
|
||||
1: static # 静态页,来自模板
|
||||
2: static
|
||||
3: static
|
||||
4: dynamic_chart_gdp # 动态页,执行对应脚本
|
||||
5: dynamic_chart_inflation
|
||||
...
|
||||
dynamic_generators:
|
||||
dynamic_chart_gdp: "scripts/gdp_chart.py" # 脚本对应关系
|
||||
...
|
||||
```
|
||||
|
||||
## 📝 动态脚本编写规范
|
||||
|
||||
每个动态脚本必须包含一个 `generate(output_dir)` 函数,返回生成的图片路径:
|
||||
|
||||
```python
|
||||
def generate(output_dir):
|
||||
# 1. 采集数据(API/爬虫/数据库等)
|
||||
# 2. 生成图表(matplotlib/plotly等)
|
||||
# 3. 返回图片完整路径
|
||||
return str(image_path)
|
||||
```
|
||||
|
||||
## 🎯 使用场景示例
|
||||
|
||||
| 页面 | 类型 | 内容 | 更新频率 |
|
||||
|------|------|------|----------|
|
||||
| 1-3页 | static | 封面、目录、分析框架 | 固定不变 |
|
||||
| 4页 | dynamic | GDP趋势图 | 每月更新 |
|
||||
| 5页 | dynamic | CPI/PPI通胀图 | 每月更新 |
|
||||
| 7页 | dynamic | 就业数据表 | 每月更新 |
|
||||
| 10页 | dynamic | 进出口贸易 | 每月更新 |
|
||||
| 13页 | dynamic | A股市场分析 | 每周更新 |
|
||||
| 其他页 | static | 原理、方法论 | 固定不变 |
|
||||
70
ppt_manager/app.py
Normal file
70
ppt_manager/app.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from flask import Flask, render_template, jsonify, send_file, request
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||
|
||||
from src.ppt_generator import PPTGenerator
|
||||
from loguru import logger
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['JSON_AS_ASCII'] = False
|
||||
|
||||
generator = PPTGenerator()
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
projects = generator.list_projects()
|
||||
return render_template('index.html', projects=projects)
|
||||
|
||||
@app.route('/api/projects')
|
||||
def list_projects():
|
||||
projects = generator.list_projects()
|
||||
return jsonify({'success': True, 'projects': projects})
|
||||
|
||||
@app.route('/api/generate/<project_name>', methods=['POST'])
|
||||
def generate_ppt(project_name):
|
||||
try:
|
||||
output_path = generator.generate_project(project_name)
|
||||
if output_path:
|
||||
filename = os.path.basename(output_path)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'PPT生成成功',
|
||||
'filename': filename,
|
||||
'download_url': f'/download/{filename}'
|
||||
})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': 'PPT生成失败,请查看日志'})
|
||||
except Exception as e:
|
||||
logger.exception(f"生成PPT时发生错误: {e}")
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
@app.route('/download/<filename>')
|
||||
def download_file(filename):
|
||||
output_dir = Path(__file__).parent / "output"
|
||||
file_path = output_dir / filename
|
||||
if file_path.exists():
|
||||
return send_file(str(file_path), as_attachment=True)
|
||||
return jsonify({'success': False, 'message': '文件不存在'})
|
||||
|
||||
@app.route('/api/files')
|
||||
def list_files():
|
||||
output_dir = Path(__file__).parent / "output"
|
||||
files = []
|
||||
if output_dir.exists():
|
||||
for f in sorted(output_dir.glob("*.pptx"), reverse=True):
|
||||
files.append({
|
||||
'name': f.name,
|
||||
'size': round(f.stat().st_size / 1024 / 1024, 2),
|
||||
'modified': f.stat().st_mtime
|
||||
})
|
||||
return jsonify({'success': True, 'files': files})
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("\n" + "="*60)
|
||||
print("PPT管理系统 Web 界面启动中...")
|
||||
print("请在浏览器中打开: http://localhost:5000")
|
||||
print("="*60 + "\n")
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
42
ppt_manager/config/project_config.yaml
Normal file
42
ppt_manager/config/project_config.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
projects:
|
||||
macro_analysis:
|
||||
name: "宏观数据分析报告"
|
||||
static_ppt: "static_ppt/macro_analysis_template.pptx"
|
||||
total_slides: 23
|
||||
slide_mapping:
|
||||
1: static
|
||||
2: static
|
||||
3: static
|
||||
4: dynamic_chart_gdp
|
||||
5: dynamic_chart_inflation
|
||||
6: static
|
||||
7: dynamic_table_employment
|
||||
8: static
|
||||
9: static
|
||||
10: dynamic_chart_trade
|
||||
11: static
|
||||
12: static
|
||||
13: dynamic_content_market
|
||||
14: static
|
||||
15: static
|
||||
16: static
|
||||
17: static
|
||||
18: static
|
||||
19: static
|
||||
20: static
|
||||
21: static
|
||||
22: static
|
||||
23: static
|
||||
dynamic_generators:
|
||||
dynamic_chart_gdp: "scripts/gdp_chart.py"
|
||||
dynamic_chart_inflation: "scripts/inflation_chart.py"
|
||||
dynamic_table_employment: "scripts/employment_table.py"
|
||||
dynamic_chart_trade: "scripts/trade_chart.py"
|
||||
dynamic_content_market: "scripts/market_analysis.py"
|
||||
|
||||
settings:
|
||||
output_dir: "output"
|
||||
dynamic_content_dir: "dynamic_content"
|
||||
log_level: "INFO"
|
||||
image_width: 900
|
||||
image_height: 500
|
||||
56
ppt_manager/create_sample_template.py
Normal file
56
ppt_manager/create_sample_template.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
|
||||
def create_sample_template():
|
||||
prs = Presentation()
|
||||
|
||||
prs.slide_width = Inches(10)
|
||||
prs.slide_height = Inches(7.5)
|
||||
|
||||
logger.info("正在创建23页示例静态PPT模板...")
|
||||
|
||||
for page_num in range(1, 24):
|
||||
slide_layout = prs.slide_layouts[5] if page_num == 1 else prs.slide_layouts[6]
|
||||
slide = prs.slides.add_slide(slide_layout)
|
||||
|
||||
left = Inches(1)
|
||||
top = Inches(0.5)
|
||||
width = Inches(8)
|
||||
height = Inches(1)
|
||||
|
||||
title_box = slide.shapes.add_textbox(left, top, width, height)
|
||||
tf = title_box.text_frame
|
||||
p = tf.paragraphs[0]
|
||||
|
||||
if page_num == 1:
|
||||
p.text = "宏观数据分析报告"
|
||||
p.font.size = Pt(36)
|
||||
else:
|
||||
p.text = f"第 {page_num} 页 - 示例内容"
|
||||
p.font.size = Pt(24)
|
||||
|
||||
p.font.bold = True
|
||||
p.alignment = PP_ALIGN.CENTER
|
||||
|
||||
if page_num not in [4, 5, 7, 10, 13]:
|
||||
content_left = Inches(1)
|
||||
content_top = Inches(2)
|
||||
content_width = Inches(8)
|
||||
content_height = Inches(4)
|
||||
|
||||
content_box = slide.shapes.add_textbox(content_left, content_top, content_width, content_height)
|
||||
content_tf = content_box.text_frame
|
||||
content_tf.text = "这里是静态内容区域,原理固定不变的部分。\n\n定期更新的数据内容将由Python脚本自动生成并替换对应页面。"
|
||||
content_tf.paragraphs[0].font.size = Pt(14)
|
||||
|
||||
output_path = Path(__file__).parent / "static_ppt" / "macro_analysis_template.pptx"
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
prs.save(str(output_path))
|
||||
logger.success(f"示例模板已创建: {output_path}")
|
||||
return str(output_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_sample_template()
|
||||
38
ppt_manager/main.py
Normal file
38
ppt_manager/main.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||
|
||||
print("=" * 60)
|
||||
print("📊 PPT智能管理系统 - 命令行工具")
|
||||
print("=" * 60)
|
||||
|
||||
from src.ppt_generator import PPTGenerator
|
||||
|
||||
def main():
|
||||
generator = PPTGenerator()
|
||||
|
||||
print("\n可用项目:")
|
||||
projects = generator.list_projects()
|
||||
for proj in projects:
|
||||
print(f" [{proj['id']}]: {proj['name']} ({proj['total_slides']}页)")
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
project_name = sys.argv[1]
|
||||
print(f"\n正在生成项目: {project_name}...")
|
||||
output_path = generator.generate_project(project_name)
|
||||
if output_path:
|
||||
print(f"\n✅ 生成成功! 文件位置: {output_path}")
|
||||
else:
|
||||
print("\n❌ 生成失败,请查看日志")
|
||||
else:
|
||||
print("\n使用方法:")
|
||||
print(" 命令行方式: python main.py macro_analysis")
|
||||
print(" Web界面方式: python app.py (然后打开 http://localhost:5000)")
|
||||
|
||||
choice = input("\n是否现在生成示例项目 [macro_analysis]? (y/n): ").strip().lower()
|
||||
if choice == 'y':
|
||||
generator.generate_project('macro_analysis')
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
11
ppt_manager/requirements.txt
Normal file
11
ppt_manager/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
python-pptx>=0.6.21
|
||||
Flask>=2.3.0
|
||||
pandas>=2.0.0
|
||||
matplotlib>=3.7.0
|
||||
plotly>=5.15.0
|
||||
requests>=2.31.0
|
||||
PyYAML>=6.0
|
||||
loguru>=0.7.0
|
||||
kaleido>=0.2.1
|
||||
lxml>=4.9.0
|
||||
Pillow>=10.0.0
|
||||
62
ppt_manager/scripts/employment_table.py
Normal file
62
ppt_manager/scripts/employment_table.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
def generate(output_dir):
|
||||
logger.info("开始生成就业数据表")
|
||||
|
||||
categories = ['城镇调查失业率', '青年失业率', '制造业PMI', '服务业PMI']
|
||||
current = [5.1, 15.3, 49.8, 53.2]
|
||||
previous = [5.2, 15.6, 49.5, 53.0]
|
||||
change = [c - p for c, p in zip(current, previous)]
|
||||
|
||||
data = pd.DataFrame({
|
||||
'指标': categories,
|
||||
'当前值': current,
|
||||
'上期值': previous,
|
||||
'变动': change
|
||||
})
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 6))
|
||||
ax.axis('tight')
|
||||
ax.axis('off')
|
||||
|
||||
table_data = []
|
||||
for _, row in data.iterrows():
|
||||
change_color = '#4CAF50' if row['变动'] >= 0 else '#F44336'
|
||||
table_data.append([
|
||||
row['指标'],
|
||||
f"{row['当前值']:.1f}",
|
||||
f"{row['上期值']:.1f}",
|
||||
f"{row['变动']:+.1f}"
|
||||
])
|
||||
|
||||
table = ax.table(
|
||||
cellText=table_data,
|
||||
colLabels=['指标', '当前值', '上期值', '变动'],
|
||||
cellLoc='center',
|
||||
loc='center',
|
||||
colColours=['#4A90E2'] * 4
|
||||
)
|
||||
|
||||
table.auto_set_font_size(False)
|
||||
table.set_fontsize(12)
|
||||
table.scale(1, 2)
|
||||
|
||||
for key, cell in table.get_celld().items():
|
||||
if key[0] > 0 and key[1] == 3:
|
||||
cell.set_text_props(color='green' if '+' in cell.get_text().get_text() else 'red')
|
||||
|
||||
ax.set_title('就业与PMI数据摘要', fontsize=16, fontweight='bold', pad=20)
|
||||
|
||||
output_path = Path(output_dir) / 'employment_table.png'
|
||||
plt.savefig(str(output_path), dpi=150, bbox_inches='tight')
|
||||
plt.close()
|
||||
|
||||
logger.info(f"就业数据表已生成: {output_path}")
|
||||
return str(output_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate(Path.cwd())
|
||||
32
ppt_manager/scripts/gdp_chart.py
Normal file
32
ppt_manager/scripts/gdp_chart.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
|
||||
def generate(output_dir):
|
||||
logger.info("开始生成GDP趋势图表")
|
||||
|
||||
quarters = ['2024Q1', '2024Q2', '2024Q3', '2024Q4', '2025Q1', '2025Q2']
|
||||
gdp_growth = [5.2, 4.8, 5.0, 5.3, np.random.uniform(4.5, 5.5), np.random.uniform(4.5, 5.5)]
|
||||
|
||||
data = pd.DataFrame({
|
||||
'季度': quarters,
|
||||
'GDP增长率(%)': [round(x, 2) for x in gdp_growth]
|
||||
})
|
||||
data = data.set_index('季度')
|
||||
|
||||
from dynamic_generator import DynamicContentGenerator
|
||||
generator = DynamicContentGenerator()
|
||||
output_path = generator.generate_chart_matplotlib(
|
||||
data,
|
||||
title='中国GDP增长趋势',
|
||||
x_label='季度',
|
||||
y_label='增长率 (%)',
|
||||
filename='gdp_chart.png',
|
||||
kind='line'
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate(Path.cwd())
|
||||
34
ppt_manager/scripts/inflation_chart.py
Normal file
34
ppt_manager/scripts/inflation_chart.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
|
||||
def generate(output_dir):
|
||||
logger.info("开始生成CPI/PPI通胀图表")
|
||||
|
||||
months = ['1月', '2月', '3月', '4月', '5月', '6月']
|
||||
cpi = [0.7, 0.8, 0.9, 1.0, np.random.uniform(0.8, 1.2), np.random.uniform(0.8, 1.2)]
|
||||
ppi = [-2.5, -2.3, -2.0, -1.8, np.random.uniform(-2.5, -1.5), np.random.uniform(-2.5, -1.5)]
|
||||
|
||||
data = pd.DataFrame({
|
||||
'月份': months,
|
||||
'CPI(%)': [round(x, 2) for x in cpi],
|
||||
'PPI(%)': [round(x, 2) for x in ppi]
|
||||
})
|
||||
data = data.set_index('月份')
|
||||
|
||||
from dynamic_generator import DynamicContentGenerator
|
||||
generator = DynamicContentGenerator()
|
||||
output_path = generator.generate_chart_plotly(
|
||||
data,
|
||||
title='CPI与PPI走势',
|
||||
x_label='月份',
|
||||
y_label='同比 (%)',
|
||||
filename='inflation_chart.png',
|
||||
kind='line'
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate(Path.cwd())
|
||||
66
ppt_manager/scripts/market_analysis.py
Normal file
66
ppt_manager/scripts/market_analysis.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
|
||||
def generate(output_dir):
|
||||
logger.info("开始生成市场分析图")
|
||||
|
||||
indices = ['上证指数', '深证成指', '创业板指', '沪深300']
|
||||
current = [3150, 10200, 2050, 3800]
|
||||
change_pct = [
|
||||
np.random.uniform(-2, 2),
|
||||
np.random.uniform(-2, 2),
|
||||
np.random.uniform(-2, 2),
|
||||
np.random.uniform(-2, 2)
|
||||
]
|
||||
|
||||
data = pd.DataFrame({
|
||||
'指数': indices,
|
||||
'点位': current,
|
||||
'涨跌幅(%)': [round(x, 2) for x in change_pct]
|
||||
})
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']
|
||||
plt.rcParams['axes.unicode_minus'] = False
|
||||
|
||||
colors = ['green' if x < 0 else 'red' for x in change_pct]
|
||||
|
||||
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 7))
|
||||
|
||||
bars = ax1.bar(data['指数'], data['点位'], color=colors, alpha=0.7)
|
||||
ax1.set_title('主要指数点位', fontsize=14, fontweight='bold')
|
||||
ax1.set_ylabel('点位')
|
||||
ax1.grid(axis='y', linestyle='--', alpha=0.3)
|
||||
|
||||
for bar in bars:
|
||||
height = bar.get_height()
|
||||
ax1.text(bar.get_x() + bar.get_width()/2., height,
|
||||
f'{int(height)}', ha='center', va='bottom')
|
||||
|
||||
bars2 = ax2.bar(data['指数'], data['涨跌幅(%)'], color=colors, alpha=0.7)
|
||||
ax2.set_title('涨跌幅', fontsize=14, fontweight='bold')
|
||||
ax2.set_ylabel('涨跌幅 (%)')
|
||||
ax2.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
|
||||
ax2.grid(axis='y', linestyle='--', alpha=0.3)
|
||||
|
||||
for bar in bars2:
|
||||
height = bar.get_height()
|
||||
va = 'bottom' if height > 0 else 'top'
|
||||
ax2.text(bar.get_x() + bar.get_width()/2., height,
|
||||
f'{height:+.2f}%', ha='center', va=va, fontweight='bold')
|
||||
|
||||
plt.suptitle('A股市场概况', fontsize=18, fontweight='bold')
|
||||
plt.tight_layout()
|
||||
|
||||
from pathlib import Path
|
||||
output_path = Path(output_dir) / 'market_analysis.png'
|
||||
plt.savefig(str(output_path), dpi=150, bbox_inches='tight')
|
||||
plt.close()
|
||||
|
||||
logger.info(f"市场分析图已生成: {output_path}")
|
||||
return str(output_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pathlib import Path
|
||||
generate(Path.cwd())
|
||||
34
ppt_manager/scripts/trade_chart.py
Normal file
34
ppt_manager/scripts/trade_chart.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
|
||||
def generate(output_dir):
|
||||
logger.info("开始生成进出口贸易图表")
|
||||
|
||||
months = ['1月', '2月', '3月', '4月', '5月', '6月']
|
||||
exports = np.random.randint(2800, 3200, 6)
|
||||
imports = np.random.randint(2000, 2400, 6)
|
||||
|
||||
data = pd.DataFrame({
|
||||
'月份': months,
|
||||
'出口(亿美元)': exports,
|
||||
'进口(亿美元)': imports
|
||||
})
|
||||
data = data.set_index('月份')
|
||||
|
||||
from dynamic_generator import DynamicContentGenerator
|
||||
generator = DynamicContentGenerator()
|
||||
output_path = generator.generate_chart_matplotlib(
|
||||
data,
|
||||
title='进出口贸易情况',
|
||||
x_label='月份',
|
||||
y_label='金额 (亿美元)',
|
||||
filename='trade_chart.png',
|
||||
kind='bar'
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pathlib import Path
|
||||
generate(Path.cwd())
|
||||
0
ppt_manager/src/__init__.py
Normal file
0
ppt_manager/src/__init__.py
Normal file
39
ppt_manager/src/config_loader.py
Normal file
39
ppt_manager/src/config_loader.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import yaml
|
||||
import os
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
|
||||
class ConfigLoader:
|
||||
def __init__(self, config_path=None):
|
||||
if config_path is None:
|
||||
base_dir = Path(__file__).parent.parent
|
||||
config_path = base_dir / "config" / "project_config.yaml"
|
||||
|
||||
self.config_path = Path(config_path)
|
||||
self.config = self._load_config()
|
||||
logger.info(f"配置文件已加载: {self.config_path}")
|
||||
|
||||
def _load_config(self):
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def get_project(self, project_name):
|
||||
return self.config.get('projects', {}).get(project_name)
|
||||
|
||||
def get_all_projects(self):
|
||||
return self.config.get('projects', {})
|
||||
|
||||
def get_settings(self):
|
||||
return self.config.get('settings', {})
|
||||
|
||||
def get_dynamic_script(self, project_name, dynamic_key):
|
||||
project = self.get_project(project_name)
|
||||
if project:
|
||||
return project.get('dynamic_generators', {}).get(dynamic_key)
|
||||
return None
|
||||
|
||||
def get_slide_mapping(self, project_name):
|
||||
project = self.get_project(project_name)
|
||||
if project:
|
||||
return project.get('slide_mapping', {})
|
||||
return {}
|
||||
93
ppt_manager/src/dynamic_generator.py
Normal file
93
ppt_manager/src/dynamic_generator.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
import importlib.util
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
import plotly.express as px
|
||||
import plotly.io as pio
|
||||
|
||||
class DynamicContentGenerator:
|
||||
def __init__(self, base_dir=None):
|
||||
if base_dir is None:
|
||||
self.base_dir = Path(__file__).parent.parent
|
||||
else:
|
||||
self.base_dir = Path(base_dir)
|
||||
self.dynamic_content_dir = self.base_dir / "dynamic_content"
|
||||
self.dynamic_content_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"动态内容生成器初始化, 输出目录: {self.dynamic_content_dir}")
|
||||
|
||||
def run_script(self, script_path):
|
||||
script_file = self.base_dir / script_path
|
||||
if not script_file.exists():
|
||||
logger.error(f"脚本文件不存在: {script_file}")
|
||||
return None
|
||||
|
||||
logger.info(f"执行动态脚本: {script_file}")
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("dynamic_module", str(script_file))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
if hasattr(module, 'generate'):
|
||||
result = module.generate(self.dynamic_content_dir)
|
||||
logger.success(f"脚本执行完成: {result}")
|
||||
return result
|
||||
else:
|
||||
logger.error(f"脚本中没有找到 generate() 函数")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(f"执行脚本时出错: {e}")
|
||||
return None
|
||||
|
||||
def generate_chart_matplotlib(self, data, title, x_label, y_label, filename, kind='line'):
|
||||
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']
|
||||
plt.rcParams['axes.unicode_minus'] = False
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12, 7))
|
||||
|
||||
if kind == 'line':
|
||||
if isinstance(data, pd.DataFrame):
|
||||
data.plot(ax=ax, kind='line', linewidth=2, marker='o')
|
||||
else:
|
||||
ax.plot(data)
|
||||
elif kind == 'bar':
|
||||
if isinstance(data, pd.DataFrame):
|
||||
data.plot(ax=ax, kind='bar')
|
||||
else:
|
||||
ax.bar(range(len(data)), data)
|
||||
|
||||
ax.set_title(title, fontsize=16, fontweight='bold')
|
||||
ax.set_xlabel(x_label, fontsize=12)
|
||||
ax.set_ylabel(y_label, fontsize=12)
|
||||
ax.grid(True, linestyle='--', alpha=0.7)
|
||||
plt.legend()
|
||||
plt.tight_layout()
|
||||
|
||||
output_path = self.dynamic_content_dir / filename
|
||||
plt.savefig(str(output_path), dpi=150, bbox_inches='tight')
|
||||
plt.close()
|
||||
logger.info(f"图表已生成: {output_path}")
|
||||
return str(output_path)
|
||||
|
||||
def generate_chart_plotly(self, data, title, x_label, y_label, filename, kind='line'):
|
||||
if isinstance(data, pd.DataFrame):
|
||||
if kind == 'line':
|
||||
fig = px.line(data, title=title)
|
||||
elif kind == 'bar':
|
||||
fig = px.bar(data, title=title)
|
||||
else:
|
||||
fig = px.line(y=data, title=title)
|
||||
|
||||
fig.update_layout(
|
||||
xaxis_title=x_label,
|
||||
yaxis_title=y_label,
|
||||
font=dict(family="Microsoft YaHei, SimHei"),
|
||||
title_x=0.5
|
||||
)
|
||||
|
||||
output_path = self.dynamic_content_dir / filename
|
||||
pio.write_image(fig, str(output_path), width=1200, height=700, scale=2)
|
||||
logger.info(f"Plotly图表已生成: {output_path}")
|
||||
return str(output_path)
|
||||
71
ppt_manager/src/ppt_core.py
Normal file
71
ppt_manager/src/ppt_core.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
import os
|
||||
|
||||
class PPTCore:
|
||||
def __init__(self, base_dir=None):
|
||||
if base_dir is None:
|
||||
self.base_dir = Path(__file__).parent.parent
|
||||
else:
|
||||
self.base_dir = Path(base_dir)
|
||||
logger.info(f"PPT核心模块初始化, 基础目录: {self.base_dir}")
|
||||
|
||||
def load_static_ppt(self, static_ppt_path):
|
||||
ppt_path = self.base_dir / static_ppt_path
|
||||
logger.info(f"加载静态PPT: {ppt_path}")
|
||||
if not ppt_path.exists():
|
||||
logger.error(f"静态PPT文件不存在: {ppt_path}")
|
||||
raise FileNotFoundError(f"静态PPT文件不存在: {ppt_path}")
|
||||
return Presentation(str(ppt_path))
|
||||
|
||||
def create_new_ppt(self):
|
||||
return Presentation()
|
||||
|
||||
def copy_slide(self, source_prs, target_prs, slide_index):
|
||||
source_slide = source_prs.slides[slide_index - 1]
|
||||
slide_layout = target_prs.slide_layouts[6] if len(target_prs.slide_layouts) > 6 else target_prs.slide_layouts[0]
|
||||
new_slide = target_prs.slides.add_slide(slide_layout)
|
||||
|
||||
for shp in new_slide.shapes:
|
||||
sp = shp.element
|
||||
sp.getparent().remove(sp)
|
||||
|
||||
for shape in source_slide.shapes:
|
||||
el = shape.element
|
||||
new_slide.shapes._spTree.insert_element_before(el, 'p:extLst')
|
||||
|
||||
logger.debug(f"已复制第 {slide_index} 页")
|
||||
return new_slide
|
||||
|
||||
def insert_image_to_slide(self, slide, image_path, left=1, top=1.5, width=8, height=5):
|
||||
left_inch = Inches(left)
|
||||
top_inch = Inches(top)
|
||||
width_inch = Inches(width)
|
||||
height_inch = Inches(height)
|
||||
|
||||
slide.shapes.add_picture(str(image_path), left_inch, top_inch, width_inch, height_inch)
|
||||
logger.info(f"已插入图片: {image_path}")
|
||||
|
||||
def add_title_to_slide(self, slide, title_text, font_size=24, is_bold=True):
|
||||
left = Inches(0.5)
|
||||
top = Inches(0.2)
|
||||
width = Inches(9)
|
||||
height = Inches(0.8)
|
||||
|
||||
title_box = slide.shapes.add_textbox(left, top, width, height)
|
||||
tf = title_box.text_frame
|
||||
tf.text = title_text
|
||||
p = tf.paragraphs[0]
|
||||
p.font.size = Pt(font_size)
|
||||
p.font.bold = is_bold
|
||||
p.alignment = PP_ALIGN.CENTER
|
||||
|
||||
def save_ppt(self, prs, output_path):
|
||||
output_file = self.base_dir / output_path
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
prs.save(str(output_file))
|
||||
logger.success(f"PPT已保存: {output_file}")
|
||||
return str(output_file)
|
||||
119
ppt_manager/src/ppt_generator.py
Normal file
119
ppt_manager/src/ppt_generator.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
from datetime import datetime
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from config_loader import ConfigLoader
|
||||
from ppt_core import PPTCore
|
||||
from dynamic_generator import DynamicContentGenerator
|
||||
|
||||
class PPTGenerator:
|
||||
def __init__(self, base_dir=None):
|
||||
if base_dir is None:
|
||||
self.base_dir = Path(__file__).parent.parent
|
||||
else:
|
||||
self.base_dir = Path(base_dir)
|
||||
|
||||
self.config_loader = ConfigLoader()
|
||||
self.ppt_core = PPTCore(self.base_dir)
|
||||
self.dynamic_generator = DynamicContentGenerator(self.base_dir)
|
||||
|
||||
log_file = self.base_dir / "logs" / f"ppt_generator_{datetime.now().strftime('%Y%m%d')}.log"
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
logger.add(str(log_file), rotation="10 MB", level="INFO", encoding="utf-8")
|
||||
|
||||
logger.info("PPT生成器初始化完成")
|
||||
|
||||
def generate_project(self, project_name):
|
||||
logger.info(f"开始生成项目: {project_name}")
|
||||
|
||||
project_config = self.config_loader.get_project(project_name)
|
||||
if not project_config:
|
||||
logger.error(f"找不到项目配置: {project_name}")
|
||||
return None
|
||||
|
||||
slide_mapping = self.config_loader.get_slide_mapping(project_name)
|
||||
total_slides = project_config.get('total_slides', 0)
|
||||
static_ppt_path = project_config.get('static_ppt', '')
|
||||
|
||||
logger.info(f"加载静态PPT: {static_ppt_path}")
|
||||
logger.info(f"总页数: {total_slides}")
|
||||
|
||||
dynamic_generated_files = {}
|
||||
for slide_num, mapping in slide_mapping.items():
|
||||
if mapping != 'static':
|
||||
script_path = self.config_loader.get_dynamic_script(project_name, mapping)
|
||||
if script_path:
|
||||
logger.info(f"执行第 {slide_num} 页动态脚本: {script_path}")
|
||||
result = self.dynamic_generator.run_script(script_path)
|
||||
if result:
|
||||
dynamic_generated_files[slide_num] = result
|
||||
logger.success(f"第 {slide_num} 页动态内容生成成功: {result}")
|
||||
|
||||
try:
|
||||
source_prs = self.ppt_core.load_static_ppt(static_ppt_path)
|
||||
target_prs = self.ppt_core.create_new_ppt()
|
||||
|
||||
for slide_num in range(1, total_slides + 1):
|
||||
mapping = slide_mapping.get(slide_num, 'static')
|
||||
|
||||
if mapping == 'static':
|
||||
logger.info(f"处理第 {slide_num} 页: 静态内容")
|
||||
if slide_num - 1 < len(source_prs.slides):
|
||||
self.ppt_core.copy_slide(source_prs, target_prs, slide_num)
|
||||
else:
|
||||
logger.warning(f"静态PPT中缺少第 {slide_num} 页,创建空白页")
|
||||
slide_layout = target_prs.slide_layouts[6] if len(target_prs.slide_layouts) > 6 else target_prs.slide_layouts[0]
|
||||
target_prs.slides.add_slide(slide_layout)
|
||||
else:
|
||||
logger.info(f"处理第 {slide_num} 页: 动态内容 - {mapping}")
|
||||
slide_layout = target_prs.slide_layouts[6] if len(target_prs.slide_layouts) > 6 else target_prs.slide_layouts[0]
|
||||
new_slide = target_prs.slides.add_slide(slide_layout)
|
||||
|
||||
self.ppt_core.add_title_to_slide(new_slide, f"第{slide_num}页 - 动态更新内容", font_size=20)
|
||||
|
||||
if slide_num in dynamic_generated_files:
|
||||
image_path = dynamic_generated_files[slide_num]
|
||||
if Path(image_path).exists():
|
||||
self.ppt_core.insert_image_to_slide(new_slide, image_path, left=0.8, top=1.2, width=8.5, height=5)
|
||||
|
||||
output_filename = f"{project_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pptx"
|
||||
output_path = Path("output") / output_filename
|
||||
final_path = self.ppt_core.save_ppt(target_prs, str(output_path))
|
||||
|
||||
logger.success(f"=" * 50)
|
||||
logger.success(f"项目 {project_name} 生成完成!")
|
||||
logger.success(f"输出文件: {final_path}")
|
||||
logger.success(f"=" * 50)
|
||||
|
||||
return final_path
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"生成PPT时发生错误: {e}")
|
||||
return None
|
||||
|
||||
def list_projects(self):
|
||||
projects = self.config_loader.get_all_projects()
|
||||
result = []
|
||||
for key, info in projects.items():
|
||||
result.append({
|
||||
'id': key,
|
||||
'name': info.get('name', key),
|
||||
'total_slides': info.get('total_slides', 0),
|
||||
'slide_mapping': info.get('slide_mapping', {})
|
||||
})
|
||||
return result
|
||||
|
||||
if __name__ == "__main__":
|
||||
generator = PPTGenerator()
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
project_name = sys.argv[1]
|
||||
generator.generate_project(project_name)
|
||||
else:
|
||||
print("可用项目:")
|
||||
for proj in generator.list_projects():
|
||||
print(f" - {proj['id']}: {proj['name']} ({proj['total_slides']}页)")
|
||||
print("\n使用方法: python ppt_generator.py <项目名称>")
|
||||
179
ppt_manager/templates/index.html
Normal file
179
ppt_manager/templates/index.html
Normal file
@@ -0,0 +1,179 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PPT智能管理系统</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-blue-50 to-indigo-100 min-h-screen">
|
||||
<div class="container mx-auto px-4 py-8 max-w-5xl">
|
||||
<div class="text-center mb-10">
|
||||
<h1 class="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-600 mb-3">
|
||||
📊 PPT智能管理系统
|
||||
</h1>
|
||||
<p class="text-gray-600 text-lg">静态模板 + 动态数据 = 一键生成最新报告</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="bg-white rounded-2xl shadow-xl p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="mr-2">📁</span> 可用项目
|
||||
</h2>
|
||||
|
||||
<div id="projects-list" class="space-y-3">
|
||||
{% for project in projects %}
|
||||
<div class="project-card border-2 border-gray-200 hover:border-blue-400 rounded-xl p-4 transition-all duration-300 hover:shadow-md cursor-pointer" data-project="{{ project.id }}">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 class="font-bold text-gray-800">{{ project.name }}</h3>
|
||||
<p class="text-sm text-gray-500">共 {{ project.total_slides }} 页</p>
|
||||
</div>
|
||||
<button onclick="generatePPT('{{ project.id }}')"
|
||||
class="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-5 py-2.5 rounded-lg font-medium transition-all duration-300 transform hover:scale-105 shadow-md hover:shadow-lg">
|
||||
🚀 开始生成
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 text-xs text-gray-500">
|
||||
<span class="bg-green-100 text-green-700 px-2 py-1 rounded-full mr-1">
|
||||
静态: {{ project.slide_mapping.values() | list | count('static') }} 页
|
||||
</span>
|
||||
<span class="bg-orange-100 text-orange-700 px-2 py-1 rounded-full">
|
||||
动态: {{ project.total_slides - (project.slide_mapping.values() | list | count('static')) }} 页
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="status" class="mt-4 hidden">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-3 text-blue-500" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span id="status-text" class="text-blue-700 font-medium">正在生成PPT,请稍候...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="result" class="mt-4 hidden">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-green-700 font-bold">✅ PPT生成成功!</p>
|
||||
<p id="result-filename" class="text-green-600 text-sm mt-1"></p>
|
||||
</div>
|
||||
<a id="download-btn" href="#" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg font-medium transition-colors">
|
||||
📥 下载文件
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error" class="mt-4 hidden">
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p class="text-red-700 font-bold">❌ 生成失败</p>
|
||||
<p id="error-message" class="text-red-600 text-sm mt-1"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow-xl p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="mr-2">📋</span> 已生成文件
|
||||
</h2>
|
||||
<div id="files-list" class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<p class="text-gray-500 text-sm">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow-xl p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<span class="mr-2">📖</span> 使用说明
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="bg-gray-50 rounded-xl p-4">
|
||||
<h3 class="font-bold text-gray-700 mb-2">1️⃣ 准备静态模板</h3>
|
||||
<p class="text-gray-600">将固定不变的PPT模板放入 static_ppt 目录</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-4">
|
||||
<h3 class="font-bold text-gray-700 mb-2">2️⃣ 配置页面映射</h3>
|
||||
<p class="text-gray-600">在 config/project_config.yaml 中配置每页是静态或动态</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-4">
|
||||
<h3 class="font-bold text-gray-700 mb-2">3️⃣ 编写动态脚本</h3>
|
||||
<p class="text-gray-600">在 scripts 目录编写数据爬取和图表生成脚本</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function generatePPT(projectName) {
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultEl = document.getElementById('result');
|
||||
const errorEl = document.getElementById('error');
|
||||
|
||||
statusEl.classList.remove('hidden');
|
||||
resultEl.classList.add('hidden');
|
||||
errorEl.classList.add('hidden');
|
||||
|
||||
fetch(`/api/generate/${projectName}`, { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
statusEl.classList.add('hidden');
|
||||
if (data.success) {
|
||||
resultEl.classList.remove('hidden');
|
||||
document.getElementById('result-filename').textContent = data.filename;
|
||||
document.getElementById('download-btn').href = data.download_url;
|
||||
loadFiles();
|
||||
} else {
|
||||
errorEl.classList.remove('hidden');
|
||||
document.getElementById('error-message').textContent = data.message;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
statusEl.classList.add('hidden');
|
||||
errorEl.classList.remove('hidden');
|
||||
document.getElementById('error-message').textContent = err.message;
|
||||
});
|
||||
}
|
||||
|
||||
function loadFiles() {
|
||||
fetch('/api/files')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const filesList = document.getElementById('files-list');
|
||||
if (data.files.length === 0) {
|
||||
filesList.innerHTML = '<p class="text-gray-500 text-sm">暂无生成的文件</p>';
|
||||
} else {
|
||||
filesList.innerHTML = data.files.map(f => `
|
||||
<div class="flex justify-between items-center bg-gray-50 rounded-lg p-3 hover:bg-gray-100 transition-colors">
|
||||
<div>
|
||||
<p class="font-medium text-gray-700 text-sm">${f.name}</p>
|
||||
<p class="text-xs text-gray-500">${f.size} MB</p>
|
||||
</div>
|
||||
<a href="/download/${f.name}" class="text-blue-500 hover:text-blue-700 text-sm font-medium">
|
||||
下载
|
||||
</a>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadFiles();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user