Compare commits

...

17 Commits

Author SHA1 Message Date
9c8de5e664 暂存-说明文档(部分) 2026-05-16 00:45:51 +08:00
91bedce2d7 暂存-安装脚本 2026-05-15 12:08:02 +08:00
6ccd3eb9c1 暂存-安装服务 2026-05-12 15:56:56 +08:00
42e3050fa2 删除遗漏的api test数据 2026-05-09 16:44:28 +08:00
75ceec0798 补充遗漏翻译,新增启动脚本,整理import 2026-05-09 16:42:17 +08:00
e542c482d7 添加api调用接口
Co-authored-by: Copilot <copilot@github.com>
2026-05-08 22:24:33 +08:00
a26f7214f9 修改部分参数
Co-authored-by: Copilot <copilot@github.com>
2026-05-08 01:16:40 +08:00
40b8cc8023 切片引擎解耦,打印机页面优化 2026-05-08 01:16:08 +08:00
ced6c67e83 lhye200 2026-05-01 02:01:30 +08:00
0b2199ec49 修改了质量参数、添加了账户管理功能
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 02:01:19 +08:00
72e3a165ac 大修参数,改prusa版本到2.9.4
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 01:01:24 +08:00
2dbecfe0d4 准备大修参数
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 00:07:27 +08:00
366372da6e 能用prusa切片和预览了,添加了缺失的翻译
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 01:51:08 +08:00
22a6493e24 tmp prusa配置文件集
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 00:47:32 +08:00
0416922a94 分离cura,加入prusa,未测试 2026-04-18 23:40:03 +08:00
6981553101 修复偏移问题,修复代理问题 2026-04-15 00:22:38 +08:00
f0f9d658eb 修复偏移问题,修复代理问题 2026-04-15 00:22:12 +08:00
213 changed files with 8398 additions and 2269 deletions

4
.gitignore vendored
View File

@@ -3,4 +3,6 @@ uploads/*
tmp/* tmp/*
venv venv
instance instance
huey_queue.* huey_queue.*
*.AppImage
frpc/*

79
502_err.html Normal file
View File

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>502 - AIO 切片服务器未连接</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f8f9fa;
color: #333;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.container {
background: #fff;
padding: 40px;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
max-width: 500px;
text-align: center;
}
.icon {
font-size: 60px;
margin-bottom: 20px;
}
h1 {
font-size: 24px;
margin-bottom: 15px;
color: #dc3545;
}
p {
font-size: 16px;
line-height: 1.6;
margin-bottom: 20px;
color: #555;
text-align: left;
}
.suggestions {
background: #f1f3f5;
padding: 15px;
border-radius: 6px;
text-align: left;
font-size: 14px;
}
.suggestions ul {
margin: 10px 0 0 0;
padding-left: 20px;
}
.suggestions li {
margin-bottom: 8px;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">🔌</div>
<h1>AIO 切片服务器连接失败 (502)</h1>
<p>非常抱歉,公共代理服务器目前无法连接到后端的树莓派 AIO 切片服务。这通常是由于物理设备离线或网络异常导致的。</p>
<div class="suggestions">
<strong>可能的原因:</strong>
<ul>
<li>树莓派尚未通电或正在启动中。</li>
<li>树莓派所在的局域网断网,或未能成功连接到互联网。</li>
</ul>
<strong>建议操作:</strong>
<ul>
<li>请检查机器电源,稍等片刻后刷新页面重试。</li>
<li>若问题仍然存在,请<strong>联系管理员</strong>检查网络配置。</li>
<li>在局域网失效时,您可以尝试通过连接<strong>树莓派的本地热点</strong>直接进行文件传输。</li>
</ul>
</div>
</div>
</body>
</html>

65
README.md Normal file
View File

@@ -0,0 +1,65 @@
# AIO 3D Print Web Platform
简介
----
这是一个基于 Python 的 Web 打印管理平台,通过调用 OctoPrint 的 API 来控制支持 Klipper 的打印机,集成切片、文件管理和打印机操作等功能。前端资源位于 `app/assets`,包含若干界面截图与帮助文档(见下方示例图片)。
示例图片
---------
![Logo](app/assets/img/logo.jpg)
切片助手示例:
![Slice Helper](app/assets/img/slice_helper/slice-helper_zh-cn.png)
快速开始
--------
1. 克隆仓库:
```bash
git clone https://gitea.lhye.work/lhye200/AIO_3D_Print_Web_Platform.git
cd AIO_3D_Print_Exp
```
2. 运行安装脚本(会创建虚拟环境并安装 Python 依赖,下载运行需要的文件,安装 systemd 服务):
```bash
./install.sh
```
安装脚本说明
-------------
- 安装脚本会创建 `venv`、安装 `requirements.txt` 中列出的依赖,并尝试设置 systemd 服务。
- 安装脚本可**可选**下载 PrusaSlicer 的 AppImage 二进制(用于本地进行切片):
- 二进制来源: https://github.com/davidk/PrusaSlicer-ARM.AppImage
- 源码: https://github.com/prusa3d/PrusaSlicer
- 控制方式(环境变量):
- `PRUSA_SKIP_DOWNLOAD=1` : 跳过下载二进制(默认会询问)
- `PRUSA_AGPL_ACCEPT=1` : 自动同意 AGPLv3 条款并下载(默认需要交互确认)
支持的切片引擎
---------------
- `Cura` 有一定支持,但由于其配置方式复杂容易出错,现使用体验不佳。
- `PrusaSlicer` 较为全面的支持。
许可与第三方
---------------
- 本仓库根目录的 `LICENSE` 为本项目主体采用的许可证GPLv3
- 本项目可选使用的第三方软件 PrusaSlicer 受 AGPLv3 约束;相关说明与合规提示见 [third_party/PRUSASLICER.md](third_party/PRUSASLICER.md)。
- 如果你在服务器上运行并通过网络提供基于 AGPL 组件的服务AGPL 可能要求你向使用该服务的用户公开对应源码。
AI 协助声明
----------------
本仓库的部分内容由 AI 生成。
更多信息
------------
- 代码结构与前端资源位于 `app/`,包括 `app/assets`(图片、脚本、样式)与 `app/templates`
- 请阅读 `install.sh` 以了解安装过程的详细步骤与可配置选项。

16
aio-3d-huey.service Normal file
View File

@@ -0,0 +1,16 @@
[Unit]
Description=AIO 3D Printer Slice and Manage Platform Huey Tasks
After=network.target
[Service]
User=1000
# Placeholder path; installer will replace with actual repository path
WorkingDirectory=${REPO_DIR}/
ExecStart=${REPO_DIR}/run_huey.sh
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

17
aio-3d-main.service Normal file
View File

@@ -0,0 +1,17 @@
[Unit]
Description=AIO 3D Printer Slice and Manage Platform
After=aio-3d-huey.service network.target
Wants=aio-3d-huey.service network.target
[Service]
User=1000
# Placeholder path; installer will replace with actual repository path
WorkingDirectory=${REPO_DIR}/
ExecStart=${REPO_DIR}/run_main.sh
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -52,9 +52,14 @@ def _t(key):
def create_app(): def create_app():
app = Flask(__name__, static_url_path='/assets', static_folder='assets') app = Flask(__name__, static_url_path='/assets', static_folder='assets')
app.config['SECRET_KEY'] = 'your-secret-key-change-it-in-production' app.config['SECRET_KEY'] = 'your-secret-key-change-it-in-production'
app.config['SESSION_COOKIE_NAME'] = 'aio_session' # Prevent collision with OctoPrint's 'session' cookie
app.config['REMEMBER_COOKIE_NAME'] = 'aio_remember'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///../instance/aio_3d.db' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///../instance/aio_3d.db'
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {'connect_args': {'timeout': 15}} app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {'connect_args': {'timeout': 15}}
app.config['UPLOAD_FOLDER'] = os.path.abspath(os.path.join(app.root_path, '..', 'uploads')) app.config['UPLOAD_FOLDER'] = os.environ.get('UPLOAD_FOLDER', os.path.abspath(os.path.join(app.root_path, '..', 'uploads')))
app.config['PRINT_CONFIG_FOLDER'] = os.environ.get('PRINT_CONFIG_FOLDER', os.path.abspath(os.path.join(app.root_path, '..', 'print_config')))
app.config['PRUSA_SLICE_BIN'] = os.environ.get('PRUSA_SLICE_BIN', os.path.abspath(os.path.join(app.root_path, '..', 'prusaslicer', 'PrusaSlicer-2.9.4-aarch64-full.AppImage')))
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
@@ -78,9 +83,11 @@ def create_app():
from .routes.auth_routes import auth_bp from .routes.auth_routes import auth_bp
from .routes.admin_routes import admin_bp from .routes.admin_routes import admin_bp
from .routes.printer_routes import printer_bp from .routes.printer_routes import printer_bp
from .utils.api_handle import api_bp
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp) app.register_blueprint(admin_bp)
app.register_blueprint(printer_bp) app.register_blueprint(printer_bp)
app.register_blueprint(api_bp)
return app return app

View File

@@ -0,0 +1,58 @@
# Drucker-Helfer — Kurzanleitung
## Inhaltsverzeichnis
- Druckerstatus
- Druck vorbereiten
- Steuerung
- Drucker-Helfer (diese Seite)
- Systemkonfiguration (Admin)
- OctoPrint-Panel (Admin)
---
## Druckerstatus
Zeigt aktuellen Druckerzustand, Temperaturen und aktive Aufgaben an.
![Druckerstatus Platzhalterbild](assets/doc/images/printer_status_de.png)
---
## Druck vorbereiten
GCode an den Drucker senden, Temperaturen setzen und mit `Druck vorbereiten` bzw. `Jetzt drucken` starten.
![Druck vorbereiten Platzhalterbild](assets/doc/images/printer_prepare_de.png)
---
## Steuerung
Manuelle Grundsteuerungen: Achsen homing, Düsen/Betten bewegen, Pause/Fortsetzen, Druck abbrechen.
![Steuerung Platzhalterbild](assets/doc/images/printer_control_de.png)
---
## Drucker-Helfer (diese Seite)
Tipps zur Fehlerbehebung (Netzwerk, Filament, Bettleveling) und Checkliste vor dem Drucken.
![Drucker Helfer Platzhalterbild](assets/doc/images/printer_helper_de.png)
---
## Systemkonfiguration (Admin)
Admin-Einstellungen für Druckerabmessungen, Limits, Basisprofile und Verbindungsdaten.
![Systemkonfiguration Platzhalterbild](assets/doc/images/printer_system_de.png)
---
## OctoPrint-Panel (Admin)
Eingebettetes OctoPrint-Panel: `OctoPrint Basis-URL` und API-Key konfigurieren, Live-Panel verwenden.
![OctoPrint Platzhalterbild](assets/doc/images/printer_octoprint_de.png)

View File

@@ -0,0 +1,64 @@
# Printer Helper — Quick Guide
## Table of Contents
- Printer Status
- Prepare Print
- Control
- Printer Helper (this page)
- System Configuration (Admin)
- OctoPrint Panel (Admin)
---
## Printer Status
Shows current printer state, temperatures and active job information.
![Printer Status placeholder image](assets/doc/images/printer_status_en.png)
Use this page to monitor `Printer Status` and `Active Print Job`.
---
## Prepare Print
Send prepared GCode to the printer, set temperatures and start a print using `Prepare Print` and `Print Now`.
![Prepare Print placeholder image](assets/doc/images/printer_prepare_en.png)
---
## Control
Basic manual controls: home axes, move nozzle/bed, pause/resume and cancel print.
![Control placeholder image](assets/doc/images/printer_control_en.png)
---
## Printer Helper (this page)
Guides common troubleshooting steps (connectivity, filament, bed leveling) and quick checks before printing.
![Printer Helper placeholder image](assets/doc/images/printer_helper_en.png)
---
## System Configuration (Admin)
Admin-only settings for printer dimensions, limits, shared profiles and connection settings.
![System Config placeholder image](assets/doc/images/printer_system_en.png)
---
## OctoPrint Panel (Admin)
Embedded OctoPrint panel: configure `OctoPrint Base URL`, API key and use the live panel when available.
![OctoPrint Panel placeholder image](assets/doc/images/printer_octoprint_en.png)
---
If you want I can add annotated screenshots for specific printer models.

View File

@@ -0,0 +1,60 @@
# 打印助手 — 快速指南
## 目录
- 打印机状态
- 准备打印
- 控制
- 打印助手(本页)
- 系统配置(管理员)
- OctoPrint 面板(管理员)
---
## 打印机状态
显示当前打印机状态、温度和任务信息。
![打印机状态 占位图](assets/doc/images/printer_status_zh-cn.png)
可在此查看 `打印机状态``当前打印任务`
---
## 准备打印
将准备好的 GCode 发送到打印机,设置温度并使用 `准备打印``立即打印` 开始。
![准备打印 占位图](assets/doc/images/printer_prepare_zh-cn.png)
---
## 控制
手动控制:回原点、移动喷嘴/平台、暂停/恢复与取消打印。
![控制 占位图](assets/doc/images/printer_control_zh-cn.png)
---
## 打印助手(本页)
提供常见故障排查步骤(网络、挤出机、床平整)和打印前检查清单。
![打印助手 占位图](assets/doc/images/printer_helper_zh-cn.png)
---
## 系统配置(管理员)
管理员设置打印机尺寸、限制、基础配置和连接信息。
![系统配置 占位图](assets/doc/images/printer_system_zh-cn.png)
---
## OctoPrint 面板(管理员)
内嵌 OctoPrint 面板:配置 `OctoPrint 基础 URL`、API 密钥并使用可用的实时面板。
![OctoPrint 占位图](assets/doc/images/printer_octoprint_zh-cn.png)

View File

@@ -0,0 +1,82 @@
# Slice-Helfer — Kurzanleitung
## Inhaltsverzeichnis
- Startseite
- Meine Dateien
- Plater (Bauteilplatte)
- Konto-Verwaltung
- Slice-Helfer (diese Seite)
- Systemeinstellungen (Admin)
- Benutzerverwaltung (Admin)
- API-Schlüssel (Admin)
---
## Startseite
Übersicht des Slicer-Dashboards und schnelle Aktionen.
![Startseite Bild](../../assets/img/slice_helper/home_de.png)
Benutzen Sie die Navigation, um `Startseite` zu öffnen und über `STL Hochladen & Slicen` einen neuen Slice zu starten.
---
## Meine Dateien
Verwalten Sie hochgeladene STL- und GCode-Dateien: hochladen, herunterladen, löschen.
![Meine Dateien Bild](../../assets/img/slice_helper/my-files_de.png)
Wichtige Aktionen: `STL hochladen`, `GCode Herunterladen`, `Löschen`.
---
## Plater (Bauteilplatte)
Modelle auf der Bauteilplatte anordnen, verschieben, drehen und skalieren. Vor dem Slicen `Zusammenführen & Slicen`.
![Plater Bild](../../assets/img/slice_helper/plater_de.png)
---
## Konto-Verwaltung
Für angemeldete Benutzer: Profil, Passwort ändern und aktive Sitzungen verwalten.
![Konto Bild](../../assets/img/slice_helper/account-management_de.png)
---
## Slice-Helfer (diese Seite)
Erklärung der empfohlenen Slicing-Schritte: `Qualitätsprofil` wählen, `Support` und `Fülldichte` konfigurieren, dann `Hochladen & Slicen`.
![Slice Helfer Bild](../../assets/img/slice_helper/slice-helper_de.png)
Statusmeldungen: `Wartend`, `Slicen`, `Gesliced`, `Fehlgeschlagen`.
---
## Systemeinstellungen (Admin)
Admins konfigurieren globale Slicer-Engines und Standardprofile.
![Systemeinstellungen Bild](../../assets/img/slice_helper/system-settings_de.png)
---
## Benutzerverwaltung (Admin)
Admins können Benutzer hinzufügen/ändern und Quoten sowie Rollen setzen (`Benutzer`, `Admin`).
![Benutzerverwaltung Bild](../../assets/img/slice_helper/user-management_de.png)
---
## API-Schlüssel (Admin)
Verwalten Sie API-Schlüssel für externe Integrationen: `Neuen API-Schlüssel erstellen` und `Schlüssel generieren`.
![API Schlüssel Bild](../../assets/img/slice_helper/api-keys_de.png)

View File

@@ -0,0 +1,88 @@
# Slice Helper — Quick Guide
## Table of Contents
- Home
- My Files
- Plater (Build Plate)
- Account Management
- Slice Helper (this page)
- System Settings (Admin)
- User Management (Admin)
- API Keys (Admin)
---
## Home
Overview of the slicer dashboard and quick actions.
![Home image](../../assets/img/slice_helper/home_en.png)
Use the top navigation to open `Home` and start a new slice via `Upload & Slice STL`.
---
## My Files
Manage uploaded STL and GCode files. You can upload, delete and download sliced GCode.
![My Files image](../../assets/img/slice_helper/my-files_en.png)
Common actions: `Upload STL`, `Download GCode`, `Delete`.
---
## Plater (Build Plate)
Arrange models on the build plate before slicing. Use translate/rotate/scale tools and `Merge & Slice`.
![Plater image](../../assets/img/slice_helper/plater_en.png)
Tip: ensure all models fit the printable area before slicing.
---
## Account Management
Available when logged in. Update profile, change password, and manage active sessions.
![Account image](../../assets/img/slice_helper/account-management_en.png)
---
## Slice Helper (this page)
This page explains slice workflows and recommended settings. Choose a `Quality Profile`, set `Support` and `Infill Density` then `Upload & Slice`.
![Slice Helper image](../../assets/img/slice_helper/slice-helper_en.png)
Status messages: `Waiting`, `Slicing`, `Sliced`, `Failed`.
---
## System Settings (Admin)
Admins can configure global slicer engines and default profiles under `System Settings`.
![System Settings image](../../assets/img/slice_helper/system-settings_en.png)
---
## User Management (Admin)
Admins can add/edit users, set quotas and roles (`User`, `Admin`).
![User Management image](../../assets/img/slice_helper/user-management_en.png)
---
## API Keys (Admin)
Manage API keys used by external tools. `Create New API Key`, name it and `Generate Key`.
![API Keys image](../../assets/img/slice_helper/api-keys_en.png)
---
If you need example workflows or screenshots, tell me which page to expand.

View File

@@ -0,0 +1,84 @@
# 切片助手 — 快速指南
## 目录
- 主页
- 我的文件
- 构建板 (Plater)
- 账号管理
- 切片助手(本页)
- 系统设置(管理员)
- 用户管理(管理员)
- API 密钥(管理员)
---
## 主页
切片仪表盘概览与快速操作入口。
![主页 图片](../../assets/img/slice_helper/home_zh-cn.png)
使用导航栏进入“主页”,通过 `上传并切片 STL` 开始新切片。
---
## 我的文件
管理已上传的 STL 与 GCode可上传、下载或删除文件。
![我的文件 图片](../../assets/img/slice_helper/my-files_zh-cn.png)
常用操作:`上传STL``下载 GCode``删除`
---
## 构建板 (Plater)
在构建板上放置与调整模型(平移/旋转/缩放),确认位置后使用 `合并并切片`
![构建板 图片](../../assets/img/slice_helper/plater_zh-cn.png)
提示:切片前确保模型均在可打印范围内。
---
## 账号管理
登录用户可在此更新资料、修改密码并管理活跃会话。
![账号管理 图片](../../assets/img/slice_helper/account-management_zh-cn.png)
---
## 切片助手(本页)
本页说明推荐的切片流程与设置:选择 `质量配置`、设置 `支撑``填充密度`,然后 `上传 & 切片`
![切片助手 图片](../../assets/img/slice_helper/slice-helper_zh-cn.png)
状态提示:`等待中``切片中``已切片``失败`
---
## 系统设置(管理员)
管理员可在此配置全局切片引擎与默认配置文件。
![系统设置 图片](../../assets/img/slice_helper/system-settings_zh-cn.png)
---
## 用户管理(管理员)
管理员可添加/编辑用户并设置配额与角色(`普通用户``管理员`)。
![用户管理 图片](../../assets/img/slice_helper/user-management_zh-cn.png)
---
## API 密钥(管理员)
管理外部工具使用的 API 密钥;点击 `创建新的 API 密钥`,输入名称并 `生成密钥`
![API 密钥 图片](../../assets/img/slice_helper/api-keys_zh-cn.png)

281
app/assets/i18n/de.json Normal file
View File

@@ -0,0 +1,281 @@
{
"Language": "Sprache",
"English": "English",
"Chinese": "中文",
"Guest": "Gast",
"Login": "Anmelden",
"Logout": "Abmelden",
"Home": "Startseite",
"New Slice": "Neuer Slice",
"My Files": "Meine Dateien",
"Admin Options": "Admin-Optionen",
"System Settings": "Systemeinstellungen",
"User Management": "Benutzerverwaltung",
"Dashboard": "Dashboard",
"Total Prints": "Gesamte Drucke",
"You have sliced": "Sie haben",
"files": "Dateien gesliced.",
"Upload & Slice STL": "STL Hochladen & Slicen",
"Select STL File": "STL-Datei auswählen",
"Quality Profile": "Qualitätsprofil",
"Upload & Slice": "Hochladen & Slicen",
"3D Preview Area": "3D-Vorschaubereich",
"Upload a file to display": "Eine Datei zur Anzeige hochladen",
"Date Uploaded": "Hochladedatum",
"Original Name": "Originalname",
"Status": "Status",
"Actions": "Aktionen",
"Waiting": "Wartend",
"Merging": "Zusammenführen",
"Waiting in queue for slicing": "Wartet in der Warteschlange aufs Slicen",
"Slicing": "Slicen",
"Sliced": "Gesliced",
"Uploaded": "Hochgeladen",
"Failed": "Fehlgeschlagen",
"This model has already been sliced. The existing GCode will be overwritten. Continue?": "Dieses Modell wurde bereits gesliced. Der existierende GCode wird überschrieben. Fortfahren?",
"Upload STL": "STL hochladen",
"Download GCode": "GCode Herunterladen",
"GCode Preview": "GCode Vorschau",
"Delete": "Löschen",
"No files uploaded yet.": "Noch keine Dateien hochgeladen.",
"Drag & Drop STL files here or Click to Select": "STL-Dateien hierher ziehen & ablegen oder zum Auswählen klicken",
"Uploading...": "Lädt hoch...",
"Simplifying": "Vereinfache",
"Simplifying...": "Vereinfache...",
"Proxy Skip Size (MB)": "Proxy-Überspringgröße (MB)",
"Files smaller than this will not generate a simplified proxy.": "Dateien, die kleiner sind, werden keine vereinfachte Proxy generieren.",
"Upload Complete!": "Hochladen abgeschlossen!",
"Upload error.": "Fehler beim Hochladen.",
"Upload failed.": "Hochladen fehlgeschlagen.",
"Please upload a valid .stl file!": "Bitte laden Sie eine gültige .stl Datei hoch!",
"Slicing queued!": "Slicen in die Warteschlange eingereiht!",
"Draft Quality": "Entwurfsqualität",
"Standard Quality": "Standardqualität",
"High Quality": "Hohe Qualität",
"Dynamic Quality": "Dynamische Qualität",
"Low Quality": "Niedrige Qualität",
"Super Quality": "Super Qualität",
"Ultra Quality": "Ultra Qualität",
"Plater": "Druckplatte",
"Layer Progress:": "Schichtfortschritt:",
"Loading and Parsing GCode Data...": "Lade und verarbeite GCode-Daten...",
"Failed to load GCode preview.": "Fehler beim Laden der GCode-Vorschau.",
"Outer Wall": "Außenwand",
"Inner Wall": "Innenwand",
"Infill": "Füllung",
"Skin/TopBottom": "Hülle/ObenUnten",
"Travel (Move)": "Bewegung (Reise)",
"Skirt": "Skirt",
"Support Interface": "Stützstruktur-Grenzschicht",
"Back": "Zurück",
"Layer": "Schicht",
"Plater / Build Plate": "Druckbettleiste",
"Translate (W)": "Verschieben (W)",
"Rotate (E)": "Drehen (E)",
"Scale (R)": "Skalieren (R)",
"Scale": "Skalieren",
"Uniform Scale": "Gleichmäßige Skalierung",
"Lay Flat": "Flach legen",
"Remove Selected (Del)": "Ausgewähltes entfernen (Del)",
"Available Models": "Verfügbare Modelle",
"No STL models uploaded yet. Go upload some first.": "Noch keine STL-Modelle hochgeladen. Laden Sie zuerst einige hoch.",
"Other Settings": "Andere Einstellungen",
"Infill Density": "Fülldichte",
"Support": "Stützstruktur",
"None": "Keine",
"Touching Buildplate": "Nur Druckbett berührend",
"Everywhere": "Überall",
"Support Type": "Stützstruktur-Typ",
"Tree": "Baum",
"Lines": "Linien",
"Grid": "Gitter",
"Triangles": "Dreiecke",
"Concentric": "Konzentrisch",
"Zig Zag": "Zickzack",
"Cross": "Kreuz",
"Gyroid": "Gyroid",
"Honeycomb": "Wabe",
"Octagon": "Achteck",
"Clear Board": "Druckbett leeren",
"Merge & Slice": "Zusammenführen & Slicen",
"Error loading STL model file.": "Fehler beim Laden der STL-Datei.",
"Please add at least one model to the build plate.": "Bitte fügen Sie mindestens ein Modell zur Druckplatte hinzu.",
"One or more models are outside the print area. Please adjust them before slicing.": "Mindestens ein Modell liegt außerhalb des Druckbereichs. Bitte anpassen.",
"Error:": "Fehler:",
"ID": "ID",
"Username": "Benutzername",
"Role": "Rolle",
"Created At": "Erstellt am",
"Admin": "Admin",
"User": "Benutzer",
"WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?": "WARNUNG: Sind Sie sicher, dass Sie diesen Benutzer UND ALLE seine Dateien löschen wollen?",
"SliceEngine Configurations": "Slicing-Engine-Konfigurationen",
"Plater Origin Offset X (mm)": "Druckbett Ursprung Offset X (mm)",
"Adjust the X-axis compilation offset for combined files on the build plate.": "X-Achsen-Offset für kombinierte Dateien anpassen.",
"Plater Origin Offset Y (mm)": "Druckbett Ursprung Offset Y (mm)",
"Adjust the Y-axis compilation offset for combined files on the build plate.": "Y-Achsen-Offset für kombinierte Dateien anpassen.",
"Default Plater Settings": "Standard-Druckbetteinstellungen",
"Default Infill Density (%)": "Standard-Fülldichte (%)",
"Default Support": "Standard-Stützstruktur",
"Default Support Type": "Standard-Stützstruktur-Typ",
"Default Quality Profile": "Standard-Qualitätsprofil",
"Save Settings": "Einstellungen speichern",
"You are editing a composite model. The existing composite will be updated and re-sliced. Continue?": "Sie bearbeiten ein kombiniiges Modell. Fortfahren?",
"Select": "Auswählen",
"OctoPrint settings updated": "OctoPrint-Einstellungen aktualisiert",
"Settings updated successfully": "Einstellungen erfolgreich aktualisiert",
"Print starting! Going to dashboard...": "Druck startet! Gehe zum Dashboard...",
"Are you sure you want to delete this file?": "Sind Sie sicher, dass Sie diese Datei löschen wollen?",
"OctoPrint Base URL": "OctoPrint Basis-URL",
"Temperatures": "Temperaturen",
"Notice": "Hinweis",
"Please upload valid .stl files!": "Bitte gültige .stl hochladen!",
"Print Time:": "Druckzeit:",
"System Configuration": "Systemkonfiguration",
"Command": "Befehl",
"Time Left:": "Verbleibende Zeit:",
"Yes": "Ja",
"Current State": "Aktueller Zustand",
"Error updating settings": "Fehler beim Aktualisieren der Einstellungen",
"Available Files on Printer": "Verfügbare Dateien auf dem Drucker",
"Control failed: ": "Steuerung fehlgeschlagen: ",
"Loading webcam stream...": "Lade Webcam-Stream...",
"Live Webcam": "Live-Webcam",
"No printable files found. Go slice some G-Code first!": "Keine druckbaren Dateien gefunden. Erst G-Code slicen!",
"Save Connection Settings": "Verbindungseinstellungen speichern",
"Validation Failed": "Validierung fehlgeschlagen",
"Cancel Print": "Drucken abbrechen",
"Send this file to print immediately?": "Diese Datei sofort zum Druck senden?",
"Print Now": "Jetzt drucken",
"OctoPrint Panel (Embedded)": "OctoPrint-Panel (Eingebettet)",
"Are you sure you want to perform this action?": "Sind Sie sicher, dass Sie die Aktion ausführen wollen?",
"Control": "Steuerung",
"Cancel": "Abbrechen",
"sent.": "gesendet.",
"The local IP address or hostname of your OctoPrint server.": "Lokale IP-Adresse oder Hostname Ihres OctoPrint-Servers.",
"OctoPrint Panel": "OctoPrint-Panel",
"General Operations": "Allgemeine Operationen",
"Confirm": "Bestätigen",
"Basic Control": "Basiskontrolle",
"Size:": "Größe:",
"Prepare Print": "Druck vorbereiten",
"API Key / Application Key": "API-Schlüssel / Anwendungsschlüssel",
"Configuration Required:": "Konfiguration erforderlich:",
"Paste API Key here": "API-Schlüssel hier einfügen",
"Tool/Nozzle": "Werkzeug/Düse",
"Pause/Resume": "Pause/Fortsetzen",
"Can be found in OctoPrint Settings -> Application Keys or API.": "Befindet sich in den OctoPrint-Einstellungen -> Anwendungsschlüssel oder API.",
"Preview": "Vorschau",
"Pause": "Pause",
"Bed": "Druckbett",
"Printer Status": "Druckerstatus",
"Printer": "Drucker",
"Admin / OctoPrint": "Admin / OctoPrint",
"Absolute path to save locally sliced GCode files (e.g. OctoPrint uploads folder like \"/home/pi/.octoprint/uploads\"). Leave empty to use system default.": "Absoluter Pfad zum Speichern von GCode. Leer lassen für Systemstandard.",
"Custom GCode Output Folder": "Benutzerdefinierter GCode-Ausgabeordner",
"Go to Configuration": "Zur Konfiguration springen",
"OK": "OK",
"Connection Settings": "Verbindungseinstellungen",
"OctoPrint Configuration": "OctoPrint-Konfiguration",
"The OctoPrint URL is not set. Please go to the ": "Die OctoPrint-URL ist nicht gesetzt. Gehen Sie zu ",
"Go to Print": "Zum Druck",
"Time:": "Zeit:",
"Are you sure you want to cancel the print?": "Wollen Sie den Druck wirklich abbrechen?",
"Uploading and linking GCode...": "GCode hochladen und verknüpfen...",
"Active Print Job": "Aktiver Druckauftrag",
"Upload External GCode": "Externen GCode hochladen",
"Slicer": "Slicer",
"System Config": "Systemkonfiguration",
"Home All Axes": "Alle Achsen auf Nullposition",
"Pause/Resume Print": "Druck Pausieren/Fortsetzen",
"Slice": "Slicen",
"Network error": "Netzwerkfehler",
"Printer Control": "Drucker-Steuerung",
"Error saving settings": "Fehler beim Speichern der Einstellungen",
"page to set it up.": "Seite zum Einrichten.",
"Network Error: ": "Netzwerkfehler: ",
"3D Model Files (STL)": "3D-Modelldateien (STL)",
"You have uploaded": "Sie haben hochgeladen",
"Total Space Used": "Verwendeter Speicherplatz",
"Sliced Files (GCode)": "Geslicte Dateien (GCode)",
"You have sliced or uploaded": "Sie haben geslict oder hochgeladen",
"Default Storage Quotas (MB)": "Standard Speicherquoten (MB)",
"Guest STL Quota": "Gast STL Quoten",
"Unlimited": "Unbegrenzt",
"Guest GCode Quota": "Gast GCode Quoten",
"New User STL Quota": "Neuer Benutzer STL Quoten",
"New User GCode Quota": "Neuer Benutzer GCode Quoten",
"Quota": "Quote",
"GCode Storage Quota Exceeded. Please delete some files first.": "GCode Speicherplatz überschritten. Bitte löschen Sie zuerst einige Dateien.",
"Edit Quota": "Quota bearbeiten",
"Edit Quota for": "Quota bearbeiten für",
"Reset Password": "Passwort zurücksetzen",
"Reset Password for": "Passwort zurücksetzen für",
"Save": "Speichern",
"STL Quota": "STL Quote",
"GCode Quota": "GCode Quote",
"New Password": "Neues Passwort",
"Add User": "Benutzer hinzufügen",
"Password": "Passwort",
"Is Admin": "Ist Administrator",
"Create User": "Benutzer erstellen",
"Build Plate Model Path (.stl)": "Pfad zum Build-Platte-Modell (.stl)",
"Absolute path to a custom build plate STL model to show in the plater. Leave empty to use none.": "Absoluter Pfad zum benutzerdefinierten Build-Platte-STL-Modell, das im Plater angezeigt werden soll. Leer lassen, um keines zu verwenden.",
"Default Material Profile": "Standard Materialprofil",
"Slicing Engine Configurations": "Slicer-Engine-Konfigurationen",
"Slicing Engine": "Slicer-Engine",
"Select the engine to be used globally. Ensure the selected engine is installed and accessible on the server.": "Wählen Sie die Engine aus, die global verwendet werden soll. Stellen Sie sicher, dass die ausgewählte Engine installiert und auf dem Server zugänglich ist.",
"Material Profile": "Materialprofil",
"Custom": "Benutzerdefiniert",
"Skirt/Brim": "Sürz / Rand",
"Support material": "Stützmaterial",
"Perimeter": "Umriss",
"External perimeter": "Externer Umriss",
"Solid infill": "Solide Füllung",
"Overhang perimeter": "Überhang Umriss",
"Internal infill": "Interne Füllung",
"Bridge infill": "Brückefüllung",
"Top solid infill": "Oberste solide Füllung",
"Others": "Andere",
"Are you sure you want to clear the board?": "Sind Sie sicher, dass Sie das Brett leeren möchten?",
"d": "t",
"h": "std",
"m": "m",
"s": "s",
"Auto Leveling": "Auto-Nivellierung",
"Account Management": "Kontoverwaltung",
"Change Password": "Passwort ändern",
"Current Password": "Aktuelles Passwort",
"Confirm New Password": "Neues Passwort bestätigen",
"Update Password": "Passwort aktualisieren",
"Active Sessions": "Aktive Sitzungen",
"Device": "Gerät",
"IP Address": "IP-Adresse",
"Last Active": "Zuletzt aktiv",
"Action": "Aktion",
"This Device": "Dieses Gerät",
"Unknown Device": "Unbekanntes Gerät",
"Are you sure you want to terminate this session?": "Sind Sie sicher, dass Sie diese Sitzung beenden möchten?",
"Logout from this device?": "Von diesem Gerät abmelden?",
"No active sessions found.": "Keine aktiven Sitzungen gefunden.",
"Please login to view the webcam stream.": "Bitte melden Sie sich an, um die Live-Kamera zu sehen.",
"Remember Me": "Erinnere dich an mich",
"Merge Guest Data": "Gästendaten zusammenführen",
"Main configuration for the printer dimensions, limits and base profiles.": "Hauptkonfiguration für die Druckerabmessungen, -grenzen und Basisprofile.",
"API Keys Management": "API-Schlüsselverwaltung",
"Create New API Key": "Neuen API-Schlüssel erstellen",
"Key Name": "Schlüsselname",
"Generate Key": "Schlüssel generieren",
"Are you sure you want to delete this API Key?": "Sind Sie sicher, dass Sie diesen API-Schlüssel löschen möchten?",
"API Key Name": "API-Schlüsselname",
"No API keys found.": "Keine API-Schlüssel gefunden.",
"API Keys": "API-Schlüssel",
"Slice Helper": "Slice-Helfer",
"Printer Helper": "Drucker-Helfer",
"For security reasons, please change your default admin password.": "Aus Sicherheitsgründen ändern Sie bitte Ihr Standard-Administratorpasswort.",
"Your new password cannot be the default \"admin123\".": "Ihr neues Passwort darf nicht das Standardpasswort \"admin123\" sein.",
"Current password is incorrect.": "Das aktuelle Passwort ist falsch.",
"New passwords do not match.": "Die neuen Passwörter stimmen nicht überein.",
"New password must be at least 6 characters.": "Das neue Passwort muss mindestens 6 Zeichen lang sein.",
"Password updated successfully.": "Passwort erfolgreich aktualisiert."
}

View File

@@ -103,7 +103,7 @@
"Admin": "Admin", "Admin": "Admin",
"User": "User", "User": "User",
"WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?": "WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?", "WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?": "WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?",
"CuraEngine Configurations": "CuraEngine Configurations", "SliceEngine Configurations": "SliceEngine Configurations",
"Plater Origin Offset X (mm)": "Plater Origin Offset X (mm)", "Plater Origin Offset X (mm)": "Plater Origin Offset X (mm)",
"Adjust the X-axis compilation offset for combined files on the build plate.": "Adjust the X-axis compilation offset for combined files on the build plate.", "Adjust the X-axis compilation offset for combined files on the build plate.": "Adjust the X-axis compilation offset for combined files on the build plate.",
"Plater Origin Offset Y (mm)": "Plater Origin Offset Y (mm)", "Plater Origin Offset Y (mm)": "Plater Origin Offset Y (mm)",
@@ -113,5 +113,169 @@
"Default Support": "Default Support", "Default Support": "Default Support",
"Default Support Type": "Default Support Type", "Default Support Type": "Default Support Type",
"Default Quality Profile": "Default Quality Profile", "Default Quality Profile": "Default Quality Profile",
"Save Settings": "Save Settings" "Save Settings": "Save Settings",
"You are editing a composite model. The existing composite will be updated and re-sliced. Continue?": "You are editing a composite model. The existing composite will be updated and re-sliced. Continue?",
"Select": "Select",
"OctoPrint settings updated": "OctoPrint settings updated",
"Settings updated successfully": "Settings updated successfully",
"This model has already been sliced. The existing GCode will be overwritten. Continue?": "This model has already been sliced. The existing GCode will be overwritten. Continue?",
"Print starting! Going to dashboard...": "Print starting! Going to dashboard...",
"Are you sure you want to delete this file?": "Are you sure you want to delete this file?",
"OctoPrint Base URL": "OctoPrint Base URL",
"Temperatures": "Temperatures",
"Notice": "Notice",
"Please upload valid .stl files!": "Please upload valid .stl files!",
"Print Time:": "Print Time:",
"System Configuration": "System Configuration",
"Command": "Command",
"Time Left:": "Time Left:",
"Yes": "Yes",
"Current State": "Current State",
"Error updating settings": "Error updating settings",
"Available Files on Printer": "Available Files on Printer",
"Control failed: ": "Control failed: ",
"Loading webcam stream...": "Loading webcam stream...",
"Live Webcam": "Live Webcam",
"No printable files found. Go slice some G-Code first!": "No printable files found. Go slice some G-Code first!",
"Save Connection Settings": "Save Connection Settings",
"Upload error.": "Upload error.",
"Validation Failed": "Validation Failed",
"Cancel Print": "Cancel Print",
"Send this file to print immediately?": "Send this file to print immediately?",
"Print Now": "Print Now",
"OctoPrint Panel (Embedded)": "OctoPrint Panel (Embedded)",
"Are you sure you want to perform this action?": "Are you sure you want to perform this action?",
"Control": "Control",
"Cancel": "Cancel",
"sent.": "sent.",
"The local IP address or hostname of your OctoPrint server.": "The local IP address or hostname of your OctoPrint server.",
"OctoPrint Panel": "OctoPrint Panel",
"General Operations": "General Operations",
"Confirm": "Confirm",
"Basic Control": "Basic Control",
"Size:": "Size:",
"Prepare Print": "Prepare Print",
"API Key / Application Key": "API Key / Application Key",
"Configuration Required:": "Configuration Required:",
"Paste API Key here": "Paste API Key here",
"Tool/Nozzle": "Tool/Nozzle",
"Pause/Resume": "Pause/Resume",
"Can be found in OctoPrint Settings -> Application Keys or API.": "Can be found in OctoPrint Settings -> Application Keys or API.",
"Preview": "Preview",
"Pause": "Pause",
"Bed": "Bed",
"Printer Status": "Printer Status",
"Printer": "Printer",
"Admin / OctoPrint": "Admin / OctoPrint",
"Absolute path to save locally sliced GCode files (e.g. OctoPrint uploads folder like \"/home/pi/.octoprint/uploads\"). Leave empty to use system default.": "Absolute path to save locally sliced GCode files (e.g. OctoPrint uploads folder like \"/home/pi/.octoprint/uploads\"). Leave empty to use system default.",
"Custom GCode Output Folder": "Custom GCode Output Folder",
"Go to Configuration": "Go to Configuration",
"OK": "OK",
"Connection Settings": "Connection Settings",
"OctoPrint Configuration": "OctoPrint Configuration",
"The OctoPrint URL is not set. Please go to the ": "The OctoPrint URL is not set. Please go to the ",
"Go to Print": "Go to Print",
"Time:": "Time:",
"Are you sure you want to cancel the print?": "Are you sure you want to cancel the print?",
"Uploading and linking GCode...": "Uploading and linking GCode...",
"Active Print Job": "Active Print Job",
"Upload External GCode": "Upload External GCode",
"Slicer": "Slicer",
"System Config": "System Config",
"Home All Axes": "Home All Axes",
"Pause/Resume Print": "Pause/Resume Print",
"Slice": "Slice",
"Upload failed.": "Upload failed.",
"Network error": "Network error",
"Upload Complete!": "Upload Complete!",
"Printer Control": "Printer Control",
"Error saving settings": "Error saving settings",
"page to set it up.": "page to set it up.",
"Network Error: ": "Network Error: ",
"Upload STL": "Upload STL",
"Please upload a valid .stl file!": "Please upload a valid .stl file!",
"3D Model Files (STL)": "3D Model Files (STL)",
"You have uploaded": "You have uploaded",
"Total Space Used": "Total Space Used",
"Sliced Files (GCode)": "Sliced Files (GCode)",
"You have sliced or uploaded": "You have sliced or uploaded",
"Default Storage Quotas (MB)": "Default Storage Quotas (MB)",
"Guest STL Quota": "Guest STL Quota",
"Unlimited": "Unlimited",
"Guest GCode Quota": "Guest GCode Quota",
"New User STL Quota": "New User STL Quota",
"New User GCode Quota": "New User GCode Quota",
"Quota": "Quota",
"GCode Storage Quota Exceeded. Please delete some files first.": "GCode Storage Quota Exceeded. Please delete some files first.",
"Edit Quota": "Edit Quota",
"Edit Quota for": "Edit Quota for",
"Reset Password": "Reset Password",
"Reset Password for": "Reset Password for",
"Save": "Save",
"STL Quota": "STL Quota",
"GCode Quota": "GCode Quota",
"New Password": "New Password",
"Add User": "Add User",
"Password": "Password",
"Is Admin": "Is Admin",
"Create User": "Create User",
"Build Plate Model Path (.stl)": "Build Plate Model Path (.stl)",
"Absolute path to a custom build plate STL model to show in the plater. Leave empty to use none.": "Absolute path to a custom build plate STL model to show in the plater. Leave empty to use none.",
"Default Material Profile": "Default Material Profile",
"Slicing Engine Configurations": "Slicing Engine Configurations",
"Slicing Engine": "Slicing Engine",
"Select the engine to be used globally. Ensure the selected engine is installed and accessible on the server.": "Select the engine to be used globally. Ensure the selected engine is installed and accessible on the server.",
"Material Profile": "Material Profile",
"Custom": "Custom",
"Skirt/Brim": "Skirt/Brim",
"Support material": "Support material",
"Perimeter": "Perimeter",
"External perimeter": "External perimeter",
"Solid infill": "Solid infill",
"Overhang perimeter": "Overhang perimeter",
"Internal infill": "Internal infill",
"Bridge infill": "Bridge infill",
"Top solid infill": "Top solid infill",
"Others": "Others",
"Are you sure you want to clear the board?": "Are you sure you want to clear the board?",
"d": "d",
"h": "h",
"m": "m",
"s": "s",
"Auto Leveling": "Auto Leveling",
"Account Management": "Account Management",
"Change Password": "Change Password",
"Current Password": "Current Password",
"Confirm New Password": "Confirm New Password",
"Update Password": "Update Password",
"Active Sessions": "Active Sessions",
"Device": "Device",
"IP Address": "IP Address",
"Last Active": "Last Active",
"Action": "Action",
"This Device": "This Device",
"Unknown Device": "Unknown Device",
"Are you sure you want to terminate this session?": "Are you sure you want to terminate this session?",
"Logout from this device?": "Logout from this device?",
"No active sessions found.": "No active sessions found.",
"Please login to view the webcam stream.": "Please login to view the webcam stream.",
"Remember Me": "Remember Me",
"Merge Guest Data": "Merge Guest Data",
"Main configuration for the printer dimensions, limits and base profiles.": "Main configuration for the printer dimensions, limits and base profiles.",
"API Keys Management": "API Keys Management",
"Create New API Key": "Create New API Key",
"Key Name": "Key Name",
"Generate Key": "Generate Key",
"Are you sure you want to delete this API Key?": "Are you sure you want to delete this API Key?",
"API Key Name": "API Key Name",
"No API keys found.": "No API keys found.",
"API Keys": "API Keys",
"Slice Helper": "Slice Helper",
"Printer Helper": "Printer Helper",
"For security reasons, please change your default admin password.": "For security reasons, please change your default admin password.",
"Your new password cannot be the default \"admin123\".": "Your new password cannot be the default \"admin123\".",
"Current password is incorrect.": "Current password is incorrect.",
"New passwords do not match.": "New passwords do not match.",
"New password must be at least 6 characters.": "New password must be at least 6 characters.",
"Password updated successfully.": "Password updated successfully."
} }

View File

@@ -109,7 +109,7 @@
"Admin": "管理员", "Admin": "管理员",
"User": "普通用户", "User": "普通用户",
"WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?": "警告确定要永久删除该用户以及TA上传的所有文件和切片吗", "WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?": "警告确定要永久删除该用户以及TA上传的所有文件和切片吗",
"CuraEngine Configurations": "CuraEngine 配置", "SliceEngine Configurations": "切片引擎配置",
"Plater Origin Offset X (mm)": "构建板原点偏移 X (mm)", "Plater Origin Offset X (mm)": "构建板原点偏移 X (mm)",
"Adjust the X-axis compilation offset for combined files on the build plate.": "调整多文件在构建板合并切片时的X坐标偏移。", "Adjust the X-axis compilation offset for combined files on the build plate.": "调整多文件在构建板合并切片时的X坐标偏移。",
"Plater Origin Offset Y (mm)": "构建板原点偏移 Y (mm)", "Plater Origin Offset Y (mm)": "构建板原点偏移 Y (mm)",
@@ -119,5 +119,163 @@
"Default Support": "默认支撑类型", "Default Support": "默认支撑类型",
"Default Support Type": "默认支撑图案", "Default Support Type": "默认支撑图案",
"Default Quality Profile": "默认质量配置", "Default Quality Profile": "默认质量配置",
"Save Settings": "保存设置" "Save Settings": "保存设置",
"You are editing a composite model. The existing composite will be updated and re-sliced. Continue?": "您正在编辑合并模型。现有的组合将被更新并重新切片。继续吗?",
"Select": "选择",
"OctoPrint settings updated": "OctoPrint 设置已更新",
"Settings updated successfully": "设置更新成功",
"Print starting! Going to dashboard...": "打印开始!前往仪表板...",
"Are you sure you want to delete this file?": "您确定要删除此文件吗?",
"OctoPrint Base URL": "OctoPrint 基础 URL",
"Temperatures": "温度",
"Notice": "注意",
"Please upload valid .stl files!": "请上传有效的 .stl 文件!",
"Print Time:": "打印时间:",
"System Configuration": "系统配置",
"Command": "命令",
"Time Left:": "剩余时间:",
"Yes": "是",
"Current State": "当前状态",
"Error updating settings": "更新设置时出错",
"Available Files on Printer": "打印机上的可用文件",
"Control failed: ": "控制失败: ",
"Loading webcam stream...": "正在加载摄像头流...",
"Live Webcam": "实时摄像头",
"No printable files found. Go slice some G-Code first!": "未找到可打印文件。请先切片一些 G-Code",
"Save Connection Settings": "保存连接设置",
"Validation Failed": "验证失败",
"Cancel Print": "取消打印",
"Send this file to print immediately?": "立即发送此文件进行打印?",
"Print Now": "立即打印",
"OctoPrint Panel (Embedded)": "OctoPrint 面板 (嵌入)",
"Are you sure you want to perform this action?": "您确定要执行此操作吗?",
"Control": "控制",
"Cancel": "取消",
"sent.": "已发送。",
"The local IP address or hostname of your OctoPrint server.": "您的 OctoPrint 服务器的本地 IP 地址或主机名。",
"OctoPrint Panel": "OctoPrint 面板",
"General Operations": "通用操作",
"Confirm": "确认",
"Basic Control": "基本控制",
"Size:": "大小:",
"Prepare Print": "准备打印",
"API Key / Application Key": "API 密钥 / 应用程序密钥",
"Configuration Required:": "需要配置:",
"Paste API Key here": "在此处粘贴 API 密钥",
"Tool/Nozzle": "工具/喷嘴",
"Pause/Resume": "暂停/恢复",
"Can be found in OctoPrint Settings -> Application Keys or API.": "可以在 OctoPrint 设置 -> 应用程序密钥或 API 中找到。",
"Preview": "预览",
"Pause": "暂停",
"Bed": "热床",
"Printer Status": "打印机状态",
"Printer": "打印机",
"Admin / OctoPrint": "管理员 / OctoPrint",
"Absolute path to save locally sliced GCode files (e.g. OctoPrint uploads folder like \"/home/pi/.octoprint/uploads\"). Leave empty to use system default.": "保存本地切片 GCode 文件的绝对路径(例如 OctoPrint 的 uploads 文件夹:\"/home/pi/.octoprint/uploads\")。留空以使用系统默认值。",
"Custom GCode Output Folder": "自定义 GCode 输出文件夹",
"Go to Configuration": "前往配置",
"OK": "确定",
"Connection Settings": "连接设置",
"OctoPrint Configuration": "OctoPrint 配置",
"The OctoPrint URL is not set. Please go to the ": "未设置 OctoPrint URL。请前往",
"Go to Print": "前往打印",
"Time:": "时间:",
"Are you sure you want to cancel the print?": "您确定要取消打印吗?",
"Uploading and linking GCode...": "正在上传并链接 GCode...",
"Active Print Job": "当前打印任务",
"Upload External GCode": "上传外部 GCode",
"Slicer": "切片软件",
"System Config": "系统配置",
"Home All Axes": "全部轴归零",
"Pause/Resume Print": "暂停/恢复打印",
"Slice": "切片",
"Network error": "网络错误",
"Printer Control": "打印机控制",
"Error saving settings": "保存设置时出错",
"page to set it up.": "页面进行设置。",
"Network Error: ": "网络错误: ",
"3D Model Files (STL)": "3D 模型文件 (STL)",
"You have uploaded": "您已上传",
"Total Space Used": "占用空间",
"Sliced Files (GCode)": "已切片文件 (GCode)",
"You have sliced or uploaded": "您已切片或上传",
"Default Storage Quotas (MB)": "默认存储配额 (MB)",
"Guest STL Quota": "访客 STL 配额",
"Unlimited": "无限制",
"Guest GCode Quota": "访客 GCode 配额",
"New User STL Quota": "新用户 STL 配额",
"New User GCode Quota": "新用户 GCode 配额",
"Quota": "限额",
"GCode Storage Quota Exceeded. Please delete some files first.": "GCode 存储配额已超限,请先删除部分旧文件。",
"Edit Quota": "编辑配额",
"Edit Quota for": "编辑配额用于",
"Reset Password": "重置密码",
"Reset Password for": "重置密码 用户:",
"Save": "保存",
"STL Quota": "STL 配额",
"GCode Quota": "GCode 配额",
"New Password": "新密码",
"Add User": "添加用户",
"Password": "密码",
"Is Admin": "设为管理员",
"Create User": "创建用户",
"Build Plate Model Path (.stl)": "构建板模型路径 (.stl)",
"Absolute path to a custom build plate STL model to show in the plater. Leave empty to use none.": "保存自定义构建板 STL 模型的绝对路径,以在 plater 中显示。留空以使用默认值。",
"Default Material Profile": "默认材料配置",
"Slicing Engine Configurations": "切片引擎配置",
"Slicing Engine": "切片引擎",
"Select the engine to be used globally. Ensure the selected engine is installed and accessible on the server.": "选择要全局使用的引擎。确保所选引擎已安装且在服务器上可访问。",
"Material Profile": "材料配置",
"Custom": "自定义",
"Skirt/Brim": "裙边",
"Support material": "支撑材料",
"Perimeter": "轮廓",
"External perimeter": "外部轮廓",
"Solid infill": "实体填充",
"Overhang perimeter": "悬垂轮廓",
"Internal infill": "内部填充",
"Bridge infill": "桥接填充",
"Top solid infill": "顶部实体填充",
"Others": "其他",
"Are you sure you want to clear the board?": "您确定要清空构建板吗?",
"d": "天",
"h": "时",
"m": "分",
"s": "秒",
"Auto Leveling": "自动调平",
"Account Management": "账号管理",
"Change Password": "修改密码",
"Current Password": "当前密码",
"Confirm New Password": "确认新密码",
"Update Password": "更新密码",
"Active Sessions": "活跃会话",
"Device": "设备",
"IP Address": "IP 地址",
"Last Active": "最后活跃",
"Action": "操作",
"This Device": "当前设备",
"Unknown Device": "未知设备",
"Are you sure you want to terminate this session?": "您确定要终止此会话吗?",
"Logout from this device?": "从此设备注销?",
"No active sessions found.": "未找到活跃的会话。",
"Please login to view the webcam stream.": "请登录以查看实时摄像头。",
"Remember Me": "记住我",
"Merge Guest Data": "合并访客数据",
"Main configuration for the printer dimensions, limits and base profiles.": "打印机尺寸、限制和基础配置的主要配置。",
"API Keys Management": "API 密钥管理",
"Create New API Key": "创建新的 API 密钥",
"Key Name": "密钥名称",
"Generate Key": "生成密钥",
"Are you sure you want to delete this API Key?": "您确定要删除此 API 密钥吗?",
"API Key Name": "API 密钥名称",
"No API keys found.": "未找到 API 密钥。",
"API Keys": "API 密钥",
"Slice Helper": "切片助手",
"Printer Helper": "打印助手",
"For security reasons, please change your default admin password.": "出于安全原因,请修改您的默认管理员密码。",
"Your new password cannot be the default \"admin123\".": "新密码不能设置为系统默认的 \"admin123\"。",
"Current password is incorrect.": "当前密码不正确。",
"New passwords do not match.": "新密码不匹配。",
"New password must be at least 6 characters.": "新密码必须至少6个字符。",
"Password updated successfully.": "密码更新成功。"
} }

BIN
app/assets/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
app/assets/img/favicon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
app/assets/img/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,371 +0,0 @@
( function () {
/**
* Description: A THREE loader for STL ASCII files, as created by Solidworks and other CAD programs.
*
* Supports both binary and ASCII encoded files, with automatic detection of type.
*
* The loader returns a non-indexed buffer geometry.
*
* Limitations:
* Binary decoding supports "Magics" color format (http://en.wikipedia.org/wiki/STL_(file_format)#Color_in_binary_STL).
* There is perhaps some question as to how valid it is to always assume little-endian-ness.
* ASCII decoding assumes file is UTF-8.
*
* Usage:
* const loader = new STLLoader();
* loader.load( './models/stl/slotted_disk.stl', function ( geometry ) {
* scene.add( new THREE.Mesh( geometry ) );
* });
*
* For binary STLs geometry might contain colors for vertices. To use it:
* // use the same code to load STL as above
* if (geometry.hasColors) {
* material = new THREE.MeshPhongMaterial({ opacity: geometry.alpha, vertexColors: true });
* } else { .... }
* const mesh = new THREE.Mesh( geometry, material );
*
* For ASCII STLs containing multiple solids, each solid is assigned to a different group.
* Groups can be used to assign a different color by defining an array of materials with the same length of
* geometry.groups and passing it to the Mesh constructor:
*
* const mesh = new THREE.Mesh( geometry, material );
*
* For example:
*
* const materials = [];
* const nGeometryGroups = geometry.groups.length;
*
* const colorMap = ...; // Some logic to index colors.
*
* for (let i = 0; i < nGeometryGroups; i++) {
*
* const material = new THREE.MeshPhongMaterial({
* color: colorMap[i],
* wireframe: false
* });
*
* }
*
* materials.push(material);
* const mesh = new THREE.Mesh(geometry, materials);
*/
class STLLoader extends THREE.Loader {
constructor( manager ) {
super( manager );
}
load( url, onLoad, onProgress, onError ) {
const scope = this;
const loader = new THREE.FileLoader( this.manager );
loader.setPath( this.path );
loader.setResponseType( 'arraybuffer' );
loader.setRequestHeader( this.requestHeader );
loader.setWithCredentials( this.withCredentials );
loader.load( url, function ( text ) {
try {
onLoad( scope.parse( text ) );
} catch ( e ) {
if ( onError ) {
onError( e );
} else {
console.error( e );
}
scope.manager.itemError( url );
}
}, onProgress, onError );
}
parse( data ) {
function isBinary( data ) {
const reader = new DataView( data );
const face_size = 32 / 8 * 3 + 32 / 8 * 3 * 3 + 16 / 8;
const n_faces = reader.getUint32( 80, true );
const expect = 80 + 32 / 8 + n_faces * face_size;
if ( expect === reader.byteLength ) {
return true;
} // An ASCII STL data must begin with 'solid ' as the first six bytes.
// However, ASCII STLs lacking the SPACE after the 'd' are known to be
// plentiful. So, check the first 5 bytes for 'solid'.
// Several encodings, such as UTF-8, precede the text with up to 5 bytes:
// https://en.wikipedia.org/wiki/Byte_order_mark#Byte_order_marks_by_encoding
// Search for "solid" to start anywhere after those prefixes.
// US-ASCII ordinal values for 's', 'o', 'l', 'i', 'd'
const solid = [ 115, 111, 108, 105, 100 ];
for ( let off = 0; off < 5; off ++ ) {
// If "solid" text is matched to the current offset, declare it to be an ASCII STL.
if ( matchDataViewAt( solid, reader, off ) ) return false;
} // Couldn't find "solid" text at the beginning; it is binary STL.
return true;
}
function matchDataViewAt( query, reader, offset ) {
// Check if each byte in query matches the corresponding byte from the current offset
for ( let i = 0, il = query.length; i < il; i ++ ) {
if ( query[ i ] !== reader.getUint8( offset + i, false ) ) return false;
}
return true;
}
function parseBinary( data ) {
const reader = new DataView( data );
const faces = reader.getUint32( 80, true );
let r,
g,
b,
hasColors = false,
colors;
let defaultR, defaultG, defaultB, alpha; // process STL header
// check for default color in header ("COLOR=rgba" sequence).
for ( let index = 0; index < 80 - 10; index ++ ) {
if ( reader.getUint32( index, false ) == 0x434F4C4F
/*COLO*/
&& reader.getUint8( index + 4 ) == 0x52
/*'R'*/
&& reader.getUint8( index + 5 ) == 0x3D
/*'='*/
) {
hasColors = true;
colors = new Float32Array( faces * 3 * 3 );
defaultR = reader.getUint8( index + 6 ) / 255;
defaultG = reader.getUint8( index + 7 ) / 255;
defaultB = reader.getUint8( index + 8 ) / 255;
alpha = reader.getUint8( index + 9 ) / 255;
}
}
const dataOffset = 84;
const faceLength = 12 * 4 + 2;
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array( faces * 3 * 3 );
const normals = new Float32Array( faces * 3 * 3 );
for ( let face = 0; face < faces; face ++ ) {
const start = dataOffset + face * faceLength;
const normalX = reader.getFloat32( start, true );
const normalY = reader.getFloat32( start + 4, true );
const normalZ = reader.getFloat32( start + 8, true );
if ( hasColors ) {
const packedColor = reader.getUint16( start + 48, true );
if ( ( packedColor & 0x8000 ) === 0 ) {
// facet has its own unique color
r = ( packedColor & 0x1F ) / 31;
g = ( packedColor >> 5 & 0x1F ) / 31;
b = ( packedColor >> 10 & 0x1F ) / 31;
} else {
r = defaultR;
g = defaultG;
b = defaultB;
}
}
for ( let i = 1; i <= 3; i ++ ) {
const vertexstart = start + i * 12;
const componentIdx = face * 3 * 3 + ( i - 1 ) * 3;
vertices[ componentIdx ] = reader.getFloat32( vertexstart, true );
vertices[ componentIdx + 1 ] = reader.getFloat32( vertexstart + 4, true );
vertices[ componentIdx + 2 ] = reader.getFloat32( vertexstart + 8, true );
normals[ componentIdx ] = normalX;
normals[ componentIdx + 1 ] = normalY;
normals[ componentIdx + 2 ] = normalZ;
if ( hasColors ) {
colors[ componentIdx ] = r;
colors[ componentIdx + 1 ] = g;
colors[ componentIdx + 2 ] = b;
}
}
}
geometry.setAttribute( 'position', new THREE.BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new THREE.BufferAttribute( normals, 3 ) );
if ( hasColors ) {
geometry.setAttribute( 'color', new THREE.BufferAttribute( colors, 3 ) );
geometry.hasColors = true;
geometry.alpha = alpha;
}
return geometry;
}
function parseASCII( data ) {
const geometry = new THREE.BufferGeometry();
const patternSolid = /solid([\s\S]*?)endsolid/g;
const patternFace = /facet([\s\S]*?)endfacet/g;
let faceCounter = 0;
const patternFloat = /[\s]+([+-]?(?:\d*)(?:\.\d*)?(?:[eE][+-]?\d+)?)/.source;
const patternVertex = new RegExp( 'vertex' + patternFloat + patternFloat + patternFloat, 'g' );
const patternNormal = new RegExp( 'normal' + patternFloat + patternFloat + patternFloat, 'g' );
const vertices = [];
const normals = [];
const normal = new THREE.Vector3();
let result;
let groupCount = 0;
let startVertex = 0;
let endVertex = 0;
while ( ( result = patternSolid.exec( data ) ) !== null ) {
startVertex = endVertex;
const solid = result[ 0 ];
while ( ( result = patternFace.exec( solid ) ) !== null ) {
let vertexCountPerFace = 0;
let normalCountPerFace = 0;
const text = result[ 0 ];
while ( ( result = patternNormal.exec( text ) ) !== null ) {
normal.x = parseFloat( result[ 1 ] );
normal.y = parseFloat( result[ 2 ] );
normal.z = parseFloat( result[ 3 ] );
normalCountPerFace ++;
}
while ( ( result = patternVertex.exec( text ) ) !== null ) {
vertices.push( parseFloat( result[ 1 ] ), parseFloat( result[ 2 ] ), parseFloat( result[ 3 ] ) );
normals.push( normal.x, normal.y, normal.z );
vertexCountPerFace ++;
endVertex ++;
} // every face have to own ONE valid normal
if ( normalCountPerFace !== 1 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the normal of face number ' + faceCounter );
} // each face have to own THREE valid vertices
if ( vertexCountPerFace !== 3 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the vertices of face number ' + faceCounter );
}
faceCounter ++;
}
const start = startVertex;
const count = endVertex - startVertex;
geometry.addGroup( start, count, groupCount );
groupCount ++;
}
geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new THREE.Float32BufferAttribute( normals, 3 ) );
return geometry;
}
function ensureString( buffer ) {
if ( typeof buffer !== 'string' ) {
return THREE.LoaderUtils.decodeText( new Uint8Array( buffer ) );
}
return buffer;
}
function ensureBinary( buffer ) {
if ( typeof buffer === 'string' ) {
const array_buffer = new Uint8Array( buffer.length );
for ( let i = 0; i < buffer.length; i ++ ) {
array_buffer[ i ] = buffer.charCodeAt( i ) & 0xff; // implicitly assumes little-endian
}
return array_buffer.buffer || array_buffer;
} else {
return buffer;
}
} // start
const binData = ensureBinary( data );
return isBinary( binData ) ? parseBinary( binData ) : parseASCII( ensureString( data ) );
}
}
THREE.STLLoader = STLLoader;
} )();

File diff suppressed because one or more lines are too long

View File

@@ -26,7 +26,22 @@ class PrintFile(db.Model):
status = db.Column(db.String(50), default='waiting') # waiting, slicing, sliced, failed status = db.Column(db.String(50), default='waiting') # waiting, slicing, sliced, failed
transform_matrix = db.Column(db.Text, nullable=True) # json format of 16-element array transform_matrix = db.Column(db.Text, nullable=True) # json format of 16-element array
class UserSession(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
session_token = db.Column(db.String(100), unique=True, nullable=False)
ip_address = db.Column(db.String(50))
user_agent = db.Column(db.String(255))
last_active = db.Column(db.DateTime, default=datetime.utcnow)
is_active = db.Column(db.Boolean, default=True)
class SystemConfig(db.Model): class SystemConfig(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(50), unique=True, nullable=False) key = db.Column(db.String(50), unique=True, nullable=False)
value = db.Column(db.String(255), nullable=False) value = db.Column(db.String(255), nullable=False)
class ApiKey(db.Model):
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(100), unique=True, nullable=False)
name = db.Column(db.String(100), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)

View File

@@ -1,18 +1,18 @@
import json import json
import trimesh
import uuid import uuid
import os import os
import configparser import configparser
import secrets
from datetime import datetime from datetime import datetime
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, session, make_response, send_file, abort, jsonify from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, session, make_response, send_file, abort, jsonify
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from app.models import db, User, PrintFile, SystemConfig from app.models import db, User, PrintFile, SystemConfig, ApiKey
from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
from app import i18n_dict from app import i18n_dict
# import trimesh.repair
from app.utils.stl_simplifier import simplify_stl from app.utils.stl_simplifier import simplify_stl
from app.utils.slice_engines import get_all_engines
main_bp = Blueprint('main', __name__) main_bp = Blueprint('main', __name__)
@@ -21,6 +21,12 @@ main_bp = Blueprint('main', __name__)
auth_bp = Blueprint('auth', __name__, url_prefix='/auth') auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
admin_bp = Blueprint('admin', __name__, url_prefix='/admin') admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
def get_gcode_dir():
conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
if conf and conf.value and os.path.exists(conf.value):
return conf.value
return current_app.config['UPLOAD_FOLDER']
# Guest User Middleware # Guest User Middleware
@admin_bp.before_request @admin_bp.before_request
def require_admin(): def require_admin():
@@ -39,6 +45,11 @@ def settings():
default_support = request.form.get('default_support', 'false') default_support = request.form.get('default_support', 'false')
default_support_pattern = request.form.get('default_support_pattern', 'tree') default_support_pattern = request.form.get('default_support_pattern', 'tree')
default_quality = request.form.get('default_quality', 'base_global_standard.inst.cfg') default_quality = request.form.get('default_quality', 'base_global_standard.inst.cfg')
default_material = request.form.get('default_material', '')
default_printer = request.form.get('default_printer', '')
gcode_upload_folder = request.form.get('gcode_upload_folder', '').strip()
slicer_engine = request.form.get('slicer_engine', 'cura')
build_plate_model_path = request.form.get('build_plate_model_path', '').strip()
# update or create config entries # update or create config entries
config_items = [ config_items = [
@@ -48,7 +59,16 @@ def settings():
('default_infill', default_infill), ('default_infill', default_infill),
('default_support', default_support), ('default_support', default_support),
('default_support_pattern', default_support_pattern), ('default_support_pattern', default_support_pattern),
('default_quality', default_quality) ('default_quality', default_quality),
('default_material', default_material),
('default_printer', default_printer),
('gcode_upload_folder', gcode_upload_folder),
('slicer_engine', slicer_engine),
('build_plate_model_path', build_plate_model_path),
('default_guest_stl_quota_mb', request.form.get('default_guest_stl_quota_mb', '0')),
('default_guest_gcode_quota_mb', request.form.get('default_guest_gcode_quota_mb', '0')),
('default_user_stl_quota_mb', request.form.get('default_user_stl_quota_mb', '0')),
('default_user_gcode_quota_mb', request.form.get('default_user_gcode_quota_mb', '0'))
] ]
for key, val in config_items: for key, val in config_items:
conf = SystemConfig.query.filter_by(key=key).first() conf = SystemConfig.query.filter_by(key=key).first()
@@ -63,13 +83,107 @@ def settings():
return redirect(url_for('admin.settings')) return redirect(url_for('admin.settings'))
configs = {c.key: c.value for c in SystemConfig.query.all()} configs = {c.key: c.value for c in SystemConfig.query.all()}
presets = get_quality_presets() engines = get_all_engines()
return render_template('admin/settings.html', configs=configs, presets=presets) return render_template('admin/settings.html', configs=configs, engines=engines)
@admin_bp.route('/users') @admin_bp.route('/users')
def users(): def users():
all_users = User.query.order_by(User.created_at.desc()).all() all_users = User.query.order_by(User.created_at.desc()).all()
return render_template('admin/users.html', users=all_users) user_quotas = {}
# Load defaults
def_guest_stl = SystemConfig.query.filter_by(key="default_guest_stl_quota_mb").first()
def_guest_stl_val = def_guest_stl.value if def_guest_stl else '0'
def_guest_gcode = SystemConfig.query.filter_by(key="default_guest_gcode_quota_mb").first()
def_guest_gcode_val = def_guest_gcode.value if def_guest_gcode else '0'
def_user_stl = SystemConfig.query.filter_by(key="default_user_stl_quota_mb").first()
def_user_stl_val = def_user_stl.value if def_user_stl else '0'
def_user_gcode = SystemConfig.query.filter_by(key="default_user_gcode_quota_mb").first()
def_user_gcode_val = def_user_gcode.value if def_user_gcode else '0'
for u in all_users:
if u.is_admin:
eff_stl = '0'
eff_gcode = '0'
elif u.is_guest:
eff_stl = def_guest_stl_val
eff_gcode = def_guest_gcode_val
else:
eff_stl = def_user_stl_val
eff_gcode = def_user_gcode_val
sq = SystemConfig.query.filter_by(key=f"user_{u.id}_stl_quota_mb").first()
gq = SystemConfig.query.filter_by(key=f"user_{u.id}_gcode_quota_mb").first()
user_stl = sq.value if sq else '0'
user_gcode = gq.value if gq else '0'
user_quotas[u.id] = {
'stl': user_stl,
'gcode': user_gcode,
'eff_stl': eff_stl if user_stl == '0' else user_stl,
'eff_gcode': eff_gcode if user_gcode == '0' else user_gcode,
}
return render_template('admin/users.html', users=all_users, user_quotas=user_quotas)
@admin_bp.route('/user/add', methods=['POST'])
def add_user():
username = request.form.get('username')
password = request.form.get('password')
is_admin = request.form.get('is_admin') == 'on'
if User.query.filter_by(username=username).first():
flash("Username already exists", "danger")
return redirect(url_for('admin.users'))
u = User(username=username, is_guest=False, is_admin=is_admin)
u.password_hash = generate_password_hash(password)
db.session.add(u)
db.session.commit()
# Save quotas
stl_quota = request.form.get('stl_quota_mb', '0')
gcode_quota = request.form.get('gcode_quota_mb', '0')
if stl_quota != '0':
db.session.add(SystemConfig(key=f"user_{u.id}_stl_quota_mb", value=stl_quota))
if gcode_quota != '0':
db.session.add(SystemConfig(key=f"user_{u.id}_gcode_quota_mb", value=gcode_quota))
db.session.commit()
flash("User created.", "success")
return redirect(url_for('admin.users'))
@admin_bp.route('/user/<int:user_id>/quota', methods=['POST'])
def update_quota(user_id):
stl_quota = request.form.get('stl_quota_mb', '0')
gcode_quota = request.form.get('gcode_quota_mb', '0')
def set_conf(k, v):
c = SystemConfig.query.filter_by(key=k).first()
if not c:
c = SystemConfig(key=k)
db.session.add(c)
c.value = str(v)
set_conf(f"user_{user_id}_stl_quota_mb", stl_quota)
set_conf(f"user_{user_id}_gcode_quota_mb", gcode_quota)
db.session.commit()
flash("Quotas updated.", "success")
return redirect(url_for('admin.users'))
@admin_bp.route('/user/<int:user_id>/password', methods=['POST'])
def reset_password(user_id):
user = User.query.get_or_404(user_id)
if user.is_guest:
flash("Cannot set password for guests.", "danger")
return redirect(url_for('admin.users'))
pwd = request.form.get('password')
user.password_hash = generate_password_hash(pwd)
db.session.commit()
flash("Password updated.", "success")
return redirect(url_for('admin.users'))
@admin_bp.route('/user/<int:user_id>/delete', methods=['POST']) @admin_bp.route('/user/<int:user_id>/delete', methods=['POST'])
def delete_user(user_id): def delete_user(user_id):
@@ -82,7 +196,7 @@ def delete_user(user_id):
for print_file in print_files: for print_file in print_files:
stl_path = os.path.join(current_app.config['UPLOAD_FOLDER'], print_file.filename) stl_path = os.path.join(current_app.config['UPLOAD_FOLDER'], print_file.filename)
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode' gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
gcode_path = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename) gcode_path = os.path.join(get_gcode_dir(), gcode_filename)
proxy_path = stl_path + '.proxy.stl' proxy_path = stl_path + '.proxy.stl'
if os.path.exists(stl_path): if os.path.exists(stl_path):
@@ -102,30 +216,32 @@ def delete_user(user_id):
flash(f'User {user.username} and all their files have been deleted.', 'success') flash(f'User {user.username} and all their files have been deleted.', 'success')
return redirect(url_for('admin.users')) return redirect(url_for('admin.users'))
def get_bed_dimensions():
try:
path = os.path.join(current_app.root_path, '..', 'print_config', 'printers', 'creality_ender3v3se.def.json')
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
w = data['overrides']['machine_width']['default_value']
h = data['overrides']['machine_depth']['default_value']
hd = data['overrides']['machine_height']['default_value']
return w, h, hd
except:
return 200, 200, 200
def get_quality_presets():
try:
path = os.path.join(current_app.root_path, '..', 'print_config', 'quality', 'creality', 'presets')
files = [f for f in os.listdir(path) if f.endswith('.inst.cfg')]
presets = []
for f in files:
# name = f.replace('.inst.cfg', '').replace('base_', '').replace('_', ' ')
name = f.replace('.inst.cfg', '')
presets.append((f, name))
presets.sort(key=lambda x: x[1])
return presets
except:
return []
@admin_bp.route('/api_keys')
def api_keys():
keys = ApiKey.query.order_by(ApiKey.created_at.desc()).all()
return render_template('admin/api_keys.html', keys=keys)
@admin_bp.route('/api_key/add', methods=['POST'])
def add_api_key():
name = request.form.get('name')
if not name:
flash("Name is required", "danger")
return redirect(url_for('admin.api_keys'))
key_value = secrets.token_urlsafe(32)
new_key = ApiKey(name=name, key=key_value)
db.session.add(new_key)
db.session.commit()
flash(f"API Key '{name}' created.", "success")
return redirect(url_for('admin.api_keys'))
@admin_bp.route('/api_key/<int:key_id>/delete', methods=['POST'])
def delete_api_key(key_id):
key = ApiKey.query.get_or_404(key_id)
db.session.delete(key)
db.session.commit()
flash(f'API Key {key.name} deleted.', 'success')
return redirect(url_for('admin.api_keys'))

View File

@@ -1,5 +1,4 @@
import json import json
import trimesh
import uuid import uuid
import os import os
import configparser import configparser
@@ -8,11 +7,12 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from app.models import db, User, PrintFile, SystemConfig from app.models import db, User, PrintFile, SystemConfig, UserSession
from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
from app import i18n_dict from app import i18n_dict
# import trimesh.repair
from app.utils.stl_simplifier import simplify_stl from app.utils.stl_simplifier import simplify_stl
from app.routes.main_routes import get_quota_info
from app.routes.admin_routes import get_gcode_dir
main_bp = Blueprint('main', __name__) main_bp = Blueprint('main', __name__)
@@ -28,16 +28,119 @@ def login():
username = request.form.get('username') username = request.form.get('username')
password = request.form.get('password') password = request.form.get('password')
user = User.query.filter_by(username=username, is_guest=False).first() user = User.query.filter_by(username=username, is_guest=False).first()
remember = bool(request.form.get('remember'))
merge_data = bool(request.form.get('merge_data'))
if user and check_password_hash(user.password_hash, password): if user and check_password_hash(user.password_hash, password):
login_user(user) # Clear old password check flag
return redirect(url_for('main.index')) session.pop('pwd_check_done', None)
session.pop('must_change_password', None)
login_user(user, remember=remember)
session_token = str(uuid.uuid4())
# 尝试获取反向代理传递的真实 IP
client_ip = request.headers.get('X-Real-IP')
if not client_ip:
client_ip = request.remote_addr
user_session = UserSession(
user_id=user.id,
session_token=session_token,
ip_address=client_ip,
user_agent=request.user_agent.string
)
db.session.add(user_session)
db.session.commit()
session['user_session_token'] = session_token
if merge_data:
guest_id = request.cookies.get('guest_id')
if guest_id:
guest_user = User.query.filter_by(guest_cookie_id=guest_id, is_guest=True).first()
if guest_user:
guest_files = PrintFile.query.filter_by(user_id=guest_user.id).all()
stl_quota, stl_used = get_quota_info(user, 'stl')
gcode_quota, gcode_used = get_quota_info(user, 'gcode')
stl_quota_bytes = stl_quota * 1024 * 1024 if stl_quota > 0 else float('inf')
gcode_quota_bytes = gcode_quota * 1024 * 1024 if gcode_quota > 0 else float('inf')
upload_dir = current_app.config.get('UPLOAD_FOLDER', 'uploads')
gcode_dir = get_gcode_dir()
for pf in guest_files:
file_size = 0
file_type = 'stl'
is_external_gcode = pf.original_filename.lower().endswith(('.gcode', '.gco', '.g'))
if is_external_gcode or pf.status == 'sliced':
file_type = 'gcode'
g_filename = pf.filename.rsplit('.', 1)[0] + '.gcode'
path = os.path.join(gcode_dir, g_filename)
if os.path.exists(path):
file_size = os.path.getsize(path)
else:
p2 = os.path.join(upload_dir, g_filename)
if os.path.exists(p2): file_size = os.path.getsize(p2)
else:
path = os.path.join(upload_dir, pf.filename)
if os.path.exists(path):
file_size = os.path.getsize(path)
# Check quota
can_merge = True
if not user.is_admin:
if file_type == 'stl' and (stl_used + file_size > stl_quota_bytes):
can_merge = False
elif file_type == 'gcode' and (gcode_used + file_size > gcode_quota_bytes):
can_merge = False
if can_merge:
pf.user_id = user.id
if file_type == 'stl': stl_used += file_size
else: gcode_used += file_size
else:
# delete from disk to prevent orphans
stl_path = os.path.join(upload_dir, pf.filename)
proxy_path = stl_path + '.proxy.stl'
gcode_filename = pf.filename.rsplit('.', 1)[0] + '.gcode'
gp = os.path.join(gcode_dir, gcode_filename)
fp = os.path.join(upload_dir, gcode_filename)
if os.path.exists(stl_path): os.remove(stl_path)
if os.path.exists(proxy_path): os.remove(proxy_path)
if os.path.exists(gp): os.remove(gp)
if os.path.exists(fp): os.remove(fp)
db.session.delete(pf)
# Save changes to files first so SQLAlchemy doesn't try to nullify related keys
db.session.commit()
# Delete guest user after merge
db.session.delete(guest_user)
db.session.commit()
response = make_response(redirect(url_for('main.index')))
if merge_data:
response.delete_cookie('guest_id')
return response
flash('Invalid username or password', 'danger') flash('Invalid username or password', 'danger')
return render_template('auth/login.html') return render_template('auth/login.html')
@auth_bp.route('/logout') @auth_bp.route('/logout')
@login_required @login_required
def logout(): def logout():
session_token = session.get('user_session_token')
if session_token:
user_session = UserSession.query.filter_by(session_token=session_token).first()
if user_session:
user_session.is_active = False
db.session.commit()
logout_user() logout_user()
session.pop('user_session_token', None)
response = make_response(redirect(url_for('main.index'))) response = make_response(redirect(url_for('main.index')))
response.delete_cookie('guest_id') # Optionally clear guest cookie response.delete_cookie('guest_id') # Optionally clear guest cookie
return response return response

View File

@@ -1,5 +1,4 @@
import json import json
import trimesh
import uuid import uuid
import os import os
import configparser import configparser
@@ -8,22 +7,119 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from app.models import db, User, PrintFile, SystemConfig from app.models import db, User, PrintFile, SystemConfig, UserSession
from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
from app import i18n_dict from app import i18n_dict
# import trimesh.repair
from app.utils.stl_simplifier import simplify_stl from app.utils.stl_simplifier import simplify_stl
from app.routes.admin_routes import get_gcode_dir
from app.utils.slice_engines import get_slicer_engine
from app.utils.gcode_parser import get_gcode_metadata
main_bp = Blueprint('main', __name__) main_bp = Blueprint('main', __name__)
@main_bp.before_app_request
def check_user_session():
if current_user.is_authenticated and not current_user.is_guest:
session_token = session.get('user_session_token')
client_ip = request.headers.get('X-Real-IP') or request.remote_addr
if session_token:
user_session = UserSession.query.filter_by(session_token=session_token).first()
if not user_session or not user_session.is_active:
logout_user()
session.pop('user_session_token', None)
flash('Your session has been terminated.', 'warning')
return redirect(url_for('auth.login'))
else:
user_session.last_active = datetime.utcnow()
user_session.ip_address = client_ip
db.session.commit()
else:
# Re-authenticated via remember me, but no session token
new_session_token = str(uuid.uuid4())
user_session = UserSession(
user_id=current_user.id,
session_token=new_session_token,
ip_address=client_ip,
user_agent=request.user_agent.string,
last_active=datetime.utcnow()
)
db.session.add(user_session)
db.session.commit()
session['user_session_token'] = new_session_token
# Check default admin password securely without checking hash on every request
if current_user.is_admin:
if session.get('pwd_check_done') is None:
session['pwd_check_done'] = True
if check_password_hash(current_user.password_hash, 'admin123'):
session['must_change_password'] = True
else:
session.pop('must_change_password', None)
if session.get('must_change_password'):
if request.endpoint and request.endpoint not in ['main.account', 'auth.logout', 'static']:
flash('For security reasons, please change your default admin password.', 'warning')
return redirect(url_for('main.account'))
auth_bp = Blueprint('auth', __name__, url_prefix='/auth') auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
admin_bp = Blueprint('admin', __name__, url_prefix='/admin') admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
def get_quota_info(user, file_type):
# Returns (quota_mb, current_size_bytes)
if user.is_admin:
quota_mb = 0.0
else:
conf = SystemConfig.query.filter_by(key=f"user_{user.id}_{file_type}_quota_mb").first()
quota_mb = float(conf.value) if conf else 0.0
if quota_mb == 0.0:
if user.is_guest:
def_conf = SystemConfig.query.filter_by(key=f"default_guest_{file_type}_quota_mb").first()
else:
def_conf = SystemConfig.query.filter_by(key=f"default_user_{file_type}_quota_mb").first()
quota_mb = float(def_conf.value) if def_conf else 0.0
user_files = PrintFile.query.filter_by(user_id=user.id).all()
current_size = 0
upload_dir = current_app.config.get('UPLOAD_FOLDER', 'uploads')
gcode_dir = get_gcode_dir()
for pf in user_files:
if file_type == 'stl' and not pf.original_filename.lower().endswith(('.gcode', '.gco', '.g')):
path = os.path.join(upload_dir, pf.filename)
if os.path.exists(path):
current_size += os.path.getsize(path)
elif file_type == 'gcode':
g_filename = pf.filename.rsplit('.', 1)[0] + '.gcode'
path = os.path.join(gcode_dir, g_filename)
if os.path.exists(path):
current_size += os.path.getsize(path)
else:
p2 = os.path.join(upload_dir, g_filename)
if os.path.exists(p2): current_size += os.path.getsize(p2)
return quota_mb, current_size
def check_quota(user, file_type, size_bytes):
if user.is_admin:
return True
quota_mb, current_size = get_quota_info(user, file_type)
if quota_mb <= 0.0:
return True
if current_size + size_bytes > quota_mb * 1024 * 1024:
return False
return True
# Guest User Middleware # Guest User Middleware
@main_bp.before_app_request @main_bp.before_app_request
def assign_guest_cookie(): def assign_guest_cookie():
if request.path.startswith('/api/'):
return
if not current_user.is_authenticated: if not current_user.is_authenticated:
guest_id = request.cookies.get('guest_id') guest_id = request.cookies.get('guest_id')
if not guest_id: if not guest_id:
@@ -48,8 +144,68 @@ def set_guest_cookie(response):
# --- Main Routes --- # --- Main Routes ---
@main_bp.route('/') @main_bp.route('/')
@login_required
def index(): def index():
return render_template('slice/index.html') user_files = PrintFile.query.filter((PrintFile.user_id == current_user.id) | (current_user.is_admin)).all() if current_user.is_admin else PrintFile.query.filter_by(user_id=current_user.id).all()
stl_count = 0
stl_size = 0
gcode_count = 0
gcode_size = 0
upload_dir = current_app.config.get('UPLOAD_FOLDER', 'uploads')
gcode_dir = get_gcode_dir()
for f in user_files:
is_external_gcode = f.original_filename.lower().endswith(('.gcode', '.gco', '.g'))
if is_external_gcode:
gcode_count += 1
gcode_path = os.path.join(gcode_dir, f.filename.replace('.stl', '.gcode'))
if os.path.exists(gcode_path):
gcode_size += os.path.getsize(gcode_path)
else:
stl_count += 1
stl_path = os.path.join(upload_dir, f.filename)
if os.path.exists(stl_path):
stl_size += os.path.getsize(stl_path)
if f.status == 'sliced':
gcode_count += 1
gcode_filename = f.filename.rsplit('.', 1)[0] + '.gcode'
gcode_path = os.path.join(gcode_dir, gcode_filename)
if os.path.exists(gcode_path):
gcode_size += os.path.getsize(gcode_path)
else:
gcode_fallback = os.path.join(upload_dir, gcode_filename)
if os.path.exists(gcode_fallback):
gcode_size += os.path.getsize(gcode_fallback)
def format_size(size_bytes):
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.2f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.2f} MB"
else:
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
stl_quota_mb, stl_used_bytes = get_quota_info(current_user, 'stl')
gcode_quota_mb, gcode_used_bytes = get_quota_info(current_user, 'gcode')
return render_template('slice/index.html',
stl_count=stl_count,
stl_size_str=format_size(stl_size),
gcode_count=gcode_count,
gcode_size_str=format_size(gcode_size),
stl_used_bytes=stl_used_bytes,
stl_quota_mb=stl_quota_mb,
gcode_used_bytes=gcode_used_bytes,
gcode_quota_mb=gcode_quota_mb,
format_size=format_size
)
@main_bp.route('/set_language/<lang>') @main_bp.route('/set_language/<lang>')
def set_language(lang): def set_language(lang):
@@ -78,6 +234,15 @@ def files():
if file.filename == '': if file.filename == '':
continue continue
if file and file.filename.lower().endswith('.stl'): if file and file.filename.lower().endswith('.stl'):
file.seek(0, os.SEEK_END)
size_bytes = file.tell()
file.seek(0, os.SEEK_SET)
if not check_quota(current_user, 'stl', size_bytes):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'error': 'STL Storage Quota Exceeded'}), 400
flash('STL Storage Quota Exceeded', 'danger')
continue
original_filename = file.filename # Do not use secure_filename to keep Chinese characters original_filename = file.filename # Do not use secure_filename to keep Chinese characters
ext = os.path.splitext(original_filename)[1].lower() ext = os.path.splitext(original_filename)[1].lower()
if not ext: if not ext:
@@ -133,7 +298,12 @@ def download_gcode(file_id):
return redirect(url_for('main.files')) return redirect(url_for('main.files'))
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode' gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename) conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
gcode_dir = conf.value if (conf and conf.value and os.path.exists(conf.value)) else current_app.config['UPLOAD_FOLDER']
filepath = os.path.join(gcode_dir, gcode_filename)
if not os.path.exists(filepath):
fallback = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
if os.path.exists(fallback): filepath = fallback
if os.path.exists(filepath): if os.path.exists(filepath):
safe_name = print_file.original_filename.rsplit('.', 1)[0] + '.gcode' safe_name = print_file.original_filename.rsplit('.', 1)[0] + '.gcode'
@@ -149,23 +319,42 @@ def preview_gcode(file_id):
abort(403) abort(403)
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode' gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename) conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
gcode_dir = conf.value if (conf and conf.value and os.path.exists(conf.value)) else current_app.config['UPLOAD_FOLDER']
filepath = os.path.join(gcode_dir, gcode_filename)
if not os.path.exists(filepath):
fallback = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
if os.path.exists(fallback): filepath = fallback
content = "File not found or not ready." content = "File not found or not ready."
line_count = 0 line_count = 0
time_info = "-"
layer1_time = "-"
filament_used = "-"
if os.path.exists(filepath): if os.path.exists(filepath):
metadata = get_gcode_metadata(filepath)
time_info = metadata.get('print_time', '-')
layer1_time = metadata.get('first_layer_time', '-')
filament_used = metadata.get('filament_used', '-')
with open(filepath, 'r') as f: with open(filepath, 'r') as f:
lines = f.readlines() lines = f.readlines()
line_count = len(lines) line_count = len(lines)
content = "".join(lines[:500]) # Preview first 500 lines content = "".join(lines[:500]) # Preview first 500 lines
if line_count > 500: if line_count > 500:
content += f"\n... \n[Preview truncated. Total lines: {line_count}. Please download to view full file.]" content += f"\n... \n[Preview truncated. Total lines: {line_count}. Please download to view full file.]"
w, h, hd = get_bed_dimensions() engine_name = SystemConfig.query.filter_by(key='slicer_engine').first()
if engine_name:
engine = get_slicer_engine(str(engine_name.value), current_app.config['PRINT_CONFIG_FOLDER'], current_app.config['PRUSA_SLICE_BIN'])
w, h, hd = engine.get_bed_dimensions()
configs = {c.key: c.value for c in SystemConfig.query.all()} configs = {c.key: c.value for c in SystemConfig.query.all()}
offset_x = float(configs.get('offset_x', '0.0')) offset_x = float(configs.get('offset_x', '0.0'))
offset_y = float(configs.get('offset_y', '0.0')) offset_y = float(configs.get('offset_y', '0.0'))
return render_template('slice/gcode_preview.html', file=print_file, content=content, line_count=line_count, machine_width=w, machine_depth=h, machine_height=hd, offset_x=offset_x, offset_y=offset_y) return render_template('slice/gcode_preview.html', file=print_file, content=content, line_count=line_count, machine_width=w, machine_depth=h, machine_height=hd, offset_x=offset_x, offset_y=offset_y,
time_info=time_info, layer1_time=layer1_time, filament_used=filament_used)
@main_bp.route('/delete_file/<int:file_id>', methods=['POST']) @main_bp.route('/delete_file/<int:file_id>', methods=['POST'])
@login_required @login_required
@@ -176,7 +365,11 @@ def delete_file(file_id):
stl_path = os.path.join(current_app.config['UPLOAD_FOLDER'], print_file.filename) stl_path = os.path.join(current_app.config['UPLOAD_FOLDER'], print_file.filename)
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode' gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
gcode_path = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename) conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
gcode_dir = conf.value if (conf and conf.value and os.path.exists(conf.value)) else current_app.config['UPLOAD_FOLDER']
gcode_path = os.path.join(gcode_dir, gcode_filename)
fallback_gcode = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
proxy_path = stl_path + '.proxy.stl' proxy_path = stl_path + '.proxy.stl'
if os.path.exists(stl_path): if os.path.exists(stl_path):
@@ -185,6 +378,8 @@ def delete_file(file_id):
os.remove(proxy_path) os.remove(proxy_path)
if os.path.exists(gcode_path): if os.path.exists(gcode_path):
os.remove(gcode_path) os.remove(gcode_path)
if os.path.exists(fallback_gcode):
os.remove(fallback_gcode)
db.session.delete(print_file) db.session.delete(print_file)
db.session.commit() db.session.commit()
@@ -193,38 +388,18 @@ def delete_file(file_id):
# --- Auth Routes --- # --- Auth Routes ---
def get_bed_dimensions():
try:
path = os.path.join(current_app.root_path, '..', 'print_config', 'printers', 'creality_ender3v3se.def.json')
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
w = data['overrides']['machine_width']['default_value']
h = data['overrides']['machine_depth']['default_value']
hd = data['overrides']['machine_height']['default_value']
return w, h, hd
except:
return 200, 200, 200
def get_quality_presets():
try:
path = os.path.join(current_app.root_path, '..', 'print_config', 'quality', 'creality', 'presets')
files = [f for f in os.listdir(path) if f.endswith('.inst.cfg')]
presets = []
for f in files:
# name = f.replace('.inst.cfg', '').replace('base_', '').replace('_', ' ')
name = f.replace('.inst.cfg', '')
presets.append((f, name))
presets.sort(key=lambda x: x[1])
return presets
except:
return []
@main_bp.route('/plater') @main_bp.route('/plater')
@login_required @login_required
def plater(): def plater():
w, h, hd = get_bed_dimensions() quota_mb, current_size = get_quota_info(current_user, 'gcode')
presets = get_quality_presets() quota_exceeded = (quota_mb > 0 and current_size >= quota_mb * 1024 * 1024)
engine_name = SystemConfig.query.filter_by(key='slicer_engine').first()
if engine_name:
engine = get_slicer_engine(str(engine_name.value), current_app.config['PRINT_CONFIG_FOLDER'], current_app.config['PRUSA_SLICE_BIN'])
w, h, hd = engine.get_bed_dimensions()
print(f"Bed dimensions: {w}x{h}x{hd}")
configs = {c.key: c.value for c in SystemConfig.query.all()} configs = {c.key: c.value for c in SystemConfig.query.all()}
offset_x = float(configs.get('offset_x', '0.0')) offset_x = float(configs.get('offset_x', '0.0'))
@@ -234,10 +409,32 @@ def plater():
default_support = configs.get('default_support', 'false') default_support = configs.get('default_support', 'false')
default_support_pattern = configs.get('default_support_pattern', 'tree') default_support_pattern = configs.get('default_support_pattern', 'tree')
default_quality = configs.get('default_quality', 'base_global_standard.inst.cfg') default_quality = configs.get('default_quality', 'base_global_standard.inst.cfg')
default_material = configs.get('default_material', '')
user_files = PrintFile.query.filter_by(user_id=current_user.id, file_type='stl').order_by(PrintFile.created_at.desc()).all() user_files = PrintFile.query.filter_by(user_id=current_user.id, file_type='stl').order_by(PrintFile.created_at.desc()).all()
models = [{'id': f.id, 'name': f.original_filename, 'status': f.status, 'url': url_for('main.serve_proxy_file', file_id=f.id), 'transform_matrix': f.transform_matrix} for f in user_files] models = [{'id': f.id, 'name': f.original_filename, 'status': f.status, 'url': url_for('main.serve_proxy_file', file_id=f.id), 'transform_matrix': f.transform_matrix} for f in user_files]
return render_template('slice/plater.html', w=w, h=h, hd=hd, presets=presets, last_quality=default_quality, models=models, offset_x=offset_x, offset_y=offset_y, default_infill=default_infill, default_support=default_support, default_support_pattern=default_support_pattern) return render_template('slice/plater.html', w=w, h=h, hd=hd, last_quality=default_quality, last_material=default_material, models=models, offset_x=offset_x, offset_y=offset_y, default_infill=default_infill, default_support=default_support, default_support_pattern=default_support_pattern, quota_exceeded=quota_exceeded, configs=configs)
import re
import markdown
@main_bp.route('/helper_slice')
def helper_slice():
lang = request.cookies.get('lang', 'en')
filepath = os.path.join(current_app.root_path, 'assets', 'doc', f'slice_helper_{lang}.md')
if not os.path.exists(filepath):
filepath = os.path.join(current_app.root_path, 'assets', 'doc', 'slice_helper_en.md')
content_html = ""
if os.path.exists(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
md_text = f.read()
content_html = markdown.markdown(md_text, extensions=['fenced_code', 'tables'])
# Rewrite relative image links to /assets/doc/
content_html = re.sub(r'src="(?!http|/)([^"]+)"', r'src="/assets/doc/\1"', content_html)
return render_template('slice/helper_slice.html', content_html=content_html)
@main_bp.route('/file/<int:file_id>') @main_bp.route('/file/<int:file_id>')
@login_required @login_required
@@ -263,9 +460,13 @@ def serve_proxy_file(file_id):
@main_bp.route('/api/merge_and_slice', methods=['POST']) @main_bp.route('/api/merge_and_slice', methods=['POST'])
@login_required @login_required
def merge_and_slice(): def merge_and_slice():
quota_mb, current_size = get_quota_info(current_user, 'gcode')
if quota_mb > 0 and current_size >= quota_mb * 1024 * 1024:
return jsonify({'success': False, 'error': 'GCode Storage Quota Exceeded. Please delete some files first.'})
data = request.json data = request.json
pieces = data.get('pieces', []) pieces = data.get('pieces', [])
quality = data.get('quality', 'base_global_standard.inst.cfg') quality = data.get('quality', 'base_global_standard.inst.cfg')
material = data.get('material', '')
infill_density = data.get('infill', '20') infill_density = data.get('infill', '20')
support_enable = data.get('support', 'false') support_enable = data.get('support', 'false')
support_pattern = data.get('support_pattern', 'lines') support_pattern = data.get('support_pattern', 'lines')
@@ -303,6 +504,7 @@ def merge_and_slice():
"matrix": p['raw_matrix'], "matrix": p['raw_matrix'],
"settings": { "settings": {
"quality": quality, "quality": quality,
"material": material,
"infill": infill_density, "infill": infill_density,
"support": support_enable, "support": support_enable,
"support_pattern": support_pattern "support_pattern": support_pattern
@@ -326,6 +528,7 @@ def merge_and_slice():
"parts": [], "parts": [],
"settings": { "settings": {
"quality": quality, "quality": quality,
"material": material,
"infill": infill_density, "infill": infill_density,
"support": support_enable, "support": support_enable,
"support_pattern": support_pattern "support_pattern": support_pattern
@@ -347,6 +550,7 @@ def merge_and_slice():
"matrix": pieces[0].get('raw_matrix', pieces[0]['matrix']), "matrix": pieces[0].get('raw_matrix', pieces[0]['matrix']),
"settings": { "settings": {
"quality": quality, "quality": quality,
"material": material,
"infill": infill_density, "infill": infill_density,
"support": support_enable, "support": support_enable,
"support_pattern": support_pattern "support_pattern": support_pattern
@@ -358,7 +562,7 @@ def merge_and_slice():
temp_filename = f"temp_edit_{uuid.uuid4().hex}.stl" temp_filename = f"temp_edit_{uuid.uuid4().hex}.stl"
temp_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], temp_filename) temp_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], temp_filename)
merge_and_slice_task(target_file_id, inputs, temp_filepath, quality, infill_density, support_enable, support_pattern, delete_stl=True) merge_and_slice_task(target_file_id, inputs, temp_filepath, quality, material, infill_density, support_enable, support_pattern, delete_stl=True)
elif len(inputs) == 1 and is_edit: elif len(inputs) == 1 and is_edit:
target_file_id = pieces[0]['file_id'] target_file_id = pieces[0]['file_id']
print_file = PrintFile.query.get(target_file_id) print_file = PrintFile.query.get(target_file_id)
@@ -371,7 +575,7 @@ def merge_and_slice():
temp_filename = f"temp_{uuid.uuid4().hex}.stl" temp_filename = f"temp_{uuid.uuid4().hex}.stl"
temp_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], temp_filename) temp_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], temp_filename)
merge_and_slice_task(target_file_id, inputs, temp_filepath, quality, infill_density, support_enable, support_pattern, delete_stl=True) merge_and_slice_task(target_file_id, inputs, temp_filepath, quality, material, infill_density, support_enable, support_pattern, delete_stl=True)
else: else:
# Multiple models, create a new "Merged Slice" PrintFile entry to keep track of combination # Multiple models, create a new "Merged Slice" PrintFile entry to keep track of combination
timestamp = datetime.now().strftime('%Y%m%d%H%M%S') timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
@@ -384,6 +588,7 @@ def merge_and_slice():
"parts": [], "parts": [],
"settings": { "settings": {
"quality": quality, "quality": quality,
"material": material,
"infill": infill_density, "infill": infill_density,
"support": support_enable, "support": support_enable,
"support_pattern": support_pattern "support_pattern": support_pattern
@@ -410,7 +615,76 @@ def merge_and_slice():
db.session.add(print_file) db.session.add(print_file)
db.session.commit() db.session.commit()
merge_and_slice_task(print_file.id, inputs, merged_filepath, quality, infill_density, support_enable, support_pattern, delete_stl=False) merge_and_slice_task(print_file.id, inputs, merged_filepath, quality, material, infill_density, support_enable, support_pattern, delete_stl=False)
return jsonify({'success': True, 'message': 'Plater slice queued!'}) return jsonify({'success': True, 'message': 'Plater slice queued!'})
@main_bp.route('/api/build_plate_model')
@login_required
def build_plate_model():
conf = SystemConfig.query.filter_by(key='build_plate_model_path').first()
if conf and conf.value and os.path.exists(conf.value):
return send_file(conf.value)
abort(404)
@main_bp.route('/api/engine_options/<engine_name>')
@login_required
def engine_options(engine_name):
engine = get_slicer_engine(engine_name, current_app.config['PRINT_CONFIG_FOLDER'], current_app.config['PRUSA_SLICE_BIN'])
presets = engine.get_quality_presets()
patterns = engine.get_support_patterns()
materials = engine.get_materials() if hasattr(engine, 'get_materials') else []
printers = engine.get_all_printers() if hasattr(engine, 'get_all_printers') else []
return jsonify({'presets': presets, 'support_patterns': patterns, 'materials': materials, 'printers': printers})
@main_bp.route('/account', methods=['GET', 'POST'])
@login_required
def account():
if current_user.is_guest:
flash('Guests cannot manage accounts.', 'danger')
return redirect(url_for('main.index'))
if request.method == 'POST':
action = request.form.get('action')
if action == 'change_password':
current_pass = request.form.get('current_password')
new_pass = request.form.get('new_password')
confirm_pass = request.form.get('confirm_password')
if not check_password_hash(current_user.password_hash, current_pass):
flash('Current password is incorrect.', 'danger')
elif new_pass != confirm_pass:
flash('New passwords do not match.', 'danger')
elif len(new_pass) < 6:
flash('New password must be at least 6 characters.', 'danger')
elif current_user.is_admin and new_pass == 'admin123':
flash('Your new password cannot be the default "admin123".', 'danger')
else:
current_user.password_hash = generate_password_hash(new_pass)
db.session.commit()
# If they just changed it, clear the must change flag
session.pop('must_change_password', None)
flash('Password updated successfully.', 'success')
elif action == 'terminate_session':
session_id = request.form.get('session_id')
token_to_terminate = request.form.get('session_token')
my_session_token = session.get('user_session_token')
if token_to_terminate == my_session_token:
flash('You cannot terminate your current session from here. Please logout instead.', 'warning')
else:
us = UserSession.query.filter_by(id=session_id, user_id=current_user.id).first()
if us:
us.is_active = False
db.session.commit()
flash('Session terminated.', 'success')
return redirect(url_for('main.account'))
sessions = UserSession.query.filter_by(user_id=current_user.id, is_active=True).order_by(UserSession.last_active.desc()).all()
current_token = session.get('user_session_token')
return render_template('slice/account.html', sessions=sessions, current_token=current_token)

View File

@@ -1,12 +1,18 @@
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for, current_app, Response import os
from flask_login import login_required, current_user
from websockets.sync.client import connect as ws_connect
import websockets.exceptions import websockets.exceptions
import threading import threading
import requests import requests
from urllib.parse import urlparse import uuid
from app.models import SystemConfig, db import traceback
from werkzeug.utils import secure_filename
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for, current_app, Response
from flask_login import login_required, current_user
from flask_sock import Server, ConnectionClosed
from websockets.sync.client import connect as ws_connect
from urllib.parse import urlparse, urlencode
from app.models import SystemConfig, db, PrintFile
from app.utils.octoprint_client import OctoPrintClient from app.utils.octoprint_client import OctoPrintClient
from app.utils.gcode_parser import get_gcode_metadata
printer_bp = Blueprint('printer', __name__, url_prefix='/printer') printer_bp = Blueprint('printer', __name__, url_prefix='/printer')
@@ -17,6 +23,23 @@ def get_octo_client():
return OctoPrintClient(url.value, apikey.value) return OctoPrintClient(url.value, apikey.value)
return None return None
def _enrich_job_data(job_data):
if job_data and job_data.get('job', {}).get('file', {}).get('name'):
internal_name = job_data['job']['file']['name']
internal_stl_name = str(internal_name)[:-5]+"stl"
if current_user.is_authenticated and current_user.is_admin:
pf = PrintFile.query.filter_by(filename=internal_stl_name).first()
elif current_user.is_authenticated:
pf = PrintFile.query.filter_by(filename=internal_stl_name, user_id=current_user.id).first()
else:
pf = None
if pf:
job_data['job']['file']['display_name'] = pf.original_filename
else:
job_data['job']['file']['display_name'] = internal_name
return job_data
@printer_bp.route('/status') @printer_bp.route('/status')
@login_required @login_required
def status(): def status():
@@ -27,37 +50,143 @@ def status():
if client: if client:
try: try:
status_data = client.get_printer_status() status_data = client.get_printer_status()
job_data = client.get_job_info() print(status_data)
print(client.get_job_info())
job_data = _enrich_job_data(client.get_job_info())
except Exception as e: except Exception as e:
error = str(e) error = str(e)
print(error)
else: else:
error = "OctoPrint is not configured." error = "OctoPrint is not configured."
return render_template('printer/status.html', status=status_data, job=job_data, error=error) return render_template('printer/status.html', status=status_data, job=job_data, error=error)
@printer_bp.route('/api/status_data')
@login_required
def api_status_data():
client = get_octo_client()
if client:
try:
status_data = client.get_printer_status()
job_data = _enrich_job_data(client.get_job_info())
return jsonify({'success': True, 'status': status_data, 'job': job_data})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
return jsonify({'success': False, 'error': 'OctoPrint is not configured.'})
def get_gcode_dir():
conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
if conf and conf.value and os.path.exists(conf.value):
return conf.value
return current_app.config['UPLOAD_FOLDER']
@printer_bp.route('/prepare') @printer_bp.route('/prepare')
@login_required @login_required
def prepare(): def prepare():
client = get_octo_client()
# Query only the sliced GCode files belonging to the current user
user_files = PrintFile.query.filter_by(user_id=current_user.id, status='sliced').order_by(PrintFile.created_at.desc()).all()
files = [] files = []
error = None gcode_dir = get_gcode_dir()
client = get_octo_client()
octo_files_dict = {}
if client: if client:
try: try:
res = client.get_files() octo_resp = client.get_files()
files = res.get('files', []) for item in octo_resp.get('files', []):
octo_files_dict[item.get('name')] = item
except Exception as e: except Exception as e:
error = str(e) pass
else:
for f in user_files:
gcode_filename = f.filename.rsplit('.', 1)[0] + '.gcode'
gcode_path = os.path.join(gcode_dir, gcode_filename)
size = 0
f.meta_print_time = '-'
f.meta_first_layer_time = '-'
f.meta_filament_used = '-'
if os.path.exists(gcode_path):
size = os.path.getsize(gcode_path)
metadata = get_gcode_metadata(gcode_path)
f.meta_print_time = metadata.get('print_time', '-')
f.meta_first_layer_time = metadata.get('first_layer_time', '-')
f.meta_filament_used = metadata.get('filament_used', '-')
# Upload to OctoPrint if not found but exists locally
if client and gcode_filename not in octo_files_dict and size > 0:
try:
resp = client.upload_file('local', gcode_path, gcode_filename)
uploaded_loc = resp.get('files', {}).get('local', {})
if gcode_filename in uploaded_loc:
octo_files_dict[gcode_filename] = uploaded_loc[gcode_filename]
except Exception as e:
pass
octo_info = octo_files_dict.get(gcode_filename, {})
analysis = octo_info.get('gcodeAnalysis', None)
files.append({
'id': f.id,
'name': f.original_filename.rsplit('.', 1)[0] + '.gcode',
'type': 'machinecode',
'size': size,
'origin': 'local',
'path': gcode_filename,
'gcodeAnalysis': analysis,
'meta_print_time': f.meta_print_time,
'meta_first_layer_time': f.meta_first_layer_time,
'meta_filament_used': f.meta_filament_used
})
error = None
if not get_octo_client():
error = "OctoPrint is not configured." error = "OctoPrint is not configured."
return render_template('printer/prepare.html', files=files, error=error) return render_template('printer/prepare.html', files=files, error=error)
def check_printer_control_permission(client):
if current_user.is_admin:
return True, None
try:
status_data = client.get_printer_status()
state = status_data.get('state', {}).get('text', '')
active_states = ['Printing', 'Paused', 'Pausing', 'Resuming', 'Cancelling']
if state not in active_states:
return True, None
job_info = client.get_job_info()
internal_name = job_info.get('job', {}).get('file', {}).get('name')
if not internal_name:
return False, "现在有任务正在运行,非管理员无法进行控制。"
pf = PrintFile.query.filter_by(filename=internal_name).first()
if pf and pf.user_id == current_user.id:
return True, None
else:
return False, "现在有任务正在运行,您无权进行此操作。只有管理员或任务发起者可以进行控制。"
except Exception:
pass
return True, None
@printer_bp.route('/api/print_file', methods=['POST']) @printer_bp.route('/api/print_file', methods=['POST'])
@login_required @login_required
def api_print_file(): def api_print_file():
path = request.json.get('path') path = request.json.get('path')
location = request.json.get('origin', 'local') location = request.json.get('origin', 'local')
client = get_octo_client() client = get_octo_client()
if client and path: if client and path:
allowed, err_msg = check_printer_control_permission(client)
if not allowed:
return jsonify({"success": False, "error": err_msg})
try: try:
client.select_file(location, path, print_after_select=True) client.select_file(location, path, print_after_select=True)
return jsonify({"success": True}) return jsonify({"success": True})
@@ -66,29 +195,73 @@ def api_print_file():
return jsonify({"success": False, "error": "Not configured or missing path"}) return jsonify({"success": False, "error": "Not configured or missing path"})
@printer_bp.route('/control') @printer_bp.route('/control')
@login_required
def control(): def control():
client = get_octo_client() client = get_octo_client()
webcam_url = None webcam_url = None
error = None error = None
if client: if client:
try: try:
webcam_url = client.get_webcam_stream_url() raw_url = client.get_webcam_stream_url()
# If it's an absolute url pointing to the base url, strip it or proxy it via octo_proxy
parsed_raw = urlparse(raw_url)
base_config = SystemConfig.query.filter_by(key='octoprint_url').first()
if base_config and base_config.value:
base_url = base_config.value.rstrip('/')
parsed_base = urlparse(base_url)
# If they share the same host, replace with proxy
# Usually OctoPrint webcam streams are on the same host or relative
path = parsed_raw.path
if path.startswith('/'):
path = path[1:]
query = parsed_raw.query
# build proxy url
if query:
webcam_url = url_for('printer.octo_proxy', path=path) + '?' + query
else:
webcam_url = url_for('printer.octo_proxy', path=path)
else:
webcam_url = raw_url
except Exception as e: except Exception as e:
error = str(e) error = str(e)
else: else:
error = "OctoPrint is not configured." error = "OctoPrint is not configured."
return render_template('printer/control.html', webcam_url=webcam_url, error=error) return render_template('printer/control.html', webcam_url=webcam_url, error=error)
import re
import markdown
@printer_bp.route('/helper_printer')
def helper_printer():
lang = request.cookies.get('lang', 'en')
filepath = os.path.join(current_app.root_path, 'assets', 'doc', f'printer_helper_{lang}.md')
if not os.path.exists(filepath):
filepath = os.path.join(current_app.root_path, 'assets', 'doc', 'printer_helper_en.md')
content_html = ""
if os.path.exists(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
md_text = f.read()
content_html = markdown.markdown(md_text, extensions=['fenced_code', 'tables'])
# Rewrite relative image links to /assets/doc/
content_html = re.sub(r'src="(?!http|/)([^"]+)"', r'src="/assets/doc/\1"', content_html)
return render_template('printer/helper_printer.html', content_html=content_html)
@printer_bp.route('/api/command', methods=['POST']) @printer_bp.route('/api/command', methods=['POST'])
@login_required @login_required
def api_command(): def api_command():
cmd = request.json.get('command') cmd = request.json.get('command')
client = get_octo_client() client = get_octo_client()
if client and cmd: if client and cmd:
allowed, err_msg = check_printer_control_permission(client)
if not allowed:
return jsonify({"success": False, "error": err_msg})
try: try:
if cmd == 'home': if cmd == 'home':
client.home_axes() client.home_axes()
elif cmd == 'auto_level':
client.auto_leveling()
elif cmd == 'pause': elif cmd == 'pause':
client.pause_print() client.pause_print()
elif cmd == 'cancel': elif cmd == 'cancel':
@@ -98,6 +271,49 @@ def api_command():
return jsonify({"success": False, "error": str(e)}) return jsonify({"success": False, "error": str(e)})
return jsonify({"success": False, "error": "Invalid client or command"}) return jsonify({"success": False, "error": "Invalid client or command"})
@printer_bp.route('/api/upload_gcode', methods=['POST'])
@login_required
def upload_gcode():
if 'file' not in request.files:
return jsonify({"success": False, "error": "No file part"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"success": False, "error": "No selected file"}), 400
if not file.filename.lower().endswith(('.gcode', '.gco', '.g')):
return jsonify({"success": False, "error": "Only standard .gcode files are supported."}), 400
sec_name = secure_filename(file.filename)
random_prefix = uuid.uuid4().hex[:8]
# We save pseudo-STL filename so that our conventional parser works (replaces .ext with .gcode)
# i.e., "some_file.gcode" -> pseudo .stl tracker
pseudo_stl_filename = f"{random_prefix}_{sec_name.rsplit('.', 1)[0]}.stl"
gcode_filename = f"{random_prefix}_{sec_name.rsplit('.', 1)[0]}.gcode"
gcode_path = os.path.join(get_gcode_dir(), gcode_filename)
file.save(gcode_path)
print_file = PrintFile(
user_id=current_user.id,
original_filename=file.filename, # keep original GCode name
filename=pseudo_stl_filename,
file_type='stl',
status='sliced'
)
db.session.add(print_file)
db.session.commit()
client = get_octo_client()
if client:
try:
client.upload_file('local', gcode_path, gcode_filename)
except Exception:
pass
return jsonify({"success": True})
@printer_bp.route('/octo_config', methods=['GET', 'POST']) @printer_bp.route('/octo_config', methods=['GET', 'POST'])
@login_required @login_required
def octo_config(): def octo_config():
@@ -141,13 +357,13 @@ def octo_embed():
embed_url = url_for('printer.octo_proxy') if url and url.value else None embed_url = url_for('printer.octo_proxy') if url and url.value else None
return render_template('printer/octo_embed.html', embed_url=embed_url) return render_template('printer/octo_embed.html', embed_url=embed_url)
@printer_bp.route('/proxy', defaults={'path': ''}, websocket=True) @printer_bp.route('/proxy', defaults={'path': ''}, websocket=True, strict_slashes=False)
@printer_bp.route('/proxy/<path:path>', websocket=True) @printer_bp.route('/proxy/<path:path>', websocket=True)
@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) @printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], strict_slashes=False)
@printer_bp.route('/proxy/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) @printer_bp.route('/proxy/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
@login_required @login_required
def octo_proxy(path): def octo_proxy(path):
if not current_user.is_admin: if current_user.is_guest:
return "Unauthorized", 403 return "Unauthorized", 403
url_config = SystemConfig.query.filter_by(key='octoprint_url').first() url_config = SystemConfig.query.filter_by(key='octoprint_url').first()
@@ -159,12 +375,10 @@ def octo_proxy(path):
# print("----- REQUEST HEADERS -----") # print("----- REQUEST HEADERS -----")
# for k, v in request.headers: # for k, v in request.headers:
# print(f"{k}: {v}") # print(f"{k}: {v}")
# print("----- END HEADERS -----") # print("----- END REQUEST HEADERS -----")
# --- WebSocket Proxy Logic --- # --- WebSocket Proxy Logic ---
if request.headers.get('Upgrade', '').lower() == 'websocket': if request.headers.get('Upgrade', '').lower() == 'websocket':
from flask_sock import Server, ConnectionClosed
# Check if environment supports WebSockets # Check if environment supports WebSockets
try: try:
ws = Server(request.environ) ws = Server(request.environ)
@@ -210,7 +424,6 @@ def octo_proxy(path):
remote_ws = ws_connect(target_url, additional_headers=ws_headers) remote_ws = ws_connect(target_url, additional_headers=ws_headers)
print("WS Proxy connected to remote.") print("WS Proxy connected to remote.")
except Exception as e: except Exception as e:
import traceback
traceback.print_exc() traceback.print_exc()
print(f"Remote WS Connection Error: {e}") print(f"Remote WS Connection Error: {e}")
ws.close(1011, str(e)) ws.close(1011, str(e))
@@ -263,14 +476,15 @@ def octo_proxy(path):
class WebSocketResponse(Response): class WebSocketResponse(Response):
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
print("WS Response __call__") print("WS Response __call__")
if getattr(ws, 'mode', 'werkzeug') == 'werkzeug': if getattr(ws, 'mode', 'werkzeug') == 'gunicorn':
raise StopIteration()
elif getattr(ws, 'mode', 'werkzeug') == 'werkzeug':
return super().__call__(*args, **kwargs) return super().__call__(*args, **kwargs)
return [] return []
return WebSocketResponse() return WebSocketResponse()
# --- Standard HTTP Proxy Logic --- # --- Standard HTTP Proxy Logic ---
# from urllib.parse import urlparse
target_url = f"{base_url}/{path}" target_url = f"{base_url}/{path}"
if request.query_string: if request.query_string:
@@ -278,17 +492,26 @@ def octo_proxy(path):
# Build headers for reverse proxy based on nginx config reference # Build headers for reverse proxy based on nginx config reference
parsed_base = urlparse(base_url) parsed_base = urlparse(base_url)
headers = {k: v for k, v in request.headers if k.lower() not in ['host', 'content-length']} headers = {k: v for k, v in request.headers if k.lower() not in ['host', 'content-length', 'origin', 'referer']}
# NGINX equivalent proxy headers # print(f"Proxying to: {target_url}")
# Spoof Host, Origin, and Referer to match the backend URL completely
# This prevents Tornado's strict Origin vs Host CSRF/CORS validation from failing
# headers['Host'] = parsed_base.netloc
headers['Host'] = request.host headers['Host'] = request.host
if 'Origin' in request.headers:
headers['Origin'] = base_url
if 'Referer' in request.headers:
headers['Referer'] = f"{base_url}/{path}"
headers['X-Real-IP'] = request.remote_addr headers['X-Real-IP'] = request.remote_addr
headers['X-Real-Port'] = str(request.environ.get('REMOTE_PORT', '')) headers['X-Real-Port'] = str(request.environ.get('REMOTE_PORT', ''))
forwarded_for = request.headers.get('X-Forwarded-For', '') forwarded_for = request.headers.get('X-Forwarded-For', '')
headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr
headers['X-Forwarded-Protocol'] = request.scheme headers['X-Forwarded-Proto'] = request.scheme
headers['X-Script-Name'] = "/printer/proxy" headers['X-Script-Name'] = "/printer/proxy"
headers['X-Forwarded-Host'] = request.host headers['X-Forwarded-Host'] = request.host
headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80')) headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))
@@ -299,6 +522,27 @@ def octo_proxy(path):
if request.headers.get('Connection'): if request.headers.get('Connection'):
headers['Connection'] = request.headers.get('Connection') headers['Connection'] = request.headers.get('Connection')
# OctoPrint requires an X-CSRF-Token header matching the csrf_token_* cookie for POST/PUT/DELETE
if request.method not in ['GET', 'HEAD', 'OPTIONS'] and 'X-CSRF-Token' not in request.headers:
for cookie_name, cookie_value in request.cookies.items():
if cookie_name.startswith('csrf_token_'):
headers['X-CSRF-Token'] = cookie_value
break
# if path == 'api/login':
# print("----- SEND HEADERS -----")
# for k, v in headers.items():
# if k in request.headers.keys():
# print(f"{k} :(from request): {request.headers[k]} (to send): {v}")
# else:
# print(f"{k} :(from request): None (to send): {v}")
# for k,v in request.headers.items():
# if k not in headers.keys():
# print(f"{k} :(from request): {request.headers[k]} (to send): None")
# print("----- END SEND HEADERS -----")
try: try:
# proxy_connect_timeout 60s, proxy_read_timeout 600s # proxy_connect_timeout 60s, proxy_read_timeout 600s
resp = requests.request( resp = requests.request(
@@ -316,7 +560,15 @@ def octo_proxy(path):
# Strip headers that might break the iframe or framing # Strip headers that might break the iframe or framing
excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'x-frame-options', 'content-security-policy'] excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'x-frame-options', 'content-security-policy']
response_headers = [(name, value) for (name, value) in resp.headers.items() if name.lower() not in excluded_headers]
# We must use raw headers to prevent requests from joining multiple Set-Cookie headers with a comma
# Joining Set-Cookie with a comma breaks standard cookie parsing in the browser due to commas in dates
response_headers = [(name, value) for name, value in resp.raw.headers.items()
if name.lower() not in excluded_headers and name.lower() != 'set-cookie']
for cookie in resp.raw.headers.get_all('set-cookie', []):
# ensure we preserve the proxy path override
response_headers.append(('Set-Cookie', cookie))
def generate(): def generate():
for chunk in resp.iter_content(chunk_size=8192): for chunk in resp.iter_content(chunk_size=8192):

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>{{ _('API Keys Management') }}</h2>
<div class="card mb-4">
<div class="card-header">{{ _('Create New API Key') }}</div>
<div class="card-body">
<form action="{{ url_for('admin.add_api_key') }}" method="POST" class="form-inline">
<input type="text" name="name" class="form-control mb-2 mr-sm-2" placeholder="{{ _('Key Name') }}" required>
<button type="submit" class="btn btn-primary mb-2">{{ _('Generate Key') }}</button>
</form>
</div>
</div>
<table class="table table-bordered">
<thead>
<tr>
<th>{{ _('ID') }}</th>
<th>{{ _('API Key Name') }}</th>
<th>{{ _('API Key') }}</th>
<th>{{ _('Created At') }}</th>
<th>{{ _('Action') }}</th>
</tr>
</thead>
<tbody>
{% for key in keys %}
<tr>
<td>{{ key.id }}</td>
<td>{{ key.name }}</td>
<td><code>{{ key.key }}</code></td>
<td>{{ key.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>
<form action="{{ url_for('admin.delete_api_key', key_id=key.id) }}" method="POST" style="display:inline;" onsubmit="event.preventDefault(); window.customConfirm('{{ _('Are you sure you want to delete this API Key?') }}', () => { this.submit(); });">
<button type="submit" class="btn btn-danger btn-sm">{{ _('Delete') }}</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center">{{ _('No API keys found.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -7,29 +7,47 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<h5>{{ _('CuraEngine Configurations') }}</h5> <h5 class="card-title text-primary border-bottom pb-2 mt-4 mb-3">{{ _('SliceEngine Configurations') }}</h5>
<hr>
<form id="settingsForm" onsubmit="submitSettings(event)"> <form id="settingsForm" onsubmit="submitSettings(event)">
<div class="mb-3"> <div class="mb-3">
<label for="offset_x" class="form-label">{{ _('Plater Origin Offset X (mm)') }}</label> <label for="offset_x" class="form-label">{{ _('Plater Origin Offset X (mm)') }}</label>
<input type="number" class="form-control" name="offset_x" id="offset_x" value="{{ configs.get('offset_x', '0') }}"> <input type="number" class="form-control" name="offset_x" id="offset_x" value="{{ configs.get('offset_x', '0') }}">
<div class="form-text">{{ _('Adjust the X-axis compilation offset for combined files on the build plate.') }}</div> <div class="form-text"><i class="bi bi-info-circle me-1"></i>{{ _('Adjust the X-axis compilation offset for combined files on the build plate.') }}</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="offset_y" class="form-label">{{ _('Plater Origin Offset Y (mm)') }}</label> <label for="offset_y" class="form-label">{{ _('Plater Origin Offset Y (mm)') }}</label>
<input type="number" class="form-control" name="offset_y" id="offset_y" value="{{ configs.get('offset_y', '0') }}"> <input type="number" class="form-control" name="offset_y" id="offset_y" value="{{ configs.get('offset_y', '0') }}">
<div class="form-text">{{ _('Adjust the Y-axis compilation offset for combined files on the build plate.') }}</div> <div class="form-text"><i class="bi bi-info-circle me-1"></i>{{ _('Adjust the Y-axis compilation offset for combined files on the build plate.') }}</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="proxy_skip_size_mb" class="form-label">{{ _('Proxy Skip Size (MB)') }}</label> <label for="proxy_skip_size_mb" class="form-label">{{ _('Proxy Skip Size (MB)') }}</label>
<input type="number" class="form-control" name="proxy_skip_size_mb" id="proxy_skip_size_mb" value="{{ configs.get('proxy_skip_size_mb', '5.0') }}" step="0.1" min="0"> <input type="number" class="form-control" name="proxy_skip_size_mb" id="proxy_skip_size_mb" value="{{ configs.get('proxy_skip_size_mb', '5.0') }}" step="0.1" min="0">
<div class="form-text">{{ _('Files smaller than this will not generate a simplified proxy.') }}</div> <div class="form-text"><i class="bi bi-info-circle me-1"></i>{{ _('Files smaller than this will not generate a simplified proxy.') }}</div>
</div> </div>
<h5 class="mt-4">{{ _('Default Plater Settings') }}</h5> <div class="mb-3">
<hr> <label for="gcode_upload_folder" class="form-label"><i class="bi bi-folder2-open me-2"></i>{{ _('Custom GCode Output Folder') }}</label>
<input type="text" class="form-control" name="gcode_upload_folder" id="gcode_upload_folder" value="{{ configs.get('gcode_upload_folder', '') }}">
<div class="form-text"><i class="bi bi-info-circle me-1"></i>{{ _('Absolute path to save locally sliced GCode files (e.g. OctoPrint uploads folder like "/home/pi/.octoprint/uploads"). Leave empty to use system default.') }}</div>
</div>
<h5 class="card-title text-primary border-bottom pb-2 mt-4 mb-3"><i class="bi bi-grid-3x3 me-2"></i>{{ _('Default Plater Settings') }}</h5>
<div class="mb-3">
<label for="build_plate_model_path" class="form-label">{{ _('Build Plate Model Path (.stl)') }}</label>
<input type="text" class="form-control" name="build_plate_model_path" id="build_plate_model_path" value="{{ configs.get('build_plate_model_path', '') }}">
<div class="form-text"><i class="bi bi-info-circle me-1"></i>{{ _('Absolute path to a custom build plate STL model to show in the plater. Leave empty to use none.') }}</div>
</div>
<div class="mb-3">
<label for="default_printer" class="form-label">{{ _('Default Printer Profile') }}</label>
<select class="form-select" name="default_printer" id="default_printer" data-selected="{{ configs.get('default_printer', '') }}">
<!-- Loaded via JS -->
</select>
<div class="form-text"><i class="bi bi-info-circle me-1"></i>{{ _('Main configuration for the printer dimensions, limits and base profiles.') }}</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="default_infill" class="form-label">{{ _('Default Infill Density (%)') }}</label> <label for="default_infill" class="form-label">{{ _('Default Infill Density (%)') }}</label>
@@ -47,29 +65,64 @@
<div class="mb-3"> <div class="mb-3">
<label for="default_support_pattern" class="form-label">{{ _('Default Support Type') }}</label> <label for="default_support_pattern" class="form-label">{{ _('Default Support Type') }}</label>
<select class="form-select" name="default_support_pattern" id="default_support_pattern"> <select class="form-select" name="default_support_pattern" id="default_support_pattern" data-selected="{{ configs.get('default_support_pattern', 'tree') }}">
<option value="tree" {% if configs.get('default_support_pattern', 'tree') == 'tree' %}selected{% endif %}>{{ _('Tree') }}</option> <!-- Loaded via JS -->
<option value="lines" {% if configs.get('default_support_pattern', 'tree') == 'lines' %}selected{% endif %}>{{ _('Lines') }}</option> </select>
<option value="grid" {% if configs.get('default_support_pattern', 'tree') == 'grid' %}selected{% endif %}>{{ _('Grid') }}</option> </div>
<option value="triangles" {% if configs.get('default_support_pattern', 'tree') == 'triangles' %}selected{% endif %}>{{ _('Triangles') }}</option>
<option value="concentric" {% if configs.get('default_support_pattern', 'tree') == 'concentric' %}selected{% endif %}>{{ _('Concentric') }}</option> <div class="mb-3">
<option value="zigzag" {% if configs.get('default_support_pattern', 'tree') == 'zigzag' %}selected{% endif %}>{{ _('Zig Zag') }}</option> <label for="default_quality" class="form-label">{{ _('Default Quality Profile') }}</label>
<option value="cross" {% if configs.get('default_support_pattern', 'tree') == 'cross' %}selected{% endif %}>{{ _('Cross') }}</option> <select class="form-select" name="default_quality" id="default_quality" data-selected="{{ configs.get('default_quality', 'base_global_standard.inst.cfg') }}">
<option value="gyroid" {% if configs.get('default_support_pattern', 'tree') == 'gyroid' %}selected{% endif %}>{{ _('Gyroid') }}</option> <!-- Loaded via JS -->
<option value="honeycomb" {% if configs.get('default_support_pattern', 'tree') == 'honeycomb' %}selected{% endif %}>{{ _('Honeycomb') }}</option>
<option value="octagon" {% if configs.get('default_support_pattern', 'tree') == 'octagon' %}selected{% endif %}>{{ _('Octagon') }}</option>
</select> </select>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label for="default_quality" class="form-label">{{ _('Default Quality Profile') }}</label> <label for="default_material" class="form-label">{{ _('Default Material Profile') }}</label>
<select class="form-select" name="default_quality" id="default_quality"> <select class="form-select" name="default_material" id="default_material" data-selected="{{ configs.get('default_material', '') }}">
{% for key, name in presets %} <!-- Loaded via JS -->
<option value="{{ key }}" {% if configs.get('default_quality', 'base_global_standard.inst.cfg') == key %}selected{% endif %}>{{ _(name) }}</option>
{% endfor %}
</select> </select>
</div> </div>
<h5 class="card-title text-primary border-bottom pb-2 mt-4 mb-3"><i class="bi bi-cpu me-2"></i>{{ _('Slicing Engine Configurations') }}</h5>
<div class="mb-3">
<label for="slicer_engine" class="form-label">{{ _('Slicing Engine') }}</label>
<select class="form-select" name="slicer_engine" id="slicer_engine">
{% for engine in engines %}
<option value="{{ engine.name }}" {% if configs.get('slicer_engine', 'cura') == engine.name %}selected{% endif %} {% if not engine.is_available %}disabled{% endif %}>
{{ engine.display_name }} {% if not engine.is_available %}({{ _('Not Available') }}){% endif %}
</option>
{% endfor %}
</select>
<div class="form-text mt-2"><i class="bi bi-info-circle me-1"></i>{{ _('Select the engine to be used globally. Ensure the selected engine is installed and accessible on the server.') }}</div>
</div>
<h5 class="card-title text-primary border-bottom pb-2 mt-4 mb-3"><i class="bi bi-hdd-network me-2"></i>{{ _('Default Storage Quotas (MB)') }}</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">{{ _('Guest STL Quota') }} <small class="text-muted">(0 = {{ _('Unlimited') }})</small></label>
<input type="number" class="form-control" name="default_guest_stl_quota_mb" value="{{ configs.get('default_guest_stl_quota_mb', '0') }}" min="0">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">{{ _('Guest GCode Quota') }} <small class="text-muted">(0 = {{ _('Unlimited') }})</small></label>
<input type="number" class="form-control" name="default_guest_gcode_quota_mb" value="{{ configs.get('default_guest_gcode_quota_mb', '0') }}" min="0">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">{{ _('New User STL Quota') }} <small class="text-muted">(0 = {{ _('Unlimited') }})</small></label>
<input type="number" class="form-control" name="default_user_stl_quota_mb" value="{{ configs.get('default_user_stl_quota_mb', '0') }}" min="0">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">{{ _('New User GCode Quota') }} <small class="text-muted">(0 = {{ _('Unlimited') }})</small></label>
<input type="number" class="form-control" name="default_user_gcode_quota_mb" value="{{ configs.get('default_user_gcode_quota_mb', '0') }}" min="0">
</div>
</div>
<button type="submit" class="btn btn-primary" id="btn-save-settings">{{ _('Save Settings') }}</button> <button type="submit" class="btn btn-primary" id="btn-save-settings">{{ _('Save Settings') }}</button>
</form> </form>
</div> </div>
@@ -109,5 +162,69 @@ function submitSettings(event) {
btn.innerHTML = originalText; btn.innerHTML = originalText;
}); });
} }
document.addEventListener('DOMContentLoaded', function() {
const engineSelect = document.getElementById('slicer_engine');
const qualitySelect = document.getElementById('default_quality');
const materialSelect = document.getElementById('default_material');
const patternSelect = document.getElementById('default_support_pattern');
const printerSelect = document.getElementById('default_printer');
function updateOptions(engine) {
fetch(`/api/engine_options/${engine}`)
.then(res => res.json())
.then(data => {
const selPtr = printerSelect.getAttribute('data-selected');
printerSelect.innerHTML = '';
if(data.printers) {
data.printers.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id; opt.textContent = p.name;
printerSelect.appendChild(opt);
});
}
if(selPtr) printerSelect.value = selPtr;
const selQ = qualitySelect.getAttribute('data-selected');
qualitySelect.innerHTML = '';
data.presets.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id; opt.textContent = p.name;
qualitySelect.appendChild(opt);
});
if(selQ) qualitySelect.value = selQ;
const selM = materialSelect.getAttribute('data-selected');
materialSelect.innerHTML = '';
// Add an empty option for material (optional fallback)
const emptyOpt = document.createElement('option');
emptyOpt.value = ''; emptyOpt.textContent = "{{ _('Auto / Default') }}";
materialSelect.appendChild(emptyOpt);
if(data.materials) {
data.materials.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id; opt.textContent = p.name;
materialSelect.appendChild(opt);
});
}
if(selM) materialSelect.value = selM;
const selP = patternSelect.getAttribute('data-selected');
patternSelect.innerHTML = '';
data.support_patterns.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id; opt.textContent = p.name;
patternSelect.appendChild(opt);
});
if(selP) patternSelect.value = selP;
});
}
engineSelect.addEventListener('change', function() {
updateOptions(this.value);
});
// Initial load
updateOptions(engineSelect.value);
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -5,6 +5,9 @@
<h1 class="h2">{{ _('User Management') }}</h1> <h1 class="h2">{{ _('User Management') }}</h1>
</div> </div>
<div class="mb-3 text-end">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addUserModal"><i class="bi bi-person-plus me-1"></i>{{ _('Add User') }}</button>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped table-sm"> <table class="table table-striped table-sm">
<thead> <thead>
@@ -12,6 +15,7 @@
<th>{{ _('ID') }}</th> <th>{{ _('ID') }}</th>
<th>{{ _('Username') }}</th> <th>{{ _('Username') }}</th>
<th>{{ _('Role') }}</th> <th>{{ _('Role') }}</th>
<th>{{ _('Quotas') }}</th>
<th>{{ _('Created At') }}</th> <th>{{ _('Created At') }}</th>
<th>{{ _('Actions') }}</th> <th>{{ _('Actions') }}</th>
</tr> </tr>
@@ -30,8 +34,33 @@
<span class="badge bg-primary">{{ _('User') }}</span> <span class="badge bg-primary">{{ _('User') }}</span>
{% endif %} {% endif %}
</td> </td>
<td>
{% if user.is_admin %}
<small class="text-muted d-block">STL: {{ _('Unlimited') }}</small>
<small class="text-muted d-block">GCode: {{ _('Unlimited') }}</small>
{% else %}
<small class="text-muted d-block">STL:
{% if user_quotas[user.id]['stl'] == '0' %}
{{ _('Default') }} ({{ user_quotas[user.id]['eff_stl'] if user_quotas[user.id]['eff_stl'] != '0' else _('Unlimited') }} MB)
{% else %}
{{ user_quotas[user.id]['stl'] }} MB
{% endif %}
</small>
<small class="text-muted d-block">GCode:
{% if user_quotas[user.id]['gcode'] == '0' %}
{{ _('Default') }} ({{ user_quotas[user.id]['eff_gcode'] if user_quotas[user.id]['eff_gcode'] != '0' else _('Unlimited') }} MB)
{% else %}
{{ user_quotas[user.id]['gcode'] }} MB
{% endif %}
</small>
{% endif %}
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td> <td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td> <td>
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editQuotaModal{{ user.id }}">{{ _('Edit Quota') }}</button>
{% if not user.is_guest %}
<button class="btn btn-sm btn-outline-info" data-bs-toggle="modal" data-bs-target="#resetPwdModal{{ user.id }}">{{ _('Reset Password') }}</button>
{% endif %}
<form action="{{ url_for('admin.delete_user', user_id=user.id) }}" method="POST" class="d-inline" onsubmit="event.preventDefault(); window.customConfirm('{{ _('WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?') }}', () => { this.submit(); });"> <form action="{{ url_for('admin.delete_user', user_id=user.id) }}" method="POST" class="d-inline" onsubmit="event.preventDefault(); window.customConfirm('{{ _('WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?') }}', () => { this.submit(); });">
<button type="submit" class="btn btn-sm btn-outline-danger" {% if user.id == current_user.id %}disabled{% endif %}>{{ _('Delete') }}</button> <button type="submit" class="btn btn-sm btn-outline-danger" {% if user.id == current_user.id %}disabled{% endif %}>{{ _('Delete') }}</button>
</form> </form>
@@ -41,4 +70,110 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% for user in users %}
<!-- Edit Quota Modal -->
<div class="modal fade" id="editQuotaModal{{ user.id }}" tabindex="-1" aria-labelledby="editQuotaModalLabel{{ user.id }}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ url_for('admin.update_quota', user_id=user.id) }}" method="POST">
<div class="modal-header">
<h5 class="modal-title" id="editQuotaModalLabel{{ user.id }}">{{ _('Edit Quota for') }} {{ user.username }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">{{ _('STL Quota') }} (MB) <small class="text-muted">(0 = {{ _('Default') }})</small></label>
<input type="number" class="form-control" name="stl_quota_mb" value="{{ user_quotas[user.id]['stl'] }}" min="0">
</div>
<div class="mb-3">
<label class="form-label">{{ _('GCode Quota') }} (MB) <small class="text-muted">(0 = {{ _('Default') }})</small></label>
<input type="number" class="form-control" name="gcode_quota_mb" value="{{ user_quotas[user.id]['gcode'] }}" min="0">
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">{{ _('Save') }}</button>
</div>
</form>
</div>
</div>
</div>
{% if not user.is_guest %}
<!-- Reset Password Modal -->
<div class="modal fade" id="resetPwdModal{{ user.id }}" tabindex="-1" aria-labelledby="resetPwdModalLabel{{ user.id }}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ url_for('admin.reset_password', user_id=user.id) }}" method="POST">
<div class="modal-header">
<h5 class="modal-title" id="resetPwdModalLabel{{ user.id }}">{{ _('Reset Password for') }} {{ user.username }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">{{ _('New Password') }}</label>
<input type="password" class="form-control" name="password" required>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">{{ _('Save') }}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endfor %}
<!-- Add User Modal -->
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ url_for('admin.add_user') }}" method="POST">
<div class="modal-header">
<h5 class="modal-title" id="addUserModalLabel">{{ _('Add User') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">{{ _('Username') }}</label>
<input type="text" class="form-control" name="username" required>
</div>
<div class="mb-3">
<label class="form-label">{{ _('Password') }}</label>
<input type="password" class="form-control" name="password" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" name="is_admin" id="isAdminCheck">
<label class="form-check-label" for="isAdminCheck">{{ _('Is Admin') }}</label>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">{{ _('Create User') }}</button>
</div>
</form>
</div>
</div>
</div>
<!-- Bootstrap Modal Z-Index Fix -->
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.modal').forEach(function(modal) {
document.body.appendChild(modal);
});
});
</script>
<!-- Bootstrap Modal Z-Index Fix -->
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.modal').forEach(function(modal) {
document.body.appendChild(modal);
});
});
</script>
{% endblock %} {% endblock %}

View File

@@ -5,19 +5,27 @@
<div class="col-md-6 mt-5"> <div class="col-md-6 mt-5">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-primary text-white"> <div class="card-header bg-primary text-white">
<h4 class="mb-0">Login</h4> <h4 class="mb-0">{{ _('Login') }}</h4>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('auth.login') }}"> <form method="POST" action="{{ url_for('auth.login') }}">
<div class="mb-3"> <div class="mb-3">
<label for="username" class="form-label">Username</label> <label for="username" class="form-label">{{ _('Username') }}</label>
<input type="text" class="form-control" name="username" id="username" required> <input type="text" class="form-control" name="username" id="username" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="password" class="form-label">Password</label> <label for="password" class="form-label">{{ _('Password') }}</label>
<input type="password" class="form-control" name="password" id="password" required> <input type="password" class="form-control" name="password" id="password" required>
</div> </div>
<button type="submit" class="btn btn-primary w-100">Login</button> <div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" name="remember" id="remember">
<label class="form-check-label" for="remember">{{ _('Remember Me') }}</label>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" name="merge_data" id="merge_data" checked>
<label class="form-check-label" for="merge_data">{{ _('Merge Guest Data') }}</label>
</div>
<button type="submit" class="btn btn-primary w-100">{{ _('Login') }}</button>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AIO 3D Slicer</title> <title>AIO 3D Slicer</title>
<link href="{{ url_for('static', filename='img/favicon.ico') }}" rel="icon" type="image/x-icon">
<!-- Bootstrap 5 CSS --> <!-- Bootstrap 5 CSS -->
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet"> <link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">
<!-- Bootstrap Icons --> <!-- Bootstrap Icons -->
@@ -22,7 +23,17 @@
.card-header { border-bottom: 1px solid rgba(0,0,0,.05); background-color: transparent; } .card-header { border-bottom: 1px solid rgba(0,0,0,.05); background-color: transparent; }
.toast-container { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 1055; width: auto; max-width: 90%; pointer-events: none; } .toast-container { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 1055; width: auto; max-width: 90%; pointer-events: none; }
.toast { border-radius: 0.5rem; box-shadow: 0 0.5rem 1rem rgba(0,0,0,.25); opacity: 1 !important; pointer-events: auto; } .toast {
border-radius: 0.5rem;
box-shadow: 0 0.5rem 1rem rgba(0,0,0,.25);
pointer-events: auto;
border: none;
transform: translateY(-20px);
transition: opacity 0.35s ease, transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important;
}
.toast.showing, .toast.show {
transform: translateY(0);
}
/* 页面切换动画 Page Transition */ /* 页面切换动画 Page Transition */
@keyframes pageFadeInSlide { @keyframes pageFadeInSlide {
@@ -33,11 +44,23 @@
/* 提升 Accordion 折叠栏动画更平滑 */ /* 提升 Accordion 折叠栏动画更平滑 */
.collapsing { transition: height 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) !important; } .collapsing { transition: height 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) !important; }
/* 移动端适配 */
@media (max-width: 767.98px) {
body { padding-top: 126px !important; }
.sidebar { top: 126px; width: 100%; border-bottom: 1px solid #ddd; box-shadow: 0 4px 6px rgba(0,0,0,.1); }
.sidebar-sticky { height: calc(100vh - 126px); }
.mobile-subnav { display: flex !important; }
}
@media (max-width: 454.98px) {
.navbar-brand { display: none !important; }
}
</style> </style>
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top shadow-sm"> <div class="fixed-top">
<div class="container-fluid position-relative d-flex justify-content-between align-items-center"> <nav class="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm">
<div class="container-fluid position-relative d-flex justify-content-between align-items-center">
<a class="navbar-brand fw-bold" href="{{ url_for('main.index') }}"><i class="bi bi-printer me-2"></i>AIO 3D Slicer</a> <a class="navbar-brand fw-bold" href="{{ url_for('main.index') }}"><i class="bi bi-printer me-2"></i>AIO 3D Slicer</a>
<div class="d-none d-md-flex mx-auto" style="position: absolute; left: 50%; transform: translateX(-50%);"> <div class="d-none d-md-flex mx-auto" style="position: absolute; left: 50%; transform: translateX(-50%);">
@@ -55,6 +78,7 @@
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="langDropdown"> <ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="langDropdown">
<li><a class="dropdown-item {% if request.cookies.get('lang') == 'en' %}active{% endif %}" href="{{ url_for('main.set_language', lang='en') }}"><i class="bi bi-translate me-2"></i>English</a></li> <li><a class="dropdown-item {% if request.cookies.get('lang') == 'en' %}active{% endif %}" href="{{ url_for('main.set_language', lang='en') }}"><i class="bi bi-translate me-2"></i>English</a></li>
<li><a class="dropdown-item {% if request.cookies.get('lang') == 'zh-cn' %}active{% endif %}" href="{{ url_for('main.set_language', lang='zh-cn') }}"><i class="bi bi-translate me-2"></i>中文 (简体)</a></li> <li><a class="dropdown-item {% if request.cookies.get('lang') == 'zh-cn' %}active{% endif %}" href="{{ url_for('main.set_language', lang='zh-cn') }}"><i class="bi bi-translate me-2"></i>中文 (简体)</a></li>
<li><a class="dropdown-item {% if request.cookies.get('lang') == 'de' %}active{% endif %}" href="{{ url_for('main.set_language', lang='de') }}"><i class="bi bi-translate me-2"></i>Deutsch</a></li>
</ul> </ul>
</div> </div>
@@ -71,6 +95,18 @@
</div> </div>
</nav> </nav>
<!-- 移动端专属二级导航栏 -->
<div class="d-none mobile-subnav align-items-center bg-white border-bottom px-3 py-2 shadow-sm d-md-none" style="justify-content: space-between;">
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-expanded="false" aria-controls="sidebarMenu">
<i class="bi bi-list fs-4"></i>
</button>
<div class="btn-group border border-secondary shadow-sm rounded-pill p-1 bg-dark" role="group" style="background-color: #1a1e21 !important;">
<a href="{{ url_for('main.files') }}" class="btn btn-sm rounded-pill {% if not request.blueprint == 'printer' %}btn-primary active fw-bold text-white px-3{% else %}btn-transparent text-secondary border-0 px-2{% endif %}" style="transition: all 0.2s;"><i class="bi bi-layers me-1"></i>{{ _('Slicer') }}</a>
<a href="{{ url_for('printer.status') }}" class="btn btn-sm rounded-pill {% if request.blueprint == 'printer' %}btn-primary active fw-bold text-white px-3{% else %}btn-transparent text-secondary border-0 px-2{% endif %}" style="transition: all 0.2s;"><i class="bi bi-printer-fill me-1"></i>{{ _('Printer') }}</a>
</div>
</div>
</div>
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-white sidebar collapse border-end"> <nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-white sidebar collapse border-end">
@@ -96,6 +132,18 @@
<i class="bi bi-arrows-move me-2"></i>{{ _('Control') }} <i class="bi bi-arrows-move me-2"></i>{{ _('Control') }}
</a> </a>
</li> </li>
<li class="nav-item mb-1">
<a class="nav-link text-dark {% if request.endpoint == 'printer.helper_printer' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.helper_printer') }}">
<i class="bi bi-question-square me-2"></i>{{ _('Printer Helper') }}
</a>
</li>
<!-- {% if current_user.is_authenticated and not current_user.is_guest %}
<li class="nav-item mb-1">
<a class="nav-link text-dark {% if request.endpoint == 'main.account' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.account') }}">
<i class="bi bi-person-badge me-2"></i>{{ _('Account Management') }}
</a>
</li>
{% endif %} -->
</ul> </ul>
{% if current_user.is_authenticated and current_user.is_admin %} {% if current_user.is_authenticated and current_user.is_admin %}
@@ -117,6 +165,9 @@
{% endif %} {% endif %}
{% else %} {% else %}
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mb-2 text-muted fw-bold text-uppercase" style="font-size: 0.75rem;">
<span><i class="bi bi-list-task me-1"></i>{{ _('General Operations') }}</span>
</h6>
<ul class="nav flex-column nav-pills gap-1"> <ul class="nav flex-column nav-pills gap-1">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark {% if request.endpoint == 'main.index' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.index') }}"> <a class="nav-link text-dark {% if request.endpoint == 'main.index' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.index') }}">
@@ -133,6 +184,18 @@
<i class="bi bi-grid-3x3 me-2"></i>{{ _('Plater') }} <i class="bi bi-grid-3x3 me-2"></i>{{ _('Plater') }}
</a> </a>
</li> </li>
{% if current_user.is_authenticated and not current_user.is_guest %}
<li class="nav-item mb-1">
<a class="nav-link text-dark {% if request.endpoint == 'main.account' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.account') }}">
<i class="bi bi-person-badge me-2"></i>{{ _('Account Management') }}
</a>
</li>
{% endif %}
<li class="nav-item mb-1">
<a class="nav-link text-dark {% if request.endpoint == 'main.helper_slice' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.helper_slice') }}">
<i class="bi bi-question-square me-2"></i>{{ _('Slice Helper') }}
</a>
</li>
</ul> </ul>
{% if current_user.is_authenticated and current_user.is_admin %} {% if current_user.is_authenticated and current_user.is_admin %}
@@ -150,6 +213,11 @@
<i class="bi bi-people me-2"></i>{{ _('User Management') }} <i class="bi bi-people me-2"></i>{{ _('User Management') }}
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link text-dark {% if request.endpoint == 'admin.api_keys' %}active text-white shadow-sm{% endif %}" href="{{ url_for('admin.api_keys') }}">
<i class="bi bi-key me-2"></i>{{ _('API Keys') }}
</a>
</li>
</ul> </ul>
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -158,31 +226,30 @@
</nav> </nav>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 mt-4 bg-light min-vh-100 pb-5"> <main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 mt-4 bg-light min-vh-100 pb-5">
<!-- Toast Notification Container -->
<div class="toast-container" id="global-toast-container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
{% set toast_class = 'bg-success text-white' if category == 'success' else 'bg-danger text-white' if category == 'danger' else 'bg-warning text-dark' if category == 'warning' else 'bg-primary text-white' %}
<div class="toast align-items-center border-0 {{ toast_class }} mb-2" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body fw-medium">
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</div> </div>
</div> </div>
<!-- Toast Notification Container -->
<div class="toast-container" id="global-toast-container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
{% set toast_class = 'bg-success text-white' if category == 'success' else 'bg-danger text-white' if category == 'danger' else 'bg-warning text-dark' if category == 'warning' else 'bg-primary text-white' %}
<div class="toast align-items-center border-0 {{ toast_class }} mb-2" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body fw-medium">
{{ _(message) if _ else message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<!-- Global Custom Alert Modal --> <!-- Global Custom Alert Modal -->
<div class="modal fade" id="globalAlertModal" tabindex="-1" aria-hidden="true" style="z-index: 1060;"> <div class="modal fade" id="globalAlertModal" tabindex="-1" aria-hidden="true" style="z-index: 1060;">
<div class="modal-dialog modal-dialog-centered modal-sm"> <div class="modal-dialog modal-dialog-centered modal-sm">

View File

@@ -17,8 +17,15 @@
<div class="card-header bg-dark text-light fw-bold rounded-top"> <div class="card-header bg-dark text-light fw-bold rounded-top">
<i class="bi bi-camera-video me-1"></i>{{ _('Live Webcam') }} <i class="bi bi-camera-video me-1"></i>{{ _('Live Webcam') }}
</div> </div>
<div class="card-body p-0 ratio ratio-16x9"> <div class="card-body p-0 ratio ratio-16x9 bg-secondary bg-opacity-25 d-flex align-items-center justify-content-center">
{% if current_user.is_guest %}
<div class="text-center text-dark">
<i class="bi bi-lock-fill display-4 d-block mb-3"></i>
<h5 class="mb-0">{{ _('Please login to view the webcam stream.') }}</h5>
</div>
{% else %}
<img src="{{ webcam_url }}" alt="{{ _('Loading webcam stream...') }}" class="w-100 h-100 object-fit-cover"> <img src="{{ webcam_url }}" alt="{{ _('Loading webcam stream...') }}" class="w-100 h-100 object-fit-cover">
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -30,11 +37,23 @@
<i class="bi bi-dpad me-1"></i>{{ _('Basic Control') }} <i class="bi bi-dpad me-1"></i>{{ _('Basic Control') }}
</div> </div>
<div class="card-body text-center d-flex flex-column justify-content-center align-items-center"> <div class="card-body text-center d-flex flex-column justify-content-center align-items-center">
<!-- Home button --> <!-- Motion Controls -->
<button class="btn btn-lg btn-primary rounded-circle mb-4 shadow" style="width: 80px; height: 80px;" onclick="sendCommand('home')" title="{{ _('Home All Axes') }}"> <div class="d-flex gap-4 justify-content-center mb-4 w-100">
<i class="bi bi-house-door fs-2"></i> <!-- Home button -->
</button> <div>
<div class="text-muted mb-4">{{ _('Home All Axes') }} (G28)</div> <button class="btn btn-lg btn-primary rounded-circle shadow mb-2" style="width: 80px; height: 80px;" onclick="sendCommand('home')" title="{{ _('Home All Axes') }}">
<i class="bi bi-house-door fs-2"></i>
</button>
<div class="text-muted small">{{ _('Home All Axes') }}<br>(G28)</div>
</div>
<!-- Auto Level button -->
<div>
<button class="btn btn-lg btn-info rounded-circle shadow mb-2 text-white" style="width: 80px; height: 80px;" onclick="sendCommand('auto_level')" title="{{ _('Auto Leveling') }}">
<i class="bi bi-grid-3x3 fs-2"></i>
</button>
<div class="text-muted small">{{ _('Auto Leveling') }}<br>(G29)</div>
</div>
</div>
<!-- Quick macros --> <!-- Quick macros -->
<div class="d-flex gap-3 justify-content-center flex-wrap w-100"> <div class="d-flex gap-3 justify-content-center flex-wrap w-100">
@@ -52,7 +71,7 @@
<script> <script>
function sendCommand(cmdName) { function sendCommand(cmdName) {
if (cmdName === 'cancel' || cmdName === 'home') { if (cmdName === 'cancel' || cmdName === 'home' || cmdName === 'auto_level') {
window.customConfirm("{{ _('Are you sure you want to perform this action?') }}", () => doSendCommand(cmdName)); window.customConfirm("{{ _('Are you sure you want to perform this action?') }}", () => doSendCommand(cmdName));
} else { } else {
doSendCommand(cmdName); doSendCommand(cmdName);

View File

@@ -0,0 +1,56 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{{ _('Printer Helper') }}</h1>
</div>
<div class="row">
<!-- Helper Content -->
<div class="col-12 markdown-body p-4 bg-white rounded shadow-sm">
{% if content_html %}
{{ content_html|safe }}
{% else %}
<p class="text-muted">{{ _('Documentation not available.') }}</p>
{% endif %}
</div>
</div>
<style>
/* 简单的 Markdown 样式优化 */
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
}
.markdown-body h1 {
font-size: 2em;
padding-bottom: .3em;
border-bottom: 1px solid #eaecef;
}
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: 6px;
}
.markdown-body pre {
background-color: #f6f8fa;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
}
.markdown-body code {
font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;
}
.markdown-body table {
width: 100%;
margin-bottom: 1rem;
color: #212529;
border-collapse: collapse;
}
.markdown-body table th, .markdown-body table td {
padding: 0.5rem;
border: 1px solid #dee2e6;
}
</style>
{% endblock %}

View File

@@ -1,20 +1,38 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
<style>
/* Prevent the outer page from scrolling */
body {
overflow: hidden;
}
/* Make the main container take exactly the viewport height and act as a flex column */
main {
height: 100vh;
padding-bottom: 0 !important;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Let the iframe container fill all the remaining height automatically */
.octo-panel-container {
flex-grow: 1;
margin-bottom: 0 !important;
border-radius: 0px !important;
}
</style>
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="bi bi-window-sidebar text-info me-2"></i>{{ _('OctoPrint Panel (Embedded)') }}</h1> <h1 class="h2"><i class="bi bi-window-sidebar text-info me-2"></i>{{ _('OctoPrint Panel (Embedded)') }}</h1>
</div> </div>
{% if embed_url %} {% if embed_url %}
<div class="card shadow rounded overflow-hidden" style="height: calc(100vh - 180px); min-height: 500px;"> <div class="card shadow overflow-hidden octo-panel-container position-relative">
<!-- iFrame wrapper for responsivness --> <iframe src="{{ embed_url }}"
<div class="w-100 h-100 position-relative"> class="position-absolute border-0 w-100 h-100"
<iframe src="{{ embed_url }}" style="top: 0; left: 0;"
class="position-absolute border-0 w-100 h-100" allowfullscreen>
style="top: 0; left: 0;" </iframe>
allowfullscreen>
</iframe>
</div>
</div> </div>
{% else %} {% else %}
<div class="alert alert-warning shadow-sm border-0 d-flex align-items-center" role="alert"> <div class="alert alert-warning shadow-sm border-0 d-flex align-items-center" role="alert">

View File

@@ -1,8 +1,29 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
<style>
@keyframes blink-fade {
0% { background-color: rgba(255, 193, 7, 0.4); box-shadow: 0 0 15px rgba(255, 193, 7, 0.6); }
50% { background-color: rgba(255, 193, 7, 0); box-shadow: 0 0 0 rgba(255, 193, 7, 0); }
100% { background-color: rgba(255, 193, 7, 0.4); box-shadow: 0 0 15px rgba(255, 193, 7, 0.6); }
}
.animate-blink {
animation: blink-fade 0.8s ease-in-out 4;
border: 2px solid #ffc107 !important;
border-radius: 8px;
}
.transition-style {
transition: all 0.3s ease;
}
</style>
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="bi bi-file-earmark-plus text-primary me-2"></i>{{ _('Prepare Print') }}</h1> <h1 class="h2"><i class="bi bi-file-earmark-plus text-primary me-2"></i>{{ _('Prepare Print') }}</h1>
<div>
<button type="button" class="btn btn-outline-primary btn-sm rounded shadow-sm fw-bold" onclick="document.getElementById('gcodeUploadInput').click();">
<i class="bi bi-upload me-1"></i>{{ _('Upload External GCode') }}
</button>
<input type="file" id="gcodeUploadInput" style="display: none;" accept=".gcode,.gco,.g" onchange="uploadExternalGcode(this)">
</div>
</div> </div>
{% if error %} {% if error %}
@@ -17,12 +38,13 @@
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
{% for f in files %} {% for f in files %}
{% if f.type == 'machinecode' %} {% if f.type == 'machinecode' %}
<div class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3"> <div id="file-{{ f.id }}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3 transition-style">
<div class="me-auto text-truncate" style="max-width: 80%;"> <div class="me-auto text-truncate" style="max-width: 80%;">
<h6 class="mb-1"><i class="bi bi-file-earmark-code text-primary me-2"></i>{{ f.name }}</h6> <h6 class="mb-1"><i class="bi bi-file-earmark-code text-primary me-2"></i>{{ f.name }}</h6>
<small class="text-muted d-block">{{ _('Size:') }} {{ f.size }} bytes, {{ _('Time:') }} {{ f.gcodeAnalysis.estimatedPrintTime if f.gcodeAnalysis else 'Unknown' }}s</small> <small class="text-muted d-block pb-1">{{ _('Size:') }} {{ f.size | filesizeformat }}, {% if f.meta_print_time and f.meta_print_time != '-' %}{{ _('Estimated Time:') }} {{ f.meta_print_time }}{% else %}{{ _('Time:') }} {{ f.gcodeAnalysis.estimatedPrintTime if f.gcodeAnalysis else 'Unknown' }}s{% endif %}</small>
</div> </div>
<div> <div>
<a href="{{ url_for('main.preview_gcode', file_id=f.id) }}" class="btn btn-sm btn-outline-info rounded-pill px-3 shadow-sm me-2"><i class="bi bi-eye me-1"></i>{{ _('Preview') }}</a>
<button class="btn btn-sm btn-outline-success rounded-pill px-3 shadow-sm" onclick="printFile('{{ f.origin }}', '{{ f.path }}')"><i class="bi bi-play-fill me-1"></i>{{ _('Print Now') }}</button> <button class="btn btn-sm btn-outline-success rounded-pill px-3 shadow-sm" onclick="printFile('{{ f.origin }}', '{{ f.path }}')"><i class="bi bi-play-fill me-1"></i>{{ _('Print Now') }}</button>
<!-- <button class="btn btn-sm btn-outline-secondary rounded-pill ms-2" onclick="selectFile('{{ f.origin }}', '{{ f.path }}')">{{ _('Select') }}</button> --> <!-- <button class="btn btn-sm btn-outline-secondary rounded-pill ms-2" onclick="selectFile('{{ f.origin }}', '{{ f.path }}')">{{ _('Select') }}</button> -->
</div> </div>
@@ -59,6 +81,54 @@ function printFile(origin, path) {
.catch(err => window.customAlert("Error: " + err)); .catch(err => window.customAlert("Error: " + err));
}); });
} }
function uploadExternalGcode(input) {
if (!input.files || input.files.length === 0) return;
let file = input.files[0];
let formData = new FormData();
formData.append('file', file);
window.showToast("{{ _('Uploading and linking GCode...') }}", "info");
let btn = document.querySelector('button[onclick="document.getElementById(\'gcodeUploadInput\').click();"]');
let oldBtnHtml = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Upload/Sync...';
btn.disabled = true;
fetch("{{ url_for('printer.upload_gcode') }}", {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(data => {
if(data.success) {
window.location.reload();
} else {
window.customAlert("Upload failed: " + data.error);
btn.innerHTML = oldBtnHtml;
btn.disabled = false;
}
}).catch(e => {
window.customAlert("Error: " + e);
btn.innerHTML = oldBtnHtml;
btn.disabled = false;
});
}
window.addEventListener('DOMContentLoaded', (event) => {
if(window.location.hash) {
let el = document.querySelector(window.location.hash);
if(el) {
setTimeout(() => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('animate-blink');
// fallback to remove class later
setTimeout(() => el.classList.remove('animate-blink'), 3500);
}, 300);
}
}
});
</script> </script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -21,7 +21,7 @@
<i class="bi bi-info-circle me-1"></i>{{ _('Current State') }} <i class="bi bi-info-circle me-1"></i>{{ _('Current State') }}
</div> </div>
<div class="card-body text-center"> <div class="card-body text-center">
<h3 class="display-6 mt-3 text-primary">{{ status.get('state', {}).get('text', 'Unknown') }}</h3> <h3 class="display-6 mt-3 text-primary" id="printer-state-text">{{ status.get('state', {}).get('text', 'Unknown') if status else 'Unknown' }}</h3>
</div> </div>
</div> </div>
</div> </div>
@@ -33,43 +33,42 @@
<i class="bi bi-thermometer-half me-1"></i>{{ _('Temperatures') }} <i class="bi bi-thermometer-half me-1"></i>{{ _('Temperatures') }}
</div> </div>
<div class="card-body"> <div class="card-body">
{% set temps = status.get('temperature', {}) %} {% set temps = status.get('temperature', {}) if status else {} %}
<h5 class="mb-1"><i class="bi bi-fire text-danger me-2"></i>{{ _('Tool/Nozzle') }}</h5> <h5 class="mb-1"><i class="bi bi-fire text-danger me-2"></i>{{ _('Tool/Nozzle') }}</h5>
<h4 class="ms-4 mb-4"> <h4 class="ms-4 mb-4">
{{ temps.get('tool0', {}).get('actual', 0) }} °C <span id="tool-actual">{{ temps.get('tool0', {}).get('actual', 0) }}</span> °C
<small class="text-muted fs-6">/ {{ temps.get('tool0', {}).get('target', 0) }} °C</small> <small class="text-muted fs-6">/ <span id="tool-target">{{ temps.get('tool0', {}).get('target', 0) }}</span> °C</small>
</h4> </h4>
<h5 class="mb-1"><i class="bi bi-square-fill text-warning me-2"></i>{{ _('Bed') }}</h5> <h5 class="mb-1"><i class="bi bi-square-fill text-warning me-2"></i>{{ _('Bed') }}</h5>
<h4 class="ms-4"> <h4 class="ms-4">
{{ temps.get('bed', {}).get('actual', 0) }} °C <span id="bed-actual">{{ temps.get('bed', {}).get('actual', 0) }}</span> °C
<small class="text-muted fs-6">/ {{ temps.get('bed', {}).get('target', 0) }} °C</small> <small class="text-muted fs-6">/ <span id="bed-target">{{ temps.get('bed', {}).get('target', 0) }}</span> °C</small>
</h4> </h4>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% if job and job.get('job', {}).get('file', {}).get('name') %} <div class="card shadow-sm mt-4 border-success" id="active-job-card" style="display: {% if job and job.get('job', {}).get('file', {}).get('name') %}block{% else %}none{% endif %};">
<div class="card shadow-sm mt-4 border-success">
<div class="card-header bg-success text-white fw-bold"> <div class="card-header bg-success text-white fw-bold">
<i class="bi bi-play-circle me-1"></i>{{ _('Active Print Job') }} <i class="bi bi-play-circle me-1"></i>{{ _('Active Print Job') }}
</div> </div>
<div class="card-body"> <div class="card-body">
<h5>{{ job.get('job', {}).get('file', {}).get('name') }}</h5> <h5 id="job-file-name">{{ job.get('job', {}).get('file', {}).get('display_name') or job.get('job', {}).get('file', {}).get('name') if job else '' }}</h5>
{% set progress = job.get('progress', {}).get('completion', 0) %} {% set progress = job.get('progress', {}).get('completion', 0) if job else 0 %}
{% if progress == None %}{% set progress = 0 %}{% endif %} {% if progress == None %}{% set progress = 0 %}{% endif %}
<div class="progress mt-3 mb-2" style="height: 25px;"> <div class="progress mt-3 mb-2" style="height: 25px;">
<div class="progress-bar progress-bar-striped progress-bar-animated bg-success" role="progressbar" style="width: {{ progress }}%;" aria-valuenow="{{ progress }}" aria-valuemin="0" aria-valuemax="100"> <div id="job-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-success" role="progressbar" style="width: {{ progress }}%;" aria-valuenow="{{ progress }}" aria-valuemin="0" aria-valuemax="100">
{{ "%.1f"|format(progress) }}% <span id="job-progress-text">{{ "%.1f"|format(progress) }}%</span>
</div> </div>
</div> </div>
<div class="d-flex justify-content-between text-muted small mt-2"> <div class="d-flex justify-content-between text-muted small mt-2">
<span><strong>{{ _('Print Time:') }}</strong> {{ job.get('progress', {}).get('printTime', 0) }}s</span> <span><strong>{{ _('Print Time:') }}</strong> <span id="job-print-time"></span></span>
<span><strong>{{ _('Time Left:') }}</strong> {{ job.get('progress', {}).get('printTimeLeft', 0) }}s</span> <span><strong>{{ _('Time Left:') }}</strong> <span id="job-time-left"></span></span>
</div> </div>
<div class="mt-4 gap-2 d-flex"> <div class="mt-4 gap-2 d-flex">
@@ -78,11 +77,65 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
{% endif %} {% endif %}
<script> <script>
function formatTime(seconds) {
if (!seconds && seconds !== 0) return '0{{ _("s") }}';
seconds = Math.round(seconds);
let d = Math.floor(seconds / 86400);
let h = Math.floor((seconds % 86400) / 3600);
let m = Math.floor((seconds % 3600) / 60);
let s = Math.floor(seconds % 60);
let res = [];
if (d > 0) res.push(d + '{{ _("d") }}');
if (h > 0 || d > 0) res.push(h + '{{ _("h") }}');
if (m > 0 || h > 0 || d > 0) res.push(m + '{{ _("m") }}');
if (s > 0 || res.length === 0) res.push(s + '{{ _("s") }}');
return res.join(' ');
}
function updateStatus() {
fetch('{{ url_for("printer.api_status_data") }}')
.then(res => res.json())
.then(data => {
if(data.success) {
if(data.status && data.status.state) {
document.getElementById('printer-state-text').innerText = data.status.state.text || 'Unknown';
}
if(data.status && data.status.temperature) {
const tool = data.status.temperature.tool0 || {};
const bed = data.status.temperature.bed || {};
document.getElementById('tool-actual').innerText = tool.actual !== undefined ? tool.actual : 0;
document.getElementById('tool-target').innerText = tool.target !== undefined ? tool.target : 0;
document.getElementById('bed-actual').innerText = bed.actual !== undefined ? bed.actual : 0;
document.getElementById('bed-target').innerText = bed.target !== undefined ? bed.target : 0;
}
const jobCard = document.getElementById('active-job-card');
if(data.job && data.job.job && data.job.job.file && data.job.job.file.name) {
jobCard.style.display = 'block';
document.getElementById('job-file-name').innerText = data.job.job.file.display_name || data.job.job.file.name;
let progress = data.job.progress && data.job.progress.completion ? data.job.progress.completion : 0;
document.getElementById('job-progress-bar').style.width = progress + '%';
document.getElementById('job-progress-bar').setAttribute('aria-valuenow', progress);
document.getElementById('job-progress-text').innerText = progress.toFixed(1) + '%';
document.getElementById('job-print-time').innerText = formatTime(data.job.progress ? data.job.progress.printTime : 0);
document.getElementById('job-time-left').innerText = formatTime(data.job.progress ? data.job.progress.printTimeLeft : 0);
} else {
jobCard.style.display = 'none';
}
}
})
.catch(err => console.error("Error fetching status:", err));
}
{% if not error %}
// Run once immediately to populate initial data consistently
updateStatus();
setInterval(updateStatus, 1000);
{% endif %}
function sendCmd(cmd) { function sendCmd(cmd) {
if(cmd === 'cancel') { if(cmd === 'cancel') {
window.customConfirm("{{ _('Are you sure you want to cancel the print?') }}", () => doSendCmd(cmd)); window.customConfirm("{{ _('Are you sure you want to cancel the print?') }}", () => doSendCmd(cmd));
@@ -99,12 +152,11 @@ function doSendCmd(cmd) {
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
if(data.success) { if(data.success) {
window.location.reload(); updateStatus();
} else { } else {
window.customAlert("Error: " + data.error); window.customAlert("Error: " + data.error);
} }
}); });
} }
setTimeout(() => { if (!window.pauseRefresh) window.location.reload(); }, 15000);
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,134 @@
{% extends 'base.html' %}
{% block content %}
<div class="row g-4 mt-1">
<div class="col-12 d-flex justify-content-between align-items-center">
<h4 class="mb-0 fw-bold"><i class="bi bi-person-badge me-2"></i>{{ _('Account Management') }}</h4>
</div>
<!-- Password Change Section -->
<div class="col-lg-5">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white pt-3 pb-2 border-bottom-0">
<h5 class="card-title fw-bold text-primary mb-0"><i class="bi bi-shield-lock me-2"></i>{{ _('Change Password') }}</h5>
</div>
<div class="card-body">
<form action="{{ url_for('main.account') }}" method="POST">
<input type="hidden" name="action" value="change_password">
<div class="mb-3">
<label class="form-label text-muted small fw-bold">{{ _('Current Password') }}</label>
<input type="password" name="current_password" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label text-muted small fw-bold">{{ _('New Password') }}</label>
<input type="password" name="new_password" class="form-control" required minlength="6">
</div>
<div class="mb-4">
<label class="form-label text-muted small fw-bold">{{ _('Confirm New Password') }}</label>
<input type="password" name="confirm_password" class="form-control" required minlength="6">
</div>
<button type="submit" class="btn btn-primary w-100 fw-bold rounded-pill"><i class="bi bi-check-circle me-2"></i>{{ _('Update Password') }}</button>
</form>
</div>
</div>
</div>
<!-- Active Sessions Section -->
<div class="col-lg-7">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white pt-3 pb-2 border-bottom-0 d-flex justify-content-between align-items-center">
<h5 class="card-title fw-bold text-success mb-0"><i class="bi bi-laptop me-2"></i>{{ _('Active Sessions') }}</h5>
<span class="badge bg-success rounded-pill">{{ sessions|length }}</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-borderless align-middle mb-0">
<thead class="table-light text-muted small">
<tr>
<th class="ps-4 fw-normal">{{ _('Device') }}</th>
<th class="fw-normal">{{ _('IP Address') }}</th>
<th class="fw-normal">{{ _('Last Active') }}</th>
<th class="text-end pe-4 fw-normal">{{ _('Action') }}</th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr class="{% if s.session_token == current_token %}bg-light{% endif %}">
<td class="ps-4">
<div class="d-flex align-items-center">
<i class="bi bi-display me-2 text-secondary fs-5"></i>
<div>
{% set device_name = _('Unknown Device') %}
{% if s.user_agent %}
{% set ua = s.user_agent|lower %}
{% if 'windows' in ua %}
{% set device_name = 'Windows' %}
{% elif 'android' in ua %}
{% set device_name = 'Android' %}
{% elif 'iphone' in ua or 'ipad' in ua %}
{% set device_name = 'iOS' %}
{% elif 'mac' in ua or 'darwin' in ua %}
{% set device_name = 'macOS' %}
{% elif 'linux' in ua %}
{% set device_name = 'Linux' %}
{% endif %}
{% endif %}
<div class="text-truncate" style="max-width: 150px; cursor: help;" title="{{ s.user_agent or _('Unknown Device') }}">{{ device_name }}</div>
{% if s.session_token == current_token %}
<span class="badge bg-primary mt-1">{{ _('This Device') }}</span>
{% endif %}
</div>
</div>
</td>
<td>
<span class="badge bg-secondary font-monospace">{{ s.ip_address }}</span>
</td>
<td>
<small class="text-muted local-time" data-utc="{{ s.last_active.isoformat() }}Z">{{ s.last_active.strftime('%Y-%m-%d %H:%M:%S') }}</small>
</td>
<td class="text-end pe-4">
{% if s.session_token != current_token %}
<form action="{{ url_for('main.account') }}" method="POST" class="d-inline" id="form-{{ s.id }}">
<input type="hidden" name="action" value="terminate_session">
<input type="hidden" name="session_id" value="{{ s.id }}">
<input type="hidden" name="session_token" value="{{ s.session_token }}">
<button type="button" class="btn btn-sm btn-outline-danger px-3 rounded-pill" onclick="customConfirm('{{ _('Are you sure you want to terminate this session?') }}', () => document.getElementById('form-{{ s.id }}').submit());"><i class="bi bi-x-octagon me-1"></i>{{ _('Logout') }}</button>
</form>
{% else %}
<button class="btn btn-sm btn-outline-secondary px-3 rounded-pill" onclick="customConfirm('{{ _('Logout from this device?') }}', () => window.location.href='{{ url_for('auth.logout') }}');"><i class="bi bi-box-arrow-right me-1"></i>{{ _('Logout') }}</button>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center py-4 text-muted">{{ _('No active sessions found.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function() {
document.querySelectorAll('.local-time').forEach(function(el) {
const utcDate = new Date(el.getAttribute('data-utc'));
if (!isNaN(utcDate)) {
const pad = (n) => n.toString().padStart(2, '0');
const yyyy = utcDate.getFullYear();
const MM = pad(utcDate.getMonth() + 1);
const dd = pad(utcDate.getDate());
const HH = pad(utcDate.getHours());
const mm = pad(utcDate.getMinutes());
const ss = pad(utcDate.getSeconds());
el.textContent = `${yyyy}-${MM}-${dd} ${HH}:${mm}:${ss}`;
}
});
});
</script>
{% endblock %}

View File

@@ -39,12 +39,15 @@
</thead> </thead>
<tbody> <tbody>
{% for file in files %} {% for file in files %}
<tr id="file-row-{{ file.id }}" data-status="{{ file.status }}"> {% set is_gcode = file.original_filename.lower().endswith('.gcode') or file.original_filename.lower().endswith('.gco') or file.original_filename.lower().endswith('.g') %}
<tr id="file-row-{{ file.id }}" data-status="{{ file.status }}" data-is-gcode="{{ 'true' if is_gcode else 'false' }}">
<td class="ps-4 text-muted"> <td class="ps-4 text-muted">
<i class="bi bi-clock me-1"></i> <i class="bi bi-clock me-1"></i>
<span class="local-time" data-utc="{{ file.created_at.isoformat() }}">{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</span> <span class="local-time" data-utc="{{ file.created_at.isoformat() }}">{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
</td> </td>
<td class="fw-medium">{{ file.original_filename }}</td> <td class="fw-medium">
<i class="bi {{ 'bi-file-earmark-code text-success' if is_gcode else 'bi-box text-primary' }} me-2"></i>{{ file.original_filename }}
</td>
<td id="status-{{ file.id }}"> <td id="status-{{ file.id }}">
{% if file.status == 'waiting' %} {% if file.status == 'waiting' %}
<span class="badge bg-info text-dark rounded-pill fw-normal px-2" title="{{ _('Waiting in queue for slicing') }}"><i class="bi bi-hourglass-split me-1"></i>{{ _('Waiting') }}...</span> <span class="badge bg-info text-dark rounded-pill fw-normal px-2" title="{{ _('Waiting in queue for slicing') }}"><i class="bi bi-hourglass-split me-1"></i>{{ _('Waiting') }}...</span>
@@ -64,7 +67,9 @@
</td> </td>
<td class="pe-4"> <td class="pe-4">
<div class="d-flex gap-2" id="actions-container-{{ file.id }}"> <div class="d-flex gap-2" id="actions-container-{{ file.id }}">
{% if not is_gcode %}
<a href="{{ url_for('main.plater') }}?add={{ file.id }}" class="btn btn-sm btn-outline-warning shadow-sm" title="{{ _('Slice') }}"><i class="bi bi-braces"></i> {{ _('Slice') }}</a> <a href="{{ url_for('main.plater') }}?add={{ file.id }}" class="btn btn-sm btn-outline-warning shadow-sm" title="{{ _('Slice') }}"><i class="bi bi-braces"></i> {{ _('Slice') }}</a>
{% endif %}
{% if file.status == 'sliced' %} {% if file.status == 'sliced' %}
<a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-sm btn-outline-primary shadow-sm" title="{{ _('Download GCode') }}"><i class="bi bi-download"></i></a> <a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-sm btn-outline-primary shadow-sm" title="{{ _('Download GCode') }}"><i class="bi bi-download"></i></a>
<a href="{{ url_for('main.preview_gcode', file_id=file.id) }}" class="btn btn-sm btn-outline-info shadow-sm" title="{{ _('GCode Preview') }}"><i class="bi bi-eye"></i></a> <a href="{{ url_for('main.preview_gcode', file_id=file.id) }}" class="btn btn-sm btn-outline-info shadow-sm" title="{{ _('GCode Preview') }}"><i class="bi bi-eye"></i></a>
@@ -138,7 +143,10 @@ document.addEventListener('DOMContentLoaded', function() {
let actionsHtml = ''; let actionsHtml = '';
const platerUrl = `{{ url_for('main.plater') }}?add=${id}`; const platerUrl = `{{ url_for('main.plater') }}?add=${id}`;
actionsHtml += `<a href="${platerUrl}" class="btn btn-sm btn-outline-warning shadow-sm" title="{{ _('Slice') }}"><i class="bi bi-braces"></i> {{ _('Slice') }}</a>\n`; const isGcode = tr.getAttribute('data-is-gcode') === 'true';
if (!isGcode) {
actionsHtml += `<a href="${platerUrl}" class="btn btn-sm btn-outline-warning shadow-sm" title="{{ _('Slice') }}"><i class="bi bi-braces"></i> {{ _('Slice') }}</a>\n`;
}
if (status === 'sliced') { if (status === 'sliced') {
const downloadUrl = `{{ url_for('main.download_gcode', file_id=999999999) }}`.replace('999999999', id); const downloadUrl = `{{ url_for('main.download_gcode', file_id=999999999) }}`.replace('999999999', id);

View File

@@ -1,10 +1,18 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-2 border-bottom">
<h1 class="h2"><i class="bi bi-eye text-primary me-2"></i>{{ _('GCode Preview') }}: {{ file.original_filename }}</h1>
<div> <div>
<a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-primary btn-sm rounded shadow-sm"><i class="bi bi-download"></i> {{ _('Download GCode') }}</a> <h1 class="h2 mb-1"><i class="bi bi-eye text-primary me-2"></i>{{ _('GCode Preview') }}: {{ file.original_filename }}</h1>
<div class="text-muted small">
<span class="me-3"><i class="bi bi-clock-history me-1"></i>{{ _('Estimated Time:') }} <span class="fw-bold">{{ time_info }}</span></span>
<span class="me-3"><i class="bi bi-layers me-1"></i>{{ _('First Layer Time:') }} <span class="fw-bold">{{ layer1_time }}</span></span>
<span><i class="bi bi-rulers me-1"></i>{{ _('Filament Used [mm]:') }} <span class="fw-bold">{{ filament_used }}</span></span>
</div>
</div>
<div class="mt-2 mt-md-0">
<a href="{{ url_for('printer.prepare') }}#file-{{ file.id }}" class="btn btn-warning btn-sm rounded shadow-sm fw-bold"><i class="bi bi-printer"></i> {{ _('Go to Print') }}</a>
<a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-primary btn-sm rounded shadow-sm ms-2"><i class="bi bi-download"></i> {{ _('Download GCode') }}</a>
<a href="{{ url_for('main.files') }}" class="btn btn-outline-secondary btn-sm rounded ms-2 shadow-sm"><i class="bi bi-arrow-left"></i> {{ _('Back') }}</a> <a href="{{ url_for('main.files') }}" class="btn btn-outline-secondary btn-sm rounded ms-2 shadow-sm"><i class="bi bi-arrow-left"></i> {{ _('Back') }}</a>
</div> </div>
</div> </div>
@@ -20,15 +28,7 @@
<div id="canvas-container" class="w-100 h-100 d-block overflow-hidden"></div> <div id="canvas-container" class="w-100 h-100 d-block overflow-hidden"></div>
<!-- Legend Overlay --> <!-- Legend Overlay -->
<div class="position-absolute top-0 start-0 m-3 p-2 rounded shadow bg-dark bg-opacity-75 border border-secondary" style="color: #eee; font-size: 0.85rem; pointer-events: auto; z-index: 10;"> <div id="legend-overlay" class="position-absolute top-0 start-0 m-3 p-2 rounded shadow bg-dark bg-opacity-75 border border-secondary" style="color: #eee; font-size: 0.85rem; pointer-events: auto; z-index: 10;">
<div class="mb-1 legend-item user-select-none" data-type="WALL-OUTER" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #eb8b38;"></span>{{ _('Outer Wall') }}</div>
<div class="mb-1 legend-item user-select-none" data-type="WALL-INNER" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #4080cf;"></span>{{ _('Inner Wall') }}</div>
<div class="mb-1 legend-item user-select-none" data-type="FILL" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #ccc04b;"></span>{{ _('Infill') }}</div>
<div class="mb-1 legend-item user-select-none" data-type="SKIN" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #9e60b3;"></span>{{ _('Skin/TopBottom') }}</div>
<div class="mb-1 legend-item user-select-none" data-type="SUPPORT" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #57b357;"></span>{{ _('Support') }}</div>
<div class="mb-1 legend-item user-select-none" data-type="SKIRT" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #00ffff;"></span>{{ _('Skirt') }}</div>
<div class="mb-1 legend-item user-select-none" data-type="SUPPORT-INTERFACE" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #2b6b2b;"></span>{{ _('Support Interface') }}</div>
<div class="mb-1 legend-item user-select-none" data-type="TRAVEL" style="cursor: pointer; transition: opacity 0.2s; opacity: 0.4;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #405060;"></span>{{ _('Travel (Move)') }}</div>
</div> </div>
<!-- Bottom Slider (Intra-Layer Progress) --> <!-- Bottom Slider (Intra-Layer Progress) -->
@@ -56,17 +56,6 @@
<script> <script>
document.addEventListener('DOMContentLoaded', async function() { document.addEventListener('DOMContentLoaded', async function() {
const COLORS = {
'WALL-OUTER': new THREE.Color(0xeb8b38),
'WALL-INNER': new THREE.Color(0x4080cf),
'FILL': new THREE.Color(0xccc04b),
'SKIN': new THREE.Color(0x9e60b3),
'SUPPORT': new THREE.Color(0x57b357),
'SKIRT': new THREE.Color(0x00ffff),
'SUPPORT-INTERFACE': new THREE.Color(0x2b6b2b),
'TRAVEL': new THREE.Color(0x405060),
'DEFAULT': new THREE.Color(0xaaaaaa),
};
// Inject printer machine dimensions via Jinja // Inject printer machine dimensions via Jinja
const bedWidth = {{ machine_width | default(220) }}; const bedWidth = {{ machine_width | default(220) }};
@@ -76,10 +65,37 @@ document.addEventListener('DOMContentLoaded', async function() {
const offsetY = {{ offset_y | default(0.0) }}; const offsetY = {{ offset_y | default(0.0) }};
// Type indices for shader visibility filtering // Type indices for shader visibility filtering
const TYPE_INDEX = { let COLORS = {};
'TRAVEL': 0, 'WALL-OUTER': 1, 'WALL-INNER': 2, let TYPE_INDEX = {};
'FILL': 3, 'SKIN': 4, 'SUPPORT': 5, 'DEFAULT': 6, let gcodeMat = null;
'SKIRT': 7, 'SUPPORT-INTERFACE': 8
const SLICER_CONFIGS = {
'Cura': [
{ id: 'TRAVEL', label: '{{ _("Travel (Move)") }}', color: 0x405060, defaultShow: false },
{ id: 'WALL-OUTER', label: '{{ _("Outer Wall") }}', color: 0xeb8b38, defaultShow: true },
{ id: 'WALL-INNER', label: '{{ _("Inner Wall") }}', color: 0x4080cf, defaultShow: true },
{ id: 'FILL', label: '{{ _("Infill") }}', color: 0xccc04b, defaultShow: true },
{ id: 'SKIN', label: '{{ _("Skin/TopBottom") }}', color: 0x9e60b3, defaultShow: true },
{ id: 'SUPPORT', label: '{{ _("Support") }}', color: 0x57b357, defaultShow: true },
{ id: 'SKIRT', label: '{{ _("Skirt") }}', color: 0x00ffff, defaultShow: true },
{ id: 'SUPPORT-INTERFACE', label: '{{ _("Support Interface") }}', color: 0x2b6b2b, defaultShow: true },
{ id: 'DEFAULT', label: '{{ _("Others") }}', color: 0xaaaaaa, defaultShow: true }
],
'Prusa': [
{ id: 'TRAVEL', label: '{{ _("Travel (Move)") }}', color: 0x405060, defaultShow: false },
{ id: 'Custom', label: '{{ _("Custom") }}', color: 0xd0e0ff, defaultShow: true },
{ id: 'Skirt/Brim', label: '{{ _("Skirt/Brim") }}', color: 0x00FFFF, defaultShow: true },
{ id: 'Support material', label: '{{ _("Support material") }}', color: 0x90EE90, defaultShow: true },
{ id: 'Perimeter', label: '{{ _("Perimeter") }}', color: 0xFFFFE0, defaultShow: true },
{ id: 'External perimeter', label: '{{ _("External perimeter") }}', color: 0xFFA500, defaultShow: true },
{ id: 'Solid infill', label: '{{ _("Solid infill") }}', color: 0x800080, defaultShow: true },
{ id: 'Overhang perimeter', label: '{{ _("Overhang perimeter") }}', color: 0x00008B, defaultShow: true },
{ id: 'Internal infill', label: '{{ _("Internal infill") }}', color: 0x8B0000, defaultShow: true },
{ id: 'Bridge infill', label: '{{ _("Bridge infill") }}', color: 0x0000FF, defaultShow: true },
{ id: 'Top solid infill', label: '{{ _("Top solid infill") }}', color: 0xFF0000, defaultShow: true },
{ id: 'Support material interface', label: '{{ _("Support Interface") }}', color: 0x2b6b2b, defaultShow: true },
{ id: 'DEFAULT', label: '{{ _("Others") }}', color: 0xaaaaaa, defaultShow: true }
]
}; };
let layers = []; let layers = [];
@@ -90,87 +106,90 @@ document.addEventListener('DOMContentLoaded', async function() {
const layerDisplay = document.getElementById('layer-display'); const layerDisplay = document.getElementById('layer-display');
const progressSlider = document.getElementById('progress-slider'); const progressSlider = document.getElementById('progress-slider');
// Shader material for high-speed dynamic feature visibility function setupSlicerConfig(text) {
const gcodeMat = new THREE.ShaderMaterial({ let slicerType = 'Cura'; // default
uniforms: { if (text.substring(0, 500).includes('generated by PrusaSlicer')) {
uShowOuter: { value: 1.0 }, slicerType = 'Prusa';
uShowInner: { value: 1.0 }, }
uShowInfill: { value: 1.0 },
uShowSkin: { value: 1.0 }, const config = SLICER_CONFIGS[slicerType];
uShowSupport: { value: 1.0 },
uShowSkirt: { value: 1.0 }, // 1. Build uniforms & shader strings dynamically
uShowSupportInterface: { value: 1.0 }, let uniformsObj = {};
uShowTravel: { value: 0.0 }, let fragmentUniformsDecl = '';
uShowDefault: { value: 1.0 } let fragmentUniformsLogic = '';
},
vertexShader: ` let overlayHTML = '';
attribute float pType;
varying vec3 vColor; config.forEach((c, idx) => {
varying float vType; COLORS[c.id] = new THREE.Color(c.color);
void main() { TYPE_INDEX[c.id] = idx;
vColor = color;
vType = pType; const uniformName = 'uShow' + idx;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); uniformsObj[uniformName] = { value: c.defaultShow ? 1.0 : 0.0 };
}
`, fragmentUniformsDecl += `uniform float ${uniformName};\n`;
fragmentShader: `
varying vec3 vColor; if (idx === 0) {
varying float vType; fragmentUniformsLogic += `if (t == 0) show = ${uniformName};\n`;
uniform float uShowOuter; } else {
uniform float uShowInner; fragmentUniformsLogic += ` else if (t == ${idx}) show = ${uniformName};\n`;
uniform float uShowInfill;
uniform float uShowSkin;
uniform float uShowSupport;
uniform float uShowSkirt;
uniform float uShowSupportInterface;
uniform float uShowTravel;
uniform float uShowDefault;
void main() {
float show = 1.0;
int t = int(vType + 0.5);
if (t == 0) show = uShowTravel;
else if (t == 1) show = uShowOuter;
else if (t == 2) show = uShowInner;
else if (t == 3) show = uShowInfill;
else if (t == 4) show = uShowSkin;
else if (t == 5) show = uShowSupport;
else if (t == 7) show = uShowSkirt;
else if (t == 8) show = uShowSupportInterface;
else show = uShowDefault;
if (show < 0.5) discard;
gl_FragColor = vec4(vColor, 1.0);
}
`,
vertexColors: true,
side: THREE.DoubleSide,
linewidth: 1
});
// Binding the Legend Buttons
const uniformMap = {
'WALL-OUTER': 'uShowOuter',
'WALL-INNER': 'uShowInner',
'FILL': 'uShowInfill',
'SKIN': 'uShowSkin',
'SUPPORT': 'uShowSupport',
'SKIRT': 'uShowSkirt',
'SUPPORT-INTERFACE': 'uShowSupportInterface',
'TRAVEL': 'uShowTravel'
};
document.querySelectorAll('.legend-item').forEach(el => {
el.addEventListener('click', function() {
const t = this.dataset.type;
const uniformName = uniformMap[t];
if (uniformName) {
const currentVal = gcodeMat.uniforms[uniformName].value;
const newVal = currentVal > 0.5 ? 0.0 : 1.0;
gcodeMat.uniforms[uniformName].value = newVal;
this.style.opacity = newVal > 0.5 ? "1.0" : "0.4";
} }
// Build Legend UI
const hexColor = '#' + c.color.toString(16).padStart(6, '0');
const opacityStyle = c.defaultShow ? '1.0' : '0.4';
overlayHTML += `
<div class="mb-1 legend-item user-select-none" data-id="${c.id}" data-uniform="${uniformName}" style="cursor: pointer; transition: opacity 0.2s; opacity: ${opacityStyle};"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: ${hexColor};"></span>${c.label}</div>`;
}); });
});
// Add fallback condition
fragmentUniformsLogic += ` else show = 1.0;\n`;
document.getElementById('legend-overlay').innerHTML = overlayHTML;
gcodeMat = new THREE.ShaderMaterial({
uniforms: uniformsObj,
vertexShader: `
attribute float pType;
varying vec3 vColor;
varying float vType;
void main() {
vColor = color;
vType = pType;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
varying vec3 vColor;
varying float vType;
${fragmentUniformsDecl}
void main() {
float show = 1.0;
int t = int(vType + 0.5);
${fragmentUniformsLogic}
if (show < 0.5) discard;
gl_FragColor = vec4(vColor, 1.0);
}
`,
vertexColors: true,
side: THREE.DoubleSide,
linewidth: 1
});
// Legend binding
document.querySelectorAll('.legend-item').forEach(el => {
el.addEventListener('click', function() {
const uniformName = this.dataset.uniform;
if (uniformName && gcodeMat.uniforms[uniformName]) {
const currentVal = gcodeMat.uniforms[uniformName].value;
const newVal = currentVal > 0.5 ? 0.0 : 1.0;
gcodeMat.uniforms[uniformName].value = newVal;
this.style.opacity = newVal > 0.5 ? "1.0" : "0.4";
}
});
});
}
function init3D() { function init3D() {
const container = document.getElementById('canvas-container'); const container = document.getElementById('canvas-container');
@@ -218,6 +237,7 @@ document.addEventListener('DOMContentLoaded', async function() {
document.getElementById('loading-overlay').classList.add('d-none'); document.getElementById('loading-overlay').classList.add('d-none');
document.getElementById('preview-container').classList.remove('d-none'); document.getElementById('preview-container').classList.remove('d-none');
setupSlicerConfig(gcodeText);
init3D(); init3D();
parseGCode(gcodeText); parseGCode(gcodeText);
@@ -249,6 +269,13 @@ document.addEventListener('DOMContentLoaded', async function() {
const lines = text.split('\n'); const lines = text.split('\n');
let current = { x: 0, y: 0, z: 0, e: 0 }; let current = { x: 0, y: 0, z: 0, e: 0 };
let relativeE = false; // Track M83 (relative) vs M82 (absolute)
// Dynamically compute width and layer height based on gcode info if possible
let extWidth = 0.4;
let layerHeight = 0.2;
let pWidth = extWidth;
let currentTypeStr = 'DEFAULT'; let currentTypeStr = 'DEFAULT';
let currentExtrudePoints = []; let currentExtrudePoints = [];
@@ -291,41 +318,84 @@ document.addEventListener('DOMContentLoaded', async function() {
} }
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
let chunk = lines[i].trim().toUpperCase(); let chunk = lines[i].trim();
if (!chunk) continue; if (!chunk) continue;
let upperChunk = chunk.toUpperCase();
if (chunk.startsWith(';LAYER:')) { if (upperChunk.startsWith('M82')) relativeE = false;
else if (upperChunk.startsWith('M83')) relativeE = true;
if (upperChunk.startsWith(';LAYER:') || upperChunk.startsWith(';LAYER_CHANGE')) {
flushLayer(); flushLayer();
} else if (chunk.startsWith(';TYPE:')) { } else if (upperChunk.startsWith(';LAYER_HEIGHT:')) {
let lh = parseFloat(chunk.substring(14));
if (!isNaN(lh) && lh > 0) layerHeight = lh;
} else if (upperChunk.startsWith(';HEIGHT:')) {
let lh = parseFloat(chunk.substring(8));
if (!isNaN(lh) && lh > 0) layerHeight = lh;
} else if (upperChunk.startsWith(';WIDTH:')) {
let w = parseFloat(chunk.substring(7));
if (!isNaN(w) && w > 0) pWidth = w;
} else if (upperChunk.startsWith(';TYPE:')) {
currentTypeStr = chunk.substring(6).trim(); currentTypeStr = chunk.substring(6).trim();
} else if (chunk.startsWith('G0') || chunk.startsWith('G1')) { } else if (chunk.startsWith(';') && COLORS[chunk.substring(1).trim()] !== undefined) {
currentTypeStr = chunk.substring(1).trim();
} else if (upperChunk.startsWith(';') && chunk.includes(' perimeter')) {
currentTypeStr = chunk.substring(1).trim();
} else if (upperChunk.startsWith(';') && chunk.includes(' infill')) {
// Heuristics for Prusa/Slic3r specific comments like `; Internal infill`
currentTypeStr = chunk.substring(1).trim();
} else if (upperChunk.startsWith(';') && chunk.includes(' material')) {
// Support material
currentTypeStr = chunk.substring(1).trim();
} else if (upperChunk.startsWith(';') && chunk.includes('Skirt/Brim')) {
// Skirt/Brim
currentTypeStr = 'Skirt/Brim';
} else if (upperChunk.startsWith('G0') || upperChunk.startsWith('G1')) {
let next = { x: current.x, y: current.y, z: current.z, e: current.e }; let next = { x: current.x, y: current.y, z: current.z, e: current.e };
let parts = chunk.split(/\s+/); let parts = upperChunk.split(/\s+/);
let hasMove = false; let hasMove = false;
let hasE = false;
let eVal = 0;
for (let p of parts) { for (let p of parts) {
if (p.startsWith('X')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { next.x = v; hasMove = true; } } if (p.startsWith('X')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { next.x = v; hasMove = true; } }
if (p.startsWith('Y')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { next.y = v; hasMove = true; } } if (p.startsWith('Y')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { next.y = v; hasMove = true; } }
if (p.startsWith('Z')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { next.z = v; hasMove = true; } } if (p.startsWith('Z')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { next.z = v; hasMove = true; } }
if (p.startsWith('E')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { next.e = v; } } if (p.startsWith('E')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { eVal = v; hasE = true; } }
} }
if (hasMove && !isNaN(next.x) && !isNaN(next.y) && !isNaN(next.z)) { if (hasMove && !isNaN(next.x) && !isNaN(next.y) && !isNaN(next.z)) {
let isExtrude = (next.e > current.e); let isExtrude = false;
if (hasE) {
if (relativeE) {
next.e = current.e + eVal;
isExtrude = eVal > 0;
} else {
next.e = eVal;
isExtrude = next.e > current.e;
}
}
// Cura uses G0 for travel generally // Cura uses G0 for travel generally
if (chunk.startsWith('G0') && !chunk.includes('E')) isExtrude = false; if (upperChunk.startsWith('G0') && !upperChunk.includes('E')) isExtrude = false;
let activeType = isExtrude ? currentTypeStr : 'TRAVEL'; let activeType = isExtrude ? currentTypeStr : 'TRAVEL';
let col = COLORS[activeType] || COLORS['DEFAULT']; let resolvedType = activeType;
let tIdx = TYPE_INDEX[activeType] !== undefined ? TYPE_INDEX[activeType] : TYPE_INDEX['DEFAULT'];
if (isExtrude && COLORS[activeType] === undefined) {
resolvedType = 'DEFAULT';
}
let col = COLORS[resolvedType] || COLORS['DEFAULT'];
let tIdx = TYPE_INDEX[resolvedType] !== undefined ? TYPE_INDEX[resolvedType] : TYPE_INDEX['DEFAULT'];
if (isExtrude) { if (isExtrude) {
let dx = next.x - current.x; let dx = next.x - current.x;
let dy = next.y - current.y; let dy = next.y - current.y;
let dist = Math.sqrt(dx*dx + dy*dy); let dist = Math.sqrt(dx*dx + dy*dy);
if (dist > 0.0001) { if (dist > 0.0001) {
let hw = 0.4 / 2.0; // 0.4mm wire width let hw = pWidth / 2.0;
let hh = 0.2 / 2.0; // 0.2mm layer height roughly let hh = layerHeight / 2.0;
let nx = -(dy / dist) * hw; let nx = -(dy / dist) * hw;
let ny = (dx / dist) * hw; let ny = (dx / dist) * hw;
@@ -381,15 +451,23 @@ document.addEventListener('DOMContentLoaded', async function() {
for(let k=0; k<6; k++) { currentExtrudeColors.push(col.r*0.8, col.g*0.8, col.b*0.8); currentExtrudeTypes.push(tIdx); } for(let k=0; k<6; k++) { currentExtrudeColors.push(col.r*0.8, col.g*0.8, col.b*0.8); currentExtrudeTypes.push(tIdx); }
} }
} else { } else {
currentTravelPoints.push(current.x, current.y, current.z); // Travel lines get slight vertical offset for visibility
currentTravelPoints.push(next.x, next.y, next.z); let zOff = 0.05;
currentTravelPoints.push(current.x, current.y, current.z + zOff);
currentTravelPoints.push(next.x, next.y, next.z + zOff);
currentTravelColors.push(col.r, col.g, col.b, col.r, col.g, col.b); currentTravelColors.push(col.r, col.g, col.b, col.r, col.g, col.b);
currentTravelTypes.push(tIdx, tIdx); currentTravelTypes.push(tIdx, tIdx);
} }
current.x = next.x; current.y = next.y; current.z = next.z; current.e = next.e; // Update E based on parsed G-code execution type
if (hasE) {
if (relativeE) current.e += eVal;
else current.e = eVal;
}
current.x = next.x; current.y = next.y; current.z = next.z;
} }
} else if (chunk.startsWith('G92')) { } else if (upperChunk.startsWith('G92')) {
let parts = chunk.split(/\s+/); let parts = chunk.split(/\s+/);
for (let p of parts) { for (let p of parts) {
if (p.startsWith('E')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) current.e = v; } if (p.startsWith('E')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) current.e = v; }

View File

@@ -0,0 +1,56 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{{ _('Slice Helper') }}</h1>
</div>
<div class="row">
<!-- Helper Content -->
<div class="col-12 markdown-body p-4 bg-white rounded shadow-sm">
{% if content_html %}
{{ content_html|safe }}
{% else %}
<p class="text-muted">{{ _('Documentation not available.') }}</p>
{% endif %}
</div>
</div>
<style>
/* 简单的 Markdown 样式优化 */
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
}
.markdown-body h1 {
font-size: 2em;
padding-bottom: .3em;
border-bottom: 1px solid #eaecef;
}
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: 6px;
}
.markdown-body pre {
background-color: #f6f8fa;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
}
.markdown-body code {
font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;
}
.markdown-body table {
width: 100%;
margin-bottom: 1rem;
color: #212529;
border-collapse: collapse;
}
.markdown-body table th, .markdown-body table td {
padding: 0.5rem;
border: 1px solid #dee2e6;
}
</style>
{% endblock %}

View File

@@ -6,11 +6,50 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-4"> <!-- STL Files Stats -->
<div class="col-md-6">
<div class="card text-white bg-primary mb-3 shadow-sm border-0"> <div class="card text-white bg-primary mb-3 shadow-sm border-0">
<div class="card-header border-0 fs-5 fw-medium"><i class="bi bi-bar-chart-fill me-2"></i>{{ _('Total Prints') }}</div> <div class="card-header border-0 fs-5 fw-medium">
<i class="bi bi-box me-2"></i>{{ _('3D Model Files (STL)') }}
</div>
<div class="card-body mt-2"> <div class="card-body mt-2">
<h5 class="card-title">{{ _('You have sliced') }} <b class="fs-1 mx-2">{{ current_user.print_files|length }}</b> {{ _('files') }}</h5> <h5 class="card-title mb-3">
{{ _('You have uploaded') }} <b class="fs-1 mx-2">{{ stl_count }}</b> {{ _('files') }}
</h5>
<p class="card-text mb-2">
<i class="bi bi-hdd-fill me-1"></i>{{ _('Total Space Used') }}: <strong>{{ format_size(stl_used_bytes) }}</strong>
{% if stl_quota_mb > 0 %} / <strong>{{ stl_quota_mb }} MB</strong> <small class="opacity-75">({{ _('Quota') }})</small>{% else %} <small class="opacity-75">({{ _('Unlimited') }})</small>{% endif %}
</p>
{% if stl_quota_mb > 0 %}
{% set stl_percent = (stl_used_bytes / (stl_quota_mb * 1024 * 1024) * 100)|round(1) %}
<div class="progress bg-white bg-opacity-25" style="height: 8px;">
<div class="progress-bar {% if stl_percent > 90 %}bg-danger{% elif stl_percent > 75 %}bg-warning{% else %}bg-info{% endif %}" role="progressbar" style="width: {{ stl_percent if stl_percent <= 100 else 100 }}%"></div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- GCode Files Stats -->
<div class="col-md-6">
<div class="card text-white bg-success mb-3 shadow-sm border-0">
<div class="card-header border-0 fs-5 fw-medium">
<i class="bi bi-file-earmark-code me-2"></i>{{ _('Sliced Files (GCode)') }}
</div>
<div class="card-body mt-2">
<h5 class="card-title mb-3">
{{ _('You have sliced or uploaded') }} <b class="fs-1 mx-2">{{ gcode_count }}</b> {{ _('files') }}
</h5>
<p class="card-text mb-2">
<i class="bi bi-hdd-network-fill me-1"></i>{{ _('Total Space Used') }}: <strong>{{ format_size(gcode_used_bytes) }}</strong>
{% if gcode_quota_mb > 0 %} / <strong>{{ gcode_quota_mb }} MB</strong> <small class="opacity-75">({{ _('Quota') }})</small>{% else %} <small class="opacity-75">({{ _('Unlimited') }})</small>{% endif %}
</p>
{% if gcode_quota_mb > 0 %}
{% set gc_percent = (gcode_used_bytes / (gcode_quota_mb * 1024 * 1024) * 100)|round(1) %}
<div class="progress bg-white bg-opacity-25" style="height: 8px;">
<div class="progress-bar {% if gc_percent > 90 %}bg-danger{% elif gc_percent > 75 %}bg-warning{% else %}bg-info{% endif %}" role="progressbar" style="width: {{ gc_percent if gc_percent <= 100 else 100 }}%"></div>
</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -95,23 +95,26 @@
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label for="support-pattern" class="form-label text-secondary small mb-1">{{ _('Support Type') }}</label> <label for="support-pattern" class="form-label text-secondary small mb-1">{{ _('Support Type') }}</label>
<select class="form-select form-select-sm" id="support-pattern" {% if default_support == 'false' %}disabled{% endif %}> <select class="form-select form-select-sm" id="support-pattern" data-selected="{{ default_support_pattern }}" {% if default_support == 'false' %}disabled{% endif %}></select>
<option value="tree" {% if default_support_pattern == 'tree' %}selected{% endif %}>{{ _('Tree') }}</option>
<option value="lines" {% if default_support_pattern == 'lines' %}selected{% endif %}>{{ _('Lines') }}</option>
<option value="grid" {% if default_support_pattern == 'grid' %}selected{% endif %}>{{ _('Grid') }}</option>
<option value="triangles" {% if default_support_pattern == 'triangles' %}selected{% endif %}>{{ _('Triangles') }}</option>
<option value="concentric" {% if default_support_pattern == 'concentric' %}selected{% endif %}>{{ _('Concentric') }}</option>
<option value="zigzag" {% if default_support_pattern == 'zigzag' %}selected{% endif %}>{{ _('Zig Zag') }}</option>
<option value="cross" {% if default_support_pattern == 'cross' %}selected{% endif %}>{{ _('Cross') }}</option>
<option value="gyroid" {% if default_support_pattern == 'gyroid' %}selected{% endif %}>{{ _('Gyroid') }}</option>
<option value="honeycomb" {% if default_support_pattern == 'honeycomb' %}selected{% endif %}>{{ _('Honeycomb') }}</option>
<option value="octagon" {% if default_support_pattern == 'octagon' %}selected{% endif %}>{{ _('Octagon') }}</option>
</select>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="card shadow-sm flex-shrink-0 mb-3">
<div class="card-header bg-light fw-bold text-secondary d-flex justify-content-between align-items-center collapsed" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapseMaterial" aria-expanded="false">
<span><i class="bi bi-box me-2"></i>{{ _('Material Profile') }}</span>
<i class="bi bi-chevron-bar-contract"></i>
</div>
<div id="collapseMaterial" class="collapse" data-bs-parent="#platerSidebarAccordion">
<div class="card-body">
<div class="mb-3">
<select class="form-select bg-light" id="material" data-selected="{{ last_material }}"></select>
</div>
</div>
</div>
</div>
<div class="card shadow-sm flex-shrink-0 mb-3"> <div class="card shadow-sm flex-shrink-0 mb-3">
<div class="card-header bg-light fw-bold text-secondary d-flex justify-content-between align-items-center collapsed" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapseQuality" aria-expanded="false"> <div class="card-header bg-light fw-bold text-secondary d-flex justify-content-between align-items-center collapsed" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapseQuality" aria-expanded="false">
<span><i class="bi bi-gear-wide-connected me-2"></i>{{ _('Quality Profile') }}</span> <span><i class="bi bi-gear-wide-connected me-2"></i>{{ _('Quality Profile') }}</span>
@@ -120,11 +123,7 @@
<div id="collapseQuality" class="collapse" data-bs-parent="#platerSidebarAccordion"> <div id="collapseQuality" class="collapse" data-bs-parent="#platerSidebarAccordion">
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <div class="mb-3">
<select class="form-select bg-light" id="quality"> <select class="form-select bg-light" id="quality" data-selected="{{ last_quality }}"></select>
{% for key, name in presets %}
<option value="{{ key }}" {% if key == last_quality %}selected{% endif %}>{{ _(name) }}</option>
{% endfor %}
</select>
</div> </div>
</div> </div>
</div> </div>
@@ -132,7 +131,7 @@
</div> <!-- End of accordion wrapper --> </div> <!-- End of accordion wrapper -->
<div class="mt-auto pt-3 border-top d-flex flex-column gap-2 mb-1"> <div class="mt-auto pt-3 border-top d-flex flex-column gap-2 mb-1">
<button class="btn btn-outline-danger w-100" onclick="clearPlate()"><i class="bi bi-trash me-2"></i>{{ _('Clear Board') }}</button> <button class="btn btn-outline-danger w-100" onclick="customConfirm('{{ _('Are you sure you want to clear the board?') }}', clearPlate)"><i class="bi bi-trash me-2"></i>{{ _('Clear Board') }}</button>
<button class="btn btn-primary w-100 py-2 fs-5 shadow-sm" onclick="mergeAndSlice()" id="btn-merge"><i class="bi bi-gear-fill me-2" id="merge-icon"></i><span id="merge-text">{{ _('Merge & Slice') }}</span></button> <button class="btn btn-primary w-100 py-2 fs-5 shadow-sm" onclick="mergeAndSlice()" id="btn-merge"><i class="bi bi-gear-fill me-2" id="merge-icon"></i><span id="merge-text">{{ _('Merge & Slice') }}</span></button>
</div> </div>
@@ -148,6 +147,11 @@
<script> <script>
// Toggle icons on collapse // Toggle icons on collapse
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
{% if quota_exceeded %}
window.customConfirm("{{ _('GCode Storage Quota Exceeded. Please delete some files first.') }}",()=>{window.location.href = "{{ url_for('main.files') }}"});
return;
{% endif %}
const cards = document.querySelectorAll('.collapse'); const cards = document.querySelectorAll('.collapse');
cards.forEach(card => { cards.forEach(card => {
card.addEventListener('show.bs.collapse', function () { card.addEventListener('show.bs.collapse', function () {
@@ -221,6 +225,18 @@ function initPlater() {
axesHelper.position.set(-bedWidth / 2, -bedDepth / 2, 0.2); axesHelper.position.set(-bedWidth / 2, -bedDepth / 2, 0.2);
scene.add(axesHelper); scene.add(axesHelper);
{% if configs.get('build_plate_model_path') %}
const bpLoader = new THREE.STLLoader();
bpLoader.load("{{ url_for('main.build_plate_model') }}", function (geometry) {
const material = new THREE.MeshPhongMaterial({ color: 0x999999, specular: 0x111111, shininess: 200 });
const mesh = new THREE.Mesh(geometry, material);
<!-- mesh.rotation.set(-Math.PI / 2, 0, 0); -->
mesh.rotation.set(0, 0, 0);
mesh.position.set(0, 0, -0.1); // Slightly below the grid
scene.add(mesh);
});
{% endif %}
// Show Bed Box outline // Show Bed Box outline
const boxGeo = new THREE.BoxGeometry(bedWidth, bedDepth, bedHeight); const boxGeo = new THREE.BoxGeometry(bedWidth, bedDepth, bedHeight);
const edges = new THREE.EdgesGeometry(boxGeo); const edges = new THREE.EdgesGeometry(boxGeo);
@@ -666,8 +682,22 @@ function addModelToPlate(btnElement, fileId, url, name, status) {
supportSelect.value = data.settings.support; supportSelect.value = data.settings.support;
supportSelect.dispatchEvent(new Event('change')); supportSelect.dispatchEvent(new Event('change'));
} }
if (data.settings.support_pattern) document.getElementById('support-pattern').value = data.settings.support_pattern;
if (data.settings.quality) document.getElementById('quality').value = data.settings.quality; if (data.settings.support_pattern) {
const sSelect = document.getElementById('support-pattern');
sSelect.setAttribute('data-selected', data.settings.support_pattern);
sSelect.value = data.settings.support_pattern;
}
if (data.settings.quality) {
const qSelect = document.getElementById('quality');
qSelect.setAttribute('data-selected', data.settings.quality);
qSelect.value = data.settings.quality;
}
if (data.settings.material) {
const mSelect = document.getElementById('material');
mSelect.setAttribute('data-selected', data.settings.material);
mSelect.value = data.settings.material;
}
} }
} catch (e) {} } catch (e) {}
} }
@@ -861,7 +891,7 @@ function mergeAndSlice() {
if (m.userData.geomTrans) { if (m.userData.geomTrans) {
mat.multiply(m.userData.geomTrans); mat.multiply(m.userData.geomTrans);
} }
const translation = new THREE.Matrix4().makeTranslation((bedWidth / 2) + offsetX, (bedDepth / 2) + offsetY, 0); const translation = new THREE.Matrix4().makeTranslation(offsetX,offsetY, 0);
mat.premultiply(translation); mat.premultiply(translation);
return { return {
file_id: m.userData.fileId, file_id: m.userData.fileId,
@@ -871,6 +901,7 @@ function mergeAndSlice() {
}); });
const quality = document.getElementById('quality').value; const quality = document.getElementById('quality').value;
const material = document.getElementById('material').value;
const infill = document.getElementById('infill-density').value; const infill = document.getElementById('infill-density').value;
const support = document.getElementById('support-type').value; const support = document.getElementById('support-type').value;
const supportPattern = document.getElementById('support-pattern').value; const supportPattern = document.getElementById('support-pattern').value;
@@ -889,14 +920,18 @@ function mergeAndSlice() {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ pieces: pieces, quality: quality, infill: infill, support: support, support_pattern: supportPattern, is_edit: isEdit, target_file_id: targetFileId }) body: JSON.stringify({ pieces: pieces, quality: quality, material: material, infill: infill, support: support, support_pattern: supportPattern, is_edit: isEdit, target_file_id: targetFileId })
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if(data.success) { if(data.success) {
window.location.href = "{{ url_for('main.files') }}"; window.location.href = "{{ url_for('main.files') }}";
} else { } else {
window.customAlert("{{ _('Error:') }} " + data.error); let errorMsg = data.error;
if (errorMsg === 'GCode Storage Quota Exceeded. Please delete some files first.') {
errorMsg = "{{ _('GCode Storage Quota Exceeded. Please delete some files first.') }}";
}
window.customAlert("{{ _('Error:') }} " + errorMsg);
btn.disabled = false; btn.disabled = false;
icon.className = 'bi bi-gear-fill me-2'; icon.className = 'bi bi-gear-fill me-2';
text.innerText = '{{ _("Merge & Slice") }}'; text.innerText = '{{ _("Merge & Slice") }}';
@@ -939,4 +974,48 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
</script> </script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const engine = "{{ configs.get('slicer_engine', 'cura') }}";
const qualitySelect = document.getElementById('quality');
const patternSelect = document.getElementById('support-pattern');
const materialSelect = document.getElementById('material');
fetch(`/api/engine_options/${engine}`)
.then(res => res.json())
.then(data => {
const selQ = qualitySelect.getAttribute('data-selected');
qualitySelect.innerHTML = '';
data.presets.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id; opt.textContent = p.name;
qualitySelect.appendChild(opt);
});
if(selQ) qualitySelect.value = selQ;
const selM = materialSelect.getAttribute('data-selected');
materialSelect.innerHTML = '';
const emptyOpt = document.createElement('option');
emptyOpt.value = ''; emptyOpt.textContent = "{{ _('Auto / Default') }}";
materialSelect.appendChild(emptyOpt);
if(data.materials) {
data.materials.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id; opt.textContent = p.name;
materialSelect.appendChild(opt);
});
}
if(selM) materialSelect.value = selM;
const selP = patternSelect.getAttribute('data-selected');
patternSelect.innerHTML = '';
data.support_patterns.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id; opt.textContent = p.name;
patternSelect.appendChild(opt);
});
if(selP) patternSelect.value = selP;
});
});
</script>
{% endblock %} {% endblock %}

88
app/utils/api_handle.py Normal file
View File

@@ -0,0 +1,88 @@
import functools
from flask import Blueprint, request, jsonify
from app.models import ApiKey, PrintFile, SystemConfig
from app.utils.octoprint_client import OctoPrintClient
api_bp = Blueprint('api_handle', __name__, url_prefix='/api/v1')
def get_octo_client():
url = SystemConfig.query.filter_by(key='octoprint_url').first()
apikey = SystemConfig.query.filter_by(key='octoprint_apikey').first()
if url and url.value and apikey and apikey.value:
return OctoPrintClient(url.value, apikey.value)
return None
def _enrich_job_data(job_data):
if job_data and job_data.get('job', {}).get('file', {}).get('name'):
internal_name = job_data['job']['file']['name']
internal_stl_name = str(internal_name)[:-5]+"stl"
pf = PrintFile.query.filter_by(filename=internal_stl_name).first()
if pf:
job_data['job']['file']['display_name'] = pf.original_filename
else:
job_data['job']['file']['display_name'] = internal_name
return job_data
def require_api_key(f):
@functools.wraps(f)
def decorated(*args, **kwargs):
api_key_header = request.headers.get('X-Api-Key')
if not api_key_header:
return jsonify({'error': 'Missing API Key in headers (X-Api-Key)'}), 401
key_record = ApiKey.query.filter_by(key=api_key_header).first()
if not key_record:
return jsonify({'error': 'Invalid API Key'}), 401
return f(*args, **kwargs)
return decorated
@api_bp.route('/status', methods=['GET'])
@require_api_key
def get_status():
client = get_octo_client()
if not client:
return jsonify({'error': 'Printer not configured'}), 503
try:
status_data = client.get_printer_status()
job_data = client.get_job_info()
job_data = _enrich_job_data(job_data)
return jsonify({'status': status_data, 'job': job_data})
except Exception as e:
return jsonify({'error': str(e)}), 500
@api_bp.route('/octoprint_client', methods=['POST'])
@require_api_key
def invoke_octoprint_client():
"""
Expects JSON payload like:
{
"method": "pause_print",
"kwargs": {"action": "pause"}
}
"""
client = get_octo_client()
if not client:
return jsonify({'error': 'Printer not configured'}), 503
data = request.get_json()
if not data or 'method' not in data:
return jsonify({'error': 'Missing method in JSON payload'}), 400
method_name = data['method']
kwargs = data.get('kwargs', {})
args = data.get('args', [])
if not hasattr(client, method_name):
return jsonify({'error': f'Method {method_name} not found on OctoPrintClient'}), 400
func = getattr(client, method_name)
if not callable(func) or method_name.startswith('_'):
return jsonify({'error': f'Method {method_name} is not allowed'}), 403
try:
result = func(*args, **kwargs)
return jsonify({'success': True, 'result': result})
except Exception as e:
return jsonify({'error': str(e)}), 500

View File

@@ -143,7 +143,6 @@ class ConfParse:
if evaluated != field_val and not isinstance(evaluated, type): if evaluated != field_val and not isinstance(evaluated, type):
if val_dict.get("type") == "str" and not isinstance(evaluated, str): if val_dict.get("type") == "str" and not isinstance(evaluated, str):
if isinstance(evaluated, (list, dict)): if isinstance(evaluated, (list, dict)):
import json
val_dict[field] = json.dumps(evaluated).replace(" ", "") val_dict[field] = json.dumps(evaluated).replace(" ", "")
else: else:
val_dict[field] = str(evaluated) val_dict[field] = str(evaluated)

32
app/utils/gcode_parser.py Normal file
View File

@@ -0,0 +1,32 @@
import os
def get_gcode_metadata(filepath):
metadata = {
'print_time': '-',
'first_layer_time': '-',
'filament_used': '-'
}
if not os.path.exists(filepath):
return metadata
try:
# Read the last few KB to find estimated time and filament used
with open(filepath, 'rb') as f:
f.seek(0, 2)
file_size = f.tell()
chunk_size = min(65536, file_size) # read last 64KB
f.seek(file_size - chunk_size)
chunk = f.read().decode('utf-8', errors='ignore')
lines = chunk.splitlines()
for line in reversed(lines):
if line.startswith('; estimated printing time (normal mode) ='):
metadata['print_time'] = line.split('=')[1].strip()
elif line.startswith('; estimated first layer printing time (normal mode) ='):
metadata['first_layer_time'] = line.split('=')[1].strip()
elif line.startswith('; filament used [mm] ='):
metadata['filament_used'] = line.split('=')[1].strip()
except Exception:
pass
return metadata

View File

@@ -73,6 +73,24 @@ class OctoPrintClient:
payload = {"command": "select", "print": print_after_select} payload = {"command": "select", "print": print_after_select}
return self._request("POST", f"/api/files/{location}/{path}", json=payload) return self._request("POST", f"/api/files/{location}/{path}", json=payload)
def upload_file(self, location, path_to_file, filename_override=None):
"""Upload a file to OctoPrint"""
with open(path_to_file, 'rb') as f:
files = {'file': (filename_override or path_to_file.split('/')[-1], f, 'application/octet-stream')}
url = urljoin(self.base_url, f"/api/files/{location}")
response = self.session.post(url, files=files)
response.raise_for_status()
if response.status_code == 204:
return True
try:
return response.json()
except ValueError:
return response.text
def delete_file(self, location, path):
"""Delete a file from OctoPrint"""
return self._request("DELETE", f"/api/files/{location}/{path}")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Printer Status # Printer Status
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -92,6 +110,10 @@ class OctoPrintClient:
"""Get information about the current print job and progress.""" """Get information about the current print job and progress."""
return self._request("GET", "/api/job") return self._request("GET", "/api/job")
def get_printer_err_log(self):
"""Fetch the printer error log, if available."""
return self._request("GET", "/api/printer/error")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Printer Control # Printer Control
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -123,6 +145,9 @@ class OctoPrintClient:
"""Convenience method to home the printer axes.""" """Convenience method to home the printer axes."""
return self._request("POST", "/api/printer/printhead", json={"command": "home", "axes": axes}) return self._request("POST", "/api/printer/printhead", json={"command": "home", "axes": axes})
def auto_leveling(self):
return self.send_gcode("G29")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Webcam / Video # Webcam / Video
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------

View File

@@ -0,0 +1,24 @@
from .cura_engine import CuraEngine
from .prusa_slicer_engine import PrusaSlicerEngine
def get_all_engines():
"""Returns a list of instantiated engines."""
return [
CuraEngine(),
PrusaSlicerEngine()
]
def get_slicer_engine(engine_name="prusa", print_config_folder=None, config_slice_bin_path=None):
"""
Factory function to retrieve the requested slicing engine instance.
Valid names: 'cura', 'prusa_slicer'
"""
engine_name = engine_name.lower().strip()
if engine_name in ['cura', 'cura_engine', 'curaengine']:
return CuraEngine(print_config_folder)
elif engine_name in ['prusa', 'prusa_slicer', 'prusaslicer']:
return PrusaSlicerEngine(print_config_folder, config_slice_bin_path)
else:
# Default fallback
return PrusaSlicerEngine(print_config_folder, config_slice_bin_path)

View File

@@ -0,0 +1,257 @@
import os
import subprocess
import json
import uuid
import configparser
from app.utils.conf_parse import ConfParse
from app.models import SystemConfig
class CuraEngine:
def __init__(self, print_config_folder=None):
self.name = "cura"
self.display_name = "UltiMaker Cura"
self.is_available = self._check_available()
self.print_config_folder = os.path.join(print_config_folder, "cura_engine") if print_config_folder else None
def _check_available(self):
try:
# check if CuraEngine is available in PATH
result = subprocess.run(["CuraEngine", "help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return result.returncode == 0 or b"Usage:" in result.stdout or b"Usage:" in result.stderr
except (FileNotFoundError, OSError):
return False
def slice(self, app, stl_filepath, gcode_filepath, **kwargs):
"""
Slices via CuraEngine.
Returns (success_bool, error_msg_if_any)
"""
quality_preset = kwargs.get('quality_preset')
infill_density = kwargs.get('infill_density')
support_enable = kwargs.get('support_enable')
support_pattern = kwargs.get('support_pattern')
tmp_def_path = None
try:
printers_path = os.path.join(self.print_config_folder, 'printers') if self.print_config_folder else None
extruders_path = os.path.join(self.print_config_folder, 'extruders') if self.print_config_folder else None
materials_path = os.path.join(self.print_config_folder, 'materials') if self.print_config_folder else None
presets_path = os.path.join(self.print_config_folder, 'quality') if self.print_config_folder else None
variants_path = os.path.join(self.print_config_folder, 'variants') if self.print_config_folder else None
env = os.environ.copy()
env["CURA_ENGINE_SEARCH_PATH"] = f"{printers_path}:{extruders_path}:{materials_path}:{presets_path}:{variants_path}"
db_printer = SystemConfig.query.filter_by(key='default_printer').first()
p_val = db_printer.value if db_printer and db_printer.value else 'creality_ender3v3se.def.json'
if not p_val.endswith('.def.json'): p_val += '.def.json'
def_files = [
os.path.join(printers_path, "fdmprinter.def.json"),
os.path.join(printers_path, "fdmextruder.def.json"),
os.path.join(printers_path, "creality_base.def.json"),
os.path.join(printers_path, p_val)
]
inst_files_list = []
quality_type = None
preset_path = None
if quality_preset:
config = configparser.ConfigParser()
preset_path = os.path.join(presets_path, 'creality', 'presets', quality_preset)
if os.path.exists(preset_path):
config.read(preset_path)
material_type_from_preset = config.get('metadata', 'material', fallback=None)
variant_type = config.get('metadata', 'variant', fallback=None)
quality_type = config.get('metadata', 'quality_type', fallback=None)
# Use explicit material if provided, otherwise fallback to preset's material
material_type = kwargs.get('material_preset') or material_type_from_preset
if material_type:
m_path = os.path.join(materials_path, f"{material_type}.inst.cfg")
if not os.path.exists(m_path) and kwargs.get('material_preset'):
m_path = os.path.join(materials_path, f"{kwargs.get('material_preset')}")
if os.path.exists(m_path): inst_files_list.append(m_path)
if variant_type:
variant_d = variant_type.split("mm")[0]
v_path = os.path.join(variants_path, "creality", f"{p_val.replace('.def.json', '')}_{variant_d}.inst.cfg")
if os.path.exists(v_path): inst_files_list.append(v_path)
if support_pattern == 'tree':
t_path = os.path.join(self.print_config_folder, 'supports', 'tree.inst.cfg') if self.print_config_folder else None
if t_path and os.path.exists(t_path): inst_files_list.append(t_path)
elif support_pattern and support_pattern != 'false':
n_path = os.path.join(self.print_config_folder, 'supports', 'normal.inst.cfg') if self.print_config_folder else None
if n_path and os.path.exists(n_path): inst_files_list.append(n_path)
if quality_preset and quality_type:
g_path = os.path.join(self.print_config_folder, 'creality', 'globals', f"{quality_type}.inst.cfg") if self.print_config_folder else None
if g_path and os.path.exists(g_path): inst_files_list.append(g_path)
if quality_preset and preset_path and os.path.exists(preset_path):
inst_files_list.append(preset_path)
p = ConfParse(def_files)
settings_with_inst = p.add_inst_cfg(inst_files_list)
if infill_density is not None:
if "infill_sparse_density" not in settings_with_inst: settings_with_inst["infill_sparse_density"] = {}
settings_with_inst["infill_sparse_density"]["value"] = str(infill_density)
if "infill_line_distance" not in settings_with_inst: settings_with_inst["infill_line_distance"] = {}
settings_with_inst["infill_line_distance"]["value"] = str(100 / int(infill_density)) if int(infill_density) > 0 else "9999"
if support_enable is not None:
if "support_enable" not in settings_with_inst: settings_with_inst["support_enable"] = {}
settings_with_inst["support_enable"]["value"] = True if support_enable in ['true', 'buildplate'] else False
if "support_type" not in settings_with_inst: settings_with_inst["support_type"] = {}
settings_with_inst["support_type"]["value"] = "'buildplate'" if support_enable == 'buildplate' else "'everywhere'"
if support_pattern == 'tree':
if "support_structure" not in settings_with_inst: settings_with_inst["support_structure"] = {}
settings_with_inst["support_structure"]["value"] = "'tree'"
elif support_pattern and "support_pattern" in settings_with_inst and "options" in settings_with_inst["support_pattern"] and support_pattern in settings_with_inst["support_pattern"]["options"].keys():
if "support_structure" not in settings_with_inst: settings_with_inst["support_structure"] = {}
settings_with_inst["support_structure"]["value"] = "'normal'"
if "support_pattern" not in settings_with_inst: settings_with_inst["support_pattern"] = {}
settings_with_inst["support_pattern"]["value"] = f"'{support_pattern}'"
res = p.parse_configs(settings_with_inst)
override_dict = {}
for k, v in res.items():
if v.get("enabled", True):
val = v.get("value", None)
if val is not None:
override_dict[k] = {"value": val, "default_value": val}
elif "default_value" in v:
override_dict[k] = {"default_value": v["default_value"], "value": v["default_value"]}
tmp_def_filename = f"tmp_{uuid.uuid4().hex}.def.json"
tmp_def_path = os.path.join(app.config['UPLOAD_FOLDER'], tmp_def_filename)
tmp_def_obj = {
"version": 2,
"name": "TempProfile",
"inherits": "fdmprinter",
"metadata": {
"visible": True,
"author": "System",
"manufacturer": "System",
"file_formats": "text/x-gcode",
"first_start_actions": ["MachineSettingsAction"],
"has_materials": True,
"has_variants": True,
"has_machine_quality": True,
"variants_name": "Nozzle Size",
"preferred_variant_name": "0.4mm Nozzle",
"preferred_quality_type": "standard",
"preferred_material": "generic_pla",
},
"overrides": override_dict
}
pretty_json = json.dumps(tmp_def_obj, indent=4)
with open(tmp_def_path, "w") as f:
f.write(pretty_json)
command = [
"CuraEngine", "slice",
"-j", tmp_def_path,
"-l", stl_filepath,
"-o", gcode_filepath
]
app.logger.info(f"Running command: {' '.join(command)}")
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
stdout, stderr = process.communicate()
if process.returncode == 0:
return True, None
else:
err_msg = stderr.decode() if stderr else "Unknown CuraEngine error"
app.logger.error(f"CuraEngine Error: {err_msg}")
return False, err_msg
except Exception as e:
app.logger.error(f"CuraEngine Exception: {e}")
return False, str(e)
finally:
if tmp_def_path and os.path.exists(tmp_def_path):
try:
os.remove(tmp_def_path)
except Exception as e:
app.logger.error(f"Failed to delete temp JSON config {tmp_def_path}: {e}")
def get_quality_presets(self):
try:
path = os.path.join(self.print_config_folder, 'quality', 'creality', 'presets') if self.print_config_folder else None
if not path or not os.path.exists(path): return []
files = [f for f in os.listdir(path) if f.endswith('.inst.cfg')]
presets = []
for f in files:
presets.append({'id': f, 'name': f.replace('.inst.cfg', '')})
presets.sort(key=lambda x: x['name'])
return presets
except:
return []
def get_support_patterns(self):
return [
{'id': 'tree', 'name': 'Tree'},
{'id': 'lines', 'name': 'Lines'},
{'id': 'grid', 'name': 'Grid'},
{'id': 'triangles', 'name': 'Triangles'},
{'id': 'concentric', 'name': 'Concentric'},
{'id': 'zigzag', 'name': 'Zig Zag'},
{'id': 'cross', 'name': 'Cross'},
{'id': 'gyroid', 'name': 'Gyroid'},
{'id': 'honeycomb', 'name': 'Honeycomb'},
{'id': 'octagon', 'name': 'Octagon'}
]
def get_materials(self):
try:
path = os.path.join(self.print_config_folder, 'materials') if self.print_config_folder else None
if not path or not os.path.exists(path): return []
files = [f for f in os.listdir(path) if f.endswith('.inst.cfg')]
materials = []
for f in files:
materials.append({'id': f, 'name': f.replace('.inst.cfg', '').replace('generic_', 'Generic ').replace('_', ' ').title()})
materials.sort(key=lambda x: x['name'])
return materials
except:
return []
def get_bed_dimensions(self):
try:
db_printer = SystemConfig.query.filter_by(key='default_printer').first()
p_val = db_printer.value if db_printer and db_printer.value else 'creality_ender3v3se.def.json'
if not p_val.endswith('.def.json'): p_val += '.def.json'
path = os.path.join(self.print_config_folder, 'printers', p_val)
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
w = data['overrides']['machine_width']['default_value']
h = data['overrides']['machine_depth']['default_value']
hd = data['overrides']['machine_height']['default_value']
return w, h, hd
except:
pass
return 220, 220, 250
def get_all_printers(self):
try:
path = os.path.join(self.print_config_folder, 'printers') if self.print_config_folder else None
if not path or not os.path.exists(path): return []
files = [f for f in os.listdir(path) if f.endswith('.inst.cfg')]
printers = []
for f in files:
printers.append({'id': f, 'name': f.replace('..def.json', '').replace('generic_', 'Generic ').replace('_', ' ').title()})
printers.sort(key=lambda x: x['name'])
return printers
except:
return []

View File

@@ -0,0 +1,195 @@
import os
import subprocess
import configparser
import uuid
import shutil
from app.models import SystemConfig
class PrusaSlicerEngine:
def __init__(self, print_config_folder=None, config_slice_bin_path=None):
self.name = "prusa_slicer"
self.display_name = "PrusaSlicer"
self.config_slice_bin_path = config_slice_bin_path
self.is_available = self._check_available()
self.print_config_folder = os.path.join(print_config_folder, 'prusa_slicer') if print_config_folder else None
def _check_available(self):
try:
# Prefer explicit environment variable, then PATH, then a bundled AppImage under the repo
prusa_bin = self.config_slice_bin_path or shutil.which('prusa-slicer') or shutil.which('prusa-slicer.exe')
if not prusa_bin:
return False
result = subprocess.run([prusa_bin, "--help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return b"Usage:" in result.stdout or b"Slic3r" in result.stdout or b"PrusaSlicer" in result.stdout or result.returncode == 0
except Exception:
return False
def add_ini_keys(self, config_path, target_section, all_configs):
config = configparser.ConfigParser(interpolation=None)
config.read(config_path)
if target_section not in config:
config[target_section] = {}
for k, v in config[target_section].items():
all_configs[k] = v
def slice(self, app, stl_filepath, gcode_filepath, **kwargs):
"""
Slices via prusa-slicer CLI mapping standard kwargs to PRUSA parameters where possible.
"""
try:
prusa_bin = self.config_slice_bin_path or shutil.which('prusa-slicer') or shutil.which('prusa-slicer.exe')
# Base command
command = [prusa_bin, '-g', stl_filepath, '--output', gcode_filepath]
# Map quality, infill, supports to PrusaSlicer CLI arguments.
# Example defaults, normally these would load from an .ini or be dynamically matched.
quality_preset = kwargs.get('quality_preset')
material_preset = kwargs.get('material_preset')
infill_density = kwargs.get('infill_density')
support_enable = kwargs.get('support_enable')
support_pattern = kwargs.get('support_pattern')
# print(support_pattern)
all_configs = {}
db_printer = SystemConfig.query.filter_by(key='default_printer').first()
p_val = db_printer.value if db_printer and db_printer.value else 'Ender3_V3_SE'
if not p_val.endswith('.ini'): p_val += '.ini'
printer_ini = os.path.join(self.print_config_folder, 'printers', p_val) if self.print_config_folder else None
if printer_ini and os.path.exists(printer_ini):
self.add_ini_keys(printer_ini, 'settings', all_configs)
if quality_preset:
q_ini = os.path.join(self.print_config_folder, 'quality', f"{quality_preset}.ini") if self.print_config_folder else None
if q_ini and os.path.exists(q_ini):
self.add_ini_keys(q_ini, 'settings', all_configs)
if material_preset:
m_ini = os.path.join(self.print_config_folder, 'materials', f"{material_preset}.ini") if self.print_config_folder else None
if m_ini and os.path.exists(m_ini):
self.add_ini_keys(m_ini, 'settings', all_configs)
if infill_density is not None:
command.extend([f"--fill-density={infill_density}%"])
if support_enable and support_enable != 'false':
# command.append("--support-material")
if support_enable == 'buildplate':
command.append("--support-material-buildplate-only")
# PrusaSlicer equivalent for tree supports => organic
support_pattern_ini = os.path.join(self.print_config_folder, 'supports', f'{support_pattern}.ini') if self.print_config_folder else None
if support_pattern_ini and os.path.exists(support_pattern_ini):
self.add_ini_keys(support_pattern_ini, 'settings', all_configs)
else:
# Load the default no_support.ini if no support is enabled
no_support_ini = os.path.join(self.print_config_folder, 'supports', 'no_support.ini') if self.print_config_folder else None
if no_support_ini and os.path.exists(no_support_ini):
self.add_ini_keys(no_support_ini, 'settings', all_configs)
else:
all_configs['support_material'] = '0'
tmp_ini_filename = f"tmp_{uuid.uuid4().hex}.ini"
tmp_ini_path = os.path.join(app.config['UPLOAD_FOLDER'], tmp_ini_filename)
with open(tmp_ini_path, 'w') as f:
for key, value in all_configs.items():
f.write(f"{key} = {value}\n")
command.extend(["--load", tmp_ini_path])
env = os.environ.copy()
app.logger.info(f"Running command: {' '.join(command)}")
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
stdout, stderr = process.communicate()
if process.returncode == 0:
# Clean up the temporary .ini file
os.remove(tmp_ini_path)
return True, None
else:
err_msg = stderr.decode() if stderr else "Unknown prusa-slicer error"
app.logger.error(f"PrusaSlicer Error: {err_msg}")
return False, err_msg
except Exception as e:
app.logger.error(f"PrusaSlicer Exception: {e}")
return False, str(e)
def get_quality_presets(self):
all_files = [f for f in os.listdir(os.path.join(self.print_config_folder, "quality")) if f.endswith('.ini')] if self.print_config_folder else []
quality_presets = []
for file in all_files:
with open(os.path.join(self.print_config_folder, "quality", file), 'r') as f:
config = configparser.ConfigParser()
config.read_file(f)
if 'metadata' in config:
quality_presets.append({
'id': file.replace('.ini', ''),
'name': config['metadata'].get('show_name', file.replace('.ini', '').replace('_', ' '))
})
return quality_presets
def get_support_patterns(self):
all_files = [f for f in os.listdir(os.path.join(self.print_config_folder,"supports")) if f.endswith('.ini')] if self.print_config_folder else []
support_presets = []
for file in all_files:
with open(os.path.join(self.print_config_folder, "supports", file), 'r') as f:
config = configparser.ConfigParser()
config.read_file(f)
if 'metadata' in config:
support_presets.append({
'id': file.replace('.ini', ''),
'name': config['metadata'].get('show_name', file.replace('.ini', '').replace('_', ' '))
})
return support_presets
def get_materials(self):
all_files = [f for f in os.listdir(os.path.join(self.print_config_folder, "materials")) if f.endswith('.ini')] if self.print_config_folder else []
materials = []
for file in all_files:
with open(os.path.join(self.print_config_folder, "materials", file), 'r') as f:
config = configparser.ConfigParser()
config.read_file(f)
if 'metadata' in config:
materials.append({
'id': file.replace('.ini', ''),
'name': config['metadata'].get('show_name', file.replace('.ini', '').replace('_', ' '))
})
return materials
def get_bed_dimensions(self):
try:
db_printer = SystemConfig.query.filter_by(key='default_printer').first()
p_val = db_printer.value if db_printer and db_printer.value else 'Ender3_V3_SE.ini'
if not p_val.endswith('.ini'): p_val += '.ini'
path = os.path.join(self.print_config_folder, 'printers', p_val)
config = configparser.ConfigParser()
config.read(path)
if 'settings' in config and 'bed_shape' in config['settings']:
# format is usually like 0x0,220x0,220x220,0x220
coords = config['settings']['bed_shape'].split(',')
max_x = max([float(c.split('x')[0]) for c in coords])
max_y = max([float(c.split('x')[1]) for c in coords])
# height
h = 250
if 'max_print_height' in config['settings']:
h = float(config['settings']['max_print_height'])
return max_x, max_y, h
except:
pass
return 220, 220, 250
def get_all_printers(self):
all_files = [f for f in os.listdir(os.path.join(self.print_config_folder, "printers")) if f.endswith('.ini')] if self.print_config_folder else []
printers = []
for file in all_files:
with open(os.path.join(self.print_config_folder, "printers", file), 'r') as f:
config = configparser.ConfigParser()
config.read_file(f)
if 'metadata' in config:
printers.append({
'id': file.replace('.ini', ''),
'name': config['metadata'].get('show_name', file.replace('.ini', '').replace('_', ' '))
})
return printers

View File

@@ -1,15 +1,17 @@
from huey import SqliteHuey
import subprocess import subprocess
import os import os
from app.models import db, PrintFile, SystemConfig
from app.utils.conf_parse import ConfParse
import json import json
import uuid import uuid
import configparser import configparser
from huey import SqliteHuey
from app import create_app
from app.models import db, PrintFile, SystemConfig
from app.utils.conf_parse import ConfParse
from app.utils.slice_engines import get_slicer_engine
from app.utils.stl_merger import merge_stls
from app.utils.stl_simplifier import simplify_stl
import os
# Ensure instance directory exists # Ensure instance directory exists
instance_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'instance') instance_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'instance')
os.makedirs(instance_dir, exist_ok=True) os.makedirs(instance_dir, exist_ok=True)
@@ -17,12 +19,18 @@ huey_db_path = os.path.join(instance_dir, 'huey_queue.db')
huey = SqliteHuey(filename=huey_db_path) huey = SqliteHuey(filename=huey_db_path)
def get_gcode_dir(app):
with app.app_context():
conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
if conf and conf.value and os.path.exists(conf.value):
return conf.value
return app.config['UPLOAD_FOLDER']
@huey.task() @huey.task()
def slice_stl_task(file_id, stl_filepath, quality_preset=None, infill_density=None, support_enable=None, support_pattern=None, delete_stl=False): def slice_stl_task(file_id, stl_filepath, quality_preset=None, material_preset=None, infill_density=None, support_enable=None, support_pattern=None, delete_stl=False):
# This is run by the Huey worker # This is run by the Huey worker
# We need to create an app context to interact with the database # We need to create an app context to interact with the database
from app import create_app
app = create_app() app = create_app()
with app.app_context(): with app.app_context():
print_file = PrintFile.query.get(file_id) print_file = PrintFile.query.get(file_id)
@@ -31,199 +39,65 @@ def slice_stl_task(file_id, stl_filepath, quality_preset=None, infill_density=No
# Cache variables and commit slicing status # Cache variables and commit slicing status
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode' gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
gcode_filepath = os.path.join(app.config['UPLOAD_FOLDER'], gcode_filename) gcode_filepath = os.path.join(get_gcode_dir(app), gcode_filename)
print_file.status = 'slicing' print_file.status = 'slicing'
db.session.commit() db.session.commit()
# Remove DB session to avoid locking the sqlite db during long slicing operations # Remove DB session to avoid locking the sqlite db during long slicing operations
db.session.remove() db.session.remove()
tmp_def_path = None
try: try:
# Create Cura engine options # Optionally fetch the preferred engine from db conf or just default to prusa
# use our local minimal configurations detached from the entire Cura framework # For now default to prusa or whichever is passed via kwargs if implemented later
print_config_path = os.path.abspath(os.path.join(app.root_path, '..', 'print_config')) conf_engine = SystemConfig.query.filter_by(key='slicer_engine').first()
printers_path = os.path.join(print_config_path, 'printers') engine_name = conf_engine.value if conf_engine and conf_engine.value else "prusa"
extruders_path = os.path.join(print_config_path, 'extruders') db.session.remove()
materials_path = os.path.join(print_config_path, 'materials')
presets_path = os.path.join(print_config_path, 'quality')
variants_path = os.path.join(print_config_path, 'variants')
env = os.environ.copy() slicer = get_slicer_engine(engine_name,app.config['PRINT_CONFIG_FOLDER'])
env["CURA_ENGINE_SEARCH_PATH"] = f"{printers_path}:{extruders_path}:{materials_path}:{presets_path}:{variants_path}"
def_files = [ success, err_msg = slicer.slice(
os.path.join(printers_path, "fdmprinter.def.json"), app=app,
os.path.join(printers_path, "fdmextruder.def.json"), stl_filepath=stl_filepath,
os.path.join(printers_path, "creality_base.def.json"), gcode_filepath=gcode_filepath,
os.path.join(printers_path, "creality_ender3v3se.def.json") quality_preset=quality_preset,
] material_preset=material_preset,
infill_density=infill_density,
inst_files_list = [] support_enable=support_enable,
support_pattern=support_pattern
if quality_preset: )
config = configparser.ConfigParser()
preset_path = os.path.join(presets_path, 'creality', 'presets', quality_preset)
if os.path.exists(preset_path):
config.read(preset_path)
material_type = config.get('metadata', 'material', fallback=None)
variant_type = config.get('metadata', 'variant', fallback=None)
quality_type = config.get('metadata', 'quality_type', fallback=None)
if material_type:
m_path = os.path.join(materials_path, f"{material_type}.inst.cfg")
if os.path.exists(m_path): inst_files_list.append(m_path)
if variant_type:
variant_d = variant_type.split("mm")[0]
v_path = os.path.join(variants_path, "creality", f"creality_ender3v3se_{variant_d}.inst.cfg")
if os.path.exists(v_path): inst_files_list.append(v_path)
if support_pattern == 'tree':
t_path = os.path.join(print_config_path, 'supports', 'tree.inst.cfg')
if os.path.exists(t_path): inst_files_list.append(t_path)
elif support_pattern and support_pattern != 'false':
n_path = os.path.join(print_config_path, 'supports', 'normal.inst.cfg')
if os.path.exists(n_path): inst_files_list.append(n_path)
if quality_preset and quality_type:
g_path = os.path.join(presets_path, 'creality', 'globals', f"{quality_type}.inst.cfg")
if os.path.exists(g_path): inst_files_list.append(g_path)
if quality_preset and os.path.exists(preset_path):
inst_files_list.append(preset_path)
p = ConfParse(def_files)
settings_with_inst = p.add_inst_cfg(inst_files_list)
if infill_density is not None:
if "infill_sparse_density" not in settings_with_inst: settings_with_inst["infill_sparse_density"] = {}
settings_with_inst["infill_sparse_density"]["value"] = str(infill_density)
if "infill_line_distance" not in settings_with_inst: settings_with_inst["infill_line_distance"] = {}
settings_with_inst["infill_line_distance"]["value"] = str(100 / int(infill_density)) if int(infill_density) > 0 else "9999"
if support_enable is not None:
if "support_enable" not in settings_with_inst: settings_with_inst["support_enable"] = {}
settings_with_inst["support_enable"]["value"] = True if support_enable in ['true', 'buildplate'] else False
if "support_type" not in settings_with_inst: settings_with_inst["support_type"] = {}
settings_with_inst["support_type"]["value"] = "'buildplate'" if support_enable == 'buildplate' else "'everywhere'"
if support_pattern == 'tree':
if "support_structure" not in settings_with_inst: settings_with_inst["support_structure"] = {}
settings_with_inst["support_structure"]["value"] = "'tree'"
elif support_pattern in settings_with_inst["support_pattern"]["options"].keys():
if "support_structure" not in settings_with_inst: settings_with_inst["support_structure"] = {}
settings_with_inst["support_structure"]["value"] = "'normal'"
if "support_pattern" not in settings_with_inst: settings_with_inst["support_pattern"] = {}
settings_with_inst["support_pattern"]["value"] = f"'{support_pattern}'"
# Parse to exact values
res = p.parse_configs(settings_with_inst)
override_dict = {}
for k, v in res.items():
if v.get("enabled", True):
val = v.get("value", None)
if val is not None:
# Filter out our protective ConfigStr wrappers
# if type(val).__name__ == "ConfigStr": pass
# else: override_dict[k] = {"default_value": val}
override_dict[k] = {"value": val,"default_value": val}
elif "default_value" in v:
override_dict[k] = {"default_value": v["default_value"], "value": v["default_value"]}
tmp_def_filename = f"tmp_{uuid.uuid4().hex}.def.json"
tmp_def_path = os.path.join(app.config['UPLOAD_FOLDER'], tmp_def_filename)
tmp_def_obj = {
"version": 2,
"name": "TempProfile",
"inherits": "fdmprinter",
"metadata": {
"visible": True,
"author": "System",
"manufacturer": "System",
"file_formats": "text/x-gcode",
"first_start_actions": ["MachineSettingsAction"],
"has_materials": True,
"has_variants": True,
"has_machine_quality": True,
"variants_name": "Nozzle Size",
"preferred_variant_name": "0.4mm Nozzle",
"preferred_quality_type": "standard",
"preferred_material": "generic_pla",
},
"overrides": override_dict
}
pretty_json = json.dumps(tmp_def_obj, indent=4)
with open(tmp_def_path, "w") as f:
f.write(pretty_json)
command = [
"CuraEngine", "slice",
"-j", tmp_def_path,
"-l", stl_filepath,
"-o", gcode_filepath
]
app.logger.info(f"Running command: {' '.join(command)}")
# print(f"Running command: {' '.join(command)}")
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
stdout, stderr = process.communicate()
# if stdout:
# print(f"[CuraEngine STDOUT]\n{stdout.decode('utf-8', errors='ignore')}")
# if stderr:
# print(f"[CuraEngine STDERR]\n{stderr.decode('utf-8', errors='ignore')}", flush=True)
# Re-fetch print_file and update status # Re-fetch print_file and update status
print_file = PrintFile.query.get(file_id) print_file = PrintFile.query.get(file_id)
if not print_file: if not print_file:
return return
if process.returncode == 0: if success:
print_file.status = 'sliced' print_file.status = 'sliced'
else: else:
print_file.status = 'failed' print_file.status = 'failed'
app.logger.error(f"CuraEngine Error: {stderr.decode()}") app.logger.error(f"Slicing Task Failed: {err_msg}")
except Exception as e: except Exception as e:
# Re-fetch in case of exception
print_file = PrintFile.query.get(file_id) print_file = PrintFile.query.get(file_id)
if print_file: if print_file:
print_file.status = 'failed' print_file.status = 'failed'
app.logger.error(f"Subprocess Exception: {e}") app.logger.error(f"Subprocess Exception: {e}")
if delete_stl and os.path.exists(stl_filepath): finally:
try: if delete_stl and os.path.exists(stl_filepath):
os.remove(stl_filepath) try:
except Exception as e: os.remove(stl_filepath)
app.logger.error(f"Failed to delete temp STL {stl_filepath}: {e}") except Exception as e:
app.logger.error(f"Failed to delete temp STL {stl_filepath}: {e}")
if tmp_def_path and os.path.exists(tmp_def_path):
try: db.session.commit()
os.remove(tmp_def_path) db.session.remove()
# pass
except Exception as e:
app.logger.error(f"Failed to delete temp JSON config {tmp_def_path}: {e}")
db.session.commit()
db.session.remove()
@huey.task() @huey.task()
def merge_and_slice_task(file_id, inputs, merged_filepath, quality_preset=None, infill_density=None, support_enable=None, support_pattern=None, delete_stl=False): def merge_and_slice_task(file_id, inputs, merged_filepath, quality_preset=None, material_preset=None, infill_density=None, support_enable=None, support_pattern=None, delete_stl=False):
from app import create_app
app = create_app() app = create_app()
with app.app_context(): with app.app_context():
from app.models import PrintFile, db
print_file = PrintFile.query.get(file_id) print_file = PrintFile.query.get(file_id)
if not print_file: if not print_file:
return return
@@ -231,12 +105,11 @@ def merge_and_slice_task(file_id, inputs, merged_filepath, quality_preset=None,
db.session.remove() db.session.remove()
try: try:
from app.utils.stl_merger import merge_stls
merge_stls(inputs, merged_filepath) merge_stls(inputs, merged_filepath)
# Now trigger the regular slicing task # Now trigger the regular slicing task
# We can just call the slicing logic or enqueue it # We can just call the slicing logic or enqueue it
slice_stl_task(file_id, merged_filepath, quality_preset, infill_density, support_enable, support_pattern, delete_stl=delete_stl) slice_stl_task(file_id, merged_filepath, quality_preset, material_preset, infill_density, support_enable, support_pattern, delete_stl=delete_stl)
except Exception as e: except Exception as e:
print_file = PrintFile.query.get(file_id) print_file = PrintFile.query.get(file_id)
if print_file: if print_file:
@@ -248,13 +121,8 @@ def merge_and_slice_task(file_id, inputs, merged_filepath, quality_preset=None,
@huey.task() @huey.task()
def simplify_stl_task(file_id, filepath): def simplify_stl_task(file_id, filepath):
from app import create_app
app = create_app() app = create_app()
with app.app_context(): with app.app_context():
from app.models import PrintFile, SystemConfig, db
import os
from app.utils.stl_simplifier import simplify_stl
print_file = PrintFile.query.get(file_id) print_file = PrintFile.query.get(file_id)
if not print_file: if not print_file:
return return

196
install.sh Executable file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
# 安装目录确认(默认使用脚本所在目录)
DEFAULT_INSTALL_DIR="$REPO_DIR"
echo "默认安装目录: $DEFAULT_INSTALL_DIR"
read -r -p "请输入安装目录(回车使用默认): " INSTALL_DIR_INPUT
if [ -z "$INSTALL_DIR_INPUT" ]; then
INSTALL_DIR="$DEFAULT_INSTALL_DIR"
else
INSTALL_DIR="$INSTALL_DIR_INPUT"
fi
# 创建并解析目标路径为绝对路径
mkdir -p "$INSTALL_DIR"
INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)"
if [ "$INSTALL_DIR" != "$REPO_DIR" ]; then
echo "选择的安装目录 ($INSTALL_DIR) 与脚本所在目录 ($REPO_DIR) 不同。"
if [ -f "$INSTALL_DIR/run_main.sh" ] || [ -f "$INSTALL_DIR/install.sh" ]; then
echo "目标目录已包含仓库文件;将在该目录继续安装。"
REPO_DIR="$INSTALL_DIR"
else
read -r -p "目标目录不包含本仓库。是否将当前仓库复制到 $INSTALL_DIR 并在其下继续安装?输入 'yes' 或 'y' 表示同意(默认 no " COPY_REPLY
COPY_REPLY="${COPY_REPLY:-no}"
case "${COPY_REPLY,,}" in
y|yes||1)
COPY_CONFIRM=1
;;
*)
COPY_CONFIRM=0
;;
esac
if [ "$COPY_CONFIRM" -eq 1 ]; then
if command -v rsync >/dev/null 2>&1; then
rsync -a --exclude='.git' "$REPO_DIR/" "$INSTALL_DIR/"
else
cp -a "$REPO_DIR/." "$INSTALL_DIR/"
fi
REPO_DIR="$INSTALL_DIR"
echo "仓库已复制到 $REPO_DIR"
else
echo "将继续使用脚本所在目录作为仓库路径:$REPO_DIR"
echo "注意systemd 服务和脚本仍将引用 $REPO_DIR"
fi
fi
fi
# 依赖于 REPO_DIR 的路径和变量
VENV_DIR="$REPO_DIR/venv"
PYTHON_BIN="${PYTHON:-python3}"
PRUSA_URL="https://github.com/davidk/PrusaSlicer-ARM.AppImage/releases/download/version_2.9.4/PrusaSlicer-2.9.4-aarch64-full.AppImage"
PRUSA_DIR="$REPO_DIR/prusaslicer"
PRUSA_FILE="$PRUSA_DIR/$(basename "$PRUSA_URL")"
PRUSA_SKIP_DOWNLOAD="${PRUSA_SKIP_DOWNLOAD:-0}"
PRUSA_AGPL_ACCEPT="${PRUSA_AGPL_ACCEPT:-0}"
echo "正在将 AIO_3D_Print_Web_Platform 安装到:$REPO_DIR"
echo "使用的 Python: $PYTHON_BIN"
echo "如果服务正在运行,将尝试停止它们(可能需要 sudo"
sudo systemctl stop aio-3d-main.service 2>/dev/null || true
sudo systemctl stop aio-3d-huey.service 2>/dev/null || true
echo "正在创建虚拟环境(如果不存在):$VENV_DIR"
if [ ! -d "$VENV_DIR" ]; then
$PYTHON_BIN -m venv "$VENV_DIR"
fi
echo "正在激活虚拟环境并安装 Python 依赖"
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"
# 检测 http(s) 代理(支持大小写环境变量)
PROXY=""
if [ -n "${HTTPS_PROXY:-}" ]; then
PROXY="$HTTPS_PROXY"
elif [ -n "${https_proxy:-}" ]; then
PROXY="$https_proxy"
elif [ -n "${HTTP_PROXY:-}" ]; then
PROXY="$HTTP_PROXY"
elif [ -n "${http_proxy:-}" ]; then
PROXY="$http_proxy"
fi
pip_with_proxy() {
# 用法: pip_with_proxy install [参数...]
if [ -n "$PROXY" ] && [ "$1" = "install" ]; then
shift
pip install --proxy "$PROXY" "$@"
else
pip "$@"
fi
}
pip_with_proxy install --upgrade pip setuptools wheel
if [ -f "$REPO_DIR/requirements.txt" ]; then
pip_with_proxy install -r "$REPO_DIR/requirements.txt"
else
echo "警告:在 $REPO_DIR 未找到 requirements.txt"
fi
echo "确保运行脚本具有可执行权限"
chmod +x "$REPO_DIR/run_main.sh" "$REPO_DIR/run_huey.sh"
echo "正在检查 PrusaSlicer AppImage可选"
mkdir -p "$PRUSA_DIR"
if [ ! -f "$PRUSA_FILE" ]; then
if [ "$PRUSA_SKIP_DOWNLOAD" = "1" ]; then
echo "检测到 PRUSA_SKIP_DOWNLOAD=1跳过 PrusaSlicer 下载。"
else
cat <<'AGPL_NOTICE'
PrusaSlicer 使用 GNU Affero General Public License v3 (AGPLv3) 授权。
源码仓库: https://github.com/prusa3d/PrusaSlicer
本安装器引用的二进制仓库: https://github.com/davidk/PrusaSlicer-ARM.AppImage
下载并运行 PrusaSlicer 即表示您同意 AGPLv3 的许可条款。
如果您通过网络向用户提供基于该软件的服务AGPLv3 可能要求您向用户提供相应源码。
详情请参见 third_party/PRUSASLICER.md 获取源码与合规说明。
AGPL_NOTICE
if [ "$PRUSA_AGPL_ACCEPT" != "1" ]; then
read -r -p "是否接受 AGPLv3 许可并允许下载 PrusaSlicer 二进制?输入 'yes' 或 'y' 表示同意(或设置 PRUSA_AGPL_ACCEPT=1 自动同意): " PRUSA_REPLY
else
PRUSA_REPLY="yes"
fi
case "${PRUSA_REPLY,,}" in
y|yes||1)
PRUSA_APPROVED=1
;;
*)
PRUSA_APPROVED=0
;;
esac
if [ "$PRUSA_APPROVED" -eq 1 ]; then
echo "正在下载 PrusaSlicer AppImage 到 $PRUSA_FILE"
if command -v curl >/dev/null 2>&1; then
if [ -n "$PROXY" ]; then
curl -x "$PROXY" -L -o "$PRUSA_FILE" "$PRUSA_URL"
else
curl -L -o "$PRUSA_FILE" "$PRUSA_URL"
fi
elif command -v wget >/dev/null 2>&1; then
if [ -n "$PROXY" ]; then
env HTTP_PROXY="$PROXY" HTTPS_PROXY="$PROXY" wget -O "$PRUSA_FILE" "$PRUSA_URL"
else
wget -O "$PRUSA_FILE" "$PRUSA_URL"
fi
else
echo "警告:未检测到 curl 或 wget无法自动下载 PrusaSlicer AppImage。"
fi
if [ -f "$PRUSA_FILE" ]; then
chmod +x "$PRUSA_FILE"
fi
else
echo "用户未接受 AGPL已跳过 PrusaSlicer 下载。"
fi
fi
else
echo "已存在 PrusaSlicer AppImage$PRUSA_FILE"
fi
echo "准备并安装 systemd 服务文件(需要 sudo"
for svc in "aio-3d-main.service" "aio-3d-huey.service"; do
SRC="$REPO_DIR/$svc"
if [ ! -f "$SRC" ]; then
echo "警告:未找到 $SRC,跳过"
continue
fi
if [ "$svc" = "aio-3d-main.service" ]; then
EXEC="$REPO_DIR/run_main.sh"
else
EXEC="$REPO_DIR/run_huey.sh"
fi
TMPFILE="/tmp/$svc"
awk -v wd="$REPO_DIR" -v exec="$EXEC" '
{ if ($0 ~ /^WorkingDirectory=/) { print "WorkingDirectory=" wd; next } \
if ($0 ~ /^ExecStart=/) { print "ExecStart=" exec; next } \
print $0 }' "$SRC" > "$TMPFILE"
echo "正在安装 $svc -> /etc/systemd/system/$svc"
sudo cp "$TMPFILE" "/etc/systemd/system/$svc"
done
echo "重新加载 systemd 守护进程并启用服务"
sudo systemctl daemon-reload
sudo systemctl enable aio-3d-main.service aio-3d-huey.service || true
sudo systemctl restart aio-3d-huey.service || true
sudo systemctl restart aio-3d-main.service || true
echo "安装完成"

BIN
out.stl

Binary file not shown.

View File

@@ -1,178 +0,0 @@
import re
with open('app/routes/printer_routes.py', 'r', encoding='utf-8') as f:
content = f.read()
# Find everything between @sock.route('/proxy', bp=printer_bp) and def octo_proxy(path):
start_str = "@sock.route('/proxy', bp=printer_bp)"
end_str = "@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])"
pre_content = content[:content.find(start_str)]
post_content = content[content.find(end_str):]
new_proxy = """@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
@printer_bp.route('/proxy/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
@login_required
def octo_proxy(path):
if not current_user.is_admin:
return "Unauthorized", 403
url_config = SystemConfig.query.filter_by(key='octoprint_url').first()
if not url_config or not url_config.value:
return "OctoPrint URL not configured", 404
base_url = url_config.value.rstrip('/')
# --- WebSocket Proxy Logic ---
if request.headers.get('Upgrade', '').lower() == 'websocket':
from flask_sock import Server, ConnectionClosed
# Check if environment supports WebSockets
try:
ws = Server(request.environ)
except Exception as e:
return "WebSocket Upgrade Failed", 400
def handle_ws():
if base_url.startswith('https://'):
ws_base = base_url.replace('https://', 'wss://', 1)
else:
ws_base = base_url.replace('http://', 'ws://', 1)
target_url = f"{ws_base}/{path}"
if request.query_string:
target_url = f"{target_url}?{request.query_string.decode('utf-8')}"
# Forward essential headers like NGINX Proxy
headers = {
'Host': request.host,
'X-Real-IP': request.remote_addr,
}
forwarded_for = request.headers.get('X-Forwarded-For', '')
headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr
headers['X-Forwarded-Proto'] = request.scheme
headers['X-Forwarded-Host'] = request.host
headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))
if request.headers.get('Cookie'):
headers['Cookie'] = request.headers.get('Cookie')
try:
remote_ws = ws_connect(target_url, additional_headers=headers)
except Exception as e:
ws.close(1011, str(e))
return
def recv_loop():
try:
for message in remote_ws:
ws.send(message)
except Exception:
pass
finally:
try: remote_ws.close()
except: pass
try: ws.close()
except: pass
t = threading.Thread(target=recv_loop)
t.daemon = True
t.start()
try:
while True:
data = ws.receive()
if data is None:
break
remote_ws.send(data)
except Exception:
pass
finally:
try: remote_ws.close()
except: pass
try: ws.close()
except: pass
try:
handle_ws()
except ConnectionClosed:
pass
except Exception:
pass
finally:
try: ws.close()
except: pass
class WebSocketResponse(Response):
def __call__(self, *args, **kwargs):
if getattr(ws, 'mode', 'werkzeug') == 'werkzeug':
return super().__call__(*args, **kwargs)
return []
return WebSocketResponse()
# --- Standard HTTP Proxy Logic ---
from urllib.parse import urlparse
target_url = f"{base_url}/{path}"
if request.query_string:
target_url = f"{target_url}?{request.query_string.decode('utf-8')}"
# Build headers for reverse proxy based on nginx config reference
parsed_base = urlparse(base_url)
headers = {k: v for k, v in request.headers if k.lower() not in ['host', 'content-length']}
# NGINX equivalent proxy headers
headers['Host'] = request.host
headers['X-Real-IP'] = request.remote_addr
headers['X-Real-Port'] = str(request.environ.get('REMOTE_PORT', ''))
forwarded_for = request.headers.get('X-Forwarded-For', '')
headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr
headers['X-Forwarded-Protocol'] = request.scheme
headers['X-Script-Name'] = "/printer/proxy"
headers['X-Forwarded-Host'] = request.host
headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))
headers['REMOTE-HOST'] = request.remote_addr
if request.headers.get('Upgrade'):
headers['Upgrade'] = request.headers.get('Upgrade')
if request.headers.get('Connection'):
headers['Connection'] = request.headers.get('Connection')
try:
# proxy_connect_timeout 60s, proxy_read_timeout 600s
resp = requests.request(
method=request.method,
url=target_url,
headers=headers,
data=request.get_data(),
cookies=request.cookies,
allow_redirects=False,
stream=True,
timeout=(60, 600)
)
except requests.exceptions.RequestException as e:
return f"Proxy connection error: {str(e)}", 502
# Strip headers that might break the iframe or framing
excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'x-frame-options', 'content-security-policy']
response_headers = [(name, value) for (name, value) in resp.headers.items() if name.lower() not in excluded_headers]
def generate():
for chunk in resp.iter_content(chunk_size=8192):
if chunk:
yield chunk
return Response(generate(), resp.status_code, response_headers)
"""
post_end_str = " return Response(generate(), resp.status_code, response_headers)"
post_end_idx = post_content.find(post_end_str) + len(post_end_str)
final_content = pre_content + new_proxy + post_content[post_end_idx:]
with open('app/routes/printer_routes.py', 'w', encoding='utf-8') as f:
f.write(final_content)
print("Patched!")

View File

@@ -1,45 +0,0 @@
import re
with open('app/routes/printer_routes.py', 'r', encoding='utf-8') as f:
content = f.read()
# Replace the WS Proxy header logic
target_str = """ ws_headers['Host'] = request.host
ws_headers['X-Real-IP'] = request.remote_addr
forwarded_for = request.headers.get('X-Forwarded-For', '')
ws_headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr
ws_headers['X-Forwarded-Proto'] = request.scheme
ws_headers['X-Forwarded-Host'] = request.host
ws_headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))"""
replacement_str = """ ws_headers['Host'] = request.host
ws_headers['X-Real-IP'] = request.remote_addr
forwarded_for = request.headers.get('X-Forwarded-For', '')
ws_headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr
ws_headers['X-Forwarded-Proto'] = request.scheme
ws_headers['X-Forwarded-Host'] = request.host
ws_headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))
ws_headers['X-Script-Name'] = '/printer/proxy'"""
new_content = content.replace(target_str, replacement_str)
origin_str = """ # Mask Origin/Referer to bypass Octoprint CSRF if needed
if 'Origin' in ws_headers:
ws_headers['Origin'] = base_url
if 'Referer' in ws_headers:
ws_headers['Referer'] = f"{base_url}/{path}\""""
replacement_origin = """ # Match Tornado's expectations for Origin to avoid 400 Bad Request
parsed_base = urlparse(base_url)
ws_headers['Host'] = parsed_base.netloc
if 'Origin' in ws_headers:
ws_headers['Origin'] = base_url
if 'Referer' in ws_headers:
ws_headers['Referer'] = f"{base_url}/{path}\""""
new_content = new_content.replace(origin_str, replacement_origin)
with open('app/routes/printer_routes.py', 'w', encoding='utf-8') as f:
f.write(new_content)
print('Patched!')

Some files were not shown because too many files have changed in this diff Show More