FilesView.vue 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. <script setup lang="ts">
  2. import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  3. import { useRouter } from 'vue-router'
  4. import type { FileKind } from '@shared/types'
  5. import AppIcon from '@/components/common/AppIcon.vue'
  6. import LibraryGrid from '@/components/library/LibraryGrid.vue'
  7. import PreviewModal from '@/components/library/PreviewModal.vue'
  8. import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
  9. import type { LibraryFilterOption, UiLibraryItem } from '@/components/library/types'
  10. import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
  11. import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
  12. import { queueLibraryItemForComposer } from '@/components/workspace/pendingComposer'
  13. import { fileToUi, sessionSidebarItem } from '@/components/workspace/viewModels'
  14. import { useFilesStore } from '@/stores/files'
  15. import { useAuthStore } from '@/stores/auth'
  16. import { useChatStore } from '@/stores/chat'
  17. const files = useFilesStore()
  18. const auth = useAuthStore()
  19. const chat = useChatStore()
  20. const router = useRouter()
  21. const selected = ref<UiLibraryItem | null>(null)
  22. const mediaIndex = ref<number | null>(null)
  23. const historySearch = ref('')
  24. let searchTimer: number | undefined
  25. const items = computed(() => files.filteredItems.map(fileToUi))
  26. const lightboxMedia = computed(() => items.value.filter(item => item.kind === 'image' || item.kind === 'video').map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
  27. const agentNames = computed(() => new Map(auth.profiles.map(profile => [profile.name, profile.agentName || profile.displayName || profile.name])))
  28. const historyItems = computed(() => chat.sessions
  29. .filter(session => !['cron', 'ios_group'].includes(session.source))
  30. .filter(session => !historySearch.value.trim() || `${session.title} ${session.preview || ''}`.toLocaleLowerCase().includes(historySearch.value.trim().toLocaleLowerCase()))
  31. .map(session => sessionSidebarItem(session, chat.unreadCounts[session.id] ?? 0, agentNames.value.get(session.profile || auth.activeProfile?.name || ''))))
  32. const options = computed<LibraryFilterOption[]>(() => {
  33. const count = (kind: string) => files.items.filter(item => item.kind === kind).length
  34. return [
  35. { id: 'all', label: '全部', count: files.items.length, icon: 'files' },
  36. { id: 'image', label: '图片', count: count('image'), icon: 'image' },
  37. { id: 'video', label: '视频', count: count('video'), icon: 'video' },
  38. { id: 'audio', label: '音频', count: count('audio'), icon: 'audio' },
  39. { id: 'document', label: '文档', count: count('document'), icon: 'file' },
  40. { id: 'other', label: '其他', count: count('other'), icon: 'file' },
  41. ]
  42. })
  43. function updateSearch(value: string) {
  44. files.query = value
  45. window.clearTimeout(searchTimer)
  46. searchTimer = window.setTimeout(() => void files.load(true), 260)
  47. }
  48. function updateFilter(value: string) {
  49. files.kindFilter = value as FileKind | 'all'
  50. void files.load(true)
  51. }
  52. async function addToComposer(item: UiLibraryItem) {
  53. if (!queueLibraryItemForComposer(item)) return
  54. await router.push('/chat')
  55. }
  56. async function openSource(item: UiLibraryItem) {
  57. if (!item.sourceSessionId) return
  58. if (item.sourceProfile && auth.profiles.some(profile => profile.name === item.sourceProfile)) {
  59. auth.selectProfile(item.sourceProfile)
  60. }
  61. await router.push({
  62. path: `/chat/${encodeURIComponent(item.sourceSessionId)}`,
  63. query: {
  64. ...(item.sourceMessageId ? { message: item.sourceMessageId } : {}),
  65. ...(item.sourceProfile ? { profile: item.sourceProfile } : {}),
  66. },
  67. })
  68. }
  69. function selectFile(item: UiLibraryItem) {
  70. if (item.kind === 'image' || item.kind === 'video') {
  71. const index = lightboxMedia.value.findIndex(media => media.url === (item.previewUrl || item.downloadUrl))
  72. mediaIndex.value = index >= 0 ? index : null
  73. return
  74. }
  75. selected.value = item
  76. }
  77. async function selectSession(id: string) {
  78. const session = chat.sessions.find(item => item.id === id)
  79. const profile = session?.profile || auth.activeProfile?.name
  80. await router.push({ path: `/chat/${encodeURIComponent(id)}`, query: profile ? { profile } : {} })
  81. }
  82. onMounted(() => {
  83. void files.load(true)
  84. void chat.loadSessions(auth.activeProfile?.name)
  85. void chat.loadUnread(auth.activeProfile?.name)
  86. })
  87. onBeforeUnmount(() => window.clearTimeout(searchTimer))
  88. watch(() => files.items, () => {
  89. if (selected.value && !files.items.some(item => item.id === selected.value?.id)) selected.value = null
  90. })
  91. watch(() => auth.activeProfile?.name, profile => {
  92. selected.value = null
  93. files.profile = profile
  94. void files.load(true)
  95. void chat.loadSessions(profile)
  96. void chat.loadUnread(profile)
  97. })
  98. </script>
  99. <template>
  100. <WorkspaceView sidebar-title="历史记录" :sidebar-subtitle="`${chat.sessions.length} 个会话`">
  101. <template #sidebar>
  102. <ResourceSidebar
  103. :items="historyItems"
  104. :active-id="chat.activeSessionId"
  105. :loading="chat.isLoading"
  106. :search="historySearch"
  107. single-line
  108. search-placeholder="搜索会话"
  109. empty-title="还没有会话"
  110. empty-description="创建会话后会显示在这里。"
  111. @search="historySearch = $event"
  112. @select="selectSession"
  113. />
  114. </template>
  115. <section class="library-workspace">
  116. <header class="library-header">
  117. <div><h2>文件库</h2><span><AppIcon name="files" :size="15" />{{ items.length }}</span></div>
  118. <label class="file-search"><AppIcon name="search" :size="15" /><input :value="files.query" type="search" placeholder="搜索文件名、路径或会话" @input="updateSearch(($event.target as HTMLInputElement).value)" /></label>
  119. </header>
  120. <nav class="file-tabs" aria-label="文件类型筛选">
  121. <button v-for="option in options" :key="option.id" type="button" :class="{ active: option.id === files.kindFilter }" @click="updateFilter(option.id)">{{ option.label }}<small v-if="option.count !== undefined">{{ option.count }}</small></button>
  122. <div class="file-tabs__status"><span v-if="files.error" class="error"><AppIcon name="alert" :size="13" />{{ files.error }}</span><span v-else>{{ files.nextCursor ? '还有更多文件可加载' : items.length ? '已同步到最新' : '' }}</span><button class="icon-button" type="button" aria-label="刷新文件库" title="刷新文件库" :disabled="files.isLoading" @click="files.load(true)"><AppIcon name="refresh" :size="15" /></button></div>
  123. </nav>
  124. <LibraryGrid
  125. :items="items"
  126. :selected-id="selected?.id"
  127. :loading="files.isLoading"
  128. :has-more="files.hasMore"
  129. empty-title="文件库还是空的"
  130. empty-description="聊天中的图片、文档和附件会出现在这里。"
  131. @select="selectFile"
  132. @load-more="files.loadMore"
  133. @add-to-composer="addToComposer"
  134. @source="openSource"
  135. />
  136. </section>
  137. </WorkspaceView>
  138. <PreviewModal v-if="selected" :item="selected" :items="items" @close="selected = null" @add-to-composer="addToComposer" @source="openSource" />
  139. <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
  140. </template>
  141. <style scoped>
  142. .library-workspace { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
  143. .library-header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 0 22px; border-bottom: 1px solid var(--line); }.library-header > div { display: flex; align-items: center; gap: 10px; }.library-header h2 { margin: 0; font-size: 15px; letter-spacing: -.02em; }.library-header span { display: flex; align-items: center; gap: 5px; color: var(--text-muted); font-size: 10px; }.file-search { display: flex; width: min(280px, 38vw); min-height: 32px; align-items: center; gap: 7px; padding: 0 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--text-muted); }.file-search:focus-within { border-color: var(--line-strong); }.file-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--text-primary); font-size: 11px; }
  144. .file-tabs { display: flex; min-height: 46px; align-items: center; gap: 4px; padding: 0 22px; border-bottom: 1px solid var(--line); overflow-x: auto; }.file-tabs > button { min-height: 28px; padding: 0 10px; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 10px; white-space: nowrap; }.file-tabs > button:hover, .file-tabs > button.active { background: var(--surface-soft); color: var(--text-primary); }.file-tabs > button.active { font-weight: 650; }.file-tabs > button small { margin-left: 4px; color: var(--text-muted); font-size: 8px; }.file-tabs__status { display: flex; min-width: 0; align-items: center; gap: 7px; margin-left: auto; color: var(--text-muted); font-size: 9px; white-space: nowrap; }.file-tabs__status > span { overflow: hidden; text-overflow: ellipsis; }.file-tabs__status .error { color: var(--danger); }.file-tabs__status .icon-button { display: grid; width: 28px; height: 28px; flex: 0 0 auto; place-items: center; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.file-tabs__status .icon-button:hover { background: var(--surface-soft); color: var(--text-primary); }
  145. @media (max-width: 600px) { .library-header { min-height: 52px; padding: 0 13px; }.library-header h2 { font-size: 14px; }.file-search { width: min(180px, 52vw); }.file-tabs { padding: 0 13px; }.file-tabs__status { display: none; } }
  146. </style>