feat(theme): 接入云海子主题通道(A 类免插件,规范 v1.12 §2/§3)

本次同时包含会话前遗留的本地未提交改动(作业模块 M5 相关页面/接口/样式调整、
host.config 的 SSO 兜底推导、.gitignore 等),按团队确认一并提交。

主题通道部分:
- index.html 插 A 类 loader:唯一接入点,FOUC 门/manifest 预取/主题 link 追加与排序
  全由通道 yh-theme-init.js 承担,主题升级走 /static 通道,本应用无需重新构建
- vite.config 加 /static dev 代理(YH_THEME_PROXY_TARGET 可覆盖,默认线上通道)
- host.config 回写 __APP_CONFIG__.serviceHost(仅同源):init 的 appearance 请求有硬性
  同源限制,跨源会被直接跳过;本地 dev 保持空串走同源 /api 代理
- 顶栏头像接平台键 yh-avatar,监听 yh-appearance-updated,userInfo 兜底
- view-ui-plus 声明锁 1.3.24(对齐通道皮肤生成版本)
- .gitea/workflows/build.yml:CI 平台统一更新(构建容器名唯一化 + npm 走 npmmirror)
This commit is contained in:
lijing
2026-10-21 17:38:11 +08:00
parent 70bd3a247f
commit bb7a832290
32 changed files with 2294 additions and 740 deletions
+139 -21
View File
@@ -1,14 +1,36 @@
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from "vue";
import { ref, reactive, computed, onMounted, watch, provide } from "vue";
import layout from "./components/layout.vue";
import { useRouter } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import config from "@/assets/config/host.config";
import { baseApi } from "@/api/module/homework";
import { resolveMenuPage, dropDroppedModules } from "@/utils/menuRoute";
import { dropDroppedModules, normalizeModList, flattenMenuNodes, firstMenuPath } from "@/utils/menuRoute";
import routes from "@/router/routes";
import { Message } from "view-ui-plus";
const router = useRouter();
const route = useRoute();
const isLogin = ref(false);
/**
* 视图重挂载信号(配合下方 router-view 的 :key)。
*
* 解决的问题:路由表里有多组**共用同一组件**的路由 ——
* /list/detail ↔ /homeworklist/detail 都是 HomeworkDetails.vue
* /stu/today ↔ /stu/redo 都是 StudentHomeworks.vue
* Vue 遇到「同一组件」会复用实例:onMounted 不再执行,按 meta 只算一次的状态
* (学生端「待订正」默认落 todo 标签)也不会重算,
* 于是表现为「点左边栏右边没反应,多点几下/刷新才变」。
* 加 key 后每个路由 path 各持有一份实例,切换必然重建。
*
* 顺带把「点击当前菜单项」从 router.go(0)(整页 reload、重新拉身份与菜单、白屏一下)
* 换成自增 tick,只软重挂载内容区。
*/
const viewTick = ref(0);
provide("refreshView", () => {
viewTick.value += 1;
});
const viewKey = computed(() => `${route.fullPath}#${viewTick.value}`);
const layoutData = reactive({
appName: "课程作业",
baseHost: config.baseHost,
@@ -24,13 +46,92 @@ function roleOfPath(p: string): "teacher" | "student" {
return "teacher";
}
/* ============================================================================
* 路由权限:左侧菜单管「入口」,不管「点进去之后」
* ----------------------------------------------------------------------------
* 两类页面,两套口径:
*
* ① **入口页**(左侧菜单里的节点:我布置的 / 全部作业 / 作业统计 / 今日作业…)
* → 必须配在 GetUserMod 里才放行。菜单就是入口清单,没配的入口不该被 URL
* 直接打开(否则左边栏一片失选,用户以为系统坏了)。
*
* ② **落地页**(从页面上点进去的二级目录:发作业 / 提交情况 / 批改中心 /
* 作业详情 / 学生作业详情)→ **一律放行,不判任何权限**。它们不是目录,
* 左侧栏只负责高亮「是从哪个入口节点点进来的」。
*
* 落地页的判定不看"它在没在菜单里",而是看路由表里是否声明了 meta.parentId
* —— 这是"我从哪个入口进来"的显式契约。**踩过的坑**:/submissions(提交情况)
* 既能从左侧菜单进、也能从作业列表行操作进,若没声明 parentId,就会被当成
* ① 类入口去判权限;真实环境账号菜单里常常没配这个节点 → 点按钮直接被弹回
* 第一个节点(用户报"这个按钮不让点")。凡是能从页面内点进去的路由,
* 必须在 routes.ts 声明 meta.parentId。
* ========================================================================== */
/** 菜单是否已就绪。加载期间一律放行,等菜单到达后由下方 watch 复核,
* 否则首屏会因为"菜单还没到"把正常页面误判成无权 */
const menuReady = ref(false);
const normalizedMenu = computed(() => normalizeModList(layoutData.modList));
const menuNodes = computed(() => flattenMenuNodes(normalizedMenu.value));
/** 当前用户可访问的 route.path 集合:菜单入口 ∪ 全部落地页 */
const allowedPaths = computed(() => {
const allow = new Set<string>();
// ① 入口页:菜单里配了才放行
menuNodes.value.forEach((n: any) => {
if (n.page) allow.add(n.page);
});
// ② 落地页:声明了 meta.parentId 的一律放行(点进去的二级目录不判权限)
routes.forEach((r: any) => {
if (r.redirect) return; // 别名由 vue-router 先解析到真实路由,守卫看到的是目标 path
if ((r.meta as any)?.parentId != null) allow.add(r.path);
});
return allow;
});
/** 无权/无匹配时落地的第一个菜单页面(与 initShell 的登录落地规则一致) */
const firstPath = computed(() => firstMenuPath(normalizedMenu.value));
/** 菜单为空视为"降级/加载失败",不拦路由,避免把用户困在死循环重定向里 */
const guardActive = computed(() => menuReady.value && allowedPaths.value.size > 0);
const routeAllowed = computed(() => !guardActive.value || allowedPaths.value.has(route.path));
router.beforeEach((to) => {
if (!guardActive.value) return true;
if (allowedPaths.value.has(to.path)) return true;
const first = firstPath.value;
if (!first || first === to.path) return false; // 没有可用目标:直接不渲染,好过显示无权页
Message.warning(`当前账号没有「${(to.meta as any)?.title || to.path}」的权限,已回到第一个可用页面`);
return { path: first, replace: true };
});
/* 首屏 URL 直达无权页的场景:守卫在菜单未就绪时放行了,菜单到达后必须复核并纠正 */
watch(guardActive, () => {
if (!guardActive.value) return;
if (routeAllowed.value) return;
const first = firstPath.value;
if (first && first !== route.path) router.replace(first);
});
async function initShell() {
/* ★ 必须先等 router 的首次导航解析完成。
main.ts 里是同步 app.mount(),App 的 onMounted 有机会早于 vue-router 的
初始导航 —— 此时 router.currentRoute 还停在 START_LOCATION(path === "/"),
于是「深链接」会被整片误判成"访问应用入口":
· 冷启动 /#/stats、/#/list/new、/#/list/detail 一律被 replace 到第一个节点;
· 学生端 URL(/stu/*)的 role 也会被判成教师端,导致视角与菜单都错。
等 isReady 之后再读 route,上述判断才基于真实目标。 */
await router.isReady();
const role = roleOfPath(router.currentRoute.value.path);
try {
// 端分离:先切换 mock 当前视角,再拉身份与菜单
await baseApi.setRole(role).send();
} catch {
/* 忽略:非 mock 环境可能无此接口 */
menuReady.value = false; // 重新拉菜单期间不拦路由,避免用上一个角色的菜单误判
if (config.isMock) {
// 端分离:仅 mock 后端有此端点(切换教师/学生视角),真后端不存在,不发起请求
try {
await baseApi.setRole(role).send();
} catch {
/* mock 下该端点不应失败,此处仅兜底 */
}
}
try {
// 1. 获取当前用户信息
@@ -59,19 +160,11 @@ async function initShell() {
const hasChildren = (modList || []).some((m: any) => m.children && m.children.length > 0);
layoutData.isAlone = (modList || []).length <= 1 && !hasChildren;
menuReady.value = true;
isLogin.value = true;
// 登录后跳转第一个可用模块("/" 本身即已收到页,无需跳转)
if (router.currentRoute.value.path === "/") {
const firstModule = (modList || [])[0];
const firstRaw = firstModule?.children?.length
? firstModule.children[0]
: firstModule;
const first = resolveMenuPage(firstRaw?.page, firstRaw?.modId ?? firstRaw?.id, firstRaw?.name);
if (!first.external && first.path && first.path !== "/") {
router.replace(first.path);
}
}
// 注:不再有「path === "/" 就跳第一个节点」的分支 —— 入口地址由路由表的
// "/" → "/list" 重定向处理(router.isReady 之前读 currentRoute 会踩上面那个坑),
// 「当前路由没有对应菜单节点 → 落第一个节点」统一交给守卫与上方复核处理。
} catch (error: any) {
console.error("加载用户信息失败", error);
if (error?.message !== "redirect") {
@@ -81,6 +174,7 @@ async function initShell() {
layoutData.userInfo = { name: "管理员", avatar: "" };
layoutData.modList = [];
layoutData.isAlone = true;
menuReady.value = true;
isLogin.value = true;
}
}
@@ -100,7 +194,11 @@ watch(
<layout v-if="isLogin" :layoutData="layoutData" />
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
<div class="main-wrapper">
<router-view />
<!-- routeAllowed:当前账号菜单里没有该路由时不渲染内容(守卫已拦,这里兜住
「菜单晚于路由到达」的瞬间,避免无权页闪一下再被 replace 掉) -->
<router-view v-if="routeAllowed" v-slot="{ Component }">
<component :is="Component" :key="viewKey" />
</router-view>
</div>
</div>
</template>
@@ -129,4 +227,24 @@ body {
vertical-align: middle;
margin: 0 2px;
}
/*
* 表格横向滚动条(对齐宿舍通知):列多的后台表格**就该横向滚动**,不要去压列宽收进一屏。
* 浏览器默认的粗灰条又厚又灰,正好贴在分页条上方,看着像「表格压住了分页」;
* 收成 8px 浅灰圆角、轨道透明,与整页观感一致,也不再挤占表体高度(高度已在
* calcTableHeight 里预留 15px)。
*/
.ivu-table-overflowX::-webkit-scrollbar {
height: 8px;
}
.ivu-table-overflowX::-webkit-scrollbar-thumb {
background: #dcdee2;
border-radius: 4px;
}
.ivu-table-overflowX::-webkit-scrollbar-thumb:hover {
background: #c5c8ce;
}
.ivu-table-overflowX::-webkit-scrollbar-track {
background: transparent;
}
</style>