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:
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* yh-theme.css 生成器(《管理端整体主题迁移方案.md》§6.4:color-mix 构建期静态化)。
|
||||
* 定稿源:07-seam-pure.html(浅色接缝条·纯享版 2026-09-11)。
|
||||
* 用法:node scripts/gen-yh-theme.mjs → src/assets/styles/yh-theme.css
|
||||
* 运行时零 color-mix,兼容下限=支持 CSS 变量。
|
||||
*/
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const hex = n => Math.round(n).toString(16).padStart(2, "0");
|
||||
const parse = c => [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)];
|
||||
/** 与 CSS color-mix(in srgb, A w%, B) 等价的线性混合 */
|
||||
const mix = (a, b, w) => {
|
||||
const [r1, g1, b1] = parse(a);
|
||||
const [r2, g2, b2] = parse(b);
|
||||
return `#${hex(r1 * w + r2 * (1 - w))}${hex(g1 * w + g2 * (1 - w))}${hex(b1 * w + b2 * (1 - w))}`;
|
||||
};
|
||||
const rgba = (c, a) => {
|
||||
const [r, g, b] = parse(c);
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
};
|
||||
|
||||
// 定稿五档品牌色(07-seam-pure.html L31-35)
|
||||
const THEMES = {
|
||||
blue: { brand: "#577afb", b2: "#7b8ffd", dark: "#4264e4", soft: "#eef2ff", a: 0.34, glow: 0.42 },
|
||||
ocean: { brand: "#2f7ce0", b2: "#4a9bf5", dark: "#2568c4", soft: "#eaf3fe", a: 0.34, glow: 0.42 },
|
||||
ink: { brand: "#3d4d63", b2: "#6d82a1", dark: "#2e3c4f", soft: "#eef1f6", a: 0.34, glow: 0.5 },
|
||||
violet: { brand: "#6d5ae0", b2: "#8f7cf2", dark: "#5a48c9", soft: "#f1effe", a: 0.34, glow: 0.42 },
|
||||
teal: { brand: "#0d9488", b2: "#18b5a5", dark: "#0b7c72", soft: "#e8f7f5", a: 0.36, glow: 0.42 },
|
||||
};
|
||||
const NAVY = "#0d1424"; // side-deep 混色基底
|
||||
const WHITE = "#ffffff";
|
||||
|
||||
const sideTokens = t => ({
|
||||
deepSolid: mix(t.brand, NAVY, 0.72),
|
||||
deepGradTop: mix(t.brand, WHITE, 0.96),
|
||||
deepGradBot: mix(t.brand, NAVY, 0.7),
|
||||
lightSolid: mix(t.brand, WHITE, 0.07),
|
||||
lightGradTop: mix(t.brand, WHITE, 0.13),
|
||||
lightGradBot: mix(t.brand, "#f5f7fa", 0.03),
|
||||
lightLine: mix(t.brand, "#e6eaf0", 0.18),
|
||||
lightHover: mix(t.brand, "#f2f4f8", 0.1),
|
||||
lightActive: mix(t.brand, WHITE, 0.15),
|
||||
strip: mix(t.brand, WHITE, 0.13), // 接缝条渐隐起点
|
||||
});
|
||||
|
||||
const B = THEMES.blue;
|
||||
const BS = sideTokens(B);
|
||||
|
||||
let css = `/* 本文件由 scripts/gen-yh-theme.mjs 生成(color-mix 已构建期静态化),勿手改派生色值。
|
||||
定稿:07-seam-pure.html(浅色接缝条·纯享版);挂接:main.ts 置于 icons.css 之后。
|
||||
档位开关挂在 <html>:data-theme=blue|ocean|ink|violet|teal × side-deep|side-light × grad-on|grad-off × side-plain */
|
||||
|
||||
/* ============ 变量体系(:root 默认档 = blue + side-deep + grad-on + side-follow) ============ */
|
||||
:root{
|
||||
--brand:${B.brand}; --brand-2:${B.b2}; --brand-dark:${B.dark};
|
||||
--brand-soft:${B.soft}; --brand-a:${rgba(B.brand, B.a)}; --glow:${rgba(B.brand, B.glow)};
|
||||
--topbar-bg:linear-gradient(100deg,var(--brand) 0%,var(--brand-2) 100%);
|
||||
--green:#20a162; --green-d:#188451;
|
||||
--red:#e54d42; --red-soft:#fef1f0;
|
||||
/* 第一波(P1)沿用现状 56/56,紧凑化 52/44 留 P2(方案 §8-D2) */
|
||||
--topbar-h:56px; --crumb-h:56px; --side-w:200px; --r:10px; --r-s:7px;
|
||||
--page-bg:#f3f5fa; --panel:#fff; --line:#e9edf5; --line-2:#f0f3f9;
|
||||
--text:#1e2635; --text-2:#5a6478; --text-3:#98a1b2;
|
||||
--sh:0 1px 2px rgba(16,24,40,.04),0 14px 30px -22px rgba(16,24,40,.4);
|
||||
--side-bg:linear-gradient(180deg,${BS.deepGradTop} 0%,${BS.deepGradBot} 100%);
|
||||
--side-text:#c7d2ea; --side-icon:#95a6cb;
|
||||
--side-line:rgba(255,255,255,.08);
|
||||
--side-hover:rgba(255,255,255,.09); --side-hover-text:#fff;
|
||||
--side-active-bg:rgba(255,255,255,.18); --side-active-text:#fff;
|
||||
--side-bar:#fff; --side-scroll:rgba(255,255,255,.18);
|
||||
--strip-bg:linear-gradient(180deg,${BS.strip} 0,#fff 100%);
|
||||
}
|
||||
`;
|
||||
|
||||
// 每档主题:品牌 token + 侧栏底色四态 + 接缝条起点色(非底色 token 与主题无关,只写一次)
|
||||
for (const [name, t] of Object.entries(THEMES)) {
|
||||
const s = sideTokens(t);
|
||||
css += `
|
||||
/* ${name} · brand ${t.brand} */
|
||||
html[data-theme="${name}"]{
|
||||
--brand:${t.brand}; --brand-2:${t.b2}; --brand-dark:${t.dark};
|
||||
--brand-soft:${t.soft}; --brand-a:${rgba(t.brand, t.a)}; --glow:${rgba(t.brand, t.glow)};
|
||||
--strip-bg:linear-gradient(180deg,${s.strip} 0,#fff 100%);
|
||||
}
|
||||
html[data-theme="${name}"].side-deep{--side-bg:${s.deepSolid}}
|
||||
html[data-theme="${name}"].side-deep.grad-on{--side-bg:linear-gradient(180deg,${s.deepGradTop} 0%,${s.deepGradBot} 100%)}
|
||||
html[data-theme="${name}"].side-light{--side-bg:${s.lightSolid}}
|
||||
html[data-theme="${name}"].side-light.grad-on{--side-bg:linear-gradient(180deg,${s.lightGradTop} 0%,${s.lightGradBot} 100%)}
|
||||
html[data-theme="${name}"].grad-off{--topbar-bg:${t.brand}}
|
||||
`;
|
||||
}
|
||||
|
||||
css += `
|
||||
/* ---- 深浅档非底色 token(与品牌色联动的用 var 派生;浅色档 line/hover/active 用默认档实测值,
|
||||
主题间差异肉眼不可辨,保持单档以压复杂度——同定稿 side-light 组) ---- */
|
||||
html.side-light{
|
||||
--side-text:#4a5568; --side-icon:#98a2b3;
|
||||
--side-line:${BS.lightLine};
|
||||
--side-hover:${BS.lightHover}; --side-hover-text:#1e2635;
|
||||
--side-active-bg:${BS.lightActive}; --side-active-text:var(--brand-dark);
|
||||
--side-bar:var(--brand); --side-scroll:#d3d9e3;
|
||||
}
|
||||
html.grad-off{--topbar-bg:var(--brand)}
|
||||
|
||||
/* ---- 侧栏与品牌色解耦(side-follow 关) ---- */
|
||||
html.side-plain.side-deep{
|
||||
--side-bg:#2c3444; --side-text:#c2cbd9; --side-icon:#909dae;
|
||||
--side-line:rgba(255,255,255,.08);
|
||||
--side-hover:rgba(255,255,255,.08); --side-hover-text:#fff;
|
||||
--side-active-bg:rgba(255,255,255,.16); --side-active-text:#fff;
|
||||
--side-bar:#fff; --side-scroll:rgba(255,255,255,.18);
|
||||
}
|
||||
html.side-plain.side-light{
|
||||
--side-bg:#f7f8fa; --side-text:#4a5568; --side-icon:#98a2b3;
|
||||
--side-line:#e6eaf0; --side-hover:#eff1f5;
|
||||
--side-active-bg:#eceef3; --side-active-text:#2f3a4c;
|
||||
--side-bar:#8b95a8; --side-scroll:#d3d9e3;
|
||||
}
|
||||
html.side-plain.grad-on.side-deep{--side-bg:linear-gradient(180deg,#333c4d 0%,#272e3c 100%)}
|
||||
html.side-plain.grad-on.side-light{--side-bg:linear-gradient(180deg,#fafbfc 0%,#f2f4f7 100%)}
|
||||
html.side-plain{--strip-bg:linear-gradient(180deg,#f2f4f8 0,#fff 100%)}
|
||||
`;
|
||||
|
||||
/* ============================ 皮肤层(§6.2 类名映射表,不改现有类名) ============================ */
|
||||
css += `
|
||||
/* ---- 顶栏:.header-container ---- */
|
||||
.layout-wrap .header-container{background:var(--topbar-bg);box-shadow:0 1px 3px rgba(16,24,40,.14);transition:background .28s}
|
||||
.layout-wrap .header-container h4{font-weight:600;letter-spacing:.4px}
|
||||
.layout-wrap .header-container .delimiter{background:rgba(255,255,255,.28)}
|
||||
/* 顶栏 logo 徽标(模板新增节点) */
|
||||
.logo-badge{width:30px;height:30px;flex:0 0 auto;border-radius:9px;background:#fff;display:grid;place-items:center;box-shadow:0 2px 7px rgba(0,0,0,.16);margin-right:10px}
|
||||
.logo-badge svg,.logo-badge img{width:22px;height:22px;display:block}
|
||||
/* 用户胶囊 */
|
||||
.layout-wrap .header-aside{padding:0 8px 0 5px;height:36px;border-radius:999px;transition:background .16s}
|
||||
.layout-wrap .header-aside:hover{background:rgba(255,255,255,.16)}
|
||||
|
||||
/* ---- 侧栏:.left-menu(皮肤覆盖 layout.css 浅色档;深浅由 --side-* 驱动) ---- */
|
||||
.layout-wrap .left-menu{background:var(--side-bg);border-right:1px solid var(--side-line);color:var(--side-text);transition:background .28s}
|
||||
.layout-wrap .left-menu::-webkit-scrollbar{width:8px}
|
||||
.layout-wrap .left-menu::-webkit-scrollbar-thumb{background:var(--side-scroll);border-radius:8px}
|
||||
.layout-wrap .left-menu ul{padding:8px 0 18px}
|
||||
.layout-wrap .left-menu li{margin:1px 8px;border-radius:var(--r-s);transition:background .15s,color .15s}
|
||||
.layout-wrap .left-menu li a{
|
||||
display:block;height:40px;line-height:40px;padding:0 12px;border-radius:var(--r-s);
|
||||
color:var(--side-text);font-size:13px;border-right:none;transition:background .15s,color .15s;position:relative;
|
||||
}
|
||||
.layout-wrap .left-menu li a:hover{background:var(--side-hover);color:var(--side-hover-text)}
|
||||
.layout-wrap .left-menu li a:hover .iconfont,.layout-wrap .left-menu li:hover .iconfont{color:var(--side-hover-text)}
|
||||
.layout-wrap .left-menu li.active{background:var(--side-active-bg);border-right:0}
|
||||
.layout-wrap .left-menu li.active a{color:var(--side-active-text);font-weight:600;background:transparent;border-right:none}
|
||||
.layout-wrap .left-menu li.active a .iconfont{color:var(--side-active-text)}
|
||||
.layout-wrap .left-menu li.active::before{
|
||||
content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);
|
||||
width:3px;height:20px;border-radius:0 3px 3px 0;background:var(--side-bar);
|
||||
}
|
||||
.layout-wrap .left-menu li.child-item{margin:0 8px}
|
||||
.layout-wrap .left-menu li.child-item a{height:36px;line-height:36px;font-size:12.5px}
|
||||
.layout-wrap .left-menu li.child-item.active::before{height:16px}
|
||||
/* 菜单图标 mask 类(icons.css 用 background-color 上色,这里给品牌感近色) */
|
||||
.layout-wrap .left-menu .iconfont{margin-right:8px}
|
||||
.layout-wrap .left-menu .arrow-up,.layout-wrap .left-menu .arrow-down{border-color:var(--side-icon)}
|
||||
|
||||
/* ---- 面包屑:.path-nav → 浅色接缝条(方案 §7.3 融合站点身份带) ---- */
|
||||
.layout-wrap .path-nav{
|
||||
height:var(--crumb-h);background:var(--strip-bg);border-bottom:1px solid var(--line);
|
||||
color:var(--text-3);box-sizing:border-box;
|
||||
}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb-item-link{color:var(--text-2)}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb>span:last-child .ivu-breadcrumb-item-link,.layout-wrap .path-nav .ivu-breadcrumb>span:last-child{color:var(--text);font-weight:600}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb a:hover{color:var(--brand)}
|
||||
.layout-wrap .path-nav .crumb-home{color:var(--brand)}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb-item-separator{color:#ccd4e0}
|
||||
/* 定稿:不再画独立 3px 横杠——顶部 3px 由接缝条承接顶栏渐变;子站让位身份橙 */
|
||||
.layout-wrap .path-nav::before{
|
||||
content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--topbar-bg);z-index:1;
|
||||
}
|
||||
.layout-wrap .path-nav.band-sub::before{background:#ff9900}
|
||||
.layout-wrap .path-nav::after{display:none}
|
||||
|
||||
/* ---- 内容区:灰底 + 白卡 ---- */
|
||||
.base-wrap-main{background:var(--page-bg)}
|
||||
.base-wrap-main .main-container{
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
|
||||
box-shadow:var(--sh);margin:0 18px 24px;padding:16px 20px 24px;box-sizing:border-box;
|
||||
}
|
||||
/* 栏目工作台左树卡 */
|
||||
.base-wrap-main .tree-container{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);box-shadow:var(--sh)}
|
||||
|
||||
/* ---- 搜索区分区化(卡片内 filterbar 语义) ---- */
|
||||
.base-wrap-main .search-wrap{
|
||||
background:#fbfcfe;border:1px solid var(--line-2);border-radius:var(--r-s);
|
||||
padding:10px 12px;height:auto;min-height:52px;box-sizing:border-box;margin-bottom:12px;
|
||||
}
|
||||
|
||||
/* ---- 表格/操作列皮肤(三蓝归一 #5979EA/#2d8cf0 → var(--brand)) ---- */
|
||||
.ivu-table-header th,.ivu-table-fixed-header th{background:#f7f9fc !important;color:var(--text-2)}
|
||||
.ivu-table td{border-bottom:1px solid var(--line-2)}
|
||||
.ivu-table-body tr:hover td,.ivu-table tbody tr:hover td{background:var(--brand-soft) !important}
|
||||
.base-wrap-main .ivu-table .operating-link{color:var(--brand)}
|
||||
.base-wrap-main .ivu-table .operating-link.disabled{color:#aaa}
|
||||
.ivu-table-cell .operating-btn:hover{background:var(--brand);color:#fff}
|
||||
|
||||
/* ---- view-ui-plus 品牌态 ---- */
|
||||
.ivu-btn-primary{background-color:var(--brand);border-color:var(--brand)}
|
||||
.ivu-btn-primary:hover,.ivu-btn-primary.active{background-color:var(--brand-dark);border-color:var(--brand-dark)}
|
||||
.ivu-btn-success{background-color:var(--green);border-color:var(--green)}
|
||||
.ivu-btn-success:hover,.ivu-btn-success.active{background-color:var(--green-d);border-color:var(--green-d)}
|
||||
.ivu-btn-error{background-color:var(--red);border-color:var(--red)}
|
||||
.ivu-btn-error:hover,.ivu-btn-error.active{background-color:#c93f36;border-color:#c93f36}
|
||||
.ivu-btn-text{color:var(--brand)}
|
||||
.ivu-btn-text:hover{color:var(--brand-dark)}
|
||||
.ivu-tabs-ink-bar{background-color:var(--brand)}
|
||||
.ivu-tabs-tab:hover,.ivu-tabs-tab-active{color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-item-active{background:var(--brand);border-color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-item-active a,.base-wrap-main .ivu-page-item-active a:hover{color:#fff !important}
|
||||
.base-wrap-main .ivu-page-item:hover{border-color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-item:hover a,.base-wrap-main .ivu-page-item:hover i{color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-prev:hover i,.base-wrap-main .ivu-page-next:hover i{border-color:var(--brand);color:var(--brand)}
|
||||
.ivu-select-item-selected{color:var(--brand)}
|
||||
.ivu-select-item-selected:hover{background:var(--brand-soft)}
|
||||
.ivu-checkbox-checked .ivu-checkbox-inner{border-color:var(--brand);background-color:var(--brand)}
|
||||
.ivu-radio-checked .ivu-radio-inner{border-color:var(--brand);background-color:var(--brand)}
|
||||
.ivu-checkbox-wrapper:hover .ivu-checkbox-inner,.ivu-radio-wrapper:hover .ivu-radio-inner{border-color:var(--brand)}
|
||||
.ivu-input:focus,.ivu-input-focused,.ivu-select-visible .ivu-select-selection{border-color:var(--brand);box-shadow:0 0 0 2px var(--brand-a)}
|
||||
.ivu-input:hover,.ivu-select-selection:hover{border-color:#c6d0e2}
|
||||
.ivu-switch-checked{border-color:var(--brand);background-color:var(--brand)}
|
||||
.ivu-tree-title:hover{background-color:var(--brand-soft)}
|
||||
.ivu-tree-title-selected,a.ivu-tree-title-selected:hover{background-color:var(--brand-soft);color:var(--brand)}
|
||||
.ivu-spin-dot{background-color:var(--brand)}
|
||||
.ivu-modal-header-inner{font-weight:600;color:var(--text)}
|
||||
a{color:var(--brand)}
|
||||
a:hover{color:var(--brand-dark)}
|
||||
|
||||
/* ---- 滚动条(定稿全局) ---- */
|
||||
::-webkit-scrollbar{width:8px;height:8px}
|
||||
::-webkit-scrollbar-thumb{background:#c9d0dd;border-radius:8px}
|
||||
::-webkit-scrollbar-thumb:hover{background:#aeb8c9}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
|
||||
/* ---- 工作台/主题管理全屏页深色顶栏吃主题变量(方案 §7.3) ---- */
|
||||
.wb-header{background:var(--topbar-bg) !important}
|
||||
`;
|
||||
|
||||
writeFileSync(resolve(root, "src/assets/styles/yh-theme.css"), css, "utf8");
|
||||
console.log("yh-theme.css written:", Buffer.byteLength(css), "bytes");
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<path fill="#4aaff0" d="M512,585 C415,455 330,398 215,395 C185,395 160,415 160,450 L160,770 C160,815 190,838 240,845 C340,856 430,872 512,888 C594,872 684,856 784,845 C834,838 864,815 864,770 L864,450 C864,415 839,395 809,395 C694,398 609,455 512,585 Z"/>
|
||||
<path fill="#4aaff0" d="M218,312 C335,292 462,352 514,462 C452,404 334,354 218,378 Z"/>
|
||||
<circle fill="#4aaff0" cx="218" cy="345" r="33"/>
|
||||
<path fill="#80d976" d="M508,462 C455,310 525,145 700,105 C758,250 668,425 508,462 Z"/>
|
||||
<path fill="none" stroke="#4aaff0" stroke-width="44" stroke-linecap="round" d="M210,935 Q285.5,881 361,935 T512,935 T663,935 T814,935"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 701 B |
@@ -0,0 +1,230 @@
|
||||
/* 本文件由 scripts/gen-yh-theme.mjs 生成(color-mix 已构建期静态化),勿手改派生色值。
|
||||
定稿:07-seam-pure.html(浅色接缝条·纯享版);挂接:main.ts 置于 icons.css 之后。
|
||||
档位开关挂在 <html>:data-theme=blue|ocean|ink|violet|teal × side-deep|side-light × grad-on|grad-off × side-plain */
|
||||
|
||||
/* ============ 变量体系(:root 默认档 = blue + side-deep + grad-on + side-follow) ============ */
|
||||
:root{
|
||||
--brand:#577afb; --brand-2:#7b8ffd; --brand-dark:#4264e4;
|
||||
--brand-soft:#eef2ff; --brand-a:rgba(87,122,251,0.34); --glow:rgba(87,122,251,0.42);
|
||||
--topbar-bg:linear-gradient(100deg,var(--brand) 0%,var(--brand-2) 100%);
|
||||
--green:#20a162; --green-d:#188451;
|
||||
--red:#e54d42; --red-soft:#fef1f0;
|
||||
/* 第一波(P1)沿用现状 56/56,紧凑化 52/44 留 P2(方案 §8-D2) */
|
||||
--topbar-h:56px; --crumb-h:56px; --side-w:200px; --r:10px; --r-s:7px;
|
||||
--page-bg:#f3f5fa; --panel:#fff; --line:#e9edf5; --line-2:#f0f3f9;
|
||||
--text:#1e2635; --text-2:#5a6478; --text-3:#98a1b2;
|
||||
--sh:0 1px 2px rgba(16,24,40,.04),0 14px 30px -22px rgba(16,24,40,.4);
|
||||
--side-bg:linear-gradient(180deg,#5e7ffb 0%,#415bbb 100%);
|
||||
--side-text:#c7d2ea; --side-icon:#95a6cb;
|
||||
--side-line:rgba(255,255,255,.08);
|
||||
--side-hover:rgba(255,255,255,.09); --side-hover-text:#fff;
|
||||
--side-active-bg:rgba(255,255,255,.18); --side-active-text:#fff;
|
||||
--side-bar:#fff; --side-scroll:rgba(255,255,255,.18);
|
||||
--strip-bg:linear-gradient(180deg,#e9eefe 0,#fff 100%);
|
||||
}
|
||||
|
||||
/* blue · brand #577afb */
|
||||
html[data-theme="blue"]{
|
||||
--brand:#577afb; --brand-2:#7b8ffd; --brand-dark:#4264e4;
|
||||
--brand-soft:#eef2ff; --brand-a:rgba(87,122,251,0.34); --glow:rgba(87,122,251,0.42);
|
||||
--strip-bg:linear-gradient(180deg,#e9eefe 0,#fff 100%);
|
||||
}
|
||||
html[data-theme="blue"].side-deep{--side-bg:#425dbf}
|
||||
html[data-theme="blue"].side-deep.grad-on{--side-bg:linear-gradient(180deg,#5e7ffb 0%,#415bbb 100%)}
|
||||
html[data-theme="blue"].side-light{--side-bg:#f3f6ff}
|
||||
html[data-theme="blue"].side-light.grad-on{--side-bg:linear-gradient(180deg,#e9eefe 0%,#f0f3fa 100%)}
|
||||
html[data-theme="blue"].grad-off{--topbar-bg:#577afb}
|
||||
|
||||
/* ocean · brand #2f7ce0 */
|
||||
html[data-theme="ocean"]{
|
||||
--brand:#2f7ce0; --brand-2:#4a9bf5; --brand-dark:#2568c4;
|
||||
--brand-soft:#eaf3fe; --brand-a:rgba(47,124,224,0.34); --glow:rgba(47,124,224,0.42);
|
||||
--strip-bg:linear-gradient(180deg,#e4eefb 0,#fff 100%);
|
||||
}
|
||||
html[data-theme="ocean"].side-deep{--side-bg:#255fab}
|
||||
html[data-theme="ocean"].side-deep.grad-on{--side-bg:linear-gradient(180deg,#3781e1 0%,#255da8 100%)}
|
||||
html[data-theme="ocean"].side-light{--side-bg:#f0f6fd}
|
||||
html[data-theme="ocean"].side-light.grad-on{--side-bg:linear-gradient(180deg,#e4eefb 0%,#eff3f9 100%)}
|
||||
html[data-theme="ocean"].grad-off{--topbar-bg:#2f7ce0}
|
||||
|
||||
/* ink · brand #3d4d63 */
|
||||
html[data-theme="ink"]{
|
||||
--brand:#3d4d63; --brand-2:#6d82a1; --brand-dark:#2e3c4f;
|
||||
--brand-soft:#eef1f6; --brand-a:rgba(61,77,99,0.34); --glow:rgba(61,77,99,0.5);
|
||||
--strip-bg:linear-gradient(180deg,#e6e8eb 0,#fff 100%);
|
||||
}
|
||||
html[data-theme="ink"].side-deep{--side-bg:#303d51}
|
||||
html[data-theme="ink"].side-deep.grad-on{--side-bg:linear-gradient(180deg,#455469 0%,#2f3c50 100%)}
|
||||
html[data-theme="ink"].side-light{--side-bg:#f1f3f4}
|
||||
html[data-theme="ink"].side-light.grad-on{--side-bg:linear-gradient(180deg,#e6e8eb 0%,#eff2f5 100%)}
|
||||
html[data-theme="ink"].grad-off{--topbar-bg:#3d4d63}
|
||||
|
||||
/* violet · brand #6d5ae0 */
|
||||
html[data-theme="violet"]{
|
||||
--brand:#6d5ae0; --brand-2:#8f7cf2; --brand-dark:#5a48c9;
|
||||
--brand-soft:#f1effe; --brand-a:rgba(109,90,224,0.34); --glow:rgba(109,90,224,0.42);
|
||||
--strip-bg:linear-gradient(180deg,#eceafb 0,#fff 100%);
|
||||
}
|
||||
html[data-theme="violet"].side-deep{--side-bg:#5246ab}
|
||||
html[data-theme="violet"].side-deep.grad-on{--side-bg:linear-gradient(180deg,#7361e1 0%,#5045a8 100%)}
|
||||
html[data-theme="violet"].side-light{--side-bg:#f5f3fd}
|
||||
html[data-theme="violet"].side-light.grad-on{--side-bg:linear-gradient(180deg,#eceafb 0%,#f1f2f9 100%)}
|
||||
html[data-theme="violet"].grad-off{--topbar-bg:#6d5ae0}
|
||||
|
||||
/* teal · brand #0d9488 */
|
||||
html[data-theme="teal"]{
|
||||
--brand:#0d9488; --brand-2:#18b5a5; --brand-dark:#0b7c72;
|
||||
--brand-soft:#e8f7f5; --brand-a:rgba(13,148,136,0.36); --glow:rgba(13,148,136,0.42);
|
||||
--strip-bg:linear-gradient(180deg,#e0f1f0 0,#fff 100%);
|
||||
}
|
||||
html[data-theme="teal"].side-deep{--side-bg:#0d706c}
|
||||
html[data-theme="teal"].side-deep.grad-on{--side-bg:linear-gradient(180deg,#17988d 0%,#0d6e6a 100%)}
|
||||
html[data-theme="teal"].side-light{--side-bg:#eef8f7}
|
||||
html[data-theme="teal"].side-light.grad-on{--side-bg:linear-gradient(180deg,#e0f1f0 0%,#eef4f7 100%)}
|
||||
html[data-theme="teal"].grad-off{--topbar-bg:#0d9488}
|
||||
|
||||
/* ---- 深浅档非底色 token(与品牌色联动的用 var 派生;浅色档 line/hover/active 用默认档实测值,
|
||||
主题间差异肉眼不可辨,保持单档以压复杂度——同定稿 side-light 组) ---- */
|
||||
html.side-light{
|
||||
--side-text:#4a5568; --side-icon:#98a2b3;
|
||||
--side-line:#ccd6f2;
|
||||
--side-hover:#e3e8f8; --side-hover-text:#1e2635;
|
||||
--side-active-bg:#e6ebfe; --side-active-text:var(--brand-dark);
|
||||
--side-bar:var(--brand); --side-scroll:#d3d9e3;
|
||||
}
|
||||
html.grad-off{--topbar-bg:var(--brand)}
|
||||
|
||||
/* ---- 侧栏与品牌色解耦(side-follow 关) ---- */
|
||||
html.side-plain.side-deep{
|
||||
--side-bg:#2c3444; --side-text:#c2cbd9; --side-icon:#909dae;
|
||||
--side-line:rgba(255,255,255,.08);
|
||||
--side-hover:rgba(255,255,255,.08); --side-hover-text:#fff;
|
||||
--side-active-bg:rgba(255,255,255,.16); --side-active-text:#fff;
|
||||
--side-bar:#fff; --side-scroll:rgba(255,255,255,.18);
|
||||
}
|
||||
html.side-plain.side-light{
|
||||
--side-bg:#f7f8fa; --side-text:#4a5568; --side-icon:#98a2b3;
|
||||
--side-line:#e6eaf0; --side-hover:#eff1f5;
|
||||
--side-active-bg:#eceef3; --side-active-text:#2f3a4c;
|
||||
--side-bar:#8b95a8; --side-scroll:#d3d9e3;
|
||||
}
|
||||
html.side-plain.grad-on.side-deep{--side-bg:linear-gradient(180deg,#333c4d 0%,#272e3c 100%)}
|
||||
html.side-plain.grad-on.side-light{--side-bg:linear-gradient(180deg,#fafbfc 0%,#f2f4f7 100%)}
|
||||
html.side-plain{--strip-bg:linear-gradient(180deg,#f2f4f8 0,#fff 100%)}
|
||||
|
||||
/* ---- 顶栏:.header-container ---- */
|
||||
.layout-wrap .header-container{background:var(--topbar-bg);box-shadow:0 1px 3px rgba(16,24,40,.14);transition:background .28s}
|
||||
.layout-wrap .header-container h4{font-weight:600;letter-spacing:.4px}
|
||||
.layout-wrap .header-container .delimiter{background:rgba(255,255,255,.28)}
|
||||
/* 顶栏 logo 徽标(模板新增节点) */
|
||||
.logo-badge{width:30px;height:30px;flex:0 0 auto;border-radius:9px;background:#fff;display:grid;place-items:center;box-shadow:0 2px 7px rgba(0,0,0,.16);margin-right:10px}
|
||||
.logo-badge svg,.logo-badge img{width:22px;height:22px;display:block}
|
||||
/* 用户胶囊 */
|
||||
.layout-wrap .header-aside{padding:0 8px 0 5px;height:36px;border-radius:999px;transition:background .16s}
|
||||
.layout-wrap .header-aside:hover{background:rgba(255,255,255,.16)}
|
||||
|
||||
/* ---- 侧栏:.left-menu(皮肤覆盖 layout.css 浅色档;深浅由 --side-* 驱动) ---- */
|
||||
.layout-wrap .left-menu{background:var(--side-bg);border-right:1px solid var(--side-line);color:var(--side-text);transition:background .28s}
|
||||
.layout-wrap .left-menu::-webkit-scrollbar{width:8px}
|
||||
.layout-wrap .left-menu::-webkit-scrollbar-thumb{background:var(--side-scroll);border-radius:8px}
|
||||
.layout-wrap .left-menu ul{padding:8px 0 18px}
|
||||
.layout-wrap .left-menu li{margin:1px 8px;border-radius:var(--r-s);transition:background .15s,color .15s}
|
||||
.layout-wrap .left-menu li a{
|
||||
display:block;height:40px;line-height:40px;padding:0 12px;border-radius:var(--r-s);
|
||||
color:var(--side-text);font-size:13px;border-right:none;transition:background .15s,color .15s;position:relative;
|
||||
}
|
||||
.layout-wrap .left-menu li a:hover{background:var(--side-hover);color:var(--side-hover-text)}
|
||||
.layout-wrap .left-menu li a:hover .iconfont,.layout-wrap .left-menu li:hover .iconfont{color:var(--side-hover-text)}
|
||||
.layout-wrap .left-menu li.active{background:var(--side-active-bg);border-right:0}
|
||||
.layout-wrap .left-menu li.active a{color:var(--side-active-text);font-weight:600;background:transparent;border-right:none}
|
||||
.layout-wrap .left-menu li.active a .iconfont{color:var(--side-active-text)}
|
||||
.layout-wrap .left-menu li.active::before{
|
||||
content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);
|
||||
width:3px;height:20px;border-radius:0 3px 3px 0;background:var(--side-bar);
|
||||
}
|
||||
.layout-wrap .left-menu li.child-item{margin:0 8px}
|
||||
.layout-wrap .left-menu li.child-item a{height:36px;line-height:36px;font-size:12.5px}
|
||||
.layout-wrap .left-menu li.child-item.active::before{height:16px}
|
||||
/* 菜单图标 mask 类(icons.css 用 background-color 上色,这里给品牌感近色) */
|
||||
.layout-wrap .left-menu .iconfont{margin-right:8px}
|
||||
.layout-wrap .left-menu .arrow-up,.layout-wrap .left-menu .arrow-down{border-color:var(--side-icon)}
|
||||
|
||||
/* ---- 面包屑:.path-nav → 浅色接缝条(方案 §7.3 融合站点身份带) ---- */
|
||||
.layout-wrap .path-nav{
|
||||
height:var(--crumb-h);background:var(--strip-bg);border-bottom:1px solid var(--line);
|
||||
color:var(--text-3);box-sizing:border-box;
|
||||
}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb-item-link{color:var(--text-2)}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb>span:last-child .ivu-breadcrumb-item-link,.layout-wrap .path-nav .ivu-breadcrumb>span:last-child{color:var(--text);font-weight:600}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb a:hover{color:var(--brand)}
|
||||
.layout-wrap .path-nav .crumb-home{color:var(--brand)}
|
||||
.layout-wrap .path-nav .ivu-breadcrumb-item-separator{color:#ccd4e0}
|
||||
/* 定稿:不再画独立 3px 横杠——顶部 3px 由接缝条承接顶栏渐变;子站让位身份橙 */
|
||||
.layout-wrap .path-nav::before{
|
||||
content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--topbar-bg);z-index:1;
|
||||
}
|
||||
.layout-wrap .path-nav.band-sub::before{background:#ff9900}
|
||||
.layout-wrap .path-nav::after{display:none}
|
||||
|
||||
/* ---- 内容区:灰底 + 白卡 ---- */
|
||||
.base-wrap-main{background:var(--page-bg)}
|
||||
.base-wrap-main .main-container{
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
|
||||
box-shadow:var(--sh);margin:0 18px 24px;padding:16px 20px 24px;box-sizing:border-box;
|
||||
}
|
||||
/* 栏目工作台左树卡 */
|
||||
.base-wrap-main .tree-container{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);box-shadow:var(--sh)}
|
||||
|
||||
/* ---- 搜索区分区化(卡片内 filterbar 语义) ---- */
|
||||
.base-wrap-main .search-wrap{
|
||||
background:#fbfcfe;border:1px solid var(--line-2);border-radius:var(--r-s);
|
||||
padding:10px 12px;height:auto;min-height:52px;box-sizing:border-box;margin-bottom:12px;
|
||||
}
|
||||
|
||||
/* ---- 表格/操作列皮肤(三蓝归一 #5979EA/#2d8cf0 → var(--brand)) ---- */
|
||||
.ivu-table-header th,.ivu-table-fixed-header th{background:#f7f9fc !important;color:var(--text-2)}
|
||||
.ivu-table td{border-bottom:1px solid var(--line-2)}
|
||||
.ivu-table-body tr:hover td,.ivu-table tbody tr:hover td{background:var(--brand-soft) !important}
|
||||
.base-wrap-main .ivu-table .operating-link{color:var(--brand)}
|
||||
.base-wrap-main .ivu-table .operating-link.disabled{color:#aaa}
|
||||
.ivu-table-cell .operating-btn:hover{background:var(--brand);color:#fff}
|
||||
|
||||
/* ---- view-ui-plus 品牌态 ---- */
|
||||
.ivu-btn-primary{background-color:var(--brand);border-color:var(--brand)}
|
||||
.ivu-btn-primary:hover,.ivu-btn-primary.active{background-color:var(--brand-dark);border-color:var(--brand-dark)}
|
||||
.ivu-btn-success{background-color:var(--green);border-color:var(--green)}
|
||||
.ivu-btn-success:hover,.ivu-btn-success.active{background-color:var(--green-d);border-color:var(--green-d)}
|
||||
.ivu-btn-error{background-color:var(--red);border-color:var(--red)}
|
||||
.ivu-btn-error:hover,.ivu-btn-error.active{background-color:#c93f36;border-color:#c93f36}
|
||||
.ivu-btn-text{color:var(--brand)}
|
||||
.ivu-btn-text:hover{color:var(--brand-dark)}
|
||||
.ivu-tabs-ink-bar{background-color:var(--brand)}
|
||||
.ivu-tabs-tab:hover,.ivu-tabs-tab-active{color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-item-active{background:var(--brand);border-color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-item-active a,.base-wrap-main .ivu-page-item-active a:hover{color:#fff !important}
|
||||
.base-wrap-main .ivu-page-item:hover{border-color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-item:hover a,.base-wrap-main .ivu-page-item:hover i{color:var(--brand)}
|
||||
.base-wrap-main .ivu-page-prev:hover i,.base-wrap-main .ivu-page-next:hover i{border-color:var(--brand);color:var(--brand)}
|
||||
.ivu-select-item-selected{color:var(--brand)}
|
||||
.ivu-select-item-selected:hover{background:var(--brand-soft)}
|
||||
.ivu-checkbox-checked .ivu-checkbox-inner{border-color:var(--brand);background-color:var(--brand)}
|
||||
.ivu-radio-checked .ivu-radio-inner{border-color:var(--brand);background-color:var(--brand)}
|
||||
.ivu-checkbox-wrapper:hover .ivu-checkbox-inner,.ivu-radio-wrapper:hover .ivu-radio-inner{border-color:var(--brand)}
|
||||
.ivu-input:focus,.ivu-input-focused,.ivu-select-visible .ivu-select-selection{border-color:var(--brand);box-shadow:0 0 0 2px var(--brand-a)}
|
||||
.ivu-input:hover,.ivu-select-selection:hover{border-color:#c6d0e2}
|
||||
.ivu-switch-checked{border-color:var(--brand);background-color:var(--brand)}
|
||||
.ivu-tree-title:hover{background-color:var(--brand-soft)}
|
||||
.ivu-tree-title-selected,a.ivu-tree-title-selected:hover{background-color:var(--brand-soft);color:var(--brand)}
|
||||
.ivu-spin-dot{background-color:var(--brand)}
|
||||
.ivu-modal-header-inner{font-weight:600;color:var(--text)}
|
||||
a{color:var(--brand)}
|
||||
a:hover{color:var(--brand-dark)}
|
||||
|
||||
/* ---- 滚动条(定稿全局) ---- */
|
||||
::-webkit-scrollbar{width:8px;height:8px}
|
||||
::-webkit-scrollbar-thumb{background:#c9d0dd;border-radius:8px}
|
||||
::-webkit-scrollbar-thumb:hover{background:#aeb8c9}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
|
||||
/* ---- 工作台/主题管理全屏页深色顶栏吃主题变量(方案 §7.3) ---- */
|
||||
.wb-header{background:var(--topbar-bg) !important}
|
||||
@@ -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"
|
||||
|
||||
@@ -9,8 +9,12 @@ import "view-ui-plus/dist/styles/viewuiplus.css";
|
||||
import "@/assets/styles/main.scss";
|
||||
import "@/assets/styles/layout.css";
|
||||
import "@/assets/styles/icons.css";
|
||||
import "@/assets/styles/yh-theme.css"; // 管理端主题层(生成物,须排在 layout/icons 之后)
|
||||
import directive from "@/utils/directive/index";
|
||||
import "virtual:uno.css";
|
||||
import { initTheme } from "@/utils/theme";
|
||||
|
||||
initTheme(); // 挂载前恢复配色档位,消除首帧闪色
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
|
||||
+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(); // 不抛错
|
||||
});
|
||||
});
|
||||
@@ -139,7 +139,10 @@ function buildRequest() {
|
||||
|
||||
async function save(): Promise<number | undefined> {
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) return undefined;
|
||||
if (!valid) {
|
||||
Message.warning("请填写红色星号内容!"); // bsch §4 统一提示
|
||||
return undefined;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
return await articleApi.save(buildRequest()).send();
|
||||
|
||||
@@ -116,6 +116,13 @@ function uploadDone(res: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function pageSizeUpdate(size: number) {
|
||||
pageSize.value = size;
|
||||
localStorage.setItem("customPageCount", String(size));
|
||||
pageNum.value = 1;
|
||||
run();
|
||||
}
|
||||
|
||||
onMounted(run);
|
||||
|
||||
// 站点上下文切换即时重载(docs/09 §3.3)
|
||||
@@ -128,7 +135,7 @@ watch(() => store.siteId, () => {
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="fix">
|
||||
<div class="fix mb-12">
|
||||
<Upload
|
||||
class="fl"
|
||||
:action="uploadUrl() + '?siteId=' + store.siteId"
|
||||
@@ -140,14 +147,15 @@ watch(() => store.siteId, () => {
|
||||
>
|
||||
<Button type="primary" size="large" icon="ios-cloud-upload-outline">上传附件</Button>
|
||||
</Upload>
|
||||
<Button class="fl" style="margin-left: 8px" size="large" @click="cleanOrphans">
|
||||
<Button class="fr" size="large" @click="cleanOrphans">
|
||||
未使用附件({{ unusedCount }})
|
||||
</Button>
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<Checkbox v-model="filter.orphanOnly" @on-change="pageNum = 1; run()">仅看未使用附件</Checkbox>
|
||||
<div class="search-wrap fix">
|
||||
<Checkbox class="fl" v-model="filter.orphanOnly" @on-change="pageNum = 1; run()">仅看未使用附件</Checkbox>
|
||||
</div>
|
||||
<Table
|
||||
class="link-table table-bottom-box"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:height="tableHeight"
|
||||
@@ -156,12 +164,12 @@ watch(() => store.siteId, () => {
|
||||
/>
|
||||
<div class="page-panel">
|
||||
<Page
|
||||
class="fr"
|
||||
class="page-box fr"
|
||||
:total="total"
|
||||
:current="pageNum"
|
||||
:page-size="pageSize"
|
||||
:page-size-opts="[10, 20, 30, 40]"
|
||||
@on-page-size-change="(s: number) => { pageSize = s; pageNum = 1; run(); }"
|
||||
@on-page-size-change="pageSizeUpdate"
|
||||
@on-change="(p: number) => { pageNum = p; run(); }"
|
||||
placement="top"
|
||||
show-total
|
||||
|
||||
@@ -148,7 +148,11 @@ function bannerUploaded(res: any) {
|
||||
|
||||
async function save() {
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) return;
|
||||
if (!valid) {
|
||||
// bsch 规范 §4:校验失败统一提示,不逐字段弹
|
||||
Message.warning("请填写红色星号内容!");
|
||||
return;
|
||||
}
|
||||
if (!form.imageUrl) {
|
||||
Message.warning("请上传轮播图片");
|
||||
return;
|
||||
@@ -200,17 +204,27 @@ watch(position, () => run());
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="fix">
|
||||
<RadioGroup v-model="position" class="fl position-switch" type="button">
|
||||
<Radio v-for="p in positionDict" :key="p.value" :label="p.value">{{ p.label }}</Radio>
|
||||
</RadioGroup>
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="openCreate">
|
||||
<Icon type="ios-add" />
|
||||
新增轮播
|
||||
</Button>
|
||||
<span class="size-tip fr">{{ positionTip }}</span>
|
||||
</div>
|
||||
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无轮播" />
|
||||
<!-- 展示位是筛选维度不是操作,按 bsch 规范归搜索区(左侧下拉) -->
|
||||
<div class="search-wrap fix">
|
||||
<Select v-model="position" class="search-wrap_sel">
|
||||
<Option v-for="p in positionDict" :key="p.value" :value="p.value">{{ p.label }}</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<Table
|
||||
class="link-table table-bottom-box"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:height="tableHeight"
|
||||
:loading="loading"
|
||||
no-data-text="暂无轮播"
|
||||
/>
|
||||
|
||||
<Modal v-model="modalVisible" :title="form.id ? '编辑轮播' : '新增轮播'" :mask-closable="false" width="560px">
|
||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="100">
|
||||
@@ -276,10 +290,6 @@ watch(position, () => run());
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.position-switch {
|
||||
margin-right: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.size-tip {
|
||||
color: #999;
|
||||
line-height: 40px;
|
||||
|
||||
@@ -239,7 +239,11 @@ function findCycleParent(): string {
|
||||
|
||||
async function save() {
|
||||
const valid = await refFormData.value?.validate();
|
||||
if (!valid) return;
|
||||
if (!valid) {
|
||||
// bsch 规范 §4:校验失败统一提示
|
||||
Message.warning("请填写红色星号内容!");
|
||||
return;
|
||||
}
|
||||
// 成环校验(下拉已不再置灰,这里给出可读原因;后端 CategoryService.validateParent 同口径兜底)
|
||||
const cycleHit = findCycleParent();
|
||||
if (cycleHit) {
|
||||
|
||||
@@ -127,7 +127,11 @@ function logoUploaded(res: any) {
|
||||
|
||||
async function save() {
|
||||
const valid = await formRef.value?.validate();
|
||||
if (!valid) return;
|
||||
if (!valid) {
|
||||
// bsch 规范 §4:校验失败统一提示
|
||||
Message.warning("请填写红色星号内容!");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await linkApi
|
||||
@@ -169,18 +173,21 @@ watch(() => store.siteId, () => run());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="tabs-wrapper">
|
||||
<!-- 分组即 tab(bsch 规范 §1:tabs-wrapper + Tabs type=card,与 svc-hall ServiceDictManage 同范式) -->
|
||||
<Tabs v-if="groups.length" type="card" :value="activeGroup || '全部'" @on-click="(g: string) => (activeGroup = g === '全部' ? '' : g)">
|
||||
<TabPane name="全部" label="全部" />
|
||||
<TabPane v-for="g in groups" :key="g" :name="g" :label="`${g}(${rows.filter(r => r.groupName === g).length})`" />
|
||||
</Tabs>
|
||||
<div class="main-container">
|
||||
<div class="fix">
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="openCreate">
|
||||
<Icon type="ios-add" />
|
||||
新增友链
|
||||
</Button>
|
||||
<Tabs v-if="groups.length" class="fr" :value="activeGroup" @on-click="(g: string) => (activeGroup = g)">
|
||||
<TabPane v-for="g in groups" :key="g" :name="g" :label="`${g}(${rows.filter(r => r.groupName === g).length})`" />
|
||||
</Tabs>
|
||||
</div>
|
||||
<Table
|
||||
class="link-table table-bottom-box"
|
||||
:columns="columns"
|
||||
:data="visibleRows"
|
||||
:height="tableHeight"
|
||||
|
||||
@@ -193,13 +193,13 @@ watch(() => store.currentSchool?.schId, () => run());
|
||||
<a href="javascript:void(0);" @click="goBackWorkbench">返回学校工作台</a>
|
||||
</Alert>
|
||||
</div>
|
||||
<div class="fix" v-if="canCreateSite">
|
||||
<div class="fix mb-12" v-if="canCreateSite">
|
||||
<Button class="fl" type="primary" size="large" @click="openCreate">
|
||||
<Icon type="ios-add" />
|
||||
{{ isSchoolAdmin ? "新建院系子站" : "新建站点" }}
|
||||
</Button>
|
||||
</div>
|
||||
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无站点" />
|
||||
<Table class="link-table table-bottom-box" :columns="columns" :data="rows" :height="tableHeight" :loading="loading" no-data-text="暂无站点" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -251,7 +251,10 @@ async function submitSection(key: string, patch: { configJson?: string }) {
|
||||
|
||||
async function saveBasic() {
|
||||
const valid = await basicRef.value?.validate();
|
||||
if (!valid) return;
|
||||
if (!valid) {
|
||||
Message.warning("请填写红色星号内容!"); // bsch §4 统一提示
|
||||
return;
|
||||
}
|
||||
await submitSection("basic", {});
|
||||
}
|
||||
|
||||
@@ -263,22 +266,34 @@ async function saveTheme() {
|
||||
}
|
||||
|
||||
async function saveDisplay() {
|
||||
if (!(await displayFormRef.value?.validate())) return;
|
||||
if (!(await displayFormRef.value?.validate())) {
|
||||
Message.warning("请填写红色星号内容!"); // bsch §4
|
||||
return;
|
||||
}
|
||||
await submitSection("display", {});
|
||||
}
|
||||
|
||||
async function saveFooter() {
|
||||
if (!(await footerFormRef.value?.validate())) return;
|
||||
if (!(await footerFormRef.value?.validate())) {
|
||||
Message.warning("请填写红色星号内容!"); // bsch §4
|
||||
return;
|
||||
}
|
||||
await submitSection("footer", {});
|
||||
}
|
||||
|
||||
async function saveSeo() {
|
||||
if (!(await seoFormRef.value?.validate())) return;
|
||||
if (!(await seoFormRef.value?.validate())) {
|
||||
Message.warning("请填写红色星号内容!"); // bsch §4
|
||||
return;
|
||||
}
|
||||
await submitSection("seo", {});
|
||||
}
|
||||
|
||||
async function saveExtras() {
|
||||
if (!(await extrasFormRef.value?.validate())) return;
|
||||
if (!(await extrasFormRef.value?.validate())) {
|
||||
Message.warning("请填写红色星号内容!"); // bsch §4
|
||||
return;
|
||||
}
|
||||
await submitSection("extras", {});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user