V13-FE-006: consolidate approved UI and contract hardening
This commit is contained in:
+194
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
KbxAccessDenied,
|
||||
KbxApplicationShell,
|
||||
KbxHomePage,
|
||||
KbxPermissionHostKey,
|
||||
KbxNotificationCenter,
|
||||
KbxOperationCenter,
|
||||
KbxRuntimeBanner,
|
||||
KbxUnsavedChangesDialog,
|
||||
KbxUtilityRail,
|
||||
KbxScreenUtilityHostKey,
|
||||
resolveKbxSafeRecentPath,
|
||||
resolveKbxSafeWorkspacePath,
|
||||
useKbxShortcuts,
|
||||
applyKbxTheme,
|
||||
} from '@kbx/ui'
|
||||
import type { KbxAiAnswer, KbxAiScreenContext, KbxHomeAttentionItem, KbxHomeLaunchItem, KbxNavigationSection, KbxRecentNavigation, KbxScreenDefinition, KbxSuggestionRequest, KbxWorkspaceTab } from '@kbx/contracts'
|
||||
import type { KbxScreenUtilityHost, KbxScreenUtilityTab } from '@kbx/ui'
|
||||
import { generatedScreens } from '../registry/screens.generated'
|
||||
import { navigationSections, resolvedNavigationEntries } from './navigationCatalog'
|
||||
import { screenRoutePatterns } from '../router/appRoutes'
|
||||
import { useKbxWorkspaceStore } from './workspaceStore'
|
||||
import { canWorkspaceSave, requestWorkspaceSave } from './workspaceLifecycleRegistry'
|
||||
import { useKbxRuntime } from '../runtime/useKbxRuntime'
|
||||
import { kbxTelemetry } from '../telemetry/kbxTelemetryClient'
|
||||
import { helpRegistry } from '../help/helpRegistry'
|
||||
import { askKbxAssistant } from '../utility/aiAssistantApi'
|
||||
import { submitKbxSuggestion } from '../utility/suggestionApi'
|
||||
import { kbxAiCapabilities } from '../permissions/authorizationPolicy'
|
||||
import { KbxScreenPreferenceScopeKey } from '../preferences/screenPreferenceStore'
|
||||
import { loadWorkspaceSession, saveWorkspaceSession } from './workspaceSessionStore'
|
||||
|
||||
const props=withDefaults(defineProps<{productName?:string;grantedPermissions?:string[];preferenceScope?:string;appVersion?:string;userRole?:string}>(),{productName:'KBX',grantedPermissions:()=>[],preferenceScope:'',appVersion:'unknown',userRole:'user'})
|
||||
const frameEmit=defineEmits<{profile:[]}>()
|
||||
const router=useRouter();const route=useRoute();const store=useKbxWorkspaceStore();const pendingClose=ref<KbxWorkspaceTab|null>(null);const savingClose=ref(false);const runtimePanel=ref<'operations'|'notifications'|null>(null);const runtimePanelTargetId=ref<string|null>(null);const runtimePanelEl=ref<HTMLElement|null>(null);const runtimeReturnFocus=ref<HTMLElement|null>(null)
|
||||
const { notice, operations, notifications, unreadCount, refresh, markRead }=useKbxRuntime()
|
||||
const themeMode=computed(()=>(store.preference.themeMode==='dark'?'dark':'light') as 'light'|'dark')
|
||||
const screenPreferenceScope=computed(()=>props.preferenceScope)
|
||||
provide(KbxScreenPreferenceScopeKey,screenPreferenceScope)
|
||||
watch(themeMode,mode=>applyKbxTheme(mode),{immediate:true})
|
||||
const screenById=new Map(generatedScreens.map(screen=>[screen.id,screen]))
|
||||
const hasPermission=(required?:string[])=>!required?.length || required.every(permission=>props.grantedPermissions.includes(permission))
|
||||
provide(KbxPermissionHostKey,{has:(permission:string)=>props.grantedPermissions.includes(permission)})
|
||||
const allowedEntries=computed(()=>resolvedNavigationEntries.filter(entry=>hasPermission(entry.permissions)&&entry.menuVisible!==false))
|
||||
const allowedIds=computed(()=>new Set(allowedEntries.value.map(x=>x.screenId)))
|
||||
const sections=computed<KbxNavigationSection[]>(()=>navigationSections.map(section=>({...section,entries:section.entries.filter(entry=>allowedIds.value.has(entry.screenId))})).filter(section=>section.entries.length))
|
||||
const favorites=computed(()=>store.favoriteEntries(allowedEntries.value))
|
||||
function screenFallback(screenId:string){return allowedEntries.value.find(entry=>entry.screenId===screenId)?.path??'/home'}
|
||||
function safeWorkspacePath(screenId:string,candidate:string){return resolveKbxSafeWorkspacePath(screenRoutePatterns[screenId]??[],candidate,screenFallback(screenId))}
|
||||
const recents=computed<KbxRecentNavigation[]>(()=>store.preference.recents.flatMap(item=>{const nav=allowedEntries.value.find(entry=>entry.screenId===item.screenId);if(!nav)return [];const recent=resolveKbxSafeRecentPath(nav,item.path);return [{...item,title:nav.title,path:safeWorkspacePath(item.screenId,recent)}]}))
|
||||
const allowedTabs=computed(()=>store.tabs.filter(tab=>{const screen=screenById.get(tab.screenId);return Boolean(screen&&hasPermission(screen.permissions)&&safeWorkspacePath(tab.screenId,tab.path)===tab.path)}))
|
||||
const isHome=computed(()=>route.path==='/home')
|
||||
const activeScreenId=computed(()=>String(route.meta.screenId??''))
|
||||
const activeScreen=computed(()=>screenById.get(activeScreenId.value))
|
||||
const activeScreenKnown=computed(()=>!activeScreenId.value || Boolean(activeScreen.value))
|
||||
const activeScreenAllowed=computed(()=>activeScreenKnown.value && (!activeScreen.value || hasPermission(activeScreen.value.permissions)))
|
||||
const activeModule=computed(()=>isHome.value?'':activeScreen.value?.module??'')
|
||||
const runningOperationCount=computed(()=>operations.value.filter(x=>x.status==='running'||x.status==='queued').length)
|
||||
const failedOperationCount=computed(()=>operations.value.filter(x=>x.status==='failed'||x.status==='partially-completed').length)
|
||||
const urgentUnreadCount=computed(()=>notifications.value.filter(x=>!x.readAt&&(x.severity==='warning'||x.severity==='error')).length)
|
||||
const runtimeScreenAllowed=(screenId?:string)=>!screenId||Boolean(screenById.get(screenId)&&hasPermission(screenById.get(screenId)?.permissions))
|
||||
const homeOperations=computed(()=>operations.value.filter(item=>runtimeScreenAllowed(item.sourceScreenId)))
|
||||
const homeNotifications=computed(()=>notifications.value.filter(item=>runtimeScreenAllowed(item.screenId)))
|
||||
const utilityTab=ref<KbxScreenUtilityTab|null>(null)
|
||||
const utilityScreen=ref<KbxScreenDefinition|null>(null)
|
||||
const utilityAiAnswer=ref<KbxAiAnswer|null>(null)
|
||||
const utilityAiLoading=ref(false)
|
||||
const utilityAiError=ref('')
|
||||
const utilitySuggestionSubmitting=ref(false)
|
||||
const utilityHelp=computed(()=>utilityScreen.value?.helpKey?helpRegistry[utilityScreen.value.helpKey]:undefined)
|
||||
const utilityCapabilities=computed(()=>kbxAiCapabilities(props.grantedPermissions,['explain','suggest','draft']))
|
||||
const utilityAiContext=computed<KbxAiScreenContext|undefined>(()=>utilityScreen.value&&utilityCapabilities.value.length?{screenId:utilityScreen.value.id,screenVersion:utilityScreen.value.version,allowedCapabilities:utilityCapabilities.value}:undefined)
|
||||
const utilitySuggestionContext=computed(()=>utilityScreen.value&&props.grantedPermissions.includes('common.suggestion.create')?{screenId:utilityScreen.value.id,screenVersion:utilityScreen.value.version,route:route.fullPath,appVersion:props.appVersion,userRole:props.userRole,activeFilters:[]}:undefined)
|
||||
const utilityHost:KbxScreenUtilityHost={
|
||||
available(screen){const tabs:KbxScreenUtilityTab[]=[];if(screen.helpKey&&helpRegistry[screen.helpKey])tabs.push('help');if(utilityCapabilities.value.length)tabs.push('ai');if(props.grantedPermissions.includes('common.suggestion.create'))tabs.push('suggestion');return tabs},
|
||||
open(screen,tab){closeRuntimePanel(false);utilityScreen.value=screen;utilityTab.value=tab;utilityAiAnswer.value=null;utilityAiError.value=''},
|
||||
}
|
||||
provide(KbxScreenUtilityHostKey,utilityHost)
|
||||
async function askUtilityAi(question:string){if(!utilityAiContext.value)return;utilityAiLoading.value=true;utilityAiError.value='';try{utilityAiAnswer.value=await askKbxAssistant({question,context:utilityAiContext.value})}catch{utilityAiError.value='네트워크 상태를 확인한 후 다시 질문하세요.'}finally{utilityAiLoading.value=false}}
|
||||
async function submitUtilitySuggestion(request:KbxSuggestionRequest){utilitySuggestionSubmitting.value=true;try{await submitKbxSuggestion(request);utilityTab.value=null}finally{utilitySuggestionSubmitting.value=false}}
|
||||
const permittedScreenIds=computed(()=>new Set(generatedScreens.filter(screen=>hasPermission(screen.permissions)).map(screen=>screen.id)))
|
||||
function restoreWorkspaceForScope(scope:string){
|
||||
const snapshot=loadWorkspaceSession(scope)
|
||||
if(!snapshot)return {restored:0,dirtyDiscarded:0}
|
||||
const restored:KbxWorkspaceTab[]=[]
|
||||
for(const item of snapshot.tabs){
|
||||
const screen=screenById.get(item.screenId)
|
||||
if(!screen||!hasPermission(screen.permissions))continue
|
||||
const safe=safeWorkspacePath(item.screenId,item.path)
|
||||
if(safe!==item.path)continue
|
||||
restored.push({key:`${item.screenId}:${safe}`,screenId:item.screenId,title:screen.title,path:safe,pinned:item.pinned,dirty:false,resumed:true,openedAt:item.openedAt,lastActivatedAt:item.lastActivatedAt})
|
||||
}
|
||||
return {restored:store.restoreTabs(restored),dirtyDiscarded:snapshot.dirtyDiscardedCount}
|
||||
}
|
||||
function persistWorkspaceSession(){
|
||||
const scope=screenPreferenceScope.value
|
||||
if(scope)saveWorkspaceSession(scope,store.tabs)
|
||||
}
|
||||
|
||||
watch(permittedScreenIds,ids=>{const removed=store.pruneTabs(ids);if(removed)store.shellNotice='권한 변경으로 더 이상 사용할 수 없는 열린 업무를 안전하게 닫았습니다.'},{immediate:true})
|
||||
watch(allowedIds,ids=>store.reconcilePreference(ids),{immediate:true})
|
||||
|
||||
async function syncCurrentRoute(){
|
||||
if(isHome.value)return
|
||||
const screenId=activeScreenId.value
|
||||
if(!screenId)return
|
||||
const screen=screenById.get(screenId)
|
||||
if(!screen||!hasPermission(screen.permissions))return
|
||||
const safe=safeWorkspacePath(screenId,route.fullPath)
|
||||
if(safe!==route.fullPath){store.shellNotice='허용되지 않은 화면 경로를 차단했습니다.';await router.replace('/home');return}
|
||||
const navEntry=resolvedNavigationEntries.find(entry=>entry.screenId===screenId&&entry.menuVisible!==false&&hasPermission(entry.permissions))
|
||||
const recent=navEntry&&navEntry.recentPolicy!=='none'?{screenId,title:screen.title,path:navEntry.recentPolicy==='route'?resolveKbxSafeRecentPath(navEntry,safe):navEntry.path,visitedAt:new Date().toISOString()}:null
|
||||
const synced=store.syncRoute(screenId,screen.title,safe,recent)
|
||||
if(!synced&&route.path!=='/home')await router.replace('/home')
|
||||
}
|
||||
watch(()=>props.preferenceScope,(scope,previous)=>{
|
||||
store.setPreferenceScope(scope);utilityTab.value=null;utilityScreen.value=null;closeRuntimePanel(false)
|
||||
const resume=scope?restoreWorkspaceForScope(scope):{restored:0,dirtyDiscarded:0}
|
||||
if(resume.restored||resume.dirtyDiscarded)void nextTick(()=>store.notifyShell(`${resume.restored?`이전 세션 업무 ${resume.restored}개를 위치만 안전하게 복원했습니다. `:''}${resume.dirtyDiscarded?`미저장 편집 ${resume.dirtyDiscarded}개는 데이터 정합성을 위해 복원하지 않았습니다.`:''}`.trim()))
|
||||
if(previous!==undefined&&scope!==previous&&route.path!=='/home')void router.replace('/home')
|
||||
},{immediate:true})
|
||||
watch(()=>props.grantedPermissions.join('|'),()=>{utilityTab.value=null;utilityScreen.value=null;closeRuntimePanel(false)})
|
||||
watch(()=>store.tabs.map(tab=>[tab.key,Boolean(tab.dirty),Boolean(tab.pinned),Boolean(tab.resumed),tab.lastActivatedAt]).join('|'),persistWorkspaceSession,{flush:'post'})
|
||||
watch(()=>route.fullPath,()=>{closeRuntimePanel(false);void syncCurrentRoute()},{immediate:true})
|
||||
watch(activeScreen,(screen,previous)=>{utilityTab.value=null;utilityScreen.value=null;
|
||||
if(previous?.telemetry?.enabled&&hasPermission(previous.permissions))kbxTelemetry.track('screen.close',{screenId:previous.id,screenVersion:previous.version,attributes:{module:previous.module,closeReason:'navigate'}})
|
||||
if(screen?.telemetry?.enabled&&hasPermission(screen.permissions))kbxTelemetry.track('screen.open',{screenId:screen.id,screenVersion:screen.version,attributes:{module:screen.module,launchMode:'tab'}})
|
||||
},{immediate:true})
|
||||
|
||||
|
||||
function closeRuntimePanel(restoreFocus=true){
|
||||
const target=runtimeReturnFocus.value
|
||||
runtimePanel.value=null
|
||||
runtimePanelTargetId.value=null
|
||||
runtimeReturnFocus.value=null
|
||||
if(restoreFocus&&target)void nextTick(()=>target.focus())
|
||||
}
|
||||
function openMenuSearch(){closeRuntimePanel(false);store.menuSearchOpen=true}
|
||||
async function toggleRuntimePanel(kind:'operations'|'notifications',targetId:string|null=null){
|
||||
if(runtimePanel.value===kind&&!targetId){closeRuntimePanel();return}
|
||||
utilityTab.value=null;utilityScreen.value=null
|
||||
runtimeReturnFocus.value=document.activeElement instanceof HTMLElement?document.activeElement:null
|
||||
runtimePanel.value=kind
|
||||
runtimePanelTargetId.value=targetId
|
||||
await nextTick();if(!targetId)runtimePanelEl.value?.focus()
|
||||
}
|
||||
|
||||
async function openHome(){store.deactivate();if(route.path!=='/home')await router.push('/home')}
|
||||
async function openEntry(requested:(typeof resolvedNavigationEntries)[number]){const entry=allowedEntries.value.find(item=>item.screenId===requested.screenId);if(!entry)return;store.menuSearchOpen=false;await router.push(entry.path)}
|
||||
async function openRecent(entry:KbxRecentNavigation){const nav=allowedEntries.value.find(item=>item.screenId===entry.screenId);if(!nav)return;const candidate=safeWorkspacePath(entry.screenId,resolveKbxSafeRecentPath(nav,entry.path));await router.push(candidate)}
|
||||
async function selectTab(tab:KbxWorkspaceTab){const stored=store.tabs.find(item=>item.key===tab.key);if(!stored||!permittedScreenIds.value.has(stored.screenId))return;const safe=safeWorkspacePath(stored.screenId,stored.path);if(safe==='/home'&&stored.path!=='/home'){store.close(stored.key);store.shellNotice='변조되었거나 더 이상 유효하지 않은 열린 업무 경로를 제거했습니다.';await openHome();return}store.activate(stored.key);if(route.fullPath!==safe)await router.push(safe)}
|
||||
async function launchHomeItem(item:KbxHomeLaunchItem){if(item.tabKey){const tab=store.tabs.find(tab=>tab.key===item.tabKey);if(tab)return selectTab(tab)}const nav=allowedEntries.value.find(entry=>entry.screenId===item.screenId);if(!nav)return;const candidate=item.source==='recent'?resolveKbxSafeRecentPath(nav,item.path):nav.path;await router.push(safeWorkspacePath(item.screenId,candidate))}
|
||||
async function openHomeAttention(item:KbxHomeAttentionItem){
|
||||
if(item.tabKey){const tab=store.tabs.find(tab=>tab.key===item.tabKey);if(tab)return selectTab(tab)}
|
||||
if(item.operationId){await toggleRuntimePanel('operations',item.operationId);return}
|
||||
if(item.notificationId){
|
||||
await markRead(item.notificationId)
|
||||
if(item.screenId){
|
||||
const nav=allowedEntries.value.find(entry=>entry.screenId===item.screenId)
|
||||
if(nav){
|
||||
const candidate=item.path??nav.path
|
||||
const safe=safeWorkspacePath(item.screenId,candidate)
|
||||
if(item.path&&safe!==item.path){store.shellNotice='알림에 포함된 허용되지 않은 이동 경로를 차단했습니다.';await toggleRuntimePanel('notifications');return}
|
||||
await router.push(safe);return
|
||||
}
|
||||
}
|
||||
await toggleRuntimePanel('notifications',item.notificationId)
|
||||
}
|
||||
}
|
||||
function closeTab(tab:KbxWorkspaceTab){if(tab.dirty){pendingClose.value=tab;return}void discardAndClose(tab)}
|
||||
async function discardAndClose(tab:KbxWorkspaceTab){const wasCurrent=route.fullPath===tab.path;store.close(tab.key);pendingClose.value=null;if(wasCurrent){const next=store.activeTab;if(next)await selectTab(next);else await openHome()}}
|
||||
async function saveAndClose(){const tab=pendingClose.value;if(!tab)return;savingClose.value=true;try{const saved=await requestWorkspaceSave(tab.path);if(saved)await discardAndClose(tab)}finally{savingClose.value=false}}
|
||||
useKbxShortcuts([{key:'Ctrl+K',scope:'application',execute:openMenuSearch},{key:'Esc',scope:'application',priority:20,enabled:()=>Boolean(runtimePanel.value),execute:()=>closeRuntimePanel()}])
|
||||
onMounted(()=>kbxTelemetry.start());onBeforeUnmount(()=>kbxTelemetry.stop())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxApplicationShell
|
||||
:product-name="props.productName" :active-module="activeModule" :active-screen-id="activeScreenId" :sections="sections" :all-entries="allowedEntries" :favorites="favorites" :recents="recents" :tabs="allowedTabs" :active-tab-key="store.activeKey" :side-nav-collapsed="store.preference.sideNavCollapsed" :menu-search-open="store.menuSearchOpen" :operation-count="runningOperationCount" :notification-count="unreadCount" :operations-open="runtimePanel==='operations'" :notifications-open="runtimePanel==='notifications'" :home-active="isHome" :workspace-max-tabs="store.preference.workspaceMaxTabs" :shell-notice="store.shellNotice" :theme-mode="themeMode"
|
||||
@home="openHome" @open="openEntry" @open-recent="openRecent" @select-tab="selectTab" @close-tab="closeTab" @toggle-tab-pin="tab=>store.togglePinned(tab.key)" @toggle-favorite="store.toggleFavorite" @toggle-side-nav="store.toggleSideNav" @menu-search-open="openMenuSearch" @menu-search-close="store.menuSearchOpen=false" @operations="toggleRuntimePanel('operations')" @notifications="toggleRuntimePanel('notifications')" @theme-toggle="store.toggleTheme" @profile="frameEmit('profile')" @dismiss-shell-notice="store.dismissShellNotice"
|
||||
>
|
||||
<template #runtime><KbxRuntimeBanner :notice="notice" @retry="refresh" /><aside v-if="runtimePanel" id="kbx-runtime-panel" ref="runtimePanelEl" class="runtime-panel" tabindex="-1" role="region" :aria-labelledby="runtimePanel==='operations'?'kbx-runtime-panel-title-operations':'kbx-runtime-panel-title-notifications'"><header class="runtime-panel__header"><strong :id="runtimePanel==='operations'?'kbx-runtime-panel-title-operations':'kbx-runtime-panel-title-notifications'">{{runtimePanel==='operations'?'작업 센터':'알림 센터'}}</strong><button type="button" aria-label="패널 닫기" @click="closeRuntimePanel()">×</button></header><KbxOperationCenter v-if="runtimePanel==='operations'" :operations="operations" :active-id="runtimePanelTargetId" /><KbxNotificationCenter v-else :notifications="notifications" :active-id="runtimePanelTargetId" @read="markRead" /></aside></template>
|
||||
<KbxHomePage v-if="isHome" :entries="allowedEntries" :favorites="favorites" :recents="recents" :operation-count="runningOperationCount" :operation-failure-count="failedOperationCount" :notification-count="unreadCount" :urgent-notification-count="urgentUnreadCount" :operations="homeOperations" :notifications="homeNotifications" :tabs="allowedTabs" @open="openEntry" @launch="launchHomeItem" @attention="openHomeAttention" @toggle-favorite="store.toggleFavorite" @menu-search="openMenuSearch" @operations="toggleRuntimePanel('operations')" @notifications="toggleRuntimePanel('notifications')" />
|
||||
<KbxAccessDenied v-else-if="!activeScreenAllowed" @home="openHome" @menu-search="openMenuSearch" />
|
||||
<router-view v-else />
|
||||
</KbxApplicationShell>
|
||||
<KbxUnsavedChangesDialog :open="Boolean(pendingClose)" :can-save="pendingClose?canWorkspaceSave(pendingClose.path):false" :saving="savingClose" @stay="pendingClose=null" @discard="pendingClose&&discardAndClose(pendingClose)" @save="saveAndClose" />
|
||||
<KbxUtilityRail triggerless :open-tab="utilityTab" :help="utilityHelp" :ai-context="utilityAiContext" :ai-answer="utilityAiAnswer" :ai-loading="utilityAiLoading" :ai-error="utilityAiError" :ai-current-screen-label="utilityScreen?.title" :suggestion-context="utilitySuggestionContext" :suggestion-submitting="utilitySuggestionSubmitting" @close="utilityTab=null" @ai-ask="askUtilityAi" @suggestion-submit="submitUtilitySuggestion" />
|
||||
</template>
|
||||
|
||||
<style scoped>.runtime-panel__header{min-height:var(--kbx-control-height);display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-2);padding:0 var(--kbx-space-2);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.runtime-panel__header button{width:var(--kbx-control-xs);height:var(--kbx-control-xs);border:0;border-radius:var(--kbx-radius-sm);background:transparent;color:var(--kbx-color-text)}.runtime-panel__header button:hover{background:var(--kbx-color-surface-hover)}.runtime-panel{position:fixed;z-index:var(--kbx-z-runtime-panel);right:var(--kbx-space-2);top:var(--kbx-runtime-panel-top);width:min(var(--kbx-runtime-panel-width),calc(100vw - var(--kbx-space-6)));max-height:70vh;overflow:auto;padding:var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-md);background:var(--kbx-color-surface);box-shadow:var(--kbx-shadow-overlay)}</style>
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import type { KbxNavigationEntry, KbxNavigationResolvedEntry, KbxNavigationSection, KbxScreenDefinition } from '@kbx/contracts'
|
||||
import { generatedScreens } from '../registry/screens.generated'
|
||||
|
||||
export const navigationCatalog: KbxNavigationEntry[] = [
|
||||
{ screenId:'OMS-ORD-001', path:'/oms/orders', section:'주문', keywords:['주문조회','주문관리','출고대기'], order:10, homePriority:10, homeGroup:'주문 처리' },
|
||||
{ screenId:'OMS-ORD-002', path:'/oms/orders/new', section:'주문', keywords:['주문등록','신규주문'], order:20, homePriority:20, homeGroup:'주문 처리', permissions:['oms.order.create','erp.item.read'] },
|
||||
{ screenId:'OMS-ORD-003', path:'/oms/orders/import', section:'주문', keywords:['엑셀','업로드','대량주문'], order:30, homePriority:30, homeGroup:'대량 업무' },
|
||||
{ screenId:'OMS-CLM-001', path:'/oms/claims', section:'CS/클레임', keywords:['반품','교환','취소','클레임'], order:40 },
|
||||
{ screenId:'ERP-MST-ITEM-001', path:'/erp/items', section:'기준정보', keywords:['품목','상품','SKU','마스터'], order:10, homePriority:10, homeGroup:'기준정보' },
|
||||
{ screenId:'ERP-PRICE-001', path:'/erp/item-prices', section:'기준정보', keywords:['품목단가','단가','가격','일괄입력','Fast Entry'], order:15, homePriority:20, homeGroup:'대량 업무' },
|
||||
{ screenId:'ERP-PUR-001', path:'/erp/purchases/new', section:'구매', keywords:['구매','발주','매입'], order:20 },
|
||||
{ screenId:'ERP-INV-001', path:'/erp/inventory', section:'재고', keywords:['재고','현재고','가용재고','로케이션'], order:30, homePriority:30, homeGroup:'재고' },
|
||||
{ screenId:'ERP-INV-MOVE-001', path:'/erp/inventory-moves/new', section:'재고', keywords:['재고이동','창고이동'], order:40 },
|
||||
{ screenId:'WMS-WORK-001', path:'/wms/work', section:'작업관리', keywords:['입고','적치','피킹','실사','작업'], order:10, homePriority:10, homeGroup:'현장 운영' },
|
||||
{ screenId:'COMMON-OPS-001', path:'/operations/exceptions', section:'운영', keywords:['예외','오류','실패','미처리'], order:10, homePriority:5, homeGroup:'예외 처리' },
|
||||
{ screenId:'COMMON-REC-001', path:'/operations/reconcile', section:'운영', keywords:['대사','정합성','불일치','비교'], order:20, homePriority:15, homeGroup:'예외 처리' },
|
||||
{ screenId:'COMMON-EXP-001', path:'/internal/kbx/experiments', section:'KBX', keywords:['UX','실험','A/B','점진배포','롤백'], order:75, menuVisible:false, favoriteAllowed:false },
|
||||
{ screenId:'COMMON-UX-001', path:'/internal/kbx/ux-metrics', section:'KBX', keywords:['UX','지표','텔레메트리','자동화','개입률'], order:80, menuVisible:false, favoriteAllowed:false },
|
||||
{ screenId:'COMMON-DATA-001', path:'/internal/kbx/external-data', section:'KBX', keywords:['외부데이터','KRX','OPENDART','KIS','신선도','출처'], order:85, menuVisible:false, favoriteAllowed:false },
|
||||
{ screenId:'COMMON-DS-001', path:'/internal/kbx/catalog', section:'KBX', keywords:['컴포넌트','디자인시스템','카탈로그'], order:90, menuVisible:false, favoriteAllowed:false },
|
||||
]
|
||||
|
||||
const screenById = new Map<string,KbxScreenDefinition>(generatedScreens.map(screen => [screen.id, screen]))
|
||||
export const resolvedNavigationEntries: KbxNavigationResolvedEntry[] = navigationCatalog.map(entry => {
|
||||
const screen=screenById.get(entry.screenId)
|
||||
if(!screen) throw new Error(`Navigation references unknown screen: ${entry.screenId}`)
|
||||
return { ...entry, title:screen.title, module:screen.module, permissions:entry.permissions ?? screen.permissions, launchMode:entry.launchMode ?? 'tab', menuVisible:entry.menuVisible ?? true, favoriteAllowed:entry.favoriteAllowed ?? true, recentPolicy:entry.recentPolicy ?? 'screen' }
|
||||
})
|
||||
|
||||
const moduleLabel:Record<string,string>={OMS:'OMS',ERP:'ERP',WMS:'WMS',COMMON:'공통 운영'}
|
||||
export const navigationSections:KbxNavigationSection[] = ['OMS','ERP','WMS','COMMON'].flatMap(module => {
|
||||
const moduleEntries=resolvedNavigationEntries.filter(x=>x.module===module && x.menuVisible!==false)
|
||||
const groups=[...new Set(moduleEntries.map(x=>x.section))]
|
||||
return groups.map(group=>({ key:`${module}:${group}`, label:`${moduleLabel[module]} · ${group}`, module:module as KbxNavigationSection['module'], entries:moduleEntries.filter(x=>x.section===group).sort((a,b)=>(a.order??0)-(b.order??0)) }))
|
||||
})
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { onBeforeUnmount, onMounted, watch, type Ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useKbxWorkspaceStore } from './workspaceStore'
|
||||
import { registerWorkspaceLifecycle } from './workspaceLifecycleRegistry'
|
||||
export function useKbxWorkspaceBinding(dirty:Ref<boolean>, save?:()=>boolean|Promise<boolean>){const route=useRoute();const store=useKbxWorkspaceStore();let unregister:(()=>void)|undefined;onMounted(()=>{unregister=registerWorkspaceLifecycle(route.fullPath,{save});store.setActiveDirty(dirty.value)});watch(dirty,value=>store.setActiveDirty(value));onBeforeUnmount(()=>unregister?.())}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export interface KbxWorkspaceLifecycle {
|
||||
save?: () => boolean | Promise<boolean>
|
||||
}
|
||||
const registry = new Map<string,KbxWorkspaceLifecycle>()
|
||||
export function registerWorkspaceLifecycle(path:string, lifecycle:KbxWorkspaceLifecycle){ registry.set(path,lifecycle); return () => registry.delete(path) }
|
||||
export async function requestWorkspaceSave(path:string):Promise<boolean>{ const handler=registry.get(path)?.save; return handler ? await handler() : false }
|
||||
export function canWorkspaceSave(path:string){ return Boolean(registry.get(path)?.save) }
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import type { KbxWorkspaceTab } from '@kbx/contracts'
|
||||
|
||||
const PREFIX = 'kbx.workspace.session.v1'
|
||||
const MAX_SCOPE_LENGTH = 160
|
||||
const MAX_TABS = 12
|
||||
const MAX_PATH_LENGTH = 2048
|
||||
const MAX_ID_LENGTH = 128
|
||||
const MAX_TIME_LENGTH = 64
|
||||
const MAX_STORED_BYTES = 48_000
|
||||
|
||||
export interface KbxWorkspaceSessionSnapshot {
|
||||
tabs: Array<Pick<KbxWorkspaceTab, 'screenId' | 'path' | 'pinned' | 'openedAt' | 'lastActivatedAt'>>
|
||||
dirtyDiscardedCount: number
|
||||
savedAt: string
|
||||
}
|
||||
|
||||
function bounded(value: unknown, max: number) {
|
||||
if (typeof value !== 'string') return ''
|
||||
const clean = value.trim()
|
||||
return clean.length > 0 && clean.length <= max ? clean : ''
|
||||
}
|
||||
function scopeKey(scopeValue: unknown) {
|
||||
const scope = bounded(scopeValue, MAX_SCOPE_LENGTH)
|
||||
return scope ? `${PREFIX}:${encodeURIComponent(scope)}` : ''
|
||||
}
|
||||
function safeTime(value: unknown) {
|
||||
const time = bounded(value, MAX_TIME_LENGTH)
|
||||
return time && !Number.isNaN(Date.parse(time)) ? time : new Date().toISOString()
|
||||
}
|
||||
function safePath(value: unknown) {
|
||||
const raw = bounded(value, MAX_PATH_LENGTH)
|
||||
if (!raw || /[\u0000-\u001f\u007f\\]|%2f|%5c|%00|%0d|%0a|%09|%2e%2e/i.test(raw)) return ''
|
||||
try {
|
||||
const parsed = new URL(raw, 'https://kbx.local')
|
||||
if (parsed.origin !== 'https://kbx.local' || parsed.username || parsed.password) return ''
|
||||
// Workspace resume deliberately persists only route identity. Query/hash may contain transient
|
||||
// filters, tokens, customer/order text or other sensitive context and must not survive refresh.
|
||||
return parsed.pathname
|
||||
} catch { return '' }
|
||||
}
|
||||
function normalizeSnapshot(value: unknown): KbxWorkspaceSessionSnapshot | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const raw = value as Partial<KbxWorkspaceSessionSnapshot>
|
||||
if (!Array.isArray(raw.tabs)) return null
|
||||
const tabs: KbxWorkspaceSessionSnapshot['tabs'] = []
|
||||
const seen = new Set<string>()
|
||||
for (const candidate of raw.tabs.slice(0, MAX_TABS)) {
|
||||
if (!candidate || typeof candidate !== 'object') continue
|
||||
const item = candidate as Partial<KbxWorkspaceTab>
|
||||
const screenId = bounded(item.screenId, MAX_ID_LENGTH)
|
||||
const path = safePath(item.path)
|
||||
if (!screenId || !path) continue
|
||||
const identity = `${screenId}:${path}`
|
||||
if (seen.has(identity)) continue
|
||||
seen.add(identity)
|
||||
tabs.push({screenId, path, pinned:Boolean(item.pinned), openedAt:safeTime(item.openedAt), lastActivatedAt:safeTime(item.lastActivatedAt)})
|
||||
}
|
||||
const dirtyDiscardedCount = Math.min(MAX_TABS, Math.max(0, Number.isFinite(Number(raw.dirtyDiscardedCount)) ? Number(raw.dirtyDiscardedCount) : 0))
|
||||
return { tabs, dirtyDiscardedCount, savedAt:safeTime(raw.savedAt) }
|
||||
}
|
||||
|
||||
export function loadWorkspaceSession(scopeValue: string): KbxWorkspaceSessionSnapshot | null {
|
||||
const key = scopeKey(scopeValue)
|
||||
if (!key || typeof sessionStorage === 'undefined') return null
|
||||
let raw = ''
|
||||
try { raw = sessionStorage.getItem(key) ?? '' } catch { return null }
|
||||
if (!raw || raw.length > MAX_STORED_BYTES) return null
|
||||
try { return normalizeSnapshot(JSON.parse(raw)) } catch { return null }
|
||||
}
|
||||
|
||||
export function saveWorkspaceSession(scopeValue: string, tabs: readonly KbxWorkspaceTab[]) {
|
||||
const key = scopeKey(scopeValue)
|
||||
if (!key || typeof sessionStorage === 'undefined') return false
|
||||
const cleanTabs = tabs.filter(tab => !tab.dirty).slice(0, MAX_TABS).flatMap(tab => {
|
||||
const screenId = bounded(tab.screenId, MAX_ID_LENGTH)
|
||||
const path = safePath(tab.path)
|
||||
if (!screenId || !path) return []
|
||||
return [{screenId, path, pinned:Boolean(tab.pinned), openedAt:safeTime(tab.openedAt), lastActivatedAt:safeTime(tab.lastActivatedAt)}]
|
||||
})
|
||||
const snapshot:KbxWorkspaceSessionSnapshot = {
|
||||
tabs: cleanTabs,
|
||||
dirtyDiscardedCount: Math.min(MAX_TABS, tabs.filter(tab => tab.dirty).length),
|
||||
savedAt: new Date().toISOString(),
|
||||
}
|
||||
const payload = JSON.stringify(snapshot)
|
||||
if (payload.length > MAX_STORED_BYTES) return false
|
||||
try { sessionStorage.setItem(key, payload); return true } catch { return false }
|
||||
}
|
||||
|
||||
export function clearWorkspaceSession(scopeValue: string) {
|
||||
const key = scopeKey(scopeValue)
|
||||
if (!key || typeof sessionStorage === 'undefined') return false
|
||||
try { sessionStorage.removeItem(key); return true } catch { return false }
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { KbxNavigationPreference, KbxNavigationResolvedEntry, KbxRecentNavigation, KbxWorkspaceTab } from '@kbx/contracts'
|
||||
|
||||
const STORAGE_PREFIX='kbx.navigation.preference.v3'
|
||||
const LEGACY_STORAGE_PREFIX='kbx.navigation.preference.v2'
|
||||
const MAX_STORED_PREFERENCE_BYTES=64_000
|
||||
const MAX_SCOPE_LENGTH=160
|
||||
const MAX_ID_LENGTH=128
|
||||
const MAX_TITLE_LENGTH=120
|
||||
const MAX_RECENT_PATH_LENGTH=1024
|
||||
const defaultPreference=():KbxNavigationPreference=>({favorites:[],recents:[],sideNavCollapsed:false,workspaceMaxTabs:10,themeMode:'light'})
|
||||
function boundedString(value:unknown,max:number,truncate=false){if(typeof value!=='string')return '';const clean=value.trim();if(clean.length<=max)return clean;return truncate?clean.slice(0,max):''}
|
||||
function normalizeScope(value?:string|null){const scope=boundedString(value,MAX_SCOPE_LENGTH);return scope||null}
|
||||
function storageKey(scope:string,prefix=STORAGE_PREFIX){return `${prefix}:${encodeURIComponent(scope)}`}
|
||||
function uniqueStrings(values:unknown[],limit:number){return [...new Set(values.map(value=>boundedString(value,MAX_ID_LENGTH)).filter(Boolean))].slice(0,limit)}
|
||||
function normalizePreference(value:unknown,scope:string|null):KbxNavigationPreference{
|
||||
const raw=(value&&typeof value==='object'?value:{}) as Partial<KbxNavigationPreference>
|
||||
const favorites=Array.isArray(raw.favorites)?uniqueStrings(raw.favorites,50):[]
|
||||
const recentsRaw=Array.isArray(raw.recents)?raw.recents.flatMap(item=>{
|
||||
if(!item||typeof item!=='object')return []
|
||||
const candidate=item as Partial<KbxRecentNavigation>
|
||||
const screenId=boundedString(candidate.screenId,MAX_ID_LENGTH)
|
||||
const title=boundedString(candidate.title,MAX_TITLE_LENGTH,true)
|
||||
const path=boundedString(candidate.path,MAX_RECENT_PATH_LENGTH)
|
||||
const visitedAt=boundedString(candidate.visitedAt,64)
|
||||
if(!screenId||!title||!path.startsWith('/')||!visitedAt||Number.isNaN(Date.parse(visitedAt)))return []
|
||||
return [{screenId,title,path,visitedAt} satisfies KbxRecentNavigation]
|
||||
}):[]
|
||||
const recents=[...new Map(recentsRaw.map(item=>[item.screenId,item])).values()].sort((a,b)=>b.visitedAt.localeCompare(a.visitedAt)).slice(0,12)
|
||||
const workspaceMaxTabs=Math.min(12,Math.max(8,Number.isFinite(Number(raw.workspaceMaxTabs))?Number(raw.workspaceMaxTabs):10))
|
||||
const themeMode=raw.themeMode==='dark'?'dark':'light'
|
||||
return {favorites,recents,sideNavCollapsed:Boolean(raw.sideNavCollapsed),workspaceMaxTabs,themeMode,...(scope?{scopeKey:scope}:{})}
|
||||
}
|
||||
function parseStoredPreference(raw:string|null,scope:string){
|
||||
if(!raw||raw.length>MAX_STORED_PREFERENCE_BYTES)return null
|
||||
try{return normalizePreference(JSON.parse(raw),scope)}catch{return null}
|
||||
}
|
||||
function loadPreference(scope:string|null):KbxNavigationPreference{
|
||||
if(!scope || typeof localStorage==='undefined')return defaultPreference()
|
||||
let currentRaw:string|null=null;let legacyRaw:string|null=null
|
||||
try{currentRaw=localStorage.getItem(storageKey(scope));legacyRaw=localStorage.getItem(storageKey(scope,LEGACY_STORAGE_PREFIX))}catch{return normalizePreference({},scope)}
|
||||
const current=parseStoredPreference(currentRaw,scope)
|
||||
if(current)return current
|
||||
const legacy=parseStoredPreference(legacyRaw,scope)
|
||||
if(legacy){try{localStorage.setItem(storageKey(scope),JSON.stringify(legacy))}catch{};return legacy}
|
||||
return normalizePreference({},scope)
|
||||
}
|
||||
|
||||
export const useKbxWorkspaceStore=defineStore('kbx-workspace',()=>{
|
||||
const scopeKey=ref<string|null>(null)
|
||||
const preference=ref<KbxNavigationPreference>(defaultPreference())
|
||||
const tabs=ref<KbxWorkspaceTab[]>([])
|
||||
const activeKey=ref<string|null>(null)
|
||||
const menuSearchOpen=ref(false)
|
||||
const shellNotice=ref<string|null>(null)
|
||||
|
||||
function persist(){
|
||||
if(!scopeKey.value||typeof localStorage==='undefined')return
|
||||
try{localStorage.setItem(storageKey(scopeKey.value),JSON.stringify(normalizePreference(preference.value,scopeKey.value)))}
|
||||
catch{shellNotice.value='개인화 설정을 저장하지 못했습니다. 현재 세션에서는 계속 사용할 수 있습니다.'}
|
||||
}
|
||||
function clearWorkspaceSession(){tabs.value=[];activeKey.value=null;menuSearchOpen.value=false;shellNotice.value=null}
|
||||
function setPreferenceScope(value?:string|null){
|
||||
const next=normalizeScope(value)
|
||||
if(next===scopeKey.value)return
|
||||
clearWorkspaceSession()
|
||||
scopeKey.value=next
|
||||
preference.value=loadPreference(next)
|
||||
}
|
||||
function key(screenId:string,path:string){return `${screenId}:${path}`}
|
||||
function evictForCapacity(){
|
||||
if(tabs.value.length<preference.value.workspaceMaxTabs)return true
|
||||
const candidate=[...tabs.value]
|
||||
.filter(tab=>!tab.dirty&&!tab.pinned&&tab.key!==activeKey.value)
|
||||
.sort((a,b)=>a.lastActivatedAt.localeCompare(b.lastActivatedAt))[0]
|
||||
if(!candidate){shellNotice.value=`열린 업무가 최대 ${preference.value.workspaceMaxTabs}개입니다. 미저장 또는 고정된 업무를 정리한 후 다시 여세요.`;return false}
|
||||
close(candidate.key)
|
||||
return true
|
||||
}
|
||||
function restoreTabs(restored:KbxWorkspaceTab[]){
|
||||
const limit=preference.value.workspaceMaxTabs
|
||||
const normalized=restored.filter(tab=>!tab.dirty).slice(0,limit).map(tab=>({...tab,dirty:false,resumed:true}))
|
||||
tabs.value=normalized
|
||||
activeKey.value=null
|
||||
return normalized.length
|
||||
}
|
||||
function notifyShell(message:string){shellNotice.value=boundedString(message,240,true)||null}
|
||||
function syncRoute(screenId:string,title:string,path:string,recent?:KbxRecentNavigation|null){
|
||||
const now=new Date().toISOString();const tabKey=key(screenId,path);let tab=tabs.value.find(x=>x.key===tabKey)
|
||||
if(!tab){if(!evictForCapacity())return false;tab={key:tabKey,screenId,title,path,openedAt:now,lastActivatedAt:now,dirty:false};tabs.value.push(tab)}else{tab.title=title;tab.path=path;tab.lastActivatedAt=now;tab.resumed=false}
|
||||
activeKey.value=tabKey;shellNotice.value=null
|
||||
if(recent)recordRecent(recent)
|
||||
return true
|
||||
}
|
||||
function setDirty(tabKey:string,dirty:boolean){const tab=tabs.value.find(x=>x.key===tabKey);if(tab)tab.dirty=dirty}
|
||||
function setActiveDirty(dirty:boolean){if(activeKey.value)setDirty(activeKey.value,dirty)}
|
||||
function togglePinned(tabKey:string){const tab=tabs.value.find(x=>x.key===tabKey);if(tab)tab.pinned=!tab.pinned}
|
||||
function close(tabKey:string){const index=tabs.value.findIndex(x=>x.key===tabKey);if(index<0)return;const wasActive=activeKey.value===tabKey;tabs.value.splice(index,1);if(wasActive){const next=tabs.value[Math.min(index,tabs.value.length-1)]??tabs.value.at(-1);activeKey.value=next?.key??null}}
|
||||
function activate(tabKey:string){const tab=tabs.value.find(x=>x.key===tabKey);if(tab){activeKey.value=tabKey;tab.lastActivatedAt=new Date().toISOString();tab.resumed=false}}
|
||||
function deactivate(){activeKey.value=null}
|
||||
function pruneTabs(allowedScreenIds:Set<string>){
|
||||
const active=activeKey.value;const before=tabs.value.length
|
||||
tabs.value=tabs.value.filter(tab=>allowedScreenIds.has(tab.screenId))
|
||||
if(active&&!tabs.value.some(tab=>tab.key===active))activeKey.value=tabs.value.at(-1)?.key??null
|
||||
return before-tabs.value.length
|
||||
}
|
||||
function reconcilePreference(allowedScreenIds:Set<string>){
|
||||
const favorites=preference.value.favorites.filter(id=>allowedScreenIds.has(id))
|
||||
const recents=preference.value.recents.filter(item=>allowedScreenIds.has(item.screenId))
|
||||
const changed=favorites.length!==preference.value.favorites.length||recents.length!==preference.value.recents.length
|
||||
if(changed){preference.value={...preference.value,favorites,recents};persist()}
|
||||
return changed
|
||||
}
|
||||
function toggleFavorite(screenId:string){const normalized=boundedString(screenId,MAX_ID_LENGTH);if(!normalized)return;const list=preference.value.favorites;const index=list.indexOf(normalized);if(index>=0)list.splice(index,1);else list.unshift(normalized);preference.value.favorites=uniqueStrings(list,50);persist()}
|
||||
function recordRecent(recent:KbxRecentNavigation){const normalized=normalizePreference({...preference.value,recents:[recent,...preference.value.recents.filter(x=>x.screenId!==recent.screenId)]},scopeKey.value).recents;preference.value.recents=normalized;persist()}
|
||||
function toggleSideNav(){preference.value.sideNavCollapsed=!preference.value.sideNavCollapsed;persist()}
|
||||
function toggleTheme(){preference.value.themeMode=preference.value.themeMode==='dark'?'light':'dark';persist()}
|
||||
function favoriteEntries(entries:KbxNavigationResolvedEntry[]){const map=new Map(entries.filter(x=>x.favoriteAllowed!==false).map(x=>[x.screenId,x]));return preference.value.favorites.map(id=>map.get(id)).filter((x):x is KbxNavigationResolvedEntry=>Boolean(x))}
|
||||
function dismissShellNotice(){shellNotice.value=null}
|
||||
const activeTab=computed(()=>tabs.value.find(x=>x.key===activeKey.value)??null)
|
||||
return {scopeKey,preference,tabs,activeKey,activeTab,menuSearchOpen,shellNotice,setPreferenceScope,clearWorkspaceSession,restoreTabs,notifyShell,syncRoute,setDirty,setActiveDirty,togglePinned,close,activate,deactivate,pruneTabs,reconcilePreference,toggleFavorite,recordRecent,toggleSideNav,toggleTheme,favoriteEntries,dismissShellNotice}
|
||||
})
|
||||
Reference in New Issue
Block a user