- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构 - 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐 - 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作 - 新增管理后台、支付与云端文件 API,更新 README 与项目规范 Co-authored-by: Cursor <cursoragent@cursor.com>
12 KiB
name, description
| name | description |
|---|---|
| uniapp-cli-architecture | 生成 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<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))。
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
目录
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 结构对齐 ApiResult,code 与 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.vue:onLaunch → hydrate() + 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 },
},
},
})
H5:VITE_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