From ad7c5923a65a2109aa6e7ce14012229a8dc2ea11 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 5 Feb 2026 13:45:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 파일 정보 조회를 위한 getFileInfo 함수를 추가하여, 파일의 메타데이터를 공개 접근으로 조회할 수 있도록 하였습니다. - 파일 업로드 컴포넌트에서 파일 아이콘 매핑 및 파일 미리보기 기능을 개선하여 사용자 경험을 향상시켰습니다. - V2 파일 업로드 컴포넌트의 설정 패널을 추가하여, 파일 업로드 관련 설정을 보다 쉽게 관리할 수 있도록 하였습니다. - 파일 뷰어 모달을 추가하여 다양한 파일 형식의 미리보기를 지원합니다. --- .../src/controllers/fileController.ts | 51 + backend-node/src/routes/fileRoutes.ts | 8 + .../screen/InteractiveDataTable.tsx | 33 +- frontend/components/v2/V2Media.tsx | 22 +- frontend/lib/api/file.ts | 28 + .../registry/components/file-upload/index.ts | 9 +- .../registry/components/image-widget/index.ts | 7 +- frontend/lib/registry/components/index.ts | 1 + .../v2-file-upload/FileManagerModal.tsx | 421 ++++++ .../v2-file-upload/FileUploadComponent.tsx | 1345 +++++++++++++++++ .../v2-file-upload/FileUploadConfigPanel.tsx | 287 ++++ .../v2-file-upload/FileViewerModal.tsx | 543 +++++++ .../v2-file-upload/V2FileUploadRenderer.tsx | 56 + .../components/v2-file-upload/config.ts | 62 + .../components/v2-file-upload/index.ts | 46 + .../components/v2-file-upload/types.ts | 114 ++ .../components/v2-media/V2MediaRenderer.tsx | 26 +- .../v2-table-list/TableListComponent.tsx | 67 +- frontend/lib/utils/webTypeMapping.ts | 20 +- 19 files changed, 3103 insertions(+), 43 deletions(-) create mode 100644 frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx create mode 100644 frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx create mode 100644 frontend/lib/registry/components/v2-file-upload/FileUploadConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-file-upload/FileViewerModal.tsx create mode 100644 frontend/lib/registry/components/v2-file-upload/V2FileUploadRenderer.tsx create mode 100644 frontend/lib/registry/components/v2-file-upload/config.ts create mode 100644 frontend/lib/registry/components/v2-file-upload/index.ts create mode 100644 frontend/lib/registry/components/v2-file-upload/types.ts diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index a648a4f9..28a46232 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -1261,5 +1261,56 @@ export const setRepresentativeFile = async ( } }; +/** + * 파일 정보 조회 (메타데이터만, 파일 내용 없음) + * 공개 접근 허용 + */ +export const getFileInfo = async (req: Request, res: Response) => { + try { + const { objid } = req.params; + + if (!objid) { + return res.status(400).json({ + success: false, + message: "파일 ID가 필요합니다.", + }); + } + + // 파일 정보 조회 + const fileRecord = await queryOne( + `SELECT objid, real_file_name, file_size, file_ext, file_path, regdate, is_representative + FROM attach_file_info + WHERE objid = $1 AND status = 'ACTIVE'`, + [parseInt(objid)] + ); + + if (!fileRecord) { + return res.status(404).json({ + success: false, + message: "파일을 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + data: { + objid: fileRecord.objid.toString(), + realFileName: fileRecord.real_file_name, + fileSize: fileRecord.file_size, + fileExt: fileRecord.file_ext, + filePath: fileRecord.file_path, + regdate: fileRecord.regdate, + isRepresentative: fileRecord.is_representative, + }, + }); + } catch (error) { + console.error("파일 정보 조회 오류:", error); + res.status(500).json({ + success: false, + message: "파일 정보 조회 중 오류가 발생했습니다.", + }); + } +}; + // Multer 미들웨어 export export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일 diff --git a/backend-node/src/routes/fileRoutes.ts b/backend-node/src/routes/fileRoutes.ts index 4514e37f..562a0b7f 100644 --- a/backend-node/src/routes/fileRoutes.ts +++ b/backend-node/src/routes/fileRoutes.ts @@ -11,6 +11,7 @@ import { generateTempToken, getFileByToken, setRepresentativeFile, + getFileInfo, } from "../controllers/fileController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -31,6 +32,13 @@ router.get("/public/:token", getFileByToken); */ router.get("/preview/:objid", previewFile); +/** + * @route GET /api/files/info/:objid + * @desc 파일 정보 조회 (메타데이터만, 파일 내용 없음) - 공개 접근 허용 + * @access Public + */ +router.get("/info/:objid", getFileInfo); + // 모든 파일 API는 인증 필요 router.use(authenticateToken); diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 2c400df5..582aa413 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -43,7 +43,7 @@ import { } from "lucide-react"; import { tableTypeApi } from "@/lib/api/screen"; import { commonCodeApi } from "@/lib/api/commonCode"; -import { apiClient, getCurrentUser, UserInfo } from "@/lib/api/client"; +import { apiClient, getCurrentUser, UserInfo, getFullImageUrl } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup"; import { cn } from "@/lib/utils"; @@ -2224,6 +2224,37 @@ export const InteractiveDataTable: React.FC = ({ // 파일 타입 컬럼 처리 (가상 파일 컬럼 포함) const isFileColumn = actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn; + // 🖼️ 이미지 타입 컬럼: 썸네일로 표시 + const isImageColumn = actualWebType === "image" || actualWebType === "img"; + if (isImageColumn && value) { + // value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용 + // 🔑 download 대신 preview 사용 (공개 접근 허용) + const isObjid = /^\d+$/.test(String(value)); + const imageUrl = isObjid + ? `/api/files/preview/${value}` + : getFullImageUrl(String(value)); + + return ( +
+ 이미지 { + e.stopPropagation(); + // 이미지 클릭 시 크게 보기 (새 탭에서 열기) + window.open(imageUrl, "_blank"); + }} + onError={(e) => { + // 이미지 로드 실패 시 기본 아이콘 표시 + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
+ ); + } + // 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리) if (isFileColumn && rowData) { // 현재 행의 기본키 값 가져오기 diff --git a/frontend/components/v2/V2Media.tsx b/frontend/components/v2/V2Media.tsx index 7321808f..733d6657 100644 --- a/frontend/components/v2/V2Media.tsx +++ b/frontend/components/v2/V2Media.tsx @@ -124,7 +124,8 @@ export const V2Media = forwardRef( const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); const recordTableName = formData?.tableName || tableName; const recordId = formData?.id; - const effectiveColumnName = isRecordMode ? 'attachments' : (columnName || id || 'attachments'); + // 🔑 columnName 우선 사용 (실제 DB 컬럼명), 없으면 id, 최후에 attachments + const effectiveColumnName = columnName || id || 'attachments'; // 레코드용 targetObjid 생성 const getRecordTargetObjid = useCallback(() => { @@ -471,13 +472,21 @@ export const V2Media = forwardRef( // 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식 if (onFormDataChange && targetColumn) { + // 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값) + // 복수 파일: 콤마 구분 문자열로 전달 + const formValue = config.multiple + ? fileIds.join(',') + : (fileIds[0] || ''); + console.log("📝 [V2Media] formData 업데이트:", { columnName: targetColumn, fileIds, + formValue, + isMultiple: config.multiple, isRecordMode: effectiveIsRecordMode, }); // (fieldName: string, value: any) 형식으로 호출 - onFormDataChange(targetColumn, fileIds); + onFormDataChange(targetColumn, formValue); } // 그리드 파일 상태 새로고침 이벤트 발생 @@ -601,12 +610,19 @@ export const V2Media = forwardRef( // 폼 데이터 업데이트 - 부모 컴포넌트 시그니처에 맞게 (fieldName, value) 형식 if (onFormDataChange && targetColumn) { + // 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값) + // 복수 파일: 콤마 구분 문자열로 전달 + const formValue = config.multiple + ? fileIds.join(',') + : (fileIds[0] || ''); + console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", { columnName: targetColumn, fileIds, + formValue, }); // (fieldName: string, value: any) 형식으로 호출 - onFormDataChange(targetColumn, fileIds); + onFormDataChange(targetColumn, formValue); } toast.success(`${fileName} 삭제 완료`); diff --git a/frontend/lib/api/file.ts b/frontend/lib/api/file.ts index e6cab8ae..f848c7e6 100644 --- a/frontend/lib/api/file.ts +++ b/frontend/lib/api/file.ts @@ -298,3 +298,31 @@ export const setRepresentativeFile = async (objid: string): Promise<{ throw new Error("대표 파일 설정에 실패했습니다."); } }; + +/** + * 파일 정보 조회 (메타데이터만, objid로 조회) + */ +export const getFileInfoByObjid = async (objid: string): Promise<{ + success: boolean; + data?: { + objid: string; + realFileName: string; + fileSize: number; + fileExt: string; + filePath: string; + regdate: string; + isRepresentative: boolean; + }; + message?: string; +}> => { + try { + const response = await apiClient.get(`/files/info/${objid}`); + return response.data; + } catch (error) { + console.error("파일 정보 조회 오류:", error); + return { + success: false, + message: "파일 정보 조회에 실패했습니다.", + }; + } +}; diff --git a/frontend/lib/registry/components/file-upload/index.ts b/frontend/lib/registry/components/file-upload/index.ts index fcca65cc..3f059ae1 100644 --- a/frontend/lib/registry/components/file-upload/index.ts +++ b/frontend/lib/registry/components/file-upload/index.ts @@ -14,22 +14,23 @@ import { FileUploadConfig } from "./types"; */ export const FileUploadDefinition = createComponentDefinition({ id: "file-upload", - name: "파일 업로드", - nameEng: "FileUpload Component", - description: "파일 업로드를 위한 파일 선택 컴포넌트", + name: "파일 업로드 (레거시)", + nameEng: "FileUpload Component (Legacy)", + description: "파일 업로드를 위한 파일 선택 컴포넌트 (레거시)", category: ComponentCategory.INPUT, webType: "file", component: FileUploadComponent, defaultConfig: { placeholder: "입력하세요", }, - defaultSize: { width: 350, height: 240 }, // 40 * 6 (파일 선택 + 목록 표시) + defaultSize: { width: 350, height: 240 }, configPanel: FileUploadConfigPanel, icon: "Edit", tags: [], version: "1.0.0", author: "개발팀", documentation: "https://docs.example.com/components/file-upload", + hidden: true, // v2-file-upload 사용으로 패널에서 숨김 }); // 타입 내보내기 diff --git a/frontend/lib/registry/components/image-widget/index.ts b/frontend/lib/registry/components/image-widget/index.ts index 67abbc80..aee663e8 100644 --- a/frontend/lib/registry/components/image-widget/index.ts +++ b/frontend/lib/registry/components/image-widget/index.ts @@ -13,9 +13,9 @@ import { ImageWidgetConfigPanel } from "./ImageWidgetConfigPanel"; */ export const ImageWidgetDefinition = createComponentDefinition({ id: "image-widget", - name: "이미지 위젯", - nameEng: "Image Widget", - description: "이미지 표시 및 업로드", + name: "이미지 위젯 (레거시)", + nameEng: "Image Widget (Legacy)", + description: "이미지 표시 및 업로드 (레거시)", category: ComponentCategory.INPUT, webType: "image", component: ImageWidget, @@ -32,6 +32,7 @@ export const ImageWidgetDefinition = createComponentDefinition({ version: "1.0.0", author: "개발팀", documentation: "https://docs.example.com/components/image-widget", + hidden: true, // v2-file-upload 사용으로 패널에서 숨김 }); // 컴포넌트 내보내기 diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 6519230d..172f0067 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -111,6 +111,7 @@ import "./v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스 import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트 import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트 import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트 +import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx new file mode 100644 index 00000000..de838fbf --- /dev/null +++ b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx @@ -0,0 +1,421 @@ +"use client"; + +import React, { useState, useRef } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { FileInfo, FileUploadConfig } from "./types"; +import { + Upload, + Download, + Trash2, + Eye, + File, + FileText, + Image as ImageIcon, + Video, + Music, + Archive, + Presentation, + X, + Star +} from "lucide-react"; +import { formatFileSize } from "@/lib/utils"; +import { FileViewerModal } from "./FileViewerModal"; + +interface FileManagerModalProps { + isOpen: boolean; + onClose: () => void; + uploadedFiles: FileInfo[]; + onFileUpload: (files: File[]) => Promise; + onFileDownload: (file: FileInfo) => void; + onFileDelete: (file: FileInfo) => void; + onFileView: (file: FileInfo) => void; + onSetRepresentative?: (file: FileInfo) => void; // 대표 이미지 설정 콜백 + config: FileUploadConfig; + isDesignMode?: boolean; +} + +export const FileManagerModal: React.FC = ({ + isOpen, + onClose, + uploadedFiles, + onFileUpload, + onFileDownload, + onFileDelete, + onFileView, + onSetRepresentative, + config, + isDesignMode = false, +}) => { + const [dragOver, setDragOver] = useState(false); + const [uploading, setUploading] = useState(false); + const [viewerFile, setViewerFile] = useState(null); + const [isViewerOpen, setIsViewerOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); // 선택된 파일 (좌측 미리보기용) + const [previewImageUrl, setPreviewImageUrl] = useState(null); // 이미지 미리보기 URL + const fileInputRef = useRef(null); + + // 파일 아이콘 가져오기 + const getFileIcon = (fileExt: string) => { + const ext = fileExt.toLowerCase(); + + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) { + return ; + } else if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) { + return ; + } else if (['xls', 'xlsx', 'csv'].includes(ext)) { + return ; + } else if (['ppt', 'pptx'].includes(ext)) { + return ; + } else if (['mp4', 'avi', 'mov', 'webm'].includes(ext)) { + return