YD_Event.Web/src/router/backEnd.ts
ltx 9d07d9eb51 feat(website): 增加官网赛事信息管理与 Excel 模板
新增隐藏的官网赛事信息后台路由和编辑页面。支持从 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 及其服务端密钥配置。
2026-10-19 15:58:41 +08:00

454 lines
14 KiB
TypeScript
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.

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;
}
}