diff --git a/frontend/src/assets/img/integration-green.svg b/frontend/src/assets/img/integration-green.svg new file mode 100644 index 00000000..5701e72f --- /dev/null +++ b/frontend/src/assets/img/integration-green.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/img/integration.svg b/frontend/src/assets/img/integration.svg new file mode 100644 index 00000000..35ff4d68 --- /dev/null +++ b/frontend/src/assets/img/integration.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/AgentEmbedChannelPanel.vue b/frontend/src/components/AgentEmbedChannelPanel.vue index 37408d40..5a3f9bff 100644 --- a/frontend/src/components/AgentEmbedChannelPanel.vue +++ b/frontend/src/components/AgentEmbedChannelPanel.vue @@ -20,7 +20,7 @@ @click="openDrawer(ch)" >
- +
@@ -75,7 +75,7 @@ v-model:visible="showDrawer" :title="drawerTitle" :description="drawerDescription" - icon="internet" + icon="code" storage-key="setting-drawer:embed-channel" :confirm-loading="saving" :hide-footer="!isAdmin" @@ -85,6 +85,16 @@

{{ $t('embedPublish.sectionChannel') }}

+
+ + +
+
{{ $t('embedPublish.enabled') }} @@ -182,7 +192,7 @@

{{ $t('embedPublish.allowWebSearchDesc') }}

-

+

{{ $t('embedPublish.agentWebSearchDisabledHint') }}

@@ -199,7 +209,7 @@

{{ $t('embedPublish.allowFileUploadDesc') }}

-

+

{{ $t('embedPublish.agentImageUploadDisabledHint') }}

@@ -477,6 +487,7 @@ import SettingDrawer from '@/components/settings/SettingDrawer.vue' import EmbedChannelPreview from '@/components/EmbedChannelPreview.vue' import { listEmbedChannels, + listAllEmbedChannels, createEmbedChannel, updateEmbedChannel, deleteEmbedChannel, @@ -498,12 +509,17 @@ import { validateAllowedOrigins, type AllowedOriginsValidationError, } from '@/utils/embedAllowedOrigins' +import { listAgents, type CustomAgent } from '@/api/agent' -const props = defineProps<{ - agentId: string +const props = withDefaults(defineProps<{ + agentId?: string + tenantMode?: boolean + initialAgentId?: string agentWebSearchEnabled?: boolean agentImageUploadEnabled?: boolean -}>() +}>(), { + tenantMode: false, +}) const { t } = useI18n() const authStore = useAuthStore() @@ -530,6 +546,38 @@ const originsError = ref('') const drawerSnippetTab = ref<'iframe' | 'widget' | 'secure'>('iframe') const secureServerLangTab = ref<'node' | 'go'>('node') const sessionStats = ref>({}) +const agents = ref([]) +const createAgentId = ref('') + +const agentOptions = computed(() => + agents.value.map((agent) => ({ label: agent.name, value: agent.id })), +) + +const drawerAgentId = computed(() => { + if (!props.tenantMode) return props.agentId || '' + if (editingId.value) { + return drawerChannel.value?.agent_id || '' + } + return createAgentId.value +}) + +const drawerAgent = computed(() => + agents.value.find((agent) => agent.id === drawerAgentId.value), +) + +const agentWebSearchEnabledEffective = computed(() => { + if (props.tenantMode) { + return drawerAgent.value?.config?.web_search_enabled === true + } + return props.agentWebSearchEnabled === true +}) + +const agentImageUploadEnabledEffective = computed(() => { + if (props.tenantMode) { + return drawerAgent.value?.config?.image_upload_enabled === true + } + return props.agentImageUploadEnabled === true +}) const EMBED_TOKEN_STORAGE = 'weknora_embed_publish_tokens' const WEKNORA_BRAND_COLOR = '#07C05F' @@ -618,6 +666,10 @@ const channelSummary = (ch: EmbedChannel) => { const parts = [ `${t('embedPublish.rateLimit')} ${ch.rate_limit_per_minute || 30}${t('embedPublish.rateLimitUnit')}`, ] + if (props.tenantMode) { + const agentName = agents.value.find((agent) => agent.id === ch.agent_id)?.name + if (agentName) parts.unshift(agentName) + } if (ch.allowed_origins?.length) { parts.push(t('embedPublish.originsCount', { n: ch.allowed_origins.length })) } @@ -644,11 +696,20 @@ const storeToken = (channelId: string, token: string) => { } const load = async () => { - if (!props.agentId) return + if (!props.tenantMode && !props.agentId) return loading.value = true try { - const res = await listEmbedChannels(props.agentId) - channels.value = res?.data || [] + if (props.tenantMode) { + const [res, agentRes] = await Promise.all([ + listAllEmbedChannels(), + listAgents(), + ]) + channels.value = res?.data || [] + agents.value = agentRes?.data || [] + } else { + const res = await listEmbedChannels(props.agentId!) + channels.value = res?.data || [] + } tokenByChannel.value = { ...loadStoredTokens(), ...tokenByChannel.value } await Promise.all(channels.value.map(async (ch) => { try { @@ -665,8 +726,8 @@ const load = async () => { } } -watch(() => props.agentId, () => { - if (props.agentId) load() +watch(() => [props.tenantMode, props.agentId], () => { + if (props.tenantMode || props.agentId) load() }, { immediate: true }) const tokenFor = (ch: EmbedChannel) => tokenByChannel.value[ch.id] || ch.publish_token @@ -779,6 +840,7 @@ const fillFormFromChannel = (ch: EmbedChannel) => { const openCreate = () => { editingId.value = '' editingEnabled.value = true + createAgentId.value = props.tenantMode ? (props.initialAgentId || '') : '' form.value = defaultForm() originsText.value = '' originsError.value = '' @@ -854,7 +916,12 @@ const saveForm = async () => { const updated = channels.value.find((ch) => ch.id === editingId.value) ?? res?.data if (updated) fillFormFromChannel(updated) } else { - const res = await createEmbedChannel(props.agentId, payload) + const targetAgentId = props.tenantMode ? createAgentId.value : props.agentId + if (!targetAgentId) { + MessagePlugin.warning(t('integrations.selectAgentHint')) + return + } + const res = await createEmbedChannel(targetAgentId, payload) if (res?.data?.publish_token) { storeToken(res.data.id, res.data.publish_token) revealedTokens[res.data.id] = true diff --git a/frontend/src/components/IMChannelPanel.vue b/frontend/src/components/IMChannelPanel.vue index 3f6b1b66..6635a4f3 100644 --- a/frontend/src/components/IMChannelPanel.vue +++ b/frontend/src/components/IMChannelPanel.vue @@ -19,8 +19,14 @@ class="channel-card channel-card--clickable" @click="openDrawer(channel)" > -
- +
+ +
@@ -73,115 +79,238 @@ -
- -
- - - - {{ $t('agentEditor.im.wecom') }} - - - {{ $t('agentEditor.im.feishu') }} - - - {{ $t('agentEditor.im.slack') }} - - - {{ $t('agentEditor.im.telegram') }} - - - {{ $t('agentEditor.im.dingtalk') }} - - - {{ $t('agentEditor.im.mattermost') }} - - - {{ $t('agentEditor.im.wechat') }} - - -
+ - -
- - -
+ - -
- - - WebSocket - Webhook - -

{{ $t('agentEditor.im.mattermostModeHint') }}

-

{{ $t('agentEditor.im.modeHint') }}

+
+
+ + + + + {{ title }}
+
- -
- -
- +
+
+

{{ $t('agentEditor.im.sectionChannel') }}

+ +
+ + - - -
-
- -
- - - {{ $t('agentEditor.im.outputStream') }} - {{ $t('agentEditor.im.outputFull') }} - -
+
+ + + + +
+ + {{ item.label }} +
+
+
+
- -
- - - {{ $t('agentEditor.im.sessionModeUser') }} - - {{ $t('agentEditor.im.sessionModeThread') }} - - -

{{ $t('agentEditor.im.sessionModeHint') }}

-
+
+ + +

{{ $t('agentEditor.im.channelNameDefaultHint') }}

+
+
- -
- - - - -

{{ $t('agentEditor.im.fileKnowledgeBaseHint') }}

-
+
+

{{ $t('agentEditor.im.sectionStatus') }}

+
+
+ +
+
+ +
+
+
+
- -
+ +
+
+

{{ $t('agentEditor.im.sectionAccess') }}

+
+ +
+ + +
+

+ {{ formData.platform === 'mattermost' ? $t('agentEditor.im.mattermostModeHint') : $t('agentEditor.im.modeHint') }} +

+
+ +
+ +
+ + +
+
+
+ +
+

{{ $t('agentEditor.im.sectionSession') }}

+
+ +
+ + +
+

{{ $t('agentEditor.im.sessionModeHint') }}

+
+
+ +
+

{{ $t('agentEditor.im.sectionCallback') }}

+
+ +
+ + + + +
+
+
+
+ + +
+
+

{{ $t('agentEditor.im.sectionKnowledge') }}

+
+ + + + +

{{ $t('agentEditor.im.fileKnowledgeBaseHint') }}

+
+
+
+ + +
+
+

{{ $t('agentEditor.im.sectionCredentials') }}

+
@@ -336,7 +465,7 @@
-

{{ $t('agentEditor.im.dingtalkCardTemplateIdHint') }}

+

{{ $t('agentEditor.im.dingtalkCardTemplateIdHint') }}

@@ -365,20 +494,25 @@
-
-
- - +
+
+
+ +

{{ $t('agentEditor.im.mattermostPostToMainHint') }}

+
+
+ +
-

{{ $t('agentEditor.im.mattermostPostToMainHint') }}

+
+
@@ -430,30 +566,111 @@ import { useI18n } from 'vue-i18n'; import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next'; import { listIMChannels, createIMChannel, updateIMChannel, deleteIMChannel, toggleIMChannel, - getWeChatQRCode, pollWeChatQRCodeStatus, + getWeChatQRCode, pollWeChatQRCodeStatus, listAllIMChannels, listAgents, + type IMChannelOverview, type CustomAgent, } from '@/api/agent'; import { useChatResourcesStore } from '@/stores/chatResources'; import type { IMChannel } from '@/api/agent'; import { useAuthStore } from '@/stores/auth'; import SettingDrawer from '@/components/settings/SettingDrawer.vue'; +import wecomLogo from '@/assets/img/im/wecom.svg'; +import feishuLogo from '@/assets/img/im/feishu.svg'; +import slackLogo from '@/assets/img/im/slack.svg'; +import telegramLogo from '@/assets/img/im/telegram.svg'; +import dingtalkLogo from '@/assets/img/im/dingtalk.svg'; +import mattermostLogo from '@/assets/img/im/mattermost.svg'; +import wechatLogo from '@/assets/img/im/wechat.svg'; + +type IMPlatform = IMChannel['platform']; + +const PLATFORM_LOGO: Record = { + wecom: wecomLogo, + feishu: feishuLogo, + slack: slackLogo, + telegram: telegramLogo, + dingtalk: dingtalkLogo, + mattermost: mattermostLogo, + wechat: wechatLogo, +}; + +const platformLogo = (platform: string): string => (platform ? PLATFORM_LOGO[platform] || '' : ''); const { t } = useI18n(); const authStore = useAuthStore(); -const props = defineProps<{ - agentId: string; -}>(); +const props = withDefaults(defineProps<{ + agentId?: string; + tenantMode?: boolean; + initialAgentId?: string; +}>(), { + tenantMode: false, +}); -const channels = ref([]); +const agents = ref([]); +const agentOptions = computed(() => + agents.value.map((agent) => ({ label: agent.name, value: agent.id })), +); + +const channels = ref>([]); const loading = ref(false); const saving = ref(false); const showCreateDialog = ref(false); const editingChannel = ref(null); +const editingEnabled = ref(true); +const wizardStep = ref(0); +const channelNameTouched = ref(false); -const drawerTitle = computed(() => - editingChannel.value ? t('agentEditor.im.editChannel') : t('agentEditor.im.addChannel'), +const stepTitles = computed(() => [ + t('agentEditor.im.stepBasic'), + t('agentEditor.im.stepConnection'), + t('agentEditor.im.stepKnowledge'), + t('agentEditor.im.stepCredentials'), +]); + +const drawerStepDescription = computed(() => stepTitles.value[wizardStep.value] ?? ''); + +const drawerConfirmText = computed(() => + wizardStep.value < stepTitles.value.length - 1 ? t('common.next') : t('common.save'), ); +const platformOptions = computed(() => ([ + { value: 'wecom' as IMPlatform, label: t('agentEditor.im.wecom'), logo: wecomLogo }, + { value: 'feishu' as IMPlatform, label: t('agentEditor.im.feishu'), logo: feishuLogo }, + { value: 'slack' as IMPlatform, label: t('agentEditor.im.slack'), logo: slackLogo }, + { value: 'telegram' as IMPlatform, label: t('agentEditor.im.telegram'), logo: telegramLogo }, + { value: 'dingtalk' as IMPlatform, label: t('agentEditor.im.dingtalk'), logo: dingtalkLogo }, + { value: 'mattermost' as IMPlatform, label: t('agentEditor.im.mattermost'), logo: mattermostLogo }, + { value: 'wechat' as IMPlatform, label: t('agentEditor.im.wechat'), logo: wechatLogo }, +])); + +const drawerTitle = computed(() => { + if (editingChannel.value) { + return formData.value.name?.trim() || t('agentEditor.im.unnamed'); + } + return t('agentEditor.im.addChannel'); +}); + +function validateWizardStep(step: number): boolean { + if (step === 0 && props.tenantMode && !editingChannel.value && !formData.value.target_agent_id) { + MessagePlugin.warning(t('integrations.selectAgentHint')); + return false; + } + return true; +} + +function prevWizardStep() { + if (wizardStep.value > 0) wizardStep.value -= 1; +} + +async function handleDrawerConfirm() { + if (wizardStep.value < stepTitles.value.length - 1) { + if (!validateWizardStep(wizardStep.value)) return; + wizardStep.value += 1; + return; + } + await handleSave(); +} + // Knowledge base options for file-to-KB feature const knowledgeBases = ref<{ id: string; name: string }[]>([]); @@ -469,6 +686,7 @@ let wechatPollTimer: ReturnType | null = null; const defaultCredentials = (): Record => ({}); const formData = ref({ + target_agent_id: '', platform: 'wecom' as 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost' | 'wechat', name: '', mode: 'websocket' as 'webhook' | 'websocket' | 'longpoll', @@ -482,7 +700,7 @@ const channelMenuOptions = computed(() => ([ { content: t('common.delete'), value: 'delete', theme: 'error' }, ])); -const channelSummary = (channel: IMChannel) => { +const channelSummary = (channel: IMChannel | IMChannelOverview) => { const parts = [ platformLabel(channel.platform), channel.mode, @@ -491,14 +709,31 @@ const channelSummary = (channel: IMChannel) => { if (channel.session_mode === 'thread') { parts.push(t('agentEditor.im.sessionModeThread')); } + if (props.tenantMode) { + const agentName = agentLabel(channel.agent_id, (channel as IMChannelOverview).agent_name); + if (agentName) parts.unshift(agentName); + } return parts.join(' · '); }; +function agentLabel(agentId: string, overviewName?: string): string { + if (overviewName) return overviewName; + return agents.value.find((agent) => agent.id === agentId)?.name || ''; +} + function platformLabel(platform: string): string { const key = `agentEditor.im.${platform}`; return t(key); } +function defaultChannelName(platform: string = formData.value.platform): string { + return platformLabel(platform); +} + +function resolvedChannelName(): string { + return formData.value.name.trim() || defaultChannelName(); +} + function platformSupportsThread(platform: string): boolean { return ['slack', 'mattermost', 'feishu', 'telegram'].includes(platform); } @@ -526,6 +761,7 @@ const wechatBound = computed(() => { function onPlatformChange(val: string | number | boolean) { + if (editingChannel.value) return; formData.value.credentials = defaultCredentials(); stopWeChatPolling(); wechatQRContent.value = ''; @@ -540,6 +776,9 @@ function onPlatformChange(val: string | number | boolean) { formData.value.mode = 'websocket'; formData.value.output_mode = 'stream'; } + if (!channelNameTouched.value) { + formData.value.name = defaultChannelName(String(val)); + } } async function startWeChatBinding() { @@ -617,11 +856,23 @@ async function loadChannels() { loading.value = true; try { const chatResources = useChatResourcesStore(); - const [channelRes] = await Promise.all([ - listIMChannels(props.agentId), - chatResources.ensureKnowledgeBases(), - ]); - channels.value = channelRes.data || []; + if (props.tenantMode) { + const [channelRes, agentRes] = await Promise.all([ + listAllIMChannels(), + listAgents(), + chatResources.ensureKnowledgeBases(), + ]); + channels.value = channelRes.data || []; + agents.value = agentRes?.data || []; + } else if (props.agentId) { + const [channelRes] = await Promise.all([ + listIMChannels(props.agentId), + chatResources.ensureKnowledgeBases(), + ]); + channels.value = channelRes.data || []; + } else { + channels.value = []; + } knowledgeBases.value = chatResources.rawKnowledgeBases.map((kb: any) => ({ id: kb.id, name: kb.name })); } catch { channels.value = []; @@ -659,11 +910,14 @@ async function copyUrl(channel: IMChannel) { function openCreate() { resetForm(); + if (props.tenantMode && props.initialAgentId) { + formData.value.target_agent_id = props.initialAgentId; + } showCreateDialog.value = true; } -function openDrawer(channel: IMChannel) { - editChannel(channel); +function openDrawer(channel: IMChannel | IMChannelOverview) { + void editChannel(channel); } function confirmDelete(id: string) { @@ -680,30 +934,53 @@ function confirmDelete(id: string) { }); } -function editChannel(channel: IMChannel) { - editingChannel.value = channel; +async function editChannel(channel: IMChannel | IMChannelOverview) { + wizardStep.value = 0; + let fullChannel: IMChannel | null = null; + if (props.tenantMode && !('credentials' in channel)) { + try { + const res = await listIMChannels(channel.agent_id); + fullChannel = (res.data || []).find((item) => item.id === channel.id) || null; + } catch { + fullChannel = null; + } + } else { + fullChannel = channel as IMChannel; + } + if (!fullChannel) { + MessagePlugin.error(t('common.operationFailed')); + return; + } + editingChannel.value = fullChannel; + editingEnabled.value = fullChannel.enabled; + channelNameTouched.value = true; formData.value = { - platform: channel.platform, - name: channel.name, - mode: channel.mode, - output_mode: channel.output_mode, - session_mode: channel.session_mode || 'user', - knowledge_base_id: channel.knowledge_base_id || '', - credentials: { ...channel.credentials }, + target_agent_id: fullChannel.agent_id, + platform: fullChannel.platform, + name: fullChannel.name, + mode: fullChannel.mode, + output_mode: fullChannel.output_mode, + session_mode: fullChannel.session_mode || 'user', + knowledge_base_id: fullChannel.knowledge_base_id || '', + credentials: { ...fullChannel.credentials }, }; showCreateDialog.value = true; } function resetForm() { editingChannel.value = null; + editingEnabled.value = true; + wizardStep.value = 0; + channelNameTouched.value = false; stopWeChatPolling(); wechatQRContent.value = ''; wechatQRImgUrl.value = ''; wechatQRCode.value = ''; wechatQRStatus.value = ''; formData.value = { + target_agent_id: props.tenantMode ? (props.initialAgentId || '') : '', platform: 'wecom', - name: '', + name: defaultChannelName('wecom'), mode: 'websocket', output_mode: 'stream', session_mode: 'user', @@ -723,18 +1000,24 @@ async function handleSave() { if (editingChannel.value) { await updateIMChannel(editingChannel.value.id, { - name: formData.value.name, + name: resolvedChannelName(), mode: formData.value.mode, output_mode: formData.value.output_mode, session_mode: formData.value.session_mode, knowledge_base_id: formData.value.knowledge_base_id, credentials: formData.value.credentials, + enabled: editingEnabled.value, }); MessagePlugin.success(t('common.updateSuccess')); } else { - await createIMChannel(props.agentId, { + const targetAgentId = props.tenantMode ? formData.value.target_agent_id : props.agentId; + if (!targetAgentId) { + MessagePlugin.warning(t('integrations.selectAgentHint')); + return; + } + await createIMChannel(targetAgentId, { platform: formData.value.platform, - name: formData.value.name, + name: resolvedChannelName(), mode: formData.value.mode, output_mode: formData.value.output_mode, session_mode: formData.value.session_mode, @@ -754,7 +1037,7 @@ async function handleSave() { } } -async function handleToggle(channel: IMChannel) { +async function handleToggle(channel: IMChannel | IMChannelOverview) { try { await toggleIMChannel(channel.id); await loadChannels(); @@ -774,9 +1057,20 @@ async function handleDelete(id: string) { } onMounted(() => { - loadChannels(); + if (props.tenantMode || props.agentId) { + loadChannels(); + } }); +watch( + () => [props.tenantMode, props.agentId], + () => { + if (props.tenantMode || props.agentId) { + loadChannels(); + } + }, +); + onUnmounted(() => { stopWeChatPolling(); }); @@ -895,14 +1189,20 @@ onUnmounted(() => { &__badge { flex-shrink: 0; - width: 36px; - height: 36px; - border-radius: 9px; + width: 28px; + height: 28px; + border-radius: 7px; display: flex; align-items: center; justify-content: center; - background: color-mix(in srgb, var(--td-brand-color) 10%, transparent); - color: var(--td-brand-color); + background: var(--td-bg-color-secondarycontainer); + overflow: hidden; + } + + &__logo { + width: 16px; + height: 16px; + object-fit: contain; } &__body { @@ -969,48 +1269,87 @@ onUnmounted(() => { } } -.platform-badge { - display: inline-block; - padding: 2px 8px; - border-radius: 4px; +.drawer-platform-icon { + width: 16px; + height: 16px; + object-fit: contain; +} + +.im-steps { + display: flex; + gap: 8px; + margin-bottom: 16px; + border-bottom: 1px solid var(--td-component-stroke); + padding-bottom: 12px; +} + +.im-step { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; font-size: 12px; + color: var(--td-text-color-placeholder); +} + +.im-step-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.im-step.active { + color: var(--td-brand-color); font-weight: 500; - line-height: 18px; +} - &.wecom { - background: rgba(0, 130, 239, 0.08); - color: #0082ef; - } +.im-step.done { + color: var(--td-text-color-secondary); + font-weight: 500; +} - &.feishu { - background: rgba(51, 112, 255, 0.08); - color: #3370ff; - } +.im-step-num { + flex-shrink: 0; + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + border: 1px solid var(--td-component-stroke); + color: var(--td-text-color-placeholder); + background: transparent; +} - &.slack { - background: rgba(224, 30, 90, 0.08); - color: #e01e5a; - } +.im-step.active .im-step-num { + background: var(--td-brand-color); + color: #fff; + border-color: var(--td-brand-color); +} - &.telegram { - background: rgba(38, 166, 219, 0.08); - color: #26a6db; - } +.im-step.done .im-step-num { + background: var(--td-bg-color-secondarycontainer); + color: var(--td-brand-color); + border-color: var(--td-component-stroke); +} - &.dingtalk { - background: rgba(23, 126, 251, 0.08); - color: #177efb; - } +.im-step-check { + font-size: 12px; +} - &.mattermost { - background: rgba(25, 42, 77, 0.08); - color: #192a4d; - } +.im-step-body { + display: flex; + flex-direction: column; + gap: 0; +} - &.wechat { - background: rgba(7, 193, 96, 0.08); - color: #07c160; - } +:deep(.im-drawer__section.setting-drawer__section) { + gap: 10px; + padding: 10px 0 14px; } .callback-url-control { @@ -1032,44 +1371,143 @@ onUnmounted(() => { .drawer-form { display: flex; flex-direction: column; - gap: 16px; + gap: 12px; } .form-item { - .form-label { - display: block; - margin-bottom: 8px; - font-size: 14px; - font-weight: 500; + margin-bottom: 0; +} + +.form-label { + display: block; + margin-bottom: 6px; + font-size: 13px; + font-weight: 500; + color: var(--td-text-color-primary); + line-height: 1.4; + + &--inline { + margin-bottom: 0; + } + + &.required::after { + content: '*'; + color: var(--td-error-color); + margin-left: 4px; + } +} + +.im-platform-select-prefix { + width: 16px; + height: 16px; + object-fit: contain; +} + +.im-platform-select-option { + display: flex; + align-items: center; + gap: 8px; + + &__icon { + width: 16px; + height: 16px; + object-fit: contain; + flex-shrink: 0; + } +} + +.form-desc { + margin: 4px 0 0; + font-size: 12px; + line-height: 1.45; + color: var(--td-text-color-placeholder); +} + +.option-chips { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; + padding: 3px; + border-radius: 8px; + background: var(--td-bg-color-secondarycontainer); +} + +.option-chip { + border: none; + background: transparent; + color: var(--td-text-color-secondary); + font: inherit; + font-size: 12px; + line-height: 1.3; + padding: 5px 10px; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; + white-space: nowrap; + + &:hover:not(:disabled) { color: var(--td-text-color-primary); } -} -.mattermost-post-main-row { - .mattermost-post-main-label { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } - .form-label { - margin-bottom: 0; - flex: 1; - } + &--active { + background: var(--td-bg-color-container); + color: var(--td-brand-color); + font-weight: 500; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); } } -.form-divider { - height: 1px; - background: var(--td-component-stroke); - margin: 4px 0; +.settings-group { + display: flex; + flex-direction: column; + gap: 0; } -.form-hint { - margin: 6px 0 0; - font-size: 12px; - color: var(--td-text-color-placeholder); - line-height: 1.4; +.setting-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--td-component-stroke); + + &--last { + border-bottom: none; + padding-bottom: 0; + } +} + +.setting-info { + flex: 1; + min-width: 0; + max-width: 72%; + padding-right: 8px; + + label { + display: block; + margin: 0 0 4px; + font-size: 13px; + font-weight: 500; + color: var(--td-text-color-primary); + line-height: 1.4; + } + + .desc { + margin: 0; + font-size: 12px; + line-height: 1.45; + color: var(--td-text-color-placeholder); + } +} + +.setting-control { + flex-shrink: 0; + padding-top: 2px; } .platform-link-hint { @@ -1171,3 +1609,17 @@ onUnmounted(() => { text-align: center; } + + diff --git a/frontend/src/components/IMChannelsOverviewPanel.vue b/frontend/src/components/IMChannelsOverviewPanel.vue deleted file mode 100644 index 57d38a83..00000000 --- a/frontend/src/components/IMChannelsOverviewPanel.vue +++ /dev/null @@ -1,442 +0,0 @@ - - - - - diff --git a/frontend/src/components/SessionSourceFilter.vue b/frontend/src/components/SessionSourceFilter.vue index 2f78843b..cc31b3b6 100644 --- a/frontend/src/components/SessionSourceFilter.vue +++ b/frontend/src/components/SessionSourceFilter.vue @@ -75,7 +75,7 @@ const currentOption = computed(() => const iconFor = (item: SourceItem | undefined): string => { if (!item) return 'chat' if (item.value === DEFAULT_SESSION_BUCKET_KEY) return 'chat' - if (item.value.startsWith('embed:')) return 'internet' + if (item.value.startsWith('embed:')) return 'code' return 'link' } diff --git a/frontend/src/components/UserMenu.vue b/frontend/src/components/UserMenu.vue index 356e1848..12701be1 100644 --- a/frontend/src/components/UserMenu.vue +++ b/frontend/src/components/UserMenu.vue @@ -103,16 +103,6 @@ {{ $t('settings.apiInfo') }} - - - -
- -
-
- @@ -251,9 +231,7 @@ import { useAuthStore } from '@/stores/auth' import { MessagePlugin } from 'tdesign-vue-next' import { getCurrentUser, logout as logoutApi, userInfoFromApi } from '@/api/auth' import { useI18n } from 'vue-i18n' -import IMChannelsOverviewPanel from '@/components/IMChannelsOverviewPanel.vue' import CreateTenantDialog from '@/components/CreateTenantDialog.vue' -import { listAllIMChannels, type IMChannelOverview } from '@/api/agent' import { navigateAfterTenantSwitch, persistLastActiveTenantPreference, @@ -309,15 +287,10 @@ const canSeeQuickNav = (key: string): boolean => { } const menuRef = ref() -const imMenuItemRef = ref() const tenantMenuItemRef = ref() const menuVisible = ref(false) -const imSubmenuOpen = ref(false) -const imSubmenuStyle = ref>({}) const tenantSubmenuOpen = ref(false) const tenantSubmenuStyle = ref>({}) -const hasActiveIMChannels = ref(false) -let imSubmenuHideTimer: ReturnType | null = null let tenantSubmenuHideTimer: ReturnType | null = null // 用户信息 @@ -375,28 +348,7 @@ const handleSystemAdmin = () => { // Hover-driven submenu controls. A small hide delay tolerates the pointer // slipping off briefly onto the gap between menu item and submenu pane. -const showIMSubmenu = () => { - if (imSubmenuHideTimer) { - clearTimeout(imSubmenuHideTimer) - imSubmenuHideTimer = null - } - // Compute panel position based on the menu item's rect — the panel is - // teleported to body so we can't rely on CSS `left: 100%`. - positionIMSubmenu() - imSubmenuOpen.value = true - clampFloatingToViewport('.im-submenu-floating', imSubmenuStyle) -} - -const scheduleHideIMSubmenu = () => { - if (imSubmenuHideTimer) clearTimeout(imSubmenuHideTimer) - imSubmenuHideTimer = setTimeout(() => { - imSubmenuOpen.value = false - imSubmenuHideTimer = null - }, 180) -} - const closeAll = () => { - imSubmenuOpen.value = false tenantSubmenuOpen.value = false menuVisible.value = false } @@ -423,7 +375,7 @@ const onTenantCreated = async (newTenant: TenantInfo) => { // ---------- Tenant switcher submenu ---------- // -// Same hover-driven submenu pattern as the IM panel above; data comes from +// Same hover-driven submenu pattern; data comes from // authStore.memberships (refreshed from /auth/me when the submenu opens and // after membership-changing actions). PR 4 of #1303 relaxed the X-Tenant-ID // gate in middleware/auth.go to accept active membership rows, so flipping @@ -550,58 +502,8 @@ const positionTenantSubmenu = () => { } } -// Silent prefetch so the "live" indicator on the IM menu item reflects reality -// as soon as the user sees the avatar area. Errors are swallowed — the -// indicator just stays off if the request fails, which is the conservative -// default. The panel component emits channels-changed after toggle/refresh so -// we stay in sync without re-polling. -const refreshIMStatus = async () => { - try { - const resp = await listAllIMChannels() - const data: IMChannelOverview[] = resp?.data || [] - hasActiveIMChannels.value = data.some((c) => c.enabled) - } catch { - // Intentionally ignored — indicator just stays off. - } -} - -const onChannelsChanged = (channels: IMChannelOverview[]) => { - hasActiveIMChannels.value = channels.some((c) => c.enabled) -} - // Anchor the floating submenu just to the right of the hovered menu item, // clamped to the viewport so it stays visible near the screen edge. -const positionIMSubmenu = () => { - const el = imMenuItemRef.value - if (!el) return - // Same rationale as `positionTenantSubmenu` — convert visual-pixel rect to - // CSS pixels before feeding the fixed submenu's CSS lengths. - const zoom = getRootZoom() - const rect = rectToCssPx(el.getBoundingClientRect(), zoom) - const { width: vw } = cssViewportSize(zoom) - const PANEL_WIDTH = 300 - const GAP = 8 - const MARGIN = 8 - - let left = rect.right + GAP - // If the panel would overflow the right edge, flip to the left side. - if (left + PANEL_WIDTH + MARGIN > vw) { - left = Math.max(MARGIN, rect.left - PANEL_WIDTH - GAP) - } - - // Align with the menu item's top; bottom-clamping is done after render - // when we know the panel's actual height (see clampSubmenuToViewport). - // 之前用 PANEL_MAX_HEIGHT=520 提前 clamp 会把实际只有 ~140px 的面板 - // 顶到屏幕中上方,与菜单项错开很多。 - const top = Math.max(MARGIN, rect.top) - - imSubmenuStyle.value = { - left: `${left}px`, - top: `${top}px`, - } -} - -// 在面板真正渲染后,按它的实际高度做底部 clamp,避免面板跑出屏幕外。 const clampFloatingToViewport = (selector: string, target: { value: Record }) => { requestAnimationFrame(() => { const panel = document.querySelector(selector) as HTMLElement | null @@ -712,22 +614,16 @@ const loadUserInfo = async () => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node if (menuRef.value && menuRef.value.contains(target)) return - // The IM and tenant submenus are teleported to body, so they're not inside - // menuRef — check them separately to avoid closing the dropdown when the - // user clicks one of the floating panels. - const imFloating = document.querySelector('.im-submenu-floating') - if (imFloating && imFloating.contains(target)) return + // Tenant submenu is teleported to body, so it's not inside menuRef. const tenantFloating = document.querySelector('.tenant-submenu-floating') if (tenantFloating && tenantFloating.contains(target)) return menuVisible.value = false - imSubmenuOpen.value = false tenantSubmenuOpen.value = false } onMounted(() => { document.addEventListener('click', handleClickOutside) loadUserInfo() - refreshIMStatus() }) onUnmounted(() => { @@ -1103,45 +999,6 @@ onUnmounted(() => { color: var(--td-text-color-secondary); } } - - // "Live" indicator — shown when at least one IM channel is enabled. - // A small green dot with a halo that pulses to signal active connections. - .live-indicator { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - width: 10px; - height: 10px; - margin-right: 2px; - flex-shrink: 0; - } - - .live-indicator-dot { - position: relative; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--td-success-color, #07c160); - - // Pulsing halo around the dot. Prefers-reduced-motion disables it. - &::after { - content: ''; - position: absolute; - inset: -3px; - border-radius: 50%; - background: var(--td-success-color, #07c160); - opacity: 0.45; - animation: im-live-pulse 1.6s ease-out infinite; - pointer-events: none; - } - } - - @media (prefers-reduced-motion: reduce) { - .live-indicator-dot::after { - animation: none; - } - } } .menu-icon { @@ -1246,39 +1103,10 @@ onUnmounted(() => { transform: translateY(0); } -// Live indicator halo animation — a soft expanding ring to signal that at -// least one IM channel is actively connected. -@keyframes im-live-pulse { - 0% { - transform: scale(0.9); - opacity: 0.45; - } - - 70% { - transform: scale(1.8); - opacity: 0; - } - - 100% { - transform: scale(1.8); - opacity: 0; - } -} diff --git a/frontend/src/views/platform/RoutePlaceholder.vue b/frontend/src/views/platform/RoutePlaceholder.vue new file mode 100644 index 00000000..fb1355c8 --- /dev/null +++ b/frontend/src/views/platform/RoutePlaceholder.vue @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/views/platform/index.vue b/frontend/src/views/platform/index.vue index 630b1a4a..3e95d8fa 100644 --- a/frontend/src/views/platform/index.vue +++ b/frontend/src/views/platform/index.vue @@ -10,6 +10,7 @@ +