Browse Source

Feature: print files directly from project view (closes #930) (#932)

* feat: print files directly from project view (closes #930)

Show printable files from linked library folders directly in the project
detail page, with Print Now and Add to Queue buttons per file. Removes
the detour through the File Manager for common reprint workflows.
lietschaend 1 month ago
parent
commit
f95b2acd7d

+ 14 - 1
backend/app/api/routes/library.py

@@ -1044,14 +1044,16 @@ async def scan_external_folder(
 async def list_files(
     response: Response,
     folder_id: int | None = None,
+    project_id: int | None = None,
     include_root: bool = True,
     db: AsyncSession = Depends(get_db),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
-    """List files, optionally filtered by folder.
+    """List files, optionally filtered by folder or project.
 
     Args:
         folder_id: Filter by folder ID. If None and include_root=True, returns root files.
+        project_id: Return all files across folders linked to this project (bulk fetch, avoids N+1).
         include_root: If True and folder_id is None, returns files at root level.
                      If False and folder_id is None, returns all files.
     """
@@ -1059,6 +1061,10 @@ async def list_files(
 
     if folder_id is not None:
         query = query.where(LibraryFile.folder_id == folder_id)
+    elif project_id is not None:
+        # Single join instead of one query per folder (avoids N+1 pattern)
+        query = query.join(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
+        query = query.where(LibraryFolder.project_id == project_id)
     elif include_root:
         query = query.where(LibraryFile.folder_id.is_(None))
 
@@ -2210,6 +2216,12 @@ async def print_library_file(
     if not printer_manager.is_connected(printer_id):
         raise HTTPException(status_code=400, detail="Printer is not connected")
 
+    # Validate project exists before dispatching so a bogus ID yields 404, not a FK-constraint 500
+    if body.project_id is not None:
+        project_result = await db.execute(select(Project).where(Project.id == body.project_id))
+        if not project_result.scalar_one_or_none():
+            raise HTTPException(status_code=404, detail="Project not found")
+
     plate_name = body.plate_name
     if not plate_name and body.plate_id is not None:
         plate_name = f"Plate {body.plate_id}"
@@ -2225,6 +2237,7 @@ async def print_library_file(
             printer_id=printer_id,
             printer_name=printer.name,
             options=body.model_dump(exclude_none=True),
+            project_id=body.project_id,
             requested_by_user_id=None,
             requested_by_username=None,
         )

+ 8 - 0
backend/app/api/routes/print_queue.py

@@ -21,6 +21,7 @@ from backend.app.models.library import LibraryFile
 from backend.app.models.print_batch import PrintBatch
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
+from backend.app.models.project import Project
 from backend.app.models.user import User
 from backend.app.schemas.print_queue import (
     PrintBatchResponse,
@@ -484,6 +485,12 @@ async def add_to_queue(
                 if plate_time is not None:
                     cached_print_time = plate_time
 
+    # Validate project exists before insert so a bogus ID yields 404, not an FK-constraint 500
+    if data.project_id is not None:
+        project_result = await db.execute(select(Project).where(Project.id == data.project_id))
+        if not project_result.scalar_one_or_none():
+            raise HTTPException(status_code=404, detail="Project not found")
+
     ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
     items = []
     for i in range(quantity):
@@ -508,6 +515,7 @@ async def add_to_queue(
             timelapse=data.timelapse,
             use_ams=data.use_ams,
             gcode_injection=data.gcode_injection,
+            project_id=data.project_id,
             position=max_pos + 1 + i,
             status="pending",
             created_by_id=current_user.id if current_user else None,

+ 2 - 0
backend/app/schemas/library.py

@@ -208,6 +208,8 @@ class FilePrintRequest(BaseModel):
     layer_inspect: bool = False
     timelapse: bool = False
     use_ams: bool = True
+    # Project to associate the resulting archive with
+    project_id: int | None = None
 
 
 class FileUploadResponse(BaseModel):

+ 2 - 0
backend/app/schemas/print_queue.py

@@ -44,6 +44,8 @@ class PrintQueueItemCreate(BaseModel):
     gcode_injection: bool = False
     # Batch: create multiple copies (creates a batch if > 1)
     quantity: int = 1
+    # Project to associate the resulting archive with
+    project_id: int | None = None
 
 
 class PrintQueueItemUpdate(BaseModel):

+ 4 - 0
backend/app/services/archive.py

@@ -854,6 +854,7 @@ class ArchiveService:
         print_data: dict | None = None,
         created_by_id: int | None = None,
         original_filename: str | None = None,
+        project_id: int | None = None,
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
 
@@ -864,6 +865,8 @@ class ArchiveService:
             created_by_id: User ID who created this archive (optional, for user tracking)
             original_filename: Original human-readable filename (optional, for library files
                 stored with UUID names)
+            project_id: Project to associate this archive with (optional, set when triggered
+                from the project view)
         """
         # Verify printer exists if specified
         if printer_id is not None:
@@ -974,6 +977,7 @@ class ArchiveService:
             quantity=quantity,
             extra_data=metadata,
             created_by_id=created_by_id,
+            project_id=project_id,
         )
 
         self.db.add(archive)

+ 6 - 0
backend/app/services/background_dispatch.py

@@ -54,6 +54,7 @@ class PrintDispatchJob:
     options: dict[str, Any] = field(default_factory=dict)
     requested_by_user_id: int | None = None
     requested_by_username: str | None = None
+    project_id: int | None = None
 
 
 @dataclass(slots=True)
@@ -160,6 +161,7 @@ class BackgroundDispatchService:
         options: dict[str, Any],
         requested_by_user_id: int | None,
         requested_by_username: str | None,
+        project_id: int | None = None,
     ) -> dict[str, Any]:
         return await self._dispatch(
             kind="print_library_file",
@@ -170,6 +172,7 @@ class BackgroundDispatchService:
             options=options,
             requested_by_user_id=requested_by_user_id,
             requested_by_username=requested_by_username,
+            project_id=project_id,
         )
 
     async def cancel_job(self, job_id: int) -> dict[str, Any]:
@@ -257,6 +260,7 @@ class BackgroundDispatchService:
         options: dict[str, Any],
         requested_by_user_id: int | None,
         requested_by_username: str | None,
+        project_id: int | None = None,
     ) -> dict[str, Any]:
         async with self._lock:
             has_pending_for_printer = any(job.printer_id == printer_id for job in self._queued_jobs)
@@ -279,6 +283,7 @@ class BackgroundDispatchService:
                 options=options,
                 requested_by_user_id=requested_by_user_id,
                 requested_by_username=requested_by_username,
+                project_id=project_id,
             )
             self._next_job_id += 1
             self._batch_total += 1
@@ -722,6 +727,7 @@ class BackgroundDispatchService:
                 printer_id=job.printer_id,
                 source_file=file_path,
                 original_filename=lib_file.filename,
+                project_id=job.project_id,
             )
             if not archive:
                 raise RuntimeError("Failed to create archive")

+ 1 - 0
backend/app/services/print_scheduler.py

@@ -1641,6 +1641,7 @@ class PrintScheduler:
                     source_file=file_path,
                     original_filename=filename,
                     created_by_id=item.created_by_id,
+                    project_id=item.project_id,
                 )
                 if archive:
                     item.archive_id = archive.id

+ 103 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -1141,4 +1141,107 @@ describe('PrintModal', () => {
       expect(input.value).toBe('5');
     });
   });
+
+  describe('project_id forwarding', () => {
+    beforeEach(() => {
+      // Additional handlers needed for library file mode
+      server.use(
+        http.get('/api/v1/library/files/:id', () => {
+          return HttpResponse.json({
+            id: 5,
+            filename: 'benchy.gcode.3mf',
+            print_name: null,
+            file_type: '3mf',
+            folder_id: null,
+            project_id: null,
+            file_hash: null,
+            file_size_bytes: 1024,
+            thumbnail_path: null,
+            created_at: '2024-01-01T00:00:00Z',
+            updated_at: '2024-01-01T00:00:00Z',
+          });
+        }),
+        http.get('/api/v1/library/files/:id/plates', () => {
+          return HttpResponse.json({ is_multi_plate: false, plates: [] });
+        }),
+        http.get('/api/v1/library/files/:id/filament-requirements', () => {
+          return HttpResponse.json({ file_id: 5, filename: 'benchy.gcode.3mf', filaments: [] });
+        }),
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
+        }),
+      );
+    });
+
+    it('includes project_id in printLibraryFile call when projectId prop is set', async () => {
+      let capturedBody: Record<string, unknown> | null = null;
+      server.use(
+        http.post('/api/v1/library/files/:id/print', async ({ request }) => {
+          capturedBody = await request.json() as Record<string, unknown>;
+          return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 'abc', dispatch_position: 0 });
+        })
+      );
+      const user = userEvent.setup();
+
+      render(
+        <PrintModal
+          mode="reprint"
+          libraryFileId={5}
+          archiveName="Benchy"
+          projectId={42}
+          initialSelectedPrinterIds={[1]}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      // Wait for the modal to load printer and file data
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByRole('button', { name: /^print$/i }));
+
+      await waitFor(() => {
+        expect(capturedBody).not.toBeNull();
+        expect(capturedBody?.project_id).toBe(42);
+      });
+    });
+
+    it('does NOT include project_id in reprintArchive call (archives carry their own project association)', async () => {
+      // The reprintArchive branch omits project_id by design — archives already carry
+      // their project association from the original print. This test guards that intent.
+      let capturedBody: Record<string, unknown> | null = null;
+      server.use(
+        http.post('/api/v1/archives/:id/reprint', async ({ request }) => {
+          capturedBody = await request.json() as Record<string, unknown>;
+          return HttpResponse.json({ status: 'dispatched' });
+        })
+      );
+      const user = userEvent.setup();
+
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          projectId={42}
+          initialSelectedPrinterIds={[1]}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByRole('button', { name: /^print$/i }));
+
+      await waitFor(() => {
+        expect(capturedBody).not.toBeNull();
+        expect(capturedBody).not.toHaveProperty('project_id');
+      });
+    });
+  });
 });

+ 225 - 0
frontend/src/__tests__/pages/ProjectDetailPage.test.tsx

@@ -0,0 +1,225 @@
+/**
+ * Tests for the ProjectDetailPage component.
+ * Covers: isSlicedFilename conditional print-button logic, linked folder file rendering,
+ * and the PrintModal open trigger with projectId.
+ */
+
+/// <reference types="@testing-library/jest-dom" />
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { ProjectDetailPage } from '../../pages/ProjectDetailPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+// Mock useParams so the component receives a fixed project id without a nested Router
+vi.mock('react-router-dom', async () => {
+  const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
+  return {
+    ...actual,
+    useParams: () => ({ id: '1' }),
+    useNavigate: () => vi.fn(),
+  };
+});
+
+const mockProject = {
+  id: 1,
+  name: 'Test Project',
+  description: 'A test project',
+  color: '#00ae42',
+  status: 'active',
+  priority: 'normal',
+  due_date: null,
+  notes: null,
+  parent_id: null,
+  archive_count: 0,
+  total_print_time_seconds: 0,
+  total_filament_grams: 0,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const mockFolder = {
+  id: 10,
+  name: 'Sliced Files',
+  project_id: 1,
+  archive_id: null,
+  parent_id: null,
+  file_count: 3,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+function makeFile(overrides: { id: number; filename: string; file_type?: string }) {
+  return {
+    id: overrides.id,
+    filename: overrides.filename,
+    print_name: null,
+    file_type: overrides.file_type ?? '3mf',
+    folder_id: 10,
+    project_id: 1,
+    file_hash: null,
+    file_size_bytes: 1024,
+    thumbnail_path: null,
+    created_at: '2024-01-01T00:00:00Z',
+    updated_at: '2024-01-01T00:00:00Z',
+    duplicate_count: 0,
+  };
+}
+
+describe('ProjectDetailPage', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/projects/:id', () => {
+        return HttpResponse.json(mockProject);
+      }),
+      http.get('/api/v1/projects/:id/archives', () => {
+        return HttpResponse.json([]);
+      }),
+      http.get('/api/v1/projects/:id/bom', () => {
+        return HttpResponse.json([]);
+      }),
+      http.get('/api/v1/projects/:id/timeline', () => {
+        return HttpResponse.json([]);
+      }),
+      http.get('/api/v1/library/folders/by-project/:id', () => {
+        return HttpResponse.json([mockFolder]);
+      }),
+    );
+  });
+
+  describe('isSlicedFilename — conditional print button', () => {
+    it('shows print button for .gcode files', async () => {
+      server.use(
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([makeFile({ id: 1, filename: 'benchy.gcode', file_type: 'gcode' })]);
+        })
+      );
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByTitle('Print Now')).toBeInTheDocument();
+      });
+    });
+
+    it('shows print button for .gcode.3mf files', async () => {
+      server.use(
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([makeFile({ id: 2, filename: 'benchy.gcode.3mf', file_type: '3mf' })]);
+        })
+      );
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByTitle('Print Now')).toBeInTheDocument();
+      });
+    });
+
+    it('does NOT show print button for .gcode.bak files (regression for includes bug)', async () => {
+      server.use(
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([makeFile({ id: 3, filename: 'benchy.gcode.bak', file_type: '3mf' })]);
+        })
+      );
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('benchy.gcode.bak')).toBeInTheDocument();
+      });
+
+      expect(screen.queryByTitle('Print Now')).not.toBeInTheDocument();
+    });
+
+    it('does NOT show print button for .stl files', async () => {
+      server.use(
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([makeFile({ id: 4, filename: 'model.stl', file_type: 'stl' })]);
+        })
+      );
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('model.stl')).toBeInTheDocument();
+      });
+
+      expect(screen.queryByTitle('Print Now')).not.toBeInTheDocument();
+    });
+  });
+
+  describe('linked folder file rendering', () => {
+    it('renders filenames from linked folder', async () => {
+      server.use(
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([
+            makeFile({ id: 5, filename: 'part_a.gcode.3mf', file_type: '3mf' }),
+            makeFile({ id: 6, filename: 'design.stl', file_type: 'stl' }),
+          ]);
+        })
+      );
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('part_a.gcode.3mf')).toBeInTheDocument();
+        expect(screen.getByText('design.stl')).toBeInTheDocument();
+      });
+    });
+
+    it('renders the linked folder name', async () => {
+      server.use(
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([]);
+        })
+      );
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Sliced Files')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('print modal trigger', () => {
+    it('opens PrintModal when print button is clicked on a sliced file', async () => {
+      const user = userEvent.setup();
+
+      server.use(
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([makeFile({ id: 7, filename: 'cube.gcode.3mf', file_type: '3mf' })]);
+        }),
+        http.get('/api/v1/printers/', () => {
+          return HttpResponse.json([]);
+        }),
+        http.get('/api/v1/library/files/:id', () => {
+          return HttpResponse.json(makeFile({ id: 7, filename: 'cube.gcode.3mf', file_type: '3mf' }));
+        }),
+        http.get('/api/v1/library/files/:id/plates', () => {
+          return HttpResponse.json({ is_multi_plate: false, plates: [] });
+        }),
+        http.get('/api/v1/library/files/:id/filament-requirements', () => {
+          return HttpResponse.json({ file_id: 7, filename: 'cube.gcode.3mf', filaments: [] });
+        }),
+      );
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByTitle('Print Now')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByTitle('Print Now'));
+
+      // PrintModal should open — look for the modal heading "Print"
+      await waitFor(() => {
+        expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 7 - 1
frontend/src/api/client.ts

@@ -1441,6 +1441,8 @@ export interface PrintQueueItemCreate {
   gcode_injection?: boolean;
   // Batch: create multiple copies (creates a batch if > 1)
   quantity?: number;
+  // Project to associate the resulting archive with
+  project_id?: number;
 }
 
 export interface PrintQueueItemUpdate {
@@ -4240,11 +4242,14 @@ export const api = {
   getLibraryFoldersByArchive: (archiveId: number) =>
     request<LibraryFolder[]>(`/library/folders/by-archive/${archiveId}`),
 
-  getLibraryFiles: (folderId?: number | null, includeRoot = true) => {
+  getLibraryFiles: (folderId?: number | null, includeRoot = true, projectId?: number) => {
     const params = new URLSearchParams();
     if (folderId !== undefined && folderId !== null) {
       params.set('folder_id', String(folderId));
     }
+    if (projectId !== undefined) {
+      params.set('project_id', String(projectId));
+    }
     params.set('include_root', String(includeRoot));
     return request<LibraryFileListItem[]>(`/library/files?${params}`);
   },
@@ -4379,6 +4384,7 @@ export const api = {
       layer_inspect?: boolean;
       timelapse?: boolean;
       use_ams?: boolean;
+      project_id?: number;
     }
   ) =>
     request<BackgroundDispatchResponse>(

+ 5 - 0
frontend/src/components/PrintModal/index.tsx

@@ -48,6 +48,7 @@ export function PrintModal({
   initialSelectedPrinterIds,
   onClose,
   onSuccess,
+  projectId,
 }: PrintModalProps) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -654,6 +655,7 @@ export function PrintModal({
         ? new Date(scheduleOptions.scheduledTime).toISOString()
         : undefined,
       ...printOptions,
+      project_id: projectId ?? undefined,
     });
 
     // Model-based assignment
@@ -734,8 +736,11 @@ export function PrintModal({
                   plate_name: selectedPlateName,
                   ams_mapping: printerMapping,
                   ...printOptions,
+                  project_id: projectId,
                 });
               } else {
+                // project_id is intentionally omitted here: reprintArchive targets an existing
+                // archive that already carries its own project association from the original print.
                 await api.reprintArchive(archiveId!, printerId, {
                   plate_id: selectedPlate ?? undefined,
                   plate_name: selectedPlateName,

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

@@ -32,6 +32,8 @@ export interface PrintModalProps {
   onClose: () => void;
   /** Handler for successful operation */
   onSuccess?: () => void;
+  /** Project ID to associate the resulting archive with (only when triggered from project view) */
+  projectId?: number;
 }
 
 /**

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -2902,6 +2902,9 @@ export default {
       forQuickAccess: 'für schnellen Zugriff auf dieses Projekt.',
       fileCount: '{{count}} Datei(en)',
       empty: 'Keine Ordner verknüpft. Gehen Sie zum Dateimanager und verknüpfen Sie einen Ordner mit diesem Projekt.',
+      noFiles: 'Keine Dateien in diesem Ordner.',
+      print: 'Jetzt drucken',
+      addToQueue: 'Zur Warteschlange',
     },
     bom: {
       title: 'Stückliste',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -2904,6 +2904,9 @@ export default {
       forQuickAccess: 'to this project for quick access.',
       fileCount: '{{count}} file(s)',
       empty: 'No folders linked. Go to File Manager and link a folder to this project.',
+      noFiles: 'No files in this folder.',
+      print: 'Print Now',
+      addToQueue: 'Add to Queue',
     },
     bom: {
       title: 'Bill of Materials',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -2889,6 +2889,9 @@ export default {
       forQuickAccess: 'pour un accès rapide.',
       fileCount: '{{count}} fichier(s)',
       empty: 'Aucun dossier lié.',
+      noFiles: 'Aucun fichier dans ce dossier.',
+      print: 'Imprimer maintenant',
+      addToQueue: 'Ajouter à la file',
     },
     bom: {
       title: 'BOM (Liste matériel)',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -2888,6 +2888,9 @@ export default {
       forQuickAccess: 'a questo progetto per accesso rapido.',
       fileCount: '{{count}} file',
       empty: 'Nessuna cartella collegata. Vai a Gestore file e collega una cartella a questo progetto.',
+      noFiles: 'Nessun file in questa cartella.',
+      print: 'Stampa ora',
+      addToQueue: 'Aggiungi alla coda',
     },
     bom: {
       title: 'Distinta materiali',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -2901,6 +2901,9 @@ export default {
       forQuickAccess: 'してクイックアクセスできるようにします。',
       fileCount: '{{count}}ファイル',
       empty: '<空>',
+      noFiles: 'このフォルダにファイルはありません。',
+      print: '今すぐ印刷',
+      addToQueue: 'キューに追加',
     },
     bom: {
       title: '部品表',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2888,6 +2888,9 @@ export default {
       forQuickAccess: 'a este projeto para acesso rápido.',
       fileCount: '{{count}} arquivo(s)',
       empty: 'Nenhuma pasta vinculada. Vá para o Gerenciador de Arquivos e vincule uma pasta a este projeto.',
+      noFiles: 'Nenhum arquivo nesta pasta.',
+      print: 'Imprimir agora',
+      addToQueue: 'Adicionar à fila',
     },
     bom: {
       title: 'Lista de Materiais',

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

@@ -2888,6 +2888,9 @@ export default {
       forQuickAccess: '到此项目以便快速访问。',
       fileCount: '{{count}} 个文件',
       empty: '未链接文件夹。前往文件管理器将文件夹链接到此项目。',
+      noFiles: '此文件夹中没有文件。',
+      print: '立即打印',
+      addToQueue: '加入队列',
     },
     bom: {
       title: '材料清单',

+ 1 - 1
frontend/src/pages/FileManagerPage.tsx

@@ -677,7 +677,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
 // Helper to check if a file is sliced (printable)
 function isSlicedFilename(filename: string): boolean {
   const lower = filename.toLowerCase();
-  return lower.endsWith('.gcode') || lower.includes('.gcode.');
+  return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
 }
 
 // File Card

+ 165 - 28
frontend/src/pages/ProjectDetailPage.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useMemo } from 'react';
 import DOMPurify from 'dompurify';
 import { useParams, useNavigate, Link } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -31,21 +31,31 @@ import {
   FolderOpen,
   Download,
   Pencil,
+  Play,
+  CalendarPlus,
+  FileBox,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { parseUTCDate, formatDateOnly, formatDateTime, formatDurationFromHours, type TimeFormat } from '../utils/date';
-import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate } from '../api/client';
+import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate, LibraryFileListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { RichTextEditor } from '../components/RichTextEditor';
 import { ConfirmModal } from '../components/ConfirmModal';
+import { PrintModal } from '../components/PrintModal';
 
 // Project edit modal (reused from ProjectsPage)
 import { ProjectModal } from './ProjectsPage';
 import { getCurrencySymbol } from '../utils/currency';
 
+// Returns true for sliced (printable) files: .gcode and .gcode.3mf
+function isSlicedFilename(filename: string): boolean {
+  const lower = filename.toLowerCase();
+  return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
+}
+
 function formatFilament(grams: number): string {
   if (grams >= 1000) {
     return `${(grams / 1000).toFixed(2)}kg`;
@@ -201,6 +211,8 @@ export function ProjectDetailPage() {
   const [showEditModal, setShowEditModal] = useState(false);
   const [editingNotes, setEditingNotes] = useState(false);
   const [notesContent, setNotesContent] = useState('');
+  const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
+  const [scheduleFile, setScheduleFile] = useState<LibraryFileListItem | null>(null);
 
   const projectId = parseInt(id || '0', 10);
 
@@ -239,6 +251,26 @@ export function ProjectDetailPage() {
     enabled: projectId > 0,
   });
 
+  // Single bulk query — replaces the previous N+1 useQueries pattern
+  const { data: allProjectFiles, isLoading: projectFilesLoading } = useQuery({
+    queryKey: ['project-files', projectId],
+    queryFn: () => api.getLibraryFiles(null, false, projectId),
+    enabled: projectId > 0,
+  });
+
+  // Group files by folder_id for the section-based render
+  const filesByFolder = useMemo(() => {
+    const map = new Map<number, LibraryFileListItem[]>();
+    for (const file of allProjectFiles ?? []) {
+      if (file.folder_id != null) {
+        const arr = map.get(file.folder_id) ?? [];
+        arr.push(file);
+        map.set(file.folder_id, arr);
+      }
+    }
+    return map;
+  }, [allProjectFiles]);
+
   const currency = getCurrencySymbol(settings?.currency || 'USD');
   const timeFormat: TimeFormat = settings?.time_format || 'system';
 
@@ -473,7 +505,7 @@ export function ProjectDetailPage() {
           </button>
           <div className="flex items-center gap-3">
             <div
-              className="w-4 h-4 rounded-full flex-shrink-0"
+              className="w-4 h-4 rounded-full shrink-0"
               style={{ backgroundColor: project.color || '#6b7280' }}
             />
             <div>
@@ -837,7 +869,7 @@ export function ProjectDetailPage() {
         </CardContent>
       </Card>
 
-      {/* Files section - linked folders from File Manager */}
+      {/* Files section - linked folders from File Manager with printable files */}
       <Card>
         <CardContent className="p-4">
           <div className="flex items-center justify-between mb-3">
@@ -855,27 +887,102 @@ export function ProjectDetailPage() {
           </p>
 
           {linkedFolders && linkedFolders.length > 0 ? (
-            <div className="space-y-2">
-              {linkedFolders.map((folder) => (
-                <Link
-                  key={folder.id}
-                  to={`/files?folder=${folder.id}`}
-                  className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
-                >
-                  <div className="flex items-center gap-3 min-w-0">
-                    <FolderOpen className="w-5 h-5 text-bambu-green flex-shrink-0" />
-                    <div className="min-w-0">
-                      <p className="text-sm text-white truncate">
-                        {folder.name}
-                      </p>
-                      <p className="text-xs text-bambu-gray">
-                        {t('projectDetail.files.fileCount', { count: folder.file_count })}
+            <div className="space-y-4">
+              {linkedFolders.map((folder) => {
+                const files = filesByFolder.get(folder.id) ?? [];
+                const isLoading = projectFilesLoading;
+
+                return (
+                  <div key={folder.id}>
+                    {/* Folder header — links to File Manager */}
+                    <Link
+                      to={`/files?folder=${folder.id}`}
+                      className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors mb-2"
+                    >
+                      <div className="flex items-center gap-3 min-w-0">
+                        <FolderOpen className="w-5 h-5 text-bambu-green shrink-0" />
+                        <div className="min-w-0">
+                          <p className="text-sm text-white truncate">{folder.name}</p>
+                          <p className="text-xs text-bambu-gray">
+                            {t('projectDetail.files.fileCount', { count: folder.file_count })}
+                          </p>
+                        </div>
+                      </div>
+                      <ChevronRight className="w-4 h-4 text-bambu-gray shrink-0" />
+                    </Link>
+
+                    {/* File list within the folder */}
+                    {isLoading ? (
+                      <div className="flex items-center gap-2 px-3 py-2 text-bambu-gray text-sm">
+                        <Loader2 className="w-4 h-4 animate-spin" />
+                      </div>
+                    ) : files.length === 0 ? (
+                      <p className="text-bambu-gray/60 text-xs italic px-3">
+                        {t('projectDetail.files.noFiles')}
                       </p>
-                    </div>
+                    ) : (
+                      <div className="space-y-1 pl-3">
+                        {files.map((file) => {
+                          const printable = isSlicedFilename(file.filename);
+                          return (
+                            <div
+                              key={file.id}
+                              className="flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
+                            >
+                              {/* Thumbnail */}
+                              <div className="w-10 h-10 shrink-0 rounded bg-bambu-dark overflow-hidden flex items-center justify-center">
+                                {file.thumbnail_path ? (
+                                  <img
+                                    src={api.getLibraryFileThumbnailUrl(file.id)}
+                                    alt={file.print_name || file.filename}
+                                    className="w-full h-full object-cover"
+                                  />
+                                ) : (
+                                  <FileBox className="w-5 h-5 text-bambu-gray/40" />
+                                )}
+                              </div>
+
+                              {/* Name + type badge */}
+                              <div className="flex-1 min-w-0">
+                                <p className="text-sm text-white truncate" title={file.print_name || file.filename}>
+                                  {file.print_name || file.filename}
+                                </p>
+                                <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
+                                  file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
+                                  : file.file_type === 'gcode' ? 'bg-blue-500/20 text-blue-400'
+                                  : 'bg-bambu-gray/20 text-bambu-gray'
+                                }`}>
+                                  {file.file_type.toUpperCase()}
+                                </span>
+                              </div>
+
+                              {/* Print actions for sliced files */}
+                              {printable && (
+                                <div className="flex items-center gap-1 shrink-0">
+                                  <button
+                                    onClick={() => setPrintFile(file)}
+                                    title={t('projectDetail.files.print')}
+                                    className="p-1.5 rounded hover:bg-bambu-green/20 text-bambu-green transition-colors"
+                                  >
+                                    <Play className="w-4 h-4" />
+                                  </button>
+                                  <button
+                                    onClick={() => setScheduleFile(file)}
+                                    title={t('projectDetail.files.addToQueue')}
+                                    className="p-1.5 rounded hover:bg-blue-500/20 text-blue-400 transition-colors"
+                                  >
+                                    <CalendarPlus className="w-4 h-4" />
+                                  </button>
+                                </div>
+                              )}
+                            </div>
+                          );
+                        })}
+                      </div>
+                    )}
                   </div>
-                  <ChevronRight className="w-4 h-4 text-bambu-gray flex-shrink-0" />
-                </Link>
-              ))}
+                );
+              })}
             </div>
           ) : (
             <p className="text-bambu-gray/70 text-sm italic">
@@ -1066,7 +1173,7 @@ export function ProjectDetailPage() {
                         onClick={() => hasPermission('projects:update') && handleToggleAcquired(item)}
                         disabled={updateBomMutation.isPending || !hasPermission('projects:update')}
                         title={!hasPermission('projects:update') ? t('projectDetail.bom.noUpdatePermission') : undefined}
-                        className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
+                        className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors shrink-0 ${
                           item.is_complete
                             ? 'bg-status-ok border-status-ok text-white'
                             : hasPermission('projects:update')
@@ -1095,7 +1202,7 @@ export function ProjectDetailPage() {
                             <button
                               onClick={() => hasPermission('projects:update') && handleEditBomItem(item)}
                               disabled={!hasPermission('projects:update')}
-                              className={`p-1 rounded transition-colors flex-shrink-0 ${
+                              className={`p-1 rounded transition-colors shrink-0 ${
                                 hasPermission('projects:update')
                                   ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
                                   : 'text-bambu-gray/50 cursor-not-allowed'
@@ -1107,7 +1214,7 @@ export function ProjectDetailPage() {
                             <button
                               onClick={() => hasPermission('projects:update') && handleDeleteBomItem(item.id, item.name)}
                               disabled={!hasPermission('projects:update')}
-                              className={`p-1 rounded transition-colors flex-shrink-0 ${
+                              className={`p-1 rounded transition-colors shrink-0 ${
                                 hasPermission('projects:update')
                                   ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400'
                                   : 'text-bambu-gray/50 cursor-not-allowed'
@@ -1127,7 +1234,7 @@ export function ProjectDetailPage() {
                             className="flex items-center gap-1 mt-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
                             onClick={(e) => e.stopPropagation()}
                           >
-                            <ExternalLink className="w-3 h-3 flex-shrink-0" />
+                            <ExternalLink className="w-3 h-3 shrink-0" />
                             <span className="truncate">
                               {(() => {
                                 try {
@@ -1186,7 +1293,7 @@ export function ProjectDetailPage() {
             <div className="space-y-3">
               {timeline.slice(0, 10).map((event, index) => (
                 <div key={index} className="flex gap-3">
-                  <div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
+                  <div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
                     event.event_type === 'print_completed' ? 'bg-status-ok/20 text-status-ok' :
                     event.event_type === 'print_failed' ? 'bg-status-error/20 text-status-error' :
                     event.event_type === 'print_started' ? 'bg-yellow-500/20 text-yellow-400' :
@@ -1317,6 +1424,36 @@ export function ProjectDetailPage() {
           onCancel={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
         />
       )}
+
+      {/* Print directly from project — reprint mode */}
+      {printFile && (
+        <PrintModal
+          mode="reprint"
+          libraryFileId={printFile.id}
+          archiveName={printFile.print_name || printFile.filename}
+          projectId={projectId}
+          onClose={() => setPrintFile(null)}
+          onSuccess={() => {
+            setPrintFile(null);
+            queryClient.invalidateQueries({ queryKey: ['archives'] });
+          }}
+        />
+      )}
+
+      {/* Add to queue from project */}
+      {scheduleFile && (
+        <PrintModal
+          mode="add-to-queue"
+          libraryFileId={scheduleFile.id}
+          archiveName={scheduleFile.print_name || scheduleFile.filename}
+          projectId={projectId}
+          onClose={() => setScheduleFile(null)}
+          onSuccess={() => {
+            setScheduleFile(null);
+            queryClient.invalidateQueries({ queryKey: ['queue'] });
+          }}
+        />
+      )}
     </div>
   );
 }