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
+13 -14
View File
@@ -15,6 +15,7 @@ import { resolveMenuPage } from "@/utils/menuRoute";
import { useCmsStore } from "@/store";
import { adminPathOf, stripSiteSeg, schoolSubPath, resolveRouteSite } from "@/utils/sitePath";
import SiteSwitcher from "@/components/SiteSwitcher.vue";
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
import config from "@/assets/config/host.config";
const route = useRoute();
@@ -295,6 +296,8 @@ watch(
<template>
<div class="layout-wrap">
<header class="header-container fix">
<!-- 云海 logo 徽标(07-seam-pure 定稿顶栏,皮肤见 yh-theme.css .logo-badge) -->
<span class="logo-badge fl"><img src="@/assets/images/yunhai-logo.svg" alt="云海" /></span>
<div class="fl align-middle icon-box">
<div title="首页" class="icon-btn" @click="linkHomePage">
<i class="iconfont icon-shouye"></i>
@@ -323,6 +326,8 @@ watch(
<i class="iconfont icon-logout"></i>
</a>
</div>
<!-- 配色切换器(fr 顺序:DOM 后置即位于用户胶囊左侧) -->
<div class="theme-cap fr"><ThemeSwitcher /></div>
</header>
<div class="left-menu" v-if="!isAlone">
<div class="menu-top-box"></div>
@@ -442,21 +447,15 @@ watch(
font-size: 14px;
}
/* 站点色带(docs/13 §1.4):::after 挂底边——.path-nav 高度 56px 固定且未设 border-box,
直接加 border-bottom 会改盒高(W2 高度链教训),绝对定位不参与布局 */
.path-nav::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
/* 顶栏浮动作增补:logo 徽标垂直居中(56-30)/2=13px;配色胶囊同高对齐。
接缝条与站点身份带(原 .path-nav::after 3px 色带)已由 yh-theme.css 统一接管
(band-sub 时 ::before 让位身份橙),本组件不再画色带,仅保留 band-* 类名作档位 */
.header-container .logo-badge {
margin: 13px 10px 0 14px;
}
.path-nav.band-main::after {
background: #577afb;
}
.path-nav.band-sub::after {
background: #ff9900;
.theme-cap {
height: 56px;
margin-right: 6px;
}
/* W2(D6):左树高度链修复——UserBase App.vue:222-224 同款覆盖式。
+115
View File
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
/**
* 配色切换器(yh-theme §6.5)行为回归:
* 点主题色 → <html data-theme> 与 localStorage 同步;开关 → side-deep/side-light、grad-*、
* side-plain 档位类名互斥切换;胶囊点击开合弹层。沿用 createApp 手写挂载桩风格。
*/
import { beforeEach, describe, expect, it } from "vitest";
import { createApp, nextTick } from "vue";
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
import { THEME_STORAGE_KEY, loadThemeState } from "@/utils/theme";
let host: HTMLDivElement;
let app: ReturnType<typeof createApp>;
function mount() {
host = document.createElement("div");
document.body.appendChild(host);
app = createApp(ThemeSwitcher);
app.mount(host);
}
beforeEach(() => {
localStorage.clear();
const el = document.documentElement;
el.removeAttribute("data-theme");
["side-deep", "side-light", "grad-on", "grad-off", "side-plain"].forEach(c =>
el.classList.remove(c)
);
app?.unmount();
host?.remove();
mount();
});
function options(): HTMLButtonElement[] {
return Array.from(host.querySelectorAll(".theme-opt"));
}
function switches(): HTMLInputElement[] {
return Array.from(host.querySelectorAll(".sw input"));
}
/** 按 i 元素内联背景色定位主题按钮(swatch 色 = 定稿表) */
function optionByColor(color: string): HTMLButtonElement {
const el = options().find(b => (b.querySelector("i") as HTMLElement).style.background === color);
if (!el) throw new Error(`option not found: ${color}`);
return el;
}
function click(el: HTMLElement) {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}
describe("ThemeSwitcher", () => {
it("初始态:无存储 → 默认档 blue 高亮,active 类唯一", () => {
const active = options().filter(b => b.classList.contains("active"));
expect(active).toHaveLength(1);
expect(active[0].textContent).toContain("原版靛蓝");
});
it("点「云海蓝」→ html[data-theme=ocean] 且持久化", () => {
click(optionByColor("rgb(47, 124, 224)"));
expect(document.documentElement.dataset.theme).toBe("ocean");
expect(loadThemeState().theme).toBe("ocean");
expect(JSON.parse(localStorage.getItem(THEME_STORAGE_KEY)!).theme).toBe("ocean");
});
it("关「侧栏深色」→ side-deep 移除、side-light 挂上(互斥)", () => {
expect(document.documentElement.classList.contains("side-deep")).toBe(true);
const [follow, deep] = switches();
void follow;
deep.checked = false;
deep.dispatchEvent(new Event("change", { bubbles: true }));
nextTick();
const cl = document.documentElement.classList;
expect(cl.contains("side-deep")).toBe(false);
expect(cl.contains("side-light")).toBe(true);
expect(loadThemeState().sideDeep).toBe(false);
});
it("关「侧栏跟随顶栏色」→ side-plain 挂上", () => {
const [follow] = switches();
follow.checked = false;
follow.dispatchEvent(new Event("change", { bubbles: true }));
nextTick();
expect(document.documentElement.classList.contains("side-plain")).toBe(true);
expect(loadThemeState().sideFollow).toBe(false);
});
it("胶囊点击开合弹层", async () => {
const btn = host.querySelector(".theme-btn") as HTMLElement;
const pop = host.querySelector(".theme-pop") as HTMLElement;
expect(pop.classList.contains("open")).toBe(false);
click(btn);
await nextTick();
expect(pop.classList.contains("open")).toBe(true);
click(btn);
await nextTick();
expect(pop.classList.contains("open")).toBe(false);
});
it("挂载前已有存储(teal+浅色+无渐变)→ 按钮态与 html 档恢复", () => {
localStorage.setItem(
THEME_STORAGE_KEY,
JSON.stringify({ theme: "teal", sideDeep: false, gradOn: false, sideFollow: true })
);
app.unmount();
mount();
expect(document.documentElement.dataset.theme).toBe("teal");
const active = options().find(b => b.classList.contains("active"));
expect(active?.textContent).toContain("松石绿");
const [, deep, grad] = switches();
expect(deep.checked).toBe(false);
expect(grad.checked).toBe(false);
});
});
+274
View File
@@ -0,0 +1,274 @@
<script setup lang="ts">
/* 管理端配色切换器(yh-theme 定稿 07-seam-pure 顶栏「配色」胶囊,方案 §6.5)。
* 状态唯一来源:utils/theme(localStorage 持久化 + <html> 档位类名),与门户皮肤包正交。 */
import { onBeforeUnmount, onMounted, ref } from "vue";
import {
loadThemeState,
saveThemeState,
applyThemeState,
type ThemeName,
type ThemeState,
} from "@/utils/theme";
const THEME_META: { name: ThemeName; label: string; color: string }[] = [
{ name: "blue", label: "原版靛蓝", color: "#577afb" },
{ name: "ocean", label: "云海蓝", color: "#2f7ce0" },
{ name: "ink", label: "石墨墨", color: "#3d4d63" },
{ name: "violet", label: "靛紫", color: "#6d5ae0" },
{ name: "teal", label: "松石绿", color: "#0d9488" },
];
const wrapEl = ref<HTMLElement | null>(null);
const open = ref(false);
const state = ref<ThemeState>(loadThemeState());
/* 挂载即把存储态落到 <html>(与 main.ts initTheme 同源幂等;组件晚于引导挂载时也自洽) */
applyThemeState(state.value);
function commit(patch: Partial<ThemeState>) {
state.value = { ...state.value, ...patch };
applyThemeState(state.value);
saveThemeState(state.value);
}
function toggle(ev: MouseEvent) {
ev.stopPropagation();
open.value = !open.value;
}
/** 切换器外任意点击即收起(弹层内部点击由 wrap 判定放过) */
function onDocClick(ev: MouseEvent) {
if (open.value && wrapEl.value && !wrapEl.value.contains(ev.target as Node)) {
open.value = false;
}
}
onMounted(() => document.addEventListener("click", onDocClick, true));
onBeforeUnmount(() => document.removeEventListener("click", onDocClick, true));
</script>
<template>
<div ref="wrapEl" class="theme-wrap">
<button type="button" class="theme-btn" :class="{ on: open }" @click="toggle">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a9 9 0 1 0 0 18c1.2 0 1.9-.9 1.9-1.8 0-.6-.3-1-.5-1.4-.2-.4-.4-.8-.4-1.3 0-.9.8-1.7 1.8-1.7H17a4 4 0 0 0 4-4c0-4.3-4-7.8-9-7.8z"/>
<circle cx="7.6" cy="11.2" r="1.1"/><circle cx="11" cy="7.6" r="1.1"/><circle cx="15.4" cy="8.6" r="1.1"/>
</svg>
<span>配色</span>
<svg class="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M5.5 9.5l6.5 6.5 6.5-6.5"/>
</svg>
</button>
<div class="theme-pop" :class="{ open }">
<p class="tp-head">主题色</p>
<div class="tp-grid">
<button
v-for="t in THEME_META"
:key="t.name"
type="button"
class="theme-opt"
:class="{ active: state.theme === t.name }"
@click="commit({ theme: t.name })"
>
<i :style="{ background: t.color }"></i>{{ t.label }}
</button>
</div>
<div class="tp-sep"></div>
<p class="tp-head">侧栏样式</p>
<label class="tp-row">
<span>侧栏跟随顶栏色</span>
<span class="sw">
<input type="checkbox" :checked="state.sideFollow" @change="commit({ sideFollow: ($event.target as HTMLInputElement).checked })" />
<i></i>
</span>
</label>
<label class="tp-row" style="margin-top: 9px">
<span>侧栏深色(关=浅色)</span>
<span class="sw">
<input type="checkbox" :checked="state.sideDeep" @change="commit({ sideDeep: ($event.target as HTMLInputElement).checked })" />
<i></i>
</span>
</label>
<div class="tp-sep"></div>
<label class="tp-row">
<span>顶部过渡色(渐变)</span>
<span class="sw">
<input type="checkbox" :checked="state.gradOn" @change="commit({ gradOn: ($event.target as HTMLInputElement).checked })" />
<i></i>
</span>
</label>
</div>
</div>
</template>
<style scoped>
/* 皮肤规格照搬定稿 07-seam-pure.html .theme-wrap/.theme-btn/.theme-pop 段 */
.theme-wrap {
position: relative;
height: 100%;
display: flex;
align-items: center;
}
.theme-btn {
display: flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 11px;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.34);
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-size: 12.5px;
transition: background 0.16s, border-color 0.16s;
}
.theme-btn:hover,
.theme-btn.on {
background: rgba(255, 255, 255, 0.26);
border-color: rgba(255, 255, 255, 0.6);
}
.theme-btn svg {
width: 14px;
height: 14px;
display: block;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.theme-btn svg circle {
fill: currentColor;
stroke: none;
}
.theme-btn .caret {
width: 11px;
height: 11px;
opacity: 0.85;
transition: transform 0.18s;
}
.theme-btn.on .caret {
transform: rotate(180deg);
}
.theme-pop {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 224px;
background: #fff;
color: var(--text, #1e2635);
border-radius: 12px;
border: 1px solid var(--line, #e9edf5);
padding: 12px;
box-shadow: 0 12px 34px -10px rgba(16, 24, 40, 0.28), 0 2px 6px rgba(16, 24, 40, 0.08);
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: 0.18s;
z-index: 40;
}
.theme-pop.open {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.tp-head {
font-size: 11.5px;
color: var(--text-3, #98a1b2);
letter-spacing: 0.5px;
margin: 0 2px 8px;
}
.tp-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.theme-opt {
display: flex;
align-items: center;
gap: 7px;
height: 32px;
padding: 0 8px;
cursor: pointer;
text-align: left;
border: 1px solid var(--line, #e9edf5);
border-radius: 8px;
background: #fff;
color: var(--text-2, #5a6478);
font-size: 12px;
transition: 0.15s;
font-family: inherit;
}
.theme-opt:hover {
border-color: #cfd7e6;
background: #fafbfe;
}
.theme-opt i {
width: 13px;
height: 13px;
border-radius: 50%;
flex: 0 0 auto;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.theme-opt.active {
border-color: var(--brand, #577afb);
color: var(--brand, #577afb);
background: var(--brand-soft, #eef2ff);
font-weight: 600;
}
.tp-sep {
height: 1px;
background: var(--line, #e9edf5);
margin: 12px 0 10px;
}
.tp-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12.5px;
color: var(--text-2, #5a6478);
cursor: pointer;
padding: 0 2px;
}
.sw {
position: relative;
width: 38px;
height: 21px;
flex: 0 0 auto;
}
.sw input {
position: absolute;
inset: 0;
opacity: 0;
margin: 0;
cursor: pointer;
z-index: 1;
}
.sw i {
position: absolute;
inset: 0;
border-radius: 999px;
background: #d7dce6;
transition: background 0.2s;
pointer-events: none;
}
.sw i::after {
content: "";
position: absolute;
top: 2.5px;
left: 2.5px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.28);
transition: transform 0.2s;
}
.sw input:checked + i {
background: var(--brand, #577afb);
}
.sw input:checked + i::after {
transform: translateX(17px);
}
</style>
+16 -5
View File
@@ -66,7 +66,8 @@ const columns = [
h(
"a",
{
style: { color: "#2d8cf0", cursor: "pointer", marginRight: "6px" },
class: "operating-link",
style: { cursor: "pointer" },
onClick: () => goEdit(p.row.id),
},
p.row.title
@@ -212,6 +213,18 @@ function doDelete(row: ArticlePageItem) {
});
}
// 关键字即输即搜(bsch 规范 §2:300ms 防抖,无检索按钮)
let keywordTimer: ReturnType<typeof setTimeout>;
watch(
() => search.keyword,
() => {
clearTimeout(keywordTimer);
keywordTimer = setTimeout(() => {
pageNum.value = 1;
run();
}, 300);
}
);
onMounted(async () => {
await loadCategories();
await run();
@@ -264,17 +277,15 @@ watch(() => store.siteId, async () => {
<Option v-for="s in statusDict" :key="s.value" :value="s.value">{{ s.label }}</Option>
</Select>
<Input
search
enter-button
class="fr"
placeholder="标题关键字"
style="width: 220px"
style="width: 200px"
v-model="search.keyword"
@on-search="pageNum = 1; run()"
@on-clear="pageNum = 1; run()"
/>
</div>
<Table
class="link-table table-bottom-box"
:columns="columns"
:data="rows"
:height="tableHeight"