| 文件 |
职责 |
关键范式 |
main.ts |
8 步装配入口 |
① 取 UMD 全局 fail-fast → ② Pinia → ③ Router + 守卫 → ④ 注入 http(setRouterForRedirect + setBusinessErrorToaster)→ ⑤ Vant(npm import + app.use)→ ⑥ v-perm → ⑦ hydrate → ⑧ mount;import 'vant/lib/index.css' + import '@/styles/tokens.css' 在顶部 |
App.vue |
根组件 |
裸 <router-view/>(套壳与否由路由树决定) |
router/index.ts |
路由表 + 守卫链 |
嵌套路由(layout 父路由 path=/ + children);守卫 4 步(白名单→登录态→刷新→作用域);declare module 'vue-router' 扩展 RouteMeta(public/scopeRequired/title);afterEach 同步 document.title |
styles/tokens.css |
全局设计 token |
:root CSS 自定义属性(--app-bg-page/--app-text-primary/--app-spacing-md/--app-font-lg/--app-tabbar-height/--app-nav-bar-height);plain CSS 非 SCSS |
api/org/institution.ts |
机构列表接口 |
InstitutionDTO + InstitutionFilter extends PageQuery;listInstitutions(params) 返回 PageR<InstitutionDTO>;http.get<T> + .then(r => r.data) |
api/academic/checkin.ts |
打卡记录接口 |
CheckinRecordDTO + CheckinFilter extends PageQuery(date 必填 + classId 可选);listCheckinRecords(params) 返回 PageR<CheckinRecordDTO> |
views/login/index.vue |
双 tab 登录页 |
van-tabs v-model:active(string 兼容 Vant);账密 tab(van-form + van-field + rules FieldRule[]);微信 tab(authorizeWechat + setInterval 1.5s + onUnmounted 清);showToast 函数式 API;activeTab = ref<string>('password') |
views/layout/index.vue |
布局壳 |
van-nav-bar(fixed + placeholder)+ 内容区 <router-view/> + <tabbar-menu :tab-tree="login.tabTree"/>;handleLogout 用 showConfirmDialog + doLogout + router.push('/login');padding-bottom 给 tabbar 留位 |
views/org/institution/list.vue |
机构列表页 |
useListPage 解构到顶层(const { rows, total, loading, error, page, fetchList } = useListPage<T>({...}));van-search + <list-page>(:rows/:total/:loading/:error/@load/@refresh);#default slot { row } 强类型 |
views/academic/checkin/list.vue |
教务打卡列表页 |
原生 date input(today() 取 yyyy-MM-dd);STATUS_MAP Record(1-4→正常/迟到/缺勤/请假 + van-tag type);onDateChange 显式 fetchList()(不等 watch 防抖)
|
3. 范式摘录
3.1 UMD 全局 + define 注入(业务代码不 import 框架)
// ✅ 正确:裸标识符(vite define 编译期替换为 Vue.ref / VueRouter.useRouter 等)
const rows = ref([]);
const router = useRouter();
const store = useLogin(); // Pinia.defineStore 注入
// ✅ 正确:import type(编译期擦除,零运行时足迹)
import type { Ref } from 'vue';
import type { PageR, PageQuery } from '@unified/shared/types/api';
import type { FieldRule } from 'vant'; // Vant 类型走 npm(非 UMD 全局)
// ❌ 禁止:值 import 框架(会打进 dist,ESLint 拦截)
// import { ref, computed } from 'vue';
// import { createRouter } from 'vue-router';
// import { defineStore } from 'pinia';
// import axios from 'axios';
// ✅ Vant 例外:走 npm 编译(非 UMD 全局),main.ts 装配点可值 import
import Vant from 'vant';
import { showFailToast, showConfirmDialog, showToast } from 'vant';
3.2 useListPage 收口 + 解构到顶层(模板自动解包)
<script setup lang="ts">
import { useListPage } from '@unified/shared/composables/use-list-page';
import { useScope } from '@unified/shared/store/scope';
import ListPage from '@unified/shared/components/list-page.vue';
import { listInstitutions, type InstitutionDTO, type InstitutionFilter } from '@/api/org/institution';
const scope = useScope();
const filters = reactive<{ keyword: string }>({ keyword: '' });
// ⚠️ 必须解构到顶层(Vue 只自动解包顶层 setup 绑定;lp.rows 不解包 → 模板 :rows="lp.rows" 报 Ref 类型错)
const { rows, total, loading, error, page, fetchList } = useListPage<InstitutionDTO>({
fetcher: (page, pageSize) => {
const q: InstitutionFilter = { ...scope.toQuery(), keyword: filters.keyword || undefined, page, pageSize };
return listInstitutions(q);
},
});
function onLoad(): void { page.value += 1; } // 触底加载下一页
function onRefresh(): void { page.value = 1; } // 下拉刷新重置
function onSearch(): void { page.value = 1; } // 搜索重置
onMounted(() => { fetchList(); });
</script>
<template>
<van-search v-model="filters.keyword" @search="onSearch" @clear="onSearch" />
<list-page :rows="rows" :total="total" :loading="loading" :error="error" @load="onLoad" @refresh="onRefresh">
<template #default="{ row }">
<van-cell :title="row.name" :label="`编码 ${row.code}`" />
<!-- row 强类型 = InstitutionDTO(泛型 SFC 自动推断) -->
</template>
</list-page>
</template>
3.3 Vant 表单 rules(FieldRule 类型 + 正确 trigger 值)
<script setup lang="ts">
import { showToast } from 'vant';
import type { FieldRule } from 'vant';
const form = reactive({ username: '', password: '' });
// ✅ 正确:显式 FieldRule[] 类型 + trigger 为 Vant FieldValidateTrigger('onBlur'|'onChange'|'onSubmit')
const rules: Record<'username' | 'password', FieldRule[]> = {
username: [{ required: true, message: '请输入用户名', trigger: 'onBlur' }],
password: [{ required: true, message: '请输入密码', trigger: 'onBlur' }],
};
async function handleSubmit(): Promise<void> { ... }
</script>
<template>
<van-form @submit="handleSubmit">
<van-field v-model="form.username" name="username" label="用户名" :rules="rules.username" />
<van-field v-model="form.password" name="password" type="password" label="密码" :rules="rules.password" />
<van-button native-type="submit" round block :loading="submitting">登录</van-button>
</van-form>
</template>
3.4 http 层零 UI 依赖(toast 注入 + 路由注入)
// shared/api/http.ts(底层基础设施,零 UI 依赖)
let toastHandler: ((message: string) => void) | undefined;
export function setBusinessErrorToaster(fn: (message: string) => void): void {
toastHandler = fn;
}
function toastBusinessError(message: string): void {
if (toastHandler) toastHandler(message);
else console.warn('[biz-error]', message); // 未注入时降级
}
// apps/teacher/src/main.ts(装配 UI 后注入,方向 UI→基础)
import { showFailToast } from 'vant';
import { setBusinessErrorToaster, setRouterForRedirect } from '@unified/shared/api/http';
setBusinessErrorToaster((m) => showFailToast(m));
setRouterForRedirect(router); // 解 router→http→router 循环依赖
3.5 monorepo 每端独立 tsconfig(@ 路径各端独立)
// apps/teacher/tsconfig.json(本端独立,extends 根 tsconfig.base.json)
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@": ["./src"], // 本端业务层(各端独立)
"@/*": ["./src/*"],
"@unified/shared": ["../../packages/shared/src"], // 共享层(源码消费)
"@unified/shared/*": ["../../packages/shared/src/*"]
}
},
"include": [
"../../global.d.ts", // 根 UMD 全局声明
"./src/**/*.ts",
"./src/**/*.vue",
"../../packages/shared/src/**/*.ts", // 共享层源码(类型直达)
"../../packages/shared/src/**/*.vue",
"../../shims/**/*.ts" // 胶水层
]
}
3.6 微信授权轮询(占位,onUnmounted 必清定时器)
<script setup lang="ts">
import { authorizeWechat, pollWechatStatus } from '@unified/shared/api/auth/wechat';
import { useLogin } from '@unified/shared/store/login';
import { showToast } from 'vant';
const loginStore = useLogin();
const authUrl = ref('');
const polling = ref(false);
let pollTimer: ReturnType<typeof setInterval> | undefined;
function stopPolling(): void {
if (pollTimer !== undefined) { clearInterval(pollTimer); pollTimer = undefined; }
polling.value = false;
}
async function handleTabChange(tab: string | number): Promise<void> {
stopPolling();
if (tab === 'wechat') {
try {
const auth = await authorizeWechat(campusId);
if (auth.code === 200 && auth.data) {
authUrl.value = auth.data.url;
polling.value = true;
pollTimer = setInterval(async () => {
try {
const s = await pollWechatStatus(auth.data!.state);
if (s.code !== 200 || !s.data) return; // 单次静默
if (s.data.status === 'success' && s.data.token) {
stopPolling();
loginStore.applyLogin(s.data.token);
await onLoginSuccess(s.data.token);
} else if (s.data.status === 'cancel' || s.data.status === 'expire') {
stopPolling();
authUrl.value = '';
}
} catch { /* 网络故障静默,下轮重试 */ }
}, 1500);
}
} catch {
stopPolling();
showToast('微信授权暂未开放'); // 后端未就绪
}
}
}
onUnmounted(() => { stopPolling(); }); // 组件卸载必清(防内存泄漏)
</script>
4. 新增文件清单(照此范式写)
| 场景 |
照哪个范式 |
备注 |
| 新域列表页 |
§3.2(useListPage + list-page) |
补 api/{域}/{资源}.ts + views/{域}/{资源}/list.vue |
| 新域详情页 |
手动路由 + api 接口 |
views/{域}/{资源}/detail.vue |
| 新域接口 |
api/org/institution.ts 范式 |
一资源一文件;DTO + Filter + 请求函数 |
| 新共享组件 |
components/list-page.vue 范式 |
泛型 SFC 或普通 SFC;零业务耦合 |
| 新共享 composable |
composables/use-list-page.ts 范式 |
泛型 + 返回 Ref 集合 |
| 新指令 |
directives/perm.ts 范式 |
mounted + updated 双钩子 |
| student/parent 端 |
teacher 端全量复制 |
改 package.json name + vite port + router 路由表 + views 页面 |