feat(shell): fluid layout with collapsible sidebar drawer + fix query-tool MultiSelect

Convert portal shell from block-centered (max-width 1600px) layout to full-viewport
fluid flexbox with collapsible sidebar: desktop push-mode (240px → 0), mobile overlay
drawer with backdrop. Rename .content → .shell-content to avoid CSS collision with
page-level classes. Override page-level max-width constraints when embedded in shell.

Also replace native <select multiple> in query-tool with shared MultiSelect component
for equipment and workcenter group filters, matching resource-status/history UX.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-11 18:04:55 +08:00
parent 1e7f8f4498
commit 35d83d424c
17 changed files with 826 additions and 53 deletions

View File

@@ -1,10 +1,17 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import HealthStatus from './components/HealthStatus.vue';
import { consumeNavigationNotice, syncNavigationRoutes } from './router.js';
import { normalizeRoutePath } from './routeContracts.js';
import {
SIDEBAR_STORAGE_KEY,
buildSidebarUiState,
isMobileViewport,
parseSidebarCollapsedPreference,
serializeSidebarCollapsedPreference,
} from './sidebarState.js';
const route = useRoute();
const router = useRouter();
@@ -20,6 +27,9 @@ const adminLinks = ref({
pages: '/admin/pages',
performance: '/admin/performance',
});
const sidebarCollapsed = ref(false);
const sidebarMobileOpen = ref(false);
const isMobile = ref(false);
function toShellPath(targetRoute) {
return normalizeRoutePath(targetRoute);
@@ -46,6 +56,71 @@ const adminLoginHref = computed(() => {
return `/admin/login?next=${encodeURIComponent('/portal-shell')}`;
});
const sidebarUiState = computed(() =>
buildSidebarUiState({
isMobile: isMobile.value,
sidebarCollapsed: sidebarCollapsed.value,
sidebarMobileOpen: sidebarMobileOpen.value,
}),
);
const sidebarToggleLabel = computed(() => {
if (isMobile.value) {
return sidebarMobileOpen.value ? '關閉側邊欄' : '開啟側邊欄';
}
return sidebarCollapsed.value ? '展開側邊欄' : '收合側邊欄';
});
function restoreSidebarPreference() {
try {
const stored = window.sessionStorage.getItem(SIDEBAR_STORAGE_KEY);
sidebarCollapsed.value = parseSidebarCollapsedPreference(stored);
} catch {
sidebarCollapsed.value = false;
}
}
function persistSidebarPreference() {
try {
window.sessionStorage.setItem(
SIDEBAR_STORAGE_KEY,
serializeSidebarCollapsedPreference(sidebarCollapsed.value),
);
} catch {
// Keep UI behavior deterministic even if storage is unavailable.
}
}
function checkViewport() {
isMobile.value = isMobileViewport(window.innerWidth);
if (!isMobile.value) {
sidebarMobileOpen.value = false;
}
}
function closeMobileSidebar() {
sidebarMobileOpen.value = false;
}
function toggleSidebar() {
if (isMobile.value) {
sidebarMobileOpen.value = !sidebarMobileOpen.value;
return;
}
sidebarCollapsed.value = !sidebarCollapsed.value;
persistSidebarPreference();
}
function handleViewportResize() {
checkViewport();
}
function handleGlobalKeydown(event) {
if (event.key === 'Escape') {
closeMobileSidebar();
}
}
async function loadNavigation() {
loading.value = true;
errorMessage.value = '';
@@ -111,12 +186,22 @@ async function loadNavigation() {
}
onMounted(() => {
restoreSidebarPreference();
checkViewport();
window.addEventListener('resize', handleViewportResize, { passive: true });
window.addEventListener('keydown', handleGlobalKeydown);
void loadNavigation();
});
onUnmounted(() => {
window.removeEventListener('resize', handleViewportResize);
window.removeEventListener('keydown', handleGlobalKeydown);
});
watch(
() => route.fullPath,
() => {
closeMobileSidebar();
navigationNotice.value = consumeNavigationNotice();
},
{ immediate: true },
@@ -124,9 +209,27 @@ watch(
</script>
<template>
<div class="shell">
<div class="shell" :class="sidebarUiState.shellClass">
<header class="shell-header">
<div>
<div class="shell-header-left">
<button
type="button"
class="sidebar-toggle"
:aria-expanded="sidebarUiState.ariaExpanded"
:aria-label="sidebarToggleLabel"
@click="toggleSidebar"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M4 6h16M4 12h16M4 18h16"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
<h1>MES 報表入口</h1>
</div>
<div class="shell-header-right">
@@ -144,8 +247,12 @@ watch(
</div>
</header>
<Transition name="overlay-fade">
<div v-if="isMobile && sidebarMobileOpen" class="sidebar-overlay" @click="closeMobileSidebar" />
</Transition>
<main class="shell-main">
<aside class="sidebar">
<aside class="sidebar" :class="sidebarUiState.sidebarClass">
<div v-if="loading" class="muted">載入導覽中...</div>
<div v-else-if="errorMessage" class="error">{{ errorMessage }}</div>
<template v-else>
@@ -164,7 +271,7 @@ watch(
</template>
</aside>
<section class="content">
<section class="shell-content">
<div v-if="navigationNotice" class="notice-banner">{{ navigationNotice }}</div>
<div class="breadcrumb">
<span v-if="breadcrumb.drawerName">{{ breadcrumb.drawerName }}</span>

View File

@@ -56,7 +56,7 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
),
'/query-tool': createNativeLoader(
() => import('../query-tool/App.vue'),
[() => import('../query-tool/style.css')],
[() => import('../resource-shared/styles.css'), () => import('../query-tool/style.css')],
),
'/tmtt-defect': createNativeLoader(
() => import('../tmtt-defect/App.vue'),

View File

@@ -0,0 +1,34 @@
export const SIDEBAR_STORAGE_KEY = 'portal-shell:sidebar-collapsed';
export const MOBILE_BREAKPOINT = 900;
export function isMobileViewport(width, breakpoint = MOBILE_BREAKPOINT) {
return Number(width) <= breakpoint;
}
export function parseSidebarCollapsedPreference(value) {
return String(value).trim() === 'true';
}
export function serializeSidebarCollapsedPreference(collapsed) {
return collapsed ? 'true' : 'false';
}
export function buildSidebarUiState({ isMobile, sidebarCollapsed, sidebarMobileOpen }) {
const mobile = Boolean(isMobile);
const desktopCollapsed = !mobile && Boolean(sidebarCollapsed);
const mobileOpen = mobile && Boolean(sidebarMobileOpen);
const sidebarVisible = mobile ? mobileOpen : !desktopCollapsed;
return {
shellClass: {
'sidebar-collapsed': desktopCollapsed,
},
sidebarClass: {
'sidebar--collapsed': desktopCollapsed,
'sidebar--mobile-open': mobileOpen,
'sidebar--mobile-closed': mobile && !mobileOpen,
},
sidebarVisible,
ariaExpanded: sidebarVisible ? 'true' : 'false',
};
}

View File

@@ -15,9 +15,8 @@ body {
.shell {
min-height: 100vh;
padding: 20px;
max-width: 1600px;
margin: 0 auto;
display: flex;
flex-direction: column;
}
.shell-header {
@@ -27,8 +26,15 @@ body {
gap: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border-radius: 12px;
padding: 20px;
padding: 12px 20px;
min-height: var(--shell-header-height, 56px);
flex-shrink: 0;
}
.shell-header-left {
display: inline-flex;
align-items: center;
gap: 12px;
}
.shell-header h1 {
@@ -47,6 +53,39 @@ body {
gap: 12px;
}
.sidebar-toggle {
width: 36px;
height: 36px;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 8px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
}
.sidebar-toggle:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.55);
}
.sidebar-toggle:active {
transform: translateY(1px);
}
.sidebar-toggle:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
.sidebar-toggle svg {
width: 18px;
height: 18px;
}
.admin-entry {
display: inline-flex;
align-items: center;
@@ -73,20 +112,77 @@ body {
}
.shell-main {
margin-top: 14px;
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 12px;
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.sidebar {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
width: var(--portal-sidebar-width, 240px);
min-width: var(--portal-sidebar-width, 240px);
border-right: 1px solid #e5e7eb;
padding: 10px;
height: fit-content;
position: sticky;
top: 20px;
overflow-y: auto;
overflow-x: hidden;
white-space: nowrap;
flex-shrink: 0;
transition:
width 0.3s ease,
min-width 0.3s ease,
padding 0.3s ease,
transform 0.3s ease,
box-shadow 0.3s ease;
}
.sidebar--collapsed {
width: 0;
min-width: 0;
padding: 0;
border-right: none;
overflow: hidden;
}
.sidebar--collapsed * {
visibility: hidden;
}
.sidebar--mobile-open,
.sidebar--mobile-closed {
position: fixed;
top: var(--shell-header-height, 56px);
left: 0;
bottom: 0;
width: min(280px, calc(100vw - 40px));
min-width: min(280px, calc(100vw - 40px));
z-index: 40;
box-shadow: 8px 0 24px rgba(15, 23, 42, 0.22);
}
.sidebar--mobile-open {
transform: translateX(0);
}
.sidebar--mobile-closed {
transform: translateX(-100%);
}
.sidebar-overlay {
position: fixed;
inset: 0;
z-index: 35;
background: rgba(15, 23, 42, 0.4);
}
.overlay-fade-enter-active,
.overlay-fade-leave-active {
transition: opacity 0.3s ease;
}
.overlay-fade-enter-from,
.overlay-fade-leave-to {
opacity: 0;
}
.drawer + .drawer {
@@ -123,12 +219,33 @@ body {
font-weight: 600;
}
.content {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
.shell-content {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
background: #f5f7fa;
padding: 16px;
min-height: 70vh;
}
.shell-content .resource-page,
.shell-content .dashboard,
.shell-content .qc-gate-page,
.shell-content .job-query-page,
.shell-content .excel-query-page,
.shell-content .query-tool-page,
.shell-content .tmtt-page,
.shell-content .tables-page {
max-width: none;
min-height: auto;
}
.shell-content .tables-page .container {
max-width: none;
}
.shell-content .resource-page {
padding: 0;
}
.breadcrumb {
@@ -282,7 +399,7 @@ body {
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.24);
border: 1px solid #e2e8f0;
padding: 14px;
z-index: 30;
z-index: 50;
}
.health-popup h4 {
@@ -363,25 +480,29 @@ body {
@media (prefers-reduced-motion: reduce) {
.drawer-link,
.route-fade-enter-active,
.route-fade-leave-active {
.route-fade-leave-active,
.sidebar,
.sidebar--mobile-open,
.sidebar--mobile-closed,
.sidebar-overlay,
.overlay-fade-enter-active,
.overlay-fade-leave-active,
.sidebar-toggle {
transition: none !important;
}
.dot.loading {
animation: none !important;
}
}
@media (max-width: 900px) {
.shell-main {
grid-template-columns: 1fr;
}
.shell-header {
flex-direction: column;
align-items: flex-start;
padding: 12px 16px;
}
.shell-header-right {
width: 100%;
flex-direction: column;
align-items: flex-start;
margin-left: auto;
}
.health-trigger {
@@ -389,7 +510,7 @@ body {
justify-content: space-between;
}
.sidebar {
position: static;
.shell-content {
padding: 12px;
}
}

View File

@@ -1,6 +1,7 @@
<script setup>
import { onMounted } from 'vue';
import { computed, onMounted } from 'vue';
import MultiSelect from '../resource-shared/components/MultiSelect.vue';
import FilterToolbar from '../shared-ui/components/FilterToolbar.vue';
import SectionCard from '../shared-ui/components/SectionCard.vue';
import { useQueryToolData } from './composables/useQueryToolData.js';
@@ -25,6 +26,20 @@ const {
exportCurrentCsv,
} = useQueryToolData();
const equipmentOptions = computed(() =>
equipment.options.map((item) => ({
value: item.RESOURCEID,
label: item.RESOURCENAME || item.RESOURCEID,
})),
);
const workcenterGroupOptions = computed(() =>
batch.workcenterGroups.map((group) => ({
value: group.name || group,
label: group.name || group,
})),
);
function formatCell(value) {
if (value === null || value === undefined || value === '') {
return '-';
@@ -65,11 +80,14 @@ onMounted(async () => {
</label>
<label class="query-tool-filter">
<span>站點群組</span>
<select v-model="batch.selectedWorkcenterGroups" multiple size="3">
<option v-for="group in batch.workcenterGroups" :key="group.name || group" :value="group.name || group">
{{ group.name || group }}
</option>
</select>
<MultiSelect
:model-value="batch.selectedWorkcenterGroups"
:options="workcenterGroupOptions"
:disabled="loading.bootstrapping"
placeholder="全部群組"
searchable
@update:model-value="batch.selectedWorkcenterGroups = $event"
/>
</label>
<template #actions>
<button type="button" class="query-tool-btn query-tool-btn-primary" :disabled="loading.resolving" @click="resolveLots">
@@ -178,11 +196,14 @@ onMounted(async () => {
<FilterToolbar>
<label class="query-tool-filter">
<span>設備複選</span>
<select v-model="equipment.selectedEquipmentIds" multiple size="4">
<option v-for="item in equipment.options" :key="item.RESOURCEID" :value="item.RESOURCEID">
{{ item.RESOURCENAME || item.RESOURCEID }}
</option>
</select>
<MultiSelect
:model-value="equipment.selectedEquipmentIds"
:options="equipmentOptions"
:disabled="loading.bootstrapping || loading.equipment"
placeholder="全部設備"
searchable
@update:model-value="equipment.selectedEquipmentIds = $event"
/>
</label>
<label class="query-tool-filter">
<span>查詢類型</span>

View File

@@ -4,9 +4,11 @@
@layer base {
:root {
--portal-shell-max-width: 1600px;
--portal-shell-max-width: none;
--portal-shell-gap: 12px;
--portal-shell-bg: #f5f7fa;
--portal-sidebar-width: 240px;
--shell-header-height: 56px;
--portal-panel-bg: #ffffff;
--portal-text-primary: #1f2937;
--portal-text-secondary: #64748b;
@@ -61,7 +63,7 @@
}
.u-content-shell {
max-width: var(--portal-shell-max-width);
width: 100%;
margin-left: auto;
margin-right: auto;
}

View File

@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
SIDEBAR_STORAGE_KEY,
buildSidebarUiState,
parseSidebarCollapsedPreference,
serializeSidebarCollapsedPreference,
} from '../src/portal-shell/sidebarState.js';
function readSource(relativePath) {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8');
}
test('buildSidebarUiState marks desktop collapse correctly', () => {
const expanded = buildSidebarUiState({
isMobile: false,
sidebarCollapsed: false,
sidebarMobileOpen: false,
});
assert.equal(expanded.sidebarClass['sidebar--collapsed'], false);
assert.equal(expanded.ariaExpanded, 'true');
const collapsed = buildSidebarUiState({
isMobile: false,
sidebarCollapsed: true,
sidebarMobileOpen: false,
});
assert.equal(collapsed.sidebarClass['sidebar--collapsed'], true);
assert.equal(collapsed.sidebarClass['sidebar--mobile-open'], false);
assert.equal(collapsed.ariaExpanded, 'false');
});
test('buildSidebarUiState marks mobile overlay states correctly', () => {
const closed = buildSidebarUiState({
isMobile: true,
sidebarCollapsed: true,
sidebarMobileOpen: false,
});
assert.equal(closed.sidebarClass['sidebar--mobile-closed'], true);
assert.equal(closed.sidebarClass['sidebar--collapsed'], false);
assert.equal(closed.ariaExpanded, 'false');
const open = buildSidebarUiState({
isMobile: true,
sidebarCollapsed: true,
sidebarMobileOpen: true,
});
assert.equal(open.sidebarClass['sidebar--mobile-open'], true);
assert.equal(open.sidebarClass['sidebar--mobile-closed'], false);
assert.equal(open.ariaExpanded, 'true');
});
test('sidebar collapsed preference serializes and parses as expected', () => {
assert.equal(serializeSidebarCollapsedPreference(true), 'true');
assert.equal(serializeSidebarCollapsedPreference(false), 'false');
assert.equal(parseSidebarCollapsedPreference('true'), true);
assert.equal(parseSidebarCollapsedPreference('false'), false);
assert.equal(parseSidebarCollapsedPreference(null), false);
});
test('portal shell template uses shell-content and overlay close wiring', () => {
const source = readSource('src/portal-shell/App.vue');
assert.match(source, /<section class="shell-content">/);
assert.doesNotMatch(source, /<section class="content">/);
assert.match(source, /class="sidebar-overlay"/);
assert.match(source, /@click="closeMobileSidebar"/);
assert.match(source, /event\.key === 'Escape'/);
assert.match(source, /SIDEBAR_STORAGE_KEY/);
});
test('toggleSidebar keeps desktop collapse and persistence wiring', () => {
const source = readSource('src/portal-shell/App.vue');
assert.match(source, /function toggleSidebar\(\)/);
assert.match(source, /sidebarCollapsed\.value = !sidebarCollapsed\.value/);
assert.match(source, /persistSidebarPreference\(\)/);
});
test('sessionStorage preference restore wiring exists', () => {
const source = readSource('src/portal-shell/App.vue');
assert.match(source, /function restoreSidebarPreference\(\)/);
assert.match(source, /window\.sessionStorage\.getItem\(SIDEBAR_STORAGE_KEY\)/);
});
test('sidebar sessionStorage key remains stable', () => {
assert.equal(SIDEBAR_STORAGE_KEY, 'portal-shell:sidebar-collapsed');
});