diff --git a/WonderQ-Admin-UI-Vue/src/lib/layout-settings.test.ts b/WonderQ-Admin-UI-Vue/src/lib/layout-settings.test.ts new file mode 100644 index 0000000..0c9bdfb --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/layout-settings.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_LAYOUT_SETTINGS, + LAYOUT_SETTINGS_KEY, + loadLayoutSettings, + normalizeLayoutSettings, + resetLayoutSettings, + saveLayoutSettings, + type LayoutSettings, +} from "./layout-settings"; + +function createStorage(initial: Record = {}): Storage { + const values = new Map(Object.entries(initial)); + + return { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + }; +} + +describe("layout settings", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("exposes the RuoYi default settings without sharing the exported object", () => { + expect(normalizeLayoutSettings(undefined)).toEqual(DEFAULT_LAYOUT_SETTINGS); + expect(normalizeLayoutSettings(undefined)).not.toBe(DEFAULT_LAYOUT_SETTINGS); + }); + + it("falls back for invalid enum, boolean, and theme values", () => { + expect( + normalizeLayoutSettings({ + navType: "invalid", + sideTheme: "invalid", + theme: "#409ef", + tagsView: "yes", + tagsViewPersist: 1, + tagsIcon: null, + tagsViewStyle: "invalid", + fixedHeader: "false", + sidebarLogo: 0, + dynamicTitle: {}, + footerVisible: [], + }), + ).toEqual(DEFAULT_LAYOUT_SETTINGS); + }); + + it("filters unknown fields and accepts only six-digit hex themes", () => { + const settings = normalizeLayoutSettings({ + theme: "#12AbEf", + unknown: true, + }); + + expect(settings).toEqual({ ...DEFAULT_LAYOUT_SETTINGS, theme: "#12AbEf" }); + expect("unknown" in settings).toBe(false); + }); + + it("saves and loads a settings round-trip", () => { + const storage = createStorage(); + const settings: LayoutSettings = { + ...DEFAULT_LAYOUT_SETTINGS, + navType: "mix", + tagsView: false, + theme: "#123456", + }; + + saveLayoutSettings(settings, storage); + + expect(storage.getItem(LAYOUT_SETTINGS_KEY)).toBe(JSON.stringify(settings)); + expect(loadLayoutSettings(storage)).toEqual(settings); + }); + + it("safely falls back when storage access or JSON parsing fails", () => { + const storage = createStorage({ [LAYOUT_SETTINGS_KEY]: "{" }); + expect(loadLayoutSettings(storage)).toEqual(DEFAULT_LAYOUT_SETTINGS); + + const throwingStorage = { + getItem: () => { + throw new Error("blocked"); + }, + removeItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + } as unknown as Storage; + + expect(loadLayoutSettings(throwingStorage)).toEqual(DEFAULT_LAYOUT_SETTINGS); + expect(() => saveLayoutSettings(DEFAULT_LAYOUT_SETTINGS, throwingStorage)).not.toThrow(); + expect(resetLayoutSettings(throwingStorage)).toEqual(DEFAULT_LAYOUT_SETTINGS); + }); + + it("normalizes legal JSON with invalid, missing, and unknown stored fields", () => { + const storage = createStorage({ + [LAYOUT_SETTINGS_KEY]: JSON.stringify({ + navType: "invalid", + sideTheme: "theme-light", + theme: "blue", + tagsView: false, + tagsIcon: "yes", + tagsViewStyle: "chrome", + fixedHeader: true, + unknown: "ignored", + }), + }); + + expect(loadLayoutSettings(storage)).toEqual({ + ...DEFAULT_LAYOUT_SETTINGS, + sideTheme: "theme-light", + tagsView: false, + tagsViewStyle: "chrome", + fixedHeader: true, + }); + }); + + it("safely handles a localStorage getter that throws during default resolution", () => { + vi.stubGlobal("window", { + get localStorage() { + throw new Error("blocked"); + }, + }); + + expect(() => loadLayoutSettings()).not.toThrow(); + expect(() => saveLayoutSettings(DEFAULT_LAYOUT_SETTINGS)).not.toThrow(); + expect(() => resetLayoutSettings()).not.toThrow(); + }); + + it("resets settings by clearing the persisted value", () => { + const storage = createStorage({ [LAYOUT_SETTINGS_KEY]: JSON.stringify({ theme: "#123456" }) }); + + expect(resetLayoutSettings(storage)).toEqual(DEFAULT_LAYOUT_SETTINGS); + expect(storage.getItem(LAYOUT_SETTINGS_KEY)).toBeNull(); + }); +}); diff --git a/WonderQ-Admin-UI-Vue/src/lib/layout-settings.ts b/WonderQ-Admin-UI-Vue/src/lib/layout-settings.ts new file mode 100644 index 0000000..cfc5bff --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/layout-settings.ts @@ -0,0 +1,130 @@ +export type NavigationType = "left" | "mix" | "top"; +export type SideTheme = "theme-dark" | "theme-light"; +export type TagsViewStyle = "card" | "chrome"; + +export interface LayoutSettings { + navType: NavigationType; + sideTheme: SideTheme; + theme: string; + tagsView: boolean; + tagsViewPersist: boolean; + tagsIcon: boolean; + tagsViewStyle: TagsViewStyle; + fixedHeader: boolean; + sidebarLogo: boolean; + dynamicTitle: boolean; + footerVisible: boolean; +} + +export const LAYOUT_SETTINGS_KEY = "wonderq-admin-layout-setting"; + +export const DEFAULT_LAYOUT_SETTINGS: LayoutSettings = { + navType: "left", + sideTheme: "theme-dark", + theme: "#409EFF", + tagsView: true, + tagsViewPersist: false, + tagsIcon: false, + tagsViewStyle: "card", + fixedHeader: false, + sidebarLogo: true, + dynamicTitle: false, + footerVisible: false, +}; + +const navigationTypes: readonly NavigationType[] = ["left", "mix", "top"]; +const sideThemes: readonly SideTheme[] = ["theme-dark", "theme-light"]; +const tagsViewStyles: readonly TagsViewStyle[] = ["card", "chrome"]; + +const unavailableStorage: Storage = { + get length() { + return 0; + }, + clear() {}, + getItem() { + return null; + }, + key() { + return null; + }, + removeItem() {}, + setItem() {}, +}; + +function getDefaultStorage(): Storage { + try { + if (typeof window === "undefined") { + return unavailableStorage; + } + + return window.localStorage; + } catch { + return unavailableStorage; + } +} + +function isRecord(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input); +} + +function valueOrDefault(value: unknown, values: readonly T[], fallback: T): T { + return values.includes(value as T) ? (value as T) : fallback; +} + +function booleanOrDefault(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function themeOrDefault(value: unknown): string { + return typeof value === "string" && /^#[0-9A-Fa-f]{6}$/.test(value) + ? value + : DEFAULT_LAYOUT_SETTINGS.theme; +} + +export function normalizeLayoutSettings(input: unknown): LayoutSettings { + const source = isRecord(input) ? input : {}; + + return { + navType: valueOrDefault(source.navType, navigationTypes, DEFAULT_LAYOUT_SETTINGS.navType), + sideTheme: valueOrDefault(source.sideTheme, sideThemes, DEFAULT_LAYOUT_SETTINGS.sideTheme), + theme: themeOrDefault(source.theme), + tagsView: booleanOrDefault(source.tagsView, DEFAULT_LAYOUT_SETTINGS.tagsView), + tagsViewPersist: booleanOrDefault(source.tagsViewPersist, DEFAULT_LAYOUT_SETTINGS.tagsViewPersist), + tagsIcon: booleanOrDefault(source.tagsIcon, DEFAULT_LAYOUT_SETTINGS.tagsIcon), + tagsViewStyle: valueOrDefault(source.tagsViewStyle, tagsViewStyles, DEFAULT_LAYOUT_SETTINGS.tagsViewStyle), + fixedHeader: booleanOrDefault(source.fixedHeader, DEFAULT_LAYOUT_SETTINGS.fixedHeader), + sidebarLogo: booleanOrDefault(source.sidebarLogo, DEFAULT_LAYOUT_SETTINGS.sidebarLogo), + dynamicTitle: booleanOrDefault(source.dynamicTitle, DEFAULT_LAYOUT_SETTINGS.dynamicTitle), + footerVisible: booleanOrDefault(source.footerVisible, DEFAULT_LAYOUT_SETTINGS.footerVisible), + }; +} + +export function loadLayoutSettings(storage: Storage = getDefaultStorage()): LayoutSettings { + try { + const serialized = storage.getItem(LAYOUT_SETTINGS_KEY); + return serialized === null ? normalizeLayoutSettings(undefined) : normalizeLayoutSettings(JSON.parse(serialized)); + } catch { + return normalizeLayoutSettings(undefined); + } +} + +export function saveLayoutSettings( + settings: LayoutSettings, + storage: Storage = getDefaultStorage(), +): void { + try { + storage.setItem(LAYOUT_SETTINGS_KEY, JSON.stringify(normalizeLayoutSettings(settings))); + } catch { + // Storage may be unavailable or disabled in the current browser context. + } +} + +export function resetLayoutSettings(storage: Storage = getDefaultStorage()): LayoutSettings { + try { + storage.removeItem(LAYOUT_SETTINGS_KEY); + } catch { + // Storage may be unavailable or disabled in the current browser context. + } + + return normalizeLayoutSettings(undefined); +}