다국어 자동생성
This commit is contained in:
parent
18b5161398
commit
61a7f585b4
|
|
@ -1165,6 +1165,33 @@ export async function saveMenu(
|
||||||
|
|
||||||
logger.info("메뉴 저장 성공", { savedMenu });
|
logger.info("메뉴 저장 성공", { savedMenu });
|
||||||
|
|
||||||
|
// 다국어 메뉴 카테고리 자동 생성
|
||||||
|
try {
|
||||||
|
const { MultiLangService } = await import("../services/multilangService");
|
||||||
|
const multilangService = new MultiLangService();
|
||||||
|
|
||||||
|
// 회사명 조회
|
||||||
|
const companyInfo = await queryOne<{ company_name: string }>(
|
||||||
|
`SELECT company_name FROM company_mng WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
|
||||||
|
|
||||||
|
// 메뉴 경로 조회 및 카테고리 생성
|
||||||
|
const menuPath = await multilangService.getMenuPath(savedMenu.objid.toString());
|
||||||
|
await multilangService.ensureMenuCategory(companyCode, companyName, menuPath);
|
||||||
|
|
||||||
|
logger.info("메뉴 다국어 카테고리 생성 완료", {
|
||||||
|
menuObjId: savedMenu.objid.toString(),
|
||||||
|
menuPath,
|
||||||
|
});
|
||||||
|
} catch (categoryError) {
|
||||||
|
logger.warn("메뉴 다국어 카테고리 생성 실패 (메뉴 저장은 성공)", {
|
||||||
|
menuObjId: savedMenu.objid.toString(),
|
||||||
|
error: categoryError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const response: ApiResponse<any> = {
|
const response: ApiResponse<any> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "메뉴가 성공적으로 저장되었습니다.",
|
message: "메뉴가 성공적으로 저장되었습니다.",
|
||||||
|
|
@ -2649,6 +2676,24 @@ export const createCompany = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 다국어 카테고리 자동 생성
|
||||||
|
try {
|
||||||
|
const { MultiLangService } = await import("../services/multilangService");
|
||||||
|
const multilangService = new MultiLangService();
|
||||||
|
await multilangService.ensureCompanyCategory(
|
||||||
|
createdCompany.company_code,
|
||||||
|
createdCompany.company_name
|
||||||
|
);
|
||||||
|
logger.info("회사 다국어 카테고리 생성 완료", {
|
||||||
|
companyCode: createdCompany.company_code,
|
||||||
|
});
|
||||||
|
} catch (categoryError) {
|
||||||
|
logger.warn("회사 다국어 카테고리 생성 실패 (회사 등록은 성공)", {
|
||||||
|
companyCode: createdCompany.company_code,
|
||||||
|
error: categoryError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("회사 등록 성공", {
|
logger.info("회사 등록 성공", {
|
||||||
companyCode: createdCompany.company_code,
|
companyCode: createdCompany.company_code,
|
||||||
companyName: createdCompany.company_name,
|
companyName: createdCompany.company_name,
|
||||||
|
|
|
||||||
|
|
@ -1098,3 +1098,80 @@ export const getBatchTranslations = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/multilang/screen-labels
|
||||||
|
* 화면 라벨 다국어 키 자동 생성 API
|
||||||
|
*/
|
||||||
|
export const generateScreenLabelKeys = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { screenId, menuObjId, labels } = req.body;
|
||||||
|
|
||||||
|
logger.info("화면 라벨 다국어 키 생성 요청", {
|
||||||
|
screenId,
|
||||||
|
menuObjId,
|
||||||
|
labelCount: labels?.length,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 필수 파라미터 검증
|
||||||
|
if (!screenId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "screenId는 필수입니다.",
|
||||||
|
error: { code: "MISSING_SCREEN_ID" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!labels || !Array.isArray(labels) || labels.length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "labels 배열이 필요합니다.",
|
||||||
|
error: { code: "MISSING_LABELS" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 회사 정보
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 회사명 조회
|
||||||
|
const { queryOne } = await import("../database/db");
|
||||||
|
const companyInfo = await queryOne<{ company_name: string }>(
|
||||||
|
`SELECT company_name FROM company_mng WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
|
||||||
|
|
||||||
|
const multiLangService = new MultiLangService();
|
||||||
|
const results = await multiLangService.generateScreenLabelKeys({
|
||||||
|
screenId: Number(screenId),
|
||||||
|
companyCode,
|
||||||
|
companyName,
|
||||||
|
menuObjId,
|
||||||
|
labels,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: ApiResponse<typeof results> = {
|
||||||
|
success: true,
|
||||||
|
message: `${results.length}개의 다국어 키가 생성되었습니다.`,
|
||||||
|
data: results,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("화면 라벨 다국어 키 생성 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "화면 라벨 다국어 키 생성 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ import {
|
||||||
previewKey,
|
previewKey,
|
||||||
createOverrideKey,
|
createOverrideKey,
|
||||||
getOverrideKeys,
|
getOverrideKeys,
|
||||||
|
|
||||||
|
// 화면 라벨 다국어 API
|
||||||
|
generateScreenLabelKeys,
|
||||||
} from "../controllers/multilangController";
|
} from "../controllers/multilangController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -73,4 +76,7 @@ router.post("/keys/preview", previewKey); // 키 미리보기
|
||||||
router.post("/keys/override", createOverrideKey); // 오버라이드 키 생성
|
router.post("/keys/override", createOverrideKey); // 오버라이드 키 생성
|
||||||
router.get("/keys/overrides/:companyCode", getOverrideKeys); // 오버라이드 키 목록 조회
|
router.get("/keys/overrides/:companyCode", getOverrideKeys); // 오버라이드 키 목록 조회
|
||||||
|
|
||||||
|
// 화면 라벨 다국어 자동 생성 API
|
||||||
|
router.post("/screen-labels", generateScreenLabelKeys); // 화면 라벨 다국어 키 자동 생성
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1293,4 +1293,291 @@ export class MultiLangService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 회사/메뉴 기반 카테고리 자동 생성 메서드
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면(screen) 루트 카테고리 확인 또는 생성
|
||||||
|
*/
|
||||||
|
async ensureScreenRootCategory(): Promise<number> {
|
||||||
|
try {
|
||||||
|
// 기존 screen 카테고리 확인
|
||||||
|
const existing = await queryOne<{ category_id: number }>(
|
||||||
|
`SELECT category_id FROM multi_lang_category
|
||||||
|
WHERE category_code = 'screen' AND parent_id IS NULL`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return existing.category_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 없으면 생성
|
||||||
|
const result = await queryOne<{ category_id: number }>(
|
||||||
|
`INSERT INTO multi_lang_category
|
||||||
|
(category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active, created_date)
|
||||||
|
VALUES ('screen', '화면', NULL, 1, 'screen', '화면 디자이너에서 자동 생성된 다국어 키', 100, 'Y', NOW())
|
||||||
|
RETURNING category_id`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("화면 루트 카테고리 생성", { categoryId: result?.category_id });
|
||||||
|
return result!.category_id;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("화면 루트 카테고리 생성 실패:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사 카테고리 확인 또는 생성
|
||||||
|
*/
|
||||||
|
async ensureCompanyCategory(companyCode: string, companyName: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const screenRootId = await this.ensureScreenRootCategory();
|
||||||
|
|
||||||
|
// 기존 회사 카테고리 확인
|
||||||
|
const existing = await queryOne<{ category_id: number }>(
|
||||||
|
`SELECT category_id FROM multi_lang_category
|
||||||
|
WHERE category_code = $1 AND parent_id = $2`,
|
||||||
|
[companyCode, screenRootId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return existing.category_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사 카테고리 생성
|
||||||
|
const displayName = companyCode === "*" ? "공통" : companyName;
|
||||||
|
const keyPrefix = companyCode === "*" ? "common" : companyCode.toLowerCase();
|
||||||
|
|
||||||
|
const result = await queryOne<{ category_id: number }>(
|
||||||
|
`INSERT INTO multi_lang_category
|
||||||
|
(category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active, created_date)
|
||||||
|
VALUES ($1, $2, $3, 2, $4, $5, $6, 'Y', NOW())
|
||||||
|
RETURNING category_id`,
|
||||||
|
[
|
||||||
|
companyCode,
|
||||||
|
displayName,
|
||||||
|
screenRootId,
|
||||||
|
keyPrefix,
|
||||||
|
`${displayName} 회사의 화면 다국어`,
|
||||||
|
companyCode === "*" ? 0 : 10,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("회사 카테고리 생성", { companyCode, categoryId: result?.category_id });
|
||||||
|
return result!.category_id;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("회사 카테고리 생성 실패:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴 카테고리 확인 또는 생성 (메뉴 경로 전체)
|
||||||
|
*/
|
||||||
|
async ensureMenuCategory(
|
||||||
|
companyCode: string,
|
||||||
|
companyName: string,
|
||||||
|
menuPath: string[] // ["영업관리", "수주관리"]
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
if (menuPath.length === 0) {
|
||||||
|
return await this.ensureCompanyCategory(companyCode, companyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parentId = await this.ensureCompanyCategory(companyCode, companyName);
|
||||||
|
let currentLevel = 3;
|
||||||
|
|
||||||
|
for (const menuName of menuPath) {
|
||||||
|
// 현재 메뉴 카테고리 확인
|
||||||
|
const existing = await queryOne<{ category_id: number }>(
|
||||||
|
`SELECT category_id FROM multi_lang_category
|
||||||
|
WHERE category_name = $1 AND parent_id = $2`,
|
||||||
|
[menuName, parentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
parentId = existing.category_id;
|
||||||
|
} else {
|
||||||
|
// 메뉴 카테고리 생성
|
||||||
|
const menuCode = `${companyCode}_${menuName}`.replace(/\s+/g, "_");
|
||||||
|
const keyPrefix = menuName.toLowerCase().replace(/\s+/g, "_");
|
||||||
|
|
||||||
|
const result = await queryOne<{ category_id: number }>(
|
||||||
|
`INSERT INTO multi_lang_category
|
||||||
|
(category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active, created_date)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 0, 'Y', NOW())
|
||||||
|
RETURNING category_id`,
|
||||||
|
[menuCode, menuName, parentId, currentLevel, keyPrefix, `${menuName} 메뉴의 다국어`]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("메뉴 카테고리 생성", { menuName, categoryId: result?.category_id });
|
||||||
|
parentId = result!.category_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLevel++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parentId;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("메뉴 카테고리 생성 실패:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴 경로 조회 (menu_info에서 부모 메뉴까지)
|
||||||
|
*/
|
||||||
|
async getMenuPath(menuObjId: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const menus = await query<{ menu_name_kor: string; level: number }>(
|
||||||
|
`WITH RECURSIVE menu_path AS (
|
||||||
|
SELECT objid, parent_obj_id, menu_name_kor, 1 as level
|
||||||
|
FROM menu_info
|
||||||
|
WHERE objid = $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT m.objid, m.parent_obj_id, m.menu_name_kor, mp.level + 1
|
||||||
|
FROM menu_info m
|
||||||
|
INNER JOIN menu_path mp ON m.objid = mp.parent_obj_id
|
||||||
|
WHERE m.parent_obj_id IS NOT NULL AND m.parent_obj_id != 0
|
||||||
|
)
|
||||||
|
SELECT menu_name_kor, level FROM menu_path
|
||||||
|
WHERE menu_name_kor IS NOT NULL
|
||||||
|
ORDER BY level DESC`,
|
||||||
|
[menuObjId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return menus.map((m) => m.menu_name_kor);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("메뉴 경로 조회 실패:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 라벨 다국어 키 자동 생성
|
||||||
|
*/
|
||||||
|
async generateScreenLabelKeys(params: {
|
||||||
|
screenId: number;
|
||||||
|
companyCode: string;
|
||||||
|
companyName: string;
|
||||||
|
menuObjId?: string;
|
||||||
|
labels: Array<{ componentId: string; label: string; type?: string }>;
|
||||||
|
}): Promise<Array<{ componentId: string; keyId: number; langKey: string }>> {
|
||||||
|
try {
|
||||||
|
logger.info("화면 라벨 다국어 키 자동 생성 시작", {
|
||||||
|
screenId: params.screenId,
|
||||||
|
companyCode: params.companyCode,
|
||||||
|
labelCount: params.labels.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 메뉴 경로 조회
|
||||||
|
const menuPath = params.menuObjId
|
||||||
|
? await this.getMenuPath(params.menuObjId)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// 메뉴 카테고리 확보
|
||||||
|
const categoryId = await this.ensureMenuCategory(
|
||||||
|
params.companyCode,
|
||||||
|
params.companyName,
|
||||||
|
menuPath
|
||||||
|
);
|
||||||
|
|
||||||
|
// 카테고리 경로 조회 (키 생성용)
|
||||||
|
const categoryPath = await this.getCategoryPath(categoryId);
|
||||||
|
const keyPrefixParts = categoryPath.map((c) => c.keyPrefix);
|
||||||
|
|
||||||
|
const results: Array<{ componentId: string; keyId: number; langKey: string }> = [];
|
||||||
|
|
||||||
|
for (const labelInfo of params.labels) {
|
||||||
|
// 라벨을 키 형태로 변환 (한글 → 스네이크케이스)
|
||||||
|
const keyMeaning = this.labelToKeyMeaning(labelInfo.label);
|
||||||
|
const langKey = [...keyPrefixParts, keyMeaning].join(".");
|
||||||
|
|
||||||
|
// 기존 키 확인
|
||||||
|
const existingKey = await queryOne<{ key_id: number }>(
|
||||||
|
`SELECT key_id FROM multi_lang_key_master
|
||||||
|
WHERE lang_key = $1 AND company_code = $2`,
|
||||||
|
[langKey, params.companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
let keyId: number;
|
||||||
|
|
||||||
|
if (existingKey) {
|
||||||
|
keyId = existingKey.key_id;
|
||||||
|
logger.info("기존 키 사용", { langKey, keyId });
|
||||||
|
} else {
|
||||||
|
// 새 키 생성
|
||||||
|
const keyResult = await queryOne<{ key_id: number }>(
|
||||||
|
`INSERT INTO multi_lang_key_master
|
||||||
|
(company_code, lang_key, description, is_active, category_id, key_meaning, created_date, created_by)
|
||||||
|
VALUES ($1, $2, $3, 'Y', $4, $5, NOW(), 'system')
|
||||||
|
RETURNING key_id`,
|
||||||
|
[
|
||||||
|
params.companyCode,
|
||||||
|
langKey,
|
||||||
|
`화면 ${params.screenId}의 ${labelInfo.type || "라벨"}: ${labelInfo.label}`,
|
||||||
|
categoryId,
|
||||||
|
keyMeaning,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
keyId = keyResult!.key_id;
|
||||||
|
|
||||||
|
// 한국어 텍스트 저장 (원문)
|
||||||
|
await query(
|
||||||
|
`INSERT INTO multi_lang_text (key_id, lang_code, lang_text, is_active, created_date, created_by)
|
||||||
|
VALUES ($1, 'KR', $2, 'Y', NOW(), 'system')
|
||||||
|
ON CONFLICT (key_id, lang_code) DO UPDATE SET lang_text = $2, updated_date = NOW()`,
|
||||||
|
[keyId, labelInfo.label]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("새 키 생성", { langKey, keyId });
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
componentId: labelInfo.componentId,
|
||||||
|
keyId,
|
||||||
|
langKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("화면 라벨 다국어 키 생성 완료", {
|
||||||
|
screenId: params.screenId,
|
||||||
|
generatedCount: results.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("화면 라벨 다국어 키 생성 실패:", error);
|
||||||
|
throw new Error(
|
||||||
|
`화면 라벨 다국어 키 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라벨을 키 의미로 변환 (한글 → 스네이크케이스 또는 영문 유지)
|
||||||
|
*/
|
||||||
|
private labelToKeyMeaning(label: string): string {
|
||||||
|
// 이미 영문 스네이크케이스면 그대로 사용
|
||||||
|
if (/^[a-z][a-z0-9_]*$/.test(label)) {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 영문 일반이면 스네이크케이스로 변환
|
||||||
|
if (/^[A-Za-z][A-Za-z0-9 ]*$/.test(label)) {
|
||||||
|
return label.toLowerCase().replace(/\s+/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 한글이면 간단한 변환 (특수문자 제거, 공백을 _로)
|
||||||
|
return label
|
||||||
|
.replace(/[^\w가-힣\s]/g, "")
|
||||||
|
.replace(/\s+/g, "_")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
Layout,
|
Layout,
|
||||||
Monitor,
|
Monitor,
|
||||||
Square,
|
Square,
|
||||||
|
Languages,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -34,6 +35,8 @@ interface DesignerToolbarProps {
|
||||||
isSaving?: boolean;
|
isSaving?: boolean;
|
||||||
showZoneBorders?: boolean;
|
showZoneBorders?: boolean;
|
||||||
onToggleZoneBorders?: () => void;
|
onToggleZoneBorders?: () => void;
|
||||||
|
onGenerateMultilang?: () => void;
|
||||||
|
isGeneratingMultilang?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||||
|
|
@ -50,6 +53,8 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
showZoneBorders = true,
|
showZoneBorders = true,
|
||||||
onToggleZoneBorders,
|
onToggleZoneBorders,
|
||||||
|
onGenerateMultilang,
|
||||||
|
isGeneratingMultilang = false,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 py-3 shadow-sm">
|
<div className="flex items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 py-3 shadow-sm">
|
||||||
|
|
@ -226,6 +231,20 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||||
|
|
||||||
<div className="h-6 w-px bg-gray-300" />
|
<div className="h-6 w-px bg-gray-300" />
|
||||||
|
|
||||||
|
{onGenerateMultilang && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onGenerateMultilang}
|
||||||
|
disabled={isGeneratingMultilang}
|
||||||
|
className="flex items-center space-x-1"
|
||||||
|
title="화면 라벨에 대한 다국어 키를 자동으로 생성합니다"
|
||||||
|
>
|
||||||
|
<Languages className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{isGeneratingMultilang ? "생성 중..." : "다국어"}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
|
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
|
||||||
<Save className="h-4 w-4" />
|
<Save className="h-4 w-4" />
|
||||||
<span>{isSaving ? "저장 중..." : "저장"}</span>
|
<span>{isSaving ? "저장 중..." : "저장"}</span>
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isGeneratingMultilang, setIsGeneratingMultilang] = useState(false);
|
||||||
|
|
||||||
// 🆕 화면에 할당된 메뉴 OBJID
|
// 🆕 화면에 할당된 메뉴 OBJID
|
||||||
const [menuObjid, setMenuObjid] = useState<number | undefined>(undefined);
|
const [menuObjid, setMenuObjid] = useState<number | undefined>(undefined);
|
||||||
|
|
@ -1447,6 +1448,103 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}
|
}
|
||||||
}, [selectedScreen, layout, screenResolution]);
|
}, [selectedScreen, layout, screenResolution]);
|
||||||
|
|
||||||
|
// 다국어 자동 생성 핸들러
|
||||||
|
const handleGenerateMultilang = useCallback(async () => {
|
||||||
|
if (!selectedScreen?.screenId) {
|
||||||
|
toast.error("화면 정보가 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGeneratingMultilang(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 모든 컴포넌트에서 라벨 정보 추출
|
||||||
|
const labels: Array<{ componentId: string; label: string; type?: string }> = [];
|
||||||
|
|
||||||
|
const extractLabels = (components: ComponentData[]) => {
|
||||||
|
components.forEach((comp) => {
|
||||||
|
const anyComp = comp as any;
|
||||||
|
|
||||||
|
// 라벨 추출
|
||||||
|
if (anyComp.label && typeof anyComp.label === "string" && anyComp.label.trim()) {
|
||||||
|
labels.push({
|
||||||
|
componentId: comp.id,
|
||||||
|
label: anyComp.label.trim(),
|
||||||
|
type: "label",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 제목 추출 (컨테이너, 카드 등)
|
||||||
|
if (anyComp.title && typeof anyComp.title === "string" && anyComp.title.trim()) {
|
||||||
|
labels.push({
|
||||||
|
componentId: comp.id,
|
||||||
|
label: anyComp.title.trim(),
|
||||||
|
type: "title",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 버튼 텍스트 추출
|
||||||
|
if (anyComp.componentConfig?.text && typeof anyComp.componentConfig.text === "string") {
|
||||||
|
labels.push({
|
||||||
|
componentId: comp.id,
|
||||||
|
label: anyComp.componentConfig.text.trim(),
|
||||||
|
type: "button",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeholder 추출
|
||||||
|
if (anyComp.placeholder && typeof anyComp.placeholder === "string" && anyComp.placeholder.trim()) {
|
||||||
|
labels.push({
|
||||||
|
componentId: comp.id,
|
||||||
|
label: anyComp.placeholder.trim(),
|
||||||
|
type: "placeholder",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자식 컴포넌트 재귀 탐색
|
||||||
|
if (anyComp.children && Array.isArray(anyComp.children)) {
|
||||||
|
extractLabels(anyComp.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
extractLabels(layout.components);
|
||||||
|
|
||||||
|
if (labels.length === 0) {
|
||||||
|
toast.info("다국어로 변환할 라벨이 없습니다.");
|
||||||
|
setIsGeneratingMultilang(false);
|
||||||
|
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({
|
||||||
|
screenId: selectedScreen.screenId,
|
||||||
|
menuObjId: menuObjid?.toString(),
|
||||||
|
labels,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
toast.success(`${response.data.length}개의 다국어 키가 생성되었습니다.`);
|
||||||
|
console.log("✅ 다국어 키 생성 완료:", response.data);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error?.details || "다국어 키 생성에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 다국어 생성 실패:", error);
|
||||||
|
toast.error("다국어 키 생성 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingMultilang(false);
|
||||||
|
}
|
||||||
|
}, [selectedScreen, layout.components, menuObjid]);
|
||||||
|
|
||||||
// 템플릿 드래그 처리
|
// 템플릿 드래그 처리
|
||||||
const handleTemplateDrop = useCallback(
|
const handleTemplateDrop = useCallback(
|
||||||
(e: React.DragEvent, template: TemplateComponent) => {
|
(e: React.DragEvent, template: TemplateComponent) => {
|
||||||
|
|
@ -4217,6 +4315,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
onBack={onBackToList}
|
onBack={onBackToList}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
|
onGenerateMultilang={handleGenerateMultilang}
|
||||||
|
isGeneratingMultilang={isGeneratingMultilang}
|
||||||
/>
|
/>
|
||||||
{/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
|
{/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Database, ArrowLeft, Save, Monitor, Smartphone } from "lucide-react";
|
import { Database, ArrowLeft, Save, Monitor, Smartphone, Languages } from "lucide-react";
|
||||||
import { ScreenResolution } from "@/types/screen";
|
import { ScreenResolution } from "@/types/screen";
|
||||||
|
|
||||||
interface SlimToolbarProps {
|
interface SlimToolbarProps {
|
||||||
|
|
@ -13,6 +13,8 @@ interface SlimToolbarProps {
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
isSaving?: boolean;
|
isSaving?: boolean;
|
||||||
onPreview?: () => void;
|
onPreview?: () => void;
|
||||||
|
onGenerateMultilang?: () => void;
|
||||||
|
isGeneratingMultilang?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||||
|
|
@ -23,6 +25,8 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||||
onSave,
|
onSave,
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
onPreview,
|
onPreview,
|
||||||
|
onGenerateMultilang,
|
||||||
|
isGeneratingMultilang = false,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-14 items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 shadow-sm">
|
<div className="flex h-14 items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 shadow-sm">
|
||||||
|
|
@ -70,6 +74,18 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||||
<span>반응형 미리보기</span>
|
<span>반응형 미리보기</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{onGenerateMultilang && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onGenerateMultilang}
|
||||||
|
disabled={isGeneratingMultilang}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
title="화면 라벨에 대한 다국어 키를 자동으로 생성합니다"
|
||||||
|
>
|
||||||
|
<Languages className="h-4 w-4" />
|
||||||
|
<span>{isGeneratingMultilang ? "생성 중..." : "다국어 생성"}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
|
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
|
||||||
<Save className="h-4 w-4" />
|
<Save className="h-4 w-4" />
|
||||||
<span>{isSaving ? "저장 중..." : "저장"}</span>
|
<span>{isSaving ? "저장 중..." : "저장"}</span>
|
||||||
|
|
|
||||||
|
|
@ -360,3 +360,43 @@ export async function toggleLangKey(keyId: number): Promise<ApiResponse<string>>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 화면 라벨 다국어 자동 생성 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface ScreenLabelKeyResult {
|
||||||
|
componentId: string;
|
||||||
|
keyId: number;
|
||||||
|
langKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateScreenLabelKeysRequest {
|
||||||
|
screenId: number;
|
||||||
|
menuObjId?: string;
|
||||||
|
labels: Array<{
|
||||||
|
componentId: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 라벨 다국어 키 자동 생성
|
||||||
|
*/
|
||||||
|
export async function generateScreenLabelKeys(
|
||||||
|
params: GenerateScreenLabelKeysRequest
|
||||||
|
): Promise<ApiResponse<ScreenLabelKeyResult[]>> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/multilang/screen-labels", params);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
|
||||||
|
details: error.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue