Просмотр исходного кода

feat: 重排文件库分类与会话侧栏

samien 2 месяцев назад
Родитель
Сommit
95d46f4ec3

+ 3 - 2
src/client/components/app/WorkspaceShell.vue

@@ -59,7 +59,8 @@ const navItems: NavItem[] = [
 
 // The active workspace is already represented by its main canvas and sidebar
 // context. Keep this strip focused on destinations the user can switch to.
-const featureNavItems = computed(() => navItems.filter(item => item.key !== activeNav.value.key))
+const featureNavItems = computed(() => navItems.filter(item => item.key !== activeNav.value.key
+  && !(activeNav.value.key === 'groups' && item.key === 'files')))
 
 const activeNav = computed(() => {
   const item = navItems.find(entry => route.path.startsWith(entry.path))
@@ -69,7 +70,7 @@ const activeNav = computed(() => {
 const contextHeading = computed(() => ({
   chat: '历史记录',
   groups: '群聊记录',
-  files: '文件筛选',
+  files: '历史记录',
 })[activeNav.value.key])
 
 const hasPrimaryAction = computed(() => activeNav.value.key === 'chat' || activeNav.value.key === 'groups')

+ 47 - 18
src/client/views/FilesView.vue

@@ -4,26 +4,34 @@ import { useRouter } from 'vue-router'
 import type { FileKind } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
 import LibraryGrid from '@/components/library/LibraryGrid.vue'
-import LibrarySidebar from '@/components/library/LibrarySidebar.vue'
 import PreviewModal from '@/components/library/PreviewModal.vue'
 import type { LibraryFilterOption, UiLibraryItem } from '@/components/library/types'
+import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
 import { queueLibraryItemForComposer } from '@/components/workspace/pendingComposer'
-import { fileToUi } from '@/components/workspace/viewModels'
+import { fileToUi, sessionSidebarItem } from '@/components/workspace/viewModels'
 import { useFilesStore } from '@/stores/files'
 import { useAuthStore } from '@/stores/auth'
+import { useChatStore } from '@/stores/chat'
 
 const files = useFilesStore()
 const auth = useAuthStore()
+const chat = useChatStore()
 const router = useRouter()
 const selected = ref<UiLibraryItem | null>(null)
+const historySearch = ref('')
 let searchTimer: number | undefined
 
 const items = computed(() => files.filteredItems.map(fileToUi))
+const agentNames = computed(() => new Map(auth.profiles.map(profile => [profile.name, profile.agentName || profile.displayName || profile.name])))
+const historyItems = computed(() => chat.sessions
+  .filter(session => !['cron', 'ios_group'].includes(session.source))
+  .filter(session => !historySearch.value.trim() || `${session.title} ${session.preview || ''}`.toLocaleLowerCase().includes(historySearch.value.trim().toLocaleLowerCase()))
+  .map(session => sessionSidebarItem(session, chat.unreadCounts[session.id] ?? 0, agentNames.value.get(session.profile || auth.activeProfile?.name || ''))))
 const options = computed<LibraryFilterOption[]>(() => {
   const count = (kind: string) => files.items.filter(item => item.kind === kind).length
   return [
-    { id: 'all', label: '全部文件', count: files.items.length, icon: 'files' },
+    { id: 'all', label: '全部', count: files.items.length, icon: 'files' },
     { id: 'image', label: '图片', count: count('image'), icon: 'image' },
     { id: 'video', label: '视频', count: count('video'), icon: 'video' },
     { id: 'audio', label: '音频', count: count('audio'), icon: 'audio' },
@@ -62,7 +70,17 @@ async function openSource(item: UiLibraryItem) {
   })
 }
 
-onMounted(() => files.load(true))
+async function selectSession(id: string) {
+  const session = chat.sessions.find(item => item.id === id)
+  const profile = session?.profile || auth.activeProfile?.name
+  await router.push({ path: `/chat/${encodeURIComponent(id)}`, query: profile ? { profile } : {} })
+}
+
+onMounted(() => {
+  void files.load(true)
+  void chat.loadSessions(auth.activeProfile?.name)
+  void chat.loadUnread(auth.activeProfile?.name)
+})
 onBeforeUnmount(() => window.clearTimeout(searchTimer))
 watch(() => files.items, () => {
   if (selected.value && !files.items.some(item => item.id === selected.value?.id)) selected.value = null
@@ -71,26 +89,36 @@ watch(() => auth.activeProfile?.name, profile => {
   selected.value = null
   files.profile = profile
   void files.load(true)
+  void chat.loadSessions(profile)
+  void chat.loadUnread(profile)
 })
 </script>
 
 <template>
-  <WorkspaceView sidebar-title="文件库" :sidebar-subtitle="`${files.items.length} 个文件`">
+  <WorkspaceView sidebar-title="历史记录" :sidebar-subtitle="`${chat.sessions.length} 个会话`">
     <template #sidebar>
-      <LibrarySidebar
-        :search="files.query"
-        :filter="files.kindFilter"
-        :options="options"
-        :loading="files.isLoading"
-        :error="files.error"
-        :progress="files.nextCursor ? '还有更多文件可加载' : files.items.length ? '已同步到最新' : ''"
-        @search="updateSearch"
-        @filter="updateFilter"
-        @refresh="files.load(true)"
+      <ResourceSidebar
+        :items="historyItems"
+        :active-id="chat.activeSessionId"
+        :loading="chat.isLoading"
+        :search="historySearch"
+        single-line
+        search-placeholder="搜索会话"
+        empty-title="还没有会话"
+        empty-description="创建会话后会显示在这里。"
+        @search="historySearch = $event"
+        @select="selectSession"
       />
     </template>
     <section class="library-workspace">
-      <header class="library-header"><div><h2>文件库</h2><p>9119 保存的文件与附件</p></div><span><AppIcon name="files" :size="15" />{{ items.length }}</span></header>
+      <header class="library-header">
+        <div><h2>文件库</h2><span><AppIcon name="files" :size="15" />{{ items.length }}</span></div>
+        <label class="file-search"><AppIcon name="search" :size="15" /><input :value="files.query" type="search" placeholder="搜索文件名、路径或会话" @input="updateSearch(($event.target as HTMLInputElement).value)" /></label>
+      </header>
+      <nav class="file-tabs" aria-label="文件类型筛选">
+        <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>
+        <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>
+      </nav>
       <LibraryGrid
         :items="items"
         :selected-id="selected?.id"
@@ -110,6 +138,7 @@ watch(() => auth.activeProfile?.name, profile => {
 
 <style scoped>
 .library-workspace { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
-.library-header { display: flex; min-height: 62px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 20px; border-bottom: 1px solid var(--line); }.library-header h2 { margin: 0; font-size: 14px; letter-spacing: -.02em; }.library-header p { margin: 3px 0 0; color: var(--text-muted); font-size: 9px; }.library-header > span { display: flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 10px; }
-@media (max-width: 600px) { .library-header { min-height: 54px; padding: 8px 13px; } }
+.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; }
+.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); }
+@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; } }
 </style>

+ 2 - 1
tests/e2e/workspace.spec.ts

@@ -21,12 +21,13 @@ test('navigates every 9119 workspace without blank transitions', async ({ page }
   await expect(page.getByRole('heading', { name: '设计与工程协作' })).toBeVisible()
   await expect(page.getByRole('button', { name: '对话', exact: true })).toBeVisible()
   await expect(page.getByRole('button', { name: '群聊', exact: true })).toHaveCount(0)
+  await expect(page.getByRole('button', { name: '文件库', exact: true })).toHaveCount(0)
   await expect(page.getByRole('button', { name: '产物', exact: true })).toHaveCount(0)
   await expect(page.getByRole('button', { name: '新建群聊' })).toBeVisible()
   await expect(page.getByRole('button', { name: '管理群聊' })).toBeVisible()
   await expect(page.getByRole('button', { name: '发送消息' })).toBeVisible()
 
-  await page.getByRole('button', { name: '文件库' }).click()
+  await page.goto('/files')
   await expect(page.getByRole('heading', { name: '文件库', exact: true }).last()).toBeVisible()
   await expect(page.getByText('demo-report.pdf')).toBeVisible()
   await page.getByText('demo-report.pdf').click()