Przeglądaj źródła

Merge branch '0.2.2b1' into fix-ams-slot

AneoPsy 2 miesięcy temu
rodzic
commit
01b8fbb14f

+ 1 - 0
backend/app/api/routes/settings.py

@@ -105,6 +105,7 @@ async def get_settings(
                 "ams_temp_good",
                 "ams_temp_fair",
                 "library_disk_warning_gb",
+                "low_stock_threshold",
             ]:
                 settings_dict[setting.key] = float(setting.value)
             elif setting.key in [

+ 9 - 0
backend/app/schemas/settings.py

@@ -149,6 +149,14 @@ class AppSettings(BaseModel):
         default="", description="Bearer token for Prometheus metrics authentication (optional)"
     )
 
+    # Inventory low stock threshold
+    low_stock_threshold: float = Field(
+        default=20.0,
+        ge=0.1,
+        le=99.9,
+        description="Low stock threshold percentage (%) for inventory filtering and display",
+    )
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -210,3 +218,4 @@ class AppSettingsUpdate(BaseModel):
     preferred_slicer: str | None = None
     prometheus_enabled: bool | None = None
     prometheus_token: str | None = None
+    low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)

+ 21 - 0
backend/tests/integration/test_settings_api.py

@@ -166,6 +166,27 @@ class TestSettingsAPI:
         assert result["ams_temp_good"] == 25.0
         assert result["ams_temp_fair"] == 32.0
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_low_stock_threshold(self, async_client: AsyncClient):
+        """Verify low stock threshold setting can be updated."""
+        # Get default value
+        response = await async_client.get("/api/v1/settings/")
+        assert response.status_code == 200
+        assert response.json()["low_stock_threshold"] == 20.0
+
+        # Update to custom value
+        response = await async_client.put("/api/v1/settings/", json={"low_stock_threshold": 15.5})
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["low_stock_threshold"] == 15.5
+
+        # Verify persistence
+        response = await async_client.get("/api/v1/settings/")
+        assert response.status_code == 200
+        assert response.json()["low_stock_threshold"] == 15.5
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_update_notification_language(self, async_client: AsyncClient):

+ 654 - 0
frontend/src/__tests__/components/FileUploadModal.test.tsx

@@ -0,0 +1,654 @@
+/**
+ * Tests for the FileUploadModal component.
+ * Tests file upload, drag-and-drop, ZIP/3MF/STL detection, and autoUpload mode.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { FileUploadModal } from '../../components/FileUploadModal';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+describe('FileUploadModal', () => {
+  const defaultProps = {
+    folderId: null as number | null,
+    onClose: vi.fn(),
+    onUploadComplete: vi.fn(),
+  };
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+
+    server.use(
+      http.post('/api/v1/library/files', () => {
+        return HttpResponse.json({
+          id: 1,
+          filename: 'test.gcode.3mf',
+          file_type: '3mf',
+          file_size: 1048576,
+          thumbnail_path: null,
+          duplicate_of: null,
+          metadata: null,
+        });
+      }),
+      http.post('/api/v1/library/extract-zip', () => {
+        return HttpResponse.json({
+          extracted: 3,
+          errors: [],
+        });
+      })
+    );
+  });
+
+  describe('rendering', () => {
+    it('renders the modal with title', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByText('Upload Files')).toBeInTheDocument();
+    });
+
+    it('renders drag and drop zone', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByText(/Drag & drop/)).toBeInTheDocument();
+    });
+
+    it('renders click to browse text', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByText(/click to browse/i)).toBeInTheDocument();
+    });
+
+    it('renders Cancel button', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+    });
+
+    it('renders Upload button disabled when no files', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      const uploadButton = screen.getByRole('button', { name: /Upload/i });
+      expect(uploadButton).toBeDisabled();
+    });
+
+    it('shows all file types supported text', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByText(/All file types supported/i)).toBeInTheDocument();
+    });
+  });
+
+  describe('file selection', () => {
+    it('shows added file in the list', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      expect(screen.getByText('model.gcode.3mf')).toBeInTheDocument();
+    });
+
+    it('shows file size in MB', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['x'.repeat(1048576)], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      expect(screen.getByText('1.00 MB')).toBeInTheDocument();
+    });
+
+    it('enables Upload button when files are added', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      expect(uploadButton).not.toBeDisabled();
+    });
+
+    it('shows file count in Upload button', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const files = [
+        new File(['a'], 'file1.3mf', { type: 'application/octet-stream' }),
+        new File(['b'], 'file2.stl', { type: 'application/octet-stream' }),
+      ];
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, files);
+
+      expect(screen.getByRole('button', { name: /Upload \(2\)/i })).toBeInTheDocument();
+    });
+
+    it('accepts any file type (not restricted like UploadModal)', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'readme.txt', { type: 'text/plain' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      expect(screen.getByText('readme.txt')).toBeInTheDocument();
+    });
+  });
+
+  describe('file removal', () => {
+    it('removes a file when X button is clicked', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      expect(screen.getByText('model.3mf')).toBeInTheDocument();
+
+      const fileRow = screen.getByText('model.3mf').closest('.flex');
+      const removeButton = fileRow?.querySelector('button');
+      if (removeButton) {
+        await user.click(removeButton);
+      }
+
+      await waitFor(() => {
+        expect(screen.queryByText('model.3mf')).not.toBeInTheDocument();
+      });
+    });
+
+    it('disables Upload button after removing all files', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const fileRow = screen.getByText('model.3mf').closest('.flex');
+      const removeButton = fileRow?.querySelector('button');
+      if (removeButton) {
+        await user.click(removeButton);
+      }
+
+      await waitFor(() => {
+        const uploadButton = screen.getByRole('button', { name: /Upload/i });
+        expect(uploadButton).toBeDisabled();
+      });
+    });
+  });
+
+  describe('file type detection', () => {
+    it('shows ZIP options when .zip file is added', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('ZIP files detected')).toBeInTheDocument();
+        expect(screen.getByText(/Preserve folder structure/)).toBeInTheDocument();
+        expect(screen.getByText(/Create folder from ZIP/)).toBeInTheDocument();
+      });
+    });
+
+    it('shows 3MF info when .3mf file is added', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, threemfFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('3MF files detected')).toBeInTheDocument();
+      });
+    });
+
+    it('shows STL thumbnail option when .stl file is added', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const stlFile = new File(['solid'], 'bracket.stl', { type: 'application/sla' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, stlFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
+        expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();
+      });
+    });
+
+    it('shows STL thumbnail option when ZIP file is added (may contain STLs)', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
+        expect(screen.getByText(/ZIP files may contain STL/i)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('ZIP options', () => {
+    it('preserve structure checkbox is checked by default', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        const label = screen.getByText(/Preserve folder structure/).closest('label');
+        const checkbox = label?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+        expect(checkbox).toBeChecked();
+      });
+    });
+
+    it('create folder checkbox is unchecked by default', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        const label = screen.getByText(/Create folder from ZIP/).closest('label');
+        const checkbox = label?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+        expect(checkbox).not.toBeChecked();
+      });
+    });
+
+    it('can toggle ZIP options', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('ZIP files detected')).toBeInTheDocument();
+      });
+
+      const preserveLabel = screen.getByText(/Preserve folder structure/).closest('label');
+      const preserveCheckbox = preserveLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+      await user.click(preserveCheckbox);
+      expect(preserveCheckbox).not.toBeChecked();
+
+      const createFolderLabel = screen.getByText(/Create folder from ZIP/).closest('label');
+      const createFolderCheckbox = createFolderLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+      await user.click(createFolderCheckbox);
+      expect(createFolderCheckbox).toBeChecked();
+    });
+  });
+
+  describe('upload flow', () => {
+    it('calls onUploadComplete after successful upload', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(defaultProps.onUploadComplete).toHaveBeenCalled();
+      });
+    });
+
+    it('calls onFileUploaded with response data for each file', async () => {
+      const onFileUploaded = vi.fn();
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} onFileUploaded={onFileUploaded} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(onFileUploaded).toHaveBeenCalledWith(
+          expect.objectContaining({
+            id: 1,
+            filename: 'test.gcode.3mf',
+          })
+        );
+      });
+    });
+
+    it('shows uploading state while uploading', async () => {
+      // Delay the response to observe uploading state
+      server.use(
+        http.post('/api/v1/library/files', async () => {
+          await new Promise((resolve) => setTimeout(resolve, 100));
+          return HttpResponse.json({
+            id: 1,
+            filename: 'model.3mf',
+            file_type: '3mf',
+            file_size: 1024,
+            thumbnail_path: null,
+            duplicate_of: null,
+            metadata: null,
+          });
+        })
+      );
+
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      // Should show uploading state
+      await waitFor(() => {
+        expect(screen.getByText('Uploading...')).toBeInTheDocument();
+        expect(document.querySelector('.animate-spin')).toBeInTheDocument();
+      });
+    });
+
+    it('shows error state on upload failure', async () => {
+      server.use(
+        http.post('/api/v1/library/files', () => {
+          return HttpResponse.json({ detail: 'File too large' }, { status: 413 });
+        })
+      );
+
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(defaultProps.onUploadComplete).toHaveBeenCalled();
+      });
+    });
+
+    it('closes modal after manual upload completes', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(defaultProps.onUploadComplete).toHaveBeenCalled();
+        expect(defaultProps.onClose).toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('autoUpload mode', () => {
+    it('uploads immediately when file is added', async () => {
+      const onFileUploaded = vi.fn();
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          autoUpload
+          onFileUploaded={onFileUploaded}
+        />
+      );
+
+      const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      await waitFor(() => {
+        expect(onFileUploaded).toHaveBeenCalledWith(
+          expect.objectContaining({ id: 1 })
+        );
+      });
+    });
+
+    it('calls onClose after autoUpload completes', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} autoUpload />);
+
+      const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      await waitFor(() => {
+        expect(defaultProps.onClose).toHaveBeenCalled();
+        expect(defaultProps.onUploadComplete).toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('close behavior', () => {
+    it('calls onClose when Cancel button is clicked', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      await user.click(screen.getByRole('button', { name: 'Cancel' }));
+      expect(defaultProps.onClose).toHaveBeenCalled();
+    });
+
+    it('calls onClose when X button is clicked', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      // The X button is the one in the header (not file remove buttons)
+      const headerButtons = screen.getByText('Upload Files').parentElement?.querySelectorAll('button');
+      const closeButton = headerButtons?.[0];
+
+      if (closeButton) {
+        await user.click(closeButton);
+        expect(defaultProps.onClose).toHaveBeenCalled();
+      }
+    });
+
+    it('always shows Cancel button (modal auto-closes after upload)', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+    });
+  });
+
+  describe('drag and drop', () => {
+    it('highlights drop zone on drag over', () => {
+      render(<FileUploadModal {...defaultProps} />);
+
+      const dropZone = screen.getByText(/Drag & drop/).closest('div[class*="border-dashed"]');
+
+      if (dropZone) {
+        fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } });
+        expect(dropZone.className).toContain('border-bambu-green');
+      }
+    });
+
+    it('removes highlight on drag leave', () => {
+      render(<FileUploadModal {...defaultProps} />);
+
+      const dropZone = screen.getByText(/Drag & drop/).closest('div[class*="border-dashed"]');
+
+      if (dropZone) {
+        fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } });
+        fireEvent.dragLeave(dropZone, { dataTransfer: { files: [] } });
+        expect(dropZone.className).not.toContain('bg-bambu-green');
+      }
+    });
+  });
+
+  describe('folder context', () => {
+    it('accepts folderId prop for uploading to specific folder', () => {
+      render(<FileUploadModal {...defaultProps} folderId={5} />);
+      // Component should render without errors with a folder context
+      expect(screen.getByText('Upload Files')).toBeInTheDocument();
+    });
+  });
+
+  describe('validateFile prop', () => {
+    it('rejects files that fail validation and shows error', async () => {
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          validateFile={(file) => {
+            if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';
+          }}
+        />
+      );
+
+      const file = new File(['content'], 'model.stl', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      // Error should be shown
+      expect(screen.getByText('Only .gcode files allowed')).toBeInTheDocument();
+      // File should NOT be added to the list
+      expect(screen.queryByText('model.stl')).not.toBeInTheDocument();
+    });
+
+    it('allows files that pass validation', async () => {
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          validateFile={(file) => {
+            if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';
+          }}
+        />
+      );
+
+      const file = new File(['content'], 'model.gcode', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      expect(screen.getByText('model.gcode')).toBeInTheDocument();
+      expect(screen.queryByText('Only .gcode files allowed')).not.toBeInTheDocument();
+    });
+
+    it('clears validation error when a new file is added', async () => {
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          validateFile={(file) => {
+            if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';
+          }}
+        />
+      );
+
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+
+      // First add an invalid file
+      const badFile = new File(['content'], 'model.stl', { type: 'application/octet-stream' });
+      await user.upload(fileInput, badFile);
+      expect(screen.getByText('Only .gcode files allowed')).toBeInTheDocument();
+
+      // Then add a valid file — error should clear
+      const goodFile = new File(['content'], 'model.gcode', { type: 'application/octet-stream' });
+      await user.upload(fileInput, goodFile);
+      expect(screen.queryByText('Only .gcode files allowed')).not.toBeInTheDocument();
+    });
+  });
+
+  describe('accept prop', () => {
+    it('sets accept attribute on file input', () => {
+      render(<FileUploadModal {...defaultProps} accept=".gcode,.gcode.3mf" />);
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      expect(fileInput.accept).toBe('.gcode,.gcode.3mf');
+    });
+
+    it('does not set accept attribute when prop is omitted', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      expect(fileInput.accept).toBe('');
+    });
+  });
+
+  describe('onFileUploaded error handling', () => {
+    it('shows error and keeps modal open when onFileUploaded returns a string', async () => {
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          onFileUploaded={() => 'This file was sliced for the wrong printer'}
+        />
+      );
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(screen.getByText('This file was sliced for the wrong printer')).toBeInTheDocument();
+      });
+
+      // Modal should NOT close
+      expect(defaultProps.onClose).not.toHaveBeenCalled();
+    });
+
+    it('clears file list when onFileUploaded returns an error', async () => {
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          onFileUploaded={() => 'Incompatible printer'}
+        />
+      );
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(screen.getByText('Incompatible printer')).toBeInTheDocument();
+      });
+
+      // File list should be cleared
+      expect(screen.queryByText('model.3mf')).not.toBeInTheDocument();
+    });
+
+    it('closes modal normally when onFileUploaded returns undefined', async () => {
+      const onFileUploaded = vi.fn();
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} onFileUploaded={onFileUploaded} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(defaultProps.onClose).toHaveBeenCalled();
+      });
+    });
+  });
+});

+ 112 - 12
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -569,8 +569,8 @@ describe('FileManagerPage', () => {
     });
   });
 
-  describe('upload modal with advanced 3MF support', () => {
-    it('opens upload modal', async () => {
+  describe('upload modal (FileUploadModal)', () => {
+    it('opens upload modal when Upload button is clicked', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
 
@@ -586,6 +586,27 @@ describe('FileManagerPage', () => {
       });
     });
 
+    it('closes upload modal when Cancel is clicked', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByRole('button', { name: 'Cancel' }));
+
+      await waitFor(() => {
+        expect(screen.queryByText('Upload Files')).not.toBeInTheDocument();
+      });
+    });
+
     it('shows 3MF extraction info when 3MF file is added', async () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
@@ -600,17 +621,12 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
       });
 
-      // Create a mock 3MF file
       const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
-
-      // Get the hidden file input
       const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
       expect(fileInput).toBeInTheDocument();
 
-      // Simulate file selection
       await user.upload(fileInput, threemfFile);
 
-      // 3MF extraction info should appear
       await waitFor(() => {
         expect(screen.getByText('3MF files detected')).toBeInTheDocument();
         expect(screen.getByText(/Printer model.*will be automatically extracted/i)).toBeInTheDocument();
@@ -631,22 +647,106 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
       });
 
-      // Create a mock STL file
       const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
-
-      // Get the hidden file input
       const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
       expect(fileInput).toBeInTheDocument();
 
-      // Simulate file selection
       await user.upload(fileInput, stlFile);
 
-      // STL thumbnail option should appear
       await waitFor(() => {
         expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
         expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();
       });
     });
+
+    it('shows ZIP options when ZIP file is added', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('ZIP files detected')).toBeInTheDocument();
+        expect(screen.getByText(/Preserve folder structure/)).toBeInTheDocument();
+      });
+    });
+
+    it('can add a file via the file input', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      await waitFor(() => {
+        expect(screen.getByText('model.3mf')).toBeInTheDocument();
+        expect(screen.getByRole('button', { name: /Upload \(1\)/i })).toBeInTheDocument();
+      });
+    });
+
+    it('uploads file and refreshes file list', async () => {
+      server.use(
+        http.post('/api/v1/library/files', () => {
+          return HttpResponse.json({
+            id: 10,
+            filename: 'uploaded.3mf',
+            file_type: '3mf',
+            file_size: 1024,
+            thumbnail_path: null,
+            duplicate_of: null,
+            metadata: null,
+          });
+        })
+      );
+
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      const file = new File(['content'], 'uploaded.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      // Modal should auto-close after upload completes
+      await waitFor(() => {
+        expect(screen.queryByText('Upload Files')).not.toBeInTheDocument();
+      });
+    });
   });
 
   describe('authentication-based UI changes', () => {

+ 396 - 0
frontend/src/__tests__/pages/InventoryPageLowStock.test.tsx

@@ -0,0 +1,396 @@
+/**
+ * Tests for low stock threshold functionality in InventoryPage.
+ *
+ * Tests that the low stock threshold:
+ * - Is loaded from backend settings API
+ * - Can be updated via the UI
+ * - Persists changes to the backend
+ * - Does not use localStorage
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import InventoryPageRouter from '../../pages/InventoryPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockSettings = {
+  auto_archive: true,
+  save_thumbnails: true,
+  capture_finish_photo: true,
+  default_filament_cost: 25.0,
+  currency: 'USD',
+  energy_cost_per_kwh: 0.15,
+  energy_tracking_mode: 'total',
+  spoolman_enabled: false,
+  spoolman_url: '',
+  spoolman_sync_mode: 'auto',
+  spoolman_disable_weight_sync: false,
+  spoolman_report_partial_usage: true,
+  check_updates: true,
+  check_printer_firmware: true,
+  include_beta_updates: false,
+  language: 'en',
+  notification_language: 'en',
+  bed_cooled_threshold: 35,
+  ams_humidity_good: 40,
+  ams_humidity_fair: 60,
+  ams_temp_good: 28,
+  ams_temp_fair: 35,
+  ams_history_retention_days: 30,
+  per_printer_mapping_expanded: false,
+  date_format: 'system',
+  time_format: 'system',
+  default_printer_id: null,
+  virtual_printer_enabled: false,
+  virtual_printer_access_code: '',
+  virtual_printer_mode: 'immediate',
+  dark_style: 'classic',
+  dark_background: 'neutral',
+  dark_accent: 'green',
+  light_style: 'classic',
+  light_background: 'neutral',
+  light_accent: 'green',
+  ftp_retry_enabled: true,
+  ftp_retry_count: 3,
+  ftp_retry_delay: 2,
+  ftp_timeout: 30,
+  mqtt_enabled: false,
+  mqtt_broker: '',
+  mqtt_port: 1883,
+  mqtt_username: '',
+  mqtt_password: '',
+  mqtt_topic_prefix: 'bambuddy',
+  mqtt_use_tls: false,
+  external_url: '',
+  ha_enabled: false,
+  ha_url: '',
+  ha_token: '',
+  ha_url_from_env: false,
+  ha_token_from_env: false,
+  ha_env_managed: false,
+  library_archive_mode: 'ask',
+  library_disk_warning_gb: 5.0,
+  camera_view_mode: 'window',
+  preferred_slicer: 'bambu_studio',
+  prometheus_enabled: false,
+  prometheus_token: '',
+  low_stock_threshold: 20.0,
+};
+
+const mockSpools = [
+  {
+    id: 1,
+    material: 'PLA',
+    subtype: null,
+    brand: 'Polymaker',
+    color_name: 'Red',
+    rgba: 'FF0000FF',
+    label_weight: 1000,
+    core_weight: 250,
+    weight_used: 900, // 10% remaining - low stock
+    slicer_filament: null,
+    slicer_filament_name: null,
+    nozzle_temp_min: null,
+    nozzle_temp_max: null,
+    note: null,
+    added_full: null,
+    last_used: null,
+    encode_time: null,
+    tag_uid: null,
+    tray_uuid: null,
+    data_origin: null,
+    tag_type: null,
+    archived_at: null,
+    created_at: '2025-01-01T00:00:00Z',
+    updated_at: '2025-01-01T00:00:00Z',
+    k_profiles: [],
+    cost_per_kg: null,
+    last_scale_weight: null,
+    last_weighed_at: null,
+  },
+  {
+    id: 2,
+    material: 'PETG',
+    subtype: null,
+    brand: 'eSun',
+    color_name: 'Blue',
+    rgba: '0000FFFF',
+    label_weight: 1000,
+    core_weight: 250,
+    weight_used: 200, // 80% remaining - not low stock
+    slicer_filament: null,
+    slicer_filament_name: null,
+    nozzle_temp_min: null,
+    nozzle_temp_max: null,
+    note: null,
+    added_full: null,
+    last_used: null,
+    encode_time: null,
+    tag_uid: null,
+    tray_uuid: null,
+    data_origin: null,
+    tag_type: null,
+    archived_at: null,
+    created_at: '2025-01-02T00:00:00Z',
+    updated_at: '2025-01-02T00:00:00Z',
+    k_profiles: [],
+    cost_per_kg: null,
+    last_scale_weight: null,
+    last_weighed_at: null,
+  },
+  {
+    id: 3,
+    material: 'ABS',
+    subtype: null,
+    brand: 'Hatchbox',
+    color_name: 'Black',
+    rgba: '000000FF',
+    label_weight: 1000,
+    core_weight: 250,
+    weight_used: 850, // 15% remaining - low stock
+    slicer_filament: null,
+    slicer_filament_name: null,
+    nozzle_temp_min: null,
+    nozzle_temp_max: null,
+    note: null,
+    added_full: null,
+    last_used: null,
+    encode_time: null,
+    tag_uid: null,
+    tray_uuid: null,
+    data_origin: null,
+    tag_type: null,
+    archived_at: null,
+    created_at: '2025-01-03T00:00:00Z',
+    updated_at: '2025-01-03T00:00:00Z',
+    k_profiles: [],
+    cost_per_kg: null,
+    last_scale_weight: null,
+    last_weighed_at: null,
+  },
+];
+
+describe('InventoryPage - Low Stock Threshold', () => {
+  beforeEach(() => {
+    // Clear localStorage to ensure we're not relying on it
+    localStorage.clear();
+
+    server.use(
+      http.get('/api/v1/settings/', () => {
+        return HttpResponse.json(mockSettings);
+      }),
+      http.put('/api/v1/settings/', async ({ request }) => {
+        const body = (await request.json()) as Partial<typeof mockSettings>;
+        return HttpResponse.json({ ...mockSettings, ...body });
+      }),
+      http.get('/api/v1/inventory/spools', () => {
+        return HttpResponse.json(mockSpools);
+      }),
+      http.get('/api/v1/inventory/assignments', () => {
+        return HttpResponse.json([]);
+      }),
+      http.get('/api/v1/spoolman/settings', () => {
+        return HttpResponse.json({ spoolman_enabled: 'false' });
+      })
+    );
+  });
+
+  describe('default threshold from backend', () => {
+    it('loads the default threshold of 20% from backend settings', async () => {
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        // Find the low stock stat showing the threshold
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+    });
+
+    it('calculates low stock count based on default threshold', async () => {
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        // With default 20% threshold, spools with 10% and 15% remaining should be counted (2 spools)
+        const lowStockSection = screen.getByText(/low stock/i).closest('div');
+        expect(lowStockSection).toBeInTheDocument();
+      });
+    });
+
+    it('does not use localStorage for threshold', async () => {
+      // Set a value in localStorage that should be ignored
+      localStorage.setItem('bambuddy-low-stock-threshold', '50');
+
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        // Should show backend value (20%), not localStorage value (50%)
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('updating threshold via UI', () => {
+    it('shows edit button for threshold', async () => {
+      const user = userEvent.setup();
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+
+      // Find the edit button within the low stock threshold section
+      const thresholdText = screen.getByText(/< 20%/i);
+      const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
+      expect(editButton).toBeInTheDocument();
+
+      await user.click(editButton);
+
+      // Input field should appear with default threshold value
+      await waitFor(() => {
+        const input = screen.getByDisplayValue('20');
+        expect(input).toBeInTheDocument();
+      });
+    });
+
+    it('updates threshold and persists to backend', async () => {
+      const user = userEvent.setup();
+      let updatedSettings: Partial<typeof mockSettings> | null = null;
+
+      server.use(
+        http.put('/api/v1/settings/', async ({ request }) => {
+          const body = (await request.json()) as Partial<typeof mockSettings>;
+          updatedSettings = body;
+          return HttpResponse.json({ ...mockSettings, ...body });
+        })
+      );
+
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+
+      // Click edit button within the low stock threshold section
+      const thresholdText = screen.getByText(/< 20%/i);
+      const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
+      await user.click(editButton);
+
+      // Enter new value
+      const input = screen.getByDisplayValue('20');
+      await user.clear(input);
+      await user.type(input, '15.5');
+
+      // Submit form
+      const saveButton = screen.getByRole('button', { name: /save/i });
+      await user.click(saveButton);
+
+      // Verify API was called with correct value
+      await waitFor(() => {
+        expect(updatedSettings).toEqual({ low_stock_threshold: 15.5 });
+      });
+    });
+
+    it('validates threshold input range', async () => {
+      const user = userEvent.setup();
+      let updatedSettings: Partial<typeof mockSettings> | null = null;
+
+      server.use(
+        http.put('/api/v1/settings/', async ({ request }) => {
+          const body = (await request.json()) as Partial<typeof mockSettings>;
+          updatedSettings = body;
+          return HttpResponse.json({ ...mockSettings, ...body });
+        })
+      );
+
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+
+      // Click edit button within the low stock threshold section
+      const thresholdText = screen.getByText(/< 20%/i);
+      const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
+      await user.click(editButton);
+
+      // Try invalid values
+      const input = screen.getByDisplayValue('20');
+
+      // Too low (0 is below the 0.1 minimum)
+      await user.clear(input);
+      await user.type(input, '0');
+
+      const saveButton = screen.getByRole('button', { name: /save/i });
+      await user.click(saveButton);
+
+      // Should show error and NOT call the PUT endpoint
+      await waitFor(() => {
+        expect(updatedSettings).toBeNull();
+      });
+    });
+
+    it('allows canceling threshold edit', async () => {
+      const user = userEvent.setup();
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+
+      // Click edit button within the low stock threshold section
+      const thresholdText = screen.getByText(/< 20%/i);
+      const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
+      await user.click(editButton);
+
+      // Change value
+      const input = screen.getByDisplayValue('20');
+      await user.clear(input);
+      await user.type(input, '30');
+
+      // Cancel
+      const cancelButton = screen.getByRole('button', { name: /cancel/i });
+      await user.click(cancelButton);
+
+      // Should revert to original display
+      await waitFor(() => {
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('custom threshold from backend', () => {
+    it('loads custom threshold value from backend', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ ...mockSettings, low_stock_threshold: 25.0 });
+        })
+      );
+
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 25%/i)).toBeInTheDocument();
+      });
+    });
+
+    it('applies custom threshold to low stock filtering', async () => {
+      // With threshold at 30%, all 3 test spools should be low stock (10%, 15%, and we'd need to check 80%)
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ ...mockSettings, low_stock_threshold: 30.0 });
+        })
+      );
+
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 30%/i)).toBeInTheDocument();
+      });
+
+      // The low stock count should reflect the new threshold
+      // Implementation would show appropriate count based on 30% threshold
+    });
+  });
+});

+ 6 - 2
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -112,12 +112,16 @@ describe('PrintersPage', () => {
   });
 
   describe('printer info', () => {
-    it('shows IP address', async () => {
+    it('shows IP address in printer info modal', async () => {
       render(<PrintersPage />);
 
       await waitFor(() => {
-        expect(screen.getByText('192.168.1.100')).toBeInTheDocument();
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
       });
+
+      // IP address is shown in the PrinterInfoModal (accessed via 3-dot menu),
+      // not directly on the card. Verify the printer data loaded correctly.
+      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
     });
 
     it('shows location when set', async () => {

+ 2 - 0
frontend/src/api/client.ts

@@ -837,6 +837,8 @@ export interface AppSettings {
   prometheus_token: string;
   // Bed cooled threshold
   bed_cooled_threshold: number;
+  // Inventory low stock threshold
+  low_stock_threshold: number;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;

+ 352 - 0
frontend/src/components/FileUploadModal.tsx

@@ -0,0 +1,352 @@
+import { useState, useRef, type DragEvent } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  Upload,
+  X,
+  File,
+  Loader2,
+  CheckCircle,
+  XCircle,
+  Archive as ArchiveIcon,
+  Printer,
+  Image,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type { LibraryFileUploadResponse } from '../api/client';
+import { Button } from './Button';
+
+interface UploadFile {
+  file: File;
+  status: 'pending' | 'uploading' | 'success' | 'error';
+  error?: string;
+  isZip?: boolean;
+  is3mf?: boolean;
+  extractedCount?: number;
+}
+
+interface FileUploadModalProps {
+  folderId: number | null;
+  onClose: () => void;
+  onUploadComplete: () => void;
+  /** Called after each file is successfully uploaded with its response data. Return a string to show an error and prevent modal from closing. */
+  onFileUploaded?: (file: LibraryFileUploadResponse) => string | void;
+  /** When true, automatically uploads the file as soon as it's added and closes the modal */
+  autoUpload?: boolean;
+  /** Validate files before adding. Return a string to reject with an error message. */
+  validateFile?: (file: File) => string | undefined;
+  /** Restrict file picker to specific file types (e.g. ".gcode,.gcode.3mf") */
+  accept?: string;
+}
+
+export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUploaded, autoUpload, validateFile, accept }: FileUploadModalProps) {
+  const { t } = useTranslation();
+  const [files, setFiles] = useState<UploadFile[]>([]);
+  const [isDragging, setIsDragging] = useState(false);
+  const [isUploading, setIsUploading] = useState(false);
+  const [preserveZipStructure, setPreserveZipStructure] = useState(true);
+  const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
+  const [generateStlThumbnails, setGenerateStlThumbnails] = useState(true);
+  const [uploadError, setUploadError] = useState<string | null>(null);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
+    e.preventDefault();
+    setIsDragging(true);
+  };
+
+  const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
+    e.preventDefault();
+    setIsDragging(false);
+  };
+
+  const handleDrop = (e: DragEvent<HTMLDivElement>) => {
+    e.preventDefault();
+    setIsDragging(false);
+    addFiles(Array.from(e.dataTransfer.files));
+  };
+
+  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
+    if (e.target.files) {
+      addFiles(Array.from(e.target.files));
+    }
+  };
+
+  const updateFileStatus = (file: File, update: Partial<UploadFile>) => {
+    setFiles((prev) => prev.map((f) => (f.file === file ? { ...f, ...update } : f)));
+  };
+
+  const uploadFiles = async (filesToUpload: UploadFile[]) => {
+    setIsUploading(true);
+
+    for (const uf of filesToUpload) {
+      if (uf.status !== 'pending') continue;
+
+      updateFileStatus(uf.file, { status: 'uploading' });
+
+      try {
+        if (uf.isZip) {
+          const result = await api.extractZipFile(uf.file, folderId, preserveZipStructure, createFolderFromZip, generateStlThumbnails);
+          updateFileStatus(uf.file, {
+            status: result.errors.length > 0 && result.extracted === 0 ? 'error' : 'success',
+            extractedCount: result.extracted,
+            error: result.errors.length > 0 ? t('fileManager.zipFilesFailed', '{{count}} files failed', { count: result.errors.length }) : undefined,
+          });
+        } else {
+          const result = await api.uploadLibraryFile(uf.file, folderId, generateStlThumbnails);
+          updateFileStatus(uf.file, { status: 'success' });
+          const error = onFileUploaded?.(result);
+          if (error) {
+            setUploadError(error);
+            setFiles([]);
+            setIsUploading(false);
+            return;
+          }
+        }
+      } catch (err) {
+        updateFileStatus(uf.file, {
+          status: 'error',
+          error: err instanceof Error ? err.message : t('fileManager.uploadFailed', 'Upload failed'),
+        });
+      }
+    }
+
+    setIsUploading(false);
+    onUploadComplete();
+    onClose();
+  };
+
+  const addFiles = (newFiles: File[]) => {
+    setUploadError(null);
+    if (validateFile) {
+      for (const file of newFiles) {
+        const error = validateFile(file);
+        if (error) {
+          setUploadError(error);
+          return;
+        }
+      }
+    }
+    const toUpload: UploadFile[] = newFiles.map((file) => ({
+      file,
+      status: 'pending' as const,
+      isZip: file.name.toLowerCase().endsWith('.zip'),
+      is3mf: file.name.toLowerCase().endsWith('.3mf'),
+    }));
+    setFiles((prev) => [...prev, ...toUpload]);
+
+    if (autoUpload && newFiles.length > 0) {
+      uploadFiles(toUpload);
+    }
+  };
+
+  const removeFile = (index: number) => {
+    setFiles((prev) => prev.filter((_, i) => i !== index));
+  };
+
+  const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
+  const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending');
+  const has3mfFiles = files.some((f) => f.is3mf && f.status === 'pending');
+  const pendingCount = files.filter((f) => f.status === 'pending').length;
+  const allDone = files.length > 0 && pendingCount === 0 && !isUploading;
+
+  return (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-lg border border-bambu-dark-tertiary">
+        <div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
+          <h2 className="text-lg font-semibold text-white">{t('fileManager.uploadFiles')}</h2>
+          <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
+            <X className="w-5 h-5 text-bambu-gray" />
+          </button>
+        </div>
+
+        <div className="p-4 space-y-4">
+          {/* Drop Zone */}
+          <div
+            onDragOver={handleDragOver}
+            onDragLeave={handleDragLeave}
+            onDrop={handleDrop}
+            onClick={() => fileInputRef.current?.click()}
+            className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
+              isDragging
+                ? 'border-bambu-green bg-bambu-green/10'
+                : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
+            }`}
+          >
+            <Upload className={`w-10 h-10 mx-auto mb-3 ${isDragging ? 'text-bambu-green' : 'text-bambu-gray'}`} />
+            <p className="text-white font-medium">
+              {isDragging ? t('fileManager.dropFilesHere') : t('fileManager.dragDropFiles')}
+            </p>
+            <p className="text-sm text-bambu-gray mt-1">{t('fileManager.orClickToBrowse')}</p>
+            <p className="text-xs text-bambu-gray/70 mt-2">{t('fileManager.allFileTypesSupported')}</p>
+          </div>
+
+          <input
+            ref={fileInputRef}
+            type="file"
+            multiple
+            accept={accept}
+            className="hidden"
+            onChange={handleFileSelect}
+          />
+
+          {/* ZIP Options */}
+          {hasZipFiles && (
+            <div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
+              <div className="flex items-start gap-3">
+                <ArchiveIcon className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" />
+                <div className="flex-1">
+                  <p className="text-sm text-blue-300 font-medium">{t('fileManager.zipFilesDetected')}</p>
+                  <p className="text-xs text-blue-300/70 mt-1">
+                    {t('fileManager.zipExtractOptions')}
+                  </p>
+                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={preserveZipStructure}
+                      onChange={(e) => setPreserveZipStructure(e.target.checked)}
+                      className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                    />
+                    <span className="text-sm text-white">{t('fileManager.preserveZipStructure')}</span>
+                  </label>
+                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={createFolderFromZip}
+                      onChange={(e) => setCreateFolderFromZip(e.target.checked)}
+                      className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                    />
+                    <span className="text-sm text-white">{t('fileManager.createFolderFromZip')}</span>
+                  </label>
+                </div>
+              </div>
+            </div>
+          )}
+
+          {/* 3MF File Info */}
+          {has3mfFiles && (
+            <div className="p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg">
+              <div className="flex items-start gap-3">
+                <Printer className="w-5 h-5 text-purple-400 mt-0.5 flex-shrink-0" />
+                <div className="flex-1">
+                  <p className="text-sm text-purple-300 font-medium">{t('fileManager.threemfDetected')}</p>
+                  <p className="text-xs text-purple-300/70 mt-1">
+                    {t('fileManager.threemfExtractionInfo')}
+                  </p>
+                </div>
+              </div>
+            </div>
+          )}
+
+          {/* STL Thumbnail Options */}
+          {(hasStlFiles || hasZipFiles) && (
+            <div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
+              <div className="flex items-start gap-3">
+                <Image className="w-5 h-5 text-bambu-green mt-0.5 flex-shrink-0" />
+                <div className="flex-1">
+                  <p className="text-sm text-bambu-green font-medium">{t('fileManager.stlThumbnailGeneration')}</p>
+                  <p className="text-xs text-bambu-green/70 mt-1">
+                    {hasZipFiles && !hasStlFiles
+                      ? t('fileManager.zipMayContainStl')
+                      : t('fileManager.thumbnailsCanBeGenerated')}
+                  </p>
+                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={generateStlThumbnails}
+                      onChange={(e) => setGenerateStlThumbnails(e.target.checked)}
+                      className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                    />
+                    <span className="text-sm text-white">{t('fileManager.generateThumbnailsForStl')}</span>
+                  </label>
+                </div>
+              </div>
+            </div>
+          )}
+
+          {/* File List */}
+          {files.length > 0 && (
+            <div className="max-h-48 overflow-y-auto space-y-2">
+              {files.map((uploadFile, index) => (
+                <div
+                  key={index}
+                  className="flex items-center gap-3 p-2 bg-bambu-dark rounded-lg"
+                >
+                  {uploadFile.isZip ? (
+                    <ArchiveIcon className="w-4 h-4 text-blue-400 flex-shrink-0" />
+                  ) : (
+                    <File className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+                  )}
+                  <div className="flex-1 min-w-0">
+                    <p className="text-sm text-white truncate">{uploadFile.file.name}</p>
+                    <p className="text-xs text-bambu-gray">
+                      {(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB
+                      {uploadFile.isZip && uploadFile.status === 'pending' && (
+                        <span className="text-blue-400 ml-2">• {t('fileManager.willBeExtracted')}</span>
+                      )}
+                      {uploadFile.extractedCount !== undefined && (
+                        <span className="text-green-400 ml-2">• {t('fileManager.filesExtracted', { count: uploadFile.extractedCount })}</span>
+                      )}
+                    </p>
+                  </div>
+                  {uploadFile.status === 'pending' && (
+                    <button
+                      onClick={() => removeFile(index)}
+                      className="p-1 hover:bg-bambu-dark-tertiary rounded"
+                    >
+                      <X className="w-4 h-4 text-bambu-gray" />
+                    </button>
+                  )}
+                  {uploadFile.status === 'uploading' && (
+                    <Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
+                  )}
+                  {uploadFile.status === 'success' && (
+                    <CheckCircle className="w-4 h-4 text-green-500" />
+                  )}
+                  {uploadFile.status === 'error' && (
+                    <span title={uploadFile.error}>
+                      <XCircle className="w-4 h-4 text-red-500" />
+                    </span>
+                  )}
+                </div>
+              ))}
+            </div>
+          )}
+
+          {/* Compatibility Error */}
+          {uploadError && (
+            <div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
+              <div className="flex items-start gap-3">
+                <XCircle className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
+                <p className="text-sm text-red-300">{uploadError}</p>
+              </div>
+            </div>
+          )}
+        </div>
+
+        <div className="p-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
+          <Button variant="secondary" onClick={onClose}>
+            {t('common.cancel')}
+          </Button>
+          {!allDone && (
+            <Button
+              onClick={() => uploadFiles(files)}
+              disabled={pendingCount === 0 || isUploading}
+            >
+              {isUploading ? (
+                <>
+                  <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                  {t('fileManager.uploading')}
+                </>
+              ) : (
+                <>
+                  <Upload className="w-4 h-4 mr-2" />
+                  {t('common.upload')} {pendingCount > 0 ? `(${pendingCount})` : ''}
+                </>
+              )}
+            </Button>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 32 - 23
frontend/src/components/PrintModal/index.tsx

@@ -43,6 +43,7 @@ export function PrintModal({
   libraryFileId,
   archiveName,
   queueItem,
+  initialSelectedPrinterIds,
   onClose,
   onSuccess,
 }: PrintModalProps) {
@@ -60,6 +61,9 @@ export function PrintModal({
     if (mode === 'edit-queue-item' && queueItem?.printer_id) {
       return [queueItem.printer_id];
     }
+    if (initialSelectedPrinterIds?.length) {
+      return initialSelectedPrinterIds;
+    }
     return [];
   });
 
@@ -677,7 +681,10 @@ export function PrintModal({
             <p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
               {mode === 'reprint' ? (
                 <>
-                  Send <span className="text-white">{archiveName}</span> to printer(s)
+                  Send <span className="text-white">{archiveName}</span> to{' '}
+                  {initialSelectedPrinterIds?.length === 1 && printers
+                    ? <span className="text-white">{printers.find(p => p.id === initialSelectedPrinterIds[0])?.name ?? 'printer(s)'}</span>
+                    : 'printer(s)'}
                 </>
               ) : (
                 <>
@@ -695,26 +702,28 @@ export function PrintModal({
               onSelect={setSelectedPlate}
             />
 
-            {/* Printer selection with per-printer mapping */}
-            <PrinterSelector
-              printers={printers || []}
-              selectedPrinterIds={selectedPrinters}
-              onMultiSelect={setSelectedPrinters}
-              isLoading={loadingPrinters}
-              allowMultiple={true}
-              showInactive={mode === 'edit-queue-item'}
-              printerMappingResults={multiPrinterMapping.printerResults}
-              filamentReqs={effectiveFilamentReqs}
-              onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
-              onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
-              assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
-              onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
-              targetModel={targetModel}
-              onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
-              targetLocation={targetLocation}
-              onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
-              slicedForModel={slicedForModel}
-            />
+            {/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
+            {!initialSelectedPrinterIds?.length && (
+              <PrinterSelector
+                printers={printers || []}
+                selectedPrinterIds={selectedPrinters}
+                onMultiSelect={setSelectedPrinters}
+                isLoading={loadingPrinters}
+                allowMultiple={true}
+                showInactive={mode === 'edit-queue-item'}
+                printerMappingResults={multiPrinterMapping.printerResults}
+                filamentReqs={effectiveFilamentReqs}
+                onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
+                onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
+                assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
+                onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
+                targetModel={targetModel}
+                onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
+                targetLocation={targetLocation}
+                onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
+                slicedForModel={slicedForModel}
+              />
+            )}
 
             {/* Filament override - shown in model mode when filament requirements are available */}
             {assignmentMode === 'model' && targetModel && effectiveFilamentReqs && availableFilaments && availableFilaments.length > 0 && (
@@ -759,7 +768,7 @@ export function PrintModal({
                 filamentReqs={effectiveFilamentReqs}
                 manualMappings={manualMappings}
                 onManualMappingChange={setManualMappings}
-                defaultExpanded={settings?.per_printer_mapping_expanded ?? false}
+                defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)}
                 currencySymbol={currencySymbol}
                 defaultCostPerKg={defaultCostPerKg}
               />
@@ -767,7 +776,7 @@ export function PrintModal({
 
             {/* Print options */}
             {(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
-              <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
+              <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} defaultExpanded={!!initialSelectedPrinterIds?.length} />
             )}
 
             {/* Schedule options - only for queue modes */}

+ 2 - 0
frontend/src/components/PrintModal/types.ts

@@ -26,6 +26,8 @@ export interface PrintModalProps {
   archiveName: string;
   /** Existing queue item (only for edit-queue-item mode) */
   queueItem?: PrintQueueItem;
+  /** Pre-select specific printers when opening the modal */
+  initialSelectedPrinterIds?: number[];
   /** Handler for closing the modal */
   onClose: () => void;
   /** Handler for successful operation */

+ 246 - 0
frontend/src/components/PrinterInfoModal.tsx

@@ -0,0 +1,246 @@
+import { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { X, Copy, Check, Signal } from 'lucide-react';
+import { Card, CardContent } from './Card';
+import { formatDateOnly } from '../utils/date';
+import { getPrinterImage, getWifiStrength } from '../utils/printer';
+import type { Printer, PrinterStatus } from '../api/client';
+
+interface PrinterInfoModalProps {
+  printer: Printer;
+  status?: PrinterStatus;
+  totalPrintHours?: number;
+  onClose: () => void;
+}
+
+function CopyButton({ value }: { value: string }) {
+  const { t } = useTranslation();
+  const [copied, setCopied] = useState(false);
+
+  const handleCopy = async () => {
+    try {
+      await navigator.clipboard.writeText(value);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
+    } catch {
+      // Clipboard may not be available in non-secure contexts
+    }
+  };
+
+  return (
+    <button
+      onClick={handleCopy}
+      className="ml-2 p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
+      title={copied ? t('printers.copied') : t('printers.copyToClipboard')}
+    >
+      {copied ? <Check className="w-3.5 h-3.5 text-bambu-green" /> : <Copy className="w-3.5 h-3.5" />}
+    </button>
+  );
+}
+
+export function PrinterInfoModal({ printer, status, totalPrintHours, onClose }: PrinterInfoModalProps) {
+  const { t } = useTranslation();
+
+  useEffect(() => {
+    const handleKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKey);
+    return () => window.removeEventListener('keydown', handleKey);
+  }, [onClose]);
+
+  const rows: { label: string; value: React.ReactNode }[] = [];
+
+  // Model
+  rows.push({
+    label: t('printers.model'),
+    value: printer.model ?? '—',
+  });
+
+  // Connection Status
+  rows.push({
+    label: t('common.status'),
+    value: (
+      <span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${
+        status?.connected
+          ? 'bg-bambu-green/20 text-bambu-green'
+          : 'bg-red-500/20 text-red-400'
+      }`}>
+        <span className={`w-1.5 h-1.5 rounded-full ${status?.connected ? 'bg-bambu-green' : 'bg-red-400'}`} />
+        {status?.connected ? t('printers.status.available') : t('printers.status.offline')}
+      </span>
+    ),
+  });
+
+  // State
+  if (status?.state) {
+    const stateMap: Record<string, string> = {
+      IDLE: 'printers.status.idle',
+      RUNNING: 'printers.status.printing',
+      PAUSE: 'printers.status.paused',
+      FINISH: 'printers.status.finished',
+      FAILED: 'printers.status.error',
+    };
+    rows.push({
+      label: t('printers.state'),
+      value: t(stateMap[status.state] ?? 'printers.status.unknown'),
+    });
+  }
+
+  // IP Address
+  rows.push({
+    label: t('printers.ipAddress'),
+    value: (
+      <span className="flex items-center">
+        <span className="font-mono">{printer.ip_address}</span>
+        <CopyButton value={printer.ip_address} />
+      </span>
+    ),
+  });
+
+  // Serial Number
+  rows.push({
+    label: t('printers.serialNumber'),
+    value: (
+      <span className="flex items-center">
+        <span className="font-mono truncate">{printer.serial_number}</span>
+        <CopyButton value={printer.serial_number} />
+      </span>
+    ),
+  });
+
+  // WiFi Signal
+  if (status?.wifi_signal != null) {
+    const wifi = getWifiStrength(status.wifi_signal);
+    rows.push({
+      label: t('printers.wifiSignalLabel'),
+      value: (
+        <span className="flex items-center gap-2">
+          <Signal className={`w-4 h-4 ${wifi.color}`} />
+          <span className={wifi.color}>{t(wifi.labelKey)}</span>
+          <span className="text-bambu-gray text-xs">({status.wifi_signal} dBm)</span>
+        </span>
+      ),
+    });
+  }
+
+  // Firmware
+  rows.push({
+    label: t('printers.firmware'),
+    value: status?.firmware_version ?? '—',
+  });
+
+  // Developer Mode
+  if (status?.developer_mode != null) {
+    rows.push({
+      label: t('printers.developerMode'),
+      value: (
+        <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
+          status.developer_mode
+            ? 'bg-bambu-green/20 text-bambu-green'
+            : 'bg-bambu-dark-tertiary text-bambu-gray'
+        }`}>
+          {status.developer_mode ? t('printers.enabled') : t('printers.disabled')}
+        </span>
+      ),
+    });
+  }
+
+  // Nozzle Count
+  rows.push({
+    label: t('printers.nozzleCount'),
+    value: printer.nozzle_count,
+  });
+
+  // SD Card
+  if (status?.sdcard != null) {
+    rows.push({
+      label: t('printers.sdCard'),
+      value: (
+        <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
+          status.sdcard
+            ? 'bg-bambu-green/20 text-bambu-green'
+            : 'bg-bambu-dark-tertiary text-bambu-gray'
+        }`}>
+          {status.sdcard ? t('printers.inserted') : t('printers.notInserted')}
+        </span>
+      ),
+    });
+  }
+
+  // Auto-Archive
+  rows.push({
+    label: t('printers.autoArchive'),
+    value: (
+      <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
+        printer.auto_archive
+          ? 'bg-bambu-green/20 text-bambu-green'
+          : 'bg-bambu-dark-tertiary text-bambu-gray'
+      }`}>
+        {printer.auto_archive ? t('printers.enabled') : t('printers.disabled')}
+      </span>
+    ),
+  });
+
+  // Total Print Hours
+  if (totalPrintHours != null && totalPrintHours > 0) {
+    rows.push({
+      label: t('printers.totalPrintHours'),
+      value: `${Math.round(totalPrintHours)}h`,
+    });
+  }
+
+  // Location
+  if (printer.location) {
+    rows.push({
+      label: t('printers.sort.location'),
+      value: printer.location,
+    });
+  }
+
+  // Added date
+  rows.push({
+    label: t('printers.addedOn'),
+    value: formatDateOnly(printer.created_at),
+  });
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+      role="dialog"
+      aria-modal="true"
+      onClick={onClose}
+    >
+      <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+        <CardContent>
+          <div className="flex items-center justify-between mb-4">
+            <h2 className="text-lg font-semibold text-white">
+              {printer.name}
+            </h2>
+            <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded flex-shrink-0">
+              <X className="w-5 h-5 text-bambu-gray" />
+            </button>
+          </div>
+
+          {/* Printer Image */}
+          <div className="flex justify-center mb-4">
+            <img
+              src={getPrinterImage(printer.model)}
+              alt={printer.model ?? printer.name}
+              className="h-24 object-contain"
+            />
+          </div>
+
+          <div className="space-y-0">
+            {rows.map((row, i) => (
+              <div key={i} className="flex items-center justify-between gap-4 py-2.5 border-b border-bambu-dark-tertiary last:border-0">
+                <span className="text-sm text-bambu-gray whitespace-nowrap">{row.label}</span>
+                <span className="text-sm text-white text-right">{row.value}</span>
+              </div>
+            ))}
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 4 - 1
frontend/src/i18n/index.ts

@@ -9,6 +9,7 @@ import fr from './locales/fr';
 import ja from './locales/ja';
 import it from './locales/it';
 import ptBR from './locales/pt-BR';
+import zhCN from './locales/zh-CN';
 
 const resources = {
   en: { translation: en },
@@ -17,6 +18,7 @@ const resources = {
   ja: { translation: ja },
   it: { translation: it },
   'pt-BR': { translation: ptBR },
+  'zh-CN': { translation: zhCN },
 };
 
 i18n
@@ -25,7 +27,7 @@ i18n
   .init({
     resources,
     fallbackLng: 'en',
-    supportedLngs: ['en', 'de', 'fr', 'ja', 'it', 'pt-BR'],
+    supportedLngs: ['en', 'de', 'fr', 'ja', 'it', 'pt-BR', 'zh-CN'],
 
     detection: {
       // Order of detection methods
@@ -55,4 +57,5 @@ export const availableLanguages = [
   { code: 'ja', name: 'Japanese', nativeName: '日本語' },
   { code: 'it', name: 'Italian', nativeName: 'Italiano' },
   { code: 'pt-BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
+  { code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' },
 ];

+ 23 - 2
frontend/src/i18n/locales/de.ts

@@ -54,6 +54,8 @@ export default {
     refresh: 'Aktualisieren',
     download: 'Herunterladen',
     upload: 'Hochladen',
+    uploading: 'Hochladen...',
+    uploadFailed: 'Hochladen fehlgeschlagen',
     actions: 'Aktionen',
     status: 'Status',
     name: 'Name',
@@ -199,6 +201,7 @@ export default {
     chamberLightOn: 'Kammerbeleuchtung einschalten',
     chamberLightOff: 'Kammerbeleuchtung ausschalten',
     // Files
+    files: 'Dateien',
     browseFiles: 'Druckerdateien durchsuchen',
     // Smart plug
     autoOffAfterPrint: 'Automatisches Ausschalten nach Druck',
@@ -214,6 +217,19 @@ export default {
     skipObject: 'Objekt überspringen',
     reconnect: 'Neu verbinden',
     mqttDebug: 'MQTT-Debug',
+    printerInformation: 'Druckerinformationen',
+    copyToClipboard: 'Kopieren',
+    copied: 'Kopiert!',
+    state: 'Zustand',
+    wifiSignalLabel: 'WLAN-Signal',
+    developerMode: 'Entwicklermodus',
+    enabled: 'Aktiviert',
+    disabled: 'Deaktiviert',
+    addedOn: 'Hinzugefügt',
+    sdCard: 'SD-Karte',
+    inserted: 'Eingelegt',
+    notInserted: 'Nicht eingelegt',
+    totalPrintHours: 'Druckstunden',
     activeNozzle: 'Aktiv: {{nozzle}} Düse',
     nozzleRack: 'Düsenhalter',
     nozzleDocked: 'Angedockt',
@@ -462,6 +478,10 @@ export default {
     },
     developerModeWarning: 'Der Entwickler-LAN-Modus ist nicht aktiviert auf: {{names}}. Einige Funktionen funktionieren möglicherweise nicht.',
     howToEnable: 'Aktivieren',
+    incompatibleFile: 'Diese Datei wurde für {{slicedFor}} geslicet, aber dieser Drucker ist ein {{printerModel}}',
+    dropNotPrintable: 'Nur .gcode- und .gcode.3mf-Dateien können gedruckt werden',
+    dropToPrint: 'Zum Drucken ablegen',
+    cannotPrint: 'Drucker beschäftigt',
   },
 
   // Archives page
@@ -2241,7 +2261,8 @@ export default {
     willBeExtracted: 'Wird extrahiert',
     filesExtracted: '{{count}} Dateien extrahiert',
     uploadComplete: 'Upload abgeschlossen: {{succeeded}} erfolgreich',
-    uploadFailed: '{{count}} fehlgeschlagen',
+    uploadFailed: 'Hochladen fehlgeschlagen',
+    zipFilesFailed: '{{count}} Dateien fehlgeschlagen',
     uploading: 'Hochladen...',
     changeLink: 'Verknüpfung ändern...',
     linkTo: 'Verknüpfen mit...',
@@ -2691,7 +2712,6 @@ export default {
     sinceTracking: 'Seit Beginn der Erfassung',
     loadedInAms: 'Im AMS/Ext geladen',
     remaining: 'Verbleibend',
-    lowStockThreshold: '<20% verbleibend',
     weightCheck: 'Gewichtskontrolle',
     lastWeighed: 'Zuletzt gewogen',
     neverWeighed: 'Nie gewogen',
@@ -2744,6 +2764,7 @@ export default {
     clearHistory: 'Löschen',
     historyCleared: 'Verbrauchshistorie gelöscht',
     fillSourceLabel: '(Inv)',
+    lowStockThresholdError: 'Der Schwellenwert muss zwischen 0.1 und 99.9 liegen',
   },
 
   // Timelapse

+ 23 - 2
frontend/src/i18n/locales/en.ts

@@ -54,6 +54,8 @@ export default {
     refresh: 'Refresh',
     download: 'Download',
     upload: 'Upload',
+    uploading: 'Uploading...',
+    uploadFailed: 'Upload failed',
     actions: 'Actions',
     status: 'Status',
     name: 'Name',
@@ -199,6 +201,7 @@ export default {
     chamberLightOn: 'Turn on chamber light',
     chamberLightOff: 'Turn off chamber light',
     // Files
+    files: 'Files',
     browseFiles: 'Browse printer files',
     // Smart plug
     autoOffAfterPrint: 'Auto power-off after print',
@@ -214,6 +217,19 @@ export default {
     skipObject: 'Skip Object',
     reconnect: 'Reconnect',
     mqttDebug: 'MQTT Debug',
+    printerInformation: 'Printer Information',
+    copyToClipboard: 'Copy',
+    copied: 'Copied!',
+    state: 'State',
+    wifiSignalLabel: 'WiFi Signal',
+    developerMode: 'Developer Mode',
+    enabled: 'Enabled',
+    disabled: 'Disabled',
+    addedOn: 'Added',
+    sdCard: 'SD Card',
+    inserted: 'Inserted',
+    notInserted: 'Not inserted',
+    totalPrintHours: 'Print Hours',
     activeNozzle: 'Active: {{nozzle}} nozzle',
     nozzleRack: 'Nozzle Rack',
     nozzleDocked: 'Docked',
@@ -462,6 +478,10 @@ export default {
     },
     developerModeWarning: 'Developer LAN mode is not enabled on: {{names}}. Some features may not work.',
     howToEnable: 'How to enable',
+    incompatibleFile: 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}',
+    dropNotPrintable: 'Only .gcode and .gcode.3mf files can be printed',
+    dropToPrint: 'Drop to print',
+    cannotPrint: 'Printer busy',
   },
 
   // Archives page
@@ -2241,7 +2261,8 @@ export default {
     willBeExtracted: 'Will be extracted',
     filesExtracted: '{{count}} files extracted',
     uploadComplete: 'Upload complete: {{succeeded}} succeeded',
-    uploadFailed: '{{count}} failed',
+    uploadFailed: 'Upload failed',
+    zipFilesFailed: '{{count}} files failed',
     uploading: 'Uploading...',
     changeLink: 'Change Link...',
     linkTo: 'Link to...',
@@ -2695,7 +2716,6 @@ export default {
     sinceTracking: 'Since tracking started',
     loadedInAms: 'Loaded in AMS/Ext',
     remaining: 'Remaining',
-    lowStockThreshold: '<20% remaining',
     weightCheck: 'Weight Check',
     lastWeighed: 'Last weighed',
     neverWeighed: 'Never weighed',
@@ -2748,6 +2768,7 @@ export default {
     clearHistory: 'Clear',
     historyCleared: 'Usage history cleared',
     fillSourceLabel: '(Inv)',
+    lowStockThresholdError: 'Threshold must be between 0.1 and 99.9',
   },
 
   // Timelapse

+ 23 - 2
frontend/src/i18n/locales/fr.ts

@@ -54,6 +54,8 @@ export default {
     refresh: 'Actualiser',
     download: 'Télécharger',
     upload: 'Téléverser',
+    uploading: 'Téléversement...',
+    uploadFailed: 'Échec du téléversement',
     actions: 'Actions',
     status: 'Statut',
     name: 'Nom',
@@ -199,6 +201,7 @@ export default {
     chamberLightOn: 'Allumer la lumière de la chambre',
     chamberLightOff: 'Éteindre la lumière de la chambre',
     // Files
+    files: 'Fichiers',
     browseFiles: 'Parcourir les fichiers de l\'imprimante',
     // Smart plug
     autoOffAfterPrint: 'Extinction auto après impression',
@@ -214,6 +217,19 @@ export default {
     skipObject: 'Sauter l\'objet',
     reconnect: 'Reconnecter',
     mqttDebug: 'Débogage MQTT',
+    printerInformation: 'Informations imprimante',
+    copyToClipboard: 'Copier',
+    copied: 'Copié !',
+    state: 'État',
+    wifiSignalLabel: 'Signal WiFi',
+    developerMode: 'Mode développeur',
+    enabled: 'Activé',
+    disabled: 'Désactivé',
+    addedOn: 'Ajoutée le',
+    sdCard: 'Carte SD',
+    inserted: 'Insérée',
+    notInserted: 'Non insérée',
+    totalPrintHours: 'Heures d\'impression',
     activeNozzle: 'Active : buse {{nozzle}}',
     nozzleRack: 'Rack à buses',
     nozzleDocked: 'Rangée',
@@ -462,6 +478,10 @@ export default {
     },
     developerModeWarning: 'Le mode développeur LAN n\'est pas activé sur : {{names}}. Certaines fonctionnalités peuvent ne pas fonctionner.',
     howToEnable: 'Comment activer',
+    incompatibleFile: 'Ce fichier a été tranché pour {{slicedFor}}, mais cette imprimante est une {{printerModel}}',
+    dropNotPrintable: 'Seuls les fichiers .gcode et .gcode.3mf peuvent être imprimés',
+    dropToPrint: 'Déposer pour imprimer',
+    cannotPrint: 'Imprimante occupée',
   },
 
   // Archives page
@@ -2229,7 +2249,8 @@ export default {
     willBeExtracted: 'Sera extrait',
     filesExtracted: '{{count}} fichiers extraits',
     uploadComplete: 'Terminé : {{succeeded}} succès',
-    uploadFailed: '{{count}} échecs',
+    uploadFailed: 'Échec du téléversement',
+    zipFilesFailed: '{{count}} fichiers échoués',
     uploading: 'Téléversement...',
     changeLink: 'Modifier lien...',
     linkTo: 'Lier à...',
@@ -2683,7 +2704,6 @@ export default {
     sinceTracking: 'Depuis le début du suivi',
     loadedInAms: 'Chargé dans AMS/Ext',
     remaining: 'Restant',
-    lowStockThreshold: '<20% restant',
     weightCheck: 'Vérification poids',
     lastWeighed: 'Dernière pesée',
     neverWeighed: 'Jamais pesé',
@@ -2732,6 +2752,7 @@ export default {
     clearHistory: 'Effacer',
     historyCleared: 'Historique effacé',
     fillSourceLabel: '(Inv)',
+    lowStockThresholdError: 'Le seuil doit être compris entre 0.1 et 99.9',
   },
 
   // Timelapse

+ 23 - 1
frontend/src/i18n/locales/it.ts

@@ -53,6 +53,8 @@ export default {
     refresh: 'Aggiorna',
     download: 'Scarica',
     upload: 'Carica',
+    uploading: 'Caricamento...',
+    uploadFailed: 'Caricamento fallito',
     actions: 'Azioni',
     status: 'Stato',
     name: 'Nome',
@@ -196,6 +198,7 @@ export default {
     chamberLightOn: 'Accendi luce camera',
     chamberLightOff: 'Spegni luce camera',
     // Files
+    files: 'File',
     browseFiles: 'Sfoglia file stampante',
     // Smart plug
     autoOffAfterPrint: 'Spegnimento automatico dopo stampa',
@@ -211,6 +214,19 @@ export default {
     skipObject: 'Salta Oggetto',
     reconnect: 'Riconnetti',
     mqttDebug: 'Debug MQTT',
+    printerInformation: 'Informazioni stampante',
+    copyToClipboard: 'Copia',
+    copied: 'Copiato!',
+    state: 'Stato',
+    wifiSignalLabel: 'Segnale WiFi',
+    developerMode: 'Modalità sviluppatore',
+    enabled: 'Attivato',
+    disabled: 'Disattivato',
+    addedOn: 'Aggiunta il',
+    sdCard: 'Scheda SD',
+    inserted: 'Inserita',
+    notInserted: 'Non inserita',
+    totalPrintHours: 'Ore di stampa',
     activeNozzle: 'Attivo: ugello {{nozzle}}',
     nozzleRack: 'Rack Ugelli',
     nozzleDocked: 'Agganciato',
@@ -453,6 +469,10 @@ export default {
     },
     developerModeWarning: 'La modalità sviluppatore LAN non è attivata su: {{names}}. Alcune funzionalità potrebbero non funzionare.',
     howToEnable: 'Come attivare',
+    incompatibleFile: 'Questo file è stato preparato per {{slicedFor}}, ma questa stampante è una {{printerModel}}',
+    dropNotPrintable: 'Solo i file .gcode e .gcode.3mf possono essere stampati',
+    dropToPrint: 'Rilascia per stampare',
+    cannotPrint: 'Stampante occupata',
   },
 
   // Archives page
@@ -2046,7 +2066,8 @@ export default {
     willBeExtracted: 'Sara estratto',
     filesExtracted: '{{count}} file estratti',
     uploadComplete: 'Caricamento completato: {{succeeded}} riusciti',
-    uploadFailed: '{{count}} falliti',
+    uploadFailed: 'Caricamento fallito',
+    zipFilesFailed: '{{count}} file falliti',
     uploading: 'Caricamento...',
     changeLink: 'Cambia collegamento...',
     linkTo: 'Collega a...',
@@ -2454,6 +2475,7 @@ export default {
     spoolRestored: 'Bobina ripristinata',
     deleteConfirm: 'Sei sicuro di voler eliminare questa bobina? Questa azione non può essere annullata.',
     advancedSettings: 'Impostazioni Avanzate',
+    lowStockThresholdError: 'La soglia deve essere tra 0.1 e 99.9',
     weightCheck: 'Controllo Peso',
     lastWeighed: 'Ultima pesatura',
     neverWeighed: 'Mai pesato',

+ 22 - 1
frontend/src/i18n/locales/ja.ts

@@ -51,6 +51,8 @@ export default {
     refresh: '更新',
     download: 'ダウンロード',
     upload: 'アップロード',
+    uploading: 'アップロード中...',
+    uploadFailed: 'アップロード失敗',
     actions: '操作',
     status: 'ステータス',
     name: '名前',
@@ -189,6 +191,7 @@ export default {
     maintenanceUpToDate: 'すべてのメンテナンスが最新です',
     chamberLightOn: 'チャンバーライトをオンにしました',
     chamberLightOff: 'チャンバーライトをオフにしました',
+    files: 'ファイル',
     browseFiles: 'プリンターのファイルを参照',
     hmsErrors: 'クリックしてHMSエラーを表示',
     resume: '再開',
@@ -196,6 +199,19 @@ export default {
     stop: '停止',
     reconnect: '再接続',
     mqttDebug: 'MQTTデバッグ',
+    printerInformation: 'プリンター情報',
+    copyToClipboard: 'コピー',
+    copied: 'コピーしました!',
+    state: '状態',
+    wifiSignalLabel: 'WiFi信号',
+    developerMode: '開発者モード',
+    enabled: '有効',
+    disabled: '無効',
+    addedOn: '追加日',
+    sdCard: 'SDカード',
+    inserted: '挿入済み',
+    notInserted: '未挿入',
+    totalPrintHours: '印刷時間',
     activeNozzle: 'アクティブ: {{side}}ノズル',
     nozzleRack: 'ノズルラック',
     nozzleDocked: 'ドッキング中',
@@ -469,6 +485,10 @@ export default {
     clickToViewHmsErrors: 'クリックしてHMSエラーを表示',
     developerModeWarning: '開発者LANモードが有効になっていません: {{names}}。一部の機能が動作しない可能性があります。',
     howToEnable: '有効化方法',
+    incompatibleFile: 'このファイルは{{slicedFor}}用にスライスされていますが、このプリンターは{{printerModel}}です',
+    dropNotPrintable: '.gcodeおよび.gcode.3mfファイルのみ印刷できます',
+    dropToPrint: 'ドロップして印刷',
+    cannotPrint: 'プリンター使用中',
   },
   archives: {
     title: '印刷アーカイブ',
@@ -2172,6 +2192,7 @@ export default {
     filesExtracted: '• {{count}}個のファイルを展開済み',
     uploadComplete: 'アップロード完了: {{count}}個成功',
     uploadFailed: 'アップロード失敗',
+    zipFilesFailed: '{{count}}個のファイルが失敗',
     uploading: 'アップロード中...',
     changeLink: 'リンクを変更...',
     linkTo: 'リンク先...',
@@ -2617,7 +2638,6 @@ export default {
     sinceTracking: '追跡開始以降',
     loadedInAms: 'AMS/Extに装填中',
     remaining: '残り',
-    lowStockThreshold: '残り20%未満',
     weightCheck: '重量チェック',
     lastWeighed: '最終計量',
     neverWeighed: '未計量',
@@ -2668,6 +2688,7 @@ export default {
     clearHistory: 'クリア',
     historyCleared: '使用履歴がクリアされました',
     fillSourceLabel: '(Inv)',
+    lowStockThresholdError: 'しきい値は0.1から99.9の間でなければなりません',
   },
   timelapse: {
     download: 'ダウンロード',

+ 23 - 2
frontend/src/i18n/locales/pt-BR.ts

@@ -54,6 +54,8 @@ export default {
     refresh: 'Atualizar',
     download: 'Baixar',
     upload: 'Enviar',
+    uploading: 'Enviando...',
+    uploadFailed: 'Falha no envio',
     actions: 'Ações',
     status: 'Status',
     name: 'Nome',
@@ -199,6 +201,7 @@ export default {
     chamberLightOn: 'Ligar luz da câmara',
     chamberLightOff: 'Desligar luz da câmara',
     // Files
+    files: 'Arquivos',
     browseFiles: 'Procurar arquivos da impressora',
     // Smart plug
     autoOffAfterPrint: 'Desligamento automático após impressão',
@@ -214,6 +217,19 @@ export default {
     skipObject: 'Ignorar objeto',
     reconnect: 'Reconectar',
     mqttDebug: 'Depuração MQTT',
+    printerInformation: 'Informações da impressora',
+    copyToClipboard: 'Copiar',
+    copied: 'Copiado!',
+    state: 'Estado',
+    wifiSignalLabel: 'Sinal WiFi',
+    developerMode: 'Modo desenvolvedor',
+    enabled: 'Ativado',
+    disabled: 'Desativado',
+    addedOn: 'Adicionada em',
+    sdCard: 'Cartão SD',
+    inserted: 'Inserido',
+    notInserted: 'Não inserido',
+    totalPrintHours: 'Horas de impressão',
     activeNozzle: 'Ativo: {{nozzle}} bico',
     nozzleRack: 'Suporte de bicos',
     nozzleDocked: 'Acoplado',
@@ -462,6 +478,10 @@ export default {
     },
     developerModeWarning: 'O modo desenvolvedor LAN não está ativado em: {{names}}. Alguns recursos podem não funcionar.',
     howToEnable: 'Como ativar',
+    incompatibleFile: 'Este arquivo foi fatiado para {{slicedFor}}, mas esta impressora é uma {{printerModel}}',
+    dropNotPrintable: 'Apenas arquivos .gcode e .gcode.3mf podem ser impressos',
+    dropToPrint: 'Solte para imprimir',
+    cannotPrint: 'Impressora ocupada',
   },
 
   // Archives page
@@ -2241,7 +2261,8 @@ export default {
     willBeExtracted: 'Será extraído',
     filesExtracted: '{{count}} arquivos extraídos',
     uploadComplete: 'Upload concluído: {{succeeded}} bem-sucedidos',
-    uploadFailed: '{{count}} falhou',
+    uploadFailed: 'Falha no envio',
+    zipFilesFailed: '{{count}} arquivos falharam',
     uploading: 'Enviando...',
     changeLink: 'Alterar link...',
     linkTo: 'Vincular a...',
@@ -2695,7 +2716,6 @@ export default {
     sinceTracking: 'Desde o início do rastreamento',
     loadedInAms: 'Carregado no AMS/Ext',
     remaining: 'Restante',
-    lowStockThreshold: '<20% restante',
     weightCheck: 'Verificação de Peso',
     lastWeighed: 'Última pesagem',
     neverWeighed: 'Nunca pesado',
@@ -2744,6 +2764,7 @@ export default {
     clearHistory: 'Limpar',
     historyCleared: 'Histórico de uso limpo',
     fillSourceLabel: '(Inv)',
+    lowStockThresholdError: 'O limite deve estar entre 0.1 e 99.9',
   },
 
   // Timelapse

+ 3662 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -0,0 +1,3662 @@
+export default {
+  // Navigation
+  nav: {
+    printers: '打印机',
+    archives: '归档',
+    queue: '队列',
+    stats: '统计',
+    profiles: '配置文件',
+    maintenance: '维护',
+    projects: '项目',
+    inventory: '耗材',
+    files: '文件管理器',
+    settings: '设置',
+    system: '系统',
+    collapseSidebar: '收起侧边栏',
+    expandSidebar: '展开侧边栏',
+    update: '更新',
+    updateAvailable: '有可用更新:v{{version}}',
+    updateAvailableBanner: '版本 {{version}} 已发布!',
+    viewUpdate: '查看更新',
+    viewOnGithub: '在 GitHub 上查看',
+    keyboardShortcuts: '键盘快捷键 (?)',
+    switchToLight: '切换到浅色模式',
+    switchToDark: '切换到深色模式',
+    smartSwitches: '智能开关',
+    logout: '退出登录',
+  },
+
+  // Common
+  common: {
+    save: '保存',
+    saving: '保存中...',
+    cancel: '取消',
+    delete: '删除',
+    edit: '编辑',
+    add: '添加',
+    close: '关闭',
+    confirm: '确认',
+    loading: '加载中...',
+    error: '错误',
+    success: '成功',
+    warning: '警告',
+    enabled: '已启用',
+    disabled: '已禁用',
+    yes: '是',
+    no: '否',
+    on: '开',
+    off: '关',
+    all: '全部',
+    none: '无',
+    search: '搜索',
+    filter: '筛选',
+    sort: '排序',
+    refresh: '刷新',
+    download: '下载',
+    upload: '上传',
+    uploading: '上传中...',
+    uploadFailed: '上传失败',
+    actions: '操作',
+    status: '状态',
+    name: '名称',
+    description: '描述',
+    date: '日期',
+    time: '时间',
+    hours: '小时',
+    minutes: '分钟',
+    seconds: '秒',
+    days: '天',
+    enable: '启用',
+    disable: '禁用',
+    permissions: '权限',
+    noPrinters: '未配置打印机',
+    noData: '暂无数据',
+    linkNotFound: '未找到链接',
+    required: '必填',
+    optional: '可选',
+    dismiss: '关闭',
+    apply: '应用',
+    reset: '重置',
+    export: '导出',
+    import: '导入',
+    clear: '清除',
+    selectAll: '全选',
+    deselectAll: '取消全选',
+    noChange: '— 不更改 —',
+    unchanged: '未更改',
+    unassigned: '未分配',
+    unknown: '未知',
+    unknownError: '未知错误',
+    today: '今天',
+    tomorrow: '明天',
+    asap: '尽快',
+    overdue: '已逾期',
+    now: '现在',
+    collapse: '收起',
+    expand: '展开',
+    viewArchive: '查看归档',
+    viewInFileManager: '在文件管理器中查看',
+    addedBy: '由 {{username}} 添加',
+    prints: '次打印',
+    more: '还有 {{count}} 个',
+    ascending: '升序',
+    descending: '降序',
+    printer: '打印机',
+    remove: '移除',
+    type: '类型',
+    print: '打印',
+    rename: '重命名',
+    move: '移动',
+    create: '创建',
+    duplicate: '复制',
+    left: '左',
+    right: '右',
+  },
+
+  // Printers page
+  printers: {
+    title: '打印机',
+    addPrinter: '添加打印机',
+    editPrinter: '编辑打印机',
+    deletePrinter: '删除打印机',
+    printerName: '打印机名称',
+    serialNumber: '序列号',
+    ipAddress: 'IP 地址 / 主机名',
+    accessCode: '访问码',
+    model: '型号',
+    nozzleCount: '喷嘴数量',
+    autoArchive: '自动归档',
+    status: {
+      available: '可用',
+      idle: '空闲',
+      printing: '打印中',
+      paused: '已暂停',
+      offline: '离线',
+      error: '错误',
+      finished: '已完成',
+      unknown: '未知',
+    },
+    temperatures: {
+      nozzle: '喷嘴',
+      bed: '热床',
+      chamber: '腔室',
+    },
+    progress: '{{percent}}% 完成',
+    timeRemaining: '剩余 {{time}}',
+    deleteConfirm: '确定要删除"{{name}}"吗?',
+    maintenanceOk: '维护正常',
+    maintenanceWarning: '{{count}} 个警告',
+    maintenanceWarning_plural: '{{count}} 个警告',
+    maintenanceDue: '{{count}} 个到期',
+    maintenanceDue_plural: '{{count}} 个到期',
+    // Sort options
+    sort: {
+      name: '名称',
+      status: '状态',
+      model: '型号',
+      location: '位置',
+      ascending: '升序排列',
+      descending: '降序排列',
+    },
+    // Card size
+    cardSize: {
+      small: '小卡片',
+      medium: '中卡片',
+      large: '大卡片',
+      extraLarge: '超大卡片',
+    },
+    // Controls
+    hideOffline: '隐藏离线',
+    nextAvailable: '下一个可用',
+    powerOn: '开机',
+    offlinePrintersWithPlugs: '带智能插座的离线打印机',
+    noPrintersConfigured: '尚未配置打印机',
+    // Printer card
+    readyToPrint: '准备打印',
+    external: '外部',
+    extL: '外置左',
+    extR: '外置右',
+    deleteArchives: '删除打印归档',
+    noLabel: '无标签',
+    printPreview: '打印预览',
+    width: '宽度',
+    height: '高度',
+    noObjectsFound: '未找到对象',
+    objectsLoadedOnPrintStart: '对象在打印开始时加载',
+    willBeSkipped: '将被跳过',
+    name: '名称',
+    serialCannotBeChanged: '序列号无法更改',
+    locationHelp: '用于分组打印机和筛选队列任务',
+    // WiFi signal strength
+    wifiSignal: {
+      veryWeak: '非常弱',
+      weak: '弱',
+      fair: '一般',
+      good: '良好',
+      excellent: '优秀',
+    },
+    // Maintenance
+    maintenanceUpToDate: '所有维护均已完成 - 点击查看',
+    // Chamber light
+    chamberLightOn: '打开腔室灯',
+    chamberLightOff: '关闭腔室灯',
+    // Files
+    browseFiles: '浏览打印机文件',
+    // Smart plug
+    autoOffAfterPrint: '打印后自动关机',
+    autoOffExecuted: '已执行自动关机 - 开启打印机以重置',
+    // HMS errors
+    hmsErrors: 'HMS 错误',
+    viewHmsErrors: '查看 {{count}} 个 HMS 错误',
+    // Actions
+    resume: '继续',
+    pause: '暂停',
+    stop: '停止',
+    camera: '摄像头',
+    skipObject: '跳过对象',
+    reconnect: '重新连接',
+    mqttDebug: 'MQTT 调试',
+    activeNozzle: '当前:{{nozzle}} 喷嘴',
+    nozzleRack: '喷嘴架',
+    nozzleDocked: '已停靠',
+    nozzleMounted: '已安装',
+    nozzleActive: '使用中',
+    nozzleIdle: '空闲',
+    nozzleDiameter: '直径',
+    nozzleType: '类型',
+    nozzleStatus: '状态',
+    nozzleFilament: '耗材',
+    nozzleWear: '磨损',
+    nozzleMaxTemp: '最高温度',
+    nozzleSerial: '序列号',
+    nozzleHardenedSteel: '硬化钢',
+    nozzleStainlessSteel: '不锈钢',
+    nozzleTungstenCarbide: '碳化钨',
+    nozzleFlow: '流量',
+    nozzleHighFlow: '高流量',
+    nozzleStandardFlow: '标准',
+    // Firmware
+    firmwareUpdate: '固件更新',
+    firmwareInstructions: '在打印机触摸屏上,前往',
+    firmwareNav: '导航到',
+    settings: '设置',
+    firmware: '固件',
+    // Discovery
+    discoverPrinters: '发现打印机',
+    searching: '搜索中...',
+    manualEntry: '手动输入',
+    addFromCloud: '从云端添加',
+    // Toast messages
+    toast: {
+      printerDeleted: '打印机已删除',
+      printerAdded: '打印机已添加',
+      printerUpdated: '打印机已更新',
+      failedToDelete: '删除打印机失败',
+      failedToAdd: '添加打印机失败',
+      failedToUpdate: '更新打印机失败',
+      commandSent: '命令已发送',
+      failedToSendCommand: '发送命令失败',
+      turnedOn: '{{name}} 已开启',
+      failedToPowerOn: '开启 {{name}} 失败',
+      scriptTriggered: '脚本已触发',
+      printStopped: '打印已停止',
+      printPaused: '打印已暂停',
+      printResumed: '打印已继续',
+      referenceDeleted: '参考已删除',
+      detectionAreaSaved: '检测区域已保存',
+      failedToRunScript: '运行脚本失败',
+      failedToStopPrint: '停止打印失败',
+      failedToPausePrint: '暂停打印失败',
+      failedToResumePrint: '继续打印失败',
+      failedToControlChamberLight: '控制腔室灯失败',
+      failedToUpdateSetting: '更新设置失败',
+      failedToSkipObjects: '跳过对象失败',
+      failedToRereadRfid: '重新读取 RFID 失败',
+      failedToCheckPlate: '检查打印板失败',
+      failedToUpdateLabel: '更新标签失败',
+      failedToDeleteReference: '删除参考失败',
+      failedToSaveDetectionArea: '保存检测区域失败',
+      plateCheckEnabled: '打印板检查已启用',
+      plateCheckDisabled: '打印板检查已禁用',
+      calibrationSaved: '校准已保存!',
+      calibrationFailed: '校准失败',
+      rfidRereadInitiated: '已发起 RFID 重新读取',
+    },
+    // Connection status
+    connection: {
+      connected: '已连接',
+      offline: '离线',
+    },
+    // Queue info
+    queue: {
+      inQueue: '队列中有 {{count}} 个打印任务',
+      inQueue_plural: '队列中有 {{count}} 个打印任务',
+    },
+    // Controls section
+    controls: '控制',
+    // RFID
+    rfid: {
+      reread: '重新读取 RFID',
+    },
+    // Permissions
+    permission: {
+      noAdd: '您没有添加打印机的权限',
+      noEdit: '您没有编辑打印机的权限',
+      noDelete: '您没有删除打印机的权限',
+      noControl: '您没有控制打印机的权限',
+      noFiles: '您没有访问打印机文件的权限',
+      noAmsRfid: '您没有重新读取 AMS RFID 的权限',
+      noSmartPlugControl: '您没有控制智能插座的权限',
+      noCamera: '您没有查看摄像头的权限',
+    },
+    // Add/Edit modal
+    modal: {
+      addTitle: '添加打印机',
+      editTitle: '编辑打印机',
+      myPrinter: '我的打印机',
+      selectModel: '选择型号...',
+      locationGroup: '位置 / 分组(可选)',
+      locationPlaceholder: '例如:工作室、办公室、地下室',
+      autoArchiveLabel: '自动归档已完成的打印',
+      fromPrinterSettings: '来自打印机设置',
+      modelOptional: '型号(可选)',
+      saveChanges: '保存更改',
+    },
+    // Skip objects
+    skipObjects: {
+      tooltip: '跳过对象',
+      onlyWhilePrinting: '跳过对象(仅在打印时)',
+      requiresMultiple: '跳过对象(需要2个以上对象)',
+      title: '跳过对象',
+      matchIdsInfo: '将 ID 与打印机显示屏上的 ID 进行对照',
+      printerShowsIds: '打印机屏幕上显示构建板上对象的 ID',
+      skipSelected: '跳过所选',
+      skipping: '跳过中...',
+      noObjectsSelected: '未选择对象',
+      selectObjectsToSkip: '选择要从当前打印中跳过的对象',
+      skipped: '已跳过',
+      objectsSkipped: '对象已跳过',
+      activeCount: '{{count}} 个活跃',
+      waitForLayer: '等待第2层以上才能跳过对象(当前第 {{layer}} 层)',
+      skip: '跳过',
+      confirmTitle: '跳过对象?',
+      confirmMessage: '确定要跳过"{{name}}"吗?此操作无法撤销。',
+    },
+    // Confirm modals
+    confirm: {
+      deleteTitle: '删除打印机',
+      deleteMessage: '确定要删除"{{name}}"吗?这将移除所有连接设置。',
+      deleteArchivesNote: '此打印机的所有打印历史将被永久删除。',
+      keepArchivesNote: '打印历史将保留,但不再与此打印机关联。',
+      stopTitle: '停止打印',
+      stopMessage: '确定要停止"{{name}}"上的当前打印吗?这将取消打印任务。',
+      stopButton: '停止打印',
+      pauseTitle: '暂停打印',
+      pauseMessage: '确定要暂停"{{name}}"上的当前打印吗?',
+      pauseButton: '暂停打印',
+      resumeTitle: '继续打印',
+      resumeMessage: '确定要继续"{{name}}"上的打印吗?',
+      resumeButton: '继续打印',
+      powerOnTitle: '开启打印机',
+      powerOnMessage: '确定要打开"{{name}}"的电源吗?',
+      powerOnButton: '开机',
+      powerOffTitle: '关闭打印机',
+      powerOffMessage: '确定要关闭"{{name}}"的电源吗?',
+      powerOffWarning: '警告:"{{name}}"正在打印中!确定要关闭电源吗?这将中断打印并可能损坏打印机。',
+      powerOffButton: '关机',
+    },
+    // Discovery
+    discovery: {
+      title: '发现打印机',
+      searching: '搜索中...',
+      scanning: '扫描中...',
+      scanProgress: '扫描中... {{scanned}}/{{total}}',
+      foundPrinters: '发现 {{count}} 台打印机',
+      noPrintersFound: '未找到打印机',
+      noPrintersFoundSubnet: '在指定子网中未找到打印机。',
+      noPrintersFoundNetwork: '在网络上未找到打印机。',
+      allConfigured: '所有发现的打印机已配置完毕。',
+      alreadyAdded: '已添加',
+      select: '选择',
+      manualEntry: '手动输入',
+      addFromCloud: '从云端添加',
+      subnetToScan: '要扫描的子网',
+      dockerNote: '检测到 Docker 环境。请以 CIDR 格式输入打印机所在子网。需要在 docker-compose.yml 中设置 network_mode: host。',
+      scanSubnet: '扫描子网查找打印机',
+      discoverNetwork: '在网络上发现打印机',
+      scanningSubnet: '正在扫描子网查找拓竹打印机...',
+      scanningNetwork: '正在扫描网络...',
+      serialRequired: '需要序列号',
+      unknown: '未知',
+      failedToStart: '启动发现失败',
+    },
+    // Filaments section
+    filaments: '耗材',
+    // Camera
+    openCameraOverlay: '打开摄像头叠加层',
+    openCameraWindow: '在新窗口中打开摄像头',
+    // Firmware
+    firmwareUpdateAvailable: '固件更新可用:{{current}} → {{latest}}',
+    firmwareUpToDate: '固件 {{version}} — 已是最新',
+    firmwareUpdateButton: '更新',
+    // Plate detection
+    plateDetection: {
+      noPermission: '您没有更新打印机的权限',
+      enabledClick: '打印板检查已启用 - 点击禁用',
+      disabledClick: '打印板检查已禁用 - 点击启用',
+      manageCalibration: '管理打印板检测校准',
+      calibrationRequired: '需要校准',
+      calibrationInstructions: '请确保构建板<strong>完全空置</strong>,然后点击校准。',
+      calibrationDescription: '校准会拍摄空置打印板的参考图像。后续检查将与此参考进行比较以检测物体。',
+      calibrationTip: '<strong>提示:</strong>您最多可以为不同的打印板存储5个校准。系统会在检查时自动使用最佳匹配。',
+      plateEmpty: '打印板似乎是空的',
+      objectsDetected: '在打印板上检测到物体',
+      confidence: '置信度',
+      difference: '差异',
+      analysisPreview: '分析预览:',
+      analysisLegend: '绿色框 = 检测区域,红色覆盖 = 与校准的差异',
+      savedReferences: '已保存的参考 ({{count}}/{{max}})',
+      deleteReference: '删除参考',
+      labelPlaceholder: '标签...',
+      clickToEdit: '{{label}} - 点击编辑',
+      clickToAddLabel: '点击添加标签',
+    },
+    // Fans
+    fans: {
+      partCooling: '零件冷却风扇',
+      auxiliary: '辅助风扇',
+      chamber: '腔室风扇',
+    },
+    // HMS errors
+    clickToViewHmsErrors: '点击查看 HMS 错误',
+    estimatedCompletion: '预计完成时间',
+    slotOptions: '槽位选项',
+    // Firmware modal
+    firmwareModal: {
+      title: '固件更新',
+      titleUpToDate: '固件信息',
+      currentVersion: '当前版本:',
+      latestVersion: '最新版本:',
+      releaseNotes: '发布说明',
+      checkingPrereqs: '正在检查前提条件...',
+      sdCardReady: 'SD 卡已就绪。点击下方上传固件。',
+      uploadedSuccess: '固件已上传到 SD 卡!',
+      applyInstructions: '在打印机上应用更新:',
+      step1: '在打印机触摸屏上,前往<strong>设置</strong>',
+      step2: '导航到<strong>固件</strong>',
+      step3: '选择<strong>从 SD 卡更新</strong>',
+      step4: '更新将需要 10-20 分钟',
+      done: '完成',
+      starting: '启动中...',
+      uploadFirmware: '上传固件',
+      uploadFailed: '上传启动失败:{{error}}',
+      uploadedToast: '固件已上传!请在打印机屏幕上触发更新。',
+    },
+    accessCodePlaceholder: '留空以保持当前值',
+    // ROI editor
+    roi: {
+      title: '检测区域 (ROI)',
+      xStart: 'X 起点',
+      yStart: 'Y 起点',
+      width: '宽度',
+      height: '高度',
+      instruction: '调整检测区域以聚焦到构建板。预览中的绿色框显示当前区域。',
+    },
+    developerModeWarning: '以下打印机未启用开发者局域网模式:{{names}}。某些功能可能无法使用。',
+    howToEnable: '如何启用',
+    dropToPrint: '拖放以打印',
+    cannotPrint: '打印机忙碌',
+  },
+
+  // Archives page
+  archives: {
+    title: '打印归档',
+    searchPlaceholder: '搜索归档...',
+    filterByPrinter: '按打印机筛选',
+    filterByStatus: '按状态筛选',
+    sortBy: '排序方式',
+    sortNewest: '最新优先',
+    sortOldest: '最旧优先',
+    sortName: '名称',
+    sortDuration: '时长',
+    sortLargest: '最大优先',
+    sortSmallest: '最小优先',
+    sortSize: '大小',
+    noArchives: '未找到归档',
+    noArchivesSearch: '没有匹配搜索的归档',
+    noArchivesYet: '暂无归档',
+    loadingArchives: '加载归档中...',
+    releaseToUpload: '释放以上传',
+    showAll: '显示全部',
+    showFavoritesOnly: '仅显示收藏',
+    gridView: '网格视图',
+    listView: '列表视图',
+    calendarView: '日历视图',
+    logView: '打印日志',
+    manageTags: '管理标签',
+    showFailedPrints: '显示失败的打印',
+    hideFailedPrints: '隐藏失败的打印',
+    printTime: '打印时间',
+    filamentUsed: '耗材用量',
+    cost: '成本',
+    reprint: '重新打印',
+    preview: '预览',
+    deleteArchive: '删除归档',
+    deleteConfirm: '确定要删除此归档吗?',
+    favorite: '收藏',
+    unfavorite: '取消收藏',
+    viewDetails: '查看详情',
+    status: {
+      completed: '已完成',
+      failed: '失败',
+      stopped: '已停止',
+    },
+    toast: {
+      source3mfAttached: '源 3MF 已附加:{{filename}}',
+      failedUploadSource3mf: '上传源 3MF 失败',
+      source3mfRemoved: '源 3MF 已移除',
+      failedRemoveSource3mf: '移除源 3MF 失败',
+      f3dAttached: 'F3D 已附加:{{filename}}',
+      failedUploadF3d: '上传 F3D 失败',
+      f3dRemoved: 'F3D 已移除',
+      failedRemoveF3d: '移除 F3D 失败',
+      timelapseAttached: '延时摄影已附加:{{filename}}',
+      timelapseAlreadyAttached: '延时摄影已附加',
+      noMatchingTimelapse: '未找到匹配的延时摄影',
+      failedScanTimelapse: '扫描延时摄影失败',
+      failedAttachTimelapse: '附加延时摄影失败',
+      timelapseRemoved: '延时摄影已移除',
+      failedRemoveTimelapse: '移除延时摄影失败',
+      timelapseUploaded: '延时摄影已上传:{{filename}}',
+      failedUploadTimelapse: '上传延时摄影失败',
+      archiveDeleted: '归档已删除',
+      failedDeleteArchive: '删除归档失败',
+      addedToFavorites: '已添加到收藏',
+      removedFromFavorites: '已从收藏中移除',
+      projectUpdated: '项目已更新',
+      failedUpdateProject: '更新项目失败',
+      linkCopied: '链接已复制到剪贴板',
+      failedCopyLink: '复制链接失败',
+      photoDeleted: '照片已删除',
+      failedDeletePhoto: '删除照片失败',
+      failedDeleteArchives: '删除归档失败',
+      failedUpdateFavorites: '更新收藏失败',
+      exportDownloaded: '导出已下载',
+      exportFailed: '导出失败',
+    },
+    menu: {
+      print: '打印',
+      schedule: '排程',
+      openInBambuStudio: '在切片软件中打开',
+      slice: '切片',
+      externalLink: '外部链接',
+      viewOnMakerWorld: '在 MakerWorld 上查看',
+      preview3d: '3D 预览',
+      viewTimelapse: '查看延时摄影',
+      scanForTimelapse: '扫描延时摄影',
+      uploadTimelapse: '上传延时摄影',
+      removeTimelapse: '移除延时摄影',
+      downloadSource3mf: '下载源 3MF',
+      uploadSource3mf: '上传源 3MF',
+      replaceSource3mf: '替换源 3MF',
+      removeSource3mf: '移除源 3MF',
+      uploadF3d: '上传 F3D',
+      replaceF3d: '替换 F3D',
+      downloadF3d: '下载 F3D',
+      removeF3d: '移除 F3D',
+      download: '下载',
+      copyDownloadLink: '复制下载链接',
+      qrCode: '二维码',
+      viewPhotos: '查看照片',
+      viewPhotosCount: '查看照片 ({{count}})',
+      projectPage: '项目页面',
+      addToFavorites: '添加到收藏',
+      removeFromFavorites: '从收藏中移除',
+      edit: '编辑',
+      goToProject: '前往项目:{{name}}',
+      addToProject: '添加到项目',
+      removeFromProject: '从项目中移除',
+      loading: '加载中...',
+      noProjectsAvailable: '无可用项目',
+      select: '选择',
+      deselect: '取消选择',
+      delete: '删除',
+    },
+    permission: {
+      noReprint: '您没有重新打印此归档的权限',
+      noAddToQueue: '您没有添加到队列的权限',
+      noUpdateArchives: '您没有更新归档的权限',
+      noUploadFiles: '您没有上传文件的权限',
+      noDownload: '您没有下载归档的权限',
+      noCopyLink: '您没有复制下载链接的权限',
+      noDelete: '您没有删除此归档的权限',
+      noCreate: '您没有创建归档的权限',
+    },
+    card: {
+      previousPlate: '上一个板',
+      nextPlate: '下一个板',
+      plateNumber: '板 {{index}}',
+      moreOptions: '右键查看更多选项',
+      addToFavorites: '添加到收藏',
+      removeFromFavorites: '从收藏中移除',
+      cancelled: '已取消',
+      failed: '失败',
+      duplicate: '重复',
+      duplicateTitle: '此模型之前已打印过',
+      openSource3mf: '在 Bambu Studio 中打开源 3MF(右键查看更多选项)',
+      downloadF3d: '下载 Fusion 360 设计文件',
+      viewTimelapse: '查看延时摄影',
+      viewPhoto: '查看 1 张照片',
+      viewPhotos: '查看 {{count}} 张照片',
+      openFolder: '打开文件夹:{{name}}',
+      slicedFile: '已切片文件 - 可以打印',
+      sourceFile: '仅源文件 - 无 AMS 映射可用',
+      gcode: 'GCODE',
+      source: '源文件',
+      project: '项目:{{name}}',
+      estimated: '预计:{{time}}',
+      actual: '实际:{{time}}',
+      accuracy: '准确度:{{percent}}%',
+      filament: '{{weight}}g',
+      layer: '{{count}} 层',
+      layers: '{{count}} 层',
+      object: '{{count}} 个对象',
+      objects: '{{count}} 个对象',
+      slicedFor: '为 {{model}} 切片',
+      uploadedBy: '上传者',
+      noPermissionReprint: '您没有重新打印的权限',
+      noFileForReprint: '无可用的 3MF 文件 — 打印记录时无法从打印机下载该文件',
+      noPermissionEdit: '您没有编辑归档的权限',
+      noPermissionDelete: '您没有删除归档的权限',
+      reprint: '重新打印',
+      schedulePrint: '排程打印',
+      schedule: '排程',
+      openInBambuStudio: '在切片软件中打开',
+      openInBambuStudioToSlice: '在切片软件中打开进行切片',
+      slice: '切片',
+      externalLink: '外部链接',
+      makerWorld: 'MakerWorld:{{designer}}',
+      viewProject: '查看项目',
+      noExternalLink: '无外部链接',
+      preview3d: '3D 预览',
+      download: '下载',
+      edit: '编辑',
+      delete: '删除',
+    },
+    modal: {
+      deleteArchive: '删除归档',
+      deleteConfirm: '确定要删除"{{name}}"吗?此操作无法撤销。',
+      deleteButton: '删除',
+      removeSource3mf: '移除源 3MF',
+      removeSource3mfConfirm: '确定要从"{{name}}"中移除源 3MF 文件吗?这将删除原始切片项目文件。',
+      removeButton: '移除',
+      removeF3d: '移除 F3D',
+      removeF3dConfirm: '确定要从"{{name}}"中移除 Fusion 360 设计文件吗?',
+      removeTimelapse: '移除延时摄影',
+      removeTimelapseConfirm: '确定要从"{{name}}"中移除延时摄影视频吗?',
+      timelapse: '{{name}} - 延时摄影',
+      selectTimelapse: '选择延时摄影',
+      selectTimelapseDesc: '未找到自动匹配。请选择此打印的延时摄影:',
+      deleteArchives: '删除归档',
+      deleteArchivesConfirm: '确定要删除 {{count}} 个归档吗?此操作无法撤销。',
+      deleteCount: '删除 {{count}} 个',
+    },
+    page: {
+      title: '归档',
+      printsCount: '{{filtered}} / {{total}} 次打印',
+      dropFilesHere: '将 .3mf 文件拖放到此处',
+      releaseToUpload: '释放以上传',
+      only3mfSupported: '仅支持 .3mf 文件',
+      close: '关闭',
+      selected: '已选择 {{count}} 个',
+      selectAll: '全选',
+      tags: '标签',
+      project: '项目',
+      favorite: '收藏',
+      delete: '删除',
+      toggledFavorites: '已切换 {{count}} 个归档的收藏状态',
+      failedUpdateFavorites: '更新收藏失败',
+      archivesDeleted: '已删除 {{count}} 个归档',
+      failedDeleteArchives: '删除归档失败',
+      photoDeleted: '照片已删除',
+      failedDeletePhoto: '删除照片失败',
+    },
+    list: {
+      name: '名称',
+      printer: '打印机',
+      date: '日期',
+      size: '大小',
+      actions: '操作',
+      hasTimelapse: '有延时摄影',
+    },
+    log: {
+      date: '日期',
+      printName: '打印名称',
+      printer: '打印机',
+      user: '用户',
+      status: '状态',
+      duration: '时长',
+      filament: '耗材',
+      allPrinters: '所有打印机',
+      allUsers: '所有用户',
+      allStatuses: '所有状态',
+      cancelled: '已取消',
+      skipped: '已跳过',
+      dateFrom: '从',
+      dateTo: '到',
+      noEntries: '未找到打印日志条目',
+      showing: '显示 {{count}} / {{total}} 条',
+      rowsPerPage: '行数',
+      page: '页',
+      prev: '上一页',
+      next: '下一页',
+      clearLog: '清除日志',
+      clearLogTitle: '清除打印日志',
+      clearLogConfirm: '所有打印日志条目将被永久删除。归档和队列项目不受影响。此操作无法撤销。确定要继续吗?',
+      clearLogButton: '全部清除',
+      cleared: '已清除 {{count}} 条日志',
+      clearFailed: '清除打印日志失败',
+    },
+  },
+
+  // Queue page
+  queue: {
+    title: '打印队列',
+    subtitle: '排程和管理您的打印任务',
+    addToQueue: '添加到队列',
+    print: '打印',
+    reprint: '重新打印',
+    schedulePrint: '排程打印',
+    editQueueItem: '编辑队列项目',
+    printToPrinters: '打印到 {{count}} 台打印机',
+    queueToPrinters: '排队到 {{count}} 台打印机',
+    sending: '发送中...',
+    sendingProgress: '发送中 {{current}}/{{total}}...',
+    adding: '添加中...',
+    addingProgress: '添加中 {{current}}/{{total}}...',
+    savingProgress: '保存中 {{current}}/{{total}}...',
+    clearQueue: '清空队列',
+    clearHistory: '清除历史',
+    emptyQueue: '队列为空',
+    position: '位置',
+    scheduledTime: '排程时间',
+    moveUp: '上移',
+    moveDown: '下移',
+    startNow: '立即开始',
+    printingInProgress: '打印进行中...',
+    viewArchive: '查看归档',
+    viewInFileManager: '在文件管理器中查看',
+    itemCount: '{{count}} 个项目',
+    itemCount_plural: '{{count}} 个项目',
+    dragToReorder: '拖动以重新排序(仅限尽快)',
+    reorderHint: '位置仅影响"尽快"项目。排程项目按设定时间运行。',
+    addedBy: '由 {{name}} 添加',
+    nextInQueue: '队列中的下一个',
+    clearPlate: '清理打印板并开始下一个',
+    clearPlateSuccess: '打印板已清理 — 准备进行下一个打印',
+    plateReady: '打印板已清理 — 准备进行下一个打印',
+    plateNumber: '板 {{index}}',
+    sections: {
+      currentlyPrinting: '正在打印',
+      queued: '排队中',
+      history: '历史',
+    },
+    status: {
+      pending: '等待中',
+      waiting: '等待中',
+      printing: '打印中',
+      paused: '已暂停',
+      completed: '已完成',
+      failed: '失败',
+      skipped: '已跳过',
+      cancelled: '已取消',
+    },
+    summary: {
+      printing: '打印中',
+      queued: '排队中',
+      totalTime: '总队列时间',
+      totalWeight: '总队列重量',
+      history: '历史',
+    },
+    filter: {
+      allPrinters: '所有打印机',
+      unassigned: '未分配',
+      allStatus: '所有状态',
+      allLocations: '所有位置',
+      any: '任意',
+    },
+    sort: {
+      byPosition: '按位置排序',
+      byName: '按名称排序',
+      byPrinter: '按打印机排序',
+      bySchedule: '按排程排序',
+      byDate: '按日期排序',
+      ascendingOldest: '升序(最旧优先)',
+      descendingNewest: '降序(最新优先)',
+    },
+    badges: {
+      staged: '已暂存',
+      requiresPrevious: '需要前一个成功',
+      autoPowerOff: '自动关机',
+    },
+    empty: {
+      title: '没有排程的打印',
+      description: '从归档页面使用右键菜单中的"排程"选项来排程打印,或拖放文件开始。',
+    },
+    time: {
+      asap: '尽快',
+      overdue: '已逾期',
+      now: '现在',
+      lessThanMinute: '不到一分钟',
+      inMinutes: '{{count}} 分钟后',
+      inHours: '{{count}} 小时后',
+    },
+    actions: {
+      stopPrint: '停止打印',
+      startPrint: '开始打印',
+      requeue: '重新排队',
+    },
+    bulkEdit: {
+      title: '编辑 {{count}} 个项目',
+      title_plural: '编辑 {{count}} 个项目',
+      description: '仅更改的设置将应用于所选项目。',
+      printer: '打印机',
+      noChange: '— 不更改 —',
+      queueOptions: '队列选项',
+      staged: '暂存(手动开始)',
+      autoPowerOff: '打印后自动关机',
+      requirePrevious: '要求前一个成功',
+      printOptions: '打印选项',
+      bedLevelling: '热床调平',
+      flowCalibration: '流量校准',
+      vibrationCalibration: '振动校准',
+      layerInspection: '首层检查',
+      timelapse: '延时摄影',
+      useAms: '使用 AMS',
+      applyChanges: '应用更改',
+      selectAll: '全选',
+      deselectAll: '取消全选',
+      selected: '已选择 {{count}} 个',
+      editSelected: '编辑所选',
+      cancelSelected: '取消所选',
+    },
+    confirm: {
+      cancelTitle: '取消排程打印',
+      cancelMessage: '确定要取消"{{name}}"吗?',
+      stopTitle: '停止打印',
+      stopMessage: '确定要停止当前打印"{{name}}"吗?这将取消打印机上的打印任务。',
+      removeTitle: '从历史中移除',
+      removeMessage: '确定要从队列历史中移除"{{name}}"吗?',
+      clearHistoryTitle: '清除历史',
+      clearHistoryMessage: '确定要从历史中移除所有 {{count}} 个项目吗?',
+      cancelButton: '取消打印',
+      stopButton: '停止打印',
+      thisPrint: '此打印',
+      thisItem: '此项目',
+    },
+    toast: {
+      cancelled: '队列项目已取消',
+      cancelFailed: '取消项目失败',
+      removed: '队列项目已移除',
+      removeFailed: '移除项目失败',
+      stopped: '打印已停止',
+      stopFailed: '停止打印失败',
+      released: '打印已释放到队列',
+      startFailed: '开始打印失败',
+      reorderFailed: '重新排序队列失败',
+      historyCleared: '已清除 {{count}} 条历史记录',
+      clearHistoryFailed: '清除历史失败',
+      updateFailed: '更新项目失败',
+      bulkCancelled: '已取消 {{count}} 个项目',
+      bulkCancelFailed: '批量取消项目失败',
+    },
+    permissions: {
+      noStopPrint: '您没有停止打印的权限',
+      noStartPrint: '您没有开始打印的权限',
+      noEdit: '您没有编辑此队列项目的权限',
+      noCancel: '您没有取消此队列项目的权限',
+      noRequeue: '您没有重新排队的权限',
+      noRemove: '您没有移除此队列项目的权限',
+      noClearHistory: '您没有清除所有历史的权限',
+      noEditItems: '您没有编辑队列项目的权限',
+      noCancelItems: '您没有取消队列项目的权限',
+    },
+  },
+
+  backgroundDispatch: {
+    unknownFile: '未知文件',
+    unknownPrinter: '未知打印机',
+    startingPrints: '正在开始打印',
+    progressSummary: '{{complete}}/{{total}} 完成 • 已分发:{{dispatched}} • 处理中:{{processing}}',
+    expandDetails: '展开分发详情',
+    collapseDetails: '收起分发详情',
+    dismissToast: '关闭分发通知',
+    cancelDispatchJob: '取消分发任务',
+    cancel: '取消',
+    cancelling: '取消中…',
+    status: {
+      dispatched: '已分发',
+      processing: '处理中',
+      completed: '已完成',
+      failed: '失败',
+      cancelled: '已取消',
+    },
+    toast: {
+      cancellingUpload: '取消上传中...',
+      cancelled: '分发已取消',
+      cancelFailed: '取消分发失败',
+      completeWithFailures: '后台分发完成:{{completed}} 成功,{{failed}} 失败',
+      completeSuccess: '后台分发完成:{{completed}} 成功',
+    },
+  },
+
+  // Statistics page
+  stats: {
+    title: '仪表板',
+    subtitle: '拖动小部件以重新排列。点击眼睛图标隐藏。',
+    overview: '概览',
+    totalPrints: '总打印次数',
+    successRate: '成功率',
+    totalPrintTime: '总打印时间',
+    printTime: '打印时间',
+    totalFilament: '总耗材用量',
+    filamentUsed: '耗材用量',
+    filamentCost: '耗材成本',
+    totalCost: '总成本',
+    energyUsed: '能耗',
+    energyCost: '能源成本',
+    averagePrintTime: '平均打印时间',
+    printsPerDay: '每日打印次数',
+    byPrinter: '按打印机',
+    printsByPrinter: '各打印机打印次数',
+    byMaterial: '按材料',
+    byMonth: '按月份',
+    last7Days: '最近 7 天',
+    last30Days: '最近 30 天',
+    last90Days: '最近 90 天',
+    allTime: '全部时间',
+    quickStats: '快速统计',
+    printActivity: '打印活动',
+    filamentTypes: '耗材类型',
+    filamentTrends: '耗材趋势',
+    failureAnalysis: '失败分析',
+    timeAccuracy: '时间准确度',
+    successful: '成功:',
+    failed: '失败:',
+    perfectEstimate: '100% = 完美估计',
+    noTimeAccuracyData: '暂无时间准确度数据',
+    noFilamentData: '暂无耗材数据',
+    noPrinterData: '暂无打印机数据',
+    noPrintData: '暂无打印数据',
+    noPrintDataLast30Days: '最近 30 天无打印数据',
+    failureReasons: '失败原因',
+    topFailureReasons: '主要失败原因',
+    failedPrintsCount: '{{failed}} / {{total}} 次打印失败',
+    lastWeekRate: '上周:{{rate}}%',
+    resetLayout: '重置布局',
+    recalculateCosts: '重新计算成本',
+    recalculateCostsHint: '使用当前耗材价格重新计算所有归档成本',
+    exportStats: '导出统计',
+    exportAsCsv: '导出为 CSV',
+    exportAsExcel: '导出为 Excel',
+    hiddenCount: '{{count}} 个已隐藏',
+    exportDownloaded: '导出已下载',
+    exportFailed: '导出失败',
+    layoutReset: '布局已重置',
+    recalculatedCosts: '已为 {{count}} 个归档重新计算成本',
+    recalculateFailed: '重新计算成本失败',
+    loadingStats: '加载统计数据中...',
+    noPermissionResetLayout: '您没有重置布局的权限',
+    noPermissionRecalculate: '您没有重新计算成本的权限',
+    noPrintDataInRange: '所选范围内无打印数据',
+    periodFilament: '期间耗材',
+    periodCost: '期间成本',
+    avgPerPrint: '每次打印平均',
+    usageOverTime: '随时间的使用量',
+    filamentByWeight: '重量',
+    printDuration: '打印时长',
+    printerUtilization: '打印机利用率',
+    filamentSuccess: '按材料成功率',
+    printHabits: '打印习惯',
+    printTimeOfDay: '打印时段',
+    colorDistribution: '颜色分布',
+    noColorData: '暂无颜色数据',
+    records: '记录',
+    longestPrint: '最长打印',
+    heaviestPrint: '最重打印',
+    mostExpensivePrint: '最贵打印',
+    busiestDay: '最忙碌的一天',
+    successStreak: '连续成功',
+    streakPrint: '连续打印',
+    streakPrints: '{{count}} 次连续打印',
+    printerStats: '打印机统计',
+    hours: '小时',
+    avgPrints: '平均打印',
+    noArchiveData: '暂无打印数据',
+    filamentByTime: '时间',
+    avgWeight: '平均重量',
+    avgTime: '平均时间',
+    filamentByPrints: '打印次数',
+    timeframe: {
+      'today': '今天',
+      'this-week': '本周',
+      'this-month': '本月',
+      'last-7': '最近 7 天',
+      'last-30': '最近 30 天',
+      'last-90': '最近 90 天',
+      'this-year': '今年',
+      'all-time': '全部时间',
+      'custom': '自定义范围',
+      from: '从',
+      to: '到',
+    },
+  },
+
+  // Maintenance page
+  maintenance: {
+    title: '维护',
+    overview: '概览',
+    allOk: '所有维护均已完成',
+    dueCount: '{{count}} 项到期',
+    dueCount_plural: '{{count}} 项到期',
+    warningCount: '{{count}} 个警告',
+    warningCount_plural: '{{count}} 个警告',
+    totalPrintTime: '总打印时间',
+    nextMaintenance: '下次维护',
+    nothingDue: '无到期项目',
+    tasks: '任务',
+    lastPerformed: '上次执行',
+    interval: '间隔',
+    hoursRemaining: '剩余 {{hours}} 小时',
+    hoursOverdue: '逾期 {{hours}} 小时',
+    markDone: '标记为完成',
+    performMaintenance: '执行维护',
+    history: '历史',
+    noHistory: '无维护历史',
+    editPrintHours: '编辑打印时间',
+    currentHours: '当前小时数',
+    statusTab: '状态',
+    settingsTab: '设置',
+    overdueCount: '{{count}} 个逾期',
+    dueSoonCount: '{{count}} 个即将到期',
+    dueSoon: '即将到期',
+    allGood: '一切正常',
+    overdueBy: '逾期 {{duration}}',
+    dueIn: '{{duration}} 后到期',
+    timeLeft: '剩余 {{duration}}',
+    day: '1 天',
+    days: '{{count}} 天',
+    week: '1 周',
+    weeks: '{{count}} 周',
+    month: '1 个月',
+    months: '{{count}} 个月',
+    year: '1 年',
+    maintenanceTypes: '维护类型',
+    maintenanceTypesDescription: '系统类型和您的自定义维护任务',
+    addCustomType: '添加自定义类型',
+    restoreDefaults: '恢复默认任务',
+    intervalType: '间隔类型',
+    intervalValue: '间隔 ({{type}})',
+    icon: '图标',
+    documentationLink: '文档链接(可选)',
+    assignToPrinters: '分配给打印机',
+    selectAtLeastOnePrinter: '至少选择一台打印机',
+    addType: '添加类型',
+    custom: '自定义',
+    printHours: '打印小时数',
+    calendarDays: '日历天数',
+    exampleName: '例如:更换 HEPA 过滤器',
+    viewDocumentation: '查看文档',
+    timeBasedInterval: '基于时间的间隔',
+    intervalOverrides: '间隔覆盖',
+    intervalOverridesDescription: '为特定打印机自定义间隔',
+    assignedToPrinters: '已分配给打印机:',
+    noPrintersAssigned: '未分配打印机',
+    addPrinterShort: '添加:',
+    printersAssignedClick: '已分配 {{count}} 台打印机 - 点击管理',
+    removeFromPrinter: '从此打印机移除',
+    types: {
+      lubricateCarbonRods: '润滑碳纤维杆',
+      lubricateRails: '润滑线性导轨',
+      cleanNozzle: '清洁喷嘴/热端',
+      checkBelts: '检查皮带张力',
+      cleanBuildPlate: '清洁构建板',
+      checkExtruder: '检查挤出机齿轮',
+      checkCooling: '检查冷却风扇',
+      generalInspection: '综合检查',
+      cleanCarbonRods: '清洁碳纤维杆',
+      cleanLinearRails: '清洁线性导轨',
+      checkPtfeTube: '检查 PTFE 管',
+      replaceHepaFilter: '更换 HEPA 过滤器',
+      replaceCarbonFilter: '更换活性炭过滤器',
+      lubricateLeftNozzleRail: '润滑左喷嘴导轨',
+    },
+    maintenanceComplete: '维护已标记为完成',
+    typeUpdated: '维护类型已更新',
+    typeDeleted: '维护类型已删除',
+    defaultsRestored: '已恢复 {{count}} 个默认任务',
+    printHoursUpdated: '打印小时数已更新',
+    printerAssigned: '打印机已分配',
+    printerRemoved: '打印机已移除',
+    deleteTypeConfirm: '删除"{{name}}"?',
+    deleteSystemTypeTitle: '删除默认维护任务?',
+    deleteSystemTypeMessage: '确定要删除默认维护任务"{{name}}"吗?',
+    noPermissionUpdate: '您没有更新维护项目的权限',
+    noPermissionPerform: '您没有执行维护的权限',
+    noPermissionEditTypes: '您没有编辑维护类型的权限',
+    noPermissionDeleteTypes: '您没有删除维护类型的权限',
+    noPermissionEditHours: '您没有编辑打印时间的权限',
+    noPermissionRemovePrinter: '您没有移除打印机分配的权限',
+    noPermissionAssignPrinter: '您没有分配打印机的权限',
+    noPermissionEditIntervals: '您没有编辑间隔的权限',
+    configureSettings: '配置维护类型和间隔',
+  },
+
+  // Settings page
+  settings: {
+    title: '设置',
+    general: '通用',
+    tabs: {
+      general: '通用',
+      smartPlugs: '智能插座',
+      notifications: '通知',
+      filament: '耗材',
+      network: '网络',
+      apiKeys: 'API 密钥',
+      virtualPrinter: '虚拟打印机',
+      users: '身份验证',
+      backup: '备份',
+      emailAuth: '邮箱认证',
+    },
+    email: {
+      smtpSettings: 'SMTP 配置',
+      smtpHost: 'SMTP 服务器',
+      smtpPort: 'SMTP 端口',
+      security: '安全',
+      authentication: '认证',
+      username: '用户名',
+      password: '密码',
+      fromEmail: '发件邮箱',
+      fromName: '发件人名称',
+      testConnection: '测试 SMTP 连接',
+      testRecipient: '测试收件邮箱',
+      sendTest: '发送测试邮件',
+      sending: '发送中...',
+      save: '保存设置',
+      saving: '保存中...',
+      advancedAuth: '高级认证',
+      advancedAuthEnabled: '高级认证已启用',
+      advancedAuthEnabledDesc: '基于邮箱的用户管理功能已激活。新用户将通过邮件收到自动生成的密码,用户可以通过忘记密码功能重置密码。',
+      advancedAuthDisabled: '高级认证已禁用',
+      advancedAuthDisabledDesc: '启用高级认证以激活基于邮箱的用户管理功能。',
+      enable: '启用',
+      disable: '禁用',
+      feature1: '密码自动生成并通过邮件发送给新用户',
+      feature2: '用户可以使用用户名或邮箱登录',
+      feature3: '忘记密码功能可用',
+      feature4: '管理员可以通过邮件重置用户密码',
+      errors: {
+        requiredFields: '请填写所有必填字段',
+        usernameRequired: '启用认证时需要用户名',
+        enterTestEmail: '请输入测试邮箱地址',
+        smtpServerAndEmail: '测试前请填写 SMTP 服务器和发件邮箱',
+        usernamePasswordRequired: '启用认证时需要用户名和密码',
+        configureSmtpFirst: '请先配置并测试 SMTP 设置',
+      },
+      success: {
+        settingsSaved: 'SMTP 设置保存成功',
+      },
+      securityOptions: {
+        starttls: 'STARTTLS(端口 587)',
+        ssl: 'SSL/TLS(端口 465)',
+        none: '无(端口 25)',
+      },
+      authOptions: {
+        enabled: '已启用',
+        disabled: '已禁用',
+      },
+    },
+    appearance: '外观',
+    notifications: '通知',
+    smartPlugs: '智能插座',
+    spoolman: 'Spoolman',
+    updates: '更新',
+    language: '语言',
+    languageDescription: '选择您的首选语言',
+    theme: '主题',
+    themeLight: '浅色',
+    themeDark: '深色',
+    themeSystem: '跟随系统',
+    defaultView: '默认视图',
+    defaultViewDescription: '打开应用时显示的页面',
+    checkForUpdates: '检查更新',
+    autoUpdate: '自动更新',
+    currentVersion: '当前版本',
+    latestVersion: '最新版本',
+    upToDate: '已是最新版本',
+    updateAvailable: '有可用更新',
+    notificationLanguage: '通知语言',
+    notificationLanguageDescription: '推送通知的语言',
+    bedCooledThreshold: '热床冷却阈值',
+    bedCooledThresholdDescription: '打印后热床被视为已冷却的温度',
+    notificationProviders: '通知提供商',
+    addProvider: '添加提供商',
+    editProvider: '编辑提供商',
+    providerType: '提供商类型',
+    testNotification: '测试通知',
+    testSuccess: '测试通知发送成功',
+    testFailed: '发送测试通知失败',
+    quietHours: '免打扰时间',
+    quietHoursDescription: '在此时间段内不发送通知',
+    quietHoursStart: '开始',
+    quietHoursEnd: '结束',
+    events: {
+      title: '通知事件',
+      printStart: '打印开始',
+      printComplete: '打印完成',
+      printFailed: '打印失败',
+      printStopped: '打印停止',
+      printProgress: '进度里程碑',
+      printProgressDescription: '在 25%、50%、75% 时通知',
+      printerOffline: '打印机离线',
+      printerError: '打印机错误',
+      filamentLow: '耗材不足',
+      maintenanceDue: '维护到期',
+      maintenanceDueDescription: '需要维护时通知',
+    },
+    smartPlug: {
+      title: '智能插座',
+      add: '添加智能插座',
+      edit: '编辑智能插座',
+      name: '名称',
+      ipAddress: 'IP 地址',
+      linkedPrinter: '关联打印机',
+      autoOn: '自动开启',
+      autoOnDescription: '打印开始时开启',
+      autoOff: '自动关闭',
+      autoOffDescription: '打印完成后关闭',
+      offDelay: '关闭延迟',
+      offDelayMinutes: '打印后分钟数',
+      offDelayTemp: '当喷嘴温度低于',
+      currentState: '当前状态',
+      turnOn: '开启',
+      turnOff: '关闭',
+    },
+    filamentTracking: '耗材追踪',
+    filamentTrackingDesc: '选择如何追踪您的耗材。您可以使用内置库存或连接外部 Spoolman 服务器。',
+    trackingModeBuiltIn: '内置库存',
+    trackingModeBuiltInDesc: '包含 RFID 自动匹配和用量追踪',
+    trackingModeSpoolmanDesc: '外部耗材管理服务器',
+    builtInFeatureRfid: '自动检测 AMS 中的拓竹 RFID 耗材',
+    builtInFeatureUsage: '追踪每次打印的耗材消耗',
+    builtInFeatureCatalog: '管理耗材、颜色和 K 值配置文件',
+    builtInFeatureThirdParty: '第三方耗材可分配到库存耗材',
+    amsSyncButton: '从 AMS 同步重量',
+    amsSyncTitle: '从 AMS 同步耗材重量',
+    amsSyncMessage: '这将使用已连接打印机的当前 AMS 剩余百分比值覆盖所有库存耗材重量。用于从损坏的重量数据中恢复。打印机必须在线。',
+    amsSyncing: '同步中...',
+    amsSyncSuccess: '已同步 {{synced}} 个耗材,跳过 {{skipped}} 个',
+    amsSyncError: '从 AMS 同步重量失败',
+    spoolmanUrl: 'Spoolman URL',
+    spoolmanUrlHint: 'Spoolman 服务器的 URL(例如 http://localhost:7912)',
+    spoolmanConnected: '已连接',
+    spoolmanDisconnected: '未连接',
+    status: '状态',
+    connect: '连接',
+    disconnect: '断开',
+    howSyncWorks: '同步工作原理',
+    syncInfoRfidOnly: '仅同步带有 RFID 的官方拓竹耗材',
+    syncInfoAutoCreate: '首次同步时自动在 Spoolman 中创建新耗材',
+    syncInfoThirdPartySkipped: '非拓竹耗材(第三方、重新填充的)将被跳过',
+    linkingExistingSpools: '链接现有耗材',
+    linkingExistingSpoolsDesc: '要将现有的 Spoolman 耗材链接到您的 AMS,请将鼠标悬停在 AMS 槽位上并点击"链接到 Spoolman"。',
+    syncMode: '同步模式',
+    syncModeAuto: '自动',
+    syncModeManual: '仅手动',
+    syncModeAutoDesc: '检测到更改时自动同步 AMS 数据',
+    syncModeManualDesc: '仅在手动触发时同步',
+    syncAmsData: '同步 AMS 数据',
+    syncAmsDataDesc: '手动将打印机 AMS 数据同步到 Spoolman',
+    allPrinters: '所有打印机',
+    noDefaultPrinter: '无默认(每次询问)',
+    sidebarOrder: '侧边栏顺序',
+    saveThumbnails: '保存缩略图',
+    captureFinishPhoto: '拍摄完成照片',
+    noPrintersConfigured: '未配置打印机',
+    archiveMode: {
+      always: '始终创建归档条目',
+      never: '从不创建归档条目',
+      ask: '每次询问',
+    },
+    checkForUpdatesLabel: '检查更新',
+    checkPrinterFirmware: '检查打印机固件',
+    includeBetaUpdates: '包含测试版本',
+    includeBetaUpdatesDesc: '检查更新时通知测试版和预发布版本',
+    enableRetry: '启用重试',
+    homeAssistantDescription: '通过 Home Assistant 控制智能插座',
+    environmentManagedLabel: '(环境变量管理)',
+    autoEnabledViaEnv: '通过环境变量自动启用',
+    urlFromEnvReadOnly: '值由 HA_URL 环境变量设置(只读)',
+    tokenFromEnvReadOnly: '值由 HA_TOKEN 环境变量设置(只读)',
+    mqttConnectedTo: '已连接到',
+    prometheusDescription: '以 Prometheus 格式暴露打印机数据',
+    noSmartPlugsTitle: '未配置智能插座',
+    noSmartPlugsDescription: '添加基于 Tasmota 的智能插座以追踪能耗并自动化电源控制。',
+    noProvidersTitle: '未配置提供商',
+    noProvidersDescription: '添加提供商以接收警报。',
+    noTemplatesAvailable: '无可用模板。重启后端以加载默认模板。',
+    apiPermissionView: '查看打印机状态和队列',
+    apiPermissionEdit: '添加和移除打印队列中的项目',
+    apiKeysEmptyTitle: '无 API 密钥',
+    apiKeysEmptyDescription: '创建 API 密钥以与外部服务集成。',
+    noUsersFound: '未找到用户',
+    noGroupsFound: '未找到组',
+    noGroupsAvailable: '无可用组',
+    passwordsDoNotMatch: '密码不匹配',
+    systemGroupWarning: '系统组名称不可更改',
+    authDisabledTitle: '身份验证已禁用',
+    authDisabledFeature1: '需要登录才能访问系统',
+    authDisabledFeature2: '创建多个用户并基于组的权限管理',
+    authDisabledFeature3: '使用 50+ 个细粒度权限控制访问',
+    userHasCreated: '此用户已创建:',
+    userItemsQuestion: '您想如何处理这些项目?',
+    deleteUserConfirm: '确定要删除此用户吗?',
+    actionCannotBeUndone: '此操作无法撤销。',
+    addFirstSmartPlug: '添加您的第一个智能插座',
+    providers: '提供商',
+    log: '日志',
+    testAll: '全部测试',
+    testResults: '测试结果',
+    testPassedCount: '{{count}} 个通过',
+    testFailedCount: '{{count}} 个失败',
+    messageTemplates: '消息模板',
+    messageTemplatesDescription: '自定义每个事件的通知消息。',
+    apiKeys: 'API 密钥',
+    apiKeysDescription: '创建 API 密钥用于外部集成和 Webhook。',
+    createKey: '创建密钥',
+    apiKeyCreated: 'API 密钥创建成功',
+    apiKeyCopyWarning: '请立即复制此密钥 - 它不会再次显示!',
+    useInApiBrowser: '在 API 浏览器中使用',
+    createNewApiKey: '创建新 API 密钥',
+    keyName: '密钥名称',
+    keyNamePlaceholder: '例如:Home Assistant、OctoPrint',
+    readStatus: '读取状态',
+    readStatusDescription: '查看打印机状态和队列',
+    manageQueue: '管理队列',
+    manageQueueDescription: '添加和移除打印队列中的项目',
+    controlPrinter: '控制打印机',
+    controlPrinterDescription: '暂停、继续和停止打印',
+    unnamedKey: '未命名密钥',
+    lastUsed: '上次使用',
+    read: '读取',
+    control: '控制',
+    createFirstKey: '创建您的第一个密钥',
+    webhookEndpoints: 'Webhook 端点',
+    webhookApiKeyHint: '在 X-API-Key 请求头中使用您的 API 密钥。',
+    webhook: {
+      getAllStatus: '获取所有打印机状态',
+      getSpecificStatus: '获取特定打印机状态',
+      addToQueue: '添加到打印队列',
+      pausePrint: '暂停打印',
+      resumePrint: '继续打印',
+      stopPrint: '停止打印',
+    },
+    apiBrowser: 'API 浏览器',
+    apiBrowserDescription: '浏览和测试所有可用的 API 端点。',
+    apiKeyForTesting: '测试用 API 密钥',
+    apiKeyPlaceholder: '在此粘贴您的 API 密钥以测试需要认证的端点...',
+    apiKeyHint: '此密钥将作为 X-API-Key 请求头随请求发送。',
+    deleteApiKeyTitle: '删除 API 密钥',
+    deleteApiKeyMessage: '确定要删除此 API 密钥吗?使用此密钥的所有集成将停止工作。',
+    deleteKey: '删除密钥',
+    amsDisplayThresholds: 'AMS 显示阈值',
+    amsThresholdsDescription: '配置 AMS 湿度和温度指示器的颜色阈值。',
+    humidity: '湿度',
+    goodGreen: '良好(绿色)',
+    fairOrange: '一般(橙色)',
+    aboveFairBad: '超过一般阈值显示为红色(差)',
+    temperature: '温度',
+    goodBlue: '良好(蓝色)',
+    aboveFairHot: '超过一般阈值显示为红色(热)',
+    historyRetention: '历史保留',
+    keepSensorHistory: '保留传感器历史',
+    historyRetentionDescription: '较旧的湿度和温度数据将被自动删除',
+    printModal: '打印对话框',
+    expandCustomMapping: '默认展开自定义映射',
+    expandCustomMappingDescription: '打印到多台打印机时,默认展开显示每台打印机的 AMS 映射',
+    authentication: '身份验证',
+    authEnabledDescription: '您的实例已通过用户身份验证保护',
+    authDisabledDescription: '启用以要求登录并管理用户访问',
+    authDisabledMessage: '启用身份验证以创建用户账户、管理权限并保护您的 Bambuddy 实例。',
+    enableAuthentication: '启用身份验证',
+    currentUser: '当前用户',
+    changePassword: '修改密码',
+    admin: '管理员',
+    users: '用户',
+    addUser: '添加用户',
+    groups: '组',
+    addGroup: '添加组',
+    system: '系统',
+    noDescription: '无描述',
+    userCount: '{{count}} 个用户',
+    permissionCount: '{{count}} 个权限',
+    createUser: '创建用户',
+    username: '用户名',
+    enterUsername: '输入用户名',
+    password: '密码',
+    enterPassword: '输入密码(至少 6 个字符)',
+    confirmPassword: '确认密码',
+    confirmPasswordPlaceholder: '确认密码',
+    viewReleaseOnGitHub: '在 GitHub 上查看发布',
+    turnAllPlugsOn: '开启所有插座',
+    turnAllPlugsOff: '关闭所有插座',
+    clearNotificationLogs: '清除通知日志',
+    clearLogsMessage: '这将永久删除所有 30 天前的通知日志。此操作无法撤销。',
+    clearLogs: '清除日志',
+    resetUiPreferences: '重置 UI 偏好',
+    resetUiPreferencesMessage: '这将重置所有 UI 偏好为默认值:侧边栏顺序、主题、仪表板布局、视图模式和排序偏好。您的打印机、归档和服务器设置不会受到影响。清除后页面将重新加载。',
+    resetPreferences: '重置偏好',
+    deleteGroupTitle: '删除组',
+    deleteGroupMessage: '确定要删除此组吗?此组中的用户将失去这些权限。',
+    deleteGroup: '删除组',
+    disableAuthenticationTitle: '禁用身份验证',
+    disableAuthenticationMessage: '确定要禁用身份验证吗?这将使您的 Bambuddy 实例无需登录即可访问。所有用户将保留在数据库中但身份验证将被禁用。',
+    disableAuthentication: '禁用身份验证',
+    configureBambuddy: '配置 Bambuddy',
+    systemDefault: '系统默认',
+    archiveSettings: '归档设置',
+    newWindow: '新窗口',
+    embeddedOverlay: '嵌入式叠加层',
+    preferredSlicer: '首选切片软件',
+    preferredSlicerDescription: '选择要用于打开文件的切片软件',
+    externalCameras: '外部摄像头',
+    costTracking: '成本追踪',
+    printsOnly: '仅打印',
+    totalConsumption: '总消耗',
+    dataManagement: '数据管理',
+    storageUsage: '存储使用情况',
+    storageUsageDescription: '按类别的数据使用情况明细',
+    storageUsageTotal: '总计',
+    storageUsageErrors: '错误',
+    storageUsageOtherBreakdown: '其他(包括静态资源、脚本和配置文件)',
+    storageUsageSystem: '系统',
+    storageUsageData: '数据',
+    storageUsageUnavailable: '存储使用信息不可用',
+    clearNotificationLogsDescription: '删除 30 天前的通知日志',
+    resetUiPreferencesDescription: '重置侧边栏顺序、主题、视图模式和布局偏好。打印机、归档和设置不受影响。',
+    enableHomeAssistant: '启用 Home Assistant',
+    enableMqtt: '启用 MQTT',
+    useTls: '使用 TLS',
+    enableMetricsEndpoint: '启用指标端点',
+    availableMetrics: '可用指标',
+    editUser: '编辑用户',
+    deleteUserTitle: '删除用户',
+    groupName: '组名称',
+    leaveEmptyForAnonymous: '留空为匿名',
+    leaveEmptyForNoAuth: '留空为无认证',
+    enterNewPassword: '输入新密码',
+    confirmNewPassword: '确认新密码',
+    enterGroupName: '输入组名称',
+    enterDescriptionOptional: '输入描述(可选)',
+    enterCurrentPassword: '输入当前密码',
+    enterNewPasswordMin6: '输入新密码(至少 6 个字符)',
+    toast: {
+      keyCopied: '密钥已复制到剪贴板',
+      copyFailed: '复制密钥失败',
+      keyAddedToBrowser: '密钥已添加到 API 浏览器',
+      clearLogsFailed: '清除日志失败',
+      uiPreferencesReset: 'UI 偏好已重置。刷新中...',
+      authDisabled: '身份验证已成功禁用',
+      authDisableFailed: '禁用身份验证失败',
+      apiKeyCreated: 'API 密钥已创建',
+      apiKeyDeleted: 'API 密钥已删除',
+      userCreated: '用户创建成功',
+      userUpdated: '用户更新成功',
+      userDeleted: '用户删除成功',
+      groupCreated: '组创建成功',
+      groupUpdated: '组更新成功',
+      groupDeleted: '组删除成功',
+      fillRequiredFields: '请填写所有必填字段',
+      passwordsDoNotMatch: '密码不匹配',
+      passwordTooShort: '密码至少需要 6 个字符',
+      enterGroupName: '请输入组名称',
+      settingsSaved: '设置已保存',
+      cameraSettingsSaved: '摄像头设置已保存',
+      enterCameraUrl: '请输入摄像头 URL',
+      passwordChanged: '密码修改成功',
+      connectionFailed: '连接失败',
+      testFailed: '测试失败',
+      cameraConnected: '摄像头已连接{{resolution}}',
+    },
+    testConnection: '测试连接',
+    catalog: {
+      spoolCatalog: '耗材目录',
+      spoolCatalogDescription: '按品牌/类型的空耗材重量。用于添加耗材时的自动重量查找。',
+      searchCatalog: '搜索目录...',
+      addNewEntry: '添加新条目',
+      namePlaceholder: '名称(例如:Bambu Lab - 塑料)',
+      weight: '重量',
+      type: '类型',
+      default: '默认',
+      custom: '自定义',
+      noMatch: '没有条目匹配您的搜索',
+      empty: '目录中没有条目',
+      deleteEntry: '删除条目',
+      deleteConfirm: '确定要删除"{{name}}"吗?',
+      resetCatalog: '重置目录',
+      resetConfirm: '重置目录为默认值?这将移除所有自定义条目。',
+      loadFailed: '加载耗材目录失败',
+      nameWeightRequired: '名称和重量为必填项',
+      entryAdded: '条目已添加',
+      addFailed: '添加条目失败',
+      entryUpdated: '条目已更新',
+      updateFailed: '更新条目失败',
+      entryDeleted: '条目已删除',
+      deleteFailed: '删除条目失败',
+      resetSuccess: '目录已重置为默认值',
+      resetFailed: '重置目录失败',
+      exported: '已导出 {{count}} 条',
+      imported: '已导入 {{added}} 条(跳过 {{skipped}} 条)',
+      importFailed: '导入失败:无效的 JSON 格式',
+      exportTooltip: '导出目录为 JSON',
+      importTooltip: '从 JSON 导入目录',
+      resetTooltip: '重置为默认值',
+    },
+    colorCatalog: {
+      title: '颜色目录',
+      description: '按制造商/材料的耗材颜色。用于添加耗材时的自动颜色查找。',
+      searchColors: '搜索颜色...',
+      allManufacturers: '所有制造商',
+      addNewColor: '添加新颜色',
+      manufacturer: '制造商',
+      colorName: '颜色名称',
+      hex: '十六进制',
+      materialOptional: '材料(可选)',
+      showing: '显示 {{filtered}} / {{total}} 种颜色',
+      noMatch: '没有颜色匹配您的搜索',
+      empty: '目录中没有颜色',
+      deleteColor: '删除颜色',
+      deleteConfirm: '确定要删除"{{name}}"吗?',
+      resetCatalog: '重置颜色目录',
+      resetConfirm: '重置目录为默认值?这将移除所有自定义颜色。',
+      sync: '同步',
+      starting: '启动中...',
+      syncTooltip: '从 FilamentColors.xyz 同步(2000+ 种颜色,可能需要一分钟)',
+      loadFailed: '加载颜色目录失败',
+      fieldsRequired: '制造商、颜色名称和十六进制颜色为必填项',
+      colorAdded: '颜色已添加',
+      addFailed: '添加颜色失败',
+      colorUpdated: '颜色已更新',
+      updateFailed: '更新颜色失败',
+      colorDeleted: '颜色已删除',
+      deleteFailed: '删除颜色失败',
+      resetSuccess: '颜色目录已重置为默认值',
+      resetFailed: '重置目录失败',
+      syncUpToDate: '已是最新(检查了 {{count}} 种颜色)',
+      syncComplete: '添加了 {{added}} 种新颜色({{skipped}} 种已存在)',
+      syncError: '同步错误',
+      syncFailed: '从 FilamentColors.xyz 同步失败',
+      exported: '已导出 {{count}} 种颜色',
+      imported: '已导入 {{added}} 种颜色(跳过 {{skipped}} 种)',
+      importFailed: '导入失败:无效的 JSON 格式',
+    },
+  },
+
+  // Notifications (for push notifications)
+  notification: {
+    printStarted: {
+      title: '打印已开始',
+      body: '{{printer}}:{{filename}} 已开始打印',
+    },
+    printCompleted: {
+      title: '打印已完成',
+      body: '{{printer}}:{{filename}} 已成功完成',
+    },
+    printFailed: {
+      title: '打印失败',
+      body: '{{printer}}:{{filename}} 打印失败',
+    },
+    printStopped: {
+      title: '打印已停止',
+      body: '{{printer}}:{{filename}} 已停止',
+    },
+    printProgress: {
+      title: '打印进度',
+      body: '{{printer}}:{{filename}} 已完成 {{percent}}%',
+    },
+    printerOffline: {
+      title: '打印机离线',
+      body: '{{printer}} 已离线',
+    },
+    printerError: {
+      title: '打印机错误',
+      body: '{{printer}}:{{error}}',
+    },
+    filamentLow: {
+      title: '耗材不足',
+      body: '{{printer}}:耗材即将用完',
+    },
+    maintenanceDue: {
+      title: '维护到期',
+      body: '{{printer}}:{{items}} 需要关注',
+    },
+  },
+
+  // Errors
+  errors: {
+    generic: '出了点问题',
+    networkError: '网络错误。请检查您的连接。',
+    notFound: '未找到',
+    unauthorized: '未授权',
+    serverError: '服务器错误',
+    validationError: '请检查您的输入',
+    printerConnectionFailed: '连接打印机失败',
+    saveFailed: '保存更改失败',
+    deleteFailed: '删除失败',
+    loadFailed: '加载数据失败',
+  },
+
+  // HMS Errors modal
+  hmsErrors: {
+    title: '错误 - {{name}}',
+    noErrors: '无错误',
+    viewOnWiki: '在拓竹 Wiki 上查看',
+    clearInstructions: '在打印机上清除错误以在此处消除它们。',
+    clearErrors: '清除错误',
+    clearSuccess: 'HMS 错误已清除',
+    clearFailed: '清除 HMS 错误失败',
+  },
+
+  // MQTT Debug modal
+  mqttDebug: {
+    title: 'MQTT 调试日志',
+    searchPlaceholder: '搜索主题或负载...',
+    noMessages: '尚未记录消息',
+    startLoggingHint: '点击"开始记录"以开始捕获 MQTT 消息',
+    noMessagesMatch: '没有消息匹配您的筛选条件',
+    adjustFilterHint: '尝试调整您的搜索或筛选条件',
+    incoming: '传入',
+    outgoing: '传出',
+    loggingStopped: '记录已停止',
+    loggingActive: '记录中 - 消息将自动刷新',
+    startLogging: '开始记录',
+    stopLogging: '停止记录',
+    clearLog: '清除日志',
+    topic: '主题',
+    timestamp: '时间戳',
+    direction: '方向',
+    all: '全部',
+  },
+
+  // Printer File Manager modal
+  printerFiles: {
+    title: '文件管理器',
+    storageUsed: '已用:',
+    storageFree: '剩余:',
+    filterPlaceholder: '筛选文件...',
+    deleteButton: '删除',
+    deleteFiles: '删除 {{count}} 个文件',
+    deleteFileConfirm: '删除"{{name}}"?此操作无法撤销。',
+    deleteFilesConfirm: '删除 {{count}} 个选中的文件?此操作无法撤销。',
+    noFiles: '打印机上没有文件',
+    loadingFiles: '加载文件中...',
+    failedToLoad: '加载文件失败',
+    toast: {
+      filesDeleted: '已删除 {{count}} 个文件',
+      deleteFailed: '删除失败:{{error}}',
+    },
+  },
+
+  // Confirmations
+  confirm: {
+    delete: '确定要删除吗?',
+    unsavedChanges: '您有未保存的更改。确定要离开吗?',
+    clearQueue: '确定要清空队列吗?',
+  },
+
+  // Login page
+  login: {
+    title: 'Bambuddy 登录',
+    subtitle: '登录您的账户',
+    username: '用户名',
+    usernamePlaceholder: '输入您的用户名',
+    usernameOrEmail: '用户名或邮箱',
+    usernameOrEmailPlaceholder: '用户名或 @ 邮箱',
+    password: '密码',
+    passwordPlaceholder: '输入您的密码',
+    signIn: '登录',
+    signingIn: '登录中...',
+    forgotPassword: '忘记密码?',
+    loginSuccess: '登录成功',
+    loginFailed: '登录失败',
+    enterCredentials: '请输入用户名和密码',
+    forgotPasswordTitle: '忘记密码',
+    forgotPasswordMessage: '如果您忘记了密码,请联系系统管理员进行重置。',
+    forgotPasswordEmailMessage: '输入您的邮箱地址,我们将向您发送新密码。',
+    emailAddress: '邮箱地址',
+    emailPlaceholder: 'your.email@example.com',
+    cancel: '取消',
+    sending: '发送中...',
+    sendResetEmail: '发送重置邮件',
+    howToReset: '如何重置密码:',
+    resetStep1: '联系您的 Bambuddy 管理员',
+    resetStep2: '请他们在用户管理中重置您的密码',
+    resetStep3: '他们可以为您设置一个临时密码',
+    resetStep4: '使用新密码登录并在设置中修改密码',
+    gotIt: '知道了',
+  },
+
+  // Setup page
+  setup: {
+    title: 'Bambuddy 设置',
+    subtitle: '为您的 Bambuddy 实例配置身份验证',
+    enableAuth: '启用身份验证',
+    adminAccount: '管理员账户',
+    adminAccountDesc: '如果管理员用户已存在,将使用现有管理员账户启用身份验证。如需使用现有管理员,请将下方字段留空,或输入新凭据创建新管理员用户。',
+    adminUsername: '管理员用户名',
+    adminPassword: '管理员密码',
+    optionalIfAdminExists: '(如管理员用户已存在则为可选)',
+    adminUsernamePlaceholder: '输入管理员用户名(可选)',
+    adminPasswordPlaceholder: '输入管理员密码(可选)',
+    confirmPassword: '确认密码',
+    confirmPasswordPlaceholder: '确认管理员密码',
+    settingUp: '设置中...',
+    completeSetup: '完成设置',
+    toast: {
+      authEnabledAdminCreated: '身份验证已启用并创建了管理员用户',
+      authEnabledExistingAdmins: '使用现有管理员用户启用了身份验证',
+      setupCompleted: '设置完成',
+      enterBothCredentials: '请输入管理员用户名和密码,或将两者留空以使用现有管理员用户',
+      passwordsDoNotMatch: '密码不匹配',
+      passwordTooShort: '密码至少需要 6 个字符',
+    },
+  },
+
+  // Password change
+  changePassword: {
+    title: '修改密码',
+    currentPassword: '当前密码',
+    currentPasswordPlaceholder: '输入当前密码',
+    newPassword: '新密码',
+    newPasswordPlaceholder: '输入新密码(至少 6 个字符)',
+    confirmPassword: '确认新密码',
+    confirmPasswordPlaceholder: '确认新密码',
+    passwordsDoNotMatch: '密码不匹配',
+    passwordTooShort: '密码至少需要 6 个字符',
+    changing: '修改中...',
+    success: '密码修改成功',
+    failed: '密码修改失败',
+  },
+
+  // Plate detection alert
+  plateAlert: {
+    title: '打印已暂停!',
+    message: '在构建板上检测到物体。打印已自动暂停。请清理打印板并继续打印。',
+    understand: '我知道了',
+  },
+
+  // Camera page
+  camera: {
+    title: '摄像头视图',
+    invalidPrinterId: '无效的打印机 ID',
+    live: '实时',
+    snapshot: '快照',
+    restartStream: '重启流',
+    refreshSnapshot: '刷新快照',
+    fullscreen: '全屏',
+    exitFullscreen: '退出全屏',
+    connectingToCamera: '连接摄像头中...',
+    capturingSnapshot: '拍摄快照中...',
+    connectionLost: '连接已断开',
+    connectionFailed: '摄像头连接失败',
+    reconnecting: '{{countdown}} 秒后重新连接...(第 {{attempt}}/{{max}} 次尝试)',
+    reconnectNow: '立即重新连接',
+    cameraUnavailable: '摄像头不可用',
+    cameraUnavailableDesc: '请确保打印机已通电并已连接。',
+    noCamera: '无可用摄像头',
+    retry: '重试',
+    cameraStream: '摄像头流',
+    zoomOut: '缩小',
+    zoomIn: '放大',
+    resetZoom: '重置缩放',
+    recording: '录制中',
+    startRecording: '开始录制',
+    stopRecording: '停止录制',
+    chamberLight: '切换腔室灯',
+  },
+
+  // Groups management
+  groups: {
+    title: '组管理',
+    subtitle: '管理访问控制的权限组',
+    backToSettings: '返回设置',
+    createGroup: '创建组',
+    noPermission: '您没有访问此页面的权限。',
+    system: '系统',
+    noDescription: '无描述',
+    usersCount: '{{count}} 个用户',
+    permissionsCount: '{{count}} 个权限',
+    edit: '编辑',
+    delete: '删除',
+    toast: {
+      created: '组创建成功',
+      updated: '组更新成功',
+      deleted: '组删除成功',
+      enterGroupName: '请输入组名称',
+    },
+    modal: {
+      editGroup: '编辑组',
+      createGroup: '创建组',
+      cancel: '取消',
+      saving: '保存中...',
+      creating: '创建中...',
+      saveChanges: '保存更改',
+    },
+    form: {
+      groupName: '组名称',
+      groupNamePlaceholder: '输入组名称',
+      systemGroupWarning: '系统组名称不可更改',
+      description: '描述',
+      descriptionPlaceholder: '输入描述(可选)',
+      permissions: '权限(已选 {{count}} 个)',
+    },
+    deleteModal: {
+      title: '删除组',
+      message: '确定要删除此组吗?此组中的用户将失去这些权限。',
+      confirm: '删除组',
+    },
+    editor: {
+      title: '编辑组',
+      createTitle: '创建组',
+      search: '搜索权限...',
+      selectAll: '全选',
+      clearAll: '清除全部',
+      permissionsSelected: '已选 {{count}} 个',
+      noResults: '没有权限匹配您的搜索',
+    },
+  },
+
+  // Users management
+  users: {
+    title: '用户管理',
+    subtitle: '管理用户及其对 Bambuddy 实例的访问',
+    backToSettings: '返回设置',
+    createUser: '创建用户',
+    noPermission: '您没有访问此页面的权限。',
+    admin: '管理员',
+    noGroups: '无组',
+    active: '活跃',
+    inactive: '非活跃',
+    edit: '编辑',
+    delete: '删除',
+    system: '系统',
+    noGroupsAvailable: '无可用组',
+    table: {
+      username: '用户名',
+      groups: '组',
+      status: '状态',
+      actions: '操作',
+    },
+    toast: {
+      created: '用户创建成功',
+      updated: '用户更新成功',
+      deleted: '用户删除成功',
+      fillRequired: '请填写所有必填字段',
+      passwordsDoNotMatch: '密码不匹配',
+      passwordTooShort: '密码至少需要 6 个字符',
+    },
+    modal: {
+      createUser: '创建用户',
+      editUser: '编辑用户',
+      cancel: '取消',
+      creating: '创建中...',
+      saving: '保存中...',
+      saveChanges: '保存更改',
+      advancedAuthSubtitle: '使用高级认证',
+    },
+    form: {
+      username: '用户名',
+      usernamePlaceholder: '输入用户名',
+      email: '邮箱',
+      emailPlaceholder: 'user@example.com',
+      password: '密码',
+      passwordPlaceholder: '输入密码',
+      confirmPassword: '确认密码',
+      confirmPasswordPlaceholder: '确认密码',
+      newPasswordPlaceholder: '输入新密码',
+      confirmNewPasswordPlaceholder: '确认新密码',
+      leaveBlankToKeep: '留空以保持当前值',
+      groups: '组',
+      optional: '可选',
+      autoGeneratedPassword: '将自动生成安全密码并通过邮件发送给用户。',
+      passwordManagedByAdvancedAuth: '密码由高级认证管理。使用"重置密码"通过邮件向用户发送新密码。',
+      resetPassword: '重置密码',
+      resettingPassword: '重置密码中...',
+    },
+    deleteModal: {
+      title: '删除用户',
+      message: '确定要删除此用户吗?此操作无法撤销。',
+      confirm: '删除用户',
+    },
+  },
+
+  // Stream overlay
+  streamOverlay: {
+    title: '流叠加层',
+    invalidPrinterId: '无效的打印机 ID',
+    cameraStream: '摄像头流',
+    progress: '进度',
+    eta: '预计完成时间',
+    printerIdle: '打印机空闲',
+    printerOffline: '打印机离线',
+    status: {
+      printing: '打印中',
+      paused: '已暂停',
+      finished: '已完成',
+      failed: '失败',
+      idle: '空闲',
+      unknown: '未知',
+    },
+  },
+
+  // Profiles
+  profiles: {
+    title: '配置文件',
+    subtitle: '管理您的切片预设和压力推进校准',
+    tabs: {
+      cloud: '云端配置文件',
+      local: '本地配置文件',
+      kprofiles: 'K 值配置',
+    },
+    localProfiles: {
+      title: '本地配置文件',
+      subtitle: '从 OrcaSlicer 导入和管理切片预设',
+      import: '导入配置文件',
+      importDesc: '将 .bbscfg、.bbsflmt、.orca_filament、.zip 或 .json 文件拖放到此处',
+      importing: '导入中...',
+      search: '搜索本地预设...',
+      noPresets: '暂无本地预设',
+      badge: '本地',
+      edit: '编辑',
+      delete: '删除',
+      cancel: '取消',
+      deleteConfirmTitle: '删除预设',
+      deleteConfirm: '确定要删除此预设吗?此操作无法撤销。',
+      source: '来源',
+      inheritsFrom: '继承自',
+      filamentType: '类型',
+      vendor: '厂商',
+      compatiblePrinters: '兼容打印机',
+      nozzleTemp: '喷嘴温度',
+      cost: '成本',
+      density: '密度',
+      pressureAdvance: '压力推进',
+      filament: '耗材',
+      process: '工艺',
+      printer: '打印机',
+      toast: {
+        importSuccess: '已导入 {{count}} 个预设',
+        importSkipped: '跳过 {{count}} 个预设(重复)',
+        importError: '导入时出现 {{count}} 个错误',
+        deleted: '预设已删除',
+        updated: '预设已更新',
+      },
+    },
+    connectedAs: '已连接为',
+    logout: '退出登录',
+    noLogoutPermission: '您没有退出登录的权限',
+    failedToLoad: '加载配置文件失败',
+    retry: '重试',
+    time: {
+      justNow: '刚刚',
+      minsAgo: '{{count}} 分钟前',
+      hoursAgo: '{{count}} 小时前',
+      daysAgo: '{{count}} 天前',
+    },
+    toast: {
+      loggedOut: '已退出登录',
+    },
+    login: {
+      title: '连接到拓竹云',
+      subtitle: '跨设备同步您的切片预设',
+      email: '邮箱',
+      password: '密码',
+      region: '地区',
+      regionGlobal: '全球',
+      regionChina: '中国',
+      verificationCode: '验证码',
+      totpCode: '验证器代码',
+      checkEmail: '检查您的邮箱 ({{email}}) 获取 6 位验证码',
+      enterTotpHint: '输入验证器应用中的 6 位代码',
+      accessToken: '访问令牌',
+      accessTokenHint: '粘贴您的拓竹访问令牌(来自 Bambu Studio)',
+      back: '返回',
+      loginButton: '登录',
+      verifyButton: '验证',
+      setTokenButton: '设置令牌',
+      useToken: '改用访问令牌',
+      useEmail: '改用邮箱登录',
+      toast: {
+        loggedIn: '登录成功',
+        codeSent: '验证码已发送到您的邮箱',
+        enterTotp: '输入验证器应用中的代码',
+        tokenSet: '令牌设置成功',
+      },
+    },
+    presets: {
+      myPreset: '我的预设(可编辑)',
+      duplicate: '复制',
+      editable: '可编辑',
+      failedToLoadDetails: '加载预设详情失败',
+      deleteConfirm: '删除此预设?',
+      deleteWarning: '这将从拓竹云中永久删除"{{name}}"。此操作无法撤销。',
+      noDuplicatePermission: '您没有复制预设的权限',
+      noEditPermission: '您没有编辑预设的权限',
+      noDeletePermission: '您没有删除预设的权限',
+      types: {
+        filament: '耗材预设',
+        printer: '打印机预设',
+        process: '工艺预设',
+      },
+      toast: {
+        deleted: '预设已删除',
+        created: '预设已创建',
+        updated: '预设已更新',
+        duplicated: '预设已复制',
+        fieldAdded: '字段"{{key}}"已添加',
+        exported: '预设已导出',
+      },
+      baseLabel: '基础:{{name}}',
+      currentLabel: '当前:{{name}}',
+      newPreset: '新建预设',
+      editPreset: '编辑预设',
+      duplicatePreset: '复制预设',
+      createNewPreset: '创建新预设',
+      customizeSettings: '自定义新预设的设置',
+      compareWithBase: '与基础预设比较',
+      compare: '比较',
+      basePreset: '基础预设',
+      selectBasePreset: '选择基础预设...',
+      presetName: '预设名称',
+      myCustomPreset: '我的自定义预设',
+      inheritsFrom: '继承自',
+      dropJsonToImport: '拖放 JSON 以导入',
+      tabs: {
+        common: '常用',
+        allFields: '所有字段',
+      },
+      availableFields: '可用字段',
+      searchFieldsPlaceholder: '搜索字段...',
+      noMatchingFields: '没有匹配的字段',
+      allFieldsAdded: '所有字段已添加',
+      addCustomField: '添加自定义字段',
+      yourOverrides: '您的覆盖值',
+      noOverridesYet: '暂无覆盖值',
+      clickFieldsToAdd: '点击左侧的字段进行添加',
+      saveAsTemplate: '保存为模板',
+      jsonTip: '提示:将 .json 文件拖放到此对话框的任意位置以导入设置',
+    },
+    cloudView: {
+      searchPlaceholder: '搜索预设...',
+      templates: '模板',
+      refresh: '刷新',
+      newPreset: '新建预设',
+      clearFilters: '清除筛选',
+      compareMode: '比较模式',
+      selectAnotherPreset: '选择另一个 {{type}} 预设',
+      clickTwoPresets: '点击两个相同类型的预设进行比较',
+      selectFirst: '1. 选择第一个',
+      selectSecond: '2. 选择第二个',
+      compareNow: '立即比较',
+      lastSynced: '上次同步:',
+      showingCount: '显示 {{showing}} / {{total}} 个预设',
+      noPresetsFound: '未找到预设',
+      columns: {
+        filament: '耗材',
+        process: '工艺',
+        printer: '打印机',
+      },
+      noFilamentPresets: '无耗材预设',
+      noProcessPresets: '无工艺预设',
+      noPrinterPresets: '无打印机预设',
+      filters: {
+        type: '类型',
+        owner: '所有者',
+        printer: '打印机',
+        nozzle: '喷嘴',
+        filament: '耗材',
+        layer: '层',
+        all: '全部',
+        myPresets: '我的预设',
+        builtIn: '内置',
+        process: '工艺',
+      },
+      noTemplatesPermission: '您没有管理模板的权限',
+      noRefreshPermission: '您没有刷新配置文件的权限',
+      noCreatePermission: '您没有创建预设的权限',
+    },
+    templates: {
+      title: '快速模板',
+      noTemplates: '暂无模板',
+      createFirst: '从预设编辑器创建模板',
+      typeFilter: '类型:',
+      deleteTitle: '删除模板',
+      deleteWarning: '此操作无法撤销',
+      deleteConfirm: '确定要删除"{{name}}"吗?',
+      namePlaceholder: '模板名称',
+      descriptionPlaceholder: '描述',
+      settingsJson: '设置 (JSON)',
+      fieldsCount: '{{count}} 个字段',
+      shownInModals: '在对话框中显示',
+      hiddenInModals: '在对话框中隐藏',
+      apply: '应用',
+      toast: {
+        deleted: '模板已删除',
+        updated: '模板已更新',
+        created: '模板已创建',
+        applied: '模板已应用',
+      },
+    },
+  },
+
+  // Support/Debug
+  support: {
+    debugLoggingActive: '调试日志记录已激活',
+    manageLogs: '管理',
+    collectItem7: '打印机连接和固件版本',
+    collectItem8: '集成状态(Spoolman、MQTT、HA)',
+    collectItem9: '网络接口(仅子网)',
+    collectItem10: 'Python 包版本',
+    collectItem11: '数据库健康检查',
+    collectItem12: 'Docker 环境详情',
+  },
+
+  // File manager
+  fileManager: {
+    title: '文件管理器',
+    subtitle: '组织和管理您的打印文件',
+    uploadFiles: '上传文件',
+    newFolder: '新建文件夹',
+    folderName: '文件夹名称',
+    folderNamePlaceholder: '例如:功能零件',
+    renameFile: '重命名文件',
+    renameFolder: '重命名文件夹',
+    moveFiles: '移动 {{count}} 个文件',
+    rootNoFolder: '根目录(无文件夹)',
+    current: '当前',
+    linkFolder: '链接文件夹',
+    linkFolderDescription: '将"{{name}}"链接到项目或归档以便快速访问。',
+    project: '项目',
+    archive: '归档',
+    noProjectsFound: '未找到项目',
+    noArchivesFound: '未找到归档',
+    unlink: '取消链接',
+    link: '链接',
+    dragDropFiles: '将文件拖放到此处',
+    dropFilesHere: '将文件放在此处',
+    orClickToBrowse: '或点击浏览',
+    allFileTypesSupported: '支持所有文件类型。ZIP 文件将被解压。',
+    zipFilesDetected: '检测到 ZIP 文件',
+    zipExtractOptions: 'ZIP 文件将被解压。选择如何处理文件夹结构:',
+    preserveZipStructure: '保留 ZIP 中的文件夹结构',
+    createFolderFromZip: '从 ZIP 文件名创建文件夹',
+    stlThumbnailGeneration: 'STL 缩略图生成',
+    zipMayContainStl: 'ZIP 文件可能包含 STL 文件。可以在解压时生成缩略图。',
+    thumbnailsCanBeGenerated: '可以为 STL 文件生成缩略图。大型模型可能需要更长时间处理。',
+    generateThumbnailsForStl: '为 STL 文件生成缩略图',
+    threemfDetected: '检测到 3MF 文件',
+    threemfExtractionInfo: '将自动从 3MF 文件中提取打印机型号、材料、颜色和打印设置。',
+    willBeExtracted: '将被解压',
+    filesExtracted: '已解压 {{count}} 个文件',
+    uploadComplete: '上传完成:{{succeeded}} 个成功',
+    uploadFailed: '{{count}} 个失败',
+    uploading: '上传中...',
+    changeLink: '更改链接...',
+    linkTo: '链接到...',
+    linkToProjectOrArchive: '链接到项目或归档',
+    addToQueue: '添加到队列',
+    schedulePrint: '排程',
+    generateThumbnail: '生成缩略图',
+    generateThumbnails: '生成缩略图',
+    generateThumbnailsForMissing: '为缺少缩略图的 STL 文件生成缩略图',
+    gridView: '网格视图',
+    listView: '列表视图',
+    lowDiskSpaceWarning: '磁盘空间不足警告',
+    lowDiskSpaceDetails: '仅剩 {{free}}(总共 {{total}})。阈值设置为 {{threshold}} GB。',
+    files: '文件',
+    folders: '文件夹',
+    size: '大小',
+    free: '剩余',
+    allFiles: '所有文件',
+    wrap: '换行',
+    enableTextWrapping: '启用文本换行',
+    disableTextWrapping: '禁用文本换行',
+    dragToResizeTooltip: '拖动调整大小,双击重置',
+    searchFiles: '搜索文件...',
+    allTypes: '所有类型',
+    prints: '打印',
+    ascending: '升序',
+    descending: '降序',
+    resultsCount: '{{showing}} / {{total}} 个文件',
+    selectAll: '全选',
+    deselectAll: '取消全选',
+    selected: '已选择 {{count}} 个',
+    adding: '添加中...',
+    loadingFiles: '加载文件中...',
+    folderIsEmpty: '文件夹为空',
+    noFilesYet: '暂无文件',
+    folderEmptyDescription: '上传文件或将文件移入此文件夹以开始使用。',
+    noFilesDescription: '上传文件以开始组织您的打印相关文件。',
+    noMatchingFiles: '没有匹配的文件',
+    noMatchingFilesDescription: '没有文件匹配您当前的搜索或筛选条件。',
+    clearFilters: '清除筛选',
+    printedCount: '已打印 {{count}} 次',
+    uploadedBy: '上传者',
+    deleteFolder: '删除文件夹',
+    deleteFile: '删除文件',
+    deleteFilesCount: '删除 {{count}} 个文件',
+    deleteFolderConfirm: '确定要删除此文件夹吗?其中的所有文件也将被删除。',
+    deleteFileConfirm: '确定要删除此文件吗?',
+    deleteFilesConfirm: '确定要删除 {{count}} 个选中的文件吗?此操作无法撤销。',
+    deleting: '删除中...',
+    noPermissionRenameFolder: '您没有重命名文件夹的权限',
+    noPermissionLinkFolder: '您没有链接文件夹的权限',
+    noPermissionDeleteFolder: '您没有删除文件夹的权限',
+    noPermissionPrint: '您没有打印的权限',
+    noPermissionAddToQueue: '您没有添加到队列的权限',
+    noPermissionDownload: '您没有下载文件的权限',
+    noPermissionRenameFile: '您没有重命名此文件的权限',
+    noPermissionGenerateThumbnail: '您没有生成缩略图的权限',
+    noPermissionDeleteFile: '您没有删除此文件的权限',
+    noPermissionCreateFolder: '您没有创建文件夹的权限',
+    noPermissionUpload: '您没有上传文件的权限',
+    noPermissionMoveFiles: '您没有移动文件的权限',
+    noPermissionDeleteFiles: '您没有删除文件的权限',
+    toast: {
+      folderCreated: '文件夹已创建',
+      folderDeleted: '文件夹已删除',
+      fileDeleted: '文件已删除',
+      filesDeleted: '已删除 {{count}} 个文件',
+      filesMoved: '文件已移动',
+      folderLinked: '文件夹已链接',
+      folderUnlinked: '文件夹已取消链接',
+      addedToQueue: '已将 {{count}} 个文件添加到队列',
+      addedToQueuePartial: '已添加 {{added}} 个文件,{{failed}} 个失败',
+      failedToAddToQueue: '添加文件失败:{{error}}',
+      fileRenamed: '文件已重命名',
+      folderRenamed: '文件夹已重命名',
+      thumbnailsGenerated: '已生成 {{count}} 个缩略图',
+      thumbnailsGeneratedPartial: '已生成 {{succeeded}} 个缩略图,{{failed}} 个失败',
+      noStlMissingThumbnails: '没有缺少缩略图的 STL 文件',
+      failedToGenerateThumbnails: '生成缩略图失败:{{error}}',
+      thumbnailGenerated: '缩略图已生成',
+      failedToGenerateThumbnail: '生成缩略图失败:{{error}}',
+    },
+  },
+
+  // Projects
+  projects: {
+    title: '项目',
+    subtitle: '组织和跟踪您的 3D 打印项目',
+    newProject: '新建项目',
+    editProject: '编辑项目',
+    deleteProject: '删除项目',
+    projectName: '项目名称',
+    description: '描述',
+    noProjects: '暂无项目',
+    noProjectsFiltered: '没有{{status}}项目',
+    noProjectsFilteredHelp: '您没有任何{{status}}项目。当项目状态更改时,它们将出现在这里。',
+    createFirst: '创建您的第一个项目以开始组织相关打印、跟踪进度和管理构建。',
+    createFirstButton: '创建您的第一个项目',
+    create: '创建',
+    files: '文件',
+    prints: '打印',
+    plates: '板',
+    parts: '零件',
+    lastModified: '最后修改',
+    deleteConfirm: '确定要删除此项目吗?归档和队列项目将被取消链接但不会被删除。',
+    addFiles: '添加文件',
+    removeFile: '移除文件',
+    viewDetails: '查看详情',
+    namePlaceholder: '例如:Voron 2.4 构建',
+    descriptionPlaceholder: '可选描述...',
+    color: '颜色',
+    targetPlates: '目标板数',
+    targetPlatesPlaceholder: '例如:25',
+    targetPlatesHelp: '打印任务数量',
+    targetParts: '目标零件数',
+    targetPartsPlaceholder: '例如:150',
+    targetPartsHelp: '所需零件总数',
+    tagsLabel: '标签(逗号分隔)',
+    tagsPlaceholder: '例如:voron、功能件、礼物',
+    dueDate: '截止日期',
+    priority: '优先级',
+    priorityLow: '低',
+    priorityNormal: '普通',
+    priorityHigh: '高',
+    priorityUrgent: '紧急',
+    statusActive: '进行中',
+    statusCompleted: '已完成',
+    statusArchived: '已归档',
+    done: '完成',
+    completed: '已完成',
+    failed: '失败',
+    inQueue: '队列中',
+    noPrintsYet: '暂无打印',
+    printJobs: '打印任务(板)',
+    partsPrinted: '已打印零件',
+    failedParts: '失败零件',
+    import: '导入',
+    export: '导出',
+    importProject: '导入项目',
+    exportAll: '导出所有项目',
+    loading: '加载项目中...',
+    noEditPermission: '您没有编辑项目的权限',
+    noDeletePermission: '您没有删除项目的权限',
+    noCreatePermission: '您没有创建项目的权限',
+    noImportPermission: '您没有导入项目的权限',
+    noExportPermission: '您没有导出项目的权限',
+    toast: {
+      created: '项目已创建',
+      updated: '项目已更新',
+      deleted: '项目已删除',
+      imported: '项目已导入',
+      multipleImported: '已导入 {{count}} 个项目',
+      importFailed: '导入失败',
+      exported: '项目已导出(仅元数据)',
+    },
+  },
+
+  // Project detail page
+  projectDetail: {
+    notFound: '未找到项目',
+    backToProjects: '返回项目',
+    export: '导出',
+    exportProject: '导出项目',
+    noExportPermission: '您没有导出项目的权限',
+    noEditPermission: '您没有编辑项目的权限',
+    partOf: '属于:',
+    priorityLabel: '优先级:',
+    noPrints: '此项目暂无打印',
+    status: {
+      active: '进行中',
+      completed: '已完成',
+      archived: '已归档',
+    },
+    priority: {
+      low: '低',
+      normal: '普通',
+      high: '高',
+      urgent: '紧急',
+    },
+    dueDate: {
+      overdue: '已逾期',
+      today: '今天到期',
+      daysLeft: '还有 {{count}} 天',
+    },
+    progress: {
+      platesProgress: '板进度',
+      partsProgress: '零件进度',
+      printJobs: '打印任务',
+      parts: '零件',
+      percentComplete: '{{percent}}% 完成',
+      remaining: '剩余 {{count}} 个',
+    },
+    stats: {
+      printJobs: '打印任务',
+      total: '总计',
+      failed: '{{count}} 个失败',
+      partsPrinted: '已打印 {{count}} 个零件',
+      printTime: '打印时间',
+      filamentUsed: '耗材用量',
+    },
+    cost: {
+      title: '成本追踪',
+      filamentCost: '耗材成本',
+      energy: '能源',
+      budget: '预算',
+      remaining: '剩余',
+    },
+    subProjects: {
+      title: '子项目 ({{count}})',
+    },
+    notes: {
+      title: '备注',
+      noEditPermission: '您没有编辑备注的权限',
+      placeholder: '添加关于此项目的备注...',
+      empty: '暂无备注。点击编辑添加备注。',
+    },
+    files: {
+      title: '文件',
+      linkFolders: '从文件管理器链接文件夹',
+      forQuickAccess: '到此项目以便快速访问。',
+      fileCount: '{{count}} 个文件',
+      empty: '未链接文件夹。前往文件管理器将文件夹链接到此项目。',
+    },
+    bom: {
+      title: '材料清单',
+      acquired: '已获取 {{completed}}/{{total}}',
+      showAll: '显示全部',
+      hideDone: '隐藏已完成',
+      addPart: '添加零件',
+      noAddPermission: '您没有添加零件的权限',
+      partNamePlaceholder: '零件名称(例如:M3x8 螺丝)',
+      partName: '零件名称',
+      qty: '数量',
+      price: '价格 ({{currency}})',
+      sourcingUrlPlaceholder: '采购链接(可选)',
+      remarksPlaceholder: '备注(可选)',
+      deletePart: '删除零件',
+      deleteConfirm: '确定要删除"{{name}}"吗?',
+      noUpdatePermission: '您没有更新零件的权限',
+      noEditPermission: '您没有编辑零件的权限',
+      noDeletePermission: '您没有删除零件的权限',
+      totalCost: '总成本:',
+      empty: '材料清单中没有零件。添加硬件、电子元件或其他组件以跟踪需要采购的物品。',
+    },
+    timeline: {
+      title: '活动时间线',
+      empty: '暂无活动。',
+    },
+    template: {
+      saveAsTemplate: '保存为模板',
+      noCreatePermission: '您没有创建模板的权限',
+    },
+    queue: {
+      title: '队列',
+      viewAll: '查看全部',
+      printing: '{{count}} 个打印中',
+      queued: '{{count}} 个排队中',
+    },
+    prints: {
+      title: '打印 ({{count}})',
+    },
+    toast: {
+      projectUpdated: '项目已更新',
+      partAdded: '零件已添加',
+      partRemoved: '零件已移除',
+      exportFailed: '导出失败',
+      projectExported: '项目已导出',
+      templateCreated: '模板已创建',
+    },
+  },
+
+  // System info
+  system: {
+    title: '系统信息',
+    version: '版本',
+    uptime: '运行时间',
+    cpuUsage: 'CPU 使用率',
+    memoryUsage: '内存使用率',
+    diskUsage: '磁盘使用率',
+    networkInfo: '网络信息',
+    logs: '日志',
+    debugMode: '调试模式',
+    enableDebug: '启用调试日志',
+    disableDebug: '禁用调试日志',
+    downloadLogs: '下载日志',
+    clearLogs: '清除日志',
+    dockerInfo: 'Docker 信息',
+    containerName: '容器名称',
+    imageName: '镜像名称',
+    platform: '平台',
+    architecture: '架构',
+  },
+
+  // Library (K Profiles)
+  library: {
+    title: '耗材库',
+    addFilament: '添加耗材',
+    editFilament: '编辑耗材',
+    deleteFilament: '删除耗材',
+    vendor: '厂商',
+    material: '材料',
+    color: '颜色',
+    kFactor: 'K 值',
+    temperature: '温度',
+    noFilaments: '耗材库中没有耗材',
+    deleteConfirm: '确定要删除此耗材吗?',
+    importFromPrinter: '从打印机导入',
+    exportToFile: '导出到文件',
+  },
+
+  // Spoolman
+  spoolman: {
+    title: 'Spoolman 集成',
+    enabled: 'Spoolman 已启用',
+    url: 'Spoolman URL',
+    connected: '已连接',
+    disconnected: '未连接',
+    testConnection: '测试连接',
+    sync: '同步',
+    syncing: '同步中...',
+    lastSync: '上次同步',
+    linkToSpoolman: '链接到 Spoolman',
+    openInSpoolman: '在 Spoolman 中打开',
+    unlinkSpool: '取消链接耗材',
+    selectSpool: '选择耗材',
+    noUnlinkedSpools: '无未链接的耗材',
+    linkSuccess: '耗材已成功链接到 Spoolman',
+    linkFailed: '链接耗材失败',
+    spoolId: '耗材 ID',
+    fillSourceLabel: '(Spoolman)',
+    weight: '重量',
+    remaining: '剩余',
+    disableWeightSync: '禁用 AMS 估计重量同步',
+    disableWeightSyncDesc: '不从 AMS 估计值更新剩余容量。如果您更喜欢 Spoolman 的用量追踪而非 AMS 百分比估计,请使用此选项。新耗材仍将使用 AMS 估计值作为初始重量。',
+    reportPartialUsage: '报告失败打印的部分用量',
+    reportPartialUsageDesc: '当打印失败或被取消时,根据层进度报告估计的耗材使用量。',
+  },
+
+  // Inventory
+  inventory: {
+    title: '耗材库存',
+    addSpool: '添加耗材',
+    editSpool: '编辑耗材',
+    material: '材料',
+    selectMaterial: '选择材料...',
+    subtype: '子类型',
+    brand: '品牌',
+    searchBrand: '搜索品牌...',
+    useCustomBrand: '使用"{{brand}}"',
+    useCustomMaterial: '使用自定义材料:{{material}}',
+    colorName: '颜色名称',
+    colorNamePlaceholder: '翡翠白、烈焰红...',
+    color: '颜色',
+    hexColor: '十六进制颜色',
+    pickColor: '选择自定义颜色',
+    labelWeight: '标签重量',
+    coreWeight: '空盘重量',
+    searchSpoolWeight: '搜索耗材重量...',
+    weightUsed: '已使用',
+    currentWeight: '剩余重量',
+    measuredWeight: '称量重量',
+    costPerKg: '每公斤成本',
+    measuredWeightError: '称量重量必须在 {{min}}g 到 {{max}}g 之间。',
+    slicerFilament: '切片耗材',
+    slicerFilamentName: '切片预设名称',
+    slicerPreset: '切片预设',
+    searchPresets: '搜索耗材预设...',
+    selectedPreset: '已选择',
+    noPresetsFound: '未找到预设',
+    tempOverrides: '温度覆盖',
+    note: '备注',
+    notePlaceholder: '关于此耗材的任何备注...',
+    archive: '归档',
+    restore: '恢复',
+    noSpools: '暂无耗材。添加您的第一个耗材开始使用。',
+    noManualSpools: '没有手动添加的耗材。请先向库存中添加耗材。',
+    kProfiles: 'K 值配置',
+    addKProfile: '添加 K 值配置',
+    assignSpool: '分配耗材',
+    unassignSpool: '取消分配',
+    assignSuccess: '耗材已分配,AMS 槽位已配置',
+    assignFailed: '分配耗材失败',
+    selectSpool: '选择要分配到此槽位的耗材',
+    assigned: '已分配',
+    assigning: '分配中...',
+    searchSpools: '搜索耗材...',
+    allMaterials: '所有材料',
+    filterByBrand: '按品牌筛选...',
+    showArchived: '显示已归档',
+    quickAdd: '快速添加(库存)',
+    quantity: '数量',
+    stock: '库存',
+    configured: '已配置',
+    spoolsCreated: '已创建 {{count}} 个耗材',
+    spoolCreated: '耗材已创建',
+    spoolUpdated: '耗材已更新',
+    spoolDeleted: '耗材已删除',
+    spoolArchived: '耗材已归档',
+    spoolRestored: '耗材已恢复',
+    deleteConfirm: '确定要删除此耗材吗?此操作无法撤销。',
+    archiveConfirm: '确定要归档此耗材吗?',
+    advancedSettings: '高级设置',
+    filamentInfoTab: '耗材信息',
+    paProfileTab: 'PA 配置',
+    filamentInfo: '耗材',
+    additional: '附加',
+    loadingPresets: '加载云端预设中...',
+    cloudConnected: '云端已连接',
+    cloudNotConnected: '云端未连接(使用默认值)',
+    recentColors: '最近',
+    searchColors: '搜索颜色...',
+    searchResults: '搜索结果',
+    allColors: '所有颜色',
+    commonColors: '常用颜色',
+    showLess: '显示更少',
+    showAll: '显示全部',
+    noColorsFound: '没有颜色匹配您的搜索',
+    noResults: '未找到匹配项',
+    selectMaterialFirst: '请先在耗材信息选项卡中选择材料。',
+    noPrintersConfigured: '未配置打印机。添加打印机以使用 PA 配置。',
+    matchingFilter: '匹配',
+    anyBrand: '任何品牌',
+    anyVariant: '任何变体',
+    autoSelect: '自动选择',
+    matches: '匹配',
+    match: '匹配',
+    noMatches: '无匹配',
+    connected: '已连接',
+    offline: '离线',
+    printerOffline: '打印机离线。连接后查看校准配置。',
+    noKProfilesMatch: '没有 K 值配置匹配所选耗材。',
+    leftNozzle: '左喷嘴',
+    rightNozzle: '右喷嘴',
+    profilesSelected: '个校准配置已选择',
+    totalInventory: '总库存',
+    totalConsumed: '总消耗',
+    byMaterial: '按材料',
+    inPrinter: '在打印机中',
+    lowStock: '库存不足',
+    sinceTracking: '自开始追踪',
+    loadedInAms: '已装载到 AMS/外置',
+    remaining: '剩余',
+    lowStockThreshold: '剩余 <20%',
+    weightCheck: '重量检查',
+    lastWeighed: '上次称量',
+    neverWeighed: '从未称量',
+    search: '搜索耗材...',
+    showing: '显示',
+    to: '到',
+    of: '共',
+    show: '显示',
+    spools: '个耗材',
+    spool: '个耗材',
+    page: '页',
+    noSpoolsMatch: '未找到结果',
+    noSpoolsMatchDesc: '尝试调整您的搜索或筛选条件。',
+    active: '活跃',
+    archived: '已归档',
+    all: '全部',
+    used: '已使用',
+    new: '新的',
+    clearFilters: '清除筛选',
+    table: '表格',
+    cards: '卡片',
+    net: '净重',
+    groupSimilar: '分组',
+    groupedSpools: '{{count}} 个相同耗材',
+    groupedRows: '行',
+    columns: '列',
+    configureColumns: '配置列',
+    configureColumnsDesc: '拖动以重新排序列或使用箭头。使用眼睛图标切换可见性。',
+    visible: '可见',
+    reset: '重置',
+    cancel: '取消',
+    applyChanges: '应用更改',
+    moveUp: '上移',
+    moveDown: '下移',
+    hideColumn: '隐藏列',
+    showColumn: '显示列',
+    linkToSpool: '链接到耗材',
+    tagLinked: '标签已链接到耗材',
+    tagLinkFailed: '链接标签失败',
+    tagAlreadyLinked: '标签已链接到其他耗材',
+    unknownTag: '检测到未知 RFID 标签',
+    usageHistory: '使用历史',
+    noUsageHistory: '暂无使用记录',
+    printName: '打印名称',
+    weightConsumed: '消耗重量',
+    clearHistory: '清除',
+    historyCleared: '使用历史已清除',
+    fillSourceLabel: '(库存)',
+  },
+
+  // Timelapse
+  timelapse: {
+    title: '延时摄影',
+    create: '创建延时摄影',
+    download: '下载',
+    delete: '删除',
+    preview: '预览',
+    frameRate: '帧率',
+    quality: '质量',
+    processing: '处理中...',
+    noTimelapses: '无可用延时摄影',
+  },
+
+  // AMS
+  ams: {
+    title: 'AMS',
+    slot: '槽位',
+    empty: '空',
+    emptySlot: '空槽位',
+    unknown: '未知',
+    humidity: '湿度',
+    temperature: '温度',
+    filamentType: '耗材类型',
+    filamentColor: '颜色',
+    remaining: '剩余',
+    history: 'AMS 历史',
+    noHistory: '无可用历史',
+    configureSlot: '配置槽位',
+    externalSpool: '外置耗材',
+    profile: '配置',
+    kFactor: 'K 值',
+    fill: '填充',
+    configure: '配置',
+    used: '已使用',
+    remainingUnit: '剩余',
+  },
+
+  // Print modal
+  printModal: {
+    title: '开始打印',
+    selectPrinter: '选择打印机',
+    selectPlate: '选择板',
+    filamentMapping: '耗材映射',
+    totalCost: '总成本:',
+    slotRemainingShort: ' - 剩余 {{grams}}g',
+    printSettings: '打印设置',
+    bedLeveling: '热床调平',
+    flowCalibration: '流量校准',
+    vibrationCalibration: '振动校准',
+    layerInspection: '首层检查',
+    timelapse: '延时摄影',
+    startPrint: '开始打印',
+    addToQueue: '添加到队列',
+    cancel: '取消',
+    noPrintersAvailable: '无可用打印机',
+    printerBusy: '打印机忙碌',
+    printerOffline: '打印机离线',
+    sameTypeDifferentColor: '相同类型,不同颜色',
+    filamentTypeNotLoaded: '耗材类型未装载',
+    openCalendar: '打开日历',
+    leftNozzle: '左',
+    rightNozzle: '右',
+    leftNozzleTooltip: '左喷嘴',
+    rightNozzleTooltip: '右喷嘴',
+    filamentOverride: '耗材覆盖',
+    filamentOverrideHint: '可选覆盖用于基于模型的耗材分配。调度器将使用您选择的耗材而不是原始 3MF 值进行匹配。',
+    originalFilament: '原始',
+    overrideWith: '覆盖为',
+    resetToOriginal: '恢复为原始',
+  },
+
+  // Backup
+  backup: {
+    title: '备份与恢复',
+    createBackup: '创建备份',
+    restoreBackup: '恢复备份',
+    restoreDescription: '从备份文件替换所有数据',
+    downloadBackup: '下载备份',
+    uploadBackup: '上传备份',
+    lastBackup: '上次备份',
+    autoBackup: '自动备份',
+    backupNow: '立即备份',
+    restoreWarning: '警告:恢复备份将覆盖所有当前数据。',
+    includeArchives: '包含归档',
+    includeSettings: '包含设置',
+    includeProfiles: '包含配置文件',
+    backupSuccess: '备份创建成功',
+    restoreSuccess: '备份恢复成功',
+    backupFailed: '备份失败',
+    restoreFailed: '恢复失败',
+    restoreNote: '恢复期间虚拟打印机将停止',
+  },
+
+  // Tags
+  tags: {
+    title: '标签',
+    addTag: '添加标签',
+    editTag: '编辑标签',
+    deleteTag: '删除标签',
+    tagName: '标签名称',
+    tagColor: '标签颜色',
+    noTags: '无标签',
+    deleteConfirm: '确定要删除此标签吗?',
+    manageTags: '管理标签',
+  },
+
+  // Upload modal (archives)
+  uploadModal: {
+    title: '上传 3MF 文件',
+    dragDrop: '将 .3mf 文件拖放到此处',
+    or: '或',
+    browseFiles: '浏览文件',
+    extractionInfo: '将从 3MF 文件元数据中自动提取打印机型号。',
+    uploaded: '已上传',
+    failed: '失败',
+    uploading: '上传中...',
+    upload: '上传',
+    uploadFailed: '上传失败',
+  },
+
+  // Edit Archive Modal
+  editArchive: {
+    title: '编辑归档',
+    name: '名称',
+    namePlaceholder: '打印名称',
+    printer: '打印机',
+    noPrinter: '无打印机',
+    project: '项目',
+    noProject: '无项目',
+    itemsPrinted: '打印数量',
+    itemsPrintedHelp: '此打印任务中生产的物品数量',
+    notes: '备注',
+    notesPlaceholder: '添加关于此打印的备注...',
+    externalLink: '外部链接',
+    externalLinkPlaceholder: 'https://printables.com/model/...',
+    externalLinkHelp: '链接到 Printables、Thingiverse 或其他来源',
+    tags: '标签',
+    tagsPlaceholder: '添加标签...',
+    addMoreTags: '添加更多标签...',
+    matchingTags: '匹配"{{query}}"',
+    existingTags: '现有标签',
+    clickToAdd: '(点击添加)',
+    status: '状态',
+    failureReason: '失败原因',
+    selectReason: '选择原因...',
+    photos: '打印成品照片',
+    photosHelp: '点击 + 添加打印成品照片',
+    printResult: '打印成品',
+    saving: '保存中...',
+    failureReasons: {
+      adhesionFailure: '附着力失败',
+      spaghettiDetached: '拉丝 / 脱落',
+      layerShift: '层偏移',
+      cloggedNozzle: '喷嘴堵塞',
+      filamentRunout: '耗材用完',
+      warping: '翘曲',
+      stringing: '拉丝',
+      underExtrusion: '挤出不足',
+      powerFailure: '断电',
+      userCancelled: '用户取消',
+      other: '其他',
+    },
+    statuses: {
+      completed: '已完成',
+      failed: '失败',
+      aborted: '已取消',
+      printing: '打印中',
+    },
+  },
+
+  // K-Profiles
+  kProfiles: {
+    title: 'K 值配置',
+    noPrintersConfigured: '未配置打印机',
+    addPrinterInSettings: '在设置中添加打印机以管理 K 值配置',
+    noActivePrinters: '无活跃打印机',
+    enablePrinterConnection: '启用打印机连接以查看其 K 值配置',
+    loadingProfiles: '加载 K 值配置中...',
+    printerOffline: '打印机离线',
+    printerOfflineDesc: '所选打印机未连接。开启电源以查看 K 值配置。',
+    noMatchingProfiles: '无匹配的配置',
+    noMatchingProfilesDesc: '没有配置匹配您的搜索条件',
+    noKProfiles: '无 K 值配置',
+    noKProfilesDesc: '未找到 {{diameter}}mm 喷嘴的压力推进配置',
+    createFirstProfile: '创建第一个配置',
+    printer: '打印机',
+    nozzle: '喷嘴',
+    refresh: '刷新',
+    addProfile: '添加配置',
+    export: '导出',
+    import: '导入',
+    select: '选择',
+    selectAll: '全选',
+    delete: '删除',
+    searchPlaceholder: '按名称或耗材搜索...',
+    allExtruders: '所有挤出机',
+    leftOnly: '仅左侧',
+    rightOnly: '仅右侧',
+    allFlow: '所有流量',
+    hfOnly: '仅高流量',
+    sOnly: '仅标准',
+    sortName: '排序:名称',
+    sortKValue: '排序:K 值',
+    sortFilament: '排序:耗材',
+    leftExtruder: '左挤出机',
+    rightExtruder: '右挤出机',
+    modal: {
+      addTitle: '添加 K 值配置',
+      editTitle: '编辑 K 值配置',
+      profileName: '配置名称',
+      profileNamePlaceholder: '我的 PLA 配置',
+      kValue: 'K 值',
+      kValuePlaceholder: '0.020',
+      kValueHelp: '典型范围:PLA 0.01 - 0.06,PETG 0.02 - 0.10',
+      filament: '耗材',
+      selectFilament: '选择耗材...',
+      noFilamentsHelp: '未找到耗材。请先在 Bambu Studio 中创建 K 值配置。',
+      flowType: '流量类型',
+      highFlow: '高流量',
+      standard: '标准',
+      nozzleSize: '喷嘴尺寸',
+      extruder: '挤出机',
+      extruders: '挤出机',
+      left: '左',
+      right: '右',
+      notes: '备注(本地存储)',
+      notesPlaceholder: '添加关于此配置的备注...',
+      notesHelp: '备注保存在 Bambuddy 中,不在打印机上',
+      syncing: '与打印机同步中...',
+      savingExtruder: '保存到挤出机 {{current}}/{{total}}...',
+      pleaseWait: '请稍候',
+    },
+    deleteConfirm: {
+      title: '删除配置',
+      cannotUndo: '此操作无法撤销',
+      message: '确定要从打印机删除"{{name}}"吗?',
+    },
+    bulkDelete: {
+      title: '删除配置',
+      cannotUndo: '此操作无法撤销',
+      message: '确定要从打印机删除 {{count}} 个选中的配置吗?',
+    },
+    toast: {
+      profileSaved: 'K 值配置已保存',
+      profilesSaved: 'K 值配置已保存到 {{count}} 个挤出机',
+      selectAtLeastOneExtruder: '请至少选择一个挤出机',
+      profileDeleted: 'K 值配置已删除',
+      profilesDeleted: '已删除 {{count}} 个配置',
+      exportedProfiles: '已导出 {{count}} 个配置',
+      importedProfiles: '已导入 {{count}} / {{total}} 个配置',
+      noProfilesToExport: '无可导出的配置',
+      invalidFileFormat: '无效的文件格式',
+      failedToParseImport: '解析导入文件失败',
+      failedToSaveBatch: '批量保存 K 值配置失败',
+      noteSaved: '备注已保存',
+      failedToSaveNote: '保存备注失败',
+    },
+    permission: {
+      noRead: '您没有刷新配置的权限',
+      noCreate: '您没有添加配置的权限',
+      noUpdate: '您没有更新 K 值配置的权限',
+      noDelete: '您没有删除 K 值配置的权限',
+      noExport: '您没有导出配置的权限',
+      noImport: '您没有导入配置的权限',
+    },
+  },
+
+  // Virtual Printer
+  virtualPrinter: {
+    title: '虚拟打印机',
+    running: '运行中',
+    stopped: '已停止',
+    description: {
+      default: '启用虚拟打印机,使其在 Bambu Studio 和 OrcaSlicer 中可见。发送到此打印机的文件将直接归档而不打印。',
+      proxy: '启用代理,将切片软件流量中继到真实打印机,允许在任何网络上远程打印。',
+    },
+    enable: {
+      title: '启用虚拟打印机',
+      visibleInSlicer: '在切片软件发现中显示为"Bambuddy"',
+      proxyingTo: '代理到 {{name}}',
+      notActive: '未激活',
+    },
+    model: {
+      title: '打印机型号',
+      description: '选择要模拟的打印机型号。',
+      restartWarning: '更改型号将重启虚拟打印机',
+    },
+    accessCode: {
+      title: '访问码',
+      isSet: '访问码已设置',
+      notSet: '未设置访问码 - 需要设置才能启用',
+      placeholder: '输入 8 位字符代码',
+      placeholderChange: '输入新代码以更改',
+      hint: '必须恰好 8 个字符。切片软件使用此代码进行认证。',
+      charCount: '({{count}}/8)',
+    },
+    targetPrinter: {
+      title: '目标打印机',
+      configured: '代理目标已配置',
+      notConfigured: '未选择目标打印机 - 代理模式需要设置',
+      placeholder: '选择打印机...',
+      hint: '选择要将切片软件流量代理到的打印机。打印机必须处于局域网模式。',
+      noPrinters: '未配置打印机。请先添加打印机以使用代理模式。',
+    },
+    remoteInterface: {
+      title: '网络接口覆盖',
+      configured: '接口覆盖已激活',
+      optional: '可选 - 当自动检测的 IP 不正确时使用(例如多网卡、Docker、VPN)',
+      placeholder: '自动检测(默认)...',
+      hint: '覆盖通过 SSDP 广播并在 TLS 证书中使用的 IP 地址。在 Bambuddy 有多个网络接口时很有用。',
+    },
+    mode: {
+      title: '模式',
+      archive: '归档',
+      archiveDesc: '立即归档文件',
+      review: '审核',
+      reviewDesc: '归档前审核',
+      queue: '队列',
+      queueDesc: '归档并添加到队列',
+      proxy: '代理',
+      proxyDesc: '中继到真实打印机',
+    },
+    setupRequired: {
+      title: '需要设置',
+      description: '虚拟打印机功能需要额外的系统配置才能工作。包括端口转发、防火墙规则和平台特定设置。',
+      readGuide: '启用前请阅读设置指南',
+    },
+    howItWorks: {
+      title: '工作原理',
+      step1: '在同一局域网中,虚拟打印机会通过发现机制自动出现在您的切片软件(Bambu Studio / OrcaSlicer)中。从其他网络,通过 IP 地址和访问码手动添加。',
+      step2: '在归档、审核和队列模式下,使用切片软件中的"发送"按钮将 3MF 文件上传到 Bambuddy。切片软件会显示"打印成功"— 文件已存储,未打印。',
+      step3: '在代理模式下,虚拟打印机将所有流量中继到真实打印机 — 打印会立即开始,就像直接连接一样。',
+    },
+    status: {
+      title: '状态详情',
+      printerName: '打印机名称',
+      model: '型号',
+      serialNumber: '序列号',
+      mode: '模式',
+      pendingFiles: '待处理文件',
+      targetPrinter: '目标打印机',
+      ftpPort: 'FTP 端口',
+      mqttPort: 'MQTT 端口',
+      ftpConnections: 'FTP 连接',
+      mqttConnections: 'MQTT 连接',
+    },
+    toast: {
+      updated: '虚拟打印机设置已更新',
+      failedToUpdate: '更新设置失败',
+      accessCodeRequired: '请先设置访问码',
+      targetPrinterRequired: '请先选择目标打印机',
+      bindIpRequired: '请先设置绑定 IP',
+      accessCodeEmpty: '访问码不能为空',
+      accessCodeLength: '访问码必须恰好 8 个字符',
+      created: '虚拟打印机已创建',
+      failedToCreate: '创建虚拟打印机失败',
+      deleted: '虚拟打印机已删除',
+      failedToDelete: '删除虚拟打印机失败',
+    },
+    list: {
+      title: '虚拟打印机',
+      add: '添加',
+      addFirst: '添加虚拟打印机',
+      empty: '未配置虚拟打印机。添加一个以开始使用。',
+    },
+    bindIp: {
+      title: '绑定接口',
+      placeholder: '选择接口...',
+      hint: '此虚拟打印机绑定的网络接口。每台打印机必须唯一。',
+    },
+    proxy: {
+      accessCodeHint: '在代理模式下,在切片软件中使用目标打印机的访问码。连接会透明转发到真实打印机。',
+    },
+    addDialog: {
+      title: '添加虚拟打印机',
+      name: '名称',
+      hint: '创建后可以配置访问码、目标打印机和其他设置。',
+      create: '创建',
+    },
+    deleteConfirm: {
+      title: '删除虚拟打印机',
+      message: '确定要删除"{{name}}"吗?这将停止此打印机的所有服务。',
+    },
+  },
+
+  // Model Viewer
+  modelViewer: {
+    openInSlicer: '在切片软件中打开',
+    tabs: {
+      model: '3D 模型',
+      gcode: 'G-code 预览',
+    },
+    notAvailable: '不可用',
+    notSliced: '未切片',
+    plates: '板',
+    allPlates: '所有板',
+    plateNumber: '板 {{number}}',
+    plateCount: '{{count}} 个板',
+    plateCount_other: '{{count}} 个板',
+    objectCount: '{{count}} 个对象',
+    objectCount_other: '{{count}} 个对象',
+    filamentCount: '{{count}} 种耗材',
+    filamentCount_other: '{{count}} 种耗材',
+    eta: '预计 {{minutes}} 分钟',
+    noPreview: '此文件无可用预览',
+    pagination: {
+      pageOf: '第 {{current}} / {{total}} 页',
+      prev: '上一页',
+      next: '下一页',
+    },
+    errors: {
+      failedToLoad: '加载文件失败',
+      noMeshes: '3MF 文件中未找到网格',
+      unsupportedFormat: '不支持的文件格式',
+    },
+  },
+
+  // Maintenance type descriptions
+  maintenanceDescriptions: {
+    lubricateCarbonRods: '在碳纤维杆上涂抹润滑剂以确保顺畅运动',
+    lubricateRails: '在线性导轨上涂抹润滑剂以确保顺畅运动',
+    cleanNozzle: '清洁热端和喷嘴以防止堵塞',
+    checkBelts: '检查皮带张力以确保打印精度',
+    cleanBuildPlate: '清洁构建板以获得更好的附着力',
+    checkExtruder: '检查挤出机齿轮磨损情况',
+    checkCooling: '确保冷却风扇正常工作',
+    generalInspection: '打印机综合检查',
+    cleanCarbonRods: '清洁碳纤维杆以减少摩擦',
+    cleanLinearRails: '擦拭线性导轨以清除灰尘和碎屑',
+    checkPtfeTube: '检查 PTFE 管的磨损或损坏',
+    replaceHepaFilter: '更换 HEPA 过滤器以保证空气质量',
+    replaceCarbonFilter: '更换活性炭过滤器',
+    lubricateLeftNozzleRail: '润滑左喷嘴导轨(H2 系列)',
+  },
+
+  // Smart Plugs
+  smartPlugs: {
+    offline: '离线',
+    admin: '管理',
+    openPlugAdminPage: '打开插座管理页面',
+    deleteSmartPlug: '删除智能插座',
+    turnOnSmartPlug: '开启智能插座',
+    turnOffSmartPlug: '关闭智能插座',
+    turnOn: '开启',
+    turnOff: '关闭',
+    addSmartPlug: {
+      scanningNetwork: '扫描网络中...',
+      chooseEntity: '选择实体...',
+      connectionFailed: '连接失败',
+      searchEntities: '搜索实体...',
+      searchPowerSensors: '搜索功率传感器...',
+      searchEnergySensors: '搜索能量传感器...',
+      placeholders: {
+        plugName: '客厅插座',
+        mqttStateOnValue: 'ON、true、1',
+        mqttSameAsPower: '与功率主题相同,或不同',
+      },
+    },
+  },
+
+  // Rich Text Editor
+  richTextEditor: {
+    bold: '粗体',
+    italic: '斜体',
+    underline: '下划线',
+    bulletList: '无序列表',
+    numberedList: '有序列表',
+    alignLeft: '左对齐',
+    alignCenter: '居中对齐',
+    alignRight: '右对齐',
+    addLink: '添加链接',
+    removeLink: '移除链接',
+  },
+
+  // External Links
+  externalLinks: {
+    noLinksConfigured: '未配置外部链接',
+    deleteLink: '删除链接',
+    removeCustomIcon: '移除自定义图标',
+    openInNewTab: '在新标签页中打开',
+    placeholders: {
+      linkName: '我的链接',
+    },
+  },
+
+  // Keyboard Shortcuts Modal
+  keyboardShortcuts: {
+    title: '键盘快捷键',
+    navigation: '导航',
+    archivesSection: '归档',
+    kProfilesSection: 'K 值配置',
+    generalSection: '通用',
+    shortcuts: {
+      goToPrinters: '前往打印机',
+      goToArchives: '前往归档',
+      goToQueue: '前往队列',
+      goToStats: '前往统计',
+      goToProfiles: '前往云端配置',
+      goToSettings: '前往设置',
+      focusSearch: '聚焦搜索',
+      openUploadModal: '打开上传对话框',
+      clearSelection: '清除选择 / 取消焦点',
+      contextMenu: '卡片右键菜单',
+      refreshProfiles: '刷新配置',
+      newProfile: '新建配置',
+      exitSelectionMode: '退出选择模式',
+      showHelp: '显示此帮助',
+    },
+    footer: '按 Esc 或点击外部关闭',
+  },
+
+  // Notification Log
+  notificationLog: {
+    title: '通知日志',
+    events: {
+      printStarted: '打印开始',
+      printComplete: '打印完成',
+      printFailed: '打印失败',
+      printStopped: '打印停止',
+      progress: '进度',
+      printerOffline: '打印机离线',
+      printerError: '打印机错误',
+      lowFilament: '耗材不足',
+      maintenanceDue: '维护到期',
+      test: '测试',
+    },
+    timeAgo: {
+      justNow: '刚刚',
+      minutesAgo: '{{minutes}} 分钟前',
+      hoursAgo: '{{hours}} 小时前',
+    },
+  },
+
+  // Restore/Backup Modal
+  restoreBackup: {
+    title: '恢复备份',
+    restoring: '恢复中...',
+    restoreComplete: '恢复完成',
+    restoreFailed: '恢复失败',
+    importSettings: '从备份文件导入设置',
+    pleaseWait: '请稍候,正在恢复您的数据',
+    clickToSelect: '点击选择备份文件(.json 或 .zip)',
+    howDuplicateHandling: '重复处理方式:',
+    categories: {
+      printers: '打印机',
+      smartPlugs: '智能插座',
+      notificationProviders: '通知提供商',
+      filaments: '耗材',
+      archives: '归档',
+      pendingUploads: '待处理上传',
+      settingsTemplates: '设置和模板',
+    },
+    matchingInfo: {
+      printers: '按序列号匹配',
+      smartPlugs: '按 IP 地址匹配',
+      notificationProviders: '按名称匹配',
+      filaments: '按名称 + 类型 + 品牌匹配',
+      archives: '按内容哈希匹配',
+      pendingUploads: '按文件名匹配',
+      settingsTemplates: '始终覆盖',
+    },
+    replaceExisting: '替换现有数据',
+    keepExisting: '保留现有数据',
+    replaceDescription: '用备份数据覆盖已存在的项目',
+    keepDescription: '仅恢复不存在的项目',
+    caution: '注意:',
+    cautionText: '覆盖将用备份数据替换您当前的配置。出于安全考虑,打印机访问码永远不会被覆盖。',
+    itemsRestored: '已恢复项目',
+    itemsSkipped: '已跳过项目',
+    restored: '已恢复',
+    skipped: '已跳过(已存在)',
+    filesLabel: '文件(3MF、缩略图等)',
+    newApiKeysGenerated: '已生成新 API 密钥',
+    newApiKeysWarning: '这些密钥仅显示一次。请立即复制!',
+    processingBackup: '处理备份文件中...',
+    noDataFound: '备份文件中未找到可恢复的数据。',
+    failedToRestore: '恢复备份失败。请检查文件格式。',
+  },
+
+  // Backup Export Modal
+  backupExport: {
+    title: '导出备份',
+    selectData: '选择要包含的数据',
+    selectAll: '全选',
+    selectNone: '全不选',
+    categoryDescriptions: {
+      settings: '语言、主题、更新偏好',
+      notifications: 'ntfy、Pushover、Discord 等',
+      templates: '自定义消息模板',
+      smartPlugs: 'Tasmota 插座配置',
+      externalLinks: '侧边栏外部服务链接',
+      printers: '打印机信息(不含访问码)',
+      plateDetection: '空打印板参考图像',
+      filaments: '耗材类型和成本',
+      maintenance: '自定义维护计划',
+      archives: '所有打印数据 + 文件(3MF、缩略图、照片)',
+      projects: '项目、材料清单和附件',
+      pendingUploads: '虚拟打印机待审核的上传',
+      apiKeys: 'Webhook API 密钥(导入时生成新密钥)',
+    },
+    requiresPrinters: '需要选择打印机',
+    zipFileWarning: '将创建 ZIP 文件。',
+    zipFileDescription: '包括所有 3MF 文件、缩略图、延时摄影和照片。这可能需要一些时间并生成较大的文件。',
+    includeAccessCodes: '包含访问码',
+    includeAccessCodesDescription: '用于转移到另一台机器',
+    includeAccessCodesWarning: '访问码将以明文形式包含。请妥善保管此备份文件!',
+    categoriesSelected: '已选择 {{selectedCount}} 个类别',
+  },
+
+  // Pending Uploads Panel
+  pendingUploads: {
+    placeholders: {
+      notes: '添加关于此打印的备注...',
+    },
+    discardUpload: '丢弃上传',
+    archiveAllUploads: '归档所有上传',
+    discardAllUploads: '丢弃所有上传',
+    archive: '归档',
+    timeAgo: {
+      justNow: '刚刚',
+      minutesAgo: '{{minutes}} 分钟前',
+      hoursAgo: '{{hours}} 小时前',
+      daysAgo: '{{days}} 天前',
+    },
+  },
+
+  // API Browser
+  apiBrowser: {
+    placeholders: {
+      requestBody: 'JSON 请求体...',
+      searchEndpoints: '搜索端点...',
+    },
+  },
+
+  // Configure AMS Slot Modal
+  configureAmsSlot: {
+    title: '配置 AMS 槽位',
+    slotConfigured: '槽位已配置!',
+    configuringSlot: '正在配置槽位:',
+    slotLabel: '{{ams}} 槽位 {{slot}}',
+    searchPresets: '搜索预设...',
+    colorPlaceholder: '颜色名称或十六进制(例如:棕色、FF8800)',
+    clearCustomColor: '清除自定义颜色',
+    noCloudPresets: '无云端预设。登录拓竹云以同步。',
+    noPresetsAvailable: '无可用预设。登录拓竹云或导入本地配置。',
+    noMatchingPresets: '未找到匹配的预设。',
+    custom: '自定义',
+    builtin: '内置',
+    settingsSentToPrinter: '设置已发送到打印机',
+    filamentProfile: '耗材配置',
+    kProfileLabel: 'K 值配置(压力推进)',
+    filteringFor: '筛选:{{material}}',
+    noKProfile: '无 K 值配置(使用默认值 0.020)',
+    noMatchingKProfiles: '未找到匹配的 K 值配置。将使用默认 K=0.020。',
+    selectFilamentFirst: '请先选择耗材配置',
+    kFromCalibration: 'K={{value}}(来自打印机校准)',
+    customColorLabel: '自定义颜色(可选)',
+    presetColors: '{{name}} 颜色:',
+    showLessColors: '显示更少颜色',
+    showMoreColors: '显示更多颜色',
+    clear: '清除',
+    hexLabel: '十六进制:#{{hex}}',
+    resetting: '重置中...',
+    resetSlot: '重置槽位',
+    cancel: '取消',
+    configuring: '配置中...',
+    configureSlot: '配置槽位',
+  },
+
+  // GitHub Backup Settings
+  githubBackup: {
+    title: 'GitHub 备份',
+    history: '历史',
+    downloadBackup: '下载备份',
+    restoreBackup: '恢复备份',
+    noBackupsYet: '暂无备份',
+  },
+
+  // Email Settings
+  emailSettings: {
+    placeholders: {
+      fromName: 'BamBuddy',
+    },
+  },
+
+  // Tag Management Modal
+  tagManagement: {
+    searchTags: '搜索标签...',
+    renameTag: '重命名标签',
+    deleteTag: '删除标签',
+  },
+
+  // Notification Template Editor
+  notificationTemplates: {
+    placeholders: {
+      title: '通知标题...',
+      body: '通知正文...',
+    },
+  },
+
+  // Batch Tag Modal
+  batchTag: {
+    placeholders: {
+      newTag: '输入新标签...',
+    },
+  },
+
+  // Photo Gallery Modal
+  photoGallery: {
+    deletePhoto: '删除照片',
+  },
+
+  // Filament Hover Card
+  filamentHoverCard: {
+    copySpoolUuid: '复制耗材 UUID',
+  },
+
+  // K Profiles View
+  kProfilesView: {
+    hasNote: '有备注',
+    copyProfile: '复制配置',
+  },
+
+  // Layout/Navigation
+  layout: {
+    openMenu: '打开菜单',
+    noPermissionSystemInfo: '您没有查看系统信息的权限',
+  },
+
+  // Dashboard
+  dashboard: {
+    dragToReorder: '拖动以重新排列',
+    hideWidget: '隐藏小部件',
+  },
+
+  // Notification Provider Card
+  notificationProviderCard: {
+    deleteNotificationProvider: '删除通知提供商',
+  },
+
+  // File Manager Modal
+  fileManagerModal: {
+    closeFileManager: '关闭文件管理器',
+    sortFiles: '排序文件',
+    goToParentFolder: '返回上级文件夹',
+    threeView: '3D 视图',
+  },
+
+  // Embedded Camera Viewer
+  embeddedCameraViewer: {
+    refreshStream: '刷新流',
+    close: '关闭',
+    zoomOut: '缩小',
+    resetZoom: '重置缩放',
+    zoomIn: '放大',
+    dragToResize: '拖动调整大小',
+  },
+
+  // Timelapse Viewer
+  timelapseViewer: {
+    skipBack5s: '后退 5 秒',
+    skipForward5s: '前进 5 秒',
+  },
+
+  // Notification Providers
+  notificationProviders: {
+    descriptions: {
+      email: 'SMTP 邮件通知',
+      telegram: '通过 Telegram 机器人通知',
+      discord: '通过 Webhook 发送到 Discord 频道',
+      ntfy: '免费、可自托管的推送通知',
+      pushover: '简单、可靠的推送通知',
+      callmebot: '通过 CallMeBot 的免费 WhatsApp 通知',
+      webhook: '通用 HTTP POST 到任意 URL',
+    },
+  },
+
+  // Log Viewer
+  logViewer: {
+    searchPlaceholder: '搜索消息或日志名称...',
+    noLogEntries: '未找到日志条目',
+  },
+
+  // Switchbar Popover
+  switchbarPopover: {
+    noSwitchesInSwitchbar: '切换栏中没有开关',
+  },
+
+  // Project Page Modal
+  projectPageModal: {
+    placeholders: {
+      title: '标题',
+      designer: '设计师',
+      license: '许可证',
+      description: '输入描述...',
+      profileTitle: '配置标题',
+      profileDescription: '配置描述...',
+    },
+  },
+
+  // Spoolman Settings
+  spoolmanSettings: {},
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting: '等待中',
+    justNow: '刚刚',
+    now: '现在',
+    minsAgo: '{{count}} 分钟前',
+    inMins: '{{count}} 分钟后',
+    hoursAgo: '{{count}} 小时前',
+    inHours: '{{count}} 小时后',
+    daysAgo: '{{count}} 天前',
+    inDays: '{{count}} 天后',
+  },
+
+  // SpoolBuddy Kiosk
+  spoolbuddy: {
+    nav: {
+      dashboard: '仪表板',
+      ams: 'AMS',
+      inventory: '库存',
+      writeTag: '写入',
+      settings: '设置',
+    },
+    status: {
+      nfcReady: 'NFC 就绪',
+      nfcOff: 'NFC 关闭',
+      offline: '离线',
+      online: '在线',
+      noPrinters: '无打印机',
+      deviceOffline: '设备离线',
+      waitingConnection: '等待设备连接...',
+      systemReady: '系统就绪',
+      status: '状态',
+    },
+    dashboard: {
+      readyToScan: '准备扫描',
+      idleMessage: '将耗材放在秤上以识别',
+      nfcHint: 'NFC 标签将自动读取',
+      device: '设备',
+      syncWeight: '同步重量',
+      weightSynced: '已同步!',
+      unknownTag: '未知标签',
+      newTag: '检测到新标签',
+      onScale: '在秤上',
+      linkSpool: '链接到耗材',
+      linkTagTitle: '将标签链接到耗材',
+      linkTag: '链接标签',
+      selectSpool: '选择要链接此标签的耗材:',
+      noUntagged: '未找到没有标签的耗材',
+      tagDetected: '检测到标签',
+      noTag: '无标签',
+      tagId: '标签',
+      grossWeight: '毛重',
+      spoolSize: '耗材盘尺寸',
+      close: '关闭',
+      currentSpool: '当前耗材',
+    },
+    modal: {
+      spoolDetected: '检测到耗材',
+      assignToAms: '分配到 AMS',
+      syncWeight: '同步重量',
+      weightSynced: '已同步!',
+      syncing: '同步中...',
+      newTagDetected: '检测到新标签',
+      addToInventory: '添加到库存',
+      assignToAmsTitle: '分配到 AMS',
+      selectSlot: '选择槽位',
+      assign: '分配',
+      assigning: '分配中...',
+      assignSuccess: '已分配!',
+      assignError: '分配耗材失败。请重试。',
+      noPrinterSelected: '选择打印机...',
+      noAmsDetected: '此打印机未检测到 AMS',
+      slot: '槽位',
+    },
+    weight: {
+      noReading: '无读数',
+      stable: '稳定',
+      measuring: '测量中...',
+      tare: '去皮',
+      calibrate: '校准',
+    },
+    spool: {
+      remaining: '剩余',
+      material: '材料',
+      brand: '品牌',
+      color: '颜色',
+      coreWeight: '空盘',
+      labelWeight: '标签',
+      scaleWeight: '秤重',
+      netWeight: '净重',
+      lastUsed: '上次使用',
+    },
+    ams: {
+      noData: '未检测到 AMS',
+      connectAms: '连接 AMS 以查看耗材槽位',
+      noPrinter: '未选择打印机',
+      selectPrinter: '从顶部栏选择打印机',
+      printerDisconnected: '打印机已断开',
+      humidity: '湿度',
+      level: '余量',
+      active: '活跃',
+      slot: '槽位',
+      empty: '空',
+    },
+    inventory: {
+      search: '搜索耗材...',
+      empty: '库存中没有耗材',
+      noResults: '没有匹配的耗材',
+      spools: '个耗材',
+      addSpool: '添加耗材',
+    },
+    settings: {
+      tabDevice: '设备',
+      tabDisplay: '显示',
+      tabScale: '秤',
+      tabUpdates: '更新',
+      nfcReader: 'NFC 读卡器',
+      type: '类型',
+      connection: '连接',
+      notConnected: '不适用',
+      deviceInfo: '设备信息',
+      hostname: '主机',
+      uptime: '运行时间',
+      brightness: '亮度',
+      saved: '已保存',
+      noBacklight: '未检测到 DSI 背光。亮度控制需要 DSI 显示屏。',
+      screenBlank: '屏幕熄灭超时',
+      screenBlankDesc: '不活动后屏幕关闭。触摸唤醒。',
+      displayNote: '亮度作为软件滤镜应用。',
+      scaleCalibration: '秤校准',
+      currentWeight: '当前重量',
+      tareOffset: '去皮',
+      calFactor: '系数',
+      knownWeight: '已知重量',
+      calStep1: '移除秤上所有物品并按设置零点。',
+      calStep2: '将已知重量放在秤上。',
+      setZero: '设置零点',
+      calibrateNow: '校准',
+      calibrated: '已校准',
+      tareSet: '去皮命令已发送。等待设备响应...',
+      tareFailed: '发送去皮命令失败',
+      zeroSet: '零点已设置。将已知重量放在秤上。',
+      calibrationDone: '校准完成!',
+      calibrationFailed: '校准失败',
+      lastCalibrated: '上次校准',
+      stable: '稳定',
+      settling: '稳定中...',
+      firmware: '固件',
+      scale: '秤',
+      noDevice: '未找到 SpoolBuddy 设备',
+      daemonVersion: '守护进程版本',
+      currentVersion: '当前',
+      versionPending: '等待守护进程...',
+      checking: '检查中...',
+      checkUpdates: '检查更新',
+      updateAvailable: '有可用更新',
+      updateInstructions: '通过 SSH 更新:运行 SpoolBuddy 安装脚本进行升级。',
+      upToDate: '已是最新',
+      includeBeta: '包含测试版本',
+    },
+    writeTag: {
+      tabExisting: '现有耗材',
+      tabNew: '新耗材',
+      tabReplace: '替换标签',
+      searchPlaceholder: '按材料、颜色、品牌搜索...',
+      noUntaggedSpools: '没有无标签的耗材',
+      noTaggedSpools: '没有有标签的耗材',
+      selectSpool: '选择一个耗材,然后将空白 NTAG 放在读卡器上',
+      placeTag: '将 NTAG 放在读卡器上',
+      tagReady: '检测到标签 — 准备写入',
+      writeTag: '写入标签',
+      replaceTag: '替换标签',
+      writing: '写入标签中...',
+      waiting: '等待 SpoolBuddy...',
+      writeSuccess: '标签写入成功!',
+      writeFailed: '写入失败',
+      queueFailed: '排队写入命令失败',
+      tryAgain: '重试',
+      cancel: '取消',
+      replaceWarning: '旧标签将被取消链接。新标签将替换它。',
+      deviceOffline: 'SpoolBuddy 离线',
+      material: '材料',
+      colorName: '颜色名称',
+      color: '颜色',
+      brand: '品牌',
+      weight: '重量 (g)',
+      createSpool: '创建耗材',
+      creating: '创建中...',
+      spoolCreated: '耗材已创建!准备写入。',
+      createFailed: '创建耗材失败',
+    },
+  },
+};

+ 16 - 5
frontend/src/pages/ArchivesPage.tsx

@@ -7,6 +7,7 @@ import {
   Trash2,
   Clock,
   Package,
+  Coins,
   Layers,
   Search,
   Filter,
@@ -48,7 +49,7 @@ import {
   User,
   Play,
   ClipboardList,
-  Coins,
+  Zap,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
@@ -923,10 +924,20 @@ function ArchiveCard({
               {archive.filament_used_grams.toFixed(1)}g
             </div>
           )}
-          {archive.cost != null && (
-            <div className="flex items-center gap-1.5 text-bambu-gray">
-              <Coins className="w-3 h-3" />
-              {currency}{archive.cost.toFixed(2)}
+          {(archive.cost != null || archive.energy_cost != null) && (
+            <div className="flex items-center gap-3 text-bambu-gray">
+              {archive.cost != null && (
+                <div className="flex items-center gap-1.5">
+                  <Coins className="w-3 h-3" />
+                  {currency}{archive.cost.toFixed(2)}
+                </div>
+              )}
+                {archive.energy_cost != null && (
+                  <div className="flex items-center gap-1.5" title={`${t('stats.energyUsed')}: ${archive.energy_kwh?.toFixed(3) || 'N/A'} kWh`}>
+                    <Zap className="w-3 h-3" />
+                    {currency}{archive.energy_cost.toFixed(2)}
+                  </div>
+                )}
             </div>
           )}
           {(archive.layer_height || archive.total_layers) && (

+ 3 - 331
frontend/src/pages/FileManagerPage.tsx

@@ -1,4 +1,4 @@
-import { useState, useRef, useCallback, useMemo, useEffect, type DragEvent } from 'react';
+import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
 import { useSearchParams } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
@@ -27,8 +27,6 @@ import {
   AlertTriangle,
   Filter,
   X,
-  CheckCircle,
-  XCircle,
   Link2,
   Unlink,
   Archive as ArchiveIcon,
@@ -54,6 +52,7 @@ import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
+import { FileUploadModal } from '../components/FileUploadModal';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
@@ -404,332 +403,6 @@ function LinkFolderModal({ folder, onClose, onLink, isLoading, t }: LinkFolderMo
   );
 }
 
-// Upload Modal with Drag & Drop
-interface UploadModalProps {
-  folderId: number | null;
-  onClose: () => void;
-  onUploadComplete: () => void;
-  t: TFunction;
-}
-
-interface UploadFile {
-  file: File;
-  status: 'pending' | 'uploading' | 'success' | 'error';
-  error?: string;
-  isZip?: boolean;
-  is3mf?: boolean;
-  extractedCount?: number;
-}
-
-function UploadModal({ folderId, onClose, onUploadComplete, t }: UploadModalProps) {
-  const [files, setFiles] = useState<UploadFile[]>([]);
-  const [isDragging, setIsDragging] = useState(false);
-  const [isUploading, setIsUploading] = useState(false);
-  const [preserveZipStructure, setPreserveZipStructure] = useState(true);
-  const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
-  const [generateStlThumbnails, setGenerateStlThumbnails] = useState(true);
-  const fileInputRef = useRef<HTMLInputElement>(null);
-
-  const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
-    e.preventDefault();
-    setIsDragging(true);
-  };
-
-  const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
-    e.preventDefault();
-    setIsDragging(false);
-  };
-
-  const handleDrop = (e: DragEvent<HTMLDivElement>) => {
-    e.preventDefault();
-    setIsDragging(false);
-    const droppedFiles = Array.from(e.dataTransfer.files);
-    addFiles(droppedFiles);
-  };
-
-  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
-    if (e.target.files) {
-      addFiles(Array.from(e.target.files));
-    }
-  };
-
-  const addFiles = (newFiles: File[]) => {
-    const uploadFiles: UploadFile[] = newFiles.map((file) => ({
-      file,
-      status: 'pending',
-      isZip: file.name.toLowerCase().endsWith('.zip'),
-      is3mf: file.name.toLowerCase().endsWith('.3mf'),
-    }));
-    setFiles((prev) => [...prev, ...uploadFiles]);
-  };
-
-  const removeFile = (index: number) => {
-    setFiles((prev) => prev.filter((_, i) => i !== index));
-  };
-
-  const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
-  const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending');
-  const has3mfFiles = files.some((f) => f.is3mf && f.status === 'pending');
-
-  const handleUpload = async () => {
-    if (files.length === 0) return;
-
-    setIsUploading(true);
-
-    // Handle all files with library upload (ZIP and regular files including .3mf)
-    for (let i = 0; i < files.length; i++) {
-      if (files[i].status !== 'pending') continue;
-
-      setFiles((prev) =>
-        prev.map((f, idx) => (idx === i ? { ...f, status: 'uploading' } : f))
-      );
-
-      try {
-        if (files[i].isZip) {
-          // Extract ZIP file
-          const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip, generateStlThumbnails);
-          setFiles((prev) =>
-            prev.map((f, idx) =>
-              idx === i
-                ? {
-                    ...f,
-                    status: result.errors.length > 0 && result.extracted === 0 ? 'error' : 'success',
-                    extractedCount: result.extracted,
-                    error: result.errors.length > 0 ? `${result.errors.length} files failed` : undefined,
-                  }
-                : f
-            )
-          );
-        } else {
-          // Regular file upload (STL, .3mf, etc.) - .3mf files automatically get metadata extracted
-          await api.uploadLibraryFile(files[i].file, folderId, generateStlThumbnails);
-          setFiles((prev) =>
-            prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
-          );
-        }
-      } catch (err) {
-        setFiles((prev) =>
-          prev.map((f, idx) =>
-            idx === i
-              ? { ...f, status: 'error', error: err instanceof Error ? err.message : 'Upload failed' }
-              : f
-          )
-        );
-      }
-    }
-
-    setIsUploading(false);
-    onUploadComplete();
-    // Auto-close modal after upload completes
-    onClose();
-  };
-
-  const pendingCount = files.filter((f) => f.status === 'pending').length;
-  const successCount = files.filter((f) => f.status === 'success').length;
-  const errorCount = files.filter((f) => f.status === 'error').length;
-  const allDone = files.length > 0 && pendingCount === 0 && !isUploading;
-
-  return (
-    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
-      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-lg border border-bambu-dark-tertiary">
-        <div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
-          <h2 className="text-lg font-semibold text-white">{t('fileManager.uploadFiles')}</h2>
-          <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
-            <X className="w-5 h-5 text-bambu-gray" />
-          </button>
-        </div>
-
-        <div className="p-4 space-y-4">
-          {/* Drop Zone */}
-          <div
-            onDragOver={handleDragOver}
-            onDragLeave={handleDragLeave}
-            onDrop={handleDrop}
-            onClick={() => fileInputRef.current?.click()}
-            className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
-              isDragging
-                ? 'border-bambu-green bg-bambu-green/10'
-                : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
-            }`}
-          >
-            <Upload className={`w-10 h-10 mx-auto mb-3 ${isDragging ? 'text-bambu-green' : 'text-bambu-gray'}`} />
-            <p className="text-white font-medium">
-              {isDragging ? t('fileManager.dropFilesHere') : t('fileManager.dragDropFiles')}
-            </p>
-            <p className="text-sm text-bambu-gray mt-1">{t('fileManager.orClickToBrowse')}</p>
-            <p className="text-xs text-bambu-gray/70 mt-2">{t('fileManager.allFileTypesSupported')}</p>
-          </div>
-
-          <input
-            ref={fileInputRef}
-            type="file"
-            multiple
-            className="hidden"
-            onChange={handleFileSelect}
-          />
-
-          {/* ZIP Options */}
-          {hasZipFiles && (
-            <div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
-              <div className="flex items-start gap-3">
-                <ArchiveIcon className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" />
-                <div className="flex-1">
-                  <p className="text-sm text-blue-300 font-medium">{t('fileManager.zipFilesDetected')}</p>
-                  <p className="text-xs text-blue-300/70 mt-1">
-                    {t('fileManager.zipExtractOptions')}
-                  </p>
-                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
-                    <input
-                      type="checkbox"
-                      checked={preserveZipStructure}
-                      onChange={(e) => setPreserveZipStructure(e.target.checked)}
-                      className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
-                    />
-                    <span className="text-sm text-white">{t('fileManager.preserveZipStructure')}</span>
-                  </label>
-                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
-                    <input
-                      type="checkbox"
-                      checked={createFolderFromZip}
-                      onChange={(e) => setCreateFolderFromZip(e.target.checked)}
-                      className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
-                    />
-                    <span className="text-sm text-white">{t('fileManager.createFolderFromZip')}</span>
-                  </label>
-                </div>
-              </div>
-            </div>
-          )}
-
-          {/* 3MF File Info - Advanced Extraction */}
-          {has3mfFiles && (
-            <div className="p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg">
-              <div className="flex items-start gap-3">
-                <Printer className="w-5 h-5 text-purple-400 mt-0.5 flex-shrink-0" />
-                <div className="flex-1">
-                  <p className="text-sm text-purple-300 font-medium">{t('fileManager.threemfDetected')}</p>
-                  <p className="text-xs text-purple-300/70 mt-1">
-                    {t('fileManager.threemfExtractionInfo')}
-                  </p>
-                </div>
-              </div>
-            </div>
-          )}
-
-          {/* STL Thumbnail Options - show for STL files or ZIP files (which may contain STLs) */}
-          {(hasStlFiles || hasZipFiles) && (
-            <div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
-              <div className="flex items-start gap-3">
-                <Image className="w-5 h-5 text-bambu-green mt-0.5 flex-shrink-0" />
-                <div className="flex-1">
-                  <p className="text-sm text-bambu-green font-medium">{t('fileManager.stlThumbnailGeneration')}</p>
-                  <p className="text-xs text-bambu-green/70 mt-1">
-                    {hasZipFiles && !hasStlFiles
-                      ? t('fileManager.zipMayContainStl')
-                      : t('fileManager.thumbnailsCanBeGenerated')}
-                  </p>
-                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
-                    <input
-                      type="checkbox"
-                      checked={generateStlThumbnails}
-                      onChange={(e) => setGenerateStlThumbnails(e.target.checked)}
-                      className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
-                    />
-                    <span className="text-sm text-white">{t('fileManager.generateThumbnailsForStl')}</span>
-                  </label>
-                </div>
-              </div>
-            </div>
-          )}
-
-          {/* File List */}
-          {files.length > 0 && (
-            <div className="max-h-48 overflow-y-auto space-y-2">
-              {files.map((uploadFile, index) => (
-                <div
-                  key={index}
-                  className="flex items-center gap-3 p-2 bg-bambu-dark rounded-lg"
-                >
-                  {uploadFile.isZip ? (
-                    <ArchiveIcon className="w-4 h-4 text-blue-400 flex-shrink-0" />
-                  ) : (
-                    <File className="w-4 h-4 text-bambu-gray flex-shrink-0" />
-                  )}
-                  <div className="flex-1 min-w-0">
-                    <p className="text-sm text-white truncate">{uploadFile.file.name}</p>
-                    <p className="text-xs text-bambu-gray">
-                      {(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB
-                      {uploadFile.isZip && uploadFile.status === 'pending' && (
-                        <span className="text-blue-400 ml-2">• {t('fileManager.willBeExtracted')}</span>
-                      )}
-                      {uploadFile.extractedCount !== undefined && (
-                        <span className="text-green-400 ml-2">• {t('fileManager.filesExtracted', { count: uploadFile.extractedCount })}</span>
-                      )}
-                    </p>
-                  </div>
-                  {uploadFile.status === 'pending' && (
-                    <button
-                      onClick={() => removeFile(index)}
-                      className="p-1 hover:bg-bambu-dark-tertiary rounded"
-                    >
-                      <X className="w-4 h-4 text-bambu-gray" />
-                    </button>
-                  )}
-                  {uploadFile.status === 'uploading' && (
-                    <Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
-                  )}
-                  {uploadFile.status === 'success' && (
-                    <CheckCircle className="w-4 h-4 text-green-500" />
-                  )}
-                  {uploadFile.status === 'error' && (
-                    <span title={uploadFile.error}>
-                      <XCircle className="w-4 h-4 text-red-500" />
-                    </span>
-                  )}
-                </div>
-              ))}
-            </div>
-          )}
-
-          {/* Summary */}
-          {allDone && (
-            <div className="p-3 bg-bambu-dark rounded-lg">
-              <p className="text-sm text-white">
-                {t('fileManager.uploadComplete', { succeeded: successCount })}
-                {errorCount > 0 && <span className="text-red-400">, {t('fileManager.uploadFailed', { count: errorCount })}</span>}
-              </p>
-            </div>
-          )}
-        </div>
-
-        <div className="p-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
-          <Button variant="secondary" onClick={onClose}>
-            {allDone ? t('common.close') : t('common.cancel')}
-          </Button>
-          {!allDone && (
-            <Button
-              onClick={handleUpload}
-              disabled={pendingCount === 0 || isUploading}
-            >
-              {isUploading ? (
-                <>
-                  <Loader2 className="w-4 h-4 mr-2 animate-spin" />
-                  {t('fileManager.uploading')}
-                </>
-              ) : (
-                <>
-                  <Upload className="w-4 h-4 mr-2" />
-                  {t('common.upload')} {pendingCount > 0 ? `(${pendingCount})` : ''}
-                </>
-              )}
-            </Button>
-          )}
-        </div>
-      </div>
-    </div>
-  );
-}
-
 // Folder Tree Item
 interface FolderTreeItemProps {
   folder: LibraryFolderTree;
@@ -2228,11 +1901,10 @@ export function FileManagerPage() {
       )}
 
       {showUploadModal && (
-        <UploadModal
+        <FileUploadModal
           folderId={selectedFolderId}
           onClose={() => setShowUploadModal(false)}
           onUploadComplete={handleUploadComplete}
-          t={t}
         />
       )}
 

+ 100 - 7
frontend/src/pages/InventoryPage.tsx

@@ -1,4 +1,4 @@
-import { useState, useMemo, type ReactNode } from 'react';
+import { useState, useMemo, useEffect, type ReactNode } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import {
@@ -20,7 +20,7 @@ import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
 import { formatSlotLabel } from '../utils/amsHelpers';
 
 type ArchiveFilter = 'active' | 'archived';
-type UsageFilter = 'all' | 'used' | 'new';
+type UsageFilter = 'all' | 'used' | 'new' | 'lowstock';
 type ViewMode = 'table' | 'cards';
 type SortDirection = 'asc' | 'desc';
 type SortState = { column: string; direction: SortDirection } | null;
@@ -513,6 +513,30 @@ function InventoryPage() {
     }
   };
 
+  // Low stock threshold from backend settings
+  const lowStockThreshold = settings?.low_stock_threshold ?? 20;
+  const [showThresholdInput, setShowThresholdInput] = useState(false);
+  const [thresholdInput, setThresholdInput] = useState(lowStockThreshold.toString());
+
+  // Sync thresholdInput when lowStockThreshold changes and input is not shown
+  useEffect(() => {
+    if (!showThresholdInput) {
+      setThresholdInput(lowStockThreshold.toString());
+    }
+  }, [lowStockThreshold, showThresholdInput]);
+
+  const updateThresholdMutation = useMutation({
+    mutationFn: (threshold: number) => api.updateSettings({ low_stock_threshold: threshold }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['settings'] });
+      showToast(t('common.saved'), 'success');
+      setShowThresholdInput(false);
+    },
+    onError: () => {
+      showToast(t('inventory.lowStockThresholdError'), 'error');
+    },
+  });
+
   // Stats calculation (active spools only)
   const stats = useMemo(() => {
     if (!spools) return null;
@@ -528,14 +552,14 @@ function InventoryPage() {
       totalWeight += remaining;
       totalConsumed += s.weight_used;
       const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
-      if (pct < 20) lowStock++;
+      if (pct < lowStockThreshold) lowStock++;
       const mat = s.material || 'Unknown';
       if (!byMaterial[mat]) byMaterial[mat] = { count: 0, weight: 0 };
       byMaterial[mat].count++;
       byMaterial[mat].weight += remaining;
     }
     return { totalWeight, totalConsumed, lowStock, byMaterial, totalSpools: activeCount };
-  }, [spools]);
+  }, [spools, lowStockThreshold]);
 
   const inPrinterCount = assignments?.length ?? 0;
 
@@ -574,6 +598,12 @@ function InventoryPage() {
       filtered = filtered.filter((s) => s.weight_used > 0);
     } else if (usageFilter === 'new') {
       filtered = filtered.filter((s) => s.weight_used === 0);
+    } else if (usageFilter === 'lowstock') {
+      filtered = filtered.filter((s) => {
+        const remaining = Math.max(0, s.label_weight - s.weight_used);
+        const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
+        return pct < lowStockThreshold;
+      });
     }
 
     // Material dropdown
@@ -607,7 +637,7 @@ function InventoryPage() {
     }
 
     return filtered;
-  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, stockFilter, search]);
+  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, stockFilter, search, lowStockThreshold]);
 
   // Reset page on filter changes
   const resetPage = () => setPageIndex(0);
@@ -818,7 +848,59 @@ function InventoryPage() {
               <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.lowStock')}</span>
             </div>
             <div className={`text-xl font-bold ${stats.lowStock > 0 ? 'text-yellow-400' : 'text-white'}`}>{stats.lowStock}</div>
-            <div className="text-xs text-bambu-gray mt-1">{t('inventory.lowStockThreshold')}</div>
+            <div className="text-xs text-bambu-gray mt-1 flex items-center gap-2">
+              {showThresholdInput ? (
+                <form
+                  onSubmit={e => {
+                    e.preventDefault();
+                    const val = parseFloat(thresholdInput);
+                    if (!isNaN(val) && val >= 0.1 && val <= 99.9) {
+                      updateThresholdMutation.mutate(val);
+                    } else {
+                      showToast(t('inventory.lowStockThresholdError'), 'error');
+                    }
+                  }}
+                  className="flex items-center gap-2"
+                >
+                  <span className="text-xs text-bambu-gray">{'<'}</span>
+                  <input
+                    type="text"
+                    inputMode="decimal"
+                    pattern="^\d{0,2}(\.\d?)?$"
+                    maxLength={4}
+                    value={thresholdInput}
+                    onChange={e => {
+                      // Only allow up to 2 digits before decimal and 1 after
+                      const val = e.target.value.replace(/[^\d.]/g, '');
+                      if (/^\d{0,2}(\.\d?)?$/.test(val)) {
+                        setThresholdInput(val);
+                      }
+                    }}
+                    className="px-1.5 py-1 rounded border border-bambu-dark-tertiary text-xs text-white bg-bambu-dark-secondary focus:outline-none focus:border-bambu-green w-14 text-center"
+                    onWheel={e => e.currentTarget.blur()}
+                    disabled={updateThresholdMutation.isPending}
+                  />
+
+                  <span className="text-xs text-bambu-gray">%</span>
+                  <Button type="submit" size="sm" disabled={updateThresholdMutation.isPending}>{t('common.save')}</Button>
+                  <Button type="button" size="sm" variant="ghost" onClick={() => setShowThresholdInput(false)} disabled={updateThresholdMutation.isPending}>{t('common.cancel')}</Button>
+                </form>
+              ) : (
+                <>
+                  <span className="text-bambu-gray">{'< '}{lowStockThreshold}%</span>
+                  <button
+                    className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors"
+                    title={t('common.edit')}
+                    onClick={() => {
+                      setThresholdInput(lowStockThreshold.toString());
+                      setShowThresholdInput(true);
+                    }}
+                  >
+                    <Edit2 className="w-4 h-4" />
+                  </button>
+                </>
+              )}
+            </div>
           </div>
         </div>
       )}
@@ -960,6 +1042,17 @@ function InventoryPage() {
           >
             {t('inventory.new')}
           </button>
+          <button
+            onClick={() => { setUsageFilter('lowstock'); resetPage(); }}
+            className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
+              usageFilter === 'lowstock'
+                ? 'bg-yellow-500/20 text-yellow-400'
+                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
+            }`}
+          >
+            <AlertTriangle className="w-3.5 h-3.5" />
+            {t('inventory.lowStock')}
+          </button>
         </div>
 
         {/* Stock filter chips */}
@@ -1541,7 +1634,7 @@ function SpoolTableRow({
       ))}
       <td className="py-3 px-4">
         <div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
-          <button onClick={onEdit} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('inventory.editSpool')}>
+          <button onClick={onEdit} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('common.edit')}>
             <Edit2 className="w-4 h-4" />
           </button>
           {spool.archived_at ? (

+ 189 - 43
frontend/src/pages/PrintersPage.tsx

@@ -42,6 +42,8 @@ import {
   XCircle,
   User,
   Home,
+  Printer as PrinterIcon,
+  Info,
 } from 'lucide-react';
 
 import { useNavigate } from 'react-router-dom';
@@ -64,7 +66,11 @@ import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { useToast } from '../contexts/ToastContext';
 import { ChamberLight } from '../components/icons/ChamberLight';
 import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
+import { FileUploadModal } from '../components/FileUploadModal';
+import { PrintModal } from '../components/PrintModal';
+import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { getGlobalTrayId } from '../utils/amsHelpers';
+import { getPrinterImage, getWifiStrength } from '../utils/printer';
 
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
@@ -1088,36 +1094,6 @@ function getSpoolmanFillLevel(
   ));
 }
 
-function getPrinterImage(model: string | null | undefined): string {
-  if (!model) return '/img/printers/default.png';
-
-  const modelLower = model.toLowerCase().replace(/\s+/g, '');
-
-  // Map model names to image files
-  if (modelLower.includes('x1e')) return '/img/printers/x1e.png';
-  if (modelLower.includes('x1c') || modelLower.includes('x1carbon')) return '/img/printers/x1c.png';
-  if (modelLower.includes('x1')) return '/img/printers/x1c.png';
-  if (modelLower.includes('h2dpro') || modelLower.includes('h2d-pro')) return '/img/printers/h2dpro.png';
-  if (modelLower.includes('h2d')) return '/img/printers/h2d.png';
-  if (modelLower.includes('h2c')) return '/img/printers/h2c.png';
-  if (modelLower.includes('h2s')) return '/img/printers/h2d.png';
-  if (modelLower.includes('p2s')) return '/img/printers/p1s.png';
-  if (modelLower.includes('p1s')) return '/img/printers/p1s.png';
-  if (modelLower.includes('p1p')) return '/img/printers/p1p.png';
-  if (modelLower.includes('a1mini')) return '/img/printers/a1mini.png';
-  if (modelLower.includes('a1')) return '/img/printers/a1.png';
-
-  return '/img/printers/default.png';
-}
-
-function getWifiStrength(rssi: number): { labelKey: string; color: string; bars: number } {
-  if (rssi >= -50) return { labelKey: 'printers.wifiSignal.excellent', color: 'text-bambu-green', bars: 4 };
-  if (rssi >= -60) return { labelKey: 'printers.wifiSignal.good', color: 'text-bambu-green', bars: 3 };
-  if (rssi >= -70) return { labelKey: 'printers.wifiSignal.fair', color: 'text-yellow-400', bars: 2 };
-  if (rssi >= -80) return { labelKey: 'printers.wifiSignal.weak', color: 'text-orange-400', bars: 1 };
-  return { labelKey: 'printers.wifiSignal.veryWeak', color: 'text-red-400', bars: 1 };
-}
-
 /**
  * Check if a tray contains a Bambu Lab spool (RFID-tagged).
  * Only checks hardware identifiers (tray_uuid, tag_uid) — NOT tray_info_idx,
@@ -1452,6 +1428,13 @@ function PrinterCard({
   const [showPauseConfirm, setShowPauseConfirm] = useState(false);
   const [showResumeConfirm, setShowResumeConfirm] = useState(false);
   const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
+  const [showUploadForPrint, setShowUploadForPrint] = useState(false);
+  const [showPrinterInfo, setShowPrinterInfo] = useState(false);
+  const closePrinterInfo = useCallback(() => setShowPrinterInfo(false), []);
+  const [printAfterUpload, setPrintAfterUpload] = useState<{ id: number; filename: string } | null>(null);
+  const [isDraggingFile, setIsDraggingFile] = useState(false);
+  const [isDropUploading, setIsDropUploading] = useState(false);
+  const dragCounterRef = useRef(0);
   const [amsHistoryModal, setAmsHistoryModal] = useState<{
     amsId: number;
     amsLabel: string;
@@ -2108,8 +2091,106 @@ function PrinterCard({
     }
   };
 
+  const canDrop = isConnected && status?.state !== 'RUNNING' && status?.state !== 'PAUSE' && hasPermission('printers:control');
+
+  const handleCardDragEnter = (e: React.DragEvent) => {
+    e.preventDefault();
+    dragCounterRef.current++;
+    if (dragCounterRef.current === 1) setIsDraggingFile(true);
+  };
+
+  const handleCardDragOver = (e: React.DragEvent) => {
+    e.preventDefault();
+    e.dataTransfer.dropEffect = canDrop ? 'copy' : 'none';
+  };
+
+  const handleCardDragLeave = (e: React.DragEvent) => {
+    e.preventDefault();
+    dragCounterRef.current--;
+    if (dragCounterRef.current === 0) setIsDraggingFile(false);
+  };
+
+  const handleCardDrop = async (e: React.DragEvent) => {
+    e.preventDefault();
+    dragCounterRef.current = 0;
+    setIsDraggingFile(false);
+
+    if (!canDrop) return;
+
+    const droppedFiles = Array.from(e.dataTransfer.files);
+    const file = droppedFiles[0];
+    if (!file) return;
+
+    // Only accept sliced/printable files (.gcode, .gcode.3mf, etc.)
+    const lower = file.name.toLowerCase();
+    if (!lower.endsWith('.gcode') && !lower.includes('.gcode.')) {
+      showToast(t('printers.dropNotPrintable', 'Only .gcode and .gcode.3mf files can be printed'), 'error');
+      return;
+    }
+
+    setIsDropUploading(true);
+    try {
+      const result = await api.uploadLibraryFile(file, null);
+
+      // Check printer compatibility if sliced_for_model is available in metadata
+      const slicedFor = (result.metadata as Record<string, unknown>)?.sliced_for_model as string | undefined;
+      const printerModel = mapModelCode(printer.model);
+      if (slicedFor && printerModel && slicedFor.toLowerCase() !== printerModel.toLowerCase()) {
+        await api.deleteLibraryFile(result.id).catch(() => {});
+        showToast(
+          t('printers.incompatibleFile', 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}', { slicedFor, printerModel }),
+          'error'
+        );
+        return;
+      }
+
+      setPrintAfterUpload({ id: result.id, filename: result.filename });
+    } catch {
+      showToast(t('common.uploadFailed', 'Upload failed'), 'error');
+    } finally {
+      setIsDropUploading(false);
+    }
+  };
+
   return (
-    <Card className="relative">
+    <Card
+      className="relative"
+      onDragEnter={handleCardDragEnter}
+      onDragOver={handleCardDragOver}
+      onDragLeave={handleCardDragLeave}
+      onDrop={handleCardDrop}
+    >
+      {/* Drop zone overlay */}
+      {(isDraggingFile || isDropUploading) && (
+        <div
+          className={`absolute inset-0 z-10 rounded-xl border-2 border-dashed flex items-center justify-center transition-colors ${
+            isDropUploading
+              ? 'bg-bambu-green/10 border-bambu-green/50'
+              : canDrop
+                ? 'bg-bambu-green/10 border-bambu-green'
+                : 'bg-red-500/10 border-red-500/50'
+          }`}
+        >
+          <div className="text-center">
+            {isDropUploading ? (
+              <>
+                <Loader2 className="w-8 h-8 mx-auto mb-2 text-bambu-green animate-spin" />
+                <p className="text-sm font-medium text-bambu-green">{t('common.uploading', 'Uploading...')}</p>
+              </>
+            ) : canDrop ? (
+              <>
+                <PrinterIcon className="w-8 h-8 mx-auto mb-2 text-bambu-green" />
+                <p className="text-sm font-medium text-bambu-green">{t('printers.dropToPrint', 'Drop to print')}</p>
+              </>
+            ) : (
+              <>
+                <X className="w-8 h-8 mx-auto mb-2 text-red-400" />
+                <p className="text-sm font-medium text-red-400">{t('printers.cannotPrint', 'Printer busy')}</p>
+              </>
+            )}
+          </div>
+        </div>
+      )}
       <CardContent className={cardSize >= 3 ? 'p-5' : ''}>
         {/* Header */}
         <div className={getSpacing()}>
@@ -2179,6 +2260,16 @@ function PrinterCard({
                     <Pencil className="w-4 h-4" />
                     {t('common.edit')}
                   </button>
+                  <button
+                    className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
+                    onClick={() => {
+                      setShowPrinterInfo(true);
+                      setShowMenu(false);
+                    }}
+                  >
+                    <Info className="w-4 h-4" />
+                    {t('printers.printerInformation')}
+                  </button>
                   <button
                     className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
                     onClick={() => {
@@ -2687,6 +2778,7 @@ function PrinterCard({
                           {chamberFan ?? 0}%
                         </span>
                       </div>
+
                     </div>
 
                     {/* Right: Print Control Buttons */}
@@ -3572,22 +3664,17 @@ function PrinterCard({
 
         {/* Connection Info & Actions - hidden in compact mode */}
         {viewMode === 'expanded' && (
-          <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
-            <div className="text-xs text-bambu-gray">
-              <p>{printer.ip_address}</p>
-              <p className="truncate">{printer.serial_number}</p>
-            </div>
-            <div className="flex items-center gap-2 flex-wrap">
-              {/* Chamber Light Toggle */}
+          <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex items-center justify-end gap-2 flex-wrap">
+              {/* Chamber Light */}
               <Button
                 variant="secondary"
                 size="sm"
                 onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
                 disabled={!status?.connected || chamberLightMutation.isPending || !hasPermission('printers:control')}
                 title={!hasPermission('printers:control') ? t('printers.permission.noControl') : (status?.chamber_light ? t('printers.chamberLightOff') : t('printers.chamberLightOn'))}
-                className={status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30 border-yellow-500/30' : ''}
+                className={status?.chamber_light ? '!border-yellow-500 !text-yellow-400 hover:!bg-yellow-500/20' : ''}
               >
-                <ChamberLight on={status?.chamber_light ?? false} className="w-4 h-4" />
+                <ChamberLight on={status?.chamber_light ?? false} className={`w-4 h-4 ${status?.chamber_light ? 'text-yellow-400' : ''}`} />
               </Button>
               {/* Camera Button */}
               <Button
@@ -3650,13 +3737,24 @@ function PrinterCard({
                 variant="secondary"
                 size="sm"
                 onClick={() => setShowFileManager(true)}
-                disabled={!hasPermission('printers:files')}
+                disabled={!isConnected || !hasPermission('printers:files')}
                 title={!hasPermission('printers:files') ? t('printers.permission.noFiles') : t('printers.browseFiles')}
               >
                 <HardDrive className="w-4 h-4" />
-                Files
+                {t('printers.files')}
               </Button>
-            </div>
+              {isConnected && status?.state !== 'RUNNING' && status?.state !== 'PAUSE' && (
+                <Button
+                  size="sm"
+                  onClick={() => setShowUploadForPrint(true)}
+                  disabled={!hasPermission('printers:control')}
+                  title={!hasPermission('printers:control') ? t('printers.permission.noControl') : t('common.print')}
+                  className="!bg-bambu-green hover:!bg-bambu-green/80 !text-white"
+                >
+                  <PrinterIcon className="w-4 h-4" />
+                  {t('common.print')}
+                </Button>
+              )}
           </div>
         )}
       </CardContent>
@@ -3670,6 +3768,45 @@ function PrinterCard({
         />
       )}
 
+      {/* Upload for Print Modal */}
+      {showUploadForPrint && (
+        <FileUploadModal
+          folderId={null}
+          onClose={() => setShowUploadForPrint(false)}
+          onUploadComplete={() => {}}
+          autoUpload
+          accept=".gcode,.3mf"
+          validateFile={(file) => {
+            const lower = file.name.toLowerCase();
+            if (!lower.endsWith('.gcode') && !lower.includes('.gcode.')) {
+              return t('printers.dropNotPrintable', 'Only .gcode and .gcode.3mf files can be printed');
+            }
+          }}
+          onFileUploaded={(uploadedFile) => {
+            // Check printer compatibility if sliced_for_model is available in metadata
+            const slicedFor = (uploadedFile.metadata as Record<string, unknown>)?.sliced_for_model as string | undefined;
+            const printerModel = mapModelCode(printer.model);
+            if (slicedFor && printerModel && slicedFor.toLowerCase() !== printerModel.toLowerCase()) {
+              api.deleteLibraryFile(uploadedFile.id).catch(() => {});
+              return t('printers.incompatibleFile', 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}', { slicedFor, printerModel });
+            }
+            setPrintAfterUpload({ id: uploadedFile.id, filename: uploadedFile.filename });
+          }}
+        />
+      )}
+
+      {/* Print Modal (after upload) */}
+      {printAfterUpload && (
+        <PrintModal
+          mode="reprint"
+          libraryFileId={printAfterUpload.id}
+          archiveName={printAfterUpload.filename}
+          initialSelectedPrinterIds={[printer.id]}
+          onClose={() => setPrintAfterUpload(null)}
+          onSuccess={() => setPrintAfterUpload(null)}
+        />
+      )}
+
       {/* MQTT Debug Modal */}
       {showMQTTDebug && (
         <MQTTDebugModal
@@ -3679,6 +3816,15 @@ function PrinterCard({
         />
       )}
 
+      {showPrinterInfo && (
+        <PrinterInfoModal
+          printer={printer}
+          status={status}
+          totalPrintHours={maintenanceInfo?.total_print_hours}
+          onClose={closePrinterInfo}
+        />
+      )}
+
       {/* Plate Check Result Modal */}
       {plateCheckResult && (
         <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => closePlateCheckModal()}>

+ 25 - 0
frontend/src/utils/printer.ts

@@ -0,0 +1,25 @@
+export function getPrinterImage(model: string | null | undefined): string {
+  if (!model) return '/img/printers/default.png';
+  const m = model.toLowerCase().replace(/\s+/g, '');
+  if (m.includes('x1e')) return '/img/printers/x1e.png';
+  if (m.includes('x1c') || m.includes('x1carbon')) return '/img/printers/x1c.png';
+  if (m.includes('x1')) return '/img/printers/x1c.png';
+  if (m.includes('h2dpro') || m.includes('h2d-pro')) return '/img/printers/h2dpro.png';
+  if (m.includes('h2d')) return '/img/printers/h2d.png';
+  if (m.includes('h2c')) return '/img/printers/h2c.png';
+  if (m.includes('h2s')) return '/img/printers/h2d.png';
+  if (m.includes('p2s')) return '/img/printers/p1s.png';
+  if (m.includes('p1s')) return '/img/printers/p1s.png';
+  if (m.includes('p1p')) return '/img/printers/p1p.png';
+  if (m.includes('a1mini')) return '/img/printers/a1mini.png';
+  if (m.includes('a1')) return '/img/printers/a1.png';
+  return '/img/printers/default.png';
+}
+
+export function getWifiStrength(rssi: number): { labelKey: string; color: string; bars: number } {
+  if (rssi >= -50) return { labelKey: 'printers.wifiSignal.excellent', color: 'text-bambu-green', bars: 4 };
+  if (rssi >= -60) return { labelKey: 'printers.wifiSignal.good', color: 'text-bambu-green', bars: 3 };
+  if (rssi >= -70) return { labelKey: 'printers.wifiSignal.fair', color: 'text-yellow-400', bars: 2 };
+  if (rssi >= -80) return { labelKey: 'printers.wifiSignal.weak', color: 'text-orange-400', bars: 1 };
+  return { labelKey: 'printers.wifiSignal.veryWeak', color: 'text-red-400', bars: 1 };
+}

Plik diff jest za duży
+ 0 - 0
static/assets/index-C8l0CpBG.css


Plik diff jest za duży
+ 0 - 0
static/assets/index-D5I4wfky.css


Plik diff jest za duży
+ 0 - 0
static/assets/index-QpdASmr7.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-ug6uJJTK.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-C8l0CpBG.css">
+    <script type="module" crossorigin src="/assets/index-QpdASmr7.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-D5I4wfky.css">
   </head>
   <body>
     <div id="root"></div>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików