Compare commits
6
Commits
5d3046b653
...
99f38726a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99f38726a2 | ||
|
|
dde53378c3 | ||
|
|
eca5b11ea4 | ||
|
|
daaccb8ad7 | ||
|
|
e839c62bba | ||
|
|
b03bc6ad82 |
@@ -16,6 +16,9 @@ src-tauri/target/
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# Claude Code 工作树/临时
|
||||||
|
.claude/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
Generated
+10
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 =="
|
||||||
Generated
+207
-1
@@ -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"
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 阿里云 OSS:PUT Object,Authorization 头 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼公开访问 URL:domain/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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,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"
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 (isOssEnabled()) {
|
||||||
if (images.length === 1) {
|
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++
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 操作。
|
||||||
|
* 供 AiPanel(SSE 路径)与 AgentPanel(relay 路径)共用,避免复制粘贴。
|
||||||
|
*/
|
||||||
|
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'
|
||||||
|
|||||||
@@ -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 用 ObjectURL,synced 用真实 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
|
||||||
|
* - 否则:暂存 IndexedDB(pending),返回 "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 }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() : ''
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
* relay.ts — u-relay 中继客户端(Agent 模式)
|
||||||
|
* - WebSocket 连 u-relay(wss://.../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()
|
||||||
@@ -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 URL(base64url 编码)放进 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
@@ -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 {
|
// 注入当前主题 getter,bg.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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文库条目 */
|
/** 文库条目 */
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 url(none 返回 undefined) */
|
||||||
|
function markerUrl(cap: string | undefined, kind: 'start' | 'end') {
|
||||||
|
if (cap === 'arrow') return 'url(#anno-arrow-' + kind + ')'
|
||||||
|
if (cap === 'dot') return 'url(#anno-dot)'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 气泡文字样式 */
|
||||||
|
function bubbleTextStyle(anno: Annotation) {
|
||||||
|
return {
|
||||||
|
fontSize: (anno.fontSize || 14) + 'px',
|
||||||
|
color: resolveColor(anno.color, false) || '#1e293b',
|
||||||
|
fontWeight: anno.bold ? 700 : 400,
|
||||||
|
fontStyle: anno.italic ? 'italic' : 'normal',
|
||||||
|
textAlign: anno.align || 'left'
|
||||||
|
} as Record<string, string | number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 气泡文字 → md 渲染 HTML(与编辑器一致:行内格式 + # 标题块级语法);空文本不渲染 */
|
||||||
|
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>
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 页码 HUD:3 秒无操作淡出 ---------- */
|
||||||
|
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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import ViewerApp from './ViewerApp.vue'
|
||||||
|
|
||||||
|
createApp(ViewerApp).mount('#app')
|
||||||
+16
@@ -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>
|
||||||
@@ -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:web(mode=web)→ 浏览器模式,端口 8080
|
// npm run dev:web(mode=web)→ 浏览器模式,端口 8080
|
||||||
// npm run tauri:dev → 默认 mode,端口 5173
|
// npm run tauri:dev → 默认 mode,端口 5173
|
||||||
|
|||||||
Reference in New Issue
Block a user