Просмотр исходного кода

fix(web): Vite dev HMR/proxy, NextLoading, API base URL; add deploy scripts

- Vite: avoid binding HMR to public EIP; server.origin; watch polling via env; add esbuild devDependency for build
- NextLoading: clear duplicate overlays; userInfo: always resolve getApiUserInfo; safer default avatar
- Centralize public API base (getApiPublicBase) for request, axios-utils, SignalR, job iframe link
- CORS origins in App.json; default MySQL host 127.0.0.1 in Database.json
- scripts: aidop-web/api systemd units; SQL notes for duplicate Demo01 rename (underline vs SysUser)

Made-with: Cursor
AidopCore 1 неделя назад
Родитель
Сommit
2c51c25f56

+ 3 - 2
Web/package.json

@@ -45,7 +45,7 @@
 		"echarts": "^6.0.0",
 		"echarts-gl": "^2.0.9",
 		"echarts-wordcloud": "^2.1.0",
-		"element-plus": "^2.13.5",
+		"element-plus": "^2.13.6",
 		"ezuikit-js": "^8.2.6",
 		"js-cookie": "^3.0.5",
 		"js-table2excel": "^1.1.2",
@@ -100,6 +100,7 @@
 		"code-inspector-plugin": "^1.4.4",
 		"colors": "^1.4.0",
 		"dotenv": "^17.3.1",
+		"esbuild": "^0.27.4",
 		"eslint": "^10.0.3",
 		"eslint-plugin-vue": "^10.8.0",
 		"globals": "^17.4.0",
@@ -154,4 +155,4 @@
 		"vue-next-admin",
 		"next-admin"
 	]
-}
+}

+ 1 - 1
Web/src/router/route.ts

@@ -55,7 +55,7 @@ export const dynamicRoutes: Array<RouteRecordRaw> = [
 		component: () => import('/@/views/system/job/dashboard.vue'),
 		meta: {
 			title: '任务看板',
-			isLink: window.__env__.VITE_API_URL + '/schedule',
+			isLink: `${String(window.__env__?.VITE_API_URL || window.location.origin).replace(/\/$/, '')}/schedule`,
 			isHide: true,
 			isKeepAlive: true,
 			isAffix: false,

+ 7 - 3
Web/src/stores/userInfo.ts

@@ -59,7 +59,10 @@ export const useUserInfo = defineStore('userInfo', {
 				getAPI(SysAuthApi)
 					.apiSysAuthUserInfoGet()
 					.then(async (res: any) => {
-						if (res.data.result == null) return;
+						if (res.data.result == null) {
+							resolve({} as UserInfos);
+							return;
+						}
 						var d = res.data.result;
 						const userInfos = {
 							id: d.id,
@@ -69,7 +72,7 @@ export const useUserInfo = defineStore('userInfo', {
 							idCardNum: d.idCardNum,
 							email: d.email,
 							accountType: d.accountType,
-							avatar: d.avatar ?? '/upload/logo.png',
+							avatar: d.avatar ?? '/app-icons/aidop-logo.png',
 							address: d.address,
 							signature: d.signature,
 							orgId: d.orgId,
@@ -97,7 +100,8 @@ export const useUserInfo = defineStore('userInfo', {
 						Local.set('themeConfig', storesThemeConfig.themeConfig);
 
 						resolve(userInfos);
-					});
+					})
+					.catch(() => resolve({} as UserInfos));
 			});
 		},
 

+ 11 - 0
Web/src/utils/api-public-base.ts

@@ -0,0 +1,11 @@
+/**
+ * 浏览器侧 API 根地址。
+ * VITE_API_URL 为空时走当前页面 origin,请求经 Vite dev 代理到后端(公网只需放行前端端口)。
+ */
+export function getApiPublicBase(): string {
+	const raw = window.__env__?.VITE_API_URL;
+	if (raw != null && String(raw).trim() !== '') {
+		return String(raw).replace(/\/+$/, '');
+	}
+	return window.location.origin;
+}

+ 2 - 1
Web/src/utils/axios-utils.ts

@@ -13,10 +13,11 @@ import { ElMessage } from 'element-plus';
 import { Local, Session } from '../utils/storage';
 import { useUserInfo } from "/@/stores/userInfo";
 import {useRoute, useRouter} from "vue-router";
+import { getApiPublicBase } from '/@/utils/api-public-base';
 
 // 接口服务器配置
 export const serveConfig = new Configuration({
-	basePath: window.__env__.VITE_API_URL,
+	basePath: getApiPublicBase(),
 });
 
 // token 键定义

+ 9 - 2
Web/src/utils/loading.ts

@@ -6,9 +6,17 @@ import '/@/theme/loading.scss';
  * @method start 创建 loading
  * @method done 移除 loading
  */
+function removeAllLoadingNext(): void {
+	document.querySelectorAll('.loading-next').forEach((node) => {
+		node.parentNode?.removeChild(node);
+	});
+}
+
 export const NextLoading = {
 	// 创建 loading
 	start: () => {
+		// 登录等流程可能连续 start 两次,只移除一层会导致遮罩永久挡住界面
+		removeAllLoadingNext();
 		const bodys: Element = document.body;
 		const div = <HTMLElement>document.createElement('div');
 		div.setAttribute('class', 'loading-next');
@@ -36,8 +44,7 @@ export const NextLoading = {
 		nextTick(() => {
 			setTimeout(() => {
 				window.nextLoading = false;
-				const el = <HTMLElement>document.querySelector('.loading-next');
-				el?.parentNode?.removeChild(el);
+				removeAllLoadingNext();
 			}, time);
 		});
 	},

+ 2 - 1
Web/src/utils/request.ts

@@ -2,13 +2,14 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
 import { ElMessage } from 'element-plus';
 import { Local, } from '/@/utils/storage';
 import {clearAccessAfterReload} from "/@/utils/axios-utils";
+import { getApiPublicBase } from '/@/utils/api-public-base';
 
 // 定义请求中止控制器映射表
 const abortControllerMap: Map<string, AbortController> = new Map();
 
 // 配置新建一个 axios 实例
 export const service = axios.create({
-	baseURL: window.__env__.VITE_API_URL as any,
+	baseURL: getApiPublicBase() as any,
 	timeout: 50000,
 	//headers: { 'Content-Type': 'application/json' }, 这个会导致生成代码的上传文件 file为空
 });

+ 2 - 1
Web/src/views/system/onlineUser/signalR.ts

@@ -1,11 +1,12 @@
 import * as SignalR from '@microsoft/signalr';
 import { ElNotification } from 'element-plus';
 import { getToken } from '/@/utils/axios-utils';
+import { getApiPublicBase } from '/@/utils/api-public-base';
 
 // 初始化SignalR对象
 const connection = new SignalR.HubConnectionBuilder()
 	.configureLogging(SignalR.LogLevel.Information)
-	.withUrl(`${window.__env__.VITE_API_URL}/hubs/onlineUser?token=${getToken()}`, { transport: SignalR.HttpTransportType.WebSockets, skipNegotiation: true })
+	.withUrl(`${getApiPublicBase()}/hubs/onlineUser?token=${getToken()}`, { transport: SignalR.HttpTransportType.WebSockets, skipNegotiation: true })
 	.withAutomaticReconnect({
 		nextRetryDelayInMilliseconds: () => {
 			return 5000; // 每5秒重连一次

+ 62 - 34
Web/vite.config.ts

@@ -9,7 +9,7 @@ import { CodeInspectorPlugin } from 'code-inspector-plugin';
 import fs from 'fs';
 import { visualizer } from 'rollup-plugin-visualizer';
 import { webUpdateNotice } from '@plugin-web-update-notification/vite';
-import vitePluginsAutoI18n, { EmptyTranslator, YoudaoTranslator } from 'vite-auto-i18n-plugin';
+import vitePluginsAutoI18n, { EmptyTranslator } from 'vite-auto-i18n-plugin';
 const pathResolve = (dir: string) => {
 	return resolve(__dirname, '.', dir);
 };
@@ -22,10 +22,15 @@ const alias: Record<string, string> = {
 
 const viteConfig = defineConfig((mode: ConfigEnv) => {
 	const env = loadEnv(mode.mode, process.cwd());
+	/** dev 代理目标:始终本机后端。勿用浏览器用的公网 VITE_API_URL,避免绕一圈且易配错 */
+	const apiProxyTarget = env.VITE_PROXY_TARGET || 'http://127.0.0.1:5005';
+	const devPort = Number(env.VITE_PORT) || 8888;
+	/** 浏览器访问用公网 IP/域名;勿写入 server.hmr.host,否则 WS 会 bind 该地址,云主机 EIP 常不在网卡上 → EADDRNOTAVAIL */
+	const devPublicHost = String(env.VITE_DEV_PUBLIC_HOST || '').trim();
 	fs.writeFileSync('./public/config.js', `window.__env__ = ${JSON.stringify(env, null, 2)} `);
 	return {
 		plugins: [
-			visualizer({ open: false }), // 开启可视化分析页面
+			visualizer({ open: false }),
 			CodeInspectorPlugin({
 				bundler: 'vite',
 				hotKeys: ['shiftKey'],
@@ -45,25 +50,21 @@ const viteConfig = defineConfig((mode: ConfigEnv) => {
 				},
 			}),
 			vueSetupExtend(),
-			compression({
-				deleteOriginalAssets: false, // 是否删除源文件
-				threshold: 5120, // 对大于 5KB 文件进行 gzip 压缩,单位Bytes
-				skipIfLargerOrEqual: true, // 如果压缩后的文件大小等于或大于原始文件,则跳过压缩
-				// algorithm: 'gzip', // 压缩算法,可选[‘gzip’,‘brotliCompress’,‘deflate’,‘deflateRaw’]
-				// exclude: [/\.(br)$/, /\.(gz)$/], // 排除指定文件
-			}),
+			...(mode.command === 'build'
+				? [
+						compression({
+							deleteOriginalAssets: false,
+							threshold: 5120,
+							skipIfLargerOrEqual: true,
+						}),
+					]
+				: []),
 			JSON.parse(env.VITE_OPEN_CDN) ? buildConfig.cdn() : null,
-			// 使用说明 https://github.com/auto-i18n/auto-i18n-translation-plugins
 			vitePluginsAutoI18n({
-				// 是否触发翻译
 				enabled: false,
-				originLang: 'zh-cn', //源语言,翻译以此语言为基础
-				targetLangList: ['zh-hk', 'zh-tw', 'en', 'it'], // 目标语言列表,支持配置多个语言
-				translator: new EmptyTranslator(), // 只生成Web\lang\index.json文件
-				// translator: new YoudaoTranslator({ // 有道实时翻译
-				// appId: '你申请的appId',
-				// appKey: '你申请的appKey'
-				// })
+				originLang: 'zh-cn',
+				targetLangList: ['zh-hk', 'zh-tw', 'en', 'it'],
+				translator: new EmptyTranslator(),
 			}),
 		],
 		root: process.cwd(),
@@ -74,14 +75,38 @@ const viteConfig = defineConfig((mode: ConfigEnv) => {
 			host: '0.0.0.0',
 			port: env.VITE_PORT as unknown as number,
 			open: JSON.parse(env.VITE_OPEN),
-			hmr: true,
+			...(mode.command === 'serve' && devPublicHost
+				? { origin: `http://${devPublicHost}:${devPort}` }
+				: {}),
+			// VITE_HMR=false 可关热更新。公网访问时不要给 hmr.host 填 EIP(见 devPublicHost 注释)
+			// 勿设 hmr.port === server.port:Vite 8 会先把 WS 占住该端口,HTTP 再监听会冲突并退到 8889,浏览器仍访问 8888 会 426
+			hmr: env.VITE_HMR === 'false' ? false : true,
+			// 文件监视过多时 inotify 会 ENOSPC;设 VITE_WATCH_POLLING=true 可改用轮询(略耗 CPU)
+			watch:
+				env.VITE_WATCH_POLLING === 'true'
+					? { usePolling: true, interval: 1500 }
+					: undefined,
 			proxy: {
-				'^/api': {
-					target: env.VITE_API_URL,
+				// 勿对 /api 开 ws:true,在部分环境下会导致普通 GET 挂起无响应
+				'/api': {
+					target: apiProxyTarget,
+					changeOrigin: true,
+				},
+				'/upload': {
+					target: apiProxyTarget,
+					changeOrigin: true,
+				},
+				'/Upload': {
+					target: apiProxyTarget,
+					changeOrigin: true,
+				},
+				'/hubs': {
+					target: apiProxyTarget,
 					changeOrigin: true,
+					ws: true,
 				},
-				'^/[Uu]pload': {
-					target: env.VITE_API_URL,
+				'/schedule': {
+					target: apiProxyTarget,
 					changeOrigin: true,
 				},
 			},
@@ -92,23 +117,26 @@ const viteConfig = defineConfig((mode: ConfigEnv) => {
 			assetsInlineLimit: 5000, // 小于此阈值的导入或引用资源将内联为 base64 编码
 			sourcemap: false, // 构建后是否生成 source map 文件
 			extractComments: false, // 移除注释
-			minify: 'terser', // 启用后 terserOptions 配置才有效
-			terserOptions: {
-				compress: {
-					drop_console: true, // 生产环境时移除console
-					drop_debugger: true,
-				},
+			// esbuild 压缩内存占用远低于 terser;maxParallelFileOps 降低并发,便于小内存环境完成构建
+			minify: 'esbuild',
+			esbuild: {
+				drop: mode.mode === 'production' ? ['console', 'debugger'] : [],
 			},
 			rollupOptions: {
 				output: {
 					chunkFileNames: 'assets/js/[name]-[hash].js', // 引入文件名的名称
 					entryFileNames: 'assets/js/[name]-[hash].js', // 包的入口文件名称
 					assetFileNames: 'assets/[ext]/[name]-[hash].[ext]', // 资源文件像 字体,图片等
-					manualChunks(id) {
-						if (id.includes('node_modules')) {
-							return id.toString().match(/\/node_modules\/(?!.pnpm)(?<moduleName>[^\/]*)\//)?.groups!.moduleName ?? 'vender';
-						}
-					},
+					// 小内存环境构建时可去掉 manualChunks,降低 Rollup 分析内存(大机器可恢复分包策略)
+					...(process.env.VITE_LOW_MEM_BUILD === '1'
+						? {}
+						: {
+								manualChunks(id: string) {
+									if (id.includes('node_modules')) {
+										return id.toString().match(/\/node_modules\/(?!.pnpm)(?<moduleName>[^\/]*)\//)?.groups!.moduleName ?? 'vender';
+									}
+								},
+							}),
 				},
 				...(JSON.parse(env.VITE_OPEN_CDN) ? { external: buildConfig.external } : {}),
 			},

+ 18 - 0
scripts/aidop-api.service

@@ -0,0 +1,18 @@
+[Unit]
+Description=AiDOP API (Admin.NET Web.Entry on 5005)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+WorkingDirectory=/home/admin/.openclaw/workspace/AiDOPCore/server/publish
+Environment=DOTNET_ROOT=/root/.dotnet
+Environment=PATH=/root/.dotnet:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
+Environment=ASPNETCORE_ENVIRONMENT=Development
+ExecStart=/root/.dotnet/dotnet Admin.NET.Web.Entry.dll --urls http://0.0.0.0:5005
+Restart=always
+RestartSec=10
+LimitNOFILE=65535
+
+[Install]
+WantedBy=multi-user.target

+ 19 - 0
scripts/aidop-web.service

@@ -0,0 +1,19 @@
+[Unit]
+Description=AiDOP Web (Vite dev, bind 0.0.0.0:8888)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+WorkingDirectory=/home/admin/.openclaw/workspace/AiDOPCore/Web
+ExecStart=/usr/bin/npm run dev -- --host 0.0.0.0
+Restart=always
+RestartSec=10
+LimitNOFILE=1048576
+TimeoutStartSec=180
+Environment=NODE_OPTIONS=--max-old-space-size=8192
+# 大仓库 + 低配机 inotify 易 ENOSPC,与 vite server.watch 配合(见 vite.config.ts)
+Environment=VITE_WATCH_POLLING=true
+
+[Install]
+WantedBy=multi-user.target

+ 49 - 0
scripts/fix-duplicate-sys-user-demo01.sql

@@ -0,0 +1,49 @@
+-- 将「非种子」的重复账号 Demo01 改名为 Demo02,保留官方种子用户 Id = 1300000000117
+-- 执行前请:USE 你的库名; 并做好备份
+--
+-- 表名取决于 Database.json → DbSettings.EnableUnderLine:
+--   false:表 SysUser,列 Id / Account / RealName / UpdateTime(驼峰)
+--   true :表 sys_user,列 id / account / real_name / update_time(下划线)
+-- 下面默认写下划线版;若库为 SysUser,把 sys_user 改为 SysUser、列名改为驼峰即可。
+
+-- ========== 1. 预览 ==========
+SELECT id,
+       tenant_id,
+       account,
+       real_name,
+       phone,
+       remark,
+       create_time
+FROM sys_user
+WHERE account IN ('Demo01', 'Demo02')
+ORDER BY account, id;
+
+-- ========== 2. 保留的种子 Demo01 Id(若与你库不一致请修改)==========
+SET @keep_demo01_id = 1300000000117;
+
+-- ========== 3. 目标账号 Demo02 必须尚不存在(框架全局账号唯一)==========
+SELECT id, account, real_name
+FROM sys_user
+WHERE account = 'Demo02';
+-- 上面若有结果:请先换一个未占用账号,并把下面 UPDATE 里的 Demo02 改成新名字
+
+-- ========== 4. 重命名:非种子的 Demo01 → Demo02 ==========
+START TRANSACTION;
+
+UPDATE sys_user
+SET account = 'Demo02',
+    update_time = NOW(3)
+WHERE account = 'Demo01'
+  AND id <> @keep_demo01_id;
+
+COMMIT;
+
+-- ========== 5. 校验 ==========
+SELECT id, tenant_id, account, real_name, phone
+FROM sys_user
+WHERE account IN ('Demo01', 'Demo02')
+ORDER BY account, id;
+-- 期望:一条 Demo01(id = @keep_demo01_id),一条 Demo02(原重复那条)
+
+-- ========== 附录:若仍要物理删除非种子 Demo01(慎用)==========
+-- 需先按 user_id 清理子表,见本文件历史版本或自行处理外键后再 DELETE。

+ 2 - 2
server/Admin.NET.Application/Configuration/App.json

@@ -1,4 +1,4 @@
-{
+{
   "$schema": "https://gitee.com/dotnetchina/Furion/raw/v4/schemas/v4/furion-schema.json",
 
   "Urls": "http://*:5005", // 默认端口
@@ -37,7 +37,7 @@
   },
   "CorsAccessorSettings": {
     //"PolicyName": "App.Cors.Policy", // 跨域策略名称
-    //"WithOrigins": [ "http://localhost:5005", "https://gitee.com" ], // 允许的跨域地址
+    "WithOrigins": [ "http://localhost:8888", "http://127.0.0.1:8888", "http://106.14.73.46:8888" ], // 前端开发服来源(公网/本机)
     "WithExposedHeaders": [ "Content-Disposition", "X-Pagination", "access-token", "x-access-token", "Access-Control-Expose-Headersx-access-token" ], // 如果前端不代理且是axios请求
     "SignalRSupport": true // 启用 SignalR 跨域支持
   },

+ 1 - 1
server/Admin.NET.Application/Configuration/Database.json

@@ -10,7 +10,7 @@
         //"ConfigId": "1300000000001", // 默认库标识-禁止修改
         "DbType": "MySql",
         "DbNickName": "系统库",
-        "ConnectionString": "Server=106.14.73.46;Port=3306;Database=aidopcore;Uid=aidopremote;Pwd=AidOp#Remote2026$Secure;SslMode=None;Charset=utf8mb4;AllowLoadLocalInfile=true;AllowUserVariables=true;",
+        "ConnectionString": "Server=127.0.0.1;Port=3306;Database=aidopcore;Uid=aidopremote;Pwd=AidOp#Remote2026$Secure;SslMode=None;Charset=utf8mb4;AllowLoadLocalInfile=true;AllowUserVariables=true;",
         // 本地 SQLite 示例(切回时改 DbType 为 Sqlite 并恢复下行连接串)
         //"DbType": "Sqlite",
         //"ConnectionString": "DataSource=./Admin.NET.db",