快乐的梦鱼 hai 2 semanas
achega
27178d10a1

+ 25 - 0
.gitignore

@@ -0,0 +1,25 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+release/

+ 3 - 0
.vscode/extensions.json

@@ -0,0 +1,3 @@
+{
+  "recommendations": ["Vue.volar"]
+}

+ 18 - 0
README.md

@@ -0,0 +1,18 @@
+# Vue 3 + TypeScript + Vite
+
+This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
+
+## Recommended IDE Setup
+
+- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
+
+## Type Support For `.vue` Imports in TS
+
+TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
+
+If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
+
+1. Disable the built-in TypeScript Extension
+   1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
+   2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
+2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.

+ 32 - 0
apps.json

@@ -0,0 +1,32 @@
+[
+  {
+    "id": 1,
+    "title": "闽南文化驾驶舱",
+    "keepScreenSize": true,
+    "aspectRatio": "16/9",
+    "openType": "iframe",
+    "url": "https://mn.wenlvti.net/test/#/"
+  },
+  {
+    "id": 2,
+    "title": "闽南文化官网",
+    "openType": "iframe",
+    "url": "https://minnan.wenlvti.net/"
+  },
+  {
+    "id": 3,
+    "title": "文保中心官网",
+    "openType": "iframe",
+    "url": "https://xmswhycbhzx.cn/"
+  },
+  {
+    "id": 4,
+    "title": "文物管家后台",
+    "url": "https://wwgj.wenlvti.net/hTurbPWtgS.php"
+  },
+  {
+    "id": 5,
+    "title": "文物管家后台22",
+    "url": "https://wwgj.wenlvti.net/hTurbPWtgS.php"
+  }
+]

+ 306 - 0
dist-electron/main.js

@@ -0,0 +1,306 @@
+import { Menu, app, BrowserWindow, WebContentsView, ipcMain } from "electron";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+import fs from "node:fs";
+const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
+process.env.APP_ROOT = path.join(__dirname$1, "..");
+const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
+const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
+const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
+process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST;
+let mainWindow;
+let childView;
+let isSideOpen = true;
+let childViewAspectRatio = 0;
+const SIDE_WIDTH = 250;
+const EXPAND_VIEW_SIZE = 40;
+const LOADING_VIEW_WIDTH = 150;
+const LOADING_VIEW_HEIGHT = 100;
+function loadWindowPage(window, subPath) {
+  if (VITE_DEV_SERVER_URL) {
+    window.loadURL(VITE_DEV_SERVER_URL + "#" + subPath);
+  } else {
+    window.loadFile(path.join(RENDERER_DIST, "index.html") + "#" + subPath);
+  }
+}
+function loadViewUrl(view, subPath) {
+  if (!mainWindow) {
+    return;
+  }
+  if (VITE_DEV_SERVER_URL) {
+    view.webContents.loadURL(VITE_DEV_SERVER_URL + "#" + subPath);
+  } else {
+    view.webContents.loadFile(path.join(RENDERER_DIST, "index.html") + "#" + subPath);
+  }
+}
+Menu.setApplicationMenu(null);
+function createWindow() {
+  mainWindow = new BrowserWindow({
+    icon: path.join(process.env.VITE_PUBLIC, "icon.ico"),
+    webPreferences: {
+      preload: path.join(__dirname$1, "preload.mjs"),
+      contextIsolation: true,
+      allowRunningInsecureContent: true,
+      partition: "persist:minnan-demo-app"
+    },
+    width: 1200,
+    height: 800
+  });
+  childView = new WebContentsView({
+    webPreferences: {
+      partition: "persist:minnan-demo-app",
+      allowRunningInsecureContent: true,
+      enableBlinkFeatures: "PasswordManager"
+    }
+  });
+  const expandButtonView = new WebContentsView({
+    webPreferences: {
+      preload: path.join(__dirname$1, "preload.mjs"),
+      contextIsolation: true
+    }
+  });
+  const loadingView = new BrowserWindow({
+    skipTaskbar: true,
+    width: LOADING_VIEW_WIDTH,
+    height: LOADING_VIEW_HEIGHT,
+    parent: mainWindow,
+    thickFrame: true,
+    titleBarStyle: "hidden",
+    webPreferences: {
+      preload: path.join(__dirname$1, "preload.mjs"),
+      contextIsolation: true
+    }
+  });
+  mainWindow.contentView.addChildView(childView);
+  mainWindow.contentView.addChildView(expandButtonView);
+  expandButtonView.setVisible(false);
+  childView.webContents.on("did-start-loading", () => {
+    loadingView.show();
+  });
+  childView.webContents.on("did-stop-loading", () => {
+    loadingView.hide();
+  });
+  childView.webContents.on("did-fail-load", (_, errorCode, errorDescription) => {
+    loadingView.hide();
+    loadWindowPage(loadingView, "/error?code=" + errorCode + "&message=" + errorDescription);
+  });
+  function updateChildWindowBounds() {
+    const bounds = mainWindow.getBounds();
+    expandButtonView.setBounds({
+      x: 0,
+      y: bounds.height - 100,
+      width: EXPAND_VIEW_SIZE,
+      height: EXPAND_VIEW_SIZE
+    });
+    loadingView.setBounds({
+      x: bounds.x + (bounds.width - LOADING_VIEW_WIDTH) / 2,
+      y: bounds.y + (bounds.height - LOADING_VIEW_HEIGHT) / 2,
+      width: LOADING_VIEW_WIDTH,
+      height: LOADING_VIEW_HEIGHT
+    });
+    if (childViewAspectRatio) {
+      const rect = {
+        x: isSideOpen ? SIDE_WIDTH : 0,
+        y: 0,
+        width: bounds.width - (isSideOpen ? SIDE_WIDTH : 0),
+        height: bounds.height
+      };
+      const availableRatio = rect.width / rect.height;
+      let childWidth, childHeight;
+      if (availableRatio > childViewAspectRatio) {
+        childHeight = rect.height;
+        childWidth = childHeight * childViewAspectRatio;
+      } else {
+        childWidth = rect.width;
+        childHeight = childWidth / childViewAspectRatio;
+      }
+      const childX = rect.x + (rect.width - childWidth) / 2;
+      const childY = rect.y + (rect.height - childHeight) / 2;
+      childView.setBounds({
+        x: Math.round(childX),
+        y: Math.round(childY),
+        width: Math.round(childWidth),
+        height: Math.round(childHeight)
+      });
+    } else {
+      childView.setBounds({
+        x: isSideOpen ? SIDE_WIDTH : 0,
+        y: 0,
+        width: bounds.width - (isSideOpen ? SIDE_WIDTH : 0),
+        height: bounds.height
+      });
+    }
+  }
+  loadWindowPage(mainWindow, "/");
+  loadWindowPage(loadingView, "/loading");
+  loadViewUrl(childView, "/hello");
+  loadViewUrl(expandButtonView, "/expand");
+  updateChildWindowBounds();
+  mainWindow.on("resize", () => {
+    updateChildWindowBounds();
+  });
+  mainWindow.webContents.on("did-finish-load", () => {
+    mainWindow == null ? void 0 : mainWindow.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
+  });
+  function handleWindowFullScreenKeys(event, input) {
+    if (input.key === "F11" && input.type === "keyDown") {
+      event.preventDefault();
+      mainWindow == null ? void 0 : mainWindow.setFullScreen(!(mainWindow == null ? void 0 : mainWindow.isFullScreen()));
+    } else if (input.key === "F12" && input.type === "keyDown") {
+      event.preventDefault();
+      mainWindow == null ? void 0 : mainWindow.webContents.toggleDevTools();
+    }
+  }
+  mainWindow.webContents.on("before-input-event", (event, input) => {
+    handleWindowFullScreenKeys(event, input);
+  });
+  childView.webContents.on("before-input-event", (event, input) => {
+    handleWindowFullScreenKeys(event, input);
+  });
+  ipcMain.on("exit-app", () => {
+    app.quit();
+  });
+  ipcMain.on("toggle-fullscreen", (_event, isFullScreen) => {
+    if (mainWindow) {
+      if (isFullScreen) {
+        mainWindow.setFullScreen(true);
+      } else {
+        mainWindow.setFullScreen(false);
+      }
+    }
+  });
+  ipcMain.on("load-child-url", (_event, url, aspectRatio) => {
+    if (childView)
+      childView.webContents.loadURL(url);
+    childViewAspectRatio = aspectRatio;
+    updateChildWindowBounds();
+  });
+  ipcMain.on("toggle-child-side", (_event, value) => {
+    isSideOpen = value;
+    expandButtonView.setVisible(!isSideOpen);
+    mainWindow == null ? void 0 : mainWindow.webContents.send("main-side-state-changed", isSideOpen);
+    updateChildWindowBounds();
+  });
+  ipcMain.handle("get-app-path", () => {
+    return app.getAppPath();
+  });
+  ipcMain.on("open-window", (_event, url) => {
+    const newWin = new BrowserWindow({
+      icon: path.join(process.env.VITE_PUBLIC, "icon.ico"),
+      webPreferences: {
+        preload: path.join(__dirname$1, "preload.mjs"),
+        contextIsolation: true,
+        allowRunningInsecureContent: true
+      },
+      fullscreenable: true,
+      maximizable: true,
+      width: 1200,
+      height: 800
+    });
+    newWin.loadURL(url);
+    newWin.maximize();
+    newWin.webContents.on("before-input-event", (event, input) => {
+      if (input.key === "F11" && input.type === "keyDown") {
+        event.preventDefault();
+        newWin == null ? void 0 : newWin.setFullScreen(!(newWin == null ? void 0 : newWin.fullScreen));
+      } else if (input.key === "F12" && input.type === "keyDown") {
+        event.preventDefault();
+        newWin == null ? void 0 : newWin.webContents.toggleDevTools();
+      }
+    });
+  });
+  ipcMain.handle("load-apps-json", async () => {
+    const appPath = process.cwd();
+    const appsJsonPath = path.join(appPath, "apps.json");
+    try {
+      if (fs.existsSync(appsJsonPath)) {
+        const data = fs.readFileSync(appsJsonPath, "utf8");
+        return JSON.parse(data);
+      } else {
+        const devAppsJsonPath = path.join(process.env.VITE_PUBLIC || "", "apps.json");
+        if (fs.existsSync(devAppsJsonPath)) {
+          const data = fs.readFileSync(devAppsJsonPath, "utf8");
+          return JSON.parse(data);
+        }
+        throw new Error("apps.json not found");
+      }
+    } catch (error) {
+      console.error("Error loading apps.json:", error);
+      throw error;
+    }
+  });
+  ipcMain.handle("load-default-apps-json", async () => {
+    const devAppsJsonPath = path.join(process.env.VITE_PUBLIC || "", "apps.json");
+    if (fs.existsSync(devAppsJsonPath)) {
+      const data = fs.readFileSync(devAppsJsonPath, "utf8");
+      return JSON.parse(data);
+    }
+    throw new Error("apps.json not found");
+  });
+  ipcMain.on("show-config", () => {
+    const configWindow = new BrowserWindow({
+      icon: path.join(process.env.VITE_PUBLIC, "icon.ico"),
+      webPreferences: {
+        preload: path.join(__dirname$1, "preload.mjs"),
+        contextIsolation: true,
+        allowRunningInsecureContent: true
+      },
+      title: "列表配置",
+      parent: mainWindow || void 0,
+      skipTaskbar: true,
+      minimizable: false,
+      maximizable: false,
+      modal: true,
+      width: 800,
+      height: 600
+    });
+    loadWindowPage(configWindow, "/config");
+  });
+  ipcMain.on("save-apps-json", (_event, appsJson) => {
+    const appPath = process.cwd();
+    const appsJsonPath = path.join(appPath, "apps.json");
+    try {
+      fs.writeFileSync(appsJsonPath, appsJson);
+      mainWindow == null ? void 0 : mainWindow.webContents.send("main-config-changed");
+    } catch (error) {
+      console.error("Error saving apps.json:", error);
+      throw error;
+    }
+  });
+  ipcMain.on("show-about", () => {
+    const aboutWindow = new BrowserWindow({
+      icon: path.join(process.env.VITE_PUBLIC, "icon.ico"),
+      webPreferences: {
+        preload: path.join(__dirname$1, "preload.mjs"),
+        contextIsolation: true,
+        allowRunningInsecureContent: true
+      },
+      parent: mainWindow || void 0,
+      title: "关于程序",
+      skipTaskbar: true,
+      maximizable: false,
+      minimizable: false,
+      modal: true,
+      width: 450,
+      height: 470
+    });
+    loadWindowPage(aboutWindow, "/about");
+  });
+}
+app.on("window-all-closed", () => {
+  if (process.platform !== "darwin") {
+    app.quit();
+    mainWindow = null;
+  }
+});
+app.on("activate", () => {
+  if (BrowserWindow.getAllWindows().length === 0) {
+    createWindow();
+  }
+});
+app.whenReady().then(createWindow);
+export {
+  MAIN_DIST,
+  RENDERER_DIST,
+  VITE_DEV_SERVER_URL
+};

+ 35 - 0
dist-electron/preload.mjs

@@ -0,0 +1,35 @@
+"use strict";
+const electron = require("electron");
+electron.contextBridge.exposeInMainWorld("ipcRenderer", {
+  on(...args) {
+    const [channel, listener] = args;
+    return electron.ipcRenderer.on(channel, (event, ...args2) => listener(event, ...args2));
+  },
+  off(...args) {
+    const [channel, ...omit] = args;
+    return electron.ipcRenderer.off(channel, ...omit);
+  },
+  send(...args) {
+    const [channel, ...omit] = args;
+    return electron.ipcRenderer.send(channel, ...omit);
+  },
+  invoke(...args) {
+    const [channel, ...omit] = args;
+    return electron.ipcRenderer.invoke(channel, ...omit);
+  }
+  // You can expose other APTs you need here.
+  // ...
+});
+electron.contextBridge.exposeInMainWorld("electronAPI", {
+  exit: () => electron.ipcRenderer.send("exit-app"),
+  toggleFullScreen: (isFullScreen) => electron.ipcRenderer.send("toggle-fullscreen", isFullScreen),
+  toggleDevTools: () => electron.ipcRenderer.send("toggle-dev-tools"),
+  loadAppsJson: () => electron.ipcRenderer.invoke("load-apps-json"),
+  loadDefaultAppsJson: () => electron.ipcRenderer.invoke("load-default-apps-json"),
+  saveAppsJson: (appsJson) => electron.ipcRenderer.send("save-apps-json", appsJson),
+  openWindow: (url) => electron.ipcRenderer.send("open-window", url),
+  loadChildUrl: (url, aspectRatio) => electron.ipcRenderer.send("load-child-url", url, aspectRatio),
+  toggleChildSide: (isSideOen) => electron.ipcRenderer.send("toggle-child-side", isSideOen),
+  showConfig: () => electron.ipcRenderer.send("show-config"),
+  showAbout: () => electron.ipcRenderer.send("show-about")
+});

+ 44 - 0
electron-builder.json5

@@ -0,0 +1,44 @@
+// @see - https://www.electron.build/configuration/configuration
+{
+  "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
+  "appId": "MinnanDemo",
+  "asar": true,
+  "productName": "演示程序",
+  "icon": "public/icon.ico",
+  "directories": {
+    "output": "release/${version}"
+  },
+  "files": [
+    "dist",
+    "dist-electron"
+  ],
+  "mac": {
+    "target": [
+      "dmg"
+    ],
+    "artifactName": "${productName}-Mac-${version}-Installer.${ext}"
+  },
+  "win": {
+    "target": [
+      {
+        "target": "nsis",
+        "arch": [
+          "x64"
+        ]
+      }
+    ],
+    "artifactName": "${productName}-Windows-${version}-Setup.${ext}"
+  },
+  "nsis": {
+    "oneClick": false,
+    "perMachine": false,
+    "allowToChangeInstallationDirectory": true,
+    "deleteAppDataOnUninstall": false
+  },
+  "linux": {
+    "target": [
+      "AppImage"
+    ],
+    "artifactName": "${productName}-Linux-${version}.${ext}"
+  }
+}

+ 40 - 0
electron/electron-env.d.ts

@@ -0,0 +1,40 @@
+/// <reference types="vite-plugin-electron/electron-env" />
+
+declare namespace NodeJS {
+  interface ProcessEnv {
+    /**
+     * The built directory structure
+     *
+     * ```tree
+     * ├─┬─┬ dist
+     * │ │ └── index.html
+     * │ │
+     * │ ├─┬ dist-electron
+     * │ │ ├── main.js
+     * │ │ └── preload.js
+     * │
+     * ```
+     */
+    APP_ROOT: string
+    /** /dist/ or /public/ */
+    VITE_PUBLIC: string
+  }
+}
+
+// Used in Renderer process, expose in `preload.ts`
+interface Window {
+  ipcRenderer: import('electron').IpcRenderer
+  electronAPI: {
+    exit: () => void
+    toggleFullScreen: (isFullScreen: boolean) => void
+    toggleDevTools: () => void
+    loadAppsJson: () => Promise<any>
+    loadDefaultAppsJson: () => Promise<any>
+    saveAppsJson: (appsJson: string) => void
+    openWindow: (url: string) => void
+    loadChildUrl: (url: string, aspectRatio: number) => void
+    toggleChildSide: (isSideOen: boolean) => void
+    showConfig: () => void
+    showAbout: () => void
+  }
+}

+ 360 - 0
electron/main.ts

@@ -0,0 +1,360 @@
+import { app, BrowserWindow, Event, Input, ipcMain, Menu, WebContentsView } from 'electron'
+import { fileURLToPath } from 'node:url'
+import path from 'node:path'
+import fs from 'node:fs'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+
+// The built directory structure
+//
+// ├─┬─┬ dist
+// │ │ └── index.html
+// │ │
+// │ ├─┬ dist-electron
+// │ │ ├── main.js
+// │ │ └── preload.mjs
+// │
+process.env.APP_ROOT = path.join(__dirname, '..')
+
+// 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
+export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
+export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron')
+export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist')
+
+process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST
+
+let mainWindow: BrowserWindow | null
+let childView: WebContentsView | null
+
+let isSideOpen = true
+let childViewAspectRatio = 0; 
+const SIDE_WIDTH = 250
+const EXPAND_VIEW_SIZE = 40
+const LOADING_VIEW_WIDTH = 150
+const LOADING_VIEW_HEIGHT = 100
+
+function loadWindowPage(window: BrowserWindow, subPath: string) {
+  if (VITE_DEV_SERVER_URL) {
+    window.loadURL(VITE_DEV_SERVER_URL + "#" + subPath)
+  } else {
+    window.loadFile(path.join(RENDERER_DIST, 'index.html') + "#" + subPath)
+  }
+}
+function loadViewUrl(view: WebContentsView, subPath: string) {
+  if (!mainWindow) {
+    return
+  }
+  if (VITE_DEV_SERVER_URL) {
+    view.webContents.loadURL(VITE_DEV_SERVER_URL + "#" + subPath)
+  } else {
+    view.webContents.loadFile(path.join(RENDERER_DIST, 'index.html') + "#" + subPath)
+  }
+}
+
+Menu.setApplicationMenu(null)
+
+function createWindow() {
+  mainWindow = new BrowserWindow({
+    icon: path.join(process.env.VITE_PUBLIC, 'icon.ico'),
+    webPreferences: {
+      preload: path.join(__dirname, 'preload.mjs'),
+      contextIsolation: true,
+      allowRunningInsecureContent: true,
+      partition: 'persist:minnan-demo-app',
+    },
+    width: 1200,
+    height: 800,
+  })
+
+  childView = new WebContentsView({
+    webPreferences: {
+      partition: 'persist:minnan-demo-app',
+      allowRunningInsecureContent: true,
+      enableBlinkFeatures: 'PasswordManager',
+    },
+  })
+  const expandButtonView = new WebContentsView({
+    webPreferences: {
+      preload: path.join(__dirname, 'preload.mjs'),
+      contextIsolation: true,
+    },
+  })
+  const loadingView = new BrowserWindow({
+    skipTaskbar: true,
+    width: LOADING_VIEW_WIDTH,
+    height: LOADING_VIEW_HEIGHT,
+    parent: mainWindow,
+    thickFrame: true,
+    titleBarStyle: 'hidden',
+    webPreferences: {
+      preload: path.join(__dirname, 'preload.mjs'),
+      contextIsolation: true,
+    },
+  })
+  mainWindow.contentView.addChildView(childView)
+  mainWindow.contentView.addChildView(expandButtonView)
+  expandButtonView.setVisible(false)
+
+  childView.webContents.on('did-start-loading', () => {
+    loadingView.show()
+  });
+  childView.webContents.on('did-stop-loading', () => {
+    loadingView.hide()
+  });
+  childView.webContents.on('did-fail-load', (_, errorCode, errorDescription) => {
+    loadingView.hide()
+    loadWindowPage(loadingView, '/error?code=' + errorCode + '&message=' + errorDescription);
+  });
+
+  function updateChildWindowBounds() {
+    const bounds = mainWindow!.getBounds();
+    expandButtonView.setBounds({ 
+      x: 0, 
+      y: bounds.height - 100, 
+      width: EXPAND_VIEW_SIZE, 
+      height: EXPAND_VIEW_SIZE 
+    })
+    loadingView.setBounds({ 
+      x: bounds.x + (bounds.width - LOADING_VIEW_WIDTH) / 2, 
+      y: bounds.y + (bounds.height - LOADING_VIEW_HEIGHT) / 2, 
+      width: LOADING_VIEW_WIDTH, 
+      height: LOADING_VIEW_HEIGHT 
+    })
+    if (childViewAspectRatio) {
+      // 保持子页纵横比
+      const rect = {
+        x: isSideOpen ? SIDE_WIDTH : 0, 
+        y: 0, 
+        width: bounds.width - (isSideOpen ? SIDE_WIDTH : 0), 
+        height: bounds.height 
+      };
+      
+      // 计算子窗口的最佳尺寸(contain模式)
+      const availableRatio = rect.width / rect.height;
+      let childWidth, childHeight;
+      
+      if (availableRatio > childViewAspectRatio) {
+        // 高度受限制
+        childHeight = rect.height;
+        childWidth = childHeight * childViewAspectRatio;
+      } else {
+        // 宽度受限制
+        childWidth = rect.width;
+        childHeight = childWidth / childViewAspectRatio;
+      }
+      
+      // 计算居中位置
+      const childX = rect.x + (rect.width - childWidth) / 2;
+      const childY = rect.y + (rect.height - childHeight) / 2;
+      
+      // 应用到子窗口
+      childView!.setBounds({ 
+        x: Math.round(childX), 
+        y: Math.round(childY), 
+        width: Math.round(childWidth), 
+        height: Math.round(childHeight) 
+      });
+    } else {
+      childView!.setBounds({ 
+        x: isSideOpen ? SIDE_WIDTH : 0, 
+        y: 0, 
+        width: bounds.width - (isSideOpen ? SIDE_WIDTH : 0), 
+        height: bounds.height 
+      });
+    }
+  }
+
+  loadWindowPage(mainWindow, '/');
+  loadWindowPage(loadingView, '/loading');
+  loadViewUrl(childView, '/hello');
+  loadViewUrl(expandButtonView, '/expand');
+  updateChildWindowBounds();
+
+  mainWindow.on('resize', () => {
+    updateChildWindowBounds()
+  })
+  mainWindow.webContents.on('did-finish-load', () => {
+    mainWindow?.webContents.send('main-process-message', (new Date).toLocaleString())
+  })
+
+  function handleWindowFullScreenKeys(event: Event, input: Input) {
+    if (input.key === 'F11' && input.type === 'keyDown') {
+      event.preventDefault();
+      mainWindow?.setFullScreen(!mainWindow?.isFullScreen())
+    } else if (input.key === 'F12' && input.type === 'keyDown') {
+      event.preventDefault();
+      mainWindow?.webContents.toggleDevTools();
+    }
+  }
+
+  // 添加F11全屏切换功能
+  mainWindow.webContents.on('before-input-event', (event, input) => {
+    handleWindowFullScreenKeys(event, input)
+  })
+  childView.webContents.on('before-input-event', (event, input) => {
+    handleWindowFullScreenKeys(event, input)
+  })
+  // 处理退出应用事件
+  ipcMain.on('exit-app', () => {
+    app.quit()
+  })
+  // 处理全屏切换事件
+  ipcMain.on('toggle-fullscreen', (_event, isFullScreen: boolean) => {
+    if (mainWindow) {
+      if (isFullScreen) {
+        mainWindow.setFullScreen(true)
+      } else {
+        mainWindow.setFullScreen(false)
+      }
+    }
+  })
+  // 加载子页URL
+  ipcMain.on('load-child-url', (_event, url: string, aspectRatio: number) => {
+    if (childView)
+      childView.webContents.loadURL(url)
+    childViewAspectRatio = aspectRatio
+    updateChildWindowBounds()
+  })
+  // 子页侧边栏开关
+  ipcMain.on('toggle-child-side', (_event, value: boolean) => {
+    isSideOpen = value
+    expandButtonView.setVisible(!isSideOpen)
+    mainWindow?.webContents.send('main-side-state-changed', isSideOpen)
+    updateChildWindowBounds()
+  })
+  // 处理获取应用路径事件
+  ipcMain.handle('get-app-path', () => {
+    return app.getAppPath()
+  })
+  // 处理打开窗口事件
+  ipcMain.on('open-window', (_event, url: string) => {
+    const newWin = new BrowserWindow({
+      icon: path.join(process.env.VITE_PUBLIC, 'icon.ico'),
+      webPreferences: {
+        preload: path.join(__dirname, 'preload.mjs'),
+        contextIsolation: true,
+        allowRunningInsecureContent: true,
+      },
+      fullscreenable: true,
+      maximizable: true,
+      width: 1200,
+      height: 800,
+    })
+    newWin.loadURL(url)
+    newWin.maximize();
+    // 添加F11全屏切换功能
+    newWin.webContents.on('before-input-event', (event, input) => {
+      if (input.key === 'F11' && input.type === 'keyDown') {
+        event.preventDefault();
+        newWin?.setFullScreen(!newWin?.fullScreen)
+      } else if (input.key === 'F12' && input.type === 'keyDown') {
+        event.preventDefault();
+        newWin?.webContents.toggleDevTools();
+      }
+    })
+  })
+  // 处理加载apps.json事件
+  ipcMain.handle('load-apps-json', async () => {
+    const appPath = process.cwd()
+    const appsJsonPath = path.join(appPath, 'apps.json')
+
+    try {
+      if (fs.existsSync(appsJsonPath)) {
+        const data = fs.readFileSync(appsJsonPath, 'utf8')
+        return JSON.parse(data)
+      } else {
+        // 开发环境下回退到public目录
+        const devAppsJsonPath = path.join(process.env.VITE_PUBLIC || '', 'apps.json')
+        if (fs.existsSync(devAppsJsonPath)) {
+          const data = fs.readFileSync(devAppsJsonPath, 'utf8')
+          return JSON.parse(data)
+        }
+        throw new Error('apps.json not found')
+      }
+    } catch (error) {
+      console.error('Error loading apps.json:', error)
+      throw error
+    }
+  })
+  // 处理加载默认apps.json事件
+  ipcMain.handle('load-default-apps-json', async () => {
+    const devAppsJsonPath = path.join(process.env.VITE_PUBLIC || '', 'apps.json')
+    if (fs.existsSync(devAppsJsonPath)) {
+      const data = fs.readFileSync(devAppsJsonPath, 'utf8')
+      return JSON.parse(data)
+    }
+    throw new Error('apps.json not found')
+  })
+  // 处理显示配置窗口事件
+  ipcMain.on('show-config', () => {
+    const configWindow = new BrowserWindow({
+      icon: path.join(process.env.VITE_PUBLIC, 'icon.ico'),
+      webPreferences: {
+        preload: path.join(__dirname, 'preload.mjs'),
+        contextIsolation: true,
+        allowRunningInsecureContent: true,
+      },
+      title: '列表配置',
+      parent: mainWindow || undefined,
+      skipTaskbar: true,
+      minimizable: false,
+      maximizable: false,
+      modal: true,
+      width: 800,
+      height: 600,
+    })
+    loadWindowPage(configWindow, '/config')
+  });
+  // 处理保存apps.json事件
+  ipcMain.on('save-apps-json', (_event, appsJson: string) => {
+    const appPath = process.cwd()
+    const appsJsonPath = path.join(appPath, 'apps.json')
+    try {
+      fs.writeFileSync(appsJsonPath, appsJson)
+      mainWindow?.webContents.send('main-config-changed')
+    } catch (error) {
+      console.error('Error saving apps.json:', error)
+      throw error
+    }
+  })
+  // 处理显示关于窗口事件
+  ipcMain.on('show-about', () => {
+    const aboutWindow = new BrowserWindow({
+      icon: path.join(process.env.VITE_PUBLIC, 'icon.ico'),
+      webPreferences: {
+        preload: path.join(__dirname, 'preload.mjs'),
+        contextIsolation: true,
+        allowRunningInsecureContent: true,
+      },
+      parent: mainWindow || undefined,
+      title: '关于程序',
+      skipTaskbar: true,
+      maximizable: false,
+      minimizable: false,
+      modal: true,
+      width: 450,
+      height: 470,
+    })
+    loadWindowPage(aboutWindow, '/about')
+  });
+
+}
+
+// Quit when all mainWindowdows are closed, except on macOS. There, it's common
+// for applications and their menu bar to stay active until the user quits
+// explicitly with Cmd + Q.
+app.on('window-all-closed', () => {
+  if (process.platform !== 'darwin') {
+    app.quit()
+    mainWindow = null
+  }
+})
+app.on('activate', () => {
+  // On OS X it's common to re-create a mainWindowdow in the app when the
+  // dock icon is clicked and there are no other mainWindowdows open.
+  if (BrowserWindow.getAllWindows().length === 0) {
+    createWindow()
+  }
+})
+
+app.whenReady().then(createWindow)

+ 39 - 0
electron/preload.ts

@@ -0,0 +1,39 @@
+import { ipcRenderer, contextBridge } from 'electron'
+
+// --------- Expose some API to the Renderer process ---------
+contextBridge.exposeInMainWorld('ipcRenderer', {
+  on(...args: Parameters<typeof ipcRenderer.on>) {
+    const [channel, listener] = args
+    return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args))
+  },
+  off(...args: Parameters<typeof ipcRenderer.off>) {
+    const [channel, ...omit] = args
+    return ipcRenderer.off(channel, ...omit)
+  },
+  send(...args: Parameters<typeof ipcRenderer.send>) {
+    const [channel, ...omit] = args
+    return ipcRenderer.send(channel, ...omit)
+  },
+  invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
+    const [channel, ...omit] = args
+    return ipcRenderer.invoke(channel, ...omit)
+  },
+
+  // You can expose other APTs you need here.
+  // ...
+})
+
+// 暴露窗口控制API
+contextBridge.exposeInMainWorld('electronAPI', {
+  exit: () => ipcRenderer.send('exit-app'),
+  toggleFullScreen: (isFullScreen: boolean) => ipcRenderer.send('toggle-fullscreen', isFullScreen),
+  toggleDevTools: () => ipcRenderer.send('toggle-dev-tools'),
+  loadAppsJson: () => ipcRenderer.invoke('load-apps-json'),
+  loadDefaultAppsJson: () => ipcRenderer.invoke('load-default-apps-json'),
+  saveAppsJson: (appsJson: string) => ipcRenderer.send('save-apps-json', appsJson),
+  openWindow: (url: string) => ipcRenderer.send('open-window', url),
+  loadChildUrl: (url: string, aspectRatio: number) => ipcRenderer.send('load-child-url', url, aspectRatio),
+  toggleChildSide: (isSideOen: boolean) => ipcRenderer.send('toggle-child-side', isSideOen),
+  showConfig: () => ipcRenderer.send('show-config'),
+  showAbout: () => ipcRenderer.send('show-about'),
+})

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>演示APP</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 6734 - 0
package-lock.json


+ 32 - 0
package.json

@@ -0,0 +1,32 @@
+{
+  "name": "minnan-demo-app",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc && vite build && electron-builder",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@guolao/vue-monaco-editor": "^1.6.0",
+    "@imengyu/imengyu-utils": "^0.0.25",
+    "@imengyu/vue-scroll-rect": "^0.1.8",
+    "@imengyu/vue3-context-menu": "^1.5.3",
+    "ant-design-vue": "^4.2.6",
+    "vue": "^3.4.21",
+    "vue-router": "^4.6.4"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.0.4",
+    "electron": "^30.5.1",
+    "electron-builder": "^24.13.3",
+    "sass-embedded": "^1.97.2",
+    "typescript": "^5.2.2",
+    "vite": "^5.1.6",
+    "vite-plugin-electron": "^0.28.6",
+    "vite-plugin-electron-renderer": "^0.14.5",
+    "vue-tsc": "^2.0.26"
+  },
+  "main": "dist-electron/main.js"
+}

+ 27 - 0
public/apps.json

@@ -0,0 +1,27 @@
+[
+  {
+    "id": 1,
+    "title": "闽南文化驾驶舱",
+    "keepScreenSize": true,
+    "aspectRatio": "16/9",
+    "openType": "iframe",
+    "url": "https://mn.wenlvti.net/test/#/"
+  },
+  {
+    "id": 2,
+    "title": "闽南文化官网",
+    "openType": "iframe",
+    "url": "https://minnan.wenlvti.net/"
+  },
+  {
+    "id": 3,
+    "title": "文保中心官网",
+    "openType": "iframe",
+    "url": "https://xmswhycbhzx.cn/"
+  },
+  {
+    "id": 4,
+    "title": "文物管家后台",
+    "url": "https://wwgj.wenlvti.net/hTurbPWtgS.php"
+  }
+]

BIN=BIN
public/icon.ico


BIN=BIN
public/icon.png


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 8 - 0
public/icon.svg


+ 28 - 0
src/App.vue

@@ -0,0 +1,28 @@
+<template>
+  <a-config-provider 
+    :locale="zhCN.locale"
+    :theme="{ token: { colorPrimary: '#44a1ee', borderRadius: `${12}px` } }"
+  >
+    <router-view />
+  </a-config-provider>
+</template>
+
+<script setup lang="ts">
+import zhCN from 'ant-design-vue/es/locale/zh_CN';
+
+</script>
+
+<style lang="scss">
+html,body {
+  margin: 0;
+  padding: 0;
+  overflow: hidden;
+  width: 100%;
+  height: 100%;
+}
+#app {
+  position: relative;
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 9 - 0
src/assets/colors.scss

@@ -0,0 +1,9 @@
+
+$primary-color: #44a1ee;
+$bg-light: #fff;
+$bg-lighter: #f5f5f5;
+$text-color: #333;
+$text-light: #999;
+$text-white: #fff;
+$panel-width: 250px;
+$shadow: 0 2px 10px rgba(0, 0, 0, 0.1);

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 0
src/assets/failed.svg


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 0
src/assets/hello.svg


+ 1 - 0
src/assets/left.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1768298356195" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4599" xmlns:xlink="http://www.w3.org/1999/xlink" width="40" height="40"><path d="M740.352 849.919l-57.225 59.008-399.479-396.929 399.476-396.924 57.228 59.004-335.872 337.92z" fill="#272636" p-id="4600"></path></svg>

+ 14 - 0
src/assets/menu.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1768967410009"
+  class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8412"
+  xmlns:xlink="http://www.w3.org/1999/xlink" width="20" height="20">
+  <path
+    d="M133.310936 296.552327l757.206115 0c19.781623 0 35.950949-16.169326 35.950949-35.950949 0-19.781623-15.997312-35.950949-35.950949-35.950949L133.310936 224.650428c-19.781623 0-35.950949 16.169326-35.950949 35.950949C97.359987 280.383 113.529313 296.552327 133.310936 296.552327z"
+    fill="#575B66" p-id="8413"></path>
+  <path
+    d="M890.51705 476.135058 133.310936 476.135058c-19.781623 0-35.950949 16.169326-35.950949 35.950949 0 19.781623 16.169326 35.950949 35.950949 35.950949l757.206115 0c19.781623 0 35.950949-16.169326 35.950949-35.950949C926.467999 492.304384 910.298673 476.135058 890.51705 476.135058z"
+    fill="#575B66" p-id="8414"></path>
+  <path
+    d="M890.51705 727.447673 133.310936 727.447673c-19.781623 0-35.950949 15.997312-35.950949 35.950949s16.169326 35.950949 35.950949 35.950949l757.206115 0c19.781623 0 35.950949-15.997312 35.950949-35.950949S910.298673 727.447673 890.51705 727.447673z"
+    fill="#575B66" p-id="8415"></path>
+</svg>

+ 1 - 0
src/assets/vue.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

+ 49 - 0
src/components/AppList.vue

@@ -0,0 +1,49 @@
+<template>
+  <ScrollRect class="app-list" scroll="vertical" containerClass="inner">
+    <slot />
+  </ScrollRect>
+</template>
+
+<script setup lang="ts">
+import { ScrollRect } from '@imengyu/vue-scroll-rect';
+</script>
+
+<style lang="scss">
+@use '../assets/colors.scss' as *;
+
+.app-list {
+  flex: 1;
+  width: auto;
+
+  .inner {
+    padding: 10px;
+  }
+
+  .app-item {
+    padding: 12px 15px;
+    margin-bottom: 8px;
+    background-color: white;
+    border-radius: 25px;
+    cursor: pointer;
+    transition: all 0.3s ease;
+
+    img {
+      width: 16px;
+      height: 16px;
+      margin-right: 8px;
+      border-radius: 4px;
+    }
+
+    &:hover {
+      background-color: rgba($primary-color, 0.1);
+      box-shadow: rgba($primary-color, 0.2) 0 0 10px;
+    }
+    &.active {
+      background-color: $primary-color;
+      box-shadow: rgba($primary-color, 0.3) 0 1px 10px;
+      color: white;
+    }
+  }
+}
+</style>
+

+ 34 - 0
src/components/AppListItem.vue

@@ -0,0 +1,34 @@
+<template>
+  <div class="app-item" :class="{ active: activeAppId === app.id }" @click="emit('selectApp', app)">
+    <img :src="useDefaultIcon ? vueIcon : iconUrl" alt="应用图标" @error="useDefaultIcon = true" />
+    {{ app.title }}
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, PropType, ref } from 'vue';
+import { AppItem } from '../model/App';
+import vueIcon from '../assets/vue.svg';
+
+const emit = defineEmits(['selectApp']);
+
+const props = defineProps({
+  app: {
+    type: Object as PropType<AppItem>,
+    default: () => {},
+  },
+  activeAppId: {
+    type: Number,
+    default: 0,
+  }
+})
+const iconUrl = computed(() => {
+  if (!props.app.url)
+    return '';
+  const parsedUrl = new URL(props.app.url);
+  return `${parsedUrl.origin}/favicon.ico`
+})
+
+const useDefaultIcon = ref(false);
+
+</script>

+ 5 - 0
src/config/version.json

@@ -0,0 +1,5 @@
+{
+  "version": "1.0.0",
+  "versionCode": 15,
+  "buildDate": "2026-01-21"
+}

+ 28 - 0
src/main.ts

@@ -0,0 +1,28 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+import router from './router'
+import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css'
+import '@imengyu/vue-scroll-rect/lib/vue-scroll-rect.css'
+import 'ant-design-vue/dist/reset.css';
+import ContextMenu from '@imengyu/vue3-context-menu'
+import ScrollRect from '@imengyu/vue-scroll-rect'
+import Antd from 'ant-design-vue';
+import { install as VueMonacoEditorPlugin } from '@guolao/vue-monaco-editor';
+
+
+createApp(App)
+  .use(router)
+  .use(Antd)
+  .use(ScrollRect)
+  .use(ContextMenu)
+  .use(VueMonacoEditorPlugin, {
+    paths: {
+      vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.43.0/min/vs',
+    },
+  })
+  .mount('#app').$nextTick(() => {
+    // Use contextBridge
+    window.ipcRenderer.on('main-process-message', (_event, message) => {
+      console.log(message)
+    })
+  })

+ 22 - 0
src/model/App.ts

@@ -0,0 +1,22 @@
+export interface AppItem {
+  /**
+   * 应用ID
+   */
+  id: number
+  /**
+   * 应用显示标题
+   */
+  title: string
+  /**
+   * 应用地址URL
+   */
+  url: string
+  /**
+   * 是否需要保持显示尺寸比例,配合aspectRatio使用
+   */
+  keepScreenSize?: boolean
+  /**
+   * 屏幕尺寸比例。例如 16 / 9
+   */
+  aspectRatio?: string
+}

+ 51 - 0
src/router/index.ts

@@ -0,0 +1,51 @@
+import { createRouter, createWebHashHistory } from 'vue-router'
+import Main from '../views/Main.vue'
+import Config from '../views/Config.vue'
+import Expand from '../views/Expand.vue'
+import Hello from '../views/Hello.vue'
+import Loading from '../views/Loading.vue'
+import Error from '../views/Error.vue'
+import About from '../views/About.vue'
+
+const router = createRouter({
+  history: createWebHashHistory(import.meta.env.BASE_URL),
+  routes: [
+    {
+      path: '/',
+      name: 'Main',
+      component: Main,
+    },
+    {
+      path: '/config',
+      name: 'Config',
+      component: Config,
+    },
+    {
+      path: '/expand',
+      name: 'Expand',
+      component: Expand,
+    },
+    {
+      path: '/hello',
+      name: 'Hello',
+      component: Hello,
+    },
+    {
+      path: '/loading',
+      name: 'Loading',
+      component: Loading,
+    },
+    {
+      path: '/error',
+      name: 'Error',
+      component: Error,
+    },
+    {
+      path: '/about',
+      name: 'About',
+      component: About,
+    },
+  ]
+})
+
+export default router

+ 166 - 0
src/views/About.vue

@@ -0,0 +1,166 @@
+<template>
+  <div class="about-container">
+    <div class="about-content">
+      <div class="about-header">
+        <div class="about-icon">
+          <img src="/icon.svg" alt="演示程序" />
+        </div>
+        <div class="about-title">
+          <h1>演示程序</h1>
+          <p class="about-tagline">公司内部的项目演示</p>
+        </div>
+      </div>
+      <div class="about-info">
+        <div class="about-version">
+          <span class="info-label">版本:</span>
+          <span class="info-value">{{ version.version }}</span>
+        </div>
+        <div class="about-build">
+          <span class="info-label">构建版本:</span>
+          <span class="info-value">{{ version.versionCode }}</span>
+        </div>
+        <div class="about-date">
+          <span class="info-label">构建日期:</span>
+          <span class="info-value">{{ version.buildDate }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import version from '../config/version.json'
+</script>
+
+<style lang="scss">
+.about-container {
+  min-height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.about-content {
+  padding: 40px;
+}
+.about-header {
+  display: flex;
+  align-items: center;
+  gap: 24px;
+  margin-bottom: 32px;
+  padding-bottom: 24px;
+  border-bottom: 1px solid #e0e0e0;
+
+  .about-icon {
+    width: 80px;
+    height: 80px;
+    border-radius: 50%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    animation: pulse 2s infinite;
+
+    img {
+      width: 50px;
+      height: 50px;
+      object-fit: contain;
+    }
+  }
+
+  .about-title {
+    h1 {
+      margin: 0;
+      font-size: 28px;
+      font-weight: bold;
+      color: #2c3e50;
+    }
+
+    .about-tagline {
+      margin: 8px 0 0 0;
+      font-size: 16px;
+      color: #7f8c8d;
+    }
+  }
+}
+.about-info {
+  margin-bottom: 32px;
+  padding: 24px;
+  background: #f8f9fa;
+  border-radius: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+
+
+  .about-version,
+  .about-build,
+  .about-date {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    gap: 10px;
+
+    .info-label {
+      font-weight: 600;
+      color: #555;
+      font-size: 14px;
+    }
+
+    .info-value {
+      font-size: 14px;
+      color: #333;
+      font-weight: 500;
+    }
+  }
+}
+
+@keyframes pulse {
+  0% {
+    box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.4);
+  }
+  70% {
+    box-shadow: 0 0 0 10px rgba(102, 126, 234, 0);
+  }
+  100% {
+    box-shadow: 0 0 0 0 rgba(102, 126, 234, 0);
+  }
+}
+
+@media (max-width: 768px) {
+  .about-content {
+    padding: 30px;
+  }
+
+  .about-header {
+    flex-direction: column;
+    text-align: center;
+    gap: 16px;
+  }
+
+  .about-info {
+    padding: 20px;
+  }
+
+  .features-list {
+    grid-template-columns: repeat(2, 1fr);
+  }
+
+  .about-title h1 {
+    font-size: 24px;
+  }
+
+  .about-tagline {
+    font-size: 14px;
+  }
+}
+
+@media (max-width: 480px) {
+  .about-content {
+    padding: 24px;
+  }
+
+  .features-list {
+    grid-template-columns: 1fr;
+  }
+}
+</style>

+ 238 - 0
src/views/Config.vue

@@ -0,0 +1,238 @@
+<template>
+  <div class="config-container">
+    <div class="editor-container">
+      <vue-monaco-editor 
+        v-model:value="code"
+        :options="MONACO_EDITOR_OPTIONS" 
+      />
+    </div>
+    <div class="footer">
+      <div>
+        <a-button @click="resetConfig">恢复默认</a-button>
+        <a-button @click="showHelp = true">
+          <QuestionCircleOutlined />
+        </a-button>
+      </div>
+      <div>
+        <a-button @click="closeConfig">取消</a-button>
+        <a-button type="primary" @click="saveConfig">保存配置</a-button>
+      </div>
+    </div>
+    <a-modal
+      title="配置格式说明"
+      v-model:visible="showHelp"
+      :footer="null"
+      width="600px"
+    >
+      <ScrollRect scroll="vertical" :height="260" containerClass="help-content">
+        <div class="help-section">
+          <p class="help-text">配置文件使用JSON格式,包含一个应用数组。每个应用对象支持以下字段:</p>
+        </div>    
+        <div class="help-section">
+          <h4 class="help-field-title">必填字段</h4>
+          <ul class="help-list">
+            <li><code>id</code>: 应用ID(数字类型,唯一)</li>
+            <li><code>title</code>: 应用显示标题(字符串类型)</li>
+            <li><code>url</code>: 应用地址URL(字符串类型)</li>
+          </ul>
+        </div>      
+        <div class="help-section">
+          <h4 class="help-field-title">可选字段</h4>
+          <ul class="help-list">
+            <li><code>keepScreenSize</code>: 是否保持显示尺寸比例(布尔类型,默认false)</li>
+            <li><code>aspectRatio</code>: 屏幕尺寸比例(字符串类型,例如 "16/9")</li>
+          </ul>
+        </div>       
+        <div class="help-section">
+          <h4 class="help-field-title">配置示例</h4>
+          <div class="help-code">
+            <pre>{{ configExample }}</pre>
+          </div>
+        </div>       
+        <div class="help-section">
+          <h4 class="help-field-title">使用说明</h4>
+          <ul class="help-list">
+            <li>每个应用必须有唯一的ID</li>
+            <li>URL必须是完整的地址(包含http://或https://)</li>
+            <li>设置<code>keepScreenSize</code>为true时,建议同时设置<code>aspectRatio</code></li>
+            <li>修改配置后点击"保存配置"按钮生效</li>
+          </ul>
+        </div>
+      </ScrollRect>
+    </a-modal>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Modal } from 'ant-design-vue'
+import { onMounted, ref } from 'vue'
+import { QuestionCircleOutlined } from '@ant-design/icons-vue'
+import { ScrollRect } from '@imengyu/vue-scroll-rect'
+
+const code = ref('')
+const MONACO_EDITOR_OPTIONS = {
+  language: 'json',
+}
+const showHelp = ref(false)
+
+const configExample = ref(`[
+  {
+    "id": 1,
+    "title": "Google",
+    "url": "https://www.google.com"
+  },
+  {
+    "id": 2,
+    "title": "百度",
+    "url": "https://www.baidu.com",
+    "keepScreenSize": true,
+    "aspectRatio": "16/9"
+  },
+  {
+    "id": 3,
+    "title": "GitHub",
+    "url": "https://github.com"
+  }
+]`)
+
+onMounted(() => {
+  window.electronAPI.loadAppsJson().then((data) => {
+    code.value = JSON.stringify(data, null, 2)
+  })
+})
+
+function resetConfig() {
+  window.electronAPI.loadDefaultAppsJson().then((data) => {
+    code.value = JSON.stringify(data, null, 2)
+  })
+}
+function closeConfig() {
+  window.close()
+}
+function saveConfig() {
+  try {
+    JSON.parse(code.value);
+  } catch (error) {
+    Modal.error({
+      title: '解析JSON失败',
+      content: '请检查JSON格式是否正确。' + error,
+    });
+    return;
+  }
+  window.electronAPI.saveAppsJson(code.value)
+  Modal.success({
+    title: '保存成功',
+    content: '配置已保存。',
+    onOk() {
+      closeConfig()
+    }
+  })
+}
+</script>
+
+<style lang="scss">
+.config-container {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
+
+  .editor-container {
+    flex: 1;
+    position: relative;
+  }
+  .footer {
+    padding: 10px;
+    gap: 6px;
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    margin-top: 10px;
+
+    > div {
+      display: flex;
+      flex-direction: row;
+      gap: 6px;
+    }
+  }
+}
+
+.help-content {
+  max-height: 60vh;
+  padding-right: 10px;
+
+  .help-section {
+    margin-bottom: 20px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  .help-section-title {
+    font-size: 16px;
+    font-weight: 600;
+    color: #333;
+    margin: 0 0 10px 0;
+    padding-bottom: 5px;
+    border-bottom: 1px solid #e8e8e8;
+  }
+
+  .help-field-title {
+    font-size: 14px;
+    font-weight: 600;
+    color: #555;
+    margin: 0 0 8px 0;
+  }
+
+  .help-text {
+    font-size: 14px;
+    color: #666;
+    line-height: 1.5;
+    margin: 0 0 12px 0;
+  }
+
+  .help-list {
+    font-size: 14px;
+    color: #666;
+    line-height: 1.6;
+    margin: 0 0 12px 0;
+    padding-left: 20px;
+
+    li {
+      margin-bottom: 4px;
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+    }
+
+    code {
+      background-color: #f5f5f5;
+      padding: 2px 4px;
+      border-radius: 3px;
+      font-family: 'Courier New', Courier, monospace;
+      font-size: 13px;
+      color: #d73a49;
+    }
+  }
+
+  .help-code {
+    background-color: #f6f8fa;
+    border: 1px solid #e1e4e8;
+    border-radius: 6px;
+    padding: 12px;
+    margin: 8px 0;
+
+    pre {
+      margin: 0;
+      font-family: 'Courier New', Courier, monospace;
+      font-size: 13px;
+      line-height: 1.5;
+      color: #24292e;
+      white-space: pre-wrap;
+      word-wrap: break-word;
+    }
+  }
+}
+</style>

+ 38 - 0
src/views/Error.vue

@@ -0,0 +1,38 @@
+<template>
+  <div class="error-container">
+    <img src="../assets/failed.svg" alt="Failed" />
+    <p>当前页面无法正常加载</p>
+    <p>{{ message }}</p>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { useRoute } from 'vue-router';
+
+const route = useRoute()
+const message = computed(() => {
+  return `${route.query.message} (${route.query.code})`
+})
+
+</script>
+
+<style lang="scss">
+.error-container {
+  padding: 20px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+
+  img {
+    width: 80px;
+    height: 80px;
+    margin-bottom: 20px;
+  }
+  p {
+    margin: 0;
+    font-size: 16px;
+  }
+}
+</style>

+ 40 - 0
src/views/Expand.vue

@@ -0,0 +1,40 @@
+<template>
+  <div class="expand-container">
+    <div class="open-left-btn" title="展开" @click="openLeftPanel">
+      <img src="../assets/left.svg" alt="展开" />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+function openLeftPanel() {
+  window.electronAPI.toggleChildSide(true)
+}
+</script>
+
+<style lang="scss">
+.expand-container {
+  padding: 0px;
+  overflow: hidden;
+
+  .open-left-btn {
+    width: 40px;
+    height: 40px;
+    background-color: #fff;
+    border: none;
+    border-radius: 50%;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    transition: background-color 0.3s ease;
+    z-index: 20;
+
+    img {
+      width: 15px;
+      height: 15px;
+      transform: rotate(180deg);
+    }
+  }
+}
+</style>

+ 103 - 0
src/views/Hello.vue

@@ -0,0 +1,103 @@
+<template>
+  <div class="hello-container">
+    <div class="hello-content">
+      <div class="hello-icon">
+        <img src="../assets/hello.svg" alt="演示程序" />
+      </div>
+      <div class="hello-text">
+        <h1 class="hello-title">欢迎使用演示程序</h1>
+        <p class="hello-description">从左侧菜单选择应用程序打开</p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss">
+@use '../assets/colors.scss' as *;
+
+.hello-container {
+  min-height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: $bg-lighter;
+  padding: 20px;
+  padding-bottom: 150px;
+}
+
+.hello-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  text-align: center;
+  max-width: 600px;
+  background-color: white;
+  border-radius: 16px;
+  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
+  padding: 40px;
+  transition: transform 0.3s ease, box-shadow 0.3s ease;
+
+  &:hover {
+    transform: translateY(-5px);
+    box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15);
+  }
+}
+
+.hello-icon {
+  margin-bottom: 30px;
+  border-radius: 50%;
+  animation: pulse 2s infinite;
+
+  img {
+    width: 120px;
+    height: 120px;
+    object-fit: contain;
+  }
+}
+
+.hello-title {
+  font-size: 28px;
+  font-weight: bold;
+  color: #2c3e50;
+  margin: 0 0 15px 0;
+  line-height: 1.2;
+}
+
+.hello-description {
+  font-size: 16px;
+  color: #7f8c8d;
+  margin: 0;
+  line-height: 1.5;
+}
+
+@keyframes pulse {
+  0% {
+    transform: scale(1);
+  }
+  50% {
+    transform: scale(1.05);
+  }
+  100% {
+    transform: scale(1);
+  }
+}
+
+@media (max-width: 768px) {
+  .hello-content {
+    padding: 30px;
+  }
+
+  .hello-icon img {
+    width: 100px;
+    height: 100px;
+  }
+
+  .hello-title {
+    font-size: 24px;
+  }
+
+  .hello-description {
+    font-size: 14px;
+  }
+}
+</style>

+ 21 - 0
src/views/Loading.vue

@@ -0,0 +1,21 @@
+<template>
+  <div class="loading-container">
+    <a-spin :spinning="true"></a-spin>
+    <span>正在载入请稍后...</span>
+  </div>
+</template>
+
+<style lang="scss">
+.loading-container {
+  padding: 20px 10px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+
+  span {
+    margin-top: 10px;
+    font-size: 14px;
+  }
+}
+</style>

+ 260 - 0
src/views/Main.vue

@@ -0,0 +1,260 @@
+<script setup lang="ts">
+import { ScrollRect } from '@imengyu/vue-scroll-rect'
+import { ref, onMounted } from 'vue'
+import ContextMenu from '@imengyu/vue3-context-menu'
+import { HtmlUtils } from '@imengyu/imengyu-utils'
+import { AppItem } from '../model/App'
+import AppList from '../components/AppList.vue'
+import AppListItem from '../components/AppListItem.vue'
+
+// 状态管理
+const apps = ref<AppItem[]>([])
+const selectedApp = ref<AppItem | null>(null)
+const isFullScreen = ref(false)
+const isLoading = ref(false)
+const currentAspectRatio = ref('')
+const isLeftPanelOpen = ref(true)
+const menuBtn = ref<HTMLButtonElement>()
+
+// 切换左侧面板
+function toggleLeftPanel() {
+  isLeftPanelOpen.value = !isLeftPanelOpen.value
+  window.electronAPI.toggleChildSide(isLeftPanelOpen.value)
+}
+
+// 从本地加载应用数据
+async function loadApps() {
+  try {
+    const data = await window.electronAPI.loadAppsJson()
+    apps.value = data
+  } catch (error) {
+    console.error('Failed to load apps:', error)
+  }
+}
+
+// 选择应用
+function selectApp(app: AppItem) {
+  isLoading.value = true
+  //if (app.openType === 'window') {
+  //  window.electronAPI.openWindow(app.url)
+  //} else {
+    selectedApp.value = app
+
+    function parseAspectRatio(aspectRatio: string) {
+      if (aspectRatio && aspectRatio.includes('/')) {
+        const [width, height] = aspectRatio.split('/').map(Number)
+        if (width > 0 && height > 0) {
+          currentAspectRatio.value = `${width} : ${height}`
+          return width / height
+        }
+      }
+      return 0
+    }
+
+    window.electronAPI.loadChildUrl(app.url, app.keepScreenSize ? (
+      parseAspectRatio(app.aspectRatio || '16:9')
+    ) : 0)
+  //}
+  setTimeout(() => {
+    isLoading.value = false
+  }, 2000);
+}
+// 退出应用
+function exitApp() {
+  window.electronAPI.exit()
+}
+// 切换全屏
+function toggleFullScreen() {
+  isFullScreen.value = !isFullScreen.value
+  window.electronAPI.toggleFullScreen(isFullScreen.value)
+}
+// 显示配置窗口
+function showConfig() {
+  window.electronAPI.showConfig()
+}
+// 显示关于窗口
+function showAbout() {
+  window.electronAPI.showAbout()
+}
+// 显示菜单
+function showMenu() {
+  if (ContextMenu.isAnyContextMenuOpen())
+    return
+  ContextMenu.showContextMenu({
+    x: HtmlUtils.getLeft(menuBtn.value!) + menuBtn.value!.offsetWidth,
+    y: HtmlUtils.getTop(menuBtn.value!) + menuBtn.value!.offsetHeight + 8,
+    direction: 'bl',
+    items: [
+      {
+        label: '列表配置',
+        onClick: showConfig,
+      },
+      {
+        label: '全屏',
+        shortcut: 'F11',
+        onClick: toggleFullScreen,
+      },
+      {
+        label: '关于',
+        divided: 'up',
+        onClick: showAbout,
+      },
+      {
+        label: '退出',
+        shortcut: 'Alt+F4',
+        onClick: exitApp,
+      },
+    ],
+  })
+}
+
+// 初始化加载数据
+onMounted(() => {
+  loadApps()
+  window.ipcRenderer.on('main-side-state-changed', (_event, isOpen) => {
+    isLeftPanelOpen.value = isOpen
+  });
+  window.ipcRenderer.on('main-config-changed', (_event) => {
+    loadApps();
+  });
+
+})
+</script>
+
+<template>
+  <div class="app-container">
+    <!-- 左侧应用列表 -->
+    <div v-if="isLeftPanelOpen" class="left-panel">
+      <h2 class="panel-title">
+        <div>
+          <img src="/icon.svg" alt="应用" />
+          应用列表
+        </div>
+        <button ref="menuBtn" class="control-btn" @click="showMenu">
+          <img src="../assets/menu.svg" alt="菜单" />
+        </button>
+      </h2>
+      <AppList>
+        <AppListItem 
+          v-for="app in apps" 
+          :key="app.id"
+          :app="app"
+          :activeAppId="selectedApp?.id"
+          @selectApp="selectApp(app)"
+        />
+      </AppList>
+      <!-- 底部控制按钮 -->
+      <div class="bottom-panel">
+        <button class="control-btn" @click="toggleLeftPanel">
+          <img src="../assets/left.svg" alt="折叠" />
+        </button>
+      </div>
+    </div>
+    <!-- 右侧iframe区域 -->
+    <div class="right-panel">
+      <div v-if="currentAspectRatio" class="aspect-ratio-info">
+        显示比例:{{ currentAspectRatio }}
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss">
+@use '../assets/colors.scss' as *;
+
+.app-container {
+  display: flex;
+  flex-direction: row;
+  height: 100vh;
+  width: 100vw;
+  overflow: hidden;
+  font-family: Arial, sans-serif;
+  color: $text-color;
+  background-color: #000;
+
+  // 左侧面板
+  .left-panel {
+    position: relative;
+    width: $panel-width;
+    background: $bg-light;
+    display: flex;
+    flex-direction: column;
+    border-right: 1px solid $bg-lighter;
+
+    .panel-title {
+      display: flex;
+      flex-direction: row;
+      align-items: center;
+      justify-content: space-between;
+      padding: 20px;
+      margin: 0;
+      font-size: 18px;
+      color: $primary-color;
+      font-weight: bold;
+
+      > div {
+        display: flex;
+        flex-direction: row;
+        align-items: center;
+        align-content: center;
+
+        img {
+          width: 28px;
+          height: 28px;
+          margin-right: 8px;
+        }
+      }
+    }
+  }
+
+  // 右侧面板
+  .right-panel {
+    position: relative;
+    flex: 1;
+    padding: 0;
+
+    .aspect-ratio-info {
+      position: absolute;
+      right: 10px;
+      top: 10px;
+
+      padding: 10px;
+      font-size: 14px;
+      border-radius: 5px;
+      color: $text-color;
+      background-color: $bg-light;
+    }
+  }
+
+  // 底部面板
+  .bottom-panel {
+    flex-shrink: 0;
+    padding: 15px;
+    display: flex;
+    justify-content: flex-start;
+    gap: 10px;
+  }
+}
+
+.control-btn {
+  position: relative;
+  padding: 10px;
+  border: none;;
+  cursor: pointer;
+  font-size: 14px;
+  font-weight: bold;
+  transition: all 0.3s ease;
+  color: #000;
+  border-radius: 50%;
+  background-color: transparent;
+
+  img {
+    width: 16px;
+    height: 16px;
+  }
+
+  &:hover {
+    background-color: #ddd;
+  }
+}
+</style>

+ 1 - 0
src/vite-env.d.ts

@@ -0,0 +1 @@
+/// <reference types="vite/client" />

+ 25 - 0
tsconfig.json

@@ -0,0 +1,25 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "preserve",
+
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true
+  },
+  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "electron"],
+  "references": [{ "path": "./tsconfig.node.json" }]
+}

+ 11 - 0
tsconfig.node.json

@@ -0,0 +1,11 @@
+{
+  "compilerOptions": {
+    "composite": true,
+    "skipLibCheck": true,
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowSyntheticDefaultImports": true,
+    "strict": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 29 - 0
vite.config.ts

@@ -0,0 +1,29 @@
+import { defineConfig } from 'vite'
+import path from 'node:path'
+import electron from 'vite-plugin-electron/simple'
+import vue from '@vitejs/plugin-vue'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  plugins: [
+    vue(),
+    electron({
+      main: {
+        // Shortcut of `build.lib.entry`.
+        entry: 'electron/main.ts',
+      },
+      preload: {
+        // Shortcut of `build.rollupOptions.input`.
+        // Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`.
+        input: path.join(__dirname, 'electron/preload.ts'),
+      },
+      // Ployfill the Electron and Node.js API for Renderer process.
+      // If you want use Node.js in Renderer process, the `nodeIntegration` needs to be enabled in the Main process.
+      // See 👉 https://github.com/electron-vite/vite-plugin-electron-renderer
+      renderer: process.env.NODE_ENV === 'test'
+        // https://github.com/electron-vite/vite-plugin-electron-renderer/issues/78#issuecomment-2053600808
+        ? undefined
+        : {},
+    }),
+  ],
+})