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:
chenxing
2026-09-19 16:48:16 +08:00
parent 6f1f666479
commit f29c193ea7
19 changed files with 1299 additions and 72 deletions
+41 -22
View File
@@ -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;
}
/**
* 数字转大写汉字
+94
View File
@@ -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"]);
});
});
+84
View File
@@ -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());
}
+94
View File
@@ -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(); // 不抛错
});
});