feat(admin-ui): add persisted layout settings model

This commit is contained in:
duanshuwen
2026-08-26 20:20:29 +08:00
parent 807d8891d5
commit 63ce8797bc
2 changed files with 272 additions and 0 deletions

View File

@@ -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<string, string> = {}): 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();
});
});

View File

@@ -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<string, unknown> {
return typeof input === "object" && input !== null && !Array.isArray(input);
}
function valueOrDefault<T>(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);
}