Просмотр исходного кода

Let a busy or offline printer take a dropped file (#2849)

    Dragging a sliced file onto a printer card refused the drop unless the
    printer was connected and neither RUNNING nor PAUSE. The overlay went red
    with "Printer busy", handleCardDrop returned early, and the file was
    discarded with no toast and nothing uploaded. The card's Print button was
    hidden by the same condition, so both routes into "Print from Printer
    Card" closed at once and the way through was the File Manager, uploading
    and queueing by hand.

    The gate never described a real constraint. Every print Bambuddy sends
    becomes a queue item; dropping onto an idle printer only looks instant
    because the scheduler dispatches it on the next pass. Busy is a timing
    difference, not a different path. The modal has always passed
    disableBusy={false} to PrinterSelector, and asapToastShouldPromiseLaterStart
    exists precisely to say "this will start later" when the target cannot
    take it now. cleanup_library_after_dispatch is a print_queue column
    consumed at dispatch, not on close, so the transient upload survives
    however long the item waits.

    Offline is included for the same reason: the queue dispatches when the
    printer comes back, so a machine that is powered down can be given work.

    The overlay now says which one is happening -- "Drop to print" when the
    job would start immediately, "Drop to queue" when it would wait, covering
    a print in progress, a paused job, an AMS mid-cycle, a plate not yet
    cleared, and a printer that is offline. The predicate behind that wording
    is the one the modal already used for its own later-start notice, lifted
    out of PrintModal into utils/printer as isPrinterCurrentlyDispatchable so
    the card cannot promise something the modal contradicts a second later.

    The drop is also gated on the permissions the flow actually exercises. It
    uploads to the library and creates a queue item, so library:upload and
    queue:create -- the pair the Print button beside it has always checked.
    printers:control, which it checked before and never uses, meant someone
    holding that alone got the file uploaded and then rejected by the queue,
    leaving a library row behind with nothing pointing at it. The refusal now
    names whichever of the two is missing instead of claiming the printer is
    busy.

    printers.cannotPrint is dropped in favour of printers.dropToQueue across
    all 13 locales; its text was both unused and, after this, wrong.

    The Print button stays inside the expanded-card block, so S-size cards
    still show the drop zone and no button, exactly as before.
maziggy 2 недель назад
Родитель
Сommit
85dc768b80

+ 200 - 0
frontend/src/__tests__/pages/PrintersPageDropOnBusy.test.tsx

@@ -0,0 +1,200 @@
+/**
+ * Dropping a file onto a busy or offline printer queues it (#2849).
+ *
+ * The card used to refuse the drop unless the printer was connected and
+ * neither RUNNING nor PAUSE, showing a red "Printer busy" and silently
+ * discarding the file. That gate was never needed: a dropped file always
+ * becomes a queue item — dropping onto an idle printer just dispatches it
+ * straight away — so a busy printer only means the item waits its turn. The
+ * workaround was to upload to Archives and queue it from there by hand, which
+ * is the same thing with more steps.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1C',
+  ip_address: '192.168.1.100',
+  serial_number: '01P00A000000001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'stainless_steel',
+  location: 'Workshop',
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+function makeStatus(over: Record<string, unknown>) {
+  return {
+    connected: true,
+    state: 'IDLE',
+    progress: 0,
+    layer_num: 0,
+    total_layers: 0,
+    temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+    remaining_time: 0,
+    filename: null,
+    wifi_signal: -29,
+    speed_level: 2,
+    vt_tray: [],
+    ams: [],
+    ...over,
+  };
+}
+
+/** Records every library upload the page attempts. */
+const uploads: string[] = [];
+
+function renderWith(statusOver: Record<string, unknown>) {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(makeStatus(statusOver))),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    http.post('/api/v1/library/files', async ({ request }) => {
+      const form = await request.formData();
+      uploads.push((form.get('file') as File).name);
+      return HttpResponse.json({ id: 7, filename: 'part.gcode', metadata: {} });
+    }),
+  );
+  return render(<PrintersPage />);
+}
+
+async function card(): Promise<HTMLElement> {
+  await waitFor(() => expect(document.getElementById('printer-card-1')).not.toBeNull());
+  return document.getElementById('printer-card-1') as HTMLElement;
+}
+
+const gcode = () => new File(['G28\n'], 'part.gcode', { type: 'text/plain' });
+
+describe('PrintersPage — drop onto a busy printer (#2849)', () => {
+  beforeEach(() => {
+    uploads.length = 0;
+    vi.clearAllMocks();
+  });
+
+  it('offers to queue instead of refusing while a print is running', async () => {
+    renderWith({ state: 'RUNNING' });
+    fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Drop to queue')).toBeInTheDocument();
+    // The old refusal copy must be gone, not merely restyled.
+    expect(screen.queryByText('Printer busy')).toBeNull();
+    expect(screen.queryByText('Drop to print')).toBeNull();
+  });
+
+  it('offers to queue while a print is paused', async () => {
+    renderWith({ state: 'PAUSE' });
+    fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Drop to queue')).toBeInTheDocument();
+  });
+
+  it('offers to queue for an offline printer so it can be scheduled', async () => {
+    // The queue dispatches when the printer comes back, so refusing the drop
+    // helped nobody.
+    renderWith({ connected: false, state: 'IDLE' });
+    fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Drop to queue')).toBeInTheDocument();
+  });
+
+  it('still says "print" when the printer would start it immediately', async () => {
+    renderWith({ state: 'IDLE' });
+    fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Drop to print')).toBeInTheDocument();
+    expect(screen.queryByText('Drop to queue')).toBeNull();
+  });
+
+  it('says "queue" when an idle printer is drying, which also defers the start', async () => {
+    renderWith({ state: 'IDLE', ams: [{ id: 0, dry_time: 240, tray: [] }] });
+    fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Drop to queue')).toBeInTheDocument();
+  });
+
+  it('actually uploads the dropped file while the printer is running', async () => {
+    // The regression itself: handleCardDrop returned early, so nothing was
+    // uploaded and the file vanished with no feedback at all.
+    renderWith({ state: 'RUNNING' });
+    const el = await card();
+
+    fireEvent.dragEnter(el, { dataTransfer: { files: [gcode()] } });
+    fireEvent.drop(el, { dataTransfer: { files: [gcode()] } });
+
+    // Asserted by count, not name: the test environment's FormData does not
+    // preserve the filename through the multipart round trip. That an upload
+    // happened at all is the whole regression.
+    await waitFor(() => expect(uploads).toHaveLength(1));
+  });
+
+  it('keeps the Print button available while a print is running', async () => {
+    // The button is the other half of "Print from Printer Card" and was hidden
+    // by the same condition. Leaving it hidden while the drop zone accepted the
+    // same file would have had the two routes disagree on the same card.
+    renderWith({ state: 'RUNNING' });
+    await card();
+
+    expect(await screen.findByTitle('Print')).toBeInTheDocument();
+  });
+
+  it('keeps the Print button available while the printer is offline', async () => {
+    renderWith({ connected: false, state: 'IDLE' });
+    await card();
+
+    expect(await screen.findByTitle('Print')).toBeInTheDocument();
+  });
+
+  it('opens the Print Modal after a drop on a printer that is mid-print', async () => {
+    // Reaching the modal from a busy printer is what this change newly allows,
+    // so it is worth proving the modal actually renders rather than the drop
+    // ending in a dead end.
+    renderWith({ state: 'RUNNING' });
+    const el = await card();
+    fireEvent.drop(el, { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Print Job')).toBeInTheDocument();
+    expect(await screen.findByText('part.gcode')).toBeInTheDocument();
+  });
+
+  it('opens the Print Modal after a drop on an offline printer', async () => {
+    // Offline is the further reach: the queue holds the job until the printer
+    // reconnects, so the modal has to cope with having no live AMS data.
+    renderWith({ connected: false, state: 'IDLE' });
+    const el = await card();
+    fireEvent.drop(el, { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Print Job')).toBeInTheDocument();
+    expect(await screen.findByText('part.gcode')).toBeInTheDocument();
+
+    // And the job is actually submittable — an offline printer must not leave
+    // the user in a modal whose Print button never enables. The backend puts no
+    // connectivity condition on queue creation either.
+    await waitFor(() => {
+      const submit = document.querySelector('button[type="submit"]') as HTMLButtonElement | null;
+      expect(submit).not.toBeNull();
+      expect(submit!.disabled).toBe(false);
+    });
+  });
+
+  it('rejects a file that is not printable, busy or not', async () => {
+    renderWith({ state: 'RUNNING' });
+    const el = await card();
+
+    const stl = new File(['solid'], 'model.stl', { type: 'model/stl' });
+    fireEvent.drop(el, { dataTransfer: { files: [stl] } });
+
+    await waitFor(() =>
+      expect(screen.getByText('Only .gcode and .gcode.3mf files can be printed')).toBeInTheDocument()
+    );
+    expect(uploads).toEqual([]);
+  });
+});

+ 145 - 0
frontend/src/__tests__/pages/PrintersPageDropPermission.test.tsx

@@ -0,0 +1,145 @@
+/**
+ * The printer card's drop zone is gated on the permissions it actually uses (#2849).
+ *
+ * Dropping a file uploads it to the library and creates a queue item, so the
+ * two permissions that matter are `library:upload` and `queue:create` -- the
+ * same pair the card's Print button has always checked. The drop zone instead
+ * checked `printers:control`, which the flow never exercises, so a user with
+ * control but neither of the other two got the drop accepted, the file
+ * uploaded, and then a 403 from the queue with an orphaned library row left
+ * behind.
+ */
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const permissions = { granted: ['library:upload', 'queue:create'] as string[] };
+
+const mockUseAuth = {
+  user: { id: 1, username: 'operator', permissions: [] as string[] },
+  authEnabled: true,
+  requiresSetup: false,
+  loading: false,
+  isAdmin: false,
+  login: vi.fn(),
+  loginWithToken: vi.fn(),
+  logout: vi.fn(),
+  refreshUser: vi.fn(),
+  refreshAuth: vi.fn(),
+  hasPermission: vi.fn((permission: string) => permissions.granted.includes(permission)),
+  hasAnyPermission: vi.fn(() => true),
+  hasAllPermissions: vi.fn(() => true),
+  canModify: vi.fn(() => true),
+};
+
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  return { ...actual, useAuth: () => mockUseAuth };
+});
+
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1C',
+  ip_address: '192.168.1.100',
+  serial_number: '01P00A000000001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'stainless_steel',
+  location: 'Workshop',
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const uploads: string[] = [];
+
+function renderPage() {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+    http.get('/api/v1/printers/:id/status', () =>
+      HttpResponse.json({
+        connected: true,
+        state: 'RUNNING',
+        progress: 40,
+        layer_num: 10,
+        total_layers: 100,
+        temperatures: { nozzle: 220, bed: 60, chamber: 30 },
+        remaining_time: 600,
+        filename: 'running.gcode',
+        wifi_signal: -29,
+        speed_level: 2,
+        vt_tray: [],
+        ams: [],
+      })
+    ),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    http.post('/api/v1/library/files', () => {
+      uploads.push('upload');
+      return HttpResponse.json({ id: 7, filename: 'part.gcode', metadata: {} });
+    }),
+  );
+  render(<PrintersPage />);
+}
+
+async function card(): Promise<HTMLElement> {
+  await waitFor(() => expect(document.getElementById('printer-card-1')).not.toBeNull());
+  return document.getElementById('printer-card-1') as HTMLElement;
+}
+
+const gcode = () => new File(['G28\n'], 'part.gcode', { type: 'text/plain' });
+
+describe('PrintersPage — drop zone permissions (#2849)', () => {
+  beforeEach(() => {
+    uploads.length = 0;
+    permissions.granted = ['library:upload', 'queue:create'];
+  });
+
+  it('accepts the drop when the user can upload and queue', async () => {
+    renderPage();
+    fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Drop to queue')).toBeInTheDocument();
+  });
+
+  it('refuses when the user cannot upload to the library', async () => {
+    permissions.granted = ['queue:create'];
+    renderPage();
+    const el = await card();
+    fireEvent.dragEnter(el, { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('You do not have permission to upload files')).toBeInTheDocument();
+    fireEvent.drop(el, { dataTransfer: { files: [gcode()] } });
+    // Nothing is uploaded, so no orphaned library row is left behind.
+    await waitFor(() => expect(uploads).toEqual([]));
+  });
+
+  it('refuses when the user cannot create queue items', async () => {
+    // The case the old printers:control gate got wrong: the upload would have
+    // succeeded and the queue POST then 403'd.
+    permissions.granted = ['library:upload'];
+    renderPage();
+    const el = await card();
+    fireEvent.dragEnter(el, { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('You do not have permission to add to queue')).toBeInTheDocument();
+    fireEvent.drop(el, { dataTransfer: { files: [gcode()] } });
+    await waitFor(() => expect(uploads).toEqual([]));
+  });
+
+  it('is not granted by printers:control alone', async () => {
+    permissions.granted = ['printers:control'];
+    renderPage();
+    fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
+
+    // printers:control grants neither, so the more specific upload message wins.
+    expect(await screen.findByText('You do not have permission to upload files')).toBeInTheDocument();
+    expect(screen.queryByText('Drop to queue')).toBeNull();
+  });
+});

+ 58 - 0
frontend/src/__tests__/utils/isPrinterCurrentlyDispatchable.test.ts

@@ -0,0 +1,58 @@
+/**
+ * Tests for the shared "would ASAP mean now" predicate (#2849).
+ *
+ * Every print goes through the queue, so this is not "can we print at all" —
+ * it is whether a queue item aimed at this printer starts immediately or
+ * waits. The PrintModal uses it to promise a later start; the printer card
+ * uses it to decide whether a dropped file says "Drop to print" or "Drop to
+ * queue". They share it so the card cannot promise one thing and the modal
+ * immediately contradict it.
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { PrinterStatus } from '../../api/client';
+import { isPrinterCurrentlyDispatchable } from '../../utils/printer';
+
+const status = (over: Partial<PrinterStatus> = {}): PrinterStatus =>
+  ({ connected: true, state: 'IDLE', ...over }) as PrinterStatus;
+
+describe('isPrinterCurrentlyDispatchable', () => {
+  it('accepts the states that can take a print right now', () => {
+    for (const state of ['IDLE', 'FINISH', 'FAILED']) {
+      expect(isPrinterCurrentlyDispatchable(status({ state }))).toBe(true);
+    }
+  });
+
+  it('rejects a printer that is mid-print', () => {
+    // The #2849 case: the drop is still allowed, it just queues.
+    expect(isPrinterCurrentlyDispatchable(status({ state: 'RUNNING' }))).toBe(false);
+    expect(isPrinterCurrentlyDispatchable(status({ state: 'PAUSE' }))).toBe(false);
+    expect(isPrinterCurrentlyDispatchable(status({ state: 'PREPARE' }))).toBe(false);
+  });
+
+  it('rejects a disconnected printer whatever its last known state', () => {
+    expect(isPrinterCurrentlyDispatchable(status({ connected: false, state: 'IDLE' }))).toBe(false);
+  });
+
+  it('rejects a printer still awaiting plate clear', () => {
+    // FINISH alone would pass; the plate is physically in the way.
+    expect(isPrinterCurrentlyDispatchable(status({ state: 'FINISH', awaiting_plate_clear: true }))).toBe(false);
+  });
+
+  it('rejects a printer whose AMS is drying', () => {
+    expect(
+      isPrinterCurrentlyDispatchable(status({ ams: [{ dry_time: 240 }] as PrinterStatus['ams'] }))
+    ).toBe(false);
+  });
+
+  it('ignores an AMS that is loaded but not drying', () => {
+    expect(
+      isPrinterCurrentlyDispatchable(status({ ams: [{ dry_time: 0 }] as PrinterStatus['ams'] }))
+    ).toBe(true);
+  });
+
+  it('treats an unknown or missing status as not dispatchable', () => {
+    expect(isPrinterCurrentlyDispatchable(undefined)).toBe(false);
+    expect(isPrinterCurrentlyDispatchable(status({ state: undefined }))).toBe(false);
+  });
+});

+ 2 - 9
frontend/src/components/PrintModal/index.tsx

@@ -2,7 +2,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/rea
 import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, X } from 'lucide-react';
 import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, X } from 'lucide-react';
 import { useEffect, useMemo, useRef, useState } from 'react';
 import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import type { CostCenterSummary, PrinterStatus, PrintQueueItemCreate, PrintQueueItemUpdate, SlotMaterial } from '../../api/client';
+import type { CostCenterSummary, PrintQueueItemCreate, PrintQueueItemUpdate, SlotMaterial } from '../../api/client';
 import { api } from '../../api/client';
 import { api } from '../../api/client';
 import { useAuth } from '../../contexts/AuthContext';
 import { useAuth } from '../../contexts/AuthContext';
 import { Card, CardContent } from '../Card';
 import { Card, CardContent } from '../Card';
@@ -17,7 +17,7 @@ import {
 } from '../../hooks/useFilamentMapping';
 } from '../../hooks/useFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { getColorName } from '../../utils/colors';
 import { getColorName } from '../../utils/colors';
-import { isGcodeCompatible } from '../../utils/printer';
+import { isGcodeCompatible, isPrinterCurrentlyDispatchable } from '../../utils/printer';
 import { getCurrencySymbol } from '../../utils/currency';
 import { getCurrencySymbol } from '../../utils/currency';
 import { getBedTypeInfo } from '../../utils/bedType';
 import { getBedTypeInfo } from '../../utils/bedType';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
@@ -514,13 +514,6 @@ export function PrintModal({
     printerStatus?.ams_filament_backup,
     printerStatus?.ams_filament_backup,
   );
   );
 
 
-  const isPrinterCurrentlyDispatchable = (status: PrinterStatus | undefined): boolean => {
-    if (!status?.connected) return false;
-    if (status.awaiting_plate_clear) return false;
-    if (status.ams?.some((ams) => ams.dry_time > 0)) return false;
-    return ['IDLE', 'FINISH', 'FAILED'].includes(status.state ?? '');
-  };
-
   const asapToastShouldPromiseLaterStart = async (): Promise<boolean> => {
   const asapToastShouldPromiseLaterStart = async (): Promise<boolean> => {
     if (scheduleOptions.scheduleType !== 'asap' || assignmentMode !== 'printer') return false;
     if (scheduleOptions.scheduleType !== 'asap' || assignmentMode !== 'printer') return false;
     if (selectedPrinters.length === 0) return false;
     if (selectedPrinters.length === 0) return false;

+ 1 - 1
frontend/src/i18n/locales/de.ts

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: 'Diese Datei wurde für {{slicedFor}} geslicet, aber dieser Drucker ist ein {{printerModel}}',
     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',
     dropNotPrintable: 'Nur .gcode- und .gcode.3mf-Dateien können gedruckt werden',
     dropToPrint: 'Zum Drucken ablegen',
     dropToPrint: 'Zum Drucken ablegen',
-    cannotPrint: 'Drucker beschäftigt',
+    dropToQueue: 'Zum Einreihen ablegen',
   },
   },
 
 
   // Archives page
   // Archives page

+ 1 - 1
frontend/src/i18n/locales/en.ts

@@ -869,7 +869,7 @@ export default {
     incompatibleFile: 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}',
     incompatibleFile: 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}',
     dropNotPrintable: 'Only .gcode and .gcode.3mf files can be printed',
     dropNotPrintable: 'Only .gcode and .gcode.3mf files can be printed',
     dropToPrint: 'Drop to print',
     dropToPrint: 'Drop to print',
-    cannotPrint: 'Printer busy',
+    dropToQueue: 'Drop to queue',
   },
   },
 
 
   // Archives page
   // Archives page

+ 1 - 1
frontend/src/i18n/locales/es.ts

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: 'Este archivo se laminó para {{slicedFor}}, pero esta impresora es una {{printerModel}}',
     incompatibleFile: 'Este archivo se laminó para {{slicedFor}}, pero esta impresora es una {{printerModel}}',
     dropNotPrintable: 'Solo se pueden imprimir archivos .gcode y .gcode.3mf',
     dropNotPrintable: 'Solo se pueden imprimir archivos .gcode y .gcode.3mf',
     dropToPrint: 'Suelte para imprimir',
     dropToPrint: 'Suelte para imprimir',
-    cannotPrint: 'Impresora ocupada',
+    dropToQueue: 'Suelte para poner en cola',
   },
   },
 
 
   // Archives page
   // Archives page

+ 1 - 1
frontend/src/i18n/locales/fr.ts

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: 'Ce fichier a été tranché pour {{slicedFor}}, mais cette imprimante est une {{printerModel}}',
     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',
     dropNotPrintable: 'Seuls les fichiers .gcode et .gcode.3mf peuvent être imprimés',
     dropToPrint: 'Déposer pour imprimer',
     dropToPrint: 'Déposer pour imprimer',
-    cannotPrint: 'Imprimante occupée',
+    dropToQueue: 'Déposer pour mettre en file',
   },
   },
 
 
   // Archives page
   // Archives page

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

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: 'Questo file è stato preparato per {{slicedFor}}, ma questa stampante è una {{printerModel}}',
     incompatibleFile: 'Questo file è stato preparato per {{slicedFor}}, ma questa stampante è una {{printerModel}}',
     dropNotPrintable: 'Solo i file .gcode e .gcode.3mf possono essere stampati',
     dropNotPrintable: 'Solo i file .gcode e .gcode.3mf possono essere stampati',
     dropToPrint: 'Rilascia per stampare',
     dropToPrint: 'Rilascia per stampare',
-    cannotPrint: 'Stampante occupata',
+    dropToQueue: 'Rilascia per accodare',
   },
   },
 
 
   // Archives page
   // Archives page

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

@@ -863,7 +863,7 @@ export default {
     incompatibleFile: 'このファイルは{{slicedFor}}用にスライスされていますが、このプリンターは{{printerModel}}です',
     incompatibleFile: 'このファイルは{{slicedFor}}用にスライスされていますが、このプリンターは{{printerModel}}です',
     dropNotPrintable: '.gcodeおよび.gcode.3mfファイルのみ印刷できます',
     dropNotPrintable: '.gcodeおよび.gcode.3mfファイルのみ印刷できます',
     dropToPrint: 'ドロップして印刷',
     dropToPrint: 'ドロップして印刷',
-    cannotPrint: 'プリンター使用中',
+    dropToQueue: 'ドロップしてキューに追加',
   },
   },
 
 
   // Archives page
   // Archives page

+ 1 - 1
frontend/src/i18n/locales/ko.ts

@@ -816,7 +816,7 @@ export default {
     incompatibleFile: '이 파일은 {{slicedFor}}용으로 슬라이싱되었지만, 이 프린터는 {{printerModel}}입니다',
     incompatibleFile: '이 파일은 {{slicedFor}}용으로 슬라이싱되었지만, 이 프린터는 {{printerModel}}입니다',
     dropNotPrintable: '.gcode 및 .gcode.3mf 파일만 인쇄할 수 있습니다',
     dropNotPrintable: '.gcode 및 .gcode.3mf 파일만 인쇄할 수 있습니다',
     dropToPrint: '놓아서 인쇄',
     dropToPrint: '놓아서 인쇄',
-    cannotPrint: '프린터 사용 중',
+    dropToQueue: '놓아서 대기열에 추가',
     addPreflight: {
     addPreflight: {
       checking: '연결 확인 중...',
       checking: '연결 확인 중...',
       warning: '일부 연결 확인이 실패했습니다. 이 프린터가 오프라인으로 표시될 수 있습니다. 아래 항목을 검토하고 수정하거나 그냥 저장하세요.',
       warning: '일부 연결 확인이 실패했습니다. 이 프린터가 오프라인으로 표시될 수 있습니다. 아래 항목을 검토하고 수정하거나 그냥 저장하세요.',

+ 1 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: 'Este arquivo foi fatiado para {{slicedFor}}, mas esta impressora é uma {{printerModel}}',
     incompatibleFile: 'Este arquivo foi fatiado para {{slicedFor}}, mas esta impressora é uma {{printerModel}}',
     dropNotPrintable: 'Apenas arquivos .gcode e .gcode.3mf podem ser impressos',
     dropNotPrintable: 'Apenas arquivos .gcode e .gcode.3mf podem ser impressos',
     dropToPrint: 'Solte para imprimir',
     dropToPrint: 'Solte para imprimir',
-    cannotPrint: 'Impressora ocupada',
+    dropToQueue: 'Solte para adicionar à fila',
   },
   },
 
 
   // Archives page
   // Archives page

+ 1 - 1
frontend/src/i18n/locales/ru.ts

@@ -821,7 +821,7 @@ export default {
     incompatibleFile: "Файл подготовлен для {{slicedFor}}, а выбранный принтер — {{printerModel}}",
     incompatibleFile: "Файл подготовлен для {{slicedFor}}, а выбранный принтер — {{printerModel}}",
     dropNotPrintable: "Для печати подходят только файлы .gcode и .gcode.3mf",
     dropNotPrintable: "Для печати подходят только файлы .gcode и .gcode.3mf",
     dropToPrint: "Перетащите файл для печати",
     dropToPrint: "Перетащите файл для печати",
-    cannotPrint: "Принтер занят",
+    dropToQueue: "Перетащите файл в очередь",
   },
   },
   archives: {
   archives: {
     title: "Архив печати",
     title: "Архив печати",

+ 1 - 1
frontend/src/i18n/locales/tr.ts

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: 'Bu dosya {{slicedFor}} için dilimlendi, ancak bu yazıcı bir {{printerModel}}',
     incompatibleFile: 'Bu dosya {{slicedFor}} için dilimlendi, ancak bu yazıcı bir {{printerModel}}',
     dropNotPrintable: 'Yalnızca .gcode ve .gcode.3mf dosyaları yazdırılabilir',
     dropNotPrintable: 'Yalnızca .gcode ve .gcode.3mf dosyaları yazdırılabilir',
     dropToPrint: 'Yazdırmak için bırakın',
     dropToPrint: 'Yazdırmak için bırakın',
-    cannotPrint: 'Yazıcı meşgul',
+    dropToQueue: 'Kuyruğa eklemek için bırakın',
   },
   },
 
 
   // Arşivler sayfası
   // Arşivler sayfası

+ 1 - 1
frontend/src/i18n/locales/uk.ts

@@ -868,7 +868,7 @@ export default {
     incompatibleFile: "Цей файл нарізано для {{slicedFor}}, а модель цього принтера — {{printerModel}}",
     incompatibleFile: "Цей файл нарізано для {{slicedFor}}, а модель цього принтера — {{printerModel}}",
     dropNotPrintable: "Можна роздрукувати лише файли .gcode і .gcode.3mf.",
     dropNotPrintable: "Можна роздрукувати лише файли .gcode і .gcode.3mf.",
     dropToPrint: "Відпустіть для друку",
     dropToPrint: "Відпустіть для друку",
-    cannotPrint: "Принтер зайнятий",
+    dropToQueue: "Відпустіть, щоб додати в чергу",
   },
   },
 
 
   // Archives page
   // Archives page

+ 1 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: '此文件是为 {{slicedFor}} 切片的,但该打印机是 {{printerModel}}',
     incompatibleFile: '此文件是为 {{slicedFor}} 切片的,但该打印机是 {{printerModel}}',
     dropNotPrintable: '只能打印 .gcode 和 .gcode.3mf 文件',
     dropNotPrintable: '只能打印 .gcode 和 .gcode.3mf 文件',
     dropToPrint: '拖放以打印',
     dropToPrint: '拖放以打印',
-    cannotPrint: '打印机忙碌',
+    dropToQueue: '拖放以加入队列',
   },
   },
 
 
   // Archives page
   // Archives page

+ 1 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: '此檔案是為 {{slicedFor}} 切片的,但該印表機是 {{printerModel}}',
     incompatibleFile: '此檔案是為 {{slicedFor}} 切片的,但該印表機是 {{printerModel}}',
     dropNotPrintable: '只能列印 .gcode 和 .gcode.3mf 檔案',
     dropNotPrintable: '只能列印 .gcode 和 .gcode.3mf 檔案',
     dropToPrint: '拖放以列印',
     dropToPrint: '拖放以列印',
-    cannotPrint: '印表機忙碌',
+    dropToQueue: '拖放以加入佇列',
   },
   },
 
 
   // Archives page
   // Archives page

+ 46 - 22
frontend/src/pages/PrintersPage.tsx

@@ -167,7 +167,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool, resolveSlotNozzleDiameter } from '../utils/amsHelpers';
 import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool, resolveSlotNozzleDiameter } from '../utils/amsHelpers';
-import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
+import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
 import { Collapsible } from '../components/Collapsible';
 import { ConnectionDiagnosticModal, DiagnosticChecklist } from '../components/ConnectionDiagnostic';
 import { ConnectionDiagnosticModal, DiagnosticChecklist } from '../components/ConnectionDiagnostic';
@@ -3288,7 +3288,22 @@ function PrinterCard({
     }
     }
   };
   };
 
 
-  const canDrop = isConnected && status?.state !== 'RUNNING' && status?.state !== 'PAUSE' && hasPermission('printers:control');
+  // A dropped file always becomes a queue item, so a printer that is busy,
+  // offline or mid-drying is no reason to refuse the drop — it only means the
+  // item waits its turn instead of starting now (#2849). Rejecting RUNNING /
+  // PAUSE / disconnected sent people to the File Manager to do by hand exactly
+  // what this would have done for them.
+  //
+  // What remains is what the flow actually performs: upload the file, then
+  // create a queue item. It never touches printers:control, which is what this
+  // used to check — so someone holding that but neither of these had the file
+  // uploaded and then rejected by the queue, leaving it stranded. The Print
+  // button below has always checked this pair.
+  const canDrop = hasPermission('library:upload') && hasPermission('queue:create');
+
+  // Drives the wording alone. Shared with the PrintModal so the card's promise
+  // and the modal's own "will start later" toast cannot disagree.
+  const dropWouldQueue = !isPrinterCurrentlyDispatchable(status);
 
 
   const handleCardDragEnter = (e: React.DragEvent) => {
   const handleCardDragEnter = (e: React.DragEvent) => {
     e.preventDefault();
     e.preventDefault();
@@ -3601,12 +3616,18 @@ function PrinterCard({
             ) : canDrop ? (
             ) : canDrop ? (
               <>
               <>
                 <PrinterIcon className="w-8 h-8 mx-auto mb-2 text-bambu-green" />
                 <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>
+                <p className="text-sm font-medium text-bambu-green">
+                  {dropWouldQueue ? t('printers.dropToQueue') : t('printers.dropToPrint')}
+                </p>
               </>
               </>
             ) : (
             ) : (
               <>
               <>
                 <X className="w-8 h-8 mx-auto mb-2 text-red-600 dark:text-red-400" />
                 <X className="w-8 h-8 mx-auto mb-2 text-red-600 dark:text-red-400" />
-                <p className="text-sm font-medium text-red-700 dark:text-red-400">{t('printers.cannotPrint', 'Printer busy')}</p>
+                <p className="text-sm font-medium text-red-700 dark:text-red-400">
+                  {!hasPermission('library:upload')
+                    ? t('fileManager.noPermissionUpload')
+                    : t('fileManager.noPermissionAddToQueue')}
+                </p>
               </>
               </>
             )}
             )}
           </div>
           </div>
@@ -6289,24 +6310,27 @@ function PrinterCard({
                 >
                 >
                   <HardDrive className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
                   <HardDrive className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
                 </Button>
                 </Button>
-                {isConnected && status?.state !== 'RUNNING' && status?.state !== 'PAUSE' && (
-                  <Button
-                    size="sm"
-                    onClick={() => setShowUploadForPrint(true)}
-                    disabled={!hasPermission('library:upload') || !hasPermission('queue:create')}
-                    title={
-                      !hasPermission('library:upload')
-                        ? t('fileManager.noPermissionUpload')
-                        : !hasPermission('queue:create')
-                          ? t('fileManager.noPermissionAddToQueue')
-                          : t('common.print')
-                    }
-                    className={`${footerActionButtonClass} !bg-bambu-green hover:!bg-bambu-green/80 !text-white`}
-                  >
-                    <PrinterIcon className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
-                    {t('common.print')}
-                  </Button>
-                )}
+                {/* Shown whatever the printer is doing (#2849): this uploads a
+                    file and queues it, which a busy or offline printer is no
+                    reason to refuse -- it only means the item waits. Hiding it
+                    while the drop zone accepted the same file would have left
+                    the two routes into this flow disagreeing. */}
+                <Button
+                  size="sm"
+                  onClick={() => setShowUploadForPrint(true)}
+                  disabled={!hasPermission('library:upload') || !hasPermission('queue:create')}
+                  title={
+                    !hasPermission('library:upload')
+                      ? t('fileManager.noPermissionUpload')
+                      : !hasPermission('queue:create')
+                        ? t('fileManager.noPermissionAddToQueue')
+                        : t('common.print')
+                  }
+                  className={`${footerActionButtonClass} !bg-bambu-green hover:!bg-bambu-green/80 !text-white`}
+                >
+                  <PrinterIcon className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
+                  {t('common.print')}
+                </Button>
               </div>
               </div>
             </div>
             </div>
         </div>
         </div>

+ 17 - 1
frontend/src/utils/printer.ts

@@ -56,7 +56,23 @@ export function getWifiStrength(rssi: number): { labelKey: string; color: string
   return { labelKey: 'printers.wifiSignal.veryWeak', color: 'text-red-400', bars: 1 };
   return { labelKey: 'printers.wifiSignal.veryWeak', color: 'text-red-400', bars: 1 };
 }
 }
 
 
-import type { PrintQueueItem } from '../api/client';
+import type { PrinterStatus, PrintQueueItem } from '../api/client';
+
+/**
+ * True when a queue item aimed at this printer would start now rather than wait.
+ *
+ * Every print Bambuddy sends goes through the queue, so this is not "can we
+ * print at all" — it is "will ASAP mean now". The PrintModal uses it to promise
+ * a later start, and the printer card uses it to say whether a dropped file
+ * prints or queues. Both must agree, or the card promises one thing and the
+ * modal immediately says another.
+ */
+export function isPrinterCurrentlyDispatchable(status: PrinterStatus | undefined): boolean {
+  if (!status?.connected) return false;
+  if (status.awaiting_plate_clear) return false;
+  if (status.ams?.some((ams) => ams.dry_time > 0)) return false;
+  return ['IDLE', 'FINISH', 'FAILED'].includes(status.state ?? '');
+}
 
 
 /**
 /**
  * Filters queue items based on printer compatibility (filament types and colors).
  * Filters queue items based on printer compatibility (filament types and colors).

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-61kdXEBh.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-D5Du0dc3.js"></script>
+    <script type="module" crossorigin src="/assets/index-61kdXEBh.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-VSpFxsmE.css">
     <link rel="stylesheet" crossorigin href="/assets/index-VSpFxsmE.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов