feat(frontend): add publish integrations center and streamline agent editor

Move IM and web embed channel management into a dedicated sidebar entry,
remove the user-menu IM overview submenu, and show a lightweight publish
channel status row in the agent editor basic settings instead of full panels.
This commit is contained in:
wizardchen
2026-06-24 16:28:39 +08:00
committed by lyingbug
parent 4db727ac69
commit 314b2a035c
19 changed files with 1398 additions and 876 deletions
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20" fill="none">
<path d="M4.25 8V12a2 2 0 0 0 2 2H11.5" stroke="#07C05F" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.75 12V8a2 2 0 0 0-2-2H8.5" stroke="#07C05F" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 353 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20" fill="none">
<path d="M4.25 8V12a2 2 0 0 0 2 2H11.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.75 12V8a2 2 0 0 0-2-2H8.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 363 B

@@ -20,7 +20,7 @@
@click="openDrawer(ch)"
>
<div class="channel-card__badge">
<t-icon name="internet" size="18px" />
<t-icon name="code" size="18px" />
</div>
<div class="channel-card__body">
<div class="channel-card__header">
@@ -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 @@
<section class="setting-drawer__section embed-drawer__section">
<h4 class="setting-drawer__section-title">{{ $t('embedPublish.sectionChannel') }}</h4>
<div v-if="tenantMode && !editingId" class="form-item">
<label class="form-label">{{ $t('integrations.selectAgent') }}</label>
<t-select
v-model="createAgentId"
:options="agentOptions"
filterable
:placeholder="$t('integrations.selectAgentPlaceholder')"
/>
</div>
<div v-if="editingId && isAdmin" class="form-item">
<div class="enable-row">
<span class="form-label form-label--inline">{{ $t('embedPublish.enabled') }}</span>
@@ -182,7 +192,7 @@
<div class="setting-info">
<label>{{ $t('embedPublish.allowWebSearch') }}</label>
<p class="desc">{{ $t('embedPublish.allowWebSearchDesc') }}</p>
<p v-if="form.allow_web_search && !agentWebSearchEnabled" class="desc desc--warn">
<p v-if="form.allow_web_search && !agentWebSearchEnabledEffective" class="desc desc--warn">
{{ $t('embedPublish.agentWebSearchDisabledHint') }}
</p>
</div>
@@ -199,7 +209,7 @@
<div class="setting-info">
<label>{{ $t('embedPublish.allowFileUpload') }}</label>
<p class="desc">{{ $t('embedPublish.allowFileUploadDesc') }}</p>
<p v-if="form.allow_file_upload && !agentImageUploadEnabled" class="desc desc--warn">
<p v-if="form.allow_file_upload && !agentImageUploadEnabledEffective" class="desc desc--warn">
{{ $t('embedPublish.agentImageUploadDisabledHint') }}
</p>
</div>
@@ -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<Record<string, number>>({})
const agents = ref<CustomAgent[]>([])
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
File diff suppressed because it is too large Load Diff
@@ -1,442 +0,0 @@
<template>
<div class="im-submenu" @click.stop>
<div class="submenu-header">
<div class="titles">
<h4 class="title">{{ $t('imOverview.pageTitle') }}</h4>
<p class="subtitle" :title="$t('imOverview.subtitle')">
{{ $t('imOverview.subtitle') }}
</p>
</div>
<t-button
variant="text"
theme="default"
size="small"
:loading="loading"
@click="loadAll"
>
<template #icon><t-icon name="refresh" /></template>
</t-button>
</div>
<div class="submenu-body">
<div v-if="loading && channels.length === 0" class="state-placeholder">
<t-loading size="small" />
</div>
<div v-else-if="channels.length === 0" class="state-placeholder">
<span class="empty-text">{{ $t('imOverview.empty') }}</span>
</div>
<div v-else class="channels-list">
<div
v-for="ch in channels"
:key="ch.id"
class="channel-item"
:class="{ 'is-disabled': !ch.enabled }"
role="button"
:tabindex="0"
:title="$t('imOverview.gotoAgentEditor')"
@click="gotoAgentEditor(ch)"
@keydown.enter="gotoAgentEditor(ch)"
@keydown.space.prevent="gotoAgentEditor(ch)"
>
<div class="item-main">
<div class="channel-primary">
<span class="platform-avatar">
<img
v-if="hasPlatformLogo(ch.platform)"
:src="platformLogo(ch.platform)"
:alt="platformLabel(ch.platform)"
/>
<t-icon v-else name="link" size="14px" />
</span>
<span class="channel-name" :title="channelDisplayName(ch)">
{{ channelDisplayName(ch) }}
</span>
</div>
<div class="channel-secondary">
<span class="agent-avatar-sm">
<span
v-if="agentMeta(ch.agent_id)?.is_builtin"
class="builtin-avatar"
:class="agentMeta(ch.agent_id)?.config?.agent_mode === 'smart-reasoning' ? 'agent' : 'normal'"
>
<t-icon
:name="agentMeta(ch.agent_id)?.config?.agent_mode === 'smart-reasoning' ? 'control-platform' : 'chat'"
size="10px"
/>
</span>
<span
v-else-if="agentMeta(ch.agent_id)?.avatar"
class="builtin-avatar agent-emoji"
>{{ agentMeta(ch.agent_id)!.avatar }}</span>
<AgentAvatar
v-else
:name="agentDisplayName(ch)"
size="small"
/>
</span>
<span class="agent-name" :title="agentDisplayName(ch)">
{{ agentDisplayName(ch) }}
</span>
</div>
</div>
<div class="item-actions" @click.stop>
<t-switch
:value="ch.enabled"
size="small"
:loading="togglingId === ch.id"
:disabled="!authStore.hasRole('admin')"
@change="handleToggle(ch)"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { MessagePlugin } from 'tdesign-vue-next';
import { useAuthStore } from '@/stores/auth';
import {
listAllIMChannels,
listAgents,
toggleIMChannel,
type IMChannelOverview,
type CustomAgent,
} from '@/api/agent';
import AgentAvatar from '@/components/AgentAvatar.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';
const PLATFORM_LOGO: Record<string, string> = {
wecom: wecomLogo,
feishu: feishuLogo,
slack: slackLogo,
telegram: telegramLogo,
dingtalk: dingtalkLogo,
mattermost: mattermostLogo,
wechat: wechatLogo,
};
const props = defineProps<{
active: boolean;
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'channels-changed', channels: IMChannelOverview[]): void;
}>();
const { t } = useI18n();
const router = useRouter();
const authStore = useAuthStore();
const channels = ref<IMChannelOverview[]>([]);
const agentMap = ref<Map<string, CustomAgent>>(new Map());
const loading = ref(false);
const togglingId = ref<string>('');
watch(
() => props.active,
(v, old) => {
if (v && !old) loadAll();
},
{ immediate: true },
);
function hasPlatformLogo(p: string): boolean {
return Boolean(PLATFORM_LOGO[p]);
}
function platformLogo(p: string): string {
return PLATFORM_LOGO[p] || '';
}
function platformLabel(p: string): string {
return t(`agentEditor.im.${p}`);
}
function channelDisplayName(ch: IMChannelOverview): string {
return ch.name || platformLabel(ch.platform);
}
function agentMeta(agentId: string): CustomAgent | undefined {
return agentMap.value.get(agentId);
}
function agentDisplayName(ch: IMChannelOverview): string {
return ch.agent_name || agentMeta(ch.agent_id)?.name || t('imOverview.builtinAgent');
}
async function loadAll() {
loading.value = true;
try {
const [chanResp, agentResp] = await Promise.all([
listAllIMChannels(),
listAgents(),
]);
channels.value = chanResp?.data || [];
const m = new Map<string, CustomAgent>();
(agentResp?.data || []).forEach((a) => m.set(a.id, a));
agentMap.value = m;
emit('channels-changed', channels.value);
} catch (err) {
console.error('Failed to load IM overview:', err);
MessagePlugin.error(t('imOverview.loadFailed'));
} finally {
loading.value = false;
}
}
async function handleToggle(ch: IMChannelOverview) {
if (togglingId.value === ch.id) return;
togglingId.value = ch.id;
const prev = ch.enabled;
ch.enabled = !prev;
try {
await toggleIMChannel(ch.id);
const resp = await listAllIMChannels();
channels.value = resp?.data || [];
emit('channels-changed', channels.value);
} catch (err: any) {
ch.enabled = prev;
console.error('Failed to toggle IM channel:', err);
MessagePlugin.error(err?.message || t('common.operationFailed'));
} finally {
if (togglingId.value === ch.id) togglingId.value = '';
}
}
function gotoAgentEditor(ch: IMChannelOverview) {
emit('close');
router.push({
path: '/platform/agents',
query: { edit: ch.agent_id, section: 'im' },
});
}
</script>
<style lang="less" scoped>
.im-submenu {
display: flex;
flex-direction: column;
width: 300px;
max-height: 520px;
background: var(--td-bg-color-container);
border-radius: 10px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
border: 0.5px solid var(--td-component-stroke);
overflow: hidden;
}
.submenu-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
padding: 10px 12px 8px;
border-bottom: 0.5px solid var(--td-component-stroke);
.titles {
min-width: 0;
}
.title {
margin: 0 0 4px;
font-size: 12px;
font-weight: 600;
color: var(--td-text-color-secondary);
line-height: 1.3;
}
.subtitle {
margin: 0;
font-size: 11px;
color: var(--td-text-color-placeholder);
line-height: 1.45;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
}
.submenu-body {
flex: 1;
overflow-y: auto;
padding: 4px;
}
.state-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 40px 0;
color: var(--td-text-color-placeholder);
.empty-text {
font-size: 12px;
}
}
.channels-list {
display: flex;
flex-direction: column;
}
.channel-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px;
border-radius: 6px;
cursor: pointer;
outline: none;
transition: background 0.15s;
& + & {
border-top: 0.5px solid var(--td-component-stroke);
}
&:hover,
&:focus-visible {
background: var(--td-bg-color-secondarycontainer);
}
&.is-disabled .item-main {
opacity: 0.6;
}
}
.item-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.channel-primary {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.platform-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
flex-shrink: 0;
background: var(--td-bg-color-secondarycontainer);
img {
width: 18px;
height: 18px;
object-fit: contain;
}
.t-icon {
color: var(--td-text-color-placeholder);
}
}
.channel-name {
font-size: 13px;
font-weight: 500;
color: var(--td-text-color-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
line-height: 1.35;
}
.channel-secondary {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
padding-left: 32px;
}
.agent-avatar-sm {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
:deep(.agent-avatar),
:deep(.agent-avatar-small) {
width: 16px;
height: 16px;
}
:deep(.agent-avatar-letter) {
font-size: 9px !important;
}
}
.agent-name {
font-size: 11px;
color: var(--td-text-color-placeholder);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
line-height: 1.35;
}
.item-actions {
flex-shrink: 0;
display: inline-flex;
align-items: center;
padding-top: 2px;
}
.builtin-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 4px;
flex-shrink: 0;
&.agent-emoji {
font-size: 11px;
line-height: 1;
background: var(--td-bg-color-container-hover);
}
&.normal {
background: linear-gradient(135deg, rgba(7, 192, 95, 0.15) 0%, rgba(7, 192, 95, 0.08) 100%);
color: var(--td-brand-color-active);
}
&.agent {
background: linear-gradient(135deg, rgba(124, 77, 255, 0.15) 0%, rgba(124, 77, 255, 0.08) 100%);
color: var(--td-brand-color);
}
}
</style>
@@ -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'
}
+3 -175
View File
@@ -103,16 +103,6 @@
<t-icon name="secured" class="menu-icon" />
<span>{{ $t('settings.apiInfo') }}</span>
</div>
<div ref="imMenuItemRef" class="menu-item menu-item--submenu" :class="{ 'is-open': imSubmenuOpen }"
@mouseenter="showIMSubmenu" @mouseleave="scheduleHideIMSubmenu">
<t-icon name="link" class="menu-icon" />
<span class="menu-item-label">{{ $t('imOverview.menuTitle') }}</span>
<span v-if="hasActiveIMChannels" class="live-indicator" :title="$t('imOverview.liveIndicator')"
aria-hidden="true">
<span class="live-indicator-dot"></span>
</span>
<t-icon name="chevron-right" class="menu-chevron" />
</div>
<div class="menu-divider"></div>
<div class="menu-item" @click="handleSettings">
<t-icon name="setting" class="menu-icon" />
@@ -173,16 +163,6 @@
</div>
</Transition>
<!-- IM submenu is teleported to body because the sidebar (.aside_box) has
overflow:hidden, which would otherwise clip any absolutely-positioned
child that reaches past its bounds. -->
<Teleport to="body">
<div v-if="imSubmenuOpen" class="im-submenu-floating" :style="imSubmenuStyle" @mouseenter="showIMSubmenu"
@mouseleave="scheduleHideIMSubmenu">
<IMChannelsOverviewPanel :active="imSubmenuOpen" @close="closeAll" @channels-changed="onChannelsChanged" />
</div>
</Teleport>
<!-- Tenant switcher floating panel — shares the same teleport rationale
as the IM submenu. Data comes from authStore.memberships, kept fresh via
GET /auth/me when the submenu opens (throttled) and after invite/create. -->
@@ -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<HTMLElement>()
const imMenuItemRef = ref<HTMLElement>()
const tenantMenuItemRef = ref<HTMLElement>()
const menuVisible = ref(false)
const imSubmenuOpen = ref(false)
const imSubmenuStyle = ref<Record<string, string>>({})
const tenantSubmenuOpen = ref(false)
const tenantSubmenuStyle = ref<Record<string, string>>({})
const hasActiveIMChannels = ref(false)
let imSubmenuHideTimer: ReturnType<typeof setTimeout> | null = null
let tenantSubmenuHideTimer: ReturnType<typeof setTimeout> | 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<string, string> }) => {
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;
}
}
</style>
<style lang="less">
// Non-scoped: the IM submenu is teleported to <body> so scoped styles
// from this component won't reach it. The panel component's own CSS is
// scoped and self-contained; this rule only positions the wrapper.
.im-submenu-floating {
position: fixed;
z-index: 1100;
// Invisible padding forms a pointer bridge from the menu item to the
// panel so hovering across the gap doesn't fire mouseleave-hide.
padding-left: 2px;
}
// Tenant switcher submenu — same teleport rationale as .im-submenu-floating.
// Tenant switcher submenu — teleported to <body>.
// All styling for the panel itself lives here (not in a child component) so
// the markup in UserMenu.vue stays self-contained.
.tenant-submenu-floating {
+11 -3
View File
@@ -85,7 +85,7 @@
<div class="menu_item-box">
<div class="menu_icon">
<img class="icon"
:src="getImgSrc(item.icon == 'zhishiku' ? knowledgeIcon : item.icon == 'agent' ? agentIcon : item.icon == 'organization' ? organizationIcon : item.icon == 'logout' ? logoutIcon : item.icon == 'setting' ? settingIcon : prefixIcon)"
:src="getImgSrc(item.icon == 'zhishiku' ? knowledgeIcon : item.icon == 'agent' ? agentIcon : item.icon == 'integration' ? integrationIcon : item.icon == 'organization' ? organizationIcon : item.icon == 'logout' ? logoutIcon : item.icon == 'setting' ? settingIcon : prefixIcon)"
alt="">
</div>
<template v-if="!uiStore.sidebarCollapsed">
@@ -392,6 +392,8 @@ const isMenuItemActive = (itemPath: string): boolean => {
currentRoute === 'knowledgeBaseSettings';
case 'agents':
return currentRoute === 'agentList';
case 'integrations':
return currentRoute === 'integrations';
case 'organizations':
return currentRoute === 'organizationList';
case 'creatChat':
@@ -422,13 +424,13 @@ const getIconActiveState = (itemPath: string) => {
// 分离上下两部分菜单(使用 visibleMenuArr 以便 lite 模式过滤 logout)
const topMenuItems = computed<MenuItem[]>(() => {
return (visibleMenuArr.value as unknown as MenuItem[]).filter((item: MenuItem) =>
item.path === 'knowledge-bases' || item.path === 'agents' || item.path === 'organizations' || item.path === 'creatChat'
item.path === 'knowledge-bases' || item.path === 'agents' || item.path === 'integrations' || item.path === 'organizations' || item.path === 'creatChat'
);
});
const bottomMenuItems = computed<MenuItem[]>(() => {
return (visibleMenuArr.value as unknown as MenuItem[]).filter((item: MenuItem) => {
if (item.path === 'knowledge-bases' || item.path === 'agents' || item.path === 'organizations' || item.path === 'creatChat') {
if (item.path === 'knowledge-bases' || item.path === 'agents' || item.path === 'integrations' || item.path === 'organizations' || item.path === 'creatChat') {
return false;
}
return true;
@@ -1009,6 +1011,7 @@ let prefixIcon = ref('prefixIcon.svg');
let logoutIcon = ref('logout.svg');
let settingIcon = ref('setting.svg');
let agentIcon = ref('agent.svg');
let integrationIcon = ref('integration.svg');
let organizationIcon = ref('organization.svg');
let pathPrefix = ref(route.name)
const getIcon = (path: string) => {
@@ -1017,6 +1020,7 @@ const getIcon = (path: string) => {
const creatChatActiveState = getIconActiveState('creatChat');
const settingsActiveState = getIconActiveState('settings');
const agentsActiveState = route.name === 'agentList';
const integrationsActiveState = route.name === 'integrations';
const organizationsActiveState = route.name === 'organizationList';
// 知识库图标:只在知识库页面显示绿色
@@ -1025,6 +1029,8 @@ const getIcon = (path: string) => {
// 智能体图标:只在智能体页面显示绿色
agentIcon.value = agentsActiveState ? 'agent-green.svg' : 'agent.svg';
integrationIcon.value = integrationsActiveState ? 'integration-green.svg' : 'integration.svg';
// 组织图标:只在组织页面显示绿色
organizationIcon.value = organizationsActiveState ? 'organization-green.svg' : 'organization.svg';
@@ -1049,6 +1055,8 @@ const handleMenuClick = async (path: string) => {
}
} else if (path === 'agents') {
router.push('/platform/agents')
} else if (path === 'integrations') {
router.push('/platform/integrations')
} else if (path === 'organizations') {
// 组织菜单项:跳转到组织列表
router.push('/platform/organizations')
+49 -1
View File
@@ -2,6 +2,7 @@ export default {
menu: {
knowledgeBase: 'Knowledge Base',
agents: 'Agents',
integrations: 'Publish & Integrations',
organizations: 'Shared Spaces',
chat: 'Chat',
newChat: 'New Chat',
@@ -5059,7 +5060,7 @@ export default {
basic: 'Basics',
knowledge: 'Knowledge & Retrieval',
capability: 'Extensions',
integration: 'Publish & Integrate',
integration: 'Publish & Integrations',
},
placeholders: {
available: 'Available variables: ',
@@ -5220,6 +5221,7 @@ export default {
deleteConfirm: 'Are you sure you want to delete this channel? This action cannot be undone.',
channelName: 'Channel Name',
channelNamePlaceholder: 'Enter a name for easy identification',
channelNameDefaultHint: 'Defaults to the platform name; you can customize it, or leave blank to use the platform name on save',
platform: 'Platform',
mode: 'Connection Mode',
outputMode: 'Output Mode',
@@ -5259,6 +5261,21 @@ export default {
wechatHint: 'Requires iOS WeChat 8.0.70+, direct messages only',
wechatQRExpired: 'QR code expired, please try again',
wechatBinding: 'Binding...',
sectionChannel: 'Channel info',
sectionConnection: 'Connection',
sectionCredentials: 'Platform credentials',
selectPlatform: 'Select platform',
selectPlatformDesc: 'Choose the messaging platform to connect',
enabled: 'Enable channel',
stepBasic: 'Basic info',
stepConnection: 'Connection',
stepKnowledge: 'File storage',
stepCredentials: 'Credentials',
sectionAccess: 'Access & output',
sectionSession: 'Session',
sectionCallback: 'Callback URL',
sectionKnowledge: 'File storage',
sectionStatus: 'Status',
},
agentType: {
label: 'Agent Type',
@@ -5574,6 +5591,37 @@ export default {
syncResultDeleted: '-{n}',
moreActions: 'More',
},
integrations: {
title: 'Publish & Integrations',
subtitle: 'Publish agents to IM platforms and websites',
tabs: {
im: 'IM Integration',
embed: 'Web Embed',
},
manageSectionTitle: 'Channel configuration',
manageSectionDesc: 'Select an agent to create or edit integration channels for that agent',
selectAgent: 'Select agent',
selectAgentDesc: 'Each integration channel is bound to one agent. Choose the agent to configure.',
selectAgentPlaceholder: 'Choose an agent',
selectAgentHint: 'Please select an agent first',
manageChannel: 'Manage channel',
goToCenter: 'Open Integrations',
imOverview: {
title: 'Connected IM channels',
subtitle: 'All IM channels across agents in this workspace. Toggle quickly or configure details below.',
},
embedOverview: {
title: 'Web embed channels',
subtitle: 'All web embed channels across agents in this workspace. Toggle quickly or configure details below.',
empty: 'No web embed channels configured yet',
loadFailed: 'Failed to load web embed channels',
},
agentEditor: {
label: 'Publish channels',
desc: 'Publish this agent to IM platforms or websites. Manage in Integrations.',
manage: 'Manage',
},
},
imOverview: {
menuTitle: 'Connected IMs',
pageTitle: 'Connected IMs',
+48
View File
@@ -2,6 +2,7 @@ export default {
menu: {
knowledgeBase: "지식베이스",
agents: "에이전트",
integrations: "게시 및 통합",
organizations: "공유 스페이스",
chat: "대화",
newChat: "새 대화",
@@ -5161,6 +5162,7 @@ export default {
deleteConfirm: "이 채널을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
channelName: "채널 이름",
channelNamePlaceholder: "식별하기 쉬운 이름을 입력하세요",
channelNameDefaultHint: "기본값은 플랫폼 이름이며, 직접 수정할 수 있습니다. 비워 두면 저장 시 플랫폼 이름이 사용됩니다",
platform: "플랫폼",
mode: "연결 모드",
outputMode: "출력 모드",
@@ -5200,6 +5202,21 @@ export default {
wechatHint: "iOS WeChat 8.0.70+ 버전이 필요하며, 1:1 채팅 메시지만 지원됩니다",
wechatQRExpired: "QR 코드가 만료되었습니다. 다시 가져오세요",
wechatBinding: "연결 중...",
sectionChannel: "채널 정보",
sectionConnection: "연결 설정",
sectionCredentials: "플랫폼 자격 증명",
selectPlatform: "플랫폼 선택",
selectPlatformDesc: "연결할 메시징 플랫폼을 선택하세요",
enabled: "채널 활성화",
stepBasic: "기본 정보",
stepConnection: "연결 설정",
stepKnowledge: "파일 저장",
stepCredentials: "플랫폼 자격 증명",
sectionAccess: "접속 및 출력",
sectionSession: "세션 설정",
sectionCallback: "콜백 URL",
sectionKnowledge: "파일 저장",
sectionStatus: "실행 상태",
},
tools: {
thinking: '사고',
@@ -5585,6 +5602,37 @@ export default {
syncResultUpdated: "~{n}",
syncResultDeleted: "-{n}",
},
integrations: {
title: "게시 및 통합",
subtitle: "에이전트를 IM 플랫폼과 웹사이트에 게시",
tabs: {
im: "IM 통합",
embed: "웹 임베드",
},
manageSectionTitle: "채널 구성",
manageSectionDesc: "에이전트를 선택한 후 해당 에이전트의 통합 채널을 생성하거나 편집할 수 있습니다",
selectAgent: "에이전트 선택",
selectAgentDesc: "각 통합 채널은 하나의 에이전트에 연결됩니다. 구성할 에이전트를 선택하세요",
selectAgentPlaceholder: "에이전트를 선택하세요",
selectAgentHint: "먼저 에이전트를 선택하세요",
manageChannel: "채널 관리",
goToCenter: "통합 센터로 이동",
imOverview: {
title: "연결된 IM",
subtitle: "현재 워크스페이스의 모든 IM 채널입니다. 빠르게 켜고 끄거나 아래에서 상세 구성할 수 있습니다",
},
embedOverview: {
title: "웹 임베드 채널",
subtitle: "현재 워크스페이스의 모든 웹 임베드 채널입니다. 빠르게 켜고 끄거나 아래에서 상세 구성할 수 있습니다",
empty: "아직 웹 임베드 채널이 없습니다",
loadFailed: "웹 임베드 채널을 불러오지 못했습니다",
},
agentEditor: {
label: "게시 채널",
desc: "IM 플랫폼 또는 웹사이트에 에이전트를 게시합니다. 통합 센터에서 관리하세요.",
manage: "관리",
},
},
imOverview: {
menuTitle: "연결된 IM",
pageTitle: "연결된 IM",
+48
View File
@@ -37,6 +37,7 @@ export default {
noSessions: 'Пока нет диалогов',
loadMoreSessions: 'Загрузить ещё',
agents: 'Агенты',
integrations: 'Публикация и интеграция',
organizations: 'Общие пространства'
},
newUserGuide: {
@@ -4731,6 +4732,7 @@ export default {
deleteConfirm: 'Вы уверены, что хотите удалить этот канал? Это действие не может быть отменено.',
channelName: 'Имя канала',
channelNamePlaceholder: 'Введите имя для легкой идентификации',
channelNameDefaultHint: 'По умолчанию используется название платформы; можно изменить. Если оставить пустым, при сохранении подставится название платформы',
platform: 'Платформа',
mode: 'Режим подключения',
outputMode: 'Режим вывода',
@@ -4770,6 +4772,21 @@ export default {
wechatHint: 'Требуется iOS WeChat 8.0.70+, поддерживаются только сообщения 1:1',
wechatQRExpired: 'Срок действия QR-кода истёк, получите новый',
wechatBinding: 'Привязка...',
sectionChannel: 'Информация о канале',
sectionConnection: 'Подключение',
sectionCredentials: 'Учётные данные платформы',
selectPlatform: 'Выберите платформу',
selectPlatformDesc: 'Выберите платформу обмена сообщениями для подключения',
enabled: 'Включить канал',
stepBasic: 'Основное',
stepConnection: 'Подключение',
stepKnowledge: 'Хранение файлов',
stepCredentials: 'Учётные данные',
sectionAccess: 'Доступ и вывод',
sectionSession: 'Сессии',
sectionCallback: 'URL обратного вызова',
sectionKnowledge: 'Хранение файлов',
sectionStatus: 'Статус',
},
agentType: {
label: 'Тип агента',
@@ -5407,6 +5424,37 @@ export default {
syncResultUpdated: '~{n}',
syncResultDeleted: '-{n}',
},
integrations: {
title: 'Публикация и интеграция',
subtitle: 'Публикуйте агентов на IM-платформах и сайтах',
tabs: {
im: 'IM-интеграция',
embed: 'Веб-встраивание',
},
manageSectionTitle: 'Настройка каналов',
manageSectionDesc: 'Выберите агента, чтобы создать или изменить каналы интеграции для него',
selectAgent: 'Выберите агента',
selectAgentDesc: 'Каждый канал интеграции привязан к одному агенту. Выберите агента для настройки.',
selectAgentPlaceholder: 'Выберите агента',
selectAgentHint: 'Сначала выберите агента',
manageChannel: 'Управлять каналом',
goToCenter: 'Открыть интеграции',
imOverview: {
title: 'Подключённые IM-каналы',
subtitle: 'Все IM-каналы агентов в этом пространстве. Быстро включайте/выключайте или настраивайте ниже.',
},
embedOverview: {
title: 'Каналы веб-встраивания',
subtitle: 'Все каналы веб-встраивания агентов в этом пространстве. Быстро включайте/выключайте или настраивайте ниже.',
empty: 'Каналы веб-встраивания ещё не настроены',
loadFailed: 'Не удалось загрузить каналы веб-встраивания',
},
agentEditor: {
label: 'Каналы публикации',
desc: 'Публикуйте агента в IM или на сайт. Управление — в разделе «Интеграции».',
manage: 'Управлять',
},
},
imOverview: {
menuTitle: 'Подключённые IM',
pageTitle: 'Подключённые IM',
+48
View File
@@ -2,6 +2,7 @@ export default {
menu: {
knowledgeBase: "知识库",
agents: "智能体",
integrations: "发布集成",
organizations: "共享空间",
chat: "对话",
newChat: "新对话",
@@ -5168,6 +5169,7 @@ export default {
deleteConfirm: "确定删除该渠道?删除后无法恢复。",
channelName: "渠道名称",
channelNamePlaceholder: "输入渠道名称,方便辨识",
channelNameDefaultHint: "默认为平台名称,可自行修改;留空保存时也会使用平台名称",
platform: "平台",
mode: "接入模式",
outputMode: "输出模式",
@@ -5207,6 +5209,21 @@ export default {
wechatHint: "需要 iOS 微信 8.0.70+ 版本,仅支持单聊消息",
wechatQRExpired: "二维码已过期,请重新获取",
wechatBinding: "绑定中...",
sectionChannel: "渠道信息",
sectionConnection: "连接设置",
sectionCredentials: "平台凭证",
selectPlatform: "选择平台",
selectPlatformDesc: "选择要接入的即时通讯平台",
enabled: "启用渠道",
stepBasic: "基本信息",
stepConnection: "连接设置",
stepKnowledge: "文件存储",
stepCredentials: "平台凭证",
sectionAccess: "接入与输出",
sectionSession: "会话设置",
sectionCallback: "回调地址",
sectionKnowledge: "文件存储",
sectionStatus: "运行状态",
},
tools: {
thinking: "思考",
@@ -5592,6 +5609,37 @@ export default {
syncResultDeleted: "-{n}",
moreActions: "更多",
},
integrations: {
title: "发布集成",
subtitle: "将智能体发布到 IM 平台与网站",
tabs: {
im: "IM 集成",
embed: "网页嵌入",
},
manageSectionTitle: "渠道配置",
manageSectionDesc: "选择智能体后,可创建或编辑该智能体下的集成渠道",
selectAgent: "选择智能体",
selectAgentDesc: "每个集成渠道绑定一个智能体,请先选择要配置的智能体",
selectAgentPlaceholder: "请选择智能体",
selectAgentHint: "请先选择一个智能体",
manageChannel: "管理渠道",
goToCenter: "前往集成中心",
imOverview: {
title: "已接入的 IM",
subtitle: "当前空间下所有智能体已接入的 IM 渠道,可快速启停或选择下方进行详细配置",
},
embedOverview: {
title: "网页嵌入渠道",
subtitle: "当前空间下所有智能体的网页嵌入渠道,可快速启停或选择下方进行详细配置",
empty: "当前空间还没有配置网页嵌入渠道",
loadFailed: "加载网页嵌入渠道失败",
},
agentEditor: {
label: "发布渠道",
desc: "将智能体发布到 IM 平台或网站,在集成中心统一管理",
manage: "管理",
},
},
imOverview: {
menuTitle: "已接入的 IM",
pageTitle: "已接入的 IM",
+6
View File
@@ -128,6 +128,12 @@ const router = createRouter({
component: () => import("../views/agent/AgentList.vue"),
meta: { requiresInit: true, requiresAuth: true }
},
{
path: "integrations",
name: "integrations",
component: () => import("../views/platform/RoutePlaceholder.vue"),
meta: { requiresInit: true, requiresAuth: true }
},
{
path: "creatChat",
name: "globalCreatChat",
+1
View File
@@ -28,6 +28,7 @@ export const useMenuStore = defineStore('menuStore', () => {
},
{ title: '', titleKey: 'menu.knowledgeBase', icon: 'zhishiku', path: 'knowledge-bases' },
{ title: '', titleKey: 'menu.agents', icon: 'agent', path: 'agents' },
{ title: '', titleKey: 'menu.integrations', icon: 'integration', path: 'integrations' },
{ title: '', titleKey: 'menu.organizations', icon: 'organization', path: 'organizations' },
{ title: '', titleKey: 'menu.settings', icon: 'setting', path: 'settings' },
{ title: '', titleKey: 'menu.logout', icon: 'logout', path: 'logout' }
+98 -42
View File
@@ -71,6 +71,29 @@
</div>
</div>
<!-- 集成渠道状态(编辑模式,配置在集成中心) -->
<div v-if="editorMode === 'edit' && editorAgent?.id" class="setting-row">
<div class="setting-info">
<label>{{ $t('integrations.agentEditor.label') }}</label>
<p class="desc">{{ $t('integrations.agentEditor.desc') }}</p>
</div>
<div class="setting-control">
<div class="integration-inline">
<span class="integration-inline__stat">
{{ $t('integrations.tabs.im') }} · {{ agentIMChannelCount }}
</span>
<span class="integration-inline__sep" aria-hidden="true">|</span>
<span class="integration-inline__stat">
{{ $t('integrations.tabs.embed') }} · {{ agentEmbedChannelCount }}
</span>
<button type="button" class="integration-inline__link" @click="gotoIntegrations">
<span>{{ $t('integrations.agentEditor.manage') }}</span>
<t-icon name="chevron-right" size="14px" />
</button>
</div>
</div>
</div>
<!-- 运行模式(首先选择) -->
<div class="setting-row">
<div class="setting-info">
@@ -1315,39 +1338,6 @@
v-show="currentSection === 'share'" class="section">
<AgentShareSettings :agent-id="editorAgent.id" :agent="editorAgent" />
</div>
<!-- IM集成(仅编辑模式) -->
<div v-if="editorMode === 'edit' && editorAgent?.id && currentSection === 'im'" class="section">
<div class="section-header">
<h2>{{ $t('agentEditor.im.title') }}</h2>
<p class="section-description">
{{ $t('agentEditor.im.description') }}
<a href="https://github.com/Tencent/WeKnora/blob/main/docs/IM%E9%9B%86%E6%88%90%E5%BC%80%E5%8F%91%E6%96%87%E6%A1%A3.md"
target="_blank" rel="noopener noreferrer" class="doc-link">
{{ $t('agentEditor.im.docLink') }}
<t-icon name="link" class="link-icon" />
</a>
</p>
</div>
<div class="settings-group">
<IMChannelPanel :agent-id="editorAgent.id" />
</div>
</div>
<!-- 网页嵌入(仅编辑模式) -->
<div v-if="editorMode === 'edit' && editorAgent?.id && currentSection === 'embed'" class="section">
<div class="section-header">
<h2>{{ $t('agentEditor.embed.title') }}</h2>
<p class="section-description">{{ $t('agentEditor.embed.description') }}</p>
</div>
<div class="settings-group">
<AgentEmbedChannelPanel
:agent-id="editorAgent.id"
:agent-web-search-enabled="formData.config?.web_search_enabled === true"
:agent-image-upload-enabled="formData.config?.image_upload_enabled === true"
/>
</div>
</div>
</div>
<!-- 底部操作栏 -->
@@ -1372,6 +1362,7 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
import { useRouter } from 'vue-router';
import AgentCreateContextualGuide from '@/components/AgentCreateContextualGuide.vue';
import {
AGENT_EDITOR_FOCUS_SECTION_EVENT,
@@ -1382,6 +1373,7 @@ import { MessagePlugin } from 'tdesign-vue-next';
import {
createAgent,
updateAgent,
listIMChannels,
type CustomAgent,
type PlaceholderDefinition,
type AgentTypePreset,
@@ -1403,8 +1395,7 @@ import AgentAvatar from '@/components/AgentAvatar.vue';
import PromptTemplateSelector from '@/components/PromptTemplateSelector.vue';
import ModelSelector from '@/components/ModelSelector.vue';
import AgentShareSettings from '@/components/AgentShareSettings.vue';
import IMChannelPanel from '@/components/IMChannelPanel.vue';
import AgentEmbedChannelPanel from '@/components/AgentEmbedChannelPanel.vue';
import { listEmbedChannels } from '@/api/embed';
import { getRootZoom, rectToCssPx } from '@/utils/zoom';
import {
evaluateToolRequirement,
@@ -1415,6 +1406,7 @@ import {
const uiStore = useUIStore();
const authStore = useAuthStore();
const router = useRouter();
const orgStore = useOrganizationStore();
const chatResources = useChatResourcesStore();
const editorResources = useEditorResourcesStore();
@@ -1472,7 +1464,7 @@ const currentSection = ref(props.initialSection || 'basic');
const onAgentEditorFocusSection = (event: Event) => {
const section = (event as CustomEvent<{ section?: string }>).detail?.section
if (section) {
if (section && navItems.value.some((item) => item.key === section)) {
currentSection.value = section
}
}
@@ -1857,14 +1849,10 @@ const navItems = computed(() => {
if (isAgentMode.value && skillsAvailable.value) {
items.push({ key: 'skills', icon: 'lightbulb', label: t('agent.editor.skillsConfig') });
}
// 发布与集成(仅编辑模式)
// 发布(仅编辑模式)
if (editorMode.value === 'edit' && editorAgent.value?.id && !editorAgent.value?.is_builtin && !authStore.isLiteMode) {
items.push({ key: 'share', icon: 'share', label: t('knowledgeEditor.sidebar.share') });
}
if (editorMode.value === 'edit' && editorAgent.value?.id) {
items.push({ key: 'im', icon: 'chat-message', label: t('agentEditor.im.title') });
items.push({ key: 'embed', icon: 'internet', label: t('agentEditor.embed.title') });
}
return items;
});
@@ -1892,7 +1880,7 @@ const navGroups = computed(() => {
{
key: 'integration',
label: t('agentEditor.navGroups.integration'),
items: pickItems(['share', 'im', 'embed']),
items: pickItems(['share']),
},
].filter((group) => group.items.length > 0);
});
@@ -2079,6 +2067,30 @@ watch(currentSection, (section) => {
}
});
const agentIMChannelCount = ref(0);
const agentEmbedChannelCount = ref(0);
async function loadAgentIntegrationCounts(agentId: string) {
try {
const [imResp, embedResp] = await Promise.all([
listIMChannels(agentId),
listEmbedChannels(agentId),
]);
agentIMChannelCount.value = imResp?.data?.length ?? 0;
agentEmbedChannelCount.value = embedResp?.data?.length ?? 0;
} catch {
agentIMChannelCount.value = 0;
agentEmbedChannelCount.value = 0;
}
}
function gotoIntegrations() {
const agentId = editorAgent.value?.id;
if (!agentId) return;
handleClose();
router.push({ path: '/platform/integrations', query: { agentId } });
}
const filteredIntentPlaceholders = computed(() => {
if (!intentPromptPopup.value.prefix) {
return placeholderData.value.system_prompt;
@@ -2492,6 +2504,7 @@ watch(() => props.visible, async (val) => {
if (agentData.is_builtin) {
fillBuiltinAgentDefaults();
}
void loadAgentIntegrationCounts(agentData.id);
} else {
// 创建新智能体,使用系统默认值
const newFormData = JSON.parse(JSON.stringify(defaultFormData));
@@ -2557,6 +2570,9 @@ watch(() => props.visible, async (val) => {
}
applyDefaultChatModelIfEmpty()
}
} else {
agentIMChannelCount.value = 0;
agentEmbedChannelCount.value = 0;
}
});
@@ -4259,6 +4275,46 @@ const handleSave = async () => {
}
}
.integration-inline {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
&__stat {
font-size: 13px;
color: var(--td-text-color-secondary);
}
&__sep {
color: var(--td-component-stroke);
font-size: 12px;
}
&__link {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: 4px;
padding: 0;
border: none;
background: transparent;
font-size: 13px;
line-height: 1;
color: var(--td-brand-color);
cursor: pointer;
&:hover {
opacity: 0.85;
}
:deep(.t-icon) {
display: block;
}
}
}
.select-option-with-tag {
display: flex;
align-items: center;
+7
View File
@@ -1128,6 +1128,13 @@ const fetchList = (force = false) => {
const checkAndOpenEditModal = () => {
const editId = route.query.edit as string
const section = route.query.section as string
if (editId && (section === 'im' || section === 'embed' || section === 'integrations')) {
router.replace({
path: '/platform/integrations',
query: { tab: section, agentId: editId },
})
return
}
if (editId) {
const agent = agents.value.find(a => a.id === editId)
if (agent) {
@@ -0,0 +1,330 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="visible" class="settings-overlay" @click.self="handleClose">
<div class="settings-modal">
<button class="close-btn" @click="handleClose" :aria-label="$t('common.close')">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M15 5L5 15M5 5L15 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div class="settings-container">
<div class="settings-sidebar">
<div class="sidebar-header">
<h2 class="sidebar-title">{{ $t('integrations.title') }}</h2>
<p class="sidebar-subtitle">{{ $t('integrations.subtitle') }}</p>
</div>
<div class="settings-nav">
<div
v-for="item in navItems"
:key="item.key"
:class="['nav-item', { active: currentSection === item.key }]"
@click="currentSection = item.key"
>
<t-icon :name="item.icon" class="nav-icon" />
<span class="nav-label">{{ item.label }}</span>
</div>
</div>
</div>
<div class="settings-content">
<div class="content-wrapper">
<div v-show="currentSection === 'im'" class="section">
<div class="section-header">
<h2>{{ $t('agentEditor.im.title') }}</h2>
<p class="section-description">
{{ $t('agentEditor.im.description') }}
<a
href="https://github.com/Tencent/WeKnora/blob/main/docs/IM%E9%9B%86%E6%88%90%E5%BC%80%E5%8F%91%E6%96%87%E6%A1%A3.md"
target="_blank"
rel="noopener noreferrer"
class="doc-link"
>
{{ $t('agentEditor.im.docLink') }}
<t-icon name="link" class="link-icon" />
</a>
</p>
</div>
<IMChannelPanel tenant-mode :initial-agent-id="initialAgentId" />
</div>
<div v-show="currentSection === 'embed'" class="section">
<div class="section-header">
<h2>{{ $t('agentEditor.embed.title') }}</h2>
<p class="section-description">{{ $t('agentEditor.embed.description') }}</p>
</div>
<AgentEmbedChannelPanel tenant-mode :initial-agent-id="initialAgentId" />
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import IMChannelPanel from '@/components/IMChannelPanel.vue';
import AgentEmbedChannelPanel from '@/components/AgentEmbedChannelPanel.vue';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const currentSection = ref<'im' | 'embed'>('im');
const initialAgentId = ref('');
const visible = computed(() => route.name === 'integrations');
const navItems = computed(() => [
{ key: 'im' as const, icon: 'chat-message', label: t('integrations.tabs.im') },
{ key: 'embed' as const, icon: 'code', label: t('integrations.tabs.embed') },
]);
function applyRouteQuery() {
const tab = route.query.tab as string;
if (tab === 'im' || tab === 'embed') {
currentSection.value = tab;
}
initialAgentId.value = (route.query.agentId as string) || '';
}
function syncRouteQuery() {
const query: Record<string, string> = { tab: currentSection.value };
if (initialAgentId.value) {
query.agentId = initialAgentId.value;
}
router.replace({ path: route.path, query });
}
function handleClose() {
if (route.name !== 'integrations') return;
if (window.history.length > 1) {
router.back();
} else {
router.push('/platform/knowledge-bases');
}
}
watch(visible, (open) => {
if (open) applyRouteQuery();
});
watch(currentSection, () => {
if (visible.value) syncRouteQuery();
});
watch(
() => [route.query.tab, route.query.agentId],
() => {
if (visible.value) applyRouteQuery();
},
);
</script>
<style scoped lang="less">
.settings-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.settings-modal {
position: relative;
width: 90vw;
max-width: 1100px;
height: 85vh;
max-height: 750px;
background: var(--td-bg-color-container);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
overflow: hidden;
}
.close-btn {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--td-text-color-secondary);
transition: all 0.2s ease;
z-index: 10;
&:hover {
background: var(--td-bg-color-container-hover);
color: var(--td-text-color-primary);
}
}
.settings-container {
display: flex;
height: 100%;
width: 100%;
overflow: hidden;
}
.settings-sidebar {
width: 208px;
background-color: var(--td-bg-color-settings-modal);
border-right: 1px solid var(--td-component-stroke);
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 16px 14px 12px;
border-bottom: 1px solid var(--td-component-stroke);
flex-shrink: 0;
}
.sidebar-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--td-text-color-primary);
}
.sidebar-subtitle {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--td-text-color-placeholder);
}
.settings-nav {
flex: 1;
padding: 8px;
overflow-y: auto;
min-height: 0;
}
.nav-item {
display: flex;
align-items: center;
padding: 6px 12px;
margin-bottom: 2px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
color: var(--td-text-color-primary);
&:hover {
background: var(--td-bg-color-container-hover);
}
&.active {
background: var(--td-bg-color-secondarycontainer);
color: var(--td-brand-color);
font-weight: 500;
.nav-icon {
color: var(--td-brand-color);
}
}
}
.nav-icon {
margin-right: 8px;
font-size: 16px;
color: var(--td-text-color-secondary);
flex-shrink: 0;
}
.nav-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content-wrapper {
flex: 1;
overflow-y: auto;
padding: 24px 28px 28px;
}
.section-header {
margin-bottom: 20px;
h2 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: var(--td-text-color-primary);
}
}
.section-description {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--td-text-color-secondary);
.doc-link {
color: var(--td-brand-color);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
&:hover {
text-decoration: underline;
}
}
.link-icon {
font-size: 14px;
}
}
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
.settings-modal {
transition: transform 0.2s ease, opacity 0.2s ease;
}
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.settings-modal {
transform: scale(0.98);
opacity: 0;
}
}
</style>
@@ -0,0 +1,7 @@
<template><div class="route-placeholder" aria-hidden="true" /></template>
<style scoped>
.route-placeholder {
display: none;
}
</style>
+2
View File
@@ -10,6 +10,7 @@
</div>
<!-- 全局设置模态框,供所有 platform 子路由使用 -->
<Settings />
<IntegrationsModal />
<!-- 全局命令面板 (⌘K),随 platform 路由存活 -->
<GlobalCommandPalette />
<!-- 全局右上角"待处理邀请"铃铛。固定定位,z-index 低于抽屉,业务页面
@@ -26,6 +27,7 @@ import { useRoute, useRouter } from 'vue-router'
import useKnowledgeBase from '@/hooks/useKnowledgeBase'
import UploadMask from '@/components/upload-mask.vue'
import Settings from '@/views/settings/Settings.vue'
import IntegrationsModal from '@/views/integrations/IntegrationsModal.vue'
import GlobalCommandPalette from '@/components/GlobalCommandPalette.vue'
import GlobalInvitationBell from '@/components/GlobalInvitationBell.vue'
import NewUserGuide from '@/components/NewUserGuide.vue'