From f9575d7b5f9c6747f205e30bd7dcd4457d4dfeaa Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 14 Jan 2026 13:08:44 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=9E=90=EB=8F=99=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/screen/ScreenDesigner.tsx | 195 +++----- frontend/lib/utils/multilangLabelExtractor.ts | 446 ++++++++++++++++++ 2 files changed, 521 insertions(+), 120 deletions(-) create mode 100644 frontend/lib/utils/multilangLabelExtractor.ts diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 25fba342..c4cfac05 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1460,115 +1460,44 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD setIsGeneratingMultilang(true); try { - // 모든 컴포넌트에서 라벨 정보 추출 - const labels: Array<{ componentId: string; label: string; type?: string }> = []; - const addedLabels = new Set(); // 중복 방지 + // 공통 유틸 사용하여 라벨 추출 + const { extractMultilangLabels, extractTableNames, applyMultilangMappings } = await import( + "@/lib/utils/multilangLabelExtractor" + ); + const { apiClient } = await import("@/lib/api/client"); - const addLabel = (componentId: string, label: string, type: string) => { - const key = `${label}_${type}`; - if (label && label.trim() && !addedLabels.has(key)) { - addedLabels.add(key); - labels.push({ componentId, label: label.trim(), type }); + // 테이블별 컬럼 라벨 로드 + const tableNames = extractTableNames(layout.components); + const columnLabelMap: Record> = {}; + + for (const tableName of tableNames) { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + if (response.data?.success && response.data?.data) { + const columns = response.data.data.columns || response.data.data; + if (Array.isArray(columns)) { + columnLabelMap[tableName] = {}; + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name || col.name; + const colLabel = col.displayName || col.columnLabel || col.column_label || colName; + if (colName) { + columnLabelMap[tableName][colName] = colLabel; + } + }); + } + } + } catch (error) { + console.error(`컬럼 라벨 조회 실패 (${tableName}):`, error); } - }; + } - const extractLabels = (components: ComponentData[]) => { - components.forEach((comp) => { - const anyComp = comp as any; - const config = anyComp.componentConfig; - - // 1. 기본 라벨 추출 - if (anyComp.label && typeof anyComp.label === "string") { - addLabel(comp.id, anyComp.label, "label"); - } - - // 2. 제목 추출 (컨테이너, 카드 등) - if (anyComp.title && typeof anyComp.title === "string") { - addLabel(comp.id, anyComp.title, "title"); - } - - // 3. 버튼 텍스트 추출 - if (config?.text && typeof config.text === "string") { - addLabel(comp.id, config.text, "button"); - } - - // 4. placeholder 추출 - if (anyComp.placeholder && typeof anyComp.placeholder === "string") { - addLabel(comp.id, anyComp.placeholder, "placeholder"); - } - - // 5. 테이블 컬럼 헤더 추출 (table-list, split-panel-layout 등) - if (config?.columns && Array.isArray(config.columns)) { - config.columns.forEach((col: any, index: number) => { - if (col.displayName && typeof col.displayName === "string") { - addLabel(`${comp.id}_col_${index}`, col.displayName, "column"); - } - }); - } - - // 6. 분할 패널 - 좌측/우측 패널 컬럼 추출 - if (config?.leftPanel?.columns && Array.isArray(config.leftPanel.columns)) { - config.leftPanel.columns.forEach((col: any, index: number) => { - if (col.displayName && typeof col.displayName === "string") { - addLabel(`${comp.id}_left_col_${index}`, col.displayName, "column"); - } - }); - } - if (config?.rightPanel?.columns && Array.isArray(config.rightPanel.columns)) { - config.rightPanel.columns.forEach((col: any, index: number) => { - if (col.displayName && typeof col.displayName === "string") { - addLabel(`${comp.id}_right_col_${index}`, col.displayName, "column"); - } - }); - } - - // 7. 검색 필터 필드 추출 - if (config?.filter?.filters && Array.isArray(config.filter.filters)) { - config.filter.filters.forEach((filter: any, index: number) => { - if (filter.label && typeof filter.label === "string") { - addLabel(`${comp.id}_filter_${index}`, filter.label, "filter"); - } - }); - } - - // 8. 폼 필드 라벨 추출 (input-form 등) - if (config?.fields && Array.isArray(config.fields)) { - config.fields.forEach((field: any, index: number) => { - if (field.label && typeof field.label === "string") { - addLabel(`${comp.id}_field_${index}`, field.label, "field"); - } - if (field.placeholder && typeof field.placeholder === "string") { - addLabel(`${comp.id}_field_ph_${index}`, field.placeholder, "placeholder"); - } - }); - } - - // 9. 탭 라벨 추출 - if (config?.tabs && Array.isArray(config.tabs)) { - config.tabs.forEach((tab: any, index: number) => { - if (tab.label && typeof tab.label === "string") { - addLabel(`${comp.id}_tab_${index}`, tab.label, "tab"); - } - }); - } - - // 10. 액션 버튼 추출 - if (config?.actions?.actions && Array.isArray(config.actions.actions)) { - config.actions.actions.forEach((action: any, index: number) => { - if (action.label && typeof action.label === "string") { - addLabel(`${comp.id}_action_${index}`, action.label, "action"); - } - }); - } - - // 자식 컴포넌트 재귀 탐색 - if (anyComp.children && Array.isArray(anyComp.children)) { - extractLabels(anyComp.children); - } - }); - }; - - extractLabels(layout.components); + // 라벨 추출 (다국어 설정과 동일한 로직) + const extractedLabels = extractMultilangLabels(layout.components, columnLabelMap); + const labels = extractedLabels.map((l) => ({ + componentId: l.componentId, + label: l.label, + type: l.type, + })); if (labels.length === 0) { toast.info("다국어로 변환할 라벨이 없습니다."); @@ -1576,13 +1505,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD return; } - console.log("🌐 다국어 자동 생성 요청:", { - screenId: selectedScreen.screenId, - menuObjid, - labelsCount: labels.length, - labels: labels.slice(0, 5), // 처음 5개만 로그 - }); - // API 호출 const { generateScreenLabelKeys } = await import("@/lib/api/multilang"); const response = await generateScreenLabelKeys({ @@ -1592,13 +1514,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }); if (response.success && response.data) { - toast.success(`${response.data.length}개의 다국어 키가 생성되었습니다.`); - console.log("✅ 다국어 키 생성 완료:", response.data); + // 자동 매핑 적용 + const updatedComponents = applyMultilangMappings(layout.components, response.data); + + // 레이아웃 업데이트 + setLayout((prev) => ({ + ...prev, + components: updatedComponents, + })); + + toast.success(`${response.data.length}개의 다국어 키가 생성되고 컴포넌트에 매핑되었습니다.`); } else { toast.error(response.error?.details || "다국어 키 생성에 실패했습니다."); } } catch (error) { - console.error("❌ 다국어 생성 실패:", error); + console.error("다국어 생성 실패:", error); toast.error("다국어 키 생성 중 오류가 발생했습니다."); } finally { setIsGeneratingMultilang(false); @@ -5165,10 +5095,35 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD isOpen={showMultilangSettingsModal} onClose={() => setShowMultilangSettingsModal(false)} components={layout.components} - onSave={(updates) => { - // TODO: 컴포넌트에 langKeyId 저장 로직 구현 - console.log("다국어 설정 저장:", updates); - toast.success(`${updates.length}개 항목의 다국어 설정이 저장되었습니다.`); + onSave={async (updates) => { + if (updates.length === 0) { + toast.info("저장할 변경사항이 없습니다."); + return; + } + + try { + // 공통 유틸 사용하여 매핑 적용 + const { applyMultilangMappings } = await import("@/lib/utils/multilangLabelExtractor"); + + // 매핑 형식 변환 + const mappings = updates.map((u) => ({ + componentId: u.componentId, + keyId: u.langKeyId, + langKey: u.langKey, + })); + + // 레이아웃 업데이트 + const updatedComponents = applyMultilangMappings(layout.components, mappings); + setLayout((prev) => ({ + ...prev, + components: updatedComponents, + })); + + toast.success(`${updates.length}개 항목의 다국어 설정이 저장되었습니다.`); + } catch (error) { + console.error("다국어 설정 저장 실패:", error); + toast.error("다국어 설정 저장 중 오류가 발생했습니다."); + } }} /> diff --git a/frontend/lib/utils/multilangLabelExtractor.ts b/frontend/lib/utils/multilangLabelExtractor.ts new file mode 100644 index 00000000..0e5a34c5 --- /dev/null +++ b/frontend/lib/utils/multilangLabelExtractor.ts @@ -0,0 +1,446 @@ +/** + * 다국어 라벨 추출 유틸리티 + * 화면 디자이너의 컴포넌트에서 다국어 처리가 필요한 라벨을 추출합니다. + */ + +import { ComponentData } from "@/types/screen"; + +// 추출된 라벨 타입 +export interface ExtractedLabel { + id: string; + componentId: string; + label: string; + type: "label" | "title" | "button" | "placeholder" | "column" | "filter" | "field" | "tab" | "action"; + parentType?: string; + parentLabel?: string; + langKeyId?: number; + langKey?: string; +} + +// 입력 폼 컴포넌트인지 확인 +const INPUT_COMPONENT_TYPES = new Set([ + "text-field", + "number-field", + "date-field", + "datetime-field", + "select-field", + "checkbox-field", + "radio-field", + "textarea-field", + "file-field", + "email-field", + "tel-field", + "password-field", + "entity-field", + "code-field", + "category-field", + "input-field", + "widget", +]); + +const isInputComponent = (comp: any): boolean => { + const compType = comp.componentType || comp.type; + if (INPUT_COMPONENT_TYPES.has(compType)) return true; + if (compType === "widget" && comp.widgetType) return true; + if (comp.inputType || comp.webType) return true; + return false; +}; + +// 컬럼 라벨 맵 타입 +export type ColumnLabelMap = Record>; + +/** + * 컴포넌트에서 다국어 라벨 추출 + * @param components 컴포넌트 배열 + * @param columnLabelMap 테이블별 컬럼 라벨 맵 (선택사항) + * @returns 추출된 라벨 배열 + */ +export function extractMultilangLabels( + components: ComponentData[], + columnLabelMap: ColumnLabelMap = {} +): ExtractedLabel[] { + const labels: ExtractedLabel[] = []; + const addedLabels = new Set(); + + const addLabel = ( + componentId: string, + label: string, + type: ExtractedLabel["type"], + parentType?: string, + parentLabel?: string, + langKeyId?: number, + langKey?: string + ) => { + const key = `${componentId}_${type}_${label}`; + if (label && label.trim() && !addedLabels.has(key)) { + addedLabels.add(key); + labels.push({ + id: key, + componentId, + label: label.trim(), + type, + parentType, + parentLabel, + langKeyId, + langKey, + }); + } + }; + + const extractFromComponent = (comp: ComponentData, parentType?: string, parentLabel?: string) => { + const anyComp = comp as any; + const config = anyComp.componentConfig; + const compType = anyComp.componentType || anyComp.type; + const compLabel = anyComp.label || anyComp.title || compType; + + // 1. 기본 라벨 - 입력 폼 컴포넌트인 경우에만 추출 + if (isInputComponent(anyComp)) { + if (anyComp.label && typeof anyComp.label === "string") { + addLabel(comp.id, anyComp.label, "label", parentType, parentLabel, anyComp.langKeyId, anyComp.langKey); + } + } + + // 2. 제목 + if (anyComp.title && typeof anyComp.title === "string") { + addLabel(comp.id, anyComp.title, "title", parentType, parentLabel); + } + + // 3. 버튼 텍스트 + if (config?.text && typeof config.text === "string") { + addLabel(comp.id, config.text, "button", parentType, parentLabel); + } + + // 4. placeholder + if (anyComp.placeholder && typeof anyComp.placeholder === "string") { + addLabel(comp.id, anyComp.placeholder, "placeholder", parentType, parentLabel); + } + + // 5. 테이블 컬럼 - columnLabelMap에서 한글 라벨 조회 + const tableName = config?.selectedTable || config?.tableName || config?.table || anyComp.tableName; + if (config?.columns && Array.isArray(config.columns)) { + config.columns.forEach((col: any, index: number) => { + const colName = col.columnName || col.field || col.name; + // columnLabelMap에서 한글 라벨 조회, 없으면 displayName 사용 + const colLabel = columnLabelMap[tableName]?.[colName] || col.displayName || col.label || colName; + + if (colLabel && typeof colLabel === "string") { + addLabel( + `${comp.id}_col_${index}`, + colLabel, + "column", + compType, + compLabel, + col.langKeyId, + col.langKey + ); + } + }); + } + + // 6. 분할 패널 컬럼 - columnLabelMap에서 한글 라벨 조회 + const leftTableName = config?.leftPanel?.selectedTable || config?.leftPanel?.tableName || tableName; + if (config?.leftPanel?.columns && Array.isArray(config.leftPanel.columns)) { + config.leftPanel.columns.forEach((col: any, index: number) => { + const colName = col.columnName || col.field || col.name; + const colLabel = columnLabelMap[leftTableName]?.[colName] || col.displayName || col.label || colName; + if (colLabel && typeof colLabel === "string") { + addLabel( + `${comp.id}_left_col_${index}`, + colLabel, + "column", + compType, + `${compLabel} (좌측)`, + col.langKeyId, + col.langKey + ); + } + }); + } + const rightTableName = config?.rightPanel?.selectedTable || config?.rightPanel?.tableName || tableName; + if (config?.rightPanel?.columns && Array.isArray(config.rightPanel.columns)) { + config.rightPanel.columns.forEach((col: any, index: number) => { + const colName = col.columnName || col.field || col.name; + const colLabel = columnLabelMap[rightTableName]?.[colName] || col.displayName || col.label || colName; + if (colLabel && typeof colLabel === "string") { + addLabel( + `${comp.id}_right_col_${index}`, + colLabel, + "column", + compType, + `${compLabel} (우측)`, + col.langKeyId, + col.langKey + ); + } + }); + } + + // 7. 검색 필터 + if (config?.filter?.filters && Array.isArray(config.filter.filters)) { + config.filter.filters.forEach((filter: any, index: number) => { + if (filter.label && typeof filter.label === "string") { + addLabel( + `${comp.id}_filter_${index}`, + filter.label, + "filter", + compType, + compLabel, + filter.langKeyId, + filter.langKey + ); + } + }); + } + + // 8. 폼 필드 + if (config?.fields && Array.isArray(config.fields)) { + config.fields.forEach((field: any, index: number) => { + if (field.label && typeof field.label === "string") { + addLabel( + `${comp.id}_field_${index}`, + field.label, + "field", + compType, + compLabel, + field.langKeyId, + field.langKey + ); + } + }); + } + + // 9. 탭 + if (config?.tabs && Array.isArray(config.tabs)) { + config.tabs.forEach((tab: any, index: number) => { + if (tab.label && typeof tab.label === "string") { + addLabel( + `${comp.id}_tab_${index}`, + tab.label, + "tab", + compType, + compLabel, + tab.langKeyId, + tab.langKey + ); + } + }); + } + + // 10. 액션 버튼 + if (config?.actions?.actions && Array.isArray(config.actions.actions)) { + config.actions.actions.forEach((action: any, index: number) => { + if (action.label && typeof action.label === "string") { + addLabel( + `${comp.id}_action_${index}`, + action.label, + "action", + compType, + compLabel, + action.langKeyId, + action.langKey + ); + } + }); + } + + // 자식 컴포넌트 재귀 탐색 + if (anyComp.children && Array.isArray(anyComp.children)) { + anyComp.children.forEach((child: ComponentData) => { + extractFromComponent(child, compType, compLabel); + }); + } + }; + + components.forEach((comp) => extractFromComponent(comp)); + return labels; +} + +/** + * 컴포넌트에서 테이블명 추출 + * @param components 컴포넌트 배열 + * @returns 테이블명 Set + */ +export function extractTableNames(components: ComponentData[]): Set { + const tableNames = new Set(); + + const extractTableName = (comp: any) => { + const config = comp.componentConfig; + + // 1. 컴포넌트 직접 tableName + if (comp.tableName) tableNames.add(comp.tableName); + + // 2. componentConfig 직접 tableName + if (config?.tableName) tableNames.add(config.tableName); + + // 3. 테이블 리스트 컴포넌트 - selectedTable (주요!) + if (config?.selectedTable) tableNames.add(config.selectedTable); + + // 4. 테이블 리스트 컴포넌트 - table 속성 + if (config?.table) tableNames.add(config.table); + + // 5. 분할 패널의 leftPanel/rightPanel + if (config?.leftPanel?.tableName) tableNames.add(config.leftPanel.tableName); + if (config?.rightPanel?.tableName) tableNames.add(config.rightPanel.tableName); + if (config?.leftPanel?.table) tableNames.add(config.leftPanel.table); + if (config?.rightPanel?.table) tableNames.add(config.rightPanel.table); + if (config?.leftPanel?.selectedTable) tableNames.add(config.leftPanel.selectedTable); + if (config?.rightPanel?.selectedTable) tableNames.add(config.rightPanel.selectedTable); + + // 6. 검색 필터의 tableName + if (config?.filter?.tableName) tableNames.add(config.filter.tableName); + + // 7. properties 안의 tableName + if (comp.properties?.tableName) tableNames.add(comp.properties.tableName); + if (comp.properties?.selectedTable) tableNames.add(comp.properties.selectedTable); + + // 자식 컴포넌트 탐색 + if (comp.children && Array.isArray(comp.children)) { + comp.children.forEach(extractTableName); + } + }; + + components.forEach(extractTableName); + return tableNames; +} + +/** + * 다국어 키 매핑 결과를 컴포넌트에 적용 + * @param components 원본 컴포넌트 배열 + * @param mappings 다국어 키 매핑 결과 [{componentId, keyId, langKey}] + * @returns 업데이트된 컴포넌트 배열 + */ +export function applyMultilangMappings( + components: ComponentData[], + mappings: Array<{ componentId: string; keyId: number; langKey: string }> +): ComponentData[] { + // 매핑을 빠르게 찾기 위한 맵 생성 + const mappingMap = new Map(mappings.map((m) => [m.componentId, m])); + + const updateComponent = (comp: ComponentData): ComponentData => { + const anyComp = comp as any; + const config = anyComp.componentConfig; + let updated = { ...comp } as any; + + // 기본 컴포넌트 라벨 매핑 확인 + const labelMapping = mappingMap.get(comp.id); + if (labelMapping) { + updated.langKeyId = labelMapping.keyId; + updated.langKey = labelMapping.langKey; + + // 버튼 컴포넌트의 경우 componentConfig에도 매핑 + if (config?.text) { + updated.componentConfig = { + ...updated.componentConfig, + langKeyId: labelMapping.keyId, + langKey: labelMapping.langKey, + }; + } + } + + // 컬럼 매핑 + if (config?.columns && Array.isArray(config.columns)) { + const updatedColumns = config.columns.map((col: any, index: number) => { + const colMapping = mappingMap.get(`${comp.id}_col_${index}`); + if (colMapping) { + return { ...col, langKeyId: colMapping.keyId, langKey: colMapping.langKey }; + } + return col; + }); + updated.componentConfig = { ...config, columns: updatedColumns }; + } + + // 분할 패널 좌측 컬럼 매핑 + if (config?.leftPanel?.columns && Array.isArray(config.leftPanel.columns)) { + const updatedLeftColumns = config.leftPanel.columns.map((col: any, index: number) => { + const colMapping = mappingMap.get(`${comp.id}_left_col_${index}`); + if (colMapping) { + return { ...col, langKeyId: colMapping.keyId, langKey: colMapping.langKey }; + } + return col; + }); + updated.componentConfig = { + ...updated.componentConfig, + leftPanel: { ...config.leftPanel, columns: updatedLeftColumns }, + }; + } + + // 분할 패널 우측 컬럼 매핑 + if (config?.rightPanel?.columns && Array.isArray(config.rightPanel.columns)) { + const updatedRightColumns = config.rightPanel.columns.map((col: any, index: number) => { + const colMapping = mappingMap.get(`${comp.id}_right_col_${index}`); + if (colMapping) { + return { ...col, langKeyId: colMapping.keyId, langKey: colMapping.langKey }; + } + return col; + }); + updated.componentConfig = { + ...updated.componentConfig, + rightPanel: { ...config.rightPanel, columns: updatedRightColumns }, + }; + } + + // 필터 매핑 + if (config?.filter?.filters && Array.isArray(config.filter.filters)) { + const updatedFilters = config.filter.filters.map((filter: any, index: number) => { + const filterMapping = mappingMap.get(`${comp.id}_filter_${index}`); + if (filterMapping) { + return { ...filter, langKeyId: filterMapping.keyId, langKey: filterMapping.langKey }; + } + return filter; + }); + updated.componentConfig = { + ...updated.componentConfig, + filter: { ...config.filter, filters: updatedFilters }, + }; + } + + // 폼 필드 매핑 + if (config?.fields && Array.isArray(config.fields)) { + const updatedFields = config.fields.map((field: any, index: number) => { + const fieldMapping = mappingMap.get(`${comp.id}_field_${index}`); + if (fieldMapping) { + return { ...field, langKeyId: fieldMapping.keyId, langKey: fieldMapping.langKey }; + } + return field; + }); + updated.componentConfig = { ...updated.componentConfig, fields: updatedFields }; + } + + // 탭 매핑 + if (config?.tabs && Array.isArray(config.tabs)) { + const updatedTabs = config.tabs.map((tab: any, index: number) => { + const tabMapping = mappingMap.get(`${comp.id}_tab_${index}`); + if (tabMapping) { + return { ...tab, langKeyId: tabMapping.keyId, langKey: tabMapping.langKey }; + } + return tab; + }); + updated.componentConfig = { ...updated.componentConfig, tabs: updatedTabs }; + } + + // 액션 버튼 매핑 + if (config?.actions?.actions && Array.isArray(config.actions.actions)) { + const updatedActions = config.actions.actions.map((action: any, index: number) => { + const actionMapping = mappingMap.get(`${comp.id}_action_${index}`); + if (actionMapping) { + return { ...action, langKeyId: actionMapping.keyId, langKey: actionMapping.langKey }; + } + return action; + }); + updated.componentConfig = { + ...updated.componentConfig, + actions: { ...config.actions, actions: updatedActions }, + }; + } + + // 자식 컴포넌트 재귀 처리 + if (anyComp.children && Array.isArray(anyComp.children)) { + updated.children = anyComp.children.map((child: ComponentData) => updateComponent(child)); + } + + return updated; + }; + + return components.map(updateComponent); +} +