Ver Fonte

将未读入口改为顶部铃铛并修复已读计数更新

Samien há 1 mês atrás
pai
commit
f4d18c64a0

+ 23 - 37
src/client/components/app/WorkspaceShell.vue

@@ -488,6 +488,10 @@ defineExpose({openLocalVm:showLocalVmSettings})
       <div v-if="applicationWorkspace" class="bot-list-header">
         <button class="bot-logo-trigger sidebar-brand" type="button" aria-label="返回 Bot 列表" title="夭夭 AI" @click="navigate('/conversations')"><BrandMark :size="32" :label="false" compact bare /></button>
         <span class="bot-list-header__spacer" />
+        <button class="bot-list-toolbar-button unread-entry" type="button" :aria-label="`未读消息,${unread.total} 条`" aria-haspopup="dialog" aria-controls="unread-center" :aria-expanded="unreadOpen" title="未读消息" @click="openUnread">
+          <AppIcon name="bell" :size="20" />
+          <b v-if="unread.total" class="unread-entry__count" aria-hidden="true">{{ unread.total > 99 ? '99+' : unread.total }}</b>
+        </button>
         <button class="bot-list-toolbar-button sidebar-search-trigger" type="button" aria-label="搜索" :aria-expanded="sidebarSearchOpen" @click="openSidebarSearch(desktopSidebarContext)"><AppIcon name="search" :size="21" /></button>
         <button class="bot-list-toolbar-button sidebar-create-trigger" type="button" aria-label="新建" aria-haspopup="menu" :aria-expanded="createMenuOpen" @click="openCreateMenu"><AppIcon name="plus" :size="23" /></button>
       </div>
@@ -495,23 +499,11 @@ defineExpose({openLocalVm:showLocalVmSettings})
         <button class="rail__brand sidebar-brand" type="button" aria-label="返回聊天" title="夭夭 AI" @click="navigate(applicationWorkspace ? '/conversations' : '/chat')">
           <BrandMark :size="sidebarCollapsed ? 26 : 32" :label="false" compact bare />
         </button>
+        <button class="unread-entry" type="button" :aria-label="`未读消息,${unread.total} 条`" aria-haspopup="dialog" aria-controls="unread-center" :aria-expanded="unreadOpen" title="未读消息" @click="openUnread">
+          <AppIcon name="bell" :size="20" />
+          <b v-if="unread.total" class="unread-entry__count" aria-hidden="true">{{ unread.total > 99 ? '99+' : unread.total }}</b>
+        </button>
       </div>
-      <button
-        class="unread-entry"
-        :class="{ 'unread-entry--pending': unread.total > 0 }"
-        type="button"
-        :aria-label="`未读消息,${unread.total} 条`"
-        aria-haspopup="dialog"
-        aria-controls="unread-center"
-        :aria-expanded="unreadOpen"
-        title="未读消息"
-        @click="openUnread"
-      >
-        <AppIcon name="bell" :size="18" />
-        <span class="unread-entry__label">未读消息</span>
-        <b v-if="unread.total" class="unread-entry__count" aria-hidden="true">{{ unread.total > 99 ? '99+' : unread.total }}</b>
-        <span v-else class="unread-entry__status" aria-hidden="true">全部已读</span>
-      </button>
       <button
         v-if="!applicationWorkspace"
         class="sidebar-collapse"
@@ -809,7 +801,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
 .mobile-header, .mobile-drawer, .drawer-scrim { display: none; }
 
 .desktop-sidebar {
-  --sidebar-search-top: 114px;
+  --sidebar-search-top: 58px;
   position: relative;
   z-index: 20;
   display: flex;
@@ -1038,33 +1030,27 @@ defineExpose({openLocalVm:showLocalVmSettings})
 
 <style scoped>
 .unread-entry {
-  display: flex;
-  min-height: 44px;
+  position: relative;
+  display: grid;
+  width: 36px;
+  height: 36px;
   flex: 0 0 auto;
-  align-items: center;
-  gap: 10px;
-  margin: 2px 12px 10px;
-  padding: 10px 12px;
+  place-items: center;
+  padding: 0;
   border: 1px solid transparent;
-  border-radius: 10px;
+  border-radius: 50%;
   background: transparent;
   color: var(--text-secondary);
   text-align: left;
   cursor: pointer;
   transition: background-color 140ms ease, border-color 140ms ease;
 }
-.unread-entry--pending { border-color: var(--line); background: var(--surface-soft); color: var(--text-primary); }
-.unread-entry:hover, .unread-entry[aria-expanded="true"] { background: var(--surface-hover); }
+.sidebar-brand-row .unread-entry { position: absolute; top: 9px; right: 49px; }
+.bot-list-header .unread-entry { position: relative; color: var(--text-secondary); }
+.unread-entry:hover, .unread-entry[aria-expanded="true"], .bot-list-header .unread-entry:hover, .bot-list-header .unread-entry[aria-expanded="true"] { background: var(--surface-hover); color: var(--text-primary); }
 .unread-entry:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
-.unread-entry__label { flex: 1; font-size: 14px; font-weight: 600; white-space: nowrap; }
-.unread-entry__status { color: var(--text-muted); font-size: 12px; white-space: nowrap; }
-.unread-entry__count { min-width: 24px; padding: 3px 7px; border-radius: 20px; background: var(--accent); color: var(--text-on-solid); font-size: 12px; line-height: 16px; text-align: center; white-space: nowrap; font-variant-numeric: tabular-nums; }
-.desktop-sidebar--collapsed .unread-entry { position: relative; justify-content: center; margin: 4px 8px 10px; padding: 10px; }
-.desktop-sidebar--collapsed .unread-entry__label, .desktop-sidebar--collapsed .unread-entry__status { display: none; }
-.desktop-sidebar--collapsed .unread-entry__count { position: absolute; top: -4px; right: -4px; min-width: 18px; padding: 1px 4px; font-size: 10px; }
-@media (max-width: 900px) {
-  .workspace-shell--conversations .unread-entry { order: 1; margin: 8px 12px; }
-  .workspace-shell--conversations .sidebar-footer { order: 2; }
-}
-@media (max-width: 767px) { .unread-entry { display: none; } }
+.unread-entry__count { position: absolute; top: -3px; right: -3px; display: grid; min-width: 18px; height: 18px; place-items: center; padding: 0 4px; border: 2px solid var(--surface); border-radius: 20px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; font-weight: 600; line-height: 14px; white-space: nowrap; font-variant-numeric: tabular-nums; }
+.desktop-sidebar--collapsed .sidebar-brand-row { flex-direction: column; gap: 8px; padding: 9px 13px; }
+.desktop-sidebar--collapsed .unread-entry { position: relative; top: auto; right: auto; }
+@media (max-width: 767px) { .unread-entry, .bot-list-header .unread-entry { display: none; } }
 </style>

+ 10 - 6
src/client/stores/unread.ts

@@ -5,24 +5,28 @@ const createUnreadStore=()=>{
  const snapshot=ref<UnreadSnapshot>(emptyUnread()),error=ref(''),scope=ref('')
  let epoch=0,revision=0,timer:ReturnType<typeof setInterval>|undefined,inflight:Promise<void>|undefined
  function reset(account='') { epoch++;revision++;scope.value=account;snapshot.value=emptyUnread();error.value='';inflight=undefined;clearInterval(timer);timer=undefined }
+ function accept(value:UnreadSnapshot){
+  snapshot.value=value;error.value=''
+  void window.yaoyaoDesktop?.unreadState?.(scope.value).catch(()=>{})
+ }
  async function refresh(){
   if(!scope.value)return
   if(inflight)return inflight
   const generation=epoch,request=revision
   const pending=apiRequest<UnreadSnapshot>('/api/app/unread').then(value=>{
    if(generation!==epoch||request!==revision)return
-   snapshot.value=value;error.value=''
-   void window.yaoyaoDesktop?.unreadState?.(scope.value).catch(()=>{})
-  }).catch(e=>{if(generation===epoch)error.value=e instanceof Error?e.message:'未读同步失败'})
+   accept(value)
+  }).catch(e=>{if(generation===epoch&&request===revision)error.value=e instanceof Error?e.message:'未读同步失败'})
   inflight=pending;try{await pending}finally{if(inflight===pending)inflight=undefined}
  }
  function start(account:string){reset(account);void window.yaoyaoDesktop?.unreadState?.(account).catch(()=>{});void refresh();timer=setInterval(()=>void refresh(),3000)}
  async function read(items:UnreadConversation[]){
-  const generation=epoch,account=scope.value;revision++;inflight=undefined
-  await apiRequest<UnreadSnapshot>('/api/app/unread/read',{method:'POST',body:JSON.parse(JSON.stringify({items:items.map(({mode,id,profile,messages})=>({mode,id,profile,messages}))}))})
+  const generation=epoch,account=scope.value,request=++revision;inflight=undefined
+  const value=await apiRequest<UnreadSnapshot>('/api/app/unread/read',{method:'POST',body:JSON.parse(JSON.stringify({items:items.map(({mode,id,profile,messages})=>({mode,id,profile,messages}))}))})
   if(generation!==epoch||account!==scope.value)return
+  if(request===revision){accept(value);return}
   // Concurrent reads can finish out of order; fetch the latest authority.
-  revision++;await refresh()
+  revision++;inflight=undefined;await refresh()
  }
  async function visible(mode:'bot'|'chat',id:string,profile:string|undefined,ids:string[]){
   if(!ids.length||!scope.value)return

+ 16 - 4
src/client/views/ConversationsView.vue

@@ -398,13 +398,25 @@ async function load(id = selected.value, append = false, signal?: AbortSignal) {
     if (own === generation) loading.value = false
   }
 }
+let unreadNavigation=0
 async function locateUnread(id:string){
- for(let i=0;i<100&&active.value?.id===selected.value;i++){
-  await nextTick();if(timeline.value?.scrollToMessage(id)){const query={...route.query};delete query.unread;await router.replace({query});return}
-  if(!older.value||loadingOlder.value)return
-  await loadOlder()
+ const own=++unreadNavigation,conversation=selected.value,task=selectedTask.value,path=route.path
+ const current=()=>!disposed&&own===unreadNavigation&&route.path===path&&active.value?.id===conversation&&selected.value===conversation
+  &&selectedTask.value===task&&route.query.unread===id
+ try{
+  for(let i=0;i<100&&current();i++){
+   await nextTick();if(!current()||timeline.value?.scrollToMessage(id))return
+   if(!older.value||loadingOlder.value)return
+   await loadOlder()
+  }
+ }finally{
+  // A removed/unavailable target must not keep all visible messages unread.
+  if(current()){const query={...route.query};delete query.unread;await router.replace({query})}
  }
 }
+watch(()=>route.query.unread,id=>{
+ if(typeof id==='string'&&!loading.value&&active.value?.id===selected.value)void locateUnread(id)
+},{flush:'post'})
 const unread=useUnreadStore()
 const pendingReads = new Set<string>()
 async function markVisible(ids:string[]) {

+ 61 - 3
tests/client/conversationRecovery.test.ts

@@ -1,18 +1,21 @@
 import { afterEach, beforeEach, expect, it, vi } from 'vitest'
 import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
-import { shallowRef } from 'vue'
+import { defineComponent, shallowRef } from 'vue'
 import { createMemoryHistory, createRouter, matchedRouteKey } from 'vue-router'
 import ConversationsView from '@/views/ConversationsView.vue'
 import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
 import { apiRequest } from '@/api/client'
+import { useUnreadStore } from '@/stores/unread'
 
 vi.mock('@/api/client', async importOriginal => ({ ...await importOriginal<typeof import('@/api/client')>(), apiRequest: vi.fn(), setApiCsrfToken: vi.fn() }))
 vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ user: { id: 'owner' }, profiles: [], refreshProfileAvatars: async () => {} }) }))
 vi.mock('@/stores/theme', () => ({ useThemeStore: () => ({}) }))
 let wrapper: VueWrapper | undefined
 const sources: Array<{ url: string; close: ReturnType<typeof vi.fn> }> = []
+const scrollToMessage = vi.fn(() => true)
 beforeEach(() => {
   vi.useFakeTimers()
+  useUnreadStore().reset(); scrollToMessage.mockReset().mockReturnValue(true)
   sources.length = 0
   vi.stubGlobal('EventSource', class extends EventTarget {
     readonly close = vi.fn()
@@ -21,14 +24,20 @@ beforeEach(() => {
 })
 afterEach(() => {
   wrapper?.unmount(); wrapper = undefined
+  useUnreadStore().reset()
   vi.mocked(apiRequest).mockReset(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers()
 })
-async function mountView(path = '/conversations') {
+async function mountView(path = '/conversations', stubTimeline = false) {
   const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/conversations/:id?', component: { template: '<div />' } }] })
   await router.push(path); await router.isReady()
   wrapper = shallowMount(ConversationsView, { global: {
     provide: { [matchedRouteKey as symbol]: shallowRef(router.currentRoute.value.matched[0]) }, plugins: [router],
-    stubs: { WorkspaceShell: { template: '<div><slot name="sidebar" /><slot /></div>' }, ComposerShell: { template: '<div />', methods: { filesSnapshot: () => [] } }, Teleport: true },
+    stubs: { WorkspaceShell: { template: '<div><slot name="sidebar" /><slot /></div>' }, ComposerShell: { template: '<div />', methods: { filesSnapshot: () => [] } }, Teleport: true,
+      ...(stubTimeline ? { MessageTimeline: defineComponent({
+        name: 'WorkspaceMessageTimeline', props: ['messages', 'title'], emits: ['visibleMessages'], template: '<div />',
+        methods: { scrollToMessage, scrollToBottom() {}, isFollowingBottom: () => true },
+      }) } : {}),
+    },
   } })
   await flushPromises()
 }
@@ -111,3 +120,52 @@ it('aborts a pending detail on going offline and ignores its late response', asy
   expect(wrapper!.findComponent(WorkspaceMessageTimeline).props('title')).not.toBe('迟到会话')
   expect(sources).toHaveLength(0)
 })
+
+it.each([true, false])('resumes visible-message reads when entering the already-open conversation (target found: %s)', async found => {
+  const unread = useUnreadStore()
+  const pending = {
+    total: 2, bot: 2, chat: 0, conversations: [{ mode: 'bot' as const, id: 'c', name: '研发助手', preview: '回复', count: 2, updatedAt: 1,
+      messages: [{ id: 'first', seq: 1, version: 1 }, { id: 'second', seq: 2, version: 1 }],
+    }],
+  }
+  vi.mocked(apiRequest).mockImplementation(async path => {
+    if (path === '/api/app/unread') return pending as never
+    if (path === '/api/app/unread/read') return { ...pending, total: 1, bot: 1,
+      conversations: [{ ...pending.conversations[0], count: 1, messages: [pending.conversations[0]!.messages[1]] }],
+    } as never
+    if (path.startsWith('/api/app/conversations/c')) return {
+      conversation: { id: 'c', kind: 'direct', name: '研发助手', memberIds: [], readSeq: 0, lastSeq: 2 },
+      messages: ['first', 'second'].map(id => ({ id, role: 'assistant', status: 'complete', content: '回复', attachments: [], tools: [], createdAt: 1 })),
+      hasOlder: false, run: null, interactions: [], context: null,
+    } as never
+    return successfulResponse(path) as never
+  })
+  unread.start('owner'); await flushPromises()
+  await mountView('/conversations/c', true)
+  expect(wrapper!.findComponent(WorkspaceMessageTimeline).props('title')).toBe('研发助手')
+  scrollToMessage.mockReturnValue(found)
+  await wrapper!.vm.$router.push({ query: { unread: found ? 'first' : 'removed-message' } }); await flushPromises()
+  expect(scrollToMessage).toHaveBeenCalledWith(found ? 'first' : 'removed-message')
+  expect(wrapper!.vm.$router.currentRoute.value.query.unread).toBeUndefined()
+  wrapper!.findComponent(WorkspaceMessageTimeline).vm.$emit('visibleMessages', ['first'])
+  await flushPromises()
+  expect(unread.total).toBe(1)
+  expect(unread.snapshot.conversations[0]?.messages.map(message => message.id)).toEqual(['second'])
+  expect(apiRequest).toHaveBeenCalledWith('/api/app/unread/read', expect.objectContaining({
+    body: { items: [{ mode: 'bot', id: 'c', messages: [{ id: 'first', seq: 1, version: 1 }] }] },
+  }))
+})
+
+it('locates an unread target after opening and loading a different conversation', async () => {
+  vi.mocked(apiRequest).mockImplementation(async path => {
+    if (path.startsWith('/api/app/conversations/c')) return {
+      conversation: { id: 'c', kind: 'direct', name: '研发助手', memberIds: [], readSeq: 0, lastSeq: 1 },
+      messages: [{ id: 'first', role: 'assistant', status: 'complete', content: '回复', attachments: [], tools: [], createdAt: 1 }],
+      hasOlder: false, run: null, interactions: [], context: null,
+    } as never
+    return successfulResponse(path) as never
+  })
+  await mountView('/conversations/c?unread=first', true)
+  expect(scrollToMessage).toHaveBeenCalledWith('first')
+  expect(wrapper!.vm.$router.currentRoute.value.query.unread).toBeUndefined()
+})

+ 78 - 0
tests/client/unreadStore.test.ts

@@ -0,0 +1,78 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { flushPromises } from '@vue/test-utils'
+import { apiRequest } from '@/api/client'
+import { useUnreadStore } from '@/stores/unread'
+import { emptyUnread, type UnreadSnapshot } from '@shared/unread'
+
+vi.mock('@/api/client', () => ({ apiRequest: vi.fn() }))
+
+const unread = useUnreadStore()
+function snapshot(ids: string[]): UnreadSnapshot {
+  return { total: ids.length, bot: ids.length, chat: 0, conversations: ids.length ? [{
+    mode: 'bot', id: 'c', name: '研发助手', preview: '回复', count: ids.length, updatedAt: 1,
+    messages: ids.map((id, seq) => ({ id, seq, version: 1 })),
+  }] : [] }
+}
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  const promise = new Promise<T>(done => { resolve = done })
+  return { promise, resolve }
+}
+beforeEach(() => { vi.useFakeTimers(); unread.reset(); vi.mocked(apiRequest).mockReset() })
+afterEach(() => { unread.reset(); vi.restoreAllMocks(); vi.useRealTimers() })
+
+it('updates the badge from the read acknowledgement without waiting for another GET', async () => {
+  const initial = snapshot(['first', 'second'])
+  vi.mocked(apiRequest).mockResolvedValueOnce(initial)
+  unread.start('owner'); await flushPromises()
+  vi.mocked(apiRequest).mockImplementation(async path => {
+    if (path === '/api/app/unread/read') return snapshot(['second']) as never
+    throw new Error('后续同步暂不可用')
+  })
+  await unread.visible('bot', 'c', undefined, ['first'])
+  expect(unread.total).toBe(1)
+  expect(unread.snapshot.conversations[0]?.messages.map(message => message.id)).toEqual(['second'])
+  expect(apiRequest).toHaveBeenLastCalledWith('/api/app/unread/read', expect.objectContaining({
+    body: { items: [{ mode: 'bot', id: 'c', messages: [{ id: 'first', seq: 0, version: 1 }] }] },
+  }))
+  await unread.visible('bot', 'c', undefined, ['first'])
+  expect(apiRequest).toHaveBeenCalledTimes(2)
+})
+
+it('does not let a poll started before reading restore already-read messages', async () => {
+  const initial = snapshot(['first', 'second']), poll = deferred<UnreadSnapshot>()
+  vi.mocked(apiRequest).mockResolvedValueOnce(initial)
+  unread.start('owner'); await flushPromises()
+  vi.mocked(apiRequest).mockImplementation(path => path === '/api/app/unread'
+    ? poll.promise as never : Promise.resolve(snapshot(['second'])) as never)
+  const polling = unread.refresh()
+  await unread.visible('bot', 'c', undefined, ['first'])
+  poll.resolve(initial); await polling
+  expect(unread.total).toBe(1)
+})
+
+it('reconciles concurrent read acknowledgements that arrive in reverse order', async () => {
+  const initial = snapshot(['first', 'second'])
+  const first = deferred<UnreadSnapshot>(), second = deferred<UnreadSnapshot>()
+  vi.mocked(apiRequest).mockResolvedValueOnce(initial)
+  unread.start('owner'); await flushPromises()
+  vi.mocked(apiRequest).mockReturnValueOnce(first.promise as never).mockReturnValueOnce(second.promise as never).mockResolvedValue(emptyUnread())
+  const readingFirst = unread.visible('bot', 'c', undefined, ['first'])
+  const readingSecond = unread.visible('bot', 'c', undefined, ['second'])
+  second.resolve(emptyUnread()); await readingSecond
+  first.resolve(snapshot(['second'])); await readingFirst
+  expect(unread.total).toBe(0)
+  expect(unread.snapshot.conversations).toEqual([])
+})
+
+it('ignores a read acknowledgement after the account changes', async () => {
+  const pending = deferred<UnreadSnapshot>()
+  vi.mocked(apiRequest).mockResolvedValueOnce(snapshot(['first']))
+  unread.start('owner'); await flushPromises()
+  vi.mocked(apiRequest).mockReturnValueOnce(pending.promise as never)
+  const reading = unread.visible('bot', 'c', undefined, ['first'])
+  unread.reset('another-owner'); unread.snapshot = snapshot(['new-message'])
+  pending.resolve(emptyUnread()); await reading
+  expect(unread.total).toBe(1)
+  expect(unread.snapshot.conversations[0]?.messages[0]?.id).toBe('new-message')
+})