|
|
@@ -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; } }
|