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

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 1 день тому
батько
коміт
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",