Prechádzať zdrojové kódy

📦 增加更新脚本部署

快乐的梦鱼 1 týždeň pred
rodič
commit
5684017b3f

+ 2 - 2
package-lock.json

@@ -15,7 +15,7 @@
         "@vuemap/vue-amap": "^2.1.12",
         "@vueup/vue-quill": "^1.2.0",
         "ant-design-vue": "^4.2.6",
-        "axios": "^1.9.0",
+        "axios": "^1.11.0",
         "bootstrap": "^5.3.0",
         "dayjs": "^1.11.18",
         "lodash-es": "^4.17.21",
@@ -32,7 +32,7 @@
         "vue3-carousel": "^0.15.0"
       },
       "devDependencies": {
-        "@inquirer/prompts": "^7.5.3",
+        "@inquirer/prompts": "^7.8.4",
         "@tsconfig/node22": "^22.0.2",
         "@types/ali-oss": "^6.16.11",
         "@types/node": "^22.16.5",

+ 4 - 3
package.json

@@ -11,7 +11,8 @@
     "build": "run-p type-check \"build-only {@}\" --",
     "preview": "vite preview",
     "build-only": "vite build",
-    "type-check": "vue-tsc --build"
+    "type-check": "vue-tsc --build",
+    "updater": "node src/scripts/UpdateScript/index.mjs"
   },
   "dependencies": {
     "@imengyu/imengyu-utils": "^0.0.14",
@@ -21,7 +22,7 @@
     "@vuemap/vue-amap": "^2.1.12",
     "@vueup/vue-quill": "^1.2.0",
     "ant-design-vue": "^4.2.6",
-    "axios": "^1.9.0",
+    "axios": "^1.11.0",
     "bootstrap": "^5.3.0",
     "dayjs": "^1.11.18",
     "lodash-es": "^4.17.21",
@@ -38,7 +39,7 @@
     "vue3-carousel": "^0.15.0"
   },
   "devDependencies": {
-    "@inquirer/prompts": "^7.5.3",
+    "@inquirer/prompts": "^7.8.4",
     "@tsconfig/node22": "^22.0.2",
     "@types/ali-oss": "^6.16.11",
     "@types/node": "^22.16.5",

+ 9 - 0
src/assets/scss/main.scss

@@ -80,6 +80,7 @@ $small-banner-height: 445px;
     margin: 0 auto;
     max-width: $selection-max-width;
     background-color: $box-dark-trans-color3;
+    -webkit-backdrop-filter: blur(5px);
     backdrop-filter: blur(5px);
     display: flex;
     flex-direction: row;
@@ -202,10 +203,17 @@ $small-banner-height: 445px;
 
       margin-bottom: 40px;
 
+      &.small {
+        margin-bottom: 20px;
+      }
       &.left-right {
         justify-content: space-between;
       }
 
+      .button-placeholder {
+        flex-shrink: 0;
+        width: 100px;
+      }
       .small-more {
         display: flex;
         flex-direction: row;
@@ -228,6 +236,7 @@ $small-banner-height: 445px;
       padding: 10px 15px;
       margin-right: 8px;
       cursor: pointer;
+      -webkit-user-select: none;
       user-select: none;
       outline: none;
       flex-shrink: 0;

+ 2 - 2
src/components/FooterSmall.vue

@@ -1,6 +1,6 @@
 <template>
   <footer class="small-footer">
-    <a href="https://minnan.wenlvti.net/">闽南文化生态保护区 (厦门市)</a>
+    <!-- <a href="https://minnan.wenlvti.net/">闽南文化生态保护区 (厦门市)</a> -->
     <a href="#">
       <img src="@/assets/images/footer/GonganLogo.png" />
       闽公网安备 44040202000131号
@@ -17,7 +17,7 @@
   flex-direction: row;
   justify-content: center;
   align-items: center;
-  padding: 40px 0;
+  padding: 10px 0;
   background-color: $background-color;
 
   a {

+ 14 - 10
src/pages/forms/form.vue

@@ -3,17 +3,18 @@
   <div class="about main-background main-background-type0">
     <div class="nav-placeholder"></div>
     <!-- 表单 -->
-    <section class="main-section ">
+    <section class="main-section small-h">
       <div class="content">
-        <a-button :icon="h(ArrowLeftOutlined)" @click="handleBack">返回主页</a-button>
-        <div class="title">
+        <div class="title left-right small">
+          <a-button :icon="h(ArrowLeftOutlined)" @click="handleBack">返回主页</a-button>
           <h2>{{ title }}</h2>
+          <div class="button-placeholder"></div>
         </div>
         <a-spin v-if="loadingData" />
         <template v-else>   
           <a-tabs centered>
             <a-tab-pane key="1" :tab="basicTabText">
-              <ScrollRect scroll="vertical" style="height: 80vh">
+              <ScrollRect scroll="vertical" style="height: 70vh">
                 <DynamicForm
                   ref="formBase"
                   :model="(formModel as any)" 
@@ -21,10 +22,13 @@
                 />
               </ScrollRect>
               <div class="d-flex flex-column mt-3">
-                <span>
-                  <ExclamationCircleOutlined />
-                  提示:上传文件时请勿离开页面防止上传失败,在关闭页面之前请提交您的修改以防丢失。
-                </span>
+                <div class="d-flex flex-row w-100 align-items-center justify-content-between">
+                  <span>
+                    <ExclamationCircleOutlined class="me-3" />
+                    提示:上传文件时请勿离开页面防止上传失败,在关闭页面之前请提交您的修改以防丢失。
+                  </span>
+                  <a-button size="small" type="primary">历史版本</a-button>
+                </div>
                 <a-button 
                   type="primary"
                   block 
@@ -64,9 +68,9 @@ import { useWindowOnUnLoadConfirm } from '@/composeable/WindowOnUnLoad';
 import { DynamicForm, type IDynamicFormOptions, type IDynamicFormRef } from '@imengyu/vue-dynamic-form';
 import { message, Modal, type FormInstance } from 'ant-design-vue';
 import { ArrowLeftOutlined, ExclamationCircleOutlined } from '@ant-design/icons-vue';
-import InheritorContent from '@/api/inheritor/InheritorContent';
-import type { DataModel } from '@imengyu/js-request-transform';
 import { ScrollRect } from '@imengyu/vue-scroll-rect';
+import type { DataModel } from '@imengyu/js-request-transform';
+import InheritorContent from '@/api/inheritor/InheritorContent';
 
 const props = defineProps({
   title: {

+ 4 - 1
src/pages/index.vue

@@ -6,7 +6,10 @@
       <h1>欢迎!</h1>
       <p></p>
       
-      <RouterLink v-if="authStore.isLogged" to="/inheritor">
+      <RouterLink v-if="authStore.isLogged && authStore.loginType === 1" to="/admin">
+        <a-button size="large" type="primary">进入管理员页面</a-button>
+      </RouterLink>
+      <RouterLink v-else-if="authStore.isLogged" to="/inheritor">
         <a-button size="large" type="primary">进入非遗数字化资源信息校对</a-button>
       </RouterLink>
       <RouterLink v-else to="/login">

+ 5 - 0
src/scripts/.gitignore

@@ -0,0 +1,5 @@
+node_modules
+_config.json
+_localConfig.json
+_token.json
+upload.zip

+ 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>

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

@@ -0,0 +1,530 @@
+/**
+ * 更新发布工具
+ * 
+ * Copyright © 2025 imengyu.top imengyu-update-server
+ */
+
+import { program } from 'commander';
+import { password, input, confirm, select } from '@inquirer/prompts';
+import { writeFile, readFile } from 'node:fs/promises';
+import { postAppUpdate, postWebUpdate } from './postUpdate.mjs';
+import Table from 'cli-table3';
+import md5 from 'md5';
+import axios from 'axios';
+import path from 'path';
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { config } from './postConfig.mjs';
+
+//基础配置
+//========================================
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+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)}`;
+}
+
+readFile(constant.TokenSave).then((res) => {
+  currentData = JSON.parse(res);
+  initIdentifier();
+  start();
+}).catch(() => {
+  initIdentifier();
+  start();
+})
+
+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);
+});
+
+function getErrorMessage(e) {
+  return e instanceof Error ? e.message : (typeof e === 'object' ? e : ('' + e));
+}
+
+//登录相关
+//========================================
+
+async function checkLogged() {
+  try {
+    await axiosInstance.get('/auth');
+    console.log('已登录');
+  } catch (e) {
+    console.error('获取状态失败:', getErrorMessage(e));
+  }
+}
+async function login(user) {
+  try {
+    const pass = await password({ message: '输入密码' });
+    const res = await axiosInstance.post('/auth?rember=true', {
+      method: 'key',
+      key: `${user}@${md5(pass)}`,
+    })
+    currentData.token = {
+      authName: res.data.authName,
+      authKey: res.data.authKey,
+    };
+    writeFile(constant.TokenSave, JSON.stringify(currentData));
+    console.log('登录成功');
+  } catch (e) {
+    console.error('登录失败', getErrorMessage(e));
+  }
+}
+async function logout() {
+  currentToken = '';
+  writeFile(constant.TokenSave, JSON.stringify(currentData));
+  try {
+    await axiosInstance.delete('/auth');
+    console.log('退出登录成功');
+  } catch(e) {
+    console.error('退出登录失败', getErrorMessage(e));
+  }
+}
+
+//版本相关
+//========================================
+
+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());
+  }
+}
+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,
+    })),
+    default: defaultVersionId,
+    message: '选择一个更新',
+  }));
+  return resultId;
+}
+
+//更新相关
+//========================================
+
+const stateConstant = [ 'Deleted', 'Normal', 'Deprecated' ];
+const typeConstant = [ 'Unknow', 'Web', 'app' ];
+const storageTypeConstant = [ 'Unknow', 'LocalStorage', 'AliOSS' ];
+
+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;
+  }
+}
+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);
+  }
+}
+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,
+  }
+}
+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;
+    }
+  }
+}
+async function testVersion(version) {
+  try {
+    const data = await axiosInstance.get('/update-get-info?version=' + version);
+    console.log('版本信息', data.data);
+  } catch(e) {
+    console.log('获取失败', e);
+  }
+}
+
+//程序入口
+//============================================
+
+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);
+}
+
+process.on('unhandledRejection', (reason, p) => {
+  console.error('Promise: ', p, 'Reason: ', reason)
+})

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

@@ -0,0 +1,76 @@
+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: [
+        'index.html',
+      ],
+      newFolderNameGenerateType: 'hash',
+    },
+    deprecateConfig: {
+      indexFile: 'index.html',
+      updateHtml: readFileSync(path.resolve(__dirname, './deprecate.html'), 'utf8')
+    },
+  },
+  buildWebCommand: 'npm run build-only', //构建命令
+  buildWebOutDir: '../../../dist', //构建输出目录。相对于当前文件目录  
+  buildWebOptions: {
+    skipFiles: [
+    ], //打包忽略文件,相对于 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方法');
+  },
+} 

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

@@ -0,0 +1,421 @@
+/**
+ * 更新发布工具
+ * 
+ * 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 './index.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 updateInfo = await input({ message: '输入更新信息', default: postConfig.lastUpdateInfo });
+  if (updateInfo === 'git') {
+    console.log('开始获取git提交信息');
+    updateInfo = await execAsync('git log -1 --pretty=format:"%h %s"');
+    console.log('使用git提交信息作为更新信息');
+  }
+  return updateInfo;
+}
+async function uploadMulitPartLarge(axiosInstance, fileInfo, filePath) {
+
+  console.log('开始分片上传');
+
+  const multuploadInfo = (await axiosInstance.post('/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-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(false, postConfig.lastVersion);
+  const updateInfo = await getUpdateInfo(postConfig);
+
+  const serverConfig = await axiosInstance.post('/update-post', { 
+    config: { 
+      type: 2, 
+      test: true, 
+      versionId, 
+      uploadAppConfig: {}, 
+      submitKey: config.submitKey 
+    } 
+  });
+
+  postConfig.lastVersion = versionId;
+  postConfig.lastUpdateInfo = 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-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-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-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-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-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(false, postConfig.lastVersion);
+  const updateInfo = await getUpdateInfo(postConfig);
+
+  postConfig.lastVersion = versionId;
+  postConfig.lastUpdateInfo = updateInfo;
+  postConfig.lastTodaySubVersion++;
+
+  await axiosInstance.post('/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-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);
+}

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

@@ -0,0 +1,96 @@
+import fs from 'fs';
+import 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);
+    });
+  })
+}
+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(path) {
+    if (!skipFiles || skipFiles.length === 0)
+      return;
+    return skipFiles.find((item) => path.startsWith(item));
+  }
+
+  async function loopDir(path, subPrefix) {
+    const files = await readdir(path);
+    for (const file of files) {
+      const subPath = subPrefix + '/' + file;
+      if (checkPathSkip(subPath))
+        continue;
+      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;
+}