Răsfoiți Sursa

重新设计桌面未读消息入口与面板

Samien 1 lună în urmă
părinte
comite
102ed078cf

Fișier diff suprimat deoarece este prea mare
+ 99 - 16
src/client/components/app/UnreadPanel.vue


+ 69 - 6
src/client/components/app/WorkspaceShell.vue

@@ -101,7 +101,26 @@ const emit = defineEmits<{
   botSettingsChanged: []
 }>()
 
-const unread=useUnreadStore(),unreadOpen=ref(false)
+const unread = useUnreadStore(), unreadOpen = ref(false)
+const unreadAnchor = ref({ left: 0, top: 0 })
+let unreadTrigger: HTMLButtonElement | undefined
+
+function openUnread(event: MouseEvent) {
+  unreadTrigger = event.currentTarget as HTMLButtonElement
+  const entry = unreadTrigger.getBoundingClientRect()
+  const sidebar = unreadTrigger.closest('aside')!.getBoundingClientRect()
+  unreadAnchor.value = { left: sidebar.right + 12, top: entry.top }
+  profileMenuOpen.value = false
+  createMenuOpen.value = false
+  settingsMenuOpen.value = false
+  toolsMenuOpen.value = false
+  unreadOpen.value = true
+}
+
+function closeUnread() {
+  unreadOpen.value = false
+  void nextTick(() => { if (unreadTrigger?.isConnected) unreadTrigger.focus() })
+}
 const route = useRoute()
 const router = useRouter()
 const mobileDrawerOpen = ref(false)
@@ -447,7 +466,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
 </script>
 
 <template>
-  <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed && !applicationWorkspace, 'workspace-shell--sidebar-focused': sidebarFocusMode, 'workspace-shell--conversations': applicationWorkspace, 'workspace-shell--conversation-open': applicationWorkspace && !!route.params.id }" :inert="unreadOpen || settingsOpen || !!standalone || (applicationWorkspace && sidebarSearchOpen)">
+  <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed && !applicationWorkspace, 'workspace-shell--sidebar-focused': sidebarFocusMode, 'workspace-shell--conversations': applicationWorkspace, 'workspace-shell--conversation-open': applicationWorkspace && !!route.params.id }" :inert="(unreadOpen || settingsOpen || !!standalone || (applicationWorkspace && sidebarSearchOpen)) ? true : undefined">
     <header class="mobile-header" :inert="mobileDrawerOpen">
       <button ref="mobileNavigationTrigger" class="icon-button" type="button" aria-label="打开导航" @click="openMobileDrawer">
         <AppIcon name="menu" :size="20" />
@@ -477,6 +496,22 @@ defineExpose({openLocalVm:showLocalVmSettings})
           <BrandMark :size="sidebarCollapsed ? 26 : 32" :label="false" compact bare />
         </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"
@@ -543,7 +578,6 @@ defineExpose({openLocalVm:showLocalVmSettings})
         </div>
       </section>
 
-      <button class="unread-entry" type="button" :aria-label="`未读消息,${unread.total} 条`" title="未读消息" @click="unreadOpen=true"><AppIcon name="bell" :size="19" /><span v-if="!sidebarCollapsed || applicationWorkspace">未读消息</span><b v-if="unread.total">{{ unread.total>99?'99+':unread.total }}</b></button>
       <div class="sidebar-footer">
         <button v-if="applicationWorkspace" class="sidebar-tools-trigger" type="button" aria-haspopup="menu" :aria-expanded="toolsMenuOpen" @click="openToolsMenu"><YaoYaoSidebarIcon name="tools" :size="18" /><span>工具</span><AppIcon name="chevron-down" :size="15" /></button>
         <div class="sidebar-account-switcher">
@@ -752,7 +786,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
         </div>
       </div>
     </Teleport>
-  <UnreadPanel v-if="unreadOpen" :mode="applicationWorkspace?'bot':'chat'" @close="unreadOpen=false" />
+    <UnreadPanel v-if="unreadOpen" :anchor="unreadAnchor" @close="closeUnread" />
   </div>
 </template>
 
@@ -775,7 +809,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
 .mobile-header, .mobile-drawer, .drawer-scrim { display: none; }
 
 .desktop-sidebar {
-  --sidebar-search-top: 58px;
+  --sidebar-search-top: 114px;
   position: relative;
   z-index: 20;
   display: flex;
@@ -1003,5 +1037,34 @@ defineExpose({openLocalVm:showLocalVmSettings})
 </style>
 
 <style scoped>
-.unread-entry{display:flex;align-items:center;gap:10px;margin:8px 12px;padding:10px 12px;border:0;border-radius:10px;background:transparent;color:inherit;text-align:left;cursor:pointer;min-height:44px}.unread-entry:hover{background:var(--surface-hover,rgba(128,128,128,.1))}.unread-entry span{flex:1}.unread-entry b{border-radius:20px;background:var(--accent,#796bcd);color:white;min-width:22px;padding:2px 6px;font-size:12px;white-space:nowrap}.desktop-sidebar--collapsed .unread-entry{position:relative;margin:8px;padding:10px}.desktop-sidebar--collapsed .unread-entry b{position:absolute;top:0;right:0;font-size:10px}@media(max-width:767px){.unread-entry{display:none}}
+.unread-entry {
+  display: flex;
+  min-height: 44px;
+  flex: 0 0 auto;
+  align-items: center;
+  gap: 10px;
+  margin: 2px 12px 10px;
+  padding: 10px 12px;
+  border: 1px solid transparent;
+  border-radius: 10px;
+  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); }
+.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; } }
 </style>

+ 118 - 0
tests/client/unreadPanel.test.ts

@@ -0,0 +1,118 @@
+import { mount, flushPromises } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createPinia } from 'pinia'
+import { createMemoryHistory, createRouter } from 'vue-router'
+import UnreadPanel from '@/components/app/UnreadPanel.vue'
+import { useAuthStore } from '@/stores/auth'
+import { useUnreadStore } from '@/stores/unread'
+import type { UnreadSnapshot } from '@shared/unread'
+
+const snapshot: UnreadSnapshot = {
+  total: 5, bot: 2, chat: 3,
+  conversations: [
+    { mode: 'bot', id: 'bot-1', name: '开发助手', preview: '任务已完成', count: 2, updatedAt: 1, messages: [{ id: 'bot-message', seq: 1, taskId: 'task-1' }] },
+    { mode: 'chat', id: 'chat-1', profile: 'ops', name: '运维聊天', preview: '检查结果', count: 3, updatedAt: 2, messages: [{ id: 'chat-message', seq: 2 }] },
+  ],
+}
+const unread = useUnreadStore()
+
+async function mountPanel() {
+  const pinia = createPinia()
+  const router = createRouter({
+    history: createMemoryHistory(),
+    routes: [
+      { path: '/conversations', component: { template: '<div />' } },
+      { path: '/conversations/:id', component: { template: '<div />' } },
+      { path: '/chat/:id', component: { template: '<div />' } },
+    ],
+  })
+  await router.push('/conversations')
+  await router.isReady()
+  const selectProfile = vi.spyOn(useAuthStore(pinia), 'selectProfile').mockResolvedValue(undefined)
+  const wrapper = mount(UnreadPanel, {
+    attachTo: document.body,
+    props: { anchor: { left: 372, top: 86 } },
+    global: { plugins: [pinia, router] },
+  })
+  return { wrapper, router, selectProfile }
+}
+
+beforeEach(() => {
+  unread.reset()
+  unread.snapshot = structuredClone(snapshot)
+})
+afterEach(() => {
+  vi.restoreAllMocks()
+  unread.reset()
+})
+
+function button(selector: string) { return document.querySelector<HTMLButtonElement>(selector)! }
+
+describe('Unread center', () => {
+  it('starts with all modes and marks only the selected mode as read', async () => {
+    const read = vi.spyOn(unread, 'read').mockResolvedValue(undefined)
+    const { wrapper } = await mountPanel()
+    expect(document.querySelector('.unread-header p')?.textContent).toBe('5 条未读 · 2 个会话')
+    expect(document.querySelectorAll('.unread-row')).toHaveLength(2)
+    button('.unread-filters button:nth-child(2)').click()
+    await flushPromises()
+    expect(document.querySelector('.unread-header p')?.textContent).toBe('2 条未读 · 1 个会话')
+    expect(document.querySelectorAll('.unread-row')).toHaveLength(1)
+    button('.unread-clear').click()
+    await flushPromises()
+    expect(read).toHaveBeenCalledOnce()
+    expect(read).toHaveBeenCalledWith([snapshot.conversations[0]])
+    wrapper.unmount()
+  })
+
+  it('preserves unread message navigation without marking unseen messages as read', async () => {
+    const read = vi.spyOn(unread, 'read').mockResolvedValue(undefined)
+    const { wrapper, router, selectProfile } = await mountPanel()
+    button('.unread-row').click()
+    await flushPromises()
+    expect(router.currentRoute.value.path).toBe('/conversations/bot-1')
+    expect(router.currentRoute.value.query).toEqual({ taskId: 'task-1', unread: 'bot-message' })
+    expect(wrapper.emitted('close')).toHaveLength(1)
+    expect(read).not.toHaveBeenCalled()
+    document.querySelectorAll<HTMLButtonElement>('.unread-row')[1]!.click()
+    await flushPromises()
+    expect(selectProfile).toHaveBeenCalledWith('ops')
+    expect(router.currentRoute.value.path).toBe('/chat/chat-1')
+    expect(router.currentRoute.value.query).toEqual({ profile: 'ops', unread: 'chat-message' })
+    expect(read).not.toHaveBeenCalled()
+    wrapper.unmount()
+  })
+
+  it('keeps keyboard focus inside the dialog', async () => {
+    const { wrapper } = await mountPanel()
+    const panel = document.querySelector<HTMLElement>('#unread-center')!
+    expect(document.activeElement).toBe(panel)
+    panel.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }))
+    const last = [...panel.querySelectorAll('button')].at(-1)!
+    expect(document.activeElement).toBe(last)
+    last.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }))
+    expect(document.activeElement).toBe(button('.unread-close'))
+    wrapper.unmount()
+  })
+
+  it('prevents duplicate bulk reads and keeps errors visible for retry', async () => {
+    let reject!: (reason: Error) => void
+    const read = vi.spyOn(unread, 'read').mockImplementationOnce(() => new Promise<void>((_, fail) => { reject = fail }))
+    const { wrapper } = await mountPanel()
+    button('.unread-clear').click()
+    await flushPromises()
+    expect(button('.unread-clear').disabled).toBe(true)
+    expect(button('.unread-clear').textContent).toContain('正在同步')
+    button('.unread-clear').click()
+    expect(read).toHaveBeenCalledOnce()
+    reject(new Error('同步失败,请重试'))
+    await flushPromises()
+    expect(document.querySelector('[role="alert"]')?.textContent).toBe('同步失败,请重试')
+    expect(button('.unread-clear').disabled).toBe(false)
+    unread.reset()
+    await flushPromises()
+    expect(document.querySelector('.unread-empty')?.textContent).toContain('暂无未读消息')
+    expect(button('.unread-clear').disabled).toBe(true)
+    wrapper.unmount()
+  })
+})

+ 26 - 2
tests/client/workspaceShell.test.ts

@@ -1,7 +1,9 @@
-import { mount, flushPromises } from '@vue/test-utils'
+import { DOMWrapper, mount, flushPromises } from '@vue/test-utils'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { createMemoryHistory, createRouter } from 'vue-router'
 import { defineComponent } from 'vue'
+import { createPinia } from 'pinia'
+import { useUnreadStore } from '@/stores/unread'
 import type { Profile } from '@shared/types'
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
 
@@ -46,7 +48,7 @@ async function mountShell(start = '/chat') {
       sidebarTitle: '历史记录',
     },
     global: {
-      plugins: [router],
+      plugins: [router, createPinia()],
       stubs: {
         SettingsCenterDialog: SettingsCenterDialogStub,
         BotPluginsDialog: defineComponent({emits:['close'],template:'<div data-testid="plugins-dialog"><button @click="$emit(\'close\')">关闭已连接应用</button></div>'}),
@@ -64,6 +66,7 @@ async function mountShell(start = '/chat') {
 
 beforeEach(() => {
   localStorage.clear()
+  useUnreadStore().reset()
   delete window.yaoyaoDesktop
 })
 
@@ -293,3 +296,24 @@ it('does not expose server update controls to a non-admin browser account', asyn
   expect(document.querySelector('.workspace-settings-menu')?.textContent).not.toContain('检测更新')
   wrapper.unmount()
 })
+
+it('opens the unread center beside the sidebar and restores focus after Escape', async () => {
+  const wrapper = await mountShell('/conversations')
+  const trigger = wrapper.get<HTMLButtonElement>('.desktop-sidebar .unread-entry')
+  vi.spyOn(trigger.element, 'getBoundingClientRect').mockReturnValue({ top: 86 } as DOMRect)
+  vi.spyOn(wrapper.get('.desktop-sidebar').element, 'getBoundingClientRect').mockReturnValue({ right: 360 } as DOMRect)
+  await trigger.trigger('click')
+  expect(trigger.attributes('aria-expanded')).toBe('true')
+  expect(wrapper.attributes()).toHaveProperty('inert')
+  const panel = document.querySelector<HTMLElement>('#unread-center')!
+  expect(panel).not.toBeNull()
+  expect(document.activeElement).toBe(panel)
+  expect(panel.parentElement?.style.getPropertyValue('--unread-left')).toBe('372px')
+  expect(panel.parentElement?.style.getPropertyValue('--unread-top')).toBe('86px')
+  await new DOMWrapper(panel).trigger('keydown', { key: 'Escape' })
+  await flushPromises()
+  expect(document.querySelector('#unread-center')).toBeNull()
+  expect(wrapper.attributes()).not.toHaveProperty('inert')
+  expect(document.activeElement).toBe(trigger.element)
+  wrapper.unmount()
+})

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff