diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js index 2a3e22d..cfe8d25 100644 --- a/dist-electron/main/main.js +++ b/dist-electron/main/main.js @@ -1,6 +1,6 @@ "use strict"; require("electron"); -require("./main-D4EDpIiu.js"); +require("./main-3jJaZPgE.js"); require("electron-squirrel-startup"); require("electron-log"); require("bytenode"); diff --git a/dist/index.html b/dist/index.html index 6add19d..28380ff 100644 --- a/dist/index.html +++ b/dist/index.html @@ -1,17 +1,17 @@ - - - - - NIANXX - - - - - - -
- - + + + + + NIANXX + + + + + + +
+ + diff --git a/docs/ChannelRefactorPlan.md b/docs/ChannelRefactorPlan.md new file mode 100644 index 0000000..d8052cd --- /dev/null +++ b/docs/ChannelRefactorPlan.md @@ -0,0 +1,238 @@ +# 渠道硬编码移除与动态关联重构计划 + +## 上下文与目标 + +当前 `zn-ai` 的"一键打开各渠道"功能存在以下问题: +1. **渠道数据硬编码**在 `src/constant/channel.ts` 中,仅包含 fliggy/meituan/douyin 三个固定渠道; +2. **`AddChannelDialog.vue` 是纯 UI 空壳**,收集表单后不做任何持久化或事件抛出; +3. **`TaskCenter.vue` 存在 bug**:将普通数组 `channels` 当作 ref 使用(`channels.value`),实际传递 `undefined` 给主进程; +4. **脚本管理功能**中,每个 `AutomationScript` 已有 `channel` 字段(存储渠道名称或 URL),但未被"一键打开"功能复用。 + +**目标**: +- 移除 `channels.ts` 中的硬编码主数据源,改为从脚本管理中动态聚合可用渠道; +- 重塑 `AddChannelDialog` 的交互:快捷搜索选择渠道 → 展示已选列表 → 保存; +- 修复 `TaskCenter.vue` 的渠道传递逻辑,使用用户动态配置的列表; +- 确保 `open_all_channel.js` 接收的数据格式正确,按需同步调整。 + +--- + +## 关键文件清单 + +| 路径 | 角色 | 改动类型 | +|------|------|----------| +| `zn-ai/src/constant/channel.ts` | 硬编码渠道常量 | 移除数组,保留字典+解析工具 | +| `zn-ai/src/stores/channel.ts` | **新建**渠道状态 store | 新增 | +| `zn-ai/src/stores/script.ts` | 脚本 Pinia store | 只读依赖(聚合 channel) | +| `zn-ai/src/pages/home/components/AddChannelDialog.vue` | 渠道关联弹窗 | **重写**模板与逻辑 | +| `zn-ai/src/pages/home/TaskCenter.vue` | 任务中心卡片 | 修复+替换数据源 | +| `zn-ai/src/pages/home/index.vue` | 首页容器 | 可能需补充 store 初始化 | +| `zn-ai/src/pages/scripts/components/ScriptEditorDialog.vue` | 脚本编辑弹窗 | 替换 `channels` 引用为字典 | +| `zn-ai/electron/scripts/open_all_channel.js` | 打开渠道的 Playwright 脚本 | 按需调整字段读取 | +| `zn-ai/electron/process/runTaskOperationService.ts` | IPC 处理(OPEN_CHANNEL) | 按需做数据格式适配 | + +--- + +## 方案设计 + +### 1. 数据层:`constant/channel.ts` + 新建 `stores/channel.ts` + +#### `constant/channel.ts` 改造 +- **移除** `export const channels: Item[]` 硬编码数组。 +- **保留** `Item` 接口(`{id, channelName, channelUrl}`)。 +- **新增** `channelDictionary: Record`,仅作为"已知渠道名称→URL"的参考字典(从原数组迁移)。 +- **新增** `resolveChannel(value: string): { name?: string; url: string }`: + - 若 `value` 匹配 `channelDictionary` 的 key,返回 `{name: value, url: dictionary[value]}`; + - 若 `value` 本身像 URL(以 http 开头),尝试从字典反向查找 name,找不到则 name 取 hostname 或原值; + - 其他情况返回 `{name: value, url: value}`。 + +#### 新建 `stores/channel.ts`(Pinia) +使用 Composition API 风格,与 `script.ts`/`cron.ts` 保持一致。 + +```ts +export interface ChannelItem { + id: string; + channelName: string; + channelUrl: string; +} + +const STORAGE_KEY = 'zn-ai:selected-channels'; + +export const useChannelStore = defineStore('channel', () => { + const scriptStore = useScriptStore(); + + // 用户选中的"一键打开"渠道(持久化到 localStorage) + const selectedChannels = ref([]); + + // 从脚本 store 动态聚合可用渠道(去重,按 URL 去重) + const availableChannels = computed(() => { + const map = new Map(); + for (const script of scriptStore.safeScripts) { + if (!script.channel) continue; + const resolved = resolveChannel(script.channel); + const url = resolved.url; + if (!map.has(url)) { + map.set(url, { + id: `channel-${url}`, + channelName: resolved.name || url, + channelUrl: url, + }); + } + } + return Array.from(map.values()); + }); + + const loadSelectedChannels = () => { ... }; + const saveSelectedChannels = () => { ... }; + const addSelectedChannel = (item: ChannelItem) => { ... }; + const removeSelectedChannel = (id: string) => { ... }; + const setSelectedChannels = (items: ChannelItem[]) => { ... }; + + return { + selectedChannels, + availableChannels, + loadSelectedChannels, + saveSelectedChannels, + addSelectedChannel, + removeSelectedChannel, + setSelectedChannels, + }; +}); +``` + +**注意**:`AddChannelDialog` 打开时应基于 `availableChannels` 做搜索,确认保存时调用 `setSelectedChannels` + `saveSelectedChannels`。 + +--- + +### 2. UI 层:`AddChannelDialog.vue` 重塑 + +当前弹窗是两个空白输入框(名称+URL),需要改为**搜索选择 + 已选列表**。 + +#### 新交互流程 +1. **顶部搜索区**:`el-autocomplete`(或 `el-input` + 自定义下拉列表)绑定 `availableChannels` 过滤结果; +2. **选择后**:将渠道添加到下方的"已选渠道"列表(去重检查); +3. **已选列表**:使用卡片/表格形式展示,每行包含: + - 渠道名称 + - 渠道 URL(截断显示,tooltip 展示完整) + - 删除按钮(`el-icon Delete`) +4. **底部操作**:取消 / 确认。确认时持久化到 store。 + +#### 状态隔离 +弹窗打开时,先 `loadSelectedChannels()` 到本地临时数组;编辑过程中操作临时数组;确认时写入 store 并持久化;取消时直接关闭,不污染 store。 + +#### 样式适配 +复用项目已有的 `custom-script-dialog` 圆角/深色模式变量,保持视觉一致性。 + +--- + +### 3. 修复 `TaskCenter.vue` + +- **移除** `import { channels } from '@constant/channel'`。 +- **注入** `useChannelStore`。 +- 点击"一键打开各渠道"时: + ```ts + const channelStore = useChannelStore(); + window.api.openChannel(channelStore.selectedChannels) + ``` +- 如果 `selectedChannels` 为空,可给出提示(`ElMessage.warning`)或仍然允许空数组由主进程处理。 + +--- + +### 4. `ScriptEditorDialog.vue` 适配 + +- 将 `import { channels } from '@constant/channel'` 改为 `import { channelDictionary, resolveChannel } from '@constant/channel'`。 +- `getChannelUrl(channel)` 改为在 `channelDictionary` 中查找。 +- channel URL 替换 watcher 逻辑同步使用 `channelDictionary` 的 values。 + +--- + +### 5. `open_all_channel.js` 与主进程适配 + +当前 `open_all_channel.js` 读取环境变量 `CHANNELS`,期望元素结构为 `{ channelUrl: string }`(第 81 行 `channels[i]?.channelUrl`)。 + +`channelStore.selectedChannels` 的结构保持为 `Item`(`{ id, channelName, channelUrl }`),因此**主进程接收到的数组本身已包含 `channelUrl` 字段**。 + +需要确认 `runTaskOperationService.ts` 中 `IPC_EVENTS.OPEN_CHANNEL` handler: +```ts +ipcMain.handle(IPC_EVENTS.OPEN_CHANNEL, async (_event, channels: any) => { ... }) +``` +它直接将 `channels` 传给 `executeScriptServiceInstance.executeScript(scriptPath, { channels })`,后者 JSON.stringify 后写入 `CHANNELS` 环境变量。 + +**结论**:只要 `TaskCenter.vue` 传递的数组元素包含 `channelUrl`,`open_all_channel.js` **无需修改**即可正常工作。 + +但用户要求"同步更新 open_all_channel.js",为确保严谨,计划内包含**兼容性检查**:若发现字段名不一致,统一调整为 `channelUrl`;若需要增强(如按名称去重、空值过滤),则在 `runTaskOperationService.ts` 的 handler 中做适配,保持 `open_all_channel.js` 的输入契约稳定。 + +--- + +## Sub-agent 分工(共 3 个) + +### Agent A — 数据层与字典迁移 +**负责范围**: +- 改造 `zn-ai/src/constant/channel.ts`(移除数组,保留字典+`resolveChannel`)。 +- 新建 `zn-ai/src/stores/channel.ts`(Pinia store,含 `availableChannels` / `selectedChannels` / localStorage 持久化)。 +- 修改 `ScriptEditorDialog.vue` 中的引用,从原 `channels` 数组切换到 `channelDictionary`。 + +**输入依赖**:已确认的 `script.ts` 结构、`channel.ts` 原数组。 +**输出产物**:可运行的数据层代码。 + +--- + +### Agent B — AddChannelDialog UI 重构 +**负责范围**: +- 重写 `zn-ai/src/pages/home/components/AddChannelDialog.vue`。 +- 实现搜索选择(`el-autocomplete` 或自定义搜索+下拉)与已选列表展示。 +- 对接 `useChannelStore`:读取 `availableChannels`,保存时写入 `selectedChannels`。 +- 保持与项目一致的 dark/light 样式。 + +**输入依赖**:Agent A 完成的 `stores/channel.ts`。 +**工作方式**:可以并行启动,但代码编辑需等待 Agent A 的 store 文件就位(或先在本地 mock 接口)。 + +--- + +### Agent C — TaskCenter 修复与端到端验证 +**负责范围**: +- 修改 `zn-ai/src/pages/home/TaskCenter.vue`:移除硬编码导入,注入 `useChannelStore`,修复 `.value` bug。 +- 检查 `zn-ai/electron/process/runTaskOperationService.ts` 与 `open_all_channel.js` 的数据格式兼容性,必要时做适配。 +- 在 `zn-ai/src/pages/home/index.vue` 中添加 `channelStore.loadSelectedChannels()` 初始化(onMounted 或合适位置)。 +- 运行项目(`npm run dev` 或等效命令),验证: + 1. 脚本管理中有渠道数据时,`AddChannelDialog` 能搜索到; + 2. 选择并保存后,`TaskCenter` 能正确传递数组; + 3. `open_all_channel.js` 能正常解析并打开页面。 + +**输入依赖**:Agent A 与 Agent B 的产物。 +**工作方式**:必须在 A、B 完成后启动。 + +--- + +## 执行顺序 + +``` +Agent A (数据层) ─┬─► Agent B (UI 弹窗) + │ + └─► Agent C (流程修复与验证) +``` + +- **A 与 B 可并行或准并行**:B 可以在 A 提交 store 初版后立即开始对接。 +- **C 必须在 A、B 都完成后启动**,负责联调与验证。 + +--- + +## 风险与回退策略 + +| 风险 | 缓解措施 | +|------|----------| +| `availableChannels` 为空(脚本管理无渠道数据) | `AddChannelDialog` 搜索下拉显示"暂无可用渠道",已选列表允许空状态;`TaskCenter` 点击时给出提示 | +| 脚本 `channel` 字段格式混乱(既有 URL 又有名称) | `resolveChannel()` 统一做归一化处理,以 URL 为唯一键 | +| `open_all_channel.js` 字段契约被打破 | 在 `runTaskOperationService.ts` 的 handler 中做字段映射/过滤,保证脚本侧输入不变 | +| localStorage 中旧数据格式不兼容 | `loadSelectedChannels` 读取时做 schema 校验,不兼容则清空并回退到空数组 | + +--- + +## 验证清单(Agent C 负责) + +- [ ] `channels.ts` 中不再导出硬编码数组。 +- [ ] `AddChannelDialog` 打开后,能从脚本中搜索出渠道(如 fliggy / meituan / douyin)。 +- [ ] 选择多个渠道后列表正确显示,删除单个后状态正确。 +- [ ] 点击"确认"关闭弹窗,再次打开能恢复上次保存的选项。 +- [ ] `TaskCenter` 中点击"一键打开各渠道",`openChannel` 参数为正确的 `ChannelItem[]`(非 undefined)。 +- [ ] `open_all_channel.js` 成功解析 `CHANNELS` 并依次打开对应页面。 +- [ ] 脚本编辑弹窗中 `getChannelUrl` 仍能正确将 `fliggy` 解析为对应 URL。 diff --git a/electron/main.ts b/electron/main.ts index ac983b2..d36f20b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -152,7 +152,9 @@ if (started) { // logManager.error('unhandledRejection', reason, promise); // }); -app.whenReady().then(() => { +app.whenReady().then(async () => { + await configManager.init(); + gatewayManager.init(); onProviderChange(() => { diff --git a/electron/process/runTaskOperationService.ts b/electron/process/runTaskOperationService.ts index 8cd8a36..fb90c11 100644 --- a/electron/process/runTaskOperationService.ts +++ b/electron/process/runTaskOperationService.ts @@ -205,14 +205,20 @@ export function runTaskOperationService() { openedTabIndexByChannelName.clear() - if (Array.isArray(channels)) { - for (let i = 0; i < channels.length; i++) { - const name = channels[i]?.channelName - if (name) openedTabIndexByChannelName.set(String(name), i) - } + const validChannels = Array.isArray(channels) + ? channels.filter((c) => c && typeof c === 'object' && c.channelUrl) + : [] + + if (validChannels.length === 0) { + return { success: false, error: '没有可用的渠道配置' } } - const result = await executeScriptServiceInstance.executeScript(scriptPath, { channels }) + for (let i = 0; i < validChannels.length; i++) { + const name = validChannels[i]?.channelName + if (name) openedTabIndexByChannelName.set(String(name), i) + } + + const result = await executeScriptServiceInstance.executeScript(scriptPath, { channels: validChannels }) return { success: true, result } } catch (error) { return { success: false, error: (error as any)?.message || 'open channel failed' } diff --git a/electron/service/config-service/index.ts b/electron/service/config-service/index.ts index 1ca5596..1f1ed4f 100644 --- a/electron/service/config-service/index.ts +++ b/electron/service/config-service/index.ts @@ -1,9 +1,7 @@ -import { app, BrowserWindow, ipcMain } from 'electron' +import { BrowserWindow, ipcMain } from 'electron' import type { ConfigKeys, IConfig } from '@lib/types' import { CONFIG_KEYS, IPC_EVENTS } from '@lib/constants' -import { debounce, simpleCloneDeep } from '@lib/utils' -import * as fs from 'fs' -import * as path from 'path' +import { debounce } from '@lib/utils' import logManager from '@electron/service/logger' @@ -15,33 +13,47 @@ const DEFAULT_CONFIG: IConfig = { [CONFIG_KEYS.MINIMIZE_TO_TRAY]: false, [CONFIG_KEYS.PROVIDER]: '', [CONFIG_KEYS.DEFAULT_MODEL]: null, + [CONFIG_KEYS.SELECTED_CHANNELS]: [], } export class ConfigService { private static _instance: ConfigService; - private _config: IConfig; - private _configPath: string; + private _store: any; private _defaultConfig: IConfig = DEFAULT_CONFIG; private _listeners: Array<(config: IConfig) => void> = []; private constructor() { - // 获取配置文件路径 - this._configPath = path.join(app.getPath('userData'), 'config.json'); - // 加载配置 - this._config = this._loadConfig(); - // 设置 IPC 事件 + // store 通过 init() 异步初始化,避免 ESM 模块在 CJS 打包环境中同步导入的问题 + } + + public async init() { + if (this._store) return; + const { default: Store } = await import('electron-store'); + this._store = new Store({ + name: 'config', + defaults: DEFAULT_CONFIG, + }); this._setupIpcEvents(); logManager.info('ConfigService initialized successfully.') } + private _ensureStore() { + if (!this._store) { + throw new Error('ConfigService has not been initialized. Call init() first.'); + } + } + private _setupIpcEvents() { const duration = 200; const handelUpdate = debounce((val) => this.update(val), duration); ipcMain.handle(IPC_EVENTS.GET_CONFIG, (_, key) => this.get(key)); - ipcMain.on(IPC_EVENTS.SET_CONFIG, (_, key, val) => this.set(key, val)); + ipcMain.handle(IPC_EVENTS.SET_CONFIG, (_, key, val) => { + this.set(key, val); + return { success: true }; + }); ipcMain.on(IPC_EVENTS.UPDATE_CONFIG, (_, updates) => handelUpdate(updates)); } @@ -52,66 +64,51 @@ export class ConfigService { return this._instance; } - private _loadConfig(): IConfig { - try { - if (fs.existsSync(this._configPath)) { - const configContent = fs.readFileSync(this._configPath, 'utf-8'); - const config = { ...this._defaultConfig, ...JSON.parse(configContent) }; - logManager.info('Config loaded successfully from:', this._configPath); - return config; - } - } catch (error) { - logManager.error('Failed to load config:', error); - } - return { ...this._defaultConfig }; - } - - private _saveConfig(): void { - try { - // 确保目录存在 - fs.mkdirSync(path.dirname(this._configPath), { recursive: true }); - // 写入 - fs.writeFileSync(this._configPath, JSON.stringify(this._config, null, 2), 'utf-8'); - // 通知监听者 - this._notifyListeners(); - - logManager.info('Config saved successfully to:', this._configPath); - } catch (error) { - logManager.error('Failed to save config:', error); - } - } - private _notifyListeners(): void { - BrowserWindow.getAllWindows().forEach(win => win.webContents.send(IPC_EVENTS.CONFIG_UPDATED, this._config)); - this._listeners.forEach(listener => listener({ ...this._config })); + this._ensureStore(); + BrowserWindow.getAllWindows().forEach(win => win.webContents.send(IPC_EVENTS.CONFIG_UPDATED, this._store.store)); + this._listeners.forEach(listener => listener({ ...this._store.store })); } public getConfig(): IConfig { - return simpleCloneDeep(this._config); + if (!this._store) { + return { ...this._defaultConfig }; + } + return this._store.store; } public get(key: ConfigKeys): T { - return this._config[key] as T + if (!this._store) { + return this._defaultConfig[key] as T; + } + return this._store.get(key) as T } public set(key: ConfigKeys, value: unknown, autoSave: boolean = true): void { - if (!(key in this._config)) return; - const oldValue = this._config[key]; + this._ensureStore(); + const oldValue = this._store.get(key); if (oldValue === value) return; - this._config[key] = value as never; - logManager.debug(`Config set: ${key} = ${value}`); - autoSave && this._saveConfig(); + this._store.set(key, value); + logManager.info(`Config set: ${key} = ${JSON.stringify(value)}`); + logManager.info(`Config file path: ${this._store.path}`); + autoSave && this._notifyListeners(); } public update(updates: Partial, autoSave: boolean = true): void { - this._config = { ...this._config, ...updates }; - autoSave && this._saveConfig(); + this._ensureStore(); + (Object.keys(updates) as ConfigKeys[]).forEach((key) => { + this._store.set(key, updates[key]); + }); + autoSave && this._notifyListeners(); } public resetToDefault(): void { - this._config = { ...this._defaultConfig }; + this._ensureStore(); + (Object.keys(this._defaultConfig) as ConfigKeys[]).forEach((key) => { + this._store.set(key, this._defaultConfig[key]); + }); logManager.info('Config reset to default.'); - this._saveConfig(); + this._notifyListeners(); } public onConfigChange(listener: ((config: IConfig) => void)): () => void { diff --git a/global.d.ts b/global.d.ts index 162dd9a..6585fce 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,4 +1,5 @@ import { IPC_EVENTS } from '@lib/constants' +import type { ConfigKeys } from '@lib/types' import type { AutomationScript, ScriptSaveInput, ScriptExecutionResult } from '@lib/script-types' declare global { @@ -61,6 +62,16 @@ declare global { return: Promise } + // 配置 + [IPC_EVENTS.GET_CONFIG]: { + params: [key: ConfigKeys] + return: Promise + } + [IPC_EVENTS.SET_CONFIG]: { + params: [key: ConfigKeys, value: any] + return: Promise<{ success: boolean }> + } + // 脚本管理 [IPC_EVENTS.SCRIPT_LIST]: { params: [] diff --git a/src/constant/channel.ts b/src/constant/channel.ts index 89501cb..3640d3b 100644 --- a/src/constant/channel.ts +++ b/src/constant/channel.ts @@ -1,25 +1,41 @@ -import { v4 as uuidv4 } from 'uuid' - export interface Item { id: string channelName: string channelUrl: string } -export const channels: Item[] = [ - { - id: uuidv4(), - channelName: 'fliggy', - channelUrl: 'https://hotel.fliggy.com/ebooking/hotelBaseInfoUv.htm#/ebk/homeV1', - }, - { - id: uuidv4(), - channelName: 'meituan', - channelUrl: 'https://me.meituan.com/ebooking/merchant/product#/index', - }, - { - id: uuidv4(), - channelName: 'douyin', - channelUrl: 'https://life.douyin.com/p/travel-ari/hotel/price_amount_state?groupid=1816249020842116', +export const channelDictionary: Record = { + fliggy: 'https://hotel.fliggy.com/ebooking/hotelBaseInfoUv.htm#/ebk/homeV1', + meituan: 'https://me.meituan.com/ebooking/merchant/product#/index', + douyin: 'https://life.douyin.com/p/travel-ari/hotel/price_amount_state?groupid=1816249020842116', +} + +export function resolveChannel(value: string): { name?: string; url: string } { + const trimmed = value.trim() + if (!trimmed) { + return { url: '' } } -] \ No newline at end of file + + // 如果是已知渠道名称 + if (channelDictionary[trimmed]) { + return { name: trimmed, url: channelDictionary[trimmed] } + } + + // 如果是 URL + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + // 尝试反向查找名称 + const entry = Object.entries(channelDictionary).find(([, url]) => url === trimmed) + if (entry) { + return { name: entry[0], url: trimmed } + } + try { + const hostname = new URL(trimmed).hostname + return { name: hostname, url: trimmed } + } catch { + return { name: trimmed, url: trimmed } + } + } + + // 其他情况:当作未知名称,URL 也用它本身(下游需自行判断) + return { name: trimmed, url: trimmed } +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 041a188..3268d59 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -102,6 +102,7 @@ export enum CONFIG_KEYS { DEFAULT_MODEL = 'defaultModel', AUTO_CHECK_UPDATE = 'autoCheckUpdate', AUTO_DOWNLOAD_UPDATE = 'autoDownloadUpdate', + SELECTED_CHANNELS = 'selectedChannels', } export enum MENU_IDS { diff --git a/src/lib/types.ts b/src/lib/types.ts index c9bfa6a..9f03fcb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -18,6 +18,8 @@ export interface IConfig { [CONFIG_KEYS.PROVIDER]?: string; // 默认模型 [CONFIG_KEYS.DEFAULT_MODEL]?: string | null; + // 选中的渠道 + [CONFIG_KEYS.SELECTED_CHANNELS]: Array<{ id: string; channelName: string; channelUrl: string }>; } export interface Provider { diff --git a/src/pages/home/TaskCenter.vue b/src/pages/home/TaskCenter.vue index 9374e9f..abf4a10 100644 --- a/src/pages/home/TaskCenter.vue +++ b/src/pages/home/TaskCenter.vue @@ -34,18 +34,24 @@ -