Compare commits
10 Commits
e45df2f269
...
9e0a808635
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e0a808635 | |||
| a1ac91c1ec | |||
| fc605b1a94 | |||
| 664ca9c65c | |||
| 1430e9e061 | |||
| b9a388b570 | |||
| 056dc58318 | |||
| 9ec6173b10 | |||
| 2f9c545503 | |||
| 9933a1711a |
10
.gitignore
vendored
@@ -36,3 +36,13 @@ npm-debug.log*
|
|||||||
# TypeScript 构建缓存
|
# TypeScript 构建缓存
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
tsconfig.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
@@ -8,9 +8,11 @@
|
|||||||
"name": "u-ppt",
|
"name": "u-ppt",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"typescript": "~5.6.3",
|
"typescript": "~5.6.3",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.0.5",
|
||||||
@@ -861,6 +863,233 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
|
||||||
|
|||||||
@@ -7,12 +7,17 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"tauri:dev": "tauri dev",
|
||||||
|
"tauri:build": "tauri build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"typescript": "~5.6.3",
|
"typescript": "~5.6.3",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.0.5",
|
||||||
|
|||||||
4893
src-tauri/Cargo.lock
generated
Normal file
26
src-tauri/Cargo.toml
Normal 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
@@ -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 |
33
src-tauri/bindings/types.ts
Normal 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
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 514 B |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 838 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 530 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 693 B |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 743 B |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
310
src-tauri/src/ai.rs
Normal 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=浅色渐变。
|
||||||
|
|
||||||
|
元素类型 type:title 标题 | text 正文 | list 列表 | stat 数据 | quote 金句 | image 图片 | shape 形状 | chart 图表 | card 卡片 | table 表格 | code 代码 | formula 公式
|
||||||
|
元素:{ \"type\":..., \"x\":数字,\"y\":数字,\"w\":数字,\"h\":数字 (0-100), \"content\":字符串, \"style\":{...} }
|
||||||
|
- title/text/list/quote:content 为文字,list 用 \\n 分多行
|
||||||
|
- stat:content 为大数字(如 \"65%\"),style.label 为说明
|
||||||
|
- card:content 第一行=标题、其余行=正文;style.accent=顶部色条键,style.icon=emoji 图标
|
||||||
|
- shape:style.shapeType=rect|circle|triangle,style.fill=颜色键,style.gradient=true 渐变,style.opacity=0~1
|
||||||
|
- chart:content 为 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)完成率。
|
||||||
|
- table:content 为 Markdown 管道表格字符串,style.header=true 首行加粗
|
||||||
|
- code:content 为代码文本,style.lang=语言如 js/python
|
||||||
|
- formula:content 为 LaTeX 公式字符串,如 \"E = mc^2\"
|
||||||
|
- image:content 留空
|
||||||
|
|
||||||
|
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/App.vue
@@ -71,6 +71,49 @@ function onSave() {
|
|||||||
}
|
}
|
||||||
function openAi() { switchTab('ai') }
|
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) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (document.body.dataset.mode === 'present') return
|
if (document.body.dataset.mode === 'present') return
|
||||||
@@ -119,6 +162,9 @@ onUnmounted(() => {
|
|||||||
@open-ai="openAi"
|
@open-ai="openAi"
|
||||||
@save="onSave"
|
@save="onSave"
|
||||||
@open-templates="templateVisible = true"
|
@open-templates="templateVisible = true"
|
||||||
|
@export-json="onExportJson"
|
||||||
|
@import-json="onImportJson"
|
||||||
|
@export-pdf="onExportPdf"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="editor-body">
|
<div class="editor-body">
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const CHART_TYPES: Array<{ k: ChartType; label: string; icon: string }> = [
|
|||||||
{ k: 'radar', label: '雷达图', icon: '🕸' },
|
{ k: 'radar', label: '雷达图', icon: '🕸' },
|
||||||
{ k: 'progress', 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 selected = computed(() => store.getSelected())
|
||||||
const slide = computed(() => store.currentSlide.value)
|
const slide = computed(() => store.currentSlide.value)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ const emit = defineEmits<{
|
|||||||
(e: 'open-ai'): void
|
(e: 'open-ai'): void
|
||||||
(e: 'save'): void
|
(e: 'save'): void
|
||||||
(e: 'open-templates'): void
|
(e: 'open-templates'): void
|
||||||
|
(e: 'export-json'): void
|
||||||
|
(e: 'import-json'): void
|
||||||
|
(e: 'export-pdf'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const themeKeys = Object.keys(themes)
|
const themeKeys = Object.keys(themes)
|
||||||
@@ -70,6 +73,9 @@ defineProps<{ disabledActions?: string[] }>()
|
|||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
<button class="btn" data-action="library" title="我的演示文库" @click="action('library')">📁 文库</button>
|
<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="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="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" 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>
|
<button class="btn primary" data-action="present" title="开始演示 (F5)" :disabled="disabledActions?.includes('present')" @click="action('present')">▶ 演示</button>
|
||||||
|
|||||||
73
src/composables/useStore.ts
Normal 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
@@ -0,0 +1,173 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
* backend.ts — 持久化后端抽象层
|
||||||
|
*
|
||||||
|
* store 只管内存状态 + Op Log,持久化通过 Backend 接口抽象。
|
||||||
|
* 三个实现:
|
||||||
|
* - LocalBackend (localStorage,Web 版当前模式)
|
||||||
|
* - TauriBackend (Rust 文件系统,桌面版,B4 实现)
|
||||||
|
* - CloudBackend (HTTP API,云端版,C0 实现)
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* - 接口只管读写数据,不管状态逻辑(状态逻辑在 store)
|
||||||
|
* - 所有方法返回 Promise(TauriBackend/CloudBackend 天然异步)
|
||||||
|
* - LocalBackend 内部用 localStorage 但包装为 Promise(统一接口)
|
||||||
|
* ===================================================================== */
|
||||||
|
import type { Deck, LibItem, PageTemplate, Theme, ChatMessage, AiCfg } from './types'
|
||||||
|
|
||||||
|
export interface Backend {
|
||||||
|
// ---- 当前 deck ----
|
||||||
|
loadDeck(): Promise<Deck | null>
|
||||||
|
saveDeck(deck: Deck): Promise<void>
|
||||||
|
loadBackup(): Promise<Deck | null>
|
||||||
|
saveBackup(deck: Deck): Promise<void>
|
||||||
|
clearBackup(): Promise<void>
|
||||||
|
|
||||||
|
// ---- 文库 ----
|
||||||
|
loadLibrary(): Promise<LibItem[]>
|
||||||
|
saveLibrary(lib: LibItem[]): Promise<void>
|
||||||
|
getActiveLibId(): Promise<string | null>
|
||||||
|
setActiveLibId(id: string | null): Promise<void>
|
||||||
|
|
||||||
|
// ---- 会话(按 chatId 隔离) ----
|
||||||
|
loadChat(chatId: string): Promise<ChatMessage[]>
|
||||||
|
saveChat(chatId: string, msgs: ChatMessage[]): Promise<void>
|
||||||
|
clearChat(chatId: string): Promise<void>
|
||||||
|
|
||||||
|
// ---- 用户模板 ----
|
||||||
|
loadTemplates(): Promise<PageTemplate[]>
|
||||||
|
saveTemplates(tpls: PageTemplate[]): Promise<void>
|
||||||
|
|
||||||
|
// ---- 自定义主题 ----
|
||||||
|
loadThemes(): Promise<Record<string, Theme>>
|
||||||
|
saveThemes(themes: Record<string, Theme>): Promise<void>
|
||||||
|
|
||||||
|
// ---- AI 配置 ----
|
||||||
|
loadCfg(): Promise<Partial<AiCfg> | null>
|
||||||
|
saveCfg(cfg: AiCfg): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* LocalBackend — localStorage 实现(Web 版当前模式)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
const KEYS = {
|
||||||
|
deck: 'u-ppt.deck.v1',
|
||||||
|
deckBak: 'u-ppt.deck.v1.bak',
|
||||||
|
lib: 'u-ppt.library.v1',
|
||||||
|
activeLib: 'u-ppt.activeLib.v1',
|
||||||
|
chatPrefix: 'u-ppt.chat.',
|
||||||
|
templates: 'u-ppt.templates.v1',
|
||||||
|
themes: 'u-ppt.themes.v1',
|
||||||
|
cfg: 'u-ppt.ai-cfg.v1',
|
||||||
|
}
|
||||||
|
|
||||||
|
function lsGet(key: string): string | null {
|
||||||
|
try { return localStorage.getItem(key) } catch (e) { return null }
|
||||||
|
}
|
||||||
|
function lsSet(key: string, val: string): void {
|
||||||
|
try { localStorage.setItem(key, val) } catch (e) { /* 配额满等忽略 */ }
|
||||||
|
}
|
||||||
|
function lsDel(key: string): void {
|
||||||
|
try { localStorage.removeItem(key) } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LocalBackend implements Backend {
|
||||||
|
async loadDeck(): Promise<Deck | null> {
|
||||||
|
const raw = lsGet(KEYS.deck)
|
||||||
|
if (!raw) return null
|
||||||
|
try { return JSON.parse(raw) } catch (e) { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveDeck(deck: Deck): Promise<void> {
|
||||||
|
lsSet(KEYS.deck, JSON.stringify(deck))
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadBackup(): Promise<Deck | null> {
|
||||||
|
const raw = lsGet(KEYS.deckBak)
|
||||||
|
if (!raw) return null
|
||||||
|
try { return JSON.parse(raw) } catch (e) { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveBackup(deck: Deck): Promise<void> {
|
||||||
|
lsSet(KEYS.deckBak, JSON.stringify(deck))
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearBackup(): Promise<void> {
|
||||||
|
lsDel(KEYS.deckBak)
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadLibrary(): Promise<LibItem[]> {
|
||||||
|
const raw = lsGet(KEYS.lib)
|
||||||
|
if (!raw) return []
|
||||||
|
try { return JSON.parse(raw) || [] } catch (e) { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveLibrary(lib: LibItem[]): Promise<void> {
|
||||||
|
lsSet(KEYS.lib, JSON.stringify(lib))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveLibId(): Promise<string | null> {
|
||||||
|
return lsGet(KEYS.activeLib)
|
||||||
|
}
|
||||||
|
|
||||||
|
async setActiveLibId(id: string | null): Promise<void> {
|
||||||
|
if (id) lsSet(KEYS.activeLib, id)
|
||||||
|
else lsDel(KEYS.activeLib)
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadChat(chatId: string): Promise<ChatMessage[]> {
|
||||||
|
const raw = lsGet(KEYS.chatPrefix + chatId)
|
||||||
|
if (!raw) return []
|
||||||
|
try { return JSON.parse(raw) || [] } catch (e) { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveChat(chatId: string, msgs: ChatMessage[]): Promise<void> {
|
||||||
|
lsSet(KEYS.chatPrefix + chatId, JSON.stringify(msgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearChat(chatId: string): Promise<void> {
|
||||||
|
lsDel(KEYS.chatPrefix + chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadTemplates(): Promise<PageTemplate[]> {
|
||||||
|
const raw = lsGet(KEYS.templates)
|
||||||
|
if (!raw) return []
|
||||||
|
try { return JSON.parse(raw) || [] } catch (e) { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveTemplates(tpls: PageTemplate[]): Promise<void> {
|
||||||
|
// 只存用户模板(category === 'user')
|
||||||
|
lsSet(KEYS.templates, JSON.stringify(tpls.filter(t => t.category === 'user')))
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadThemes(): Promise<Record<string, Theme>> {
|
||||||
|
const raw = lsGet(KEYS.themes)
|
||||||
|
if (!raw) return {}
|
||||||
|
try { return JSON.parse(raw) || {} } catch (e) { return {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveThemes(themes: Record<string, Theme>): Promise<void> {
|
||||||
|
lsSet(KEYS.themes, JSON.stringify(themes))
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCfg(): Promise<Partial<AiCfg> | null> {
|
||||||
|
const raw = lsGet(KEYS.cfg)
|
||||||
|
if (!raw) return null
|
||||||
|
try { return JSON.parse(raw) } catch (e) { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveCfg(cfg: AiCfg): Promise<void> {
|
||||||
|
lsSet(KEYS.cfg, JSON.stringify(cfg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 当前活动的 Backend 实例 ---------- */
|
||||||
|
let activeBackend: Backend = new LocalBackend()
|
||||||
|
|
||||||
|
export function getBackend(): Backend {
|
||||||
|
return activeBackend
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setBackend(b: Backend): void {
|
||||||
|
activeBackend = b
|
||||||
|
}
|
||||||
44
src/core/bridge.ts
Normal 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 () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/* =====================================================================
|
|
||||||
* inline.ts — Markdown 行内格式渲染
|
|
||||||
* 把 **加粗** / *斜体* / ~~删除线~~ / ==高亮== / `代码` / ~下标~ / ^上标^ 转 HTML
|
|
||||||
* 先转义 HTML,再按顺序替换(避免互相破坏)
|
|
||||||
* ===================================================================== */
|
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把 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
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 纯函数 reducer:applyOp(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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
* 由 store.js 迁移:用 Vue reactive 替代手写 pub/sub,组件自动追踪
|
* 由 store.js 迁移:用 Vue reactive 替代手写 pub/sub,组件自动追踪
|
||||||
* ===================================================================== */
|
* ===================================================================== */
|
||||||
import { reactive, computed } from 'vue'
|
import { reactive, computed } from 'vue'
|
||||||
import type { AiCfg, Deck, LibItem, PageTemplate, Slide, SlideElement, ThemeKey } 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 { DECK_VERSION, SAMPLE_DECK, createElement, themes, uid } from './sample'
|
||||||
|
import { applyOp, invertOp, type Op, type HistoryEntry } from './op'
|
||||||
|
|
||||||
/* ---------- localStorage 键 ---------- */
|
/* ---------- localStorage 键 ---------- */
|
||||||
const LS_KEY = 'u-ppt.deck.v1'
|
const LS_KEY = 'u-ppt.deck.v1'
|
||||||
@@ -70,15 +71,17 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
/* ---------- 历史快照 ---------- */
|
/* ---------- Op 历史栈(undo/redo 基于 Op 回滚) ---------- */
|
||||||
interface Snapshot { deck: Deck; index: number; sel: string | null }
|
|
||||||
const HIST_MAX = 40
|
const HIST_MAX = 40
|
||||||
let hist: Snapshot[] = []
|
let hist: HistoryEntry[] = [] // 过去的操作
|
||||||
let future: Snapshot[] = []
|
let future: HistoryEntry[] = [] // redo 栈
|
||||||
let suppressHistory = false
|
let suppressHistory = false
|
||||||
let touchKey: string | null = null
|
let touchKey: string | null = null
|
||||||
let touchTimer: ReturnType<typeof setTimeout> | 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<{
|
const state = reactive<{
|
||||||
deck: Deck
|
deck: Deck
|
||||||
@@ -173,20 +176,56 @@ export function resolveBg(key: string): string {
|
|||||||
return t[key] || '#ffffff'
|
return t[key] || '#ffffff'
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 历史快照 ---------- */
|
/* ---------- 内部:执行 Op 并推入历史 ---------- */
|
||||||
function pushHistory() {
|
|
||||||
if (suppressHistory) return
|
/** 执行一个 Op,计算逆 Op,推入历史栈。返回是否执行成功 */
|
||||||
hist.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
|
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()
|
if (hist.length > HIST_MAX) hist.shift()
|
||||||
future.length = 0
|
future.length = 0
|
||||||
|
// coalesce 逻辑
|
||||||
|
if (opts?.coalesceKey) {
|
||||||
|
touchKey = opts.coalesceKey
|
||||||
|
if (touchTimer) clearTimeout(touchTimer)
|
||||||
|
touchTimer = setTimeout(() => { touchKey = null }, 900)
|
||||||
|
}
|
||||||
|
scheduleSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 连续「同一元素同一字段」的操作合并为一条历史(如拖字号滑块、连续敲字) */
|
/* ---------- 数据版本迁移(旧版数据补字段升级,不丢弃) ---------- */
|
||||||
function touchHistory(key: string) {
|
function migrateDeck(d: any): Deck {
|
||||||
if (suppressHistory) return
|
// v2→v3: 给元素补默认 chartType / 新元素类型无需处理
|
||||||
if (touchKey !== key) { pushHistory(); touchKey = key }
|
if (!d.v || d.v < 3) {
|
||||||
if (touchTimer) clearTimeout(touchTimer)
|
// 确保每个元素有 style 对象
|
||||||
touchTimer = setTimeout(() => { touchKey = null }, 900)
|
(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
|
let loaded: Deck | null = null
|
||||||
try { loaded = JSON.parse(localStorage.getItem(LS_KEY) || 'null') } catch (e) { /* ignore */ }
|
try { loaded = JSON.parse(localStorage.getItem(LS_KEY) || 'null') } catch (e) { /* ignore */ }
|
||||||
const curV = SAMPLE_DECK.v
|
const curV = SAMPLE_DECK.v
|
||||||
const valid = loaded && loaded.slides && loaded.slides.length && loaded.v === curV
|
let deck: Deck
|
||||||
if (!valid && loaded && loaded.slides) {
|
if (loaded && loaded.slides && loaded.slides.length) {
|
||||||
try { localStorage.setItem(LS_KEY + '.bak', JSON.stringify(loaded)) } catch (e) {} // 旧版备份
|
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, {
|
Object.assign(state, {
|
||||||
deck: valid ? loaded! : clone(SAMPLE_DECK),
|
deck,
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
activeLibId: null
|
activeLibId: null
|
||||||
@@ -245,101 +292,73 @@ function selectElement(id: string | null) { state.selectedId = id || null }
|
|||||||
|
|
||||||
/* ---------- 主题 ---------- */
|
/* ---------- 主题 ---------- */
|
||||||
function setTheme(name: string) {
|
function setTheme(name: string) {
|
||||||
// 允许内置主题键与已存的自定义主题键
|
|
||||||
if (!themes[name] && !getCustomThemes()[name]) return
|
if (!themes[name] && !getCustomThemes()[name]) return
|
||||||
pushHistory()
|
execOp({ type: 'set_theme', theme: name, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.deck.theme = name
|
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 幻灯片 CRUD ---------- */
|
/* ---------- 幻灯片 CRUD ---------- */
|
||||||
function addSlide(atIndex?: number) {
|
function addSlide(atIndex?: number) {
|
||||||
pushHistory()
|
|
||||||
const s: Slide = { id: uid('s'), background: 'bg', elements: [] }
|
const s: Slide = { id: uid('s'), background: 'bg', elements: [] }
|
||||||
const idx = atIndex != null ? atIndex + 1 : state.deck.slides.length
|
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.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null
|
||||||
scheduleSave()
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
function dupSlide() {
|
function dupSlide() {
|
||||||
pushHistory()
|
|
||||||
const cur = currentSlide.value; if (!cur) return
|
const cur = currentSlide.value; if (!cur) return
|
||||||
const copy = clone(cur); copy.id = uid('s')
|
const copy = clone(cur); copy.id = uid('s')
|
||||||
copy.elements.forEach(e => { e.id = uid('el') })
|
copy.elements.forEach(e => { e.id = uid('el') })
|
||||||
state.deck.slides.splice(state.currentIndex + 1, 0, copy)
|
const idx = state.currentIndex + 1
|
||||||
state.currentIndex++
|
execOp({ type: 'add_slide', atIndex: idx, slide: copy, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
|
state.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function delSlide(i?: number): boolean {
|
function delSlide(i?: number): boolean {
|
||||||
if (state.deck.slides.length <= 1) return false
|
if (state.deck.slides.length <= 1) return false
|
||||||
pushHistory()
|
|
||||||
const idx = i != null ? i : state.currentIndex
|
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
|
if (state.currentIndex >= state.deck.slides.length) state.currentIndex = state.deck.slides.length - 1
|
||||||
state.selectedId = null
|
state.selectedId = null
|
||||||
scheduleSave()
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveSlide(from: number, to: number) {
|
function moveSlide(from: number, to: number) {
|
||||||
if (from === to || from < 0 || to < 0) return
|
if (from === to || from < 0 || to < 0) return
|
||||||
const arr = state.deck.slides
|
if (to >= state.deck.slides.length) return
|
||||||
if (to >= arr.length) return
|
execOp({ type: 'move_slide', from, to, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
pushHistory()
|
|
||||||
const item = arr.splice(from, 1)[0]
|
|
||||||
arr.splice(to, 0, item)
|
|
||||||
state.currentIndex = to
|
state.currentIndex = to
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSlideBackground(bg: string) {
|
function setSlideBackground(bg: string) {
|
||||||
pushHistory()
|
execOp({ type: 'set_slide_bg', index: state.currentIndex, bg: bg as Slide['background'], clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
currentSlide.value.background = bg as Slide['background']
|
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 元素 CRUD ---------- */
|
/* ---------- 元素 CRUD ---------- */
|
||||||
function addElement(type: SlideElement['type'], over?: Parameters<typeof createElement>[1]) {
|
function addElement(type: SlideElement['type'], over?: Parameters<typeof createElement>[1]) {
|
||||||
pushHistory()
|
|
||||||
const el = createElement(type, over)
|
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
|
state.selectedId = el.id
|
||||||
scheduleSave()
|
|
||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateElement(id: string, patch: Partial<SlideElement> & { style?: Record<string, unknown> }) {
|
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(','))
|
const coalesceKey = id + ':' + (patch.style ? Object.keys(patch.style).sort().join(',') : Object.keys(patch).sort().join(','))
|
||||||
touchHistory(key)
|
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: id, patch, clientId: CLIENT_ID, timestamp: Date.now() }, { coalesceKey })
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function delElement(id: string) {
|
function delElement(id: string) {
|
||||||
pushHistory()
|
const el = findElement(id)
|
||||||
const s = currentSlide.value
|
const deletedElement = el ? clone(el) : undefined
|
||||||
s.elements = s.elements.filter(e => e.id !== id)
|
execOp({ type: 'del_element', slideIdx: state.currentIndex, elementId: id, deletedElement, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
if (state.selectedId === id) state.selectedId = null
|
if (state.selectedId === id) state.selectedId = null
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveElementZ(id: string, dir: number) {
|
function moveElementZ(id: string, dir: number) {
|
||||||
pushHistory()
|
execOp({ type: 'move_element_z', slideIdx: state.currentIndex, elementId: id, dir, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findElement(id: string): SlideElement | null {
|
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
|
return s.elements.find(e => e.id === id) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- undo / redo ---------- */
|
/* ---------- undo / redo(基于 Op 回滚) ---------- */
|
||||||
function undo(): boolean {
|
function undo(): boolean {
|
||||||
if (!hist.length) return false
|
if (!hist.length) return false
|
||||||
future.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
|
const entry = hist.pop()!
|
||||||
const prev = hist.pop()!
|
|
||||||
suppressHistory = true
|
suppressHistory = true
|
||||||
state.deck = prev.deck
|
state.deck = applyOp(state.deck, entry.backward)
|
||||||
state.currentIndex = Math.min(prev.index, state.deck.slides.length - 1)
|
|
||||||
state.selectedId = prev.sel
|
|
||||||
suppressHistory = false
|
suppressHistory = false
|
||||||
|
future.push(entry)
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function redo(): boolean {
|
function redo(): boolean {
|
||||||
if (!future.length) return false
|
if (!future.length) return false
|
||||||
hist.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
|
const entry = future.pop()!
|
||||||
const nxt = future.pop()!
|
|
||||||
suppressHistory = true
|
suppressHistory = true
|
||||||
state.deck = nxt.deck
|
state.deck = applyOp(state.deck, entry.forward)
|
||||||
state.currentIndex = Math.min(nxt.index, state.deck.slides.length - 1)
|
|
||||||
state.selectedId = nxt.sel
|
|
||||||
suppressHistory = false
|
suppressHistory = false
|
||||||
|
hist.push(entry)
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 整体替换(AI 生成 / 导入 / 重置用) ---------- */
|
/* ---------- 整体替换(AI 生成 / 导入 / 重置用) ---------- */
|
||||||
function replaceDeck(newDeck: Deck | Record<string, unknown>, opts?: { keepHistory?: boolean; newChat?: boolean }) {
|
function replaceDeck(newDeck: Deck | Record<string, unknown>, opts?: { keepHistory?: boolean; newChat?: boolean }) {
|
||||||
if (!opts?.keepHistory) pushHistory()
|
// 自动备份:newChat=true 时说明是生成整套/重置等“覆盖性操作”,把当前 deck 存入 .bak
|
||||||
suppressHistory = true
|
if (opts?.newChat && state.deck?.slides?.length) {
|
||||||
|
try { localStorage.setItem(LS_KEY + '.bak', JSON.stringify(state.deck)) } catch (e) {}
|
||||||
|
}
|
||||||
let d = newDeck as Deck
|
let d = newDeck as Deck
|
||||||
if (!d || typeof d !== 'object') d = {} as Deck
|
if (!d || typeof d !== 'object') d = {} as Deck
|
||||||
d.v = DECK_VERSION // 维护版本号
|
d.v = DECK_VERSION
|
||||||
// 健壮性:损坏/空数据兜底为一页空白,避免下游 forEach 崩溃
|
|
||||||
if (!Array.isArray(d.slides) || !d.slides.length) {
|
if (!Array.isArray(d.slides) || !d.slides.length) {
|
||||||
d.slides = [{ id: uid('s'), background: 'bg', elements: [] }]
|
d.slides = [{ id: uid('s'), background: 'bg', elements: [] }]
|
||||||
}
|
}
|
||||||
// 会话 ID 绑定:newChat=true 开新会话;否则沿用 deck 自带的 chatId(文库加载时);都没有则生成新的
|
|
||||||
if (opts?.newChat || !d.chatId) {
|
if (opts?.newChat || !d.chatId) {
|
||||||
d.chatId = newChatId()
|
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.activeLibId = null
|
||||||
state.currentIndex = Math.min(state.currentIndex, d.slides.length - 1)
|
state.currentIndex = Math.min(state.currentIndex, d.slides.length - 1)
|
||||||
if (state.currentIndex < 0) state.currentIndex = 0
|
if (state.currentIndex < 0) state.currentIndex = 0
|
||||||
state.selectedId = null
|
state.selectedId = null
|
||||||
suppressHistory = false
|
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceSlide(index: number, newSlide: Slide) {
|
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 = newSlide.elements || []
|
||||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
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
|
state.selectedId = null
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendSlide(newSlide: Slide) {
|
function appendSlide(newSlide: Slide) {
|
||||||
pushHistory()
|
|
||||||
newSlide.id = uid('s')
|
newSlide.id = uid('s')
|
||||||
newSlide.elements = newSlide.elements || []
|
newSlide.elements = newSlide.elements || []
|
||||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
||||||
state.deck.slides.push(newSlide)
|
const idx = state.deck.slides.length
|
||||||
state.currentIndex = state.deck.slides.length - 1
|
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
|
state.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertSlideAt(index: number, newSlide: Slide) {
|
function insertSlideAt(index: number, newSlide: Slide) {
|
||||||
pushHistory()
|
|
||||||
newSlide.id = uid('s')
|
newSlide.id = uid('s')
|
||||||
newSlide.elements = newSlide.elements || []
|
newSlide.elements = newSlide.elements || []
|
||||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
||||||
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.currentIndex = index
|
||||||
state.selectedId = null
|
state.selectedId = null
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
@@ -489,14 +504,12 @@ function saveCustomTheme(t: import('./types').Theme): string {
|
|||||||
|
|
||||||
/** 应用一个主题(内置 key 或自定义对象)到当前 deck */
|
/** 应用一个主题(内置 key 或自定义对象)到当前 deck */
|
||||||
function applyTheme(nameOrObj: string | import('./types').Theme) {
|
function applyTheme(nameOrObj: string | import('./types').Theme) {
|
||||||
pushHistory()
|
|
||||||
if (typeof nameOrObj === 'string') {
|
if (typeof nameOrObj === 'string') {
|
||||||
state.deck.theme = nameOrObj
|
execOp({ type: 'set_theme', theme: nameOrObj, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
} else {
|
} else {
|
||||||
const key = saveCustomTheme(nameOrObj)
|
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 {
|
function addSlideFromTemplate(tplId: string): boolean {
|
||||||
const tpl = getTemplates().find(t => t.id === tplId)
|
const tpl = getTemplates().find(t => t.id === tplId)
|
||||||
if (!tpl) return false
|
if (!tpl) return false
|
||||||
pushHistory()
|
|
||||||
const newSlide: Slide = {
|
const newSlide: Slide = {
|
||||||
id: uid('s'),
|
id: uid('s'),
|
||||||
background: tpl.background,
|
background: tpl.background,
|
||||||
elements: clone(tpl.elements).map(e => ({ ...e, id: uid('el') }))
|
elements: clone(tpl.elements).map(e => ({ ...e, id: uid('el') }))
|
||||||
}
|
}
|
||||||
const idx = state.currentIndex + 1
|
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.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null
|
||||||
scheduleSave()
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 删除用户模板 ---------- */
|
||||||
/** 删除用户模板 */
|
/** 删除用户模板 */
|
||||||
function deleteTemplate(tplId: string) {
|
function deleteTemplate(tplId: string) {
|
||||||
const user = getUserTemplates().filter(t => t.id !== tplId)
|
const user = getUserTemplates().filter(t => t.id !== tplId)
|
||||||
@@ -633,7 +645,6 @@ function newBlankDeck() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 会话管理(按 chatId 隔离) ---------- */
|
/* ---------- 会话管理(按 chatId 隔离) ---------- */
|
||||||
import type { ChatMessage } from './types'
|
|
||||||
|
|
||||||
function newChatId(): string {
|
function newChatId(): string {
|
||||||
return 'chat-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)
|
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) {}
|
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 = {
|
export const store = {
|
||||||
// 状态(响应式)
|
// 状态(响应式)
|
||||||
@@ -699,4 +723,6 @@ export const store = {
|
|||||||
getTemplates, getUserTemplates, saveCurrentAsTemplate, addSlideFromTemplate, deleteTemplate,
|
getTemplates, getUserTemplates, saveCurrentAsTemplate, addSlideFromTemplate, deleteTemplate,
|
||||||
// 会话管理
|
// 会话管理
|
||||||
getChatId, getChat, setChat, clearChat, newChatId,
|
getChatId, getChat, setChat, clearChat, newChatId,
|
||||||
|
// 备份恢复
|
||||||
|
recoverBackup,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,8 +85,6 @@ export interface ElementStyle {
|
|||||||
// card
|
// card
|
||||||
icon?: string
|
icon?: string
|
||||||
accent?: ColorKey
|
accent?: ColorKey
|
||||||
/** 文字元素是否启用 Markdown 行内格式(加粗/斜体/高亮等) */
|
|
||||||
rich?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 幻灯片元素 */
|
/** 幻灯片元素 */
|
||||||
|
|||||||
@@ -275,11 +275,6 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
flex: 1;
|
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;此处仅兜底圆角 */
|
/* 渐变形状由 renderElement 内联 backgroundImage;此处仅兜底圆角 */
|
||||||
.el-shape.gradient { border-radius: var(--shape-radius, 0); }
|
.el-shape.gradient { border-radius: var(--shape-radius, 0); }
|
||||||
|
|
||||||
@@ -314,7 +309,7 @@
|
|||||||
.canvas-placeholder .ph-sub { font-size: 14px; opacity: .8; }
|
.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-stat .num { overflow-wrap: anywhere; }
|
||||||
.el-chart .bar-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.el-chart .bar-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
|||||||
@@ -121,3 +121,18 @@
|
|||||||
.present-stage .slide-inner.animated .el[data-type="list"] .li { opacity: 1 !important; animation: none !important; }
|
.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; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,5 +3,8 @@ import vue from '@vitejs/plugin-vue'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
server: { host: '127.0.0.1', port: 8080, open: true }
|
// Tauri 期望固定端口 5173;Web 单独跑用 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 },
|
||||||
})
|
})
|
||||||
|
|||||||