--- 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` | | `application/extra/api.php` `skip_sign` | 无需签名(支付回调等) | 路径:`/api/{controller}/{action}`。成功 `code=1`。分页 `page` `limit` → `{ list, total, page, limit }`。 ```typescript // types/api.ts export interface ApiResult { code: number msg: string time: number data: T request_id?: string } export interface PaginateData { 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/<域>/.vue src/main.ts App.vue styles/{variables,element-overrides}.scss mock/v1/<资源>/.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}` const REAL_ROUTES = new Set([ // '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 { const out: Record = {} 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)) { if (v === undefined) continue out[k] = encode(v) } } return out } export function buildSignHeaders(_method: string, url: string, data?: unknown): Record { 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) => { 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(options: HttpOptions): Promise> { return (await http.request>(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(res: ApiResult): 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(path: string, params?: Record, skipAuth = false) { return httpRequest({ url: path, method: 'GET', params, skipAuth }) } export function apiPost(path: string, data?: unknown, skipAuth = false) { return httpRequest({ 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('/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//' const method = 'GET' if (USE_MOCK && !shouldUseRealApi(method, path)) return mockXxx(input) return unwrapApi(await apiGet(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 | null role: 'admin' | 'user' | 'sync' | '' } export const user: Module = { 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; 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({ 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(loader: () => Promise, isEmpty: (d: T) => boolean) { const status: Ref = ref('loading') const data: Ref = 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 clearCache: () => Promise openExternal: (url: string) => Promise } 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 => ipcRenderer.invoke('token:set', v), clearCache: (): Promise => ipcRenderer.invoke('cache:clear'), openExternal: (url: string): Promise => 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 (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/`