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:
@@ -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 |
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user