commit cfeabf2d374ab84fef32416a3131965dbc9e2b8d Author: 绝尘 <237809796@qq.com> Date: Sun Jul 12 13:12:09 2026 +0800 新增: u-ppt Vue 3 在线演示工具初始版本 - 核心架构: Vue 3 + Vite + TypeScript,零运行时依赖(仅 Vue) - 编辑器: 幻灯片增删排序、元素拖拽八向缩放、双击编辑、12 种元素类型 (标题/正文/列表/数据/金句/图片/形状/图表/卡片/表格/代码/公式) - 图表: 8 种 SVG 自绘(柱状/条形/折线/面积/饼图/环形/雷达/进度) - 富文本: 结构化 segments(加粗/斜体/颜色/高亮/上下标/代码/链接) - AI 能力: 对话编辑、生成整套、润色本页、大纲→逐页生成、一键美化、AI 配图 - 会话绑定: 每份 PPT 独立会话,切换 PPT 自动切换对话历史 - 模板系统: 7 个内置版式 + 用户自存模板 - 演示模式: 全屏播放、键盘/鼠标/滚轮导航、入场动画 - 持久化: localStorage 存 deck/文库/会话/模板/配置 - 支持多服务商: 智谱/DeepSeek/通义/Kimi/豆包/OpenAI/Anthropic/Gemini/Groq/Ollama diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..818ac86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +dist-ssr/ + +# Tauri (未来桌面版) +src-tauri/target/ + +# IDE +.vscode/* +!.vscode/extensions.json +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Logs +*.log +npm-debug.log* + +# Env +.env +.env.local +.env.*.local + +# Vite临时 +.vite/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c8cf64 --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# u-ppt · 在线演示工具 + +基于 **Vue 3 + Vite + TypeScript** 的在线 PPT 应用,无后端。支持编辑、全屏演示,以及 AI 对话式创作。 + +> 历史版本为纯 HTML/CSS/JS 无构建方案,已迁移至 Vue 3 组件化架构,便于长期迭代。 + +## ✨ 功能 + +| 模块 | 能力 | +|------|------| +| **编辑器** | 幻灯片增删/排序、元素拖拽与八向缩放、双击编辑文字、属性面板(字号/颜色/对齐/加粗/形状/层级)、5 套主题、页面背景、7 个内置页面模板 + 自存模板 | +| **演示** | 全屏播放、← →/Space/Home/End 翻页、F 切换全屏、B 黑屏、Esc 退出、左键下一页/右键上一页/滚轮翻页、页码与进度 | +| **AI 创作** | 一键生成整套 PPT、润色当前页、对话式连续编辑、流式打字机输出、随时中断 | +| **AI 深化** | 大纲→逐页生成(先出可编辑大纲,逐页填充)、AI 配图(接入图像模型)、AI 配色/主题推荐、一键美化整份(逐页排版优化) | +| **演示文库** | 命名保存多套 PPT;文库面板管理(打开/重命名/复制/删除)、首屏迷你预览;当前编辑自动保存不丢 | +| **持久化** | 自动保存到浏览器 localStorage,刷新不丢;一键重置为内置示例;Ctrl+Z/Y 撤销重做 | + +支持元素:标题 / 正文 / 列表 / 数据 / 金句 / 形状 / 图片 / 图表(柱状/条形/折线/面积/饼图/环形/雷达/进度 8 种)/ 卡片 / 表格 / 代码 / 公式。 + +## 🚀 使用 + +### 开发 +```bash +npm install +npm run dev +# 浏览器自动打开 http://localhost:8080 +``` + +### 构建 +```bash +npm run build # 产出 dist/,可部署到任意静态托管 +npm run preview # 本地预览构建结果 +``` + +### 起本地静态服务(构建后,便于 AI 跨域调试) +```bash +npm run build +python -m http.server 8080 --directory dist +``` + +## ⌨ 快捷键 + +**编辑模式** +- `Ctrl/Cmd + Z` 撤销 `Ctrl/Cmd + Shift + Z` 重做 `Delete` 删除选中元素 +- `Ctrl/Cmd + S` 触发保存提示(其实已自动保存) + +**演示模式** +- `→` `↓` `Space` `PageDown`:下一页 `←` `↑` `PageUp`:上一页 +- `Home` / `End`:首页 / 末页 `F`:切换全屏 `B`:黑屏 `Esc`:退出 + +## 📚 演示文库(多套 PPT) + +支持保存多份独立演示,随时切换编辑。 + +- **💾 保存**(工具栏):当前已从文库打开则直接更新该份;否则弹出文库让你命名。 +- **📁 文库**(工具栏):打开文库面板 + - 顶部输入名字 → **存入文库**(同名覆盖,不同名则新建一份) + - **+ 新建空白**:从空白演示开始创作 + - 列表每份支持:**打开** / **重命名** / **复制** / **删除**,左侧显示首屏迷你预览 +- 所有内容存浏览器本地(`u-ppt.library`),不上云,刷新不丢。 + +## 🤖 AI 配置 + +点击右上角 **⚙**,在「服务商」下拉选厂商,地址与模型自动填好,只需填 API Key: + +| 服务商 | 协议 | Base URL | 默认模型 | +|--------|------|----------|----------| +| 智谱 GLM(OpenAI) | OpenAI | `open.bigmodel.cn/api/paas/v4` | glm-4.6 | +| 智谱 GLM(Anthropic) | Anthropic | `open.bigmodel.cn/api/anthropic` | glm-4.6 | +| **DeepSeek** | OpenAI | `api.deepseek.com` | deepseek-chat | +| 通义千问(阿里) | OpenAI | `dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus | +| Kimi(月之暗面) | OpenAI | `api.moonshot.cn/v1` | moonshot-v1-8k | +| 豆包(火山引擎) | OpenAI | `ark.cn-beijing.volces.com/api/v3` | doubao-pro-32k¹ | +| OpenAI | OpenAI | `api.openai.com/v1` | gpt-4o-mini | +| Anthropic Claude | Anthropic | `api.anthropic.com` | claude-sonnet-5 | +| Google Gemini | OpenAI | `generativelanguage.googleapis.com/v1beta/openai` | gemini-2.0-flash | +| Groq(极速) | OpenAI | `api.groq.com/openai/v1` | llama-3.3-70b-versatile | +| Ollama(本地) | OpenAI | `localhost:11434/v1` | llama3.1² | + +¹ 豆包实际需填你在火山方舟创建的**接入点 endpoint id**(`ep-xxx`)。 +² 本地服务(localhost / 127.0.0.1)无需填 Key;Ollama 需设环境变量 `OLLAMA_ORIGINS=*` 允许浏览器跨端口访问。 + +选「**自定义**」可手填任意 OpenAI / Anthropic 兼容服务(如自建网关、LiteLLM、one-api 等)。改字段后保存即可,配置全部存本地 localStorage。 + +> **协议差异**:OpenAI 走 `/chat/completions` + `Authorization: Bearer`;Anthropic 走 `/v1/messages` + `x-api-key` + `anthropic-version`,system 提示置于顶层,流式解析 `content_block_delta`。厂商选错协议会报错,按表格对应即可。 + +### ⚠ 跨域 (CORS) 说明 + +浏览器直连 LLM API 可能被跨域策略拦截(因服务方而异)。若控制台报 CORS 错: +1. 改用本地起服务(见上)再试; +2. 仍不行,在「代理 URL」填一个转发地址(如 Cloudflare Worker / 自建简易代理),请求会改走该代理,Base URL 与 Key 不变。 + +## 🧠 AI 深化能力 + +在原有「一键生成整套」基础上,新增四个可控、渐进式的 AI 创作能力。入口在右侧 **🤖 AI 助手** 面板的顶部按钮区。 + +### 📋 大纲→逐页生成 + +原「生成整套」是一次性出结果,不可干预。深化后的工作流: + +1. 点 **📋 大纲** → 面板展开,输入主题 → **✨ 生成大纲** +2. AI 先出一份可编辑大纲(5-8 页),每页含:页类型(封面/目录/内容/金句/结尾)、标题、要点、补充指令 +3. 你可以自由编辑:改标题、增删要点、调页类型、删页、补 hint(如「数据页:用 stat+chart」「对比页:双 card」) +4. 点单条 **生成此页** 预览效果,或点 **全部生成** 串行生成(带进度 N/M,可中断) +5. 满意后 **应用到文稿** —— 把已生成的页面替换整份 deck + +这样你完全控制节奏与内容结构,不满意可只重生成某一条。 + +### 🎨 美化整份 + +遍历当前文稿的所有页,逐页调 AI 优化**排版**(位置/尺寸/字号/对齐/装饰),**文字内容不变**。适合内容已经到位但版式粗糙的场景。流式气泡实时显示进度「正在美化 3/8 页…」,可中断。 + +### 🎨 AI 配图 + +选中画布上的 **图片元素** 后,右侧属性面板会出现「**🎨 AI 配图**」按钮: + +1. 点击后输入图片描述(如「现代办公室协作场景,俯拍,柔和光线」) +2. 调用图像模型生成图片并自动填入该元素 + +需在 **⚙ 设置** 的「图像模型(可选)」区配置(DALL-E 协议 `/images/generations`): +- 图像 Base URL:留空则复用主配置的 Base URL +- 图像 API Key:留空则复用主配置的 Key +- 图像模型:默认 `dall-e-3`,智谱可用 `cogview-3`,其他兼容服务填对应模型名 + +### AI 配色/主题推荐 + +在对话中输入「给这套演示换个配色」「推荐一个适合科技主题的主题」等,AI 会返回 6 色配色方案 + 推荐背景,确认后应用为新主题(自动存入自定义主题库)。 + +## 📐 元素与图表 + +### 图表(8 种) + +选中图表元素后,右侧属性面板「图表类型」下拉可切换: + +| 类型 | 代号 | 适用场景 | +|------|------|----------| +| 柱状图 | `bar` | 离散类型对比(默认) | +| 条形图 | `hbar` | 标签较长的横向对比 | +| 折线图 | `line` | 时间序列/趋势变化 | +| 面积图 | `area` | 累计趋势/体量对比 | +| 饼图 | `pie` | 占比构成 | +| 环形图 | `doughnut` | 轻量占比 | +| 雷达图 | `radar` | 多维度能力对比(≥3 维) | +| 进度图 | `progress` | 完成率/百分比 | + +属性面板还可切换「图例」与「网格线」显示。 + +**数据格式(两种):** + +单系列(向后兼容): +```json +[ {"label":"配图","value":65}, {"label":"文字","value":10} ] +``` + +多系列(用于柱状/折线/面积/雷达对比): +```json +{ + "series": ["Q1", "Q2"], + "items": [ + {"label":"华东", "values":[120, 150]}, + {"label":"华南", "values":[80, 95]} + ] +} +``` + +全部用 SVG 自绘,零运行时依赖。修改 content 后图表自动重绘。 + +### 表格(table) + +content 为 **Markdown 管道表格**字符串: + +``` +| 指标 | Q1 | Q2 | +|------|----|----| +| 营收 | 120 | 150 | +| 利润 | 30 | 45 | +``` + +属性面板可切换「首行表头」。 + +### 代码(code) + +content 为代码原文,等宽字体渲染,保留缩进与换行。属性面板可选填语言(仅作右上角徽标提示,不做语法高亮)。 + +### 公式(formula) + +content 为 **LaTeX 子集**字符串(不含 `$$`)。支持: + +- 上下标:`E = mc^2`、`x_1`、`a^{bc}`、`a_{ij}` +- 分数:`\frac{a}{b}` +- 根号:`\sqrt{x}`、`\sqrt[n]{x}` +- 求和/积分:`\sum_{i=1}^n x_i`、`\int_0^1 f(x)dx` +- 希腊字母与运算符:`\alpha \beta \pi \infty \leq \geq \neq \times \pm \cdot \to \rightarrow \forall \exists \partial \nabla` 等 + +渲染由内置极简渲染器处理(纯 HTML/CSS,不引入 KaTeX)。未识别的 `\xxx` 会去掉反斜杠后原样显示。 + +## 📋 页面模板 + +点工具栏 **📋 模板** 打开模板面板: + +- **内置模板**(7 个):空白页 / 标题页 / 章节页 / 三卡片 / 对比页 / 金句页 / 数据页,点击即基于模板新建一页 +- **存为模板**:输入名称 → 把当前页的元素结构(含背景)保存为用户模板,下次可复用版式 +- 自存模板可删除,内置不可删 + +模板只记录「版式 + 占位内容」,应用到新页时会重新生成 id,不会与原页发生关联。 + +## 📁 文件结构 + +``` +u-ppt/ +├── index.html # 入口(挂载点 + 模块脚本) +├── package.json # Vue 3 + Vite + TypeScript +├── vite.config.ts +├── tsconfig.json +├── src/ +│ ├── main.ts # createApp 挂载 +│ ├── App.vue # 根组件:编辑/演示模式切换 +│ ├── core/ # 纯逻辑层(无 Vue 依赖,可单测) +│ │ ├── types.ts # Deck/Slide/Element/Style 类型 +│ │ ├── store.ts # 响应式状态/localStorage/CRUD/undo-redo +│ │ ├── ai.ts # AI 引擎:流式 SSE/JSON 解析/prompt +│ │ └── sample.ts # 主题预设/元素工厂/内置 8 页示例 +│ ├── composables/ +│ │ └── useEditor.ts # 画布拖拽/缩放/选中逻辑 +│ ├── components/ +│ │ ├── editor/ +│ │ │ ├── Toolbar.vue # 顶部工具栏 +│ │ │ ├── ThumbBar.vue # 左侧缩略图 +│ │ │ ├── Canvas.vue # 中间画布 +│ │ │ ├── ElementView.vue # 单元素渲染(9 种类型) +│ │ │ ├── PropsPanel.vue # 右侧属性面板 +│ │ │ └── AddGrid.vue # 添加元素网格 +│ │ ├── present/ +│ │ │ └── PresentMode.vue # 全屏演示 +│ │ ├── ai/ +│ │ │ └── AiPanel.vue # AI 聊天面板 +│ │ └── modals/ +│ │ ├── SettingsModal.vue +│ │ └── LibraryModal.vue +│ └── styles/ # 全局 CSS(由原 css/ 平移) +│ ├── base.css +│ ├── editor.css +│ ├── present.css +│ └── chat.css +└── README.md +``` + +## 🧱 数据模型 + +元素用**百分比坐标**存储,编辑画布与全屏演示共用一份数据,自动等比缩放。可 `store.exportJSON()` 导出 / `importJSON()` 导入,便于备份与迁移。 + +## 📌 范围说明 + +纯前端工具,不含:多人协作、云同步、复杂图表(折线/饼图)、音视频嵌入、导出 PPTX/PDF。后续可扩展。 diff --git a/index.html b/index.html new file mode 100644 index 0000000..6853df7 --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + + + u-ppt · 在线演示工具 + + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c307400 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1500 @@ +{ + "name": "u-ppt", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "u-ppt", + "version": "1.0.0", + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "~5.6.3", + "vite": "^6.0.5", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..585756e --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "u-ppt", + "version": "1.0.0", + "description": "在线演示工具 - Vue 3 + Vite + TypeScript 版", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "~5.6.3", + "vite": "^6.0.5", + "vue-tsc": "^2.2.0" + } +} diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..0693118 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,178 @@ + + + + diff --git a/src/components/ai/AiPanel.vue b/src/components/ai/AiPanel.vue new file mode 100644 index 0000000..a05d34b --- /dev/null +++ b/src/components/ai/AiPanel.vue @@ -0,0 +1,455 @@ + + + + diff --git a/src/components/ai/OutlinePanel.vue b/src/components/ai/OutlinePanel.vue new file mode 100644 index 0000000..4ac7952 --- /dev/null +++ b/src/components/ai/OutlinePanel.vue @@ -0,0 +1,352 @@ + + + + + + diff --git a/src/components/editor/AddGrid.vue b/src/components/editor/AddGrid.vue new file mode 100644 index 0000000..e85515d --- /dev/null +++ b/src/components/editor/AddGrid.vue @@ -0,0 +1,24 @@ + + + + diff --git a/src/components/editor/Canvas.vue b/src/components/editor/Canvas.vue new file mode 100644 index 0000000..7b99ccd --- /dev/null +++ b/src/components/editor/Canvas.vue @@ -0,0 +1,105 @@ + + + + diff --git a/src/components/editor/ChartView.vue b/src/components/editor/ChartView.vue new file mode 100644 index 0000000..5b1e2c9 --- /dev/null +++ b/src/components/editor/ChartView.vue @@ -0,0 +1,499 @@ + + + + + + diff --git a/src/components/editor/ElementView.vue b/src/components/editor/ElementView.vue new file mode 100644 index 0000000..0cad6e9 --- /dev/null +++ b/src/components/editor/ElementView.vue @@ -0,0 +1,317 @@ + + + + diff --git a/src/components/editor/PropsPanel.vue b/src/components/editor/PropsPanel.vue new file mode 100644 index 0000000..4e2cc24 --- /dev/null +++ b/src/components/editor/PropsPanel.vue @@ -0,0 +1,355 @@ + + + + diff --git a/src/components/editor/ThumbBar.vue b/src/components/editor/ThumbBar.vue new file mode 100644 index 0000000..e00d98a --- /dev/null +++ b/src/components/editor/ThumbBar.vue @@ -0,0 +1,63 @@ + + + + + + diff --git a/src/components/editor/Toolbar.vue b/src/components/editor/Toolbar.vue new file mode 100644 index 0000000..def6820 --- /dev/null +++ b/src/components/editor/Toolbar.vue @@ -0,0 +1,79 @@ + + + + diff --git a/src/components/modals/LibraryModal.vue b/src/components/modals/LibraryModal.vue new file mode 100644 index 0000000..043a3dc --- /dev/null +++ b/src/components/modals/LibraryModal.vue @@ -0,0 +1,154 @@ + + + + diff --git a/src/components/modals/SettingsModal.vue b/src/components/modals/SettingsModal.vue new file mode 100644 index 0000000..2f87e6a --- /dev/null +++ b/src/components/modals/SettingsModal.vue @@ -0,0 +1,192 @@ + + + + + + diff --git a/src/components/modals/TemplateModal.vue b/src/components/modals/TemplateModal.vue new file mode 100644 index 0000000..46aa569 --- /dev/null +++ b/src/components/modals/TemplateModal.vue @@ -0,0 +1,189 @@ + + + + + + diff --git a/src/components/present/PresentMode.vue b/src/components/present/PresentMode.vue new file mode 100644 index 0000000..61e2caa --- /dev/null +++ b/src/components/present/PresentMode.vue @@ -0,0 +1,252 @@ + + + + diff --git a/src/composables/useEditor.ts b/src/composables/useEditor.ts new file mode 100644 index 0000000..b618905 --- /dev/null +++ b/src/composables/useEditor.ts @@ -0,0 +1,117 @@ +/* ===================================================================== + * useEditor.ts — 画布拖拽/缩放/选中逻辑(从 editor.js 提取) + * 用 Vue ref 暴露拖拽会话状态,供 Canvas.vue 响应式绑定 + * ===================================================================== */ +import { ref, type Ref } from 'vue' +import { store } from '../core/store' + +interface DragSession { + mode: 'move' | 'resize' + id: string + ax?: number // resize 水平轴:-1/0/1 + ay?: number // resize 垂直轴:-1/0/1 + startX: number + startY: number + startW: number + startH: number + px0: number + py0: number + rectW: number + rectH: number + result?: { x: number; y: number; w: number; h: number } +} + +export function useEditor() { + const drag: Ref = ref(null) + const editing = ref(false) // 是否在 contenteditable 编辑中(暂停拖拽) + + /** 鼠标按下:空白取消选中 / 手柄缩放 / 元素拖拽 */ + function onCanvasMouseDown(e: MouseEvent, canvasEl: HTMLElement) { + if (editing.value) 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) { + store.selectElement(null) + return + } + if (handle) { + const id = handle.parentElement!.dataset.id! + startResize(id, handle.dataset.handle!, e, canvasEl) + e.preventDefault() + return + } + const id = target!.dataset.id! + startDrag(id, e, canvasEl) + e.preventDefault() + } + + function startDrag(id: string, e: MouseEvent, canvasEl: HTMLElement) { + if (store.getSelectedId() !== id) store.selectElement(id) + const el = store.findElement(id); if (!el) return + const rect = canvasEl.getBoundingClientRect() + drag.value = { + mode: 'move', id, + startX: el.x, startY: el.y, startW: el.w, startH: el.h, + px0: e.clientX, py0: e.clientY, rectW: rect.width, rectH: rect.height + } + } + + const AXES: Record = { + tl: [-1, -1], tm: [0, -1], tr: [1, -1], + lm: [-1, 0], rm: [1, 0], + bl: [-1, 1], bm: [0, 1], br: [1, 1] + } + + function startResize(id: string, dir: string, e: MouseEvent, canvasEl: HTMLElement) { + const el = store.findElement(id); if (!el) return + const rect = canvasEl.getBoundingClientRect() + const [ax, ay] = AXES[dir] || [1, 1] + drag.value = { + mode: 'resize', id, ax, ay, + startX: el.x, startY: el.y, startW: el.w, startH: el.h, + px0: e.clientX, py0: e.clientY, rectW: rect.width, rectH: rect.height + } + } + + function onMouseMove(e: MouseEvent) { + 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 + let nx = d.startX, ny = d.startY, nw = d.startW, nh = d.startH + + if (d.mode === 'move') { + nx = d.startX + dx; ny = d.startY + dy + } else { + const MIN = 3 + if (d.ax === 1) { nw = Math.max(MIN, d.startW + dx) } + else if (d.ax === -1) { nw = Math.max(MIN, d.startW - dx); nx = d.startX + (d.startW - nw) } + if (d.ay === 1) { nh = Math.max(MIN, d.startH + dy) } + else if (d.ay === -1) { nh = Math.max(MIN, d.startH - dy); ny = d.startY + (d.startH - nh) } + } + d.result = { x: nx, y: ny, w: nw, h: nh } + drag.value = { ...d } // 触发响应式更新 + } + + function onMouseUp() { + const d = drag.value + if (!d) return + const r = d.result + const id = d.id + drag.value = null + if (r) store.updateElement(id, r) + } + + function onContentEditableFocus() { editing.value = true } + + return { + drag, + editing, + onCanvasMouseDown, + onMouseMove, + onMouseUp, + onContentEditableFocus + } +} diff --git a/src/core/ai.ts b/src/core/ai.ts new file mode 100644 index 0000000..b31df25 --- /dev/null +++ b/src/core/ai.ts @@ -0,0 +1,611 @@ +/* ===================================================================== + * ai.ts — AI 内容创作引擎 + * - OpenAI 兼容 /chat/completions + * - Anthropic 兼容 /v1/messages + * - 流式 SSE 解析、AbortController 中断 + * - 三类调用:generate / polish / chat + * 由 ai.js 迁移:逻辑几乎不变,仅改为 TS + ESM + * ===================================================================== */ +import type { AiOp, ChartItem, Deck, Slide, SlideElement, ElementStyle, RichLine } from './types' +import { elementTypes, uid } from './sample' +import { store } from './store' +import { normSegments } from './richtext' + +const SEP = '%%PPT_JSON%%' // 对话模式中,自然语言回复与结构化操作的分隔标记 +const VALID_TYPES = ['title', 'text', 'list', 'stat', 'quote', 'image', 'shape', 'chart', 'card', 'table', 'code', 'formula'] as const + +/* 智谱/通用错误码 → 可操作提示 */ +const ERROR_HINTS: Record = { + '1113': '该 Key 账户按量计费余额不足或无资源包。智谱 Coding Plan 套餐额度仅限官方工具(Claude Code 等)使用,网页应用需走按量计费——请到 bigmodel.cn 充值后重试。', + '1309': 'GLM Coding Plan 套餐已到期,需续订后使用。', + '1211': '模型名错误,请在设置里检查模型名(如 glm-4.6)。', + '1234': '模型名错误,请检查模型名拼写。', + '1123': 'API Key 无效或已过期,请检查设置中的 Key。', + '1303': '该 Key 无对应模型权限,请更换模型或 Key。' +} +const ERROR_HINTS_BY_STATUS: Record = { + 401: '鉴权失败:API Key 无效或格式错误,请检查设置。', + 403: '无权限或来源被拒:Key 无该模型权限,或调用来源受限。', + 404: '接口路径错误:请检查 Base URL 与所选协议是否匹配。', + 429: '限流或额度不足,请稍后重试或检查账户额度。' +} + +/* ============================================================ + * Prompt 模板 + * ============================================================ */ +const SYS_BASE = + '你是「u-ppt」的内容创作助手,擅长把主题变成结构清晰、视觉现代、带入场动效的中文演示稿。\n' + + '输出必须严格遵循下面的数据模型,坐标用百分比(0-100),字号为数字。\n\n' + + '幻灯片模型:\n' + + '{ "slides": [ { "background": "bg|panel|primary|accent|g-primary|g-deep|g-soft", "elements": [ 元素, ... ] } ] }\n' + + '背景:bg/panel=浅底;primary/accent=纯色深底;g-primary=主→强调渐变(深);g-deep=深色渐变;g-soft=浅色渐变。\n\n' + + '元素类型 type:title 标题 | text 正文 | list 列表 | stat 数据 | quote 金句 | image 图片 | shape 形状 | chart 图表 | card 卡片 | table 表格 | code 代码 | formula 公式\n' + + '元素:{ "type":..., "x":数字,"y":数字,"w":数字,"h":数字 (0-100), "content":字符串, "style":{...} }\n' + + ' - title/text/list/quote:content 为文字,list 用 \\n 分多行\n' + + ' - stat:content 为大数字(如 "65%"),style.label 为说明\n' + + ' - card:content 第一行=标题、其余行=正文;style.accent=顶部色条键,style.icon=emoji 图标\n' + + ' - shape:style.shapeType=rect|circle|triangle,style.fill=颜色键,style.gradient=true 渐变,style.opacity=0~1\n' + + ' - chart:content 为 JSON,两种格式:\n' + + ' 单系列:[{"label":"","value":数字}, ...]\n' + + ' 多系列:{"series":["Q1","Q2"], "items":[{"label":"华东","values":[120,150]}, ...]}\n' + + ' style.max=最大值,style.chartType=bar|line|area|pie|doughnut|radar|hbar|progress(默认 bar)\n' + + ' style.legend=true 显示图例,style.grid=true 显示网格线,style.stack=true 堆叠(多系列柱状)\n' + + ' 图表类型选择建议:柱状(bar)对比离散类型;折线(line)显示趋势;面积(area)累计趋势;\n' + + ' 饼图(pie)占比构成;环形(doughnut)轻量占比;雷达(radar)多维度对比;条形(hbar)长标签对比;进度(progress)完成率。\n' + + ' - table:content 为 Markdown 管道表格字符串(首行表头,用 | 分列,换行分行),style.header=true 首行加粗\n' + + ' - code:content 为代码文本(保留符原样,不要转义),style.lang=语言如 js/python(可选)\n' + + ' - formula:content 为 LaTeX 公式字符串(不含 $$ 分隔符),如 "E = mc^2"、"\\sum_{i=1}^n x_i"\n' + + ' - image:content 留空\n\n' + + '可选 segments 字段(结构化富文本,同一行内不同片段可有不同样式):\n' + + ' segments: [[ {"text":"华东 "}, {"text":"增长 23%","bold":true,"color":"accent"} ], ...]\n' + + ' 每个 segment 支持:bold/italic/underline/strike/color(主题键或#hex)/highlight(黄底)/code/sup/sub/fontSize/link\n' + + ' 仅在需要行内混合样式时用 segments;纯文本继续用 content 即可。segments 与 content 二选一。\n\n' + + 'style 公共字段:fontSize 数字、color 颜色键、align left|center|right、bold 布尔、italic 布尔、label/labelColor/labelSize、radius\n' + + '颜色 color/fill/accent:一律用主题键 primary|accent|text|muted(系统按背景深浅自动反相,深底文字自动变白,不要手填 #ffffff)。\n\n' + + '入场动画 style.anim(重要,给主要元素都加上,演示时逐个入场):\n' + + ' 可选:fade-up(默认上浮) | fade | scale | zoom | slide-l | slide-r | pop(弹现) | rotate(旋转) | bounce(弹跳) | flip(翻转) | blur(聚焦)\n' + + ' 建议:标题/金句用 pop 或 scale;正文/列表用 fade-up;卡片用 fade-up 或 slide-l/slide-r;装饰形状用 scale。\n\n' + + '版式规范:\n' + + '- 画布逻辑 1280×720。封面标题 y≈30 h≈18;内容标题 y≈8-10。\n' + + '- 字号:title 44-66、text 22-28、list 24-30、stat 数字 64-80、quote 40-52。\n' + + '- 一页一个观点,留白充足,列表不超过 5 条。\n' + + '- 现代版式:多用 card 分组;封面/金句/结尾用 g-primary;目录用 3-4 张卡片网格;数据页 stat+chart。\n' + + '- emoji 极克制:默认不给 card.icon,除非确有助于理解,多数卡片留空。\n\n' + + '内容准则(重要——避免「AI 味」,写得像该领域的真人):\n' + + '- 标题写具体事实而非口号:「华东 Q3 增长 23%」而非「业绩腾飞」;「同屏字+口述记忆降 50%」而非「效率革命」。\n' + + '- 正文要有实质:具体数字、案例、步骤、来源;少用「赋能/助力/打造/引领/开启/一站式」这类空词。\n' + + '- 禁用万能金句:「少即是多」「一图胜千言」「未来已来」「让 X 更简单」「不止是 X,更是 Y」。\n' + + '- 列表每条是可执行/可验证的要点,不是三词排比(避免「清晰、简洁、高效」式口号)。\n' + + '- 有判断、有观点,像从业者写的;数据尽量给来源(人名/书名/研究)。\n' + + '- 不要使用 markdown 代码块,直接输出 JSON。' + +const SYS_GENERATE = + SYS_BASE + + '\n任务:根据用户主题生成一套完整演示(封面、目录、3-5 页内容、结尾,共 6-8 页)。\n' + + '要求:每个主要元素都加 style.anim;封面与结尾用 g-primary;至少 2 张 card;数据页用 stat+chart。\n' + + '严格输出:{"action":"create_all","slides":[...]}' + +const SYS_POLISH = + SYS_BASE + + '\n任务:按指令优化给定的“单页”,保持主题一致、布局合理,并为各元素补充 style.anim。\n' + + '严格输出:{"action":"update_page","slides":[ 优化后的单页 ],"note":"一句话说明改动"}' + +const SYS_CHAT = + SYS_BASE + + '\n任务:与用户对话式编辑当前演示。先用中文流式回复用户(解释你将做什么),' + + '如需修改 PPT,在回复最后另起一行输出分隔标记 ' + SEP + ',紧随其后输出 JSON 操作。\n' + + 'JSON 操作格式:{"action":"add_page|update_page|create_all|answer","slides":[...],"target":页码(从1开始,可选)}\n' + + '- add_page:在 target 页后插入新页;- update_page:替换 target 页;- create_all:整体替换;- answer:仅回答不改稿。\n' + + '没有改动时不要输出分隔标记,仅回复即可。' + +/* ============================================================ + * 上下文摘要 + * ============================================================ */ +function deckContext(currentIdx: number, selectedEl?: SlideElement | null): string { + const deck: Deck = store.getDeck() + const lines = ['当前主题: ' + deck.theme + ',共 ' + deck.slides.length + ' 页。'] + deck.slides.forEach((s, i) => { + const types = s.elements.map(e => e.type).join('/') + const head = (s.elements[0] && s.elements[0].type === 'title') ? (' 标题:"' + (s.elements[0].content || '') + '"') : '' + lines.push('第' + (i + 1) + '页 [' + types + ']' + head + (i === currentIdx ? ' ← 当前页' : '')) + }) + lines.push('\n当前页(第' + (currentIdx + 1) + '页)完整JSON:\n' + JSON.stringify(store.currentSlide.value)) + // 选中元素上下文:让 AI 知道用户正在编辑哪个元素,对话直接围绕它 + if (selectedEl) { + const typeLabel = selectedEl.type + const preview = (selectedEl.content || '').slice(0, 100) + lines.push('\n【用户当前选中的元素】(第' + (currentIdx + 1) + '页)') + lines.push('类型: ' + typeLabel + ',内容预览: "' + preview + '"') + lines.push('完整JSON: ' + JSON.stringify(selectedEl)) + lines.push('用户接下来的对话默认针对此元素,除非明确说整页/整套。') + } + return lines.join('\n') +} + +/* ============================================================ + * 底层:流式请求 + * ============================================================ */ +interface StreamOpts { + jsonMode?: boolean + onVisible?: (text: string) => void + signal?: AbortSignal +} + +interface Message { role: 'system' | 'user' | 'assistant'; content: string } + +async function streamChat(messages: Message[], opts: StreamOpts): Promise<{ json: any; reply: string; op: any }> { + const cfg = store.getCfg() + const isLocal = /localhost|127\.0\.0\.1/i.test(cfg.base || '') + if (!cfg.key && !isLocal) throw new Error('未配置 API Key,请点击右上角 ⚙ 填写。') + if (cfg.protocol === 'anthropic') return runAnthropic(messages, opts, cfg) + return runOpenAI(messages, opts, cfg) +} + +function apiUrl(cfg: { proxy: string; base: string }): string { + return (cfg.proxy || cfg.base || '').replace(/\/+$/, '') +} + +async function postJSON(url: string, headers: Record, body: unknown, signal?: AbortSignal): Promise { + try { + return await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal }) + } catch (e: any) { + if (e.name === 'AbortError') throw e + throw new Error('请求失败(可能是 CORS 跨域拦截)。可在设置中配置"代理 URL"。\n' + e.message) + } +} + +// OpenAI 兼容 +async function runOpenAI(messages: Message[], opts: StreamOpts, cfg: ReturnType) { + const url = apiUrl(cfg) + '/chat/completions' + const body: Record = { model: cfg.model || 'glm-4.6', messages, stream: true, temperature: 0.75 } + if (opts.jsonMode) body.response_format = { type: 'json_object' } + const resp = await postJSON(url, + { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + cfg.key }, body, opts.signal) + return consumeStream(resp, (obj: any) => { + const ch = obj.choices && obj.choices[0] + return ch && ch.delta && ch.delta.content + }, opts) +} + +// Anthropic 兼容 +async function runAnthropic(messages: Message[], opts: StreamOpts, cfg: ReturnType) { + const url = apiUrl(cfg) + '/v1/messages' + const sysParts: string[] = [] + const msgs: Message[] = [] + messages.forEach(m => { + if (m.role === 'system') { if (m.content) sysParts.push(m.content) } + else msgs.push({ role: m.role, content: m.content }) + }) + const body: Record = { + model: cfg.model || 'glm-4.6', + messages: msgs, + max_tokens: opts.jsonMode ? 8192 : 4096, + stream: true, + temperature: 0.75 + } + if (sysParts.length) body.system = sysParts.join('\n\n') + const headers = { + 'Content-Type': 'application/json', + 'x-api-key': cfg.key, + 'anthropic-version': '2023-06-01', + 'anthropic-dangerous-direct-browser-access': 'true' + } + const resp = await postJSON(url, headers, body, opts.signal) + return consumeStream(resp, (obj: any) => { + if (obj.type === 'content_block_delta' && obj.delta) return obj.delta.text || '' + return null + }, opts) +} + +// 通用 SSE 消费 +async function consumeStream(resp: Response, extractDelta: (obj: any) => string | null, opts: StreamOpts): Promise<{ json: any; reply: string; op: any }> { + if (!resp.ok) { + let t = ''; try { t = await resp.text() } catch (e) {} + let msg = '接口返回 ' + resp.status + let hint = ERROR_HINTS_BY_STATUS[resp.status] + try { + const err = JSON.parse(t).error + if (err) { + msg += ' [' + err.code + '] ' + (err.message || '') + if (err.code && ERROR_HINTS[err.code]) hint = ERROR_HINTS[err.code] + } else { msg += ' ' + (t || '').slice(0, 200) } + } catch (e) { msg += ' ' + (t || '').slice(0, 200) } + if (hint) msg += '\n\n💡 ' + hint + throw new Error(msg) + } + const reader = resp.body!.getReader() + const dec = new TextDecoder('utf-8') + let sseBuf = '' + let full = '' + let pending = '' + let sepMode = false + + function feed(delta: string | null) { + if (delta == null) return + full += delta + if (opts.jsonMode || sepMode) return + pending += delta + const i = pending.indexOf(SEP) + if (i >= 0) { + emit(pending.slice(0, i)) + sepMode = true + pending = '' + return + } + const keep = SEP.length - 1 + if (pending.length > keep) { + const safe = pending.slice(0, pending.length - keep) + emit(safe) + pending = pending.slice(safe.length) + } + } + function emit(text: string) { if (opts.onVisible) opts.onVisible(text) } + + for (;;) { + const chunk = await reader.read() + if (chunk.done) break + sseBuf += dec.decode(chunk.value, { stream: true }) + const lines = sseBuf.split('\n') + sseBuf = lines.pop()! + for (let k = 0; k < lines.length; k++) { + const line = lines[k].trim() + if (!line || line.indexOf('data:') !== 0) continue + const payload = line.slice(5).trim() + if (!payload || payload === '[DONE]') continue + let obj: any; try { obj = JSON.parse(payload) } catch (e) { continue } + const delta = extractDelta(obj) + if (delta != null) feed(delta) + } + } + // 末帧残留 data: 行补解析 + const tail = sseBuf.trim() + if (tail.indexOf('data:') === 0) { + const tp = tail.slice(5).trim() + if (tp && tp !== '[DONE]') { + let to: any; try { to = JSON.parse(tp) } catch (e) { to = null } + if (to) { const td = extractDelta(to); if (td != null) feed(td) } + } + } + if (!opts.jsonMode && !sepMode && pending) emit(pending) + + if (opts.jsonMode) return { json: tryParse(full), reply: '', op: null } + const parts = full.split(SEP) + return { + json: null, + reply: (parts[0] || '').trim(), + op: parts.length > 1 ? tryParse(parts.slice(1).join(SEP)) : null + } +} + +function tryParse(s: string): any { + if (!s) return null + s = String(s).replace(/```json/gi, '').replace(/```/g, '').trim() + const i = s.indexOf('{'), j = s.lastIndexOf('}') + if (i < 0 || j < 0) return null + const candidate = s.slice(i, j + 1) + try { return JSON.parse(candidate) } + catch (e) { + try { return JSON.parse(candidate.replace(/,(\s*[}\]])/g, '$1')) } + catch (e2) { return null } + } +} + +/* ============================================================ + * 数据规范化(AI 输出 → 可入库) + * ============================================================ */ +function validColor(v: string): string | undefined { + if (!v) return v + if (v.charAt(0) === '#') return /^#[0-9a-f]{3,8}$/i.test(v) ? v : undefined + return ['primary', 'accent', 'text', 'muted'].indexOf(v) >= 0 ? v : undefined +} + +function normStyle(st: any): ElementStyle { + st = st || {} + const out: any = { ...st } + if (out.fontSize != null) out.fontSize = Number(out.fontSize) || 24 + if (out.bold != null) out.bold = !!out.bold + if (out.italic != null) out.italic = !!out.italic + if (out.labelSize != null) out.labelSize = Number(out.labelSize) || 16 + if (out.radius != null) out.radius = Number(out.radius) || 0 + if (out.max != null) out.max = Number(out.max) || 0 + // 图表子类型校验 + const VALID_CHART_TYPES = ['bar', 'line', 'pie', 'area', 'doughnut', 'radar', 'hbar', 'progress'] + if (out.chartType != null && !VALID_CHART_TYPES.includes(out.chartType)) delete out.chartType + if (out.legend != null) out.legend = !!out.legend + if (out.stack != null) out.stack = !!out.stack + if (out.grid != null) out.grid = !!out.grid + if (out.header != null) out.header = !!out.header + if (out.inline != null) out.inline = !!out.inline + if (typeof out.lang === 'string' && out.lang.length > 16) out.lang = out.lang.slice(0, 16) + ;['color', 'fill', 'accent', 'labelColor'].forEach(k => { + if (validColor(out[k]) === undefined && out[k] != null) delete out[k] + }) + if (typeof out.icon === 'string' && out.icon.length > 8) out.icon = out.icon.slice(0, 8) + return out +} + +function normElement(e: any): SlideElement | null { + if (!e || typeof e !== 'object') return null + const type = (VALID_TYPES as readonly string[]).indexOf(e.type) >= 0 ? e.type : 'text' + const out: SlideElement = { + id: e.id || uid('el'), + type: type as SlideElement['type'], + x: clampNum(e.x, 0, 100, 10), + y: clampNum(e.y, 0, 100, 10), + w: clampNum(e.w, 3, 100, 60), + h: clampNum(e.h, 3, 100, 15), + content: (e.content == null ? '' : String(e.content)), + style: normStyle(e.style || {}) + } + // 可选 segments(结构化富文本) + if (e.segments) { + const segs = normSegments(e.segments) + if (segs) (out as any).segments = segs + } + return out +} + +const VALID_BGS = ['bg', 'panel', 'primary', 'accent', 'g-primary', 'g-deep', 'g-soft'] + +function normSlide(s: any): Slide | null { + if (!s || typeof s !== 'object') return null + const bg = VALID_BGS.indexOf(s.background) >= 0 ? s.background + : (typeof s.background === 'string' && s.background.charAt(0) === '#' ? s.background : 'bg') + const els = (Array.isArray(s.elements) ? s.elements : []).map(normElement).filter(Boolean) as SlideElement[] + return { id: uid('s'), background: bg as Slide['background'], elements: els } +} + +function normSlides(arr: any[]): Slide[] { + return (Array.isArray(arr) ? arr : []).map(normSlide).filter((s): s is Slide => s !== null) +} + +function clampNum(v: number, lo: number, hi: number, dflt: number): number { + const n = Number(v) + if (isNaN(n)) return dflt + return Math.max(lo, Math.min(hi, n)) +} + +/* ============================================================ + * 高层 API + * ============================================================ */ + +/** 生成整套 */ +export async function generate(opts: { topic: string; count?: number; signal?: AbortSignal }): Promise<{ action: 'create_all'; slides: Slide[] }> { + const count = opts.count || 7 + const messages: Message[] = [ + { role: 'system', content: SYS_GENERATE }, + { role: 'user', content: '主题:' + opts.topic + '\n请生成约 ' + count + ' 页(含封面与结尾),中文内容。' } + ] + const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) + if (!r.json) throw new Error('AI 输出无法解析为 JSON,请重试。') + const slides = normSlides(r.json.slides || r.json) + if (!slides.length) throw new Error('AI 未生成有效幻灯片,请重试或换一个主题。') + return { action: 'create_all', slides } +} + +/** 润色单页 */ +export async function polish(opts: { slide: Slide; instruction?: string; signal?: AbortSignal }): Promise<{ action: 'update_page'; slide: Slide; note: string }> { + const instruction = opts.instruction || '让这页内容更有吸引力、表达更精炼' + const messages: Message[] = [ + { role: 'system', content: SYS_POLISH }, + { role: 'user', content: '指令:' + instruction + '\n当前页JSON:\n' + JSON.stringify(opts.slide) } + ] + const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) + if (!r.json) throw new Error('AI 输出无法解析,请重试。') + const arr = r.json.slides || (r.json.slide ? [r.json.slide] : (r.json.elements ? [r.json] : [])) + const slides = normSlides(arr) + if (!slides.length) throw new Error('AI 未返回有效页面。') + return { action: 'update_page', slide: slides[0], note: r.json.note || '' } +} + +/** 对话式编辑 */ +export async function chat(opts: { + history: Message[] + input: string + onVisible?: (text: string) => void + signal?: AbortSignal + /** 当前选中的元素(可选,注入上下文让对话围绕它) */ + selectedElement?: SlideElement | null +}): Promise<{ reply: string; op: AiOp | null }> { + const messages: Message[] = [{ role: 'system', content: SYS_CHAT + '\n\n' + deckContext(store.getCurrentIndex(), opts.selectedElement || null) }] + ;(opts.history || []).forEach(m => messages.push(m)) + messages.push({ role: 'user', content: opts.input }) + const r = await streamChat(messages, { jsonMode: false, onVisible: opts.onVisible, signal: opts.signal }) + return { reply: r.reply, op: normalizeOp(r.op) } +} + +function normalizeOp(json: any): AiOp | null { + if (!json) return null + let action: AiOp['action'] = json.action || 'answer' + const slides = normSlides(json.slides) + const target = json.target != null ? (Number(json.target) - 1) : null + if (action === 'update_page' && slides.length) action = 'update_page' + if (action === 'add_page' && slides.length) action = 'add_page' + if ((action === 'update_page' || action === 'add_page') && !slides.length) action = 'answer' + return { action, slides, target: isNaN(target as number) ? null : target, note: json.note || '' } +} + +export function isConfigured(): boolean { return !!(store.getCfg().key) } + +/* ============================================================ + * AI 深化能力:大纲 / 逐页生成 / 配图 / 主题建议 / 一键美化 + * ============================================================ */ + +import type { Outline, OutlineItem, ThemeSuggestion, ImageGenResult } from './types' + +/* ---------- Prompt 模板 ---------- */ + +const SYS_OUTLINE = + '你是「u-ppt」的演示策划助手。用户给你一个主题,你先制定大纲,不要直接写完整幻灯片。\n\n' + + '大纲要素:先拟定一个具体、有信息量的整份标题(不要「关于 X 的分享」这类空泛标题);\n' + + '然后拆成 5-8 页,每页给出:页类型 kind、标题 title、3-5 条要点 points、可选 hint。\n' + + 'kind 取值:cover(封面) / toc(目录) / content(内容) / quote(金句) / end(结尾)。\n' + + '封面与结尾各 1 页,目录可选。\n' + + 'hint 用来给后续生成幻灯片的 AI 补充指令,例如「数据页:用 stat+chart」「对比页:双 card 并置」「引言页:深色背景金句」。\n\n' + + '内容准则:\n' + + '- 标题写具体事实而非口号(「华东 Q3 增长 23%」而非「业绩腾飞」)。\n' + + '- 要点每条是可验证的论点或具体数字,不要三词排比。\n' + + '- 有判断、有观点,像该领域从业者写的;数据尽量给来源。\n' + + '- 禁用万能金句:「少即是多」「一图胜千言」「未来已来」。\n\n' + + '严格输出 JSON:\n' + + '{ "title": "整份标题", "items": [ { "kind":"cover", "title":"页标题", "points":["要点 1","要点 2"], "hint":"可选" } ] }\n' + + '不要使用 markdown 代码块,直接输出 JSON。' + +const SYS_GEN_PAGE = + SYS_BASE + + '\n任务:根据大纲中的一条,生成「一页」幻灯片。严格遵循要点与 hint,不要偏离主题。\n' + + '严格输出:{ "background":"...", "elements":[ ... ] }(单个 slide 对象,不要数组)。\n' + + 'kind=cover 用 g-primary 背景;kind=quote 用 g-deep;kind=end 用 g-primary;kind=content 数据页用 stat+chart。' + +const SYS_THEME = + '你是配色设计师。根据主题关键词,推荐一套现代、专业的 6 色配色(primary 主色、accent 强调色、bg 背景、panel 面板、text 正文、muted 次要文字)。\n\n' + + '要求:\n' + + '- 主色饱和度适中,不刺眼;强调色与主色形成对比但不冲突。\n' + + '- bg 与 panel 接近白色,差异轻微。\n' + + '- text 深于 primary,muted 为中性灰。\n' + + '- 所有颜色用 #hex 格式。\n\n' + + '可选背景推荐:bg/panel(浅底)/primary/accent(深底)/g-primary(主→强调渐变)/g-deep(深色渐变)。\n' + + '内容页优先 bg/panel;封面/结尾优先 g-primary;金句页优先 g-deep。\n\n' + + '严格输出 JSON:\n' + + '{ "name":"主题名","primary":"#...","accent":"#...","bg":"#...","panel":"#...","text":"#...","muted":"#...","recommendBg":"g-primary","reason":"一句话设计理由" }\n' + + '不要使用 markdown 代码块。' + +const SYS_BEAUTIFY = + SYS_BASE + + '\n任务:优化给定页面的「排版」(位置、尺寸、字号、对齐、装饰元素),保持文字内容不变。\n' + + '如果布局合理则微调;如果有明显问题(元素重叠、字号失衡、留白不足)则重排。\n' + + '严格输出:{ "background":"原背景或更合适的键", "elements":[ ... ], "note":"一句话说明" }\n' + + '不要改动文字内容本身,只优化视觉。' + +/* ---------- 1. 大纲生成 ---------- */ + +export async function outline(opts: { topic: string; count?: number; signal?: AbortSignal }): Promise { + const count = opts.count || 7 + const messages: Message[] = [ + { role: 'system', content: SYS_OUTLINE }, + { role: 'user', content: '主题:' + opts.topic + '\n请拟定约 ' + count + ' 页的大纲(含封面与结尾),中文。' } + ] + const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) + if (!r.json) throw new Error('AI 未返回有效大纲,请重试。') + const items: OutlineItem[] = (Array.isArray(r.json.items) ? r.json.items : []).map((it: any, i: number) => ({ + id: 'ol-' + Date.now() + '-' + i, + kind: ['cover', 'toc', 'content', 'quote', 'end'].includes(it.kind) ? it.kind : 'content', + title: String(it.title || '未命名').slice(0, 80), + points: Array.isArray(it.points) ? it.points.map((p: any) => String(p).slice(0, 200)).filter(Boolean).slice(0, 6) : [], + hint: it.hint ? String(it.hint).slice(0, 120) : undefined, + done: false + })) + if (!items.length) throw new Error('大纲为空,请重试或换一个主题。') + return { title: String(r.json.title || opts.topic).slice(0, 80), topic: opts.topic, items } +} + +/* ---------- 2. 按大纲条目生成单页 ---------- */ + +export async function generatePage(opts: { item: OutlineItem; index: number; total: number; signal?: AbortSignal }): Promise { + const user = + '大纲第 ' + (opts.index + 1) + '/' + opts.total + ' 页:\n' + + '类型:' + opts.item.kind + '\n' + + '标题:' + opts.item.title + '\n' + + '要点:' + opts.item.points.map((p, i) => (i + 1) + '. ' + p).join('\n') + + (opts.item.hint ? '\n补充指令:' + opts.item.hint : '') + const messages: Message[] = [ + { role: 'system', content: SYS_GEN_PAGE }, + { role: 'user', content: user } + ] + const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) + if (!r.json) throw new Error('AI 未返回有效页面。') + const slides = normSlides([r.json]) + if (!slides.length) throw new Error('AI 输出无法解析为页面。') + return slides[0] +} + +/* ---------- 3. AI 配图 ---------- */ + +/** 图像生成——当前支持 DALL-E 协议(OpenAI /images/generations)与通用返回 URL 的网关 */ +export async function generateImage(opts: { prompt: string; signal?: AbortSignal }): Promise { + const cfg = store.getCfg() + const imgBase = (cfg.imgBase || cfg.base || '').replace(/\/+$/, '') + const imgKey = cfg.imgKey || cfg.key + const imgModel = cfg.imgModel || 'dall-e-3' + if (!imgKey) throw new Error('未配置 API Key,无法生成图片。') + const url = imgBase + '/images/generations' + const body = { + model: imgModel, + prompt: opts.prompt, + n: 1, + size: '1024x1024', + response_format: 'b64_json' + } + let resp: Response + try { + resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + imgKey }, + body: JSON.stringify(body), + signal: opts.signal + }) + } catch (e: any) { + if (e.name === 'AbortError') throw e + throw new Error('图片生成请求失败(可能是 CORS)。\n' + e.message) + } + if (!resp.ok) { + let t = ''; try { t = await resp.text() } catch (e) {} + throw new Error('图片生成失败 ' + resp.status + ':' + t.slice(0, 200)) + } + const json = await resp.json() + const item = json.data && json.data[0] + if (!item) throw new Error('图片生成返回为空。') + const dataUrl = item.b64_json + ? 'data:image/png;base64,' + item.b64_json + : (item.url || '') + if (!dataUrl) throw new Error('图片生成返回无图像数据。') + return { url: dataUrl, revisedPrompt: item.revised_prompt } +} + +/** 判断是否配置了图像生成能力(当前以主 Key 是否存在为信号) */ +export function isImageConfigured(): boolean { + const cfg = store.getCfg() + return !!(cfg.imgKey || cfg.key) +} + +/* ---------- 4. AI 主题/配色建议 ---------- */ + +export async function suggestTheme(opts: { topic: string; signal?: AbortSignal }): Promise { + const messages: Message[] = [ + { role: 'system', content: SYS_THEME }, + { role: 'user', content: '主题关键词:' + opts.topic + '\n请推荐一套配色,并说明设计理由(一句话)。' } + ] + const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) + if (!r.json) throw new Error('AI 未返回有效配色方案。') + const j = r.json + // 基本校验,非法 hex 回退中性灰 + const safeHex = (v: string, dflt: string) => /^#[0-9a-f]{6}$/i.test(v) ? v : dflt + return { + name: String(j.name || '自定义').slice(0, 20), + primary: safeHex(j.primary, '#4f46e5'), + accent: safeHex(j.accent, '#06b6d4'), + bg: safeHex(j.bg, '#ffffff'), + panel: safeHex(j.panel, '#f8fafc'), + text: safeHex(j.text, '#1e293b'), + muted: safeHex(j.muted, '#64748b'), + recommendBg: j.recommendBg || 'g-primary', + reason: j.reason ? String(j.reason).slice(0, 200) : undefined + } +} + +/* ---------- 5. 一键美化单页(排版优化,内容不变) ---------- */ + +export async function beautifyPage(opts: { slide: Slide; signal?: AbortSignal }): Promise<{ slide: Slide; note: string }> { + const messages: Message[] = [ + { role: 'system', content: SYS_BEAUTIFY }, + { role: 'user', content: '当前页 JSON:\n' + JSON.stringify(opts.slide) } + ] + const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) + if (!r.json) throw new Error('AI 未返回有效页面。') + const slides = normSlides([r.json]) + if (!slides.length) throw new Error('AI 输出无法解析。') + return { slide: slides[0], note: r.json.note || '' } +} + +/** 导出 ChartItem 类型供组件用 */ +export type { ChartItem, Outline, OutlineItem, ThemeSuggestion, ImageGenResult } diff --git a/src/core/inline.ts b/src/core/inline.ts new file mode 100644 index 0000000..6c2b1f9 --- /dev/null +++ b/src/core/inline.ts @@ -0,0 +1,54 @@ +/* ===================================================================== + * inline.ts — Markdown 行内格式渲染 + * 把 **加粗** / *斜体* / ~~删除线~~ / ==高亮== / `代码` / ~下标~ / ^上标^ 转 HTML + * 先转义 HTML,再按顺序替换(避免互相破坏) + * ===================================================================== */ + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>') +} + +/** + * 把 Markdown 行内语法转 HTML。 + * 输入应为一行文本(不含换行)。 + * 安全:先转义 HTML,再插入受控标签。 + */ +export function renderInline(text: string): string { + if (!text) return '' + let s = escapeHtml(text) + + // 加粗 **text**(必须先于斜体,避免 ** 与 * 冲突) + s = s.replace(/\*\*([^*]+)\*\*/g, '$1') + // 斜体 *text* 或 _text_ + s = s.replace(/(?$1') + s = s.replace(/(?$1') + // 删除线 ~~text~~ + s = s.replace(/~~([^~]+)~~/g, '$1') + // 高亮 ==text== + s = s.replace(/==([^=]+)==/g, '$1') + // 行内代码 `code` + s = s.replace(/`([^`]+)`/g, '$1') + // 下标 ~text~(与删除线区分:单个 ~) + s = s.replace(/(?$1') + // 上标 ^text^ + s = s.replace(/\^([^\^\s][^\^]*?)\^/g, '$1') + + return s +} + +/** + * 渲染多行文本:逐行 renderInline,用
连接。 + * 用于 title / text / quote 元素的 content。 + */ +export function renderInlineMulti(text: string): string { + if (!text) return '' + return text.split('\n').map(renderInline).join('
') +} + +/** + * 是否包含 Markdown 行内语法(用于决定是否走 rich 渲染路径) + */ +export function hasInlineSyntax(text: string): boolean { + if (!text) return false + return /\*\*[^*]+\*\*|(?/g, '>') +} + +/* ---------- segments → HTML(渲染用) ---------- */ + +function segmentToHtml(seg: RichSegment): string { + let html = escapeHtml(seg.text) + if (!html) return '' + + // 嵌套标签(顺序:外层 → 内层) + if (seg.code) html = `${html}` + if (seg.sup) html = `${html}` + if (seg.sub) html = `${html}` + if (seg.strike) html = `${html}` + if (seg.underline) html = `${html}` + if (seg.italic) html = `${html}` + if (seg.bold) html = `${html}` + + // 颜色 / 字号 / 高亮 / 链接 → 用 span 包裹 + const styles: string[] = [] + if (seg.color) styles.push(`color:${seg.color}`) + if (seg.fontSize) styles.push(`font-size:${seg.fontSize}px`) + if (seg.highlight) styles.push('background:rgba(245,158,11,.25);padding:0 .15em;border-radius:2px') + + if (seg.link) { + const safeHref = /^(https?:|\/)/.test(seg.link) ? seg.link : '#' + html = `${html}` + } else if (styles.length) { + html = `${html}` + } + + return html +} + +/** segments → 完整 HTML(多行,用
分隔) */ +export function segmentsToHtml(lines: RichLine[]): string { + if (!lines || !lines.length) return '' + return lines + .map(line => (line.segments || []).map(segmentToHtml).join('')) + .join('
') +} + +/* ---------- segments → 纯文本(AI context / 搜索 / 兼容) ---------- */ + +export function segmentsToPlain(lines: RichLine[]): string { + if (!lines || !lines.length) return '' + return lines + .map(line => (line.segments || []).map(s => s.text || '').join('')) + .join('\n') +} + +/* ---------- 纯文本 → segments(升级) ---------- */ + +export function plainToSegments(text: string): RichLine[] { + if (!text) return [] + return text.split('\n').map(line => ({ + segments: line ? [{ text: line }] : [] + })) +} + +/* ---------- Markdown → segments(从方案 A 无损迁移) ---------- */ + +const MD_PATTERNS: Array<{ regex: RegExp; parse: (m: string, inner: string) => Partial | null }> = [ + { regex: /\*\*([^*]+)\*\*/, parse: (_m, inner) => ({ text: inner, bold: true }) }, + { regex: /(? ({ text: inner, italic: true }) }, + { regex: /~~([^~]+)~~/, parse: (_m, inner) => ({ text: inner, strike: true }) }, + { regex: /==([^=]+)==/, parse: (_m, inner) => ({ text: inner, highlight: true }) }, + { regex: /`([^`]+)`/, parse: (_m, inner) => ({ text: inner, code: true }) }, + { regex: /\^([^\^\s][^\^]*?)\^/, parse: (_m, inner) => ({ text: inner, sup: true }) }, + { regex: /(? ({ text: inner, sub: true }) } +] + +/** 把一行 Markdown 文本解析为 segments */ +export function markdownLineToSegments(line: string): RichSegment[] { + if (!line) return [] + const segments: RichSegment[] = [] + let remaining = line + + while (remaining.length > 0) { + let earliestIdx = -1 + let earliestMatch: RegExpMatchArray | null = null + let earliestParser: typeof MD_PATTERNS[0]['parse'] | null = null + + for (const pat of MD_PATTERNS) { + const m = remaining.match(pat.regex) + if (m && m.index !== undefined && (earliestIdx === -1 || m.index < earliestIdx)) { + earliestIdx = m.index + earliestMatch = m + earliestParser = pat.parse + } + } + + if (earliestIdx === -1 || !earliestMatch || !earliestParser) { + // 无更多匹配,剩余全部为纯文本 + segments.push({ text: remaining }) + break + } + + // 匹配前的纯文本 + if (earliestIdx > 0) { + segments.push({ text: remaining.slice(0, earliestIdx) }) + } + // 匹配的格式段 + const parsed = earliestParser(earliestMatch[0], earliestMatch[1]) + if (parsed) segments.push(parsed as RichSegment) + remaining = remaining.slice(earliestIdx + earliestMatch[0].length) + } + + return segments +} + +/** 把多行 Markdown 文本解析为 RichLine[] */ +export function markdownToSegments(text: string): RichLine[] { + if (!text) return [] + return text.split('\n').map(line => ({ segments: markdownLineToSegments(line) })) +} + +/* ---------- 合并相邻相同 segment(压缩存储) ---------- */ + +function segEqual(a: RichSegment, b: RichSegment): boolean { + return a.bold === b.bold && a.italic === b.italic && a.underline === b.underline && + a.strike === b.strike && a.color === b.color && a.highlight === b.highlight && + a.code === b.code && a.sup === b.sup && a.sub === b.sub && + a.fontSize === b.fontSize && a.link === b.link +} + +export function mergeSegments(lines: RichLine[]): RichLine[] { + return lines.map(line => { + const merged: RichSegment[] = [] + for (const seg of line.segments || []) { + const last = merged[merged.length - 1] + if (last && segEqual(last, seg)) { + last.text += seg.text + } else { + merged.push({ ...seg }) + } + } + return { segments: merged } + }) +} + +/* ---------- 工具:判断是否有格式(至少一个 segment 有样式) ---------- */ + +export function hasFormatting(lines: RichLine[]): boolean { + if (!lines || !lines.length) return false + for (const line of lines) { + for (const seg of line.segments || []) { + if (seg.bold || seg.italic || seg.underline || seg.strike || seg.color || + seg.highlight || seg.code || seg.sup || seg.sub || seg.fontSize || seg.link) { + return true + } + } + } + return false +} + +/* ---------- 工具:安全化(AI 输出或外部数据 → 合法 segments) ---------- */ + +export function normSegments(input: any): RichLine[] | undefined { + if (!input || !Array.isArray(input)) return undefined + const lines: RichLine[] = [] + for (const line of input) { + if (!line || !Array.isArray(line.segments)) continue + const segs: RichSegment[] = [] + for (const seg of line.segments) { + if (!seg || typeof seg !== 'object') continue + const text = String(seg.text || '') + if (!text) continue + const out: RichSegment = { text } + if (seg.bold) out.bold = true + if (seg.italic) out.italic = true + if (seg.underline) out.underline = true + if (seg.strike) out.strike = true + if (seg.highlight) out.highlight = true + if (seg.code) out.code = true + if (seg.sup) out.sup = true + if (seg.sub) out.sub = true + if (typeof seg.color === 'string') { + // 白名单:主题键或合法 hex + if (/^(primary|accent|text|muted)$/.test(seg.color) || /^#[0-9a-f]{3,8}$/i.test(seg.color)) { + out.color = seg.color + } + } + if (typeof seg.fontSize === 'number' && seg.fontSize >= 8 && seg.fontSize <= 200) { + out.fontSize = seg.fontSize + } + if (typeof seg.link === 'string' && /^(https?:|\/)/.test(seg.link)) { + out.link = seg.link + } + segs.push(out) + } + lines.push({ segments: segs }) + } + return lines.length ? lines : undefined +} diff --git a/src/core/sample.ts b/src/core/sample.ts new file mode 100644 index 0000000..85b92b5 --- /dev/null +++ b/src/core/sample.ts @@ -0,0 +1,169 @@ +/* ===================================================================== + * sample.ts — 画布逻辑尺寸、主题预设、元素工厂、内置示例 deck + * 由 data.js 迁移 + * ===================================================================== */ +import type { Deck, ElementType, SlideElement, Theme, ThemeKey, ElementStyle } from './types' + +/** 画布逻辑尺寸(元素百分比坐标基于此) */ +export const CANVAS_W = 1280 +export const CANVAS_H = 720 + +/** 主题预设 */ +export const themes: Record = { + indigo: { name: '靛蓝', primary: '#4f46e5', accent: '#06b6d4', bg: '#ffffff', panel: '#f8fafc', text: '#1e293b', muted: '#64748b' }, + emerald: { name: '翠绿', primary: '#059669', accent: '#f59e0b', bg: '#ffffff', panel: '#f0fdf4', text: '#064e3b', muted: '#6b7280' }, + rose: { name: '绯红', primary: '#e11d48', accent: '#f97316', bg: '#ffffff', panel: '#fff1f2', text: '#1f2937', muted: '#6b7280' }, + slate: { name: '墨黑', primary: '#0f172a', accent: '#6366f1', bg: '#ffffff', panel: '#f1f5f9', text: '#0f172a', muted: '#64748b' }, + sunset: { name: '暮紫', primary: '#7c3aed', accent: '#ec4899', bg: '#ffffff', panel: '#faf5ff', text: '#1e1b4b', muted: '#6b7280' } +} + +/** 生成唯一 id */ +export function uid(prefix: string = 'el'): string { + return prefix + '-' + Math.random().toString(36).slice(2, 9) +} + +/** 元素类型元信息:label + 默认样式 */ +export const elementTypes: Record = { + title: { label: '标题', defaults: { fontSize: 48, bold: true, color: 'primary', align: 'left', anim: 'fade-up' } }, + text: { label: '正文', defaults: { fontSize: 22, color: 'text', align: 'left', anim: 'fade-up' } }, + list: { label: '列表', defaults: { fontSize: 24, color: 'text', anim: 'fade-up' } }, + 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' } }, + 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' } }, + code: { label: '代码', defaults: { fontSize: 16, color: 'text', lang: '', anim: 'fade' } }, + formula: { label: '公式', defaults: { fontSize: 32, color: 'text', align: 'center', anim: 'fade-up' } } +} + +/** 元素工厂:生成带默认 style 的元素 */ +export function createElement(type: ElementType, over?: Partial & { style?: Partial }): SlideElement { + // 按元素类型给合理的默认尺寸(w/h 百分比,画布 1280×720) + const DEFAULT_SIZE: Record = { + title: { w: 70, h: 14 }, + text: { w: 70, h: 20 }, + list: { w: 70, h: 30 }, + stat: { w: 30, h: 35 }, + quote: { w: 70, h: 25 }, + image: { w: 40, h: 40 }, + shape: { w: 25, h: 25 }, + chart: { w: 45, h: 35 }, + card: { w: 35, h: 35 }, + table: { w: 60, h: 35 }, + code: { w: 55, h: 30 }, + formula: { w: 40, h: 15 } + } + const sz = DEFAULT_SIZE[type] || { w: 60, h: 20 } + const base: SlideElement = { id: uid('el'), type, x: 10, y: 10, w: sz.w, h: sz.h, content: '', style: {} } + const t = elementTypes[type] + if (t?.defaults) Object.assign(base.style, t.defaults) + if (over) { + const { style, ...rest } = over + Object.assign(base, rest) + if (style) Object.assign(base.style, style) + } + return base +} + +/** 卡片元素构造助手 */ +function card(x: number, y: number, w: number, h: number, _icon: string, title: string, body: string, accent: string, anim?: string): SlideElement { + return { + id: uid('el'), type: 'card', x, y, w, h, + content: title + '\n' + body, + style: { accent: accent || 'primary', icon: '', anim: (anim as ElementStyle['anim']) || 'fade-up' } + } +} + +/** 内置示例 deck 版本号(v3: 新增 table/code/formula 元素 + 图表子类型) */ +export const DECK_VERSION = 3 + +/** 内置示例:8 页,主题「幻灯片设计的证据」 */ +export const SAMPLE_DECK: Deck = { + v: DECK_VERSION, + theme: 'indigo', + slides: [ + /* 1. 封面 */ + { + id: uid('s'), background: 'g-primary', + elements: [ + { id: uid('el'), type: 'shape', x: 55, y: -25, w: 65, h: 100, content: '', style: { shapeType: 'circle', fill: 'accent', gradient: true, opacity: 0.3, anim: 'scale' } }, + { id: uid('el'), type: 'shape', x: 0, y: 0, w: 6, h: 100, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, anim: 'slide-l' } }, + { id: uid('el'), type: 'title', x: 10, y: 30, w: 66, h: 18, content: '幻灯片设计的证据', style: { fontSize: 62, color: 'text', align: 'left', anim: 'pop' } }, + { id: uid('el'), type: 'text', x: 10, y: 52, w: 60, h: 10, content: '5 个来自认知科学的演示原则', style: { fontSize: 26, color: 'muted', align: 'left', anim: 'fade-up' } }, + { id: uid('el'), type: 'shape', x: 10, y: 66, w: 9, h: 1.6, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'fade-up' } }, + { id: uid('el'), type: 'text', x: 10, y: 84, w: 50, h: 6, content: '基于 Mayer 多媒体学习研究', style: { fontSize: 16, color: 'muted', align: 'left', anim: 'fade' } } + ] + }, + /* 2. 目录 */ + { + id: uid('s'), background: 'bg', + elements: [ + { id: uid('el'), type: 'title', x: 8, y: 8, w: 60, h: 12, content: '五条原则', style: { fontSize: 42, color: 'primary', align: 'left', anim: 'fade-up' } }, + { id: uid('el'), type: 'shape', x: 8, y: 21, w: 5, h: 1.4, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'fade-up' } }, + card(6, 28, 28, 30, '', '6 秒法则', '首屏注意力:观众前 6 秒决定去留', 'primary'), + card(36, 28, 28, 30, '', '图片优势', '配图信息被记住的概率约为纯文字 6 倍', 'accent'), + card(66, 28, 28, 30, '', '冗余效应', '同屏文字 + 口述,记忆反而下降', 'primary'), + card(6, 62, 44, 32, '', '故事结构', '情节化信息的回忆率远高于罗列', 'accent'), + card(52, 62, 42, 32, '', '对比制造记忆', '并置差异比平铺罗列更易被记住', 'primary') + ] + }, + /* 3. 6 秒法则 */ + { + id: uid('s'), background: 'panel', + elements: [ + { id: uid('el'), type: 'stat', x: 5, y: 14, w: 30, h: 44, content: '6 秒', style: { fontSize: 116, color: 'primary', label: '观众首次看到一张幻灯片时,形成判断的平均时间', labelColor: 'muted', labelSize: 20, anim: 'scale' } }, + { id: uid('el'), type: 'title', x: 40, y: 18, w: 54, h: 12, content: '前 6 秒决定一切', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } }, + { id: uid('el'), type: 'text', x: 40, y: 34, w: 54, h: 32, content: '若标题与核心视觉在 6 秒内无法被识别,观众会停止投入注意力。\n\n把最强结论或图像放到最显眼处,把铺垫藏进口头讲解里。', style: { fontSize: 22, color: 'text', align: 'left', anim: 'fade-up' } } + ] + }, + /* 4. 图片优势 */ + { + id: uid('s'), background: 'bg', + elements: [ + { id: uid('el'), type: 'title', x: 8, y: 8, w: 80, h: 11, content: '图片优势效应', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } }, + { id: uid('el'), type: 'stat', x: 6, y: 24, w: 26, h: 30, content: '65%', style: { fontSize: 76, color: 'primary', label: '三天后仍被记住的视觉信息', labelColor: 'muted', labelSize: 18, anim: 'scale' } }, + { id: uid('el'), type: 'stat', x: 34, y: 24, w: 26, h: 30, content: '10%', style: { fontSize: 76, color: 'accent', label: '纯文字信息的留存率', labelColor: 'muted', labelSize: 18, anim: 'scale' } }, + { id: uid('el'), type: 'chart', x: 62, y: 22, w: 32, h: 36, content: '[{"label":"配图","value":65},{"label":"图文结合","value":55},{"label":"口述加字","value":28},{"label":"纯文字","value":10}]', style: { color: 'primary', max: 100, anim: 'fade-up' } }, + { id: uid('el'), type: 'text', x: 8, y: 80, w: 84, h: 7, content: '来源:John Medina《大脑当家》引用的图片优势记忆实验综合数据。', style: { fontSize: 16, color: 'muted', align: 'left', anim: 'fade' } } + ] + }, + /* 5. 冗余效应 */ + { + id: uid('s'), background: 'bg', + elements: [ + { id: uid('el'), type: 'title', x: 10, y: 7, w: 80, h: 11, content: '信息越多,记得越少', style: { fontSize: 36, color: 'primary', align: 'center', anim: 'fade-up' } }, + { id: uid('el'), type: 'card', x: 7, y: 23, w: 40, h: 66, content: '常见做法\n整页铺满文字\n逐条照念\n背景动画抢夺注意\n图表塞满所有维度', style: { accent: 'muted', icon: '', anim: 'slide-l' } }, + { id: uid('el'), type: 'card', x: 53, y: 23, w: 40, h: 66, content: '认知友好\n一页一个核心观点\n图为主、字为辅\n口述与画面互补\n只保留支撑结论的数据', style: { accent: 'primary', icon: '', anim: 'slide-r' } } + ] + }, + /* 6. 故事结构 */ + { + id: uid('s'), background: 'g-deep', + elements: [ + { id: uid('el'), type: 'shape', x: 8, y: 24, w: 4, h: 52, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'slide-l' } }, + { id: uid('el'), type: 'quote', x: 16, y: 26, w: 76, h: 34, content: '人记住一个故事的概率,是记住一组事实的约 22 倍。', style: { fontSize: 48, color: 'text', italic: true, align: 'left', anim: 'scale' } }, + { id: uid('el'), type: 'text', x: 16, y: 70, w: 60, h: 8, content: '— Chip & Dan Heath《让创意更有黏性》', style: { fontSize: 22, color: 'muted', align: 'left', anim: 'fade-up' } } + ] + }, + /* 7. 操作清单 */ + { + id: uid('s'), background: 'panel', + elements: [ + { id: uid('el'), type: 'title', x: 8, y: 8, w: 80, h: 12, content: '可立刻执行的 5 条', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } }, + { id: uid('el'), type: 'list', x: 10, y: 26, w: 80, h: 60, content: '标题控制在 6 个词以内,把结论前置\n每页只讲一个观点,其余移到口头讲解\n用一张全屏照片替代多个小图标\n数据用前后对比呈现,而非平铺罗列\n结尾给一个明确的行动,而不是谢谢', style: { fontSize: 26, color: 'text', anim: 'fade-up' } } + ] + }, + /* 8. 结尾 */ + { + id: uid('s'), background: 'g-primary', + elements: [ + { id: uid('el'), type: 'shape', x: 30, y: -10, w: 50, h: 60, content: '', style: { shapeType: 'circle', fill: 'accent', gradient: true, opacity: 0.22, anim: 'scale' } }, + { id: uid('el'), type: 'title', x: 10, y: 32, w: 80, h: 18, content: '设计是减法', style: { fontSize: 72, color: 'text', align: 'center', anim: 'pop' } }, + { id: uid('el'), type: 'text', x: 18, y: 54, w: 64, h: 8, content: '去掉一切无关,剩下的自然有力。', style: { fontSize: 26, color: 'muted', align: 'center', anim: 'fade-up' } }, + { id: uid('el'), type: 'text', x: 20, y: 80, w: 60, h: 6, content: '参考:Mayer 多媒体学习 · Heath 黏性理论 · Medina 大脑当家', style: { fontSize: 16, color: 'accent', align: 'center', anim: 'fade' } } + ] + } + ] +} diff --git a/src/core/store.ts b/src/core/store.ts new file mode 100644 index 0000000..9144e8c --- /dev/null +++ b/src/core/store.ts @@ -0,0 +1,702 @@ +/* ===================================================================== + * store.ts — 状态管理:deck 数据 / localStorage 持久化 / CRUD / undo-redo + * 由 store.js 迁移:用 Vue reactive 替代手写 pub/sub,组件自动追踪 + * ===================================================================== */ +import { reactive, computed } from 'vue' +import type { AiCfg, Deck, LibItem, PageTemplate, Slide, SlideElement, ThemeKey } from './types' +import { DECK_VERSION, SAMPLE_DECK, createElement, themes, uid } from './sample' + +/* ---------- localStorage 键 ---------- */ +const LS_KEY = 'u-ppt.deck.v1' +const LS_CFG = 'u-ppt.ai-cfg.v1' +const LIB_KEY = 'u-ppt.library.v1' +const ACTIVE_KEY = 'u-ppt.activeLib.v1' +const CHAT_PREFIX = 'u-ppt.chat.' +const SAVE_DELAY = 400 + +/* ---------- 内置页面模板(版式预设) ---------- */ +const BUILTIN_TEMPLATES: PageTemplate[] = [ + { + id: 'tpl-blank', name: '空白页', category: 'built-in', background: 'bg', + elements: [] + }, + { + id: 'tpl-title', name: '标题页', category: 'built-in', 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', + 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' } }, + { id: 's3', type: 'text', x: 12, y: 58, w: 60, h: 8, content: '章节描述', style: { fontSize: 22, color: 'muted', align: 'left', anim: 'fade-up' } } + ] + }, + { + id: 'tpl-cards3', name: '三卡片', category: 'built-in', 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' } }, + { id: 'c3', type: 'card', x: 36, y: 28, w: 28, h: 60, content: '卡片二\n要点描述', style: { accent: 'accent', icon: '', anim: 'fade-up' } }, + { id: 'c4', type: 'card', x: 66, y: 28, w: 28, h: 60, content: '卡片三\n要点描述', style: { accent: 'primary', icon: '', anim: 'fade-up' } } + ] + }, + { + id: 'tpl-compare', name: '对比页', category: 'built-in', 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' } }, + { id: 'p3', type: 'card', x: 53, y: 24, w: 40, h: 65, content: '右\n要点一\n要点二', style: { accent: 'primary', icon: '', anim: 'slide-r' } } + ] + }, + { + id: 'tpl-quote', name: '金句页', category: 'built-in', 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' } }, + { id: 'q3', type: 'text', x: 16, y: 70, w: 60, h: 8, content: '— 出处', style: { fontSize: 22, color: 'muted', align: 'left', anim: 'fade-up' } } + ] + }, + { + id: 'tpl-data', name: '数据页', category: 'built-in', 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' } } + ] + } +] + +/* ---------- 历史快照 ---------- */ +interface Snapshot { deck: Deck; index: number; sel: string | null } +const HIST_MAX = 40 +let hist: Snapshot[] = [] +let future: Snapshot[] = [] +let suppressHistory = false +let touchKey: string | null = null +let touchTimer: ReturnType | null = null + +/* ---------- 响应式状态(单一数据源) ---------- */ +const state = reactive<{ + deck: Deck + currentIndex: number + selectedId: string | null + activeLibId: string | null +}>({ + deck: JSON.parse(JSON.stringify(SAMPLE_DECK)), + currentIndex: 0, + selectedId: null, + activeLibId: null +}) + +let saveTimer: ReturnType | null = null + +/* ---------- 工具 ---------- */ +function clone(o: T): T { return JSON.parse(JSON.stringify(o)) } + +function scheduleSave() { + if (saveTimer) clearTimeout(saveTimer) + saveTimer = setTimeout(() => { + try { localStorage.setItem(LS_KEY, JSON.stringify(state.deck)) } catch (e) { /* 配额满等忽略 */ } + }, 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 } +} + +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('') +} + +/** 背景是否深色: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 + 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 + 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' +} + +/* ---------- 历史快照 ---------- */ +function pushHistory() { + if (suppressHistory) return + hist.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId }) + if (hist.length > HIST_MAX) hist.shift() + future.length = 0 +} + +/** 连续「同一元素同一字段」的操作合并为一条历史(如拖字号滑块、连续敲字) */ +function touchHistory(key: string) { + if (suppressHistory) return + if (touchKey !== key) { pushHistory(); touchKey = key } + if (touchTimer) clearTimeout(touchTimer) + touchTimer = setTimeout(() => { touchKey = null }, 900) +} + +/* ---------- 初始化 ---------- */ +function init() { + let loaded: Deck | null = null + try { loaded = JSON.parse(localStorage.getItem(LS_KEY) || 'null') } catch (e) { /* ignore */ } + const curV = SAMPLE_DECK.v + const valid = loaded && loaded.slides && loaded.slides.length && loaded.v === curV + if (!valid && loaded && loaded.slides) { + try { localStorage.setItem(LS_KEY + '.bak', JSON.stringify(loaded)) } catch (e) {} // 旧版备份 + } + Object.assign(state, { + deck: valid ? loaded! : clone(SAMPLE_DECK), + currentIndex: 0, + selectedId: null, + activeLibId: null + }) + try { state.activeLibId = localStorage.getItem(ACTIVE_KEY) || null } catch (e) {} +} + +/* ---------- 读(computed 自动追踪) ---------- */ +const deck = computed(() => state.deck) +const slides = computed(() => state.deck.slides) +const count = computed(() => state.deck.slides.length) +const currentIndex = computed(() => state.currentIndex) +const theme = computed(() => state.deck.theme) +const selectedId = computed(() => state.selectedId) +const activeLibId = computed(() => state.activeLibId) +const currentSlide = computed(() => state.deck.slides[state.currentIndex]) + +function getCurrentIndex() { return state.currentIndex } +function getTheme() { return state.deck.theme } +function getSelectedId() { return state.selectedId } +function getDeck() { return state.deck } +function getSlides() { return state.deck.slides } +function getCount() { return state.deck.slides.length } + +function getSelected(): SlideElement | null { + const s = currentSlide.value + if (!s || !state.selectedId) return null + return s.elements.find(e => e.id === state.selectedId) || null +} + +const canUndo = computed(() => hist.length > 0) +const canRedo = computed(() => future.length > 0) + +/* ---------- 选择 / 翻页 ---------- */ +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 + scheduleSave() +} +function selectElement(id: string | null) { state.selectedId = id || null } + +/* ---------- 主题 ---------- */ +function setTheme(name: string) { + // 允许内置主题键与已存的自定义主题键 + if (!themes[name] && !getCustomThemes()[name]) return + pushHistory() + state.deck.theme = name + scheduleSave() +} + +/* ---------- 幻灯片 CRUD ---------- */ +function addSlide(atIndex?: number) { + pushHistory() + const s: Slide = { id: uid('s'), background: 'bg', elements: [] } + const idx = atIndex != null ? atIndex + 1 : state.deck.slides.length + state.deck.slides.splice(idx, 0, s) + state.currentIndex = idx + state.selectedId = null + scheduleSave() + return s +} + +function dupSlide() { + pushHistory() + const cur = currentSlide.value; if (!cur) return + const copy = clone(cur); copy.id = uid('s') + copy.elements.forEach(e => { e.id = uid('el') }) + state.deck.slides.splice(state.currentIndex + 1, 0, copy) + state.currentIndex++ + state.selectedId = null + scheduleSave() +} + +function delSlide(i?: number): boolean { + if (state.deck.slides.length <= 1) return false + pushHistory() + const idx = i != null ? i : state.currentIndex + state.deck.slides.splice(idx, 1) + if (state.currentIndex >= state.deck.slides.length) state.currentIndex = state.deck.slides.length - 1 + state.selectedId = null + scheduleSave() + return true +} + +function moveSlide(from: number, to: number) { + if (from === to || from < 0 || to < 0) return + const arr = state.deck.slides + if (to >= arr.length) return + pushHistory() + const item = arr.splice(from, 1)[0] + arr.splice(to, 0, item) + state.currentIndex = to + scheduleSave() +} + +function setSlideBackground(bg: string) { + pushHistory() + currentSlide.value.background = bg as Slide['background'] + scheduleSave() +} + +/* ---------- 元素 CRUD ---------- */ +function addElement(type: SlideElement['type'], over?: Parameters[1]) { + pushHistory() + const el = createElement(type, over) + currentSlide.value.elements.push(el) + state.selectedId = el.id + scheduleSave() + return el +} + +function updateElement(id: string, patch: Partial & { style?: Record }) { + const key = id + ':' + (patch.style ? Object.keys(patch.style).sort().join(',') : Object.keys(patch).sort().join(',')) + touchHistory(key) + const el = findElement(id); if (!el) return + if (patch.style) { Object.assign(el.style, patch.style) } + const rest = { ...patch }; delete (rest as Record).style + Object.assign(el, rest) + scheduleSave() +} + +function delElement(id: string) { + pushHistory() + const s = currentSlide.value + s.elements = s.elements.filter(e => e.id !== id) + if (state.selectedId === id) state.selectedId = null + scheduleSave() +} + +function moveElementZ(id: string, dir: number) { + pushHistory() + const s = currentSlide.value + const i = s.elements.findIndex(e => e.id === id) + if (i < 0) return + const j = dir > 0 ? i + 1 : i - 1 + if (j < 0 || j >= s.elements.length) return + const tmp = s.elements[i]; s.elements[i] = s.elements[j]; s.elements[j] = tmp + scheduleSave() +} + +function findElement(id: string): SlideElement | null { + const s = currentSlide.value + return s.elements.find(e => e.id === id) || null +} + +/* ---------- undo / redo ---------- */ +function undo(): boolean { + if (!hist.length) return false + future.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId }) + const prev = hist.pop()! + suppressHistory = true + state.deck = prev.deck + state.currentIndex = Math.min(prev.index, state.deck.slides.length - 1) + state.selectedId = prev.sel + suppressHistory = false + scheduleSave() + return true +} + +function redo(): boolean { + if (!future.length) return false + hist.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId }) + const nxt = future.pop()! + suppressHistory = true + state.deck = nxt.deck + state.currentIndex = Math.min(nxt.index, state.deck.slides.length - 1) + state.selectedId = nxt.sel + suppressHistory = false + scheduleSave() + return true +} + +/* ---------- 整体替换(AI 生成 / 导入 / 重置用) ---------- */ +function replaceDeck(newDeck: Deck | Record, opts?: { keepHistory?: boolean; newChat?: boolean }) { + if (!opts?.keepHistory) pushHistory() + suppressHistory = true + let d = newDeck as Deck + if (!d || typeof d !== 'object') d = {} as Deck + d.v = DECK_VERSION // 维护版本号 + // 健壮性:损坏/空数据兜底为一页空白,避免下游 forEach 崩溃 + if (!Array.isArray(d.slides) || !d.slides.length) { + d.slides = [{ id: uid('s'), background: 'bg', elements: [] }] + } + // 会话 ID 绑定:newChat=true 开新会话;否则沿用 deck 自带的 chatId(文库加载时);都没有则生成新的 + if (opts?.newChat || !d.chatId) { + d.chatId = newChatId() + } + state.deck = d + state.activeLibId = null + state.currentIndex = Math.min(state.currentIndex, d.slides.length - 1) + if (state.currentIndex < 0) state.currentIndex = 0 + state.selectedId = null + suppressHistory = false + scheduleSave() +} + +function replaceSlide(index: number, newSlide: Slide) { + pushHistory() + newSlide.id = state.deck.slides[index] ? state.deck.slides[index].id : uid('s') + newSlide.elements = newSlide.elements || [] + newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') }) + state.deck.slides[index] = newSlide + state.selectedId = null + scheduleSave() +} + +function appendSlide(newSlide: Slide) { + pushHistory() + newSlide.id = uid('s') + newSlide.elements = newSlide.elements || [] + newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') }) + state.deck.slides.push(newSlide) + state.currentIndex = state.deck.slides.length - 1 + state.selectedId = null + scheduleSave() +} + +function insertSlideAt(index: number, newSlide: Slide) { + pushHistory() + newSlide.id = uid('s') + newSlide.elements = newSlide.elements || [] + newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') }) + state.deck.slides.splice(index, 0, newSlide) + state.currentIndex = index + state.selectedId = null + scheduleSave() +} + +function reset() { + replaceDeck(clone(SAMPLE_DECK), { newChat: true }) + hist = []; future = [] +} + +function exportJSON(): string { return JSON.stringify(state.deck, null, 2) } + +function importJSON(str: string) { + let d: Deck + try { d = JSON.parse(str) } catch (e) { throw new Error('数据格式无效:不是合法 JSON') } + if (!d || !d.slides || !d.slides.length) throw new Error('数据格式无效:缺少 slides') + if (!themes[d.theme]) d.theme = 'indigo' + replaceDeck(d) +} + +/* ---------- AI 配置持久化 ---------- */ +const DEFAULT_CFG: AiCfg = { + preset: 'zhipu', protocol: 'openai', + base: 'https://open.bigmodel.cn/api/paas/v4', key: '', model: 'glm-4.6', proxy: '', + imgBase: '', imgKey: '', imgModel: '' +} + +function getCfg(): AiCfg { + try { + const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null') + return { ...DEFAULT_CFG, ...(c || {}) } + } catch (e) { return clone(DEFAULT_CFG) } +} + +function setCfg(cfg: AiCfg) { + localStorage.setItem(LS_CFG, JSON.stringify({ ...DEFAULT_CFG, ...cfg })) +} + +/* ---------- 自定义主题:AI 配色建议应用后存储 ---------- */ +const CUSTOM_THEME_KEY = 'u-ppt.themes.v1' + +/** 内置 + 自定义主题全量返回(自定义优先在前) */ +function getAllThemes(): Record { + let custom: Record = {} + try { custom = JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) || '{}') || {} } catch (e) {} + return { ...custom, ...themes } +} + +/** 获取自定义主题列表(仅自定义的) */ +function getCustomThemes(): Record { + try { return JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) || '{}') || {} } catch (e) { return {} } +} + +/** 保存一个自定义主题(key 形式 'custom--') */ +function saveCustomTheme(t: import('./types').Theme): string { + const key = 'custom-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6) + const all = getCustomThemes() + all[key] = t + try { localStorage.setItem(CUSTOM_THEME_KEY, JSON.stringify(all)) } catch (e) {} + return key +} + +/** 应用一个主题(内置 key 或自定义对象)到当前 deck */ +function applyTheme(nameOrObj: string | import('./types').Theme) { + pushHistory() + if (typeof nameOrObj === 'string') { + state.deck.theme = nameOrObj + } else { + const key = saveCustomTheme(nameOrObj) + state.deck.theme = key + } + scheduleSave() +} + +/* ---------- 页面模板(版式复用) ---------- */ +const TEMPLATE_KEY = 'u-ppt.templates.v1' + +/** 获取全部模板(内置 + 用户) */ +function getTemplates(): PageTemplate[] { + let user: PageTemplate[] = [] + try { user = JSON.parse(localStorage.getItem(TEMPLATE_KEY) || '[]') || [] } catch (e) {} + return [...BUILTIN_TEMPLATES, ...user] +} + +/** 仅用户模板 */ +function getUserTemplates(): PageTemplate[] { + try { return JSON.parse(localStorage.getItem(TEMPLATE_KEY) || '[]') || [] } catch (e) { return [] } +} + +function setTemplates(arr: PageTemplate[]) { + // 只存用户模板,不存内置 + const user = arr.filter(t => t.category === 'user') + try { localStorage.setItem(TEMPLATE_KEY, JSON.stringify(user)) } catch (e) {} +} + +/** 存当前页为模板(仅复制元素结构,不复制具体 id) */ +function saveCurrentAsTemplate(name: string): PageTemplate { + const slide = currentSlide.value + const tpl: PageTemplate = { + id: 'tpl-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6), + name, + category: 'user', + background: slide.background, + elements: clone(slide.elements).map(e => ({ ...e, id: uid('el') })), + createdAt: Date.now() + } + const user = getUserTemplates() + user.unshift(tpl) + setTemplates(user) + return tpl +} + +/** 从模板新建一页(插入到当前页之后) */ +function addSlideFromTemplate(tplId: string): boolean { + const tpl = getTemplates().find(t => t.id === tplId) + if (!tpl) return false + pushHistory() + const newSlide: Slide = { + id: uid('s'), + background: tpl.background, + elements: clone(tpl.elements).map(e => ({ ...e, id: uid('el') })) + } + const idx = state.currentIndex + 1 + state.deck.slides.splice(idx, 0, newSlide) + state.currentIndex = idx + state.selectedId = null + scheduleSave() + return true +} + +/** 删除用户模板 */ +function deleteTemplate(tplId: string) { + const user = getUserTemplates().filter(t => t.id !== tplId) + setTemplates(user) +} + +/* ---------- 演示文库:多套 PPT 命名存储 ---------- */ +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) {} +} +function getActiveLibId() { return state.activeLibId } +function setActive(id: string | null) { + state.activeLibId = id + try { id ? localStorage.setItem(ACTIVE_KEY, id) : localStorage.removeItem(ACTIVE_KEY) } catch (e) {} +} + +/** name 非空:按名字找同名覆盖,否则新建;name 空:覆盖当前 active(无 active 则新建"未命名") */ +function saveToLibrary(name?: string | null): string { + const lib = getLibrary() + const now = Date.now() + const deckCopy = clone(state.deck) + let item: LibItem | undefined + if (name) item = lib.find(x => x.name === name) + else if (state.activeLibId) item = lib.find(x => x.id === state.activeLibId) + if (item) { item.deck = deckCopy; if (name) item.name = name; item.updatedAt = now } + else { + const id = 'lib-' + now + '-' + Math.random().toString(36).slice(2, 6) + lib.unshift({ id, name: name || '未命名演示', deck: deckCopy, createdAt: now, updatedAt: now }) + item = lib[0] + } + setLibrary(lib) + setActive(item.id) + return item.id +} + +function loadFromLibrary(id: string): boolean { + const item = getLibrary().find(x => x.id === id) + if (!item) return false + replaceDeck(clone(item.deck)) + setActive(id) + return true +} + +function renameInLibrary(id: string, name: string) { + if (!name) return + const lib = getLibrary() + const item = lib.find(x => x.id === id) + if (!item) return + item.name = name; item.updatedAt = Date.now() + setLibrary(lib) +} + +function deleteFromLibrary(id: string) { + setLibrary(getLibrary().filter(x => x.id !== id)) + if (state.activeLibId === id) setActive(null) +} + +function duplicateInLibrary(id: string): string | null { + const item = getLibrary().find(x => x.id === id) + if (!item) return null + const now = Date.now() + const copy: LibItem = { + id: 'lib-' + now + '-' + Math.random().toString(36).slice(2, 6), + name: item.name + ' 副本', deck: clone(item.deck), createdAt: now, updatedAt: now + } + const lib = getLibrary(); lib.unshift(copy); setLibrary(lib) + return copy.id +} + +function newBlankDeck() { + replaceDeck({ v: DECK_VERSION, theme: state.deck.theme as ThemeKey, slides: [{ id: uid('s'), background: 'bg', elements: [] }] }, { newChat: true }) + setActive(null) +} + +/* ---------- 会话管理(按 chatId 隔离) ---------- */ +import type { ChatMessage } from './types' + +function newChatId(): string { + return 'chat-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8) +} + +/** 当前 deck 的 chatId(没有则生成一个并存入 deck) */ +function getChatId(): string { + if (!state.deck.chatId) { + state.deck.chatId = newChatId() + scheduleSave() + } + return state.deck.chatId +} + +/** 读取某个 chatId 的会话 */ +function getChat(chatId: string): ChatMessage[] { + try { return JSON.parse(localStorage.getItem(CHAT_PREFIX + chatId) || '[]') || [] } catch (e) { return [] } +} + +/** 写入某个 chatId 的会话 */ +function setChat(chatId: string, msgs: ChatMessage[]) { + try { localStorage.setItem(CHAT_PREFIX + chatId, JSON.stringify(msgs)) } catch (e) {} +} + +/** 清除某个 chatId 的会话 */ +function clearChat(chatId: string) { + try { localStorage.removeItem(CHAT_PREFIX + chatId) } catch (e) {} +} + +/* ---------- 导出 ---------- */ +export const store = { + // 状态(响应式) + state, + // computed + deck, slides, count, currentIndex, theme, selectedId, activeLibId, + currentSlide, canUndo, canRedo, + // 初始化 + init, + // 读 + getDeck, getSlides, getCount, getCurrentIndex, getTheme, + getSelectedId, getSelected, + // 选择/翻页 + setCurrentIndex, selectElement, + // 主题 + setTheme, + // 幻灯片 CRUD + addSlide, dupSlide, delSlide, moveSlide, setSlideBackground, + // 元素 CRUD + addElement, updateElement, delElement, moveElementZ, findElement, + // undo/redo + undo, redo, + // 整体替换 + replaceDeck, replaceSlide, appendSlide, insertSlideAt, + reset, exportJSON, importJSON, + // AI 配置 + getCfg, setCfg, + // 文库 + getLibrary, getActiveLibId, saveToLibrary, loadFromLibrary, + renameInLibrary, deleteFromLibrary, duplicateInLibrary, newBlankDeck, + // 自定义主题 + getAllThemes, getCustomThemes, saveCustomTheme, applyTheme, + // 页面模板 + getTemplates, getUserTemplates, saveCurrentAsTemplate, addSlideFromTemplate, deleteTemplate, + // 会话管理 + getChatId, getChat, setChat, clearChat, newChatId, +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..a330305 --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,258 @@ +/* ===================================================================== + * types.ts — 数据模型类型定义 + * ===================================================================== */ + +/** 主题键 */ +export type ThemeKey = 'indigo' | 'emerald' | 'rose' | 'slate' | 'sunset' + +/** 背景键:bg/panel=浅底;primary/accent=纯色深底;g-*=渐变 */ +export type BgKey = + | 'bg' | 'panel' | 'primary' | 'accent' + | 'g-primary' | 'g-deep' | 'g-soft' + | (string & {}) // 允许自定义 #hex + +/** 颜色键:主题色键 或 #hex */ +export type ColorKey = 'primary' | 'accent' | 'text' | 'muted' | (string & {}) + +/** 元素类型 */ +export type ElementType = + | 'title' | 'text' | 'list' | 'stat' | 'quote' + | 'image' | 'shape' | 'chart' | 'card' + | 'table' | 'code' | 'formula' + +/** 图表子类型 */ +export type ChartType = + | 'bar' | 'line' | 'pie' | 'area' + | 'doughnut' | 'radar' | 'hbar' | 'progress' + +/** 入场动画类型 */ +export type AnimType = + | 'fade-up' | 'fade' | 'scale' | 'zoom' | 'slide-l' | 'slide-r' + | 'pop' | 'rotate' | 'bounce' | 'flip' | 'blur' + +/** 形状类型 */ +export type ShapeType = 'rect' | 'circle' | 'triangle' + +/** 主题对象 */ +export interface Theme { + name: string + primary: string + accent: string + bg: string + panel: string + text: string + muted: string +} + +/** 元素样式(所有字段可选,按 type 决定哪些有效) */ +export interface ElementStyle { + fontSize?: number + color?: ColorKey + align?: 'left' | 'center' | 'right' + bold?: boolean + italic?: boolean + anim?: AnimType + // stat + label?: string + labelColor?: ColorKey + labelSize?: number + // image + fit?: 'contain' | 'cover' + // shape + shapeType?: ShapeType + fill?: ColorKey + gradient?: boolean + opacity?: number + radius?: number + // chart + max?: number + chartType?: ChartType + /** chart 显示图例 */ + legend?: boolean + /** chart 堆叠(多系列柱状图有效) */ + stack?: boolean + /** chart 水平网格线 */ + grid?: boolean + // table + /** 表格首行为表头 */ + header?: boolean + // code + /** 代码语言(用于高亮提示,如 js/ts/python) */ + lang?: string + // formula + /** 公式是否为行内 */ + inline?: boolean + // card + icon?: string + accent?: ColorKey + /** 文字元素是否启用 Markdown 行内格式(加粗/斜体/高亮等) */ + rich?: boolean +} + +/** 幻灯片元素 */ +export interface SlideElement { + id: string + type: ElementType + x: number + y: number + w: number + h: number + content: string + style: ElementStyle + /** 结构化富文本(可选,存在时优先于 content 渲染) */ + segments?: RichLine[] +} + +/** 单张幻灯片 */ +export interface Slide { + id: string + background: BgKey + elements: SlideElement[] +} + +/** 一份演示文稿 */ +export interface Deck { + v: number + theme: ThemeKey | string + slides: Slide[] + /** 关联的会话 ID(用于对话与 PPT 绑定) */ + chatId?: string +} + +/** 图表数据条目(单系列) */ +export interface ChartItem { + label?: string + value?: number +} + +/** 图表多系列数据(content 可为该格式) */ +export interface ChartMultiSeries { + /** 系列名列表 */ + series?: string[] + /** 数据条目,每项含多个系列值 */ + items?: Array<{ label?: string; values?: number[]; value?: number }> +} + +/** 页面模板(版式复用) */ +export interface PageTemplate { + id: string + name: string + /** 模板分类:built-in / user */ + category: 'built-in' | 'user' + background: BgKey + /** 元素结构(内容可为占位符如「标题」「正文」) */ + elements: SlideElement[] + createdAt?: number +} + +/** AI 配置 */ +export interface AiCfg { + preset: string + protocol: 'openai' | 'anthropic' + base: string + key: string + model: string + proxy: string + /** 图像模型独立配置(可选,留空则复用主配置) */ + imgBase?: string + imgKey?: string + imgModel?: string +} + +/** 文库条目 */ +export interface LibItem { + id: string + name: string + deck: Deck + createdAt: number + updatedAt: number +} + +/** AI 返回的操作 */ +export interface AiOp { + action: 'create_all' | 'add_page' | 'update_page' | 'answer' + slides: Slide[] + target: number | null // 1-based 页码 + note: string +} + +/** 聊天消息 */ +export interface ChatMessage { + role: 'user' | 'assistant' + content: string +} + +/* ---------- AI 深化能力相关类型 ---------- */ + +/** 大纲条目 */ +export interface OutlineItem { + id: string + /** 页类型:cover 封面 / toc 目录 / content 内容 / quote 金句 / end 结尾 */ + kind: 'cover' | 'toc' | 'content' | 'quote' | 'end' + /** 页标题 */ + title: string + /** 要点列表(每条会展开成正文或列表项) */ + points: string[] + /** 备注给 AI 的额外指令,如「用对比卡片」「数据页」 */ + hint?: string + /** 该条是否已生成为幻灯片 */ + done?: boolean +} + +/** 大纲生成结果 */ +export interface Outline { + title: string // 整份演示的标题 + topic: string // 用户输入的主题 + items: OutlineItem[] +} + +/** AI 主题/配色建议 */ +export interface ThemeSuggestion { + name: string + primary: string + accent: string + bg: string + panel: string + text: string + muted: string + /** 推荐背景键 */ + recommendBg?: BgKey + /** 设计理由 */ + reason?: string +} + +/** AI 配图结果 */ +export interface ImageGenResult { + url: string // 图像 URL(data: 或 https:) + revisedPrompt?: string +} + +/* ---------- 结构化富文本 ---------- */ + +/** 单个富文本片段 */ +export interface RichSegment { + text: string + bold?: boolean + italic?: boolean + underline?: boolean + strike?: boolean + /** 主题键 primary|accent|text|muted 或 #hex */ + color?: string + /** 高亮背景(黄色底) */ + highlight?: boolean + /** 行内代码样式 */ + code?: boolean + /** 上标 */ + sup?: boolean + /** 下标 */ + sub?: boolean + /** 片段级字号覆盖 */ + fontSize?: number + /** 链接 URL */ + link?: string +} + +/** 一行富文本(由多个 segment 组成) */ +export interface RichLine { + segments: RichSegment[] +} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..27d0869 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,12 @@ +import './styles/base.css' +import './styles/editor.css' +import './styles/present.css' +import './styles/chat.css' +import { createApp } from 'vue' +import App from './App.vue' +import { store } from './core/store' + +// 初始化 store(加载本地数据) +store.init() + +createApp(App).mount('#app') diff --git a/src/styles/base.css b/src/styles/base.css new file mode 100644 index 0000000..7db9438 --- /dev/null +++ b/src/styles/base.css @@ -0,0 +1,126 @@ +/* ===================================================================== + * base.css — CSS 变量、reset、公共组件(按钮/输入/弹窗/toast) + * 编辑器 UI 用固定中性色;画布内元素配色由 JS 渲染时给 inline hex + * ===================================================================== */ +:root { + --ui-bg: #f1f5f9; /* 整体背景 */ + --ui-panel: #ffffff; /* 面板/卡片 */ + --ui-bar: #ffffff; /* 顶栏 */ + --ui-border: #e2e8f0; /* 分隔线/边框 */ + --ui-hover: #f1f5f9; /* 悬停 */ + --ui-text: #1e293b; /* 主文字 */ + --ui-muted: #64748b; /* 次要文字 */ + --ui-primary: #4f46e5; /* 主操作 */ + --ui-primary-soft: #eef2ff; + --ui-danger: #e11d48; + --ui-success: #059669; + --radius: 10px; + --radius-sm: 6px; + --shadow-sm: 0 1px 2px rgba(15, 23, 42, .06), 0 1px 1px rgba(15, 23, 42, .04); + --shadow-md: 0 4px 12px rgba(15, 23, 42, .08), 0 2px 4px rgba(15, 23, 42, .04); + --shadow-lg: 0 12px 32px rgba(15, 23, 42, .14); + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", Roboto, Helvetica, Arial, sans-serif; + --mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +* { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } +body { + font-family: var(--font); + color: var(--ui-text); + background: var(--ui-bg); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + overflow: hidden; +} +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; } + +.hidden { display: none !important; } +.muted { color: var(--ui-muted); } + +/* ---------- 按钮 ---------- */ +.btn { + display: inline-flex; align-items: center; gap: 4px; + height: 32px; padding: 0 12px; + border: 1px solid var(--ui-border); + background: var(--ui-panel); + color: var(--ui-text); + border-radius: var(--radius-sm); + font-size: 13px; white-space: nowrap; + transition: background .12s, border-color .12s, transform .05s; +} +.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.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; } +.btn.small { height: 26px; padding: 0 10px; font-size: 12px; } +.btn:disabled { opacity: .5; cursor: not-allowed; } + +/* ---------- 表单控件 ---------- */ +input[type="text"], input[type="password"], input[type="range"], select, textarea { + width: 100%; + border: 1px solid var(--ui-border); + background: #fff; + border-radius: var(--radius-sm); + padding: 6px 10px; + outline: none; + transition: border-color .12s, box-shadow .12s; +} +input:focus, select:focus, textarea:focus { + border-color: var(--ui-primary); + box-shadow: 0 0 0 3px var(--ui-primary-soft); +} +textarea { resize: vertical; line-height: 1.5; } +input[type="range"] { padding: 0; height: 6px; -webkit-appearance: none; appearance: none; background: var(--ui-border); } +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; appearance: none; + width: 16px; height: 16px; border-radius: 50%; + background: var(--ui-primary); cursor: pointer; border: 2px solid #fff; + box-shadow: var(--shadow-sm); +} + +/* ---------- toast ---------- */ +.toast { + position: fixed; left: 50%; bottom: 32px; + transform: translateX(-50%) translateY(20px); + background: rgba(15, 23, 42, .92); color: #fff; + padding: 10px 18px; border-radius: 8px; + font-size: 13px; opacity: 0; pointer-events: none; + transition: opacity .2s, transform .2s; z-index: 9999; + box-shadow: var(--shadow-md); max-width: 80vw; +} +.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } + +/* ---------- 弹窗 ---------- */ +.modal-mask { + position: fixed; inset: 0; background: rgba(15, 23, 42, .45); + display: flex; align-items: center; justify-content: center; + z-index: 1000; backdrop-filter: blur(2px); +} +.modal { + width: 460px; max-width: 92vw; + background: var(--ui-panel); border-radius: var(--radius); + padding: 22px 24px; box-shadow: var(--shadow-lg); +} +.modal h3 { font-size: 17px; margin-bottom: 6px; } +.modal-tip { color: var(--ui-muted); font-size: 12.5px; margin-bottom: 16px; } +.form-row { margin-bottom: 14px; } +.form-row label { display: block; font-size: 12px; color: var(--ui-muted); margin-bottom: 5px; font-weight: 500; } +.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; } + +/* ---------- 滚动条 ---------- */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: #94a3b8; } +::-webkit-scrollbar-track { background: transparent; } + +/* 尊重系统「减少动态」偏好 */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } +} diff --git a/src/styles/chat.css b/src/styles/chat.css new file mode 100644 index 0000000..ba04021 --- /dev/null +++ b/src/styles/chat.css @@ -0,0 +1,105 @@ +/* ===================================================================== + * chat.css — AI 聊天面板、消息气泡、设置相关 + * AiPanel 根元素为 .panel-pane.ai-pane,布局在 editor.css 定义 + * ===================================================================== */ + +/* 快捷操作工具条 */ +.ai-actions { + display: flex; flex-wrap: wrap; gap: 6px; + padding: 10px 12px; margin: 0; + border-bottom: 1px solid var(--ui-border, #e2e8f0); + flex-shrink: 0; background: var(--ui-panel, #f8fafc); +} +.ai-action { + height: 28px; padding: 0 10px; + border: 1px solid var(--ui-border, #e2e8f0); background: #fff; + color: var(--ui-text, #1e293b); border-radius: 5px; + font-size: 12px; font-weight: 500; line-height: 1; + cursor: pointer; transition: all .12s; + 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)); +} +.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; } +/* 破坏性操作(清空)再轻一点 */ +.ai-action.ghost { + border-color: transparent; background: transparent; + color: var(--ui-muted, #64748b); +} +.ai-action.ghost:hover { + border-color: transparent; background: transparent; + color: var(--ui-danger, #e11d48); +} + +/* 选中元素提示条 */ +.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; + font-size: 12px; color: var(--ui-text, #1e293b); + flex-shrink: 0; +} +.selected-hint-icon { color: var(--ui-primary, #4f46e5); 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; } + +/* 消息列表 */ +.chat-messages { + flex: 1; min-height: 0; overflow-y: auto; + padding: 4px 2px; display: flex; flex-direction: column; gap: 12px; +} +.chat-empty { + color: var(--ui-muted); font-size: 13px; text-align: center; + padding: 28px 10px; line-height: 1.9; +} +.chat-empty b { color: var(--ui-text); } +.msg { display: flex; } +.msg.user { justify-content: flex-end; } +.msg .bubble { + max-width: 90%; padding: 9px 13px; border-radius: 14px; + font-size: 13px; line-height: 1.65; word-break: break-word; +} +.msg.user .bubble { background: var(--ui-primary); color: #fff; border-bottom-right-radius: 4px; } +.msg.ai .bubble { background: var(--ui-hover); color: var(--ui-text); border-bottom-left-radius: 4px; } +.msg.system .bubble { background: transparent; color: var(--ui-muted); font-size: 12px; font-style: italic; } +.msg.ai .bubble.error { background: #fff1f2; color: var(--ui-danger); } +.msg.ai .bubble.success { background: #f0fdf4; color: var(--ui-success); } +.msg .bubble .diff-tag { display: inline-block; font-size: 11px; padding: 1px 7px; border-radius: 99px; margin-bottom: 6px; background: rgba(5,150,105,.12); color: var(--ui-success); font-weight: 600; } + +.msg .bubble .stream-text { white-space: pre-wrap; } + +/* 消息中的代码块 */ +.msg .msg-code { + margin: 6px 0; padding: 10px 12px; + background: rgba(15,23,42,.06); border-radius: 8px; + overflow-x: auto; max-width: 100%; + font-size: 12px; line-height: 1.5; + font-family: 'Consolas','Monaco','Courier New',monospace; +} +.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 .js { color: #059669; } /* string value */ +.msg .msg-code .jn { color: #d97706; } /* number */ +.msg .msg-code .jb { color: #e11d48; font-weight: 600; } /* boolean/null */ + +.cursor { + display: inline-block; width: 7px; height: 1em; + background: currentColor; vertical-align: -2px; margin-left: 2px; + animation: blink 1s steps(2, start) infinite; +} +@keyframes blink { 50% { opacity: 0; } } + +/* 输入栏 */ +.chat-input-bar { + border-top: 1px solid var(--ui-border); padding-top: 10px; margin-top: 4px; + display: flex; flex-direction: column; gap: 8px; flex-shrink: 0; +} +#chatInput { resize: none; line-height: 1.5; } +.chat-input-bar .btns { display: flex; gap: 8px; justify-content: flex-end; } diff --git a/src/styles/editor.css b/src/styles/editor.css new file mode 100644 index 0000000..474d38e --- /dev/null +++ b/src/styles/editor.css @@ -0,0 +1,354 @@ +/* ===================================================================== + * editor.css — 编辑模式布局:工具栏 / 缩略图 / 画布 / 属性面板 + * ===================================================================== */ + +.app-editor { display: flex; flex-direction: column; height: 100vh; overflow: hidden; } + +/* ---------- 顶部工具栏 ---------- */ +.toolbar { + height: 54px; flex-shrink: 0; + display: flex; align-items: center; justify-content: space-between; + padding: 0 16px; gap: 12px; + background: var(--ui-bar); + border-bottom: 1px solid var(--ui-border); + box-shadow: var(--shadow-sm); + z-index: 10; +} +.brand { display: flex; align-items: baseline; gap: 6px; } +.brand .logo { color: var(--ui-primary); font-size: 20px; font-weight: 800; } +.brand .name { font-size: 17px; font-weight: 700; letter-spacing: .5px; } +.brand .sub { font-size: 12px; color: var(--ui-muted); } +.tools { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.tools .sep { width: 1px; height: 22px; background: var(--ui-border); margin: 0 4px; } +.theme-select { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ui-muted); } +.theme-select select { width: auto; height: 30px; padding: 0 8px; } + +/* ---------- 编辑区三栏 ---------- */ +.editor-body { + flex: 1; min-height: 0; + display: grid; grid-template-columns: 178px 1fr 304px; +} + +/* ---------- 左:缩略图 ---------- */ +.thumb-bar { border-right: 1px solid var(--ui-border); background: var(--ui-panel); overflow: hidden; display: flex; flex-direction: column; } +.thumb-list { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; } +.thumb-item { position: relative; cursor: pointer; } +.thumb-num { + position: absolute; top: -6px; left: -6px; z-index: 2; + width: 18px; height: 18px; border-radius: 50%; + background: var(--ui-text); color: #fff; font-size: 11px; + display: flex; align-items: center; justify-content: center; +} +.thumb-preview { + position: relative; width: 100%; aspect-ratio: 16 / 9; + border: 1px solid var(--ui-border); border-radius: var(--radius-sm); + overflow: hidden; background: #fff; + box-shadow: var(--shadow-sm); transition: outline-color .12s, transform .1s; + outline: 2px solid transparent; outline-offset: 1px; +} +.thumb-item:hover .thumb-preview { transform: translateY(-1px); } +.thumb-item.active .thumb-preview { outline-color: var(--ui-primary); } +.thumb-del { + position: absolute; top: 2px; right: 2px; z-index: 3; + width: 18px; height: 18px; border-radius: 4px; border: none; + background: rgba(15,23,42,.6); color: #fff; font-size: 11px; + opacity: 0; transition: opacity .12s; line-height: 1; +} +.thumb-item:hover .thumb-del { opacity: 1; } +.thumb-del:hover { background: var(--ui-danger); } +.thumb-add { + border: 1.5px dashed var(--ui-border); border-radius: var(--radius-sm); + height: 40px; background: transparent; color: var(--ui-muted); + display: flex; align-items: center; justify-content: center; gap: 4px; +} +.thumb-add:hover { border-color: var(--ui-primary); color: var(--ui-primary); background: var(--ui-primary-soft); } +/* 缩略图内的迷你元素,统一缩小字号 */ +.thumb-preview .el { pointer-events: none; } + +/* ---------- 中:画布 ---------- */ +.canvas-stage { + display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 28px; min-width: 0; overflow: hidden; position: relative; +} +.canvas-frame { + position: relative; + width: min(100%, calc((100vh - 54px - 56px) * 16 / 9)); + max-width: 100%; + aspect-ratio: 16 / 9; + box-shadow: var(--shadow-md); border-radius: 4px; overflow: hidden; + background: #fff; +} +.canvas { + position: absolute; top: 0; left: 0; + width: 1280px; height: 720px; + transform-origin: top left; +} +.canvas-status { margin-top: 10px; font-size: 12px; color: var(--ui-muted); } + +/* ---------- 画布元素 ---------- */ +.el { position: absolute; overflow: hidden; } +.el[data-type="title"], .el[data-type="text"], .el[data-type="list"], +.el[data-type="stat"], .el[data-type="quote"] { + display: flex; + align-items: flex-start; +} +.el[data-type="stat"] { align-items: center; justify-content: center; } +.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; } +.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; } +.el-list .li::before { + content: ""; position: absolute; left: 0; top: .55em; + width: .35em; height: .35em; border-radius: 50%; background: currentColor; +} +.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%; } +.el-image { width: 100%; height: 100%; object-fit: cover; } +.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; } +.el-chart .bar-track { flex: 1; height: .7em; background: rgba(100,116,139,.18); border-radius: 99px; overflow: hidden; } +.el-chart .bar-fill { height: 100%; border-radius: 99px; } +.el-chart .bar-val { width: 3em; text-align: right; flex-shrink: 0; } + +/* ===== 图表 SVG 通用 ===== */ +.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; } +.el-chart .pie-legend { + position: absolute; right: 0; top: 50%; transform: translateY(-50%); + display: flex; flex-direction: column; gap: .3em; font-size: 13px; +} +.el-chart .pie-legend-item { display: flex; align-items: center; gap: .4em; } +.el-chart .pie-legend-dot { width: .8em; height: .8em; border-radius: 2px; flex-shrink: 0; } + +/* ===== 表格 ===== */ +.el-table { + width: 100%; height: 100%; border-collapse: collapse; + font-size: inherit; table-layout: fixed; +} +.el-table th, .el-table td { + padding: .55em .7em; text-align: left; + border-bottom: 1px solid rgba(100,116,139,.25); + overflow: hidden; word-break: break-word; +} +.el-table th { + font-weight: 700; color: var(--ui-primary, #4f46e5); + border-bottom: 2px solid rgba(100,116,139,.35); + background: rgba(100,116,139,.06); + white-space: nowrap; +} +.el-table tbody tr:nth-child(even) { background: rgba(100,116,139,.04); } + +/* ===== 代码 ===== */ +.el-code { + width: 100%; height: 100%; margin: 0; + padding: 1em 1.2em; box-sizing: border-box; + background: rgba(15,23,42,.04); + border-radius: 8px; border: 1px solid rgba(100,116,139,.2); + overflow: auto; font-family: 'Consolas','Monaco','Courier New',monospace; + font-size: inherit; line-height: 1.6; color: inherit; + white-space: pre; position: relative; +} +.el-code code { font-family: inherit; } +.el-code .code-lang { + position: absolute; top: .4em; right: .8em; + opacity: .45; font-size: 11px; font-family: inherit; + text-transform: uppercase; letter-spacing: .05em; +} + +/* ===== 公式 ===== */ +.el-formula { + width: 100%; height: 100%; + display: flex; align-items: center; justify-content: center; + font-family: 'Cambria Math','STIX','Latin Modern Math',serif; + font-size: inherit; line-height: 1.5; +} +.el-formula sup, .el-formula sub { font-size: .65em; } +.el-formula .frac { + display: inline-flex; flex-direction: column; align-items: center; + vertical-align: middle; margin: 0 .2em; +} +.el-formula .frac .num { border-bottom: 1.5px solid currentColor; padding: 0 .3em; } +.el-formula .frac .den { padding: 0 .3em; } +.el-formula .sqrt { position: relative; padding-left: .3em; display: inline-flex; align-items: flex-end; } +.el-formula .sqrt .rad { font-size: .6em; vertical-align: super; margin-right: .1em; } +.el-formula .sqrt .overline { border-top: 1.5px solid currentColor; padding: 0 .2em; } + +/* 选中态与手柄 */ +.el.selected { outline: 2px solid var(--ui-primary); outline-offset: 0; } +.el.dragging { opacity: .85; } +.handle { + position: absolute; width: 10px; height: 10px; + background: #fff; border: 1.5px solid var(--ui-primary); + border-radius: 2px; z-index: 50; +} +.handle.tl { top: -5px; left: -5px; cursor: nwse-resize; } +.handle.tr { top: -5px; right: -5px; cursor: nesw-resize; } +.handle.bl { bottom: -5px; left: -5px; cursor: nesw-resize; } +.handle.br { bottom: -5px; right: -5px; cursor: nwse-resize; } +.handle.tm { top: -5px; left: 50%; margin-left: -5px; cursor: ns-resize; } +.handle.bm { bottom: -5px; left: 50%; margin-left: -5px; cursor: ns-resize; } +.handle.lm { left: -5px; top: 50%; margin-top: -5px; cursor: ew-resize; } +.handle.rm { right: -5px; top: 50%; margin-top: -5px; cursor: ew-resize; } + +/* 元素文字可直接双击编辑 */ +.el [contenteditable="true"] { outline: none; cursor: text; } +.el [contenteditable="true"]:focus { background: rgba(79,70,229,.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; } +.panel-tabs { display: flex; border-bottom: 1px solid var(--ui-border); flex-shrink: 0; } +.panel-tab { + flex: 1; padding: 11px; border: none; background: transparent; + font-size: 13px; color: var(--ui-muted); border-bottom: 2px solid transparent; +} +.panel-tab:hover { color: var(--ui-text); background: var(--ui-hover); } +.panel-tab.active { color: var(--ui-primary); border-bottom-color: var(--ui-primary); font-weight: 600; } +.panel-pane { flex: 1; overflow-y: auto; padding: 16px; } +.panel-pane.active { display: block; } +/* AI 面板需要纵向擞布局,且不滚动整块(输入框常驻底部) */ +.ai-pane { display: flex; flex-direction: column; padding: 0; overflow: hidden; } +.ai-pane .chat-messages { flex: 1; overflow-y: auto; padding: 0 16px; } + +/* ---------- 属性面板 ---------- */ +.prop-section { padding-bottom: 16px; margin-bottom: 16px; border-bottom: 1px solid var(--ui-border); } +.prop-section:last-child { border-bottom: none; margin-bottom: 0; } +.prop-title { font-size: 12px; color: var(--ui-muted); margin-bottom: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .4px; } +.prop-hint { font-size: 12.5px; color: var(--ui-muted); text-align: center; line-height: 1.8; margin-bottom: 16px; } +.prop-row { margin-bottom: 14px; } +.prop-row > label { display: block; font-size: 12px; color: var(--ui-muted); margin-bottom: 6px; font-weight: 500; } +.color-row { display: flex; gap: 6px; } +.color-row select { flex: 1; } +.color-row input[type="color"] { width: 38px; height: 32px; padding: 2px; flex-shrink: 0; } + +.add-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.add-grid button { + height: 48px; border: 1px solid var(--ui-border); background: #fff; + border-radius: var(--radius-sm); font-size: 12px; color: var(--ui-text); + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; +} +.add-grid button:hover { border-color: var(--ui-primary); color: var(--ui-primary); background: var(--ui-primary-soft); } +.add-grid button .ic { font-size: 18px; } + +.bg-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.bg-grid button { height: 36px; border: 1px solid var(--ui-border); background: #fff; border-radius: var(--radius-sm); font-size: 12px; } +.bg-grid button.active { border-color: var(--ui-primary); box-shadow: 0 0 0 2px var(--ui-primary-soft); } + +/* 分段按钮组 */ +.seg { display: flex; gap: 6px; } +.seg button { + flex: 1; height: 32px; border: 1px solid var(--ui-border); background: #fff; + border-radius: var(--radius-sm); color: var(--ui-text); font-size: 13px; +} +.seg button:hover { background: var(--ui-hover); } +.seg button.active { background: var(--ui-primary); border-color: var(--ui-primary); color: #fff; } +.seg button.danger { color: var(--ui-danger); } +.seg button.danger:hover { background: #fff1f2; } + +/* ---------- 卡片元素 ---------- */ +.el[data-type="card"] { + background: #ffffff; + border-radius: 16px; + box-shadow: 0 12px 30px rgba(15, 23, 42, .12), 0 3px 8px rgba(15, 23, 42, .06); + overflow: hidden; + display: flex; flex-direction: column; +} +.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; } +.card-title { + font-weight: 700; + font-size: 1.5em; + line-height: 1.15; + color: var(--ui-text, #1e293b); + display: flex; align-items: center; gap: .4em; +} +.card-body { + font-size: 1em; + line-height: 1.5; + color: var(--ui-muted, #64748b); + white-space: pre-wrap; + flex: 1; +} +/* 兼容旧 class 名 */ +.el-card-title { font-weight: 700; font-size: 1.5em; line-height: 1.15; display: flex; align-items: center; gap: .45em; } +.el-card-text { font-size: 1em; line-height: 1.5; opacity: .88; white-space: pre-wrap; } +.el-card-body { padding: 1.1em 1.3em 1.2em; flex: 1; display: flex; flex-direction: column; gap: .5em; justify-content: flex-start; } + +/* 渐变形状由 renderElement 内联 backgroundImage;此处仅兜底圆角 */ +.el-shape.gradient { border-radius: var(--shape-radius, 0); } + +/* 装饰圆/光晕(shape 配合) */ +.el[data-deco="glow"] { filter: blur(2px); opacity: .9; } + +/* ---------- 文库弹窗 ---------- */ +.lib-modal { width: 580px; max-width: 94vw; max-height: 86vh; display: flex; flex-direction: column; } +.lib-save-bar { display: flex; gap: 8px; margin-bottom: 14px; } +.lib-save-bar input { flex: 1; } +.lib-list { flex: 1; overflow-y: auto; border: 1px solid var(--ui-border); border-radius: var(--radius-sm); background: #fff; min-height: 140px; } +.lib-list:empty::before { + content: '文库为空 —— 在上方命名并存入你的第一份演示'; + display: block; padding: 32px; text-align: center; color: var(--ui-muted); font-size: 13px; +} +.lib-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--ui-border); } +.lib-item:last-child { border-bottom: none; } +.lib-item.active { background: var(--ui-primary-soft); } +.lib-thumb { width: 72px; height: 41px; border: 1px solid var(--ui-border); border-radius: 4px; overflow: hidden; flex-shrink: 0; position: relative; background: #fff; } +.lib-info { flex: 1; min-width: 0; } +.lib-name { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.lib-meta { font-size: 12px; color: var(--ui-muted); margin-top: 2px; } +.lib-ops { display: flex; gap: 6px; flex-shrink: 0; } +.lib-ops .btn { height: 28px; padding: 0 10px; font-size: 12px; } +.lib-ops .btn.del { background: transparent; color: var(--ui-danger); border-color: var(--ui-border); } +.lib-ops .btn.del:hover { background: #fff1f2; } +.modal-actions .muted { margin-right: auto; font-size: 12px; } + +/* 空页占位引导 */ +.canvas-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--ui-muted); pointer-events: none; } +.canvas-placeholder .ph-icon { font-size: 48px; font-weight: 200; color: var(--ui-border); } +.canvas-placeholder .ph-sub { font-size: 14px; opacity: .8; } + +/* 长内容溢出兆底 */ +.el-text, .el-card-title, .el-card-text { overflow: hidden; } +.el-stat .num { overflow-wrap: anywhere; } +.el-chart .bar-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ---------- 富文本提示 ---------- */ +.rich-hint { margin-top: 6px; display: flex; flex-direction: column; gap: 5px; } +.rich-syntax { + font-size: 11px; color: var(--ui-muted); line-height: 1.7; + background: var(--ui-hover, #f1f5f9); padding: 5px 8px; border-radius: 4px; + white-space: pre-wrap; word-break: break-all; +} +.rich-actions { display: flex; gap: 6px; } +.rich-btn { + height: 22px; padding: 0 8px; font-size: 11px; + 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.danger { color: var(--ui-danger, #e11d48); border-color: transparent; } +.rich-btn.danger:hover { color: var(--ui-danger, #e11d48); background: #fff1f2; } + +/* ---------- 富文本渲染样式 ---------- */ +.el-text-rich strong, .el-list strong, .el-card strong { font-weight: 700; } +.el-text-rich em, .el-list em, .el-card em { font-style: italic; } +.el-text-rich del, .el-list del, .el-card del { text-decoration: line-through; opacity: .7; } +.el-text-rich u, .el-list u, .el-card u { text-decoration: underline; text-underline-offset: 2px; } +.el-text-rich mark, .el-list mark, .el-card mark { background: rgba(245,158,11,.25); padding: 0 .15em; border-radius: 2px; } +.el-text-rich code, .el-list code, .el-card code { + font-family: 'Consolas','Monaco','Courier New',monospace; + background: rgba(100,116,139,.12); padding: .1em .3em; border-radius: 3px; + font-size: .85em; +} +.el-text-rich sup, .el-list sup, .el-card sup { font-size: .65em; vertical-align: super; } +.el-text-rich sub, .el-list sub, .el-card sub { font-size: .65em; vertical-align: sub; } +.el-text-rich a, .el-list a, .el-card a { text-decoration: underline; text-underline-offset: 2px; } + +/* 触屏设备(无 hover)缩略图删除按钮常驻 */ +@media (hover: none) { .thumb-del { opacity: 1 !important; } } diff --git a/src/styles/present.css b/src/styles/present.css new file mode 100644 index 0000000..05546b0 --- /dev/null +++ b/src/styles/present.css @@ -0,0 +1,123 @@ +/* ===================================================================== + * present.css — 演示模式:全屏 / 过渡动画 / HUD(页码/进度/导航点) + * 元素渲染复用 editor.css 的 .el 系列样式(present 模式不渲染手柄) + * ===================================================================== */ + +.app-present { + position: fixed; inset: 0; z-index: 500; + background: #0a0a0a; +} +.present-stage { + width: 100vw; height: 100vh; + display: flex; align-items: center; justify-content: center; + overflow: hidden; cursor: pointer; + position: relative; +} + +/* slide-layer 由 JS 设置 transform: scale() 以适配屏幕;origin 居中 */ +.slide-layer { + width: 1280px; height: 720px; + transform-origin: center center; + position: relative; + box-shadow: 0 30px 80px rgba(0, 0, 0, .5); +} +/* slide-inner 承载实际页面内容与切换动画(与外层 scale 互不冲突) */ +.slide-inner { + position: relative; width: 100%; height: 100%; + background: #fff; overflow: hidden; +} +.slide-inner.enter-next { animation: ppt-enter-next .38s cubic-bezier(.22, .61, .36, 1) both; } +.slide-inner.enter-prev { animation: ppt-enter-prev .38s cubic-bezier(.22, .61, .36, 1) both; } +@keyframes ppt-enter-next { + from { opacity: 0; transform: translateX(48px) scale(.985); } + to { opacity: 1; transform: translateX(0) scale(1); } +} +@keyframes ppt-enter-prev { + from { opacity: 0; transform: translateX(-48px) scale(.985); } + to { opacity: 1; transform: translateX(0) scale(1); } +} + +/* 黑屏(按 B) */ +.present-stage.black::after { + content: ""; position: absolute; inset: 0; background: #000; z-index: 5; +} + +/* 演示模式下元素禁用编辑交互 */ +.present-stage .el { pointer-events: none; } + +/* ---------- HUD ---------- */ +.present-hud { + position: absolute; left: 0; right: 0; bottom: 0; + padding: 14px 28px 18px; color: #fff; z-index: 10; + background: linear-gradient(to top, rgba(0, 0, 0, .45), transparent); + transition: opacity .5s ease; + pointer-events: none; +} +.present-hud:hover { opacity: 1 !important; } +.present-stage.idle .present-hud { opacity: 0; } +.hud-progress { height: 3px; background: rgba(255, 255, 255, .22); border-radius: 2px; margin-bottom: 12px; } +.hud-progress-bar { height: 100%; background: #fff; border-radius: 2px; width: 0; transition: width .35s ease; } +.hud-row { display: flex; align-items: center; justify-content: space-between; font-size: 13px; } +.hud-dots { display: flex; gap: 7px; } +.hud-dot { + width: 8px; height: 8px; border-radius: 50%; + background: rgba(255, 255, 255, .38); cursor: pointer; pointer-events: auto; + transition: background .15s, transform .15s; +} +.hud-dot:hover { background: rgba(255, 255, 255, .7); } +.hud-dot.active { background: #fff; transform: scale(1.25); } +.hud-row .btn.ghost { color: rgba(255, 255, 255, .85); pointer-events: auto; } +.hud-row .btn.ghost:hover { background: rgba(255, 255, 255, .12); color: #fff; } + +/* 演示提示(首次进入) */ +.present-hint { + position: absolute; top: 18px; left: 50%; transform: translateX(-50%); + color: rgba(255, 255, 255, .7); font-size: 12.5px; z-index: 10; + background: rgba(0, 0, 0, .4); padding: 6px 14px; border-radius: 99px; + pointer-events: none; animation: ppt-hint-fade 3.5s ease forwards; +} +@keyframes ppt-hint-fade { + 0%, 70% { opacity: 1; } 100% { opacity: 0; } +} + +/* ============ 元素入场动画(仅演示模式,逐元素 stagger) ============ + * renderSlide 给 slide-inner 加 .animated,并按元素顺序设 animation-delay + * data-anim 指定具体动效,未指定则默认 fade-up + * ---------------------------------------------------------------------- */ +.present-stage .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; } +.present-stage .slide-inner.animated .el[data-anim="zoom"] { animation: ppt-scale .7s cubic-bezier(.22,.61,.36,1) both; } +.present-stage .slide-inner.animated .el[data-anim="slide-l"] { animation: ppt-slide-l .55s cubic-bezier(.22,.61,.36,1) both; } +.present-stage .slide-inner.animated .el[data-anim="slide-r"] { animation: ppt-slide-r .55s cubic-bezier(.22,.61,.36,1) both; } +.present-stage .slide-inner.animated .el[data-anim="pop"] { animation: ppt-pop .6s cubic-bezier(.34,1.56,.64,1) both; } +.present-stage .slide-inner.animated .el { animation: ppt-fade-up .55s cubic-bezier(.22,.61,.36,1) both; } + +/* 列表逐条入场 */ +.present-stage .slide-inner.animated .el[data-type="list"] .li { opacity: 0; animation: ppt-fade-up .42s ease both; } + +@keyframes ppt-fade-up { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: none; } } +@keyframes ppt-fade { from { opacity: 0; } to { opacity: 1; } } +@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; } } + +/* 扩展特效 */ +.present-stage .slide-inner.animated .el[data-anim="rotate"] { animation: ppt-rotate .6s cubic-bezier(.22,.61,.36,1) both; } +.present-stage .slide-inner.animated .el[data-anim="bounce"] { animation: ppt-bounce .75s cubic-bezier(.34,1.56,.64,1) both; } +.present-stage .slide-inner.animated .el[data-anim="flip"] { animation: ppt-flip .65s ease both; } +.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-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; } } + +/* 无障碍:尊重「减少动态」系统设置,避免元素永远透明 */ +@media (prefers-reduced-motion: reduce) { + .present-stage .el, + .present-stage .slide-inner.animated .el[data-type="list"] .li { opacity: 1 !important; animation: none !important; } + .slide-inner.enter-next, .slide-inner.enter-prev { animation: none !important; } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..438c8c7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { "@/*": ["src/*"] } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"] +} diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo new file mode 100644 index 0000000..5d6e84d --- /dev/null +++ b/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/env.d.ts","./src/main.ts","./src/composables/useeditor.ts","./src/core/ai.ts","./src/core/sample.ts","./src/core/store.ts","./src/core/types.ts","./src/components/ai/aipanel.vue","./src/components/editor/addgrid.vue","./src/components/editor/canvas.vue","./src/components/editor/elementview.vue","./src/components/editor/propspanel.vue","./src/components/editor/thumbbar.vue","./src/components/editor/toolbar.vue","./src/components/modals/librarymodal.vue","./src/components/modals/settingsmodal.vue","./src/components/present/presentmode.vue"],"errors":true,"version":"5.6.3"} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b057a29 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { host: '127.0.0.1', port: 8080, open: true } +})