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,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
}
close()
reset()
})
const confirm = async () => {
channelStore.setSelectedChannels(localSelected.value.map((item) => ({ ...item })))
await channelStore.saveSelectedChannels()
close()
}
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,
}
})