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

19 KiB
Raw Blame History

name, description
name description
electron-vue2-architecture 生成 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 }

// 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))

HeaderX-App-Id X-Timestamp X-Nonce X-Sign。空字符串参与拼接;object/arrayJSON.stringify

原则

  • views → composable → service.tsmock.ts | api.tshelperscore
  • store actions → service.tsmutation 不发 HTTP。
  • 契约不明则停;单迭代单域。
  • VUE_APP_USE_MOCKtrue | hybrid | falseproduction 必 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

目录

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

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

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

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

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

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

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。首域 authlogin/logout)。

// 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

import type { UserState } from './modules/user'
export interface RootState { user: UserState }

store/modules/user.ts

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

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.tsROUTE_NAMES + constantRouteslogin/404/public)。guards.ts:未登录 → loginmeta.rolesuser/hasRoleindex.tshash + DefaultLayout children。

composables/usePageState.ts

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

// 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

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

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

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

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

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

// 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

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

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

{
  "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"
  }
}

初始化顺序

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

[ ] 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: truecontextIsolation: false
  • header.Token(用 Authorization: Bearer
  • production mock 或 secret 入库
  • 页面 axios 裸调
  • mutation 内发 HTTP

例外

  • window.electronAPI IPC 白名单
  • electron-builder 目录 build/