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:
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
require("electron");
|
||||
require("./main-BekteP6H.js");
|
||||
require("./main-ByCp1zrw.js");
|
||||
require("electron-squirrel-startup");
|
||||
require("electron-log");
|
||||
require("bytenode");
|
||||
|
||||
@@ -41,6 +41,9 @@ var IPC_EVENTS = /* @__PURE__ */ ((IPC_EVENTS2) => {
|
||||
IPC_EVENTS2["IS_DARK_THEME"] = "is-dark-theme";
|
||||
IPC_EVENTS2["THEME_MODE_UPDATED"] = "theme-mode-updated";
|
||||
IPC_EVENTS2["EXECUTE_SCRIPT"] = "execute-script";
|
||||
IPC_EVENTS2["TASK_PROGRESS"] = "task:progress";
|
||||
IPC_EVENTS2["TASK_STARTED"] = "task:started";
|
||||
IPC_EVENTS2["TASK_COMPLETED"] = "task:completed";
|
||||
IPC_EVENTS2["OPEN_CHANNEL"] = "open-channel";
|
||||
IPC_EVENTS2["SCRIPT_LIST"] = "script:list";
|
||||
IPC_EVENTS2["SCRIPT_GET"] = "script:get";
|
||||
@@ -100,6 +103,22 @@ const api = {
|
||||
},
|
||||
// 执行脚本
|
||||
executeScript: (params) => electron.ipcRenderer.invoke(IPC_EVENTS.EXECUTE_SCRIPT, params),
|
||||
// 任务事件
|
||||
onTaskProgress: (cb) => {
|
||||
const subscription = (_event, payload) => cb(payload);
|
||||
electron.ipcRenderer.on(IPC_EVENTS.TASK_PROGRESS, subscription);
|
||||
return () => electron.ipcRenderer.removeListener(IPC_EVENTS.TASK_PROGRESS, subscription);
|
||||
},
|
||||
onTaskStarted: (cb) => {
|
||||
const subscription = (_event, payload) => cb(payload);
|
||||
electron.ipcRenderer.on(IPC_EVENTS.TASK_STARTED, subscription);
|
||||
return () => electron.ipcRenderer.removeListener(IPC_EVENTS.TASK_STARTED, subscription);
|
||||
},
|
||||
onTaskCompleted: (cb) => {
|
||||
const subscription = (_event, payload) => cb(payload);
|
||||
electron.ipcRenderer.on(IPC_EVENTS.TASK_COMPLETED, subscription);
|
||||
return () => electron.ipcRenderer.removeListener(IPC_EVENTS.TASK_COMPLETED, subscription);
|
||||
},
|
||||
// 打开渠道
|
||||
openChannel: (channels) => electron.ipcRenderer.invoke(IPC_EVENTS.OPEN_CHANNEL, channels),
|
||||
// 脚本管理
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| 模块 | 当前状态 | 关键文件 |
|
||||
|---|---|---|
|
||||
| 脚本执行 | 通过 `utilityProcess.fork` 串行执行,**阻塞式返回结果**,无实时推送 | `electron/service/execute-script-service/index.ts` |
|
||||
| 任务列表 UI | 使用 `@constant/task` **静态假数据** | `src/components/TaskList/List.vue`、`Card.vue` |
|
||||
| 任务列表 UI | 使用 `@constant/task` **静态假数据** | `src/pages/home/components/TaskList.vue`、`TaskCard.vue` |
|
||||
| 脚本触发入口 | `TaskOperationDialog.vue` 中调用 `window.api.executeScript(options)` | `src/pages/home/components/TaskOperationDialog.vue` |
|
||||
| 状态管理 | 已有 `store/script.ts` 管理脚本元数据,**缺少任务(Task)生命周期管理** | `src/store/script.ts` |
|
||||
| IPC 通信 | 只有 Request/Response 模式(invoke/handle),**无主进程主动推送** | `electron/preload/index.ts` |
|
||||
@@ -52,6 +52,7 @@ interface Task {
|
||||
dateRange: [string, string];
|
||||
status: TaskStatus;
|
||||
subTasks: SubTask[];
|
||||
roomList: any[]; // 保留原始房型列表,用于重试时重新传参
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -177,7 +178,7 @@ interface Task {
|
||||
### Phase 4:UI 组件改造
|
||||
**目标**:将假数据替换为真实任务数据,支持 tab 切换与操作。
|
||||
|
||||
9. **`src/components/TaskList/Card.vue`**(修改)
|
||||
9. **`src/pages/home/components/TaskCard.vue`**(修改)
|
||||
- `props` 改为接收 `Task` 或 `SubTask` 对象
|
||||
- 根据 `status` 渲染不同状态标签(`warning` 运行中 / `error` 失败 / `success` 成功)
|
||||
- 按钮逻辑:
|
||||
@@ -186,10 +187,11 @@ interface Task {
|
||||
- `success` → "移除"(emit `remove`)
|
||||
- 显示进度条(`el-progress` 或自定义 div)
|
||||
|
||||
10. **`src/components/TaskList/List.vue`**(修改)
|
||||
10. **`src/pages/home/components/TaskList.vue`**(修改)
|
||||
- 从 `useTaskStore()` 读取任务列表
|
||||
- "待处理" tab 显示 `pendingTasks`,"已处理" tab 显示 `completedTasks`
|
||||
- 动态计算 `total` 数量
|
||||
- 顶部日期时间实时动态化("今天"/"昨天"/具体日期 + `HH:mm:ss`)
|
||||
- 处理 `retry-failed`(调用 `taskStore.retryFailedSubTasks(taskId)`)/ `remove` 事件
|
||||
|
||||
---
|
||||
@@ -259,13 +261,15 @@ List.vue / Card.vue 响应式更新 UI
|
||||
List.vue 调用 taskStore.retryFailedSubTasks(taskId)
|
||||
├─ 将该 Task 下所有 failed 的 SubTask 重置为 pending
|
||||
├─ Task 状态回退为 pending / running
|
||||
└─ 重新调用 window.api.executeScript({ taskId, ... })
|
||||
└─ 重新调用 window.api.executeScript({ taskId, roomType, startTime, endTime, operation, roomList })
|
||||
↓
|
||||
主进程只执行 status === pending 的 SubTask(串行排队)
|
||||
↓
|
||||
执行完成后更新 Task 状态,移回"已处理" Tab
|
||||
```
|
||||
|
||||
> **注意**:重试时需要 `roomList` 参数。`createTask` 会将 `options.roomList` 存入 `Task.roomList` 并随任务列表一起持久化到 `electron-store`,确保刷新后重试仍能拿到完整参数。
|
||||
|
||||
---
|
||||
|
||||
## 五、文件变更清单汇总
|
||||
@@ -282,17 +286,60 @@ List.vue 调用 taskStore.retryFailedSubTasks(taskId)
|
||||
| `global.d.ts` | 更新 `WindowApi` 类型 |
|
||||
| `electron/service/execute-script-service/index.ts` | 解析进度并 emit 事件 |
|
||||
| `electron/process/runTaskOperationService.ts` | 绑定 taskId 并推送 IPC |
|
||||
| `src/components/TaskList/List.vue` | 接入真实数据与 tab 过滤 |
|
||||
| `src/components/TaskList/Card.vue` | 动态状态、进度、操作按钮 |
|
||||
| `src/pages/home/components/TaskList.vue` | 接入真实数据、tab 过滤、日期时间动态化 |
|
||||
| `src/pages/home/components/TaskCard.vue` | 动态状态、进度、操作按钮 |
|
||||
| `src/pages/home/components/TaskOperationDialog.vue` | 执行前创建 Task |
|
||||
| `electron/scripts/*.js` | 插入 `__ZN_PROGRESS__` 输出(可选) |
|
||||
|
||||
---
|
||||
|
||||
## 六、调整说明(2026-04-14)
|
||||
## 六、调整说明(2026-04-16)
|
||||
|
||||
针对实现边界补充以下确认:
|
||||
|
||||
1. **排队机制**:一个 Task 内的多个 SubTask(各渠道脚本)在主进程中**串行排队执行**,不会并发启动多个浏览器实例。
|
||||
2. **重试粒度**:"重试"操作仅针对该 Task 下 `status === 'failed'` 的 SubTask,成功的 SubTask 保持原结果不变。
|
||||
3. **Store 方法**:`src/store/task.ts` 中增加 `retryFailedSubTasks(taskId)` 方法,用于批量重置并重新触发失败子任务。
|
||||
4. **视觉 UI 延用当前**:`TaskList.vue` 与 `TaskCard.vue` 保持现有样式和布局,仅将数据来源从 `@constant/task` 静态假数据替换为 `useTaskStore`,并在现有样式框架内绑定状态、进度与操作按钮。
|
||||
5. **日期时间动态化**:`TaskList.vue` 顶部原有的静态日期("今天")和时间("02:32:05")改为响应式实时显示:
|
||||
- 左侧日期标签根据当前日期自动判断显示 **"今天"**、**"昨天"** 或具体日期(如 `04/16`)。
|
||||
- 右侧时间通过 `setInterval` 每秒更新,格式为 `HH:mm:ss`。
|
||||
- 组件卸载时自动清理定时器。
|
||||
6. **数据持久化使用 `electron-store`**:
|
||||
- 渲染层 Store `useTaskStore` 通过 IPC (`GET_CONFIG` / `SET_CONFIG`) 读写任务列表,避免主进程直接暴露 Store 实例到渲染层。
|
||||
- 主进程在 `config-service` 的 `DEFAULT_CONFIG` 中新增 `CONFIG_KEYS.TASK_LIST`,默认值为空数组 `[]`。
|
||||
- `useTaskStore` 初始化时异步加载已持久化的任务列表;每次 `createTask`、`completeSubTask`、`removeTask` 等变更操作后,通过 `window.api.invoke(IPC_EVENTS.SET_CONFIG, CONFIG_KEYS.TASK_LIST, tasks.value)` 同步到 `electron-store`。
|
||||
- 注意:持久化数据为任务列表(含 SubTask 状态),实时进度更新(`task:progress`)频繁变化时可**仅更新内存状态**,待 `task:completed` 后再统一持久化,以减少写盘次数。
|
||||
|
||||
---
|
||||
|
||||
## 七、Sub-agent 开发分工
|
||||
|
||||
共启动 **4 个 sub-agent** 按流水线并行开发。
|
||||
|
||||
| Sub-agent | 负责阶段 | 关键文件 | 依赖 |
|
||||
|---|---|---|---|
|
||||
| **SA-1 主进程** | Phase 1 + Phase 2 | `src/lib/task-types.ts`、`src/lib/constants.ts`、`electron/preload/index.ts`、`global.d.ts`、`electron/service/execute-script-service/index.ts`、`electron/process/runTaskOperationService.ts` | 无 |
|
||||
| **SA-2 状态管理** | Phase 3 | `src/store/task.ts`(新建)、`src/App.vue`(挂载监听) | 需 SA-1 的类型与 IPC 契约 |
|
||||
| **SA-3 前端 UI** | Phase 4 + Phase 5 | `src/pages/home/components/TaskList.vue`、`TaskCard.vue`、`TaskOperationDialog.vue` | 需 SA-2 的 Store API(可按本计划接口契约先行开发) |
|
||||
| **SA-4 脚本进度** | Phase 6 | `electron/scripts/mt_trace.js`、`fg_trace.js`、`dy_hotel_trace.js`、`dy_hot_spring_trace.js` | 无 |
|
||||
|
||||
### 各 Sub-agent 验收标准
|
||||
|
||||
**SA-1**:
|
||||
- `executeScriptService` 继承 `EventEmitter`,能解析 `__ZN_PROGRESS__` 前缀并触发 `progress`/`stdout`/`stderr` 事件。
|
||||
- `runTaskOperationService` 的 `EXECUTE_SCRIPT` handler 接收 `options.taskId`,为每个脚本生成 `subTaskId`,串行执行并推送 `task:started` / `task:progress` / `task:completed` IPC 事件。
|
||||
|
||||
**SA-2**:
|
||||
- `useTaskStore` 通过 `electron-store` 持久化任务列表(IPC 读写)。
|
||||
- 提供 `createTask`、`updateSubTaskProgress`、`completeSubTask`、`retryFailedSubTasks`、`removeTask`。
|
||||
- `pendingTasks` / `completedTasks` computed 正确过滤。
|
||||
|
||||
**SA-3**:
|
||||
- `TaskList.vue` / `TaskCard.vue` 接入 `useTaskStore`,Tab 数量和任务数量动态计算。
|
||||
- `running` 状态显示进度条和最新日志展开;`failed` / `partial_failed` 显示"重试失败项";`success` 显示"移除"。
|
||||
- `TaskOperationDialog.vue` 确认时先 `taskStore.createTask(options)`,再 `window.api.executeScript(options)`。
|
||||
|
||||
**SA-4**:
|
||||
- 每个脚本在关键步骤输出 `__ZN_PROGRESS__{"step":"...","percent":N}`。
|
||||
- 不影响原有脚本逻辑。
|
||||
|
||||
7
docs/todo-list.md
Normal file
7
docs/todo-list.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 功能清单
|
||||
|
||||
1、任务列表
|
||||
2、走本地模型配置,重构模型对话功能 - 完成
|
||||
3、上传表单信息+读取信息,脚本执行录取表单
|
||||
4、定时任务脚本关联多个脚本执行
|
||||
5、一键打开渠道可以新增渠道 - 完成
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
9
global.d.ts
vendored
9
global.d.ts
vendored
@@ -48,6 +48,11 @@ declare global {
|
||||
return: void
|
||||
}
|
||||
|
||||
// 任务事件
|
||||
[IPC_EVENTS.TASK_PROGRESS]: { params: [payload: any]; return: void; }
|
||||
[IPC_EVENTS.TASK_STARTED]: { params: [payload: any]; return: void; }
|
||||
[IPC_EVENTS.TASK_COMPLETED]: { params: [payload: any]; return: void; }
|
||||
|
||||
// 主题事件
|
||||
[IPC_EVENTS.THEME_MODE_UPDATED]: {
|
||||
params: [isDark: boolean]
|
||||
@@ -157,6 +162,10 @@ declare global {
|
||||
},
|
||||
// 执行脚本
|
||||
executeScript: (options: any) => Promise<{success: boolean, error?: string}>,
|
||||
// 任务事件
|
||||
onTaskProgress: (cb: (payload: any) => void) => () => void;
|
||||
onTaskStarted: (cb: (payload: any) => void) => () => void;
|
||||
onTaskCompleted: (cb: (payload: any) => void) => () => void;
|
||||
// 打开渠道
|
||||
openChannel: (channels: any) => Promise<{success: boolean, error?: string}>,
|
||||
// 脚本管理
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<!--
|
||||
* @Author: kongbeiwu lishaohua-520@qq.com
|
||||
* @Date: 2025-12-21 23:02:06
|
||||
* @LastEditors: kongbeiwu lishaohua-520@qq.com
|
||||
* @LastEditTime: 2025-12-28 11:09:00
|
||||
* @FilePath: /project/zn-ai/src/renderer/components/TaskList/Card.vue
|
||||
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
||||
-->
|
||||
<template>
|
||||
<div v-for="item in task" :key="item.id"
|
||||
class="border border-solid border-[#E5E8EE] dark:border-[#2a2a2d] rounded-[12px] p-[12px] mb-[12px] task dark:bg-[#1f1f22]">
|
||||
<div class="flex items-center pb-[12px] border-b border-dashed border-[#E5E8EE] dark:border-[#2a2a2d]">
|
||||
<!-- <img class="w-[24px] h-[24px] rounded-[8px]] mr-[4px]" src="@assets/images/task/xc.png" /> -->
|
||||
<div
|
||||
class="w-[24px] h-[24px] rounded-[4px] bg-[#EFF6FF] dark:bg-[#1f1f22] text-[#2B7FFF] text-[14px] font-bold border border-solid border-[#BEDBFF] dark:border-[#2a2a2d] flex justify-center items-center">
|
||||
{{ item.name[0] }}</div>
|
||||
<div class="text-[16px] text-[#171717] dark:text-gray-100 font-bold mr-[8px] ml-[4px]">{{ item.name }}</div>
|
||||
<div class="pl-[8px] pr-[8px] text-[12px] rounded-[100px]" :class="item.statusColor">{{
|
||||
item.statusText }}</div>
|
||||
</div>
|
||||
<div class="flex items-center mt-[12px]">
|
||||
<component :is="item.desIcon" :color="item.color" class="w-[15px] mr-[4px]" />
|
||||
<div class="text-[14px]" :class="`text-[${item.color}]`" :style="{ color: item.color }">{{ item.des }}</div>
|
||||
</div>
|
||||
<div class="mt-[24px]">
|
||||
<button class="w-[100%] h-[40px] bg-[#2B7FFF] text-white text-[14px] rounded-[12px]">{{ item.statusColor !==
|
||||
'error' ? '查看' : '处理' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from "vue";
|
||||
import { task } from '@constant/task'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: all .2s linear;
|
||||
}
|
||||
|
||||
.task .success {
|
||||
background-color: #E0FAEC;
|
||||
color: #1FC16B;
|
||||
}
|
||||
|
||||
.task .error {
|
||||
background-color: #FFEBEC;
|
||||
color: #FB3748;
|
||||
}
|
||||
|
||||
.task .warning {
|
||||
background-color: #FFF3EB;
|
||||
color: #FA7319;
|
||||
}
|
||||
|
||||
.task:hover {
|
||||
z-index: 2;
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, .1);
|
||||
transform: translate3d(0, -2px, 0);
|
||||
}</style>
|
||||
@@ -1,77 +0,0 @@
|
||||
<template>
|
||||
<div class="task p-3">
|
||||
<div class="flex border border-[#BEDBFF] dark:border-[#2a2a2d] h-12 p-1 rounded-[10px] bg-[#EFF6FF] dark:bg-[#1f1f22] task-tab">
|
||||
<div v-for="item in tabs" :key="item.value" class="flex-1 flex text-center items-center h-full align-middle text" :class="active === item.value && 'active'" @click="changeTab(item.value)">
|
||||
<div class="flex-1">{{ item.name }}<span v-if="item.total">{{`(${item.total > 98 && item.total + '+' || item.total})`}}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-3 mb-3 text-[14px]">
|
||||
<div class="text-[#171717] dark:text-gray-100">今天</div>
|
||||
<div class="text-[#99A0AE] dark:text-gray-500">02:32:05</div>
|
||||
</div>
|
||||
<div>
|
||||
<Card />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from './Card.vue';
|
||||
import { ref, reactive } from "vue";
|
||||
|
||||
const tabs = reactive([
|
||||
{
|
||||
name: '待处理',
|
||||
value: 1,
|
||||
total: 10,
|
||||
},
|
||||
{
|
||||
name: '已处理',
|
||||
value: 2,
|
||||
total: 99,
|
||||
}
|
||||
])
|
||||
const active = ref(1);
|
||||
const changeTab = (val:number) => {
|
||||
active.value = val;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-tab .text {
|
||||
color: #525866;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.dark) .task-tab .text {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.task-tab .active {
|
||||
position: relative;
|
||||
color: #2B7FFF;
|
||||
background: #FFFFFF;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:global(.dark) .task-tab .active {
|
||||
color: #2B7FFF;
|
||||
background: #1f1f22;
|
||||
}
|
||||
|
||||
.task-tab .active::after {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: '';
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2B7FFF;
|
||||
}
|
||||
|
||||
:global(.dark) .task-tab .active::after {
|
||||
border: 1px solid #2B7FFF;
|
||||
}
|
||||
</style>
|
||||
@@ -52,6 +52,11 @@ export enum IPC_EVENTS {
|
||||
// 执行脚本
|
||||
EXECUTE_SCRIPT = 'execute-script',
|
||||
|
||||
// 任务事件
|
||||
TASK_PROGRESS = 'task:progress',
|
||||
TASK_STARTED = 'task:started',
|
||||
TASK_COMPLETED = 'task:completed',
|
||||
|
||||
// 打开渠道
|
||||
OPEN_CHANNEL = 'open-channel',
|
||||
|
||||
@@ -104,6 +109,7 @@ export enum CONFIG_KEYS {
|
||||
AUTO_DOWNLOAD_UPDATE = 'autoDownloadUpdate',
|
||||
SELECTED_CHANNELS = 'selectedChannels',
|
||||
IMAGE_CACHE = 'imageCache',
|
||||
TASK_LIST = 'taskList',
|
||||
}
|
||||
|
||||
export enum MENU_IDS {
|
||||
|
||||
40
src/lib/task-types.ts
Normal file
40
src/lib/task-types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export type SubTaskStatus = 'pending' | 'running' | 'success' | 'failed';
|
||||
|
||||
export interface SubTask {
|
||||
id: string;
|
||||
taskId: string;
|
||||
scriptId: string;
|
||||
name: string;
|
||||
status: SubTaskStatus;
|
||||
progress: number;
|
||||
message: string;
|
||||
stdoutTail: string;
|
||||
stderrTail: string;
|
||||
error?: string;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export type TaskStatus = 'pending' | 'running' | 'success' | 'partial_failed' | 'failed';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
operation: 'open' | 'close';
|
||||
roomType: string;
|
||||
dateRange: [string, string];
|
||||
status: TaskStatus;
|
||||
subTasks: SubTask[];
|
||||
roomList: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TaskProgressPayload {
|
||||
taskId: string;
|
||||
subTaskId: string;
|
||||
progress?: number;
|
||||
message?: string;
|
||||
stdoutTail?: string;
|
||||
stderrTail?: string;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<template>
|
||||
<div class="task p-3">
|
||||
<div class="flex border border-[#BEDBFF] dark:border-[#2a2a2d] h-[48px] p-[4px] rounded-[10px] bg-[#EFF6FF] dark:bg-[#222225] task-tab">
|
||||
<div v-for="item in tabs" :key="item.value" class="flex-1 flex text-center items-center h-full align-middle text" :class="active === item.value && 'active'" @click="changeTab(item.value)">
|
||||
<div class="flex-1">{{ item.name }}<span v-if="item.total">{{`(${item.total > 98 && item.total + '+' || item.total})`}}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<div>今天</div>
|
||||
<div>02:32:05</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from "vue";
|
||||
|
||||
const tabs = reactive([
|
||||
{
|
||||
name: '待处理',
|
||||
value: 1,
|
||||
total: 10,
|
||||
},
|
||||
{
|
||||
name: '已处理',
|
||||
value: 2,
|
||||
total: 99,
|
||||
}
|
||||
])
|
||||
const active = ref(1);
|
||||
const changeTab = (val:number) => {
|
||||
active.value = val;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-tab .text {
|
||||
color: #525866;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dark .task-tab .text {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.task-tab .active {
|
||||
position: relative;
|
||||
color: #2B7FFF;
|
||||
background: #FFFFFF;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.dark .task-tab .active {
|
||||
background: #1f1f22;
|
||||
border-color: #2a2a2d;
|
||||
}
|
||||
.task-tab .active::after {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: '';
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2B7FFF;
|
||||
}
|
||||
</style>
|
||||
161
src/pages/home/components/TaskCard.vue
Normal file
161
src/pages/home/components/TaskCard.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div
|
||||
class="border border-solid border-[#E5E8EE] dark:border-[#2a2a2d] rounded-[12px] p-[12px] mb-[12px] task dark:bg-[#1f1f22]">
|
||||
<div class="flex items-center pb-[12px] border-b border-dashed border-[#E5E8EE] dark:border-[#2a2a2d]">
|
||||
<div
|
||||
class="w-[24px] h-[24px] rounded-[4px] bg-[#EFF6FF] dark:bg-[#1f1f22] text-[#2B7FFF] text-[14px] font-bold border border-solid border-[#BEDBFF] dark:border-[#2a2a2d] flex justify-center items-center">
|
||||
{{ displayTitle[0] }}</div>
|
||||
<div class="text-[16px] text-[#171717] dark:text-gray-100 font-bold mr-[8px] ml-[4px]">{{ displayTitle }}</div>
|
||||
<div v-if="isSubTask && parentTitle" class="text-[12px] text-[#99A0AE] dark:text-gray-500 mr-[8px]">{{ parentTitle }}</div>
|
||||
<div class="pl-[8px] pr-[8px] text-[12px] rounded-[100px]" :class="statusClass">{{ statusText }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Running SubTask: progress and message -->
|
||||
<div v-if="isSubTask && item.status === 'running'" class="mt-[12px]">
|
||||
<div class="w-full h-[6px] bg-[#E5E8EE] dark:bg-[#2a2a2d] rounded-[3px] overflow-hidden mb-[8px]">
|
||||
<div class="h-full bg-[#2B7FFF] rounded-[3px] transition-all duration-300"
|
||||
:style="{ width: `${(item as SubTask).progress}%` }" />
|
||||
</div>
|
||||
<div class="text-[14px] text-[#525866] dark:text-gray-400 mb-[8px]">{{ (item as SubTask).message }}</div>
|
||||
<div v-if="showStdout"
|
||||
class="text-[12px] text-[#99A0AE] dark:text-gray-500 bg-[#F5F5F5] dark:bg-[#2a2a2d] rounded-[8px] p-[8px] max-h-[120px] overflow-y-auto whitespace-pre-wrap">
|
||||
{{ (item as SubTask).stdoutTail || '暂无输出' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default description -->
|
||||
<div v-else class="flex items-center mt-[12px]">
|
||||
<RiErrorWarningFill :color="desColor" class="w-[15px] mr-[4px]" />
|
||||
<div class="text-[14px]" :class="`text-[${desColor}]`" :style="{ color: desColor }">{{ description }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-[24px]">
|
||||
<button
|
||||
class="w-[100%] h-[40px] bg-[#2B7FFF] text-white text-[14px] rounded-[12px]"
|
||||
@click="handleButtonClick">
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { RiErrorWarningFill } from '@remixicon/vue'
|
||||
import type { Task, SubTask } from '@lib/task-types'
|
||||
|
||||
const props = defineProps<{
|
||||
item: SubTask | Task
|
||||
isSubTask?: boolean
|
||||
parentTitle?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: [taskId: string]
|
||||
remove: [taskId: string]
|
||||
}>()
|
||||
|
||||
const showStdout = ref(false)
|
||||
|
||||
const displayTitle = computed(() => {
|
||||
return props.isSubTask ? (props.item as SubTask).name : (props.item as Task).title
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
const status = props.item.status
|
||||
if (status === 'success') return 'success'
|
||||
if (status === 'failed' || status === 'partial_failed') return 'error'
|
||||
return 'warning'
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
const status = props.item.status
|
||||
if (status === 'success') return '成功'
|
||||
if (status === 'failed' || status === 'partial_failed') return '失败'
|
||||
if (status === 'running') return '执行中'
|
||||
return '等待中'
|
||||
})
|
||||
|
||||
const desColor = computed(() => {
|
||||
const status = props.item.status
|
||||
if (status === 'success') return '#1FC16B'
|
||||
if (status === 'failed' || status === 'partial_failed') return '#FB3748'
|
||||
return '#FA7319'
|
||||
})
|
||||
|
||||
const description = computed(() => {
|
||||
if (props.isSubTask) {
|
||||
const sub = props.item as SubTask
|
||||
if (sub.status === 'failed' && sub.error) return sub.error
|
||||
if (sub.message) return sub.message
|
||||
if (sub.status === 'success') return '任务执行成功'
|
||||
return '任务执行中,请勿关闭浏览器'
|
||||
}
|
||||
const task = props.item as Task
|
||||
const successCount = task.subTasks.filter(s => s.status === 'success').length
|
||||
const failedCount = task.subTasks.filter(s => s.status === 'failed').length
|
||||
const total = task.subTasks.length
|
||||
if (task.status === 'success') return '任务执行成功'
|
||||
if (task.status === 'failed') return `${failedCount}/${total} 个子任务失败`
|
||||
if (task.status === 'partial_failed') return `${successCount} 成功, ${failedCount} 失败`
|
||||
return '任务执行中,请勿关闭浏览器'
|
||||
})
|
||||
|
||||
const buttonText = computed(() => {
|
||||
const status = props.item.status
|
||||
if (status === 'running') return '查看'
|
||||
if (status === 'failed' || status === 'partial_failed') return '重试失败项'
|
||||
if (status === 'success') return '移除'
|
||||
return '查看'
|
||||
})
|
||||
|
||||
const handleButtonClick = () => {
|
||||
const status = props.item.status
|
||||
if (status === 'running' && props.isSubTask) {
|
||||
showStdout.value = !showStdout.value
|
||||
return
|
||||
}
|
||||
if (status === 'running' || status === 'pending') {
|
||||
// 查看:无操作或提示等待
|
||||
return
|
||||
}
|
||||
if (status === 'failed' || status === 'partial_failed') {
|
||||
const taskId = props.isSubTask ? (props.item as SubTask).taskId : (props.item as Task).id
|
||||
emit('retry', taskId)
|
||||
return
|
||||
}
|
||||
if (status === 'success') {
|
||||
const taskId = props.isSubTask ? (props.item as SubTask).taskId : (props.item as Task).id
|
||||
emit('remove', taskId)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: all .2s linear;
|
||||
}
|
||||
|
||||
.task .success {
|
||||
background-color: #E0FAEC;
|
||||
color: #1FC16B;
|
||||
}
|
||||
|
||||
.task .error {
|
||||
background-color: #FFEBEC;
|
||||
color: #FB3748;
|
||||
}
|
||||
|
||||
.task .warning {
|
||||
background-color: #FFF3EB;
|
||||
color: #FA7319;
|
||||
}
|
||||
|
||||
.task:hover {
|
||||
z-index: 2;
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, .1);
|
||||
transform: translate3d(0, -2px, 0);
|
||||
}
|
||||
</style>
|
||||
163
src/pages/home/components/TaskList.vue
Normal file
163
src/pages/home/components/TaskList.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="task p-3">
|
||||
<div class="flex border border-[#BEDBFF] dark:border-[#2a2a2d] h-12 p-1 rounded-[10px] bg-[#EFF6FF] dark:bg-[#1f1f22] task-tab">
|
||||
<div v-for="item in tabs" :key="item.value" class="flex-1 flex text-center items-center h-full align-middle text"
|
||||
:class="active === item.value && 'active'" @click="changeTab(item.value)">
|
||||
<div class="flex-1">{{ item.name }}<span v-if="item.total">{{
|
||||
`(${item.total > 98 && item.total + '+' || item.total})` }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-3 mb-3 text-[14px]">
|
||||
<div class="text-[#171717] dark:text-gray-100">{{ currentDateLabel }}</div>
|
||||
<div class="text-[#99A0AE] dark:text-gray-500">{{ currentTime }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Pending tab: flatten to subtask cards -->
|
||||
<template v-if="active === 1">
|
||||
<template v-for="task in taskStore.pendingTasks" :key="task.id">
|
||||
<Card v-for="subTask in task.subTasks" :key="subTask.id" :item="subTask" :is-sub-task="true"
|
||||
:parent-title="task.title" @retry="handleRetry" @remove="handleRemove" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Completed tab: task cards -->
|
||||
<template v-if="active === 2">
|
||||
<Card v-for="task in taskStore.completedTasks" :key="task.id" :item="task" :is-sub-task="false"
|
||||
@retry="handleRetry" @remove="handleRemove" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from './TaskCard.vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useTaskStore } from '@stores/task'
|
||||
import type { Task } from '@lib/task-types'
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
|
||||
const now = ref(new Date())
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const currentDateLabel = computed(() => {
|
||||
const today = new Date()
|
||||
const y = today.getFullYear()
|
||||
const m = today.getMonth()
|
||||
const d = today.getDate()
|
||||
|
||||
const current = now.value
|
||||
const cy = current.getFullYear()
|
||||
const cm = current.getMonth()
|
||||
const cd = current.getDate()
|
||||
|
||||
if (cy === y && cm === m && cd === d) {
|
||||
return '今天'
|
||||
}
|
||||
|
||||
const yesterday = new Date(y, m, d - 1)
|
||||
if (cy === yesterday.getFullYear() && cm === yesterday.getMonth() && cd === yesterday.getDate()) {
|
||||
return '昨天'
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(cm + 1)}/${pad(cd)}`
|
||||
})
|
||||
|
||||
const currentTime = computed(() => {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const h = pad(now.value.getHours())
|
||||
const m = pad(now.value.getMinutes())
|
||||
const s = pad(now.value.getSeconds())
|
||||
return `${h}:${m}:${s}`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => {
|
||||
now.value = new Date()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
})
|
||||
|
||||
const tabs = reactive([
|
||||
{
|
||||
name: '待处理',
|
||||
value: 1,
|
||||
total: computed(() => taskStore.pendingTasks.length),
|
||||
},
|
||||
{
|
||||
name: '已处理',
|
||||
value: 2,
|
||||
total: computed(() => taskStore.completedTasks.length),
|
||||
}
|
||||
])
|
||||
const active = ref(1)
|
||||
const changeTab = (val: number) => {
|
||||
active.value = val
|
||||
}
|
||||
|
||||
const handleRetry = (taskId: string) => {
|
||||
taskStore.retryFailedSubTasks(taskId)
|
||||
const task = taskStore.pendingTasks.find((t: Task) => t.id === taskId) || taskStore.completedTasks.find((t: Task) => t.id === taskId)
|
||||
if (task) {
|
||||
const options = {
|
||||
taskId: task.id,
|
||||
roomType: task.roomType,
|
||||
startTime: task.dateRange[0],
|
||||
endTime: task.dateRange[1],
|
||||
operation: task.operation,
|
||||
roomList: task.roomList || [],
|
||||
}
|
||||
window.api.executeScript(options)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = (taskId: string) => {
|
||||
taskStore.removeTask(taskId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-tab .text {
|
||||
color: #525866;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.dark) .task-tab .text {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.task-tab .active {
|
||||
position: relative;
|
||||
color: #2B7FFF;
|
||||
background: #FFFFFF;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:global(.dark) .task-tab .active {
|
||||
color: #2B7FFF;
|
||||
background: #1f1f22;
|
||||
}
|
||||
|
||||
.task-tab .active::after {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: '';
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2B7FFF;
|
||||
}
|
||||
|
||||
:global(.dark) .task-tab .active::after {
|
||||
border: 1px solid #2B7FFF;
|
||||
}
|
||||
</style>
|
||||
@@ -41,8 +41,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { hotelStaffTypeMappingListUsingPost } from '@api/index'
|
||||
import { useTaskStore } from '@stores/task'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
|
||||
const isVisible = ref(false)
|
||||
const roomList: any = ref([])
|
||||
const title = ref('渠道房型操作')
|
||||
@@ -119,6 +122,8 @@ const confirm = () => {
|
||||
/**
|
||||
* 坑:传给进程的参数不能是ref包裹的reactive对象
|
||||
*/
|
||||
const task = taskStore.createTask(options)
|
||||
options.taskId = task.id
|
||||
window.api.executeScript(options)
|
||||
|
||||
reset()
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import TaskList from '../../components/TaskList/index.vue'
|
||||
import TaskList from './components/TaskList.vue'
|
||||
import TaskOperationDialog from './components/TaskOperationDialog.vue'
|
||||
import AddChannelDialog from './components/AddChannelDialog.vue'
|
||||
import ChatHistory from './ChatHistory.vue'
|
||||
@@ -27,12 +27,14 @@ import { useChatStore } from '@stores/chat'
|
||||
import { useProviderStore } from '@stores/providers'
|
||||
import { useChannelStore } from '@stores/channel'
|
||||
import { useScriptStore } from '@stores/script'
|
||||
import { useTaskStore } from '@stores/task'
|
||||
import emitter from '@src/utils/emitter'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const providerStore = useProviderStore()
|
||||
const channelStore = useChannelStore()
|
||||
const scriptStore = useScriptStore()
|
||||
const taskStore = useTaskStore()
|
||||
const taskOperationDialog = ref()
|
||||
const addChannelDialog = ref()
|
||||
|
||||
@@ -42,6 +44,13 @@ onMounted(async () => {
|
||||
chatStore.subscribeToGateway()
|
||||
await scriptStore.fetchScripts()
|
||||
await channelStore.loadSelectedChannels()
|
||||
await taskStore.init()
|
||||
|
||||
window.api.onTaskProgress((payload) => taskStore.updateSubTaskProgress(payload))
|
||||
window.api.onTaskStarted((payload) => {
|
||||
taskStore.updateSubTaskProgress({ ...payload, message: '开始执行' })
|
||||
})
|
||||
window.api.onTaskCompleted((payload) => taskStore.completeSubTask(payload))
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
164
src/stores/task.ts
Normal file
164
src/stores/task.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { IPC_EVENTS, CONFIG_KEYS } from '@lib/constants';
|
||||
import type { Task, SubTask, TaskProgressPayload } from '@lib/task-types';
|
||||
|
||||
export const useTaskStore = defineStore('task', () => {
|
||||
const tasks = ref<Task[]>([]);
|
||||
let initialized = false;
|
||||
|
||||
// 初始化时从 electron-store 加载
|
||||
const init = async () => {
|
||||
if (initialized) return;
|
||||
try {
|
||||
const saved = await window.api.invoke(IPC_EVENTS.GET_CONFIG, CONFIG_KEYS.TASK_LIST);
|
||||
if (Array.isArray(saved)) {
|
||||
tasks.value = saved;
|
||||
}
|
||||
initialized = true;
|
||||
} catch (e) {
|
||||
console.error('Failed to load tasks from store', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 持久化 helper(用于减少写盘次数,可在需要时调用)
|
||||
const persist = async () => {
|
||||
try {
|
||||
await window.api.invoke(IPC_EVENTS.SET_CONFIG, CONFIG_KEYS.TASK_LIST, tasks.value);
|
||||
} catch (e) {
|
||||
console.error('Failed to persist tasks', e);
|
||||
}
|
||||
};
|
||||
|
||||
const createTask = (options: any): Task => {
|
||||
const taskId = crypto.randomUUID();
|
||||
const roomTypeObj = options.roomList.find((item: any) => item.id === options.roomType);
|
||||
const scriptMappings = [
|
||||
{ prop: 'fzName', scriptId: 'fg_trace.js', name: '飞猪房态追踪' },
|
||||
{ prop: 'mtName', scriptId: 'mt_trace.js', name: '美团房态追踪' },
|
||||
{ prop: 'dyHotelName', scriptId: 'dy_hotel_trace.js', name: '抖音酒店房态追踪' },
|
||||
{ prop: 'dyHotSpringName', scriptId: 'dy_hot_spring_trace.js', name: '抖音温泉房态追踪' },
|
||||
];
|
||||
const subTasks: SubTask[] = scriptMappings
|
||||
.filter(({ prop }) => roomTypeObj?.[prop])
|
||||
.map(({ scriptId, name, prop }) => ({
|
||||
id: `${taskId}_${prop}`,
|
||||
taskId,
|
||||
scriptId,
|
||||
name,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
message: '等待执行',
|
||||
stdoutTail: '',
|
||||
stderrTail: '',
|
||||
startedAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const task: Task = {
|
||||
id: taskId,
|
||||
title: `${options.operation === 'open' ? '开启' : '关闭'}渠道房型 - ${roomTypeObj?.pmsName || ''}`,
|
||||
operation: options.operation,
|
||||
roomType: options.roomType,
|
||||
dateRange: [options.startTime, options.endTime],
|
||||
status: 'pending',
|
||||
subTasks,
|
||||
roomList: options.roomList || [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
tasks.value = [task, ...tasks.value];
|
||||
persist();
|
||||
return task;
|
||||
};
|
||||
|
||||
const updateSubTaskProgress = (payload: TaskProgressPayload & { taskId: string; subTaskId: string }) => {
|
||||
const task = tasks.value.find((t) => t.id === payload.taskId);
|
||||
if (!task) return;
|
||||
const subTask = task.subTasks.find((s) => s.id === payload.subTaskId);
|
||||
if (!subTask) return;
|
||||
if (subTask.status === 'pending') subTask.status = 'running';
|
||||
if (payload.progress !== undefined) subTask.progress = payload.progress;
|
||||
if (payload.message !== undefined) subTask.message = payload.message;
|
||||
if (payload.stdoutTail !== undefined) subTask.stdoutTail = payload.stdoutTail;
|
||||
if (payload.stderrTail !== undefined) subTask.stderrTail = payload.stderrTail;
|
||||
task.status = deriveTaskStatus(task.subTasks);
|
||||
task.updatedAt = new Date().toISOString();
|
||||
// 进度更新不持久化,只在内存中更新以减少写盘
|
||||
};
|
||||
|
||||
const completeSubTask = (payload: { taskId: string; subTaskId: string; success: boolean; exitCode: number | null; error?: string }) => {
|
||||
const task = tasks.value.find((t) => t.id === payload.taskId);
|
||||
if (!task) return;
|
||||
const subTask = task.subTasks.find((s) => s.id === payload.subTaskId);
|
||||
if (!subTask) return;
|
||||
subTask.status = payload.success ? 'success' : 'failed';
|
||||
subTask.progress = payload.success ? 100 : subTask.progress;
|
||||
if (payload.error) subTask.error = payload.error;
|
||||
subTask.completedAt = new Date().toISOString();
|
||||
task.status = deriveTaskStatus(task.subTasks);
|
||||
task.updatedAt = new Date().toISOString();
|
||||
persist();
|
||||
};
|
||||
|
||||
const retryFailedSubTasks = async (taskId: string) => {
|
||||
const task = tasks.value.find((t) => t.id === taskId);
|
||||
if (!task) return;
|
||||
const failedSubTasks = task.subTasks.filter((s) => s.status === 'failed');
|
||||
if (failedSubTasks.length === 0) return;
|
||||
failedSubTasks.forEach((s) => {
|
||||
s.status = 'pending';
|
||||
s.progress = 0;
|
||||
s.message = '等待执行';
|
||||
s.stdoutTail = '';
|
||||
s.stderrTail = '';
|
||||
s.error = undefined;
|
||||
s.completedAt = undefined;
|
||||
});
|
||||
task.status = 'pending';
|
||||
task.updatedAt = new Date().toISOString();
|
||||
persist();
|
||||
|
||||
// 重新触发主进程执行
|
||||
const options = {
|
||||
taskId: task.id,
|
||||
roomType: task.roomType,
|
||||
startTime: task.dateRange[0],
|
||||
endTime: task.dateRange[1],
|
||||
operation: task.operation,
|
||||
roomList: [], // 主进程会自行从 roomList 中查找,但这里可以传空;若主进程已支持 taskId 则无需重复传 roomList
|
||||
};
|
||||
// 由于当前 roomList 不在 store 中持久化,调用方(UI)应负责在重试时补充 roomList。
|
||||
// 为了兼容性,UI 层重试时直接调用 window.api.executeScript({ taskId, roomType, startTime, endTime, operation, roomList })
|
||||
// 本方法只负责重置状态并持久化,不直接调用 executeScript(避免 roomList 丢失)。
|
||||
// 若 UI 没有传入 roomList,当前主进程逻辑依赖它。建议 UI 层在调用 retry 后重新发起 executeScript。
|
||||
};
|
||||
|
||||
const removeTask = (taskId: string) => {
|
||||
tasks.value = tasks.value.filter((t) => t.id !== taskId);
|
||||
persist();
|
||||
};
|
||||
|
||||
const pendingTasks = computed(() => tasks.value.filter((t) => t.status === 'pending' || t.status === 'running'));
|
||||
const completedTasks = computed(() => tasks.value.filter((t) => t.status === 'success' || t.status === 'failed' || t.status === 'partial_failed'));
|
||||
|
||||
function deriveTaskStatus(subTasks: SubTask[]): Task['status'] {
|
||||
if (subTasks.every((s) => s.status === 'success')) return 'success';
|
||||
if (subTasks.every((s) => s.status === 'failed')) return 'failed';
|
||||
if (subTasks.some((s) => s.status === 'failed') && subTasks.some((s) => s.status === 'success')) return 'partial_failed';
|
||||
if (subTasks.some((s) => s.status === 'running')) return 'running';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
return {
|
||||
tasks,
|
||||
init,
|
||||
createTask,
|
||||
updateSubTaskProgress,
|
||||
completeSubTask,
|
||||
retryFailedSubTasks,
|
||||
removeTask,
|
||||
pendingTasks,
|
||||
completedTasks,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user