前端工程结构
适用端:Web | 类型:机制
Vue 3.5 + Vite 8 + TypeScript 6 + Element Plus 2.13 + Pinia 3,包管理只用 pnpm。本页讲清目录职责、环境变量与 mode 的关系、alias 的顺序陷阱,以及「自动导入到底覆盖了什么」。
前置阅读:环境准备与启动。
它解决什么问题
CRUD 页面的配对约定是全项目最重要的结构规则:
src/api/<module>/<feature>/index.ts ↔ src/views/<module>/<feature>/index.vue
src/views/<module>/<feature>/<Feature>Form.vue例:src/api/system/post/index.ts ↔ src/views/system/post/{index.vue, PostForm.vue}。新增功能照这个形状放文件,别人不用问就能找到。
代码在哪
根目录
| 文件 / 目录 | 作用 |
|---|---|
package.json | name: ainb-ui-admin-vue3,version: 2026.07-snapshot,engines: { node >= 20.19.0, pnpm >= 8.6.0 } |
vite.config.ts | 主构建配置(159 行,alias / server / css / build 全在这里) |
build/vite/index.ts | 插件工厂 createVitePlugins(isBuild, env) |
build/vite/optimize.ts | optimizeDeps 的 include / exclude 清单 |
vitest.config.ts | 独立的单测配置(不复用 vite.config.ts,避免带上整套构建插件) |
.env .env.local .env.dev .env.test .env.stage .env.prod | 6 个环境文件 |
types/ | 全局 d.ts:global.d.ts(PageParam / PageResult)、router.d.ts(RouteMeta)、env.d.ts |
uno.config.ts | UnoCSS 原子类配置 |
eslint.config.mjs / stylelint.config.js / prettier.config.js | 代码风格(见下文「风格约定」) |
src/lib/ | ★ vendored 的 form-create + form-designer 源码副本 |
src/ 一级目录
| 目录 | 职责 | 备注 |
|---|---|---|
api/ | 后端接口封装 | 一级:agent ai bpm crm im infra login system |
views/ | 页面 | 一级:Error Home IFrame Login Profile Redirect agent ai bpm crm im infra system |
components/ | 全局组件,63 个目录 + index.ts | unplugin 自动注册,无需 import |
hooks/ | 组合式函数 | hooks/web/ 23 个 + hooks/event/useScrollTo.ts(共 24) |
store/modules/ | Pinia | user / permission / dict / app / tagsView / … |
router/ | index.ts + modules/remaining.ts(静态路由 697 行) | 详见路由与权限机制 |
config/axios/ | 请求封装(唯一非业务的 config 目录) | 详见axios 封装与 API 约定 |
directives/ | v-hasPermi / v-hasRole / v-mountedFocus | |
utils/ | dict / formatTime / dateUtil / tree / auth / download / encrypt / routerHelper / permission / formRules / table / … | |
layout/ | 布局与菜单渲染 | |
lib/ | ★ vendored form-create / form-designer | 零 diff,ESLint 已排除 |
plugins/ | echarts / elementPlus / formCreate / svgIcon / tongji / unocss / vueI18n / animate.css | |
locales/ | zh-CN.ts / en.ts | |
styles/ | variables.scss(被自动注入每个 SCSS)、index.scss(含 v-hasPermi 的隐藏样式) | |
types/ | 手写 d.ts 13 个 + 生成的 auto-imports.d.ts / auto-components.d.ts |
域与后端模块的对应
| 前端目录 | 后端模块 | 说明 |
|---|---|---|
views/system(20 个功能目录) | enlev-module-system | ★ 其中 resource / rule / role/grant / permissionDiag 是定制权限体系 |
views/infra(21 个) | enlev-module-infra | ★ 其中 dataModel / formDesign / dataInterface / portal / billTemplate / build 是低代码定制 |
views/bpm(10 个) | enlev-module-bpm | simple 是 SIMPLE 流程设计器 |
views/ai(8 个) | enlev-module-ai | model/apiKey 等 |
views/agent(7 个:assistant / config / def / promptTemplate / tooldef / usage / workflow) | ★ enlev-module-agent | 全自研 |
views/crm、views/im | 模块未启用 | 上游遗留,靠后端菜单过滤不显示 |
核心机制
环境变量与 mode
pnpm dev 的脚本是 vite --mode env.local。这里 env.local 是mode 名,不是文件名——Vite 无论如何都会加载 .env.local 这一档,所以最终生效的是 .env + .env.local。
vite.config.ts 里读 env 的写法也值得知道(开发态从命令行参数取 mode,构建态用 ConfigEnv.mode):
const isBuild = command === 'build'
if (!isBuild) {
env = loadEnv((process.argv[3] === '--mode' ? process.argv[4] : process.argv[3]), root)
} else {
env = loadEnv(mode, root)
}各环境的关键差异:
| 文件 | NODE_ENV | VITE_BASE_URL | VITE_OUT_DIR | 其它 |
|---|---|---|---|---|
.env(公共) | — | — | — | VITE_APP_TITLE=启凌智搭、VITE_PORT=80、VITE_APP_TENANT_ENABLE=true、默认登录租户/账号/密码、API 加密开关与 AES 密钥 |
.env.local | development | http://localhost:48080 | — | VITE_API_URL=/admin-api、VITE_APP_CAPTCHA_ENABLE=false、VITE_SOURCEMAP=false |
.env.dev | production | http://api-dashboard.enlev.cn | dist | VITE_SOURCEMAP=true、验证码开 |
.env.prod | production | ''(同源) | dist-prod | VITE_DROP_DEBUGGER/CONSOLE=true |
.env.test | — | — | dist-test | VITE_BASE_PATH=/admin-ui-vue3/ |
.env.stage | — | — | dist-stage | VITE_BASE_PATH 指向静态站 |
.env.local 是纳入 git 跟踪的
不合常理但确实如此。不要把它加进 .gitignore,也不要把里面的值搬到别处——它是本地联调的唯一事实来源。pnpm dev-server(--mode dev)连的是远端 api-dashboard.enlev.cn,不能用来验证本地后端改动。
alias:顺序敏感的 vendored 转发
@ 指向 src/,其余全是把上游的 @form-create/* npm 包名转发到工程内源码:
// vite.config.ts:75-121(节选,注释为原文)
alias: [
{ find: /\@\//, replacement: `${pathResolve('src')}/` },
// form-create 运行时已改为工程内源码(src/lib/form-create),不再走 npm 包。
// ⚠️ 顺序敏感:Rollup 的 alias 对字符串 find 只在 `id === find` 或
// `id.startsWith(find + '/')` 时命中,所以更长的前缀必须排在前面。
{ find: '@form-create/element-ui/auto-import', replacement: pathResolve('src/lib/form-create/element-ui/auto-import.js') },
{ find: '@form-create/element-ui', replacement: pathResolve('src/lib/form-create/element-ui/src/index.js') },
{ find: '@form-create/utils/lib', replacement: pathResolve('src/lib/form-create/utils/lib') },
{ find: '@form-create/utils', replacement: pathResolve('src/lib/form-create/utils/lib/index.js') },
// 控件包是 @form-create/component-elm-<name>/src/index 这种带子路径的写法,
// 字符串 find 在 `component-elm-` 这种半个路径段上匹配不到,只能用正则
{ find: /^@form-create\/component-elm-(.*)/, replacement: `${pathResolve('src/lib/form-create/components/elm-')}$1` },
// 设计器同样已 vendored(src/lib/form-designer,见其 VENDOR.md)
{ find: '@form-create/designer', replacement: pathResolve('src/lib/form-designer/index.js') }
]改这块之前先读注释:把 @form-create/utils 排到 @form-create/utils/lib 前面,后者的子路径导入会被前者截胡,报错还很难懂。
自动导入到底覆盖什么
build/vite/index.ts 原文:
AutoImport({
include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/, /\.md$/],
imports: [
'vue', 'vue-router',
{
'@/hooks/web/useI18n': ['useI18n'],
'@/hooks/web/useMessage': ['useMessage'],
'@/hooks/web/useTable': ['useTable'],
'@/hooks/web/useCrudSchemas': ['useCrudSchemas'],
'@/utils/formRules': ['required'],
'@/utils/dict': ['DICT_TYPE'],
'@/utils/table': ['indexMethod']
}
],
dts: !isBuild && 'src/types/auto-imports.d.ts',
resolvers: [ElementPlusResolver()]
}),
Components({
dts: !isBuild && 'src/types/auto-components.d.ts',
resolvers: [ElementPlusResolver()],
globs: ['src/components/**/**.{vue, md}', '!src/components/DiyEditor/components/mobile/**']
})翻译成人话:
| 能直接用、不用 import | 说明 |
|---|---|
ref / computed / watch / onMounted / … | vue 全部 API |
useRoute / useRouter / onBeforeRouteLeave / … | vue-router 全部 API |
useI18n / useMessage / useTable / useCrudSchemas | 项目自己的 4 个 hook |
DICT_TYPE / required / indexMethod | 字典枚举、必填规则、序号列方法 |
ElMessage / ElMessageBox / ElNotification / … | Element Plus(走 ElementPlusResolver) |
src/components/** 下所有 .vue | 全局组件;DiyEditor/components/mobile/** 被排除 |
不在自动导入范围内(必须显式 import):src/utils/dict 里除 DICT_TYPE 之外的函数(getIntDictOptions / getStrDictOptions)、src/api/**、src/store/**、其它 hooks(useTagsView / useCache / useFieldPermission …)、src/views/** 下的局部组件。
dts: !isBuild 意味着类型声明只在 dev 时生成
src/types/auto-imports.d.ts 与 auto-components.d.ts 只有跑 pnpm dev 才会写出来,且被 .gitignore 的 auto-*.d.ts 忽略(src/lib/**/auto-*.d.ts 有例外规则,那是 vendored 的上游文件)。新拉代码或新增全局组件后直接 pnpm ts:check,会报一堆「找不到组件」——先 pnpm dev 起一次再检查。
src/components/index.ts 里的 setupGlobCom 只手工全局注册了 Icon 与 LottieAnimation 两个,其余全靠 unplugin。
构建产物与分包
build: {
chunkSizeWarningLimit: 2000,
minify: 'oxc',
outDir: env.VITE_OUT_DIR || 'dist',
reportCompressedSize: false,
// 用独立的 .map 文件,不要内联。
// 内联时 vite 的 build-import-analysis 会走
// chunk.code.replace(convertSourceMap.mapFileCommentRegex, ''),
// 而这个正则在本项目这种体量的 chunk 上会回溯到爆栈:
// RangeError: Maximum call stack size exceeded,构建直接失败。
sourcemap: env.VITE_SOURCEMAP === 'true',
rollupOptions: {
output: {
minify: { compress: { dropDebugger: …, dropConsole: … } },
codeSplitting: {
groups: [
{ name: 'echarts', test: /node_modules[\\/]echarts[\\/]/ },
// form-create 运行时已 vendored 到 src/lib,不再在 node_modules 下,
// 这条 test 必须跟着改,否则它会散进主 chunk。
{ name: 'form-create', test: /src[\\/]lib[\\/]form-create[\\/]/ },
{ name: 'form-designer', test: /src[\\/]lib[\\/]form-designer[\\/]/ },
{ name: 'monaco-editor', test: /node_modules[\\/]monaco-editor[\\/]/ }
]
}
}
}
}四个大依赖各自成 chunk。压缩产物由 VITE_COMPRESS(gzip / brotli,可逗号组合)控制,只在 build 时生效。构建脚本自带 --max_old_space_size=8192,直接 pnpm build:prod 即可。
SCSS 变量注入
每个 SCSS 文件都会被自动前置 @use "…/src/styles/variables.scss" as *;,所以变量无需手工导入。这里有一段专门防 Windows 上重复注入的守卫:
additionalData: (source: string, filename: string) => {
const normalizedFilename = normalizePath(filename)
// Windows 下更容易触发重复注入:定义或显式转导变量的文件,不能再次注入同一个
// `@use ... as *`,否则 Sass 会报 duplicate global variables。
if (normalizedFilename.endsWith('/src/styles/variables.scss') ||
normalizedFilename.endsWith('/src/styles/global.module.scss')) {
return source
}
return `@use "${getRelativeScssUsePath(filename, variablesScssPath)}" as *;\n${source}`
},
api: 'modern-compiler'另有 css.lightningcss.errorRecovery: true,注释原文:「Preserve legacy star-hack declarations by stripping invalid syntax during minification.」——vendored 的老 CSS 里有 *property 这类星号 hack,不开这个会压缩失败。
风格约定
只列与常见默认不同的:
- Prettier:
semi: false、printWidth: 100、singleQuote: true、trailingComma: 'none'、arrowParens: 'always'、vueIndentScriptAndStyle: false;缩进 2 空格; - ESLint 关掉了大量 vue/TS 规则(
no-explicit-any、no-unused-vars、attributes-order、multi-word-component-names等),但强制vue/html-self-closing;src/lib/**整体排除; - Stylelint 强制
properties-order; - 提交前跑
pnpm lint(=lint:eslint:check+lint:style:check+lint:format:check),lint-staged.config.mjs也在。
怎么扩展
新增一个 CRUD 页面:
src/api/<域>/<功能>/index.ts:import request from '@/config/axios'+export interface XxxVO+getXxxPage / getXxx / createXxx / updateXxx / deleteXxx(可选exportXxx、getSimpleXxxList);src/views/<域>/<功能>/index.vue:列表页(ContentWrap+Search/查询表单 +el-table+Pagination);- 同目录
XxxForm.vue:新增/编辑弹窗(Dialog+el-form); - 菜单与权限串入库(后端),刷新后由
permissionStore.generateRoutes()自动生成路由——前端不需要手写路由; - 按钮上加
v-hasPermi="['<域>:<功能>:create']"。
新增一个全局组件:放 src/components/<Name>/(src/components/**/**.vue 会被自动扫描),跑一次 pnpm dev 让 auto-components.d.ts 更新,之后在任意页面直接 <Name />。若组件名与 Element Plus 冲突,ElementPlusResolver 优先,改用别的名字。
新增一个 hook:放 src/hooks/web/;只有希望「免 import 直接用」的才去 build/vite/index.ts 的 AutoImport.imports 里登记(登记后同样要重跑 pnpm dev)。
新增一个环境变量:以 VITE_ 开头写进 .env(公共)或 .env.local(本地),代码里 import.meta.env.VITE_XXX 读取。现有变量集中在 config/axios/config.ts、utils/encrypt.ts、utils/auth.ts、router/index.ts、Login 组件与 plugins/tongji。
坑与注意
useTable与useCrudSchemas虽然在自动导入清单里,但src/views中零调用点(实测 grep 为空)——它们是上游遗留能力。现有页面走的是「手写el-table+getList()」的路子,新写页面请跟随现有风格,不要为了用 hook 而重构。- 没有
useDicthook:取字典用src/utils/dict.ts的getIntDictOptions / getStrDictOptions / getBoolDictOptions(底层是 dict store),DICT_TYPE才是自动导入的。 src/api/types.ts不存在:PageParam/PageResult是types/global.d.ts里的全局声明,直接写、不要 import。- Vite 8 的分包 API 是
output.codeSplitting.groups,不是老的manualChunks。抄网上教程时注意。 vitest.config.ts的environment是node,不是 jsdom——现有 58 个*.spec.ts都是纯逻辑测试。要测组件行为得先改 environment 并补 DOM 依赖。pnpm test:e2e是坏的:playwright.config.ts与tests/e2e/都不存在。ts:check走增量缓存,缓存脏了用pnpm ts:check:clean;它带--max_old_space_size=8192,内存给够。- 不要用
npm i/yarn:会重写 lockfile,vendored 的src/lib与 pnpm 的构建白名单(pnpm-workspace.yaml的allowBuilds)都可能受影响。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
相关页面
- 路由与权限机制:菜单怎么变成路由、
v-hasPermi怎么判; - axios 封装与 API 约定:
src/config/axios全解; - 通用组件与 hooks:63 个组件目录与 24 个 hook 的选型指南;
- 仓库结构与改名约定:
src/libvendored 的来龙去脉。
