Ver código fonte

优化样式,去除演示程序文字

快乐的梦鱼 3 dias atrás
pai
commit
2ac2488306

+ 51 - 0
apps.json

@@ -0,0 +1,51 @@
+{
+  "闽南文化": [
+    {
+      "id": 1,
+      "title": "闽南文化驾驶舱",
+      "keepScreenSize": true,
+      "aspectRatio": "16/9",
+      "openType": "iframe",
+      "url": "https://mn.wenlvti.net/test/#/",
+      "inputPassword": {
+        "username": "admin",
+        "password": "bh2643"
+      }
+    },
+    {
+      "id": 2,
+      "title": "闽南文化官网",
+      "openType": "iframe",
+      "url": "https://minnan.wenlvti.net/"
+    },
+    {
+      "id": 3,
+      "title": "湖里驾驶舱",
+      "keepScreenSize": true,
+      "aspectRatio": "16/9",
+      "openType": "iframe",
+      "url": "https://hulicdn.wenlvti.net/control_cabin/text_one/#/login",
+      "inputPassword": {
+        "username": "admin",
+        "password": "bh2643"
+      }
+    }
+  ],
+  "文物管家": [
+    {
+      "id": 4,
+      "title": "文物管家后台",
+      "url": "https://wwgj.wenlvti.net/hTurbPWtgS.php",
+      "inputPassword": {
+        "username": "admin",
+        "password": "bh2643"
+      }
+    },
+    {
+      "id": 13,
+      "title": "文保中心官网",
+      "openType": "iframe",
+      "url": "https://www.xmswhycbhzx.cn/"
+    }
+  ]
+}

+ 1 - 1
electron-builder.json5

@@ -3,7 +3,7 @@
   "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
   "appId": "MinnanDemo",
   "asar": true,
-  "productName": "演示程序",
+  "productName": "厦门博合文化科技有限公司产品演示",
   "icon": "public/icon.ico",
   "directories": {
     "output": "release/${version}"

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

@@ -37,5 +37,7 @@ interface Window {
     showConfig: () => void
     showAbout: () => void
     clearCache: () => Promise<void>
+    refresh: () => void
+    openInSystem: (url: string) => void
   }
 }

+ 89 - 81
electron/main.ts

@@ -1,4 +1,4 @@
-import { app, BrowserWindow, dialog, Event, Input, ipcMain, Menu, WebContentsView } from 'electron'
+import { app, BrowserWindow, dialog, Event, Input, ipcMain, Menu, shell, WebContentsView } from 'electron'
 import { fileURLToPath } from 'node:url'
 import path from 'node:path'
 import fs from 'node:fs'
@@ -24,11 +24,17 @@ 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
 
+type InputPassword = {
+  username: string;
+  password: string;
+}
+
 let mainWindow: BrowserWindow | null
 let childView: WebContentsView | null
 
 let isSideOpen = true
 let childViewAspectRatio = 0; 
+let childViewInputPassword: InputPassword | undefined;
 const SIDE_WIDTH = 250
 const EXPAND_VIEW_SIZE = 40
 const LOADING_VIEW_WIDTH = 150
@@ -96,7 +102,8 @@ function createWindow() {
   })
   mainWindow.contentView.addChildView(childView)
   mainWindow.contentView.addChildView(expandButtonView)
-  expandButtonView.setVisible(false)
+  expandButtonView.setVisible(false)  
+  expandButtonView.setBackgroundColor('#00000000')
 
   childView.webContents.on('did-start-loading', () => {
     loadingView.show()
@@ -111,58 +118,54 @@ function createWindow() {
 
   function updateChildWindowBounds() {
     const bounds = mainWindow!.getBounds();
-    expandButtonView.setBounds({ 
-      x: 0, 
-      y: bounds.height - 100, 
-      width: EXPAND_VIEW_SIZE, 
-      height: EXPAND_VIEW_SIZE 
+    const contentBounds = mainWindow!.getContentBounds();
+    const contentWidth = contentBounds.width;
+    const contentHeight = contentBounds.height;
+
+    expandButtonView.setBounds({
+      x: 0,
+      y: contentHeight - 100,
+      width: EXPAND_VIEW_SIZE,
+      height: EXPAND_VIEW_SIZE
     })
-    loadingView.setBounds({ 
-      x: bounds.x + (bounds.width - LOADING_VIEW_WIDTH) / 2, 
-      y: bounds.y + 30, 
-      width: LOADING_VIEW_WIDTH, 
-      height: LOADING_VIEW_HEIGHT 
+    loadingView.setBounds({
+      x: bounds.x + (bounds.width - LOADING_VIEW_WIDTH) / 2,
+      y: bounds.y + 30,
+      width: LOADING_VIEW_WIDTH,
+      height: LOADING_VIEW_HEIGHT
     })
+
+    const availableX = isSideOpen ? SIDE_WIDTH : 0;
+    const availableWidth = contentWidth - availableX;
+    const availableHeight = contentHeight;
+
     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;
+      const availableRatio = availableWidth / availableHeight;
       let childWidth, childHeight;
-      
+
       if (availableRatio > childViewAspectRatio) {
-        // 高度受限制
-        childHeight = rect.height;
+        childHeight = availableHeight;
         childWidth = childHeight * childViewAspectRatio;
       } else {
-        // 宽度受限制
-        childWidth = rect.width;
+        childWidth = availableWidth;
         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) 
+
+      const childX = availableX + (availableWidth - childWidth) / 2;
+      const childY = (availableHeight - 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 
+      childView!.setBounds({
+        x: availableX,
+        y: 0,
+        width: availableWidth,
+        height: availableHeight
       });
     }
   }
@@ -227,6 +230,40 @@ function createWindow() {
   childView.webContents.on('before-input-event', (event, input) => {
     handleWindowFullScreenKeys(childView!, event, input)
   })
+  childView.webContents.on('did-finish-load', () => {
+    if (childViewInputPassword) {
+      const script = `
+        (function() {
+          function tryFill() {
+            const passwordInputs = document.querySelectorAll('input[type="password"]');
+            if (passwordInputs.length === 0) return false;
+            const allInputs = Array.from(document.querySelectorAll('input'));
+            const usernameInput = allInputs.find(el => {
+              const type = el.getAttribute('type');
+              return !type || type === 'text' || type === 'email';
+            });
+            const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
+            if (usernameInput) {
+              nativeInputValueSetter.call(usernameInput, ${JSON.stringify(childViewInputPassword.username)});
+              usernameInput.dispatchEvent(new Event('input', { bubbles: true }));
+              usernameInput.dispatchEvent(new Event('change', { bubbles: true }));
+            }
+            const pwdInput = passwordInputs[0];
+            nativeInputValueSetter.call(pwdInput, ${JSON.stringify(childViewInputPassword.password)});
+            pwdInput.dispatchEvent(new Event('input', { bubbles: true }));
+            pwdInput.dispatchEvent(new Event('change', { bubbles: true }));
+            return true;
+          }
+          let attempts = 0;
+          const timer = setInterval(() => {
+            if (tryFill() || ++attempts >= 10) clearInterval(timer);
+          }, 500);
+        })();
+      `;
+      childView!.webContents.executeJavaScript(script).catch(() => {});
+    }
+  });
+
   // 处理退出应用事件
   ipcMain.on('exit-app', () => {
     app.quit()
@@ -243,45 +280,10 @@ function createWindow() {
   })
   ipcMain.on('toggle-dev-tools', toggleDevTools)
   // 加载子页URL
-  ipcMain.on('load-child-url', (_event, url: string, aspectRatio: number, inputPassword?: { username: string; password: string }) => {
-    if (childView) {
-      console.log('load-child-url', url, inputPassword)
+  ipcMain.on('load-child-url', (_event, url: string, aspectRatio: number, inputPassword?: InputPassword) => {
+    if (childView)
       childView.webContents.loadURL(url)
-      if (inputPassword) {
-        childView.webContents.once('did-finish-load', () => {
-          console.log('did-finish-load', inputPassword)
-          const script = `
-            (function() {
-              function tryFill() {
-                const passwordInputs = document.querySelectorAll('input[type="password"]');
-                if (passwordInputs.length === 0) return false;
-                const allInputs = Array.from(document.querySelectorAll('input'));
-                const usernameInput = allInputs.find(el => {
-                  const type = el.getAttribute('type');
-                  return !type || type === 'text' || type === 'email';
-                });
-                const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
-                if (usernameInput) {
-                  nativeInputValueSetter.call(usernameInput, ${JSON.stringify(inputPassword.username)});
-                  usernameInput.dispatchEvent(new Event('input', { bubbles: true }));
-                  usernameInput.dispatchEvent(new Event('change', { bubbles: true }));
-                }
-                const pwdInput = passwordInputs[0];
-                nativeInputValueSetter.call(pwdInput, ${JSON.stringify(inputPassword.password)});
-                pwdInput.dispatchEvent(new Event('input', { bubbles: true }));
-                pwdInput.dispatchEvent(new Event('change', { bubbles: true }));
-                return true;
-              }
-              let attempts = 0;
-              const timer = setInterval(() => {
-                if (tryFill() || ++attempts >= 10) clearInterval(timer);
-              }, 500);
-            })();
-          `;
-          childView!.webContents.executeJavaScript(script).catch(() => {});
-        });
-      }
-    }
+    childViewInputPassword = inputPassword;
     childViewAspectRatio = aspectRatio
     updateChildWindowBounds()
   })
@@ -292,6 +294,12 @@ function createWindow() {
     mainWindow?.webContents.send('main-side-state-changed', isSideOpen)
     updateChildWindowBounds()
   })
+  ipcMain.on('open-in-system', (_event, url: string) => {
+    shell.openExternal(url)
+  })
+  ipcMain.on('refresh-child-view', () => {
+    childView?.webContents.reloadIgnoringCache()
+  })
   // 处理获取应用路径事件
   ipcMain.handle('get-app-path', () => {
     return app.getAppPath()

+ 2 - 0
electron/preload.ts

@@ -37,4 +37,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
   showConfig: () => ipcRenderer.send('show-config'),
   showAbout: () => ipcRenderer.send('show-about'),
   clearCache: () => ipcRenderer.invoke('clear-cache'),
+  openInSystem: (url: string) => ipcRenderer.send('open-in-system', url),
+  refresh: () => ipcRenderer.send('refresh-child-view'),
 })

+ 1 - 1
index.html

@@ -4,7 +4,7 @@
     <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>
+    <title>Electron</title>
   </head>
   <body>
     <div id="app"></div>

Diferenças do arquivo suprimidas por serem muito extensas
+ 1723 - 180
package-lock.json


+ 14 - 2
package.json

@@ -1,7 +1,7 @@
 {
   "name": "minnan-demo-app",
   "private": true,
-  "version": "1.0.2",
+  "version": "1.0.3",
   "type": "module",
   "author": "厦门博合文化科技有限公司",
   "description": "厦门博合文化科技有限公司内部的项目演示",
@@ -9,15 +9,27 @@
     "dev": "vite",
     "typecheck": "vue-tsc",
     "build": "vite build && electron-builder",
-    "preview": "vite preview"
+    "preview": "vite preview",
+    "updater": "node src/scripts/UpdateScript/index.mjs"
   },
   "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",
+    "@inquirer/prompts": "^8.5.2",
+    "ali-oss": "^6.23.0",
     "ant-design-vue": "^4.2.6",
+    "archiver": "^8.0.0",
+    "axios": "^1.18.1",
+    "cli-progress": "^3.12.0",
+    "cli-table3": "^0.6.5",
+    "commander": "^15.0.0",
     "electron-updater": "^6.8.9",
+    "form-data": "^4.0.6",
+    "json5": "^2.2.3",
+    "md5": "^2.3.0",
+    "minimatch": "^10.2.5",
     "vue": "^3.4.21",
     "vue-router": "^4.6.4"
   },

Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 0
src/assets/edit.svg


Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 0
src/assets/more.svg


+ 13 - 3
src/components/AppList.vue

@@ -16,22 +16,27 @@ import { ScrollRect } from '@imengyu/vue-scroll-rect';
   width: auto;
 
   .inner {
-    padding: 10px;
+    padding: 0 10px 10px 10px;
   }
 
   h5 {
     font-size: 14px;
     font-weight: bold;
-    margin-bottom: 10px;
+    margin: 13px 0;
   }
 
   .app-item {
     padding: 12px 15px;
     margin-bottom: 8px;
+    border-radius: 6px;
+    border: 1px solid rgba($primary-color, 0.1);
     background-color: white;
-    border-radius: 25px;
     cursor: pointer;
     transition: all 0.3s ease;
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    align-items: center;
 
     img {
       width: 16px;
@@ -39,6 +44,11 @@ import { ScrollRect } from '@imengyu/vue-scroll-rect';
       margin-right: 8px;
       border-radius: 4px;
     }
+    .more {
+      width: 16px;
+      height: 16px;
+      cursor: pointer;
+    }
 
     &:hover {
       background-color: rgba($primary-color, 0.1);

+ 29 - 3
src/components/AppListItem.vue

@@ -1,7 +1,10 @@
 <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>
+      <img :src="useDefaultIcon ? vueIcon : iconUrl" alt="应用图标" @error="useDefaultIcon = true" />
+      {{ app.title }}
+    </div>
+    <img src="../assets/more.svg" title="更多" @click="showMore"  class="more" />
   </div>
 </template>
 
@@ -9,8 +12,9 @@
 import { computed, PropType, ref } from 'vue';
 import { AppItem } from '../model/App';
 import vueIcon from '../assets/vue.svg';
+import ContextMenu from '@imengyu/vue3-context-menu';
 
-const emit = defineEmits(['selectApp']);
+const emit = defineEmits(['selectApp', 'refreshApp','openInSystem']);
 
 const props = defineProps({
   app: {
@@ -31,4 +35,26 @@ const iconUrl = computed(() => {
 
 const useDefaultIcon = ref(false);
 
+function showMore(e: MouseEvent) {
+  e.stopPropagation()
+  ContextMenu.showContextMenu({
+    x: e.clientX,
+    y: e.clientY,
+    direction: 'bl',
+    items: [
+      {
+        label: '在系统浏览器中打开',
+        onClick: () => {
+          emit('openInSystem', props.app)
+        },
+      },
+      {
+        label: '刷新(重新加载)',
+        onClick: () => {
+          emit('refreshApp', props.app)
+        },
+      },
+    ],
+  })
+}
 </script>

+ 2 - 0
src/composeable/AppTitle.ts

@@ -0,0 +1,2 @@
+export const APP_TITLE_KEY = 'app-title'
+export const DEFAULT_TITLE = '厦门博合文化科技有限公司的产品'

+ 3 - 3
src/config/version.json

@@ -1,5 +1,5 @@
 {
-  "version": "1.0.2",
-  "versionCode": 22,
-  "buildDate": "2026-06-22"
+  "version": "1.0.3",
+  "versionCode": 24,
+  "buildDate": "2026-07-01"
 }

+ 7 - 0
src/scripts/.gitignore

@@ -0,0 +1,7 @@
+node_modules
+_config.json
+_localConfig.json
+_token.json
+_serverConfig.json
+upload.zip
+_upload.zip

+ 393 - 0
src/scripts/UpdateScript/app.mjs

@@ -0,0 +1,393 @@
+/**
+ * 更新发布工具
+ *
+ * Copyright © 2025 imengyu.top imengyu-update-server
+ */
+
+import { program } from 'commander';
+import { input, confirm, select } from '@inquirer/prompts';
+import { postAppUpdate, postWebUpdate } from './postUpdate.mjs';
+import Table from 'cli-table3';
+import { config } from './postConfig.mjs';
+import {
+  axiosInstance,
+  initAuth,
+  login,
+  logout,
+  checkLogged,
+} from './auth.mjs';
+
+// 版本相关
+//========================================
+
+export async function viewVersion(type) {
+  if (type === 'all' || !type) {
+    const data = await axiosInstance.get('/version/list');
+    const table = new Table({
+      head: ['ID', '版本'], 
+      colWidths: [10, 20 ]
+    });
+
+    data.data.forEach((d) => {
+      table.push([ d.id, d.version ]);
+    })
+    console.log(table.toString());
+  } else {
+    let data = null;
+    try {
+      if (Number.isNaN(Number(type)))
+        data = await axiosInstance.get('/version/get-by-name?name=' + type);
+      else
+        data = await axiosInstance.get('/version/' + type);
+    }
+    catch (e) {
+      console.error('Failed to load version info', e);
+      return;
+    }
+    const table = new Table({
+      head: ['key', 'data'], 
+      colWidths: [30, 60]
+    });
+    table.push(
+      [ 'ID', data.data.id ],
+      [ '状态', stateConstant[data.data.status] ],
+      [ '版本号', data.data.version ],
+      [ '创建时间', new Date(data.data.createAt).toString() ],
+      [ '设置', data.data.config ],
+      [ '激活的Web更新ID', data.data.webUpdateId ],
+      [ '激活的App更新ID', data.data.appUpdateId ],
+      [ '激活的下一个App更新ID', data.data.appUpdateNextId ],
+    );
+    console.log(table.toString());
+  }
+}
+export async function getVersion(action, type) {
+  switch(action) {
+    case 'view':
+      await viewVersion(type);
+      break;
+    case 'new': {
+      const version = await input({ message: 'Enter version name, like (1.0.0)' });
+      try {
+        await axiosInstance.post('/version', {
+          version: version,
+          status: 1,
+          config: "{}",
+        });
+        console.log('Add version success');
+      } catch (e) {
+        console.error('Failed to add version', e);
+      }
+      break;
+    }
+    case 'delete': {
+      const versionId = type ? type : await input({ message: '输入版本ID' });
+      if (!await confirm({ message: `确定删除版本 ${versionId}?`, default: false }))
+        return;
+      if (!await confirm({ message: '确认删除版本?此操作会删除所属版本的所有更新项目、存储等,无法恢复,是否确定删除?', default: false }))
+        return;
+      try {
+        await axiosInstance.delete('/version/' + versionId);
+        console.log('删除版本成功');
+      } catch (e) {
+        console.error('删除版本失败', e);
+      }
+      break;
+    }
+    case 'set-state': {
+      const versionId = type ? type : await input({ message: '输入版本ID' });
+      const state = await select({
+        message: '设置状态',
+        choices: [
+          {
+            name: 'NotEnable',
+            value: 0,
+          },
+          {
+            name: 'Normal',
+            value: 1,
+          },
+          {
+            name: 'Deprecated',
+            value: 2,
+          },
+        ],
+      });
+
+      try {
+        await axiosInstance.put('/version/' + versionId, {
+          status: state
+        });
+        console.log('设置状态成功');
+      } catch (e) {
+        console.error('设置状态失败', e);
+      }
+      break;
+    }
+    case 'set-config': {
+      const versionId = type ? type : await input({ message: '输入版本ID' });
+      const config = await input({ message: '输入配置Json' });
+      try {
+        await axiosInstance.put('/version/' + versionId, {
+          config: config,
+        });
+        console.log('设置配置成功');
+      } catch (e) {
+        console.error('设置配置失败', e);
+      }
+      break;
+    }
+    case 'set-active-app-update': {
+      const versionId = await input({ message: '输入版本ID' });
+      const updateId = await input({ message: '输入更新ID' });
+      const isNext = await confirm({ message: 'Set as next active?', default: false });
+
+      try {
+        await axiosInstance.post('/update/active/app', { versionId, updateId, isNext });
+        console.log('成功');
+      } catch (e) {
+        console.error('失败', e);
+      }
+      break;
+    }
+    case 'set-active-web-update': {
+      const versionId = await input({ message: '输入版本ID' });
+      const updateId = await input({ message: '输入更新ID' });
+
+      try {
+        await axiosInstance.post('/update/active/web', { versionId, updateId });
+        console.log('成功');
+      } catch (e) {
+        console.error('失败', e);
+      }
+      break;
+    }
+    default:
+      console.error('未知参数', action);
+      break;
+  }
+}
+
+//选择方法
+//========================================
+
+export async function selectVersion(defaultVersionId = null) {
+  const data = (await axiosInstance.get('/version/list?full=true&search=' + JSON.stringify({ 
+    appId: config.appId 
+  }))).data;
+  if (data.length === 0) {
+    console.error('没有版本');
+    return;
+  }
+  const versionId = (await select({
+    choices: data.map(p => ({
+      value: p.id,
+      name: p.version,
+    })),
+    default: defaultVersionId,
+    message: '选择一个版本',
+  }));
+  const versionName = data.find(p => p.id === versionId).version;
+  return { versionId, versionName };
+}
+export async function selectUpdate() {
+  const { versionId } = await selectVersion();
+  const data = (await axiosInstance.get('/version/update?search=' + JSON.stringify({ versionId }))).data;
+  if (data.length === 0) {
+    console.error('没有更新');
+    return;
+  }
+  const resultId = (await select({
+    choices: data.map(p => ({
+      value: p.id,
+      name: p.version,
+    })),
+    message: '选择一个更新',
+  }));
+  return resultId;
+}
+
+//更新相关
+//========================================
+
+const stateConstant = [ 'Deleted', 'Normal', 'Deprecated' ];
+const typeConstant = [ 'Unknow', 'Web', 'app' ];
+const storageTypeConstant = [ 'Unknow', 'LocalStorage', 'AliOSS' ];
+
+export async function postUpdate(type, options) {
+  switch (type) {
+    case 'web': {
+      await postWebUpdate(axiosInstance, options);
+      break;
+    }
+    case 'app': {
+      await postAppUpdate(axiosInstance, options);
+      break;
+    }
+    default:
+      console.error('Unknow type', type);
+      break;
+  }
+}
+export async function deprecateOrDeleteUpdate(updateId) {
+  if (!updateId)
+    updateId = await selectUpdate();
+
+  const { currentUpdateInfo, currentVersionInfo } = await viewUpdate(updateId);
+
+  const deprecate = (await select({
+    choices: [
+      {
+        name: 'Deprecate',
+        value: 0,
+      },
+      {
+        name: 'Delete',
+        value: 1,
+      },
+    ],
+    message: '删除或弃用?',
+  })) === 0;
+
+  if (deprecate && currentUpdateInfo.type !== 1) {
+    console.log('只有Web更新允许弃用');
+    return;
+  }
+  if (currentUpdateInfo.status === 0) {
+    console.log(`当前状态 ${stateConstant[currentUpdateInfo.status]} 无法弃用`);
+    return;
+  }
+
+  if (deprecate) {
+    if (!await confirm({ message: `确定弃用当前版本 ${currentUpdateInfo.versionCode} ?弃用会删除存储文件以及备份。此操作无法恢复!`, default: false }))
+      return;
+  } else {
+    if (!await confirm({ message: `确定删除当前版本 ${currentUpdateInfo.versionCode} ?此操作无法恢复!`, default: false }))
+      return;
+  }
+
+  try {
+    await axiosInstance.post(`/update/${deprecate ? 'deprecate' : 'delete'}`, { updateId });
+    console.log(`${deprecate ? '弃用' : '删除'} 成功`);
+  } catch (e) {
+    console.error(`操作失败`, e);
+  }
+}
+export async function viewUpdate(updateId) {
+  
+  let currentUpdateInfo = null
+  let currentVersionInfo = null
+
+  try {
+    currentUpdateInfo = (await axiosInstance.get('/update/' + updateId)).data;
+  } catch (e) {
+    console.error('加载更新信息失败', updateId);
+  }
+  try {
+    currentVersionInfo = (await axiosInstance.get('/version/' + currentUpdateInfo.versionId)).data;
+  } catch (e) {
+    console.error('加载版本信息失败', currentUpdateInfo.versionId);
+  }
+
+  const table = new Table({
+    head: ['key', 'data'], 
+    colWidths: [20, 40]
+  });
+  table.push(
+    [ 'ID', currentUpdateInfo.id ],
+    [ '所属应用', currentUpdateInfo.name ],
+    [ '更新信息', currentUpdateInfo.updateInfo ],
+    [ '版本号', currentUpdateInfo.versionCode ],
+    [ '创建时间', new Date(currentUpdateInfo.createAt).toString() ],
+    [ '类型', typeConstant[currentUpdateInfo.type] ],
+    [ '状态', stateConstant[currentUpdateInfo.status] ],
+    [ '强制更新', currentUpdateInfo.force ],
+    [ '公共访问路径', currentUpdateInfo.publicUrl ],
+    [ '存储类型', storageTypeConstant[currentUpdateInfo.storageType] ],
+    [ '存储路径', currentUpdateInfo.storagePath ],
+  );
+
+  console.log(table.toString());
+
+  if (currentUpdateInfo.activeWebVersionName) {
+    table.push(
+      [ '使用中的Web版本', currentUpdateInfo.activeWebVersionName ],
+    );
+  }
+  if (currentUpdateInfo.activeAppVersionName) {
+    table.push(
+      [ '使用中的App版本', currentUpdateInfo.activeAppVersionName ],
+    );
+  }
+
+  return {
+    currentUpdateInfo,
+    currentVersionInfo,
+  }
+}
+export async function getUpdate(action, type, all, options) {
+  const typeNotANumber = isNaN(new Number(type)); 
+
+  if (action === 'view' && (!type || typeNotANumber)) {
+
+    let hasSerch = false;
+    const search = {};
+    const sort = {
+      field: "createAt",
+      order: "descend"
+    }
+    if (typeNotANumber && type !== 'all') {
+      hasSerch = true;
+      search.version = type;
+    }
+
+    const data = await axiosInstance.get('/update/list?full=true' + (hasSerch ? ('&search=' + JSON.stringify(search)) : '') + '&sort=' + JSON.stringify(sort));
+    const table = new Table({
+      head: ['ID', '版本', '版本号', '类型', '状态'], 
+      colWidths: [10, 10, 20, 10, 15 ]
+    });
+
+    if (type !== 'all' && all !== 'all' && data.data.length > 10) {
+      data.data = data.data.slice(0, 10);
+      console.log('filter!', all);
+    }
+
+    data.data.forEach((d) => {
+      table.push([ 
+        d.id, 
+        (d.activeAppVersionName ? `${d.activeAppVersionName} (App)` : (
+          d.activeWebVersionName? `${d.activeWebVersionName} (Web)` : '无'
+        )),  
+        d.versionCode, typeConstant[d.type], stateConstant[d.status] 
+      ]);
+    })
+    
+    console.log(table.toString());
+  } else {
+    switch (action) {
+      case 'post': {
+        await postUpdate(type, options);
+        break;
+      }
+      case 'delete': {
+        await deprecateOrDeleteUpdate(type);
+        break;
+      }
+      case 'view': {
+        viewUpdate(type);
+        break;
+      }
+      default: 
+        console.error('Unknow action', action);
+        break;
+    }
+  }
+}
+export async function testVersion(version) {
+  try {
+    const data = await axiosInstance.get('/update-get-info?version=' + version);
+    console.log('版本信息', data.data);
+  } catch(e) {
+    console.log('获取失败', e);
+  }
+}

+ 152 - 0
src/scripts/UpdateScript/auth.mjs

@@ -0,0 +1,152 @@
+/**
+ * 更新发布工具 - 认证与服务器连接模块
+ *
+ * Copyright © 2025 imengyu.top imengyu-update-server
+ */
+
+import { readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import axios from 'axios';
+import md5 from 'md5';
+import { password } from '@inquirer/prompts';
+import { config } from './postConfig.mjs';
+
+// 基础配置
+// ========================================
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+export const constant = {
+  ServerUrl: config.server,
+  TokenSave: path.resolve(__dirname, './_token.json'),
+};
+
+let currentData = {
+  token: '',
+  identifier: '',
+};
+
+function initIdentifier() {
+  if (!currentData.identifier)
+    currentData.identifier = `commandClient${Math.floor(Math.random() * 1000)}`;
+}
+
+function getErrorMessage(e) {
+  return e instanceof Error ? e.message : (typeof e === 'object' ? e : '' + e);
+}
+
+// Axios 实例
+// ========================================
+
+export const axiosInstance = axios.create({
+  baseURL: constant.ServerUrl,
+  timeoutErrorMessage: '请求超时,请检查网络连接',
+  responseType: 'json',
+  withCredentials: false,
+  validateStatus: () => true,
+});
+
+axiosInstance.interceptors.request.use((value) => {
+  value.headers['authorization'] = JSON.stringify({
+    auth: currentData?.token?.authName,
+    validity: currentData?.token?.authKey,
+    nonce: 'aaaaaaaaaa',
+    identifier: currentData.identifier,
+    key: 'abc123',
+  });
+  value.url =
+    value.url +
+    (value.url.includes('?') ? '&' : '?') +
+    `identifier=${currentData.identifier}`;
+  return value;
+});
+
+axiosInstance.interceptors.response.use((value) => {
+  if (value.data.success) return value.data;
+  else return Promise.reject(value.data);
+});
+
+// 认证初始化
+// ========================================
+
+/**
+ * 初始化认证模块,从本地加载 token
+ * @returns {Promise<void>}
+ */
+export async function initAuth() {
+  try {
+    const res = await readFile(constant.TokenSave);
+    const parsed = JSON.parse(res);
+    if (parsed && typeof parsed === 'object') {
+      currentData = { ...currentData, ...parsed };
+    }
+  } catch {
+    // 无 token 文件时使用默认值
+  }
+  initIdentifier();
+}
+
+/**
+ * 获取当前认证数据(只读)
+ */
+export function getCurrentData() {
+  return { ...currentData };
+}
+
+// 登录相关
+// ========================================
+
+/**
+ * 检查登录状态
+ */
+export async function checkLogged() {
+  try {
+    await axiosInstance.get('/auth');
+    console.log('已登录');
+  } catch (e) {
+    console.error('获取状态失败:', getErrorMessage(e));
+  }
+}
+
+/**
+ * 登录
+ * @param {string} user 用户名
+ */
+export async function login(user) {
+  try {
+    const pass = await password({ message: '输入密码' });
+    //console.log('测试', user, pass)
+    //throw '测试'
+    //updateServer1
+    //updateServer1
+    const res = await axiosInstance.post('/auth?rember=true', {
+      method: 'key',
+      key: `${user}@${md5(pass)}`,
+    });
+    currentData.token = {
+      authName: res.data.authName,
+      authKey: res.data.authKey,
+    };
+    await writeFile(constant.TokenSave, JSON.stringify(currentData));
+    console.log('登录成功');
+  } catch (e) {
+    console.error('登录失败', getErrorMessage(e));
+  }
+}
+
+/**
+ * 退出登录
+ */
+export async function logout() {
+  currentData.token = '';
+  await writeFile(constant.TokenSave, JSON.stringify(currentData));
+  try {
+    await axiosInstance.delete('/auth');
+    console.log('退出登录成功');
+  } catch (e) {
+    console.error('退出登录失败', getErrorMessage(e));
+  }
+}

+ 22 - 0
src/scripts/UpdateScript/deprecate.html

@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>提示</title>
+    <style>.global-error{position:fixed;left:0;right:0;bottom:100px;top:0;display:flex;flex-direction:column;justify-content:center;align-items:center;background-color:#fff;-webkit-app-region:drag}.global-error span{margin-top:20px;font-size:16px}button{margin-top:20px;padding:0;display:inline-block;-webkit-app-region:no-drag;height:40px;width:120px;appearance:none;outline:none;border:none;background-color:#0083da;color:#fff;cursor:pointer}button:hover{background-color:#005c99}</style>
+  </head>
+  <body>
+    <script>
+      function relaunch() {
+        if (typeof api.relaunch == 'function') 
+          api.relaunch();
+        else
+          location.reload();
+      }
+    </script>
+    <div class="global-error">
+      <span>系统已更新,请重启</span>
+      <button id="load-confirm-button" onclick="relaunch()">确定</button>
+    </div>
+</html>

+ 51 - 0
src/scripts/UpdateScript/index.mjs

@@ -0,0 +1,51 @@
+/**
+ * 更新发布工具
+ *
+ * Copyright © 2025 imengyu.top imengyu-update-server
+ */
+
+import { program } from 'commander';
+import { login, checkLogged, logout, initAuth } from './auth.mjs';
+import { testVersion, getVersion, getUpdate } from './app.mjs';
+
+//程序入口
+//============================================
+
+program
+  .command('login <user>')
+  .description('登录')
+  .action(login);
+program
+  .command('logstate')
+  .description('检查登录状态')
+  .action(checkLogged);
+program
+  .command('logout')
+  .description('退出登录')
+  .action(logout);
+
+program
+  .command('test <version>')
+  .description('测试更新入口')
+  .action(testVersion);
+
+program
+  .command('version <action> [type]')
+  .description('查看版本信息/发布版本/删除版本/设置版本, action 可选 view/new/delete/set-web-update/set-app-update/set-next-app-update')
+  .action(getVersion);
+program
+  .command('update <action> [type] [all]')
+  .description('查看更新信息/发布更新, action 可选 view/post, view type 可选 id/all; post type 可选 web/app')
+  .option('--skip', '跳过构建')
+  .option('--ndelete', '不删除构建文件')
+  .action(getUpdate);
+
+function start() {
+  program.parse(process.argv);
+}
+
+initAuth().then(start);
+
+process.on('unhandledRejection', (reason, p) => {
+  console.error('Promise: ', p, 'Reason: ', reason)
+})

+ 79 - 0
src/scripts/UpdateScript/postConfig.mjs

@@ -0,0 +1,79 @@
+import { readFileSync, existsSync } from 'node:fs';
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { pad } from './utils.mjs';
+import path from 'node:path';
+import JSON5 from 'json5';
+
+//基础配置
+//========================================
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const localConfigPath = path.resolve(__dirname, './_localConfig.json');
+const localConfig = existsSync(localConfigPath) ? JSON.parse(readFileSync(localConfigPath, 'utf8')) : {};
+
+//提交更新配置
+//========================================
+
+export const config = {
+  server: localConfig.updateServer || 'https://update-server1.imengyu.top/',
+  submitKey: '',
+  appId: 2,
+  uploadWebConfig: {
+    storageAction: 'override',
+    storageProps: {
+      overrideMode: 'overrideFiles',
+      overrideFiles: [
+        'latest.yml',
+      ],
+      newFolderNameGenerateType: 'hash',
+    },
+    deprecateConfig: {
+      indexFile: 'index.html',
+      updateHtml: readFileSync(path.resolve(__dirname, './deprecate.html'), 'utf8')
+    },
+  },
+  buildWebCommand: 'npm run build', //构建命令
+  buildWebOutDir: '../../../release', //构建输出目录。相对于当前文件目录  
+  buildWebOptions: {
+    skipFiles: [
+      'win-unpacked',
+      'builder-debug.yml',
+      'der-effective-config.yaml',
+    ], //打包忽略文件,相对于 buildWebOutDir ,判断开头
+  },
+  buildWebOutVersionPath: '', //版本号输出目录,输出版本号至文件以供项目使用。相对于当前文件目录
+  getCustomConfig: async (param, isApp, versionName) => {
+    return {}//额外的自定义配置
+  },
+  /**
+   * 自定义生成Web版本号的方法。
+   * @param {Date} now 当前日期
+   * @param {Number} lastTodaySubVersion 今天上传的之前版本数量
+   * @returns 
+   */
+  buildWebVersionGenerateCommand: async (now, lastTodaySubVersion) => {
+    //生成Web版本号
+    const version = `${now.getFullYear().toString().substring(2)}${pad(now.getMonth() + 1, 2)}${pad(now.getDate(), 2)}.${pad(lastTodaySubVersion, 2)}`;
+    return version;
+  },
+  buildAppCallback: async (param, versionCode, versionName, lastTodaySubVersion) => {
+    //构建App
+    throw new Error('未实现buildAppCallback方法');
+  }, 
+  buildAppGetUploadFile: async (param, versionCode, versionName) => {
+    //获取上传文件路径
+    throw new Error('未实现buildAppGetUploadFile方法');
+  }, 
+  buildAppGetOSSFileName: async (param, versionCode, versionName) => {
+    //生成OSS保存路径
+    throw new Error('buildAppGetOSSFileName');
+  },//构建命令
+  buildAppOutDir: './dist', //构建输出目录。相对于当前文件目录
+  buildAppGetVersion: async (versionName) => {
+    //获取版本号
+    throw new Error('未实现buildAppGetVersion方法');
+  },
+} 

+ 423 - 0
src/scripts/UpdateScript/postUpdate.mjs

@@ -0,0 +1,423 @@
+/**
+ * 更新发布工具
+ * 
+ * Copyright © 2025 imengyu.top imengyu-update-server
+ */
+
+import { confirm, select, input } from '@inquirer/prompts';
+import { writeFile, readFile, access, unlink, stat, constants } from 'node:fs/promises';
+import path from 'node:path';
+import OSS from 'ali-oss';
+import cliProgress from 'cli-progress';
+import { selectVersion } from './app.mjs';
+import { config } from './postConfig.mjs';
+import { readFileRange, compressZip, execAsync } from './utils.mjs';
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+//基础配置
+//========================================
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const UPLOAD_APP_TYPE_LOCAL = 1;
+const UPLOAD_APP_TYPE_ALI_OSS = 2;
+
+async function getConfig() {
+  let postConfig = {
+    lastVersion: '',
+    lastUpdateInfo: '',
+    lastSubmitDay: '',
+    lastTodaySubVersion: 0,
+  };
+  try {
+    postConfig = JSON.parse(await readFile(path.resolve(__dirname, './_config.json')));
+  } catch {
+    //
+  }
+  if (postConfig.lastSubmitDay != new Date().getDate()) 
+    postConfig.lastTodaySubVersion = 0;
+  return postConfig;
+}
+async function saveConfig(postConfig) {
+  postConfig.lastSubmitDay = new Date().getDate();
+  await writeFile(path.resolve(__dirname, './_config.json'), JSON.stringify(postConfig));
+}
+
+async function getUpdateInfo(postConfig) {
+  let lastUseGit = false;
+  let updateInfo = await input({ message: '输入更新信息', default: postConfig.lastUpdateInfo });
+  if (updateInfo === 'git') {
+    console.log('开始获取git提交信息');
+    lastUseGit = true;
+    updateInfo = await execAsync('git log -1 --pretty=format:"%h %s"');
+    console.log('使用git提交信息 "' + updateInfo + '" 作为更新信息');
+  }
+  return { updateInfo, lastUseGit };
+}
+export async function uploadMulitPartLarge(axiosInstance, fileInfo, filePath) {
+
+  console.log('开始分片上传');
+
+  const multuploadInfo = (await axiosInstance.post('/update/update-large-token', {
+    fileSize: fileInfo.size,
+    fileName: path.basename(filePath)
+  })).data;
+  const chunkSize = multuploadInfo.splitPartSize;
+
+  const bar1 = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
+  bar1.start(100, 0);
+
+  for (let i = 0; i < multuploadInfo.allChunks; i++) {
+    const start = i * chunkSize;
+    const len = Math.min(start + chunkSize, fileInfo.size) - start;
+    const uploadZipContent = await readFileRange(filePath, start, len);
+    const subFormData = new FormData();
+    subFormData.append("file", new Blob([ uploadZipContent ], { type : 'application/zip' }), 'upload.zip');
+    subFormData.append("key", multuploadInfo.key);
+    subFormData.append("info", JSON.stringify({}));
+    
+    (await axiosInstance.post('/update/update-large', subFormData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    })).data;
+    
+    bar1.update(Math.floor(i / multuploadInfo.allChunks * 100));
+  }
+
+  bar1.update(100);
+  bar1.stop();
+
+  return multuploadInfo.key;
+}
+async function aliOSSMultipartUpload(client, fileName, uploadFile, progressCallback) {
+  
+  const fileInfo = await stat(uploadFile); // 获取文件信息
+  const partSize = 10 * 1024 * 1024; // 分片大小,这里使用10MB
+  const partCount = Math.ceil(fileInfo.size / partSize); // 计算总分片数
+  
+  console.log(`开始分片上传到阿里OSS,文件大小: ${(fileInfo.size / 1024 / 1024).toFixed(2)}MB,分片数: ${partCount}`);
+  
+  try {
+    // 初始化分片上传
+    const multipartUpload = await client.initMultipartUpload(fileName);
+    const uploadId = multipartUpload.uploadId;
+    
+    // 上传分片
+    const parts = [];
+    for (let i = 0; i < partCount; i++) {
+      const start = i * partSize;
+      const len = Math.min(partSize, fileInfo.size - start);
+      
+      // 读取文件分片
+      const fileData = await readFileRange(uploadFile, start, len);
+      
+      // 上传分片
+      const partResult = await client.uploadPart(fileName, fileData, uploadId, i + 1);
+      parts.push({
+        ETag: partResult.res.headers.etag,
+        PartNumber: i + 1
+      });
+      
+      // 计算并回调进度
+      const progress = (i + 1) / partCount;
+      if (progressCallback) {
+        progressCallback(progress);
+      }
+      
+      console.log(`已上传分片 ${i + 1}/${partCount} (${(progress * 100).toFixed(2)}%)`);
+    }
+    
+    // 完成分片上传
+    await client.completeMultipartUpload(fileName, uploadId, parts);
+    
+    console.log('阿里OSS分片上传完成');
+    
+    // 获取上传后的文件URL
+    const url = await client.generateObjectUrl(fileName);
+    return url;
+  } catch (error) {
+    console.error('阿里OSS分片上传失败:', error);
+    throw error;
+  }
+}
+
+//App更新与提交
+//========================================
+
+export async function postAppUpdate(axiosInstance, param) {
+  const postConfig = await getConfig();
+  const { versionId, versionName } = await selectVersion(postConfig.lastVersion);
+  const { updateInfo, lastUseGit } = await getUpdateInfo(postConfig);
+
+  const serverConfig = await axiosInstance.post('/update/update-post', { 
+    config: { 
+      type: 2, 
+      test: true, 
+      versionId, 
+      uploadAppConfig: {}, 
+      submitKey: config.submitKey 
+    } 
+  });
+
+  postConfig.lastVersion = versionId;
+  postConfig.lastUpdateInfo = lastUseGit ? 'git': updateInfo;
+  postConfig.lastTodaySubVersion++;
+
+  const updateSource = (await select({
+    choices: [
+      {
+        name: '重新构建',
+        value: 'rebuild',
+      },
+      {
+        name: '已上传的阿里OSS文件路径',
+        value: 'uploaded-alioss',
+      },
+    ],
+    message: '选择上传来源',
+    default: 'rebuild',
+  }));
+
+  const force = await confirm({ message: '强制更新?', default: false });
+  const updateNext = await confirm({ message: '作为下个版本?', default: false });
+  const versionCode = await config.buildAppGetVersion(versionName);
+
+  await saveConfig(postConfig);
+
+  if (updateSource === 'rebuild') 
+    await config.buildAppCallback(param, versionCode, versionName, postConfig.lastTodaySubVersion);
+  else if (updateSource === 'uploaded-alioss') {
+    const fileName = await input({ message: '输入已上传的阿里OSS文件路径' });
+    try {
+      const result = (await axiosInstance.post('/update/update-post', {
+        type: 2,
+        ossConfig: {
+          ossPath: fileName,
+          ossPublic: '',
+        },
+        uploadAppConfig: {
+          updateAsNext: updateNext,
+        },
+        versionId,
+        updateInfo,
+        versionCode: versionCode
+      })).data;
+      console.log('上传成功');
+      console.log('新更新ID: ' + result.updateId);
+    } catch (e) {
+      console.error('上传失败', e);
+    }
+    return;
+  }
+  else {
+    console.error('错误的选择');
+    return;
+  }
+
+  const uploadFile = await config.buildAppGetUploadFile(param, versionCode, versionName);
+  const fileName = `${await config.buildAppGetOSSFileName(param, versionCode, path.basename(uploadFile), uploadFile)}`;
+  try {
+    await access(uploadFile, constants.R_OK)
+  } catch {
+    console.error(`Failed to access ${uploadFile}, did you created it?`);
+    return;
+  }
+
+  console.log('开始上传');
+
+  //小于8mb则小文件上传,否则使用阿里OSS上传
+  const fileInfo = await stat(uploadFile);
+  if (fileInfo.size < 8 * 1024 * 1024) {
+    const appData = (await readFile(uploadFile));
+
+    const formData = new FormData();
+    formData.append("file", new Blob([ appData ], { type : 'application/zip' }));
+    formData.append("config", JSON.stringify({
+      type: 2,
+      versionId,
+      versionCode,
+      updateInfo,
+      updateCustomConfig: await config.getCustomConfig(param, true, versionName),
+      uploadAppConfig: { updateAsNext: updateNext },
+      force,
+      fileName,
+    }));
+
+    try {
+      const result = (await axiosInstance.post('/update/update-post', formData, {
+        headers: { 'Content-Type': 'multipart/form-data' }
+      })).data;
+      console.log('上传成功');
+      console.log('新更新ID: ' + result.updateId);
+    } catch (e) {
+      console.error('上传失败', e);
+    }
+  } else {
+
+    let ossConfig;
+    let multuploadedKey;
+
+    if (serverConfig.uploadAppType === UPLOAD_APP_TYPE_LOCAL) {
+      //本地大文件上传
+      multuploadedKey = await uploadMulitPartLarge(axiosInstance, fileInfo, uploadFile);
+    } else if (serverConfig.uploadAppType === UPLOAD_APP_TYPE_ALI_OSS) {
+      //阿里OSS上传
+      //请求STS进行临时授权
+      const stsToken = (await axiosInstance.post('/update/update-ali-oss-sts')).data;
+      const client = new OSS({
+        region: stsToken.Region,
+        accessKeyId: stsToken.AccessKeyId,
+        accessKeySecret: stsToken.AccessKeySecret,
+        stsToken: stsToken.SecurityToken,
+        bucket: stsToken.Bucket,
+        refreshSTSToken: async () => {
+          const refreshToken = (await axiosInstance.get("/update/update-ali-oss-sts")).data;
+          return {
+            accessKeyId: refreshToken.AccessKeyId,
+            accessKeySecret: refreshToken.AccessKeySecret,
+            stsToken: refreshToken.SecurityToken,
+          };
+        },
+      });
+      
+      //小于96mb则直接上传,否则分片上传
+
+      console.log('Start upload to ali oss');
+
+      let returnUrl = '';
+      if (fileInfo.size < 96 * 1024 * 1024) {
+        const result = await client.put(fileName, uploadFile);
+        returnUrl = result.url;
+      } else {
+        const bar1 = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
+        bar1.start(100, 0);
+        returnUrl = await aliOSSMultipartUpload(client, fileName, uploadFile, (p) => {
+          bar1.update(p * 100);
+        });
+        bar1.update(100);
+        bar1.stop();
+      }
+
+      ossConfig = {
+        ossPath: fileName,
+        ossPublic: returnUrl,
+      };
+
+      console.log('Upload to ali oss done');
+    } else {
+      console.error('错误的 uploadAppType 配置 ' + serverConfig.uploadAppType);
+    }
+  
+    try {
+      const result = (await axiosInstance.post('/update/update-post', {
+        config: {
+          type: 2,
+          ossConfig,
+          multuploadedKey,
+          uploadAppConfig: {
+            updateAsNext: updateNext,
+          },
+          updateCustomConfig: await config.getCustomConfig(param, true, versionName),
+          versionId,
+          updateInfo,
+          versionCode: versionCode,
+          fileName
+        },
+      })).data;
+      console.log('上传成功');
+      console.log('新更新ID: ' + result.updateId);
+    } catch (e) {
+      console.error('上传失败', e);
+    }
+  }
+}
+
+//Web更新与提交
+//========================================
+
+export async function postWebUpdate(axiosInstance, param) {
+  const skipBuild = param.skip
+  const noDelete = param.ndelete;
+  
+  const postConfig = await getConfig();
+  const { versionId, versionName } = await selectVersion(postConfig.lastVersion);
+  const { updateInfo, lastUseGit } = await getUpdateInfo(postConfig);
+
+  postConfig.lastVersion = versionId;
+  postConfig.lastUpdateInfo = lastUseGit ? 'git': updateInfo;
+  postConfig.lastTodaySubVersion++;
+
+  await axiosInstance.post('/update/update-post', { config: { type: 1, test: true, versionId, uploadWebConfig: config.uploadWebConfig, submitKey: config.submitKey } });
+
+  const now = new Date();
+  const versionCode = await config.buildWebVersionGenerateCommand(now, postConfig.lastTodaySubVersion);
+
+  if (config.buildWebOutVersionPath)
+    await writeFile(path.resolve(__dirname, config.buildWebOutVersionPath), versionCode);
+
+  await saveConfig(postConfig);
+  
+  if (!skipBuild && config.buildWebCommand) {
+    console.log('正在执行构建...');
+    await execAsync(config.buildWebCommand);
+    console.log('构建完成');
+  }
+
+  const distDir = path.resolve(__dirname, config.buildWebOutDir);
+
+  try {
+    await access(distDir, constants.R_OK)
+  } catch {
+    console.error(`Failed to access ${distDir}`);
+    return;
+  }
+
+  const outputPath = __dirname + '/upload.zip';
+  const skipFiles = config?.buildWebOptions?.skipFiles ?? [];
+
+  if (!skipBuild) {
+    console.log('开始压缩zip...');
+    await compressZip(distDir, outputPath, skipFiles);
+  }
+
+  console.log('开始上传zip');
+
+  let success = false;
+
+  //小于8mb则小文件上传,否则分片上传
+  const fileInfo = await stat(outputPath);
+  const formData = new FormData();
+  const submitConfig = {
+    type: 1,
+    versionId,
+    updateInfo,
+    versionCode,
+    uploadWebConfig: config.uploadWebConfig,
+    updateCustomConfig: await config.getCustomConfig(param, true, versionName),
+  }
+
+  if (fileInfo.size < 8 * 1024 * 1024) {
+    const uploadZipContent = await readFile(outputPath);
+    formData.append("file", new Blob([ uploadZipContent ], { type : 'application/zip' }), 'upload.zip');
+  } else {
+    submitConfig.multuploadedKey = await uploadMulitPartLarge(axiosInstance, fileInfo, outputPath);
+  }
+
+  try {
+    formData.append("config", JSON.stringify(submitConfig));
+    const result = (await axiosInstance.post('/update/update-post', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    })).data;
+    console.log('上传成功');
+    console.log('新更新ID: ' + result.updateId);
+    success = true;
+  } catch (e) {
+    console.error('上传失败', e);
+  }
+  if (!success || noDelete) 
+    return;
+    
+  console.log('删除zip');
+  await unlink(outputPath);
+}

+ 111 - 0
src/scripts/UpdateScript/utils.mjs

@@ -0,0 +1,111 @@
+import fs from 'fs';
+import * as archiver from 'archiver';
+import { exec } from 'child_process';
+import { readdir, stat } from 'fs/promises';
+
+export function readFileRange(file, start, length) {
+  return new Promise((resolve, reject) => {
+    fs.open(file, 'r', (err, fd) => {
+      if (err) {
+        reject('Error opening file:', er);
+        return;
+      }
+      const buffer = Buffer.alloc(length);
+      fs.read(fd, buffer, 0, length, start, (err, bytesRead, buffer) => {
+        if (err) {
+          reject('Error reading file:', err)
+          return;
+        }
+        fs.close(fd, (err) => {
+          if (err) {
+            reject('Error closing file:', err)
+            return ;
+          }
+          resolve(buffer);
+        });
+      });
+    });
+  })
+  
+}
+export function pad(num, n) {
+  var strNum = num.toString();
+  var len = strNum.length;
+  while (len < n) {
+    strNum = "0" + strNum;
+    len++;
+  }
+  return strNum;
+}
+export function execAsync(command) {
+  return new Promise((resolve, reject) => {
+    console.log('[CMD] ' + command);
+    exec(command, (error, stdout, stderr) => {
+      if (error) {
+        reject(error);
+        return;
+      }
+      resolve(stdout);
+    });
+  })
+}
+/**
+ * 压缩zip
+ * @param {string} dir 压缩目录
+ * @param {string} targetFilePath 压缩文件路径
+ * @param {string[]} skipFiles 压缩忽略文件 支持按开头匹配,和文件类型通配符匹配,如:'*.js', '*.css', '*.html'
+ * @returns {Promise<void>}
+ */
+export async function compressZip(dir, targetFilePath, skipFiles) {
+  const output = fs.createWriteStream(targetFilePath);
+  const archive = archiver('zip', {
+    zlib: { level: 9 } // Sets the compression level.
+  });
+  archive.pipe(output);
+
+  function checkPathSkip(pathString) {
+    if (!pathString)
+      return true;
+    if (pathString.startsWith('.') || pathString.startsWith('/.'))
+      return true;
+    if (!skipFiles || skipFiles.length === 0)
+      return;
+    return skipFiles.find((item) => (
+      pathString.startsWith(item) || pathString.startsWith(`/${item}`)
+      || (item.includes('*.') && pathString.endsWith(item.split('*')[1]))
+    ));
+  }
+
+  async function loopDir(path, subPrefix) {
+    const files = await readdir(path);
+    for (const file of files) {
+      const subPath = subPrefix + '/' + file;
+      if (checkPathSkip(subPath))
+        continue;
+      console.log('File: ' + subPath);
+      const filestat = await stat(dir + subPath);
+      if (filestat.isDirectory()) {
+        await loopDir(dir + subPath, subPath);
+      } else {
+        archive.file(dir + subPath, { name: subPath });
+      }
+    }
+  }
+  await loopDir(dir, '')
+
+  console.log('等待压缩zip...');
+
+  const waitArchive = new Promise((resolve, reject) => {
+    output.on('close', function() {
+      console.log(archive.pointer() + ' total bytes');
+      console.log('archiver has been finalized and the output file descriptor has closed.');
+      resolve();
+    });
+    archive.on('error', function(err) {
+      reject(err);
+    });
+  })
+
+  await archive.finalize();
+  await waitArchive;
+}

+ 29 - 3
src/views/About.vue

@@ -3,11 +3,14 @@
     <div class="about-content">
       <div class="about-header">
         <div class="about-icon">
-          <img src="/icon.svg" alt="演示程序" />
+          <img src="/icon.svg" alt="Logo" />
         </div>
         <div class="about-title">
-          <h1>演示程序</h1>
-          <p class="about-tagline">厦门博合文化科技有限公司内部的项目演示</p>
+          <h1>
+            {{ appTitle }}
+            <img src="../assets/edit.svg" title="编辑应用标题" @click="showEditModal" style="cursor: pointer;width:25px;" />
+          </h1>
+          <p class="about-tagline">厦门博合文化科技有限公司</p>
         </div>
       </div>
       <div class="about-info">
@@ -24,12 +27,35 @@
           <span class="info-value">{{ version.buildDate }}</span>
         </div>
       </div>
+      <a-modal v-model:open="modalVisible" title="编辑应用标题" @ok="handleOk" okText="确定" cancelText="取消">
+        <a-input v-model:value="editTitle" placeholder="请输入应用标题" @pressEnter="handleOk" />
+      </a-modal>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
+import { ref } from 'vue'
 import version from '../config/version.json'
+import { APP_TITLE_KEY, DEFAULT_TITLE } from '../composeable/AppTitle'
+
+const appTitle = ref(localStorage.getItem(APP_TITLE_KEY) || DEFAULT_TITLE)
+const editTitle = ref('')
+const modalVisible = ref(false)
+
+function showEditModal() {
+  editTitle.value = appTitle.value
+  modalVisible.value = true
+}
+
+function handleOk() {
+  const newTitle = editTitle.value.trim()
+  if (newTitle) {
+    appTitle.value = newTitle
+    localStorage.setItem(APP_TITLE_KEY, newTitle)
+  }
+  modalVisible.value = false
+}
 </script>
 
 <style lang="scss">

+ 0 - 2
src/views/Config.vue

@@ -31,7 +31,6 @@
         <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>
@@ -53,7 +52,6 @@
         <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>

+ 5 - 1
src/views/Expand.vue

@@ -13,16 +13,20 @@ function openLeftPanel() {
 </script>
 
 <style lang="scss">
+html {
+  background-color: transparent;
+}
 .expand-container {
   padding: 0px;
   overflow: hidden;
+  background-color: transparent;
 
   .open-left-btn {
     width: 40px;
     height: 40px;
     background-color: #fff;
     border: none;
-    border-radius: 50%;
+    border-radius: 6px;
     cursor: pointer;
     display: flex;
     align-items: center;

+ 3 - 2
src/views/Hello.vue

@@ -2,10 +2,10 @@
   <div class="hello-container">
     <div class="hello-content">
       <div class="hello-icon">
-        <img src="../assets/hello.svg" alt="演示程序" />
+        <img src="../assets/hello.svg" alt="logo" />
       </div>
       <div class="hello-text">
-        <h1 class="hello-title">欢迎使用演示程序</h1>
+        <h1 class="hello-title">欢迎您!</h1>
         <p class="hello-description">从左侧菜单选择应用程序打开</p>
       </div>
     </div>
@@ -23,6 +23,7 @@
   background: $bg-lighter;
   padding: 20px;
   padding-bottom: 150px;
+  background-color: #000;
 }
 
 .hello-content {

+ 30 - 1
src/views/Main.vue

@@ -6,6 +6,7 @@ import { AppItem } from '../model/App'
 import AppList from '../components/AppList.vue'
 import AppListItem from '../components/AppListItem.vue'
 import UpdateStatus from '../components/UpdateStatus.vue'
+import { APP_TITLE_KEY, DEFAULT_TITLE } from '../composeable/AppTitle.ts'
 
 // 状态管理
 const apps = ref<Record<string, AppItem[]>>({})
@@ -33,6 +34,11 @@ async function loadApps() {
     } else {
       apps.value = data
     }
+    let id = 0;
+    for (const group in apps.value) {
+      for (const app of apps.value[group])
+        app.id = ++id;
+    }
   } catch (error) {
     console.error('Failed to load apps:', error)
   }
@@ -58,6 +64,7 @@ function selectApp(app: AppItem) {
       return 0
     }
 
+    document.title = app.title;
     window.electronAPI.loadChildUrl(app.url, app.keepScreenSize ? (
       parseAspectRatio(app.aspectRatio || '16:9')
     ) : 0, app.inputPassword ? {
@@ -69,6 +76,21 @@ function selectApp(app: AppItem) {
     isLoading.value = false
   }, 2000);
 }
+
+// 打开应用
+function openInSystem(app: AppItem) {
+  window.electronAPI.openInSystem(app.url)
+}
+
+// 刷新应用
+function refreshApp(app: AppItem) {
+  if (app.id === selectedApp.value?.id) {
+    selectApp(app)
+  } else {
+    window.electronAPI.refresh()
+  }
+}
+
 // 退出应用
 function exitApp() {
   window.electronAPI.exit()
@@ -139,8 +161,10 @@ function showMenu() {
   })
 }
 
+
 // 初始化加载数据
 onMounted(() => {
+  document.title = localStorage.getItem(APP_TITLE_KEY) || DEFAULT_TITLE
   loadApps()
   window.ipcRenderer.on('main-side-state-changed', (_event, isOpen) => {
     isLeftPanelOpen.value = isOpen
@@ -175,6 +199,8 @@ onMounted(() => {
             :app="app"
             :activeAppId="selectedApp?.id"
             @selectApp="selectApp(app)"
+            @openInSystem="openInSystem(app)"
+            @refreshApp="refreshApp(app)"
           />
         </div>
       </AppList>
@@ -215,6 +241,8 @@ onMounted(() => {
     display: flex;
     flex-direction: column;
     border-right: 1px solid $bg-lighter;
+    border-top-right-radius: 10px;
+    border-bottom-right-radius: 10px;
 
     .panel-title {
       display: flex;
@@ -280,8 +308,9 @@ onMounted(() => {
   font-weight: bold;
   transition: all 0.3s ease;
   color: #000;
-  border-radius: 50%;
+  border-radius: 6px;
   background-color: transparent;
+  background-color: #efefef;
 
   img {
     width: 16px;