Compare commits
14
Commits
a5ebfd8b74
...
5d3046b653
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d3046b653 | ||
|
|
af5211d46d | ||
|
|
8eef4606c5 | ||
|
|
0907a104cf | ||
|
|
cc804f0749 | ||
|
|
db3b2c24b7 | ||
|
|
7f08f654d9 | ||
|
|
01f0f45007 | ||
|
|
cf2de7d64d | ||
|
|
64d9531b02 | ||
|
|
aad7e6f76f | ||
|
|
f58fea1f68 | ||
|
|
e2b31579ea | ||
|
|
352496c00a |
Generated
+2068
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -5,22 +5,31 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --mode web",
|
"dev": "vite",
|
||||||
|
"dev:web": "vite --mode web",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri:dev": "tauri dev",
|
"tauri:dev": "tauri dev",
|
||||||
"tauri:build": "tauri build"
|
"tauri:build": "tauri build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"dompurify": "^3.4.14",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
|
"marked": "^18.0.10",
|
||||||
|
"pdfjs-dist": "^5.6.205",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.11.4",
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"@vue/test-utils": "^2.4.11",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"typescript": "~5.6.3",
|
"typescript": "~5.6.3",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.0.5",
|
||||||
|
"vitest": "^4.1.11",
|
||||||
"vue-tsc": "^2.2.0"
|
"vue-tsc": "^2.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "u-ppt 主窗口默认能力:IPC 命令调用 + AI 流式事件订阅",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:event:default",
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"core:event:allow-unlisten"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ pub fn exec_op(state: State<AppState>, op_json: serde_json::Value) -> Result<OpR
|
|||||||
.map_err(|e| format!("Op 解析失败: {}", e))?;
|
.map_err(|e| format!("Op 解析失败: {}", e))?;
|
||||||
|
|
||||||
let mut deck = state.deck.lock().map_err(|e| e.to_string())?;
|
let mut deck = state.deck.lock().map_err(|e| e.to_string())?;
|
||||||
let backward_op = invert_op(&deck, &op);
|
let _backward_op = invert_op(&deck, &op);
|
||||||
*deck = apply_op(&deck, &op);
|
*deck = apply_op(&deck, &op);
|
||||||
|
|
||||||
let mut hist = state.history.lock().map_err(|e| e.to_string())?;
|
let mut hist = state.history.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -154,7 +154,7 @@ pub fn ping() -> String {
|
|||||||
|
|
||||||
/// 保存 deck 到文件
|
/// 保存 deck 到文件
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_file(content: String) -> Result<String, String> {
|
pub fn save_file(_content: String) -> Result<String, String> {
|
||||||
Ok("ok".to_string())
|
Ok("ok".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,8 +205,11 @@ pub async fn ai_proxy_stream(
|
|||||||
.map_err(|e| format!("请求失败: {}", e))?;
|
.map_err(|e| format!("请求失败: {}", e))?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status().as_u16();
|
||||||
let text = resp.text().await.unwrap_or_default();
|
let text = resp.text().await.unwrap_or_default();
|
||||||
return Ok(text);
|
// 错误体带状态码前缀,前端按「接口返回 N」格式提示
|
||||||
|
let _ = app.emit("ai-error", &text);
|
||||||
|
return Err(format!("__HTTP_{}__{}", status, text));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 流式读取 SSE
|
// 流式读取 SSE
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// 本文件的 struct/fn 多数是 TS 端的 Rust 镜像供序列化参考,非死代码
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
mod ai;
|
mod ai;
|
||||||
mod color;
|
mod color;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
|||||||
+1
-1
@@ -337,7 +337,7 @@ pub fn invert_op(input_deck: &Deck, op: &crate::model::Op) -> crate::model::Op {
|
|||||||
|
|
||||||
"del_element" => {
|
"del_element" => {
|
||||||
let slide_idx = op.payload.get("slideIdx").or_else(|| op.payload.get("slide_idx")).and_then(|v| v.as_f64()).map(|f| f as usize);
|
let slide_idx = op.payload.get("slideIdx").or_else(|| op.payload.get("slide_idx")).and_then(|v| v.as_f64()).map(|f| f as usize);
|
||||||
let element_id = op.payload.get("elementId").or_else(|| op.payload.get("element_id")).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
let _element_id = op.payload.get("elementId").or_else(|| op.payload.get("element_id")).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
if let Some(deleted) = op.payload.get("deletedElement").or_else(|| op.payload.get("deleted_element")) {
|
if let Some(deleted) = op.payload.get("deletedElement").or_else(|| op.payload.get("deleted_element")) {
|
||||||
Op {
|
Op {
|
||||||
op_type: "add_element".into(),
|
op_type: "add_element".into(),
|
||||||
|
|||||||
+232
-11
@@ -2,8 +2,11 @@
|
|||||||
App.vue — 根组件:编辑/演示模式切换、工具栏、三栏布局、弹窗
|
App.vue — 根组件:编辑/演示模式切换、工具栏、三栏布局、弹窗
|
||||||
===================================================================== -->
|
===================================================================== -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import { store } from './core/store'
|
import { store } from './core/store'
|
||||||
|
import { readAsDataURL, fileToImageElement } from './core/importer'
|
||||||
|
import { checkImageQuota } from './core/ai'
|
||||||
|
import AppDialog from './components/common/AppDialog.vue'
|
||||||
import Toolbar from './components/editor/Toolbar.vue'
|
import Toolbar from './components/editor/Toolbar.vue'
|
||||||
import ThumbBar from './components/editor/ThumbBar.vue'
|
import ThumbBar from './components/editor/ThumbBar.vue'
|
||||||
import Canvas from './components/editor/Canvas.vue'
|
import Canvas from './components/editor/Canvas.vue'
|
||||||
@@ -12,10 +15,13 @@ import AiPanel from './components/ai/AiPanel.vue'
|
|||||||
import SettingsModal from './components/modals/SettingsModal.vue'
|
import SettingsModal from './components/modals/SettingsModal.vue'
|
||||||
import LibraryModal from './components/modals/LibraryModal.vue'
|
import LibraryModal from './components/modals/LibraryModal.vue'
|
||||||
import TemplateModal from './components/modals/TemplateModal.vue'
|
import TemplateModal from './components/modals/TemplateModal.vue'
|
||||||
|
import ImportModal from './components/modals/ImportModal.vue'
|
||||||
|
import PrintModal from './components/modals/PrintModal.vue'
|
||||||
import PresentMode from './components/present/PresentMode.vue'
|
import PresentMode from './components/present/PresentMode.vue'
|
||||||
|
import HomePage from './components/HomePage.vue'
|
||||||
|
|
||||||
/* ---------- 模式 ---------- */
|
/* ---------- 模式 ---------- */
|
||||||
const mode = ref<'editor' | 'present'>('editor')
|
const mode = ref<'home' | 'editor' | 'present'>('home')
|
||||||
const presentVisible = ref(false)
|
const presentVisible = ref(false)
|
||||||
const presentStartIndex = ref(0)
|
const presentStartIndex = ref(0)
|
||||||
|
|
||||||
@@ -29,6 +35,14 @@ function switchTab(name: 'props' | 'ai') {
|
|||||||
const settingsVisible = ref(false)
|
const settingsVisible = ref(false)
|
||||||
const libraryVisible = ref(false)
|
const libraryVisible = ref(false)
|
||||||
const templateVisible = ref(false)
|
const templateVisible = ref(false)
|
||||||
|
const importVisible = ref(false)
|
||||||
|
const printVisible = ref(false)
|
||||||
|
const deckLoaded = ref(false) // 文库/导入是否加载了新 deck,防止误切
|
||||||
|
const importModalRef = ref<InstanceType<typeof ImportModal> | null>(null)
|
||||||
|
|
||||||
|
function dropFilesIntoImport(fl: FileList) {
|
||||||
|
importModalRef.value?.acceptDroppedFiles(fl)
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- toast ---------- */
|
/* ---------- toast ---------- */
|
||||||
const toastText = ref('')
|
const toastText = ref('')
|
||||||
@@ -69,7 +83,6 @@ function onSave() {
|
|||||||
libraryVisible.value = true
|
libraryVisible.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function openAi() { switchTab('ai') }
|
|
||||||
|
|
||||||
/* ---------- 导出/导入 ---------- */
|
/* ---------- 导出/导入 ---------- */
|
||||||
function onExportJson() {
|
function onExportJson() {
|
||||||
@@ -96,6 +109,7 @@ function onImportJson() {
|
|||||||
try {
|
try {
|
||||||
store.importJSON(reader.result as string)
|
store.importJSON(reader.result as string)
|
||||||
toast('已导入')
|
toast('已导入')
|
||||||
|
if (mode.value === 'home') mode.value = 'editor'
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast('导入失败:' + (e?.message || String(e)))
|
toast('导入失败:' + (e?.message || String(e)))
|
||||||
}
|
}
|
||||||
@@ -105,13 +119,27 @@ function onImportJson() {
|
|||||||
input.click()
|
input.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onNewBlank() {
|
||||||
|
store.newBlankDeck()
|
||||||
|
deckLoaded.value = true
|
||||||
|
mode.value = 'editor'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImportMaterials() { importVisible.value = true }
|
||||||
|
|
||||||
|
function onImportClose() {
|
||||||
|
importVisible.value = false
|
||||||
|
if (mode.value === 'home') {
|
||||||
|
const slides = store.getSlides()
|
||||||
|
// 不是仅有 1 页空白页时视为已导入内容
|
||||||
|
if (slides.length > 1 || slides[0]?.elements?.length > 0) {
|
||||||
|
mode.value = 'editor'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onExportPdf() {
|
function onExportPdf() {
|
||||||
// 用浏览器打印 + @media print CSS 实现 PDF 导出
|
printVisible.value = true
|
||||||
// 演示模式的 slide-layer 已有 print 样式
|
|
||||||
document.body.classList.add('printing')
|
|
||||||
window.print()
|
|
||||||
setTimeout(() => document.body.classList.remove('printing'), 500)
|
|
||||||
toast('在打印对话框中选择「另存为 PDF」')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 全局快捷键(编辑模式) ---------- */
|
/* ---------- 全局快捷键(编辑模式) ---------- */
|
||||||
@@ -122,6 +150,8 @@ function onKey(e: KeyboardEvent) {
|
|||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (settingsVisible.value) { settingsVisible.value = false; return }
|
if (settingsVisible.value) { settingsVisible.value = false; return }
|
||||||
if (libraryVisible.value) { libraryVisible.value = false; return }
|
if (libraryVisible.value) { libraryVisible.value = false; return }
|
||||||
|
// 多选态:先退多选
|
||||||
|
if (store.getSelection().length > 1) { store.selectElement(null); return }
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
|
||||||
@@ -137,7 +167,40 @@ function onKey(e: KeyboardEvent) {
|
|||||||
e.preventDefault(); if (!inField) toast('已自动保存到本地'); return
|
e.preventDefault(); if (!inField) toast('已自动保存到本地'); return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Delete' && !inField) {
|
/* 元素剪贴:输入框内放行原生行为(复制文本等);多选时作用于整组 */
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C') && !inField) {
|
||||||
|
const sel = store.getSelection()
|
||||||
|
const ids = sel.length > 1 ? sel : (store.getSelectedId() ? [store.getSelectedId()!] : [])
|
||||||
|
if (ids.length && store.copyElements(ids)) { e.preventDefault(); toast(`已复制 ${ids.length} 个元素`) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'x' || e.key === 'X') && !inField) {
|
||||||
|
const sel = store.getSelection()
|
||||||
|
const ids = sel.length > 1 ? sel : (store.getSelectedId() ? [store.getSelectedId()!] : [])
|
||||||
|
if (ids.length && store.cutElements(ids)) { e.preventDefault(); toast(`已剪切 ${ids.length} 个元素`) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V') && !inField) {
|
||||||
|
if (store.pasteElements()) { e.preventDefault(); }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'd' || e.key === 'D') && !inField) {
|
||||||
|
const sel = store.getSelection()
|
||||||
|
const ids = sel.length > 1 ? sel : (store.getSelectedId() ? [store.getSelectedId()!] : [])
|
||||||
|
if (ids.length && store.copyElements(ids) && store.pasteElements()) { e.preventDefault(); toast('已复制副本') }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A') && !inField) {
|
||||||
|
e.preventDefault()
|
||||||
|
store.selectAllElements()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete/Backspace:批注选中时让位给 AnnotationLayer 删批注,否则删元素 */
|
||||||
|
if ((e.key === 'Delete' || e.key === 'Backspace') && !inField) {
|
||||||
|
if (store.selectedAnnoId.value) return
|
||||||
|
const sel = store.getSelection()
|
||||||
|
if (sel.length > 1) { e.preventDefault(); store.delElements(sel); return }
|
||||||
const id = store.getSelectedId()
|
const id = store.getSelectedId()
|
||||||
if (id) { e.preventDefault(); store.delElement(id) }
|
if (id) { e.preventDefault(); store.delElement(id) }
|
||||||
}
|
}
|
||||||
@@ -145,13 +208,147 @@ function onKey(e: KeyboardEvent) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('keydown', onKey)
|
document.addEventListener('keydown', onKey)
|
||||||
|
document.addEventListener('paste', onPaste)
|
||||||
|
window.addEventListener('dragover', onGlobalDragOver)
|
||||||
|
window.addEventListener('drop', onGlobalDrop)
|
||||||
|
window.addEventListener('dragleave', onGlobalDragLeave)
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ---------- 系统剪贴板粘贴:按内容类型分发(截图/图片URL/文本) ---------- */
|
||||||
|
async function onPaste(e: ClipboardEvent) {
|
||||||
|
if (document.body.dataset.mode === 'present') return
|
||||||
|
// 输入框内保持原生粘贴文本
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
if (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable) return
|
||||||
|
const cd = e.clipboardData
|
||||||
|
if (!cd) return
|
||||||
|
|
||||||
|
// 1) 图片(截图/复制的图片文件)→ 图片元素
|
||||||
|
const items = cd.items
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
e.preventDefault()
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
const dataUrl = await readAsDataURL(file)
|
||||||
|
const quotaErr = checkImageQuota(dataUrl)
|
||||||
|
if (quotaErr) { toast(quotaErr); return }
|
||||||
|
const el = fileToImageElement(file, dataUrl)
|
||||||
|
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style })
|
||||||
|
toast('已插入剪贴板图片')
|
||||||
|
} catch (err: any) {
|
||||||
|
toast('读取剪贴板图片失败:' + (err?.message || String(err)))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 文本:图片 URL → 图片元素;其他文本 → 文本元素(内部元素剪贴板优先级更高,keydown 已处理)
|
||||||
|
if (store.hasClipboard()) return
|
||||||
|
const text = cd.getData('text/plain')
|
||||||
|
if (!text || !text.trim()) return
|
||||||
|
e.preventDefault()
|
||||||
|
const trimmed = text.trim()
|
||||||
|
// 图片直链(常见图床/截图床格式)
|
||||||
|
if (/^https?:\/\/\S+\.(png|jpe?g|gif|webp|svg|bmp|ico)(\?\S*)?$/i.test(trimmed)) {
|
||||||
|
store.addElement('image', { content: trimmed, x: 30, y: 20, w: 40, h: 45, style: { fit: 'contain', anim: 'fade' } })
|
||||||
|
toast('已插入图片链接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 普通文本 → 文本元素(多行按 list 更合适则用 list)
|
||||||
|
const lines = trimmed.split(/\n+/).filter(Boolean)
|
||||||
|
if (lines.length >= 3) {
|
||||||
|
store.addElement('list', { content: lines.join('\n'), x: 10, y: 25, w: 55, h: 45, style: { fontSize: 24, align: 'left', anim: 'fade-up' } })
|
||||||
|
toast(`已插入列表(${lines.length} 行)`)
|
||||||
|
} else {
|
||||||
|
const isTitle = trimmed.length <= 30 && lines.length === 1
|
||||||
|
store.addElement(isTitle ? 'title' : 'text', {
|
||||||
|
content: trimmed,
|
||||||
|
x: 10, y: isTitle ? 8 : 25, w: 70, h: isTitle ? 12 : 15,
|
||||||
|
style: isTitle ? { fontSize: 44, align: 'left', anim: 'fade-up' } : { fontSize: 24, align: 'left', anim: 'fade-up' }
|
||||||
|
})
|
||||||
|
toast(isTitle ? '已插入标题' : '已插入文本')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 全局拖放:JSON 直接导入,其他文件进资料导入弹窗 ---------- */
|
||||||
|
const globalDragOver = ref(false)
|
||||||
|
|
||||||
|
function onGlobalDragOver(e: DragEvent) {
|
||||||
|
if (!e.dataTransfer?.types.includes('Files')) return
|
||||||
|
e.preventDefault() // 阻止浏览器默认打开文件
|
||||||
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
|
if (!globalDragOver.value) globalDragOver.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalDragLeave(e: DragEvent) {
|
||||||
|
if (e.relatedTarget) return // 目标还在窗口内
|
||||||
|
globalDragOver.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onGlobalDrop(e: DragEvent) {
|
||||||
|
const fl = e.dataTransfer?.files
|
||||||
|
if (!fl || fl.length === 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
globalDragOver.value = false
|
||||||
|
|
||||||
|
// 单个 .json 文件 → 直接导入 deck(与工具栏 📥 导入同路径)
|
||||||
|
if (fl.length === 1 && /\.json$/i.test(fl[0].name)) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
store.importJSON(reader.result as string)
|
||||||
|
toast('已导入')
|
||||||
|
if (mode.value === 'home') mode.value = 'editor'
|
||||||
|
} catch (err: any) {
|
||||||
|
toast('导入失败:' + (err?.message || String(err)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsText(fl[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他文件 → 打开资料导入弹窗并预填
|
||||||
|
importVisible.value = true
|
||||||
|
await nextTick()
|
||||||
|
dropFilesIntoImport(fl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 持久化失败告警 → toast(防止静默丢数据) */
|
||||||
|
watch(() => store.saveWarning.text, (txt) => {
|
||||||
|
if (txt) toast(txt)
|
||||||
|
})
|
||||||
|
|
||||||
|
/* 文库关闭时若在首页且从文库加载了 deck 则切入编辑 */
|
||||||
|
watch(libraryVisible, (now, prev) => {
|
||||||
|
if (prev && !now && deckLoaded.value) {
|
||||||
|
deckLoaded.value = false
|
||||||
|
if (mode.value === 'home') mode.value = 'editor'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('keydown', onKey)
|
document.removeEventListener('keydown', onKey)
|
||||||
|
document.removeEventListener('paste', onPaste)
|
||||||
|
window.removeEventListener('dragover', onGlobalDragOver)
|
||||||
|
window.removeEventListener('drop', onGlobalDrop)
|
||||||
|
window.removeEventListener('dragleave', onGlobalDragLeave)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- ===================== 首页 ===================== -->
|
||||||
|
<HomePage
|
||||||
|
v-show="mode === 'home'"
|
||||||
|
@new-blank="onNewBlank"
|
||||||
|
@open-library="libraryVisible = true"
|
||||||
|
@import-materials="importVisible = true"
|
||||||
|
@open-deck="deckLoaded = true; mode = 'editor'"
|
||||||
|
@ai-create="onNewBlank(); switchTab('ai')"
|
||||||
|
@toast="toast"
|
||||||
|
@open-settings="settingsVisible = true"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- ===================== 编辑模式 ===================== -->
|
<!-- ===================== 编辑模式 ===================== -->
|
||||||
<div v-show="mode === 'editor'" class="app-editor">
|
<div v-show="mode === 'editor'" class="app-editor">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
@@ -159,10 +356,10 @@ onUnmounted(() => {
|
|||||||
@present="onPresent"
|
@present="onPresent"
|
||||||
@open-library="libraryVisible = true"
|
@open-library="libraryVisible = true"
|
||||||
@open-settings="settingsVisible = true"
|
@open-settings="settingsVisible = true"
|
||||||
@open-ai="openAi"
|
|
||||||
@save="onSave"
|
@save="onSave"
|
||||||
@open-templates="templateVisible = true"
|
@open-templates="templateVisible = true"
|
||||||
@export-json="onExportJson"
|
@export-json="onExportJson"
|
||||||
|
@import-materials="onImportMaterials"
|
||||||
@import-json="onImportJson"
|
@import-json="onImportJson"
|
||||||
@export-pdf="onExportPdf"
|
@export-pdf="onExportPdf"
|
||||||
/>
|
/>
|
||||||
@@ -212,13 +409,37 @@ onUnmounted(() => {
|
|||||||
@close="libraryVisible = false"
|
@close="libraryVisible = false"
|
||||||
@toast="toast"
|
@toast="toast"
|
||||||
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai')"
|
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai')"
|
||||||
|
@open-deck="mode = 'editor'"
|
||||||
/>
|
/>
|
||||||
<TemplateModal
|
<TemplateModal
|
||||||
:visible="templateVisible"
|
:visible="templateVisible"
|
||||||
@close="templateVisible = false"
|
@close="templateVisible = false"
|
||||||
@toast="toast"
|
@toast="toast"
|
||||||
/>
|
/>
|
||||||
|
<ImportModal
|
||||||
|
ref="importModalRef"
|
||||||
|
:visible="importVisible"
|
||||||
|
@close="onImportClose"
|
||||||
|
@toast="toast"
|
||||||
|
@open-settings="settingsVisible = true"
|
||||||
|
/>
|
||||||
|
<PrintModal
|
||||||
|
v-if="printVisible"
|
||||||
|
@close="printVisible = false"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 轻提示 -->
|
<!-- 轻提示 -->
|
||||||
<div class="toast" :class="{ show: toastShow }">{{ toastText }}</div>
|
<div class="toast" :class="{ show: toastShow }">{{ toastText }}</div>
|
||||||
|
|
||||||
|
<!-- 全局拖放提示遮罩 -->
|
||||||
|
<div v-if="globalDragOver" class="global-drop-overlay">
|
||||||
|
<div class="global-drop-card">
|
||||||
|
<span class="global-drop-icon">📥</span>
|
||||||
|
<strong>松开导入文件</strong>
|
||||||
|
<span class="global-drop-hint">.json 直接导入 · 图片/文档进入资料导入</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 全局统一对话框(appAlert/appConfirm/appPrompt) -->
|
||||||
|
<AppDialog />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
<!-- =====================================================================
|
||||||
|
HomePage.vue — 首页:品牌展示、快捷入口、最近文库
|
||||||
|
===================================================================== -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
|
import { store } from '../core/store'
|
||||||
|
import type { LibItem } from '../core/types'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'new-blank'): void
|
||||||
|
(e: 'open-library'): void
|
||||||
|
(e: 'import-materials'): void
|
||||||
|
(e: 'open-settings'): void
|
||||||
|
(e: 'toast', msg: string): void
|
||||||
|
(e: 'open-deck'): void
|
||||||
|
(e: 'ai-create'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const libVersion = ref(0)
|
||||||
|
const library = computed<LibItem[]>(() => {
|
||||||
|
void libVersion.value
|
||||||
|
return store.getLibrary().slice(0, 6) // 最近 6 个
|
||||||
|
})
|
||||||
|
|
||||||
|
function bump() { libVersion.value++ }
|
||||||
|
onMounted(bump)
|
||||||
|
|
||||||
|
function loadItem(id: string) {
|
||||||
|
if (store.loadFromLibrary(id)) {
|
||||||
|
emit('toast', '已加载:' + (library.value.find(x => x.id === id)?.name || ''))
|
||||||
|
emit('open-deck')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 firstSlideTitle(item: LibItem): string {
|
||||||
|
const el = item?.deck?.slides?.[0]?.elements?.[0]
|
||||||
|
return el?.type === 'title' ? (el.content || '无标题') : '无标题'
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 上次未入库的工作区草稿(改了没保存就关/刷新),提示继续编辑 */
|
||||||
|
const workDraft = computed(() => {
|
||||||
|
if (!store.getActiveLibId() && store.hasWorkDeck()) {
|
||||||
|
const d = store.getDeck()
|
||||||
|
const t = d.slides[0]?.elements.find(e => e.type === 'title')
|
||||||
|
return { title: t?.content || '未命名草稿', pages: d.slides.length }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="home">
|
||||||
|
<!-- 背景装饰 -->
|
||||||
|
<div class="home-bg">
|
||||||
|
<div class="home-glow top-right"></div>
|
||||||
|
<div class="home-glow bottom-left"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-inner">
|
||||||
|
<!-- 品牌区 -->
|
||||||
|
<header class="home-brand">
|
||||||
|
<span class="home-logo">▦</span>
|
||||||
|
<h1 class="home-title">u-ppt</h1>
|
||||||
|
<p class="home-desc">轻量在线演示工具 · 支持 AI 创作与本地资料导入</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 未保存草稿恢复 -->
|
||||||
|
<button v-if="workDraft" class="draft-resume" @click="emit('open-deck')">
|
||||||
|
<span class="draft-icon">⏵</span>
|
||||||
|
<span class="draft-info">
|
||||||
|
<strong>继续编辑「{{ workDraft.title }}」</strong>
|
||||||
|
<span>{{ workDraft.pages }} 页 · 上次未保存的草稿已自动暂存</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 快捷入口 -->
|
||||||
|
<div class="home-actions">
|
||||||
|
<button class="action-card" @click="emit('new-blank')">
|
||||||
|
<span class="action-icon">+</span>
|
||||||
|
<span class="action-label">新建空白</span>
|
||||||
|
<span class="action-hint">从空白页开始创作</span>
|
||||||
|
</button>
|
||||||
|
<button class="action-card" @click="emit('open-library')">
|
||||||
|
<span class="action-icon">📁</span>
|
||||||
|
<span class="action-label">从文库打开</span>
|
||||||
|
<span class="action-hint">继续已有的演示</span>
|
||||||
|
</button>
|
||||||
|
<button class="action-card" @click="emit('import-materials')">
|
||||||
|
<span class="action-icon">📂</span>
|
||||||
|
<span class="action-label">导入资料</span>
|
||||||
|
<span class="action-hint">图片/PDF/DOCX → AI 生成</span>
|
||||||
|
</button>
|
||||||
|
<button class="action-card accent" @click="emit('ai-create')">
|
||||||
|
<span class="action-icon">🤖</span>
|
||||||
|
<span class="action-label">AI 创作</span>
|
||||||
|
<span class="action-hint">输入主题,AI 生成全套演示</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 最近文库 -->
|
||||||
|
<div v-if="library.length > 0" class="home-recent">
|
||||||
|
<h2 class="section-title">最近文档</h2>
|
||||||
|
<div class="recent-list">
|
||||||
|
<button
|
||||||
|
v-for="item in library"
|
||||||
|
:key="item.id"
|
||||||
|
class="recent-item"
|
||||||
|
@click="loadItem(item.id)"
|
||||||
|
:title="item.name"
|
||||||
|
>
|
||||||
|
<span class="recent-icon">📄</span>
|
||||||
|
<span class="recent-info">
|
||||||
|
<span class="recent-name">{{ item.name }}</span>
|
||||||
|
<span class="recent-meta">
|
||||||
|
{{ item.deck.slides.length }} 页 · {{ formatTime(item.updatedAt || item.createdAt) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="recent-preview">{{ firstSlideTitle(item) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部 -->
|
||||||
|
<footer class="home-footer">
|
||||||
|
<button class="btn ghost" @click="emit('open-settings')">⚙ 设置</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--ui-bg, #f1f5f9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 背景光晕 */
|
||||||
|
.home-bg { position: absolute; inset: 0; pointer-events: none; }
|
||||||
|
.home-glow {
|
||||||
|
position: absolute;
|
||||||
|
width: 480px; height: 480px;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(120px);
|
||||||
|
opacity: .08;
|
||||||
|
}
|
||||||
|
.home-glow.top-right { top: -120px; right: -80px; background: var(--ui-primary, #4f46e5); }
|
||||||
|
.home-glow.bottom-left { bottom: -160px; left: -80px; background: var(--ui-primary, #4f46e5); }
|
||||||
|
|
||||||
|
.home-inner {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 40px;
|
||||||
|
padding: 40px 24px;
|
||||||
|
max-width: 680px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 品牌 */
|
||||||
|
.home-brand { text-align: center; }
|
||||||
|
.home-logo {
|
||||||
|
font-size: 56px; line-height: 1;
|
||||||
|
color: var(--ui-primary, #4f46e5);
|
||||||
|
display: block; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.home-title {
|
||||||
|
font-size: 32px; font-weight: 700;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
color: var(--ui-text, #1e293b);
|
||||||
|
}
|
||||||
|
.home-desc {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--ui-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 未保存草稿恢复卡片 */
|
||||||
|
.draft-resume {
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
width: 100%; padding: 14px 18px;
|
||||||
|
border: 1px solid var(--ui-primary, #4f46e5);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--ui-primary, #4f46e5) 6%, var(--ui-panel, #fff));
|
||||||
|
cursor: pointer; text-align: left;
|
||||||
|
transition: box-shadow .15s, transform .15s;
|
||||||
|
}
|
||||||
|
.draft-resume:hover { box-shadow: var(--shadow-md, 0 4px 12px rgba(0,0,0,.08)); transform: translateY(-1px); }
|
||||||
|
.draft-icon {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
background: var(--ui-primary, #4f46e5); color: #fff;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 16px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.draft-info { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.draft-info strong { font-size: 14px; color: var(--ui-text, #1e293b); }
|
||||||
|
.draft-info span { font-size: 12px; color: var(--ui-muted, #64748b); }
|
||||||
|
|
||||||
|
/* 快捷入口网格 */
|
||||||
|
.home-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.action-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid var(--ui-border, #e2e8f0);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--ui-panel, #fff);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.action-card:hover {
|
||||||
|
border-color: var(--ui-primary, #4f46e5);
|
||||||
|
box-shadow: var(--shadow-md, 0 4px 12px rgba(15,23,42,.08));
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.action-card:active { transform: translateY(0); }
|
||||||
|
.action-icon { font-size: 28px; line-height: 1; margin-bottom: 4px; }
|
||||||
|
.action-label { font-size: 16px; font-weight: 600; color: var(--ui-text, #1e293b); }
|
||||||
|
.action-hint { font-size: 13px; color: var(--ui-muted, #64748b); }
|
||||||
|
|
||||||
|
/* 最近文档 */
|
||||||
|
.home-recent { width: 100%; }
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px; font-weight: 600;
|
||||||
|
color: var(--ui-muted, #64748b);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
}
|
||||||
|
.recent-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--ui-border, #e2e8f0);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--ui-panel, #fff);
|
||||||
|
}
|
||||||
|
.recent-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--ui-border, #f1f5f9);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .1s;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.recent-item:last-child { border-bottom: none; }
|
||||||
|
.recent-item:hover { background: var(--ui-hover, #f1f5f9); }
|
||||||
|
.recent-icon { font-size: 20px; flex-shrink: 0; }
|
||||||
|
.recent-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.recent-name {
|
||||||
|
font-size: 14px; font-weight: 500;
|
||||||
|
color: var(--ui-text, #1e293b);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.recent-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ui-muted, #64748b);
|
||||||
|
}
|
||||||
|
.recent-preview {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ui-muted, #64748b);
|
||||||
|
max-width: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部 */
|
||||||
|
.home-footer { display: flex; gap: 8px; }
|
||||||
|
</style>
|
||||||
+108
-91
@@ -3,74 +3,15 @@
|
|||||||
发送/停止/生成整套/润色本页/流式渲染/操作应用
|
发送/停止/生成整套/润色本页/流式渲染/操作应用
|
||||||
===================================================================== -->
|
===================================================================== -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, nextTick, computed, watch } from 'vue'
|
import { ref, nextTick, computed, watch, onUnmounted } from 'vue'
|
||||||
import type { ChatMessage, AiOp, Slide } from '../../core/types'
|
import type { ChatMessage, AiOp, Slide } from '../../core/types'
|
||||||
import { store } from '../../core/store'
|
import { store } from '../../core/store'
|
||||||
import { generate, polish, chat, beautifyPage, isConfigured } from '../../core/ai'
|
import { generate, polish, chat, beautifyPage, isConfigured } from '../../core/ai'
|
||||||
import { elementTypes } from '../../core/sample'
|
import { elementTypes } from '../../core/sample'
|
||||||
|
import { renderMd } from '../../core/markdown'
|
||||||
|
import { appPrompt, appConfirm } from '../../core/dialog'
|
||||||
import OutlinePanel from './OutlinePanel.vue'
|
import OutlinePanel from './OutlinePanel.vue'
|
||||||
|
|
||||||
/* ---------- 消息内容渲染:提取 JSON 代码块并格式化 ---------- */
|
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 尝试把 JSON 字符串格式化(缩进),失败则返回原文 */
|
|
||||||
function tryFormatJson(raw: string): string {
|
|
||||||
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch (e) { return raw }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 对格式化后的 JSON 做轻量语法着色(不引依赖,纯正则) */
|
|
||||||
function highlightJson(code: string): string {
|
|
||||||
return escapeHtml(code)
|
|
||||||
.replace(/("(?:\\.|[^"\\])*"\s*:)/g, '<span class="jk">$1</span>')
|
|
||||||
.replace(/:\s*("(?:\\.|[^"\\])*")/g, ': <span class="js">$1</span>')
|
|
||||||
.replace(/:\s*(-?\d+\.?\d*)/g, ': <span class="jn">$1</span>')
|
|
||||||
.replace(/:\s*(true|false|null)/g, ': <span class="jb">$1</span>')
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RenderedPart {
|
|
||||||
type: 'text' | 'code'
|
|
||||||
html: string
|
|
||||||
lang?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把消息内容拆分为文本段 + JSON 代码块段 */
|
|
||||||
function renderMessageContent(content: string): RenderedPart[] {
|
|
||||||
if (!content) return []
|
|
||||||
const parts: RenderedPart[] = []
|
|
||||||
// 匹配 ```json ... ``` 或 ``` ... ``` 代码块
|
|
||||||
const codeBlockRe = /```(\w*)\n?([\s\S]*?)```/g
|
|
||||||
let lastIdx = 0
|
|
||||||
let m: RegExpExecArray | null
|
|
||||||
while ((m = codeBlockRe.exec(content)) !== null) {
|
|
||||||
// 代码块前的文本
|
|
||||||
if (m.index > lastIdx) {
|
|
||||||
const text = content.slice(lastIdx, m.index)
|
|
||||||
if (text.trim()) parts.push({ type: 'text', html: escapeHtml(text) })
|
|
||||||
}
|
|
||||||
const lang = m[1] || ''
|
|
||||||
const code = m[2]
|
|
||||||
const trimmed = code.trim()
|
|
||||||
// JSON 代码块 → 格式化 + 着色
|
|
||||||
if (!lang || lang === 'json') {
|
|
||||||
parts.push({ type: 'code', lang: 'json', html: highlightJson(tryFormatJson(trimmed)) })
|
|
||||||
} else {
|
|
||||||
parts.push({ type: 'code', lang, html: escapeHtml(trimmed) })
|
|
||||||
}
|
|
||||||
lastIdx = m.index + m[0].length
|
|
||||||
}
|
|
||||||
// 尾部文本
|
|
||||||
if (lastIdx < content.length) {
|
|
||||||
const text = content.slice(lastIdx)
|
|
||||||
if (text.trim()) parts.push({ type: 'text', html: escapeHtml(text) })
|
|
||||||
}
|
|
||||||
// 没有代码块 → 返回整个为文本段
|
|
||||||
if (!parts.length) parts.push({ type: 'text', html: escapeHtml(content) })
|
|
||||||
return parts
|
|
||||||
}
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'busy-change', busy: boolean): void
|
(e: 'busy-change', busy: boolean): void
|
||||||
(e: 'toast', msg: string): void
|
(e: 'toast', msg: string): void
|
||||||
@@ -131,6 +72,8 @@ function persistChat() {
|
|||||||
function setBusy(b: boolean) {
|
function setBusy(b: boolean) {
|
||||||
busy.value = b
|
busy.value = b
|
||||||
emit('busy-change', b)
|
emit('busy-change', b)
|
||||||
|
// 停止/完成后焦点回输入框,继续对话无需重新点击
|
||||||
|
if (!b) nextTick(() => inputEl.value?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollBottom() {
|
function scrollBottom() {
|
||||||
@@ -159,10 +102,20 @@ function addPersisted(role: ChatMessage['role'], text: string) {
|
|||||||
scrollBottom()
|
scrollBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在跑的流式气泡集(组件卸载时统一停 rAF) */
|
||||||
|
const activeStreams = new Set<StreamCtrl>()
|
||||||
|
|
||||||
/** 创建一条流式气泡(返回控制器对象) */
|
/** 创建一条流式气泡(返回控制器对象) */
|
||||||
interface StreamCtrl {
|
interface StreamCtrl {
|
||||||
msg: RenderMsg
|
msg: RenderMsg
|
||||||
started: boolean
|
started: boolean
|
||||||
|
/** 打字机缓冲:网络 delta 先入队,rAF 匀速释放渲染(参考 devflow 流式体验) */
|
||||||
|
buffer: string
|
||||||
|
shown: string
|
||||||
|
rafId: number | null
|
||||||
|
flushPending: boolean
|
||||||
|
/** 冲刷完成回调(持久化等收尾动作) */
|
||||||
|
settled?: (() => void) | null
|
||||||
}
|
}
|
||||||
function streamBubble(placeholder?: string): StreamCtrl {
|
function streamBubble(placeholder?: string): StreamCtrl {
|
||||||
const msg: RenderMsg = {
|
const msg: RenderMsg = {
|
||||||
@@ -173,27 +126,74 @@ function streamBubble(placeholder?: string): StreamCtrl {
|
|||||||
}
|
}
|
||||||
renderMsgs.value.push(msg)
|
renderMsgs.value.push(msg)
|
||||||
scrollBottom()
|
scrollBottom()
|
||||||
return { msg, started: !placeholder }
|
const ctrl: StreamCtrl = { msg, started: !placeholder, buffer: '', shown: '', rafId: null, flushPending: false }
|
||||||
|
activeStreams.add(ctrl)
|
||||||
|
return ctrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** rAF 匀速释放缓冲:每帧最多放 CHARS_PER_FRAME 字符,网络抖动时也不突兀蹦字 */
|
||||||
|
const CHARS_PER_FRAME = 4
|
||||||
|
|
||||||
|
function pumpTypewriter(s: StreamCtrl) {
|
||||||
|
if (s.rafId != null) return
|
||||||
|
s.rafId = requestAnimationFrame(() => {
|
||||||
|
s.rafId = null
|
||||||
|
if (!s.buffer.length) {
|
||||||
|
// 缓冲排空:流已结束则收尾,否则待下一批 delta
|
||||||
|
if (s.flushPending) {
|
||||||
|
s.msg.streaming = false
|
||||||
|
s.flushPending = false
|
||||||
|
s.settled?.()
|
||||||
|
s.settled = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 每帧固定放 4 字符 ≈ 240 字符/秒,网络抖动时也保持匀速观感
|
||||||
|
const take = Math.min(s.buffer.length, CHARS_PER_FRAME)
|
||||||
|
s.shown += s.buffer.slice(0, take)
|
||||||
|
s.buffer = s.buffer.slice(take)
|
||||||
|
s.msg.content = s.shown
|
||||||
|
scrollBottom()
|
||||||
|
if (s.buffer.length || s.flushPending) pumpTypewriter(s)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function streamOnVisible(s: StreamCtrl) {
|
function streamOnVisible(s: StreamCtrl) {
|
||||||
return (t: string) => {
|
return (t: string) => {
|
||||||
if (!s.started) { s.msg.content = ''; s.started = true }
|
if (!s.started) { s.msg.content = ''; s.shown = ''; s.started = true }
|
||||||
s.msg.content += t
|
s.buffer += t
|
||||||
scrollBottom()
|
pumpTypewriter(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function streamDone(s: StreamCtrl) {
|
/** 流结束:标记待冲刷(缓冲剩余字符继续匀速放完再收尾),可选收尾回调 */
|
||||||
s.msg.streaming = false
|
function streamFlush(s: StreamCtrl, onSettled?: () => void) {
|
||||||
if (!s.msg.content) s.msg.content = ''
|
s.settled = onSettled || null
|
||||||
|
s.flushPending = true
|
||||||
|
if (!s.buffer.length) {
|
||||||
|
// 缓冲已空:立即收尾
|
||||||
|
s.msg.streaming = false
|
||||||
|
s.flushPending = false
|
||||||
|
s.settled?.()
|
||||||
|
s.settled = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pumpTypewriter(s)
|
||||||
|
}
|
||||||
|
function streamDone(s: StreamCtrl, onSettled?: () => void) {
|
||||||
|
streamFlush(s, onSettled)
|
||||||
}
|
}
|
||||||
function streamSetText(s: StreamCtrl, txt: string) {
|
function streamSetText(s: StreamCtrl, txt: string) {
|
||||||
|
// 外部直接设置最终文本(跳过打字机,用于错误/提示)
|
||||||
|
if (s.rafId != null) { cancelAnimationFrame(s.rafId); s.rafId = null }
|
||||||
|
s.buffer = ''
|
||||||
|
s.shown = txt
|
||||||
s.msg.content = txt
|
s.msg.content = txt
|
||||||
s.msg.streaming = false
|
s.msg.streaming = false
|
||||||
|
s.flushPending = false
|
||||||
}
|
}
|
||||||
function streamError(s: StreamCtrl, msg: string) {
|
function streamError(s: StreamCtrl, msg: string) {
|
||||||
|
streamSetText(s, '⚠ ' + msg)
|
||||||
s.msg.error = true
|
s.msg.error = true
|
||||||
s.msg.content = '⚠ ' + msg
|
|
||||||
s.msg.streaming = false
|
|
||||||
}
|
}
|
||||||
function streamTag(s: StreamCtrl, txt: string) {
|
function streamTag(s: StreamCtrl, txt: string) {
|
||||||
if (txt) s.msg.tag = txt
|
if (txt) s.msg.tag = txt
|
||||||
@@ -215,6 +215,11 @@ function applyOp(op: AiOp, lockedIdx: number): string {
|
|||||||
let t = op.target != null ? op.target : lockedIdx
|
let t = op.target != null ? op.target : lockedIdx
|
||||||
t = Math.max(0, Math.min(t, store.getCount() - 1))
|
t = Math.max(0, Math.min(t, store.getCount() - 1))
|
||||||
store.replaceSlide(t, slides[0])
|
store.replaceSlide(t, slides[0])
|
||||||
|
// 目标页与发送时页不同:跳转过去让用户直接看到改动
|
||||||
|
if (t !== lockedIdx) {
|
||||||
|
store.setCurrentIndex(t)
|
||||||
|
return '已更新第 ' + (t + 1) + ' 页并跳转'
|
||||||
|
}
|
||||||
return '已更新第 ' + (t + 1) + ' 页'
|
return '已更新第 ' + (t + 1) + ' 页'
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
@@ -230,10 +235,7 @@ function persistStream(s: StreamCtrl) {
|
|||||||
function onSend() {
|
function onSend() {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
const text = inputText.value.trim()
|
const text = inputText.value.trim()
|
||||||
if (!text) {
|
if (!text) return
|
||||||
if (!isConfigured()) { emit('open-settings'); return }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
runChat(text)
|
runChat(text)
|
||||||
@@ -241,7 +243,11 @@ function onSend() {
|
|||||||
|
|
||||||
async function runChat(input: string) {
|
async function runChat(input: string) {
|
||||||
addPersisted('user', input)
|
addPersisted('user', input)
|
||||||
const history = chatLog.value.slice(-10).map(m => ({ role: m.role, content: m.content }))
|
// history 发送前剥离系统追加的 [✓ ...] 标记行,避免污染 AI 上下文
|
||||||
|
const history = chatLog.value.slice(-10).map(m => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content.replace(/\n?\[✓[^\]]*\]$/g, '')
|
||||||
|
}))
|
||||||
const idx0 = store.getCurrentIndex()
|
const idx0 = store.getCurrentIndex()
|
||||||
const stream = streamBubble()
|
const stream = streamBubble()
|
||||||
|
|
||||||
@@ -256,18 +262,24 @@ async function runChat(input: string) {
|
|||||||
signal: abortCtrl.signal,
|
signal: abortCtrl.signal,
|
||||||
selectedElement: selectedEl.value
|
selectedElement: selectedEl.value
|
||||||
})
|
})
|
||||||
streamDone(stream)
|
// op 应用与 tag 立即执行;持久化等打字机冲刷完成(内容已齐)再落
|
||||||
if (!r.reply) {
|
|
||||||
streamSetText(stream, '(已完成)')
|
|
||||||
}
|
|
||||||
if (r.op && r.op.action !== 'answer' && r.op.slides.length) {
|
if (r.op && r.op.action !== 'answer' && r.op.slides.length) {
|
||||||
const applied = applyOp(r.op, idx0)
|
const applied = applyOp(r.op, idx0)
|
||||||
streamTag(stream, applied)
|
streamTag(stream, applied)
|
||||||
}
|
}
|
||||||
persistStream(stream)
|
streamDone(stream, () => {
|
||||||
|
if (!stream.msg.content) streamSetText(stream, '(已完成)')
|
||||||
|
persistStream(stream)
|
||||||
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
|
if (e?.name === 'AbortError') {
|
||||||
else streamError(stream, e?.message || String(e))
|
// 停止:放弃缓冲中未显示的部分之外,把已显示内容立即固化
|
||||||
|
if (stream.rafId != null) { cancelAnimationFrame(stream.rafId); stream.rafId = null }
|
||||||
|
stream.msg.streaming = false
|
||||||
|
stream.msg.content = stream.shown ? stream.shown + '\n(已停止)' : '(已停止)'
|
||||||
|
} else {
|
||||||
|
streamError(stream, e?.message || String(e))
|
||||||
|
}
|
||||||
persistStream(stream)
|
persistStream(stream)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false); abortCtrl = null
|
setBusy(false); abortCtrl = null
|
||||||
@@ -281,7 +293,7 @@ function onStop() {
|
|||||||
/* ---------- 生成整套 ---------- */
|
/* ---------- 生成整套 ---------- */
|
||||||
async function onGenerate() {
|
async function onGenerate() {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
const topic = inputText.value.trim() || prompt('请输入演示主题,例如「远程办公的兴起与未来」')
|
const topic = inputText.value.trim() || await appPrompt('生成整套', { message: '请输入演示主题', placeholder: '例如「远程办公的兴起与未来」' })
|
||||||
if (!topic) return
|
if (!topic) return
|
||||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
@@ -334,9 +346,9 @@ async function onPolish() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 清空 ---------- */
|
/* ---------- 清空 ---------- */
|
||||||
function onClear() {
|
async function onClear() {
|
||||||
if (!chatLog.value.length) { toast('对话已是空的'); return }
|
if (!chatLog.value.length) { toast('对话已是空的'); return }
|
||||||
if (!confirm('清空当前 PPT 的对话记录?')) return
|
if (!(await appConfirm('清空对话记录?', '将清空当前 PPT 的对话记录', { danger: true, okText: '清空' }))) return
|
||||||
chatLog.value = []
|
chatLog.value = []
|
||||||
store.clearChat(currentChatId.value)
|
store.clearChat(currentChatId.value)
|
||||||
renderMsgs.value = []
|
renderMsgs.value = []
|
||||||
@@ -392,6 +404,14 @@ defineExpose({ isBusy, focus })
|
|||||||
/* ---------- 初始化:从持久化记录重建 ---------- */
|
/* ---------- 初始化:从持久化记录重建 ---------- */
|
||||||
rebuildFromChatLog()
|
rebuildFromChatLog()
|
||||||
|
|
||||||
|
/* 组件卸载:停掉所有在跑的打字机 rAF */
|
||||||
|
onUnmounted(() => {
|
||||||
|
for (const s of activeStreams) {
|
||||||
|
if (s.rafId != null) cancelAnimationFrame(s.rafId)
|
||||||
|
}
|
||||||
|
activeStreams.clear()
|
||||||
|
})
|
||||||
|
|
||||||
/* ---------- 会话切换:chatId 变化时重新加载对话 ---------- */
|
/* ---------- 会话切换:chatId 变化时重新加载对话 ---------- */
|
||||||
watch(currentChatId, () => {
|
watch(currentChatId, () => {
|
||||||
chatLog.value = loadChat()
|
chatLog.value = loadChat()
|
||||||
@@ -427,10 +447,8 @@ watch(currentChatId, () => {
|
|||||||
<div v-for="m in renderMsgs" :key="m.key" class="msg" :class="m.role">
|
<div v-for="m in renderMsgs" :key="m.key" class="msg" :class="m.role">
|
||||||
<div class="bubble" :class="{ error: m.error }">
|
<div class="bubble" :class="{ error: m.error }">
|
||||||
<span v-if="m.tag" class="diff-tag">✓ {{ m.tag }}</span>
|
<span v-if="m.tag" class="diff-tag">✓ {{ m.tag }}</span>
|
||||||
<template v-for="(p, pi) in renderMessageContent(m.content)" :key="pi">
|
<!-- Markdown 整段渲染(含代码块/列表/加粗等,经 DOMPurify 消毒) -->
|
||||||
<pre v-if="p.type === 'code'" class="msg-code" :data-lang="p.lang"><code v-html="p.html"></code></pre>
|
<div v-if="m.content" class="md-body" v-html="renderMd(m.content)"></div>
|
||||||
<span v-else class="stream-text" v-html="p.html"></span>
|
|
||||||
</template>
|
|
||||||
<span v-if="m.streaming" class="cursor"></span>
|
<span v-if="m.streaming" class="cursor"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -443,7 +461,6 @@ watch(currentChatId, () => {
|
|||||||
id="chatInput"
|
id="chatInput"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="输入指令,回车发送(Shift+Enter 换行)"
|
placeholder="输入指令,回车发送(Shift+Enter 换行)"
|
||||||
:disabled="busy"
|
|
||||||
@keydown="onInputKeydown"
|
@keydown="onInputKeydown"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="btns">
|
<div class="btns">
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<!-- =====================================================================
|
||||||
|
AppDialog.vue — 全局统一对话框宿主(替代原生 alert/confirm/prompt)
|
||||||
|
由 core/dialog.ts 的模块级状态驱动;在 App.vue 模板放置 <AppDialog /> 接线
|
||||||
|
===================================================================== -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import { dialogState, resolveDialog } from '../../core/dialog'
|
||||||
|
|
||||||
|
const inputValue = ref('')
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
watch(() => dialogState.open.value, async (v) => {
|
||||||
|
if (!v) return
|
||||||
|
inputValue.value = dialogState.options.value?.defaultValue || ''
|
||||||
|
await nextTick()
|
||||||
|
if (dialogState.options.value?.type === 'prompt') {
|
||||||
|
inputRef.value?.focus()
|
||||||
|
inputRef.value?.select()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onMaskKeydown(e: KeyboardEvent) {
|
||||||
|
// confirm/alert 无输入框,键盘焦点在 mask 上时也支持 Enter/Esc
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
resolveDialog({ ok: true, value: inputValue.value })
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
resolveDialog({ ok: false, value: '' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="dialogState.open.value && dialogState.options.value"
|
||||||
|
class="modal-mask"
|
||||||
|
tabindex="-1"
|
||||||
|
@keydown="onMaskKeydown"
|
||||||
|
@click.self="resolveDialog({ ok: false, value: '' })"
|
||||||
|
>
|
||||||
|
<div class="modal app-dialog">
|
||||||
|
<h3>{{ dialogState.options.value.title }}</h3>
|
||||||
|
<p v-if="dialogState.options.value.message" class="modal-tip">{{ dialogState.options.value.message }}</p>
|
||||||
|
<div v-if="dialogState.options.value.type === 'prompt'" class="form-row">
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="inputValue"
|
||||||
|
:placeholder="dialogState.options.value.placeholder || ''"
|
||||||
|
@keydown.enter.prevent="resolveDialog({ ok: true, value: inputValue })"
|
||||||
|
@keydown.esc.prevent="resolveDialog({ ok: false, value: '' })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button v-if="dialogState.options.value.type !== 'alert'" class="btn" @click="resolveDialog({ ok: false, value: '' })">取消</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
:class="dialogState.options.value.danger ? 'danger' : 'primary'"
|
||||||
|
@click="resolveDialog({ ok: true, value: inputValue })"
|
||||||
|
>{{ dialogState.options.value.okText || '确定' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
import type { ElementType } from '../../core/types'
|
import type { ElementType } from '../../core/types'
|
||||||
import { elementTypes } from '../../core/sample'
|
import { elementTypes } from '../../core/sample'
|
||||||
|
|
||||||
const TYPES: ElementType[] = ['title', 'text', 'list', 'stat', 'quote', 'image', 'shape', 'chart', 'card', 'table', 'code', 'formula']
|
const TYPES: ElementType[] = ['title', 'text', 'list', 'stat', 'quote', 'image', 'video', 'shape', 'chart', 'card', 'table', 'code', 'formula']
|
||||||
const ICONS: Record<string, string> = {
|
const ICONS: Record<string, string> = {
|
||||||
title: 'T', text: '¶', list: '☰', stat: '#', quote: '“”',
|
title: 'T', text: '¶', list: '☰', stat: '#', quote: '“”',
|
||||||
image: '🖼', shape: '▭', chart: '📊', card: '◰',
|
image: '🖼', video: '🎬', shape: '▭', chart: '📊', card: '◰',
|
||||||
table: '▦', code: '</>', formula: '∑'
|
table: '▦', code: '</>', formula: '∑'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
<!-- =====================================================================
|
||||||
|
AnnotationLayer.vue — 画布批注叠加层
|
||||||
|
连线(SVG,1280×720 用户坐标)+ 气泡(DOM,百分比定位,可拖动)
|
||||||
|
自读 store,铺满 .canvas 与 ElementView 平级,pointer-events 精确放行
|
||||||
|
===================================================================== -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
import { store, resolveColor, isDarkBg } from '../../core/store'
|
||||||
|
import { markdownToSegments, segmentsToHtml } from '../../core/richtext'
|
||||||
|
import type { Annotation, SlideElement } from '../../core/types'
|
||||||
|
|
||||||
|
const CANVAS_W = 1280
|
||||||
|
const CANVAS_H = 720
|
||||||
|
|
||||||
|
const slide = computed(() => store.currentSlide.value)
|
||||||
|
|
||||||
|
/** 当前页背景是否深色 → 文字反相 */
|
||||||
|
const dark = computed(() => isDarkBg(slide.value?.background || ''))
|
||||||
|
|
||||||
|
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
|
||||||
|
|
||||||
|
/** 扁平列表:{ el, anno },供连线与气泡渲染 */
|
||||||
|
const items = computed(() => {
|
||||||
|
const out: { el: SlideElement; anno: Annotation }[] = []
|
||||||
|
for (const el of slide.value?.elements || []) {
|
||||||
|
const list = el.style?.annotations
|
||||||
|
if (list?.length) for (const anno of list) out.push({ el, anno })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 拖动中的临时覆盖:key = elId::annoId → 部分 box 字段(移动改 bx/by,缩放改 bw/bh) */
|
||||||
|
const dragOverride = ref<Record<string, { bx?: number; by?: number; bw?: number; bh?: number }>>({})
|
||||||
|
const keyOf = (elId: string, annoId: string) => elId + '::' + annoId
|
||||||
|
|
||||||
|
/** 当前选中的批注(elId::annoId),高亮 + 显示缩放手柄;
|
||||||
|
* 同步到 store.selectedAnnoId 供全局 Delete 键裁决(批注删除优先于宿主元素) */
|
||||||
|
const selectedKey = ref<string | null>(null)
|
||||||
|
watch(selectedKey, v => store.setSelectedAnnoId(v ? v.split('::')[1] : null))
|
||||||
|
|
||||||
|
/** 宿主图片被取消选中 / 切页 / 批注被删 → 清空批注选中与编辑态 */
|
||||||
|
watch(
|
||||||
|
[() => store.selectedId.value, items],
|
||||||
|
([selId]) => {
|
||||||
|
if (!selectedKey.value) return
|
||||||
|
const stillExists = items.value.some(
|
||||||
|
({ el, anno }) => keyOf(el.id, anno.id) === selectedKey.value && el.id === selId
|
||||||
|
)
|
||||||
|
if (!stillExists) {
|
||||||
|
selectedKey.value = null
|
||||||
|
editingKey.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 新增批注自动进入编辑:选中图片的 annotations 出现新 id(且文本为空)→ 直接开写 */
|
||||||
|
const seenAnnoIds = new Set<string>()
|
||||||
|
// 挂载时把存量批注标记已见(老数据不触发编辑);此后出现的新 id 才自动进入编辑
|
||||||
|
for (const { anno } of items.value) seenAnnoIds.add(anno.id)
|
||||||
|
watch(items, (list) => {
|
||||||
|
const selId = store.selectedId.value
|
||||||
|
for (const { el, anno } of list) {
|
||||||
|
if (!seenAnnoIds.has(anno.id)) {
|
||||||
|
seenAnnoIds.add(anno.id)
|
||||||
|
if (el.id === selId && !anno.text) {
|
||||||
|
selectedKey.value = keyOf(el.id, anno.id)
|
||||||
|
editingKey.value = keyOf(el.id, anno.id)
|
||||||
|
nextTick(() => { editRef.value?.focus() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { deep: false })
|
||||||
|
|
||||||
|
/** 气泡当前坐标(拖动时用临时覆盖,否则用 anno 原值) */
|
||||||
|
function box(el: SlideElement, anno: Annotation) {
|
||||||
|
const ov = dragOverride.value[keyOf(el.id, anno.id)]
|
||||||
|
return {
|
||||||
|
bx: ov?.bx ?? anno.bx, by: ov?.by ?? anno.by,
|
||||||
|
bw: ov?.bw ?? anno.bw, bh: ov?.bh ?? anno.bh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 射线-矩形边框求交:从矩形中心 (cx,cy) 朝目标 (tx,ty) 方向,
|
||||||
|
* 取与矩形边框(半宽 hw、半高 hh)的交点。用于让连线从图片/气泡「边缘」出发,
|
||||||
|
* 不穿越本体。目标与中心重合时回退中心。
|
||||||
|
*/
|
||||||
|
function edgePoint(cx: number, cy: number, hw: number, hh: number, tx: number, ty: number) {
|
||||||
|
const dx = tx - cx, dy = ty - cy
|
||||||
|
if (!dx && !dy) return { x: cx, y: cy }
|
||||||
|
// 缩放因子:让 |dx*s|<=hw 且 |dy*s|<=hh,取首先触边者
|
||||||
|
const sx = dx ? hw / Math.abs(dx) : Infinity
|
||||||
|
const sy = dy ? hh / Math.abs(dy) : Infinity
|
||||||
|
const s = Math.min(sx, sy)
|
||||||
|
return { x: cx + dx * s, y: cy + dy * s }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连线几何:起点=图片边缘、终点=气泡边缘(沿两中心连线方向),坐标转 1280×720 用户单位。
|
||||||
|
* 气泡中心落入图片框内(或两框重叠到边缘交点交叉)时返回 null → 不画线。 */
|
||||||
|
function lineGeom(el: SlideElement, anno: Annotation) {
|
||||||
|
const ex = el.x / 100 * CANVAS_W, ey = el.y / 100 * CANVAS_H
|
||||||
|
const ew = el.w / 100 * CANVAS_W, eh = el.h / 100 * CANVAS_H
|
||||||
|
const b = box(el, anno)
|
||||||
|
const bx = b.bx / 100 * CANVAS_W, by = b.by / 100 * CANVAS_H
|
||||||
|
const bw = b.bw / 100 * CANVAS_W, bh = b.bh / 100 * CANVAS_H
|
||||||
|
// 图片锚点中心(ax/ay 允许自定义偏移,默认几何中心)
|
||||||
|
const icx = ex + (anno.ax ?? 50) / 100 * ew
|
||||||
|
const icy = ey + (anno.ay ?? 50) / 100 * eh
|
||||||
|
const bcx = bx + bw / 2, bcy = by + bh / 2
|
||||||
|
// 气泡中心落在图片框内 → 连线无意义,隐藏
|
||||||
|
if (bcx >= ex && bcx <= ex + ew && bcy >= ey && bcy <= ey + eh) return null
|
||||||
|
const dist = Math.hypot(bcx - icx, bcy - icy)
|
||||||
|
// 起点从图片边缘出发(朝气泡方向),终点落在气泡边缘(朝图片方向)
|
||||||
|
const s = edgePoint(icx, icy, ew / 2, eh / 2, bcx, bcy)
|
||||||
|
const t = edgePoint(bcx, bcy, bw / 2, bh / 2, icx, icy)
|
||||||
|
// 两边缘点分别从各自中心前进的距离之和 ≥ 两中心距离 → 边框重叠,画出来会反向/交叉,隐藏
|
||||||
|
const sd = Math.hypot(s.x - icx, s.y - icy)
|
||||||
|
const td = Math.hypot(t.x - bcx, t.y - bcy)
|
||||||
|
if (sd + td >= dist) return null
|
||||||
|
return { x1: s.x, y1: s.y, x2: t.x, y2: t.y }
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineColor(anno: Annotation) {
|
||||||
|
return resolveColor(anno.line?.color, dark.value) || (dark.value ? '#ffffff' : '#64748b')
|
||||||
|
}
|
||||||
|
function lineWidth(anno: Annotation) {
|
||||||
|
return (anno.line?.width ?? 0.5) * 2 // viewBox 用户单位偏细,放大系数;默认 0.5px
|
||||||
|
}
|
||||||
|
function dash(anno: Annotation) {
|
||||||
|
return anno.line?.style === 'dashed' ? '10 8' : undefined
|
||||||
|
}
|
||||||
|
/** cap → marker url(none 返回 undefined) */
|
||||||
|
function markerUrl(cap: string | undefined, kind: 'start' | 'end') {
|
||||||
|
if (cap === 'arrow') return 'url(#anno-arrow-' + kind + ')'
|
||||||
|
if (cap === 'dot') return 'url(#anno-dot)'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 气泡文字样式 */
|
||||||
|
function bubbleTextStyle(anno: Annotation) {
|
||||||
|
return {
|
||||||
|
fontSize: (anno.fontSize || 14) + 'px',
|
||||||
|
color: resolveColor(anno.color, false) || '#1e293b',
|
||||||
|
fontWeight: anno.bold ? 700 : 400,
|
||||||
|
fontStyle: anno.italic ? 'italic' : 'normal',
|
||||||
|
textAlign: anno.align || 'left'
|
||||||
|
} as Record<string, string | number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 气泡文字 → md 渲染 HTML(复用 richtext 行内格式 + 本组件补充 # 标题块级语法) */
|
||||||
|
function renderText(anno: Annotation): string {
|
||||||
|
const t = anno.text || ''
|
||||||
|
if (!t.trim()) return '<span class="anno-empty">双击输入批注</span>'
|
||||||
|
const base = anno.fontSize || 14
|
||||||
|
const HSIZE = [1.8, 1.5, 1.25] // #, ##, ### 相对基准字号的倍数
|
||||||
|
return t.split('\n').map(line => {
|
||||||
|
const h = /^(#{1,3})\s+(.*)$/.exec(line)
|
||||||
|
if (h) {
|
||||||
|
const level = h[1].length // 1~3
|
||||||
|
const inner = segmentsToHtml(markdownToSegments(h[2]))
|
||||||
|
const size = Math.round(base * HSIZE[level - 1])
|
||||||
|
return `<span style="display:block;font-size:${size}px;font-weight:700;line-height:1.25">${inner}</span>`
|
||||||
|
}
|
||||||
|
return segmentsToHtml(markdownToSegments(line))
|
||||||
|
}).join('<br>')
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 气泡内联编辑(双击进入,编辑 md 源文) ---------- */
|
||||||
|
const editingKey = ref<string | null>(null)
|
||||||
|
const editRef = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
function startEdit(el: SlideElement, anno: Annotation) {
|
||||||
|
store.selectElement(el.id)
|
||||||
|
selectedKey.value = keyOf(el.id, anno.id)
|
||||||
|
editingKey.value = keyOf(el.id, anno.id)
|
||||||
|
nextTick(() => { editRef.value?.focus(); editRef.value?.select() })
|
||||||
|
}
|
||||||
|
function commitEdit(el: SlideElement, anno: Annotation, value: string) {
|
||||||
|
editingKey.value = null
|
||||||
|
if (value !== anno.text) store.updateAnnotation(el.id, anno.id, { text: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 气泡拖动 / 缩放 ---------- */
|
||||||
|
const MIN_BW = 6, MIN_BH = 4 // 气泡最小尺寸(百分比)
|
||||||
|
interface DragState {
|
||||||
|
mode: 'move' | 'resize'
|
||||||
|
key: string; el: SlideElement; anno: Annotation
|
||||||
|
startBx: number; startBy: number; startBw: number; startBh: number
|
||||||
|
px0: number; py0: number
|
||||||
|
rect: DOMRect
|
||||||
|
}
|
||||||
|
let ds: DragState | null = null
|
||||||
|
|
||||||
|
function startDrag(mode: 'move' | 'resize', e: MouseEvent, el: SlideElement, anno: Annotation) {
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
store.selectElement(el.id)
|
||||||
|
selectedKey.value = keyOf(el.id, anno.id)
|
||||||
|
const canvas = (e.currentTarget as HTMLElement).closest('.canvas') as HTMLElement | null
|
||||||
|
if (!canvas) return
|
||||||
|
ds = {
|
||||||
|
mode, key: keyOf(el.id, anno.id), el, anno,
|
||||||
|
startBx: anno.bx, startBy: anno.by, startBw: anno.bw, startBh: anno.bh,
|
||||||
|
px0: e.clientX, py0: e.clientY,
|
||||||
|
rect: canvas.getBoundingClientRect()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onDragMove)
|
||||||
|
document.addEventListener('mouseup', onDragUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBubbleDown(e: MouseEvent, el: SlideElement, anno: Annotation) {
|
||||||
|
startDrag('move', e, el, anno)
|
||||||
|
}
|
||||||
|
function onResizeDown(e: MouseEvent, el: SlideElement, anno: Annotation) {
|
||||||
|
startDrag('resize', e, el, anno)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragMove(e: MouseEvent) {
|
||||||
|
if (!ds) return
|
||||||
|
const dxPct = (e.clientX - ds.px0) / ds.rect.width * 100
|
||||||
|
const dyPct = (e.clientY - ds.py0) / ds.rect.height * 100
|
||||||
|
if (ds.mode === 'resize') {
|
||||||
|
const nbw = clamp(ds.startBw + dxPct, MIN_BW, 100 - ds.startBx)
|
||||||
|
const nbh = clamp(ds.startBh + dyPct, MIN_BH, 100 - ds.startBy)
|
||||||
|
dragOverride.value = { ...dragOverride.value, [ds.key]: { bw: nbw, bh: nbh } }
|
||||||
|
} else {
|
||||||
|
const nbx = clamp(ds.startBx + dxPct, 0, 100 - ds.startBw)
|
||||||
|
const nby = clamp(ds.startBy + dyPct, 0, 100 - ds.startBh)
|
||||||
|
dragOverride.value = { ...dragOverride.value, [ds.key]: { bx: nbx, by: nby } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragUp() {
|
||||||
|
if (ds) {
|
||||||
|
const ov = dragOverride.value[ds.key]
|
||||||
|
if (ov) {
|
||||||
|
const patch = ds.mode === 'resize' ? { bw: ov.bw, bh: ov.bh } : { bx: ov.bx, by: ov.by }
|
||||||
|
store.updateAnnotation(ds.el.id, ds.anno.id, patch)
|
||||||
|
}
|
||||||
|
const next = { ...dragOverride.value }
|
||||||
|
delete next[ds.key]
|
||||||
|
dragOverride.value = next
|
||||||
|
}
|
||||||
|
ds = null
|
||||||
|
document.removeEventListener('mousemove', onDragMove)
|
||||||
|
document.removeEventListener('mouseup', onDragUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('mousemove', onDragMove)
|
||||||
|
document.removeEventListener('mouseup', onDragUp)
|
||||||
|
document.removeEventListener('keydown', onLayerKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 选中批注时 Delete/Backspace 删除该批注(输入态/编辑态放行)。
|
||||||
|
* store.selectedAnnoId 已同步,App.vue 的元素删除会先让位 */
|
||||||
|
function onLayerKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key !== 'Delete' && e.key !== 'Backspace') return
|
||||||
|
if (document.body.dataset.mode === 'present') return
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
if (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable) return
|
||||||
|
if (!selectedKey.value || editingKey.value) return
|
||||||
|
const hit = items.value.find(({ el, anno }) => keyOf(el.id, anno.id) === selectedKey.value)
|
||||||
|
if (!hit) return
|
||||||
|
e.preventDefault()
|
||||||
|
store.delAnnotation(hit.el.id, hit.anno.id)
|
||||||
|
selectedKey.value = null
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', onLayerKeydown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="anno-layer">
|
||||||
|
<!-- 连线层 -->
|
||||||
|
<svg
|
||||||
|
class="anno-svg"
|
||||||
|
viewBox="0 0 1280 720"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id="anno-arrow-end" markerUnits="userSpaceOnUse"
|
||||||
|
markerWidth="16" markerHeight="16" refX="12" refY="6" orient="auto"
|
||||||
|
>
|
||||||
|
<path d="M0,0 L12,6 L0,12 Z" fill="context-stroke" />
|
||||||
|
</marker>
|
||||||
|
<marker
|
||||||
|
id="anno-arrow-start" markerUnits="userSpaceOnUse"
|
||||||
|
markerWidth="16" markerHeight="16" refX="0" refY="6" orient="auto"
|
||||||
|
>
|
||||||
|
<path d="M12,0 L0,6 L12,12 Z" fill="context-stroke" />
|
||||||
|
</marker>
|
||||||
|
<marker
|
||||||
|
id="anno-dot" markerUnits="userSpaceOnUse"
|
||||||
|
markerWidth="12" markerHeight="12" refX="5" refY="5"
|
||||||
|
>
|
||||||
|
<circle cx="5" cy="5" r="4" fill="context-stroke" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<template v-for="{ el, anno } in items" :key="'ln-' + el.id + anno.id">
|
||||||
|
<line
|
||||||
|
v-if="lineGeom(el, anno)"
|
||||||
|
v-bind="lineGeom(el, anno)!"
|
||||||
|
:stroke="lineColor(anno)"
|
||||||
|
:stroke-width="lineWidth(anno)"
|
||||||
|
:stroke-dasharray="dash(anno)"
|
||||||
|
stroke-linecap="round"
|
||||||
|
:marker-start="markerUrl(anno.line?.startCap, 'start')"
|
||||||
|
:marker-end="markerUrl(anno.line?.endCap, 'end')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 气泡层 -->
|
||||||
|
<div
|
||||||
|
v-for="{ el, anno } in items"
|
||||||
|
:key="'bb-' + el.id + anno.id"
|
||||||
|
class="anno-bubble"
|
||||||
|
:class="{ selected: selectedKey === keyOf(el.id, anno.id) }"
|
||||||
|
:data-el-id="el.id"
|
||||||
|
:data-anno-id="anno.id"
|
||||||
|
:style="{
|
||||||
|
left: box(el, anno).bx + '%',
|
||||||
|
top: box(el, anno).by + '%',
|
||||||
|
width: box(el, anno).bw + '%',
|
||||||
|
height: box(el, anno).bh + '%',
|
||||||
|
...bubbleTextStyle(anno)
|
||||||
|
}"
|
||||||
|
@mousedown="onBubbleDown($event, el, anno)"
|
||||||
|
@dblclick.stop="startEdit(el, anno)"
|
||||||
|
>
|
||||||
|
<!-- 编辑态:md 源文 textarea -->
|
||||||
|
<textarea
|
||||||
|
v-if="editingKey === keyOf(el.id, anno.id)"
|
||||||
|
ref="editRef"
|
||||||
|
class="anno-bubble-edit"
|
||||||
|
:value="anno.text"
|
||||||
|
placeholder="支持 **加粗** *斜体* ==高亮== # 标题"
|
||||||
|
@mousedown.stop
|
||||||
|
@blur="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
@keydown.enter.exact.prevent="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
@keydown.enter.ctrl.prevent="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
@keydown.esc.prevent="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
></textarea>
|
||||||
|
<!-- 展示态:md 渲染 -->
|
||||||
|
<span v-else class="anno-bubble-text" v-html="renderText(anno)"></span>
|
||||||
|
<span
|
||||||
|
class="anno-resize"
|
||||||
|
title="拖动调整气泡大小"
|
||||||
|
@mousedown="onResizeDown($event, el, anno)"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.anno-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.anno-svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* SVG 子元素默认 visiblePainted 会拦截点击,父级 pointer-events:none 不继承 → 显式关闭,
|
||||||
|
否则贴着图片边缘的连线描边会挡住图片的点选(回归根因) */
|
||||||
|
.anno-svg * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.anno-bubble {
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: move;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.1);
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
user-select: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.anno-bubble.selected {
|
||||||
|
border-color: var(--ui-primary, #4f46e5);
|
||||||
|
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.25), 0 2px 8px rgba(15, 23, 42, 0.1);
|
||||||
|
}
|
||||||
|
.anno-bubble-text {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.anno-bubble-text :deep(.anno-empty) {
|
||||||
|
color: var(--ui-muted, #94a3b8);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.anno-bubble-edit {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
resize: none;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
.anno-resize {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, transparent 50%, var(--ui-primary, #4f46e5) 50%);
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .12s;
|
||||||
|
}
|
||||||
|
.anno-bubble:hover .anno-resize,
|
||||||
|
.anno-bubble.selected .anno-resize {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,18 +7,33 @@ import { store, resolveBg } from '../../core/store'
|
|||||||
import { CANVAS_W } from '../../core/sample'
|
import { CANVAS_W } from '../../core/sample'
|
||||||
import { useEditor } from '../../composables/useEditor'
|
import { useEditor } from '../../composables/useEditor'
|
||||||
import ElementView from './ElementView.vue'
|
import ElementView from './ElementView.vue'
|
||||||
|
import AnnotationLayer from './AnnotationLayer.vue'
|
||||||
|
|
||||||
const canvasRef = ref<HTMLElement>()
|
const canvasRef = ref<HTMLElement>()
|
||||||
const canvasFrameRef = ref<HTMLElement>()
|
const canvasFrameRef = ref<HTMLElement>() // 注意:绑定 .canvas-frame(不含 stage 的 padding),scale 以它为基准
|
||||||
|
|
||||||
const {
|
const {
|
||||||
drag, editing,
|
drag, marquee, editing,
|
||||||
onCanvasMouseDown, onMouseMove, onMouseUp, onContentEditableFocus
|
onCanvasMouseDown, onMouseMove, onMouseUp, onContentEditableFocus
|
||||||
} = useEditor()
|
} = useEditor()
|
||||||
|
|
||||||
const slide = computed(() => store.currentSlide.value)
|
const slide = computed(() => store.currentSlide.value)
|
||||||
const selectedId = computed(() => store.selectedId.value)
|
const selectedId = computed(() => store.selectedId.value)
|
||||||
|
const selectedIds = computed(() => store.selectedIds.value || [])
|
||||||
const HANDLES = ['tl', 'tm', 'tr', 'lm', 'rm', 'bl', 'bm', 'br']
|
const HANDLES = ['tl', 'tm', 'tr', 'lm', 'rm', 'bl', 'bm', 'br']
|
||||||
|
const noteOpen = ref(false) // 演讲者备注栏展开态
|
||||||
|
|
||||||
|
/** 框选矩形样式(% → CSS) */
|
||||||
|
const marqueeBox = computed(() => {
|
||||||
|
const m = marquee.value
|
||||||
|
if (!m) return null
|
||||||
|
return {
|
||||||
|
left: Math.min(m.x0, m.x1) + '%',
|
||||||
|
top: Math.min(m.y0, m.y1) + '%',
|
||||||
|
width: Math.abs(m.x1 - m.x0) + '%',
|
||||||
|
height: Math.abs(m.y1 - m.y0) + '%'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function fitCanvas() {
|
function fitCanvas() {
|
||||||
if (!canvasFrameRef.value || !canvasRef.value) return
|
if (!canvasFrameRef.value || !canvasRef.value) return
|
||||||
@@ -32,11 +47,16 @@ function onElementBlur(id: string, field: string, value: string) {
|
|||||||
editing.value = false
|
editing.value = false
|
||||||
if (field === 'label') {
|
if (field === 'label') {
|
||||||
store.updateElement(id, { style: { label: value } })
|
store.updateElement(id, { style: { label: value } })
|
||||||
} else if (store.findElement(id)?.type === 'list') {
|
return
|
||||||
store.updateElement(id, { content: value })
|
|
||||||
} else {
|
|
||||||
store.updateElement(id, { content: value })
|
|
||||||
}
|
}
|
||||||
|
// 形状输入文字:装饰细条(h 过小)自动撑高到能容纳一行字,避免文字被裁
|
||||||
|
const el = store.findElement(id)
|
||||||
|
if (el?.type === 'shape' && value.trim() && el.h < 6) {
|
||||||
|
const grow = 6 - el.h
|
||||||
|
store.updateElement(id, { content: value, h: 6, y: Math.max(0, el.y - grow / 2) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.updateElement(id, { content: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拖拽中元素的实时位置(覆盖 state) */
|
/** 拖拽中元素的实时位置(覆盖 state) */
|
||||||
@@ -53,24 +73,46 @@ function liveBox(elId: string) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 多选组拖拽中各元素的实时位置 */
|
||||||
|
function liveMultiBox(elId: string) {
|
||||||
|
const d = drag.value
|
||||||
|
if (d && d.mode === 'multi-move' && d.multiResult) {
|
||||||
|
const r = d.multiResult.find(t => t.id === elId)
|
||||||
|
if (r) return { left: r.x + '%', top: r.y + '%' }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNoteInput(e: Event) {
|
||||||
|
store.setSlideNote((e.target as HTMLTextAreaElement).value)
|
||||||
|
}
|
||||||
|
|
||||||
const onResize = () => fitCanvas()
|
const onResize = () => fitCanvas()
|
||||||
|
|
||||||
|
/** 监听 frame 尺寸:v-show 隐藏→显示、窗口缩放、布局变化都会触发(隐藏时 rect=0,onMounted 的 rAF 会空跑) */
|
||||||
|
let frameObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('resize', onResize)
|
window.addEventListener('resize', onResize)
|
||||||
document.addEventListener('mousemove', onMouseMove)
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
document.addEventListener('mouseup', onMouseUp)
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
requestAnimationFrame(fitCanvas)
|
requestAnimationFrame(fitCanvas)
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && canvasFrameRef.value) {
|
||||||
|
frameObserver = new ResizeObserver(() => fitCanvas())
|
||||||
|
frameObserver.observe(canvasFrameRef.value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', onResize)
|
window.removeEventListener('resize', onResize)
|
||||||
document.removeEventListener('mousemove', onMouseMove)
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
document.removeEventListener('mouseup', onMouseUp)
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
if (frameObserver) { frameObserver.disconnect(); frameObserver = null }
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="canvas-stage" ref="canvasFrameRef">
|
<main class="canvas-stage">
|
||||||
<div class="canvas-frame">
|
<div class="canvas-frame" ref="canvasFrameRef">
|
||||||
<div
|
<div
|
||||||
class="canvas"
|
class="canvas"
|
||||||
ref="canvasRef"
|
ref="canvasRef"
|
||||||
@@ -91,15 +133,45 @@ onUnmounted(() => {
|
|||||||
:el="el"
|
:el="el"
|
||||||
:bg="slide.background"
|
:bg="slide.background"
|
||||||
edit
|
edit
|
||||||
:show-handles="el.id === selectedId"
|
:show-handles="el.id === selectedId && selectedIds.length <= 1"
|
||||||
:class="{ selected: el.id === selectedId, dragging: drag?.id === el.id && !!drag?.result }"
|
:class="{
|
||||||
:style="liveBox(el.id) || {}"
|
selected: el.id === selectedId,
|
||||||
|
'multi-selected': selectedIds.includes(el.id),
|
||||||
|
dragging: drag?.id === el.id && !!drag?.result
|
||||||
|
}"
|
||||||
|
:style="liveBox(el.id) || liveMultiBox(el.id) || {}"
|
||||||
@blur="onElementBlur"
|
@blur="onElementBlur"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 框选矩形 -->
|
||||||
|
<div v-if="marqueeBox" class="marquee-box" :style="marqueeBox"></div>
|
||||||
|
|
||||||
|
<AnnotationLayer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="canvas-status">
|
<div class="canvas-footer">
|
||||||
<span>{{ store.currentIndex.value + 1 }} / {{ store.count.value }}</span>
|
<div class="canvas-status">
|
||||||
|
<span>{{ store.currentIndex.value + 1 }} / {{ store.count.value }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="note-toggle"
|
||||||
|
:class="{ active: noteOpen || !!slide?.note }"
|
||||||
|
:title="noteOpen ? '收起备注' : '演讲者备注'"
|
||||||
|
@click="noteOpen = !noteOpen"
|
||||||
|
>
|
||||||
|
<span class="note-toggle-icon">✎</span>
|
||||||
|
<span>备注</span>
|
||||||
|
<span v-if="slide?.note && !noteOpen" class="note-dot"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-show="noteOpen" class="canvas-note">
|
||||||
|
<textarea
|
||||||
|
class="canvas-note-input"
|
||||||
|
:value="slide?.note || ''"
|
||||||
|
placeholder="演讲者备注(不显示在幻灯片上)…"
|
||||||
|
rows="2"
|
||||||
|
@input="onNoteInput"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { SlideElement, BgKey } from '../../core/types'
|
import type { SlideElement, BgKey } from '../../core/types'
|
||||||
import { store, resolveColor } from '../../core/store'
|
import { store, resolveColor, isDarkBg } from '../../core/store'
|
||||||
import { segmentsToHtml, markdownToSegments, hasFormatting } from '../../core/richtext'
|
import { segmentsToHtml, markdownToSegments, hasFormatting } from '../../core/richtext'
|
||||||
import ChartView from './ChartView.vue'
|
import ChartView from './ChartView.vue'
|
||||||
|
|
||||||
@@ -53,38 +53,20 @@ const props = defineProps<{
|
|||||||
edit?: boolean
|
edit?: boolean
|
||||||
/** 是否显示八向缩放手柄 */
|
/** 是否显示八向缩放手柄 */
|
||||||
showHandles?: boolean
|
showHandles?: boolean
|
||||||
|
/** 是否为缩略图端(视频等重元素降级为静态渲染) */
|
||||||
|
thumb?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'blur', id: string, field: string, value: string): void
|
(e: 'blur', id: string, field: string, value: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/** 当前页背景是否深色 → 文字是否需要反相 */
|
/** 当前页背景是否深色 → 文字是否需要反相(isDarkBg 复用 store 导出的统一实现) */
|
||||||
const dark = computed(() => {
|
const dark = computed(() => {
|
||||||
const d = store.state.deck ? (props.el.type === 'card' ? false : isDarkBg(props.bg)) : false
|
const d = store.state.deck ? (props.el.type === 'card' ? false : isDarkBg(props.bg)) : false
|
||||||
return d
|
return d
|
||||||
})
|
})
|
||||||
|
|
||||||
function isDarkBg(bg: string): boolean {
|
|
||||||
if (!bg) return false
|
|
||||||
if (bg.charAt(0) === '#') return isDarkHex(bg)
|
|
||||||
if (bg === 'primary' || bg === 'accent') return true
|
|
||||||
if (bg.indexOf('g-') === 0) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
function hexToRgb(hex: string) {
|
|
||||||
const c = String(hex).replace('#', '')
|
|
||||||
const full = c.length === 3 ? c[0] + c[0] + c[1] + c[1] + c[2] + c[2] : c
|
|
||||||
const r = parseInt(full.substr(0, 2), 16)
|
|
||||||
const g = parseInt(full.substr(2, 2), 16)
|
|
||||||
const b = parseInt(full.substr(4, 2), 16)
|
|
||||||
return (isNaN(r) || isNaN(g) || isNaN(b)) ? null : { r, g, b }
|
|
||||||
}
|
|
||||||
function isDarkHex(hex: string): boolean {
|
|
||||||
const rgb = hexToRgb(hex); if (!rgb) return false
|
|
||||||
return (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) < 145
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = computed(() => props.el.style || {})
|
const s = computed(() => props.el.style || {})
|
||||||
|
|
||||||
const boxStyle = computed(() => {
|
const boxStyle = computed(() => {
|
||||||
@@ -128,8 +110,33 @@ const shapeBg = computed(() => {
|
|||||||
return fill
|
return fill
|
||||||
})
|
})
|
||||||
|
|
||||||
const isCircle = computed(() => s.value.shapeType === 'circle')
|
/** 各形状 clip-path(rect/circle 走 CSS 圆角,不走 clip) */
|
||||||
const isTriangle = computed(() => s.value.shapeType === 'triangle')
|
const SHAPE_CLIPS: Partial<Record<string, string>> = {
|
||||||
|
triangle: 'polygon(50% 0, 100% 100%, 0 100%)',
|
||||||
|
diamond: 'polygon(50% 0, 100% 50%, 50% 100%, 0 50%)',
|
||||||
|
pentagon: 'polygon(50% 0, 100% 38%, 82% 100%, 18% 100%, 0 38%)',
|
||||||
|
hexagon: 'polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%)',
|
||||||
|
star: 'polygon(50% 0, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)',
|
||||||
|
arrow: 'polygon(0 20%, 75% 20%, 75% 0, 100% 50%, 75% 100%, 75% 80%, 0 80%)',
|
||||||
|
chevron: 'polygon(0 0, 75% 0, 100% 50%, 75% 100%, 0 100%, 25% 50%)',
|
||||||
|
bubble: 'polygon(0 0, 100% 0, 100% 75%, 25% 75%, 10% 100%, 15% 75%, 0 75%)'
|
||||||
|
}
|
||||||
|
const shapeClip = computed(() => SHAPE_CLIPS[s.value.shapeType || 'rect'] || null)
|
||||||
|
|
||||||
|
/** 形状文字(md 渲染;segments 存在时优先) */
|
||||||
|
const renderedShapeText = computed(() => {
|
||||||
|
if (props.el.segments && props.el.segments.length && hasFormatting(props.el.segments)) {
|
||||||
|
return segmentsToHtml(props.el.segments)
|
||||||
|
}
|
||||||
|
return segmentsToHtml(markdownToSegments(props.el.content || ''))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 形状文字层样式:对齐 + 未设色时默认白(填充色底) */
|
||||||
|
const shapeTextStyle = computed(() => {
|
||||||
|
const css: Record<string, string> = { textAlign: s.value.align || 'center' }
|
||||||
|
if (!s.value.color) css.color = '#fff'
|
||||||
|
return css
|
||||||
|
})
|
||||||
|
|
||||||
const cardAccentColor = computed(() => resolveColor(s.value.accent, false))
|
const cardAccentColor = computed(() => resolveColor(s.value.accent, false))
|
||||||
|
|
||||||
@@ -139,6 +146,19 @@ const cardParts = computed(() => {
|
|||||||
return { title: lines[0] || '', body: lines.slice(1).join('\n') }
|
return { title: lines[0] || '', body: lines.slice(1).join('\n') }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 卡片标题/正文失焦:从 DOM 读两块文字拼回 content(标题在前的兄弟节点)。
|
||||||
|
* 标题空但正文非空时保留空行占位,避免正文首行漂移成标题。 */
|
||||||
|
function onBlurCard(e: Event) {
|
||||||
|
const node = e.target as HTMLElement
|
||||||
|
const card = node.closest('.el-card')
|
||||||
|
if (!card) return
|
||||||
|
const title = (card.querySelector('.card-title')?.textContent || '').trim()
|
||||||
|
const body = (card.querySelector('.card-body')?.textContent || '').trim()
|
||||||
|
if (!title && !body) { emit('blur', props.el.id, 'content', ''); return }
|
||||||
|
if (body) emit('blur', props.el.id, 'content', title + '\n' + body)
|
||||||
|
else emit('blur', props.el.id, 'content', title)
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Rich text(segments 结构化富文本)---------- */
|
/* ---------- Rich text(segments 结构化富文本)---------- */
|
||||||
/** 是否有 segments(结构化富文本),优先于 content */
|
/** 是否有 segments(结构化富文本),优先于 content */
|
||||||
const hasSegments = computed(() => {
|
const hasSegments = computed(() => {
|
||||||
@@ -181,6 +201,7 @@ function onBlur(e: Event, field: string) {
|
|||||||
:data-id="el.id"
|
:data-id="el.id"
|
||||||
:data-type="el.type"
|
:data-type="el.type"
|
||||||
:data-anim="s.anim"
|
:data-anim="s.anim"
|
||||||
|
:data-shape="el.type === 'shape' ? (s.shapeType || 'rect') : undefined"
|
||||||
:style="{
|
:style="{
|
||||||
left: el.x + '%',
|
left: el.x + '%',
|
||||||
top: el.y + '%',
|
top: el.y + '%',
|
||||||
@@ -245,20 +266,63 @@ function onBlur(e: Event, field: string) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 图片 -->
|
<!-- 图片(无内容时显示占位提示,不渲染空 src 的裂图) -->
|
||||||
<template v-else-if="el.type === 'image'">
|
<template v-else-if="el.type === 'image'">
|
||||||
<img class="el-image" :src="el.content" draggable="false" />
|
<div v-if="!el.content" class="el-image-empty">
|
||||||
|
<span class="el-image-empty-icon">🖼</span>
|
||||||
|
<span class="el-image-empty-text">拖入图片 · 属性面板「本地图片」或 AI 配图</span>
|
||||||
|
</div>
|
||||||
|
<img v-else class="el-image" :src="el.content" draggable="false" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 视频(缩略图端降级静态;preload=metadata 控制加载开销) -->
|
||||||
|
<template v-else-if="el.type === 'video'">
|
||||||
|
<div v-if="!el.content" class="el-image-empty">
|
||||||
|
<span class="el-image-empty-icon">🎬</span>
|
||||||
|
<span class="el-image-empty-text">属性面板选择本地视频或填入 URL</span>
|
||||||
|
</div>
|
||||||
|
<!-- 缩略图:poster 静态图(无 poster 黑底▶),不加载视频 -->
|
||||||
|
<div v-else-if="thumb" class="el-video-thumb">
|
||||||
|
<img v-if="s.poster" :src="s.poster" draggable="false" />
|
||||||
|
<span v-else class="el-video-thumb-play">▶</span>
|
||||||
|
</div>
|
||||||
|
<!-- 编辑/演示:可播放(点选拖动交给画布 mousedown 冒泡,不阻断) -->
|
||||||
|
<video
|
||||||
|
v-else
|
||||||
|
class="el-video"
|
||||||
|
:src="el.content"
|
||||||
|
:poster="s.poster || undefined"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
:autoplay="!!s.autoplay"
|
||||||
|
:loop="!!s.loop"
|
||||||
|
:muted="!!s.muted"
|
||||||
|
:style="{ objectFit: s.fit || 'contain' }"
|
||||||
|
draggable="false"
|
||||||
|
@click.stop
|
||||||
|
></video>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 形状 -->
|
<!-- 形状 -->
|
||||||
<template v-else-if="el.type === 'shape'">
|
<template v-else-if="el.type === 'shape'">
|
||||||
<div v-if="isCircle" class="el-shape" :class="{ gradient: s.gradient }" :style="{ borderRadius: '50%', background: shapeBg }"></div>
|
<div v-if="s.shapeType === 'circle' || s.shapeType === 'ellipse'" class="el-shape" :class="{ gradient: s.gradient }" :style="{ borderRadius: '50%', background: shapeBg }"></div>
|
||||||
<div v-else-if="isTriangle" class="el-shape">
|
<div v-else-if="shapeClip" class="el-shape" :class="{ gradient: s.gradient }" :style="{ background: shapeBg, clipPath: shapeClip }"></div>
|
||||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%;height:100%;display:block">
|
|
||||||
<polygon points="50,5 95,95 5,95" :fill="shapeBg" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div v-else class="el-shape" :class="{ gradient: s.gradient }" :style="{ background: shapeBg, borderRadius: (s.radius != null ? s.radius : 12) + 'px' }"></div>
|
<div v-else class="el-shape" :class="{ gradient: s.gradient }" :style="{ background: shapeBg, borderRadius: (s.radius != null ? s.radius : 12) + 'px' }"></div>
|
||||||
|
<!-- 形状文字层:编辑态 contenteditable,非编辑态渲染 md;未设色时默认白(填充色底) -->
|
||||||
|
<div
|
||||||
|
v-if="edit"
|
||||||
|
class="el-shape-text"
|
||||||
|
:style="!s.color ? { color: '#fff' } : {}"
|
||||||
|
contenteditable="true"
|
||||||
|
data-edit="content"
|
||||||
|
@blur="onBlur($event, 'content')"
|
||||||
|
>{{ el.content }}</div>
|
||||||
|
<div
|
||||||
|
v-else-if="el.content"
|
||||||
|
class="el-shape-text"
|
||||||
|
:style="shapeTextStyle"
|
||||||
|
v-html="renderedShapeText"
|
||||||
|
></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 图表 -->
|
<!-- 图表 -->
|
||||||
@@ -271,8 +335,18 @@ function onBlur(e: Event, field: string) {
|
|||||||
<div class="card-bar" :style="{ background: cardAccentColor }"></div>
|
<div class="card-bar" :style="{ background: cardAccentColor }"></div>
|
||||||
<div class="el-card">
|
<div class="el-card">
|
||||||
<div v-if="s.icon" class="card-icon">{{ s.icon }}</div>
|
<div v-if="s.icon" class="card-icon">{{ s.icon }}</div>
|
||||||
<div class="card-title">{{ cardParts.title }}</div>
|
<div
|
||||||
<div class="card-body">{{ cardParts.body }}</div>
|
class="card-title"
|
||||||
|
:contenteditable="edit"
|
||||||
|
data-edit="content"
|
||||||
|
@blur="edit && onBlurCard($event)"
|
||||||
|
>{{ cardParts.title }}</div>
|
||||||
|
<div
|
||||||
|
class="card-body"
|
||||||
|
:contenteditable="edit"
|
||||||
|
data-edit="content"
|
||||||
|
@blur="edit && onBlurCard($event)"
|
||||||
|
>{{ cardParts.body }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
import { computed, watch, ref } from 'vue'
|
import { computed, watch, ref } from 'vue'
|
||||||
import { store } from '../../core/store'
|
import { store } from '../../core/store'
|
||||||
import { elementTypes } from '../../core/sample'
|
import { elementTypes } from '../../core/sample'
|
||||||
import { generateImage, isImageConfigured } from '../../core/ai'
|
import { generateImage, isImageConfigured, checkImageQuota } from '../../core/ai'
|
||||||
|
import { readAsDataURL } from '../../core/importer'
|
||||||
|
import { appAlert, appConfirm, appPrompt } from '../../core/dialog'
|
||||||
import { markdownToSegments, segmentsToPlain, hasFormatting } from '../../core/richtext'
|
import { markdownToSegments, segmentsToPlain, hasFormatting } from '../../core/richtext'
|
||||||
import AddGrid from './AddGrid.vue'
|
import AddGrid from './AddGrid.vue'
|
||||||
import type { ElementType, ChartType } from '../../core/types'
|
import type { ElementType, ChartType, ShapeType } from '../../core/types'
|
||||||
|
|
||||||
const CHART_TYPES: Array<{ k: ChartType; label: string; icon: string }> = [
|
const CHART_TYPES: Array<{ k: ChartType; label: string; icon: string }> = [
|
||||||
{ k: 'bar', label: '柱状图', icon: '📊' },
|
{ k: 'bar', label: '柱状图', icon: '📊' },
|
||||||
@@ -23,13 +25,14 @@ const CHART_TYPES: Array<{ k: ChartType; label: string; icon: string }> = [
|
|||||||
|
|
||||||
const selected = computed(() => store.getSelected())
|
const selected = computed(() => store.getSelected())
|
||||||
const slide = computed(() => store.currentSlide.value)
|
const slide = computed(() => store.currentSlide.value)
|
||||||
|
const multiCount = computed(() => store.getSelection().length)
|
||||||
|
|
||||||
/** 临时输入态(v-model 绑定) */
|
/** 临时输入态(v-model 绑定) */
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
const fontSize = ref(24)
|
const fontSize = ref(24)
|
||||||
const colorSel = ref('primary')
|
const colorSel = ref('primary')
|
||||||
const colorPicker = ref('#000000')
|
const colorPicker = ref('#000000')
|
||||||
const shape = ref<'rect' | 'circle' | 'triangle'>('rect')
|
const shape = ref<ShapeType>('rect')
|
||||||
const chartType = ref<ChartType>('bar')
|
const chartType = ref<ChartType>('bar')
|
||||||
const codeLang = ref('')
|
const codeLang = ref('')
|
||||||
const imgBusy = ref(false)
|
const imgBusy = ref(false)
|
||||||
@@ -64,11 +67,11 @@ watch(selected, (el) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
const hasText = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula'].includes(selected.value.type))
|
const hasText = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula', 'shape', 'card'].includes(selected.value.type))
|
||||||
const canFont = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula'].includes(selected.value.type))
|
const canFont = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula', 'shape', 'card'].includes(selected.value.type))
|
||||||
const canAlign = computed(() => selected.value && ['title', 'text', 'quote', 'stat', 'table', 'formula'].includes(selected.value.type))
|
const canAlign = computed(() => selected.value && ['title', 'text', 'quote', 'stat', 'table', 'formula', 'shape'].includes(selected.value.type))
|
||||||
const canBI = computed(() => selected.value && ['title', 'text', 'quote', 'list'].includes(selected.value.type))
|
const canBI = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'shape'].includes(selected.value.type))
|
||||||
const canColor = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula'].includes(selected.value.type))
|
const canColor = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula', 'shape'].includes(selected.value.type))
|
||||||
const isShape = computed(() => selected.value?.type === 'shape')
|
const isShape = computed(() => selected.value?.type === 'shape')
|
||||||
|
|
||||||
const typeLabel = computed(() => {
|
const typeLabel = computed(() => {
|
||||||
@@ -80,6 +83,27 @@ function onAdd(type: ElementType) {
|
|||||||
store.addElement(type)
|
store.addElement(type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 多选批量操作 */
|
||||||
|
function onMultiCopy() {
|
||||||
|
const sel = store.getSelection()
|
||||||
|
if (sel.length) store.copyElements(sel)
|
||||||
|
}
|
||||||
|
async function onMultiDel() {
|
||||||
|
const sel = store.getSelection()
|
||||||
|
if (!sel.length) return
|
||||||
|
if (await appConfirm('删除选中的 ' + sel.length + ' 个元素?', '可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
|
||||||
|
store.delElements(sel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onAlignOp(edge: 'left' | 'hcenter' | 'right' | 'top' | 'vmiddle' | 'bottom') {
|
||||||
|
const sel = store.getSelection()
|
||||||
|
if (sel.length > 1) store.alignElements(sel, edge)
|
||||||
|
}
|
||||||
|
function onDistribute(axis: 'h' | 'v') {
|
||||||
|
const sel = store.getSelection()
|
||||||
|
if (sel.length > 2) store.distributeElements(sel, axis)
|
||||||
|
}
|
||||||
|
|
||||||
function onContentInput() {
|
function onContentInput() {
|
||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
store.updateElement(selected.value.id, { content: content.value })
|
store.updateElement(selected.value.id, { content: content.value })
|
||||||
@@ -140,9 +164,12 @@ function onZ(dir: number) {
|
|||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
store.moveElementZ(selected.value.id, dir)
|
store.moveElementZ(selected.value.id, dir)
|
||||||
}
|
}
|
||||||
function onDel() {
|
async function onDel() {
|
||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
store.delElement(selected.value.id)
|
const label = elementTypes[selected.value.type]?.label || '元素'
|
||||||
|
if (await appConfirm('删除这个' + label + '?', '可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
|
||||||
|
store.delElement(selected.value.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function onBg(k: string) {
|
function onBg(k: string) {
|
||||||
store.setSlideBackground(k)
|
store.setSlideBackground(k)
|
||||||
@@ -161,7 +188,7 @@ function applyMarkdown() {
|
|||||||
if (!el) return
|
if (!el) return
|
||||||
const segs = markdownToSegments(content.value)
|
const segs = markdownToSegments(content.value)
|
||||||
if (segs.length && hasFormatting(segs)) {
|
if (segs.length && hasFormatting(segs)) {
|
||||||
store.updateElement(el.id, { segments: segs } as any)
|
store.updateElement(el.id, { segments: segs })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,28 +196,120 @@ function applyMarkdown() {
|
|||||||
function clearRich() {
|
function clearRich() {
|
||||||
const el = selected.value
|
const el = selected.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
store.updateElement(el.id, { segments: undefined } as any)
|
store.updateElement(el.id, { segments: undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- AI 配图 ---------- */
|
/* ---------- AI 配图 / 本地换图 ---------- */
|
||||||
async function onAiImage() {
|
async function onAiImage() {
|
||||||
if (imgBusy.value) return
|
if (imgBusy.value) return
|
||||||
const el = selected.value
|
const el = selected.value
|
||||||
if (!el || el.type !== 'image') return
|
if (!el || el.type !== 'image') return
|
||||||
if (!isImageConfigured()) { alert('请先在「AI 设置」中配置 API Key'); return }
|
if (!isImageConfigured()) { appAlert('需要 API Key', '请先在「AI 设置」中配置'); return }
|
||||||
const promptText = window.prompt('描述你想要的图片,例如「现代办公室协作场景,俯拍,柔和光线」')
|
const promptText = await appPrompt('AI 配图', { placeholder: '描述你想要的图片,如「现代办公室协作场景,俯拍,柔和光线」' })
|
||||||
if (!promptText) return
|
if (!promptText) return
|
||||||
imgBusy.value = true
|
imgBusy.value = true
|
||||||
imgAbort = new AbortController()
|
imgAbort = new AbortController()
|
||||||
try {
|
try {
|
||||||
const r = await generateImage({ prompt: promptText, signal: imgAbort.signal })
|
const r = await generateImage({ prompt: promptText, signal: imgAbort.signal })
|
||||||
|
const quotaErr = checkImageQuota(r.url)
|
||||||
|
if (quotaErr) { appAlert('配图受限', quotaErr); return }
|
||||||
store.updateElement(el.id, { content: r.url })
|
store.updateElement(el.id, { content: r.url })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name !== 'AbortError') alert('配图失败:' + (e?.message || String(e)))
|
if (e?.name !== 'AbortError') appAlert('配图失败', e?.message || String(e))
|
||||||
} finally {
|
} finally {
|
||||||
imgBusy.value = false; imgAbort = null
|
imgBusy.value = false; imgAbort = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 批注(仅图片元素) ---------- */
|
||||||
|
/** 复用文件内颜色选项集合(与颜色 select 的 option values 同款) */
|
||||||
|
const ANNO_COLORS: Array<{ k: string; label: string }> = [
|
||||||
|
{ k: 'primary', label: '主色' },
|
||||||
|
{ k: 'accent', label: '强调色' },
|
||||||
|
{ k: 'text', label: '正文色' },
|
||||||
|
{ k: 'muted', label: '次要色' },
|
||||||
|
{ k: '#ffffff', label: '白色' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 当前图片的批注列表 */
|
||||||
|
const annotations = computed<any[]>(() => selected.value?.style?.annotations || [])
|
||||||
|
|
||||||
|
/** 折叠状态:记录「已展开」的批注 id(默认折叠,仅展开正在编辑的一条) */
|
||||||
|
const expandedAnno = ref<string | null>(null)
|
||||||
|
function toggleAnno(annoId: string) {
|
||||||
|
expandedAnno.value = expandedAnno.value === annoId ? null : annoId
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAnno() {
|
||||||
|
if (!selected.value) return
|
||||||
|
const id = store.addAnnotation(selected.value.id)
|
||||||
|
if (id) expandedAnno.value = id // 新增自动展开
|
||||||
|
}
|
||||||
|
function delAnno(annoId: string) {
|
||||||
|
if (selected.value) store.delAnnotation(selected.value.id, annoId)
|
||||||
|
if (expandedAnno.value === annoId) expandedAnno.value = null
|
||||||
|
}
|
||||||
|
function patchAnno(annoId: string, patch: Record<string, any>) {
|
||||||
|
if (!selected.value) return
|
||||||
|
store.updateAnnotation(selected.value.id, annoId, patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批注文字摘要(折叠态卡片头显示) */
|
||||||
|
function annoSummary(anno: any): string {
|
||||||
|
const t = (anno.text || '').trim()
|
||||||
|
return t ? (t.length > 12 ? t.slice(0, 12) + '…' : t) : '(空批注)'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地上传图片替换当前图片元素内容 */
|
||||||
|
function onLocalImage() {
|
||||||
|
const el = selected.value
|
||||||
|
if (!el || el.type !== 'image') return
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = 'image/*'
|
||||||
|
input.onchange = async () => {
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
const dataUrl = await readAsDataURL(file)
|
||||||
|
const quotaErr = checkImageQuota(dataUrl)
|
||||||
|
if (quotaErr) { appAlert('图片受限', quotaErr); return }
|
||||||
|
store.updateElement(el.id, { content: dataUrl })
|
||||||
|
} catch (e: any) {
|
||||||
|
appAlert('读取图片失败', e?.message || String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 视频元素 ---------- */
|
||||||
|
function onLocalVideo() {
|
||||||
|
const el = selected.value
|
||||||
|
if (!el) return
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = 'video/*'
|
||||||
|
input.onchange = async () => {
|
||||||
|
const f = input.files?.[0]
|
||||||
|
if (!f) return
|
||||||
|
// 注意:大视频会整体读入内存(dataURL),本地工具首版接受
|
||||||
|
const dataUrl = await readAsDataURL(f)
|
||||||
|
store.updateElement(el.id, { content: dataUrl })
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
|
function onVideoUrl(e: Event) {
|
||||||
|
const el = selected.value
|
||||||
|
if (!el) return
|
||||||
|
const v = (e.target as HTMLInputElement).value.trim()
|
||||||
|
if (v) store.updateElement(el.id, { content: v })
|
||||||
|
}
|
||||||
|
function toggleVideoOpt(key: 'autoplay' | 'loop' | 'muted') {
|
||||||
|
const el = selected.value
|
||||||
|
if (!el) return
|
||||||
|
const cur = (el.style as any)[key]
|
||||||
|
store.updateElement(el.id, { style: { [key]: !cur } })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -201,6 +320,42 @@ async function onAiImage() {
|
|||||||
<AddGrid @add="onAdd" />
|
<AddGrid @add="onAdd" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 多选态:批量操作 -->
|
||||||
|
<section v-else-if="multiCount > 1" class="prop-section" id="propMulti">
|
||||||
|
<h4 class="prop-title">多选 · <span>{{ multiCount }} 个元素</span></h4>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>批量操作</label>
|
||||||
|
<div class="seg">
|
||||||
|
<button @click="onMultiCopy" title="Ctrl+C">⧉ 复制</button>
|
||||||
|
<button class="danger" @click="onMultiDel" title="Delete">🗑 删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>对齐</label>
|
||||||
|
<div class="seg">
|
||||||
|
<button @click="onAlignOp('left')" title="左对齐">⇤</button>
|
||||||
|
<button @click="onAlignOp('hcenter')" title="水平居中">↔</button>
|
||||||
|
<button @click="onAlignOp('right')" title="右对齐">⇥</button>
|
||||||
|
<button @click="onAlignOp('top')" title="顶对齐">⇧</button>
|
||||||
|
<button @click="onAlignOp('vmiddle')" title="垂直居中">↕</button>
|
||||||
|
<button @click="onAlignOp('bottom')" title="底对齐">⇩</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>分布</label>
|
||||||
|
<div class="seg">
|
||||||
|
<button :disabled="multiCount < 3" @click="onDistribute('h')" :title="multiCount < 3 ? '需要选中 3 个以上' : '水平等间距'">⋯</button>
|
||||||
|
<button :disabled="multiCount < 3" @click="onDistribute('v')" :title="multiCount < 3 ? '需要选中 3 个以上' : '垂直等间距'">⋮</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>提示</label>
|
||||||
|
<div style="font-size:12px;color:var(--ui-muted);line-height:1.6">
|
||||||
|
拖动整组移动 · Shift+点击加选/减选<br />空白处拖动框选 · Esc 取消多选
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 选中元素 -->
|
<!-- 选中元素 -->
|
||||||
<section v-else class="prop-section" id="propElement">
|
<section v-else class="prop-section" id="propElement">
|
||||||
<h4 class="prop-title">元素 · <span>{{ typeLabel }}</span></h4>
|
<h4 class="prop-title">元素 · <span>{{ typeLabel }}</span></h4>
|
||||||
@@ -208,7 +363,12 @@ async function onAiImage() {
|
|||||||
<!-- 内容 -->
|
<!-- 内容 -->
|
||||||
<div v-if="hasText && selected.type !== 'stat'" class="prop-row">
|
<div v-if="hasText && selected.type !== 'stat'" class="prop-row">
|
||||||
<label>内容</label>
|
<label>内容</label>
|
||||||
<textarea rows="3" placeholder="输入文字(列表用换行分隔)" v-model="content" @input="onContentInput"></textarea>
|
<textarea
|
||||||
|
rows="3"
|
||||||
|
:placeholder="selected.type === 'card' ? '第一行=标题,其余行=正文' : selected.type === 'shape' ? '形状内文字(支持 **加粗** 等 md 语法)' : '输入文字(列表用换行分隔)'"
|
||||||
|
v-model="content"
|
||||||
|
@input="onContentInput"
|
||||||
|
></textarea>
|
||||||
<!-- 富文本格式提示 -->
|
<!-- 富文本格式提示 -->
|
||||||
<div v-if="['title','text','quote','list'].includes(selected.type)" class="rich-hint">
|
<div v-if="['title','text','quote','list'].includes(selected.type)" class="rich-hint">
|
||||||
<div class="rich-syntax">
|
<div class="rich-syntax">
|
||||||
@@ -268,7 +428,15 @@ async function onAiImage() {
|
|||||||
<select v-model="shape" @change="onShapeChange">
|
<select v-model="shape" @change="onShapeChange">
|
||||||
<option value="rect">矩形</option>
|
<option value="rect">矩形</option>
|
||||||
<option value="circle">圆形</option>
|
<option value="circle">圆形</option>
|
||||||
|
<option value="ellipse">椭圆</option>
|
||||||
<option value="triangle">三角</option>
|
<option value="triangle">三角</option>
|
||||||
|
<option value="diamond">菱形</option>
|
||||||
|
<option value="pentagon">五边形</option>
|
||||||
|
<option value="hexagon">六边形</option>
|
||||||
|
<option value="star">五角星</option>
|
||||||
|
<option value="arrow">箭头</option>
|
||||||
|
<option value="chevron">折角</option>
|
||||||
|
<option value="bubble">气泡</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -321,10 +489,38 @@ async function onAiImage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AI 配图(仅图片元素) -->
|
<!-- 图片来源(仅图片元素) -->
|
||||||
<div v-if="selected.type === 'image'" class="prop-row">
|
<div v-if="selected.type === 'image'" class="prop-row">
|
||||||
<label>AI 配图</label>
|
<label>图片来源</label>
|
||||||
<button class="btn" :disabled="imgBusy" @click="onAiImage">{{ imgBusy ? '生成中…' : '🎨 AI 配图' }}</button>
|
<div class="seg">
|
||||||
|
<button @click="onLocalImage" title="从本地选择图片">📁 本地图片</button>
|
||||||
|
<button :disabled="imgBusy" @click="onAiImage">{{ imgBusy ? '生成中…' : '🎨 AI 配图' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视频来源(仅视频元素) -->
|
||||||
|
<div v-if="selected.type === 'video'" class="prop-row">
|
||||||
|
<label>视频来源</label>
|
||||||
|
<div class="seg">
|
||||||
|
<button @click="onLocalVideo" title="从本地选择视频文件">📁 本地视频</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="video-url"
|
||||||
|
placeholder="或粘贴视频 URL (https://…)"
|
||||||
|
style="margin-top:6px;width:100%;box-sizing:border-box"
|
||||||
|
:value="selected.content"
|
||||||
|
@change="onVideoUrl($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- 视频播放选项 -->
|
||||||
|
<div v-if="selected.type === 'video'" class="prop-row">
|
||||||
|
<label>播放选项</label>
|
||||||
|
<div class="seg">
|
||||||
|
<button :class="{ active: !!selected.style.autoplay }" @click="toggleVideoOpt('autoplay')" title="自动播放">自动</button>
|
||||||
|
<button :class="{ active: !!selected.style.loop }" @click="toggleVideoOpt('loop')" title="循环">循环</button>
|
||||||
|
<button :class="{ active: !!selected.style.muted }" @click="toggleVideoOpt('muted')" title="静音">静音</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 层级 -->
|
<!-- 层级 -->
|
||||||
@@ -336,6 +532,108 @@ async function onAiImage() {
|
|||||||
<button class="danger" @click="onDel" title="删除">🗑</button>
|
<button class="danger" @click="onDel" title="删除">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 批注(仅图片元素) -->
|
||||||
|
<div v-if="selected.type === 'image'" class="prop-row">
|
||||||
|
<label>批注</label>
|
||||||
|
<div class="anno-head">
|
||||||
|
<button @click="addAnno" title="添加一条批注">+ 添加批注</button>
|
||||||
|
</div>
|
||||||
|
<div class="anno-list">
|
||||||
|
<div v-for="(anno, i) in annotations" :key="anno.id" class="anno-card" :class="{ open: expandedAnno === anno.id }">
|
||||||
|
<div class="anno-card-head" @click="toggleAnno(anno.id)">
|
||||||
|
<span class="anno-caret">{{ expandedAnno === anno.id ? '▾' : '▸' }}</span>
|
||||||
|
<span class="anno-card-title">批注 {{ i + 1 }}</span>
|
||||||
|
<span v-if="expandedAnno !== anno.id" class="anno-card-summary">{{ annoSummary(anno) }}</span>
|
||||||
|
<button class="anno-del" @click.stop="delAnno(anno.id)" title="删除批注">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="expandedAnno === anno.id" class="anno-body">
|
||||||
|
<textarea
|
||||||
|
class="anno-text"
|
||||||
|
rows="2"
|
||||||
|
placeholder="批注文字"
|
||||||
|
:value="anno.text"
|
||||||
|
@input="patchAnno(anno.id, { text: ($event.target as HTMLTextAreaElement).value })"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<!-- 气泡文字样式 -->
|
||||||
|
<div class="anno-sub">气泡文字</div>
|
||||||
|
<div class="anno-line">
|
||||||
|
<label class="anno-lbl">字号 {{ anno.fontSize || 16 }}px</label>
|
||||||
|
<input
|
||||||
|
type="range" min="12" max="48"
|
||||||
|
:value="anno.fontSize || 16"
|
||||||
|
@input="patchAnno(anno.id, { fontSize: Number(($event.target as HTMLInputElement).value) })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="anno-line">
|
||||||
|
<select
|
||||||
|
:value="anno.color || 'text'"
|
||||||
|
@change="patchAnno(anno.id, { color: ($event.target as HTMLSelectElement).value })"
|
||||||
|
>
|
||||||
|
<option v-for="c in ANNO_COLORS" :key="c.k" :value="c.k">{{ c.label }}</option>
|
||||||
|
</select>
|
||||||
|
<div class="seg">
|
||||||
|
<button :class="{ active: !!anno.bold }" @click="patchAnno(anno.id, { bold: !anno.bold })" title="加粗"><b>B</b></button>
|
||||||
|
<button :class="{ active: !!anno.italic }" @click="patchAnno(anno.id, { italic: !anno.italic })" title="斜体"><i>I</i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="anno-line">
|
||||||
|
<div class="seg">
|
||||||
|
<button :class="{ active: anno.align === 'left' }" @click="patchAnno(anno.id, { align: 'left' })">⬅</button>
|
||||||
|
<button :class="{ active: anno.align === 'center' || !anno.align }" @click="patchAnno(anno.id, { align: 'center' })">⬌</button>
|
||||||
|
<button :class="{ active: anno.align === 'right' }" @click="patchAnno(anno.id, { align: 'right' })">➡</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 连线样式 -->
|
||||||
|
<div class="anno-sub">连线</div>
|
||||||
|
<div class="anno-line">
|
||||||
|
<select
|
||||||
|
:value="anno.line?.style || 'solid'"
|
||||||
|
@change="patchAnno(anno.id, { line: { style: ($event.target as HTMLSelectElement).value } })"
|
||||||
|
>
|
||||||
|
<option value="solid">实线</option>
|
||||||
|
<option value="dashed">虚线</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
:value="anno.line?.color || 'muted'"
|
||||||
|
@change="patchAnno(anno.id, { line: { color: ($event.target as HTMLSelectElement).value } })"
|
||||||
|
>
|
||||||
|
<option v-for="c in ANNO_COLORS" :key="c.k" :value="c.k">{{ c.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="anno-line">
|
||||||
|
<label class="anno-lbl">线宽 {{ anno.line?.width ?? 0.5 }}px</label>
|
||||||
|
<input
|
||||||
|
type="range" min="0.5" max="8" step="0.5"
|
||||||
|
:value="anno.line?.width ?? 0.5"
|
||||||
|
@input="patchAnno(anno.id, { line: { width: Number(($event.target as HTMLInputElement).value) } })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="anno-line">
|
||||||
|
<select
|
||||||
|
:value="anno.line?.startCap || 'none'"
|
||||||
|
@change="patchAnno(anno.id, { line: { startCap: ($event.target as HTMLSelectElement).value } })"
|
||||||
|
>
|
||||||
|
<option value="none">起点·无</option>
|
||||||
|
<option value="arrow">起点·箭头</option>
|
||||||
|
<option value="dot">起点·圆点</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
:value="anno.line?.endCap || 'arrow'"
|
||||||
|
@change="patchAnno(anno.id, { line: { endCap: ($event.target as HTMLSelectElement).value } })"
|
||||||
|
>
|
||||||
|
<option value="none">终点·无</option>
|
||||||
|
<option value="arrow">终点·箭头</option>
|
||||||
|
<option value="dot">终点·圆点</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 当前页背景 -->
|
<!-- 当前页背景 -->
|
||||||
@@ -352,3 +650,126 @@ async function onAiImage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.anno-head {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.anno-head button {
|
||||||
|
flex: 1;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--ui-border, #e2e8f0);
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ui-text, #1e293b);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.anno-head button:hover {
|
||||||
|
background: var(--ui-primary-soft, #eef2ff);
|
||||||
|
border-color: var(--ui-primary, #4f46e5);
|
||||||
|
color: var(--ui-primary, #4f46e5);
|
||||||
|
}
|
||||||
|
.anno-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.anno-card {
|
||||||
|
border: 0.5px solid var(--ui-border, #e2e8f0);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--ui-panel, #f8fafc);
|
||||||
|
}
|
||||||
|
.anno-card.open {
|
||||||
|
border-color: var(--ui-primary, #4f46e5);
|
||||||
|
}
|
||||||
|
.anno-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.anno-card-head:hover {
|
||||||
|
background: var(--ui-primary-soft, #eef2ff);
|
||||||
|
}
|
||||||
|
.anno-caret {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--ui-muted, #64748b);
|
||||||
|
width: 12px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.anno-card-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ui-text, #1e293b);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.anno-card-summary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ui-muted, #64748b);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.anno-del {
|
||||||
|
margin-left: auto;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--ui-border, #e2e8f0);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ui-muted, #64748b);
|
||||||
|
cursor: pointer;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.anno-del:hover {
|
||||||
|
background: #fff1f2;
|
||||||
|
border-color: var(--ui-danger, #ef4444);
|
||||||
|
color: var(--ui-danger, #ef4444);
|
||||||
|
}
|
||||||
|
.anno-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
}
|
||||||
|
.anno-text {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.anno-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ui-muted, #64748b);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.anno-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.anno-line > select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.anno-lbl {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ui-muted, #64748b);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.anno-line > input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { store, resolveBg } from '../../core/store'
|
import { store, resolveBg } from '../../core/store'
|
||||||
|
import { appConfirm } from '../../core/dialog'
|
||||||
import ElementView from './ElementView.vue'
|
import ElementView from './ElementView.vue'
|
||||||
|
|
||||||
const slides = computed(() => store.slides.value)
|
const slides = computed(() => store.slides.value)
|
||||||
@@ -12,9 +13,11 @@ const currentIndex = computed(() => store.currentIndex.value)
|
|||||||
function onClickItem(i: number) {
|
function onClickItem(i: number) {
|
||||||
store.setCurrentIndex(i)
|
store.setCurrentIndex(i)
|
||||||
}
|
}
|
||||||
function onClickDel(i: number, e: Event) {
|
async function onClickDel(i: number, e: Event) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
store.delSlide(i)
|
if (await appConfirm('删除这页幻灯片?', '第 ' + (i + 1) + ' 页,可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
|
||||||
|
store.delSlide(i)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function onClickAdd() {
|
function onClickAdd() {
|
||||||
store.addSlide(store.getCurrentIndex())
|
store.addSlide(store.getCurrentIndex())
|
||||||
@@ -41,6 +44,7 @@ function onClickAdd() {
|
|||||||
:key="el.id"
|
:key="el.id"
|
||||||
:el="el"
|
:el="el"
|
||||||
:bg="slide.background"
|
:bg="slide.background"
|
||||||
|
thumb
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
<!-- =====================================================================
|
<!-- =====================================================================
|
||||||
Toolbar.vue — 顶部工具栏
|
Toolbar.vue — 顶部工具栏
|
||||||
|
信息架构分层:高频创作动作直接暴露;文件域(库/存取/导出)收进「文件」菜单
|
||||||
===================================================================== -->
|
===================================================================== -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { store } from '../../core/store'
|
import { store } from '../../core/store'
|
||||||
import { themes } from '../../core/sample'
|
import { themes } from '../../core/sample'
|
||||||
|
import { appConfirm } from '../../core/dialog'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'present'): void
|
(e: 'present'): void
|
||||||
(e: 'open-library'): void
|
(e: 'open-library'): void
|
||||||
(e: 'open-settings'): void
|
(e: 'open-settings'): void
|
||||||
(e: 'open-ai'): void
|
|
||||||
(e: 'save'): void
|
(e: 'save'): void
|
||||||
(e: 'open-templates'): void
|
(e: 'open-templates'): void
|
||||||
(e: 'export-json'): void
|
(e: 'export-json'): void
|
||||||
(e: 'import-json'): void
|
(e: 'import-json'): void
|
||||||
|
(e: 'import-materials'): void
|
||||||
(e: 'export-pdf'): void
|
(e: 'export-pdf'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -28,26 +30,66 @@ function onThemeChange() {
|
|||||||
store.setTheme(currentTheme.value)
|
store.setTheme(currentTheme.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function action(a: string) {
|
async function action(a: string) {
|
||||||
switch (a) {
|
switch (a) {
|
||||||
case 'add-slide': store.addSlide(store.getCurrentIndex()); break
|
case 'add-slide': store.addSlide(store.getCurrentIndex()); break
|
||||||
case 'dup-slide': store.dupSlide(); break
|
case 'dup-slide': store.dupSlide(); break
|
||||||
case 'del-slide':
|
case 'del-slide':
|
||||||
if (store.delSlide()) { /* ok */ }
|
if (await appConfirm('删除这页幻灯片?', '第 ' + (store.getCurrentIndex() + 1) + ' 页,可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
|
||||||
|
store.delSlide()
|
||||||
|
}
|
||||||
break
|
break
|
||||||
case 'reset':
|
case 'reset':
|
||||||
if (confirm('重置为内置示例?当前编辑内容将丢失(可用 Ctrl+Z 撤销)。')) {
|
if (await appConfirm('重置为内置示例?', '当前编辑内容将丢失(可用 Ctrl+Z 撤销)', { danger: true })) {
|
||||||
store.reset()
|
store.reset()
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'present': emit('present'); break
|
case 'present': emit('present'); break
|
||||||
case 'library': emit('open-library'); break
|
case 'library': emit('open-library'); break
|
||||||
case 'save': emit('save'); break
|
case 'save': emit('save'); break
|
||||||
case 'open-ai': emit('open-ai'); break
|
|
||||||
case 'settings': emit('open-settings'); break
|
case 'settings': emit('open-settings'); break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 文件菜单(低频动作收纳) ---------- */
|
||||||
|
const fileMenuOpen = ref(false)
|
||||||
|
const fileMenuBtn = ref<HTMLElement>()
|
||||||
|
|
||||||
|
function toggleFileMenu() {
|
||||||
|
fileMenuOpen.value = !fileMenuOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileAction(a: string) {
|
||||||
|
fileMenuOpen.value = false
|
||||||
|
switch (a) {
|
||||||
|
case 'library': emit('open-library'); break
|
||||||
|
case 'save': emit('save'); break
|
||||||
|
case 'export-json': emit('export-json'); break
|
||||||
|
case 'import-materials': emit('import-materials'); break
|
||||||
|
case 'import-json': emit('import-json'); break
|
||||||
|
case 'export-pdf': emit('export-pdf'); break
|
||||||
|
case 'reset': action('reset'); break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocClick(e: MouseEvent) {
|
||||||
|
if (fileMenuOpen.value && fileMenuBtn.value && !fileMenuBtn.value.contains(e.target as Node)) {
|
||||||
|
fileMenuOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && fileMenuOpen.value) fileMenuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousedown', onDocClick)
|
||||||
|
document.addEventListener('keydown', onKeydown)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousedown', onDocClick)
|
||||||
|
document.removeEventListener('keydown', onKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
defineProps<{ disabledActions?: string[] }>()
|
defineProps<{ disabledActions?: string[] }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -58,12 +100,17 @@ defineProps<{ disabledActions?: string[] }>()
|
|||||||
<span class="name">u-ppt</span>
|
<span class="name">u-ppt</span>
|
||||||
<span class="sub">在线演示工具</span>
|
<span class="sub">在线演示工具</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tools">
|
<div class="tools">
|
||||||
<button class="btn" data-action="add-slide" title="新建幻灯片" :disabled="disabledActions?.includes('add-slide')" @click="action('add-slide')">+ 幻灯片</button>
|
<!-- 高频:页面操作 -->
|
||||||
|
<button class="btn tool-add" data-action="add-slide" title="新建幻灯片" :disabled="disabledActions?.includes('add-slide')" @click="action('add-slide')">+ 幻灯片</button>
|
||||||
<button class="btn" data-action="templates" title="从模板新建页" @click="emit('open-templates')">📋 模板</button>
|
<button class="btn" data-action="templates" title="从模板新建页" @click="emit('open-templates')">📋 模板</button>
|
||||||
<button class="btn" data-action="dup-slide" title="复制当前页" :disabled="disabledActions?.includes('dup-slide')" @click="action('dup-slide')">⎘ 复制</button>
|
|
||||||
<button class="btn" data-action="del-slide" title="删除当前页" :disabled="disabledActions?.includes('del-slide')" @click="action('del-slide')">🗑 删除</button>
|
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
|
<button class="btn icon-only" data-action="dup-slide" title="复制当前页" :disabled="disabledActions?.includes('dup-slide')" @click="action('dup-slide')">⎘</button>
|
||||||
|
<button class="btn icon-only danger-hover" data-action="del-slide" title="删除当前页" :disabled="disabledActions?.includes('del-slide')" @click="action('del-slide')">🗑</button>
|
||||||
|
<span class="sep"></span>
|
||||||
|
|
||||||
|
<!-- 主题 -->
|
||||||
<label class="theme-select">
|
<label class="theme-select">
|
||||||
主题
|
主题
|
||||||
<select v-model="currentTheme" @change="onThemeChange">
|
<select v-model="currentTheme" @change="onThemeChange">
|
||||||
@@ -71,15 +118,94 @@ defineProps<{ disabledActions?: string[] }>()
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
<button class="btn" data-action="library" title="我的演示文库" @click="action('library')">📁 文库</button>
|
|
||||||
<button class="btn" data-action="save" title="保存到文库" :disabled="disabledActions?.includes('save')" @click="action('save')">💾 保存</button>
|
<!-- 低频:文件域收进菜单 -->
|
||||||
<button class="btn" data-action="export-json" title="导出 JSON" @click="emit('export-json')">📤 导出</button>
|
<div class="file-menu" ref="fileMenuBtn">
|
||||||
<button class="btn" data-action="import-json" title="导入 JSON" @click="emit('import-json')">📥 导入</button>
|
<button class="btn" data-action="file-menu" :class="{ active: fileMenuOpen }" @click="toggleFileMenu">
|
||||||
<button class="btn" data-action="export-pdf" title="打印/导出 PDF" @click="emit('export-pdf')">🖨 PDF</button>
|
🗀 文件 <span class="chevron" :class="{ open: fileMenuOpen }">▾</span>
|
||||||
<button class="btn" data-action="open-ai" title="打开 AI 助手" @click="action('open-ai')">🤖 AI</button>
|
</button>
|
||||||
<button class="btn" data-action="reset" title="重置为内置示例" :disabled="disabledActions?.includes('reset')" @click="action('reset')">⟲ 重置</button>
|
<Transition name="menu-pop">
|
||||||
|
<div v-if="fileMenuOpen" class="file-dropdown" role="menu">
|
||||||
|
<button class="menu-item" role="menuitem" @click="fileAction('library')">
|
||||||
|
<span class="mi-icon">📁</span><span class="mi-label">文库</span><span class="mi-hint">我的演示</span>
|
||||||
|
</button>
|
||||||
|
<button class="menu-item" role="menuitem" :disabled="disabledActions?.includes('save')" @click="fileAction('save')">
|
||||||
|
<span class="mi-icon">💾</span><span class="mi-label">保存到文库</span><span class="mi-hint">已自动暂存</span>
|
||||||
|
</button>
|
||||||
|
<div class="menu-sep"></div>
|
||||||
|
<button class="menu-item" role="menuitem" @click="fileAction('import-materials')">
|
||||||
|
<span class="mi-icon">📂</span><span class="mi-label">导入资料</span><span class="mi-hint">图片 / 文档 / 目录</span>
|
||||||
|
</button>
|
||||||
|
<button class="menu-item" role="menuitem" @click="fileAction('import-json')">
|
||||||
|
<span class="mi-icon">📥</span><span class="mi-label">导入 JSON</span>
|
||||||
|
</button>
|
||||||
|
<button class="menu-item" role="menuitem" @click="fileAction('export-json')">
|
||||||
|
<span class="mi-icon">📤</span><span class="mi-label">导出 JSON</span>
|
||||||
|
</button>
|
||||||
|
<button class="menu-item" role="menuitem" @click="fileAction('export-pdf')">
|
||||||
|
<span class="mi-icon">🖨</span><span class="mi-label">导出 PDF</span><span class="mi-hint">打印 / 另存</span>
|
||||||
|
</button>
|
||||||
|
<div class="menu-sep"></div>
|
||||||
|
<button class="menu-item danger" role="menuitem" :disabled="disabledActions?.includes('reset')" @click="fileAction('reset')">
|
||||||
|
<span class="mi-icon">⟲</span><span class="mi-label">重置为示例</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 终端动作 -->
|
||||||
<button class="btn primary" data-action="present" title="开始演示 (F5)" :disabled="disabledActions?.includes('present')" @click="action('present')">▶ 演示</button>
|
<button class="btn primary" data-action="present" title="开始演示 (F5)" :disabled="disabledActions?.includes('present')" @click="action('present')">▶ 演示</button>
|
||||||
<button class="btn ghost" data-action="settings" title="AI 设置" @click="action('settings')">⚙</button>
|
<button class="btn ghost icon-only" data-action="settings" title="AI 设置" @click="action('settings')">⚙</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 新建幻灯片:创作起点,给主色轻底强调 */
|
||||||
|
.tool-add { background: var(--ui-primary-soft); border-color: transparent; color: var(--ui-primary); font-weight: 500; }
|
||||||
|
.tool-add:hover { background: #e0e7ff; }
|
||||||
|
.tool-add:disabled { opacity: .5; }
|
||||||
|
|
||||||
|
/* 纯图标按钮:删除/复制降为图标,减文字噪音 */
|
||||||
|
.btn.icon-only { width: 36px; padding: 0; justify-content: center; font-size: 16px; }
|
||||||
|
.btn.danger-hover:hover { color: var(--ui-danger); border-color: var(--ui-danger); background: rgba(225, 29, 72, .05); }
|
||||||
|
|
||||||
|
/* ---------- 文件菜单 ---------- */
|
||||||
|
.file-menu { position: relative; display: inline-flex; }
|
||||||
|
.file-menu .btn.active { background: var(--ui-hover); border-color: var(--ui-primary); }
|
||||||
|
.chevron { font-size: 16px; margin-left: 2px; opacity: .6; transition: transform .15s; }
|
||||||
|
.chevron.open { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
.file-dropdown {
|
||||||
|
position: absolute; top: calc(100% + 6px); left: 0;
|
||||||
|
width: max-content; min-width: 216px;
|
||||||
|
background: var(--ui-panel);
|
||||||
|
border: 1px solid var(--ui-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 5px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.menu-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
width: 100%; padding: 7px 10px;
|
||||||
|
border: none; background: transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px; color: var(--ui-text);
|
||||||
|
text-align: left; cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.menu-item:hover { background: var(--ui-hover); }
|
||||||
|
.menu-item:disabled { opacity: .45; cursor: not-allowed; background: transparent; }
|
||||||
|
.menu-item .mi-icon { width: 18px; text-align: center; flex-shrink: 0; }
|
||||||
|
.menu-item .mi-label { flex-shrink: 0; }
|
||||||
|
.menu-item .mi-hint { margin-left: auto; font-size: 11px; color: var(--ui-muted); padding-left: 14px; }
|
||||||
|
.menu-item.danger { color: var(--ui-danger); }
|
||||||
|
.menu-item.danger:hover { background: rgba(225, 29, 72, .06); }
|
||||||
|
.menu-sep { height: 1px; background: var(--ui-border); margin: 5px 8px; }
|
||||||
|
|
||||||
|
/* 弹出动画 */
|
||||||
|
.menu-pop-enter-active { transition: opacity .14s, transform .14s; }
|
||||||
|
.menu-pop-leave-active { transition: opacity .1s, transform .1s; }
|
||||||
|
.menu-pop-enter-from, .menu-pop-leave-to { opacity: 0; transform: translateY(-4px); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,459 @@
|
|||||||
|
<!-- =====================================================================
|
||||||
|
ImportModal.vue — 本地资料导入对话框
|
||||||
|
支持:图片直接插入,文档(PDF/DOCX/MD/TXT)→ LLM 分析生成幻灯片
|
||||||
|
===================================================================== -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { store } from '../../core/store'
|
||||||
|
import {
|
||||||
|
scanFiles, readEntries,
|
||||||
|
aiAnalyzeDocument,
|
||||||
|
fileToImageElement, imagesToSlideElements,
|
||||||
|
fileToVideoElement,
|
||||||
|
ACCEPT_ATTR,
|
||||||
|
type FileEntry, type ImportReport,
|
||||||
|
describeReport
|
||||||
|
} from '../../core/importer'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ visible: boolean }>(), { visible: false })
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
(e: 'toast', msg: string): void
|
||||||
|
(e: 'open-settings'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/* ---------- 状态 ---------- */
|
||||||
|
const activeTab = ref<'files' | 'dir'>('files')
|
||||||
|
const entries = ref<FileEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
const analyzing = ref(false) // AI 分析中
|
||||||
|
const analyzeDone = ref(false) // AI 分析完成
|
||||||
|
const analyzeProgress = ref('') // AI 分析进度文案
|
||||||
|
const analyzeError = ref('') // AI 分析错误
|
||||||
|
const showImages = ref(true)
|
||||||
|
const showVideos = ref(true)
|
||||||
|
const showDocs = ref(true)
|
||||||
|
|
||||||
|
/* ---------- 过滤后的文件 ---------- */
|
||||||
|
const filteredEntries = computed(() => {
|
||||||
|
return entries.value.filter(e => {
|
||||||
|
if (e.kind === 'image' && !showImages.value) return false
|
||||||
|
if (e.kind === 'video' && !showVideos.value) return false
|
||||||
|
if (e.kind === 'document' && !showDocs.value) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageEntries = computed(() => filteredEntries.value.filter(e => e.kind === 'image'))
|
||||||
|
const videoEntries = computed(() => filteredEntries.value.filter(e => e.kind === 'video'))
|
||||||
|
const docEntries = computed(() => filteredEntries.value.filter(e => e.kind === 'document'))
|
||||||
|
const unsupportedCount = computed(() => entries.value.filter(e => e.kind === 'unsupported').length)
|
||||||
|
|
||||||
|
/** 是否有文档需要 AI 分析 */
|
||||||
|
const hasDocs = computed(() => docEntries.value.length > 0)
|
||||||
|
/** AI 是否已配置 */
|
||||||
|
const aiReady = computed(() => !!store.getCfg().key)
|
||||||
|
|
||||||
|
/* ---------- 打开文件选择器 ---------- */
|
||||||
|
let fileInput: HTMLInputElement | null = null
|
||||||
|
let dirInput: HTMLInputElement | null = null
|
||||||
|
|
||||||
|
function openFilePicker() {
|
||||||
|
if (!fileInput) {
|
||||||
|
fileInput = document.createElement('input')
|
||||||
|
fileInput.type = 'file'
|
||||||
|
fileInput.multiple = true
|
||||||
|
fileInput.accept = ACCEPT_ATTR
|
||||||
|
fileInput.onchange = () => handleFiles(fileInput!.files)
|
||||||
|
}
|
||||||
|
fileInput.value = ''
|
||||||
|
fileInput.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDirPicker() {
|
||||||
|
if (!dirInput) {
|
||||||
|
dirInput = document.createElement('input')
|
||||||
|
dirInput.type = 'file'
|
||||||
|
;(dirInput as any).webkitdirectory = true
|
||||||
|
dirInput.onchange = () => handleFiles(dirInput!.files)
|
||||||
|
}
|
||||||
|
dirInput.value = ''
|
||||||
|
dirInput.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 拖放导入(两个 dropzone 通用) ---------- */
|
||||||
|
const dragOver = ref(false)
|
||||||
|
|
||||||
|
function onDropzoneDragOver(e: DragEvent) {
|
||||||
|
if (!e.dataTransfer?.types.includes('Files')) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
|
dragOver.value = true
|
||||||
|
}
|
||||||
|
function onDropzoneDragLeave() {
|
||||||
|
dragOver.value = false
|
||||||
|
}
|
||||||
|
function onDropzoneDrop(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
dragOver.value = false
|
||||||
|
const fl = e.dataTransfer?.files
|
||||||
|
if (fl && fl.length > 0) void handleFiles(fl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供父组件预填拖入的文件(全局拖放 → 打开弹窗并直接进入预览态) */
|
||||||
|
function acceptDroppedFiles(fl: FileList) {
|
||||||
|
void handleFiles(fl)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ acceptDroppedFiles })
|
||||||
|
|
||||||
|
async function handleFiles(fl: FileList | null) {
|
||||||
|
if (!fl || fl.length === 0) return
|
||||||
|
loading.value = true
|
||||||
|
loaded.value = false
|
||||||
|
analyzeDone.value = false
|
||||||
|
analyzeProgress.value = ''
|
||||||
|
analyzeError.value = ''
|
||||||
|
try {
|
||||||
|
const scanned = await scanFiles(fl)
|
||||||
|
entries.value = scanned
|
||||||
|
// 读取文件原始内容
|
||||||
|
if (scanned.some(e => e.kind === 'image' || e.kind === 'video' || e.kind === 'document')) {
|
||||||
|
await readEntries(scanned)
|
||||||
|
}
|
||||||
|
loaded.value = true
|
||||||
|
} catch (e: any) {
|
||||||
|
emit('toast', '读取失败: ' + (e?.message || String(e)))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- AI 分析文档 ---------- */
|
||||||
|
async function runAiAnalysis() {
|
||||||
|
const docs = docEntries.value.filter(d => d.data && !d.slides)
|
||||||
|
if (docs.length === 0) return
|
||||||
|
|
||||||
|
if (!aiReady.value) {
|
||||||
|
analyzeError.value = '请先在 ⚙ 设置中配置 AI API Key'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzing.value = true
|
||||||
|
analyzeProgress.value = ''
|
||||||
|
analyzeError.value = ''
|
||||||
|
let successCount = 0
|
||||||
|
|
||||||
|
for (const doc of docs) {
|
||||||
|
analyzeProgress.value = `正在 AI 分析(${successCount + 1}/${docs.length}):${doc.name}`
|
||||||
|
try {
|
||||||
|
await aiAnalyzeDocument(doc)
|
||||||
|
successCount++
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.message || String(e)
|
||||||
|
doc.error = msg
|
||||||
|
analyzeError.value = `「${doc.name}」分析失败: ${msg}`
|
||||||
|
console.warn(`AI 分析失败 ${doc.name}:`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzing.value = false
|
||||||
|
analyzeProgress.value = ''
|
||||||
|
analyzeDone.value = successCount > 0
|
||||||
|
if (successCount > 0 && !analyzeError.value) {
|
||||||
|
emit('toast', `AI 完成 ${successCount} 个文档分析,共生成 ${docs.reduce((s, d) => s + (d.slides?.length || 0), 0)} 页幻灯片`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 执行导入 ---------- */
|
||||||
|
function doImport() {
|
||||||
|
const images = imageEntries.value.filter(e => e.data)
|
||||||
|
const videos = videoEntries.value.filter(e => e.data)
|
||||||
|
const docs = docEntries.value.filter(e => e.slides && e.slides.length > 0)
|
||||||
|
|
||||||
|
if (images.length === 0 && videos.length === 0 && docs.length === 0) {
|
||||||
|
// 如果有文档但还没 AI 分析,先分析
|
||||||
|
if (docEntries.value.filter(d => d.data && !d.slides).length > 0) {
|
||||||
|
void runAiAnalysis() // fire-and-forget:分析完成后 UI 自动更新按钮
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('toast', '没有可导入的内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let insertedImages = 0
|
||||||
|
let insertedSlides = 0
|
||||||
|
// 整次导入合并为一条 undo 记录
|
||||||
|
const batch = store.beginBatch()
|
||||||
|
|
||||||
|
// 图片体积闸门:localStorage 约 5MB 字符上限,超限导入后刷新会丢图
|
||||||
|
if (images.length > 0) {
|
||||||
|
const imgChars = images.reduce((s, e) => s + (e.data?.length || 0), 0)
|
||||||
|
let deckChars = 0
|
||||||
|
try { deckChars = JSON.stringify(store.getDeck()).length } catch (e) { /* ignore */ }
|
||||||
|
const QUOTA_CHARS = 4_500_000
|
||||||
|
if (deckChars + imgChars > QUOTA_CHARS) {
|
||||||
|
emit('toast', '图片过大:导入后约 ' + ((deckChars + imgChars) / 1048576).toFixed(1) + 'MB,超本地存储上限(约 5MB),刷新可能丢失。请压缩图片、减少数量,或先清理文库/旧图')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 图片 → 当前幻灯片
|
||||||
|
if (images.length > 0) {
|
||||||
|
if (images.length === 1) {
|
||||||
|
const el = fileToImageElement(images[0].file, images[0].data!)
|
||||||
|
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
|
||||||
|
insertedImages = 1
|
||||||
|
} else {
|
||||||
|
const els = imagesToSlideElements(images.map(f => ({ file: f.file, dataUrl: f.data! })))
|
||||||
|
for (const el of els) {
|
||||||
|
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
|
||||||
|
insertedImages++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.5 视频 → 当前幻灯片(每个视频一页,元素铺满合理区域)
|
||||||
|
if (videos.length > 0) {
|
||||||
|
for (const v of videos) {
|
||||||
|
const el = fileToVideoElement(v.file, v.data!)
|
||||||
|
if (videos.length === 1) {
|
||||||
|
store.addElement('video', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
|
||||||
|
insertedImages++
|
||||||
|
} else {
|
||||||
|
// 多视频:一视频一页
|
||||||
|
store.appendSlide({ id: `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, background: 'bg', elements: [el] }, batch)
|
||||||
|
insertedSlides++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. AI 生成的幻灯片 → 追加到 deck
|
||||||
|
if (docs.length > 0) {
|
||||||
|
for (const d of docs) {
|
||||||
|
if (d.slides) {
|
||||||
|
for (const slide of d.slides) {
|
||||||
|
store.appendSlide(slide, batch)
|
||||||
|
insertedSlides++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const report: ImportReport = { images: insertedImages, slides: insertedSlides, files: entries.value.length }
|
||||||
|
emit('toast', describeReport(report))
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 清除 ---------- */
|
||||||
|
function clearFiles() {
|
||||||
|
entries.value = []
|
||||||
|
loaded.value = false
|
||||||
|
analyzeDone.value = false
|
||||||
|
analyzeProgress.value = ''
|
||||||
|
analyzeError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 格式友好文件大小 ---------- */
|
||||||
|
function fmtSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 截取文本预览前一段 */
|
||||||
|
function textPreview(data: string, maxLen = 80): string {
|
||||||
|
return data.replace(/\n/g, ' ').slice(0, maxLen) + (data.length > maxLen ? '…' : '')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="props.visible" class="modal-mask" @click.self="emit('close')">
|
||||||
|
<div class="modal import-modal">
|
||||||
|
<header class="modal-header">
|
||||||
|
<h2>📂 导入本地资料</h2>
|
||||||
|
<button class="close" @click="emit('close')">✕</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ======== 选择区域 ======== -->
|
||||||
|
<div v-if="!loaded" class="import-select">
|
||||||
|
<div class="import-tabs">
|
||||||
|
<button class="tab" :class="{ active: activeTab === 'files' }" @click="activeTab = 'files'">📄 选择文件</button>
|
||||||
|
<button class="tab" :class="{ active: activeTab === 'dir' }" @click="activeTab = 'dir'">📁 读取目录</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="activeTab === 'files'"
|
||||||
|
class="dropzone"
|
||||||
|
:class="{ 'drag-over': dragOver }"
|
||||||
|
@click="openFilePicker"
|
||||||
|
@dragover="onDropzoneDragOver"
|
||||||
|
@dragleave="onDropzoneDragLeave"
|
||||||
|
@drop="onDropzoneDrop"
|
||||||
|
>
|
||||||
|
<div class="dropzone-icon">📄</div>
|
||||||
|
<div class="dropzone-text">
|
||||||
|
<strong>点击选择或拖入文件</strong>
|
||||||
|
<span class="hint">图片直接插入 · 文档(PDF/DOCX/MD/TXT)由 AI 分析生成幻灯片</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="dropzone"
|
||||||
|
:class="{ 'drag-over': dragOver }"
|
||||||
|
@click="openDirPicker"
|
||||||
|
@dragover="onDropzoneDragOver"
|
||||||
|
@dragleave="onDropzoneDragLeave"
|
||||||
|
@drop="onDropzoneDrop"
|
||||||
|
>
|
||||||
|
<div class="dropzone-icon">📁</div>
|
||||||
|
<div class="dropzone-text">
|
||||||
|
<strong>点击选择目录</strong>
|
||||||
|
<span class="hint">读取目录下所有支持的图片和文档,AI 自动分析生成</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">
|
||||||
|
<span class="spin">⟳</span> 正在读取文件...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== 预览区 ======== -->
|
||||||
|
<div v-else class="import-preview">
|
||||||
|
<div class="preview-actions">
|
||||||
|
<span class="file-count">
|
||||||
|
已选 {{ filteredEntries.length }} 个文件
|
||||||
|
<template v-if="unsupportedCount > 0">({{ unsupportedCount }} 个不支持的类型已忽略)</template>
|
||||||
|
</span>
|
||||||
|
<button class="btn-sm" @click="clearFiles">重新选择</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 过滤 -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<label class="chk"><input type="checkbox" v-model="showImages" /> 🖼 图片 ({{ imageEntries.length }})</label>
|
||||||
|
<label class="chk"><input type="checkbox" v-model="showVideos" /> 🎬 视频 ({{ videoEntries.length }})</label>
|
||||||
|
<label class="chk"><input type="checkbox" v-model="showDocs" /> 📄 文档 ({{ docEntries.length }})</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI 分析进度 -->
|
||||||
|
<div v-if="analyzing" class="ai-progress">
|
||||||
|
<span class="spin">⟳</span> {{ analyzeProgress || 'AI 正在分析文档内容,理解语义并生成幻灯片…' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI 分析错误 -->
|
||||||
|
<div v-if="analyzeError && !analyzing" class="ai-error">
|
||||||
|
⚠ {{ analyzeError }}
|
||||||
|
<template v-if="!aiReady">
|
||||||
|
<button class="btn-sm" @click="emit('open-settings')">去配置</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件列表 -->
|
||||||
|
<div class="file-list">
|
||||||
|
<div v-for="(entry, i) in filteredEntries" :key="i" class="file-item" :class="entry.kind">
|
||||||
|
<span class="file-icon">{{ entry.kind === 'image' ? '🖼' : entry.kind === 'video' ? '🎬' : '📄' }}</span>
|
||||||
|
<span class="file-name" :title="entry.name">{{ entry.name }}</span>
|
||||||
|
<span class="file-size">{{ fmtSize(entry.file.size) }}</span>
|
||||||
|
<span class="file-status">
|
||||||
|
<!-- 图片 -->
|
||||||
|
<template v-if="entry.kind === 'image' && entry.data">✅ 图片就绪</template>
|
||||||
|
<!-- 视频 -->
|
||||||
|
<template v-else-if="entry.kind === 'video' && entry.data">✅ 视频就绪</template>
|
||||||
|
<!-- 文档:AI 分析结果 -->
|
||||||
|
<template v-else-if="entry.kind === 'document' && entry.slides">✅ AI → {{ entry.slides.length }} 页</template>
|
||||||
|
<template v-else-if="entry.kind === 'document' && entry.data && analyzeDone">⏳ 待分析</template>
|
||||||
|
<template v-else-if="entry.kind === 'document' && entry.data && analyzing">⟳ AI 分析中</template>
|
||||||
|
<template v-else-if="entry.kind === 'document' && entry.data">📄 {{ textPreview(entry.data) }}</template>
|
||||||
|
<template v-else-if="entry.error">⚠ {{ entry.error }}</template>
|
||||||
|
<template v-else-if="entry.kind === 'unsupported'">⏭ 跳过</template>
|
||||||
|
<template v-else>⏳ 读取中</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文档幻灯片预览 -->
|
||||||
|
<div v-if="docEntries.filter(e => e.slides).length > 0" class="preview-slides">
|
||||||
|
<span class="preview-title">🤖 AI 解析结果</span>
|
||||||
|
<div class="preview-scroll">
|
||||||
|
<div v-for="(entry, i) in docEntries.filter(e => e.slides)" :key="'p' + i" class="preview-item">
|
||||||
|
<strong>{{ entry.name }}</strong>
|
||||||
|
→ {{ entry.slides?.length }} 页幻灯片
|
||||||
|
<span class="preview-titles">
|
||||||
|
{{ entry.slides?.map(s => {
|
||||||
|
const t = s.elements.find(e => e.type === 'title')
|
||||||
|
return t ? t.content.slice(0, 20) : '无标题'
|
||||||
|
}).join(' · ') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="import-actions">
|
||||||
|
<button class="btn primary" @click="doImport" :disabled="filteredEntries.length === 0 || analyzing">
|
||||||
|
{{ hasDocs && !analyzeDone ? '🤖 AI 分析文档并导入' : '✅ 导入以上内容' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn" @click="emit('close')">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.import-modal { max-width: 660px; max-height: 85vh; display: flex; flex-direction: column; }
|
||||||
|
.import-select { padding: 8px 0; }
|
||||||
|
.import-tabs { display: flex; gap: 4px; margin-bottom: 16px; }
|
||||||
|
.import-tabs .tab {
|
||||||
|
flex: 1; padding: 8px 16px; border: 1px solid var(--border, #e2e8f0);
|
||||||
|
background: var(--bg, #fff); border-radius: 8px; cursor: pointer;
|
||||||
|
font-size: 14px; transition: all .15s;
|
||||||
|
}
|
||||||
|
.import-tabs .tab.active { background: var(--primary, #4f46e5); color: #fff; border-color: var(--primary, #4f46e5); }
|
||||||
|
.dropzone {
|
||||||
|
border: 2px dashed var(--border, #cbd5e1); border-radius: 12px; padding: 36px 24px;
|
||||||
|
display: flex; align-items: center; gap: 20px; cursor: pointer; transition: all .2s;
|
||||||
|
background: var(--panel, #f8fafc);
|
||||||
|
}
|
||||||
|
.dropzone:hover { border-color: var(--primary, #4f46e5); background: color-mix(in srgb, var(--primary, #4f46e5) 5%, var(--panel, #f8fafc)); }
|
||||||
|
.dropzone.drag-over { border-color: var(--primary, #4f46e5); background: color-mix(in srgb, var(--primary, #4f46e5) 10%, var(--panel, #f8fafc)); }
|
||||||
|
.dropzone-icon { font-size: 44px; }
|
||||||
|
.dropzone-text { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.dropzone-text strong { font-size: 16px; }
|
||||||
|
.hint { font-size: 13px; color: var(--muted, #64748b); }
|
||||||
|
|
||||||
|
.import-preview { display: flex; flex-direction: column; gap: 8px; overflow: hidden; }
|
||||||
|
.preview-actions { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.file-count { font-size: 14px; color: var(--muted, #64748b); }
|
||||||
|
.btn-sm { padding: 4px 12px; font-size: 13px; border: 1px solid var(--border, #e2e8f0); border-radius: 6px; background: var(--bg, #fff); cursor: pointer; }
|
||||||
|
|
||||||
|
.filter-bar { display: flex; gap: 16px; padding: 4px 0; }
|
||||||
|
.filter-bar .chk { display: flex; align-items: center; gap: 6px; font-size: 14px; cursor: pointer; }
|
||||||
|
|
||||||
|
.ai-progress { padding: 6px 12px; background: rgba(79, 70, 229, 0.06); border-radius: 8px; font-size: 13px; color: var(--primary, #4f46e5); }
|
||||||
|
.ai-error { padding: 6px 12px; background: rgba(239, 68, 68, 0.06); border-radius: 8px; font-size: 13px; color: #dc2626; display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
.file-list { max-height: 200px; overflow-y: auto; border: 1px solid var(--border, #e2e8f0); border-radius: 8px; display: flex; flex-direction: column; }
|
||||||
|
.file-item { display: flex; align-items: center; gap: 8px; padding: 5px 10px; font-size: 13px; border-bottom: 1px solid var(--border, #f1f5f9); }
|
||||||
|
.file-item:last-child { border-bottom: none; }
|
||||||
|
.file-item.document { background: rgba(5, 150, 105, 0.04); }
|
||||||
|
.file-item.unsupported { opacity: 0.5; }
|
||||||
|
.file-icon { font-size: 16px; width: 24px; text-align: center; }
|
||||||
|
.file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.file-size { color: var(--muted, #64748b); min-width: 56px; text-align: right; }
|
||||||
|
.file-status { min-width: 80px; text-align: right; font-size: 12px; }
|
||||||
|
|
||||||
|
.preview-slides { font-size: 13px; }
|
||||||
|
.preview-title { font-weight: 600; display: block; margin-bottom: 4px; }
|
||||||
|
.preview-scroll { max-height: 100px; overflow-y: auto; }
|
||||||
|
.preview-item { padding: 3px 0; }
|
||||||
|
.preview-titles { display: block; font-size: 12px; color: var(--muted, #64748b); margin-top: 1px; }
|
||||||
|
|
||||||
|
.import-actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 8px; border-top: 1px solid var(--border, #e2e8f0); }
|
||||||
|
|
||||||
|
.loading { text-align: center; padding: 24px; color: var(--muted, #64748b); font-size: 14px; }
|
||||||
|
.spin { display: inline-block; animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import type { LibItem, Slide } from '../../core/types'
|
import type { LibItem, Slide } from '../../core/types'
|
||||||
import { store, resolveBg } from '../../core/store'
|
import { store, resolveBg } from '../../core/store'
|
||||||
|
import { appPrompt, appConfirm } from '../../core/dialog'
|
||||||
import ElementView from '../editor/ElementView.vue'
|
import ElementView from '../editor/ElementView.vue'
|
||||||
|
|
||||||
const props = defineProps<{ visible: boolean }>()
|
const props = defineProps<{ visible: boolean }>()
|
||||||
@@ -12,6 +13,7 @@ const emit = defineEmits<{
|
|||||||
(e: 'close'): void
|
(e: 'close'): void
|
||||||
(e: 'toast', msg: string): void
|
(e: 'toast', msg: string): void
|
||||||
(e: 'switch-tab', tab: string): void
|
(e: 'switch-tab', tab: string): void
|
||||||
|
(e: 'open-deck'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const libVersion = ref(0)
|
const libVersion = ref(0)
|
||||||
@@ -77,6 +79,7 @@ function onSave() {
|
|||||||
function onNewBlank() {
|
function onNewBlank() {
|
||||||
store.newBlankDeck()
|
store.newBlankDeck()
|
||||||
emit('switch-tab', 'props')
|
emit('switch-tab', 'props')
|
||||||
|
emit('open-deck')
|
||||||
toast('已新建空白演示,在右侧添加元素')
|
toast('已新建空白演示,在右侧添加元素')
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
@@ -84,13 +87,14 @@ function onNewBlank() {
|
|||||||
function onOpen(id: string) {
|
function onOpen(id: string) {
|
||||||
if (store.loadFromLibrary(id)) {
|
if (store.loadFromLibrary(id)) {
|
||||||
toast('已打开')
|
toast('已打开')
|
||||||
|
emit('open-deck')
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRename(id: string) {
|
async function onRename(id: string) {
|
||||||
const cur = store.getLibrary().find(x => x.id === id)
|
const cur = store.getLibrary().find(x => x.id === id)
|
||||||
const name = prompt('重命名为', cur ? cur.name : '')
|
const name = await appPrompt('重命名', { defaultValue: cur ? cur.name : '' })
|
||||||
if (name != null && name.trim()) {
|
if (name != null && name.trim()) {
|
||||||
store.renameInLibrary(id, name.trim())
|
store.renameInLibrary(id, name.trim())
|
||||||
bump()
|
bump()
|
||||||
@@ -103,8 +107,8 @@ function onDuplicate(id: string) {
|
|||||||
bump()
|
bump()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDelete(id: string) {
|
async function onDelete(id: string) {
|
||||||
if (confirm('删除这份演示?此操作不可撤销。')) {
|
if (await appConfirm('删除这份演示?', '此操作不可撤销', { danger: true, okText: '删除' })) {
|
||||||
store.deleteFromLibrary(id)
|
store.deleteFromLibrary(id)
|
||||||
toast('已删除')
|
toast('已删除')
|
||||||
bump()
|
bump()
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<!-- =====================================================================
|
||||||
|
PrintModal.vue — PDF 导出:全量幻灯片打印预览
|
||||||
|
复用 ElementView 渲染每个元素(与编辑器/演示一致,图表/表格/公式不降级)
|
||||||
|
页面几何:A4 横向满幅(297mm × 209.8mm ≈ 16:9),元素 % 定位原样映射
|
||||||
|
===================================================================== -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { store, resolveBg } from '../../core/store'
|
||||||
|
import ElementView from '../editor/ElementView.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||||
|
|
||||||
|
// 响应式引用:预览随 deck 变化(快照会导致预览与数据脱节)
|
||||||
|
const slides = computed(() => store.getDeck().slides)
|
||||||
|
|
||||||
|
function onPrint() { window.print() }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="print-modal">
|
||||||
|
<!-- 操作栏(打印时不显示) -->
|
||||||
|
<div class="print-bar no-print">
|
||||||
|
<button class="btn primary" @click="onPrint">🖨 打印 / 另存为 PDF</button>
|
||||||
|
<span class="muted">{{ slides.length }} 页 · A4 横向</span>
|
||||||
|
<span class="sep"></span>
|
||||||
|
<button class="btn ghost" @click="emit('close')">✕ 关闭</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 所有幻灯片逐页渲染(复用编辑器元素渲染,几何与编辑画布同构) -->
|
||||||
|
<div
|
||||||
|
v-for="(slide, i) in slides"
|
||||||
|
:key="slide.id"
|
||||||
|
class="print-page"
|
||||||
|
:style="{ background: resolveBg(slide.background) }"
|
||||||
|
>
|
||||||
|
<div class="print-number">{{ i + 1 }}</div>
|
||||||
|
<ElementView
|
||||||
|
v-for="el in slide.elements"
|
||||||
|
:key="el.id"
|
||||||
|
:el="el"
|
||||||
|
:bg="slide.background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 屏幕上的容器 */
|
||||||
|
.print-modal {
|
||||||
|
position: fixed; inset: 0; z-index: 900;
|
||||||
|
background: #e2e8f0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.print-bar {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
/* 打印页:A4 横向满幅 16:9(297 × 209.8mm),元素 % 坐标系与 1280×720 画布同构 */
|
||||||
|
.print-page {
|
||||||
|
width: 297mm; height: 209.8mm;
|
||||||
|
margin: 12mm auto;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 16px rgba(0,0,0,0.1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transform-origin: top left;
|
||||||
|
}
|
||||||
|
.print-number {
|
||||||
|
position: absolute; bottom: 3mm; right: 5mm;
|
||||||
|
font-size: 11px; color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 打印样式 */
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
.print-modal { position: static; background: none; overflow: visible; }
|
||||||
|
.print-page {
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
width: 297mm; height: 209.8mm;
|
||||||
|
break-after: page;
|
||||||
|
/* 确保背景色打印 */
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
/* 最后一页不再分页(避免尾部空白页) */
|
||||||
|
.print-page:last-child { break-after: auto; }
|
||||||
|
/* 入场动画在打印上下文中不可播放,冻结到最终态(防 opacity:0 截帧) */
|
||||||
|
.print-page * { animation: none !important; transition: none !important; }
|
||||||
|
.print-number { bottom: 3mm; right: 5mm; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { store, resolveBg } from '../../core/store'
|
import { store, resolveBg } from '../../core/store'
|
||||||
|
import { appConfirm } from '../../core/dialog'
|
||||||
import type { PageTemplate } from '../../core/types'
|
import type { PageTemplate } from '../../core/types'
|
||||||
import ElementView from '../editor/ElementView.vue'
|
import ElementView from '../editor/ElementView.vue'
|
||||||
|
|
||||||
@@ -21,6 +22,23 @@ const tick = ref(0)
|
|||||||
const templates = computed(() => { tick.value; return store.getTemplates() })
|
const templates = computed(() => { tick.value; return store.getTemplates() })
|
||||||
function bump() { tick.value++ }
|
function bump() { tick.value++ }
|
||||||
|
|
||||||
|
/* ---------- 按分组展示:内置模板分节,自存模板单独一节 ---------- */
|
||||||
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
|
basic: '基础版式', content: '内容版式', data: '数据版式', ending: '收尾版式', user: '我的模板'
|
||||||
|
}
|
||||||
|
const groupedTemplates = computed(() => {
|
||||||
|
const all = templates.value
|
||||||
|
const userTpls = all.filter(t => t.category === 'user')
|
||||||
|
const builtin = all.filter(t => t.category === 'built-in')
|
||||||
|
const groups: Array<{ key: string; label: string; items: typeof all }> = []
|
||||||
|
for (const key of ['basic', 'content', 'data', 'ending']) {
|
||||||
|
const items = builtin.filter(t => (t.group || 'content') === key)
|
||||||
|
if (items.length) groups.push({ key, label: GROUP_LABELS[key], items })
|
||||||
|
}
|
||||||
|
if (userTpls.length) groups.push({ key: 'user', label: GROUP_LABELS.user, items: userTpls })
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
function onSave() {
|
function onSave() {
|
||||||
const v = nameInput.value.trim()
|
const v = nameInput.value.trim()
|
||||||
if (!v) { emit('toast', '请输入模板名'); return }
|
if (!v) { emit('toast', '请输入模板名'); return }
|
||||||
@@ -48,8 +66,8 @@ function onPick(tpl: PageTemplate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 删除自存模板 ---------- */
|
/* ---------- 删除自存模板 ---------- */
|
||||||
function onDelete(tpl: PageTemplate) {
|
async function onDelete(tpl: PageTemplate) {
|
||||||
if (confirm('删除模板「' + tpl.name + '」?')) {
|
if (await appConfirm('删除模板', '「' + tpl.name + '」删除后不可恢复', { danger: true, okText: '删除' })) {
|
||||||
store.deleteTemplate(tpl.id)
|
store.deleteTemplate(tpl.id)
|
||||||
bump()
|
bump()
|
||||||
emit('toast', '已删除')
|
emit('toast', '已删除')
|
||||||
@@ -68,20 +86,25 @@ function onDelete(tpl: PageTemplate) {
|
|||||||
<button class="btn primary" @click="onSave">💾 保存为模板</button>
|
<button class="btn primary" @click="onSave">💾 保存为模板</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模板网格 -->
|
<!-- 模板网格(按分组分节) -->
|
||||||
<div class="tpl-grid">
|
<div class="tpl-groups">
|
||||||
<div v-for="tpl in templates" :key="tpl.id" class="tpl-card" :class="tpl.category" @click="onPick(tpl)">
|
<section v-for="g in groupedTemplates" :key="g.key" class="tpl-group">
|
||||||
<div class="tpl-thumb" :style="{ background: resolveBg(tpl.background) }">
|
<h4 class="tpl-group-title">{{ g.label }} <span class="tpl-group-count">{{ g.items.length }}</span></h4>
|
||||||
<div class="tpl-thumb-layer">
|
<div class="tpl-grid">
|
||||||
<ElementView v-for="el in tpl.elements" :key="el.id" :el="el" :bg="tpl.background" />
|
<div v-for="tpl in g.items" :key="tpl.id" class="tpl-card" :class="tpl.category" @click="onPick(tpl)">
|
||||||
|
<div class="tpl-thumb" :style="{ background: resolveBg(tpl.background) }">
|
||||||
|
<div class="tpl-thumb-layer">
|
||||||
|
<ElementView v-for="el in tpl.elements" :key="el.id" :el="el" :bg="tpl.background" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tpl-info">
|
||||||
|
<span class="tpl-name">{{ tpl.name }}</span>
|
||||||
|
<span class="tpl-badge" :class="tpl.category">{{ tpl.category === 'built-in' ? '内置' : '自存' }}</span>
|
||||||
|
<button v-if="tpl.category === 'user'" class="tpl-del" title="删除" @click.stop="onDelete(tpl)">🗑</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tpl-info">
|
</section>
|
||||||
<span class="tpl-name">{{ tpl.name }}</span>
|
|
||||||
<span class="tpl-badge" :class="tpl.category">{{ tpl.category === 'built-in' ? '内置' : '自存' }}</span>
|
|
||||||
<button v-if="tpl.category === 'user'" class="tpl-del" title="删除" @click.stop="onDelete(tpl)">🗑</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
@@ -110,25 +133,54 @@ function onDelete(tpl: PageTemplate) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tpl-grid {
|
.tpl-groups {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 1em;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: .2em;
|
padding: .2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tpl-group {
|
||||||
|
margin-bottom: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-group-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ui-muted, #64748b);
|
||||||
|
margin: .6em 0 .6em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-group-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
background: rgba(100, 116, 139, .12);
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片宽 ≈ (720 - 48 padding - 2×12 gap) / 3 ≈ 212px → scale = 212/1280 ≈ 0.166 */
|
||||||
.tpl-card {
|
.tpl-card {
|
||||||
|
--tpl-scale: 0.166;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 2px solid rgba(100, 116, 139, .2);
|
border: 1px solid var(--ui-border, #e2e8f0);
|
||||||
transition: border-color .15s, transform .15s;
|
box-shadow: 0 1px 3px rgba(15, 23, 42, .06);
|
||||||
|
transition: border-color .15s, transform .15s, box-shadow .15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tpl-card:hover {
|
.tpl-card:hover {
|
||||||
border-color: var(--primary, #4f46e5);
|
border-color: var(--ui-primary, #4f46e5);
|
||||||
|
box-shadow: 0 4px 12px rgba(15, 23, 42, .12);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +197,7 @@ function onDelete(tpl: PageTemplate) {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 1280px;
|
width: 1280px;
|
||||||
height: 720px;
|
height: 720px;
|
||||||
transform: scale(0.094); /* 约 120px 宽 */
|
transform: scale(var(--tpl-scale));
|
||||||
transform-origin: top left;
|
transform-origin: top left;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -154,25 +206,29 @@ function onDelete(tpl: PageTemplate) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .4em;
|
gap: .4em;
|
||||||
padding: .5em .6em;
|
padding: .45em .6em;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tpl-name {
|
.tpl-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tpl-badge {
|
.tpl-badge {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
background: rgba(100, 116, 139, .15);
|
background: rgba(100, 116, 139, .15);
|
||||||
color: var(--muted, #64748b);
|
color: var(--ui-muted, #64748b);
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tpl-badge.user {
|
.tpl-badge.user {
|
||||||
background: rgba(79, 70, 229, .12);
|
background: var(--ui-primary-soft, #eef2ff);
|
||||||
color: var(--primary, #4f46e5);
|
color: var(--ui-primary, #4f46e5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tpl-del {
|
.tpl-del {
|
||||||
@@ -180,10 +236,13 @@ function onDelete(tpl: PageTemplate) {
|
|||||||
opacity: .5;
|
opacity: .5;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tpl-del:hover {
|
.tpl-del:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
color: #e11d48;
|
color: var(--ui-danger, #e11d48);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
/* =====================================================================
|
/* =====================================================================
|
||||||
* useEditor.ts — 画布拖拽/缩放/选中逻辑(从 editor.js 提取)
|
* useEditor.ts — 画布拖拽/缩放/选中/框选逻辑
|
||||||
* 用 Vue ref 暴露拖拽会话状态,供 Canvas.vue 响应式绑定
|
* 用 Vue ref 暴露拖拽会话状态,供 Canvas.vue 响应式绑定
|
||||||
* ===================================================================== */
|
* ===================================================================== */
|
||||||
import { ref, type Ref } from 'vue'
|
import { ref, type Ref } from 'vue'
|
||||||
import { store } from '../core/store'
|
import { store } from '../core/store'
|
||||||
|
|
||||||
interface DragSession {
|
interface DragSession {
|
||||||
mode: 'move' | 'resize'
|
mode: 'move' | 'resize' | 'multi-move'
|
||||||
id: string
|
id: string
|
||||||
|
ids?: string[] // multi-move 的元素集
|
||||||
ax?: number // resize 水平轴:-1/0/1
|
ax?: number // resize 水平轴:-1/0/1
|
||||||
ay?: number // resize 垂直轴:-1/0/1
|
ay?: number // resize 垂直轴:-1/0/1
|
||||||
startX: number
|
startX: number
|
||||||
@@ -19,21 +20,44 @@ interface DragSession {
|
|||||||
rectW: number
|
rectW: number
|
||||||
rectH: number
|
rectH: number
|
||||||
result?: { x: number; y: number; w: number; h: number }
|
result?: { x: number; y: number; w: number; h: number }
|
||||||
|
multiStart?: Array<{ id: string; x: number; y: number; w: number; h: number }> // multi-move 起点快照
|
||||||
|
multiResult?: Array<{ id: string; x: number; y: number }> // multi-move 各元素目标位
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 框选会话(空白处按下拖动 → marquee) */
|
||||||
|
interface MarqueeSession {
|
||||||
|
x0: number; y0: number; x1: number; y1: number // 百分比坐标
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEditor() {
|
export function useEditor() {
|
||||||
const drag: Ref<DragSession | null> = ref(null)
|
const drag: Ref<DragSession | null> = ref(null)
|
||||||
|
const marquee: Ref<MarqueeSession | null> = ref(null)
|
||||||
const editing = ref(false) // 是否在 contenteditable 编辑中(暂停拖拽)
|
const editing = ref(false) // 是否在 contenteditable 编辑中(暂停拖拽)
|
||||||
|
|
||||||
/** 鼠标按下:空白取消选中 / 手柄缩放 / 元素拖拽 */
|
/** 鼠标按下:空白→框选起点 / 手柄缩放 / 元素拖拽(Shift 加选) */
|
||||||
function onCanvasMouseDown(e: MouseEvent, canvasEl: HTMLElement) {
|
function onCanvasMouseDown(e: MouseEvent, canvasEl: HTMLElement) {
|
||||||
if (editing.value) return
|
if (editing.value) return
|
||||||
|
if (e.button !== 0) return // 仅左键
|
||||||
const target = (e.target as HTMLElement).closest('.el') as HTMLElement | null
|
const target = (e.target as HTMLElement).closest('.el') as HTMLElement | null
|
||||||
const handle = (e.target as HTMLElement).classList && (e.target as HTMLElement).classList.contains('handle')
|
const handle = (e.target as HTMLElement).classList && (e.target as HTMLElement).classList.contains('handle')
|
||||||
? (e.target as HTMLElement) : null
|
? (e.target as HTMLElement) : null
|
||||||
|
|
||||||
if (!target && !handle) {
|
if (!target && !handle) {
|
||||||
|
const sel = store.getSelection()
|
||||||
|
if (!e.shiftKey && sel.length > 1) {
|
||||||
|
store.selectElement(null) // 多选态点空白:先只清多选
|
||||||
|
return
|
||||||
|
}
|
||||||
store.selectElement(null)
|
store.selectElement(null)
|
||||||
|
// 启动框选
|
||||||
|
const rect = canvasEl.getBoundingClientRect()
|
||||||
|
marquee.value = {
|
||||||
|
x0: (e.clientX - rect.left) / rect.width * 100,
|
||||||
|
y0: (e.clientY - rect.top) / rect.height * 100,
|
||||||
|
x1: (e.clientX - rect.left) / rect.width * 100,
|
||||||
|
y1: (e.clientY - rect.top) / rect.height * 100
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (handle) {
|
if (handle) {
|
||||||
@@ -43,7 +67,19 @@ export function useEditor() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const id = target!.dataset.id!
|
const id = target!.dataset.id!
|
||||||
startDrag(id, e, canvasEl)
|
// Shift+点击:加选/减选,不进入拖拽
|
||||||
|
if (e.shiftKey) {
|
||||||
|
store.toggleSelect(id)
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 点击已选多选集中的元素 → 拖动整组;否则正常单选拖动
|
||||||
|
const sel = store.getSelection()
|
||||||
|
if (sel.length > 1 && sel.includes(id)) {
|
||||||
|
startMultiDrag(sel, e, canvasEl)
|
||||||
|
} else {
|
||||||
|
startDrag(id, e, canvasEl)
|
||||||
|
}
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +94,23 @@ export function useEditor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 多选整组拖动:记录各元素起始位置(multiResult 始终 = 起点 + 累计位移,不从上次结果累加) */
|
||||||
|
function startMultiDrag(ids: string[], e: MouseEvent, canvasEl: HTMLElement) {
|
||||||
|
const starts = ids
|
||||||
|
.map(id => store.findElement(id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(el => ({ id: el!.id, x: el!.x, y: el!.y, w: el!.w, h: el!.h }))
|
||||||
|
if (!starts.length) return
|
||||||
|
const rect = canvasEl.getBoundingClientRect()
|
||||||
|
drag.value = {
|
||||||
|
mode: 'multi-move', id: ids[ids.length - 1], ids,
|
||||||
|
startX: 0, startY: 0, startW: 0, startH: 0,
|
||||||
|
px0: e.clientX, py0: e.clientY, rectW: rect.width, rectH: rect.height,
|
||||||
|
multiStart: starts.map(s => ({ id: s.id, x: s.x, y: s.y, w: s.w, h: s.h })),
|
||||||
|
multiResult: starts.map(s => ({ id: s.id, x: s.x, y: s.y }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const AXES: Record<string, [number, number]> = {
|
const AXES: Record<string, [number, number]> = {
|
||||||
tl: [-1, -1], tm: [0, -1], tr: [1, -1],
|
tl: [-1, -1], tm: [0, -1], tr: [1, -1],
|
||||||
lm: [-1, 0], rm: [1, 0],
|
lm: [-1, 0], rm: [1, 0],
|
||||||
@@ -76,10 +129,35 @@ export function useEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onMouseMove(e: MouseEvent) {
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
// 框选拖动:更新 marquee 矩形(Canvas 渲染选框)
|
||||||
|
const m = marquee.value
|
||||||
|
if (m) {
|
||||||
|
const canvas = document.querySelector('.canvas') as HTMLElement | null
|
||||||
|
if (canvas) {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
m.x1 = Math.max(0, Math.min(100, (e.clientX - rect.left) / rect.width * 100))
|
||||||
|
m.y1 = Math.max(0, Math.min(100, (e.clientY - rect.top) / rect.height * 100))
|
||||||
|
marquee.value = { ...m }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const d = drag.value
|
const d = drag.value
|
||||||
if (!d) return
|
if (!d) return
|
||||||
const dx = (e.clientX - d.px0) / d.rectW * 100
|
const dx = (e.clientX - d.px0) / d.rectW * 100
|
||||||
const dy = (e.clientY - d.py0) / d.rectH * 100
|
const dy = (e.clientY - d.py0) / d.rectH * 100
|
||||||
|
|
||||||
|
if (d.mode === 'multi-move' && d.multiStart) {
|
||||||
|
// 关键:基于「起点 + 累计位移」计算,而非在上次结果上累加(否则位移重复叠加)
|
||||||
|
d.multiResult = d.multiStart.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
x: Math.max(0, Math.min(100 - s.w, s.x + dx)),
|
||||||
|
y: Math.max(0, Math.min(100 - s.h, s.y + dy))
|
||||||
|
}))
|
||||||
|
drag.value = { ...d }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let nx = d.startX, ny = d.startY, nw = d.startW, nh = d.startH
|
let nx = d.startX, ny = d.startY, nw = d.startW, nh = d.startH
|
||||||
|
|
||||||
if (d.mode === 'move') {
|
if (d.mode === 'move') {
|
||||||
@@ -96,11 +174,45 @@ export function useEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onMouseUp() {
|
function onMouseUp() {
|
||||||
|
// 结束框选:计算与 marquee 相交的元素 → 多选
|
||||||
|
const m = marquee.value
|
||||||
|
if (m) {
|
||||||
|
marquee.value = null
|
||||||
|
const left = Math.min(m.x0, m.x1), right = Math.max(m.x0, m.x1)
|
||||||
|
const top = Math.min(m.y0, m.y1), bottom = Math.max(m.y0, m.y1)
|
||||||
|
// 过小的框(<1%)视为点击空白,仅清选
|
||||||
|
if (right - left > 1 || bottom - top > 1) {
|
||||||
|
const s = store.currentSlide.value
|
||||||
|
const hits = (s?.elements || [])
|
||||||
|
.filter(el => {
|
||||||
|
const ex = el.x, ey = el.y, ew = el.x + el.w, eh = el.y + el.h
|
||||||
|
return ex < right && ew > left && ey < bottom && eh > top // 矩形相交
|
||||||
|
})
|
||||||
|
.map(el => el.id)
|
||||||
|
if (hits.length) store.selectMany(hits)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const d = drag.value
|
const d = drag.value
|
||||||
if (!d) return
|
if (!d) return
|
||||||
const r = d.result
|
const r = d.result
|
||||||
|
const mr = d.multiResult
|
||||||
const id = d.id
|
const id = d.id
|
||||||
drag.value = null
|
drag.value = null
|
||||||
|
if (d.mode === 'multi-move' && mr) {
|
||||||
|
// 多选位移落盘(相对起点有变化的才提交)
|
||||||
|
const moved = mr.filter(r2 => {
|
||||||
|
const el = store.findElement(r2.id)
|
||||||
|
return el && (el.x !== r2.x || el.y !== r2.y)
|
||||||
|
})
|
||||||
|
if (moved.length) {
|
||||||
|
const group = store.beginBatch()
|
||||||
|
for (const t of moved) store.updateElement(t.id, { x: t.x, y: t.y })
|
||||||
|
void group
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (r) store.updateElement(id, r)
|
if (r) store.updateElement(id, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +220,7 @@ export function useEditor() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
drag,
|
drag,
|
||||||
|
marquee,
|
||||||
editing,
|
editing,
|
||||||
onCanvasMouseDown,
|
onCanvasMouseDown,
|
||||||
onMouseMove,
|
onMouseMove,
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
/* =====================================================================
|
|
||||||
* useStore.ts — 统一 Store 接口(兼容 Tauri 和 Web 双模式)
|
|
||||||
*
|
|
||||||
* 设计:
|
|
||||||
* - 检测 __TAURI__ 环境,决定走 invoke 还是直接调 TS store
|
|
||||||
* - 暴露与现有 store 相同的方法签名(88 处组件调用零改动)
|
|
||||||
* - 返回 reactive ref(与 Vue 响应式兼容)
|
|
||||||
*
|
|
||||||
* Web 模式:直接 return store(现有行为,零改动)
|
|
||||||
* Tauri 模式:返回封装对象,内部 invoke Rust 命令
|
|
||||||
* ===================================================================== */
|
|
||||||
import type { Deck, Slide, SlideElement, LibItem, PageTemplate, ChatMessage, AiCfg } from '../core/types'
|
|
||||||
import { store as tsStore } from '../core/store'
|
|
||||||
import { invoke } from '../core/bridge'
|
|
||||||
|
|
||||||
/** 是否运行在 Tauri 桌面环境中 */
|
|
||||||
export const isTauri = typeof window !== 'undefined' && '__TAURI__' in window
|
|
||||||
|
|
||||||
/** 根据环境创建 store 接口 */
|
|
||||||
function createStore() {
|
|
||||||
// Web 模式:直接返回现有 TS store
|
|
||||||
if (!isTauri) return tsStore
|
|
||||||
|
|
||||||
// Tauri 模式:封装 invoke 调用
|
|
||||||
// 注意:所有写操作通过 exec_op 统一走 Op Log
|
|
||||||
const CLIENT_ID = 'client-' + Math.random().toString(36).slice(2, 10)
|
|
||||||
|
|
||||||
/** 生成一个 Op 并发送到 Rust 后端 */
|
|
||||||
async function execOp(type: string, payload: Record<string, unknown>) {
|
|
||||||
try {
|
|
||||||
const result = await invoke<{ success: boolean; deck: Deck; error?: string }>('exec_op', {
|
|
||||||
opJson: { type, clientId: CLIENT_ID, timestamp: Date.now(), ...payload }
|
|
||||||
})
|
|
||||||
if (result?.deck) {
|
|
||||||
// 更新本地 ref 状态(由 TauriBridge watcher 处理)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('exec_op 失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 返回与 TS store 类型兼容的对象
|
|
||||||
// 关键属性通过 computed ref 暴露
|
|
||||||
return {
|
|
||||||
...tsStore,
|
|
||||||
// 读操作暂时委托给 TS store(未来Rust端会emit状态同步)
|
|
||||||
// 写操作改为 invoke
|
|
||||||
setTheme: async (name: string) => {
|
|
||||||
await execOp('set_theme', { theme: name })
|
|
||||||
},
|
|
||||||
addSlide: async (atIndex?: number) => {
|
|
||||||
const slide: Slide = { id: 's-' + Date.now(), background: 'bg', elements: [] }
|
|
||||||
const idx = atIndex != null ? atIndex + 1 : tsStore.getCount()
|
|
||||||
await execOp('add_slide', { atIndex: idx, slide })
|
|
||||||
tsStore.addSlide(atIndex)
|
|
||||||
},
|
|
||||||
updateElement: async (id: string, patch: Record<string, unknown>) => {
|
|
||||||
await execOp('update_element', { slideIdx: tsStore.getCurrentIndex(), elementId: id, patch })
|
|
||||||
},
|
|
||||||
delElement: async (id: string) => {
|
|
||||||
const el = tsStore.findElement(id)
|
|
||||||
await execOp('del_element', { slideIdx: tsStore.getCurrentIndex(), elementId: id, deletedElement: el ? { ...el } : undefined })
|
|
||||||
},
|
|
||||||
undo: async () => {
|
|
||||||
await invoke('undo')
|
|
||||||
},
|
|
||||||
redo: async () => {
|
|
||||||
await invoke('redo')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const store = createStore()
|
|
||||||
+210
-45
@@ -10,9 +10,10 @@ import type { AiOp, ChartItem, Deck, Slide, SlideElement, ElementStyle, RichLine
|
|||||||
import { elementTypes, uid } from './sample'
|
import { elementTypes, uid } from './sample'
|
||||||
import { store } from './store'
|
import { store } from './store'
|
||||||
import { normSegments } from './richtext'
|
import { normSegments } from './richtext'
|
||||||
|
import { isTauri, aiProxy, aiProxyStream } from './bridge'
|
||||||
|
|
||||||
const SEP = '%%PPT_JSON%%' // 对话模式中,自然语言回复与结构化操作的分隔标记
|
const SEP = '%%PPT_JSON%%' // 对话模式中,自然语言回复与结构化操作的分隔标记
|
||||||
const VALID_TYPES = ['title', 'text', 'list', 'stat', 'quote', 'image', 'shape', 'chart', 'card', 'table', 'code', 'formula'] as const
|
const VALID_TYPES = ['title', 'text', 'list', 'stat', 'quote', 'image', 'video', 'shape', 'chart', 'card', 'table', 'code', 'formula'] as const
|
||||||
|
|
||||||
/* 智谱/通用错误码 → 可操作提示 */
|
/* 智谱/通用错误码 → 可操作提示 */
|
||||||
const ERROR_HINTS: Record<string, string> = {
|
const ERROR_HINTS: Record<string, string> = {
|
||||||
@@ -39,12 +40,12 @@ const SYS_BASE =
|
|||||||
'幻灯片模型:\n' +
|
'幻灯片模型:\n' +
|
||||||
'{ "slides": [ { "background": "bg|panel|primary|accent|g-primary|g-deep|g-soft", "elements": [ 元素, ... ] } ] }\n' +
|
'{ "slides": [ { "background": "bg|panel|primary|accent|g-primary|g-deep|g-soft", "elements": [ 元素, ... ] } ] }\n' +
|
||||||
'背景:bg/panel=浅底;primary/accent=纯色深底;g-primary=主→强调渐变(深);g-deep=深色渐变;g-soft=浅色渐变。\n\n' +
|
'背景:bg/panel=浅底;primary/accent=纯色深底;g-primary=主→强调渐变(深);g-deep=深色渐变;g-soft=浅色渐变。\n\n' +
|
||||||
'元素类型 type:title 标题 | text 正文 | list 列表 | stat 数据 | quote 金句 | image 图片 | shape 形状 | chart 图表 | card 卡片 | table 表格 | code 代码 | formula 公式\n' +
|
'元素类型 type:title 标题 | text 正文 | list 列表 | stat 数据 | quote 金句 | image 图片 | video 视频 | shape 形状 | chart 图表 | card 卡片 | table 表格 | code 代码 | formula 公式\n' +
|
||||||
'元素:{ "type":..., "x":数字,"y":数字,"w":数字,"h":数字 (0-100), "content":字符串, "style":{...} }\n' +
|
'元素:{ "type":..., "x":数字,"y":数字,"w":数字,"h":数字 (0-100), "content":字符串, "style":{...} }\n' +
|
||||||
' - title/text/list/quote:content 为文字,list 用 \\n 分多行\n' +
|
' - title/text/list/quote:content 为文字,list 用 \\n 分多行\n' +
|
||||||
' - stat:content 为大数字(如 "65%"),style.label 为说明\n' +
|
' - stat:content 为大数字(如 "65%"),style.label 为说明\n' +
|
||||||
' - card:content 第一行=标题、其余行=正文;style.accent=顶部色条键,style.icon=emoji 图标\n' +
|
' - card:content 第一行=标题、其余行=正文;style.accent=顶部色条键,style.icon=emoji 图标\n' +
|
||||||
' - shape:style.shapeType=rect|circle|triangle,style.fill=颜色键,style.gradient=true 渐变,style.opacity=0~1\n' +
|
' - shape:style.shapeType=rect|circle|ellipse|triangle|diamond|pentagon|hexagon|star|arrow|chevron|bubble,style.fill=颜色键,style.gradient=true 渐变,style.opacity=0~1\n' +
|
||||||
' - chart:content 为 JSON,两种格式:\n' +
|
' - chart:content 为 JSON,两种格式:\n' +
|
||||||
' 单系列:[{"label":"","value":数字}, ...]\n' +
|
' 单系列:[{"label":"","value":数字}, ...]\n' +
|
||||||
' 多系列:{"series":["Q1","Q2"], "items":[{"label":"华东","values":[120,150]}, ...]}\n' +
|
' 多系列:{"series":["Q1","Q2"], "items":[{"label":"华东","values":[120,150]}, ...]}\n' +
|
||||||
@@ -55,7 +56,8 @@ const SYS_BASE =
|
|||||||
' - table:content 为 Markdown 管道表格字符串(首行表头,用 | 分列,换行分行),style.header=true 首行加粗\n' +
|
' - table:content 为 Markdown 管道表格字符串(首行表头,用 | 分列,换行分行),style.header=true 首行加粗\n' +
|
||||||
' - code:content 为代码文本(保留符原样,不要转义),style.lang=语言如 js/python(可选)\n' +
|
' - code:content 为代码文本(保留符原样,不要转义),style.lang=语言如 js/python(可选)\n' +
|
||||||
' - formula:content 为 LaTeX 公式字符串(不含 $$ 分隔符),如 "E = mc^2"、"\\sum_{i=1}^n x_i"\n' +
|
' - formula:content 为 LaTeX 公式字符串(不含 $$ 分隔符),如 "E = mc^2"、"\\sum_{i=1}^n x_i"\n' +
|
||||||
' - image:content 留空\n\n' +
|
' - image:content 留空\n' +
|
||||||
|
' - video:content 填视频 URL\n\n' +
|
||||||
'可选 segments 字段(结构化富文本,同一行内不同片段可有不同样式):\n' +
|
'可选 segments 字段(结构化富文本,同一行内不同片段可有不同样式):\n' +
|
||||||
' segments: [[ {"text":"华东 "}, {"text":"增长 23%","bold":true,"color":"accent"} ], ...]\n' +
|
' segments: [[ {"text":"华东 "}, {"text":"增长 23%","bold":true,"color":"accent"} ], ...]\n' +
|
||||||
' 每个 segment 支持:bold/italic/underline/strike/color(主题键或#hex)/highlight(黄底)/code/sup/sub/fontSize/link\n' +
|
' 每个 segment 支持:bold/italic/underline/strike/color(主题键或#hex)/highlight(黄底)/code/sup/sub/fontSize/link\n' +
|
||||||
@@ -103,20 +105,37 @@ const SYS_CHAT =
|
|||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
function deckContext(currentIdx: number, selectedEl?: SlideElement | null): string {
|
function deckContext(currentIdx: number, selectedEl?: SlideElement | null): string {
|
||||||
const deck: Deck = store.getDeck()
|
const deck: Deck = store.getDeck()
|
||||||
|
/** 图片/超长 content 脱敏:base64 会撑爆上下文,替换为占位说明 */
|
||||||
|
const sanitizeEl = (el: SlideElement): SlideElement => {
|
||||||
|
if (el.type === 'image' || el.type === 'video') {
|
||||||
|
const src = el.content || ''
|
||||||
|
const label = el.type === 'video' ? '视频' : '图片'
|
||||||
|
const desc = src.startsWith('data:')
|
||||||
|
? `[${label} base64 ${Math.round(src.length / 1024)}KB]`
|
||||||
|
: (src ? `[${label}URL: ${src.slice(0, 80)}${src.length > 80 ? '…' : ''}]` : `[空${label}]`)
|
||||||
|
return { ...el, content: desc }
|
||||||
|
}
|
||||||
|
if (el.content && el.content.length > 2000) {
|
||||||
|
return { ...el, content: el.content.slice(0, 2000) + `…[截断,共${el.content.length}字符]` }
|
||||||
|
}
|
||||||
|
return el
|
||||||
|
}
|
||||||
const lines = ['当前主题: ' + deck.theme + ',共 ' + deck.slides.length + ' 页。']
|
const lines = ['当前主题: ' + deck.theme + ',共 ' + deck.slides.length + ' 页。']
|
||||||
deck.slides.forEach((s, i) => {
|
deck.slides.forEach((s, i) => {
|
||||||
const types = s.elements.map(e => e.type).join('/')
|
const types = s.elements.map(e => e.type).join('/')
|
||||||
const head = (s.elements[0] && s.elements[0].type === 'title') ? (' 标题:"' + (s.elements[0].content || '') + '"') : ''
|
const head = (s.elements[0] && s.elements[0].type === 'title') ? (' 标题:"' + (s.elements[0].content || '') + '"') : ''
|
||||||
lines.push('第' + (i + 1) + '页 [' + types + ']' + head + (i === currentIdx ? ' ← 当前页' : ''))
|
lines.push('第' + (i + 1) + '页 [' + types + ']' + head + (i === currentIdx ? ' ← 当前页' : ''))
|
||||||
})
|
})
|
||||||
lines.push('\n当前页(第' + (currentIdx + 1) + '页)完整JSON:\n' + JSON.stringify(store.currentSlide.value))
|
const cur = store.currentSlide.value
|
||||||
|
const curSanitized = { ...cur, elements: cur.elements.map(sanitizeEl) }
|
||||||
|
lines.push('\n当前页(第' + (currentIdx + 1) + '页)完整JSON:\n' + JSON.stringify(curSanitized))
|
||||||
// 选中元素上下文:让 AI 知道用户正在编辑哪个元素,对话直接围绕它
|
// 选中元素上下文:让 AI 知道用户正在编辑哪个元素,对话直接围绕它
|
||||||
if (selectedEl) {
|
if (selectedEl) {
|
||||||
const typeLabel = selectedEl.type
|
const typeLabel = selectedEl.type
|
||||||
const preview = (selectedEl.content || '').slice(0, 100)
|
const preview = (selectedEl.content || '').replace(/\n/g, ' ').slice(0, 100)
|
||||||
lines.push('\n【用户当前选中的元素】(第' + (currentIdx + 1) + '页)')
|
lines.push('\n【用户当前选中的元素】(第' + (currentIdx + 1) + '页)')
|
||||||
lines.push('类型: ' + typeLabel + ',内容预览: "' + preview + '"')
|
lines.push('类型: ' + typeLabel + ',内容预览: "' + preview + '"')
|
||||||
lines.push('完整JSON: ' + JSON.stringify(selectedEl))
|
lines.push('完整JSON: ' + JSON.stringify(sanitizeEl(selectedEl)))
|
||||||
lines.push('用户接下来的对话默认针对此元素,除非明确说整页/整套。')
|
lines.push('用户接下来的对话默认针对此元素,除非明确说整页/整套。')
|
||||||
}
|
}
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
@@ -145,9 +164,55 @@ function apiUrl(cfg: { proxy: string; base: string }): string {
|
|||||||
return (cfg.proxy || cfg.base || '').replace(/\/+$/, '')
|
return (cfg.proxy || cfg.base || '').replace(/\/+$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 桌面流式请求:Rust ai_proxy_stream 转发 SSE 原始 chunk,
|
||||||
|
* 事件桥逐 chunk 喂 SSE 解析器(与浏览器路径共用 createSSESink,保留打字机效果与 SEP 协议)。
|
||||||
|
* 返回 null 表示桌面桥不可用(回退浏览器 fetch 路径)。
|
||||||
|
*/
|
||||||
|
async function desktopStream(
|
||||||
|
url: string, apiKey: string, body: Record<string, unknown>,
|
||||||
|
extractDelta: (obj: any) => string | null, opts: StreamOpts
|
||||||
|
): Promise<{ json: any; reply: string; op: any } | null> {
|
||||||
|
const sink = createSSESink(extractDelta, opts)
|
||||||
|
const full = await aiProxyStream(url, apiKey, JSON.stringify(body), (chunk) => sink.push(chunk))
|
||||||
|
if (!full) return null
|
||||||
|
if (full.status === 0 && full.error) throw new Error('桌面代理请求失败:' + full.error)
|
||||||
|
// 非 200:按 consumeStream 相同格式报错(含智谱错误码翻译)
|
||||||
|
if (full.status >= 400) {
|
||||||
|
let msg = '接口返回 ' + full.status
|
||||||
|
const hint = ERROR_HINTS_BY_STATUS[full.status]
|
||||||
|
try {
|
||||||
|
const err = JSON.parse(full.body).error
|
||||||
|
if (err) {
|
||||||
|
msg += ' [' + err.code + '] ' + (err.message || '')
|
||||||
|
if (err.code && ERROR_HINTS[err.code]) msg += '\n\n💡 ' + ERROR_HINTS[err.code]
|
||||||
|
} else msg += ' ' + (full.body || '').slice(0, 200)
|
||||||
|
} catch { msg += ' ' + (full.body || '').slice(0, 200) }
|
||||||
|
if (hint && !msg.includes('💡')) msg += '\n\n💡 ' + hint
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
return sink.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一 POST 入口:
|
||||||
|
* - 桌面(Tauri)→ Rust 侧 reqwest 代理(无 CORS)
|
||||||
|
* - Web → 浏览器 fetch(支持 CORS 的网关如智谱可直连)
|
||||||
|
* 桌面流式请求的 SSE 由 bridge 内部转为 onVisible 增量回调
|
||||||
|
*/
|
||||||
async function postJSON(url: string, headers: Record<string, string>, body: unknown, signal?: AbortSignal): Promise<Response> {
|
async function postJSON(url: string, headers: Record<string, string>, body: unknown, signal?: AbortSignal): Promise<Response> {
|
||||||
|
const bodyStr = JSON.stringify(body)
|
||||||
|
// 桌面代理路径:headers 中取鉴权(Authorization Bearer 或 x-api-key)
|
||||||
|
if (isTauri()) {
|
||||||
|
const apiKey = headers['Authorization']?.replace(/^Bearer\s+/i, '') || headers['x-api-key'] || ''
|
||||||
|
const proxied = await aiProxy(url, apiKey, bodyStr)
|
||||||
|
if (proxied) {
|
||||||
|
if (proxied.status === 0 && proxied.error) throw new Error('桌面代理请求失败:' + proxied.error)
|
||||||
|
return new Response(proxied.body, { status: proxied.status })
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal })
|
return await fetch(url, { method: 'POST', headers, body: bodyStr, signal })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.name === 'AbortError') throw e
|
if (e.name === 'AbortError') throw e
|
||||||
throw new Error('请求失败(可能是 CORS 跨域拦截)。可在设置中配置"代理 URL"。\n' + e.message)
|
throw new Error('请求失败(可能是 CORS 跨域拦截)。可在设置中配置"代理 URL"。\n' + e.message)
|
||||||
@@ -159,6 +224,16 @@ async function runOpenAI(messages: Message[], opts: StreamOpts, cfg: ReturnType<
|
|||||||
const url = apiUrl(cfg) + '/chat/completions'
|
const url = apiUrl(cfg) + '/chat/completions'
|
||||||
const body: Record<string, unknown> = { model: cfg.model || 'glm-4.6', messages, stream: true, temperature: 0.75 }
|
const body: Record<string, unknown> = { model: cfg.model || 'glm-4.6', messages, stream: true, temperature: 0.75 }
|
||||||
if (opts.jsonMode) body.response_format = { type: 'json_object' }
|
if (opts.jsonMode) body.response_format = { type: 'json_object' }
|
||||||
|
|
||||||
|
// 桌面流式:Rust 事件桥推送 SSE 增量(保留打字机效果),完成后一次性解析
|
||||||
|
if (isTauri() && opts.onVisible) {
|
||||||
|
const extract = (obj: any) => {
|
||||||
|
const ch = obj.choices && obj.choices[0]
|
||||||
|
return (ch && ch.delta && ch.delta.content) || null
|
||||||
|
}
|
||||||
|
const r = await desktopStream(url, cfg.key, body, extract, opts)
|
||||||
|
if (r) return r
|
||||||
|
}
|
||||||
const resp = await postJSON(url,
|
const resp = await postJSON(url,
|
||||||
{ 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + cfg.key }, body, opts.signal)
|
{ 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + cfg.key }, body, opts.signal)
|
||||||
return consumeStream(resp, (obj: any) => {
|
return consumeStream(resp, (obj: any) => {
|
||||||
@@ -184,6 +259,16 @@ async function runAnthropic(messages: Message[], opts: StreamOpts, cfg: ReturnTy
|
|||||||
temperature: 0.75
|
temperature: 0.75
|
||||||
}
|
}
|
||||||
if (sysParts.length) body.system = sysParts.join('\n\n')
|
if (sysParts.length) body.system = sysParts.join('\n\n')
|
||||||
|
|
||||||
|
// 桌面流式(同 OpenAI 路径)
|
||||||
|
if (isTauri() && opts.onVisible) {
|
||||||
|
const extract = (obj: any) => {
|
||||||
|
if (obj.type === 'content_block_delta' && obj.delta) return obj.delta.text || null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const r = await desktopStream(url, cfg.key, body, extract, opts)
|
||||||
|
if (r) return r
|
||||||
|
}
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-api-key': cfg.key,
|
'x-api-key': cfg.key,
|
||||||
@@ -197,7 +282,7 @@ async function runAnthropic(messages: Message[], opts: StreamOpts, cfg: ReturnTy
|
|||||||
}, opts)
|
}, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通用 SSE 消费
|
// 通用 SSE 消费(浏览器 fetch 流)
|
||||||
async function consumeStream(resp: Response, extractDelta: (obj: any) => string | null, opts: StreamOpts): Promise<{ json: any; reply: string; op: any }> {
|
async function consumeStream(resp: Response, extractDelta: (obj: any) => string | null, opts: StreamOpts): Promise<{ json: any; reply: string; op: any }> {
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
let t = ''; try { t = await resp.text() } catch (e) {}
|
let t = ''; try { t = await resp.text() } catch (e) {}
|
||||||
@@ -215,6 +300,20 @@ async function consumeStream(resp: Response, extractDelta: (obj: any) => string
|
|||||||
}
|
}
|
||||||
const reader = resp.body!.getReader()
|
const reader = resp.body!.getReader()
|
||||||
const dec = new TextDecoder('utf-8')
|
const dec = new TextDecoder('utf-8')
|
||||||
|
const sseSink = createSSESink(extractDelta, opts)
|
||||||
|
for (;;) {
|
||||||
|
const chunk = await reader.read()
|
||||||
|
if (chunk.done) break
|
||||||
|
sseSink.push(dec.decode(chunk.value, { stream: true }))
|
||||||
|
}
|
||||||
|
return sseSink.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE 解析核心:数据源无关(fetch 流 / Tauri 事件流通用)。
|
||||||
|
* push() 喂原始 chunk(可能含多行/半行),finish() 返回与 consumeStream 相同结构。
|
||||||
|
*/
|
||||||
|
function createSSESink(extractDelta: (obj: any) => string | null, opts: StreamOpts) {
|
||||||
let sseBuf = ''
|
let sseBuf = ''
|
||||||
let full = ''
|
let full = ''
|
||||||
let pending = ''
|
let pending = ''
|
||||||
@@ -241,39 +340,41 @@ async function consumeStream(resp: Response, extractDelta: (obj: any) => string
|
|||||||
}
|
}
|
||||||
function emit(text: string) { if (opts.onVisible) opts.onVisible(text) }
|
function emit(text: string) { if (opts.onVisible) opts.onVisible(text) }
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
const chunk = await reader.read()
|
|
||||||
if (chunk.done) break
|
|
||||||
sseBuf += dec.decode(chunk.value, { stream: true })
|
|
||||||
const lines = sseBuf.split('\n')
|
|
||||||
sseBuf = lines.pop()!
|
|
||||||
for (let k = 0; k < lines.length; k++) {
|
|
||||||
const line = lines[k].trim()
|
|
||||||
if (!line || line.indexOf('data:') !== 0) continue
|
|
||||||
const payload = line.slice(5).trim()
|
|
||||||
if (!payload || payload === '[DONE]') continue
|
|
||||||
let obj: any; try { obj = JSON.parse(payload) } catch (e) { continue }
|
|
||||||
const delta = extractDelta(obj)
|
|
||||||
if (delta != null) feed(delta)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 末帧残留 data: 行补解析
|
|
||||||
const tail = sseBuf.trim()
|
|
||||||
if (tail.indexOf('data:') === 0) {
|
|
||||||
const tp = tail.slice(5).trim()
|
|
||||||
if (tp && tp !== '[DONE]') {
|
|
||||||
let to: any; try { to = JSON.parse(tp) } catch (e) { to = null }
|
|
||||||
if (to) { const td = extractDelta(to); if (td != null) feed(td) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!opts.jsonMode && !sepMode && pending) emit(pending)
|
|
||||||
|
|
||||||
if (opts.jsonMode) return { json: tryParse(full), reply: '', op: null }
|
|
||||||
const parts = full.split(SEP)
|
|
||||||
return {
|
return {
|
||||||
json: null,
|
/** 喂一个网络 chunk(SSE 帧文本,可跨界) */
|
||||||
reply: (parts[0] || '').trim(),
|
push(chunk: string) {
|
||||||
op: parts.length > 1 ? tryParse(parts.slice(1).join(SEP)) : null
|
sseBuf += chunk.replace(/\r/g, '')
|
||||||
|
const lines = sseBuf.split('\n')
|
||||||
|
sseBuf = lines.pop() || ''
|
||||||
|
for (const line of lines) {
|
||||||
|
const l = line.trim()
|
||||||
|
if (!l || l.indexOf('data:') !== 0) continue
|
||||||
|
const payload = l.slice(5).trim()
|
||||||
|
if (!payload || payload === '[DONE]') continue
|
||||||
|
let obj: any; try { obj = JSON.parse(payload) } catch (e) { continue }
|
||||||
|
const delta = extractDelta(obj)
|
||||||
|
if (delta != null) feed(delta)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** 流结束:解析残留行并汇总 */
|
||||||
|
finish(): { json: any; reply: string; op: any } {
|
||||||
|
const tail = sseBuf.trim()
|
||||||
|
if (tail.indexOf('data:') === 0) {
|
||||||
|
const tp = tail.slice(5).trim()
|
||||||
|
if (tp && tp !== '[DONE]') {
|
||||||
|
let to: any; try { to = JSON.parse(tp) } catch (e) { to = null }
|
||||||
|
if (to) { const td = extractDelta(to); if (td != null) feed(td) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!opts.jsonMode && !sepMode && pending) emit(pending)
|
||||||
|
if (opts.jsonMode) return { json: tryParse(full), reply: '', op: null }
|
||||||
|
const parts = full.split(SEP)
|
||||||
|
return {
|
||||||
|
json: null,
|
||||||
|
reply: (parts[0] || '').trim(),
|
||||||
|
op: parts.length > 1 ? tryParse(parts.slice(1).join(SEP)) : null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +417,9 @@ function normStyle(st: any): ElementStyle {
|
|||||||
if (out.grid != null) out.grid = !!out.grid
|
if (out.grid != null) out.grid = !!out.grid
|
||||||
if (out.header != null) out.header = !!out.header
|
if (out.header != null) out.header = !!out.header
|
||||||
if (out.inline != null) out.inline = !!out.inline
|
if (out.inline != null) out.inline = !!out.inline
|
||||||
|
if (out.autoplay != null) out.autoplay = !!out.autoplay
|
||||||
|
if (out.loop != null) out.loop = !!out.loop
|
||||||
|
if (out.muted != null) out.muted = !!out.muted
|
||||||
if (typeof out.lang === 'string' && out.lang.length > 16) out.lang = out.lang.slice(0, 16)
|
if (typeof out.lang === 'string' && out.lang.length > 16) out.lang = out.lang.slice(0, 16)
|
||||||
;['color', 'fill', 'accent', 'labelColor'].forEach(k => {
|
;['color', 'fill', 'accent', 'labelColor'].forEach(k => {
|
||||||
if (validColor(out[k]) === undefined && out[k] != null) delete out[k]
|
if (validColor(out[k]) === undefined && out[k] != null) delete out[k]
|
||||||
@@ -383,6 +487,35 @@ export async function generate(opts: { topic: string; count?: number; signal?: A
|
|||||||
return { action: 'create_all', slides }
|
return { action: 'create_all', slides }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 文档内容 → AI 分析后生成幻灯片 */
|
||||||
|
export async function generateFromDocument(opts: {
|
||||||
|
text: string
|
||||||
|
filename?: string
|
||||||
|
signal?: AbortSignal
|
||||||
|
}): Promise<{ action: 'create_all'; slides: Slide[] }> {
|
||||||
|
const SYS_DOC =
|
||||||
|
SYS_BASE +
|
||||||
|
'\n任务:分析下方提供的文档内容,将其提炼为 5-10 页专业演示稿(含封面 1 页 + 内容 3-7 页 + 结尾 1 页)。\n' +
|
||||||
|
'要求:\n' +
|
||||||
|
'- 提取文档中的核心观点、关键数据、重要结论,不要遗漏重要信息\n' +
|
||||||
|
'- 每页只有一个主题,标题具体、有信息量,如「Q3 营收增长 23%」而非「业绩回顾」\n' +
|
||||||
|
'- 正文有实质内容,包含具体数字、案例、来源;避免空泛口号\n' +
|
||||||
|
'- 合理运用 card(对比/分组)、stat(突出数据)、chart(趋势/分布)等元素\n' +
|
||||||
|
'- 每个主要元素都加 style.anim 入场动画\n' +
|
||||||
|
'- 封面用 g-primary 渐变,结尾可用 g-primary 或 g-deep,内容页用 bg/panel 浅底\n' +
|
||||||
|
'严格输出 JSON:{"action":"create_all","slides":[...]}'
|
||||||
|
|
||||||
|
const messages: Message[] = [
|
||||||
|
{ role: 'system', content: SYS_DOC },
|
||||||
|
{ role: 'user', content: '文档' + (opts.filename ? '(' + opts.filename + ')' : '') + '内容如下:\n\n' + opts.text }
|
||||||
|
]
|
||||||
|
const r = await streamChat(messages, { jsonMode: true, signal: opts.signal })
|
||||||
|
if (!r.json) throw new Error('AI 输出无法解析为 JSON,请重试。')
|
||||||
|
const slides = normSlides(r.json.slides || r.json)
|
||||||
|
if (!slides.length) throw new Error('AI 未生成有效幻灯片,请重试或检查文档内容。')
|
||||||
|
return { action: 'create_all', slides }
|
||||||
|
}
|
||||||
|
|
||||||
/** 润色单页 */
|
/** 润色单页 */
|
||||||
export async function polish(opts: { slide: Slide; instruction?: string; signal?: AbortSignal }): Promise<{ action: 'update_page'; slide: Slide; note: string }> {
|
export async function polish(opts: { slide: Slide; instruction?: string; signal?: AbortSignal }): Promise<{ action: 'update_page'; slide: Slide; note: string }> {
|
||||||
const instruction = opts.instruction || '让这页内容更有吸引力、表达更精炼'
|
const instruction = opts.instruction || '让这页内容更有吸引力、表达更精炼'
|
||||||
@@ -526,16 +659,18 @@ export async function generateImage(opts: { prompt: string; signal?: AbortSignal
|
|||||||
const cfg = store.getCfg()
|
const cfg = store.getCfg()
|
||||||
const imgBase = (cfg.imgBase || cfg.base || '').replace(/\/+$/, '')
|
const imgBase = (cfg.imgBase || cfg.base || '').replace(/\/+$/, '')
|
||||||
const imgKey = cfg.imgKey || cfg.key
|
const imgKey = cfg.imgKey || cfg.key
|
||||||
const imgModel = cfg.imgModel || 'dall-e-3'
|
// 图像模型默认值跟随服务商:智谱用 cogview,其余走 OpenAI 兼容默认
|
||||||
|
const imgModel = cfg.imgModel || (imgBase.includes('bigmodel.cn') ? 'cogview-3-plus' : 'dall-e-3')
|
||||||
if (!imgKey) throw new Error('未配置 API Key,无法生成图片。')
|
if (!imgKey) throw new Error('未配置 API Key,无法生成图片。')
|
||||||
const url = imgBase + '/images/generations'
|
const url = imgBase + '/images/generations'
|
||||||
const body = {
|
const body: Record<string, unknown> = {
|
||||||
model: imgModel,
|
model: imgModel,
|
||||||
prompt: opts.prompt,
|
prompt: opts.prompt,
|
||||||
n: 1,
|
n: 1,
|
||||||
size: '1024x1024',
|
size: '1024x1024'
|
||||||
response_format: 'b64_json'
|
|
||||||
}
|
}
|
||||||
|
// 智谱 CogView 不支持 response_format 参数(会报错),仅对 OpenAI 兼容网关传 b64_json
|
||||||
|
if (!imgBase.includes('bigmodel.cn')) body.response_format = 'b64_json'
|
||||||
let resp: Response
|
let resp: Response
|
||||||
try {
|
try {
|
||||||
resp = await fetch(url, {
|
resp = await fetch(url, {
|
||||||
@@ -559,6 +694,25 @@ export async function generateImage(opts: { prompt: string; signal?: AbortSignal
|
|||||||
? 'data:image/png;base64,' + item.b64_json
|
? 'data:image/png;base64,' + item.b64_json
|
||||||
: (item.url || '')
|
: (item.url || '')
|
||||||
if (!dataUrl) throw new Error('图片生成返回无图像数据。')
|
if (!dataUrl) throw new Error('图片生成返回无图像数据。')
|
||||||
|
// 返回的是临时 URL 而非 base64:链接会过期导致日后裂图,先抓回本地存 data URL
|
||||||
|
if (/^https?:/i.test(dataUrl)) {
|
||||||
|
try {
|
||||||
|
const imgResp = await fetch(dataUrl, { signal: opts.signal })
|
||||||
|
if (imgResp.ok) {
|
||||||
|
const blob = await imgResp.blob()
|
||||||
|
const b64 = await new Promise<string>((resolve, reject) => {
|
||||||
|
const r = new FileReader()
|
||||||
|
r.onload = () => resolve(r.result as string)
|
||||||
|
r.onerror = () => reject(r.error)
|
||||||
|
r.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
return { url: b64, revisedPrompt: item.revised_prompt }
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === 'AbortError') throw e
|
||||||
|
// 抓取失败(常见为 CORS)——退回原 URL,仍可显示但有过期风险
|
||||||
|
}
|
||||||
|
}
|
||||||
return { url: dataUrl, revisedPrompt: item.revised_prompt }
|
return { url: dataUrl, revisedPrompt: item.revised_prompt }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +722,17 @@ export function isImageConfigured(): boolean {
|
|||||||
return !!(cfg.imgKey || cfg.key)
|
return !!(cfg.imgKey || cfg.key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 检查写入图片后是否超 localStorage 上限(约 5MB 字符),返回错误文案或 null */
|
||||||
|
export function checkImageQuota(dataUrl: string): string | null {
|
||||||
|
const QUOTA_CHARS = 4_500_000
|
||||||
|
let deckChars = 0
|
||||||
|
try { deckChars = JSON.stringify(store.getDeck()).length } catch (e) { /* ignore */ }
|
||||||
|
if (deckChars + dataUrl.length > QUOTA_CHARS) {
|
||||||
|
return '图片写入后将超本地存储上限(约 5MB),已取消。请删除部分旧图,或导出 JSON 备份后清理文库'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 4. AI 主题/配色建议 ---------- */
|
/* ---------- 4. AI 主题/配色建议 ---------- */
|
||||||
|
|
||||||
export async function suggestTheme(opts: { topic: string; signal?: AbortSignal }): Promise<ThemeSuggestion> {
|
export async function suggestTheme(opts: { topic: string; signal?: AbortSignal }): Promise<ThemeSuggestion> {
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
/* =====================================================================
|
|
||||||
* backend.ts — 持久化后端抽象层
|
|
||||||
*
|
|
||||||
* store 只管内存状态 + Op Log,持久化通过 Backend 接口抽象。
|
|
||||||
* 三个实现:
|
|
||||||
* - LocalBackend (localStorage,Web 版当前模式)
|
|
||||||
* - TauriBackend (Rust 文件系统,桌面版,B4 实现)
|
|
||||||
* - CloudBackend (HTTP API,云端版,C0 实现)
|
|
||||||
*
|
|
||||||
* 设计原则:
|
|
||||||
* - 接口只管读写数据,不管状态逻辑(状态逻辑在 store)
|
|
||||||
* - 所有方法返回 Promise(TauriBackend/CloudBackend 天然异步)
|
|
||||||
* - LocalBackend 内部用 localStorage 但包装为 Promise(统一接口)
|
|
||||||
* ===================================================================== */
|
|
||||||
import type { Deck, LibItem, PageTemplate, Theme, ChatMessage, AiCfg } from './types'
|
|
||||||
|
|
||||||
export interface Backend {
|
|
||||||
// ---- 当前 deck ----
|
|
||||||
loadDeck(): Promise<Deck | null>
|
|
||||||
saveDeck(deck: Deck): Promise<void>
|
|
||||||
loadBackup(): Promise<Deck | null>
|
|
||||||
saveBackup(deck: Deck): Promise<void>
|
|
||||||
clearBackup(): Promise<void>
|
|
||||||
|
|
||||||
// ---- 文库 ----
|
|
||||||
loadLibrary(): Promise<LibItem[]>
|
|
||||||
saveLibrary(lib: LibItem[]): Promise<void>
|
|
||||||
getActiveLibId(): Promise<string | null>
|
|
||||||
setActiveLibId(id: string | null): Promise<void>
|
|
||||||
|
|
||||||
// ---- 会话(按 chatId 隔离) ----
|
|
||||||
loadChat(chatId: string): Promise<ChatMessage[]>
|
|
||||||
saveChat(chatId: string, msgs: ChatMessage[]): Promise<void>
|
|
||||||
clearChat(chatId: string): Promise<void>
|
|
||||||
|
|
||||||
// ---- 用户模板 ----
|
|
||||||
loadTemplates(): Promise<PageTemplate[]>
|
|
||||||
saveTemplates(tpls: PageTemplate[]): Promise<void>
|
|
||||||
|
|
||||||
// ---- 自定义主题 ----
|
|
||||||
loadThemes(): Promise<Record<string, Theme>>
|
|
||||||
saveThemes(themes: Record<string, Theme>): Promise<void>
|
|
||||||
|
|
||||||
// ---- AI 配置 ----
|
|
||||||
loadCfg(): Promise<Partial<AiCfg> | null>
|
|
||||||
saveCfg(cfg: AiCfg): Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* LocalBackend — localStorage 实现(Web 版当前模式)
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
const KEYS = {
|
|
||||||
deck: 'u-ppt.deck.v1',
|
|
||||||
deckBak: 'u-ppt.deck.v1.bak',
|
|
||||||
lib: 'u-ppt.library.v1',
|
|
||||||
activeLib: 'u-ppt.activeLib.v1',
|
|
||||||
chatPrefix: 'u-ppt.chat.',
|
|
||||||
templates: 'u-ppt.templates.v1',
|
|
||||||
themes: 'u-ppt.themes.v1',
|
|
||||||
cfg: 'u-ppt.ai-cfg.v1',
|
|
||||||
}
|
|
||||||
|
|
||||||
function lsGet(key: string): string | null {
|
|
||||||
try { return localStorage.getItem(key) } catch (e) { return null }
|
|
||||||
}
|
|
||||||
function lsSet(key: string, val: string): void {
|
|
||||||
try { localStorage.setItem(key, val) } catch (e) { /* 配额满等忽略 */ }
|
|
||||||
}
|
|
||||||
function lsDel(key: string): void {
|
|
||||||
try { localStorage.removeItem(key) } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LocalBackend implements Backend {
|
|
||||||
async loadDeck(): Promise<Deck | null> {
|
|
||||||
const raw = lsGet(KEYS.deck)
|
|
||||||
if (!raw) return null
|
|
||||||
try { return JSON.parse(raw) } catch (e) { return null }
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveDeck(deck: Deck): Promise<void> {
|
|
||||||
lsSet(KEYS.deck, JSON.stringify(deck))
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadBackup(): Promise<Deck | null> {
|
|
||||||
const raw = lsGet(KEYS.deckBak)
|
|
||||||
if (!raw) return null
|
|
||||||
try { return JSON.parse(raw) } catch (e) { return null }
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveBackup(deck: Deck): Promise<void> {
|
|
||||||
lsSet(KEYS.deckBak, JSON.stringify(deck))
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearBackup(): Promise<void> {
|
|
||||||
lsDel(KEYS.deckBak)
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadLibrary(): Promise<LibItem[]> {
|
|
||||||
const raw = lsGet(KEYS.lib)
|
|
||||||
if (!raw) return []
|
|
||||||
try { return JSON.parse(raw) || [] } catch (e) { return [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveLibrary(lib: LibItem[]): Promise<void> {
|
|
||||||
lsSet(KEYS.lib, JSON.stringify(lib))
|
|
||||||
}
|
|
||||||
|
|
||||||
async getActiveLibId(): Promise<string | null> {
|
|
||||||
return lsGet(KEYS.activeLib)
|
|
||||||
}
|
|
||||||
|
|
||||||
async setActiveLibId(id: string | null): Promise<void> {
|
|
||||||
if (id) lsSet(KEYS.activeLib, id)
|
|
||||||
else lsDel(KEYS.activeLib)
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadChat(chatId: string): Promise<ChatMessage[]> {
|
|
||||||
const raw = lsGet(KEYS.chatPrefix + chatId)
|
|
||||||
if (!raw) return []
|
|
||||||
try { return JSON.parse(raw) || [] } catch (e) { return [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveChat(chatId: string, msgs: ChatMessage[]): Promise<void> {
|
|
||||||
lsSet(KEYS.chatPrefix + chatId, JSON.stringify(msgs))
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearChat(chatId: string): Promise<void> {
|
|
||||||
lsDel(KEYS.chatPrefix + chatId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadTemplates(): Promise<PageTemplate[]> {
|
|
||||||
const raw = lsGet(KEYS.templates)
|
|
||||||
if (!raw) return []
|
|
||||||
try { return JSON.parse(raw) || [] } catch (e) { return [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveTemplates(tpls: PageTemplate[]): Promise<void> {
|
|
||||||
// 只存用户模板(category === 'user')
|
|
||||||
lsSet(KEYS.templates, JSON.stringify(tpls.filter(t => t.category === 'user')))
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadThemes(): Promise<Record<string, Theme>> {
|
|
||||||
const raw = lsGet(KEYS.themes)
|
|
||||||
if (!raw) return {}
|
|
||||||
try { return JSON.parse(raw) || {} } catch (e) { return {} }
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveThemes(themes: Record<string, Theme>): Promise<void> {
|
|
||||||
lsSet(KEYS.themes, JSON.stringify(themes))
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadCfg(): Promise<Partial<AiCfg> | null> {
|
|
||||||
const raw = lsGet(KEYS.cfg)
|
|
||||||
if (!raw) return null
|
|
||||||
try { return JSON.parse(raw) } catch (e) { return null }
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveCfg(cfg: AiCfg): Promise<void> {
|
|
||||||
lsSet(KEYS.cfg, JSON.stringify(cfg))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 当前活动的 Backend 实例 ---------- */
|
|
||||||
let activeBackend: Backend = new LocalBackend()
|
|
||||||
|
|
||||||
export function getBackend(): Backend {
|
|
||||||
return activeBackend
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setBackend(b: Backend): void {
|
|
||||||
activeBackend = b
|
|
||||||
}
|
|
||||||
+57
-30
@@ -1,44 +1,71 @@
|
|||||||
/* =====================================================================
|
/* =====================================================================
|
||||||
* bridge.ts — Tauri IPC 安全调用桥接
|
* bridge.ts — Tauri IPC 桥接(AI 代理专用)
|
||||||
*
|
* 桌面版(Tauri WebView)直连 AI 网关受 CORS 限制,走 Rust 侧 reqwest 代理;
|
||||||
* 安全检测 __TAURI__ 环境,动态 import @tauri-apps/api/core。
|
* Web 版返回 null,调用方回退浏览器 fetch(智谱等支持 CORS 的网关直连可用)
|
||||||
* Web 模式下返回 mock,组件无需关心环境。
|
|
||||||
* ===================================================================== */
|
* ===================================================================== */
|
||||||
|
|
||||||
/** 动态获取 invoke 函数 */
|
/** 是否运行在 Tauri 桌面环境 */
|
||||||
let _invoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null
|
export function isTauri(): boolean {
|
||||||
|
return typeof window !== 'undefined' && ('__TAURI__' in window || '__TAURI_INTERNALS__' in window)
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureInvoke() {
|
let _invoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null
|
||||||
if (_invoke) return true
|
let _invokeTried = false
|
||||||
if (typeof window === 'undefined' || !('__TAURI__' in window)) return false
|
|
||||||
|
/** 懒加载 Tauri invoke(动态 import,Web 版不会打进包) */
|
||||||
|
async function getInvoke() {
|
||||||
|
if (_invokeTried) return _invoke
|
||||||
|
_invokeTried = true
|
||||||
|
if (!isTauri()) return null
|
||||||
try {
|
try {
|
||||||
const mod = await import('@tauri-apps/api/core')
|
const mod = await import('@tauri-apps/api/core')
|
||||||
_invoke = mod.invoke
|
_invoke = mod.invoke
|
||||||
return true
|
} catch { _invoke = null }
|
||||||
} catch {
|
return _invoke
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface ProxyResp {
|
||||||
* 安全调用 Tauri IPC 命令。
|
status: number
|
||||||
* Web 模式下返回 undefined(调用方需处理)。
|
body: string
|
||||||
*/
|
error?: string
|
||||||
export async function invoke<T = unknown>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
|
|
||||||
const ok = await ensureInvoke()
|
|
||||||
if (!ok || !_invoke) return undefined
|
|
||||||
return _invoke(cmd, args) as Promise<T>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 注册 Tauri 事件监听(用于 Rust → 前端推送) */
|
/** 非流式代理:Rust 侧 fetch,返回 {status, body};非桌面环境返回 null */
|
||||||
export async function listen<T>(event: string, handler: (payload: T) => void): Promise<() => void> {
|
export async function aiProxy(url: string, apiKey: string, body: string): Promise<ProxyResp | null> {
|
||||||
if (typeof window === 'undefined' || !('__TAURI__' in window)) {
|
const invoke = await getInvoke()
|
||||||
return () => {} // 空注销函数
|
if (!invoke) return null
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const { listen } = await import('@tauri-apps/api/event')
|
const r = await invoke('ai_proxy', { url, apiKey, body }) as ProxyResp
|
||||||
return listen(event, (e: { payload: T }) => handler(e.payload))
|
return r
|
||||||
} catch {
|
} catch (e: any) {
|
||||||
return () => {}
|
return { status: 0, body: '', error: String(e?.message || e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 流式代理:Rust 侧 SSE,原始 chunk(含 "data: {...}" 帧)通过 onChunk 推送;
|
||||||
|
* SSE 解析由 ai.ts 的 createSSESink 统一处理(与浏览器路径共用)。非桌面返回 null */
|
||||||
|
export async function aiProxyStream(
|
||||||
|
url: string, apiKey: string, body: string,
|
||||||
|
onChunk: (raw: string) => void
|
||||||
|
): Promise<ProxyResp | null> {
|
||||||
|
const invoke = await getInvoke()
|
||||||
|
if (!invoke) return null
|
||||||
|
try {
|
||||||
|
const eventMod = await import('@tauri-apps/api/event')
|
||||||
|
const unlisten = await eventMod.listen<string>('ai-delta', (ev) => {
|
||||||
|
onChunk(ev.payload || '')
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const full = await invoke('ai_proxy_stream', { url, apiKey, body }) as unknown as string
|
||||||
|
return { status: 200, body: full }
|
||||||
|
} finally {
|
||||||
|
unlisten()
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// Rust 侧错误协议:__HTTP_<status>__<body>
|
||||||
|
const raw = String(e?.message || e)
|
||||||
|
const m = raw.match(/^__HTTP_(\d+)__/)
|
||||||
|
if (m) return { status: Number(m[1]), body: raw.slice(m[0].length) }
|
||||||
|
return { status: 0, body: '', error: raw }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
* dialog.ts — 全局对话框服务(替代原生 alert/confirm/prompt)
|
||||||
|
*
|
||||||
|
* Promise 风格:await appConfirm('删除?') → boolean
|
||||||
|
* 渲染走 AppDialogHost(主 app 内 Teleport 到 body),
|
||||||
|
* App.vue 模板放置 <AppDialogHost /> 即接线,无需手动 mount
|
||||||
|
* ===================================================================== */
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface DialogOptions {
|
||||||
|
type: 'alert' | 'confirm' | 'prompt'
|
||||||
|
title: string
|
||||||
|
message?: string
|
||||||
|
/** prompt 默认值 */
|
||||||
|
defaultValue?: string
|
||||||
|
/** input placeholder */
|
||||||
|
placeholder?: string
|
||||||
|
/** 确认按钮文案(默认「确定」) */
|
||||||
|
okText?: string
|
||||||
|
/** 危险操作样式(确认按钮红色) */
|
||||||
|
danger?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogResult { ok: boolean; value: string }
|
||||||
|
type Resolver = (r: DialogResult) => void
|
||||||
|
|
||||||
|
/* ---------- 模块级响应式状态(AppDialogHost 渲染消费) ---------- */
|
||||||
|
export const dialogState = {
|
||||||
|
open: ref(false),
|
||||||
|
options: ref<DialogOptions | null>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolver: Resolver | null = null
|
||||||
|
|
||||||
|
/** 由 AppDialogHost 的 close 事件回调 */
|
||||||
|
export function resolveDialog(r: DialogResult) {
|
||||||
|
dialogState.open.value = false
|
||||||
|
dialogState.options.value = null
|
||||||
|
if (resolver) { resolver(r); resolver = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(opts: DialogOptions): Promise<DialogResult> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
resolver = resolve
|
||||||
|
dialogState.options.value = opts
|
||||||
|
dialogState.open.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 对外 API ---------- */
|
||||||
|
|
||||||
|
/** 信息提示(替代 alert) */
|
||||||
|
export function appAlert(title: string, message?: string) {
|
||||||
|
return show({ type: 'alert', title, message })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认(替代 confirm)→ Promise<boolean> */
|
||||||
|
export function appConfirm(title: string, message?: string, opts?: { okText?: string; danger?: boolean }) {
|
||||||
|
return show({ type: 'confirm', title, message, okText: opts?.okText, danger: opts?.danger }).then(r => r.ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输入(替代 prompt)→ Promise<string | null>(取消为 null) */
|
||||||
|
export function appPrompt(title: string, opts?: { message?: string; defaultValue?: string; placeholder?: string }) {
|
||||||
|
return show({ type: 'prompt', title, message: opts?.message, defaultValue: opts?.defaultValue, placeholder: opts?.placeholder })
|
||||||
|
.then(r => (r.ok ? r.value : null))
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
* importer.ts — 本地文件/目录资料导入引擎
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* - 图片 → data URL → 直接插入 image 元素
|
||||||
|
* - 文档(PDF/DOCX/MD/TXT)→ 提取原始文本 → 由 LLM 分析生成幻灯片
|
||||||
|
* - LLM 理解文档语义,输出结构化 PPT,比规则解析更智能
|
||||||
|
* ===================================================================== */
|
||||||
|
import type { Slide, SlideElement } from './types'
|
||||||
|
import { createElement } from './sample'
|
||||||
|
// 本地打包 pdf.js worker(避免依赖 CDN,离线/桌面/内网可用)
|
||||||
|
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
||||||
|
|
||||||
|
/** 允许的图片扩展名 */
|
||||||
|
export const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'])
|
||||||
|
|
||||||
|
/** 允许的文档扩展名(由 LLM 解析) */
|
||||||
|
export const DOC_EXTS = new Set(['.md', '.markdown', '.txt', '.text', '.pdf', '.docx', '.doc'])
|
||||||
|
|
||||||
|
/** 允许的视频扩展名(实际判定以 MIME video/* 为准,此处用于 accept 与无 MIME 环境兜底) */
|
||||||
|
export const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi', '.mkv'])
|
||||||
|
|
||||||
|
/** 文件选择器 accept 属性(由支持清单派生,保持单一数据源) */
|
||||||
|
export const ACCEPT_ATTR = [...IMAGE_EXTS, ...VIDEO_EXTS, ...DOC_EXTS].join(',')
|
||||||
|
|
||||||
|
/** 文件分类结果 */
|
||||||
|
export interface FileEntry {
|
||||||
|
file: File
|
||||||
|
name: string
|
||||||
|
ext: string
|
||||||
|
kind: 'image' | 'video' | 'document' | 'unsupported'
|
||||||
|
/** 图片的 data URL,或文档的原始文本 */
|
||||||
|
data?: string
|
||||||
|
/** AI 解析出的幻灯片(文档读取 + LLM 分析后填充) */
|
||||||
|
slides?: Slide[]
|
||||||
|
/** 错误信息 */
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导入统计 */
|
||||||
|
export interface ImportReport {
|
||||||
|
images: number
|
||||||
|
slides: number
|
||||||
|
files: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileKind(file: File): FileEntry['kind'] {
|
||||||
|
const name = file.name.toLowerCase()
|
||||||
|
const dot = name.lastIndexOf('.')
|
||||||
|
const ext = dot >= 0 ? name.slice(dot) : ''
|
||||||
|
if (IMAGE_EXTS.has(ext)) return 'image'
|
||||||
|
// 视频按 MIME 判定(扩展名太杂),无 MIME 时扩展名兜底
|
||||||
|
if (file.type.startsWith('video/') || VIDEO_EXTS.has(ext)) return 'video'
|
||||||
|
if (DOC_EXTS.has(ext)) return 'document'
|
||||||
|
return 'unsupported'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取文件为 ArrayBuffer */
|
||||||
|
function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||||
|
reader.onerror = () => reject(new Error(`读取失败: ${file.name}`))
|
||||||
|
reader.readAsArrayBuffer(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取图片为 Data URL */
|
||||||
|
export function readAsDataURL(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result as string)
|
||||||
|
reader.onerror = () => reject(new Error(`读取失败: ${file.name}`))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取文本文件 */
|
||||||
|
function readAsText(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result as string)
|
||||||
|
reader.onerror = () => reject(new Error(`读取失败: ${file.name}`))
|
||||||
|
reader.readAsText(file, 'utf-8')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* PDF 文本提取(仅提取原始文本,不做结构解析)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export async function extractPdfText(file: File): Promise<string> {
|
||||||
|
const pdfjsLib: any = await import('pdfjs-dist')
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
|
||||||
|
|
||||||
|
const buffer = await readAsArrayBuffer(file)
|
||||||
|
const pdf = await pdfjsLib.getDocument({ data: buffer }).promise
|
||||||
|
const pages: string[] = []
|
||||||
|
|
||||||
|
for (let i = 1; i <= pdf.numPages; i++) {
|
||||||
|
const page = await pdf.getPage(i)
|
||||||
|
const content = await page.getTextContent()
|
||||||
|
const lines = groupPdfTextItems(
|
||||||
|
content.items as Array<{ str: string; transform: number[] }>
|
||||||
|
)
|
||||||
|
pages.push(lines.join('\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages.map((p, i) => `[p${i + 1}]\n${p}`).join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意:阈值 5 基于坐标差值,多栏排版可能合并错乱,交由 LLM 理解时再纠正
|
||||||
|
function groupPdfTextItems(
|
||||||
|
items: Array<{ str: string; transform: number[] }>
|
||||||
|
): string[] {
|
||||||
|
const lines: string[] = []
|
||||||
|
let currentLine = ''
|
||||||
|
let lastY = 0
|
||||||
|
for (const item of items) {
|
||||||
|
const text = item.str
|
||||||
|
if (!text && text.length === 0) continue
|
||||||
|
const y = item.transform[5]
|
||||||
|
if (currentLine && Math.abs(y - lastY) > 5) {
|
||||||
|
lines.push(currentLine.trimEnd())
|
||||||
|
currentLine = text
|
||||||
|
} else {
|
||||||
|
currentLine +=
|
||||||
|
(currentLine && !currentLine.endsWith(' ') && !text.startsWith(' ') ? ' ' : '') + text
|
||||||
|
}
|
||||||
|
lastY = y
|
||||||
|
}
|
||||||
|
if (currentLine.trim()) lines.push(currentLine.trimEnd())
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* DOCX 文本提取(转为 Markdown 文本,不解析为幻灯片)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export async function extractDocxText(file: File): Promise<string> {
|
||||||
|
const mammoth: any = await import('mammoth')
|
||||||
|
const buffer = await readAsArrayBuffer(file)
|
||||||
|
const result = await mammoth.convertToMarkdown({ arrayBuffer: buffer })
|
||||||
|
return result.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 从文本文件直接读取
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
async function readTextFile(file: File): Promise<string> {
|
||||||
|
return readAsText(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 图片导入 → SlideElement(image 类型)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export function fileToImageElement(file: File, dataUrl: string): SlideElement {
|
||||||
|
const isWide = file.type === 'image/svg+xml'
|
||||||
|
return createElement('image', {
|
||||||
|
content: dataUrl,
|
||||||
|
x: 15, y: 12, w: isWide ? 60 : 40, h: isWide ? 40 : 50,
|
||||||
|
style: { fit: 'contain', anim: 'fade' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imagesToSlideElements(files: Array<{ file: File; dataUrl: string }>): SlideElement[] {
|
||||||
|
if (files.length === 1) {
|
||||||
|
return [fileToImageElement(files[0].file, files[0].dataUrl)]
|
||||||
|
} const cols = Math.ceil(Math.sqrt(files.length))
|
||||||
|
const rows = Math.ceil(files.length / cols)
|
||||||
|
const cellW = Math.floor(80 / cols)
|
||||||
|
const cellH = Math.floor(60 / rows)
|
||||||
|
return files.map((f, i) => {
|
||||||
|
const col = i % cols
|
||||||
|
const row = Math.floor(i / cols)
|
||||||
|
return createElement('image', {
|
||||||
|
content: f.dataUrl,
|
||||||
|
x: 8 + col * cellW, y: 8 + row * cellH,
|
||||||
|
w: cellW - 4, h: cellH - 4,
|
||||||
|
style: { fit: 'cover', anim: 'fade' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 视频导入 → SlideElement(video 类型)
|
||||||
|
* 注意:dataURL 会把整个视频读入内存,大视频占内存较高(本地工具首版接受)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export function fileToVideoElement(file: File, dataUrl: string): SlideElement {
|
||||||
|
return createElement('video', {
|
||||||
|
content: dataUrl,
|
||||||
|
x: 20, y: 15, w: 60, h: 45,
|
||||||
|
style: { anim: 'fade' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 批量文件扫描与读取
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export async function scanFiles(fileList: FileList): Promise<FileEntry[]> {
|
||||||
|
const entries: FileEntry[] = []
|
||||||
|
for (let i = 0; i < fileList.length; i++) {
|
||||||
|
const file = fileList[i]
|
||||||
|
const name = file.name
|
||||||
|
const dot = name.lastIndexOf('.')
|
||||||
|
const ext = dot >= 0 ? name.slice(dot).toLowerCase() : ''
|
||||||
|
const kind = getFileKind(file)
|
||||||
|
entries.push({ file, name, ext, kind })
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取文件内容(图片 → data URL,文档 → 原始文本),原位修改 entries */
|
||||||
|
export async function readEntries(entries: FileEntry[]): Promise<void> {
|
||||||
|
// 并行读取:各文件互不依赖,错误就地记到 entry.error 不中断整体
|
||||||
|
await Promise.all(entries.map(async (entry) => {
|
||||||
|
try {
|
||||||
|
if (entry.kind === 'image') {
|
||||||
|
entry.data = await readAsDataURL(entry.file)
|
||||||
|
} else if (entry.kind === 'video') {
|
||||||
|
// 视频 → data URL(与图片同管线,播放元素直接消费)
|
||||||
|
entry.data = await readAsDataURL(entry.file)
|
||||||
|
} else if (entry.kind === 'document') {
|
||||||
|
if (entry.ext === '.pdf') {
|
||||||
|
entry.data = await extractPdfText(entry.file)
|
||||||
|
} else if (entry.ext === '.docx' || entry.ext === '.doc') {
|
||||||
|
entry.data = await extractDocxText(entry.file)
|
||||||
|
} else {
|
||||||
|
// .md / .markdown / .txt / .text
|
||||||
|
const text = await readTextFile(entry.file)
|
||||||
|
entry.data = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
entry.error = e?.message || String(e)
|
||||||
|
console.warn(`读取失败 ${entry.name}:`, e)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 使用 AI 将一个文档的文本内容解析为幻灯片(异步,调用 LLM) */
|
||||||
|
export async function aiAnalyzeDocument(
|
||||||
|
entry: FileEntry,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<Slide[]> {
|
||||||
|
if (!entry.data || entry.kind !== 'document') {
|
||||||
|
throw new Error('没有可分析的文本内容')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { generateFromDocument } = await import('./ai')
|
||||||
|
|
||||||
|
// 文档文本太长时截断(按 token 估算,保留关键部分:开头 + 结尾)
|
||||||
|
let text = entry.data
|
||||||
|
const MAX_CHARS = 12000
|
||||||
|
if (text.length > MAX_CHARS) {
|
||||||
|
const keepFront = Math.floor(MAX_CHARS * 0.5)
|
||||||
|
const keepTail = MAX_CHARS - keepFront
|
||||||
|
text = text.slice(0, keepFront) + '\n\n...(中间省略 ' + (text.length - MAX_CHARS) + ' 字符)...\n\n' + text.slice(-keepTail)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await generateFromDocument({
|
||||||
|
text,
|
||||||
|
filename: entry.name,
|
||||||
|
signal
|
||||||
|
})
|
||||||
|
|
||||||
|
entry.slides = result.slides
|
||||||
|
return result.slides
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成导入统计描述 */
|
||||||
|
export function describeReport(r: ImportReport): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (r.slides > 0) parts.push(`AI 生成 ${r.slides} 页幻灯片`)
|
||||||
|
if (r.images > 0) parts.push(`插入 ${r.images} 张图片`)
|
||||||
|
if (r.files > 0) parts.push(`共处理 ${r.files} 个文件`)
|
||||||
|
return parts.join(',') || '未导入任何内容'
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
* markdown.ts — AI 消息 Markdown 渲染(参考 devflow useMarkdown 设计)
|
||||||
|
* - marked 静态导入(u-ppt 包体敏感度低,无需动态加载)
|
||||||
|
* - DOMPurify.sanitize 防 XSS,可安全 v-html
|
||||||
|
* - 历史消息整段缓存(文本不变命中跳过 parse+sanitize)
|
||||||
|
* - JSON 代码块轻量着色(保留原有正则方案,不引 hljs 控包体)
|
||||||
|
* ===================================================================== */
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
|
marked.setOptions({ gfm: true, breaks: true })
|
||||||
|
|
||||||
|
/* ---------- JSON 轻量着色(与原 AiPanel 实现一致) ---------- */
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryFormatJson(raw: string): string {
|
||||||
|
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch (e) { return raw }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 代码着色(纯正则,无依赖) */
|
||||||
|
function highlightJson(code: string): string {
|
||||||
|
return escapeHtml(code)
|
||||||
|
.replace(/("(?:\\.|[^"\\])*"\s*:)/g, '<span class="jk">$1</span>')
|
||||||
|
.replace(/:\s*("(?:\\.|[^"\\])*")/g, ': <span class="js">$1</span>')
|
||||||
|
.replace(/:\s*(-?\d+\.?\d*)/g, ': <span class="jn">$1</span>')
|
||||||
|
.replace(/:\s*(true|false|null)/g, ': <span class="jb">$1</span>')
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- code renderer:json 格式化+着色,其余转义 ---------- */
|
||||||
|
const renderer = new marked.Renderer()
|
||||||
|
renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
|
||||||
|
const language = (lang || '').toLowerCase()
|
||||||
|
if (!language || language === 'json') {
|
||||||
|
return `<pre class="msg-code" data-lang="json"><code>${highlightJson(tryFormatJson(text.trim()))}</code></pre>`
|
||||||
|
}
|
||||||
|
return `<pre class="msg-code" data-lang="${escapeHtml(language)}"><code>${escapeHtml(text)}</code></pre>`
|
||||||
|
}
|
||||||
|
marked.use({ renderer })
|
||||||
|
|
||||||
|
/* ---------- 缓存 + 主入口 ---------- */
|
||||||
|
const _mdCache = new Map<string, string>()
|
||||||
|
const MD_CACHE_LIMIT = 300
|
||||||
|
|
||||||
|
/** 渲染 Markdown → 消毒 HTML(可安全 v-html)。失败降级纯文本转义。 */
|
||||||
|
export function renderMd(text: string): string {
|
||||||
|
const hit = _mdCache.get(text)
|
||||||
|
if (hit != null) return hit
|
||||||
|
let html: string
|
||||||
|
try {
|
||||||
|
html = DOMPurify.sanitize(marked.parse(text) as string, {
|
||||||
|
ADD_ATTR: ['data-lang'],
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
html = '<p>' + escapeHtml(text).replace(/\n/g, '<br>') + '</p>'
|
||||||
|
}
|
||||||
|
if (_mdCache.size >= MD_CACHE_LIMIT) {
|
||||||
|
// 简单淘汰:清掉最早的键(Map 保持插入序)
|
||||||
|
const firstKey = _mdCache.keys().next().value
|
||||||
|
if (firstKey !== undefined) _mdCache.delete(firstKey)
|
||||||
|
}
|
||||||
|
_mdCache.set(text, html)
|
||||||
|
return html
|
||||||
|
}
|
||||||
+43
-5
@@ -20,6 +20,7 @@ export type OpType =
|
|||||||
| 'move_slide'
|
| 'move_slide'
|
||||||
| 'replace_slide'
|
| 'replace_slide'
|
||||||
| 'set_slide_bg'
|
| 'set_slide_bg'
|
||||||
|
| 'set_slide_note'
|
||||||
| 'add_element'
|
| 'add_element'
|
||||||
| 'update_element'
|
| 'update_element'
|
||||||
| 'del_element'
|
| 'del_element'
|
||||||
@@ -40,6 +41,8 @@ export interface Op {
|
|||||||
export interface HistoryEntry {
|
export interface HistoryEntry {
|
||||||
forward: Op
|
forward: Op
|
||||||
backward: Op
|
backward: Op
|
||||||
|
/** 批次标记:同批多条(如一次导入 N 张图)undo/redo 时整组一起回滚 */
|
||||||
|
group?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 工具 ---------- */
|
/* ---------- 工具 ---------- */
|
||||||
@@ -104,11 +107,24 @@ export function applyOp(inputDeck: Deck, op: Op): Deck {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'set_slide_note': {
|
||||||
|
const index = op.index as number
|
||||||
|
const note = op.note as string
|
||||||
|
if (deck.slides[index]) {
|
||||||
|
deck.slides[index].note = note
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'add_element': {
|
case 'add_element': {
|
||||||
const slideIdx = op.slideIdx as number
|
const slideIdx = op.slideIdx as number
|
||||||
const element = op.element as SlideElement
|
const element = op.element as SlideElement
|
||||||
|
const atIndex = op.atIndex as number | undefined
|
||||||
const s = deck.slides[slideIdx]
|
const s = deck.slides[slideIdx]
|
||||||
if (s) s.elements.push(element)
|
if (s) {
|
||||||
|
if (atIndex != null) s.elements.splice(atIndex, 0, element) // 指定位置插入(撤销删除时回到原 z 序)
|
||||||
|
else s.elements.push(element)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +231,12 @@ export function invertOp(inputDeck: Deck, op: Op): Op {
|
|||||||
return { type: 'set_slide_bg', index, bg: oldBg }
|
return { type: 'set_slide_bg', index, bg: oldBg }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'set_slide_note': {
|
||||||
|
const index = op.index as number
|
||||||
|
const oldNote = deck.slides[index]?.note ?? ''
|
||||||
|
return { type: 'set_slide_note', index, note: oldNote }
|
||||||
|
}
|
||||||
|
|
||||||
case 'add_element': {
|
case 'add_element': {
|
||||||
// 逆:删除加的元素
|
// 逆:删除加的元素
|
||||||
return { type: 'del_element', slideIdx: op.slideIdx, elementId: (op.element as SlideElement).id }
|
return { type: 'del_element', slideIdx: op.slideIdx, elementId: (op.element as SlideElement).id }
|
||||||
@@ -244,18 +266,34 @@ export function invertOp(inputDeck: Deck, op: Op): Op {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'del_element': {
|
case 'del_element': {
|
||||||
// 逆:把删掉的元素加回去
|
// 逆:把删掉的元素加回原位置(保持 z 序)
|
||||||
const slideIdx = op.slideIdx as number
|
const slideIdx = op.slideIdx as number
|
||||||
const deletedElement = op.deletedElement as SlideElement | undefined
|
const deletedElement = op.deletedElement as SlideElement | undefined
|
||||||
if (deletedElement) {
|
if (deletedElement) {
|
||||||
return { type: 'add_element', slideIdx, element: deletedElement }
|
const s = deck.slides[slideIdx]
|
||||||
|
const at = s ? s.elements.findIndex(e => e.id === deletedElement.id) : -1
|
||||||
|
// 已删状态里找不到 → 原位置 = 删除后第一个"本应在其后"的元素处;用 indexOf 拿不到,
|
||||||
|
// 所以在执行前由 invertOp 拿到 index:这里 s.elements 已无该元素,取其后继位置
|
||||||
|
const insertAt = at >= 0 ? at : (s ? s.elements.length : 0)
|
||||||
|
return { type: 'add_element', slideIdx, element: deletedElement, atIndex: insertAt }
|
||||||
}
|
}
|
||||||
return { type: 'replace_deck', deck: clone(deck) }
|
return { type: 'replace_deck', deck: clone(deck) }
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'move_element_z': {
|
case 'move_element_z': {
|
||||||
// 逆:方向反过来
|
// 逆:方向反过来。正向若越界是 no-op(applyOp break),
|
||||||
return { type: 'move_element_z', slideIdx: op.slideIdx, elementId: op.elementId, dir: -(op.dir as number) }
|
// 此时逆必须也是 no-op(空 patch 的 update_element 无副作用)——
|
||||||
|
// 否则撤销会移动一个从未移动过的元素
|
||||||
|
const slideIdx = op.slideIdx as number
|
||||||
|
const elementId = op.elementId as string
|
||||||
|
const s = deck.slides[slideIdx]
|
||||||
|
const i = s ? s.elements.findIndex(e => e.id === elementId) : -1
|
||||||
|
const j = i >= 0 ? ((op.dir as number) > 0 ? i + 1 : i - 1) : -1
|
||||||
|
const outOfRange = i < 0 || j < 0 || j >= (s ? s.elements.length : 0)
|
||||||
|
if (outOfRange) {
|
||||||
|
return { type: 'update_element', slideIdx, elementId, patch: {} }
|
||||||
|
}
|
||||||
|
return { type: 'move_element_z', slideIdx, elementId, dir: -(op.dir as number) }
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'set_theme': {
|
case 'set_theme': {
|
||||||
|
|||||||
+4
-2
@@ -30,7 +30,8 @@ export const elementTypes: Record<ElementType, { label: string; defaults: Elemen
|
|||||||
stat: { label: '数据', defaults: { fontSize: 64, color: 'primary', label: '说明文字', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
|
stat: { label: '数据', defaults: { fontSize: 64, color: 'primary', label: '说明文字', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
|
||||||
quote: { label: '金句', defaults: { fontSize: 40, color: 'text', italic: true, align: 'center', anim: 'scale' } },
|
quote: { label: '金句', defaults: { fontSize: 40, color: 'text', italic: true, align: 'center', anim: 'scale' } },
|
||||||
image: { label: '图片', defaults: { fit: 'cover', anim: 'fade' } },
|
image: { label: '图片', defaults: { fit: 'cover', anim: 'fade' } },
|
||||||
shape: { label: '形状', defaults: { shapeType: 'rect', fill: 'accent', radius: 12, anim: 'fade' } },
|
video: { label: '视频', defaults: { fit: 'contain', anim: 'fade' } },
|
||||||
|
shape: { label: '形状', defaults: { shapeType: 'rect', fill: 'accent', radius: 12, fontSize: 20, anim: 'fade' } },
|
||||||
chart: { label: '图表', defaults: { color: 'primary', max: 100, chartType: 'bar', legend: true, grid: true, anim: 'fade-up' } },
|
chart: { label: '图表', defaults: { color: 'primary', max: 100, chartType: 'bar', legend: true, grid: true, anim: 'fade-up' } },
|
||||||
card: { label: '卡片', defaults: { fontSize: 26, accent: 'primary', icon: '', anim: 'fade-up' } },
|
card: { label: '卡片', defaults: { fontSize: 26, accent: 'primary', icon: '', anim: 'fade-up' } },
|
||||||
table: { label: '表格', defaults: { fontSize: 16, color: 'text', header: true, anim: 'fade-up' } },
|
table: { label: '表格', defaults: { fontSize: 16, color: 'text', header: true, anim: 'fade-up' } },
|
||||||
@@ -48,7 +49,8 @@ export function createElement(type: ElementType, over?: Partial<SlideElement> &
|
|||||||
stat: { w: 30, h: 35 },
|
stat: { w: 30, h: 35 },
|
||||||
quote: { w: 70, h: 25 },
|
quote: { w: 70, h: 25 },
|
||||||
image: { w: 40, h: 40 },
|
image: { w: 40, h: 40 },
|
||||||
shape: { w: 25, h: 25 },
|
video: { w: 50, h: 32 },
|
||||||
|
shape: { w: 30, h: 15 },
|
||||||
chart: { w: 45, h: 35 },
|
chart: { w: 45, h: 35 },
|
||||||
card: { w: 35, h: 35 },
|
card: { w: 35, h: 35 },
|
||||||
table: { w: 60, h: 35 },
|
table: { w: 60, h: 35 },
|
||||||
|
|||||||
+433
-44
@@ -3,7 +3,7 @@
|
|||||||
* 由 store.js 迁移:用 Vue reactive 替代手写 pub/sub,组件自动追踪
|
* 由 store.js 迁移:用 Vue reactive 替代手写 pub/sub,组件自动追踪
|
||||||
* ===================================================================== */
|
* ===================================================================== */
|
||||||
import { reactive, computed } from 'vue'
|
import { reactive, computed } from 'vue'
|
||||||
import type { AiCfg, Deck, LibItem, PageTemplate, Slide, SlideElement, ThemeKey, ChatMessage } from './types'
|
import type { AiCfg, Deck, LibItem, PageTemplate, Slide, SlideElement, ThemeKey, ChatMessage, Annotation } from './types'
|
||||||
import { DECK_VERSION, SAMPLE_DECK, createElement, themes, uid } from './sample'
|
import { DECK_VERSION, SAMPLE_DECK, createElement, themes, uid } from './sample'
|
||||||
import { applyOp, invertOp, type Op, type HistoryEntry } from './op'
|
import { applyOp, invertOp, type Op, type HistoryEntry } from './op'
|
||||||
|
|
||||||
@@ -15,21 +15,22 @@ const ACTIVE_KEY = 'u-ppt.activeLib.v1'
|
|||||||
const CHAT_PREFIX = 'u-ppt.chat.'
|
const CHAT_PREFIX = 'u-ppt.chat.'
|
||||||
const SAVE_DELAY = 400
|
const SAVE_DELAY = 400
|
||||||
|
|
||||||
/* ---------- 内置页面模板(版式预设) ---------- */
|
/* ---------- 内置页面模板(版式预设,按 group 分组展示) ---------- */
|
||||||
const BUILTIN_TEMPLATES: PageTemplate[] = [
|
const BUILTIN_TEMPLATES: PageTemplate[] = [
|
||||||
|
/* ===== 基础 ===== */
|
||||||
{
|
{
|
||||||
id: 'tpl-blank', name: '空白页', category: 'built-in', background: 'bg',
|
id: 'tpl-blank', name: '空白页', category: 'built-in', group: 'basic', background: 'bg',
|
||||||
elements: []
|
elements: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-title', name: '标题页', category: 'built-in', background: 'g-primary',
|
id: 'tpl-title', name: '标题页', category: 'built-in', group: 'basic', background: 'g-primary',
|
||||||
elements: [
|
elements: [
|
||||||
{ id: 'b1', type: 'title', x: 10, y: 38, w: 80, h: 20, content: '标题', style: { fontSize: 64, color: 'text', align: 'center', anim: 'pop' } },
|
{ id: 'b1', type: 'title', x: 10, y: 38, w: 80, h: 20, content: '标题', style: { fontSize: 64, color: 'text', align: 'center', anim: 'pop' } },
|
||||||
{ id: 'b2', type: 'text', x: 20, y: 60, w: 60, h: 8, content: '副标题', style: { fontSize: 24, color: 'muted', align: 'center', anim: 'fade-up' } }
|
{ id: 'b2', type: 'text', x: 20, y: 60, w: 60, h: 8, content: '副标题', style: { fontSize: 24, color: 'muted', align: 'center', anim: 'fade-up' } }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-section', name: '章节页', category: 'built-in', background: 'g-deep',
|
id: 'tpl-section', name: '章节页', category: 'built-in', group: 'basic', background: 'g-deep',
|
||||||
elements: [
|
elements: [
|
||||||
{ id: 's1', type: 'shape', x: 0, y: 0, w: 6, h: 100, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, anim: 'slide-l' } },
|
{ id: 's1', type: 'shape', x: 0, y: 0, w: 6, h: 100, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, anim: 'slide-l' } },
|
||||||
{ id: 's2', type: 'title', x: 12, y: 38, w: 70, h: 18, content: '章节标题', style: { fontSize: 56, color: 'text', align: 'left', anim: 'fade-up' } },
|
{ id: 's2', type: 'title', x: 12, y: 38, w: 70, h: 18, content: '章节标题', style: { fontSize: 56, color: 'text', align: 'left', anim: 'fade-up' } },
|
||||||
@@ -37,7 +38,19 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-cards3', name: '三卡片', category: 'built-in', background: 'bg',
|
id: 'tpl-toc', name: '目录页', category: 'built-in', group: 'basic', background: 'bg',
|
||||||
|
elements: [
|
||||||
|
{ id: 't1', type: 'title', x: 8, y: 8, w: 40, h: 12, content: '目录', style: { fontSize: 44, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 't2', type: 'shape', x: 8, y: 23, w: 10, h: 1.6, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'fade-up' } },
|
||||||
|
{ id: 't3', type: 'list', x: 10, y: 32, w: 36, h: 50, content: '01 第一个主题\n02 第二个主题\n03 第三个主题\n04 第四个主题', style: { fontSize: 26, color: 'text', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 't4', type: 'shape', x: 55, y: 0, w: 45, h: 100, content: '', style: { shapeType: 'circle', fill: 'accent', gradient: true, opacity: 0.15, anim: 'scale' } },
|
||||||
|
{ id: 't5', type: 'stat', x: 58, y: 32, w: 36, h: 36, content: '04', style: { fontSize: 90, color: 'primary', label: '个章节', labelColor: 'muted', labelSize: 20, anim: 'scale' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ===== 内容 ===== */
|
||||||
|
{
|
||||||
|
id: 'tpl-cards3', name: '三卡片', category: 'built-in', group: 'content', background: 'bg',
|
||||||
elements: [
|
elements: [
|
||||||
{ id: 'c1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
{ id: 'c1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
{ id: 'c2', type: 'card', x: 6, y: 28, w: 28, h: 60, content: '卡片一\n要点描述', style: { accent: 'primary', icon: '', anim: 'fade-up' } },
|
{ id: 'c2', type: 'card', x: 6, y: 28, w: 28, h: 60, content: '卡片一\n要点描述', style: { accent: 'primary', icon: '', anim: 'fade-up' } },
|
||||||
@@ -46,7 +59,7 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-compare', name: '对比页', category: 'built-in', background: 'bg',
|
id: 'tpl-compare', name: '对比页', category: 'built-in', group: 'content', background: 'bg',
|
||||||
elements: [
|
elements: [
|
||||||
{ id: 'p1', type: 'title', x: 10, y: 7, w: 80, h: 11, content: '对比标题', style: { fontSize: 36, color: 'primary', align: 'center', anim: 'fade-up' } },
|
{ id: 'p1', type: 'title', x: 10, y: 7, w: 80, h: 11, content: '对比标题', style: { fontSize: 36, color: 'primary', align: 'center', anim: 'fade-up' } },
|
||||||
{ id: 'p2', type: 'card', x: 7, y: 24, w: 40, h: 65, content: '左\n要点一\n要点二', style: { accent: 'muted', icon: '', anim: 'slide-l' } },
|
{ id: 'p2', type: 'card', x: 7, y: 24, w: 40, h: 65, content: '左\n要点一\n要点二', style: { accent: 'muted', icon: '', anim: 'slide-l' } },
|
||||||
@@ -54,7 +67,78 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-quote', name: '金句页', category: 'built-in', background: 'g-deep',
|
id: 'tpl-bullets', name: '要点列表', category: 'built-in', group: 'content', background: 'bg',
|
||||||
|
elements: [
|
||||||
|
{ id: 'l1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '要点标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 'l2', type: 'shape', x: 8, y: 23, w: 10, h: 1.6, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'fade-up' } },
|
||||||
|
{ id: 'l3', type: 'list', x: 10, y: 30, w: 80, h: 55, content: '第一个要点:具体说明\n第二个要点:具体说明\n第三个要点:具体说明\n第四个要点:具体说明', style: { fontSize: 28, color: 'text', align: 'left', anim: 'fade-up' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-image-text', name: '图文混排', category: 'built-in', group: 'content', background: 'bg',
|
||||||
|
elements: [
|
||||||
|
{ id: 'it1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '图文标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 'it2', type: 'image', x: 8, y: 26, w: 42, h: 60, content: '', style: { fit: 'cover', anim: 'fade-up' } },
|
||||||
|
{ id: 'it3', type: 'list', x: 56, y: 28, w: 38, h: 55, content: '要点一:具体说明\n要点二:具体说明\n要点三:具体说明', style: { fontSize: 26, color: 'text', align: 'left', anim: 'fade-up' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-timeline', name: '时间线', category: 'built-in', group: 'content', background: 'bg',
|
||||||
|
elements: [
|
||||||
|
{ id: 'tl1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '发展历程', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 'tl2', type: 'shape', x: 10, y: 52, w: 80, h: 0.6, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, opacity: 0.6, anim: 'fade' } },
|
||||||
|
{ id: 'tl3', type: 'shape', x: 18, y: 48.5, w: 2.4, h: 2.4, content: '', style: { shapeType: 'circle', fill: 'primary', anim: 'pop' } },
|
||||||
|
{ id: 'tl4', type: 'shape', x: 48, y: 48.5, w: 2.4, h: 2.4, content: '', style: { shapeType: 'circle', fill: 'primary', anim: 'pop' } },
|
||||||
|
{ id: 'tl5', type: 'shape', x: 78, y: 48.5, w: 2.4, h: 2.4, content: '', style: { shapeType: 'circle', fill: 'primary', anim: 'pop' } },
|
||||||
|
{ id: 'tl6', type: 'stat', x: 8, y: 26, w: 22, h: 20, content: '2023', style: { fontSize: 40, color: 'primary', label: '里程碑说明', labelColor: 'muted', labelSize: 16, anim: 'fade-up' } },
|
||||||
|
{ id: 'tl7', type: 'stat', x: 38, y: 26, w: 22, h: 20, content: '2024', style: { fontSize: 40, color: 'primary', label: '里程碑说明', labelColor: 'muted', labelSize: 16, anim: 'fade-up' } },
|
||||||
|
{ id: 'tl8', type: 'stat', x: 68, y: 26, w: 22, h: 20, content: '2025', style: { fontSize: 40, color: 'accent', label: '里程碑说明', labelColor: 'muted', labelSize: 16, anim: 'fade-up' } },
|
||||||
|
{ id: 'tl9', type: 'text', x: 8, y: 60, w: 22, h: 25, content: '这一年的关键事件与成果描述', style: { fontSize: 20, color: 'muted', align: 'center', anim: 'fade-up' } },
|
||||||
|
{ id: 'tl10', type: 'text', x: 38, y: 60, w: 22, h: 25, content: '这一年的关键事件与成果描述', style: { fontSize: 20, color: 'muted', align: 'center', anim: 'fade-up' } },
|
||||||
|
{ id: 'tl11', type: 'text', x: 68, y: 60, w: 22, h: 25, content: '这一年的关键事件与成果描述', style: { fontSize: 20, color: 'muted', align: 'center', anim: 'fade-up' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-steps', name: '步骤流程', category: 'built-in', group: 'content', background: 'bg',
|
||||||
|
elements: [
|
||||||
|
{ id: 'st1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '实施步骤', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 'st2', type: 'stat', x: 8, y: 30, w: 18, h: 26, content: '1', style: { fontSize: 64, color: 'primary', label: '第一步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
|
||||||
|
{ id: 'st3', type: 'text', x: 8, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } },
|
||||||
|
{ id: 'st4', type: 'shape', x: 27.5, y: 40, w: 5, h: 0.8, content: '', style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' } },
|
||||||
|
{ id: 'st5', type: 'stat', x: 33, y: 30, w: 18, h: 26, content: '2', style: { fontSize: 64, color: 'primary', label: '第二步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
|
||||||
|
{ id: 'st6', type: 'text', x: 33, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } },
|
||||||
|
{ id: 'st7', type: 'shape', x: 52.5, y: 40, w: 5, h: 0.8, content: '', style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' } },
|
||||||
|
{ id: 'st8', type: 'stat', x: 58, y: 30, w: 18, h: 26, content: '3', style: { fontSize: 64, color: 'primary', label: '第三步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
|
||||||
|
{ id: 'st9', type: 'text', x: 58, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } },
|
||||||
|
{ id: 'st10', type: 'shape', x: 77.5, y: 40, w: 5, h: 0.8, content: '', style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' } },
|
||||||
|
{ id: 'st11', type: 'stat', x: 83, y: 30, w: 18, h: 26, content: '4', style: { fontSize: 64, color: 'accent', label: '第四步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
|
||||||
|
{ id: 'st12', type: 'text', x: 83, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ===== 数据 ===== */
|
||||||
|
{
|
||||||
|
id: 'tpl-data', name: '数据页', category: 'built-in', group: 'data', background: 'panel',
|
||||||
|
elements: [
|
||||||
|
{ id: 'd1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '数据标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 'd2', type: 'stat', x: 8, y: 28, w: 28, h: 30, content: '65%', style: { fontSize: 76, color: 'primary', label: '说明', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
|
||||||
|
{ id: 'd3', type: 'chart', x: 42, y: 26, w: 52, h: 40, content: '[{"label":"A","value":65},{"label":"B","value":45},{"label":"C","value":30}]', style: { color: 'primary', max: 100, chartType: 'bar', anim: 'fade-up' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-data3', name: '三数据页', category: 'built-in', group: 'data', background: 'bg',
|
||||||
|
elements: [
|
||||||
|
{ id: 'dd1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '关键数据', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 'dd2', type: 'stat', x: 8, y: 30, w: 27, h: 30, content: '2.4x', style: { fontSize: 68, color: 'primary', label: '指标说明一', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
|
||||||
|
{ id: 'dd3', type: 'stat', x: 37, y: 30, w: 27, h: 30, content: '87%', style: { fontSize: 68, color: 'accent', label: '指标说明二', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
|
||||||
|
{ id: 'dd4', type: 'stat', x: 66, y: 30, w: 27, h: 30, content: '120k', style: { fontSize: 68, color: 'primary', label: '指标说明三', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
|
||||||
|
{ id: 'dd5', type: 'chart', x: 15, y: 62, w: 70, h: 30, content: '{"series":["本季","上季"],"items":[{"label":"华东","values":[120,95]},{"label":"华南","values":[100,88]},{"label":"华北","values":[86,70]}]}', style: { color: 'primary', chartType: 'bar', legend: true, grid: true, anim: 'fade-up' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ===== 收尾 ===== */
|
||||||
|
{
|
||||||
|
id: 'tpl-quote', name: '金句页', category: 'built-in', group: 'ending', background: 'g-deep',
|
||||||
elements: [
|
elements: [
|
||||||
{ id: 'q1', type: 'shape', x: 8, y: 24, w: 4, h: 52, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'slide-l' } },
|
{ id: 'q1', type: 'shape', x: 8, y: 24, w: 4, h: 52, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'slide-l' } },
|
||||||
{ id: 'q2', type: 'quote', x: 16, y: 28, w: 76, h: 34, content: '金句内容', style: { fontSize: 48, color: 'text', italic: true, align: 'left', anim: 'scale' } },
|
{ id: 'q2', type: 'quote', x: 16, y: 28, w: 76, h: 34, content: '金句内容', style: { fontSize: 48, color: 'text', italic: true, align: 'left', anim: 'scale' } },
|
||||||
@@ -62,11 +146,22 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-data', name: '数据页', category: 'built-in', background: 'panel',
|
id: 'tpl-team', name: '团队页', category: 'built-in', group: 'ending', background: 'bg',
|
||||||
elements: [
|
elements: [
|
||||||
{ id: 'd1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '数据标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
{ id: 'tm1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '核心团队', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||||
{ id: 'd2', type: 'stat', x: 8, y: 28, w: 28, h: 30, content: '65%', style: { fontSize: 76, color: 'primary', label: '说明', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
|
{ id: 'tm2', type: 'card', x: 8, y: 28, w: 27, h: 55, content: '姓名\n职位\n一句话介绍', style: { accent: 'primary', icon: '', anim: 'fade-up' } },
|
||||||
{ id: 'd3', type: 'chart', x: 42, y: 26, w: 52, h: 40, content: '[{"label":"A","value":65},{"label":"B","value":45},{"label":"C","value":30}]', style: { color: 'primary', max: 100, chartType: 'bar', anim: 'fade-up' } }
|
{ id: 'tm3', type: 'card', x: 37, y: 28, w: 27, h: 55, content: '姓名\n职位\n一句话介绍', style: { accent: 'accent', icon: '', anim: 'fade-up' } },
|
||||||
|
{ id: 'tm4', type: 'card', x: 66, y: 28, w: 27, h: 55, content: '姓名\n职位\n一句话介绍', style: { accent: 'primary', icon: '', anim: 'fade-up' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-thanks', name: '感谢页', category: 'built-in', group: 'ending', background: 'g-primary',
|
||||||
|
elements: [
|
||||||
|
{ id: 'th1', type: 'shape', x: 55, y: -25, w: 65, h: 100, content: '', style: { shapeType: 'circle', fill: 'accent', gradient: true, opacity: 0.3, anim: 'scale' } },
|
||||||
|
{ id: 'th2', type: 'shape', x: 0, y: 0, w: 6, h: 100, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, anim: 'slide-l' } },
|
||||||
|
{ id: 'th3', type: 'title', x: 10, y: 34, w: 70, h: 18, content: '感谢观看', style: { fontSize: 62, color: 'text', align: 'left', anim: 'pop' } },
|
||||||
|
{ id: 'th4', type: 'text', x: 10, y: 56, w: 60, h: 10, content: '欢迎交流与讨论', style: { fontSize: 26, color: 'muted', align: 'left', anim: 'fade-up' } },
|
||||||
|
{ id: 'th5', type: 'text', x: 10, y: 82, w: 50, h: 7, content: '联系方式 / 二维码占位', style: { fontSize: 18, color: 'muted', align: 'left', anim: 'fade' } }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -87,12 +182,18 @@ const state = reactive<{
|
|||||||
deck: Deck
|
deck: Deck
|
||||||
currentIndex: number
|
currentIndex: number
|
||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
|
/** 多选集(框选/Shift加选时非空;selectedId 始终为锚点元素) */
|
||||||
|
selectedIds: string[]
|
||||||
activeLibId: string | null
|
activeLibId: string | null
|
||||||
|
/** 当前选中的批注 id(纯 UI 态,不持久化;Delete 删除优先级高于宿主元素) */
|
||||||
|
selectedAnnoId: string | null
|
||||||
}>({
|
}>({
|
||||||
deck: JSON.parse(JSON.stringify(SAMPLE_DECK)),
|
deck: JSON.parse(JSON.stringify(SAMPLE_DECK)),
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
activeLibId: null
|
selectedIds: [],
|
||||||
|
activeLibId: null,
|
||||||
|
selectedAnnoId: null
|
||||||
})
|
})
|
||||||
|
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -100,13 +201,40 @@ let saveTimer: ReturnType<typeof setTimeout> | null = null
|
|||||||
/* ---------- 工具 ---------- */
|
/* ---------- 工具 ---------- */
|
||||||
function clone<T>(o: T): T { return JSON.parse(JSON.stringify(o)) }
|
function clone<T>(o: T): T { return JSON.parse(JSON.stringify(o)) }
|
||||||
|
|
||||||
|
/* ---------- 持久化告警(写入失败置位,供 UI 提示,避免静默丢数据) ---------- */
|
||||||
|
const saveWarning = reactive({ text: '' })
|
||||||
|
|
||||||
|
/** 安全写入 localStorage;失败时置告警(非静默),返回是否成功 */
|
||||||
|
function safeSet(key: string, val: string): boolean {
|
||||||
|
try { localStorage.setItem(key, val); saveWarning.text = ''; return true }
|
||||||
|
catch (e) {
|
||||||
|
if (!saveWarning.text) {
|
||||||
|
saveWarning.text = '本地存储空间不足,最新改动可能未保存。请导出 JSON 备份,或删除部分图片/文库释放空间'
|
||||||
|
}
|
||||||
|
console.error('[u-ppt] 保存失败:', key, e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleSave() {
|
function scheduleSave() {
|
||||||
if (saveTimer) clearTimeout(saveTimer)
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
saveTimer = setTimeout(() => {
|
saveTimer = setTimeout(() => {
|
||||||
try { localStorage.setItem(LS_KEY, JSON.stringify(state.deck)) } catch (e) { /* 配额满等忽略 */ }
|
safeSet(LS_KEY, JSON.stringify(state.deck))
|
||||||
|
syncActiveLibItem()
|
||||||
}, SAVE_DELAY)
|
}, SAVE_DELAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 自动暂存:deck 有活跃文库条目时,改动自动回写文库(不点保存也不丢,刷新/首页重开均为最新) */
|
||||||
|
function syncActiveLibItem() {
|
||||||
|
if (!state.activeLibId) return
|
||||||
|
const lib = getLibrary()
|
||||||
|
const item = lib.find(x => x.id === state.activeLibId)
|
||||||
|
if (!item) return
|
||||||
|
item.deck = clone(state.deck)
|
||||||
|
item.updatedAt = Date.now()
|
||||||
|
setLibrary(lib)
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 颜色/背景解析(纯函数,供组件调用) ---------- */
|
/* ---------- 颜色/背景解析(纯函数,供组件调用) ---------- */
|
||||||
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||||
const c = String(hex).replace('#', '')
|
const c = String(hex).replace('#', '')
|
||||||
@@ -179,7 +307,7 @@ export function resolveBg(key: string): string {
|
|||||||
/* ---------- 内部:执行 Op 并推入历史 ---------- */
|
/* ---------- 内部:执行 Op 并推入历史 ---------- */
|
||||||
|
|
||||||
/** 执行一个 Op,计算逆 Op,推入历史栈。返回是否执行成功 */
|
/** 执行一个 Op,计算逆 Op,推入历史栈。返回是否执行成功 */
|
||||||
function execOp(op: Op, opts?: { coalesceKey?: string }): void {
|
function execOp(op: Op, opts?: { coalesceKey?: string; group?: string }): void {
|
||||||
if (suppressHistory) {
|
if (suppressHistory) {
|
||||||
state.deck = applyOp(state.deck, op)
|
state.deck = applyOp(state.deck, op)
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
@@ -199,7 +327,7 @@ function execOp(op: Op, opts?: { coalesceKey?: string }): void {
|
|||||||
// 执行正向
|
// 执行正向
|
||||||
state.deck = applyOp(state.deck, op)
|
state.deck = applyOp(state.deck, op)
|
||||||
// 推入历史
|
// 推入历史
|
||||||
const entry: HistoryEntry = { forward: op, backward }
|
const entry: HistoryEntry = { forward: op, backward, group: opts?.group }
|
||||||
hist.push(entry)
|
hist.push(entry)
|
||||||
if (hist.length > HIST_MAX) hist.shift()
|
if (hist.length > HIST_MAX) hist.shift()
|
||||||
future.length = 0
|
future.length = 0
|
||||||
@@ -229,6 +357,18 @@ function migrateDeck(d: any): Deck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 初始化 ---------- */
|
/* ---------- 初始化 ---------- */
|
||||||
|
|
||||||
|
/** 工作区 deck 是否有用户内容(区别于示例/空白):非示例且有元素,或页数>1 */
|
||||||
|
function hasWorkDeck(): boolean {
|
||||||
|
const d = state.deck
|
||||||
|
if (!d || !d.slides?.length) return false
|
||||||
|
const sampleTitle = SAMPLE_DECK.slides[0]?.elements.find(e => e.type === 'title')?.content
|
||||||
|
const firstTitle = d.slides[0]?.elements.find(e => e.type === 'title')?.content
|
||||||
|
const isSample = d.slides.length === SAMPLE_DECK.slides.length && sampleTitle && firstTitle === sampleTitle
|
||||||
|
if (isSample) return false
|
||||||
|
return d.slides.length > 1 || d.slides.some(s => s.elements.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
let loaded: Deck | null = null
|
let loaded: Deck | null = null
|
||||||
try { loaded = JSON.parse(localStorage.getItem(LS_KEY) || 'null') } catch (e) { /* ignore */ }
|
try { loaded = JSON.parse(localStorage.getItem(LS_KEY) || 'null') } catch (e) { /* ignore */ }
|
||||||
@@ -285,10 +425,114 @@ function setCurrentIndex(i: number) {
|
|||||||
i = Math.max(0, Math.min(state.deck.slides.length - 1, i))
|
i = Math.max(0, Math.min(state.deck.slides.length - 1, i))
|
||||||
if (i === state.currentIndex) return
|
if (i === state.currentIndex) return
|
||||||
state.currentIndex = i
|
state.currentIndex = i
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
}
|
}
|
||||||
function selectElement(id: string | null) { state.selectedId = id || null }
|
function selectElement(id: string | null) { state.selectedId = id || null; state.selectedIds = [] }
|
||||||
|
|
||||||
|
/* ---------- 多选(框选 / Shift 加选 / Ctrl+A) ---------- */
|
||||||
|
const selectedIds = computed(() => state.selectedIds)
|
||||||
|
|
||||||
|
/* ---------- 批注选中(纯 UI 态) ---------- */
|
||||||
|
const selectedAnnoId = computed(() => state.selectedAnnoId)
|
||||||
|
function setSelectedAnnoId(id: string | null) { state.selectedAnnoId = id }
|
||||||
|
|
||||||
|
/** 有效多选集:过滤掉已不存在的元素,非空(length>1)时表示多选态 */
|
||||||
|
function getSelection(): string[] {
|
||||||
|
const s = currentSlide.value
|
||||||
|
if (!s || !state.selectedIds.length) return []
|
||||||
|
return state.selectedIds.filter(id => s.elements.some(e => e.id === id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMany(ids: string[]) {
|
||||||
|
state.selectedIds = [...ids]
|
||||||
|
state.selectedId = ids.length ? ids[ids.length - 1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shift+点击:在多选集中加入/移除某元素 */
|
||||||
|
function toggleSelect(id: string) {
|
||||||
|
const cur = [...state.selectedIds]
|
||||||
|
const i = cur.indexOf(id)
|
||||||
|
if (i >= 0) cur.splice(i, 1)
|
||||||
|
else cur.push(id)
|
||||||
|
state.selectedIds = cur
|
||||||
|
state.selectedId = cur.length ? cur[cur.length - 1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllElements() {
|
||||||
|
const s = currentSlide.value
|
||||||
|
if (!s) return
|
||||||
|
state.selectedIds = s.elements.map(e => e.id)
|
||||||
|
state.selectedId = state.selectedIds.length ? state.selectedIds[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多元素整体位移(框选拖动);每个元素独立 op 但同批 undo */
|
||||||
|
function moveElementsBy(ids: string[], dx: number, dy: number) {
|
||||||
|
const group = beginBatch()
|
||||||
|
for (const id of ids) {
|
||||||
|
const el = findElement(id)
|
||||||
|
if (!el) continue
|
||||||
|
const x = Math.max(0, Math.min(100 - el.w, el.x + dx))
|
||||||
|
const y = Math.max(0, Math.min(100 - el.h, el.y + dy))
|
||||||
|
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: id, patch: { x, y }, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多选对齐:left左对齐 hcenter水平居中 right右对齐 top顶对齐 vmiddle垂直居中 bottom底对齐 */
|
||||||
|
function alignElements(ids: string[], edge: 'left' | 'hcenter' | 'right' | 'top' | 'vmiddle' | 'bottom') {
|
||||||
|
const els = ids.map(findElement).filter(Boolean) as SlideElement[]
|
||||||
|
if (els.length < 2) return
|
||||||
|
const group = beginBatch()
|
||||||
|
const minX = Math.min(...els.map(e => e.x))
|
||||||
|
const maxX = Math.max(...els.map(e => e.x + e.w))
|
||||||
|
const minY = Math.min(...els.map(e => e.y))
|
||||||
|
const maxY = Math.max(...els.map(e => e.y + e.h))
|
||||||
|
for (const el of els) {
|
||||||
|
let x = el.x, y = el.y
|
||||||
|
if (edge === 'left') x = minX
|
||||||
|
if (edge === 'right') x = maxX - el.w
|
||||||
|
if (edge === 'hcenter') x = (minX + maxX) / 2 - el.w / 2
|
||||||
|
if (edge === 'top') y = minY
|
||||||
|
if (edge === 'bottom') y = maxY - el.h
|
||||||
|
if (edge === 'vmiddle') y = (minY + maxY) / 2 - el.h / 2
|
||||||
|
if (x !== el.x || y !== el.y) {
|
||||||
|
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: el.id, patch: { x, y }, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多选分布:h 水平等间距分布(x 方向)v 垂直等间距分布(y 方向);首尾不动,中间均匀排布 */
|
||||||
|
function distributeElements(ids: string[], axis: 'h' | 'v') {
|
||||||
|
const els = ids.map(findElement).filter(Boolean) as SlideElement[]
|
||||||
|
if (els.length < 3) return
|
||||||
|
const group = beginBatch()
|
||||||
|
const sorted = axis === 'h' ? [...els].sort((a, b) => a.x - b.x) : [...els].sort((a, b) => a.y - b.y)
|
||||||
|
const first = sorted[0], last = sorted[sorted.length - 1]
|
||||||
|
let gapTotal: number
|
||||||
|
if (axis === 'h') {
|
||||||
|
// last.x - first.x - first.w 为首尾间总空隙(含中间元素占的宽),再减中间元素总宽 = 纯空隙
|
||||||
|
gapTotal = (last.x - first.x - first.w) - sorted.slice(1, -1).reduce((s, e) => s + e.w, 0)
|
||||||
|
} else {
|
||||||
|
gapTotal = (last.y - first.y - first.h) - sorted.slice(1, -1).reduce((s, e) => s + e.h, 0)
|
||||||
|
}
|
||||||
|
const gap = gapTotal / (sorted.length - 1)
|
||||||
|
let cur = axis === 'h' ? first.x + first.w + gap : first.y + first.h + gap
|
||||||
|
for (let i = 1; i < sorted.length - 1; i++) {
|
||||||
|
const el = sorted[i]
|
||||||
|
const patch = axis === 'h' ? { x: cur, y: el.y } : { x: el.x, y: cur }
|
||||||
|
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: el.id, patch, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
|
||||||
|
cur += (axis === 'h' ? el.w : el.h) + gap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除(同批 undo) */
|
||||||
|
function delElements(ids: string[]) {
|
||||||
|
const group = beginBatch()
|
||||||
|
for (const id of ids) {
|
||||||
|
if (findElement(id)) delElement(id)
|
||||||
|
}
|
||||||
|
void group
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 主题 ---------- */
|
/* ---------- 主题 ---------- */
|
||||||
function setTheme(name: string) {
|
function setTheme(name: string) {
|
||||||
@@ -302,7 +546,7 @@ function addSlide(atIndex?: number) {
|
|||||||
const idx = atIndex != null ? atIndex + 1 : state.deck.slides.length
|
const idx = atIndex != null ? atIndex + 1 : state.deck.slides.length
|
||||||
execOp({ type: 'add_slide', atIndex: idx, slide: s, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'add_slide', atIndex: idx, slide: s, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.currentIndex = idx
|
state.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +557,7 @@ function dupSlide() {
|
|||||||
const idx = state.currentIndex + 1
|
const idx = state.currentIndex + 1
|
||||||
execOp({ type: 'add_slide', atIndex: idx, slide: copy, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'add_slide', atIndex: idx, slide: copy, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.currentIndex = idx
|
state.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function delSlide(i?: number): boolean {
|
function delSlide(i?: number): boolean {
|
||||||
@@ -322,7 +566,7 @@ function delSlide(i?: number): boolean {
|
|||||||
const deletedSlide = clone(state.deck.slides[idx])
|
const deletedSlide = clone(state.deck.slides[idx])
|
||||||
execOp({ type: 'del_slide', index: idx, deletedSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'del_slide', index: idx, deletedSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
if (state.currentIndex >= state.deck.slides.length) state.currentIndex = state.deck.slides.length - 1
|
if (state.currentIndex >= state.deck.slides.length) state.currentIndex = state.deck.slides.length - 1
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,10 +581,17 @@ function setSlideBackground(bg: string) {
|
|||||||
execOp({ type: 'set_slide_bg', index: state.currentIndex, bg: bg as Slide['background'], clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'set_slide_bg', index: state.currentIndex, bg: bg as Slide['background'], clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSlideNote(note: string) {
|
||||||
|
execOp(
|
||||||
|
{ type: 'set_slide_note', index: state.currentIndex, note, clientId: CLIENT_ID, timestamp: Date.now() },
|
||||||
|
{ coalesceKey: 'slide-note:' + state.currentIndex }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 元素 CRUD ---------- */
|
/* ---------- 元素 CRUD ---------- */
|
||||||
function addElement(type: SlideElement['type'], over?: Parameters<typeof createElement>[1]) {
|
function addElement(type: SlideElement['type'], over?: Parameters<typeof createElement>[1], group?: string) {
|
||||||
const el = createElement(type, over)
|
const el = createElement(type, over)
|
||||||
execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
|
||||||
state.selectedId = el.id
|
state.selectedId = el.id
|
||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
@@ -350,41 +601,162 @@ function updateElement(id: string, patch: Partial<SlideElement> & { style?: Reco
|
|||||||
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: id, patch, clientId: CLIENT_ID, timestamp: Date.now() }, { coalesceKey })
|
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: id, patch, clientId: CLIENT_ID, timestamp: Date.now() }, { coalesceKey })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 批注(annotation):挂在 element.style.annotations,走 updateElement ---------- */
|
||||||
|
function addAnnotation(elId: string) {
|
||||||
|
const el = findElement(elId)
|
||||||
|
if (!el) return
|
||||||
|
const list: Annotation[] = [...(el.style.annotations || [])]
|
||||||
|
const anno: Annotation = {
|
||||||
|
id: uid('an'),
|
||||||
|
text: '',
|
||||||
|
bx: Math.min(90, (el.x || 0) + (el.w || 20) + 4), by: Math.max(2, el.y || 10),
|
||||||
|
bw: 22, bh: 12,
|
||||||
|
ax: 50, ay: 50,
|
||||||
|
}
|
||||||
|
list.push(anno)
|
||||||
|
updateElement(elId, { style: { annotations: list } })
|
||||||
|
return anno.id
|
||||||
|
}
|
||||||
|
function updateAnnotation(elId: string, annoId: string, patch: Partial<Annotation>) {
|
||||||
|
const el = findElement(elId)
|
||||||
|
if (!el) return
|
||||||
|
const list = (el.style.annotations || []).map(a => {
|
||||||
|
if (a.id !== annoId) return a
|
||||||
|
const next: Annotation = { ...a, ...patch }
|
||||||
|
if (patch.line) next.line = { ...(a.line || {}), ...patch.line }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
updateElement(elId, { style: { annotations: list } })
|
||||||
|
}
|
||||||
|
function delAnnotation(elId: string, annoId: string) {
|
||||||
|
const el = findElement(elId)
|
||||||
|
if (!el) return
|
||||||
|
const list = (el.style.annotations || []).filter(a => a.id !== annoId)
|
||||||
|
updateElement(elId, { style: { annotations: list } })
|
||||||
|
}
|
||||||
|
|
||||||
function delElement(id: string) {
|
function delElement(id: string) {
|
||||||
const el = findElement(id)
|
const el = findElement(id)
|
||||||
const deletedElement = el ? clone(el) : undefined
|
const deletedElement = el ? clone(el) : undefined
|
||||||
execOp({ type: 'del_element', slideIdx: state.currentIndex, elementId: id, deletedElement, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'del_element', slideIdx: state.currentIndex, elementId: id, deletedElement, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
if (state.selectedId === id) state.selectedId = null
|
if (state.selectedId === id) state.selectedId = null; state.selectedIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveElementZ(id: string, dir: number) {
|
function moveElementZ(id: string, dir: number) {
|
||||||
execOp({ type: 'move_element_z', slideIdx: state.currentIndex, elementId: id, dir, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'move_element_z', slideIdx: state.currentIndex, elementId: id, dir, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 元素剪贴板(内存级,跨页可用,不持久化;支持多元素) ---------- */
|
||||||
|
let clipboard: SlideElement[] = []
|
||||||
|
|
||||||
|
function copyElements(ids: string[]): boolean {
|
||||||
|
const els = ids.map(findElement).filter(Boolean) as SlideElement[]
|
||||||
|
if (!els.length) return false
|
||||||
|
clipboard = clone(els)
|
||||||
|
pasteCount = 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyElement(id: string): boolean {
|
||||||
|
return copyElements([id])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 剪切 = 复制 + 删除(删除走 op,可撤销;撤销后剪贴板仍保留内容) */
|
||||||
|
function cutElements(ids: string[]): boolean {
|
||||||
|
if (!copyElements(ids)) return false
|
||||||
|
delElements(ids)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cutElement(id: string): boolean {
|
||||||
|
return cutElements([id])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 粘贴到当前页:新 id、位置阶梯偏移错开原位;连续粘贴合并为一条 undo */
|
||||||
|
let pasteCount = 0
|
||||||
|
let pasteTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let pasteGroup: string | null = null
|
||||||
|
|
||||||
|
function pasteElements(): boolean {
|
||||||
|
if (!clipboard.length) return false
|
||||||
|
pasteCount++
|
||||||
|
const off = (pasteCount - 1) * 3
|
||||||
|
// 连续粘贴(2s 内)共用同一 group → 一次 Ctrl+Z 整批撤销
|
||||||
|
if (!pasteTimer) pasteGroup = beginBatch()
|
||||||
|
else { clearTimeout(pasteTimer) }
|
||||||
|
pasteTimer = setTimeout(() => { pasteTimer = null; pasteGroup = null }, 2000)
|
||||||
|
|
||||||
|
const newIds: string[] = []
|
||||||
|
for (const src of clipboard) {
|
||||||
|
const el = createElement(src.type, {
|
||||||
|
...src,
|
||||||
|
id: uid('el'),
|
||||||
|
x: Math.min(97, Math.max(0, src.x + off)),
|
||||||
|
y: Math.min(97, Math.max(0, src.y + off)),
|
||||||
|
content: src.content,
|
||||||
|
segments: src.segments ? clone(src.segments) : undefined,
|
||||||
|
style: { ...src.style }
|
||||||
|
})
|
||||||
|
execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() }, { group: pasteGroup! })
|
||||||
|
newIds.push(el.id)
|
||||||
|
}
|
||||||
|
state.selectedIds = newIds
|
||||||
|
state.selectedId = newIds.length ? newIds[newIds.length - 1] : null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteElement(): boolean {
|
||||||
|
return pasteElements()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasClipboard(): boolean { return clipboard.length > 0 }
|
||||||
|
|
||||||
function findElement(id: string): SlideElement | null {
|
function findElement(id: string): SlideElement | null {
|
||||||
const s = currentSlide.value
|
const s = currentSlide.value
|
||||||
return s.elements.find(e => e.id === id) || null
|
return s.elements.find(e => e.id === id) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- undo / redo(基于 Op 回滚) ---------- */
|
/* ---------- undo / redo(基于 Op 回滚,同 group 批次整体回滚) ---------- */
|
||||||
|
let batchCounter = 0
|
||||||
|
|
||||||
|
/** 开始一个操作批次:期间所有 execOp 记录同 group 标记,undo 时整组回滚 */
|
||||||
|
function beginBatch(): string {
|
||||||
|
return 'batch-' + (++batchCounter) + '-' + Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
function undo(): boolean {
|
function undo(): boolean {
|
||||||
if (!hist.length) return false
|
if (!hist.length) return false
|
||||||
const entry = hist.pop()!
|
// 弹出栈顶;若带 group 标记,同组的全部一起弹出(整批回滚)
|
||||||
|
const popped: HistoryEntry[] = []
|
||||||
|
do {
|
||||||
|
popped.push(hist.pop()!)
|
||||||
|
} while (hist.length > 0 && hist[hist.length - 1].group != null && hist[hist.length - 1].group === popped[0].group)
|
||||||
suppressHistory = true
|
suppressHistory = true
|
||||||
state.deck = applyOp(state.deck, entry.backward)
|
// popped 按执行顺序倒排(栈顶=最后执行在最前),撤回时后执行的先撤
|
||||||
|
for (const e of popped) {
|
||||||
|
state.deck = applyOp(state.deck, e.backward)
|
||||||
|
}
|
||||||
suppressHistory = false
|
suppressHistory = false
|
||||||
future.push(entry)
|
for (let i = popped.length - 1; i >= 0; i--) future.push(popped[i])
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function redo(): boolean {
|
function redo(): boolean {
|
||||||
if (!future.length) return false
|
if (!future.length) return false
|
||||||
const entry = future.pop()!
|
// 同组的一起重做
|
||||||
|
const popped: HistoryEntry[] = []
|
||||||
|
const headGroup = future[future.length - 1].group
|
||||||
|
do {
|
||||||
|
popped.push(future.pop()!)
|
||||||
|
} while (future.length > 0 && headGroup != null && future[future.length - 1].group === headGroup)
|
||||||
suppressHistory = true
|
suppressHistory = true
|
||||||
state.deck = applyOp(state.deck, entry.forward)
|
// popped[0] 是最先执行的(undo 时最后压入 future),重做按原顺序:倒序遍历
|
||||||
|
for (let i = popped.length - 1; i >= 0; i--) {
|
||||||
|
state.deck = applyOp(state.deck, popped[i].forward)
|
||||||
|
}
|
||||||
suppressHistory = false
|
suppressHistory = false
|
||||||
hist.push(entry)
|
for (let i = popped.length - 1; i >= 0; i--) hist.push(popped[i])
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -415,7 +787,7 @@ function replaceDeck(newDeck: Deck | Record<string, unknown>, opts?: { keepHisto
|
|||||||
state.activeLibId = null
|
state.activeLibId = null
|
||||||
state.currentIndex = Math.min(state.currentIndex, d.slides.length - 1)
|
state.currentIndex = Math.min(state.currentIndex, d.slides.length - 1)
|
||||||
if (state.currentIndex < 0) state.currentIndex = 0
|
if (state.currentIndex < 0) state.currentIndex = 0
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceSlide(index: number, newSlide: Slide) {
|
function replaceSlide(index: number, newSlide: Slide) {
|
||||||
@@ -423,17 +795,17 @@ function replaceSlide(index: number, newSlide: Slide) {
|
|||||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
||||||
const oldSlide = state.deck.slides[index] ? clone(state.deck.slides[index]) : undefined
|
const oldSlide = state.deck.slides[index] ? clone(state.deck.slides[index]) : undefined
|
||||||
execOp({ type: 'replace_slide', index, slide: newSlide, oldSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'replace_slide', index, slide: newSlide, oldSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendSlide(newSlide: Slide) {
|
function appendSlide(newSlide: Slide, group?: string) {
|
||||||
newSlide.id = uid('s')
|
newSlide.id = uid('s')
|
||||||
newSlide.elements = newSlide.elements || []
|
newSlide.elements = newSlide.elements || []
|
||||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
||||||
const idx = state.deck.slides.length
|
const idx = state.deck.slides.length
|
||||||
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
|
||||||
state.currentIndex = idx
|
state.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertSlideAt(index: number, newSlide: Slide) {
|
function insertSlideAt(index: number, newSlide: Slide) {
|
||||||
@@ -442,7 +814,7 @@ function insertSlideAt(index: number, newSlide: Slide) {
|
|||||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
||||||
execOp({ type: 'add_slide', atIndex: index, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'add_slide', atIndex: index, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.currentIndex = index
|
state.currentIndex = index
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
@@ -475,7 +847,7 @@ function getCfg(): AiCfg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setCfg(cfg: AiCfg) {
|
function setCfg(cfg: AiCfg) {
|
||||||
localStorage.setItem(LS_CFG, JSON.stringify({ ...DEFAULT_CFG, ...cfg }))
|
safeSet(LS_CFG, JSON.stringify({ ...DEFAULT_CFG, ...cfg }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 自定义主题:AI 配色建议应用后存储 ---------- */
|
/* ---------- 自定义主题:AI 配色建议应用后存储 ---------- */
|
||||||
@@ -562,7 +934,7 @@ function addSlideFromTemplate(tplId: string): boolean {
|
|||||||
const idx = state.currentIndex + 1
|
const idx = state.currentIndex + 1
|
||||||
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.currentIndex = idx
|
state.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null; state.selectedIds = []
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,7 +950,7 @@ function getLibrary(): LibItem[] {
|
|||||||
try { return JSON.parse(localStorage.getItem(LIB_KEY) || '[]') || [] } catch (e) { return [] }
|
try { return JSON.parse(localStorage.getItem(LIB_KEY) || '[]') || [] } catch (e) { return [] }
|
||||||
}
|
}
|
||||||
function setLibrary(arr: LibItem[]) {
|
function setLibrary(arr: LibItem[]) {
|
||||||
try { localStorage.setItem(LIB_KEY, JSON.stringify(arr)) } catch (e) {}
|
safeSet(LIB_KEY, JSON.stringify(arr))
|
||||||
}
|
}
|
||||||
function getActiveLibId() { return state.activeLibId }
|
function getActiveLibId() { return state.activeLibId }
|
||||||
function setActive(id: string | null) {
|
function setActive(id: string | null) {
|
||||||
@@ -664,9 +1036,15 @@ function getChat(chatId: string): ChatMessage[] {
|
|||||||
try { return JSON.parse(localStorage.getItem(CHAT_PREFIX + chatId) || '[]') || [] } catch (e) { return [] }
|
try { return JSON.parse(localStorage.getItem(CHAT_PREFIX + chatId) || '[]') || [] } catch (e) { return [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 写入某个 chatId 的会话 */
|
/** 写入某个 chatId 的会话(超 1.5MB 时从最旧开始丢弃,防写满 localStorage) */
|
||||||
function setChat(chatId: string, msgs: ChatMessage[]) {
|
function setChat(chatId: string, msgs: ChatMessage[]) {
|
||||||
try { localStorage.setItem(CHAT_PREFIX + chatId, JSON.stringify(msgs)) } catch (e) {}
|
let list = msgs
|
||||||
|
while (list.length > 2) {
|
||||||
|
const size = JSON.stringify(list).length
|
||||||
|
if (size <= 1_500_000) break
|
||||||
|
list = list.slice(2) // 成对丢弃(user+assistant)
|
||||||
|
}
|
||||||
|
safeSet(CHAT_PREFIX + chatId, JSON.stringify(list))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清除某个 chatId 的会话 */
|
/** 清除某个 chatId 的会话 */
|
||||||
@@ -691,6 +1069,7 @@ function recoverBackup(): boolean {
|
|||||||
export const store = {
|
export const store = {
|
||||||
// 状态(响应式)
|
// 状态(响应式)
|
||||||
state,
|
state,
|
||||||
|
saveWarning,
|
||||||
// computed
|
// computed
|
||||||
deck, slides, count, currentIndex, theme, selectedId, activeLibId,
|
deck, slides, count, currentIndex, theme, selectedId, activeLibId,
|
||||||
currentSlide, canUndo, canRedo,
|
currentSlide, canUndo, canRedo,
|
||||||
@@ -704,11 +1083,19 @@ export const store = {
|
|||||||
// 主题
|
// 主题
|
||||||
setTheme,
|
setTheme,
|
||||||
// 幻灯片 CRUD
|
// 幻灯片 CRUD
|
||||||
addSlide, dupSlide, delSlide, moveSlide, setSlideBackground,
|
addSlide, dupSlide, delSlide, moveSlide, setSlideBackground, setSlideNote,
|
||||||
// 元素 CRUD
|
// 元素 CRUD
|
||||||
addElement, updateElement, delElement, moveElementZ, findElement,
|
addElement, updateElement, delElement, moveElementZ, findElement,
|
||||||
|
// 批注
|
||||||
|
addAnnotation, updateAnnotation, delAnnotation,
|
||||||
|
// 元素剪贴板
|
||||||
|
copyElement, copyElements, cutElement, cutElements, pasteElement, pasteElements, hasClipboard,
|
||||||
|
// 多选
|
||||||
|
selectedIds, getSelection, selectMany, toggleSelect, selectAllElements,
|
||||||
|
selectedAnnoId, setSelectedAnnoId,
|
||||||
|
moveElementsBy, delElements, alignElements, distributeElements,
|
||||||
// undo/redo
|
// undo/redo
|
||||||
undo, redo,
|
undo, redo, beginBatch,
|
||||||
// 整体替换
|
// 整体替换
|
||||||
replaceDeck, replaceSlide, appendSlide, insertSlideAt,
|
replaceDeck, replaceSlide, appendSlide, insertSlideAt,
|
||||||
reset, exportJSON, importJSON,
|
reset, exportJSON, importJSON,
|
||||||
@@ -723,6 +1110,8 @@ export const store = {
|
|||||||
getTemplates, getUserTemplates, saveCurrentAsTemplate, addSlideFromTemplate, deleteTemplate,
|
getTemplates, getUserTemplates, saveCurrentAsTemplate, addSlideFromTemplate, deleteTemplate,
|
||||||
// 会话管理
|
// 会话管理
|
||||||
getChatId, getChat, setChat, clearChat, newChatId,
|
getChatId, getChat, setChat, clearChat, newChatId,
|
||||||
|
// 暂存检测
|
||||||
|
hasWorkDeck,
|
||||||
// 备份恢复
|
// 备份恢复
|
||||||
recoverBackup,
|
recoverBackup,
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-2
@@ -17,7 +17,7 @@ export type ColorKey = 'primary' | 'accent' | 'text' | 'muted' | (string & {})
|
|||||||
/** 元素类型 */
|
/** 元素类型 */
|
||||||
export type ElementType =
|
export type ElementType =
|
||||||
| 'title' | 'text' | 'list' | 'stat' | 'quote'
|
| 'title' | 'text' | 'list' | 'stat' | 'quote'
|
||||||
| 'image' | 'shape' | 'chart' | 'card'
|
| 'image' | 'video' | 'shape' | 'chart' | 'card'
|
||||||
| 'table' | 'code' | 'formula'
|
| 'table' | 'code' | 'formula'
|
||||||
|
|
||||||
/** 图表子类型 */
|
/** 图表子类型 */
|
||||||
@@ -31,7 +31,9 @@ export type AnimType =
|
|||||||
| 'pop' | 'rotate' | 'bounce' | 'flip' | 'blur'
|
| 'pop' | 'rotate' | 'bounce' | 'flip' | 'blur'
|
||||||
|
|
||||||
/** 形状类型 */
|
/** 形状类型 */
|
||||||
export type ShapeType = 'rect' | 'circle' | 'triangle'
|
export type ShapeType =
|
||||||
|
| 'rect' | 'circle' | 'ellipse' | 'triangle' | 'diamond'
|
||||||
|
| 'pentagon' | 'hexagon' | 'star' | 'arrow' | 'chevron' | 'bubble'
|
||||||
|
|
||||||
/** 主题对象 */
|
/** 主题对象 */
|
||||||
export interface Theme {
|
export interface Theme {
|
||||||
@@ -44,6 +46,26 @@ export interface Theme {
|
|||||||
muted: string
|
muted: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批注(连线 + 气泡) */
|
||||||
|
export interface Annotation {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
/** 气泡框:百分比坐标,与元素同坐标系(画布 1280×720) */
|
||||||
|
bx: number; by: number; bw: number; bh: number
|
||||||
|
/** 图片侧连线锚点(相对图片框的百分比 0~100),默认 50/50 */
|
||||||
|
ax?: number; ay?: number
|
||||||
|
/** 气泡文字整体样式 */
|
||||||
|
fontSize?: number; color?: ColorKey; bold?: boolean; italic?: boolean; align?: 'left' | 'center' | 'right'
|
||||||
|
/** 连线样式 */
|
||||||
|
line?: {
|
||||||
|
style?: 'solid' | 'dashed'
|
||||||
|
width?: number
|
||||||
|
color?: ColorKey
|
||||||
|
startCap?: 'none' | 'arrow' | 'dot'
|
||||||
|
endCap?: 'none' | 'arrow' | 'dot'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 元素样式(所有字段可选,按 type 决定哪些有效) */
|
/** 元素样式(所有字段可选,按 type 决定哪些有效) */
|
||||||
export interface ElementStyle {
|
export interface ElementStyle {
|
||||||
fontSize?: number
|
fontSize?: number
|
||||||
@@ -58,6 +80,11 @@ export interface ElementStyle {
|
|||||||
labelSize?: number
|
labelSize?: number
|
||||||
// image
|
// image
|
||||||
fit?: 'contain' | 'cover'
|
fit?: 'contain' | 'cover'
|
||||||
|
// video
|
||||||
|
poster?: string
|
||||||
|
autoplay?: boolean
|
||||||
|
loop?: boolean
|
||||||
|
muted?: boolean
|
||||||
// shape
|
// shape
|
||||||
shapeType?: ShapeType
|
shapeType?: ShapeType
|
||||||
fill?: ColorKey
|
fill?: ColorKey
|
||||||
@@ -85,6 +112,8 @@ export interface ElementStyle {
|
|||||||
// card
|
// card
|
||||||
icon?: string
|
icon?: string
|
||||||
accent?: ColorKey
|
accent?: ColorKey
|
||||||
|
// annotation(批注气泡,image 起步,未来任意元素)
|
||||||
|
annotations?: Annotation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 幻灯片元素 */
|
/** 幻灯片元素 */
|
||||||
@@ -106,6 +135,7 @@ export interface Slide {
|
|||||||
id: string
|
id: string
|
||||||
background: BgKey
|
background: BgKey
|
||||||
elements: SlideElement[]
|
elements: SlideElement[]
|
||||||
|
note?: string // 演讲者备注/页脚批注(不渲染到幻灯片)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一份演示文稿 */
|
/** 一份演示文稿 */
|
||||||
@@ -137,6 +167,8 @@ export interface PageTemplate {
|
|||||||
name: string
|
name: string
|
||||||
/** 模板分类:built-in / user */
|
/** 模板分类:built-in / user */
|
||||||
category: 'built-in' | 'user'
|
category: 'built-in' | 'user'
|
||||||
|
/** 展示分组:basic 基础 / content 内容 / data 数据 / ending 收尾(缺省按内容归组) */
|
||||||
|
group?: 'basic' | 'content' | 'data' | 'ending'
|
||||||
background: BgKey
|
background: BgKey
|
||||||
/** 元素结构(内容可为占位符如「标题」「正文」) */
|
/** 元素结构(内容可为占位符如「标题」「正文」) */
|
||||||
elements: SlideElement[]
|
elements: SlideElement[]
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== PDF 导出 / 打印 ===== */
|
||||||
|
/* A4 横向满幅:幻灯片 16:9 与 297×209.8mm 同构,元素 % 坐标原样映射 */
|
||||||
|
@media print {
|
||||||
|
@page { size: A4 landscape; margin: 0; }
|
||||||
|
body { overflow: visible !important; height: auto !important; }
|
||||||
|
html { height: auto !important; }
|
||||||
|
}
|
||||||
h1, h2, h3, h4, h5, h6, p { margin: 0; }
|
h1, h2, h3, h4, h5, h6, p { margin: 0; }
|
||||||
button { font-family: inherit; cursor: pointer; }
|
button { font-family: inherit; cursor: pointer; }
|
||||||
input, select, textarea { font-family: inherit; font-size: inherit; color: inherit; }
|
input, select, textarea { font-family: inherit; font-size: inherit; color: inherit; }
|
||||||
@@ -97,6 +105,24 @@ input[type="range"]::-webkit-slider-thumb {
|
|||||||
}
|
}
|
||||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
|
||||||
|
/* ---------- 全局拖放遮罩 ---------- */
|
||||||
|
.global-drop-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 9000;
|
||||||
|
background: rgba(15, 23, 42, .55); backdrop-filter: blur(2px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
pointer-events: none; /* 不拦截 drop,让 window 接管 */
|
||||||
|
}
|
||||||
|
.global-drop-card {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||||
|
padding: 36px 48px; border-radius: 16px;
|
||||||
|
border: 2px dashed rgba(255, 255, 255, .65);
|
||||||
|
background: rgba(255, 255, 255, .08);
|
||||||
|
color: #fff; text-align: center;
|
||||||
|
}
|
||||||
|
.global-drop-card .global-drop-icon { font-size: 48px; }
|
||||||
|
.global-drop-card strong { font-size: 18px; }
|
||||||
|
.global-drop-card .global-drop-hint { font-size: 13px; opacity: .75; }
|
||||||
|
|
||||||
/* ---------- 弹窗 ---------- */
|
/* ---------- 弹窗 ---------- */
|
||||||
.modal-mask {
|
.modal-mask {
|
||||||
position: fixed; inset: 0; background: rgba(15, 23, 42, .45);
|
position: fixed; inset: 0; background: rgba(15, 23, 42, .45);
|
||||||
|
|||||||
+35
-4
@@ -73,6 +73,31 @@
|
|||||||
|
|
||||||
.msg .bubble .stream-text { white-space: pre-wrap; }
|
.msg .bubble .stream-text { white-space: pre-wrap; }
|
||||||
|
|
||||||
|
/* Markdown 正文排版(marked 渲染后的气泡内容) */
|
||||||
|
.msg .bubble .md-body > *:first-child { margin-top: 0; }
|
||||||
|
.msg .bubble .md-body > *:last-child { margin-bottom: 0; }
|
||||||
|
.msg .bubble .md-body p { margin: 6px 0; }
|
||||||
|
.msg .bubble .md-body h1, .msg .bubble .md-body h2, .msg .bubble .md-body h3,
|
||||||
|
.msg .bubble .md-body h4 { margin: 10px 0 6px; font-size: 14px; font-weight: 600; }
|
||||||
|
.msg .bubble .md-body h1 { font-size: 16px; }
|
||||||
|
.msg .bubble .md-body h2 { font-size: 15px; }
|
||||||
|
.msg .bubble .md-body ul, .msg .bubble .md-body ol { margin: 6px 0; padding-left: 1.4em; }
|
||||||
|
.msg .bubble .md-body li { margin: 3px 0; }
|
||||||
|
.msg .bubble .md-body strong { font-weight: 600; }
|
||||||
|
.msg .bubble .md-body code {
|
||||||
|
font-family: 'Consolas','Monaco','Courier New',monospace;
|
||||||
|
font-size: 12px; padding: 1px 5px; border-radius: 4px;
|
||||||
|
background: rgba(15,23,42,.08);
|
||||||
|
}
|
||||||
|
.msg.user .md-body code { background: rgba(255,255,255,.2); }
|
||||||
|
.msg .bubble .md-body blockquote {
|
||||||
|
margin: 6px 0; padding: 2px 10px; border-left: 3px solid var(--ui-border);
|
||||||
|
color: var(--ui-muted);
|
||||||
|
}
|
||||||
|
.msg .bubble .md-body table { border-collapse: collapse; margin: 6px 0; font-size: 12px; }
|
||||||
|
.msg .bubble .md-body th, .msg .bubble .md-body td { border: 1px solid var(--ui-border); padding: 4px 8px; }
|
||||||
|
.msg .bubble .md-body hr { border: none; border-top: 1px solid var(--ui-border); margin: 8px 0; }
|
||||||
|
|
||||||
/* 消息中的代码块 */
|
/* 消息中的代码块 */
|
||||||
.msg .msg-code {
|
.msg .msg-code {
|
||||||
margin: 6px 0; padding: 10px 12px;
|
margin: 6px 0; padding: 10px 12px;
|
||||||
@@ -89,12 +114,18 @@
|
|||||||
.msg .msg-code .jn { color: #d97706; } /* number */
|
.msg .msg-code .jn { color: #d97706; } /* number */
|
||||||
.msg .msg-code .jb { color: #e11d48; font-weight: 600; } /* boolean/null */
|
.msg .msg-code .jb { color: #e11d48; font-weight: 600; } /* boolean/null */
|
||||||
|
|
||||||
|
/* 流式打字机光标(参考 devflow:细竖线 + 柔和步进闪烁) */
|
||||||
.cursor {
|
.cursor {
|
||||||
display: inline-block; width: 7px; height: 1em;
|
display: inline-block;
|
||||||
background: currentColor; vertical-align: -2px; margin-left: 2px;
|
width: 2px; height: 14px;
|
||||||
animation: blink 1s steps(2, start) infinite;
|
background: var(--ui-primary, #4f46e5);
|
||||||
|
margin-left: 2px; vertical-align: text-bottom;
|
||||||
|
animation: blink 0.8s step-end infinite;
|
||||||
|
}
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
}
|
}
|
||||||
@keyframes blink { 50% { opacity: 0; } }
|
|
||||||
|
|
||||||
/* 输入栏 */
|
/* 输入栏 */
|
||||||
.chat-input-bar {
|
.chat-input-bar {
|
||||||
|
|||||||
+128
-2
@@ -72,7 +72,7 @@
|
|||||||
}
|
}
|
||||||
.canvas-frame {
|
.canvas-frame {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min(100%, calc((100vh - 54px - 56px) * 16 / 9));
|
width: min(100%, calc((100vh - 54px - 116px) * 16 / 9));
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
box-shadow: var(--shadow-md); border-radius: 4px; overflow: hidden;
|
box-shadow: var(--shadow-md); border-radius: 4px; overflow: hidden;
|
||||||
@@ -83,7 +83,78 @@
|
|||||||
width: 1280px; height: 720px;
|
width: 1280px; height: 720px;
|
||||||
transform-origin: top left;
|
transform-origin: top left;
|
||||||
}
|
}
|
||||||
.canvas-status { margin-top: 10px; font-size: 12px; color: var(--ui-muted); }
|
.canvas-footer {
|
||||||
|
margin-top: 10px;
|
||||||
|
flex: none;
|
||||||
|
width: min(100%, calc((100vh - 54px - 116px) * 16 / 9));
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.canvas-status { font-size: 12px; color: var(--ui-muted); flex: none; }
|
||||||
|
.note-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 13px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ui-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s, color .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.note-toggle:hover {
|
||||||
|
background: var(--ui-primary-soft);
|
||||||
|
color: var(--ui-primary);
|
||||||
|
}
|
||||||
|
.note-toggle.active {
|
||||||
|
color: var(--ui-primary);
|
||||||
|
border-color: var(--ui-border);
|
||||||
|
background: var(--ui-panel);
|
||||||
|
}
|
||||||
|
.note-toggle-icon { font-size: 13px; line-height: 1; }
|
||||||
|
.note-dot {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ui-primary);
|
||||||
|
}
|
||||||
|
.canvas-note {
|
||||||
|
margin-top: 8px;
|
||||||
|
flex: none;
|
||||||
|
/* 与 .canvas-frame 同宽,保持对齐 */
|
||||||
|
width: min(100%, calc((100vh - 54px - 116px) * 16 / 9));
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.canvas-note-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: 120px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--ui-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ui-text);
|
||||||
|
background: var(--ui-panel);
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.canvas-note-input:focus {
|
||||||
|
border-color: var(--ui-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--ui-primary-soft);
|
||||||
|
}
|
||||||
|
.canvas-note-input::placeholder {
|
||||||
|
color: var(--ui-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 画布元素 ---------- */
|
/* ---------- 画布元素 ---------- */
|
||||||
.el { position: absolute; overflow: hidden; }
|
.el { position: absolute; overflow: hidden; }
|
||||||
@@ -107,7 +178,51 @@
|
|||||||
.el-stat .num { font-weight: 800; line-height: 1; }
|
.el-stat .num { font-weight: 800; line-height: 1; }
|
||||||
.el-stat .label { margin-top: .35em; text-align: center; }
|
.el-stat .label { margin-top: .35em; text-align: center; }
|
||||||
.el-shape { width: 100%; height: 100%; }
|
.el-shape { width: 100%; height: 100%; }
|
||||||
|
/* 形状文字层:覆盖在图形上方、居中;颜色默认白(形状为填充色底),字号/粗斜继承 style。
|
||||||
|
padding 稍大:clip-path 多边形(箭头/星形等)边缘收窄,文字留出安全区 */
|
||||||
|
.el-shape-text {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8% 12%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
text-align: center;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
/* rect/椭圆这类满幅形状,文字安全区可以小 */
|
||||||
|
.el[data-type="shape"][data-shape="rect"] > .el-shape-text,
|
||||||
|
.el[data-type="shape"][data-shape="circle"] > .el-shape-text,
|
||||||
|
.el[data-type="shape"][data-shape="ellipse"] > .el-shape-text {
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
/* 空形状不显示文字占位:装饰形状(分隔条/小三角)保持纯净,
|
||||||
|
加文字的引导由属性面板「内容」输入框承担 */
|
||||||
.el-image { width: 100%; height: 100%; object-fit: cover; }
|
.el-image { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.el-video { width: 100%; height: 100%; background: #000; border-radius: 8px; display: block; }
|
||||||
|
/* 视频缩略图降级:不加载视频,poster 静态图 / 黑底▶占位 */
|
||||||
|
.el-video-thumb {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: #0f172a;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.el-video-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.el-video-thumb-play { color: rgba(255,255,255,.75); font-size: 28px; }
|
||||||
|
/* 空图片元素占位(未填 content 时不渲染裂图) */
|
||||||
|
.el-image-empty {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
border: 2px dashed rgba(100, 116, 139, .45); border-radius: 10px;
|
||||||
|
background: rgba(148, 163, 184, .08); color: var(--ui-muted, #64748b);
|
||||||
|
text-align: center; padding: 8px;
|
||||||
|
}
|
||||||
|
.el-image-empty-icon { font-size: 28px; opacity: .7; }
|
||||||
|
.el-image-empty-text { font-size: 12px; line-height: 1.5; }
|
||||||
.el-chart { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; gap: .5em; }
|
.el-chart { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; gap: .5em; }
|
||||||
.el-chart .bar-row { display: flex; align-items: center; gap: .6em; font-size: 14px; }
|
.el-chart .bar-row { display: flex; align-items: center; gap: .6em; font-size: 14px; }
|
||||||
.el-chart .bar-label { width: 26%; flex-shrink: 0; }
|
.el-chart .bar-label { width: 26%; flex-shrink: 0; }
|
||||||
@@ -182,6 +297,17 @@
|
|||||||
/* 选中态与手柄 */
|
/* 选中态与手柄 */
|
||||||
.el.selected { outline: 2px solid var(--ui-primary); outline-offset: 0; }
|
.el.selected { outline: 2px solid var(--ui-primary); outline-offset: 0; }
|
||||||
.el.dragging { opacity: .85; }
|
.el.dragging { opacity: .85; }
|
||||||
|
/* 多选态:次级高亮(锚点仍用 .selected 实线) */
|
||||||
|
.el.multi-selected { outline: 1.5px dashed color-mix(in srgb, var(--ui-primary) 70%, transparent); outline-offset: 1px; }
|
||||||
|
|
||||||
|
/* 框选矩形 */
|
||||||
|
.marquee-box {
|
||||||
|
position: absolute;
|
||||||
|
border: 1.5px dashed var(--ui-primary);
|
||||||
|
background: color-mix(in srgb, var(--ui-primary) 8%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
.handle {
|
.handle {
|
||||||
position: absolute; width: 10px; height: 10px;
|
position: absolute; width: 10px; height: 10px;
|
||||||
background: #fff; border: 1.5px solid var(--ui-primary);
|
background: #fff; border: 1.5px solid var(--ui-primary);
|
||||||
|
|||||||
@@ -84,7 +84,9 @@
|
|||||||
* renderSlide 给 slide-inner 加 .animated,并按元素顺序设 animation-delay
|
* renderSlide 给 slide-inner 加 .animated,并按元素顺序设 animation-delay
|
||||||
* data-anim 指定具体动效,未指定则默认 fade-up
|
* data-anim 指定具体动效,未指定则默认 fade-up
|
||||||
* ---------------------------------------------------------------------- */
|
* ---------------------------------------------------------------------- */
|
||||||
.present-stage .el { opacity: 0; }
|
/* 初始隐藏(入场前)。注:必须限定 .animated 上下文——若动画未挂上/被打断,
|
||||||
|
* 元素回落到此处的 opacity:0 会导致「闪现后消失」(曾因 pop/bounce 100% 帧缺 opacity 触发) */
|
||||||
|
.present-stage .slide-inner.animated .el { opacity: 0; }
|
||||||
.present-stage .slide-inner.animated .el { will-change: transform, opacity; }
|
.present-stage .slide-inner.animated .el { will-change: transform, opacity; }
|
||||||
.present-stage .slide-inner.animated .el[data-anim="fade"] { animation: ppt-fade .5s ease both; }
|
.present-stage .slide-inner.animated .el[data-anim="fade"] { animation: ppt-fade .5s ease both; }
|
||||||
.present-stage .slide-inner.animated .el[data-anim="scale"] { animation: ppt-scale .6s cubic-bezier(.22,.61,.36,1) both; }
|
.present-stage .slide-inner.animated .el[data-anim="scale"] { animation: ppt-scale .6s cubic-bezier(.22,.61,.36,1) both; }
|
||||||
@@ -102,7 +104,7 @@
|
|||||||
@keyframes ppt-scale { from { opacity: 0; transform: scale(.86); } to { opacity: 1; transform: none; } }
|
@keyframes ppt-scale { from { opacity: 0; transform: scale(.86); } to { opacity: 1; transform: none; } }
|
||||||
@keyframes ppt-slide-l { from { opacity: 0; transform: translateX(-52px); } to { opacity: 1; transform: none; } }
|
@keyframes ppt-slide-l { from { opacity: 0; transform: translateX(-52px); } to { opacity: 1; transform: none; } }
|
||||||
@keyframes ppt-slide-r { from { opacity: 0; transform: translateX(52px); } to { opacity: 1; transform: none; } }
|
@keyframes ppt-slide-r { from { opacity: 0; transform: translateX(52px); } to { opacity: 1; transform: none; } }
|
||||||
@keyframes ppt-pop { 0% { opacity: 0; transform: scale(.55); } 60% { opacity: 1; transform: scale(1.06); } 100% { transform: none; } }
|
@keyframes ppt-pop { 0% { opacity: 0; transform: scale(.55); } 60% { opacity: 1; transform: scale(1.06); } 100% { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
/* 扩展特效 */
|
/* 扩展特效 */
|
||||||
.present-stage .slide-inner.animated .el[data-anim="rotate"] { animation: ppt-rotate .6s cubic-bezier(.22,.61,.36,1) both; }
|
.present-stage .slide-inner.animated .el[data-anim="rotate"] { animation: ppt-rotate .6s cubic-bezier(.22,.61,.36,1) both; }
|
||||||
@@ -111,7 +113,7 @@
|
|||||||
.present-stage .slide-inner.animated .el[data-anim="blur"] { animation: ppt-blur .6s ease both; }
|
.present-stage .slide-inner.animated .el[data-anim="blur"] { animation: ppt-blur .6s ease both; }
|
||||||
|
|
||||||
@keyframes ppt-rotate { from { opacity: 0; transform: rotate(-12deg) scale(.85); } to { opacity: 1; transform: none; } }
|
@keyframes ppt-rotate { from { opacity: 0; transform: rotate(-12deg) scale(.85); } to { opacity: 1; transform: none; } }
|
||||||
@keyframes ppt-bounce { 0% { opacity: 0; transform: translateY(-50px); } 55% { opacity: 1; } 72% { transform: translateY(8px); } 100% { transform: none; } }
|
@keyframes ppt-bounce { 0% { opacity: 0; transform: translateY(-50px); } 55% { opacity: 1; } 72% { opacity: 1; transform: translateY(8px); } 100% { opacity: 1; transform: none; } }
|
||||||
@keyframes ppt-flip { from { opacity: 0; transform: perspective(900px) rotateY(75deg); } to { opacity: 1; transform: none; } }
|
@keyframes ppt-flip { from { opacity: 0; transform: perspective(900px) rotateY(75deg); } to { opacity: 1; transform: none; } }
|
||||||
@keyframes ppt-blur { from { opacity: 0; filter: blur(14px); } to { opacity: 1; filter: none; } }
|
@keyframes ppt-blur { from { opacity: 0; filter: blur(14px); } to { opacity: 1; filter: none; } }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/* dialog.test.ts — 全局对话框服务与宿主组件闭环验证(Teleport 后从 document 查询) */
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import AppDialog from '../src/components/common/AppDialog.vue'
|
||||||
|
import { appAlert, appConfirm, appPrompt, dialogState, resolveDialog } from '../src/core/dialog'
|
||||||
|
|
||||||
|
/** 触发 body 上(Teleport)按钮点击 */
|
||||||
|
function clickBtn(idx: number) {
|
||||||
|
const btns = document.body.querySelectorAll('.modal-actions button')
|
||||||
|
;(btns[idx] as HTMLElement).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dialog 服务 + AppDialog 宿主', () => {
|
||||||
|
it('appPrompt 打开对话框 → 输入值提交', async () => {
|
||||||
|
const wrapper = mount(AppDialog, { attachTo: document.body })
|
||||||
|
const p = appPrompt('生成整套', { placeholder: '主题' })
|
||||||
|
await flushPromises()
|
||||||
|
expect(dialogState.open.value).toBe(true)
|
||||||
|
const input = document.body.querySelector('.modal-mask input') as HTMLInputElement
|
||||||
|
expect(input).not.toBeNull()
|
||||||
|
input.value = '远程办公'
|
||||||
|
input.dispatchEvent(new Event('input'))
|
||||||
|
await flushPromises()
|
||||||
|
clickBtn(1) // 确定
|
||||||
|
await expect(p).resolves.toBe('远程办公')
|
||||||
|
expect(dialogState.open.value).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appConfirm 取消 → false;确定 → true', async () => {
|
||||||
|
const wrapper = mount(AppDialog, { attachTo: document.body })
|
||||||
|
const p1 = appConfirm('删除?', '不可恢复', { danger: true })
|
||||||
|
await flushPromises()
|
||||||
|
clickBtn(0) // 取消
|
||||||
|
await expect(p1).resolves.toBe(false)
|
||||||
|
|
||||||
|
const p2 = appConfirm('删除?')
|
||||||
|
await flushPromises()
|
||||||
|
clickBtn(1) // 确定
|
||||||
|
await expect(p2).resolves.toBe(true)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appAlert 无输入框,单按钮关闭', async () => {
|
||||||
|
const wrapper = mount(AppDialog, { attachTo: document.body })
|
||||||
|
const p = appAlert('提示', '内容')
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.querySelector('.modal-mask input')).toBeNull()
|
||||||
|
expect(document.body.querySelectorAll('.modal-actions button').length).toBe(1)
|
||||||
|
clickBtn(0)
|
||||||
|
await expect(p).resolves.toEqual({ ok: true, value: '' })
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('对话框 DOM 挂到 body(Teleport)且带 modal-mask/modal 类', async () => {
|
||||||
|
const wrapper = mount(AppDialog, { attachTo: document.body })
|
||||||
|
appAlert('标题')
|
||||||
|
await flushPromises()
|
||||||
|
const mask = document.body.querySelector('.modal-mask')
|
||||||
|
expect(mask).not.toBeNull()
|
||||||
|
expect(mask!.querySelector('.modal')).not.toBeNull()
|
||||||
|
resolveDialog({ ok: true, value: '' })
|
||||||
|
await flushPromises()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
* op.test.ts — Op 引擎核心语义测试
|
||||||
|
* 保护对象:applyOp 正向应用 / invertOp 逆操作 / undo 往返一致性
|
||||||
|
* 这些是 undo/redo 正确性的地基,任何改动必须保持以下断言成立
|
||||||
|
* ===================================================================== */
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { applyOp, invertOp, type Op } from '../src/core/op'
|
||||||
|
import type { Deck, Slide, SlideElement } from '../src/core/types'
|
||||||
|
|
||||||
|
/* ---------- 测试数据工厂 ---------- */
|
||||||
|
function el(id: string, over: Partial<SlideElement> = {}): SlideElement {
|
||||||
|
return {
|
||||||
|
id, type: 'text', x: 10, y: 10, w: 40, h: 15,
|
||||||
|
content: id, style: { fontSize: 24 },
|
||||||
|
...over
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function slide(id: string, elements: SlideElement[] = []): Slide {
|
||||||
|
return { id, background: 'bg', elements }
|
||||||
|
}
|
||||||
|
function deck(slides: Slide[]): Deck {
|
||||||
|
return { v: 3, theme: 'indigo', slides, chatId: 'test' } as Deck
|
||||||
|
}
|
||||||
|
|
||||||
|
const D1 = deck([slide('s1', [el('a'), el('b')]), slide('s2', [el('c')])])
|
||||||
|
|
||||||
|
/* ---------- applyOp 正向 ---------- */
|
||||||
|
describe('applyOp', () => {
|
||||||
|
it('add_element 在目标页追加元素', () => {
|
||||||
|
const op: Op = { type: 'add_element', slideIdx: 0, element: el('new') }
|
||||||
|
const d = applyOp(D1, op)
|
||||||
|
expect(d.slides[0].elements.map(e => e.id)).toEqual(['a', 'b', 'new'])
|
||||||
|
expect(d.slides[1].elements).toHaveLength(1) // 其他页不受影响
|
||||||
|
})
|
||||||
|
|
||||||
|
it('del_element 删除指定元素', () => {
|
||||||
|
const op: Op = { type: 'del_element', slideIdx: 0, elementId: 'a' }
|
||||||
|
const d = applyOp(D1, op)
|
||||||
|
expect(d.slides[0].elements.map(e => e.id)).toEqual(['b'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('update_element 只改 patch 覆盖的字段', () => {
|
||||||
|
const op: Op = { type: 'update_element', slideIdx: 0, elementId: 'a', patch: { content: '改后', style: { fontSize: 32 } } }
|
||||||
|
const d = applyOp(D1, op)
|
||||||
|
const target = d.slides[0].elements[0]
|
||||||
|
expect(target.content).toBe('改后')
|
||||||
|
expect(target.style.fontSize).toBe(32)
|
||||||
|
expect(target.x).toBe(10) // 未覆盖字段保持
|
||||||
|
})
|
||||||
|
|
||||||
|
it('move_slide 调整页序', () => {
|
||||||
|
const op: Op = { type: 'move_slide', from: 0, to: 1 }
|
||||||
|
const d = applyOp(D1, op)
|
||||||
|
expect(d.slides.map(s => s.id)).toEqual(['s2', 's1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不 mutate 输入 deck(纯函数)', () => {
|
||||||
|
const snapshot = JSON.stringify(D1)
|
||||||
|
applyOp(D1, { type: 'add_element', slideIdx: 0, element: el('x') })
|
||||||
|
applyOp(D1, { type: 'del_element', slideIdx: 0, elementId: 'a' })
|
||||||
|
expect(JSON.stringify(D1)).toBe(snapshot)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ---------- invertOp + undo 往返 ---------- */
|
||||||
|
describe('invertOp 往返一致性', () => {
|
||||||
|
const cases: Array<{ name: string; op: Op }> = [
|
||||||
|
{ name: 'add_element', op: { type: 'add_element', slideIdx: 0, element: el('new') } },
|
||||||
|
{ name: 'del_element', op: { type: 'del_element', slideIdx: 0, elementId: 'a', deletedElement: el('a') } },
|
||||||
|
{ name: 'update_element', op: { type: 'update_element', slideIdx: 0, elementId: 'a', patch: { content: '改', style: { bold: true } } } },
|
||||||
|
{ name: 'add_slide', op: { type: 'add_slide', atIndex: 1, slide: slide('s3') } },
|
||||||
|
{ name: 'move_slide', op: { type: 'move_slide', from: 0, to: 1 } },
|
||||||
|
{ name: 'set_slide_bg', op: { type: 'set_slide_bg', index: 0, bg: 'primary' } },
|
||||||
|
{ name: 'move_element_z', op: { type: 'move_element_z', slideIdx: 0, elementId: 'b', dir: 1 } },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const { name, op } of cases) {
|
||||||
|
it(`${name}:执行后撤销回到原状`, () => {
|
||||||
|
const after = applyOp(D1, op)
|
||||||
|
const back = invertOp(D1, op)
|
||||||
|
const restored = applyOp(after, back)
|
||||||
|
expect(JSON.stringify(restored)).toBe(JSON.stringify(D1))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('update_element 无 style patch 也往返一致', () => {
|
||||||
|
const op: Op = { type: 'update_element', slideIdx: 1, elementId: 'c', patch: { x: 55, y: 60 } }
|
||||||
|
const after = applyOp(D1, op)
|
||||||
|
const restored = applyOp(after, invertOp(D1, op))
|
||||||
|
expect(JSON.stringify(restored)).toBe(JSON.stringify(D1))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ---------- 连续操作链撤销 ---------- */
|
||||||
|
describe('多步操作链撤销', () => {
|
||||||
|
it('三条操作逐步回滚最终回到初始', () => {
|
||||||
|
let d = D1
|
||||||
|
const trail: Array<{ forward: Op; backward: Op }> = []
|
||||||
|
const ops: Op[] = [
|
||||||
|
{ type: 'add_element', slideIdx: 0, element: el('n1') },
|
||||||
|
{ type: 'update_element', slideIdx: 0, elementId: 'n1', patch: { content: '更新' } },
|
||||||
|
{ type: 'del_element', slideIdx: 0, elementId: 'b' },
|
||||||
|
]
|
||||||
|
for (const op of ops) {
|
||||||
|
trail.push({ forward: op, backward: invertOp(d, op) })
|
||||||
|
d = applyOp(d, op)
|
||||||
|
}
|
||||||
|
// 逆序回滚
|
||||||
|
for (let i = trail.length - 1; i >= 0; i--) {
|
||||||
|
d = applyOp(d, trail[i].backward)
|
||||||
|
}
|
||||||
|
expect(JSON.stringify(d)).toBe(JSON.stringify(D1))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/* shape-render.test.ts — 形状元素渲染回归(细条/装饰形状不被文字层污染) */
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ElementView from '../src/components/editor/ElementView.vue'
|
||||||
|
import { store } from '../src/core/store'
|
||||||
|
import type { SlideElement } from '../src/core/types'
|
||||||
|
|
||||||
|
/** 目录页 t2:标题下方的细横条(rect 128×11.5px) */
|
||||||
|
const t2: SlideElement = {
|
||||||
|
id: 't2', type: 'shape', x: 8, y: 23, w: 10, h: 1.6, content: '',
|
||||||
|
style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'fade-up' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 步骤页 st4:步骤间的小三角 */
|
||||||
|
const st4: SlideElement = {
|
||||||
|
id: 'st4', type: 'shape', x: 27.5, y: 40, w: 5, h: 0.8, content: '',
|
||||||
|
style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('装饰形状渲染', () => {
|
||||||
|
it('细横条 rect:编辑态渲染图形 div + 空 contenteditable 文字层,无占位文字', () => {
|
||||||
|
const w = mount(ElementView, { props: { el: t2, bg: 'bg', edit: true }, attachTo: document.body })
|
||||||
|
const shape = w.find('.el-shape')
|
||||||
|
expect(shape.exists()).toBe(true)
|
||||||
|
expect(shape.attributes('style')).toContain('border-radius: 4px')
|
||||||
|
const text = w.find('.el-shape-text')
|
||||||
|
expect(text.exists()).toBe(true) // 编辑态保留可点击输入
|
||||||
|
expect(text.text()).toBe('') // 无占位文字
|
||||||
|
// 根节点暴露 data-shape 供 CSS 区分满幅/多边形安全区
|
||||||
|
expect(w.find('.el').attributes('data-shape')).toBe('rect')
|
||||||
|
w.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('小三角 triangle:走 clip-path 分支,非正比框渲染', () => {
|
||||||
|
const w = mount(ElementView, { props: { el: st4, bg: 'bg', edit: true }, attachTo: document.body })
|
||||||
|
const shape = w.find('.el-shape')
|
||||||
|
expect(shape.attributes('style') || '').toContain('clip-path')
|
||||||
|
w.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非编辑态(模板预览):空 content 不渲染文字层', () => {
|
||||||
|
const w = mount(ElementView, { props: { el: t2, bg: 'bg' }, attachTo: document.body })
|
||||||
|
expect(w.find('.el-shape-text').exists()).toBe(false)
|
||||||
|
w.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('有文字的形状:非编辑态渲染 md 文字层且默认白字', () => {
|
||||||
|
void store
|
||||||
|
const el = { ...t2, h: 10, content: '**加粗**文字' }
|
||||||
|
const w = mount(ElementView, { props: { el, bg: 'bg' }, attachTo: document.body })
|
||||||
|
const text = w.find('.el-shape-text')
|
||||||
|
expect(text.exists()).toBe(true)
|
||||||
|
expect(text.html()).toContain('<strong>加粗</strong>')
|
||||||
|
expect(text.attributes('style') || '').toContain('rgb(255, 255, 255)')
|
||||||
|
w.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
+16
-3
@@ -3,9 +3,22 @@ import vue from '@vitejs/plugin-vue'
|
|||||||
|
|
||||||
export default defineConfig(({ mode }) => ({
|
export default defineConfig(({ mode }) => ({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
// npm run dev(mode=web)→ 浏览器模式,端口 8080
|
build: {
|
||||||
|
chunkSizeWarningLimit: 800,
|
||||||
|
},
|
||||||
|
// npm run dev:web(mode=web)→ 浏览器模式,端口 8080
|
||||||
// npm run tauri:dev → 默认 mode,端口 5173
|
// npm run tauri:dev → 默认 mode,端口 5173
|
||||||
server: mode === 'web'
|
server: mode === 'web'
|
||||||
? { host: '127.0.0.1', port: 8080, open: true }
|
? {
|
||||||
: { host: '127.0.0.1', port: 5173, strictPort: true },
|
host: '127.0.0.1', port: 8080, open: true,
|
||||||
|
// 与 tauri dev 共存时防止 watch Cargo 产物(EBUSY 崩 watcher)
|
||||||
|
watch: { ignored: ['**/src-tauri/target/**'] },
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
host: '127.0.0.1', port: 5173, strictPort: true,
|
||||||
|
// 不 watch src-tauri/target:Cargo 编译期间写 dll 会触发 EBUSY 崩掉 watcher
|
||||||
|
watch: { ignored: ['**/src-tauri/target/**'] },
|
||||||
|
},
|
||||||
|
// 桌面模式 HMR 由 tauri-cli 管理,无需自动打开浏览器
|
||||||
|
clearScreen: false,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user