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

fix: 统一复用夭夭图片视频灯箱

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

+ 84 - 0
src/client/components/library/ImagePreviewLightbox.vue

@@ -0,0 +1,84 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+import { ChevronBackOutline, ChevronForwardOutline, CloseOutline } from '@vicons/ionicons5'
+
+export type PreviewMedia = { url: string; name: string; type?: 'image' | 'video' }
+
+const props = defineProps<{
+  images: PreviewMedia[]
+  modelValue: number | null
+  closeLabel?: string
+  previousLabel?: string
+  nextLabel?: string
+}>()
+const emit = defineEmits<{ 'update:modelValue': [value: number | null]; close: [] }>()
+
+const dragStart = ref<{ pointerId: number; x: number } | null>(null)
+const previewMedia = computed(() => props.modelValue === null ? null : props.images[props.modelValue] || null)
+const canPrevious = computed(() => (props.modelValue ?? 0) > 0)
+const canNext = computed(() => props.modelValue !== null && props.modelValue < props.images.length - 1)
+
+function close() { dragStart.value = null; emit('update:modelValue', null); emit('close') }
+function move(direction: -1 | 1) {
+  if (props.modelValue === null) return
+  const next = props.modelValue + direction
+  if (next >= 0 && next < props.images.length) emit('update:modelValue', next)
+}
+function onKeydown(event: KeyboardEvent) {
+  if (props.modelValue === null) return
+  if (event.key === 'Escape') { event.preventDefault(); close() }
+  else if (event.key === 'ArrowLeft') { event.preventDefault(); move(-1) }
+  else if (event.key === 'ArrowRight') { event.preventDefault(); move(1) }
+}
+function onPointerDown(event: PointerEvent) {
+  if (!event.isPrimary) return
+  dragStart.value = { pointerId: event.pointerId, x: event.clientX }
+}
+function onPointerUp(event: PointerEvent) {
+  const start = dragStart.value
+  dragStart.value = null
+  if (!start || start.pointerId !== event.pointerId || Math.abs(event.clientX - start.x) < 48) return
+  move(event.clientX < start.x ? 1 : -1)
+}
+
+onMounted(() => window.addEventListener('keydown', onKeydown))
+onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
+</script>
+
+<template>
+  <Teleport to="body">
+    <div
+      v-if="previewMedia"
+      class="image-preview-overlay"
+      role="dialog"
+      aria-modal="true"
+      :aria-label="`预览 ${previewMedia.name}`"
+      @click.self="close"
+      @pointerdown="onPointerDown"
+      @pointerup="onPointerUp"
+      @pointercancel="dragStart = null"
+    >
+      <button class="image-preview-close" type="button" :aria-label="closeLabel || '关闭预览'" @click="close"><CloseOutline aria-hidden="true" /></button>
+      <button v-if="canPrevious" class="image-preview-nav image-preview-nav--previous" type="button" :aria-label="previousLabel || '上一张媒体'" @click.stop="move(-1)"><ChevronBackOutline aria-hidden="true" /></button>
+      <Transition name="image-preview-change" mode="out-in">
+        <video v-if="previewMedia.type === 'video'" :key="previewMedia.url" :src="previewMedia.url" :aria-label="previewMedia.name" class="image-preview-video" controls playsinline preload="metadata" @click.stop @pointerdown.stop @pointerup.stop />
+        <img v-else :key="previewMedia.url" :src="previewMedia.url" :alt="previewMedia.name" class="image-preview-img" draggable="false" @click.stop />
+      </Transition>
+      <button v-if="canNext" class="image-preview-nav image-preview-nav--next" type="button" :aria-label="nextLabel || '下一张媒体'" @click.stop="move(1)"><ChevronForwardOutline aria-hidden="true" /></button>
+      <span v-if="images.length > 1" class="image-preview-counter">{{ (modelValue || 0) + 1 }} / {{ images.length }}</span>
+    </div>
+  </Teleport>
+</template>
+
+<style scoped>
+.image-preview-overlay { position: fixed; z-index: 9999; inset: 0; display: flex; align-items: center; justify-content: center; isolation: isolate; background: rgba(0,0,0,.85); touch-action: none; user-select: none; }
+.image-preview-img, .image-preview-video { position: relative; z-index: 1; max-width: 90vw; max-height: 84vh; border-radius: 8px; object-fit: contain; box-shadow: 0 22px 64px rgba(0,0,0,.42); }
+.image-preview-video { width: min(90vw, 1200px); background: #000; }
+.image-preview-close, .image-preview-nav { position: absolute; z-index: 2; display: inline-flex; align-items: center; justify-content: center; border: 0; background: rgba(255,255,255,.12); color: #fff; cursor: pointer; backdrop-filter: blur(12px); transition: background .15s ease, transform .15s ease; }
+.image-preview-close:hover, .image-preview-close:focus-visible, .image-preview-nav:hover, .image-preview-nav:focus-visible { outline: none; background: rgba(255,255,255,.22); }
+.image-preview-close { top: 20px; right: 20px; width: 40px; height: 40px; border-radius: 50%; }.image-preview-close svg { width: 20px; height: 20px; }
+.image-preview-nav { top: 50%; width: 46px; height: 58px; border-radius: 14px; transform: translateY(-50%); }.image-preview-nav svg { width: 24px; height: 24px; }.image-preview-nav:hover, .image-preview-nav:focus-visible { transform: translateY(-50%) scale(1.04); }.image-preview-nav--previous { left: 24px; }.image-preview-nav--next { right: 24px; }
+.image-preview-counter { position: absolute; z-index: 2; bottom: 22px; left: 50%; padding: 6px 10px; border-radius: 999px; background: rgba(0,0,0,.46); color: rgba(255,255,255,.9); font-size: 12px; font-variant-numeric: tabular-nums; transform: translateX(-50%); }
+.image-preview-change-enter-active, .image-preview-change-leave-active { transition: opacity .16s ease, transform .16s ease; }.image-preview-change-enter-from { opacity: 0; transform: translateX(14px); }.image-preview-change-leave-to { opacity: 0; transform: translateX(-14px); }
+@media (max-width: 600px) { .image-preview-img, .image-preview-video { max-width: calc(100vw - 32px); max-height: 78vh; }.image-preview-video { width: calc(100vw - 32px); }.image-preview-close { top: max(14px, env(safe-area-inset-top)); right: 14px; }.image-preview-nav { width: 38px; height: 50px; border-radius: 12px; }.image-preview-nav--previous { left: 10px; }.image-preview-nav--next { right: 10px; } }
+</style>

+ 13 - 2
src/client/views/ChatView.vue

@@ -9,6 +9,7 @@ import ComposerShell from '@/components/composer/ComposerShell.vue'
 import type { ComposerOption, ComposerReference, ComposerSubmit } from '@/components/composer/types'
 import PreviewInspector from '@/components/library/PreviewInspector.vue'
 import PreviewModal from '@/components/library/PreviewModal.vue'
+import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
 import type { UiLibraryItem } from '@/components/library/types'
 import { mediaItemsFromMessages, previewItemFromUrl } from '@/components/library/mediaSequence'
 import MessageTimeline from '@/components/messages/MessageTimeline.vue'
@@ -29,6 +30,7 @@ const search = ref('')
 const quoted = ref<UiMessage | null>(null)
 const preview = ref<UiLibraryItem | null>(null)
 const filePreview = ref<UiLibraryItem | null>(null)
+const mediaPreviewIndex = ref<number | null>(null)
 const modelDialog = ref(false)
 const showTools = ref(true)
 const queueMode = ref(false)
@@ -53,6 +55,7 @@ const sidebarItems = computed(() => sessions.value.map(session => sessionSidebar
 )))
 const messages = computed(() => chatMessagesToUi(chat.messages, profile => profile ? agentNames.value.get(profile) : undefined))
 const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
+const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
 const activeSession = computed(() => chat.activeSession)
 const connected = computed(() => ['connected', 'ready'].includes(chat.connectionState))
 const interaction = computed(() => chatInteraction(chat.pendingApproval, chat.pendingClarification))
@@ -147,12 +150,19 @@ async function send(payload: ComposerSubmit) {
 function openAttachment(attachment: NonNullable<UiMessage['attachments']>[number]) {
   const item = previewItemFromUrl(attachment.name, attachment.url || '', attachment.id, attachment.kind)
   item.size = attachment.size
-  if (item.kind === 'image' || item.kind === 'video') filePreview.value = item
+  if (item.kind === 'image' || item.kind === 'video') openMedia(item)
   else preview.value = item
 }
 
+function openMedia(item: UiLibraryItem) {
+  const index = lightboxMedia.value.findIndex(media => media.url === (item.previewUrl || item.downloadUrl))
+  mediaPreviewIndex.value = index >= 0 ? index : null
+}
+
 function openLocalFile({ name, url }: { name: string; url: string }) {
-  filePreview.value = previewItemFromUrl(name, url)
+  const item = previewItemFromUrl(name, url)
+  if (item.kind === 'image' || item.kind === 'video') openMedia(item)
+  else filePreview.value = item
 }
 
 async function selectModel(id: string) {
@@ -331,6 +341,7 @@ watch(() => chat.activeSessionId, async id => {
   </WorkspaceView>
 
   <PreviewModal v-if="filePreview" :item="filePreview" :items="conversationMediaItems" @close="filePreview = null" @add-to-composer="filePreview = null" @source="filePreview = null" />
+  <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" />
 
   <ChoiceDialog :open="modelDialog" title="选择模型" :options="modelOptions" :selected-id="selectedModelId" @close="modelDialog = false" @select="selectModel" />
 

+ 14 - 1
src/client/views/FilesView.vue

@@ -5,6 +5,7 @@ import type { FileKind } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
 import LibraryGrid from '@/components/library/LibraryGrid.vue'
 import PreviewModal from '@/components/library/PreviewModal.vue'
+import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
 import type { LibraryFilterOption, UiLibraryItem } from '@/components/library/types'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
@@ -19,10 +20,12 @@ const auth = useAuthStore()
 const chat = useChatStore()
 const router = useRouter()
 const selected = ref<UiLibraryItem | null>(null)
+const mediaIndex = ref<number | null>(null)
 const historySearch = ref('')
 let searchTimer: number | undefined
 
 const items = computed(() => files.filteredItems.map(fileToUi))
+const lightboxMedia = computed(() => items.value.filter(item => item.kind === 'image' || item.kind === 'video').map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
 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))
@@ -70,6 +73,15 @@ async function openSource(item: UiLibraryItem) {
   })
 }
 
+function selectFile(item: UiLibraryItem) {
+  if (item.kind === 'image' || item.kind === 'video') {
+    const index = lightboxMedia.value.findIndex(media => media.url === (item.previewUrl || item.downloadUrl))
+    mediaIndex.value = index >= 0 ? index : null
+    return
+  }
+  selected.value = item
+}
+
 async function selectSession(id: string) {
   const session = chat.sessions.find(item => item.id === id)
   const profile = session?.profile || auth.activeProfile?.name
@@ -126,7 +138,7 @@ watch(() => auth.activeProfile?.name, profile => {
         :has-more="files.hasMore"
         empty-title="文件库还是空的"
         empty-description="聊天中的图片、文档和附件会出现在这里。"
-        @select="selected = $event"
+        @select="selectFile"
         @load-more="files.loadMore"
         @add-to-composer="addToComposer"
         @source="openSource"
@@ -134,6 +146,7 @@ watch(() => auth.activeProfile?.name, profile => {
     </section>
   </WorkspaceView>
   <PreviewModal v-if="selected" :item="selected" :items="items" @close="selected = null" @add-to-composer="addToComposer" @source="openSource" />
+  <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
 </template>
 
 <style scoped>

+ 14 - 2
src/client/views/GroupsView.vue

@@ -9,6 +9,7 @@ import type { ComposerOption, ComposerReference, ComposerSubmit } from '@/compon
 import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
 import GroupManager from '@/components/groups/GroupManager.vue'
 import PreviewModal from '@/components/library/PreviewModal.vue'
+import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
 import type { UiLibraryItem } from '@/components/library/types'
 import { mediaItemsFromMessages, previewItemFromUrl } from '@/components/library/mediaSequence'
 import MessageTimeline from '@/components/messages/MessageTimeline.vue'
@@ -29,6 +30,7 @@ const managerOpen = ref(false)
 const showTools = ref(true)
 const quoted = ref<UiMessage | null>(null)
 const preview = ref<UiLibraryItem | null>(null)
+const mediaPreviewIndex = ref<number | null>(null)
 const composer = ref<InstanceType<typeof ComposerShell> | null>(null)
 
 const filteredRooms = computed(() => groups.rooms
@@ -37,6 +39,7 @@ const filteredRooms = computed(() => groups.rooms
 const sidebarItems = computed(() => filteredRooms.value.map(roomSidebarItem))
 const messages = computed(() => groups.messages.map(groupMessageToUi))
 const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
+const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
 const agents = computed(() => groups.agents.map(agentToUi))
 const connected = computed(() => ['connected', 'ready'].includes(groups.connectionState))
 const activeInteraction = computed(() => groupInteraction(groups.pendingInteractions[0]))
@@ -99,13 +102,21 @@ async function archiveRoom() {
 }
 
 function openLocalFile({ name, url }: { name: string; url: string }) {
-  preview.value = previewItemFromUrl(name, url)
+  const item = previewItemFromUrl(name, url)
+  if (item.kind === 'image' || item.kind === 'video') openMedia(item)
+  else preview.value = item
+}
+
+function openMedia(item: UiLibraryItem) {
+  const index = lightboxMedia.value.findIndex(media => media.url === (item.previewUrl || item.downloadUrl))
+  mediaPreviewIndex.value = index >= 0 ? index : null
 }
 
 function openAttachment(attachment: NonNullable<UiMessage['attachments']>[number]) {
   const item = previewItemFromUrl(attachment.name, attachment.url || '', attachment.id, attachment.kind)
   item.size = attachment.size
-  preview.value = item
+  if (item.kind === 'image' || item.kind === 'video') openMedia(item)
+  else preview.value = item
 }
 
 onMounted(async () => {
@@ -218,6 +229,7 @@ watch(() => route.params.roomId, async roomId => {
 
   <CreateGroupDialog :open="createOpen" :profiles="profiles" :busy="groups.isLoading" @close="createOpen = false" @create="createRoom" />
   <PreviewModal v-if="preview" :item="preview" :items="conversationMediaItems" @close="preview = null" @add-to-composer="preview = null" @source="preview = null" />
+  <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" />
 </template>
 
 <style scoped>