feat: add task management and progress reporting

- Implemented task and subtask structures with progress tracking.
- Added reporting functionality to log progress at various stages in hotel room status management scripts.
- Created a task store to manage tasks and their states, including persistence to local storage.
- Updated UI components to display task lists and handle task actions (retry, remove).
- Removed deprecated TaskCard and TaskList components, replacing them with a new structure for better maintainability.
- Enhanced script execution service to emit progress events for UI updates.
This commit is contained in:
DEV_DSW
2026-04-16 16:59:49 +08:00
parent b1f589a674
commit 210e8eb363
24 changed files with 788 additions and 237 deletions

View File

@@ -55,6 +55,23 @@ const api: WindowApi = {
// 执行脚本
executeScript: (params: any) => ipcRenderer.invoke(IPC_EVENTS.EXECUTE_SCRIPT, params),
// 任务事件
onTaskProgress: (cb: (payload: any) => void) => {
const subscription = (_event: any, payload: any) => cb(payload);
ipcRenderer.on(IPC_EVENTS.TASK_PROGRESS, subscription);
return () => ipcRenderer.removeListener(IPC_EVENTS.TASK_PROGRESS, subscription);
},
onTaskStarted: (cb: (payload: any) => void) => {
const subscription = (_event: any, payload: any) => cb(payload);
ipcRenderer.on(IPC_EVENTS.TASK_STARTED, subscription);
return () => ipcRenderer.removeListener(IPC_EVENTS.TASK_STARTED, subscription);
},
onTaskCompleted: (cb: (payload: any) => void) => {
const subscription = (_event: any, payload: any) => cb(payload);
ipcRenderer.on(IPC_EVENTS.TASK_COMPLETED, subscription);
return () => ipcRenderer.removeListener(IPC_EVENTS.TASK_COMPLETED, subscription);
},
// 打开渠道
openChannel: (channels: any) => ipcRenderer.invoke(IPC_EVENTS.OPEN_CHANNEL, channels),

View File

@@ -1,5 +1,5 @@
import { ipcMain, app } from 'electron';
import { ipcMain, app, BrowserWindow } from 'electron';
import { IPC_EVENTS } from '@lib/constants';
import { launchLocalChrome } from '@electron/utils/chrome/launchLocalChrome'
import { executeScriptService } from '@electron/service/execute-script-service';
@@ -15,6 +15,7 @@ import fs from 'fs'
import path from 'path'
import { spawn } from 'child_process'
import log from 'electron-log';
import { randomUUID } from 'crypto';
const openedTabIndexByChannelName = new Map<string, number>()
@@ -228,7 +229,7 @@ export function runTaskOperationService() {
// 执行脚本
ipcMain.handle(IPC_EVENTS.EXECUTE_SCRIPT, async (_event, options: any) => {
try {
// 从options.roomList列表中找到对应的名称
const taskId = options.taskId || randomUUID();
const roomType = options.roomList.find((item: any) => item.id === options.roomType);
const pairs: Array<[string, string]> = [
@@ -246,33 +247,82 @@ export function runTaskOperationService() {
if (!fs.existsSync(p)) {
throw new Error(`Script not found for channel ${channel}: ${p}`)
}
return { channel, scriptPath: p }
return { channel, fileName, scriptPath: p }
})
const channelNameMap: Record<string, string> = {
fzName: 'fliggy',
mtName: 'meituan',
dyHotelName: 'douyin',
dyHotSpringName: 'douyin',
}
const defaultTabIndexMap: Record<string, number> = {
fliggy: 0,
meituan: 1,
douyin: 2
}
const results: any[] = []
for (let i = 0; i < scriptPaths.length; i++) {
const item = scriptPaths[i]
const channelNameMap: Record<string, string> = {
fzName: 'fliggy',
mtName: 'meituan',
dyHotelName: 'douyin',
dyHotSpringName: 'douyin',
}
const defaultTabIndexMap: Record<string, number> = {
fliggy: 0,
meituan: 1,
douyin: 2
}
const subTaskId = `${taskId}_${item.channel}`;
const mappedName = channelNameMap[item.channel]
const tabIndex = mappedName ? (openedTabIndexByChannelName.get(mappedName) ?? defaultTabIndexMap[mappedName] ?? i) : i
const win = BrowserWindow.getAllWindows()[0];
win?.webContents.send(IPC_EVENTS.TASK_STARTED, {
taskId,
subTaskId,
scriptId: item.fileName,
name: item.channel,
});
const onProgress = (payload: any) => {
win?.webContents.send(IPC_EVENTS.TASK_PROGRESS, payload);
};
const onStdout = (payload: any) => {
win?.webContents.send(IPC_EVENTS.TASK_PROGRESS, {
...payload,
stdoutTail: payload.text,
});
};
const onStderr = (payload: any) => {
win?.webContents.send(IPC_EVENTS.TASK_PROGRESS, {
...payload,
stderrTail: payload.text,
});
};
executeScriptServiceInstance.on('progress', onProgress);
executeScriptServiceInstance.on('stdout', onStdout);
executeScriptServiceInstance.on('stderr', onStderr);
log.info(`Launching script for channel ${item.channel}: ${item.scriptPath} (tabIndex: ${tabIndex})`)
const result = await executeScriptServiceInstance.executeScript(item.scriptPath, {
roomType: roomType[item.channel],
startTime: options.startTime,
endTime: options.endTime,
operation: options.operation,
tabIndex,
})
const result = await executeScriptServiceInstance.executeScript(
item.scriptPath,
{
roomType: roomType[item.channel],
startTime: options.startTime,
endTime: options.endTime,
operation: options.operation,
tabIndex,
},
taskId,
subTaskId,
);
executeScriptServiceInstance.off('progress', onProgress);
executeScriptServiceInstance.off('stdout', onStdout);
executeScriptServiceInstance.off('stderr', onStderr);
win?.webContents.send(IPC_EVENTS.TASK_COMPLETED, {
taskId,
subTaskId,
success: result.success,
exitCode: result.exitCode,
error: result.error,
});
results.push({
channel: item.channel,
scriptPath: item.scriptPath,

View File

@@ -4,6 +4,10 @@ import tabsPkg from './common/tabs.js';
const { preparePage, safeDisconnectBrowser } = tabsPkg;
function reportProgress(step, percent) {
console.log('__ZN_PROGRESS__' + JSON.stringify({ step, percent }));
}
const parseDateInput = (dateStr) => {
if (!dateStr) return null;
if (typeof dateStr === 'number' && Number.isFinite(dateStr)) return new Date(dateStr);
@@ -158,6 +162,7 @@ const navigateToRoomStatusManagement = async (page) => {
let browser;
try {
reportProgress('连接本地浏览器', 10);
const groupId = '1816249020842116';
const homeUrl = `https://life.douyin.com/p/home?groupid=${groupId}`;
const priceAmountStateUrl = `https://life.douyin.com/p/travel-ari/hotel/price_amount_state?groupid=${groupId}`;
@@ -175,6 +180,7 @@ const navigateToRoomStatusManagement = async (page) => {
} catch (e2) {}
}
reportProgress('定位目标页面', 30);
// Navigation logic (User provided)
try {
// Try to click the store/login button if visible
@@ -199,12 +205,14 @@ const navigateToRoomStatusManagement = async (page) => {
if (!roomType || !startDate) {
log.info('ROOM_TYPE/START_DATE not provided, skip room toggle.');
reportProgress('执行完成', 100);
return;
}
// Wait for table rows to appear
await page.locator('.lifep-table-row').first().waitFor({ state: 'visible', timeout: 15000 });
reportProgress('操作房态数据', 60);
const dateList = buildDateList(startDate, endDate);
for (const dateStr of dateList) {
try {
@@ -215,11 +223,13 @@ const navigateToRoomStatusManagement = async (page) => {
await page.waitForTimeout(500 + Math.random() * 500);
}
reportProgress('保存并校验', 90);
} catch (error) {
log.error(error);
process.exitCode = 1;
} finally {
await safeDisconnectBrowser(browser);
reportProgress('执行完成', 100);
process.exit(process.exitCode || 0);
}
})();

View File

@@ -4,6 +4,10 @@ import tabsPkg from './common/tabs.js';
const { preparePage, safeDisconnectBrowser } = tabsPkg;
function reportProgress(step, percent) {
console.log('__ZN_PROGRESS__' + JSON.stringify({ step, percent }));
}
const parseDateInput = (dateStr) => {
if (!dateStr) return null;
if (typeof dateStr === 'number' && Number.isFinite(dateStr)) return new Date(dateStr);
@@ -158,6 +162,7 @@ const navigateToRoomStatusManagement = async (page) => {
let browser;
try {
reportProgress('连接本地浏览器', 10);
const groupId = '1816249020842116';
const homeUrl = `https://life.douyin.com/p/home?groupid=${groupId}`;
const priceAmountStateUrl = `https://life.douyin.com/p/travel-ari/hotel/price_amount_state?groupid=${groupId}`;
@@ -175,6 +180,7 @@ const navigateToRoomStatusManagement = async (page) => {
} catch (e2) {}
}
reportProgress('定位目标页面', 30);
// Navigation logic (User provided)
try {
// Try to click the store/login button if visible
@@ -199,12 +205,14 @@ const navigateToRoomStatusManagement = async (page) => {
if (!roomType || !startDate) {
log.info('ROOM_TYPE/START_DATE not provided, skip room toggle.');
reportProgress('执行完成', 100);
return;
}
// Wait for table rows to appear
await page.locator('.lifep-table-row').first().waitFor({ state: 'visible', timeout: 15000 });
reportProgress('操作房态数据', 60);
const dateList = buildDateList(startDate, endDate);
for (const dateStr of dateList) {
try {
@@ -215,11 +223,13 @@ const navigateToRoomStatusManagement = async (page) => {
await page.waitForTimeout(500 + Math.random() * 500);
}
reportProgress('保存并校验', 90);
} catch (error) {
log.error(error);
process.exitCode = 1;
} finally {
await safeDisconnectBrowser(browser);
reportProgress('执行完成', 100);
process.exit(process.exitCode || 0);
}
})();

View File

@@ -4,6 +4,10 @@ import tabsPkg from './common/tabs.js';
const { preparePage, safeDisconnectBrowser } = tabsPkg;
function reportProgress(step, percent) {
console.log('__ZN_PROGRESS__' + JSON.stringify({ step, percent }));
}
const parseDateInput = (dateStr) => {
if (!dateStr) return null;
if (typeof dateStr === 'number' && Number.isFinite(dateStr)) return new Date(dateStr);
@@ -137,6 +141,7 @@ const toggleRoomByDateIndex = async (container, { roomType, dateIndex, operation
let browser;
try {
reportProgress('连接本地浏览器', 10);
const targetUrl = 'https://hotel.fliggy.com/ebooking/hotelBaseInfoUv.htm#/ebk/homeV1';
const prepared = await preparePage(chromium, { targetUrl });
browser = prepared.browser;
@@ -144,6 +149,7 @@ const toggleRoomByDateIndex = async (container, { roomType, dateIndex, operation
await page.waitForTimeout(4000 + Math.random() * 300);
reportProgress('定位目标页面', 30);
await ensureRoomPriceCalendar(page);
const roomType = process.env.ROOM_TYPE;
@@ -153,12 +159,14 @@ const toggleRoomByDateIndex = async (container, { roomType, dateIndex, operation
if (!roomType || !startDate) {
log.info('ROOM_TYPE/START_DATE not provided, skip room toggle.');
reportProgress('执行完成', 100);
return;
}
const container = page.locator('#price-reserve-table-container');
await container.waitFor({ state: 'visible' });
reportProgress('操作房态数据', 60);
const dateList = buildDateList(startDate, endDate);
for (const mmdd of dateList) {
const dateIndex = await findHeaderDateIndex(container, mmdd);
@@ -167,12 +175,15 @@ const toggleRoomByDateIndex = async (container, { roomType, dateIndex, operation
}
await toggleRoomByDateIndex(container, { roomType, dateIndex, operation });
await page.waitForTimeout(600 + Math.random() * 600);
}
}
reportProgress('保存并校验', 90);
} catch (error) {
log.error(error);
process.exitCode = 1;
} finally {
await safeDisconnectBrowser(browser);
reportProgress('执行完成', 100);
process.exit(process.exitCode || 0);
}
})();

View File

@@ -4,6 +4,10 @@ import tabsPkg from './common/tabs.js';
const { preparePage, safeDisconnectBrowser } = tabsPkg;
function reportProgress(step, percent) {
console.log('__ZN_PROGRESS__' + JSON.stringify({ step, percent }));
}
const parseDateInput = (dateStr) => {
if (!dateStr) return null;
if (typeof dateStr === 'number' && Number.isFinite(dateStr)) return new Date(dateStr);
@@ -173,6 +177,7 @@ const toggleRoom = async (page, { roomType, mmdd, operation }) => {
let browser;
try {
reportProgress('连接本地浏览器', 10);
const targetUrl = 'https://me.meituan.com/ebooking/merchant/product#/index';
const prepared = await preparePage(chromium, { targetUrl });
browser = prepared.browser;
@@ -180,6 +185,7 @@ const toggleRoom = async (page, { roomType, mmdd, operation }) => {
await page.waitForTimeout(4000 + Math.random() * 300);
reportProgress('定位目标页面', 30);
// Navigation logic from user snippet
try {
const menu = page.locator('#vina-menu-item-100114001 > .lz-submenu-title');
@@ -194,7 +200,7 @@ const toggleRoom = async (page, { roomType, mmdd, operation }) => {
await page.waitForTimeout(1000);
}
}
const calendarLink = page.locator('a').filter({ hasText: '房价房量日历' });
if (await calendarLink.isVisible()) {
await calendarLink.click();
@@ -211,12 +217,14 @@ const toggleRoom = async (page, { roomType, mmdd, operation }) => {
if (!roomType || !startDate) {
log.info('ROOM_TYPE/START_DATE not provided, skip room toggle.');
reportProgress('执行完成', 100);
return;
}
// Wait for table
await page.locator('.vxe-table--body-wrapper').waitFor({ state: 'visible', timeout: 15000 });
reportProgress('操作房态数据', 60);
const dateList = buildDateList(startDate, endDate);
for (const mmdd of dateList) {
try {
@@ -227,11 +235,13 @@ const toggleRoom = async (page, { roomType, mmdd, operation }) => {
await page.waitForTimeout(600 + Math.random() * 600);
}
reportProgress('保存并校验', 90);
} catch (error) {
log.error(error);
process.exitCode = 1;
} finally {
await safeDisconnectBrowser(browser);
reportProgress('执行完成', 100);
process.exit(process.exitCode || 0);
}
})();

View File

@@ -15,6 +15,7 @@ const DEFAULT_CONFIG: IConfig = {
[CONFIG_KEYS.DEFAULT_MODEL]: null,
[CONFIG_KEYS.SELECTED_CHANNELS]: [],
[CONFIG_KEYS.IMAGE_CACHE]: [],
[CONFIG_KEYS.TASK_LIST]: [],
}
export class ConfigService {

View File

@@ -8,6 +8,8 @@ export class executeScriptService extends EventEmitter {
async executeScript(
scriptPath: string,
options: Record<string, any>,
taskId?: string,
subTaskId?: string,
): Promise<{ success: boolean; exitCode: number | null; stdoutTail: string; stderrTail: string; error?: string }> {
const MAX_TAIL = 32 * 1024;
@@ -48,6 +50,20 @@ export class executeScriptService extends EventEmitter {
const text = data.toString();
stdoutTail = appendTail(stdoutTail, text);
log.info(`stdout: ${text}`);
if (text.includes('__ZN_PROGRESS__')) {
try {
const jsonStr = text.split('__ZN_PROGRESS__')[1]?.trim();
if (jsonStr) {
const parsed = JSON.parse(jsonStr);
this.emit('progress', { taskId, subTaskId, ...parsed });
}
} catch {
// ignore invalid JSON
}
}
this.emit('stdout', { taskId, subTaskId, text });
});
}
@@ -56,6 +72,7 @@ export class executeScriptService extends EventEmitter {
const text = data.toString();
stderrTail = appendTail(stderrTail, text);
log.info(`stderr: ${text}`);
this.emit('stderr', { taskId, subTaskId, text });
});
}