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

458 lines
12 KiB

<script setup lang="ts">
/* ================================
* Imports
* ================================ */
import { ref, computed, watch, onMounted, onBeforeUnmount } 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 / Base states
* ================================ */
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);
const lastNonAdminPath = ref<string>("/home");
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();
const roles = auth?.userInfo?.roles ?? auth?.roles ?? [];
const authCd = auth?.userInfo?.authCd ?? auth?.authCd ?? auth?.auth;
const inRoles = Array.isArray(roles)
? roles.includes("ROLE_ADMIN")
: roles === "ROLE_ADMIN";
isAdmin.value = inRoles || authCd === "ADMIN";
}
function updateUsername() {
const auth = readAuth();
username.value = auth?.userInfo?.username ?? auth?.username ?? "";
}
/* ================================
* Derived route state
* ================================ */
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("/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);
/* ================================
* 사용자 메뉴 (우측)
* ================================ */
const menu = ref<MenuItem[]>([]);
const menuItems: MenuItem[] = [
{ title: "Select Project", click: () => goSelect() },
{ title: "Change Password", click: () => {} },
{ title: "Logout", icon: "mdi-logout", click: () => logOut() },
];
/* ================================
* 상단 Hover 하위 메뉴 스트립
* ================================ */
type DepthItem = { title: string; path: string };
const hoverBar = ref<{
open: boolean;
items: DepthItem[];
}>({ open: false, items: [] });
let hideTimer: number | null = null;
function showHoverStrip(m: MenuItem) {
if (!m.depth?.length) return;
if (hideTimer) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
hoverBar.value = {
open: true,
items: m.depth,
};
}
function scheduleHideStrip() {
if (hideTimer) window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => {
hoverBar.value.open = false;
}, 140);
}
function keepStrip() {
if (hideTimer) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
}
/* ================================
* Navigation
* ================================ */
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");
}
}
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();
// 라우트가 바뀌면 스트립 닫기
hoverBar.value.open = false;
if (!isAdminRoute.value) lastNonAdminPath.value = route.fullPath || "/home";
},
{ immediate: true },
);
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
* ================================ */
onMounted(() => {
updateUsername();
computeIsAdmin();
refreshProjectName();
menu.value = menuItems;
window.addEventListener("storage", onStorage);
});
onBeforeUnmount(() => {
window.removeEventListener("storage", onStorage);
if (hideTimer) window.clearTimeout(hideTimer);
});
</script>
<template>
<v-app>
<!-- ===== 상단 탑바 ===== -->
<v-app-bar flat height="64" class="topbar">
<div
variant="text"
size="large"
class="brand-btn text-h5 text-primary"
@click="goMain"
aria-label="Home"
>
AUTOFLOW WEB CONSOLE
</div>
<!-- 여기 스페이서를 '브랜드 다음' 둬서 오른쪽으로 밀기 -->
<v-spacer />
<!-- 메뉴는 우측 정렬 -->
<div class="right-nav d-none d-md-flex" v-if="!hideAllMenus">
<!-- 관리자 메뉴 -->
<template v-if="showAdminTabs">
<template v-for="(m, i) in adminMenus" :key="'am_' + i">
<v-btn
variant="text"
class="nav-btn"
:class="{
'nav-active':
m.depth?.some((d: any) => isLinkActive(d.path)) ||
isLinkActive(m.path),
}"
@mouseenter="showHoverStrip(m)"
@mouseleave="scheduleHideStrip"
@click="m.path && router.push(m.path)"
>
<v-icon start :icon="m.icon" class="mr-1" />
{{ m.title }}
</v-btn>
</template>
</template>
<!-- 기본 메뉴 -->
<template v-else>
<template v-for="(m, i) in baseMenus" :key="'m_' + i">
<v-btn
variant="text"
class="nav-btn text-white"
:class="{
'nav-active':
m.depth?.some((d: any) => isLinkActive(d.path)) ||
isLinkActive(m.path),
}"
@mouseenter="showHoverStrip(m)"
@mouseleave="scheduleHideStrip"
@click="!m.depth?.length && m.path && router.push(m.path)"
>
<v-icon start :icon="m.icon" class="mr-1" />
{{ m.title }}
</v-btn>
</template>
</template>
</div>
<!-- 우측 아이콘들 -->
<v-tooltip v-if="isAdmin" location="bottom" text="Settings">
<template #activator="{ props }" v-if="!hideAllMenus">
<v-btn
icon
color="primary"
class="mr-2"
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
color="primary"
class="mr-2"
@click="goSelect"
v-bind="props"
aria-label="Project"
>
<v-icon>mdi-file-tree</v-icon>
</v-btn>
</template>
</v-tooltip>
<div class="d-none d-md-flex flex-column align-end userbox">
<div class="font-weight-black">{{ username || "GUEST" }}</div>
<div class="text-subtitle-2">
{{ projectName || "No Project Selected" }}
</div>
</div>
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn icon color="primary" v-bind="props" class="mr-1">
<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"
@click="item.click"
:prepend-icon="item.icon"
>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
<!-- ===== 상단 하위 메뉴 스트립 (호버 표시) ===== -->
<v-slide-y-transition>
<v-sheet
v-if="hoverBar.open"
class="hover-strip"
elevation="8"
color="surface"
@mouseenter="keepStrip"
@mouseleave="scheduleHideStrip"
>
<v-container class="py-2" :fluid="true">
<!-- 중앙 정렬 -->
<v-row class="g-2" no-gutters justify="center" align="center">
<v-col class="d-flex flex-wrap justify-center" cols="12">
<v-btn
v-for="d in hoverBar.items"
:key="d.path"
size="small"
class="mx-1 my-1 strip-chip"
:variant="isLinkActive(d.path) ? 'tonal' : 'text'"
:color="isLinkActive(d.path) ? 'primary' : undefined"
@click="router.push(d.path)"
>
{{ d.title }}
</v-btn>
</v-col>
</v-row>
</v-container>
</v-sheet>
</v-slide-y-transition>
<!-- 본문 -->
<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: rgba(18, 18, 18, 0.7) !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;
}
.right-nav {
display: flex;
align-items: center;
gap: 8px; /* 버튼 간격 */
justify-content: flex-end;
}
.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;
}
.userbox {
min-width: 180px;
}
/* ===== 호버 스트립 (상단 바로 아래, 이미지 스타일) ===== */
.hover-strip {
position: fixed;
top: var(--v-layout-top, 64px); /* app-bar 바로 아래 */
left: 0;
right: 0;
z-index: 2500;
/* 다크 배경 + 살짝 투명 + 경계 */
background: rgba(32, 32, 32, 0.96);
border-bottom: 1px solid rgb(145, 61, 61);
backdrop-filter: blur(6px);
}
/* 버튼(알약) 다크에서도 비활성 글자/테두리 선명 */
.strip-chip {
border-radius: 9999px !important;
text-transform: none;
font-weight: 600;
letter-spacing: 0;
height: 30px;
padding: 0 14px;
color: #e5e7eb !important; /* 비활성도 흐려 보이지 않게 */
}
.strip-chip.v-btn--variant-text {
/* text 변형일 때도 흐릿하지 않게 약한 테두리 */
border: 1px solid rgba(255, 255, 255, 0.14) !important;
background: transparent !important;
}
.strip-chip:hover {
background: rgba(255, 255, 255, 0.06) !important;
}
</style>