task-14(壳层/路由): 实现侧边栏折叠与窄屏行为及偏好持久化
sidebar-collapse 提供阈值/宽高常量、toggle、localStorage 偏好读写(失败容忍)、 initialCollapsed(持久化优先,窄屏自动收起);AdminLayout 接入并持久化折叠状态。
This commit is contained in:
@@ -12,11 +12,27 @@ import {
|
|||||||
shouldShowEmptyMenu,
|
shouldShowEmptyMenu,
|
||||||
} from '@/layout/empty-state'
|
} from '@/layout/empty-state'
|
||||||
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
|
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
|
||||||
|
import { initialCollapsed, toggleCollapsed, writeCollapsePreference } from '@/layout/sidebar-collapse'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const session = useAdminSessionStore()
|
const session = useAdminSessionStore()
|
||||||
const collapsed = ref(false)
|
const collapsed = ref(readInitialCollapse())
|
||||||
|
|
||||||
|
function readInitialCollapse(): boolean {
|
||||||
|
const storage = typeof window !== 'undefined' ? window.localStorage : null
|
||||||
|
const width = typeof window !== 'undefined' ? window.innerWidth : 0
|
||||||
|
return initialCollapsed(width, storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCollapsed(value: boolean): void {
|
||||||
|
collapsed.value = value
|
||||||
|
if (typeof window !== 'undefined') writeCollapsePreference(window.localStorage, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar(): void {
|
||||||
|
setCollapsed(toggleCollapsed(collapsed.value))
|
||||||
|
}
|
||||||
|
|
||||||
const activePath = computed(() => route.path)
|
const activePath = computed(() => route.path)
|
||||||
const menuEntries = computed(() => toSidebarEntries(session.menuTree))
|
const menuEntries = computed(() => toSidebarEntries(session.menuTree))
|
||||||
@@ -65,7 +81,7 @@ async function signOut() {
|
|||||||
<el-empty v-if="emptyMenu" :description="EMPTY_MENU_TITLE" :image-size="72" />
|
<el-empty v-if="emptyMenu" :description="EMPTY_MENU_TITLE" :image-size="72" />
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
|
|
||||||
<button class="sidebar-collapse" type="button" @click="collapsed = !collapsed">
|
<button class="sidebar-collapse" type="button" @click="toggleSidebar">
|
||||||
{{ collapsed ? '展开菜单' : '收起菜单' }}
|
{{ collapsed ? '展开菜单' : '收起菜单' }}
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/** 侧边栏折叠与窄屏布局状态(任务 14):宽高常量、阈值、偏好持久化。 */
|
||||||
|
|
||||||
|
export const SIDEBAR_EXPANDED_WIDTH = 248
|
||||||
|
export const SIDEBAR_COLLAPSED_WIDTH = 64
|
||||||
|
export const SIDEBAR_NARROW_THRESHOLD = 900
|
||||||
|
|
||||||
|
/** 视口窄到需要自动收起侧边栏。 */
|
||||||
|
export function isNarrowScreen(width: number): boolean {
|
||||||
|
return width > 0 && width <= SIDEBAR_NARROW_THRESHOLD
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 反转折叠状态。 */
|
||||||
|
export function toggleCollapsed(current: boolean): boolean {
|
||||||
|
return !current
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollapseStorage = Pick<Storage, 'getItem' | 'setItem'> | null
|
||||||
|
|
||||||
|
const PREFERENCE_KEY = 'admin.sidebar.collapsed'
|
||||||
|
|
||||||
|
function parsePreference(raw: string | null): boolean | null {
|
||||||
|
return raw === 'true' ? true : raw === 'false' ? false : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取持久化偏好;无存储或值非法返回 null。 */
|
||||||
|
export function readCollapsePreference(storage: CollapseStorage): boolean | null {
|
||||||
|
try {
|
||||||
|
return parsePreference(storage?.getItem(PREFERENCE_KEY) ?? null)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入折叠偏好;存储不可用时静默失败(不阻断交互)。 */
|
||||||
|
export function writeCollapsePreference(storage: CollapseStorage, collapsed: boolean): void {
|
||||||
|
try {
|
||||||
|
storage?.setItem(PREFERENCE_KEY, String(collapsed))
|
||||||
|
} catch {
|
||||||
|
// localStorage 不可用(隐私模式等)时忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始折叠值:优先持久化偏好;无偏好时窄屏自动折叠、宽屏展开。
|
||||||
|
* width<=0(如 SSR/未知)视为无法判定,回退 false。
|
||||||
|
*/
|
||||||
|
export function initialCollapsed(width: number, storage: CollapseStorage): boolean {
|
||||||
|
const preference = readCollapsePreference(storage)
|
||||||
|
if (preference !== null) return preference
|
||||||
|
return isNarrowScreen(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 侧边栏应占用的像素宽(用于宽度计算/测试,不直接改 CSS)。 */
|
||||||
|
export function sidebarPixelWidth(collapsed: boolean): number {
|
||||||
|
return collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
import {
|
||||||
|
initialCollapsed,
|
||||||
|
isNarrowScreen,
|
||||||
|
readCollapsePreference,
|
||||||
|
SIDEBAR_COLLAPSED_WIDTH,
|
||||||
|
SIDEBAR_EXPANDED_WIDTH,
|
||||||
|
SIDEBAR_NARROW_THRESHOLD,
|
||||||
|
sidebarPixelWidth,
|
||||||
|
toggleCollapsed,
|
||||||
|
writeCollapsePreference,
|
||||||
|
type CollapseStorage,
|
||||||
|
} from '../src/layout/sidebar-collapse.ts'
|
||||||
|
|
||||||
|
function fakeStorage(entries: Record<string, string>, failing = false): CollapseStorage {
|
||||||
|
return {
|
||||||
|
getItem(key: string) {
|
||||||
|
if (failing) throw new Error('storage down')
|
||||||
|
return key in entries ? entries[key] : null
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
if (failing) throw new Error('storage down')
|
||||||
|
entries[key] = value
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test_task_014_sidebar_responsive_behavior_normal_primary_path', () => {
|
||||||
|
// 正常主路径:折叠开关反转状态。
|
||||||
|
assert.equal(toggleCollapsed(false), true)
|
||||||
|
assert.equal(toggleCollapsed(true), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_014_sidebar_responsive_behavior_normal_variant_input', () => {
|
||||||
|
// 正常变体:不同宽度判定窄屏与宽屏。
|
||||||
|
assert.equal(isNarrowScreen(SIDEBAR_NARROW_THRESHOLD), true)
|
||||||
|
assert.equal(isNarrowScreen(SIDEBAR_NARROW_THRESHOLD + 1), false)
|
||||||
|
assert.equal(isNarrowScreen(1440), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_014_sidebar_responsive_behavior_normal_repeated_operation_is_idempotent', () => {
|
||||||
|
// 正常重复:偏好读写与宽度结果稳定。
|
||||||
|
const storage = fakeStorage({})
|
||||||
|
assert.equal(initialCollapsed(1000, storage), initialCollapsed(1000, storage))
|
||||||
|
writeCollapsePreference(storage, true)
|
||||||
|
assert.equal(readCollapsePreference(storage), true)
|
||||||
|
assert.equal(readCollapsePreference(storage), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_014_sidebar_responsive_behavior_boundary_empty_input', () => {
|
||||||
|
// 边界空值:未知宽度/无存储不崩溃且回退展开。
|
||||||
|
assert.equal(isNarrowScreen(0), false)
|
||||||
|
assert.equal(initialCollapsed(0, null), false)
|
||||||
|
assert.equal(readCollapsePreference(null), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_014_sidebar_responsive_behavior_boundary_single_item', () => {
|
||||||
|
// 边界单元素:单值持久化可读回。
|
||||||
|
const storage = fakeStorage({ 'admin.sidebar.collapsed': 'false' })
|
||||||
|
assert.equal(readCollapsePreference(storage), false)
|
||||||
|
assert.equal(initialCollapsed(1024, storage), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_014_sidebar_responsive_behavior_boundary_limit_or_missing_field', () => {
|
||||||
|
// 边界上限/缺字段:非法持久化值按无偏好处理;像素宽与常量一致。
|
||||||
|
const storage = fakeStorage({ 'admin.sidebar.collapsed': 'maybe' })
|
||||||
|
assert.equal(readCollapsePreference(storage), null)
|
||||||
|
assert.equal(sidebarPixelWidth(true), SIDEBAR_COLLAPSED_WIDTH)
|
||||||
|
assert.equal(sidebarPixelWidth(false), SIDEBAR_EXPANDED_WIDTH)
|
||||||
|
assert.equal(SIDEBAR_COLLAPSED_WIDTH < SIDEBAR_EXPANDED_WIDTH, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_014_sidebar_responsive_behavior_invalid_input_rejected', () => {
|
||||||
|
// 异常输入:存储读写失败被容忍,不阻断交互。
|
||||||
|
const broken = fakeStorage({}, true)
|
||||||
|
assert.equal(readCollapsePreference(broken), null)
|
||||||
|
assert.doesNotThrow(() => writeCollapsePreference(broken, true))
|
||||||
|
const fallback = fakeStorage({ 'admin.sidebar.collapsed': 'true' })
|
||||||
|
assert.equal(initialCollapsed(1440, fallback), true, '持久化优先于视口宽度')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_014_sidebar_responsive_behavior_dependency_failure_returns_actionable_message', () => {
|
||||||
|
// 依赖失败:壳层接入折叠逻辑;CSS 有 is-collapsed 与窄屏媒体适配。
|
||||||
|
const layout = readSource('src/layout/AdminLayout.vue')
|
||||||
|
assert.match(layout, /initialCollapsed/)
|
||||||
|
assert.match(layout, /writeCollapsePreference/)
|
||||||
|
assert.match(layout, /toggleSidebar/)
|
||||||
|
const css = readSource('src/styles/main.css')
|
||||||
|
assert.match(css, /is-collapsed/)
|
||||||
|
assert.match(css, /@media \(max-width: 820px\)/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user