From 13e7a4c6022ce1cb5cf81f054f26737d322c8ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 5 Sep 2026 12:26:30 +0800 Subject: [PATCH] =?UTF-8?q?task-14(=E5=A3=B3=E5=B1=82/=E8=B7=AF=E7=94=B1):?= =?UTF-8?q?=20=E5=AE=9E=E7=8E=B0=E4=BE=A7=E8=BE=B9=E6=A0=8F=E6=8A=98?= =?UTF-8?q?=E5=8F=A0=E4=B8=8E=E7=AA=84=E5=B1=8F=E8=A1=8C=E4=B8=BA=E5=8F=8A?= =?UTF-8?q?=E5=81=8F=E5=A5=BD=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sidebar-collapse 提供阈值/宽高常量、toggle、localStorage 偏好读写(失败容忍)、 initialCollapsed(持久化优先,窄屏自动收起);AdminLayout 接入并持久化折叠状态。 --- admin-frontend-vue/src/layout/AdminLayout.vue | 20 +++- .../src/layout/sidebar-collapse.ts | 56 +++++++++++ admin-frontend-vue/tests/task-14.test.ts | 93 +++++++++++++++++++ 3 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 admin-frontend-vue/src/layout/sidebar-collapse.ts create mode 100644 admin-frontend-vue/tests/task-14.test.ts diff --git a/admin-frontend-vue/src/layout/AdminLayout.vue b/admin-frontend-vue/src/layout/AdminLayout.vue index 9519949c..a3ad28ff 100644 --- a/admin-frontend-vue/src/layout/AdminLayout.vue +++ b/admin-frontend-vue/src/layout/AdminLayout.vue @@ -12,11 +12,27 @@ import { shouldShowEmptyMenu, } from '@/layout/empty-state' import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue' +import { initialCollapsed, toggleCollapsed, writeCollapsePreference } from '@/layout/sidebar-collapse' const route = useRoute() const router = useRouter() 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 menuEntries = computed(() => toSidebarEntries(session.menuTree)) @@ -65,7 +81,7 @@ async function signOut() { - diff --git a/admin-frontend-vue/src/layout/sidebar-collapse.ts b/admin-frontend-vue/src/layout/sidebar-collapse.ts new file mode 100644 index 00000000..03eae78d --- /dev/null +++ b/admin-frontend-vue/src/layout/sidebar-collapse.ts @@ -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 | 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 +} diff --git a/admin-frontend-vue/tests/task-14.test.ts b/admin-frontend-vue/tests/task-14.test.ts new file mode 100644 index 00000000..558dcd22 --- /dev/null +++ b/admin-frontend-vue/tests/task-14.test.ts @@ -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, 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\)/) +})