耗时数月独立开发,摘录最具代表性的架构决策与实现细节,欢迎 Star 交流。
一、项目定位:前端开发者进阶全栈的桥梁
YunHe-Vue(云禾管理系统)是一套面向前端开发者进阶全栈的企业级后台管理系统模板。
它的核心设计理念是:TypeScript 一把梭。前端用 Vue 3 + TS,后端用 NestJS + TS,同一门语言、同一套类型系统,配合 NestJS 与 Vue 高度相似的「模块化 + 依赖注入 + 装饰器」开发范式,让前端开发者在熟悉的生态里完成全栈能力的平滑过渡。
| 项目信息 | 地址 |
|---|---|
| 在线演示 | cnbox.online |
| 项目文档 | ace627.github.io |
| 开源仓库 | github.com/Ace627/YunH... |
| 文档仓库 | github.com/Ace627/ace6... |
⚡ 项目正在持续迭代维护中,Star 是最好的更新动力。
二、技术全景
| 层级 | 技术 | 选型理由 |
|---|---|---|
| 前端框架 | Vue 3.5 + Composition API | 最新 Vue 生态,<script setup lang="ts"> 语法 |
| 前端路由 | Vue Router 5 | 动态路由 + 权限过滤,路由配置完全由后端下发 |
| 状态管理 | Pinia 3 | 轻量、类型友好,天然适配 Composition API |
| UI 组件库 | Element Plus 2 | 企业级组件库,按需导入,SCSS 变量主题化 |
| 构建工具 | Vite 8 + Rolldown | 秒级冷启动,内置拆包 / 压缩 / 分析全链路 |
| CSS 方案 | SCSS + BEM + UnoCSS | 语义化主方案 + 原子化为辅,灵活可切换 |
| 后端框架 | NestJS 11 | 企业级 Node.js 框架,模块化 + 依赖注入 + 装饰器 |
| ORM | TypeORM | Active Record / Data Mapper 双模式,支持 Migration |
| 数据库 | MySQL 8 + Redis 7 | 业务数据 + 缓存 / 队列 |
| 消息队列 | BullMQ | 基于 Redis 的可靠队列,支撑定时任务调度 |
| AI 服务 | LangChain + OpenAI | 流式对话、上下文管理、Token 统计 |
| 容器化 | Docker + Nginx | 多阶段构建 + 健康探针 + Nginx 反向代理 + gzip_static |
| Monorepo | pnpm workspace | 前后端同仓,统一脚本,共享类型 |
三、核心系统设计 ------ 每一个功能都有它的设计考量
1. 前后端一体化的 RBAC 权限模型
权限模型是后台系统的骨架,设计不好会让整个项目越写越乱。我的方案是 RBAC(基于角色的访问控制)+ 前后端双闭环:
后端侧:
- 用户 → 角色 → 菜单,菜单粒度为「目录 / 菜单 / 按钮」三级
- 每个接口通过
@RequirePermissions装饰器声明所需权限码 - JWT 签发时写入用户角色与权限集合,Guard 层统一校验
- 公共接口通过
@Public装饰器标记跳过鉴权
前端侧:
- 菜单数据由后端下发,前端递归生成路由表并动态注册
- 路由守卫在页面跳转时校验权限,无权限重定向 403
v-permissions指令控制按钮级 DOM 显隐,比v-if更语义化v-roles指令按角色控制,适用场景比权限码更粗粒度- Permission Store 集中管理路由状态,全局可消费
设计价值:权限配置全部收敛到后台管理界面,增删菜单角色无需动代码、无需重新部署前端,真正实现运维与开发分离。
2. 大文件分片上传 ------ 完整的企业级文件传输方案
处理大文件上传不是「能传上去就行」,真正的挑战在于:上传中断后怎么办?重复上传同一文件怎么避免?合并分片时怎么不把服务器内存打满?
我的方案覆盖了全生命周期:
前端:计算文件 MD5 → 调用检查接口 → 已存在直接秒传,未上传则按分片并发上传 → 全部上传完成调用合并接口。
后端 :checkFile 接口先查目标文件是否存在(秒传),再查临时目录有哪些分片(断点续传),返回已上传的分片列表,前端只传缺失分片。合并时使用 Node.js Stream 管道,边读边写不占内存 ,这在文件较大的情况下与 fs.writeFileSync(buffer) 是数量级的差距。
typescript
// 流式合并------不积攒内存,一台小机器也能合大文件
const writeStream = createWriteStream(finalFilePath)
for (const chunk of chunks) {
await pipeline(createReadStream(resolve(tempDir, chunk)), writeStream, { end: false })
}
writeStream.end()
📌 当前因服务器资源有限,文件写入本地
uploads目录。但合并逻辑基于 Stream 抽象,替换读写目标为 MinIO / OSS 的 SDK 流即可无缝接入云存储,按需改造即可,不涉及业务层改动。
3. 动态定时任务调度 ------ 从硬编码到可视化
传统定时任务最常见的痛点是:需求让你凌晨 3 点跑个 Excel 导出,你得改代码发版。
我的方案基于 BullMQ + Redis 实现运行时动态调度:
- 所有业务 Service 通过 NestJS 的
DiscoveryService自动扫描注册到任务服务中 - 新增任务只需在后台界面填写「Service 类名.方法名(参数)」即可,格式校验由
analysisInvokeTarget方法完整覆盖 - Cron 表达式的启停、修改、手动执行全部通过 API 操作,无需重启服务
- 任务执行失败时,支持「立即重试」与「执行一次」两种错误策略,杜绝雪崩
- 完整的日志记录、分页查询、Excel 导出
typescript
// 自动发现业务 Service,无需手动注册
private loadBusinessServices() {
const providers = this.discovery.getProviders()
for (const wrapper of providers) {
const { metatype } = wrapper
if (!metatype || !metatype.name.endsWith('Service')) continue
this.serviceMap.set(metatype.name, metatype)
}
}
设计价值:任务配置全部可视化,运维人员不需要理解代码,开发者不需要改代码发版。
4. AI 对话模块 ------ 企业级落地 LangChain 的完整实践
AI 功能很容易做成 Demo,但真正上线需要考虑:Token 成本怎么控制?长对话上下文怎么管理?并发请求怎么处理?
我的落地策略:
- 流式响应:使用 SSE 协议逐字返回,用户体验类似 ChatGPT
- 懒更新摘要 :不是每轮对话都调 AI 生成摘要。设定阈值(如 12 条消息),且只有消息数到达阈值后才每隔 6 条消息生成一次摘要,大幅削减 AI 调用次数
- 上下文策略:摘要 + 最近 6 条消息组装上下文,在上下文完整度与 Token 消耗之间取平衡
- 历史持久化:消息、会话、摘要、Token 用量全部入库,支持会话列表与管理
typescript
// 懒更新摘要------只在需要的时候才调 AI,用最小的成本保留上下文
private async lazyUpdateSummary(conversationId: string) {
const count = await this.messageRepository
.createQueryBuilder('message')
.where('message.conversationId = :conversationId', { conversationId })
.getCount()
if (count < 12) return // 不够 12 条,不生成
if ((count - 12) % 6 !== 0) return // 不在更新节点,跳过
const summary = await this.generateSummary(conversationId)
await this.conversationRepository.update(conversationId, { summary })
}
设计价值:这不是一个玩具 Demo,而是真正考虑了生产环境成本和可维护性的工程方案。
5. 云原生健康检查 + 企业级日志 + 操作审计
这一组模块共同构成了系统的「可观测性」:
| 模块 | 实现 |
|---|---|
| 健康检查 | /live(存活探测)+ /ready(就绪探测)双探针,适配 K8s Pod 生命周期;网络 / 数据库 / 内存 / 磁盘 / RSS 全维度检查 |
| 日志系统 | Winston 集成,按日期滚动分割,压缩归档,自动清理 14 天前日志;开发环境控制台输出,生产环境 JSON 文件 |
| 操作日志 | OperationLogInterceptor 拦截器,自动记录请求耗时、IP + 归属地、query + body 参数、成功/失败状态 |
| 登录日志 | 登录成功 / 失败全量记录,IP + 归属地 + 设备 + 浏览器信息 |
| 在线用户 | Redis 维护活跃 Session,支持强制下线 |
四、前端工程化 ------ 让代码自己说话
如果说后端系统设计体现的是架构能力,那前端工程化体现的是开发者对开发体验和代码品质的追求。
4.1 构建全链路优化
智能拆包
不是简单地把 node_modules 拆成 vendor 就完了。我按模块体积、更新频率、缓存策略做了精细化分组:
typescript
// vite.config.ts
codeSplitting: {
groups: [
{ name: 'element-plus', test: /node_modules[\\/]element-plus/, priority: 30 },
{ name: 'echarts', test: /node_modules[\\/](echarts|zrender)/, priority: 25 },
{ name: 'vue-vendor', test: /node_modules[\\/](vue|vue-router|pinia)/, priority: 15 },
{ name: 'utils', test: /node_modules[\\/](dayjs|axios|lodash-es)/, priority: 12 },
{ name: 'vendor', test: /node_modules/, priority: 10 },
]
}
设计逻辑 :element-plus 和 echarts 体积大且更新频率低,最高优先级独立拆包,最大化浏览器缓存命中率;vue-vendor 是框架本身,几乎不更新,也单独拆出;剩余的按优先级逐级兜底。
压缩与部署闭环
- 构建端 :
vite-plugin-compression生成.gz文件,压缩级别 9,阈值 10KB - 部署端 :Nginx 开启
gzip_static on,直接使用构建产物,不在服务器端实时压缩,降低 CPU 负载 - 环境变量 :
VITE_DROP_CONSOLE和VITE_DROP_DEBUGGER控制产物清理,生产环境自动剔除
打包分析
集成 rollup-plugin-visualizer,构建后自动生成 stats.html,展示各模块体积和 gzip / brotli 压缩对比,方便做针对性优化。
4.2 开发体验优化
自动按需导入
三个插件形成闭环:
typescript
// vite.config.ts
// 1. Element Plus 组件 + 样式按需导入
ElementPlus({ useSource: true })
// 2. Vue / Pinia / Vue Router / VueUse 自动导入
AutoImport({
resolvers: [ElementPlusResolver({ importStyle: 'sass' })],
imports: ['vue', 'pinia', 'vue-router', '@vueuse/core'],
dts: 'types/auto-generate/auto-import.d.ts',
dirs: ['src/store/modules', 'src/hooks'],
})
// 3. 组件自动注册
AutoComponents({
resolvers: [ElementPlusResolver({ importStyle: 'sass' })],
dts: 'types/auto-generate/auto-components.d.ts',
})
效果 :ref、computed、onMounted、Store、Hooks 全部告别手动 import,IDE 类型提示完美运行。
UnoCSS 原子化 CSS
基于 presetWind3 预设,兼容 Tailwind / Windi / Bootstrap 规则,同时封装了项目级快捷方式和自定义规则。作为 SCSS + BEM 主方案的补充,在需要快速布局或微调间距的场景下使用,不强制,不捆绑。
SVG 图标管理自动化
给设计师要的 SVG 经常带一堆 AI / Sketch 导出的冗余属性。写了一个脚本:
json
// package.json
"svg:clean": "esno ./scripts/svg-clean.ts"
丢到 src/assets/icons 目录下,执行 pnpm svg:clean,批量清理无用属性。同时所有图标统一通过 SvgIcon 全局组件使用,name 直接取文件名,零配置。
4.3 组件设计的取舍
ProTable / ProSearch / ProPagination:组合而非集成
很多后台模板喜欢做一个巨型 ProTable,把搜索、表格、分页全部塞到一起,用起来确实方便,但一旦有定制需求就无从下手。
我的方案是三个独立组件,各司其职:
ProTable负责表格渲染,通过 Proxy 代理暴露所有 Element Plus 原生方法ProSearch负责搜索表单,配置化生成ProPagination负责分页逻辑
最大的好处是:你不需要的时候可以不引入,需要定制的时候可以直接用 Element Plus 原生组件,不会被封装层束缚。
ECharts 封装:按需引入 + 防御式设计
ECharts 全量引入会显著增加包体积,我用了一个专门的入口文件只注册项目实际使用的图表类型和组件。ProChart 组件内部用 ResizeObserver 监听容器变化自动重绘,主题切换时同步更新配色,onUnmounted 中调用 dispose 防止内存泄漏。
DictTag:字典回显的最后一公里
前面通过 useDict Hook 将字典数据全局缓存到了 Pinia Store,但回显时总不能每处都手写 dictList.find()。DictTag 组件封装了这一步------传入字典类型和值,自动匹配回显文案和样式:
vue
<DictTag dictType="sys_status" :value="row.status" />
4.4 一个你可能没注意到的细节:KeepAlive 自动匹配
动态加载的组件没有静态 name,KeepAlive 无法匹配,缓存失效。传统做法是手动写 defineOptions({ name: 'xxx' }),但动态路由上百个页面,漏一个就失效。
我的方案是在组件加载时自动注入 name:
typescript
// router.helper.ts
export function loadView(componentPath: string) {
// ...
const componentName = upperFirst(camelCase(componentPath.replace('index', '')))
const component = views[path]
return () => component().then((comp) => ((comp.default.name = componentName), comp))
}
动态页面一个 defineOptions 都不用写,只有静态路由(如 404)才需要。这个细节能体现你对 Vue 组件加载与 KeepAlive 匹配机制的深入理解。
4.5 更多工程化细节速览
| 模块 | 做了什么 |
|---|---|
| 缓存工具 | 参考 Redis 接口封装 localStorage,支持设置过期时间、通配符 key 查询、获取剩余 TTL,统一缓存键常量管理,避免硬编码和冲突 |
| 进度条 | useProgress Hook 封装 nprogress,支持从环境变量控制是否开启,默认配置合并,在 axios 拦截器和路由守卫中统一触发 |
| 动态标题 | useDynamicTitle Hook,监听路由变化自动更新 document.title,onUnmounted 清理监听器 |
| 递归菜单 | SidebarItem 组件递归渲染任意层级菜单,单叶子节点自动提升,外部链接新窗口打开,CSS 变量主题化 |
| 系统配置 | defaultSettings 集中管理所有配置项默认值,SettingPanel 可视化配置面板,用户偏好自动持久化到 local,支持一键重置 |
| 首屏动画 | 纯 CSS 实现加载动画,避免白屏 |
| 主题切换 | View Transition API 实现从点击位置向外扩散的圆形过渡动效,SCSS @forward 自定义 ElementPlus 主题变量,Vite 预处理器自动注入 |
| 模块化启动 | main.ts 只负责串联,setupPlugins / setupDirectives / setupStore / setupRouter 各自独立,职责清晰 |
五、后端架构 ------ 分层解耦与防御性设计
5.1 响应缓存拦截器 + 防缓存雪崩
热点接口频繁调用会打满数据库连接。我在 NestJS 拦截器层面实现了一套缓存方案:
@ResponseCache装饰器声明缓存 key 和 TTLResponseCacheInterceptor透明拦截:命中 Redis 直接返回,未命中执行接口后写入 Redis- TTL 加随机抖动:每个 key 的过期时间在上限基础上叠加 0~20% 的随机偏移,避免缓存同时过期引发雪崩
typescript
// 防雪崩的 TTL 计算
const baseTtl = options.ttl || 60
const jitter = Math.floor(Math.random() * (baseTtl * 0.2))
const ttl = baseTtl + jitter
这与 Redis 官方的 EXPIRE 抖动策略原理一致,但你不需要在业务代码里重复实现,一个装饰器搞定。
5.2 异常处理
AllExceptionsFilter 全局捕获异常,区分 BusinessException(业务异常,返回友好提示)与未知异常(记录日志并返回通用错误信息),避免敏感信息泄露到前端。
5.3 安全防护
- Helmet:设置安全 HTTP 头
- 限流 :
ThrottlerGuard限制接口调用频率 - 密码加密:Argon2 算法,比 bcrypt 更抗 GPU 暴力破解
六、快速开始
项目脚本一览
json
{
"scripts": {
"dev:server": "pnpm --filter server start:dev",
"build:server": "pnpm --filter server build",
"dev:admin": "pnpm --filter admin dev",
"build:admin": "pnpm --filter admin build",
"docker:up": "docker compose up -d --build",
"docker:down": "docker compose down",
"docker:restart": "docker compose down && docker compose up -d --build",
"docker:reset": "docker compose down -v && docker compose up -d --build",
"svg:clean": "esno ./scripts/svg-clean.ts"
}
}
Docker 一键启动(推荐)
bash
pnpm docker:up
服务启动后访问 http://localhost 即可。
本地开发
bash
pnpm install
pnpm dev:admin # 前端
pnpm dev:server # 后端
七、写在最后
这个项目不是抄模板拼凑出来的,是我一个人从空目录开始,一步步搭建架构、封装组件、解决 Bug、打磨细节的结果。
它代表了我对「什么是高质量代码」的理解:
- 组件封装要有边界感------不把功能做死,不把灵活度做没
- 工程化配置要形成闭环------构建、压缩、部署三端对齐,不搞半吊子方案
- 每个功能都要经得起追问------不只是「能跑」,而是能说出「为什么这样设计」
如果你也在进阶全栈的路上,希望这个项目能给你一些参考和启发。遇到任何问题,欢迎提 Issue 交流。
⚡ 项目正在持续迭代维护中,觉得有帮助的话,Star 是最好的鼓励。
开源仓库 :github.com/Ace627/YunH...
在线演示 :cnbox.online
项目文档 :ace627.github.io