Files
coursehomework-front/src/App.vue
T
lijing bb7a832290 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)
2026-10-21 17:38:11 +08:00

250 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch, provide } from "vue";
import layout from "./components/layout.vue";
import { useRoute, useRouter } from "vue-router";
import config from "@/assets/config/host.config";
import { baseApi } from "@/api/module/homework";
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,
ssoHost: config.ssoHost,
appList: [] as any[],
userInfo: null as any,
modList: [] as any[],
isAlone: false,
});
function roleOfPath(p: string): "teacher" | "student" {
if (p.startsWith("/stu")) return "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);
menuReady.value = false; // 重新拉菜单期间不拦路由,避免用上一个角色的菜单误判
if (config.isMock) {
// 端分离:仅 mock 后端有此端点(切换教师/学生视角),真后端不存在,不发起请求
try {
await baseApi.setRole(role).send();
} catch {
/* mock 下该端点不应失败,此处仅兜底 */
}
}
try {
// 1. 获取当前用户信息
const user: any = await baseApi.getUserInfo().send();
const { id, name, profilePicture } = user || {};
// 2. 获取当前用户已授权的模块列表(剔除已下线的审批模块,避免死菜单)
const modList: any[] = dropDroppedModules(await baseApi.getUserMod().send());
layoutData.userInfo = { id, name: name || "管理员", avatar: profilePicture || "" };
layoutData.modList = modList || [];
// 开发环境打印平台原始模块配置,便于排查菜单路径解析问题
if (import.meta.env.DEV) {
console.table(
(modList || []).map((m: any) => ({
name: m.name,
id: m.id,
modId: m.modId,
path: m.page,
}))
);
}
// 有子菜单时不算「单模块独占」模式
const hasChildren = (modList || []).some((m: any) => m.children && m.children.length > 0);
layoutData.isAlone = (modList || []).length <= 1 && !hasChildren;
menuReady.value = true;
isLogin.value = true;
// 注:不再有「path === "/" 就跳第一个节点」的分支 —— 入口地址由路由表的
// "/" → "/list" 重定向处理(router.isReady 之前读 currentRoute 会踩上面那个坑),
// 「当前路由没有对应菜单节点 → 落第一个节点」统一交给守卫与上方复核处理。
} catch (error: any) {
console.error("加载用户信息失败", error);
if (error?.message !== "redirect") {
Message.error("载入用户信息失败,请检查后端服务是否正常!");
}
// 降级:使用默认值("/" 即已收到页,无需跳转)
layoutData.userInfo = { name: "管理员", avatar: "" };
layoutData.modList = [];
layoutData.isAlone = true;
menuReady.value = true;
isLogin.value = true;
}
}
onMounted(initShell);
// 端分离:路由跨角色前缀(/stu、/par、其它)切换时,重新设定视角并初始化壳层
watch(
() => router.currentRoute.value.path,
(p, prev) => {
if (roleOfPath(p) !== roleOfPath(prev || "")) initShell();
}
);
</script>
<template>
<layout v-if="isLogin" :layoutData="layoutData" />
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
<div class="main-wrapper">
<!-- routeAllowed:当前账号菜单里没有该路由时不渲染内容(守卫已拦,这里兜住
「菜单晚于路由到达」的瞬间,避免无权页闪一下再被 replace 掉) -->
<router-view v-if="routeAllowed" v-slot="{ Component }">
<component :is="Component" :key="viewKey" />
</router-view>
</div>
</div>
</template>
<style lang="scss">
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
#app {
height: 100%;
}
/* 页面级滚动容器:main-wrapper 只有 min-height 时内容超一屏会被 body 的
overflow:hidden 直接裁掉(如发作业页),无任何滚动条。定高 + 纵向滚动,
放得下的列表页(表格高度由 Utils.useTableHeight 算好)不受影响 */
.base-wrap-main .main-wrapper {
height: 100%;
overflow-y: auto;
}
/* 表格操作列(operatingHandle 渲染的 Tooltip 包裹层)横排且随单元格居中 */
.ivu-table-cell > .ivu-tooltip {
display: inline-block;
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>