Files
24kycj 88c6ce8ccc 重构 monorepo 并完善网页端订阅与首页体验
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构
- 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐
- 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作
- 新增管理后台、支付与云端文件 API,更新 README 与项目规范

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 18:17:39 +08:00

12 KiB
Raw Permalink Blame History

name, description
name description
uniapp-cli-architecture 生成 uni-app Vue3 Vite TS 标准前端工程;HTTP/Sign/Token/分页/Mock 与 FastAdmin AppApi 对齐。 用于新建项目、域模块、页面、mock 与 API 联调。

uni-app CLIFastAdmin 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<T>
application/extra/api.php skip_sign 无需签名(支付回调等)

路径:/api/{controller}/{action}。成功 code=1。分页 page limit{ list, total, page, limit }

// types/api.ts
export interface ApiResult<T = unknown> {
  code: number
  msg: string
  time: number
  data: T
  request_id?: string
}

export interface PaginateData<T> {
  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))

HeaderX-App-Id X-Timestamp X-Nonce X-Sign。空字符串参与拼接;object/arrayJSON.stringify

原则

  • 页面 → service.tsmock.ts | api.tsrequest()
  • 契约不明则停;单迭代单域。
  • VITE_USE_MOCKtrue | hybrid | falseproduction 必 false
  • secret 不进仓库;小程序禁内置 secret。

技术栈

uni-app CLI · Vite · Vue3 · TS · Pinia · scss · uni-ui · js-sha256

目录

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/<资源>/<action>.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

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

import { MOCK_MODE } from '../config'

type RouteKey = `${Uppercase<string>} ${string}`

const REAL_ROUTES = new Set<RouteKey>([
  // '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

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<string, string> {
  const out: Record<string, string> = {}
  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<string, unknown>)) {
      if (v === undefined) continue
      out[k] = encode(v)
    }
  }
  return out
}

export function buildSignHeaders(_method: string, url: string, data?: unknown): Record<string, string> {
  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

import { API_BASE, API_SUCCESS_CODE } from '../../config'
import { buildSignHeaders } from '../sign'
import type { ApiResult } from '../../types/api'

export type HttpOptions = Omit<UniApp.RequestOptions, 'url'> & {
  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<string, unknown>) {
  if (!data) return undefined
  const out: Record<string, unknown> = {}
  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<T>(options: HttpOptions): Promise<ApiResult<T>> {
  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<string, unknown>)
  }
  const bodyStr =
    method === 'GET' || data == null
      ? ''
      : typeof data === 'string'
        ? data
        : JSON.stringify(data)

  const header: Record<string, string> = {
    'Content-Type': 'application/json',
    ...(options.header as Record<string, string>),
    ...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<T>
        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

import { httpRequest } from './http/core'

export type { ApiResult, PaginateData } from '../types/api'
export { setUnauthorizedHandler, compactQuery, isApiSuccess } from './http/core'

export function request<T>(
  options: Omit<UniApp.RequestOptions, 'url'> & { url: string; skipAuth?: boolean },
) {
  return httpRequest<T>(options)
}

域模块

每域必备:types.ts api.ts service.ts mock.ts index.ts

// api.ts — path 与后端控制器一致
export function apiPost<T>(path: string, data?: unknown, skipAuth = false) {
  return request<T>({ url: path, method: 'POST', data, skipAuth })
}
export function apiGet<T>(path: string, data?: Record<string, unknown>, skipAuth = false) {
  return request<T>({ 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/<controller>/<action>'
  const method = 'GET'
  if (USE_MOCK && !shouldUseRealApi(method, path)) return mockXxx(input)
  return apiGet<XxxData>(path, input)
}

// mock.ts — fixture 结构对齐 ApiResultcode 与 VITE_API_SUCCESS_CODE 一致
// index.ts — export * from './types'; export * from './service'

客户端校验失败:返回 { code: 40001, msg, data: null },不发 HTTP。

stores/user.ts

import { defineStore } from 'pinia'

const TOKEN_KEY = 'token'

export const useUserStore = defineStore('user', {
  state: () => ({ token: '' as string, profile: null as Record<string, unknown> | null }),
  actions: {
    setSession(token: string, profile: Record<string, unknown>) {
      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.vueonLaunchhydrate() + setUnauthorizedHandler(() => userStore.logout())

navigation

// 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

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 },
    },
  },
})

H5VITE_API_BASE='' + proxy。小程序/App:完整域名;后端 fastadmin.cors_request_domain 放行。

package.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"
  }
}

初始化顺序

1. npx degit dcloudio/uni-preset-vue#vite-ts <name> && cd <name>
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

[ ] 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