diff --git a/WonderQ-Admin-UI-Vue/src/lib/tags.test.ts b/WonderQ-Admin-UI-Vue/src/lib/tags.test.ts new file mode 100644 index 0000000..42e895c --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/tags.test.ts @@ -0,0 +1,255 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { + addVisitedTag, + closeAllTags, + closeTag, + closeTagsToLeft, + closeTagsToRight, + getTagsViewsKey, + isVisitedTag, + TAGS_VIEWS_KEY, + type VisitedTag, +} from "./tags"; +import { useTagsStore } from "../stores/tags"; +import { LAYOUT_SETTINGS_KEY } from "./layout-settings"; + +const home: VisitedTag = { + path: "/index", + fullPath: "/index", + title: "首页", + name: "Index", + affix: true, +}; +const reports: VisitedTag = { path: "/reports", fullPath: "/reports", title: "报表" }; +const users: VisitedTag = { path: "/users", fullPath: "/users", title: "用户" }; +const settings: VisitedTag = { path: "/settings", fullPath: "/settings", title: "设置" }; +const validQueryTag: VisitedTag = { + path: "/query", + fullPath: "/query?tab=all", + title: "查询", + query: { tab: "all", filters: ["open", "owned"] }, +}; + +describe("tags pure functions", () => { + it("deduplicates visited tags by fullPath without mutating the input", () => { + const views = [home, reports]; + const result = addVisitedTag(views, { ...reports, title: "报表更新" }); + + expect(result).toEqual(views); + expect(result).not.toBe(views); + }); + + it("keeps affix home when closing a tag", () => { + expect(closeTag([home, reports], "/index", "/index")).toEqual({ + views: [home, reports], + activePath: "/index", + }); + }); + + it("chooses previous, then next, then home when closing the active tag", () => { + expect(closeTag([home, reports, users], "/reports", "/reports")).toEqual({ + views: [home, users], + activePath: "/index", + }); + expect(closeTag([home, reports, users], "/users", "/users")).toEqual({ + views: [home, reports], + activePath: "/reports", + }); + expect(closeTag([home], "/index", "/index")).toEqual({ views: [home], activePath: "/index" }); + }); + + it("closes only the requested side and never removes the selected tag", () => { + const views = [home, reports, users, settings]; + + expect(closeTagsToLeft(views, "/settings")).toEqual([home, settings]); + expect(closeTagsToRight(views, "/reports")).toEqual([home, reports]); + expect(closeTagsToLeft(views, "/users")).toEqual([home, users, settings]); + }); + + it("closes all non-affix tags", () => { + expect(closeAllTags([home, reports, users])).toEqual([home]); + }); +}); + +describe("useTagsStore", () => { + beforeEach(() => { + setActivePinia(createPinia()); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { localStorage: new MapStorage() }, + }); + }); + + afterEach(() => { + Reflect.deleteProperty(globalThis, "window"); + }); + + it("keeps activePath in sync and exposes close fallback to callers", () => { + const store = useTagsStore(); + store.addView(home); + store.addView(reports); + store.addView(users); + + expect(store.activePath).toBe("/users"); + expect(store.closeSelectedTag()).toBe("/reports"); + expect(store.activePath).toBe("/reports"); + expect(store.visitedViews.map((view) => view.fullPath)).toEqual(["/index", "/reports"]); + }); + + it("syncs activePath after closing either side or all tags", () => { + const store = useTagsStore(); + store.addView(home); + store.addView(reports); + store.addView(users); + store.addView(settings); + + store.closeRightTags("/reports"); + expect(store.activePath).toBe("/reports"); + + store.addView(users); + store.addView(settings); + store.activePath = "/reports"; + store.closeLeftTags("/settings"); + expect(store.activePath).toBe("/settings"); + + store.closeAllTags(); + expect(store.activePath).toBe("/index"); + expect(store.refreshSelectedTag()).toEqual(home); + }); + + it("reads and writes only when tagsView and tagsViewPersist are both true", () => { + const storage = new MapStorage(); + const combinations = [ + { tagsView: false, tagsViewPersist: true }, + { tagsView: true, tagsViewPersist: false }, + { tagsView: true, tagsViewPersist: true }, + ]; + + for (const options of combinations) { + storage.clear(); + const store = createStore(storage); + store.setPersistenceEnabled(options); + store.addView(reports); + + const persisted = storage.getItem(TAGS_VIEWS_KEY); + expect(persisted === null).toBe(!options.tagsView || !options.tagsViewPersist); + if (options.tagsView && options.tagsViewPersist) { + expect(JSON.parse(persisted ?? "null")).toEqual([home, reports]); + } + } + + storage.setItem(LAYOUT_SETTINGS_KEY, "layout-sentinel"); + const store = createStore(storage); + store.setPersistenceEnabled({ tagsView: true, tagsViewPersist: true }); + store.updateView({ ...reports, title: "报表页" }); + expect(storage.getItem(LAYOUT_SETTINGS_KEY)).toBe("layout-sentinel"); + }); + + it("restores only with both settings enabled", () => { + const storage = new MapStorage(); + storage.setItem(TAGS_VIEWS_KEY, JSON.stringify([home, reports])); + const disabledStore = createStore(storage); + disabledStore.setPersistenceEnabled({ tagsView: false, tagsViewPersist: true }); + disabledStore.restorePersistedViews(); + expect(disabledStore.visitedViews).toEqual([home]); + + disabledStore.setPersistenceEnabled({ tagsView: true, tagsViewPersist: true }); + expect(disabledStore.visitedViews).toEqual([home]); + disabledStore.restorePersistedViews(); + expect(disabledStore.visitedViews).toEqual([home, reports]); + + const enabledStore = createStore(storage); + enabledStore.setPersistenceEnabled({ tagsView: true, tagsViewPersist: true }); + enabledStore.restorePersistedViews(); + expect(enabledStore.visitedViews).toEqual([home, reports]); + }); + + it.each([ + ["old format object", JSON.stringify({ views: [home, reports] })], + ["empty path", JSON.stringify([{ path: " ", fullPath: "/reports", title: "报表" }])], + ["empty fullPath", JSON.stringify([{ path: "/reports", fullPath: "", title: "报表" }])], + ["empty title", JSON.stringify([{ path: "/reports", fullPath: "/reports", title: " " }])], + ["non-array", JSON.stringify({ path: "/reports", fullPath: "/reports", title: "报表" })], + ["invalid JSON", "not-json"], + ])("falls back to home for %s persisted data", (_label, serialized) => { + const storage = new MapStorage(); + storage.setItem(TAGS_VIEWS_KEY, serialized); + const store = createStore(storage); + store.setPersistenceEnabled({ tagsView: true, tagsViewPersist: true }); + store.restorePersistedViews(); + expect(store.visitedViews).toEqual([home]); + }); + + it("drops invalid optional fields without allowing truthy strings to control affix behavior", () => { + expect(isVisitedTag({ ...validQueryTag, name: 42, icon: false, affix: "false", query: { bad: false } })).toBe(false); + const storage = new MapStorage(); + storage.setItem(TAGS_VIEWS_KEY, JSON.stringify([ + { ...validQueryTag, name: 42, icon: false, affix: "false", query: { bad: false } }, + ])); + const store = createStore(storage); + store.setPersistenceEnabled({ tagsView: true, tagsViewPersist: true }); + store.restorePersistedViews(); + + expect(store.visitedViews).toEqual([home]); + store.closeAllTags(); + expect(store.visitedViews).toEqual([home]); + }); + + it("preserves a valid query object and string arrays during restore", () => { + const storage = new MapStorage(); + storage.setItem(TAGS_VIEWS_KEY, JSON.stringify([validQueryTag])); + const store = createStore(storage); + store.setPersistenceEnabled({ tagsView: true, tagsViewPersist: true }); + store.restorePersistedViews(); + + expect(store.visitedViews).toEqual([home, validQueryTag]); + }); + + it("isolates persisted views by safe scope without touching layout settings", () => { + const storage = new MapStorage(); + storage.setItem(LAYOUT_SETTINGS_KEY, "layout-sentinel"); + + const accountA = createStore(storage); + accountA.setPersistenceScope("account/A"); + accountA.setPersistenceEnabled({ tagsView: true, tagsViewPersist: true }); + accountA.addView(reports); + + const accountB = createStore(storage); + accountB.setPersistenceScope("account B"); + accountB.setPersistenceEnabled({ tagsView: true, tagsViewPersist: true }); + accountB.addView(users); + + expect(storage.getItem(getTagsViewsKey("account/A"))).toContain("/reports"); + expect(storage.getItem(getTagsViewsKey("account B"))).toContain("/users"); + expect(storage.getItem(getTagsViewsKey("account/A"))).not.toContain("/users"); + expect(storage.getItem(LAYOUT_SETTINGS_KEY)).toBe("layout-sentinel"); + + const longScope = "x".repeat(200); + expect(getTagsViewsKey("account/A")).not.toBe(getTagsViewsKey("account_A")); + expect(getTagsViewsKey(longScope)).not.toBe(getTagsViewsKey(`${longScope}a`)); + + const restoredA = createStore(storage); + restoredA.setPersistenceScope("account/A"); + restoredA.setPersistenceEnabled({ tagsView: true, tagsViewPersist: true }); + restoredA.restorePersistedViews(); + expect(restoredA.visitedViews).toEqual([home, reports]); + }); +}); + +function createStore(storage: Storage) { + Object.defineProperty(globalThis, "window", { configurable: true, value: { localStorage: storage } }); + setActivePinia(createPinia()); + return useTagsStore(); +} + +class MapStorage implements Storage { + private readonly values = new Map(); + + get length() { return this.values.size; } + clear() { this.values.clear(); } + getItem(key: string) { return this.values.get(key) ?? null; } + key(index: number) { return [...this.values.keys()][index] ?? null; } + removeItem(key: string) { this.values.delete(key); } + setItem(key: string, value: string) { this.values.set(key, value); } +} diff --git a/WonderQ-Admin-UI-Vue/src/lib/tags.ts b/WonderQ-Admin-UI-Vue/src/lib/tags.ts new file mode 100644 index 0000000..3c0900a --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/lib/tags.ts @@ -0,0 +1,120 @@ +export type TagQuery = Record; + +export type VisitedTag = { + path: string; + fullPath: string; + title: string; + name?: string; + query?: TagQuery; + icon?: string; + affix?: boolean; +}; + +export type CloseTagResult = { + views: VisitedTag[]; + activePath: string; +}; + +export const HOME_TAG: VisitedTag = { + path: "/index", + fullPath: "/index", + title: "首页", + name: "Index", + affix: true, +}; + +export const TAGS_VIEWS_KEY = "wonderq-admin-tags-views:anonymous"; + +const TAGS_VIEWS_KEY_PREFIX = "wonderq-admin-tags-views"; + +function safePersistenceScope(scope: string | null): string { + if (scope === null || scope === "" || scope === "anonymous") return "anonymous"; + return encodeURIComponent(scope); +} + +export function getTagsViewsKey(scope: string | null = null): string { + const safeScope = safePersistenceScope(scope); + return safeScope === "anonymous" ? TAGS_VIEWS_KEY : `${TAGS_VIEWS_KEY_PREFIX}:${safeScope}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeQuery(value: unknown): TagQuery | undefined { + if (!isRecord(value)) return undefined; + const entries = Object.entries(value); + if (!entries.every(([, item]) => typeof item === "string" || (Array.isArray(item) && item.every((part) => typeof part === "string")))) { + return undefined; + } + return Object.fromEntries(entries.map(([key, item]) => [key, Array.isArray(item) ? [...item] : item])) as TagQuery; +} + +export function normalizeVisitedTag(value: unknown): VisitedTag | null { + if (!isRecord(value)) return null; + const { path, fullPath, title } = value; + if (typeof path !== "string" || path.trim() === "" + || typeof fullPath !== "string" || fullPath.trim() === "" + || typeof title !== "string" || title.trim() === "") { + return null; + } + + const tag: VisitedTag = { path, fullPath, title }; + if (typeof value.name === "string") tag.name = value.name; + if (typeof value.icon === "string") tag.icon = value.icon; + if (typeof value.affix === "boolean") tag.affix = value.affix; + const query = normalizeQuery(value.query); + if (query) tag.query = query; + return tag; +} + +export function isVisitedTag(value: unknown): value is VisitedTag { + if (!isRecord(value) || normalizeVisitedTag(value) === null) return false; + if (Object.prototype.hasOwnProperty.call(value, "name") && typeof value.name !== "string") return false; + if (Object.prototype.hasOwnProperty.call(value, "icon") && typeof value.icon !== "string") return false; + if (Object.prototype.hasOwnProperty.call(value, "affix") && typeof value.affix !== "boolean") return false; + if (Object.prototype.hasOwnProperty.call(value, "query") && normalizeQuery(value.query) === undefined) return false; + return true; +} + +export function addVisitedTag(views: readonly VisitedTag[], tag: VisitedTag): VisitedTag[] { + if (views.some((view) => view.fullPath === tag.fullPath)) { + return [...views]; + } + return [...views, { ...tag }]; +} + +export function closeTag( + views: readonly VisitedTag[], + fullPath: string, + activePath: string, +): CloseTagResult { + const index = views.findIndex((view) => view.fullPath === fullPath); + if (index < 0 || views[index]?.affix) { + return { views: [...views], activePath }; + } + + const nextViews = views.filter((view) => view.fullPath !== fullPath); + if (fullPath !== activePath) { + return { views: nextViews, activePath }; + } + + const fallback = views[index - 1] ?? views[index + 1] ?? HOME_TAG; + return { views: nextViews, activePath: fallback.fullPath }; +} + +export function closeTagsToLeft(views: readonly VisitedTag[], fullPath: string): VisitedTag[] { + const index = views.findIndex((view) => view.fullPath === fullPath); + if (index < 0) return [...views]; + return views.filter((view, viewIndex) => view.affix || viewIndex >= index).map((view) => ({ ...view })); +} + +export function closeTagsToRight(views: readonly VisitedTag[], fullPath: string): VisitedTag[] { + const index = views.findIndex((view) => view.fullPath === fullPath); + if (index < 0) return [...views]; + return views.filter((view, viewIndex) => view.affix || viewIndex <= index).map((view) => ({ ...view })); +} + +export function closeAllTags(views: readonly VisitedTag[]): VisitedTag[] { + return views.filter((view) => view.affix).map((view) => ({ ...view })); +} diff --git a/WonderQ-Admin-UI-Vue/src/stores/tags.ts b/WonderQ-Admin-UI-Vue/src/stores/tags.ts new file mode 100644 index 0000000..7858b25 --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/stores/tags.ts @@ -0,0 +1,166 @@ +import { ref } from "vue"; +import { defineStore } from "pinia"; +import { + addVisitedTag, + closeAllTags as closeAllVisitedTags, + closeTag, + closeTagsToLeft, + closeTagsToRight, + getTagsViewsKey, + HOME_TAG, + isVisitedTag, + normalizeVisitedTag, + type VisitedTag, +} from "../lib/tags"; + +export type TagsPersistenceOptions = { + tagsView: boolean; + tagsViewPersist: boolean; +}; + +function getStorage(): Storage | null { + try { + return typeof window === "undefined" ? null : window.localStorage; + } catch { + return null; + } +} + +function readPersistedViews(storageKey: string): VisitedTag[] { + try { + const serialized = getStorage()?.getItem(storageKey); + if (serialized === null || serialized === undefined) return [HOME_TAG]; + const parsed: unknown = JSON.parse(serialized); + if (!Array.isArray(parsed)) return [HOME_TAG]; + + const views = parsed + .filter(isVisitedTag) + .map(normalizeVisitedTag) + .filter((view): view is VisitedTag => view !== null) + .reduce((result, view) => addVisitedTag(result, view), []); + return [HOME_TAG, ...views.filter((view) => view.fullPath !== HOME_TAG.fullPath && !view.affix)]; + } catch { + return [HOME_TAG]; + } +} + +export const useTagsStore = defineStore("tags", () => { + const visitedViews = ref([{ ...HOME_TAG }]); + const activePath = ref(HOME_TAG.fullPath); + const tagsView = ref(false); + const tagsViewPersist = ref(false); + let persistenceScope: string | null = null; + + function storageKey() { + return getTagsViewsKey(persistenceScope); + } + + function persist() { + if (!tagsView.value || !tagsViewPersist.value) return; + try { + getStorage()?.setItem(storageKey(), JSON.stringify(visitedViews.value)); + } catch { + // Storage may be unavailable or disabled in the current browser context. + } + } + + function setPersistenceEnabled(options: TagsPersistenceOptions) { + tagsView.value = options.tagsView; + tagsViewPersist.value = options.tagsViewPersist; + } + + function setPersistenceScope(scope: string | null) { + persistenceScope = scope; + } + + function addView(view: VisitedTag) { + visitedViews.value = addVisitedTag(visitedViews.value, view); + activePath.value = view.fullPath; + persist(); + } + + function updateView(view: VisitedTag) { + const index = visitedViews.value.findIndex((item) => item.fullPath === view.fullPath); + if (index < 0) { + addView(view); + return; + } + visitedViews.value = visitedViews.value.map((item, itemIndex) => itemIndex === index ? { ...view } : { ...item }); + activePath.value = view.fullPath; + persist(); + } + + function closeSelectedTag(fullPath = activePath.value): string { + const result = closeTag(visitedViews.value, fullPath, activePath.value); + visitedViews.value = result.views; + activePath.value = result.activePath; + persist(); + return result.activePath; + } + + function closeOthersTags(fullPath = activePath.value) { + visitedViews.value = visitedViews.value.filter((view) => view.affix || view.fullPath === fullPath).map((view) => ({ ...view })); + activePath.value = visitedViews.value.some((view) => view.fullPath === fullPath) ? fullPath : HOME_TAG.fullPath; + persist(); + } + + function closeLeftTags(fullPath = activePath.value) { + visitedViews.value = closeTagsToLeft(visitedViews.value, fullPath); + if (!visitedViews.value.some((view) => view.fullPath === activePath.value)) { + activePath.value = visitedViews.value.some((view) => view.fullPath === fullPath) + ? fullPath + : HOME_TAG.fullPath; + } + persist(); + } + + function closeRightTags(fullPath = activePath.value) { + visitedViews.value = closeTagsToRight(visitedViews.value, fullPath); + if (!visitedViews.value.some((view) => view.fullPath === activePath.value)) { + activePath.value = visitedViews.value.some((view) => view.fullPath === fullPath) + ? fullPath + : HOME_TAG.fullPath; + } + persist(); + } + + function closeAllTags() { + visitedViews.value = closeAllVisitedTags(visitedViews.value); + activePath.value = HOME_TAG.fullPath; + persist(); + } + + function refreshSelectedTag(): VisitedTag { + const selected = visitedViews.value.find((view) => view.fullPath === activePath.value); + if (selected) return selected; + const fallback = visitedViews.value.find((view) => view.fullPath === HOME_TAG.fullPath) ?? HOME_TAG; + activePath.value = fallback.fullPath; + return fallback; + } + + function restorePersistedViews() { + if (!tagsView.value || !tagsViewPersist.value) return; + visitedViews.value = readPersistedViews(storageKey()); + activePath.value = visitedViews.value.some((view) => view.fullPath === activePath.value) + ? activePath.value + : HOME_TAG.fullPath; + } + + return { + visitedViews, + activePath, + tagsView, + tagsViewPersist, + setPersistenceEnabled, + setPersistenceScope, + addView, + updateView, + closeSelectedTag, + closeOthersTags, + closeLeftTags, + closeRightTags, + closeAllTags, + refreshSelectedTag, + restorePersistedViews, + }; +});