mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-04 09:10:59 +00:00
This commit removes the deprecated frontend/web-old directory, updates various project configuration files including tsconfig, vite config, environment variables, and backend data models. It also fixes several API paths, adds tenant awareness to multiple models, and makes minor UI adjustments.
496 lines
15 KiB
TypeScript
496 lines
15 KiB
TypeScript
/**
|
||
* 静态路由定义 + IframeRouteManager。
|
||
*
|
||
* 静态路由 = 首屏即注册的路由(Layout、登录页、404/500、iframe 占位等),
|
||
* 不依赖菜单权限,用户未登录时即可访问。
|
||
*
|
||
* 动态路由由 `beforeEach.ts` → `RouteRegistry` 在登录后根据不同角色的菜单列表动态 `addRoute`。
|
||
*/
|
||
import type { AppRouteRecordRaw } from "@utils";
|
||
import type { AppRouteRecord, RouteMeta } from "@/types/router";
|
||
import { defineComponent, h, onMounted, ref } from "vue";
|
||
import type { RouteRecordRaw } from "vue-router";
|
||
import { RouterView, useRoute } from "vue-router";
|
||
import { t } from "@wangeditor-next/editor";
|
||
|
||
/** 首页 / 仪表盘父级 meta(侧栏、静态子路由共用) */
|
||
export const HOME_MENU_META: RouteMeta = {
|
||
title: "menus.home.title",
|
||
icon: "ri:home-smile-2-line",
|
||
keepAlive: true,
|
||
fixedTab: true,
|
||
};
|
||
|
||
export const DASHBOARD_PARENT_META: RouteMeta = {
|
||
title: "menus.dashboard.title",
|
||
icon: "ri:pie-chart-line",
|
||
alwaysShow: true,
|
||
};
|
||
|
||
/** iframe 路由注册表(与动态路由、守卫共用) */
|
||
export class IframeRouteManager {
|
||
private static instance: IframeRouteManager;
|
||
private iframeRoutes: AppRouteRecord[] = [];
|
||
|
||
private constructor() {}
|
||
|
||
static getInstance(): IframeRouteManager {
|
||
if (!IframeRouteManager.instance) {
|
||
IframeRouteManager.instance = new IframeRouteManager();
|
||
}
|
||
return IframeRouteManager.instance;
|
||
}
|
||
|
||
add(route: AppRouteRecord): void {
|
||
if (!this.iframeRoutes.find((r) => r.path === route.path)) {
|
||
this.iframeRoutes.push(route);
|
||
}
|
||
}
|
||
|
||
getAll(): AppRouteRecord[] {
|
||
return this.iframeRoutes;
|
||
}
|
||
|
||
findByPath(path: string): AppRouteRecord | undefined {
|
||
return this.iframeRoutes.find((route) => route.path === path);
|
||
}
|
||
|
||
clear(): void {
|
||
this.iframeRoutes = [];
|
||
}
|
||
|
||
save(): void {
|
||
if (this.iframeRoutes.length > 0) {
|
||
sessionStorage.setItem("iframeRoutes", JSON.stringify(this.iframeRoutes));
|
||
}
|
||
}
|
||
|
||
load(): void {
|
||
try {
|
||
const data = sessionStorage.getItem("iframeRoutes");
|
||
if (data) {
|
||
this.iframeRoutes = JSON.parse(data);
|
||
}
|
||
} catch (error) {
|
||
console.error("[IframeRouteManager] 加载 iframe 路由失败:", error);
|
||
this.iframeRoutes = [];
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 根 Layout 的 route.name;动态路由 `addRoute` 父级须与此一致 */
|
||
export const ROOT_LAYOUT_ROUTE_NAME = "RootLayout" as const;
|
||
|
||
/** 静态首页子路由 name(面包屑等) */
|
||
export const HOME_ROUTE_NAME = "Home" as const;
|
||
|
||
/** 目录占位:仅嵌一层 RouterView(与 ComponentLoader 中占位同源) */
|
||
export const NestedRouterParent = defineComponent({
|
||
name: "NestedRouterParent",
|
||
setup() {
|
||
return () => h(RouterView);
|
||
},
|
||
});
|
||
|
||
/** 后端菜单 / 动态路由里 `component` 占位(与 ComponentLoader 约定一致) */
|
||
export const ROUTE_COMPONENT_LAYOUT = "/index/index";
|
||
|
||
/** 多级目录父级占位(`views/nested/router-view-parent`) */
|
||
export const ROUTE_COMPONENT_NESTED_PARENT = "/nested/router-view-parent";
|
||
|
||
/** 登录页备用 path(与静态 `/login` 并存,守卫与白名单用) */
|
||
export const ROUTE_PATH_LOGIN_ALT = "/auth/login";
|
||
|
||
/**
|
||
* 主框架布局:新版 art 体系(`src/components/layouts/index.vue` + `src/components/layouts/fa-*` 组件)。
|
||
* 旧版 Left/Top/Mix 壳子已移除,统一使用 `@/components/layouts/index.vue`。
|
||
*/
|
||
export const Layout = () => import("@/components/layouts/index.vue");
|
||
|
||
/** iframe 内跳页面:内联组件(无需 views/outside/Iframe.vue) */
|
||
const IframeView = defineComponent({
|
||
name: "IframeView",
|
||
setup() {
|
||
const route = useRoute();
|
||
const isLoading = ref(true);
|
||
const iframeUrl = ref("");
|
||
const iframeRef = ref<HTMLIFrameElement | null>(null);
|
||
|
||
onMounted(() => {
|
||
const iframeRoute = IframeRouteManager.getInstance().findByPath(route.path);
|
||
if (iframeRoute?.meta) {
|
||
iframeUrl.value = iframeRoute.meta.link || "";
|
||
}
|
||
});
|
||
|
||
const handleIframeLoad = () => {
|
||
isLoading.value = false;
|
||
};
|
||
|
||
return () =>
|
||
h("div", { class: "box-border w-full h-full", "v-loading": isLoading.value }, [
|
||
h("iframe", {
|
||
ref: iframeRef,
|
||
src: iframeUrl.value,
|
||
frameborder: "0",
|
||
class: "w-full h-full min-h-[calc(100vh-120px)] border-none",
|
||
onLoad: handleIframeLoad,
|
||
}),
|
||
]);
|
||
},
|
||
});
|
||
|
||
/**
|
||
* `/dashboard` 下静态子路由(唯一数据源)。
|
||
* 下方壳层菜单合并函数由此剥离 `component` 生成侧栏补全菜单。
|
||
*/
|
||
export const dashboardLayoutChildren: AppRouteRecordRaw[] = [
|
||
{
|
||
path: "workplace",
|
||
name: "DashboardWorkplace",
|
||
component: () => import("@views/dashboard/workplace/index.vue"),
|
||
meta: {
|
||
title: "menus.dashboard.workplace",
|
||
icon: "ri:bar-chart-box-line",
|
||
keepAlive: true,
|
||
},
|
||
},
|
||
{
|
||
path: "analysis",
|
||
name: "DashboardAnalysis",
|
||
component: () => import("@views/dashboard/analysis/index.vue"),
|
||
meta: {
|
||
title: "menus.dashboard.analysis",
|
||
icon: "ri:align-item-bottom-line",
|
||
keepAlive: false,
|
||
},
|
||
},
|
||
{
|
||
path: "screen",
|
||
name: "DashboardScreen",
|
||
component: () => import("@views/dashboard/screen/index.vue"),
|
||
meta: {
|
||
title: "数据大屏",
|
||
icon: "ri:tv-line",
|
||
keepAlive: false,
|
||
hidden: false,
|
||
},
|
||
},
|
||
];
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// 静态壳层菜单:后端未下发 /home、/dashboard 时补全侧栏;混合模式路由按 name 去重合并
|
||
|
||
export const mergeShellHomeMenu: AppRouteRecord = {
|
||
path: "/home",
|
||
name: "Home",
|
||
meta: { ...HOME_MENU_META, shellRoute: true },
|
||
};
|
||
|
||
/** 去掉组件与 redirect,供侧栏合并(菜单节点不需要懒加载引用) */
|
||
function stripRouteRecordForShell(route: RouteRecordRaw): AppRouteRecord {
|
||
const children = route.children?.map(stripRouteRecordForShell);
|
||
return {
|
||
path: route.path,
|
||
name: route.name,
|
||
meta: (route.meta ?? {}) as AppRouteRecord["meta"],
|
||
...(children?.length ? { children } : {}),
|
||
} as AppRouteRecord;
|
||
}
|
||
|
||
export function getDashboardMenuTreeForMerge(): AppRouteRecord {
|
||
return {
|
||
name: "Dashboard",
|
||
path: "/dashboard",
|
||
meta: DASHBOARD_PARENT_META,
|
||
children: dashboardLayoutChildren.map(stripRouteRecordForShell),
|
||
};
|
||
}
|
||
|
||
export function mergeAppRouteRecords(
|
||
primary: AppRouteRecord[],
|
||
secondary: AppRouteRecord[]
|
||
): AppRouteRecord[] {
|
||
const usedNames = new Set<string>();
|
||
|
||
const collectNames = (routes: AppRouteRecord[]) => {
|
||
for (const r of routes) {
|
||
if (r.name) usedNames.add(String(r.name));
|
||
if (r.children?.length) collectNames(r.children);
|
||
}
|
||
};
|
||
collectNames(primary);
|
||
|
||
const pickFresh = (routes: AppRouteRecord[]): AppRouteRecord[] => {
|
||
const out: AppRouteRecord[] = [];
|
||
for (const r of routes) {
|
||
const n = r.name ? String(r.name) : "";
|
||
if (n && usedNames.has(n)) continue;
|
||
const next: AppRouteRecord = { ...r };
|
||
if (r.children?.length) {
|
||
next.children = pickFresh(r.children);
|
||
}
|
||
if (n) usedNames.add(n);
|
||
out.push(next);
|
||
}
|
||
return out;
|
||
};
|
||
|
||
return [...primary, ...pickFresh(secondary)];
|
||
}
|
||
|
||
function normalizeMenuPath(path?: string): string {
|
||
if (!path || !path.trim()) return "";
|
||
const p = path.trim();
|
||
return p.startsWith("/") ? p : `/${p}`;
|
||
}
|
||
|
||
function collectPathsAndNames(items: AppRouteRecord[], paths: Set<string>, names: Set<string>) {
|
||
for (const r of items) {
|
||
const np = normalizeMenuPath(r.path as string);
|
||
if (np) paths.add(np);
|
||
if (r.name) names.add(String(r.name));
|
||
if (r.children?.length) collectPathsAndNames(r.children, paths, names);
|
||
}
|
||
}
|
||
|
||
function dashboardRoutesToShellMenu(route: AppRouteRecord, parentAbs = ""): AppRouteRecord {
|
||
const raw = route.path?.trim() ?? "";
|
||
const fullPath =
|
||
raw.startsWith("/") && raw !== "/"
|
||
? raw
|
||
: parentAbs
|
||
? `${parentAbs.replace(/\/$/, "")}/${raw.replace(/^\/+/, "")}`
|
||
: `/${raw.replace(/^\/+/, "")}`;
|
||
const meta = { ...route.meta, shellRoute: true as const };
|
||
const children = route.children?.map((c) => dashboardRoutesToShellMenu(c, fullPath));
|
||
return {
|
||
...route,
|
||
path: fullPath,
|
||
meta,
|
||
children,
|
||
component: undefined,
|
||
redirect: undefined,
|
||
};
|
||
}
|
||
|
||
export function mergeShellRoutesIntoMenu(menuList: AppRouteRecord[]): AppRouteRecord[] {
|
||
const paths = new Set<string>();
|
||
const names = new Set<string>();
|
||
collectPathsAndNames(menuList, paths, names);
|
||
|
||
const additions: AppRouteRecord[] = [];
|
||
|
||
const tryPush = (item: AppRouteRecord) => {
|
||
const p = normalizeMenuPath(item.path as string);
|
||
const n = item.name ? String(item.name) : "";
|
||
if (p && !paths.has(p) && (!n || !names.has(n))) {
|
||
additions.push(item);
|
||
if (p) paths.add(p);
|
||
if (n) names.add(n);
|
||
if (item.children?.length) {
|
||
collectPathsAndNames(item.children, paths, names);
|
||
}
|
||
}
|
||
};
|
||
|
||
tryPush(mergeShellHomeMenu);
|
||
|
||
if (!paths.has("/dashboard")) {
|
||
tryPush(dashboardRoutesToShellMenu(structuredClone(getDashboardMenuTreeForMerge())));
|
||
}
|
||
|
||
if (additions.length === 0) return menuList;
|
||
return [...additions, ...menuList];
|
||
}
|
||
|
||
/**
|
||
* 静态路由配置(不需要权限就能访问的路由)
|
||
*
|
||
* 属性说明:
|
||
* isHideTab: true 表示不在标签页中显示
|
||
*
|
||
* 注意事项:
|
||
* 1、path、name 不要和动态路由冲突,否则会导致路由冲突无法访问
|
||
* 2、静态路由不管是否登录都可以访问
|
||
*/
|
||
export const staticRoutes: AppRouteRecordRaw[] = [
|
||
{
|
||
path: "/redirect",
|
||
meta: { hidden: true },
|
||
component: Layout,
|
||
children: [
|
||
{
|
||
path: "/redirect/:path(.*)",
|
||
component: () => import("@views/redirect/index.vue"),
|
||
},
|
||
],
|
||
},
|
||
{
|
||
path: "/login",
|
||
name: "Login",
|
||
meta: { hidden: true, isHideTab: true, title: "menus.login.title" },
|
||
component: () => import("@views/module_system/auth/login/index.vue"),
|
||
},
|
||
{
|
||
path: "/tenant-select",
|
||
name: "TenantSelect",
|
||
meta: { hidden: true, isHideTab: true, title: "选择租户" },
|
||
component: () => import("@views/module_system/auth/tenant-select/index.vue"),
|
||
},
|
||
/** 无 Layout 全屏异常页;守卫与白名单跳转使用(勿再在 RootLayout 下重复挂载同组件) */
|
||
{
|
||
path: "/401",
|
||
name: "401",
|
||
meta: { hidden: true, title: "401" },
|
||
component: () => import("@views/exception/401/index.vue"),
|
||
},
|
||
{
|
||
path: "/403",
|
||
name: "403",
|
||
component: () => import("@views/exception/403/index.vue"),
|
||
meta: { hidden: true, title: "403" },
|
||
},
|
||
{
|
||
path: "/404",
|
||
name: "404",
|
||
meta: { hidden: true, title: "404" },
|
||
component: () => import("@views/exception/404/index.vue"),
|
||
},
|
||
{
|
||
path: "/500",
|
||
name: "500",
|
||
meta: { hidden: true, title: "500" },
|
||
component: () => import("@views/exception/500/index.vue"),
|
||
},
|
||
{
|
||
path: "/",
|
||
name: ROOT_LAYOUT_ROUTE_NAME,
|
||
redirect: "/home",
|
||
component: Layout,
|
||
children: [
|
||
/** 首页(侧栏补入逻辑见同文件 `mergeShellRoutesIntoMenu`) */
|
||
{
|
||
path: "home",
|
||
name: HOME_ROUTE_NAME,
|
||
component: () => import("@views/dashboard/home/index.vue"),
|
||
meta: HOME_MENU_META,
|
||
},
|
||
/** 仪表盘子路由定义见同文件导出的 `dashboardLayoutChildren` */
|
||
{
|
||
path: "dashboard",
|
||
name: "Dashboard",
|
||
redirect: "/dashboard/workplace",
|
||
component: NestedRouterParent,
|
||
meta: DASHBOARD_PARENT_META,
|
||
children: dashboardLayoutChildren,
|
||
},
|
||
/** 工单管理 */
|
||
{
|
||
path: "ticket",
|
||
name: "ModuleTicket",
|
||
component: () => import("@views/module_system/ticket/index.vue"),
|
||
meta: { title: "工单管理", icon: "ri:feedback-line", keepAlive: true },
|
||
},
|
||
/** 插件市场 */
|
||
{
|
||
path: "plugin-market",
|
||
name: "PluginMarket",
|
||
component: () => import("@/views/module_system/plugin/index.vue"),
|
||
meta: { title: "插件市场", icon: "ri:store-2-line", keepAlive: false },
|
||
},
|
||
/** 快速链接:统一父级,不在菜单中展示,通过 URL 或 fastEnter 直接访问 */
|
||
{
|
||
path: "fastlink",
|
||
name: "Fastlink",
|
||
component: NestedRouterParent,
|
||
meta: { hidden: true },
|
||
children: [
|
||
{
|
||
path: "profile",
|
||
name: "FastlinkProfile",
|
||
meta: { title: t("menus.system.userCenter"), icon: "ri:user-line", hidden: true },
|
||
component: () => import("@views/fastlink/current/profile.vue"),
|
||
},
|
||
{
|
||
path: "changelog",
|
||
name: "FastlinkChangeLog",
|
||
meta: {
|
||
title: t("menus.changelog.title"),
|
||
icon: "ri:draft-line",
|
||
hidden: true,
|
||
keepAlive: true,
|
||
},
|
||
component: () => import("@views/fastlink/changelog/index.vue"),
|
||
},
|
||
{
|
||
path: "pricing",
|
||
name: "FastlinkPricing",
|
||
meta: {
|
||
title: t("menus.dashboard.pricing"),
|
||
icon: "ri:money-cny-box-line",
|
||
hidden: true,
|
||
keepAlive: true,
|
||
},
|
||
component: () => import("@views/fastlink/pricing/index.vue"),
|
||
},
|
||
{
|
||
path: "article/list",
|
||
name: "FastlinkArticleList",
|
||
meta: {
|
||
title: t("menus.article.articleList"),
|
||
icon: "ri:article-line",
|
||
hidden: true,
|
||
keepAlive: true,
|
||
},
|
||
component: () => import("@views/fastlink/article/index.vue"),
|
||
},
|
||
{
|
||
path: "tutorial",
|
||
name: "FastlinkTutorial",
|
||
meta: {
|
||
title: t("menus.dashboard.tutorial"),
|
||
icon: "ri:book-2-line",
|
||
hidden: true,
|
||
keepAlive: true,
|
||
},
|
||
component: () => import("@views/fastlink/tutorial/index.vue"),
|
||
},
|
||
{
|
||
path: "fachat",
|
||
name: "FastlinkFachat",
|
||
meta: {
|
||
title: t("menus.fachat.title"),
|
||
icon: "ri:message-3-line",
|
||
hidden: true,
|
||
keepAlive: true,
|
||
},
|
||
component: () => import("@views/fastlink/fachat/index.vue"),
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
path: "/outside",
|
||
component: () => import("@/components/layouts/index.vue"),
|
||
name: "Outside",
|
||
meta: { title: "menus.outside.title" },
|
||
children: [
|
||
{
|
||
path: "/outside/iframe/:path",
|
||
name: "Iframe",
|
||
component: IframeView,
|
||
meta: { title: "iframe" },
|
||
},
|
||
],
|
||
},
|
||
// 通配 404 必须置于静态路由最后(name 勿与上方 `/404` 重复,否则按名跳转不稳定)
|
||
{
|
||
path: "/:pathMatch(.*)*",
|
||
name: "CatchAll404",
|
||
component: () => import("@views/exception/404/index.vue"),
|
||
meta: { hidden: true, title: "404" },
|
||
},
|
||
];
|