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:
@@ -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 同款覆盖式。
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user