diff --git a/index.html b/index.html
index 6e8be96..67c84f2 100644
--- a/index.html
+++ b/index.html
@@ -4,6 +4,22 @@
+
+
<%- title %>
<%- injectScript %>
diff --git a/src/assets/image/yunhai-logo.svg b/src/assets/image/yunhai-logo.svg
new file mode 100644
index 0000000..7d1d358
--- /dev/null
+++ b/src/assets/image/yunhai-logo.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/styles/layout.css b/src/assets/styles/layout.css
deleted file mode 100644
index d1bde98..0000000
--- a/src/assets/styles/layout.css
+++ /dev/null
@@ -1,33 +0,0 @@
-.fl{float:left}.fr{float:right}.fix{*zoom:1}.fix:after{display:table;content:"";clear:both}.pr{position:relative}.pa{position:absolute}.pf{position:fixed}.align-middle{position:relative;top:50%;transform:translateY(-50%)}.text-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nav-btn-wrap{position:fixed;top:67px;right:15px;max-width:200px;z-index:100}*{font-family:"微软雅黑","Microsoft YaHei","Helvetica Neue",Helvetica,"PingFang SC","Hiragino Sans GB",Arial,sans-serif}.header-container{background:transparent;transition:all 300ms;box-shadow:0 10px 20px 0 rgba(203,203,203,0.09);height:56px;position:fixed;top:0;left:0;width:100%;z-index:5}.header-container .icon-box{margin:0 24px 0 24px;cursor:pointer;background-repeat:no-repeat;background-position:30px center;background-size:14px}.header-container .icon-btn{background:transparent;height:24px;width:24px;line-height:24px;box-sizing:border-box;border-radius:2px;transition:all 500ms}.header-container .icon-btn i{color:#fff;font-size:22px;font-weight:100}.header-container .icon-btn.active{transform:rotate(90deg)}.header-container .delimiter{width:2px;border-left:1px solid rgba(255,255,255,0.5);height:20px}.header-container h4{font-size:19px;color:#fff;margin-left:12px;font-weight:bold;font-family:SimSun}.header-container .school-bg{position:absolute;top:0;height:100%;width:100%;left:0;right:0;z-index:-1}.header-container .header-aside{max-width:260px;height:56px}.header-container .header-aside img{float:left;width:28px;height:28px;border-radius:50%;cursor:pointer}.header-container .header-aside .name{max-width:90px;color:#fff;margin:0 32px 0 14px}.header-container .header-aside a{outline:none}.header-container .header-aside a:hover{opacity:.8}.header-container .header-aside .logout{padding:5px;margin:0 25px 0}.header-container .header-aside .logout i{color:#fff;font-size:18px}.left-menu{position:fixed;background:#fff;color:#272829;width:200px;left:0;top:56px;bottom:0px;display:flex;flex-direction:column;z-index:9;overflow:auto}.left-menu .menu-top-box{height:10px;border-right:1px solid #EBEEF4}.left-menu li{height:50px;transition:border 200ms;border-left:0px solid transparent;position:relative}.left-menu li a{color:#272829;width:100%;display:block;padding-left:35px;line-height:50px;outline:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border-right:1px solid #EBEEF4;transition:none}.left-menu li.active{background:#f8f8f8;border-left:4px solid transparent;border-right:none}.left-menu li.active a{border-right:1px none;border-right:none}.left-menu li:hover{background:#f8f8f8}.left-menu li:hover a{border-right:none}.left-menu li .icon-box{line-height:1;height:20px;position:relative;top:25px;margin-top:-6px;padding-right:10px;font-size:14px;color:#656667}.left-menu .clear-box{flex:1;border-right:1px solid #EBEEF4}.path-nav{height:56px;width:calc(100% - 200px);position:relative;top:56px;left:200px;line-height:20px;font-size:12px;background:#fff;color:#969799;z-index:4;padding:16px 0 20px 24px}.path-nav .ivu-breadcrumb a{color:#848C98}.path-nav .ivu-breadcrumb a:hover{color:#6a727f}.path-nav .ivu-breadcrumb-item-link{font-size:14px}.path-nav .icon-category{color:#848C98;position:relative;top:1px}.app-panel{position:fixed;top:56px;margin-top:-6px;left:14px;background:#fff;z-index:10;transition:all 200ms;border-radius:8px;box-shadow:0 10px 20px 0 rgba(0,0,0,0.06);padding:20px 20px 0}.app-panel .tips-box{position:absolute;top:-5px;left:13px;width:0;height:0;border-right:5px solid transparent;border-bottom:7px solid #fff;border-left:5px solid transparent;z-index:1}.app-panel ul{padding-bottom:20px;border-bottom:1px solid #EBEEF4}.app-panel li{width:100px;margin-left:15px;text-align:center}.app-panel li a{display:block;color:#656667;padding:15px 0;border-radius:5px;transition:all 500ms;outline:none}.app-panel li a:hover{background:#eee}.app-panel li .icon-box{width:46px;height:46px;margin:0 auto 15px}.app-panel li .icon-box img{width:100%;height:100%;-o-object-fit:cover;object-fit:cover;border-radius:50%}.app-panel li:first-child{margin-left:0}.app-panel .link-panel{height:60px;line-height:60px}.app-panel .link-panel i{margin-left:8px;position:relative;top:-1px}.app-panel .no-used-box{min-width:300px;text-align:center;height:110px;border-bottom:1px dashed #EBEEF4;line-height:30px;padding:40px 0}.no-authority-wrap{width:600px;height:320px;background:#fff;border-radius:15px;text-align:center;left:50%;position:relative;top:50%;transform:translateX(-50%) translateY(-50%);color:#272829;background-image:url(../image/no-authority.png);background-repeat:no-repeat;background-size:280px 152px;background-position:center 50px;overflow:hidden}.no-authority-wrap h3{font-size:18px;font-weight:500;margin-top:220px}.no-authority-wrap h6{font-size:12px;font-weight:500;margin-top:10px}.arrow-down{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAGCAYAAAD68A/GAAAAjUlEQVQYV2MsKSnh/vj1q+Xs6dP3MGAB2dnZ8v/+/RNhrK+vZ3r24tUsBsb/22fPmLEWWW16errWfwaGXob/nEmMIAlsitPSss0ZGP9UMzMxxU+fPv09WCG6Yqb//z/9Y2BMY/wvET9rVsM3kDxcIbJixv//WRgYDFNnzUr/DTMIRSFMMYhubGz8h+xeANRcRMpSDyHXAAAAAElFTkSuQmCC);background-repeat:no-repeat;background-size:10px 6px;background-position:90% center;transition:all 400ms;width:10px;height:6px;position:absolute;right:8%;top:22px}.arrow-up{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAGCAYAAAD68A/GAAAAjUlEQVQYV2MsKSnh/vj1q+Xs6dP3MGAB2dnZ8v/+/RNhrK+vZ3r24tUsBsb/22fPmLEWWW16errWfwaGXob/nEmMIAlsitPSss0ZGP9UMzMxxU+fPv09WCG6Yqb//z/9Y2BMY/wvET9rVsM3kDxcIbJixv//WRgYDFNnzUr/DTMIRSFMMYhubGz8h+xeANRcRMpSDyHXAAAAAElFTkSuQmCC);background-repeat:no-repeat;background-size:10px 6px;background-position:90% center;transition:all 400ms;width:10px;height:6px;position:absolute;right:8%;top:22px;transform:rotate(180deg)}.child-item-wrap{transition:all 500ms linear;overflow:hidden}.child-item-wrap li.child-item{border-left:transparent}.child-item-wrap li.child-item a{margin-left:24px;padding-left:35px}.fade_apps-enter-active,.fade_apps-leave-active{transition:all 1ms;transform:translateY(5px)}.fade_apps-leave-to{opacity:0}.fade_child_ul-enter-active{transition:all .5s ease-in-out}.fade_child_ul-leave-active{transition:all .5s ease-out}.fade_child_ul-enter{max-height:0}.fade_child_ul-enter-to{max-height:100%}.fade_child_ul-leave{max-height:100%}.fade_child_ul-leave-to{max-height:0}.base-wrap-main{padding:56px 0 0 200px;height:calc(100% - 56px);position:relative;background:#fff}.base-wrap-main .main-wrapper{min-width:760px;min-height:100%;position:relative;width:100%;box-sizing:border-box}.base-wrap-main .main-container{background:#ffffff;box-sizing:border-box;padding:0 24px;padding-bottom:24px;overflow:hidden}.base-wrap-main .default-wrapper{min-width:760px;min-height:100%;position:relative;width:100%;box-sizing:border-box}.base-wrap-main .page-wrapper{min-width:760px;min-height:100%;position:relative;width:100%;box-sizing:border-box}.base-wrap-main .tree-wrapper{height:100%}.base-wrap-main .tree-wrapper .tree-container{width:300px;float:left;border:1px solid #eeeeee;background:#ffffff;padding:10px;overflow:auto;margin-left:24px;height:calc(100% - 20px);border-radius:3px}.base-wrap-main .tree-wrapper .main-container{height:100%;margin-left:264px;overflow:auto}.base-wrap-main .tree-wrapper .page-panel{left:288px}.base-wrap-main .page-panel{height:72px;position:absolute;margin:0 auto;left:24px;right:24px;bottom:0;padding:20px 0;background:#fff}.base-wrap-main .page-panel .ivu-page li:hover{border-color:#5979ea}.base-wrap-main .page-panel .ivu-page li:hover a,.base-wrap-main .page-panel .ivu-page li:hover i{color:#5979ea}.base-wrap-main .page-panel .ivu-page-item-active{background:#5979ea;border-color:#5979ea}.base-wrap-main .page-panel .ivu-page-item-active a,.base-wrap-main .page-panel .ivu-page-item-active a:hover{color:#fff !important}.base-wrap-main .page-panel .ivu-page-disabled{border-color:#dcdee2}.base-wrap-main .page-panel .ivu-page-options-sizer{margin-right:0}.base-wrap-main .page-panel::after{display:block;position:relative;content:'';background:#eee;height:1px;width:100%;position:absolute;top:0}.base-wrap-main .tabs-panel{margin:0 24px 12px}.base-wrap-main .tabs-panel.ivu-tabs.ivu-tabs-card>.ivu-tabs-bar .ivu-tabs-tab{border-radius:0;background:#fff;margin-right:-1px;border-color:#EBEEF5 !important;height:39px;line-height:28px}.base-wrap-main .tabs-panel.ivu-tabs.ivu-tabs-card>.ivu-tabs-bar .ivu-tabs-tab.ivu-tabs-tab-active{color:#5979EA !important;height:40px}.base-wrap-main .tabs-panel.ivu-tabs.ivu-tabs-card .ivu-tabs-bar{border-color:#EBEEF5 !important;margin-bottom:0}.base-wrap-main .tabs-panel.ivu-tabs.ivu-tabs-card>.ivu-tabs-bar.ivu-tabs-tab-active{border-top:1px solid #EBEEF5}.base-wrap-main .tabs-panel.ivu-tabs.ivu-tabs-card>.ivu-tabs-bar .ivu-tabs-nav-container{height:40px}.base-wrap-main .btn-panel{margin-left:28px}.base-wrap-main.alone-page{padding-left:0}.search-wrap{background:#f7f7f7;padding:10px 16px;height:52px}.search-wrap_date .ivu-date-picker{width:150px}.search-wrap_sel{width:150px;float:left}.search-wrap_input{width:200px;float:left}.public-custom-switch .ivu-radio-wrapper-checked{background:#6b8cf4 !important;border-color:#6b8cf4 !important}.public-custom-switch .ivu-radio-group-item{width:90px;text-align:center}.public-upload-img li{width:100px;height:100px;border:1px solid #eee;border-radius:2px;padding:4px;position:relative;float:left;margin:0 10px 10px 0}.public-upload-img li img{width:100%;height:100%;object-fit:contain}.public-upload-img li:hover .upload-list-cover{display:block}.public-upload-img li .upload-list-cover{position:absolute;top:4px;bottom:4px;left:4px;right:4px;background:rgba(0,0,0,0.6);text-align:center;display:none;transition:all .3s}.public-upload-img li .upload-list-cover i{color:#fff;position:relative;top:50%;cursor:pointer;transform:translateY(-50%)}.public-upload-img li.add-li{border:none;padding:0;width:100px;height:100px}.public-upload-img li .add-box i{line-height:100px}.ivu-btn{font-size:14px;line-height:20px}.ivu-btn span{margin:0 3px}.ivu-btn-large{height:36px}a.ivu-btn-large{line-height:36px}.ivu-btn-primary{border-color:#5979EA;background:#5979EA}.ivu-btn-primary:hover{background:#708bed;border-color:#708bed}.ivu-btn-primary:active{background:#2c54e4;border-color:#2c54e4}.ivu-btn-primary.ivu-btn-ghost{background-color:#fff;color:#5979EA;border-color:#5979EA}.ivu-btn-success{border-color:#45B972;background:#45B972}.ivu-btn-success:hover{background:#57c080;border-color:#57c080}.ivu-btn-success:active{background:#37945b;border-color:#37945b}.ivu-btn-success.ivu-btn-ghost{background-color:#fff;color:#45B972;border-color:#45B972}.ivu-btn-error{border-color:#ed4d4d;background:#ed4d4d}.ivu-btn-error:hover{background:#ef6464;border-color:#ef6464}.ivu-btn-error:active{background:#e81f1f;border-color:#e81f1f}.ivu-btn-error.ivu-btn-ghost{background-color:#fff;color:#ed4d4d;border-color:#ed4d4d}.ivu-input-search{border-color:#5979EA !important;background:#5979EA !important}.ivu-input-search:hover{background:#708bed !important;border-color:#708bed !important}.ivu-input-search:active{background:#2c54e4 !important;border-color:#2c54e4 !important}.ivu-input-search.ivu-btn-ghost{background-color:#fff !important;color:#5979EA !important;border-color:#5979EA !important}.ivu-table-wrapper{border:none}.ivu-table::after{background:none}.ivu-table::before{background:none}.ivu-table-header th,.ivu-table-fixed-header th{height:48px !important;color:#595959;font-size:14px;background:#fff;border:none}.ivu-table td{height:48px;border:none;border-top:1px solid #ecedf3}.ivu-table td span{font-size:14px;font-family:"Microsoft YaHei","微软雅黑" !important}.ivu-table td .ivu-icon{font-family:'Ionicons' !important}.ivu-tabs-bar{margin-bottom:10px;border-bottom:1px solid #EBEEF5}.ivu-tabs-bar .ivu-tabs-ink-bar{background:#5979EA}.ivu-tabs-bar .ivu-tabs-tab-active,.ivu-tabs-bar .ivu-tabs-tab:hover,.ivu-tabs-bar .ivu-tabs-tab:active,.ivu-tabs-bar .ivu-tabs-tab-focused{color:#5979EA}.ivu-form-item{margin-bottom:20px}.ivu-form-item-error-tip{font-size:13px;padding-top:4px}.ivu-alert{margin-bottom:12px !important}.ivu-modal-wrap{display:flex;align-items:center;transform:translateY(0%)}.ivu-modal-wrap .ivu-form-item:last-child{margin-bottom:0px}.ivu-modal{top:0 !important}.ivu-modal-body{max-height:550px;overflow:auto}.ivu-table-cell .operating-btn{width:32px;height:32px;position:relative;display:block;border-radius:50%;color:gray;font-size:18px;margin-top:2px;line-height:32px;cursor:pointer}.ivu-table-cell .operating-btn:hover{background:#9e89cf;color:#fff}.ivu-table-cell .operating-btn.disabled{color:#c5c8ce;opacity:.3;cursor:not-allowed}.ivu-table-cell .operating-btn.disabled:hover{background:#fff}.ivu-table-cell .operating-link{display:inline-block;color:#2d8cf0;cursor:pointer;padding-right:10px}.ivu-table-cell .operating-link.disabled{color:#aaa;cursor:default}.status-shenhe span::before{content:'';display:inline-block;width:10px;height:10px;border-radius:50%;margin:auto 10px auto 0px;background-color:#e8a54c}.status-tongguo span::before{content:'';display:inline-block;width:10px;height:10px;border-radius:50%;margin:auto 10px auto 0px;background-color:#45B972}.status-chexiao span::before{content:'';display:inline-block;width:10px;height:10px;border-radius:50%;margin:auto 10px auto 0px;background-color:#bfbfbf}.status-jujue span::before{content:'';display:inline-block;width:10px;height:10px;border-radius:50%;margin:auto 10px auto 0px;background-color:#ed4d4d}.header-container{background:#577afb}.header-container .school-bg{display:none}.left-menu{background:#F6F8F9}.left-menu li:hover{background:#EFF4FF}.left-menu li.active{background:#EFF4FF;border-left:none ;border-right:4px solid #577afb}
-/* ============================================================
- 菜单高亮闪烁修复(本地化后追加,优先级最高)
- 问题:原 .left-menu li 设了 transition:border 200ms,
- 切换 A→B 时 active 的 border-left(border-right) 在
- transparent/none 与 4px #577afb 之间做 200ms 动画,
- 导致离开的项左侧出现一闪而逝的淡蓝边框。
- 修复:仅过渡 background-color,边框变化即时生效;
- 明确 active 仅保留右侧 4px 蓝边、无左侧边框。
- ============================================================ */
-.left-menu li {
- border-left: 0 solid transparent;
- border-right: 0 solid transparent;
- transition: background-color 200ms;
-}
-.left-menu li a {
- border-right: 1px solid #EBEEF4;
- transition: none;
-}
-.left-menu li.active {
- background: #EFF4FF;
- border-left: 0;
- border-right: 4px solid #577afb;
-}
-.left-menu li.active a {
- border-right: none;
-}
-.left-menu li:hover {
- background: #EFF4FF;
-}
-.left-menu li:hover a {
- border-right: none;
-}
diff --git a/src/components/ThemeSwitcher.vue b/src/components/ThemeSwitcher.vue
new file mode 100644
index 0000000..9e769b9
--- /dev/null
+++ b/src/components/ThemeSwitcher.vue
@@ -0,0 +1,274 @@
+
+
+
+
+
+
+
+
主题色
+
+
+
+
+
侧栏样式
+
+
+
+
+
+
+
+
+
diff --git a/src/components/UserAvatar.vue b/src/components/UserAvatar.vue
new file mode 100644
index 0000000..aefb0b1
--- /dev/null
+++ b/src/components/UserAvatar.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+ {{ text }}
+
+
+
+
+
diff --git a/src/components/layout.vue b/src/components/layout.vue
index 4e8c143..c87a395 100644
--- a/src/components/layout.vue
+++ b/src/components/layout.vue
@@ -3,6 +3,9 @@ import { computed, ref } from "vue";
import { useRoute } from "vue-router";
import { LayoutData } from "@/types/Basics";
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 route = useRoute();
@@ -10,6 +13,15 @@ const route = useRoute();
const modList = computed(() => props.layoutData.modList || []);
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):网关热重载路由+目标快照,无须重启 */
const reloading = ref(false);
const lastReloadTime = ref("");
@@ -32,44 +44,94 @@ const reloadPage = inject("reload", () => {}) as () => void;
-
+
+
-
+
+
+
+
+
+
+
+ 首页
+
+
+ {{ layoutData.appName }}
+ {{ currTitle }}
+
+
+
diff --git a/src/main.ts b/src/main.ts
index 739285f..4095b97 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -15,8 +15,9 @@ import ViewUIPlus from "view-ui-plus";
import "view-ui-plus/dist/styles/viewuiplus.css";
import "@/assets/styles/main.scss";
-// 本地化的布局/主题样式(原远程 base.layout.beta / _iview_override / default-theme 合并)
-import "@/assets/styles/layout.css";
+// 布局/主题/图标(base.layout.css + iview.override.css + icons.css)由中央静态通道下发:
+// index.html 的 yh-theme-init.js 按 manifest 版本追加 ,天然排在本文件组件库样式之后
+//(本地 layout.css 快照已随之退役,删防双份漂移)
import "virtual:uno.css";
import "virtual:svg-icons-register";
diff --git a/src/utils/avatar.ts b/src/utils/avatar.ts
new file mode 100644
index 0000000..40d5bda
--- /dev/null
+++ b/src/utils/avatar.ts
@@ -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;
+}
diff --git a/src/utils/theme.ts b/src/utils/theme.ts
new file mode 100644
index 0000000..fbc7ceb
--- /dev/null
+++ b/src/utils/theme.ts
@@ -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 在 内先行挂好,本模块只负责读写与切换。
+ */
+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());
+}
diff --git a/vite.config.ts b/vite.config.ts
index bf14fdf..4463b07 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -95,6 +95,13 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
target: "http://base.yunhaixingtong.com",
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: "./",