| <template> | |
| <MoveablePanel | |
| class="notes-panel" | |
| :width="300" | |
| :height="560" | |
| :title="`幻灯片${slideIndex + 1}的批注`" | |
| :left="-270" | |
| :top="90" | |
| :minWidth="300" | |
| :minHeight="400" | |
| :maxWidth="480" | |
| :maxHeight="780" | |
| resizeable | |
| @close="close()" | |
| > | |
| <div class="container"> | |
| <div class="notes" ref="notesRef"> | |
| <div class="note" :class="{ 'active': activeNoteId === note.id }" v-for="note in notes" :key="note.id" @click="handleClickNote(note)"> | |
| <div class="header note-header"> | |
| <div class="user"> | |
| <div class="avatar"><IconUser /></div> | |
| <div class="user-info"> | |
| <div class="username">{{ note.user }}</div> | |
| <div class="time">{{ new Date(note.time).toLocaleString() }}</div> | |
| </div> | |
| </div> | |
| <div class="btns"> | |
| <div class="btn reply" @click="replyNoteId = note.id">回复</div> | |
| <div class="btn delete" @click.stop="deleteNote(note.id)">删除</div> | |
| </div> | |
| </div> | |
| <div class="content">{{ note.content }}</div> | |
| <div class="replies" v-if="note.replies?.length"> | |
| <div class="reply-item" v-for="reply in note.replies" :key="reply.id"> | |
| <div class="header reply-header"> | |
| <div class="user"> | |
| <div class="avatar"><IconUser /></div> | |
| <div class="user-info"> | |
| <div class="username">{{ reply.user }}</div> | |
| <div class="time">{{ new Date(reply.time).toLocaleString() }}</div> | |
| </div> | |
| </div> | |
| <div class="btns"> | |
| <div class="btn delete" @click.stop="deleteReply(note.id, reply.id)">删除</div> | |
| </div> | |
| </div> | |
| <div class="content">{{ reply.content }}</div> | |
| </div> | |
| </div> | |
| <div class="note-reply" v-if="replyNoteId === note.id"> | |
| <TextArea :padding="6" v-model:value="replyContent" placeholder="输入回复内容" :rows="1" @enter.prevent="createNoteReply()" /> | |
| <div class="reply-btns"> | |
| <Button class="btn" size="small" @click="replyNoteId = ''">取消</Button> | |
| <Button class="btn" size="small" type="primary" @click="createNoteReply()">回复</Button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="empty" v-if="!notes.length">本页暂无批注</div> | |
| </div> | |
| <div class="send"> | |
| <TextArea | |
| ref="textAreaRef" | |
| v-model:value="content" | |
| :padding="6" | |
| :placeholder="`输入批注(为${handleElementId ? '选中元素' : '当前页幻灯片' })`" | |
| :rows="2" | |
| @focus="replyNoteId = ''; activeNoteId = ''" | |
| @enter.prevent="createNote()" | |
| /> | |
| <div class="footer"> | |
| <IconDelete class="btn icon" v-tooltip="'清空本页批注'" style="flex: 1" @click="clear()" /> | |
| <Button type="primary" class="btn" style="flex: 12" @click="createNote()">添加批注</Button> | |
| </div> | |
| </div> | |
| </div> | |
| </MoveablePanel> | |
| </template> | |
| <script lang="ts" setup> | |
| import { ref, computed, watch, nextTick } from 'vue' | |
| import { storeToRefs } from 'pinia' | |
| import { nanoid } from 'nanoid' | |
| import { useMainStore, useSlidesStore } from '@/store' | |
| import type { Note } from '@/types/slides' | |
| import MoveablePanel from '@/components/MoveablePanel.vue' | |
| import TextArea from '@/components/TextArea.vue' | |
| import Button from '@/components/Button.vue' | |
| const slidesStore = useSlidesStore() | |
| const mainStore = useMainStore() | |
| const { slideIndex, currentSlide } = storeToRefs(slidesStore) | |
| const { handleElementId } = storeToRefs(mainStore) | |
| const content = ref('') | |
| const replyContent = ref('') | |
| const notes = computed(() => currentSlide.value?.notes || []) | |
| const activeNoteId = ref('') | |
| const replyNoteId = ref('') | |
| const textAreaRef = ref<InstanceType<typeof TextArea>>() | |
| const notesRef = ref<HTMLElement>() | |
| watch(slideIndex, () => { | |
| activeNoteId.value = '' | |
| replyNoteId.value = '' | |
| }) | |
| const scrollToBottom = () => { | |
| if (notesRef.value) { | |
| notesRef.value.scrollTop = notesRef.value.scrollHeight | |
| } | |
| } | |
| const createNote = () => { | |
| if (!content.value) { | |
| if (textAreaRef.value) textAreaRef.value.focus() | |
| return | |
| } | |
| const newNote: Note = { | |
| id: nanoid(), | |
| content: content.value, | |
| time: new Date().getTime(), | |
| user: '测试用户', | |
| } | |
| if (handleElementId.value) newNote.elId = handleElementId.value | |
| const newNotes = [ | |
| ...notes.value, | |
| newNote, | |
| ] | |
| slidesStore.updateSlide({ notes: newNotes }) | |
| content.value = '' | |
| nextTick(scrollToBottom) | |
| } | |
| const deleteNote = (id: string) => { | |
| const newNotes = notes.value.filter(note => note.id !== id) | |
| slidesStore.updateSlide({ notes: newNotes }) | |
| } | |
| const createNoteReply = () => { | |
| if (!replyContent.value) return | |
| const currentNote = notes.value.find(note => note.id === replyNoteId.value) | |
| if (!currentNote) return | |
| const newReplies = [ | |
| ...currentNote.replies || [], | |
| { | |
| id: nanoid(), | |
| content: replyContent.value, | |
| time: new Date().getTime(), | |
| user: '测试用户', | |
| }, | |
| ] | |
| const newNote: Note = { | |
| ...currentNote, | |
| replies: newReplies, | |
| } | |
| const newNotes = notes.value.map(note => note.id === replyNoteId.value ? newNote : note) | |
| slidesStore.updateSlide({ notes: newNotes }) | |
| replyContent.value = '' | |
| replyNoteId.value = '' | |
| nextTick(scrollToBottom) | |
| } | |
| const deleteReply = (noteId: string, replyId: string) => { | |
| const currentNote = notes.value.find(note => note.id === noteId) | |
| if (!currentNote || !currentNote.replies) return | |
| const newReplies = currentNote.replies.filter(reply => reply.id !== replyId) | |
| const newNote: Note = { | |
| ...currentNote, | |
| replies: newReplies, | |
| } | |
| const newNotes = notes.value.map(note => note.id === noteId ? newNote : note) | |
| slidesStore.updateSlide({ notes: newNotes }) | |
| } | |
| const handleClickNote = (note: Note) => { | |
| activeNoteId.value = note.id | |
| if (note.elId) { | |
| const elIds = currentSlide.value.elements.map(item => item.id) | |
| if (elIds.includes(note.elId)) { | |
| mainStore.setActiveElementIdList([note.elId]) | |
| } | |
| else mainStore.setActiveElementIdList([]) | |
| } | |
| else mainStore.setActiveElementIdList([]) | |
| } | |
| const clear = () => { | |
| slidesStore.updateSlide({ notes: [] }) | |
| } | |
| const close = () => { | |
| mainStore.setNotesPanelState(false) | |
| } | |
| </script> | |
| <style lang="scss" scoped> | |
| .notes-panel { | |
| height: 100%; | |
| font-size: 12px; | |
| user-select: none; | |
| } | |
| .container { | |
| height: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .notes { | |
| flex: 1; | |
| overflow: auto; | |
| margin: 0 -10px; | |
| padding: 2px 12px; | |
| } | |
| .empty { | |
| width: 100%; | |
| height: 100%; | |
| color: #999; | |
| font-style: italic; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .note { | |
| border: 1px solid #eee; | |
| border-radius: 4px; | |
| padding: 10px; | |
| & + .note { | |
| margin-top: 10px; | |
| } | |
| &.active { | |
| background-color: #f7f7f7; | |
| } | |
| .header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-start; | |
| margin-bottom: 8px; | |
| &:hover { | |
| .btns { | |
| opacity: 1; | |
| } | |
| } | |
| } | |
| .user { | |
| display: flex; | |
| align-items: center; | |
| .avatar { | |
| width: 30px; | |
| height: 30px; | |
| border-radius: 50%; | |
| background-color: #42ba97; | |
| color: #fff; | |
| font-size: 18px; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| margin-right: 10px; | |
| } | |
| .username { | |
| font-size: 14px; | |
| } | |
| .time { | |
| font-size: 12px; | |
| color: #aaa; | |
| } | |
| } | |
| .btns { | |
| display: flex; | |
| align-items: center; | |
| opacity: 0; | |
| .btn { | |
| margin-left: 5px; | |
| cursor: pointer; | |
| &:hover { | |
| text-decoration: underline; | |
| color: $themeColor; | |
| } | |
| } | |
| } | |
| .replies { | |
| margin-left: 20px; | |
| margin-top: 15px; | |
| .reply-item { | |
| margin-top: 10px; | |
| .content { | |
| margin-top: 5px; | |
| } | |
| } | |
| } | |
| } | |
| .note-reply { | |
| margin-top: 15px; | |
| } | |
| .reply-btns { | |
| margin-top: 5px; | |
| text-align: right; | |
| .btn { | |
| margin-left: 8px; | |
| } | |
| } | |
| .send { | |
| height: 120px; | |
| flex-shrink: 0; | |
| text-align: right; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: flex-end; | |
| .footer { | |
| margin-top: 10px; | |
| display: flex; | |
| .btn { | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| &.icon { | |
| font-size: 18px; | |
| color: #666; | |
| cursor: pointer; | |
| } | |
| } | |
| .btn + .btn { | |
| margin-left: 8px; | |
| } | |
| } | |
| } | |
| </style> |