From 0b106a3dd2d4b585955aeb06b13dcccb91fe35e8 Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Wed, 26 Aug 2026 20:30:18 +0800 Subject: [PATCH] feat(admin-ui): add responsive layout state --- .../src/stores/layout.test.ts | 79 ++++++++++++++ WonderQ-Admin-UI-Vue/src/stores/layout.ts | 103 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 WonderQ-Admin-UI-Vue/src/stores/layout.test.ts create mode 100644 WonderQ-Admin-UI-Vue/src/stores/layout.ts diff --git a/WonderQ-Admin-UI-Vue/src/stores/layout.test.ts b/WonderQ-Admin-UI-Vue/src/stores/layout.test.ts new file mode 100644 index 0000000..3a6e8ba --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/stores/layout.test.ts @@ -0,0 +1,79 @@ +import { createPinia, setActivePinia } from "pinia"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useLayoutStore } from "./layout"; + +describe("layout store", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + [991, "mobile"], + [992, "desktop"], + [993, "desktop"], + ] as const)("uses the strict mobile breakpoint at %spx", (width, expectedDevice) => { + setActivePinia(createPinia()); + const store = useLayoutStore(); + + store.syncViewport(width); + + expect(store.device).toBe(expectedDevice); + expect(store.sidebarOpened).toBe(expectedDevice === "desktop"); + }); + + it("is safe to use without window", () => { + setActivePinia(createPinia()); + const store = useLayoutStore(); + + expect(() => store.syncViewport()).not.toThrow(); + expect(() => store.startViewportWatcher()).not.toThrow(); + expect(store.device).toBe("desktop"); + }); + + it("registers one resize watcher, updates on resize, and unsubscribes idempotently", () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + const browserWindow = { innerWidth: 993, addEventListener, removeEventListener }; + vi.stubGlobal("window", browserWindow); + setActivePinia(createPinia()); + const store = useLayoutStore(); + + const stop = store.startViewportWatcher(); + const sameStop = store.startViewportWatcher(); + const resizeHandler = addEventListener.mock.calls[0][1] as () => void; + + expect(sameStop).toBe(stop); + expect(addEventListener).toHaveBeenCalledTimes(1); + expect(store.device).toBe("desktop"); + + browserWindow.innerWidth = 991; + resizeHandler(); + expect(store.device).toBe("mobile"); + expect(store.sidebarOpened).toBe(false); + + stop(); + stop(); + expect(removeEventListener).toHaveBeenCalledTimes(1); + + browserWindow.innerWidth = 993; + resizeHandler(); + expect(store.device).toBe("mobile"); + }); + + it("controls the sidebar and animation flag independently", () => { + setActivePinia(createPinia()); + const store = useLayoutStore(); + + store.toggleSidebar({ withoutAnimation: true }); + expect(store.sidebarOpened).toBe(false); + expect(store.withoutAnimation).toBe(true); + + store.openSidebar({ withoutAnimation: false }); + expect(store.sidebarOpened).toBe(true); + expect(store.withoutAnimation).toBe(false); + + store.closeSidebar(); + expect(store.sidebarOpened).toBe(false); + expect(store.withoutAnimation).toBe(false); + }); +}); diff --git a/WonderQ-Admin-UI-Vue/src/stores/layout.ts b/WonderQ-Admin-UI-Vue/src/stores/layout.ts new file mode 100644 index 0000000..9c802ab --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/stores/layout.ts @@ -0,0 +1,103 @@ +import { ref } from "vue"; +import { defineStore } from "pinia"; + +export type DeviceType = "desktop" | "mobile"; + +export type SidebarToggleOptions = { + withoutAnimation?: boolean; +}; + +const MOBILE_BREAKPOINT = 992; +const noopUnsubscribe = () => undefined; + +export const useLayoutStore = defineStore("layout", () => { + const device = ref("desktop"); + const sidebarOpened = ref(true); + const settingsDrawerOpen = ref(false); + const withoutAnimation = ref(false); + let stopViewportWatcher: (() => void) | null = null; + + function applyToggleOptions(options: SidebarToggleOptions = {}) { + if (options.withoutAnimation !== undefined) { + withoutAnimation.value = options.withoutAnimation; + } + } + + function syncViewport(width?: number) { + const viewportWidth = width ?? (typeof window === "undefined" ? 1024 : window.innerWidth); + const nextDevice: DeviceType = viewportWidth < MOBILE_BREAKPOINT ? "mobile" : "desktop"; + + device.value = nextDevice; + if (nextDevice === "mobile") { + sidebarOpened.value = false; + } + } + + function toggleSidebar(options?: SidebarToggleOptions) { + applyToggleOptions(options); + sidebarOpened.value = !sidebarOpened.value; + } + + function openSidebar(options?: SidebarToggleOptions) { + applyToggleOptions(options); + sidebarOpened.value = true; + } + + function closeSidebar(options?: SidebarToggleOptions) { + applyToggleOptions(options); + sidebarOpened.value = false; + } + + function openSettings() { + settingsDrawerOpen.value = true; + } + + function closeSettings() { + settingsDrawerOpen.value = false; + } + + function startViewportWatcher() { + if (typeof window === "undefined") { + return noopUnsubscribe; + } + if (stopViewportWatcher) { + return stopViewportWatcher; + } + + const currentWindow = window; + let active = true; + function handleResize() { + if (!active) return; + syncViewport(currentWindow.innerWidth); + } + + syncViewport(currentWindow.innerWidth); + currentWindow.addEventListener("resize", handleResize); + + function unsubscribe() { + if (!active) return; + active = false; + currentWindow.removeEventListener("resize", handleResize); + if (stopViewportWatcher === unsubscribe) { + stopViewportWatcher = null; + } + } + + stopViewportWatcher = unsubscribe; + return unsubscribe; + } + + return { + device, + sidebarOpened, + settingsDrawerOpen, + withoutAnimation, + syncViewport, + toggleSidebar, + openSidebar, + closeSidebar, + openSettings, + closeSettings, + startViewportWatcher, + }; +});