pnpm monorepo,按角色分包:
teacher/student/parent三端共享packages/shared(身份/登录态、通用组件、接口封装)。 本文档覆盖四块:技术栈 / 目录结构与用途 / 编码规范 / 构建。接口契约见工作区文档库unified/、academic/05-architecture/、teaching/05-architecture/(本仓库不复制契约原文)。 框架层选型(2026-11-06 定稿):UMD 混合方案(Vue 系 UMD 自托管 + Vant npm 编译,因 Vant 4 官方无 UMD 构建)。
一个移动端仓服务三个角色端:
| 端 | 主要场景 | 主要域 | 当前状态 |
|---|---|---|---|
| 老师端 | 打卡、查看/批改练习、备课查看 | 教务(打卡)+ 教学(练习/备课) | ✅ 落地(4 门禁全绿) |
| 学生端 | 做题、查看练习记录与分析 | 教学 | ⏳ 待按 teacher 模板建 |
| 家长端 | 绑定孩子、查看孩子练习/费用 | 教务(bindchild 绑定、费用)+ 教学(练习) | ⏳ 待按 teacher 模板建 |
三端底层用户统一(canonical_user_id + 7 角色),故共用 shared 的 SSO 接入与登录态,仅按角色分包差异视图。
| 层 | 内容 | 引入方式 | 版本管理 |
|---|---|---|---|
| 框架层(UMD 自托管) | Vue 3 / vue-demi / Vue Router 4 / Pinia / axios,共 5 项 UMD | index.html 按固定顺序引入,源文件自托管到自有 OSS(自定义域名 + SSL);dev 经 Vite publicDir 本地命中 vendor/ |
版本事实 = index.html 头部"vendor 版本表"(钉死次版本)+ 仓库 vendor/ 源文件(git 入库) |
| UI 层(npm 编译) | Vant 4(官方无 UMD 构建,仅 es/lib) | import Vant from 'vant' 编译进 bundle(app.use(Vant) 全局注册 + import 'vant/lib/index.css') |
pnpm-lock.yaml 锁版本(vant@4.10.2) |
| 业务层 | 本项目全部业务代码(TS 编写) | 本地 pnpm 工具链编译(Vite/vue-tsc/ESLint/Prettier 为 dev 依赖),产物 dist/ 经 nginx 部署 |
pnpm-lock.yaml 入库 |
es/(ESM)+ lib/(CJS)构建,无 UMD/IIFE 产物(设计上只给打包器消费)。故"UMD 自托管"最多做到 Vue 系 5 项;Vant 走 npm 编译进 bundle 属设计预期,dist 含 Vant 运行时(与 PC 端"dist 不含 ElementPlus"的差异点,由 Vant 无 UMD 决定)。pnpm dev:teacher 起 Vite 本地服务(publicDir=vendor,框架 UMD 本地命中;HMR 只热更业务代码);/api 代理到后端 dev 地址。pnpm build 打包业务 + Vant → dist/;产物禁止包含 Vue 框架运行时(质量门禁 grep 抽查,防 alias 配错导致 vue 包被打进 bundle)。index.html 版本表为准)| # | 资源 | OSS 路径模板 | 全局变量 | 用途 |
|---|---|---|---|---|
| 1 | Vue 3(prod UMD) | vue/{v}/vue.global.prod.js |
Vue |
框架本体 |
| 2 | vue-demi(IIFE) | vue-demi/{v}/vue-demi.iife.js |
VueDemi |
pinia 桥接层:pinia IIFE 构建硬依赖 VueDemi 全局;漏加载 → pinia 报 VueDemi is not defined。与 pinia 同升同降 |
| 3 | Vue Router 4 | vue-router/{v}/vue-router.global.prod.js |
VueRouter |
路由(按端组织 + 守卫链) |
| 4 | Pinia | pinia/{v}/pinia.iife.prod.js |
Pinia |
状态(login / scope 两 store) |
| 5 | axios | axios/{v}/axios.min.js |
axios |
HTTP(统一封装于 shared/api/http) |
OSS 根路径固定:
https://<自有OSS域名>/static/vendor/<库名>/<版本号>/<文件名>/,只增不删。 与 PC 端差异:移动端 UMD 仅 5 项(无 Element Plus 系列,UI 库走 Vant npm);vue-demi 同理保留(pinia 桥接层)。
| 关注点 | 选型 | 理由 / 依据 |
|---|---|---|
| 构建 | Vite 6.x | 业务代码 HMR + 产物构建;框架 UMD 不打包 |
| 语言 | TypeScript strict | 契约类型化:R<T> / PageData<T> / 各域 DTO;UMD 全局经仓库根 global.d.ts 声明类型 |
| UI 组件库 | Vant 4(npm 编译,默认中文) | 移动交互组件库;Vant 4 默认简体中文无需传 locale;app.use(Vant) 全局注册 + import 'vant/lib/index.css' |
| 状态 | Pinia(UMD 自托管) | login(token/角色/权限码/tabTree)+ scope(institution/campus 作用域) |
| 路由 | Vue Router 4(UMD 自托管) | 按端组织 + 守卫链(登录态 → 静默刷新 → 白名单 → 作用域就绪) |
| HTTP | axios 1.x(UMD 自托管,封装于 shared/api/http) |
统一信封处理 + 401 refresh 单例重放;业务错误不 reject |
| 鉴权 | SSO token(access 30min + refresh 30 天,ADR-014)+ 微信 per-campus 授权(家长端主入口,占位) | 统一 identity 签发;菜单/按钮按权限码显隐,禁 role == N 硬编码 |
| 包管理 | pnpm 10.12.1(lockfile 入库) | monorepo 工作区管理(packages/shared + apps/{端}) |
| 代码风格 | ESLint 9 + Prettier 3 | 提交前 lint;no-explicit-any 强制 |
| 样式 | plain CSS token(tokens.css,:root 自定义属性 --app-*) |
main.ts 早引入;组件 var(--app-xxx) 引 token,禁硬编码色值/尺寸。规避 PC 端 SCSS/sass 依赖 |
| 类型检查 | vue-tsc --noEmit(双工程:根 shared + 各端独立) |
质量门禁 0 错误 |
vendor/ 对应目录并 commit → 3. 上传 OSS 对应路径 → 4. 改 index.html 版本表 → 5. 全量回归(vue-tsc + build + 端点联调)。
> 版本升级不再受 lockfile 保护(UMD 部分),靠仓库 diff + 回归兜底;发布前 vendor/ 与 OSS 内容 md5 对账。
> Vant 升级走 pnpm-lock.yaml 锁版本 + pnpm up vant(不走 OSS 自托管)。
> 升级后重跑 node scripts/gen-shims.cjs(重新生成 shims/ 转发清单)。views/layout/index.vue):van-nav-bar(顶部)+ 内容区 <router-view/>(中)+ <tabbar-menu :tab-tree="login.tabTree"/>(底部 van-tabbar)。作为父路由 component(嵌套路由),App.vue 保持裸 <router-view/> 不改——套壳与否由路由树决定(登录页是顶层路由不套壳;业务域是 layout 的子路由套壳)。shared/components/tabbar-menu.vue):递归渲染 tabTree(MenuNode[] 树),取顶层节点渲染为 van-tabbar-item(有 path 的渲染为可跳转 tab;有 children 的纯分组节点不渲染为 tab——tab 必须可跳转,是 UI 形态约束非权限过滤)。高亮靠 :model-value="route.path"(van-tabbar 内部 watch route 自动同步);点击经 router.push(SPA 免刷新)。tabTree(MenuNode[] 树,契约在 shared/types/api.ts),前端原样渲染、不在前端二次过滤(避免维护"权限码 → 导航"映射的双份漂移)。MenuNode.code 保留权限码(域.资源.动作),供按钮级 v-perm 与导航项联动复用。tabTree 是响应式视图非凭证(与 roles/permissions 一致),刷新后降级空数组、tab 全隐藏,完整恢复依赖后续 /api/auth/me 端点(规划项)。POST /api/auth/login → 凭证落盘(applyLogin 写 token-store + 响应式视图)→ scope.set 初始化作用域(移动端无独立作用域选择页:登录响应直接下发 institutionId/campusId)→ router.push('/') 进 layout 首页。shared/api/auth/wechat.ts 为占位接口(路径/字段按通用 per-campus 授权流程假设,联调前与后端核对):
POST /api/auth/wechat/authorize 发起授权(按 campusId 取对应 appid 的微信授权 URL + 本地 state);GET /api/auth/wechat/status?state=xxx 轮询状态(pending/scanning/success/cancel/expire);success 时返回的 token 结构同 LoginResult,直接复用 applyLogin(不新增冗余登录方法)。views/login/index.vue):van-tabs 两 tab(账号密码 / 微信授权);切到微信 tab 时发起授权 + setInterval 1.5s 轮询,组件 onUnmounted 必清定时器防泄漏;单次轮询业务错误/网络故障静默跳过(下轮重试),终态(success/cancel/expire)停止;后端未就绪时授权区显"暂未开放"占位。http.ts 拦截器无法直接 import router(router 守卫用 useLogin→login store,而 http 被 router 守卫依赖,会成 router→http→router 循环),改用模块级注入 setRouterForRedirect(router)(main.ts 装配后注入);未注入时降级 location.href。所有登录态跳转(401 跳登录、登出跳登录、登录成功进首页)统一走 router.push(SPA,免全页刷新)。setBusinessErrorToaster(fn) 由 main.ts 装配 Vant 后注入 showFailToast(方向 UI→基础,非基础→UI)。http 层本身不 import Vant。.
├── README.md ← 本文件
├── package.json ← 根(workspaces 声明 + 公共脚本/依赖:pnpm typecheck/lint/build)
├── pnpm-workspace.yaml ← pnpm 工作区(packages/* + apps/*)
├── tsconfig.base.json ← TS 公共配置(strict 基线;apps/* 和根 tsconfig 均 extends 此文件)
├── tsconfig.json ← 根 tsconfig(shared + shims 项目;paths: @unified/shared → shared/src)
├── global.d.ts ← UMD 全局 + vite define 裸标识符的 TS 声明(5 项:Vue/VueDemi/VueRouter/Pinia/axios)
├── .env.example ← 环境变量模板(VITE_API_BASE / VITE_WECHAT_APPID)
├── vendor/ ← 【框架层 UMD 自托管源文件】git 入库(5 项,与 PC 端同源 md5 一致)
│ ├── vue@3.5.45/ vue.global.prod.js
│ ├── vue-demi@0.14.10/ vue-demi.iife.js(pinia 桥接层)
│ ├── vue-router@4.6.4/ vue-router.global.prod.js
│ ├── pinia@2.3.1/ pinia.iife.prod.js(依赖 VueDemi 全局)
│ └── axios@1.21.0/ axios.min.js
│ (上传 OSS 经 /static/vendor/<库名>/<版本号>/ 暴露;只增不删)
├── shims/ ← 【胶水层】'vue'/'vue-router'/'pinia' 模块名 → UMD 全局的唯一转发层
│ ├── vue.ts ← 由 scripts/gen-shims.cjs 生成(vue 161 项导出)
│ ├── vue-router.ts ← 同机制(23 项)
│ └── pinia.ts ← 同机制(17 项)
├── scripts/
│ └── gen-shims.cjs ← 构建辅助:框架升级五步后重跑(重新生成 shims/ 转发清单)
├── eslint.config.js ← ESLint 9 flat config(monorepo 适配;FRAMEWORK_MODULE_SELECTOR 只含 UMD 框架)
├── packages/
│ └── shared/ ← 【三端共享层】源码消费(main → src/index.ts,免构建)
│ ├── package.json ← 包名 @unified/shared;devDeps(vue/vue-router/pinia/axios/vant/dayjs)
│ └── src/
│ ├── index.ts ← 桶导出(re-export 全模块)
│ ├── types/api.ts ← 契约类型:R<T> / PageData<T> / PageR<T> / PageQuery / MenuNode
│ ├── identity/
│ │ └── token-store.ts ← token 物理存取(localStorage 收口)
│ ├── api/
│ │ ├── http.ts ← axios 实例 + 统一信封 + 401 refresh 单例 + setRouterForRedirect + setBusinessErrorToaster
│ │ └── auth/
│ │ ├── login.ts ← 登录/登出(LoginResult 含 tabTree 字段)
│ │ └── wechat.ts ← 微信 per-campus 授权(占位,后端未就绪)
│ ├── store/
│ │ ├── login.ts ← Pinia setup store(token/角色/权限码/tabTree + applyLogin/doLogout/hydrate)
│ │ └── scope.ts ← Pinia setup store(institution/campus 作用域 + toQuery)
│ ├── composables/
│ │ └── use-list-page.ts ← 列表页统一规范(分页五元组 + 统一分页解构 + watch 自动重载)
│ ├── directives/
│ │ └── perm.ts ← v-perm 按钮级权限指令(权限码三段式,缺失时 remove 元素)
│ └── components/
│ ├── list-page.vue ← 泛型 SFC(generic="T extends object"):van-pull-refresh + van-list + 三态
│ └── tabbar-menu.vue ← 底部导航(递归渲染 tabTree,van-tabbar + router.push)
└── apps/
├── teacher/ ← 【老师端】完整可跑(4 门禁全绿)
│ ├── package.json ← 包名 unified-teacher;devDeps(vite/vant/@vitejs/plugin-vue)
│ ├── vite.config.ts ← define(VUE_APIS/VUE_ROUTER_APIS/PINIA_APIS)+ publicDir(serve=vendor)
│ │ + alias(vue/vue-router/pinia→shims,@unified/shared→shared/src,@→./src)
│ │ + server port 5174 + /api 代理 + chunkSizeWarningLimit 1024
│ ├── tsconfig.json ← 本端独立 tsconfig(extends 根 base;paths: @→./src + @unified/shared→shared/src)
│ │ + include 本端 src + 根 global.d.ts + shared/src + shims/
│ ├── index.html ← 5 项 UMD 按序引入 + vendor 版本表 + <div id="app"> + /src/main.ts
│ └── src/
│ ├── main.ts ← 8 步装配:取 UMD 全局 → Pinia → Router → 注入 http → Vant → v-perm → hydrate → mount
│ ├── App.vue ← 裸 <router-view/>
│ ├── router/index.ts ← 路由表(layout 父路由 + 业务域子路由)+ 守卫链 4 步
│ ├── styles/tokens.css ← 全局设计 token(:root CSS 自定义属性 --app-*)
│ ├── api/ ← 本端域接口(org/institution.ts + academic/checkin.ts)
│ └── views/
│ ├── login/index.vue ← 双 tab 登录(van-tabs 账密 + 微信占位)
│ ├── layout/index.vue ← 布局壳(van-nav-bar + router-view + tabbar-menu)
│ ├── org/institution/list.vue ← 机构列表(useListPage + list-page + van-search)
│ └── academic/checkin/list.vue ← 教务打卡(useListPage + list-page + 日期筛选 + 状态 van-tag)
├── student/ ← 【学生端】待建(按 teacher 模板复制)
└── parent/ ← 【家长端】待建(按 teacher 模板复制 + 微信授权主入口 + bindchild)
目录用途约定:
| 目录 | 用途 | 约束 |
|---|---|---|
packages/shared |
三端共享层:SSO/登录态、接口封装、通用组件、契约类型、composable、指令 | 唯一 token 存取与 SSO 实现点;零端差异逻辑;改 shared 必须三端 build 通过 |
apps/{端}/src/views |
该端页面 | 只经 shared/api 发起请求;不实现登录(登录页除外) |
apps/{端}/src/router |
该端独立路由 + 权限码守卫 | 角色差异用权限码,不写 role == N |
apps/{端}/src/api |
该端域接口扩展(DTO + 请求函数) | 一资源一文件;DTO 与后端逐字段对齐(camelCase) |
apps/{端}/tsconfig.json |
本端独立 TS 配置 | @→本端 src(monorepo 下各端独立);拉入 shared/src + shims + 根 global.d.ts |
vendor/ |
框架层 UMD 源文件版本事实 | 与 OSS 内容一致;只增不删 |
shims/ |
框架模块名 → UMD 全局转发胶水层 | 全仓唯一允许出现 vue/vue-router/pinia 模块名的目录;由 gen-shims.cjs 生成,禁手改 |
global.d.ts |
UMD 全局 + define 裸标识符 TS 声明 | 放仓库根(覆盖 shims/ + shared/src + apps/*/src);不声明 Vant 全局(Vant 走 npm 自带类型) |
packages/shared/src/identity;三端不得各自实现登录(对齐"底层用户统一")。bindchild 为事实源:家长端进入时按绑定关系拉取孩子数据,不本地存绑定快照。views/ + router),但共用 shared/components 与 shared/api;角色差异用权限码判断,不写 role == N 硬编码。shared/api/,三端 views 内不散写请求。pnpm build 通过后方可提交(跨端回归门禁)。import { showToast/showFailToast/showConfirmDialog } from 'vant'(不可 import { Toast }——后者是组件对象 WithInstall/DefineComponent,不可调用)。code」信封 {code, data, message, success};判成功读 code === 200,错误文案读 message,不靠拦截器 reject、不靠 HTTP status。1xxx 认证/权限 · 2xxx 参数 · 3xxx 数据 · 4xxx 依赖 · 5xxx 系统;1001/1002 走 identity 统一刷新/重登流程(并发只刷一次)。page/pageSize(默认 1/10,上限 200);响应 data 恒为 PageData 对象 {list, total, page, pageSize, pages},三端统一读 res.data.list / res.data.total(useListPage<T> 收口,不再区分分页/非分页两套读法)。camelCase;组件 PascalCase;组合式函数 useXxx。shared/identity(SSO 接入、token 存取/刷新/吊销)+ shared/components(list-page + tabbar-menu)+ shared/composables(useListPage)三端共用一份。useListPage<T> + <list-page> 泛型 SFC(van-pull-refresh + van-list + 三态);useListPage 结果须解构到顶层(const { rows, total, loading, error, page, fetchList } = useListPage<T>({...}))——Vue 只自动解包顶层 setup 绑定,嵌套属性(lp.rows)不解包。| 对象 | 规则 | 示例 |
|---|---|---|
| 文件 | 组件 PascalCase.vue;其余 kebab-case.ts |
ListPage.vue、use-list-page.ts、list-page.vue |
| 组件 | 注册名 PascalCase,模板里 kebab-case | <list-page> / <tabbar-menu> |
| 组合式函数 | useXxx |
useListPage<T>()、useScope() |
| 变量/函数 | camelCase |
institutionId、handleSubmit |
| 接口/类型 | 名词 PascalCase,DTO 带域前缀 | InstitutionDTO、CheckinRecordDTO |
| API 字段 | 一律 camelCase,与后端 DTO 严格对齐;禁止前端自造映射层 |
— |
| 权限码 | 域.资源.动作 三段式(点分) |
academic.checkin.view |
| 事件/方法 | 内部回调 onXxx;用户触发的提交类 handleXxx |
onLoad、handleSubmit |
| 禁词 | 禁用 userid/schoolid/campusid 类旧系统混用命名 |
— |
apps/*/src 和 packages/shared/src 内禁止值 import 框架(import { ref } from 'vue' 会把框架运行时打进 dist,ESLint no-restricted-syntax 拦截)。落地机制:组合式 API 裸标识符(ref/computed/defineStore…)经 vite define 编译期替换为 Vue.*/VueRouter.*/Pinia.* 全局访问(清单见 vite.config.ts 的 VUE_APIS/VUE_ROUTER_APIS/PINIA_APIS);仅允许 import type(编译期擦除、零运行时足迹)。SFC 模板编译器产物内部的模块名 import 经 vite.config.ts 的 alias 重定向到 shims/*.ts(运行时从 globalThis 取值转发,框架本体不进 dist)。Vant 不在 define/alias/eslint 禁值清单内(走 npm 编译,非 UMD 全局)。interface 类型;可选项给默认值;禁内联 any。useListPage<T>。institutionId/campusId 只从 useScope() 取,禁止页面自拼。van-field + 显式 rules(FieldRule[] 类型,Vant 的 trigger 是 'onBlur'|'onChange'|'onSubmit');前端规则只拦格式,业务错误由后端 code 裁决。v-perm="'域.资源.动作'" 自定义指令(读 login store 权限码集);禁止 role === N 硬编码。showConfirmDialog 二次确认 + 操作后刷新。any 全面禁用(vue-tsc + ESLint no-explicit-any 双卡);未知结构用 unknown + 类型收窄。api/{域}/{资源}.ts 类型定义再写调用;禁止"先 any 后补类型"。R<T>/PageData<T> 泛型只在 shared/types/api.ts 定义一次,全仓引用。import type { FieldRule } from 'vant'(vant 根 export * from './field' 已 re-export FieldRule);命令式 API 用函数(import { showToast } from 'vant')。| 门禁 | 命令 / 做法 | 基线 |
|---|---|---|
| 类型检查 | pnpm typecheck(vue-tsc --noEmit 双工程:根 tsconfig = shared+shims;apps/teacher/tsconfig.json = 本端) |
0 错误 |
| 构建 | pnpm build(vite,按端) |
0 错误;主 chunk < 1024KB;dist/ 产物 grep 不得含 Vue 框架运行时本体(判据:createBaseVNode/queueJob/flushJobs/createAppContext/mountComponent/normalizeVNode/__VUE_PROD_DEVTOOLS__ 全 0——Vue runtime-core/reactivity 内部标识,prod 压缩仍保留、非公开 API、shims 不引用。⚠️ 不可用公开 API 名如 openBlock/createVNode/Teleport/Suspense 当判据:shims 转发层 E.createVNode 压缩后必然保留属性名,属设计预期。辅助佐证:dist 主 chunk 231KB = Vant + 业务,非 Vant+全量 Vue) |
| Lint | pnpm lint(eslint .) |
0 错误 |
| 跨端回归 | 改 shared 后三端全量 build | 全绿方可提交 |
| vendor 一致性 | 发布前 vendor/ 与 OSS 路径内容 md5 对账 |
一致方可发布 |
git push 前必须经用户确认(逐次确认)。feat: / fix: / chore: / docs: / refactor:);影响 shared 的提交在正文标注 affects: student,parent,teacher。.env 走 .gitignore,仅 .env.example 入库);vendor 变更必须与 index.html 版本表同步提交。pnpm install # 工作区安装(pnpm 10.12.1;lockfile 入库)
pnpm dev:teacher # 老师端开发(vite dev,port 5174;框架 UMD 经 publicDir=vendor 本地命中;/api 代理到后端 dev)
pnpm build # 三端全量构建 → apps/*/dist/(当前仅 teacher 端有 build script)
pnpm typecheck # 双工程类型检查(根 shared + apps/teacher)
pnpm lint # 全仓 ESLint
环境变量(.env.example 入库,实际 .env.* 不入库):VITE_API_BASE(后端 base)、VITE_WECHAT_APPID(家长端微信入口)。
框架层升级:执行 §1.4 五步流程。
academic/05-architecture/ 与 teaching/05-architecture/;学生端契约兼容基线见 unified/00-planning/cross-system-reconciliation.md §5。