parent
8114ca58c5
commit
96d6d13f61
@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import IconArrowDown from "@/components/atoms/button/IconArrowDown.vue";
|
||||
import IconArrowUp from "@/components/atoms/button/IconArrowUp.vue";
|
||||
import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue";
|
||||
import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.vue";
|
||||
import { computed, onBeforeUnmount, onMounted, watch, ref } from "vue";
|
||||
import { DataGroupService } from "@/components/service/management/DataGroupService";
|
||||
import { storage } from "@/utils/storage";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useAutoflowStore } from "@/stores/autoflowStore";
|
||||
|
||||
const { projectId } = storeToRefs(useAutoflowStore());
|
||||
|
||||
const props = defineProps<{ editData: any; mode: "create" | "edit" }>();
|
||||
const emit = defineEmits<{
|
||||
(e: "close-modal"): void;
|
||||
(e: "saved", v: any): void;
|
||||
}>();
|
||||
|
||||
const isEdit = computed(() => props.mode === "edit");
|
||||
|
||||
const saving = ref(false);
|
||||
const errorMsg = ref("");
|
||||
|
||||
const form = ref({ name: "", description: "" });
|
||||
|
||||
// 편집 데이터 → 폼
|
||||
function hydrateFormFromEdit(data: any) {
|
||||
if (!data) return;
|
||||
form.value.name = data.workflowName ?? data.dsNm ?? data.name ?? "";
|
||||
form.value.description =
|
||||
data.workflowDescription ?? data.dsDesc ?? data.description ?? "";
|
||||
}
|
||||
onMounted(() => {
|
||||
if (isEdit.value) hydrateFormFromEdit(props.editData);
|
||||
});
|
||||
watch(
|
||||
() => props.editData,
|
||||
(v) => {
|
||||
if (isEdit.value) hydrateFormFromEdit(v);
|
||||
},
|
||||
);
|
||||
|
||||
// 유저/프로젝트
|
||||
function getAuthUser() {
|
||||
const authObj =
|
||||
(typeof storage?.getAuth === "function" ? storage.getAuth() : null) ??
|
||||
JSON.parse(localStorage.getItem("autoflow-auth") || "{}");
|
||||
const ui = authObj?.userInfo ?? authObj?.userinfo ?? authObj ?? {};
|
||||
return { id: Number(ui.id), username: String(ui.username ?? "").trim() };
|
||||
}
|
||||
|
||||
function cleanUndefined<T extends Record<string, any>>(obj: T): T {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([, v]) => v !== undefined),
|
||||
) as T;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
errorMsg.value = "";
|
||||
|
||||
const name = (form.value.name ?? "").trim(); // ← 제한 없음(한글/특수문자 OK)
|
||||
if (!name) {
|
||||
errorMsg.value = "이름을 입력하세요.";
|
||||
return;
|
||||
}
|
||||
|
||||
const { id: userId, username } = getAuthUser();
|
||||
if (!userId || !username) {
|
||||
errorMsg.value = "로그인 사용자 정보를 찾을 수 없습니다.";
|
||||
return;
|
||||
}
|
||||
if (!projectId.value) {
|
||||
errorMsg.value = "프로젝트가 선택되지 않았습니다.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
saving.value = true;
|
||||
|
||||
if (isEdit.value) {
|
||||
const rawId = props.editData?.id ?? props.editData?.deviceKey;
|
||||
const id = Number(rawId);
|
||||
if (!id) {
|
||||
errorMsg.value = "수정할 ID가 없습니다.";
|
||||
return;
|
||||
}
|
||||
|
||||
const viewRes = await DataGroupService.view(id);
|
||||
const current = (viewRes?.data ?? viewRes) || {};
|
||||
|
||||
const updatePayload = {
|
||||
id,
|
||||
dsNm: name,
|
||||
dsDesc: form.value.description ?? "",
|
||||
|
||||
projectId: current.projectId,
|
||||
regUserId: current.regUserId,
|
||||
regUserNm: current.regUserNm,
|
||||
|
||||
modUserId: userId,
|
||||
modUserNm: username,
|
||||
refType: "TRAINING_SCRIPT",
|
||||
};
|
||||
|
||||
const { data } = await DataGroupService.update(id, updatePayload);
|
||||
emit("saved", data);
|
||||
emit("close-modal");
|
||||
} else {
|
||||
const createPayload = {
|
||||
dsNm: name,
|
||||
dsDesc: form.value.description ?? "",
|
||||
regUserId: userId,
|
||||
regUserNm: username,
|
||||
projectId: projectId.value!,
|
||||
refType: "TRAINING_SCRIPT",
|
||||
};
|
||||
|
||||
const { data } = await DataGroupService.add(createPayload);
|
||||
emit("saved", data);
|
||||
emit("close-modal");
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("데이터그룹 저장 실패:", e);
|
||||
const status = e?.response?.status;
|
||||
const raw =
|
||||
(typeof e?.response?.data === "string"
|
||||
? e?.response?.data
|
||||
: e?.response?.data?.message || e?.response?.data?.error) ||
|
||||
e?.message ||
|
||||
"";
|
||||
if (status === 409)
|
||||
errorMsg.value = "같은 이름의 데이터그룹이 이미 존재합니다.";
|
||||
else if (status === 400) errorMsg.value = "요청 형식이 올바르지 않습니다.";
|
||||
else if (status === 401 || status === 403)
|
||||
errorMsg.value = "권한이 없거나 로그인 정보가 만료되었습니다.";
|
||||
else errorMsg.value = raw || `요청 실패 (HTTP ${status ?? "Error"})`;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onEsc(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") emit("close-modal");
|
||||
}
|
||||
onMounted(() => window.addEventListener("keydown", onEsc));
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title
|
||||
class="text-white font-weight-bold text-h6"
|
||||
style="background-color: #1976d2"
|
||||
>
|
||||
{{ isEdit ? "Edit TrainingGroup" : "Create TrainingGroup" }}
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<div class="text-subtitle-1 font-weight-medium mb-4">
|
||||
TrainingGroup Information
|
||||
</div>
|
||||
|
||||
<v-form @submit.prevent="submit">
|
||||
<!-- Name: 제한 없음 -->
|
||||
<div class="mb-5">
|
||||
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"
|
||||
>TrainingGroup Name</label
|
||||
>
|
||||
<v-text-field
|
||||
v-model="form.name"
|
||||
variant="outlined"
|
||||
:disabled="saving"
|
||||
dense
|
||||
hide-details="auto"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description: 제한 없음 -->
|
||||
<div class="mb-5">
|
||||
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"
|
||||
>Description</label
|
||||
>
|
||||
<v-textarea
|
||||
v-model="form.description"
|
||||
variant="outlined"
|
||||
:disabled="saving"
|
||||
rows="3"
|
||||
dense
|
||||
hide-details="auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="mt-3 text-error">{{ errorMsg }}</div>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="justify-end" style="padding: 16px 24px">
|
||||
<v-btn color="success" :loading="saving" @click="submit">
|
||||
{{ isEdit ? "Update" : "Save" }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
text
|
||||
class="white--text"
|
||||
:disabled="saving"
|
||||
@click="$emit('close-modal')"
|
||||
>Close</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
@ -0,0 +1,20 @@
|
||||
import { request } from "@/components/service/index";
|
||||
|
||||
export const MlflowService = {
|
||||
getRuns: (experimentId: string) => {
|
||||
return request.get("/api/mlflow/runs", {
|
||||
experimentId,
|
||||
});
|
||||
},
|
||||
|
||||
getExperimentByName: (experimentName: string) => {
|
||||
return request.get("/api/mlflow/experiment", {
|
||||
experimentName,
|
||||
});
|
||||
},
|
||||
getExperimentRun: (runId: string) => {
|
||||
return request.get("/api/mlflow/run", {
|
||||
runId,
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,162 @@
|
||||
<!-- src/components/layout/TopNav.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { menuUtils } from "@/utils/menuUtils";
|
||||
import { storage } from "@/utils/storage";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const drawer = ref(false);
|
||||
|
||||
// ===== 권한/활성 =====
|
||||
function readRolesFromStorage(): string[] {
|
||||
try {
|
||||
const raw =
|
||||
storage.get?.("autoflow-auth") ??
|
||||
localStorage.getItem("autoflow-auth") ??
|
||||
null;
|
||||
const auth = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
let roles = auth?.userInfo?.roles ?? auth?.roles ?? [];
|
||||
if (typeof roles === "string")
|
||||
roles = roles.split(",").map((s: string) => s.trim());
|
||||
return Array.isArray(roles) ? roles : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const isAdmin = computed(() => {
|
||||
const roles = readRolesFromStorage();
|
||||
return roles.some((r) => r === "ROLE_ADMIN" || r === "ADMIN");
|
||||
});
|
||||
|
||||
const menus = computed(() => [
|
||||
...menuUtils.menuItem,
|
||||
...(isAdmin.value ? menuUtils.adminMenuItem : []),
|
||||
]);
|
||||
|
||||
const isActive = (path?: string) => !!path && route.path.startsWith(path);
|
||||
const go = (path: string) => {
|
||||
if (path && path !== route.path) router.push(path);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 상단 앱바 -->
|
||||
<v-app-bar flat height="64" class="topbar">
|
||||
<!-- 모바일 햄버거 -->
|
||||
<v-app-bar-nav-icon class="d-md-none" @click="drawer = true" />
|
||||
|
||||
<!-- 좌측 로고/타이틀 -->
|
||||
<v-toolbar-title class="text-subtitle-1 font-weight-bold">
|
||||
Autoflow Web Console
|
||||
</v-toolbar-title>
|
||||
|
||||
<!-- 가로 메뉴(데스크탑) -->
|
||||
<div class="d-none d-md-flex align-center ml-4 ga-1">
|
||||
<template v-for="(m, i) in menus" :key="`m_${i}`">
|
||||
<!-- depth가 있는 메뉴: 드롭다운 -->
|
||||
<v-menu v-if="m.depth?.length" open-on-hover close-on-content-click>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
variant="text"
|
||||
class="nav-btn"
|
||||
:class="{
|
||||
'nav-active': m.depth?.some((d: any) => isActive(d.path)),
|
||||
}"
|
||||
append-icon="mdi-chevron-down"
|
||||
>
|
||||
<v-icon start :icon="m.icon" class="mr-1" />
|
||||
{{ m.title }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" class="min-w-48">
|
||||
<v-list-item
|
||||
v-for="(d, j) in m.depth"
|
||||
:key="`d_${j}`"
|
||||
:title="d.title"
|
||||
:to="d.path"
|
||||
:active="isActive(d.path)"
|
||||
:color="isActive(d.path) ? 'primary' : undefined"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<!-- 단일 링크 -->
|
||||
<v-btn
|
||||
v-else
|
||||
variant="text"
|
||||
class="nav-btn"
|
||||
:class="{ 'nav-active': isActive(m.path) }"
|
||||
@click="go(m.path)"
|
||||
>
|
||||
<v-icon start :icon="m.icon" class="mr-1" />
|
||||
{{ m.title }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<!-- 우측 액션 예시 -->
|
||||
<div class="d-none d-md-flex align-center ga-2">
|
||||
<v-btn icon variant="text" :to="'/home'"><v-icon>mdi-home</v-icon></v-btn>
|
||||
<v-btn icon variant="text"><v-icon>mdi-cog</v-icon></v-btn>
|
||||
</div>
|
||||
</v-app-bar>
|
||||
|
||||
<!-- 모바일 드로어 -->
|
||||
<v-navigation-drawer v-model="drawer" temporary class="d-md-none" width="280">
|
||||
<v-list nav density="comfortable" class="pa-2">
|
||||
<template v-for="(m, i) in menus" :key="`sm_${i}`">
|
||||
<v-list-group v-if="m.depth?.length">
|
||||
<template #activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
:prepend-icon="m.icon"
|
||||
:title="m.title"
|
||||
/>
|
||||
</template>
|
||||
<v-list-item
|
||||
v-for="(d, j) in m.depth"
|
||||
:key="`smd_${j}`"
|
||||
:title="d.title"
|
||||
:to="d.path"
|
||||
:active="isActive(d.path)"
|
||||
@click="drawer = false"
|
||||
/>
|
||||
</v-list-group>
|
||||
|
||||
<v-list-item
|
||||
v-else
|
||||
:prepend-icon="m.icon"
|
||||
:title="m.title"
|
||||
:to="m.path"
|
||||
:active="isActive(m.path)"
|
||||
@click="drawer = false"
|
||||
/>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
background: rgba(18, 18, 18, 0.7) !important;
|
||||
backdrop-filter: saturate(140%) blur(8px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.nav-btn {
|
||||
height: 40px;
|
||||
text-transform: none;
|
||||
border-radius: 10px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.nav-active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
.min-w-48 {
|
||||
min-width: 12rem;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in new issue