RightPanelTabs.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. <template>
  2. <div class="right-panel right-panel-tabs">
  3. <div class="panel-tab-bar">
  4. <div class="panel-tab-toggle-wrapper" ref="toggleRef">
  5. <div class="panel-tab-toggle" :class="{ active: showTabList }" @click="toggleTabList" title="全部标签页">
  6. <div class="panel-tab-toggle-icon"></div>
  7. </div>
  8. <div v-if="showTabList" class="tab-list-popup" @click.stop @pointerdown.stop>
  9. <div class="tab-list-search">
  10. <div class="tab-list-search-icon"></div>
  11. <input ref="searchInputRef" v-model="searchText" class="tab-list-search-input" placeholder="搜索标签页" />
  12. </div>
  13. <div class="tab-list">
  14. <div
  15. v-for="tab in filteredTabs"
  16. :key="tab.id"
  17. class="tab-list-item"
  18. :class="{ active: tab.id === activeTabId }"
  19. @click="handleSelect(tab.id)"
  20. >
  21. <div
  22. class="tab-list-item-icon"
  23. :class="`tab-icon-${tab.type}`"
  24. :style="tab.type === 'file' ? { backgroundImage: getFileIconVar(tab.title) } : null"
  25. ></div>
  26. <span class="tab-list-item-title">{{ tab.title }}</span>
  27. </div>
  28. <div v-if="filteredTabs.length === 0" class="tab-list-empty">暂无匹配标签页</div>
  29. </div>
  30. </div>
  31. </div>
  32. <div class="panel-tab-scroll" ref="scrollRef" :class="{ sliding: isSliding }">
  33. <div class="panel-tab-track" :style="isSliding ? { transform: `translateX(${-trackShiftPx}px)` } : null">
  34. <div
  35. v-for="tab in tabs"
  36. :key="tab.id"
  37. :class="['panel-tab', `panel-tab-${tab.type}`, { active: tab.id === activeTabId }]"
  38. @click="handleSwitch(tab.id)"
  39. >
  40. <div class="panel-tab-icon" :style="tab.type === 'file' ? { backgroundImage: getFileIconVar(tab.title) } : null"></div>
  41. <span class="panel-tab-title">{{ tab.title }}</span>
  42. <div class="panel-tab-close" @click.stop="handleClose(tab.id)" title="关闭"></div>
  43. </div>
  44. </div>
  45. </div>
  46. </div>
  47. <div class="panel-tab-content">
  48. <FilePreviewPanel
  49. v-if="activeTab?.type === 'file'"
  50. :file="activeTab.file"
  51. :editable="activeTab.editable"
  52. :save-filename="activeTab.file.name || ''"
  53. hide-header
  54. @close="closeActive"
  55. @save-success="handleSaveSuccess"
  56. @open-markdown-tab="emit('open-markdown-tab', $event)"
  57. />
  58. <AgentDetailPanel
  59. v-else-if="activeTab?.type === 'agent'"
  60. :messageIndex="activeTab.messageIndex"
  61. :agentName="activeTab.agent"
  62. :agentId="activeTab.agentId"
  63. :messages="messages"
  64. hide-header
  65. @close="closeActive"
  66. />
  67. <ScheduleDetailPanel
  68. v-else-if="activeTab?.type === 'schedule'"
  69. :task="activeTab.task"
  70. hide-header
  71. @close="closeActive"
  72. @deleted="emit('schedule-deleted')"
  73. @updated="emit('schedule-updated')"
  74. @open-session="handleOpenSession"
  75. />
  76. <ModalPreview />
  77. </div>
  78. </div>
  79. </template>
  80. <script setup lang="ts">
  81. import { computed, nextTick, ref } from 'vue';
  82. import { onClickOutside, useResizeObserver } from '@vueuse/core';
  83. import FilePreviewPanel from '/@/views/ventAI/manageAssistent/components/resource/FilePreviewPanel.vue';
  84. import ModalPreview from '/@/views/ventAI/manageAssistent/components/resource/ModalPreview.vue';
  85. import AgentDetailPanel from './AgentDetailPanel.vue';
  86. import ScheduleDetailPanel from './ScheduleDetailPanel.vue';
  87. import { getFileIconVar } from '/@/views/ventAI/manageAssistent/lib/index';
  88. import type { Message, RightPanelTab } from '/@/views/ventAI/manageAssistent/types/index';
  89. const props = defineProps<{
  90. tabs: RightPanelTab[];
  91. activeTabId: string | null;
  92. messages: Message[];
  93. }>();
  94. const emit = defineEmits<{
  95. (e: 'switch-tab', id: string): void;
  96. (e: 'close-tab', id: string): void;
  97. (e: 'save-success', content: string): void;
  98. (e: 'schedule-deleted'): void;
  99. (e: 'schedule-updated'): void;
  100. (e: 'open-schedule-session', sessionId: string, prompt: string): void;
  101. (e: 'open-markdown-tab', payload: { title: string; content: string }): void;
  102. }>();
  103. const activeTab = computed(() => props.tabs.find((t) => t.id === props.activeTabId) || null);
  104. // 按容器实际宽度动态计算可容纳的标签数量;激活标签像素中心对齐容器中心,平移量钳制保证首标签贴左、尾标签贴右
  105. const TAB_GAP = 8;
  106. const TAB_BASE_WIDTH = 150;
  107. const scrollRef = ref<HTMLElement>();
  108. const visibleCount = ref(1);
  109. const scrollWidth = ref(0);
  110. useResizeObserver(scrollRef, () => {
  111. const width = scrollRef.value?.clientWidth ?? 0;
  112. scrollWidth.value = width;
  113. visibleCount.value = Math.max(1, Math.floor((width + TAB_GAP) / (TAB_BASE_WIDTH + TAB_GAP)));
  114. });
  115. const trackShiftPx = computed(() => {
  116. const len = props.tabs.length;
  117. if (len <= visibleCount.value || !scrollWidth.value) return 0;
  118. const trackWidth = len * (TAB_BASE_WIDTH + TAB_GAP) - TAB_GAP;
  119. const maxShift = Math.max(0, trackWidth - scrollWidth.value);
  120. const activeIdx = Math.max(
  121. 0,
  122. props.tabs.findIndex((t) => t.id === props.activeTabId)
  123. );
  124. const activeCenter = activeIdx * (TAB_BASE_WIDTH + TAB_GAP) + TAB_BASE_WIDTH / 2;
  125. return Math.min(Math.max(activeCenter - scrollWidth.value / 2, 0), maxShift);
  126. });
  127. const isSliding = computed(() => props.tabs.length > visibleCount.value);
  128. const toggleRef = ref<HTMLElement>();
  129. const searchInputRef = ref<HTMLInputElement>();
  130. const showTabList = ref(false);
  131. const searchText = ref('');
  132. const filteredTabs = computed(() => {
  133. const kw = searchText.value.trim().toLowerCase();
  134. if (!kw) return props.tabs;
  135. return props.tabs.filter((t) => t.title.toLowerCase().includes(kw));
  136. });
  137. const toggleTabList = async () => {
  138. showTabList.value = !showTabList.value;
  139. if (showTabList.value) {
  140. searchText.value = '';
  141. await nextTick();
  142. searchInputRef.value?.focus();
  143. }
  144. };
  145. const handleSelect = (id: string) => {
  146. emit('switch-tab', id);
  147. showTabList.value = false;
  148. };
  149. onClickOutside(toggleRef, () => {
  150. showTabList.value = false;
  151. });
  152. const handleSwitch = (id: string) => emit('switch-tab', id);
  153. const handleClose = (id: string) => emit('close-tab', id);
  154. const closeActive = () => {
  155. if (activeTab.value) emit('close-tab', activeTab.value.id);
  156. };
  157. const handleSaveSuccess = (content: string) => emit('save-success', content);
  158. const handleOpenSession = (sessionId: string, prompt: string) => emit('open-schedule-session', sessionId, prompt);
  159. </script>
  160. <style scoped lang="less">
  161. .right-panel-tabs {
  162. display: flex;
  163. flex-direction: column;
  164. overflow: hidden;
  165. .panel-tab-bar {
  166. display: flex;
  167. align-items: center;
  168. gap: 8px;
  169. padding: 10px 12px;
  170. flex-shrink: 0;
  171. border-bottom: 1px solid rgb(63 80 106 / 25%);
  172. .panel-tab-toggle-wrapper {
  173. position: relative;
  174. flex-shrink: 0;
  175. display: flex;
  176. align-items: center;
  177. .panel-tab-toggle {
  178. display: flex;
  179. align-items: center;
  180. justify-content: center;
  181. width: 28px;
  182. height: 28px;
  183. border: 1px solid rgb(63 80 106 / 35%);
  184. background: rgb(63 80 106 / 12%);
  185. cursor: pointer;
  186. transition: all 0.2s;
  187. border-radius: 4px;
  188. &:hover,
  189. &.active {
  190. background: rgb(10 132 255 / 15%);
  191. border-color: rgb(10 132 255 / 40%);
  192. }
  193. .panel-tab-toggle-icon {
  194. width: 14px;
  195. height: 14px;
  196. background-image: var(--img-chat-tabs-icon);
  197. background-repeat: no-repeat;
  198. background-size: 100% 100%;
  199. background-position: center;
  200. opacity: 0.8;
  201. }
  202. }
  203. .tab-list-popup {
  204. position: absolute;
  205. top: calc(100% + 6px);
  206. left: 0;
  207. z-index: 1000;
  208. display: flex;
  209. flex-direction: column;
  210. width: 240px;
  211. border: 1px solid #1a4a6f;
  212. background: #0a2a3f;
  213. max-height: 320px;
  214. border-radius: 8px;
  215. box-shadow: 0 4px 20px rgb(0 0 0 / 40%);
  216. .tab-list-search {
  217. display: flex;
  218. align-items: center;
  219. height: 34px;
  220. margin: 10px 10px 4px;
  221. padding: 0 12px;
  222. border: none;
  223. background: transparent;
  224. transition: border-bottom-color 0.2s;
  225. gap: 8px;
  226. border-bottom: 1px solid rgb(63 80 106 / 50%);
  227. flex-shrink: 0;
  228. &:hover {
  229. border-bottom-color: rgb(63 80 106 / 70%);
  230. }
  231. &:focus-within {
  232. border-bottom-color: rgb(10 132 255 / 70%);
  233. }
  234. .tab-list-search-icon {
  235. width: 15px;
  236. height: 15px;
  237. flex-shrink: 0;
  238. background-image: var(--img-chat-options-search-icon);
  239. background-repeat: no-repeat;
  240. background-size: 100% 100%;
  241. background-position: center;
  242. opacity: 0.7;
  243. }
  244. .tab-list-search-input {
  245. flex: 1;
  246. height: 100%;
  247. padding: 0;
  248. border: none;
  249. background: transparent;
  250. color: #e0e6ed;
  251. min-width: 0;
  252. font-size: 12px;
  253. outline: none;
  254. &::placeholder {
  255. color: #6b7684;
  256. }
  257. }
  258. }
  259. .tab-list {
  260. flex: 1;
  261. overflow-y: auto;
  262. padding: 6px;
  263. .tab-list-item {
  264. display: flex;
  265. align-items: center;
  266. gap: 10px;
  267. padding: 8px 10px;
  268. cursor: pointer;
  269. border-radius: 6px;
  270. transition: background 0.2s;
  271. &:hover {
  272. background: rgb(10 132 255 / 15%);
  273. }
  274. &.active {
  275. background: rgb(10 132 255 / 20%);
  276. }
  277. .tab-list-item-icon {
  278. width: 14px;
  279. height: 14px;
  280. flex-shrink: 0;
  281. background-repeat: no-repeat;
  282. background-size: 100% 100%;
  283. background-position: center;
  284. opacity: 0.8;
  285. }
  286. .tab-icon-file {
  287. background-image: var(--img-chat-attach-icon);
  288. }
  289. .tab-icon-agent {
  290. background-image: var(--img-chat-agent-icon);
  291. }
  292. .tab-icon-schedule {
  293. background-image: var(--img-chat-options-schedule-icon);
  294. }
  295. .tab-list-item-title {
  296. flex: 1;
  297. color: #c9d1d9;
  298. overflow: hidden;
  299. min-width: 0;
  300. text-overflow: ellipsis;
  301. white-space: nowrap;
  302. font-size: 12px;
  303. }
  304. }
  305. .tab-list-empty {
  306. padding: 16px 0;
  307. color: #6b7684;
  308. text-align: center;
  309. font-size: 12px;
  310. }
  311. }
  312. }
  313. }
  314. .panel-tab-scroll {
  315. flex: 1;
  316. min-width: 0;
  317. overflow: hidden;
  318. .panel-tab-track {
  319. display: flex;
  320. align-items: center;
  321. gap: 8px;
  322. width: 100%;
  323. }
  324. &.sliding .panel-tab-track {
  325. width: max-content;
  326. transition: transform 0.25s ease;
  327. }
  328. &:not(.sliding) .panel-tab {
  329. flex: 1 1 150px;
  330. max-width: 240px;
  331. }
  332. }
  333. .panel-tab {
  334. display: flex;
  335. flex: 0 0 150px;
  336. align-items: center;
  337. height: 28px;
  338. padding: 6px 12px;
  339. border: 1px solid rgb(63 80 106 / 35%);
  340. background: rgb(63 80 106 / 12%);
  341. color: #8b949e;
  342. cursor: pointer;
  343. transition: all 0.2s;
  344. gap: 6px;
  345. min-width: 0;
  346. border-radius: 4px;
  347. white-space: nowrap;
  348. font-size: 12px;
  349. &:hover {
  350. background: rgb(10 132 255 / 8%);
  351. border-color: rgb(10 132 255 / 30%);
  352. color: #c9d1d9;
  353. }
  354. &.active {
  355. background: rgb(10 132 255 / 18%);
  356. border-color: rgb(10 132 255 / 50%);
  357. color: #e0e6ed;
  358. box-shadow: 0 1px 4px rgb(10 132 255 / 25%);
  359. }
  360. .panel-tab-icon {
  361. width: 14px;
  362. height: 14px;
  363. flex-shrink: 0;
  364. background-repeat: no-repeat;
  365. background-size: 100% 100%;
  366. background-position: center;
  367. opacity: 0.8;
  368. }
  369. .panel-tab-title {
  370. flex: 1;
  371. min-width: 0;
  372. overflow: hidden;
  373. text-overflow: ellipsis;
  374. }
  375. .panel-tab-close {
  376. width: 16px;
  377. height: 16px;
  378. flex-shrink: 0;
  379. background-image: var(--img-chat-close-icon-btn);
  380. background-repeat: no-repeat;
  381. background-size: 100% 100%;
  382. background-position: center;
  383. opacity: 0.6;
  384. border-radius: 3px;
  385. transition: all 0.2s;
  386. &:hover {
  387. opacity: 1;
  388. background-color: rgb(255 77 79 / 20%);
  389. }
  390. }
  391. }
  392. .panel-tab-file .panel-tab-icon {
  393. background-image: var(--img-chat-attach-icon);
  394. }
  395. .panel-tab-agent .panel-tab-icon {
  396. background-image: var(--img-chat-agent-icon);
  397. }
  398. .panel-tab-schedule .panel-tab-icon {
  399. background-image: var(--img-chat-options-schedule-icon);
  400. }
  401. }
  402. .panel-tab-content {
  403. display: flex;
  404. flex: 1;
  405. flex-direction: column;
  406. overflow: hidden;
  407. min-height: 0;
  408. }
  409. }
  410. </style>