EXAMPLES.md 14 KB

移动端前端代码样例索引(三段式注释 + 范式摘录)

本文件是移动端仓库的代码范式参考(与 PC 端 EXAMPLES.md 同构)。 所有 TS 文件采用三段式注释:① 入参 / ② 出参 / ③ 异常场景;SFC 骨架顺序:imports → store → 状态 → 生命周期 → 方法。 文件索引列出当前已落地文件的职责 + 关键范式,新增文件照此范式写。

1. 三段式注释模板

/**
 * 文件头(一句话说清职责 + 所属域/层)
 *
 * 典型使用(调用方文件路径 + 代码片段):
 *   ...
 */

/**
 * 函数/方法注释。
 *
 * 入参:
 *   - paramName Type:说明(必填/可选/默认值)。
 * 出参:
 *   - ReturnType:说明(成功/失败各返回什么)。
 * 异常场景:
 *   - 条件 A:行为 X(静默/抛错/降级/跳登录)。
 */
function xxx(param: Type): ReturnType { ... }

规则:

  • 入参/出参必须写(哪怕是"无");异常场景必须写(至少一条);
  • 禁止"见文档"式空注释;禁止复制粘贴函数名当注释;
  • SFC 内:<script setup> 顶部文件头 + 每个方法三段式(可省略"无参/无出参"但异常场景不可省);
  • 模板注释用 <!-- ... -->,只写"为什么这么布局/绑定",不重复 props/emit 类型。

2. 文件索引(当前已落地)

packages/shared/src/(三端共用)

文件 职责 关键范式
types/api.ts 契约类型唯一定义点 R<T> 信封 / PageData<T>(list,total,page,pageSize,pages)/ PageR<T> / PageQuery / MenuNode(code+label+path?+icon?+children?)
identity/token-store.ts token 物理存取(唯一 localStorage 读写点) KEYS 常量 + setTokens/getAccessToken/getRefreshToken/getExpiresAt/clearTokens;getExpiresAt 用 Number.isFinite 收窄(fail-closed)
api/http.ts axios 实例 + 信封 + 401 refresh 单例 + toast 注入 UNAUTHORIZED_CODES 常量;refreshAccessToken 单例(let refreshing: Promise<boolean>\|undefined);setRouterForRedirect 模块级注入(解循环依赖);setBusinessErrorToaster 模块级注入(零 UI 依赖);toast 去重 1s Map
api/auth/login.ts 登录/登出接口 LoginResult 含 tabTree: MenuNode[](移动端 = 底部导航树);login() 返回 R<LoginResult>;logout(refreshToken) 返回 R<undefined>
api/auth/wechat.ts 微信 per-campus 授权(占位) WechatAuthStatus 状态机 5 值;authorizeWechat(campusId) → R<WechatAuthorizeResult>;pollWechatStatus(state) → R<WechatStatusResult>(success 时 token 结构同 LoginResult);TODO 标注待后端核对
store/login.ts Pinia setup store(登录态视图) tabTree 仅内存态(不落 localStorage);isLoggedIn computed(提前 30s 判过期);applyLogin/doLogout/hydrate 三动作;hasPerm 方法
store/scope.ts Pinia setup store(作用域) current ref(institutionId/campusId);ready computed(institutionId > 0);set 校验 > 0;toQuery 返回 {institutionId, campusId?}(campusId=0 省略)
composables/use-list-page.ts 列表页统一规范 composable 泛型 useListPage<T>(options);rows = ref([]) as Ref<T[]>(断言注释说明);watch([page,pageSize]) 自动重载;fetchList 统一解 res.data.{list,total};loading 防重入
directives/perm.ts v-perm 按钮级权限指令 mounted + updated 双钩子(登录态/权限集可能挂载后变化);权限缺失 el.remove()(非 disabled);fail-closed
components/list-page.vue 泛型 SFC 列表页(Vue 3.5 generic="T extends object") van-pull-refresh + van-list;三态(loading 骨架 / 空态 van-empty / 错误态 van-notice-bar);finished computed(rows.length >= total);#default slot :row 强类型 = T
components/tabbar-menu.vue 底部导航(递归渲染 tabTree) van-tabbar + van-tabbar-item;v-show="item.path"(无 path 的纯分组不渲染为 tab);:model-value="route.path" 高亮;onTabClick 走 router.push(SPA)
index.ts 桶导出 只 export *,零运行时逻辑(避免副作用/循环依赖)

apps/teacher/src/(老师端)

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. 新增文件清单(照此范式写)

文件 职责 关键范式
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.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 页面