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:
DEV_DSW
2026-04-16 15:13:30 +08:00
parent 7bd5a1aa20
commit 411f4f3421
15 changed files with 668 additions and 214 deletions

View File

@@ -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");

4
dist/index.html vendored
View File

@@ -8,8 +8,8 @@
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"
/>
<script type="module" crossorigin src="./assets/index-CfM9LZqc.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CAGR0NJ4.css">
<script type="module" crossorigin src="./assets/index-BmR_czvN.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DWCm8z3C.css">
</head>
<body>
<div id="app"></div>

238
docs/ChannelRefactorPlan.md Normal file
View 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。

View File

@@ -152,7 +152,9 @@ if (started) {
// logManager.error('unhandledRejection', reason, promise);
// });
app.whenReady().then(() => {
app.whenReady().then(async () => {
await configManager.init();
gatewayManager.init();
onProviderChange(() => {

View File

@@ -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' }

View File

@@ -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<IConfig>({
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<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 {
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<IConfig>, 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 {

11
global.d.ts vendored
View File

@@ -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<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]: {
params: []

View File

@@ -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<string, string> = {
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: '' }
}
// 如果是已知渠道名称
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 }
}
]

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -34,18 +34,24 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Settings } from '@lucide/vue'
import { taskCenterList, taskCenterItem } from '@constant/taskCenterList'
import { channels } from '@constant/channel'
import { useChannelStore } from '@stores/channel'
import emitter from '@utils/emitter'
const channelStore = useChannelStore()
const taskList = computed(() => taskCenterList)
// 点击任务项
const handleTaskItem = (item: taskCenterItem) => {
// 一键打开各渠道
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
}

View File

@@ -1,50 +1,120 @@
<template>
<el-dialog v-model="isVisible" width="480" align-center class="dark-dialog">
<template #title>
<span>添加渠道</span>
</template>
<el-form :model="form" :rules="rules" ref="formRef" label-position="top" class="pl-4 pr-4 pt-4 dark-form">
<el-form-item prop="channelName">
<template #label>
<span>渠道名称</span>
</template>
<el-input v-model="form.channelName" placeholder="请输入渠道名称" />
</el-form-item>
<el-form-item prop="channelUrl">
<template #label>
<span>渠道链接</span>
</template>
<el-input v-model="form.channelUrl" placeholder="请输入渠道链接" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button class="cancel-btn" @click="cancel">取消</el-button>
<el-button type="primary" @click="confirm">确认</el-button>
<el-dialog
v-model="isVisible"
width="560"
align-center
class="custom-script-dialog"
:show-close="false"
@closed="handleClosed"
>
<template #header>
<div class="sticky top-0 z-10 bg-[#F4F3EB] dark:bg-[#1f1f22] flex justify-between items-start">
<div>
<h2 class="text-[20px] font-serif text-[#171717] dark:text-[#f3f4f6] font-normal tracking-tight">
关联渠道
</h2>
</div>
<button @click="close" class="text-[#99A0AE] dark:text-gray-500 hover:text-[#171717] dark:hover:text-[#f3f4f6] transition-colors mt-[4px]">
<el-icon class="text-[20px] cursor-pointer"><Close /></el-icon>
</button>
</div>
</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>
</template>
<script setup lang="ts">
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 formRef = ref()
const form = ref({
channelName: '',
channelUrl: ''
})
const rules = ref({
channelName: [
{ required: true, message: '请输入渠道名称', trigger: 'blur' },
],
channelUrl: [
{ required: true, message: '请输入渠道链接', trigger: 'blur' },
]
})
const searchQuery = ref('')
const localSelected = ref<ChannelItem[]>([])
const open = () => {
const open = async () => {
await channelStore.loadSelectedChannels()
localSelected.value = channelStore.selectedChannels.map((item) => ({ ...item }))
searchQuery.value = ''
isVisible.value = true
}
@@ -53,24 +123,46 @@ const close = () => {
}
const reset = () => {
form.value.channelName = ''
form.value.channelUrl = ''
formRef.value?.resetFields()
searchQuery.value = ''
localSelected.value = []
}
const handleClosed = () => {
reset()
}
const cancel = () => {
close()
reset()
}
const confirm = () => {
formRef.value.validate((valid: boolean) => {
if (!valid) {
return
}
const confirm = async () => {
channelStore.setSelectedChannels(localSelected.value.map((item) => ({ ...item })))
await channelStore.saveSelectedChannels()
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({
@@ -79,105 +171,98 @@ defineExpose({
})
</script>
<style scoped>
.dark-dialog {
<style>
.custom-script-dialog {
background-color: #F4F3EB !important;
border-radius: 20px !important;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15) !important;
}
.dark .dark-dialog {
.dark .custom-script-dialog {
background-color: #1f1f22 !important;
}
.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) {
.custom-script-dialog .el-dialog__body {
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) {
font-weight: 500;
color: #4B4B4B !important;
}
.dark .dark-form :deep(.el-form-item__label) {
color: #9ca3af !important;
}
.dark-form :deep(.el-input__wrapper) {
/* Input styling */
.custom-script-dialog .el-input__wrapper,
.custom-script-dialog .el-autocomplete .el-input__wrapper {
background-color: #EDECE4 !important;
border-radius: 12px !important;
box-shadow: none !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;
}
.dark .dark-form :deep(.el-input__inner) {
.dark .custom-script-dialog .el-input__wrapper,
.dark .custom-script-dialog .el-autocomplete .el-input__wrapper {
background-color: #222225 !important;
color: #f3f4f6 !important;
}
.dark-form :deep(.el-input__inner::placeholder) {
.custom-script-dialog .el-input__wrapper.is-focus,
.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;
}
.dark .dark-form :deep(.el-input__inner::placeholder) {
.dark .custom-script-dialog .el-input__inner::placeholder {
color: #6b7280 !important;
}
.dialog-footer {
padding: 16px 24px 20px;
display: flex;
justify-content: flex-end;
gap: 12px;
/* Autocomplete dropdown */
.custom-script-dialog .el-autocomplete-suggestion {
background-color: #F4F3EB !important;
border-radius: 12px !important;
}
.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 {
background-color: #EDECE4 !important;
border-color: transparent !important;
color: #4B4B4B !important;
}
.cancel-btn:hover {
background-color: #E5E4DC !important;
color: #171717 !important;
}
.dark .cancel-btn {
background-color: #222225 !important;
color: #9ca3af !important;
}
.dark .cancel-btn:hover {
background-color: #2a2a2d !important;
color: #f3f4f6 !important;

View File

@@ -25,10 +25,14 @@ import ChatBox from './ChatBox.vue'
import TaskCenter from './TaskCenter.vue'
import { useChatStore } from '@stores/chat'
import { useProviderStore } from '@stores/providers'
import { useChannelStore } from '@stores/channel'
import { useScriptStore } from '@stores/script'
import emitter from '@src/utils/emitter'
const chatStore = useChatStore()
const providerStore = useProviderStore()
const channelStore = useChannelStore()
const scriptStore = useScriptStore()
const taskOperationDialog = ref()
const addChannelDialog = ref()
@@ -36,6 +40,8 @@ onMounted(async () => {
await providerStore.init()
chatStore.loadSessions()
chatStore.subscribeToGateway()
await scriptStore.fetchScripts()
await channelStore.loadSelectedChannels()
})
onBeforeUnmount(() => {

View File

@@ -150,7 +150,7 @@ import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
import type { AutomationScript, ScriptSaveInput } from '@lib/script-types';
import { useScriptStore } from '@src/stores/script';
import { channels } from '@constant/channel';
import { channelDictionary } from '@constant/channel';
const { t } = useI18n();
const store = useScriptStore();
@@ -197,16 +197,15 @@ watch(() => props.script, (script) => {
watch(() => form.value.channel, (newUrl) => {
if (!newUrl) return;
channels.forEach((c: any) => {
if (form.value.code.includes(c.channelUrl)) {
form.value.code = form.value.code.split(c.channelUrl).join(newUrl);
Object.values(channelDictionary).forEach((url) => {
if (form.value.code.includes(url)) {
form.value.code = form.value.code.split(url).join(newUrl);
}
});
});
function getChannelUrl(channel: string): string | undefined {
const item = channels.find((c) => c.channelName === channel);
return item?.channelUrl;
return channelDictionary[channel];
}
async function handleStartRecording() {

85
src/stores/channel.ts Normal file
View 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,
}
})