Files
SoonDesign/.cursor/skills/uniapp-cli-architecture/SKILL.md
T
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

436 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: uniapp-cli-architecture
description: >-
生成 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 }`
```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 结构对齐 ApiResultcode 与 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`