feat(theme+规范): 管理端接入 yh-theme 主题层并按 bsch 应用页规范升级页面骨架
- 主题层: scripts/gen-yh-theme.mjs 生成 assets/styles/yh-theme.css(构建期静态化 color-mix), 5 主题档×侧栏深浅×渐变×side-plain 挂 html 档位类名; main.ts 于 icons.css 后引入并 initTheme() 消首帧闪色 - utils/theme.ts(+8 用例): localStorage(yh-theme) 持久化 + <html> 档位类名幂等切换 - ThemeSwitcher.vue(+6 用例): 顶栏配色胶囊(5 色网格+3 开关), 定稿 07-seam-pure 同款皮肤 - SchoolLayout: 顶栏 logo 徽标+配色切换器挂载; 站点身份色带退役, 子站橙由 yh-theme .band-sub::before 融合 - useTableHeight 响应化(+5 用例): ResizeObserver(body) 重算修复窗口缩放冻结, 常量 56/56 档不动 - bsch 规范骨架: 操作区 fix mb-12/次按钮 fr, Table 补 link-table table-bottom-box, 文章关键字 200px 即输即搜(300ms 防抖去 enter-button), 附件分页 customPageCount 记忆+page-box fr, 轮播展示位移入搜索区, 友链分组改 tabs-wrapper+Tabs card, 五表单校验失败统一"请填写红色星号内容!" - 142 用例全绿, vite build 通过
This commit is contained in:
+41
-22
@@ -1,5 +1,5 @@
|
||||
import { IObj, UseTableSetting } from "@/types/Basics";
|
||||
import { resolveComponent } from "vue";
|
||||
import { resolveComponent, ref, onScopeDispose, getCurrentScope } from "vue";
|
||||
export default class Utils {
|
||||
/**
|
||||
* 格式化日期
|
||||
@@ -128,7 +128,11 @@ export default class Utils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算表格高度
|
||||
* 表格高度(响应式):返回 Ref<number>,模板绑定自动解包。
|
||||
* svc-hall 走查教训——一次性快照在窗口缩放/滚动条出现后冻结,
|
||||
* 这里挂 ResizeObserver(body) 重算;无 RO 环境优雅降级为静态值。
|
||||
* 常量沿用 56/56 档(顶栏 56 + 面包屑 56 = 112,见《管理端整体主题迁移方案》附录 A,
|
||||
* P2 紧凑化时集中改这里)。
|
||||
*/
|
||||
public static useTableHeight({
|
||||
isSearch = false,
|
||||
@@ -137,28 +141,43 @@ export default class Utils {
|
||||
isOperat = false,
|
||||
otherH = 0,
|
||||
} = {}) {
|
||||
let bodyH = document.body.clientHeight,
|
||||
headerH = 112,
|
||||
searchH = 52,
|
||||
operatH = 48,
|
||||
tabsH = 56,
|
||||
pageH = 72,
|
||||
paddingH = 8;
|
||||
const calc = () => {
|
||||
let bodyH = document.body.clientHeight,
|
||||
headerH = 112,
|
||||
searchH = 52,
|
||||
operatH = 48,
|
||||
tabsH = 56,
|
||||
pageH = 72,
|
||||
paddingH = 8;
|
||||
|
||||
try {
|
||||
//@ts-ignore 平台注入的全局对象,独立运行时不存在
|
||||
const bschUtil = BschLoadUtil.getInstance();
|
||||
if (!bschUtil.getIsSelf()) headerH = 56;
|
||||
} catch (e) {
|
||||
/* 独立运行:保留自身 header(56) + 面包屑(56) = 112 */
|
||||
try {
|
||||
//@ts-ignore 平台注入的全局对象,独立运行时不存在
|
||||
const bschUtil = BschLoadUtil.getInstance();
|
||||
if (!bschUtil.getIsSelf()) headerH = 56;
|
||||
} catch (e) {
|
||||
/* 独立运行:保留自身 header(56) + 面包屑(56) = 112 */
|
||||
}
|
||||
let tableH = bodyH - headerH - paddingH;
|
||||
isOperat && (tableH -= operatH);
|
||||
isTabs && (tableH -= tabsH);
|
||||
isSearch && (tableH -= searchH);
|
||||
isPage && (tableH -= pageH);
|
||||
otherH > 0 && (tableH -= otherH);
|
||||
return tableH;
|
||||
};
|
||||
|
||||
const h = ref(calc());
|
||||
if (typeof ResizeObserver === "function" && document?.body) {
|
||||
let raf = 0;
|
||||
const ro = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => (h.value = calc()));
|
||||
});
|
||||
ro.observe(document.body);
|
||||
// 作用域内创建则随作用域释放;组件外裸调用不挂(避免无主观察器泄漏告警)
|
||||
if (getCurrentScope()) onScopeDispose(() => ro.disconnect());
|
||||
}
|
||||
let tableH = bodyH - headerH - paddingH;
|
||||
isOperat && (tableH -= operatH);
|
||||
isTabs && (tableH -= tabsH);
|
||||
isSearch && (tableH -= searchH);
|
||||
isPage && (tableH -= pageH);
|
||||
otherH > 0 && (tableH -= otherH);
|
||||
return tableH;
|
||||
return h;
|
||||
}
|
||||
/**
|
||||
* 数字转大写汉字
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 管理端主题档位状态回归(yh-theme 迁移方案 §6.5):
|
||||
* 持久化读写、脏数据回退默认档、applyThemeState 类名幂等互斥。
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_THEME_STATE,
|
||||
THEME_STORAGE_KEY,
|
||||
applyThemeState,
|
||||
initTheme,
|
||||
loadThemeState,
|
||||
parseThemeState,
|
||||
saveThemeState,
|
||||
} from "@/utils/theme";
|
||||
|
||||
function memStore(init: Record<string, string> = {}) {
|
||||
const map = new Map(Object.entries(init));
|
||||
return {
|
||||
getItem: (k: string) => map.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void map.set(k, v),
|
||||
_dump: () => Object.fromEntries(map),
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseThemeState", () => {
|
||||
it("null/坏 JSON/非对象一律回退默认档", () => {
|
||||
expect(parseThemeState(null)).toEqual(DEFAULT_THEME_STATE);
|
||||
expect(parseThemeState("{oops")).toEqual(DEFAULT_THEME_STATE);
|
||||
expect(parseThemeState("[1,2]")).toEqual(DEFAULT_THEME_STATE);
|
||||
});
|
||||
it("单字段非法只回退该字段,其余保留", () => {
|
||||
const s = parseThemeState(JSON.stringify({ theme: "neon", gradOn: false }));
|
||||
expect(s.theme).toBe("blue");
|
||||
expect(s.gradOn).toBe(false);
|
||||
expect(s.sideDeep).toBe(true);
|
||||
});
|
||||
it("全合法字段按存档恢复", () => {
|
||||
const raw = JSON.stringify({ theme: "teal", sideDeep: false, gradOn: false, sideFollow: false });
|
||||
expect(parseThemeState(raw)).toEqual({ theme: "teal", sideDeep: false, gradOn: false, sideFollow: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("load/saveThemeState", () => {
|
||||
it("save 后 load 往返一致,键名约定 yh-theme", () => {
|
||||
const store = memStore();
|
||||
const state = { theme: "ocean" as const, sideDeep: true, gradOn: false, sideFollow: true };
|
||||
saveThemeState(state, store);
|
||||
expect(Object.keys(store._dump())).toEqual([THEME_STORAGE_KEY]);
|
||||
expect(loadThemeState(store)).toEqual(state);
|
||||
});
|
||||
it("storage 缺失/抛错不崩溃(隐私模式兜底)", () => {
|
||||
expect(loadThemeState(undefined)).toEqual(DEFAULT_THEME_STATE);
|
||||
const broken = { getItem: () => null, setItem: vi.fn(() => { throw new Error("quota"); }) };
|
||||
expect(() => saveThemeState(DEFAULT_THEME_STATE, broken)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyThemeState", () => {
|
||||
const root = () => document.documentElement;
|
||||
|
||||
beforeEach(() => {
|
||||
root().className = "";
|
||||
delete root().dataset.theme;
|
||||
});
|
||||
|
||||
it("默认档挂 data-theme=blue + side-deep + grad-on,且不出现互斥类", () => {
|
||||
applyThemeState(DEFAULT_THEME_STATE, root());
|
||||
expect(root().dataset.theme).toBe("blue");
|
||||
expect(root().classList).toContain("side-deep");
|
||||
expect(root().classList).not.toContain("side-light");
|
||||
expect(root().classList).toContain("grad-on");
|
||||
expect(root().classList).not.toContain("grad-off");
|
||||
expect(root().classList).not.toContain("side-plain");
|
||||
});
|
||||
|
||||
it("反复切换幂等:任意顺序调用后类名集合恰好匹配最后状态", () => {
|
||||
applyThemeState({ theme: "violet", sideDeep: false, gradOn: true, sideFollow: false }, root());
|
||||
applyThemeState({ theme: "ink", sideDeep: true, gradOn: false, sideFollow: true }, root());
|
||||
expect(root().dataset.theme).toBe("ink");
|
||||
expect([...root().classList].sort()).toEqual(["grad-off", "side-deep"]);
|
||||
});
|
||||
|
||||
it("initTheme 读 localStorage 恢复现场(启动引导路径)", () => {
|
||||
window.localStorage.setItem(
|
||||
THEME_STORAGE_KEY,
|
||||
JSON.stringify({ theme: "teal", sideDeep: false, gradOn: true, sideFollow: true })
|
||||
);
|
||||
const st = initTheme();
|
||||
expect(st.theme).toBe("teal");
|
||||
expect(root().dataset.theme).toBe("teal");
|
||||
expect([...root().classList].sort()).toEqual(["grad-on", "side-light"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 管理端主题状态(yh-theme,定稿 07-seam-pure.html,方案《管理端整体主题迁移方案》§6.5)。
|
||||
* 档位全部挂在 <html>:data-theme × side-deep|side-light × grad-on|grad-off × side-plain,
|
||||
* 皮肤规则在 assets/styles/yh-theme.css(生成物)。与门户皮肤包(templates/themes)正交。
|
||||
*/
|
||||
export const THEME_NAMES = ["blue", "ocean", "ink", "violet", "teal"] as const;
|
||||
export type ThemeName = (typeof THEME_NAMES)[number];
|
||||
|
||||
/** 定稿默认档:blue + side-deep + grad-on + 侧栏跟随(D1 拍板 deep) */
|
||||
export interface ThemeState {
|
||||
theme: ThemeName;
|
||||
/** 侧栏深色(false=浅色档) */
|
||||
sideDeep: boolean;
|
||||
/** 顶栏渐变(联动侧栏竖向渐变) */
|
||||
gradOn: boolean;
|
||||
/** 侧栏颜色跟随顶栏品牌色(false=中性灰 side-plain) */
|
||||
sideFollow: boolean;
|
||||
}
|
||||
|
||||
export const THEME_STORAGE_KEY = "yh-theme";
|
||||
|
||||
export const DEFAULT_THEME_STATE: ThemeState = {
|
||||
theme: "blue",
|
||||
sideDeep: true,
|
||||
gradOn: true,
|
||||
sideFollow: true,
|
||||
};
|
||||
|
||||
/** 宽松解析:任一字段非法/缺失即回退默认档(脏数据不阻塞启动) */
|
||||
export function parseThemeState(raw: string | null): ThemeState {
|
||||
if (!raw) return { ...DEFAULT_THEME_STATE };
|
||||
try {
|
||||
const o = JSON.parse(raw);
|
||||
if (!o || typeof o !== "object") return { ...DEFAULT_THEME_STATE };
|
||||
return {
|
||||
theme: THEME_NAMES.includes(o.theme) ? o.theme : DEFAULT_THEME_STATE.theme,
|
||||
sideDeep: typeof o.sideDeep === "boolean" ? o.sideDeep : DEFAULT_THEME_STATE.sideDeep,
|
||||
gradOn: typeof o.gradOn === "boolean" ? o.gradOn : DEFAULT_THEME_STATE.gradOn,
|
||||
sideFollow: typeof o.sideFollow === "boolean" ? o.sideFollow : DEFAULT_THEME_STATE.sideFollow,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_THEME_STATE };
|
||||
}
|
||||
}
|
||||
|
||||
interface StorageLike {
|
||||
getItem(k: string): string | null;
|
||||
setItem(k: string, v: string): void;
|
||||
}
|
||||
|
||||
export function loadThemeState(storage: StorageLike | undefined = globalThis.localStorage): ThemeState {
|
||||
if (!storage) return { ...DEFAULT_THEME_STATE };
|
||||
return parseThemeState(storage.getItem(THEME_STORAGE_KEY));
|
||||
}
|
||||
|
||||
export function saveThemeState(
|
||||
state: ThemeState,
|
||||
storage: StorageLike | undefined = globalThis.localStorage
|
||||
): void {
|
||||
try {
|
||||
storage?.setItem(THEME_STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 把状态落到根元素档位类名(幂等:先清后挂),返回传入的 state 便于链式 */
|
||||
export function applyThemeState(
|
||||
state: ThemeState,
|
||||
root: HTMLElement = document.documentElement
|
||||
): ThemeState {
|
||||
root.dataset.theme = state.theme;
|
||||
root.classList.toggle("side-deep", state.sideDeep);
|
||||
root.classList.toggle("side-light", !state.sideDeep);
|
||||
root.classList.toggle("grad-on", state.gradOn);
|
||||
root.classList.toggle("grad-off", !state.gradOn);
|
||||
root.classList.toggle("side-plain", !state.sideFollow);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** 启动引导:main.ts 于挂载前调用,消除首帧闪色 */
|
||||
export function initTheme(): ThemeState {
|
||||
return applyThemeState(loadThemeState());
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* useTableHeight 响应化回归(svc-hall 走查教训:一次性快照在窗口缩放后高度冻结)。
|
||||
* 契约:返回 Ref<number>(模板绑定自动解包);document.body 尺寸变化 → 重算;
|
||||
* 作用域销毁 → ResizeObserver.disconnect。常量保持 56/56 档不动(P2 才紧凑化)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { effectScope } from "vue";
|
||||
import Utils from "@/utils/index";
|
||||
|
||||
let disconnectCount = 0;
|
||||
|
||||
function setBodyHeight(px: number) {
|
||||
Object.defineProperty(document.body, "clientHeight", { value: px, configurable: true });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
disconnectCount = 0;
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
disconnect() {
|
||||
disconnectCount++;
|
||||
}
|
||||
}
|
||||
);
|
||||
// rAF 同步执行,便于断言
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: () => void) => (cb(), 1));
|
||||
vi.stubGlobal("cancelAnimationFrame", () => {});
|
||||
setBodyHeight(800);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Utils.useTableHeight(响应式)", () => {
|
||||
it("初始值按常量表计算:800 − 112(顶栏+面包屑) − 8 − 48(操作区) = 632", () => {
|
||||
const scope = effectScope();
|
||||
const h = scope.run(() => Utils.useTableHeight({ isOperat: true }))!;
|
||||
expect(h.value).toBe(632);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("各开关叠加扣减:search52 + tabs56 + page72", () => {
|
||||
const scope = effectScope();
|
||||
const h = scope.run(() =>
|
||||
Utils.useTableHeight({ isSearch: true, isTabs: true, isPage: true, otherH: 20 })
|
||||
)!;
|
||||
expect(h.value).toBe(800 - 112 - 8 - 52 - 56 - 72 - 20);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("body 高度变化 → ref 自动重算(不再冻结)", () => {
|
||||
// 用可回写实例收集回调
|
||||
let cb: (() => void) | undefined;
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
constructor(c: () => void) {
|
||||
cb = c;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {
|
||||
disconnectCount++;
|
||||
}
|
||||
}
|
||||
);
|
||||
const scope = effectScope();
|
||||
const h = scope.run(() => Utils.useTableHeight({ isOperat: true }))!;
|
||||
expect(h.value).toBe(632);
|
||||
setBodyHeight(1000);
|
||||
cb!();
|
||||
expect(h.value).toBe(1000 - 112 - 8 - 48);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("作用域销毁 → disconnect 释放观察器", () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => Utils.useTableHeight({}));
|
||||
expect(disconnectCount).toBe(0);
|
||||
scope.stop();
|
||||
expect(disconnectCount).toBe(1);
|
||||
});
|
||||
|
||||
it("无 ResizeObserver 环境(老内核/独立 node)→ 优雅降级为静态值", () => {
|
||||
vi.stubGlobal("ResizeObserver", undefined);
|
||||
const scope = effectScope();
|
||||
const h = scope.run(() => Utils.useTableHeight({ isOperat: true }))!;
|
||||
expect(h.value).toBe(632);
|
||||
scope.stop(); // 不抛错
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user