Compare commits

...
6 Commits
37 changed files with 3246 additions and 122 deletions
+3
View File
@@ -16,6 +16,9 @@ src-tauri/target/
*.swo *.swo
*~ *~
# Claude Code 工作树/临时
.claude/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
+10
View File
@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.2",
"dompurify": "^3.4.14", "dompurify": "^3.4.14",
"mammoth": "^1.12.0", "mammoth": "^1.12.0",
"marked": "^18.0.10", "marked": "^18.0.10",
@@ -1594,6 +1595,15 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@types/chai": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz",
+1
View File
@@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.2",
"dompurify": "^3.4.14", "dompurify": "^3.4.14",
"mammoth": "^1.12.0", "mammoth": "^1.12.0",
"marked": "^18.0.10", "marked": "^18.0.10",
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# =====================================================================
# publish-viewer.sh — u-ppt 查看器一键发布
# 流程: 构建 → 全量上传七牛 bucket → 可达性核对 → CDN 缓存刷新
# 用法: bash scripts/publish-viewer.sh [--check-only]
# 配套技能: publish-uppt-viewer (踩坑记录见技能文档)
# =====================================================================
set -e
PROJECT="/e/wk-lab/u-ppt"
BUCKET="u-res"
CDN="https://img.1216.top"
cd "$PROJECT"
if [ "$1" != "--check-only" ]; then
echo "== [1/4] 类型检查 + 构建 =="
npx vue-tsc -b || { echo "vue-tsc 失败,中止"; exit 1; }
npm run build || { echo "构建失败,中止"; exit 1; }
cd dist
echo "== [2/4] 全量上传 (viewer.html + assets/*) =="
qshell rput "$BUCKET" viewer.html viewer.html --overwrite > /dev/null 2>&1 || echo "上传 viewer.html 失败"
for f in $(ls assets/); do
qshell rput "$BUCKET" "assets/$f" "assets/$f" --overwrite > /dev/null 2>&1 || echo "上传 assets/$f 失败"
done
else
cd dist
echo "== check-only: 跳过构建上传 =="
fi
echo "== [3/4] 可达性核对 =="
FAIL=0
for f in viewer.html $(ls assets/ | sed 's|^|assets/|'); do
code=$(curl -s -o /dev/null -w "%{http_code}" "$CDN/$f")
if [ "$code" != "200" ]; then echo "FAIL $code $f"; FAIL=1; fi
done
[ "$FAIL" = "0" ] && echo "ALL-200"
echo "== [4/4] 刷新 CDN 缓存 =="
echo "$CDN/viewer.html" > /tmp/uppt-refresh.txt
echo "$CDN/index.html" >> /tmp/uppt-refresh.txt
qshell cdnrefresh -i /tmp/uppt-refresh.txt 2>&1 | grep -o "Code: [0-9]*"
echo "== 完成: $CDN/viewer.html =="
+207 -1
View File
@@ -556,6 +556,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"crypto-common", "crypto-common",
"subtle",
] ]
[[package]] [[package]]
@@ -1279,6 +1280,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.38.0" version = "0.38.0"
@@ -1328,6 +1338,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.10.1" version = "1.10.1"
@@ -1826,6 +1842,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2075,6 +2101,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -2570,6 +2597,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"mime", "mime",
"mime_guess",
"native-tls", "native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
@@ -2625,6 +2653,30 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -3009,6 +3061,17 @@ dependencies = [
"stable_deref_trait", "stable_deref_trait",
] ]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -3416,6 +3479,64 @@ dependencies = [
"tauri-utils", "tauri-utils",
] ]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.3" version = "2.11.3"
@@ -3954,12 +4075,17 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
name = "u-ppt" name = "u-ppt"
version = "1.0.0" version = "1.0.0"
dependencies = [ dependencies = [
"base64 0.22.1",
"futures", "futures",
"hmac",
"httpdate",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"sha1",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
"tokio", "tokio",
"ts-rs", "ts-rs",
] ]
@@ -4005,6 +4131,12 @@ dependencies = [
"unic-common", "unic-common",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -4544,6 +4676,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -4577,13 +4718,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6", "windows_x86_64_msvc 0.52.6",
] ]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]] [[package]]
name = "windows-threading" name = "windows-threading"
version = "0.1.0" version = "0.1.0"
@@ -4614,6 +4772,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -4626,6 +4790,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.42.2" version = "0.42.2"
@@ -4638,12 +4808,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.42.2" version = "0.42.2"
@@ -4656,6 +4838,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.42.2" version = "0.42.2"
@@ -4668,6 +4856,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.42.2" version = "0.42.2"
@@ -4680,6 +4874,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -4692,6 +4892,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.5.40" version = "0.5.40"
+6 -1
View File
@@ -15,12 +15,17 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
ts-rs = { version = "10", features = ["no-serde-warnings"] } ts-rs = { version = "10", features = ["no-serde-warnings"] }
reqwest = { version = "0.12", features = ["json", "stream"] } reqwest = { version = "0.12", features = ["json", "stream", "multipart"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
futures = "0.3" futures = "0.3"
hmac = "0.12"
sha1 = "0.10"
base64 = "0.22"
httpdate = "1"
[features] [features]
export-types = [] export-types = []
+2 -1
View File
@@ -7,6 +7,7 @@
"core:default", "core:default",
"core:event:default", "core:event:default",
"core:event:allow-listen", "core:event:allow-listen",
"core:event:allow-unlisten" "core:event:allow-unlisten",
"dialog:default"
] ]
} }
+122
View File
@@ -7,6 +7,7 @@
* - AI 流式通过 emit("ai-delta", token) 实时推送到前端 * - AI 流式通过 emit("ai-delta", token) 实时推送到前端
* - 前端检测 __TAURI__ 环境,Web 模式回退到 TS store * - 前端检测 __TAURI__ 环境,Web 模式回退到 TS store
* ===================================================================== */ * ===================================================================== */
use base64::Engine;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::State; use tauri::State;
use tauri::Emitter; use tauri::Emitter;
@@ -247,3 +248,124 @@ pub fn write_file(path: String, content: String) -> Result<(), String> {
pub fn read_file(path: String) -> Result<String, String> { pub fn read_file(path: String) -> Result<String, String> {
std::fs::read_to_string(&path).map_err(|e| format!("读取失败: {}", e)) std::fs::read_to_string(&path).map_err(|e| format!("读取失败: {}", e))
} }
/* ---------- 本地文件读取(桌面拖拽/系统对话框选择后的路径 → 字节) ---------- */
#[derive(Serialize)]
pub struct LocalFileData {
pub name: String, // 文件名(不含目录)
pub mime: String, // 按扩展名推断,未知为空串
pub base64: String, // 文件字节的标准 base64
}
/// 按扩展名推断 MIME 类型(未知返回空串)
fn mime_by_ext(path: &std::path::Path) -> String {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
"ico" => "image/x-icon",
"mp4" => "video/mp4",
"webm" => "video/webm",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
"mkv" => "video/x-matroska",
"pdf" => "application/pdf",
"md" => "text/markdown",
"txt" => "text/plain",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"doc" => "application/msword",
"json" => "application/json",
_ => "",
}
.to_string()
}
/// 目录/文件名是否应跳过(隐藏项 + 常见垃圾目录)
fn is_junk(name: &std::ffi::OsStr) -> bool {
let name = name.to_string_lossy();
name.starts_with('.')
|| matches!(
name.as_ref(),
"node_modules" | "target" | "__MACOSX"
)
}
/// 递归展开目录:最大深度 3,跳过隐藏/垃圾目录,文件总数上限 200
fn collect_files(
path: &std::path::Path,
depth: u32,
out: &mut Vec<std::path::PathBuf>,
) -> Result<(), String> {
const MAX_FILES: usize = 200;
if out.len() >= MAX_FILES {
return Err("文件过多(>200),请分批选择".into());
}
if path.is_dir() {
if depth >= 3 {
return Ok(()); // 超过最大深度,不再下钻
}
let entries = std::fs::read_dir(path).map_err(|e| format!("读取目录失败: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("读取目录失败: {}", e))?;
let name = entry.file_name();
if is_junk(&name) {
continue;
}
collect_files(&entry.path(), depth + 1, out)?;
}
} else if path.is_file() {
out.push(path.to_path_buf());
}
Ok(())
}
/// 批量读取本地文件。路径来自系统文件对话框或拖拽事件,由用户主动选择,
/// 读取后内容以 base64 回传前端构造 File 对象。
/// 目录会递归展开(深度 3,跳过隐藏与垃圾目录);单个文件读取失败直接跳过,全部失败才报错。
#[tauri::command]
pub async fn read_files(paths: Vec<String>) -> Result<Vec<LocalFileData>, String> {
// 目录展开是纯磁盘 IO,放阻塞线程池避免卡 async 运行时
let expanded = tokio::task::spawn_blocking(move || -> Result<Vec<std::path::PathBuf>, String> {
let mut files = Vec::new();
for p in &paths {
collect_files(std::path::Path::new(p), 0, &mut files)?;
}
Ok(files)
})
.await
.map_err(|e| format!("任务执行失败: {}", e))??;
let mut out = Vec::new();
for path in expanded {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let mime = mime_by_ext(&path);
// 逐个读文件是阻塞 IO,同样放阻塞线程池
let read = tokio::task::spawn_blocking(move || std::fs::read(&path))
.await
.map_err(|e| format!("任务执行失败: {}", e))?;
if let Ok(bytes) = read {
out.push(LocalFileData {
name,
mime,
base64: base64::engine::general_purpose::STANDARD.encode(&bytes),
});
}
}
if out.is_empty() {
return Err("没有可读取的文件".into());
}
Ok(out)
}
+4
View File
@@ -6,11 +6,13 @@ mod color;
mod commands; mod commands;
mod model; mod model;
mod op; mod op;
mod oss;
pub fn run() { pub fn run() {
let app_state = commands::AppState::new(); let app_state = commands::AppState::new();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(app_state) .manage(app_state)
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::ping, commands::ping,
@@ -27,6 +29,8 @@ pub fn run() {
commands::ai_proxy_stream, commands::ai_proxy_stream,
commands::write_file, commands::write_file,
commands::read_file, commands::read_file,
commands::read_files,
oss::oss_upload,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+287
View File
@@ -0,0 +1,287 @@
/* =====================================================================
* oss.rs — 对象存储上传(阿里云 OSS / 七牛云 Kodo
* 在 Rust 端发起 HTTP,规避浏览器 CORS 与密钥暴露:
* - 阿里云:PUT Object + Authorization 头 V1 签名(HMAC-SHA1
* - 七牛云:表单直传 multipart/form-data + 上传凭证 UpToken
* 前端通过 oss_upload 命令传入 provider/配置/对象 key/文件 base64,返回可访问 URL
* ===================================================================== */
use base64::engine::general_purpose::{STANDARD as B64, URL_SAFE as B64_URLSAFE};
use base64::Engine;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha1::Sha1;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
/// 七牛区域探测结果缓存(<ak>:<bucket> -> 上传地址)。
/// 多文件批量上传时避免每个文件都多一次探测 HTTP 往返。
static QINIU_REGION_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
fn qiniu_region_cache() -> &'static Mutex<HashMap<String, String>> {
QINIU_REGION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
type HmacSha1 = Hmac<Sha1>;
/// 上传参数(provider 决定使用哪组字段)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OssUploadArgs {
/// "aliyun" | "qiniu"
pub provider: String,
/// 对象 key(前端已拼好目录前缀,如 "images/2026/xxx.png"
pub key: String,
/// 文件字节的 base64(标准 base64,非 urlsafe
pub data_base64: String,
/// MIME 类型(如 image/png);缺省 application/octet-stream
pub content_type: Option<String>,
/* ---- 阿里云 OSS ---- */
pub access_key_id: Option<String>,
pub access_key_secret: Option<String>,
/// endpoint,如 oss-cn-hangzhou.aliyuncs.com(不含 bucket、不含协议)
pub endpoint: Option<String>,
pub bucket: Option<String>,
/* ---- 七牛云 Kodo ---- */
pub access_key: Option<String>,
pub secret_key: Option<String>,
/// 上传域名,如 https://upload.qiniup.com(缺省用该值)
pub up_host: Option<String>,
/// 绑定的公开访问域名,如 https://cdn.example.com(用于拼最终 URL
pub domain: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct OssUploadResult {
pub url: String,
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// RFC1123 GMT 日期(如 "Wed, 28 Dec 2022 10:27:41 GMT"
fn http_date() -> String {
httpdate::fmt_http_date(SystemTime::now())
}
fn hmac_sha1(key: &[u8], msg: &[u8]) -> Vec<u8> {
let mut mac = HmacSha1::new_from_slice(key).expect("HMAC 接受任意长度 key");
mac.update(msg);
mac.finalize().into_bytes().to_vec()
}
/// 命令入口:按 provider 分发
#[tauri::command]
pub async fn oss_upload(args: OssUploadArgs) -> Result<OssUploadResult, String> {
let bytes = B64
.decode(args.data_base64.as_bytes())
.map_err(|e| format!("文件数据解码失败: {}", e))?;
let content_type = args
.content_type
.clone()
.unwrap_or_else(|| "application/octet-stream".to_string());
match args.provider.as_str() {
"aliyun" => upload_aliyun(&args, bytes, &content_type).await,
"qiniu" => upload_qiniu(&args, bytes, &content_type).await,
other => Err(format!("不支持的 OSS 服务商: {}", other)),
}
}
/// 阿里云 OSSPUT ObjectAuthorization 头 V1 签名
async fn upload_aliyun(
args: &OssUploadArgs,
bytes: Vec<u8>,
content_type: &str,
) -> Result<OssUploadResult, String> {
let ak = args.access_key_id.as_deref().unwrap_or("").trim();
let sk = args.access_key_secret.as_deref().unwrap_or("").trim();
let endpoint = args.endpoint.as_deref().unwrap_or("").trim();
let bucket = args.bucket.as_deref().unwrap_or("").trim();
if ak.is_empty() || sk.is_empty() || endpoint.is_empty() || bucket.is_empty() {
return Err("阿里云配置不完整(需 AccessKeyId/Secret/endpoint/bucket".into());
}
let key = args.key.trim_start_matches('/');
let date = http_date();
// 签名串:VERB\nContent-MD5\nContent-Type\nDate\nCanonicalizedResource
// Content-MD5 留空;无 x-oss- 头
let canonical_resource = format!("/{}/{}", bucket, key);
let string_to_sign = format!(
"PUT\n\n{}\n{}\n{}",
content_type, date, canonical_resource
);
let signature = B64.encode(hmac_sha1(sk.as_bytes(), string_to_sign.as_bytes()));
let authorization = format!("OSS {}:{}", ak, signature);
// endpoint 可能带协议,规整为纯 host
let host = endpoint
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_end_matches('/');
let url = format!("https://{}.{}/{}", bucket, host, key);
let client = reqwest::Client::new();
let resp = client
.put(&url)
.header("Date", &date)
.header("Content-Type", content_type)
.header("Authorization", &authorization)
.body(bytes)
.send()
.await
.map_err(|e| format!("上传请求失败: {}", e))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("阿里云返回 {}{}", status.as_u16(), body));
}
Ok(OssUploadResult { url })
}
/// 七牛云 Kodo:表单直传 multipart/form-data + UpToken
async fn upload_qiniu(
args: &OssUploadArgs,
bytes: Vec<u8>,
content_type: &str,
) -> Result<OssUploadResult, String> {
let ak = args.access_key.as_deref().unwrap_or("").trim();
let sk = args.secret_key.as_deref().unwrap_or("").trim();
let bucket = args.bucket.as_deref().unwrap_or("").trim();
let domain = args.domain.as_deref().unwrap_or("").trim();
if ak.is_empty() || sk.is_empty() || bucket.is_empty() || domain.is_empty() {
return Err("七牛云配置不完整(需 AccessKey/SecretKey/bucket/domain".into());
}
let up_host = match args
.up_host
.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
Some(h) => h.to_string(),
// 未填写上传域名时自动探测区域:硬编码华东 upload.qiniup.com 会在
// 华南(z2)等非华东 bucket 上报 incorrect region,故走官方查询接口
None => qiniu_detect_up_host(ak, bucket).await?,
};
let key = args.key.trim_start_matches('/');
let token = qiniu_upload_token(ak, sk, bucket, key);
let file_part = reqwest::multipart::Part::bytes(bytes)
.file_name(key.to_string())
.mime_str(content_type)
.map_err(|e| format!("构造上传表单失败: {}", e))?;
let form = reqwest::multipart::Form::new()
.text("token", token)
.text("key", key.to_string())
.part("file", file_part);
let client = reqwest::Client::new();
let resp = client
.post(up_host)
.multipart(form)
.send()
.await
.map_err(|e| format!("上传请求失败: {}", e))?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!("七牛云返回 {}{}", status.as_u16(), body));
}
// 拼公开访问 URLdomain/key
let base = domain.trim_end_matches('/');
let url = if base.starts_with("http://") || base.starts_with("https://") {
format!("{}/{}", base, key)
} else {
format!("https://{}/{}", base, key)
};
Ok(OssUploadResult { url })
}
/// 七牛区域探测:按 ak/bucket 查询官方 uc 接口,返回上传地址(https://<域名>
async fn qiniu_detect_up_host(ak: &str, bucket: &str) -> Result<String, String> {
let cache_key = format!("{}:{}", ak, bucket);
// 先查缓存,命中直接返回
if let Some(host) = qiniu_region_cache()
.lock()
.ok()
.and_then(|map| map.get(&cache_key).cloned())
{
return Ok(host);
}
// 公开接口无需签名;锁内只做 map 读写不 await
let url = format!(
"https://uc.qiniuapi.com/v4/query?ak={}&bucket={}",
ak, bucket
);
let resp = reqwest::Client::new()
.get(&url)
.send()
.await
.map_err(|e| format!("无法自动探测七牛上传区域(可手动填写上传域名):{}", e))?;
if !resp.status().is_success() {
return Err(format!(
"无法自动探测七牛上传区域(可手动填写上传域名):接口返回 {}",
resp.status().as_u16()
));
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("无法自动探测七牛上传区域(可手动填写上传域名):{}", e))?;
// 取 hosts[0].up.domains 中第一个非空域名,健壮处理字段缺失/为空的情况
let domain = json["hosts"]
.as_array()
.and_then(|hosts| hosts.first())
.and_then(|h| h["up"]["domains"].as_array())
.and_then(|domains| {
domains
.iter()
.filter_map(|d| d.as_str())
.find(|d| !d.trim().is_empty())
})
.map(|d| d.trim().to_string());
let domain = match domain {
Some(d) => d,
None => {
return Err(
"无法自动探测七牛上传区域(可手动填写上传域名):接口未返回可用域名".into(),
)
}
};
let host = format!("https://{}", domain);
if let Ok(mut map) = qiniu_region_cache().lock() {
map.insert(cache_key, host.clone());
}
Ok(host)
}
/// 七牛上传凭证:AccessKey:urlsafe_b64(hmac_sha1(sk, encodedPolicy)):encodedPolicy
fn qiniu_upload_token(ak: &str, sk: &str, bucket: &str, key: &str) -> String {
let deadline = now_unix() + 3600; // 1 小时有效
// scope 指定为 bucket:key,前端 key 必须与之一致
let put_policy = format!(
"{{\"scope\":\"{}:{}\",\"deadline\":{}}}",
bucket, key, deadline
);
let encoded_policy = B64_URLSAFE.encode(put_policy.as_bytes());
let sign = hmac_sha1(sk.as_bytes(), encoded_policy.as_bytes());
let encoded_sign = B64_URLSAFE.encode(sign);
format!("{}:{}:{}", ak, encoded_sign, encoded_policy)
}
+167 -20
View File
@@ -6,13 +6,22 @@ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { store } from './core/store' import { store } from './core/store'
import { readAsDataURL, fileToImageElement } from './core/importer' import { readAsDataURL, fileToImageElement } from './core/importer'
import { checkImageQuota } from './core/ai' import { checkImageQuota } from './core/ai'
import { putAsset, isOssEnabled } from './core/assets'
import { publishDeck } from './core/share'
import { appAlert } from './core/dialog'
import { isTauri, readLocalFiles } from './core/bridge'
import AppDialog from './components/common/AppDialog.vue' import AppDialog from './components/common/AppDialog.vue'
import FileDock from './components/common/FileDock.vue'
import FilePreviewDrawer from './components/common/FilePreviewDrawer.vue'
import { addFiles } from './core/attachments'
import Toolbar from './components/editor/Toolbar.vue' import Toolbar from './components/editor/Toolbar.vue'
import ThumbBar from './components/editor/ThumbBar.vue' import ThumbBar from './components/editor/ThumbBar.vue'
import Canvas from './components/editor/Canvas.vue' import Canvas from './components/editor/Canvas.vue'
import PropsPanel from './components/editor/PropsPanel.vue' import PropsPanel from './components/editor/PropsPanel.vue'
import AiPanel from './components/ai/AiPanel.vue' import AiPanel from './components/ai/AiPanel.vue'
import AgentPanel from './components/ai/AgentPanel.vue'
import SettingsModal from './components/modals/SettingsModal.vue' import SettingsModal from './components/modals/SettingsModal.vue'
import OssSettingsModal from './components/modals/OssSettingsModal.vue'
import LibraryModal from './components/modals/LibraryModal.vue' import LibraryModal from './components/modals/LibraryModal.vue'
import TemplateModal from './components/modals/TemplateModal.vue' import TemplateModal from './components/modals/TemplateModal.vue'
import ImportModal from './components/modals/ImportModal.vue' import ImportModal from './components/modals/ImportModal.vue'
@@ -26,13 +35,14 @@ const presentVisible = ref(false)
const presentStartIndex = ref(0) const presentStartIndex = ref(0)
/* ---------- 活动面板 tab ---------- */ /* ---------- 活动面板 tab ---------- */
const activeTab = ref<'props' | 'ai'>('props') const activeTab = ref<'props' | 'ai' | 'agent'>('props')
function switchTab(name: 'props' | 'ai') { function switchTab(name: 'props' | 'ai' | 'agent') {
activeTab.value = name activeTab.value = name
} }
/* ---------- 弹窗 ---------- */ /* ---------- 弹窗 ---------- */
const settingsVisible = ref(false) const settingsVisible = ref(false)
const ossVisible = ref(false)
const libraryVisible = ref(false) const libraryVisible = ref(false)
const templateVisible = ref(false) const templateVisible = ref(false)
const importVisible = ref(false) const importVisible = ref(false)
@@ -40,8 +50,8 @@ const printVisible = ref(false)
const deckLoaded = ref(false) // 文库/导入是否加载了新 deck,防止误切 const deckLoaded = ref(false) // 文库/导入是否加载了新 deck,防止误切
const importModalRef = ref<InstanceType<typeof ImportModal> | null>(null) const importModalRef = ref<InstanceType<typeof ImportModal> | null>(null)
function dropFilesIntoImport(fl: FileList) { function dropFilesIntoImport(files: File[]) {
importModalRef.value?.acceptDroppedFiles(fl) importModalRef.value?.acceptDroppedFiles(files)
} }
/* ---------- toast ---------- */ /* ---------- toast ---------- */
@@ -125,7 +135,58 @@ function onNewBlank() {
mode.value = 'editor' mode.value = 'editor'
} }
function onImportMaterials() { importVisible.value = true } function onImportMaterials() {
if (isTauri()) {
void (async () => {
try {
const { open } = await import('@tauri-apps/plugin-dialog')
const { ACCEPT_ATTR } = await import('./core/importer')
const sel = await open({
multiple: true,
directory: false,
filters: [{ name: '资料', extensions: ACCEPT_ATTR.split(',').map(s => s.slice(1)) }]
})
if (!sel) return // 用户取消
const paths = Array.isArray(sel) ? sel : [sel]
const files = await readLocalFiles(paths)
if (!files.length) { toast('未能读取所选文件'); return }
// 单个 json 与拖拽同分流
if (files.length === 1 && /\.json$/i.test(files[0].name)) { await importJsonFile(files[0]); return }
importVisible.value = true
await nextTick()
dropFilesIntoImport(files)
return
} catch (e: any) {
toast('打开文件选择器失败:' + (e?.message || String(e)))
}
importVisible.value = true
})()
return
}
importVisible.value = true
}
function onHome() {
mode.value = 'home'
}
/* ---------- 分享:发布 deck 到 OSS,生成公开只读链接 ---------- */
async function onShare() {
try {
const { url } = await publishDeck()
try { await navigator.clipboard.writeText(url) } catch (e) { /* 剪贴板失败不阻断展示 */ }
await appAlert('分享链接已生成' + '(已复制到剪贴板)', url)
} catch (e: any) {
const msg = e?.message || String(e)
// 未配置云存储时引导到设置页
if (msg.includes('云存储') || msg.includes('OSS')) {
await appAlert('无法分享', msg)
ossVisible.value = true
} else {
await appAlert('分享失败', msg)
}
}
}
function onImportClose() { function onImportClose() {
importVisible.value = false importVisible.value = false
@@ -212,6 +273,19 @@ onMounted(() => {
window.addEventListener('dragover', onGlobalDragOver) window.addEventListener('dragover', onGlobalDragOver)
window.addEventListener('drop', onGlobalDrop) window.addEventListener('drop', onGlobalDrop)
window.addEventListener('dragleave', onGlobalDragLeave) window.addEventListener('dragleave', onGlobalDragLeave)
// 桌面原生拖拽通道(动态 import,Web 版不受影响)
if (isTauri()) {
void (async () => {
try {
const { getCurrentWebview } = await import('@tauri-apps/api/webview')
await getCurrentWebview().onDragDropEvent((ev) => {
if (ev.payload.type === 'drop') void onNativeDrop(ev.payload.paths)
})
} catch (e: any) {
console.warn('原生拖拽监听失败:', e)
}
})()
}
}) })
/* ---------- 系统剪贴板粘贴:按内容类型分发(截图/图片URL/文本) ---------- */ /* ---------- 系统剪贴板粘贴:按内容类型分发(截图/图片URL/文本) ---------- */
@@ -231,6 +305,14 @@ async function onPaste(e: ClipboardEvent) {
const file = item.getAsFile() const file = item.getAsFile()
if (!file) return if (!file) return
try { try {
// OSS 启用:粘贴图片走资产库(上云或离线暂存),content 存引用
if (isOssEnabled()) {
const ref = await putAsset(file)
const el = fileToImageElement(file, ref)
store.addElement('image', { content: ref, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style })
toast('已插入剪贴板图片')
return
}
const dataUrl = await readAsDataURL(file) const dataUrl = await readAsDataURL(file)
const quotaErr = checkImageQuota(dataUrl) const quotaErr = checkImageQuota(dataUrl)
if (quotaErr) { toast(quotaErr); return } if (quotaErr) { toast(quotaErr); return }
@@ -244,7 +326,30 @@ async function onPaste(e: ClipboardEvent) {
} }
} }
// 2) 文本:图片 URL → 图片元素;其他文本 → 文本元素(内部元素剪贴板优先级更高,keydown 已处理 // 2) 常规文件(非图片:PDF/视频/文档/压缩包等)→ 附件坞(仅会话态
// 两路兜底:cd.files(多数浏览器直接给 FileList+ items.getAsFile()(部分只在 items 暴露)
const regularFiles: File[] = []
const seen = new Set<string>() // 名称+大小去重,避免两路重复
const collect = (f: File | null) => {
if (!f) return
if (f.type.startsWith('image/')) return // 图片已在上面处理
const key = `${f.name}${f.size}`
if (seen.has(key)) return
seen.add(key)
regularFiles.push(f)
}
for (const f of Array.from(cd.files)) collect(f)
for (const item of items) {
if (item.kind === 'file' && !item.type.startsWith('image/')) collect(item.getAsFile())
}
if (regularFiles.length) {
e.preventDefault()
const n = addFiles(regularFiles)
toast(`已添加 ${n} 个附件`)
return
}
// 3) 文本:图片 URL → 图片元素;其他文本 → 文本元素(内部元素剪贴板优先级更高,keydown 已处理)
if (store.hasClipboard()) return if (store.hasClipboard()) return
const text = cd.getData('text/plain') const text = cd.getData('text/plain')
if (!text || !text.trim()) return if (!text || !text.trim()) return
@@ -272,6 +377,18 @@ async function onPaste(e: ClipboardEvent) {
} }
} }
/* ---------- JSON 文件导入(浏览器选择器与桌面拖拽共用) ---------- */
async function importJsonFile(file: File) {
const text = await file.text()
try {
store.importJSON(text)
toast('已导入')
if (mode.value === 'home') mode.value = 'editor'
} catch (err: any) {
toast('导入失败:' + (err?.message || String(err)))
}
}
/* ---------- 全局拖放:JSON 直接导入,其他文件进资料导入弹窗 ---------- */ /* ---------- 全局拖放:JSON 直接导入,其他文件进资料导入弹窗 ---------- */
const globalDragOver = ref(false) const globalDragOver = ref(false)
@@ -288,6 +405,8 @@ function onGlobalDragLeave(e: DragEvent) {
} }
async function onGlobalDrop(e: DragEvent) { async function onGlobalDrop(e: DragEvent) {
// 桌面拖拽全权交给原生事件通道,防止双触发
if (isTauri()) { e.preventDefault(); return }
const fl = e.dataTransfer?.files const fl = e.dataTransfer?.files
if (!fl || fl.length === 0) return if (!fl || fl.length === 0) return
e.preventDefault() e.preventDefault()
@@ -295,24 +414,32 @@ async function onGlobalDrop(e: DragEvent) {
// 单个 .json 文件 → 直接导入 deck(与工具栏 📥 导入同路径) // 单个 .json 文件 → 直接导入 deck(与工具栏 📥 导入同路径)
if (fl.length === 1 && /\.json$/i.test(fl[0].name)) { if (fl.length === 1 && /\.json$/i.test(fl[0].name)) {
const reader = new FileReader() await importJsonFile(fl[0])
reader.onload = () => {
try {
store.importJSON(reader.result as string)
toast('已导入')
if (mode.value === 'home') mode.value = 'editor'
} catch (err: any) {
toast('导入失败:' + (err?.message || String(err)))
}
}
reader.readAsText(fl[0])
return return
} }
// 其他文件 → 打开资料导入弹窗并预填 // 其他文件 → 打开资料导入弹窗并预填
importVisible.value = true importVisible.value = true
await nextTick() await nextTick()
dropFilesIntoImport(fl) dropFilesIntoImport(Array.from(fl))
}
/* ---------- 桌面原生拖拽(Tauri onDragDropEvent 通道) ---------- */
async function onNativeDrop(paths: string[]) {
if (!paths || paths.length === 0) return
const files = await readLocalFiles(paths)
if (!files.length) { toast('未能读取所选文件'); return }
// 单个 .json → 直接导入 deck,与浏览器拖拽同分流
if (files.length === 1 && /\.json$/i.test(files[0].name)) {
await importJsonFile(files[0])
return
}
// 其他文件 → 打开资料导入弹窗并预填
importVisible.value = true
await nextTick()
dropFilesIntoImport(files)
} }
/* 持久化失败告警 → toast(防止静默丢数据) */ /* 持久化失败告警 → toast(防止静默丢数据) */
@@ -347,6 +474,7 @@ onUnmounted(() => {
@ai-create="onNewBlank(); switchTab('ai')" @ai-create="onNewBlank(); switchTab('ai')"
@toast="toast" @toast="toast"
@open-settings="settingsVisible = true" @open-settings="settingsVisible = true"
@open-oss="ossVisible = true"
/> />
<!-- ===================== 编辑模式 ===================== --> <!-- ===================== 编辑模式 ===================== -->
@@ -356,10 +484,13 @@ onUnmounted(() => {
@present="onPresent" @present="onPresent"
@open-library="libraryVisible = true" @open-library="libraryVisible = true"
@open-settings="settingsVisible = true" @open-settings="settingsVisible = true"
@open-oss="ossVisible = true"
@save="onSave" @save="onSave"
@open-templates="templateVisible = true" @open-templates="templateVisible = true"
@export-json="onExportJson" @export-json="onExportJson"
@import-materials="onImportMaterials" @import-materials="onImportMaterials"
@home="onHome"
@share="onShare"
@import-json="onImportJson" @import-json="onImportJson"
@export-pdf="onExportPdf" @export-pdf="onExportPdf"
/> />
@@ -376,6 +507,7 @@ onUnmounted(() => {
<div class="panel-tabs"> <div class="panel-tabs">
<button class="panel-tab" :class="{ active: activeTab === 'props' }" @click="switchTab('props')">🎨 属性</button> <button class="panel-tab" :class="{ active: activeTab === 'props' }" @click="switchTab('props')">🎨 属性</button>
<button class="panel-tab" :class="{ active: activeTab === 'ai' }" @click="switchTab('ai')">🤖 AI 助手</button> <button class="panel-tab" :class="{ active: activeTab === 'ai' }" @click="switchTab('ai')">🤖 AI 助手</button>
<button class="panel-tab" :class="{ active: activeTab === 'agent' }" @click="switchTab('agent')">📡 Agent</button>
</div> </div>
<PropsPanel v-show="activeTab === 'props'" /> <PropsPanel v-show="activeTab === 'props'" />
@@ -385,7 +517,13 @@ onUnmounted(() => {
@busy-change="onBusyChange" @busy-change="onBusyChange"
@toast="toast" @toast="toast"
@open-settings="settingsVisible = true" @open-settings="settingsVisible = true"
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai')" @switch-tab="(t: string) => switchTab(t as 'props' | 'ai' | 'agent')"
/>
<AgentPanel
v-show="activeTab === 'agent'"
@toast="toast"
@open-settings="settingsVisible = true"
/> />
</aside> </aside>
</div> </div>
@@ -404,11 +542,16 @@ onUnmounted(() => {
@close="settingsVisible = false" @close="settingsVisible = false"
@toast="toast" @toast="toast"
/> />
<OssSettingsModal
:visible="ossVisible"
@close="ossVisible = false"
@toast="toast"
/>
<LibraryModal <LibraryModal
:visible="libraryVisible" :visible="libraryVisible"
@close="libraryVisible = false" @close="libraryVisible = false"
@toast="toast" @toast="toast"
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai')" @switch-tab="(t: string) => switchTab(t as 'props' | 'ai' | 'agent')"
@open-deck="mode = 'editor'" @open-deck="mode = 'editor'"
/> />
<TemplateModal <TemplateModal
@@ -440,6 +583,10 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- 会话态附件坞与右侧预览抽屉 -->
<FileDock />
<FilePreviewDrawer />
<!-- 全局统一对话框appAlert/appConfirm/appPrompt --> <!-- 全局统一对话框appAlert/appConfirm/appPrompt -->
<AppDialog /> <AppDialog />
</template> </template>
+2
View File
@@ -12,6 +12,7 @@ const emit = defineEmits<{
(e: 'open-library'): void (e: 'open-library'): void
(e: 'import-materials'): void (e: 'import-materials'): void
(e: 'open-settings'): void (e: 'open-settings'): void
(e: 'open-oss'): void
(e: 'toast', msg: string): void (e: 'toast', msg: string): void
(e: 'open-deck'): void (e: 'open-deck'): void
(e: 'ai-create'): void (e: 'ai-create'): void
@@ -133,6 +134,7 @@ const workDraft = computed(() => {
<!-- 底部 --> <!-- 底部 -->
<footer class="home-footer"> <footer class="home-footer">
<button class="btn ghost" @click="emit('open-oss')"> 云存储</button>
<button class="btn ghost" @click="emit('open-settings')"> 设置</button> <button class="btn ghost" @click="emit('open-settings')"> 设置</button>
</footer> </footer>
</div> </div>
+223
View File
@@ -0,0 +1,223 @@
<!-- =====================================================================
AgentPanel.vue Agent 模式面板u-relay 中继接入远端 Agent
连接状态徽标 + 指令输入 + 进度流 + 结果按 SEP 协议应用到 deck
===================================================================== -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { store } from '../../core/store'
import { relay, type RelayStatus } from '../../core/relay'
import { buildAgentPrompt, parseChatReply } from '../../core/ai'
import { renderMd } from '../../core/markdown'
const emit = defineEmits<{
(e: 'toast', msg: string): void
(e: 'open-settings'): void
}>()
interface StreamMsg {
key: number
role: 'user' | 'assistant' | 'system'
content: string
streaming?: boolean
tag?: string
error?: boolean
}
const messagesEl = ref<HTMLElement | null>(null)
const inputEl = ref<HTMLTextAreaElement | null>(null)
const inputText = ref('')
const status = ref<RelayStatus>(relay.getStatus())
const statusDetail = ref('')
const busy = ref(false)
let keySeq = 0
const msgs = ref<StreamMsg[]>([])
const configured = computed(() => relay.isConfigured())
const STATUS_LABEL: Record<RelayStatus, string> = {
disabled: '未启用',
connecting: '连接中',
connected: '已连接',
reconnecting: '重连中',
error: '连接失败'
}
function scrollBottom() {
nextTick(() => {
const el = messagesEl.value
if (el) el.scrollTop = el.scrollHeight
})
}
function push(role: StreamMsg['role'], content: string, opts?: Partial<StreamMsg>): StreamMsg {
const m: StreamMsg = { key: ++keySeq, role, content, ...opts }
msgs.value.push(m)
scrollBottom()
return m
}
/** 进行中的请求:request_id → 流式气泡 */
const inflight = new Map<string, StreamMsg>()
function onStatus(s: RelayStatus, detail?: string) {
status.value = s
statusDetail.value = detail || ''
}
function onProgress(rid: string, text: string) {
const m = inflight.get(rid)
if (!m) return
m.content = text
m.streaming = true
scrollBottom()
}
function onResult(rid: string, text: string) {
let m = inflight.get(rid)
inflight.delete(rid)
relay.settle(rid)
if (m) {
m.content = text
m.streaming = false
} else {
m = push('assistant', text)
}
applyResult(m)
busy.value = false
}
function applyResult(m: StreamMsg) {
const { reply, op } = parseChatReply(m.content)
m.content = reply || '(无文字回复)'
if (op && op.action !== 'answer' && op.slides.length) {
const slides = op.slides
const idx = store.getCurrentIndex()
if (op.action === 'create_all') {
store.replaceDeck({ theme: store.theme.value, slides }, { newChat: true })
m.tag = '已替换为 ' + slides.length + ' 页新演示'
} else if (op.action === 'add_page') {
const at = (op.target != null ? op.target : idx) + 1
store.insertSlideAt(Math.min(at, store.getCount()), slides[0])
m.tag = '已新增 1 页'
} else if (op.action === 'update_page') {
const t = Math.max(0, Math.min(op.target != null ? op.target : idx, store.getCount() - 1))
store.replaceSlide(t, slides[0])
if (t !== idx) store.setCurrentIndex(t)
m.tag = '已更新第 ' + (t + 1) + ' 页'
}
}
scrollBottom()
}
async function onSend() {
if (busy.value || !inputText.value.trim()) return
if (!configured.value) { emit('toast', '请先在设置中配置 Agent 中继'); emit('open-settings'); return }
if (status.value !== 'connected') { emit('toast', '中继未连接,请稍候'); return }
const input = inputText.value.trim()
inputText.value = ''
push('user', input)
const prompt = buildAgentPrompt(input)
busy.value = true
try {
const rid = relay.request(prompt)
inflight.set(rid, push('assistant', '', { streaming: true }))
} catch (e: any) {
push('assistant', '⚠ ' + (e?.message || String(e)), { error: true })
busy.value = false
}
}
function onToggleConnect() {
if (status.value === 'connected' || status.value === 'connecting' || status.value === 'reconnecting') {
relay.disconnect()
} else {
relay.connect()
}
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend() }
}
relay.setHandlers({ onStatus, onProgress, onResult })
onMounted(() => {
if (configured.value) relay.connect()
})
onUnmounted(() => {
// Tab 线 handler
})
</script>
<template>
<div class="panel-pane agent-pane">
<div class="agent-status-bar">
<span class="status-badge" :class="status">
<i class="dot"></i>{{ STATUS_LABEL[status] }}
</span>
<button class="agent-action ghost" @click="onToggleConnect">
{{ (status === 'connected' || status === 'connecting' || status === 'reconnecting') ? '断开' : '连接' }}
</button>
<button class="agent-action ghost" @click="emit('open-settings')">设置</button>
</div>
<div v-if="!configured" class="agent-empty">
未配置 Agent 中继请点击右上角设置填写<br />中继 URL / Token / 设备 ID三项齐备即启用
</div>
<div v-show="configured" class="agent-messages" ref="messagesEl">
<div v-if="!msgs.length" class="agent-empty">
通过中继把指令发给远端 Agent例如<br />把当前页的标题改得更有冲击力
</div>
<div v-for="m in msgs" :key="m.key" class="msg" :class="m.role">
<div class="bubble" :class="{ error: m.error }">
<span v-if="m.tag" class="diff-tag"> {{ m.tag }}</span>
<div v-if="m.content" class="md-body" v-html="renderMd(m.content)"></div>
<span v-if="m.streaming" class="cursor"></span>
</div>
</div>
</div>
<div class="chat-input-bar">
<textarea
ref="inputEl"
v-model="inputText"
rows="3"
:placeholder="configured ? '输入 Agent 指令,回车发送(Shift+Enter 换行)' : '请先配置中继'"
:disabled="!configured"
@keydown="onInputKeydown"
></textarea>
<div class="btns">
<button class="btn primary" :disabled="busy || !configured" @click="onSend">发送</button>
</div>
</div>
</div>
</template>
<style scoped>
.agent-pane { display: flex; flex-direction: column; height: 100%; }
.agent-status-bar {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-bottom: 1px solid var(--ui-border);
}
.status-badge {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--ui-text-secondary, #888); flex: 1;
}
.status-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
.status-badge.connected .dot { background: #22c55e; }
.status-badge.connecting .dot, .status-badge.reconnecting .dot { background: #f59e0b; animation: pulse 1.2s infinite; }
.status-badge.error .dot { background: #ef4444; }
@keyframes pulse { 50% { opacity: 0.3; } }
.agent-action {
border: none; background: none; cursor: pointer; font-size: 12px;
color: var(--ui-text-secondary, #888); padding: 2px 6px;
}
.agent-action:hover { color: var(--ui-text, #333); }
.agent-messages { flex: 1; overflow-y: auto; padding: 10px; }
.agent-empty {
color: var(--ui-text-secondary, #999); font-size: 13px;
text-align: center; padding: 32px 12px; line-height: 1.8;
}
</style>
+132
View File
@@ -0,0 +1,132 @@
<!-- =====================================================================
FileDock.vue 附件坞页面粘贴常规文件后浮现的可点击附件条
core/attachments 模块级状态驱动点击文件 打开右侧预览抽屉
仅会话态不落盘刷新即失
===================================================================== -->
<script setup lang="ts">
import { attachmentState, openPreview, removeAttachment, clearAttachments, formatSize } from '../../core/attachments'
/* 类型 → 图标(纯 emoji,与工具栏风格一致,零依赖) */
const ICONS: Record<string, string> = {
image: '🖼️', video: '🎬', pdf: '📕',
markdown: '📝', text: '📄', doc: '📘', meta: '📎'
}
</script>
<template>
<!-- 有附件才显示 -->
<div v-if="attachmentState.list.value.length" class="file-dock">
<div class="file-dock-head">
<span class="file-dock-title">📎 附件 {{ attachmentState.list.value.length }}</span>
<button class="file-dock-clear" title="清空附件" @click="clearAttachments">清空</button>
</div>
<div class="file-dock-list">
<div
v-for="att in attachmentState.list.value"
:key="att.id"
class="file-chip"
:class="{ active: attachmentState.activeId.value === att.id }"
:title="`${att.name} · ${formatSize(att.size)}`"
@click="openPreview(att.id)"
>
<span class="file-chip-icon">{{ ICONS[att.kind] || '📎' }}</span>
<span class="file-chip-name">{{ att.name }}</span>
<span class="file-chip-size">{{ formatSize(att.size) }}</span>
<button
class="file-chip-del"
title="移除"
@click.stop="removeAttachment(att.id)"
>×</button>
</div>
</div>
</div>
</template>
<style scoped>
/* 附件坞:贴底居中悬浮条 */
.file-dock {
position: fixed;
left: 50%;
bottom: 16px;
transform: translateX(-50%);
z-index: 60;
max-width: min(80vw, 760px);
background: var(--ui-panel);
border: 1px solid var(--ui-border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 8px 10px;
}
.file-dock-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.file-dock-title {
font-size: 12px;
font-weight: 600;
color: var(--ui-muted);
}
.file-dock-clear {
border: none;
background: none;
color: var(--ui-muted);
font-size: 12px;
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-sm);
}
.file-dock-clear:hover { background: var(--ui-hover); color: var(--ui-danger); }
.file-dock-list {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
}
.file-chip {
display: flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
max-width: 220px;
padding: 6px 8px;
background: var(--ui-bg);
border: 1px solid var(--ui-border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color .15s, background .15s;
}
.file-chip:hover { border-color: var(--ui-primary); }
.file-chip.active {
border-color: var(--ui-primary);
background: var(--ui-primary-soft);
}
.file-chip-icon { font-size: 16px; line-height: 1; }
.file-chip-name {
font-size: 13px;
color: var(--ui-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-chip-size {
font-size: 11px;
color: var(--ui-muted);
flex: 0 0 auto;
}
.file-chip-del {
border: none;
background: none;
color: var(--ui-muted);
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 0 2px;
border-radius: 4px;
flex: 0 0 auto;
}
.file-chip-del:hover { color: var(--ui-danger); background: var(--ui-hover); }
</style>
+276
View File
@@ -0,0 +1,276 @@
<!-- =====================================================================
FilePreviewDrawer.vue 附件右侧抽屉预览
图片/视频直接 ObjectURL 预览PDF/DOCX 提取文本MD renderMd
其余类型展示元数据和下载双击内容或点击放大按钮可占满窗口
===================================================================== -->
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { attachmentState, closePreview, downloadAttachment, getAttachment, loadPreviewText } from '../../core/attachments'
import { renderMd } from '../../core/markdown'
const activeAttachment = computed(() => getAttachment(attachmentState.activeId.value))
const renderedMarkdown = computed(() => renderMd(activeAttachment.value?.text || ''))
/* 媒体 ObjectURL:每个附件只创建一份,切换/关闭/卸载时统一回收,防泄漏 */
const mediaUrl = ref('')
function revokeMedia() {
if (mediaUrl.value) { URL.revokeObjectURL(mediaUrl.value); mediaUrl.value = '' }
}
watch(() => attachmentState.activeId.value, () => {
revokeMedia()
const att = activeAttachment.value
if (att && (att.kind === 'image' || att.kind === 'video')) {
mediaUrl.value = URL.createObjectURL(att.file)
}
void loadPreviewText(att)
}, { immediate: true })
onBeforeUnmount(revokeMedia)
function toggleMaximize() {
attachmentState.maximized.value = !attachmentState.maximized.value
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (attachmentState.maximized.value) attachmentState.maximized.value = false
else closePreview()
}
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
</script>
<template>
<Teleport to="body">
<div
v-if="activeAttachment"
class="file-preview-mask"
:class="{ maximized: attachmentState.maximized.value }"
>
<aside class="file-preview-drawer" role="dialog" aria-modal="true" :aria-label="`${activeAttachment.name} 预览`">
<header class="file-preview-head">
<div class="file-preview-title" :title="activeAttachment.name">
<span class="file-preview-title-icon">📎</span>
<span>{{ activeAttachment.name }}</span>
</div>
<div class="file-preview-actions">
<button class="file-preview-btn" :title="attachmentState.maximized.value ? '退出放大' : '放大预览'" @click="toggleMaximize">
{{ attachmentState.maximized.value ? '↙' : '↗' }}
</button>
<button class="file-preview-btn close" title="关闭预览" @click="closePreview">×</button>
</div>
</header>
<main class="file-preview-body" @dblclick="toggleMaximize">
<!-- 图片 -->
<img
v-if="activeAttachment.kind === 'image'"
class="file-preview-image"
:src="mediaUrl"
:alt="activeAttachment.name"
/>
<!-- 视频 -->
<video
v-else-if="activeAttachment.kind === 'video'"
class="file-preview-video"
:src="mediaUrl"
controls
>你的浏览器不支持视频预览</video>
<!-- Markdown -->
<article
v-else-if="activeAttachment.kind === 'markdown' && activeAttachment.textState === 'done'"
class="file-preview-markdown"
v-html="renderedMarkdown"
/>
<!-- PDF / DOC / TXT统一纯文本阅读 -->
<pre
v-else-if="(activeAttachment.kind === 'pdf' || activeAttachment.kind === 'doc' || activeAttachment.kind === 'text') && activeAttachment.textState === 'done'"
class="file-preview-text"
>{{ activeAttachment.text }}</pre>
<!-- 文本加载 / 错误 -->
<div v-else-if="activeAttachment.textState === 'loading'" class="file-preview-status">正在提取文件内容</div>
<div v-else-if="activeAttachment.textState === 'error'" class="file-preview-status error">
预览读取失败{{ activeAttachment.textError }}
</div>
<!-- 无结构化预览的文件 -->
<div v-else class="file-preview-meta">
<span class="file-preview-meta-icon">📎</span>
<strong>{{ activeAttachment.name }}</strong>
<span>{{ activeAttachment.file.type || '未知文件类型' }}</span>
<span>{{ activeAttachment.size.toLocaleString() }} B</span>
<button class="file-preview-download" @click="downloadAttachment(activeAttachment)">下载文件</button>
</div>
</main>
<footer class="file-preview-foot">
<span>{{ activeAttachment.file.type || '未知类型' }}</span>
<button class="file-preview-download link" @click="downloadAttachment(activeAttachment)">下载</button>
</footer>
</aside>
</div>
</Teleport>
</template>
<style scoped>
.file-preview-mask {
position: fixed;
inset: 0;
z-index: 80;
pointer-events: none;
}
.file-preview-drawer {
position: absolute;
top: 0;
right: 0;
width: min(480px, 100vw);
height: 100%;
display: flex;
flex-direction: column;
background: var(--ui-panel);
border-left: 1px solid var(--ui-border);
box-shadow: var(--shadow-lg);
pointer-events: auto;
animation: drawer-enter .18s ease-out;
}
.file-preview-mask.maximized .file-preview-drawer {
width: 100vw;
border-left: none;
}
@keyframes drawer-enter {
from { transform: translateX(28px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.file-preview-head,
.file-preview-foot {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
padding: 10px 12px;
border-bottom: 1px solid var(--ui-border);
}
.file-preview-title {
display: flex;
align-items: center;
min-width: 0;
gap: 7px;
flex: 1;
color: var(--ui-text);
font-size: 14px;
font-weight: 600;
}
.file-preview-title span:last-child {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.file-preview-title-icon { font-size: 17px; }
.file-preview-actions { display: flex; gap: 4px; }
.file-preview-btn {
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--ui-muted);
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.file-preview-btn:hover { background: var(--ui-hover); color: var(--ui-text); }
.file-preview-btn.close:hover { color: var(--ui-danger); }
.file-preview-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
background: var(--ui-bg);
}
.file-preview-image,
.file-preview-video {
display: block;
width: 100%;
max-height: 100%;
object-fit: contain;
margin: auto;
}
.file-preview-image { min-height: 200px; }
.file-preview-text {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--ui-text);
font: 13px/1.7 var(--mono);
}
.file-preview-markdown {
color: var(--ui-text);
font-size: 14px;
line-height: 1.7;
overflow-wrap: anywhere;
}
.file-preview-markdown :deep(h1),
.file-preview-markdown :deep(h2),
.file-preview-markdown :deep(h3) { margin: 1.1em 0 .5em; }
.file-preview-markdown :deep(p),
.file-preview-markdown :deep(ul),
.file-preview-markdown :deep(ol) { margin: .6em 0; }
.file-preview-markdown :deep(pre) {
padding: 10px;
overflow: auto;
background: #0f172a;
color: #e2e8f0;
border-radius: var(--radius-sm);
}
.file-preview-markdown :deep(code) { font-family: var(--mono); }
.file-preview-status {
display: grid;
place-items: center;
height: 100%;
color: var(--ui-muted);
font-size: 14px;
text-align: center;
}
.file-preview-status.error { color: var(--ui-danger); }
.file-preview-meta {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 100%;
color: var(--ui-muted);
font-size: 13px;
text-align: center;
}
.file-preview-meta strong { color: var(--ui-text); overflow-wrap: anywhere; }
.file-preview-meta-icon { font-size: 44px; }
.file-preview-download {
padding: 7px 12px;
border: 1px solid var(--ui-primary);
border-radius: var(--radius-sm);
background: var(--ui-primary);
color: #fff;
font-size: 13px;
cursor: pointer;
}
.file-preview-download:hover { filter: brightness(.94); }
.file-preview-foot {
justify-content: space-between;
border-top: 1px solid var(--ui-border);
border-bottom: none;
color: var(--ui-muted);
font-size: 12px;
}
.file-preview-download.link {
padding: 3px 6px;
border: none;
background: transparent;
color: var(--ui-primary);
}
</style>
+6 -2
View File
@@ -6,6 +6,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import type { SlideElement, BgKey } from '../../core/types' import type { SlideElement, BgKey } from '../../core/types'
import { store, resolveColor, isDarkBg } from '../../core/store' import { store, resolveColor, isDarkBg } from '../../core/store'
import { resolveRef } from '../../core/assets'
import { segmentsToHtml, markdownToSegments, hasFormatting } from '../../core/richtext' import { segmentsToHtml, markdownToSegments, hasFormatting } from '../../core/richtext'
import ChartView from './ChartView.vue' import ChartView from './ChartView.vue'
@@ -85,6 +86,9 @@ const boxStyle = computed(() => {
const dataList = computed(() => (props.el.content || '').split('\n')) const dataList = computed(() => (props.el.content || '').split('\n'))
/** 媒体源解析:把 "asset:<id>" 引用解析为可渲染 URL(本地 ObjectURL 或云端 URL),其余原样 */
const mediaSrc = computed(() => resolveRef(props.el.content))
/** 表格解析 */ /** 表格解析 */
const tableRows = computed(() => { const tableRows = computed(() => {
const lines = (props.el.content || '').split('\n').map(l => l.trim()).filter(Boolean) const lines = (props.el.content || '').split('\n').map(l => l.trim()).filter(Boolean)
@@ -272,7 +276,7 @@ function onBlur(e: Event, field: string) {
<span class="el-image-empty-icon">🖼</span> <span class="el-image-empty-icon">🖼</span>
<span class="el-image-empty-text">拖入图片 · 属性面板本地图片 AI 配图</span> <span class="el-image-empty-text">拖入图片 · 属性面板本地图片 AI 配图</span>
</div> </div>
<img v-else class="el-image" :src="el.content" draggable="false" /> <img v-else class="el-image" :src="mediaSrc" draggable="false" />
</template> </template>
<!-- 视频缩略图端降级静态preload=metadata 控制加载开销 --> <!-- 视频缩略图端降级静态preload=metadata 控制加载开销 -->
@@ -290,7 +294,7 @@ function onBlur(e: Event, field: string) {
<video <video
v-else v-else
class="el-video" class="el-video"
:src="el.content" :src="mediaSrc"
:poster="s.poster || undefined" :poster="s.poster || undefined"
controls controls
preload="metadata" preload="metadata"
+17
View File
@@ -7,6 +7,7 @@ import { store } from '../../core/store'
import { elementTypes } from '../../core/sample' import { elementTypes } from '../../core/sample'
import { generateImage, isImageConfigured, checkImageQuota } from '../../core/ai' import { generateImage, isImageConfigured, checkImageQuota } from '../../core/ai'
import { readAsDataURL } from '../../core/importer' import { readAsDataURL } from '../../core/importer'
import { putAsset, isOssEnabled } from '../../core/assets'
import { appAlert, appConfirm, appPrompt } from '../../core/dialog' import { appAlert, appConfirm, appPrompt } from '../../core/dialog'
import { markdownToSegments, segmentsToPlain, hasFormatting } from '../../core/richtext' import { markdownToSegments, segmentsToPlain, hasFormatting } from '../../core/richtext'
import AddGrid from './AddGrid.vue' import AddGrid from './AddGrid.vue'
@@ -271,6 +272,12 @@ function onLocalImage() {
const file = input.files?.[0] const file = input.files?.[0]
if (!file) return if (!file) return
try { try {
// OSS 线content /URL 5MB
if (isOssEnabled()) {
const ref = await putAsset(file)
store.updateElement(el.id, { content: ref })
return
}
const dataUrl = await readAsDataURL(file) const dataUrl = await readAsDataURL(file)
const quotaErr = checkImageQuota(dataUrl) const quotaErr = checkImageQuota(dataUrl)
if (quotaErr) { appAlert('图片受限', quotaErr); return } if (quotaErr) { appAlert('图片受限', quotaErr); return }
@@ -292,6 +299,16 @@ function onLocalVideo() {
input.onchange = async () => { input.onchange = async () => {
const f = input.files?.[0] const f = input.files?.[0]
if (!f) return if (!f) return
// OSS
if (isOssEnabled()) {
try {
const ref = await putAsset(f)
store.updateElement(el.id, { content: ref })
} catch (e: any) {
appAlert('读取视频失败', e?.message || String(e))
}
return
}
// dataURL // dataURL
const dataUrl = await readAsDataURL(f) const dataUrl = await readAsDataURL(f)
store.updateElement(el.id, { content: dataUrl }) store.updateElement(el.id, { content: dataUrl })
+15 -2
View File
@@ -12,12 +12,15 @@ const emit = defineEmits<{
(e: 'present'): void (e: 'present'): void
(e: 'open-library'): void (e: 'open-library'): void
(e: 'open-settings'): void (e: 'open-settings'): void
(e: 'open-oss'): void
(e: 'save'): void (e: 'save'): void
(e: 'open-templates'): void (e: 'open-templates'): void
(e: 'export-json'): void (e: 'export-json'): void
(e: 'import-json'): void (e: 'import-json'): void
(e: 'import-materials'): void (e: 'import-materials'): void
(e: 'export-pdf'): void (e: 'export-pdf'): void
(e: 'home'): void
(e: 'share'): void
}>() }>()
const themeKeys = Object.keys(themes) const themeKeys = Object.keys(themes)
@@ -48,6 +51,7 @@ async function action(a: string) {
case 'library': emit('open-library'); break case 'library': emit('open-library'); break
case 'save': emit('save'); break case 'save': emit('save'); break
case 'settings': emit('open-settings'); break case 'settings': emit('open-settings'); break
case 'oss': emit('open-oss'); break
} }
} }
@@ -68,6 +72,7 @@ function fileAction(a: string) {
case 'import-materials': emit('import-materials'); break case 'import-materials': emit('import-materials'); break
case 'import-json': emit('import-json'); break case 'import-json': emit('import-json'); break
case 'export-pdf': emit('export-pdf'); break case 'export-pdf': emit('export-pdf'); break
case 'share': emit('share'); break
case 'reset': action('reset'); break case 'reset': action('reset'); break
} }
} }
@@ -95,11 +100,11 @@ defineProps<{ disabledActions?: string[] }>()
<template> <template>
<header class="toolbar"> <header class="toolbar">
<div class="brand"> <button class="brand" title="返回首页" @click="emit('home')">
<span class="logo"></span> <span class="logo"></span>
<span class="name">u-ppt</span> <span class="name">u-ppt</span>
<span class="sub">在线演示工具</span> <span class="sub">在线演示工具</span>
</div> </button>
<div class="tools"> <div class="tools">
<!-- 高频页面操作 --> <!-- 高频页面操作 -->
@@ -145,6 +150,9 @@ defineProps<{ disabledActions?: string[] }>()
<button class="menu-item" role="menuitem" @click="fileAction('export-pdf')"> <button class="menu-item" role="menuitem" @click="fileAction('export-pdf')">
<span class="mi-icon">🖨</span><span class="mi-label">导出 PDF</span><span class="mi-hint">打印 / 另存</span> <span class="mi-icon">🖨</span><span class="mi-label">导出 PDF</span><span class="mi-hint">打印 / 另存</span>
</button> </button>
<button class="menu-item" role="menuitem" @click="fileAction('share')">
<span class="mi-icon">🔗</span><span class="mi-label">生成分享链接</span><span class="mi-hint">公开只读 · 云端</span>
</button>
<div class="menu-sep"></div> <div class="menu-sep"></div>
<button class="menu-item danger" role="menuitem" :disabled="disabledActions?.includes('reset')" @click="fileAction('reset')"> <button class="menu-item danger" role="menuitem" :disabled="disabledActions?.includes('reset')" @click="fileAction('reset')">
<span class="mi-icon"></span><span class="mi-label">重置为示例</span> <span class="mi-icon"></span><span class="mi-label">重置为示例</span>
@@ -155,12 +163,17 @@ defineProps<{ disabledActions?: string[] }>()
<!-- 终端动作 --> <!-- 终端动作 -->
<button class="btn primary" data-action="present" title="开始演示 (F5)" :disabled="disabledActions?.includes('present')" @click="action('present')"> 演示</button> <button class="btn primary" data-action="present" title="开始演示 (F5)" :disabled="disabledActions?.includes('present')" @click="action('present')"> 演示</button>
<button class="btn ghost icon-only" data-action="oss" title="云存储 OSS 设置" @click="action('oss')"></button>
<button class="btn ghost icon-only" data-action="settings" title="AI 设置" @click="action('settings')"></button> <button class="btn ghost icon-only" data-action="settings" title="AI 设置" @click="action('settings')"></button>
</div> </div>
</header> </header>
</template> </template>
<style scoped> <style scoped>
/* 品牌区按钮化:去掉 button 默认外观,保留原有视觉,hover 轻反馈 */
.brand { border: none; background: transparent; padding: 0; font: inherit; text-align: left; cursor: pointer; }
.brand:hover { opacity: .8; }
/* 新建幻灯片:创作起点,给主色轻底强调 */ /* 新建幻灯片:创作起点,给主色轻底强调 */
.tool-add { background: var(--ui-primary-soft); border-color: transparent; color: var(--ui-primary); font-weight: 500; } .tool-add { background: var(--ui-primary-soft); border-color: transparent; color: var(--ui-primary); font-weight: 500; }
.tool-add:hover { background: #e0e7ff; } .tool-add:hover { background: #e0e7ff; }
+34 -12
View File
@@ -14,6 +14,7 @@ import {
type FileEntry, type ImportReport, type FileEntry, type ImportReport,
describeReport describeReport
} from '../../core/importer' } from '../../core/importer'
import { putAsset, isOssEnabled } from '../../core/assets'
const props = withDefaults(defineProps<{ visible: boolean }>(), { visible: false }) const props = withDefaults(defineProps<{ visible: boolean }>(), { visible: false })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -65,7 +66,7 @@ function openFilePicker() {
fileInput.type = 'file' fileInput.type = 'file'
fileInput.multiple = true fileInput.multiple = true
fileInput.accept = ACCEPT_ATTR fileInput.accept = ACCEPT_ATTR
fileInput.onchange = () => handleFiles(fileInput!.files) fileInput.onchange = () => handleFiles(fileInput!.files ? Array.from(fileInput!.files) : null)
} }
fileInput.value = '' fileInput.value = ''
fileInput.click() fileInput.click()
@@ -76,7 +77,7 @@ function openDirPicker() {
dirInput = document.createElement('input') dirInput = document.createElement('input')
dirInput.type = 'file' dirInput.type = 'file'
;(dirInput as any).webkitdirectory = true ;(dirInput as any).webkitdirectory = true
dirInput.onchange = () => handleFiles(dirInput!.files) dirInput.onchange = () => handleFiles(dirInput!.files ? Array.from(dirInput!.files) : null)
} }
dirInput.value = '' dirInput.value = ''
dirInput.click() dirInput.click()
@@ -98,17 +99,17 @@ function onDropzoneDrop(e: DragEvent) {
e.preventDefault() e.preventDefault()
dragOver.value = false dragOver.value = false
const fl = e.dataTransfer?.files const fl = e.dataTransfer?.files
if (fl && fl.length > 0) void handleFiles(fl) if (fl && fl.length > 0) void handleFiles(Array.from(fl))
} }
/** 供父组件预填拖入的文件(全局拖放 → 打开弹窗并直接进入预览态) */ /** 供父组件预填拖入的文件(全局拖放 → 打开弹窗并直接进入预览态) */
function acceptDroppedFiles(fl: FileList) { function acceptDroppedFiles(fl: File[]) {
void handleFiles(fl) void handleFiles(fl)
} }
defineExpose({ acceptDroppedFiles }) defineExpose({ acceptDroppedFiles })
async function handleFiles(fl: FileList | null) { async function handleFiles(fl: File[] | null) {
if (!fl || fl.length === 0) return if (!fl || fl.length === 0) return
loading.value = true loading.value = true
loaded.value = false loaded.value = false
@@ -167,7 +168,7 @@ async function runAiAnalysis() {
} }
/* ---------- 执行导入 ---------- */ /* ---------- 执行导入 ---------- */
function doImport() { async function doImport() {
const images = imageEntries.value.filter(e => e.data) const images = imageEntries.value.filter(e => e.data)
const videos = videoEntries.value.filter(e => e.data) const videos = videoEntries.value.filter(e => e.data)
const docs = docEntries.value.filter(e => e.slides && e.slides.length > 0) const docs = docEntries.value.filter(e => e.slides && e.slides.length > 0)
@@ -188,20 +189,40 @@ function doImport() {
const batch = store.beginBatch() const batch = store.beginBatch()
// localStorage 5MB // localStorage 5MB
if (images.length > 0) { // OSS localStorage
if (images.length > 0 && !isOssEnabled()) {
const imgChars = images.reduce((s, e) => s + (e.data?.length || 0), 0) const imgChars = images.reduce((s, e) => s + (e.data?.length || 0), 0)
let deckChars = 0 let deckChars = 0
try { deckChars = JSON.stringify(store.getDeck()).length } catch (e) { /* ignore */ } try { deckChars = JSON.stringify(store.getDeck()).length } catch (e) { /* ignore */ }
const QUOTA_CHARS = 4_500_000 const QUOTA_CHARS = 4_500_000
if (deckChars + imgChars > QUOTA_CHARS) { if (deckChars + imgChars > QUOTA_CHARS) {
emit('toast', '图片过大:导入后约 ' + ((deckChars + imgChars) / 1048576).toFixed(1) + 'MB,超本地存储上限(约 5MB),刷新可能丢失。请压缩图片、减少数量,或先清理文库/旧图') emit('toast', '图片过大:导入后约 ' + ((deckChars + imgChars) / 1048576).toFixed(1) + 'MB,超本地存储上限(约 5MB),刷新可能丢失。请压缩图片、减少数量,或在 ☁ 云存储中启用 OSS')
return return
} }
} }
// 1. // 1. OSS content 5MB
if (images.length > 0) { if (images.length > 0) {
if (images.length === 1) { if (isOssEnabled()) {
if (images.length === 1) {
const ref = await putAsset(images[0].file)
const el = fileToImageElement(images[0].file, ref)
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
insertedImages = 1
} else {
const els = await Promise.all(images.map(async f => {
const ref = await putAsset(f.file)
return fileToImageElement(f.file, ref)
}))
// dataUrl /
const grid = imagesToSlideElements(images.map(f => ({ file: f.file, dataUrl: '' })))
for (let i = 0; i < els.length; i++) {
els[i].x = grid[i].x; els[i].y = grid[i].y; els[i].w = grid[i].w; els[i].h = grid[i].h
store.addElement('image', { content: els[i].content, x: els[i].x, y: els[i].y, w: els[i].w, h: els[i].h, style: els[i].style }, batch)
insertedImages++
}
}
} else if (images.length === 1) {
const el = fileToImageElement(images[0].file, images[0].data!) const el = fileToImageElement(images[0].file, images[0].data!)
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch) store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
insertedImages = 1 insertedImages = 1
@@ -214,10 +235,11 @@ function doImport() {
} }
} }
// 1.5 // 1.5 OSS
if (videos.length > 0) { if (videos.length > 0) {
for (const v of videos) { for (const v of videos) {
const el = fileToVideoElement(v.file, v.data!) const content = isOssEnabled() ? await putAsset(v.file) : v.data!
const el = fileToVideoElement(v.file, content)
if (videos.length === 1) { if (videos.length === 1) {
store.addElement('video', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch) store.addElement('video', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
insertedImages++ insertedImages++
+197
View File
@@ -0,0 +1,197 @@
<!-- =====================================================================
OssSettingsModal.vue 云存储OSS设置弹窗
所有媒体资产上云避免撑爆本地存储离线暂存本地联网自动同步
===================================================================== -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { OssCfg } from '../../core/types'
import { store } from '../../core/store'
import { syncState, syncPending, canUploadNow } from '../../core/assets'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
}>()
const form = ref<OssCfg>({
enabled: false, provider: 'aliyun', dir: 'u-ppt',
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: '',
viewerBase: ''
})
function loadFromStore() {
form.value = { ...form.value, ...store.getOssCfg() }
}
watch(() => props.visible, (v) => {
if (v) loadFromStore()
}, { immediate: true })
function save() {
const c = form.value
store.setOssCfg({
enabled: c.enabled,
provider: c.provider,
dir: c.dir.trim(),
aliAccessKeyId: c.aliAccessKeyId.trim(),
aliAccessKeySecret: c.aliAccessKeySecret.trim(),
aliEndpoint: c.aliEndpoint.trim(),
aliBucket: c.aliBucket.trim(),
qiniuAccessKey: c.qiniuAccessKey.trim(),
qiniuSecretKey: c.qiniuSecretKey.trim(),
qiniuBucket: c.qiniuBucket.trim(),
qiniuUpHost: c.qiniuUpHost.trim(),
qiniuDomain: c.qiniuDomain.trim(),
viewerBase: c.viewerBase.trim()
})
emit('toast', c.enabled ? '已保存云存储设置(已启用)' : '已保存云存储设置(未启用)')
emit('close')
}
async function manualSync() {
if (!canUploadNow()) {
emit('toast', '当前不可同步:需桌面版 + 已联网 + 已启用并保存配置')
return
}
const r = await syncPending()
emit('toast', `同步完成:成功 ${r.done},失败 ${r.fail}`)
}
</script>
<template>
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal">
<h3>云存储OSS设置</h3>
<p class="modal-tip">
开启后图片/视频等媒体自动上传对象存储避免撑爆本地离线时先暂存本地联网后自动同步云端密钥仅保存在本地
</p>
<div class="form-row">
<label>启用云存储</label>
<label class="switch-line">
<input type="checkbox" v-model="form.enabled" />
<span>{{ form.enabled ? '开' : '关(维持本地内嵌)' }}</span>
</label>
</div>
<div class="form-row">
<label>服务商</label>
<select v-model="form.provider">
<option value="aliyun">阿里云 OSS</option>
<option value="qiniu">七牛云 Kodo</option>
</select>
</div>
<div class="form-row">
<label>所属目录</label>
<input type="text" v-model="form.dir" placeholder="如 u-ppt/images,可留空" />
</div>
<div class="form-row">
<label>查看器地址</label>
<input type="text" v-model="form.viewerBase" placeholder="如 https://img.1216.top,生成分享链接必填" />
</div>
<!-- 阿里云 -->
<template v-if="form.provider === 'aliyun'">
<div class="section-title">阿里云 OSS</div>
<div class="form-row">
<label>AccessKeyId</label>
<input type="text" v-model="form.aliAccessKeyId" placeholder="LTAI..." autocomplete="off" />
</div>
<div class="form-row">
<label>AccessKeySecret</label>
<input type="password" v-model="form.aliAccessKeySecret" placeholder="密钥" autocomplete="off" />
</div>
<div class="form-row">
<label>Endpoint</label>
<input type="text" v-model="form.aliEndpoint" placeholder="oss-cn-hangzhou.aliyuncs.com" />
</div>
<div class="form-row">
<label>Bucket</label>
<input type="text" v-model="form.aliBucket" placeholder="bucket 名称" />
</div>
</template>
<!-- 七牛云 -->
<template v-else>
<div class="section-title">七牛云 Kodo</div>
<div class="form-row">
<label>AccessKey</label>
<input type="text" v-model="form.qiniuAccessKey" placeholder="AK" autocomplete="off" />
</div>
<div class="form-row">
<label>SecretKey</label>
<input type="password" v-model="form.qiniuSecretKey" placeholder="SK" autocomplete="off" />
</div>
<div class="form-row">
<label>Bucket</label>
<input type="text" v-model="form.qiniuBucket" placeholder="空间名称" />
</div>
<div class="form-row">
<label>加速域名</label>
<input type="text" v-model="form.qiniuDomain" placeholder="https://cdn.example.com" />
</div>
<div class="form-row">
<label>上传域名可选</label>
<input type="text" v-model="form.qiniuUpHost" placeholder="留空自动探测区域" />
</div>
</template>
<div class="sync-bar">
<div class="sync-info">
<div>
待同步 {{ syncState.pending }} <template v-if="syncState.syncing">同步中</template>
<span v-if="syncState.lastError" class="sync-err">· 有错误</span>
</div>
<div v-if="syncState.lastError" class="sync-err-detail">{{ syncState.lastError }}</div>
</div>
<button class="btn" @click="manualSync" :disabled="syncState.syncing">立即同步</button>
</div>
<div class="modal-actions">
<button class="btn" @click="emit('close')">取消</button>
<button class="btn primary" @click="save">保存</button>
</div>
</div>
</div>
</template>
<style scoped>
.section-title {
margin: 18px 0 10px;
padding-top: 14px;
border-top: 1px solid var(--ui-border);
font-size: 13px;
font-weight: 600;
color: var(--ui-text);
}
.switch-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ui-muted);
}
.switch-line input { width: auto; }
.sync-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--ui-border);
}
.sync-info { font-size: 12px; color: var(--ui-muted); }
.sync-err { color: #e5484d; }
.sync-err-detail {
margin-top: 4px;
max-width: 420px;
word-break: break-all;
font-size: 12px;
line-height: 1.5;
color: #e5484d;
}
</style>
+27 -3
View File
@@ -34,7 +34,8 @@ const PROTO_DEFAULTS: Record<string, { base: string; model: string }> = {
const form = ref<AiCfg>({ const form = ref<AiCfg>({
preset: 'zhipu', protocol: 'openai', preset: 'zhipu', protocol: 'openai',
base: '', key: '', model: '', proxy: '', base: '', key: '', model: '', proxy: '',
imgBase: '', imgKey: '', imgModel: '' imgBase: '', imgKey: '', imgModel: '',
relayUrl: '', relayToken: '', relayDeviceId: ''
}) })
/** 从 store 读取并填充表单 */ /** 从 store 读取并填充表单 */
@@ -46,7 +47,10 @@ function loadFromStore() {
base: c.base, key: c.key, model: c.model, proxy: c.proxy, base: c.base, key: c.key, model: c.model, proxy: c.proxy,
imgBase: c.imgBase || '', imgBase: c.imgBase || '',
imgKey: c.imgKey || '', imgKey: c.imgKey || '',
imgModel: c.imgModel || '' imgModel: c.imgModel || '',
relayUrl: c.relayUrl || '',
relayToken: c.relayToken || '',
relayDeviceId: c.relayDeviceId || ''
} }
} }
@@ -87,7 +91,10 @@ function save() {
proxy: form.value.proxy.trim(), proxy: form.value.proxy.trim(),
imgBase: (form.value.imgBase || '').trim(), imgBase: (form.value.imgBase || '').trim(),
imgKey: (form.value.imgKey || '').trim(), imgKey: (form.value.imgKey || '').trim(),
imgModel: (form.value.imgModel || '').trim() imgModel: (form.value.imgModel || '').trim(),
relayUrl: (form.value.relayUrl || '').trim(),
relayToken: (form.value.relayToken || '').trim(),
relayDeviceId: (form.value.relayDeviceId || '').trim()
}) })
const label = form.value.preset === 'custom' const label = form.value.preset === 'custom'
? (form.value.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') + ' 自定义' ? (form.value.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') + ' 自定义'
@@ -172,6 +179,23 @@ function save() {
<input type="text" v-model="form.imgModel" placeholder="dall-e-3" /> <input type="text" v-model="form.imgModel" placeholder="dall-e-3" />
</div> </div>
<div class="section-title">Agent 中继可选三项齐备即启用 Agent 模式</div>
<div class="form-row">
<label>中继 URL</label>
<input type="text" v-model="form.relayUrl" placeholder="wss://..." />
</div>
<div class="form-row">
<label>中继 Token</label>
<input type="password" v-model="form.relayToken" placeholder="设备配对 Token" autocomplete="off" />
</div>
<div class="form-row">
<label>设备 ID</label>
<input type="text" v-model="form.relayDeviceId" placeholder="device_id" />
</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn" @click="emit('close')">取消</button> <button class="btn" @click="emit('close')">取消</button>
<button class="btn primary" @click="save">保存</button> <button class="btn primary" @click="save">保存</button>
+39
View File
@@ -547,6 +547,45 @@ export async function chat(opts: {
return { reply: r.reply, op: normalizeOp(r.op) } return { reply: r.reply, op: normalizeOp(r.op) }
} }
/** SEP 分隔标记(对话回复与 JSON 操作的分隔),导出给 relay 等 Transport 复用 */
export const CHAT_SEP = SEP
/**
* chat SEP SEP JSON
* AiPanelSSE AgentPanelrelay
*/
export function parseChatReply(text: string): { reply: string; op: AiOp | null } {
const parts = text.split(SEP)
return {
reply: (parts[0] || '').trim(),
op: parts.length > 1 ? normalizeOp(tryParse(parts.slice(1).join(SEP))) : null
}
}
/**
* Agent prompt + chat + deck
* deck >700KB JSON + deck大纲摘要 1MiB
*/
export function buildAgentPrompt(input: string): string {
const SYS_AGENT =
SYS_BASE +
'\n任务:你是通过中继接入的远程 Agent。根据用户指令编辑当前演示。\n' +
'回复格式:先用中文说明你将做什么,如需修改 PPT,在回复最后另起一行输出分隔标记 ' + SEP + ',紧随其后输出 JSON 操作。\n' +
'JSON 操作格式:{"action":"add_page|update_page|create_all|answer","slides":[...],"target":页码(从1开始,可选)}\n' +
'- add_page:在 target 页后插入新页;- update_page:替换 target 页;- create_all:整体替换;- answer:仅回答不改稿。\n' +
'没有改动时不要输出分隔标记。不要使用 markdown 代码块。'
const deck = store.getDeck()
let body: string
const full = JSON.stringify(deck)
if (full.length > 700 * 1024) {
// 超大 deck 降级:大纲摘要 + 当前页完整 JSON
body = deckContext(store.getCurrentIndex(), null)
} else {
body = '完整 deck JSON\n' + full + '\n当前页码:第 ' + (store.getCurrentIndex() + 1) + ' 页'
}
return SYS_AGENT + '\n\n' + body + '\n\n用户指令:' + input
}
function normalizeOp(json: any): AiOp | null { function normalizeOp(json: any): AiOp | null {
if (!json) return null if (!json) return null
let action: AiOp['action'] = json.action || 'answer' let action: AiOp['action'] = json.action || 'answer'
+276
View File
@@ -0,0 +1,276 @@
/* =====================================================================
* assets.ts // OSS localStorage
*
*
* - deck element.content base64
* · "asset:<id>" 线 IndexedDB
* · "https://..." 访
* · data:/blob:/http URL deck
* - blob IndexedDB localStorage 5MB
* - 线 OSS + OSS URL
* - URL Vue reactive "asset:<id>" ObjectURL hydrate
* ===================================================================== */
import { reactive } from 'vue'
import { store } from './store'
import { ossUpload, isTauri, type OssUploadReq } from './bridge'
import type { OssCfg } from './types'
const DB_NAME = 'u-ppt-assets'
const STORE = 'assets'
const DB_VER = 1
/** IndexedDB 资产记录 */
interface AssetRecord {
id: string
blob: Blob
name: string
type: string
/** 目标对象 key(dir 前缀 + 日期 + 随机名 + 扩展名),同步时上传到此 key */
key: string
/** pending=待上传;synced=已在云端 */
status: 'pending' | 'synced'
/** 同步后的公开 URL */
url?: string
createdAt: number
}
/** 引用 → 可访问 URL 的响应式映射(供渲染同步读取;pending 用 ObjectURLsynced 用真实 URL */
const refUrls = reactive<Record<string, string>>({})
/** 同步状态(供 UI 展示) */
export const syncState = reactive({ pending: 0, syncing: false, lastError: '' })
let _db: IDBDatabase | null = null
let _dbPromise: Promise<IDBDatabase> | null = null
function openDB(): Promise<IDBDatabase> {
if (_db) return Promise.resolve(_db)
if (_dbPromise) return _dbPromise
_dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VER)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: 'id' })
}
}
req.onsuccess = () => { _db = req.result; resolve(_db) }
req.onerror = () => reject(req.error || new Error('IndexedDB 打开失败'))
})
return _dbPromise
}
function tx(db: IDBDatabase, mode: IDBTransactionMode): IDBObjectStore {
return db.transaction(STORE, mode).objectStore(STORE)
}
function idbGet(id: string): Promise<AssetRecord | undefined> {
return openDB().then(db => new Promise((resolve, reject) => {
const r = tx(db, 'readonly').get(id)
r.onsuccess = () => resolve(r.result as AssetRecord | undefined)
r.onerror = () => reject(r.error)
}))
}
function idbPut(rec: AssetRecord): Promise<void> {
return openDB().then(db => new Promise((resolve, reject) => {
const r = tx(db, 'readwrite').put(rec)
r.onsuccess = () => resolve()
r.onerror = () => reject(r.error)
}))
}
function idbAll(): Promise<AssetRecord[]> {
return openDB().then(db => new Promise((resolve, reject) => {
const r = tx(db, 'readonly').getAll()
r.onsuccess = () => resolve((r.result || []) as AssetRecord[])
r.onerror = () => reject(r.error)
}))
}
/** 生成唯一 id */
function genId(): string {
return 'a' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
/** 从文件名/MIME 推断扩展名 */
function extOf(name: string, type: string): string {
const m = /\.([a-zA-Z0-9]{1,6})$/.exec(name || '')
if (m) return m[1].toLowerCase()
const map: Record<string, string> = {
'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif',
'image/webp': 'webp', 'image/svg+xml': 'svg',
'video/mp4': 'mp4', 'video/webm': 'webm', 'application/pdf': 'pdf'
}
return map[type] || 'bin'
}
/** 拼目标对象 key<dir>/YYYY/MM/<id>.<ext>dir 为空则省略) */
function buildKey(cfg: OssCfg, id: string, ext: string): string {
const d = new Date()
const ym = `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}`
const dir = (cfg.dir || '').trim().replace(/^\/+|\/+$/g, '')
const parts = [dir, ym, `${id}.${ext}`].filter(Boolean)
return parts.join('/')
}
/** Blob → 标准 base64(去掉 dataURL 前缀) */
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const s = reader.result as string
const i = s.indexOf(',')
resolve(i >= 0 ? s.slice(i + 1) : s)
}
reader.onerror = () => reject(reader.error || new Error('读取文件失败'))
reader.readAsDataURL(blob)
})
}
/** 由 OSS 配置构造上传请求(不含 key/data) */
export function buildUploadReq(cfg: OssCfg, key: string, dataBase64: string, contentType: string): OssUploadReq {
if (cfg.provider === 'aliyun') {
return {
provider: 'aliyun', key, dataBase64, contentType,
accessKeyId: cfg.aliAccessKeyId, accessKeySecret: cfg.aliAccessKeySecret,
endpoint: cfg.aliEndpoint, bucket: cfg.aliBucket
}
}
return {
provider: 'qiniu', key, dataBase64, contentType,
accessKey: cfg.qiniuAccessKey, secretKey: cfg.qiniuSecretKey,
bucket: cfg.qiniuBucket, upHost: cfg.qiniuUpHost, domain: cfg.qiniuDomain
}
}
/** 云端上传可用:配置已启用 + 桌面环境 + 在线 */
export function canUploadNow(): boolean {
const cfg = store.getOssCfg()
return cfg.enabled && isTauri() && (typeof navigator === 'undefined' || navigator.onLine)
}
/** OSS 是否启用(决定图片/视频是否走资产库) */
export function isOssEnabled(): boolean {
return store.getOssCfg().enabled
}
/**
* element.content
* - https URL
* - IndexedDBpending "asset:<id>" ObjectURL
*/
export async function putAsset(file: Blob, filename?: string): Promise<string> {
const cfg = store.getOssCfg()
const id = genId()
const name = filename || (file as File).name || id
const type = file.type || 'application/octet-stream'
const ext = extOf(name, type)
const key = buildKey(cfg, id, ext)
const rec: AssetRecord = { id, blob: file, name, type, key, status: 'pending', createdAt: Date.now() }
await idbPut(rec)
const ref = `asset:${id}`
// 立即登记 ObjectURL,让离线态也能马上渲染
refUrls[ref] = URL.createObjectURL(file)
if (canUploadNow()) {
try {
const base64 = await blobToBase64(file)
const url = await ossUpload(buildUploadReq(cfg, key, base64, type))
if (url) {
rec.status = 'synced'; rec.url = url
await idbPut(rec)
// 已上云:释放临时 URL,直接返回真实 URL 作为引用
const tmp = refUrls[ref]; delete refUrls[ref]
if (tmp) URL.revokeObjectURL(tmp)
return url
}
} catch (e: any) {
// 上传失败 → 保持 pending,后续同步重试
syncState.lastError = e?.message || String(e)
}
}
refreshPendingCount()
return ref
}
/** 同步解析引用为可渲染 URL:asset: 引用查映射,其余(http/data/blob)原样返回 */
export function resolveRef(ref: string | undefined | null): string {
if (!ref) return ''
if (ref.startsWith('asset:')) return refUrls[ref] || ''
return ref
}
/** 启动时 hydrate:为所有 pending 资产重建 ObjectURL,让离线暂存的媒体重新可见 */
export async function hydrate(): Promise<void> {
try {
const all = await idbAll()
for (const rec of all) {
const ref = `asset:${rec.id}`
if (rec.status === 'synced' && rec.url) {
// 已同步的历史引用仍可能残留在旧 deck 中,映射到真实 URL 兜底
refUrls[ref] = rec.url
} else if (!refUrls[ref]) {
refUrls[ref] = URL.createObjectURL(rec.blob)
}
}
refreshPendingCount()
// 监听联网事件自动同步
if (typeof window !== 'undefined') {
window.addEventListener('online', () => { void syncPending() })
}
// 启动若已具备条件,尝试同步一次
if (canUploadNow()) void syncPending()
} catch (e: any) {
syncState.lastError = e?.message || String(e)
}
}
async function refreshPendingCount(): Promise<void> {
try {
const all = await idbAll()
syncState.pending = all.filter(r => r.status === 'pending').length
} catch { /* ignore */ }
}
/**
* deck "asset:<id>" URL
* 线/
*/
export async function syncPending(): Promise<{ done: number; fail: number }> {
if (syncState.syncing) return { done: 0, fail: 0 }
if (!canUploadNow()) return { done: 0, fail: 0 }
const cfg = store.getOssCfg()
syncState.syncing = true
syncState.lastError = ''
let done = 0, fail = 0
try {
const all = await idbAll()
const pending = all.filter(r => r.status === 'pending')
for (const rec of pending) {
try {
const base64 = await blobToBase64(rec.blob)
const url = await ossUpload(buildUploadReq(cfg, rec.key, base64, rec.type))
if (!url) { fail++; continue }
rec.status = 'synced'; rec.url = url
await idbPut(rec)
const ref = `asset:${rec.id}`
// 回写 deck 引用;释放临时 ObjectURL
store.rewriteAssetRef(ref, url)
const tmp = refUrls[ref]
refUrls[ref] = url
if (tmp && tmp.startsWith('blob:')) URL.revokeObjectURL(tmp)
done++
} catch (e: any) {
fail++
syncState.lastError = e?.message || String(e)
}
}
} finally {
syncState.syncing = false
await refreshPendingCount()
}
return { done, fail }
}
+159
View File
@@ -0,0 +1,159 @@
/* =====================================================================
* attachments.ts dialog.ts
*
* /
*
* - deck / localStorage
* - ObjectURL /remove/clear revoke
* - PDF/DOCX/MD/TXT
* ===================================================================== */
import { ref } from 'vue'
import { extractPdfText, extractDocxText } from './importer'
/** 附件预览类型(决定抽屉渲染方式) */
export type PreviewKind = 'image' | 'video' | 'pdf' | 'markdown' | 'text' | 'doc' | 'meta'
/** 会话态附件 */
export interface Attachment {
id: number
file: File
name: string
ext: string
size: number
/** 预览分类 */
kind: PreviewKind
/** 懒加载的提取文本(pdf/doc/md/txt 预览用) */
text?: string
/** 文本提取状态 */
textState: 'none' | 'loading' | 'done' | 'error'
textError?: string
}
/* ---------- 模块级响应式状态(FileDock / FilePreviewDrawer 消费) ---------- */
export const attachmentState = {
/** 附件列表 */
list: ref<Attachment[]>([]),
/** 当前预览的附件 id(null = 抽屉关闭) */
activeId: ref<number | null>(null),
/** 预览放大占满窗口 */
maximized: ref(false)
}
let nextId = 1
/** 扩展名 → 预览类型映射 */
const KIND_BY_EXT: Record<string, PreviewKind> = {
'.png': 'image', '.jpg': 'image', '.jpeg': 'image', '.gif': 'image',
'.webp': 'image', '.svg': 'image', '.bmp': 'image', '.ico': 'image',
'.pdf': 'pdf',
'.md': 'markdown', '.markdown': 'markdown',
'.txt': 'text', '.text': 'text',
'.doc': 'doc', '.docx': 'doc'
}
/** 取小写扩展名(含点;无扩展名为空串) */
export function getExt(name: string): string {
const dot = name.lastIndexOf('.')
return dot >= 0 ? name.slice(dot).toLowerCase() : ''
}
/** 文件 → 预览类型:扩展名优先,MIME(video/*)兜底,其余回落 meta(仅元数据+下载) */
export function getPreviewKind(file: File): PreviewKind {
const byExt = KIND_BY_EXT[getExt(file.name)]
if (byExt) return byExt
if (file.type.startsWith('video/')) return 'video'
if (file.type.startsWith('image/')) return 'image'
if (file.type === 'text/plain') return 'text'
if (file.type === 'application/pdf') return 'pdf'
return 'meta'
}
/** 人性化文件大小 */
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
/** 加入附件坞(仅会话态,不落盘) */
export function addFiles(files: File[] | FileList): number {
let added = 0
for (const file of Array.from(files)) {
attachmentState.list.value.push({
id: nextId++,
file,
name: file.name || `粘贴文件-${nextId}`,
ext: getExt(file.name),
size: file.size,
kind: getPreviewKind(file),
textState: 'none'
})
added++
}
return added
}
/** 按类型查找附件 */
export function getAttachment(id: number | null): Attachment | null {
if (id == null) return null
return attachmentState.list.value.find(a => a.id === id) ?? null
}
/** 移除单个附件(若是当前预览项则顺带关抽屉) */
export function removeAttachment(id: number) {
const idx = attachmentState.list.value.findIndex(a => a.id === id)
if (idx < 0) return
attachmentState.list.value.splice(idx, 1)
if (attachmentState.activeId.value === id) {
attachmentState.activeId.value = null
attachmentState.maximized.value = false
}
}
/** 清空附件坞 */
export function clearAttachments() {
attachmentState.list.value = []
attachmentState.activeId.value = null
attachmentState.maximized.value = false
}
/** 打开抽屉预览 */
export function openPreview(id: number) {
attachmentState.activeId.value = id
attachmentState.maximized.value = false
void loadPreviewText(getAttachment(id))
}
/** 关闭抽屉 */
export function closePreview() {
attachmentState.activeId.value = null
attachmentState.maximized.value = false
}
/** 懒加载预览文本(复用 importer 的 PDF/DOCX 提取),结果缓存在附件对象上 */
export async function loadPreviewText(att: Attachment | null): Promise<void> {
if (!att) return
if (att.textState !== 'none') return
if (att.kind !== 'pdf' && att.kind !== 'doc' && att.kind !== 'markdown' && att.kind !== 'text') return
att.textState = 'loading'
try {
if (att.kind === 'pdf') att.text = await extractPdfText(att.file)
else if (att.kind === 'doc') att.text = await extractDocxText(att.file)
else att.text = await att.file.text()
att.textState = 'done'
} catch (e: any) {
att.textState = 'error'
att.textError = e?.message || String(e)
}
}
/** 下载附件(meta 类型兜底出口,也供常规预览手动另存) */
export function downloadAttachment(att: Attachment) {
const url = URL.createObjectURL(att.file)
const a = document.createElement('a')
a.href = url
a.download = att.name
a.click()
URL.revokeObjectURL(url)
}
+93
View File
@@ -0,0 +1,93 @@
/* =====================================================================
* bg.ts //
* viewer store
* ===================================================================== */
import { themes } from './sample'
/* ---------- 颜色工具 ---------- */
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const c = String(hex).replace('#', '')
const full = c.length === 3 ? c[0] + c[0] + c[1] + c[1] + c[2] + c[2] : c
const r = parseInt(full.substr(0, 2), 16)
const g = parseInt(full.substr(2, 2), 16)
const b = parseInt(full.substr(4, 2), 16)
return (isNaN(r) || isNaN(g) || isNaN(b)) ? null : { r, g, b }
}
export function isDarkHex(hex: string): boolean {
const rgb = hexToRgb(hex); if (!rgb) return false
return (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) < 145
}
export function shade(hex: string, pct: number): string {
const rgb = hexToRgb(hex); if (!rgb) return hex
const f = pct < 0 ? 0 : 255, p = Math.abs(pct) / 100
const r = Math.round((f - rgb.r) * p + rgb.r)
const g = Math.round((f - rgb.g) * p + rgb.g)
const b = Math.round((f - rgb.b) * p + rgb.b)
return '#' + [r, g, b].map(x => { const s = x.toString(16); return s.length < 2 ? '0' + s : s }).join('')
}
export function isValidHex(s: string): boolean {
return typeof s === 'string' && /^#[0-9a-f]{3,8}$/i.test(s)
}
/* ---------- 自定义主题读取(与 store 共用同一 localStorage 键) ---------- */
export const CUSTOM_THEME_KEY = 'u-ppt.themes.v1'
/** 内置 + 自定义主题全量返回(自定义优先在前) */
export function getAllThemes(): Record<string, import('./types').Theme> {
let custom: Record<string, import('./types').Theme> = {}
try { custom = JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) || '{}') || {} } catch (e) {}
return { ...custom, ...themes }
}
/* ---------- 当前主题注入:store 初始化时注册,viewer 无需引入 store ---------- */
let getTheme: () => string = () => 'indigo'
/** 由 store 注入当前主题 getter(避免 bg.ts 反向依赖 store 造成循环/viewer 拉入编辑器状态) */
export function registerBgThemeGetter(fn: () => string): void {
getTheme = fn
}
/** 背景是否深色:primary/accent/渐变键视为深;bg/panel 视为浅;hex 按亮度判断 */
export function isDarkBg(bg: string): boolean {
if (!bg) return false
if (bg.charAt(0) === '#') return isDarkHex(bg)
if (bg === 'primary' || bg === 'accent') return true
if (bg.indexOf('g-') === 0) return true
return false
}
/** 背景解析:渐变键 → CSS gradient;其余 → 主题色或合法 hex;未知键回退白 */
export function resolveBg(key: string): string {
const t = (getAllThemes()[getTheme()] || {}) as unknown as Record<string, string>
if (!key) return '#ffffff'
if (key.charAt(0) === '#') return isValidHex(key) ? key : '#ffffff'
if (key === 'g-primary') return 'linear-gradient(135deg, ' + t.primary + ' 0%, ' + t.accent + ' 100%)'
if (key === 'g-deep') return 'linear-gradient(160deg, ' + shade(t.primary, -28) + ' 0%, ' + t.primary + ' 100%)'
if (key === 'g-soft') return 'linear-gradient(135deg, ' + (t.panel || '#f8fafc') + ' 0%, ' + (t.bg || '#ffffff') + ' 100%)'
return t[key] || '#ffffff'
}
/**
* dark=true
* hex 退 CSS/HTML color
*/
export function resolveColor(key: string | undefined, dark: boolean): string {
const t = (getAllThemes()[getTheme()] || {}) as unknown as Record<string, string>
if (!key) return ''
if (key.charAt(0) === '#') {
if (!isValidHex(key)) return dark ? '#ffffff' : (t.text || '#1e293b')
if (dark && isDarkHex(key)) return '#ffffff'
return key
}
if (dark) {
if (key === 'text') return '#ffffff'
if (key === 'muted') return 'rgba(255,255,255,0.72)'
if (key === 'primary') return '#ffffff'
return t[key] || '#ffffff'
}
return t[key] || t.text || '#1e293b'
}
+45
View File
@@ -69,3 +69,48 @@ export async function aiProxyStream(
return { status: 0, body: '', error: raw } return { status: 0, body: '', error: raw }
} }
} }
/** OSS 上传参数(camelCase 与 Rust OssUploadArgs 对应) */
export interface OssUploadReq {
provider: 'aliyun' | 'qiniu'
key: string
dataBase64: string
contentType?: string
// 阿里云
accessKeyId?: string
accessKeySecret?: string
endpoint?: string
bucket?: string
// 七牛云
accessKey?: string
secretKey?: string
upHost?: string
domain?: string
}
/**
* Rust oss_upload
* 访 URL null退
* Rust
*/
export async function ossUpload(req: OssUploadReq): Promise<string | null> {
const invoke = await getInvoke()
if (!invoke) return null
const r = await invoke('oss_upload', { args: req }) as { url: string }
return r.url
}
/** 读取本地文件(Rust 端支持目录展开),把 base64 转为 File 对象。非桌面环境返回 [] */
export async function readLocalFiles(paths: string[]): Promise<File[]> {
const invoke = await getInvoke()
if (!invoke) return []
const items = await invoke('read_files', { paths }) as Array<{ name: string; mime: string; base64: string }>
const files: File[] = []
for (const it of items) {
const bin = atob(it.base64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
files.push(new File([bytes], it.name, { type: it.mime || '' }))
}
return files
}
+2 -3
View File
@@ -201,10 +201,9 @@ export function fileToVideoElement(file: File, dataUrl: string): SlideElement {
* *
* ============================================================ */ * ============================================================ */
export async function scanFiles(fileList: FileList): Promise<FileEntry[]> { export async function scanFiles(fileList: File[] | FileList): Promise<FileEntry[]> {
const entries: FileEntry[] = [] const entries: FileEntry[] = []
for (let i = 0; i < fileList.length; i++) { for (const file of Array.from(fileList)) {
const file = fileList[i]
const name = file.name const name = file.name
const dot = name.lastIndexOf('.') const dot = name.lastIndexOf('.')
const ext = dot >= 0 ? name.slice(dot).toLowerCase() : '' const ext = dot >= 0 ? name.slice(dot).toLowerCase() : ''
+212
View File
@@ -0,0 +1,212 @@
/* =====================================================================
* relay.ts u-relay Agent
* - WebSocket u-relaywss://.../ws/miniapp),miniapp 身份握手
* - 30s ping / pong + 退
* - request_id /
* - payload JSON u-claw
* agent_request{request_id,prompt} / agent_progress|agent_result{text}
* ===================================================================== */
import { store } from './store'
/** SEP 分隔标记:agent 回复文本中此标记后的 JSON 是 deck 操作(与 ai.ts chat 协议一致) */
export const RELAY_SEP = '%%PPT_JSON%%'
export type RelayStatus = 'disabled' | 'connecting' | 'connected' | 'reconnecting' | 'error'
export interface RelayEvent {
/** 路由骨架字段(容忍未知字段,仅按需取用) */
kind?: string
from?: string
payload?: any
ts?: number
[k: string]: unknown
}
export interface RelayHandlers {
onStatus?: (s: RelayStatus, detail?: string) => void
/** 收到本 device 的下行事件(已通过 request_id 过滤匹配) */
onProgress?: (requestId: string, text: string) => void
onResult?: (requestId: string, text: string) => void
}
const HANDSHAKE_TIMEOUT = 10_000 // relay 要求连接后 10s 内完成握手
const PING_INTERVAL = 30_000 // 应用层心跳
const PONG_TIMEOUT = 15_000 // 超时视为半连接,主动断开触发重连
const MAX_FRAME = 1024 * 1024 // 服务端帧上限 1MiB
const RECONNECT_BASE = 1_000 // 重连退避基数
const RECONNECT_MAX = 30_000 // 重连退避上限
function genId(): string {
return 'req-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10)
}
export class RelayClient {
private ws: WebSocket | null = null
private cfgKey = '' // 连接时配置指纹,配置变更后重连生效
private status: RelayStatus = 'disabled'
private handlers: RelayHandlers = {}
private pingTimer: ReturnType<typeof setInterval> | null = null
private pongTimer: ReturnType<typeof setTimeout> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
private lastPong = 0
private attempts = 0
private manualClose = false
setHandlers(h: RelayHandlers) { this.handlers = h }
private setStatus(s: RelayStatus, detail?: string) {
this.status = s
this.handlers.onStatus?.(s, detail)
}
getStatus(): RelayStatus { return this.status }
/** 配置是否齐备(三项均有值才算启用) */
isConfigured(): boolean {
const c = store.getCfg()
return !!(c.relayUrl && c.relayToken && c.relayDeviceId)
}
/** 连接(已连接且配置未变则跳过) */
connect() {
if (!this.isConfigured()) { this.setStatus('disabled'); return }
const c = store.getCfg()
const key = c.relayUrl + '|' + c.relayToken + '|' + c.relayDeviceId
if (this.ws && this.cfgKey === key &&
(this.status === 'connected' || this.status === 'connecting' || this.status === 'reconnecting')) return
this.closeSocket()
this.cfgKey = key
this.manualClose = false
this.attempts = 0
this.open()
}
/** 主动断开(停止重连) */
disconnect() {
this.manualClose = true
this.closeSocket()
this.setStatus('disabled')
}
private closeSocket() {
this.stopTimers()
if (this.ws) {
try { this.ws.onclose = null; this.ws.close() } catch (e) { /* ignore */ }
this.ws = null
}
}
private stopTimers() {
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null }
if (this.pongTimer) { clearTimeout(this.pongTimer); this.pongTimer = null }
if (this.handshakeTimer) { clearTimeout(this.handshakeTimer); this.handshakeTimer = null }
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null }
}
private open() {
const c = store.getCfg()
this.setStatus(this.attempts ? 'reconnecting' : 'connecting')
let ws: WebSocket
try { ws = new WebSocket(c.relayUrl!) } catch (e: any) {
this.setStatus('error', 'URL 无效:' + (e?.message || e)); return
}
this.ws = ws
// 握手超时看门狗:10s 内未收到 hello_ack 视为失败
this.handshakeTimer = setTimeout(() => {
if (this.status === 'connecting' || this.status === 'reconnecting') {
try { ws.close() } catch (e) { /* ignore */ }
}
}, HANDSHAKE_TIMEOUT)
ws.onopen = () => {
ws.send(JSON.stringify({ kind: 'miniapp', device_id: c.relayDeviceId, token: c.relayToken }))
}
ws.onmessage = (ev) => {
if (typeof ev.data !== 'string') return
let msg: RelayEvent
try { msg = JSON.parse(ev.data) } catch (e) { return }
const payload = msg.payload
// 控制帧:握手确认 / pong
if (msg.kind === 'control') {
const ck = payload && payload.control_kind
if (ck === 'hello_ack') {
if (this.handshakeTimer) { clearTimeout(this.handshakeTimer); this.handshakeTimer = null }
this.attempts = 0
this.lastPong = Date.now()
this.setStatus('connected')
this.startPing()
} else if (ck === 'pong') {
this.lastPong = Date.now()
if (this.pongTimer) { clearTimeout(this.pongTimer); this.pongTimer = null }
}
return
}
// 业务事件(来自同 device_id 的 device 端):按 payload 内协议分发
if (!payload || typeof payload !== 'object') return
const rid = typeof payload.request_id === 'string' ? payload.request_id : ''
if (!rid || !this.pending.has(rid)) return // 不匹配自己请求的一律忽略
if (payload.type === 'agent_progress') this.handlers.onProgress?.(rid, String(payload.text || ''))
else if (payload.type === 'agent_result') this.handlers.onResult?.(rid, String(payload.text || ''))
}
ws.onclose = () => {
if (this.manualClose) return
this.stopTimers()
// 指数退避重连
const delay = Math.min(RECONNECT_BASE * Math.pow(2, this.attempts), RECONNECT_MAX)
this.attempts++
this.setStatus('reconnecting', delay + 'ms 后重连')
this.reconnectTimer = setTimeout(() => this.open(), delay)
}
ws.onerror = () => { /* onclose 会跟着触发,统一在 onclose 处理 */ }
}
private startPing() {
this.stopPingOnly()
this.pingTimer = setInterval(() => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
// pong 看门狗:超时视为 TCP 半连接,主动断开走重连
if (Date.now() - this.lastPong > PING_INTERVAL + PONG_TIMEOUT) {
try { this.ws.close() } catch (e) { /* ignore */ }
return
}
this.ws.send(JSON.stringify({ control_kind: 'ping' }))
if (this.pongTimer) clearTimeout(this.pongTimer)
this.pongTimer = setTimeout(() => {
try { this.ws?.close() } catch (e) { /* ignore */ }
}, PONG_TIMEOUT)
}, PING_INTERVAL)
}
private stopPingOnly() {
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null }
if (this.pongTimer) { clearTimeout(this.pongTimer); this.pongTimer = null }
}
/* ---------- 请求 ---------- */
private pending = new Set<string>()
/**
* agent request_id
* @throws 1MiB
*/
request(prompt: string): string {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) throw new Error('中继未连接')
const frame = JSON.stringify({ type: 'agent_request', request_id: genId(), prompt })
if (frame.length > MAX_FRAME) throw new Error('请求体超过 1MiB 帧上限(deck 上下文过大),请精简演示后重试')
const rid = JSON.parse(frame).request_id as string
this.pending.add(rid)
this.ws.send(frame)
return rid
}
/** 请求结束(收到 result 或调用方放弃)后释放匹配槽 */
settle(rid: string) { this.pending.delete(rid) }
}
/** 全局单例:Agent 面板与设置页共用一条连接 */
export const relay = new RelayClient()
+111
View File
@@ -0,0 +1,111 @@
/* =====================================================================
* share.ts deck JSON OSS
* <qiniuDomain aliyun bucket 域名>#/p/<shareId> viewer
* URL<bucket>.<endpoint>/<dir>/share/<id>.json
* URL<domain>/<dir>/share/<id>.json
* ===================================================================== */
import { store } from './store'
import { ossUpload, isTauri } from './bridge'
import { canUploadNow, isOssEnabled, buildUploadReq } from './assets'
import type { OssCfg, Deck } from './types'
/** 分享发布前置校验:不满足时返回中文原因 */
export function shareReady(): { ok: boolean; reason?: string } {
if (!isOssEnabled()) return { ok: false, reason: '请先在 ☁ 云存储中配置并启用 OSS 后再分享' }
if (!canUploadNow()) {
if (!isTauri()) return { ok: false, reason: '分享发布仅支持桌面版,请在桌面应用中操作' }
return { ok: false, reason: '当前离线,请联网后再分享' }
}
const cfg = store.getOssCfg()
if (cfg.provider === 'qiniu' && !(cfg.qiniuDomain || '').trim()) {
return { ok: false, reason: '七牛云分享需配置绑定的公开访问域名(Bucket 绑定的 CDN 域名)' }
}
return { ok: true }
}
/** 生成分享 id'p' + 时间戳36进制 + 随机后缀 */
function genShareId(): string {
return 'p' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
}
/** 按 dir 规则(trim + 去首尾斜杠)拼分享对象 key */
function shareKey(cfg: OssCfg, shareId: string): string {
const dir = (cfg.dir || '').trim().replace(/^\/+|\/+$/g, '')
return [dir, 'share', `${shareId}.json`].filter(Boolean).join('/')
}
/** 由 OSS 配置 + key 反推公开访问 URL */
function jsonUrlFor(cfg: OssCfg, key: string): string {
if (cfg.provider === 'qiniu') {
return `${(cfg.qiniuDomain || '').trim().replace(/\/+$/, '')}/${key}`
}
// 阿里云:<bucket>.<endpoint host>/<key>endpoint 规整同 oss.rs:去协议、去尾斜杠)
const host = (cfg.aliEndpoint || '').trim().replace(/^https?:\/\//, '').replace(/\/+$/, '')
const bucket = (cfg.aliBucket || '').trim()
return `https://${bucket}.${host}/${key}`
}
/* ---------- hash JSON ----------
* OSS shareId
* JSON URLbase64url hash
* #/p/<shareId> 退
* #/p/<shareId>:<b64url>
*/
function b64urlEncode(s: string): string {
return btoa(unescape(encodeURIComponent(s))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function b64urlDecode(s: string): string {
const std = s.replace(/-/g, '+').replace(/_/g, '/')
return decodeURIComponent(escape(atob(std + '='.repeat((4 - std.length % 4) % 4))))
}
/** 解析 location.hash,返回 { shareId, jsonUrl }jsonUrl 为 null 时由调用方回退反推。非法格式返回 null */
export function parseShareHash(): { shareId: string; jsonUrl: string | null } | null {
if (typeof location === 'undefined') return null
const m = /^#\/p\/([A-Za-z0-9]+)(?::([A-Za-z0-9_-]+))?$/.exec(location.hash || '')
if (!m) return null
return { shareId: m[1], jsonUrl: m[2] ? b64urlDecode(m[2]) : null }
}
/** 由分享 id 反推 JSON 地址(旧格式回退:仅当本机存有相同 OSS 配置时可用) */
export function shareJsonUrlOf(shareId: string): string {
return jsonUrlFor(store.getOssCfg(), shareKey(store.getOssCfg(), shareId))
}
export async function publishDeck(): Promise<{ url: string; jsonUrl: string }> {
const ready = shareReady()
if (!ready.ok) throw new Error(ready.reason)
// 资产检查:本地暂存未上云的图片/视频会让观众看不到
let unsynced = 0
for (const slide of store.getDeck().slides || []) {
for (const el of slide.elements || []) {
if ((el.type === 'image' || el.type === 'video') && (el.content || '').startsWith('asset:')) unsynced++
}
}
if (unsynced > 0) {
throw new Error(`${unsynced} 张图片/视频还未同步到云端,请先在 ☁ 云存储中「立即同步」后再分享`)
}
// viewerBase:查看器页的公开基地址(如 https://img.1216.top),
// 分享链接必须指向渲染页而非 JSON 文件(否则观众看到的是 JSON 源码)
const cfg = store.getOssCfg()
const base = (cfg.viewerBase || '').trim().replace(/\/+$/, '')
if (!base) {
throw new Error('未配置查看器地址:请在 ☁ 云存储设置的「查看器地址」填入 viewer 页的公开网址')
}
const shareId = genShareId()
const key = shareKey(cfg, shareId)
// 深拷贝后剥离演讲者备注(slide.note),批注(el.style.annotations)保留照常渲染;
// 置 undefined 使 JSON.stringify 不输出该字段
const deck = JSON.parse(JSON.stringify(store.getDeck())) as Deck
for (const slide of deck.slides || []) slide.note = undefined
const dataBase64 = btoa(unescape(encodeURIComponent(JSON.stringify(deck))))
const url = await ossUpload(buildUploadReq(cfg, key, dataBase64, 'application/json'))
if (!url) throw new Error('上传分享数据失败:ossUpload 未返回 URL')
const jsonUrl = url.split('#')[0]
// JSON 地址编码进 hash:观众端零配置拉取
const hash = `${shareId}:${b64urlEncode(jsonUrl)}`
return { url: `${base}/viewer.html#/p/${hash}`, jsonUrl }
}
+41 -77
View File
@@ -3,7 +3,7 @@
* store.js Vue reactive pub/sub * store.js Vue reactive pub/sub
* ===================================================================== */ * ===================================================================== */
import { reactive, computed } from 'vue' import { reactive, computed } from 'vue'
import type { AiCfg, Deck, LibItem, PageTemplate, Slide, SlideElement, ThemeKey, ChatMessage, Annotation } from './types' import type { AiCfg, Deck, LibItem, OssCfg, PageTemplate, Slide, SlideElement, ThemeKey, ChatMessage, Annotation } from './types'
import { DECK_VERSION, SAMPLE_DECK, createElement, themes, uid } from './sample' import { DECK_VERSION, SAMPLE_DECK, createElement, themes, uid } from './sample'
import { applyOp, invertOp, type Op, type HistoryEntry } from './op' import { applyOp, invertOp, type Op, type HistoryEntry } from './op'
@@ -235,74 +235,12 @@ function syncActiveLibItem() {
setLibrary(lib) setLibrary(lib)
} }
/* ---------- 颜色/背景解析纯函数,供组件调用) ---------- */ /* ---------- 颜色/背景解析纯函数已抽至 ./bg,此处再导出保持调用方 import 路径不变 ---------- */
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null { export { isDarkBg, resolveBg, isDarkHex, isValidHex, resolveColor, getAllThemes } from './bg'
const c = String(hex).replace('#', '') import { getAllThemes, registerBgThemeGetter, CUSTOM_THEME_KEY } from './bg'
const full = c.length === 3 ? c[0] + c[0] + c[1] + c[1] + c[2] + c[2] : c
const r = parseInt(full.substr(0, 2), 16)
const g = parseInt(full.substr(2, 2), 16)
const b = parseInt(full.substr(4, 2), 16)
return (isNaN(r) || isNaN(g) || isNaN(b)) ? null : { r, g, b }
}
export function isDarkHex(hex: string): boolean { // 注入当前主题 getterbg.ts 解析背景时读取(viewer 侧无需引入本 store
const rgb = hexToRgb(hex); if (!rgb) return false registerBgThemeGetter(() => state.deck.theme)
return (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) < 145
}
export function shade(hex: string, pct: number): string {
const rgb = hexToRgb(hex); if (!rgb) return hex
const f = pct < 0 ? 0 : 255, p = Math.abs(pct) / 100
const r = Math.round((f - rgb.r) * p + rgb.r)
const g = Math.round((f - rgb.g) * p + rgb.g)
const b = Math.round((f - rgb.b) * p + rgb.b)
return '#' + [r, g, b].map(x => { const s = x.toString(16); return s.length < 2 ? '0' + s : s }).join('')
}
/** 背景是否深色:primary/accent/渐变键视为深;bg/panel 视为浅;hex 按亮度判断 */
export function isDarkBg(bg: string): boolean {
if (!bg) return false
if (bg.charAt(0) === '#') return isDarkHex(bg)
if (bg === 'primary' || bg === 'accent') return true
if (bg.indexOf('g-') === 0) return true
return false
}
export function isValidHex(s: string): boolean {
return typeof s === 'string' && /^#[0-9a-f]{3,8}$/i.test(s)
}
/**
* dark=true
* hex 退 CSS/HTML color
*/
export function resolveColor(key: string | undefined, dark: boolean): string {
const t = (getAllThemes()[state.deck.theme] || {}) as unknown as Record<string, string>
if (!key) return ''
if (key.charAt(0) === '#') {
if (!isValidHex(key)) return dark ? '#ffffff' : (t.text || '#1e293b')
if (dark && isDarkHex(key)) return '#ffffff'
return key
}
if (dark) {
if (key === 'text') return '#ffffff'
if (key === 'muted') return 'rgba(255,255,255,0.72)'
if (key === 'primary') return '#ffffff'
return t[key] || '#ffffff'
}
return t[key] || t.text || '#1e293b'
}
/** 背景解析:渐变键 → CSS gradient;其余 → 主题色或合法 hex;未知键回退白 */
export function resolveBg(key: string): string {
const t = (getAllThemes()[state.deck.theme] || {}) as unknown as Record<string, string>
if (!key) return '#ffffff'
if (key.charAt(0) === '#') return isValidHex(key) ? key : '#ffffff'
if (key === 'g-primary') return 'linear-gradient(135deg, ' + t.primary + ' 0%, ' + t.accent + ' 100%)'
if (key === 'g-deep') return 'linear-gradient(160deg, ' + shade(t.primary, -28) + ' 0%, ' + t.primary + ' 100%)'
if (key === 'g-soft') return 'linear-gradient(135deg, ' + (t.panel || '#f8fafc') + ' 0%, ' + (t.bg || '#ffffff') + ' 100%)'
return t[key] || '#ffffff'
}
/* ---------- 内部:执行 Op 并推入历史 ---------- */ /* ---------- 内部:执行 Op 并推入历史 ---------- */
@@ -639,7 +577,7 @@ function delElement(id: string) {
const el = findElement(id) const el = findElement(id)
const deletedElement = el ? clone(el) : undefined const deletedElement = el ? clone(el) : undefined
execOp({ type: 'del_element', slideIdx: state.currentIndex, elementId: id, deletedElement, clientId: CLIENT_ID, timestamp: Date.now() }) execOp({ type: 'del_element', slideIdx: state.currentIndex, elementId: id, deletedElement, clientId: CLIENT_ID, timestamp: Date.now() })
if (state.selectedId === id) state.selectedId = null; state.selectedIds = [] if (state.selectedId === id) { state.selectedId = null; state.selectedIds = [] }
} }
function moveElementZ(id: string, dir: number) { function moveElementZ(id: string, dir: number) {
@@ -850,16 +788,40 @@ function setCfg(cfg: AiCfg) {
safeSet(LS_CFG, JSON.stringify({ ...DEFAULT_CFG, ...cfg })) safeSet(LS_CFG, JSON.stringify({ ...DEFAULT_CFG, ...cfg }))
} }
/* ---------- 自定义主题:AI 配色建议应用后存储 ---------- */ /* ---------- OSS 配置持久化 ---------- */
const CUSTOM_THEME_KEY = 'u-ppt.themes.v1' const LS_OSS = 'u-ppt.oss-cfg.v1'
const DEFAULT_OSS: OssCfg = {
/** 内置 + 自定义主题全量返回(自定义优先在前) */ enabled: false, provider: 'aliyun', dir: 'u-ppt',
function getAllThemes(): Record<string, import('./types').Theme> { aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
let custom: Record<string, import('./types').Theme> = {} qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: '',
try { custom = JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) || '{}') || {} } catch (e) {} viewerBase: ''
return { ...custom, ...themes }
} }
function getOssCfg(): OssCfg {
try {
const c = JSON.parse(localStorage.getItem(LS_OSS) || 'null')
return { ...DEFAULT_OSS, ...(c || {}) }
} catch (e) { return { ...DEFAULT_OSS } }
}
function setOssCfg(cfg: OssCfg) {
safeSet(LS_OSS, JSON.stringify({ ...DEFAULT_OSS, ...cfg }))
}
/** 资产同步回写:把 deck 中所有等于 oldRef 的 element.content 替换为新 URL,并落盘 */
function rewriteAssetRef(oldRef: string, newUrl: string) {
let changed = false
for (const slide of state.deck.slides) {
for (const el of slide.elements) {
if (el.content === oldRef) { el.content = newUrl; changed = true }
}
}
if (changed) scheduleSave()
return changed
}
/* ---------- 自定义主题:AI 配色建议应用后存储(全量读取复用 bg.ts ---------- */
/** 获取自定义主题列表(仅自定义的) */ /** 获取自定义主题列表(仅自定义的) */
function getCustomThemes(): Record<string, import('./types').Theme> { function getCustomThemes(): Record<string, import('./types').Theme> {
try { return JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) || '{}') || {} } catch (e) { return {} } try { return JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) || '{}') || {} } catch (e) { return {} }
@@ -1101,6 +1063,8 @@ export const store = {
reset, exportJSON, importJSON, reset, exportJSON, importJSON,
// AI 配置 // AI 配置
getCfg, setCfg, getCfg, setCfg,
// OSS 配置
getOssCfg, setOssCfg, rewriteAssetRef,
// 文库 // 文库
getLibrary, getActiveLibId, saveToLibrary, loadFromLibrary, getLibrary, getActiveLibId, saveToLibrary, loadFromLibrary,
renameInLibrary, deleteFromLibrary, duplicateInLibrary, newBlankDeck, renameInLibrary, deleteFromLibrary, duplicateInLibrary, newBlankDeck,
+33
View File
@@ -187,6 +187,39 @@ export interface AiCfg {
imgBase?: string imgBase?: string
imgKey?: string imgKey?: string
imgModel?: string imgModel?: string
/** Agent 中继(u-relay)配置:三项均配置才启用 Agent 模式 */
relayUrl?: string
relayToken?: string
relayDeviceId?: string
}
/** 对象存储(OSS)配置:所有媒体资产上云,离线暂存本地,联网自动同步 */
export interface OssCfg {
/** 是否启用 OSS(关闭则维持旧的 base64 内嵌行为) */
enabled: boolean
/** 服务商:阿里云 / 七牛云 */
provider: 'aliyun' | 'qiniu'
/** 对象 key 目录前缀(如 "u-ppt/images",可空) */
dir: string
/* ---- 阿里云 OSS ---- */
aliAccessKeyId: string
aliAccessKeySecret: string
/** endpoint,如 oss-cn-hangzhou.aliyuncs.com */
aliEndpoint: string
aliBucket: string
/* ---- 七牛云 Kodo ---- */
qiniuAccessKey: string
qiniuSecretKey: string
qiniuBucket: string
/** 上传域名,如 https://upload.qiniup.com,可空用默认 */
qiniuUpHost: string
/** 绑定的公开访问域名,如 https://cdn.example.com */
qiniuDomain: string
/** 分享查看器页的公开基地址(如 https://img.1216.top),分享链接 = <viewerBase>/viewer.html#/p/<id> */
viewerBase: string
} }
/** 文库条目 */ /** 文库条目 */
+4
View File
@@ -5,8 +5,12 @@ import './styles/chat.css'
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import { store } from './core/store' import { store } from './core/store'
import { hydrate } from './core/assets'
// 初始化 store(加载本地数据) // 初始化 store(加载本地数据)
store.init() store.init()
// 资产库 hydrate:重建离线暂存资产的可渲染 URL,并监听联网自动同步
void hydrate()
createApp(App).mount('#app') createApp(App).mount('#app')
+204
View File
@@ -0,0 +1,204 @@
<!-- =====================================================================
ViewerAnnotations.vue 分享查看器批注层只读
结构/几何与编辑器 AnnotationLayer 一致SVG 连线1280×720+ DOM 气泡百分比定位
整层 pointer-events:none观众不可交互也不挡翻页点击空文本批注不渲染气泡
===================================================================== -->
<script setup lang="ts">
import { computed } from 'vue'
import type { Annotation, Slide, SlideElement } from '../core/types'
import { isDarkBg, resolveColor } from '../core/bg'
import { markdownToSegments, segmentsToHtml } from '../core/richtext'
const props = defineProps<{ slide: Slide }>()
const CANVAS_W = 1280
const CANVAS_H = 720
/** 当前页背景是否深色 → 文字/连线反相 */
const dark = computed(() => isDarkBg(props.slide.background || ''))
/** 扁平列表:{ el, anno },供连线与气泡渲染 */
const items = computed(() => {
const out: { el: SlideElement; anno: Annotation }[] = []
for (const el of props.slide.elements || []) {
const list = el.style?.annotations
if (list?.length) for (const anno of list) out.push({ el, anno })
}
return out
})
/**
* 射线-矩形边框求交从矩形中心 (cx,cy) 朝目标 (tx,ty) 方向
* 取与矩形边框半宽 hw半高 hh的交点
*/
function edgePoint(cx: number, cy: number, hw: number, hh: number, tx: number, ty: number) {
const dx = tx - cx, dy = ty - cy
if (!dx && !dy) return { x: cx, y: cy }
const sx = dx ? hw / Math.abs(dx) : Infinity
const sy = dy ? hh / Math.abs(dy) : Infinity
const s = Math.min(sx, sy)
return { x: cx + dx * s, y: cy + dy * s }
}
/** 连线几何:起点=图片边缘、终点=气泡边缘,坐标转 1280×720 用户单位;框内/重叠返回 null */
function lineGeom(el: SlideElement, anno: Annotation) {
const ex = el.x / 100 * CANVAS_W, ey = el.y / 100 * CANVAS_H
const ew = el.w / 100 * CANVAS_W, eh = el.h / 100 * CANVAS_H
const bx = anno.bx / 100 * CANVAS_W, by = anno.by / 100 * CANVAS_H
const bw = anno.bw / 100 * CANVAS_W, bh = anno.bh / 100 * CANVAS_H
const icx = ex + (anno.ax ?? 50) / 100 * ew
const icy = ey + (anno.ay ?? 50) / 100 * eh
const bcx = bx + bw / 2, bcy = by + bh / 2
if (bcx >= ex && bcx <= ex + ew && bcy >= ey && bcy <= ey + eh) return null
const dist = Math.hypot(bcx - icx, bcy - icy)
const s = edgePoint(icx, icy, ew / 2, eh / 2, bcx, bcy)
const t = edgePoint(bcx, bcy, bw / 2, bh / 2, icx, icy)
const sd = Math.hypot(s.x - icx, s.y - icy)
const td = Math.hypot(t.x - bcx, t.y - bcy)
if (sd + td >= dist) return null
return { x1: s.x, y1: s.y, x2: t.x, y2: t.y }
}
function lineColor(anno: Annotation) {
return resolveColor(anno.line?.color, dark.value) || (dark.value ? '#ffffff' : '#64748b')
}
function lineWidth(anno: Annotation) {
return (anno.line?.width ?? 0.5) * 2
}
function dash(anno: Annotation) {
return anno.line?.style === 'dashed' ? '10 8' : undefined
}
/** cap → marker urlnone 返回 undefined */
function markerUrl(cap: string | undefined, kind: 'start' | 'end') {
if (cap === 'arrow') return 'url(#anno-arrow-' + kind + ')'
if (cap === 'dot') return 'url(#anno-dot)'
return undefined
}
/** 气泡文字样式 */
function bubbleTextStyle(anno: Annotation) {
return {
fontSize: (anno.fontSize || 14) + 'px',
color: resolveColor(anno.color, false) || '#1e293b',
fontWeight: anno.bold ? 700 : 400,
fontStyle: anno.italic ? 'italic' : 'normal',
textAlign: anno.align || 'left'
} as Record<string, string | number>
}
/** 气泡文字 → md 渲染 HTML(与编辑器一致:行内格式 + # 标题块级语法);空文本不渲染 */
function hasText(anno: Annotation): boolean {
return !!(anno.text || '').trim()
}
function renderText(anno: Annotation): string {
const t = anno.text || ''
const base = anno.fontSize || 14
const HSIZE = [1.8, 1.5, 1.25]
return t.split('\n').map(line => {
const h = /^(#{1,3})\s+(.*)$/.exec(line)
if (h) {
const level = h[1].length
const inner = segmentsToHtml(markdownToSegments(h[2]))
const size = Math.round(base * HSIZE[level - 1])
return `<span style="display:block;font-size:${size}px;font-weight:700;line-height:1.25">${inner}</span>`
}
return segmentsToHtml(markdownToSegments(line))
}).join('<br>')
}
</script>
<template>
<div class="anno-layer">
<!-- 连线层 -->
<svg class="anno-svg" viewBox="0 0 1280 720" preserveAspectRatio="none">
<defs>
<marker
id="anno-arrow-end" markerUnits="userSpaceOnUse"
markerWidth="16" markerHeight="16" refX="12" refY="6" orient="auto"
>
<path d="M0,0 L12,6 L0,12 Z" fill="context-stroke" />
</marker>
<marker
id="anno-arrow-start" markerUnits="userSpaceOnUse"
markerWidth="16" markerHeight="16" refX="0" refY="6" orient="auto"
>
<path d="M12,0 L0,6 L12,12 Z" fill="context-stroke" />
</marker>
<marker
id="anno-dot" markerUnits="userSpaceOnUse"
markerWidth="12" markerHeight="12" refX="5" refY="5"
>
<circle cx="5" cy="5" r="4" fill="context-stroke" />
</marker>
</defs>
<template v-for="{ el, anno } in items" :key="'ln-' + el.id + anno.id">
<line
v-if="lineGeom(el, anno)"
v-bind="lineGeom(el, anno)!"
:stroke="lineColor(anno)"
:stroke-width="lineWidth(anno)"
:stroke-dasharray="dash(anno)"
stroke-linecap="round"
:marker-start="markerUrl(anno.line?.startCap, 'start')"
:marker-end="markerUrl(anno.line?.endCap, 'end')"
/>
</template>
</svg>
<!-- 气泡层空文本不渲染 -->
<div
v-for="{ el, anno } in items"
v-show="hasText(anno)"
:key="'bb-' + el.id + anno.id"
class="anno-bubble"
:style="{
left: anno.bx + '%',
top: anno.by + '%',
width: anno.bw + '%',
height: anno.bh + '%',
...bubbleTextStyle(anno)
}"
>
<span class="anno-bubble-text" v-html="renderText(anno)"></span>
</div>
</div>
</template>
<style scoped>
.anno-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.anno-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.anno-svg * {
pointer-events: none;
}
.anno-bubble {
position: absolute;
box-sizing: border-box;
padding: 8px 10px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 10px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.1);
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
overflow: hidden;
}
.anno-bubble-text {
display: block;
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
+219
View File
@@ -0,0 +1,219 @@
<!-- =====================================================================
ViewerApp.vue 分享查看器观众版独立入口
数据源分享 JSON不连 store仅静态渲染当前页
===================================================================== -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { Deck, Slide } from '../core/types'
import { parseShareHash, shareJsonUrlOf } from '../core/share'
import { resolveBg, registerBgThemeGetter } from '../core/bg'
import { CANVAS_W, CANVAS_H } from '../core/sample'
import ElementView from '../components/editor/ElementView.vue'
import ViewerAnnotations from './ViewerAnnotations.vue'
type Status = 'loading' | 'ready' | 'error' | 'invalid'
const status = ref<Status>('loading')
const errMsg = ref('')
const deck = ref<Deck | null>(null)
const index = ref(0)
const scale = ref(1)
const idle = ref(false)
const slides = computed<Slide[]>(() => deck.value?.slides ?? [])
const total = computed(() => slides.value.length)
const currentSlide = computed<Slide | null>(() => {
const arr = slides.value
if (!arr.length) return null
return arr[Math.max(0, Math.min(index.value, arr.length - 1))]
})
/* ---------- 加载分享 JSON ---------- */
onMounted(async () => {
const parsed = parseShareHash()
if (!parsed) {
status.value = 'invalid'
return
}
try {
// JSON 退
const jsonUrl = parsed.jsonUrl || shareJsonUrlOf(parsed.shareId)
const res = await fetch(jsonUrl)
if (!res.ok) throw new Error('HTTP ' + res.status)
deck.value = JSON.parse(await res.text()) as Deck
if (!deck.value?.slides?.length) throw new Error('分享内容为空')
// deck resolveBg /
registerBgThemeGetter(() => String(deck.value?.theme || 'indigo'))
status.value = 'ready'
fit()
} catch (e: any) {
errMsg.value = e?.message || String(e)
status.value = 'error'
}
})
/* ---------- 缩放适配 ---------- */
function fit() {
scale.value = Math.min(window.innerWidth / CANVAS_W, window.innerHeight / CANVAS_H)
}
/* ---------- 翻页 ---------- */
function next() {
if (index.value < total.value - 1) index.value++
wake()
}
function prev() {
if (index.value > 0) index.value--
wake()
}
/* ---------- 键盘 ---------- */
function onKey(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowRight': case ' ': case 'PageDown':
e.preventDefault(); next(); break
case 'ArrowLeft': case 'PageUp':
e.preventDefault(); prev(); break
}
}
/* ---------- 点击:左半屏上一页 / 右半屏下一页 ---------- */
function onClick(e: MouseEvent) {
const stage = stageEl.value
if (!stage) return
const rect = stage.getBoundingClientRect()
if (e.clientX - rect.left > rect.width / 2) next()
else prev()
}
/* ---------- 触摸滑动 ---------- */
let touchX = 0
function onTouchStart(e: TouchEvent) {
touchX = e.changedTouches[0].clientX
}
function onTouchEnd(e: TouchEvent) {
const dx = e.changedTouches[0].clientX - touchX
if (dx > 40) prev()
else if (dx < -40) next()
}
/* ---------- 页码 HUD3 秒无操作淡出 ---------- */
let idleTimer: ReturnType<typeof setTimeout> | null = null
function wake() {
idle.value = false
if (idleTimer) clearTimeout(idleTimer)
idleTimer = setTimeout(() => { idle.value = true }, 3000)
}
function bindEvents() {
document.addEventListener('keydown', onKey)
window.addEventListener('resize', fit)
}
function unbindEvents() {
document.removeEventListener('keydown', onKey)
window.removeEventListener('resize', fit)
if (idleTimer) clearTimeout(idleTimer)
}
const stageEl = ref<HTMLElement | null>(null)
onMounted(bindEvents)
onUnmounted(unbindEvents)
</script>
<template>
<!-- 无效链接 -->
<div v-if="status === 'invalid'" class="viewer-state">
<div class="viewer-state-title">链接无效</div>
</div>
<!-- 加载失败 -->
<div v-else-if="status === 'error'" class="viewer-state">
<div class="viewer-state-title">加载失败链接可能已失效</div>
<div v-if="errMsg" class="viewer-state-detail">{{ errMsg }}</div>
</div>
<!-- 加载中 -->
<div v-else-if="status === 'loading'" class="viewer-state">
<div class="viewer-spinner"></div>
<div class="viewer-state-title">加载中</div>
</div>
<!-- 渲染 -->
<div v-else class="viewer-stage" :class="{ idle }" ref="stageEl" @click="onClick" @touchstart.passive="onTouchStart" @touchend.passive="onTouchEnd" @mousemove="wake">
<!-- 沉浸背景当前页主色放大铺满 + 模糊消除黑边手机竖屏尤甚 -->
<div class="viewer-backdrop" :style="{ background: resolveBg(currentSlide?.background || 'bg') }"></div>
<div class="slide-layer" :style="{ transform: 'scale(' + scale + ')' }">
<div v-if="currentSlide" class="slide-inner" :style="{ background: resolveBg(currentSlide.background) }">
<ElementView
v-for="el in currentSlide.elements"
:key="el.id"
:el="el"
:bg="currentSlide.background"
/>
<ViewerAnnotations :slide="currentSlide" />
</div>
</div>
<div class="viewer-hud">{{ (index + 1) + ' / ' + total }}</div>
</div>
</template>
<style scoped>
.viewer-state {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-secondary, #888);
font-size: 15px;
user-select: none;
}
.viewer-state-title { font-weight: 500; }
.viewer-state-detail { font-size: 12px; opacity: 0.6; }
.viewer-spinner {
width: 28px;
height: 28px;
border: 3px solid rgba(128, 128, 128, 0.25);
border-top-color: var(--accent, #5b8def);
border-radius: 50%;
animation: viewer-spin 0.9s linear infinite;
}
@keyframes viewer-spin { to { transform: rotate(360deg); } }
.viewer-stage {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #000;
cursor: pointer;
}
/* 沉浸背景层:幻灯片背景色的同色柔和延伸,不做明暗/饱和处理,过渡平缓 */
.viewer-backdrop {
position: absolute;
inset: -40px;
filter: blur(48px);
}
.viewer-backdrop + .slide-layer { box-shadow: 0 12px 40px rgba(0, 0, 0, .25); }
.viewer-hud {
position: fixed;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
padding: 4px 12px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 12px;
user-select: none;
pointer-events: none;
transition: opacity 0.5s;
}
.viewer-stage.idle .viewer-hud { opacity: 0; }
</style>
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import ViewerApp from './ViewerApp.vue'
createApp(ViewerApp).mount('#app')
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>u-ppt · 演示</title>
<link rel="stylesheet" href="/src/styles/base.css" />
<!-- editor.css 含 .el 系列元素样式(定位/字体/形状/表格等),与编辑器/演示保持同一渲染 -->
<link rel="stylesheet" href="/src/styles/editor.css" />
<link rel="stylesheet" href="/src/styles/present.css" />
</head>
<body data-mode="present">
<div id="app"></div>
<script type="module" src="/src/viewer/main.ts"></script>
</body>
</html>
+6
View File
@@ -5,6 +5,12 @@ export default defineConfig(({ mode }) => ({
plugins: [vue()], plugins: [vue()],
build: { build: {
chunkSizeWarningLimit: 800, chunkSizeWarningLimit: 800,
rollupOptions: {
input: {
main: 'index.html',
viewer: 'viewer.html',
},
},
}, },
// npm run dev:webmode=web)→ 浏览器模式,端口 8080 // npm run dev:webmode=web)→ 浏览器模式,端口 8080
// npm run tauri:dev → 默认 mode,端口 5173 // npm run tauri:dev → 默认 mode,端口 5173