Procházet zdrojové kódy

Add archived group and topic management

samien před 2 měsíci
rodič
revize
b76c5b199b

+ 1 - 1
src/client/stores/groups.ts

@@ -110,7 +110,7 @@ export const useGroupsStore = defineStore('groups', () => {
   }
 
   function topicsForRoom(roomId: string): GroupTopicSummary[] {
-    return protocol.value.topicsByRoom[roomId] ?? []
+    return (protocol.value.topicsByRoom[roomId] ?? []).filter(topic => !topic.archived)
   }
 
   function markTopicRead(roomId: string, topicId: string | undefined, messages: GroupMessage[]): void {

+ 53 - 39
src/client/views/GroupsView.vue

@@ -45,11 +45,10 @@ const modelOptionsError = ref<Record<string, string>>({})
 const agentUpdateBusy = ref<Record<string, boolean>>({})
 const agentUpdateError = ref<Record<string, string>>({})
 const managerError = ref('')
-const roomActionMenu = ref<{ roomId: string; x: number; y: number } | null>(null)
+const roomActionMenu = ref<{ roomId: string; topicId?: string; x: number; y: number } | null>(null)
 const expandedRoomIds = ref(new Set<string>())
-const showArchivedRooms = ref(false)
+const archivedOverlayOpen = ref(false)
 const archivedRoomList = ref<GroupRoomSummary[]>([])
-const showArchivedTopics = ref(false)
 const archivedTopicList = ref<GroupTopicSummary[]>([])
 
 const activeRooms = computed(() => groups.rooms.filter(room => !room.archived))
@@ -226,10 +225,20 @@ async function openRoomManager(id: string) {
 }
 
 function openRoomActions(roomId: string, event: MouseEvent) {
-  if (!groups.topicProtocol || topicFromSidebarItemId(roomId) || !activeRooms.value.some(room => room.id === roomId)) return
+  const topic = topicFromSidebarItemId(roomId)
   const width = 174
-  const height = 46
+  const height = topic ? 46 : 80
   const inset = 8
+  if (topic) {
+    roomActionMenu.value = {
+      roomId: topic.roomId,
+      topicId: topic.topicId,
+      x: Math.max(inset, Math.min(event.clientX, window.innerWidth - width - inset)),
+      y: Math.max(inset, Math.min(event.clientY, window.innerHeight - height - inset)),
+    }
+    return
+  }
+  if (!groups.topicProtocol || !activeRooms.value.some(room => room.id === roomId)) return
   roomActionMenu.value = {
     roomId,
     x: Math.max(inset, Math.min(event.clientX, window.innerWidth - width - inset)),
@@ -250,6 +259,7 @@ function handleRoomActionKey(event: KeyboardEvent) {
 
 async function startTopicFromRoomAction() {
   const roomId = roomActionMenu.value?.roomId
+  if (roomActionMenu.value?.topicId) return
   closeRoomActions()
   if (!roomId) return
   try {
@@ -356,9 +366,12 @@ async function archiveRoom() {
   await router.replace(groupRoute())
 }
 
-async function toggleArchivedRooms() {
-  showArchivedRooms.value = !showArchivedRooms.value
-  if (showArchivedRooms.value) archivedRoomList.value = await groups.archivedRooms()
+async function openArchivedOverlay() {
+  archivedOverlayOpen.value = true
+  archivedRoomList.value = await groups.archivedRooms()
+  archivedTopicList.value = groups.selectedRoomId
+    ? await groups.archivedTopics(groups.selectedRoomId)
+    : []
 }
 
 async function restoreArchivedRoom(roomId: string) {
@@ -366,18 +379,19 @@ async function restoreArchivedRoom(roomId: string) {
   archivedRoomList.value = await groups.archivedRooms()
 }
 
-async function archiveSelectedTopic() {
-  const roomId = groups.selectedRoomId
-  const topic = groups.selectedTopic
-  if (!roomId || !topic || !window.confirm(`归档“${topic.title}”?历史消息会保留。`)) return
-  await groups.archiveTopic(roomId, topic.id)
+async function archiveTopicFromAction() {
+  const action = roomActionMenu.value
+  closeRoomActions()
+  if (!action?.topicId) return
+  await groups.archiveTopic(action.roomId, action.topicId)
 }
 
-async function toggleArchivedTopics() {
-  const roomId = groups.selectedRoomId
-  if (!roomId) return
-  showArchivedTopics.value = !showArchivedTopics.value
-  if (showArchivedTopics.value) archivedTopicList.value = await groups.archivedTopics(roomId)
+async function archiveRoomFromAction() {
+  const action = roomActionMenu.value
+  closeRoomActions()
+  if (!action || action.topicId) return
+  await groups.archiveRoom(action.roomId)
+  if (route.params.roomId === action.roomId) await router.replace(groupRoute())
 }
 
 async function restoreArchivedTopic(topicId: string) {
@@ -473,14 +487,13 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         <YaoYaoSidebarIcon name="add" />
         <span>新建群聊</span>
       </button>
-      <button class="sidebar-primary-action" type="button" :disabled="groups.availability !== 'available'" @click="toggleArchivedRooms">
+      <button class="sidebar-primary-action" type="button" :disabled="groups.availability !== 'available'" @click="openArchivedOverlay">
         <AppIcon name="archive" :size="15" />
-        <span>{{ showArchivedRooms ? '活跃群聊' : '已归档' }}</span>
+        <span>已归档</span>
       </button>
     </template>
     <template #sidebar>
       <ResourceSidebar
-        v-if="!showArchivedRooms"
         :items="sidebarItems"
         :active-id="groups.selectedRoomId"
         :loading="groups.isLoading"
@@ -493,26 +506,12 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         @context-menu="openRoomActions"
         @toggle="toggleRoomTopics"
       />
-      <section v-else class="archived-list" aria-label="已归档群聊">
-        <p v-if="!archivedRoomList.length">没有已归档群聊</p>
-        <article v-for="archived in archivedRoomList" :key="archived.id">
-          <span>{{ archived.name }}</span>
-          <button type="button" @click="restoreArchivedRoom(archived.id)">恢复</button>
-        </article>
-      </section>
     </template>
 
     <div v-if="groups.availability === 'unsupported' || groups.availability === 'unavailable'" class="groups-unavailable">
       <EmptyState icon="alert" :title="groups.availability === 'unsupported' ? '群聊协议版本不兼容' : '群聊服务暂不可用'" :description="groups.error || `请确认 9119 已安装 YaoYao 群聊 protocol ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL} 插件。`" action-label="重新检查" @action="groups.refresh" />
     </div>
     <div v-else class="groups-workspace">
-      <section v-if="showArchivedTopics" class="archived-topic-list" aria-label="已归档话题">
-        <p v-if="!archivedTopicList.length">此群聊没有已归档话题</p>
-        <article v-for="topic in archivedTopicList" :key="topic.id">
-          <span>{{ topic.title }}</span>
-          <button type="button" @click="restoreArchivedTopic(topic.id)">恢复</button>
-        </article>
-      </section>
       <MessageTimeline
         :messages="messages"
         :title="groups.topicProtocol ? (groups.selectedTopic?.title || '新话题') : (groups.selectedRoom?.name || '群聊')"
@@ -538,8 +537,6 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
             <span v-if="hostAgent" class="group-host-chip" :title="`用户未明确 @ 时由 ${hostAgent.displayName || hostAgent.profile} 负责回应`">主持人 {{ hostAgent.displayName || hostAgent.profile }}</span>
             <div class="group-avatars" aria-label="群聊成员"><span v-for="agent in agents.slice(0, 4)" :key="agent.id" :title="agent.isHost ? `${agent.name} · 主持人` : agent.name" :class="{ host: agent.isHost }">{{ agent.name.slice(0, 1).toUpperCase() }}</span><em v-if="agents.length > 4">+{{ agents.length - 4 }}</em></div>
             <button class="icon-button" type="button" title="管理群聊" aria-label="管理群聊" :disabled="!groups.selectedRoom" @click="managerOpen = true"><AppIcon name="dots" /></button>
-            <button v-if="groups.selectedTopic" class="icon-button" type="button" title="归档话题" aria-label="归档话题" @click="archiveSelectedTopic"><AppIcon name="archive" /></button>
-            <button v-if="groups.selectedRoom" class="icon-button" type="button" title="已归档话题" aria-label="已归档话题" @click="toggleArchivedTopics"><AppIcon name="archive" /></button>
           </div>
         </template>
       </MessageTimeline>
@@ -597,22 +594,39 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
   <Teleport to="body">
     <Transition name="group-menu">
       <section v-if="roomActionMenu" class="group-room-actions" :style="roomActionMenuStyle" role="menu" aria-label="群聊房间操作" @contextmenu.prevent>
-        <button class="group-action-row" role="menuitem" type="button" @click="startTopicFromRoomAction"><AppIcon name="plus" :size="14" />新建话题</button>
+        <button v-if="roomActionMenu.topicId" class="group-action-row danger" role="menuitem" type="button" @click="archiveTopicFromAction"><AppIcon name="archive" :size="14" />归档话题</button>
+        <template v-else>
+          <button class="group-action-row" role="menuitem" type="button" @click="startTopicFromRoomAction"><AppIcon name="plus" :size="14" />新建话题</button>
+          <button class="group-action-row danger" role="menuitem" type="button" @click="archiveRoomFromAction"><AppIcon name="archive" :size="14" />归档群聊</button>
+        </template>
       </section>
     </Transition>
   </Teleport>
+
+  <Teleport to="body">
+    <Transition name="group-menu">
+      <div v-if="archivedOverlayOpen" class="archived-overlay-backdrop" role="presentation" @click.self="archivedOverlayOpen = false">
+        <section class="archived-overlay" role="dialog" aria-modal="true" aria-label="已归档群聊和话题">
+          <header><div><small>群聊归档</small><strong>已归档内容</strong></div><button type="button" aria-label="关闭" @click="archivedOverlayOpen = false"><AppIcon name="close" :size="16" /></button></header>
+          <section class="archived-section"><h3>群聊</h3><p v-if="!archivedRoomList.length">没有已归档群聊</p><article v-for="archived in archivedRoomList" :key="archived.id"><span>{{ archived.name }}</span><button type="button" @click="restoreArchivedRoom(archived.id)">恢复</button></article></section>
+          <section class="archived-section"><h3>当前群聊的话题</h3><p v-if="!groups.selectedRoom">请先打开一个群聊查看其已归档话题</p><p v-else-if="!archivedTopicList.length">没有已归档话题</p><article v-for="topic in archivedTopicList" :key="topic.id"><span>{{ topic.title }}</span><button type="button" @click="restoreArchivedTopic(topic.id)">恢复</button></article></section>
+        </section>
+      </div>
+    </Transition>
+  </Teleport>
 </template>
 
 <style scoped>
 .groups-workspace, .groups-unavailable { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; background: var(--conversation-canvas); }.groups-unavailable { overflow: auto; }
 .group-room-actions { position: fixed; z-index: 205; width: 174px; box-sizing: border-box; padding: 6px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-raised); box-shadow: 0 12px 34px rgba(0,0,0,.16); }
 .group-action-row { display: flex; width: 100%; min-height: 34px; align-items: center; gap: 8px; padding: 0 9px; border: 0; border-radius: 7px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 11px; text-align: left; }.group-action-row:hover, .group-action-row:focus-visible { outline: 0; background: var(--surface-soft); color: var(--text-primary); }
+.group-action-row.danger:hover, .group-action-row.danger:focus-visible { color: var(--danger); }
 .group-menu-enter-active, .group-menu-leave-active { transition: opacity 100ms ease, transform 120ms var(--ease-out); transform-origin: top right; }.group-menu-enter-from, .group-menu-leave-to { opacity: 0; transform: translateY(-3px) scale(.98); }
 .sidebar-primary-action { display: flex; width: 100%; min-height: 40px; align-items: center; gap: 10px; padding: 0 11px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; font-size: 12px; font-weight: 610; text-align: left; transition: background-color 120ms ease; }
 .sidebar-primary-action:hover, .sidebar-primary-action:focus-visible { background: var(--surface-hover); outline: 0; }
 .sidebar-primary-action:focus-visible { box-shadow: inset 0 0 0 1px var(--line-strong); }
 .sidebar-primary-action:disabled { cursor: not-allowed; opacity: .35; }
-.archived-list, .archived-topic-list { display: grid; gap: 8px; padding: 10px; color: var(--text-secondary); font-size: 12px; }.archived-list article, .archived-topic-list article { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px; border: 1px solid var(--line); border-radius: 8px; }.archived-list button, .archived-topic-list button { border: 0; border-radius: 6px; background: var(--surface-hover); color: var(--text-primary); cursor: pointer; padding: 4px 8px; }
+.archived-overlay-backdrop { position: fixed; z-index: 220; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(0,0,0,.36); }.archived-overlay { width: min(460px, 100%); max-height: min(620px, calc(100vh - 40px)); overflow: auto; border: 1px solid var(--line); border-radius: 16px; background: var(--surface-raised); box-shadow: 0 24px 70px rgba(0,0,0,.28); }.archived-overlay header { display: flex; align-items: center; justify-content: space-between; padding: 18px 18px 14px; border-bottom: 1px solid var(--line); }.archived-overlay header small { display: block; color: var(--text-secondary); font-size: 10px; }.archived-overlay header strong { font-size: 15px; }.archived-overlay header button { display: grid; width: 30px; height: 30px; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; }.archived-section { display: grid; gap: 8px; padding: 16px 18px; }.archived-section + .archived-section { border-top: 1px solid var(--line); }.archived-section h3 { margin: 0; font-size: 12px; }.archived-section p { margin: 0; color: var(--text-secondary); font-size: 12px; }.archived-section article { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 0; }.archived-section article span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.archived-section article button { border: 0; border-radius: 7px; background: var(--surface-hover); color: var(--text-primary); cursor: pointer; padding: 5px 9px; }
 .group-header-actions { display: flex; align-items: center; gap: 8px; }.group-host-chip { max-width: 150px; overflow: hidden; padding: 4px 7px; border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line)); border-radius: 999px; background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--accent); font-size: 9px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }.group-avatars { display: flex; align-items: center; }.group-avatars span, .group-avatars em { display: grid; width: 24px; height: 24px; margin-left: -5px; place-items: center; border: 2px solid var(--canvas); border-radius: 8px; background: var(--accent); color: var(--text-on-solid); font-size: 8px; font-style: normal; font-weight: 650; }.group-avatars span.host { box-shadow: 0 0 0 1px var(--accent); }.group-avatars span:first-child { margin-left: 0; }.group-avatars em { background: var(--surface-hover); color: var(--text-secondary); }
 .group-error { display: flex; width: min(760px, calc(100% - 32px)); margin: 0 auto 4px; align-items: center; gap: 6px; color: var(--danger); font-size: 9px; }
 @media (max-width: 480px) { .group-avatars, .group-host-chip { display: none; } }

+ 1 - 0
src/server/app.ts

@@ -127,6 +127,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   app.use(bodyParser({
     encoding: 'utf-8',
     enableTypes: ['json'],
+    parsedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
     jsonLimit: '2mb',
     onError: (_error, ctx) => {
       throw new HttpError(ctx.status === 413 ? 413 : 400, 'Invalid JSON request body', 'invalid_json_body')

+ 19 - 2
src/server/routes.ts

@@ -632,7 +632,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   })
   router.get('/api/app/groups/rooms', async (ctx) => {
     await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/rooms', {
-      search: searchFrom(ctx, ['limit', 'cursor']),
+      search: searchFrom(ctx, ['limit', 'cursor', 'archived']),
     })
   })
   router.post('/api/app/groups/rooms', async (ctx) => {
@@ -650,6 +650,11 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       })
     })
   }
+  router.post('/api/app/groups/rooms/:roomID/restore', async (ctx) => {
+    await proxy(ctx, dependencies.upstream, groupPath(ctx, '/restore'), {
+      method: 'POST', requestBody: body(ctx),
+    })
+  })
   router.post('/api/app/groups/rooms/:roomID/agents', async (ctx) => {
     await proxy(ctx, dependencies.upstream, groupPath(ctx, '/agents'), {
       method: 'POST', requestBody: body(ctx),
@@ -671,7 +676,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   })
   router.get('/api/app/groups/rooms/:roomID/topics', async (ctx) => {
     await proxy(ctx, dependencies.upstream, groupPath(ctx, '/topics'), {
-      search: searchFrom(ctx, ['limit', 'cursor']),
+      search: searchFrom(ctx, ['limit', 'cursor', 'archived']),
     })
   })
   router.patch('/api/app/groups/rooms/:roomID/topics/:topicID', async (ctx) => {
@@ -680,6 +685,18 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       method: 'PATCH', requestBody: body(ctx),
     })
   })
+  router.delete('/api/app/groups/rooms/:roomID/topics/:topicID', async (ctx) => {
+    const topicID = canonicalUUID(ctx.params.topicID, 'topic ID')
+    await proxy(ctx, dependencies.upstream, groupPath(ctx, `/topics/${topicID}`), {
+      method: 'DELETE', requestBody: body(ctx),
+    })
+  })
+  router.post('/api/app/groups/rooms/:roomID/topics/:topicID/restore', async (ctx) => {
+    const topicID = canonicalUUID(ctx.params.topicID, 'topic ID')
+    await proxy(ctx, dependencies.upstream, groupPath(ctx, `/topics/${topicID}/restore`), {
+      method: 'POST', requestBody: body(ctx),
+    })
+  })
   router.patch('/api/app/groups/rooms/:roomID/topics/:topicID/read', async (ctx) => {
     const topicID = canonicalUUID(ctx.params.topicID, 'topic ID')
     await proxy(ctx, dependencies.upstream, groupPath(ctx, `/topics/${topicID}/read`), {

+ 24 - 1
tests/server/app.test.ts

@@ -319,13 +319,14 @@ describe('8800 BFF', () => {
     const topicID = '22222222-2222-4222-8222-222222222222'
 
     await request(runtime.app.callback())
-      .get(`/api/app/groups/rooms/${roomID}/topics?limit=25&cursor=next-topic&ignored=drop-me`)
+      .get(`/api/app/groups/rooms/${roomID}/topics?limit=25&cursor=next-topic&archived=true&ignored=drop-me`)
       .set('Host', '127.0.0.1:8800')
       .expect(200)
     const topics = records.at(-1)!
     expect(topics.path).toBe(`/api/plugins/yaoyao/v1/rooms/${roomID}/topics`)
     expect(topics.search.get('limit')).toBe('25')
     expect(topics.search.get('cursor')).toBe('next-topic')
+    expect(topics.search.get('archived')).toBe('true')
     expect(topics.search.has('ignored')).toBe(false)
 
     const bootstrap = await request(runtime.app.callback())
@@ -345,6 +346,28 @@ describe('8800 BFF', () => {
     expect(renamed.path).toBe(`/api/plugins/yaoyao/v1/rooms/${roomID}/topics/${topicID}`)
     expect(renamed.body).toEqual({ requestId: '33333333-3333-4333-8333-333333333333', title: '新标题' })
 
+    await request(runtime.app.callback())
+      .delete(`/api/app/groups/rooms/${roomID}/topics/${topicID}`)
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Cookie', cookieHeader(bootstrap))
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({ requestId: '55555555-5555-4555-8555-555555555555' })
+      .expect(200)
+    expect(records.at(-1)?.method).toBe('DELETE')
+    expect(records.at(-1)?.path).toBe(`/api/plugins/yaoyao/v1/rooms/${roomID}/topics/${topicID}`)
+
+    await request(runtime.app.callback())
+      .post(`/api/app/groups/rooms/${roomID}/topics/${topicID}/restore`)
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Cookie', cookieHeader(bootstrap))
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({ requestId: '66666666-6666-4666-8666-666666666666' })
+      .expect(200)
+    expect(records.at(-1)?.method).toBe('POST')
+    expect(records.at(-1)?.path).toBe(`/api/plugins/yaoyao/v1/rooms/${roomID}/topics/${topicID}/restore`)
+
     await request(runtime.app.callback())
       .patch(`/api/app/groups/rooms/${roomID}/topics/${topicID}/read`)
       .set('Host', '127.0.0.1:8800')