├── app ├── core │ ├── tools │ │ ├── settings │ │ │ ├── app-settings.ts │ │ │ ├── index.ts │ │ │ ├── window-bounds.ts │ │ │ └── create-settings.ts │ │ ├── log │ │ │ ├── index.ts │ │ │ ├── log.ts │ │ │ └── system-logger.ts │ │ ├── window │ │ │ ├── index.ts │ │ │ └── create-window.ts │ │ ├── index.ts │ │ ├── native-require.ts │ │ ├── paths.ts │ │ ├── consts.ts │ │ └── utils.ts │ ├── api │ │ ├── handlers │ │ │ ├── index.ts │ │ │ └── test.api.ts │ │ ├── index.ts │ │ ├── handle-response.ts │ │ └── request.ts │ ├── store │ │ ├── index.ts │ │ ├── store.ts │ │ ├── actions │ │ │ └── demo.action.ts │ │ ├── reducers │ │ │ └── auto-reducer.ts │ │ └── with-store.ts │ ├── renderer.init.ts │ └── main.init.ts ├── src │ ├── views │ │ ├── about │ │ │ ├── about.less │ │ │ ├── auto.routes.ts │ │ │ └── about.tsx │ │ ├── common │ │ │ ├── no-match │ │ │ │ ├── no-match.less │ │ │ │ ├── index.ts │ │ │ │ └── no-match.tsx │ │ │ ├── alert-modal │ │ │ │ ├── index.tsx │ │ │ │ ├── alert-modal.less │ │ │ │ └── alert-modal.tsx │ │ │ └── auto.routes.ts │ │ ├── log-viewer │ │ │ ├── auto.routes.ts │ │ │ ├── log-viewer.less │ │ │ ├── log-reader.tsx │ │ │ └── log-viewer.tsx │ │ ├── demo │ │ │ ├── auto.routes.ts │ │ │ ├── page-params.tsx │ │ │ └── demo.tsx │ │ └── home │ │ │ └── auto.routes.ts │ ├── context │ │ ├── index.ts │ │ └── router.ctx.ts │ ├── components │ │ ├── loader │ │ │ ├── index.ts │ │ │ ├── loader.less │ │ │ └── loader.tsx │ │ ├── app-layout │ │ │ ├── index.tsx │ │ │ ├── app-layout.less │ │ │ └── app-layout.tsx │ │ ├── app-router │ │ │ ├── index.ts │ │ │ ├── router-hooks.ts │ │ │ └── app-router.tsx │ │ ├── app-sidebar │ │ │ ├── index.tsx │ │ │ ├── app-sidebar.less │ │ │ ├── side-menus.json │ │ │ └── app-sidebar.tsx │ │ ├── app-titlebar │ │ │ ├── index.tsx │ │ │ ├── app-titlebar.less │ │ │ └── app-titlebar.tsx │ │ ├── async-import │ │ │ ├── index.ts │ │ │ └── async-import.tsx │ │ └── index.ts │ ├── styles │ │ ├── theme.less │ │ ├── index.less │ │ ├── antd-theme.less │ │ ├── _var.less │ │ └── common.less │ ├── index.html │ ├── page-resource.ts │ ├── index.tsx │ ├── app.tsx │ └── auto-routes.ts └── electron │ ├── tray │ ├── index.ts │ └── app-tray.ts │ ├── menus │ ├── index.ts │ └── tray-menus.ts │ ├── index.ts │ └── main.ts ├── .eslintignore ├── assets ├── app-icon │ ├── icon │ │ ├── icon.icns │ │ ├── icon.ico │ │ └── icon@128.ico │ ├── app-icon@128.png │ └── app-icon@256.png └── tray-icon │ ├── tray-icon-dark.png │ ├── tray-icon-light.png │ ├── tray-icon-dark@2x.png │ ├── tray-icon-dark@3x.png │ ├── tray-icon-light@2x.png │ └── tray-icon-light@3x.png ├── types ├── react.d.ts ├── core │ ├── api.d.ts │ ├── tools.d.ts │ └── store.d.ts ├── api-interface │ └── getUserPermissionsUsingGET.d.ts ├── custom-event.d.ts ├── global.d.ts └── router.d.ts ├── .vscode ├── extensions.json ├── settings.json └── create-item.template.js ├── .prettierrc ├── .gitignore ├── tsconfig.json ├── LICENSE ├── README.md ├── .eslintrc.js └── package.json /app/core/tools/settings/app-settings.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/core/tools/log/index.ts: -------------------------------------------------------------------------------- 1 | export * from './log' 2 | -------------------------------------------------------------------------------- /app/src/views/about/about.less: -------------------------------------------------------------------------------- 1 | .about { 2 | } 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /.git 2 | /.vscode 3 | node_modules 4 | dist -------------------------------------------------------------------------------- /app/electron/tray/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app-tray' 2 | -------------------------------------------------------------------------------- /app/src/context/index.ts: -------------------------------------------------------------------------------- 1 | export * from './router.ctx' 2 | -------------------------------------------------------------------------------- /app/core/api/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './test.api' 2 | -------------------------------------------------------------------------------- /app/electron/menus/index.ts: -------------------------------------------------------------------------------- 1 | export * from './tray-menus' 2 | -------------------------------------------------------------------------------- /app/src/components/loader/index.ts: -------------------------------------------------------------------------------- 1 | export * from './loader' 2 | -------------------------------------------------------------------------------- /app/core/tools/window/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-window' 2 | -------------------------------------------------------------------------------- /app/src/components/app-layout/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './app-layout' 2 | -------------------------------------------------------------------------------- /app/src/components/app-router/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app-router' 2 | -------------------------------------------------------------------------------- /app/src/components/app-sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './app-sidebar' 2 | -------------------------------------------------------------------------------- /app/src/components/app-titlebar/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './app-titlebar' 2 | -------------------------------------------------------------------------------- /app/src/components/async-import/index.ts: -------------------------------------------------------------------------------- 1 | export * from './async-import' 2 | -------------------------------------------------------------------------------- /app/core/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './request' 2 | export * from './handlers' 3 | -------------------------------------------------------------------------------- /app/core/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './store' 2 | export * from './with-store' 3 | -------------------------------------------------------------------------------- /app/src/styles/theme.less: -------------------------------------------------------------------------------- 1 | @import './_var.less'; 2 | @import './antd-theme.less'; 3 | -------------------------------------------------------------------------------- /app/src/views/common/no-match/no-match.less: -------------------------------------------------------------------------------- 1 | .no-match { 2 | height: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /app/src/views/common/no-match/index.ts: -------------------------------------------------------------------------------- 1 | import NoMatch from './no-match' 2 | export default NoMatch 3 | -------------------------------------------------------------------------------- /app/src/views/common/alert-modal/index.tsx: -------------------------------------------------------------------------------- 1 | import AlertModal from './alert-modal' 2 | export default AlertModal 3 | -------------------------------------------------------------------------------- /assets/app-icon/icon/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanten/electron-antd/HEAD/assets/app-icon/icon/icon.icns -------------------------------------------------------------------------------- /assets/app-icon/icon/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanten/electron-antd/HEAD/assets/app-icon/icon/icon.ico -------------------------------------------------------------------------------- /assets/app-icon/app-icon@128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanten/electron-antd/HEAD/assets/app-icon/app-icon@128.png -------------------------------------------------------------------------------- /assets/app-icon/app-icon@256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanten/electron-antd/HEAD/assets/app-icon/app-icon@256.png -------------------------------------------------------------------------------- /assets/app-icon/icon/icon@128.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanten/electron-antd/HEAD/assets/app-icon/icon/icon@128.ico -------------------------------------------------------------------------------- /app/core/tools/log/log.ts: -------------------------------------------------------------------------------- 1 | import { SystemLogger } from './system-logger' 2 | export const log = new SystemLogger('main') 3 | -------------------------------------------------------------------------------- /assets/tray-icon/tray-icon-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanten/electron-antd/HEAD/assets/tray-icon/tray-icon-dark.png -------------------------------------------------------------------------------- /assets/tray-icon/tray-icon-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanten/electron-antd/HEAD/assets/tray-icon/tray-icon-light.png -------------------------------------------------------------------------------- /assets/tray-icon/tray-icon-dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanten/electron-antd/HEAD/assets/tray-icon/tray-icon-dark@2x.png -------------------------------------------------------------------------------- /assets/tray-icon/tray-icon-dark@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanten/electron-antd/HEAD/assets/tray-icon/tray-icon-dark@3x.png -------------------------------------------------------------------------------- /app/src/components/loader/loader.less: -------------------------------------------------------------------------------- 1 | @import '~@/src/styles/_var.less'; 2 | 3 | .app-svg-loader { 4 | fill: @color_primary; 5 | } 6 | -------------------------------------------------------------------------------- /assets/tray-icon/tray-icon-light@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanten/electron-antd/HEAD/assets/tray-icon/tray-icon-light@2x.png -------------------------------------------------------------------------------- /assets/tray-icon/tray-icon-light@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanten/electron-antd/HEAD/assets/tray-icon/tray-icon-light@3x.png -------------------------------------------------------------------------------- /app/src/context/router.ctx.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const RouteContext = React.createContext>({}) 4 | -------------------------------------------------------------------------------- /types/react.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace JSX { 2 | // 自定义 jsx 标签 (全局组件) 3 | interface IntrinsicElements { 4 | // Navbar: any 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /app/core/tools/settings/index.ts: -------------------------------------------------------------------------------- 1 | import { windowBounds } from './window-bounds' 2 | 3 | export * from './create-settings' 4 | 5 | export const settings = { 6 | windowBounds, 7 | } 8 | -------------------------------------------------------------------------------- /app/electron/index.ts: -------------------------------------------------------------------------------- 1 | import { initMain } from '@/core/main.init' 2 | 3 | async function startApp() { 4 | await initMain() 5 | await import('./main') 6 | } 7 | 8 | startApp() 9 | -------------------------------------------------------------------------------- /app/src/styles/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd/dist/antd.less'; 2 | @import '~remixicon/fonts/remixicon.css'; // 图标库 https://remixicon.com/ 3 | @import './common.less'; 4 | @import './theme.less'; 5 | -------------------------------------------------------------------------------- /app/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './async-import' 2 | export * from './app-router' 3 | export * from './app-titlebar' 4 | export * from './app-sidebar' 5 | export * from './app-layout' 6 | export * from './loader' 7 | -------------------------------------------------------------------------------- /app/core/tools/index.ts: -------------------------------------------------------------------------------- 1 | export * from './consts' 2 | export * from './log' 3 | export * from './paths' 4 | export * from './utils' 5 | export * from './window' 6 | export * from './native-require' 7 | export * from './settings' 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "esbenp.prettier-vscode", 6 | "dbaeumer.vscode-eslint" 7 | ] 8 | } -------------------------------------------------------------------------------- /types/core/api.d.ts: -------------------------------------------------------------------------------- 1 | import * as api from '@/core/api' 2 | declare global { 3 | /** 4 | * 各种网络请求 5 | * 6 | * @source app/core/api 7 | * @define build/webpack.config.base.ts#L37 8 | */ 9 | const $api: typeof api 10 | } 11 | -------------------------------------------------------------------------------- /types/core/tools.d.ts: -------------------------------------------------------------------------------- 1 | import * as tools from '@/core/tools' 2 | 3 | declare global { 4 | type Tools = typeof tools 5 | 6 | /** 7 | * @source app/core/tools 8 | * @define build/webpack.config.base.ts#L38 9 | */ 10 | const $tools: Tools 11 | } 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 112, 4 | "proseWrap": "never", 5 | "eslintIntegration": true, 6 | "singleQuote": true, 7 | "semi": false, 8 | "trailingComma": "es5", 9 | "arrowParens": "always", 10 | "endOfLine": "auto" 11 | } 12 | -------------------------------------------------------------------------------- /types/api-interface/getUserPermissionsUsingGET.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace queryTestInfoUsingGET { 2 | interface Params {} 3 | 4 | interface Response { 5 | code: number 6 | status: boolean 7 | data: { 8 | info: string 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/core/renderer.init.ts: -------------------------------------------------------------------------------- 1 | import { getGlobal } from '@electron/remote' 2 | 3 | export function initRenderer(): void { 4 | // @ts-ignore 5 | global.__$tools = getGlobal('__$tools') 6 | // @ts-ignore 7 | global.__$api = getGlobal('__$api') 8 | // @ts-ignore 9 | global.__$store = getGlobal('__$store') 10 | } 11 | -------------------------------------------------------------------------------- /app/src/views/log-viewer/auto.routes.ts: -------------------------------------------------------------------------------- 1 | const routes: RouteConfig[] = [ 2 | { 3 | name: 'LogViewer', 4 | path: '/log-viewer', 5 | windowOptions: { 6 | title: 'LogViewer', 7 | }, 8 | createConfig: { 9 | saveWindowBounds: true, 10 | }, 11 | }, 12 | ] 13 | 14 | export default routes 15 | -------------------------------------------------------------------------------- /app/src/views/demo/auto.routes.ts: -------------------------------------------------------------------------------- 1 | const routes: RouteConfig[] = [ 2 | { 3 | name: 'Demo', 4 | path: '/demo', 5 | createConfig: { 6 | single: false, 7 | showCustomTitlebar: true, 8 | }, 9 | }, 10 | { 11 | name: 'PageParams', 12 | path: '/page-params/:test', 13 | }, 14 | ] 15 | 16 | export default routes 17 | -------------------------------------------------------------------------------- /app/src/components/app-router/router-hooks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 路由钩子,页面切切换时触发 3 | * @param props 4 | * @param next 继续渲染 5 | * @this AppRouter 6 | */ 7 | export function beforeRouter(props: PageProps, next: () => void): boolean | void | Promise { 8 | window.dispatchEvent(new CustomEvent('router-update', { detail: props })) 9 | next() 10 | } 11 | -------------------------------------------------------------------------------- /app/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /app/core/tools/settings/window-bounds.ts: -------------------------------------------------------------------------------- 1 | import { Rectangle } from 'electron' 2 | import { CreateSettings } from './create-settings' 3 | 4 | type T = { 5 | [K in RouteName]?: { 6 | /** 窗口坐标 */ 7 | rect: Rectangle 8 | /** 是否最大化 */ 9 | maximized?: boolean 10 | } 11 | } 12 | 13 | /** 窗口位置尺寸 */ 14 | export const windowBounds = new CreateSettings('window-bounds') 15 | -------------------------------------------------------------------------------- /app/core/store/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux' 2 | import thunk from 'redux-thunk' 3 | import { reducer, initialState } from './reducers/auto-reducer' 4 | 5 | export const store = createStore, unknown, unknown>( 6 | reducer, 7 | initialState, 8 | applyMiddleware(thunk) 9 | ) 10 | 11 | declare global { 12 | type AppStore = typeof store 13 | } 14 | -------------------------------------------------------------------------------- /app/core/tools/native-require.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 跳过 webpack 使用原生 require 3 | */ 4 | export let nativeRequire: (packageName: string) => any 5 | 6 | try { 7 | nativeRequire = 8 | (global as any).__non_webpack_require__ === 'function' 9 | ? (global as any).__non_webpack_require__ 10 | : eval('require') 11 | } catch { 12 | nativeRequire = () => { 13 | throw new Error('Require Error!') 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/core/main.init.ts: -------------------------------------------------------------------------------- 1 | import * as tools from './tools' 2 | import { store } from './store' 3 | import * as api from './api' 4 | 5 | export async function initMain(): Promise { 6 | return new Promise(async (resolve) => { 7 | // @ts-ignore 8 | global.__$tools = tools 9 | // @ts-ignore 10 | global.__$api = api 11 | // @ts-ignore 12 | global.__$store = store 13 | 14 | resolve() 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /app/src/views/about/auto.routes.ts: -------------------------------------------------------------------------------- 1 | const routes: RouteConfig[] = [ 2 | { 3 | name: 'About', 4 | path: '/about', 5 | windowOptions: { 6 | title: 'About', 7 | resizable: false, 8 | minimizable: false, 9 | maximizable: false, 10 | fullscreenable: false, 11 | width: 300, 12 | height: 240, 13 | }, 14 | createConfig: { 15 | hideMenus: true, 16 | }, 17 | }, 18 | ] 19 | 20 | export default routes 21 | -------------------------------------------------------------------------------- /app/src/views/common/alert-modal/alert-modal.less: -------------------------------------------------------------------------------- 1 | @import '~@/src/styles/_var.less'; 2 | .alert-modal { 3 | height: 100%; 4 | padding: 0; 5 | .footer { 6 | padding: 8px 16px; 7 | background-color: fade(#000, 4); 8 | } 9 | 10 | .message-box { 11 | // background-color: @color_white; 12 | background-color: fade(#000, 4); 13 | margin-top: 8px; 14 | border-radius: @radius; 15 | padding: 8px; 16 | overflow-y: auto; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/views/common/auto.routes.ts: -------------------------------------------------------------------------------- 1 | const routes: RouteConfig[] = [ 2 | { 3 | name: 'NoMatch', 4 | path: '*', 5 | windowOptions: { 6 | title: 'Page Error', 7 | }, 8 | }, 9 | { 10 | name: 'AlertModal', 11 | path: '/alert-modal', 12 | windowOptions: { 13 | title: 'Alert', 14 | width: 460, 15 | height: 240, 16 | resizable: false, 17 | }, 18 | createConfig: { 19 | hideMenus: true, 20 | }, 21 | }, 22 | ] 23 | 24 | export default routes 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .history 3 | node_modules 4 | dist 5 | release 6 | build/.out 7 | 8 | # local env files 9 | .env.local 10 | .env.*.local 11 | 12 | # Log files 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # Lock files 18 | #package-lock.json 19 | #yarn.lock 20 | 21 | # Editor directories and files 22 | .idea 23 | #.vscode 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | *-bak.* 30 | 31 | .tmp 32 | .settings 33 | .project 34 | *.log 35 | *.dat 36 | -------------------------------------------------------------------------------- /app/src/page-resource.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 页面资源集合,请不要在主进程中引用 3 | */ 4 | 5 | // common 6 | export const NoMatch = import('./views/common/no-match') 7 | export const AlertModal = import('./views/common/alert-modal') 8 | 9 | // 设为 undefined 将不会创建路由,一般用于重定向 10 | export const Home = undefined 11 | 12 | export const Demo = import('./views/demo/demo') 13 | export const PageParams = import('./views/demo/page-params') 14 | export const LogViewer = import('./views/log-viewer/log-viewer') 15 | export const About = import('./views/about/about') 16 | -------------------------------------------------------------------------------- /app/src/views/home/auto.routes.ts: -------------------------------------------------------------------------------- 1 | const routes: RouteConfig[] = [ 2 | { 3 | name: 'Home', 4 | path: '/', 5 | redirectTo: '/demo?form=home', 6 | windowOptions: { 7 | title: 'App Home (redirect to demo)', 8 | width: 1200, 9 | height: 800, 10 | minWidth: 800, 11 | minHeight: 600, 12 | }, 13 | createConfig: { 14 | showSidebar: true, 15 | showCustomTitlebar: true, 16 | saveWindowBounds: true, 17 | openDevTools: true, 18 | }, 19 | }, 20 | ] 21 | 22 | export default routes 23 | -------------------------------------------------------------------------------- /app/core/store/actions/demo.action.ts: -------------------------------------------------------------------------------- 1 | export const initialState = { 2 | count: 1, 3 | } 4 | 5 | export function ACTION_ADD_COUNT( 6 | data: StoreDatas['ACTION_ADD_COUNT'], 7 | state: StoreStates, 8 | action: StoreAction<'ACTION_ADD_COUNT'> 9 | ): { count: StoreStates['count'] } { 10 | console.log({ data, state, action }) 11 | return { count: data } 12 | } 13 | 14 | declare global { 15 | interface StoreStates { 16 | count: number 17 | } 18 | 19 | interface StoreDatas { 20 | ACTION_ADD_COUNT: StoreStates['count'] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/views/demo/page-params.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const PageParams: React.FC = (props) => { 4 | const pageParams = JSON.stringify(props.params) 5 | const pageQuery = JSON.stringify(props.query) 6 | 7 | console.log('PageParams', props.params) 8 | console.log('PageQuery', props.query) 9 | 10 | return ( 11 |
12 |

Params: {pageParams}

13 |

Query: {pageQuery}

14 |
15 | ) 16 | } // class PageParams end 17 | 18 | export default PageParams 19 | -------------------------------------------------------------------------------- /types/custom-event.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEventMap { 2 | /** [Window] 自定义事件:路由刷新 */ 3 | 'router-update': CustomEvent 4 | } 5 | 6 | interface WindowEventMap extends CustomEventMap {} 7 | 8 | declare namespace Electron { 9 | interface WebContents { 10 | /** [BrowserWindow] 自定义事件: DOM 准备就绪 */ 11 | send(channel: 'dom-ready', createConfig: CreateConfig): void 12 | } 13 | 14 | interface IpcRenderer { 15 | /** [BrowserWindow] 自定义事件: DOM 准备就绪 */ 16 | on(channel: 'dom-ready', callback: (event: Electron.IpcRendererEvent, data: CreateConfig) => void): void 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/components/loader/loader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import './loader.less' 4 | 5 | export const Loader: React.FC = () => { 6 | return ( 7 | 8 | 9 | 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /app/src/components/app-sidebar/app-sidebar.less: -------------------------------------------------------------------------------- 1 | @import '~@/src/styles/_var.less'; 2 | 3 | .app-sidebar { 4 | width: @sidebar_width; 5 | height: 100vh; 6 | background-color: @sidebar_background; 7 | color: fade(@color_light, 75); 8 | user-select: none; 9 | 10 | .app-sidebar-header { 11 | margin-top: 36px; 12 | } 13 | 14 | .side-menu { 15 | margin-top: @padding; 16 | .side-menu-item { 17 | width: 100%; 18 | text-align: center; 19 | padding: @padding; 20 | color: fade(@color_light, 65); 21 | &:hover, 22 | &.active { 23 | color: @color_white; 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import reactDom from 'react-dom' 3 | import { ipcRenderer } from 'electron' 4 | 5 | import { initRenderer } from '@/core/renderer.init' 6 | import App from './app' 7 | import '@/src/styles/index.less' 8 | 9 | initRenderer() 10 | 11 | let createConfig: CreateConfig 12 | 13 | function renderApp() { 14 | reactDom.render(, document.getElementById('app')) 15 | } 16 | 17 | ipcRenderer.on('dom-ready', (event, data) => { 18 | createConfig = data 19 | renderApp() 20 | }) 21 | 22 | // 组件热更新 23 | // if (module.hot) { 24 | // module.hot.accept('./app', renderApp) 25 | // } 26 | -------------------------------------------------------------------------------- /app/src/components/app-layout/app-layout.less: -------------------------------------------------------------------------------- 1 | @import '~@/src/styles/_var.less'; 2 | .app-layout { 3 | overflow: hidden; 4 | 5 | &.darwin { 6 | // mac 专用样式 7 | } 8 | 9 | .app-content-wrap { 10 | height: 100vh; 11 | overflow: hidden; 12 | } 13 | 14 | &.has-titlebar { 15 | .app-content { 16 | height: calc(~'100vh - @{titlebar_height}'); 17 | } 18 | } 19 | 20 | .app-content { 21 | box-sizing: border-box; 22 | overflow: auto; 23 | background-color: @color_background; 24 | position: relative; 25 | height: 100vh; 26 | 27 | .layout-padding { 28 | padding: @padding_layout; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/views/common/no-match/no-match.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Button, Result } from 'antd' 3 | 4 | import './no-match.less' 5 | 6 | const ErrorPage: React.FC = () => { 7 | return ( 8 |
9 | Sorry, the page you visited does not exist.

} 13 | extra={ 14 | 17 | } 18 | /> 19 |
20 | ) 21 | } 22 | 23 | export default ErrorPage 24 | -------------------------------------------------------------------------------- /app/src/components/app-sidebar/side-menus.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "key": "Demo", 4 | "href": "#/", 5 | "title": "Home", 6 | "icon": "home-3" 7 | }, 8 | { 9 | "key": "LogViewer", 10 | "href": "#/log-viewer", 11 | "title": "Log Viewer", 12 | "icon": "terminal-box" 13 | }, 14 | { 15 | "key": "PageParams", 16 | "href": "#/page-params/ok?obj={\"ss\":[\"1\",2,3]}", 17 | "title": "Page Params", 18 | "icon": "flask" 19 | }, 20 | { 21 | "key": "any", 22 | "href": "#/any", 23 | "title": "No Match", 24 | "icon": "forbid" 25 | }, 26 | { 27 | "key": "About", 28 | "href": "#/about", 29 | "title": "About", 30 | "icon": "information" 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /app/core/api/handlers/test.api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 详细接口类型定义在: @/typescript/api-interface/* 3 | */ 4 | 5 | /** 6 | * 测试接口 7 | * @param params 8 | * @param options 9 | */ 10 | export function queryTestInfo( 11 | params?: queryTestInfoUsingGET.Params, 12 | options?: RequestOptions 13 | ): Promise { 14 | return $api.request('/api-test/demo-test', params, options) 15 | } 16 | 17 | /** 18 | * 测试接口-返回错误 19 | * @param params 20 | * @param options 21 | */ 22 | export function queryTestInfoError( 23 | params?: queryTestInfoUsingGET.Params, 24 | options?: RequestOptions 25 | ): Promise { 26 | return $api.request('/api-test/demo-test-error', params, options) 27 | } 28 | -------------------------------------------------------------------------------- /app/src/app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ConfigProvider, Spin } from 'antd' 3 | import zhCN from 'antd/es/locale/zh_CN' 4 | 5 | import { AppRouter, AppLayout, Loader } from '@/src/components' 6 | 7 | import routes from './auto-routes' 8 | 9 | interface AppProps { 10 | createConfig: CreateConfig 11 | } 12 | 13 | Spin.setDefaultIndicator() 14 | 15 | export default class App extends React.Component { 16 | render(): JSX.Element { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /types/global.d.ts: -------------------------------------------------------------------------------- 1 | import type { CommonEnvVariables, EnvVariables } from '../build/config/env.config' 2 | 3 | declare global { 4 | const nodeRequire: NodeRequire 5 | 6 | type ReactDivProps = React.DetailedHTMLProps, HTMLDivElement> 7 | 8 | /** 获取 Promise 返回值 */ 9 | type PromiseReturnType = T extends Promise ? U : never 10 | 11 | /** 获取 Promise 返回值 (递归) */ 12 | type PromiseReturnTypeDeep = T extends Promise 13 | ? U extends Promise 14 | ? PromiseReturnType 15 | : U 16 | : never 17 | 18 | /** 使用此类型替代 any object */ 19 | interface AnyObj { 20 | [key: string]: any 21 | } 22 | 23 | namespace NodeJS { 24 | /** 环境变量 */ 25 | interface ProcessEnv extends CommonEnvVariables, EnvVariables {} 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "react", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "experimentalDecorators": true, 11 | "useUnknownInCatchVariables": false, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "suppressImplicitAnyIndexErrors": true, 15 | "sourceMap": false, 16 | "locale": "zh-CN", 17 | "baseUrl": ".", 18 | "types": ["webpack-env", "node"], 19 | "paths": { 20 | "@/*": ["app/*"], 21 | "@root/*": ["*"] 22 | }, 23 | "lib": ["esnext", "dom", "scripthost"] 24 | }, 25 | "include": ["types", "app"], 26 | "exclude": ["node_modules", "build", "dist", "release", "assets"] 27 | } 28 | -------------------------------------------------------------------------------- /app/src/views/log-viewer/log-viewer.less: -------------------------------------------------------------------------------- 1 | @import '~@/src/styles/_var.less'; 2 | .log-viewer { 3 | height: 100%; 4 | background-color: fade(@color_dark, 75); 5 | .log-list-container { 6 | height: inherit; 7 | } 8 | .log-list { 9 | overflow-y: auto; 10 | background-color: fade(@color_white, 5); 11 | user-select: none; 12 | li { 13 | color: fade(@color_white, 75); 14 | padding: 2px 8px; 15 | cursor: pointer; 16 | transition: background-color 0.2s; 17 | &:hover { 18 | background-color: fade(@color_white, 15); 19 | } 20 | &.active { 21 | background-color: @color_primary; 22 | } 23 | } 24 | } 25 | 26 | .log-detail { 27 | padding: @padding / 2; 28 | color: fade(@color_white, 75); 29 | overflow-x: hidden; 30 | overflow-y: auto; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/views/about/about.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shell } from 'electron' 3 | 4 | import './about.less' 5 | 6 | const About: React.FC = () => { 7 | return ( 8 |
9 | 10 |

{$tools.APP_TITLE}

11 |

12 | {$tools.APP_NAME} Version {$tools.APP_VERSION} 13 |

14 |

15 | Copyright © {new Date().getFullYear()}{' '} 16 | { 18 | shell.openExternal('https://github.com/lanten') 19 | }} 20 | > 21 | lanten. 22 | {' '} 23 | All rights (demo) 24 |

25 |
26 | ) 27 | } 28 | 29 | export default About 30 | -------------------------------------------------------------------------------- /app/core/store/reducers/auto-reducer.ts: -------------------------------------------------------------------------------- 1 | const actions = require.context('../actions', true, /^((?!\.d\.ts).)*(\.ts)$/) 2 | const actionsH: { [key: string]: ActionFn } = {} 3 | 4 | export const initialState: any = {} 5 | 6 | actions.keys().forEach((item) => { 7 | const actionItem = Object.assign({}, actions(item)) 8 | 9 | if (actionItem.initialState) { 10 | Object.assign(initialState, actionItem.initialState) 11 | } 12 | 13 | delete actionItem.initialState 14 | 15 | for (const key in actionItem) { 16 | actionsH[key] = actionItem[key] 17 | } 18 | }) 19 | 20 | export function reducer( 21 | state: StoreStates, 22 | action: StoreAction 23 | ): StoreStates & AnyObj { 24 | const actionFn: ActionFn = actionsH[action.type] 25 | const resState = (actionFn && actionFn(action.data, state, action)) || {} 26 | 27 | return Object.assign({}, state, resState) 28 | } 29 | -------------------------------------------------------------------------------- /app/electron/tray/app-tray.ts: -------------------------------------------------------------------------------- 1 | import { Menu, Tray, nativeTheme, nativeImage } from 'electron' 2 | 3 | import { trayMenus } from '../menus' 4 | 5 | const { APP_NAME, TRAY_ICON_DARK, TRAY_ICON_LIGHT } = $tools 6 | 7 | export interface AppIconConfig { 8 | menus?: any 9 | title?: string 10 | icon?: string 11 | } 12 | 13 | export function creatAppTray({ menus = trayMenus, title = APP_NAME, icon }: AppIconConfig = {}): Tray { 14 | const iconPath = 15 | icon ?? 16 | (process.platform === 'darwin' 17 | ? nativeTheme.shouldUseDarkColors 18 | ? TRAY_ICON_LIGHT 19 | : TRAY_ICON_DARK 20 | : TRAY_ICON_LIGHT) 21 | 22 | const image = nativeImage.createFromPath(iconPath) 23 | image.isMacTemplateImage = true 24 | const tray = new Tray(image) 25 | tray.setToolTip(title) 26 | tray.setContextMenu(Menu.buildFromTemplate(menus)) 27 | 28 | tray.on('double-click', () => { 29 | $tools.createWindow('Home') 30 | }) 31 | 32 | return tray 33 | } 34 | -------------------------------------------------------------------------------- /app/src/components/app-titlebar/app-titlebar.less: -------------------------------------------------------------------------------- 1 | @import '~@/src/styles/_var.less'; 2 | .app-titlebar { 3 | user-select: none; 4 | background-color: @titlebar_background; 5 | height: @titlebar_height; 6 | line-height: @titlebar_height; 7 | padding-left: @padding_layout; 8 | border-bottom: 1px solid @color_border; 9 | 10 | .titlebar-controller { 11 | .titlebar-btn { 12 | display: flex; 13 | justify-content: center; 14 | align-items: center; 15 | padding: 0 12px; 16 | transition: all 0.2s; 17 | color: @color_gray; 18 | &:hover { 19 | background-color: fade(@color_black, 8); 20 | } 21 | 22 | &.titlebar-btn-close { 23 | &:hover { 24 | background-color: @color_error; 25 | color: @color_white; 26 | } 27 | } 28 | } 29 | } 30 | } 31 | 32 | // Mac 下无侧边栏时标题居中对齐 33 | .darwin { 34 | .app-titlebar { 35 | .title-content { 36 | text-align: center; 37 | justify-content: center; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /types/core/store.d.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction } from 'redux' 2 | 3 | declare global { 4 | type StoreDatasKeys = keyof StoreDatas 5 | type StoreStateKeys = keyof StoreStates 6 | 7 | type ActionFn = ( 8 | data: StoreAction['data'], 9 | state: StoreStates, 10 | action: StoreAction 11 | ) => { [key: string]: any } 12 | 13 | interface AliasStates { 14 | [key: string]: StoreStateKeys 15 | } 16 | 17 | interface StoreAction extends AnyAction { 18 | type: K 19 | data: StoreDatas[K] 20 | } 21 | 22 | type AsyncDispatch = (dispatch: StoreProps['dispatch']) => Promise 23 | 24 | type Dispatch = (options: StoreAction | AsyncDispatch) => Promise | void 25 | interface StoreProps { 26 | readonly dispatch: Dispatch 27 | } 28 | 29 | /** 30 | * Redux Store 31 | * 32 | * @source app/core/store 33 | * @define build/webpack.config.base.ts#L39 34 | */ 35 | const $store: AppStore 36 | } 37 | -------------------------------------------------------------------------------- /app/src/components/app-layout/app-layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import clsx from 'clsx' 3 | 4 | import { AppTitlebar, AppSidebar } from '../' 5 | 6 | import './app-layout.less' 7 | 8 | interface AppLayoutProps { 9 | createConfig: CreateConfig 10 | children: JSX.Element 11 | } 12 | 13 | export class AppLayout extends React.Component { 14 | render(): JSX.Element { 15 | const { createConfig } = this.props 16 | return ( 17 |
24 | {createConfig.showSidebar ? : null} 25 |
26 | {createConfig.showCustomTitlebar ? : null} 27 |
{this.props.children}
28 |
29 |
30 | ) 31 | } 32 | } // class AppLayout end 33 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "eslint.validate": [ 4 | "javascript", 5 | "javascriptreact", 6 | "typescript", 7 | "typescriptreact" 8 | ], 9 | "[typescript]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | }, 12 | "[typescriptreact]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | }, 15 | "[javascript]": { 16 | "editor.defaultFormatter": "esbenp.prettier-vscode" 17 | }, 18 | "[javascriptreact]": { 19 | "editor.defaultFormatter": "esbenp.prettier-vscode" 20 | }, 21 | "[less]": { 22 | "editor.defaultFormatter": "esbenp.prettier-vscode" 23 | }, 24 | "[json]": { 25 | "editor.defaultFormatter": "esbenp.prettier-vscode" 26 | }, 27 | "[scss]": { 28 | "editor.defaultFormatter": "esbenp.prettier-vscode" 29 | }, 30 | "material-icon-theme.activeIconPack": "react_redux", 31 | "create.defaultFileTemplate": "tsx", 32 | "create.defaultFolderTemplate": "react-component", 33 | "cSpell.words": [ 34 | "remixicon" 35 | ] 36 | } -------------------------------------------------------------------------------- /app/core/tools/paths.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { app } from 'electron' 3 | 4 | /** 当前应用程序所在目录 */ 5 | export const APP_PATH: string = app.getAppPath() 6 | 7 | /** 当前用户的应用数据文件夹 */ 8 | export const APP_DATA_PATH: string = app.getPath('appData') 9 | 10 | /** 储存你应用程序设置文件的文件夹 */ 11 | export const USER_DATA_PATH: string = app.getPath('userData') 12 | 13 | /** 应用程序的日志文件夹 */ 14 | export const LOGS_PATH: string = 15 | process.platform === 'darwin' 16 | ? path.resolve(app.getPath('logs'), `../${app.name}`) 17 | : path.resolve(USER_DATA_PATH, 'logs') 18 | 19 | /** 资源文件夹 */ 20 | export const ASSETS_PATH: string = 21 | process.env.NODE_ENV === 'development' ? 'assets' : path.join(APP_PATH, 'assets') 22 | 23 | /** 24 | * 转换资源路径 25 | * @param pathStr 26 | */ 27 | export function asAssetsPath(pathStr: string): string { 28 | return path.join(ASSETS_PATH, pathStr) 29 | } 30 | 31 | /** 32 | * 转换绝对路径 33 | * @param pathStr 34 | */ 35 | export function asAbsolutePath(pathStr: string): string { 36 | return path.resolve(APP_PATH, pathStr) 37 | } 38 | -------------------------------------------------------------------------------- /app/src/components/async-import/async-import.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface AsyncImportProps extends PageProps { 4 | element: Promise 5 | hook?: RouterHook 6 | } 7 | 8 | /** 9 | * 异步导入组件 10 | * @param importComponent 11 | * @param hook // 这里的 hook 指的是路由钩子 12 | */ 13 | export const AsyncImport: React.FC = ({ element, hook, ...props }) => { 14 | const [lazyElement, setLazyElement] = React.useState(null) 15 | 16 | React.useEffect(() => { 17 | /// 非静态路由重置组件 18 | /// WARN: 存在性能隐患 19 | if (!props.isStatic) setLazyElement(null) 20 | 21 | element?.then(({ default: Page }) => { 22 | const next = () => setLazyElement() 23 | if (hook) { 24 | const hookRes = hook(props || {}, next) 25 | if (typeof hookRes === 'boolean' && hookRes) { 26 | next() 27 | } 28 | } else { 29 | next() 30 | } 31 | }) 32 | }, [element, props.isStatic ? void 0 : props.location.key]) 33 | 34 | return lazyElement 35 | } 36 | -------------------------------------------------------------------------------- /app/electron/main.ts: -------------------------------------------------------------------------------- 1 | import { app, Tray } from 'electron' 2 | 3 | import { creatAppTray } from './tray' 4 | 5 | require('@electron/remote/main').initialize() 6 | 7 | $tools.log.info(`Application <${$tools.APP_NAME}> launched.`) 8 | 9 | let tray: Tray 10 | 11 | const appLock = app.requestSingleInstanceLock() 12 | 13 | if (!appLock) { 14 | // 作为第二个实例运行时, 主动结束进程 15 | app.quit() 16 | } 17 | 18 | app.on('second-instance', () => { 19 | // 当运行第二个实例时, 打开或激活首页 20 | $tools.createWindow('Home') 21 | }) 22 | 23 | app.on('ready', () => { 24 | tray = creatAppTray() 25 | $tools.createWindow('Home') 26 | }) 27 | 28 | app.on('activate', () => { 29 | if (process.platform == 'darwin') { 30 | $tools.createWindow('Home') 31 | } 32 | }) 33 | 34 | app.on('window-all-closed', () => { 35 | // if (process.platform !== 'darwin') { 36 | // app.quit() 37 | // } 38 | }) 39 | 40 | app.on('before-quit', () => { 41 | $tools.log.info(`Application <${$tools.APP_NAME}> has exited normally.`) 42 | 43 | if (process.platform === 'win32') { 44 | tray.destroy() 45 | } 46 | }) 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Lanten 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > ⚠ No longer maintained, it is recommended to use [electron-vite-react](https://github.com/electron-vite/electron-vite-react) 2 | > 3 | ## Quick start 4 | install 5 | ```bash 6 | yarn 7 | # or 8 | npm install 9 | ``` 10 | 11 | start dev 12 | ```bash 13 | npm run dev 14 | ``` 15 | 16 | ## Overview 17 | - webpack 18 | - electron 19 | - electron-builder 20 | - electron-log 21 | - react 22 | - react-router 23 | - redux 24 | - ant-design 25 | - remixicon 26 | - less 27 | - typescript 28 | - eslint 29 | - prettier 30 | 31 | ## DevTools 32 | 33 | Toggle DevTools: 34 | 35 | * OSX: Cmd Alt I or F12 36 | * Linux: Ctrl Shift I or F12 37 | * Windows: Ctrl Shift I or F12 38 | 39 | ## Build package 40 | 41 | Modify [builder.config.ts](./build/builder.config.ts) to edit package info. 42 | 43 | For a full list of options see: https://www.electron.build/configuration/configuration 44 | 45 | Create a package for OSX, Windows and Linux 46 | ``` 47 | npm run build 48 | ``` 49 | 50 | Please check the `release` folder after the build is complete. 51 | 52 | 53 | 54 | ## License 55 | [MIT](./LICENSE) 56 | -------------------------------------------------------------------------------- /app/core/store/with-store.ts: -------------------------------------------------------------------------------- 1 | import { connect, MapStateToPropsParam } from 'react-redux' 2 | 3 | export type MapStateList = (StoreStateKeys | AliasStates)[] | AliasStates 4 | 5 | /** 6 | * 挂载 Redux Store 7 | * @param options 8 | * @param mapStates 9 | */ 10 | export function withStore(options: MapStateList): any { 11 | return connect(mapStates(options), (dispatch: any) => new Object({ dispatch }), undefined, { 12 | forwardRef: true, 13 | }) 14 | } 15 | 16 | export function mapStates(options: MapStateList): MapStateToPropsParam { 17 | return (states: StoreStates) => { 18 | const resState = {} 19 | if (options instanceof Array) { 20 | options.forEach((val) => { 21 | if (typeof val === 'string') { 22 | resState[val] = states[val] 23 | } else { 24 | Object.assign(resState, mapAliasStates(val, states)) 25 | } 26 | }) 27 | } else { 28 | Object.assign(resState, mapAliasStates(options, states)) 29 | } 30 | return resState 31 | } 32 | } 33 | 34 | function mapAliasStates(alias: AliasStates, states: StoreStates) { 35 | const resState = {} 36 | 37 | for (const key in alias) { 38 | const statesKey = alias[key] 39 | resState[key] = states[statesKey] 40 | } 41 | 42 | return resState 43 | } 44 | -------------------------------------------------------------------------------- /app/src/styles/antd-theme.less: -------------------------------------------------------------------------------- 1 | /** 2 | * https://github.com/ant-design/ant-design/blob/master/components/style/themes/default.less 3 | */ 4 | 5 | @import './_var.less'; 6 | 7 | @primary-color: @color_primary; // 全局主色 8 | 9 | @link-color: @color_primary; // 链接色 10 | @success-color: @color_success; // 成功色 11 | @warning-color: @color_warn; // 警告色 12 | @error-color: @color_error; // 错误色 13 | @heading-color: @color_title; // 标题色 14 | @text-color: @color_default; // 主文本色 15 | @text-color-secondary: @color_light; // 次文本色 16 | @disabled-color: @color_disabled; // 失效色 17 | 18 | // @body-background: transparent; 19 | // @component-background: fade(@color_white, 75); 20 | 21 | @border-color-base: @color_border; // 边框色 22 | @border-color-split: @color_border_light; // 分割线颜色 23 | 24 | @primary-5: @color_primary_light; // hover 25 | @primary-6: @color_primary_dark; // active 26 | 27 | @border-radius-base: 2px; // 组件/浮层圆角 28 | @box-shadow-base: 0px 1px 2px 0px rgba(128, 134, 149, 0.2); // 浮层阴影 29 | @font-size-base: 14px; // 主字号 30 | 31 | // ---------------------------------------------------------- 32 | 33 | .ant-btn { 34 | &.ant-btn-primary { 35 | &[disabled] { 36 | background-color: lighten(@color_primary, 20); 37 | color: @color_white; 38 | border-color: lighten(@color_primary, 10); 39 | } 40 | } 41 | } 42 | 43 | .ant-message { 44 | display: flex; 45 | justify-content: center; 46 | padding-top: @titlebar_height + @padding_layout; 47 | } 48 | -------------------------------------------------------------------------------- /app/core/api/handle-response.ts: -------------------------------------------------------------------------------- 1 | import { Notification, BrowserWindow } from 'electron' 2 | 3 | /** 4 | * 网络请求发生错误时的处理 5 | * 注意这个函数运行在主进程中, 请不要使用 Document API 6 | * 7 | * @param err 8 | * @param sendData 9 | * @param options 10 | */ 11 | export async function errorAction(err: AnyObj, sendData: AnyObj, options: RequestOptions): Promise { 12 | const { code, message } = err 13 | const { errorType } = options 14 | 15 | $tools.log.error(`[request:${code}] [${errorType}]`, err) 16 | 17 | switch (code) { 18 | // 跳转到未登录页 19 | case 30000: 20 | // ... 21 | break 22 | 23 | // 无权限跳转 24 | case 30002: 25 | // ... 26 | break 27 | 28 | default: 29 | const title = `Request Error: [${code}]` 30 | if (errorType === 'notification') { 31 | const n = new Notification({ 32 | icon: $tools.APP_ICON, 33 | title, 34 | body: message, 35 | }) 36 | n.show() 37 | } else { 38 | await $tools.createWindow('AlertModal', { 39 | windowOptions: { 40 | modal: true, 41 | parent: BrowserWindow.getFocusedWindow() || undefined, 42 | title, 43 | }, 44 | createConfig: { 45 | delayToShow: 100, // DESC 避免无边框窗口闪烁 46 | }, 47 | query: { 48 | type: 'error', 49 | title, 50 | message, 51 | }, 52 | }) 53 | } 54 | break 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/src/auto-routes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module $tools/auto-routes 自动化路由 3 | * @use $tools.routes 4 | * 5 | * - 自动导入 pages 文件夹下所有的 routes.tsx? 以生成路由 6 | * - 通过 `$tools.routes` 获取全局路由对象 7 | */ 8 | 9 | /** 默认路由配置 */ 10 | const DEFAULT_ROUTE_CONFIG: Partial = {} 11 | 12 | const pages = require.context('@/src/views', true, /\/auto\.routes\.tsx?$/) 13 | 14 | /** 以 name 为 key 的路由 Map */ 15 | const routes: Map = new Map() 16 | 17 | pages.keys().forEach((path) => { 18 | const conf: RouteConfig | RouteConfig[] = pages(path).default 19 | flatRoutes(conf) 20 | }) 21 | 22 | function flatRoutes(routes: RouteConfig | RouteConfig[], parent?: RouteConfig) { 23 | const routesH = Array.isArray(routes) ? routes : [routes] 24 | 25 | routesH.forEach((conf) => { 26 | if (parent) { 27 | conf.parentNamePath = parent.parentNamePath ? parent.parentNamePath.concat(parent.name) : [parent.name] 28 | conf.parent = parent 29 | } 30 | 31 | if (Array.isArray(conf.routes) && conf.routes.length) { 32 | // if (conf.path) { 33 | // console.warn(`路由配置异常 [${conf.name}]:配有 routes 子路由的情况下不应存在 path 字段`) 34 | // } 35 | flatRoutes(conf.routes, conf) 36 | } else { 37 | } 38 | addRouteConfig(Object.assign({}, DEFAULT_ROUTE_CONFIG, conf)) 39 | }) 40 | } 41 | 42 | /** 43 | * 添加一个路由 44 | * @param conf 45 | */ 46 | function addRouteConfig(conf: RouteConfig) { 47 | routes.set(conf.name, conf) 48 | } 49 | 50 | export default routes 51 | -------------------------------------------------------------------------------- /app/electron/menus/tray-menus.ts: -------------------------------------------------------------------------------- 1 | import { MenuItemConstructorOptions } from 'electron' 2 | 3 | export const trayMenus: MenuItemConstructorOptions[] = [ 4 | { 5 | label: 'Home', 6 | click: (): void => { 7 | $tools.createWindow('Home') 8 | }, 9 | }, 10 | 11 | { 12 | label: 'Page Params', 13 | click: (): void => { 14 | $tools.createWindow('PageParams', { 15 | params: { test: 'test-params' }, 16 | query: { testObj: JSON.stringify({ aa: ['bb', 'cc'] }) }, 17 | }) 18 | }, 19 | }, 20 | 21 | { 22 | label: 'Demo - Custom Titlebar', 23 | click: (): void => { 24 | $tools.createWindow('Demo') 25 | }, 26 | }, 27 | 28 | { 29 | label: 'Help', 30 | submenu: [ 31 | { 32 | label: 'Log Viewer', 33 | click: (): void => { 34 | $tools.createWindow('LogViewer') 35 | }, 36 | }, 37 | { type: 'separator' }, 38 | { 39 | label: 'About', 40 | click: (): void => { 41 | $tools.createWindow('About') 42 | // app.setAboutPanelOptions({ 43 | // applicationName: $tools.APP_NAME, 44 | // applicationVersion: $tools.APP_VERSION, 45 | // copyright: 'lanten', 46 | // authors: ['lanten'], 47 | // website: 'https://github.com/lanten/electron-antd', 48 | // iconPath: $tools.APP_ICON, 49 | // }) 50 | // app.showAboutPanel() 51 | }, 52 | }, 53 | ], 54 | }, 55 | 56 | { type: 'separator' }, 57 | 58 | { label: 'Quit', role: 'quit' }, 59 | ] 60 | -------------------------------------------------------------------------------- /app/core/tools/log/system-logger.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import electronLog, { ElectronLog } from 'electron-log' 3 | import { formatDate } from '../utils' 4 | import { LOGS_PATH } from '../paths' 5 | 6 | /** 7 | * 创建一个 electron-log 记录器 8 | * 参考:https://github.com/megahertz/electron-log 9 | */ 10 | export class SystemLogger { 11 | public logger: ElectronLog 12 | public logId: string 13 | public logFileName: string 14 | 15 | /** 16 | * @param logId 记录器 ID 17 | */ 18 | constructor(logId: string) { 19 | this.logId = logId 20 | this.logFileName = `${formatDate(new Date(), 'YYYY-MM')}.log` 21 | this.logger = electronLog.create(logId) 22 | 23 | this.logger.transports.file.resolvePath = () => { 24 | return path.resolve(LOGS_PATH, this.logFileName) 25 | } 26 | 27 | const isDev = process.env.NODE_ENV === 'development' ? ' [dev]' : '' 28 | 29 | this.logger.transports.file.format = `[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}]${isDev} {text}` 30 | this.logger.transports.console.format = '[{level}] {text}' 31 | } 32 | 33 | log(...params: any[]): void { 34 | this.logger.log(...params) 35 | } 36 | 37 | info(...params: any[]): void { 38 | this.logger.info(...params) 39 | } 40 | 41 | warn(...params: any[]): void { 42 | this.logger.warn(...params) 43 | } 44 | 45 | error(...params: any[]): void { 46 | this.logger.error(...params) 47 | } 48 | 49 | debug(...params: any[]): void { 50 | this.logger.debug(...params) 51 | } 52 | 53 | verbose(...params: any[]): void { 54 | this.logger.verbose(...params) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/core/tools/consts.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindowConstructorOptions } from 'electron' 2 | import { asAssetsPath } from './paths' 3 | 4 | /** 应用名称 */ 5 | export const APP_NAME = app.name 6 | 7 | /** 应用版本 */ 8 | export const APP_VERSION = app.getVersion() 9 | 10 | /** 应用标题 */ 11 | export const APP_TITLE = process.env.PROJECT_TITLE 12 | 13 | /** 应用主图标 (桌面) */ 14 | export const APP_ICON = asAssetsPath('app-icon/app-icon@256.png') 15 | 16 | /** 亮色风格托盘图标 标准尺寸 16*16, 系统会自动载入 @2x 和 @3x */ 17 | export const TRAY_ICON_LIGHT = asAssetsPath('tray-icon/tray-icon-light.png') 18 | 19 | /** 暗色风格托盘图标 (仅 macOS) */ 20 | export const TRAY_ICON_DARK = asAssetsPath('tray-icon/tray-icon-dark.png') 21 | 22 | /** 创建新窗口时默认加载的选项 */ 23 | export const DEFAULT_WINDOW_OPTIONS: BrowserWindowConstructorOptions = { 24 | icon: APP_ICON, 25 | minWidth: 200, 26 | minHeight: 200, 27 | width: 800, 28 | height: 600, 29 | show: false, 30 | hasShadow: true, 31 | webPreferences: { 32 | contextIsolation: false, 33 | nodeIntegration: true, 34 | scrollBounce: true, 35 | }, 36 | titleBarStyle: 'hidden', // 隐藏标题栏, 但显示窗口控制按钮 37 | // frame: process.platform === 'darwin' ? true : false, // 无边框窗口 38 | // frame: false, // 无边框窗口 39 | // skipTaskbar: false, // 是否在任务栏中隐藏窗口 40 | // backgroundColor: '#fff', 41 | // transparent: true, // 窗口是否透明 42 | // titleBarStyle: 'hidden', 43 | // vibrancy: 'fullscreen-ui', // OSX 毛玻璃效果 44 | } 45 | 46 | export const DEFAULT_CREATE_CONFIG: CreateConfig = { 47 | showSidebar: false, 48 | showCustomTitlebar: false, 49 | autoShow: true, 50 | delayToShow: 0, 51 | single: true, 52 | } 53 | -------------------------------------------------------------------------------- /app/src/styles/_var.less: -------------------------------------------------------------------------------- 1 | // colors 2 | @color_blue: #30b6f2; 3 | @color_green: #0acc6a; 4 | @color_yellow: #f1a737; 5 | @color_orange: #da5b44; 6 | @color_red: #ff5257; 7 | @color_purple: #955dca; 8 | 9 | @color_primary: #3e82db; 10 | @color_primary_light: lighten(@color_primary, 20); 11 | @color_primary_dark: darken(@color_primary, 20); 12 | 13 | @color_info: @color_blue; 14 | @color_success: @color_green; 15 | @color_warn: @color_yellow; 16 | @color_error: @color_red; 17 | 18 | @color_black: #333; 19 | @color_dark: #111113; 20 | @color_title: #17233d; 21 | @color_default: #212224; 22 | @color_light: #f2f3f5; 23 | @color_disabled: #c5c8ce; 24 | @color_white: #ffffff; 25 | @color_gray: #808695; 26 | 27 | // 颜色集 用于遍历, 值为所定义的颜色变量后半部分 : @color_{value} 28 | @colors: primary, info, success, warn, error, orange, purple, title, default, gray, light, white, disabled; 29 | 30 | @color_border: #dcdcdc; 31 | @color_border_light: #e8eaec; 32 | @border_light_bottom: 1px solid @color_border_light; 33 | 34 | @color_background: #f5f5f5; 35 | @color_placeholder: #c5cad5; 36 | 37 | @titlebar_height: 28px; 38 | @titlebar_background: fade(@color_black, 10); 39 | 40 | @sidebar_width: 68px; 41 | @sidebar_background: fade(@color_dark, 75); 42 | 43 | @padding: 16px; 44 | @padding_lg: 24px; 45 | @padding_layout: @padding; 46 | 47 | @radius: 4px; 48 | @radius_lg: 8px; 49 | 50 | @shadow: 0px 1px 2px 0px rgba(128, 134, 149, 0.2); 51 | 52 | @animation: cubic-bezier(0.25, 0.46, 0.45, 0.94); 53 | @animationFadeOut: cubic-bezier(0.175, 0.82, 0.265, 1); 54 | @animationFadeIn: cubic-bezier(0.55, 0.055, 0.675, 0.19); 55 | @animationOutBack: cubic-bezier(0.68, -0.55, 0.265, 1.55); 56 | -------------------------------------------------------------------------------- /app/src/views/common/alert-modal/alert-modal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Button } from 'antd' 3 | 4 | import './alert-modal.less' 5 | 6 | interface AlertModalQuery { 7 | type: 'info' | 'warn' | 'error' 8 | title: string 9 | message: string 10 | } 11 | 12 | const TYPES_CONFIG = { 13 | info: { 14 | icon: , 15 | }, 16 | warn: { 17 | icon: , 18 | }, 19 | error: { 20 | icon: , 21 | }, 22 | } 23 | 24 | export default class AlertModal extends React.Component> { 25 | get typesConfig(): typeof TYPES_CONFIG['info'] { 26 | const { type } = this.props.query 27 | return TYPES_CONFIG[type || 'info'] 28 | } 29 | 30 | render(): JSX.Element { 31 | const { title, message } = this.props.query 32 | return ( 33 |
34 |
35 |
{this.typesConfig.icon}
36 |
37 |

{title}

38 |

{message}

39 |
40 |
41 | 42 |
43 | 51 |
52 |
53 | ) 54 | } 55 | } // class AlertModal end 56 | -------------------------------------------------------------------------------- /app/src/components/app-sidebar/app-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Tooltip } from 'antd' 3 | 4 | import AppSideMenus from './side-menus.json' 5 | import './app-sidebar.less' 6 | 7 | interface SideMenuItem { 8 | key: string 9 | href: string 10 | title: string 11 | icon: string 12 | } 13 | 14 | interface State { 15 | activeMenuKey: string 16 | } 17 | 18 | export class AppSidebar extends React.Component { 19 | state: State = { 20 | activeMenuKey: AppSideMenus[0]?.key, 21 | } 22 | 23 | componentDidMount() { 24 | window.addEventListener('router-update', this.onRouterUpdate) 25 | } 26 | 27 | onRouterUpdate = (e: CustomEventMap['router-update']) => { 28 | const routeProps: PageProps = e.detail 29 | this.setState({ activeMenuKey: routeProps.name }) 30 | } 31 | 32 | render() { 33 | return ( 34 |
35 |
36 | 37 |
38 | 39 |
{AppSideMenus.map(this.renderMenuItem)}
40 |
41 | ) 42 | } 43 | 44 | renderMenuItem = ({ key, icon, title, href }: SideMenuItem) => { 45 | const { activeMenuKey } = this.state 46 | const isActive = activeMenuKey === key 47 | 48 | return ( 49 | 50 | 55 | 56 | ) 57 | } 58 | 59 | componentWillUnmount() { 60 | window.removeEventListener('router-update', this.onRouterUpdate) 61 | } 62 | } // class AppSidebar end 63 | -------------------------------------------------------------------------------- /app/core/tools/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 格式化日期 3 | * @param d 4 | * @param format 'YYYY-MM-DD H:I:S.MS' 5 | */ 6 | export function formatDate(date: Date = new Date(), format = 'YYYY-MM-DD H:I:S.MS'): string { 7 | const obj = { 8 | YYYY: date.getFullYear().toString().padStart(4, '0'), 9 | MM: (date.getMonth() + 1).toString().padStart(2, '0'), 10 | DD: date.getDate().toString().padStart(2, '0'), 11 | H: date.getHours().toString().padStart(2, '0'), 12 | I: date.getMinutes().toString().padStart(2, '0'), 13 | S: date.getSeconds().toString().padStart(2, '0'), 14 | MS: date.getMilliseconds().toString().padStart(3, '0'), 15 | } 16 | 17 | return format.replace(/(YYYY|MM|DD|H|I|S|MS)/g, (_, $1) => { 18 | return obj[$1] 19 | }) 20 | } 21 | 22 | /** 23 | * 获取 url 参数 24 | * @param search 25 | */ 26 | export function getQuery(search: string) { 27 | const query: Record = {} 28 | 29 | const searchH = search[0] === '?' ? search.substring(1) : search 30 | 31 | searchH 32 | .trim() 33 | .split('&') 34 | .forEach((str) => { 35 | const strArr = str.split('=') 36 | const key = strArr[0] 37 | 38 | if (!key) return 39 | 40 | let val = decodeURIComponent(strArr[1]) 41 | 42 | try { 43 | if ((val.startsWith('{') || val.startsWith('[')) && (val.endsWith('}') || val.endsWith(']'))) { 44 | val = JSON.parse(val) 45 | } 46 | } catch (err) { 47 | $tools.log.error(err) 48 | } 49 | query[key] = val 50 | }) 51 | return query 52 | } 53 | 54 | /** 55 | * 转换成 url search 56 | * @param obj 57 | */ 58 | export function toSearch(obj: Record): string { 59 | if (typeof obj === 'string') return obj 60 | 61 | const arr = Object.keys(obj).map((key) => { 62 | let val = obj[key] 63 | 64 | if (typeof val !== 'string') { 65 | try { 66 | val = JSON.stringify(val) 67 | } catch (err) { 68 | console.error(err) 69 | } 70 | } 71 | 72 | return `${key}=${encodeURIComponent(val)}` 73 | }) 74 | return '?' + arr.join('&') 75 | } 76 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typescript-eslint 规则参考 3 | * https://www.npmjs.com/package/@typescript-eslint/eslint-plugin#supported-rules 4 | */ 5 | 6 | module.exports = { 7 | root: true, 8 | 9 | parser: '@typescript-eslint/parser', // 指定ESLint解析器 10 | 11 | extends: [ 12 | 'plugin:@typescript-eslint/recommended', 13 | 'plugin:react/recommended', 14 | 'plugin:jsx-control-statements/recommended', 15 | 'prettier', 16 | 'plugin:prettier/recommended', 17 | ], 18 | 19 | settings: { 20 | react: { 21 | version: 'detect', 22 | }, 23 | }, 24 | 25 | plugins: ['react', 'prettier'], 26 | 27 | env: { 28 | browser: true, 29 | node: true, 30 | es6: true, 31 | mocha: true, 32 | 'jsx-control-statements/jsx-control-statements': true, 33 | }, 34 | 35 | globals: { 36 | $api: true, 37 | }, 38 | 39 | rules: { 40 | // indent: ['error', 2, { SwitchCase: 1 }], // 强制使用两个空格作为缩进 41 | quotes: ['error', 'single'], //强制使用单引号 42 | semi: ['error', 'never'], //强制不使用分号结尾 43 | 'comma-dangle': ['error', 'always-multiline'], // 逗号结束 44 | 'no-param-reassign': 'error', // 禁止对 function 的参数进行重新赋值 45 | 'jsx-quotes': ['error', 'prefer-double'], // 强制所有 JSX 属性值使用双引号。 46 | 'prettier/prettier': 'error', // prettier 47 | 'prefer-rest-params': 0, 48 | 49 | 'react/display-name': 0, 50 | 'jsx-control-statements/jsx-use-if-tag': 0, // 强制在 jsx 中使用 if 判断 51 | 'jsx-control-statements/jsx-jcs-no-undef': 0, 52 | '@typescript-eslint/no-explicit-any': 0, // 禁用 any 类型 53 | '@typescript-eslint/ban-ts-ignore': 0, // 禁用 @ts-ignore 54 | '@typescript-eslint/explicit-function-return-type': 0, // 在函数和类方法上需要显式的返回类型 55 | '@typescript-eslint/no-var-requires': 0, // 除 import 语句外,禁止使用require语句 56 | '@typescript-eslint/no-namespace': 0, // 禁用 namespace 57 | '@typescript-eslint/no-use-before-define': 0, 58 | '@typescript-eslint/no-empty-interface': 0, 59 | '@typescript-eslint/no-empty-function': 1, 60 | '@typescript-eslint/no-unused-vars': 1, // 导入内容未使用 61 | '@typescript-eslint/ban-ts-comment': 0, // 禁用 @ts-ignore 等注释 62 | }, 63 | } 64 | -------------------------------------------------------------------------------- /.vscode/create-item.template.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is template config for extension: [Create Item By Template] 3 | * This is a JavaScript code file that will be executed in the Node environment 4 | * And you can add any Javascript(commonjs) code here 5 | * For more advanced usage, please see this wiki: https://github.com/lanten/create-item-by-template/wiki/Template-Example 6 | */ 7 | 8 | /** file list */ 9 | const files = { 10 | tsx: name => { 11 | const nameH = toCamel(name) 12 | return [ 13 | `import React from 'react'`, 14 | ``, 15 | `import './${name}.less'`, 16 | ``, 17 | `export default class ${nameH} extends React.Component {`, 18 | ` render() {`, 19 | ` return (`, 20 | `
`, 21 | `

component ${name} is created

`, 22 | `
`, 23 | ` )`, 24 | ` }`, 25 | `} // class ${nameH} end`, 26 | ``, 27 | ] 28 | }, 29 | 30 | routes: name => { 31 | const nameH = toCamel(name) 32 | return [ 33 | `const routes: RouteConfig[] = [`, 34 | ` {`, 35 | ` key: '${nameH}',`, 36 | ` path: '/${name}',`, 37 | ` windowOptions: {`, 38 | ` title: '${nameH}',`, 39 | ` },`, 40 | ` },`, 41 | `]`, 42 | ``, 43 | `export default routes`, 44 | ``, 45 | ] 46 | }, 47 | 48 | 'index.ts': name => { 49 | return [`export * from './${name}'`, ``] 50 | }, 51 | 52 | less: name => { 53 | return [`.${name} {`, ``, `}`] 54 | }, 55 | } 56 | 57 | /** folder list */ 58 | const folders = { 59 | 'react-component': name => { 60 | return { 61 | 'index.tsx': files['index.ts'], 62 | [`${name}.tsx`]: files.tsx, 63 | [`${name}.less`]: files.less, 64 | } 65 | }, 66 | 67 | 'react-routes': name => { 68 | return { 69 | [`${name}.tsx`]: files.tsx, 70 | [`routes.ts`]: files.routes, 71 | [`${name}.less`]: files.less, 72 | } 73 | }, 74 | } 75 | 76 | /** 77 | * 中划线转驼峰 78 | * @param {String} str 79 | * @param {Boolean} c 首字母大写 80 | */ 81 | function toCamel(str, c = true) { 82 | let strH = str.replace(/([^\-])(?:\-+([^\-]))/g, (_, $1, $2) => $1 + $2.toUpperCase()) 83 | if (c) strH = strH.slice(0, 1).toUpperCase() + strH.slice(1) 84 | return strH 85 | } 86 | 87 | module.exports = { files, folders } 88 | -------------------------------------------------------------------------------- /app/src/views/log-viewer/log-reader.tsx: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | export interface LogFile { 5 | name: string 6 | fileName: string 7 | absolutePath: string 8 | } 9 | 10 | export interface LogDetailLine { 11 | date: string 12 | type: string 13 | env: string 14 | message: string 15 | } 16 | 17 | export class LogReader { 18 | private watcher?: fs.FSWatcher 19 | private oldLogDetail = '' 20 | 21 | /** 获取文件列表 */ 22 | getLogFiles(): LogFile[] { 23 | let files 24 | try { 25 | files = fs.readdirSync($tools.LOGS_PATH) 26 | } catch (error) { 27 | $tools.log.error('Failed to read log file list.') 28 | return [] 29 | } 30 | 31 | const res: LogFile[] = [] 32 | files.forEach((fileName) => { 33 | const ext = fileName.replace(/.+(\..+)$/, '$1') 34 | if (ext !== '.log') return 35 | 36 | res.unshift({ 37 | fileName, 38 | name: fileName.replace(ext, ''), 39 | absolutePath: path.resolve($tools.LOGS_PATH, fileName), 40 | }) 41 | }) 42 | 43 | return res 44 | } 45 | 46 | /** 打开并监听日志文件 */ 47 | watchingLogFile(file: LogFile, listener: (detail: LogDetailLine[]) => void): void { 48 | listener(this.getLogDetail(file)) 49 | 50 | if (this.watcher) { 51 | this.closeWatcher() 52 | } 53 | 54 | this.watcher = fs.watch(file.absolutePath, (event, filename) => { 55 | console.log('文件刷新', { event, filename }) 56 | listener(this.getLogDetail(file)) 57 | }) 58 | } 59 | 60 | /** 获取日志详情 */ 61 | getLogDetail(file: LogFile): LogDetailLine[] { 62 | let detailStr = '' 63 | try { 64 | detailStr = fs.readFileSync(file.absolutePath, { encoding: 'utf-8' }) 65 | } catch (error) { 66 | $tools.log.error('Failed to read log file detail.') 67 | } 68 | 69 | const res: LogDetailLine[] = [] 70 | 71 | if (detailStr) { 72 | detailStr 73 | .replace(this.oldLogDetail, '') 74 | .split(/(\n|\r|\r\n)/) 75 | .forEach((str) => { 76 | if (!str.trim()) return 77 | if (str[0] !== '[') { 78 | res.push({ date: '', type: '', env: '', message: str }) 79 | } else { 80 | str.replace( 81 | /^\[([^\]]*)\]\s*\[([^\]]*)\]\s*\[([^\]]*)\]\s*(.*)$/, 82 | (_, date, type, env, message) => { 83 | res.push({ date, type, env, message }) 84 | return '' 85 | } 86 | ) 87 | } 88 | }) 89 | this.oldLogDetail = detailStr 90 | } else { 91 | this.resetDetailHistory() 92 | } 93 | 94 | return res 95 | } 96 | 97 | closeWatcher() { 98 | this.watcher?.close() 99 | this.watcher = undefined 100 | } 101 | 102 | resetDetailHistory() { 103 | this.oldLogDetail = '' 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electron-antd", 3 | "appId": "org.electron.electron-antd", 4 | "version": "1.0.0", 5 | "buildVersion": "0001", 6 | "private": false, 7 | "main": "dist/main/main.js", 8 | "author": "lanten", 9 | "scripts": { 10 | "dev": "cross-env NODE_ENV=development BUILD_ENV=dev npm run ts:dev", 11 | "build:dev": "cross-env NODE_ENV=production BUILD_ENV=dev npm run ts:build", 12 | "build": "cross-env NODE_ENV=production BUILD_ENV=prod npm run ts:build", 13 | "ts:dev": "npm run ts:compile && node build/.out/tasks/dev-server.js", 14 | "ts:build": "npm run ts:compile && node build/.out/tasks/build.js", 15 | "ts:compile": "tsc --project build/build.tsconfig.json", 16 | "lint": "eslint --ext .ts,.tsx src" 17 | }, 18 | "devDependencies": { 19 | "@electron/remote": "^2.0.1", 20 | "@types/css-minimizer-webpack-plugin": "^3.2.1", 21 | "@types/html-webpack-plugin": "^3.2.6", 22 | "@types/mini-css-extract-plugin": "^2.4.0", 23 | "@types/node": "^17.0.9", 24 | "@types/react": "^17.0.38", 25 | "@types/react-dom": "^17.0.11", 26 | "@types/react-redux": "^7.1.22", 27 | "@types/react-router-dom": "^5.3.2", 28 | "@types/webpack": "^5.28.0", 29 | "@types/webpack-dev-server": "^4.7.1", 30 | "@types/webpack-env": "^1.16.3", 31 | "@types/webpackbar": "^4.0.3", 32 | "@typescript-eslint/eslint-plugin": "^5.9.1", 33 | "@typescript-eslint/parser": "^5.9.1", 34 | "antd": "^4.18.3", 35 | "axios": "^0.24.0", 36 | "clsx": "^1.1.1", 37 | "cross-env": "^7.0.3", 38 | "css-hot-loader": "^1.4.4", 39 | "css-loader": "^6.5.1", 40 | "css-minimizer-webpack-plugin": "^3.3.1", 41 | "electron": "^16.0.7", 42 | "electron-builder": "^22.14.5", 43 | "electron-log": "^4.4.4", 44 | "eslint": "^8.7.0", 45 | "eslint-config-prettier": "^8.3.0", 46 | "eslint-plugin-jsx-control-statements": "^2.2.1", 47 | "eslint-plugin-prettier": "^4.0.0", 48 | "eslint-plugin-react": "^7.28.0", 49 | "eslint-webpack-plugin": "^3.1.1", 50 | "html-webpack-plugin": "^5.5.0", 51 | "less": "^4.1.2", 52 | "less-loader": "^10.2.0", 53 | "mini-css-extract-plugin": "^2.5.1", 54 | "picocolors": "^1.0.0", 55 | "prettier": "^2.5.1", 56 | "react": "^17.0.2", 57 | "react-dom": "^17.0.2", 58 | "react-redux": "^7.2.6", 59 | "react-router-dom": "^6.2.1", 60 | "redux": "^4.1.2", 61 | "redux-thunk": "^2.4.1", 62 | "remixicon": "^2.5.0", 63 | "style-loader": "^3.3.1", 64 | "terser-webpack-plugin": "^5.3.0", 65 | "ts-import-plugin": "^2.0.0", 66 | "ts-loader": "^9.2.6", 67 | "tslib": "^2.3.1", 68 | "typescript": "^4.5.4", 69 | "webpack": "^5.66.0", 70 | "webpack-cli": "^4.9.1", 71 | "webpack-dev-server": "^4.7.3", 72 | "webpackbar": "^5.0.2" 73 | }, 74 | "license": "MIT", 75 | "repository": { 76 | "type": "git", 77 | "url": "git+https://github.com/lanten/electron-antd.git" 78 | }, 79 | "bugs": { 80 | "url": "https://github.com/lanten/electron-antd/issues" 81 | }, 82 | "homepage": "https://github.com/lanten/electron-antd#readme" 83 | } 84 | -------------------------------------------------------------------------------- /app/core/tools/settings/create-settings.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { USER_DATA_PATH } from '../paths' 4 | import { log } from '../log' 5 | 6 | export class CreateSettings { 7 | public filePath: string 8 | public settingsPath: string 9 | 10 | /** 11 | * 创建一个设置项集合 (json) 12 | * @param name 集合名称 13 | * @param folderPath 存放 json 的文件夹 14 | */ 15 | constructor(public readonly name: string, public readonly folderPath?: string) { 16 | this.settingsPath = folderPath ?? path.resolve(USER_DATA_PATH, 'settings') 17 | this.filePath = path.resolve(this.settingsPath, `${name}.settings.json`) 18 | } 19 | 20 | hasSettingsFile(): boolean { 21 | const hasFile = fs.existsSync(this.filePath) 22 | 23 | if (hasFile) { 24 | return true 25 | } else if (!fs.existsSync(this.settingsPath)) { 26 | fs.mkdirSync(this.settingsPath, { recursive: true }) 27 | } 28 | return false 29 | } 30 | 31 | createSettingsFile(): void { 32 | fs.writeFileSync(this.filePath, '{}') 33 | log.info(`Create settings file <${this.name}> path: ${this.filePath}`) 34 | } 35 | 36 | /** 获取全部配置 */ 37 | get(): Partial 38 | /** 通过 key 获取单个配置 */ 39 | get(key: K): T[K] 40 | /** 通过 key 获取单个配置 */ 41 | get(key?: keyof T): unknown { 42 | let config: Partial = {} 43 | if (this.hasSettingsFile()) { 44 | const configStr = fs.readFileSync(this.filePath, 'utf-8') 45 | try { 46 | config = JSON.parse(configStr) 47 | } catch (error) { 48 | log.error(error) 49 | } 50 | } else { 51 | config = {} 52 | this.createSettingsFile() 53 | } 54 | 55 | if (key) { 56 | return config[key] 57 | } else { 58 | return config 59 | } 60 | } 61 | 62 | /** 63 | * 写入配置项 64 | * @param key 65 | * @param config 66 | */ 67 | set(key: K | Partial, config?: Partial | Partial): boolean { 68 | const jsonConfig = this.get() 69 | let confH: Partial 70 | 71 | if (typeof key === 'string') { 72 | if (config) { 73 | confH = config 74 | } else { 75 | return false 76 | } 77 | } else if (key) { 78 | confH = key as Partial 79 | } else { 80 | return false 81 | } 82 | 83 | let flg = false 84 | try { 85 | let saveStr: string 86 | let logMessage: string 87 | if (typeof key === 'string') { 88 | saveStr = JSON.stringify(Object.assign({}, jsonConfig, { [key]: confH }), undefined, 2) 89 | logMessage = `Set settings <${this.name}> - <${key}> : ` 90 | } else { 91 | saveStr = JSON.stringify(Object.assign({}, jsonConfig, confH), undefined, 2) 92 | logMessage = `Set settings <${this.name}> : ` 93 | } 94 | fs.writeFileSync(this.filePath, saveStr, 'utf-8') 95 | log.info(logMessage, confH) 96 | flg = true 97 | } catch (error) { 98 | log.error(error) 99 | } 100 | 101 | return flg 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /types/router.d.ts: -------------------------------------------------------------------------------- 1 | import { RouteProps, Location, NavigateFunction } from 'react-router-dom' 2 | import { BrowserWindow, BrowserWindowConstructorOptions } from 'electron' 3 | import * as pageResource from '@/src/page-resource' 4 | 5 | declare global { 6 | /** 路由的 key, 与组件 class 名称对应 */ 7 | type RouteName = keyof typeof pageResource 8 | 9 | /** 页面默认 props */ 10 | interface PageProps

, Q = Record, LocationState = unknown> 11 | extends RouteContextType, 12 | RouteConfig { 13 | /** 关闭当前窗口 */ 14 | closeWindow: () => void 15 | /** 当前窗口 BrowserWindow 实例 */ 16 | currentWindow: BrowserWindow 17 | /** 当前路由 key */ 18 | name: RouteName 19 | } 20 | 21 | interface RouteLocation extends Location { 22 | state: S 23 | } 24 | 25 | interface RouteContextType, P = Record, S = unknown> { 26 | /** 由 location.search 转换来的对象 */ 27 | query: Q 28 | /** react-router location 对象 */ 29 | location: RouteLocation 30 | /** react-router 路由跳转方法 */ 31 | navigate: NavigateFunction 32 | /** 路由参数 */ 33 | params: P 34 | } 35 | 36 | /** 新窗口启动参数 */ 37 | interface CreateConfig { 38 | /** 显示标题栏 默认 true */ 39 | showCustomTitlebar?: boolean 40 | /** 显示侧边栏 默认 false */ 41 | showSidebar?: boolean 42 | /** 以新窗口打开时是否启动 DevTools */ 43 | openDevTools?: boolean 44 | /** 记住窗口关闭时的位置和尺寸, 窗口打开时自动加载 */ 45 | saveWindowBounds?: boolean 46 | /** 延迟执行 win.show() 单位:ms 默认:0 (适当的延迟避免 DOM 渲染完成前白屏或闪烁) */ 47 | delayToShow?: number 48 | /** 创建完成后自动显示 默认:true */ 49 | autoShow?: boolean 50 | /** 禁止重复创建窗口 默认:true */ 51 | single?: boolean 52 | /** 隐藏菜单栏 默认:false */ 53 | hideMenus?: boolean 54 | /** 窗口创建完成回调 */ 55 | created?: (win: BrowserWindow) => void 56 | } 57 | 58 | interface RouteMenuConfig { 59 | /** 菜单文字说明 */ 60 | tooltip?: string 61 | /** 菜单图标 https://remixicon.com/ */ 62 | icon?: string 63 | /** 排序控制 */ 64 | index?: number 65 | } 66 | 67 | /** 路由配置规范 */ 68 | interface RouteConfig extends Omit, CreateConfig { 69 | /** 页面资源 name, 对应 page-resource 中的变量名 */ 70 | name: RouteName 71 | /** 是否静态 */ 72 | isStatic?: boolean 73 | /** 重定向 */ 74 | redirectTo?: string 75 | /** 自定义参数, 视情况而定 */ 76 | type?: string 77 | 78 | /** 以 createWindow 打开时, 加载的 BrowserWindow 选项 */ 79 | windowOptions?: BrowserWindowConstructorOptions 80 | /** 新窗口启动参数 */ 81 | createConfig?: CreateConfig 82 | 83 | /** 84 | * 侧边菜单相关配置 85 | * 设为 false 不显示 86 | * 默认:void 87 | */ 88 | sideMenu?: RouteMenuConfig | boolean 89 | /** 90 | * 子路由 91 | * 子路由 path 与父级不存在嵌套关系 92 | */ 93 | routes?: RouteConfig[] 94 | /** 95 | * 父级路由 96 | * 用于控制菜单层级 97 | * auto-routes 会根据 RouteConfig 层级自动注入,无需手动声明 98 | */ 99 | parent?: RouteConfig 100 | /** 101 | * 嵌套层级路径,路由 name 的数组 102 | * 用明确路由嵌套层级 103 | * auto-routes 会根据 RouteConfig 层级自动注入,无需手动声明 104 | */ 105 | parentNamePath?: string[] 106 | } 107 | 108 | type RouterHook = (props: PageProps, next: () => void) => boolean | void | Promise 109 | } 110 | -------------------------------------------------------------------------------- /app/src/components/app-titlebar/app-titlebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { getCurrentWindow } from '@electron/remote' 3 | 4 | import './app-titlebar.less' 5 | 6 | interface State { 7 | /** 当前路由 props */ 8 | routeProps: Partial 9 | /** 是否最大化 */ 10 | maximized: boolean 11 | } 12 | 13 | export class AppTitlebar extends React.Component { 14 | currentWindow = getCurrentWindow() 15 | 16 | state: State = { 17 | routeProps: {}, 18 | maximized: this.currentWindow.isMaximized(), 19 | } 20 | 21 | componentDidMount(): void { 22 | window.addEventListener('router-update', this.onRouterUpdate) 23 | this.currentWindow.on('maximize', this.onMaximize) 24 | this.currentWindow.on('unmaximize', this.onUnmaximize) 25 | } 26 | 27 | onRouterUpdate = (e: CustomEventMap['router-update']): void => { 28 | const routeProps = e.detail 29 | this.setState({ routeProps }) 30 | } 31 | 32 | onMaximize = (): void => { 33 | this.setState({ maximized: true }) 34 | } 35 | 36 | onUnmaximize = (): void => { 37 | this.setState({ maximized: false }) 38 | } 39 | 40 | renderWindowController(): JSX.Element | void { 41 | const { routeProps, maximized } = this.state 42 | 43 | // 最大化按钮 44 | const maxSizeBtn = this.currentWindow.isMaximizable() ? ( 45 | maximized ? ( 46 |

this.currentWindow.unmaximize()}> 47 | 48 |
49 | ) : ( 50 |
this.currentWindow.maximize()}> 51 | 52 |
53 | ) 54 | ) : ( 55 | void 0 56 | ) 57 | 58 | return ( 59 |
60 | {/* 最小化按钮 */} 61 | {this.currentWindow.isMinimizable() && ( 62 |
this.currentWindow.minimize()}> 63 | 64 |
65 | )} 66 | 67 | {/* 最大化按钮 */} 68 | {maxSizeBtn} 69 | 70 | {/* 关闭按钮 */} 71 | {this.currentWindow.isClosable() && ( 72 |
73 | 74 |
75 | )} 76 |
77 | ) 78 | } 79 | 80 | render(): JSX.Element { 81 | const { routeProps } = this.state 82 | return ( 83 |
84 |
85 | 86 |

{routeProps.currentWindow?.title}

87 |

{routeProps.location?.pathname}

88 |
89 | 90 | {process.platform !== 'darwin' && this.renderWindowController()} 91 |
92 | ) 93 | } 94 | 95 | componentWillUnmount(): void { 96 | // 移除事件监听 97 | window.removeEventListener('router-update', this.onRouterUpdate) 98 | this.currentWindow.removeListener('maximize', this.onMaximize) 99 | this.currentWindow.removeListener('unmaximize', this.onUnmaximize) 100 | } 101 | } // class AppTitlebar end 102 | -------------------------------------------------------------------------------- /app/core/api/request.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from 'axios' 2 | import path from 'path' 3 | import { errorAction } from './handle-response' 4 | 5 | // axios 跨域请求携带 cookie 6 | axios.defaults.withCredentials = true 7 | 8 | const DEFAULT_CONFIG = { 9 | method: 'POST', 10 | host: process.env.API_HOST, 11 | protocol: process.env.API_PROTOCOL, 12 | baseUrl: process.env.API_BASE_PATH, 13 | timeout: 30000, 14 | loading: false, 15 | errorType: 'notification', 16 | checkStatus: true, 17 | headers: { 18 | 'Content-Type': 'application/json', 19 | }, 20 | } 21 | 22 | // 默认传递的参数 23 | const DEFAULT_PARAMS = { 24 | // TODO 每一个请求传递的默认参数, 这在某些需要手动传递 token 的场景下很管用 25 | } 26 | 27 | /** 28 | * 发起一个请求 29 | * @param apiPath 30 | * @param params 31 | * @param optionsSource 32 | */ 33 | export async function request( 34 | apiPath: string, 35 | params?: RequestParams, 36 | optionsSource?: RequestOptions 37 | ): Promise { 38 | const options: RequestOptions = Object.assign({}, DEFAULT_CONFIG, optionsSource) 39 | const { method, protocol, host, baseUrl, headers, responseType, checkStatus, formData } = options 40 | const sendData: AxiosRequestConfig = { 41 | url: `${protocol}${path.join(host || '', baseUrl || '', apiPath || '')}`, 42 | method, 43 | headers, 44 | responseType, 45 | } 46 | 47 | const paramsData = Object.assign({}, DEFAULT_PARAMS, params) 48 | 49 | if (method === 'GET') { 50 | sendData.params = params 51 | } else if (formData) { 52 | const formData = new FormData() 53 | Object.keys(paramsData).forEach((key) => { 54 | formData.append(key, paramsData[key]) 55 | }) 56 | sendData.data = formData 57 | } else { 58 | sendData.data = paramsData 59 | } 60 | 61 | return axios(sendData) 62 | .then((res) => { 63 | const data: T = res.data 64 | 65 | // TODO 根据后端接口设定成功条件, 例如此处 `data.code == 200` 66 | if (!checkStatus || data.code == 200) { 67 | return data 68 | } else { 69 | return Promise.reject(data) 70 | } 71 | }) 72 | .catch(async (err) => { 73 | await errorAction(err, sendData, options) 74 | return Promise.reject({ ...err, path: apiPath, sendData, resData: err }) 75 | }) 76 | } 77 | 78 | /** - interface - split ------------------------------------------------------------------- */ 79 | 80 | declare global { 81 | /** 82 | * 网络请求参数 83 | */ 84 | interface RequestParams { 85 | [key: string]: any 86 | } 87 | 88 | /** 89 | * 网络请求返回值 90 | */ 91 | interface RequestRes { 92 | // TODO 各种返回值格式层出不穷, 请根据实际内容重新定义类型 93 | /** 状态码,成功返回 200 */ 94 | code: number 95 | /** 错误消息 */ 96 | message: string 97 | /** 返回数据 */ 98 | data: any 99 | } 100 | 101 | /** 102 | * 请求选项 103 | */ 104 | interface RequestOptions { 105 | /** 请求类型: [POST | GET] 默认: POST */ 106 | method?: 'GET' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'POST' | 'PUT' | 'PATCH' 107 | /** 基本 url, 没有特殊需求无需传递 */ 108 | baseUrl?: string 109 | /** 请求域名 */ 110 | host?: string 111 | /** 协议 */ 112 | protocol?: string 113 | /** 使用 formData 传递参数 */ 114 | formData?: boolean 115 | /** 接口分组 */ 116 | group?: string 117 | /** 超时时间,单位: ms */ 118 | timeout?: number 119 | /** 请求过程中是否显示 Loading */ 120 | loading?: boolean 121 | /** 发生错误时提示框类型, 默认: notification */ 122 | errorType?: 'notification' | 'modal' | false 123 | /** 自定义请求头 */ 124 | headers?: any 125 | /** 类型动态设置 */ 126 | responseType?: 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream' 127 | /** 是否校验请求状态 */ 128 | checkStatus?: boolean 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /app/src/components/app-router/app-router.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { getCurrentWindow } from '@electron/remote' 3 | import { 4 | HashRouter as Router, 5 | Route, 6 | Routes, 7 | Navigate, 8 | RouteProps, 9 | useNavigate, 10 | useLocation, 11 | useParams, 12 | } from 'react-router-dom' 13 | import { Provider } from 'react-redux' 14 | 15 | import * as pageResource from '@/src/page-resource' 16 | import { RouteContext } from '@/src/context' 17 | import { AsyncImport } from '../async-import' 18 | import { beforeRouter } from './router-hooks' 19 | 20 | interface AppRouterProps { 21 | routes: Map 22 | store: AppStore 23 | } 24 | 25 | interface AppRouterState { 26 | readyToClose: boolean 27 | } 28 | 29 | const currentWindow = getCurrentWindow() 30 | 31 | export class AppRouter extends React.Component { 32 | static defaultProps = { 33 | routes: [], 34 | } 35 | 36 | /** 路由容器 ref */ 37 | public readonly contentRef = React.createRef() 38 | 39 | readonly state: AppRouterState = { 40 | readyToClose: false, 41 | } 42 | 43 | constructor(props: AppRouterProps) { 44 | super(props) 45 | 46 | // 保证组件正常卸载,防止 Redux 内存泄露 47 | window.onbeforeunload = () => { 48 | this.setState({ readyToClose: true }) 49 | } 50 | } 51 | 52 | render(): JSX.Element | null { 53 | const { store, routes } = this.props 54 | const { readyToClose } = this.state 55 | 56 | const routesElement = this.createRoutes(routes) 57 | 58 | if (readyToClose) return null 59 | return ( 60 | 61 | 62 | {routesElement} 63 | 64 | 65 | ) 66 | } 67 | 68 | createRoutes(routes: Map | RouteConfig[]): JSX.Element[] { 69 | const res: JSX.Element[] = [] 70 | let noMatch: JSX.Element | undefined = undefined 71 | 72 | routes.forEach((conf) => { 73 | const routeEl = this.creatRouteItem(conf) 74 | if (!routeEl) return 75 | 76 | if (conf.path === '*') { 77 | noMatch = routeEl 78 | } else if (routeEl) { 79 | res.push(routeEl) 80 | } 81 | }) 82 | 83 | if (noMatch) res.push(noMatch) 84 | 85 | return res 86 | } 87 | 88 | creatRouteItem = (routeConfig: RouteConfig) => { 89 | const { path, redirectTo, name, ...params } = routeConfig 90 | const element = pageResource[name] as unknown as Promise | undefined 91 | const routeProps: RouteProps = { path } 92 | const nextProps = { 93 | name, 94 | contentRef: this.contentRef, 95 | currentWindow, 96 | closeWindow: this.closeWindow, 97 | ...params, 98 | } 99 | 100 | if (redirectTo) { 101 | routeProps.element = 102 | } else if (element instanceof Promise) { 103 | routeProps.element = ( 104 | 105 | {(routeHooksParams) => { 106 | return 107 | }} 108 | 109 | ) 110 | } else { 111 | throw new Error(`Route config error! \n ${JSON.stringify(routeConfig, undefined, 2)}`) 112 | } 113 | 114 | return 115 | } 116 | 117 | closeWindow = (): void => { 118 | this.setState({ readyToClose: true }, () => { 119 | currentWindow.close() 120 | }) 121 | } 122 | } 123 | 124 | interface RouterContextProps { 125 | children: (props: RouteContextType) => React.ReactNode 126 | } 127 | 128 | function RouteCtx({ children }: RouterContextProps) { 129 | const location = useLocation() 130 | const navigate = useNavigate() 131 | const params = useParams() 132 | const query = Object.assign($tools.getQuery(window.location.search), $tools.getQuery(location.search)) 133 | 134 | const ctxValue: RouteContextType = { location, navigate, query, params } 135 | 136 | return {children(ctxValue)} 137 | } 138 | -------------------------------------------------------------------------------- /app/src/styles/common.less: -------------------------------------------------------------------------------- 1 | @import './_var.less'; 2 | 3 | html { 4 | background-color: transparent; 5 | } 6 | 7 | body, 8 | #app { 9 | height: 100vh; 10 | } 11 | 12 | p, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6 { 19 | margin: 0; 20 | } 21 | 22 | a, 23 | span, 24 | i { 25 | display: inline-block; 26 | } 27 | 28 | a { 29 | cursor: pointer; 30 | text-decoration: none; 31 | color: @color_blue; 32 | &:focus { 33 | text-decoration: none; 34 | } 35 | &:hover { 36 | color: darken(@color_blue, 20); 37 | } 38 | } 39 | 40 | img, 41 | [class^='ri-'], 42 | [class*=' ri-'] { 43 | user-select: none; 44 | } 45 | 46 | input, 47 | textarea { 48 | -webkit-appearance: none; 49 | } 50 | 51 | ul { 52 | padding: 0; 53 | margin: 0; 54 | li { 55 | list-style-type: none; 56 | } 57 | } 58 | 59 | .flex { 60 | display: flex; 61 | 62 | &.row { 63 | flex-direction: row; 64 | } 65 | 66 | &.column { 67 | flex-direction: column; 68 | } 69 | 70 | &.center { 71 | justify-content: center; 72 | align-items: center; 73 | } 74 | 75 | &.center-h { 76 | justify-content: center; 77 | } 78 | 79 | &.center-v { 80 | align-items: center; 81 | } 82 | 83 | &.between { 84 | justify-content: space-between; 85 | } 86 | 87 | &.end { 88 | justify-content: flex-end; 89 | } 90 | 91 | .flex-none { 92 | flex: none; 93 | } 94 | 95 | .loopFlex(@i) when (@i > 0) { 96 | .flex-@{i} { 97 | flex: @i; 98 | } 99 | .loopFlex(@i - 1); 100 | } 101 | .loopFlex(12); 102 | } 103 | 104 | .text { 105 | &-center { 106 | text-align: center; 107 | } 108 | &-left { 109 | text-align: left; 110 | } 111 | &-right { 112 | text-align: right; 113 | } 114 | &-ellipsis { 115 | -webkit-line-clamp: 1; 116 | -webkit-box-orient: vertical; 117 | overflow: hidden; 118 | white-space: nowrap; 119 | text-overflow: ellipsis; 120 | } 121 | &-wrap { 122 | word-wrap: break-word; 123 | } 124 | &-nowrap { 125 | word-wrap: keep-all; 126 | white-space: nowrap; 127 | } 128 | // 大写字母 129 | &-up { 130 | text-transform: uppercase; 131 | } 132 | 133 | // 控制字体粗细 text-w{num} [3-7] , text-w5 134 | .loopFontWeight(@i) when (@i <=7) { 135 | &-w@{i} { 136 | font-weight: @i*100; 137 | } 138 | .loopFontWeight(@i+1); 139 | } 140 | .loopFontWeight(3); 141 | 142 | // 字体颜色 通过变量:@colors 遍历, text-{name} 143 | .loopTextColor(@i) when (@i > 0) { 144 | @name: extract(@colors, @i); 145 | // @val: extract(@color-val, @n); 146 | @nameV: 'color_@{name}'; 147 | &-@{name} { 148 | color: @@nameV; 149 | } 150 | .loopTextColor((@i - 1)); 151 | } 152 | .loopTextColor(length(@colors)); 153 | } 154 | 155 | // 字体大小 fs-{size} [10-60] 156 | .loopFontSize(@i) when (@i <=60) { 157 | .fs-@{i} { 158 | font-size: @i * 1px; 159 | } 160 | .loopFontSize(@i + 2); 161 | } 162 | .loopFontSize(10); 163 | 164 | // 盒模型 size:0-32(4 的整数倍) m-{size} ml-{size} mr-{size} mt-{size} mb-{size} 165 | .loopBox(@i) when (@i <=8) { 166 | @size: @i*4; 167 | @sizePx: @size*1px; 168 | .m-@{size} { 169 | margin: @sizePx; 170 | } 171 | .mt-@{size} { 172 | margin-top: @sizePx; 173 | } 174 | .ml-@{size} { 175 | margin-left: @sizePx; 176 | } 177 | .mr-@{size} { 178 | margin-right: @sizePx; 179 | } 180 | .mb-@{size} { 181 | margin-bottom: @sizePx; 182 | } 183 | .p-@{size} { 184 | padding: @sizePx; 185 | } 186 | .pt-@{size} { 187 | padding-top: @sizePx; 188 | } 189 | .pl-@{size} { 190 | padding-left: @sizePx; 191 | } 192 | .pr-@{size} { 193 | padding-right: @sizePx; 194 | } 195 | .pb-@{size} { 196 | padding-bottom: @sizePx; 197 | } 198 | .loopBox(@i+1); 199 | } 200 | .loopBox(0); 201 | 202 | .drag { 203 | -webkit-app-region: drag; 204 | } 205 | 206 | @scrollbar-size: 14px; 207 | @scrollbar-track-background-color: @color_background; 208 | @scrollbar-thumb-background-color: #c4c4c4; 209 | @scrollbar-thumb-hover-background-color: #9e9e9e; 210 | ::-webkit-scrollbar { 211 | height: @scrollbar-size; 212 | width: @scrollbar-size; 213 | } 214 | 215 | ::-webkit-scrollbar-thumb { 216 | border-radius: 10px; 217 | border: 3px solid @scrollbar-track-background-color; 218 | background-color: @scrollbar-thumb-background-color; 219 | } 220 | 221 | ::-webkit-scrollbar-track { 222 | // border-radius: 10px; 223 | background-color: @scrollbar-track-background-color; 224 | } 225 | 226 | ::-webkit-scrollbar-thumb:hover { 227 | background-color: @scrollbar-thumb-hover-background-color; 228 | } 229 | -------------------------------------------------------------------------------- /app/core/tools/window/create-window.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { BrowserWindow, BrowserWindowConstructorOptions } from 'electron' 3 | import { enable as enableRemote } from '@electron/remote/main' 4 | 5 | import { log } from '../log' 6 | import routes from '@/src/auto-routes' 7 | 8 | const { NODE_ENV, port, host } = process.env 9 | 10 | /** 创建新窗口相关选项 */ 11 | export interface CreateWindowOptions { 12 | /** 路由启动参数 */ 13 | params?: Record 14 | /** URL 启动参数 */ 15 | query?: Record 16 | /** BrowserWindow 选项 */ 17 | windowOptions?: BrowserWindowConstructorOptions 18 | /** 窗口启动参数 */ 19 | createConfig?: CreateConfig 20 | } 21 | 22 | /** 已创建的窗口列表 */ 23 | export const windowList: Map = new Map() 24 | 25 | /** 26 | * 通过 routes 中的 key(name) 得到 url 27 | * @param key 28 | */ 29 | export function getWindowUrl(key: RouteName, options: CreateWindowOptions = {}): string { 30 | let routePath = routes.get(key)?.path 31 | 32 | if (typeof routePath === 'string' && options.params) { 33 | routePath = routePath.replace(/\:([^\/]+)/g, (_, $1) => { 34 | return options.params?.[$1] 35 | }) 36 | } 37 | 38 | const query = options.query ? $tools.toSearch(options.query) : '' 39 | 40 | if (NODE_ENV === 'development') { 41 | return `http://${host}:${port}#${routePath}${query}` 42 | } else { 43 | return `file://${path.join(__dirname, '../renderer/index.html')}#${routePath}${query}` 44 | } 45 | } 46 | 47 | /** 48 | * 创建一个新窗口 49 | * @param key 50 | * @param options 51 | */ 52 | export function createWindow(key: RouteName, options: CreateWindowOptions = {}): Promise { 53 | return new Promise((resolve) => { 54 | const routeConfig: RouteConfig | AnyObj = routes.get(key) || {} 55 | 56 | const windowOptions: BrowserWindowConstructorOptions = { 57 | ...$tools.DEFAULT_WINDOW_OPTIONS, // 默认新窗口选项 58 | ...routeConfig.windowOptions, // routes 中的配置的window选项 59 | ...options.windowOptions, // 调用方法时传入的选项 60 | } 61 | 62 | const createConfig: CreateConfig = { 63 | ...$tools.DEFAULT_CREATE_CONFIG, 64 | ...routeConfig.createConfig, 65 | ...options.createConfig, 66 | } 67 | 68 | if (createConfig.showCustomTitlebar) { 69 | windowOptions.frame = false 70 | } 71 | 72 | let activeWin: BrowserWindow | boolean 73 | if (createConfig.single) { 74 | activeWin = activeWindow(key) 75 | if (activeWin) { 76 | resolve(activeWin) 77 | return activeWin 78 | } 79 | } 80 | 81 | const win = new BrowserWindow(windowOptions) 82 | 83 | const url = getWindowUrl(key, options) 84 | windowList.set(key, win) 85 | win.loadURL(url) 86 | 87 | if (createConfig.saveWindowBounds) { 88 | const { rect } = $tools.settings.windowBounds.get(key) || {} 89 | if (rect) win.setBounds(rect) 90 | // if (maximized) win.maximize() 91 | } 92 | 93 | if (createConfig.hideMenus) win.setMenuBarVisibility(false) 94 | if (createConfig.created) createConfig.created(win) 95 | 96 | enableRemote(win.webContents) 97 | 98 | win.webContents.on('dom-ready', () => { 99 | win.webContents.send('dom-ready', createConfig) 100 | }) 101 | 102 | win.webContents.on('did-finish-load', () => { 103 | if (createConfig.autoShow) { 104 | if (createConfig.delayToShow) { 105 | setTimeout(() => { 106 | win.show() 107 | }, createConfig.delayToShow) 108 | } else { 109 | win.show() 110 | } 111 | } 112 | resolve(win) 113 | }) 114 | 115 | win.once('ready-to-show', () => { 116 | if (createConfig.openDevTools) win.webContents.openDevTools() 117 | }) 118 | 119 | win.once('show', () => { 120 | log.info(`Window <${key}:${win.id}> url: ${url} opened.`) 121 | }) 122 | 123 | win.on('close', () => { 124 | if (createConfig.saveWindowBounds && win) { 125 | const maximized = win.isMaximized() 126 | const rect = maximized ? $tools.settings.windowBounds.get(key)?.rect : win.getBounds() 127 | $tools.settings.windowBounds.set(key, { rect, maximized }) 128 | } 129 | windowList.delete(key) 130 | log.info(`Window <${key}:${win.id}> closed.`) 131 | }) 132 | }) 133 | } 134 | 135 | /** 136 | * 激活一个已存在的窗口, 成功返回 BrowserWindow 失败返回 false 137 | * @param key 138 | */ 139 | export function activeWindow(key: RouteName): BrowserWindow | false { 140 | const win: BrowserWindow | undefined = windowList.get(key) 141 | 142 | if (win) { 143 | win.show() 144 | return win 145 | } else { 146 | return false 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /app/src/views/demo/demo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Button, Input, Spin, Card } from 'antd' 3 | 4 | import { withStore } from '@/core/store' 5 | 6 | interface DemoProps extends PageProps, StoreProps { 7 | count: StoreStates['count'] 8 | countAlias: StoreStates['count'] 9 | } 10 | 11 | declare interface DemoState { 12 | resData: Partial 13 | loading: boolean 14 | createWindowLoading: boolean 15 | asyncDispatchLoading: boolean 16 | } 17 | 18 | @withStore(['count', { countAlias: 'count' }]) 19 | export default class Demo extends React.Component { 20 | // state 初始化 21 | state: DemoState = { 22 | resData: {}, 23 | loading: false, 24 | createWindowLoading: false, 25 | asyncDispatchLoading: false, 26 | } 27 | 28 | // 构造函数 29 | constructor(props: DemoProps) { 30 | super(props) 31 | } 32 | 33 | componentDidMount(): void { 34 | console.log(this) 35 | } 36 | 37 | render(): JSX.Element { 38 | const { resData, loading, createWindowLoading, asyncDispatchLoading } = this.state 39 | const { count: reduxCount, countAlias } = this.props 40 | return ( 41 |
42 | 43 |

redux count : {reduxCount}

44 |

redux countAlias : {countAlias}

45 | 46 |
47 | 55 | 56 | 65 | 66 | 76 |
77 | 78 |

79 | Redux runs in the main process, which means it can be shared across all renderer processes. 80 |

81 | 82 | 85 |
86 | 87 | 88 | 89 |
90 | 93 | 94 | 97 | 98 | 101 |
102 | 103 | 104 |
105 |
106 |
107 | ) 108 | } 109 | 110 | asyncDispatch = (dispatch: Dispatch): Promise => { 111 | return new Promise((resolve) => { 112 | this.setState({ asyncDispatchLoading: true }) 113 | setTimeout(() => { 114 | const { count } = this.props 115 | dispatch({ type: 'ACTION_ADD_COUNT', data: count + 1 }) 116 | this.setState({ asyncDispatchLoading: false }) 117 | resolve() 118 | }, 1000) 119 | }) 120 | } 121 | 122 | openNewWindow = (): void => { 123 | this.setState({ createWindowLoading: true }) 124 | $tools.createWindow('Demo').finally(() => this.setState({ createWindowLoading: false })) 125 | } 126 | 127 | requestTest(): void { 128 | this.setState({ loading: true }) 129 | $api 130 | .queryTestInfo({}) 131 | .then((resData) => { 132 | this.setState({ resData }) 133 | }) 134 | .finally(() => this.setState({ loading: false })) 135 | } 136 | 137 | requestTestError(): void { 138 | this.setState({ loading: true }) 139 | $api 140 | .queryTestInfoError({}) 141 | .catch((resData) => { 142 | this.setState({ resData }) 143 | }) 144 | .finally(() => this.setState({ loading: false })) 145 | } 146 | 147 | requestTestErrorModal(): void { 148 | this.setState({ loading: true }) 149 | $api 150 | .queryTestInfoError({}, { errorType: 'modal' }) 151 | .catch((resData) => { 152 | this.setState({ resData }) 153 | }) 154 | .finally(() => this.setState({ loading: false })) 155 | } 156 | } // class Demo end 157 | -------------------------------------------------------------------------------- /app/src/views/log-viewer/log-viewer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Menu, shell } from '@electron/remote' 3 | import { message } from 'antd' 4 | import clsx from 'clsx' 5 | 6 | import { LogFile, LogReader, LogDetailLine } from './log-reader' 7 | 8 | import './log-viewer.less' 9 | 10 | interface Props extends PageProps {} 11 | 12 | interface State { 13 | logFiles: LogFile[] 14 | activeFile: LogFile 15 | logDetail: LogDetailLine[] 16 | } 17 | 18 | const TYPE_COLORS = { 19 | info: 'text-info', 20 | warn: 'text-warn', 21 | error: 'text-error', 22 | } 23 | 24 | class LogViewerClass extends React.Component { 25 | private logReader = new LogReader() 26 | private logDetailRef = React.createRef() 27 | 28 | readonly state: State = { 29 | logFiles: this.logReader.getLogFiles(), 30 | activeFile: { absolutePath: '', fileName: '', name: '' }, 31 | logDetail: [], 32 | } 33 | 34 | private fileListMenu 35 | private activeMenuFileItem: LogFile | undefined 36 | 37 | constructor(props: Props) { 38 | super(props) 39 | this.state.activeFile = this.state.logFiles[0] 40 | this.fileListMenu = Menu.buildFromTemplate([ 41 | { label: '打开日志文件', click: this.openLogFile }, 42 | { label: '打开日志所在目录', click: this.openLogFileDir }, 43 | { type: 'separator' }, 44 | { label: '删除日志文件', click: this.deleteLogFile }, 45 | ]) 46 | } 47 | 48 | componentDidMount() { 49 | const { logFiles, activeFile } = this.state 50 | this.watchingLogFile(activeFile || logFiles[0]) 51 | } 52 | 53 | openLogFile = () => { 54 | console.log(this.activeMenuFileItem) 55 | if (!this.activeMenuFileItem) return 56 | shell.openPath(this.activeMenuFileItem.absolutePath) 57 | } 58 | 59 | openLogFileDir = () => { 60 | if (!this.activeMenuFileItem) return 61 | shell.showItemInFolder(this.activeMenuFileItem.absolutePath) 62 | } 63 | 64 | deleteLogFile = () => { 65 | if (!this.activeMenuFileItem) return 66 | shell.openPath(this.activeMenuFileItem.absolutePath) 67 | console.log(this.activeMenuFileItem) 68 | } 69 | 70 | onFileListContextMenu = (fileItem: LogFile) => { 71 | this.activeMenuFileItem = fileItem 72 | this.fileListMenu.popup({ 73 | window: this.props.currentWindow, 74 | callback: () => { 75 | this.activeMenuFileItem = undefined 76 | }, 77 | }) 78 | } 79 | 80 | refresh = () => { 81 | const logFiles = this.logReader.getLogFiles() 82 | const activeFile = logFiles[0] 83 | this.setState({ logFiles, activeFile }) 84 | this.watchingLogFile(activeFile) 85 | } 86 | 87 | render(): JSX.Element { 88 | const { logFiles, activeFile, logDetail } = this.state 89 | return ( 90 |
91 |
92 |
    93 | {logFiles.map((v) => ( 94 |
  • { 98 | if (activeFile.name === v.name) return 99 | this.logReader.resetDetailHistory() 100 | this.watchingLogFile(v) 101 | }} 102 | onContextMenu={() => this.onFileListContextMenu(v)} 103 | > 104 | {v.name} 105 |
  • 106 | ))} 107 |
108 |
109 | 刷新 110 |
111 |
112 | 113 | {logDetail.map(this.renderLogLine)} 114 | 115 |
116 | ) 117 | } 118 | 119 | /** 渲染日志行 */ 120 | renderLogLine = (v: LogDetailLine, i: number): JSX.Element => { 121 | const color = TYPE_COLORS[v.type] 122 | return ( 123 |

124 | {v.date && ( 125 | 126 | [{v.date}] 127 | 128 | )} 129 | {v.type && ( 130 | 131 | [{v.type}] 132 | 133 | )} 134 | {v.env && ( 135 | 136 | [{v.env}] 137 | 138 | )} 139 |  {v.message} 140 |

141 | ) 142 | } 143 | 144 | /** 打开并监听日志文件 */ 145 | watchingLogFile(file: LogFile): void { 146 | const hide = message.loading('正在加载日志列表...', 0) 147 | setTimeout(() => { 148 | this.setState({ activeFile: file, logDetail: [] }, () => { 149 | this.logReader.watchingLogFile(file, (detail) => { 150 | this.setState({ logDetail: this.state.logDetail.concat(detail) }, () => { 151 | const { current: detailDom } = this.logDetailRef 152 | if (detailDom) { 153 | detailDom.scrollTop = detailDom.scrollHeight 154 | } 155 | }) 156 | }) 157 | }) 158 | hide() 159 | }, 200) 160 | } 161 | 162 | componentWillUnmount() { 163 | this.logReader.resetDetailHistory() 164 | this.logReader.closeWatcher() 165 | } 166 | } // class LogViewer end 167 | 168 | export default LogViewerClass 169 | --------------------------------------------------------------------------------