新增: OSS 资产库与桌面本地文件读取,交互与云同步增强
This commit is contained in:
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",
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
+103
-15
@@ -6,6 +6,8 @@ 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 { 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 FileDock from './components/common/FileDock.vue'
|
||||||
import FilePreviewDrawer from './components/common/FilePreviewDrawer.vue'
|
import FilePreviewDrawer from './components/common/FilePreviewDrawer.vue'
|
||||||
@@ -16,6 +18,7 @@ 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 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'
|
||||||
@@ -36,6 +39,7 @@ function switchTab(name: 'props' | 'ai') {
|
|||||||
|
|
||||||
/* ---------- 弹窗 ---------- */
|
/* ---------- 弹窗 ---------- */
|
||||||
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)
|
||||||
@@ -43,8 +47,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 ---------- */
|
||||||
@@ -128,7 +132,40 @@ 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'
|
||||||
|
}
|
||||||
|
|
||||||
function onImportClose() {
|
function onImportClose() {
|
||||||
importVisible.value = false
|
importVisible.value = false
|
||||||
@@ -215,6 +252,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/文本) ---------- */
|
||||||
@@ -234,6 +284,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 }
|
||||||
@@ -298,6 +356,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)
|
||||||
|
|
||||||
@@ -314,6 +384,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()
|
||||||
@@ -321,24 +393,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(防止静默丢数据) */
|
||||||
@@ -373,6 +453,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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- ===================== 编辑模式 ===================== -->
|
<!-- ===================== 编辑模式 ===================== -->
|
||||||
@@ -382,10 +463,12 @@ 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"
|
||||||
@import-json="onImportJson"
|
@import-json="onImportJson"
|
||||||
@export-pdf="onExportPdf"
|
@export-pdf="onExportPdf"
|
||||||
/>
|
/>
|
||||||
@@ -430,6 +513,11 @@ 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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,14 @@ 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
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const themeKeys = Object.keys(themes)
|
const themeKeys = Object.keys(themes)
|
||||||
@@ -48,6 +50,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,11 +98,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">
|
||||||
<!-- 高频:页面操作 -->
|
<!-- 高频:页面操作 -->
|
||||||
@@ -155,12 +158,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 (images.length === 1) {
|
if (isOssEnabled()) {
|
||||||
|
if (images.length === 1) {
|
||||||
|
const ref = await putAsset(images[0].file)
|
||||||
|
const el = fileToImageElement(images[0].file, ref)
|
||||||
|
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
|
||||||
|
insertedImages = 1
|
||||||
|
} else {
|
||||||
|
const els = await Promise.all(images.map(async f => {
|
||||||
|
const ref = await putAsset(f.file)
|
||||||
|
return fileToImageElement(f.file, ref)
|
||||||
|
}))
|
||||||
|
// 复用网格布局(dataUrl 传空串,只取行列坐标/尺寸)
|
||||||
|
const grid = imagesToSlideElements(images.map(f => ({ file: f.file, dataUrl: '' })))
|
||||||
|
for (let i = 0; i < els.length; i++) {
|
||||||
|
els[i].x = grid[i].x; els[i].y = grid[i].y; els[i].w = grid[i].w; els[i].h = grid[i].h
|
||||||
|
store.addElement('image', { content: els[i].content, x: els[i].x, y: els[i].y, w: els[i].w, h: els[i].h, style: els[i].style }, batch)
|
||||||
|
insertedImages++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (images.length === 1) {
|
||||||
const el = fileToImageElement(images[0].file, images[0].data!)
|
const el = fileToImageElement(images[0].file, images[0].data!)
|
||||||
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
|
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
|
||||||
insertedImages = 1
|
insertedImages = 1
|
||||||
@@ -214,10 +235,11 @@ function doImport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1.5 视频 → 当前幻灯片(每个视频一页,元素铺满合理区域)
|
// 1.5 视频 → 当前幻灯片(OSS 启用时走资产库;每个视频一页,元素铺满合理区域)
|
||||||
if (videos.length > 0) {
|
if (videos.length > 0) {
|
||||||
for (const v of videos) {
|
for (const v of videos) {
|
||||||
const el = fileToVideoElement(v.file, v.data!)
|
const content = isOssEnabled() ? await putAsset(v.file) : v.data!
|
||||||
|
const el = fileToVideoElement(v.file, content)
|
||||||
if (videos.length === 1) {
|
if (videos.length === 1) {
|
||||||
store.addElement('video', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
|
store.addElement('video', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
|
||||||
insertedImages++
|
insertedImages++
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<!-- =====================================================================
|
||||||
|
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: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- 阿里云 -->
|
||||||
|
<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>
|
||||||
@@ -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) */
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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() : ''
|
||||||
|
|||||||
+34
-1
@@ -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'
|
||||||
|
|
||||||
@@ -850,6 +850,37 @@ function setCfg(cfg: AiCfg) {
|
|||||||
safeSet(LS_CFG, JSON.stringify({ ...DEFAULT_CFG, ...cfg }))
|
safeSet(LS_CFG, JSON.stringify({ ...DEFAULT_CFG, ...cfg }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- OSS 配置持久化 ---------- */
|
||||||
|
const LS_OSS = 'u-ppt.oss-cfg.v1'
|
||||||
|
const DEFAULT_OSS: OssCfg = {
|
||||||
|
enabled: false, provider: 'aliyun', dir: 'u-ppt',
|
||||||
|
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
|
||||||
|
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
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 配色建议应用后存储 ---------- */
|
/* ---------- 自定义主题:AI 配色建议应用后存储 ---------- */
|
||||||
const CUSTOM_THEME_KEY = 'u-ppt.themes.v1'
|
const CUSTOM_THEME_KEY = 'u-ppt.themes.v1'
|
||||||
|
|
||||||
@@ -1101,6 +1132,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,
|
||||||
|
|||||||
@@ -189,6 +189,32 @@ export interface AiCfg {
|
|||||||
imgModel?: string
|
imgModel?: 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
|
||||||
|
}
|
||||||
|
|
||||||
/** 文库条目 */
|
/** 文库条目 */
|
||||||
export interface LibItem {
|
export interface LibItem {
|
||||||
id: string
|
id: 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')
|
||||||
|
|||||||
Reference in New Issue
Block a user