88c6ce8ccc
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构 - 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐 - 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作 - 新增管理后台、支付与云端文件 API,更新 README 与项目规范 Co-authored-by: Cursor <cursoragent@cursor.com>
634 lines
19 KiB
Markdown
634 lines
19 KiB
Markdown
---
|
||
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/`
|