Fork me on GitHub

从零搭建移动端 H5 审批系统:Vue3 全家桶实战

 

以开发者视角,基于 Vite 官方脚手架快速起步,逐步集成路由、状态管理、请求层、UI 组件库和移动端适配能力,完成一个可落地的审批 H5 基础工程。


一、业务场景

企业内部办公系统的典型链路:

审批流转产生待办 → IM 推送消息 → 用户点击 → 打开 H5 页面 → 审批操作

项目产出是一个跑在 App 内置 WebView 中的 H5 页面,包含审批列表和审批详情。


二、技术选型

层面

选型

框架

Vue 3.4 + Composition API

构建

Vite 5

类型

TypeScript 5

UI 库

Vant 4(移动端 Vue3 最成熟的组件库)

路由

Vue Router 4(Hash 模式,WebView 兼容性最佳)

状态管理

Pinia 2

请求库

alova 3 + Axios(声明式请求管理 + 底层网络)

CSS

UnoCSS 原子化 + Less

移动适配

postcss-px-to-viewport-8-plugin(px → vw,375 设计稿)

包管理器统一使用 pnpm。


三、项目初始化

实际开发中,开发者不会从空目录一步步 mkdirnpm init。Vite 官方提供了成熟的项目模板,一行命令就能生成结构完整、配置齐全的基础工程。

3.1 脚手架创建项目

pnpm create vite approval-h5 --template vue-ts
cd approval-h5
pnpm install

--template vue-ts 指定 Vue3 + TypeScript 模板。执行完即得到一个可运行的标准项目:

approval-h5/
├── index.html
├── package.json
├── tsconfig.json
├── tsconfig.app.json
├── tsconfig.node.json
├── vite.config.ts
├── public/
└── src/
    ├── main.ts
    ├── App.vue
    ├── style.css
    └── vite-env.d.ts

验证:

pnpm dev

浏览器访问 http://localhost:5173,看到 Vue 默认页说明初始化成功。

脚手架已经帮你配好了 vite.config.tstsconfig 系列、package.json 的 scripts。你不必从零写 Vite 插件配置和 TypeScript 编译选项,精力留在架构设计上。

3.2 安装运行时依赖

脚手架给了 Vue3 + Vite + TypeScript 的底座,追加业务核心依赖:

# 路由 & 状态管理
pnpm add vue-router@^4.6.4 pinia@^2.3.1

# UI 组件库
pnpm add vant@^4.9.24

# 请求库(alova + Axios 做底层适配器)
pnpm add alova@^3.5.1 @alova/adapter-axios@^2.0.18 axios@^1.7.2

3.3 安装开发依赖

# 原子化 CSS
pnpm add -D unocss@^66.7.2 less@^4.2.0

# Vant 按需引入
pnpm add -D unplugin-auto-import@^21.0.0 unplugin-vue-components@^32.1.0 @vant/auto-import-resolver@^1.3.0

# 移动端 px → vw 自动转换
pnpm add -D postcss-px-to-viewport-8-plugin@^1.2.5

# Node 类型声明(vite.config.ts 中 resolve 需要)
pnpm add -D @types/node@^20.14.2

3.4 创建业务目录

脚手架提供的是通用骨架,不含业务分层。在 src/ 下追加本项目需要的目录:

mkdir -p src/{router,store/modules,api/modules,utils/platforms,views/approval-detail,views/approval-list,styles}
mkdir -p types

此时项目结构演变为:

approval-h5/
├── index.html              # 脚手架生成
├── package.json            # 脚手架生成 + 新增依赖
├── tsconfig.json           # 脚手架生成
├── tsconfig.app.json       # 脚手架生成
├── tsconfig.node.json      # 脚手架生成
├── vite.config.ts          # 脚手架生成(后续修改)
├── public/
├── src/
│   ├── main.ts             # 脚手架生成(后续修改)
│   ├── App.vue             # 脚手架生成(后续修改)
│   ├── style.css           # 脚手架生成
│   ├── vite-env.d.ts       # 脚手架生成
│   ├── api/modules/        # 新增
│   ├── components/         # 脚手架已有,后续替换
│   ├── router/             # 新增
│   ├── store/modules/      # 新增
│   ├── styles/             # 新增
│   ├── utils/platforms/    # 新增
│   └── views/              # 新增
│       ├── approval-detail/
│       └── approval-list/
└── types/                  # 新增

到此骨架就绪,下面逐层完善配置和代码。


四、TypeScript 配置

脚手架已生成 tsconfig.jsontsconfig.app.jsontsconfig.node.json,只需两项调整。

4.1 路径别名

tsconfig.app.json 中添加 baseUrlpaths,让 TypeScript 识别 @/ 别名:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

脚手架将应用代码的 TS 配置拆分到了 tsconfig.app.json,所以路径别名加在这里。tsconfig.json 通过 references 引用它。

4.2 全局类型声明

创建 types/global.d.ts

/// <reference types="vite/client" />

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

interface ImportMetaEnv {
  readonly VITE_API_BASE_URL: string
  readonly VITE_APP_TITLE: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

另外,unplugin-auto-importunplugin-vue-components 后续会自动生成 types/auto-imports.d.tstypes/components.d.ts,提前创建占位文件避免首次构建类型报错。


五、Vite 配置

脚手架已生成 vite.config.ts,在此基础上追加四个插件和相关配置:

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import UnoCSS from 'unocss/vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { VantResolver } from '@vant/auto-import-resolver'

export default defineConfig(({ mode }) => ({
  plugins: [
    vue(),
    UnoCSS(),
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'types/auto-imports.d.ts',
      resolvers: [VantResolver()],
    }),
    Components({
      resolvers: [VantResolver()],
      dts: 'types/components.d.ts',
    }),
  ],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
    },
  },
  server: {
    host: '0.0.0.0',
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
}))

四个插件的分工:

插件

作用

@vitejs/plugin-vue

编译 .vue 单文件组件

unocss/vite

原子化 CSS 按需生成

unplugin-auto-import

自动 import ref/reactive/useRouter 等,不用手写

unplugin-vue-components

自动按需引入 Vant 组件,Tree Shaking

VantResolver() 让你在模板里写 <van-button> 不用 import,构建时只打包用到的 Vant 组件,从 600KB 砍到 160KB。


六、入口文件与路由

6.1 index.html

脚手架已生成 index.html,调整 viewport 为移动端适配版本:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta
    name="viewport"
    content="width=device-width, initial-scale=1.0, maximum-scale=1.0,
           minimum-scale=1.0, user-scalable=no, viewport-fit=cover"
  />
  <meta name="format-detection" content="telephone=no" />
  <title>审批系统</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.ts"></script>
</body>
</html>

viewport 关键属性:

  • user-scalable=no — 禁止用户双指缩放
  • viewport-fit=cover — 撑满 iPhone 刘海屏,配合 safe-area-inset-bottom 适配安全区
  • format-detection=telephone=no — 禁止 iOS Safari 把数字识别成电话号码

6.2 应用入口

脚手架已生成 src/main.tssrc/App.vue,改造为:

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import 'uno.css'
import { initPlatform } from './utils/platform'

async function bootstrap() {
  const app = createApp(App)
  app.use(createPinia())
  app.use(router)

  // 初始化平台适配层(鉴权、JSBridge 等)
  await initPlatform()

  app.mount('#app')
}

bootstrap()
<!-- src/App.vue -->
<template>
  <div class="min-h-screen bg-#f7f8fa">
    <router-view />
  </div>
</template>

6.3 路由

// src/router/index.ts
import { createRouter, createWebHashHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    redirect: '/approval-list',
  },
  {
    path: '/approval-list',
    name: 'ApprovalList',
    component: () => import('@/views/approval-list/index.vue'),
    meta: { title: '审批列表' },
  },
  {
    path: '/approval-detail/:id',
    name: 'ApprovalDetail',
    component: () => import('@/views/approval-detail/index.vue'),
    meta: { title: '审批详情' },
  },
]

const router = createRouter({
  history: createWebHashHistory(),
  routes,
})

router.beforeEach((to) => {
  document.title = (to.meta.title as string) || '审批系统'
})

export default router

三个设计决策:

  1. createWebHashHistory() — Hash 模式,所有 WebView 环境通吃
  2. () => import(...) — 路由懒加载,按页面拆 chunk
  3. beforeEach 动态 title — 每个页面独立标题,导航栏和浏览器标签同步

七、请求层

7.1 alova 实例

// src/api/index.ts
import { createAlova } from 'alova'
import { axiosRequestAdapter } from '@alova/adapter-axios'
import axios from 'axios'
import { useUserStore } from '@/store/modules/user'

const httpInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 15000,
  headers: { 'Content-Type': 'application/json' },
})

// 请求拦截:自动注入 token
httpInstance.interceptors.request.use((config) => {
  const userStore = useUserStore()
  if (userStore.token) {
    config.headers.Authorization = `Bearer ${userStore.token}`
  }
  return config
})

// 响应拦截:统一错误处理
httpInstance.interceptors.response.use(
  (response) => response.data,
  (error) => {
    const msg = error.response?.data?.message || '请求失败'
    return Promise.reject(new Error(msg))
  }
)

// 创建 alova 实例,用 Axios 做底层适配器
const alovaInstance = createAlova({
  requestAdapter: axiosRequestAdapter({ axios: httpInstance }),
  responded: {
    onSuccess: async (response) => {
      const res = response as { code: number; data: any; message?: string }
      if (res.code === 0) return res.data
      throw new Error(res.message || '请求异常')
    },
    onError: (error) => { throw error },
  },
})

export default alovaInstance

alova 的 requestAdapter 让你在享受 alova 声明式 API 的同时,复用 Axios 成熟的拦截器、超时、取消请求等能力。

7.2 业务 API

// src/api/modules/approval.ts
import alovaInstance from '@/api'

export interface ApprovalItem {
  id: string
  title: string
  type: string
  applicant: string
  status: 'pending' | 'approved' | 'rejected'
  createTime: string
}

export function getApprovalList(params: { status?: string; page: number; pageSize: number }) {
  return alovaInstance.Get<{ list: ApprovalItem[]; total: number }>(
    '/api/approval/list',
    { params }
  )
}

export function getApprovalDetail(id: string) {
  return alovaInstance.Get<ApprovalItem>(`/api/approval/detail/${id}`)
}

export function submitApproval(data: { id: string; action: string; comment?: string }) {
  return alovaInstance.Post('/api/approval/submit', data)
}
// src/api/modules/auth.ts
import alovaInstance from '@/api'

export function login(credential: string) {
  return alovaInstance.Post<{ token: string; userId: string; userName: string }>(
    '/api/auth/login',
    { credential }
  )
}

八、状态管理

// src/store/modules/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getUserCredential } from '@/utils/platform'
import { login } from '@/api/modules/auth'

export const useUserStore = defineStore('user', () => {
  const token = ref('')
  const userId = ref('')
  const userName = ref('')

  const isLoggedIn = computed(() => !!token.value)

  async function initUser() {
    const credential = await getUserCredential()
    if (!credential) return
    const userInfo = await login(credential)
    token.value = userInfo.token
    userId.value = userInfo.userId
    userName.value = userInfo.userName
  }

  return { token, userId, userName, isLoggedIn, initUser }
})

初始化链路:

getUserCredential()           → login(credential)         → Pinia store
平台获取身份凭证(code/token)    后端换 token + 用户信息      存入全局状态

九、平台适配层

整个项目架构中最关键的设计。不同 IM 平台(钉钉/飞书/企业微信/内部 IM)的差异只在于两点:用户身份怎么获取、原生能力怎么调用。用适配器模式抽象它。

9.1 适配器接口 + 默认实现

// src/utils/platform.ts

export interface PlatformAdapter {
  name: string
  init(): Promise<boolean>
  getUserCredential(): Promise<string>
  closeWindow(): void
  takePhoto?(): Promise<string>
}

// 默认适配器:纯浏览器环境,从 URL 参数取 token
const defaultAdapter: PlatformAdapter = {
  name: 'default-browser',

  async init() {
    return true
  },

  async getUserCredential() {
    const params = new URLSearchParams(window.location.search)
    return params.get('token') || params.get('auth_code') || ''
  },

  closeWindow() {
    if (history.length > 1) {
      history.back()
    } else {
      window.close()
    }
  },
}

let currentAdapter: PlatformAdapter = defaultAdapter

export function registerPlatformAdapter(adapter: PlatformAdapter) {
  currentAdapter = adapter
}

export async function initPlatform() {
  await currentAdapter.init()
}

export async function getUserCredential() {
  return currentAdapter.getUserCredential()
}

export function closePlatformWindow() {
  currentAdapter.closeWindow()
}

9.2 钉钉适配器(示例)

// src/utils/platforms/dingtalk.ts
import type { PlatformAdapter } from '../platform'

export const dingtalkAdapter: PlatformAdapter = {
  name: 'dingtalk',

  async init() {
    return new Promise((resolve) => {
      ;(window as any).dd.ready(() => resolve(true))
      ;(window as any).dd.error(() => resolve(false))
    })
  },

  async getUserCredential() {
    return new Promise((resolve) => {
      ;(window as any).dd.runtime.permission.requestAuthCode({
        corpId: import.meta.env.VITE_DD_CORP_ID,
        onSuccess: (result: any) => resolve(result.code),
        onFail: () => resolve(''),
      })
    })
  },

  closeWindow() {
    ;(window as any).dd.biz.navigation.close()
  },
}

9.3 接入内部平台

假设内部办公软件的 JSBridge 叫 MyIMBridge

// src/utils/platforms/my-im.ts
import type { PlatformAdapter } from '../platform'

export const myIMAdapter: PlatformAdapter = {
  name: 'my-im',

  async init() {
    return window.MyIMBridge.init()
  },

  async getUserCredential() {
    return window.MyIMBridge.getToken()
  },

  closeWindow() {
    window.MyIMBridge.close()
  },
}

在入口注册:

// src/main.ts —— 加两行
import { myIMAdapter } from './utils/platforms/my-im'
import { registerPlatformAdapter } from './utils/platform'

registerPlatformAdapter(myIMAdapter)

设计原则:默认适配器能独立跑通全流程(URL 传 token),新平台只加文件不删代码——开闭原则。


十、移动端适配

10.1 px → vw

postcss.config.cjs(后缀必须是 .cjs,因为脚手架 "type": "module"):

module.exports = {
  plugins: {
    'postcss-px-to-viewport-8-plugin': {
      viewportWidth: 375,
      unitPrecision: 5,
      viewportUnit: 'vw',
      minPixelValue: 1,
      mediaQuery: false,
    },
  },
}

效果:width: 100px 构建后自动变成 width: 26.66667vw

10.2 UnoCSS

unocss.config.ts

import { defineConfig, presetUno, presetAttributify } from 'unocss'

export default defineConfig({
  presets: [presetUno(), presetAttributify()],
  shortcuts: {
    'flex-center': 'flex items-center justify-center',
    'flex-between': 'flex items-center justify-between',
    'text-ellipsis': 'overflow-hidden text-ellipsis whitespace-nowrap',
  },
  theme: {
    colors: {
      primary: '#1989fa',
      success: '#07c160',
      warning: '#ff976a',
      danger: '#ee0a24',
    },
  },
})

模板里直接用原子类:

<div class="flex items-center gap-12px mb-16px">
  <span class="color-primary font-500">同意</span>
</div>

10.3 全局样式 + 安全区

// src/styles/global.less
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html, body {
  font-size: 14px;
  color: #323233;
  background: #f7f8fa;
  -webkit-font-smoothing: antialiased;
}

// iPhone 刘海屏底部安全区
body {
  padding-bottom: constant(safe-area-inset-bottom);
  padding-bottom: env(safe-area-inset-bottom);
}

10.4 环境变量

# .env.development
VITE_API_BASE_URL=/api
VITE_APP_TITLE=审批系统

# .env.production
VITE_API_BASE_URL=https://api.your-domain.com
VITE_APP_TITLE=审批系统

十一、页面实现

11.1 审批列表页

<template>
  <div class="approval-list-page">
    <van-nav-bar title="审批列表" fixed placeholder />

    <van-tabs v-model:active="activeTab" @change="onTabChange">
      <van-tab title="全部" name="" />
      <van-tab title="待审批" name="pending" />
      <van-tab title="已通过" name="approved" />
      <van-tab title="已驳回" name="rejected" />
    </van-tabs>
    <van-pull-refresh v-model="refreshing" @refresh="onRefresh">
      <van-list
        v-model:loading="loading"
        :finished="finished"
        finished-text="没有更多了"
        @load="onLoad"
      >
        <div
          v-for="item in list"
          :key="item.id"
          class="approval-card"
          @click="goDetail(item.id)"
        >
          <div class="flex-between">
            <span class="text-15px font-500">{{ item.title }}</span>
            <van-tag :type="statusTagType(item.status)" size="medium">
              {{ statusLabel(item.status) }}
            </van-tag>
          </div>
          <div class="flex-between mt-8px text-13px color-#999">
            <span>{{ item.applicant }}</span>
            <span>{{ item.createTime }}</span>
          </div>
        </div>
      </van-list>
    </van-pull-refresh>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { getApprovalList } from '@/api/modules/approval'
import type { ApprovalItem } from '@/api/modules/approval'

const router = useRouter()
const activeTab = ref('')
const list = ref<ApprovalItem[]>([])
const refreshing = ref(false)
const loading = ref(false)
const finished = ref(false)

let page = 1

async function onLoad() {
  try {
    const res = await getApprovalList({
      status: activeTab.value,
      page: page++,
      pageSize: 20,
    })
    list.value.push(...res.list)
    if (list.value.length >= res.total) finished.value = true
  } catch {
    showToast('加载失败')
  }
  loading.value = false
}

async function onRefresh() {
  page = 1
  finished.value = false
  list.value = []
  refreshing.value = false
  await onLoad()
}

function onTabChange() {
  onRefresh()
}

function goDetail(id: string) {
  router.push(`/approval-detail/${id}`)
}

function statusTagType(status: string): 'warning' | 'success' | 'danger' | 'default' {
  const map: Record<string, 'warning' | 'success' | 'danger'> = {
    pending: 'warning',
    approved: 'success',
    rejected: 'danger',
  }
  return map[status] || 'default'
}

function statusLabel(status: string) {
  const map: Record<string, string> = {
    pending: '待审批',
    approved: '已通过',
    rejected: '已驳回',
  }
  return map[status] || status
}
</script>

页面组成:van-nav-bar 导航 + van-tabs 状态筛选 + van-pull-refresh 下拉刷新 + van-list 上拉加载。

11.2 审批详情页

<template>
  <div class="approval-detail-page pb-70px">
    <van-nav-bar
      title="审批详情"
      left-text="返回"
      left-arrow
      fixed placeholder
      @click-left="router.back"
    />

    <van-cell-group inset>
      <van-cell
        v-for="(value, key) in detail.formData"
        :key="key"
        :title="key"
        :value="String(value)"
      />
    </van-cell-group>
    <van-cell-group inset>
      <van-steps direction="vertical" :active="detail.approvalHistory?.length">
        <van-step v-for="item in detail.approvalHistory" :key="item.time">
          <template #active-icon>
            <van-icon name="checked" color="#07c160" />
          </template>
          <div class="text-14px font-500">{{ item.nodeName }}</div>
          <div class="text-12px color-#999">
            {{ item.approver }} · {{ item.time }}
          </div>
        </van-step>
      </van-steps>
    </van-cell-group>
    <ApprovalActions
      v-if="detail.status === 'pending'"
      @approve="handleApprove"
      @reject="handleReject"
    />
  </div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import { getApprovalDetail, submitApproval } from '@/api/modules/approval'
import { closePlatformWindow } from '@/utils/platform'

const route = useRoute()
const router = useRouter()
const detail = ref<any>({})

onMounted(async () => {
  try {
    detail.value = await getApprovalDetail(route.params.id as string)
  } catch {
    showToast('加载失败')
  }
})

async function handleApprove(comment: string) {
  try {
    await submitApproval({ id: route.params.id as string, action: 'approve', comment })
    showToast('审批通过')
    setTimeout(closePlatformWindow, 1000)
  } catch {
    showToast('操作失败')
  }
}

async function handleReject(comment: string) {
  try {
    await submitApproval({ id: route.params.id as string, action: 'reject', comment })
    showToast('已驳回')
    setTimeout(closePlatformWindow, 1000)
  } catch {
    showToast('操作失败')
  }
}
</script>

11.3 审批操作组件

<template>
  <div class="fixed bottom-0 left-0 right-0 flex gap-12px p-16px bg-white approval-actions">
    <van-button
      round
      block
      type="danger"
      :loading="loading"
      @click="showRejectDialog = true"
    >
      驳回
    </van-button>
    <van-button
      round
      block
      type="primary"
      :loading="loading"
      @click="$emit('approve', '')"
    >
      同意
    </van-button>
    <van-dialog
      v-model:show="showRejectDialog"
      title="驳回理由"
      show-cancel-button
      @confirm="onRejectConfirm"
    >
      <van-field
        v-model="rejectComment"
        type="textarea"
        placeholder="请输入驳回理由"
        rows="3"
      />
    </van-dialog>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue'

defineProps<{ loading?: boolean }>()
const emit = defineEmits<{
  approve: [comment: string]
  reject: [comment: string]
}>()

const showRejectDialog = ref(false)
const rejectComment = ref('')

function onRejectConfirm() {
  emit('reject', rejectComment.value)
  showRejectDialog.value = false
  rejectComment.value = ''
}
</script>
<style scoped>
.approval-actions {
  padding-bottom: calc(16px + env(safe-area-inset-bottom));
  box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
}
</style>

十二、构建验证

pnpm build

输出:

✓ 35 modules transformed.

dist/index.html                    0.46 kB │ gzip: 0.29 kB
dist/assets/index-CjqzWpxW.css  203.54 kB │ gzip: 54.23 kB
dist/assets/index-Bcj8A2oL.js   162.87 kB │ gzip: 63.90 kB

✓ built in 2.61s

产物分析:

文件

原始大小

Gzip

HTML 入口

0.46 KB

0.29 KB

CSS(含 Vant 按需样式)

203 KB

54 KB

JS 主包

162 KB

64 KB

总计

~366 KB

~118 KB

Gzip 后约 118KB,3G 网络下 1.5 秒完成传输。


十三、最终目录结构

approval-h5/
├── index.html                   # 脚手架生成,已修改 viewport
├── package.json                 # 脚手架生成,已追加依赖
├── vite.config.ts               # 脚手架生成,已追加插件和配置
├── tsconfig.json                # 脚手架生成
├── tsconfig.app.json            # 脚手架生成,已添加路径别名
├── tsconfig.node.json           # 脚手架生成
├── unocss.config.ts             # 新增
├── postcss.config.cjs           # 新增
├── .env.development             # 新增
├── .env.production              # 新增
│
├── src/
│   ├── main.ts                  # 脚手架生成,已重写
│   ├── App.vue                  # 脚手架生成,已重写
│   ├── router/index.ts          # 新增
│   ├── store/
│   │   └── modules/user.ts      # 新增
│   ├── api/
│   │   ├── index.ts             # 新增
│   │   └── modules/
│   │       ├── auth.ts          # 新增
│   │       └── approval.ts      # 新增
│   ├── utils/
│   │   ├── platform.ts          # 新增
│   │   └── platforms/dingtalk.ts # 新增
│   ├── views/
│   │   ├── approval-list/index.vue   # 新增
│   │   └── approval-detail/index.vue # 新增
│   ├── components/
│   │   └── ApprovalActions.vue  # 新增
│   └── styles/global.less       # 新增
│
└── types/
    ├── global.d.ts              # 新增
    ├── auto-imports.d.ts        # 插件自动生成
    └── components.d.ts          # 插件自动生成

十四、开发命令速查

cd approval-h5

# 启动开发服务器 → http://localhost:3000
pnpm dev

# 类型检查 + 生产构建
pnpm build

# 本地预览生产构建
pnpm preview

十五、踩坑记录

#

问题

原因

解决

1

build 报多个类型错误

unplugin-auto-import 生成的 auto-imports.d.tscomponents.d.ts 首次为空文件,vue-tsc 找不到声明

types/ 下手动创建占位声明文件

2

postcss 配置不生效

脚手架默认 "type": "module".js 被当成 ESM

改为 postcss.config.cjs

3

@alova/adapter-axios 参数类型报错

alova 3.x 适配器 API 变化,需要传 { axios: instance } 对象

写成 axiosRequestAdapter({ axios: httpInstance })

4

Vant Tag 的 type prop 类型窄

Vant 把 type 定义为字面量联合类型,Record<string, string> 对不上

返回值显式声明为 'warning' | 'success' | 'danger' | 'default'

 

posted @ 2026-06-13 13:45  极度恐慌_JG  阅读(21)  评论(0)    收藏  举报