V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { buildNavigationEntries, groupByModule } from './navigationCatalog'
import { useScreenPreferenceStore } from './screenPreferenceStore'
import { useWorkspaceStore, type WorkspaceTab } from './workspaceStore'
import KsGlobalHeader from './KsGlobalHeader.vue'
import KsSideNavigation from './KsSideNavigation.vue'
import KsWorkspaceTabs from './KsWorkspaceTabs.vue'
import KsMenuSearch from './KsMenuSearch.vue'
import type { NavigationEntry } from './navigationCatalog'
defineProps<{ productName?: string; environment?: string; automationStatus?: string }>()
const route = useRoute()
const router = useRouter()
const preference = useScreenPreferenceStore()
const workspace = useWorkspaceStore()
const collapsed = ref(false)
const menuSearchOpen = ref(false)
const appVersion = import.meta.env.VITE_APP_VERSION ?? '0.1.0'
const entries = () => buildNavigationEntries(router.getRoutes()).filter(entry => !entry.internalOnly)
const sections = () => groupByModule(entries())
const favorites = () => preference.favoriteScreenIds
.map(id => entries().find(entry => entry.screenId === id))
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
const activeTabKey = () => {
const screenId = route.meta.screenId
return typeof screenId === 'string' ? `${screenId}:${route.path}` : null
}
watch(() => route.fullPath, () => {
const meta = route.meta
if (typeof meta.screenId !== 'string' || meta.module === 'Home') return
const title = typeof meta.title === 'string' ? meta.title : route.path
preference.recordVisit(meta.screenId, route.path)
workspace.open(meta.screenId, route.path, title)
}, { immediate: true })
function goHome() {
router.push('/home')
}
function openMenuSearch() {
menuSearchOpen.value = true
}
function selectEntry(entry: NavigationEntry) {
menuSearchOpen.value = false
router.push(entry.path)
}
function selectTab(tab: WorkspaceTab) {
router.push(tab.path)
}
function closeTab(tab: WorkspaceTab) {
const wasActive = activeTabKey() === `${tab.screenId}:${tab.path}`
workspace.close(tab)
if (wasActive) {
const next = workspace.tabs.at(-1)
router.push(next ? next.path : '/home')
}
}
function onKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
menuSearchOpen.value = true
}
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<div class="ks-app-shell">
<a class="ks-skip" href="#ks-main">본문으로 건너뛰기</a>
<KsGlobalHeader :product-name="productName" :environment="environment" :automation-status="automationStatus" @home="goHome" @menu-search="openMenuSearch" />
<KsWorkspaceTabs :tabs="workspace.visibleTabs" :overflow-count="workspace.overflowCount" :active-key="activeTabKey()" @select="selectTab" @close="closeTab" @toggle-pin="workspace.togglePin" />
<div class="ks-app-shell__body">
<KsSideNavigation :sections="sections()" :favorites="favorites()" :active-path="route.path" :collapsed="collapsed" @toggle-collapsed="collapsed = !collapsed" />
<main id="ks-main" class="ks-app-shell__main" tabindex="-1">
<slot />
</main>
</div>
<footer class="ks-app-shell__footer">RESEARCH_CANDIDATE_NOT_PRODUCTION</footer>
<div class="ks-app-shell__version" data-testid="app-version" aria-label="애플리케이션 버전">v{{ appVersion }} · UI contract 4.0</div>
<KsMenuSearch :open="menuSearchOpen" :entries="entries()" @close="menuSearchOpen = false" @select="selectEntry" />
</div>
</template>
<style scoped>
.ks-app-shell { min-height: 100vh; display: grid; grid-template-rows: auto auto 1fr auto; background: var(--ks-color-canvas); }
.ks-app-shell__body { display: flex; min-height: 0; }
.ks-app-shell__main { flex: 1; min-width: 0; padding: var(--ks-space-6); overflow: auto; }
.ks-app-shell__footer { padding: var(--ks-space-2) var(--ks-space-6); border-top: 1px solid var(--ks-color-border); background: var(--ks-color-surface); color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
.ks-app-shell__version { position: fixed; right: var(--ks-space-3); bottom: var(--ks-space-2); z-index: 20; padding: .25rem .5rem; border: 1px solid var(--ks-color-border); border-radius: var(--ks-radius-sm); background: rgb(255 255 255 / 92%); color: var(--ks-color-text-muted); font-size: .7rem; box-shadow: var(--ks-shadow-sm); }
.ks-skip { position: fixed; left: var(--ks-space-2); top: -4rem; z-index: 1000; padding: var(--ks-space-2); background: var(--ks-color-surface); }
.ks-skip:focus { top: var(--ks-space-2); }
@media (max-width: 900px) { .ks-app-shell__body { flex-direction: column; } }
</style>