新增隐藏的官网赛事信息后台路由和编辑页面。支持从 Excel 拖拽或选择导入草稿、下载完整示例及当前数据,维护中英文赛事信息、公告、当前/检录场次、赛程与发布状态;通过已有 /api/website-competition 接口加载和保存,失败时提供重试与提示。 新增 Excel 工作簿生成与解析:覆盖赛事设置、赛程、全能/挑战/Battle 成绩、选手和纪录,并校验表头、编号及淘汰赛数据;兼容旧模板和 Excel 时间单元格。新增接口响应解包及工作簿往返、错误场景测试。 验证:node scripts/website-competition-response-test.mjs、node scripts/website-competition-workbook-test.mjs、npm run build:local 与 git diff --cached --check 均通过。此提交只含前端;运行时依赖后端 /api/website-competition 及其服务端密钥配置。
454 lines
14 KiB
TypeScript
454 lines
14 KiB
TypeScript
import { RouteRecordRaw } from 'vue-router';
|
||
import { useUserInfo } from '/@/stores/userInfo';
|
||
// import { useRequestOldRoutes } from '/@/stores/requestOldRoutes';
|
||
import pinia from '/@/stores/index';
|
||
import { Session } from '/@/utils/storage';
|
||
import { NextLoading } from '/@/utils/loading';
|
||
import { dynamicRoutes, notFoundAndNoPower } from '/@/router/route';
|
||
import { formatTwoStageRoutes, formatFlatteningRoutes, router } from '/@/router/index';
|
||
import { useRoutesList } from '/@/stores/routesList';
|
||
import { useTagsViewRoutes } from '/@/stores/tagsViewRoutes';
|
||
import SysMenuApi from '/@/api/SysMenuApi';
|
||
import { accessTokenKey } from '/@/utils/request';
|
||
|
||
// 后端控制路由
|
||
|
||
/**
|
||
* 获取目录下的 .vue、.tsx 全部文件
|
||
* @method import.meta.glob
|
||
* @link 参考:https://cn.vitejs.dev/guide/features.html#json
|
||
*/
|
||
const layouModules: any = import.meta.glob('../layout/routerView/*.{vue,tsx}');
|
||
const viewsModules: any = import.meta.glob('../views/**/*.{vue,tsx}');
|
||
const dynamicViewsModules: Record<string, Function> = Object.assign({}, { ...layouModules }, { ...viewsModules });
|
||
|
||
/**
|
||
* 后端控制路由:初始化方法,防止刷新时路由丢失
|
||
* @method NextLoading 界面 loading 动画开始执行
|
||
* @method useUserInfo().setUserInfos() 触发初始化用户信息 pinia
|
||
* @method useRequestOldRoutes().setRequestOldRoutes() 存储接口原始路由(未处理component),根据需求选择使用
|
||
* @method setAddRoute 添加动态路由
|
||
* @method setFilterMenuAndCacheTagsViewRoutes 设置路由到 pinia routesList 中(已处理成多级嵌套路由)及缓存多级嵌套数组处理后的一维数组
|
||
*/
|
||
export async function initBackEndControlRoutes() {
|
||
// 界面 loading 动画开始执行
|
||
if (window.nextLoading === undefined) NextLoading.start();
|
||
// 无 token 停止执行下一步
|
||
if (!Session.get(accessTokenKey)) return false;
|
||
// 触发初始化用户信息 pinia
|
||
// https://gitee.com/lyt-top/vue-next-admin/issues/I5F1HP
|
||
await useUserInfo().getUserInfo();
|
||
// 获取路由菜单数据
|
||
const { data } = await getBackEndControlRoutes();
|
||
// 无登录权限时,添加判断
|
||
// https://gitee.com/lyt-top/vue-next-admin/issues/I64HVO
|
||
const routeList = data ?? [];
|
||
if (routeList.length === 0) return Promise.resolve(true);
|
||
// 存储接口原始路由(未处理component),根据需求选择使用
|
||
// useRequestOldRoutes().setRequestOldRoutes(JSON.parse(JSON.stringify(data ?? [])));
|
||
// 处理路由(component),替换 dynamicRoutes(/@/router/route)第一个顶级 children 的路由
|
||
|
||
//静态路由
|
||
let staticRoute = [
|
||
{
|
||
path: '/website-competition',
|
||
name: 'websiteCompetition',
|
||
component: 'website/competition/index',
|
||
meta: {
|
||
title: '官网赛事信息',
|
||
isHide: true,
|
||
isKeepAlive: false,
|
||
icon: 'iconfont icon-shouye',
|
||
},
|
||
},
|
||
{
|
||
path: '/personal',
|
||
name: 'personal',
|
||
component: 'personal/index',
|
||
meta: {
|
||
title: '个人中心',
|
||
isHide: true,
|
||
isKeepAlive: true,
|
||
},
|
||
},
|
||
{
|
||
path: '/system/config/design',
|
||
name: 'configDesign',
|
||
component: 'system/config/design',
|
||
meta: {
|
||
title: '配置设计',
|
||
isKeepAlive: false,
|
||
isHide: true,
|
||
},
|
||
},
|
||
{
|
||
path: '/system/config/render',
|
||
name: 'configRender',
|
||
component: 'system/config/render',
|
||
meta: {
|
||
title: '编辑配置项',
|
||
isKeepAlive: false,
|
||
isHide: true,
|
||
},
|
||
},
|
||
{
|
||
path: '/system/config/items',
|
||
name: 'configItems',
|
||
component: 'system/config/itemList',
|
||
meta: {
|
||
title: '配置项列表',
|
||
isKeepAlive: false,
|
||
isHide: true,
|
||
},
|
||
},
|
||
{
|
||
path: '/blog/article/operate',
|
||
name: 'articleOperate',
|
||
component: 'blog/article/operate',
|
||
meta: {
|
||
title: '新增文章',
|
||
isKeepAlive: false,
|
||
isHide: true,
|
||
},
|
||
},
|
||
];
|
||
// 工作台业务页需要携带赛事上下文,不能依赖菜单接口是否已经下发全部子菜单。
|
||
// 这些路由只作为隐藏的运行路由注册,左侧菜单仍以后端权限菜单为准。
|
||
// 兼容已部署数据库里的旧菜单数据:旧版“秩序册”曾错误复用 launcher 路由。
|
||
// 前端按稳定的路由名称/菜单编码修正运行地址,避免用户必须等待菜单种子重新入库。
|
||
const normalizedRouteList = normalizeScoringMenuRoutes(routeList);
|
||
const routesWithScoringFallback = ensureScoringOperationalRoutes(normalizedRouteList);
|
||
dynamicRoutes[0].children = await backEndComponent([...routesWithScoringFallback, ...staticRoute]);
|
||
// dynamicRoutes[0].children = await backEndComponent(routeList);
|
||
// 添加动态路由
|
||
await setAddRoute();
|
||
// 设置路由到 pinia routesList 中(已处理成多级嵌套路由)及缓存多级嵌套数组处理后的一维数组
|
||
setFilterMenuAndCacheTagsViewRoutes();
|
||
}
|
||
|
||
/**
|
||
* 设置路由到 pinia routesList 中(已处理成多级嵌套路由)及缓存多级嵌套数组处理后的一维数组
|
||
* @description 用于左侧菜单、横向菜单的显示
|
||
* @description 用于 tagsView、菜单搜索中:未过滤隐藏的(isHide)
|
||
*/
|
||
export async function setFilterMenuAndCacheTagsViewRoutes() {
|
||
const storesRoutesList = useRoutesList(pinia);
|
||
storesRoutesList.setRoutesList(dynamicRoutes[0].children as any);
|
||
setCacheTagsViewRoutes();
|
||
}
|
||
|
||
/**
|
||
* 缓存多级嵌套数组处理后的一维数组
|
||
* @description 用于 tagsView、菜单搜索中:未过滤隐藏的(isHide)
|
||
*/
|
||
export function setCacheTagsViewRoutes() {
|
||
const storesTagsView = useTagsViewRoutes(pinia);
|
||
storesTagsView.setTagsViewRoutes(formatTwoStageRoutes(formatFlatteningRoutes(dynamicRoutes))[0].children);
|
||
}
|
||
|
||
/**
|
||
* 处理路由格式及添加捕获所有路由或 404 Not found 路由
|
||
* @description 替换 dynamicRoutes(/@/router/route)第一个顶级 children 的路由
|
||
* @returns 返回替换后的路由数组
|
||
*/
|
||
export function setFilterRouteEnd() {
|
||
let filterRouteEnd: any = formatTwoStageRoutes(formatFlatteningRoutes(dynamicRoutes));
|
||
// notFoundAndNoPower 防止 404、401 不在 layout 布局中,不设置的话,404、401 界面将全屏显示
|
||
// 关联问题 No match found for location with path 'xxx'
|
||
filterRouteEnd[0].children = [...filterRouteEnd[0].children, ...notFoundAndNoPower];
|
||
return filterRouteEnd;
|
||
}
|
||
|
||
/**
|
||
* 添加动态路由
|
||
* @method router.addRoute
|
||
* @description 此处循环为 dynamicRoutes(/@/router/route)第一个顶级 children 的路由一维数组,非多级嵌套
|
||
* @link 参考:https://next.router.vuejs.org/zh/api/#addroute
|
||
*/
|
||
export async function setAddRoute() {
|
||
await setFilterRouteEnd().forEach((route: RouteRecordRaw) => {
|
||
router.addRoute(route);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 请求后端路由菜单接口
|
||
* @description isRequestRoutes 为 true,则开启后端控制路由
|
||
* @returns 返回后端路由菜单数据
|
||
*/
|
||
export function getBackEndControlRoutes() {
|
||
return SysMenuApi.getMenus();
|
||
}
|
||
|
||
function normalizeScoringMenuRoutes(routes: any[]) {
|
||
return routes.map((item) => {
|
||
const children = item?.children?.length ? normalizeScoringMenuRoutes(item.children) : item?.children;
|
||
const routeName = String(item?.name || item?.routeName || '');
|
||
const menuCode = String(item?.code || item?.meta?.code || '');
|
||
const title = String(item?.meta?.title || item?.title || '');
|
||
const path = String(item?.path || '');
|
||
|
||
// 识别 launcher:
|
||
// 旧版种子中曾把 scoring:launcher 或 /scoring/launcher 误命名为“秩序册”;
|
||
// 无论其 title 叫“秩序册”、“工作台”还是“打分系统工作台”,只要编码/路由为 launcher,就规范为 launcher
|
||
const isLauncher =
|
||
routeName === 'scoringLauncher' ||
|
||
menuCode === 'scoring:launcher' ||
|
||
path === '/scoring/launcher' ||
|
||
((title === '工作台' || title === '计分工作台') && path.startsWith('/scoring'));
|
||
|
||
const isOrderBook =
|
||
!isLauncher &&
|
||
(routeName === 'scoringOrderBook' ||
|
||
menuCode === 'scoring:orderbook' ||
|
||
path === '/scoring/orderbook' ||
|
||
(title === '秩序册' && path.startsWith('/scoring')));
|
||
|
||
const isWorkbench =
|
||
routeName === 'scoringWorkbench' ||
|
||
menuCode === 'scoring:workbench' ||
|
||
path === '/scoring/workbench' ||
|
||
(title === '成绩管理' && path.startsWith('/scoring'));
|
||
|
||
const isScoringCast =
|
||
routeName === 'scoringCast' ||
|
||
menuCode === 'scoring:cast' ||
|
||
path === '/scoring/cast' ||
|
||
((title === '成绩投屏' || title === '赛事大屏') && path.startsWith('/scoring'));
|
||
|
||
if (isScoringCast) {
|
||
return {
|
||
...item,
|
||
path: '/scoring/cast',
|
||
component: 'scoring/cast/index',
|
||
children,
|
||
};
|
||
}
|
||
if (isWorkbench) {
|
||
return {
|
||
...item,
|
||
path: '/scoring/workbench',
|
||
component: 'scoring/workbench/index',
|
||
children,
|
||
};
|
||
}
|
||
if (isLauncher) {
|
||
return {
|
||
...item,
|
||
path: '/scoring/launcher',
|
||
component: 'scoring/launcher/index',
|
||
children,
|
||
};
|
||
}
|
||
if (isOrderBook) {
|
||
return {
|
||
...item,
|
||
path: '/scoring/orderbook',
|
||
component: 'scoring/orderbook/index',
|
||
children,
|
||
};
|
||
}
|
||
return children === item?.children ? item : { ...item, children };
|
||
});
|
||
}
|
||
|
||
function isScoringRelatedRoute(item: any): boolean {
|
||
if (!item) return false;
|
||
const path = String(item?.path || '');
|
||
const code = String(item?.code || item?.meta?.code || '');
|
||
const name = String(item?.name || item?.routeName || '');
|
||
const title = String(item?.meta?.title || item?.title || '');
|
||
return (
|
||
path === '/scoring' ||
|
||
path.startsWith('/scoring/') ||
|
||
code === 'scoring' ||
|
||
code.startsWith('scoring:') ||
|
||
name === 'scoring' ||
|
||
name.startsWith('scoring') ||
|
||
title.includes('打分系统工作台') ||
|
||
title.includes('计分工作台') ||
|
||
title === '工作台'
|
||
);
|
||
}
|
||
|
||
function ensureScoringOperationalRoutes(routes: any[]) {
|
||
const paths = new Set<string>();
|
||
const collectPaths = (items: any[]) =>
|
||
items.forEach((item) => {
|
||
if (item?.path) paths.add(item.path);
|
||
if (item?.children?.length) collectPaths(item.children);
|
||
});
|
||
collectPaths(routes);
|
||
|
||
const scoringEnabled =
|
||
paths.has('/scoring') ||
|
||
paths.has('/scoring/launcher') ||
|
||
paths.has('/scoring/workbench') ||
|
||
paths.has('/scoring/orderbook') ||
|
||
paths.has('/scoring/cast') ||
|
||
routes.some((item) => isScoringRelatedRoute(item));
|
||
|
||
if (!scoringEnabled) return routes;
|
||
|
||
// 提取原有菜单中的图标(若有),优先使用
|
||
let scoringIcon = 'ele-Monitor';
|
||
const findScoringIcon = (items: any[]) => {
|
||
for (const item of items) {
|
||
const icon = item?.meta?.icon || item?.icon;
|
||
if (isScoringRelatedRoute(item) && icon) {
|
||
scoringIcon = icon;
|
||
return;
|
||
}
|
||
if (item?.children?.length) findScoringIcon(item.children);
|
||
}
|
||
};
|
||
findScoringIcon(routes);
|
||
|
||
// 从原有树中清理所有打分系统相关节点,并记录首个打分节点的插入位置,彻底避免出现重复菜单或多余的秩序册
|
||
let firstScoringIndex = -1;
|
||
const cleanRoutes = (items: any[]): any[] => {
|
||
const result: any[] = [];
|
||
for (let i = 0; i < items.length; i++) {
|
||
const item = items[i];
|
||
if (isScoringRelatedRoute(item)) {
|
||
if (firstScoringIndex === -1) {
|
||
firstScoringIndex = result.length;
|
||
}
|
||
continue;
|
||
}
|
||
if (item?.children?.length) {
|
||
result.push({
|
||
...item,
|
||
children: cleanRoutes(item.children),
|
||
});
|
||
} else {
|
||
result.push(item);
|
||
}
|
||
}
|
||
return result;
|
||
};
|
||
|
||
const routesWithoutScoring = cleanRoutes(routes);
|
||
if (firstScoringIndex === -1) firstScoringIndex = routesWithoutScoring.length;
|
||
|
||
// 唯一的打分系统工作台父级目录及其 4 个标准业务子菜单:
|
||
// 1. 打分系统工作台:选择计分模式和赛事
|
||
// 2. 秩序册:秩序册导入
|
||
// 3. 成绩管理:出场名单、打分单与更正
|
||
// 4. 成绩投屏:大屏投屏操作台
|
||
const scoringParent = {
|
||
path: '/scoring',
|
||
name: 'scoring',
|
||
component: 'layout/routerView/parent',
|
||
redirect: '/scoring/launcher',
|
||
meta: {
|
||
title: '打分系统工作台',
|
||
isHide: false,
|
||
isKeepAlive: true,
|
||
icon: scoringIcon || 'ele-Trophy',
|
||
},
|
||
children: [
|
||
{
|
||
path: '/scoring/launcher',
|
||
name: 'scoringLauncher',
|
||
component: 'scoring/launcher/index',
|
||
meta: {
|
||
title: '打分系统工作台',
|
||
isHide: false,
|
||
isKeepAlive: true,
|
||
icon: 'ele-Monitor',
|
||
},
|
||
},
|
||
{
|
||
path: '/scoring/orderbook',
|
||
name: 'scoringOrderBook',
|
||
component: 'scoring/orderbook/index',
|
||
meta: {
|
||
title: '秩序册',
|
||
isHide: false,
|
||
isKeepAlive: true,
|
||
icon: 'ele-Notebook',
|
||
},
|
||
},
|
||
{
|
||
path: '/scoring/workbench',
|
||
name: 'scoringWorkbench',
|
||
component: 'scoring/workbench/index',
|
||
meta: {
|
||
title: '成绩管理',
|
||
isHide: false,
|
||
isKeepAlive: true,
|
||
icon: 'ele-Management',
|
||
},
|
||
},
|
||
{
|
||
path: '/scoring/cast',
|
||
name: 'scoringCast',
|
||
component: 'scoring/cast/index',
|
||
meta: {
|
||
title: '成绩投屏',
|
||
isHide: false,
|
||
isKeepAlive: true,
|
||
icon: 'ele-DataLine',
|
||
},
|
||
},
|
||
],
|
||
};
|
||
|
||
routesWithoutScoring.splice(firstScoringIndex, 0, scoringParent);
|
||
return routesWithoutScoring;
|
||
}
|
||
|
||
/**
|
||
* 重新请求后端路由菜单接口
|
||
* @description 用于菜单管理界面刷新菜单(未进行测试)
|
||
* @description 路径:/src/views/system/menu/component/addMenu.vue
|
||
*/
|
||
export async function setBackEndControlRefreshRoutes() {
|
||
await getBackEndControlRoutes();
|
||
}
|
||
|
||
/**
|
||
* 后端路由 component 转换
|
||
* @param routes 后端返回的路由表数组
|
||
* @returns 返回处理成函数后的 component
|
||
*/
|
||
export function backEndComponent(routes: any) {
|
||
if (!routes) return;
|
||
return routes.map((item: any) => {
|
||
if (item.component === '') {
|
||
item.component = 'layout/routerView/parent';
|
||
}
|
||
if (item.component) item.component = dynamicImport(dynamicViewsModules, item.component as string);
|
||
item.children && item.children.length > 0 && backEndComponent(item.children);
|
||
return item;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 后端路由 component 转换函数
|
||
* @param dynamicViewsModules 获取目录下的 .vue、.tsx 全部文件
|
||
* @param component 当前要处理项 component
|
||
* @returns 返回处理成函数后的 component
|
||
*/
|
||
export function dynamicImport(dynamicViewsModules: Record<string, Function>, component: string) {
|
||
const keys = Object.keys(dynamicViewsModules);
|
||
const matchKeys = keys.filter((key) => {
|
||
const k = key.replace(/..\/views|../, '');
|
||
return k.startsWith(`${component}`) || k.startsWith(`/${component}`);
|
||
});
|
||
if (matchKeys?.length === 1) {
|
||
const matchKey = matchKeys[0];
|
||
return dynamicViewsModules[matchKey];
|
||
}
|
||
if (matchKeys?.length > 1) {
|
||
const exactKey = matchKeys.find((key) => {
|
||
const k = key.replace(/..\/views|../, '');
|
||
return k === `${component}.vue` || k === `/${component}.vue` || k === `${component}.tsx` || k === `/${component}.tsx`;
|
||
});
|
||
if (exactKey) return dynamicViewsModules[exactKey];
|
||
return false;
|
||
}
|
||
}
|
||
|