--- name: uniapp-cli-architecture description: >- 生成 uni-app Vue3 Vite TS 标准前端工程;HTTP/Sign/Token/分页/Mock 与 FastAdmin AppApi 对齐。 用于新建项目、域模块、页面、mock 与 API 联调。 --- # uni-app CLI(FastAdmin AppApi) ## FastAdmin 联动 | 后端 | 前端 | |------|------| | `application/api/controller/*.php` 继承 `AppApi` | `modules/<域>/api.ts` 同 path | | `.env` `[api] app_id` `app_secret` `sign_enable` | `VITE_API_*`(secret 仅 `.local`) | | `HTTP Token` | `header.Token` | | `successPaginate()` | `PaginateData` | | `application/extra/api.php` `skip_sign` | 无需签名(支付回调等) | 路径:`/api/{controller}/{action}`。成功 `code=1`。分页 `page` `limit` → `{ list, total, page, limit }`。 ```typescript // types/api.ts export interface ApiResult { code: number msg: string time: number data: T request_id?: string } export interface PaginateData { list: T[] total: number page: number limit: number } ``` | code | 含义 | |------|------| | 1 | 成功 | | 0 | 失败 | | 401 | 未登录 | | 403 | 无权限 | | 4001 | 签名/缺参 | | 4002 | 时间戳过期 | | 4003 | nonce 重复 | | 4004 | app_id 无效 | ## 签名(ApiSign) Query + Body 全参数 → 去 `sign` → 写 `app_id` `timestamp` `nonce` → ksort → `k=v&` → `strtolower(hmac_sha256(plain, secret))`。 Header:`X-App-Id` `X-Timestamp` `X-Nonce` `X-Sign`。空字符串参与拼接;`object/array` 用 `JSON.stringify`。 ## 原则 - 页面 → `service.ts` → `mock.ts` | `api.ts` → `request()`。 - 契约不明则停;单迭代单域。 - `VITE_USE_MOCK`:`true` | `hybrid` | `false`;production 必 `false`。 - secret 不进仓库;小程序禁内置 secret。 ## 技术栈 uni-app CLI · Vite · Vue3 · TS · Pinia · scss · uni-ui · js-sha256 ## 目录 ```text src/ ├── types/api.ts ├── config.ts ├── config/mock-routes.ts ├── services/http/core.ts ├── services/request.ts ├── services/sign.ts ├── modules/<域>/{types,api,service,mock,index}.ts ├── modules/navigation/{core,routes,<域>}.ts ├── stores/ ├── composables/ ├── components/{PageShell,PageState}.vue ├── pages/ ├── package-<域>/ ├── mock/v1/<资源>/.json ├── pages.json manifest.json uni.scss main.ts App.vue .env.development .env.production .env.example ``` 命名:`apiXxx` · `fetchXxx` · `mockXxx` · `goXxx` ## 环境变量 | 变量 | 默认 | 说明 | |------|------|------| | `VITE_API_BASE` | — | 根 URL,无尾斜杠;H5 dev 可空 | | `VITE_USE_MOCK` | `true` | `true`/`hybrid`/`false` | | `VITE_API_SUCCESS_CODE` | `1` | | | `VITE_API_SIGN_ENABLED` | `true` | 对齐 `[api] sign_enable` | | `VITE_API_APP_ID` | — | 对齐 `[api] app_id` | | `VITE_API_SIGN_SECRET` | — | 仅 `.env.development.local` | `.env.example` 占位;gitignore `*.local`。 ## config.ts ```typescript export const API_BASE = String(import.meta.env.VITE_API_BASE || '').trim().replace(/\/$/, '') export const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' export const MOCK_MODE = String(import.meta.env.VITE_USE_MOCK || 'false') export const API_SUCCESS_CODE = Number(import.meta.env.VITE_API_SUCCESS_CODE ?? 1) export const API_SIGN_ENABLED = import.meta.env.VITE_API_SIGN_ENABLED === 'true' export const API_APP_ID = String(import.meta.env.VITE_API_APP_ID || '').trim() export const API_SIGN_SECRET = String(import.meta.env.VITE_API_SIGN_SECRET || '').trim() ``` ## config/mock-routes.ts ```typescript import { MOCK_MODE } from '../config' type RouteKey = `${Uppercase} ${string}` const REAL_ROUTES = new Set([ // 'POST /api/user/login', ]) export function shouldUseRealApi(method: string, path: string): boolean { if (MOCK_MODE === 'false') return true if (MOCK_MODE !== 'hybrid') return false return REAL_ROUTES.has(`${method.toUpperCase()} ${path}` as RouteKey) } ``` ## services/sign.ts ```typescript import sha256 from 'js-sha256' import { API_APP_ID, API_SIGN_ENABLED, API_SIGN_SECRET } from '../config' function encode(v: unknown): string { if (Array.isArray(v) || (v !== null && typeof v === 'object')) { return JSON.stringify(v) } return String(v ?? '') } function mergeParams(url: string, data?: unknown): Record { const out: Record = {} const i = url.indexOf('?') if (i >= 0) { new URLSearchParams(url.slice(i + 1)).forEach((v, k) => { out[k] = v }) } if (data && typeof data === 'object' && !Array.isArray(data)) { for (const [k, v] of Object.entries(data as Record)) { if (v === undefined) continue out[k] = encode(v) } } return out } export function buildSignHeaders(_method: string, url: string, data?: unknown): Record { if (!API_SIGN_ENABLED || !API_SIGN_SECRET || !API_APP_ID) return {} const timestamp = String(Math.floor(Date.now() / 1000)) const nonce = `${Date.now()}_${Math.random().toString(36).slice(2, 12)}` const params = mergeParams(url, data) delete params.sign params.app_id = API_APP_ID params.timestamp = timestamp params.nonce = nonce const plain = Object.keys(params) .sort() .map((k) => `${k}=${params[k]}`) .join('&') return { 'X-App-Id': API_APP_ID, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Sign': sha256.hmac(API_SIGN_SECRET, plain).toLowerCase(), } } ``` ## services/http/core.ts ```typescript import { API_BASE, API_SUCCESS_CODE } from '../../config' import { buildSignHeaders } from '../sign' import type { ApiResult } from '../../types/api' export type HttpOptions = Omit & { url: string skipAuth?: boolean } function resolveUrl(path: string): string { if (/^https?:\/\//i.test(path)) return path const base = API_BASE.replace(/\/$/, '') return `${base}${path.startsWith('/') ? path : `/${path}`}` } export function compactQuery(data?: Record) { if (!data) return undefined const out: Record = {} for (const [k, v] of Object.entries(data)) { if (v !== undefined && v !== null && v !== '') out[k] = v } return Object.keys(out).length ? out : undefined } const TOKEN_KEY = 'token' let onUnauthorized: (() => void) | null = null export function setUnauthorizedHandler(fn: (() => void) | null) { onUnauthorized = fn } export function isApiSuccess(code: number): boolean { return code === API_SUCCESS_CODE } export async function httpRequest(options: HttpOptions): Promise> { const method = (options.method || 'GET').toUpperCase() const url = resolveUrl(options.url) let data = options.data if (method === 'GET' && data && typeof data === 'object') { data = compactQuery(data as Record) } const bodyStr = method === 'GET' || data == null ? '' : typeof data === 'string' ? data : JSON.stringify(data) const header: Record = { 'Content-Type': 'application/json', ...(options.header as Record), ...buildSignHeaders(method, url, data), } if (!options.skipAuth) { const token = String(uni.getStorageSync(TOKEN_KEY) || '') if (token) header.Token = token } return new Promise((resolve, reject) => { uni.request({ ...options, url, method: method as UniApp.RequestOptions['method'], data, header, timeout: options.timeout ?? 15000, success: (res) => { const body = res.data as ApiResult if (!body || typeof body.code !== 'number') { reject(new Error('invalid response')) return } if (body.code === 401 && onUnauthorized) onUnauthorized() resolve(body) }, fail: reject, }) }) } ``` ## services/request.ts ```typescript import { httpRequest } from './http/core' export type { ApiResult, PaginateData } from '../types/api' export { setUnauthorizedHandler, compactQuery, isApiSuccess } from './http/core' export function request( options: Omit & { url: string; skipAuth?: boolean }, ) { return httpRequest(options) } ``` ## 域模块 每域必备:`types.ts` `api.ts` `service.ts` `mock.ts` `index.ts`。 ```typescript // api.ts — path 与后端控制器一致 export function apiPost(path: string, data?: unknown, skipAuth = false) { return request({ url: path, method: 'POST', data, skipAuth }) } export function apiGet(path: string, data?: Record, skipAuth = false) { return request({ url: path, method: 'GET', data, skipAuth }) } // service.ts import { USE_MOCK } from '@/config' import { shouldUseRealApi } from '@/config/mock-routes' export async function fetchXxx(input: XxxQuery) { const path = '/api//' const method = 'GET' if (USE_MOCK && !shouldUseRealApi(method, path)) return mockXxx(input) return apiGet(path, input) } // mock.ts — fixture 结构对齐 ApiResult,code 与 VITE_API_SUCCESS_CODE 一致 // index.ts — export * from './types'; export * from './service' ``` 客户端校验失败:返回 `{ code: 40001, msg, data: null }`,不发 HTTP。 ## stores/user.ts ```typescript import { defineStore } from 'pinia' const TOKEN_KEY = 'token' export const useUserStore = defineStore('user', { state: () => ({ token: '' as string, profile: null as Record | null }), actions: { setSession(token: string, profile: Record) { this.token = token this.profile = profile uni.setStorageSync(TOKEN_KEY, token) }, logout() { this.token = '' this.profile = null uni.removeStorageSync(TOKEN_KEY) }, hydrate() { this.token = String(uni.getStorageSync(TOKEN_KEY) || '') }, }, }) ``` `App.vue`:`onLaunch` → `hydrate()` + `setUnauthorizedHandler(() => userStore.logout())`。 ## navigation ```typescript // core.ts export function safeNavigate(url: string) { uni.navigateTo({ url, fail: () => uni.showToast({ title: '跳转失败', icon: 'none' }) }) } // routes.ts — 常量路径 // <域>.ts — goXxx() 封装 ``` ## 页面 主包 Tab/入口;分包重流程。四态 `loading|empty|success|error`。>350 行拆 composable。样式 BEM + scoped scss。 ## vite.config.ts ```typescript import { defineConfig } from 'vite' import uni from '@dcloudio/vite-plugin-uni' import { fileURLToPath, URL } from 'node:url' export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, css: { preprocessorOptions: { scss: { additionalData: '@import "@/uni.scss";' } } }, plugins: [uni()], server: { proxy: { '/api': { target: process.env.VITE_DEV_PROXY_TARGET || 'http://127.0.0.1:8080', changeOrigin: true }, }, }, }) ``` H5:`VITE_API_BASE=''` + proxy。小程序/App:完整域名;后端 `fastadmin.cors_request_domain` 放行。 ## package.json ```json { "scripts": { "dev:h5": "uni -p h5", "dev:mp-weixin": "uni -p mp-weixin", "build:h5": "uni build -p h5", "build:mp-weixin": "uni build -p mp-weixin", "typecheck": "vue-tsc --noEmit" } } ``` ## 初始化顺序 ```text 1. npx degit dcloudio/uni-preset-vue#vite-ts && cd 2. npm i pinia js-sha256 sass 3. types/api.ts config.ts config/mock-routes.ts 4. services/sign.ts http/core.ts request.ts 5. modules/navigation/* 6. stores/user.ts modules/auth/*(init/login 首域) 7. components/PageShell.vue PageState.vue 8. pages.json easycom tabBar manifest.json uni.scss 9. .env.development .env.production .env.example 10. vite.config.ts main.ts App.vue 11. npm run typecheck && npm run dev:h5 12. hybrid 联调:REAL_ROUTES 增量;sign/Token 通过后扩域 ``` ## 新建域 Checklist ```text [ ] types.ts [ ] mock/v1/*.json [ ] mock.ts api.ts service.ts index.ts [ ] navigation/<域>.ts + pages.json [ ] 页面四态 + typecheck [ ] hybrid 登记 path ``` ## 禁止 - 页面/Store import mock 或 api - api.ts mock 分支 - Authorization Bearer(用 Token) - 自创签名算法 - production mock 或 secret 入库 - 页面 uni.request ## 例外 `/static/**` · `uni.login` · `uni.requestPayment`