feat: 添加 SDD (规范驱动开发) 模块

This commit is contained in:
2026-03-18 16:17:30 +08:00
parent 90517f2289
commit c83f23c319
7 changed files with 312 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import { generatePdf } from './services/pdfGenerator';
import { selectHtmlFile } from './services/htmlImport';
import { opencodeService } from './services/opencodeService';
import { xcOpenCodeWebService } from './services/xcOpenCodeWebService';
import { sddService } from './services/sddService';
import { electronState } from './state';
log.initialize();
@@ -387,6 +388,22 @@ ipcMain.handle('xc-opencode-web-stop', async () => {
return await xcOpenCodeWebService.stop();
});
ipcMain.handle('sdd-get-status', () => {
return sddService.getStatus();
});
ipcMain.handle('sdd-get-port', () => {
return { port: sddService.port };
});
ipcMain.handle('sdd-start', async () => {
return await sddService.start();
});
ipcMain.handle('sdd-stop', async () => {
return await sddService.stop();
});
async function startServer() {
if (electronState.isDevelopment()) {
log.info('In dev mode, assuming external servers are running.');
@@ -457,6 +474,7 @@ app.on('window-all-closed', () => {
globalShortcut.unregisterAll();
opencodeService.stop();
xcOpenCodeWebService.stop();
sddService.stop();
stopClipboardWatcher();
if (process.platform !== 'darwin') {
app.quit();
@@ -474,7 +492,8 @@ app.on('before-quit', async (event) => {
await Promise.all([
opencodeService.stop(),
xcOpenCodeWebService.stop()
xcOpenCodeWebService.stop(),
sddService.stop()
]);
log.info('[App] All services stopped');

View File

@@ -45,4 +45,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
xcOpenCodeWebStop: () => ipcRenderer.invoke('xc-opencode-web-stop'),
xcOpenCodeWebGetStatus: () => ipcRenderer.invoke('xc-opencode-web-get-status'),
xcOpenCodeWebGetPort: () => ipcRenderer.invoke('xc-opencode-web-get-port'),
sddStart: () => ipcRenderer.invoke('sdd-start'),
sddStop: () => ipcRenderer.invoke('sdd-stop'),
sddGetStatus: () => ipcRenderer.invoke('sdd-get-status'),
sddGetPort: () => ipcRenderer.invoke('sdd-get-port'),
})

View File

@@ -0,0 +1,175 @@
import { spawn, exec, ChildProcess } from 'child_process';
import { app } from 'electron';
import path from 'path';
import log from 'electron-log';
const SDD_PORT = 9998;
const HEALTH_CHECK_INTERVAL = 10000;
const HEALTH_CHECK_TIMEOUT = 2000;
class SDDService {
private process: ChildProcess | null = null;
private processPid: number | null = null;
private healthCheckTimer: NodeJS.Timeout | null = null;
private _isRunning = false;
private _isStarting = false;
get port(): number {
return SDD_PORT;
}
isRunning(): boolean {
return this._isRunning;
}
getStatus(): { running: boolean; port: number } {
return {
running: this._isRunning,
port: this.port,
};
}
private getExePath(): string {
const exeName = 'XCSDD.exe';
const isPackaged = app.isPackaged;
let basePath: string;
if (isPackaged) {
basePath = path.join(process.resourcesPath, 'app.asar.unpacked');
} else {
basePath = process.cwd();
}
return path.join(basePath, 'services', 'xcsdd', exeName);
}
private getExeArgs(): string[] {
return ['--port', this.port.toString()];
}
private async checkHealth(): Promise<boolean> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
const response = await fetch(`http://127.0.0.1:${this.port}`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
return response.ok || response.status === 401;
} catch (error) {
log.warn('[SDDService] Health check failed:', error);
return false;
}
}
private startHealthCheck(): void {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
}
this.healthCheckTimer = setInterval(async () => {
const isHealthy = await this.checkHealth();
if (!isHealthy && this._isRunning) {
log.warn('[SDDService] Service not responding');
}
}, HEALTH_CHECK_INTERVAL);
}
async start(): Promise<{ success: boolean; error?: string }> {
if (this._isRunning && this.process) {
log.info('[SDDService] Already running');
return { success: true };
}
if (this._isStarting) {
log.info('[SDDService] Already starting');
return { success: true };
}
this._isStarting = true;
try {
const exePath = this.getExePath();
const exeArgs = this.getExeArgs();
log.info(`[SDDService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
this.process = spawn(exePath, exeArgs, {
stdio: 'pipe',
});
this.processPid = this.process.pid ?? null;
this.process.stdout?.on('data', (data) => {
log.info(`[SDD] ${data}`);
});
this.process.stderr?.on('data', (data) => {
log.error(`[SDD error] ${data}`);
});
this.process.on('error', (err) => {
log.error('[SDDService] Process error:', err);
this._isRunning = false;
this._isStarting = false;
this.process = null;
this.processPid = null;
});
this.process.on('exit', (code) => {
log.info(`[SDDService] Process exited with code ${code}`);
this._isRunning = false;
this._isStarting = false;
this.process = null;
this.processPid = null;
});
await new Promise<void>((resolve) => setTimeout(resolve, 2000));
this._isRunning = true;
this._isStarting = false;
this.startHealthCheck();
log.info(`[SDDService] Started successfully on port ${this.port}`);
return { success: true };
} catch (error: any) {
log.error('[SDDService] Failed to start:', error);
this._isRunning = false;
this._isStarting = false;
return { success: false, error: error.message };
}
}
async stop(): Promise<{ success: boolean; error?: string }> {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
if (!this.processPid) {
log.info('[SDDService] Not running');
return { success: true };
}
return new Promise((resolve) => {
log.info(`[SDDService] Stopping process ${this.processPid}...`);
exec(`taskkill /F /PID ${this.processPid}`, (error) => {
this.process = null;
this.processPid = null;
this._isRunning = false;
if (error) {
log.error('[SDDService] Failed to stop:', error);
resolve({ success: false, error: error.message });
} else {
log.info('[SDDService] Stopped');
resolve({ success: true });
}
});
});
}
}
export const sddService = new SDDService();

View File

@@ -0,0 +1,12 @@
import { defineModule } from '../types.js'
export const SDD_MODULE = defineModule({
id: 'sdd',
name: 'SDD',
basePath: '/sdd',
order: 16,
version: '1.0.0',
backend: {
enabled: true,
},
})

View File

@@ -0,0 +1,86 @@
import React, { useEffect, useState, useRef } from 'react'
export const SDDPage: React.FC = () => {
const [isHealthy, setIsHealthy] = useState(false)
const [port, setPort] = useState<number>(9998)
const startedRef = useRef(false)
const restartingRef = useRef(false)
const webviewRef = useRef<HTMLWebViewElement>(null)
useEffect(() => {
let mounted = true
const start = async () => {
try {
const portResult = await window.electronAPI.sddGetPort()
if (mounted) {
setPort(portResult.port)
}
const result = await window.electronAPI.sddStart()
if (!result.success && mounted) {
console.error('Failed to start SDD:', result.error)
}
restartingRef.current = false
} catch (err) {
console.error('Failed to start SDD:', err)
restartingRef.current = false
}
}
const checkStatus = async () => {
try {
const status = await window.electronAPI.sddGetStatus()
if (mounted) {
setIsHealthy(status.running)
if (!status.running && !restartingRef.current) {
restartingRef.current = true
start()
}
}
} catch (err) {
if (mounted) {
setIsHealthy(false)
if (!restartingRef.current) {
restartingRef.current = true
start()
}
}
}
}
if (!startedRef.current) {
startedRef.current = true
start()
}
const interval = setInterval(checkStatus, 2000)
return () => {
mounted = false
clearInterval(interval)
window.electronAPI.sddStop()
startedRef.current = false
}
}, [])
return (
<div className="h-full w-full relative">
<span className={`absolute bottom-2 right-2 z-10 w-1.5 h-1.5 rounded-full ${isHealthy ? 'bg-green-500' : 'bg-red-500'}`} />
{!isHealthy && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
</div>
)}
{isHealthy && (
<webview
ref={webviewRef}
src={`http://localhost:${port}`}
style={{ width: '100%', height: '100%', border: 'none' }}
allowpopups={true}
webpreferences="contextIsolation=no"
/>
)}
</div>
)
}

11
src/modules/sdd/index.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { FileCode } from 'lucide-react'
import { SDDPage } from './SDDPage'
import { SDD_MODULE } from '@shared/modules/sdd'
import { createFrontendModule } from '@/lib/module-registry'
export default createFrontendModule(SDD_MODULE, {
icon: FileCode,
component: SDDPage,
})
export { SDDPage }

View File

@@ -45,6 +45,10 @@ export interface ElectronAPI {
xcOpenCodeWebGetStatus: () => Promise<{ running: boolean; port: number }>
xcOpenCodeWebGetPort: () => Promise<{ port: number }>
xcOpenCodeWebCheckReady: () => Promise<{ ready: boolean }>
sddStart: () => Promise<{ success: boolean; error?: string }>
sddStop: () => Promise<{ success: boolean; error?: string }>
sddGetStatus: () => Promise<{ running: boolean; port: number }>
sddGetPort: () => Promise<{ port: number }>
}
declare global {