feat(lzwcai-agile-db): 更新AgileDB技能至v0.4.2版本并扩展工具集
- 将技能版本从0.2.0升级至0.4.2 - 工具数量从33个扩展至57个,新增数据源管理、AI训练、库表关联配置等功能 - 新增MQTT字段关联同步模块(8个工具)和库表关联配置(3个工具) - 添加重要的契约提示和安全确认原则,包括target默认值、alter_table操作限制等 - 修正工具参数说明,如execute_sql的executableSql改为sql,参数结构优化 - 增强安全机制,明确危险操作的用户确认流程和目标资源选择规则 - 更新README.md中的工具数量统计和功能描述
This commit is contained in:
36
lzwcai_mcpskills_generate_reports/.gitignore
vendored
Normal file
36
lzwcai_mcpskills_generate_reports/.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Project outputs
|
||||
_out/
|
||||
159
lzwcai_mcpskills_generate_reports/README.md
Normal file
159
lzwcai_mcpskills_generate_reports/README.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# lzwcai-mcpskills-generate-reports
|
||||
|
||||
用户提供 **docx 模板 + JSON 数据**,本包负责渲染成 docx,并可选做样式迁移。
|
||||
|
||||
本包**不内置模板**,模板完全由调用方维护。
|
||||
|
||||
## 安装
|
||||
|
||||
```powershell
|
||||
cd lzwcai_mcpskills_generate_reports
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Python API
|
||||
|
||||
### 渲染文档
|
||||
|
||||
```python
|
||||
from lzwcai_mcpskills_generate_reports import generate
|
||||
|
||||
out_path = generate(
|
||||
data="data.json", # dict 或 JSON 文件路径
|
||||
template="./模板.docx", # 用户自己的 docx 模板路径
|
||||
out_path="_out/报价方案.docx",
|
||||
style_ref="./用户样式.docx", # 可选:把用户模板的 theme/字体套过来
|
||||
)
|
||||
```
|
||||
|
||||
### 扫描模板占位符
|
||||
|
||||
```python
|
||||
from lzwcai_mcpskills_generate_reports import scan_template
|
||||
|
||||
result = scan_template("./模板.docx")
|
||||
print(result)
|
||||
# {
|
||||
# "placeholders": ["project_title", "contact_person", "equipments", ...],
|
||||
# "blocks": [
|
||||
# {"type": "for", "iterator": "eq", "variable": "equipments"},
|
||||
# {"type": "if", "condition": "show_layout"},
|
||||
# ...
|
||||
# ]
|
||||
# }
|
||||
```
|
||||
|
||||
## 命令行
|
||||
|
||||
```powershell
|
||||
# 渲染
|
||||
generate-report generate --template ./模板.docx --data data.json --out _out/报价方案.docx
|
||||
|
||||
# 扫描占位符
|
||||
generate-report scan --template ./模板.docx
|
||||
|
||||
# 样式迁移
|
||||
generate-report generate --template ./模板.docx --data data.json --style-ref ./用户样式.docx --out _out/报价方案_定制.docx
|
||||
```
|
||||
|
||||
## MCP Server
|
||||
|
||||
本包同时提供 MCP Server(stdio 模式),把渲染引擎暴露成 3 个 MCP 工具:
|
||||
|
||||
| 工具 | 说明 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `generate_report` | 模板 + 数据 → 渲染输出 docx,返回输出文件绝对路径 | `template`, `data`, `out`(可选 `style_ref`) |
|
||||
| `scan_template` | 扫描模板占位符与 for/if 块结构 | `template` |
|
||||
| `validate_report_data` | 校验数据契约(不渲染) | `data` |
|
||||
|
||||
其中 `data` 既可以传 JSON 对象,也可以传指向 JSON 文件的路径字符串。
|
||||
|
||||
### 启动
|
||||
|
||||
```powershell
|
||||
# 安装后用 console script 启动
|
||||
lzwcai-mcpskills-generate-reports
|
||||
|
||||
# 或直接运行入口模块
|
||||
python main.py
|
||||
```
|
||||
|
||||
### MCP 客户端配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"generate-reports": {
|
||||
"command": "lzwcai-mcpskills-generate-reports"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> stdio 模式下 stdout 被 MCP 协议占用,所有日志写入 `logs/` 目录与 stderr。
|
||||
|
||||
## 数据契约(QuoteData)
|
||||
|
||||
```json
|
||||
{
|
||||
"project_title": "大米罐装线",
|
||||
"contact_person": "张经理",
|
||||
"contact_phone": "138-0000-0000",
|
||||
"requirements": ["要求1", "要求2"],
|
||||
"layout_image": "",
|
||||
"layout_title": "整线布局尺寸图",
|
||||
"equipments": [
|
||||
{
|
||||
"index": "四",
|
||||
"name": "自动理瓶机",
|
||||
"images": [""],
|
||||
"features": [{"title": "特点", "lines": ["说明"]}],
|
||||
"params": [{"k": "材料", "v": "不锈钢"}]
|
||||
}
|
||||
],
|
||||
"quote_items": [
|
||||
{"name": "设备名", "qty": "一套", "image": "", "desc": "说明", "price": "面议"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
图片字段约定:路径/URL → 真实图;`""` → 默认占位图;`None` → 不显示。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
lzwcai_mcpskills_generate_reports/
|
||||
├── pyproject.toml
|
||||
├── README.md
|
||||
├── templates/ # 用户模板(示例,不在包内)
|
||||
│ └── standard/
|
||||
│ ├── template.docx
|
||||
│ └── meta.json
|
||||
├── samples/ # 示例数据(不在包内)
|
||||
│ └── sample_data.json
|
||||
└── lzwcai_mcpskills_generate_reports/ # Python 包
|
||||
├── __init__.py # 公共 API 入口
|
||||
├── cli.py # 命令行
|
||||
├── pipeline.py # 总入口
|
||||
├── schema.py # 数据契约 + 校验器
|
||||
├── render_quote.py # 渲染引擎
|
||||
├── style_transfer.py # 样式迁移
|
||||
└── template_scanner.py # 模板占位符扫描
|
||||
```
|
||||
|
||||
## 模板约定
|
||||
|
||||
- 使用 [Jinja2](https://jinja.palletsprojects.com/) 语法写占位符,如 `{{ project_title }}`、`{% for eq in equipments %}`。
|
||||
- 模板文件旁的 `meta.json`(可选)声明图片字段宽度,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"image_fields": {
|
||||
"layout_image": {"width_mm": 160},
|
||||
"equipments[].images[]": {"width_mm": 120},
|
||||
"quote_items[].image": {"width_mm": 30}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- BUSINESS 逻辑(内置模板维护、模板市场等)交给包外的上层系统处理。
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
lzwcai-mcpskills-generate-reports
|
||||
|
||||
纯渲染引擎,不内置模板。
|
||||
对外暴露的公共 API:
|
||||
- generate: 数据 + 模板路径 -> docx(核心入口)
|
||||
- scan_template: 模板路径 -> 占位符 JSON
|
||||
- validate: 校验数据契约
|
||||
- normalize: 归一化数据
|
||||
- transplant_style: 将用户模板样式迁移到结果文档
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from .pipeline import generate
|
||||
from .template_scanner import scan_template
|
||||
from .schema import validate, normalize, DEFAULTS
|
||||
from .style_transfer import transplant_style
|
||||
|
||||
__all__ = [
|
||||
"generate",
|
||||
"scan_template",
|
||||
"validate",
|
||||
"normalize",
|
||||
"transplant_style",
|
||||
"DEFAULTS",
|
||||
"__version__",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,122 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
cli.py:公共入口。
|
||||
|
||||
程序调用(推荐):
|
||||
from lzwcai_mcpskills_generate_reports.cli import generate_report, scan_report
|
||||
|
||||
generate_report(
|
||||
template="./模板.docx",
|
||||
data={"project_name": "x", ...},
|
||||
out="_out/a.docx",
|
||||
)
|
||||
scan_report(template="./模板.docx")
|
||||
|
||||
命令行:
|
||||
generate-report generate --template ./模板.docx --data data.json --out _out/a.docx
|
||||
generate-report scan --template ./模板.docx
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
|
||||
from lzwcai_mcpskills_generate_reports import generate, scan_template
|
||||
|
||||
|
||||
def _load_data(data):
|
||||
"""把 data 归一化为 dict:支持 dict 或 JSON 文件路径字符串。"""
|
||||
if isinstance(data, str):
|
||||
if not os.path.isfile(data):
|
||||
raise FileNotFoundError(f"数据文件不存在: {data}")
|
||||
with open(data, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError(f"data 必须是 dict 或 JSON 文件路径字符串,实际类型: {type(data).__name__}")
|
||||
return data
|
||||
|
||||
|
||||
def generate_report(template, data, out, style_ref=None):
|
||||
"""生成 docx 报告。
|
||||
|
||||
参数:
|
||||
template: 模板 docx 文件路径
|
||||
data: dict 数据 或 JSON 文件路径字符串
|
||||
out: 输出 docx 文件路径
|
||||
style_ref: 用户样式参考 docx 路径(可选)
|
||||
|
||||
返回:
|
||||
dict: {"output": 输出文件绝对路径}
|
||||
"""
|
||||
data = _load_data(data)
|
||||
out = generate(data=data, template=template, out_path=out, style_ref=style_ref)
|
||||
return {"output": out}
|
||||
|
||||
|
||||
def scan_report(template):
|
||||
"""扫描模板占位符。
|
||||
|
||||
参数:
|
||||
template: 模板 docx 文件路径
|
||||
|
||||
返回:
|
||||
dict: 占位符扫描结果
|
||||
"""
|
||||
return scan_template(template)
|
||||
|
||||
|
||||
def _build_arg_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="docx 模板渲染与占位符扫描",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
generate-report generate --template ./模板.docx --data data.json --out _out/a.docx
|
||||
generate-report generate --template ./模板.docx --data data.json --style-ref 用户样式.docx --out _out/b.docx
|
||||
generate-report scan --template ./模板.docx
|
||||
""",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
gen_parser = sub.add_parser("generate", help="渲染生成 docx")
|
||||
gen_parser.add_argument("--template", required=True, help="模板 docx 文件路径")
|
||||
gen_parser.add_argument("--data", required=True, help="数据 JSON 文件路径")
|
||||
gen_parser.add_argument("--out", required=True, help="输出 docx 文件路径")
|
||||
gen_parser.add_argument("--style-ref", default=None, help="用户上传的样式参考 docx(可选)")
|
||||
|
||||
scan_parser = sub.add_parser("scan", help="扫描模板占位符")
|
||||
scan_parser.add_argument("--template", required=True, help="模板 docx 文件路径")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
"""命令行入口(console_scripts 调用)。"""
|
||||
parser = _build_arg_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
if args.command == "generate":
|
||||
result = generate_report(
|
||||
template=args.template,
|
||||
data=args.data,
|
||||
out=args.out,
|
||||
style_ref=args.style_ref,
|
||||
)
|
||||
print(f"生成成功: {result['output']}")
|
||||
elif args.command == "scan":
|
||||
result = scan_report(template=args.template)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
except Exception as e:
|
||||
print(f"错误: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,37 @@
|
||||
2026-06-16 16:27:35 - root - INFO - [logger_config.py:95] - 日志系统初始化完成 - 日志目录: E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\logs
|
||||
2026-06-16 16:27:35 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcpskills_generate_reports'
|
||||
2026-06-16 16:27:35 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest
|
||||
2026-06-16 16:27:35 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
|
||||
2026-06-16 16:27:35 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor
|
||||
2026-06-16 16:27:35 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:129] - 收到 ListTools 请求,返回 3 个工具
|
||||
2026-06-16 16:27:35 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:169] - 收到 CallTool 请求: name=validate_report_data, arguments={"data": "samples\\sample_data.json"}
|
||||
2026-06-16 16:27:35 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:183] - 工具执行成功: validate_report_data
|
||||
2026-06-16 16:27:35 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:169] - 收到 CallTool 请求: name=scan_template, arguments={"template": "templates\\standard\\template.docx"}
|
||||
2026-06-16 16:27:35 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:183] - 工具执行成功: scan_template
|
||||
2026-06-16 16:28:01 - root - INFO - [logger_config.py:95] - 日志系统初始化完成 - 日志目录: E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\logs
|
||||
2026-06-16 16:28:01 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcpskills_generate_reports'
|
||||
2026-06-16 16:28:01 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest
|
||||
2026-06-16 16:28:01 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
|
||||
2026-06-16 16:28:01 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor
|
||||
2026-06-16 16:28:01 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:129] - 收到 ListTools 请求,返回 3 个工具
|
||||
2026-06-16 16:28:01 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:169] - 收到 CallTool 请求: name=validate_report_data, arguments={"data": "samples\\sample_data.json"}
|
||||
2026-06-16 16:28:01 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:183] - 工具执行成功: validate_report_data
|
||||
2026-06-16 16:28:01 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:169] - 收到 CallTool 请求: name=scan_template, arguments={"template": "templates\\standard\\template.docx"}
|
||||
2026-06-16 16:28:01 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:183] - 工具执行成功: scan_template
|
||||
2026-06-16 16:28:01 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:169] - 收到 CallTool 请求: name=generate_report, arguments={"template": "templates\\standard\\template.docx", "data": "samples\\sample_data.json", "out": "_out\\mcp_test.docx"}
|
||||
2026-06-16 16:28:20 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:183] - 工具执行成功: generate_report
|
||||
2026-06-17 10:11:02 - root - INFO - [logger_config.py:95] - 日志系统初始化完成 - 日志目录: E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\logs
|
||||
2026-06-17 10:11:02 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcpskills_generate_reports'
|
||||
2026-06-17 10:11:02 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest
|
||||
2026-06-17 10:11:02 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
|
||||
2026-06-17 10:11:02 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:215] - ==================================================
|
||||
2026-06-17 10:11:02 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:216] - lzwcai-mcpskills-generate-reports MCP Server 启动
|
||||
2026-06-17 10:11:02 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:217] - ==================================================
|
||||
2026-06-17 10:11:02 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:218] - 开始运行 MCP Server (stdio 模式)
|
||||
2026-06-17 10:11:02 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor
|
||||
2026-06-17 10:11:02 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: root=InitializedNotification(method='notifications/initialized', params=None, jsonrpc='2.0')
|
||||
2026-06-17 10:11:03 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: <mcp.shared.session.RequestResponder object at 0x000001E55CC2C2C0>
|
||||
2026-06-17 10:11:03 - mcp.server.lowlevel.server - INFO - [server.py:720] - Processing request of type ListToolsRequest
|
||||
2026-06-17 10:11:03 - mcp.server.lowlevel.server - DEBUG - [server.py:723] - Dispatching request of type ListToolsRequest
|
||||
2026-06-17 10:11:03 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:129] - 收到 ListTools 请求,返回 3 个工具
|
||||
2026-06-17 10:11:03 - mcp.server.lowlevel.server - DEBUG - [server.py:790] - Response sent
|
||||
@@ -0,0 +1,79 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
pipeline.py:总入口 — 串起 数据校验 -> 渲染 -> (可选)样式迁移。
|
||||
|
||||
注意:本包不维护内置模板。调用方需要自己准备 .docx 模板文件,
|
||||
并通过 template 参数传入路径。
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
|
||||
from .schema import validate, normalize
|
||||
from .render_quote import render
|
||||
from .style_transfer import transplant_style
|
||||
|
||||
|
||||
def _load_data(data):
|
||||
"""把 data 归一化为 dict:支持 dict 或 JSON 文件路径。"""
|
||||
if isinstance(data, str):
|
||||
if not os.path.isfile(data):
|
||||
raise FileNotFoundError(f"数据文件不存在: {data}")
|
||||
with open(data, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError(f"data 必须是 dict 或 JSON 文件路径,实际类型: {type(data).__name__}")
|
||||
return data
|
||||
|
||||
|
||||
def _load_meta_for_template(template_path):
|
||||
"""如果模板同目录下存在 meta.json,则读取;否则返回空 dict。"""
|
||||
meta_file = os.path.join(os.path.dirname(template_path), "meta.json")
|
||||
if os.path.isfile(meta_file):
|
||||
with open(meta_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
def generate(data, template, out_path, style_ref=None):
|
||||
"""生成报价文档。
|
||||
|
||||
参数:
|
||||
data: QuoteData dict(或 JSON 文件路径字符串)
|
||||
template: 模板 docx 文件路径
|
||||
out_path: 输出 docx 路径
|
||||
style_ref: 用户上传的样式参考 docx 路径(可选)
|
||||
|
||||
返回:
|
||||
生成的 docx 绝对路径
|
||||
"""
|
||||
data = _load_data(data)
|
||||
|
||||
if not isinstance(template, str):
|
||||
raise TypeError(f"template 必须是文件路径字符串,实际类型: {type(template).__name__}")
|
||||
if not os.path.isfile(template):
|
||||
raise FileNotFoundError(f"模板文件不存在: {template}")
|
||||
template_path = os.path.abspath(template)
|
||||
|
||||
# 归一化 + 校验
|
||||
data = normalize(data)
|
||||
errs = validate(data)
|
||||
if errs:
|
||||
raise ValueError("数据校验失败:\n" + "\n".join(errs))
|
||||
|
||||
# 确保输出目录存在
|
||||
out_dir = os.path.dirname(os.path.abspath(out_path))
|
||||
if out_dir:
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# 渲染(优先读取同目录 meta.json 作为图片配置)
|
||||
meta = _load_meta_for_template(template_path)
|
||||
render(data, out_path, template_path, meta=meta)
|
||||
|
||||
# 可选样式迁移
|
||||
if style_ref:
|
||||
if not os.path.isfile(style_ref):
|
||||
raise FileNotFoundError(f"样式参考文件不存在: {style_ref}")
|
||||
transplant_style(out_path, style_ref, out_path)
|
||||
|
||||
return os.path.abspath(out_path)
|
||||
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
render_quote.py:渲染引擎 — data + 模板路径 -> docx。
|
||||
|
||||
从 reports4 移植,做了两处通用化改造:
|
||||
1. 模板路径参数化: render(data, out_path, template_path)
|
||||
2. 图片字段配置驱动: 读 meta.json 的 image_fields 声明,不再写死字段名
|
||||
"""
|
||||
import copy
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
|
||||
from docxtpl import DocxTemplate, InlineImage
|
||||
from docx.shared import Mm
|
||||
|
||||
|
||||
def _resolve_image_path(src, tmp_files):
|
||||
"""把图片字段值解析为本地文件路径。
|
||||
|
||||
下载成功的临时文件会记录到 tmp_files,由调用方在渲染结束后统一清理。
|
||||
"""
|
||||
def _download(url):
|
||||
try:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
data = urllib.request.urlopen(req, context=ctx, timeout=30).read()
|
||||
ext = os.path.splitext(url.split("?")[0])[1] or ".png"
|
||||
fd, path = tempfile.mkstemp(suffix=ext)
|
||||
os.write(fd, data)
|
||||
os.close(fd)
|
||||
tmp_files.append(path)
|
||||
return path
|
||||
except Exception as e:
|
||||
print(f"[warn] 下载图片失败({url}): {e}")
|
||||
return None
|
||||
|
||||
if src is None:
|
||||
return ""
|
||||
if src == "":
|
||||
return ""
|
||||
if isinstance(src, str) and src.lower().startswith(("http://", "https://")):
|
||||
return _download(src) or ""
|
||||
if os.path.exists(src):
|
||||
return src
|
||||
print(f"[warn] 找不到图片({src}),跳过")
|
||||
return ""
|
||||
|
||||
|
||||
def _make_inline_image(tpl, src, width_mm, tmp_files):
|
||||
"""创建 InlineImage;src 为 "" 或 None 时不创建(返回 None)。"""
|
||||
resolved = _resolve_image_path(src, tmp_files)
|
||||
if resolved == "":
|
||||
return None
|
||||
return InlineImage(tpl, resolved, width=Mm(width_mm))
|
||||
|
||||
|
||||
def _resolve_field_path(data, path):
|
||||
"""根据字段路径提取所有目标值。
|
||||
|
||||
支持: "root_field" / "list[].field" / "list[].field[]"
|
||||
"""
|
||||
results = []
|
||||
parts = path.split(".")
|
||||
|
||||
if len(parts) == 1:
|
||||
key = parts[0]
|
||||
if key in data:
|
||||
results.append((data, key))
|
||||
return results
|
||||
|
||||
# 嵌套列表: "list[].field[]" —— 必须先于单层判断,否则会被 parts[0] 分支误捕获
|
||||
if len(parts) == 2 and "[]" in parts[1]:
|
||||
list_name = parts[0].replace("[]", "")
|
||||
field = parts[1].replace("[]", "")
|
||||
for item in data.get(list_name, []):
|
||||
lst = item.get(field, [])
|
||||
if isinstance(lst, list):
|
||||
for idx in range(len(lst)):
|
||||
results.append((lst, idx))
|
||||
return results
|
||||
|
||||
if len(parts) == 2 and "[]" in parts[0]:
|
||||
list_name = parts[0].replace("[]", "")
|
||||
field = parts[1]
|
||||
for item in data.get(list_name, []):
|
||||
if field in item:
|
||||
results.append((item, field))
|
||||
return results
|
||||
|
||||
if len(parts) == 2:
|
||||
list_name = parts[0]
|
||||
field = parts[1]
|
||||
for item in data.get(list_name, []):
|
||||
if field in item:
|
||||
results.append((item, field))
|
||||
return results
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _fill_images_from_meta(tpl, data, meta, tmp_files):
|
||||
"""根据 meta.json 中的 image_fields 声明填充所有图片字段。"""
|
||||
image_fields = meta.get("image_fields", {})
|
||||
for path, width_meta in image_fields.items():
|
||||
width_mm = width_meta.get("width_mm", 50)
|
||||
refs = _resolve_field_path(data, path)
|
||||
for container, key in refs:
|
||||
val = container[key]
|
||||
if val is None:
|
||||
container[key] = ""
|
||||
elif isinstance(val, list):
|
||||
container[key] = [
|
||||
_make_inline_image(tpl, x, width_mm, tmp_files) if x is not None else None
|
||||
for x in val
|
||||
]
|
||||
container[key] = [img for img in container[key] if img is not None]
|
||||
else:
|
||||
img = _make_inline_image(tpl, val, width_mm, tmp_files)
|
||||
container[key] = img if img else ""
|
||||
return data
|
||||
|
||||
|
||||
def render(data, out_path, template_path, meta=None):
|
||||
"""渲染报价文档。"""
|
||||
if meta is None:
|
||||
meta_dir = os.path.dirname(template_path)
|
||||
meta_file = os.path.join(meta_dir, "meta.json")
|
||||
if os.path.isfile(meta_file):
|
||||
with open(meta_file, "r", encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
else:
|
||||
meta = {}
|
||||
|
||||
# 深拷贝:避免修改调用方传入的数据,同时把字符串字段替换成 InlineImage
|
||||
data = copy.deepcopy(dict(data))
|
||||
tpl = DocxTemplate(template_path)
|
||||
|
||||
tmp_files = []
|
||||
try:
|
||||
data = _fill_images_from_meta(tpl, data, meta, tmp_files)
|
||||
tpl.render(data)
|
||||
tpl.save(out_path)
|
||||
finally:
|
||||
for p in tmp_files:
|
||||
try:
|
||||
os.remove(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return out_path
|
||||
@@ -0,0 +1,123 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
schema.py:QuoteData 数据契约、校验器、归一化。
|
||||
|
||||
所有模板共用同一契约,沿用 reports4 的 SAMPLE 字段结构。
|
||||
"""
|
||||
import copy
|
||||
|
||||
# ── 缺省值表 ──────────────────────────────────────────────
|
||||
DEFAULTS = {
|
||||
"layout_title": "整线布局尺寸图",
|
||||
"show_layout": True,
|
||||
}
|
||||
|
||||
# 中文数字扩展(用于缺 index 时自动补)
|
||||
_CN_DIGITS = [
|
||||
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十",
|
||||
"十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十",
|
||||
]
|
||||
|
||||
# 设备之前的固定章节数:一 公司简介、二 客户要求及分析、三 布局图
|
||||
_SECTION_OFFSET = 3
|
||||
|
||||
|
||||
def validate(data):
|
||||
"""校验 QuoteData 结构,返回错误信息列表(空=通过)。
|
||||
|
||||
检查:
|
||||
- 必填顶层字段: project_title, contact_person, contact_phone, requirements
|
||||
- equipments 元素: 必须有 name
|
||||
- quote_items 元素: 必须有 name
|
||||
- features 元素: 必须有 title 和 lines
|
||||
- params 元素: 必须有 k 和 v
|
||||
"""
|
||||
errors = []
|
||||
|
||||
for field in ("project_title", "contact_person", "contact_phone", "requirements"):
|
||||
val = data.get(field)
|
||||
if val is None or (isinstance(val, str) and val.strip() == ""):
|
||||
errors.append(f"必填字段缺失或为空: {field}")
|
||||
|
||||
# equipments
|
||||
eqs = data.get("equipments", [])
|
||||
if not isinstance(eqs, list):
|
||||
errors.append("equipments 必须为列表")
|
||||
else:
|
||||
for i, eq in enumerate(eqs):
|
||||
if not isinstance(eq, dict):
|
||||
errors.append(f"equipments[{i}] 必须为对象")
|
||||
continue
|
||||
if not eq.get("name"):
|
||||
errors.append(f"equipments[{i}] 缺少 name")
|
||||
# features 子元素
|
||||
for j, feat in enumerate(eq.get("features", [])):
|
||||
if not isinstance(feat, dict):
|
||||
errors.append(f"equipments[{i}].features[{j}] 必须为对象")
|
||||
continue
|
||||
if not feat.get("title"):
|
||||
errors.append(f"equipments[{i}].features[{j}] 缺少 title")
|
||||
if not feat.get("lines") or not isinstance(feat.get("lines"), list):
|
||||
errors.append(f"equipments[{i}].features[{j}] 缺少 lines 或类型错误")
|
||||
# params 子元素
|
||||
for j, p in enumerate(eq.get("params", [])):
|
||||
if not isinstance(p, dict):
|
||||
errors.append(f"equipments[{i}].params[{j}] 必须为对象")
|
||||
continue
|
||||
if not p.get("k") or not p.get("v"):
|
||||
errors.append(f"equipments[{i}].params[{j}] 缺少 k 或 v")
|
||||
|
||||
# quote_items
|
||||
items = data.get("quote_items", [])
|
||||
if not isinstance(items, list):
|
||||
errors.append("quote_items 必须为列表")
|
||||
else:
|
||||
for i, it in enumerate(items):
|
||||
if not isinstance(it, dict):
|
||||
errors.append(f"quote_items[{i}] 必须为对象")
|
||||
continue
|
||||
if not it.get("name"):
|
||||
errors.append(f"quote_items[{i}] 缺少 name")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def normalize(data):
|
||||
"""归一化数据:填缺省值、requirements list->str、index 缺失时自动补。
|
||||
|
||||
不修改入参,返回新副本。
|
||||
"""
|
||||
d = copy.deepcopy(data)
|
||||
|
||||
# requirements: list -> str
|
||||
req = d.get("requirements")
|
||||
if isinstance(req, (list, tuple)):
|
||||
d["requirements"] = "\n".join(str(x) for x in req)
|
||||
|
||||
# 缺省值
|
||||
for k, v in DEFAULTS.items():
|
||||
if k not in d:
|
||||
d[k] = v
|
||||
|
||||
# equipment index 自动补(从"四"开始,前面三节是公司简介/客户要求/布局图)
|
||||
for i, eq in enumerate(d.get("equipments", [])):
|
||||
if not eq.get("index"):
|
||||
idx = i + 1 + _SECTION_OFFSET
|
||||
eq["index"] = _CN_DIGITS[idx - 1] if idx <= len(_CN_DIGITS) else str(idx)
|
||||
|
||||
# quote_items 缺 image 给空串
|
||||
for it in d.get("quote_items", []):
|
||||
it.setdefault("image", "")
|
||||
|
||||
# equipments 缺 images 给 [""]
|
||||
for eq in d.get("equipments", []):
|
||||
eq.setdefault("images", [""])
|
||||
|
||||
# 计算后续章节的动态序号:报价表 & 售后服务
|
||||
eq_count = len(d.get("equipments", []))
|
||||
quote_idx = eq_count + _SECTION_OFFSET + 1
|
||||
after_sales_idx = eq_count + _SECTION_OFFSET + 2
|
||||
d["section_quote_table"] = _CN_DIGITS[quote_idx - 1] if quote_idx <= len(_CN_DIGITS) else str(quote_idx)
|
||||
d["section_after_sales"] = _CN_DIGITS[after_sales_idx - 1] if after_sales_idx <= len(_CN_DIGITS) else str(after_sales_idx)
|
||||
|
||||
return d
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
lzwcai-mcpskills-generate-reports MCP Server
|
||||
|
||||
把 docx 模板渲染引擎封装成 MCP 工具,提供三个工具:
|
||||
- generate_report: 数据 + 模板路径 -> 渲染输出 docx
|
||||
- scan_template: 扫描模板占位符 / for / if 块
|
||||
- validate_report_data: 校验数据契约(不渲染)
|
||||
|
||||
stdio 模式运行;所有日志走 stderr,stdout 留给 MCP 协议。
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
|
||||
import anyio
|
||||
|
||||
import mcp.types as types
|
||||
from mcp.server import NotificationOptions, Server
|
||||
from mcp.server.models import InitializationOptions
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
try:
|
||||
from .utils.logger_config import setup_system_logging, get_logger
|
||||
from . import generate, scan_template, validate, normalize
|
||||
except ImportError:
|
||||
from lzwcai_mcpskills_generate_reports.utils.logger_config import (
|
||||
setup_system_logging, get_logger,
|
||||
)
|
||||
from lzwcai_mcpskills_generate_reports import (
|
||||
generate, scan_template, validate, normalize,
|
||||
)
|
||||
|
||||
# 初始化日志系统
|
||||
setup_system_logging(app_name="lzwcai_mcpskills_generate_reports", log_level=logging.DEBUG)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# 初始化 MCP Server
|
||||
server = Server("lzwcai_mcpskills_generate_reports")
|
||||
|
||||
|
||||
def _load_data(data):
|
||||
"""把 data 归一化为 dict:支持 dict 或 JSON 文件路径字符串。"""
|
||||
if isinstance(data, str):
|
||||
if not os.path.isfile(data):
|
||||
raise FileNotFoundError(f"数据文件不存在: {data}")
|
||||
with open(data, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError(
|
||||
f"data 必须是对象或 JSON 文件路径字符串,实际类型: {type(data).__name__}"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
# ── 工具定义 ──────────────────────────────────────────────
|
||||
_DATA_DESC = "报价数据:可以是 JSON 对象,或指向 JSON 文件的路径字符串"
|
||||
|
||||
TOOL_DEFS = [
|
||||
types.Tool(
|
||||
name="generate_report",
|
||||
description=(
|
||||
"用 docx 模板 + 结构化数据渲染生成报价文档 docx,返回输出文件绝对路径。"
|
||||
"模板由调用方提供,本工具不内置模板。"
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template": {
|
||||
"type": "string",
|
||||
"description": "模板 docx 文件路径(必填)",
|
||||
},
|
||||
"data": {
|
||||
"type": ["object", "string"],
|
||||
"description": _DATA_DESC + "(必填)",
|
||||
},
|
||||
"out": {
|
||||
"type": "string",
|
||||
"description": "输出 docx 文件路径(必填)",
|
||||
},
|
||||
"style_ref": {
|
||||
"type": "string",
|
||||
"description": "用户上传的样式参考 docx 路径(可选),会把其 theme/字体套到结果文档",
|
||||
},
|
||||
},
|
||||
"required": ["template", "data", "out"],
|
||||
},
|
||||
),
|
||||
types.Tool(
|
||||
name="scan_template",
|
||||
description=(
|
||||
"扫描 docx 模板中的 Jinja2 占位符,返回需要外部提供的顶层变量列表和 for/if 块结构。"
|
||||
"用于在渲染前了解模板需要哪些数据字段。"
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template": {
|
||||
"type": "string",
|
||||
"description": "模板 docx 文件路径(必填)",
|
||||
},
|
||||
},
|
||||
"required": ["template"],
|
||||
},
|
||||
),
|
||||
types.Tool(
|
||||
name="validate_report_data",
|
||||
description=(
|
||||
"校验报价数据是否符合契约(必填字段、equipments/quote_items/features/params 结构),"
|
||||
"不渲染文档。返回校验结果和错误列表。"
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": ["object", "string"],
|
||||
"description": _DATA_DESC + "(必填)",
|
||||
},
|
||||
},
|
||||
"required": ["data"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@server.list_tools()
|
||||
async def handle_list_tools() -> list[types.Tool]:
|
||||
"""列出所有可用工具"""
|
||||
logger.info(f"收到 ListTools 请求,返回 {len(TOOL_DEFS)} 个工具")
|
||||
return TOOL_DEFS
|
||||
|
||||
|
||||
# ── 工具实现(同步函数,放线程池执行)──────────────────────
|
||||
def _do_generate_report(arguments: dict) -> dict:
|
||||
template = arguments["template"]
|
||||
data = arguments["data"]
|
||||
out = arguments["out"]
|
||||
style_ref = arguments.get("style_ref")
|
||||
|
||||
data = _load_data(data)
|
||||
out_path = generate(data=data, template=template, out_path=out, style_ref=style_ref)
|
||||
return {"output": out_path}
|
||||
|
||||
|
||||
def _do_scan_template(arguments: dict) -> dict:
|
||||
return scan_template(arguments["template"])
|
||||
|
||||
|
||||
def _do_validate(arguments: dict) -> dict:
|
||||
data = _load_data(arguments["data"])
|
||||
norm = normalize(data)
|
||||
errors = validate(norm)
|
||||
return {"valid": not errors, "errors": errors}
|
||||
|
||||
|
||||
_HANDLERS = {
|
||||
"generate_report": _do_generate_report,
|
||||
"scan_template": _do_scan_template,
|
||||
"validate_report_data": _do_validate,
|
||||
}
|
||||
|
||||
|
||||
@server.call_tool()
|
||||
async def handle_call_tool(
|
||||
name: str,
|
||||
arguments: dict | None,
|
||||
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
|
||||
"""调用工具"""
|
||||
logger.info(
|
||||
f"收到 CallTool 请求: name={name}, "
|
||||
f"arguments={json.dumps(arguments, ensure_ascii=False) if arguments else 'None'}"
|
||||
)
|
||||
|
||||
handler = _HANDLERS.get(name)
|
||||
if handler is None:
|
||||
logger.error(f"未找到工具: {name}")
|
||||
raise ValueError(f"未知工具: {name}")
|
||||
|
||||
args = arguments or {}
|
||||
try:
|
||||
# 渲染/扫描是同步阻塞的 CPU/IO 任务,放线程池避免霸占 event loop
|
||||
result = await anyio.to_thread.run_sync(handler, args)
|
||||
logger.info(f"工具执行成功: {name}")
|
||||
except Exception as e:
|
||||
logger.error(f"工具执行失败: {name}: {e}", exc_info=True)
|
||||
result = {"error": str(e), "tool_name": name}
|
||||
|
||||
return [
|
||||
types.TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, ensure_ascii=False, indent=2),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def run_server():
|
||||
"""运行 MCP Server (stdio 模式)"""
|
||||
async with stdio_server() as streams:
|
||||
await server.run(
|
||||
streams[0],
|
||||
streams[1],
|
||||
InitializationOptions(
|
||||
server_name="lzwcai_mcpskills_generate_reports",
|
||||
server_version="0.1.0",
|
||||
capabilities=server.get_capabilities(
|
||||
notification_options=NotificationOptions(),
|
||||
experimental_capabilities={},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""主入口"""
|
||||
logger.info("=" * 50)
|
||||
logger.info("lzwcai-mcpskills-generate-reports MCP Server 启动")
|
||||
logger.info("=" * 50)
|
||||
logger.info("开始运行 MCP Server (stdio 模式)")
|
||||
anyio.run(run_server)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,183 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
style_transfer.py:从用户上传的 docx 抽取视觉样式(theme1.xml + styles.xml 的默认字体引用),
|
||||
套到内置骨架渲染结果上,产出"风格接近用户模板"的文档。
|
||||
|
||||
Level 1 (MVP):整体替换 theme + 默认字体引用,不整体覆盖 styles(避免版式崩)。
|
||||
"""
|
||||
import os
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
# Word OOXML 命名空间
|
||||
_NS = {
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
||||
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
|
||||
}
|
||||
|
||||
# 主题字体引用中 major/minor 属性的前缀
|
||||
_THEME_PREFIXES = ("majorHAnsi", "minorHAnsi", "majorEastAsia", "minorEastAsia",
|
||||
"majorCs", "minorCs", "major", "minor")
|
||||
|
||||
|
||||
def _unzip_docx(docx_path, target_dir):
|
||||
"""解压 docx(zip)到目标目录。"""
|
||||
with zipfile.ZipFile(docx_path, "r") as z:
|
||||
z.extractall(target_dir)
|
||||
|
||||
|
||||
def _zip_docx(source_dir, docx_path):
|
||||
"""将目录内容打包为 docx(zip)。"""
|
||||
with zipfile.ZipFile(docx_path, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for root, _dirs, files in os.walk(source_dir):
|
||||
for fname in files:
|
||||
full = os.path.join(root, fname)
|
||||
arcname = os.path.relpath(full, source_dir)
|
||||
z.write(full, arcname)
|
||||
|
||||
|
||||
def _safe_xml_read(xml_path):
|
||||
"""安全读取 XML 文件,返回 ElementTree 和根元素。失败返回 (None, None)。"""
|
||||
try:
|
||||
tree = ET.parse(xml_path)
|
||||
return tree, tree.getroot()
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def _copy_theme(src_dir, dst_dir):
|
||||
"""将 src 的 word/theme/theme1.xml 复制到 dst。"""
|
||||
src_theme = os.path.join(src_dir, "word", "theme", "theme1.xml")
|
||||
dst_theme = os.path.join(dst_dir, "word", "theme", "theme1.xml")
|
||||
if os.path.isfile(src_theme):
|
||||
os.makedirs(os.path.dirname(dst_theme), exist_ok=True)
|
||||
shutil.copy2(src_theme, dst_theme)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _copy_font_table(src_dir, dst_dir):
|
||||
"""将 src 的 word/fontTable.xml 复制到 dst。"""
|
||||
src_ft = os.path.join(src_dir, "word", "fontTable.xml")
|
||||
dst_ft = os.path.join(dst_dir, "word", "fontTable.xml")
|
||||
if os.path.isfile(src_ft):
|
||||
shutil.copy2(src_ft, dst_ft)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _merge_default_fonts(src_styles_path, dst_styles_path):
|
||||
"""合并默认字体引用(docDefaults 中的 rFonts)从 src 到 dst。
|
||||
|
||||
不整体覆盖 styles.xml,只替换 docDefaults 里的字体引用。
|
||||
"""
|
||||
if not os.path.isfile(src_styles_path) or not os.path.isfile(dst_styles_path):
|
||||
return False
|
||||
|
||||
_, src_root = _safe_xml_read(src_styles_path)
|
||||
dst_tree, dst_root = _safe_xml_read(dst_styles_path)
|
||||
if src_root is None or dst_root is None:
|
||||
return False
|
||||
|
||||
w_ns = _NS["w"]
|
||||
|
||||
# 找 src 的 docDefaults
|
||||
src_doc_defaults = src_root.find(f".//{{{w_ns}}}docDefaults")
|
||||
if src_doc_defaults is None:
|
||||
return False
|
||||
|
||||
src_rpr = src_doc_defaults.find(f"{{{w_ns}}}rPrDefault/{{{w_ns}}}rFonts")
|
||||
if src_rpr is None:
|
||||
return False
|
||||
|
||||
# 找 dst 的 docDefaults
|
||||
dst_doc_defaults = dst_root.find(f".//{{{w_ns}}}docDefaults")
|
||||
if dst_doc_defaults is None:
|
||||
return False
|
||||
|
||||
dst_rpr = dst_doc_defaults.find(f"{{{w_ns}}}rPrDefault/{{{w_ns}}}rFonts")
|
||||
if dst_rpr is None:
|
||||
# 如果 dst 没有 rFonts,创建并追加
|
||||
rpr_default = dst_doc_defaults.find(f"{{{w_ns}}}rPrDefault")
|
||||
if rpr_default is None:
|
||||
rpr_default = ET.SubElement(dst_doc_defaults, f"{{{w_ns}}}rPrDefault")
|
||||
rpr_default.append(src_rpr)
|
||||
else:
|
||||
# 替换 rFonts 的属性(主题字体引用)
|
||||
for attr_name, attr_val in src_rpr.attrib.items():
|
||||
if any(attr_name.endswith(prefix) for prefix in _THEME_PREFIXES):
|
||||
dst_rpr.set(attr_name, attr_val)
|
||||
|
||||
# 保存
|
||||
dst_tree.write(dst_styles_path, xml_declaration=True, encoding="UTF-8", standalone="yes")
|
||||
return True
|
||||
|
||||
|
||||
def transplant_style(result_path, user_template_path, out_path):
|
||||
"""将用户模板的视觉样式移植到结果文档上。
|
||||
|
||||
参数:
|
||||
result_path: 内置骨架渲染出的 docx 路径
|
||||
user_template_path: 用户上传的样式参考 docx
|
||||
out_path: 输出路径(可与 result_path 相同,原地覆盖)
|
||||
"""
|
||||
# 校验用户模板可打开
|
||||
try:
|
||||
from docx import Document
|
||||
Document(user_template_path)
|
||||
except Exception as e:
|
||||
print(f"[warn] 用户上传模板无法打开,跳过样式迁移: {e}")
|
||||
if out_path != result_path:
|
||||
shutil.copy2(result_path, out_path)
|
||||
return out_path
|
||||
|
||||
# 在打包覆盖前先记录原文档段落数(out_path 可能 == result_path,原地覆盖后就读不到原值了)
|
||||
src_count = None
|
||||
try:
|
||||
from docx import Document
|
||||
src_count = len(Document(result_path).paragraphs)
|
||||
except Exception as e:
|
||||
print(f"[warn] 读取原文档段落数失败,迁移后将跳过段落数校验: {e}")
|
||||
|
||||
# 创建临时工作目录
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_dir = os.path.join(tmpdir, "src") # 用户上传模板
|
||||
dst_dir = os.path.join(tmpdir, "dst") # 渲染结果
|
||||
os.makedirs(src_dir)
|
||||
os.makedirs(dst_dir)
|
||||
|
||||
# 解压
|
||||
_unzip_docx(user_template_path, src_dir)
|
||||
_unzip_docx(result_path, dst_dir)
|
||||
|
||||
# 1) 替换 theme1.xml
|
||||
_copy_theme(src_dir, dst_dir)
|
||||
|
||||
# 2) 替换 fontTable.xml
|
||||
_copy_font_table(src_dir, dst_dir)
|
||||
|
||||
# 3) 合并默认字体引用
|
||||
src_styles = os.path.join(src_dir, "word", "styles.xml")
|
||||
dst_styles = os.path.join(dst_dir, "word", "styles.xml")
|
||||
_merge_default_fonts(src_styles, dst_styles)
|
||||
|
||||
# 重新打包
|
||||
_zip_docx(dst_dir, out_path)
|
||||
|
||||
# 校验:能正常打开且段落数与迁移前一致
|
||||
try:
|
||||
from docx import Document
|
||||
dst_count = len(Document(out_path).paragraphs)
|
||||
if src_count is None:
|
||||
print(f"[ok] 样式迁移完成(无原始段落数可比对),输出段落数: {dst_count}")
|
||||
elif dst_count == 0 or dst_count != src_count:
|
||||
print(f"[warn] 样式迁移后段落数不一致 (src={src_count}, dst={dst_count}),可能损坏")
|
||||
else:
|
||||
print(f"[ok] 样式迁移完成,段落数一致: {src_count}")
|
||||
except Exception as e:
|
||||
print(f"[error] 样式迁移后文档无法打开: {e}")
|
||||
|
||||
return out_path
|
||||
@@ -0,0 +1,133 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
template_scanner.py:扫描 docx 模板中的 Jinja2 占位符。
|
||||
|
||||
只读模板,不渲染;返回模板里要求外部提供的数据字段清单。
|
||||
"""
|
||||
import re
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from jinja2 import Environment, meta
|
||||
from jinja2 import nodes as jinja_nodes
|
||||
|
||||
|
||||
def _iter_docx_text(docx_path):
|
||||
"""遍历 docx 中所有 XML 文本节点,产出原始字符串片段。"""
|
||||
with zipfile.ZipFile(docx_path, "r") as z:
|
||||
for name in z.namelist():
|
||||
# 只关心 word 主文档、页眉、页脚
|
||||
if not (name.startswith("word/document") or name.startswith("word/header") or name.startswith("word/footer")):
|
||||
continue
|
||||
data = z.read(name)
|
||||
try:
|
||||
root = ET.fromstring(data)
|
||||
except ET.ParseError:
|
||||
continue
|
||||
# w:t 节点存放文本
|
||||
for elem in root.iter():
|
||||
if elem.tag.endswith("}t"):
|
||||
if elem.text:
|
||||
yield elem.text
|
||||
|
||||
|
||||
def _extract_source(docx_path):
|
||||
"""把 docx 所有文本片段拼成一段连续的源文本,便于 Jinja2 解析。"""
|
||||
return "\n".join(_iter_docx_text(docx_path))
|
||||
|
||||
|
||||
def _normalize_docxtpl_tags(source):
|
||||
"""把 docxtpl 段落/行/单元格级标签 {%p ... %} {%tr ... %} {%tc ... %} 还原为 {% ... %}。
|
||||
|
||||
docxtpl 用 {%p、{%tr、{%tc 控制块作用于段落、表格行、表格单元格;
|
||||
扫描占位符时不需要这些粒度信息,统一成标准 Jinja2 标签即可解析。
|
||||
"""
|
||||
return re.sub(r"{%\s*(?:p|tr|tc)\s+", "{% ", source)
|
||||
|
||||
|
||||
def _walk_blocks(node, result=None):
|
||||
"""遍历 Jinja2 AST,收集 For / If 块信息。"""
|
||||
if result is None:
|
||||
result = []
|
||||
if node is None:
|
||||
return result
|
||||
|
||||
if isinstance(node, jinja_nodes.For):
|
||||
iter_name = _expression_name(node.iter)
|
||||
target_name = _expression_name(node.target)
|
||||
result.append({
|
||||
"type": "for",
|
||||
"iterator": target_name,
|
||||
"variable": iter_name,
|
||||
})
|
||||
for child in node.body:
|
||||
_walk_blocks(child, result)
|
||||
for child in node.else_ or []:
|
||||
_walk_blocks(child, result)
|
||||
return result
|
||||
|
||||
if isinstance(node, jinja_nodes.If):
|
||||
test_name = _expression_name(node.test)
|
||||
result.append({
|
||||
"type": "if",
|
||||
"condition": test_name,
|
||||
})
|
||||
for child in node.body:
|
||||
_walk_blocks(child, result)
|
||||
for child in node.else_ or []:
|
||||
_walk_blocks(child, result)
|
||||
return result
|
||||
|
||||
if hasattr(node, "body"):
|
||||
for child in node.body:
|
||||
_walk_blocks(child, result)
|
||||
|
||||
if hasattr(node, "else_") and node.else_:
|
||||
for child in node.else_:
|
||||
_walk_blocks(child, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _expression_name(expr):
|
||||
"""把 Jinja2 表达式尽量还原为可读的字符串。"""
|
||||
if expr is None:
|
||||
return None
|
||||
if isinstance(expr, jinja_nodes.Name):
|
||||
return expr.name
|
||||
if isinstance(expr, jinja_nodes.Const):
|
||||
return str(expr.value)
|
||||
if isinstance(expr, jinja_nodes.Getattr):
|
||||
return f"{_expression_name(expr.node)}.{expr.attr}"
|
||||
if isinstance(expr, jinja_nodes.Getitem):
|
||||
return f"{_expression_name(expr.node)}[{_expression_name(expr.arg)}]"
|
||||
return str(expr)
|
||||
|
||||
|
||||
def scan_template(template_path):
|
||||
"""扫描 docx 模板,返回占位符信息 JSON。
|
||||
|
||||
返回:
|
||||
{
|
||||
"placeholders": ["project_title", "equipments", ...], # 需要外部提供的最顶层变量
|
||||
"blocks": [
|
||||
{"type": "for", "iterator": "eq", "variable": "equipments"},
|
||||
{"type": "if", "condition": "show_layout"},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
source = _normalize_docxtpl_tags(_extract_source(template_path))
|
||||
if not source.strip():
|
||||
return {"placeholders": [], "blocks": []}
|
||||
|
||||
env = Environment()
|
||||
ast = env.parse(source)
|
||||
|
||||
variables = sorted(meta.find_undeclared_variables(ast))
|
||||
blocks = _walk_blocks(ast)
|
||||
|
||||
return {
|
||||
"placeholders": variables,
|
||||
"blocks": blocks,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Utils package for lzwcai_mcpskills_generate_reports"""
|
||||
|
||||
from .logger_config import setup_system_logging, get_logger
|
||||
|
||||
__all__ = ["setup_system_logging", "get_logger"]
|
||||
@@ -0,0 +1,112 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
统一日志配置模块
|
||||
提供系统级别的日志配置和管理
|
||||
|
||||
注意:MCP 协议使用 stdio 通信时,stdout 被协议占用,
|
||||
所有日志必须输出到 stderr 或文件,绝不能写 stdout。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class LoggerConfig:
|
||||
"""日志配置管理类"""
|
||||
|
||||
def __init__(self, logs_dir: str = None):
|
||||
if logs_dir:
|
||||
self.logs_dir = Path(logs_dir)
|
||||
else:
|
||||
project_root = Path(__file__).parent.parent
|
||||
self.logs_dir = project_root / "logs"
|
||||
|
||||
self.logs_dir.mkdir(exist_ok=True)
|
||||
|
||||
self.log_format = '%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s'
|
||||
self.date_format = '%Y-%m-%d %H:%M:%S'
|
||||
self.log_level = self._get_log_level_from_env()
|
||||
self._initialized = False
|
||||
|
||||
def _get_log_level_from_env(self) -> int:
|
||||
log_level_str = os.getenv('LOG_LEVEL', 'INFO').upper()
|
||||
level_mapping = {
|
||||
'DEBUG': logging.DEBUG,
|
||||
'INFO': logging.INFO,
|
||||
'WARNING': logging.WARNING,
|
||||
'WARN': logging.WARNING,
|
||||
'ERROR': logging.ERROR,
|
||||
'CRITICAL': logging.CRITICAL,
|
||||
'FATAL': logging.CRITICAL,
|
||||
}
|
||||
return level_mapping.get(log_level_str, logging.INFO)
|
||||
|
||||
def setup_logging(self,
|
||||
app_name: str = "lzwcai_mcpskills_generate_reports",
|
||||
log_level: int = logging.INFO,
|
||||
max_file_size: int = 10 * 1024 * 1024,
|
||||
backup_count: int = 5,
|
||||
console_output: bool = True) -> logging.Logger:
|
||||
if self._initialized:
|
||||
return logging.getLogger()
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(log_level)
|
||||
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
formatter = logging.Formatter(self.log_format, self.date_format)
|
||||
|
||||
# 1. 主日志文件 - 按大小滚动
|
||||
main_log_file = self.logs_dir / f"{app_name}.log"
|
||||
file_handler = RotatingFileHandler(
|
||||
main_log_file, maxBytes=max_file_size,
|
||||
backupCount=backup_count, encoding='utf-8',
|
||||
)
|
||||
file_handler.setLevel(log_level)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# 2. 错误日志文件
|
||||
error_log_file = self.logs_dir / f"{app_name}_error.log"
|
||||
error_handler = RotatingFileHandler(
|
||||
error_log_file, maxBytes=max_file_size,
|
||||
backupCount=backup_count, encoding='utf-8',
|
||||
)
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
error_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(error_handler)
|
||||
|
||||
# 3. 控制台输出到 stderr(stdio 模式下 stdout 被 MCP 协议占用)
|
||||
if console_output:
|
||||
console_handler = logging.StreamHandler(sys.stderr)
|
||||
console_handler.setLevel(log_level)
|
||||
console_handler.setFormatter(logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
self.date_format,
|
||||
))
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
self._initialized = True
|
||||
root_logger.info(f"日志系统初始化完成 - 日志目录: {self.logs_dir}")
|
||||
return root_logger
|
||||
|
||||
def get_module_logger(self, module_name: str) -> logging.Logger:
|
||||
return logging.getLogger(module_name)
|
||||
|
||||
|
||||
# 全局日志配置实例
|
||||
logger_config = LoggerConfig()
|
||||
|
||||
|
||||
def setup_system_logging(app_name: str = "lzwcai_mcpskills_generate_reports",
|
||||
log_level: int = logging.INFO) -> logging.Logger:
|
||||
return logger_config.setup_logging(app_name, log_level)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logger_config.get_module_logger(name)
|
||||
10
lzwcai_mcpskills_generate_reports/main.py
Normal file
10
lzwcai_mcpskills_generate_reports/main.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Entry point for lzwcai-mcpskills-generate-reports
|
||||
|
||||
Runs the MCP server (stdio mode) for docx report generation.
|
||||
"""
|
||||
|
||||
from lzwcai_mcpskills_generate_reports.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
40
lzwcai_mcpskills_generate_reports/pyproject.toml
Normal file
40
lzwcai_mcpskills_generate_reports/pyproject.toml
Normal file
@@ -0,0 +1,40 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "lzwcai-mcpskills-generate-reports"
|
||||
version = "0.1.0"
|
||||
description = "Render styled quotation documents from user-supplied docx templates and structured data"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
keywords = ["docx", "quotation", "report", "template", "jinja2"]
|
||||
authors = [
|
||||
{ name = "LzwCai", email = "lzwcai@example.com" },
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = [
|
||||
"docxtpl>=0.16.0",
|
||||
"python-docx>=1.1.0",
|
||||
"Jinja2>=3.1.0",
|
||||
"mcp>=1.0.0",
|
||||
"anyio>=4.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
generate-report = "lzwcai_mcpskills_generate_reports.cli:main"
|
||||
lzwcai-mcpskills-generate-reports = "lzwcai_mcpskills_generate_reports.server:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/lzwcai/lzwcai-mcpskills-generate-reports"
|
||||
Repository = "https://github.com/lzwcai/lzwcai-mcpskills-generate-reports"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["lzwcai_mcpskills_generate_reports*"]
|
||||
exclude = ["tests*", "_out*", "templates*", "samples*"]
|
||||
412
lzwcai_mcpskills_generate_reports/samples/sample_data.json
Normal file
412
lzwcai_mcpskills_generate_reports/samples/sample_data.json
Normal file
@@ -0,0 +1,412 @@
|
||||
{
|
||||
"project_title": "大米圆形纸罐全自动灌装包装整线项目",
|
||||
"contact_person": "张卫国经理",
|
||||
"contact_phone": "138-1568-9632",
|
||||
"contact_company": "XX粮油食品有限公司",
|
||||
"requirements": [
|
||||
"包装物料:成品精制大米,流动性颗粒物料;单罐净重规格:250g、500g、1000g三档快速切换。",
|
||||
"罐体参数:圆形食品级纸罐,配套易拉预封口+外旋螺纹盖双重密封;纸罐固定外径100mm(10cm),高度分3档:60mm(6cm)/120mm(12cm)/220mm(22cm),同线兼容三规格自动换型无需更换夹具。",
|
||||
"额定包装速度:以500g标准罐计,稳定产能≥20罐/分钟,连续24h不间断运行无卡罐、漏装。",
|
||||
"整线标配:大米定量称重灌装机、罐身输送线、自动上盖机、四轮旋盖机、成品出料输送机;",
|
||||
"客户选配完整模组:在线金属检测机(带自动剔除机构)+重量复检机(不合格自动分流剔除)+后端自动开箱机+折盖封箱一体机+机器人码垛单元;",
|
||||
"全线材质要求:物料接触部位304不锈钢,符合食品QS/SC生产卫生规范,支持水洗清洁。"
|
||||
],
|
||||
"layout_image": "https://dscache.tencent-cloud.cn/upload/nodir/rice-can-line-layout-202605.png",
|
||||
"layout_title": "大米纸罐灌装整线平面布局尺寸总图",
|
||||
"equipments": [
|
||||
{
|
||||
"name": "Z型大米颗粒上料提升机",
|
||||
"images": [
|
||||
"https://dscache.tencent-cloud.cn/upload/nodir/z-conveyor-01.png",
|
||||
"https://dscache.tencent-cloud.cn/upload/nodir/z-conveyor-detail.png"
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"title": "粮食专用密封提升",
|
||||
"lines": [
|
||||
"封闭式料斗输送,无大米撒料、扬尘,车间粉尘达标;适配大米、杂粮等颗粒原料连续上料。",
|
||||
"料斗加厚304不锈钢,耐磨抗冲击,不易积粮霉变,便于高压水枪冲洗。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "变频调速稳定可控",
|
||||
"lines": [
|
||||
"独立变频电机调速,上料流量与灌装主机信号联动匹配,不会断料或溢料。",
|
||||
"低噪音链条传动,连续运行故障率低,维护简单,可长期满负荷生产。"
|
||||
]
|
||||
}
|
||||
],
|
||||
"params": [
|
||||
{
|
||||
"k": "有效提升高度",
|
||||
"v": "3.6m(支持现场按需加长定制)"
|
||||
},
|
||||
{
|
||||
"k": "输送速度可调范围",
|
||||
"v": "0~16m/min"
|
||||
},
|
||||
{
|
||||
"k": "最大输送产能",
|
||||
"v": "7.2m³/h,满足25罐/分钟灌装余量"
|
||||
},
|
||||
{
|
||||
"k": "驱动总功率",
|
||||
"v": "0.55kW"
|
||||
},
|
||||
{
|
||||
"k": "供电制式",
|
||||
"v": "380V三相 50Hz"
|
||||
},
|
||||
{
|
||||
"k": "整机净重",
|
||||
"v": "385kg"
|
||||
},
|
||||
{
|
||||
"k": "物料接触材质",
|
||||
"v": "304食品级不锈钢"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "多头称重式大米灌装机",
|
||||
"images": [
|
||||
"https://dscache.tencent-cloud.cn/upload/nodir/rice-filling-machine.png"
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"title": "高精度称重灌装",
|
||||
"lines": [
|
||||
"二级快慢加料,250g/500g/1000g重量参数触摸屏一键调用,单罐称重误差≤±0.8g。",
|
||||
"独立称重料斗,不受物料料位高低影响,长时间灌装重量一致性稳定。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "食品级卫生设计",
|
||||
"lines": [
|
||||
"料仓、下料口快拆结构,无需工具即可拆卸清洗,无死角存粮。",
|
||||
"整机带防尘外封板,避免蚊虫、杂物混入大米成品。"
|
||||
]
|
||||
}
|
||||
],
|
||||
"params": [
|
||||
{
|
||||
"k": "灌装量程",
|
||||
"v": "100~1200g可调"
|
||||
},
|
||||
{
|
||||
"k": "额定产能",
|
||||
"v": "22~28罐/分钟(500g规格)"
|
||||
},
|
||||
{
|
||||
"k": "允许罐型外径",
|
||||
"v": "60~120mm"
|
||||
},
|
||||
{
|
||||
"k": "整机功率",
|
||||
"v": "1.2kW"
|
||||
},
|
||||
{
|
||||
"k": "外形长宽高",
|
||||
"v": "1450×920×1850mm"
|
||||
},
|
||||
{
|
||||
"k": "整机重量",
|
||||
"v": "420kg"
|
||||
},
|
||||
{
|
||||
"k": "操作界面",
|
||||
"v": "7寸触摸屏+PLC全自动控制"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "自动纸罐理罐机",
|
||||
"images": [
|
||||
"https://dscache.tencent-cloud.cn/upload/nodir/can-sorter.png"
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"title": "圆形纸罐定向排序",
|
||||
"lines": [
|
||||
"自动散乱上罐、扶正定位、有序输送,杜绝倒罐、卡罐,适配本次100mm外径纸罐三高度规格。",
|
||||
"定位挡板手摇快速调节,换型时间≤3分钟。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "整机耐用易维护",
|
||||
"lines": [
|
||||
"机架304不锈钢,输送带独立无极变频调速,可和灌装主机速度同步联动。",
|
||||
"机械结构简洁,易损件少,车间操作工可独立日常检修。"
|
||||
]
|
||||
}
|
||||
],
|
||||
"params": [
|
||||
{
|
||||
"k": "主体材质",
|
||||
"v": "304不锈钢机架+食品级PU输送带"
|
||||
},
|
||||
{
|
||||
"k": "整机外形尺寸",
|
||||
"v": "1020×810×1220mm"
|
||||
},
|
||||
{
|
||||
"k": "最大处理产能",
|
||||
"v": "30~50罐/分钟"
|
||||
},
|
||||
{
|
||||
"k": "理罐转盘直径",
|
||||
"v": "800mm"
|
||||
},
|
||||
{
|
||||
"k": "适配罐体外径",
|
||||
"v": "60~200mm"
|
||||
},
|
||||
{
|
||||
"k": "工作电压",
|
||||
"v": "单相220V,50Hz"
|
||||
},
|
||||
{
|
||||
"k": "整机重量",
|
||||
"v": "145kg"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "自动上盖机+单头四轮旋盖一体机",
|
||||
"images": [
|
||||
"https://dscache.tencent-cloud.cn/upload/nodir/capping-twist-machine.png"
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"title": "自动送盖+定位旋盖一体化",
|
||||
"lines": [
|
||||
"料仓自动整理螺纹外盖,分盖、落盖精准套入罐口,四轮橡胶轮柔性夹紧旋紧,不会压扁纸质罐口。",
|
||||
"旋盖扭矩数字可调,杜绝滑盖、拧过紧纸罐变形,适配易拉内封+外旋盖双层封口工艺。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "速度同步联动",
|
||||
"lines": [
|
||||
"变频调速跟随灌装线主线速度,无空罐漏旋,缺盖自动停机报警提示补料。"
|
||||
]
|
||||
}
|
||||
],
|
||||
"params": [
|
||||
{
|
||||
"k": "供电规格",
|
||||
"v": "220V 50Hz"
|
||||
},
|
||||
{
|
||||
"k": "整机装机功率",
|
||||
"v": "1.0kW"
|
||||
},
|
||||
{
|
||||
"k": "适配罐口直径",
|
||||
"v": "35~130mm"
|
||||
},
|
||||
{
|
||||
"k": "稳定旋盖速度",
|
||||
"v": "25~32罐/分钟"
|
||||
},
|
||||
{
|
||||
"k": "设备外形尺寸",
|
||||
"v": "2020×660×1510mm"
|
||||
},
|
||||
{
|
||||
"k": "整机重量",
|
||||
"v": "295kg"
|
||||
},
|
||||
{
|
||||
"k": "扭矩调节方式",
|
||||
"v": "数字扭矩电控调节"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "在线金属检测机(带剔除)",
|
||||
"images": [
|
||||
"https://dscache.tencent-cloud.cn/upload/nodir/metal-detector-reject.png"
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"title": "高精度金属异物检测",
|
||||
"lines": [
|
||||
"可检出铁、不锈钢、铜、铝等混入大米内金属碎屑、螺钉刀片,不合格罐气动自动剔除分流,不混入合格品。",
|
||||
"检测灵敏度数字可调,产品记忆存储,多规格一键切换。"
|
||||
]
|
||||
}
|
||||
],
|
||||
"params": [
|
||||
{
|
||||
"k": "检测通道尺寸",
|
||||
"v": "宽140mm×高280mm"
|
||||
},
|
||||
{
|
||||
"k": "检测灵敏度",
|
||||
"v": "Feφ1.0mm、SUSφ2.2mm"
|
||||
},
|
||||
{
|
||||
"k": "剔除方式",
|
||||
"v": "气动推杆自动剔除"
|
||||
},
|
||||
{
|
||||
"k": "适配线速",
|
||||
"v": "0~30m/min"
|
||||
},
|
||||
{
|
||||
"k": "功率",
|
||||
"v": "0.37kW"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "成品重量复检机",
|
||||
"images": [
|
||||
"https://dscache.tencent-cloud.cn/upload/nodir/checkweigher.png"
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"title": "缺料超重自动分选",
|
||||
"lines": [
|
||||
"在线动态称重,低于下限、高于上限罐体自动剔除,杜绝少装、多装次品流入装箱工序。"
|
||||
]
|
||||
}
|
||||
],
|
||||
"params": [
|
||||
{
|
||||
"k": "称重量程",
|
||||
"v": "0~2000g"
|
||||
},
|
||||
{
|
||||
"k": "称重精度",
|
||||
"v": "±0.3g"
|
||||
},
|
||||
{
|
||||
"k": "剔除方式",
|
||||
"v": "气动拨杆剔除"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "后端自动开箱+折盖封箱一体机",
|
||||
"images": [
|
||||
"https://dscache.tencent-cloud.cn/upload/nodir/case-sealer.png"
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"title": "纸箱成型封底+上盖折平封箱一次完成",
|
||||
"lines": [
|
||||
"整垛纸箱自动吸取撑开、底部胶带封牢,成品罐装箱后自动折左右上盖,上下工字封箱,适配整线连续自动化装箱。"
|
||||
]
|
||||
}
|
||||
],
|
||||
"params": [
|
||||
{
|
||||
"k": "适用纸箱尺寸范围",
|
||||
"v": "长250~450×宽180~320×高150~400mm"
|
||||
},
|
||||
{
|
||||
"k": "封箱速度",
|
||||
"v": "6~12箱/分钟"
|
||||
},
|
||||
{
|
||||
"k": "总功率",
|
||||
"v": "1.8kW"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "机械臂码垛机",
|
||||
"images": [
|
||||
"https://dscache.tencent-cloud.cn/upload/nodir/robot-palletizer.png"
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"title": "纸箱自动堆叠码垛",
|
||||
"lines": [
|
||||
"伺服抓手抓取成品整箱,按预设垛型整齐码放在托盘上,码垛高度、层数程序可调,替代人工堆垛。"
|
||||
]
|
||||
}
|
||||
],
|
||||
"params": [
|
||||
{
|
||||
"k": "最大负载",
|
||||
"v": "25kg/箱"
|
||||
},
|
||||
{
|
||||
"k": "码垛高度上限",
|
||||
"v": "1800mm"
|
||||
},
|
||||
{
|
||||
"k": "工作节拍",
|
||||
"v": "8~12箱/分钟"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"quote_items": [
|
||||
{
|
||||
"name": "Z型大米上料提升机",
|
||||
"qty": "1套",
|
||||
"image": "https://dscache.tencent-cloud.cn/upload/nodir/z-conveyor-01.png",
|
||||
"desc": "304不锈钢封闭式粮食提升,变频调速,配套灌装主机联动上料",
|
||||
"price": "面议"
|
||||
},
|
||||
{
|
||||
"name": "多头称重大米灌装机",
|
||||
"qty": "1套",
|
||||
"image": "https://dscache.tencent-cloud.cn/upload/nodir/rice-filling-machine.png",
|
||||
"desc": "三规格重量一键切换,高精度称重下料,食品级快拆清洗结构",
|
||||
"price": "面议"
|
||||
},
|
||||
{
|
||||
"name": "自动纸罐理罐机",
|
||||
"qty": "1套",
|
||||
"image": "https://dscache.tencent-cloud.cn/upload/nodir/can-sorter.png",
|
||||
"desc": "圆形纸罐自动排序扶正,适配Φ100mm三高度纸罐快速换型",
|
||||
"price": "面议"
|
||||
},
|
||||
{
|
||||
"name": "自动上盖+四轮旋盖一体机",
|
||||
"qty": "1套",
|
||||
"image": "https://dscache.tencent-cloud.cn/upload/nodir/capping-twist-machine.png",
|
||||
"desc": "自动分盖上盖,数字扭矩旋紧,适配纸罐易拉盖+外旋盖双层封口",
|
||||
"price": "面议"
|
||||
},
|
||||
{
|
||||
"name": "金属检测机(带剔除)",
|
||||
"qty": "1套",
|
||||
"image": "https://dscache.tencent-cloud.cn/upload/nodir/metal-detector-reject.png",
|
||||
"desc": "在线金属异物检测,不合格罐体自动剔除分流,食品生产合规必备",
|
||||
"price": "面议"
|
||||
},
|
||||
{
|
||||
"name": "重量复检剔除机",
|
||||
"qty": "1套",
|
||||
"image": "https://dscache.tencent-cloud.cn/upload/nodir/checkweigher.png",
|
||||
"desc": "动态在线称重,超重欠重次品自动剔除,保证净含量达标",
|
||||
"price": "面议"
|
||||
},
|
||||
{
|
||||
"name": "自动开箱折盖封箱一体机",
|
||||
"qty": "1套",
|
||||
"image": "https://dscache.tencent-cloud.cn/upload/nodir/case-sealer.png",
|
||||
"desc": "纸箱自动成型封底、装箱后折盖封箱,后端自动化装箱配套",
|
||||
"price": "面议"
|
||||
},
|
||||
{
|
||||
"name": "机器人码垛单元",
|
||||
"qty": "1套",
|
||||
"image": "https://dscache.tencent-cloud.cn/upload/nodir/robot-palletizer.png",
|
||||
"desc": "抓取成品纸箱自动码垛堆托,解放后端人工搬运堆叠",
|
||||
"price": "面议"
|
||||
},
|
||||
{
|
||||
"name": "全线不锈钢输送过渡机架+电控总控制柜",
|
||||
"qty": "1套",
|
||||
"image": "https://dscache.tencent-cloud.cn/upload/nodir/line-conveyor-total.png",
|
||||
"desc": "各设备接驳输送线、整机联动PLC总控制系统、急停、报警、联动互锁整套电气配套",
|
||||
"price": "面议"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "标准罐装线模板",
|
||||
"description": "默认模板,适用于大多数罐装线报价方案",
|
||||
"image_fields": {
|
||||
"layout_image": {"width_mm": 160, "scope": "root"},
|
||||
"equipments[].images[]": {"width_mm": 120, "scope": "list"},
|
||||
"quote_items[].image": {"width_mm": 30, "scope": "list"}
|
||||
},
|
||||
"required": ["project_title", "contact_person", "contact_phone", "requirements"],
|
||||
"optional_sections": ["show_layout"]
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user