213 lines
7.5 KiB
Vue
213 lines
7.5 KiB
Vue
<!-- =====================================================================
|
||
LibraryModal.vue — 演示文库弹窗
|
||
===================================================================== -->
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||
import type { LibItem, Slide } from '../../core/types'
|
||
import { store, resolveBg } from '../../core/store'
|
||
import { appPrompt, appConfirm } from '../../core/dialog'
|
||
import ElementView from '../editor/ElementView.vue'
|
||
import Icon from '../common/Icon.vue'
|
||
|
||
const props = defineProps<{ visible: boolean }>()
|
||
const emit = defineEmits<{
|
||
(e: 'close'): void
|
||
(e: 'toast', msg: string): void
|
||
(e: 'switch-tab', tab: string): void
|
||
(e: 'open-deck'): void
|
||
}>()
|
||
|
||
const libVersion = ref(0)
|
||
const nameInput = ref('')
|
||
const opsOpenId = ref<string | null>(null)
|
||
|
||
function toggleOps(id: string) {
|
||
opsOpenId.value = opsOpenId.value === id ? null : id
|
||
}
|
||
|
||
function onDocClick(e: MouseEvent) {
|
||
const t = e.target as HTMLElement
|
||
if (!t.closest('.lib-ops')) opsOpenId.value = null
|
||
}
|
||
onMounted(() => document.addEventListener('mousedown', onDocClick))
|
||
onUnmounted(() => document.removeEventListener('mousedown', onDocClick))
|
||
|
||
/** 读文库列表(非响应式,靠 libVersion 触发重算) */
|
||
const library = computed<LibItem[]>(() => {
|
||
void libVersion.value
|
||
return store.getLibrary()
|
||
})
|
||
const activeId = computed(() => {
|
||
void libVersion.value
|
||
return store.getActiveLibId()
|
||
})
|
||
|
||
const libCount = computed(() => library.value.length)
|
||
const placeholder = computed(() => {
|
||
const cur = library.value.find(x => x.id === activeId.value)
|
||
return cur
|
||
? '当前:「' + cur.name + '」,留空同名覆盖,输入新名另存为新份'
|
||
: '为当前演示命名后存入文库…'
|
||
})
|
||
|
||
function bump() { libVersion.value++ }
|
||
|
||
watch(() => props.visible, (v) => {
|
||
if (v) { bump(); nameInput.value = '' }
|
||
})
|
||
|
||
function toast(msg: string) { emit('toast', msg) }
|
||
|
||
function firstSlide(item: LibItem): Slide | null {
|
||
const s = item?.deck?.slides
|
||
return s && s.length ? s[0] : null
|
||
}
|
||
|
||
function formatTime(ts: number): string {
|
||
if (!ts) return ''
|
||
const diff = Date.now() - ts
|
||
if (diff < 60000) return '刚刚'
|
||
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前'
|
||
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前'
|
||
const d = new Date(ts)
|
||
return (d.getMonth() + 1) + '月' + d.getDate() + '日'
|
||
}
|
||
|
||
function onNameKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'Enter') { e.preventDefault(); onSave() }
|
||
}
|
||
|
||
function onSave() {
|
||
const name = nameInput.value.trim()
|
||
if (!name && !store.getActiveLibId()) {
|
||
toast('请输入名称')
|
||
return
|
||
}
|
||
store.saveToLibrary(name || null)
|
||
toast(name ? ('已存入文库:' + name) : '已更新到文库')
|
||
nameInput.value = ''
|
||
bump()
|
||
}
|
||
|
||
function onNewBlank() {
|
||
store.newBlankDeck()
|
||
emit('switch-tab', 'props')
|
||
emit('open-deck')
|
||
toast('已新建空白演示,在右侧添加元素')
|
||
emit('close')
|
||
}
|
||
|
||
function onOpen(id: string) {
|
||
if (store.loadFromLibrary(id)) {
|
||
opsOpenId.value = null
|
||
toast('已打开')
|
||
emit('open-deck')
|
||
emit('close')
|
||
}
|
||
}
|
||
|
||
async function onRename(id: string) {
|
||
const cur = store.getLibrary().find(x => x.id === id)
|
||
const name = await appPrompt('重命名', { defaultValue: cur ? cur.name : '' })
|
||
opsOpenId.value = null
|
||
if (name != null && name.trim()) {
|
||
store.renameInLibrary(id, name.trim())
|
||
bump()
|
||
}
|
||
}
|
||
|
||
function onDuplicate(id: string) {
|
||
opsOpenId.value = null
|
||
store.duplicateInLibrary(id)
|
||
toast('已复制')
|
||
bump()
|
||
}
|
||
|
||
async function onDelete(id: string) {
|
||
opsOpenId.value = null
|
||
if (await appConfirm('删除这份演示?', '此操作不可撤销', { danger: true, okText: '删除' })) {
|
||
store.deleteFromLibrary(id)
|
||
toast('已删除')
|
||
bump()
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="modal-mask lib-modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
|
||
<div class="modal lib-modal">
|
||
<div class="lib-head">
|
||
<h3>演示文库</h3>
|
||
<button class="btn ghost" @click="onNewBlank">+ 新建空白</button>
|
||
</div>
|
||
<p class="modal-tip">保存多套演示文稿到本地,随时切换。</p>
|
||
|
||
<div class="lib-save-card">
|
||
<div class="lib-save-title">
|
||
<span><Icon name="save" :size="14" /> 保存当前演示</span>
|
||
<span v-if="activeId" class="lib-autosave-flag">改动已自动同步到文库</span>
|
||
</div>
|
||
<div class="lib-save-row">
|
||
<input type="text" v-model="nameInput" :placeholder="placeholder" @keydown="onNameKeydown" />
|
||
<button class="btn primary" @click="onSave">存入文库</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lib-hint">共 {{ libCount }} 份</div>
|
||
|
||
<div class="lib-list">
|
||
<div v-for="it in library" :key="it.id" class="lib-item" :class="{ active: it.id === activeId }" :data-id="it.id">
|
||
<div class="lib-thumb" :style="{ background: firstSlide(it) ? resolveBg(firstSlide(it)!.background) : '#fff' }">
|
||
<div v-if="firstSlide(it)" style="position:absolute;top:0;left:0;width:1280px;height:720px;transform:scale(0.05625);transform-origin:top left;pointer-events:none">
|
||
<ElementView v-for="el in firstSlide(it)!.elements" :key="el.id" :el="el" :bg="firstSlide(it)!.background" />
|
||
</div>
|
||
</div>
|
||
<div class="lib-info">
|
||
<div class="lib-name">{{ it.name }}<span v-if="it.id === activeId"> (当前)</span></div>
|
||
<div class="lib-meta">{{ (it.deck && it.deck.slides ? it.deck.slides.length : 0) + ' 页 · ' + formatTime(it.updatedAt || it.createdAt) }}</div>
|
||
</div>
|
||
<div class="lib-ops">
|
||
<button class="btn primary small" @click="onOpen(it.id)">打开</button>
|
||
<button class="btn" @click="toggleOps(it.id)">⋯</button>
|
||
<div v-if="opsOpenId === it.id" class="ops-menu">
|
||
<button @click="onRename(it.id)">重命名</button>
|
||
<button @click="onDuplicate(it.id)">复制</button>
|
||
<button class="danger" @click="onDelete(it.id)">删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-actions">
|
||
<button class="btn" @click="emit('close')">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.lib-head { display: flex; align-items: center; justify-content: space-between; }
|
||
.lib-head h3 { margin: 0; }
|
||
.lib-save-card {
|
||
border: 1px solid color-mix(in srgb, var(--ui-primary, #5b5bd6) 22%, transparent);
|
||
background: color-mix(in srgb, var(--ui-primary, #5b5bd6) 5%, #fff);
|
||
border-radius: 10px;
|
||
padding: 12px 14px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.lib-save-title { font-size: 13px; font-weight: 600; color: var(--ui-text, #1e293b); margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; }
|
||
.lib-autosave-flag { font-size: 11px; font-weight: 400; color: var(--ui-muted, #64748b); }
|
||
.lib-save-row { display: flex; gap: 8px; }
|
||
.lib-save-row input { flex: 1; }
|
||
.lib-ops { position: relative; }
|
||
.ops-menu {
|
||
position: absolute; right: 0; top: calc(100% + 4px);
|
||
background: #fff; border: 1px solid var(--ui-border, #e2e8f0);
|
||
border-radius: 8px; box-shadow: 0 4px 16px rgba(15,23,42,.12);
|
||
min-width: 96px; z-index: 10; overflow: hidden; padding: 4px 0;
|
||
}
|
||
.ops-menu button { display: block; width: 100%; text-align: left; padding: 6px 12px; border: none; background: none; font-size: 12px; cursor: pointer; }
|
||
.ops-menu button:hover { background: var(--ui-hover, #f1f5f9); }
|
||
.ops-menu button.danger { color: var(--ui-danger, #dc2626); }
|
||
</style>
|