重构 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,44 @@
|
||||
---
|
||||
description: 对话、查证、改动、引用与指令优先级(本仓库默认)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 用户级规则(核心)
|
||||
|
||||
## 非琐碎开发(回复最前 3 行)
|
||||
|
||||
`Skills:`(0~3 个,无则写无)| `MCP:`(无则写无)| `操作:` 勾选 Skills 或写「跳过」。
|
||||
任务相关须读对应 `SKILL.md` 并按其执行;MCP 先读 schema,强相关再用,勿为调用而调用。
|
||||
|
||||
## 原则
|
||||
|
||||
- **不知则停**:无法从仓库或权威来源验证 → 只问缺什么或给最短验证;禁止猜、禁止先改再说。
|
||||
- **禁幻觉**:不编造路径、符号、API、配置、版本;不确定写「不确定」及如何查证。
|
||||
- **继承上下文**:结合整段对话;最新一条默认延续当前任务,区分细化与换方向。
|
||||
- **回复**:简体中文;标识符与代码保持原文。结构:**结论** → **要点** → **命令**(若有)。少寒暄、少加粗;禁「保证/一定/完美/彻底解决」。
|
||||
- **引用代码**:独占一行 `startLine:endLine:filepath`,行号与路径须真实。
|
||||
- **中文 UTF-8**:含中文文件 UTF-8 无 BOM;灌库只用 `scripts/apply-mysql-utf8.ps1`(禁 `type`/管道灌库);`bat` 不写中文 SQL。
|
||||
|
||||
## 改动
|
||||
|
||||
- **最小 scope**:只改任务所需;少动无关文件;禁止为省事堆进单文件(巨型组件、全局聚合 types/index 等)。
|
||||
- **单点推进**:同一问题优先一处修;连续两次仍不稳 → 停加代码,列 1~3 条假设或要最小复现。
|
||||
- **未要求不扩**:连带风险一句点出;与任务冲突时让用户选,不绕边界扩 scope。
|
||||
- **随改随清**:替换/迁路径/删功能时,在同一边界内删失效代码、引用、配置与文件;先搜索确认无引用再删,无法确认则问用户。
|
||||
- **清理范围**:不做无关全库大扫除;不主动删跨模块资产、文档、迁移、构建产物或用途不明的历史文件;删/留须在回复中简要说明。
|
||||
|
||||
## 结构
|
||||
|
||||
- 动手前读仓库既有目录、命名与同类模块(`README` / `AGENTS.md` / `docs` / 邻近代码),新代码跟现有一致。
|
||||
- **一次一边界**(feature / 层 / 子系统);契约(类型、API、路由配置)与实现(handler、service、UI)分离,避免逻辑散落。
|
||||
- 单文件过大先按仓库习惯拆分;复用现有抽象;diff 与 UI/错误处理跟仓库一致。
|
||||
|
||||
## 执行
|
||||
|
||||
- 能本机执行则直接执行并回报;不把可执行项只写成教程;失败可换路径再试。
|
||||
- `user_info` 中的日期/年份以该字段为准。
|
||||
- 密钥、令牌、生产配置脱敏,不写入仓库与日志。
|
||||
|
||||
## 遵循
|
||||
|
||||
- 用户规则、工具说明、Skill 说明中指定格式/流程须执行,非建议。
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
description: 写代码时的约束(本仓库默认)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 写代码
|
||||
|
||||
- 只改完成任务所需的代码;不 drive-by 重构、不编辑无关文件、不扩 scope;不写无调用链的示范代码、不夹带 demo 文件。
|
||||
- 写之前读上下文;命名、类型、抽象、import、注释密度与现有代码一致;优先复用已有函数/组件。
|
||||
- diff 中每行都应对任务负责:不写冗长注释/显而易见 docstring/多余变量/过度 try-catch;优先统一路径而非堆分支。
|
||||
- 不删与任务无关的注释或代码(避免难审 diff)。
|
||||
- UI:间距、字体、颜色、布局与现有设计一致。
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
description: 仅在用户明确要求时 git commit;安全协议
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Git commit
|
||||
|
||||
- 仅当用户在当前对话中明确要求创建 commit 时才提交;若含糊先问。用户未要求 commit 则不要提交。
|
||||
- 禁止:`git config` 任何修改。
|
||||
- 禁止:未获用户明确要求的破坏性命令(如 `push --force`、`reset --hard` 等)。
|
||||
- 禁止:跳过 hooks(`--no-verify`、`--no-gpg-sign` 等)除非用户明确要求。
|
||||
- 禁止:`git push --force` 到 `main`/`master`;若用户要求则先警告。
|
||||
- 避免 `git commit --amend`。仅当同时满足:用户明确要求 amend,或 commit 成功但 hook 自动改了文件需纳入;且 HEAD 为你本会话所建;且未 push(`git status` 显示 ahead 且未 push)——才 amend。commit 失败或被 hook 拒绝:禁止 amend,修问题后新建 commit。已 push:禁止 amend(除非用户明确要求并知悉需 force)。
|
||||
- 禁止:`git` 带 `-i` 的交互命令。
|
||||
- 无改动则不要空 commit。
|
||||
- 不要 stage/提交明显密钥文件(`.env`、`credentials.json` 等);用户若坚持提交须警告。
|
||||
- 创建 commit 时并行执行:`git status`、`git diff`(staged+unstaged)、`git log`(看消息风格);该阶段除 git 外不要用其它命令读仓库/扫代码;分析后写 message;再顺序:`git add` 相关文件 → `git commit` → `git status` 校验。
|
||||
- commit message 用 HEREDOC 传入(示例):`git commit -m "$(cat <<'EOF'\n...\nEOF\n)"`。
|
||||
- 禁止:用户未明确要求则不要 `git push`。
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description: GitHub 任务用 gh;创建 PR 流程
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# GitHub / PR
|
||||
|
||||
- 所有 GitHub 相关操作(issues、PR、checks、releases、从 URL 取信息)用 `gh`,通过 Shell 执行。
|
||||
- 创建 PR 前并行:`git status`、`git diff`、检查当前分支是否跟踪远端及是否与远端同步、`git log` + `git diff <base-branch>...HEAD`(覆盖从 base 分叉起的全部提交,非只看最新一条)。
|
||||
- 分析将纳入 PR 的全部改动与全部相关 commit,再写 PR 摘要。
|
||||
- 顺序:必要时建新分支 → 需要时 `git push -u origin HEAD` → `gh pr create`;`--body` 用 HEREDOC。
|
||||
- PR body 结构:`## Summary`(1~3 条)、`## Test plan`(可勾选清单)。
|
||||
- 完成后把 PR URL 返回给用户。
|
||||
- 禁止:`git config` 修改;禁止用户未要求时 `push`。
|
||||
@@ -0,0 +1,633 @@
|
||||
---
|
||||
name: electron-vue2-architecture
|
||||
description: >-
|
||||
生成 Electron + Vue2 + Element UI + Vuex 桌面端标准前端工程;HTTP/Sign/Token/分页/Mock
|
||||
与 FastAdmin AppApi 对齐。用于新建项目、域模块、页面、mock 与 API 联调。
|
||||
---
|
||||
|
||||
# Electron + Vue2(FastAdmin AppApi)
|
||||
|
||||
## FastAdmin 联动
|
||||
|
||||
| 后端 | 前端 |
|
||||
|------|------|
|
||||
| `application/api/controller/*.php` 继承 `AppApi` | `src/modules/<域>/api.ts` 同 path |
|
||||
| `.env` `[api] app_id` `app_secret` `sign_enable` | `VUE_APP_API_*`(secret 仅 `.local`) |
|
||||
| `HTTP Token` | `Authorization: Bearer {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
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public code: number, message: string, public requestId?: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 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`。
|
||||
|
||||
## 原则
|
||||
|
||||
- views → composable → `service.ts` → `mock.ts` | `api.ts` → `helpers` → `core`。
|
||||
- store actions → `service.ts`;mutation 不发 HTTP。
|
||||
- 契约不明则停;单迭代单域。
|
||||
- `VUE_APP_USE_MOCK`:`true` | `hybrid` | `false`;production 必 `false`。
|
||||
- secret 不进仓库。
|
||||
- 渲染进程 Node API 走 preload + `contextBridge`。
|
||||
- Vuex `namespaced` 模块化。
|
||||
|
||||
## 技术栈
|
||||
|
||||
Electron · Vue CLI 5 · Vue2.7 · TS · Vuex 4 · vue-router@3 · Element UI · scss · axios · js-sha256 · electron-store · electron-builder
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
electron/{main,preload,security}.ts electron/ipc/{index,store}.ts electron/tsconfig.json
|
||||
src/types/{api,global}.ts config.ts config/mock-routes.ts
|
||||
src/services/{sign,request}.ts services/http/{core,helpers}.ts
|
||||
src/modules/<域>/{types,api,service,mock,index}.ts
|
||||
src/modules/navigation/{core,routes,<域>}.ts
|
||||
src/store/{index,types}.ts store/modules/user.ts
|
||||
src/router/{index,routes,guards}.ts router/modules/<域>.ts
|
||||
src/composables/usePageState.ts components/{PageShell,PageState}.vue
|
||||
src/layouts/DefaultLayout.vue views/<域>/<Page>.vue
|
||||
src/main.ts App.vue styles/{variables,element-overrides}.scss
|
||||
mock/v1/<资源>/<action>.json
|
||||
.env.development .env.production .env.example
|
||||
vue.config.js electron-builder.yml package.json
|
||||
```
|
||||
|
||||
命名:`apiXxx` · `fetchXxx` · `mockXxx` · `goXxx` · `useXxx`
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `VUE_APP_API_BASE` | — | 根 URL,无尾斜杠 |
|
||||
| `VUE_APP_USE_MOCK` | `true` | `true`/`hybrid`/`false` |
|
||||
| `VUE_APP_API_SUCCESS_CODE` | `1` | |
|
||||
| `VUE_APP_API_SIGN_ENABLED` | `true` | 对齐 `[api] sign_enable` |
|
||||
| `VUE_APP_API_APP_ID` | — | 对齐 `[api] app_id` |
|
||||
| `VUE_APP_API_SIGN_SECRET` | — | 仅 `.env.development.local` |
|
||||
| `VUE_APP_DEV_SERVER_URL` | — | Electron dev 加载地址 |
|
||||
|
||||
`.env.example` 占位;gitignore `*.local`。
|
||||
|
||||
## config.ts
|
||||
|
||||
```typescript
|
||||
export const API_BASE = String(process.env.VUE_APP_API_BASE || '').trim().replace(/\/$/, '')
|
||||
export const USE_MOCK = process.env.VUE_APP_USE_MOCK === 'true'
|
||||
export const MOCK_MODE = String(process.env.VUE_APP_USE_MOCK || 'false')
|
||||
export const API_SUCCESS_CODE = Number(process.env.VUE_APP_API_SUCCESS_CODE ?? 1)
|
||||
export const API_SIGN_ENABLED = process.env.VUE_APP_API_SIGN_ENABLED === 'true'
|
||||
export const API_APP_ID = String(process.env.VUE_APP_API_APP_ID || '').trim()
|
||||
export const API_SIGN_SECRET = String(process.env.VUE_APP_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 axios, { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||
import { API_BASE, API_SUCCESS_CODE } from '../../config'
|
||||
import { buildSignHeaders } from '../sign'
|
||||
import type { ApiResult } from '../../types/api'
|
||||
|
||||
export type HttpOptions = AxiosRequestConfig & { skipAuth?: boolean }
|
||||
|
||||
let onUnauthorized: (() => void) | null = null
|
||||
export function setUnauthorizedHandler(fn: (() => void) | null) { onUnauthorized = fn }
|
||||
export function isApiSuccess(code: number): boolean { return code === API_SUCCESS_CODE }
|
||||
|
||||
function resolveUrl(path: string): string {
|
||||
if (/^https?:\/\//i.test(path)) return path
|
||||
const base = API_BASE.replace(/\/$/, '')
|
||||
return `${base}${path.startsWith('/') ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
const http = axios.create({ baseURL: API_BASE, timeout: 15000 })
|
||||
|
||||
http.interceptors.request.use((cfg: InternalAxiosRequestConfig & { skipAuth?: boolean }) => {
|
||||
cfg.url = resolveUrl(cfg.url || '')
|
||||
const method = (cfg.method || 'GET').toUpperCase()
|
||||
Object.assign(cfg.headers, buildSignHeaders(method, cfg.url || '', method === 'GET' ? cfg.params : cfg.data))
|
||||
if (!cfg.skipAuth) {
|
||||
const token = window.electronAPI?.getToken?.() ?? window.localStorage.getItem('token') ?? ''
|
||||
if (token) cfg.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return cfg
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(res: AxiosResponse<ApiResult>) => {
|
||||
const body = res.data
|
||||
if (!body || typeof body.code !== 'number') return Promise.reject(new Error('invalid response'))
|
||||
if (body.code === 401 && onUnauthorized) onUnauthorized()
|
||||
return res
|
||||
},
|
||||
(err) => Promise.reject(err),
|
||||
)
|
||||
|
||||
export async function httpRequest<T>(options: HttpOptions): Promise<ApiResult<T>> {
|
||||
return (await http.request<ApiResult<T>>(options)).data
|
||||
}
|
||||
```
|
||||
|
||||
## services/http/helpers.ts
|
||||
|
||||
```typescript
|
||||
import { ApiError, ApiResult } from '../../types/api'
|
||||
import { API_SUCCESS_CODE } from '../../config'
|
||||
import { httpRequest } from './core'
|
||||
|
||||
export function unwrapApi<T>(res: ApiResult<T>): T {
|
||||
if (res.code !== API_SUCCESS_CODE) throw new ApiError(res.code, res.msg || 'request failed', res.request_id)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export function apiGet<T>(path: string, params?: Record<string, unknown>, skipAuth = false) {
|
||||
return httpRequest<T>({ url: path, method: 'GET', params, skipAuth })
|
||||
}
|
||||
|
||||
export function apiPost<T>(path: string, data?: unknown, skipAuth = false) {
|
||||
return httpRequest<T>({ url: path, method: 'POST', data, skipAuth })
|
||||
}
|
||||
```
|
||||
|
||||
## services/request.ts
|
||||
|
||||
```typescript
|
||||
export type { ApiResult, PaginateData, ApiError } from '../types/api'
|
||||
export { setUnauthorizedHandler, isApiSuccess } from './http/core'
|
||||
export { unwrapApi, apiGet, apiPost } from './http/helpers'
|
||||
export type { HttpOptions } from './http/core'
|
||||
```
|
||||
|
||||
## 域模块
|
||||
|
||||
每域必备:`types.ts` `api.ts` `service.ts` `mock.ts` `index.ts`。首域 `auth`(login/logout)。
|
||||
|
||||
```typescript
|
||||
// api.ts
|
||||
import { apiGet, apiPost } from '@/services/request'
|
||||
|
||||
export function apiLogin(input: LoginInput) {
|
||||
return apiPost<LoginData>('/api/user/login', input, true)
|
||||
}
|
||||
|
||||
// service.ts
|
||||
import { USE_MOCK } from '@/config'
|
||||
import { shouldUseRealApi } from '@/config/mock-routes'
|
||||
import { unwrapApi, apiGet } from '@/services/request'
|
||||
import { apiLogin } from './api'
|
||||
import { mockXxx } from './mock'
|
||||
|
||||
export async function fetchXxx(input: XxxQuery) {
|
||||
const path = '/api/<controller>/<action>'
|
||||
const method = 'GET'
|
||||
if (USE_MOCK && !shouldUseRealApi(method, path)) return mockXxx(input)
|
||||
return unwrapApi(await apiGet<XxxData>(path, input))
|
||||
}
|
||||
|
||||
// mock.ts
|
||||
// index.ts — export * from './types'; export * from './service'
|
||||
```
|
||||
|
||||
客户端校验失败:返回 `{ code: 40001, msg, data: null }`,不发 HTTP。
|
||||
|
||||
## store/types.ts
|
||||
|
||||
```typescript
|
||||
import type { UserState } from './modules/user'
|
||||
export interface RootState { user: UserState }
|
||||
```
|
||||
|
||||
## store/modules/user.ts
|
||||
|
||||
```typescript
|
||||
import { Module } from 'vuex'
|
||||
import { fetchLogin, fetchLogout } from '@/modules/auth'
|
||||
import type { LoginInput } from '@/modules/auth'
|
||||
import type { RootState } from '../types'
|
||||
|
||||
export interface UserState {
|
||||
token: string
|
||||
profile: Record<string, unknown> | null
|
||||
role: 'admin' | 'user' | 'sync' | ''
|
||||
}
|
||||
|
||||
export const user: Module<UserState, RootState> = {
|
||||
namespaced: true,
|
||||
state: () => ({ token: '', profile: null, role: '' }),
|
||||
getters: {
|
||||
isLoggedIn: (s) => !!s.token,
|
||||
hasRole: (s) => (roles: UserState['role'][]) => roles.includes(s.role),
|
||||
},
|
||||
mutations: {
|
||||
setSession(state, p: { token: string; profile: Record<string, unknown>; role: UserState['role'] }) {
|
||||
state.token = p.token
|
||||
state.profile = p.profile
|
||||
state.role = p.role
|
||||
window.electronAPI?.setToken?.(p.token)
|
||||
window.localStorage.setItem('token', p.token)
|
||||
},
|
||||
clearSession(state) {
|
||||
state.token = ''
|
||||
state.profile = null
|
||||
state.role = ''
|
||||
window.electronAPI?.setToken?.('')
|
||||
window.localStorage.removeItem('token')
|
||||
},
|
||||
hydrate(state) {
|
||||
state.token = String(window.electronAPI?.getToken?.() ?? window.localStorage.getItem('token') ?? '')
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async login({ commit }, input: LoginInput) {
|
||||
const data = await fetchLogin(input)
|
||||
commit('setSession', { token: data.token, profile: data.profile, role: data.role })
|
||||
},
|
||||
async logout({ commit }) {
|
||||
try { await fetchLogout() } catch { /* ignore */ }
|
||||
commit('clearSession')
|
||||
},
|
||||
hydrate({ commit }) { commit('hydrate') },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## store/index.ts
|
||||
|
||||
```typescript
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import { user } from './modules/user'
|
||||
import type { RootState } from './types'
|
||||
|
||||
Vue.use(Vuex)
|
||||
export default new Vuex.Store<RootState>({
|
||||
modules: { user },
|
||||
strict: process.env.NODE_ENV !== 'production',
|
||||
})
|
||||
```
|
||||
|
||||
## router
|
||||
|
||||
`routes.ts`:`ROUTE_NAMES` + `constantRoutes`(login/404/public)。`guards.ts`:未登录 → login;`meta.roles` → `user/hasRole`。`index.ts`:`hash` + `DefaultLayout` children。
|
||||
|
||||
## composables/usePageState.ts
|
||||
|
||||
```typescript
|
||||
import { ref, Ref } from 'vue'
|
||||
|
||||
export type PageStatus = 'loading' | 'empty' | 'success' | 'error'
|
||||
|
||||
export function usePageState<T>(loader: () => Promise<T>, isEmpty: (d: T) => boolean) {
|
||||
const status: Ref<PageStatus> = ref('loading')
|
||||
const data: Ref<T | null> = ref(null)
|
||||
const error = ref('')
|
||||
async function run() {
|
||||
status.value = 'loading'
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await loader()
|
||||
data.value = res
|
||||
status.value = isEmpty(res) ? 'empty' : 'success'
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'load failed'
|
||||
status.value = 'error'
|
||||
}
|
||||
}
|
||||
return { status, data, error, run }
|
||||
}
|
||||
```
|
||||
|
||||
## navigation
|
||||
|
||||
```typescript
|
||||
// core.ts
|
||||
import router from '@/router'
|
||||
export function safeNavigate(to: string) {
|
||||
router.push(to).catch(() => router.push('/'))
|
||||
}
|
||||
// routes.ts
|
||||
// <域>.ts — goXxx()
|
||||
```
|
||||
|
||||
## 页面
|
||||
|
||||
`DefaultLayout` + `PageState` + `usePageState`。四态 `loading|empty|success|error`。>350 行拆 composable。BEM + scoped scss。
|
||||
|
||||
## main.ts
|
||||
|
||||
```typescript
|
||||
import Vue from 'vue'
|
||||
import ElementUI from 'element-ui'
|
||||
import 'element-ui/lib/theme-chalk/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
import { setUnauthorizedHandler } from '@/services/request'
|
||||
import '@/styles/element-overrides.scss'
|
||||
|
||||
Vue.use(ElementUI, { size: 'medium' })
|
||||
Vue.config.productionTip = false
|
||||
store.dispatch('user/hydrate')
|
||||
setUnauthorizedHandler(() => store.dispatch('user/logout'))
|
||||
new Vue({ router, store, render: (h) => h(App) }).$mount('#app')
|
||||
```
|
||||
|
||||
## types/global.d.ts
|
||||
|
||||
```typescript
|
||||
interface ElectronAPI {
|
||||
getToken: () => string
|
||||
setToken: (v: string) => Promise<void>
|
||||
clearCache: () => Promise<void>
|
||||
openExternal: (url: string) => Promise<void>
|
||||
}
|
||||
declare global { interface Window { electronAPI: ElectronAPI } }
|
||||
```
|
||||
|
||||
## electron/main.ts
|
||||
|
||||
```typescript
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import path from 'path'
|
||||
import { registerIpc } from './ipc'
|
||||
import { applySecurity } from './security'
|
||||
|
||||
let win: BrowserWindow | null = null
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1280, height: 800, show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true, nodeIntegration: false, sandbox: true,
|
||||
},
|
||||
})
|
||||
if (!app.isPackaged && process.env.VUE_APP_DEV_SERVER_URL) {
|
||||
win.loadURL(process.env.VUE_APP_DEV_SERVER_URL)
|
||||
} else {
|
||||
win.loadFile(path.join(__dirname, '../dist/index.html'))
|
||||
}
|
||||
win.once('ready-to-show', () => win?.show())
|
||||
win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' } })
|
||||
applySecurity(win)
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpc()
|
||||
createWindow()
|
||||
app.on('activate', () => { if (!BrowserWindow.getAllWindows().length) createWindow() })
|
||||
})
|
||||
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })
|
||||
```
|
||||
|
||||
## electron/preload.ts
|
||||
|
||||
```typescript
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getToken: (): string => ipcRenderer.invoke('token:get'),
|
||||
setToken: (v: string): Promise<void> => ipcRenderer.invoke('token:set', v),
|
||||
clearCache: (): Promise<void> => ipcRenderer.invoke('cache:clear'),
|
||||
openExternal: (url: string): Promise<void> => ipcRenderer.invoke('shell:openExternal', url),
|
||||
})
|
||||
```
|
||||
|
||||
## electron/security.ts
|
||||
|
||||
```typescript
|
||||
import { BrowserWindow, session } from 'electron'
|
||||
|
||||
export function applySecurity(win: BrowserWindow) {
|
||||
win.webContents.on('will-navigate', (e) => e.preventDefault())
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
|
||||
cb({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
'Content-Security-Policy': ["default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http:; connect-src 'self' " + (process.env.VUE_APP_API_BASE || '') + ";"],
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## electron/ipc
|
||||
|
||||
```typescript
|
||||
// index.ts
|
||||
import { registerStoreIpc } from './store'
|
||||
export function registerIpc() { registerStoreIpc() }
|
||||
|
||||
// store.ts
|
||||
import { ipcMain } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
const store = new Store()
|
||||
export function registerStoreIpc() {
|
||||
ipcMain.handle('token:get', () => store.get('token', ''))
|
||||
ipcMain.handle('token:set', (_e, v: string) => store.set('token', v))
|
||||
ipcMain.handle('cache:clear', async () => undefined)
|
||||
ipcMain.handle('shell:openExternal', (_e, url: string) => { require('electron').shell.openExternal(url) })
|
||||
}
|
||||
```
|
||||
|
||||
## vue.config.js
|
||||
|
||||
```javascript
|
||||
const { defineConfig } = require('@vue/cli-service')
|
||||
const path = require('path')
|
||||
|
||||
module.exports = defineConfig({
|
||||
publicPath: './',
|
||||
outputDir: 'dist',
|
||||
productionSourceMap: false,
|
||||
configureWebpack: { resolve: { alias: { '@': path.resolve(__dirname, 'src') } } },
|
||||
css: { loaderOptions: { sass: { additionalData: '@import "@/styles/variables.scss";' } } },
|
||||
devServer: {
|
||||
port: 9080,
|
||||
proxy: { '/api': { target: process.env.VUE_APP_DEV_PROXY_TARGET || 'http://127.0.0.1:8080', changeOrigin: true } },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## electron-builder.yml
|
||||
|
||||
```yaml
|
||||
appId: com.xenon.client
|
||||
productName: XENON-Client
|
||||
directories:
|
||||
output: dist_electron
|
||||
buildResources: build
|
||||
files:
|
||||
- dist/**
|
||||
- electron/**
|
||||
- package.json
|
||||
- "!**/*.map"
|
||||
win:
|
||||
target: nsis
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
```
|
||||
|
||||
## package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev:web": "vue-cli-service serve",
|
||||
"dev:electron": "npm run build:electron && cross-env VUE_APP_DEV_SERVER_URL=http://127.0.0.1:9080 electron .",
|
||||
"build:web": "vue-cli-service build",
|
||||
"build:electron": "tsc -p electron/tsconfig.json",
|
||||
"dist": "npm run build:web && electron-builder",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 初始化顺序
|
||||
|
||||
```text
|
||||
1. npx @vue/cli create <name>(Vue2 + TS + Router + Vuex)
|
||||
2. npm i axios element-ui js-sha256 electron-store
|
||||
3. npm i -D electron electron-builder @types/electron cross-env sass
|
||||
4. electron/* + src/types/* + config* + services/*
|
||||
5. store/* + router/* + modules/auth/* + modules/navigation/*
|
||||
6. composables/* + components/PageState.vue + layouts/DefaultLayout.vue
|
||||
7. views/auth/Login.vue + main.ts + .env*
|
||||
8. vue.config.js + electron-builder.yml
|
||||
9. npm run typecheck && npm run dev:web && npm run dev:electron
|
||||
10. hybrid:REAL_ROUTES 增量;sign/Token 通过后扩域
|
||||
```
|
||||
|
||||
## 新建域 Checklist
|
||||
|
||||
```text
|
||||
[ ] modules/<域>/{types,mock,api,service,index}.ts
|
||||
[ ] mock/v1/*.json
|
||||
[ ] router/modules/<域>.ts
|
||||
[ ] navigation/<域>.ts + views/<域>/*.vue
|
||||
[ ] 四态 + typecheck
|
||||
[ ] meta.roles(若需)
|
||||
[ ] hybrid 登记 path
|
||||
```
|
||||
|
||||
## 禁止
|
||||
|
||||
- 页面/Store import mock 或 api
|
||||
- api.ts mock 分支
|
||||
- 域内重复 apiGet/apiPost
|
||||
- 自创签名算法
|
||||
- 渲染进程 `require('fs'|'child_process'|'electron')`
|
||||
- `nodeIntegration: true` 或 `contextIsolation: false`
|
||||
- `header.Token`(用 `Authorization: Bearer`)
|
||||
- production mock 或 secret 入库
|
||||
- 页面 axios 裸调
|
||||
- mutation 内发 HTTP
|
||||
|
||||
## 例外
|
||||
|
||||
- `window.electronAPI` IPC 白名单
|
||||
- `electron-builder` 目录 `build/`
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
name: fullstack-delivery-workflow
|
||||
description: >-
|
||||
XENON 双轨交付工作流:Electron 客户端页+Mock(轨道 A)、FastAdmin 业务模块+联调(轨道 B)。
|
||||
含 progress 快照、模块签收表、四则固定提示词。配合 electron-vue2-architecture 与
|
||||
docs/api 契约使用。
|
||||
---
|
||||
|
||||
# XENON 全栈开发工作流
|
||||
|
||||
## 与架构 Skill 分工
|
||||
|
||||
| Skill | 职责 |
|
||||
|-------|------|
|
||||
| electron-vue2-architecture | Electron 目录、HTTP、Mock、Sign、Vuex、域模块代码 |
|
||||
| 本文 | 分批、自检、签收、progress 维护 |
|
||||
|
||||
执行顺序:读本文定轨道与批次 → 读 electron-vue2-architecture 写代码 → 批末门禁 → 只更新 progress 快照。
|
||||
|
||||
## 项目边界
|
||||
|
||||
| 项 | 路径 / 约定 |
|
||||
|----|-------------|
|
||||
| 后端 | `backend-xenon/`(FastAdmin · ThinkPHP) |
|
||||
| 客户端 | Electron + Vue2 + Element UI(待建或 `client/` 目录) |
|
||||
| API 契约 | `docs/api/客户端接口文档.md`、`docs/api/admin端接口文档.md`、`docs/api/数据库文档.md` |
|
||||
| 总方案 | `docs/开发方案.md` |
|
||||
| 本地联调 | `backend-xenon/scripts/start.bat` → Admin `8080`、MySQL `13306` |
|
||||
| 同步 / 离线 | 非本工作流范围 |
|
||||
|
||||
接口 url「待复核」处:按业务语义找接口,**不擅自改**文档(见 `开发方案 §3.4`)。
|
||||
|
||||
## 双轨
|
||||
|
||||
| 轨道 | 单位 | Mock | 何时用 |
|
||||
|------|------|------|--------|
|
||||
| A | 1~3 页,同业务域 | `VUE_APP_USE_MOCK=true` | Electron UI + Mock、页契约 |
|
||||
| B | 1 业务模块(该模块全部 path) | `hybrid` → `false` | 后端 Controller/Service/SQL + 客户端真服 + Admin |
|
||||
|
||||
推荐:轨道 A 阶段签收后再大规模 B;某模块 Mock 已稳可单模块直接 B。
|
||||
|
||||
## 模块 ID(P0 优先)
|
||||
|
||||
| ID | 名称 | 契约来源 |
|
||||
|----|------|----------|
|
||||
| M01 | 登录 / 鉴权 | 客户端 §1 + admin §1 |
|
||||
| M02 | 权限中间件 | `开发方案 §6` |
|
||||
| M03 | 行业 / 客户 / 工艺 / 产品类型 | admin 文档对应章节 |
|
||||
| M04 | 产品 + 媒体 | admin + 客户端产品相关 |
|
||||
| M05 | admin 用户与角色 | admin RBAC |
|
||||
| M06 | 客户端用户 | admin 客户端用户 + 客户端登录 |
|
||||
| M07 | 标题简介 | admin + 客户端 |
|
||||
| M08 | 客户端列表 / 详情 | 客户端文档 |
|
||||
| M09 | 浏览历史 / 缓存 | 客户端文档 |
|
||||
| M10 | 导入 / 导出 | admin + 客户端 |
|
||||
|
||||
## Agent 执行协议
|
||||
|
||||
1. 读 `docs/progress.md`「进度快照」。
|
||||
2. 「下一批」→ **原样复制**对应轨道「下一批提示词」;「自检」→ 复制「批次自检提示词」。
|
||||
3. 禁止改写四则提示词正文(用户明确要求改版除外)。
|
||||
4. 回复首段:`本批:…` 或 `自检:…`。
|
||||
5. 先 todos → 执行 → 批末门禁 → **只改 progress 快照**(rollout 勾选除外)。
|
||||
6. 冲突时:`docs/module-rollout.md` 优先,与快照「建议下一批」交叉核对。
|
||||
|
||||
## 轨道 A — 单批(7 步)
|
||||
|
||||
1. 从 `docs/api/客户端接口文档.md` 确认 path、method(禁止自造)。
|
||||
2. `modules/<域>/types.ts`
|
||||
3. `modules/<域>/api.ts` + `service.ts` + `mock.ts` + `index.ts`
|
||||
4. `mock/v1/<资源>/<action>.json`
|
||||
5. `views/<域>/<Page>.vue` + `composables/useXxx.ts`(仅 import service)
|
||||
6. `router/modules/<域>.ts` 挂 `DefaultLayout` children
|
||||
7. 批末:`npm run typecheck`;本批 path 已在 mock 覆盖
|
||||
|
||||
### 单页 DoD
|
||||
|
||||
- UI 可导航,主流程可点。
|
||||
- 域五件套:types、api、service、mock、view。
|
||||
- mock 响应 `{code,msg,data}` 与 FastAdmin 约定一致(成功 `code=1`)。
|
||||
- 页面无 import api/mock、无 axios 裸调。
|
||||
|
||||
### 阶段 A 签收(切入 B 前置)
|
||||
|
||||
| # | 条件 |
|
||||
|---|------|
|
||||
| 1 | 计划内客户端页契约全覆盖 |
|
||||
| 2 | 声明 path ⊆ mock/v1 或 service 覆盖 |
|
||||
| 3 | `npm run typecheck` PASS |
|
||||
| 4 | `npm run dev:web` 或 `dev:electron` 可启动 |
|
||||
| 5 | 主流程手测 5~7 条(登录 → 列表 → 详情) |
|
||||
|
||||
## 轨道 B — M-Batch(5 步)
|
||||
|
||||
| 步 | 内容 | DoD |
|
||||
|----|------|-----|
|
||||
| ① 契约 | mock + types + `docs/api` 字段对照 | mock JSON ≡ types ≡ 将实现响应 |
|
||||
| ② 后端 | `application/api`(客户端)+ `application/admin`(后台)薄 Controller + Service + 迁移/SQL | 本模块 path curl **0 fail**(Docker 8080) |
|
||||
| ③ Admin | FastAdmin 菜单/权限 + CRUD 页;**复用** Service | 无需求标 N/A,rollout Admin 列写 — |
|
||||
| ④ hybrid | `config/mock-routes.ts` REAL_ROUTES 登记 | 抽测 1~3 条主路径 |
|
||||
| ⑤ 签收 | rollout 勾选 + 更新快照 | 本行 客户端/后端/manifest/抽测已勾 |
|
||||
|
||||
### 模块 DoD
|
||||
|
||||
- 五步完成;本模块 curl 0 fail;mock 与真服形状一致(差异记入 rollout 备注)。
|
||||
|
||||
## Mock 三档
|
||||
|
||||
| VUE_APP_USE_MOCK | 行为 |
|
||||
|------------------|------|
|
||||
| true | 全 mock |
|
||||
| hybrid | REAL_ROUTES 命中 → 真 API;未命中 → mock |
|
||||
| false | 全真服 |
|
||||
|
||||
Mock / hybrid-mock **不发送**签名 Header。
|
||||
|
||||
## 批末命令(项目现状)
|
||||
|
||||
| 轨道 | 每批 | 里程碑 |
|
||||
|------|------|--------|
|
||||
| A | `npm run typecheck` | + `dev:electron` 冒烟 |
|
||||
| B | 本模块 curl 清单 0 fail | + hybrid 抽测 + `typecheck` |
|
||||
|
||||
后端抽测示例:`curl -X POST http://127.0.0.1:8080/api/...`(带 Bearer / Sign 按 `.env`)。
|
||||
|
||||
## 模块签收表(rollout)
|
||||
|
||||
| 列 | 含义 |
|
||||
|----|------|
|
||||
| 模块 ID | M01…M10 |
|
||||
| 名称 | 业务模块 |
|
||||
| API 数 | 本模块 path 数 |
|
||||
| 客户端 | Electron 页/service 已实现 |
|
||||
| 后端 | api/admin Controller 已实现 |
|
||||
| Admin | FastAdmin 后台(— 表示 N/A) |
|
||||
| manifest | REAL_ROUTES 已登记 |
|
||||
| 抽测 | hybrid 主路径通过 |
|
||||
| 签收日期 | |
|
||||
|
||||
首个未签收:客户端 / 后端 / manifest / 抽测 任一未勾。
|
||||
|
||||
## 自检 vs 下一批
|
||||
|
||||
| | 自检 | 下一批 |
|
||||
|---|------|--------|
|
||||
| 目的 | 验 DoD | 实现新一批 |
|
||||
| 首段 | `自检:…` | `本批:…` |
|
||||
| 写代码 | 否(补漏除外) | 是 |
|
||||
| 改 progress | 仅快照 | 快照 + rollout |
|
||||
|
||||
## 禁止
|
||||
|
||||
- 改 progress 内四则提示词(用户明确要求除外)
|
||||
- 自检重复实现整批
|
||||
- 无模块 ID / 无域边界空回归
|
||||
- 未登记 REAL_ROUTES 即宣称模块完成
|
||||
- api Controller 与 admin Controller 各写一套重复业务逻辑
|
||||
- 未读 `docs/api` 猜字段
|
||||
- 跨域同批大改
|
||||
- 页内 mock 数据或散落 HTTP
|
||||
- 擅自修改 API 文档「待复核」url
|
||||
|
||||
---
|
||||
|
||||
## progress.md 模板(`docs/progress.md`)
|
||||
|
||||
```markdown
|
||||
# progress.md
|
||||
|
||||
> Agent 只改「进度快照」;勿改下方四则提示词。
|
||||
|
||||
## 进度快照
|
||||
|
||||
| 项 | 填写 |
|
||||
|----|------|
|
||||
| 更新日期 | |
|
||||
| 当前轨道 | A / B |
|
||||
| 当前模块/域 | |
|
||||
| 建议下一批 | |
|
||||
| 本批已完成 | |
|
||||
|
||||
## 轨道A — 批次自检提示词
|
||||
(粘贴 SKILL 对应代码块全文)
|
||||
|
||||
## 轨道A — 下一批提示词
|
||||
(粘贴 SKILL 对应代码块全文)
|
||||
|
||||
## 轨道B — 批次自检提示词
|
||||
(粘贴 SKILL 对应代码块全文)
|
||||
|
||||
## 轨道B — 下一批提示词
|
||||
(粘贴 SKILL 对应代码块全文)
|
||||
```
|
||||
|
||||
## module-rollout.md 模板(`docs/module-rollout.md`)
|
||||
|
||||
```markdown
|
||||
# module-rollout.md
|
||||
|
||||
| ID | 名称 | API数 | 客户端 | 后端 | Admin | manifest | 抽测 | 签收日期 | 备注 |
|
||||
|----|------|-------|--------|------|-------|----------|------|----------|------|
|
||||
| M01 | 登录鉴权 | | | | | | | | |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四则提示词(固定正文,写入 progress.md)
|
||||
|
||||
### 轨道A — 下一批提示词
|
||||
|
||||
```text
|
||||
执行轨道A下一批(Electron 页+Mock)。先建 todos。须配合 electron-vue2-architecture。自判:读 progress「建议下一批」;无则从 docs/api/客户端接口文档.md 取同域未实现页,每批 1~3 页。首段「本批:轨道A <域> <页名>」。① docs/api 定 path ② modules/<域>/{types,api,service,mock,index} ③ mock/v1/*.json ④ views/<域>/*.vue + composable ⑤ router/modules/<域>.ts。批末 npm run typecheck。禁跨域同批、禁页内 axios/mock、禁自造 API。只改 progress 快照。
|
||||
```
|
||||
|
||||
### 轨道A — 批次自检提示词
|
||||
|
||||
```text
|
||||
轨道A自检本批。先建 todos。自判:本对话刚完成页优先,否则读快照「当前模块/域」。首段「自检:轨道A <域> <页名>」。核对:域五件套齐全;本批 path mock 已覆盖;页面仅 import service;主路径 mock 可点。批末 npm run typecheck。只改 progress 快照。
|
||||
```
|
||||
|
||||
### 轨道B — 下一批提示词
|
||||
|
||||
```text
|
||||
执行轨道B M-Batch。先建 todos。须配合 electron-vue2-architecture + backend-xenon。自判:读 progress + module-rollout + docs/api;按 M01→M10 取首个未签收(客户端/后端/manifest/抽测未全勾)。首段「本批:Mxx <名>」。① 契约:mock+types 对照 docs/api ② backend-xenon Controller+Service+SQL,本模块 curl 0 fail ③ FastAdmin Admin(N/A 写 —)④ config/mock-routes REAL_ROUTES + hybrid 抽测 1~3 条 ⑤ rollout 勾选+快照。禁无模块 ID、禁未 manifest 即完成、禁重复 Service 逻辑。只改 progress 快照。
|
||||
```
|
||||
|
||||
### 轨道B — 批次自检提示词
|
||||
|
||||
```text
|
||||
轨道B M-Batch 自检。先建 todos。自判:本对话模块优先,否则读快照+rollout 对应行+docs/api。首段「自检:Mxx <名>」。核对:mock/types 与 Service 响应一致;本模块 curl 0 fail;Admin 复用 Service 或 N/A;REAL_ROUTES 已登记;rollout 本行已勾。只改 progress 快照。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 初始化 Checklist
|
||||
|
||||
```text
|
||||
[ ] docs/progress.md(快照 + 四则提示词)
|
||||
[ ] docs/module-rollout.md(M01~M10 空表)
|
||||
[ ] 客户端工程按 electron-vue2-architecture 初始化
|
||||
[ ] backend-xenon/scripts/start.bat 可启动
|
||||
[ ] .env:VUE_APP_API_BASE=http://127.0.0.1:8080
|
||||
[ ] 首批:M01 或轨道 A 登录页,更新 progress 快照
|
||||
```
|
||||
|
||||
## 非琐碎开发勾选
|
||||
|
||||
- 轨道 A:`electron-vue2-architecture` + `fullstack-delivery-workflow`
|
||||
- 轨道 B:同上
|
||||
@@ -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