Files
Enterprise-Knowledge-Base/frontend/src/components/menu.vue
T
wizardchen 16e24cfc95 ui(frontend): polish channel cards and use official QQBot icon
Replace the placeholder QQBot logo with the official documentation favicon and enlarge channel card badges for clearer platform recognition.
2026-06-27 16:55:37 +08:00

1991 lines
67 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="aside_box" :class="{ 'aside_box--collapsed': uiStore.sidebarCollapsed }">
<!-- 展开时:Logo + 搜索/折叠按钮同行 -->
<div class="logo_row" v-if="!uiStore.sidebarCollapsed">
<div class="logo_box" @click="router.push('/platform/knowledge-bases')" style="cursor: pointer;">
<img class="logo" src="@/assets/img/weknora.png" alt="">
<sup v-if="isLiteEdition" class="lite-badge">Lite</sup>
</div>
<div class="logo_actions">
<t-tooltip placement="bottom">
<template #content>
<span class="cmdk-tip">
<span class="cmdk-tip-label">{{ t('menu.search') }}</span>
<span class="cmdk-tip-keys">{{ cmdModKeyLabel }}K</span>
</span>
</template>
<div class="header-icon-btn" @click="commandPaletteStore.openPalette('')"
:aria-label="t('menu.search')">
<img class="header-icon-img" :src="getImgSrc('search.svg')" alt="">
</div>
</t-tooltip>
<div class="sidebar-toggle" @click="uiStore.toggleSidebar" :title="t('menu.collapseSidebar')">
<svg viewBox="0 0 20 20" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="1.5" width="17" height="17" rx="3" stroke="currentColor" stroke-width="1.2" />
<line x1="7.5" y1="1.5" x2="7.5" y2="18.5" stroke="currentColor" stroke-width="1.2" />
<line x1="4" y1="7.5" x2="4" y2="12.5" stroke="currentColor" stroke-width="1.2"
stroke-linecap="round" />
</svg>
</div>
</div>
</div>
<!-- 折叠时:展开按钮 -->
<t-tooltip v-else :content="t('menu.expandSidebar')" placement="right">
<div class="menu_item sidebar-toggle-item" @click="uiStore.toggleSidebar">
<div class="menu_item-box">
<div class="menu_icon">
<svg class="icon" viewBox="0 0 20 20" width="20" height="20" fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="1.5" width="17" height="17" rx="3" stroke="currentColor"
stroke-width="1.2" />
<line x1="7.5" y1="1.5" x2="7.5" y2="18.5" stroke="currentColor" stroke-width="1.2" />
<line x1="5" y1="10" x2="3" y2="8" stroke="currentColor" stroke-width="1.2"
stroke-linecap="round" />
<line x1="5" y1="10" x2="3" y2="12" stroke="currentColor" stroke-width="1.2"
stroke-linecap="round" />
</svg>
</div>
</div>
</div>
</t-tooltip>
<!-- 租户选择器:仅在用户可切换租户时显示 -->
<TenantSelector v-if="canAccessAllTenants && !uiStore.sidebarCollapsed" />
<!-- 折叠时右侧拖拽展开手柄 -->
<div v-if="uiStore.sidebarCollapsed" class="sidebar-drag-handle" @mousedown="onDragHandleMouseDown" />
<!-- 上半部分:新对话吸顶 + 知识库/智能体/共享空间/历史会话随滚动一起滚走 -->
<div class="menu_top" ref="scrollContainer" @scroll="handleScroll">
<!-- 全局搜索入口:点击打开命令面板(⌘K)。展开态移至顶部 logo_row 的图标按钮;
折叠态在此处保留为图标项 + 深色 tooltip。 -->
<div class="menu_box menu_box--cmdk" v-if="uiStore.sidebarCollapsed">
<t-tooltip placement="right">
<template #content>
<span class="cmdk-tip">
<span class="cmdk-tip-label">{{ t('menu.search') }}</span>
<span class="cmdk-tip-keys">{{ cmdModKeyLabel }}K</span>
</span>
</template>
<div class="menu_item menu_item--cmdk" @click="commandPaletteStore.openPalette('')">
<div class="menu_item-box">
<div class="menu_icon">
<img class="icon" :src="getImgSrc('search.svg')" alt="">
</div>
</div>
</div>
</t-tooltip>
</div>
<div class="menu_box" :class="{ 'menu_box--sticky': item.children && !uiStore.sidebarCollapsed }"
v-for="(item, index) in topMenuItems" :key="index">
<t-tooltip :content="item.title" placement="right" :disabled="!uiStore.sidebarCollapsed">
<div @click="handleMenuClick(item.path)" @mouseenter="mouseenteMenu(item.path)"
@mouseleave="mouseleaveMenu(item.path)" :data-guide="`nav-${item.path}`"
:class="['menu_item', item.childrenPath && item.childrenPath == currentpath ? 'menu_item_c_active' : isMenuItemActive(item.path) ? 'menu_item_active' : '']">
<div class="menu_item-box">
<div class="menu_icon">
<img class="icon"
: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">
<span class="menu_title" :title="item.title">{{ item.title }}</span>
<span v-if="item.path === 'organizations' && orgStore.totalPendingJoinRequestCount > 0"
class="menu-pending-badge"
:title="t('organization.settings.pendingJoinRequestsBadge')">{{
orgStore.totalPendingJoinRequestCount }}</span>
</template>
</div>
</div>
</t-tooltip>
</div>
<!-- 历史会话:按来源筛选后统一按日期分组展示 -->
<div class="submenu" v-if="!uiStore.sidebarCollapsed"
:class="{ 'submenu--scope-fallback': showSessionScopeFallback }">
<div v-if="showSessionScopeFallback" class="session-list-scope-fallback">
<SessionSourceFilter inline :emphasized="sessionScopeFilterPinned" :sources="sessionSourceOptions"
:current="activeSessionBucketKey" @select="switchSessionBucket" />
</div>
<template v-if="sessionListBooting && !hasAnySession">
<div v-for="n in 4" :key="'skel-' + n" class="submenu_item_p session-chat-row">
<div class="session-list-row session-list-row--flat">
<t-skeleton animation="gradient" class="session-list-row__body"
:row-col="[{ width: '100%', height: '14px' }]" />
</div>
</div>
</template>
<div v-else class="session-filtered-list">
<template
v-if="activeBucket?.loading && !activeBucket.loaded && filteredGroupedSessions.length === 0">
<div v-for="n in 4" :key="'bucket-skel-' + n" class="submenu_item_p session-chat-row">
<div class="session-list-row session-list-row--flat">
<t-skeleton animation="gradient" class="session-list-row__body"
:row-col="[{ width: '100%', height: '14px' }]" />
</div>
</div>
</template>
<template v-else-if="activeBucket?.loaded && filteredGroupedSessions.length === 0">
<div class="submenu_empty">{{ t('menu.noSessions') }}</div>
</template>
<template v-else>
<template v-for="(group, groupIndex) in filteredGroupedSessions" :key="group.key">
<div v-if="group.label" class="timeline_header session-list-row session-list-row--flat"
:class="{ 'timeline_header--with-scope': groupIndex === 0 && showSessionSourceFilter && !batchMode }">
<span class="session-list-row__body">
<span class="timeline_header-label">{{ group.label }}</span>
</span>
<SessionSourceFilter v-if="groupIndex === 0 && showSessionSourceFilter && !batchMode"
inline :emphasized="sessionScopeFilterPinned" :sources="sessionSourceOptions"
:current="activeSessionBucketKey" @select="switchSessionBucket" />
</div>
<div v-for="subitem in group.items" :key="subitem.id"
class="submenu_item_p session-chat-row" :class="{
'session-chat-row--active': !batchMode && subitem.path === currentSecondpath,
'session-chat-row--selected': batchMode && batchSelectedIds.includes(subitem.id),
}">
<div class="session-list-row session-list-row--flat">
<div class="session-list-row__body">
<SessionSidebarRow :item="subitem" :batch-mode="batchMode"
:active-path="currentSecondpath" :selected-ids="batchSelectedIds"
:menu-options="buildSessionMenuOptions(subitem)"
@navigate="gotopage(subitem.path)"
@toggle-select="toggleBatchSelect(subitem.id)"
@menu-click="handleSessionMenuClick($event, subitem)"
@hover-in="mouseenteBotDownr(subitem.id)" @hover-out="mouseleaveBotDown" />
</div>
</div>
</div>
</template>
<div v-if="activeBucket?.loading && filteredGroupedSessions.length > 0"
class="session-list-loading session-list-row session-list-row--flat">
<span class="session-list-row__body">
<t-loading size="small" />
</span>
</div>
</template>
</div>
</div>
<!-- 批量管理底部操作条 -->
<div v-if="batchMode && !uiStore.sidebarCollapsed" class="batch-inline-footer">
<div class="batch-footer-left">
<t-checkbox :checked="isAllBatchSelected" :indeterminate="isBatchIndeterminate"
@change="toggleBatchSelectAll">
{{ t('batchManage.selectAll') }}
</t-checkbox>
</div>
<div class="batch-footer-right">
<t-button size="small" variant="text" @click="exitBatchMode">
{{ t('batchManage.cancel') }}
</t-button>
<t-button size="small" theme="danger" variant="base" :disabled="batchSelectedIds.length === 0"
:loading="batchDeleting" @click="handleInlineBatchDelete">
{{ t('batchManage.delete') }}{{ batchSelectedIds.length > 0 ? `(${batchDisplayCount})` : '' }}
</t-button>
</div>
</div>
</div>
<!-- 下半部分:用户菜单 -->
<div class="menu_bottom">
<UserMenu />
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { onMounted, onUnmounted, watch, computed, ref, h, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getSessionsList, delSession, batchDelSessions, deleteAllSessions, clearSessionMessages, pinSession, unpinSession } from "@/api/chat/index";
import { useChatResourcesStore } from '@/stores/chatResources';
import { listAllIMChannels } from '@/api/agent/index';
import SessionSidebarRow from './SessionSidebarRow.vue';
import SessionSourceFilter from './SessionSourceFilter.vue';
import {
SIDEBAR_BUCKET_PAGE_SIZE,
applyBucketCountProbe,
buildBucketDefinitions,
bucketHasMore,
bucketVisible,
createEmptyBucket,
flattenBucketItems,
isChannelBucket,
isChannelBucketKey,
mergeBucketPage,
prependSessionToWebBucket,
removeSessionFromBuckets,
type SidebarSessionBucket,
} from './sessionSidebarBuckets';
import type { SessionForGrouping } from './sessionGrouping';
import { listAllEmbedChannels } from '@/api/embed/index';
import {
classifyDateBucket,
configuredPlatforms,
groupSessionsByDate,
originGroupKey,
resolveSessionOrigin,
type DateBucketKey,
} from './sessionGrouping';
import {
DEFAULT_SESSION_BUCKET_KEY,
buildSessionSourceOptions,
findSessionBucketKey,
shouldShowSessionSourceFilter,
} from './sessionSidebarSourceFilter';
import { logout as logoutApi } from '@/api/auth';
import { useMenuStore } from '@/stores/menu';
import { useAuthStore } from '@/stores/auth';
import { useOrganizationStore } from '@/stores/organization';
import { useUIStore } from '@/stores/ui';
import { useCommandPaletteStore } from '@/stores/commandPalette';
import { MessagePlugin, DialogPlugin, Icon as TIcon } from "tdesign-vue-next";
import UserMenu from '@/components/UserMenu.vue';
import TenantSelector from '@/components/TenantSelector.vue';
import { useI18n } from 'vue-i18n';
import { getSystemInfo } from '@/api/system';
const chatResources = useChatResourcesStore();
// Platform logos reused from IMChannelsOverviewPanel — keeps the session list
// visually consistent with the channels admin view.
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';
import qqbotLogo from '@/assets/img/im/qqbot.png';
const PLATFORM_LOGO: Record<string, string> = {
wecom: wecomLogo,
feishu: feishuLogo,
slack: slackLogo,
telegram: telegramLogo,
dingtalk: dingtalkLogo,
mattermost: mattermostLogo,
wechat: wechatLogo,
qqbot: qqbotLogo,
};
const platformLogo = (p: string): string => (p ? PLATFORM_LOGO[p] || '' : '');
const { t } = useI18n();
const usemenuStore = useMenuStore();
const authStore = useAuthStore();
const orgStore = useOrganizationStore();
const uiStore = useUIStore();
const commandPaletteStore = useCommandPaletteStore();
// Platform-aware label for the ⌘K hint. navigator.platform is deprecated but
// the alternatives (userAgentData.platform) aren't universally available yet;
// this check is good enough for Mac vs. non-Mac.
const isMacLike = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform || '');
const cmdModKeyLabel = isMacLike ? '⌘' : 'Ctrl';
const route = useRoute();
const router = useRouter();
const currentpath = ref('');
const total = ref(0);
const sessionBuckets = ref<Record<string, SidebarSessionBucket>>({});
const bucketOrder = ref<string[]>([]);
let bucketRequestToken = 0;
const sessionListBooting = ref(false);
const currentSecondpath = ref('');
const scrollContainer = ref<HTMLElement | null>(null);
const imPlatforms = ref<string[]>([]);
const embedChannelNames = ref<Record<string, string>>({});
const activeSessionBucketKey = ref(DEFAULT_SESSION_BUCKET_KEY);
const sessionListCanScroll = ref(false);
const visibleChannelBuckets = computed(() =>
bucketOrder.value
.map((key) => sessionBuckets.value[key])
.filter((bucket): bucket is SidebarSessionBucket => !!bucket && isChannelBucket(bucket) && bucketVisible(bucket)),
);
const showSessionSourceFilter = computed(() =>
shouldShowSessionSourceFilter(visibleChannelBuckets.value.length),
);
const sessionScopeFilterPinned = computed(() =>
activeSessionBucketKey.value !== DEFAULT_SESSION_BUCKET_KEY,
);
const sessionSourceOptions = computed(() =>
buildSessionSourceOptions(
t('menu.myChats'),
visibleChannelBuckets.value.map((bucket) => ({
key: bucket.key,
label: bucket.label,
platform: bucket.platform,
})),
(platform) => platformLogo(platform),
),
);
const activeBucket = computed(() => sessionBuckets.value[activeSessionBucketKey.value]);
const hasAnySession = computed(() =>
Object.values(sessionBuckets.value).some((bucket) => bucket.items.length > 0),
);
type MenuItem = { title: string; icon: string; path: string; childrenPath?: string; children?: any[] };
const { menuArr, visibleMenuArr } = storeToRefs(usemenuStore);
let activeSubmenu = ref<string>('');
const isLiteEdition = ref(false);
// 批量管理状态
const batchMode = ref(false)
const batchSelectedIds = ref<string[]>([])
const batchDeleting = ref(false)
const allSessionIds = computed(() => {
const chatMenu = (menuArr.value as unknown as MenuItem[]).find((item: MenuItem) => item.path === 'creatChat');
if (!chatMenu?.children) return [];
return (chatMenu.children as any[]).map((s: any) => s.id);
})
const isAllBatchSelected = computed(() =>
allSessionIds.value.length > 0 && batchSelectedIds.value.length === allSessionIds.value.length
)
const isBatchIndeterminate = computed(() =>
batchSelectedIds.value.length > 0 && batchSelectedIds.value.length < allSessionIds.value.length
)
const batchDisplayCount = computed(() =>
isAllBatchSelected.value ? total.value : batchSelectedIds.value.length
)
// 是否可以访问所有租户
const canAccessAllTenants = computed(() => authStore.canAccessAllTenants);
// 是否处于知识库详情页(不包括全局聊天)
const isInKnowledgeBase = computed<boolean>(() => {
return route.name === 'knowledgeBaseDetail' ||
route.name === 'kbCreatChat' ||
route.name === 'knowledgeBaseSettings';
});
// 是否在知识库列表页面
const isInKnowledgeBaseList = computed<boolean>(() => {
return route.name === 'knowledgeBaseList';
});
// 是否在创建聊天页面
const isInCreatChat = computed<boolean>(() => {
return route.name === 'globalCreatChat' || route.name === 'kbCreatChat';
});
// 是否在对话详情页
const isInChatDetail = computed<boolean>(() => route.name === 'chat');
// 是否在智能体列表页面
const isInAgentList = computed<boolean>(() => route.name === 'agentList');
// 是否在组织列表页面
const isInOrganizationList = computed<boolean>(() => route.name === 'organizationList');
// 统一的菜单项激活状态判断
const isMenuItemActive = (itemPath: string): boolean => {
const currentRoute = route.name;
switch (itemPath) {
case 'knowledge-bases':
return currentRoute === 'knowledgeBaseList' ||
currentRoute === 'knowledgeBaseDetail' ||
currentRoute === 'knowledgeBaseSettings';
case 'agents':
return currentRoute === 'agentList';
case 'integrations':
return currentRoute === 'integrations';
case 'organizations':
return currentRoute === 'organizationList';
case 'creatChat':
return currentRoute === 'kbCreatChat' || currentRoute === 'globalCreatChat';
case 'settings':
return currentRoute === 'settings';
default:
return itemPath === currentpath.value;
}
};
// 统一的图标激活状态判断
const getIconActiveState = (itemPath: string) => {
const currentRoute = route.name;
return {
isKbActive: itemPath === 'knowledge-bases' && (
currentRoute === 'knowledgeBaseList' ||
currentRoute === 'knowledgeBaseDetail' ||
currentRoute === 'knowledgeBaseSettings'
),
isCreatChatActive: itemPath === 'creatChat' && (currentRoute === 'kbCreatChat' || currentRoute === 'globalCreatChat'),
isSettingsActive: itemPath === 'settings' && currentRoute === 'settings',
isChatActive: itemPath === 'chat' && currentRoute === 'chat'
};
};
// 分离上下两部分菜单(使用 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 === '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 === 'integrations' || item.path === 'organizations' || item.path === 'creatChat') {
return false;
}
return true;
});
});
// 当前知识库信息
const currentKbName = ref<string>('')
const currentKbInfo = ref<any>(null)
// 进行中的置顶/取消置顶请求,避免重复点击
const pinningIds = ref<Set<string>>(new Set())
// 「聊天」区内按日期分组(当前筛选来源)
const dateBucketLabels = computed<Record<DateBucketKey, string>>(() => ({
pinned: t('time.pinned'),
today: t('time.today'),
yesterday: t('time.yesterday'),
last7Days: t('time.last7Days'),
last30Days: t('time.last30Days'),
lastYear: t('time.lastYear'),
earlier: t('time.earlier'),
}));
const filteredGroupedSessions = computed(() => {
const bucket = activeBucket.value;
if (!bucket?.items.length) return [];
return groupSessionsByDate(
bucket.items.map((item) => ({
...item,
path: `chat/${item.id}`,
})),
dateBucketLabels.value,
(session) => classifyDateBucket(session.updated_at || session.created_at),
);
});
const showSessionScopeFallback = computed(() => {
if (!showSessionSourceFilter.value || batchMode.value) return false;
if (sessionListBooting.value && !hasAnySession.value) return true;
const bucket = activeBucket.value;
if (bucket?.loading && !bucket.loaded && filteredGroupedSessions.value.length === 0) return true;
if (bucket?.loaded && filteredGroupedSessions.value.length === 0) return true;
return false;
});
const refreshSessionListScrollability = async () => {
await nextTick();
const container = scrollContainer.value;
sessionListCanScroll.value = !!container && container.scrollHeight > container.clientHeight + 1;
};
/** 列表未撑满滚动区时自动续页(按当前可见 DOM 测量,避免折叠导致误判) */
const ensureBucketFillsViewport = async (key: string) => {
const MAX_ITERATIONS = 20;
for (let i = 0; i < MAX_ITERATIONS; i++) {
await nextTick();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const container = scrollContainer.value;
const bucket = sessionBuckets.value[key];
if (!container || !bucket || !bucketHasMore(bucket) || bucket.loading) break;
const hasOverflow = container.scrollHeight > container.clientHeight + 1;
if (hasOverflow) break;
const prevCount = bucket.items.length;
await loadBucketPage(key);
if ((sessionBuckets.value[key]?.items.length ?? 0) <= prevCount) break;
}
};
const mouseenteBotDownr = (val: string) => {
activeSubmenu.value = val;
}
const mouseleaveBotDown = () => {
activeSubmenu.value = '';
}
const enterBatchMode = () => {
batchMode.value = true
batchSelectedIds.value = []
}
const exitBatchMode = () => {
batchMode.value = false
batchSelectedIds.value = []
}
const toggleBatchSelect = (id: string) => {
const idx = batchSelectedIds.value.indexOf(id)
if (idx > -1) {
batchSelectedIds.value.splice(idx, 1)
} else {
batchSelectedIds.value.push(id)
}
}
const toggleBatchSelectAll = (checked: boolean) => {
batchSelectedIds.value = checked ? [...allSessionIds.value] : []
}
const handleInlineBatchDelete = () => {
if (batchSelectedIds.value.length === 0) return
const isDeleteAll = isAllBatchSelected.value
const displayCount = batchDisplayCount.value
const confirmDialog = DialogPlugin.confirm({
header: t('batchManage.deleteConfirmTitle'),
body: isDeleteAll
? t('batchManage.deleteAllConfirmBody') || t('batchManage.deleteConfirmBody', { count: displayCount })
: t('batchManage.deleteConfirmBody', { count: displayCount }),
confirmBtn: { content: t('batchManage.delete'), theme: 'danger' as const },
cancelBtn: t('batchManage.cancel'),
theme: 'warning',
onConfirm: async () => {
batchDeleting.value = true
try {
let res: any
if (isDeleteAll) {
res = await deleteAllSessions()
} else {
res = await batchDelSessions([...batchSelectedIds.value])
}
if (res && res.success === true) {
if (isDeleteAll) {
usemenuStore.clearMenuArr();
total.value = 0;
await getMessageList();
} else {
let next = sessionBuckets.value;
for (const id of batchSelectedIds.value) {
next = removeSessionFromBuckets(next, id);
}
sessionBuckets.value = next;
syncMenuStoreFromBuckets();
}
const currentChatId = route.params.chatid as string;
if (currentChatId && (isDeleteAll || batchSelectedIds.value.includes(currentChatId))) {
router.push('/platform/creatChat');
}
batchSelectedIds.value = []
MessagePlugin.success(t('batchManage.deleteSuccess'))
exitBatchMode()
} else {
MessagePlugin.error(t('batchManage.deleteFailed'))
}
} catch {
MessagePlugin.error(t('batchManage.deleteFailed'))
}
batchDeleting.value = false
confirmDialog.destroy()
},
})
}
const handleSessionMenuClick = (data: { value: string }, item: any) => {
if (data?.value === 'delete') {
delCard(item);
} else if (data?.value === 'clearMessages') {
clearMessages(item);
} else if (data?.value === 'batchManage') {
enterBatchMode()
} else if (data?.value === 'pin' || data?.value === 'unpin') {
togglePin(item, data.value === 'pin');
}
};
// 基于会话来源推导展示用的短标签已经被 platformLogo(<img>) 取代,Web 会话没有图标。
const buildSessionMenuOptions = (item: any) => {
const options: any[] = [];
if (item.is_pinned) {
options.push({
content: t('menu.unpin'),
value: 'unpin',
prefixIcon: () => h(TIcon, { name: 'pin', size: '16px' }),
});
} else {
options.push({
content: t('menu.pin'),
value: 'pin',
prefixIcon: () => h(TIcon, { name: 'pin', size: '16px' }),
});
}
options.push(
{ content: t('menu.clearMessages'), value: 'clearMessages', prefixIcon: () => h(TIcon, { name: 'clear', size: '16px' }) },
{ content: t('menu.batchManage'), value: 'batchManage', prefixIcon: () => h(TIcon, { name: 'queue', size: '16px' }) },
{ content: t('upload.deleteRecord'), value: 'delete', theme: 'error', prefixIcon: () => h(TIcon, { name: 'delete', size: '16px' }) },
);
return options;
};
const updateSessionInBuckets = (
sessionId: string,
patch: Partial<{ is_pinned: boolean; pinned_at: string | null; title: string; isNoTitle?: boolean }>,
) => {
const next: Record<string, SidebarSessionBucket> = {};
for (const [key, bucket] of Object.entries(sessionBuckets.value)) {
next[key] = {
...bucket,
items: bucket.items.map((row) => (row.id === sessionId ? { ...row, ...patch } : row)),
};
}
sessionBuckets.value = next;
syncMenuStoreFromBuckets();
};
const togglePin = (item: any, pin: boolean) => {
if (pinningIds.value.has(item.id)) return;
pinningIds.value.add(item.id);
const call = pin ? pinSession(item.id) : unpinSession(item.id);
call.then((res: any) => {
if (res && res.success) {
updateSessionInBuckets(item.id, {
is_pinned: pin,
pinned_at: pin ? new Date().toISOString() : null,
});
} else {
MessagePlugin.error(pin ? t('menu.pinFailed') : t('menu.unpinFailed'));
}
}).catch(() => {
MessagePlugin.error(pin ? t('menu.pinFailed') : t('menu.unpinFailed'));
}).finally(() => {
pinningIds.value.delete(item.id);
});
};
const clearMessages = (item: any) => {
clearSessionMessages(item.id).then((res: any) => {
if (res && res.success) {
MessagePlugin.success(t('menu.clearMessagesSuccess'));
if (item.id === route.params.chatid) {
window.dispatchEvent(new CustomEvent('session-messages-cleared', { detail: { sessionId: item.id } }));
}
} else {
MessagePlugin.error(t('menu.clearMessagesFailed'));
}
}).catch(() => {
MessagePlugin.error(t('menu.clearMessagesFailed'));
});
};
const delCard = (item: any) => {
delSession(item.id).then((res: any) => {
if (res && (res as any).success) {
sessionBuckets.value = removeSessionFromBuckets(sessionBuckets.value, item.id);
syncMenuStoreFromBuckets();
if (item.id == route.params.chatid) {
router.push('/platform/creatChat');
}
} else {
MessagePlugin.error(t('chat.deleteSessionFailed'));
}
})
}
const debounce = (fn: (...args: any[]) => void, delay: number) => {
let timer: ReturnType<typeof setTimeout>
return (...args: any[]) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}
const mapSessionRow = (item: any) => ({
title: item.title ? item.title : t('menu.newSession'),
path: `chat/${item.id}`,
id: item.id,
isMore: false,
isNoTitle: item.title ? false : true,
created_at: item.created_at,
updated_at: item.updated_at,
is_pinned: !!item.is_pinned,
pinned_at: item.pinned_at || null,
im_platform: item.im_platform || '',
description: item.description || '',
});
const syncMenuStoreFromBuckets = () => {
usemenuStore.clearMenuArr();
const flat = flattenBucketItems(sessionBuckets.value, bucketOrder.value);
flat.forEach((item) => usemenuStore.updatemenuArr(item));
total.value = flat.length;
};
const menuChildToSessionRow = (item: Record<string, unknown>): SessionForGrouping & { path: string } => {
const id = String(item.id);
return {
id,
path: typeof item.path === 'string' ? item.path : `chat/${id}`,
title: typeof item.title === 'string' ? item.title : undefined,
is_pinned: !!item.is_pinned,
created_at: typeof item.created_at === 'string' ? item.created_at : undefined,
updated_at: typeof item.updated_at === 'string' ? item.updated_at : undefined,
im_platform: typeof item.im_platform === 'string' ? item.im_platform : '',
description: typeof item.description === 'string' ? item.description : '',
};
};
const sessionExistsInBuckets = (sessionId: string) =>
Object.values(sessionBuckets.value).some((bucket) => bucket.items.some((row) => row.id === sessionId));
/** 创建会话后 menuStore 已乐观写入,但列表实际渲染自 sessionBuckets,需补齐。 */
const ensureSessionInSidebar = (sessionId: string) => {
if (!sessionId || sessionExistsInBuckets(sessionId)) return;
const web = sessionBuckets.value.web;
if (!web) return;
const chatMenu = (menuArr.value as unknown as MenuItem[]).find((item) => item.path === 'creatChat');
const fromStore = (chatMenu?.children as Record<string, unknown>[] | undefined)
?.find((item) => item.id === sessionId);
if (!fromStore) return;
sessionBuckets.value = {
...sessionBuckets.value,
web: prependSessionToWebBucket(web, menuChildToSessionRow(fromStore)),
};
total.value = flattenBucketItems(sessionBuckets.value, bucketOrder.value).length;
};
const rebuildBucketDefinitions = () => buildBucketDefinitions(
imPlatforms.value,
embedChannelNames.value,
{
web: t('menu.myChats'),
imPlatform: (platform) => t(`agentEditor.im.${platform}`),
embedChannel: (name) => name,
},
);
/** 首屏轻量探测各渠道是否有会话(page_size=1 只取 total),避免展示空文件夹 */
const probeChannelBucketCounts = async (keys: string[], token: number) => {
const targets = keys.filter((key) => isChannelBucketKey(key));
await Promise.all(
targets.map(async (key) => {
const bucket = sessionBuckets.value[key];
if (!bucket) return;
try {
const res: any = await getSessionsList(1, 1, bucket.apiSource);
if (token !== bucketRequestToken) return;
sessionBuckets.value = {
...sessionBuckets.value,
[key]: applyBucketCountProbe(bucket, res?.total ?? 0),
};
} catch {
if (token !== bucketRequestToken) return;
sessionBuckets.value = {
...sessionBuckets.value,
[key]: applyBucketCountProbe(bucket, 0),
};
}
}),
);
};
const loadBucketPage = async (key: string, page?: number, token?: number) => {
const activeToken = token ?? bucketRequestToken;
const bucket = sessionBuckets.value[key];
if (!bucket || bucket.loading) return;
const nextPage = page ?? bucket.page + 1;
sessionBuckets.value = {
...sessionBuckets.value,
[key]: { ...bucket, loading: true },
};
try {
const res: any = await getSessionsList(nextPage, SIDEBAR_BUCKET_PAGE_SIZE, bucket.apiSource);
if (activeToken !== bucketRequestToken) return;
const rows = (res?.data || []).map((item: any) => mapSessionRow(item));
const current = sessionBuckets.value[key];
sessionBuckets.value = {
...sessionBuckets.value,
[key]: mergeBucketPage(current, rows, res?.total ?? rows.length, nextPage),
};
syncMenuStoreFromBuckets();
await refreshSessionListScrollability();
} catch {
if (activeToken !== bucketRequestToken) return;
const current = sessionBuckets.value[key];
sessionBuckets.value = {
...sessionBuckets.value,
[key]: { ...current, loading: false, loaded: true },
};
}
};
const switchSessionBucket = async (key: string) => {
if (key === activeSessionBucketKey.value) return;
activeSessionBucketKey.value = key;
const bucket = sessionBuckets.value[key];
if (bucket && !bucket.loaded && !bucket.loading) {
await loadBucketPage(key, 1);
}
await ensureBucketFillsViewport(key);
await refreshSessionListScrollability();
};
const syncActiveBucketFromChat = async (sessionId: string | undefined) => {
if (!sessionId) return;
let bucketKey = findSessionBucketKey(sessionBuckets.value, sessionId);
if (!bucketKey) {
const chatMenu = (menuArr.value as unknown as MenuItem[]).find((item) => item.path === 'creatChat');
const fromStore = (chatMenu?.children as Record<string, unknown>[] | undefined)
?.find((item) => item.id === sessionId);
if (fromStore) {
bucketKey = originGroupKey(resolveSessionOrigin(menuChildToSessionRow(fromStore)));
}
}
if (!bucketKey || bucketKey === activeSessionBucketKey.value) return;
activeSessionBucketKey.value = bucketKey;
const bucket = sessionBuckets.value[bucketKey];
if (bucket && !bucket.loaded && !bucket.loading) {
await loadBucketPage(bucketKey, 1);
}
};
const initSessionBuckets = async () => {
const token = ++bucketRequestToken;
sessionListBooting.value = true;
const defs = rebuildBucketDefinitions();
bucketOrder.value = defs.map((def) => def.key);
const buckets: Record<string, SidebarSessionBucket> = {};
for (const def of defs) {
buckets[def.key] = createEmptyBucket(def);
}
sessionBuckets.value = buckets;
// 首屏:拉 web 会话 + 轻量探测各渠道 count(不拉完整列表);有会话的渠道才展示文件夹
const channelKeys = defs.map((def) => def.key).filter((key) => isChannelBucketKey(key));
await Promise.all([
loadBucketPage('web', 1, token),
probeChannelBucketCounts(channelKeys, token),
]);
if (token === bucketRequestToken) {
sessionListBooting.value = false;
syncMenuStoreFromBuckets();
await ensureBucketFillsViewport('web');
await refreshSessionListScrollability();
}
};
const getMessageList = async () => {
await initSessionBuckets();
};
// 滚动到底时为当前筛选来源加载下一页
const checkScrollBottom = async () => {
const container = scrollContainer.value;
const key = activeSessionBucketKey.value;
const bucket = sessionBuckets.value[key];
if (!container || !bucket || !bucketHasMore(bucket) || bucket.loading) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const hasOverflow = scrollHeight > clientHeight + 1;
if (!hasOverflow) {
await ensureBucketFillsViewport(key);
return;
}
const isNearBottom = scrollHeight - (scrollTop + clientHeight) < 100;
if (!isNearBottom) return;
await loadBucketPage(key);
};
const handleScroll = debounce(checkScrollBottom, 200);
async function loadCurrentKbInfo(kbId: string) {
if (!kbId || !isInKnowledgeBase.value) {
currentKbName.value = ''
currentKbInfo.value = null
return
}
const data = await chatResources.fetchKnowledgeBaseById(kbId)
if (data) {
currentKbName.value = data.name || ''
currentKbInfo.value = data
} else {
currentKbInfo.value = null
}
}
const loadSessionOriginMeta = async () => {
try {
const res: any = await listAllIMChannels();
imPlatforms.value = configuredPlatforms(res?.data || []);
} catch {
imPlatforms.value = [];
}
try {
const res: any = await listAllEmbedChannels();
const names: Record<string, string> = {};
for (const ch of res?.data || []) {
if (ch?.id && ch?.name) names[ch.id] = ch.name;
}
embedChannelNames.value = names;
} catch {
embedChannelNames.value = {};
}
};
const handleSessionTitleUpdated = (event: Event) => {
const detail = (event as CustomEvent<{ sessionId?: string; title?: string }>).detail;
if (!detail?.sessionId || !detail.title) return;
updateSessionInBuckets(detail.sessionId, { title: detail.title, isNoTitle: false });
};
onMounted(async () => {
const routeName = typeof route.name === 'string' ? route.name : (route.name ? String(route.name) : '')
currentpath.value = routeName;
if (route.params.chatid) {
currentSecondpath.value = `chat/${route.params.chatid}`;
}
window.addEventListener('session-title-updated', handleSessionTitleUpdated);
isLiteEdition.value = authStore.isLiteMode
getSystemInfo().then(res => {
if (res.data?.edition === 'lite') {
isLiteEdition.value = true
authStore.setLiteMode(true)
}
}).catch(() => { })
await loadCurrentKbInfo((route.params as any)?.kbId as string)
await loadSessionOriginMeta();
await getMessageList();
const initialChatId = route.params.chatid as string | undefined;
if (initialChatId) {
ensureSessionInSidebar(initialChatId);
await syncActiveBucketFromChat(initialChatId);
}
// 若组织列表未加载则拉取一次,用于侧栏「待审批」角标
if (orgStore.organizations.length === 0) {
orgStore.fetchOrganizations();
}
});
onUnmounted(() => {
window.removeEventListener('session-title-updated', handleSessionTitleUpdated);
});
watch([() => route.name, () => route.params], (newvalue, oldvalue) => {
const nameStr = typeof newvalue[0] === 'string' ? (newvalue[0] as string) : (newvalue[0] ? String(newvalue[0]) : '')
currentpath.value = nameStr;
if (newvalue[1].chatid) {
currentSecondpath.value = `chat/${newvalue[1].chatid}`;
} else {
currentSecondpath.value = "";
}
// 创建新会话时 creatChat 会先 updataMenuChildren,再跳转 chat/:id。
// 侧栏实际渲染 sessionBuckets,需按 buckets 判断是否缺失,不能把 menuStore 当真相来源。
const newChatId = (newvalue[1] as any)?.chatid as string | undefined;
if (nameStr === 'chat' && newChatId) {
ensureSessionInSidebar(newChatId);
void syncActiveBucketFromChat(newChatId);
}
// 路由变化时更新图标状态和知识库信息(不涉及对话列表)
getIcon(nameStr);
// 如果切换了知识库,更新知识库名称但不重新加载对话列表
if (newvalue[1].kbId !== oldvalue?.[1]?.kbId) {
loadCurrentKbInfo((newvalue[1] as any)?.kbId as string);
}
});
let knowledgeIcon = ref('zhishiku-green.svg');
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) => {
// 根据当前路由状态更新所有图标
const kbActiveState = getIconActiveState('knowledge-bases');
const creatChatActiveState = getIconActiveState('creatChat');
const settingsActiveState = getIconActiveState('settings');
const agentsActiveState = route.name === 'agentList';
const integrationsActiveState = route.name === 'integrations';
const organizationsActiveState = route.name === 'organizationList';
// 知识库图标:只在知识库页面显示绿色
knowledgeIcon.value = kbActiveState.isKbActive ? 'zhishiku-green.svg' : 'zhishiku.svg';
// 智能体图标:只在智能体页面显示绿色
agentIcon.value = agentsActiveState ? 'agent-green.svg' : 'agent.svg';
integrationIcon.value = integrationsActiveState ? 'integration-green.svg' : 'integration.svg';
// 组织图标:只在组织页面显示绿色
organizationIcon.value = organizationsActiveState ? 'organization-green.svg' : 'organization.svg';
// 对话图标:只在对话创建页面显示绿色,其他情况显示默认
prefixIcon.value = creatChatActiveState.isCreatChatActive ? 'prefixIcon-green.svg' : 'prefixIcon.svg';
// 设置图标:只在设置页面显示绿色
settingIcon.value = settingsActiveState.isSettingsActive ? 'setting-green.svg' : 'setting.svg';
// 退出图标:始终显示默认
logoutIcon.value = 'logout.svg';
}
getIcon(typeof route.name === 'string' ? route.name as string : (route.name ? String(route.name) : ''))
const handleMenuClick = async (path: string) => {
if (path === 'knowledge-bases') {
// 知识库菜单项:如果在知识库内部,跳转到当前知识库文件页;否则跳转到知识库列表
const kbId = await getCurrentKbId()
if (kbId) {
router.push(`/platform/knowledge-bases/${kbId}`)
} else {
router.push('/platform/knowledge-bases')
}
} else if (path === 'agents') {
router.push('/platform/agents')
} else if (path === 'integrations') {
router.push('/platform/integrations')
} else if (path === 'organizations') {
// 组织菜单项:跳转到组织列表
router.push('/platform/organizations')
} else if (path === 'settings') {
// 设置菜单项:打开设置弹窗并跳转路由
uiStore.openSettings()
router.push('/platform/settings')
} else {
gotopage(path)
}
}
// 处理退出登录确认
const handleLogout = () => {
gotopage('logout')
}
const getCurrentKbId = async (): Promise<string | null> => {
const kbId = (route.params as any)?.kbId as string
if (isInKnowledgeBase.value && kbId) {
return kbId
}
return null
}
const gotopage = async (path: string) => {
pathPrefix.value = path;
// 处理退出登录
if (path === 'logout') {
try {
// 调用后端API注销
await logoutApi();
} catch (error) {
// 即使API调用失败,也继续执行本地清理
console.error('注销API调用失败:', error);
}
// 清理所有状态和本地存储
authStore.logout();
MessagePlugin.success(t('menu.logoutSuccess'));
router.push('/login');
return;
} else {
if (path === 'creatChat') {
// 如果在知识库详情页,跳转到全局对话创建页
if (isInKnowledgeBase.value) {
router.push('/platform/creatChat')
} else {
// 如果不在知识库内,进入对话创建页
router.push(`/platform/creatChat`)
}
} else {
router.push(`/platform/${path}`);
}
}
getIcon(path)
}
const getImgSrc = (url: string) => {
return new URL(`/src/assets/img/${url}`, import.meta.url).href;
}
const mouseenteMenu = (path: string) => {
}
const mouseleaveMenu = (path: string) => {
}
const onDragHandleMouseDown = (e: MouseEvent) => {
e.preventDefault()
const startX = e.clientX
const expandThreshold = 40
const onMouseMove = (ev: MouseEvent) => {
if (ev.clientX - startX > expandThreshold) {
uiStore.expandSidebar()
cleanup()
}
}
const onMouseUp = () => cleanup()
const cleanup = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
</script>
<style lang="less" scoped>
.aside_box {
// 侧栏水平栅格:图标列与文案列统一对齐(Logo / 菜单 / 会话分组 / 会话行)
--sidebar-inset-x: 14px;
--sidebar-icon-size: 18px;
--sidebar-channel-icon: 14px;
--sidebar-icon-gap: 8px;
--sidebar-text-inset: calc(var(--sidebar-inset-x) + var(--sidebar-icon-size) + var(--sidebar-icon-gap)); // 40px
min-width: 260px;
width: 260px;
padding: 8px 6px 6px;
background: var(--td-bg-color-sidebar);
box-sizing: border-box;
/* Avoid 100vh because <html> carries a `zoom` multiplier for font-size
control; 100vh is evaluated against the unscaled viewport and then
scaled, so at "large" the sidebar would extend past the window. The
ancestor chain (html/body/#app/.main) is already height: 100%. */
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
border-right: 1px solid var(--td-component-stroke);
box-shadow: 1px 0 0 rgba(0, 0, 0, 0.02);
transition: width 0.25s ease, min-width 0.25s ease;
position: relative;
// macOS Wails 桌面:红绿灯位于 HiddenInset 标题栏区域,需让出顶部空间
html.wails-desktop & {
padding-top: 30px;
}
&--collapsed {
min-width: 60px;
width: 60px;
padding: 8px 3px 6px;
overflow: visible;
.menu_item {
justify-content: center;
padding: 9px 0;
.menu_item-box {
justify-content: center;
width: auto;
}
.menu_icon {
margin-right: 0;
}
}
.menu_bottom {
align-items: center;
}
.menu_top {
margin-right: 0;
padding-right: 0;
}
}
.logo_row {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
flex-shrink: 0;
padding: 0 10px 0 var(--sidebar-inset-x);
}
.sidebar-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
color: var(--td-text-color-secondary);
border-radius: 4px;
transition: background-color 0.2s ease;
box-sizing: border-box;
&:hover {
background: var(--td-bg-color-container-hover);
color: var(--td-text-color-primary);
}
}
.sidebar-drag-handle {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: ew-resize;
z-index: 10;
&:hover {
background: var(--td-brand-color-light);
}
}
.logo_box {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
overflow: hidden;
.logo {
width: 128px;
height: auto;
}
.lite-badge {
margin-left: 2px;
align-self: flex-start;
margin-top: 2px;
font-size: 9px;
font-weight: 600;
color: var(--td-text-color-placeholder);
user-select: none;
white-space: nowrap;
}
}
.logo_img {
margin-left: 24px;
width: 30px;
height: 30px;
margin-right: 7.25px;
}
.logo_txt {
transform: rotate(0.049deg);
color: var(--td-text-color-primary);
font-family: "TencentSans";
font-size: 24.12px;
font-style: normal;
font-weight: W7;
line-height: 21.7px;
}
.menu_top {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
// 抵消 .aside_box 的右内边距,让滚动条贴近面板右缘;
// 等量 padding 补回,保证列表文字位置不变。
margin-right: -4px;
padding-right: 4px;
// Claude 风格细滚动条:默认透明,悬浮时显示一条圆角细灰条
scrollbar-width: thin;
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.2s ease;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
border-radius: 6px;
transition: background-color 0.2s ease;
}
&:hover {
scrollbar-color: var(--td-scrollbar-color, rgba(0, 0, 0, 0.18)) transparent;
&::-webkit-scrollbar-thumb {
background-color: var(--td-scrollbar-color, rgba(0, 0, 0, 0.18));
}
}
&::-webkit-scrollbar-thumb:hover {
background-color: var(--td-scrollbar-hover-color, rgba(0, 0, 0, 0.32));
}
}
.menu_bottom {
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.menu_box {
display: flex;
flex-direction: column;
// 「新对话」吸顶:作为滚动容器(.menu_top)的直接子级,滚动时钉在顶部,
// 知识库/智能体/共享空间及历史列表一起从其下方滚走。背景遮挡滚动内容。
&--sticky {
position: sticky;
top: 0;
z-index: 2;
background: var(--td-bg-color-sidebar);
}
}
.upload-file-wrap {
padding: 6px;
border-radius: 3px;
height: 32px;
width: 32px;
box-sizing: border-box;
}
.upload-file-wrap:hover {
background-color: var(--td-brand-color-light);
color: var(--td-brand-color);
}
.upload-file-icon {
width: 20px;
height: 20px;
color: var(--td-text-color-secondary);
}
.active-upload {
color: var(--td-brand-color);
}
.menu_item_active {
border-radius: 4px;
background: var(--td-bg-color-secondarycontainer) !important;
.menu_icon,
.menu_title {
color: var(--td-brand-color) !important;
}
}
.menu_item_c_active {
.menu_icon,
.menu_title {
color: var(--td-text-color-primary);
}
}
.menu_p {
height: 46px;
padding: 3px 0;
box-sizing: border-box;
}
.menu_item {
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
height: 38px;
padding: 8px 10px 8px var(--sidebar-inset-x);
box-sizing: border-box;
margin-bottom: 2px;
border-radius: 4px;
transition: background-color 0.2s ease;
.menu_item-box {
display: flex;
align-items: center;
}
&:hover {
border-radius: 4px;
background: var(--td-bg-color-container-hover);
.menu_icon,
.menu_title {
color: var(--td-text-color-primary);
}
}
}
.menu_icon {
display: flex;
flex: 0 0 var(--sidebar-icon-size);
width: var(--sidebar-icon-size);
margin-right: var(--sidebar-icon-gap);
color: var(--td-text-color-secondary);
.icon {
width: 18px;
height: 18px;
overflow: hidden;
}
}
.menu_title {
color: var(--td-text-color-primary);
text-overflow: ellipsis;
font-family: var(--app-font-family);
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 20px;
overflow: hidden;
white-space: nowrap;
max-width: 120px;
flex: 1;
}
.submenu {
font-family: var(--app-font-family);
font-size: 14px;
font-style: normal;
min-width: 0;
padding-top: 3px;
}
:deep(.submenu_pin_icon) {
color: inherit;
font-size: 12px;
margin-right: 4px;
vertical-align: middle;
flex-shrink: 0;
}
.submenu_source_icon {
width: 14px;
height: 14px;
margin-right: 0px;
vertical-align: middle;
object-fit: contain;
flex-shrink: 0;
// 默认淡化处理,避免未选中状态下彩色图标与灰色标题不协调;
// 悬浮或选中时恢复彩色,交互时才引人注意。
filter: grayscale(1);
opacity: 0.55;
transition: filter 0.15s ease, opacity 0.15s ease;
}
:deep(.submenu_item:hover .submenu_source_icon),
:deep(.submenu_item_active .submenu_source_icon) {
filter: none;
opacity: 1;
}
// 列表行统一栅格:左缘 inset-x + 图标槽 18px + 间距 8px → 文案列与主菜单文字对齐
.session-list-row {
display: flex;
align-items: center;
gap: var(--sidebar-icon-gap);
padding: 0 10px 0 var(--sidebar-inset-x);
min-width: 0;
box-sizing: border-box;
}
.session-list-row__icon {
flex: 0 0 var(--sidebar-icon-size);
width: var(--sidebar-icon-size);
height: var(--sidebar-icon-size);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.session-list-row__body {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
// 聊天区分组标题 / 会话行:与「聊天」节标题同列左对齐,不再预留图标槽
.session-list-row--flat {
padding-left: var(--sidebar-inset-x);
gap: 0;
}
.session-list-loading {
display: flex;
align-items: center;
min-height: 26px;
color: var(--td-text-color-placeholder);
}
.timeline_header {
font-family: var(--app-font-family);
font-size: 11px;
font-weight: 600;
color: var(--td-text-color-disabled);
padding-top: 4px;
padding-bottom: 1px;
margin-top: 0;
line-height: 16px;
user-select: none;
}
.timeline_header-label {
white-space: nowrap;
}
.timeline_header--with-scope {
justify-content: space-between;
gap: 10px;
:deep(.session-source-filter--inline) {
flex: 0 1 auto;
min-width: 0;
max-width: 52%;
opacity: 0;
transition: opacity 0.15s ease;
}
&:hover :deep(.session-source-filter--inline),
&:focus-within :deep(.session-source-filter--inline),
:deep(.session-source-filter--inline.session-source-filter--emphasized) {
opacity: 1;
}
}
.submenu--scope-fallback {
position: relative;
padding-top: 18px;
}
.session-list-scope-fallback {
position: absolute;
top: 1px;
right: 10px;
z-index: 1;
display: flex;
justify-content: flex-end;
max-width: calc(100% - var(--sidebar-inset-x) - 10px);
:deep(.session-source-filter--inline) {
opacity: 0;
transition: opacity 0.15s ease;
}
&:hover :deep(.session-source-filter--inline),
:deep(.session-source-filter--inline.session-source-filter--emphasized) {
opacity: 1;
}
}
.submenu_item_p {
padding: 0;
box-sizing: border-box;
min-width: 0;
overflow: hidden;
&.session-chat-row .session-list-row {
min-height: 30px;
border-radius: 6px;
transition: background 0.15s ease, color 0.15s ease;
}
&.session-chat-row:hover .session-list-row {
background: var(--td-bg-color-container-hover);
:deep(.menu-more) {
color: var(--td-text-color-primary);
}
:deep(.menu-more-wrap) {
opacity: 1;
}
}
&.session-chat-row--active .session-list-row {
background: var(--td-bg-color-container-hover);
:deep(.submenu_item) {
color: var(--td-brand-color);
}
:deep(.menu-more) {
color: var(--td-text-color-primary);
}
:deep(.menu-more-wrap) {
opacity: 1;
}
}
&.session-chat-row--selected .session-list-row {
background: rgba(7, 192, 95, 0.05);
}
}
// SessionSidebarRow 为子组件,需 :deep 才能让标题省略号生效
:deep(.submenu_item) {
cursor: pointer;
display: flex;
align-items: center;
color: var(--td-text-color-primary);
font-weight: 400;
font-size: 14px;
line-height: 20px;
height: 100%;
width: 100%;
padding: 6px 0;
position: relative;
min-width: 0;
background: transparent;
.submenu_title {
display: flex;
align-items: center;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
.submenu_title-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.menu-more-wrap {
opacity: 0;
transition: opacity 0.2s ease;
flex-shrink: 0;
}
.menu-more {
display: inline-block;
font-weight: bold;
color: var(--td-brand-color);
}
.submenu_title--batch {
margin-left: 4px;
}
&.submenu_item_batch {
padding-left: 0;
}
}
:deep(.submenu_item_batch) {
cursor: pointer;
user-select: none;
}
.batch-checkbox {
flex-shrink: 0;
}
}
.batch-inline-footer {
position: sticky;
bottom: 0;
z-index: 2;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
border-top: 1px solid var(--td-component-stroke);
background: var(--td-bg-color-container);
.batch-footer-left {
display: flex;
align-items: center;
font-size: 13px;
color: var(--td-text-color-placeholder);
}
.batch-footer-right {
display: flex;
align-items: center;
gap: 6px;
}
}
/* 知识库下拉菜单样式 */
.kb-dropdown-icon {
margin-left: auto;
color: var(--td-text-color-secondary);
transition: transform 0.3s ease, color 0.2s ease;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
&.rotate-180 {
transform: rotate(180deg);
}
&:hover {
color: var(--td-brand-color);
}
&.active {
color: var(--td-brand-color);
}
&.active:hover {
color: var(--td-brand-color-active);
}
svg {
width: 12px;
height: 12px;
transition: inherit;
}
}
.kb-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-stroke);
border-radius: 6px;
box-shadow: var(--td-shadow-2);
z-index: 1000;
max-height: 200px;
overflow-y: auto;
}
.kb-dropdown-item {
padding: 8px 16px;
cursor: pointer;
transition: background-color 0.2s ease;
font-size: 14px;
color: var(--td-text-color-primary);
&:hover {
background-color: var(--td-bg-color-container-hover);
}
&.active {
background-color: var(--td-brand-color-light);
color: var(--td-brand-color);
font-weight: 500;
}
&:first-child {
border-radius: 6px 6px 0 0;
}
&:last-child {
border-radius: 0 0 6px 6px;
}
}
.menu_item-box {
display: flex;
align-items: center;
width: 100%;
position: relative;
}
/* Empty state when there are no sessions. */
.submenu_empty {
padding: 24px 14px;
text-align: center;
font-size: 12px;
color: var(--td-text-color-placeholder);
user-select: none;
}
// 顶部 logo_row 右侧的图标按钮组(搜索 + 折叠),与折叠按钮风格一致
.logo_actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.header-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
flex-shrink: 0;
cursor: pointer;
border-radius: 6px;
color: var(--td-text-color-secondary);
transition: background-color 0.2s ease;
box-sizing: border-box;
&:hover {
background: var(--td-bg-color-container-hover);
}
.header-icon-img {
width: 18px;
height: 18px;
display: block;
}
}
// 深色 tooltip 内容:标签 + 浅灰快捷键内联
.cmdk-tip {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
.cmdk-tip-label {
font-size: 13px;
}
.cmdk-tip-keys {
font-size: 13px;
opacity: 0.6;
letter-spacing: 0.5px;
}
}
.menu-pending-badge {
min-width: 18px;
height: 18px;
padding: 0 5px;
margin-left: 6px;
border-radius: 9px;
background: rgba(250, 173, 20, 0.2);
color: var(--td-warning-color);
font-size: 12px;
font-weight: 600;
line-height: 18px;
text-align: center;
flex-shrink: 0;
}
.menu_box {
position: relative;
}
</style>
<style lang="less">
// Dark mode: invert dark logo to light
html[theme-mode="dark"] .aside_box .logo_box .logo {
filter: invert(1) hue-rotate(180deg);
}
// Dark mode: 滚动条在深色背景下需要更亮的颜色才看得见
html[theme-mode="dark"] .aside_box .menu_top:hover {
scrollbar-color: rgba(255, 255, 255, 0.22) transparent;
}
html[theme-mode="dark"] .aside_box .menu_top:hover::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.22);
}
html[theme-mode="dark"] .aside_box .menu_top::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.38);
}
// Dark mode: invert the top search icon button image to match text color
html[theme-mode="dark"] .aside_box .header-icon-img {
filter: invert(1);
opacity: 0.55;
}
html[theme-mode="dark"] .aside_box .header-icon-btn:hover .header-icon-img {
opacity: 0.9;
}
// Dark mode: make SVG icons match text color (loaded via <img>, currentColor won't work)
html[theme-mode="dark"] .aside_box .menu_icon img.icon {
filter: invert(1);
opacity: 0.55;
}
// Hover state: brighter icon like text
html[theme-mode="dark"] .aside_box .menu_item:hover .menu_icon img.icon {
opacity: 0.9;
}
// menu_item_c_active: text is primary, so icon should match
html[theme-mode="dark"] .aside_box .menu_item_c_active .menu_icon img.icon {
opacity: 0.9;
}
// Active (green) icons should not be inverted
html[theme-mode="dark"] .aside_box .menu_item_active .menu_icon img.icon {
filter: none;
opacity: 1;
}
// 下拉菜单样式已统一至 @/assets/dropdown-menu.less
// 退出登录确认框样式
:deep(.t-popconfirm) {
.t-popconfirm__content {
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-stroke);
border-radius: 6px;
box-shadow: var(--td-shadow-3);
padding: 12px 16px;
font-size: 14px;
color: var(--td-text-color-primary);
max-width: 200px;
}
.t-popconfirm__arrow {
border-bottom-color: var(--td-component-stroke);
}
.t-popconfirm__arrow::after {
border-bottom-color: var(--td-bg-color-container);
}
.t-popconfirm__buttons {
margin-top: 8px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.t-button--variant-outline {
border-color: var(--td-component-border);
color: var(--td-text-color-secondary);
}
.t-button--theme-danger {
background-color: var(--td-error-color);
border-color: var(--td-error-color);
}
.t-button--theme-danger:hover {
background-color: var(--td-error-color);
border-color: var(--td-error-color);
}
}
</style>