重构 monorepo 并完善网页端订阅与首页体验
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构 - 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐 - 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作 - 新增管理后台、支付与云端文件 API,更新 README 与项目规范 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
---
|
||||
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`
|
||||
Reference in New Issue
Block a user