11 KiB
g3soft-erp 前端架构设计(v0.1)
状态:前端骨架已落地并验证通过。后端与元数据引擎未开始。
1. 定位与范围
本文档解决:g3soft-erp 前端的项目骨架怎么搭。
不在范围内(后续单独立项):
| 项 | 状态 |
|---|---|
| 元数据驱动引擎(通用查询 / 保存 / 权限) | 未设计 |
| 后端设计与 Java 分包 | 未开始 |
stk-table-vue(虚拟滚动表格) |
待定 |
| LogicFlow(审批流设计器) | 待定 |
| 登录 / 权限 / 用户模块 | 未开始(骨架已留位) |
2. 技术栈(已定)
| 项 | 选型 |
|---|---|
| 框架 | Vue 3.5 + Vite 8 |
| 语言 | TypeScript(strict) |
| 路由 | vue-router 5,静态路由 + 常量菜单 |
| 状态 | Pinia 4 + pinia-plugin-persistedstate |
| 样式 | Sass + @g3soft/tokens |
| UI 库 | @g3soft/ui(本地 link 开发,私库版本发版) |
| HTTP | axios(统一拦截器) |
| 图标 | @lucide/vue |
| 规范化 | oxlint + oxfmt |
| 测试 | Vitest + happy-dom |
| 日期 | dayjs |
后端(未开始,已定):Spring Boot 4.1 + Java 21 + spring-boot-starter-jdbc + Druid + SQL Server + JJWT + Apache Fesod。无 ORM,纯 SQL 驱动。
3. 目录结构
仓库层
g3soft-erp/
├── pnpm-workspace.yaml # 仓库唯一配置文件:overrides + allowBuilds
├── web/ # 前端应用(前端唯一的 package.json 在此)
├── server/ # 后端占位(Maven,未开始)
├── docs/ # 本文档
└── README.md
根目录刻意不放 package.json。 理由:
- 前端可交付物只有
web/一个包,那个package.json就是web/package.json,身份唯一。 - 后端
server/由 Maven 管理,不受 Node 管;一个成员的 workspace 套 monorepo 的壳只是形式主义。 - 代价:命令要
cd web再跑,根目录敲不了pnpm dev。这是有意的取舍。
pnpm-workspace.yaml 仍需保留,因为它承担两件必须在 workspace 根生效的事:overrides(把 @g3soft/ui 指向 g3soft-libs)和 allowBuilds(pnpm 11 构建脚本白名单)。已验证:根目录没有 package.json 时 overrides 依然生效。
前端层
web/src/
├── main.ts # 入口:Pinia / Router / G3UI.config / 样式
├── App.vue
├── router/
│ ├── index.ts # createRouter + 守卫 + NProgress
│ └── modules/ # 每业务域一个路由文件
│ ├── ocean.ts air.ts container.ts finance.ts crm.ts system.ts
├── layouts/
│ ├── DefaultLayout.vue # 主布局:侧栏 + 顶栏 + 页签 + 内容区
│ ├── LayoutSidebar.vue # 侧栏(含折叠)
│ ├── LayoutHeader.vue # 顶栏(含面包屑标题、用户区)
│ ├── LayoutTabbar.vue # 页签栏
│ ├── useSidebarMenu.ts # 菜单逻辑(组合式)
│ └── icons.ts # 菜单图标注册表
├── views/ # 页面,与 router/modules 一一对应
│ ├── ocean/ air/ container/ finance/ crm/ system/
│ └── error/NotFound.vue
├── components/ # 跨域共享组件(初始只有 DomainPlaceholder)
├── stores/
│ ├── pinia.ts # createPinia + 持久化插件
│ ├── app.ts # 壳层状态:侧栏、页签
│ ├── user.ts # 用户与会话
│ └── index.ts # 统一导出
├── services/
│ ├── request.ts # axios 实例 + 拦截器 + http<T>()
│ └── api/ # 按域划分的接口封装(暂空)
├── composables/ # 跨域组合式函数(暂空)
├── types/ # env.d.ts、menu.ts
├── styles/
│ ├── index.scss # 入口:tokens → ui → global
│ ├── variables.scss # 转发 tokens 的 scss API(不 emit CSS)
│ └── global.scss # reset + 全局
├── constants/
│ ├── menu.ts # 菜单常量(唯一数据源)
│ └── common.ts # 存储前缀、标题、API_BASE
└── utils/ # 全局工具(暂空)
关键约定
-
components/、composables/、utils/刻意留空 遵循「为真实出现的第二个使用方而抽象」:只有一个业务域用的东西放该域内(如views/ocean/utils.ts),出现第二个真实调用方才上提。不要因为"以后可能复用"提前抽一层。 -
业务域内部自包含
views/ocean/ ├── Index.vue # 页面 ├── components/ # 只属于海运的组件 └── utils.ts # 只属于海运的逻辑 -
样式:组件库用
g3-前缀 + BEM;业务侧页面样式用scoped;颜色尺寸一律走--g3-*变量,不自定义色值。 -
布局壳的样式不加
scoped(LayoutSidebar.vue等),因为需要被子元素感知尺寸;业务组件必须加scoped。 -
不兼容历史持久化数据
localStorage里存过的偏好设置(g3erp:preferences)、页签(g3erp:app)等,不做版本迁移、不做字段兜底。 删字段或改字段名时直接删,不要写if (oldField)这类兼容分支——store.ts的mergePreferences只遍历defaultPreferences的键,旧数据里多出来的键自然被忽略。 理由:开发期数据结构还在变,为一次性数据写迁移是纯负债。用户清一次 localStorage 即可。
4. 菜单与路由:单一数据源
这是骨架的核心设计。
constants/menu.ts 是菜单与路由的唯一数据源,新增业务域只改这一处:
constants/menu.ts
│
├──→ 侧栏菜单渲染(useSidebarMenu)
├──→ 首页重定向目标(router/index.ts 的 firstMenuPath)
└──→ 页签标题来源(DefaultLayout 的 watch,取 route.meta.title)
新增一个业务域的完整步骤:
constants/menu.ts加一项(key / title / path / icon)layouts/icons.ts注册图标(若用了新图标)router/modules/<域>.ts新增路由文件,在router/index.ts里展开views/<域>/Index.vue建页面- 在
DefaultLayout的 children 里已自动生效
页签的标题取自
route.meta.title,所以路由里写了title的页面会自动出现在页签栏;写meta: { affix: true }则不可关闭。
未来接入后端动态菜单时:constants/menu.ts 退化为「无权限时的兜底」或直接删除,useSidebarMenu 改为从 store 读。当前不做动态路由——引擎未设计,现在做等于写死一套假契约。
5. 状态管理
初始只有两个 store,业务域 store 用到再建。
| store | 职责 | 持久化 |
|---|---|---|
app.ts |
侧栏折叠、页签 | 只持久化 sidebarCollapsed(pick 显式声明) |
user.ts |
token、用户信息 | token 存 localStorage |
约定(沿用 fms 已验证的规则):
- 统一 Setup Store:
defineStore('name', () => {}, options) - 一个 store 一个领域,避免重复状态源
persist必须写pick;加载态、页签态不持久化
6. 样式链路
styles/index.scss
├─ @use '@g3soft/tokens' → :root 的 --g3-* 变量(全局只引一次)
├─ @use '@g3soft/ui/style.css' → 组件库样式
└─ @use './global' → reset + 全局
局部样式需要变量/mixin 时,用 styles/variables.scss(转发 @g3soft/tokens/scss,不 emit CSS)。
⚠️ 不要在每个
.vue里@use '@g3soft/tokens'——那会让:root在每个组件样式里重复输出。只有styles/index.scss引一次。
7. 验证结果
下列验证在「拍平为 web/ 且删除根 package.json」之后重跑过,全部通过:
| 项 | 结果 |
|---|---|
pnpm typecheck(vue-tsc strict) |
✅ 0 错误 |
pnpm lint(oxlint --deny-warnings) |
✅ 0 warnings / 0 errors |
pnpm fmt:check(oxfmt) |
✅ 全部符合 |
pnpm build |
✅ 3946 modules,路由级分包生效 |
pnpm dev |
✅ localhost:5090,/ 与 /src/* 均 200 |
@g3soft/ui link 解析 |
✅ Junction → g3soft-libs/packages/ui,dist/index.js 真实存在 |
@g3soft/tokens link 解析 |
✅ Junction → g3soft-libs/packages/tokens |
| Vue 去重 | ✅ 仅一份 vue@3.5.43 |
根目录无 package.json 时 overrides 是否生效 |
✅ 生效(install 无警告,路径解析正确) |
G3UI / zhCN 具名导出 |
✅ 存在于 dist |
搭建中实际踩到并修掉的坑(供参考):
pnpm.overrides写在包级无效 —— 只在 workspace 根生效,已移到pnpm-workspace.yaml。@lucide/vue@1.53.0的图标名与旧版不同 ——MenuFold/MenuUnfold不存在,应使用PanelLeftClose/PanelLeftOpen;FileQuestion不存在,应为FileQuestionMark。图标不确定时先查node_modules/@lucide/vue/dist/lucide-vue.d.ts。- link 方案必须配
dedupe: ['vue', ...],否则响应式失效。
另:验证过程中曾用沙盒推断「无根 package.json 时
link:路径会错位一层」,该结论是错的——沙盒的目录层级与真实项目不同。真实项目实测路径解析正确。教训:相对路径解析这类问题必须用真实项目验证,沙盒结论不可直接采信。
8. 待确认事项
| # | 事项 | 影响 | 阻塞什么 |
|---|---|---|---|
| 1 | 私库是否存在 | 决定 CI 中 @g3soft/ui 的引用方式(当前是相对路径 link) |
CI 搭建 |
| 2 | g3soft-libs/packages/framework 是否删除 |
若保留,布局壳可复用,本次手写的 layouts/* 可简化 |
无(骨架已自建) |
| 3 | stk-table-vue / LogicFlow |
列表页与审批流设计器选型 | 对应功能开发 |
| 4 | 登录 / 权限模型 | 影响 router 守卫与 user store |
用户模块 |
| 5 | 后端启动 | server/ 目前仅占位 |
联调 |
| 6 | H5 端规划 | h5/ 与 web/ 平级、互相独立;@g3soft/ui 不给 H5 用 |
H5 立项 |
9. 后续建议
- 先做业务域页面,还是先做引擎? 建议先做 1~2 个真实业务页面(如海运委托单列表 + 明细),用真实需求反推引擎该提供什么能力。直接设计引擎容易做成「想象中通用、实际不好用」。
- SQL Server 方言要单独抽层
保留 SQL Server 已定,但元数据引擎的动态 SQL 生成器会被方言绑死(分页
OFFSET/FETCH、字符串拼接用+而非||、JSON 函数弱)。把 SQL 构建抽成一层方言适配,不要让TOP 10这类语法散落在业务代码里——将来真要换库只改那一层。 - 提交信息带端前缀
同仓建议用
feat(web):/fix(api):,方便区分改动影响哪端。 - H5 端不要急着抽共享层
@g3soft/ui不给 H5 用,两端就是独立应用。等 H5 真正落地、且两处出现形态接近的重复代码时,再考虑抽共享包——不要为「以后可能要共用」提前建packages/。