You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
autoflow-web-console/src/components/common/LayoutComponent.vue

616 lines
17 KiB

<script setup lang="ts">
/* ================================
* Imports
* ================================ */
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { storage } from "@/utils/storage.js";
import { UserManagerService } from "@/components/service/management/UserManagerService";
import { menuUtils } from "@/utils/menuUtils";
/* ================================
* Types
* ================================ */
type MenuItem = {
title: string;
icon?: string;
path?: string;
depth?: Array<{ title: string; path: string }>;
click?: () => void;
};
/* ================================
* Router / Reactive base state
* ================================ */
const route = useRoute();
const router = useRouter();
const username = ref<string>("");
const projectName = ref<string>(localStorage.getItem("projectName") || "");
const isAdmin = ref<boolean>(false);
const adminMode = ref<boolean>(false); // Settings 버튼으로 온/오프
const lastNonAdminPath = ref<string>("/home");
/* ================================
* Auth / Role helpers
* ================================ */
function readAuth() {
try {
const raw =
typeof storage?.getAuth === "function"
? storage.getAuth()
: JSON.parse(localStorage.getItem("autoflow-auth") || "null");
return raw ?? null;
} catch {
return null;
}
}
function computeIsAdmin() {
const auth = readAuth();
let roles = auth?.userInfo?.roles ?? auth?.roles ?? [];
if (typeof roles === "string") {
roles = roles.split(",").map((s: string) => String(s).trim());
}
const rolesArr = Array.isArray(roles) ? roles : [];
const roleStrings = rolesArr.map((r) =>
typeof r === "object" && r !== null && "authority" in r
? String((r as { authority?: string }).authority ?? "")
: typeof r === "object" && r !== null && "name" in r
? String((r as { name?: string }).name ?? "")
: String(r),
);
const authCd = String(
auth?.userInfo?.authCd ?? auth?.authCd ?? auth?.auth ?? "",
).toUpperCase();
const username =
auth?.userInfo?.username ?? auth?.username ?? auth?.userName ?? "";
const inRoles = roleStrings.some(
(r) => r === "ROLE_ADMIN" || String(r).toUpperCase() === "ADMIN",
);
isAdmin.value =
inRoles ||
authCd === "ADMIN" ||
String(username).toLowerCase() === "admin";
}
function updateUsername() {
const auth = readAuth();
username.value = auth?.userInfo?.username ?? auth?.username ?? "";
}
/* ================================
* Derived route state
* - /select 에서는 상단 메뉴 전체 숨김
* - 관리자 탭 표시 조건: adminMode ON || 관리자 라우트
* ================================ */
const hideAllMenus = computed<boolean>(() => route.path.startsWith("/select"));
const isAdminRoute = computed<boolean>(() => {
const p = route.path || "";
const hitPath =
p.startsWith("/project") ||
p.startsWith("/users") ||
p.startsWith("/system-status") ||
p.startsWith("/select");
const hitMeta = route.matched.some((r) => r.meta?.requiresAdmin);
return hitPath || hitMeta;
});
const showAdminTabs = computed<boolean>(
() => adminMode.value || isAdminRoute.value,
);
/* ================================
* Menus (기본/관리자)
* ================================ */
const baseMenus = computed<MenuItem[]>(
() => (menuUtils?.menuItem ?? []) as MenuItem[],
);
const adminMenus = computed<MenuItem[]>(() => {
const fromUtil = (menuUtils?.adminMenuItem ?? []) as MenuItem[];
return fromUtil.length
? fromUtil
: [
{ title: "Projects", icon: "mdi-briefcase", path: "/project" },
{ title: "Users", icon: "mdi-account-multiple", path: "/users" },
];
});
const isLinkActive = (path?: string) => !!path && route.path.startsWith(path);
/* ================================
* Header dropdown menu
* ================================ */
const menu = ref<MenuItem[]>([]);
const menuItemsBase: MenuItem[] = [
{ title: "Select Project", click: () => goSelect() },
{
title: "Change Password",
click: () => {
/* open modal */
},
},
{ title: "Logout", icon: "mdi-logout", click: () => logOut() },
];
/* ================================
* Navigation actions
* ================================ */
function goMain() {
adminMode.value = false;
router.push("/home");
}
function goSelect() {
adminMode.value = false;
router.push("/select");
}
function toggleAdmin() {
if (!isAdmin.value) return;
if (adminMode.value) {
// 끌 때는 무조건 홈으로
adminMode.value = false;
router.push("/home");
} else {
adminMode.value = true;
// 켰는데 일반 라우트면 프로젝트로 이동
if (!isAdminRoute.value) router.push("/project");
}
}
/** 관리자 메뉴 클릭 시 해당 경로로 이동 (SPA 실패 시 location으로 fallback) */
function goToAdminMenu(path: string | undefined) {
if (!path) return;
router.push(path).catch((err) => {
if (err?.name !== "NavigationDuplicated") console.warn("admin nav", err);
});
setTimeout(() => {
if (route.path !== path && path === "/system-status") {
window.location.href = resolveHref(path);
}
}, 200);
}
/** base 포함 전체 href (클릭 시 네트워크 요청·이동 보장용) */
function resolveHref(path: string | undefined): string {
if (!path) return "#";
try {
return router.resolve(path).href;
} catch {
return path.startsWith("/") ? path : "/" + path;
}
}
/** 우측 드롭다운에서 관리자(경로) 항목인지 */
function isAdminMenuItem(item: MenuItem): boolean {
return !!item.path && item.path.startsWith("/system-status");
}
/** 우측 드롭다운 메뉴 클릭: click 있으면 먼저 실행, 없으면 path로 이동 */
function onMenuItemClick(item: MenuItem, e?: MouseEvent) {
e?.preventDefault();
e?.stopPropagation();
if (item.click) {
item.click();
} else if (item.path) {
router.push(item.path);
}
}
function logOut() {
UserManagerService.signOut()
.catch(console.error)
.finally(() => {
localStorage.removeItem("autoflow-auth");
localStorage.removeItem("external-auth");
localStorage.removeItem("projectName");
localStorage.removeItem("projectId");
sessionStorage.removeItem("initialRedirectDone");
username.value = "";
projectName.value = "";
adminMode.value = false;
router.push("/login");
});
}
/* ================================
* Effects
* ================================ */
function refreshProjectName() {
projectName.value = localStorage.getItem("projectName") || "";
}
// 라우트 변경 시 프로젝트명 동기화 및 마지막 일반 경로 저장
watch(
() => route.fullPath,
() => {
refreshProjectName();
computeIsAdmin();
if (!isAdminRoute.value) lastNonAdminPath.value = route.fullPath || "/home";
},
{ immediate: true },
);
// storage 이벤트 동기화
function onStorage(e: StorageEvent) {
if (!e.key || e.key === "projectName") {
refreshProjectName();
adminMode.value = false;
}
if (!e.key || e.key === "autoflow-auth" || e.key === "auth") {
updateUsername();
computeIsAdmin();
}
}
/* ================================
* Lifecycle
* ================================ */
const menuItems = computed<MenuItem[]>(() => {
const items = [...menuItemsBase];
if (isAdmin.value) {
items.splice(1, 0, {
title: "사용자 관리",
icon: "mdi-account-multiple",
path: "/users",
click: () => router.push("/users"),
});
items.splice(2, 0, {
title: "관리자",
icon: "mdi-cog",
path: "/system-status",
click: () => goToAdminMenu("/system-status"),
});
}
return items;
});
watch(menuItems, (v) => {
menu.value = v;
}, { immediate: true });
onMounted(() => {
updateUsername();
computeIsAdmin();
refreshProjectName();
menu.value = menuItems.value;
window.addEventListener("storage", onStorage);
});
onBeforeUnmount(() => {
window.removeEventListener("storage", onStorage);
});
</script>
<template>
<v-app>
<!-- ===== 상단 탑바 ===== -->
<v-app-bar flat height="64" class="topbar">
<div
variant="text"
size="large"
class="brand-btn text-h5"
@click="goMain"
aria-label="Home"
>
AUTOFLOW WEB CONSOLE
</div>
<!-- 중앙: 메뉴 그룹 (Settings / 분기) -->
<div class="center-nav d-none d-md-flex" v-if="!hideAllMenus">
<template v-if="showAdminTabs">
<template v-for="(m, i) in adminMenus" :key="'am_' + i">
<!-- 드롭다운 있는 항목 -->
<v-menu
v-if="m.depth?.length"
open-on-hover
close-on-content-click
location="bottom"
>
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="text"
class="nav-btn"
:class="{
'nav-active': m.depth?.some((d: any) =>
isLinkActive(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="'amd_' + j"
:title="d.title"
:to="d.path"
:active="isLinkActive(d.path)"
active-class="nav-active"
/>
</v-list>
</v-menu>
<!-- 드롭다운 없는 단일 항목: 버튼/링크 둘 다 클릭 시 이동 (Project/Users에서도 동작) -->
<a
v-else
:href="resolveHref(m.path)"
class="admin-nav-link"
@click.prevent="goToAdminMenu(m.path)"
>
<v-btn
variant="text"
class="nav-btn"
:class="{ 'nav-active': isLinkActive(m.path) }"
@click.prevent="goToAdminMenu(m.path)"
>
<v-icon start :icon="m.icon" class="mr-1" />
{{ m.title }}
</v-btn>
</a>
</template>
</template>
<!-- 기본 메뉴 -->
<template v-else>
<template v-for="(m, i) in baseMenus" :key="'m_' + i">
<v-menu
v-if="m.depth?.length"
open-on-hover
close-on-content-click
location="bottom"
transition="scale-transition"
>
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="text"
class="nav-btn text-white"
:class="{
'nav-active': m.depth?.some((d: any) =>
isLinkActive(d.path),
),
}"
append-icon="mdi-chevron-down"
>
<v-icon start :icon="m.icon" class="mr-1" />
{{ m.title }}
</v-btn>
</template>
<!-- ✅ 여기부터: Run 전용 디자인 -->
<template
v-if="
(m.title && m.title.toLowerCase() === 'run') ||
(m.path && m.path.startsWith('/run'))
"
>
<v-card rounded="lg" elevation="12" class="px-2 py-2">
<v-list density="comfortable" lines="one" class="min-w-48">
<template v-for="(d, j) in m.depth" :key="'run_' + j">
<v-hover v-slot="{ isHovering, props: liProps }">
<v-list-item
v-bind="liProps"
:title="d.title"
:to="d.path"
:active="isLinkActive(d.path)"
:rounded="'lg'"
:variant="
isHovering || isLinkActive(d.path)
? 'tonal'
: 'text'
"
class="mx-2 my-1"
/>
</v-hover>
</template>
</v-list>
</v-card>
</template>
<template v-else>
<v-list density="compact" class="min-w-48 subnav-list">
<v-list-item
v-for="(d, j) in m.depth"
:key="'d_' + j"
:title="d.title"
:to="d.path"
class="submenu-item"
:class="{ 'submenu-active': isLinkActive(d.path) }"
/>
</v-list>
</template>
</v-menu>
<v-btn
v-else
variant="text"
class="nav-btn"
:class="{ 'nav-active': isLinkActive(m.path) }"
@click="m.path && router.push(m.path)"
>
<v-icon start :icon="m.icon" class="mr-1" />
{{ m.title }}
</v-btn>
</template>
</template>
</div>
<v-spacer />
<!-- 우측: 기존 기능 유지 -->
<v-tooltip v-if="isAdmin" location="bottom" text="Settings">
<template #activator="{ props }" v-if="!hideAllMenus">
<v-btn
icon
class="mr-2 text-white"
v-bind="props"
@click="toggleAdmin"
aria-label="Settings"
>
<v-icon>mdi-cog</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip location="bottom" text="Project">
<template #activator="{ props }">
<v-btn
icon
class="mr-2 text-white"
@click="goSelect"
v-bind="props"
aria-label="Project"
>
<v-icon>mdi-file-tree</v-icon>
</v-btn>
</template>
</v-tooltip>
<!-- 사용자 관리 (관리자만, Project 버튼 옆) -->
<v-tooltip v-if="isAdmin && !hideAllMenus" location="bottom" text="사용자 관리">
<template #activator="{ props }">
<v-btn
icon
class="mr-2 text-white flex-shrink-0"
v-bind="props"
@click="router.push('/users')"
aria-label="사용자 관리"
>
<v-icon>mdi-account-multiple</v-icon>
</v-btn>
</template>
</v-tooltip>
<div class="d-none d-md-flex flex-column align-end userbox">
<div class="font-weight-black text-white">
{{ username || "GUEST" }}
</div>
<div class="text-subtitle-2 text-white">
{{ projectName || "No Project Selected" }}
</div>
</div>
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn icon v-bind="props" class="mr-1 text-white">
<v-icon>mdi-arrow-down-drop-circle-outline</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(item, index) in menu"
:key="index"
:value="index"
:to="item.path && !isAdminMenuItem(item) ? item.path : undefined"
:prepend-icon="item.icon"
:href="isAdminMenuItem(item) ? resolveHref(item.path) : undefined"
@click.stop.prevent="onMenuItemClick(item, $event)"
>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
<!-- 본문 -->
<v-main>
<v-container
fluid
class="pa-16 background d-flex justify-center"
style="width: 100%"
>
<slot />
</v-container>
</v-main>
</v-app>
</template>
<style scoped>
.topbar {
background: #121212 !important;
backdrop-filter: saturate(140%) blur(8px);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
/* 더 커진 홈(브랜드) 버튼 - 마우스 오버 시 포인터 */
.brand-btn {
font-weight: 800;
letter-spacing: 0.08em;
padding: 0 14px;
color: #fff;
cursor: pointer;
}
/* 중앙 고정 네비게이션 */
.center-nav {
position: absolute;
left: 50%;
transform: translateX(-50%);
gap: 8px;
align-items: center;
}
/* 관리자 메뉴 router-link: 링크 스타일 제거 */
.admin-nav-link {
text-decoration: none;
color: inherit;
display: inline-flex;
}
.nav-btn {
text-transform: none;
border-radius: 10px;
padding: 0 16px;
font-size: 14px;
color: #fff !important; /* 흰색 텍스트 통일 */
}
.nav-btn:hover {
background: rgba(59, 130, 246, 0.08);
}
.nav-active {
background: rgba(59, 130, 246, 0.22);
height: 46px;
color: #fff !important;
}
/* 드롭다운(하위 메뉴)도 동일 룩으로 */
.subnav-list {
background: transparent; /* 탑바 느낌 유지 */
}
.submenu-item {
color: #fff !important;
border-radius: 10px;
margin: 2px 8px;
}
.submenu-item:hover {
background: rgba(59, 130, 246, 0.08);
}
.submenu-active {
background: rgba(59, 130, 246, 0.22);
color: #fff !important;
}
.min-w-48 {
min-width: 12rem;
}
.userbox {
min-width: 180px;
}
</style>