diff --git a/a1_swap_mod_packer/gui.py b/a1_swap_mod_packer/gui.py index fed3e04..51bc287 100644 --- a/a1_swap_mod_packer/gui.py +++ b/a1_swap_mod_packer/gui.py @@ -58,6 +58,7 @@ except ImportError as exc: # pragma: no cover raise SystemExit("PySide6 is required to run the GUI. Install it with: pip install PySide6") from exc from . import APP_NAME, APP_TITLE +from .i18n import t, switch as i18n_switch, current_locale from .printer import PrinterConfig, send_to_printer from .batch import IndividualBuildTask, individual_batch_worker_count, run_individual_batch_builds from .core import ( @@ -396,7 +397,7 @@ class MainWindow(QMainWindow): # ===================================================================== # --- 文件输入区 --- # ===================================================================== - file_group = QGroupBox("输入 3MF 文件") + file_group = QGroupBox(t("gui.file_group.title")) self.file_group = file_group file_layout = QVBoxLayout(file_group) file_body = QHBoxLayout() @@ -405,7 +406,7 @@ class MainWindow(QMainWindow): table_layout = QVBoxLayout() self.table = DropTableWidget(self.add_paths, self.remove_selected) self.table.setColumnCount(5) - self.table.setHorizontalHeaderLabels(["顺序", "3MF 文件", "份数", "时间", "耗材"]) + self.table.setHorizontalHeaderLabels([t("gui.file_group.table.header.order"), t("gui.file_group.table.header.file"), t("gui.file_group.table.header.copies"), t("gui.file_group.table.header.time"), t("gui.file_group.table.header.filament")]) self.table.horizontalHeader().setSectionResizeMode(ORDER_COLUMN, QHeaderView.ResizeToContents) self.table.horizontalHeader().setSectionResizeMode(FILE_COLUMN, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(COPIES_COLUMN, QHeaderView.ResizeToContents) @@ -417,20 +418,20 @@ class MainWindow(QMainWindow): self.table.setMinimumHeight(120) self.table.setSelectionBehavior(QTableWidget.SelectRows) self.table.setAlternatingRowColors(True) - self.table.setToolTip("将 .3mf 文件或文件夹拖放到此处。文件夹将添加所有顶层 .3mf 文件。") + self.table.setToolTip(t("gui.file_group.table.tooltip")) self.table.itemChanged.connect(self.on_table_item_changed) self.table.itemSelectionChanged.connect(self.update_thumbnail_preview) table_layout.addWidget(self.table) # --- 合计统计标签 --- - self.total_summary_label = QLabel("合计:0 板 | 时间:0s | 耗材:0.00 g") + self.total_summary_label = QLabel(t("gui.file_group.total_summary.empty")) table_layout.addWidget(self.total_summary_label) file_body.addLayout(table_layout, 1) # --- 缩略图预览 --- - preview_group = QGroupBox("选中文件的缩略图") + preview_group = QGroupBox(t("gui.preview.title")) preview_layout = QVBoxLayout(preview_group) - self.thumbnail_label = QLabel("选择一个输入文件") + self.thumbnail_label = QLabel(t("gui.preview.select_hint")) self.thumbnail_label.setAlignment(Qt.AlignCenter) self.thumbnail_label.setFixedSize(240, 170) self.thumbnail_label.setStyleSheet( @@ -453,11 +454,11 @@ class MainWindow(QMainWindow): # --- 文件操作按钮栏 --- file_buttons = QHBoxLayout() - add_button = QPushButton("添加 3MF") - remove_button = QPushButton("移除") - remove_all_button = QPushButton("全部移除") - apply_default_copies_button = QPushButton("应用默认份数至选中行") - self.build_button = QPushButton("构建 3MF") + add_button = QPushButton(t("gui.button.add_3mf")) + remove_button = QPushButton(t("gui.button.remove")) + remove_all_button = QPushButton(t("gui.button.remove_all")) + apply_default_copies_button = QPushButton(t("gui.button.apply_copies")) + self.build_button = QPushButton(t("gui.button.build")) self.build_button.setMinimumHeight(48) self.build_button.setMinimumWidth(160) build_font = self.build_button.font() @@ -482,7 +483,7 @@ class MainWindow(QMainWindow): # ===================================================================== # --- 打包选项区 --- # ===================================================================== - options_group = QGroupBox("打包选项") + options_group = QGroupBox(t("gui.options.group.title")) grid = QGridLayout(options_group) grid.setColumnStretch(1, 1) grid.setColumnStretch(3, 1) @@ -494,8 +495,8 @@ class MainWindow(QMainWindow): self.swap_gcode_combo = QComboBox() self.swap_gcode_combo.setMinimumWidth(240) self.swap_gcode_combo.setMaximumWidth(440) - refresh_button = QPushButton("刷新") - open_folder_button = QPushButton("打开文件夹") + refresh_button = QPushButton(t("gui.options.refresh")) + open_folder_button = QPushButton(t("gui.options.open_folder")) refresh_button.setFixedWidth(refresh_button.sizeHint().width()) open_folder_button.setFixedWidth(open_folder_button.sizeHint().width()) refresh_button.clicked.connect(self.load_swap_gcode_to_combo) @@ -507,7 +508,7 @@ class MainWindow(QMainWindow): self.default_copies_edit.setFixedWidth(64) # 热床降温 - self.bed_cooldown_check = QCheckBox("等待热床降温") + self.bed_cooldown_check = QCheckBox(t("gui.options.bed_cooldown")) self.bed_cooldown_check.setChecked(True) self.cool_bed_edit = QLineEdit("45") self.cool_bed_edit.setValidator(QIntValidator(0, 120)) @@ -515,11 +516,11 @@ class MainWindow(QMainWindow): bed_row = QHBoxLayout() bed_row.addWidget(self.bed_cooldown_check) bed_row.addWidget(self.cool_bed_edit) - bed_row.addWidget(QLabel("°C")) + bed_row.addWidget(QLabel(t("gui.options.bed_cooldown.suffix"))) bed_row.addStretch(1) # 换盘后等待时间(勾选后启用,不勾选则不插入等待指令) - self.eject_wait_check = QCheckBox("换盘后等待") + self.eject_wait_check = QCheckBox(t("gui.options.eject_wait")) self.eject_wait_check.setChecked(True) self.wait_edit = QLineEdit("30") self.wait_edit.setValidator(QIntValidator(0, 3600)) @@ -527,23 +528,23 @@ class MainWindow(QMainWindow): eject_wait_row = QHBoxLayout() eject_wait_row.addWidget(self.eject_wait_check) eject_wait_row.addWidget(self.wait_edit) - eject_wait_row.addWidget(QLabel("秒")) + eject_wait_row.addWidget(QLabel(t("gui.options.wait_suffix"))) eject_wait_row.addStretch(1) # 剩余时间板号显示 - self.show_plate_number_check = QCheckBox("在剩余时间的百位显示当前板号") + self.show_plate_number_check = QCheckBox(t("gui.options.plate_number")) self.show_plate_number_check.setChecked(True) # 最后换盘 - self.swap_final_check = QCheckBox("最后一块盘后也执行换盘 G-code") + self.swap_final_check = QCheckBox(t("gui.options.final_swap")) self.swap_final_check.setChecked(True) # G-code 补丁 - self.patch_check = QCheckBox("应用可编辑的 G-code 补丁") - self.patch_check.setToolTip("使用 gcode_patches.ini") + self.patch_check = QCheckBox(t("gui.options.gcode_patches")) + self.patch_check.setToolTip(t("gui.options.gcode_patches.tooltip")) self.patch_check.setChecked(True) patch_row = QHBoxLayout() - open_patch_button = QPushButton("打开配置文件") + open_patch_button = QPushButton(t("gui.options.open_config")) open_patch_button.clicked.connect(self.open_patch_config) patch_row.addWidget(self.patch_check) patch_row.addWidget(open_patch_button) @@ -551,46 +552,46 @@ class MainWindow(QMainWindow): # 3MF 元数据模式 self.metadata_combo = QComboBox() - self.metadata_combo.addItem("保留原始预测和重量", "source") - self.metadata_combo.addItem("累加预测和耗材用量", "sum") + self.metadata_combo.addItem(t("gui.options.metadata.source"), "source") + self.metadata_combo.addItem(t("gui.options.metadata.sum"), "sum") self.metadata_combo.setFixedWidth(220) # ZIP 压缩级别 self.zip_level_combo = QComboBox() for level in range(1, 10): - self.zip_level_combo.addItem(f"级别 {level}", level) + self.zip_level_combo.addItem(t("gui.options.zip_compression.level_format", level=level), level) self.zip_level_combo.setCurrentIndex(DEFAULT_ZIP_COMPRESS_LEVEL - 1) self.zip_level_combo.setFixedWidth(120) - self.zip_level_combo.setToolTip("输出 3MF 的 zlib-ng Deflate 压缩级别。") + self.zip_level_combo.setToolTip(t("gui.options.zip_compression.tooltip")) # 独立批处理模式 - self.individual_batch_check = QCheckBox("独立批处理模式") + self.individual_batch_check = QCheckBox(t("gui.options.batch_mode")) self.individual_batch_check.setToolTip( - "将每行输入构建为独立的输出文件,使用该行的份数。" + t("gui.options.batch_mode.tooltip") ) # 构建后清空 / 跳过重复 - self.clear_after_build_check = QCheckBox("构建成功后清空输入列表") + self.clear_after_build_check = QCheckBox(t("gui.options.clear_after_build")) self.clear_after_build_check.setChecked(False) - self.skip_duplicates_check = QCheckBox("添加输入时跳过重复文件路径") + self.skip_duplicates_check = QCheckBox(t("gui.options.skip_duplicates")) self.skip_duplicates_check.setChecked(True) # 标签列固定宽度(左对齐),控件列自动对齐(stretch=1 平分空间) grid.setColumnMinimumWidth(0, 120) grid.setColumnMinimumWidth(2, 90) for label_text, row, col in [ - ("换盘 G-code", 0, 0), - ("新输入的默认份数", 1, 0), - ("换盘后等待时间", 2, 0), - ("最后换盘", 3, 0), - ("3MF 元数据", 4, 0), - ("批处理模式", 5, 0), - ("热床降温", 1, 2), - ("剩余时间板号", 2, 2), - ("G-code 补丁", 3, 2), - ("ZIP 压缩", 4, 2), - ("输入处理", 5, 2), - ("构建成功后", 6, 0), + (t("gui.options.swap_gcode.label"), 0, 0), + (t("gui.options.default_copies"), 1, 0), + (t("gui.options.eject_wait.label"), 2, 0), + (t("gui.options.final_swap.label"), 3, 0), + (t("gui.options.metadata.label"), 4, 0), + (t("gui.options.batch_mode.label"), 5, 0), + (t("gui.options.bed_cooldown.label"), 1, 2), + (t("gui.options.plate_number.label"), 2, 2), + (t("gui.options.gcode_patches.label"), 3, 2), + (t("gui.options.zip_compression.label"), 4, 2), + (t("gui.options.input_handling"), 5, 2), + (t("gui.options.clear_after_build.label"), 6, 0), ]: label = QLabel(label_text) label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) @@ -626,50 +627,59 @@ class MainWindow(QMainWindow): # 第 6 行:构建成功后清空输入列表(独立一行) grid.addWidget(self.clear_after_build_check, 6, 1, 1, 3) + + # 语言选择 + self.language_combo = QComboBox() + self.language_combo.addItem("简体中文", "zh_CN") + self.language_combo.addItem("English", "en") + self.language_combo.setFixedWidth(120) + grid.addWidget(QLabel(t("gui.options.language")), 6, 0) + grid.addWidget(self.language_combo, 6, 1) + root.addWidget(options_group) # ===================================================================== # --- 输出区 --- # ===================================================================== - output_group = QGroupBox("输出") + output_group = QGroupBox(t("gui.output.group.title")) output_layout = QFormLayout(output_group) # 输出目录 output_dir_row = QHBoxLayout() self.output_dir_edit = QLineEdit() - self.output_dir_edit.setPlaceholderText("留空则写入输入文件所在目录") + self.output_dir_edit.setPlaceholderText(t("gui.output.dir.placeholder")) self.output_dir_edit.setMinimumWidth(240) self.output_dir_edit.setMaximumWidth(520) - browse_output_dir_button = QPushButton("浏览") + browse_output_dir_button = QPushButton(t("gui.output.dir.browse")) browse_output_dir_button.setFixedWidth(browse_output_dir_button.sizeHint().width()) browse_output_dir_button.clicked.connect(self.choose_output_dir) output_dir_row.addWidget(self.output_dir_edit, 1) output_dir_row.addWidget(browse_output_dir_button) - output_layout.addRow("输出目录", output_dir_row) + output_layout.addRow(t("gui.output.dir.label"), output_dir_row) # 输出文件名规则 self.output_name_edit = QLineEdit(DEFAULT_OUTPUT_PATTERN) - self.output_name_edit.setPlaceholderText("可使用 {source}、{sources}、{plates}、{copies}、{date}、{time} 等标记") + self.output_name_edit.setPlaceholderText(t("gui.output.filename_rule.placeholder")) self.output_name_edit.setMinimumWidth(240) self.output_name_edit.setMaximumWidth(520) filename_rule_row = QHBoxLayout() - output_rule_help_button = QPushButton("?") + output_rule_help_button = QPushButton(t("gui.output.filename_rule.help")) output_rule_help_button.setFixedWidth(34) - output_rule_help_button.setToolTip("显示文件名标记帮助") + output_rule_help_button.setToolTip(t("gui.output.filename_rule.help.tooltip")) output_rule_help_button.clicked.connect(self.show_output_rule_help) filename_rule_row.addWidget(self.output_name_edit, 1) filename_rule_row.addWidget(output_rule_help_button) - output_layout.addRow("输出文件名规则", filename_rule_row) + output_layout.addRow(t("gui.output.filename_rule"), filename_rule_row) # 输出预览 self.output_preview_label = QLabel("-") - output_layout.addRow("预览", self.output_preview_label) + output_layout.addRow(t("gui.output.preview"), self.output_preview_label) # ---- 发送至打印机 ---- - self.send_to_printer_check = QCheckBox("构建后发送至打印机") + self.send_to_printer_check = QCheckBox(t("gui.output.printer.send")) self.send_to_printer_check.setChecked(False) - self.send_to_printer_check.setToolTip("通过局域网将生成的 3MF 发送到 A1 打印机并开始打印") - output_layout.addRow("打印机", self.send_to_printer_check) + self.send_to_printer_check.setToolTip(t("gui.output.printer.send.tooltip")) + output_layout.addRow(t("gui.output.printer.send"), self.send_to_printer_check) # 打印机配置行(IP、访问码、序列号) printer_config_widget = QWidget() @@ -678,13 +688,13 @@ class MainWindow(QMainWindow): printer_config_layout.setSpacing(6) self.printer_ip_edit = QLineEdit() - self.printer_ip_edit.setPlaceholderText("IP 地址") + self.printer_ip_edit.setPlaceholderText(t("gui.output.printer.ip")) self.printer_ip_edit.setMaximumWidth(140) self.printer_access_code_edit = QLineEdit() - self.printer_access_code_edit.setPlaceholderText("访问码") + self.printer_access_code_edit.setPlaceholderText(t("gui.output.printer.access_code")) self.printer_access_code_edit.setMaximumWidth(120) self.printer_serial_edit = QLineEdit() - self.printer_serial_edit.setPlaceholderText("序列号") + self.printer_serial_edit.setPlaceholderText(t("gui.output.printer.serial")) self.printer_serial_edit.setMaximumWidth(180) printer_config_layout.addWidget(self.printer_ip_edit) @@ -759,6 +769,7 @@ class MainWindow(QMainWindow): self.printer_serial_edit.textChanged.connect(self.save_current_settings) self.output_dir_edit.textChanged.connect(self.on_output_rule_changed) self.output_name_edit.textChanged.connect(self.on_output_rule_changed) + self.language_combo.currentIndexChanged.connect(self.on_language_changed) def restore_settings_to_ui(self) -> None: """从加载的设置字典恢复所有 UI 控件的状态。""" @@ -776,6 +787,10 @@ class MainWindow(QMainWindow): self.individual_batch_check.setChecked(bool(options.get("individual_batch_mode", False))) self.clear_after_build_check.setChecked(bool(options.get("clear_after_build", False))) self.skip_duplicates_check.setChecked(bool(options.get("skip_duplicates", True))) + saved_locale = str(options.get("locale", "zh_CN")) + locale_index = self.language_combo.findData(saved_locale) + if locale_index >= 0: + self.language_combo.setCurrentIndex(locale_index) self.send_to_printer_check.setChecked(bool(options.get("send_to_printer_enabled", False))) self.printer_ip_edit.setText(str(options.get("printer_ip", ""))) self.printer_access_code_edit.setText(str(options.get("printer_access_code", ""))) @@ -818,6 +833,7 @@ class MainWindow(QMainWindow): "individual_batch_mode": self.individual_batch_check.isChecked(), "clear_after_build": self.clear_after_build_check.isChecked(), "skip_duplicates": self.skip_duplicates_check.isChecked(), + "locale": self.language_combo.currentData() or "zh_CN", "send_to_printer_enabled": self.send_to_printer_check.isChecked(), "printer_ip": self.printer_ip_edit.text().strip(), "printer_access_code": self.printer_access_code_edit.text().strip(), @@ -848,30 +864,16 @@ class MainWindow(QMainWindow): if state != 0: QMessageBox.information( self, - "独立批处理模式", - "独立批处理模式将每一行输入作为独立的构建任务。\n\n" - "示例:如果你添加了 20 个单盘 3MF 文件并设置份数为 5," - "点击“构建 3MF”将创建 20 个独立的打包文件。" - "每个输出文件仅包含该源文件重复 5 次的内容。\n\n" - "这个功能适用于快速将大量独立的 3MF 作业批量转换为多份数的 " - "SwapMod 包。它不会将所有输入文件合并到一个 3MF 中。", + t("gui.dialog.individual_batch.title"), + t("gui.dialog.individual_batch.text"), ) def show_output_rule_help(self) -> None: """显示输出文件名规则的帮助对话框。""" QMessageBox.information( self, - "输出文件名规则", - "可用的文件名标记:\n\n" - "{source} — 第一个输入文件的文件名(不含 .3mf)\n" - "{sources} — 源文件名称摘要。多个不同输入时,显示为 first_source_and_N_more\n" - "{plates} — 输出中的总板数\n" - "{copies} — 输出中使用的总份数\n" - "{date} — 当前日期,格式 YYYYMMDD\n" - "{time} — 当前时间,格式 HHMMSS\n\n" - "默认规则:\n" - "{plates}-Plates-{sources}.3mf\n\n" - "在独立批处理模式下,这些标记会针对每行输入单独计算。", + t("gui.dialog.filename_help.title"), + t("gui.dialog.filename_help.text"), ) # ========================================================================= @@ -895,7 +897,7 @@ class MainWindow(QMainWindow): if index >= 0: self.swap_gcode_combo.setCurrentIndex(index) if not files: - self.log.append(f"在 {default_swap_gcode_dir()} 中未找到换盘 G-code 文件") + self.log.append(t("gui.log.no_swap_files", dir=str(default_swap_gcode_dir()))) def open_path(self, path: Path) -> None: """跨平台打开文件或文件夹(Windows/Mac/Linux)。""" @@ -918,7 +920,7 @@ class MainWindow(QMainWindow): """打开 G-code 补丁配置文件。""" path = default_patch_config_path() if not path.exists(): - QMessageBox.information(self, APP_NAME, f"补丁配置文件不存在:\n{path}") + QMessageBox.information(self, t("gui.dialog.warning.title"), t("gui.dialog.patch_config_not_found", path=str(path))) return self.open_path(path) @@ -928,7 +930,7 @@ class MainWindow(QMainWindow): def add_files(self) -> None: """通过文件对话框添加 3MF 文件到输入列表。""" - files, _ = QFileDialog.getOpenFileNames(self, "添加 3MF 文件", "", "3MF 文件 (*.3mf);;所有文件 (*)") + files, _ = QFileDialog.getOpenFileNames(self, t("gui.dialog.add_files_title"), "", f'{t("gui.dialog.file_filter.3mf")};;{t("gui.dialog.file_filter.all")}') self.add_paths([Path(file_name) for file_name in files]) def create_order_widget(self, row: int) -> QWidget: @@ -944,7 +946,7 @@ class MainWindow(QMainWindow): up_button = QToolButton() up_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowUp)) - up_button.setToolTip("上移") + up_button.setToolTip(t("gui.file_group.table.move_up")) up_button.setAutoRaise(True) up_button.setFixedSize(22, 22) up_button.setEnabled(row > 0) @@ -952,7 +954,7 @@ class MainWindow(QMainWindow): down_button = QToolButton() down_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) - down_button.setToolTip("下移") + down_button.setToolTip(t("gui.file_group.table.move_down")) down_button.setAutoRaise(True) down_button.setFixedSize(28, 28) down_button.setEnabled(row < self.table.rowCount() - 1) @@ -1010,18 +1012,18 @@ class MainWindow(QMainWindow): path = self.row_path(row) if row is not None else None self.thumbnail_label.clear() if path is None: - self.thumbnail_label.setText("选择一个输入文件") + self.thumbnail_label.setText(t("gui.preview.select_hint")) self.thumbnail_name_label.setText("") return self.thumbnail_name_label.setText(path.name) try: pixmap = self.load_thumbnail_pixmap(path) except Exception as exc: - self.thumbnail_label.setText("预览不可用") + self.thumbnail_label.setText(t("gui.preview.unavailable")) self.thumbnail_label.setToolTip(str(exc)) return if pixmap is None: - self.thumbnail_label.setText("无预览图") + self.thumbnail_label.setText(t("gui.preview.no_image")) self.thumbnail_label.setToolTip("") return scaled = pixmap.scaled(self.thumbnail_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) @@ -1060,7 +1062,7 @@ class MainWindow(QMainWindow): else: skipped += 1 if added or skipped: - self.log.append(f"已添加 {added} 个输入文件。跳过 {skipped} 个。") + self.log.append(t("gui.log.added_files", added=added, skipped=skipped)) self.update_total_summary() self.update_output_preview() @@ -1070,7 +1072,7 @@ class MainWindow(QMainWindow): try: summary = read_3mf_summary(path) except Exception as exc: - self.log.append(f"已跳过 {path}:{exc}") + self.log.append(t("gui.log.skipped_file", path=str(path), reason=str(exc))) return False row = self.table.rowCount() self._updating_table = True @@ -1114,11 +1116,11 @@ class MainWindow(QMainWindow): def base_summary_tooltip(self, summary: ThreeMfSummary) -> str: """生成 3MF 文件的基本摘要信息(用于工具提示)。""" - return ( - f"基础板数:{summary.plate_count}\n" - f"基础时间:{format_duration(summary.prediction_seconds)}\n" - f"基础耗材:{format_filament(summary.weight_grams, summary.filament_used_m)}" - ) + return "\n".join(( + t("gui.file_group.table.tooltip_base_plates", count=summary.plate_count), + t("gui.file_group.table.tooltip_base_time", time=format_duration(summary.prediction_seconds)), + t("gui.file_group.table.tooltip_base_filament", filament=format_filament(summary.weight_grams, summary.filament_used_m)) + )) def get_row_copies(self, row: int) -> int: """获取指定行的份数值。""" @@ -1279,7 +1281,7 @@ class MainWindow(QMainWindow): first_path = self.row_path(0) if self.table.rowCount() > 0 else None if not start_dir and first_path is not None: start_dir = str(first_path.parent) - directory = QFileDialog.getExistingDirectory(self, "选择输出目录", start_dir or "") + directory = QFileDialog.getExistingDirectory(self, t("gui.dialog.choose_output_dir"), start_dir or "") if directory: self.output_dir_edit.setText(directory) @@ -1322,10 +1324,10 @@ class MainWindow(QMainWindow): """刷新底部合计统计标签。""" summary = self.current_total_summary() self.total_summary_label.setText( - "合计:" - f"{summary.plate_count} 板 | " - f"时间:{format_duration(summary.prediction_seconds)} | " - f"耗材:{format_filament(summary.weight_grams, summary.filament_used_m)}" + t("gui.file_group.total_summary", + plates=summary.plate_count, + time=format_duration(summary.prediction_seconds), + filament=format_filament(summary.weight_grams, summary.filament_used_m)) ) def update_output_preview(self) -> None: @@ -1342,7 +1344,7 @@ class MainWindow(QMainWindow): if self.individual_batch_check.isChecked(): first_path = resolve_output_path([jobs[0]], self.output_naming_options(), self.summary_for_path) self.output_preview_label.setText( - f"{first_path} | {len(jobs)} 个输出文件" + t("gui.output.batch_preview", first=str(first_path), count=len(jobs)) ) else: output_path = resolve_output_path(jobs, self.output_naming_options(), self.summary_for_path) @@ -1358,7 +1360,7 @@ class MainWindow(QMainWindow): """根据当前 UI 选项构建 BuildOptions 对象。""" swap_gcode_path = self.swap_gcode_combo.currentData() if not swap_gcode_path: - raise ValueError("请在 swap_gcode 文件夹中放入至少一个换盘 G-code 文件并选择它。") + raise ValueError(t("gui.dialog.no_swap_gcode")) cool_bed_temp = int(self.cool_bed_edit.text() or 45) if self.bed_cooldown_check.isChecked() else None eject_wait_seconds = int(self.wait_edit.text() or 30) if self.eject_wait_check.isChecked() else 0 return BuildOptions( @@ -1375,11 +1377,11 @@ class MainWindow(QMainWindow): def log_build_result(self, result: Any) -> None: """将单次构建结果信息写入日志。""" - self.log.append(f"输出:{result.output_3mf}") - self.log.append(f"板数:{result.plate_count}") - self.log.append(f"预估源文件时间:{format_duration(result.total_prediction_seconds)}") - self.log.append(f"预估源文件耗材:{format_filament(result.total_weight_grams)}") - self.log.append(f"G-code MD5:{result.gcode_md5}") + self.log.append(t("gui.log.output", path=str(result.output_3mf))) + self.log.append(t("gui.log.plates", count=result.plate_count)) + self.log.append(t("gui.log.time", time=format_duration(result.total_prediction_seconds))) + self.log.append(t("gui.log.filament", filament=format_filament(result.total_weight_grams))) + self.log.append(t("gui.log.md5", md5=result.gcode_md5)) def show_success_toast(self, message: str) -> None: """显示构建成功的浮动提示条。""" @@ -1394,17 +1396,17 @@ class MainWindow(QMainWindow): ) try: send_to_printer(file_path, config) - self.log.append(f"已发送至打印机:{file_path.name}") + self.log.append(t("gui.toast.send_success", name=file_path.name)) except ImportError: - self.log.append("错误:未安装 bambulabs_api,请执行 pip install bambulabs_api") + self.log.append(t("gui.log.printer_missing")) except Exception as exc: - self.log.append(f"发送至打印机失败:{exc}") + self.log.append(t("gui.toast.send_failed", error=str(exc))) def build_output(self) -> None: """主构建入口:根据批处理模式分发到合并构建或独立构建。""" jobs = self.collect_jobs() if not jobs: - QMessageBox.warning(self, APP_NAME, "请至少添加一个 3MF 文件。") + QMessageBox.warning(self, t("gui.dialog.warning.title"), t("gui.dialog.no_files")) return try: self.save_current_settings() @@ -1413,8 +1415,8 @@ class MainWindow(QMainWindow): else: self.build_combined_output(jobs) except Exception as exc: - QMessageBox.critical(self, APP_NAME, str(exc)) - self.log.append(f"错误:{exc}") + QMessageBox.critical(self, t("gui.dialog.warning.title"), str(exc)) + self.log.append(t("gui.log.build_error", path=type(exc).__name__, error=str(exc))) return def build_combined_output(self, jobs: list[PlateJob]) -> None: @@ -1425,7 +1427,7 @@ class MainWindow(QMainWindow): self.log_build_result(result) if self.send_to_printer_check.isChecked(): self._send_to_printer(result.output_3mf) - self.show_success_toast("已创建打包的 3MF 文件。") + self.show_success_toast(t("gui.toast.combined_success")) if self.clear_after_build_check.isChecked(): self.remove_all() @@ -1444,13 +1446,13 @@ class MainWindow(QMainWindow): options = self.build_options_for_output(output_path) except Exception as exc: errors.append(f"{job.source_3mf.name}: {exc}") - self.log.append(f"构建 {job.source_3mf} 时出错:{exc}") + self.log.append(t("gui.log.build_error", path=str(job.source_3mf), error=str(exc))) continue tasks.append(IndividualBuildTask(job, options)) if tasks: worker_count = individual_batch_worker_count(len(tasks)) - self.log.append(f"正在使用 {worker_count} 个工作线程构建 {len(tasks)} 个独立输出。") + self.log.append(t("gui.log.building_individual", count=len(tasks), workers=worker_count)) batch_result = run_individual_batch_builds(tasks, max_workers=worker_count) for result in batch_result.results: self.log_build_result(result) @@ -1459,18 +1461,18 @@ class MainWindow(QMainWindow): success_count = len(batch_result.results) for failure in batch_result.failures: errors.append(f"{failure.job.source_3mf.name}: {failure.error}") - self.log.append(f"构建 {failure.job.source_3mf} 时出错:{failure.error}") + self.log.append(t("gui.log.build_error", path=str(failure.job.source_3mf), error=str(failure.error))) if errors: QMessageBox.warning( self, - APP_NAME, - f"已构建 {success_count} 个文件,但有 {len(errors)} 个文件失败。\n\n" + t("gui.dialog.warning.title"), + t("gui.dialog.batch_partial_success", success=success_count, failed=len(errors)) + "\n".join(errors[:8]) + ("\n..." if len(errors) > 8 else ""), ) return - self.show_success_toast(f"已创建 {success_count} 个打包的 3MF 文件。") + self.show_success_toast(t("gui.toast.batch_success", count=success_count)) if self.clear_after_build_check.isChecked(): self.remove_all() @@ -1497,6 +1499,29 @@ class MainWindow(QMainWindow): self.save_current_settings() super().closeEvent(event) + def on_language_changed(self) -> None: + """语言切换时重新加载翻译并刷新 UI 文本。""" + new_locale = self.language_combo.currentData() + if new_locale: + i18n_switch(new_locale) + self._refresh_ui_texts() + self.save_current_settings() + + def _refresh_ui_texts(self) -> None: + """刷新所有可通过 t() 获取的 UI 文本(窗口标题、组框等)。""" + self.file_group.setTitle(t("gui.file_group.title")) + self.table.setHorizontalHeaderLabels([ + t("gui.file_group.table.header.order"), + t("gui.file_group.table.header.file"), + t("gui.file_group.table.header.copies"), + t("gui.file_group.table.header.time"), + t("gui.file_group.table.header.filament"), + ]) + self.table.setToolTip(t("gui.file_group.table.tooltip")) + self.update_total_summary() + self.update_output_preview() + self.update_thumbnail_preview() + def main() -> int: """应用程序入口:初始化 QApplication,创建并显示主窗口,进入事件循环。""" diff --git a/a1_swap_mod_packer/i18n/__init__.py b/a1_swap_mod_packer/i18n/__init__.py new file mode 100644 index 0000000..65b6254 --- /dev/null +++ b/a1_swap_mod_packer/i18n/__init__.py @@ -0,0 +1,50 @@ +"""国际化模块:提供 t(key, **kwargs) 翻译函数,支持语言切换。 + +模块导入时自动加载 zh_CN 作为默认语言。 +通过 switch(locale) 切换语言,t() 返回当前语言的翻译文本。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +_I18N_DIR = Path(__file__).parent +_translations: dict[str, str] = {} +_current_locale: str = "zh_CN" + + +def load(locale: str) -> None: + """加载指定语言的翻译文件。""" + global _translations, _current_locale + _current_locale = locale + path = _I18N_DIR / f"{locale}.json" + if path.exists(): + _translations = json.loads(path.read_text(encoding="utf-8")) + else: + _translations = {} + + +def switch(locale: str) -> None: + """切换当前语言并返回新语言代码。""" + load(locale) + + +def current_locale() -> str: + """返回当前语言代码。""" + return _current_locale + + +def t(key: str, **kwargs: object) -> str: + """获取翻译文本,支持 {name} 插值。 + + 如果 key 不存在,返回 key 本身作为回退。 + """ + text = str(_translations.get(key, key)) + if kwargs: + text = text.format(**{k: v for k, v in kwargs.items()}) + return text + + +# 模块导入时自动加载默认中文翻译 +load("zh_CN") diff --git a/a1_swap_mod_packer/i18n/en.json b/a1_swap_mod_packer/i18n/en.json new file mode 100644 index 0000000..c57ccae --- /dev/null +++ b/a1_swap_mod_packer/i18n/en.json @@ -0,0 +1,112 @@ +{ + "_lang": "English", + + "gui.file_group.title": "Input 3MF Files", + "gui.file_group.table.header.order": "Order", + "gui.file_group.table.header.file": "3MF File", + "gui.file_group.table.header.copies": "Copies", + "gui.file_group.table.header.time": "Time", + "gui.file_group.table.header.filament": "Filament", + "gui.file_group.table.tooltip": "Drag .3mf files or folders here. Folders add all top-level .3mf files.", + "gui.file_group.table.move_up": "Move up", + "gui.file_group.table.move_down": "Move down", + "gui.file_group.table.tooltip_base_plates": "Base plates: {count}", + "gui.file_group.table.tooltip_base_time": "Base time: {time}", + "gui.file_group.table.tooltip_base_filament": "Base filament: {filament}", + "gui.file_group.total_summary": "Total: {plates} plate(s) | Time: {time} | Filament: {filament}", + "gui.file_group.total_summary.empty": "Total: 0 plates | Time: 0s | Filament: 0.00 g", + + "gui.button.add_3mf": "Add 3MF", + "gui.button.remove": "Remove", + "gui.button.remove_all": "Remove All", + "gui.button.apply_copies": "Apply Default Copies to Selected", + "gui.button.build": "Build 3MF", + + "gui.preview.title": "Selected Thumbnail", + "gui.preview.select_hint": "Select an input file", + "gui.preview.no_image": "No preview image", + "gui.preview.unavailable": "Preview unavailable", + + "gui.options.group.title": "Packing Options", + "gui.options.swap_gcode.label": "Swap G-code", + "gui.options.refresh": "Refresh", + "gui.options.open_folder": "Open Folder", + "gui.options.default_copies": "Default copies for new inputs", + "gui.options.bed_cooldown": "Wait for bed cooldown", + "gui.options.bed_cooldown.label": "Bed cooldown", + "gui.options.bed_cooldown.suffix": "°C", + "gui.options.eject_wait": "Wait after swap", + "gui.options.eject_wait.label": "Wait after swap", + "gui.options.wait_suffix": "sec", + "gui.options.plate_number": "Show current plate in the hundreds digit of remaining time", + "gui.options.plate_number.label": "Plate number", + "gui.options.final_swap": "Run swap G-code after the last plate", + "gui.options.final_swap.label": "Final swap", + "gui.options.gcode_patches": "Apply editable G-code patches", + "gui.options.gcode_patches.label": "G-code patches", + "gui.options.gcode_patches.tooltip": "Uses gcode_patches.ini", + "gui.options.open_config": "Open Config", + "gui.options.metadata.label": "3MF metadata", + "gui.options.metadata.source": "Keep source prediction and weight", + "gui.options.metadata.sum": "Sum prediction and filament", + "gui.options.zip_compression.label": "ZIP compression", + "gui.options.zip_compression.level_format": "Level {level}", + "gui.options.zip_compression.tooltip": "zlib-ng Deflate compression level for the output 3MF.", + "gui.options.batch_mode": "Individual batch mode", + "gui.options.batch_mode.label": "Batch mode", + "gui.options.batch_mode.tooltip": "Build each input row as a separate output file, using that row's copy count.", + "gui.options.clear_after_build": "Clear input list after successful build", + "gui.options.clear_after_build.label": "After build", + "gui.options.skip_duplicates": "Skip duplicate file paths when adding inputs", + "gui.options.input_handling": "Input handling", + "gui.options.language": "Language", + + "gui.output.group.title": "Output", + "gui.output.dir.label": "Output directory", + "gui.output.dir.placeholder": "Leave empty to write next to the input file", + "gui.output.dir.browse": "Browse", + "gui.output.filename_rule": "Output filename rule", + "gui.output.filename_rule.placeholder": "Use tokens such as {source}, {sources}, {plates}, {copies}, {date}, {time}", + "gui.output.filename_rule.help": "?", + "gui.output.filename_rule.help.tooltip": "Show filename token help", + "gui.output.preview": "Preview", + "gui.output.batch_preview": "{first} | {count} output file(s)", + "gui.output.printer.send": "Send to printer after build", + "gui.output.printer.ip": "IP Address", + "gui.output.printer.access_code": "Access Code", + "gui.output.printer.serial": "Serial", + "gui.output.printer.send.tooltip": "Send the generated 3MF to an A1 printer over LAN and start printing", + + "gui.dialog.individual_batch.title": "Individual Batch Mode", + "gui.dialog.individual_batch.text": "Individual batch mode treats every input row as a separate build.\n\nExample: if you add 20 single-plate 3MF files and set copies to 5, Build 3MF will create 20 separate packed files. Each output contains only that source file repeated 5 times.\n\nThis is useful for quickly batch-converting many independent 3MF jobs into multi-copy SwapMod packs. It does not combine all input files into one 3MF.", + "gui.dialog.filename_help.title": "Output Filename Rule", + "gui.dialog.filename_help.text": "Available filename tokens:\n\n{source} - First input file stem, without .3mf\n{sources} - Source name summary. For multiple different inputs, it becomes first_source_and_N_more\n{plates} - Total plate count in the output\n{copies} - Total copy count used for the output\n{date} - Current date as YYYYMMDD\n{time} - Current time as HHMMSS\n\nDefault rule:\n{plates}-Plates-{sources}.3mf\n\nIn individual batch mode, these tokens are calculated separately for each input row.", + "gui.dialog.warning.title": "A1 Swap Mod Packer", + "gui.dialog.no_files": "Please add at least one 3MF file.", + "gui.dialog.no_swap_gcode": "Please put at least one swap G-code file in the swap_gcode folder and select it.", + "gui.dialog.patch_config_not_found": "Patch config does not exist yet:\n{path}", + "gui.dialog.batch_partial_success": "Built {success} file(s), but {failed} file(s) failed.\n\n", + "gui.dialog.choose_output_dir": "Choose output directory", + "gui.dialog.add_files_title": "Add 3MF files", + "gui.dialog.file_filter.3mf": "3MF files (*.3mf)", + "gui.dialog.file_filter.all": "All files (*)", + + "gui.toast.combined_success": "The packed 3MF file was created.", + "gui.toast.batch_success": "Created {count} packed 3MF file(s).", + "gui.toast.send_success": "Sent to printer: {name}", + "gui.toast.send_failed": "Failed to send to printer: {error}", + + "gui.log.added_files": "Added {added} input file(s). Skipped {skipped}.", + "gui.log.skipped_file": "Skipped {path}: {reason}", + "gui.log.no_swap_files": "No swap G-code files found in {dir}", + "gui.log.building_individual": "Building {count} individual output(s) with {workers} worker(s).", + "gui.log.output": "Output: {path}", + "gui.log.plates": "Plates: {count}", + "gui.log.time": "Estimated source time: {time}", + "gui.log.filament": "Estimated source filament: {filament}", + "gui.log.md5": "G-code MD5: {md5}", + "gui.log.build_error": "Error building {path}: {error}", + "gui.log.printer_missing": "Error: bambulabs_api not installed, run: pip install bambulabs_api", + + "format.unknown": "Unknown" +} diff --git a/a1_swap_mod_packer/i18n/zh_CN.json b/a1_swap_mod_packer/i18n/zh_CN.json new file mode 100644 index 0000000..5157076 --- /dev/null +++ b/a1_swap_mod_packer/i18n/zh_CN.json @@ -0,0 +1,112 @@ +{ + "_lang": "简体中文", + + "gui.file_group.title": "输入 3MF 文件", + "gui.file_group.table.header.order": "顺序", + "gui.file_group.table.header.file": "3MF 文件", + "gui.file_group.table.header.copies": "份数", + "gui.file_group.table.header.time": "时间", + "gui.file_group.table.header.filament": "耗材", + "gui.file_group.table.tooltip": "将 .3mf 文件或文件夹拖放到此处。文件夹将添加所有顶层 .3mf 文件。", + "gui.file_group.table.move_up": "上移", + "gui.file_group.table.move_down": "下移", + "gui.file_group.table.tooltip_base_plates": "基础板数:{count}", + "gui.file_group.table.tooltip_base_time": "基础时间:{time}", + "gui.file_group.table.tooltip_base_filament": "基础耗材:{filament}", + "gui.file_group.total_summary": "合计:{plates} 板 | 时间:{time} | 耗材:{filament}", + "gui.file_group.total_summary.empty": "合计:0 板 | 时间:0s | 耗材:0.00 g", + + "gui.button.add_3mf": "添加 3MF", + "gui.button.remove": "移除", + "gui.button.remove_all": "全部移除", + "gui.button.apply_copies": "应用默认份数至选中行", + "gui.button.build": "构建 3MF", + + "gui.preview.title": "选中文件的缩略图", + "gui.preview.select_hint": "选择一个输入文件", + "gui.preview.no_image": "无预览图", + "gui.preview.unavailable": "预览不可用", + + "gui.options.group.title": "打包选项", + "gui.options.swap_gcode.label": "换盘 G-code", + "gui.options.refresh": "刷新", + "gui.options.open_folder": "打开文件夹", + "gui.options.default_copies": "新输入的默认份数", + "gui.options.bed_cooldown": "等待热床降温", + "gui.options.bed_cooldown.label": "热床降温", + "gui.options.bed_cooldown.suffix": "°C", + "gui.options.eject_wait": "换盘后等待", + "gui.options.eject_wait.label": "换盘后等待时间", + "gui.options.wait_suffix": "秒", + "gui.options.plate_number": "在剩余时间的百位显示当前板号", + "gui.options.plate_number.label": "剩余时间板号", + "gui.options.final_swap": "最后一块盘后也执行换盘 G-code", + "gui.options.final_swap.label": "最后换盘", + "gui.options.gcode_patches": "应用可编辑的 G-code 补丁", + "gui.options.gcode_patches.label": "G-code 补丁", + "gui.options.gcode_patches.tooltip": "使用 gcode_patches.ini", + "gui.options.open_config": "打开配置文件", + "gui.options.metadata.label": "3MF 元数据", + "gui.options.metadata.source": "保留原始预测和重量", + "gui.options.metadata.sum": "累加预测和耗材用量", + "gui.options.zip_compression.label": "ZIP 压缩", + "gui.options.zip_compression.level_format": "级别 {level}", + "gui.options.zip_compression.tooltip": "输出 3MF 的 zlib-ng Deflate 压缩级别。", + "gui.options.batch_mode": "独立批处理模式", + "gui.options.batch_mode.label": "批处理模式", + "gui.options.batch_mode.tooltip": "将每行输入构建为独立的输出文件,使用该行的份数。", + "gui.options.clear_after_build": "构建成功后清空输入列表", + "gui.options.clear_after_build.label": "构建成功后", + "gui.options.skip_duplicates": "添加输入时跳过重复文件路径", + "gui.options.input_handling": "输入处理", + "gui.options.language": "语言", + + "gui.output.group.title": "输出", + "gui.output.dir.label": "输出目录", + "gui.output.dir.placeholder": "留空则写入输入文件所在目录", + "gui.output.dir.browse": "浏览", + "gui.output.filename_rule": "输出文件名规则", + "gui.output.filename_rule.placeholder": "可使用 {source}、{sources}、{plates}、{copies}、{date}、{time} 等标记", + "gui.output.filename_rule.help": "?", + "gui.output.filename_rule.help.tooltip": "显示文件名标记帮助", + "gui.output.preview": "预览", + "gui.output.batch_preview": "{first} | {count} 个输出文件", + "gui.output.printer.send": "构建后发送至打印机", + "gui.output.printer.ip": "IP 地址", + "gui.output.printer.access_code": "访问码", + "gui.output.printer.serial": "序列号", + "gui.output.printer.send.tooltip": "通过局域网将生成的 3MF 发送到 A1 打印机并开始打印", + + "gui.dialog.individual_batch.title": "独立批处理模式", + "gui.dialog.individual_batch.text": "独立批处理模式将每一行输入作为独立的构建任务。\n\n示例:如果你添加了 20 个单板 3MF 文件并设置份数为 5,点击\"构建 3MF\"将创建 20 个独立的打包文件。每个输出文件仅包含该源文件重复 5 次的内容。\n\n这个功能适用于快速将大量独立的 3MF 作业批量转换为多份数的 SwapMod 包。它不会将所有输入文件合并到一个 3MF 中。", + "gui.dialog.filename_help.title": "输出文件名规则", + "gui.dialog.filename_help.text": "可用的文件名标记:\n\n{source} — 第一个输入文件的文件名(不含 .3mf)\n{sources} — 源文件名称摘要。多个不同输入时,显示为 first_source_and_N_more\n{plates} — 输出中的总板数\n{copies} — 输出中使用的总份数\n{date} — 当前日期,格式 YYYYMMDD\n{time} — 当前时间,格式 HHMMSS\n\n默认规则:\n{plates}-Plates-{sources}.3mf\n\n在独立批处理模式下,这些标记会针对每行输入单独计算。", + "gui.dialog.warning.title": "A1 Swap Mod Packer", + "gui.dialog.no_files": "请至少添加一个 3MF 文件。", + "gui.dialog.no_swap_gcode": "请在 swap_gcode 文件夹中放入至少一个换盘 G-code 文件并选择它。", + "gui.dialog.patch_config_not_found": "补丁配置文件不存在:{path}", + "gui.dialog.batch_partial_success": "已构建 {success} 个文件,但有 {failed} 个文件失败。\n\n", + "gui.dialog.choose_output_dir": "选择输出目录", + "gui.dialog.add_files_title": "添加 3MF 文件", + "gui.dialog.file_filter.3mf": "3MF 文件 (*.3mf)", + "gui.dialog.file_filter.all": "所有文件 (*)", + + "gui.toast.combined_success": "已创建打包的 3MF 文件。", + "gui.toast.batch_success": "已创建 {count} 个打包的 3MF 文件。", + "gui.toast.send_success": "已发送至打印机:{name}", + "gui.toast.send_failed": "发送至打印机失败:{error}", + + "gui.log.added_files": "已添加 {added} 个输入文件。跳过 {skipped} 个。", + "gui.log.skipped_file": "已跳过 {path}:{reason}", + "gui.log.no_swap_files": "在 {dir} 中未找到换盘 G-code 文件", + "gui.log.building_individual": "正在使用 {workers} 个工作线程构建 {count} 个独立输出。", + "gui.log.output": "输出:{path}", + "gui.log.plates": "板数:{count}", + "gui.log.time": "预估源文件时间:{time}", + "gui.log.filament": "预估源文件耗材:{filament}", + "gui.log.md5": "G-code MD5:{md5}", + "gui.log.build_error": "构建 {path} 时出错:{error}", + "gui.log.printer_missing": "错误:未安装 bambulabs_api,请执行 pip install bambulabs_api", + + "format.unknown": "未知" +} diff --git a/docs/superpowers/plans/2026-07-28-i18n.md b/docs/superpowers/plans/2026-07-28-i18n.md new file mode 100644 index 0000000..ec5a185 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-i18n.md @@ -0,0 +1,31 @@ +# i18n 国际化实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为 A1 Swap Mod Packer 添加中英文国际化支持,用户通过下拉框切换语言,切换即时生效并持久化。 + +**Architecture:** 新建 `i18n/` 模块,提供 `t(key, **kwargs)` 翻译函数。GUI 中所有硬编码中文替换为 `t("key")` 调用。在打包选项区添加语言下拉框,切换时重新加载翻译并刷新 UI。 + +**Tech Stack:** Python 标准库 json + PySide6 QComboBox + +## Global Constraints + +- 所有翻译键命名遵循 `模块.区域.属性` 层级 +- `t()` 在键缺失时返回键名本身作为回退 +- 语言选择持久化到 `settings.json` 的 `locale` 字段,默认 `zh_CN` +- 数值格式化(时间、耗材)保持不翻译,仅在 None 时返回翻译后的占位符 + +--- + +## 文件结构 + +| 文件 | 操作 | 职责 | +|------|------|------| +| `a1_swap_mod_packer/i18n/__init__.py` | 新建 | 翻译加载器 + `t()` 函数 | +| `a1_swap_mod_packer/i18n/zh_CN.json` | 新建 | 简体中文翻译表 | +| `a1_swap_mod_packer/i18n/en.json` | 新建 | 英文翻译表 | +| `a1_swap_mod_packer/gui.py` | 修改 | 字符串替换为 `t()` + 语言下拉框 | +| `a1_swap_mod_packer/gcode.py` | 修改 | `format_duration`/`format_filament` 的 None 返回翻译 | +| `a1_swap_mod_packer/core.py` | 修改 | 导出 `i18n` 模块的 `t` | + +---