4. 工程结构与启动链路
前端手册工程结构大约 6 分钟约 1717 字
一句话定位
本篇讲清 Web/ 前端工程「长什么样、怎么启动、从输入 URL 到页面渲染中间发生了什么」,看完就能定位任何一个文件该放哪里。
目录树(带注释)
代码位置:Web/
Web
├── .env # 通用环境变量:VITE_PORT=8888 / VITE_OPEN / VITE_PUBLIC_PATH / VITE_OPEN_CDN
├── .env.development # 开发环境:VITE_API_URL=http://localhost:5005
├── .env.production # 生产环境:VITE_API_URL 留空(走 public/config.js)
├── vite.config.ts # 别名 /@ -> src、proxy、压缩、分包、构建时写 public/config.js
├── tsconfig.json # TS 配置(paths 与别名保持一致)
├── index.html # 入口 HTML,先加载 /config.js 再加载 /src/main.ts
├── public/
│ ├── config.js # 构建时由 vite.config.ts 生成:window.__env__ = {...}
│ └── liveplayer-lib.min.js
├── api_build/ # swagger-codegen 契约生成(build.sh / build.bat / build_api.sh)
├── script/ # publish.js、build-api.js、translate.cjs 等 Node 脚本
└── src/
├── main.ts # 应用入口,注册顺序见下文
├── App.vue # 根组件:el-config-provider + router-view + 全局挂件
├── api-services/ # 【只读】swagger 生成的接口层:system / document / aiChat / dingTalk ...
│ └── system/{apis,models,api.ts,base.ts,configuration.ts,index.ts}
├── api/ # 旧版手写接口封装(历史遗留,新代码不要往这里加)
├── assets/ # 静态资源
├── components/ # 22 个通用组件目录(GSysDict / scEcharts / selector / uploadImage ...)
├── directive/ # v-auth / v-auths / v-auth-all / v-waves / v-reclick
├── hooks/ # setupVXETableHook.ts、useVxeTableOptionsHook.ts、dateTimeShortCust.ts
├── i18n/ # vue-i18n 多语言
├── layout/ # 布局外壳
│ ├── main/{defaults,classic,transverse,columns}.vue # 四种布局态(当前默认 columns)
│ ├── navBars/topBar| navMenu/ routerView/ lockScreen/ component/
├── router/
│ ├── index.ts # createRouter + 全局守卫 + ensureRoutesInit()
│ ├── backEnd.ts # 后端控制路由(项目使用)
│ ├── frontEnd.ts # 前端控制路由
│ └── route.ts # staticRoutes / dynamicRoutes / notFoundAndNoPower
├── stores/ # pinia:userInfo / themeConfig / routesList / tagsViewRoutes ...
├── theme/ # index.scss 总入口 + app.scss / page.scss / dark.scss / vxeTable.scss ...
├── types/ # *.d.ts 全局类型(global.d.ts、mitt.d.ts、axios.d.ts ...)
├── utils/ # axios-utils / storage / format / formRule / download / authFunction ...
└── views/ # 业务页面,后端菜单 component 字段与之对应main.ts 注册顺序(逐条解释)
src/main.ts 全文只有 37 行,但顺序是被依赖关系钉死的:
import '../lang/index'; // ①
import { createApp } from 'vue';
import pinia from '/@/stores/index'; // ②
import App from '/@/App.vue';
import router from '/@/router';
import { directive } from '/@/directive/index';
import { i18n } from '/@/i18n/index';
import other from '/@/utils/other';
import ElementPlus from 'element-plus';
import '/@/theme/index.scss'; // ③
import { setupVXETable } from '/@/hooks/setupVXETableHook';
import sysDict from '/@/components/sysDict/sysDict.vue';
const app = createApp(App);
directive(app); // ④
other.elSvg(app); // ⑤
app.component('GSysDict', sysDict); // ⑥
app.use(pinia).use(i18n).use(router).use(ElementPlus).use(setupVXETable).use(VueGridLayout).use(VForm3).use(VueSignaturePad).use(vue3TreeOrg).mount('#app'); // ⑦| 序号 | 动作 | 为什么必须在这里 |
|---|---|---|
| ① | import '../lang/index' | 必须在任何业务代码前执行,初始化全局语言包 |
| ② | 先建 pinia | router/index.ts 顶层就 useThemeConfig(pinia),pinia 必须在 router 之前 |
| ③ | 引入 theme/index.scss | Element Plus 原生样式在最前,项目变量与覆盖在后 |
| ④ | directive(app) | 注册 v-auth / v-auths / v-auth-all / v-waves / v-reclick |
| ⑤ | other.elSvg(app) | 把 @element-plus/icons-vue 全量注册为 ele-* 组件,并注册 SvgIcon |
| ⑥ | app.component('GSysDict', ...) | 全局字典组件,任何页面可直接 <g-sys-dict code="..."/> |
| ⑦ | use() 链 | pinia → i18n → router → ElementPlus → vxe → 第三方插件,最后 mount |
VueGridLayout(栅格拖拽)、VForm3(表单设计器)、VueSignaturePad(电子签名)、vue3-tree-org(组织架构图)属于「重型按需库」,目前是全量注册;如后续要做包体积优化,可改成页面级
defineAsyncComponent。
App.vue 的职责
src/App.vue 只做五件事,不写任何业务:
- 全局配置下发:
<el-config-provider :size="getGlobalComponentSize" :locale="getGlobalI18n">,组件尺寸与语言一次下发到所有 Element Plus 组件。 - 路由出口:
<router-view v-show="setLockScreen" />。 - 三个异步挂件:
LockScreen、Settings(布局配置抽屉)、CloseFull,都用defineAsyncComponent懒加载。 - 主题恢复:
onMounted从Local.get('themeConfig')恢复布局配置、多语言、document.documentElement.style.cssText、favicon。 - 全局副作用:
mittBus.on('openSettingsDrawer', ...)打开设置抽屉;watch(route.path)调other.useTitle()改浏览器标题;initIdleTimeout()空闲超时;阻止 Firefox 拖拽打开新窗口。
⚠️ App.vue 里有一段不带 scoped 的全局样式,把 inline 表单的控件宽度统一为 var(--next-search-control-width)。不要在业务页里再写死 width: 171px。
全局注册清单
| 类型 | 名称 | 来源 |
|---|---|---|
| 组件 | GSysDict(模板里写 <g-sys-dict>) | components/sysDict/sysDict.vue |
| 组件 | ele-* 全部 Element Plus 图标 + SvgIcon | utils/other.ts:elSvg |
| 组件 | Element Plus 全量组件 | app.use(ElementPlus) |
| 组件 | vxe-table / vxe-pc-ui 全量组件 | hooks/setupVXETableHook.ts |
| 指令 | v-auth / v-auths / v-auth-all | directive/authDirective.ts |
| 指令 | v-waves / v-reclick | directive/customDirective.ts |
| 配置 | vxe 全局默认(斑马纹、圆角、虚拟滚动 gt=50、pager pageSize 20 等) | setupVXETable |
从输入 URL 到页面渲染
浏览器输入 http://host/#/system/config
│
▼
index.html ──► /config.js 写入 window.__env__(VITE_API_URL 等运行时变量)
│
▼
/src/main.ts ──► createApp → 注册 pinia/i18n/router/ElementPlus/vxe → mount('#app')
│
▼
router.beforeEach(src/router/index.ts)
├─ NProgress.start()
├─ meta.isPublic 且无 token → 直接放行
├─ 无 token → /login?redirect=...¶ms=...(带 tid,当 tid >= 100000)
├─ 有 token 且去 /login → /dashboard/home
└─ routesList 为空 → await ensureRoutesInit() ← Promise 单例,防并发重复初始化
│
├─ initBackEndControlRoutes()(src/router/backEnd.ts)
│ 1. NextLoading.start()
│ 2. useUserInfo().setUserInfos() ← /api/sysAuth/userInfo(含 apis 权限点)
│ 3. useUserInfo().setConstList() ← 常量
│ 4. useUserInfo().setDictList() ← 全量字典
│ 5. getAPI(SysMenuApi).apiSysMenuLoginMenuTreeGet()
│ 6. backEndComponent():component 字符串 → import.meta.glob 映射的函数
│ 7. setAddRoute():router.addRoute()
│ 8. setFilterMenuAndCacheTagsViewRoutes():写 routesList / tagsViewRoutes
│
└─ 失败 → showNetworkErrorThrottled + suppressNetworkErrorFor(5000) → 跳登录页
│
▼
return { path: to.path, query: to.query } ← 重新进入,此时动态路由已就绪
│
▼
layout/index.vue → layout/main/columns.vue(默认分栏布局)
│
▼
routerView/parent.vue → keep-alive → views/system/config/index.vue
│
▼
onMounted → fetchGroupData();proxyConfig.autoLoad=true → 自动拉第一页
│
▼
router.afterEach → NProgress.done()关键点:
- 路由 component 字符串来自后端菜单,
dynamicImport()用前缀匹配import.meta.glob('../views/**/*.{vue,tsx}')的 key,必须唯一命中(命中 0 个返回 undefined → 白屏;命中多个返回 false → 白屏)。 ensureRoutesInit()用模块级initRoutesPromise缓存同一个 Promise,连续快速导航只会初始化一次;失败时置空允许重试。MIN_VALID_TENANT_ID = 100000:小于该值的tid视为未设置,不会拼到登录回跳地址上。
开发 / 构建脚本速查表
| 命令 | 作用 |
|---|---|
pnpm i | 安装依赖(Node >= 22,pnpm 12.14.0) |
pnpm dev | vite --open,默认 http://localhost:8888 |
pnpm build | node --max-old-space-size=8192 vite build(8G 堆,防止 OOM) |
pnpm lintFix | eslint --fix src/ |
pnpm format | prettier --write . |
pnpm buildApi | 调 script/build-api.js → api_build/build.bat(Win)/ build.sh(Linux)生成 src/api-services/system/ |
pnpm deployWeb / deployServer / deploy | script/publish.js 打包发布 |
pnpm translate | script/translate.cjs 多语言翻译 |
pnpm genEleIconTypes | 生成 Element Plus 图标类型 |
⚠️ 注意事项 / 常见坑
- 别名只有
/@:import xxx from '/@/utils/xxx',不要写相对路径../../。 src/api-services/**是生成物,禁止手改;要改就改后端 Swagger 注释后重新生成(见「接口层」篇)。- 不要在模块顶层解构 store:
const { userInfos } = useUserInfo()拿到的是赋值前的旧引用,登录后读到空对象(详见「状态管理」篇)。 - 页面组件必须写
name:<script setup lang="ts" name="sysConfig">,由vite-plugin-vue-setup-extend注入,keep-alive 与后端菜单匹配依赖它。 - 新增页面要落进
views/且路径唯一:后端菜单component填system/config/index(不带.vue、不带views/前缀),dynamicImport才能唯一命中。 public/config.js每次vite启动时被覆写(fs.writeFileSync),不要手工编辑它,改.env*即可。
小结
main.ts的注册顺序 = 依赖顺序(pinia → i18n → router → 组件库)。App.vue只负责全局配置、挂件与副作用,业务一律不落到这里。- 路由是后端下发 + 前端 glob 映射,页面路径必须唯一且与菜单
component对应。 - 记住两条命令就能跑起来:
pnpm dev开发、pnpm buildApi同步接口契约。