88c6ce8ccc
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构 - 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐 - 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作 - 新增管理后台、支付与云端文件 API,更新 README 与项目规范 Co-authored-by: Cursor <cursoragent@cursor.com>
436 lines
12 KiB
Markdown
436 lines
12 KiB
Markdown
---
|
||
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<T>` |
|
||
| `application/extra/api.php` `skip_sign` | 无需签名(支付回调等) |
|
||
|
||
路径:`/api/{controller}/{action}`。成功 `code=1`。分页 `page` `limit` → `{ list, total, page, limit }`。
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
## 目录
|
||
|
||
```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/<资源>/<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
|
||
|
||
```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>} ${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
|
||
|
||
```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<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
|
||
|
||
```typescript
|
||
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
|
||
|
||
```typescript
|
||
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`。
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
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
|
||
|
||
```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 <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
|
||
|
||
```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`
|