feat: Refactor channel management and UI components
- Removed hardcoded channel data from `channel.ts` and replaced it with a dynamic channel dictionary. - Introduced a new Pinia store `channel.ts` to manage selected and available channels. - Reworked `AddChannelDialog.vue` to allow users to search and select channels dynamically. - Updated `TaskCenter.vue` to utilize the new channel store and handle empty channel selections gracefully. - Enhanced IPC communication for loading and saving selected channels in the configuration. - Adjusted `runTaskOperationService.ts` to ensure proper handling of channel data. - Improved styling and structure of UI components for better user experience.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
require("electron");
|
require("electron");
|
||||||
require("./main-D4EDpIiu.js");
|
require("./main-3jJaZPgE.js");
|
||||||
require("electron-squirrel-startup");
|
require("electron-squirrel-startup");
|
||||||
require("electron-log");
|
require("electron-log");
|
||||||
require("bytenode");
|
require("bytenode");
|
||||||
|
|||||||
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -8,8 +8,8 @@
|
|||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http://8.138.234.141 https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com; connect-src 'self' http://8.138.234.141 https://api.iconify.design wss://onefeel.brother7.cn"
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http://8.138.234.141 https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com; connect-src 'self' http://8.138.234.141 https://api.iconify.design wss://onefeel.brother7.cn"
|
||||||
/>
|
/>
|
||||||
<script type="module" crossorigin src="./assets/index-CfM9LZqc.js"></script>
|
<script type="module" crossorigin src="./assets/index-BmR_czvN.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-CAGR0NJ4.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-DWCm8z3C.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
238
docs/ChannelRefactorPlan.md
Normal file
238
docs/ChannelRefactorPlan.md
Normal file
@@ -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<string, string>`,仅作为"已知渠道名称→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<ChannelItem[]>([]);
|
||||||
|
|
||||||
|
// 从脚本 store 动态聚合可用渠道(去重,按 URL 去重)
|
||||||
|
const availableChannels = computed<ChannelItem[]>(() => {
|
||||||
|
const map = new Map<string, ChannelItem>();
|
||||||
|
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。
|
||||||
@@ -152,7 +152,9 @@ if (started) {
|
|||||||
// logManager.error('unhandledRejection', reason, promise);
|
// logManager.error('unhandledRejection', reason, promise);
|
||||||
// });
|
// });
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(async () => {
|
||||||
|
await configManager.init();
|
||||||
|
|
||||||
gatewayManager.init();
|
gatewayManager.init();
|
||||||
|
|
||||||
onProviderChange(() => {
|
onProviderChange(() => {
|
||||||
|
|||||||
@@ -205,14 +205,20 @@ export function runTaskOperationService() {
|
|||||||
|
|
||||||
openedTabIndexByChannelName.clear()
|
openedTabIndexByChannelName.clear()
|
||||||
|
|
||||||
if (Array.isArray(channels)) {
|
const validChannels = Array.isArray(channels)
|
||||||
for (let i = 0; i < channels.length; i++) {
|
? channels.filter((c) => c && typeof c === 'object' && c.channelUrl)
|
||||||
const name = channels[i]?.channelName
|
: []
|
||||||
if (name) openedTabIndexByChannelName.set(String(name), i)
|
|
||||||
}
|
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 }
|
return { success: true, result }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: (error as any)?.message || 'open channel failed' }
|
return { success: false, error: (error as any)?.message || 'open channel failed' }
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
import { BrowserWindow, ipcMain } from 'electron'
|
||||||
import type { ConfigKeys, IConfig } from '@lib/types'
|
import type { ConfigKeys, IConfig } from '@lib/types'
|
||||||
import { CONFIG_KEYS, IPC_EVENTS } from '@lib/constants'
|
import { CONFIG_KEYS, IPC_EVENTS } from '@lib/constants'
|
||||||
import { debounce, simpleCloneDeep } from '@lib/utils'
|
import { debounce } from '@lib/utils'
|
||||||
import * as fs from 'fs'
|
|
||||||
import * as path from 'path'
|
|
||||||
|
|
||||||
import logManager from '@electron/service/logger'
|
import logManager from '@electron/service/logger'
|
||||||
|
|
||||||
@@ -15,33 +13,47 @@ const DEFAULT_CONFIG: IConfig = {
|
|||||||
[CONFIG_KEYS.MINIMIZE_TO_TRAY]: false,
|
[CONFIG_KEYS.MINIMIZE_TO_TRAY]: false,
|
||||||
[CONFIG_KEYS.PROVIDER]: '',
|
[CONFIG_KEYS.PROVIDER]: '',
|
||||||
[CONFIG_KEYS.DEFAULT_MODEL]: null,
|
[CONFIG_KEYS.DEFAULT_MODEL]: null,
|
||||||
|
[CONFIG_KEYS.SELECTED_CHANNELS]: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConfigService {
|
export class ConfigService {
|
||||||
private static _instance: ConfigService;
|
private static _instance: ConfigService;
|
||||||
private _config: IConfig;
|
private _store: any;
|
||||||
private _configPath: string;
|
|
||||||
private _defaultConfig: IConfig = DEFAULT_CONFIG;
|
private _defaultConfig: IConfig = DEFAULT_CONFIG;
|
||||||
|
|
||||||
private _listeners: Array<(config: IConfig) => void> = [];
|
private _listeners: Array<(config: IConfig) => void> = [];
|
||||||
|
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
// 获取配置文件路径
|
// store 通过 init() 异步初始化,避免 ESM 模块在 CJS 打包环境中同步导入的问题
|
||||||
this._configPath = path.join(app.getPath('userData'), 'config.json');
|
}
|
||||||
// 加载配置
|
|
||||||
this._config = this._loadConfig();
|
public async init() {
|
||||||
// 设置 IPC 事件
|
if (this._store) return;
|
||||||
|
const { default: Store } = await import('electron-store');
|
||||||
|
this._store = new Store<IConfig>({
|
||||||
|
name: 'config',
|
||||||
|
defaults: DEFAULT_CONFIG,
|
||||||
|
});
|
||||||
this._setupIpcEvents();
|
this._setupIpcEvents();
|
||||||
logManager.info('ConfigService initialized successfully.')
|
logManager.info('ConfigService initialized successfully.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _ensureStore() {
|
||||||
|
if (!this._store) {
|
||||||
|
throw new Error('ConfigService has not been initialized. Call init() first.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _setupIpcEvents() {
|
private _setupIpcEvents() {
|
||||||
const duration = 200;
|
const duration = 200;
|
||||||
const handelUpdate = debounce((val) => this.update(val), duration);
|
const handelUpdate = debounce((val) => this.update(val), duration);
|
||||||
|
|
||||||
ipcMain.handle(IPC_EVENTS.GET_CONFIG, (_, key) => this.get(key));
|
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));
|
ipcMain.on(IPC_EVENTS.UPDATE_CONFIG, (_, updates) => handelUpdate(updates));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,66 +64,51 @@ export class ConfigService {
|
|||||||
return this._instance;
|
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 {
|
private _notifyListeners(): void {
|
||||||
BrowserWindow.getAllWindows().forEach(win => win.webContents.send(IPC_EVENTS.CONFIG_UPDATED, this._config));
|
this._ensureStore();
|
||||||
this._listeners.forEach(listener => listener({ ...this._config }));
|
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 {
|
public getConfig(): IConfig {
|
||||||
return simpleCloneDeep(this._config);
|
if (!this._store) {
|
||||||
|
return { ...this._defaultConfig };
|
||||||
|
}
|
||||||
|
return this._store.store;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get<T = any>(key: ConfigKeys): T {
|
public get<T = any>(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 {
|
public set(key: ConfigKeys, value: unknown, autoSave: boolean = true): void {
|
||||||
if (!(key in this._config)) return;
|
this._ensureStore();
|
||||||
const oldValue = this._config[key];
|
const oldValue = this._store.get(key);
|
||||||
if (oldValue === value) return;
|
if (oldValue === value) return;
|
||||||
this._config[key] = value as never;
|
this._store.set(key, value);
|
||||||
logManager.debug(`Config set: ${key} = ${value}`);
|
logManager.info(`Config set: ${key} = ${JSON.stringify(value)}`);
|
||||||
autoSave && this._saveConfig();
|
logManager.info(`Config file path: ${this._store.path}`);
|
||||||
|
autoSave && this._notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
public update(updates: Partial<IConfig>, autoSave: boolean = true): void {
|
public update(updates: Partial<IConfig>, autoSave: boolean = true): void {
|
||||||
this._config = { ...this._config, ...updates };
|
this._ensureStore();
|
||||||
autoSave && this._saveConfig();
|
(Object.keys(updates) as ConfigKeys[]).forEach((key) => {
|
||||||
|
this._store.set(key, updates[key]);
|
||||||
|
});
|
||||||
|
autoSave && this._notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetToDefault(): void {
|
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.');
|
logManager.info('Config reset to default.');
|
||||||
this._saveConfig();
|
this._notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
public onConfigChange(listener: ((config: IConfig) => void)): () => void {
|
public onConfigChange(listener: ((config: IConfig) => void)): () => void {
|
||||||
|
|||||||
11
global.d.ts
vendored
11
global.d.ts
vendored
@@ -1,4 +1,5 @@
|
|||||||
import { IPC_EVENTS } from '@lib/constants'
|
import { IPC_EVENTS } from '@lib/constants'
|
||||||
|
import type { ConfigKeys } from '@lib/types'
|
||||||
import type { AutomationScript, ScriptSaveInput, ScriptExecutionResult } from '@lib/script-types'
|
import type { AutomationScript, ScriptSaveInput, ScriptExecutionResult } from '@lib/script-types'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -61,6 +62,16 @@ declare global {
|
|||||||
return: Promise<ThemeMode>
|
return: Promise<ThemeMode>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
[IPC_EVENTS.GET_CONFIG]: {
|
||||||
|
params: [key: ConfigKeys]
|
||||||
|
return: Promise<any>
|
||||||
|
}
|
||||||
|
[IPC_EVENTS.SET_CONFIG]: {
|
||||||
|
params: [key: ConfigKeys, value: any]
|
||||||
|
return: Promise<{ success: boolean }>
|
||||||
|
}
|
||||||
|
|
||||||
// 脚本管理
|
// 脚本管理
|
||||||
[IPC_EVENTS.SCRIPT_LIST]: {
|
[IPC_EVENTS.SCRIPT_LIST]: {
|
||||||
params: []
|
params: []
|
||||||
|
|||||||
@@ -1,25 +1,41 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
id: string
|
id: string
|
||||||
channelName: string
|
channelName: string
|
||||||
channelUrl: string
|
channelUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const channels: Item[] = [
|
export const channelDictionary: Record<string, string> = {
|
||||||
{
|
fliggy: 'https://hotel.fliggy.com/ebooking/hotelBaseInfoUv.htm#/ebk/homeV1',
|
||||||
id: uuidv4(),
|
meituan: 'https://me.meituan.com/ebooking/merchant/product#/index',
|
||||||
channelName: 'fliggy',
|
douyin: 'https://life.douyin.com/p/travel-ari/hotel/price_amount_state?groupid=1816249020842116',
|
||||||
channelUrl: 'https://hotel.fliggy.com/ebooking/hotelBaseInfoUv.htm#/ebk/homeV1',
|
}
|
||||||
},
|
|
||||||
{
|
export function resolveChannel(value: string): { name?: string; url: string } {
|
||||||
id: uuidv4(),
|
const trimmed = value.trim()
|
||||||
channelName: 'meituan',
|
if (!trimmed) {
|
||||||
channelUrl: 'https://me.meituan.com/ebooking/merchant/product#/index',
|
return { url: '' }
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: uuidv4(),
|
// 如果是已知渠道名称
|
||||||
channelName: 'douyin',
|
if (channelDictionary[trimmed]) {
|
||||||
channelUrl: 'https://life.douyin.com/p/travel-ari/hotel/price_amount_state?groupid=1816249020842116',
|
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 }
|
||||||
}
|
}
|
||||||
]
|
|
||||||
@@ -102,6 +102,7 @@ export enum CONFIG_KEYS {
|
|||||||
DEFAULT_MODEL = 'defaultModel',
|
DEFAULT_MODEL = 'defaultModel',
|
||||||
AUTO_CHECK_UPDATE = 'autoCheckUpdate',
|
AUTO_CHECK_UPDATE = 'autoCheckUpdate',
|
||||||
AUTO_DOWNLOAD_UPDATE = 'autoDownloadUpdate',
|
AUTO_DOWNLOAD_UPDATE = 'autoDownloadUpdate',
|
||||||
|
SELECTED_CHANNELS = 'selectedChannels',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MENU_IDS {
|
export enum MENU_IDS {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface IConfig {
|
|||||||
[CONFIG_KEYS.PROVIDER]?: string;
|
[CONFIG_KEYS.PROVIDER]?: string;
|
||||||
// 默认模型
|
// 默认模型
|
||||||
[CONFIG_KEYS.DEFAULT_MODEL]?: string | null;
|
[CONFIG_KEYS.DEFAULT_MODEL]?: string | null;
|
||||||
|
// 选中的渠道
|
||||||
|
[CONFIG_KEYS.SELECTED_CHANNELS]: Array<{ id: string; channelName: string; channelUrl: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Provider {
|
export interface Provider {
|
||||||
|
|||||||
@@ -34,18 +34,24 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import { Settings } from '@lucide/vue'
|
import { Settings } from '@lucide/vue'
|
||||||
import { taskCenterList, taskCenterItem } from '@constant/taskCenterList'
|
import { taskCenterList, taskCenterItem } from '@constant/taskCenterList'
|
||||||
import { channels } from '@constant/channel'
|
import { useChannelStore } from '@stores/channel'
|
||||||
import emitter from '@utils/emitter'
|
import emitter from '@utils/emitter'
|
||||||
|
|
||||||
|
const channelStore = useChannelStore()
|
||||||
const taskList = computed(() => taskCenterList)
|
const taskList = computed(() => taskCenterList)
|
||||||
|
|
||||||
// 点击任务项
|
// 点击任务项
|
||||||
const handleTaskItem = (item: taskCenterItem) => {
|
const handleTaskItem = (item: taskCenterItem) => {
|
||||||
// 一键打开各渠道
|
// 一键打开各渠道
|
||||||
if (item.type === 'channel') {
|
if (item.type === 'channel') {
|
||||||
window.api.openChannel(channels.value)
|
if (!channelStore.selectedChannels || channelStore.selectedChannels.length === 0) {
|
||||||
|
ElMessage.warning('请先配置渠道')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.api.openChannel(JSON.parse(JSON.stringify(channelStore.selectedChannels)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,120 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog v-model="isVisible" width="480" align-center class="dark-dialog">
|
<el-dialog
|
||||||
<template #title>
|
v-model="isVisible"
|
||||||
<span>添加渠道</span>
|
width="560"
|
||||||
</template>
|
align-center
|
||||||
<el-form :model="form" :rules="rules" ref="formRef" label-position="top" class="pl-4 pr-4 pt-4 dark-form">
|
class="custom-script-dialog"
|
||||||
<el-form-item prop="channelName">
|
:show-close="false"
|
||||||
<template #label>
|
@closed="handleClosed"
|
||||||
<span>渠道名称</span>
|
>
|
||||||
</template>
|
<template #header>
|
||||||
<el-input v-model="form.channelName" placeholder="请输入渠道名称" />
|
<div class="sticky top-0 z-10 bg-[#F4F3EB] dark:bg-[#1f1f22] flex justify-between items-start">
|
||||||
</el-form-item>
|
<div>
|
||||||
<el-form-item prop="channelUrl">
|
<h2 class="text-[20px] font-serif text-[#171717] dark:text-[#f3f4f6] font-normal tracking-tight">
|
||||||
<template #label>
|
关联渠道
|
||||||
<span>渠道链接</span>
|
</h2>
|
||||||
</template>
|
</div>
|
||||||
<el-input v-model="form.channelUrl" placeholder="请输入渠道链接" />
|
<button @click="close" class="text-[#99A0AE] dark:text-gray-500 hover:text-[#171717] dark:hover:text-[#f3f4f6] transition-colors mt-[4px]">
|
||||||
</el-form-item>
|
<el-icon class="text-[20px] cursor-pointer"><Close /></el-icon>
|
||||||
</el-form>
|
</button>
|
||||||
<template #footer>
|
|
||||||
<div class="dialog-footer">
|
|
||||||
<el-button class="cancel-btn" @click="cancel">取消</el-button>
|
|
||||||
<el-button type="primary" @click="confirm">确认</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<div class="px-[24px] pb-[24px] pt-[8px] space-y-5">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-[14px] text-[#171717]/80 dark:text-[#f3f4f6]/80 font-bold mb-2">搜索添加渠道</div>
|
||||||
|
<el-autocomplete
|
||||||
|
v-if="channelStore.availableChannels.length"
|
||||||
|
v-model="searchQuery"
|
||||||
|
:fetch-suggestions="querySearch"
|
||||||
|
clearable
|
||||||
|
placeholder="输入渠道名称或链接"
|
||||||
|
class="w-full"
|
||||||
|
@select="handleSelect"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<div class="flex flex-col py-1">
|
||||||
|
<span class="text-[13px] font-medium text-[#171717] dark:text-[#f3f4f6]">{{ item.channelName }}</span>
|
||||||
|
<span class="text-[12px] text-[#99A0AE] dark:text-gray-500 truncate">{{ item.channelUrl }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-autocomplete>
|
||||||
|
<div v-else class="text-[13px] text-[#99A0AE] dark:text-gray-500 py-2">
|
||||||
|
暂无可用渠道
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected list -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-[14px] text-[#171717]/80 dark:text-[#f3f4f6]/80 font-bold mb-2">已选渠道</div>
|
||||||
|
<div
|
||||||
|
v-if="localSelected.length > 0"
|
||||||
|
class="space-y-2 max-h-[240px] overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="item in localSelected"
|
||||||
|
:key="item.id"
|
||||||
|
class="flex items-center justify-between gap-3 bg-[#E8E6DE]/50 dark:bg-[#222225] p-3 rounded-xl border border-black/5 dark:border-[#2a2a2d]"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-[13px] font-medium text-[#171717] dark:text-[#f3f4f6] truncate">
|
||||||
|
{{ item.channelName }}
|
||||||
|
</div>
|
||||||
|
<el-tooltip :content="item.channelUrl" placement="top" :show-after="300">
|
||||||
|
<div class="text-[12px] text-[#99A0AE] dark:text-gray-500 truncate cursor-default">
|
||||||
|
{{ item.channelUrl }}
|
||||||
|
</div>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="shrink-0 text-[#99A0AE] dark:text-gray-500 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||||
|
@click="removeItem(item.id)"
|
||||||
|
>
|
||||||
|
<el-icon class="text-[18px]"><Delete /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-[13px] text-[#99A0AE] dark:text-gray-500 py-4 text-center bg-[#E8E6DE]/30 dark:bg-[#222225]/50 rounded-xl border border-dashed border-black/5 dark:border-[#2a2a2d]">
|
||||||
|
未选择任何渠道
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<el-button
|
||||||
|
@click="cancel"
|
||||||
|
class="!rounded-full !px-6 !h-[40px] !text-[13px] !font-semibold cancel-btn"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="confirm"
|
||||||
|
class="!rounded-full !px-6 !h-[40px] !text-[13px] !font-semibold"
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { Close, Delete } from '@element-plus/icons-vue'
|
||||||
|
import { useChannelStore, type ChannelItem } from '@stores/channel'
|
||||||
|
|
||||||
|
const channelStore = useChannelStore()
|
||||||
|
|
||||||
const isVisible = ref(false)
|
const isVisible = ref(false)
|
||||||
const formRef = ref()
|
const searchQuery = ref('')
|
||||||
const form = ref({
|
const localSelected = ref<ChannelItem[]>([])
|
||||||
channelName: '',
|
|
||||||
channelUrl: ''
|
|
||||||
})
|
|
||||||
const rules = ref({
|
|
||||||
channelName: [
|
|
||||||
{ required: true, message: '请输入渠道名称', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
channelUrl: [
|
|
||||||
{ required: true, message: '请输入渠道链接', trigger: 'blur' },
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const open = () => {
|
const open = async () => {
|
||||||
|
await channelStore.loadSelectedChannels()
|
||||||
|
localSelected.value = channelStore.selectedChannels.map((item) => ({ ...item }))
|
||||||
|
searchQuery.value = ''
|
||||||
isVisible.value = true
|
isVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,24 +123,46 @@ const close = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
form.value.channelName = ''
|
searchQuery.value = ''
|
||||||
form.value.channelUrl = ''
|
localSelected.value = []
|
||||||
formRef.value?.resetFields()
|
}
|
||||||
|
|
||||||
|
const handleClosed = () => {
|
||||||
|
reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
close()
|
close()
|
||||||
reset()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirm = () => {
|
const confirm = async () => {
|
||||||
formRef.value.validate((valid: boolean) => {
|
channelStore.setSelectedChannels(localSelected.value.map((item) => ({ ...item })))
|
||||||
if (!valid) {
|
await channelStore.saveSelectedChannels()
|
||||||
return
|
|
||||||
}
|
|
||||||
close()
|
close()
|
||||||
reset()
|
}
|
||||||
})
|
|
||||||
|
const querySearch = (queryString: string, cb: (results: ChannelItem[]) => void) => {
|
||||||
|
const list = channelStore.availableChannels
|
||||||
|
const query = queryString.trim().toLowerCase()
|
||||||
|
const results = query
|
||||||
|
? list.filter(
|
||||||
|
(item) =>
|
||||||
|
item.channelName.toLowerCase().includes(query) ||
|
||||||
|
item.channelUrl.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
: list
|
||||||
|
cb(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = (item: ChannelItem) => {
|
||||||
|
if (!localSelected.value.some((c) => c.channelUrl === item.channelUrl)) {
|
||||||
|
localSelected.value.push({ ...item })
|
||||||
|
}
|
||||||
|
searchQuery.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
localSelected.value = localSelected.value.filter((c) => c.id !== id)
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
@@ -79,105 +171,98 @@ defineExpose({
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style>
|
||||||
.dark-dialog {
|
.custom-script-dialog {
|
||||||
background-color: #F4F3EB !important;
|
background-color: #F4F3EB !important;
|
||||||
border-radius: 20px !important;
|
border-radius: 20px !important;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15) !important;
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15) !important;
|
||||||
}
|
}
|
||||||
|
.dark .custom-script-dialog {
|
||||||
.dark .dark-dialog {
|
|
||||||
background-color: #1f1f22 !important;
|
background-color: #1f1f22 !important;
|
||||||
}
|
}
|
||||||
|
.custom-script-dialog .el-dialog__body {
|
||||||
.dark-dialog :deep(.el-dialog__header) {
|
|
||||||
margin-right: 0;
|
|
||||||
padding: 20px 24px 16px;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .dark-dialog :deep(.el-dialog__header) {
|
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark-dialog :deep(.el-dialog__title) {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .dark-dialog :deep(.el-dialog__title) {
|
|
||||||
color: #f3f4f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark-dialog :deep(.el-dialog__body) {
|
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
|
max-height: calc(100vh - 360px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.custom-script-dialog .el-dialog__body::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.custom-script-dialog .el-dialog__body::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #D1CFC7;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.dark .custom-script-dialog .el-dialog__body::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #2a2a2d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark-form :deep(.el-form-item__label) {
|
/* Input styling */
|
||||||
font-weight: 500;
|
.custom-script-dialog .el-input__wrapper,
|
||||||
color: #4B4B4B !important;
|
.custom-script-dialog .el-autocomplete .el-input__wrapper {
|
||||||
}
|
|
||||||
|
|
||||||
.dark .dark-form :deep(.el-form-item__label) {
|
|
||||||
color: #9ca3af !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark-form :deep(.el-input__wrapper) {
|
|
||||||
background-color: #EDECE4 !important;
|
background-color: #EDECE4 !important;
|
||||||
border-radius: 12px !important;
|
border-radius: 12px !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
border: 1px solid transparent !important;
|
border: 1px solid transparent !important;
|
||||||
}
|
|
||||||
|
|
||||||
.dark .dark-form :deep(.el-input__wrapper) {
|
|
||||||
background-color: #222225 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark-form :deep(.el-input__wrapper.is-focus) {
|
|
||||||
border-color: #3B6DE8 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark-form :deep(.el-input__inner) {
|
|
||||||
color: #171717 !important;
|
color: #171717 !important;
|
||||||
}
|
}
|
||||||
|
.dark .custom-script-dialog .el-input__wrapper,
|
||||||
.dark .dark-form :deep(.el-input__inner) {
|
.dark .custom-script-dialog .el-autocomplete .el-input__wrapper {
|
||||||
|
background-color: #222225 !important;
|
||||||
color: #f3f4f6 !important;
|
color: #f3f4f6 !important;
|
||||||
}
|
}
|
||||||
|
.custom-script-dialog .el-input__wrapper.is-focus,
|
||||||
.dark-form :deep(.el-input__inner::placeholder) {
|
.custom-script-dialog .el-autocomplete .el-input__wrapper.is-focus {
|
||||||
|
border-color: #3B6DE8 !important;
|
||||||
|
}
|
||||||
|
.custom-script-dialog .el-input__inner {
|
||||||
|
color: #171717 !important;
|
||||||
|
}
|
||||||
|
.dark .custom-script-dialog .el-input__inner {
|
||||||
|
color: #f3f4f6 !important;
|
||||||
|
}
|
||||||
|
.custom-script-dialog .el-input__inner::placeholder {
|
||||||
color: #99A0AE !important;
|
color: #99A0AE !important;
|
||||||
}
|
}
|
||||||
|
.dark .custom-script-dialog .el-input__inner::placeholder {
|
||||||
.dark .dark-form :deep(.el-input__inner::placeholder) {
|
|
||||||
color: #6b7280 !important;
|
color: #6b7280 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-footer {
|
/* Autocomplete dropdown */
|
||||||
padding: 16px 24px 20px;
|
.custom-script-dialog .el-autocomplete-suggestion {
|
||||||
display: flex;
|
background-color: #F4F3EB !important;
|
||||||
justify-content: flex-end;
|
border-radius: 12px !important;
|
||||||
gap: 12px;
|
}
|
||||||
|
.dark .custom-script-dialog .el-autocomplete-suggestion {
|
||||||
|
background-color: #1f1f22 !important;
|
||||||
|
}
|
||||||
|
.custom-script-dialog .el-autocomplete-suggestion__wrap {
|
||||||
|
padding: 8px !important;
|
||||||
|
}
|
||||||
|
.custom-script-dialog .el-autocomplete-suggestion__list li {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.custom-script-dialog .el-autocomplete-suggestion__list li:hover {
|
||||||
|
background-color: #E8E6DE !important;
|
||||||
|
}
|
||||||
|
.dark .custom-script-dialog .el-autocomplete-suggestion__list li:hover {
|
||||||
|
background-color: #222225 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cancel button */
|
||||||
.cancel-btn {
|
.cancel-btn {
|
||||||
background-color: #EDECE4 !important;
|
background-color: #EDECE4 !important;
|
||||||
border-color: transparent !important;
|
border-color: transparent !important;
|
||||||
color: #4B4B4B !important;
|
color: #4B4B4B !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancel-btn:hover {
|
.cancel-btn:hover {
|
||||||
background-color: #E5E4DC !important;
|
background-color: #E5E4DC !important;
|
||||||
color: #171717 !important;
|
color: #171717 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .cancel-btn {
|
.dark .cancel-btn {
|
||||||
background-color: #222225 !important;
|
background-color: #222225 !important;
|
||||||
color: #9ca3af !important;
|
color: #9ca3af !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .cancel-btn:hover {
|
.dark .cancel-btn:hover {
|
||||||
background-color: #2a2a2d !important;
|
background-color: #2a2a2d !important;
|
||||||
color: #f3f4f6 !important;
|
color: #f3f4f6 !important;
|
||||||
|
|||||||
@@ -25,10 +25,14 @@ import ChatBox from './ChatBox.vue'
|
|||||||
import TaskCenter from './TaskCenter.vue'
|
import TaskCenter from './TaskCenter.vue'
|
||||||
import { useChatStore } from '@stores/chat'
|
import { useChatStore } from '@stores/chat'
|
||||||
import { useProviderStore } from '@stores/providers'
|
import { useProviderStore } from '@stores/providers'
|
||||||
|
import { useChannelStore } from '@stores/channel'
|
||||||
|
import { useScriptStore } from '@stores/script'
|
||||||
import emitter from '@src/utils/emitter'
|
import emitter from '@src/utils/emitter'
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const providerStore = useProviderStore()
|
const providerStore = useProviderStore()
|
||||||
|
const channelStore = useChannelStore()
|
||||||
|
const scriptStore = useScriptStore()
|
||||||
const taskOperationDialog = ref()
|
const taskOperationDialog = ref()
|
||||||
const addChannelDialog = ref()
|
const addChannelDialog = ref()
|
||||||
|
|
||||||
@@ -36,6 +40,8 @@ onMounted(async () => {
|
|||||||
await providerStore.init()
|
await providerStore.init()
|
||||||
chatStore.loadSessions()
|
chatStore.loadSessions()
|
||||||
chatStore.subscribeToGateway()
|
chatStore.subscribeToGateway()
|
||||||
|
await scriptStore.fetchScripts()
|
||||||
|
await channelStore.loadSelectedChannels()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ import { javascript } from '@codemirror/lang-javascript';
|
|||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
import type { AutomationScript, ScriptSaveInput } from '@lib/script-types';
|
import type { AutomationScript, ScriptSaveInput } from '@lib/script-types';
|
||||||
import { useScriptStore } from '@src/stores/script';
|
import { useScriptStore } from '@src/stores/script';
|
||||||
import { channels } from '@constant/channel';
|
import { channelDictionary } from '@constant/channel';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useScriptStore();
|
const store = useScriptStore();
|
||||||
@@ -197,16 +197,15 @@ watch(() => props.script, (script) => {
|
|||||||
|
|
||||||
watch(() => form.value.channel, (newUrl) => {
|
watch(() => form.value.channel, (newUrl) => {
|
||||||
if (!newUrl) return;
|
if (!newUrl) return;
|
||||||
channels.forEach((c: any) => {
|
Object.values(channelDictionary).forEach((url) => {
|
||||||
if (form.value.code.includes(c.channelUrl)) {
|
if (form.value.code.includes(url)) {
|
||||||
form.value.code = form.value.code.split(c.channelUrl).join(newUrl);
|
form.value.code = form.value.code.split(url).join(newUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function getChannelUrl(channel: string): string | undefined {
|
function getChannelUrl(channel: string): string | undefined {
|
||||||
const item = channels.find((c) => c.channelName === channel);
|
return channelDictionary[channel];
|
||||||
return item?.channelUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStartRecording() {
|
async function handleStartRecording() {
|
||||||
|
|||||||
85
src/stores/channel.ts
Normal file
85
src/stores/channel.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useScriptStore } from '@stores/script'
|
||||||
|
import { resolveChannel } from '@constant/channel'
|
||||||
|
import { IPC_EVENTS, CONFIG_KEYS } from '@lib/constants'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
|
export interface ChannelItem {
|
||||||
|
id: string
|
||||||
|
channelName: string
|
||||||
|
channelUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChannelStore = defineStore('channel', () => {
|
||||||
|
const scriptStore = useScriptStore()
|
||||||
|
|
||||||
|
// 用户选中的"一键打开"渠道(持久化到 electron-store)
|
||||||
|
const selectedChannels = ref<ChannelItem[]>([])
|
||||||
|
|
||||||
|
// 从脚本 store 动态聚合可用渠道(按 URL 去重)
|
||||||
|
const availableChannels = computed<ChannelItem[]>(() => {
|
||||||
|
const map = new Map<string, ChannelItem>()
|
||||||
|
for (const script of scriptStore.safeScripts) {
|
||||||
|
if (!script.channel) continue
|
||||||
|
const resolved = resolveChannel(script.channel)
|
||||||
|
const url = resolved.url
|
||||||
|
if (url && !map.has(url)) {
|
||||||
|
map.set(url, {
|
||||||
|
id: uuidv4(),
|
||||||
|
channelName: resolved.name || url,
|
||||||
|
channelUrl: url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(map.values())
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadSelectedChannels = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.api.invoke(IPC_EVENTS.GET_CONFIG, CONFIG_KEYS.SELECTED_CHANNELS)
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
selectedChannels.value = result
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[channelStore] loadSelectedChannels failed:', err)
|
||||||
|
}
|
||||||
|
selectedChannels.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSelectedChannels = async () => {
|
||||||
|
try {
|
||||||
|
// 深拷贝剥离 Vue Proxy,避免 IPC structured clone 失败
|
||||||
|
const payload = JSON.parse(JSON.stringify(selectedChannels.value))
|
||||||
|
await window.api.invoke(IPC_EVENTS.SET_CONFIG, CONFIG_KEYS.SELECTED_CHANNELS, payload)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[channelStore] saveSelectedChannels failed:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSelectedChannel = (item: ChannelItem) => {
|
||||||
|
if (!selectedChannels.value.some((c) => c.channelUrl === item.channelUrl)) {
|
||||||
|
selectedChannels.value.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeSelectedChannel = (id: string) => {
|
||||||
|
selectedChannels.value = selectedChannels.value.filter((c) => c.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSelectedChannels = (items: ChannelItem[]) => {
|
||||||
|
selectedChannels.value = items
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedChannels,
|
||||||
|
availableChannels,
|
||||||
|
loadSelectedChannels,
|
||||||
|
saveSelectedChannels,
|
||||||
|
addSelectedChannel,
|
||||||
|
removeSelectedChannel,
|
||||||
|
setSelectedChannels,
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user