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 @@ + + + + + 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; 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: "./",