feat(theme): 接入 YschTheme 中央静态通道(A 类 loader + sync 四件 + layout 骨架对齐)
This commit is contained in:
+16
@@ -4,6 +4,22 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- 主题通道引导(A 类免插件接入,照 Notice 配方):唯一接入点,不装依赖、无需 env-config。
|
||||||
|
origin 零写死,优先级:__APP_CONFIG__.staticHost > baseHost > %VITE_THEME_HOST%(仅 dev 可注入)
|
||||||
|
> 同源 "/static/…"(生产常态:通道与应用同域;本地 dev/mock 由 vite /static 代理兜到通道,
|
||||||
|
dev:mock 时 scripts/start-mock.mjs 把代理目标指到本地 9527,无 VPN 离线可跑)。
|
||||||
|
网关后端无 env-config 端点,__APP_CONFIG__ 恒为空 → 实际恒走后两档。
|
||||||
|
document.write 保证 parser-blocking:档位挂载与 FOUC 门先于首帧;build 不解析内联脚本字符串,
|
||||||
|
占位符原样留字面量 → 判首字符 % 置空,产物零环境值烧入。 -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var c = window.__APP_CONFIG__ || {};
|
||||||
|
var themeHost = "%VITE_THEME_HOST%";
|
||||||
|
if (themeHost.charAt(0) === "%") themeHost = "";
|
||||||
|
var host = String(c.staticHost || c.baseHost || themeHost).replace(/\/+$/, "");
|
||||||
|
document.write('<script src="' + host + '/static/js/yh-theme-init.js"><\/script>');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<title><%- title %></title>
|
<title><%- title %></title>
|
||||||
<%- injectScript %>
|
<%- injectScript %>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -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 |
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/* 默认头像三态组件(YschTheme 公共源,scripts/sync-apps.mjs 下发三端 src/components/UserAvatar.vue):
|
||||||
|
头像 URL 非空 → <img>;否则 style==="text" 且能取到字 → 首字;否则 → 剪影(纯 CSS/SVG,零资源文件)。
|
||||||
|
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
|
||||||
|
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
|
||||||
|
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 头像 URL:非空直接 <img>(三态第一优先) */
|
||||||
|
src?: string | null;
|
||||||
|
/** 取字来源(首字态用):经 pickAvatarText 判定,取不到字回落剪影 */
|
||||||
|
name?: string | null;
|
||||||
|
/** 直径 px;font-size = round(size * 0.44) */
|
||||||
|
size?: number;
|
||||||
|
/** 配色场景:light=页面浅底(品牌实底+白内容);brand=header 品牌色底(剪影 S1 玻璃底 / 首字 S2 白底反色,§10.3 分档) */
|
||||||
|
placement?: "light" | "brand";
|
||||||
|
}>(),
|
||||||
|
{ src: null, name: null, size: 28, placement: "light" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
|
||||||
|
const style = computed(() => resolveAvatarStyle());
|
||||||
|
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
|
||||||
|
const showText = computed(() => style.value === "text" && !!text.value);
|
||||||
|
|
||||||
|
const rootClass = computed(() => [
|
||||||
|
"yh-avatar",
|
||||||
|
props.placement === "brand" ? "yh-avatar--brand" : "yh-avatar--light",
|
||||||
|
{ "is-text": showText.value },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rootStyle = computed(() => ({
|
||||||
|
width: `${props.size}px`,
|
||||||
|
height: `${props.size}px`,
|
||||||
|
fontSize: `${Math.round(props.size * 0.44)}px`,
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="rootClass" :style="rootStyle">
|
||||||
|
<img v-if="src" class="yh-avatar__img" :src="src" alt="" />
|
||||||
|
<template v-else-if="showText">{{ text }}</template>
|
||||||
|
<svg v-else class="yh-avatar__sil" viewBox="0 0 1024 1024" aria-hidden="true">
|
||||||
|
<circle cx="512" cy="399" r="174" />
|
||||||
|
<ellipse cx="512" cy="946" rx="420" ry="240" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.yh-avatar {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.yh-avatar__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* 剪影 SVG:容器圆 overflow:hidden 裁切肩部溢出(禁用 clipPath);overflow:visible 放行 viewBox 外形状 */
|
||||||
|
.yh-avatar__sil {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
/* 页面内(浅底)——品牌实底 + 白内容 */
|
||||||
|
.yh-avatar--light {
|
||||||
|
background: var(--brand, #577afb);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
/* header(品牌色底)——与页面内反向:剪影档 = S1 半透明白,首字档 = S2 纯白底 + 品牌色字 */
|
||||||
|
.yh-avatar--brand {
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.yh-avatar--brand.is-text {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--brand, #577afb);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+84
-22
@@ -3,6 +3,9 @@ import { computed, ref } from "vue";
|
|||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { LayoutData } from "@/types/Basics";
|
import { LayoutData } from "@/types/Basics";
|
||||||
import Utils from "@/utils";
|
import Utils from "@/utils";
|
||||||
|
import config from "@/assets/config/host.config";
|
||||||
|
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
|
||||||
|
import UserAvatar from "@/components/UserAvatar.vue";
|
||||||
|
|
||||||
const props = defineProps<{ layoutData: LayoutData }>();
|
const props = defineProps<{ layoutData: LayoutData }>();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -10,6 +13,15 @@ const route = useRoute();
|
|||||||
const modList = computed(() => props.layoutData.modList || []);
|
const modList = computed(() => props.layoutData.modList || []);
|
||||||
const isActive = (m: any) => route.path === m.page;
|
const isActive = (m: any) => route.path === m.page;
|
||||||
|
|
||||||
|
/** 面包屑末段:当前路由 meta.title(三个治理页都带) */
|
||||||
|
const currTitle = computed(() => (route.meta as any)?.title || "");
|
||||||
|
|
||||||
|
/** mock 模式徽标:仅 --mode mock(VITE_IS_MOCK=true)时渲染,dev/线上恒不出现 */
|
||||||
|
const isMock = computed(() => !!config.isMock);
|
||||||
|
|
||||||
|
/** 顶栏操作者占位(网关无用户体系;三态头像取首字「网」) */
|
||||||
|
const opName = "网关治理";
|
||||||
|
|
||||||
/* 重载注册表(POST /api/internal/routes/refresh):网关热重载路由+目标快照,无须重启 */
|
/* 重载注册表(POST /api/internal/routes/refresh):网关热重载路由+目标快照,无须重启 */
|
||||||
const reloading = ref(false);
|
const reloading = ref(false);
|
||||||
const lastReloadTime = ref("");
|
const lastReloadTime = ref("");
|
||||||
@@ -32,44 +44,94 @@ const reloadPage = inject("reload", () => {}) as () => void;
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div class="layout-wrap">
|
||||||
<header class="header-container fix">
|
<header class="header-container fix">
|
||||||
<div class="icon-box fl">
|
<!-- 云海 logo 徽标(结构与 Notice 对齐,皮肤见通道 base.layout.css .logo-badge) -->
|
||||||
<h4 class="fl">{{ layoutData.appName }}</h4>
|
<span class="logo-badge fl"><img src="@/assets/image/yunhai-logo.svg" alt="云海" /></span>
|
||||||
</div>
|
<div class="delimiter fl align-middle"></div>
|
||||||
<div class="header-aside fr">
|
<h4 class="fl align-middle">{{ layoutData.appName }}</h4>
|
||||||
<span v-if="lastReloadTime" class="last-reload fl">最后更新 {{ lastReloadTime }}</span>
|
<div class="school-bg"></div>
|
||||||
<Button size="small" class="fl reload-btn" @click="reloadPage">手动刷新</Button>
|
<div class="header-tools fr">
|
||||||
<Button type="primary" size="small" class="fl reload-btn" :loading="reloading" @click="reloadRegistry">
|
<span v-if="isMock" class="mock-badge" title="当前为本地 mock 数据,重启 dev:mock 后还原">MOCK 数据</span>
|
||||||
|
<span v-if="lastReloadTime" class="last-reload">最后更新 {{ lastReloadTime }}</span>
|
||||||
|
<Button size="small" class="reload-btn" @click="reloadPage">手动刷新</Button>
|
||||||
|
<Button type="primary" size="small" class="reload-btn" :loading="reloading" @click="reloadRegistry">
|
||||||
重载注册表
|
重载注册表
|
||||||
</Button>
|
</Button>
|
||||||
<span class="name fr">网关治理</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="header-aside fr">
|
||||||
|
<UserAvatar class="fl align-middle" :name="opName" :size="28" placement="brand" />
|
||||||
|
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);">{{ opName }}</a>
|
||||||
|
</div>
|
||||||
|
<!-- 配色切换器(fr 顺序:DOM 后置即位于用户胶囊左侧) -->
|
||||||
|
<div class="theme-cap fr"><ThemeSwitcher /></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ul class="left-menu">
|
<div class="left-menu">
|
||||||
<li class="menu-top-box"></li>
|
<div class="menu-top-box"></div>
|
||||||
|
<ul>
|
||||||
<li v-for="m in modList" :key="m.page" :class="{ active: isActive(m) }">
|
<li v-for="m in modList" :key="m.page" :class="{ active: isActive(m) }">
|
||||||
<router-link :to="m.page">{{ m.title }}</router-link>
|
<router-link :to="m.page">{{ m.title }}</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li class="clear-box"></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
<div class="clear-box"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="path-nav">
|
||||||
|
<Breadcrumb separator=">">
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<span class="crumb-home">
|
||||||
|
<i class="iconfont icon-shouye"></i>
|
||||||
|
首页
|
||||||
|
</span>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbItem>{{ layoutData.appName }}</BreadcrumbItem>
|
||||||
|
<BreadcrumbItem v-if="currTitle">{{ currTitle }}</BreadcrumbItem>
|
||||||
|
</Breadcrumb>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.header-container .header-aside .name {
|
/* 仅覆盖/补充必要的动态样式,其余样式由中央静态通道的 base.layout.css(骨架 + 皮肤)提供 */
|
||||||
line-height: 56px;
|
.header-container .logo-badge {
|
||||||
font-size: 14px;
|
margin: 0 10px 0 0;
|
||||||
}
|
}
|
||||||
|
.theme-cap {
|
||||||
.header-container .header-aside .reload-btn {
|
height: var(--topbar-h);
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
/* 面包屑「首页」垂直居中:inline-flex 真居中并关掉骨架给老 iconfont 字体的基线补偿 */
|
||||||
|
.crumb-home {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.crumb-home .iconfont {
|
||||||
|
position: static;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
/* 顶栏操作区(重载按钮 + mock 徽标):皮肤把 header 改 flex 垂直居中,只留间距 */
|
||||||
|
.header-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
margin-top: 13px;
|
|
||||||
}
|
}
|
||||||
|
.header-tools .reload-btn {
|
||||||
.header-container .header-aside .last-reload {
|
margin: 0;
|
||||||
line-height: 56px;
|
}
|
||||||
|
.header-tools .last-reload {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #848c98;
|
color: rgba(255, 255, 255, 0.75);
|
||||||
margin-right: 12px;
|
}
|
||||||
|
.header-tools .mock-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 153, 0, 0.22);
|
||||||
|
border: 1px solid rgba(255, 153, 0, 0.65);
|
||||||
|
color: #ffd49a;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+3
-2
@@ -15,8 +15,9 @@ import ViewUIPlus from "view-ui-plus";
|
|||||||
import "view-ui-plus/dist/styles/viewuiplus.css";
|
import "view-ui-plus/dist/styles/viewuiplus.css";
|
||||||
|
|
||||||
import "@/assets/styles/main.scss";
|
import "@/assets/styles/main.scss";
|
||||||
// 本地化的布局/主题样式(原远程 base.layout.beta / _iview_override / default-theme 合并)
|
// 布局/主题/图标(base.layout.css + iview.override.css + icons.css)由中央静态通道下发:
|
||||||
import "@/assets/styles/layout.css";
|
// index.html 的 yh-theme-init.js 按 manifest 版本追加 <link>,天然排在本文件组件库样式之后
|
||||||
|
//(本地 layout.css 快照已随之退役,删防双份漂移)
|
||||||
|
|
||||||
import "virtual:uno.css";
|
import "virtual:uno.css";
|
||||||
import "virtual:svg-icons-register";
|
import "virtual:svg-icons-register";
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* 默认头像样式(首字 / 剪影)公共源(YschTheme,scripts/sync-apps.mjs 下发三端 src/utils/avatar.ts)。
|
||||||
|
* 取值链与主题同构(theme-core.js resolveEffectiveThemeState 的合成链写法):
|
||||||
|
* URL ?yh-avatar=text|silhouette(仅预览/排障覆盖:只影响读、绝不写回)
|
||||||
|
* > localStorage["yh-avatar"](用户自选,纯字符串)
|
||||||
|
* > localStorage["yh-avatar-sch-default"](学校默认,UserHome 登录后写入)
|
||||||
|
* > "text"(系统默认)
|
||||||
|
* 值域恒为 "text" | "silhouette",键缺失/值非法均静默回落下一级,不报错不阻塞渲染。
|
||||||
|
* 渲染三态与取字规则的唯一契约见 UserHome《设置页修复与默认头像落地方案》§10.1/§10.2。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AvatarStyle = "text" | "silhouette";
|
||||||
|
|
||||||
|
interface StorageLike {
|
||||||
|
getItem(k: string): string | null;
|
||||||
|
setItem(k: string, v: string): void;
|
||||||
|
removeItem(k: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AVATAR_STORAGE_KEY = "yh-avatar";
|
||||||
|
|
||||||
|
/** 学校默认头像存储键(UserHome 登录时写入;值格式与用户键相同,仅无"用户自选"语义) */
|
||||||
|
export const SCHOOL_DEFAULT_AVATAR_STORAGE_KEY = "yh-avatar-sch-default";
|
||||||
|
|
||||||
|
export const AVATAR_STYLES: readonly AvatarStyle[] = ["text", "silhouette"];
|
||||||
|
|
||||||
|
export const DEFAULT_AVATAR_STYLE: AvatarStyle = "text";
|
||||||
|
|
||||||
|
/** 值合法才采用(用户键/学校键同一判据;脏数据不生效) */
|
||||||
|
function readAvatarStyleOrNull(raw: string | null): AvatarStyle | null {
|
||||||
|
return raw === "text" || raw === "silhouette" ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL 覆盖参数(?yh-avatar=text|silhouette):非法值忽略,且只影响"读哪一档"、绝不写回 localStorage */
|
||||||
|
function readAvatarOverride(): AvatarStyle | null {
|
||||||
|
try {
|
||||||
|
const search = (globalThis.location && globalThis.location.search) || "";
|
||||||
|
const m = new RegExp("[?&]yh-avatar=([^&]*)").exec(search);
|
||||||
|
return m ? readAvatarStyleOrNull(decodeURIComponent(m[1])) : null;
|
||||||
|
} catch {
|
||||||
|
/* 无 location 环境(测试/SSR):无覆盖 */
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生效样式合成(与 resolveEffectiveThemeState 同构):
|
||||||
|
* URL 覆盖 > localStorage["yh-avatar"](用户自选)> localStorage["yh-avatar-sch-default"](学校默认)
|
||||||
|
* > DEFAULT_AVATAR_STYLE。
|
||||||
|
*/
|
||||||
|
export function resolveAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): AvatarStyle {
|
||||||
|
let style: AvatarStyle | null = null;
|
||||||
|
if (storage) {
|
||||||
|
style = readAvatarStyleOrNull(storage.getItem(AVATAR_STORAGE_KEY));
|
||||||
|
if (!style) style = readAvatarStyleOrNull(storage.getItem(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY));
|
||||||
|
}
|
||||||
|
if (!style) style = DEFAULT_AVATAR_STYLE;
|
||||||
|
return readAvatarOverride() || style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存用户自选(直写 localStorage,无响应式;调用方操作后自行刷新 UI) */
|
||||||
|
export function saveAvatarStyle(style: AvatarStyle, storage: StorageLike | undefined = globalThis.localStorage): void {
|
||||||
|
try {
|
||||||
|
storage?.setItem(AVATAR_STORAGE_KEY, style);
|
||||||
|
} catch {
|
||||||
|
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认) */
|
||||||
|
export function clearAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): void {
|
||||||
|
try {
|
||||||
|
storage?.removeItem(AVATAR_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
/* 隐私模式静默 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取字(顺序判定,先命中先返回):含 "@" 先截 "@" 前段(邮箱取首字母);
|
||||||
|
* 首字符必须用展开 [...s][0](禁用 charAt(0),避免代理对截半出乱码)。
|
||||||
|
* ASCII 字母转大写;纯数字回落 ""(剪影);符号/emoji/空白回落 ""(剪影);其余(CJK 等)原样。
|
||||||
|
*/
|
||||||
|
export function pickAvatarText(name: string | null | undefined): string {
|
||||||
|
let s = String(name ?? "");
|
||||||
|
const at = s.indexOf("@");
|
||||||
|
if (at > -1) s = s.slice(0, at);
|
||||||
|
const ch = [...s.trim()][0];
|
||||||
|
if (ch === undefined) return "";
|
||||||
|
if (/^[A-Za-z]$/.test(ch)) return ch.toUpperCase();
|
||||||
|
if (/^[0-9]$/.test(ch)) return "";
|
||||||
|
if (/^[\p{P}\p{S}\s]$/u.test(ch)) return "";
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20260930-1248-4e12e6d),勿手改。
|
||||||
|
* 分发方式:YschTheme `npm run sync` 拷进各端 src/utils/theme.ts(与 ThemeSwitcher.vue 配套,MD5 对齐报告)。
|
||||||
|
* 运行时档位类名由 /static/js/yh-theme-init.js 在 <head> 内先行挂好,本模块只负责读写与切换。
|
||||||
|
*/
|
||||||
|
export type ThemeName = "blue" | "ocean" | "ink" | "violet" | "teal";
|
||||||
|
|
||||||
|
/** 定稿默认档:blue + side-deep + grad-on + 侧栏跟随(D1 拍板 deep) */
|
||||||
|
export interface ThemeState {
|
||||||
|
theme: ThemeName;
|
||||||
|
/** 侧栏深色(false=浅色档) */
|
||||||
|
sideDeep: boolean;
|
||||||
|
/** 顶栏渐变(联动侧栏竖向渐变) */
|
||||||
|
gradOn: boolean;
|
||||||
|
/** 侧栏颜色跟随顶栏品牌色(false=中性灰 side-plain) */
|
||||||
|
sideFollow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StorageLike {
|
||||||
|
getItem(k: string): string | null;
|
||||||
|
setItem(k: string, v: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const THEME_NAMES: readonly ThemeName[] = ["blue", "ocean", "ink", "violet", "teal"];
|
||||||
|
|
||||||
|
export const THEME_STORAGE_KEY = "yh-theme";
|
||||||
|
|
||||||
|
/* 学校默认档存储键(UserHome 登录时写入;值格式与 yh-theme 完全相同,仅无"用户自选"语义) */
|
||||||
|
export const SCHOOL_DEFAULT_STORAGE_KEY = "yh-theme-sch-default";
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 严格读取:四字段全部合法才返回状态,否则 null(合成链"值非法→回落下一级"用;用户键仍走宽松解析保现状) */
|
||||||
|
function readThemeStateOrNull(raw: string | null): ThemeState | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(raw);
|
||||||
|
if (!o || typeof o !== "object") return null;
|
||||||
|
if (!THEME_NAMES.includes(o.theme)) return null;
|
||||||
|
if (typeof o.sideDeep !== "boolean" || typeof o.gradOn !== "boolean" || typeof o.sideFollow !== "boolean") return null;
|
||||||
|
return { theme: o.theme, sideDeep: o.sideDeep, gradOn: o.gradOn, sideFollow: o.sideFollow };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL 覆盖参数(?yh-theme= / ?yh-side=deep|light / ?yh-grad=on|off):仅预览/排障用,
|
||||||
|
非法值忽略(对应字段不覆盖),且只影响"读哪一档"、绝不写回 localStorage。 */
|
||||||
|
function readThemeOverrides(): { theme?: ThemeName; sideDeep?: boolean; gradOn?: boolean } | null {
|
||||||
|
let out = null;
|
||||||
|
try {
|
||||||
|
const search = (globalThis.location && globalThis.location.search) || "";
|
||||||
|
const get = (name) => {
|
||||||
|
const m = new RegExp("[?&]" + name + "=([^&]*)").exec(search);
|
||||||
|
return m ? decodeURIComponent(m[1]) : null;
|
||||||
|
};
|
||||||
|
const t = get("yh-theme");
|
||||||
|
if (t && THEME_NAMES.includes(t)) {
|
||||||
|
out = out || {};
|
||||||
|
out.theme = t;
|
||||||
|
}
|
||||||
|
const s = get("yh-side");
|
||||||
|
if (s === "deep" || s === "light") {
|
||||||
|
out = out || {};
|
||||||
|
out.sideDeep = s === "deep";
|
||||||
|
}
|
||||||
|
const g = get("yh-grad");
|
||||||
|
if (g === "on" || g === "off") {
|
||||||
|
out = out || {};
|
||||||
|
out.gradOn = g === "on";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 无 location 环境(测试/SSR):无覆盖 */
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生效档合成(唯一实现,init 启动段与 UserHome theme.ts 共用):
|
||||||
|
* URL 覆盖 > localStorage["yh-theme"](用户自选,存在即用——宽松修补,与历史行为一致)
|
||||||
|
* > localStorage["yh-theme-sch-default"](学校默认,整体合法才生效)
|
||||||
|
* > DEFAULT_THEME_STATE。
|
||||||
|
* 键不存在/解析失败/值非法均静默回落,不报错不阻塞首帧。
|
||||||
|
*/
|
||||||
|
export function resolveEffectiveThemeState(storage: StorageLike | undefined = globalThis.localStorage): ThemeState {
|
||||||
|
let state = null;
|
||||||
|
if (storage) {
|
||||||
|
const userRaw = storage.getItem(THEME_STORAGE_KEY);
|
||||||
|
if (userRaw) state = parseThemeState(userRaw);
|
||||||
|
if (!state) state = readThemeStateOrNull(storage.getItem(SCHOOL_DEFAULT_STORAGE_KEY));
|
||||||
|
}
|
||||||
|
if (!state) state = { ...DEFAULT_THEME_STATE };
|
||||||
|
const ov = readThemeOverrides();
|
||||||
|
if (ov) {
|
||||||
|
if (ov.theme) state = { ...state, theme: ov.theme };
|
||||||
|
if (ov.sideDeep != null) state = { ...state, sideDeep: ov.sideDeep };
|
||||||
|
if (ov.gradOn != null) state = { ...state, gradOn: ov.gradOn };
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(或本仓 init 脚本的启动段)于挂载前调用,消除首帧闪色 */
|
||||||
|
export function initTheme(): ThemeState {
|
||||||
|
return applyThemeState(loadThemeState());
|
||||||
|
}
|
||||||
@@ -95,6 +95,13 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
|||||||
target: "http://base.yunhaixingtong.com",
|
target: "http://base.yunhaixingtong.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
// 主题通道本地联调(照 Notice 配方):index.html loader 走同源相对 /static/js/yh-theme-init.js,
|
||||||
|
// manifest/CSS 经此代理到中央通道;base 域不可达(无 VPN)时由 npm run dev:mock
|
||||||
|
// (scripts/start-mock.mjs)以 YH_THEME_PROXY_TARGET 把目标覆盖为本地 9527 静态服务。
|
||||||
|
"/static": {
|
||||||
|
target: process.env.YH_THEME_PROXY_TARGET || "http://base.yunhaixingtong.com",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
base: "./",
|
base: "./",
|
||||||
|
|||||||
Reference in New Issue
Block a user