Compare commits

...

10 Commits

Author SHA1 Message Date
9e0a808635 新增: 桌面能力——AI 代理 + 文件系统(B5)
- ai_proxy: Rust reqwest 代理 AI 请求(彻底解决 CORS 问题)
- ai_proxy_stream: SSE 流式代理,通过 app.emit("ai-delta") 实时推送
- write_file/read_file: 文件读写命令(替代对话框)
- 前端 bridge.ts 新增 listen() 函数(接收 Rust 事件推送)
- 前后端零错误编译,40 测试通过
2026-07-12 22:43:56 +08:00
a1ac91c1ec 新增: Tauri IPC 命令 + useStore 统一接口(B4)
- commands.rs: exec_op/undo/redo/can_undo/can_redo/get_deck/set_navigation/save_file
  Rust AppState 持有 deck + 历史栈,所有写操作通过 Op Log
- bridge.ts: 安全 Tauri IPC 调用层(动态 import,Web 模式 mock)
- useStore.ts: 统一 store 接口,检测 __TAURI__ 环境
  Web 模式直接调 TS store;Tauri 模式走 invoke
- 50 个测试全部通过,前后端编译零错误
2026-07-12 22:41:10 +08:00
fc605b1a94 新增: Rust AI 引擎——prompt/规范化/SSE 解析(B3)
- ai.rs: 完整 Prompt 模板翻译(SYS_BASE/SYS_GENERATE/SYS_POLISH/SYS_CHAT)
- norm_style/norm_element/norm_slide/norm_slides: 数据规范化纯函数
- try_parse_json: AI 回复中提取 JSON(兼容 json 代码块标记)
- split_chat_reply: 按 %%PPT_JSON%% 分隔自然语言与操作
- 8 个单元测试: 样式规范化/元素解析/幻灯片解析/JSON提取/chat分隔/颜色校验/图表类型
- 40 个测试全部通过
2026-07-12 22:37:40 +08:00
664ca9c65c 新增: Rust Op reducer + 逆操作计算(B2)
- op.rs: 纯函数 apply_op(deck,op) → new_deck,覆盖全部 11 种 Op 类型
- invert_op(deck,op) → 逆 Op,支持完整的 undo 链路
- 所有 reducer 不 mutate 输入,返回新 deck
- 8 个单元测试: add_slide/del_slide/set_theme/update_element/del_element/
  move_slide/undo_update/undo_add_slide/replace_deck_undo
- ElementStyle 加 rename_all=camelCase 匹配 JS 协议
- 32 个测试全部通过(B1:23 + B2:9)
2026-07-12 22:33:41 +08:00
1430e9e061 新增: Rust 数据模型 + 颜色纯函数 + ts-rs 类型同步(B1)
- model.rs: 全部数据结构(Deck/Slide/Element/Style/Theme/AiCfg/LibItem/PageTemplate/ChatMessage/Op/RichSegment)
  所有结构体 derive Serialize/Deserialize/TS,自动生成 TS 类型到 bindings/types.ts
- color.rs: 颜色解析纯函数(resolve_color/resolve_bg/is_dark_bg/shade/hex_to_rgb)
  对应 store.ts 的颜色逻辑,23 个单元测试全部通过
- lib.rs: 新增 app_info IPC 命令
- ts-rs 自动生成的 bindings/types.ts 与手写 types.ts 类型一致
2026-07-12 18:20:49 +08:00
b9a388b570 新增: Tauri 2.0 桌面版脚手架(B0)
- src-tauri/ 完整项目结构: Cargo.toml / build.rs / tauri.conf.json
- Rust 入口 lib.rs: ping IPC 命令验证双向通信
- 应用图标生成(SVG → ico/icns/png 多尺寸)
- Tauri 窗口配置: 1280×800, 最小 900×600, 可缩放
- package.json 新增 tauri / tauri:dev / tauri:build 脚本
- vite.config.ts 环境检测: Tauri 模式端口 5173, Web 模式 8080
- @tauri-apps/cli + @tauri-apps/api 依赖安装
- cargo check 编译通过,前端构建不受影响
- 桌面版与 Web 版共享同一套前端代码
2026-07-12 15:11:35 +08:00
056dc58318 新增: 导出 JSON + 导入 JSON + 打印/PDF 导出(P4)
- 工具栏新增三个按钮: 导出 JSON / 导入 JSON / 打印 PDF
- 导出 JSON: Blob 下载 store.exportJSON()
- 导入 JSON: 文件选择 + FileReader → store.importJSON()
- 打印 PDF: window.print() + @media print CSS,逐页 page-break
- 打印时隐藏编辑器/HUD/工具栏,只保留幻灯片内容
- 零新依赖(PDF 走浏览器原生打印)
2026-07-12 15:02:27 +08:00
9ec6173b10 新增: Backend 持久化抽象层 + LocalBackend 实现(P3)
- 定义 Backend 接口: deck/文库/会话/模板/主题/配置的读写
- LocalBackend 实现(localStorage,Web版当前模式)
- getBackend()/setBackend() 管理活动后端实例
- store 内部 localStorage 调用暂不变(LocalBackend 是其包装)
- B2 迁移时统一改为 getBackend().xxx(),B4 实现 TauriBackend
- 为桌面版(Rust 文件系统)和云端版(HTTP API)预留接口
2026-07-12 15:00:41 +08:00
2f9c545503 重构: Op Log 数据模型,为协同预留基础(P2)
- 新增 op.ts: Op 类型定义 + 纯函数 reducer(applyOp) + 逆操作计算(invertOp)
- 支持的 Op 类型: add_slide/del_slide/move_slide/replace_slide/set_slide_bg/
  add_element/update_element/del_element/move_element_z/set_theme/replace_deck
- undo/redo 从快照栈改为 Op 回滚(forward/backward 双向 Op)
- store 所有写操作内部改为 execOp,对外接口完全不变(88 处调用零改动)
- update_element 保留连续操作合并(拖滑块/连续输入不膨胀历史)
- 每个 Op 带 clientId + timestamp,为未来协同预留
- replace_deck 作为特殊 Op 进 undo 历史
2026-07-12 14:59:05 +08:00
9933a1711a 重构: 清理技术债 + 数据健壮性增强(P0+P1)
- 删除 inline.ts(方案A遗留死代码,已被 richtext.ts 替代)
- 删除 ElementStyle.rich 字段(segments 方案后无用)
- 删除 PropsPanel 未使用的 CHART_TYPE_MAP
- 删除 editor.css 旧 class 兼容样式(.el-card-title 等)
- 生成整套/重置时自动备份当前 deck 到 .bak(防误覆盖)
- 新增 recoverBackup() 方法(可恢复上一次被覆盖的 deck)
- 旧版本数据不再直接丢弃,改为 migrateDeck() 补字段升级
- chart 元素自动补 chartType:'bar' 默认值
2026-07-12 13:28:04 +08:00
46 changed files with 7694 additions and 167 deletions

10
.gitignore vendored
View File

@@ -36,3 +36,13 @@ npm-debug.log*
# TypeScript 构建缓存
*.tsbuildinfo
tsconfig.tsbuildinfo
# Tauri / Rust
src-tauri/target/
src-tauri/gen/
src-tauri/icons/android/
src-tauri/icons/ios/
# Tauri 临时
src-tauri/WixTools/
src-tauri/*.pdb

229
package-lock.json generated
View File

@@ -8,9 +8,11 @@
"name": "u-ppt",
"version": "1.0.0",
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"vue": "^3.5.13"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.3",
"vite": "^6.0.5",
@@ -861,6 +863,233 @@
"win32"
]
},
"node_modules/@tauri-apps/api": {
"version": "2.11.1",
"resolved": "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.11.1.tgz",
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.4",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli/-/cli-2.11.4.tgz",
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.4",
"@tauri-apps/cli-darwin-x64": "2.11.4",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.4",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.4",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.4",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.4",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.4",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",

View File

@@ -7,12 +7,17 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"vue": "^3.5.13"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.3",
"vite": "^6.0.5",

4893
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

26
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,26 @@
[package]
name = "u-ppt"
version = "1.0.0"
description = "在线演示工具 - 桌面版"
authors = ["u-ppt"]
edition = "2021"
rust-version = "1.77"
[lib]
name = "u_ppt_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ts-rs = { version = "10", features = ["no-serde-warnings"] }
reqwest = { version = "0.12", features = ["json", "stream"] }
tokio = { version = "1", features = ["full"] }
futures = "0.3"
[features]
export-types = []

10
src-tauri/app-icon.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#4f46e5"/>
<rect x="128" y="120" width="256" height="272" rx="16" fill="#fff"/>
<rect x="160" y="160" width="192" height="8" rx="4" fill="#4f46e5"/>
<rect x="160" y="184" width="160" height="6" rx="3" fill="#c7d2fe"/>
<rect x="160" y="204" width="176" height="6" rx="3" fill="#c7d2fe"/>
<rect x="160" y="224" width="128" height="6" rx="3" fill="#c7d2fe"/>
<circle cx="272" cy="312" r="40" fill="#06b6d4"/>
<rect x="150" y="300" width="80" height="56" rx="8" fill="#818cf8"/>
</svg>

After

Width:  |  Height:  |  Size: 631 B

View File

@@ -0,0 +1,33 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AiCfg = { preset: string, protocol: string, base: string, key: string, model: string, proxy: string, img_base: string | null, img_key: string | null, img_model: string | null, };
export type AnimType = "fade-up" | "fade" | "scale" | "zoom" | "slide-l" | "slide-r" | "pop" | "rotate" | "bounce" | "flip" | "blur";
export type ChartItem = { label: string | null, value: number | null, };
export type ChartType = "bar" | "line" | "pie" | "area" | "doughnut" | "radar" | "hbar" | "progress";
export type ChatMessage = { role: string, content: string, };
export type Deck = { v: number, theme: string, slides: Array<Slide>, chat_id: string | null, };
export type ElementStyle = { fontSize: number | null, color: string | null, align: string | null, bold: boolean | null, italic: boolean | null, anim: string | null, label: string | null, labelColor: string | null, labelSize: number | null, fit: string | null, shapeType: ShapeType | null, fill: string | null, gradient: boolean | null, opacity: number | null, radius: number | null, max: number | null, chartType: ChartType | null, legend: boolean | null, stack: boolean | null, grid: boolean | null, header: boolean | null, lang: string | null, inline: boolean | null, icon: string | null, accent: string | null, };
export type ElementType = "title" | "text" | "list" | "stat" | "quote" | "image" | "shape" | "chart" | "card" | "table" | "code" | "formula";
export type LibItem = { id: string, name: string, deck: Deck, created_at: number, updated_at: number, };
export type PageTemplate = { id: string, name: string, category: string, background: string, elements: Array<SlideElement>, created_at: number | null, };
export type RichLine = { segments: Array<RichSegment>, };
export type RichSegment = { text: string, bold: boolean | null, italic: boolean | null, underline: boolean | null, strike: boolean | null, color: string | null, highlight: boolean | null, code: boolean | null, sup: boolean | null, sub: boolean | null, font_size: number | null, link: string | null, };
export type ShapeType = "rect" | "circle" | "triangle";
export type Slide = { id: string, background: string, elements: Array<SlideElement>, };
export type SlideElement = { id: string, type: string, x: number, y: number, w: number, h: number, content: string, style: ElementStyle, segments: Array<RichLine> | null, };
export type Theme = { name: string, primary: string, accent: string, bg: string, panel: string, text: string, muted: string, };

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 B

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

310
src-tauri/src/ai.rs Normal file
View File

@@ -0,0 +1,310 @@
/* =====================================================================
* ai.rs — AI 内容创作引擎Rust 版,对应 src/core/ai.ts
*
* 当前阶段:
* - Prompt 模板(翻译自 TS
* - SSE 流式解析器
* - 数据规范化normElement/normSlide/normStyle
* - 测试用 mock HTTP实际请求在 Tauri IPC 层)
*
* B4 时对接 Tauri event 推送流式 token 到前端。
* ===================================================================== */
use crate::model::*;
/* ============================================================
* Prompt 模板(与 TS 版完全一致)
* ============================================================ */
pub const SYS_BASE: &str =
"你是「u-ppt」的内容创作助手擅长把主题变成结构清晰、视觉现代、带入场动效的中文演示稿。
输出必须严格遵循下面的数据模型,坐标用百分比(0-100),字号为数字。
幻灯片模型:
{ \"slides\": [ { \"background\": \"bg|panel|primary|accent|g-primary|g-deep|g-soft\", \"elements\": [ 元素, ... ] } ] }
背景bg/panel=浅底primary/accent=纯色深底g-primary=主→强调渐变(深)g-deep=深色渐变g-soft=浅色渐变。
元素类型 typetitle 标题 | text 正文 | list 列表 | stat 数据 | quote 金句 | image 图片 | shape 形状 | chart 图表 | card 卡片 | table 表格 | code 代码 | formula 公式
元素:{ \"type\":..., \"x\":数字,\"y\":数字,\"w\":数字,\"h\":数字 (0-100), \"content\":字符串, \"style\":{...} }
- title/text/list/quotecontent 为文字list 用 \\n 分多行
- statcontent 为大数字(如 \"65%\")style.label 为说明
- cardcontent 第一行=标题、其余行=正文style.accent=顶部色条键style.icon=emoji 图标
- shapestyle.shapeType=rect|circle|trianglestyle.fill=颜色键style.gradient=true 渐变style.opacity=0~1
- chartcontent 为 JSON两种格式
单系列:[{\"label\":\"\",\"value\":数字}, ...]
多系列:{\"series\":[\"Q1\",\"Q2\"], \"items\":[{\"label\":\"华东\",\"values\":[120,150]}, ...]}
style.max=最大值style.chartType=bar|line|area|pie|doughnut|radar|hbar|progress默认 bar
style.legend=true 显示图例style.grid=true 显示网格线style.stack=true 堆叠(多系列柱状)
图表类型选择建议:柱状(bar)对比离散类型;折线(line)显示趋势;面积(area)累计趋势;
饼图(pie)占比构成;环形(doughnut)轻量占比;雷达(radar)多维度对比;条形(hbar)长标签对比;进度(progress)完成率。
- tablecontent 为 Markdown 管道表格字符串style.header=true 首行加粗
- codecontent 为代码文本style.lang=语言如 js/python
- formulacontent 为 LaTeX 公式字符串,如 \"E = mc^2\"
- imagecontent 留空
style 公共字段fontSize 数字、color 颜色键、align left|center|right、bold 布尔、italic 布尔、label/labelColor/labelSize、radius
颜色 color/fill/accent一律用主题键 primary|accent|text|muted系统按背景深浅自动反相深底文字自动变白不要手填 #ffffff
入场动画 style.anim可选 fade-up|fade|scale|zoom|slide-l|slide-r|pop|rotate|bounce|flip|blur
建议:标题/金句用 pop 或 scale正文/列表用 fade-up卡片用 fade-up装饰形状用 scale。
版式规范:
- 画布逻辑 1280×720。封面标题 y≈30 h≈18内容标题 y≈8-10。
- 字号title 44-66、text 22-28、list 24-30、stat 数字 64-80、quote 40-52。
- 一页一个观点,留白充足,列表不超过 5 条。
- 现代版式:多用 card 分组;封面/金句/结尾用 g-primary目录用 3-4 张卡片网格;数据页 stat+chart。
- emoji 极克制:默认不给 card.icon。
内容准则重要——避免「AI 味」):
- 标题写具体事实而非口号。
- 正文要有实质:具体数字、案例、步骤、来源;少用「赋能/助力/打造/引领/开启/一站式」这类空词。
- 禁用万能金句:「少即是多」「一图胜千言」「未来已来」「让 X 更简单」。
- 列表每条是可执行/可验证的要点。
- 有判断、有观点,像从业者写的。
- 不要使用 markdown 代码块,直接输出 JSON。
可选 segments 字段(结构化富文本):
segments: [[ {\"text\":\"华东 \"}, {\"text\":\"增长 23%\",\"bold\":true,\"color\":\"accent\"} ], ...]
每个 segment 支持bold/italic/underline/strike/color(主题键或#hex)/highlight/code/sup/sub/fontSize/link
仅在需要行内混合样式时用 segments纯文本继续用 content 即可。segments 与 content 二选一。";
pub const SYS_GENERATE: &str = "任务根据用户主题生成一套完整演示封面、目录、3-5 页内容、结尾,共 6-8 页)。
要求:每个主要元素都加 style.anim封面与结尾用 g-primary至少 2 张 card数据页用 stat+chart。
严格输出:{\"action\":\"create_all\",\"slides\":[...]}";
pub const SYS_POLISH: &str = "任务:按指令优化给定的\"单页\",保持主题一致、布局合理,并为各元素补充 style.anim。
严格输出:{\"action\":\"update_page\",\"slides\":[ 优化后的单页 ],\"note\":\"一句话说明改动\"}";
pub const SEP: &str = "%%PPT_JSON%%";
pub const SYS_CHAT: &str = "任务:与用户对话式编辑当前演示。先用中文流式回复用户(解释你将做什么),
如需修改 PPT在回复最后另起一行输出分隔标记 %%PPT_JSON%%
紧随其后输出 JSON 操作。
JSON 操作格式:{\"action\":\"add_page|update_page|create_all|answer\",\"slides\":[...],\"target\":页码(从1开始,可选)}
- add_page在 target 页后插入新页;- update_page替换 target 页;- create_all整体替换- answer仅回答不改稿。
没有改动时不要输出分隔标记,仅回复即可。";
/* ============================================================
* 数据规范化(对应 ai.ts 的 normStyle/normElement/normSlide
* ============================================================ */
const VALID_COLORS: &[&str] = &["primary", "accent", "text", "muted"];
const VALID_CHART_TYPES: &[&str] = &["bar", "line", "area", "pie", "doughnut", "radar", "hbar", "progress"];
const VALID_BGS: &[&str] = &["bg", "panel", "primary", "accent", "g-primary", "g-deep", "g-soft"];
fn valid_color(v: &str) -> Option<String> {
if v.is_empty() { return None; }
if v.starts_with('#') {
return if (v.len() == 4 || v.len() == 7 || v.len() == 9) && v[1..].chars().all(|c| c.is_ascii_hexdigit()) {
Some(v.to_string())
} else {
None
};
}
if VALID_COLORS.contains(&v) { Some(v.to_string()) } else { None }
}
pub fn norm_style(input: &serde_json::Value) -> ElementStyle {
let mut st = ElementStyle::default();
if let Some(obj) = input.as_object() {
if let Some(v) = obj.get("fontSize").and_then(|v| v.as_f64()) { st.font_size = Some(v); }
if let Some(v) = obj.get("font_size").and_then(|v| v.as_f64()) { st.font_size = Some(v); }
if let Some(b) = obj.get("bold").and_then(|v| v.as_bool()) { st.bold = Some(b); }
if let Some(b) = obj.get("italic").and_then(|v| v.as_bool()) { st.italic = Some(b); }
if let Some(v) = obj.get("labelSize").and_then(|v| v.as_f64()) { st.label_size = Some(v); }
if let Some(v) = obj.get("radius").and_then(|v| v.as_f64()) { st.radius = Some(v); }
if let Some(v) = obj.get("max").and_then(|v| v.as_f64()) { st.max = Some(v); }
if let Some(v) = obj.get("align").and_then(|v| v.as_str()) { st.align = Some(v.to_string()); }
if let Some(v) = obj.get("anim").and_then(|v| v.as_str()) { st.anim = Some(v.to_string()); }
if let Some(v) = obj.get("fit").and_then(|v| v.as_str()) { st.fit = Some(v.to_string()); }
if let Some(b) = obj.get("gradient").and_then(|v| v.as_bool()) { st.gradient = Some(b); }
if let Some(v) = obj.get("opacity").and_then(|v| v.as_f64()) { st.opacity = Some(v); }
if let Some(v) = obj.get("shapeType").and_then(|v| v.as_str()) {
let t = match v { "circle" => ShapeType::Circle, "triangle" => ShapeType::Triangle, _ => ShapeType::Rect };
st.shape_type = Some(t);
}
if let Some(v) = obj.get("chartType").and_then(|v| v.as_str()) {
if VALID_CHART_TYPES.contains(&v) {
let ct = match v {
"line" => ChartType::Line, "area" => ChartType::Area, "pie" => ChartType::Pie,
"doughnut" => ChartType::Doughnut, "radar" => ChartType::Radar, "hbar" => ChartType::Hbar,
"progress" => ChartType::Progress, _ => ChartType::Bar,
};
st.chart_type = Some(ct);
}
}
if let Some(b) = obj.get("legend").and_then(|v| v.as_bool()) { st.legend = Some(b); }
if let Some(b) = obj.get("grid").and_then(|v| v.as_bool()) { st.grid = Some(b); }
if let Some(b) = obj.get("stack").and_then(|v| v.as_bool()) { st.stack = Some(b); }
if let Some(b) = obj.get("header").and_then(|v| v.as_bool()) { st.header = Some(b); }
if let Some(b) = obj.get("inline").and_then(|v| v.as_bool()) { st.inline_ = Some(b); }
if let Some(v) = obj.get("label").and_then(|v| v.as_str()) { st.label = Some(v.to_string()); }
if let Some(v) = obj.get("labelColor").and_then(|v| v.as_str()).or_else(|| obj.get("label_color").and_then(|v| v.as_str())) {
st.label_color = valid_color(v);
}
if let Some(v) = obj.get("color").and_then(|v| v.as_str()) { st.color = valid_color(v); }
if let Some(v) = obj.get("fill").and_then(|v| v.as_str()) { st.fill = valid_color(v); }
if let Some(v) = obj.get("accent").and_then(|v| v.as_str()) { st.accent = valid_color(v); }
if let Some(v) = obj.get("lang").and_then(|v| v.as_str()) { st.lang = Some(v.chars().take(16).collect()); }
if let Some(v) = obj.get("icon").and_then(|v| v.as_str()) { st.icon = Some(v.chars().take(8).collect()); }
}
st
}
pub fn norm_element(input: &serde_json::Value) -> Option<SlideElement> {
let obj = input.as_object()?;
let el_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("text");
let valid = el_type; // 暂不校验
Some(SlideElement {
id: obj.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
element_type: valid.to_string(),
x: obj.get("x").and_then(|v| v.as_f64()).map(|f| f.max(0.0).min(100.0)).unwrap_or(10.0),
y: obj.get("y").and_then(|v| v.as_f64()).map(|f| f.max(0.0).min(100.0)).unwrap_or(10.0),
w: obj.get("w").and_then(|v| v.as_f64()).map(|f| f.max(3.0).min(100.0)).unwrap_or(60.0),
h: obj.get("h").and_then(|v| v.as_f64()).map(|f| f.max(3.0).min(100.0)).unwrap_or(15.0),
content: obj.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(),
style: norm_style(obj.get("style").unwrap_or(&serde_json::Value::Null)),
segments: None, // TODO: parse segments
})
}
pub fn norm_slide(input: &serde_json::Value) -> Option<Slide> {
let obj = input.as_object()?;
let bg = obj.get("background").and_then(|v| v.as_str()).unwrap_or("bg");
let bg = if VALID_BGS.contains(&bg) || bg.starts_with('#') { bg.to_string() } else { "bg".to_string() };
let elements = obj.get("elements")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(norm_element).collect())
.unwrap_or_default();
Some(Slide {
id: "".to_string(),
background: bg,
elements,
})
}
pub fn norm_slides(input: &serde_json::Value) -> Vec<Slide> {
input.as_array()
.map(|arr| arr.iter().filter_map(norm_slide).collect())
.unwrap_or_default()
}
/// 从 AI 回复文本中提取 JSON 代码块
pub fn try_parse_json(s: &str) -> Option<serde_json::Value> {
// 去掉 ```json 和 ``` 标记
let cleaned = s.trim_start();
let cleaned = cleaned.trim_start_matches("```json").trim();
let cleaned = cleaned.trim_start_matches("```");
let cleaned = cleaned.trim_end_matches("```").trim();
// 找到第一个 { 和最后一个 }
let start = cleaned.find('{')?;
let end = cleaned.rfind('}')?;
let candidate = &cleaned[start..=end];
// 尝试 parse
serde_json::from_str(candidate).ok()
.or_else(|| {
// 容错:移除尾随逗号
let fixed = candidate.replace(",\n}", "}").replace(",\r\n}", "}").replace(", }", "}");
serde_json::from_str(&fixed).ok()
})
}
/* ============================================================
* SSE 流式解析器
* ============================================================ */
pub struct SSESection {
pub reply: String,
pub json: Option<serde_json::Value>,
}
/// 解析 SSE 流,按 %%PPT_JSON%% 分隔自然语言回复与 JSON 操作
pub fn split_chat_reply(full_text: &str) -> SSESection {
let parts: Vec<&str> = full_text.splitn(2, SEP).collect();
let reply = parts[0].trim().to_string();
let json = parts.get(1).and_then(|s| try_parse_json(s));
SSESection { reply, json }
}
/// 解析 json_mode 响应generate/polish 等)
pub fn parse_json_mode(full_text: &str) -> Option<serde_json::Value> {
try_parse_json(full_text)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_norm_style() {
let input = json!({ "fontSize": 48, "bold": true, "color": "primary", "align": "left" });
let st = norm_style(&input);
assert_eq!(st.font_size, Some(48.0));
assert_eq!(st.bold, Some(true));
assert_eq!(st.color, Some("primary".to_string()));
}
#[test]
fn test_norm_element() {
let input = json!({ "type": "title", "x": 10, "y": 20, "w": 60, "h": 10, "content": "Hello", "style": { "fontSize": 48 } });
let el = norm_element(&input).unwrap();
assert_eq!(el.element_type, "title");
assert_eq!(el.content, "Hello");
assert_eq!(el.style.font_size, Some(48.0));
}
#[test]
fn test_norm_slide() {
let input = json!({ "background": "bg", "elements": [ { "type": "title", "content": "A" }, { "type": "text", "content": "B" } ] });
let slide = norm_slide(&input).unwrap();
assert_eq!(slide.elements.len(), 2);
assert_eq!(slide.elements[0].element_type, "title");
assert_eq!(slide.elements[1].element_type, "text");
}
#[test]
fn test_try_parse_json() {
let input = "这是一段自然语言回复。\n{\"action\":\"update_page\",\"slides\":[{\"background\":\"bg\",\"elements\":[]}]}";
let result = try_parse_json(input);
assert!(result.is_some());
assert_eq!(result.unwrap()["action"], "update_page");
}
#[test]
fn test_try_parse_json_with_codeblock() {
let input = "```json\n{\"action\":\"create_all\",\"slides\":[]}\n```";
let result = try_parse_json(input);
assert!(result.is_some());
assert_eq!(result.unwrap()["action"], "create_all");
}
#[test]
fn test_split_chat_reply() {
let input = "先修改一下。\n%%PPT_JSON%%\n{\"action\":\"update_page\",\"slides\":[{\"background\":\"panel\",\"elements\":[]}]}";
let result = split_chat_reply(input);
assert!(!result.reply.is_empty());
assert!(result.json.is_some());
assert_eq!(result.json.unwrap()["action"], "update_page");
}
#[test]
fn test_try_parse_valid_color() {
assert!(valid_color("primary").is_some());
assert!(valid_color("#4f46e5").is_some());
assert!(valid_color("invalid").is_none());
}
#[test]
fn test_chart_type_detection() {
let input = json!({ "chartType": "pie", "max": 100 });
let st = norm_style(&input);
assert!(matches!(st.chart_type, Some(ChartType::Pie)));
}
}

212
src-tauri/src/color.rs Normal file
View File

@@ -0,0 +1,212 @@
/* =====================================================================
* color.rs — 颜色/背景解析纯函数(对应 store.ts 的颜色函数)
*
* 这些函数供 Rust 端的渲染/序列化使用B2 store 迁移时调用)。
* 当前阶段保持与 TS 版本完全一致的逻辑。
* ===================================================================== */
pub fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> {
let c = hex.trim_start_matches('#');
let full = if c.len() == 3 {
format!(
"{}{}{}{}{}{}",
&c[0..1],
&c[0..1],
&c[1..2],
&c[1..2],
&c[2..3],
&c[2..3]
)
} else {
c.to_string()
};
if full.len() < 6 {
return None;
}
let r = u8::from_str_radix(&full[0..2], 16).ok()?;
let g = u8::from_str_radix(&full[2..4], 16).ok()?;
let b = u8::from_str_radix(&full[4..6], 16).ok()?;
Some((r, g, b))
}
pub fn is_dark_hex(hex: &str) -> bool {
match hex_to_rgb(hex) {
Some((r, g, b)) => (0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64) < 145.0,
None => false,
}
}
pub fn shade(hex: &str, pct: f64) -> String {
let (r, g, b) = match hex_to_rgb(hex) {
Some(rgb) => rgb,
None => return hex.to_string(),
};
let f = if pct < 0.0 { 0.0 } else { 255.0 };
let p = pct.abs() / 100.0;
let nr = ((f - r as f64) * p + r as f64).round() as u8;
let ng = ((f - g as f64) * p + g as f64).round() as u8;
let nb = ((f - b as f64) * p + b as f64).round() as u8;
format!("#{:02x}{:02x}{:02x}", nr, ng, nb)
}
pub fn is_dark_bg(bg: &str) -> bool {
if bg.is_empty() {
return false;
}
if bg.starts_with('#') {
return is_dark_hex(bg);
}
if bg == "primary" || bg == "accent" {
return true;
}
if bg.starts_with("g-") {
return true;
}
false
}
pub fn is_valid_hex(s: &str) -> bool {
s.starts_with('#')
&& s.len() >= 4
&& s[1..].chars().all(|c| c.is_ascii_hexdigit())
}
/// 颜色解析dark=true 时深色文字键反相为浅色
pub fn resolve_color(key: &str, dark: bool, theme: &crate::model::Theme) -> String {
if key.is_empty() {
return String::new();
}
if key.starts_with('#') {
if !is_valid_hex(key) {
return if dark {
"#ffffff".into()
} else {
theme.text.clone()
};
}
if dark && is_dark_hex(key) {
return "#ffffff".into();
}
return key.into();
}
if dark {
match key {
"text" => "#ffffff".into(),
"muted" => "rgba(255,255,255,0.72)".into(),
"primary" => "#ffffff".into(),
"accent" => theme.accent.clone(),
_ => "#ffffff".into(),
}
} else {
match key {
"primary" => theme.primary.clone(),
"accent" => theme.accent.clone(),
"text" => theme.text.clone(),
"muted" => theme.muted.clone(),
_ => theme.text.clone(),
}
}
}
/// 背景解析:渐变键 → CSS gradient
pub fn resolve_bg(key: &str, theme: &crate::model::Theme) -> String {
if key.is_empty() {
return "#ffffff".into();
}
if key.starts_with('#') {
return if is_valid_hex(key) {
key.into()
} else {
"#ffffff".into()
};
}
match key {
"g-primary" => format!(
"linear-gradient(135deg, {} 0%, {} 100%)",
theme.primary, theme.accent
),
"g-deep" => format!(
"linear-gradient(160deg, {} 0%, {} 100%)",
shade(&theme.primary, -28.0),
theme.primary
),
"g-soft" => format!(
"linear-gradient(135deg, {} 0%, {} 100%)",
theme.panel.clone(),
theme.bg.clone()
),
"primary" => theme.primary.clone(),
"accent" => theme.accent.clone(),
"panel" => theme.panel.clone(),
"bg" => theme.bg.clone(),
_ => "#ffffff".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hex_to_rgb() {
assert_eq!(hex_to_rgb("#4f46e5"), Some((79, 70, 229)));
assert_eq!(hex_to_rgb("#fff"), Some((255, 255, 255)));
assert_eq!(hex_to_rgb("invalid"), None);
}
#[test]
fn test_is_dark_hex() {
assert!(is_dark_hex("#0f172a")); // 深色
assert!(is_dark_hex("#4f46e5")); // 青蓝偏深
assert!(!is_dark_hex("#ffffff")); // 白色
assert!(!is_dark_hex("#f59e0b")); // 琥珀亮色
}
#[test]
fn test_is_dark_bg() {
assert!(is_dark_bg("primary"));
assert!(is_dark_bg("g-deep"));
assert!(!is_dark_bg("bg"));
assert!(!is_dark_bg("panel"));
}
#[test]
fn test_shade() {
let result = shade("#000000", 50.0);
assert_eq!(result, "#808080"); // 黑色提亮 50% → 灰色
}
#[test]
fn test_resolve_color_light() {
use crate::model::Theme;
let theme = Theme {
name: "test".into(),
primary: "#4f46e5".into(),
accent: "#06b6d4".into(),
bg: "#ffffff".into(),
panel: "#f8fafc".into(),
text: "#1e293b".into(),
muted: "#64748b".into(),
};
assert_eq!(resolve_color("primary", false, &theme), "#4f46e5");
assert_eq!(resolve_color("text", false, &theme), "#1e293b");
}
#[test]
fn test_resolve_color_dark() {
use crate::model::Theme;
let theme = Theme {
name: "test".into(),
primary: "#4f46e5".into(),
accent: "#06b6d4".into(),
bg: "#ffffff".into(),
panel: "#f8fafc".into(),
text: "#1e293b".into(),
muted: "#64748b".into(),
};
// 深色背景下 text 键 → 白色
assert_eq!(resolve_color("text", true, &theme), "#ffffff");
// primary 键在深色背景下也 → 白色
assert_eq!(resolve_color("primary", true, &theme), "#ffffff");
}
}

246
src-tauri/src/commands.rs Normal file
View File

@@ -0,0 +1,246 @@
/* =====================================================================
* commands.rs — Tauri IPC 命令(前端 invoke 入口)
*
* 设计原则:
* - 所有写操作通过 exec_op 统一走 Op Log未来协同基础
* - 所有读操作直接返回当前状态
* - AI 流式通过 emit("ai-delta", token) 实时推送到前端
* - 前端检测 __TAURI__ 环境Web 模式回退到 TS store
* ===================================================================== */
use serde::{Deserialize, Serialize};
use tauri::State;
use tauri::Emitter;
use std::sync::Mutex;
use crate::model::*;
use crate::op::{apply_op, invert_op};
/* ---------- 应用状态 ---------- */
pub struct AppState {
pub deck: Mutex<Deck>,
pub current_index: Mutex<usize>,
pub selected_id: Mutex<Option<String>>,
pub history: Mutex<Vec<crate::model::Op>>, // forward ops
pub backward: Mutex<Vec<crate::model::Op>>, // backward ops (for undo)
}
impl AppState {
pub fn new() -> Self {
Self {
deck: Mutex::new(Deck { v: 3, theme: "indigo".into(), slides: vec![], chat_id: None }),
current_index: Mutex::new(0),
selected_id: Mutex::new(None),
history: Mutex::new(vec![]),
backward: Mutex::new(vec![]),
}
}
}
/* ---------- 命令参数 ---------- */
#[derive(Deserialize)]
pub struct ExecOpArgs {
pub op: serde_json::Value, // { type, payload... }
}
#[derive(Serialize)]
pub struct OpResult {
pub success: bool,
pub deck: Deck,
pub error: Option<String>,
}
/* ---------- 命令实现 ---------- */
/// 执行一个 Op记录历史用于 undo
#[tauri::command]
pub fn exec_op(state: State<AppState>, op_json: serde_json::Value) -> Result<OpResult, String> {
let op: crate::model::Op = serde_json::from_value(op_json.clone())
.map_err(|e| format!("Op 解析失败: {}", e))?;
let mut deck = state.deck.lock().map_err(|e| e.to_string())?;
let backward_op = invert_op(&deck, &op);
*deck = apply_op(&deck, &op);
let mut hist = state.history.lock().map_err(|e| e.to_string())?;
hist.push(op);
if hist.len() > 40 { hist.remove(0); }
let mut back = state.backward.lock().map_err(|e| e.to_string())?;
back.truncate(0); // 新操作清空 redo 栈
Ok(OpResult { success: true, deck: deck.clone(), error: None })
}
/// 撤销
#[tauri::command]
pub fn undo(state: State<AppState>) -> Result<OpResult, String> {
let mut deck = state.deck.lock().map_err(|e| e.to_string())?;
let mut hist = state.history.lock().map_err(|e| e.to_string())?;
let mut back = state.backward.lock().map_err(|e| e.to_string())?;
if hist.is_empty() { return Ok(OpResult { success: false, deck: deck.clone(), error: Some("无可撤销操作".into()) }); }
let forward_op = hist.pop().unwrap();
let backward_op = invert_op(&deck, &forward_op);
*deck = apply_op(&deck, &backward_op);
back.push(forward_op);
Ok(OpResult { success: true, deck: deck.clone(), error: None })
}
/// 重做
#[tauri::command]
pub fn redo(state: State<AppState>) -> Result<OpResult, String> {
let mut deck = state.deck.lock().map_err(|e| e.to_string())?;
let mut hist = state.history.lock().map_err(|e| e.to_string())?;
let mut back = state.backward.lock().map_err(|e| e.to_string())?;
if back.is_empty() { return Ok(OpResult { success: false, deck: deck.clone(), error: Some("无可重做操作".into()) }); }
let forward_op = back.pop().unwrap();
*deck = apply_op(&deck, &forward_op);
hist.push(forward_op);
Ok(OpResult { success: true, deck: deck.clone(), error: None })
}
/// 检查是否可撤销
#[tauri::command]
pub fn can_undo(state: State<AppState>) -> bool {
state.history.lock().map(|h| !h.is_empty()).unwrap_or(false)
}
/// 检查是否可重做
#[tauri::command]
pub fn can_redo(state: State<AppState>) -> bool {
state.backward.lock().map(|b| !b.is_empty()).unwrap_or(false)
}
/// 获取当前 deck
#[tauri::command]
pub fn get_deck(state: State<AppState>) -> Deck {
state.deck.lock().unwrap().clone()
}
/// 设置当前页索引 + 选中元素
#[tauri::command]
pub fn set_navigation(state: State<AppState>, index: usize, selected: Option<String>) {
if let Ok(mut ci) = state.current_index.lock() {
*ci = index;
}
if let Ok(mut si) = state.selected_id.lock() {
*si = selected;
}
}
/// 获取连接信息
#[tauri::command]
pub fn app_info() -> serde_json::Value {
serde_json::json!({
"name": "u-ppt",
"version": env!("CARGO_PKG_VERSION"),
"platform": std::env::consts::OS,
"backend": "tauri-rust",
})
}
/// Ping 测试
#[tauri::command]
pub fn ping() -> String {
"pong".to_string()
}
/// 保存 deck 到文件
#[tauri::command]
pub fn save_file(content: String) -> Result<String, String> {
Ok("ok".to_string())
}
/* ---------- AI 请求代理(解决 CORS ---------- */
#[derive(Serialize)]
pub struct AiProxyResult {
pub status: u16,
pub body: String,
pub error: Option<String>,
}
/// 代理 AI 请求Rust 端发起,不受浏览器 CORS 限制)
#[tauri::command]
pub async fn ai_proxy(url: String, api_key: String, body: String) -> Result<AiProxyResult, String> {
let client = reqwest::Client::new();
let resp = client
.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", api_key))
.body(body)
.send()
.await
.map_err(|e| format!("请求失败: {}", e))?;
let status = resp.status().as_u16();
let text = resp.text().await.unwrap_or_default();
Ok(AiProxyResult { status, body: text, error: None })
}
/// 代理 AI 流式请求SSE通过 Tauri event 实时推送
#[tauri::command]
pub async fn ai_proxy_stream(
app: tauri::AppHandle,
url: String,
api_key: String,
body: String,
) -> Result<String, String> {
let client = reqwest::Client::new();
let resp = client
.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", api_key))
.body(body)
.send()
.await
.map_err(|e| format!("请求失败: {}", e))?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Ok(text);
}
// 流式读取 SSE
use futures::StreamExt;
let mut stream = resp.bytes_stream();
let mut full = String::new();
while let Some(chunk) = stream.next().await {
match chunk {
Ok(bytes) => {
let text = String::from_utf8_lossy(&bytes);
full.push_str(&text);
// 推送到前端
let _ = app.emit("ai-delta", &text);
}
Err(e) => {
let err_msg = format!("流式读取错误: {}", e);
let _ = app.emit("ai-error", &err_msg);
return Err(err_msg);
}
}
}
Ok(full)
}
/// 写入文件(替代文件保存对话框)
#[tauri::command]
pub fn write_file(path: String, content: String) -> Result<(), String> {
std::fs::write(&path, &content).map_err(|e| format!("写入失败: {}", e))
}
/// 读取文件(替代文件打开对话框)
#[tauri::command]
pub fn read_file(path: String) -> Result<String, String> {
std::fs::read_to_string(&path).map_err(|e| format!("读取失败: {}", e))
}

40
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,40 @@
mod ai;
mod color;
mod commands;
mod model;
mod op;
pub fn run() {
let app_state = commands::AppState::new();
tauri::Builder::default()
.manage(app_state)
.invoke_handler(tauri::generate_handler![
commands::ping,
commands::app_info,
commands::exec_op,
commands::undo,
commands::redo,
commands::can_undo,
commands::can_redo,
commands::get_deck,
commands::set_navigation,
commands::save_file,
commands::ai_proxy,
commands::ai_proxy_stream,
commands::write_file,
commands::read_file,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod tests {
use super::commands;
#[test]
fn test_ping() {
assert_eq!(commands::ping(), "pong");
}
}

6
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
u_ppt_lib::run()
}

308
src-tauri/src/model.rs Normal file
View File

@@ -0,0 +1,308 @@
/* =====================================================================
* model.rs — 数据模型(对应 src/core/types.ts
*
* 所有结构体 derive Serialize/Deserialize/TS确保
* - serde: JSON 序列化Rust ↔ IPC ↔ JS
* - ts-rs: 自动生成 TypeScript 类型定义Rust ↔ TS 类型同步)
*
* 运行 `cargo test --features export-types` 会在 ../bindings/ 生成 .ts 文件。
* ===================================================================== */
use serde::{Deserialize, Serialize};
use ts_rs::TS;
/* ---------- 颜色 / 背景 / 动画 ---------- */
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub enum ChartType {
#[serde(rename = "bar")]
Bar,
#[serde(rename = "line")]
Line,
#[serde(rename = "pie")]
Pie,
#[serde(rename = "area")]
Area,
#[serde(rename = "doughnut")]
Doughnut,
#[serde(rename = "radar")]
Radar,
#[serde(rename = "hbar")]
Hbar,
#[serde(rename = "progress")]
Progress,
}
impl Default for ChartType {
fn default() -> Self {
ChartType::Bar
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub enum ShapeType {
#[serde(rename = "rect")]
Rect,
#[serde(rename = "circle")]
Circle,
#[serde(rename = "triangle")]
Triangle,
}
impl Default for ShapeType {
fn default() -> Self {
ShapeType::Rect
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub enum AnimType {
#[serde(rename = "fade-up")]
FadeUp,
#[serde(rename = "fade")]
Fade,
#[serde(rename = "scale")]
Scale,
#[serde(rename = "zoom")]
Zoom,
#[serde(rename = "slide-l")]
SlideL,
#[serde(rename = "slide-r")]
SlideR,
#[serde(rename = "pop")]
Pop,
#[serde(rename = "rotate")]
Rotate,
#[serde(rename = "bounce")]
Bounce,
#[serde(rename = "flip")]
Flip,
#[serde(rename = "blur")]
Blur,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub enum ElementType {
#[serde(rename = "title")]
Title,
#[serde(rename = "text")]
Text,
#[serde(rename = "list")]
List,
#[serde(rename = "stat")]
Stat,
#[serde(rename = "quote")]
Quote,
#[serde(rename = "image")]
Image,
#[serde(rename = "shape")]
Shape,
#[serde(rename = "chart")]
Chart,
#[serde(rename = "card")]
Card,
#[serde(rename = "table")]
Table,
#[serde(rename = "code")]
Code,
#[serde(rename = "formula")]
Formula,
}
/* ---------- 主题 ---------- */
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub struct Theme {
pub name: String,
pub primary: String,
pub accent: String,
pub bg: String,
pub panel: String,
pub text: String,
pub muted: String,
}
/* ---------- 富文本 ---------- */
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub struct RichSegment {
pub text: String,
pub bold: Option<bool>,
pub italic: Option<bool>,
pub underline: Option<bool>,
pub strike: Option<bool>,
pub color: Option<String>,
pub highlight: Option<bool>,
pub code: Option<bool>,
#[serde(rename = "sup")]
pub sup_: Option<bool>,
#[serde(rename = "sub")]
pub sub_: Option<bool>,
pub font_size: Option<f64>,
pub link: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub struct RichLine {
pub segments: Vec<RichSegment>,
}
/* ---------- 元素 ---------- */
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
#[serde(rename_all = "camelCase")]
pub struct ElementStyle {
pub font_size: Option<f64>,
pub color: Option<String>,
pub align: Option<String>,
pub bold: Option<bool>,
pub italic: Option<bool>,
pub anim: Option<String>,
// stat
pub label: Option<String>,
pub label_color: Option<String>,
pub label_size: Option<f64>,
// image
pub fit: Option<String>,
// shape
pub shape_type: Option<ShapeType>,
pub fill: Option<String>,
pub gradient: Option<bool>,
pub opacity: Option<f64>,
pub radius: Option<f64>,
// chart
pub max: Option<f64>,
pub chart_type: Option<ChartType>,
pub legend: Option<bool>,
pub stack: Option<bool>,
pub grid: Option<bool>,
// table
pub header: Option<bool>,
// code
pub lang: Option<String>,
// formula
#[serde(rename = "inline")]
pub inline_: Option<bool>,
// card
pub icon: Option<String>,
pub accent: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub struct SlideElement {
pub id: String,
#[serde(rename = "type")]
pub element_type: String,
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
pub content: String,
pub style: ElementStyle,
pub segments: Option<Vec<RichLine>>,
}
/* ---------- 幻灯片 / Deck ---------- */
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub struct Slide {
pub id: String,
pub background: String,
pub elements: Vec<SlideElement>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub struct Deck {
pub v: i32,
pub theme: String,
pub slides: Vec<Slide>,
pub chat_id: Option<String>,
}
/* ---------- AI 配置 ---------- */
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub struct AiCfg {
pub preset: String,
pub protocol: String,
pub base: String,
pub key: String,
pub model: String,
pub proxy: String,
pub img_base: Option<String>,
pub img_key: Option<String>,
pub img_model: Option<String>,
}
/* ---------- 文库 ---------- */
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub struct LibItem {
pub id: String,
pub name: String,
pub deck: Deck,
pub created_at: f64,
pub updated_at: f64,
}
/* ---------- 模板 ---------- */
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub struct PageTemplate {
pub id: String,
pub name: String,
pub category: String,
pub background: String,
pub elements: Vec<SlideElement>,
pub created_at: Option<f64>,
}
/* ---------- 会话 ---------- */
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
/* ---------- Op Log对应 src/core/op.ts ---------- */
/// Op 的 payload 是任意 JSON不导出 TS因为 payload 是动态的)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Op {
#[serde(rename = "type")]
pub op_type: String,
pub client_id: Option<String>,
pub timestamp: Option<f64>,
#[serde(flatten)]
pub payload: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
pub forward: Op,
pub backward: Op,
}
/* ---------- 图表数据 ---------- */
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../bindings/types.ts")]
pub struct ChartItem {
pub label: Option<String>,
pub value: Option<f64>,
}

558
src-tauri/src/op.rs Normal file
View File

@@ -0,0 +1,558 @@
/* =====================================================================
* op.rs — Op Log reducer对应 src/core/op.ts
*
* 纯函数apply_op(deck, op) → new deck不 mutate 输入
* invert_op(deck, op) → 能撤销 op 的逆 op
*
* 所有 Op 通过 serde_json::Value 传递 payload按 op_type 分发。
* ===================================================================== */
use crate::model::{Deck, Slide, SlideElement};
/* ---------- 工具 ---------- */
fn clone_deck(deck: &Deck) -> Deck {
serde_json::from_str(&serde_json::to_string(deck).unwrap()).unwrap()
}
fn find_element(deck: &Deck, slide_idx: usize, el_id: &str) -> Option<usize> {
let slide = deck.slides.get(slide_idx)?;
slide.elements.iter().position(|e| e.id == el_id)
}
/* ---------- reducer ---------- */
pub fn apply_op(input_deck: &Deck, op: &crate::model::Op) -> Deck {
let mut deck = clone_deck(input_deck);
match op.op_type.as_str() {
"add_slide" => {
if let Some(slide) = op.payload.get("slide").and_then(|v| v.as_object()) {
if let Ok(s) = serde_json::from_value(serde_json::Value::Object(slide.clone())) as Result<Slide, _> {
let at_index = op.payload.get("atIndex")
.or_else(|| op.payload.get("at_index"))
.and_then(|v| v.as_f64())
.map(|f| f as usize)
.unwrap_or(deck.slides.len());
let idx = at_index.min(deck.slides.len());
deck.slides.insert(idx, s);
}
}
}
"del_slide" => {
if deck.slides.len() <= 1 {
return deck;
}
if let Some(index) = op.payload.get("index").and_then(|v| v.as_f64()).map(|f| f as usize) {
if index < deck.slides.len() {
deck.slides.remove(index);
}
}
}
"move_slide" => {
let from = op.payload.get("from").and_then(|v| v.as_f64()).map(|f| f as usize);
let to = op.payload.get("to").and_then(|v| v.as_f64()).map(|f| f as usize);
if let (Some(from), Some(to)) = (from, to) {
if from != to && from < deck.slides.len() && to < deck.slides.len() {
let item = deck.slides.remove(from);
deck.slides.insert(to, item);
}
}
}
"replace_slide" => {
if let Some(slide_val) = op.payload.get("slide") {
if let Ok(mut s) = serde_json::from_value(slide_val.clone()) as Result<Slide, _> {
if let Some(index) = op.payload.get("index").and_then(|v| v.as_f64()).map(|f| f as usize) {
if let Some(existing) = deck.slides.get(index) {
s.id = existing.id.clone(); // 保持 id 不变
}
if index < deck.slides.len() {
deck.slides[index] = s;
}
}
}
}
}
"set_slide_bg" => {
if let Some(index) = op.payload.get("index").and_then(|v| v.as_f64()).map(|f| f as usize) {
if let Some(bg) = op.payload.get("bg").and_then(|v| v.as_str()) {
if let Some(slide) = deck.slides.get_mut(index) {
slide.background = bg.to_string();
}
}
}
}
"add_element" => {
if let Some(el_val) = op.payload.get("element") {
if let Ok(el) = serde_json::from_value(el_val.clone()) as Result<SlideElement, _> {
if let Some(slide_idx) = op.payload.get("slideIdx")
.or_else(|| op.payload.get("slide_idx"))
.and_then(|v| v.as_f64())
.map(|f| f as usize)
{
if let Some(slide) = deck.slides.get_mut(slide_idx) {
slide.elements.push(el);
}
}
}
}
}
"update_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 element_id = op.payload.get("elementId")
.or_else(|| op.payload.get("element_id"))
.and_then(|v| v.as_str());
if let (Some(sidx), Some(eid)) = (slide_idx, element_id) {
if let Some(eidx) = find_element(&deck, sidx, eid) {
let slide = &mut deck.slides[sidx];
let el = &mut slide.elements[eidx];
// 应用 patch
if let Some(patch) = op.payload.get("patch").and_then(|v| v.as_object()) {
if let Some(style_patch) = patch.get("style").and_then(|v| v.as_object()) {
// 字段级 merge style
let current_style_val = serde_json::to_value(&el.style).unwrap_or_default();
if let Ok(mut current_style_map) = current_style_val.as_object().cloned().ok_or(()) {
for (k, v) in style_patch {
current_style_map.insert(k.clone(), v.clone());
}
if let Ok(new_style) = serde_json::from_value(serde_json::Value::Object(current_style_map)) {
el.style = new_style;
}
}
}
// 非 style 字段
let el_val = serde_json::to_value(&el.clone()).unwrap_or_default();
if let Ok(mut el_map) = el_val.as_object().cloned().ok_or(()) {
for (k, v) in patch {
if k != "style" {
el_map.insert(k.clone(), v.clone());
}
}
if let Ok(new_el) = serde_json::from_value(serde_json::Value::Object(el_map)) {
*el = new_el;
}
}
}
}
}
}
"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 element_id = op.payload.get("elementId")
.or_else(|| op.payload.get("element_id"))
.and_then(|v| v.as_str());
if let (Some(sidx), Some(eid)) = (slide_idx, element_id) {
if let Some(slide) = deck.slides.get_mut(sidx) {
slide.elements.retain(|e| e.id != eid);
}
}
}
"move_element_z" => {
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());
let dir = op.payload.get("dir").and_then(|v| v.as_f64()).map(|f| f as i64);
if let (Some(sidx), Some(eid), Some(dir)) = (slide_idx, element_id, dir) {
if let Some(eidx) = find_element(&deck, sidx, eid) {
let j = if dir > 0 { eidx + 1 } else { eidx.saturating_sub(1) };
if let Some(slide) = deck.slides.get_mut(sidx) {
if j < slide.elements.len() {
slide.elements.swap(eidx, j);
}
}
}
}
}
"set_theme" => {
if let Some(theme) = op.payload.get("theme").and_then(|v| v.as_str()) {
deck.theme = theme.to_string();
}
}
"replace_deck" => {
if let Some(new_deck) = op.payload.get("deck") {
if let Ok(mut d) = serde_json::from_value(new_deck.clone()) as Result<Deck, _> {
d.v = deck.v; // 保持版本号
return d;
}
}
}
_ => {} // 未知 Op不修改
}
deck
}
/* ---------- 逆操作计算 ---------- */
pub fn invert_op(input_deck: &Deck, op: &crate::model::Op) -> crate::model::Op {
use crate::model::Op;
use serde_json::json;
match op.op_type.as_str() {
"add_slide" => {
// 逆:删除加的那页
let slide_id = op.payload.get("slide").and_then(|v| v.get("id")).and_then(|v| v.as_str());
let idx = if let Some(sid) = slide_id {
input_deck.slides.iter().position(|s| s.id == sid)
} else {
op.payload.get("atIndex").and_then(|v| v.as_f64()).map(|f| f as usize)
};
Op {
op_type: "del_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "index": idx.unwrap_or(input_deck.slides.len().saturating_sub(1)) }),
}
}
"del_slide" => {
// 逆:把删掉的页插回原位
let index = op.payload.get("index").and_then(|v| v.as_f64()).map(|f| f as usize);
if let Some(deleted) = op.payload.get("deletedSlide").or_else(|| op.payload.get("deleted_slide")) {
Op {
op_type: "add_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "atIndex": index, "slide": deleted }),
}
} else {
Op { op_type: "replace_deck".into(), client_id: None, timestamp: None, payload: json!({ "deck": clone_deck(input_deck) }) }
}
}
"move_slide" => {
let from = op.payload.get("from").cloned().unwrap_or_default();
let to = op.payload.get("to").cloned().unwrap_or_default();
Op {
op_type: "move_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "from": to, "to": from }),
}
}
"replace_slide" => {
let index = op.payload.get("index").cloned().unwrap_or_default();
let old = op.payload.get("oldSlide").or_else(|| op.payload.get("old_slide")).cloned()
.unwrap_or_else(|| {
let idx = index.as_f64().map(|f| f as usize).unwrap_or(0);
serde_json::to_value(input_deck.slides.get(idx).cloned().unwrap_or_else(|| Slide { id: String::new(), background: "bg".into(), elements: vec![] })).unwrap_or_default()
});
Op {
op_type: "replace_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "index": index, "slide": old }),
}
}
"set_slide_bg" => {
let index = op.payload.get("index").and_then(|v| v.as_f64()).map(|f| f as usize);
let old_bg = index.and_then(|i| input_deck.slides.get(i)).map(|s| s.background.clone()).unwrap_or_else(|| "bg".into());
Op {
op_type: "set_slide_bg".into(),
client_id: None,
timestamp: None,
payload: json!({ "index": index, "bg": old_bg }),
}
}
"add_element" => {
let slide_idx = op.payload.get("slideIdx").or_else(|| op.payload.get("slide_idx")).cloned().unwrap_or_default();
let element_id = op.payload.get("element").and_then(|v| v.get("id")).cloned().unwrap_or_default();
Op {
op_type: "del_element".into(),
client_id: None,
timestamp: None,
payload: json!({ "slideIdx": slide_idx, "elementId": element_id }),
}
}
"update_element" => {
// 构造反向 patch取当前值
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 patch = op.payload.get("patch").cloned().unwrap_or_default();
if let Some(sidx) = slide_idx {
if let Some(eidx) = find_element(input_deck, sidx, &element_id) {
let el = &input_deck.slides[sidx].elements[eidx];
let el_val = serde_json::to_value(el).unwrap_or_default();
let mut backward_patch = serde_json::Map::new();
if let Some(patch_obj) = patch.as_object() {
if let Some(style_patch) = patch_obj.get("style").and_then(|v| v.as_object()) {
let current_style = &el.style;
let style_val = serde_json::to_value(current_style).unwrap_or_default();
let mut old_style = serde_json::Map::new();
if let Some(style_map) = style_val.as_object() {
for (k, _) in style_patch {
if let Some(v) = style_map.get(k) {
old_style.insert(k.clone(), v.clone());
}
}
}
backward_patch.insert("style".into(), serde_json::Value::Object(old_style));
}
for (k, _) in patch_obj {
if k != "style" {
if let Some(v) = el_val.get(k) {
backward_patch.insert(k.clone(), v.clone());
}
}
}
}
return Op {
op_type: "update_element".into(),
client_id: None,
timestamp: None,
payload: json!({ "slideIdx": sidx, "elementId": element_id, "patch": serde_json::Value::Object(backward_patch) }),
};
}
}
Op { op_type: "replace_deck".into(), client_id: None, timestamp: None, payload: json!({ "deck": clone_deck(input_deck) }) }
}
"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 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")) {
Op {
op_type: "add_element".into(),
client_id: None,
timestamp: None,
payload: json!({ "slideIdx": slide_idx, "element": deleted }),
}
} else {
Op { op_type: "replace_deck".into(), client_id: None, timestamp: None, payload: json!({ "deck": clone_deck(input_deck) }) }
}
}
"move_element_z" => {
let slide_idx = op.payload.get("slideIdx").or_else(|| op.payload.get("slide_idx")).cloned().unwrap_or_default();
let element_id = op.payload.get("elementId").or_else(|| op.payload.get("element_id")).cloned().unwrap_or_default();
let dir = op.payload.get("dir").and_then(|v| v.as_f64()).unwrap_or(0.0);
Op {
op_type: "move_element_z".into(),
client_id: None,
timestamp: None,
payload: json!({ "slideIdx": slide_idx, "elementId": element_id, "dir": -dir }),
}
}
"set_theme" => {
Op {
op_type: "set_theme".into(),
client_id: None,
timestamp: None,
payload: json!({ "theme": input_deck.theme }),
}
}
"replace_deck" => {
Op {
op_type: "replace_deck".into(),
client_id: None,
timestamp: None,
payload: json!({ "deck": clone_deck(input_deck) }),
}
}
_ => Op {
op_type: "replace_deck".into(),
client_id: None,
timestamp: None,
payload: json!({ "deck": clone_deck(input_deck) }),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::*;
use serde_json::json;
fn make_test_deck() -> Deck {
Deck {
v: 3,
theme: "indigo".into(),
slides: vec![
Slide {
id: "s1".into(),
background: "bg".into(),
elements: vec![
SlideElement {
id: "e1".into(),
element_type: "title".into(),
x: 10.0, y: 10.0, w: 80.0, h: 15.0,
content: "Hello".into(),
style: ElementStyle { font_size: Some(48.0), ..Default::default() },
segments: None,
},
],
},
Slide {
id: "s2".into(),
background: "panel".into(),
elements: vec![],
},
],
chat_id: None,
}
}
#[test]
fn test_add_slide() {
let deck = make_test_deck();
let new_slide = Slide { id: "s3".into(), background: "bg".into(), elements: vec![] };
let op = Op {
op_type: "add_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "atIndex": 1, "slide": new_slide }),
};
let result = apply_op(&deck, &op);
assert_eq!(result.slides.len(), 3);
assert_eq!(result.slides[1].id, "s3");
}
#[test]
fn test_del_slide() {
let deck = make_test_deck();
let op = Op {
op_type: "del_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "index": 1 }),
};
let result = apply_op(&deck, &op);
assert_eq!(result.slides.len(), 1);
assert_eq!(result.slides[0].id, "s1");
}
#[test]
fn test_set_theme() {
let deck = make_test_deck();
let op = Op {
op_type: "set_theme".into(),
client_id: None,
timestamp: None,
payload: json!({ "theme": "rose" }),
};
let result = apply_op(&deck, &op);
assert_eq!(result.theme, "rose");
}
#[test]
fn test_update_element() {
let deck = make_test_deck();
let op = Op {
op_type: "update_element".into(),
client_id: None,
timestamp: None,
payload: json!({ "slideIdx": 0, "elementId": "e1", "patch": { "style": { "fontSize": 56 } } }),
};
let result = apply_op(&deck, &op);
assert_eq!(result.slides[0].elements[0].style.font_size, Some(56.0));
}
#[test]
fn test_del_element() {
let deck = make_test_deck();
let op = Op {
op_type: "del_element".into(),
client_id: None,
timestamp: None,
payload: json!({ "slideIdx": 0, "elementId": "e1" }),
};
let result = apply_op(&deck, &op);
assert_eq!(result.slides[0].elements.len(), 0);
}
#[test]
fn test_move_slide() {
let deck = make_test_deck();
let op = Op {
op_type: "move_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "from": 0, "to": 1 }),
};
let result = apply_op(&deck, &op);
assert_eq!(result.slides[0].id, "s2");
assert_eq!(result.slides[1].id, "s1");
}
#[test]
fn test_undo_update() {
let deck = make_test_deck();
let op = Op {
op_type: "update_element".into(),
client_id: None,
timestamp: None,
payload: json!({ "slideIdx": 0, "elementId": "e1", "patch": { "style": { "fontSize": 56 } } }),
};
let backward = invert_op(&deck, &op);
let applied = apply_op(&deck, &op);
let undone = apply_op(&applied, &backward);
assert_eq!(undone.slides[0].elements[0].style.font_size, Some(48.0));
}
#[test]
fn test_undo_add_slide() {
let deck = make_test_deck();
let new_slide = Slide { id: "s3".into(), background: "bg".into(), elements: vec![] };
let op = Op {
op_type: "add_slide".into(),
client_id: None,
timestamp: None,
payload: json!({ "atIndex": 1, "slide": new_slide }),
};
let backward = invert_op(&deck, &op);
let applied = apply_op(&deck, &op);
assert_eq!(applied.slides.len(), 3);
let undone = apply_op(&applied, &backward);
assert_eq!(undone.slides.len(), 2);
}
#[test]
fn test_replace_deck_undo() {
let deck = make_test_deck();
let new_deck = Deck { v: 3, theme: "rose".into(), slides: vec![Slide { id: "s9".into(), background: "bg".into(), elements: vec![] }], chat_id: None };
let op = Op {
op_type: "replace_deck".into(),
client_id: None,
timestamp: None,
payload: json!({ "deck": new_deck }),
};
let backward = invert_op(&deck, &op);
let applied = apply_op(&deck, &op);
assert_eq!(applied.slides.len(), 1);
let undone = apply_op(&applied, &backward);
assert_eq!(undone.slides.len(), 2);
assert_eq!(undone.slides[0].id, "s1");
}
}

39
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,39 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "u-ppt",
"version": "1.0.0",
"identifier": "com.uppt.desktop",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "u-ppt · 在线演示工具",
"width": 1280,
"height": 800,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

View File

@@ -71,6 +71,49 @@ function onSave() {
}
function openAi() { switchTab('ai') }
/* ---------- 导出/导入 ---------- */
function onExportJson() {
const json = store.exportJSON()
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'u-ppt-export.json'
a.click()
URL.revokeObjectURL(url)
toast('已导出 JSON')
}
function onImportJson() {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json,application/json'
input.onchange = () => {
const file = input.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
try {
store.importJSON(reader.result as string)
toast('已导入')
} catch (e: any) {
toast('导入失败:' + (e?.message || String(e)))
}
}
reader.readAsText(file)
}
input.click()
}
function onExportPdf() {
// 用浏览器打印 + @media print CSS 实现 PDF 导出
// 演示模式的 slide-layer 已有 print 样式
document.body.classList.add('printing')
window.print()
setTimeout(() => document.body.classList.remove('printing'), 500)
toast('在打印对话框中选择「另存为 PDF」')
}
/* ---------- 全局快捷键(编辑模式) ---------- */
function onKey(e: KeyboardEvent) {
if (document.body.dataset.mode === 'present') return
@@ -119,6 +162,9 @@ onUnmounted(() => {
@open-ai="openAi"
@save="onSave"
@open-templates="templateVisible = true"
@export-json="onExportJson"
@import-json="onImportJson"
@export-pdf="onExportPdf"
/>
<div class="editor-body">

View File

@@ -20,8 +20,8 @@ const CHART_TYPES: Array<{ k: ChartType; label: string; icon: string }> = [
{ k: 'radar', label: '雷达图', icon: '🕸' },
{ k: 'progress', label: '进度图', icon: '⭕' }
]
const CHART_TYPE_MAP: Record<string, { label: string; icon: string }> = Object.fromEntries(CHART_TYPES.map(t => [t.k, t]))
const emit = defineEmits
const selected = computed(() => store.getSelected())
const slide = computed(() => store.currentSlide.value)

View File

@@ -13,6 +13,9 @@ const emit = defineEmits<{
(e: 'open-ai'): void
(e: 'save'): void
(e: 'open-templates'): void
(e: 'export-json'): void
(e: 'import-json'): void
(e: 'export-pdf'): void
}>()
const themeKeys = Object.keys(themes)
@@ -70,6 +73,9 @@ defineProps<{ disabledActions?: string[] }>()
<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>
<button class="btn" data-action="import-json" title="导入 JSON" @click="emit('import-json')">📥 导入</button>
<button class="btn" data-action="export-pdf" title="打印/导出 PDF" @click="emit('export-pdf')">🖨 PDF</button>
<button class="btn" data-action="open-ai" title="打开 AI 助手" @click="action('open-ai')">🤖 AI</button>
<button class="btn" data-action="reset" title="重置为内置示例" :disabled="disabledActions?.includes('reset')" @click="action('reset')"> 重置</button>
<button class="btn primary" data-action="present" title="开始演示 (F5)" :disabled="disabledActions?.includes('present')" @click="action('present')"> 演示</button>

View File

@@ -0,0 +1,73 @@
/* =====================================================================
* 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()

173
src/core/backend.ts Normal file
View File

@@ -0,0 +1,173 @@
/* =====================================================================
* backend.ts — 持久化后端抽象层
*
* store 只管内存状态 + Op Log持久化通过 Backend 接口抽象。
* 三个实现:
* - LocalBackend localStorageWeb 版当前模式)
* - TauriBackend Rust 文件系统桌面版B4 实现)
* - CloudBackend HTTP API云端版C0 实现)
*
* 设计原则:
* - 接口只管读写数据,不管状态逻辑(状态逻辑在 store
* - 所有方法返回 PromiseTauriBackend/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
}

44
src/core/bridge.ts Normal file
View File

@@ -0,0 +1,44 @@
/* =====================================================================
* bridge.ts — Tauri IPC 安全调用桥接
*
* 安全检测 __TAURI__ 环境,动态 import @tauri-apps/api/core。
* Web 模式下返回 mock组件无需关心环境。
* ===================================================================== */
/** 动态获取 invoke 函数 */
let _invoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null
async function ensureInvoke() {
if (_invoke) return true
if (typeof window === 'undefined' || !('__TAURI__' in window)) return false
try {
const mod = await import('@tauri-apps/api/core')
_invoke = mod.invoke
return true
} catch {
return false
}
}
/**
* 安全调用 Tauri IPC 命令。
* Web 模式下返回 undefined调用方需处理
*/
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 → 前端推送) */
export async function listen<T>(event: string, handler: (payload: T) => void): Promise<() => void> {
if (typeof window === 'undefined' || !('__TAURI__' in window)) {
return () => {} // 空注销函数
}
try {
const { listen } = await import('@tauri-apps/api/event')
return listen(event, (e: { payload: T }) => handler(e.payload))
} catch {
return () => {}
}
}

View File

@@ -1,54 +0,0 @@
/* =====================================================================
* inline.ts — Markdown 行内格式渲染
* 把 **加粗** / *斜体* / ~~删除线~~ / ==高亮== / `代码` / ~下标~ / ^上标^ 转 HTML
* 先转义 HTML再按顺序替换避免互相破坏
* ===================================================================== */
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/**
* 把 Markdown 行内语法转 HTML。
* 输入应为一行文本(不含换行)。
* 安全:先转义 HTML再插入受控标签。
*/
export function renderInline(text: string): string {
if (!text) return ''
let s = escapeHtml(text)
// 加粗 **text**(必须先于斜体,避免 ** 与 * 冲突)
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
// 斜体 *text* 或 _text_
s = s.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
s = s.replace(/(?<!\w)_([^_]+)_(?!\w)/g, '<em>$1</em>')
// 删除线 ~~text~~
s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>')
// 高亮 ==text==
s = s.replace(/==([^=]+)==/g, '<mark>$1</mark>')
// 行内代码 `code`
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
// 下标 ~text~(与删除线区分:单个 ~
s = s.replace(/(?<!~)~([^~\s][^~]*?)~(?!~)/g, '<sub>$1</sub>')
// 上标 ^text^
s = s.replace(/\^([^\^\s][^\^]*?)\^/g, '<sup>$1</sup>')
return s
}
/**
* 渲染多行文本:逐行 renderInline用 <br> 连接。
* 用于 title / text / quote 元素的 content。
*/
export function renderInlineMulti(text: string): string {
if (!text) return ''
return text.split('\n').map(renderInline).join('<br>')
}
/**
* 是否包含 Markdown 行内语法(用于决定是否走 rich 渲染路径)
*/
export function hasInlineSyntax(text: string): boolean {
if (!text) return false
return /\*\*[^*]+\*\*|(?<!\*)\*[^*]+\*(?!\*)|~~[^~]+~~|==[^=]+==|`[^`]+`|\^[^\^]+\^/.test(text)
}

274
src/core/op.ts Normal file
View File

@@ -0,0 +1,274 @@
/* =====================================================================
* op.ts — 操作日志Op Log数据模型
*
* 所有对 deck 的变更都表达为 Op通过纯函数 reducer 应用。
* Op 是可序列化、可回放、可逆的——为未来协同预留基础。
*
* 设计:
* - 元素级粒度update_element 带字段级 patch
* - replace_deck 作为特殊 Op进 undo 历史
* - 每个 Op 可计算逆 Op用于 undo
* - reducer 是纯函数:(deck, op) → newDeck
* ===================================================================== */
import type { Deck, Slide, SlideElement, ElementStyle, BgKey } from './types'
/* ---------- Op 类型定义 ---------- */
export type OpType =
| 'add_slide'
| 'del_slide'
| 'move_slide'
| 'replace_slide'
| 'set_slide_bg'
| 'add_element'
| 'update_element'
| 'del_element'
| 'move_element_z'
| 'set_theme'
| 'replace_deck'
export interface Op {
type: OpType
// 通用元数据(为协同预留)
clientId?: string
timestamp?: number
// 各类型的 payload按 type 不同,只填对应字段)
[key: string]: unknown
}
/** 带逆操作的历史条目 */
export interface HistoryEntry {
forward: Op
backward: Op
}
/* ---------- 工具 ---------- */
function clone<T>(o: T): T { return JSON.parse(JSON.stringify(o)) }
/** 在 deck 的某页里找元素 */
function findEl(deck: Deck, slideIdx: number, elId: string): SlideElement | null {
const s = deck.slides[slideIdx]
if (!s) return null
return s.elements.find(e => e.id === elId) || null
}
/* ============================================================
* 纯函数 reducerapplyOp(deck, op) → newDeck
* 不 mutate 输入,返回新 deck
* ============================================================ */
export function applyOp(inputDeck: Deck, op: Op): Deck {
const deck = clone(inputDeck) // 深拷贝,不 mutate 原数据
switch (op.type) {
case 'add_slide': {
const atIndex = op.atIndex as number | undefined
const slide = op.slide as Slide
const idx = atIndex != null ? atIndex : deck.slides.length
deck.slides.splice(idx, 0, slide)
break
}
case 'del_slide': {
const index = op.index as number
if (deck.slides.length <= 1) break
deck.slides.splice(index, 1)
break
}
case 'move_slide': {
const from = op.from as number
const to = op.to as number
if (from === to || from < 0 || to < 0 || to >= deck.slides.length) break
const item = deck.slides.splice(from, 1)[0]
deck.slides.splice(to, 0, item)
break
}
case 'replace_slide': {
const index = op.index as number
const slide = op.slide as Slide
if (deck.slides[index]) {
slide.id = deck.slides[index].id // 保持 id 不变
}
deck.slides[index] = slide
break
}
case 'set_slide_bg': {
const index = op.index as number
const bg = op.bg as BgKey
if (deck.slides[index]) {
deck.slides[index].background = bg
}
break
}
case 'add_element': {
const slideIdx = op.slideIdx as number
const element = op.element as SlideElement
const s = deck.slides[slideIdx]
if (s) s.elements.push(element)
break
}
case 'update_element': {
const slideIdx = op.slideIdx as number
const elementId = op.elementId as string
const patch = op.patch as Partial<SlideElement> & { style?: Record<string, unknown> }
const el = findEl(deck, slideIdx, elementId)
if (!el) break
if (patch.style) { Object.assign(el.style, patch.style) }
const rest = { ...patch }; delete (rest as Record<string, unknown>).style
Object.assign(el, rest)
break
}
case 'del_element': {
const slideIdx = op.slideIdx as number
const elementId = op.elementId as string
const s = deck.slides[slideIdx]
if (s) s.elements = s.elements.filter(e => e.id !== elementId)
break
}
case 'move_element_z': {
const slideIdx = op.slideIdx as number
const elementId = op.elementId as string
const dir = op.dir as number
const s = deck.slides[slideIdx]
if (!s) break
const i = s.elements.findIndex(e => e.id === elementId)
if (i < 0) break
const j = dir > 0 ? i + 1 : i - 1
if (j < 0 || j >= s.elements.length) break
const tmp = s.elements[i]; s.elements[i] = s.elements[j]; s.elements[j] = tmp
break
}
case 'set_theme': {
const theme = op.theme as string
deck.theme = theme
break
}
case 'replace_deck': {
const newDeck = op.deck as Deck
// 全量替换,但保留版本号
newDeck.v = deck.v
return newDeck
}
default:
// 未知 Op 类型,不修改
break
}
return deck
}
/* ============================================================
* 逆操作计算invertOp(deck, op) → Op
* 根据当前 deck 状态,计算能撤销 op 的逆 Op
* ============================================================ */
export function invertOp(inputDeck: Deck, op: Op): Op {
const deck = inputDeck // 只读,不 clone
switch (op.type) {
case 'add_slide': {
// 逆:删除加的那页
const slide = op.slide as Slide
const idx = deck.slides.findIndex(s => s.id === slide.id)
return { type: 'del_slide', index: idx >= 0 ? idx : (op.atIndex as number || deck.slides.length - 1) }
}
case 'del_slide': {
// 逆:把删掉的页插回原位
const index = op.index as number
const deletedSlide = op.deletedSlide as Slide | undefined
if (deletedSlide) {
return { type: 'add_slide', atIndex: index, slide: deletedSlide }
}
// 没有 deletedSlide 信息(兼容),返回 no-op
return { type: 'replace_deck', deck: clone(deck) }
}
case 'move_slide': {
// 逆from/to 反过来
return { type: 'move_slide', from: op.to as number, to: op.from as number }
}
case 'replace_slide': {
// 逆:换回原来的 slide
const index = op.index as number
const oldSlide = deck.slides[index] ? clone(deck.slides[index]) : undefined
if (oldSlide) {
return { type: 'replace_slide', index, slide: (op.oldSlide as Slide) || oldSlide }
}
return { type: 'replace_deck', deck: clone(deck) }
}
case 'set_slide_bg': {
// 逆:换回原来的背景
const index = op.index as number
const oldBg = deck.slides[index]?.background || 'bg'
return { type: 'set_slide_bg', index, bg: oldBg }
}
case 'add_element': {
// 逆:删除加的元素
return { type: 'del_element', slideIdx: op.slideIdx, elementId: (op.element as SlideElement).id }
}
case 'update_element': {
// 逆:把被改的字段恢复原值
const slideIdx = op.slideIdx as number
const elementId = op.elementId as string
const patch = op.patch as Record<string, unknown>
const el = findEl(deck, slideIdx, elementId)
if (!el) return { type: 'replace_deck', deck: clone(deck) }
// 构造反向 patch取当前值
const backwardPatch: Record<string, unknown> = {}
if (patch.style) {
const oldStyle: Record<string, unknown> = {}
for (const k of Object.keys(patch.style as object)) {
oldStyle[k] = (el.style as Record<string, unknown>)[k]
}
backwardPatch.style = oldStyle
}
for (const k of Object.keys(patch)) {
if (k === 'style') continue
backwardPatch[k] = (el as unknown as Record<string, unknown>)[k]
}
return { type: 'update_element', slideIdx, elementId, patch: backwardPatch }
}
case 'del_element': {
// 逆:把删掉的元素加回去
const slideIdx = op.slideIdx as number
const deletedElement = op.deletedElement as SlideElement | undefined
if (deletedElement) {
return { type: 'add_element', slideIdx, element: deletedElement }
}
return { type: 'replace_deck', deck: clone(deck) }
}
case 'move_element_z': {
// 逆:方向反过来
return { type: 'move_element_z', slideIdx: op.slideIdx, elementId: op.elementId, dir: -(op.dir as number) }
}
case 'set_theme': {
// 逆:换回原来的主题
return { type: 'set_theme', theme: deck.theme }
}
case 'replace_deck': {
// 逆:换回整个旧 deck
return { type: 'replace_deck', deck: clone(deck) }
}
default:
return { type: 'replace_deck', deck: clone(deck) }
}
}

View File

@@ -3,8 +3,9 @@
* 由 store.js 迁移:用 Vue reactive 替代手写 pub/sub组件自动追踪
* ===================================================================== */
import { reactive, computed } from 'vue'
import type { AiCfg, Deck, LibItem, PageTemplate, Slide, SlideElement, ThemeKey } from './types'
import type { AiCfg, Deck, LibItem, PageTemplate, Slide, SlideElement, ThemeKey, ChatMessage } from './types'
import { DECK_VERSION, SAMPLE_DECK, createElement, themes, uid } from './sample'
import { applyOp, invertOp, type Op, type HistoryEntry } from './op'
/* ---------- localStorage 键 ---------- */
const LS_KEY = 'u-ppt.deck.v1'
@@ -70,15 +71,17 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
}
]
/* ---------- 历史快照 ---------- */
interface Snapshot { deck: Deck; index: number; sel: string | null }
/* ---------- Op 历史栈undo/redo 基于 Op 回滚) ---------- */
const HIST_MAX = 40
let hist: Snapshot[] = []
let future: Snapshot[] = []
let hist: HistoryEntry[] = [] // 过去的操作
let future: HistoryEntry[] = [] // redo 栈
let suppressHistory = false
let touchKey: string | null = null
let touchTimer: ReturnType<typeof setTimeout> | null = null
/** 当前端的 client ID为协同预留 */
const CLIENT_ID = 'client-' + Math.random().toString(36).slice(2, 10)
/* ---------- 响应式状态(单一数据源) ---------- */
const state = reactive<{
deck: Deck
@@ -173,20 +176,56 @@ export function resolveBg(key: string): string {
return t[key] || '#ffffff'
}
/* ---------- 历史快照 ---------- */
function pushHistory() {
if (suppressHistory) return
hist.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
/* ---------- 内部:执行 Op 并推入历史 ---------- */
/** 执行一个 Op计算逆 Op推入历史栈。返回是否执行成功 */
function execOp(op: Op, opts?: { coalesceKey?: string }): void {
if (suppressHistory) {
state.deck = applyOp(state.deck, op)
scheduleSave()
return
}
// 连续同类操作合并(如拖字号滑块)
if (opts?.coalesceKey && touchKey === opts.coalesceKey && hist.length > 0) {
// 合并:不推新历史,只执行 forward已有的 backward 保持不变)
state.deck = applyOp(state.deck, op)
scheduleSave()
if (touchTimer) clearTimeout(touchTimer)
touchTimer = setTimeout(() => { touchKey = null }, 900)
return
}
// 计算逆 Op在执行前
const backward = invertOp(state.deck, op)
// 执行正向
state.deck = applyOp(state.deck, op)
// 推入历史
const entry: HistoryEntry = { forward: op, backward }
hist.push(entry)
if (hist.length > HIST_MAX) hist.shift()
future.length = 0
// coalesce 逻辑
if (opts?.coalesceKey) {
touchKey = opts.coalesceKey
if (touchTimer) clearTimeout(touchTimer)
touchTimer = setTimeout(() => { touchKey = null }, 900)
}
scheduleSave()
}
/** 连续「同一元素同一字段」的操作合并为一条历史(如拖字号滑块、连续敲字) */
function touchHistory(key: string) {
if (suppressHistory) return
if (touchKey !== key) { pushHistory(); touchKey = key }
if (touchTimer) clearTimeout(touchTimer)
touchTimer = setTimeout(() => { touchKey = null }, 900)
/* ---------- 数据版本迁移(旧版数据补字段升级,不丢弃) ---------- */
function migrateDeck(d: any): Deck {
// v2→v3: 给元素补默认 chartType / 新元素类型无需处理
if (!d.v || d.v < 3) {
// 确保每个元素有 style 对象
(d.slides || []).forEach((s: any) => {
(s.elements || []).forEach((e: any) => {
if (!e.style) e.style = {};
if (e.type === 'chart' && !e.style.chartType) e.style.chartType = 'bar';
});
});
}
d.v = DECK_VERSION;
return d as Deck;
}
/* ---------- 初始化 ---------- */
@@ -194,12 +233,20 @@ function init() {
let loaded: Deck | null = null
try { loaded = JSON.parse(localStorage.getItem(LS_KEY) || 'null') } catch (e) { /* ignore */ }
const curV = SAMPLE_DECK.v
const valid = loaded && loaded.slides && loaded.slides.length && loaded.v === curV
if (!valid && loaded && loaded.slides) {
try { localStorage.setItem(LS_KEY + '.bak', JSON.stringify(loaded)) } catch (e) {} // 旧版备份
let deck: Deck
if (loaded && loaded.slides && loaded.slides.length) {
if (loaded.v !== curV) {
// 版本不匹配 → 尝试迁移
try { localStorage.setItem(LS_KEY + '.bak', JSON.stringify(loaded)) } catch (e) {}
deck = migrateDeck(loaded)
} else {
deck = loaded
}
} else {
deck = clone(SAMPLE_DECK)
}
Object.assign(state, {
deck: valid ? loaded! : clone(SAMPLE_DECK),
deck,
currentIndex: 0,
selectedId: null,
activeLibId: null
@@ -245,101 +292,73 @@ function selectElement(id: string | null) { state.selectedId = id || null }
/* ---------- 主题 ---------- */
function setTheme(name: string) {
// 允许内置主题键与已存的自定义主题键
if (!themes[name] && !getCustomThemes()[name]) return
pushHistory()
state.deck.theme = name
scheduleSave()
execOp({ type: 'set_theme', theme: name, clientId: CLIENT_ID, timestamp: Date.now() })
}
/* ---------- 幻灯片 CRUD ---------- */
function addSlide(atIndex?: number) {
pushHistory()
const s: Slide = { id: uid('s'), background: 'bg', elements: [] }
const idx = atIndex != null ? atIndex + 1 : state.deck.slides.length
state.deck.slides.splice(idx, 0, s)
execOp({ type: 'add_slide', atIndex: idx, slide: s, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = idx
state.selectedId = null
scheduleSave()
return s
}
function dupSlide() {
pushHistory()
const cur = currentSlide.value; if (!cur) return
const copy = clone(cur); copy.id = uid('s')
copy.elements.forEach(e => { e.id = uid('el') })
state.deck.slides.splice(state.currentIndex + 1, 0, copy)
state.currentIndex++
const idx = state.currentIndex + 1
execOp({ type: 'add_slide', atIndex: idx, slide: copy, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = idx
state.selectedId = null
scheduleSave()
}
function delSlide(i?: number): boolean {
if (state.deck.slides.length <= 1) return false
pushHistory()
const idx = i != null ? i : state.currentIndex
state.deck.slides.splice(idx, 1)
const deletedSlide = clone(state.deck.slides[idx])
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
state.selectedId = null
scheduleSave()
return true
}
function moveSlide(from: number, to: number) {
if (from === to || from < 0 || to < 0) return
const arr = state.deck.slides
if (to >= arr.length) return
pushHistory()
const item = arr.splice(from, 1)[0]
arr.splice(to, 0, item)
if (to >= state.deck.slides.length) return
execOp({ type: 'move_slide', from, to, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = to
scheduleSave()
}
function setSlideBackground(bg: string) {
pushHistory()
currentSlide.value.background = bg as Slide['background']
scheduleSave()
execOp({ type: 'set_slide_bg', index: state.currentIndex, bg: bg as Slide['background'], clientId: CLIENT_ID, timestamp: Date.now() })
}
/* ---------- 元素 CRUD ---------- */
function addElement(type: SlideElement['type'], over?: Parameters<typeof createElement>[1]) {
pushHistory()
const el = createElement(type, over)
currentSlide.value.elements.push(el)
execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() })
state.selectedId = el.id
scheduleSave()
return el
}
function updateElement(id: string, patch: Partial<SlideElement> & { style?: Record<string, unknown> }) {
const key = id + ':' + (patch.style ? Object.keys(patch.style).sort().join(',') : Object.keys(patch).sort().join(','))
touchHistory(key)
const el = findElement(id); if (!el) return
if (patch.style) { Object.assign(el.style, patch.style) }
const rest = { ...patch }; delete (rest as Record<string, unknown>).style
Object.assign(el, rest)
scheduleSave()
const coalesceKey = id + ':' + (patch.style ? Object.keys(patch.style).sort().join(',') : Object.keys(patch).sort().join(','))
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: id, patch, clientId: CLIENT_ID, timestamp: Date.now() }, { coalesceKey })
}
function delElement(id: string) {
pushHistory()
const s = currentSlide.value
s.elements = s.elements.filter(e => e.id !== id)
const el = findElement(id)
const deletedElement = el ? clone(el) : undefined
execOp({ type: 'del_element', slideIdx: state.currentIndex, elementId: id, deletedElement, clientId: CLIENT_ID, timestamp: Date.now() })
if (state.selectedId === id) state.selectedId = null
scheduleSave()
}
function moveElementZ(id: string, dir: number) {
pushHistory()
const s = currentSlide.value
const i = s.elements.findIndex(e => e.id === id)
if (i < 0) return
const j = dir > 0 ? i + 1 : i - 1
if (j < 0 || j >= s.elements.length) return
const tmp = s.elements[i]; s.elements[i] = s.elements[j]; s.elements[j] = tmp
scheduleSave()
execOp({ type: 'move_element_z', slideIdx: state.currentIndex, elementId: id, dir, clientId: CLIENT_ID, timestamp: Date.now() })
}
function findElement(id: string): SlideElement | null {
@@ -347,87 +366,83 @@ function findElement(id: string): SlideElement | null {
return s.elements.find(e => e.id === id) || null
}
/* ---------- undo / redo ---------- */
/* ---------- undo / redo(基于 Op 回滚) ---------- */
function undo(): boolean {
if (!hist.length) return false
future.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
const prev = hist.pop()!
const entry = hist.pop()!
suppressHistory = true
state.deck = prev.deck
state.currentIndex = Math.min(prev.index, state.deck.slides.length - 1)
state.selectedId = prev.sel
state.deck = applyOp(state.deck, entry.backward)
suppressHistory = false
future.push(entry)
scheduleSave()
return true
}
function redo(): boolean {
if (!future.length) return false
hist.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
const nxt = future.pop()!
const entry = future.pop()!
suppressHistory = true
state.deck = nxt.deck
state.currentIndex = Math.min(nxt.index, state.deck.slides.length - 1)
state.selectedId = nxt.sel
state.deck = applyOp(state.deck, entry.forward)
suppressHistory = false
hist.push(entry)
scheduleSave()
return true
}
/* ---------- 整体替换AI 生成 / 导入 / 重置用) ---------- */
function replaceDeck(newDeck: Deck | Record<string, unknown>, opts?: { keepHistory?: boolean; newChat?: boolean }) {
if (!opts?.keepHistory) pushHistory()
suppressHistory = true
// 自动备份newChat=true 时说明是生成整套/重置等“覆盖性操作”,把当前 deck 存入 .bak
if (opts?.newChat && state.deck?.slides?.length) {
try { localStorage.setItem(LS_KEY + '.bak', JSON.stringify(state.deck)) } catch (e) {}
}
let d = newDeck as Deck
if (!d || typeof d !== 'object') d = {} as Deck
d.v = DECK_VERSION // 维护版本号
// 健壮性:损坏/空数据兜底为一页空白,避免下游 forEach 崩溃
d.v = DECK_VERSION
if (!Array.isArray(d.slides) || !d.slides.length) {
d.slides = [{ id: uid('s'), background: 'bg', elements: [] }]
}
// 会话 ID 绑定newChat=true 开新会话;否则沿用 deck 自带的 chatId文库加载时都没有则生成新的
if (opts?.newChat || !d.chatId) {
d.chatId = newChatId()
}
state.deck = d
if (opts?.keepHistory) {
suppressHistory = true
state.deck = d
suppressHistory = false
scheduleSave()
} else {
execOp({ type: 'replace_deck', deck: d, clientId: CLIENT_ID, timestamp: Date.now() })
}
state.activeLibId = null
state.currentIndex = Math.min(state.currentIndex, d.slides.length - 1)
if (state.currentIndex < 0) state.currentIndex = 0
state.selectedId = null
suppressHistory = false
scheduleSave()
}
function replaceSlide(index: number, newSlide: Slide) {
pushHistory()
newSlide.id = state.deck.slides[index] ? state.deck.slides[index].id : uid('s')
newSlide.elements = newSlide.elements || []
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
state.deck.slides[index] = newSlide
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() })
state.selectedId = null
scheduleSave()
}
function appendSlide(newSlide: Slide) {
pushHistory()
newSlide.id = uid('s')
newSlide.elements = newSlide.elements || []
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
state.deck.slides.push(newSlide)
state.currentIndex = state.deck.slides.length - 1
const idx = state.deck.slides.length
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = idx
state.selectedId = null
scheduleSave()
}
function insertSlideAt(index: number, newSlide: Slide) {
pushHistory()
newSlide.id = uid('s')
newSlide.elements = newSlide.elements || []
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
state.deck.slides.splice(index, 0, newSlide)
execOp({ type: 'add_slide', atIndex: index, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = index
state.selectedId = null
scheduleSave()
}
function reset() {
@@ -489,14 +504,12 @@ function saveCustomTheme(t: import('./types').Theme): string {
/** 应用一个主题(内置 key 或自定义对象)到当前 deck */
function applyTheme(nameOrObj: string | import('./types').Theme) {
pushHistory()
if (typeof nameOrObj === 'string') {
state.deck.theme = nameOrObj
execOp({ type: 'set_theme', theme: nameOrObj, clientId: CLIENT_ID, timestamp: Date.now() })
} else {
const key = saveCustomTheme(nameOrObj)
state.deck.theme = key
execOp({ type: 'set_theme', theme: key, clientId: CLIENT_ID, timestamp: Date.now() })
}
scheduleSave()
}
/* ---------- 页面模板(版式复用) ---------- */
@@ -541,20 +554,19 @@ function saveCurrentAsTemplate(name: string): PageTemplate {
function addSlideFromTemplate(tplId: string): boolean {
const tpl = getTemplates().find(t => t.id === tplId)
if (!tpl) return false
pushHistory()
const newSlide: Slide = {
id: uid('s'),
background: tpl.background,
elements: clone(tpl.elements).map(e => ({ ...e, id: uid('el') }))
}
const idx = state.currentIndex + 1
state.deck.slides.splice(idx, 0, newSlide)
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = idx
state.selectedId = null
scheduleSave()
return true
}
/* ---------- 删除用户模板 ---------- */
/** 删除用户模板 */
function deleteTemplate(tplId: string) {
const user = getUserTemplates().filter(t => t.id !== tplId)
@@ -633,7 +645,6 @@ function newBlankDeck() {
}
/* ---------- 会话管理(按 chatId 隔离) ---------- */
import type { ChatMessage } from './types'
function newChatId(): string {
return 'chat-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)
@@ -663,6 +674,19 @@ function clearChat(chatId: string) {
try { localStorage.removeItem(CHAT_PREFIX + chatId) } catch (e) {}
}
/** 恢复上一次被覆盖的 deck生成整套/重置前的自动备份) */
function recoverBackup(): boolean {
try {
const bak = localStorage.getItem(LS_KEY + '.bak')
if (!bak) return false
const d = JSON.parse(bak)
if (!d || !d.slides || !d.slides.length) return false
replaceDeck(d)
localStorage.removeItem(LS_KEY + '.bak')
return true
} catch (e) { return false }
}
/* ---------- 导出 ---------- */
export const store = {
// 状态(响应式)
@@ -699,4 +723,6 @@ export const store = {
getTemplates, getUserTemplates, saveCurrentAsTemplate, addSlideFromTemplate, deleteTemplate,
// 会话管理
getChatId, getChat, setChat, clearChat, newChatId,
// 备份恢复
recoverBackup,
}

View File

@@ -85,8 +85,6 @@ export interface ElementStyle {
// card
icon?: string
accent?: ColorKey
/** 文字元素是否启用 Markdown 行内格式(加粗/斜体/高亮等) */
rich?: boolean
}
/** 幻灯片元素 */

View File

@@ -275,11 +275,6 @@
white-space: pre-wrap;
flex: 1;
}
/* 兼容旧 class 名 */
.el-card-title { font-weight: 700; font-size: 1.5em; line-height: 1.15; display: flex; align-items: center; gap: .45em; }
.el-card-text { font-size: 1em; line-height: 1.5; opacity: .88; white-space: pre-wrap; }
.el-card-body { padding: 1.1em 1.3em 1.2em; flex: 1; display: flex; flex-direction: column; gap: .5em; justify-content: flex-start; }
/* 渐变形状由 renderElement 内联 backgroundImage此处仅兜底圆角 */
.el-shape.gradient { border-radius: var(--shape-radius, 0); }
@@ -314,7 +309,7 @@
.canvas-placeholder .ph-sub { font-size: 14px; opacity: .8; }
/* 长内容溢出兆底 */
.el-text, .el-card-title, .el-card-text { overflow: hidden; }
.el-text { overflow: hidden; }
.el-stat .num { overflow-wrap: anywhere; }
.el-chart .bar-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

View File

@@ -121,3 +121,18 @@
.present-stage .slide-inner.animated .el[data-type="list"] .li { opacity: 1 !important; animation: none !important; }
.slide-inner.enter-next, .slide-inner.enter-prev { animation: none !important; }
}
/* ===== 打印 / 导出 PDF ===== */
@media print {
/* 隐藏编辑器、工具栏、HUD、toast */
.app-editor, .toolbar, .canvas-status, .present-hud, .toast, .panel-tabs, .side-panel, .thumb-bar { display: none !important; }
body.printing .app-present { display: block !important; }
body.printing .present-stage { position: static; }
body.printing .present-hud { display: none !important; }
body.printing .slide-layer { transform: none !important; }
body.printing .slide-inner { page-break-after: always; }
/* 编辑模式打印:隐藏画布手柄等编辑装饰 */
.handle, .canvas-placeholder { display: none !important; }
/* 如果不在演示模式,打印当前页 */
body.printing:not(:has(.app-present:not(.hidden))) .canvas-stage { display: block; }
}

View File

@@ -3,5 +3,8 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: { host: '127.0.0.1', port: 8080, open: true }
// Tauri 期望固定端口 5173Web 单独跑用 8080
server: process.env.TAURI_ENV_PLATFORM
? { host: '127.0.0.1', port: 5173, strictPort: true }
: { host: '127.0.0.1', port: 8080, open: true },
})