From c16fc9368570f97a49439457bc172883aedef9bf Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Sun, 12 Apr 2026 15:46:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E5=BD=95=E5=88=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Script-Development-Plan.md | 475 ++++++++++++++++++ dist-electron/main/main.js | 342 +++++++++++++ dist-electron/preload/preload.js | 21 +- electron/main.ts | 4 + electron/preload/index.ts | 12 + electron/process/runTaskOperationService.ts | 87 ++++ electron/scripts/change-model.mjs | 18 + electron/scripts/pause-resume.mjs | 18 + electron/scripts/pause.mjs | 18 + electron/scripts/scripts.meta.json | 34 ++ electron/scripts/seed/change-model.mjs | 18 + electron/scripts/seed/pause-resume.mjs | 18 + electron/scripts/seed/pause.mjs | 18 + .../service/script-execution-service/index.ts | 37 ++ .../service/script-recorder-service/index.ts | 56 +++ .../service/script-store-service/index.ts | 246 +++++++++ global.d.ts | 46 ++ package-lock.json | 203 ++++++++ package.json | 5 + pnpm-lock.yaml | 181 +++++++ src/constant/menus.ts | 10 +- src/i18n/constants.ts | 1 + src/i18n/locales/en/script.json | 54 ++ src/i18n/locales/ja/script.json | 54 ++ src/i18n/locales/zh/script.json | 54 ++ src/lib/constants.ts | 10 + src/lib/script-api.ts | 18 + src/lib/script-types.ts | 53 ++ src/pages/about/index.vue | 44 -- src/pages/dialog/index.vue | 5 - src/pages/scripts/components/ScriptCard.vue | 185 +++++++ .../scripts/components/ScriptCreateDialog.vue | 223 ++++++++ .../scripts/components/ScriptEditorDialog.vue | 354 +++++++++++++ src/pages/scripts/components/ScriptStats.vue | 28 ++ src/pages/scripts/index.vue | 265 ++++++++++ src/router/index.ts | 6 + src/store/script.ts | 161 ++++++ src/styles/index.css | 5 + 38 files changed, 3336 insertions(+), 51 deletions(-) create mode 100644 Script-Development-Plan.md create mode 100644 electron/scripts/change-model.mjs create mode 100644 electron/scripts/pause-resume.mjs create mode 100644 electron/scripts/pause.mjs create mode 100644 electron/scripts/scripts.meta.json create mode 100644 electron/scripts/seed/change-model.mjs create mode 100644 electron/scripts/seed/pause-resume.mjs create mode 100644 electron/scripts/seed/pause.mjs create mode 100644 electron/service/script-execution-service/index.ts create mode 100644 electron/service/script-recorder-service/index.ts create mode 100644 electron/service/script-store-service/index.ts create mode 100644 src/i18n/locales/en/script.json create mode 100644 src/i18n/locales/ja/script.json create mode 100644 src/i18n/locales/zh/script.json create mode 100644 src/lib/script-api.ts create mode 100644 src/lib/script-types.ts delete mode 100644 src/pages/about/index.vue delete mode 100644 src/pages/dialog/index.vue create mode 100644 src/pages/scripts/components/ScriptCard.vue create mode 100644 src/pages/scripts/components/ScriptCreateDialog.vue create mode 100644 src/pages/scripts/components/ScriptEditorDialog.vue create mode 100644 src/pages/scripts/components/ScriptStats.vue create mode 100644 src/pages/scripts/index.vue create mode 100644 src/store/script.ts diff --git a/Script-Development-Plan.md b/Script-Development-Plan.md new file mode 100644 index 0000000..6fd0677 --- /dev/null +++ b/Script-Development-Plan.md @@ -0,0 +1,475 @@ +# zn-ai 脚本录制与管理功能开发计划 + +## 项目概述 + +基于参考图片中的脚本管理 UI 设计,为 zn-ai 项目规划脚本录制与管理功能的开发路线。该功能允许用户录制、编辑、保存和运行基于 Playwright 的浏览器自动化脚本,以扩展应用对各类 OTA 平台的适配能力。 + +## 核心目标 + +1. **实现脚本管理中心**:创建、编辑、删除、启用/禁用自动化脚本 +2. **支持脚本录制**:通过 Playwright 等工具录制用户浏览器操作并生成脚本代码 +3. **提供脚本编辑器**:支持代码高亮、语法检查的基础编辑体验 +4. **实现脚本执行**:保存的脚本可在本地 Chrome 环境中运行 +5. **集成现有架构**:与 zn-ai 现有状态管理、IPC 通信、任务执行系统深度融合 + +## 技术约束 + +- **前端框架**:Vue 3 Composition API +- **状态管理**:Pinia +- **UI 组件库**:Element Plus + Tailwind CSS +- **自动化引擎**:Playwright (已集成) +- **持久化存储**:`electron/scripts/` 文件目录 (脚本代码) + electron-store / dexie (脚本元数据及渲染进程状态) +- **脚本存储目录**:`electron/scripts/` 作为脚本源文件夹,直接存放 `.mjs` 脚本文件,供管理面板进行删除、编辑、测试 +- **IPC 通信**:现有 `hostApiFetch` / `ipcRenderer.invoke` 机制 +- **脚本执行**:复用 `executeScriptService` 和 `runTaskOperationService` + +## 开发原则 + +1. **分阶段实施**:优先核心管理功能,再逐步增强录制能力 +2. **渐进式增强**:先实现手动创建/编辑脚本,再接入录制能力 +3. **代码复用**:最大化复用现有任务执行、Chrome 连接、标签页管理逻辑 +4. **类型安全**:使用 TypeScript 确保脚本类型和配置的类型安全 +5. **向后兼容**:新功能不影响现有 OTA 脚本和任务执行流程 + +--- + +## 阶段一:基础架构与数据模型 + +### 目标 +建立脚本管理功能的基础架构,包括类型定义、数据持久化、状态管理和底层服务接口。 + +### 任务清单 + +#### 1.1 创建脚本类型定义 +- **文件**:`src/common/types/script.ts` +- **内容**: + - 定义 `AutomationScript` 核心类型(id, name, description, code, enabled, channel, createdAt, updatedAt) + - 定义 `ScriptCreateInput`、`ScriptUpdateInput`、`ScriptExecutionResult` 等 DTO + - 定义脚本状态枚举:`ScriptStatus`(idle, running, success, error) + - 定义录制状态枚举:`RecordingStatus`(idle, recording, paused, stopped) +- **依赖**:无 +- **预估工时**:2 小时 + +#### 1.2 创建脚本存储服务(主进程) +- **文件**:`src/main/service/script-store-service.ts` +- **内容**: + - 以 `electron/scripts/` 为脚本根目录,通过 Node.js `fs` API 直接管理 `.mjs` 脚本文件 + - 提供 `getScripts`、`getScriptById`、`saveScript`、`deleteScript`、`toggleScript`、`runScript` 方法 + - 脚本元数据(name, description, enabled, channel, createdAt, updatedAt)通过 `electron-store` 或同级 `scripts.meta.json` 索引文件维护 + - 初始化时检测 `electron/scripts/` 目录是否存在,不存在则自动创建并写入默认脚本种子数据(如参考图中的 change model / pause 等示例脚本) + - 脚本管理面板的所有增删改查操作均同步更新文件系统及元数据索引 +- **依赖**:Node.js `fs`/`path` 模块、`electron-store`(用于元数据索引)、脚本类型定义 +- **预估工时**:4 小时 + +#### 1.3 创建 IPC 接口层 +- **文件**:`src/main/ipc/script-ipc.ts`(主进程)及 `src/renderer/api/script.ts`(渲染进程) +- **内容**: + - 定义 IPC 通道常量:`IPC_EVENTS.SCRIPT_*` + - 主进程注册脚本相关 IPC 处理器 + - 渲染进程封装 `scriptApi` 调用层(getAll, getById, create, update, delete, run / test, startRecording, stopRecording) +- **依赖**:script-store-service +- **预估工时**:3 小时 + +#### 1.4 创建 Pinia Store +- **文件**:`src/renderer/store/script.ts` +- **内容**: + - 状态:`scripts`、`currentScript`、`recordingStatus`、`executionLogs`、`loading` + - Actions:`fetchScripts`、`saveScript`、`deleteScript`、`toggleScript`、`runScript`、`startRecording`、`stopRecording` + - Getters:按启用状态过滤脚本、按渠道分组 +- **依赖**:`scriptApi`、脚本类型定义 +- **预估工时**:4 小时 + +#### 1.5 创建国际化资源 +- **文件**:`src/renderer/i18n/locales/{zh,en,ja}/script.json` +- **内容**: + - 脚本管理页面文本(脚本管理、新建脚本、编辑、运行、删除等) + - 录制相关文本(开始录制、停止录制、录制中...) + - 状态和提示文本(启用、禁用、运行成功、运行失败) +- **依赖**:现有 i18n 配置 +- **预估工时**:2 小时 + +### 交付成果 +- 完整的脚本类型系统 +- 主进程脚本持久化服务(基于 `electron/scripts/` 文件系统) +- 渲染进程 Pinia store(可工作) +- 国际化资源就绪 + +### 阶段验收标准 +- TypeScript 编译无错误 +- Store 通过 IPC 可正确在 `electron/scripts/` 目录下创建、读取、更新、删除 `.mjs` 脚本文件 +- 元数据索引与文件系统保持同步 +- 国际化键值正确加载 + +--- + +## 阶段二:脚本管理 UI 开发 + +### 目标 +开发脚本管理功能的用户界面,复用 Element Plus 和 Tailwind CSS 保持与现有界面一致。 + +### 任务清单 + +#### 2.1 创建脚本管理主页面 +- **文件**:`src/renderer/pages/scripts/index.vue` +- **内容**: + - 页面标题"脚本管理"和"新建脚本"主按钮 + - 脚本网格/卡片列表布局(参考图片中的卡片样式) + - 空状态展示 + - 加载状态和错误处理 +- **依赖**:Script store、国际化 +- **预估工时**:4 小时 + +#### 2.2 创建脚本卡片组件 +- **文件**:`src/renderer/pages/scripts/components/ScriptCard.vue` +- **内容**: + - 脚本图标/类型标识(如 JavaScript 图标/channel 图标) + - 脚本名称和描述 + - 启用/禁用状态开关(`el-switch`) + - "编辑"和"运行"操作按钮 + - 最后运行状态/时间展示 + - 悬停阴影和过渡动画效果 +- **依赖**:Script store、Element Plus 组件、Tailwind CSS +- **预估工时**:5 小时 + +#### 2.3 创建脚本编辑/录制对话框 +- **文件**:`src/renderer/pages/scripts/components/ScriptEditorDialog.vue` +- **内容**: + - 弹窗表单:脚本名称、描述、目标渠道选择 + - 脚本代码编辑区(集成 Monaco Editor 或 `vue-codemirror`,按需高亮 JS 语法) + - "开始录制" / "停止录制"按钮及录制状态指示 + - "保存"和"取消"按钮 + - 表单验证(名称必填、代码非空) +- **依赖**:Script store、Monaco/Codemirror、Element Plus +- **预估工时**:8 小时 + +#### 2.4 创建脚本执行日志面板 +- **文件**:`src/renderer/pages/scripts/components/ScriptLogPanel.vue` +- **内容**: + - 展示最近一次脚本执行的输出日志 + - 支持成功/失败状态着色 + - 可清空的日志视图 +- **依赖**:Script store、Tailwind CSS +- **预估工时**:3 小时 + +#### 2.5 集成路由和导航 +- **文件**:`src/renderer/router/index.ts`、`SideMenus.vue` +- **内容**: + - 添加 `/scripts` 路由 + - 在侧边栏菜单添加"脚本管理"入口(配合图标) +- **依赖**:主页面组件 +- **预估工时**:2 小时 + +### 交付成果 +- 完整的脚本管理页面和组件 +- 脚本编辑/录制弹窗 +- 路由和导航集成完毕 + +### 阶段验收标准 +- 所有组件可独立渲染且样式与参考图一致 +- 表单验证正常工作 +- 响应式布局适配不同窗口尺寸 + +--- + +## 阶段三:脚本录制与执行引擎 + +### 目标 +实现脚本录制能力和脚本执行流程,与现有 Playwright 自动化体系打通。 + +### 任务清单 + +#### 3.1 实现录制服务(主进程) +- **文件**:`src/main/service/script-recorder-service.ts` +- **内容**: + - 封装 Playwright `codegen` 能力,或基于 CDP 监听用户操作生成代码 + - 录制前确保本地 Chrome 已启动 (`launchLocalChrome`) + - 将生成的 Playwright 脚本代码格式化并返回给渲染进程 + - 支持指定起始 URL(渠道登录页) +- **依赖**:Playwright、`launchLocalChrome`、现有 tabs.js 逻辑 +- **预估工时**:8 小时 + +#### 3.2 实现脚本执行服务(主进程) +- **文件**:`src/main/service/script-execution-service.ts` +- **内容**: + - 复用 `executeScriptService` 直接运行 `electron/scripts/` 目录下的 `.mjs` 脚本文件 + - 执行时通过 `__filename` 或注入的环境变量传递脚本路径,无需创建额外临时文件 + - 注入预置上下文(如 Chrome CDP 端口、渠道配置、`common/tabs.js` 等公共模块路径) + - 收集并返回 stdout/stderr 和执行结果,更新脚本最后运行状态到元数据索引 + - 面板"测试"按钮直接调用该服务执行目标脚本,并实时回传日志 +- **依赖**:`executeScriptService`、`runTaskOperationService` +- **预估工时**:6 小时 + +#### 3.3 集成录制状态到 UI +- **内容**: + - 编辑对话框中连接"开始录制"按钮到录制 IPC + - 录制过程中显示加载状态/录制指示器 + - 录制完成后自动将生成的代码填充到编辑器 + - 错误处理(Chrome 未启动、录制超时等) +- **依赖**:Script store、录制服务 +- **预估工时**:4 小时 + +#### 3.4 集成脚本测试/运行到 UI +- **内容**: + - 脚本卡片"测试"按钮直接调用 `scriptExecutionService` 运行 `electron/scripts/` 目录下对应脚本 + - 执行过程中按钮显示 loading 状态 + - 执行完成后在日志面板展示结果 + - 失败时显示错误信息和重试入口 +- **依赖**:Script store、脚本执行服务 +- **预估工时**:4 小时 + +#### 3.5 添加脚本模板和默认脚本 +- **内容**: + - 提供常用模板(点击元素、输入文本、等待页面加载) + - 预置参考图中的示例脚本(change model、pause、pause resume 等)作为 `.mjs` 文件放置到 `electron/scripts/seed/` 目录 + - 应用首次启动或检测到 `electron/scripts/` 为空时,将 seed 目录下的示例脚本复制/同步到主目录,并注册到元数据索引 + - 用户通过面板删除默认脚本时,直接删除对应的 `.mjs` 文件及索引条目 +- **依赖**:script-store-service +- **预估工时**:3 小时 + +### 交付成果 +- 用户可通过 UI 录制浏览器操作并生成脚本,保存为 `electron/scripts/` 下的 `.mjs` 文件 +- 用户可在面板中直接测试/运行 `electron/scripts/` 目录中的任意脚本并查看执行结果 +- 内置常用脚本模板及默认示例脚本 + +### 阶段验收标准 +- 录制功能至少能生成可运行的 Playwright 代码并正确落盘 +- 面板中的"测试"或"运行"按钮能直接执行 `electron/scripts/` 目录下的真实脚本文件 +- 脚本执行输出完整呈现在日志面板 +- 默认脚本从 `electron/scripts/seed/` 同步后可正常加载和运行 + +--- + +## 阶段四:测试、优化与完善 + +### 目标 +确保功能稳定可靠,优化性能和用户体验,补充高级能力。 + +### 任务清单 + +#### 4.1 编写单元测试 +- **文件**:`tests/unit/script-store.test.ts`、`tests/unit/script-recorder.test.ts` +- **内容**: + - Store 逻辑测试 + - 录制服务核心逻辑测试 + - 工具函数和类型守卫测试 +- **依赖**:Vitest、Vue Test Utils +- **预估工时**:5 小时 + +#### 4.2 添加集成测试 +- **文件**:`tests/e2e/scripts.spec.ts` +- **内容**: + - 脚本创建 -> 运行 -> 删除的完整流程 + - 录制流程测试(需 mock Chrome 环境) + - 错误场景测试 +- **依赖**:Playwright E2E 测试 +- **预估工时**:5 小时 + +#### 4.3 安全与沙箱优化 +- **内容**: + - 用户脚本执行前进行基础安全检查(禁止 `require` 危险模块、文件系统操作限制) + - 在独立子进程中运行用户脚本,隔离主进程环境 + - 录制和执行超时控制 +- **依赖**:Node.js 子进程、vm/sandbox 机制 +- **预估工时**:4 小时 + +#### 4.4 性能和体验优化 +- **内容**: + - Monaco Editor 懒加载,减少首屏体积 + - 脚本列表虚拟滚动(脚本数量大时) + - 执行日志历史分页/自动清理策略 +- **预估工时**:3 小时 + +#### 4.5 高级功能增强(可选) +- **内容**: + - 脚本导入/导出(JSON / JS 文件) + - 参数化脚本(支持变量注入) + - 脚本执行调度(与 Cron 功能联动,定时执行脚本) + - 脚本共享(团队脚本库) +- **预估工时**:10 小时(可选) + +#### 4.6 文档编写 +- **内容**: + - 用户操作指南(如何录制和运行脚本) + - 脚本开发 API 文档(预注入的上下文变量和工具函数) + - 维护文档(新增渠道适配脚本的最佳实践) +- **预估工时**:3 小时 + +### 交付成果 +- 完整的测试覆盖 +- 安全隔离的用户脚本执行环境 +- 性能优化后的流畅体验 +- 可选的高级功能 + +### 阶段验收标准 +- 核心流程测试覆盖率达到 80% 以上 +- 用户脚本在主进程隔离环境中运行 +- 性能和稳定性满足日常办公场景 + +--- + +## 时间总览 + +| 阶段 | 任务数 | 预估工时 | 累计工时 | +|------|--------|----------|----------| +| 阶段一 | 5 | 15 小时 | 15 小时 | +| 阶段二 | 5 | 22 小时 | 37 小时 | +| 阶段三 | 5 | 25 小时 | 62 小时 | +| 阶段四 | 6 | 30 小时 | 92 小时 | +| **总计** | **21** | **92 小时** | **92 小时** | + +*注:阶段四的高级功能增强为可选,如不包括则总工时为 82 小时* + +--- + +## 风险与缓解措施 + +### 技术风险 + +#### 风险 1:Playwright codegen 录制质量不稳定 +- **描述**:Playwright codegen 生成的代码可能包含不稳定的选择器,或无法覆盖 iframe、Shadow DOM 等复杂场景 +- **影响**:录制脚本可用性低,用户需要大量手动修改 +- **概率**:中 +- **缓解措施**: + 1. 在代码生成后做简单后处理(优先使用 role/text 选择器) + 2. 提供录制后编辑能力,引导用户修正选择器 + 3. 针对不同渠道提供录制建议和最佳实践文档 + +#### 风险 2:用户脚本执行安全风险 +- **描述**:用户编辑或外部导入的 `electron/scripts/` 下的脚本可能被篡改,执行恶意代码 +- **影响**:主进程或本地系统受到威胁 +- **概率**:低 +- **缓解措施**: + 1. 始终在独立子进程中执行 `electron/scripts/` 目录下的脚本(复用 `executeScriptService`) + 2. 禁用 `require` 和 `fs` 等危险 Node.js API + 3. 添加执行超时和异常捕获 + 4. 未来可引入脚本签名/沙箱策略 + +#### 风险 3:与现有任务执行系统冲突 +- **描述**:新脚本执行和现有 `runTaskOperationService` 可能并发操作同一浏览器实例导致冲突 +- **影响**:任务失败或浏览器崩溃 +- **概率**:中 +- **缓解措施**: + 1. 复用现有任务队列和锁机制(如有),或引入脚本执行队列 + 2. 执行前检测 Chrome 是否正被其他任务占用 + 3. 提供明确的任务/脚本执行状态提示 + +### 资源风险 + +#### 风险 1:开发时间超出预期 +- **描述**:录制引擎和编辑器集成复杂度可能高于预期 +- **影响**:项目延期 +- **概率**:中 +- **缓解措施**: + 1. 优先完成阶段一至三的核心功能 + 2. 阶段四作为后续迭代 + 3. Monaco Editor 可选降级为轻量级 CodeMirror + +#### 风险 2:跨平台兼容性 +- **描述**:Playwright codegen 和 Chrome 启动在不同系统上表现可能不一致 +- **影响**:Windows / macOS 用户体验差异 +- **概率**:低 +- **缓解措施**: + 1. 复用现有 `launchLocalChrome` 的跨平台逻辑 + 2. 录制服务使用纯 Playwright API,避免操作系统特有命令 + 3. 在目标平台(macOS 和 Windows)分别验证 + +--- + +## 成功标准 + +### 功能成功标准 +1. 用户可以创建、编辑、删除自动化脚本 +2. 用户可以通过录制生成可运行的 Playwright 脚本 +3. 脚本可一键运行并在日志面板查看结果 +4. 启用/禁用开关可控制脚本是否在任务中心可用 +5. 用户界面直观,与参考设计图一致 + +### 技术成功标准 +1. 代码与现有任务执行系统良好集成,无冲突 +2. 用户脚本在主进程隔离环境中安全运行 +3. 测试覆盖核心流程 +4. 可维护性和可扩展性好,便于新增渠道脚本 + +### 业务成功标准 +1. 降低新渠道适配的脚本开发门槛 +2. 提升用户自定义自动化流程的灵活性 +3. 为后续脚本市场/共享功能奠定基础 + +--- + +## 下一步行动 + +1. **需求确认**:与产品确认参考图中脚本类型、默认脚本列表和录制流程细节 +2. **技术预研**:验证 Playwright codegen 在 zn-ai 现有 Chrome 启动流程中的集成方式 +3. **编辑器选型**:确认脚本编辑器使用 Monaco Editor 还是 CodeMirror(体积和加载性能) +4. **开始实施**:按阶段一任务开始开发类型定义和 IPC 接口层 + +--- + +## 附录 + +### A. 文件结构规划 + +``` +electron/ +└── scripts/ + ├── scripts.meta.json # 脚本元数据索引(名称、启用状态、渠道等) + ├── change-model.mjs # 默认示例脚本(由 seed/ 自动同步) + ├── pause.mjs + ├── pause-resume.mjs + └── seed/ # 预置种子脚本目录(应用自带,只读模板源) + ├── change-model.mjs + ├── pause.mjs + └── pause-resume.mjs + +src/ +├── common/ +│ └── types/ +│ └── script.ts # 脚本相关类型定义 +├── main/ +│ ├── ipc/ +│ │ └── script-ipc.ts # 主进程 IPC 处理器 +│ └── service/ +│ ├── script-store-service.ts # 脚本持久化服务(读写 electron/scripts/) +│ ├── script-recorder-service.ts # 脚本录制服务 +│ └── script-execution-service.ts # 脚本执行服务(直接运行 electron/scripts/ 下脚本) +├── renderer/ +│ ├── api/ +│ │ └── script.ts # 渲染进程 API 封装 +│ ├── store/ +│ │ └── script.ts # Pinia store +│ ├── pages/ +│ │ └── scripts/ +│ │ ├── components/ +│ │ │ ├── ScriptCard.vue +│ │ │ ├── ScriptEditorDialog.vue +│ │ │ └── ScriptLogPanel.vue +│ │ └── index.vue # 脚本管理主页面 +│ └── i18n/ +│ └── locales/ +│ ├── zh/ +│ │ └── script.json +│ ├── en/ +│ │ └── script.json +│ └── ja/ +│ └── script.json +``` + +### B. 依赖库评估 + +1. **代码编辑器**:`@codemirror/lang-javascript` + `vue-codemirror`(轻量、加载快)或 `monaco-editor-vue3`(功能强但体积大) +2. **录制引擎**:`playwright` 内置 `codegen` CLI(已集成在项目依赖中) +3. **脚本格式化**:`prettier`(可选,用于美化生成的代码) +4. **状态管理**:Pinia(项目已使用) + +### C. 与现有系统集成点 + +1. **Chrome 启动**:复用 `launchLocalChrome.ts` +2. **脚本执行**:复用 `executeScriptService` +3. **标签页管理**:用户脚本中可引入 `common/tabs.js` +4. **渠道配置**:读取 `src/renderer/constant/channel.ts` 中的渠道信息 +5. **任务调度**:未来可与 Cron 功能联动,实现定时执行脚本 + +--- + +*本计划将根据实际情况进行迭代更新。建议每阶段结束后进行回顾和调整。* diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js index 5639b8d..99d15a0 100644 --- a/dist-electron/main/main.js +++ b/dist-electron/main/main.js @@ -13,6 +13,7 @@ const net = require("net"); const http = require("http"); const child_process = require("child_process"); const events = require("events"); +const playwright = require("playwright"); require("bytenode"); const electronUpdater = require("electron-updater"); function _interopNamespaceDefault(e) { @@ -79,6 +80,14 @@ var IPC_EVENTS = /* @__PURE__ */ ((IPC_EVENTS2) => { IPC_EVENTS2["THEME_MODE_UPDATED"] = "theme-mode-updated"; IPC_EVENTS2["EXECUTE_SCRIPT"] = "execute-script"; IPC_EVENTS2["OPEN_CHANNEL"] = "open-channel"; + IPC_EVENTS2["SCRIPT_LIST"] = "script:list"; + IPC_EVENTS2["SCRIPT_GET"] = "script:get"; + IPC_EVENTS2["SCRIPT_SAVE"] = "script:save"; + IPC_EVENTS2["SCRIPT_DELETE"] = "script:delete"; + IPC_EVENTS2["SCRIPT_TOGGLE"] = "script:toggle"; + IPC_EVENTS2["SCRIPT_RUN"] = "script:run"; + IPC_EVENTS2["SCRIPT_RECORD_START"] = "script:record-start"; + IPC_EVENTS2["SCRIPT_RECORD_STOP"] = "script:record-stop"; IPC_EVENTS2["UPDATE_CHECK"] = "update:check"; IPC_EVENTS2["UPDATE_DOWNLOAD"] = "update:download"; IPC_EVENTS2["UPDATE_INSTALL"] = "update:install"; @@ -1478,12 +1487,344 @@ class executeScriptService extends events.EventEmitter { }); } } +const META_FILENAME = "scripts.meta.json"; +const SEED_DIR = "seed"; +function getScriptsDir$1() { + return electron.app.isPackaged ? path.join(__dirname, "scripts") : path.join(process.cwd(), "electron/scripts"); +} +function ensureScriptsDir() { + const dir = getScriptsDir$1(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} +function getMetaPath() { + return path.join(getScriptsDir$1(), META_FILENAME); +} +function readMeta() { + const metaPath = getMetaPath(); + if (!fs.existsSync(metaPath)) { + return { scripts: [] }; + } + try { + const raw = fs.readFileSync(metaPath, "utf-8"); + const parsed = JSON.parse(raw); + if (parsed && Array.isArray(parsed.scripts)) { + return parsed; + } + } catch (err) { + log.warn("[script-store-service] Failed to read meta:", err); + } + return { scripts: [] }; +} +function writeMeta(meta) { + ensureScriptsDir(); + const metaPath = getMetaPath(); + fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf-8"); +} +function sanitizeFilename(name) { + return name.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-+|-+$/g, "") || "script"; +} +function generateUniqueFilename(name, existingNames) { + const base = sanitizeFilename(name); + let filename = `${base}.mjs`; + let counter = 1; + while (existingNames.has(filename)) { + filename = `${base}-${counter}.mjs`; + counter++; + } + return filename; +} +function seedScripts() { + const scriptsDir = getScriptsDir$1(); + const metaPath = getMetaPath(); + if (fs.existsSync(metaPath)) { + return; + } + const seedDir = path.join(scriptsDir, SEED_DIR); + if (!fs.existsSync(seedDir)) { + log.info("[script-store-service] Seed directory does not exist, skipping seed."); + return; + } + const meta = { scripts: [] }; + const seedFiles = fs.readdirSync(seedDir).filter((f) => f.endsWith(".mjs")); + for (const file of seedFiles) { + const seedPath = path.join(seedDir, file); + const destPath = path.join(scriptsDir, file); + try { + fs.copyFileSync(seedPath, destPath); + const name = file.replace(/\.mjs$/, ""); + const now = (/* @__PURE__ */ new Date()).toISOString(); + meta.scripts.push({ + id: `seed-${name}`, + name, + description: "", + filename: file, + enabled: true, + channel: "", + createdAt: now, + updatedAt: now + }); + } catch (err) { + log.warn("[script-store-service] Failed to copy seed file", file, err); + } + } + writeMeta(meta); + log.info("[script-store-service] Seeded scripts:", meta.scripts.length); +} +function initScriptStoreService() { + ensureScriptsDir(); + seedScripts(); +} +function listScripts() { + const meta = readMeta(); + return meta.scripts.map((item) => enrichWithCode(item)).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); +} +function getScript(id) { + const meta = readMeta(); + const item = meta.scripts.find((s) => s.id === id); + if (!item) return null; + return enrichWithCode(item); +} +function getScriptPathById(id) { + const meta = readMeta(); + const item = meta.scripts.find((s) => s.id === id); + if (!item) return null; + return path.join(getScriptsDir$1(), item.filename); +} +function saveScript(input) { + const meta = readMeta(); + const scriptsDir = getScriptsDir$1(); + const existingNames = new Set(meta.scripts.map((s) => s.filename)); + const now = (/* @__PURE__ */ new Date()).toISOString(); + if (input.id) { + const index = meta.scripts.findIndex((s) => s.id === input.id); + if (index >= 0) { + const existing = meta.scripts[index]; + const filePath2 = path.join(scriptsDir, existing.filename); + fs.writeFileSync(filePath2, input.code, "utf-8"); + meta.scripts[index] = { + ...existing, + name: input.name, + description: input.description, + channel: input.channel, + enabled: input.enabled, + updatedAt: now + }; + writeMeta(meta); + return enrichWithCode(meta.scripts[index]); + } + } + const filename = generateUniqueFilename(input.name, existingNames); + const filePath = path.join(scriptsDir, filename); + fs.writeFileSync(filePath, input.code, "utf-8"); + const id = `script-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const item = { + id, + name: input.name, + description: input.description, + filename, + enabled: input.enabled, + channel: input.channel, + createdAt: now, + updatedAt: now + }; + meta.scripts.push(item); + writeMeta(meta); + return enrichWithCode(item); +} +function deleteScript(id) { + const meta = readMeta(); + const index = meta.scripts.findIndex((s) => s.id === id); + if (index === -1) return false; + const item = meta.scripts[index]; + const filePath = path.join(getScriptsDir$1(), item.filename); + if (fs.existsSync(filePath)) { + try { + fs.unlinkSync(filePath); + } catch (err) { + log.warn("[script-store-service] Failed to delete script file:", err); + } + } + meta.scripts.splice(index, 1); + writeMeta(meta); + return true; +} +function toggleScript(id, enabled) { + const meta = readMeta(); + const index = meta.scripts.findIndex((s) => s.id === id); + if (index === -1) return false; + meta.scripts[index].enabled = enabled; + meta.scripts[index].updatedAt = (/* @__PURE__ */ new Date()).toISOString(); + writeMeta(meta); + return true; +} +function updateLastRun(id, lastRun) { + const meta = readMeta(); + const index = meta.scripts.findIndex((s) => s.id === id); + if (index === -1) return false; + meta.scripts[index].lastRun = lastRun; + meta.scripts[index].updatedAt = (/* @__PURE__ */ new Date()).toISOString(); + writeMeta(meta); + return true; +} +function enrichWithCode(item) { + const scriptsDir = getScriptsDir$1(); + const filePath = path.join(scriptsDir, item.filename); + let code = ""; + try { + if (fs.existsSync(filePath)) { + code = fs.readFileSync(filePath, "utf-8"); + } + } catch (err) { + log.warn("[script-store-service] Failed to read script file:", err); + } + return { + ...item, + code + }; +} +const executor = new executeScriptService(); +async function runScriptById(id, channel) { + const scriptPath = getScriptPathById(id); + if (!scriptPath) { + return { + success: false, + exitCode: null, + stdoutTail: "", + stderrTail: "", + error: "Script not found" + }; + } + const result = await executor.executeScript(scriptPath, { + SCRIPT_ID: id, + CHANNEL: channel || "" + }); + updateLastRun(id, { + time: (/* @__PURE__ */ new Date()).toISOString(), + success: result.success, + error: result.error + }); + return result; +} +let recorderBrowser = null; +let recorderContext = null; +async function startRecording(url) { + try { + await launchLocalChrome(); + if (recorderBrowser) { + await stopRecording(); + } + recorderBrowser = await playwright.chromium.connectOverCDP("http://127.0.0.1:9222"); + recorderContext = recorderBrowser.contexts()[0] || await recorderBrowser.newContext(); + const page = await recorderContext.newPage(); + const targetUrl = url || "about:blank"; + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + await page.pause(); + return { + success: true, + code: "" + }; + } catch (error) { + log.error("[script-recorder-service] Failed to start recording:", error); + return { + success: false, + error: error?.message || "Failed to start recording" + }; + } +} +async function stopRecording() { + try { + if (recorderContext) { + await recorderContext.close().catch(() => { + }); + recorderContext = null; + } + if (recorderBrowser) { + await recorderBrowser.close().catch(() => { + }); + recorderBrowser = null; + } + return { success: true, code: "" }; + } catch (error) { + log.error("[script-recorder-service] Failed to stop recording:", error); + return { + success: false, + error: error?.message || "Failed to stop recording" + }; + } +} const openedTabIndexByChannelName = /* @__PURE__ */ new Map(); function getScriptsDir() { return electron.app.isPackaged ? path.join(__dirname, "scripts") : path.join(process.cwd(), "electron/scripts"); } function runTaskOperationService() { const executeScriptServiceInstance = new executeScriptService(); + electron.ipcMain.handle(IPC_EVENTS.SCRIPT_LIST, async () => { + try { + return listScripts(); + } catch (error) { + log.error("[SCRIPT_LIST] error:", error); + throw error; + } + }); + electron.ipcMain.handle(IPC_EVENTS.SCRIPT_GET, async (_event, id) => { + try { + return getScript(id); + } catch (error) { + log.error("[SCRIPT_GET] error:", error); + throw error; + } + }); + electron.ipcMain.handle(IPC_EVENTS.SCRIPT_SAVE, async (_event, input) => { + try { + return saveScript(input); + } catch (error) { + log.error("[SCRIPT_SAVE] error:", error); + throw error; + } + }); + electron.ipcMain.handle(IPC_EVENTS.SCRIPT_DELETE, async (_event, id) => { + try { + return deleteScript(id); + } catch (error) { + log.error("[SCRIPT_DELETE] error:", error); + throw error; + } + }); + electron.ipcMain.handle(IPC_EVENTS.SCRIPT_TOGGLE, async (_event, id, enabled) => { + try { + return toggleScript(id, enabled); + } catch (error) { + log.error("[SCRIPT_TOGGLE] error:", error); + throw error; + } + }); + electron.ipcMain.handle(IPC_EVENTS.SCRIPT_RUN, async (_event, id) => { + try { + const script = getScript(id); + return await runScriptById(id, script?.channel); + } catch (error) { + log.error("[SCRIPT_RUN] error:", error); + return { success: false, exitCode: null, stdoutTail: "", stderrTail: "", error: error?.message || "Run failed" }; + } + }); + electron.ipcMain.handle(IPC_EVENTS.SCRIPT_RECORD_START, async (_event, url) => { + try { + return await startRecording(url); + } catch (error) { + log.error("[SCRIPT_RECORD_START] error:", error); + return { success: false, error: error?.message || "Recording start failed" }; + } + }); + electron.ipcMain.handle(IPC_EVENTS.SCRIPT_RECORD_STOP, async () => { + try { + return await stopRecording(); + } catch (error) { + log.error("[SCRIPT_RECORD_STOP] error:", error); + return { success: false, error: error?.message || "Recording stop failed" }; + } + }); electron.ipcMain.handle(IPC_EVENTS.OPEN_CHANNEL, async (_event, channels) => { try { await launchLocalChrome(); @@ -1629,6 +1970,7 @@ if (started) { } electron.app.whenReady().then(() => { setupMainWindow(); + initScriptStoreService(); runTaskOperationService(); }); electron.app.on("window-all-closed", () => { diff --git a/dist-electron/preload/preload.js b/dist-electron/preload/preload.js index 0164736..a41b009 100644 --- a/dist-electron/preload/preload.js +++ b/dist-electron/preload/preload.js @@ -46,6 +46,14 @@ var IPC_EVENTS = /* @__PURE__ */ ((IPC_EVENTS2) => { IPC_EVENTS2["THEME_MODE_UPDATED"] = "theme-mode-updated"; IPC_EVENTS2["EXECUTE_SCRIPT"] = "execute-script"; IPC_EVENTS2["OPEN_CHANNEL"] = "open-channel"; + IPC_EVENTS2["SCRIPT_LIST"] = "script:list"; + IPC_EVENTS2["SCRIPT_GET"] = "script:get"; + IPC_EVENTS2["SCRIPT_SAVE"] = "script:save"; + IPC_EVENTS2["SCRIPT_DELETE"] = "script:delete"; + IPC_EVENTS2["SCRIPT_TOGGLE"] = "script:toggle"; + IPC_EVENTS2["SCRIPT_RUN"] = "script:run"; + IPC_EVENTS2["SCRIPT_RECORD_START"] = "script:record-start"; + IPC_EVENTS2["SCRIPT_RECORD_STOP"] = "script:record-stop"; IPC_EVENTS2["UPDATE_CHECK"] = "update:check"; IPC_EVENTS2["UPDATE_DOWNLOAD"] = "update:download"; IPC_EVENTS2["UPDATE_INSTALL"] = "update:install"; @@ -94,6 +102,17 @@ const api = { // 执行脚本 executeScript: (params) => electron.ipcRenderer.invoke(IPC_EVENTS.EXECUTE_SCRIPT, params), // 打开渠道 - openChannel: (channels) => electron.ipcRenderer.invoke(IPC_EVENTS.OPEN_CHANNEL, channels) + openChannel: (channels) => electron.ipcRenderer.invoke(IPC_EVENTS.OPEN_CHANNEL, channels), + // 脚本管理 + scriptApi: { + list: () => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_LIST), + get: (id) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_GET, id), + save: (input) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_SAVE, input), + delete: (id) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_DELETE, id), + toggle: (id, enabled) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_TOGGLE, id, enabled), + run: (id) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_RUN, id), + startRecording: (url) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_RECORD_START, url), + stopRecording: () => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_RECORD_STOP) + } }; electron.contextBridge.exposeInMainWorld("api", api); diff --git a/electron/main.ts b/electron/main.ts index 2bd0ced..d314335 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,6 +4,7 @@ import { setupMainWindow } from './wins'; import started from 'electron-squirrel-startup' import configManager from '@electron/service/config-service' import { runTaskOperationService } from '@electron/process/runTaskOperationService' +import { initScriptStoreService } from '@electron/service/script-store-service' import log from 'electron-log'; import 'bytenode'; // Ensure bytenode is bundled/externalized correctly import { appUpdater } from '@electron/service/updater'; @@ -30,6 +31,9 @@ if (started) { app.whenReady().then(() => { setupMainWindow(); + // 初始化脚本存储服务 + initScriptStoreService() + // 开启任务操作子进程 runTaskOperationService() diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 9e9dbf5..46f5f61 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -55,6 +55,18 @@ const api: WindowApi = { // 打开渠道 openChannel: (channels: any) => ipcRenderer.invoke(IPC_EVENTS.OPEN_CHANNEL, channels), + + // 脚本管理 + scriptApi: { + list: () => ipcRenderer.invoke(IPC_EVENTS.SCRIPT_LIST), + get: (id: string) => ipcRenderer.invoke(IPC_EVENTS.SCRIPT_GET, id), + save: (input: any) => ipcRenderer.invoke(IPC_EVENTS.SCRIPT_SAVE, input), + delete: (id: string) => ipcRenderer.invoke(IPC_EVENTS.SCRIPT_DELETE, id), + toggle: (id: string, enabled: boolean) => ipcRenderer.invoke(IPC_EVENTS.SCRIPT_TOGGLE, id, enabled), + run: (id: string) => ipcRenderer.invoke(IPC_EVENTS.SCRIPT_RUN, id), + startRecording: (url?: string) => ipcRenderer.invoke(IPC_EVENTS.SCRIPT_RECORD_START, url), + stopRecording: () => ipcRenderer.invoke(IPC_EVENTS.SCRIPT_RECORD_STOP), + }, } contextBridge.exposeInMainWorld('api', api) \ No newline at end of file diff --git a/electron/process/runTaskOperationService.ts b/electron/process/runTaskOperationService.ts index 090c8ea..de493ef 100644 --- a/electron/process/runTaskOperationService.ts +++ b/electron/process/runTaskOperationService.ts @@ -3,6 +3,18 @@ import { ipcMain, app } from 'electron'; import { IPC_EVENTS } from '@lib/constants'; import { launchLocalChrome } from '@electron/utils/chrome/launchLocalChrome' import { executeScriptService } from '@electron/service/execute-script-service'; +import { + listScripts, + getScript, + saveScript, + deleteScript, + toggleScript, +} from '@electron/service/script-store-service'; +import { runScriptById } from '@electron/service/script-execution-service'; +import { + startRecording, + stopRecording, +} from '@electron/service/script-recorder-service'; import fs from 'fs' import path from 'path' import log from 'electron-log'; @@ -17,6 +29,81 @@ function getScriptsDir() { export function runTaskOperationService() { const executeScriptServiceInstance = new executeScriptService(); + + // 脚本管理 IPC + ipcMain.handle(IPC_EVENTS.SCRIPT_LIST, async () => { + try { + return listScripts(); + } catch (error: any) { + log.error('[SCRIPT_LIST] error:', error); + throw error; + } + }); + + ipcMain.handle(IPC_EVENTS.SCRIPT_GET, async (_event, id: string) => { + try { + return getScript(id); + } catch (error: any) { + log.error('[SCRIPT_GET] error:', error); + throw error; + } + }); + + ipcMain.handle(IPC_EVENTS.SCRIPT_SAVE, async (_event, input: any) => { + try { + return saveScript(input); + } catch (error: any) { + log.error('[SCRIPT_SAVE] error:', error); + throw error; + } + }); + + ipcMain.handle(IPC_EVENTS.SCRIPT_DELETE, async (_event, id: string) => { + try { + return deleteScript(id); + } catch (error: any) { + log.error('[SCRIPT_DELETE] error:', error); + throw error; + } + }); + + ipcMain.handle(IPC_EVENTS.SCRIPT_TOGGLE, async (_event, id: string, enabled: boolean) => { + try { + return toggleScript(id, enabled); + } catch (error: any) { + log.error('[SCRIPT_TOGGLE] error:', error); + throw error; + } + }); + + ipcMain.handle(IPC_EVENTS.SCRIPT_RUN, async (_event, id: string) => { + try { + const script = getScript(id); + return await runScriptById(id, script?.channel); + } catch (error: any) { + log.error('[SCRIPT_RUN] error:', error); + return { success: false, exitCode: null, stdoutTail: '', stderrTail: '', error: error?.message || 'Run failed' }; + } + }); + + ipcMain.handle(IPC_EVENTS.SCRIPT_RECORD_START, async (_event, url?: string) => { + try { + return await startRecording(url); + } catch (error: any) { + log.error('[SCRIPT_RECORD_START] error:', error); + return { success: false, error: error?.message || 'Recording start failed' }; + } + }); + + ipcMain.handle(IPC_EVENTS.SCRIPT_RECORD_STOP, async () => { + try { + return await stopRecording(); + } catch (error: any) { + log.error('[SCRIPT_RECORD_STOP] error:', error); + return { success: false, error: error?.message || 'Recording stop failed' }; + } + }); + // 打开渠道 ipcMain.handle(IPC_EVENTS.OPEN_CHANNEL, async (_event, channels: any) => { try { diff --git a/electron/scripts/change-model.mjs b/electron/scripts/change-model.mjs new file mode 100644 index 0000000..be570e2 --- /dev/null +++ b/electron/scripts/change-model.mjs @@ -0,0 +1,18 @@ +import { chromium } from 'playwright'; +import { preparePage, safeDisconnectBrowser } from './common/tabs.js'; + +(async () => { + const browser = await chromium.connectOverCDP('http://127.0.0.1:9222'); + const { page } = await preparePage(browser, { + targetUrl: 'about:blank', + }); + + // Example: navigate and click an element + // await page.goto('https://example.com'); + // await page.click('text=Change Model'); + + console.log('Change model script executed'); + + await safeDisconnectBrowser(browser); + process.exit(0); +})(); diff --git a/electron/scripts/pause-resume.mjs b/electron/scripts/pause-resume.mjs new file mode 100644 index 0000000..1fb319c --- /dev/null +++ b/electron/scripts/pause-resume.mjs @@ -0,0 +1,18 @@ +import { chromium } from 'playwright'; +import { preparePage, safeDisconnectBrowser } from './common/tabs.js'; + +(async () => { + const browser = await chromium.connectOverCDP('http://127.0.0.1:9222'); + const { page } = await preparePage(browser, { + targetUrl: 'about:blank', + }); + + // Example: fill a form and submit + // await page.fill('input[name="username"]', 'test'); + // await page.click('button[type="submit"]'); + + console.log('Pause resume script executed'); + + await safeDisconnectBrowser(browser); + process.exit(0); +})(); diff --git a/electron/scripts/pause.mjs b/electron/scripts/pause.mjs new file mode 100644 index 0000000..4b446e7 --- /dev/null +++ b/electron/scripts/pause.mjs @@ -0,0 +1,18 @@ +import { chromium } from 'playwright'; +import { preparePage, safeDisconnectBrowser } from './common/tabs.js'; + +(async () => { + const browser = await chromium.connectOverCDP('http://127.0.0.1:9222'); + const { page } = await preparePage(browser, { + targetUrl: 'about:blank', + }); + + // Example: wait for a specific element or timeout + // await page.waitForSelector('[data-testid="status-paused"]'); + // await page.waitForTimeout(2000); + + console.log('Pause script executed'); + + await safeDisconnectBrowser(browser); + process.exit(0); +})(); diff --git a/electron/scripts/scripts.meta.json b/electron/scripts/scripts.meta.json new file mode 100644 index 0000000..8056624 --- /dev/null +++ b/electron/scripts/scripts.meta.json @@ -0,0 +1,34 @@ +{ + "scripts": [ + { + "id": "seed-change-model", + "name": "change-model", + "description": "", + "filename": "change-model.mjs", + "enabled": true, + "channel": "common", + "createdAt": "2026-04-12T05:27:08.543Z", + "updatedAt": "2026-04-12T05:27:08.543Z" + }, + { + "id": "seed-pause-resume", + "name": "pause-resume", + "description": "", + "filename": "pause-resume.mjs", + "enabled": true, + "channel": "common", + "createdAt": "2026-04-12T05:27:08.544Z", + "updatedAt": "2026-04-12T05:27:08.544Z" + }, + { + "id": "seed-pause", + "name": "pause", + "description": "", + "filename": "pause.mjs", + "enabled": true, + "channel": "common", + "createdAt": "2026-04-12T05:27:08.544Z", + "updatedAt": "2026-04-12T05:27:08.544Z" + } + ] +} \ No newline at end of file diff --git a/electron/scripts/seed/change-model.mjs b/electron/scripts/seed/change-model.mjs new file mode 100644 index 0000000..be570e2 --- /dev/null +++ b/electron/scripts/seed/change-model.mjs @@ -0,0 +1,18 @@ +import { chromium } from 'playwright'; +import { preparePage, safeDisconnectBrowser } from './common/tabs.js'; + +(async () => { + const browser = await chromium.connectOverCDP('http://127.0.0.1:9222'); + const { page } = await preparePage(browser, { + targetUrl: 'about:blank', + }); + + // Example: navigate and click an element + // await page.goto('https://example.com'); + // await page.click('text=Change Model'); + + console.log('Change model script executed'); + + await safeDisconnectBrowser(browser); + process.exit(0); +})(); diff --git a/electron/scripts/seed/pause-resume.mjs b/electron/scripts/seed/pause-resume.mjs new file mode 100644 index 0000000..1fb319c --- /dev/null +++ b/electron/scripts/seed/pause-resume.mjs @@ -0,0 +1,18 @@ +import { chromium } from 'playwright'; +import { preparePage, safeDisconnectBrowser } from './common/tabs.js'; + +(async () => { + const browser = await chromium.connectOverCDP('http://127.0.0.1:9222'); + const { page } = await preparePage(browser, { + targetUrl: 'about:blank', + }); + + // Example: fill a form and submit + // await page.fill('input[name="username"]', 'test'); + // await page.click('button[type="submit"]'); + + console.log('Pause resume script executed'); + + await safeDisconnectBrowser(browser); + process.exit(0); +})(); diff --git a/electron/scripts/seed/pause.mjs b/electron/scripts/seed/pause.mjs new file mode 100644 index 0000000..4b446e7 --- /dev/null +++ b/electron/scripts/seed/pause.mjs @@ -0,0 +1,18 @@ +import { chromium } from 'playwright'; +import { preparePage, safeDisconnectBrowser } from './common/tabs.js'; + +(async () => { + const browser = await chromium.connectOverCDP('http://127.0.0.1:9222'); + const { page } = await preparePage(browser, { + targetUrl: 'about:blank', + }); + + // Example: wait for a specific element or timeout + // await page.waitForSelector('[data-testid="status-paused"]'); + // await page.waitForTimeout(2000); + + console.log('Pause script executed'); + + await safeDisconnectBrowser(browser); + process.exit(0); +})(); diff --git a/electron/service/script-execution-service/index.ts b/electron/service/script-execution-service/index.ts new file mode 100644 index 0000000..6aad480 --- /dev/null +++ b/electron/service/script-execution-service/index.ts @@ -0,0 +1,37 @@ +import { executeScriptService } from '@electron/service/execute-script-service'; +import { + getScriptPathById, + updateLastRun, +} from '@electron/service/script-store-service'; +import type { ScriptExecutionResult } from '@lib/script-types'; + +const executor = new executeScriptService(); + +export async function runScriptById( + id: string, + channel?: string, +): Promise { + const scriptPath = getScriptPathById(id); + if (!scriptPath) { + return { + success: false, + exitCode: null, + stdoutTail: '', + stderrTail: '', + error: 'Script not found', + }; + } + + const result = await executor.executeScript(scriptPath, { + SCRIPT_ID: id, + CHANNEL: channel || '', + }); + + updateLastRun(id, { + time: new Date().toISOString(), + success: result.success, + error: result.error, + }); + + return result; +} diff --git a/electron/service/script-recorder-service/index.ts b/electron/service/script-recorder-service/index.ts new file mode 100644 index 0000000..ecb32d2 --- /dev/null +++ b/electron/service/script-recorder-service/index.ts @@ -0,0 +1,56 @@ +import { chromium } from 'playwright'; +import log from 'electron-log'; +import { launchLocalChrome } from '@electron/utils/chrome/launchLocalChrome'; + +let recorderBrowser: any = null; +let recorderContext: any = null; + +export async function startRecording(url?: string): Promise<{ success: boolean; code?: string; error?: string }> { + try { + await launchLocalChrome(); + + if (recorderBrowser) { + await stopRecording(); + } + + recorderBrowser = await chromium.connectOverCDP('http://127.0.0.1:9222'); + recorderContext = recorderBrowser.contexts()[0] || (await recorderBrowser.newContext()); + const page = await recorderContext.newPage(); + const targetUrl = url || 'about:blank'; + await page.goto(targetUrl, { waitUntil: 'domcontentloaded' }); + + // 唤起 Playwright Inspector,让用户手动录制并生成代码 + await page.pause(); + + return { + success: true, + code: '', + }; + } catch (error: any) { + log.error('[script-recorder-service] Failed to start recording:', error); + return { + success: false, + error: error?.message || 'Failed to start recording', + }; + } +} + +export async function stopRecording(): Promise<{ success: boolean; code?: string; error?: string }> { + try { + if (recorderContext) { + await recorderContext.close().catch(() => {}); + recorderContext = null; + } + if (recorderBrowser) { + await recorderBrowser.close().catch(() => {}); + recorderBrowser = null; + } + return { success: true, code: '' }; + } catch (error: any) { + log.error('[script-recorder-service] Failed to stop recording:', error); + return { + success: false, + error: error?.message || 'Failed to stop recording', + }; + } +} diff --git a/electron/service/script-store-service/index.ts b/electron/service/script-store-service/index.ts new file mode 100644 index 0000000..721d40a --- /dev/null +++ b/electron/service/script-store-service/index.ts @@ -0,0 +1,246 @@ +import { app } from 'electron'; +import fs from 'fs'; +import path from 'path'; +import log from 'electron-log'; +import type { + AutomationScript, + ScriptMetaItem, + ScriptsMeta, + ScriptSaveInput, +} from '@lib/script-types'; + +const META_FILENAME = 'scripts.meta.json'; +const SEED_DIR = 'seed'; + +function getScriptsDir(): string { + return app.isPackaged + ? path.join(__dirname, 'scripts') + : path.join(process.cwd(), 'electron/scripts'); +} + +function ensureScriptsDir(): void { + const dir = getScriptsDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function getMetaPath(): string { + return path.join(getScriptsDir(), META_FILENAME); +} + +function readMeta(): ScriptsMeta { + const metaPath = getMetaPath(); + if (!fs.existsSync(metaPath)) { + return { scripts: [] }; + } + try { + const raw = fs.readFileSync(metaPath, 'utf-8'); + const parsed = JSON.parse(raw); + if (parsed && Array.isArray(parsed.scripts)) { + return parsed as ScriptsMeta; + } + } catch (err) { + log.warn('[script-store-service] Failed to read meta:', err); + } + return { scripts: [] }; +} + +function writeMeta(meta: ScriptsMeta): void { + ensureScriptsDir(); + const metaPath = getMetaPath(); + fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf-8'); +} + +function sanitizeFilename(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') + .replace(/^-+|-+$/g, '') + || 'script'; +} + +function generateUniqueFilename(name: string, existingNames: Set): string { + const base = sanitizeFilename(name); + let filename = `${base}.mjs`; + let counter = 1; + while (existingNames.has(filename)) { + filename = `${base}-${counter}.mjs`; + counter++; + } + return filename; +} + +function seedScripts(): void { + const scriptsDir = getScriptsDir(); + const metaPath = getMetaPath(); + + if (fs.existsSync(metaPath)) { + return; + } + + const seedDir = path.join(scriptsDir, SEED_DIR); + if (!fs.existsSync(seedDir)) { + log.info('[script-store-service] Seed directory does not exist, skipping seed.'); + return; + } + + const meta: ScriptsMeta = { scripts: [] }; + const seedFiles = fs.readdirSync(seedDir).filter((f) => f.endsWith('.mjs')); + + for (const file of seedFiles) { + const seedPath = path.join(seedDir, file); + const destPath = path.join(scriptsDir, file); + try { + fs.copyFileSync(seedPath, destPath); + const name = file.replace(/\.mjs$/, ''); + const now = new Date().toISOString(); + meta.scripts.push({ + id: `seed-${name}`, + name, + description: '', + filename: file, + enabled: true, + channel: '', + createdAt: now, + updatedAt: now, + }); + } catch (err) { + log.warn('[script-store-service] Failed to copy seed file', file, err); + } + } + + writeMeta(meta); + log.info('[script-store-service] Seeded scripts:', meta.scripts.length); +} + +export function initScriptStoreService(): void { + ensureScriptsDir(); + seedScripts(); +} + +export function listScripts(): AutomationScript[] { + const meta = readMeta(); + return meta.scripts + .map((item) => enrichWithCode(item)) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); +} + +export function getScript(id: string): AutomationScript | null { + const meta = readMeta(); + const item = meta.scripts.find((s) => s.id === id); + if (!item) return null; + return enrichWithCode(item); +} + +export function getScriptPathById(id: string): string | null { + const meta = readMeta(); + const item = meta.scripts.find((s) => s.id === id); + if (!item) return null; + return path.join(getScriptsDir(), item.filename); +} + +export function saveScript(input: ScriptSaveInput): AutomationScript { + const meta = readMeta(); + const scriptsDir = getScriptsDir(); + const existingNames = new Set(meta.scripts.map((s) => s.filename)); + const now = new Date().toISOString(); + + if (input.id) { + const index = meta.scripts.findIndex((s) => s.id === input.id); + if (index >= 0) { + const existing = meta.scripts[index]; + const filePath = path.join(scriptsDir, existing.filename); + fs.writeFileSync(filePath, input.code, 'utf-8'); + meta.scripts[index] = { + ...existing, + name: input.name, + description: input.description, + channel: input.channel, + enabled: input.enabled, + updatedAt: now, + }; + writeMeta(meta); + return enrichWithCode(meta.scripts[index]); + } + } + + const filename = generateUniqueFilename(input.name, existingNames); + const filePath = path.join(scriptsDir, filename); + fs.writeFileSync(filePath, input.code, 'utf-8'); + + const id = `script-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const item: ScriptMetaItem = { + id, + name: input.name, + description: input.description, + filename, + enabled: input.enabled, + channel: input.channel, + createdAt: now, + updatedAt: now, + }; + + meta.scripts.push(item); + writeMeta(meta); + return enrichWithCode(item); +} + +export function deleteScript(id: string): boolean { + const meta = readMeta(); + const index = meta.scripts.findIndex((s) => s.id === id); + if (index === -1) return false; + + const item = meta.scripts[index]; + const filePath = path.join(getScriptsDir(), item.filename); + if (fs.existsSync(filePath)) { + try { + fs.unlinkSync(filePath); + } catch (err) { + log.warn('[script-store-service] Failed to delete script file:', err); + } + } + + meta.scripts.splice(index, 1); + writeMeta(meta); + return true; +} + +export function toggleScript(id: string, enabled: boolean): boolean { + const meta = readMeta(); + const index = meta.scripts.findIndex((s) => s.id === id); + if (index === -1) return false; + + meta.scripts[index].enabled = enabled; + meta.scripts[index].updatedAt = new Date().toISOString(); + writeMeta(meta); + return true; +} + +export function updateLastRun(id: string, lastRun: NonNullable): boolean { + const meta = readMeta(); + const index = meta.scripts.findIndex((s) => s.id === id); + if (index === -1) return false; + + meta.scripts[index].lastRun = lastRun; + meta.scripts[index].updatedAt = new Date().toISOString(); + writeMeta(meta); + return true; +} + +function enrichWithCode(item: ScriptMetaItem): AutomationScript { + const scriptsDir = getScriptsDir(); + const filePath = path.join(scriptsDir, item.filename); + let code = ''; + try { + if (fs.existsSync(filePath)) { + code = fs.readFileSync(filePath, 'utf-8'); + } + } catch (err) { + log.warn('[script-store-service] Failed to read script file:', err); + } + return { + ...item, + code, + } as AutomationScript; +} diff --git a/global.d.ts b/global.d.ts index 01d308b..6ee31dd 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,4 +1,5 @@ import { IPC_EVENTS } from '@lib/constants' +import type { AutomationScript, ScriptSaveInput, ScriptExecutionResult } from '@lib/script-types' declare global { // 定义每个通道的参数和返回值类型 @@ -53,6 +54,40 @@ declare global { params: [] return: Promise } + + // 脚本管理 + [IPC_EVENTS.SCRIPT_LIST]: { + params: [] + return: Promise + } + [IPC_EVENTS.SCRIPT_GET]: { + params: [id: string] + return: Promise + } + [IPC_EVENTS.SCRIPT_SAVE]: { + params: [input: ScriptSaveInput] + return: Promise + } + [IPC_EVENTS.SCRIPT_DELETE]: { + params: [id: string] + return: Promise + } + [IPC_EVENTS.SCRIPT_TOGGLE]: { + params: [id: string, enabled: boolean] + return: Promise + } + [IPC_EVENTS.SCRIPT_RUN]: { + params: [id: string] + return: Promise + } + [IPC_EVENTS.SCRIPT_RECORD_START]: { + params: [url?: string] + return: Promise<{ success: boolean; code?: string; error?: string }> + } + [IPC_EVENTS.SCRIPT_RECORD_STOP]: { + params: [] + return: Promise<{ success: boolean; code?: string; error?: string }> + } } type TabId = string @@ -97,6 +132,17 @@ declare global { executeScript: (options: any) => Promise<{success: boolean, error?: string}>, // 打开渠道 openChannel: (channels: any) => Promise<{success: boolean, error?: string}>, + // 脚本管理 + scriptApi: { + list: () => Promise, + get: (id: string) => Promise, + save: (input: ScriptSaveInput) => Promise, + delete: (id: string) => Promise, + toggle: (id: string, enabled: boolean) => Promise, + run: (id: string) => Promise, + startRecording: (url?: string) => Promise<{ success: boolean; code?: string; error?: string }>, + stopRecording: () => Promise<{ success: boolean; code?: string; error?: string }>, + }, } interface Window { diff --git a/package-lock.json b/package-lock.json index 71533e1..1a6d53f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,9 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.41.0", "@iconify-json/material-symbols": "^1.2.50", "@iconify/vue": "^5.0.0", "@remixicon/vue": "^4.7.0", @@ -20,6 +23,7 @@ "browser-use-sdk": "^2.0.12", "bytenode": "^1.5.7", "chromium-bidi": "^15.0.0", + "codemirror": "^6.0.2", "crypto": "^1.0.1", "crypto-js": "^4.2.0", "dexie": "^4.2.1", @@ -42,6 +46,7 @@ "ts-node": "^10.9.2", "uuid": "^13.0.0", "vue": "^3.5.22", + "vue-codemirror": "^6.1.1", "vue-i18n": "^11.1.9", "vue-markdown-render": "^2.3.0", "vue-router": "^4.5.1", @@ -216,6 +221,114 @@ "sisteransi": "^1.0.5" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmmirror.com/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmmirror.com/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.41.0", + "resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.41.0.tgz", + "integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmmirror.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1748,6 +1861,41 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -1803,6 +1951,12 @@ "node": ">=10" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4264,6 +4418,21 @@ "dev": true, "license": "MIT" }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -4482,6 +4651,12 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmmirror.com/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -9923,6 +10098,12 @@ "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", "license": "MIT" }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz", @@ -10796,6 +10977,22 @@ } } }, + "node_modules/vue-codemirror": { + "version": "6.1.1", + "resolved": "https://registry.npmmirror.com/vue-codemirror/-/vue-codemirror-6.1.1.tgz", + "integrity": "sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg==", + "license": "MIT", + "dependencies": { + "@codemirror/commands": "6.x", + "@codemirror/language": "6.x", + "@codemirror/state": "6.x", + "@codemirror/view": "6.x" + }, + "peerDependencies": { + "codemirror": "6.x", + "vue": "3.x" + } + }, "node_modules/vue-component-type-helpers": { "version": "3.2.6", "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", @@ -10876,6 +11073,12 @@ "vue": "^3.5.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/wcwidth/-/wcwidth-1.0.1.tgz", diff --git a/package.json b/package.json index 06ac291..90bb65c 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,9 @@ "vite-plugin-electron-renderer": "^0.14.6" }, "dependencies": { + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.41.0", "@iconify-json/material-symbols": "^1.2.50", "@iconify/vue": "^5.0.0", "@remixicon/vue": "^4.7.0", @@ -75,6 +78,7 @@ "browser-use-sdk": "^2.0.12", "bytenode": "^1.5.7", "chromium-bidi": "^15.0.0", + "codemirror": "^6.0.2", "crypto": "^1.0.1", "crypto-js": "^4.2.0", "dexie": "^4.2.1", @@ -97,6 +101,7 @@ "ts-node": "^10.9.2", "uuid": "^13.0.0", "vue": "^3.5.22", + "vue-codemirror": "^6.1.1", "vue-i18n": "^11.1.9", "vue-markdown-render": "^2.3.0", "vue-router": "^4.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a16486f..aa12f15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@codemirror/lang-javascript': + specifier: ^6.2.5 + version: 6.2.5 + '@codemirror/theme-one-dark': + specifier: ^6.1.3 + version: 6.1.3 + '@codemirror/view': + specifier: ^6.41.0 + version: 6.41.0 '@iconify-json/material-symbols': specifier: ^1.2.50 version: 1.2.65 @@ -38,6 +47,9 @@ importers: chromium-bidi: specifier: ^15.0.0 version: 15.0.0(devtools-protocol@0.0.1608973) + codemirror: + specifier: ^6.0.2 + version: 6.0.2 crypto: specifier: ^1.0.1 version: 1.0.1 @@ -104,6 +116,9 @@ importers: vue: specifier: ^3.5.22 version: 3.5.32(typescript@5.9.3) + vue-codemirror: + specifier: ^6.1.1 + version: 6.1.1(codemirror@6.0.2)(vue@3.5.32(typescript@5.9.3)) vue-i18n: specifier: ^11.1.9 version: 11.3.1(vue@3.5.32(typescript@5.9.3)) @@ -229,6 +244,33 @@ packages: '@clack/prompts@0.11.0': resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@codemirror/autocomplete@6.20.1': + resolution: {integrity: sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==} + + '@codemirror/commands@6.10.3': + resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/language@6.12.3': + resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==} + + '@codemirror/lint@6.9.5': + resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==} + + '@codemirror/search@6.6.0': + resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==} + + '@codemirror/state@6.6.0': + resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} + + '@codemirror/theme-one-dark@6.1.3': + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + + '@codemirror/view@6.41.0': + resolution: {integrity: sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -659,6 +701,18 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/lr@1.4.8': + resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==} + '@malept/cross-spawn-promise@2.0.0': resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} engines: {node: '>= 12.13.0'} @@ -667,6 +721,9 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1390,6 +1447,9 @@ packages: code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1463,6 +1523,9 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -3004,6 +3067,9 @@ packages: stubborn-utils@1.0.2: resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} @@ -3252,6 +3318,12 @@ packages: yaml: optional: true + vue-codemirror@6.1.1: + resolution: {integrity: sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg==} + peerDependencies: + codemirror: 6.x + vue: 3.x + vue-component-type-helpers@3.2.6: resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==} @@ -3290,6 +3362,9 @@ packages: typescript: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -3438,6 +3513,69 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 + '@codemirror/autocomplete@6.20.1': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.0 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.3': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.0 + '@lezer/common': 1.5.2 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.5 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.0 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/language@6.12.3': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.5': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.0 + crelt: 1.0.6 + + '@codemirror/search@6.6.0': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.0 + crelt: 1.0.6 + + '@codemirror/state@6.6.0': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/theme-one-dark@6.1.3': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.0 + '@lezer/highlight': 1.2.3 + + '@codemirror/view@6.41.0': + dependencies: + '@codemirror/state': 6.6.0 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -3825,6 +3963,22 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lezer/common@1.5.2': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/lr@1.4.8': + dependencies: + '@lezer/common': 1.5.2 + '@malept/cross-spawn-promise@2.0.0': dependencies: cross-spawn: 7.0.6 @@ -3838,6 +3992,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@marijn/find-cluster-break@1.0.2': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4555,6 +4711,16 @@ snapshots: code-block-writer@13.0.3: {} + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/commands': 6.10.3 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.5 + '@codemirror/search': 6.6.0 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.0 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4628,6 +4794,8 @@ snapshots: create-require@1.1.1: {} + crelt@1.0.6: {} + cross-dirname@0.1.0: optional: true @@ -6344,6 +6512,8 @@ snapshots: stubborn-utils@1.0.2: {} + style-mod@4.1.3: {} + sumchecker@3.0.1: dependencies: debug: 4.4.3 @@ -6578,6 +6748,15 @@ snapshots: jiti: 2.6.1 lightningcss: 1.32.0 + vue-codemirror@6.1.1(codemirror@6.0.2)(vue@3.5.32(typescript@5.9.3)): + dependencies: + '@codemirror/commands': 6.10.3 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.0 + codemirror: 6.0.2 + vue: 3.5.32(typescript@5.9.3) + vue-component-type-helpers@3.2.6: {} vue-demi@0.14.10(vue@3.5.32(typescript@5.9.3)): @@ -6612,6 +6791,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + w3c-keyname@2.2.8: {} + wcwidth@1.0.1: dependencies: defaults: 1.0.4 diff --git a/src/constant/menus.ts b/src/constant/menus.ts index 4840a07..9e50530 100644 --- a/src/constant/menus.ts +++ b/src/constant/menus.ts @@ -1,4 +1,4 @@ -import { RiHomeLine, RiFileEditLine, RiCpuLine, RiSettingsLine, RiPuzzle2Line, RiTimeLine } from '@remixicon/vue' +import { RiHomeLine, RiFileEditLine, RiCpuLine, RiSettingsLine, RiPuzzle2Line, RiTimeLine, RiCodeLine } from '@remixicon/vue' // 菜单列表申明 export interface MenuItem { @@ -53,6 +53,14 @@ export const menus: MenuItem[] = [ }, { id: 6, + name: '脚本', + icon: RiCodeLine, + color: '#525866', + activeColor: '#2B7FFF', + url: '/scripts', + }, + { + id: 7, name: '设置', icon: RiSettingsLine, color: '#525866', diff --git a/src/i18n/constants.ts b/src/i18n/constants.ts index 203e118..002eb19 100644 --- a/src/i18n/constants.ts +++ b/src/i18n/constants.ts @@ -22,5 +22,6 @@ export const NAMESPACES = [ 'models', 'skills', 'cron', + 'script', ] as const; export type Namespace = (typeof NAMESPACES)[number]; \ No newline at end of file diff --git a/src/i18n/locales/en/script.json b/src/i18n/locales/en/script.json new file mode 100644 index 0000000..c50c161 --- /dev/null +++ b/src/i18n/locales/en/script.json @@ -0,0 +1,54 @@ +{ + "title": "Scripts", + "subtitle": "Record, edit and run automation scripts", + "newScript": "New Script", + "refresh": "Refresh", + "stats": { + "total": "Total Scripts", + "active": "Enabled", + "failed": "Recently Failed" + }, + "empty": { + "title": "No scripts yet", + "description": "Create or record automation scripts to extend platform support. Scripts run on Playwright.", + "create": "Create your first script" + }, + "card": { + "test": "Test", + "run": "Run", + "deleteConfirm": "Are you sure you want to delete this script? The script file will also be removed.", + "last": "Last run", + "channel": "Channel", + "common": "Common" + }, + "dialog": { + "createTitle": "New Script", + "editTitle": "Edit Script", + "description": "Write or record a Playwright automation script", + "name": "Script Name", + "namePlaceholder": "e.g. Fliggy room type update", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Briefly describe what this script does...", + "channel": "Target Channel", + "channelPlaceholder": "Paste channel URL", + "channelCommon": "Common", + "code": "Script Code", + "recordStart": "Start Recording", + "recordStop": "Stop Recording", + "recording": "Recording...", + "recordTip": "Recording will open the Playwright Inspector. Record actions in the Inspector and copy the generated code into the editor.", + "saveChanges": "Save Changes" + }, + "toast": { + "created": "Script created", + "updated": "Script updated", + "enabled": "Script enabled", + "disabled": "Script disabled", + "deleted": "Script deleted", + "runSuccess": "Script ran successfully", + "runFailed": "Script failed: {{error}}", + "nameRequired": "Please enter a script name", + "codeRequired": "Please enter script code", + "channelRequired": "Please enter a channel URL" + } +} diff --git a/src/i18n/locales/ja/script.json b/src/i18n/locales/ja/script.json new file mode 100644 index 0000000..7530c98 --- /dev/null +++ b/src/i18n/locales/ja/script.json @@ -0,0 +1,54 @@ +{ + "title": "スクリプト管理", + "subtitle": "自動化スクリプトの録画、編集、実行", + "newScript": "新規スクリプト", + "refresh": "更新", + "stats": { + "total": "総スクリプト数", + "active": "有効", + "failed": "最近の失敗" + }, + "empty": { + "title": "スクリプトがありません", + "description": "自動化スクリプトを作成または録画して、各種プラットフォームへの対応を拡張します。スクリプトは Playwright で実行されます。", + "create": "最初のスクリプトを作成" + }, + "card": { + "test": "テスト", + "run": "実行", + "deleteConfirm": "このスクリプトを削除してもよろしいですか?対応するスクリプトファイルも削除されます。", + "last": "前回の実行", + "channel": "チャンネル", + "common": "汎用" + }, + "dialog": { + "createTitle": "新規スクリプト", + "editTitle": "スクリプト編集", + "description": "Playwright 自動化スクリプトを作成または録画", + "name": "スクリプト名", + "namePlaceholder": "例:Fliggy 部屋タイプ変更", + "descriptionLabel": "説明", + "descriptionPlaceholder": "スクリプトの用途を簡潔に説明...", + "channel": "対象チャンネル", + "channelPlaceholder": "チャンネルURLを貼り付け", + "channelCommon": "汎用", + "code": "スクリプトコード", + "recordStart": "録画開始", + "recordStop": "録画停止", + "recording": "録画中...", + "recordTip": "録画すると Playwright Inspector が開きます。Inspector で操作を録画し、生成されたコードをエディタに貼り付けてください。", + "saveChanges": "変更を保存" + }, + "toast": { + "created": "スクリプトを作成しました", + "updated": "スクリプトを更新しました", + "enabled": "スクリプトを有効にしました", + "disabled": "スクリプトを無効にしました", + "deleted": "スクリプトを削除しました", + "runSuccess": "スクリプトの実行に成功しました", + "runFailed": "スクリプトの実行に失敗しました: {{error}}", + "nameRequired": "スクリプト名を入力してください", + "codeRequired": "スクリプトコードを入力してください", + "channelRequired": "チャンネルURLを入力してください" + } +} diff --git a/src/i18n/locales/zh/script.json b/src/i18n/locales/zh/script.json new file mode 100644 index 0000000..62cc531 --- /dev/null +++ b/src/i18n/locales/zh/script.json @@ -0,0 +1,54 @@ +{ + "title": "脚本管理", + "subtitle": "录制、编辑和运行自动化脚本", + "newScript": "新建脚本", + "refresh": "刷新", + "stats": { + "total": "脚本总数", + "active": "已启用", + "failed": "最近失败" + }, + "empty": { + "title": "暂无脚本", + "description": "创建或录制自动化脚本,以扩展对各类平台的适配能力。脚本基于 Playwright 运行。", + "create": "创建第一个脚本" + }, + "card": { + "test": "测试", + "run": "运行", + "deleteConfirm": "确定要删除此脚本吗?对应的脚本文件也会被删除。", + "last": "上次运行", + "channel": "渠道", + "common": "通用" + }, + "dialog": { + "createTitle": "新建脚本", + "editTitle": "编辑脚本", + "description": "编写或录制 Playwright 自动化脚本", + "name": "脚本名称", + "namePlaceholder": "例如:飞猪房型修改", + "descriptionLabel": "脚本描述", + "descriptionPlaceholder": "简要说明脚本用途...", + "channel": "目标渠道", + "channelPlaceholder": "粘贴渠道链接地址", + "channelCommon": "通用", + "code": "脚本代码", + "recordStart": "开始录制", + "recordStop": "停止录制", + "recording": "录制中...", + "recordTip": "录制将打开 Playwright Inspector,请在 Inspector 中录制并复制代码到编辑器。", + "saveChanges": "保存更改" + }, + "toast": { + "created": "脚本已创建", + "updated": "脚本已更新", + "enabled": "脚本已启用", + "disabled": "脚本已禁用", + "deleted": "脚本已删除", + "runSuccess": "脚本运行成功", + "runFailed": "脚本运行失败: {{error}}", + "nameRequired": "请输入脚本名称", + "codeRequired": "请输入脚本代码", + "channelRequired": "请输入渠道链接地址" + } +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 7c86161..7ce1c25 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -59,6 +59,16 @@ export enum IPC_EVENTS { // 打开渠道 OPEN_CHANNEL = 'open-channel', + // 脚本管理 + SCRIPT_LIST = 'script:list', + SCRIPT_GET = 'script:get', + SCRIPT_SAVE = 'script:save', + SCRIPT_DELETE = 'script:delete', + SCRIPT_TOGGLE = 'script:toggle', + SCRIPT_RUN = 'script:run', + SCRIPT_RECORD_START = 'script:record-start', + SCRIPT_RECORD_STOP = 'script:record-stop', + // 更新 UPDATE_CHECK = 'update:check', UPDATE_DOWNLOAD = 'update:download', diff --git a/src/lib/script-api.ts b/src/lib/script-api.ts new file mode 100644 index 0000000..06a9fdd --- /dev/null +++ b/src/lib/script-api.ts @@ -0,0 +1,18 @@ +import type { + AutomationScript, + ScriptSaveInput, + ScriptExecutionResult, +} from '@lib/script-types'; + +export const scriptApi = { + list: (): Promise => window.api.scriptApi.list(), + get: (id: string): Promise => window.api.scriptApi.get(id), + save: (input: ScriptSaveInput): Promise => window.api.scriptApi.save(input), + delete: (id: string): Promise => window.api.scriptApi.delete(id), + toggle: (id: string, enabled: boolean): Promise => window.api.scriptApi.toggle(id, enabled), + run: (id: string): Promise => window.api.scriptApi.run(id), + startRecording: (url?: string): Promise<{ success: boolean; code?: string; error?: string }> => + window.api.scriptApi.startRecording(url), + stopRecording: (): Promise<{ success: boolean; code?: string; error?: string }> => + window.api.scriptApi.stopRecording(), +}; diff --git a/src/lib/script-types.ts b/src/lib/script-types.ts new file mode 100644 index 0000000..4bc808d --- /dev/null +++ b/src/lib/script-types.ts @@ -0,0 +1,53 @@ +export interface ScriptLastRun { + time: string; + success: boolean; + error?: string; +} + +export interface AutomationScript { + id: string; + name: string; + description: string; + filename: string; + enabled: boolean; + channel: string; + createdAt: string; + updatedAt: string; + code?: string; + lastRun?: ScriptLastRun; +} + +export interface ScriptSaveInput { + id?: string; + name: string; + description: string; + code: string; + channel: string; + enabled: boolean; +} + +export interface ScriptExecutionResult { + success: boolean; + exitCode: number | null; + stdoutTail: string; + stderrTail: string; + error?: string; +} + +export type ScriptRecordingStatus = 'idle' | 'recording' | 'stopped'; + +export interface ScriptMetaItem { + id: string; + name: string; + description: string; + filename: string; + enabled: boolean; + channel: string; + createdAt: string; + updatedAt: string; + lastRun?: ScriptLastRun; +} + +export interface ScriptsMeta { + scripts: ScriptMetaItem[]; +} diff --git a/src/pages/about/index.vue b/src/pages/about/index.vue deleted file mode 100644 index e9060e7..0000000 --- a/src/pages/about/index.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/pages/dialog/index.vue b/src/pages/dialog/index.vue deleted file mode 100644 index 6f0fa1b..0000000 --- a/src/pages/dialog/index.vue +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/src/pages/scripts/components/ScriptCard.vue b/src/pages/scripts/components/ScriptCard.vue new file mode 100644 index 0000000..b42ce95 --- /dev/null +++ b/src/pages/scripts/components/ScriptCard.vue @@ -0,0 +1,185 @@ + + + diff --git a/src/pages/scripts/components/ScriptCreateDialog.vue b/src/pages/scripts/components/ScriptCreateDialog.vue new file mode 100644 index 0000000..6fd572e --- /dev/null +++ b/src/pages/scripts/components/ScriptCreateDialog.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/src/pages/scripts/components/ScriptEditorDialog.vue b/src/pages/scripts/components/ScriptEditorDialog.vue new file mode 100644 index 0000000..dccd125 --- /dev/null +++ b/src/pages/scripts/components/ScriptEditorDialog.vue @@ -0,0 +1,354 @@ + + + + + diff --git a/src/pages/scripts/components/ScriptStats.vue b/src/pages/scripts/components/ScriptStats.vue new file mode 100644 index 0000000..2d480a3 --- /dev/null +++ b/src/pages/scripts/components/ScriptStats.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/pages/scripts/index.vue b/src/pages/scripts/index.vue new file mode 100644 index 0000000..3227fb3 --- /dev/null +++ b/src/pages/scripts/index.vue @@ -0,0 +1,265 @@ + + + diff --git a/src/router/index.ts b/src/router/index.ts index 94da83a..f87049a 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -47,6 +47,12 @@ const routes = [ name: "Cron", meta: { requiresAuth: true }, }, + { + path: "/scripts", + component: () => import("@src/pages/scripts/index.vue"), + name: "Scripts", + meta: { requiresAuth: true }, + }, { path: "/setting", component: () => import("@src/pages/setting/index.vue"), diff --git a/src/store/script.ts b/src/store/script.ts new file mode 100644 index 0000000..c65698b --- /dev/null +++ b/src/store/script.ts @@ -0,0 +1,161 @@ +import { defineStore } from 'pinia'; +import { ref, computed } from 'vue'; +import { scriptApi } from '@lib/script-api'; +import type { + AutomationScript, + ScriptSaveInput, + ScriptExecutionResult, + ScriptRecordingStatus, +} from '@lib/script-types'; + +export const useScriptStore = defineStore('script', () => { + const scripts = ref([]); + const loading = ref(false); + const error = ref(null); + const recordingStatus = ref('idle'); + const executionLogs = ref>({}); + + const safeScripts = computed(() => (Array.isArray(scripts.value) ? scripts.value : [])); + const enabledScripts = computed(() => safeScripts.value.filter((s) => s.enabled)); + const scriptsByChannel = computed(() => { + const map = new Map(); + for (const script of safeScripts.value) { + const list = map.get(script.channel) || []; + list.push(script); + map.set(script.channel, list); + } + return map; + }); + + const fetchScripts = async () => { + const currentScripts = safeScripts.value; + if (currentScripts.length === 0) { + loading.value = true; + } + error.value = null; + + try { + const result = await scriptApi.list(); + scripts.value = result; + } catch (err) { + error.value = err instanceof Error ? err.message : String(err); + } finally { + loading.value = false; + } + }; + + const saveScript = async (input: ScriptSaveInput) => { + try { + const result = await scriptApi.save(input); + if (input.id) { + scripts.value = safeScripts.value.map((s) => (s.id === input.id ? result : s)); + } else { + scripts.value = [...safeScripts.value, result]; + } + return result; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error.value = msg; + throw err; + } + }; + + const deleteScript = async (id: string) => { + try { + await scriptApi.delete(id); + scripts.value = safeScripts.value.filter((s) => s.id !== id); + delete executionLogs.value[id]; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error.value = msg; + throw err; + } + }; + + const toggleScript = async (id: string, enabled: boolean) => { + try { + await scriptApi.toggle(id, enabled); + scripts.value = safeScripts.value.map((s) => + s.id === id ? { ...s, enabled, updatedAt: new Date().toISOString() } : s, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error.value = msg; + throw err; + } + }; + + const runScript = async (id: string) => { + try { + const result = await scriptApi.run(id); + executionLogs.value = { ...executionLogs.value, [id]: result }; + // 更新本地 lastRun + scripts.value = safeScripts.value.map((s) => + s.id === id + ? { + ...s, + lastRun: { + time: new Date().toISOString(), + success: result.success, + error: result.error, + }, + updatedAt: new Date().toISOString(), + } + : s, + ); + return result; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error.value = msg; + throw err; + } + }; + + const startRecording = async (url?: string) => { + recordingStatus.value = 'recording'; + try { + const result = await scriptApi.startRecording(url); + if (!result.success) { + recordingStatus.value = 'idle'; + } + return result; + } catch (err) { + recordingStatus.value = 'idle'; + throw err; + } + }; + + const stopRecording = async () => { + try { + const result = await scriptApi.stopRecording(); + recordingStatus.value = 'stopped'; + return result; + } catch (err) { + recordingStatus.value = 'idle'; + throw err; + } + }; + + const resetRecording = () => { + recordingStatus.value = 'idle'; + }; + + return { + scripts, + loading, + error, + recordingStatus, + executionLogs, + safeScripts, + enabledScripts, + scriptsByChannel, + fetchScripts, + saveScript, + deleteScript, + toggleScript, + runScript, + startRecording, + stopRecording, + resetRecording, + }; +}); diff --git a/src/styles/index.css b/src/styles/index.css index c725081..e48add4 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -134,3 +134,8 @@ body { color: #FF4949; margin-left: 3px; } + +.el-icon { + width: 20px !important; + height: 20px !important; +}