V13-FE-006: consolidate approved UI and contract hardening
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import KbxButton from '../components/KbxButton.vue'
|
||||
|
||||
withDefaults(defineProps<{ screenTitle?:string; screenId?:string; revealDetails?:boolean }>(),{revealDetails:false})
|
||||
const emit=defineEmits<{ home:[]; menuSearch:[] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="kbx-access-denied" role="alert" aria-labelledby="kbx-access-denied-title">
|
||||
<div class="kbx-access-denied__box">
|
||||
<strong id="kbx-access-denied-title">접근 권한이 없습니다.</strong>
|
||||
<p><template v-if="revealDetails"><b v-if="screenTitle">{{screenTitle}}</b><span v-if="screenId"> ({{screenId}})</span> </template>현재 사용자에게 이 업무 화면을 사용할 권한이 없습니다.</p>
|
||||
<p>메뉴 노출 여부와 실제 업무 권한은 분리되며, 서버에서도 동일 권한을 최종 검증해야 합니다.</p>
|
||||
<div class="kbx-access-denied__actions"><KbxButton label="홈으로 이동" variant="primary" @click="emit('home')" /><KbxButton label="다른 업무 찾기" @click="emit('menuSearch')" /></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kbx-access-denied{height:100%;min-height:var(--kbx-content-min-height);display:grid;place-items:center;padding:var(--kbx-space-6)}.kbx-access-denied__box{max-width:var(--kbx-access-max-width);border:var(--kbx-border-width) solid var(--kbx-color-border);border-left:var(--kbx-accent-border-width) solid var(--kbx-color-danger);padding:var(--kbx-space-5);background:var(--kbx-color-surface)}.kbx-access-denied strong{font-size:var(--kbx-font-lg)}.kbx-access-denied p{font-size:var(--kbx-font-sm);color:var(--kbx-color-text-muted);line-height:1.6}.kbx-access-denied__actions{display:flex;align-items:center;gap:var(--kbx-space-2);margin-top:var(--kbx-space-3)}
|
||||
</style>
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { KbxNavigationResolvedEntry, KbxNavigationSection, KbxRecentNavigation, KbxWorkspaceTab } from '@kbx/contracts'
|
||||
import KbxGlobalHeader from './KbxGlobalHeader.vue'
|
||||
import KbxMenuSearch from './KbxMenuSearch.vue'
|
||||
import KbxSideNavigation from './KbxSideNavigation.vue'
|
||||
import KbxWorkspaceTabs from './KbxWorkspaceTabs.vue'
|
||||
|
||||
const props=withDefaults(defineProps<{productName?:string;activeModule?:string;activeScreenId?:string|null;sections:KbxNavigationSection[];allEntries:KbxNavigationResolvedEntry[];favorites?:KbxNavigationResolvedEntry[];recents?:KbxRecentNavigation[];tabs?:KbxWorkspaceTab[];activeTabKey?:string|null;sideNavCollapsed?:boolean;menuSearchOpen?:boolean;operationCount?:number;notificationCount?:number;operationsOpen?:boolean;notificationsOpen?:boolean;homeActive?:boolean;workspaceMaxTabs?:number;shellNotice?:string|null;themeMode?:'light'|'dark'}>(),{productName:'KBX',activeModule:'',activeScreenId:null,favorites:()=>[],recents:()=>[],tabs:()=>[],activeTabKey:null,sideNavCollapsed:false,menuSearchOpen:false,operationCount:0,notificationCount:0,operationsOpen:false,notificationsOpen:false,homeActive:false,workspaceMaxTabs:10,shellNotice:null,themeMode:'light'})
|
||||
const emit=defineEmits<{home:[];open:[entry:KbxNavigationResolvedEntry];openRecent:[entry:KbxRecentNavigation];selectTab:[tab:KbxWorkspaceTab];closeTab:[tab:KbxWorkspaceTab];toggleTabPin:[tab:KbxWorkspaceTab];toggleFavorite:[screenId:string];toggleSideNav:[];menuSearchOpen:[];menuSearchClose:[];operations:[];notifications:[];themeToggle:[];profile:[];dismissShellNotice:[]}>()
|
||||
|
||||
const moduleOptions=computed(()=>[...new Set(props.sections.map(section=>section.module).filter((module):module is NonNullable<KbxNavigationSection['module']>=>Boolean(module)))])
|
||||
const focusedModule=ref(props.activeModule||moduleOptions.value[0]||'')
|
||||
watch([()=>props.activeModule,moduleOptions],()=>{if(props.activeModule)focusedModule.value=props.activeModule;else if(!moduleOptions.value.includes(focusedModule.value as any))focusedModule.value=moduleOptions.value[0]??''},{immediate:true})
|
||||
function selectModule(module:string){if(moduleOptions.value.includes(module as any))focusedModule.value=module}
|
||||
|
||||
const priorityScreenIds=computed(()=>[...props.favorites.map(x=>x.screenId),...props.recents.map(x=>x.screenId)].filter((id,index,list)=>list.indexOf(id)===index))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell" :data-kbx-module="focusedModule || 'COMMON'" :data-home-active="props.homeActive || undefined">
|
||||
<a class="skip-link" href="#kbx-main-workspace">본문 바로가기</a>
|
||||
<KbxGlobalHeader :product-name="props.productName" :active-module="focusedModule" :modules="moduleOptions" :operation-count="props.operationCount" :notification-count="props.notificationCount" :operations-open="props.operationsOpen" :notifications-open="props.notificationsOpen" :home-active="props.homeActive" :theme-mode="props.themeMode" @home="emit('home')" @module-select="selectModule" @menu-search="emit('menuSearchOpen')" @operations="emit('operations')" @notifications="emit('notifications')" @theme-toggle="emit('themeToggle')" @profile="emit('profile')" />
|
||||
<div class="below-header">
|
||||
<KbxSideNavigation :sections="props.sections" :favorites="props.favorites" :recents="props.recents" :active-screen-id="props.activeScreenId" :collapsed="props.sideNavCollapsed" :home-active="props.homeActive" :preferred-module="focusedModule" @home="emit('home')" @open="emit('open',$event)" @open-recent="emit('openRecent',$event)" @toggle-favorite="emit('toggleFavorite',$event)" @toggle-collapsed="emit('toggleSideNav')" @module-selected="selectModule" @menu-search="emit('menuSearchOpen')" />
|
||||
<main class="workspace">
|
||||
<KbxWorkspaceTabs :tabs="props.tabs" :active-key="props.activeTabKey" :max-tabs="props.workspaceMaxTabs" @select="emit('selectTab',$event)" @close-request="emit('closeTab',$event)" @toggle-pin="emit('toggleTabPin',$event)" />
|
||||
<aside v-if="props.shellNotice" class="shell-notice" role="status" aria-live="polite"><span>{{props.shellNotice}}</span><button type="button" aria-label="안내 닫기" @click="emit('dismissShellNotice')">×</button></aside>
|
||||
<section class="runtime"><slot name="runtime" /></section>
|
||||
<section id="kbx-main-workspace" class="content" tabindex="-1"><slot /></section>
|
||||
</main>
|
||||
</div>
|
||||
<KbxMenuSearch :open="props.menuSearchOpen" :entries="props.allEntries" :priority-screen-ids="priorityScreenIds" @close="emit('menuSearchClose')" @select="emit('open',$event)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>.skip-link{position:fixed;left:var(--kbx-space-2);top:calc(var(--kbx-control-lg) * -2);z-index:300;padding:var(--kbx-space-2) var(--kbx-space-3);background:var(--kbx-color-text);color:var(--kbx-color-surface);border-radius:var(--kbx-radius-sm)}.skip-link:focus{top:var(--kbx-space-2)}.app-shell{height:100vh;min-height:var(--kbx-app-shell-min-height);display:flex;flex-direction:column;background:var(--kbx-color-workspace);color:var(--kbx-color-text)}.below-header{flex:1;min-height:0;display:flex}.workspace{flex:1;min-width:0;display:flex;flex-direction:column;background:var(--kbx-color-workspace)}.runtime{background:var(--kbx-color-surface)}.content{flex:1;min-height:0;overflow:auto;background:var(--kbx-color-workspace)}.shell-notice{min-height:var(--kbx-shell-notice-height);display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-2);padding:0 var(--kbx-space-3);border-bottom:var(--kbx-border-width) solid var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface);font-size:var(--kbx-font-sm)}.shell-notice button{width:var(--kbx-control-xs);height:var(--kbx-control-xs);border:0;background:transparent;color:var(--kbx-color-text-muted)}
|
||||
</style>
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
productName?: string
|
||||
activeModule?: string
|
||||
modules?: string[]
|
||||
menuSearchLabel?: string
|
||||
operationCount?: number
|
||||
notificationCount?: number
|
||||
operationsOpen?: boolean
|
||||
notificationsOpen?: boolean
|
||||
homeActive?: boolean
|
||||
themeMode?: 'light' | 'dark'
|
||||
}>(), { productName:'KBX', activeModule:'', modules:()=>[], menuSearchLabel:'메뉴 검색', operationCount:0, notificationCount:0, operationsOpen:false, notificationsOpen:false, homeActive:false, themeMode:'light' })
|
||||
const emit = defineEmits<{ home:[]; moduleSelect:[module:string]; menuSearch:[]; operations:[]; notifications:[]; themeToggle:[]; profile:[] }>()
|
||||
const label=(module:string)=>module==='COMMON'?'공통':module
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="kbx-global-header">
|
||||
<div class="brand" aria-label="제품 및 모듈">
|
||||
<button type="button" class="brand__home" :aria-current="homeActive?'page':undefined" @click="emit('home')"><strong>{{ props.productName }}</strong><span class="brand__home-label">홈</span></button>
|
||||
<label v-if="props.modules.length" class="module-select"><span class="sr-only">업무 모듈</span><select :value="props.activeModule" @change="emit('moduleSelect',($event.target as HTMLSelectElement).value)"><option value="" disabled>모듈</option><option v-for="module in props.modules" :key="module" :value="module">{{label(module)}}</option></select></label>
|
||||
</div>
|
||||
<button class="menu-search" type="button" aria-keyshortcuts="Control+K" @click="emit('menuSearch')"><span>{{ props.menuSearchLabel }}</span><kbd>Ctrl K</kbd></button>
|
||||
<div class="actions"><button id="kbx-runtime-operations-trigger" type="button" aria-controls="kbx-runtime-panel" :aria-expanded="props.operationsOpen" @click="emit('operations')">작업 <b v-if="props.operationCount">{{ props.operationCount }}</b></button><button id="kbx-runtime-notifications-trigger" type="button" aria-controls="kbx-runtime-panel" :aria-expanded="props.notificationsOpen" @click="emit('notifications')">알림 <b v-if="props.notificationCount">{{ props.notificationCount }}</b></button><button type="button" class="theme-toggle" :aria-label="props.themeMode==='dark'?'밝은 테마로 변경':'어두운 테마로 변경'" :title="props.themeMode==='dark'?'KBX Business Dark · 밝게 전환':'KBX Business Light · 어둡게 전환'" @click="emit('themeToggle')"><span aria-hidden="true">{{props.themeMode==='dark'?'☾':'☀'}}</span><span class="theme-toggle__label">{{props.themeMode==='dark'?'Dark':'Light'}}</span></button><button type="button" @click="emit('profile')">사용자</button></div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kbx-global-header{height:var(--kbx-side-nav-collapsed-width);display:grid;grid-template-columns:var(--kbx-side-nav-width) minmax(var(--kbx-master-list-min-width),var(--kbx-access-max-width)) 1fr;align-items:center;gap:var(--kbx-space-4);padding:0 var(--kbx-space-3);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-shell-chrome);box-shadow:var(--kbx-shadow-shell);z-index:20}.brand{display:flex;align-items:center;gap:var(--kbx-space-2);min-width:0}.brand__home{height:var(--kbx-control-height);display:flex;align-items:center;gap:var(--kbx-space-2);border:0;border-radius:var(--kbx-radius-sm);background:transparent;padding:0 var(--kbx-space-2);color:var(--kbx-color-text)}.brand__home:hover,.brand__home[aria-current="page"]{background:var(--kbx-color-surface-hover)}.brand__home strong{font-size:var(--kbx-font-lg);color:var(--kbx-color-module-accent);letter-spacing:-.01em}.brand__home-label{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.module-select{padding-left:var(--kbx-space-2);border-left:var(--kbx-border-width) solid var(--kbx-color-border)}.module-select select{width:var(--kbx-global-header-module-selector-width);height:var(--kbx-control-sm);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface);color:var(--kbx-color-text);font-size:var(--kbx-font-sm);padding:0 var(--kbx-space-2)}.menu-search{height:var(--kbx-control-height);display:flex;align-items:center;justify-content:space-between;padding:0 var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface-muted);color:var(--kbx-color-text-muted);text-align:left}.menu-search:hover{border-color:var(--kbx-color-module-accent);background:var(--kbx-color-module-accent-surface)}.menu-search kbd{font-size:var(--kbx-font-xs);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);padding:var(--kbx-space-1);background:var(--kbx-color-surface)}.actions{display:flex;justify-content:flex-end;gap:var(--kbx-space-1)}.actions button{height:var(--kbx-control-sm);border:0;background:transparent;border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-2)}.actions button:hover{background:var(--kbx-color-surface-hover)}.actions b{min-width:var(--kbx-checkbox-track);display:inline-block;margin-left:var(--kbx-space-1);color:var(--kbx-color-module-accent)}.theme-toggle{display:inline-flex;align-items:center;gap:var(--kbx-space-1);border:var(--kbx-border-width) solid transparent!important}.theme-toggle:hover{border-color:var(--kbx-color-border)!important}.theme-toggle__label{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.sr-only{position:absolute;width:var(--kbx-visually-hidden-size);height:var(--kbx-visually-hidden-size);padding:0;margin:calc(var(--kbx-visually-hidden-size) * -1);overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:56rem){.kbx-global-header{grid-template-columns:auto minmax(0,1fr) auto;gap:var(--kbx-space-2);padding-inline:var(--kbx-space-2)}.brand__home-label,.menu-search kbd{display:none}.module-select{padding-left:var(--kbx-space-1)}.module-select select{width:var(--kbx-control-lg);padding:0}.menu-search{min-width:0;padding-inline:var(--kbx-space-2)}.menu-search span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions{min-width:0}.actions button{padding-inline:var(--kbx-space-1)}}@media(max-width:40rem){.kbx-global-header{grid-template-columns:auto minmax(0,1fr)}.actions{grid-column:1/-1;display:grid;grid-template-columns:repeat(4,1fr);border-top:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface)}.kbx-global-header{height:auto;min-height:var(--kbx-side-nav-collapsed-width);align-content:center}.actions button{width:100%}}
|
||||
</style>
|
||||
+218
File diff suppressed because one or more lines are too long
+16
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import type { KbxNavigationResolvedEntry } from '@kbx/contracts'
|
||||
import { searchNavigationEntries } from './navigationSearch'
|
||||
const props=withDefaults(defineProps<{open:boolean;entries:KbxNavigationResolvedEntry[];priorityScreenIds?:string[]}>(),{priorityScreenIds:()=>[]})
|
||||
const emit=defineEmits<{close:[];select:[entry:KbxNavigationResolvedEntry]}>()
|
||||
const query=ref('');const selected=ref(0);const input=ref<HTMLInputElement|null>(null);const dialog=ref<HTMLElement|null>(null);let restoreFocus:HTMLElement|null=null
|
||||
const results=computed(()=>searchNavigationEntries(props.entries,query.value,12,props.priorityScreenIds));const byId=computed(()=>new Map(props.entries.map(x=>[x.screenId,x])))
|
||||
const activeOptionId=computed(()=>results.value[selected.value]?`kbx-menu-result-${results.value[selected.value].screenId}`:undefined)
|
||||
function choose(index=selected.value){const result=results.value[index];if(!result)return;const entry=byId.value.get(result.screenId);if(entry){emit('select',entry);emit('close')}}
|
||||
function trapTab(e:KeyboardEvent){const root=dialog.value;if(!root)return;const nodes=[...root.querySelectorAll<HTMLElement>('input,button,[tabindex]:not([tabindex="-1"])')].filter(node=>!node.hasAttribute('disabled'));if(!nodes.length)return;const first=nodes[0],last=nodes.at(-1);if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus()}}
|
||||
function onKeydown(e:KeyboardEvent){if(e.key==='Escape'){e.preventDefault();emit('close')}else if(e.key==='ArrowDown'){e.preventDefault();selected.value=Math.min(selected.value+1,results.value.length-1)}else if(e.key==='ArrowUp'){e.preventDefault();selected.value=Math.max(0,selected.value-1)}else if(e.key==='Enter'){e.preventDefault();choose()}else if(e.key==='Tab'){trapTab(e)}}
|
||||
watch(()=>props.open,async (value,previous)=>{if(value){restoreFocus=document.activeElement instanceof HTMLElement?document.activeElement:null;query.value='';selected.value=0;await nextTick();input.value?.focus()}else if(previous){await nextTick();restoreFocus?.focus();restoreFocus=null}});watch(results,()=>{selected.value=0})
|
||||
</script>
|
||||
<template><div v-if="props.open" class="overlay" @mousedown.self="emit('close')"><section ref="dialog" class="dialog" role="dialog" aria-modal="true" aria-labelledby="kbx-menu-search-title" @keydown="onKeydown"><h2 id="kbx-menu-search-title" class="sr-only">메뉴·화면 검색</h2><input ref="input" v-model="query" type="search" placeholder="메뉴명, 화면코드, 업무명 검색" autocomplete="off" role="combobox" aria-autocomplete="list" aria-controls="kbx-menu-results" :aria-expanded="results.length>0" :aria-activedescendant="activeOptionId"/><div class="hint"><span v-if="!query">즐겨찾기·최근업무를 우선 표시합니다.</span><span v-else role="status" aria-live="polite">검색 결과 {{results.length}}건</span><span>↑↓ 이동 · Enter 열기 · Esc 닫기</span></div><div id="kbx-menu-results" role="listbox" aria-label="검색 결과"><button v-for="(result,index) in results" :id="`kbx-menu-result-${result.screenId}`" :key="result.screenId" type="button" role="option" :aria-selected="index===selected" class="result" :class="{selected:index===selected}" @mouseenter="selected=index" @click="choose(index)"><span class="main"><b>{{result.title}}</b><small>{{result.module}} · {{result.section}}</small></span><code>{{result.screenId}}</code></button></div><p v-if="!results.length" class="empty" role="status">일치하는 메뉴가 없습니다.</p></section></div></template>
|
||||
<style scoped>.overlay{position:fixed;inset:0;z-index:200;background:var(--kbx-color-overlay);display:flex;align-items:flex-start;justify-content:center;padding-top:var(--kbx-menu-search-top)}.dialog{width:min(var(--kbx-menu-search-width),calc(100vw - var(--kbx-space-6)));max-height:70vh;overflow:auto;background:var(--kbx-color-surface);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-md);box-shadow:var(--kbx-shadow-overlay);padding:var(--kbx-space-2)}.dialog input{width:100%;height:var(--kbx-control-lg);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-3);font-size:var(--kbx-font-md)}.hint{display:flex;justify-content:space-between;gap:var(--kbx-space-2);padding:var(--kbx-space-2) var(--kbx-space-1);color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.result{width:100%;min-height:var(--kbx-menu-search-result-height);display:flex;align-items:center;justify-content:space-between;text-align:left;border:0;border-radius:var(--kbx-radius-sm);background:transparent;padding:var(--kbx-space-1) var(--kbx-space-2)}.result.selected{background:var(--kbx-color-surface-hover)}.main{display:flex;flex-direction:column;gap:var(--kbx-space-1)}.main small,.result code{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.empty{text-align:center;color:var(--kbx-color-text-muted);padding:var(--kbx-space-6)}.sr-only{position:absolute;width:var(--kbx-visually-hidden-size);height:var(--kbx-visually-hidden-size);padding:0;margin:calc(var(--kbx-visually-hidden-size) * -1);overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}</style>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import KbxButton from '../components/KbxButton.vue'
|
||||
const emit=defineEmits<{home:[];menuSearch:[]}>()
|
||||
</script>
|
||||
<template>
|
||||
<section class="kbx-route-not-found" aria-labelledby="kbx-route-not-found-title">
|
||||
<div class="kbx-route-not-found__box">
|
||||
<strong id="kbx-route-not-found-title">화면을 찾을 수 없습니다.</strong>
|
||||
<p>주소가 변경되었거나 더 이상 제공되지 않는 업무 화면입니다. 저장된 링크를 그대로 신뢰하지 않고 현재 메뉴에서 다시 찾아주세요.</p>
|
||||
<div class="kbx-route-not-found__actions"><KbxButton label="메뉴 검색" variant="primary" @click="emit('menuSearch')"/><KbxButton label="홈으로 이동" @click="emit('home')"/></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.kbx-route-not-found{height:100%;min-height:var(--kbx-content-min-height);display:grid;place-items:center;padding:var(--kbx-space-6)}.kbx-route-not-found__box{max-width:var(--kbx-access-max-width);border:var(--kbx-border-width) solid var(--kbx-color-border);border-left:var(--kbx-accent-border-width) solid var(--kbx-color-warning);padding:var(--kbx-space-5);background:var(--kbx-color-surface)}.kbx-route-not-found strong{font-size:var(--kbx-font-lg)}.kbx-route-not-found p{font-size:var(--kbx-font-sm);color:var(--kbx-color-text-muted);line-height:1.6}.kbx-route-not-found__actions{display:flex;align-items:center;gap:var(--kbx-space-2);margin-top:var(--kbx-space-3)}</style>
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { KbxNavigationResolvedEntry, KbxNavigationSection, KbxRecentNavigation } from '@kbx/contracts'
|
||||
|
||||
const props=withDefaults(defineProps<{sections:KbxNavigationSection[];favorites?:KbxNavigationResolvedEntry[];recents?:KbxRecentNavigation[];activeScreenId?:string|null;collapsed?:boolean;homeActive?:boolean;preferredModule?:string}>(),{favorites:()=>[],recents:()=>[],activeScreenId:null,collapsed:false,homeActive:false,preferredModule:''})
|
||||
const emit=defineEmits<{home:[];open:[entry:KbxNavigationResolvedEntry];openRecent:[entry:KbxRecentNavigation];toggleFavorite:[screenId:string];toggleCollapsed:[];menuSearch:[];moduleSelected:[module:string]}>()
|
||||
const moduleLabel:Record<string,string>={OMS:'OMS',ERP:'ERP',WMS:'WMS',COMMON:'공통'}
|
||||
const moduleOrder=['OMS','ERP','WMS','COMMON'] as const
|
||||
const grouped=computed(()=>moduleOrder.map(module=>({module,label:moduleLabel[module],sections:props.sections.filter(section=>section.module===module)})).filter(group=>group.sections.length))
|
||||
const activeModule=computed(()=>props.sections.find(section=>section.entries.some(entry=>entry.screenId===props.activeScreenId))?.module)
|
||||
const selectedModule=ref<string>('')
|
||||
watch([()=>props.preferredModule,activeModule,grouped],()=>{if(props.preferredModule&&grouped.value.some(x=>x.module===props.preferredModule))selectedModule.value=props.preferredModule;else if(activeModule.value)selectedModule.value=activeModule.value;else if(!grouped.value.some(x=>x.module===selectedModule.value))selectedModule.value=grouped.value[0]?.module??''},{immediate:true})
|
||||
const selectedGroup=computed(()=>grouped.value.find(x=>x.module===selectedModule.value)??grouped.value[0])
|
||||
const isFavorite=(id:string)=>props.favorites.some(x=>x.screenId===id)
|
||||
function chooseModule(module:string){selectedModule.value=module;emit('moduleSelected',module);if(props.collapsed)emit('toggleCollapsed')}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="side-nav" :class="{collapsed:props.collapsed}" aria-label="업무 메뉴">
|
||||
<div class="side-nav__top">
|
||||
<button class="home" type="button" :class="{active:homeActive}" :aria-current="homeActive?'page':undefined" @click="emit('home')"><span aria-hidden="true">⌂</span><span v-if="!collapsed">홈</span></button>
|
||||
<button class="collapse" type="button" @click="emit('toggleCollapsed')" :aria-label="props.collapsed?'메뉴 펼치기':'메뉴 접기'">{{props.collapsed?'›':'‹'}}</button>
|
||||
</div>
|
||||
|
||||
<template v-if="!props.collapsed">
|
||||
<button class="side-nav__search" type="button" aria-keyshortcuts="Control+K" @click="emit('menuSearch')"><span>메뉴·화면 검색</span><kbd>Ctrl K</kbd></button>
|
||||
<nav class="module-switcher" aria-label="업무 모듈"><button v-for="group in grouped" :key="group.module" type="button" :aria-pressed="selectedModule===group.module" @click="chooseModule(group.module)">{{group.label}}</button></nav>
|
||||
|
||||
<section v-if="props.favorites.length" class="personal"><h2>즐겨찾기</h2><button v-for="entry in props.favorites.slice(0,6)" :key="entry.screenId" class="nav-item favorite" :class="{active:entry.screenId===props.activeScreenId}" :aria-current="entry.screenId===props.activeScreenId?'page':undefined" @click="emit('open',entry)"><span aria-hidden="true">★</span><span>{{entry.title}}</span></button></section>
|
||||
<section v-if="props.recents.length" class="personal"><h2>최근메뉴</h2><button v-for="entry in props.recents.slice(0,4)" :key="entry.screenId+entry.path" class="nav-item" @click="emit('openRecent',entry)"><span aria-hidden="true">↺</span><span>{{entry.title}}</span></button></section>
|
||||
|
||||
<section v-if="selectedGroup" class="module-block active">
|
||||
<h2 class="module-title">{{selectedGroup.label}} 업무</h2>
|
||||
<div v-for="section in selectedGroup.sections" :key="section.key" class="section-block">
|
||||
<h3>{{section.label.replace(`${selectedGroup.label} · `,'')}}</h3>
|
||||
<div v-for="entry in section.entries" :key="entry.screenId" class="row">
|
||||
<button class="nav-item main" :class="{active:entry.screenId===props.activeScreenId}" :aria-current="entry.screenId===props.activeScreenId?'page':undefined" @click="emit('open',entry)"><span>{{entry.title}}</span></button>
|
||||
<button v-if="entry.favoriteAllowed!==false" class="star" type="button" :aria-label="`${entry.title} ${isFavorite(entry.screenId)?'즐겨찾기 해제':'즐겨찾기 추가'}`" :aria-pressed="isFavorite(entry.screenId)" @click="emit('toggleFavorite',entry.screenId)">{{isFavorite(entry.screenId)?'★':'☆'}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else class="collapsed-actions"><button v-for="group in grouped" :key="group.module" type="button" :class="{active:activeModule===group.module}" :title="`${group.label} 메뉴 열기`" @click="chooseModule(group.module)">{{group.module==='COMMON'?'공':group.module.charAt(0)}}</button><button type="button" title="메뉴 검색" aria-label="메뉴 검색" aria-keyshortcuts="Control+K" @click="emit('menuSearch')">⌕</button></div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.side-nav{position:relative;width:var(--kbx-side-nav-width);min-width:var(--kbx-side-nav-width);border-right:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-navigation-surface);overflow:auto;padding:var(--kbx-space-2) var(--kbx-space-2) var(--kbx-space-5)}.side-nav.collapsed{width:var(--kbx-side-nav-collapsed-width);min-width:var(--kbx-side-nav-collapsed-width);overflow:hidden}.side-nav__top{position:sticky;top:0;z-index:3;display:flex;align-items:center;gap:var(--kbx-space-1);padding-bottom:var(--kbx-space-2);background:var(--kbx-color-navigation-surface)}.home{height:var(--kbx-control-sm);flex:1;display:flex;align-items:center;gap:var(--kbx-space-2);border:0;border-radius:var(--kbx-radius-sm);background:transparent;padding:0 var(--kbx-space-2);text-align:left}.home:hover,.home.active{background:var(--kbx-color-module-accent-surface);color:var(--kbx-color-module-accent)}.collapse{width:var(--kbx-control-xs);height:var(--kbx-control-xs);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);border-radius:var(--kbx-radius-sm)}.side-nav__search{width:100%;height:var(--kbx-control-height);display:flex;align-items:center;justify-content:space-between;padding:0 var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface-muted);color:var(--kbx-color-text-muted)}.side-nav__search kbd{font-size:var(--kbx-font-xs)}.module-switcher{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--kbx-space-1);margin-top:var(--kbx-space-2)}.module-switcher button{height:var(--kbx-control-xs);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface);font-size:var(--kbx-font-xs)}.module-switcher button[aria-pressed="true"]{border-color:var(--kbx-color-module-accent-border);color:var(--kbx-color-module-accent);font-weight:600;background:var(--kbx-color-module-accent-surface)}.side-nav h2{margin:var(--kbx-space-3) var(--kbx-space-2) var(--kbx-space-1);font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted)}.module-block{margin-top:var(--kbx-space-2);padding-top:var(--kbx-space-1);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}.module-block.active>.module-title{color:var(--kbx-color-module-accent)}.section-block h3{margin:var(--kbx-space-2) var(--kbx-space-2) var(--kbx-space-1);font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted)}.row{display:flex;align-items:center}.nav-item{min-height:var(--kbx-control-sm);border:0;background:transparent;border-radius:var(--kbx-radius-sm);display:flex;align-items:center;gap:var(--kbx-space-2);width:100%;padding:0 var(--kbx-space-2);text-align:left;color:var(--kbx-color-text)}.nav-item:hover,.nav-item.active{background:var(--kbx-color-surface-hover)}.nav-item.active{color:var(--kbx-color-module-accent);font-weight:600;box-shadow:inset var(--kbx-accent-border-width) 0 0 var(--kbx-color-module-accent)}.nav-item.main{min-width:0;flex:1}.nav-item.main span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.star{width:var(--kbx-control-xs);height:var(--kbx-control-xs);border:0;background:transparent;color:var(--kbx-color-text-muted);border-radius:var(--kbx-radius-sm)}.star:hover{background:var(--kbx-color-module-accent-surface);color:var(--kbx-color-module-accent)}.collapsed .side-nav__top{flex-direction:column}.collapsed .home{width:var(--kbx-control-md);flex:none;justify-content:center;padding:0}.collapsed .collapse{width:var(--kbx-control-md)}.collapsed-actions{display:flex;flex-direction:column;align-items:center;gap:var(--kbx-space-2);padding-top:var(--kbx-space-2);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}.collapsed-actions button{width:var(--kbx-control-md);height:var(--kbx-control-md);border:0;border-radius:var(--kbx-radius-sm);background:transparent;font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted)}.collapsed-actions button:hover,.collapsed-actions button.active{background:var(--kbx-color-module-accent-surface);color:var(--kbx-color-module-accent)}
|
||||
@media(max-width:56rem){.side-nav:not(.collapsed){width:var(--kbx-side-nav-collapsed-width);min-width:var(--kbx-side-nav-collapsed-width);overflow:hidden;padding-inline:var(--kbx-space-2)}.side-nav:not(.collapsed) .side-nav__top{flex-direction:column}.side-nav:not(.collapsed) .home{width:var(--kbx-control-md);flex:none;justify-content:center;padding:0}.side-nav:not(.collapsed) .home span:last-child{display:none}.side-nav:not(.collapsed) .collapse{width:var(--kbx-control-md)}.side-nav:not(.collapsed) .side-nav__search,.side-nav:not(.collapsed) .module-switcher,.side-nav:not(.collapsed) .personal,.side-nav:not(.collapsed) .module-block{display:none}}
|
||||
</style>
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open:boolean
|
||||
title?:string
|
||||
canSave?:boolean
|
||||
saving?:boolean
|
||||
}>(), {
|
||||
title:'저장하지 않은 변경사항이 있습니다.',
|
||||
canSave:true,
|
||||
saving:false,
|
||||
})
|
||||
const emit = defineEmits<{ stay:[]; discard:[]; save:[] }>()
|
||||
|
||||
const dialog = ref<HTMLElement | null>(null)
|
||||
let restoreFocus:HTMLElement | null = null
|
||||
|
||||
function focusables() {
|
||||
if (!dialog.value) return []
|
||||
return Array.from(dialog.value.querySelectorAll<HTMLElement>('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'))
|
||||
.filter(element => element.offsetParent !== null)
|
||||
}
|
||||
|
||||
function onKeydown(event:KeyboardEvent) {
|
||||
if (event.key === 'Escape' && !props.saving) {
|
||||
event.preventDefault()
|
||||
emit('stay')
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab') return
|
||||
const nodes = focusables()
|
||||
if (!nodes.length) return
|
||||
const first = nodes[0]
|
||||
const last = nodes[nodes.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.open, async (open, previous) => {
|
||||
if (open) {
|
||||
restoreFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
await nextTick()
|
||||
focusables()[0]?.focus()
|
||||
} else if (previous) {
|
||||
await nextTick()
|
||||
restoreFocus?.focus()
|
||||
restoreFocus = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="props.open" class="overlay" @mousedown.self="!props.saving && emit('stay')">
|
||||
<section
|
||||
ref="dialog"
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="unsaved-title"
|
||||
aria-describedby="unsaved-detail"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<h2 id="unsaved-title">{{props.title}}</h2>
|
||||
<p id="unsaved-detail">변경사항을 어떻게 처리할지 선택하세요.</p>
|
||||
<div class="actions">
|
||||
<button type="button" :disabled="props.saving" @click="emit('stay')">계속 편집</button>
|
||||
<button type="button" class="danger" :disabled="props.saving" @click="emit('discard')">변경 버리기</button>
|
||||
<button v-if="props.canSave" type="button" class="primary" :disabled="props.saving" @click="emit('save')">{{props.saving?'저장 중...':'저장 후 이동'}}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overlay{position:fixed;inset:0;z-index:220;display:flex;align-items:center;justify-content:center;background:var(--kbx-color-overlay)}
|
||||
.dialog{width:min(440px,calc(100vw - 24px));padding:18px;border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-md);background:var(--kbx-color-surface);box-shadow:var(--kbx-shadow-overlay)}
|
||||
h2{font-size:var(--kbx-font-lg);margin:0 0 8px}p{color:var(--kbx-color-text-muted)}
|
||||
.actions{display:flex;justify-content:flex-end;gap:6px;margin-top:18px}.actions button{height:34px;padding:0 12px;border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface)}
|
||||
.actions .primary{background:var(--kbx-color-primary);border-color:var(--kbx-color-primary);color:white}.actions .danger{color:var(--kbx-color-danger)}
|
||||
</style>
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import type { KbxWorkspaceTab } from '@kbx/contracts'
|
||||
const props=withDefaults(defineProps<{tabs:KbxWorkspaceTab[];activeKey?:string|null;maxTabs?:number}>(),{activeKey:null,maxTabs:10})
|
||||
const emit=defineEmits<{select:[tab:KbxWorkspaceTab];closeRequest:[tab:KbxWorkspaceTab];togglePin:[tab:KbxWorkspaceTab]}>()
|
||||
const tabButtons=ref<Record<string,HTMLButtonElement|null>>({})
|
||||
const visibleTabs=computed(()=>{if(props.tabs.length<=props.maxTabs)return props.tabs;const first=props.tabs.slice(0,props.maxTabs);const active=props.tabs.find(x=>x.key===props.activeKey);if(active&&!first.some(x=>x.key===active.key))first[first.length-1]=active;return first})
|
||||
const overflowTabs=computed(()=>props.tabs.filter(tab=>!visibleTabs.value.some(x=>x.key===tab.key)))
|
||||
const focusableKey=computed(()=>props.activeKey??visibleTabs.value[0]?.key??null)
|
||||
async function selectAndFocus(tab:KbxWorkspaceTab){emit('select',tab);await nextTick();tabButtons.value[tab.key]?.focus()}
|
||||
function move(tab:KbxWorkspaceTab,direction:'prev'|'next'|'first'|'last'){const list=visibleTabs.value;const index=list.findIndex(x=>x.key===tab.key);if(index<0)return;const target=direction==='first'?list[0]:direction==='last'?list.at(-1):list[(index+(direction==='next'?1:-1)+list.length)%list.length];if(target)void selectAndFocus(target)}
|
||||
function keydown(event:KeyboardEvent,tab:KbxWorkspaceTab){if(event.key==='ArrowLeft'){event.preventDefault();move(tab,'prev')}else if(event.key==='ArrowRight'){event.preventDefault();move(tab,'next')}else if(event.key==='Home'){event.preventDefault();move(tab,'first')}else if(event.key==='End'){event.preventDefault();move(tab,'last')}}
|
||||
function setTabButton(key:string,el:unknown){tabButtons.value[key]=el instanceof HTMLButtonElement?el:null}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="workspace-tabs" role="tablist" aria-label="열린 업무">
|
||||
<div v-if="props.tabs.length===0" class="empty">열린 업무가 없습니다.</div>
|
||||
<div v-for="tab in visibleTabs" :key="tab.key" class="tab-shell" :class="{active:tab.key===props.activeKey}">
|
||||
<button :ref="el=>setTabButton(tab.key,el)" type="button" class="tab" role="tab" :aria-selected="tab.key===props.activeKey" :tabindex="tab.key===focusableKey?0:-1" @click="emit('select',tab)" @keydown="keydown($event,tab)" @auxclick.middle.prevent="emit('closeRequest',tab)"><span v-if="tab.pinned" class="pin-mark" aria-label="고정됨">◆</span><span v-if="tab.resumed&&!tab.dirty" class="resume-mark" title="이전 세션에서 복원된 업무 위치" aria-label="이전 세션에서 복원됨">↺</span><span class="title">{{tab.title}}</span><span v-if="tab.dirty" class="dirty" title="저장하지 않은 변경" aria-label="저장하지 않은 변경">●</span></button>
|
||||
<button type="button" class="pin" :aria-pressed="Boolean(tab.pinned)" :aria-label="tab.pinned?`${tab.title} 고정 해제`:`${tab.title} 고정`" @click="emit('togglePin',tab)">{{tab.pinned?'◆':'◇'}}</button>
|
||||
<button type="button" class="close" :aria-label="`${tab.title} 닫기`" @click="emit('closeRequest',tab)">×</button>
|
||||
</div>
|
||||
<details v-if="overflowTabs.length" class="overflow"><summary>더보기 {{ overflowTabs.length }}</summary><div class="overflow-menu"><button v-for="tab in overflowTabs" :key="tab.key" type="button" @click="emit('select',tab)"><span>{{tab.title}}</span><small v-if="tab.pinned">고정</small><small v-if="tab.resumed&&!tab.dirty">복원</small><small v-if="tab.dirty">미저장</small></button></div></details>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-tabs{height:var(--kbx-workspace-tabs-height);display:flex;align-items:flex-end;gap:var(--kbx-border-width);overflow-x:auto;border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);padding:0 var(--kbx-space-2)}.tab-shell{height:var(--kbx-workspace-tab-height);max-width:var(--kbx-workspace-tab-max-width);display:flex;align-items:center;border:var(--kbx-border-width) solid transparent;border-bottom:0;background:transparent}.tab-shell.active{background:var(--kbx-color-surface);border-color:var(--kbx-color-border);box-shadow:inset 0 calc(var(--kbx-border-width) * 2) 0 var(--kbx-color-module-accent)}.tab{height:100%;min-width:0;display:flex;align-items:center;gap:var(--kbx-space-2);border:0;background:transparent;padding:0 var(--kbx-space-2);color:var(--kbx-color-text-muted);white-space:nowrap}.tab-shell.active .tab{color:var(--kbx-color-text);font-weight:500}.title{overflow:hidden;text-overflow:ellipsis}.dirty{font-size:var(--kbx-font-xs);color:var(--kbx-color-warning)}.pin-mark{font-size:var(--kbx-font-xs);color:var(--kbx-color-module-accent)}.resume-mark{font-size:var(--kbx-font-sm);color:var(--kbx-color-info);font-weight:600}.pin,.close{width:var(--kbx-control-xs);height:100%;border:0;background:transparent;color:var(--kbx-color-text-muted)}.pin{font-size:var(--kbx-font-xs)}.close{font-size:var(--kbx-font-xl)}.pin:hover,.close:hover,.pin:focus-visible,.close:focus-visible{background:var(--kbx-color-surface-hover)}.pin[aria-pressed="true"]{color:var(--kbx-color-module-accent)}.empty{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted);padding:0 var(--kbx-space-2) var(--kbx-space-2);margin:auto}.overflow{position:relative;margin-left:auto;padding-bottom:var(--kbx-space-1)}.overflow summary{list-style:none;cursor:pointer;font-size:var(--kbx-font-xs);padding:var(--kbx-space-2)}.overflow-menu{position:absolute;right:0;top:var(--kbx-control-height);z-index:80;min-width:var(--kbx-workspace-overflow-width);padding:var(--kbx-space-1);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);box-shadow:var(--kbx-shadow-overlay)}.overflow-menu button{width:100%;min-height:var(--kbx-control-xs);display:flex;align-items:center;gap:var(--kbx-space-2);border:0;background:transparent;text-align:left;padding:0 var(--kbx-space-2)}.overflow-menu button span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.overflow-menu button small{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.overflow-menu button:hover{background:var(--kbx-color-surface-hover)}
|
||||
</style>
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import type { KbxHomeAttentionItem, KbxHomeAttentionQueue, KbxHomeLaunchItem, KbxNavigationResolvedEntry, KbxOperationRun, KbxRecentNavigation, KbxUserNotification, KbxWorkspaceTab } from '@kbx/contracts'
|
||||
|
||||
const sourceLabel:Record<KbxHomeLaunchItem['source'],string>={dirty:'미저장',pinned:'고정',open:'열림',favorite:'즐겨찾기',recent:'최근',priority:'권장'}
|
||||
|
||||
function routeInstanceLabel(path:string, canonicalPath:string){
|
||||
if(path===canonicalPath)return undefined
|
||||
try{
|
||||
const pathname=new URL(path,'https://kbx.local').pathname
|
||||
const canonical=new URL(canonicalPath,'https://kbx.local').pathname
|
||||
const parts=pathname.split('/').filter(Boolean)
|
||||
const tail=parts.at(-1)??''
|
||||
const actionSegments=new Set(['edit','view','detail','modify'])
|
||||
const suffix=pathname.startsWith(canonical.endsWith('/')?canonical:`${canonical}/`)?pathname.slice(canonical.length).replace(/^\//,''):(actionSegments.has(tail.toLocaleLowerCase())?(parts.at(-2)??tail):tail)
|
||||
if(!suffix)return '개별 업무'
|
||||
const value=decodeURIComponent(suffix).replace(/[\r\n\t]/g,' ').trim()
|
||||
return value.length>32?`${value.slice(0,29)}…`:value
|
||||
}catch{return '개별 업무'}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one Home workbench without duplicating catalog-level entries.
|
||||
*
|
||||
* Open workspace instances are intentionally NOT deduplicated by ScreenId: two different order/detail
|
||||
* routes are two different pieces of work. Favorites/recents/priorities are then suppressed when that
|
||||
* screen already has an open instance, avoiding the old "same screen repeated in several card groups" problem.
|
||||
*/
|
||||
export function buildKbxHomeWorkbench(
|
||||
entries:KbxNavigationResolvedEntry[],
|
||||
favorites:KbxNavigationResolvedEntry[],
|
||||
recents:KbxRecentNavigation[],
|
||||
tabs:KbxWorkspaceTab[],
|
||||
limit=10,
|
||||
):KbxHomeLaunchItem[]{
|
||||
const byId=new Map(entries.filter(entry=>entry.menuVisible!==false).map(entry=>[entry.screenId,entry]))
|
||||
const ordered:KbxHomeLaunchItem[]=[]
|
||||
const seenLaunchKeys=new Set<string>()
|
||||
const coveredScreenIds=new Set<string>()
|
||||
|
||||
const addTab=(tab:KbxWorkspaceTab,source:'dirty'|'pinned'|'open')=>{
|
||||
const entry=byId.get(tab.screenId)
|
||||
if(!entry||seenLaunchKeys.has(tab.key))return
|
||||
seenLaunchKeys.add(tab.key)
|
||||
coveredScreenIds.add(entry.screenId)
|
||||
ordered.push({
|
||||
launchKey:tab.key,
|
||||
screenId:entry.screenId,
|
||||
title:entry.title,
|
||||
module:entry.module,
|
||||
section:entry.section,
|
||||
path:tab.path,
|
||||
source,
|
||||
sourceLabel:tab.resumed&&!tab.dirty?'복원':sourceLabel[source],
|
||||
tabKey:tab.key,
|
||||
instanceLabel:routeInstanceLabel(tab.path,entry.path),
|
||||
homeGroup:entry.homeGroup,
|
||||
})
|
||||
}
|
||||
const addCatalog=(entry:KbxNavigationResolvedEntry|undefined,source:'favorite'|'recent'|'priority',path?:string)=>{
|
||||
if(!entry||entry.menuVisible===false||coveredScreenIds.has(entry.screenId))return
|
||||
const launchKey=`screen:${entry.screenId}`
|
||||
if(seenLaunchKeys.has(launchKey))return
|
||||
seenLaunchKeys.add(launchKey)
|
||||
coveredScreenIds.add(entry.screenId)
|
||||
ordered.push({launchKey,screenId:entry.screenId,title:entry.title,module:entry.module,section:entry.section,path:path??entry.path,source,sourceLabel:sourceLabel[source],homeGroup:entry.homeGroup})
|
||||
}
|
||||
|
||||
;[...tabs].filter(tab=>tab.dirty).sort((a,b)=>b.lastActivatedAt.localeCompare(a.lastActivatedAt)).forEach(tab=>addTab(tab,'dirty'))
|
||||
;[...tabs].filter(tab=>!tab.dirty).sort((a,b)=>(Number(Boolean(b.pinned))-Number(Boolean(a.pinned)))||b.lastActivatedAt.localeCompare(a.lastActivatedAt)).forEach(tab=>addTab(tab,tab.pinned?'pinned':'open'))
|
||||
favorites.forEach(entry=>addCatalog(entry,'favorite'))
|
||||
recents.forEach(recent=>addCatalog(byId.get(recent.screenId),'recent',recent.path))
|
||||
entries.filter(entry=>entry.homePriority!=null).sort((a,b)=>(a.homePriority??999)-(b.homePriority??999)||(a.order??999)-(b.order??999)).forEach(entry=>addCatalog(entry,'priority'))
|
||||
return ordered.slice(0,Math.max(1,limit))
|
||||
}
|
||||
|
||||
/** Backward-compatible entry-only view for callers that do not need launch-source metadata. */
|
||||
export function buildKbxHomeQuickStart(entries:KbxNavigationResolvedEntry[],favorites:KbxNavigationResolvedEntry[],recents:KbxRecentNavigation[],tabs:KbxWorkspaceTab[],limit=8){
|
||||
const byId=new Map(entries.map(entry=>[entry.screenId,entry]))
|
||||
return buildKbxHomeWorkbench(entries,favorites,recents,tabs,limit).map(item=>byId.get(item.screenId)).filter((entry):entry is KbxNavigationResolvedEntry=>Boolean(entry))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build the action-oriented Home attention queue.
|
||||
* Runtime entities are display inputs only; callers must revalidate any route before navigation.
|
||||
* Priority is business-defined, while items within one priority are newest-first for predictable recovery.
|
||||
*/
|
||||
export function buildKbxHomeAttentionQueue(
|
||||
entries:KbxNavigationResolvedEntry[],
|
||||
tabs:KbxWorkspaceTab[],
|
||||
operations:KbxOperationRun[],
|
||||
notifications:KbxUserNotification[],
|
||||
limit=8,
|
||||
):KbxHomeAttentionQueue{
|
||||
const allowedIds=new Set(entries.filter(entry=>entry.menuVisible!==false).map(entry=>entry.screenId))
|
||||
const all:KbxHomeAttentionItem[]=[]
|
||||
for(const tab of tabs.filter(tab=>tab.dirty)){
|
||||
if(!allowedIds.has(tab.screenId))continue
|
||||
all.push({key:`dirty:${tab.key}`,source:'dirty',priority:10,occurredAt:tab.lastActivatedAt,title:tab.title,detail:'저장하지 않은 변경사항이 있습니다.',actionLabel:'계속 편집',screenId:tab.screenId,path:tab.path,tabKey:tab.key})
|
||||
}
|
||||
for(const operation of operations){
|
||||
if(operation.sourceScreenId&&!allowedIds.has(operation.sourceScreenId))continue
|
||||
const occurredAt=operation.completedAt??operation.startedAt??operation.requestedAt
|
||||
if(operation.status==='failed'||operation.status==='partially-completed')all.push({key:`operation:${operation.id}`,source:'operation-failed',priority:20,occurredAt,title:operation.title,detail:operation.resultMessage??(operation.status==='failed'?'작업이 실패했습니다.':'일부 항목이 처리되지 않았습니다.'),actionLabel:'작업 보기',screenId:operation.sourceScreenId,operationId:operation.id})
|
||||
else if(operation.status==='running'||operation.status==='queued')all.push({key:`operation:${operation.id}`,source:'operation-running',priority:40,occurredAt,title:operation.title,detail:operation.total?`${operation.processed??0} / ${operation.total} 처리`:'처리 중입니다.',actionLabel:'진행 보기',screenId:operation.sourceScreenId,operationId:operation.id})
|
||||
}
|
||||
for(const notification of notifications.filter(item=>!item.readAt)){
|
||||
if(notification.screenId&&!allowedIds.has(notification.screenId))continue
|
||||
const urgent=notification.severity==='warning'||notification.severity==='error'
|
||||
all.push({key:`notification:${notification.id}`,source:urgent?'notification-urgent':'notification',priority:urgent?30:50,occurredAt:notification.createdAt,title:notification.title,detail:notification.message,actionLabel:notification.action?.label??'알림 보기',screenId:notification.screenId,path:notification.action?.route,notificationId:notification.id})
|
||||
}
|
||||
all.sort((a,b)=>a.priority-b.priority||b.occurredAt.localeCompare(a.occurredAt)||a.key.localeCompare(b.key))
|
||||
const cap=Math.max(1,limit)
|
||||
const sourceCounts:KbxHomeAttentionQueue['sourceCounts']={}
|
||||
for(const item of all)sourceCounts[item.source]=(sourceCounts[item.source]??0)+1
|
||||
return {items:all.slice(0,cap),totalCount:all.length,overflowCount:Math.max(all.length-cap,0),sourceCounts}
|
||||
}
|
||||
|
||||
/** Backward-compatible item-only view. Prefer buildKbxHomeAttentionQueue for exact counts/overflow. */
|
||||
export function buildKbxHomeAttention(
|
||||
entries:KbxNavigationResolvedEntry[],
|
||||
tabs:KbxWorkspaceTab[],
|
||||
operations:KbxOperationRun[],
|
||||
notifications:KbxUserNotification[],
|
||||
limit=8,
|
||||
):KbxHomeAttentionItem[]{
|
||||
return buildKbxHomeAttentionQueue(entries,tabs,operations,notifications,limit).items
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import type { KbxMenuSearchResult, KbxNavigationResolvedEntry } from '@kbx/contracts'
|
||||
function normalize(value:string){return value.toLocaleLowerCase().replace(/\s+/g,'')}
|
||||
export function searchNavigationEntries(entries:KbxNavigationResolvedEntry[], query:string, limit=12, priorityScreenIds:string[]=[]):KbxMenuSearchResult[]{
|
||||
const q=normalize(query);const priority=new Map(priorityScreenIds.map((id,index)=>[id,index]))
|
||||
return entries.map(entry=>{
|
||||
const values=[entry.title,entry.screenId,entry.module,entry.section,...(entry.keywords??[])].map(normalize)
|
||||
let score=q?0:(priority.has(entry.screenId)?1000-(priority.get(entry.screenId)??0):1)
|
||||
for(const value of values){if(!q)continue;if(value===q)score=Math.max(score,100);else if(value.startsWith(q))score=Math.max(score,70);else if(value.includes(q))score=Math.max(score,40)}
|
||||
if(q&&priority.has(entry.screenId)&&score>0)score+=5
|
||||
return {screenId:entry.screenId,title:entry.title,module:entry.module,section:entry.section,path:entry.path,keywords:entry.keywords??[],score}
|
||||
}).filter(x=>x.score>0).sort((a,b)=>b.score-a.score||a.title.localeCompare(b.title)).slice(0,limit)
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import type { KbxNavigationResolvedEntry } from '@kbx/contracts'
|
||||
|
||||
const MAX_WORKSPACE_URL_LENGTH=2048
|
||||
function normalizePathname(value:string){const clean=value.replace(/\/+$/,'')||'/';return clean.startsWith('/')?clean:`/${clean}`}
|
||||
function hasUnsafeEncodedPath(value:string){return value.length>MAX_WORKSPACE_URL_LENGTH || /[\u0000-\u001f\u007f]|\\|%2f|%5c|%00|%0d|%0a|%09|%2e%2e/i.test(value)}
|
||||
|
||||
/** Match a Vue-Router-style path pattern without trusting persisted route strings. */
|
||||
export function matchKbxRoutePattern(pattern:string,candidatePath:string){
|
||||
if(hasUnsafeEncodedPath(candidatePath))return false
|
||||
const p=normalizePathname(pattern).split('/').filter(Boolean)
|
||||
const c=normalizePathname(candidatePath).split('/').filter(Boolean)
|
||||
if(p.length!==c.length)return false
|
||||
return p.every((segment,index)=>segment.startsWith(':') ? Boolean(c[index]) : segment===c[index])
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolve an in-memory workspace path against the screen's declared route capabilities.
|
||||
* Query/hash are retained only for the current in-memory workspace after the pathname passes the capability check.
|
||||
*/
|
||||
export function resolveKbxSafeWorkspacePath(patterns:readonly string[],candidatePath:string,fallbackPath:string){
|
||||
try{
|
||||
if(hasUnsafeEncodedPath(candidatePath))return fallbackPath
|
||||
const candidate=new URL(candidatePath,'https://kbx.local')
|
||||
if(candidate.origin!=='https://kbx.local'||candidate.username||candidate.password)return fallbackPath
|
||||
const allowed=patterns.some(pattern=>matchKbxRoutePattern(pattern,candidate.pathname))
|
||||
return allowed?`${candidate.pathname}${candidate.search}${candidate.hash}`:fallbackPath
|
||||
}catch{return fallbackPath}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolve untrusted persisted recent navigation against the current authorized catalog entry.
|
||||
* Query/hash are deliberately stripped before persistence/replay so transient filters, tokens, and sensitive
|
||||
* values do not leak into local preference storage. Dynamic route path persistence remains explicit opt-in.
|
||||
*/
|
||||
export function resolveKbxSafeRecentPath(entry:KbxNavigationResolvedEntry, storedPath:string){
|
||||
if(entry.recentPolicy!=='route')return entry.path
|
||||
try{
|
||||
if(hasUnsafeEncodedPath(storedPath))return entry.path
|
||||
const base=new URL(entry.path,'https://kbx.local')
|
||||
const candidate=new URL(storedPath,'https://kbx.local')
|
||||
if(candidate.origin!==base.origin||candidate.username||candidate.password)return entry.path
|
||||
const basePath=base.pathname.replace(/\/$/,'')||'/'
|
||||
const candidatePath=candidate.pathname.replace(/\/$/,'')||'/'
|
||||
const within=candidatePath===basePath || (basePath!=='/' && candidatePath.startsWith(`${basePath}/`))
|
||||
return within?candidate.pathname:entry.path
|
||||
}catch{return entry.path}
|
||||
}
|
||||
Reference in New Issue
Block a user