1
0

RightPanelTabs.vue 14 KB

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