feat(admin-ui): add tags view state

This commit is contained in:
duanshuwen
2026-08-26 20:52:34 +08:00
parent 3c8c6490fd
commit 96612607e5
3 changed files with 541 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,120 @@
export type TagQuery = Record<string, string | string[]>;
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<string, unknown> {
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 }));
}

View File

@@ -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<VisitedTag[]>((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<VisitedTag[]>([{ ...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,
};
});