Files
SoonDesign/.cursor/skills/electron-vue2-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

634 lines
19 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: electron-vue2-architecture
description: >-
生成 Electron + Vue2 + Element UI + Vuex 桌面端标准前端工程;HTTP/Sign/Token/分页/Mock
与 FastAdmin AppApi 对齐。用于新建项目、域模块、页面、mock 与 API 联调。
---
# Electron + Vue2FastAdmin 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. hybridREAL_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/`