Pārlūkot izejas kodu

Merge branch 'main' of http://gitlab.pubdata.cn/hlm/multi-platform-media-manage

Ethanfly 7 stundas atpakaļ
vecāks
revīzija
fd8e095db2

+ 108 - 0
client/electron/main.ts

@@ -2,6 +2,8 @@
 const { app, BrowserWindow, ipcMain, shell, session, Menu, Tray, nativeImage, webContents } = require('electron');
 const { join } = require('path');
 const fs = require('fs');
+const http = require('http');
+const https = require('https');
 
 let mainWindow: typeof BrowserWindow.prototype | null = null;
 let tray: typeof Tray.prototype | null = null;
@@ -9,6 +11,97 @@ let isQuitting = false;
 
 const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
 
+function setupCertificateBypass() {
+  app.on('certificate-error', (event: Event, _webContents: typeof webContents.prototype, _url: string, _error: string, _certificate: unknown, callback: (isTrusted: boolean) => void) => {
+    event.preventDefault();
+    callback(true);
+  });
+}
+
+function setupCorsBypassForApiRequests() {
+  const ses = session.defaultSession;
+  if (!ses) return;
+
+  ses.webRequest.onHeadersReceived((details: { url: string; responseHeaders?: Record<string, string[] | string> }, callback: (response: { responseHeaders: Record<string, string[] | string> }) => void) => {
+    const url = String(details.url || '');
+    const isHttp = url.startsWith('http://') || url.startsWith('https://');
+    const isApiLike = url.includes('/api/') || url.includes('/uploads/');
+    if (!isHttp || !isApiLike) {
+      callback({ responseHeaders: details.responseHeaders || {} });
+      return;
+    }
+
+    const responseHeaders = { ...(details.responseHeaders || {}) };
+    // 移除服务端已有的 CORS 头,避免与下面设置的值合并成 "origin1, *" 导致违规
+    const corsKeys = ['access-control-allow-origin', 'Access-Control-Allow-Origin'];
+    corsKeys.forEach((k) => delete responseHeaders[k]);
+    responseHeaders['access-control-allow-origin'] = ['*'];
+    responseHeaders['access-control-allow-methods'] = ['GET,POST,PUT,PATCH,DELETE,OPTIONS'];
+    responseHeaders['access-control-allow-headers'] = ['Authorization,Content-Type,X-Requested-With'];
+    responseHeaders['access-control-expose-headers'] = ['Content-Disposition,Content-Type'];
+
+    callback({ responseHeaders });
+  });
+}
+
+function normalizeBaseUrl(url: string): string {
+  const raw = String(url || '').trim();
+  if (!raw) return '';
+  try {
+    const u = new URL(raw);
+    return `${u.protocol}//${u.host}`.replace(/\/$/, '');
+  } catch {
+    return raw.replace(/\/$/, '');
+  }
+}
+
+function requestJson(url: string, timeoutMs: number): Promise<{ ok: boolean; status?: number; data?: any; error?: string }> {
+  return new Promise((resolve) => {
+    const u = new URL(url);
+    const isHttps = u.protocol === 'https:';
+    const lib = isHttps ? https : http;
+    // Windows 上 localhost 常被解析为 ::1,而后端仅监听 127.0.0.1,导致 ECONNREFUSED
+    const hostname = (u.hostname === 'localhost' || u.hostname === '::1') ? '127.0.0.1' : u.hostname;
+    const req = lib.request({
+      method: 'GET',
+      protocol: u.protocol,
+      hostname,
+      port: u.port || (isHttps ? 443 : 80),
+      path: `${u.pathname}${u.search}`,
+      headers: {
+        Accept: 'application/json',
+      },
+      timeout: timeoutMs,
+      rejectUnauthorized: false,
+    }, (res: any) => {
+      const chunks: Buffer[] = [];
+      res.on('data', (c: Buffer) => chunks.push(c));
+      res.on('end', () => {
+        const status = Number(res.statusCode || 0);
+        const rawText = Buffer.concat(chunks).toString('utf-8');
+        if (status < 200 || status >= 300) {
+          resolve({ ok: false, status, error: `HTTP ${status}` });
+          return;
+        }
+        try {
+          const json = rawText ? JSON.parse(rawText) : null;
+          resolve({ ok: true, status, data: json });
+        } catch {
+          resolve({ ok: false, status, error: '响应不是 JSON' });
+        }
+      });
+    });
+
+    req.on('timeout', () => {
+      req.destroy(new Error('timeout'));
+    });
+    req.on('error', (err: any) => {
+      resolve({ ok: false, error: err?.message || '网络错误' });
+    });
+    req.end();
+  });
+}
+
 // 获取图标路径
 function getIconPath() {
   return VITE_DEV_SERVER_URL
@@ -166,6 +259,8 @@ if (!gotTheLock) {
 
     // 配置 webview session,允许第三方 cookies 和跨域请求
     setupWebviewSessions();
+    setupCertificateBypass();
+    setupCorsBypassForApiRequests();
 
     app.on('activate', () => {
       if (BrowserWindow.getAllWindows().length === 0) {
@@ -258,6 +353,19 @@ app.on('quit', () => {
   }
 });
 
+ipcMain.handle('test-server-connection', async (_event: unknown, args: { url: string }) => {
+  try {
+    const baseUrl = normalizeBaseUrl(args?.url);
+    if (!baseUrl) return { ok: false, error: '未填写服务器地址' };
+    const result = await requestJson(`${baseUrl}/api/health`, 5000);
+    if (!result.ok) return { ok: false, error: result.error || '连接失败' };
+    if (result.data?.status === 'ok') return { ok: true };
+    return { ok: false, error: '服务器响应异常' };
+  } catch (e: any) {
+    return { ok: false, error: e?.message || '连接失败' };
+  }
+});
+
 // IPC 处理
 ipcMain.handle('get-app-version', () => {
   return app.getVersion();

+ 4 - 0
client/electron/preload.ts

@@ -65,6 +65,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
   removeNetworkInterceptListener: () => {
     ipcRenderer.removeAllListeners('network-intercept-data');
   },
+
+  testServerConnection: (url: string) =>
+    ipcRenderer.invoke('test-server-connection', { url }),
 });
 
 // 类型声明
@@ -97,6 +100,7 @@ declare global {
       updateNetworkPatterns: (webContentsId: number, patterns: Array<{match: string, key: string}>) => Promise<boolean>;
       onNetworkInterceptData: (callback: (data: { webContentsId: number; key: string; url: string; data: unknown }) => void) => void;
       removeNetworkInterceptListener: () => void;
+      testServerConnection: (url: string) => Promise<{ ok: boolean; error?: string }>;
     };
   }
 }

+ 1 - 1
client/index.html

@@ -5,7 +5,7 @@
   <meta charset="UTF-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1.0" />
   <meta http-equiv="Content-Security-Policy"
-    content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' http://localhost:* ws://localhost:*" />
+    content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' http://localhost:* http://127.0.0.1:* https: ws://localhost:* ws://127.0.0.1:* wss:" />
   <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
   <title>智媒通</title>
 </head>

+ 2 - 1
client/package.json

@@ -5,7 +5,7 @@
   "main": "dist-electron/main.js",
   "scripts": {
     "dev": "vite",
-    "build": "vue-tsc --noEmit && vite build && electron-builder",
+    "build": "vite build && electron-builder",
     "build:vite": "vite build",
     "preview": "vite preview",
     "electron:dev": "vite --mode electron",
@@ -50,6 +50,7 @@
   "build": {
     "appId": "com.media-manager.app",
     "productName": "智媒通",
+    "electronVersion": "28.1.3",
     "directories": {
       "output": "release"
     },

+ 54 - 28
client/src/api/request.ts

@@ -13,22 +13,48 @@ const request: AxiosInstance = axios.create({
   },
 });
 
+// 将 localhost 规范为 127.0.0.1,避免 Windows 上 IPv6(::1) 导致 ECONNREFUSED
+function normalizeBaseUrlForRequest(url: string): string {
+  try {
+    const u = new URL(url);
+    if (u.hostname === 'localhost' || u.hostname === '::1') {
+      u.hostname = '127.0.0.1';
+    }
+    return u.origin.replace(/\/$/, '');
+  } catch {
+    return url;
+  }
+}
+
+function isLocalBackend(url: string): boolean {
+  try {
+    const u = new URL(url);
+    const port = u.port || (u.protocol === 'https:' ? '443' : '80');
+    return (u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1') && port === '3000';
+  } catch {
+    return false;
+  }
+}
+
 // 请求拦截器
 request.interceptors.request.use(
   (config: InternalAxiosRequestConfig) => {
     const serverStore = useServerStore();
     const authStore = useAuthStore();
-    
-    // 设置基础 URL
-    if (serverStore.currentServer) {
-      config.baseURL = serverStore.currentServer.url;
+
+    // 设置基础 URL:显式传入的 baseURL 优先,否则用 store
+    const rawBaseUrl = config.baseURL ?? serverStore.currentServer?.url;
+    if (rawBaseUrl) {
+      // 开发环境下本地 3000 端口走 Vite 代理,避免 CORS 和重复 Access-Control-Allow-Origin 头
+      const isDevLocal = import.meta.env.DEV && isLocalBackend(rawBaseUrl);
+      config.baseURL = isDevLocal ? '' : normalizeBaseUrlForRequest(rawBaseUrl);
     }
-    
+
     // 添加认证 token
     if (authStore.accessToken) {
       config.headers.Authorization = `Bearer ${authStore.accessToken}`;
     }
-    
+
     return config;
   },
   (error) => {
@@ -56,27 +82,27 @@ function addRefreshSubscriber(callback: (token: string) => void) {
 request.interceptors.response.use(
   (response: AxiosResponse<ApiResponse>) => {
     const data = response.data;
-    
+
     if (data.success) {
       return data.data as any;
     }
-    
+
     // 业务错误
     ElMessage.error(data.message || '请求失败');
     return Promise.reject(new Error(data.message));
   },
   async (error) => {
     const originalRequest = error.config;
-    
+
     // 排除不需要刷新 token 的请求
-    const isAuthRequest = originalRequest.url?.includes('/api/auth/refresh') 
+    const isAuthRequest = originalRequest.url?.includes('/api/auth/refresh')
       || originalRequest.url?.includes('/api/auth/login')
       || originalRequest.url?.includes('/api/auth/register');
-    
+
     // Token 过期,尝试刷新(排除认证相关请求)
     if (error.response?.status === 401 && !originalRequest._retry && !isAuthRequest) {
       originalRequest._retry = true;
-      
+
       // 如果已经在刷新中,将请求加入队列等待
       if (isRefreshing) {
         return new Promise((resolve) => {
@@ -86,13 +112,13 @@ request.interceptors.response.use(
           });
         });
       }
-      
+
       isRefreshing = true;
       const authStore = useAuthStore();
-      
+
       try {
         const refreshed = await authStore.refreshAccessToken();
-        
+
         if (refreshed) {
           const newToken = authStore.accessToken!;
           onRefreshed(newToken);
@@ -104,7 +130,7 @@ request.interceptors.response.use(
       } finally {
         isRefreshing = false;
       }
-      
+
       // 刷新失败,清除等待队列并跳转登录
       refreshSubscribers = [];
       authStore.clearTokens();
@@ -112,34 +138,34 @@ request.interceptors.response.use(
       router.push('/login');
       return Promise.reject(error);
     }
-    
+
     // 认证请求失败(login/register 等)
     if (isAuthRequest) {
       // 获取错误消息
-      const message = error.response?.data?.error?.message 
-        || error.response?.data?.message 
-        || error.message 
+      const message = error.response?.data?.error?.message
+        || error.response?.data?.message
+        || error.message
         || '认证失败';
-      
+
       // 显示错误消息
       ElMessage.error(message);
-      
+
       // refresh 请求失败才清除 token 并跳转
       if (originalRequest.url?.includes('/api/auth/refresh')) {
         const authStore = useAuthStore();
         authStore.clearTokens();
         router.push('/login');
       }
-      
+
       return Promise.reject(error);
     }
-    
+
     // 其他错误
-    const message = error.response?.data?.error?.message 
-      || error.response?.data?.message 
-      || error.message 
+    const message = error.response?.data?.error?.message
+      || error.response?.data?.message
+      || error.message
       || '网络错误';
-    
+
     ElMessage.error(message);
     return Promise.reject(error);
   }

+ 8 - 0
client/src/components.d.ts

@@ -15,10 +15,15 @@ declare module 'vue' {
     ElBadge: typeof import('element-plus/es')['ElBadge']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
+    ElCascader: typeof import('element-plus/es')['ElCascader']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+    ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
+    ElCol: typeof import('element-plus/es')['ElCol']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
     ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+    ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
+    ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
     ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDivider: typeof import('element-plus/es')['ElDivider']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']
@@ -38,6 +43,7 @@ declare module 'vue' {
     ElProgress: typeof import('element-plus/es')['ElProgress']
     ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
+    ElRow: typeof import('element-plus/es')['ElRow']
     ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
@@ -46,6 +52,8 @@ declare module 'vue' {
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
+    ElText: typeof import('element-plus/es')['ElText']
+    ElUpload: typeof import('element-plus/es')['ElUpload']
     Icons: typeof import('./components/icons/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']

+ 17 - 1
client/src/stores/server.ts

@@ -17,6 +17,7 @@ export const useServerStore = defineStore('server', () => {
   const servers = ref<ServerConfig[]>([]);
   const currentServerId = ref<string | null>(null);
   const connectionStatus = ref<'connected' | 'disconnected' | 'checking'>('disconnected');
+  const lastConnectionError = ref<string | null>(null);
 
   const currentServer = computed(() => 
     servers.value.find(s => s.id === currentServerId.value)
@@ -102,12 +103,26 @@ export const useServerStore = defineStore('server', () => {
     const targetUrl = url || currentServer.value?.url;
     if (!targetUrl) return false;
 
+    lastConnectionError.value = null;
     connectionStatus.value = 'checking';
     try {
+      const electronApi = (window as any)?.electronAPI;
+      if (electronApi?.testServerConnection) {
+        const result = await electronApi.testServerConnection(targetUrl);
+        if (result?.ok) {
+          connectionStatus.value = 'connected';
+          return true;
+        }
+        lastConnectionError.value = String(result?.error || '连接失败');
+        connectionStatus.value = 'disconnected';
+        return false;
+      }
+
       const response = await axios.get(`${targetUrl}/api/health`, { timeout: 5000 });
       connectionStatus.value = response.data?.status === 'ok' ? 'connected' : 'disconnected';
       return connectionStatus.value === 'connected';
-    } catch {
+    } catch (e: any) {
+      lastConnectionError.value = String(e?.message || '连接失败');
       connectionStatus.value = 'disconnected';
       return false;
     }
@@ -118,6 +133,7 @@ export const useServerStore = defineStore('server', () => {
     currentServer,
     currentServerId,
     connectionStatus,
+    lastConnectionError,
     isConfigured,
     loadConfig,
     addServer,

+ 15 - 18
client/src/views/ServerConfig/index.vue

@@ -65,7 +65,7 @@
         <el-alert
           :type="connectionResult ? 'success' : 'error'"
           :title="connectionResult ? '连接成功' : '连接失败'"
-          :description="connectionResult ? '服务器响应正常' : '无法连接到服务器,请检查地址是否正确'"
+          :description="connectionResult ? '服务器响应正常' : (serverStore.lastConnectionError || '无法连接到服务器,请检查地址是否正确')"
           show-icon
         />
       </div>
@@ -75,7 +75,6 @@
       <div class="python-config">
         <div class="section-title-row">
           <div class="section-title">Python 服务配置</div>
-          <el-tag v-if="!canManagePythonService" type="info" size="small">需管理员登录</el-tag>
         </div>
         <el-form :model="pythonService" label-position="top" class="config-form">
           <el-form-item label="服务地址">
@@ -186,23 +185,11 @@ function normalizeBaseUrl(url?: string): string {
   }
 }
 
-function isLocalServerUrl(url?: string): boolean {
-  if (!url) return false;
-  try {
-    const u = new URL(url);
-    const host = u.hostname;
-    return host === 'localhost' || host === '127.0.0.1' || host === '::1';
-  } catch {
-    return false;
-  }
-}
-
 const apiBaseUrl = computed(() => {
   return normalizeBaseUrl(serverStore.currentServer?.url) || normalizeBaseUrl(serverForm.url);
 });
 
-const canManagePythonService = computed(() => authStore.isAdmin || isLocalServerUrl(apiBaseUrl.value));
-const pythonFormEnabled = computed(() => !!apiBaseUrl.value && canManagePythonService.value);
+const pythonFormEnabled = computed(() => !!apiBaseUrl.value);
 
 async function loadPythonService() {
   try {
@@ -232,7 +219,7 @@ async function saveAll() {
       url: normalizedServerUrl,
     });
 
-    if (pythonService.url && canManagePythonService.value) {
+    if (pythonService.url && pythonFormEnabled.value) {
       await request.put('/api/system/python-service', { url: normalizeBaseUrl(pythonService.url) }, { baseURL: normalizedServerUrl });
     }
 
@@ -245,8 +232,8 @@ async function saveAll() {
 }
 
 async function checkPythonService() {
-  if (!canManagePythonService.value) {
-    ElMessage.warning('需要管理员登录后才能配置');
+  if (!pythonFormEnabled.value) {
+    ElMessage.warning('请先填写服务器地址');
     return;
   }
   if (!apiBaseUrl.value) {
@@ -283,6 +270,7 @@ onMounted(() => {
 watch(
   () => serverStore.currentServerId,
   () => {
+    connectionResult.value = null;
     pythonCheckResult.value = null;
     pythonService.url = '';
     serverForm.url = serverStore.currentServer?.url || serverForm.url;
@@ -290,6 +278,15 @@ watch(
   }
 );
 
+// 用户修改服务器地址时清除旧的连接结果,避免误导
+watch(
+  () => serverForm.url,
+  () => {
+    connectionResult.value = null;
+    pythonCheckResult.value = null;
+  }
+);
+
 async function testConnection() {
   if (!serverForm.url) {
     ElMessage.warning('请先输入服务器地址');

+ 5 - 5
client/vite.config.ts

@@ -40,9 +40,9 @@ export default defineConfig(({ command }) => {
             resolve: (name: string) => {
               // Element Plus 图标组件通常是 PascalCase 且不以 El 开头
               const iconNames = [
-                'Fold', 'Expand', 'Loading', 'DocumentCopy', 'Message', 'Close', 
-                'Refresh', 'Delete', 'Right', 'FolderDelete', 'DataAnalysis', 
-                'User', 'Film', 'Upload', 'Clock', 'Setting', 'Document', 
+                'Fold', 'Expand', 'Loading', 'DocumentCopy', 'Message', 'Close',
+                'Refresh', 'Delete', 'Right', 'FolderDelete', 'DataAnalysis',
+                'User', 'Film', 'Upload', 'Clock', 'Setting', 'Document',
                 'Monitor', 'TrendCharts', 'ChatDotRound', 'ArrowDown', 'ArrowLeft',
                 'ArrowRight', 'VideoPlay', 'CircleCheck', 'CircleClose', 'Lock',
                 'Picture', 'Plus', 'Search', 'Edit', 'Download', 'MoreFilled',
@@ -113,11 +113,11 @@ export default defineConfig(({ command }) => {
       port: 5173,
       proxy: {
         '/api': {
-          target: 'http://localhost:3000',
+          target: 'http://127.0.0.1:3000',
           changeOrigin: true,
         },
         '/ws': {
-          target: 'ws://localhost:3000',
+          target: 'ws://127.0.0.1:3000',
           ws: true,
         },
       },

+ 6 - 10
server/src/routes/system.ts

@@ -16,17 +16,13 @@ function isLoopbackRequest(req: Request): boolean {
     || ip.startsWith('::ffff:127.');
 }
 
-function requireAdminOrLoopback(req: Request, res: Response, next: NextFunction): void {
+// 本地回环请求放行,否则需任一已登录用户(不要求管理员)
+function requireAuthOrLoopback(req: Request, res: Response, next: NextFunction): void {
   if (isLoopbackRequest(req)) {
     next();
     return;
   }
-
-  try {
-    authenticate(req, res, () => authorize('admin')(req, res, next));
-  } catch (e) {
-    next(e);
-  }
+  authenticate(req, res, next);
 }
 
 // 获取系统配置(公开)
@@ -99,7 +95,7 @@ router.put(
 
 router.get(
   '/python-service',
-  requireAdminOrLoopback,
+  requireAuthOrLoopback,
   asyncHandler(async (_req, res) => {
     const config = await systemService.getPythonServiceAdminConfig();
     res.json({ success: true, data: config });
@@ -108,7 +104,7 @@ router.get(
 
 router.put(
   '/python-service',
-  requireAdminOrLoopback,
+  requireAuthOrLoopback,
   [
     body('url').optional().isString(),
     validateRequest,
@@ -121,7 +117,7 @@ router.put(
 
 router.post(
   '/python-service/check',
-  requireAdminOrLoopback,
+  requireAuthOrLoopback,
   [
     body('url').optional().isString(),
     validateRequest,