Compare commits

..
27 Commits
Author SHA1 Message Date
lxy 4e68773642 修复: 生成质量机制根治——max_tokens截断检测+自动重试/icon白名单机械归一/空image剔除/金句引号伪元素治理/文本溢出扩高+重叠校正/图表y轴自适应防窄幅失真/stat缩字兜底/饼图图例流式排布/卡片icon字体锁定 2026-08-30 17:27:58 +08:00
lxy f7d994842a 修复: list双重标记(自带编号隐藏圆点)+深底accent小字禁用+日期时效基准+整套规划注入单页生成(字号/装饰一致性,pageInstruction双通道共享) 2026-08-30 04:36:28 +08:00
lxy c15d730d1c 测试: sanitizeShapes 形状校正覆盖(等比化/收边/防压字/拼图案/分隔条/数量兜底) 2026-08-30 04:09:53 +08:00
lxy b08bc0e038 修复: AI生成形状怪异治理(拼图案/分隔条/压字机械校正+装饰克制约束)+大纲驱动生成加固(看门狗/页序对齐/空态去重) 2026-08-30 04:07:54 +08:00
lxy c7c94283c0 优化: UI 去 AI 味——SVG 图标体系替代 emoji/字符图标,首页产品化重设计(文档网格+右侧新建栏),主色统一 #5b5bd6,补键盘焦点可见性 2026-08-30 03:32:17 +08:00
lxy dd9c3db530 新增: Agent 大纲协议(outline/gen_page 动作)与设置弹窗统一(UnifiedSettingsModal 归并 Settings/OSS/Agent 三弹窗) 2026-08-30 03:32:04 +08:00
lxy 989d44f5fa 优化: 大纲页数选择重构为标签+一体式分段单选(免下拉一键直达),样式与通道切换统一 2026-08-30 02:57:22 +08:00
lxy 99f38726a2 新增: Agent 中继模式——u-relay 客户端接入远端 agent 指令链 2026-08-29 01:43:34 +08:00
lxy dde53378c3 新增: viewer 批注展示与分享剥离演讲者备注;优化: resolveColor 迁至 bg 纯函数层 2026-08-29 01:43:34 +08:00
lxy eca5b11ea4 新增: 分享链接闭环——OSS 静态发布与观众查看器 2026-08-28 00:26:09 +08:00
lxy daaccb8ad7 新增: OSS 资产库与桌面本地文件读取,交互与云同步增强 2026-08-27 22:22:04 +08:00
lxy e839c62bba 修复: delElement 缺花括号导致删单个元素时误清空多选态 2026-08-25 20:46:03 +08:00
lxy b03bc6ad82 新增: 粘贴文件附件坞与抽屉预览,支持全屏查看 2026-08-25 15:57:40 +08:00
lxy 5d3046b653 新增: 批注/备注/对齐分布/形状扩充/video 元素——编辑器能力增强 2026-08-25 12:30:39 +08:00
lxy af5211d46d 新增: 画布批注层 AnnotationLayer,图片连线+可拖动气泡,SVG+DOM 分层 2026-08-25 12:30:24 +08:00
lxy 8eef4606c5 新增: 全局对话框服务 AppDialog,Promise 风格 appAlert/appConfirm/appPrompt 替代原生弹窗 2026-08-25 12:30:12 +08:00
lxy 0907a104cf 新增: 资料导入增强+剪贴板+框选多选+未保存自动暂存+图片占位 2026-08-24 02:13:15 +08:00
lxy cc804f0749 修复: 演示元素消失+画布错位+PDF横版导出+工具栏信息架构重构 2026-08-24 02:13:15 +08:00
lxy db3b2c24b7 新增: AI 助手 Markdown 渲染+打字机流式+桌面 Rust 代理修复 CORS 2026-08-24 02:13:15 +08:00
lxy 7f08f654d9 修复: op 引擎 undo 逆操作 z序/越界缺陷,补 14 项核心测试 2026-08-24 02:13:15 +08:00
lxy 01f0f45007 优化: 移除死代码(backend/useStore)与过期类型依赖,引入 marked/dompurify/vitest 2026-08-24 02:13:14 +08:00
lxy cf2de7d64d 修复: AI 文档分析进度与错误状态混淆,全成功时不再误显红色错误框 2026-08-17 10:15:19 +08:00
lxy 64d9531b02 重构: 打印导出复用 ElementView,图表/表格/公式不再降级为文本 2026-08-17 10:14:19 +08:00
lxy aad7e6f76f 修复: 本地存储写满静默丢数据——失败告警 + 导入图片体积闸门 2026-08-17 10:11:47 +08:00
lxy f58fea1f68 修复: PDF 导入 worker 本地化,离线/桌面不再依赖 CDN 2026-08-17 10:09:44 +08:00
lxy e2b31579ea 修复: 首页文库打开/新建空白后不切入编辑器 2026-08-17 09:36:52 +08:00
lxy 352496c00a 新增: 首页 + 资料导入 + PDF导出 + AI文档生成 2026-08-17 09:34:12 +08:00
71 changed files with 12086 additions and 1131 deletions
+3
View File
@@ -16,6 +16,9 @@ src-tauri/target/
*.swo
*~
# Claude Code 工作树/临时
.claude/
# OS
.DS_Store
Thumbs.db
+2078
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -5,22 +5,32 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite --mode web",
"dev": "vite",
"dev:web": "vite --mode web",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.2",
"dompurify": "^3.4.14",
"mammoth": "^1.12.0",
"marked": "^18.0.10",
"pdfjs-dist": "^5.6.205",
"vue": "^3.5.13"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/test-utils": "^2.4.11",
"jsdom": "^30.0.1",
"typescript": "~5.6.3",
"vite": "^6.0.5",
"vitest": "^4.1.11",
"vue-tsc": "^2.2.0"
}
}
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# =====================================================================
# publish-viewer.sh — u-ppt 查看器一键发布
# 流程: 构建 → 全量上传七牛 bucket → 可达性核对 → CDN 缓存刷新
# 用法: bash scripts/publish-viewer.sh [--check-only]
# 配套技能: publish-uppt-viewer (踩坑记录见技能文档)
# =====================================================================
set -e
PROJECT="/e/wk-lab/u-ppt"
BUCKET="u-res"
CDN="https://img.1216.top"
cd "$PROJECT"
if [ "$1" != "--check-only" ]; then
echo "== [1/4] 类型检查 + 构建 =="
npx vue-tsc -b || { echo "vue-tsc 失败,中止"; exit 1; }
npm run build || { echo "构建失败,中止"; exit 1; }
cd dist
echo "== [2/4] 全量上传 (viewer.html + assets/*) =="
qshell rput "$BUCKET" viewer.html viewer.html --overwrite > /dev/null 2>&1 || echo "上传 viewer.html 失败"
for f in $(ls assets/); do
qshell rput "$BUCKET" "assets/$f" "assets/$f" --overwrite > /dev/null 2>&1 || echo "上传 assets/$f 失败"
done
else
cd dist
echo "== check-only: 跳过构建上传 =="
fi
echo "== [3/4] 可达性核对 =="
FAIL=0
for f in viewer.html $(ls assets/ | sed 's|^|assets/|'); do
code=$(curl -s -o /dev/null -w "%{http_code}" "$CDN/$f")
if [ "$code" != "200" ]; then echo "FAIL $code $f"; FAIL=1; fi
done
[ "$FAIL" = "0" ] && echo "ALL-200"
echo "== [4/4] 刷新 CDN 缓存 =="
echo "$CDN/viewer.html" > /tmp/uppt-refresh.txt
echo "$CDN/index.html" >> /tmp/uppt-refresh.txt
qshell cdnrefresh -i /tmp/uppt-refresh.txt 2>&1 | grep -o "Code: [0-9]*"
echo "== 完成: $CDN/viewer.html =="
+207 -1
View File
@@ -556,6 +556,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -1279,6 +1280,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "html5ever"
version = "0.38.0"
@@ -1328,6 +1338,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.10.1"
@@ -1826,6 +1842,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2075,6 +2101,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2570,6 +2597,7 @@ dependencies = [
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
@@ -2625,6 +2653,30 @@ dependencies = [
"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]]
name = "ring"
version = "0.17.14"
@@ -3009,6 +3061,17 @@ dependencies = [
"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]]
name = "sha2"
version = "0.10.9"
@@ -3416,6 +3479,64 @@ dependencies = [
"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]]
name = "tauri-runtime"
version = "2.11.3"
@@ -3954,12 +4075,17 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
name = "u-ppt"
version = "1.0.0"
dependencies = [
"base64 0.22.1",
"futures",
"hmac",
"httpdate",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha1",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tokio",
"ts-rs",
]
@@ -4005,6 +4131,12 @@ dependencies = [
"unic-common",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -4544,6 +4676,15 @@ dependencies = [
"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]]
name = "windows-sys"
version = "0.61.2"
@@ -4577,13 +4718,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 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_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 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]]
name = "windows-threading"
version = "0.1.0"
@@ -4614,6 +4772,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -4626,6 +4790,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -4638,12 +4808,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -4656,6 +4838,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -4668,6 +4856,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -4680,6 +4874,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -4692,6 +4892,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "winnow"
version = "0.5.40"
+6 -1
View File
@@ -15,12 +15,17 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
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"] }
futures = "0.3"
hmac = "0.12"
sha1 = "0.10"
base64 = "0.22"
httpdate = "1"
[features]
export-types = []
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "u-ppt 主窗口默认能力:IPC 命令调用 + AI 流式事件订阅",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-unlisten",
"dialog:default"
]
}
+128 -3
View File
@@ -7,6 +7,7 @@
* - AI 流式通过 emit("ai-delta", token) 实时推送到前端
* - 前端检测 __TAURI__ 环境,Web 模式回退到 TS store
* ===================================================================== */
use base64::Engine;
use serde::{Deserialize, Serialize};
use tauri::State;
use tauri::Emitter;
@@ -60,7 +61,7 @@ pub fn exec_op(state: State<AppState>, op_json: serde_json::Value) -> Result<OpR
.map_err(|e| format!("Op 解析失败: {}", e))?;
let mut deck = state.deck.lock().map_err(|e| e.to_string())?;
let backward_op = invert_op(&deck, &op);
let _backward_op = invert_op(&deck, &op);
*deck = apply_op(&deck, &op);
let mut hist = state.history.lock().map_err(|e| e.to_string())?;
@@ -154,7 +155,7 @@ pub fn ping() -> String {
/// 保存 deck 到文件
#[tauri::command]
pub fn save_file(content: String) -> Result<String, String> {
pub fn save_file(_content: String) -> Result<String, String> {
Ok("ok".to_string())
}
@@ -205,8 +206,11 @@ pub async fn ai_proxy_stream(
.map_err(|e| format!("请求失败: {}", e))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let text = resp.text().await.unwrap_or_default();
return Ok(text);
// 错误体带状态码前缀,前端按「接口返回 N」格式提示
let _ = app.emit("ai-error", &text);
return Err(format!("__HTTP_{}__{}", status, text));
}
// 流式读取 SSE
@@ -244,3 +248,124 @@ pub fn write_file(path: String, content: String) -> Result<(), String> {
pub fn read_file(path: String) -> Result<String, String> {
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)
}
+7
View File
@@ -1,13 +1,18 @@
// 本文件的 struct/fn 多数是 TS 端的 Rust 镜像供序列化参考,非死代码
#![allow(dead_code)]
mod ai;
mod color;
mod commands;
mod model;
mod op;
mod oss;
pub fn run() {
let app_state = commands::AppState::new();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(app_state)
.invoke_handler(tauri::generate_handler![
commands::ping,
@@ -24,6 +29,8 @@ pub fn run() {
commands::ai_proxy_stream,
commands::write_file,
commands::read_file,
commands::read_files,
oss::oss_upload,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+1 -1
View File
@@ -337,7 +337,7 @@ pub fn invert_op(input_deck: &Deck, op: &crate::model::Op) -> crate::model::Op {
"del_element" => {
let slide_idx = op.payload.get("slideIdx").or_else(|| op.payload.get("slide_idx")).and_then(|v| v.as_f64()).map(|f| f as usize);
let element_id = op.payload.get("elementId").or_else(|| op.payload.get("element_id")).and_then(|v| v.as_str()).unwrap_or("").to_string();
let _element_id = op.payload.get("elementId").or_else(|| op.payload.get("element_id")).and_then(|v| v.as_str()).unwrap_or("").to_string();
if let Some(deleted) = op.payload.get("deletedElement").or_else(|| op.payload.get("deleted_element")) {
Op {
op_type: "add_element".into(),
+287
View File
@@ -0,0 +1,287 @@
/* =====================================================================
* oss.rs — 对象存储上传(阿里云 OSS / 七牛云 Kodo)
* 在 Rust 端发起 HTTP,规避浏览器 CORS 与密钥暴露:
* - 阿里云:PUT Object + Authorization 头 V1 签名(HMAC-SHA1)
* - 七牛云:表单直传 multipart/form-data + 上传凭证 UpToken
* 前端通过 oss_upload 命令传入 provider/配置/对象 key/文件 base64,返回可访问 URL
* ===================================================================== */
use base64::engine::general_purpose::{STANDARD as B64, URL_SAFE as B64_URLSAFE};
use base64::Engine;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha1::Sha1;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
/// 七牛区域探测结果缓存(<ak>:<bucket> -> 上传地址)。
/// 多文件批量上传时避免每个文件都多一次探测 HTTP 往返。
static QINIU_REGION_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
fn qiniu_region_cache() -> &'static Mutex<HashMap<String, String>> {
QINIU_REGION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
type HmacSha1 = Hmac<Sha1>;
/// 上传参数(provider 决定使用哪组字段)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OssUploadArgs {
/// "aliyun" | "qiniu"
pub provider: String,
/// 对象 key(前端已拼好目录前缀,如 "images/2026/xxx.png")
pub key: String,
/// 文件字节的 base64(标准 base64,非 urlsafe)
pub data_base64: String,
/// MIME 类型(如 image/png);缺省 application/octet-stream
pub content_type: Option<String>,
/* ---- 阿里云 OSS ---- */
pub access_key_id: Option<String>,
pub access_key_secret: Option<String>,
/// endpoint,如 oss-cn-hangzhou.aliyuncs.com(不含 bucket、不含协议)
pub endpoint: Option<String>,
pub bucket: Option<String>,
/* ---- 七牛云 Kodo ---- */
pub access_key: Option<String>,
pub secret_key: Option<String>,
/// 上传域名,如 https://upload.qiniup.com(缺省用该值)
pub up_host: Option<String>,
/// 绑定的公开访问域名,如 https://cdn.example.com(用于拼最终 URL)
pub domain: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct OssUploadResult {
pub url: String,
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// RFC1123 GMT 日期(如 "Wed, 28 Dec 2022 10:27:41 GMT")
fn http_date() -> String {
httpdate::fmt_http_date(SystemTime::now())
}
fn hmac_sha1(key: &[u8], msg: &[u8]) -> Vec<u8> {
let mut mac = HmacSha1::new_from_slice(key).expect("HMAC 接受任意长度 key");
mac.update(msg);
mac.finalize().into_bytes().to_vec()
}
/// 命令入口:按 provider 分发
#[tauri::command]
pub async fn oss_upload(args: OssUploadArgs) -> Result<OssUploadResult, String> {
let bytes = B64
.decode(args.data_base64.as_bytes())
.map_err(|e| format!("文件数据解码失败: {}", e))?;
let content_type = args
.content_type
.clone()
.unwrap_or_else(|| "application/octet-stream".to_string());
match args.provider.as_str() {
"aliyun" => upload_aliyun(&args, bytes, &content_type).await,
"qiniu" => upload_qiniu(&args, bytes, &content_type).await,
other => Err(format!("不支持的 OSS 服务商: {}", other)),
}
}
/// 阿里云 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)
}
+377 -18
View File
@@ -2,20 +2,34 @@
App.vue — 根组件:编辑/演示模式切换、工具栏、三栏布局、弹窗
===================================================================== -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { store } from './core/store'
import { readAsDataURL, fileToImageElement } from './core/importer'
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 Icon from './components/common/Icon.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 ThumbBar from './components/editor/ThumbBar.vue'
import Canvas from './components/editor/Canvas.vue'
import PropsPanel from './components/editor/PropsPanel.vue'
import AiPanel from './components/ai/AiPanel.vue'
import SettingsModal from './components/modals/SettingsModal.vue'
import UnifiedSettingsModal, { type SettingsTab } from './components/modals/UnifiedSettingsModal.vue'
import LibraryModal from './components/modals/LibraryModal.vue'
import TemplateModal from './components/modals/TemplateModal.vue'
import ImportModal from './components/modals/ImportModal.vue'
import PrintModal from './components/modals/PrintModal.vue'
import PresentMode from './components/present/PresentMode.vue'
import HomePage from './components/HomePage.vue'
/* ---------- 模式 ---------- */
const mode = ref<'editor' | 'present'>('editor')
const mode = ref<'home' | 'editor' | 'present'>('home')
const presentVisible = ref(false)
const presentStartIndex = ref(0)
@@ -27,8 +41,21 @@ function switchTab(name: 'props' | 'ai') {
/* ---------- 弹窗 ---------- */
const settingsVisible = ref(false)
const settingsTab = ref<SettingsTab>('ai')
function openSettings(tab: SettingsTab = 'ai') {
settingsTab.value = tab
settingsVisible.value = true
}
const libraryVisible = ref(false)
const templateVisible = ref(false)
const importVisible = ref(false)
const printVisible = ref(false)
const deckLoaded = ref(false) // 文库/导入是否加载了新 deck,防止误切
const importModalRef = ref<InstanceType<typeof ImportModal> | null>(null)
function dropFilesIntoImport(files: File[]) {
importModalRef.value?.acceptDroppedFiles(files)
}
/* ---------- toast ---------- */
const toastText = ref('')
@@ -69,7 +96,6 @@ function onSave() {
libraryVisible.value = true
}
}
function openAi() { switchTab('ai') }
/* ---------- 导出/导入 ---------- */
function onExportJson() {
@@ -96,6 +122,7 @@ function onImportJson() {
try {
store.importJSON(reader.result as string)
toast('已导入')
if (mode.value === 'home') mode.value = 'editor'
} catch (e: any) {
toast('导入失败:' + (e?.message || String(e)))
}
@@ -105,13 +132,78 @@ function onImportJson() {
input.click()
}
function onNewBlank() {
store.newBlankDeck()
deckLoaded.value = true
mode.value = 'editor'
}
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)
openSettings('oss')
} else {
await appAlert('分享失败', msg)
}
}
}
function onImportClose() {
importVisible.value = false
if (mode.value === 'home') {
const slides = store.getSlides()
// 不是仅有 1 页空白页时视为已导入内容
if (slides.length > 1 || slides[0]?.elements?.length > 0) {
mode.value = 'editor'
}
}
}
function onExportPdf() {
// 用浏览器打印 + @media print CSS 实现 PDF 导出
// 演示模式的 slide-layer 已有 print 样式
document.body.classList.add('printing')
window.print()
setTimeout(() => document.body.classList.remove('printing'), 500)
toast('在打印对话框中选择「另存为 PDF」')
printVisible.value = true
}
/* ---------- 全局快捷键(编辑模式) ---------- */
@@ -122,6 +214,8 @@ function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (settingsVisible.value) { settingsVisible.value = false; return }
if (libraryVisible.value) { libraryVisible.value = false; return }
// 多选态:先退多选
if (store.getSelection().length > 1) { store.selectElement(null); return }
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
@@ -134,10 +228,43 @@ function onKey(e: KeyboardEvent) {
if (inField) return; e.preventDefault(); if (store.redo()) toast('已重做'); return
}
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault(); if (!inField) toast('已自动保存到本地'); return
e.preventDefault(); onSave(); return
}
if (e.key === 'Delete' && !inField) {
/* 元素剪贴:输入框内放行原生行为(复制文本等);多选时作用于整组 */
if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C') && !inField) {
const sel = store.getSelection()
const ids = sel.length > 1 ? sel : (store.getSelectedId() ? [store.getSelectedId()!] : [])
if (ids.length && store.copyElements(ids)) { e.preventDefault(); toast(`已复制 ${ids.length} 个元素`) }
return
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'x' || e.key === 'X') && !inField) {
const sel = store.getSelection()
const ids = sel.length > 1 ? sel : (store.getSelectedId() ? [store.getSelectedId()!] : [])
if (ids.length && store.cutElements(ids)) { e.preventDefault(); toast(`已剪切 ${ids.length} 个元素`) }
return
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V') && !inField) {
if (store.pasteElements()) { e.preventDefault(); }
return
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'd' || e.key === 'D') && !inField) {
const sel = store.getSelection()
const ids = sel.length > 1 ? sel : (store.getSelectedId() ? [store.getSelectedId()!] : [])
if (ids.length && store.copyElements(ids) && store.pasteElements()) { e.preventDefault(); toast('已复制副本') }
return
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A') && !inField) {
e.preventDefault()
store.selectAllElements()
return
}
/* Delete/Backspace:批注选中时让位给 AnnotationLayer 删批注,否则删元素 */
if ((e.key === 'Delete' || e.key === 'Backspace') && !inField) {
if (store.selectedAnnoId.value) return
const sel = store.getSelection()
if (sel.length > 1) { e.preventDefault(); store.delElements(sel); return }
const id = store.getSelectedId()
if (id) { e.preventDefault(); store.delElement(id) }
}
@@ -145,24 +272,226 @@ function onKey(e: KeyboardEvent) {
onMounted(() => {
document.addEventListener('keydown', onKey)
document.addEventListener('paste', onPaste)
window.addEventListener('dragover', onGlobalDragOver)
window.addEventListener('drop', onGlobalDrop)
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/文本) ---------- */
async function onPaste(e: ClipboardEvent) {
if (document.body.dataset.mode === 'present') return
// 输入框内保持原生粘贴文本
const t = e.target as HTMLElement
if (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable) return
const cd = e.clipboardData
if (!cd) return
// 1) 图片(截图/复制的图片文件)→ 图片元素
const items = cd.items
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault()
const file = item.getAsFile()
if (!file) return
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 quotaErr = checkImageQuota(dataUrl)
if (quotaErr) { toast(quotaErr); return }
const el = fileToImageElement(file, dataUrl)
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style })
toast('已插入剪贴板图片')
} catch (err: any) {
toast('读取剪贴板图片失败:' + (err?.message || String(err)))
}
return
}
}
// 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
const text = cd.getData('text/plain')
if (!text || !text.trim()) return
e.preventDefault()
const trimmed = text.trim()
// 图片直链(常见图床/截图床格式)
if (/^https?:\/\/\S+\.(png|jpe?g|gif|webp|svg|bmp|ico)(\?\S*)?$/i.test(trimmed)) {
store.addElement('image', { content: trimmed, x: 30, y: 20, w: 40, h: 45, style: { fit: 'contain', anim: 'fade' } })
toast('已插入图片链接')
return
}
// 普通文本 → 文本元素(多行按 list 更合适则用 list)
const lines = trimmed.split(/\n+/).filter(Boolean)
if (lines.length >= 3) {
store.addElement('list', { content: lines.join('\n'), x: 10, y: 25, w: 55, h: 45, style: { fontSize: 24, align: 'left', anim: 'fade-up' } })
toast(`已插入列表(${lines.length} 行)`)
} else {
const isTitle = trimmed.length <= 30 && lines.length === 1
store.addElement(isTitle ? 'title' : 'text', {
content: trimmed,
x: 10, y: isTitle ? 8 : 25, w: 70, h: isTitle ? 12 : 15,
style: isTitle ? { fontSize: 44, align: 'left', anim: 'fade-up' } : { fontSize: 24, align: 'left', anim: 'fade-up' }
})
toast(isTitle ? '已插入标题' : '已插入文本')
}
}
/* ---------- 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 直接导入,其他文件进资料导入弹窗 ---------- */
const globalDragOver = ref(false)
function onGlobalDragOver(e: DragEvent) {
if (!e.dataTransfer?.types.includes('Files')) return
e.preventDefault() // 阻止浏览器默认打开文件
e.dataTransfer.dropEffect = 'copy'
if (!globalDragOver.value) globalDragOver.value = true
}
function onGlobalDragLeave(e: DragEvent) {
if (e.relatedTarget) return // 目标还在窗口内
globalDragOver.value = false
}
async function onGlobalDrop(e: DragEvent) {
// 桌面拖拽全权交给原生事件通道,防止双触发
if (isTauri()) { e.preventDefault(); return }
const fl = e.dataTransfer?.files
if (!fl || fl.length === 0) return
e.preventDefault()
globalDragOver.value = false
// 单个 .json 文件 → 直接导入 deck(与工具栏导入同路径)
if (fl.length === 1 && /\.json$/i.test(fl[0].name)) {
await importJsonFile(fl[0])
return
}
// 其他文件 → 打开资料导入弹窗并预填
importVisible.value = true
await nextTick()
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(防止静默丢数据) */
watch(() => store.saveWarning.text, (txt) => {
if (txt) toast(txt)
})
/* 文库关闭时若在首页且从文库加载了 deck 则切入编辑 */
watch(libraryVisible, (now, prev) => {
if (prev && !now && deckLoaded.value) {
deckLoaded.value = false
if (mode.value === 'home') mode.value = 'editor'
}
})
onUnmounted(() => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('paste', onPaste)
window.removeEventListener('dragover', onGlobalDragOver)
window.removeEventListener('drop', onGlobalDrop)
window.removeEventListener('dragleave', onGlobalDragLeave)
})
</script>
<template>
<!-- ===================== 首页 ===================== -->
<HomePage
v-show="mode === 'home'"
@new-blank="onNewBlank"
@open-library="libraryVisible = true"
@import-materials="importVisible = true"
@open-deck="deckLoaded = true; mode = 'editor'"
@ai-create="onNewBlank(); switchTab('ai')"
@toast="toast"
@open-settings="openSettings('ai')"
/>
<!-- ===================== 编辑模式 ===================== -->
<div v-show="mode === 'editor'" class="app-editor">
<Toolbar
:disabled-actions="disabledActions"
@present="onPresent"
@open-library="libraryVisible = true"
@open-settings="settingsVisible = true"
@open-ai="openAi"
@open-settings="openSettings('ai')"
@save="onSave"
@open-templates="templateVisible = true"
@export-json="onExportJson"
@import-materials="onImportMaterials"
@home="onHome"
@share="onShare"
@import-json="onImportJson"
@export-pdf="onExportPdf"
/>
@@ -177,8 +506,8 @@ onUnmounted(() => {
<!-- 右:Tabs(属性 / AI) -->
<aside class="side-panel">
<div class="panel-tabs">
<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 === 'props' }" @click="switchTab('props')"><Icon name="sliders" :size="13" /> 属性</button>
<button class="panel-tab" :class="{ active: activeTab === 'ai' }" @click="switchTab('ai')"><Icon name="sparkles" :size="13" /> AI 助手</button>
</div>
<PropsPanel v-show="activeTab === 'props'" />
@@ -187,7 +516,8 @@ onUnmounted(() => {
v-show="activeTab === 'ai'"
@busy-change="onBusyChange"
@toast="toast"
@open-settings="settingsVisible = true"
@open-settings="openSettings('ai')"
@open-relay-settings="openSettings('relay')"
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai')"
/>
</aside>
@@ -202,8 +532,9 @@ onUnmounted(() => {
/>
<!-- ===================== 弹窗 ===================== -->
<SettingsModal
<UnifiedSettingsModal
:visible="settingsVisible"
:initial-tab="settingsTab"
@close="settingsVisible = false"
@toast="toast"
/>
@@ -212,13 +543,41 @@ onUnmounted(() => {
@close="libraryVisible = false"
@toast="toast"
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai')"
@open-deck="mode = 'editor'"
/>
<TemplateModal
:visible="templateVisible"
@close="templateVisible = false"
@toast="toast"
/>
<ImportModal
ref="importModalRef"
:visible="importVisible"
@close="onImportClose"
@toast="toast"
@open-settings="openSettings('ai')"
/>
<PrintModal
v-if="printVisible"
@close="printVisible = false"
/>
<!-- 轻提示 -->
<div class="toast" :class="{ show: toastShow }">{{ toastText }}</div>
<!-- 全局拖放提示遮罩 -->
<div v-if="globalDragOver" class="global-drop-overlay">
<div class="global-drop-card">
<span class="global-drop-icon"><Icon name="download" :size="44" /></span>
<strong>松开导入文件</strong>
<span class="global-drop-hint">.json 直接导入 · 图片/文档进入资料导入</span>
</div>
</div>
<!-- 会话态附件坞与右侧预览抽屉 -->
<FileDock />
<FilePreviewDrawer />
<!-- 全局统一对话框(appAlert/appConfirm/appPrompt) -->
<AppDialog />
</template>
+356
View File
@@ -0,0 +1,356 @@
<!-- =====================================================================
HomePage.vue — 首页:产品化文档网格
布局逻辑:顶栏(品牌+全局动作)→ 工作区(左主列=文档网格,右侧栏=新建入口)
去装饰化:无光晕/无居中 hero,内容即界面;文档缩略图是主视觉
===================================================================== -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import Icon from './common/Icon.vue'
import { store, resolveBg } from '../core/store'
import type { LibItem } from '../core/types'
import ElementView from './editor/ElementView.vue'
const emit = defineEmits<{
(e: 'new-blank'): void
(e: 'open-library'): void
(e: 'import-materials'): void
(e: 'open-settings'): void
(e: 'toast', msg: string): void
(e: 'open-deck'): void
(e: 'ai-create'): void
}>()
const libVersion = ref(0)
const library = computed<LibItem[]>(() => {
void libVersion.value
return store.getLibrary()
})
function bump() { libVersion.value++ }
onMounted(bump)
function loadItem(id: string) {
if (store.loadFromLibrary(id)) {
emit('toast', '已加载:' + (library.value.find(x => x.id === id)?.name || ''))
emit('open-deck')
}
}
function removeItem(e: Event, id: string) {
e.stopPropagation()
const item = library.value.find(x => x.id === id)
store.deleteFromLibrary(id)
emit('toast', '已从文库移除:' + (item?.name || ''))
}
function formatTime(ts: number): string {
if (!ts) return ''
const diff = Date.now() - ts
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前'
const d = new Date(ts)
return d.getMonth() + 1 + '/' + d.getDate()
}
function firstSlideTitle(item: LibItem): string {
const slide = item?.deck?.slides?.[0]
const el = slide?.elements?.find(e => e.type === 'title')
return el?.content?.trim() || '无标题'
}
/* 上次未入库的工作区草稿(改了没保存就关/刷新),提示继续编辑 */
const workDraft = computed(() => {
if (!store.getActiveLibId() && store.hasWorkDeck()) {
const d = store.getDeck()
const t = d.slides[0]?.elements.find(e => e.type === 'title')
return { title: t?.content || '未命名草稿', pages: d.slides.length }
}
return null
})
</script>
<template>
<div class="home">
<!-- 顶栏:品牌 + 全局动作 -->
<header class="home-topbar">
<div class="tb-brand">
<span class="tb-logo">▦</span>
<span class="tb-name">u-ppt</span>
</div>
<div class="tb-actions">
<button class="tb-btn ghost" @click="emit('open-library')">
<Icon name="book" :size="15" /> 文库
</button>
<button class="tb-btn ghost" @click="emit('open-settings')">
<Icon name="settings" :size="15" /> 设置
</button>
</div>
</header>
<div class="home-body">
<!-- 左主列:文档网格 -->
<main class="home-main">
<!-- 草稿恢复横条 -->
<button v-if="workDraft" class="draft-resume" @click="emit('open-deck')">
<span class="draft-icon"><Icon name="play" :size="13" /></span>
<span class="draft-info">
<strong>继续编辑「{{ workDraft.title }}」</strong>
<span>{{ workDraft.pages }} 页 · 上次未保存的草稿已自动暂存</span>
</span>
<Icon name="chevron-right" :size="16" class="draft-arrow" />
</button>
<div class="section-head">
<h2 class="section-title">我的文档</h2>
<span class="section-count">{{ library.length }}</span>
</div>
<div v-if="library.length" class="doc-grid">
<button
v-for="item in library"
:key="item.id"
class="doc-card"
:title="item.name"
@click="loadItem(item.id)"
>
<span class="doc-thumb" :style="{ background: item.deck.slides[0] ? resolveBg(item.deck.slides[0].background) : '#fff' }">
<span v-if="item.deck.slides[0]?.elements?.length" class="doc-thumb-inner">
<ElementView v-for="el in item.deck.slides[0].elements" :key="el.id" :el="el" :bg="item.deck.slides[0].background" />
</span>
<span v-else class="doc-thumb-empty"><Icon name="file" :size="20" /></span>
</span>
<span class="doc-meta">
<span class="doc-name">{{ item.name }}</span>
<span class="doc-sub">
<span class="doc-title">{{ firstSlideTitle(item) }}</span>
<span class="doc-time">{{ item.deck.slides.length }} 页 · {{ formatTime(item.updatedAt || item.createdAt) }}</span>
</span>
</span>
<span class="doc-remove" title="从文库移除" @click="removeItem($event, item.id)">
<Icon name="trash" :size="13" />
</span>
</button>
</div>
<div v-else class="doc-empty">
<p class="doc-empty-title">还没有文档</p>
<p class="doc-empty-sub">从右侧开始你的第一份演示</p>
</div>
</main>
<!-- 右侧栏:新建入口 -->
<aside class="home-side">
<button class="side-card primary" @click="emit('ai-create')">
<span class="side-icon"><Icon name="sparkles" :size="18" /></span>
<span class="side-text">
<strong>AI 创作</strong>
<span>输入主题,生成全套演示</span>
</span>
</button>
<button class="side-card" @click="emit('new-blank')">
<span class="side-icon"><Icon name="plus" :size="18" /></span>
<span class="side-text">
<strong>新建空白</strong>
<span>从空白页开始创作</span>
</span>
</button>
<button class="side-card" @click="emit('import-materials')">
<span class="side-icon"><Icon name="upload" :size="18" /></span>
<span class="side-text">
<strong>导入资料</strong>
<span>图片 / PDF / DOCX</span>
</span>
</button>
</aside>
</div>
</div>
</template>
<style scoped>
.home {
height: 100vh;
display: flex;
flex-direction: column;
background: var(--ui-bg, #f1f5f9);
overflow: hidden;
}
/* ===== 顶栏 ===== */
.home-topbar {
height: 52px; flex-shrink: 0;
display: flex; align-items: center; justify-content: space-between;
padding: 0 20px;
background: var(--ui-panel, #fff);
border-bottom: 1px solid var(--ui-border, #e2e8f0);
}
.tb-brand { display: flex; align-items: baseline; gap: 6px; }
.tb-logo { color: var(--ui-primary, #5b5bd6); font-size: 18px; font-weight: 800; }
.tb-name { font-size: 16px; font-weight: 700; letter-spacing: .3px; color: var(--ui-text, #1e293b); }
.tb-actions { display: flex; gap: 6px; }
.tb-btn {
display: inline-flex; align-items: center; gap: 5px;
height: 30px; padding: 0 12px;
border: 1px solid transparent; border-radius: var(--radius-sm, 6px);
background: transparent; font-size: 13px; color: var(--ui-muted, #64748b);
}
.tb-btn:hover { background: var(--ui-hover, #f1f5f9); color: var(--ui-text, #1e293b); }
/* ===== 主体:左文档 + 右新建 ===== */
.home-body {
flex: 1; min-height: 0;
display: grid;
grid-template-columns: 1fr 248px;
gap: 24px;
padding: 24px 28px 28px;
max-width: 1200px;
width: 100%;
margin: 0 auto;
overflow: hidden;
}
.home-main { min-width: 0; overflow-y: auto; padding-right: 4px; }
/* 草稿恢复横条 */
.draft-resume {
display: flex; align-items: center; gap: 12px;
width: 100%; padding: 12px 16px; margin-bottom: 20px;
border: 1px solid color-mix(in srgb, var(--ui-primary, #5b5bd6) 35%, var(--ui-border, #e2e8f0));
border-radius: var(--radius, 10px);
background: color-mix(in srgb, var(--ui-primary, #5b5bd6) 5%, var(--ui-panel, #fff));
cursor: pointer; text-align: left;
transition: box-shadow .15s;
}
.draft-resume:hover { box-shadow: var(--shadow-md); }
.draft-icon {
width: 30px; height: 30px; border-radius: 50%;
background: var(--ui-primary, #5b5bd6); color: #fff;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.draft-icon svg { margin-left: 2px; }
.draft-info { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
.draft-info strong { font-size: 13.5px; color: var(--ui-text, #1e293b); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.draft-info span { font-size: 12px; color: var(--ui-muted, #64748b); }
.draft-arrow { color: var(--ui-muted, #64748b); flex-shrink: 0; }
/* 区块标题 */
.section-head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.section-title {
font-size: 14px; font-weight: 600;
color: var(--ui-text, #1e293b);
margin: 0;
}
.section-count {
font-size: 11px; font-weight: 600; color: var(--ui-muted, #64748b);
background: var(--ui-hover, #f1f5f9);
padding: 1px 7px; border-radius: 99px;
}
/* ===== 文档网格 ===== */
.doc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(196px, 1fr));
gap: 14px;
}
.doc-card {
position: relative;
display: flex; flex-direction: column; gap: 0;
border: 1px solid var(--ui-border, #e2e8f0);
border-radius: var(--radius, 10px);
background: var(--ui-panel, #fff);
overflow: hidden; cursor: pointer; text-align: left; padding: 0;
transition: box-shadow .15s, border-color .15s, transform .1s;
}
.doc-card:hover {
border-color: color-mix(in srgb, var(--ui-primary, #5b5bd6) 45%, var(--ui-border, #e2e8f0));
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.doc-thumb {
position: relative; width: 100%; aspect-ratio: 16 / 9;
border-bottom: 1px solid var(--ui-border, #e2e8f0);
overflow: hidden; background: #fff; display: block;
container-type: inline-size; /* 供 cqw 计算自适应缩放 */
}
.doc-thumb-inner {
position: absolute; top: 0; left: 0; width: 1280px; height: 720px;
/* 100cqw = 缩略图实际宽度,scale = 宽/1280,列宽变化时缩略图精确等比 */
transform: scale(calc(100cqw / 1280)); transform-origin: top left;
pointer-events: none; display: block;
}
.doc-thumb-empty {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: var(--ui-border, #cbd5e1);
}
.doc-meta {
display: flex; flex-direction: column; gap: 3px;
padding: 10px 12px 11px; min-width: 0;
}
.doc-name {
font-size: 13.5px; font-weight: 600; color: var(--ui-text, #1e293b);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.doc-sub { display: flex; align-items: center; gap: 6px; min-width: 0; }
.doc-title {
font-size: 12px; color: var(--ui-muted, #64748b);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0;
}
.doc-time { font-size: 11.5px; color: var(--ui-muted, #64748b); opacity: .8; flex-shrink: 0; }
.doc-remove {
position: absolute; top: 6px; right: 6px; z-index: 2;
width: 24px; height: 24px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
background: rgba(15, 23, 42, .55); color: #fff;
opacity: 0; transition: opacity .12s, background .12s;
}
.doc-card:hover .doc-remove { opacity: 1; }
.doc-remove:hover { background: var(--ui-danger, #e11d48); }
/* 空态 */
.doc-empty {
padding: 64px 0; text-align: center;
border: 1.5px dashed var(--ui-border, #e2e8f0);
border-radius: var(--radius, 10px);
}
.doc-empty-title { font-size: 14px; font-weight: 600; color: var(--ui-text, #1e293b); margin: 0 0 4px; }
.doc-empty-sub { font-size: 12.5px; color: var(--ui-muted, #64748b); margin: 0; }
/* ===== 右侧栏:新建入口 ===== */
.home-side {
display: flex; flex-direction: column; gap: 10px;
align-self: start;
position: sticky; top: 0;
}
.side-card {
display: flex; align-items: flex-start; gap: 12px;
padding: 14px 16px;
border: 1px solid var(--ui-border, #e2e8f0);
border-radius: var(--radius, 10px);
background: var(--ui-panel, #fff);
cursor: pointer; text-align: left;
transition: border-color .15s, box-shadow .15s;
}
.side-card:hover { border-color: var(--ui-border, #cbd5e1); box-shadow: var(--shadow-sm); }
.side-card.primary {
background: var(--ui-primary, #5b5bd6);
border-color: var(--ui-primary, #5b5bd6);
}
.side-card.primary:hover {
background: #4a4ac4;
box-shadow: 0 4px 14px color-mix(in srgb, var(--ui-primary, #5b5bd6) 35%, transparent);
}
.side-card.primary .side-text strong { color: #fff; }
.side-card.primary .side-text span { color: rgba(255, 255, 255, .78); }
.side-icon {
width: 32px; height: 32px; border-radius: 8px;
background: var(--ui-primary-soft, #eeeefc); color: var(--ui-primary, #5b5bd6);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.side-card.primary .side-icon { background: rgba(255, 255, 255, .16); color: #fff; }
.side-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.side-text strong { font-size: 14px; font-weight: 600; color: var(--ui-text, #1e293b); }
.side-text span { font-size: 12px; color: var(--ui-muted, #64748b); }
</style>
+510 -146
View File
@@ -1,89 +1,35 @@
<!-- =====================================================================
AiPanel.vue — AI 聊天面板
发送/停止/生成整套/润色本页/流式渲染/操作应用
AiPanel.vue — AI 聊天面板(双通道:直连 LLM / u-relay Agent)
通道切换/发送分流/流式渲染/操作应用;direct 走 chatLog 持久化,
agent 走内存 msgs(不持久化),op 应用统一走 applyOp
===================================================================== -->
<script setup lang="ts">
import { ref, nextTick, computed, watch } from 'vue'
import type { ChatMessage, AiOp, Slide } from '../../core/types'
import { ref, nextTick, computed, watch, onMounted, onUnmounted } from 'vue'
import type { ChatMessage, AiOp, Outline, Slide } from '../../core/types'
import { store } from '../../core/store'
import { generate, polish, chat, beautifyPage, isConfigured } from '../../core/ai'
import { polish, chat, beautifyPage, isConfigured, buildAgentPrompt, parseChatReply, pageInstruction } from '../../core/ai'
import { relay, type RelayStatus } from '../../core/relay'
import { elementTypes } from '../../core/sample'
import { renderMd } from '../../core/markdown'
import { appConfirm } from '../../core/dialog'
import OutlinePanel from './OutlinePanel.vue'
/* ---------- 消息内容渲染:提取 JSON 代码块并格式化 ---------- */
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** 尝试把 JSON 字符串格式化(缩进),失败则返回原文 */
function tryFormatJson(raw: string): string {
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch (e) { return raw }
}
/** 对格式化后的 JSON 做轻量语法着色(不引依赖,纯正则) */
function highlightJson(code: string): string {
return escapeHtml(code)
.replace(/("(?:\\.|[^"\\])*"\s*:)/g, '<span class="jk">$1</span>')
.replace(/:\s*("(?:\\.|[^"\\])*")/g, ': <span class="js">$1</span>')
.replace(/:\s*(-?\d+\.?\d*)/g, ': <span class="jn">$1</span>')
.replace(/:\s*(true|false|null)/g, ': <span class="jb">$1</span>')
}
interface RenderedPart {
type: 'text' | 'code'
html: string
lang?: string
}
/** 把消息内容拆分为文本段 + JSON 代码块段 */
function renderMessageContent(content: string): RenderedPart[] {
if (!content) return []
const parts: RenderedPart[] = []
// 匹配 ```json ... ``` 或 ``` ... ``` 代码块
const codeBlockRe = /```(\w*)\n?([\s\S]*?)```/g
let lastIdx = 0
let m: RegExpExecArray | null
while ((m = codeBlockRe.exec(content)) !== null) {
// 代码块前的文本
if (m.index > lastIdx) {
const text = content.slice(lastIdx, m.index)
if (text.trim()) parts.push({ type: 'text', html: escapeHtml(text) })
}
const lang = m[1] || ''
const code = m[2]
const trimmed = code.trim()
// JSON 代码块 → 格式化 + 着色
if (!lang || lang === 'json') {
parts.push({ type: 'code', lang: 'json', html: highlightJson(tryFormatJson(trimmed)) })
} else {
parts.push({ type: 'code', lang, html: escapeHtml(trimmed) })
}
lastIdx = m.index + m[0].length
}
// 尾部文本
if (lastIdx < content.length) {
const text = content.slice(lastIdx)
if (text.trim()) parts.push({ type: 'text', html: escapeHtml(text) })
}
// 没有代码块 → 返回整个为文本段
if (!parts.length) parts.push({ type: 'text', html: escapeHtml(content) })
return parts
}
import Icon from '../common/Icon.vue'
const emit = defineEmits<{
(e: 'busy-change', busy: boolean): void
(e: 'toast', msg: string): void
(e: 'open-settings'): void
(e: 'open-relay-settings'): void
(e: 'switch-tab', tab: string): void
}>()
const CHAT_KEY = 'u-ppt.chat.v1' // 旧 key,仅用于迁移
const AGENT_CHAT_PREFIX = 'u-ppt.agentchat.v1.' // agent 会话持久化前缀(按 chatId 隔离)
/** 当前会话绑定的 chatId(跟随 deck.chatId) */
const currentChatId = computed(() => store.getChatId())
/** 渲染用消息条目(带可选的流式/标签/error 状态) */
/** 渲染用消息条目(带可选的流式/标签/error 状态;direct/agent 通道共用结构) */
interface RenderMsg {
key: number
role: 'user' | 'assistant' | 'system'
@@ -93,8 +39,29 @@ interface RenderMsg {
error?: boolean
}
/* ---------- 通道 ---------- */
const channel = ref<'direct' | 'agent'>('direct')
const status = ref<RelayStatus>(relay.getStatus())
const statusDetail = ref('')
const agentConfigured = computed(() => relay.isConfigured())
const STATUS_LABEL: Record<RelayStatus, string> = {
disabled: '未启用',
connecting: '连接中',
connected: '已连接',
reconnecting: '重连中',
error: '连接失败'
}
const renderMsgs = ref<RenderMsg[]>([])
/** agent 通道消息(按 chatId 持久化到 localStorage,tag/error/streaming 状态不存) */
const agentMsgs = ref<RenderMsg[]>([])
/** 当前通道渲染的消息源 */
const viewMsgs = computed(() => (channel.value === 'direct' ? renderMsgs.value : agentMsgs.value))
const chatLog = ref<ChatMessage[]>(loadChat())
/** agent 通道当前大纲(agent 返回 outline op 后填充,传给 OutlinePanel) */
const agentOutline = ref<Outline | null>(null)
const messagesEl = ref<HTMLElement | null>(null)
const inputEl = ref<HTMLTextAreaElement | null>(null)
@@ -128,9 +95,48 @@ function persistChat() {
}, 400)
}
/* ---------- agent 会话持久化(独立前缀,结构与 ChatMessage 一致,tag/error 不存) ---------- */
let agentSaveTimer: ReturnType<typeof setTimeout> | null = null
/** 加载该 chatId 的 agent 会话(剥掉持久化字段) */
function loadAgentChat(): RenderMsg[] {
try {
const list: ChatMessage[] = JSON.parse(localStorage.getItem(AGENT_CHAT_PREFIX + currentChatId.value) || '[]') || []
return list.map(m => ({ key: ++keySeq, role: m.role as 'user' | 'assistant', content: m.content }))
} catch (e) { return [] }
}
/** 防抖持久化 agent 会话(仅 role/content,quota 溢出静默丢弃旧条目) */
function persistAgentChat() {
if (agentSaveTimer) clearTimeout(agentSaveTimer)
agentSaveTimer = setTimeout(() => {
const list: ChatMessage[] = agentMsgs.value.map(m => ({ role: m.role === 'system' ? 'assistant' : m.role, content: m.content }))
// 超 1.5MB 时成对丢弃最旧条目(参考 store.setChat)
while (list.length > 2) {
if (JSON.stringify(list).length <= 1_500_000) break
list.splice(0, 2)
}
try { localStorage.setItem(AGENT_CHAT_PREFIX + currentChatId.value, JSON.stringify(list)) } catch (e) { /* quota 满静默丢弃 */ }
}, 400)
}
/** agent 通道追加一条消息(内存 + 防抖持久化) */
function addAgentMsg(m: RenderMsg) {
agentMsgs.value.push(m)
persistAgentChat()
}
/** 清空 agent 会话(内存 + localStorage) */
function clearAgentChat() {
agentMsgs.value = []
try { localStorage.removeItem(AGENT_CHAT_PREFIX + currentChatId.value) } catch (e) {}
}
function setBusy(b: boolean) {
busy.value = b
emit('busy-change', b)
// 停止/完成后焦点回输入框,继续对话无需重新点击
if (!b) nextTick(() => inputEl.value?.focus())
}
function scrollBottom() {
@@ -159,10 +165,20 @@ function addPersisted(role: ChatMessage['role'], text: string) {
scrollBottom()
}
/** 在跑的流式气泡集(组件卸载时统一停 rAF) */
const activeStreams = new Set<StreamCtrl>()
/** 创建一条流式气泡(返回控制器对象) */
interface StreamCtrl {
msg: RenderMsg
started: boolean
/** 打字机缓冲:网络 delta 先入队,rAF 匀速释放渲染(参考 devflow 流式体验) */
buffer: string
shown: string
rafId: number | null
flushPending: boolean
/** 冲刷完成回调(持久化等收尾动作) */
settled?: (() => void) | null
}
function streamBubble(placeholder?: string): StreamCtrl {
const msg: RenderMsg = {
@@ -173,33 +189,80 @@ function streamBubble(placeholder?: string): StreamCtrl {
}
renderMsgs.value.push(msg)
scrollBottom()
return { msg, started: !placeholder }
const ctrl: StreamCtrl = { msg, started: !placeholder, buffer: '', shown: '', rafId: null, flushPending: false }
activeStreams.add(ctrl)
return ctrl
}
/** rAF 匀速释放缓冲:每帧最多放 CHARS_PER_FRAME 字符,网络抖动时也不突兀蹦字 */
const CHARS_PER_FRAME = 4
function pumpTypewriter(s: StreamCtrl) {
if (s.rafId != null) return
s.rafId = requestAnimationFrame(() => {
s.rafId = null
if (!s.buffer.length) {
// 缓冲排空:流已结束则收尾,否则待下一批 delta
if (s.flushPending) {
s.msg.streaming = false
s.flushPending = false
s.settled?.()
s.settled = null
}
return
}
// 每帧固定放 4 字符 ≈ 240 字符/秒,网络抖动时也保持匀速观感
const take = Math.min(s.buffer.length, CHARS_PER_FRAME)
s.shown += s.buffer.slice(0, take)
s.buffer = s.buffer.slice(take)
s.msg.content = s.shown
scrollBottom()
if (s.buffer.length || s.flushPending) pumpTypewriter(s)
})
}
function streamOnVisible(s: StreamCtrl) {
return (t: string) => {
if (!s.started) { s.msg.content = ''; s.started = true }
s.msg.content += t
scrollBottom()
if (!s.started) { s.msg.content = ''; s.shown = ''; s.started = true }
s.buffer += t
pumpTypewriter(s)
}
}
function streamDone(s: StreamCtrl) {
s.msg.streaming = false
if (!s.msg.content) s.msg.content = ''
/** 流结束:标记待冲刷(缓冲剩余字符继续匀速放完再收尾),可选收尾回调 */
function streamFlush(s: StreamCtrl, onSettled?: () => void) {
s.settled = onSettled || null
s.flushPending = true
if (!s.buffer.length) {
// 缓冲已空:立即收尾
s.msg.streaming = false
s.flushPending = false
s.settled?.()
s.settled = null
return
}
pumpTypewriter(s)
}
function streamDone(s: StreamCtrl, onSettled?: () => void) {
streamFlush(s, onSettled)
}
function streamSetText(s: StreamCtrl, txt: string) {
// 外部直接设置最终文本(跳过打字机,用于错误/提示)
if (s.rafId != null) { cancelAnimationFrame(s.rafId); s.rafId = null }
s.buffer = ''
s.shown = txt
s.msg.content = txt
s.msg.streaming = false
s.flushPending = false
}
function streamError(s: StreamCtrl, msg: string) {
streamSetText(s, msg)
s.msg.error = true
s.msg.content = '⚠ ' + msg
s.msg.streaming = false
}
function streamTag(s: StreamCtrl, txt: string) {
if (txt) s.msg.tag = txt
}
/** 应用 AI 返回的操作到 store */
/** 应用 AI 返回的操作到 store(direct/agent 通道统一入口;target 越界 clamp,跨页跳转) */
function applyOp(op: AiOp, lockedIdx: number): string {
const slides = op.slides
if (op.action === 'create_all' && slides.length) {
@@ -215,6 +278,11 @@ function applyOp(op: AiOp, lockedIdx: number): string {
let t = op.target != null ? op.target : lockedIdx
t = Math.max(0, Math.min(t, store.getCount() - 1))
store.replaceSlide(t, slides[0])
// 目标页与发送时页不同:跳转过去让用户直接看到改动
if (t !== lockedIdx) {
store.setCurrentIndex(t)
return '已更新第 ' + (t + 1) + ' 页并跳转'
}
return '已更新第 ' + (t + 1) + ' 页'
}
return ''
@@ -226,14 +294,167 @@ function persistStream(s: StreamCtrl) {
persistChat()
}
/* ---------- Agent 通道:relay handler 挂接 ---------- */
/** 进行中的 agent 请求:request_id → 流式气泡 */
const inflight = new Map<string, RenderMsg>()
/** 单一忙碌请求的 rid(用于「停止」) */
let agentBusyRid: string | null = null
/** 请求级看门狗:agent 180s 无响应则按停止语义释放,防挂死 */
let agentWatchdog: ReturnType<typeof setTimeout> | null = null
const AGENT_TIMEOUT_MS = 180_000
/** 当前 inflight 单页生成对应的大纲条目下标(null=非单页请求),结果到达时按此对齐写入 */
let agentPendingPageIdx: number | null = null
function onRelayStatus(s: RelayStatus, detail?: string) {
status.value = s
statusDetail.value = detail || ''
}
function onRelayProgress(rid: string, text: string) {
const m = inflight.get(rid)
if (!m) return
m.content = text
m.streaming = true
scrollBottom()
}
function onRelayResult(rid: string, text: string) {
let m = inflight.get(rid)
inflight.delete(rid)
relay.settle(rid)
if (rid === agentBusyRid) {
agentBusyRid = null
if (agentWatchdog) { clearTimeout(agentWatchdog); agentWatchdog = null }
setBusy(false)
}
if (m) {
m.content = text
m.streaming = false
} else {
m = { key: ++keySeq, role: 'assistant', content: text }
agentMsgs.value.push(m)
}
// SEP 协议解析与 op 应用(与 direct 通道共用 applyOp,行为一致)
const { reply, op } = parseChatReply(m.content)
m.content = reply || '(无文字回复)'
if (op && op.action === 'outline' && op.outline) {
// agent 返回大纲:填充 topic 后交给 OutlinePanel(作为 initialOutline),自动展开面板
op.outline.topic = op.outline.topic || agentOutlineTopic.value
agentOutline.value = op.outline
showOutline.value = true
} else if (op && (op.action === 'gen_page' || op.action === 'add_page') && op.slides.length) {
// 大纲单页生成结果:有大纲则按请求时记录的条目下标对齐回收(面板关闭也照写画布),回收后驱动下一条(串行全部生成)
if (agentOutline.value) {
outlinePanelEl.value?.acceptAgentSlide(op.slides[0], agentPendingPageIdx != null ? agentPendingPageIdx : undefined)
agentPendingPageIdx = null
driveNextOutlinePage()
} else {
toast('收到页面结果但无活动大纲,已忽略')
agentPendingPageIdx = null
}
} else if (op && op.action !== 'answer' && op.slides.length) {
m.tag = applyOp(op, store.getCurrentIndex())
}
addAgentMsg(m)
scrollBottom()
}
relay.setHandlers({ onStatus: onRelayStatus, onProgress: onRelayProgress, onResult: onRelayResult })
onMounted(() => {
// 面板挂载即连(配置齐备才连);卸载不断开(切 Tab 不掉线,relay 单例保底)
if (relay.isConfigured()) relay.connect()
})
function onToggleConnect() {
if (status.value === 'connected' || status.value === 'connecting' || status.value === 'reconnecting') {
relay.disconnect()
} else {
relay.connect()
}
}
/** 切换通道 */
function switchChannel(c: 'direct' | 'agent') {
channel.value = c
nextTick(scrollBottom)
}
/** agent 通道发送(发送分流与快捷操作共用;带多轮历史与选中元素上下文) */
function sendAgent(input: string) {
if (!agentConfigured.value) { toast('请先在设置中配置 Agent 中继'); emit('open-relay-settings'); return }
if (status.value !== 'connected') {
toast('中继未连接,请稍候')
agentFailCurrent('')
return
}
// busy 守卫:同一时刻只允许一个 agent 请求,防止并发覆盖 agentBusyRid 打断串行链
if (agentBusyRid) { toast('当前有生成任务进行中'); return }
// 历史取最近 10 条(剥离 tag 后缀),不含本次输入
const history = agentMsgs.value.slice(-10).map(m => ({
role: m.role,
content: m.content.replace(/\n?\[✓[^\]]*\]$/g, '').replace(/\n?(已停止)$/g, '')
}))
addAgentMsg({ key: ++keySeq, role: 'user', content: input })
const prompt = buildAgentPrompt(input, selectedEl.value, history)
try {
const rid = relay.request(prompt)
agentBusyRid = rid
const m: RenderMsg = { key: ++keySeq, role: 'assistant', content: '', streaming: true }
addAgentMsg(m)
inflight.set(rid, m)
setBusy(true)
// 请求级看门狗:180s 无响应按停止语义释放,防请求挂死
if (agentWatchdog) clearTimeout(agentWatchdog)
agentWatchdog = setTimeout(() => {
agentWatchdog = null
if (rid !== agentBusyRid) return
const pageIdx = agentPendingPageIdx
stopAgent()
toast('agent 响应超时')
if (pageIdx != null) toast('第 ' + (pageIdx + 1) + ' 页生成失败,可手动重试')
}, AGENT_TIMEOUT_MS)
} catch (e: any) {
addAgentMsg({ key: ++keySeq, role: 'assistant', content: (e?.message || String(e)), error: true })
agentFailCurrent(e?.message || String(e))
}
scrollBottom()
}
/** agent 请求失败收尾:清看门狗/占位,复位 busy 允许手动续发;单页请求时提示带页码的失败信息 */
function agentFailCurrent(reason: string) {
if (agentWatchdog) { clearTimeout(agentWatchdog); agentWatchdog = null }
agentBusyRid = null
const pageIdx = agentPendingPageIdx
agentPendingPageIdx = null
if (busy.value) setBusy(false)
if (pageIdx != null) toast('第 ' + (pageIdx + 1) + ' 页生成失败' + (reason ? ':' + reason : '') + ',可手动重试')
else if (reason) toast(reason)
}
/** agent 通道停止:放弃匹配槽,气泡标「(已停止)」 */
function stopAgent() {
if (!agentBusyRid) return
const rid = agentBusyRid
agentBusyRid = null
if (agentWatchdog) { clearTimeout(agentWatchdog); agentWatchdog = null }
const m = inflight.get(rid)
inflight.delete(rid)
relay.settle(rid)
if (m) {
m.streaming = false
if (!m.content) m.content = '(已停止)'
else m.content += '\n(已停止)'
}
setBusy(false)
}
/* ---------- 发送 ---------- */
function onSend() {
if (busy.value) return
const text = inputText.value.trim()
if (!text) {
if (!isConfigured()) { emit('open-settings'); return }
return
}
if (!text) return
if (channel.value === 'agent') { inputText.value = ''; sendAgent(text); return }
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
inputText.value = ''
runChat(text)
@@ -241,7 +462,11 @@ function onSend() {
async function runChat(input: string) {
addPersisted('user', input)
const history = chatLog.value.slice(-10).map(m => ({ role: m.role, content: m.content }))
// history 发送前剥离系统追加的 [✓ ...] 标记行,避免污染 AI 上下文
const history = chatLog.value.slice(-10).map(m => ({
role: m.role,
content: m.content.replace(/\n?\[✓[^\]]*\]$/g, '')
}))
const idx0 = store.getCurrentIndex()
const stream = streamBubble()
@@ -256,18 +481,24 @@ async function runChat(input: string) {
signal: abortCtrl.signal,
selectedElement: selectedEl.value
})
streamDone(stream)
if (!r.reply) {
streamSetText(stream, '(已完成)')
}
// op 应用与 tag 立即执行;持久化等打字机冲刷完成(内容已齐)再落
if (r.op && r.op.action !== 'answer' && r.op.slides.length) {
const applied = applyOp(r.op, idx0)
streamTag(stream, applied)
}
persistStream(stream)
streamDone(stream, () => {
if (!stream.msg.content) streamSetText(stream, '(已完成)')
persistStream(stream)
})
} catch (e: any) {
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
else streamError(stream, e?.message || String(e))
if (e?.name === 'AbortError') {
// 停止:放弃缓冲中未显示的部分之外,把已显示内容立即固化
if (stream.rafId != null) { cancelAnimationFrame(stream.rafId); stream.rafId = null }
stream.msg.streaming = false
stream.msg.content = stream.shown ? stream.shown + '\n(已停止)' : '(已停止)'
} else {
streamError(stream, e?.message || String(e))
}
persistStream(stream)
} finally {
setBusy(false); abortCtrl = null
@@ -275,46 +506,31 @@ async function runChat(input: string) {
}
function onStop() {
if (channel.value === 'agent') { stopAgent(); return }
if (abortCtrl) abortCtrl.abort()
}
/* ---------- 生成整套 ---------- */
async function onGenerate() {
/* ---------- 生成整套:统一入口,打开大纲面板并聚焦主题输入(大纲驱动创作) ---------- */
function onGenerate() {
if (busy.value) return
const topic = inputText.value.trim() || prompt('请输入演示主题,例如「远程办公的兴起与未来」')
if (!topic) return
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
inputText.value = ''
const stream = streamBubble('正在创作「' + topic + '」…')
setBusy(true); abortCtrl = new AbortController()
try {
const r = await generate({ topic, signal: abortCtrl.signal })
streamDone(stream)
// 生成整套 → 开新会话,旧会话保留在 localStorage
store.replaceDeck({ theme: store.theme.value, slides: r.slides }, { newChat: true })
// watch(currentChatId) 会自动清空 renderMsgs 并加载新会话(空的)
// 在新会话里记录这次生成
addPersisted('user', '✨ 生成整套:' + topic)
addPersisted('assistant', '✅ 已生成 ' + r.slides.length + ' 页演示。可在画布查看与微调,Ctrl+Z 可撤销。')
} catch (e: any) {
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
else streamError(stream, e?.message || String(e))
persistStream(stream)
} finally {
setBusy(false); abortCtrl = null
}
// 输入框已有主题则预填进大纲面板(沿用旧版「输入框即主题」习惯),并清空聊天输入避免两处重复
const prefill = inputText.value.trim()
if (prefill) inputText.value = ''
showOutline.value = true
nextTick(() => outlinePanelEl.value?.focusTopic(prefill))
}
/* ---------- 润色本页 ---------- */
async function onPolish() {
if (busy.value) return
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
const slide = store.currentSlide.value
if (!slide) return
const idx0 = store.getCurrentIndex()
const idx = idx0 + 1
addPersisted('user', '🪄 润色第 ' + idx + ' 页')
// agent 通道:转自然语言指令走中继
if (channel.value === 'agent') { sendAgent('润色第 ' + idx + ' 页:让内容更有吸引力、表达更精炼,保持布局合理'); return }
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
addPersisted('user', '润色第 ' + idx + ' 页')
const stream = streamBubble('正在润色第 ' + idx + ' 页…')
setBusy(true); abortCtrl = new AbortController()
@@ -322,7 +538,7 @@ async function onPolish() {
const r = await polish({ slide: slide as Slide, instruction: '让内容更有吸引力、表达更精炼,保持布局合理', signal: abortCtrl.signal })
streamDone(stream)
if (idx0 < store.getCount()) store.replaceSlide(idx0, r.slide)
streamSetText(stream, '🪄 已润色第 ' + idx + ' 页' + (r.note ? ':' + r.note : '') + '。Ctrl+Z 可撤销。')
streamSetText(stream, '已润色第 ' + idx + ' 页' + (r.note ? ':' + r.note : '') + '。Ctrl+Z 可撤销。')
persistStream(stream)
} catch (e: any) {
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
@@ -334,27 +550,69 @@ async function onPolish() {
}
/* ---------- 清空 ---------- */
function onClear() {
async function onClear() {
if (channel.value === 'agent') {
if (!agentMsgs.value.length) { toast('对话已是空的'); return }
if (!(await appConfirm('清空对话记录?', '将清空当前 PPT 的 Agent 对话记录', { danger: true, okText: '清空' }))) return
clearAgentChat()
return
}
if (!chatLog.value.length) { toast('对话已是空的'); return }
if (!confirm('清空当前 PPT 的对话记录?')) return
if (!(await appConfirm('清空对话记录?', '将清空当前 PPT 的对话记录', { danger: true, okText: '清空' }))) return
chatLog.value = []
store.clearChat(currentChatId.value)
renderMsgs.value = []
}
/* ---------- 大纲面板 ---------- */
/* ---------- 大纲面板(双通道通用;agent 通道走 outline/gen_page op) ---------- */
const outlinePanelEl = ref<InstanceType<typeof OutlinePanel> | null>(null)
/** agent 通道大纲主题暂存(OutlinePanel 主题输入回传,用于填充 outline.topic) */
const agentOutlineTopic = ref('')
function onToggleOutline() {
showOutline.value = !showOutline.value
}
/** OutlinePanel(agent 通道)转发指令:把生成大纲/单页的自然语言指令发给中继 Agent */
function onOutlineRequestAgent(instruction: string, pageIdx?: number) {
if (!instruction.startsWith('按大纲生成')) {
// 生成大纲指令:记住主题,agent 返回 outline 后回填 topic;期间面板进入生成中状态(agentBusyRid 驱动面板忙态)
const m = instruction.match(/主题「([^」]+)」/)
if (m) agentOutlineTopic.value = m[1]
agentOutline.value = null
agentPendingPageIdx = null
} else {
// 单页生成:记录目标条目下标,结果到达时按此对齐写入(避免扫首个未完成错位)
agentPendingPageIdx = pageIdx != null ? pageIdx : null
}
sendAgent(instruction)
}
/** agent 单页生成完成后的串行驱动:OutlinePanel 逐页回收后自动发下一条未完成条目 */
function driveNextOutlinePage() {
const panel = outlinePanelEl.value
if (!panel || !agentOutline.value) return
const next = agentOutline.value.items.findIndex(it => !it.done)
if (next < 0) { toast('大纲全部页已生成'); return }
panel.requestAgent(pageInstruction(agentOutline.value!.items[next], next, agentOutline.value!.items.length, curAgentPlan()), next)
agentPendingPageIdx = next
}
/** agent 通道当前大纲 → 整套规划摘要(注入单页指令,约束套内风格一致) */
function curAgentPlan() {
const o = agentOutline.value
return o ? { title: o.title, items: o.items.map(it => ({ kind: it.kind, title: it.title })) } : undefined
}
/* ---------- 一键美化 ---------- */
async function onBeautify() {
if (busy.value) return
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
const deck = store.getDeck()
const slides = deck.slides
if (!slides || !slides.length) { toast('当前没有幻灯片可美化'); return }
addPersisted('user', '🎨 一键美化全部 (' + slides.length + ' 页)')
// agent 通道:转自然语言指令走中继
if (channel.value === 'agent') { sendAgent('美化当前演示的全部 ' + slides.length + ' 页,让视觉与排版更精致'); return }
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
addPersisted('user', '一键美化全部 (' + slides.length + ' 页)')
const stream = streamBubble('正在美化第 1/' + slides.length + ' 页…')
setBusy(true); abortCtrl = new AbortController()
@@ -369,7 +627,7 @@ async function onBeautify() {
done++
}
streamDone(stream)
streamSetText(stream, '✅ 已美化 ' + done + ' 页')
streamSetText(stream, '已美化 ' + done + ' 页')
persistStream(stream)
} catch (e: any) {
if (e?.name === 'AbortError') streamSetText(stream, '(已停止,已美化 ' + done + ' 页)')
@@ -389,13 +647,27 @@ function isBusy() { return busy.value }
function focus() { nextTick(() => inputEl.value?.focus()) }
defineExpose({ isBusy, focus })
/* ---------- 初始化:从持久化记录重建 ---------- */
/* ---------- 初始化:从持久化记录重建(direct + agent 双通道) ---------- */
rebuildFromChatLog()
agentMsgs.value = loadAgentChat()
/* ---------- 会话切换:chatId 变化时重新加载对话 ---------- */
/* 组件卸载:停掉所有在跑的打字机 rAF;不断开 relay(切 Tab 不掉线) */
onUnmounted(() => {
for (const s of activeStreams) {
if (s.rafId != null) cancelAnimationFrame(s.rafId)
}
activeStreams.clear()
})
/* ---------- 会话切换:chatId 变化时重新加载对话(direct + agent 双通道) ---------- */
watch(currentChatId, () => {
// 有 inflight agent 请求先按停止语义清理(含看门狗/busy 复位),避免旧会话结果串进新会话
if (agentBusyRid) stopAgent()
agentPendingPageIdx = null
chatLog.value = loadChat()
rebuildFromChatLog()
agentMsgs.value = loadAgentChat()
agentOutline.value = null
scrollBottom()
})
</script>
@@ -403,16 +675,51 @@ watch(currentChatId, () => {
<template>
<div class="panel-pane ai-pane">
<div class="ai-actions">
<button class="ai-action" :disabled="busy" @click="onGenerate">✨ 生成整套</button>
<button class="ai-action" :disabled="busy" @click="onPolish">🪄 润色本页</button>
<button class="ai-action" :disabled="busy" @click="onToggleOutline">📋 大纲</button>
<button class="ai-action" :disabled="busy" @click="onBeautify">🎨 美化</button>
<button class="ai-action ghost" :disabled="busy" @click="onClear">🗑 清空</button>
<button class="ai-action" :disabled="busy" @click="onGenerate"><Icon name="sparkles" :size="14" /> 生成整套</button>
<button class="ai-action" :disabled="busy" @click="onPolish"><Icon name="wand" :size="14" /> 润色本页</button>
<button class="ai-action" :disabled="busy" @click="onToggleOutline"><Icon name="clipboard" :size="14" /> 大纲</button>
<button class="ai-action" :disabled="busy" @click="onBeautify"><Icon name="palette" :size="14" /> 美化</button>
<button class="ai-action ghost" :disabled="busy" @click="onClear"><Icon name="trash" :size="14" /> 清空</button>
<!-- 通道切换(segmented):agent 段带状态色点 -->
<div class="channel-switch">
<button class="seg" :class="{ active: channel === 'direct' }" @click="switchChannel('direct')"><Icon name="zap" :size="14" /> 直连</button>
<button
class="seg"
:class="{ active: channel === 'agent' }"
:title="statusDetail ? STATUS_LABEL[status] + ' · ' + statusDetail : STATUS_LABEL[status]"
@click="switchChannel('agent')"
>
<i class="seg-dot" :class="status"></i>Agent
</button>
</div>
</div>
<OutlinePanel v-if="showOutline" :visible="showOutline" @busy-change="setBusy" @toast="toast" @open-settings="emit('open-settings')" />
<!-- agent 通道:连接控制 + 未配置空态 -->
<div v-if="channel === 'agent' && agentConfigured" class="agent-status-bar">
<span class="status-badge" :class="status">
<i class="dot"></i>{{ STATUS_LABEL[status] }}{{ statusDetail ? ' · ' + statusDetail : '' }}
</span>
<button class="agent-action ghost" @click="onToggleConnect">
{{ (status === 'connected' || status === 'connecting' || status === 'reconnecting') ? '断开' : '连接' }}
</button>
<button class="agent-action ghost" @click="emit('open-relay-settings')">设置</button>
</div>
<!-- 选中元素提示条:告诉用户 AI 会围绕这个元素对话 -->
<OutlinePanel
v-if="showOutline"
ref="outlinePanelEl"
:visible="showOutline"
:initial-outline="channel === 'agent' ? agentOutline : null"
:agent-channel="channel === 'agent'"
:agent-busy="channel === 'agent' && busy"
@busy-change="setBusy"
@toast="toast"
@open-settings="emit('open-settings')"
@request-agent="onOutlineRequestAgent"
/>
<!-- 选中元素提示条:两通道共用,告诉 AI 会围绕这个元素对话 -->
<div v-if="selectedHint" class="selected-hint" title="AI 对话将基于此选中元素">
<span class="selected-hint-icon">④</span>
<span class="selected-hint-text">已选中:{{ selectedHint }}</span>
@@ -420,20 +727,32 @@ watch(currentChatId, () => {
</div>
<div class="chat-messages" ref="messagesEl">
<div v-if="!renderMsgs.length" class="chat-empty">
告诉我你的主题,例如:<br />
「生成一份关于<b>远程办公趋势</b>的演示」
<!-- agent 未配置空态 -->
<div v-if="channel === 'agent' && !agentConfigured" class="chat-empty">
未配置 Agent 中继。<br />
三项配置(中继 URL / Token / 设备ID)齐备即启用<br />
<button class="btn primary go-settings" @click="emit('open-relay-settings')">去设置</button>
</div>
<div v-for="m in renderMsgs" :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>
<template v-for="(p, pi) in renderMessageContent(m.content)" :key="pi">
<pre v-if="p.type === 'code'" class="msg-code" :data-lang="p.lang"><code v-html="p.html"></code></pre>
<span v-else class="stream-text" v-html="p.html"></span>
<template v-else>
<div v-if="!viewMsgs.length && !showOutline" class="chat-empty">
<template v-if="channel === 'agent'">
通过中继把指令发给远端 Agent,例如:<br />
「把当前页的标题<b>改得更有冲击力</b>」
</template>
<template v-else>
告诉我你的主题,例如:<br />
「生成一份关于<b>远程办公趋势</b>的演示」
</template>
<span v-if="m.streaming" class="cursor"></span>
</div>
</div>
<div v-for="m in viewMsgs" :key="m.key" class="msg" :class="m.role">
<div class="bubble" :class="{ error: m.error }">
<span v-if="m.tag" class="diff-tag"><Icon name="check" :size="12" /> {{ m.tag }}</span>
<!-- Markdown 整段渲染(含代码块/列表/加粗等,经 DOMPurify 消毒) -->
<div v-if="m.content" class="md-body" v-html="renderMd(m.content)"></div>
<span v-if="m.streaming" class="cursor"></span>
</div>
</div>
</template>
</div>
<div class="chat-input-bar">
@@ -442,14 +761,59 @@ watch(currentChatId, () => {
v-model="inputText"
id="chatInput"
rows="3"
placeholder="输入指令,回车发送(Shift+Enter 换行)"
:disabled="busy"
:placeholder="channel === 'agent' && !agentConfigured ? '请先配置中继' : '输入指令,回车发送(Shift+Enter 换行)'"
:disabled="channel === 'agent' && !agentConfigured"
@keydown="onInputKeydown"
></textarea>
<div class="btns">
<button v-if="!busy" class="btn primary" @click="onSend">发送</button>
<button v-if="!busy" class="btn primary" :disabled="channel === 'agent' && !agentConfigured" @click="onSend">发送</button>
<button v-else class="btn danger" @click="onStop">停止</button>
</div>
</div>
</div>
</template>
<style scoped>
/* ---------- 通道切换(segmented,与 28px ai-action 协调) ---------- */
.channel-switch {
display: inline-flex;
margin-left: auto;
border: 1px solid var(--ui-border, #ddd);
border-radius: 6px;
overflow: hidden;
}
.seg {
display: inline-flex; align-items: center; gap: 5px;
height: 26px; padding: 0 9px;
border: none; background: none; cursor: pointer;
font-size: 12px; color: var(--ui-text-secondary, #888);
}
.seg + .seg { border-left: 1px solid var(--ui-border, #ddd); }
.seg.active { background: var(--ui-primary-soft, #eef); color: var(--ui-primary, #4f6ef7); font-weight: 600; }
.seg-dot { width: 7px; height: 7px; border-radius: 50%; background: #9ca3af; }
.seg-dot.connected { background: #22c55e; }
.seg-dot.connecting, .seg-dot.reconnecting { background: #f59e0b; animation: seg-pulse 1.2s infinite; }
.seg-dot.error { background: #ef4444; }
@keyframes seg-pulse { 50% { opacity: 0.3; } }
/* ---------- agent 状态条(自原 AgentPanel 迁入) ---------- */
.agent-status-bar {
display: flex; align-items: center; gap: 8px;
padding: 6px 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: seg-pulse 1.2s infinite; }
.status-badge.error .dot { background: #ef4444; }
.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); }
.go-settings { margin-top: 10px; }
</style>
+199 -33
View File
@@ -3,34 +3,95 @@
生成大纲 → 逐条编辑 → 逐页/全部生成 → 应用到文稿
===================================================================== -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import type { Outline, OutlineItem, Slide } from '../../core/types'
import { store } from '../../core/store'
import { outline as genOutline, generatePage, isConfigured } from '../../core/ai'
import { outline as genOutline, generatePage, pageInstruction, isConfigured } from '../../core/ai'
import Icon from '../common/Icon.vue'
defineProps<{ visible: boolean }>()
const props = defineProps<{ visible: boolean; initialOutline?: Outline | null; agentChannel?: boolean; agentBusy?: boolean }>()
const emit = defineEmits<{
(e: 'busy-change', busy: boolean): void
(e: 'toast', msg: string): void
(e: 'open-settings'): void
/** agent 通道:把生成大纲/单页的指令交由 AiPanel 转发给中继 Agent(单页时携带目标条目下标) */
(e: 'request-agent', instruction: string, pageIdx?: number): void
}>()
const outline = ref<Outline | null>(null)
const topicInput = ref('')
const topicEl = ref<HTMLInputElement | null>(null)
const titleInput = ref('')
const editingTitle = ref(false)
const busy = ref(false)
/** agent 通道忙态(由 AiPanel 的 agentBusyRid 经 prop 下传):请求进行中禁用交互防并发打断 */
const agentBusy = computed(() => !!props.agentBusy)
/** 面板级总忙态:本地生成忙 or agent 请求忙 */
const locked = computed(() => busy.value || agentBusy.value)
const progress = ref({ cur: 0, total: 0 })
const generatedSlides = ref<Slide[]>([])
const pageCount = ref<'auto' | number>('auto')
/** 页数分段选项:自动=AI 按主题信息量裁量,其余为指定页数 */
const pageCountOpts: { value: 'auto' | number; label: string; tip: string }[] = [
{ value: 'auto', label: '自动', tip: '按主题信息量由 AI 裁量(5-12 页)' },
{ value: 5, label: '5', tip: '固定 5 页' },
{ value: 7, label: '7', tip: '固定 7 页' },
{ value: 10, label: '10', tip: '固定 10 页' },
{ value: 12, label: '12', tip: '固定 12 页' }
]
const expanded = ref<Set<string>>(new Set())
let abortCtrl: AbortController | null = null
function toggleExpand(id: string) {
const s = expanded.value
s.has(id) ? s.delete(id) : s.add(id)
expanded.value = new Set(s)
}
/** 首个生成页:整体替换文稿(保留主题),后续页追加;返回写入的页索引 */
function commitSlide(slide: Slide, isFirst: boolean): number {
if (isFirst) {
store.replaceDeck({ theme: store.theme.value, slides: [slide] }, { newChat: true })
return 0
}
store.appendSlide(slide)
return store.getCount() - 1
}
/** 挂载/外部传入大纲变化时(agent 通道返回 outline op),进入编辑态 */
onMounted(() => {
if (props.initialOutline) applyInitialOutline(props.initialOutline)
})
watch(() => props.initialOutline, (o) => { if (o) applyInitialOutline(o) })
function applyInitialOutline(o: Outline) {
outline.value = o
titleInput.value = o.title
generatedSlides.value = []
expanded.value = new Set()
}
/** 接收 agent 通道生成的单页(由 AiPanel 在 op 返回后调用;pageIdx 为请求时记录的目标条目下标) */
function acceptAgentSlide(slide: Slide, pageIdx?: number) {
// 优先按请求时记录的条目下标对齐;无记录时退回扫首个未完成;generatedSlides 与 items 同索引(空位补 undefined 占位)
const scanned = outline.value ? outline.value.items.findIndex(it => !it.done) : -1
const idx = pageIdx != null ? pageIdx : scanned
const at = idx >= 0 ? idx : generatedSlides.value.length
while (generatedSlides.value.length < at) generatedSlides.value.push(undefined as unknown as Slide)
generatedSlides.value[at] = slide
const isFirst = doneCount.value === 0
if (idx >= 0) outline.value!.items[idx].done = true
const committed = commitSlide(slide, isFirst)
store.setCurrentIndex(committed)
}
/** kind → 徽标文本与颜色 */
const KIND_META: Record<OutlineItem['kind'], { label: string; color: string }> = {
cover: { label: '封面', color: '#4f46e5' },
cover: { label: '封面', color: '#5b5bd6' },
toc: { label: '目录', color: '#06b6d4' },
content: { label: '内容', color: '#64748b' },
quote: { label: '金句', color: '#f59e0b' },
end: { label: '结尾', color: '#4f46e5' }
end: { label: '结尾', color: '#5b5bd6' }
}
function setBusy(b: boolean) {
@@ -44,17 +105,25 @@ const allDone = computed(() => total.value > 0 && doneCount.value === total.valu
/* ---------- 生成大纲 ---------- */
async function onGenOutline() {
if (busy.value) return
if (locked.value) return
const topic = topicInput.value.trim()
if (!topic) { emit('toast', '请先输入主题'); return }
// agent 通道:转自然语言指令走中继,大纲由 agent 返回(AiPanel 填充 initialOutline)
if (props.agentChannel) {
emit('request-agent', pageCount.value === 'auto'
? '拟定大纲:主题「' + topic + '」,页数按主题信息量自动裁量(5-12页)'
: '拟定大纲:主题「' + topic + '」,约 ' + pageCount.value + ' 页')
return
}
if (!isConfigured()) { emit('open-settings'); return }
setBusy(true)
abortCtrl = new AbortController()
try {
const r = await genOutline({ topic, signal: abortCtrl.signal })
const r = await genOutline({ topic, count: pageCount.value, signal: abortCtrl.signal })
outline.value = r
titleInput.value = r.title
generatedSlides.value = []
expanded.value = new Set()
emit('toast', '已生成 ' + r.items.length + ' 条大纲')
} catch (e: any) {
if (e?.name === 'AbortError') emit('toast', '已停止')
@@ -85,11 +154,18 @@ function removeItem(idx: number) {
outline.value.items.splice(idx, 1)
}
/** 当前大纲 → 整套规划摘要(注入单页生成,约束套内风格一致) */
function curPlan() {
return outline.value ? { title: outline.value.title, items: outline.value.items.map(it => ({ kind: it.kind, title: it.title })) } : undefined
}
/* ---------- 生成单页 ---------- */
async function onGenOne(idx: number) {
if (busy.value || !outline.value) return
if (locked.value || !outline.value) return
const item = outline.value.items[idx]
if (!item) return
// agent 通道:指令交由 AiPanel 转发(携带目标条目下标用于结果对齐),结果经 acceptAgentSlide 回收
if (props.agentChannel) { emit('request-agent', pageInstruction(item, idx, outline.value.items.length, curPlan()), idx); return }
setBusy(true)
abortCtrl = new AbortController()
try {
@@ -97,11 +173,15 @@ async function onGenOne(idx: number) {
item,
index: idx,
total: outline.value.items.length,
plan: curPlan(),
signal: abortCtrl.signal
})
// 保持 generatedSlides 与 items 顺序对齐
const isFirst = doneCount.value === 0
generatedSlides.value[idx] = slide
item.done = true
const committed = commitSlide(slide, isFirst)
store.setCurrentIndex(committed)
emit('toast', '已生成第 ' + (idx + 1) + ' 页')
} catch (e: any) {
if (e?.name === 'AbortError') emit('toast', '已停止')
@@ -113,11 +193,19 @@ async function onGenOne(idx: number) {
/* ---------- 全部生成(串行) ---------- */
async function onGenAll() {
if (busy.value || !outline.value) return
if (locked.value || !outline.value) return
const items = outline.value.items
// agent 通道:发首条指令即返回,AiPanel 在每页回收后驱动下一条(见 onOutlineRequestAgent),保证串行与页面顺序对齐
if (props.agentChannel) {
const next = items.findIndex(it => !it.done)
if (next < 0) { emit('toast', '全部条目已生成'); return }
emit('request-agent', pageInstruction(items[next], next, items.length, curPlan()), next)
return
}
setBusy(true)
abortCtrl = new AbortController()
progress.value = { cur: 0, total: items.length }
let committedCount = doneCount.value
try {
for (let i = 0; i < items.length; i++) {
if (abortCtrl.signal.aborted) break
@@ -126,10 +214,15 @@ async function onGenAll() {
item: items[i],
index: i,
total: items.length,
plan: curPlan(),
signal: abortCtrl.signal
})
const isFirst = committedCount === 0
generatedSlides.value[i] = slide
items[i].done = true
committedCount++
const committed = commitSlide(slide, isFirst)
store.setCurrentIndex(committed)
}
emit('toast', '全部生成完成(' + items.filter(it => it.done).length + '/' + items.length + ')')
} catch (e: any) {
@@ -146,25 +239,42 @@ function onStop() {
if (abortCtrl) abortCtrl.abort()
}
/* ---------- 应用到文稿 ---------- */
function onApply() {
if (!outline.value) return
const slides = generatedSlides.value.filter(Boolean)
if (!slides.length) { emit('toast', '请先生成至少一页'); return }
store.replaceDeck({ theme: store.theme.value, slides }, { newChat: true })
emit('toast', '已应用 ' + slides.length + ' 页到文稿')
}
defineExpose({
acceptAgentSlide,
requestAgent: (s: string, idx?: number) => emit('request-agent', s, idx),
/** 供「生成整套」入口聚焦主题输入框;prefill 为可选预填主题(来自聊天输入框) */
focusTopic: (prefill?: string) => {
if (prefill) topicInput.value = prefill
nextTick(() => topicEl.value?.focus())
}
})
</script>
<template>
<div class="outline-panel">
<!-- 主题输入 + 生成大纲 -->
<div class="outline-top">
<input type="text" v-model="topicInput" placeholder="输入主题,例如「人工智能的产业落地」" :disabled="busy" @keydown.enter="onGenOutline" />
<button class="btn primary" :disabled="busy" @click="onGenOutline">✨ 生成大纲</button>
<input ref="topicEl" type="text" v-model="topicInput" placeholder="输入主题,例如「人工智能的产业落地」" :disabled="locked" @keydown.enter="onGenOutline" />
<button class="btn primary" :disabled="locked" @click="onGenOutline"><Icon name="sparkles" :size="14" /> 生成大纲</button>
<button v-if="busy" class="btn danger" @click="onStop">停止</button>
</div>
<!-- 页数选择:一体式分段单选(与通道切换 .seg 同语言,一键直达免下拉) -->
<div class="outline-count" role="radiogroup" aria-label="生成页数">
<span class="count-label">页数</span>
<div class="count-seg">
<button
v-for="opt in pageCountOpts" :key="String(opt.value)"
type="button" role="radio" :aria-checked="pageCount === opt.value"
class="count-pill" :class="{ active: pageCount === opt.value }"
:title="opt.tip" :disabled="locked"
@click="pageCount = opt.value"
>{{ opt.label }}</button>
</div>
<span class="count-hint" v-if="pageCount === 'auto'">AI 裁量</span>
<span class="count-hint" v-else>固定 {{ pageCount }} 页</span>
</div>
<!-- 无大纲时的空态 -->
<div v-if="!outline" class="outline-empty">
输入主题后点击「生成大纲」,<br />AI 会先拟定大纲,再逐页生成。
@@ -175,32 +285,34 @@ function onApply() {
<!-- 标题 -->
<div class="outline-title">
<input v-if="editingTitle" type="text" v-model="titleInput" @blur="commitTitle" @keydown.enter="commitTitle" />
<h4 v-else @click="startEditTitle" title="点击编辑">{{ outline.title || '(未命名)' }} <span class="edit-hint">✎</span></h4>
<h4 v-else @click="startEditTitle" title="点击编辑">{{ outline.title || '(未命名)' }} <span class="edit-hint"><Icon name="edit" :size="12" /></span></h4>
</div>
<!-- 条目列表 -->
<div class="outline-items">
<div v-for="(it, i) in outline.items" :key="it.id" class="outline-item">
<div class="item-head">
<div class="item-head" @click="toggleExpand(it.id)">
<span class="fold-arrow" :class="{ open: expanded.has(it.id) }">▸</span>
<span class="kind-badge" :style="{ background: KIND_META[it.kind].color }">{{ KIND_META[it.kind].label }}</span>
<input class="item-title" type="text" v-model="it.title" :disabled="busy" />
<span class="status" :class="{ done: it.done }">{{ it.done ? '✓ 已生成' : '待生成' }}</span>
<button class="btn small" :disabled="busy" @click="onGenOne(i)" :title="'生成第 ' + (i + 1) + ' 页'">生成此页</button>
<button class="btn small danger" :disabled="busy" @click="removeItem(i)" title="删除">🗑</button>
<input class="item-title" type="text" v-model="it.title" :disabled="locked" @click.stop />
<span class="status" :class="{ done: it.done }">{{ it.done ? '已生成' : '待生成' }}</span>
<button class="btn small" :disabled="locked" @click.stop="onGenOne(i)" :title="'生成第 ' + (i + 1) + ' 页'">生成此页</button>
<button class="btn small danger" :disabled="locked" @click.stop="removeItem(i)" title="删除"><Icon name="trash" :size="13" /></button>
</div>
<div class="item-summary" v-if="!expanded.has(it.id) && it.points.length">{{ it.points[0] }}</div>
<!-- 要点列表 -->
<div class="item-points">
<div class="item-points" v-show="expanded.has(it.id)">
<div v-for="(p, pi) in it.points" :key="pi" class="point-row">
<input type="text" v-model="it.points[pi]" :disabled="busy" placeholder="要点内容" />
<button class="btn small ghost" :disabled="busy" @click="removePoint(it, pi)" title="删除要点">✕</button>
<input type="text" v-model="it.points[pi]" :disabled="locked" placeholder="要点内容" />
<button class="btn small ghost" :disabled="locked" @click="removePoint(it, pi)" title="删除要点"><Icon name="x" :size="13" /></button>
</div>
<button class="btn small ghost add-point" :disabled="busy" @click="addPoint(it)">+ 添加要点</button>
<button class="btn small ghost add-point" :disabled="locked" @click="addPoint(it)">+ 添加要点</button>
</div>
<!-- hint -->
<div class="item-hint">
<input type="text" v-model="it.hint" :disabled="busy" placeholder="补充指令(可选,如「用对比卡片」「数据页」" />
<div class="item-hint" v-show="expanded.has(it.id)">
<input type="text" v-model="it.hint" :disabled="locked" placeholder="补充指令(可选,如「用对比卡片」「数据页」" />
</div>
</div>
</div>
@@ -209,8 +321,7 @@ function onApply() {
<div class="outline-footer">
<span class="progress-text" v-if="progress.total">{{ progress.cur }}/{{ progress.total }}</span>
<span class="done-count" v-else>{{ doneCount }}/{{ total }} 页已生成</span>
<button class="btn primary" :disabled="busy || allDone" @click="onGenAll">全部生成</button>
<button class="btn" :disabled="busy || !doneCount" @click="onApply">应用到文稿</button>
<button class="btn primary" :disabled="locked || allDone" @click="onGenAll">全部生成</button>
</div>
</div>
</div>
@@ -233,6 +344,61 @@ function onApply() {
}
.outline-top input { flex: 1; }
/* 页数分段单选:一体式外壳+互斥 pill,与通道切换(.seg)同设计语言 */
.outline-count {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.count-label {
font-size: 11px;
color: var(--ui-muted);
flex-shrink: 0;
}
.count-seg {
display: inline-flex;
border: 1px solid var(--ui-border);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--ui-panel);
}
.count-pill {
height: 24px;
padding: 0 10px;
border: none;
background: none;
cursor: pointer;
font-size: 12px;
color: var(--ui-muted);
transition: background .12s, color .12s;
}
.count-pill + .count-pill { border-left: 1px solid var(--ui-border); }
.count-pill:hover:not(:disabled):not(.active) { background: var(--ui-primary-soft); }
.count-pill.active {
background: var(--ui-primary-soft);
color: var(--ui-primary);
font-weight: 600;
}
.count-pill:disabled { cursor: not-allowed; opacity: .55; }
.count-hint {
font-size: 11px;
color: var(--ui-muted);
opacity: .85;
}
.fold-arrow {
display: inline-block; font-size: 10px; color: var(--ui-muted);
transition: transform .15s; flex-shrink: 0; cursor: pointer;
}
.fold-arrow.open { transform: rotate(90deg); }
.item-head { cursor: pointer; }
.item-head input, .item-head button { cursor: auto; }
.item-summary {
font-size: 11px; color: var(--ui-muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
margin: -2px 0 4px 22px;
}
.outline-empty {
color: var(--ui-muted);
font-size: 13px;
+66
View File
@@ -0,0 +1,66 @@
<!-- =====================================================================
AppDialog.vue — 全局统一对话框宿主(替代原生 alert/confirm/prompt)
由 core/dialog.ts 的模块级状态驱动;在 App.vue 模板放置 <AppDialog /> 接线
===================================================================== -->
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { dialogState, resolveDialog } from '../../core/dialog'
const inputValue = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
watch(() => dialogState.open.value, async (v) => {
if (!v) return
inputValue.value = dialogState.options.value?.defaultValue || ''
await nextTick()
if (dialogState.options.value?.type === 'prompt') {
inputRef.value?.focus()
inputRef.value?.select()
}
})
function onMaskKeydown(e: KeyboardEvent) {
// confirm/alert 无输入框,键盘焦点在 mask 上时也支持 Enter/Esc
if (e.key === 'Enter') {
e.preventDefault(); e.stopPropagation()
resolveDialog({ ok: true, value: inputValue.value })
} else if (e.key === 'Escape') {
e.preventDefault(); e.stopPropagation()
resolveDialog({ ok: false, value: '' })
}
}
</script>
<template>
<Teleport to="body">
<div
v-if="dialogState.open.value && dialogState.options.value"
class="modal-mask"
tabindex="-1"
@keydown="onMaskKeydown"
@click.self="resolveDialog({ ok: false, value: '' })"
>
<div class="modal app-dialog">
<h3>{{ dialogState.options.value.title }}</h3>
<p v-if="dialogState.options.value.message" class="modal-tip">{{ dialogState.options.value.message }}</p>
<div v-if="dialogState.options.value.type === 'prompt'" class="form-row">
<input
ref="inputRef"
v-model="inputValue"
:placeholder="dialogState.options.value.placeholder || ''"
@keydown.enter.prevent="resolveDialog({ ok: true, value: inputValue })"
@keydown.esc.prevent="resolveDialog({ ok: false, value: '' })"
/>
</div>
<div class="modal-actions">
<button v-if="dialogState.options.value.type !== 'alert'" class="btn" @click="resolveDialog({ ok: false, value: '' })">取消</button>
<button
class="btn"
:class="dialogState.options.value.danger ? 'danger' : 'primary'"
@click="resolveDialog({ ok: true, value: inputValue })"
>{{ dialogState.options.value.okText || '确定' }}</button>
</div>
</div>
</div>
</Teleport>
</template>
+133
View File
@@ -0,0 +1,133 @@
<!-- =====================================================================
FileDock.vue — 附件坞:页面粘贴常规文件后浮现的可点击附件条
由 core/attachments 模块级状态驱动;点击文件 → 打开右侧预览抽屉
仅会话态,不落盘(刷新即失)
===================================================================== -->
<script setup lang="ts">
import { attachmentState, openPreview, removeAttachment, clearAttachments, formatSize } from '../../core/attachments'
import Icon from './Icon.vue'
/* 类型 → 图标名(内联 SVG 图标组件) */
const ICONS: Record<string, string> = {
image: 'image', video: 'video', pdf: 'book',
markdown: 'file-text', text: 'file-text', doc: 'book', meta: 'paperclip'
}
</script>
<template>
<!-- 有附件才显示 -->
<div v-if="attachmentState.list.value.length" class="file-dock">
<div class="file-dock-head">
<span class="file-dock-title"><Icon name="paperclip" :size="14" /> 附件 {{ 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"><Icon :name="ICONS[att.kind] || 'paperclip'" :size="14" /></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>
+277
View File
@@ -0,0 +1,277 @@
<!-- =====================================================================
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'
import Icon from './Icon.vue'
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"><Icon name="paperclip" :size="14" /></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"><Icon name="paperclip" :size="12" /></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>
+92
View File
@@ -0,0 +1,92 @@
<!-- =====================================================================
Icon.vue — 内联 SVG 线性图标(Tabler/Feather 风格,零依赖)
用法:<Icon name="save" :size="16" /> 颜色随 currentColor,替代 emoji 图标
===================================================================== -->
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
name: string
size?: number | string
strokeWidth?: number | string
}>(), { size: 16, strokeWidth: 1.8 })
const ICONS: Record<string, string> = {
/* 文件/目录 */
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
'folder-open': '<path d="M3 6a1 1 0 0 1 1-1h5l2 2h8a1 1 0 0 1 1 1v2"/><path d="M3 10h18l-1.6 8.2a1 1 0 0 1-1 .8H5.6a1 1 0 0 1-1-.8z"/>',
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>',
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
'image': '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>',
'video': '<path d="m23 7-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/>',
'cloud': '<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>',
/* 存取/流转 */
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
'printer': '<path d="M6 9V3h12v6"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="7" rx="1"/>',
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/>',
'clipboard': '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/>',
'trash': '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="m19 6-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>',
'rotate': '<path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>',
/* 操作/状态 */
'plus': '<path d="M12 5v14"/><path d="M5 12h14"/>',
'play': '<path d="m6 4 14 8-14 8z"/>',
'check': '<path d="M20 6 9 17l-5-5"/>',
'alert': '<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
'x': '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
'sliders': '<path d="M4 21v-7"/><path d="M4 10V3"/><path d="M12 21v-9"/><path d="M12 8V3"/><path d="M20 21v-5"/><path d="M20 12V3"/><path d="M2 14h4"/><path d="M10 8h4"/><path d="M18 16h4"/>',
'pointer': '<path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51z"/>',
'paperclip': '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
/* AI 特征 */
'sparkles': '<path d="M12 3l1.9 5.6a2 2 0 0 0 1.5 1.5L21 12l-5.6 1.9a2 2 0 0 0-1.5 1.5L12 21l-1.9-5.6a2 2 0 0 0-1.5-1.5L3 12l5.6-1.9a2 2 0 0 0 1.5-1.5z"/><path d="M19 3v2"/><path d="M18 4h2"/>',
'wand': '<path d="m3 21 9-9"/><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="m17.8 11.8 1.4 1.4"/><path d="m17.8 6.2 1.4-1.4"/><path d="m12.2 6.2-1.4-1.4"/>',
'zap': '<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/>',
'radio': '<circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49"/><path d="M7.76 16.25a6 6 0 0 1 0-8.49"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M4.93 19.07a10 10 0 0 1 0-14.14"/>',
'palette': '<path d="M12 22a10 10 0 1 1 10-10c0 1.66-1.34 3-3 3h-2.2a2 2 0 0 0-1.5 3.32c.4.45.7 1.05.7 1.68a2 2 0 0 1-2 2z"/><circle cx="7.5" cy="11.5" r="1"/><circle cx="11" cy="7.5" r="1"/><circle cx="16" cy="8.5" r="1"/>',
/* 图表 */
'bar-chart': '<path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/>',
'bar-chart-h': '<path d="M4 6h16"/><path d="M4 12h10"/><path d="M4 18h13"/>',
'line-chart': '<path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/>',
'area-chart': '<path d="M3 3v18h18"/><path d="M7 14l4-4 3 3 5-6"/>',
'pie': '<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>',
'doughnut': '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5"/><path d="M12 3v5.5"/><path d="M21 12h-5.5"/>',
'radar': '<path d="M12 3l7.8 4.5v9L12 21l-7.8-4.5v-9z"/><path d="M12 8l3.9 2.25v4.5L12 17l-3.9-2.25v-4.5z"/>',
'progress': '<circle cx="12" cy="12" r="9" opacity=".25"/><path d="M12 3a9 9 0 0 1 9 9"/>',
/* 文本/编辑 */
'edit': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/>',
'align-left': '<path d="M21 6H3"/><path d="M15 12H3"/><path d="M17 18H3"/>',
'align-center': '<path d="M21 6H3"/><path d="M19 12H5"/><path d="M17 18H7"/>',
'align-right': '<path d="M21 6H3"/><path d="M21 12H9"/><path d="M21 18H7"/>',
'list': '<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>',
'layout': '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/>',
'table': '<path d="M12 3v18"/><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
'code': '<path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/>',
'quote': '<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>'
}
const html = computed(() => ICONS[props.name] || '')
</script>
<template>
<svg
class="ui-icon"
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
v-html="html"
/>
</template>
<style scoped>
.ui-icon { flex-shrink: 0; vertical-align: -0.15em; }
</style>
+7 -5
View File
@@ -4,12 +4,14 @@
<script setup lang="ts">
import type { ElementType } from '../../core/types'
import { elementTypes } from '../../core/sample'
import Icon from '../common/Icon.vue'
const TYPES: ElementType[] = ['title', 'text', 'list', 'stat', 'quote', 'image', 'shape', 'chart', 'card', 'table', 'code', 'formula']
const TYPES: ElementType[] = ['title', 'text', 'list', 'stat', 'quote', 'image', 'video', 'shape', 'chart', 'card', 'table', 'code', 'formula']
/* 全部走 Icon 图标体系(纯文字伪图标是 UI 杂音来源) */
const ICONS: Record<string, string> = {
title: 'T', text: '', list: '☰', stat: '#', quote: '“”',
image: '🖼', shape: '▭', chart: '📊', card: '◰',
table: '▦', code: '</>', formula: '∑'
title: 'layout', text: 'file-text', list: 'list', stat: 'bar-chart-h', quote: 'quote',
image: 'image', video: 'video', shape: 'palette', chart: 'line-chart', card: 'book',
table: 'table', code: 'code', formula: 'file'
}
const emit = defineEmits<{ (e: 'add', type: ElementType): void }>()
@@ -18,7 +20,7 @@ const emit = defineEmits<{ (e: 'add', type: ElementType): void }>()
<template>
<div class="add-grid">
<button v-for="t in TYPES" :key="t" :data-add="t" @click="emit('add', t)">
<span class="ic">{{ ICONS[t] || '·' }}</span>{{ elementTypes[t].label }}
<span class="ic"><Icon :name="ICONS[t]" :size="16" /></span>{{ elementTypes[t].label }}
</button>
</div>
</template>
+439
View File
@@ -0,0 +1,439 @@
<!-- =====================================================================
AnnotationLayer.vue — 画布批注叠加层
连线(SVG,1280×720 用户坐标)+ 气泡(DOM,百分比定位,可拖动)
自读 store,铺满 .canvas 与 ElementView 平级,pointer-events 精确放行
===================================================================== -->
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { store, resolveColor, isDarkBg } from '../../core/store'
import { markdownToSegments, segmentsToHtml } from '../../core/richtext'
import type { Annotation, SlideElement } from '../../core/types'
const CANVAS_W = 1280
const CANVAS_H = 720
const slide = computed(() => store.currentSlide.value)
/** 当前页背景是否深色 → 文字反相 */
const dark = computed(() => isDarkBg(slide.value?.background || ''))
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
/** 扁平列表:{ el, anno },供连线与气泡渲染 */
const items = computed(() => {
const out: { el: SlideElement; anno: Annotation }[] = []
for (const el of slide.value?.elements || []) {
const list = el.style?.annotations
if (list?.length) for (const anno of list) out.push({ el, anno })
}
return out
})
/** 拖动中的临时覆盖:key = elId::annoId → 部分 box 字段(移动改 bx/by,缩放改 bw/bh) */
const dragOverride = ref<Record<string, { bx?: number; by?: number; bw?: number; bh?: number }>>({})
const keyOf = (elId: string, annoId: string) => elId + '::' + annoId
/** 当前选中的批注(elId::annoId),高亮 + 显示缩放手柄;
* 同步到 store.selectedAnnoId 供全局 Delete 键裁决(批注删除优先于宿主元素) */
const selectedKey = ref<string | null>(null)
watch(selectedKey, v => store.setSelectedAnnoId(v ? v.split('::')[1] : null))
/** 宿主图片被取消选中 / 切页 / 批注被删 → 清空批注选中与编辑态 */
watch(
[() => store.selectedId.value, items],
([selId]) => {
if (!selectedKey.value) return
const stillExists = items.value.some(
({ el, anno }) => keyOf(el.id, anno.id) === selectedKey.value && el.id === selId
)
if (!stillExists) {
selectedKey.value = null
editingKey.value = null
}
}
)
/** 新增批注自动进入编辑:选中图片的 annotations 出现新 id(且文本为空)→ 直接开写 */
const seenAnnoIds = new Set<string>()
// 挂载时把存量批注标记已见(老数据不触发编辑);此后出现的新 id 才自动进入编辑
for (const { anno } of items.value) seenAnnoIds.add(anno.id)
watch(items, (list) => {
const selId = store.selectedId.value
for (const { el, anno } of list) {
if (!seenAnnoIds.has(anno.id)) {
seenAnnoIds.add(anno.id)
if (el.id === selId && !anno.text) {
selectedKey.value = keyOf(el.id, anno.id)
editingKey.value = keyOf(el.id, anno.id)
nextTick(() => { editRef.value?.focus() })
}
}
}
}, { deep: false })
/** 气泡当前坐标(拖动时用临时覆盖,否则用 anno 原值) */
function box(el: SlideElement, anno: Annotation) {
const ov = dragOverride.value[keyOf(el.id, anno.id)]
return {
bx: ov?.bx ?? anno.bx, by: ov?.by ?? anno.by,
bw: ov?.bw ?? anno.bw, bh: ov?.bh ?? anno.bh
}
}
/**
* 射线-矩形边框求交:从矩形中心 (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 }
// 缩放因子:让 |dx*s|<=hw 且 |dy*s|<=hh,取首先触边者
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 b = box(el, anno)
const bx = b.bx / 100 * CANVAS_W, by = b.by / 100 * CANVAS_H
const bw = b.bw / 100 * CANVAS_W, bh = b.bh / 100 * CANVAS_H
// 图片锚点中心(ax/ay 允许自定义偏移,默认几何中心)
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 // viewBox 用户单位偏细,放大系数;默认 0.5px
}
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(复用 richtext 行内格式 + 本组件补充 # 标题块级语法) */
function renderText(anno: Annotation): string {
const t = anno.text || ''
if (!t.trim()) return '<span class="anno-empty">双击输入批注</span>'
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 // 1~3
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>')
}
/* ---------- 气泡内联编辑(双击进入,编辑 md 源文) ---------- */
const editingKey = ref<string | null>(null)
const editRef = ref<HTMLTextAreaElement | null>(null)
function startEdit(el: SlideElement, anno: Annotation) {
store.selectElement(el.id)
selectedKey.value = keyOf(el.id, anno.id)
editingKey.value = keyOf(el.id, anno.id)
nextTick(() => { editRef.value?.focus(); editRef.value?.select() })
}
function commitEdit(el: SlideElement, anno: Annotation, value: string) {
editingKey.value = null
if (value !== anno.text) store.updateAnnotation(el.id, anno.id, { text: value })
}
/* ---------- 气泡拖动 / 缩放 ---------- */
const MIN_BW = 6, MIN_BH = 4 // 气泡最小尺寸(百分比)
interface DragState {
mode: 'move' | 'resize'
key: string; el: SlideElement; anno: Annotation
startBx: number; startBy: number; startBw: number; startBh: number
px0: number; py0: number
rect: DOMRect
}
let ds: DragState | null = null
function startDrag(mode: 'move' | 'resize', e: MouseEvent, el: SlideElement, anno: Annotation) {
e.preventDefault(); e.stopPropagation()
store.selectElement(el.id)
selectedKey.value = keyOf(el.id, anno.id)
const canvas = (e.currentTarget as HTMLElement).closest('.canvas') as HTMLElement | null
if (!canvas) return
ds = {
mode, key: keyOf(el.id, anno.id), el, anno,
startBx: anno.bx, startBy: anno.by, startBw: anno.bw, startBh: anno.bh,
px0: e.clientX, py0: e.clientY,
rect: canvas.getBoundingClientRect()
}
document.addEventListener('mousemove', onDragMove)
document.addEventListener('mouseup', onDragUp)
}
function onBubbleDown(e: MouseEvent, el: SlideElement, anno: Annotation) {
startDrag('move', e, el, anno)
}
function onResizeDown(e: MouseEvent, el: SlideElement, anno: Annotation) {
startDrag('resize', e, el, anno)
}
function onDragMove(e: MouseEvent) {
if (!ds) return
const dxPct = (e.clientX - ds.px0) / ds.rect.width * 100
const dyPct = (e.clientY - ds.py0) / ds.rect.height * 100
if (ds.mode === 'resize') {
const nbw = clamp(ds.startBw + dxPct, MIN_BW, 100 - ds.startBx)
const nbh = clamp(ds.startBh + dyPct, MIN_BH, 100 - ds.startBy)
dragOverride.value = { ...dragOverride.value, [ds.key]: { bw: nbw, bh: nbh } }
} else {
const nbx = clamp(ds.startBx + dxPct, 0, 100 - ds.startBw)
const nby = clamp(ds.startBy + dyPct, 0, 100 - ds.startBh)
dragOverride.value = { ...dragOverride.value, [ds.key]: { bx: nbx, by: nby } }
}
}
function onDragUp() {
if (ds) {
const ov = dragOverride.value[ds.key]
if (ov) {
const patch = ds.mode === 'resize' ? { bw: ov.bw, bh: ov.bh } : { bx: ov.bx, by: ov.by }
store.updateAnnotation(ds.el.id, ds.anno.id, patch)
}
const next = { ...dragOverride.value }
delete next[ds.key]
dragOverride.value = next
}
ds = null
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragUp)
}
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragUp)
document.removeEventListener('keydown', onLayerKeydown)
})
/** 选中批注时 Delete/Backspace 删除该批注(输入态/编辑态放行)。
* store.selectedAnnoId 已同步,App.vue 的元素删除会先让位 */
function onLayerKeydown(e: KeyboardEvent) {
if (e.key !== 'Delete' && e.key !== 'Backspace') return
if (document.body.dataset.mode === 'present') return
const t = e.target as HTMLElement
if (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable) return
if (!selectedKey.value || editingKey.value) return
const hit = items.value.find(({ el, anno }) => keyOf(el.id, anno.id) === selectedKey.value)
if (!hit) return
e.preventDefault()
store.delAnnotation(hit.el.id, hit.anno.id)
selectedKey.value = null
}
onMounted(() => {
document.addEventListener('keydown', onLayerKeydown)
})
</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"
:key="'bb-' + el.id + anno.id"
class="anno-bubble"
:class="{ selected: selectedKey === keyOf(el.id, anno.id) }"
:data-el-id="el.id"
:data-anno-id="anno.id"
:style="{
left: box(el, anno).bx + '%',
top: box(el, anno).by + '%',
width: box(el, anno).bw + '%',
height: box(el, anno).bh + '%',
...bubbleTextStyle(anno)
}"
@mousedown="onBubbleDown($event, el, anno)"
@dblclick.stop="startEdit(el, anno)"
>
<!-- 编辑态:md 源文 textarea -->
<textarea
v-if="editingKey === keyOf(el.id, anno.id)"
ref="editRef"
class="anno-bubble-edit"
:value="anno.text"
placeholder="支持 **加粗** *斜体* ==高亮== # 标题"
@mousedown.stop
@blur="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
@keydown.enter.exact.prevent="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
@keydown.enter.ctrl.prevent="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
@keydown.esc.prevent="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
></textarea>
<!-- 展示态:md 渲染 -->
<span v-else class="anno-bubble-text" v-html="renderText(anno)"></span>
<span
class="anno-resize"
title="拖动调整气泡大小"
@mousedown="onResizeDown($event, el, 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;
}
/* SVG 子元素默认 visiblePainted 会拦截点击,父级 pointer-events:none 不继承 → 显式关闭,
否则贴着图片边缘的连线描边会挡住图片的点选(回归根因) */
.anno-svg * {
pointer-events: none;
}
.anno-bubble {
position: absolute;
box-sizing: border-box;
pointer-events: auto;
cursor: move;
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;
user-select: none;
overflow: hidden;
}
.anno-bubble.selected {
border-color: var(--ui-primary, #5b5bd6);
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.25), 0 2px 8px rgba(15, 23, 42, 0.1);
}
.anno-bubble-text {
display: block;
width: 100%;
height: 100%;
overflow: hidden;
}
.anno-bubble-text :deep(.anno-empty) {
color: var(--ui-muted, #94a3b8);
font-style: italic;
}
.anno-bubble-edit {
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;
border: none;
outline: none;
padding: 0;
margin: 0;
resize: none;
background: transparent;
font: inherit;
color: inherit;
cursor: text;
}
.anno-resize {
position: absolute;
right: 0;
bottom: 0;
width: 14px;
height: 14px;
cursor: nwse-resize;
background:
linear-gradient(135deg, transparent 50%, var(--ui-primary, #5b5bd6) 50%);
border-bottom-right-radius: 8px;
opacity: 0;
transition: opacity .12s;
}
.anno-bubble:hover .anno-resize,
.anno-bubble.selected .anno-resize {
opacity: 1;
}
</style>
+86 -13
View File
@@ -7,18 +7,34 @@ import { store, resolveBg } from '../../core/store'
import { CANVAS_W } from '../../core/sample'
import { useEditor } from '../../composables/useEditor'
import ElementView from './ElementView.vue'
import AnnotationLayer from './AnnotationLayer.vue'
import Icon from '../common/Icon.vue'
const canvasRef = ref<HTMLElement>()
const canvasFrameRef = ref<HTMLElement>()
const canvasFrameRef = ref<HTMLElement>() // 注意:绑定 .canvas-frame(不含 stage 的 padding),scale 以它为基准
const {
drag, editing,
drag, marquee, editing,
onCanvasMouseDown, onMouseMove, onMouseUp, onContentEditableFocus
} = useEditor()
const slide = computed(() => store.currentSlide.value)
const selectedId = computed(() => store.selectedId.value)
const selectedIds = computed(() => store.selectedIds.value || [])
const HANDLES = ['tl', 'tm', 'tr', 'lm', 'rm', 'bl', 'bm', 'br']
const noteOpen = ref(false) // 演讲者备注栏展开态
/** 框选矩形样式(% → CSS) */
const marqueeBox = computed(() => {
const m = marquee.value
if (!m) return null
return {
left: Math.min(m.x0, m.x1) + '%',
top: Math.min(m.y0, m.y1) + '%',
width: Math.abs(m.x1 - m.x0) + '%',
height: Math.abs(m.y1 - m.y0) + '%'
}
})
function fitCanvas() {
if (!canvasFrameRef.value || !canvasRef.value) return
@@ -32,11 +48,16 @@ function onElementBlur(id: string, field: string, value: string) {
editing.value = false
if (field === 'label') {
store.updateElement(id, { style: { label: value } })
} else if (store.findElement(id)?.type === 'list') {
store.updateElement(id, { content: value })
} else {
store.updateElement(id, { content: value })
return
}
// 形状输入文字:装饰细条(h 过小)自动撑高到能容纳一行字,避免文字被裁
const el = store.findElement(id)
if (el?.type === 'shape' && value.trim() && el.h < 6) {
const grow = 6 - el.h
store.updateElement(id, { content: value, h: 6, y: Math.max(0, el.y - grow / 2) })
return
}
store.updateElement(id, { content: value })
}
/** 拖拽中元素的实时位置(覆盖 state) */
@@ -53,24 +74,46 @@ function liveBox(elId: string) {
return null
}
/** 多选组拖拽中各元素的实时位置 */
function liveMultiBox(elId: string) {
const d = drag.value
if (d && d.mode === 'multi-move' && d.multiResult) {
const r = d.multiResult.find(t => t.id === elId)
if (r) return { left: r.x + '%', top: r.y + '%' }
}
return null
}
function onNoteInput(e: Event) {
store.setSlideNote((e.target as HTMLTextAreaElement).value)
}
const onResize = () => fitCanvas()
/** 监听 frame 尺寸:v-show 隐藏→显示、窗口缩放、布局变化都会触发(隐藏时 rect=0,onMounted 的 rAF 会空跑) */
let frameObserver: ResizeObserver | null = null
onMounted(() => {
window.addEventListener('resize', onResize)
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
requestAnimationFrame(fitCanvas)
if (typeof ResizeObserver !== 'undefined' && canvasFrameRef.value) {
frameObserver = new ResizeObserver(() => fitCanvas())
frameObserver.observe(canvasFrameRef.value)
}
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
if (frameObserver) { frameObserver.disconnect(); frameObserver = null }
})
</script>
<template>
<main class="canvas-stage" ref="canvasFrameRef">
<div class="canvas-frame">
<main class="canvas-stage">
<div class="canvas-frame" ref="canvasFrameRef">
<div
class="canvas"
ref="canvasRef"
@@ -91,15 +134,45 @@ onUnmounted(() => {
:el="el"
:bg="slide.background"
edit
:show-handles="el.id === selectedId"
:class="{ selected: el.id === selectedId, dragging: drag?.id === el.id && !!drag?.result }"
:style="liveBox(el.id) || {}"
:show-handles="el.id === selectedId && selectedIds.length <= 1"
:class="{
selected: el.id === selectedId,
'multi-selected': selectedIds.includes(el.id),
dragging: drag?.id === el.id && !!drag?.result
}"
:style="liveBox(el.id) || liveMultiBox(el.id) || {}"
@blur="onElementBlur"
/>
<!-- 框选矩形 -->
<div v-if="marqueeBox" class="marquee-box" :style="marqueeBox"></div>
<AnnotationLayer />
</div>
</div>
<div class="canvas-status">
<span>{{ store.currentIndex.value + 1 }} / {{ store.count.value }}</span>
<div class="canvas-footer">
<div class="canvas-status">
<span>{{ store.currentIndex.value + 1 }} / {{ store.count.value }}</span>
</div>
<button
class="note-toggle"
:class="{ active: noteOpen || !!slide?.note }"
:title="noteOpen ? '收起备注' : '演讲者备注'"
@click="noteOpen = !noteOpen"
>
<Icon name="edit" :size="13" />
<span>备注</span>
<span v-if="slide?.note && !noteOpen" class="note-dot"></span>
</button>
</div>
<div v-show="noteOpen" class="canvas-note">
<textarea
class="canvas-note-input"
:value="slide?.note || ''"
placeholder="演讲者备注(不显示在幻灯片上)…"
rows="2"
@input="onNoteInput"
></textarea>
</div>
</main>
</template>
+164 -70
View File
@@ -8,9 +8,10 @@
多系列:{ series: string[], items: [{ label, values: number[] }, ...] }
===================================================================== -->
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import type { ChartItem, ChartType, ElementStyle } from '../../core/types'
import { resolveColor } from '../../core/store'
import { niceDomain, fmtTick } from './chart-domain'
const props = defineProps<{
content: string
@@ -21,7 +22,7 @@ const props = defineProps<{
/* ---------- 颜色调色板(最多 6 个系列,交替主题色与补色) ---------- */
const PALETTE = computed(() => [
resolveColor(props.style.color, props.dark) || '#4f46e5',
resolveColor(props.style.color, props.dark) || '#5b5bd6',
resolveColor('accent', props.dark) || '#06b6d4',
'#f59e0b', '#10b981', '#ef4444', '#8b5cf6'
])
@@ -71,7 +72,7 @@ const data = computed<NormalizedData>(() => {
return { series: [], items: [], single: true }
})
/** 所有值的最大值(用于 bar/line/area/hbar 的 y 轴缩放) */
/** radar 专用:0 起最大值缩放(径向无刻度文字,行为保持不变) */
const maxValue = computed(() => {
const m = props.style.max
if (m && m > 0) return m
@@ -87,6 +88,35 @@ function esc(s: any): string {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** Y 轴刻度文字 + 水平网格线(与值域 ticks 同源,杜绝网格与折线错位) */
function yAxisAndGrid(yOfFn: (v: number) => number, baseY: number): string {
let out = ''
const ticks = domain.value.ticks
for (let i = 0; i < ticks.length; i++) {
const y = yOfFn(ticks[i])
// 网格线(跳过与基线重合的最低刻度)
if (showGrid.value && i > 0 && y < baseY - 0.5) {
out += `<line x1="6" y1="${y.toFixed(2)}" x2="96" y2="${y.toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
}
// 刻度文字(左缘右对齐)
out += `<text class="chart-text" x="4.5" y="${(y + Number(fs(1.6))).toFixed(2)}" font-size="${fs(3.2)}" text-anchor="end">${fmtTick(ticks[i])}</text>`
}
return out
}
/** 全类型统一值域:bar/hbar 0 基线;line/area 贴合数据带;style.max 作上限覆盖 */
const domain = computed(() => {
const zeroBase = chartType.value === 'bar' || chartType.value === 'hbar'
return niceDomain(allValues(), { zeroBase, maxCap: props.style.max && props.style.max > 0 ? props.style.max : undefined })
})
/** 全部数据值(值域计算用) */
function allValues(): number[] {
const out: number[] = []
for (const it of data.value.items) for (const v of it.values) out.push(v)
return out
}
/** 图例数据(pie/doughnut 用) */
const pieLegend = computed(() => {
const items = data.value.items
@@ -105,43 +135,40 @@ function barSvg(): string {
const { items, series, single } = data.value
const n = items.length
if (!n) return ''
const max = maxValue.value
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
const groupW = 90 / n
const barW = (groupW * 0.7) / seriesCount
const legendH = 0 // 图例在 SVG 外
let out = ''
// 网格线
if (showGrid.value) {
for (let g = 1; g <= 4; g++) {
const y = 10 + (80 - legendH) * g / 5 + legendH
out += `<line x1="6" y1="${y.toFixed(2)}" x2="96" y2="${y.toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
}
}
const H = vbH.value // 绘制高度(等比 viewBox)
const baseY = H - 12 // 柱底基线(底部留 12 单位给 x 轴标签)
const plotTop = 10 // plot 顶
const chartH = baseY - plotTop
// 值 → y 坐标(与 Y 轴刻度同源,0 基线)
const { min: dMin, max: dMax } = domain.value
const dSpan = dMax - dMin || 1
const yOf = (v: number) => baseY - ((v - dMin) / dSpan) * chartH
let out = yAxisAndGrid(yOf, baseY)
for (let i = 0; i < n; i++) {
const groupX = 8 + i * groupW
for (let s = 0; s < seriesCount; s++) {
const v = items[i].values[s] || 0
const h = max > 0 ? (v / max) * 78 : 0
const h = Math.max(0, baseY - yOf(v))
const x = groupX + s * barW
const y = 88 - h
const y = baseY - h
const color = PALETTE.value[s % PALETTE.value.length]
out += `<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${(barW * 0.9).toFixed(2)}" height="${h.toFixed(2)}" fill="${color}" rx="0.6"/>`
}
// x 轴 label
out += `<text class="chart-text" x="${(groupX + groupW * 0.35).toFixed(2)}" y="97" font-size="5" text-anchor="middle">${esc(items[i].label)}</text>`
out += `<text class="chart-text" x="${(groupX + groupW * 0.35).toFixed(2)}" y="${(H - 3).toFixed(2)}" font-size="${fs(5)}" text-anchor="middle">${esc(items[i].label)}</text>`
}
// 单系列时显示数值
if (single) {
for (let i = 0; i < n; i++) {
const v = items[i].values[0] || 0
const h = max > 0 ? (v / max) * 78 : 0
const y = 88 - h
const y = yOf(v)
const groupX = 8 + i * groupW
out += `<text class="chart-text" x="${(groupX + groupW * 0.35).toFixed(2)}" y="${(y - 1.5).toFixed(2)}" font-size="5" text-anchor="middle">${esc(v)}</text>`
out += `<text class="chart-text" x="${(groupX + groupW * 0.35).toFixed(2)}" y="${(y - 1.5).toFixed(2)}" font-size="${fs(5)}" text-anchor="middle">${esc(v)}</text>`
}
}
return out
@@ -154,27 +181,44 @@ function hbarSvg(): string {
const { items, single } = data.value
const n = items.length
if (!n) return ''
const max = maxValue.value
const rowH = 76 / n
const H = vbH.value
const rowH = 92 / n
const barH = rowH * 0.55
// 标签区宽度自适应最长 label(CJK 按 1.05 字宽、ASCII 按 0.56 估算),上限 40% 宽,下限 14
const f = fs(5)
const labelW = items.reduce((m, it) => {
let u = 0
for (const ch of it.label) u += /[一-鿿＀-￯]/.test(ch) ? 1.05 : 0.56
return Math.max(m, u * Number(f))
}, 8)
const labelGap = 2
const leftW = Math.min(Math.max(labelW + labelGap, 14), 40)
const plotX = leftW
const plotW = 96 - plotX - (single ? 6 : 0)
// 值 → x 坐标(0 基线,与垂直网格/刻度同源)
const { min: dMin, max: dMax } = domain.value
const dSpan = dMax - dMin || 1
const xOf = (v: number) => plotX + ((v - dMin) / dSpan) * plotW
let out = ''
if (showGrid.value) {
for (let g = 1; g <= 4; g++) {
const x = 20 + 76 * g / 5
out += `<line x1="${x.toFixed(2)}" y1="6" x2="${x.toFixed(2)}" y2="94" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
// 垂直网格线与值刻度同源(hbar 值在横轴)
for (const t of domain.value.ticks) {
const x = xOf(t)
out += `<line x1="${x.toFixed(2)}" y1="4" x2="${x.toFixed(2)}" y2="${(H - 4).toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
out += `<text class="chart-text" x="${x.toFixed(2)}" y="${(H - 5).toFixed(2)}" font-size="${fs(3.2)}" text-anchor="middle">${fmtTick(t)}</text>`
}
}
for (let i = 0; i < n; i++) {
const v = items[i].values[0] || 0
const w = max > 0 ? (v / max) * 70 : 0
const y = 8 + i * rowH + (rowH - barH) / 2
const w = Math.max(0, xOf(v) - plotX)
const y = 4 + i * rowH + (rowH - barH) / 2
const color = PALETTE.value[i % PALETTE.value.length]
out += `<rect x="20" y="${y.toFixed(2)}" width="${w.toFixed(2)}" height="${barH.toFixed(2)}" fill="${color}" rx="0.6"/>`
out += `<text class="chart-text" x="18" y="${(y + barH * 0.7).toFixed(2)}" font-size="5" text-anchor="end">${esc(items[i].label)}</text>`
out += `<rect x="${plotX.toFixed(2)}" y="${y.toFixed(2)}" width="${w.toFixed(2)}" height="${barH.toFixed(2)}" fill="${color}" rx="0.6"/>`
out += `<text class="chart-text" x="${(plotX - labelGap).toFixed(2)}" y="${(y + barH * 0.72).toFixed(2)}" font-size="${f}" text-anchor="end">${esc(items[i].label)}</text>`
if (single) {
out += `<text class="chart-text" x="${(22 + w).toFixed(2)}" y="${(y + barH * 0.7).toFixed(2)}" font-size="5" text-anchor="start">${esc(v)}</text>`
out += `<text class="chart-text" x="${(plotX + w + 2).toFixed(2)}" y="${(y + barH * 0.72).toFixed(2)}" font-size="${f}" text-anchor="start">${esc(v)}</text>`
}
}
return out
@@ -187,16 +231,15 @@ function lineSvg(): string {
const { items, series, single } = data.value
const n = items.length
if (!n) return ''
const max = maxValue.value
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
let out = ''
if (showGrid.value) {
for (let g = 1; g <= 4; g++) {
const y = 10 + 78 * g / 5
out += `<line x1="6" y1="${y.toFixed(2)}" x2="96" y2="${y.toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
}
}
const H = vbH.value
const baseY = H - 12
const chartH = baseY - 12
// 自适应值域:窄幅正值数据时 min 抬升到数据带下方,避免波动被放大失真
const { min: dMin, max: dMax } = domain.value
const span = dMax - dMin
const yOf = (v: number) => baseY - (span > 0 ? ((v - dMin) / span) * chartH : 0)
let out = yAxisAndGrid(yOf, baseY)
for (let s = 0; s < seriesCount; s++) {
const color = PALETTE.value[s % PALETTE.value.length]
@@ -204,8 +247,7 @@ function lineSvg(): string {
for (let i = 0; i < n; i++) {
const v = items[i].values[s] || 0
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
const y = 88 - (max > 0 ? (v / max) * 76 : 0)
pts.push({ x, y, val: v })
pts.push({ x, y: yOf(v), val: v })
}
const polyPts = pts.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
out += `<polyline points="${polyPts}" fill="none" stroke="${color}" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>`
@@ -217,16 +259,20 @@ function lineSvg(): string {
// x 轴 label
for (let i = 0; i < n; i++) {
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
out += `<text class="chart-text" x="${x.toFixed(2)}" y="97" font-size="5" text-anchor="middle">${esc(items[i].label)}</text>`
out += `<text class="chart-text" x="${x.toFixed(2)}" y="${(H - 3).toFixed(2)}" font-size="${fs(5)}" text-anchor="middle">${esc(items[i].label)}</text>`
}
// 单系列显示数值
// 单系列显示数值:点在区域上部 1/4 时标签放点下方(防压顶出界);相邻点 x 距离小于标签宽时隔点显示(防重叠)
if (single) {
const f = fs(5)
const minGap = Math.max(5 * fontScale.value * 2.2, 6) // 标签宽估算(数字 2-4 字符 × 字号),下限 6
for (let i = 0; i < n; i++) {
const v = items[i].values[0] || 0
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
const y = 88 - (max > 0 ? (v / max) * 76 : 0)
out += `<text class="chart-text" x="${x.toFixed(2)}" y="${(y - 2).toFixed(2)}" font-size="5" text-anchor="middle">${esc(v)}</text>`
if (i > 0 && (x - (8 + (i - 1) * (84 / (n - 1)))) < minGap && i % 2 === 1) continue // 奇数索引跳过 → 隔点显示
const py = yOf(v)
const ly = py < 12 + chartH * 0.25 ? py + 5.5 : py - 2
out += `<text class="chart-text" x="${x.toFixed(2)}" y="${ly.toFixed(2)}" font-size="${f}" text-anchor="middle">${esc(v)}</text>`
}
}
return out
@@ -239,16 +285,15 @@ function areaSvg(): string {
const { items, series, single } = data.value
const n = items.length
if (!n) return ''
const max = maxValue.value
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
let out = ''
if (showGrid.value) {
for (let g = 1; g <= 4; g++) {
const y = 10 + 78 * g / 5
out += `<line x1="6" y1="${y.toFixed(2)}" x2="96" y2="${y.toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
}
}
const H = vbH.value
const baseY = H - 12
const chartH = baseY - 12
// 自适应值域(与 line 一致)
const { min: dMin, max: dMax } = domain.value
const span = dMax - dMin
const yOf = (v: number) => baseY - (span > 0 ? ((v - dMin) / span) * chartH : 0)
let out = yAxisAndGrid(yOf, baseY)
for (let s = 0; s < seriesCount; s++) {
const color = PALETTE.value[s % PALETTE.value.length]
@@ -256,11 +301,10 @@ function areaSvg(): string {
for (let i = 0; i < n; i++) {
const v = items[i].values[s] || 0
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
const y = 88 - (max > 0 ? (v / max) * 76 : 0)
pts.push({ x, y, val: v })
pts.push({ x, y: yOf(v), val: v })
}
// 填充区域
const areaPts = `${pts[0].x.toFixed(2)},88 ` + pts.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ') + ` ${pts[n - 1].x.toFixed(2)},88`
// 填充区域:底边跟随自适应基线的 y 坐标(即 baseY),非硬编码
const areaPts = `${pts[0].x.toFixed(2)},${baseY.toFixed(2)} ` + pts.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ') + ` ${pts[n - 1].x.toFixed(2)},${baseY.toFixed(2)}`
out += `<polygon points="${areaPts}" fill="${color}" fill-opacity="0.18"/>`
// 折线
const polyPts = pts.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
@@ -269,7 +313,7 @@ function areaSvg(): string {
for (let i = 0; i < n; i++) {
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
out += `<text class="chart-text" x="${x.toFixed(2)}" y="97" font-size="5" text-anchor="middle">${esc(items[i].label)}</text>`
out += `<text class="chart-text" x="${x.toFixed(2)}" y="${(H - 3).toFixed(2)}" font-size="${fs(5)}" text-anchor="middle">${esc(items[i].label)}</text>`
}
return out
}
@@ -341,7 +385,7 @@ function pieLikeSvg(innerR: number): string {
function radarSvg(): string {
const { items, series, single } = data.value
const n = items.length
if (n < 3) return '<text class="chart-text" x="50" y="50" font-size="6" text-anchor="middle">雷达图至少 3 个维度</text>'
if (n < 3) return `<text class="chart-text" x="50" y="50" font-size="${fs(6)}" text-anchor="middle">雷达图至少 3 个维度</text>`
const max = maxValue.value
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
const cx = 50, cy = 50, r = 36
@@ -384,11 +428,12 @@ function radarSvg(): string {
}
// 维度 label
const fsr = fs(5)
for (let i = 0; i < n; i++) {
const a = -Math.PI / 2 + i * (Math.PI * 2 / n)
const lx = cx + (r + 7) * Math.cos(a)
const ly = cy + (r + 7) * Math.sin(a)
out += `<text class="chart-text" x="${lx.toFixed(2)}" y="${ly.toFixed(2)}" font-size="5" text-anchor="middle" dominant-baseline="middle">${esc(items[i].label)}</text>`
out += `<text class="chart-text" x="${lx.toFixed(2)}" y="${ly.toFixed(2)}" font-size="${fsr}" text-anchor="middle" dominant-baseline="middle">${esc(items[i].label)}</text>`
}
return out
}
@@ -420,8 +465,8 @@ function progressSvg(): string {
stroke-dasharray="${dashLen.toFixed(2)} ${(circumference - dashLen).toFixed(2)}"
transform="rotate(-90 ${cx} ${cy})"/>`
// 中心百分比文本
out += `<text class="chart-text" x="${cx}" y="${cy - 1}" font-size="14" font-weight="700" text-anchor="middle">${Math.round(pct)}%</text>`
out += `<text class="chart-text" x="${cx}" y="${cy + 7}" font-size="4" text-anchor="middle">${esc(progressData.value.label)}</text>`
out += `<text class="chart-text" x="${cx}" y="${cy - 1}" font-size="${fs(14)}" font-weight="700" text-anchor="middle">${Math.round(pct)}%</text>`
out += `<text class="chart-text" x="${cx}" y="${cy + 7}" font-size="${fs(4)}" text-anchor="middle">${esc(progressData.value.label)}</text>`
return out
}
@@ -440,10 +485,56 @@ const svgContent = computed(() => {
}
})
/** pie/doughnut 用圆心居中的 viewBox;progress 用正常 viewBox */
/** viewBox 数值高度(pie/doughnut/progress 恒 100;其余按容器宽高比换算并钳制 [60,400],
* viewBox 与 vbH 必须用同一值,否则容器极端扁/高时图形被二次拉伸形变) */
const vbH = computed(() => {
if (chartType.value === 'pie' || chartType.value === 'doughnut' || chartType.value === 'progress') return 100
const w = Math.max(chartBox.value.w, 10)
const h = Math.max(chartBox.value.h, 10)
return Math.max(60, Math.min(400, 100 * h / w))
})
const viewBox = computed(() => {
if (chartType.value === 'pie' || chartType.value === 'doughnut') return '-50 -50 100 100'
return '0 0 100 100'
if (chartType.value === 'progress') return '0 0 100 100'
return `0 0 100 ${vbH.value.toFixed(2)}`
})
/** SVG 文字字号缩放系数:viewBox 单位随宽高比变化,字号按 min(w,h)/100 等比缩放,
* 使文字在任意容器下保持与「100×100 正方视窗」一致的视觉大小且不畸变 */
const fontScale = computed(() => Math.min(chartBox.value.w, chartBox.value.h) / 100)
function fs(v: number): string {
return (v * fontScale.value).toFixed(2)
}
/** 容器逻辑像素尺寸(画布 1280×720,元素 % 定位;
* transform:scale 不影响 clientWidth/Height,测量不受画布缩放干扰) */
const chartBox = ref({ w: 100, h: 100 })
const rootEl = ref<HTMLElement | null>(null)
let fitTimer: number | null = null
function measure() {
const el = rootEl.value
if (!el) return
const w = el.clientWidth, h = el.clientHeight
if (Math.abs(w - chartBox.value.w) > 1 || Math.abs(h - chartBox.value.h) > 1) {
chartBox.value = { w, h }
}
}
function scheduleMeasure() {
if (fitTimer) clearTimeout(fitTimer)
fitTimer = setTimeout(measure, 20) as unknown as number
}
onMounted(() => {
measure()
if (typeof ResizeObserver === 'undefined') return
const ro = new ResizeObserver(scheduleMeasure)
if (rootEl.value) ro.observe(rootEl.value)
;(rootEl.value as any).__chartRo = ro
})
onBeforeUnmount(() => {
const ro = (rootEl.value as any)?.__chartRo
if (ro) ro.disconnect()
if (fitTimer) clearTimeout(fitTimer)
})
/** pie/doughnut 需要外部图例;其他类型用 SVG 内 label,多系列时显示系列图例 */
@@ -470,9 +561,10 @@ const seriesLegend = computed(() => {
</script>
<template>
<div class="el-chart" :class="'chart-' + chartType">
<svg :viewBox="viewBox" :preserveAspectRatio="chartType === 'pie' || chartType === 'doughnut' ? 'xMidYMid meet' : 'none'"
v-html="svgContent" :style="{ width: '100%', height: '100%', display: 'block', flex: chartType === 'pie' || chartType === 'doughnut' ? '1' : undefined, minWidth: chartType === 'pie' || chartType === 'doughnut' ? '0' : undefined }">
<div ref="rootEl" class="el-chart" :class="'chart-' + chartType">
<!-- 雷达图保持正方形视窗等比缩放(xMidYMid meet),其余类型 viewBox 已按容器宽高比构造,none 填满无形变 -->
<svg :viewBox="viewBox" :preserveAspectRatio="chartType === 'pie' || chartType === 'doughnut' || chartType === 'radar' || chartType === 'progress' ? 'xMidYMid meet' : 'none'"
v-html="svgContent" :style="{ width: '100%', height: '100%', display: 'block', flex: '1', minWidth: '0' }">
</svg>
<!-- 饼图/环形图图例 -->
<div v-if="showExternalLegend && (chartType === 'pie' || chartType === 'doughnut')" class="pie-legend">
@@ -491,9 +583,11 @@ const seriesLegend = computed(() => {
</template>
<style scoped>
/* 图例不再 absolute 遮挡图形:flex 流式布局,紧凑排在图下方 */
.series-legend {
position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%);
flex: none;
display: flex; gap: .8em; font-size: 11px; flex-wrap: wrap; justify-content: center;
line-height: 1.2; max-height: 2.8em; overflow: hidden;
}
.series-legend-item { display: inline-flex; align-items: center; gap: .3em; }
.series-legend-item { display: inline-flex; align-items: center; gap: .3em; white-space: nowrap; }
</style>
+246 -43
View File
@@ -3,11 +3,13 @@
editor / present / thumb 三处复用此组件
===================================================================== -->
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import type { SlideElement, BgKey } from '../../core/types'
import { store, resolveColor } from '../../core/store'
import { segmentsToHtml, markdownToSegments, hasFormatting } from '../../core/richtext'
import { store, resolveColor, isDarkBg } from '../../core/store'
import { resolveRef } from '../../core/assets'
import { segmentsToHtml, markdownToSegments, hasFormatting, hasLineMarker } from '../../core/richtext'
import ChartView from './ChartView.vue'
import Icon from '../common/Icon.vue'
/* ---------- LaTeX 子集渲染(公式元素) ---------- */
const LATEX_SYMBOLS: Record<string, string> = {
@@ -53,38 +55,20 @@ const props = defineProps<{
edit?: boolean
/** 是否显示八向缩放手柄 */
showHandles?: boolean
/** 是否为缩略图端(视频等重元素降级为静态渲染) */
thumb?: boolean
}>()
const emit = defineEmits<{
(e: 'blur', id: string, field: string, value: string): void
}>()
/** 当前页背景是否深色 → 文字是否需要反相 */
/** 当前页背景是否深色 → 文字是否需要反相(isDarkBg 复用 store 导出的统一实现) */
const dark = computed(() => {
const d = store.state.deck ? (props.el.type === 'card' ? false : isDarkBg(props.bg)) : false
return d
})
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
}
function hexToRgb(hex: string) {
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 }
}
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
}
const s = computed(() => props.el.style || {})
const boxStyle = computed(() => {
@@ -103,6 +87,9 @@ const boxStyle = computed(() => {
const dataList = computed(() => (props.el.content || '').split('\n'))
/** 媒体源解析:把 "asset:<id>" 引用解析为可渲染 URL(本地 ObjectURL 或云端 URL),其余原样 */
const mediaSrc = computed(() => resolveRef(props.el.content))
/** 表格解析 */
const tableRows = computed(() => {
const lines = (props.el.content || '').split('\n').map(l => l.trim()).filter(Boolean)
@@ -128,8 +115,33 @@ const shapeBg = computed(() => {
return fill
})
const isCircle = computed(() => s.value.shapeType === 'circle')
const isTriangle = computed(() => s.value.shapeType === 'triangle')
/** 各形状 clip-path(rect/circle 走 CSS 圆角,不走 clip) */
const SHAPE_CLIPS: Partial<Record<string, string>> = {
triangle: 'polygon(50% 0, 100% 100%, 0 100%)',
diamond: 'polygon(50% 0, 100% 50%, 50% 100%, 0 50%)',
pentagon: 'polygon(50% 0, 100% 38%, 82% 100%, 18% 100%, 0 38%)',
hexagon: 'polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%)',
star: 'polygon(50% 0, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)',
arrow: 'polygon(0 20%, 75% 20%, 75% 0, 100% 50%, 75% 100%, 75% 80%, 0 80%)',
chevron: 'polygon(0 0, 75% 0, 100% 50%, 75% 100%, 0 100%, 25% 50%)',
bubble: 'polygon(0 0, 100% 0, 100% 75%, 25% 75%, 10% 100%, 15% 75%, 0 75%)'
}
const shapeClip = computed(() => SHAPE_CLIPS[s.value.shapeType || 'rect'] || null)
/** 形状文字(md 渲染;segments 存在时优先) */
const renderedShapeText = computed(() => {
if (props.el.segments && props.el.segments.length && hasFormatting(props.el.segments)) {
return segmentsToHtml(props.el.segments)
}
return segmentsToHtml(markdownToSegments(props.el.content || ''))
})
/** 形状文字层样式:对齐 + 未设色时默认白(填充色底) */
const shapeTextStyle = computed(() => {
const css: Record<string, string> = { textAlign: s.value.align || 'center' }
if (!s.value.color) css.color = '#fff'
return css
})
const cardAccentColor = computed(() => resolveColor(s.value.accent, false))
@@ -139,6 +151,19 @@ const cardParts = computed(() => {
return { title: lines[0] || '', body: lines.slice(1).join('\n') }
})
/** 卡片标题/正文失焦:从 DOM 读两块文字拼回 content(标题在前的兄弟节点)。
* 标题空但正文非空时保留空行占位,避免正文首行漂移成标题。 */
function onBlurCard(e: Event) {
const node = e.target as HTMLElement
const card = node.closest('.el-card')
if (!card) return
const title = (card.querySelector('.card-title')?.textContent || '').trim()
const body = (card.querySelector('.card-body')?.textContent || '').trim()
if (!title && !body) { emit('blur', props.el.id, 'content', ''); return }
if (body) emit('blur', props.el.id, 'content', title + '\n' + body)
else emit('blur', props.el.id, 'content', title)
}
/* ---------- Rich text(segments 结构化富文本)---------- */
/** 是否有 segments(结构化富文本),优先于 content */
const hasSegments = computed(() => {
@@ -152,6 +177,32 @@ const renderedContent = computed(() => {
return null
})
/** quote 装饰引号规范化:LLM 常把整句包进反引号/直引号(页面显示成「`」怪引号)。
* 非编辑态渲染时剥掉包裹引号,装饰引号由 CSS 伪元素绘制成优雅弯引号。
* style.decoQuote=true(normalize 层 sanitizeQuoteDeco 置位)走伪元素双引号渲染。 */
const quotePretty = computed(() => {
if (props.el.type !== 'quote') return null
let txt = props.el.content || ''
// 1) 剥除首尾成对包裹引号(反引号/直引号/弯引号/括号引号)
const pair = txt.match(/^([`"'「『“”」』])[\s\S]*\1$/s)
if (pair) txt = txt.slice(1, -1)
// 2) 残留的直引号/反引号成对转为中文弯引号
let open = true
txt = txt.replace(/[`"‘’]/g, () => {
const c = open ? '“' : '”'
open = !open
return c
})
return txt.trim()
})
/** quote 是否渲染装饰引号:style.decoQuote 显式置位,或非编辑态下 content 自带包裹引号(运行时兜底,兼容未过 normalize 的旧数据) */
const quoteDeco = computed(() => {
if (props.el.type !== 'quote') return false
if (props.el.style.decoQuote) return true
return !props.edit && !!quotePretty.value && quotePretty.value !== (props.el.content || '').trim()
})
/** list 每行的渲染 HTML(segments 或纯文本) */
const renderedListItems = computed(() => {
if (hasSegments.value) {
@@ -173,14 +224,102 @@ function onBlur(e: Event, field: string) {
}
emit('blur', props.el.id, field, val)
}
/* ---------- 显示层溢出缩字兜底(fitText) ---------- */
const rootEl = ref<HTMLElement | null>(null)
let fitTimer: number | null = null
/** 内容超容器时按比例缩小字号(只改显示不动数据)。
* 编辑态也执行:所见即最终效果,拖拽/缩放结束后自动收敛;拖拽进行中跳过(Canvas 会挂 dragging class,避免交互期抖动)
* 下限:原字号 60% 且不低于 11px;画布 transform:scale 不影响 scrollHeight/clientHeight,检测不受缩放干扰 */
function fitText() {
const root = rootEl.value
if (!root) return
if (root.classList.contains('dragging')) return
const t = props.el.type
if (t === 'stat') { fitStat(); return }
const sel = t === 'card' ? '.el-card'
: t === 'list' ? '.el-list'
: t === 'table' ? '.el-table'
: (t === 'title' || t === 'text' || t === 'quote') ? '.el-text'
: null
if (!sel) return
const box = root.querySelector(sel) as HTMLElement | null
if (!box) return
const base = props.el.style.fontSize || 24 // 根元素基准字号(boxStyle 写入)
const min = Math.max(base * 0.6, 11)
box.style.fontSize = '' // 先恢复,防上次缩小值污染测量
let cur = base
for (let i = 0; i < 8 && box.scrollHeight > box.clientHeight + 2; i++) {
cur = Math.max(min, cur - Math.max(1, cur * 0.08))
box.style.fontSize = cur + 'px'
if (cur <= min + 0.5) break
}
}
/** stat 缩字兜底:.num 与 .label 字号独立(num 来自 fontSize/AI 常给 64-80,label 来自 labelSize),
* 整体溢出时先缩 num 再缩 label,各自循环缩到不溢出或基准的 62% 为止,
* 避免大数字+长说明被 .el 的 overflow:hidden 裁切遮挡 */
function fitStat() {
const box = rootEl.value?.querySelector('.el-stat') as HTMLElement | null
if (!box) return
const st = props.el.style
const blocks: Array<{ node: HTMLElement | null; base: number }> = [
{ node: box.querySelector('.num') as HTMLElement | null, base: st.fontSize || 24 },
{ node: box.querySelector('.label') as HTMLElement | null, base: st.labelSize || 16 }
]
// 先全部恢复基准字号,防上次缩小值污染测量
for (const b of blocks) { if (b.node) b.node.style.fontSize = '' }
if (box.scrollHeight <= box.clientHeight + 2) return
for (const b of blocks) {
const node = b.node
if (!node || box.scrollHeight <= box.clientHeight + 2) break
const min = b.base * 0.62
let cur = b.base
for (let i = 0; i < 8 && box.scrollHeight > box.clientHeight + 2; i++) {
cur = Math.max(min, cur - Math.max(1, cur * 0.08))
node.style.fontSize = cur + 'px'
if (cur <= min + 0.5) break
}
}
}
function scheduleFit() {
nextTick(() => {
if (fitTimer) clearTimeout(fitTimer)
fitTimer = setTimeout(fitText, 30) as unknown as number
// 入场动画(ppt-fade-up 等)带 translateY,动画期间测量会偏:动画结束后再补测一次
requestAnimationFrame(() => requestAnimationFrame(() => { if (fitTimer) clearTimeout(fitTimer); fitTimer = setTimeout(fitText, 400) as unknown as number }))
})
}
watch(() => [props.el.content, props.el.style.fontSize, props.el.style.label, props.el.style.labelSize, props.bg], scheduleFit, { deep: false })
// 拖拽中跳过 fit(dragging class 在)→ 结束后 dragging class 移除,监听其变化补跑一次收敛
watch(() => rootEl.value?.classList.contains('dragging'), (dragging, prev) => { if (prev && !dragging) scheduleFit() })
onMounted(() => {
scheduleFit()
// 字体异步加载完成会引起折行变化(尤其 quote serif/中文标题字体),就绪后重测一次
if (typeof document !== 'undefined' && (document as any).fonts?.ready) {
;(document as any).fonts.ready.then(() => scheduleFit()).catch(() => {})
}
// jsdom 测试环境无 ResizeObserver,跳过
if (typeof ResizeObserver === 'undefined') return
const ro = new ResizeObserver(scheduleFit)
if (rootEl.value) ro.observe(rootEl.value)
;(rootEl.value as any).__ro = ro
})
onBeforeUnmount(() => {
const ro = (rootEl.value as any)?.__ro
if (ro) ro.disconnect()
if (fitTimer) clearTimeout(fitTimer)
})
</script>
<template>
<div
ref="rootEl"
class="el"
:data-id="el.id"
:data-type="el.type"
:data-anim="s.anim"
:data-shape="el.type === 'shape' ? (s.shapeType || 'rect') : undefined"
:style="{
left: el.x + '%',
top: el.y + '%',
@@ -200,29 +339,36 @@ function onBlur(e: Event, field: string) {
data-edit="content"
@blur="onBlur($event, 'content')"
>{{ el.content }}</div>
<!-- 非编辑态 + quote:规范化装饰引号(剥反引号/直引号),伪元素渲染弯引号 -->
<div
v-else-if="el.type === 'quote' && quotePretty"
class="el-text"
:class="{ 'el-quote-pretty': quoteDeco }"
style="white-space: pre-wrap; width: 100%"
>{{ quotePretty }}</div>
<!-- 非编辑态 + 有 segments:渲染结构化富文本 -->
<div
v-else-if="renderedContent"
class="el-text el-text-rich"
v-html="renderedContent"
></div>
<!-- 非编辑态 + 纯文本 -->
<div v-else class="el-text" style="white-space: pre-wrap; width: 100%">{{ el.content }}</div>
<!-- 非编辑态 + 纯文本(空内容不渲染,避免空框) -->
<div v-else-if="el.content" class="el-text" style="white-space: pre-wrap; width: 100%">{{ el.content }}</div>
</template>
<!-- 列表 -->
<template v-else-if="el.type === 'list'">
<!-- 编辑态 -->
<div v-if="edit" class="el-list" contenteditable="true" data-edit="content" @blur="onBlur($event, 'content')">
<div v-for="(line, i) in dataList" :key="i" class="li">{{ line }}</div>
<div v-for="(line, i) in dataList" :key="i" class="li" :class="{ 'no-marker': hasLineMarker(line) }">{{ line }}</div>
</div>
<!-- 非编辑态 + 有 segments -->
<div v-else-if="renderedListItems" class="el-list">
<div v-for="(html, i) in renderedListItems" :key="i" class="li" v-html="html"></div>
<div v-for="(html, i) in renderedListItems" :key="i" class="li" :class="{ 'no-marker': hasLineMarker(dataList[i] || '') }" v-html="html"></div>
</div>
<!-- 非编辑态 + 纯文本 -->
<div v-else class="el-list">
<div v-for="(line, i) in dataList" :key="i" class="li">{{ line }}</div>
<!-- 非编辑态 + 纯文本(空内容不渲染) -->
<div v-else-if="el.content" class="el-list">
<div v-for="(line, i) in dataList" :key="i" class="li" :class="{ 'no-marker': hasLineMarker(line) }">{{ line }}</div>
</div>
</template>
@@ -230,12 +376,14 @@ function onBlur(e: Event, field: string) {
<template v-else-if="el.type === 'stat'">
<div class="el-stat">
<div
v-if="edit || el.content"
class="num"
:contenteditable="edit"
data-edit="content"
@blur="edit && onBlur($event, 'content')"
>{{ el.content }}</div>
<div
v-if="edit || s.label"
class="label"
:style="{ fontSize: (s.labelSize || 16) + 'px', color: resolveColor(s.labelColor, dark) }"
:contenteditable="edit"
@@ -245,20 +393,63 @@ function onBlur(e: Event, field: string) {
</div>
</template>
<!-- 图片 -->
<!-- 图片(无内容时显示占位提示,不渲染空 src 的裂图) -->
<template v-else-if="el.type === 'image'">
<img class="el-image" :src="el.content" draggable="false" />
<div v-if="!el.content" class="el-image-empty">
<span class="el-image-empty-icon"><Icon name="image" :size="28" /></span>
<span class="el-image-empty-text">拖入图片 · 属性面板「本地图片」或 AI 配图</span>
</div>
<img v-else class="el-image" :src="mediaSrc" draggable="false" />
</template>
<!-- 视频(缩略图端降级静态;preload=metadata 控制加载开销) -->
<template v-else-if="el.type === 'video'">
<div v-if="!el.content" class="el-image-empty">
<span class="el-image-empty-icon"><Icon name="video" :size="28" /></span>
<span class="el-image-empty-text">属性面板选择本地视频或填入 URL</span>
</div>
<!-- 缩略图:poster 静态图(无 poster 黑底▶),不加载视频 -->
<div v-else-if="thumb" class="el-video-thumb">
<img v-if="s.poster" :src="s.poster" draggable="false" />
<span v-else class="el-video-thumb-play">▶</span>
</div>
<!-- 编辑/演示:可播放(点选拖动交给画布 mousedown 冒泡,不阻断) -->
<video
v-else
class="el-video"
:src="mediaSrc"
:poster="s.poster || undefined"
controls
preload="metadata"
:autoplay="!!s.autoplay"
:loop="!!s.loop"
:muted="!!s.muted"
:style="{ objectFit: s.fit || 'contain' }"
draggable="false"
@click.stop
></video>
</template>
<!-- 形状 -->
<template v-else-if="el.type === 'shape'">
<div v-if="isCircle" class="el-shape" :class="{ gradient: s.gradient }" :style="{ borderRadius: '50%', background: shapeBg }"></div>
<div v-else-if="isTriangle" class="el-shape">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%;height:100%;display:block">
<polygon points="50,5 95,95 5,95" :fill="shapeBg" />
</svg>
</div>
<div v-if="s.shapeType === 'circle' || s.shapeType === 'ellipse'" class="el-shape" :class="{ gradient: s.gradient }" :style="{ borderRadius: '50%', background: shapeBg }"></div>
<div v-else-if="shapeClip" class="el-shape" :class="{ gradient: s.gradient }" :style="{ background: shapeBg, clipPath: shapeClip }"></div>
<div v-else class="el-shape" :class="{ gradient: s.gradient }" :style="{ background: shapeBg, borderRadius: (s.radius != null ? s.radius : 12) + 'px' }"></div>
<!-- 形状文字层:编辑态 contenteditable,非编辑态渲染 md;未设色时默认白(填充色底) -->
<div
v-if="edit"
class="el-shape-text"
:style="!s.color ? { color: '#fff' } : {}"
contenteditable="true"
data-edit="content"
@blur="onBlur($event, 'content')"
>{{ el.content }}</div>
<div
v-else-if="el.content"
class="el-shape-text"
:style="shapeTextStyle"
v-html="renderedShapeText"
></div>
</template>
<!-- 图表 -->
@@ -271,8 +462,20 @@ function onBlur(e: Event, field: string) {
<div class="card-bar" :style="{ background: cardAccentColor }"></div>
<div class="el-card">
<div v-if="s.icon" class="card-icon">{{ s.icon }}</div>
<div class="card-title">{{ cardParts.title }}</div>
<div class="card-body">{{ cardParts.body }}</div>
<div
v-if="edit || cardParts.title"
class="card-title"
:contenteditable="edit"
data-edit="content"
@blur="edit && onBlurCard($event)"
>{{ cardParts.title }}</div>
<div
v-if="edit || cardParts.body"
class="card-body"
:contenteditable="edit"
data-edit="content"
@blur="edit && onBlurCard($event)"
>{{ cardParts.body }}</div>
</div>
</template>
+472 -33
View File
@@ -5,31 +5,36 @@
import { computed, watch, ref } from 'vue'
import { store } from '../../core/store'
import { elementTypes } from '../../core/sample'
import { generateImage, isImageConfigured } from '../../core/ai'
import { generateImage, isImageConfigured, checkImageQuota } from '../../core/ai'
import { readAsDataURL } from '../../core/importer'
import { putAsset, isOssEnabled } from '../../core/assets'
import { appAlert, appConfirm, appPrompt } from '../../core/dialog'
import { markdownToSegments, segmentsToPlain, hasFormatting } from '../../core/richtext'
import AddGrid from './AddGrid.vue'
import type { ElementType, ChartType } from '../../core/types'
import Icon from '../common/Icon.vue'
import type { ElementType, ChartType, ShapeType } from '../../core/types'
const CHART_TYPES: Array<{ k: ChartType; label: string; icon: string }> = [
{ k: 'bar', label: '柱状图', icon: '📊' },
{ k: 'hbar', label: '条形图', icon: '📋' },
{ k: 'line', label: '折线图', icon: '📈' },
{ k: 'area', label: '面积图', icon: '🌄' },
{ k: 'pie', label: '饼图', icon: '🥧' },
{ k: 'doughnut', label: '环形图', icon: '🍩' },
{ k: 'radar', label: '雷达图', icon: '🕸' },
{ k: 'progress', label: '进度图', icon: '⭕' }
{ k: 'bar', label: '柱状图', icon: 'bar-chart' },
{ k: 'hbar', label: '条形图', icon: 'bar-chart-h' },
{ k: 'line', label: '折线图', icon: 'line-chart' },
{ k: 'area', label: '面积图', icon: 'area-chart' },
{ k: 'pie', label: '饼图', icon: 'pie' },
{ k: 'doughnut', label: '环形图', icon: 'doughnut' },
{ k: 'radar', label: '雷达图', icon: 'radar' },
{ k: 'progress', label: '进度图', icon: 'progress' }
]
const selected = computed(() => store.getSelected())
const slide = computed(() => store.currentSlide.value)
const multiCount = computed(() => store.getSelection().length)
/** 临时输入态(v-model 绑定) */
const content = ref('')
const fontSize = ref(24)
const colorSel = ref('primary')
const colorPicker = ref('#000000')
const shape = ref<'rect' | 'circle' | 'triangle'>('rect')
const shape = ref<ShapeType>('rect')
const chartType = ref<ChartType>('bar')
const codeLang = ref('')
const imgBusy = ref(false)
@@ -64,11 +69,11 @@ watch(selected, (el) => {
}
}, { immediate: true })
const hasText = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula'].includes(selected.value.type))
const canFont = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula'].includes(selected.value.type))
const canAlign = computed(() => selected.value && ['title', 'text', 'quote', 'stat', 'table', 'formula'].includes(selected.value.type))
const canBI = computed(() => selected.value && ['title', 'text', 'quote', 'list'].includes(selected.value.type))
const canColor = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula'].includes(selected.value.type))
const hasText = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula', 'shape', 'card'].includes(selected.value.type))
const canFont = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula', 'shape', 'card'].includes(selected.value.type))
const canAlign = computed(() => selected.value && ['title', 'text', 'quote', 'stat', 'table', 'formula', 'shape'].includes(selected.value.type))
const canBI = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'shape'].includes(selected.value.type))
const canColor = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula', 'shape'].includes(selected.value.type))
const isShape = computed(() => selected.value?.type === 'shape')
const typeLabel = computed(() => {
@@ -80,6 +85,27 @@ function onAdd(type: ElementType) {
store.addElement(type)
}
/* 多选批量操作 */
function onMultiCopy() {
const sel = store.getSelection()
if (sel.length) store.copyElements(sel)
}
async function onMultiDel() {
const sel = store.getSelection()
if (!sel.length) return
if (await appConfirm('删除选中的 ' + sel.length + ' 个元素?', '可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
store.delElements(sel)
}
}
function onAlignOp(edge: 'left' | 'hcenter' | 'right' | 'top' | 'vmiddle' | 'bottom') {
const sel = store.getSelection()
if (sel.length > 1) store.alignElements(sel, edge)
}
function onDistribute(axis: 'h' | 'v') {
const sel = store.getSelection()
if (sel.length > 2) store.distributeElements(sel, axis)
}
function onContentInput() {
if (!selected.value) return
store.updateElement(selected.value.id, { content: content.value })
@@ -140,9 +166,12 @@ function onZ(dir: number) {
if (!selected.value) return
store.moveElementZ(selected.value.id, dir)
}
function onDel() {
async function onDel() {
if (!selected.value) return
store.delElement(selected.value.id)
const label = elementTypes[selected.value.type]?.label || '元素'
if (await appConfirm('删除这个' + label + '?', '可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
store.delElement(selected.value.id)
}
}
function onBg(k: string) {
store.setSlideBackground(k)
@@ -161,7 +190,7 @@ function applyMarkdown() {
if (!el) return
const segs = markdownToSegments(content.value)
if (segs.length && hasFormatting(segs)) {
store.updateElement(el.id, { segments: segs } as any)
store.updateElement(el.id, { segments: segs })
}
}
@@ -169,28 +198,136 @@ function applyMarkdown() {
function clearRich() {
const el = selected.value
if (!el) return
store.updateElement(el.id, { segments: undefined } as any)
store.updateElement(el.id, { segments: undefined })
}
/* ---------- AI 配图 ---------- */
/* ---------- AI 配图 / 本地换图 ---------- */
async function onAiImage() {
if (imgBusy.value) return
const el = selected.value
if (!el || el.type !== 'image') return
if (!isImageConfigured()) { alert('请先在「AI 设置」中配置 API Key'); return }
const promptText = window.prompt('描述你想要的图片,例如「现代办公室协作场景,俯拍,柔和光线」')
if (!isImageConfigured()) { appAlert('需要 API Key', '请先在「AI 设置」中配置'); return }
const promptText = await appPrompt('AI 配图', { placeholder: '描述你想要的图片,如「现代办公室协作场景,俯拍,柔和光线」' })
if (!promptText) return
imgBusy.value = true
imgAbort = new AbortController()
try {
const r = await generateImage({ prompt: promptText, signal: imgAbort.signal })
const quotaErr = checkImageQuota(r.url)
if (quotaErr) { appAlert('配图受限', quotaErr); return }
store.updateElement(el.id, { content: r.url })
} catch (e: any) {
if (e?.name !== 'AbortError') alert('配图失败:' + (e?.message || String(e)))
if (e?.name !== 'AbortError') appAlert('配图失败', e?.message || String(e))
} finally {
imgBusy.value = false; imgAbort = null
}
}
/* ---------- 批注(仅图片元素) ---------- */
/** 复用文件内颜色选项集合(与颜色 select 的 option values 同款) */
const ANNO_COLORS: Array<{ k: string; label: string }> = [
{ k: 'primary', label: '主色' },
{ k: 'accent', label: '强调色' },
{ k: 'text', label: '正文色' },
{ k: 'muted', label: '次要色' },
{ k: '#ffffff', label: '白色' }
]
/** 当前图片的批注列表 */
const annotations = computed<any[]>(() => selected.value?.style?.annotations || [])
/** 折叠状态:记录「已展开」的批注 id(默认折叠,仅展开正在编辑的一条) */
const expandedAnno = ref<string | null>(null)
function toggleAnno(annoId: string) {
expandedAnno.value = expandedAnno.value === annoId ? null : annoId
}
function addAnno() {
if (!selected.value) return
const id = store.addAnnotation(selected.value.id)
if (id) expandedAnno.value = id // 新增自动展开
}
function delAnno(annoId: string) {
if (selected.value) store.delAnnotation(selected.value.id, annoId)
if (expandedAnno.value === annoId) expandedAnno.value = null
}
function patchAnno(annoId: string, patch: Record<string, any>) {
if (!selected.value) return
store.updateAnnotation(selected.value.id, annoId, patch)
}
/** 批注文字摘要(折叠态卡片头显示) */
function annoSummary(anno: any): string {
const t = (anno.text || '').trim()
return t ? (t.length > 12 ? t.slice(0, 12) + '…' : t) : '(空批注)'
}
/** 本地上传图片替换当前图片元素内容 */
function onLocalImage() {
const el = selected.value
if (!el || el.type !== 'image') return
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
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 quotaErr = checkImageQuota(dataUrl)
if (quotaErr) { appAlert('图片受限', quotaErr); return }
store.updateElement(el.id, { content: dataUrl })
} catch (e: any) {
appAlert('读取图片失败', e?.message || String(e))
}
}
input.click()
}
/* ---------- 视频元素 ---------- */
function onLocalVideo() {
const el = selected.value
if (!el) return
const input = document.createElement('input')
input.type = 'file'
input.accept = 'video/*'
input.onchange = async () => {
const f = input.files?.[0]
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),本地工具首版接受
const dataUrl = await readAsDataURL(f)
store.updateElement(el.id, { content: dataUrl })
}
input.click()
}
function onVideoUrl(e: Event) {
const el = selected.value
if (!el) return
const v = (e.target as HTMLInputElement).value.trim()
if (v) store.updateElement(el.id, { content: v })
}
function toggleVideoOpt(key: 'autoplay' | 'loop' | 'muted') {
const el = selected.value
if (!el) return
const cur = (el.style as any)[key]
store.updateElement(el.id, { style: { [key]: !cur } })
}
</script>
<template>
@@ -201,6 +338,42 @@ async function onAiImage() {
<AddGrid @add="onAdd" />
</section>
<!-- 多选态:批量操作 -->
<section v-else-if="multiCount > 1" class="prop-section" id="propMulti">
<h4 class="prop-title">多选 · <span>{{ multiCount }} 个元素</span></h4>
<div class="prop-row">
<label>批量操作</label>
<div class="seg">
<button @click="onMultiCopy" title="Ctrl+C">⧉ 复制</button>
<button class="danger" @click="onMultiDel" title="Delete"><Icon name="trash" :size="13" /> 删除</button>
</div>
</div>
<div class="prop-row">
<label>对齐</label>
<div class="seg">
<button @click="onAlignOp('left')" title="左对齐">⇤</button>
<button @click="onAlignOp('hcenter')" title="水平居中">↔</button>
<button @click="onAlignOp('right')" title="右对齐">⇥</button>
<button @click="onAlignOp('top')" title="顶对齐">⇧</button>
<button @click="onAlignOp('vmiddle')" title="垂直居中">↕</button>
<button @click="onAlignOp('bottom')" title="底对齐">⇩</button>
</div>
</div>
<div class="prop-row">
<label>分布</label>
<div class="seg">
<button :disabled="multiCount < 3" @click="onDistribute('h')" :title="multiCount < 3 ? '需要选中 3 个以上' : '水平等间距'">⋯</button>
<button :disabled="multiCount < 3" @click="onDistribute('v')" :title="multiCount < 3 ? '需要选中 3 个以上' : '垂直等间距'">⋮</button>
</div>
</div>
<div class="prop-row">
<label>提示</label>
<div style="font-size:12px;color:var(--ui-muted);line-height:1.6">
拖动整组移动 · Shift+点击加选/减选<br />空白处拖动框选 · Esc 取消多选
</div>
</div>
</section>
<!-- 选中元素 -->
<section v-else class="prop-section" id="propElement">
<h4 class="prop-title">元素 · <span>{{ typeLabel }}</span></h4>
@@ -208,7 +381,12 @@ async function onAiImage() {
<!-- 内容 -->
<div v-if="hasText && selected.type !== 'stat'" class="prop-row">
<label>内容</label>
<textarea rows="3" placeholder="输入文字(列表用换行分隔)" v-model="content" @input="onContentInput"></textarea>
<textarea
rows="3"
:placeholder="selected.type === 'card' ? '第一行=标题,其余行=正文' : selected.type === 'shape' ? '形状内文字(支持 **加粗** 等 md 语法)' : '输入文字(列表用换行分隔)'"
v-model="content"
@input="onContentInput"
></textarea>
<!-- 富文本格式提示 -->
<div v-if="['title','text','quote','list'].includes(selected.type)" class="rich-hint">
<div class="rich-syntax">
@@ -247,9 +425,9 @@ async function onAiImage() {
<div v-if="canAlign" class="prop-row">
<label>对齐</label>
<div class="seg">
<button :class="{ active: selected.style.align === 'left' }" @click="onAlign('left')">⬅</button>
<button :class="{ active: selected.style.align === 'center' || !selected.style.align }" @click="onAlign('center')">⬌</button>
<button :class="{ active: selected.style.align === 'right' }" @click="onAlign('right')">➡</button>
<button :class="{ active: selected.style.align === 'left' }" @click="onAlign('left')" title="左对齐"><Icon name="align-left" :size="14" /></button>
<button :class="{ active: selected.style.align === 'center' || !selected.style.align }" @click="onAlign('center')" title="居中"><Icon name="align-center" :size="14" /></button>
<button :class="{ active: selected.style.align === 'right' }" @click="onAlign('right')" title="右对齐"><Icon name="align-right" :size="14" /></button>
</div>
</div>
@@ -268,7 +446,15 @@ async function onAiImage() {
<select v-model="shape" @change="onShapeChange">
<option value="rect">矩形</option>
<option value="circle">圆形</option>
<option value="ellipse">椭圆</option>
<option value="triangle">三角</option>
<option value="diamond">菱形</option>
<option value="pentagon">五边形</option>
<option value="hexagon">六边形</option>
<option value="star">五角星</option>
<option value="arrow">箭头</option>
<option value="chevron">折角</option>
<option value="bubble">气泡</option>
</select>
</div>
@@ -277,7 +463,7 @@ async function onAiImage() {
<div class="prop-row">
<label>图表类型</label>
<select v-model="chartType" @change="onChartTypeChange">
<option v-for="t in CHART_TYPES" :key="t.k" :value="t.k">{{ t.icon }} {{ t.label }}</option>
<option v-for="t in CHART_TYPES" :key="t.k" :value="t.k">{{ t.label }}</option>
</select>
</div>
<div class="prop-row">
@@ -321,10 +507,38 @@ async function onAiImage() {
</div>
</div>
<!-- AI 配图(仅图片元素) -->
<!-- 图片来源(仅图片元素) -->
<div v-if="selected.type === 'image'" class="prop-row">
<label>AI 配图</label>
<button class="btn" :disabled="imgBusy" @click="onAiImage">{{ imgBusy ? '生成中…' : '🎨 AI 配图' }}</button>
<label>图片来源</label>
<div class="seg">
<button @click="onLocalImage" title="从本地选择图片"><Icon name="folder" :size="13" /> 本地图片</button>
<button :disabled="imgBusy" @click="onAiImage"><template v-if="imgBusy">生成中…</template><template v-else><Icon name="palette" :size="13" /> AI 配图</template></button>
</div>
</div>
<!-- 视频来源(仅视频元素) -->
<div v-if="selected.type === 'video'" class="prop-row">
<label>视频来源</label>
<div class="seg">
<button @click="onLocalVideo" title="从本地选择视频文件"><Icon name="folder" :size="13" /> 本地视频</button>
</div>
<input
type="text"
class="video-url"
placeholder="或粘贴视频 URL (https://…)"
style="margin-top:6px;width:100%;box-sizing:border-box"
:value="selected.content"
@change="onVideoUrl($event)"
/>
</div>
<!-- 视频播放选项 -->
<div v-if="selected.type === 'video'" class="prop-row">
<label>播放选项</label>
<div class="seg">
<button :class="{ active: !!selected.style.autoplay }" @click="toggleVideoOpt('autoplay')" title="自动播放">自动</button>
<button :class="{ active: !!selected.style.loop }" @click="toggleVideoOpt('loop')" title="循环">循环</button>
<button :class="{ active: !!selected.style.muted }" @click="toggleVideoOpt('muted')" title="静音">静音</button>
</div>
</div>
<!-- 层级 -->
@@ -333,7 +547,109 @@ async function onAiImage() {
<div class="seg">
<button @click="onZ(1)" title="上移">↑</button>
<button @click="onZ(-1)" title="下移">↓</button>
<button class="danger" @click="onDel" title="删除">🗑</button>
<button class="danger" @click="onDel" title="删除"><Icon name="trash" :size="13" /></button>
</div>
</div>
<!-- 批注(仅图片元素) -->
<div v-if="selected.type === 'image'" class="prop-row">
<label>批注</label>
<div class="anno-head">
<button @click="addAnno" title="添加一条批注">+ 添加批注</button>
</div>
<div class="anno-list">
<div v-for="(anno, i) in annotations" :key="anno.id" class="anno-card" :class="{ open: expandedAnno === anno.id }">
<div class="anno-card-head" @click="toggleAnno(anno.id)">
<span class="anno-caret">{{ expandedAnno === anno.id ? '▾' : '▸' }}</span>
<span class="anno-card-title">批注 {{ i + 1 }}</span>
<span v-if="expandedAnno !== anno.id" class="anno-card-summary">{{ annoSummary(anno) }}</span>
<button class="anno-del" @click.stop="delAnno(anno.id)" title="删除批注">×</button>
</div>
<div v-show="expandedAnno === anno.id" class="anno-body">
<textarea
class="anno-text"
rows="2"
placeholder="批注文字"
:value="anno.text"
@input="patchAnno(anno.id, { text: ($event.target as HTMLTextAreaElement).value })"
></textarea>
<!-- 气泡文字样式 -->
<div class="anno-sub">气泡文字</div>
<div class="anno-line">
<label class="anno-lbl">字号 {{ anno.fontSize || 16 }}px</label>
<input
type="range" min="12" max="48"
:value="anno.fontSize || 16"
@input="patchAnno(anno.id, { fontSize: Number(($event.target as HTMLInputElement).value) })"
/>
</div>
<div class="anno-line">
<select
:value="anno.color || 'text'"
@change="patchAnno(anno.id, { color: ($event.target as HTMLSelectElement).value })"
>
<option v-for="c in ANNO_COLORS" :key="c.k" :value="c.k">{{ c.label }}</option>
</select>
<div class="seg">
<button :class="{ active: !!anno.bold }" @click="patchAnno(anno.id, { bold: !anno.bold })" title="加粗"><b>B</b></button>
<button :class="{ active: !!anno.italic }" @click="patchAnno(anno.id, { italic: !anno.italic })" title="斜体"><i>I</i></button>
</div>
</div>
<div class="anno-line">
<div class="seg">
<button :class="{ active: anno.align === 'left' }" @click="patchAnno(anno.id, { align: 'left' })" title="左对齐"><Icon name="align-left" :size="14" /></button>
<button :class="{ active: anno.align === 'center' || !anno.align }" @click="patchAnno(anno.id, { align: 'center' })" title="居中"><Icon name="align-center" :size="14" /></button>
<button :class="{ active: anno.align === 'right' }" @click="patchAnno(anno.id, { align: 'right' })" title="右对齐"><Icon name="align-right" :size="14" /></button>
</div>
</div>
<!-- 连线样式 -->
<div class="anno-sub">连线</div>
<div class="anno-line">
<select
:value="anno.line?.style || 'solid'"
@change="patchAnno(anno.id, { line: { style: ($event.target as HTMLSelectElement).value } })"
>
<option value="solid">实线</option>
<option value="dashed">虚线</option>
</select>
<select
:value="anno.line?.color || 'muted'"
@change="patchAnno(anno.id, { line: { color: ($event.target as HTMLSelectElement).value } })"
>
<option v-for="c in ANNO_COLORS" :key="c.k" :value="c.k">{{ c.label }}</option>
</select>
</div>
<div class="anno-line">
<label class="anno-lbl">线宽 {{ anno.line?.width ?? 0.5 }}px</label>
<input
type="range" min="0.5" max="8" step="0.5"
:value="anno.line?.width ?? 0.5"
@input="patchAnno(anno.id, { line: { width: Number(($event.target as HTMLInputElement).value) } })"
/>
</div>
<div class="anno-line">
<select
:value="anno.line?.startCap || 'none'"
@change="patchAnno(anno.id, { line: { startCap: ($event.target as HTMLSelectElement).value } })"
>
<option value="none">起点·无</option>
<option value="arrow">起点·箭头</option>
<option value="dot">起点·圆点</option>
</select>
<select
:value="anno.line?.endCap || 'arrow'"
@change="patchAnno(anno.id, { line: { endCap: ($event.target as HTMLSelectElement).value } })"
>
<option value="none">终点·无</option>
<option value="arrow">终点·箭头</option>
<option value="dot">终点·圆点</option>
</select>
</div>
</div>
</div>
</div>
</div>
</section>
@@ -352,3 +668,126 @@ async function onAiImage() {
</section>
</div>
</template>
<style scoped>
.anno-head {
display: flex;
margin-bottom: 6px;
}
.anno-head button {
flex: 1;
height: 32px;
border: 1px solid var(--ui-border, #e2e8f0);
border-radius: var(--radius-sm, 6px);
background: #fff;
color: var(--ui-text, #1e293b);
font-size: 13px;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.anno-head button:hover {
background: var(--ui-primary-soft, #eeeefc);
border-color: var(--ui-primary, #5b5bd6);
color: var(--ui-primary, #5b5bd6);
}
.anno-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.anno-card {
border: 0.5px solid var(--ui-border, #e2e8f0);
border-radius: 8px;
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--ui-panel, #f8fafc);
}
.anno-card.open {
border-color: var(--ui-primary, #5b5bd6);
}
.anno-card-head {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 8px;
cursor: pointer;
user-select: none;
}
.anno-card-head:hover {
background: var(--ui-primary-soft, #eeeefc);
}
.anno-caret {
font-size: 10px;
color: var(--ui-muted, #64748b);
width: 12px;
flex: none;
}
.anno-card-title {
font-size: 12px;
font-weight: 500;
color: var(--ui-text, #1e293b);
flex: none;
}
.anno-card-summary {
font-size: 12px;
color: var(--ui-muted, #64748b);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.anno-del {
margin-left: auto;
width: 20px;
height: 20px;
line-height: 1;
padding: 0;
border-radius: 6px;
border: 1px solid var(--ui-border, #e2e8f0);
background: #fff;
color: var(--ui-muted, #64748b);
cursor: pointer;
flex: none;
}
.anno-del:hover {
background: #fff1f2;
border-color: var(--ui-danger, #ef4444);
color: var(--ui-danger, #ef4444);
}
.anno-body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 8px 8px;
}
.anno-text {
width: 100%;
resize: vertical;
}
.anno-sub {
font-size: 11px;
color: var(--ui-muted, #64748b);
margin-top: 2px;
}
.anno-line {
display: flex;
align-items: center;
gap: 6px;
}
.anno-line > select {
flex: 1;
min-width: 0;
}
.anno-lbl {
font-size: 12px;
color: var(--ui-muted, #64748b);
white-space: nowrap;
}
.anno-line > input[type="range"] {
flex: 1;
}
</style>
+6 -2
View File
@@ -4,6 +4,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { store, resolveBg } from '../../core/store'
import { appConfirm } from '../../core/dialog'
import ElementView from './ElementView.vue'
const slides = computed(() => store.slides.value)
@@ -12,9 +13,11 @@ const currentIndex = computed(() => store.currentIndex.value)
function onClickItem(i: number) {
store.setCurrentIndex(i)
}
function onClickDel(i: number, e: Event) {
async function onClickDel(i: number, e: Event) {
e.stopPropagation()
store.delSlide(i)
if (await appConfirm('删除这页幻灯片?', '第 ' + (i + 1) + ' 页,可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
store.delSlide(i)
}
}
function onClickAdd() {
store.addSlide(store.getCurrentIndex())
@@ -41,6 +44,7 @@ function onClickAdd() {
:key="el.id"
:el="el"
:bg="slide.background"
thumb
/>
</div>
</div>
+157 -21
View File
@@ -1,21 +1,26 @@
<!-- =====================================================================
Toolbar.vue — 顶部工具栏
信息架构分层:高频创作动作直接暴露;文件域(库/存取/导出)收进「文件」菜单
===================================================================== -->
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { store } from '../../core/store'
import { themes } from '../../core/sample'
import { appConfirm } from '../../core/dialog'
import Icon from '../common/Icon.vue'
const emit = defineEmits<{
(e: 'present'): void
(e: 'open-library'): void
(e: 'open-settings'): void
(e: 'open-ai'): void
(e: 'save'): void
(e: 'open-templates'): void
(e: 'export-json'): void
(e: 'import-json'): void
(e: 'import-materials'): void
(e: 'export-pdf'): void
(e: 'home'): void
(e: 'share'): void
}>()
const themeKeys = Object.keys(themes)
@@ -28,42 +33,88 @@ function onThemeChange() {
store.setTheme(currentTheme.value)
}
function action(a: string) {
async function action(a: string) {
switch (a) {
case 'add-slide': store.addSlide(store.getCurrentIndex()); break
case 'dup-slide': store.dupSlide(); break
case 'del-slide':
if (store.delSlide()) { /* ok */ }
if (await appConfirm('删除这页幻灯片?', '第 ' + (store.getCurrentIndex() + 1) + ' 页,可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
store.delSlide()
}
break
case 'reset':
if (confirm('重置为内置示例?当前编辑内容将丢失(可用 Ctrl+Z 撤销)。')) {
if (await appConfirm('重置为内置示例?', '当前编辑内容将丢失(可用 Ctrl+Z 撤销)', { danger: true })) {
store.reset()
}
break
case 'present': emit('present'); break
case 'library': emit('open-library'); break
case 'save': emit('save'); break
case 'open-ai': emit('open-ai'); break
case 'settings': emit('open-settings'); break
}
}
/* ---------- 文件菜单(低频动作收纳) ---------- */
const fileMenuOpen = ref(false)
const fileMenuBtn = ref<HTMLElement>()
function toggleFileMenu() {
fileMenuOpen.value = !fileMenuOpen.value
}
function fileAction(a: string) {
fileMenuOpen.value = false
switch (a) {
case 'library': emit('open-library'); break
case 'save': emit('save'); break
case 'export-json': emit('export-json'); break
case 'import-materials': emit('import-materials'); break
case 'import-json': emit('import-json'); break
case 'export-pdf': emit('export-pdf'); break
case 'share': emit('share'); break
case 'reset': action('reset'); break
}
}
function onDocClick(e: MouseEvent) {
if (fileMenuOpen.value && fileMenuBtn.value && !fileMenuBtn.value.contains(e.target as Node)) {
fileMenuOpen.value = false
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && fileMenuOpen.value) fileMenuOpen.value = false
}
onMounted(() => {
document.addEventListener('mousedown', onDocClick)
document.addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
document.removeEventListener('mousedown', onDocClick)
document.removeEventListener('keydown', onKeydown)
})
defineProps<{ disabledActions?: string[] }>()
</script>
<template>
<header class="toolbar">
<div class="brand">
<button class="brand" title="返回首页" @click="emit('home')">
<span class="logo">▦</span>
<span class="name">u-ppt</span>
<span class="sub">在线演示工具</span>
</div>
</button>
<div class="tools">
<button class="btn" data-action="add-slide" title="新建幻灯片" :disabled="disabledActions?.includes('add-slide')" @click="action('add-slide')">+ 幻灯片</button>
<button class="btn" data-action="templates" title="从模板新建页" @click="emit('open-templates')">📋 模板</button>
<button class="btn" data-action="dup-slide" title="复制当前页" :disabled="disabledActions?.includes('dup-slide')" @click="action('dup-slide')">⎘ 复制</button>
<button class="btn" data-action="del-slide" title="删除当前页" :disabled="disabledActions?.includes('del-slide')" @click="action('del-slide')">🗑 删除</button>
<!-- 高频:页面操作 -->
<button class="btn tool-add" data-action="add-slide" title="新建幻灯片" :disabled="disabledActions?.includes('add-slide')" @click="action('add-slide')"><Icon name="plus" :size="14" /> 幻灯片</button>
<button class="btn" data-action="templates" title="从模板新建页" @click="emit('open-templates')"><Icon name="clipboard" :size="14" /> 模板</button>
<span class="sep"></span>
<button class="btn icon-only" data-action="dup-slide" title="复制当前页" :disabled="disabledActions?.includes('dup-slide')" @click="action('dup-slide')"><Icon name="clipboard" :size="15" /></button>
<button class="btn icon-only danger-hover" data-action="del-slide" title="删除当前页" :disabled="disabledActions?.includes('del-slide')" @click="action('del-slide')"><Icon name="trash" :size="15" /></button>
<span class="sep"></span>
<!-- 主题 -->
<label class="theme-select">
主题
<select v-model="currentTheme" @change="onThemeChange">
@@ -71,15 +122,100 @@ defineProps<{ disabledActions?: string[] }>()
</select>
</label>
<span class="sep"></span>
<button class="btn" data-action="library" title="我的演示文库" @click="action('library')">📁 文库</button>
<button class="btn" data-action="save" title="保存到文库" :disabled="disabledActions?.includes('save')" @click="action('save')">💾 保存</button>
<button class="btn" data-action="export-json" title="导出 JSON" @click="emit('export-json')">📤 导出</button>
<button class="btn" data-action="import-json" title="导入 JSON" @click="emit('import-json')">📥 导入</button>
<button class="btn" data-action="export-pdf" title="打印/导出 PDF" @click="emit('export-pdf')">🖨 PDF</button>
<button class="btn" data-action="open-ai" title="打开 AI 助手" @click="action('open-ai')">🤖 AI</button>
<button class="btn" data-action="reset" title="重置为内置示例" :disabled="disabledActions?.includes('reset')" @click="action('reset')">⟲ 重置</button>
<button class="btn primary" data-action="present" title="开始演示 (F5)" :disabled="disabledActions?.includes('present')" @click="action('present')">▶ 演示</button>
<button class="btn ghost" data-action="settings" title="AI 设置" @click="action('settings')">⚙</button>
<!-- 保存:高频动作常驻工具栏 -->
<button class="btn" data-action="save" title="保存到文库 (Ctrl+S)" :disabled="disabledActions?.includes('save')" @click="action('save')"><Icon name="save" :size="14" /> 保存</button>
<!-- 低频:文件域收进菜单 -->
<div class="file-menu" ref="fileMenuBtn">
<button class="btn" data-action="file-menu" :class="{ active: fileMenuOpen }" @click="toggleFileMenu">
<Icon name="folder" :size="14" /> 文件 <span class="chevron" :class="{ open: fileMenuOpen }">▾</span>
</button>
<Transition name="menu-pop">
<div v-if="fileMenuOpen" class="file-dropdown" role="menu">
<button class="menu-item" role="menuitem" @click="fileAction('library')">
<span class="mi-icon"><Icon name="folder" :size="15" /></span><span class="mi-label">文库</span><span class="mi-hint">我的演示</span>
</button>
<button class="menu-item" role="menuitem" @click="fileAction('import-materials')">
<span class="mi-icon"><Icon name="folder-open" :size="15" /></span><span class="mi-label">导入资料</span><span class="mi-hint">图片 / 文档 / 目录</span>
</button>
<button class="menu-item" role="menuitem" @click="fileAction('import-json')">
<span class="mi-icon"><Icon name="download" :size="15" /></span><span class="mi-label">导入 JSON</span>
</button>
<button class="menu-item" role="menuitem" @click="fileAction('export-json')">
<span class="mi-icon"><Icon name="upload" :size="15" /></span><span class="mi-label">导出 JSON</span>
</button>
<button class="menu-item" role="menuitem" @click="fileAction('export-pdf')">
<span class="mi-icon"><Icon name="printer" :size="15" /></span><span class="mi-label">导出 PDF</span><span class="mi-hint">打印 / 另存</span>
</button>
<button class="menu-item" role="menuitem" @click="fileAction('share')">
<span class="mi-icon"><Icon name="link" :size="15" /></span><span class="mi-label">生成分享链接</span><span class="mi-hint">公开只读 · 云端</span>
</button>
<div class="menu-sep"></div>
<button class="menu-item danger" role="menuitem" :disabled="disabledActions?.includes('reset')" @click="fileAction('reset')">
<span class="mi-icon"><Icon name="rotate" :size="15" /></span><span class="mi-label">重置为示例</span>
</button>
</div>
</Transition>
</div>
<!-- 终端动作 -->
<button class="btn primary" data-action="present" title="开始演示 (F5)" :disabled="disabledActions?.includes('present')" @click="action('present')"><Icon name="play" :size="13" /> 演示</button>
<button class="btn ghost icon-only" data-action="settings" title="设置(AI / 云存储 / 中继)" @click="action('settings')"><Icon name="settings" :size="16" /></button>
</div>
</header>
</template>
<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:hover { background: #e0e7ff; }
.tool-add:disabled { opacity: .5; }
/* 纯图标按钮:删除/复制降为图标,减文字噪音 */
.btn.icon-only { width: 36px; padding: 0; justify-content: center; font-size: 16px; }
.btn.danger-hover:hover { color: var(--ui-danger); border-color: var(--ui-danger); background: rgba(225, 29, 72, .05); }
/* ---------- 文件菜单 ---------- */
.file-menu { position: relative; display: inline-flex; }
.file-menu .btn.active { background: var(--ui-hover); border-color: var(--ui-primary); }
.chevron { font-size: 16px; margin-left: 2px; opacity: .6; transition: transform .15s; }
.chevron.open { transform: rotate(180deg); }
.file-dropdown {
position: absolute; top: calc(100% + 6px); left: 0;
width: max-content; min-width: 216px;
background: var(--ui-panel);
border: 1px solid var(--ui-border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 5px;
z-index: 100;
}
.menu-item {
display: flex; align-items: center; gap: 10px;
width: 100%; padding: 7px 10px;
border: none; background: transparent;
border-radius: var(--radius-sm);
font-size: 13px; color: var(--ui-text);
text-align: left; cursor: pointer;
white-space: nowrap;
}
.menu-item:hover { background: var(--ui-hover); }
.menu-item:disabled { opacity: .45; cursor: not-allowed; background: transparent; }
.menu-item .mi-icon { width: 18px; text-align: center; flex-shrink: 0; }
.menu-item .mi-label { flex-shrink: 0; }
.menu-item .mi-hint { margin-left: auto; font-size: 11px; color: var(--ui-muted); padding-left: 14px; }
.menu-item.danger { color: var(--ui-danger); }
.menu-item.danger:hover { background: rgba(225, 29, 72, .06); }
.menu-sep { height: 1px; background: var(--ui-border); margin: 5px 8px; }
/* 弹出动画 */
.menu-pop-enter-active { transition: opacity .14s, transform .14s; }
.menu-pop-leave-active { transition: opacity .1s, transform .1s; }
.menu-pop-enter-from, .menu-pop-leave-to { opacity: 0; transform: translateY(-4px); }
</style>
+75
View File
@@ -0,0 +1,75 @@
/* =====================================================================
* chart-domain.ts — 图表值域自适应(nice ticks)
* 纯函数、无依赖,供 ChartView.vue 与单元测试共用
*
* 目标:
* 1. bar 类保持 0 基线;line/area 值域贴合数据带(高基数数据不贴顶)
* 2. 刻度步长取 1/2/5×10^k(下限 0.5),输出整齐的整数/半步刻度
* ===================================================================== */
/** 值域 + 刻度 */
export interface ChartDomain {
min: number
max: number
ticks: number[]
}
/** nice 步长:raw 向上取整到 1/2/5×10^k */
function niceStep(raw: number): number {
if (!(raw > 0) || !Number.isFinite(raw)) return 1
const exp = Math.floor(Math.log10(raw))
const f = raw / Math.pow(10, exp)
const nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10
return nf * Math.pow(10, exp)
}
/** 消浮点误差:保留 2 位小数 */
function round2(v: number): number {
return Math.round(v * 100) / 100
}
/**
* 计算图表值域与刻度
* @param values 所有数据值(自动过滤 NaN/Infinity)
* @param opts.zeroBase true=柱类,min 恒 0;false=line/area,值域贴合数据带
* @param opts.maxCap AI/用户指定的上限(仅当 > 数据 max 时生效)
*/
export function niceDomain(
values: number[],
opts: { zeroBase?: boolean; maxCap?: number } = {}
): ChartDomain {
const vals = values.filter(v => Number.isFinite(v))
if (!vals.length) return { min: 0, max: 1, ticks: [0, 0.5, 1] }
const dataMin = Math.min(...vals)
let dMax = Math.max(...vals)
if (opts.maxCap && opts.maxCap > dMax) dMax = opts.maxCap
let dMin = opts.zeroBase ? 0 : dataMin
if (dMax <= dMin) dMax = dMin + 1
// 小波动(波动幅度 < 最大值 10%):line/area 值域收紧为数据带 ± 幅度,而非从 0 起
if (!opts.zeroBase) {
const span = dMax - dataMin
if (span >= 0 && span / (Math.abs(dMax) || 1) < 0.10) {
const pad = Math.max(span, 0.5)
dMin = dataMin - pad
dMax = dMax + pad
}
}
// nice 步长:目标 4 段,步长 ∈ {…0.5, 1, 2, 5…},下限 0.5
let step = niceStep((dMax - dMin) / 4)
if (step < 0.5) step = 0.5
const min = Math.floor(dMin / step) * step
const max = Math.ceil(dMax / step) * step
const ticks: number[] = []
const count = Math.round((max - min) / step)
for (let i = 0; i <= count; i++) ticks.push(round2(min + i * step))
return { min: round2(min), max: round2(max), ticks }
}
/** 刻度值 → 显示文本(去尾零:2.5 → "2.5",20 → "20") */
export function fmtTick(v: number): string {
return String(Math.round(v * 100) / 100)
}
+483
View File
@@ -0,0 +1,483 @@
<!-- =====================================================================
ImportModal.vue — 本地资料导入对话框
支持:图片直接插入,文档(PDF/DOCX/MD/TXT)→ LLM 分析生成幻灯片
===================================================================== -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { store } from '../../core/store'
import {
scanFiles, readEntries,
aiAnalyzeDocument,
fileToImageElement, imagesToSlideElements,
fileToVideoElement,
ACCEPT_ATTR,
type FileEntry, type ImportReport,
describeReport
} from '../../core/importer'
import { putAsset, isOssEnabled } from '../../core/assets'
import Icon from '../common/Icon.vue'
const props = withDefaults(defineProps<{ visible: boolean }>(), { visible: false })
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
(e: 'open-settings'): void
}>()
/* ---------- 状态 ---------- */
const activeTab = ref<'files' | 'dir'>('files')
const entries = ref<FileEntry[]>([])
const loading = ref(false)
const loaded = ref(false)
const analyzing = ref(false) // AI 分析中
const analyzeDone = ref(false) // AI 分析完成
const analyzeProgress = ref('') // AI 分析进度文案
const analyzeError = ref('') // AI 分析错误
const showImages = ref(true)
const showVideos = ref(true)
const showDocs = ref(true)
/* ---------- 过滤后的文件 ---------- */
const filteredEntries = computed(() => {
return entries.value.filter(e => {
if (e.kind === 'image' && !showImages.value) return false
if (e.kind === 'video' && !showVideos.value) return false
if (e.kind === 'document' && !showDocs.value) return false
return true
})
})
const imageEntries = computed(() => filteredEntries.value.filter(e => e.kind === 'image'))
const videoEntries = computed(() => filteredEntries.value.filter(e => e.kind === 'video'))
const docEntries = computed(() => filteredEntries.value.filter(e => e.kind === 'document'))
const unsupportedCount = computed(() => entries.value.filter(e => e.kind === 'unsupported').length)
/** 是否有文档需要 AI 分析 */
const hasDocs = computed(() => docEntries.value.length > 0)
/** AI 是否已配置 */
const aiReady = computed(() => !!store.getCfg().key)
/* ---------- 打开文件选择器 ---------- */
let fileInput: HTMLInputElement | null = null
let dirInput: HTMLInputElement | null = null
function openFilePicker() {
if (!fileInput) {
fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.multiple = true
fileInput.accept = ACCEPT_ATTR
fileInput.onchange = () => handleFiles(fileInput!.files ? Array.from(fileInput!.files) : null)
}
fileInput.value = ''
fileInput.click()
}
function openDirPicker() {
if (!dirInput) {
dirInput = document.createElement('input')
dirInput.type = 'file'
;(dirInput as any).webkitdirectory = true
dirInput.onchange = () => handleFiles(dirInput!.files ? Array.from(dirInput!.files) : null)
}
dirInput.value = ''
dirInput.click()
}
/* ---------- 拖放导入(两个 dropzone 通用) ---------- */
const dragOver = ref(false)
function onDropzoneDragOver(e: DragEvent) {
if (!e.dataTransfer?.types.includes('Files')) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
dragOver.value = true
}
function onDropzoneDragLeave() {
dragOver.value = false
}
function onDropzoneDrop(e: DragEvent) {
e.preventDefault()
dragOver.value = false
const fl = e.dataTransfer?.files
if (fl && fl.length > 0) void handleFiles(Array.from(fl))
}
/** 供父组件预填拖入的文件(全局拖放 → 打开弹窗并直接进入预览态) */
function acceptDroppedFiles(fl: File[]) {
void handleFiles(fl)
}
defineExpose({ acceptDroppedFiles })
async function handleFiles(fl: File[] | null) {
if (!fl || fl.length === 0) return
loading.value = true
loaded.value = false
analyzeDone.value = false
analyzeProgress.value = ''
analyzeError.value = ''
try {
const scanned = await scanFiles(fl)
entries.value = scanned
// 读取文件原始内容
if (scanned.some(e => e.kind === 'image' || e.kind === 'video' || e.kind === 'document')) {
await readEntries(scanned)
}
loaded.value = true
} catch (e: any) {
emit('toast', '读取失败: ' + (e?.message || String(e)))
} finally {
loading.value = false
}
}
/* ---------- AI 分析文档 ---------- */
async function runAiAnalysis() {
const docs = docEntries.value.filter(d => d.data && !d.slides)
if (docs.length === 0) return
if (!aiReady.value) {
analyzeError.value = '请先在「设置」中配置 AI API Key'
return
}
analyzing.value = true
analyzeProgress.value = ''
analyzeError.value = ''
let successCount = 0
for (const doc of docs) {
analyzeProgress.value = `正在 AI 分析(${successCount + 1}/${docs.length}):${doc.name}`
try {
await aiAnalyzeDocument(doc)
successCount++
} catch (e: any) {
const msg = e?.message || String(e)
doc.error = msg
analyzeError.value = `「${doc.name}」分析失败: ${msg}`
console.warn(`AI 分析失败 ${doc.name}:`, e)
}
}
analyzing.value = false
analyzeProgress.value = ''
analyzeDone.value = successCount > 0
if (successCount > 0 && !analyzeError.value) {
emit('toast', `AI 完成 ${successCount} 个文档分析,共生成 ${docs.reduce((s, d) => s + (d.slides?.length || 0), 0)} 页幻灯片`)
}
}
/* ---------- 执行导入 ---------- */
async function doImport() {
const images = imageEntries.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)
if (images.length === 0 && videos.length === 0 && docs.length === 0) {
// 如果有文档但还没 AI 分析,先分析
if (docEntries.value.filter(d => d.data && !d.slides).length > 0) {
void runAiAnalysis() // fire-and-forget:分析完成后 UI 自动更新按钮
return
}
emit('toast', '没有可导入的内容')
return
}
let insertedImages = 0
let insertedSlides = 0
// 整次导入合并为一条 undo 记录
const batch = store.beginBatch()
// 图片体积闸门:localStorage 约 5MB 字符上限,超限导入后刷新会丢图
// (OSS 启用时走资产库引用,不占 localStorage,跳过闸门)
if (images.length > 0 && !isOssEnabled()) {
const imgChars = images.reduce((s, e) => s + (e.data?.length || 0), 0)
let deckChars = 0
try { deckChars = JSON.stringify(store.getDeck()).length } catch (e) { /* ignore */ }
const QUOTA_CHARS = 4_500_000
if (deckChars + imgChars > QUOTA_CHARS) {
emit('toast', '图片过大:导入后约 ' + ((deckChars + imgChars) / 1048576).toFixed(1) + 'MB,超本地存储上限(约 5MB),刷新可能丢失。请压缩图片、减少数量,或在云存储中启用 OSS')
return
}
}
// 1. 图片 → 当前幻灯片(OSS 启用时 content 存资产引用,绕过 5MB 墙)
if (images.length > 0) {
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!)
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 = imagesToSlideElements(images.map(f => ({ file: f.file, dataUrl: f.data! })))
for (const el of els) {
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
insertedImages++
}
}
}
// 1.5 视频 → 当前幻灯片(OSS 启用时走资产库;每个视频一页,元素铺满合理区域)
if (videos.length > 0) {
for (const v of videos) {
const content = isOssEnabled() ? await putAsset(v.file) : v.data!
const el = fileToVideoElement(v.file, content)
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)
insertedImages++
} else {
// 多视频:一视频一页
store.appendSlide({ id: `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, background: 'bg', elements: [el] }, batch)
insertedSlides++
}
}
}
// 2. AI 生成的幻灯片 → 追加到 deck
if (docs.length > 0) {
for (const d of docs) {
if (d.slides) {
for (const slide of d.slides) {
store.appendSlide(slide, batch)
insertedSlides++
}
}
}
}
const report: ImportReport = { images: insertedImages, slides: insertedSlides, files: entries.value.length }
emit('toast', describeReport(report))
emit('close')
}
/* ---------- 清除 ---------- */
function clearFiles() {
entries.value = []
loaded.value = false
analyzeDone.value = false
analyzeProgress.value = ''
analyzeError.value = ''
}
/* ---------- 格式友好文件大小 ---------- */
function fmtSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
/** 截取文本预览前一段 */
function textPreview(data: string, maxLen = 80): string {
return data.replace(/\n/g, ' ').slice(0, maxLen) + (data.length > maxLen ? '…' : '')
}
</script>
<template>
<div v-if="props.visible" class="modal-mask" @click.self="emit('close')">
<div class="modal import-modal">
<header class="modal-header">
<h2>导入本地资料</h2>
<button class="close" @click="emit('close')" title="关闭"><Icon name="x" :size="16" /></button>
</header>
<!-- ======== 选择区域 ======== -->
<div v-if="!loaded" class="import-select">
<div class="import-tabs">
<button class="tab" :class="{ active: activeTab === 'files' }" @click="activeTab = 'files'"><Icon name="file" :size="14" /> 选择文件</button>
<button class="tab" :class="{ active: activeTab === 'dir' }" @click="activeTab = 'dir'"><Icon name="folder-open" :size="14" /> 读取目录</button>
</div>
<div
v-if="activeTab === 'files'"
class="dropzone"
:class="{ 'drag-over': dragOver }"
@click="openFilePicker"
@dragover="onDropzoneDragOver"
@dragleave="onDropzoneDragLeave"
@drop="onDropzoneDrop"
>
<div class="dropzone-icon"><Icon name="file" :size="34" /></div>
<div class="dropzone-text">
<strong>点击选择或拖入文件</strong>
<span class="hint">图片直接插入 · 文档(PDF/DOCX/MD/TXT)由 AI 分析生成幻灯片</span>
</div>
</div>
<div
v-else
class="dropzone"
:class="{ 'drag-over': dragOver }"
@click="openDirPicker"
@dragover="onDropzoneDragOver"
@dragleave="onDropzoneDragLeave"
@drop="onDropzoneDrop"
>
<div class="dropzone-icon"><Icon name="folder-open" :size="34" /></div>
<div class="dropzone-text">
<strong>点击选择目录</strong>
<span class="hint">读取目录下所有支持的图片和文档,AI 自动分析生成</span>
</div>
</div>
<div v-if="loading" class="loading">
<span class="spin">⟳</span> 正在读取文件...
</div>
</div>
<!-- ======== 预览区 ======== -->
<div v-else class="import-preview">
<div class="preview-actions">
<span class="file-count">
已选 {{ filteredEntries.length }} 个文件
<template v-if="unsupportedCount > 0">({{ unsupportedCount }} 个不支持的类型已忽略)</template>
</span>
<button class="btn-sm" @click="clearFiles">重新选择</button>
</div>
<!-- 过滤 -->
<div class="filter-bar">
<label class="chk"><input type="checkbox" v-model="showImages" /> <Icon name="image" :size="13" /> 图片 ({{ imageEntries.length }})</label>
<label class="chk"><input type="checkbox" v-model="showVideos" /> <Icon name="video" :size="13" /> 视频 ({{ videoEntries.length }})</label>
<label class="chk"><input type="checkbox" v-model="showDocs" /> <Icon name="file-text" :size="13" /> 文档 ({{ docEntries.length }})</label>
</div>
<!-- AI 分析进度 -->
<div v-if="analyzing" class="ai-progress">
<span class="spin">⟳</span> {{ analyzeProgress || 'AI 正在分析文档内容,理解语义并生成幻灯片…' }}
</div>
<!-- AI 分析错误 -->
<div v-if="analyzeError && !analyzing" class="ai-error">
<Icon name="alert" :size="13" /> {{ analyzeError }}
<template v-if="!aiReady">
<button class="btn-sm" @click="emit('open-settings')">去配置</button>
</template>
</div>
<!-- 文件列表 -->
<div class="file-list">
<div v-for="(entry, i) in filteredEntries" :key="i" class="file-item" :class="entry.kind">
<span class="file-icon"><Icon :name="entry.kind === 'image' ? 'image' : entry.kind === 'video' ? 'video' : 'file-text'" :size="15" /></span>
<span class="file-name" :title="entry.name">{{ entry.name }}</span>
<span class="file-size">{{ fmtSize(entry.file.size) }}</span>
<span class="file-status">
<!-- 图片 -->
<template v-if="entry.kind === 'image' && entry.data"><Icon name="check" :size="12" /> 图片就绪</template>
<!-- 视频 -->
<template v-else-if="entry.kind === 'video' && entry.data"><Icon name="check" :size="12" /> 视频就绪</template>
<!-- 文档:AI 分析结果 -->
<template v-else-if="entry.kind === 'document' && entry.slides"><Icon name="check" :size="12" /> AI → {{ entry.slides.length }} 页</template>
<template v-else-if="entry.kind === 'document' && entry.data && analyzeDone">⏳ 待分析</template>
<template v-else-if="entry.kind === 'document' && entry.data && analyzing">⟳ AI 分析中</template>
<template v-else-if="entry.kind === 'document' && entry.data">{{ textPreview(entry.data) }}</template>
<template v-else-if="entry.error"><Icon name="alert" :size="12" /> {{ entry.error }}</template>
<template v-else-if="entry.kind === 'unsupported'">⏭ 跳过</template>
<template v-else>⏳ 读取中</template>
</span>
</div>
</div>
<!-- 文档幻灯片预览 -->
<div v-if="docEntries.filter(e => e.slides).length > 0" class="preview-slides">
<span class="preview-title">AI 解析结果</span>
<div class="preview-scroll">
<div v-for="(entry, i) in docEntries.filter(e => e.slides)" :key="'p' + i" class="preview-item">
<strong>{{ entry.name }}</strong>
→ {{ entry.slides?.length }} 页幻灯片
<span class="preview-titles">
{{ entry.slides?.map(s => {
const t = s.elements.find(e => e.type === 'title')
return t ? t.content.slice(0, 20) : '无标题'
}).join(' · ') }}
</span>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="import-actions">
<button class="btn primary" @click="doImport" :disabled="filteredEntries.length === 0 || analyzing">
<template v-if="hasDocs && !analyzeDone"><Icon name="sparkles" :size="14" /> AI 分析文档并导入</template>
<template v-else><Icon name="check" :size="14" /> 导入以上内容</template>
</button>
<button class="btn" @click="emit('close')">取消</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.import-modal { max-width: 660px; max-height: 85vh; display: flex; flex-direction: column; }
.import-select { padding: 8px 0; }
.import-tabs { display: flex; gap: 4px; margin-bottom: 16px; }
.import-tabs .tab {
flex: 1; padding: 8px 16px; border: 1px solid var(--border, #e2e8f0);
background: var(--bg, #fff); border-radius: 8px; cursor: pointer;
font-size: 14px; transition: all .15s;
}
.import-tabs .tab.active { background: var(--primary, #5b5bd6); color: #fff; border-color: var(--primary, #5b5bd6); }
.dropzone {
border: 2px dashed var(--border, #cbd5e1); border-radius: 12px; padding: 36px 24px;
display: flex; align-items: center; gap: 20px; cursor: pointer; transition: all .2s;
background: var(--panel, #f8fafc);
}
.dropzone:hover { border-color: var(--primary, #5b5bd6); background: color-mix(in srgb, var(--primary, #5b5bd6) 5%, var(--panel, #f8fafc)); }
.dropzone.drag-over { border-color: var(--primary, #5b5bd6); background: color-mix(in srgb, var(--primary, #5b5bd6) 10%, var(--panel, #f8fafc)); }
.dropzone-icon { font-size: 44px; }
.dropzone-text { display: flex; flex-direction: column; gap: 4px; }
.dropzone-text strong { font-size: 16px; }
.hint { font-size: 13px; color: var(--muted, #64748b); }
.import-preview { display: flex; flex-direction: column; gap: 8px; overflow: hidden; }
.preview-actions { display: flex; justify-content: space-between; align-items: center; }
.file-count { font-size: 14px; color: var(--muted, #64748b); }
.btn-sm { padding: 4px 12px; font-size: 13px; border: 1px solid var(--border, #e2e8f0); border-radius: 6px; background: var(--bg, #fff); cursor: pointer; }
.filter-bar { display: flex; gap: 16px; padding: 4px 0; }
.filter-bar .chk { display: flex; align-items: center; gap: 6px; font-size: 14px; cursor: pointer; }
.ai-progress { padding: 6px 12px; background: rgba(79, 70, 229, 0.06); border-radius: 8px; font-size: 13px; color: var(--primary, #5b5bd6); }
.ai-error { padding: 6px 12px; background: rgba(239, 68, 68, 0.06); border-radius: 8px; font-size: 13px; color: #dc2626; display: flex; align-items: center; gap: 8px; }
.file-list { max-height: 200px; overflow-y: auto; border: 1px solid var(--border, #e2e8f0); border-radius: 8px; display: flex; flex-direction: column; }
.file-item { display: flex; align-items: center; gap: 8px; padding: 5px 10px; font-size: 13px; border-bottom: 1px solid var(--border, #f1f5f9); }
.file-item:last-child { border-bottom: none; }
.file-item.document { background: rgba(5, 150, 105, 0.04); }
.file-item.unsupported { opacity: 0.5; }
.file-icon { font-size: 16px; width: 24px; text-align: center; }
.file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-size { color: var(--muted, #64748b); min-width: 56px; text-align: right; }
.file-status { min-width: 80px; text-align: right; font-size: 12px; }
.preview-slides { font-size: 13px; }
.preview-title { font-weight: 600; display: block; margin-bottom: 4px; }
.preview-scroll { max-height: 100px; overflow-y: auto; }
.preview-item { padding: 3px 0; }
.preview-titles { display: block; font-size: 12px; color: var(--muted, #64748b); margin-top: 1px; }
.import-actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 8px; border-top: 1px solid var(--border, #e2e8f0); }
.loading { text-align: center; padding: 24px; color: var(--muted, #64748b); font-size: 14px; }
.spin { display: inline-block; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
+72 -14
View File
@@ -2,20 +2,35 @@
LibraryModal.vue — 演示文库弹窗
===================================================================== -->
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import type { LibItem, Slide } from '../../core/types'
import { store, resolveBg } from '../../core/store'
import { appPrompt, appConfirm } from '../../core/dialog'
import ElementView from '../editor/ElementView.vue'
import Icon from '../common/Icon.vue'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
(e: 'switch-tab', tab: string): void
(e: 'open-deck'): void
}>()
const libVersion = ref(0)
const nameInput = ref('')
const opsOpenId = ref<string | null>(null)
function toggleOps(id: string) {
opsOpenId.value = opsOpenId.value === id ? null : id
}
function onDocClick(e: MouseEvent) {
const t = e.target as HTMLElement
if (!t.closest('.lib-ops')) opsOpenId.value = null
}
onMounted(() => document.addEventListener('mousedown', onDocClick))
onUnmounted(() => document.removeEventListener('mousedown', onDocClick))
/** 读文库列表(非响应式,靠 libVersion 触发重算) */
const library = computed<LibItem[]>(() => {
@@ -77,20 +92,24 @@ function onSave() {
function onNewBlank() {
store.newBlankDeck()
emit('switch-tab', 'props')
emit('open-deck')
toast('已新建空白演示,在右侧添加元素')
emit('close')
}
function onOpen(id: string) {
if (store.loadFromLibrary(id)) {
opsOpenId.value = null
toast('已打开')
emit('open-deck')
emit('close')
}
}
function onRename(id: string) {
async function onRename(id: string) {
const cur = store.getLibrary().find(x => x.id === id)
const name = prompt('重命名为', cur ? cur.name : '')
const name = await appPrompt('重命名', { defaultValue: cur ? cur.name : '' })
opsOpenId.value = null
if (name != null && name.trim()) {
store.renameInLibrary(id, name.trim())
bump()
@@ -98,13 +117,15 @@ function onRename(id: string) {
}
function onDuplicate(id: string) {
opsOpenId.value = null
store.duplicateInLibrary(id)
toast('已复制')
bump()
}
function onDelete(id: string) {
if (confirm('删除这份演示?此操作不可撤销。')) {
async function onDelete(id: string) {
opsOpenId.value = null
if (await appConfirm('删除这份演示?', '此操作不可撤销', { danger: true, okText: '删除' })) {
store.deleteFromLibrary(id)
toast('已删除')
bump()
@@ -115,13 +136,21 @@ function onDelete(id: string) {
<template>
<div class="modal-mask lib-modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal lib-modal">
<h3>演示文库</h3>
<div class="lib-head">
<h3>演示文库</h3>
<button class="btn ghost" @click="onNewBlank">+ 新建空白</button>
</div>
<p class="modal-tip">保存多套演示文稿到本地,随时切换。</p>
<div class="lib-save-row">
<input type="text" v-model="nameInput" :placeholder="placeholder" @keydown="onNameKeydown" />
<button class="btn primary" @click="onSave">存入文库</button>
<button class="btn" @click="onNewBlank">新建空白</button>
<div class="lib-save-card">
<div class="lib-save-title">
<span><Icon name="save" :size="14" /> 保存当前演示</span>
<span v-if="activeId" class="lib-autosave-flag">改动已自动同步到文库</span>
</div>
<div class="lib-save-row">
<input type="text" v-model="nameInput" :placeholder="placeholder" @keydown="onNameKeydown" />
<button class="btn primary" @click="onSave">存入文库</button>
</div>
</div>
<div class="lib-hint">共 {{ libCount }} 份</div>
@@ -138,10 +167,13 @@ function onDelete(id: string) {
<div class="lib-meta">{{ (it.deck && it.deck.slides ? it.deck.slides.length : 0) + ' 页 · ' + formatTime(it.updatedAt || it.createdAt) }}</div>
</div>
<div class="lib-ops">
<button class="btn" @click="onOpen(it.id)">打开</button>
<button class="btn" @click="onRename(it.id)">重命名</button>
<button class="btn" @click="onDuplicate(it.id)">复制</button>
<button class="btn danger" @click="onDelete(it.id)">删除</button>
<button class="btn primary small" @click="onOpen(it.id)">打开</button>
<button class="btn" @click="toggleOps(it.id)">⋯</button>
<div v-if="opsOpenId === it.id" class="ops-menu">
<button @click="onRename(it.id)">重命名</button>
<button @click="onDuplicate(it.id)">复制</button>
<button class="danger" @click="onDelete(it.id)">删除</button>
</div>
</div>
</div>
</div>
@@ -152,3 +184,29 @@ function onDelete(id: string) {
</div>
</div>
</template>
<style scoped>
.lib-head { display: flex; align-items: center; justify-content: space-between; }
.lib-head h3 { margin: 0; }
.lib-save-card {
border: 1px solid color-mix(in srgb, var(--ui-primary, #5b5bd6) 22%, transparent);
background: color-mix(in srgb, var(--ui-primary, #5b5bd6) 5%, #fff);
border-radius: 10px;
padding: 12px 14px;
margin-bottom: 12px;
}
.lib-save-title { font-size: 13px; font-weight: 600; color: var(--ui-text, #1e293b); margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; }
.lib-autosave-flag { font-size: 11px; font-weight: 400; color: var(--ui-muted, #64748b); }
.lib-save-row { display: flex; gap: 8px; }
.lib-save-row input { flex: 1; }
.lib-ops { position: relative; }
.ops-menu {
position: absolute; right: 0; top: calc(100% + 4px);
background: #fff; border: 1px solid var(--ui-border, #e2e8f0);
border-radius: 8px; box-shadow: 0 4px 16px rgba(15,23,42,.12);
min-width: 96px; z-index: 10; overflow: hidden; padding: 4px 0;
}
.ops-menu button { display: block; width: 100%; text-align: left; padding: 6px 12px; border: none; background: none; font-size: 12px; cursor: pointer; }
.ops-menu button:hover { background: var(--ui-hover, #f1f5f9); }
.ops-menu button.danger { color: var(--ui-danger, #dc2626); }
</style>
+96
View File
@@ -0,0 +1,96 @@
<!-- =====================================================================
PrintModal.vue — PDF 导出:全量幻灯片打印预览
复用 ElementView 渲染每个元素(与编辑器/演示一致,图表/表格/公式不降级)
页面几何:A4 横向满幅(297mm × 209.8mm ≈ 16:9),元素 % 定位原样映射
===================================================================== -->
<script setup lang="ts">
import { computed } from 'vue'
import { store, resolveBg } from '../../core/store'
import ElementView from '../editor/ElementView.vue'
import Icon from '../common/Icon.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
// 响应式引用:预览随 deck 变化(快照会导致预览与数据脱节)
const slides = computed(() => store.getDeck().slides)
function onPrint() { window.print() }
</script>
<template>
<div class="print-modal">
<!-- 操作栏(打印时不显示) -->
<div class="print-bar no-print">
<button class="btn primary" @click="onPrint"><Icon name="printer" :size="14" /> 打印 / 另存为 PDF</button>
<span class="muted">{{ slides.length }} 页 · A4 横向</span>
<span class="sep"></span>
<button class="btn ghost" @click="emit('close')"><Icon name="x" :size="14" /> 关闭</button>
</div>
<!-- 所有幻灯片逐页渲染(复用编辑器元素渲染,几何与编辑画布同构) -->
<div
v-for="(slide, i) in slides"
:key="slide.id"
class="print-page"
:style="{ background: resolveBg(slide.background) }"
>
<div class="print-number">{{ i + 1 }}</div>
<ElementView
v-for="el in slide.elements"
:key="el.id"
:el="el"
:bg="slide.background"
/>
</div>
</div>
</template>
<style scoped>
/* 屏幕上的容器 */
.print-modal {
position: fixed; inset: 0; z-index: 900;
background: #e2e8f0;
overflow-y: auto;
}
.print-bar {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; gap: 12px;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
}
/* 打印页:A4 横向满幅 16:9(297 × 209.8mm),元素 % 坐标系与 1280×720 画布同构 */
.print-page {
width: 297mm; height: 209.8mm;
margin: 12mm auto;
background: #fff;
box-shadow: 0 2px 16px rgba(0,0,0,0.1);
position: relative;
overflow: hidden;
transform-origin: top left;
}
.print-number {
position: absolute; bottom: 3mm; right: 5mm;
font-size: 11px; color: #94a3b8;
}
/* 打印样式 */
@media print {
.no-print { display: none !important; }
.print-modal { position: static; background: none; overflow: visible; }
.print-page {
margin: 0;
box-shadow: none;
width: 297mm; height: 209.8mm;
break-after: page;
/* 确保背景色打印 */
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* 最后一页不再分页(避免尾部空白页) */
.print-page:last-child { break-after: auto; }
/* 入场动画在打印上下文中不可播放,冻结到最终态(防 opacity:0 截帧) */
.print-page * { animation: none !important; transition: none !important; }
.print-number { bottom: 3mm; right: 5mm; }
}
</style>
-192
View File
@@ -1,192 +0,0 @@
<!-- =====================================================================
SettingsModal.vue — AI 设置弹窗
===================================================================== -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { AiCfg } from '../../core/types'
import { store } from '../../core/store'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
}>()
const PRESETS: Record<string, { protocol: 'openai' | 'anthropic'; base: string; model: string; label: string }> = {
zhipu: { protocol: 'openai', base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6', label: '智谱 GLM (OpenAI 协议)' },
zhipu_anth: { protocol: 'anthropic', base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6', label: '智谱 GLM (Anthropic 协议)' },
deepseek: { protocol: 'openai', base: 'https://api.deepseek.com', model: 'deepseek-chat', label: 'DeepSeek' },
qwen: { protocol: 'openai', base: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', label: '通义千问' },
kimi: { protocol: 'openai', base: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', label: 'Kimi' },
doubao: { protocol: 'openai', base: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-pro-32k', label: '豆包' },
openai: { protocol: 'openai', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini', label: 'OpenAI' },
anthropic: { protocol: 'anthropic', base: 'https://api.anthropic.com', model: 'claude-sonnet-5', label: 'Anthropic' },
gemini: { protocol: 'openai', base: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.0-flash', label: 'Gemini' },
groq: { protocol: 'openai', base: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', label: 'Groq' },
ollama: { protocol: 'openai', base: 'http://localhost:11434/v1', model: 'llama3.1', label: 'Ollama (本地)' }
}
const PROTO_DEFAULTS: Record<string, { base: string; model: string }> = {
openai: { base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6' },
anthropic: { base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6' }
}
const form = ref<AiCfg>({
preset: 'zhipu', protocol: 'openai',
base: '', key: '', model: '', proxy: '',
imgBase: '', imgKey: '', imgModel: ''
})
/** 从 store 读取并填充表单 */
function loadFromStore() {
const c = store.getCfg()
form.value = {
preset: PRESETS[c.preset] ? c.preset : 'custom',
protocol: c.protocol || 'openai',
base: c.base, key: c.key, model: c.model, proxy: c.proxy,
imgBase: c.imgBase || '',
imgKey: c.imgKey || '',
imgModel: c.imgModel || ''
}
}
watch(() => props.visible, (v) => {
if (v) loadFromStore()
}, { immediate: true })
function applyPreset(key: string) {
const p = PRESETS[key]
if (!p) return
form.value.protocol = p.protocol
form.value.base = p.base
form.value.model = p.model
}
function onProviderChange() {
if (form.value.preset !== 'custom') applyPreset(form.value.preset)
}
function applyProtoDefaults(p: string) {
const d = PROTO_DEFAULTS[p] || PROTO_DEFAULTS.openai
const other = PROTO_DEFAULTS[p === 'openai' ? 'anthropic' : 'openai']
if (!form.value.base.trim() || form.value.base.trim() === other.base) form.value.base = d.base
if (!form.value.model.trim() || form.value.model.trim() === other.model) form.value.model = d.model
}
function onProtocolChange() {
applyProtoDefaults(form.value.protocol)
}
function save() {
store.setCfg({
preset: form.value.preset,
protocol: form.value.protocol,
base: form.value.base.trim(),
key: form.value.key.trim(),
model: form.value.model.trim(),
proxy: form.value.proxy.trim(),
imgBase: (form.value.imgBase || '').trim(),
imgKey: (form.value.imgKey || '').trim(),
imgModel: (form.value.imgModel || '').trim()
})
const label = form.value.preset === 'custom'
? (form.value.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') + ' 自定义'
: (PRESETS[form.value.preset]?.label || form.value.preset)
emit('toast', '已保存 AI 设置(' + label + ')')
emit('close')
}
</script>
<template>
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal">
<h3>AI 设置</h3>
<p class="modal-tip">配置大模型服务商。数据仅保存在本地浏览器。</p>
<div class="form-row">
<label>服务商</label>
<select v-model="form.preset" @change="onProviderChange">
<optgroup label="国内">
<option value="zhipu">智谱 GLM (OpenAI 协议)</option>
<option value="zhipu_anth">智谱 GLM (Anthropic 协议)</option>
<option value="deepseek">DeepSeek</option>
<option value="qwen">通义千问</option>
<option value="kimi">Kimi</option>
<option value="doubao">豆包</option>
</optgroup>
<optgroup label="海外">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="gemini">Gemini</option>
<option value="groq">Groq</option>
</optgroup>
<optgroup label="本地">
<option value="ollama">Ollama</option>
</optgroup>
<option value="custom">自定义</option>
</select>
</div>
<div class="form-row">
<label>API 协议</label>
<select v-model="form.protocol" @change="onProtocolChange">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div class="form-row">
<label>Base URL</label>
<input type="text" v-model="form.base" placeholder="https://..." />
</div>
<div class="form-row">
<label>API Key</label>
<input type="password" v-model="form.key" placeholder="sk-..." autocomplete="off" />
</div>
<div class="form-row">
<label>模型</label>
<input type="text" v-model="form.model" placeholder="模型名称" />
</div>
<div class="form-row">
<label>代理 URL(可选)</label>
<input type="text" v-model="form.proxy" placeholder="留空则直连" />
</div>
<div class="section-title">图像模型(可选,用于 AI 配图)</div>
<div class="form-row">
<label>图像 Base URL</label>
<input type="text" v-model="form.imgBase" placeholder="留空则复用上方 Base URL" />
</div>
<div class="form-row">
<label>图像 API Key</label>
<input type="password" v-model="form.imgKey" placeholder="留空则复用上方 Key" autocomplete="off" />
</div>
<div class="form-row">
<label>图像模型</label>
<input type="text" v-model="form.imgModel" placeholder="dall-e-3" />
</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);
}
</style>
+90 -30
View File
@@ -4,8 +4,10 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { store, resolveBg } from '../../core/store'
import { appConfirm } from '../../core/dialog'
import type { PageTemplate } from '../../core/types'
import ElementView from '../editor/ElementView.vue'
import Icon from '../common/Icon.vue'
defineProps<{ visible: boolean }>()
const emit = defineEmits<{
@@ -21,6 +23,23 @@ const tick = ref(0)
const templates = computed(() => { tick.value; return store.getTemplates() })
function bump() { tick.value++ }
/* ---------- 按分组展示:内置模板分节,自存模板单独一节 ---------- */
const GROUP_LABELS: Record<string, string> = {
basic: '基础版式', content: '内容版式', data: '数据版式', ending: '收尾版式', user: '我的模板'
}
const groupedTemplates = computed(() => {
const all = templates.value
const userTpls = all.filter(t => t.category === 'user')
const builtin = all.filter(t => t.category === 'built-in')
const groups: Array<{ key: string; label: string; items: typeof all }> = []
for (const key of ['basic', 'content', 'data', 'ending']) {
const items = builtin.filter(t => (t.group || 'content') === key)
if (items.length) groups.push({ key, label: GROUP_LABELS[key], items })
}
if (userTpls.length) groups.push({ key: 'user', label: GROUP_LABELS.user, items: userTpls })
return groups
})
function onSave() {
const v = nameInput.value.trim()
if (!v) { emit('toast', '请输入模板名'); return }
@@ -48,8 +67,8 @@ function onPick(tpl: PageTemplate) {
}
/* ---------- 删除自存模板 ---------- */
function onDelete(tpl: PageTemplate) {
if (confirm('删除模板「' + tpl.name + '」?')) {
async function onDelete(tpl: PageTemplate) {
if (await appConfirm('删除模板', '「' + tpl.name + '」删除后不可恢复', { danger: true, okText: '删除' })) {
store.deleteTemplate(tpl.id)
bump()
emit('toast', '已删除')
@@ -60,28 +79,33 @@ function onDelete(tpl: PageTemplate) {
<template>
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal tpl-modal">
<h3>📋 页面模板</h3>
<h3><Icon name="clipboard" :size="15" /> 页面模板</h3>
<!-- 存当前页 -->
<div class="tpl-save-bar">
<input v-model="nameInput" placeholder="存当前页为模板,输入名称…" @keydown.enter="onSave" />
<button class="btn primary" @click="onSave">💾 保存为模板</button>
<button class="btn primary" @click="onSave"><Icon name="save" :size="14" /> 保存为模板</button>
</div>
<!-- 模板网格 -->
<div class="tpl-grid">
<div v-for="tpl in templates" :key="tpl.id" class="tpl-card" :class="tpl.category" @click="onPick(tpl)">
<div class="tpl-thumb" :style="{ background: resolveBg(tpl.background) }">
<div class="tpl-thumb-layer">
<ElementView v-for="el in tpl.elements" :key="el.id" :el="el" :bg="tpl.background" />
<!-- 模板网格(按分组分节) -->
<div class="tpl-groups">
<section v-for="g in groupedTemplates" :key="g.key" class="tpl-group">
<h4 class="tpl-group-title">{{ g.label }} <span class="tpl-group-count">{{ g.items.length }}</span></h4>
<div class="tpl-grid">
<div v-for="tpl in g.items" :key="tpl.id" class="tpl-card" :class="tpl.category" @click="onPick(tpl)">
<div class="tpl-thumb" :style="{ background: resolveBg(tpl.background) }">
<div class="tpl-thumb-layer">
<ElementView v-for="el in tpl.elements" :key="el.id" :el="el" :bg="tpl.background" />
</div>
</div>
<div class="tpl-info">
<span class="tpl-name">{{ tpl.name }}</span>
<span class="tpl-badge" :class="tpl.category">{{ tpl.category === 'built-in' ? '内置' : '自存' }}</span>
<button v-if="tpl.category === 'user'" class="tpl-del" title="删除" @click.stop="onDelete(tpl)"><Icon name="trash" :size="15" /></button>
</div>
</div>
</div>
<div class="tpl-info">
<span class="tpl-name">{{ tpl.name }}</span>
<span class="tpl-badge" :class="tpl.category">{{ tpl.category === 'built-in' ? '内置' : '自存' }}</span>
<button v-if="tpl.category === 'user'" class="tpl-del" title="删除" @click.stop="onDelete(tpl)">🗑</button>
</div>
</div>
</section>
</div>
<div class="modal-actions">
@@ -110,25 +134,54 @@ function onDelete(tpl: PageTemplate) {
flex: 1;
}
.tpl-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1em;
.tpl-groups {
overflow-y: auto;
padding: .2em;
}
.tpl-group {
margin-bottom: 1.2em;
}
.tpl-group-title {
font-size: 13px;
font-weight: 600;
color: var(--ui-muted, #64748b);
margin: .6em 0 .6em;
display: flex;
align-items: center;
gap: .5em;
}
.tpl-group-count {
font-size: 11px;
font-weight: 400;
background: rgba(100, 116, 139, .12);
padding: 0 6px;
border-radius: 8px;
}
.tpl-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
/* 卡片宽 ≈ (720 - 48 padding - 2×12 gap) / 3 ≈ 212px → scale = 212/1280 ≈ 0.166 */
.tpl-card {
--tpl-scale: 0.166;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
background: #fff;
border: 2px solid rgba(100, 116, 139, .2);
transition: border-color .15s, transform .15s;
border: 1px solid var(--ui-border, #e2e8f0);
box-shadow: 0 1px 3px rgba(15, 23, 42, .06);
transition: border-color .15s, transform .15s, box-shadow .15s;
}
.tpl-card:hover {
border-color: var(--primary, #4f46e5);
border-color: var(--ui-primary, #5b5bd6);
box-shadow: 0 4px 12px rgba(15, 23, 42, .12);
transform: translateY(-2px);
}
@@ -145,7 +198,7 @@ function onDelete(tpl: PageTemplate) {
left: 0;
width: 1280px;
height: 720px;
transform: scale(0.094); /* 约 120px 宽 */
transform: scale(var(--tpl-scale));
transform-origin: top left;
pointer-events: none;
}
@@ -154,25 +207,29 @@ function onDelete(tpl: PageTemplate) {
display: flex;
align-items: center;
gap: .4em;
padding: .5em .6em;
padding: .45em .6em;
font-size: 13px;
}
.tpl-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tpl-badge {
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
border-radius: 4px;
background: rgba(100, 116, 139, .15);
color: var(--muted, #64748b);
color: var(--ui-muted, #64748b);
flex: none;
}
.tpl-badge.user {
background: rgba(79, 70, 229, .12);
color: var(--primary, #4f46e5);
background: var(--ui-primary-soft, #eeeefc);
color: var(--ui-primary, #5b5bd6);
}
.tpl-del {
@@ -180,10 +237,13 @@ function onDelete(tpl: PageTemplate) {
opacity: .5;
cursor: pointer;
padding: 2px 4px;
border: none;
background: transparent;
flex: none;
}
.tpl-del:hover {
opacity: 1;
color: #e11d48;
color: var(--ui-danger, #e11d48);
}
</style>
@@ -0,0 +1,466 @@
<!-- =====================================================================
UnifiedSettingsModal.vue — 统一设置弹窗
合并原 SettingsModal(AI/中继)与 OssSettingsModal(云存储),tab 分区切换
===================================================================== -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { AiCfg, OssCfg } from '../../core/types'
import { store } from '../../core/store'
import { syncState, syncPending, canUploadNow } from '../../core/assets'
export type SettingsTab = 'ai' | 'oss' | 'relay'
const props = defineProps<{ visible: boolean; initialTab?: SettingsTab }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
}>()
const tab = ref<SettingsTab>('ai')
watch(() => props.visible, (v) => {
if (v) { tab.value = props.initialTab || 'ai'; loadFromStore() }
}, { immediate: true })
/* ==================== AI 模型 ==================== */
const PRESETS: Record<string, { protocol: 'openai' | 'anthropic'; base: string; model: string; label: string }> = {
zhipu: { protocol: 'openai', base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6', label: '智谱 GLM (OpenAI 协议)' },
zhipu_anth: { protocol: 'anthropic', base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6', label: '智谱 GLM (Anthropic 协议)' },
deepseek: { protocol: 'openai', base: 'https://api.deepseek.com', model: 'deepseek-chat', label: 'DeepSeek' },
qwen: { protocol: 'openai', base: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', label: '通义千问' },
kimi: { protocol: 'openai', base: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', label: 'Kimi' },
doubao: { protocol: 'openai', base: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-pro-32k', label: '豆包' },
openai: { protocol: 'openai', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini', label: 'OpenAI' },
anthropic: { protocol: 'anthropic', base: 'https://api.anthropic.com', model: 'claude-sonnet-5', label: 'Anthropic' },
gemini: { protocol: 'openai', base: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.0-flash', label: 'Gemini' },
groq: { protocol: 'openai', base: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', label: 'Groq' },
ollama: { protocol: 'openai', base: 'http://localhost:11434/v1', model: 'llama3.1', label: 'Ollama (本地)' }
}
const PROTO_DEFAULTS: Record<string, { base: string; model: string }> = {
openai: { base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6' },
anthropic: { base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6' }
}
const aiForm = ref<AiCfg>({
preset: 'zhipu', protocol: 'openai',
base: '', key: '', model: '', proxy: '',
imgBase: '', imgKey: '', imgModel: '',
relayUrl: '', relayToken: '', relayDeviceId: ''
})
/* ==================== 云存储(OSS) ==================== */
const ossForm = ref<OssCfg>({
enabled: false, provider: 'aliyun', dir: 'u-ppt',
aliAccessKeyId: '', aliAccessKeySecret: '', aliEndpoint: '', aliBucket: '',
qiniuAccessKey: '', qiniuSecretKey: '', qiniuBucket: '', qiniuUpHost: '', qiniuDomain: '',
viewerBase: ''
})
/** 从 store 读取并填充两份表单 */
function loadFromStore() {
const c = store.getCfg()
aiForm.value = {
preset: PRESETS[c.preset] ? c.preset : 'custom',
protocol: c.protocol || 'openai',
base: c.base, key: c.key, model: c.model, proxy: c.proxy,
imgBase: c.imgBase || '',
imgKey: c.imgKey || '',
imgModel: c.imgModel || '',
relayUrl: c.relayUrl || '',
relayToken: c.relayToken || '',
relayDeviceId: c.relayDeviceId || ''
}
ossForm.value = { ...ossForm.value, ...store.getOssCfg() }
}
function applyPreset(key: string) {
const p = PRESETS[key]
if (!p) return
aiForm.value.protocol = p.protocol
aiForm.value.base = p.base
aiForm.value.model = p.model
}
function onProviderChange() {
if (aiForm.value.preset !== 'custom') applyPreset(aiForm.value.preset)
}
function applyProtoDefaults(p: string) {
const d = PROTO_DEFAULTS[p] || PROTO_DEFAULTS.openai
const other = PROTO_DEFAULTS[p === 'openai' ? 'anthropic' : 'openai']
if (!aiForm.value.base.trim() || aiForm.value.base.trim() === other.base) aiForm.value.base = d.base
if (!aiForm.value.model.trim() || aiForm.value.model.trim() === other.model) aiForm.value.model = d.model
}
function onProtocolChange() {
applyProtoDefaults(aiForm.value.protocol)
}
/* ==================== 保存 ==================== */
function save() {
if (tab.value === 'ai') {
const f = aiForm.value
store.setCfg({
preset: f.preset,
protocol: f.protocol,
base: f.base.trim(),
key: f.key.trim(),
model: f.model.trim(),
proxy: f.proxy.trim(),
imgBase: (f.imgBase || '').trim(),
imgKey: (f.imgKey || '').trim(),
imgModel: (f.imgModel || '').trim(),
relayUrl: (f.relayUrl || '').trim(),
relayToken: (f.relayToken || '').trim(),
relayDeviceId: (f.relayDeviceId || '').trim()
})
const label = f.preset === 'custom'
? (f.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') + ' 自定义'
: (PRESETS[f.preset]?.label || f.preset)
emit('toast', '已保存 AI 设置(' + label + ')')
} else if (tab.value === 'oss') {
saveOss()
} else {
// 中继 tab:只更新中继三项,AI 其余字段原样回写
const f = aiForm.value
const c = store.getCfg()
store.setCfg({
...c,
preset: f.preset,
protocol: f.protocol,
base: f.base.trim(),
key: f.key.trim(),
model: f.model.trim(),
proxy: f.proxy.trim(),
imgBase: (f.imgBase || '').trim(),
imgKey: (f.imgKey || '').trim(),
imgModel: (f.imgModel || '').trim(),
relayUrl: (f.relayUrl || '').trim(),
relayToken: (f.relayToken || '').trim(),
relayDeviceId: (f.relayDeviceId || '').trim()
})
emit('toast', '已保存 Agent 中继设置')
}
emit('close')
}
function saveOss() {
const c = ossForm.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 ? '已保存云存储设置(已启用)' : '已保存云存储设置(未启用)')
}
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 unified-modal">
<h3>设置</h3>
<!-- tab 栏 -->
<div class="tab-bar">
<button class="tab-btn" :class="{ active: tab === 'ai' }" @click="tab = 'ai'">AI 模型</button>
<button class="tab-btn" :class="{ active: tab === 'oss' }" @click="tab = 'oss'">云存储</button>
<button class="tab-btn" :class="{ active: tab === 'relay' }" @click="tab = 'relay'">Agent 中继</button>
</div>
<!-- 内容区:统一 min-height,避免切 tab 时弹窗高度跳动 -->
<div class="settings-body">
<!-- ==================== AI 模型 ==================== -->
<template v-if="tab === 'ai'">
<p class="modal-tip">配置大模型服务商。数据仅保存在本地浏览器。</p>
<div class="form-row">
<label>服务商</label>
<select v-model="aiForm.preset" @change="onProviderChange">
<optgroup label="国内">
<option value="zhipu">智谱 GLM (OpenAI 协议)</option>
<option value="zhipu_anth">智谱 GLM (Anthropic 协议)</option>
<option value="deepseek">DeepSeek</option>
<option value="qwen">通义千问</option>
<option value="kimi">Kimi</option>
<option value="doubao">豆包</option>
</optgroup>
<optgroup label="海外">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="gemini">Gemini</option>
<option value="groq">Groq</option>
</optgroup>
<optgroup label="本地">
<option value="ollama">Ollama</option>
</optgroup>
<option value="custom">自定义</option>
</select>
</div>
<div class="form-row">
<label>API 协议</label>
<select v-model="aiForm.protocol" @change="onProtocolChange">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div class="form-row">
<label>Base URL</label>
<input type="text" v-model="aiForm.base" placeholder="https://..." />
</div>
<div class="form-row">
<label>API Key</label>
<input type="password" v-model="aiForm.key" placeholder="sk-..." autocomplete="off" />
</div>
<div class="form-row">
<label>模型</label>
<input type="text" v-model="aiForm.model" placeholder="模型名称" />
</div>
<div class="form-row">
<label>代理 URL(可选)</label>
<input type="text" v-model="aiForm.proxy" placeholder="留空则直连" />
</div>
<div class="section-title">图像模型(可选,用于 AI 配图)</div>
<div class="form-row">
<label>图像 Base URL</label>
<input type="text" v-model="aiForm.imgBase" placeholder="留空则复用上方 Base URL" />
</div>
<div class="form-row">
<label>图像 API Key</label>
<input type="password" v-model="aiForm.imgKey" placeholder="留空则复用上方 Key" autocomplete="off" />
</div>
<div class="form-row">
<label>图像模型</label>
<input type="text" v-model="aiForm.imgModel" placeholder="dall-e-3" />
</div>
</template>
<!-- ==================== 云存储 ==================== -->
<template v-else-if="tab === 'oss'">
<p class="modal-tip">
开启后,图片/视频等媒体自动上传对象存储,避免撑爆本地。离线时先暂存本地,联网后自动同步云端。密钥仅保存在本地。
</p>
<div class="form-row">
<label>启用云存储</label>
<label class="switch-line">
<input type="checkbox" v-model="ossForm.enabled" />
<span>{{ ossForm.enabled ? '开' : '关(维持本地内嵌)' }}</span>
</label>
</div>
<div class="form-row">
<label>服务商</label>
<select v-model="ossForm.provider">
<option value="aliyun">阿里云 OSS</option>
<option value="qiniu">七牛云 Kodo</option>
</select>
</div>
<div class="form-row">
<label>所属目录</label>
<input type="text" v-model="ossForm.dir" placeholder="如 u-ppt/images,可留空" />
</div>
<div class="form-row">
<label>查看器地址</label>
<input type="text" v-model="ossForm.viewerBase" placeholder="如 https://img.1216.top,生成分享链接必填" />
</div>
<!-- 阿里云 -->
<template v-if="ossForm.provider === 'aliyun'">
<div class="section-title">阿里云 OSS</div>
<div class="form-row">
<label>AccessKeyId</label>
<input type="text" v-model="ossForm.aliAccessKeyId" placeholder="LTAI..." autocomplete="off" />
</div>
<div class="form-row">
<label>AccessKeySecret</label>
<input type="password" v-model="ossForm.aliAccessKeySecret" placeholder="密钥" autocomplete="off" />
</div>
<div class="form-row">
<label>Endpoint</label>
<input type="text" v-model="ossForm.aliEndpoint" placeholder="oss-cn-hangzhou.aliyuncs.com" />
</div>
<div class="form-row">
<label>Bucket</label>
<input type="text" v-model="ossForm.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="ossForm.qiniuAccessKey" placeholder="AK" autocomplete="off" />
</div>
<div class="form-row">
<label>SecretKey</label>
<input type="password" v-model="ossForm.qiniuSecretKey" placeholder="SK" autocomplete="off" />
</div>
<div class="form-row">
<label>Bucket</label>
<input type="text" v-model="ossForm.qiniuBucket" placeholder="空间名称" />
</div>
<div class="form-row">
<label>加速域名</label>
<input type="text" v-model="ossForm.qiniuDomain" placeholder="https://cdn.example.com" />
</div>
<div class="form-row">
<label>上传域名(可选)</label>
<input type="text" v-model="ossForm.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>
</template>
<!-- ==================== Agent 中继 ==================== -->
<template v-else>
<p class="modal-tip">通过 u-relay 中继接收远端 Agent 指令。可选功能,三项配置齐备即自动启用。</p>
<div class="form-row">
<label>中继 URL</label>
<input type="text" v-model="aiForm.relayUrl" placeholder="wss://u-work.1216.top(可省略 /ws/miniapp)" />
</div>
<div class="form-row">
<label>中继 Token</label>
<input type="password" v-model="aiForm.relayToken" placeholder="设备配对 Token" autocomplete="off" />
</div>
<div class="form-row">
<label>设备 ID</label>
<input type="text" v-model="aiForm.relayDeviceId" placeholder="device_id" />
</div>
</template>
</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>
/* 弹窗加宽(仅本组件) */
.unified-modal { width: 520px; display: flex; flex-direction: column; }
/* 内容区统一最小高度:以最高 tab(云存储)为基准,切 tab 不再跳动 */
.settings-body { min-height: 440px; }
/* tab 栏:下划线高亮 */
.tab-bar {
display: flex;
gap: 18px;
margin-bottom: 16px;
border-bottom: 1px solid var(--ui-border);
}
.tab-btn {
border: none;
background: transparent;
padding: 8px 2px;
font-size: 13.5px;
color: var(--ui-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab-btn.active { color: var(--ui-primary, #5b5bd6); border-bottom-color: var(--ui-primary, #5b5bd6); font-weight: 600; }
/* 紧凑化:label 与输入同行 */
.form-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.form-row > label {
display: block;
width: 110px;
flex-shrink: 0;
text-align: right;
white-space: nowrap;
margin-bottom: 0;
}
.form-row > select,
.form-row > input { flex: 1; min-width: 0; }
.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);
}
.section-title.first { margin-top: 0; padding-top: 0; border-top: none; }
.switch-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ui-muted);
flex: 1;
}
.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>
+117 -4
View File
@@ -1,13 +1,14 @@
/* =====================================================================
* useEditor.ts — 画布拖拽/缩放/选中逻辑(从 editor.js 提取)
* useEditor.ts — 画布拖拽/缩放/选中/框选逻辑
* 用 Vue ref 暴露拖拽会话状态,供 Canvas.vue 响应式绑定
* ===================================================================== */
import { ref, type Ref } from 'vue'
import { store } from '../core/store'
interface DragSession {
mode: 'move' | 'resize'
mode: 'move' | 'resize' | 'multi-move'
id: string
ids?: string[] // multi-move 的元素集
ax?: number // resize 水平轴:-1/0/1
ay?: number // resize 垂直轴:-1/0/1
startX: number
@@ -19,21 +20,44 @@ interface DragSession {
rectW: number
rectH: number
result?: { x: number; y: number; w: number; h: number }
multiStart?: Array<{ id: string; x: number; y: number; w: number; h: number }> // multi-move 起点快照
multiResult?: Array<{ id: string; x: number; y: number }> // multi-move 各元素目标位
}
/** 框选会话(空白处按下拖动 → marquee) */
interface MarqueeSession {
x0: number; y0: number; x1: number; y1: number // 百分比坐标
}
export function useEditor() {
const drag: Ref<DragSession | null> = ref(null)
const marquee: Ref<MarqueeSession | null> = ref(null)
const editing = ref(false) // 是否在 contenteditable 编辑中(暂停拖拽)
/** 鼠标按下:空白取消选中 / 手柄缩放 / 元素拖拽 */
/** 鼠标按下:空白→框选起点 / 手柄缩放 / 元素拖拽(Shift 加选) */
function onCanvasMouseDown(e: MouseEvent, canvasEl: HTMLElement) {
if (editing.value) return
if (e.button !== 0) return // 仅左键
const target = (e.target as HTMLElement).closest('.el') as HTMLElement | null
const handle = (e.target as HTMLElement).classList && (e.target as HTMLElement).classList.contains('handle')
? (e.target as HTMLElement) : null
if (!target && !handle) {
const sel = store.getSelection()
if (!e.shiftKey && sel.length > 1) {
store.selectElement(null) // 多选态点空白:先只清多选
return
}
store.selectElement(null)
// 启动框选
const rect = canvasEl.getBoundingClientRect()
marquee.value = {
x0: (e.clientX - rect.left) / rect.width * 100,
y0: (e.clientY - rect.top) / rect.height * 100,
x1: (e.clientX - rect.left) / rect.width * 100,
y1: (e.clientY - rect.top) / rect.height * 100
}
e.preventDefault()
return
}
if (handle) {
@@ -43,7 +67,19 @@ export function useEditor() {
return
}
const id = target!.dataset.id!
startDrag(id, e, canvasEl)
// Shift+点击:加选/减选,不进入拖拽
if (e.shiftKey) {
store.toggleSelect(id)
e.preventDefault()
return
}
// 点击已选多选集中的元素 → 拖动整组;否则正常单选拖动
const sel = store.getSelection()
if (sel.length > 1 && sel.includes(id)) {
startMultiDrag(sel, e, canvasEl)
} else {
startDrag(id, e, canvasEl)
}
e.preventDefault()
}
@@ -58,6 +94,23 @@ export function useEditor() {
}
}
/** 多选整组拖动:记录各元素起始位置(multiResult 始终 = 起点 + 累计位移,不从上次结果累加) */
function startMultiDrag(ids: string[], e: MouseEvent, canvasEl: HTMLElement) {
const starts = ids
.map(id => store.findElement(id))
.filter(Boolean)
.map(el => ({ id: el!.id, x: el!.x, y: el!.y, w: el!.w, h: el!.h }))
if (!starts.length) return
const rect = canvasEl.getBoundingClientRect()
drag.value = {
mode: 'multi-move', id: ids[ids.length - 1], ids,
startX: 0, startY: 0, startW: 0, startH: 0,
px0: e.clientX, py0: e.clientY, rectW: rect.width, rectH: rect.height,
multiStart: starts.map(s => ({ id: s.id, x: s.x, y: s.y, w: s.w, h: s.h })),
multiResult: starts.map(s => ({ id: s.id, x: s.x, y: s.y }))
}
}
const AXES: Record<string, [number, number]> = {
tl: [-1, -1], tm: [0, -1], tr: [1, -1],
lm: [-1, 0], rm: [1, 0],
@@ -76,10 +129,35 @@ export function useEditor() {
}
function onMouseMove(e: MouseEvent) {
// 框选拖动:更新 marquee 矩形(Canvas 渲染选框)
const m = marquee.value
if (m) {
const canvas = document.querySelector('.canvas') as HTMLElement | null
if (canvas) {
const rect = canvas.getBoundingClientRect()
m.x1 = Math.max(0, Math.min(100, (e.clientX - rect.left) / rect.width * 100))
m.y1 = Math.max(0, Math.min(100, (e.clientY - rect.top) / rect.height * 100))
marquee.value = { ...m }
}
return
}
const d = drag.value
if (!d) return
const dx = (e.clientX - d.px0) / d.rectW * 100
const dy = (e.clientY - d.py0) / d.rectH * 100
if (d.mode === 'multi-move' && d.multiStart) {
// 关键:基于「起点 + 累计位移」计算,而非在上次结果上累加(否则位移重复叠加)
d.multiResult = d.multiStart.map(s => ({
id: s.id,
x: Math.max(0, Math.min(100 - s.w, s.x + dx)),
y: Math.max(0, Math.min(100 - s.h, s.y + dy))
}))
drag.value = { ...d }
return
}
let nx = d.startX, ny = d.startY, nw = d.startW, nh = d.startH
if (d.mode === 'move') {
@@ -96,11 +174,45 @@ export function useEditor() {
}
function onMouseUp() {
// 结束框选:计算与 marquee 相交的元素 → 多选
const m = marquee.value
if (m) {
marquee.value = null
const left = Math.min(m.x0, m.x1), right = Math.max(m.x0, m.x1)
const top = Math.min(m.y0, m.y1), bottom = Math.max(m.y0, m.y1)
// 过小的框(<1%)视为点击空白,仅清选
if (right - left > 1 || bottom - top > 1) {
const s = store.currentSlide.value
const hits = (s?.elements || [])
.filter(el => {
const ex = el.x, ey = el.y, ew = el.x + el.w, eh = el.y + el.h
return ex < right && ew > left && ey < bottom && eh > top // 矩形相交
})
.map(el => el.id)
if (hits.length) store.selectMany(hits)
}
return
}
const d = drag.value
if (!d) return
const r = d.result
const mr = d.multiResult
const id = d.id
drag.value = null
if (d.mode === 'multi-move' && mr) {
// 多选位移落盘(相对起点有变化的才提交)
const moved = mr.filter(r2 => {
const el = store.findElement(r2.id)
return el && (el.x !== r2.x || el.y !== r2.y)
})
if (moved.length) {
const group = store.beginBatch()
for (const t of moved) store.updateElement(t.id, { x: t.x, y: t.y })
void group
}
return
}
if (r) store.updateElement(id, r)
}
@@ -108,6 +220,7 @@ export function useEditor() {
return {
drag,
marquee,
editing,
onCanvasMouseDown,
onMouseMove,
-73
View File
@@ -1,73 +0,0 @@
/* =====================================================================
* useStore.ts — 统一 Store 接口(兼容 Tauri 和 Web 双模式)
*
* 设计:
* - 检测 __TAURI__ 环境,决定走 invoke 还是直接调 TS store
* - 暴露与现有 store 相同的方法签名(88 处组件调用零改动)
* - 返回 reactive ref(与 Vue 响应式兼容)
*
* Web 模式:直接 return store(现有行为,零改动)
* Tauri 模式:返回封装对象,内部 invoke Rust 命令
* ===================================================================== */
import type { Deck, Slide, SlideElement, LibItem, PageTemplate, ChatMessage, AiCfg } from '../core/types'
import { store as tsStore } from '../core/store'
import { invoke } from '../core/bridge'
/** 是否运行在 Tauri 桌面环境中 */
export const isTauri = typeof window !== 'undefined' && '__TAURI__' in window
/** 根据环境创建 store 接口 */
function createStore() {
// Web 模式:直接返回现有 TS store
if (!isTauri) return tsStore
// Tauri 模式:封装 invoke 调用
// 注意:所有写操作通过 exec_op 统一走 Op Log
const CLIENT_ID = 'client-' + Math.random().toString(36).slice(2, 10)
/** 生成一个 Op 并发送到 Rust 后端 */
async function execOp(type: string, payload: Record<string, unknown>) {
try {
const result = await invoke<{ success: boolean; deck: Deck; error?: string }>('exec_op', {
opJson: { type, clientId: CLIENT_ID, timestamp: Date.now(), ...payload }
})
if (result?.deck) {
// 更新本地 ref 状态(由 TauriBridge watcher 处理)
}
} catch (e) {
console.error('exec_op 失败:', e)
}
}
// 返回与 TS store 类型兼容的对象
// 关键属性通过 computed ref 暴露
return {
...tsStore,
// 读操作暂时委托给 TS store(未来Rust端会emit状态同步)
// 写操作改为 invoke
setTheme: async (name: string) => {
await execOp('set_theme', { theme: name })
},
addSlide: async (atIndex?: number) => {
const slide: Slide = { id: 's-' + Date.now(), background: 'bg', elements: [] }
const idx = atIndex != null ? atIndex + 1 : tsStore.getCount()
await execOp('add_slide', { atIndex: idx, slide })
tsStore.addSlide(atIndex)
},
updateElement: async (id: string, patch: Record<string, unknown>) => {
await execOp('update_element', { slideIdx: tsStore.getCurrentIndex(), elementId: id, patch })
},
delElement: async (id: string) => {
const el = tsStore.findElement(id)
await execOp('del_element', { slideIdx: tsStore.getCurrentIndex(), elementId: id, deletedElement: el ? { ...el } : undefined })
},
undo: async () => {
await invoke('undo')
},
redo: async () => {
await invoke('redo')
},
}
}
export const store = createStore()
+759 -70
View File
File diff suppressed because it is too large Load Diff
+276
View File
@@ -0,0 +1,276 @@
/* =====================================================================
* assets.ts — 统一资产存储:所有媒体(图片/视频/附件)走 OSS,避免撑爆 localStorage
*
* 设计:
* - deck 里 element.content 不再内嵌 base64,改存「引用」:
* · "asset:<id>" —— 本地待同步(离线暂存于 IndexedDB)
* · "https://..." —— 已在云端(直接可访问)
* · 旧的 data:/blob:/http URL —— 原样透传(完全兼容旧 deck)
* - blob 存 IndexedDB(不占 localStorage 的 5MB,且刷新不丢,顺带修好附件会话态丢失)
* - 离线或未配 OSS 时先暂存本地并入待同步队列;联网 + 配好 OSS 后自动同步换真实 URL
* - 渲染需同步取 URL:用 Vue reactive 映射 "asset:<id>" → ObjectURL,异步 hydrate 后自动刷新
* ===================================================================== */
import { reactive } from 'vue'
import { store } from './store'
import { ossUpload, isTauri, type OssUploadReq } from './bridge'
import type { OssCfg } from './types'
const DB_NAME = 'u-ppt-assets'
const STORE = 'assets'
const DB_VER = 1
/** IndexedDB 资产记录 */
interface AssetRecord {
id: string
blob: Blob
name: string
type: string
/** 目标对象 key(dir 前缀 + 日期 + 随机名 + 扩展名),同步时上传到此 key */
key: string
/** pending=待上传;synced=已在云端 */
status: 'pending' | 'synced'
/** 同步后的公开 URL */
url?: string
createdAt: number
}
/** 引用 → 可访问 URL 的响应式映射(供渲染同步读取;pending 用 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 }
}
+159
View File
@@ -0,0 +1,159 @@
/* =====================================================================
* attachments.ts — 会话态附件坞服务(模块级响应式状态,参考 dialog.ts 设计)
*
* 定位:页面粘贴常规文件(非图片)→ 附件坞展示 → 抽屉预览/下载
* 约束:
* - 仅会话内存态,不写 deck / localStorage,刷新即失
* - ObjectURL 统一在本模块创建/释放(remove/clear 时 revoke)
* - 预览文本(PDF/DOCX/MD/TXT)懒加载并缓存在附件对象上
* ===================================================================== */
import { ref } from 'vue'
import { extractPdfText, extractDocxText } from './importer'
/** 附件预览类型(决定抽屉渲染方式) */
export type PreviewKind = 'image' | 'video' | 'pdf' | 'markdown' | 'text' | 'doc' | 'meta'
/** 会话态附件 */
export interface Attachment {
id: number
file: File
name: string
ext: string
size: number
/** 预览分类 */
kind: PreviewKind
/** 懒加载的提取文本(pdf/doc/md/txt 预览用) */
text?: string
/** 文本提取状态 */
textState: 'none' | 'loading' | 'done' | 'error'
textError?: string
}
/* ---------- 模块级响应式状态(FileDock / FilePreviewDrawer 消费) ---------- */
export const attachmentState = {
/** 附件列表 */
list: ref<Attachment[]>([]),
/** 当前预览的附件 id(null = 抽屉关闭) */
activeId: ref<number | null>(null),
/** 预览放大占满窗口 */
maximized: ref(false)
}
let nextId = 1
/** 扩展名 → 预览类型映射 */
const KIND_BY_EXT: Record<string, PreviewKind> = {
'.png': 'image', '.jpg': 'image', '.jpeg': 'image', '.gif': 'image',
'.webp': 'image', '.svg': 'image', '.bmp': 'image', '.ico': 'image',
'.pdf': 'pdf',
'.md': 'markdown', '.markdown': 'markdown',
'.txt': 'text', '.text': 'text',
'.doc': 'doc', '.docx': 'doc'
}
/** 取小写扩展名(含点;无扩展名为空串) */
export function getExt(name: string): string {
const dot = name.lastIndexOf('.')
return dot >= 0 ? name.slice(dot).toLowerCase() : ''
}
/** 文件 → 预览类型:扩展名优先,MIME(video/*)兜底,其余回落 meta(仅元数据+下载) */
export function getPreviewKind(file: File): PreviewKind {
const byExt = KIND_BY_EXT[getExt(file.name)]
if (byExt) return byExt
if (file.type.startsWith('video/')) return 'video'
if (file.type.startsWith('image/')) return 'image'
if (file.type === 'text/plain') return 'text'
if (file.type === 'application/pdf') return 'pdf'
return 'meta'
}
/** 人性化文件大小 */
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
/** 加入附件坞(仅会话态,不落盘) */
export function addFiles(files: File[] | FileList): number {
let added = 0
for (const file of Array.from(files)) {
attachmentState.list.value.push({
id: nextId++,
file,
name: file.name || `粘贴文件-${nextId}`,
ext: getExt(file.name),
size: file.size,
kind: getPreviewKind(file),
textState: 'none'
})
added++
}
return added
}
/** 按类型查找附件 */
export function getAttachment(id: number | null): Attachment | null {
if (id == null) return null
return attachmentState.list.value.find(a => a.id === id) ?? null
}
/** 移除单个附件(若是当前预览项则顺带关抽屉) */
export function removeAttachment(id: number) {
const idx = attachmentState.list.value.findIndex(a => a.id === id)
if (idx < 0) return
attachmentState.list.value.splice(idx, 1)
if (attachmentState.activeId.value === id) {
attachmentState.activeId.value = null
attachmentState.maximized.value = false
}
}
/** 清空附件坞 */
export function clearAttachments() {
attachmentState.list.value = []
attachmentState.activeId.value = null
attachmentState.maximized.value = false
}
/** 打开抽屉预览 */
export function openPreview(id: number) {
attachmentState.activeId.value = id
attachmentState.maximized.value = false
void loadPreviewText(getAttachment(id))
}
/** 关闭抽屉 */
export function closePreview() {
attachmentState.activeId.value = null
attachmentState.maximized.value = false
}
/** 懒加载预览文本(复用 importer 的 PDF/DOCX 提取),结果缓存在附件对象上 */
export async function loadPreviewText(att: Attachment | null): Promise<void> {
if (!att) return
if (att.textState !== 'none') return
if (att.kind !== 'pdf' && att.kind !== 'doc' && att.kind !== 'markdown' && att.kind !== 'text') return
att.textState = 'loading'
try {
if (att.kind === 'pdf') att.text = await extractPdfText(att.file)
else if (att.kind === 'doc') att.text = await extractDocxText(att.file)
else att.text = await att.file.text()
att.textState = 'done'
} catch (e: any) {
att.textState = 'error'
att.textError = e?.message || String(e)
}
}
/** 下载附件(meta 类型兜底出口,也供常规预览手动另存) */
export function downloadAttachment(att: Attachment) {
const url = URL.createObjectURL(att.file)
const a = document.createElement('a')
a.href = url
a.download = att.name
a.click()
URL.revokeObjectURL(url)
}
-173
View File
@@ -1,173 +0,0 @@
/* =====================================================================
* backend.ts — 持久化后端抽象层
*
* store 只管内存状态 + Op Log,持久化通过 Backend 接口抽象。
* 三个实现:
* - LocalBackend (localStorage,Web 版当前模式)
* - TauriBackend (Rust 文件系统,桌面版,B4 实现)
* - CloudBackend (HTTP API,云端版,C0 实现)
*
* 设计原则:
* - 接口只管读写数据,不管状态逻辑(状态逻辑在 store)
* - 所有方法返回 Promise(TauriBackend/CloudBackend 天然异步)
* - LocalBackend 内部用 localStorage 但包装为 Promise(统一接口)
* ===================================================================== */
import type { Deck, LibItem, PageTemplate, Theme, ChatMessage, AiCfg } from './types'
export interface Backend {
// ---- 当前 deck ----
loadDeck(): Promise<Deck | null>
saveDeck(deck: Deck): Promise<void>
loadBackup(): Promise<Deck | null>
saveBackup(deck: Deck): Promise<void>
clearBackup(): Promise<void>
// ---- 文库 ----
loadLibrary(): Promise<LibItem[]>
saveLibrary(lib: LibItem[]): Promise<void>
getActiveLibId(): Promise<string | null>
setActiveLibId(id: string | null): Promise<void>
// ---- 会话(按 chatId 隔离) ----
loadChat(chatId: string): Promise<ChatMessage[]>
saveChat(chatId: string, msgs: ChatMessage[]): Promise<void>
clearChat(chatId: string): Promise<void>
// ---- 用户模板 ----
loadTemplates(): Promise<PageTemplate[]>
saveTemplates(tpls: PageTemplate[]): Promise<void>
// ---- 自定义主题 ----
loadThemes(): Promise<Record<string, Theme>>
saveThemes(themes: Record<string, Theme>): Promise<void>
// ---- AI 配置 ----
loadCfg(): Promise<Partial<AiCfg> | null>
saveCfg(cfg: AiCfg): Promise<void>
}
/* ============================================================
* LocalBackend — localStorage 实现(Web 版当前模式)
* ============================================================ */
const KEYS = {
deck: 'u-ppt.deck.v1',
deckBak: 'u-ppt.deck.v1.bak',
lib: 'u-ppt.library.v1',
activeLib: 'u-ppt.activeLib.v1',
chatPrefix: 'u-ppt.chat.',
templates: 'u-ppt.templates.v1',
themes: 'u-ppt.themes.v1',
cfg: 'u-ppt.ai-cfg.v1',
}
function lsGet(key: string): string | null {
try { return localStorage.getItem(key) } catch (e) { return null }
}
function lsSet(key: string, val: string): void {
try { localStorage.setItem(key, val) } catch (e) { /* 配额满等忽略 */ }
}
function lsDel(key: string): void {
try { localStorage.removeItem(key) } catch (e) {}
}
export class LocalBackend implements Backend {
async loadDeck(): Promise<Deck | null> {
const raw = lsGet(KEYS.deck)
if (!raw) return null
try { return JSON.parse(raw) } catch (e) { return null }
}
async saveDeck(deck: Deck): Promise<void> {
lsSet(KEYS.deck, JSON.stringify(deck))
}
async loadBackup(): Promise<Deck | null> {
const raw = lsGet(KEYS.deckBak)
if (!raw) return null
try { return JSON.parse(raw) } catch (e) { return null }
}
async saveBackup(deck: Deck): Promise<void> {
lsSet(KEYS.deckBak, JSON.stringify(deck))
}
async clearBackup(): Promise<void> {
lsDel(KEYS.deckBak)
}
async loadLibrary(): Promise<LibItem[]> {
const raw = lsGet(KEYS.lib)
if (!raw) return []
try { return JSON.parse(raw) || [] } catch (e) { return [] }
}
async saveLibrary(lib: LibItem[]): Promise<void> {
lsSet(KEYS.lib, JSON.stringify(lib))
}
async getActiveLibId(): Promise<string | null> {
return lsGet(KEYS.activeLib)
}
async setActiveLibId(id: string | null): Promise<void> {
if (id) lsSet(KEYS.activeLib, id)
else lsDel(KEYS.activeLib)
}
async loadChat(chatId: string): Promise<ChatMessage[]> {
const raw = lsGet(KEYS.chatPrefix + chatId)
if (!raw) return []
try { return JSON.parse(raw) || [] } catch (e) { return [] }
}
async saveChat(chatId: string, msgs: ChatMessage[]): Promise<void> {
lsSet(KEYS.chatPrefix + chatId, JSON.stringify(msgs))
}
async clearChat(chatId: string): Promise<void> {
lsDel(KEYS.chatPrefix + chatId)
}
async loadTemplates(): Promise<PageTemplate[]> {
const raw = lsGet(KEYS.templates)
if (!raw) return []
try { return JSON.parse(raw) || [] } catch (e) { return [] }
}
async saveTemplates(tpls: PageTemplate[]): Promise<void> {
// 只存用户模板(category === 'user')
lsSet(KEYS.templates, JSON.stringify(tpls.filter(t => t.category === 'user')))
}
async loadThemes(): Promise<Record<string, Theme>> {
const raw = lsGet(KEYS.themes)
if (!raw) return {}
try { return JSON.parse(raw) || {} } catch (e) { return {} }
}
async saveThemes(themes: Record<string, Theme>): Promise<void> {
lsSet(KEYS.themes, JSON.stringify(themes))
}
async loadCfg(): Promise<Partial<AiCfg> | null> {
const raw = lsGet(KEYS.cfg)
if (!raw) return null
try { return JSON.parse(raw) } catch (e) { return null }
}
async saveCfg(cfg: AiCfg): Promise<void> {
lsSet(KEYS.cfg, JSON.stringify(cfg))
}
}
/* ---------- 当前活动的 Backend 实例 ---------- */
let activeBackend: Backend = new LocalBackend()
export function getBackend(): Backend {
return activeBackend
}
export function setBackend(b: Backend): void {
activeBackend = b
}
+119
View File
@@ -0,0 +1,119 @@
/* =====================================================================
* 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'
}
/** 两 hex 色 RGB 欧氏距离(0-441);任一非法返回 Infinity */
export function colorDistance(a: string, b: string): number {
const ra = hexToRgb(a), rb = hexToRgb(b)
if (!ra || !rb) return Infinity
return Math.sqrt((ra.r - rb.r) ** 2 + (ra.g - rb.g) ** 2 + (ra.b - rb.b) ** 2)
}
/** 主题键或 hex → hex(非法回退 '#ffffff'),供混同检测等内部比较使用 */
export function resolveKeyHex(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'
return t[key] || '#ffffff'
}
/** 背景键 → 代表色 hex(用于混同检测):g-primary→primary;g-deep→shade(primary,-20)(渐变中点偏深);g-soft→panel;纯色键→对应主题色 */
export function bgRepresentHex(bg: string): string {
const t = (getAllThemes()[getTheme()] || {}) as unknown as Record<string, string>
if (!bg) return '#ffffff'
if (bg.charAt(0) === '#') return isValidHex(bg) ? bg : '#ffffff'
if (bg === 'g-primary') return t.primary || '#ffffff'
if (bg === 'g-deep') return shade(t.primary || '#ffffff', -20)
if (bg === 'g-soft') return t.panel || '#f8fafc'
return resolveKeyHex(bg)
}
+100 -28
View File
@@ -1,44 +1,116 @@
/* =====================================================================
* bridge.ts — Tauri IPC 安全调用桥接
*
* 安全检测 __TAURI__ 环境,动态 import @tauri-apps/api/core。
* Web 模式下返回 mock,组件无需关心环境。
* bridge.ts — Tauri IPC 桥接(AI 代理专用)
* 桌面版(Tauri WebView)直连 AI 网关受 CORS 限制,走 Rust 侧 reqwest 代理;
* Web 版返回 null,调用方回退浏览器 fetch(智谱等支持 CORS 的网关直连可用)
* ===================================================================== */
/** 动态获取 invoke 函数 */
let _invoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null
/** 是否运行在 Tauri 桌面环境 */
export function isTauri(): boolean {
return typeof window !== 'undefined' && ('__TAURI__' in window || '__TAURI_INTERNALS__' in window)
}
async function ensureInvoke() {
if (_invoke) return true
if (typeof window === 'undefined' || !('__TAURI__' in window)) return false
let _invoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null
let _invokeTried = false
/** 懒加载 Tauri invoke(动态 import,Web 版不会打进包) */
async function getInvoke() {
if (_invokeTried) return _invoke
_invokeTried = true
if (!isTauri()) return null
try {
const mod = await import('@tauri-apps/api/core')
_invoke = mod.invoke
return true
} catch {
return false
} catch { _invoke = null }
return _invoke
}
export interface ProxyResp {
status: number
body: string
error?: string
}
/** 非流式代理:Rust 侧 fetch,返回 {status, body};非桌面环境返回 null */
export async function aiProxy(url: string, apiKey: string, body: string): Promise<ProxyResp | null> {
const invoke = await getInvoke()
if (!invoke) return null
try {
const r = await invoke('ai_proxy', { url, apiKey, body }) as ProxyResp
return r
} catch (e: any) {
return { status: 0, body: '', error: String(e?.message || e) }
}
}
/** 流式代理:Rust 侧 SSE,原始 chunk(含 "data: {...}" 帧)通过 onChunk 推送;
* SSE 解析由 ai.ts 的 createSSESink 统一处理(与浏览器路径共用)。非桌面返回 null */
export async function aiProxyStream(
url: string, apiKey: string, body: string,
onChunk: (raw: string) => void
): Promise<ProxyResp | null> {
const invoke = await getInvoke()
if (!invoke) return null
try {
const eventMod = await import('@tauri-apps/api/event')
const unlisten = await eventMod.listen<string>('ai-delta', (ev) => {
onChunk(ev.payload || '')
})
try {
const full = await invoke('ai_proxy_stream', { url, apiKey, body }) as unknown as string
return { status: 200, body: full }
} finally {
unlisten()
}
} catch (e: any) {
// Rust 侧错误协议:__HTTP_<status>__<body>
const raw = String(e?.message || e)
const m = raw.match(/^__HTTP_(\d+)__/)
if (m) return { status: Number(m[1]), body: raw.slice(m[0].length) }
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
}
/**
* 安全调用 Tauri IPC 命令。
* Web 模式下返回 undefined(调用方需处理)。
* 调用 Rust 侧 oss_upload 上传资产。
* 成功返回可访问 URL;非桌面环境返回 null(调用方回退本地暂存)。
* 失败抛出错误(消息为 Rust 端返回的中文提示)。
*/
export async function invoke<T = unknown>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
const ok = await ensureInvoke()
if (!ok || !_invoke) return undefined
return _invoke(cmd, args) as Promise<T>
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
}
/** 注册 Tauri 事件监听(用于 Rust → 前端推送) */
export async function listen<T>(event: string, handler: (payload: T) => void): Promise<() => void> {
if (typeof window === 'undefined' || !('__TAURI__' in window)) {
return () => {} // 空注销函数
}
try {
const { listen } = await import('@tauri-apps/api/event')
return listen(event, (e: { payload: T }) => handler(e.payload))
} catch {
return () => {}
/** 读取本地文件(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
}
+66
View File
@@ -0,0 +1,66 @@
/* =====================================================================
* dialog.ts — 全局对话框服务(替代原生 alert/confirm/prompt)
*
* Promise 风格:await appConfirm('删除?') → boolean
* 渲染走 AppDialogHost(主 app 内 Teleport 到 body),
* App.vue 模板放置 <AppDialogHost /> 即接线,无需手动 mount
* ===================================================================== */
import { ref } from 'vue'
export interface DialogOptions {
type: 'alert' | 'confirm' | 'prompt'
title: string
message?: string
/** prompt 默认值 */
defaultValue?: string
/** input placeholder */
placeholder?: string
/** 确认按钮文案(默认「确定」) */
okText?: string
/** 危险操作样式(确认按钮红色) */
danger?: boolean
}
interface DialogResult { ok: boolean; value: string }
type Resolver = (r: DialogResult) => void
/* ---------- 模块级响应式状态(AppDialogHost 渲染消费) ---------- */
export const dialogState = {
open: ref(false),
options: ref<DialogOptions | null>(null)
}
let resolver: Resolver | null = null
/** 由 AppDialogHost 的 close 事件回调 */
export function resolveDialog(r: DialogResult) {
dialogState.open.value = false
dialogState.options.value = null
if (resolver) { resolver(r); resolver = null }
}
function show(opts: DialogOptions): Promise<DialogResult> {
return new Promise(resolve => {
resolver = resolve
dialogState.options.value = opts
dialogState.open.value = true
})
}
/* ---------- 对外 API ---------- */
/** 信息提示(替代 alert) */
export function appAlert(title: string, message?: string) {
return show({ type: 'alert', title, message })
}
/** 确认(替代 confirm)→ Promise<boolean> */
export function appConfirm(title: string, message?: string, opts?: { okText?: string; danger?: boolean }) {
return show({ type: 'confirm', title, message, okText: opts?.okText, danger: opts?.danger }).then(r => r.ok)
}
/** 输入(替代 prompt)→ Promise<string | null>(取消为 null) */
export function appPrompt(title: string, opts?: { message?: string; defaultValue?: string; placeholder?: string }) {
return show({ type: 'prompt', title, message: opts?.message, defaultValue: opts?.defaultValue, placeholder: opts?.placeholder })
.then(r => (r.ok ? r.value : null))
}
+281
View File
@@ -0,0 +1,281 @@
/* =====================================================================
* importer.ts — 本地文件/目录资料导入引擎
*
* 设计原则:
* - 图片 → data URL → 直接插入 image 元素
* - 文档(PDF/DOCX/MD/TXT)→ 提取原始文本 → 由 LLM 分析生成幻灯片
* - LLM 理解文档语义,输出结构化 PPT,比规则解析更智能
* ===================================================================== */
import type { Slide, SlideElement } from './types'
import { createElement } from './sample'
// 本地打包 pdf.js worker(避免依赖 CDN,离线/桌面/内网可用)
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
/** 允许的图片扩展名 */
export const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'])
/** 允许的文档扩展名(由 LLM 解析) */
export const DOC_EXTS = new Set(['.md', '.markdown', '.txt', '.text', '.pdf', '.docx', '.doc'])
/** 允许的视频扩展名(实际判定以 MIME video/* 为准,此处用于 accept 与无 MIME 环境兜底) */
export const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi', '.mkv'])
/** 文件选择器 accept 属性(由支持清单派生,保持单一数据源) */
export const ACCEPT_ATTR = [...IMAGE_EXTS, ...VIDEO_EXTS, ...DOC_EXTS].join(',')
/** 文件分类结果 */
export interface FileEntry {
file: File
name: string
ext: string
kind: 'image' | 'video' | 'document' | 'unsupported'
/** 图片的 data URL,或文档的原始文本 */
data?: string
/** AI 解析出的幻灯片(文档读取 + LLM 分析后填充) */
slides?: Slide[]
/** 错误信息 */
error?: string
}
/** 导入统计 */
export interface ImportReport {
images: number
slides: number
files: number
}
export function getFileKind(file: File): FileEntry['kind'] {
const name = file.name.toLowerCase()
const dot = name.lastIndexOf('.')
const ext = dot >= 0 ? name.slice(dot) : ''
if (IMAGE_EXTS.has(ext)) return 'image'
// 视频按 MIME 判定(扩展名太杂),无 MIME 时扩展名兜底
if (file.type.startsWith('video/') || VIDEO_EXTS.has(ext)) return 'video'
if (DOC_EXTS.has(ext)) return 'document'
return 'unsupported'
}
/** 读取文件为 ArrayBuffer */
function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.onerror = () => reject(new Error(`读取失败: ${file.name}`))
reader.readAsArrayBuffer(file)
})
}
/** 读取图片为 Data URL */
export function readAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(new Error(`读取失败: ${file.name}`))
reader.readAsDataURL(file)
})
}
/** 读取文本文件 */
function readAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(new Error(`读取失败: ${file.name}`))
reader.readAsText(file, 'utf-8')
})
}
/* ============================================================
* PDF 文本提取(仅提取原始文本,不做结构解析)
* ============================================================ */
export async function extractPdfText(file: File): Promise<string> {
const pdfjsLib: any = await import('pdfjs-dist')
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
const buffer = await readAsArrayBuffer(file)
const pdf = await pdfjsLib.getDocument({ data: buffer }).promise
const pages: string[] = []
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i)
const content = await page.getTextContent()
const lines = groupPdfTextItems(
content.items as Array<{ str: string; transform: number[] }>
)
pages.push(lines.join('\n'))
}
return pages.map((p, i) => `[p${i + 1}]\n${p}`).join('\n\n')
}
// 注意:阈值 5 基于坐标差值,多栏排版可能合并错乱,交由 LLM 理解时再纠正
function groupPdfTextItems(
items: Array<{ str: string; transform: number[] }>
): string[] {
const lines: string[] = []
let currentLine = ''
let lastY = 0
for (const item of items) {
const text = item.str
if (!text && text.length === 0) continue
const y = item.transform[5]
if (currentLine && Math.abs(y - lastY) > 5) {
lines.push(currentLine.trimEnd())
currentLine = text
} else {
currentLine +=
(currentLine && !currentLine.endsWith(' ') && !text.startsWith(' ') ? ' ' : '') + text
}
lastY = y
}
if (currentLine.trim()) lines.push(currentLine.trimEnd())
return lines
}
/* ============================================================
* DOCX 文本提取(转为 Markdown 文本,不解析为幻灯片)
* ============================================================ */
export async function extractDocxText(file: File): Promise<string> {
const mammoth: any = await import('mammoth')
const buffer = await readAsArrayBuffer(file)
const result = await mammoth.convertToMarkdown({ arrayBuffer: buffer })
return result.value
}
/* ============================================================
* 从文本文件直接读取
* ============================================================ */
async function readTextFile(file: File): Promise<string> {
return readAsText(file)
}
/* ============================================================
* 图片导入 → SlideElement(image 类型)
* ============================================================ */
export function fileToImageElement(file: File, dataUrl: string): SlideElement {
const isWide = file.type === 'image/svg+xml'
return createElement('image', {
content: dataUrl,
x: 15, y: 12, w: isWide ? 60 : 40, h: isWide ? 40 : 50,
style: { fit: 'contain', anim: 'fade' }
})
}
export function imagesToSlideElements(files: Array<{ file: File; dataUrl: string }>): SlideElement[] {
if (files.length === 1) {
return [fileToImageElement(files[0].file, files[0].dataUrl)]
} const cols = Math.ceil(Math.sqrt(files.length))
const rows = Math.ceil(files.length / cols)
const cellW = Math.floor(80 / cols)
const cellH = Math.floor(60 / rows)
return files.map((f, i) => {
const col = i % cols
const row = Math.floor(i / cols)
return createElement('image', {
content: f.dataUrl,
x: 8 + col * cellW, y: 8 + row * cellH,
w: cellW - 4, h: cellH - 4,
style: { fit: 'cover', anim: 'fade' }
})
})
}
/* ============================================================
* 视频导入 → SlideElement(video 类型)
* 注意:dataURL 会把整个视频读入内存,大视频占内存较高(本地工具首版接受)
* ============================================================ */
export function fileToVideoElement(file: File, dataUrl: string): SlideElement {
return createElement('video', {
content: dataUrl,
x: 20, y: 15, w: 60, h: 45,
style: { anim: 'fade' }
})
}
/* ============================================================
* 批量文件扫描与读取
* ============================================================ */
export async function scanFiles(fileList: File[] | FileList): Promise<FileEntry[]> {
const entries: FileEntry[] = []
for (const file of Array.from(fileList)) {
const name = file.name
const dot = name.lastIndexOf('.')
const ext = dot >= 0 ? name.slice(dot).toLowerCase() : ''
const kind = getFileKind(file)
entries.push({ file, name, ext, kind })
}
return entries
}
/** 读取文件内容(图片 → data URL,文档 → 原始文本),原位修改 entries */
export async function readEntries(entries: FileEntry[]): Promise<void> {
// 并行读取:各文件互不依赖,错误就地记到 entry.error 不中断整体
await Promise.all(entries.map(async (entry) => {
try {
if (entry.kind === 'image') {
entry.data = await readAsDataURL(entry.file)
} else if (entry.kind === 'video') {
// 视频 → data URL(与图片同管线,播放元素直接消费)
entry.data = await readAsDataURL(entry.file)
} else if (entry.kind === 'document') {
if (entry.ext === '.pdf') {
entry.data = await extractPdfText(entry.file)
} else if (entry.ext === '.docx' || entry.ext === '.doc') {
entry.data = await extractDocxText(entry.file)
} else {
// .md / .markdown / .txt / .text
const text = await readTextFile(entry.file)
entry.data = text
}
}
} catch (e: any) {
entry.error = e?.message || String(e)
console.warn(`读取失败 ${entry.name}:`, e)
}
}))
}
/** 使用 AI 将一个文档的文本内容解析为幻灯片(异步,调用 LLM) */
export async function aiAnalyzeDocument(
entry: FileEntry,
signal?: AbortSignal
): Promise<Slide[]> {
if (!entry.data || entry.kind !== 'document') {
throw new Error('没有可分析的文本内容')
}
const { generateFromDocument } = await import('./ai')
// 文档文本太长时截断(按 token 估算,保留关键部分:开头 + 结尾)
let text = entry.data
const MAX_CHARS = 12000
if (text.length > MAX_CHARS) {
const keepFront = Math.floor(MAX_CHARS * 0.5)
const keepTail = MAX_CHARS - keepFront
text = text.slice(0, keepFront) + '\n\n...(中间省略 ' + (text.length - MAX_CHARS) + ' 字符)...\n\n' + text.slice(-keepTail)
}
const result = await generateFromDocument({
text,
filename: entry.name,
signal
})
entry.slides = result.slides
return result.slides
}
/** 生成导入统计描述 */
export function describeReport(r: ImportReport): string {
const parts: string[] = []
if (r.slides > 0) parts.push(`AI 生成 ${r.slides} 页幻灯片`)
if (r.images > 0) parts.push(`插入 ${r.images} 张图片`)
if (r.files > 0) parts.push(`共处理 ${r.files} 个文件`)
return parts.join(',') || '未导入任何内容'
}
+66
View File
@@ -0,0 +1,66 @@
/* =====================================================================
* markdown.ts — AI 消息 Markdown 渲染(参考 devflow useMarkdown 设计)
* - marked 静态导入(u-ppt 包体敏感度低,无需动态加载)
* - DOMPurify.sanitize 防 XSS,可安全 v-html
* - 历史消息整段缓存(文本不变命中跳过 parse+sanitize)
* - JSON 代码块轻量着色(保留原有正则方案,不引 hljs 控包体)
* ===================================================================== */
import { marked } from 'marked'
import DOMPurify from 'dompurify'
marked.setOptions({ gfm: true, breaks: true })
/* ---------- JSON 轻量着色(与原 AiPanel 实现一致) ---------- */
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function tryFormatJson(raw: string): string {
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch (e) { return raw }
}
/** JSON 代码着色(纯正则,无依赖) */
function highlightJson(code: string): string {
return escapeHtml(code)
.replace(/("(?:\\.|[^"\\])*"\s*:)/g, '<span class="jk">$1</span>')
.replace(/:\s*("(?:\\.|[^"\\])*")/g, ': <span class="js">$1</span>')
.replace(/:\s*(-?\d+\.?\d*)/g, ': <span class="jn">$1</span>')
.replace(/:\s*(true|false|null)/g, ': <span class="jb">$1</span>')
}
/* ---------- code renderer:json 格式化+着色,其余转义 ---------- */
const renderer = new marked.Renderer()
renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
const language = (lang || '').toLowerCase()
if (!language || language === 'json') {
return `<pre class="msg-code" data-lang="json"><code>${highlightJson(tryFormatJson(text.trim()))}</code></pre>`
}
return `<pre class="msg-code" data-lang="${escapeHtml(language)}"><code>${escapeHtml(text)}</code></pre>`
}
marked.use({ renderer })
/* ---------- 缓存 + 主入口 ---------- */
const _mdCache = new Map<string, string>()
const MD_CACHE_LIMIT = 300
/** 渲染 Markdown → 消毒 HTML(可安全 v-html)。失败降级纯文本转义。 */
export function renderMd(text: string): string {
const hit = _mdCache.get(text)
if (hit != null) return hit
let html: string
try {
html = DOMPurify.sanitize(marked.parse(text) as string, {
ADD_ATTR: ['data-lang'],
})
} catch (e) {
html = '<p>' + escapeHtml(text).replace(/\n/g, '<br>') + '</p>'
}
if (_mdCache.size >= MD_CACHE_LIMIT) {
// 简单淘汰:清掉最早的键(Map 保持插入序)
const firstKey = _mdCache.keys().next().value
if (firstKey !== undefined) _mdCache.delete(firstKey)
}
_mdCache.set(text, html)
return html
}
+43 -5
View File
@@ -20,6 +20,7 @@ export type OpType =
| 'move_slide'
| 'replace_slide'
| 'set_slide_bg'
| 'set_slide_note'
| 'add_element'
| 'update_element'
| 'del_element'
@@ -40,6 +41,8 @@ export interface Op {
export interface HistoryEntry {
forward: Op
backward: Op
/** 批次标记:同批多条(如一次导入 N 张图)undo/redo 时整组一起回滚 */
group?: string
}
/* ---------- 工具 ---------- */
@@ -104,11 +107,24 @@ export function applyOp(inputDeck: Deck, op: Op): Deck {
break
}
case 'set_slide_note': {
const index = op.index as number
const note = op.note as string
if (deck.slides[index]) {
deck.slides[index].note = note
}
break
}
case 'add_element': {
const slideIdx = op.slideIdx as number
const element = op.element as SlideElement
const atIndex = op.atIndex as number | undefined
const s = deck.slides[slideIdx]
if (s) s.elements.push(element)
if (s) {
if (atIndex != null) s.elements.splice(atIndex, 0, element) // 指定位置插入(撤销删除时回到原 z 序)
else s.elements.push(element)
}
break
}
@@ -215,6 +231,12 @@ export function invertOp(inputDeck: Deck, op: Op): Op {
return { type: 'set_slide_bg', index, bg: oldBg }
}
case 'set_slide_note': {
const index = op.index as number
const oldNote = deck.slides[index]?.note ?? ''
return { type: 'set_slide_note', index, note: oldNote }
}
case 'add_element': {
// 逆:删除加的元素
return { type: 'del_element', slideIdx: op.slideIdx, elementId: (op.element as SlideElement).id }
@@ -244,18 +266,34 @@ export function invertOp(inputDeck: Deck, op: Op): Op {
}
case 'del_element': {
// 逆:把删掉的元素加回去
// 逆:把删掉的元素加回原位置(保持 z 序)
const slideIdx = op.slideIdx as number
const deletedElement = op.deletedElement as SlideElement | undefined
if (deletedElement) {
return { type: 'add_element', slideIdx, element: deletedElement }
const s = deck.slides[slideIdx]
const at = s ? s.elements.findIndex(e => e.id === deletedElement.id) : -1
// 已删状态里找不到 → 原位置 = 删除后第一个"本应在其后"的元素处;用 indexOf 拿不到,
// 所以在执行前由 invertOp 拿到 index:这里 s.elements 已无该元素,取其后继位置
const insertAt = at >= 0 ? at : (s ? s.elements.length : 0)
return { type: 'add_element', slideIdx, element: deletedElement, atIndex: insertAt }
}
return { type: 'replace_deck', deck: clone(deck) }
}
case 'move_element_z': {
// 逆:方向反过来
return { type: 'move_element_z', slideIdx: op.slideIdx, elementId: op.elementId, dir: -(op.dir as number) }
// 逆:方向反过来。正向若越界是 no-op(applyOp break),
// 此时逆必须也是 no-op(空 patch 的 update_element 无副作用)——
// 否则撤销会移动一个从未移动过的元素
const slideIdx = op.slideIdx as number
const elementId = op.elementId as string
const s = deck.slides[slideIdx]
const i = s ? s.elements.findIndex(e => e.id === elementId) : -1
const j = i >= 0 ? ((op.dir as number) > 0 ? i + 1 : i - 1) : -1
const outOfRange = i < 0 || j < 0 || j >= (s ? s.elements.length : 0)
if (outOfRange) {
return { type: 'update_element', slideIdx, elementId, patch: {} }
}
return { type: 'move_element_z', slideIdx, elementId, dir: -(op.dir as number) }
}
case 'set_theme': {
+223
View File
@@ -0,0 +1,223 @@
/* =====================================================================
* 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 // 重连退避上限
/** 补全中继地址:无路径(或仅"/")时自动追加 /ws/miniapp 端点;已含路径则原样 */
export function normalizeRelayUrl(url: string): string {
const u = url.trim().replace(/\/+$/, '')
try {
const parsed = new URL(u)
return (parsed.pathname === '/' || parsed.pathname === '') ? u + '/ws/miniapp' : u
} catch {
return u
}
}
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(normalizeRelayUrl(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()
+8
View File
@@ -170,6 +170,14 @@ export function hasFormatting(lines: RichLine[]): boolean {
return false
}
/* ---------- 工具:行首是否自带列表标记(圆点/编号,用于避免渲染层双重标记) ---------- */
/** 检测行首(允许空白)是否自带列表标记:圈号①-⑳ / 阿拉伯数字+点顿 / 圆点符号 / 中文数字+点顿 */
export function hasLineMarker(line: string): boolean {
if (!line) return false
return /^[\s]*(?:[①-⑳]|[((]?\d{1,2}[)).、.]|[-•·▪●○*]|[一二三四五六七八九十]+[、..])/.test(line)
}
/* ---------- 工具:安全化(AI 输出或外部数据 → 合法 segments) ---------- */
export function normSegments(input: any): RichLine[] | undefined {
+4 -2
View File
@@ -30,7 +30,8 @@ export const elementTypes: Record<ElementType, { label: string; defaults: Elemen
stat: { label: '数据', defaults: { fontSize: 64, color: 'primary', label: '说明文字', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
quote: { label: '金句', defaults: { fontSize: 40, color: 'text', italic: true, align: 'center', anim: 'scale' } },
image: { label: '图片', defaults: { fit: 'cover', anim: 'fade' } },
shape: { label: '形状', defaults: { shapeType: 'rect', fill: 'accent', radius: 12, anim: 'fade' } },
video: { label: '视频', defaults: { fit: 'contain', anim: 'fade' } },
shape: { label: '形状', defaults: { shapeType: 'rect', fill: 'accent', radius: 12, fontSize: 20, anim: 'fade' } },
chart: { label: '图表', defaults: { color: 'primary', max: 100, chartType: 'bar', legend: true, grid: true, anim: 'fade-up' } },
card: { label: '卡片', defaults: { fontSize: 26, accent: 'primary', icon: '', anim: 'fade-up' } },
table: { label: '表格', defaults: { fontSize: 16, color: 'text', header: true, anim: 'fade-up' } },
@@ -48,7 +49,8 @@ export function createElement(type: ElementType, over?: Partial<SlideElement> &
stat: { w: 30, h: 35 },
quote: { w: 70, h: 25 },
image: { w: 40, h: 40 },
shape: { w: 25, h: 25 },
video: { w: 50, h: 32 },
shape: { w: 30, h: 15 },
chart: { w: 45, h: 35 },
card: { w: 35, h: 35 },
table: { w: 60, h: 35 },
+111
View File
@@ -0,0 +1,111 @@
/* =====================================================================
* share.ts — 分享发布:deck JSON 上传 OSS,生成公开只读链接(零后端)
* 链接形态:<qiniuDomain 或 aliyun bucket 域名>#/p/<shareId> 由 viewer 页解析拉取
* 阿里云 URL:<bucket>.<endpoint>/<dir>/share/<id>.json
* 七牛 URL:<domain>/<dir>/share/<id>.json
* ===================================================================== */
import { store } from './store'
import { ossUpload, isTauri } from './bridge'
import { canUploadNow, isOssEnabled, buildUploadReq } from './assets'
import type { OssCfg, Deck } from './types'
/** 分享发布前置校验:不满足时返回中文原因 */
export function shareReady(): { ok: boolean; reason?: string } {
if (!isOssEnabled()) return { ok: false, reason: '请先在 ☁ 云存储中配置并启用 OSS 后再分享' }
if (!canUploadNow()) {
if (!isTauri()) return { ok: false, reason: '分享发布仅支持桌面版,请在桌面应用中操作' }
return { ok: false, reason: '当前离线,请联网后再分享' }
}
const cfg = store.getOssCfg()
if (cfg.provider === 'qiniu' && !(cfg.qiniuDomain || '').trim()) {
return { ok: false, reason: '七牛云分享需配置绑定的公开访问域名(Bucket 绑定的 CDN 域名)' }
}
return { ok: true }
}
/** 生成分享 id:'p' + 时间戳36进制 + 随机后缀 */
function genShareId(): string {
return 'p' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
}
/** 按 dir 规则(trim + 去首尾斜杠)拼分享对象 key */
function shareKey(cfg: OssCfg, shareId: string): string {
const dir = (cfg.dir || '').trim().replace(/^\/+|\/+$/g, '')
return [dir, 'share', `${shareId}.json`].filter(Boolean).join('/')
}
/** 由 OSS 配置 + key 反推公开访问 URL */
function jsonUrlFor(cfg: OssCfg, key: string): string {
if (cfg.provider === 'qiniu') {
return `${(cfg.qiniuDomain || '').trim().replace(/\/+$/, '')}/${key}`
}
// 阿里云:<bucket>.<endpoint host>/<key>(endpoint 规整同 oss.rs:去协议、去尾斜杠)
const host = (cfg.aliEndpoint || '').trim().replace(/^https?:\/\//, '').replace(/\/+$/, '')
const bucket = (cfg.aliBucket || '').trim()
return `https://${bucket}.${host}/${key}`
}
/* ---------- hash 编解码:JSON 地址随链接分发,观众端零配置 ----------
* 观众浏览器没有发布者的 OSS 配置,shareId 反推不出拉取地址,
* 所以发布时把完整 JSON 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 }
}
+470 -117
View File
@@ -3,7 +3,7 @@
* 由 store.js 迁移:用 Vue reactive 替代手写 pub/sub,组件自动追踪
* ===================================================================== */
import { reactive, computed } from 'vue'
import type { AiCfg, Deck, LibItem, PageTemplate, Slide, SlideElement, ThemeKey, ChatMessage } 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 { applyOp, invertOp, type Op, type HistoryEntry } from './op'
@@ -15,21 +15,22 @@ const ACTIVE_KEY = 'u-ppt.activeLib.v1'
const CHAT_PREFIX = 'u-ppt.chat.'
const SAVE_DELAY = 400
/* ---------- 内置页面模板(版式预设) ---------- */
/* ---------- 内置页面模板(版式预设,按 group 分组展示) ---------- */
const BUILTIN_TEMPLATES: PageTemplate[] = [
/* ===== 基础 ===== */
{
id: 'tpl-blank', name: '空白页', category: 'built-in', background: 'bg',
id: 'tpl-blank', name: '空白页', category: 'built-in', group: 'basic', background: 'bg',
elements: []
},
{
id: 'tpl-title', name: '标题页', category: 'built-in', background: 'g-primary',
id: 'tpl-title', name: '标题页', category: 'built-in', group: 'basic', background: 'g-primary',
elements: [
{ id: 'b1', type: 'title', x: 10, y: 38, w: 80, h: 20, content: '标题', style: { fontSize: 64, color: 'text', align: 'center', anim: 'pop' } },
{ id: 'b2', type: 'text', x: 20, y: 60, w: 60, h: 8, content: '副标题', style: { fontSize: 24, color: 'muted', align: 'center', anim: 'fade-up' } }
]
},
{
id: 'tpl-section', name: '章节页', category: 'built-in', background: 'g-deep',
id: 'tpl-section', name: '章节页', category: 'built-in', group: 'basic', background: 'g-deep',
elements: [
{ id: 's1', type: 'shape', x: 0, y: 0, w: 6, h: 100, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, anim: 'slide-l' } },
{ id: 's2', type: 'title', x: 12, y: 38, w: 70, h: 18, content: '章节标题', style: { fontSize: 56, color: 'text', align: 'left', anim: 'fade-up' } },
@@ -37,7 +38,19 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
]
},
{
id: 'tpl-cards3', name: '三卡片', category: 'built-in', background: 'bg',
id: 'tpl-toc', name: '目录页', category: 'built-in', group: 'basic', background: 'bg',
elements: [
{ id: 't1', type: 'title', x: 8, y: 8, w: 40, h: 12, content: '目录', style: { fontSize: 44, color: 'primary', align: 'left', anim: 'fade-up' } },
{ id: 't2', type: 'shape', x: 8, y: 23, w: 10, h: 1.6, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'fade-up' } },
{ id: 't3', type: 'list', x: 10, y: 32, w: 36, h: 50, content: '01 第一个主题\n02 第二个主题\n03 第三个主题\n04 第四个主题', style: { fontSize: 26, color: 'text', align: 'left', anim: 'fade-up' } },
{ id: 't4', type: 'shape', x: 55, y: 0, w: 45, h: 100, content: '', style: { shapeType: 'circle', fill: 'accent', gradient: true, opacity: 0.15, anim: 'scale' } },
{ id: 't5', type: 'stat', x: 58, y: 32, w: 36, h: 36, content: '04', style: { fontSize: 90, color: 'primary', label: '个章节', labelColor: 'muted', labelSize: 20, anim: 'scale' } }
]
},
/* ===== 内容 ===== */
{
id: 'tpl-cards3', name: '三卡片', category: 'built-in', group: 'content', background: 'bg',
elements: [
{ id: 'c1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
{ id: 'c2', type: 'card', x: 6, y: 28, w: 28, h: 60, content: '卡片一\n要点描述', style: { accent: 'primary', icon: '', anim: 'fade-up' } },
@@ -46,7 +59,7 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
]
},
{
id: 'tpl-compare', name: '对比页', category: 'built-in', background: 'bg',
id: 'tpl-compare', name: '对比页', category: 'built-in', group: 'content', background: 'bg',
elements: [
{ id: 'p1', type: 'title', x: 10, y: 7, w: 80, h: 11, content: '对比标题', style: { fontSize: 36, color: 'primary', align: 'center', anim: 'fade-up' } },
{ id: 'p2', type: 'card', x: 7, y: 24, w: 40, h: 65, content: '左\n要点一\n要点二', style: { accent: 'muted', icon: '', anim: 'slide-l' } },
@@ -54,7 +67,78 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
]
},
{
id: 'tpl-quote', name: '金句页', category: 'built-in', background: 'g-deep',
id: 'tpl-bullets', name: '要点列表', category: 'built-in', group: 'content', background: 'bg',
elements: [
{ id: 'l1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '要点标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
{ id: 'l2', type: 'shape', x: 8, y: 23, w: 10, h: 1.6, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'fade-up' } },
{ id: 'l3', type: 'list', x: 10, y: 30, w: 80, h: 55, content: '第一个要点:具体说明\n第二个要点:具体说明\n第三个要点:具体说明\n第四个要点:具体说明', style: { fontSize: 28, color: 'text', align: 'left', anim: 'fade-up' } }
]
},
{
id: 'tpl-image-text', name: '图文混排', category: 'built-in', group: 'content', background: 'bg',
elements: [
{ id: 'it1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '图文标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
{ id: 'it2', type: 'image', x: 8, y: 26, w: 42, h: 60, content: '', style: { fit: 'cover', anim: 'fade-up' } },
{ id: 'it3', type: 'list', x: 56, y: 28, w: 38, h: 55, content: '要点一:具体说明\n要点二:具体说明\n要点三:具体说明', style: { fontSize: 26, color: 'text', align: 'left', anim: 'fade-up' } }
]
},
{
id: 'tpl-timeline', name: '时间线', category: 'built-in', group: 'content', background: 'bg',
elements: [
{ id: 'tl1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '发展历程', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
{ id: 'tl2', type: 'shape', x: 10, y: 52, w: 80, h: 0.6, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, opacity: 0.6, anim: 'fade' } },
{ id: 'tl3', type: 'shape', x: 18, y: 48.5, w: 2.4, h: 2.4, content: '', style: { shapeType: 'circle', fill: 'primary', anim: 'pop' } },
{ id: 'tl4', type: 'shape', x: 48, y: 48.5, w: 2.4, h: 2.4, content: '', style: { shapeType: 'circle', fill: 'primary', anim: 'pop' } },
{ id: 'tl5', type: 'shape', x: 78, y: 48.5, w: 2.4, h: 2.4, content: '', style: { shapeType: 'circle', fill: 'primary', anim: 'pop' } },
{ id: 'tl6', type: 'stat', x: 8, y: 26, w: 22, h: 20, content: '2023', style: { fontSize: 40, color: 'primary', label: '里程碑说明', labelColor: 'muted', labelSize: 16, anim: 'fade-up' } },
{ id: 'tl7', type: 'stat', x: 38, y: 26, w: 22, h: 20, content: '2024', style: { fontSize: 40, color: 'primary', label: '里程碑说明', labelColor: 'muted', labelSize: 16, anim: 'fade-up' } },
{ id: 'tl8', type: 'stat', x: 68, y: 26, w: 22, h: 20, content: '2025', style: { fontSize: 40, color: 'accent', label: '里程碑说明', labelColor: 'muted', labelSize: 16, anim: 'fade-up' } },
{ id: 'tl9', type: 'text', x: 8, y: 60, w: 22, h: 25, content: '这一年的关键事件与成果描述', style: { fontSize: 20, color: 'muted', align: 'center', anim: 'fade-up' } },
{ id: 'tl10', type: 'text', x: 38, y: 60, w: 22, h: 25, content: '这一年的关键事件与成果描述', style: { fontSize: 20, color: 'muted', align: 'center', anim: 'fade-up' } },
{ id: 'tl11', type: 'text', x: 68, y: 60, w: 22, h: 25, content: '这一年的关键事件与成果描述', style: { fontSize: 20, color: 'muted', align: 'center', anim: 'fade-up' } }
]
},
{
id: 'tpl-steps', name: '步骤流程', category: 'built-in', group: 'content', background: 'bg',
elements: [
{ id: 'st1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '实施步骤', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
{ id: 'st2', type: 'stat', x: 8, y: 30, w: 18, h: 26, content: '1', style: { fontSize: 64, color: 'primary', label: '第一步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
{ id: 'st3', type: 'text', x: 8, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } },
{ id: 'st4', type: 'shape', x: 27.5, y: 40, w: 5, h: 0.8, content: '', style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' } },
{ id: 'st5', type: 'stat', x: 33, y: 30, w: 18, h: 26, content: '2', style: { fontSize: 64, color: 'primary', label: '第二步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
{ id: 'st6', type: 'text', x: 33, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } },
{ id: 'st7', type: 'shape', x: 52.5, y: 40, w: 5, h: 0.8, content: '', style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' } },
{ id: 'st8', type: 'stat', x: 58, y: 30, w: 18, h: 26, content: '3', style: { fontSize: 64, color: 'primary', label: '第三步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
{ id: 'st9', type: 'text', x: 58, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } },
{ id: 'st10', type: 'shape', x: 77.5, y: 40, w: 5, h: 0.8, content: '', style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' } },
{ id: 'st11', type: 'stat', x: 83, y: 30, w: 18, h: 26, content: '4', style: { fontSize: 64, color: 'accent', label: '第四步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
{ id: 'st12', type: 'text', x: 83, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } }
]
},
/* ===== 数据 ===== */
{
id: 'tpl-data', name: '数据页', category: 'built-in', group: 'data', background: 'panel',
elements: [
{ id: 'd1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '数据标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
{ id: 'd2', type: 'stat', x: 8, y: 28, w: 28, h: 30, content: '65%', style: { fontSize: 76, color: 'primary', label: '说明', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
{ id: 'd3', type: 'chart', x: 42, y: 26, w: 52, h: 40, content: '[{"label":"A","value":65},{"label":"B","value":45},{"label":"C","value":30}]', style: { color: 'primary', max: 100, chartType: 'bar', anim: 'fade-up' } }
]
},
{
id: 'tpl-data3', name: '三数据页', category: 'built-in', group: 'data', background: 'bg',
elements: [
{ id: 'dd1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '关键数据', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
{ id: 'dd2', type: 'stat', x: 8, y: 30, w: 27, h: 30, content: '2.4x', style: { fontSize: 68, color: 'primary', label: '指标说明一', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
{ id: 'dd3', type: 'stat', x: 37, y: 30, w: 27, h: 30, content: '87%', style: { fontSize: 68, color: 'accent', label: '指标说明二', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
{ id: 'dd4', type: 'stat', x: 66, y: 30, w: 27, h: 30, content: '120k', style: { fontSize: 68, color: 'primary', label: '指标说明三', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
{ id: 'dd5', type: 'chart', x: 15, y: 62, w: 70, h: 30, content: '{"series":["本季","上季"],"items":[{"label":"华东","values":[120,95]},{"label":"华南","values":[100,88]},{"label":"华北","values":[86,70]}]}', style: { color: 'primary', chartType: 'bar', legend: true, grid: true, anim: 'fade-up' } }
]
},
/* ===== 收尾 ===== */
{
id: 'tpl-quote', name: '金句页', category: 'built-in', group: 'ending', background: 'g-deep',
elements: [
{ id: 'q1', type: 'shape', x: 8, y: 24, w: 4, h: 52, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'slide-l' } },
{ id: 'q2', type: 'quote', x: 16, y: 28, w: 76, h: 34, content: '金句内容', style: { fontSize: 48, color: 'text', italic: true, align: 'left', anim: 'scale' } },
@@ -62,11 +146,22 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
]
},
{
id: 'tpl-data', name: '数据页', category: 'built-in', background: 'panel',
id: 'tpl-team', name: '团队页', category: 'built-in', group: 'ending', background: 'bg',
elements: [
{ id: 'd1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '数据标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
{ id: 'd2', type: 'stat', x: 8, y: 28, w: 28, h: 30, content: '65%', style: { fontSize: 76, color: 'primary', label: '说明', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
{ id: 'd3', type: 'chart', x: 42, y: 26, w: 52, h: 40, content: '[{"label":"A","value":65},{"label":"B","value":45},{"label":"C","value":30}]', style: { color: 'primary', max: 100, chartType: 'bar', anim: 'fade-up' } }
{ id: 'tm1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '核心团队', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
{ id: 'tm2', type: 'card', x: 8, y: 28, w: 27, h: 55, content: '姓名\n职位\n一句话介绍', style: { accent: 'primary', icon: '', anim: 'fade-up' } },
{ id: 'tm3', type: 'card', x: 37, y: 28, w: 27, h: 55, content: '姓名\n职位\n一句话介绍', style: { accent: 'accent', icon: '', anim: 'fade-up' } },
{ id: 'tm4', type: 'card', x: 66, y: 28, w: 27, h: 55, content: '姓名\n职位\n一句话介绍', style: { accent: 'primary', icon: '', anim: 'fade-up' } }
]
},
{
id: 'tpl-thanks', name: '感谢页', category: 'built-in', group: 'ending', background: 'g-primary',
elements: [
{ id: 'th1', type: 'shape', x: 55, y: -25, w: 65, h: 100, content: '', style: { shapeType: 'circle', fill: 'accent', gradient: true, opacity: 0.3, anim: 'scale' } },
{ id: 'th2', type: 'shape', x: 0, y: 0, w: 6, h: 100, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, anim: 'slide-l' } },
{ id: 'th3', type: 'title', x: 10, y: 34, w: 70, h: 18, content: '感谢观看', style: { fontSize: 62, color: 'text', align: 'left', anim: 'pop' } },
{ id: 'th4', type: 'text', x: 10, y: 56, w: 60, h: 10, content: '欢迎交流与讨论', style: { fontSize: 26, color: 'muted', align: 'left', anim: 'fade-up' } },
{ id: 'th5', type: 'text', x: 10, y: 82, w: 50, h: 7, content: '联系方式 / 二维码占位', style: { fontSize: 18, color: 'muted', align: 'left', anim: 'fade' } }
]
}
]
@@ -87,12 +182,18 @@ const state = reactive<{
deck: Deck
currentIndex: number
selectedId: string | null
/** 多选集(框选/Shift加选时非空;selectedId 始终为锚点元素) */
selectedIds: string[]
activeLibId: string | null
/** 当前选中的批注 id(纯 UI 态,不持久化;Delete 删除优先级高于宿主元素) */
selectedAnnoId: string | null
}>({
deck: JSON.parse(JSON.stringify(SAMPLE_DECK)),
currentIndex: 0,
selectedId: null,
activeLibId: null
selectedIds: [],
activeLibId: null,
selectedAnnoId: null
})
let saveTimer: ReturnType<typeof setTimeout> | null = null
@@ -100,86 +201,51 @@ let saveTimer: ReturnType<typeof setTimeout> | null = null
/* ---------- 工具 ---------- */
function clone<T>(o: T): T { return JSON.parse(JSON.stringify(o)) }
/* ---------- 持久化告警(写入失败置位,供 UI 提示,避免静默丢数据) ---------- */
const saveWarning = reactive({ text: '' })
/** 安全写入 localStorage;失败时置告警(非静默),返回是否成功 */
function safeSet(key: string, val: string): boolean {
try { localStorage.setItem(key, val); saveWarning.text = ''; return true }
catch (e) {
if (!saveWarning.text) {
saveWarning.text = '本地存储空间不足,最新改动可能未保存。请导出 JSON 备份,或删除部分图片/文库释放空间'
}
console.error('[u-ppt] 保存失败:', key, e)
return false
}
}
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
try { localStorage.setItem(LS_KEY, JSON.stringify(state.deck)) } catch (e) { /* 配额满等忽略 */ }
safeSet(LS_KEY, JSON.stringify(state.deck))
syncActiveLibItem()
}, SAVE_DELAY)
}
/* ---------- 颜色/背景解析(纯函数,供组件调用) ---------- */
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 }
/** 自动暂存:deck 有活跃文库条目时,改动自动回写文库(不点保存也不丢,刷新/首页重开均为最新) */
function syncActiveLibItem() {
if (!state.activeLibId) return
const lib = getLibrary()
const item = lib.find(x => x.id === state.activeLibId)
if (!item) return
item.deck = clone(state.deck)
item.updatedAt = Date.now()
setLibrary(lib)
}
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
}
/* ---------- 颜色/背景解析:纯函数已抽至 ./bg,此处再导出保持调用方 import 路径不变 ---------- */
export { isDarkBg, resolveBg, isDarkHex, isValidHex, resolveColor, getAllThemes } from './bg'
import { getAllThemes, registerBgThemeGetter, CUSTOM_THEME_KEY } from './bg'
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'
}
// 注入当前主题 getter,bg.ts 解析背景时读取(viewer 侧无需引入本 store)
registerBgThemeGetter(() => state.deck.theme)
/* ---------- 内部:执行 Op 并推入历史 ---------- */
/** 执行一个 Op,计算逆 Op,推入历史栈。返回是否执行成功 */
function execOp(op: Op, opts?: { coalesceKey?: string }): void {
function execOp(op: Op, opts?: { coalesceKey?: string; group?: string }): void {
if (suppressHistory) {
state.deck = applyOp(state.deck, op)
scheduleSave()
@@ -199,7 +265,7 @@ function execOp(op: Op, opts?: { coalesceKey?: string }): void {
// 执行正向
state.deck = applyOp(state.deck, op)
// 推入历史
const entry: HistoryEntry = { forward: op, backward }
const entry: HistoryEntry = { forward: op, backward, group: opts?.group }
hist.push(entry)
if (hist.length > HIST_MAX) hist.shift()
future.length = 0
@@ -229,6 +295,18 @@ function migrateDeck(d: any): Deck {
}
/* ---------- 初始化 ---------- */
/** 工作区 deck 是否有用户内容(区别于示例/空白):非示例且有元素,或页数>1 */
function hasWorkDeck(): boolean {
const d = state.deck
if (!d || !d.slides?.length) return false
const sampleTitle = SAMPLE_DECK.slides[0]?.elements.find(e => e.type === 'title')?.content
const firstTitle = d.slides[0]?.elements.find(e => e.type === 'title')?.content
const isSample = d.slides.length === SAMPLE_DECK.slides.length && sampleTitle && firstTitle === sampleTitle
if (isSample) return false
return d.slides.length > 1 || d.slides.some(s => s.elements.length > 0)
}
function init() {
let loaded: Deck | null = null
try { loaded = JSON.parse(localStorage.getItem(LS_KEY) || 'null') } catch (e) { /* ignore */ }
@@ -285,10 +363,114 @@ function setCurrentIndex(i: number) {
i = Math.max(0, Math.min(state.deck.slides.length - 1, i))
if (i === state.currentIndex) return
state.currentIndex = i
state.selectedId = null
state.selectedId = null; state.selectedIds = []
scheduleSave()
}
function selectElement(id: string | null) { state.selectedId = id || null }
function selectElement(id: string | null) { state.selectedId = id || null; state.selectedIds = [] }
/* ---------- 多选(框选 / Shift 加选 / Ctrl+A) ---------- */
const selectedIds = computed(() => state.selectedIds)
/* ---------- 批注选中(纯 UI 态) ---------- */
const selectedAnnoId = computed(() => state.selectedAnnoId)
function setSelectedAnnoId(id: string | null) { state.selectedAnnoId = id }
/** 有效多选集:过滤掉已不存在的元素,非空(length>1)时表示多选态 */
function getSelection(): string[] {
const s = currentSlide.value
if (!s || !state.selectedIds.length) return []
return state.selectedIds.filter(id => s.elements.some(e => e.id === id))
}
function selectMany(ids: string[]) {
state.selectedIds = [...ids]
state.selectedId = ids.length ? ids[ids.length - 1] : null
}
/** Shift+点击:在多选集中加入/移除某元素 */
function toggleSelect(id: string) {
const cur = [...state.selectedIds]
const i = cur.indexOf(id)
if (i >= 0) cur.splice(i, 1)
else cur.push(id)
state.selectedIds = cur
state.selectedId = cur.length ? cur[cur.length - 1] : null
}
function selectAllElements() {
const s = currentSlide.value
if (!s) return
state.selectedIds = s.elements.map(e => e.id)
state.selectedId = state.selectedIds.length ? state.selectedIds[0] : null
}
/** 多元素整体位移(框选拖动);每个元素独立 op 但同批 undo */
function moveElementsBy(ids: string[], dx: number, dy: number) {
const group = beginBatch()
for (const id of ids) {
const el = findElement(id)
if (!el) continue
const x = Math.max(0, Math.min(100 - el.w, el.x + dx))
const y = Math.max(0, Math.min(100 - el.h, el.y + dy))
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: id, patch: { x, y }, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
}
}
/** 多选对齐:left左对齐 hcenter水平居中 right右对齐 top顶对齐 vmiddle垂直居中 bottom底对齐 */
function alignElements(ids: string[], edge: 'left' | 'hcenter' | 'right' | 'top' | 'vmiddle' | 'bottom') {
const els = ids.map(findElement).filter(Boolean) as SlideElement[]
if (els.length < 2) return
const group = beginBatch()
const minX = Math.min(...els.map(e => e.x))
const maxX = Math.max(...els.map(e => e.x + e.w))
const minY = Math.min(...els.map(e => e.y))
const maxY = Math.max(...els.map(e => e.y + e.h))
for (const el of els) {
let x = el.x, y = el.y
if (edge === 'left') x = minX
if (edge === 'right') x = maxX - el.w
if (edge === 'hcenter') x = (minX + maxX) / 2 - el.w / 2
if (edge === 'top') y = minY
if (edge === 'bottom') y = maxY - el.h
if (edge === 'vmiddle') y = (minY + maxY) / 2 - el.h / 2
if (x !== el.x || y !== el.y) {
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: el.id, patch: { x, y }, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
}
}
}
/** 多选分布:h 水平等间距分布(x 方向)v 垂直等间距分布(y 方向);首尾不动,中间均匀排布 */
function distributeElements(ids: string[], axis: 'h' | 'v') {
const els = ids.map(findElement).filter(Boolean) as SlideElement[]
if (els.length < 3) return
const group = beginBatch()
const sorted = axis === 'h' ? [...els].sort((a, b) => a.x - b.x) : [...els].sort((a, b) => a.y - b.y)
const first = sorted[0], last = sorted[sorted.length - 1]
let gapTotal: number
if (axis === 'h') {
// last.x - first.x - first.w 为首尾间总空隙(含中间元素占的宽),再减中间元素总宽 = 纯空隙
gapTotal = (last.x - first.x - first.w) - sorted.slice(1, -1).reduce((s, e) => s + e.w, 0)
} else {
gapTotal = (last.y - first.y - first.h) - sorted.slice(1, -1).reduce((s, e) => s + e.h, 0)
}
const gap = gapTotal / (sorted.length - 1)
let cur = axis === 'h' ? first.x + first.w + gap : first.y + first.h + gap
for (let i = 1; i < sorted.length - 1; i++) {
const el = sorted[i]
const patch = axis === 'h' ? { x: cur, y: el.y } : { x: el.x, y: cur }
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: el.id, patch, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
cur += (axis === 'h' ? el.w : el.h) + gap
}
}
/** 批量删除(同批 undo) */
function delElements(ids: string[]) {
const group = beginBatch()
for (const id of ids) {
if (findElement(id)) delElement(id)
}
void group
}
/* ---------- 主题 ---------- */
function setTheme(name: string) {
@@ -302,7 +484,7 @@ function addSlide(atIndex?: number) {
const idx = atIndex != null ? atIndex + 1 : state.deck.slides.length
execOp({ type: 'add_slide', atIndex: idx, slide: s, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = idx
state.selectedId = null
state.selectedId = null; state.selectedIds = []
return s
}
@@ -313,7 +495,7 @@ function dupSlide() {
const idx = state.currentIndex + 1
execOp({ type: 'add_slide', atIndex: idx, slide: copy, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = idx
state.selectedId = null
state.selectedId = null; state.selectedIds = []
}
function delSlide(i?: number): boolean {
@@ -322,7 +504,7 @@ function delSlide(i?: number): boolean {
const deletedSlide = clone(state.deck.slides[idx])
execOp({ type: 'del_slide', index: idx, deletedSlide, clientId: CLIENT_ID, timestamp: Date.now() })
if (state.currentIndex >= state.deck.slides.length) state.currentIndex = state.deck.slides.length - 1
state.selectedId = null
state.selectedId = null; state.selectedIds = []
return true
}
@@ -337,10 +519,17 @@ function setSlideBackground(bg: string) {
execOp({ type: 'set_slide_bg', index: state.currentIndex, bg: bg as Slide['background'], clientId: CLIENT_ID, timestamp: Date.now() })
}
function setSlideNote(note: string) {
execOp(
{ type: 'set_slide_note', index: state.currentIndex, note, clientId: CLIENT_ID, timestamp: Date.now() },
{ coalesceKey: 'slide-note:' + state.currentIndex }
)
}
/* ---------- 元素 CRUD ---------- */
function addElement(type: SlideElement['type'], over?: Parameters<typeof createElement>[1]) {
function addElement(type: SlideElement['type'], over?: Parameters<typeof createElement>[1], group?: string) {
const el = createElement(type, over)
execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() })
execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
state.selectedId = el.id
return el
}
@@ -350,41 +539,162 @@ function updateElement(id: string, patch: Partial<SlideElement> & { style?: Reco
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: id, patch, clientId: CLIENT_ID, timestamp: Date.now() }, { coalesceKey })
}
/* ---------- 批注(annotation):挂在 element.style.annotations,走 updateElement ---------- */
function addAnnotation(elId: string) {
const el = findElement(elId)
if (!el) return
const list: Annotation[] = [...(el.style.annotations || [])]
const anno: Annotation = {
id: uid('an'),
text: '',
bx: Math.min(90, (el.x || 0) + (el.w || 20) + 4), by: Math.max(2, el.y || 10),
bw: 22, bh: 12,
ax: 50, ay: 50,
}
list.push(anno)
updateElement(elId, { style: { annotations: list } })
return anno.id
}
function updateAnnotation(elId: string, annoId: string, patch: Partial<Annotation>) {
const el = findElement(elId)
if (!el) return
const list = (el.style.annotations || []).map(a => {
if (a.id !== annoId) return a
const next: Annotation = { ...a, ...patch }
if (patch.line) next.line = { ...(a.line || {}), ...patch.line }
return next
})
updateElement(elId, { style: { annotations: list } })
}
function delAnnotation(elId: string, annoId: string) {
const el = findElement(elId)
if (!el) return
const list = (el.style.annotations || []).filter(a => a.id !== annoId)
updateElement(elId, { style: { annotations: list } })
}
function delElement(id: string) {
const el = findElement(id)
const deletedElement = el ? clone(el) : undefined
execOp({ type: 'del_element', slideIdx: state.currentIndex, elementId: id, deletedElement, clientId: CLIENT_ID, timestamp: Date.now() })
if (state.selectedId === id) state.selectedId = null
if (state.selectedId === id) { state.selectedId = null; state.selectedIds = [] }
}
function moveElementZ(id: string, dir: number) {
execOp({ type: 'move_element_z', slideIdx: state.currentIndex, elementId: id, dir, clientId: CLIENT_ID, timestamp: Date.now() })
}
/* ---------- 元素剪贴板(内存级,跨页可用,不持久化;支持多元素) ---------- */
let clipboard: SlideElement[] = []
function copyElements(ids: string[]): boolean {
const els = ids.map(findElement).filter(Boolean) as SlideElement[]
if (!els.length) return false
clipboard = clone(els)
pasteCount = 0
return true
}
function copyElement(id: string): boolean {
return copyElements([id])
}
/** 剪切 = 复制 + 删除(删除走 op,可撤销;撤销后剪贴板仍保留内容) */
function cutElements(ids: string[]): boolean {
if (!copyElements(ids)) return false
delElements(ids)
return true
}
function cutElement(id: string): boolean {
return cutElements([id])
}
/** 粘贴到当前页:新 id、位置阶梯偏移错开原位;连续粘贴合并为一条 undo */
let pasteCount = 0
let pasteTimer: ReturnType<typeof setTimeout> | null = null
let pasteGroup: string | null = null
function pasteElements(): boolean {
if (!clipboard.length) return false
pasteCount++
const off = (pasteCount - 1) * 3
// 连续粘贴(2s 内)共用同一 group → 一次 Ctrl+Z 整批撤销
if (!pasteTimer) pasteGroup = beginBatch()
else { clearTimeout(pasteTimer) }
pasteTimer = setTimeout(() => { pasteTimer = null; pasteGroup = null }, 2000)
const newIds: string[] = []
for (const src of clipboard) {
const el = createElement(src.type, {
...src,
id: uid('el'),
x: Math.min(97, Math.max(0, src.x + off)),
y: Math.min(97, Math.max(0, src.y + off)),
content: src.content,
segments: src.segments ? clone(src.segments) : undefined,
style: { ...src.style }
})
execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() }, { group: pasteGroup! })
newIds.push(el.id)
}
state.selectedIds = newIds
state.selectedId = newIds.length ? newIds[newIds.length - 1] : null
return true
}
function pasteElement(): boolean {
return pasteElements()
}
function hasClipboard(): boolean { return clipboard.length > 0 }
function findElement(id: string): SlideElement | null {
const s = currentSlide.value
return s.elements.find(e => e.id === id) || null
}
/* ---------- undo / redo(基于 Op 回滚) ---------- */
/* ---------- undo / redo(基于 Op 回滚,同 group 批次整体回滚) ---------- */
let batchCounter = 0
/** 开始一个操作批次:期间所有 execOp 记录同 group 标记,undo 时整组回滚 */
function beginBatch(): string {
return 'batch-' + (++batchCounter) + '-' + Date.now()
}
function undo(): boolean {
if (!hist.length) return false
const entry = hist.pop()!
// 弹出栈顶;若带 group 标记,同组的全部一起弹出(整批回滚)
const popped: HistoryEntry[] = []
do {
popped.push(hist.pop()!)
} while (hist.length > 0 && hist[hist.length - 1].group != null && hist[hist.length - 1].group === popped[0].group)
suppressHistory = true
state.deck = applyOp(state.deck, entry.backward)
// popped 按执行顺序倒排(栈顶=最后执行在最前),撤回时后执行的先撤
for (const e of popped) {
state.deck = applyOp(state.deck, e.backward)
}
suppressHistory = false
future.push(entry)
for (let i = popped.length - 1; i >= 0; i--) future.push(popped[i])
scheduleSave()
return true
}
function redo(): boolean {
if (!future.length) return false
const entry = future.pop()!
// 同组的一起重做
const popped: HistoryEntry[] = []
const headGroup = future[future.length - 1].group
do {
popped.push(future.pop()!)
} while (future.length > 0 && headGroup != null && future[future.length - 1].group === headGroup)
suppressHistory = true
state.deck = applyOp(state.deck, entry.forward)
// popped[0] 是最先执行的(undo 时最后压入 future),重做按原顺序:倒序遍历
for (let i = popped.length - 1; i >= 0; i--) {
state.deck = applyOp(state.deck, popped[i].forward)
}
suppressHistory = false
hist.push(entry)
for (let i = popped.length - 1; i >= 0; i--) hist.push(popped[i])
scheduleSave()
return true
}
@@ -415,7 +725,7 @@ function replaceDeck(newDeck: Deck | Record<string, unknown>, opts?: { keepHisto
state.activeLibId = null
state.currentIndex = Math.min(state.currentIndex, d.slides.length - 1)
if (state.currentIndex < 0) state.currentIndex = 0
state.selectedId = null
state.selectedId = null; state.selectedIds = []
}
function replaceSlide(index: number, newSlide: Slide) {
@@ -423,17 +733,17 @@ function replaceSlide(index: number, newSlide: Slide) {
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
const oldSlide = state.deck.slides[index] ? clone(state.deck.slides[index]) : undefined
execOp({ type: 'replace_slide', index, slide: newSlide, oldSlide, clientId: CLIENT_ID, timestamp: Date.now() })
state.selectedId = null
state.selectedId = null; state.selectedIds = []
}
function appendSlide(newSlide: Slide) {
function appendSlide(newSlide: Slide, group?: string) {
newSlide.id = uid('s')
newSlide.elements = newSlide.elements || []
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
const idx = state.deck.slides.length
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
state.currentIndex = idx
state.selectedId = null
state.selectedId = null; state.selectedIds = []
}
function insertSlideAt(index: number, newSlide: Slide) {
@@ -442,7 +752,7 @@ function insertSlideAt(index: number, newSlide: Slide) {
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
execOp({ type: 'add_slide', atIndex: index, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = index
state.selectedId = null
state.selectedId = null; state.selectedIds = []
}
function reset() {
@@ -475,19 +785,43 @@ function getCfg(): AiCfg {
}
function setCfg(cfg: AiCfg) {
localStorage.setItem(LS_CFG, JSON.stringify({ ...DEFAULT_CFG, ...cfg }))
safeSet(LS_CFG, JSON.stringify({ ...DEFAULT_CFG, ...cfg }))
}
/* ---------- 自定义主题:AI 配色建议应用后存储 ---------- */
const CUSTOM_THEME_KEY = 'u-ppt.themes.v1'
/** 内置 + 自定义主题全量返回(自定义优先在前) */
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 }
/* ---------- 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: '',
viewerBase: ''
}
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> {
try { return JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) || '{}') || {} } catch (e) { return {} }
@@ -562,7 +896,7 @@ function addSlideFromTemplate(tplId: string): boolean {
const idx = state.currentIndex + 1
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = idx
state.selectedId = null
state.selectedId = null; state.selectedIds = []
return true
}
@@ -578,7 +912,7 @@ function getLibrary(): LibItem[] {
try { return JSON.parse(localStorage.getItem(LIB_KEY) || '[]') || [] } catch (e) { return [] }
}
function setLibrary(arr: LibItem[]) {
try { localStorage.setItem(LIB_KEY, JSON.stringify(arr)) } catch (e) {}
safeSet(LIB_KEY, JSON.stringify(arr))
}
function getActiveLibId() { return state.activeLibId }
function setActive(id: string | null) {
@@ -664,9 +998,15 @@ function getChat(chatId: string): ChatMessage[] {
try { return JSON.parse(localStorage.getItem(CHAT_PREFIX + chatId) || '[]') || [] } catch (e) { return [] }
}
/** 写入某个 chatId 的会话 */
/** 写入某个 chatId 的会话(超 1.5MB 时从最旧开始丢弃,防写满 localStorage) */
function setChat(chatId: string, msgs: ChatMessage[]) {
try { localStorage.setItem(CHAT_PREFIX + chatId, JSON.stringify(msgs)) } catch (e) {}
let list = msgs
while (list.length > 2) {
const size = JSON.stringify(list).length
if (size <= 1_500_000) break
list = list.slice(2) // 成对丢弃(user+assistant)
}
safeSet(CHAT_PREFIX + chatId, JSON.stringify(list))
}
/** 清除某个 chatId 的会话 */
@@ -691,6 +1031,7 @@ function recoverBackup(): boolean {
export const store = {
// 状态(响应式)
state,
saveWarning,
// computed
deck, slides, count, currentIndex, theme, selectedId, activeLibId,
currentSlide, canUndo, canRedo,
@@ -704,16 +1045,26 @@ export const store = {
// 主题
setTheme,
// 幻灯片 CRUD
addSlide, dupSlide, delSlide, moveSlide, setSlideBackground,
addSlide, dupSlide, delSlide, moveSlide, setSlideBackground, setSlideNote,
// 元素 CRUD
addElement, updateElement, delElement, moveElementZ, findElement,
// 批注
addAnnotation, updateAnnotation, delAnnotation,
// 元素剪贴板
copyElement, copyElements, cutElement, cutElements, pasteElement, pasteElements, hasClipboard,
// 多选
selectedIds, getSelection, selectMany, toggleSelect, selectAllElements,
selectedAnnoId, setSelectedAnnoId,
moveElementsBy, delElements, alignElements, distributeElements,
// undo/redo
undo, redo,
undo, redo, beginBatch,
// 整体替换
replaceDeck, replaceSlide, appendSlide, insertSlideAt,
reset, exportJSON, importJSON,
// AI 配置
getCfg, setCfg,
// OSS 配置
getOssCfg, setOssCfg, rewriteAssetRef,
// 文库
getLibrary, getActiveLibId, saveToLibrary, loadFromLibrary,
renameInLibrary, deleteFromLibrary, duplicateInLibrary, newBlankDeck,
@@ -723,6 +1074,8 @@ export const store = {
getTemplates, getUserTemplates, saveCurrentAsTemplate, addSlideFromTemplate, deleteTemplate,
// 会话管理
getChatId, getChat, setChat, clearChat, newChatId,
// 暂存检测
hasWorkDeck,
// 备份恢复
recoverBackup,
}
+73 -3
View File
@@ -17,7 +17,7 @@ export type ColorKey = 'primary' | 'accent' | 'text' | 'muted' | (string & {})
/** 元素类型 */
export type ElementType =
| 'title' | 'text' | 'list' | 'stat' | 'quote'
| 'image' | 'shape' | 'chart' | 'card'
| 'image' | 'video' | 'shape' | 'chart' | 'card'
| 'table' | 'code' | 'formula'
/** 图表子类型 */
@@ -31,7 +31,9 @@ export type AnimType =
| 'pop' | 'rotate' | 'bounce' | 'flip' | 'blur'
/** 形状类型 */
export type ShapeType = 'rect' | 'circle' | 'triangle'
export type ShapeType =
| 'rect' | 'circle' | 'ellipse' | 'triangle' | 'diamond'
| 'pentagon' | 'hexagon' | 'star' | 'arrow' | 'chevron' | 'bubble'
/** 主题对象 */
export interface Theme {
@@ -44,6 +46,26 @@ export interface Theme {
muted: string
}
/** 批注(连线 + 气泡) */
export interface Annotation {
id: string
text: string
/** 气泡框:百分比坐标,与元素同坐标系(画布 1280×720) */
bx: number; by: number; bw: number; bh: number
/** 图片侧连线锚点(相对图片框的百分比 0~100),默认 50/50 */
ax?: number; ay?: number
/** 气泡文字整体样式 */
fontSize?: number; color?: ColorKey; bold?: boolean; italic?: boolean; align?: 'left' | 'center' | 'right'
/** 连线样式 */
line?: {
style?: 'solid' | 'dashed'
width?: number
color?: ColorKey
startCap?: 'none' | 'arrow' | 'dot'
endCap?: 'none' | 'arrow' | 'dot'
}
}
/** 元素样式(所有字段可选,按 type 决定哪些有效) */
export interface ElementStyle {
fontSize?: number
@@ -58,6 +80,11 @@ export interface ElementStyle {
labelSize?: number
// image
fit?: 'contain' | 'cover'
// video
poster?: string
autoplay?: boolean
loop?: boolean
muted?: boolean
// shape
shapeType?: ShapeType
fill?: ColorKey
@@ -85,6 +112,11 @@ export interface ElementStyle {
// card
icon?: string
accent?: ColorKey
// quote
/** 装饰引号由渲染层伪元素绘制(content 不含引号字符) */
decoQuote?: boolean
// annotation(批注气泡,image 起步,未来任意元素)
annotations?: Annotation[]
}
/** 幻灯片元素 */
@@ -106,6 +138,7 @@ export interface Slide {
id: string
background: BgKey
elements: SlideElement[]
note?: string // 演讲者备注/页脚批注(不渲染到幻灯片)
}
/** 一份演示文稿 */
@@ -137,6 +170,8 @@ export interface PageTemplate {
name: string
/** 模板分类:built-in / user */
category: 'built-in' | 'user'
/** 展示分组:basic 基础 / content 内容 / data 数据 / ending 收尾(缺省按内容归组) */
group?: 'basic' | 'content' | 'data' | 'ending'
background: BgKey
/** 元素结构(内容可为占位符如「标题」「正文」) */
elements: SlideElement[]
@@ -155,6 +190,39 @@ export interface AiCfg {
imgBase?: string
imgKey?: 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
}
/** 文库条目 */
@@ -168,10 +236,12 @@ export interface LibItem {
/** AI 返回的操作 */
export interface AiOp {
action: 'create_all' | 'add_page' | 'update_page' | 'answer'
action: 'create_all' | 'add_page' | 'update_page' | 'answer' | 'outline' | 'gen_page'
slides: Slide[]
target: number | null // 1-based 页码
note: string
/** action=outline 时携带的大纲 */
outline?: Outline
}
/** 聊天消息 */
+4
View File
@@ -5,8 +5,12 @@ import './styles/chat.css'
import { createApp } from 'vue'
import App from './App.vue'
import { store } from './core/store'
import { hydrate } from './core/assets'
// 初始化 store(加载本地数据)
store.init()
// 资产库 hydrate:重建离线暂存资产的可渲染 URL,并监听联网自动同步
void hydrate()
createApp(App).mount('#app')
+35 -3
View File
@@ -10,8 +10,8 @@
--ui-hover: #f1f5f9; /* 悬停 */
--ui-text: #1e293b; /* 主文字 */
--ui-muted: #64748b; /* 次要文字 */
--ui-primary: #4f46e5; /* 主操作 */
--ui-primary-soft: #eef2ff;
--ui-primary: #5b5bd6; /* 主操作(与 app-site 品牌色一致) */
--ui-primary-soft: #eeeefc;
--ui-danger: #e11d48;
--ui-success: #059669;
--radius: 10px;
@@ -34,10 +34,24 @@ body {
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
/* ===== PDF 导出 / 打印 ===== */
/* A4 横向满幅:幻灯片 16:9 与 297×209.8mm 同构,元素 % 坐标原样映射 */
@media print {
@page { size: A4 landscape; margin: 0; }
body { overflow: visible !important; height: auto !important; }
html { height: auto !important; }
}
h1, h2, h3, h4, h5, h6, p { margin: 0; }
button { font-family: inherit; cursor: pointer; }
input, select, textarea { font-family: inherit; font-size: inherit; color: inherit; }
/* 键盘导航可见性:Tab 聚焦统一主色描边(鼠标点击不触发) */
:focus-visible {
outline: 2px solid var(--ui-primary);
outline-offset: 1px;
}
.hidden { display: none !important; }
.muted { color: var(--ui-muted); }
@@ -55,7 +69,7 @@ input, select, textarea { font-family: inherit; font-size: inherit; color: inher
.btn:hover { background: var(--ui-hover); }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--ui-primary); border-color: var(--ui-primary); color: #fff; }
.btn.primary:hover { background: #4338ca; }
.btn.primary:hover { background: #4a4ac4; }
.btn.ghost { background: transparent; border-color: transparent; color: var(--ui-muted); }
.btn.ghost:hover { background: var(--ui-hover); color: var(--ui-text); }
.btn.danger { background: var(--ui-danger); border-color: var(--ui-danger); color: #fff; }
@@ -97,6 +111,24 @@ input[type="range"]::-webkit-slider-thumb {
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ---------- 全局拖放遮罩 ---------- */
.global-drop-overlay {
position: fixed; inset: 0; z-index: 9000;
background: rgba(15, 23, 42, .55); backdrop-filter: blur(2px);
display: flex; align-items: center; justify-content: center;
pointer-events: none; /* 不拦截 drop,让 window 接管 */
}
.global-drop-card {
display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: 36px 48px; border-radius: 16px;
border: 2px dashed rgba(255, 255, 255, .65);
background: rgba(255, 255, 255, .08);
color: #fff; text-align: center;
}
.global-drop-card .global-drop-icon { display: inline-flex; color: rgba(255, 255, 255, .9); }
.global-drop-card strong { font-size: 18px; }
.global-drop-card .global-drop-hint { font-size: 13px; opacity: .75; }
/* ---------- 弹窗 ---------- */
.modal-mask {
position: fixed; inset: 0; background: rgba(15, 23, 42, .45);
+43 -12
View File
@@ -19,9 +19,9 @@
white-space: nowrap;
}
.ai-action:hover {
border-color: var(--ui-primary, #4f46e5);
color: var(--ui-primary, #4f46e5);
background: var(--ui-primary-soft, rgba(79,70,229,.06));
border-color: var(--ui-primary, #5b5bd6);
color: var(--ui-primary, #5b5bd6);
background: var(--ui-primary-soft, rgba(91,91,214,.06));
}
.ai-action:disabled { opacity: .45; cursor: not-allowed; }
.ai-action:disabled:hover { border-color: var(--ui-border, #e2e8f0); color: var(--ui-text, #1e293b); background: #fff; }
@@ -39,14 +39,14 @@
.selected-hint {
display: flex; align-items: center; gap: .45em;
padding: 7px 10px; margin: 0 2px 8px;
background: var(--ui-primary-soft, rgba(79,70,229,.08));
border: 1px solid var(--ui-primary, #4f46e5); border-radius: 6px;
background: var(--ui-primary-soft, rgba(91,91,214,.08));
border: 1px solid var(--ui-primary, #5b5bd6); border-radius: 6px;
font-size: 12px; color: var(--ui-text, #1e293b);
flex-shrink: 0;
}
.selected-hint-icon { color: var(--ui-primary, #4f46e5); font-weight: 700; }
.selected-hint-icon { color: var(--ui-primary, #5b5bd6); font-weight: 700; }
.selected-hint-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.selected-hint-flag { font-size: 11px; color: var(--ui-primary, #4f46e5); flex-shrink: 0; }
.selected-hint-flag { font-size: 11px; color: var(--ui-primary, #5b5bd6); flex-shrink: 0; }
/* 消息列表 */
.chat-messages {
@@ -73,6 +73,31 @@
.msg .bubble .stream-text { white-space: pre-wrap; }
/* Markdown 正文排版(marked 渲染后的气泡内容) */
.msg .bubble .md-body > *:first-child { margin-top: 0; }
.msg .bubble .md-body > *:last-child { margin-bottom: 0; }
.msg .bubble .md-body p { margin: 6px 0; }
.msg .bubble .md-body h1, .msg .bubble .md-body h2, .msg .bubble .md-body h3,
.msg .bubble .md-body h4 { margin: 10px 0 6px; font-size: 14px; font-weight: 600; }
.msg .bubble .md-body h1 { font-size: 16px; }
.msg .bubble .md-body h2 { font-size: 15px; }
.msg .bubble .md-body ul, .msg .bubble .md-body ol { margin: 6px 0; padding-left: 1.4em; }
.msg .bubble .md-body li { margin: 3px 0; }
.msg .bubble .md-body strong { font-weight: 600; }
.msg .bubble .md-body code {
font-family: 'Consolas','Monaco','Courier New',monospace;
font-size: 12px; padding: 1px 5px; border-radius: 4px;
background: rgba(15,23,42,.08);
}
.msg.user .md-body code { background: rgba(255,255,255,.2); }
.msg .bubble .md-body blockquote {
margin: 6px 0; padding: 2px 10px; border-left: 3px solid var(--ui-border);
color: var(--ui-muted);
}
.msg .bubble .md-body table { border-collapse: collapse; margin: 6px 0; font-size: 12px; }
.msg .bubble .md-body th, .msg .bubble .md-body td { border: 1px solid var(--ui-border); padding: 4px 8px; }
.msg .bubble .md-body hr { border: none; border-top: 1px solid var(--ui-border); margin: 8px 0; }
/* 消息中的代码块 */
.msg .msg-code {
margin: 6px 0; padding: 10px 12px;
@@ -84,17 +109,23 @@
.msg.user .msg-code { background: rgba(255,255,255,.15); }
.msg .msg-code code { font-family: inherit; white-space: pre; }
/* JSON 语法着色 */
.msg .msg-code .jk { color: #4f46e5; font-weight: 600; } /* key */
.msg .msg-code .jk { color: #5b5bd6; font-weight: 600; } /* key */
.msg .msg-code .js { color: #059669; } /* string value */
.msg .msg-code .jn { color: #d97706; } /* number */
.msg .msg-code .jb { color: #e11d48; font-weight: 600; } /* boolean/null */
/* 流式打字机光标(参考 devflow:细竖线 + 柔和步进闪烁) */
.cursor {
display: inline-block; width: 7px; height: 1em;
background: currentColor; vertical-align: -2px; margin-left: 2px;
animation: blink 1s steps(2, start) infinite;
display: inline-block;
width: 2px; height: 14px;
background: var(--ui-primary, #5b5bd6);
margin-left: 2px; vertical-align: text-bottom;
animation: blink 0.8s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes blink { 50% { opacity: 0; } }
/* 输入栏 */
.chat-input-bar {
+162 -8
View File
@@ -72,7 +72,7 @@
}
.canvas-frame {
position: relative;
width: min(100%, calc((100vh - 54px - 56px) * 16 / 9));
width: min(100%, calc((100vh - 54px - 116px) * 16 / 9));
max-width: 100%;
aspect-ratio: 16 / 9;
box-shadow: var(--shadow-md); border-radius: 4px; overflow: hidden;
@@ -83,7 +83,78 @@
width: 1280px; height: 720px;
transform-origin: top left;
}
.canvas-status { margin-top: 10px; font-size: 12px; color: var(--ui-muted); }
.canvas-footer {
margin-top: 10px;
flex: none;
width: min(100%, calc((100vh - 54px - 116px) * 16 / 9));
max-width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.canvas-status { font-size: 12px; color: var(--ui-muted); flex: none; }
.note-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
height: 26px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 13px;
background: transparent;
color: var(--ui-muted);
font-size: 12px;
cursor: pointer;
transition: background .15s, color .15s, border-color .15s;
}
.note-toggle:hover {
background: var(--ui-primary-soft);
color: var(--ui-primary);
}
.note-toggle.active {
color: var(--ui-primary);
border-color: var(--ui-border);
background: var(--ui-panel);
}
.note-toggle-icon { font-size: 13px; line-height: 1; }
.note-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--ui-primary);
}
.canvas-note {
margin-top: 8px;
flex: none;
/* 与 .canvas-frame 同宽,保持对齐 */
width: min(100%, calc((100vh - 54px - 116px) * 16 / 9));
max-width: 100%;
}
.canvas-note-input {
display: block;
width: 100%;
box-sizing: border-box;
resize: vertical;
min-height: 44px;
max-height: 120px;
padding: 8px 12px;
border: 1px solid var(--ui-border);
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
color: var(--ui-text);
background: var(--ui-panel);
font-family: inherit;
outline: none;
transition: border-color .15s, box-shadow .15s;
}
.canvas-note-input:focus {
border-color: var(--ui-primary);
box-shadow: 0 0 0 3px var(--ui-primary-soft);
}
.canvas-note-input::placeholder {
color: var(--ui-muted);
}
/* ---------- 画布元素 ---------- */
.el { position: absolute; overflow: hidden; }
@@ -96,6 +167,21 @@
.el[data-type="quote"] { align-items: center; justify-content: center; }
.el[data-type="title"], .el[data-type="quote"] { font-weight: 700; }
.el[data-type="quote"] { font-style: italic; }
/* quote 装饰引号:CSS 伪元素绘制优雅弯引号,替代内容里 LLM 给的反引号/直引号怪字符。
serif 字体栈 + font-style:normal——Windows YaHei 无 italic 字形,合成斜切会把弯引号压成 // 状畸变 */
.el-quote-pretty { position: relative; padding-left: 1.1em; padding-right: 1.1em; font-family: Georgia, 'Times New Roman', 'Songti SC', serif; font-style: normal; }
.el-quote-pretty::before {
content: '\201C';
position: absolute; left: 0; top: -.08em;
font-size: 1.8em; line-height: 1; font-style: normal;
opacity: .25; font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
}
.el-quote-pretty::after {
content: '\201D';
position: absolute; right: 0; bottom: -.35em;
font-size: 1.8em; line-height: 1; font-style: normal;
opacity: .25; font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
}
.el-text { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; }
.el-list { width: 100%; height: 100%; display: flex; flex-direction: column; gap: .3em; justify-content: center; }
.el-list .li { position: relative; padding-left: 1.1em; }
@@ -103,11 +189,58 @@
content: ""; position: absolute; left: 0; top: .55em;
width: .35em; height: .35em; border-radius: 50%; background: currentColor;
}
/* 行首自带编号/符号(①、1.、- 等)时不再画圆点,避免双重标记 */
.el-list .li.no-marker::before { display: none; }
.el-list .li.no-marker { padding-left: 0; }
.el-stat { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.el-stat .num { font-weight: 800; line-height: 1; }
.el-stat .label { margin-top: .35em; text-align: center; }
.el-shape { width: 100%; height: 100%; }
/* 形状文字层:覆盖在图形上方、居中;颜色默认白(形状为填充色底),字号/粗斜继承 style。
padding 稍大:clip-path 多边形(箭头/星形等)边缘收窄,文字留出安全区 */
.el-shape-text {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 8% 12%;
box-sizing: border-box;
white-space: pre-wrap;
word-break: break-word;
text-align: center;
outline: none;
}
/* rect/椭圆这类满幅形状,文字安全区可以小 */
.el[data-type="shape"][data-shape="rect"] > .el-shape-text,
.el[data-type="shape"][data-shape="circle"] > .el-shape-text,
.el[data-type="shape"][data-shape="ellipse"] > .el-shape-text {
padding: 6px 10px;
}
/* 空形状不显示文字占位:装饰形状(分隔条/小三角)保持纯净,
加文字的引导由属性面板「内容」输入框承担 */
.el-image { width: 100%; height: 100%; object-fit: cover; }
.el-video { width: 100%; height: 100%; background: #000; border-radius: 8px; display: block; }
/* 视频缩略图降级:不加载视频,poster 静态图 / 黑底▶占位 */
.el-video-thumb {
width: 100%; height: 100%;
background: #0f172a;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
overflow: hidden;
}
.el-video-thumb img { width: 100%; height: 100%; object-fit: cover; }
.el-video-thumb-play { color: rgba(255,255,255,.75); font-size: 28px; }
/* 空图片元素占位(未填 content 时不渲染裂图) */
.el-image-empty {
width: 100%; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
border: 2px dashed rgba(100, 116, 139, .45); border-radius: 10px;
background: rgba(148, 163, 184, .08); color: var(--ui-muted, #64748b);
text-align: center; padding: 8px;
}
.el-image-empty-icon { font-size: 28px; opacity: .7; }
.el-image-empty-text { font-size: 12px; line-height: 1.5; }
.el-chart { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; gap: .5em; }
.el-chart .bar-row { display: flex; align-items: center; gap: .6em; font-size: 14px; }
.el-chart .bar-label { width: 26%; flex-shrink: 0; }
@@ -119,12 +252,17 @@
.el-chart svg { width: 100%; height: 100%; }
.el-chart .chart-text { fill: currentColor; font-family: inherit; }
.el-chart.chart-pie { position: relative; display: flex; align-items: center; }
/* 饼图图例:不再 absolute 覆盖在图上,走 flex 流式排在图右侧不遮挡;小容器紧凑换行 */
.el-chart .pie-legend {
position: absolute; right: 0; top: 50%; transform: translateY(-50%);
flex: none;
display: flex; flex-direction: column; gap: .3em; font-size: 13px;
max-width: 40%;
justify-content: center;
}
.el-chart .pie-legend-item { display: flex; align-items: center; gap: .4em; }
.el-chart .pie-legend-item { display: flex; align-items: center; gap: .4em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.el-chart .pie-legend-dot { width: .8em; height: .8em; border-radius: 2px; flex-shrink: 0; }
/* 极小容器(缩略图/窄卡片):图例换行铺底,避免侧排挤压图形 */
.el-chart.chart-pie { flex-wrap: wrap; }
/* ===== 表格 ===== */
.el-table {
@@ -137,7 +275,7 @@
overflow: hidden; word-break: break-word;
}
.el-table th {
font-weight: 700; color: var(--ui-primary, #4f46e5);
font-weight: 700; color: var(--ui-primary, #5b5bd6);
border-bottom: 2px solid rgba(100,116,139,.35);
background: rgba(100,116,139,.06);
white-space: nowrap;
@@ -182,6 +320,17 @@
/* 选中态与手柄 */
.el.selected { outline: 2px solid var(--ui-primary); outline-offset: 0; }
.el.dragging { opacity: .85; }
/* 多选态:次级高亮(锚点仍用 .selected 实线) */
.el.multi-selected { outline: 1.5px dashed color-mix(in srgb, var(--ui-primary) 70%, transparent); outline-offset: 1px; }
/* 框选矩形 */
.marquee-box {
position: absolute;
border: 1.5px dashed var(--ui-primary);
background: color-mix(in srgb, var(--ui-primary) 8%, transparent);
pointer-events: none;
z-index: 999;
}
.handle {
position: absolute; width: 10px; height: 10px;
background: #fff; border: 1.5px solid var(--ui-primary);
@@ -198,7 +347,7 @@
/* 元素文字可直接双击编辑 */
.el [contenteditable="true"] { outline: none; cursor: text; }
.el [contenteditable="true"]:focus { background: rgba(79,70,229,.06); border-radius: 2px; }
.el [contenteditable="true"]:focus { background: rgba(91,91,214,.06); border-radius: 2px; }
/* ---------- 右:侧栏 Tabs ---------- */
.side-panel { border-left: 1px solid var(--ui-border); background: var(--ui-panel); display: flex; flex-direction: column; min-height: 0; }
@@ -260,7 +409,12 @@
}
.el-card-bar { height: 6px; width: 100%; flex-shrink: 0; }
.el-card { padding: 1.1em 1.3em 1.2em; flex: 1; display: flex; flex-direction: column; gap: .5em; justify-content: flex-start; box-sizing: border-box; }
.card-icon { font-size: 1.6em; line-height: 1; margin-bottom: .1em; }
/* 卡片 emoji 图标:限定字号与行高,禁用其参与 flex 拉伸,避免大 emoji 撑破卡片布局 */
.card-icon {
font-size: 1.1em; line-height: 1.2; margin-bottom: .1em;
flex-shrink: 0; overflow: hidden; max-height: 1.4em;
font-family: 'Segoe UI Emoji', 'Apple Color Emoji', 'Noto Color Emoji', sans-serif;
}
.card-title {
font-weight: 700;
font-size: 1.5em;
@@ -326,7 +480,7 @@
border: 1px solid var(--ui-border, #e2e8f0); background: #fff;
color: var(--ui-text, #1e293b); border-radius: 4px; cursor: pointer;
}
.rich-btn:hover { border-color: var(--ui-primary, #4f46e5); color: var(--ui-primary, #4f46e5); }
.rich-btn:hover { border-color: var(--ui-primary, #5b5bd6); color: var(--ui-primary, #5b5bd6); }
.rich-btn.danger { color: var(--ui-danger, #e11d48); border-color: transparent; }
.rich-btn.danger:hover { color: var(--ui-danger, #e11d48); background: #fff1f2; }
+5 -3
View File
@@ -84,7 +84,9 @@
* renderSlide 给 slide-inner 加 .animated,并按元素顺序设 animation-delay
* data-anim 指定具体动效,未指定则默认 fade-up
* ---------------------------------------------------------------------- */
.present-stage .el { opacity: 0; }
/* 初始隐藏(入场前)。注:必须限定 .animated 上下文——若动画未挂上/被打断,
* 元素回落到此处的 opacity:0 会导致「闪现后消失」(曾因 pop/bounce 100% 帧缺 opacity 触发) */
.present-stage .slide-inner.animated .el { opacity: 0; }
.present-stage .slide-inner.animated .el { will-change: transform, opacity; }
.present-stage .slide-inner.animated .el[data-anim="fade"] { animation: ppt-fade .5s ease both; }
.present-stage .slide-inner.animated .el[data-anim="scale"] { animation: ppt-scale .6s cubic-bezier(.22,.61,.36,1) both; }
@@ -102,7 +104,7 @@
@keyframes ppt-scale { from { opacity: 0; transform: scale(.86); } to { opacity: 1; transform: none; } }
@keyframes ppt-slide-l { from { opacity: 0; transform: translateX(-52px); } to { opacity: 1; transform: none; } }
@keyframes ppt-slide-r { from { opacity: 0; transform: translateX(52px); } to { opacity: 1; transform: none; } }
@keyframes ppt-pop { 0% { opacity: 0; transform: scale(.55); } 60% { opacity: 1; transform: scale(1.06); } 100% { transform: none; } }
@keyframes ppt-pop { 0% { opacity: 0; transform: scale(.55); } 60% { opacity: 1; transform: scale(1.06); } 100% { opacity: 1; transform: none; } }
/* 扩展特效 */
.present-stage .slide-inner.animated .el[data-anim="rotate"] { animation: ppt-rotate .6s cubic-bezier(.22,.61,.36,1) both; }
@@ -111,7 +113,7 @@
.present-stage .slide-inner.animated .el[data-anim="blur"] { animation: ppt-blur .6s ease both; }
@keyframes ppt-rotate { from { opacity: 0; transform: rotate(-12deg) scale(.85); } to { opacity: 1; transform: none; } }
@keyframes ppt-bounce { 0% { opacity: 0; transform: translateY(-50px); } 55% { opacity: 1; } 72% { transform: translateY(8px); } 100% { transform: none; } }
@keyframes ppt-bounce { 0% { opacity: 0; transform: translateY(-50px); } 55% { opacity: 1; } 72% { opacity: 1; transform: translateY(8px); } 100% { opacity: 1; transform: none; } }
@keyframes ppt-flip { from { opacity: 0; transform: perspective(900px) rotateY(75deg); } to { opacity: 1; transform: none; } }
@keyframes ppt-blur { from { opacity: 0; filter: blur(14px); } to { opacity: 1; filter: none; } }
+204
View File
@@ -0,0 +1,204 @@
<!-- =====================================================================
ViewerAnnotations.vue — 分享查看器批注层(只读)
结构/几何与编辑器 AnnotationLayer 一致:SVG 连线(1280×720)+ DOM 气泡(百分比定位)
整层 pointer-events:none,观众不可交互也不挡翻页点击;空文本批注不渲染气泡
===================================================================== -->
<script setup lang="ts">
import { computed } from 'vue'
import type { Annotation, Slide, SlideElement } from '../core/types'
import { isDarkBg, resolveColor } from '../core/bg'
import { markdownToSegments, segmentsToHtml } from '../core/richtext'
const props = defineProps<{ slide: Slide }>()
const CANVAS_W = 1280
const CANVAS_H = 720
/** 当前页背景是否深色 → 文字/连线反相 */
const dark = computed(() => isDarkBg(props.slide.background || ''))
/** 扁平列表:{ el, anno },供连线与气泡渲染 */
const items = computed(() => {
const out: { el: SlideElement; anno: Annotation }[] = []
for (const el of props.slide.elements || []) {
const list = el.style?.annotations
if (list?.length) for (const anno of list) out.push({ el, anno })
}
return out
})
/**
* 射线-矩形边框求交:从矩形中心 (cx,cy) 朝目标 (tx,ty) 方向,
* 取与矩形边框(半宽 hw、半高 hh)的交点。
*/
function edgePoint(cx: number, cy: number, hw: number, hh: number, tx: number, ty: number) {
const dx = tx - cx, dy = ty - cy
if (!dx && !dy) return { x: cx, y: cy }
const sx = dx ? hw / Math.abs(dx) : Infinity
const sy = dy ? hh / Math.abs(dy) : Infinity
const s = Math.min(sx, sy)
return { x: cx + dx * s, y: cy + dy * s }
}
/** 连线几何:起点=图片边缘、终点=气泡边缘,坐标转 1280×720 用户单位;框内/重叠返回 null */
function lineGeom(el: SlideElement, anno: Annotation) {
const ex = el.x / 100 * CANVAS_W, ey = el.y / 100 * CANVAS_H
const ew = el.w / 100 * CANVAS_W, eh = el.h / 100 * CANVAS_H
const bx = anno.bx / 100 * CANVAS_W, by = anno.by / 100 * CANVAS_H
const bw = anno.bw / 100 * CANVAS_W, bh = anno.bh / 100 * CANVAS_H
const icx = ex + (anno.ax ?? 50) / 100 * ew
const icy = ey + (anno.ay ?? 50) / 100 * eh
const bcx = bx + bw / 2, bcy = by + bh / 2
if (bcx >= ex && bcx <= ex + ew && bcy >= ey && bcy <= ey + eh) return null
const dist = Math.hypot(bcx - icx, bcy - icy)
const s = edgePoint(icx, icy, ew / 2, eh / 2, bcx, bcy)
const t = edgePoint(bcx, bcy, bw / 2, bh / 2, icx, icy)
const sd = Math.hypot(s.x - icx, s.y - icy)
const td = Math.hypot(t.x - bcx, t.y - bcy)
if (sd + td >= dist) return null
return { x1: s.x, y1: s.y, x2: t.x, y2: t.y }
}
function lineColor(anno: Annotation) {
return resolveColor(anno.line?.color, dark.value) || (dark.value ? '#ffffff' : '#64748b')
}
function lineWidth(anno: Annotation) {
return (anno.line?.width ?? 0.5) * 2
}
function dash(anno: Annotation) {
return anno.line?.style === 'dashed' ? '10 8' : undefined
}
/** cap → marker 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>
+219
View File
@@ -0,0 +1,219 @@
<!-- =====================================================================
ViewerApp.vue — 分享查看器(观众版,独立入口)
数据源:分享 JSON(不连 store),仅静态渲染当前页
===================================================================== -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { Deck, Slide } from '../core/types'
import { parseShareHash, shareJsonUrlOf } from '../core/share'
import { resolveBg, registerBgThemeGetter } from '../core/bg'
import { CANVAS_W, CANVAS_H } from '../core/sample'
import ElementView from '../components/editor/ElementView.vue'
import ViewerAnnotations from './ViewerAnnotations.vue'
type Status = 'loading' | 'ready' | 'error' | 'invalid'
const status = ref<Status>('loading')
const errMsg = ref('')
const deck = ref<Deck | null>(null)
const index = ref(0)
const scale = ref(1)
const idle = ref(false)
const slides = computed<Slide[]>(() => deck.value?.slides ?? [])
const total = computed(() => slides.value.length)
const currentSlide = computed<Slide | null>(() => {
const arr = slides.value
if (!arr.length) return null
return arr[Math.max(0, Math.min(index.value, arr.length - 1))]
})
/* ---------- 加载分享 JSON ---------- */
onMounted(async () => {
const parsed = parseShareHash()
if (!parsed) {
status.value = 'invalid'
return
}
try {
// 优先用链接内嵌的 JSON 地址(观众零配置);旧格式链接回退本地配置反推
const jsonUrl = parsed.jsonUrl || shareJsonUrlOf(parsed.shareId)
const res = await fetch(jsonUrl)
if (!res.ok) throw new Error('HTTP ' + res.status)
deck.value = JSON.parse(await res.text()) as Deck
if (!deck.value?.slides?.length) throw new Error('分享内容为空')
// 注入 deck 自带主题,让 resolveBg 按分享内容的主题解析背景/元素色
registerBgThemeGetter(() => String(deck.value?.theme || 'indigo'))
status.value = 'ready'
fit()
} catch (e: any) {
errMsg.value = e?.message || String(e)
status.value = 'error'
}
})
/* ---------- 缩放适配 ---------- */
function fit() {
scale.value = Math.min(window.innerWidth / CANVAS_W, window.innerHeight / CANVAS_H)
}
/* ---------- 翻页 ---------- */
function next() {
if (index.value < total.value - 1) index.value++
wake()
}
function prev() {
if (index.value > 0) index.value--
wake()
}
/* ---------- 键盘 ---------- */
function onKey(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowRight': case ' ': case 'PageDown':
e.preventDefault(); next(); break
case 'ArrowLeft': case 'PageUp':
e.preventDefault(); prev(); break
}
}
/* ---------- 点击:左半屏上一页 / 右半屏下一页 ---------- */
function onClick(e: MouseEvent) {
const stage = stageEl.value
if (!stage) return
const rect = stage.getBoundingClientRect()
if (e.clientX - rect.left > rect.width / 2) next()
else prev()
}
/* ---------- 触摸滑动 ---------- */
let touchX = 0
function onTouchStart(e: TouchEvent) {
touchX = e.changedTouches[0].clientX
}
function onTouchEnd(e: TouchEvent) {
const dx = e.changedTouches[0].clientX - touchX
if (dx > 40) prev()
else if (dx < -40) next()
}
/* ---------- 页码 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>
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import ViewerApp from './ViewerApp.vue'
createApp(ViewerApp).mount('#app')
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest'
import { niceDomain, fmtTick } from '../src/components/editor/chart-domain'
describe('niceDomain', () => {
// 已确诊 bug 场景:数据全挤 97~98,旧实现映射到 [0,98] 导致折线贴顶
it('高基线小波动:line 值域收紧为数据带,折线不贴顶', () => {
const d = niceDomain([97, 98, 97.6, 97, 98])
// 波动 <10% → domain = [97 - 1.5, 98 + 1.5],step 0.5 → [95.5, 99.5]
expect(d.min).toBeLessThan(97)
expect(d.max).toBeGreaterThan(98)
expect(d.max - d.min).toBeLessThan(10)
})
it('高基线小波动:ticks 4~5 个且为整数步长', () => {
const d = niceDomain([97, 98, 97.6, 97, 98])
expect(d.ticks.length).toBeGreaterThanOrEqual(4)
expect(d.ticks.length).toBeLessThanOrEqual(6)
const step = d.ticks[1] - d.ticks[0]
expect(step).toBe(1)
// 首尾与 domain 对齐
expect(d.ticks[0]).toBe(d.min)
expect(d.ticks[d.ticks.length - 1]).toBe(d.max)
})
it('bar 类保持 0 基线(不因数据全大而从数据 min 起)', () => {
const d = niceDomain([97, 98, 97.6, 97, 98], { zeroBase: true })
expect(d.min).toBe(0)
expect(d.ticks[0]).toBe(0)
expect(d.max).toBeGreaterThanOrEqual(98)
})
it('常规整数数据 [1,2,3] → nice 步长(0.5 或 1),刻度覆盖 max', () => {
const d = niceDomain([1, 2, 3])
const step = d.ticks[1] - d.ticks[0]
expect([0.5, 1]).toContain(step)
expect(d.ticks).toContain(3)
})
it('小数值数据 [0.1, 0.2] → 步长 0.5,下限 0.5 生效', () => {
const d = niceDomain([0.1, 0.2])
expect(d.ticks[1] - d.ticks[0]).toBe(0.5)
expect(d.min).toBeLessThanOrEqual(0.1)
expect(d.max).toBeGreaterThanOrEqual(0.2)
})
it('单值数据不产生除零/NaN', () => {
const d = niceDomain([5])
expect(d.min).toBeLessThan(d.max)
expect(Number.isFinite(d.min)).toBe(true)
expect(Number.isFinite(d.max)).toBe(true)
expect(d.ticks.length).toBeGreaterThanOrEqual(3)
})
it('全等值数据 line 值域仍为区间而非单点', () => {
const d = niceDomain([42, 42, 42, 42])
expect(d.max).toBeGreaterThan(d.min)
expect(d.ticks.length).toBeGreaterThanOrEqual(3)
})
it('全等值数据 bar 类 → [0, ≥value]', () => {
const d = niceDomain([42, 42, 42], { zeroBase: true })
expect(d.min).toBe(0)
expect(d.max).toBeGreaterThanOrEqual(42)
})
it('maxCap 作为上限覆盖(仅当 > 数据 max)', () => {
const d = niceDomain([1, 2, 3], { zeroBase: true, maxCap: 100 })
expect(d.max).toBeGreaterThanOrEqual(100)
})
it('maxCap 小于数据 max 时不收紧值域', () => {
const d = niceDomain([1, 2, 3], { zeroBase: true, maxCap: 1 })
expect(d.max).toBeGreaterThanOrEqual(3)
})
it('空数据返回安全默认值域', () => {
const d = niceDomain([])
expect(d.max).toBeGreaterThan(d.min)
expect(d.ticks.length).toBeGreaterThanOrEqual(2)
})
it('过滤 NaN/Infinity', () => {
const d = niceDomain([NaN, Infinity, -Infinity, 5, 10])
expect(d.min).toBeLessThanOrEqual(5)
expect(d.max).toBeGreaterThanOrEqual(10)
})
it('大整数数据步长取 5×10^k', () => {
const d = niceDomain([1000, 1200, 1500, 2000], { zeroBase: true })
const step = d.ticks[1] - d.ticks[0]
expect(step).toBeGreaterThan(0)
// 2000 跨度 → 步长 500 或 1000 之类 nice 数
expect([100, 200, 500, 1000].includes(step)).toBe(true)
})
})
describe('fmtTick', () => {
it('去尾零', () => {
expect(fmtTick(2.5)).toBe('2.5')
expect(fmtTick(20)).toBe('20')
expect(fmtTick(0)).toBe('0')
expect(fmtTick(97.6)).toBe('97.6')
})
it('消浮点误差', () => {
expect(fmtTick(0.1 + 0.2)).toBe('0.3')
expect(fmtTick(2.55)).toBe('2.55')
})
})
+66
View File
@@ -0,0 +1,66 @@
/* dialog.test.ts — 全局对话框服务与宿主组件闭环验证(Teleport 后从 document 查询) */
import { describe, it, expect } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import AppDialog from '../src/components/common/AppDialog.vue'
import { appAlert, appConfirm, appPrompt, dialogState, resolveDialog } from '../src/core/dialog'
/** 触发 body 上(Teleport)按钮点击 */
function clickBtn(idx: number) {
const btns = document.body.querySelectorAll('.modal-actions button')
;(btns[idx] as HTMLElement).click()
}
describe('dialog 服务 + AppDialog 宿主', () => {
it('appPrompt 打开对话框 → 输入值提交', async () => {
const wrapper = mount(AppDialog, { attachTo: document.body })
const p = appPrompt('生成整套', { placeholder: '主题' })
await flushPromises()
expect(dialogState.open.value).toBe(true)
const input = document.body.querySelector('.modal-mask input') as HTMLInputElement
expect(input).not.toBeNull()
input.value = '远程办公'
input.dispatchEvent(new Event('input'))
await flushPromises()
clickBtn(1) // 确定
await expect(p).resolves.toBe('远程办公')
expect(dialogState.open.value).toBe(false)
wrapper.unmount()
})
it('appConfirm 取消 → false;确定 → true', async () => {
const wrapper = mount(AppDialog, { attachTo: document.body })
const p1 = appConfirm('删除?', '不可恢复', { danger: true })
await flushPromises()
clickBtn(0) // 取消
await expect(p1).resolves.toBe(false)
const p2 = appConfirm('删除?')
await flushPromises()
clickBtn(1) // 确定
await expect(p2).resolves.toBe(true)
wrapper.unmount()
})
it('appAlert 无输入框,单按钮关闭', async () => {
const wrapper = mount(AppDialog, { attachTo: document.body })
const p = appAlert('提示', '内容')
await flushPromises()
expect(document.body.querySelector('.modal-mask input')).toBeNull()
expect(document.body.querySelectorAll('.modal-actions button').length).toBe(1)
clickBtn(0)
await expect(p).resolves.toEqual({ ok: true, value: '' })
wrapper.unmount()
})
it('对话框 DOM 挂到 body(Teleport)且带 modal-mask/modal 类', async () => {
const wrapper = mount(AppDialog, { attachTo: document.body })
appAlert('标题')
await flushPromises()
const mask = document.body.querySelector('.modal-mask')
expect(mask).not.toBeNull()
expect(mask!.querySelector('.modal')).not.toBeNull()
resolveDialog({ ok: true, value: '' })
await flushPromises()
wrapper.unmount()
})
})
+47
View File
@@ -0,0 +1,47 @@
/* list-marker.test.ts — hasLineMarker 行首列表标记检测验证 */
import { describe, it, expect } from 'vitest'
import { hasLineMarker } from '../src/core/richtext'
describe('hasLineMarker 行首列表标记检测', () => {
it('圈号 ①-⑳ 识别为标记', () => {
expect(hasLineMarker('① 第一点')).toBe(true)
expect(hasLineMarker('⑳ 最后一点')).toBe(true)
})
it('阿拉伯数字+点/顿/括号 识别为标记', () => {
expect(hasLineMarker('1. 标题')).toBe(true)
expect(hasLineMarker('12、要点')).toBe(true)
expect(hasLineMarker('3)要点')).toBe(true)
expect(hasLineMarker('(4). 要点')).toBe(true)
expect(hasLineMarker('5.全角点')).toBe(true)
})
it('圆点类符号识别为标记', () => {
expect(hasLineMarker('- 要点')).toBe(true)
expect(hasLineMarker('• 要点')).toBe(true)
expect(hasLineMarker('· 要点')).toBe(true)
expect(hasLineMarker('▪ 要点')).toBe(true)
expect(hasLineMarker('● 要点')).toBe(true)
expect(hasLineMarker('○ 要点')).toBe(true)
expect(hasLineMarker('* 强调')).toBe(true)
})
it('中文数字+点顿 识别为标记', () => {
expect(hasLineMarker('一、概述')).toBe(true)
expect(hasLineMarker('十二. 展开')).toBe(true)
expect(hasLineMarker('十.收尾')).toBe(true)
})
it('行首空白不影响检测', () => {
expect(hasLineMarker(' ① 缩进的圈号')).toBe(true)
expect(hasLineMarker('\t- 制表符开头的圆点')).toBe(true)
})
it('普通文本返回 false', () => {
expect(hasLineMarker('华东 Q3 增长 23%')).toBe(false)
expect(hasLineMarker('第一点(无标点编号)')).toBe(false)
expect(hasLineMarker('')).toBe(false)
expect(hasLineMarker('2026 年趋势')).toBe(false)
expect(hasLineMarker('1 比 2 更好')).toBe(false)
})
})
+236
View File
@@ -0,0 +1,236 @@
/* =====================================================================
* norm-slides-enhance.test.ts — 生成层质量修复测试
* 覆盖:空文本元素剔除 / 文本重叠机械校正 / chart 数据形状校正
* ===================================================================== */
import { describe, it, expect } from 'vitest'
import { normSlides } from '../src/core/ai'
import type { SlideElement } from '../src/core/types'
function el(id: string, over: Partial<SlideElement> = {}): SlideElement {
return { id, type: 'text', x: 10, y: 10, w: 40, h: 10, content: '内容', style: {}, ...over }
}
function norm(elements: SlideElement[]): SlideElement[] {
return normSlides([{ background: 'bg', elements }])[0].elements
}
describe('空文本元素剔除', () => {
it.each(['title', 'text', 'quote', 'list', 'card', 'stat'] as const)('%s content 空白 → 剔除', (t) => {
const els = norm([el('a', { type: t, content: ' \n ' }), el('b', { y: 50 })])
expect(els.map(e => e.id)).toEqual(['b'])
})
it.each(['chart', 'table'] as const)('%s 空 content 不剔除', (t) => {
const els = norm([el('a', { type: t, content: '' })])
expect(els).toHaveLength(1)
})
it('小空形状(面积 ≤8% 画布)= 装饰,保留;大空框(>8%)= 残缺文本框,丢弃', () => {
// 剔除层不处理 shape,去留由 sanitizeShapes「大空框」装饰纪律决定(阈值 w%×h% > 800)
const small = norm([el('a', { type: 'shape', content: '', w: 5, h: 5, style: { shapeType: 'rect', fill: '#e74c3c' } })])
expect(small).toHaveLength(1)
const big = norm([el('a', { type: 'shape', content: '', w: 40, h: 30, style: { shapeType: 'rect', fill: '#e74c3c' } })])
expect(big).toHaveLength(0)
})
it('空 image/video(AI 占位)→ 剔除,防渲染成空白矩形框', () => {
const els = norm([el('a', { type: 'image', content: '' }), el('b', { type: 'video', content: '' }), el('c')])
expect(els.map(e => e.id)).toEqual(['c'])
})
it('有内容的 image/video 保留', () => {
const els = norm([el('a', { type: 'image', content: 'data:image/png;base64,xx' }), el('b', { type: 'video', content: 'https://v/1.mp4' })])
expect(els).toHaveLength(2)
})
it('有 segments 的元素即使 content 空也保留', () => {
const e = el('a', { content: '' })
// normSegments 输入格式:行对象数组,每行含 segments
;(e as any).segments = [{ segments: [{ text: '富文本' }] }]
expect(norm([e])).toHaveLength(1)
})
})
describe('文本重叠机械校正', () => {
it('显著相交(>30%)→ 后者下移至前者下缘 + 最小间距', () => {
// a: 10,10 40x10;b: 10,15 40x10 → 相交 40x5=200,小元素面积 400,占比 50%>30%
// 第一阶段移至 b 下缘 20,第二阶段再保 1.5 最小间距 → 21.5
const els = norm([el('a'), el('b', { y: 15 })])
expect(els.find(e => e.id === 'b')!.y).toBe(21.5)
})
it('轻微相交(≤30%,有意叠加)→ 不动', () => {
// a:10-20 b:18-28 相交高 2,小面积 400,占比 5%…精确:40x2=80,占比 20%
// 相交不显著 → 第一阶段不动;但同列 gap 仅 -2 <1.5 → 第二阶段保最小间距至 21.5
const els = norm([el('a'), el('b', { y: 18 })])
expect(els.find(e => e.id === 'b')!.y).toBe(21.5)
})
it('轻微相交变体(部分重叠但占比≤30%)→ 不动', () => {
// a: 10,10 40x10;b: 20,18 40x12 → 相交 30x2=60,小面积 400,占比 15%
// 相交不显著 → 第一阶段不动;同列 gap <1.5 → 第二阶段保最小间距至 21.5
const els = norm([el('a'), el('b', { x: 20, y: 18, w: 40, h: 12 })])
expect(els.find(e => e.id === 'b')!.y).toBe(21.5)
})
it('下移会出画布 → 缩高贴底', () => {
// a: 10-100;b(90-100) 平移到 y=100 后需缩高收进画布(h 钳 3,y 贴底 97)
const els = norm([el('a', { y: 95, h: 5, w: 40 }), el('b', { y: 90, h: 10, w: 40 })])
const b = els.find(e => e.id === 'b')!
expect(b.h).toBe(3)
expect(b.y).toBe(97)
expect(b.y + b.h).toBeLessThanOrEqual(100)
})
it('chart/shape 背景元素不参与重叠校正', () => {
const chart = el('c', { type: 'chart', content: '[{"label":"a","value":1}]', x: 10, y: 10, w: 60, h: 40, style: { chartType: 'bar' } })
const text = el('t', { y: 20 })
const els = norm([chart, text])
expect(els.find(e => e.id === 't')!.y).toBe(20)
})
})
describe('chart 数据形状校正', () => {
it('pie 多系列 → 取第一系列,values 长度=labels 长度', () => {
const pie = el('p', {
type: 'chart', x: 30, y: 30, w: 40, h: 30,
content: JSON.stringify({ series: ['Q1', 'Q2'], items: [{ label: '华东', values: [120, 150] }, { label: '华南', values: [80, 90] }] }),
style: { chartType: 'pie' }
})
const out = norm([pie])[0]
const data = JSON.parse(out.content)
expect(data).toHaveLength(2)
expect(data[0]).toEqual({ label: '华东', value: 120 })
expect(data[1]).toEqual({ label: '华南', value: 80 })
})
it('pie 单系列已是正确格式 → 不变', () => {
const src = [{ label: 'a', value: 1 }, { label: 'b', value: 2 }]
const pie = el('p', { type: 'chart', content: JSON.stringify(src), style: { chartType: 'doughnut' } })
expect(JSON.parse(norm([pie])[0].content)).toEqual(src)
})
it('radar 指标<3 → 纠正为 bar', () => {
const radar = el('r', {
type: 'chart',
content: '[{"label":"a","value":1},{"label":"b","value":2}]',
style: { chartType: 'radar' }
})
const out = norm([radar])[0]
expect(out.style.chartType).toBe('bar')
})
it('radar 指标≥3 → 保持 radar', () => {
const radar = el('r', {
type: 'chart',
content: '[{"label":"a","value":1},{"label":"b","value":2},{"label":"c","value":3}]',
style: { chartType: 'radar' }
})
expect(norm([radar])[0].style.chartType).toBe('radar')
})
})
describe('icon 白名单归一化', () => {
it.each(['✅', '⚠️', '💡', '🎯', '📌', '🔍', '⭐'])('白名单 icon %s 保留(去 VS16 归一化)', (icon) => {
const els = normSlides([{ background: 'bg', elements: [{ id: 'a', type: 'card', x: 10, y: 10, w: 40, h: 20, content: '标题\n正文', style: { icon } }] }])
expect(els[0].elements[0].style.icon).toBe(icon.replace(/️/g, ''))
})
it.each(['❗', '✔️', '❌', '☑️', '🔥', '🚀', '✨'])('非白名单 icon %s 丢弃(留空)', (icon) => {
const els = normSlides([{ background: 'bg', elements: [{ id: 'a', type: 'card', x: 10, y: 10, w: 40, h: 20, content: '标题\n正文', style: { icon } }] }])
expect(els[0].elements[0].style.icon).toBeUndefined()
})
})
describe('空 shape 金句底座框剔除(dropEmptyElements)', () => {
it('空 shape opacity<0.15 → 剔除;opacity 达标且色距足够 → 保留', () => {
// a/b x 拉开,避免触发「叠放装饰丢弃后出现者」的装饰纪律干扰本用例
const els = norm([
el('a', { type: 'shape', content: '', w: 5, h: 5, x: 5, style: { shapeType: 'rect', fill: '#e74c3c', opacity: 0.1 } }),
el('b', { type: 'shape', content: '', w: 5, h: 5, x: 60, style: { shapeType: 'rect', fill: '#e74c3c', opacity: 0.5 } }),
el('c')
])
expect(els.map(e => e.id)).toEqual(['b', 'c'])
})
it('空 shape fill 与背景色距 <60(视觉隐形)→ 剔除', () => {
const els = norm([
el('a', { type: 'shape', content: '', w: 5, h: 5, style: { shapeType: 'rect', fill: '#ffffff' } }),
el('b')
])
expect(els.map(e => e.id)).toEqual(['b'])
})
it('带内容的 shape 即使低透明度也不剔除', () => {
const els = norm([el('a', { type: 'shape', content: '文字', w: 20, h: 10, style: { fill: '#e74c3c', opacity: 0.05 } })])
expect(els).toHaveLength(1)
})
})
describe('金句引号剥离 → decoQuote 转换(sanitizeQuoteDeco)', () => {
it('quote 首尾成对弯引号包裹 → 剥离并置 style.decoQuote=true', () => {
const els = norm([el('q', { type: 'quote', content: '“少即是多”', style: { fontSize: 44 } })])
expect(els[0].content).toBe('少即是多')
expect(els[0].style.decoQuote).toBe(true)
})
it('quote 「」包裹 → 剥离并置 decoQuote', () => {
const els = norm([el('q', { type: 'quote', content: '「内容正文」', style: {} })])
expect(els[0].content).toBe('内容正文')
expect(els[0].style.decoQuote).toBe(true)
})
it('quote content 只含引号字符(剥后为空)→ 整元素剔除', () => {
const els = norm([el('q', { type: 'quote', content: '“”' }), el('b')])
expect(els.map(e => e.id)).toEqual(['b'])
})
it('quote 无引号字符 → content 与 style 不动', () => {
const els = norm([el('q', { type: 'quote', content: '没有引号的正文', style: {} })])
expect(els[0].content).toBe('没有引号的正文')
expect(els[0].style.decoQuote).toBeUndefined()
})
it('非 quote 类型的引号字符不剥离(正文合法引用)', () => {
const els = norm([el('t', { type: 'text', content: '他说“你好”', style: {} })])
expect(els[0].content).toBe('他说“你好”')
expect(els[0].style.decoQuote).toBeUndefined()
})
})
describe('文本容量估算扩高(sanitizeOverflow + estimateTextH)', () => {
it('长文本 h 不足 → 扩高(不超画布)', () => {
// 60 字正文 24px、宽 40%:每行约 (1280*0.4)/(24*1.05)≈20 字 → 3 行 → 需 h≈(3*24*1.5+0.8*24)/720*100≈17.7
const els = norm([el('t', { content: '一'.repeat(60), w: 40, h: 6, style: { fontSize: 24 } })])
expect(els[0].h).toBeGreaterThan(6)
expect(els[0].y + els[0].h).toBeLessThanOrEqual(100)
})
it('短文本 h 已足 → 不动', () => {
const els = norm([el('t', { content: '短', w: 40, h: 10, style: { fontSize: 24 } })])
expect(els[0].h).toBe(10)
})
it('扩高出画布 → 降字号一档(不低于 0.75×)', () => {
// 长文 + 低 y + 大字号:容量远超剩余空间 → 降字号
const els = norm([el('t', { y: 60, h: 10, w: 30, content: '一'.repeat(200), style: { fontSize: 48 } })])
expect(els[0].style.fontSize).toBeLessThan(48)
expect(els[0].style.fontSize!).toBeGreaterThanOrEqual(48 * 0.75)
expect(els[0].y + els[0].h).toBeLessThanOrEqual(100)
})
it('card 标题按 1.5em 折算容量(长标题单行放不下时扩高)', () => {
const els = norm([el('c', {
type: 'card', w: 25, h: 12, style: { fontSize: 20 },
content: '这是一个特别长的卡片标题超过一行折行\n正文内容'
})])
expect(els[0].h).toBeGreaterThan(12)
})
it('重叠缩高不低于容量下限:下移空间不足时缩字号而不是硬裁', () => {
// a 在上方;b y=90 与 a 相交 → 平移空间只剩 10%,长文本容量 >10 → 应缩字号而非 h=3 硬裁
const els = norm([el('a', { h: 6 }), el('b', { y: 90, h: 5, w: 40, content: '一'.repeat(80), style: { fontSize: 24 } })])
const b = els.find(e => e.id === 'b')!
expect(b.style.fontSize).toBeLessThan(24)
expect(b.y + b.h).toBeLessThanOrEqual(100)
})
})
+114
View File
@@ -0,0 +1,114 @@
/* =====================================================================
* op.test.ts — Op 引擎核心语义测试
* 保护对象:applyOp 正向应用 / invertOp 逆操作 / undo 往返一致性
* 这些是 undo/redo 正确性的地基,任何改动必须保持以下断言成立
* ===================================================================== */
import { describe, it, expect } from 'vitest'
import { applyOp, invertOp, type Op } from '../src/core/op'
import type { Deck, Slide, SlideElement } from '../src/core/types'
/* ---------- 测试数据工厂 ---------- */
function el(id: string, over: Partial<SlideElement> = {}): SlideElement {
return {
id, type: 'text', x: 10, y: 10, w: 40, h: 15,
content: id, style: { fontSize: 24 },
...over
}
}
function slide(id: string, elements: SlideElement[] = []): Slide {
return { id, background: 'bg', elements }
}
function deck(slides: Slide[]): Deck {
return { v: 3, theme: 'indigo', slides, chatId: 'test' } as Deck
}
const D1 = deck([slide('s1', [el('a'), el('b')]), slide('s2', [el('c')])])
/* ---------- applyOp 正向 ---------- */
describe('applyOp', () => {
it('add_element 在目标页追加元素', () => {
const op: Op = { type: 'add_element', slideIdx: 0, element: el('new') }
const d = applyOp(D1, op)
expect(d.slides[0].elements.map(e => e.id)).toEqual(['a', 'b', 'new'])
expect(d.slides[1].elements).toHaveLength(1) // 其他页不受影响
})
it('del_element 删除指定元素', () => {
const op: Op = { type: 'del_element', slideIdx: 0, elementId: 'a' }
const d = applyOp(D1, op)
expect(d.slides[0].elements.map(e => e.id)).toEqual(['b'])
})
it('update_element 只改 patch 覆盖的字段', () => {
const op: Op = { type: 'update_element', slideIdx: 0, elementId: 'a', patch: { content: '改后', style: { fontSize: 32 } } }
const d = applyOp(D1, op)
const target = d.slides[0].elements[0]
expect(target.content).toBe('改后')
expect(target.style.fontSize).toBe(32)
expect(target.x).toBe(10) // 未覆盖字段保持
})
it('move_slide 调整页序', () => {
const op: Op = { type: 'move_slide', from: 0, to: 1 }
const d = applyOp(D1, op)
expect(d.slides.map(s => s.id)).toEqual(['s2', 's1'])
})
it('不 mutate 输入 deck(纯函数)', () => {
const snapshot = JSON.stringify(D1)
applyOp(D1, { type: 'add_element', slideIdx: 0, element: el('x') })
applyOp(D1, { type: 'del_element', slideIdx: 0, elementId: 'a' })
expect(JSON.stringify(D1)).toBe(snapshot)
})
})
/* ---------- invertOp + undo 往返 ---------- */
describe('invertOp 往返一致性', () => {
const cases: Array<{ name: string; op: Op }> = [
{ name: 'add_element', op: { type: 'add_element', slideIdx: 0, element: el('new') } },
{ name: 'del_element', op: { type: 'del_element', slideIdx: 0, elementId: 'a', deletedElement: el('a') } },
{ name: 'update_element', op: { type: 'update_element', slideIdx: 0, elementId: 'a', patch: { content: '改', style: { bold: true } } } },
{ name: 'add_slide', op: { type: 'add_slide', atIndex: 1, slide: slide('s3') } },
{ name: 'move_slide', op: { type: 'move_slide', from: 0, to: 1 } },
{ name: 'set_slide_bg', op: { type: 'set_slide_bg', index: 0, bg: 'primary' } },
{ name: 'move_element_z', op: { type: 'move_element_z', slideIdx: 0, elementId: 'b', dir: 1 } },
]
for (const { name, op } of cases) {
it(`${name}:执行后撤销回到原状`, () => {
const after = applyOp(D1, op)
const back = invertOp(D1, op)
const restored = applyOp(after, back)
expect(JSON.stringify(restored)).toBe(JSON.stringify(D1))
})
}
it('update_element 无 style patch 也往返一致', () => {
const op: Op = { type: 'update_element', slideIdx: 1, elementId: 'c', patch: { x: 55, y: 60 } }
const after = applyOp(D1, op)
const restored = applyOp(after, invertOp(D1, op))
expect(JSON.stringify(restored)).toBe(JSON.stringify(D1))
})
})
/* ---------- 连续操作链撤销 ---------- */
describe('多步操作链撤销', () => {
it('三条操作逐步回滚最终回到初始', () => {
let d = D1
const trail: Array<{ forward: Op; backward: Op }> = []
const ops: Op[] = [
{ type: 'add_element', slideIdx: 0, element: el('n1') },
{ type: 'update_element', slideIdx: 0, elementId: 'n1', patch: { content: '更新' } },
{ type: 'del_element', slideIdx: 0, elementId: 'b' },
]
for (const op of ops) {
trail.push({ forward: op, backward: invertOp(d, op) })
d = applyOp(d, op)
}
// 逆序回滚
for (let i = trail.length - 1; i >= 0; i--) {
d = applyOp(d, trail[i].backward)
}
expect(JSON.stringify(d)).toBe(JSON.stringify(D1))
})
})
+68
View File
@@ -0,0 +1,68 @@
/* =====================================================================
* sanitize-contrast.test.ts — 对比度治理测试
* 保护对象:normSlides 内的 sanitizeContrast(深底小字 accent 降级、
* 透明/近背景色空装饰形状丢弃)
* ===================================================================== */
import { describe, it, expect } from 'vitest'
import { normSlides } from '../src/core/ai'
import type { SlideElement } from '../src/core/types'
/* ---------- 测试数据工厂 ---------- */
function textEl(id: string, over: Partial<SlideElement> = {}): SlideElement {
return { id, type: 'text', x: 10, y: 30, w: 60, h: 20, content: '正文', style: { color: 'accent', fontSize: 20 }, ...over }
}
function shapeEl(id: string, over: Partial<SlideElement> = {}): SlideElement {
return { id, type: 'shape', x: 70, y: 80, w: 10, h: 10, content: '', style: { shapeType: 'circle' }, ...over }
}
function norm(background: string, elements: SlideElement[]): SlideElement[] {
return normSlides([{ background, elements }])[0].elements
}
describe('sanitizeContrast 深底小字 accent 降级', () => {
it('深底页 text color=accent fontSize=20 → 降为 muted', () => {
const els = norm('g-primary', [textEl('t')])
expect((els[0].style as any).color).toBe('muted')
})
it('深底页 stat 大数字 color=accent fontSize=72 → 保留 accent', () => {
const els = norm('g-deep', [textEl('st', { type: 'stat', content: '65%', style: { color: 'accent', fontSize: 72 } })])
expect((els[0].style as any).color).toBe('accent')
})
it('浅底页(bg)text color=accent → 不动', () => {
const els = norm('bg', [textEl('t')])
expect((els[0].style as any).color).toBe('accent')
})
it('深底页 text color=accent fontSize=32 → 不动(大字合法)', () => {
const els = norm('primary', [textEl('t', { style: { color: 'accent', fontSize: 32 } })])
expect((els[0].style as any).color).toBe('accent')
})
it('深底页 quote color=muted → 不动(已是 muted)', () => {
const els = norm('g-primary', [textEl('q', { type: 'quote', style: { color: 'muted', fontSize: 40 } })])
expect((els[0].style as any).color).toBe('muted')
})
})
describe('sanitizeContrast 空装饰形状丢弃', () => {
it('空装饰形状无 fill(透明)→ 丢弃', () => {
const els = norm('bg', [shapeEl('s1', { style: { shapeType: 'circle' } })])
expect(els.find(e => e.id === 's1')).toBeUndefined()
})
it('空装饰形状 fill=primary 放 g-primary 背景 → 丢弃(同色隐形)', () => {
const els = norm('g-primary', [shapeEl('s1', { style: { shapeType: 'circle', fill: 'primary' } })])
expect(els.find(e => e.id === 's1')).toBeUndefined()
})
it('空装饰形状 fill=accent 放 g-primary 背景 → 保留(不同色)', () => {
const els = norm('g-primary', [shapeEl('s1', { style: { shapeType: 'circle', fill: 'accent' } })])
expect(els.find(e => e.id === 's1')).toBeDefined()
})
it('带内容形状 fill 同背景色 → 保留', () => {
const els = norm('g-primary', [shapeEl('s1', { content: '标签', style: { shapeType: 'bubble', fill: 'primary' } })])
expect(els.find(e => e.id === 's1')).toBeDefined()
})
})
+137
View File
@@ -0,0 +1,137 @@
/* =====================================================================
* sanitize-shapes.test.ts — AI 输出形状校正测试
* 保护对象:normSlides 内的 sanitizeShapes(等比化/收边/防压字)
* AI 坐标自由输出,这些校正防止 clip-path 多边形变形、形状裁切、装饰压字
* ===================================================================== */
import { describe, it, expect } from 'vitest'
import { normSlides } from '../src/core/ai'
import type { SlideElement } from '../src/core/types'
/* ---------- 测试数据工厂 ---------- */
function shape(id: string, over: Partial<SlideElement> = {}): SlideElement {
return {
id, type: 'shape', x: 10, y: 10, w: 20, h: 8,
content: '', style: { shapeType: 'rect', fill: 'accent' },
...over
}
}
function norm(elements: SlideElement[]): SlideElement[] {
return normSlides([{ background: 'bg', elements }])[0].elements
}
describe('sanitizeShapes 等比化', () => {
it('star 非正方形框 → 取小者,中心不变', () => {
const els = norm([shape('a', { style: { shapeType: 'star', fill: 'accent' }, x: 40, y: 20, w: 20, h: 8 })])
expect(els[0].w).toBe(8)
expect(els[0].h).toBe(8)
expect(els[0].x).toBe(46) // 40 + (20-8)/2
expect(els[0].y).toBe(20)
})
it('circle 扁宽框 → 正方形', () => {
const els = norm([shape('a', { style: { shapeType: 'circle', fill: 'accent' }, x: 10, y: 30, w: 30, h: 10 })])
expect(els[0].w).toBe(10)
expect(els[0].h).toBe(10)
expect(els[0].x).toBe(20) // 10 + 10
expect(els[0].y).toBe(30)
})
it('arrow 天然扁宽 → 不等比', () => {
const els = norm([shape('a', { style: { shapeType: 'arrow', fill: 'accent' }, x: 10, y: 30, w: 30, h: 10 })])
expect(els[0].w).toBe(30)
expect(els[0].h).toBe(10)
})
})
describe('sanitizeShapes 收进画布', () => {
it('形状超出下缘 → 收进', () => {
const els = norm([shape('a', { x: 10, y: 95, w: 30, h: 15 })])
expect(els[0].y).toBe(85)
expect(els[0].y + els[0].h).toBeLessThanOrEqual(100)
})
it('形状超出右缘 → 收进', () => {
const els = norm([shape('a', { x: 90, y: 10, w: 30, h: 8 })])
expect(els[0].x).toBe(70)
})
})
describe('sanitizeShapes 防压字', () => {
const title: SlideElement = {
id: 't', type: 'title', x: 10, y: 8, w: 80, h: 10, content: '标题', style: {}
}
it('空胶囊压在 title 中部 → 移到 title 正下方', () => {
const els = norm([title, shape('cap', { x: 10, y: 12, w: 40, h: 3 })])
const cap = els.find(e => e.id === 'cap')!
expect(cap.y).toBe(19) // title.y + title.h + 1
})
it('空胶囊同时压 title 与 list 两个元素 → 丢弃', () => {
const list: SlideElement = { id: 'l', type: 'list', x: 10, y: 14, w: 80, h: 40, content: '条目', style: {} }
// cap y=10 h=10:与 title 相交高 10 > 5,与 list(y14起) 相交高 6 > 5 → 命中两个
const els = norm([title, list, shape('cap', { x: 10, y: 10, w: 40, h: 10 })])
expect(els.find(e => e.id === 'cap')).toBeUndefined()
})
it('有文字的形状压字 → 不动', () => {
const els = norm([title, shape('bad', { x: 10, y: 12, w: 40, h: 3, content: '标签' })])
const bad = els.find(e => e.id === 'bad')!
expect(bad.y).toBe(12)
})
it('贴标题下缘的合法胶囊(相交浅)→ 不误伤', () => {
// cap y=16 h=4(至20),title 至 y=18,相交高 2 = h*0.5,不满足「>」判定 → 保持原位
const els = norm([title, shape('cap', { x: 10, y: 16, w: 40, h: 4 })])
const cap = els.find(e => e.id === 'cap')!
expect(cap.y).toBe(16)
})
})
describe('sanitizeShapes 怪异装饰兜底', () => {
it('横贯页面的细矩形(分隔线/进度条)→ 丢弃', () => {
const els = norm([shape('div', { x: 10, y: 50, w: 80, h: 3 })])
expect(els.find(e => e.id === 'div')).toBeUndefined()
})
it('扁宽但不到横贯程度 → 保留', () => {
const els = norm([shape('wide', { x: 10, y: 50, w: 50, h: 3 })])
expect(els.find(e => e.id === 'wide')).toBeDefined()
})
it('多形状叠放拼图案(三角+矩形+圆拼「房子」)→ 只保留先出现的', () => {
// 三角在上方,矩形/圆与其叠放 → 后两者丢弃
const els = norm([
shape('roof', { style: { shapeType: 'triangle', fill: 'accent' }, x: 40, y: 10, w: 20, h: 12 }),
shape('body', { style: { fill: 'accent' }, x: 42, y: 20, w: 16, h: 15 }),
shape('dot', { style: { shapeType: 'circle', fill: 'accent' }, x: 48, y: 24, w: 5, h: 5 })
])
expect(els.find(e => e.id === 'roof')).toBeDefined()
expect(els.find(e => e.id === 'body')).toBeUndefined()
expect(els.find(e => e.id === 'dot')).toBeUndefined()
})
it('不相交的独立装饰 → 互不影响', () => {
const els = norm([
shape('a', { x: 5, y: 10, w: 10, h: 8 }),
shape('b', { x: 60, y: 60, w: 10, h: 8 })
])
expect(els.find(e => e.id === 'a')).toBeDefined()
expect(els.find(e => e.id === 'b')).toBeDefined()
})
it('空装饰超过 5 个 → 只保留前 5 个', () => {
// 间距拉开互不叠放(w/h 最小钳制为 3,间隔须 >3),纯验数量兜底
const els = norm(Array.from({ length: 7 }, (_, i) => shape('d' + i, { x: i * 5, y: 50, w: 3, h: 3 })))
expect(els.filter(e => e.type === 'shape').length).toBe(5)
})
it('有文字的形状(bubble 标签)→ 不参与丢弃', () => {
const els = norm([
shape('tag1', { x: 10, y: 30, w: 15, h: 6, content: '要点' }),
shape('tag2', { x: 12, y: 32, w: 15, h: 6, content: '标签' })
])
expect(els.find(e => e.id === 'tag1')).toBeDefined()
expect(els.find(e => e.id === 'tag2')).toBeDefined()
})
})
+57
View File
@@ -0,0 +1,57 @@
/* shape-render.test.ts — 形状元素渲染回归(细条/装饰形状不被文字层污染) */
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ElementView from '../src/components/editor/ElementView.vue'
import { store } from '../src/core/store'
import type { SlideElement } from '../src/core/types'
/** 目录页 t2:标题下方的细横条(rect 128×11.5px) */
const t2: SlideElement = {
id: 't2', type: 'shape', x: 8, y: 23, w: 10, h: 1.6, content: '',
style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'fade-up' }
}
/** 步骤页 st4:步骤间的小三角 */
const st4: SlideElement = {
id: 'st4', type: 'shape', x: 27.5, y: 40, w: 5, h: 0.8, content: '',
style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' }
}
describe('装饰形状渲染', () => {
it('细横条 rect:编辑态渲染图形 div + 空 contenteditable 文字层,无占位文字', () => {
const w = mount(ElementView, { props: { el: t2, bg: 'bg', edit: true }, attachTo: document.body })
const shape = w.find('.el-shape')
expect(shape.exists()).toBe(true)
expect(shape.attributes('style')).toContain('border-radius: 4px')
const text = w.find('.el-shape-text')
expect(text.exists()).toBe(true) // 编辑态保留可点击输入
expect(text.text()).toBe('') // 无占位文字
// 根节点暴露 data-shape 供 CSS 区分满幅/多边形安全区
expect(w.find('.el').attributes('data-shape')).toBe('rect')
w.unmount()
})
it('小三角 triangle:走 clip-path 分支,非正比框渲染', () => {
const w = mount(ElementView, { props: { el: st4, bg: 'bg', edit: true }, attachTo: document.body })
const shape = w.find('.el-shape')
expect(shape.attributes('style') || '').toContain('clip-path')
w.unmount()
})
it('非编辑态(模板预览):空 content 不渲染文字层', () => {
const w = mount(ElementView, { props: { el: t2, bg: 'bg' }, attachTo: document.body })
expect(w.find('.el-shape-text').exists()).toBe(false)
w.unmount()
})
it('有文字的形状:非编辑态渲染 md 文字层且默认白字', () => {
void store
const el = { ...t2, h: 10, content: '**加粗**文字' }
const w = mount(ElementView, { props: { el, bg: 'bg' }, attachTo: document.body })
const text = w.find('.el-shape-text')
expect(text.exists()).toBe(true)
expect(text.html()).toContain('<strong>加粗</strong>')
expect(text.attributes('style') || '').toContain('rgb(255, 255, 255)')
w.unmount()
})
})
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>u-ppt · 演示</title>
<link rel="stylesheet" href="/src/styles/base.css" />
<!-- editor.css 含 .el 系列元素样式(定位/字体/形状/表格等),与编辑器/演示保持同一渲染 -->
<link rel="stylesheet" href="/src/styles/editor.css" />
<link rel="stylesheet" href="/src/styles/present.css" />
</head>
<body data-mode="present">
<div id="app"></div>
<script type="module" src="/src/viewer/main.ts"></script>
</body>
</html>
+22 -3
View File
@@ -3,9 +3,28 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => ({
plugins: [vue()],
// npm run dev(mode=web)→ 浏览器模式,端口 8080
build: {
chunkSizeWarningLimit: 800,
rollupOptions: {
input: {
main: 'index.html',
viewer: 'viewer.html',
},
},
},
// npm run dev:web(mode=web)→ 浏览器模式,端口 8080
// npm run tauri:dev → 默认 mode,端口 5173
server: mode === 'web'
? { host: '127.0.0.1', port: 8080, open: true }
: { host: '127.0.0.1', port: 5173, strictPort: true },
? {
host: '127.0.0.1', port: 8080, open: true,
// 与 tauri dev 共存时防止 watch Cargo 产物(EBUSY 崩 watcher)
watch: { ignored: ['**/src-tauri/target/**'] },
}
: {
host: '127.0.0.1', port: 5173, strictPort: true,
// 不 watch src-tauri/target:Cargo 编译期间写 dll 会触发 EBUSY 崩掉 watcher
watch: { ignored: ['**/src-tauri/target/**'] },
},
// 桌面模式 HMR 由 tauri-cli 管理,无需自动打开浏览器
clearScreen: false,
}))
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
},
})