CatPtain's picture
Upload index.vue
2de5a2e verified
raw
history blame
21.4 kB
<template>
<div
class="thumbnails"
@mousedown="() => setThumbnailsFocus(true)"
v-click-outside="() => setThumbnailsFocus(false)"
v-contextmenu="contextmenusThumbnails"
>
<div class="add-slide">
<div class="btn" @click="createSlide()"><IconPlus class="icon" />添加幻灯片</div>
<Popover trigger="click" placement="bottom-start" v-model:value="presetLayoutPopoverVisible" center>
<template #content>
<Templates
@select="slide => { createSlideByTemplate(slide); presetLayoutPopoverVisible = false }"
@selectAll="slides => { insertAllTemplates(slides); presetLayoutPopoverVisible = false }"
/>
</template>
<div class="select-btn"><IconDown /></div>
</Popover>
</div>
<Draggable
class="thumbnail-list"
ref="thumbnailsRef"
:modelValue="slides"
:animation="200"
:scroll="true"
:scrollSensitivity="50"
:disabled="editingSectionId"
@end="handleDragEnd"
itemKey="id"
>
<template #item="{ element, index }">
<div class="thumbnail-container">
<div class="section-title"
:data-section-id="element?.sectionTag?.id || ''"
v-if="element.sectionTag || (hasSection && index === 0)"
v-contextmenu="contextmenusSection"
>
<input
:id="`section-title-input-${element?.sectionTag?.id || 'default'}`"
type="text"
:value="element?.sectionTag?.title || ''"
placeholder="输入节名称"
@blur="$event => saveSection($event)"
@keydown.enter.stop="$event => saveSection($event)"
v-if="editingSectionId === element?.sectionTag?.id || (index === 0 && editingSectionId === 'default')"
>
<span class="text" v-else>
<div class="text-content">{{ element?.sectionTag ? (element?.sectionTag?.title || '无标题节') : '默认节' }}</div>
</span>
</div>
<div
class="thumbnail-item"
:class="{
'active': slideIndex === index,
'selected': selectedSlidesIndex.includes(index),
}"
@mousedown="$event => handleClickSlideThumbnail($event, index)"
@dblclick="enterScreening()"
v-contextmenu="contextmenusThumbnailItem"
>
<div class="label" :class="{ 'offset-left': index >= 99 }">{{ fillDigit(index + 1, 2) }}</div>
<ThumbnailSlide class="thumbnail" :slide="element" :size="120" :visible="index < slidesLoadLimit" />
<!-- 分享按钮 -->
<div class="share-btn" @click.stop="handleShareSlide(index)" v-tooltip="'分享本页'">
<IconShare class="icon" />
</div>
<div class="note-flag" v-if="element.notes && element.notes.length" @click="openNotesPanel()">{{ element.notes.length }}</div>
</div>
</div>
</template>
</Draggable>
<div class="page-number">幻灯片 {{slideIndex + 1}} / {{slides.length}}</div>
<!-- 分享链接弹窗 -->
<Modal
v-model:visible="shareModalVisible"
:width="600"
title="分享幻灯片"
:footer="null"
>
<div class="share-modal-content">
<div class="share-slide-info">
<h4>{{ shareSlideIndex + 1 }} 页幻灯片</h4>
<div class="slide-preview">
<ThumbnailSlide
v-if="shareSlideIndex >= 0 && slides[shareSlideIndex]"
:slide="slides[shareSlideIndex]"
:size="200"
/>
</div>
</div>
<div class="share-links">
<div class="link-group">
<label>单页查看链接:</label>
<div class="link-input-group">
<input
ref="slideUrlRef"
:value="shareLinks.slideUrl || '正在生成...'"
readonly
class="link-input"
/>
<button @click="copyToClipboard(shareLinks.slideUrl, '单页链接')" class="copy-btn">复制</button>
</div>
</div>
<div class="link-group">
<label>完整PPT链接:</label>
<div class="link-input-group">
<input
ref="pptUrlRef"
:value="shareLinks.pptUrl || '正在生成...'"
readonly
class="link-input"
/>
<button @click="copyToClipboard(shareLinks.pptUrl, 'PPT链接')" class="copy-btn">复制</button>
</div>
</div>
<div class="link-group">
<label>图片链接:</label>
<div class="link-input-group">
<input
ref="screenshotUrlRef"
:value="shareLinks.screenshotUrl || '正在生成...'"
readonly
class="link-input"
/>
<button @click="copyToClipboard(shareLinks.screenshotUrl, '图片链接')" class="copy-btn">复制</button>
</div>
</div>
</div>
<div class="share-tips">
<p><strong>说明:</strong></p>
<ul>
<li><strong>单页查看链接</strong>:只显示当前选中的幻灯片页面</li>
<li><strong>完整PPT链接</strong>:可以查看整个PPT演示文稿</li>
<li><strong>图片链接</strong>:直接返回幻灯片的JPEG图片</li>
</ul>
<p class="note">💡 这些链接是固定的,可以直接分享给他人访问</p>
</div>
</div>
</Modal>
</div>
</template>
<script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useMainStore, useSlidesStore, useKeyboardStore, useAuthStore } from '@/store'
import { fillDigit } from '@/utils/common'
import { isElementInViewport } from '@/utils/element'
import type { ContextmenuItem } from '@/components/Contextmenu/types'
import useSlideHandler from '@/hooks/useSlideHandler'
import useSectionHandler from '@/hooks/useSectionHandler'
import useScreening from '@/hooks/useScreening'
import useLoadSlides from '@/hooks/useLoadSlides'
import useAddSlidesOrElements from '@/hooks/useAddSlidesOrElements'
import usePPTManager from '@/hooks/usePPTManager'
import message from '@/utils/message'
import type { Slide } from '@/types/slides'
import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
import Templates from './Templates.vue'
import Popover from '@/components/Popover.vue'
import Modal from '@/components/Modal.vue'
import Draggable from 'vuedraggable'
const mainStore = useMainStore()
const slidesStore = useSlidesStore()
const keyboardStore = useKeyboardStore()
const { selectedSlidesIndex: _selectedSlidesIndex, thumbnailsFocus } = storeToRefs(mainStore)
const { slides, slideIndex, currentSlide } = storeToRefs(slidesStore)
const { ctrlKeyState, shiftKeyState } = storeToRefs(keyboardStore)
const { slidesLoadLimit } = useLoadSlides()
const selectedSlidesIndex = computed(() => [..._selectedSlidesIndex.value, slideIndex.value])
const presetLayoutPopoverVisible = ref(false)
const hasSection = computed(() => {
return slides.value.some(item => item.sectionTag)
})
const { addSlidesFromData } = useAddSlidesOrElements()
const {
copySlide,
pasteSlide,
createSlide,
createSlideByTemplate,
copyAndPasteSlide,
deleteSlide,
cutSlide,
selectAllSlide,
sortSlides,
isEmptySlide,
} = useSlideHandler()
const {
createSection,
removeSection,
removeAllSection,
removeSectionSlides,
updateSectionTitle,
} = useSectionHandler()
// 页面被切换时
const thumbnailsRef = ref<InstanceType<typeof Draggable>>()
watch(() => slideIndex.value, () => {
// 清除多选状态的幻灯片
if (selectedSlidesIndex.value.length) {
mainStore.updateSelectedSlidesIndex([])
}
// 检查当前页缩略图是否在可视范围,不在的话需要滚动到对应的位置
nextTick(() => {
const activeThumbnailRef: HTMLElement = thumbnailsRef.value?.$el?.querySelector('.thumbnail-item.active')
if (thumbnailsRef.value && activeThumbnailRef && !isElementInViewport(activeThumbnailRef, thumbnailsRef.value.$el)) {
setTimeout(() => {
activeThumbnailRef.scrollIntoView({ behavior: 'smooth' })
}, 100)
}
})
}, { immediate: true })
// 切换页面
const changeSlideIndex = (index: number) => {
mainStore.setActiveElementIdList([])
if (slideIndex.value === index) return
slidesStore.updateSlideIndex(index)
}
// 点击缩略图
const handleClickSlideThumbnail = (e: MouseEvent, index: number) => {
if (editingSectionId.value) return
const isMultiSelected = selectedSlidesIndex.value.length > 1
if (isMultiSelected && selectedSlidesIndex.value.includes(index) && e.button !== 0) return
// 按住Ctrl键,点选幻灯片,再次点击已选中的页面则取消选中
// 如果被取消选中的页面刚好是当前激活页面,则需要从其他被选中的页面中选择第一个作为当前激活页面
if (ctrlKeyState.value) {
if (slideIndex.value === index) {
if (!isMultiSelected) return
const newSelectedSlidesIndex = selectedSlidesIndex.value.filter(item => item !== index)
mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
changeSlideIndex(selectedSlidesIndex.value[0])
}
else {
if (selectedSlidesIndex.value.includes(index)) {
const newSelectedSlidesIndex = selectedSlidesIndex.value.filter(item => item !== index)
mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
}
else {
const newSelectedSlidesIndex = [...selectedSlidesIndex.value, index]
mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
}
}
}
// 按住Shift键,选择范围内的全部幻灯片
else if (shiftKeyState.value) {
if (slideIndex.value === index && !isMultiSelected) return
let minIndex = Math.min(...selectedSlidesIndex.value)
let maxIndex = index
if (index < minIndex) {
maxIndex = Math.max(...selectedSlidesIndex.value)
minIndex = index
}
const newSelectedSlidesIndex = []
for (let i = minIndex; i <= maxIndex; i++) newSelectedSlidesIndex.push(i)
mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
}
// 正常切换页面
else {
mainStore.updateSelectedSlidesIndex([])
changeSlideIndex(index)
}
}
// 设置缩略图工具栏聚焦状态(只有聚焦状态下,该部分的快捷键才能生效)
const setThumbnailsFocus = (focus: boolean) => {
if (thumbnailsFocus.value === focus) return
mainStore.setThumbnailsFocus(focus)
if (!focus) mainStore.updateSelectedSlidesIndex([])
}
// 拖拽调整顺序后进行数据的同步
const handleDragEnd = (eventData: { newIndex: number; oldIndex: number }) => {
const { newIndex, oldIndex } = eventData
if (newIndex === undefined || oldIndex === undefined || newIndex === oldIndex) return
sortSlides(newIndex, oldIndex)
}
// 打开批注面板
const openNotesPanel = () => {
mainStore.setNotesPanelState(true)
}
const editingSectionId = ref('')
const editSection = (id: string) => {
mainStore.setDisableHotkeysState(true)
editingSectionId.value = id || 'default'
nextTick(() => {
const inputRef = document.querySelector(`#section-title-input-${id || 'default'}`) as HTMLInputElement
inputRef.focus()
})
}
const saveSection = (e: FocusEvent | KeyboardEvent) => {
const title = (e.target as HTMLInputElement).value
updateSectionTitle(editingSectionId.value, title)
editingSectionId.value = ''
mainStore.setDisableHotkeysState(false)
}
const insertAllTemplates = (slides: Slide[]) => {
if (isEmptySlide.value) slidesStore.setSlides(slides)
else addSlidesFromData(slides)
}
const contextmenusSection = (el: HTMLElement): ContextmenuItem[] => {
const sectionId = el.dataset.sectionId!
return [
{
text: '删除节',
handler: () => removeSection(sectionId),
},
{
text: '删除节和幻灯片',
handler: () => {
mainStore.setActiveElementIdList([])
removeSectionSlides(sectionId)
},
},
{
text: '删除所有节',
handler: removeAllSection,
},
{
text: '重命名节',
handler: () => editSection(sectionId),
},
]
}
const { enterScreening, enterScreeningFromStart } = useScreening()
const contextmenusThumbnails = (): ContextmenuItem[] => {
return [
{
text: '粘贴',
subText: 'Ctrl + V',
handler: pasteSlide,
},
{
text: '全选',
subText: 'Ctrl + A',
handler: selectAllSlide,
},
{
text: '新建页面',
subText: 'Enter',
handler: createSlide,
},
{
text: '幻灯片放映',
subText: 'F5',
handler: enterScreeningFromStart,
},
]
}
const contextmenusThumbnailItem = (): ContextmenuItem[] => {
return [
{
text: '剪切',
subText: 'Ctrl + X',
handler: cutSlide,
},
{
text: '复制',
subText: 'Ctrl + C',
handler: copySlide,
},
{
text: '粘贴',
subText: 'Ctrl + V',
handler: pasteSlide,
},
{
text: '全选',
subText: 'Ctrl + A',
handler: selectAllSlide,
},
{ divider: true },
{
text: '新建页面',
subText: 'Enter',
handler: createSlide,
},
{
text: '复制页面',
subText: 'Ctrl + D',
handler: copyAndPasteSlide,
},
{
text: '删除页面',
subText: 'Delete',
handler: () => deleteSlide(),
},
{
text: '增加节',
handler: createSection,
disable: !!currentSlide.value.sectionTag,
},
{ divider: true },
{
text: '从当前放映',
subText: 'Shift + F5',
handler: enterScreening,
},
]
}
// 分享功能相关
const authStore = useAuthStore()
const { generateShareLinks } = usePPTManager()
const shareModalVisible = ref(false)
const shareSlideIndex = ref(-1)
const shareLinks = ref({
slideUrl: '',
pptUrl: '',
screenshotUrl: '',
})
// 防抖控制
const isGeneratingLinks = ref(false)
const lastGenerateTime = ref(0)
const generateDebounceTime = 2000 // 2秒防抖
// 处理分享幻灯片
const handleShareSlide = async (index: number) => {
if (!authStore.isLoggedIn) {
message.error('请先登录后再分享')
return
}
// 防抖检查
const now = Date.now()
if (isGeneratingLinks.value || (now - lastGenerateTime.value < generateDebounceTime)) {
message.warning('请稍后再试,避免频繁请求')
return
}
shareSlideIndex.value = index
shareModalVisible.value = true
isGeneratingLinks.value = true
lastGenerateTime.value = now
// 重置链接
shareLinks.value = {
slideUrl: '',
pptUrl: '',
screenshotUrl: '',
}
try {
// 生成分享链接
const links = await generateShareLinks(index)
shareLinks.value = {
slideUrl: links.slideUrl,
pptUrl: links.pptUrl,
screenshotUrl: links.screenshotUrl,
}
}
catch (error) {
message.error('生成分享链接失败')
// console.error('分享链接生成失败:', error)
}
finally {
// 延迟重置生成状态,确保防抖生效
setTimeout(() => {
isGeneratingLinks.value = false
}, 1000)
}
}
// 复制到剪贴板
const copyToClipboard = async (text: string, label: string) => {
if (!text || text === '正在生成...') {
message.error('链接还未生成完成')
return
}
try {
await navigator.clipboard.writeText(text)
message.success(`${label}已复制到剪贴板`)
}
catch (error) {
// 降级处理:使用传统方法复制
const textArea = document.createElement('textarea')
textArea.value = text
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand('copy')
message.success(`${label}已复制到剪贴板`)
}
catch (fallbackError) {
message.error(`复制${label}失败`)
}
document.body.removeChild(textArea)
}
}
</script>
<style lang="scss" scoped>
.thumbnails {
border-right: solid 1px $borderColor;
background-color: #fff;
display: flex;
flex-direction: column;
user-select: none;
}
.add-slide {
height: 40px;
font-size: 12px;
display: flex;
flex-shrink: 0;
border-bottom: 1px solid $borderColor;
cursor: pointer;
.btn {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
&:hover {
background-color: $lightGray;
}
}
.select-btn {
width: 30px;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
border-left: 1px solid $borderColor;
&:hover {
background-color: $lightGray;
}
}
.icon {
margin-right: 3px;
font-size: 14px;
}
}
.thumbnail-list {
padding: 5px 0;
flex: 1;
overflow: auto;
}
.thumbnail-item {
display: flex;
justify-content: center;
align-items: center;
padding: 5px 0;
position: relative;
.thumbnail {
border-radius: $borderRadius;
outline: 2px solid rgba($color: $themeColor, $alpha: .15);
}
&.active {
.label {
color: $themeColor;
}
.thumbnail {
outline-color: $themeColor;
}
}
&.selected {
.thumbnail {
outline-color: $themeColor;
}
.note-flag {
background-color: $themeColor;
&::after {
border-top-color: $themeColor;
}
}
}
.note-flag {
width: 16px;
height: 12px;
border-radius: 1px;
position: absolute;
left: 8px;
top: 13px;
font-size: 8px;
background-color: rgba($color: $themeColor, $alpha: .75);
color: #fff;
text-align: center;
line-height: 12px;
cursor: pointer;
&::after {
content: '';
width: 0;
height: 0;
position: absolute;
top: 10px;
left: 4px;
border: 4px solid transparent;
border-top-color: rgba($color: $themeColor, $alpha: .75);
}
}
.share-btn {
position: absolute;
top: 5px;
right: 5px;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
.icon {
font-size: 12px;
color: $themeColor;
}
&:hover {
background: rgba(255, 255, 255, 1);
transform: scale(1.1);
.icon {
color: darken($themeColor, 10%);
}
}
}
// 鼠标悬停时显示分享按钮
&:hover .share-btn {
opacity: 1;
}
// 当前激活页面始终显示分享按钮
&.active .share-btn {
opacity: 1;
}
}
.label {
font-size: 12px;
color: #999;
width: 20px;
cursor: grab;
&.offset-left {
position: relative;
left: -4px;
}
&:active {
cursor: grabbing;
}
}
.page-number {
height: 40px;
font-size: 12px;
border-top: 1px solid $borderColor;
line-height: 40px;
text-align: center;
color: #666;
}
.section-title {
height: 26px;
font-size: 12px;
padding: 6px 8px 2px 18px;
color: #555;
&.contextmenu-active {
color: $themeColor;
.text::before {
border-bottom-color: $themeColor;
border-right-color: $themeColor;
}
}
.text {
display: flex;
align-items: center;
position: relative;
&::before {
content: '';
width: 0;
height: 0;
border-top: 3px solid transparent;
border-left: 3px solid transparent;
border-bottom: 3px solid #555;
border-right: 3px solid #555;
margin-right: 5px;
}
.text-content {
display: inline-block;
@include ellipsis-oneline();
}
}
input {
width: 100%;
border: 0;
outline: 0;
padding: 0;
font-size: 12px;
}
}
.share-modal-content {
display: flex;
flex-direction: column;
gap: 20px;
.share-slide-info {
display: flex;
flex-direction: column;
align-items: center;
h4 {
margin-bottom: 10px;
}
.slide-preview {
display: flex;
justify-content: center;
}
}
.share-links {
display: flex;
flex-direction: column;
gap: 10px;
.link-group {
display: flex;
flex-direction: column;
label {
margin-bottom: 5px;
}
.link-input-group {
display: flex;
align-items: center;
gap: 10px;
.link-input {
flex: 1;
padding: 5px;
border: 1px solid $borderColor;
border-radius: $borderRadius;
}
.copy-btn {
padding: 5px 10px;
background-color: $themeColor;
color: #fff;
border: none;
border-radius: $borderRadius;
cursor: pointer;
&:hover {
background-color: darken($themeColor, 10%);
}
}
}
}
}
.share-tips {
p {
margin-bottom: 5px;
}
ul {
margin-left: 20px;
list-style: disc;
li {
margin-bottom: 5px;
}
}
.note {
margin-top: 10px;
color: $themeColor;
}
}
}
</style>