Переглянути джерело

feat: enhance local service management and logging in Electron app

- Added functionality to start and stop local Node and Python services upon app initialization and exit.
- Implemented IPC methods for querying local service status and retrieving service logs.
- Updated server configuration logic to automatically use local service URLs if no servers are configured.
- Enhanced UI to display service logs and provide an option to open the log file.

These changes improve the integration and observability of local services within the Electron application.
Ethanfly 22 годин тому
батько
коміт
4d49010943

+ 1 - 0
.gitignore

@@ -5,6 +5,7 @@ node_modules/
 # Build outputs
 dist/
 build/
+_bundle/
 *.tsbuildinfo
 
 # Environment files

+ 17 - 10
client/electron-builder.json

@@ -2,6 +2,7 @@
   "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
   "appId": "com.mediamanager.app",
   "productName": "智媒通",
+  "electronVersion": "28.1.3",
   "directories": {
     "output": "release"
   },
@@ -13,15 +14,25 @@
     {
       "from": "public/icons",
       "to": "icons"
+    },
+    {
+      "from": "_bundle/server",
+      "to": "server"
+    },
+    {
+      "from": "_bundle/shared",
+      "to": "shared"
+    },
+    {
+      "from": "_bundle/playwright",
+      "to": "playwright"
     }
   ],
   "win": {
     "target": [
       {
         "target": "nsis",
-        "arch": [
-          "x64"
-        ]
+        "arch": ["x64"]
       }
     ],
     "icon": "build/icon.png",
@@ -35,17 +46,13 @@
     "shortcutName": "智媒通"
   },
   "mac": {
-    "target": [
-      "dmg"
-    ],
+    "target": ["dmg"],
     "icon": "build/icon.png",
     "artifactName": "${productName}-${version}.${ext}"
   },
   "linux": {
-    "target": [
-      "AppImage"
-    ],
+    "target": ["AppImage"],
     "icon": "build/icon.png",
     "artifactName": "${productName}-${version}.${ext}"
   }
-}
+}

+ 409 - 0
client/electron/local-services.ts

@@ -0,0 +1,409 @@
+/**
+ * 本地服务管理器
+ * 在 Electron 主进程中自动启动和管理 Node 服务端与 Python 发布服务
+ *
+ * 路径策略:
+ *   开发模式:直接引用源码目录 (project-root/server/)
+ *   打包模式:引用 extraResources 打包进来的 resources/server/
+ */
+const { spawn, execSync } = require('child_process');
+const path = require('path');
+const fs = require('fs');
+const http = require('http');
+const { app } = require('electron');
+
+interface ServiceProcess {
+  process: ReturnType<typeof spawn> | null;
+  name: string;
+  port: number;
+  ready: boolean;
+}
+
+const NODE_PORT = 3000;
+const PYTHON_PORT = 5005;
+
+const services: { node: ServiceProcess; python: ServiceProcess } = {
+  node: { process: null, name: 'Node Server', port: NODE_PORT, ready: false },
+  python: { process: null, name: 'Python Service', port: PYTHON_PORT, ready: false },
+};
+
+// ==================== 文件日志 ====================
+let logFilePath = '';
+
+function getLogFilePath(): string {
+  if (!logFilePath) {
+    try {
+      const logDir = app.getPath('userData');
+      logFilePath = path.join(logDir, 'local-services.log');
+    } catch {
+      logFilePath = path.join(process.cwd(), 'local-services.log');
+    }
+  }
+  return logFilePath;
+}
+
+function log(level: string, ...args: unknown[]): void {
+  const ts = new Date().toISOString();
+  const msg = `[${ts}] [${level}] ${args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}`;
+
+  if (level === 'ERROR') {
+    console.error(msg);
+  } else {
+    console.log(msg);
+  }
+
+  try {
+    fs.appendFileSync(getLogFilePath(), msg + '\n', 'utf-8');
+  } catch { /* ignore */ }
+}
+
+// ==================== 核心工具 ====================
+
+function isPackaged(): boolean {
+  return app.isPackaged;
+}
+
+function getServerDir(): string {
+  if (isPackaged()) {
+    return path.join(process.resourcesPath, 'server');
+  }
+  return path.resolve(__dirname, '..', '..', 'server');
+}
+
+function getPythonDir(): string {
+  return path.join(getServerDir(), 'python');
+}
+
+function findPython(): string {
+  const pythonDir = getPythonDir();
+  const isWin = process.platform === 'win32';
+
+  const venvPython = isWin
+    ? path.join(pythonDir, 'venv', 'Scripts', 'python.exe')
+    : path.join(pythonDir, 'venv', 'bin', 'python');
+
+  if (fs.existsSync(venvPython)) return venvPython;
+
+  return isWin ? 'python' : 'python3';
+}
+
+function findNodeRunner(): { cmd: string; args: string[]; useElectronAsNode?: boolean } {
+  const serverDir = getServerDir();
+  const distEntry = path.join(serverDir, 'dist', 'app.js');
+  const srcEntry = path.join(serverDir, 'src', 'app.ts');
+  const isWin = process.platform === 'win32';
+
+  if (isPackaged()) {
+    return { cmd: process.execPath, args: [distEntry], useElectronAsNode: true };
+  }
+
+  if (fs.existsSync(distEntry)) {
+    return { cmd: 'node', args: [distEntry] };
+  }
+
+  const tsxBin = path.join(serverDir, 'node_modules', '.bin', isWin ? 'tsx.cmd' : 'tsx');
+  if (fs.existsSync(tsxBin)) {
+    return { cmd: tsxBin, args: [srcEntry] };
+  }
+
+  return { cmd: isWin ? 'npx.cmd' : 'npx', args: ['tsx', srcEntry] };
+}
+
+function checkPort(port: number): Promise<boolean> {
+  return new Promise((resolve) => {
+    const req = http.get(`http://127.0.0.1:${port}/`, { timeout: 2000 }, (res: any) => {
+      res.resume();
+      resolve(true);
+    });
+    req.on('error', () => resolve(false));
+    req.on('timeout', () => { req.destroy(); resolve(false); });
+  });
+}
+
+async function waitForService(port: number, timeoutMs = 30000): Promise<boolean> {
+  const start = Date.now();
+  while (Date.now() - start < timeoutMs) {
+    if (await checkPort(port)) return true;
+    await new Promise((r) => setTimeout(r, 1000));
+  }
+  return false;
+}
+
+function getEnvFilePath(): string {
+  return path.join(getServerDir(), '.env');
+}
+
+function getPlaywrightBrowsersPath(): string | null {
+  if (isPackaged()) {
+    const bundled = path.join(process.resourcesPath, 'playwright');
+    if (fs.existsSync(bundled)) return bundled;
+  }
+  return null;
+}
+
+// ==================== 诊断:列出 resources 目录结构 ====================
+
+function dumpResourcesDir(): void {
+  if (!isPackaged()) return;
+  try {
+    const resDir = process.resourcesPath;
+    log('INFO', `resources 目录: ${resDir}`);
+
+    const topEntries = fs.readdirSync(resDir);
+    log('INFO', `resources/ 内容: ${topEntries.join(', ')}`);
+
+    const serverDir = path.join(resDir, 'server');
+    if (fs.existsSync(serverDir)) {
+      const serverEntries = fs.readdirSync(serverDir);
+      log('INFO', `resources/server/ 内容: ${serverEntries.join(', ')}`);
+
+      const distDir = path.join(serverDir, 'dist');
+      if (fs.existsSync(distDir)) {
+        const distEntries = fs.readdirSync(distDir).slice(0, 20);
+        log('INFO', `resources/server/dist/ 内容(前20): ${distEntries.join(', ')}`);
+      } else {
+        log('ERROR', 'resources/server/dist/ 不存在!');
+      }
+
+      const pyDir = path.join(serverDir, 'python');
+      if (fs.existsSync(pyDir)) {
+        const pyEntries = fs.readdirSync(pyDir);
+        log('INFO', `resources/server/python/ 内容: ${pyEntries.join(', ')}`);
+      } else {
+        log('ERROR', 'resources/server/python/ 不存在!');
+      }
+
+      const nmDir = path.join(serverDir, 'node_modules');
+      log('INFO', `resources/server/node_modules/ 存在: ${fs.existsSync(nmDir)}`);
+    } else {
+      log('ERROR', 'resources/server/ 目录不存在!');
+    }
+  } catch (e: any) {
+    log('ERROR', `诊断目录结构失败: ${e.message}`);
+  }
+}
+
+// ==================== 启动服务 ====================
+
+function startNodeServer(): void {
+  const serverDir = getServerDir();
+  if (!fs.existsSync(serverDir)) {
+    log('ERROR', `server 目录不存在: ${serverDir}`);
+    return;
+  }
+
+  const { cmd, args, useElectronAsNode } = findNodeRunner();
+  log('INFO', `启动 Node 服务: ${cmd} ${args.join(' ')}`);
+  log('INFO', `工作目录: ${serverDir}`);
+
+  const distApp = path.join(serverDir, 'dist', 'app.js');
+  log('INFO', `dist/app.js 存在: ${fs.existsSync(distApp)}`);
+
+  const envFile = getEnvFilePath();
+  log('INFO', `.env 存在: ${fs.existsSync(envFile)}`);
+
+  const env: Record<string, string> = {
+    ...process.env as Record<string, string>,
+    PORT: String(NODE_PORT),
+    HOST: '127.0.0.1',
+    PYTHON_PUBLISH_SERVICE_URL: `http://127.0.0.1:${PYTHON_PORT}`,
+  };
+
+  if (useElectronAsNode) {
+    env.ELECTRON_RUN_AS_NODE = '1';
+    log('INFO', 'ELECTRON_RUN_AS_NODE=1');
+  }
+
+  const pwPath = getPlaywrightBrowsersPath();
+  if (pwPath) {
+    env.PLAYWRIGHT_BROWSERS_PATH = pwPath;
+  }
+
+  try {
+    const child = spawn(cmd, args, {
+      cwd: serverDir,
+      env,
+      stdio: ['ignore', 'pipe', 'pipe'],
+      shell: false,
+      windowsHide: true,
+    });
+
+    log('INFO', `Node 进程已 spawn, PID: ${child.pid}`);
+
+    child.on('error', (err: Error) => {
+      log('ERROR', `Node spawn error: ${err.message}`);
+    });
+
+    child.stdout?.on('data', (data: Buffer) => {
+      const msg = data.toString().trim();
+      if (msg) log('NODE', msg);
+      if (msg.includes('listening') || msg.includes('started') || msg.includes('Server running')) {
+        services.node.ready = true;
+      }
+    });
+
+    child.stderr?.on('data', (data: Buffer) => {
+      const msg = data.toString().trim();
+      if (msg) log('NODE-ERR', msg);
+    });
+
+    child.on('exit', (code: number | null, signal: string | null) => {
+      log('INFO', `Node 服务退出, code=${code}, signal=${signal}`);
+      services.node.process = null;
+      services.node.ready = false;
+    });
+
+    services.node.process = child;
+  } catch (e: any) {
+    log('ERROR', `Node spawn 异常: ${e.message}`);
+  }
+}
+
+function startPythonService(): void {
+  const pythonDir = getPythonDir();
+  const appPy = path.join(pythonDir, 'app.py');
+  if (!fs.existsSync(appPy)) {
+    log('ERROR', `Python 入口不存在: ${appPy}`);
+    return;
+  }
+
+  const pythonCmd = findPython();
+  const args = [appPy, '--port', String(PYTHON_PORT), '--host', '127.0.0.1'];
+  log('INFO', `启动 Python 服务: ${pythonCmd} ${args.join(' ')}`);
+  log('INFO', `Python 可执行文件存在: ${fs.existsSync(pythonCmd)}`);
+  log('INFO', `工作目录: ${pythonDir}`);
+
+  const env: Record<string, string> = {
+    ...process.env as Record<string, string>,
+    PYTHONUNBUFFERED: '1',
+    PYTHONIOENCODING: 'utf-8',
+  };
+
+  const pwPath = getPlaywrightBrowsersPath();
+  if (pwPath) {
+    env.PLAYWRIGHT_BROWSERS_PATH = pwPath;
+    log('INFO', `Playwright 浏览器路径: ${pwPath}`);
+  }
+
+  try {
+    const child = spawn(pythonCmd, args, {
+      cwd: pythonDir,
+      env,
+      stdio: ['ignore', 'pipe', 'pipe'],
+      shell: false,
+      windowsHide: true,
+    });
+
+    log('INFO', `Python 进程已 spawn, PID: ${child.pid}`);
+
+    child.on('error', (err: Error) => {
+      log('ERROR', `Python spawn error: ${err.message}`);
+    });
+
+    child.stdout?.on('data', (data: Buffer) => {
+      const msg = data.toString().trim();
+      if (msg) log('PYTHON', msg);
+      if (msg.includes('Running on') || msg.includes('启动服务')) {
+        services.python.ready = true;
+      }
+    });
+
+    child.stderr?.on('data', (data: Buffer) => {
+      const msg = data.toString().trim();
+      if (msg) log('PYTHON-ERR', msg);
+    });
+
+    child.on('exit', (code: number | null, signal: string | null) => {
+      log('INFO', `Python 服务退出, code=${code}, signal=${signal}`);
+      services.python.process = null;
+      services.python.ready = false;
+    });
+
+    services.python.process = child;
+  } catch (e: any) {
+    log('ERROR', `Python spawn 异常: ${e.message}`);
+  }
+}
+
+// ==================== 导出 API ====================
+
+export async function startLocalServices(): Promise<{ nodeOk: boolean; pythonOk: boolean }> {
+  // 清空旧日志
+  try { fs.writeFileSync(getLogFilePath(), '', 'utf-8'); } catch { /* ignore */ }
+
+  log('INFO', '========== 启动本地服务 ==========');
+  log('INFO', `模式: ${isPackaged() ? '打包' : '开发'}`);
+  log('INFO', `execPath: ${process.execPath}`);
+  log('INFO', `resourcesPath: ${process.resourcesPath}`);
+  log('INFO', `Server 目录: ${getServerDir()}`);
+  log('INFO', `Python 目录: ${getPythonDir()}`);
+  log('INFO', `日志文件: ${getLogFilePath()}`);
+
+  dumpResourcesDir();
+
+  const [nodeAlive, pythonAlive] = await Promise.all([
+    checkPort(NODE_PORT),
+    checkPort(PYTHON_PORT),
+  ]);
+
+  if (nodeAlive) {
+    log('INFO', `Node 服务已在 ${NODE_PORT} 端口运行`);
+    services.node.ready = true;
+  } else {
+    startNodeServer();
+  }
+
+  if (pythonAlive) {
+    log('INFO', `Python 服务已在 ${PYTHON_PORT} 端口运行`);
+    services.python.ready = true;
+  } else {
+    startPythonService();
+  }
+
+  const [nodeOk, pythonOk] = await Promise.all([
+    services.node.ready ? Promise.resolve(true) : waitForService(NODE_PORT, 30000),
+    services.python.ready ? Promise.resolve(true) : waitForService(PYTHON_PORT, 30000),
+  ]);
+
+  services.node.ready = nodeOk;
+  services.python.ready = pythonOk;
+
+  log('INFO', `结果 — Node: ${nodeOk ? '✓ 就绪' : '✗ 未就绪'}, Python: ${pythonOk ? '✓ 就绪' : '✗ 未就绪'}`);
+  return { nodeOk, pythonOk };
+}
+
+export function stopLocalServices(): void {
+  log('INFO', '正在停止本地服务...');
+
+  for (const key of ['node', 'python'] as const) {
+    const svc = services[key];
+    if (svc.process) {
+      log('INFO', `停止 ${svc.name} (PID: ${svc.process.pid})`);
+      try {
+        if (process.platform === 'win32') {
+          spawn('taskkill', ['/pid', String(svc.process.pid), '/f', '/t'], { windowsHide: true });
+        } else {
+          svc.process.kill('SIGTERM');
+        }
+      } catch (e: any) {
+        log('ERROR', `停止 ${svc.name} 失败: ${e.message}`);
+      }
+      svc.process = null;
+      svc.ready = false;
+    }
+  }
+}
+
+export function getServiceStatus(): { node: { ready: boolean; port: number }; python: { ready: boolean; port: number } } {
+  return {
+    node: { ready: services.node.ready, port: NODE_PORT },
+    python: { ready: services.python.ready, port: PYTHON_PORT },
+  };
+}
+
+export function getLogPath(): string {
+  return getLogFilePath();
+}
+
+export const LOCAL_NODE_URL = `http://127.0.0.1:${NODE_PORT}`;
+export const LOCAL_PYTHON_URL = `http://127.0.0.1:${PYTHON_PORT}`;

+ 39 - 1
client/electron/main.ts

@@ -4,6 +4,7 @@ const { join } = require('path');
 const fs = require('fs');
 const http = require('http');
 const https = require('https');
+import { startLocalServices, stopLocalServices, getServiceStatus, LOCAL_NODE_URL, LOCAL_PYTHON_URL, getLogPath } from './local-services';
 
 let mainWindow: typeof BrowserWindow.prototype | null = null;
 let tray: typeof Tray.prototype | null = null;
@@ -253,8 +254,14 @@ if (!gotTheLock) {
     }
   });
 
-  app.whenReady().then(() => {
+  app.whenReady().then(async () => {
     createTray();
+
+    // 启动本地 Node 和 Python 服务
+    console.log('[Main] 正在启动本地服务...');
+    const { nodeOk, pythonOk } = await startLocalServices();
+    console.log(`[Main] 本地服务状态: Node=${nodeOk ? '就绪' : '未就绪'}, Python=${pythonOk ? '就绪' : '未就绪'}`);
+
     createWindow();
 
     // 配置 webview session,允许第三方 cookies 和跨域请求
@@ -347,6 +354,7 @@ app.on('before-quit', () => {
 });
 
 app.on('quit', () => {
+  stopLocalServices();
   if (tray) {
     tray.destroy();
     tray = null;
@@ -379,6 +387,36 @@ ipcMain.handle('test-python-service-connection', async (_event: unknown, args: {
   }
 });
 
+// 本地服务状态查询
+ipcMain.handle('get-local-services-status', () => {
+  return getServiceStatus();
+});
+
+ipcMain.handle('get-local-urls', () => {
+  return { nodeUrl: LOCAL_NODE_URL, pythonUrl: LOCAL_PYTHON_URL };
+});
+
+ipcMain.handle('get-service-log', () => {
+  try {
+    const logPath = getLogPath();
+    if (fs.existsSync(logPath)) {
+      return { path: logPath, content: fs.readFileSync(logPath, 'utf-8') };
+    }
+    return { path: logPath, content: '(日志文件不存在)' };
+  } catch (e: any) {
+    return { path: '', content: `读取失败: ${e.message}` };
+  }
+});
+
+ipcMain.handle('open-log-file', () => {
+  try {
+    const logPath = getLogPath();
+    if (fs.existsSync(logPath)) {
+      shell.showItemInFolder(logPath);
+    }
+  } catch { /* ignore */ }
+});
+
 // IPC 处理
 ipcMain.handle('get-app-version', () => {
   return app.getVersion();

+ 8 - 0
client/electron/preload.ts

@@ -72,6 +72,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
     ipcRenderer.invoke('test-server-connection', { url }),
   testPythonServiceConnection: (url: string) =>
     ipcRenderer.invoke('test-python-service-connection', { url }),
+
+  // 本地服务管理
+  getLocalServicesStatus: () => ipcRenderer.invoke('get-local-services-status'),
+  getLocalUrls: () => ipcRenderer.invoke('get-local-urls'),
+  getServiceLog: () => ipcRenderer.invoke('get-service-log'),
+  openLogFile: () => ipcRenderer.invoke('open-log-file'),
 });
 
 // 类型声明
@@ -107,6 +113,8 @@ declare global {
       removeNetworkInterceptListener: () => void;
       testServerConnection: (url: string) => Promise<{ ok: boolean; error?: string }>;
       testPythonServiceConnection: (url: string) => Promise<{ ok: boolean; error?: string }>;
+      getLocalServicesStatus: () => Promise<{ node: { ready: boolean; port: number }; python: { ready: boolean; port: number } }>;
+      getLocalUrls: () => Promise<{ nodeUrl: string; pythonUrl: string }>;
     };
   }
 }

+ 2 - 45
client/package.json

@@ -5,8 +5,9 @@
   "main": "dist-electron/main.js",
   "scripts": {
     "dev": "vite",
-    "build": "vite build && electron-builder",
+    "build": "node scripts/build-server.js && vite build && electron-builder",
     "build:vite": "vite build",
+    "build:server": "node scripts/build-server.js",
     "preview": "vite preview",
     "electron:dev": "vite --mode electron",
     "electron:build": "vite build && electron-builder",
@@ -46,49 +47,5 @@
     "vite-plugin-electron": "^0.28.0",
     "vite-plugin-electron-renderer": "^0.14.5",
     "vue-tsc": "^1.8.27"
-  },
-  "build": {
-    "appId": "com.media-manager.app",
-    "productName": "智媒通",
-    "electronVersion": "28.1.3",
-    "directories": {
-      "output": "release"
-    },
-    "files": [
-      "dist/**/*",
-      "dist-electron/**/*"
-    ],
-    "extraResources": [
-      {
-        "from": "public/icons",
-        "to": "icons"
-      }
-    ],
-    "win": {
-      "target": [
-        "nsis",
-        "portable"
-      ],
-      "icon": "build/icon.png"
-    },
-    "mac": {
-      "target": [
-        "dmg",
-        "zip"
-      ],
-      "icon": "build/icon.png"
-    },
-    "linux": {
-      "target": [
-        "AppImage",
-        "deb"
-      ],
-      "icon": "build/icon.png"
-    },
-    "nsis": {
-      "oneClick": false,
-      "allowToChangeInstallationDirectory": true,
-      "createDesktopShortcut": true
-    }
   }
 }

+ 289 - 0
client/scripts/build-server.js

@@ -0,0 +1,289 @@
+/**
+ * 打包前置构建脚本
+ * 在 electron-builder 打包前:
+ *   1. 编译 shared + Node 服务端
+ *   2. 确保 Python 依赖就绪
+ *   3. 将所有服务端资源复制到 client/build/server-bundle/
+ *   4. 复制 Playwright 浏览器到 client/build/playwright-browsers/
+ *
+ * 使用方式: node scripts/build-server.js
+ */
+
+const { execSync } = require('child_process');
+const path = require('path');
+const fs = require('fs');
+
+const ROOT = path.resolve(__dirname, '..', '..');
+const CLIENT_DIR = path.resolve(__dirname, '..');
+const SERVER_DIR = path.join(ROOT, 'server');
+const PYTHON_DIR = path.join(SERVER_DIR, 'python');
+const SHARED_DIR = path.join(ROOT, 'shared');
+
+const BUNDLE_DIR = path.join(CLIENT_DIR, '_bundle');
+const SERVER_BUNDLE = path.join(BUNDLE_DIR, 'server');
+const SHARED_BUNDLE = path.join(BUNDLE_DIR, 'shared');
+const PLAYWRIGHT_DEST = path.join(BUNDLE_DIR, 'playwright');
+
+const isWin = process.platform === 'win32';
+
+function run(cmd, cwd) {
+  console.log(`\n> [${path.basename(cwd)}] ${cmd}`);
+  execSync(cmd, { cwd, stdio: 'inherit', shell: true });
+}
+
+function step(msg) {
+  console.log(`\n${'='.repeat(60)}`);
+  console.log(`  ${msg}`);
+  console.log('='.repeat(60));
+}
+
+function cleanDir(dir) {
+  if (fs.existsSync(dir)) {
+    fs.rmSync(dir, { recursive: true, force: true });
+  }
+  fs.mkdirSync(dir, { recursive: true });
+}
+
+function copyDir(src, dest, options = {}) {
+  const { filter, dereference = true } = options;
+  if (!fs.existsSync(src)) {
+    console.log(`    跳过(不存在): ${src}`);
+    return;
+  }
+  fs.mkdirSync(dest, { recursive: true });
+
+  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
+    const srcPath = path.join(src, entry.name);
+    const destPath = path.join(dest, entry.name);
+
+    // 解析可能的 symlink
+    let stat;
+    try {
+      stat = dereference ? fs.statSync(srcPath) : fs.lstatSync(srcPath);
+    } catch {
+      continue;
+    }
+
+    if (stat.isDirectory()) {
+      if (filter && filter.skipDir && filter.skipDir(entry.name, srcPath)) continue;
+      copyDir(srcPath, destPath, options);
+    } else if (stat.isFile()) {
+      if (filter && filter.skipFile && filter.skipFile(entry.name, srcPath)) continue;
+      fs.copyFileSync(srcPath, destPath);
+    }
+  }
+}
+
+function copyFile(src, dest) {
+  if (!fs.existsSync(src)) {
+    console.log(`    跳过(不存在): ${src}`);
+    return;
+  }
+  fs.mkdirSync(path.dirname(dest), { recursive: true });
+  fs.copyFileSync(src, dest);
+}
+
+function dirSizeMB(dir) {
+  try {
+    const out = execSync(
+      isWin
+        ? `powershell -c "(Get-ChildItem -Recurse '${dir}' -File | Measure-Object -Property Length -Sum).Sum"`
+        : `du -sb "${dir}" | cut -f1`,
+      { encoding: 'utf-8', shell: true }
+    ).trim();
+    return Math.round(parseInt(out || '0') / 1024 / 1024);
+  } catch {
+    return -1;
+  }
+}
+
+// =========================================================
+// 1. 编译 shared 包
+// =========================================================
+step('1/7  编译 shared 包');
+if (fs.existsSync(path.join(SHARED_DIR, 'package.json'))) {
+  run('npm run build', SHARED_DIR);
+} else {
+  console.log('  跳过(shared 目录不存在)');
+}
+
+// =========================================================
+// 2. 编译 Node 服务端
+// =========================================================
+step('2/7  编译 Node 服务端');
+try {
+  run('npx tsc', SERVER_DIR);
+} catch {
+  const distDir = path.join(SERVER_DIR, 'dist');
+  if (fs.existsSync(distDir) && fs.readdirSync(distDir).length > 0) {
+    console.log('  ⚠ tsc 有类型警告,但 dist/ 已生成,继续');
+  } else {
+    throw new Error('Node 服务端编译失败且 dist/ 未生成');
+  }
+}
+
+// =========================================================
+// 3. 检查 Python venv
+// =========================================================
+step('3/7  检查 Python 虚拟环境');
+const venvPython = isWin
+  ? path.join(PYTHON_DIR, 'venv', 'Scripts', 'python.exe')
+  : path.join(PYTHON_DIR, 'venv', 'bin', 'python');
+
+if (fs.existsSync(venvPython)) {
+  console.log(`  venv 已存在: ${venvPython}`);
+  const reqTxt = path.join(PYTHON_DIR, 'requirements.txt');
+  if (fs.existsSync(reqTxt)) {
+    console.log('  安装/更新 Python 依赖...');
+    run(`"${venvPython}" -m pip install -r requirements.txt --quiet`, PYTHON_DIR);
+  }
+} else {
+  console.log('  venv 不存在,尝试创建...');
+  try {
+    run(`${isWin ? 'python' : 'python3'} -m venv venv`, PYTHON_DIR);
+    const reqTxt = path.join(PYTHON_DIR, 'requirements.txt');
+    if (fs.existsSync(reqTxt)) {
+      run(`"${venvPython}" -m pip install -r requirements.txt`, PYTHON_DIR);
+    }
+  } catch (e) {
+    console.warn(`  ⚠ 创建 venv 失败: ${e.message}`);
+  }
+}
+
+if (fs.existsSync(venvPython)) {
+  console.log('  确保 Playwright chromium 已安装...');
+  try {
+    run(`"${venvPython}" -m playwright install chromium`, PYTHON_DIR);
+  } catch (e) {
+    console.warn(`  ⚠ Playwright install 失败: ${e.message}`);
+  }
+}
+
+// =========================================================
+// 4. 复制 shared 包(server 依赖它,必须先准备好)
+// =========================================================
+step('4/7  复制 shared 包');
+cleanDir(SHARED_BUNDLE);
+copyDir(path.join(SHARED_DIR, 'dist'), path.join(SHARED_BUNDLE, 'dist'));
+copyFile(path.join(SHARED_DIR, 'package.json'), path.join(SHARED_BUNDLE, 'package.json'));
+console.log('  shared 包已就绪');
+
+// =========================================================
+// 5. 复制服务端资源 + 安装依赖
+// =========================================================
+step('5/7  复制服务端资源并安装依赖');
+
+cleanDir(SERVER_BUNDLE);
+
+// 5a. 先复制 package.json 并修改 workspace 引用
+console.log('  复制 package.json ...');
+copyFile(path.join(SERVER_DIR, 'package.json'), path.join(SERVER_BUNDLE, 'package.json'));
+
+const bundlePkgPath = path.join(SERVER_BUNDLE, 'package.json');
+const bundlePkg = JSON.parse(fs.readFileSync(bundlePkgPath, 'utf-8'));
+if (bundlePkg.dependencies && bundlePkg.dependencies['@media-manager/shared']) {
+  bundlePkg.dependencies['@media-manager/shared'] = 'file:../shared';
+  fs.writeFileSync(bundlePkgPath, JSON.stringify(bundlePkg, null, 2), 'utf-8');
+  console.log('    -> @media-manager/shared: workspace:* => file:../shared');
+}
+
+// 5b. npm install 生成干净的 node_modules(避免 pnpm symlink 问题)
+console.log('  运行 npm install --omit=dev (耐心等待) ...');
+run('npm install --omit=dev', SERVER_BUNDLE);
+console.log(`    -> node_modules: ${dirSizeMB(path.join(SERVER_BUNDLE, 'node_modules'))} MB`);
+
+// 5c. 复制编译后的代码
+console.log('  复制 server/dist/ ...');
+copyDir(path.join(SERVER_DIR, 'dist'), path.join(SERVER_BUNDLE, 'dist'));
+console.log(`    -> dist: ${dirSizeMB(path.join(SERVER_BUNDLE, 'dist'))} MB`);
+
+// 5d. 复制 .env
+console.log('  复制 .env ...');
+copyFile(path.join(SERVER_DIR, '.env'), path.join(SERVER_BUNDLE, '.env'));
+
+// 5e. 创建 uploads 目录
+fs.mkdirSync(path.join(SERVER_BUNDLE, 'uploads'), { recursive: true });
+const gitkeep = path.join(SERVER_DIR, 'uploads', '.gitkeep');
+if (fs.existsSync(gitkeep)) {
+  fs.copyFileSync(gitkeep, path.join(SERVER_BUNDLE, 'uploads', '.gitkeep'));
+}
+
+// 5f. 复制 Python 服务(源码 + venv)
+console.log('  复制 server/python/ ...');
+copyDir(
+  PYTHON_DIR,
+  path.join(SERVER_BUNDLE, 'python'),
+  {
+    dereference: true,
+    filter: {
+      skipDir: (name) => name === '__pycache__' || name === '.pytest_cache',
+      skipFile: (name) => name.endsWith('.pyc'),
+    },
+  }
+);
+console.log(`    -> python: ${dirSizeMB(path.join(SERVER_BUNDLE, 'python'))} MB`);
+
+// =========================================================
+// 6. 复制 Playwright 浏览器
+// =========================================================
+step('6/7  复制 Playwright 浏览器');
+
+function findPlaywrightBrowsersDir() {
+  if (process.env.PLAYWRIGHT_BROWSERS_PATH && fs.existsSync(process.env.PLAYWRIGHT_BROWSERS_PATH)) {
+    return process.env.PLAYWRIGHT_BROWSERS_PATH;
+  }
+  const candidates = [];
+  if (isWin) {
+    candidates.push(path.join(process.env.LOCALAPPDATA || '', 'ms-playwright'));
+  } else if (process.platform === 'darwin') {
+    candidates.push(path.join(process.env.HOME || '', 'Library', 'Caches', 'ms-playwright'));
+  } else {
+    candidates.push(path.join(process.env.HOME || '', '.cache', 'ms-playwright'));
+  }
+  for (const c of candidates) {
+    if (c && fs.existsSync(c)) return c;
+  }
+  return null;
+}
+
+const pwBrowsersDir = findPlaywrightBrowsersDir();
+if (!pwBrowsersDir) {
+  console.error('  ⚠ 未找到 Playwright 浏览器缓存目录');
+} else {
+  cleanDir(PLAYWRIGHT_DEST);
+  const entries = fs.readdirSync(pwBrowsersDir);
+  const needed = entries.filter(n => n.startsWith('chromium') || n.startsWith('ffmpeg'));
+  if (needed.length === 0) {
+    console.error('  ⚠ 未找到 chromium');
+  } else {
+    for (const dir of needed) {
+      console.log(`  复制 ${dir} ...`);
+      copyDir(path.join(pwBrowsersDir, dir), path.join(PLAYWRIGHT_DEST, dir));
+      console.log(`    -> ${dirSizeMB(path.join(PLAYWRIGHT_DEST, dir))} MB`);
+    }
+  }
+}
+
+// =========================================================
+step('7/7  验证打包资源');
+
+function verify(label, p) {
+  const exists = fs.existsSync(p);
+  const icon = exists ? '✓' : '✗';
+  console.log(`  ${icon} ${label}: ${p} (${exists ? '存在' : '不存在!'})`);
+  if (!exists) {
+    console.error(`    ⚠ 缺少关键资源,打包后服务将无法启动!`);
+  }
+}
+
+verify('server dist/app.js', path.join(SERVER_BUNDLE, 'dist', 'app.js'));
+verify('server node_modules/', path.join(SERVER_BUNDLE, 'node_modules'));
+verify('server .env', path.join(SERVER_BUNDLE, '.env'));
+verify('server python/app.py', path.join(SERVER_BUNDLE, 'python', 'app.py'));
+verify('shared dist/', path.join(SHARED_BUNDLE, 'dist'));
+verify('playwright/', PLAYWRIGHT_DEST);
+
+step('构建完成!所有资源已准备就绪');
+console.log(`  server:     ${SERVER_BUNDLE}`);
+console.log(`  shared:     ${SHARED_BUNDLE}`);
+console.log(`  playwright: ${PLAYWRIGHT_DEST}`);

+ 5 - 3
client/src/router/index.ts

@@ -111,15 +111,17 @@ const router = createRouter({
 
 // 标记是否已初始化认证状态
 let authInitialized = false;
+let configLoaded = false;
 
 // 路由守卫
 router.beforeEach(async (to, _from, next) => {
   const authStore = useAuthStore();
   const serverStore = useServerStore();
   
-  // 加载服务器配置(同步操作)
-  if (!serverStore.isConfigured) {
-    serverStore.loadConfig();
+  // 加载服务器配置(异步,仅执行一次)
+  if (!configLoaded) {
+    configLoaded = true;
+    await serverStore.loadConfig();
   }
   
   // 检查服务器配置

+ 22 - 2
client/src/stores/server.ts

@@ -26,8 +26,8 @@ export const useServerStore = defineStore('server', () => {
 
   const isConfigured = computed(() => !!currentServer.value);
 
-  // 加载配置
-  function loadConfig() {
+  // 加载配置(Electron 环境下自动配置本地服务)
+  async function loadConfig() {
     const savedServers = localStorage.getItem(STORAGE_KEY);
     const savedCurrent = localStorage.getItem(CURRENT_SERVER_KEY);
     
@@ -37,6 +37,26 @@ export const useServerStore = defineStore('server', () => {
     if (savedCurrent) {
       currentServerId.value = savedCurrent;
     }
+
+    // Electron 环境:如果未配置任何服务器,自动使用本地服务地址
+    const electronApi = (window as any)?.electronAPI;
+    if (electronApi?.getLocalUrls && servers.value.length === 0) {
+      try {
+        const { nodeUrl, pythonUrl } = await electronApi.getLocalUrls();
+        servers.value = [{
+          id: SINGLE_SERVER_ID,
+          name: '本地服务',
+          url: nodeUrl,
+          pythonServiceUrl: pythonUrl,
+          isDefault: true,
+        }];
+        currentServerId.value = SINGLE_SERVER_ID;
+        saveConfig();
+        console.log('[ServerStore] 已自动配置本地服务:', nodeUrl, pythonUrl);
+      } catch (e) {
+        console.warn('[ServerStore] 获取本地服务地址失败:', e);
+      }
+    }
   }
 
   // 保存配置

+ 43 - 0
client/src/views/ServerConfig/index.vue

@@ -111,6 +111,15 @@
           返回
         </el-button>
       </div>
+
+      <!-- 启动日志(仅 Electron 环境显示) -->
+      <div v-if="serviceLog" class="service-log-section">
+        <div class="section-title-row" style="margin-top: 24px;">
+          <div class="section-title" style="font-size: 13px; color: #999;">启动日志</div>
+          <el-button link size="small" @click="openLogFile" style="font-size: 12px;">打开日志文件</el-button>
+        </div>
+        <pre class="service-log-content">{{ serviceLog }}</pre>
+      </div>
     </div>
   </div>
 </template>
@@ -298,10 +307,27 @@ async function checkPythonService() {
   }
 }
 
+const serviceLog = ref('');
+
+async function loadServiceLog() {
+  const api = (window as any)?.electronAPI;
+  if (api?.getServiceLog) {
+    try {
+      const result = await api.getServiceLog();
+      serviceLog.value = result?.content || '';
+    } catch { /* ignore */ }
+  }
+}
+
+function openLogFile() {
+  (window as any)?.electronAPI?.openLogFile?.();
+}
+
 onMounted(() => {
   serverForm.url = serverStore.currentServer?.url || '';
   pythonService.url = serverStore.currentServer?.pythonServiceUrl || '';
   if (pythonFormEnabled.value) loadPythonService();
+  loadServiceLog();
 });
 
 watch(
@@ -696,6 +722,23 @@ function goBack() {
   }
 }
 
+.service-log-section {
+  .service-log-content {
+    max-height: 200px;
+    overflow: auto;
+    background: #1e1e1e;
+    color: #d4d4d4;
+    padding: 12px;
+    border-radius: 6px;
+    font-size: 11px;
+    line-height: 1.5;
+    font-family: 'Consolas', 'Monaco', monospace;
+    white-space: pre-wrap;
+    word-break: break-all;
+    margin: 0;
+  }
+}
+
 .bottom-actions {
   display: flex;
   gap: 12px;

+ 1 - 1
package.json

@@ -18,7 +18,7 @@
     "dev": "concurrently \"pnpm --filter server dev\" \"pnpm --filter client dev\"",
     "dev:server": "pnpm --filter server dev",
     "dev:client": "pnpm --filter client dev",
-    "build": "pnpm --filter shared build && pnpm --filter server build && pnpm --filter client build",
+    "build": "pnpm --filter client build",
     "build:server": "pnpm --filter server build",
     "build:client": "pnpm --filter client build",
     "lint": "pnpm -r lint",