新增: 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
This commit is contained in:
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -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/
|
||||
254
README.md
Normal file
254
README.md
Normal file
@@ -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。后续可扩展。
|
||||
16
index.html
Normal file
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>u-ppt · 在线演示工具</title>
|
||||
<link rel="stylesheet" href="/src/styles/base.css" />
|
||||
<link rel="stylesheet" href="/src/styles/editor.css" />
|
||||
<link rel="stylesheet" href="/src/styles/present.css" />
|
||||
<link rel="stylesheet" href="/src/styles/chat.css" />
|
||||
</head>
|
||||
<body data-mode="editor">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1500
package-lock.json
generated
Normal file
1500
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
178
src/App.vue
Normal file
178
src/App.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<!-- =====================================================================
|
||||
App.vue — 根组件:编辑/演示模式切换、工具栏、三栏布局、弹窗
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { store } from './core/store'
|
||||
import Toolbar from './components/editor/Toolbar.vue'
|
||||
import ThumbBar from './components/editor/ThumbBar.vue'
|
||||
import Canvas from './components/editor/Canvas.vue'
|
||||
import PropsPanel from './components/editor/PropsPanel.vue'
|
||||
import AiPanel from './components/ai/AiPanel.vue'
|
||||
import SettingsModal from './components/modals/SettingsModal.vue'
|
||||
import LibraryModal from './components/modals/LibraryModal.vue'
|
||||
import TemplateModal from './components/modals/TemplateModal.vue'
|
||||
import PresentMode from './components/present/PresentMode.vue'
|
||||
|
||||
/* ---------- 模式 ---------- */
|
||||
const mode = ref<'editor' | 'present'>('editor')
|
||||
const presentVisible = ref(false)
|
||||
const presentStartIndex = ref(0)
|
||||
|
||||
/* ---------- 活动面板 tab ---------- */
|
||||
const activeTab = ref<'props' | 'ai'>('props')
|
||||
function switchTab(name: 'props' | 'ai') {
|
||||
activeTab.value = name
|
||||
}
|
||||
|
||||
/* ---------- 弹窗 ---------- */
|
||||
const settingsVisible = ref(false)
|
||||
const libraryVisible = ref(false)
|
||||
const templateVisible = ref(false)
|
||||
|
||||
/* ---------- toast ---------- */
|
||||
const toastText = ref('')
|
||||
const toastShow = ref(false)
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function toast(msg: string) {
|
||||
toastText.value = msg
|
||||
toastShow.value = true
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => { toastShow.value = false }, 2200)
|
||||
}
|
||||
|
||||
/* ---------- AI busy 状态(禁用部分按钮) ---------- */
|
||||
const aiBusy = ref(false)
|
||||
const disabledActions = computed(() => aiBusy.value
|
||||
? ['present', 'library', 'save', 'reset', 'add-slide', 'dup-slide', 'del-slide']
|
||||
: []
|
||||
)
|
||||
function onBusyChange(busy: boolean) { aiBusy.value = busy }
|
||||
|
||||
/* ---------- 工具栏动作 ---------- */
|
||||
function onPresent() {
|
||||
presentStartIndex.value = store.currentIndex.value
|
||||
presentVisible.value = true
|
||||
mode.value = 'present'
|
||||
document.body.dataset.mode = 'present'
|
||||
}
|
||||
function onPresentExit() {
|
||||
presentVisible.value = false
|
||||
mode.value = 'editor'
|
||||
document.body.dataset.mode = 'editor'
|
||||
}
|
||||
function onSave() {
|
||||
if (store.getActiveLibId()) {
|
||||
store.saveToLibrary()
|
||||
toast('已更新到文库')
|
||||
} else {
|
||||
libraryVisible.value = true
|
||||
}
|
||||
}
|
||||
function openAi() { switchTab('ai') }
|
||||
|
||||
/* ---------- 全局快捷键(编辑模式) ---------- */
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (document.body.dataset.mode === 'present') return
|
||||
const inField = /^(INPUT|TEXTAREA|SELECT)$/.test((e.target as HTMLElement).tagName) || (e.target as HTMLElement).isContentEditable
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (settingsVisible.value) { settingsVisible.value = false; return }
|
||||
if (libraryVisible.value) { libraryVisible.value = false; return }
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
|
||||
if (inField) return
|
||||
e.preventDefault()
|
||||
if (e.shiftKey ? store.redo() : store.undo()) toast(e.shiftKey ? '已重做' : '已撤销')
|
||||
return
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || e.key === 'Y')) {
|
||||
if (inField) return; e.preventDefault(); if (store.redo()) toast('已重做'); return
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault(); if (!inField) toast('已自动保存到本地'); return
|
||||
}
|
||||
|
||||
if (e.key === 'Delete' && !inField) {
|
||||
const id = store.getSelectedId()
|
||||
if (id) { e.preventDefault(); store.delElement(id) }
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKey)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ===================== 编辑模式 ===================== -->
|
||||
<div v-show="mode === 'editor'" class="app-editor">
|
||||
<Toolbar
|
||||
:disabled-actions="disabledActions"
|
||||
@present="onPresent"
|
||||
@open-library="libraryVisible = true"
|
||||
@open-settings="settingsVisible = true"
|
||||
@open-ai="openAi"
|
||||
@save="onSave"
|
||||
@open-templates="templateVisible = true"
|
||||
/>
|
||||
|
||||
<div class="editor-body">
|
||||
<!-- 左:缩略图 -->
|
||||
<ThumbBar />
|
||||
|
||||
<!-- 中:画布 -->
|
||||
<Canvas />
|
||||
|
||||
<!-- 右:Tabs(属性 / AI) -->
|
||||
<aside class="side-panel">
|
||||
<div class="panel-tabs">
|
||||
<button class="panel-tab" :class="{ active: activeTab === 'props' }" @click="switchTab('props')">🎨 属性</button>
|
||||
<button class="panel-tab" :class="{ active: activeTab === 'ai' }" @click="switchTab('ai')">🤖 AI 助手</button>
|
||||
</div>
|
||||
|
||||
<PropsPanel v-show="activeTab === 'props'" />
|
||||
|
||||
<AiPanel
|
||||
v-show="activeTab === 'ai'"
|
||||
@busy-change="onBusyChange"
|
||||
@toast="toast"
|
||||
@open-settings="settingsVisible = true"
|
||||
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai')"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== 演示模式 ===================== -->
|
||||
<PresentMode
|
||||
:visible="presentVisible"
|
||||
:start-index="presentStartIndex"
|
||||
@exit="onPresentExit"
|
||||
/>
|
||||
|
||||
<!-- ===================== 弹窗 ===================== -->
|
||||
<SettingsModal
|
||||
:visible="settingsVisible"
|
||||
@close="settingsVisible = false"
|
||||
@toast="toast"
|
||||
/>
|
||||
<LibraryModal
|
||||
:visible="libraryVisible"
|
||||
@close="libraryVisible = false"
|
||||
@toast="toast"
|
||||
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai')"
|
||||
/>
|
||||
<TemplateModal
|
||||
:visible="templateVisible"
|
||||
@close="templateVisible = false"
|
||||
@toast="toast"
|
||||
/>
|
||||
|
||||
<!-- 轻提示 -->
|
||||
<div class="toast" :class="{ show: toastShow }">{{ toastText }}</div>
|
||||
</template>
|
||||
455
src/components/ai/AiPanel.vue
Normal file
455
src/components/ai/AiPanel.vue
Normal file
@@ -0,0 +1,455 @@
|
||||
<!-- =====================================================================
|
||||
AiPanel.vue — AI 聊天面板
|
||||
发送/停止/生成整套/润色本页/流式渲染/操作应用
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, computed, watch } from 'vue'
|
||||
import type { ChatMessage, AiOp, Slide } from '../../core/types'
|
||||
import { store } from '../../core/store'
|
||||
import { generate, polish, chat, beautifyPage, isConfigured } from '../../core/ai'
|
||||
import { elementTypes } from '../../core/sample'
|
||||
import OutlinePanel from './OutlinePanel.vue'
|
||||
|
||||
/* ---------- 消息内容渲染:提取 JSON 代码块并格式化 ---------- */
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/** 尝试把 JSON 字符串格式化(缩进),失败则返回原文 */
|
||||
function tryFormatJson(raw: string): string {
|
||||
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch (e) { return raw }
|
||||
}
|
||||
|
||||
/** 对格式化后的 JSON 做轻量语法着色(不引依赖,纯正则) */
|
||||
function highlightJson(code: string): string {
|
||||
return escapeHtml(code)
|
||||
.replace(/("(?:\\.|[^"\\])*"\s*:)/g, '<span class="jk">$1</span>')
|
||||
.replace(/:\s*("(?:\\.|[^"\\])*")/g, ': <span class="js">$1</span>')
|
||||
.replace(/:\s*(-?\d+\.?\d*)/g, ': <span class="jn">$1</span>')
|
||||
.replace(/:\s*(true|false|null)/g, ': <span class="jb">$1</span>')
|
||||
}
|
||||
|
||||
interface RenderedPart {
|
||||
type: 'text' | 'code'
|
||||
html: string
|
||||
lang?: string
|
||||
}
|
||||
|
||||
/** 把消息内容拆分为文本段 + JSON 代码块段 */
|
||||
function renderMessageContent(content: string): RenderedPart[] {
|
||||
if (!content) return []
|
||||
const parts: RenderedPart[] = []
|
||||
// 匹配 ```json ... ``` 或 ``` ... ``` 代码块
|
||||
const codeBlockRe = /```(\w*)\n?([\s\S]*?)```/g
|
||||
let lastIdx = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = codeBlockRe.exec(content)) !== null) {
|
||||
// 代码块前的文本
|
||||
if (m.index > lastIdx) {
|
||||
const text = content.slice(lastIdx, m.index)
|
||||
if (text.trim()) parts.push({ type: 'text', html: escapeHtml(text) })
|
||||
}
|
||||
const lang = m[1] || ''
|
||||
const code = m[2]
|
||||
const trimmed = code.trim()
|
||||
// JSON 代码块 → 格式化 + 着色
|
||||
if (!lang || lang === 'json') {
|
||||
parts.push({ type: 'code', lang: 'json', html: highlightJson(tryFormatJson(trimmed)) })
|
||||
} else {
|
||||
parts.push({ type: 'code', lang, html: escapeHtml(trimmed) })
|
||||
}
|
||||
lastIdx = m.index + m[0].length
|
||||
}
|
||||
// 尾部文本
|
||||
if (lastIdx < content.length) {
|
||||
const text = content.slice(lastIdx)
|
||||
if (text.trim()) parts.push({ type: 'text', html: escapeHtml(text) })
|
||||
}
|
||||
// 没有代码块 → 返回整个为文本段
|
||||
if (!parts.length) parts.push({ type: 'text', html: escapeHtml(content) })
|
||||
return parts
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'busy-change', busy: boolean): void
|
||||
(e: 'toast', msg: string): void
|
||||
(e: 'open-settings'): void
|
||||
(e: 'switch-tab', tab: string): void
|
||||
}>()
|
||||
|
||||
const CHAT_KEY = 'u-ppt.chat.v1' // 旧 key,仅用于迁移
|
||||
|
||||
/** 当前会话绑定的 chatId(跟随 deck.chatId) */
|
||||
const currentChatId = computed(() => store.getChatId())
|
||||
|
||||
/** 渲染用消息条目(带可选的流式/标签/error 状态) */
|
||||
interface RenderMsg {
|
||||
key: number
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
streaming?: boolean
|
||||
tag?: string
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
const renderMsgs = ref<RenderMsg[]>([])
|
||||
const chatLog = ref<ChatMessage[]>(loadChat())
|
||||
const messagesEl = ref<HTMLElement | null>(null)
|
||||
const inputEl = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const inputText = ref('')
|
||||
const busy = ref(false)
|
||||
const showOutline = ref(false)
|
||||
|
||||
/* ---------- 当前选中元素(用于上下文对话) ---------- */
|
||||
const selectedEl = computed(() => store.getSelected())
|
||||
const selectedHint = computed(() => {
|
||||
const el = selectedEl.value
|
||||
if (!el) return ''
|
||||
const label = elementTypes[el.type]?.label || el.type
|
||||
const preview = (el.content || '').replace(/\n/g, ' ').slice(0, 40)
|
||||
return label + (preview ? ' · ' + preview : '')
|
||||
})
|
||||
let abortCtrl: AbortController | null = null
|
||||
let chatSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let keySeq = 0
|
||||
|
||||
function loadChat(): ChatMessage[] {
|
||||
// 按 chatId 加载该 PPT 关联的会话
|
||||
return store.getChat(currentChatId.value)
|
||||
}
|
||||
|
||||
function persistChat() {
|
||||
if (chatLog.value.length > 200) chatLog.value.splice(0, chatLog.value.length - 200)
|
||||
if (chatSaveTimer) clearTimeout(chatSaveTimer)
|
||||
chatSaveTimer = setTimeout(() => {
|
||||
store.setChat(currentChatId.value, chatLog.value)
|
||||
}, 400)
|
||||
}
|
||||
|
||||
function setBusy(b: boolean) {
|
||||
busy.value = b
|
||||
emit('busy-change', b)
|
||||
}
|
||||
|
||||
function scrollBottom() {
|
||||
nextTick(() => {
|
||||
const el = messagesEl.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
function toast(msg: string) { emit('toast', msg) }
|
||||
|
||||
/** 从持久化 chatLog 重建渲染列表 */
|
||||
function rebuildFromChatLog() {
|
||||
renderMsgs.value = chatLog.value.map(m => ({
|
||||
key: ++keySeq,
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}))
|
||||
}
|
||||
|
||||
/** 追加一条静态消息(同时进 chatLog) */
|
||||
function addPersisted(role: ChatMessage['role'], text: string) {
|
||||
chatLog.value.push({ role, content: text })
|
||||
persistChat()
|
||||
renderMsgs.value.push({ key: ++keySeq, role, content: text })
|
||||
scrollBottom()
|
||||
}
|
||||
|
||||
/** 创建一条流式气泡(返回控制器对象) */
|
||||
interface StreamCtrl {
|
||||
msg: RenderMsg
|
||||
started: boolean
|
||||
}
|
||||
function streamBubble(placeholder?: string): StreamCtrl {
|
||||
const msg: RenderMsg = {
|
||||
key: ++keySeq,
|
||||
role: 'assistant',
|
||||
content: placeholder || '',
|
||||
streaming: true
|
||||
}
|
||||
renderMsgs.value.push(msg)
|
||||
scrollBottom()
|
||||
return { msg, started: !placeholder }
|
||||
}
|
||||
function streamOnVisible(s: StreamCtrl) {
|
||||
return (t: string) => {
|
||||
if (!s.started) { s.msg.content = ''; s.started = true }
|
||||
s.msg.content += t
|
||||
scrollBottom()
|
||||
}
|
||||
}
|
||||
function streamDone(s: StreamCtrl) {
|
||||
s.msg.streaming = false
|
||||
if (!s.msg.content) s.msg.content = ''
|
||||
}
|
||||
function streamSetText(s: StreamCtrl, txt: string) {
|
||||
s.msg.content = txt
|
||||
s.msg.streaming = false
|
||||
}
|
||||
function streamError(s: StreamCtrl, msg: string) {
|
||||
s.msg.error = true
|
||||
s.msg.content = '⚠ ' + msg
|
||||
s.msg.streaming = false
|
||||
}
|
||||
function streamTag(s: StreamCtrl, txt: string) {
|
||||
if (txt) s.msg.tag = txt
|
||||
}
|
||||
|
||||
/** 应用 AI 返回的操作到 store */
|
||||
function applyOp(op: AiOp, lockedIdx: number): string {
|
||||
const slides = op.slides
|
||||
if (op.action === 'create_all' && slides.length) {
|
||||
store.replaceDeck({ theme: store.theme.value, slides }, { newChat: true })
|
||||
return '已替换为 ' + slides.length + ' 页新演示'
|
||||
}
|
||||
if (op.action === 'add_page' && slides.length) {
|
||||
const idx = (op.target != null ? op.target : lockedIdx) + 1
|
||||
store.insertSlideAt(Math.min(idx, store.getCount()), slides[0])
|
||||
return '已新增 ' + slides.length + ' 页'
|
||||
}
|
||||
if (op.action === 'update_page' && slides.length) {
|
||||
let t = op.target != null ? op.target : lockedIdx
|
||||
t = Math.max(0, Math.min(t, store.getCount() - 1))
|
||||
store.replaceSlide(t, slides[0])
|
||||
return '已更新第 ' + (t + 1) + ' 页'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 把流式气泡固化为持久化记录 */
|
||||
function persistStream(s: StreamCtrl) {
|
||||
chatLog.value.push({ role: 'assistant', content: s.msg.content + (s.msg.tag ? '\n[✓ ' + s.msg.tag + ']' : '') })
|
||||
persistChat()
|
||||
}
|
||||
|
||||
/* ---------- 发送 ---------- */
|
||||
function onSend() {
|
||||
if (busy.value) return
|
||||
const text = inputText.value.trim()
|
||||
if (!text) {
|
||||
if (!isConfigured()) { emit('open-settings'); return }
|
||||
return
|
||||
}
|
||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||||
inputText.value = ''
|
||||
runChat(text)
|
||||
}
|
||||
|
||||
async function runChat(input: string) {
|
||||
addPersisted('user', input)
|
||||
const history = chatLog.value.slice(-10).map(m => ({ role: m.role, content: m.content }))
|
||||
const idx0 = store.getCurrentIndex()
|
||||
const stream = streamBubble()
|
||||
|
||||
setBusy(true)
|
||||
abortCtrl = new AbortController()
|
||||
|
||||
try {
|
||||
const r = await chat({
|
||||
history,
|
||||
input,
|
||||
onVisible: streamOnVisible(stream),
|
||||
signal: abortCtrl.signal,
|
||||
selectedElement: selectedEl.value
|
||||
})
|
||||
streamDone(stream)
|
||||
if (!r.reply) {
|
||||
streamSetText(stream, '(已完成)')
|
||||
}
|
||||
if (r.op && r.op.action !== 'answer' && r.op.slides.length) {
|
||||
const applied = applyOp(r.op, idx0)
|
||||
streamTag(stream, applied)
|
||||
}
|
||||
persistStream(stream)
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
|
||||
else streamError(stream, e?.message || String(e))
|
||||
persistStream(stream)
|
||||
} finally {
|
||||
setBusy(false); abortCtrl = null
|
||||
}
|
||||
}
|
||||
|
||||
function onStop() {
|
||||
if (abortCtrl) abortCtrl.abort()
|
||||
}
|
||||
|
||||
/* ---------- 生成整套 ---------- */
|
||||
async function onGenerate() {
|
||||
if (busy.value) return
|
||||
const topic = inputText.value.trim() || prompt('请输入演示主题,例如「远程办公的兴起与未来」')
|
||||
if (!topic) return
|
||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||||
inputText.value = ''
|
||||
const stream = streamBubble('正在创作「' + topic + '」…')
|
||||
setBusy(true); abortCtrl = new AbortController()
|
||||
|
||||
try {
|
||||
const r = await generate({ topic, signal: abortCtrl.signal })
|
||||
streamDone(stream)
|
||||
// 生成整套 → 开新会话,旧会话保留在 localStorage
|
||||
store.replaceDeck({ theme: store.theme.value, slides: r.slides }, { newChat: true })
|
||||
// watch(currentChatId) 会自动清空 renderMsgs 并加载新会话(空的)
|
||||
// 在新会话里记录这次生成
|
||||
addPersisted('user', '✨ 生成整套:' + topic)
|
||||
addPersisted('assistant', '✅ 已生成 ' + r.slides.length + ' 页演示。可在画布查看与微调,Ctrl+Z 可撤销。')
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
|
||||
else streamError(stream, e?.message || String(e))
|
||||
persistStream(stream)
|
||||
} finally {
|
||||
setBusy(false); abortCtrl = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 润色本页 ---------- */
|
||||
async function onPolish() {
|
||||
if (busy.value) return
|
||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||||
const slide = store.currentSlide.value
|
||||
if (!slide) return
|
||||
const idx0 = store.getCurrentIndex()
|
||||
const idx = idx0 + 1
|
||||
addPersisted('user', '🪄 润色第 ' + idx + ' 页')
|
||||
const stream = streamBubble('正在润色第 ' + idx + ' 页…')
|
||||
setBusy(true); abortCtrl = new AbortController()
|
||||
|
||||
try {
|
||||
const r = await polish({ slide: slide as Slide, instruction: '让内容更有吸引力、表达更精炼,保持布局合理', signal: abortCtrl.signal })
|
||||
streamDone(stream)
|
||||
if (idx0 < store.getCount()) store.replaceSlide(idx0, r.slide)
|
||||
streamSetText(stream, '🪄 已润色第 ' + idx + ' 页' + (r.note ? ':' + r.note : '') + '。Ctrl+Z 可撤销。')
|
||||
persistStream(stream)
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
|
||||
else streamError(stream, e?.message || String(e))
|
||||
persistStream(stream)
|
||||
} finally {
|
||||
setBusy(false); abortCtrl = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 清空 ---------- */
|
||||
function onClear() {
|
||||
if (!chatLog.value.length) { toast('对话已是空的'); return }
|
||||
if (!confirm('清空当前 PPT 的对话记录?')) return
|
||||
chatLog.value = []
|
||||
store.clearChat(currentChatId.value)
|
||||
renderMsgs.value = []
|
||||
}
|
||||
|
||||
/* ---------- 大纲面板 ---------- */
|
||||
function onToggleOutline() {
|
||||
showOutline.value = !showOutline.value
|
||||
}
|
||||
|
||||
/* ---------- 一键美化 ---------- */
|
||||
async function onBeautify() {
|
||||
if (busy.value) return
|
||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||||
const deck = store.getDeck()
|
||||
const slides = deck.slides
|
||||
if (!slides || !slides.length) { toast('当前没有幻灯片可美化'); return }
|
||||
addPersisted('user', '🎨 一键美化全部 (' + slides.length + ' 页)')
|
||||
const stream = streamBubble('正在美化第 1/' + slides.length + ' 页…')
|
||||
setBusy(true); abortCtrl = new AbortController()
|
||||
|
||||
let done = 0
|
||||
try {
|
||||
for (let i = 0; i < slides.length; i++) {
|
||||
if (abortCtrl.signal.aborted) break
|
||||
stream.msg.content = '正在美化 ' + (i + 1) + '/' + slides.length + ' 页…'
|
||||
scrollBottom()
|
||||
const r = await beautifyPage({ slide: store.getDeck().slides[i], signal: abortCtrl.signal })
|
||||
store.replaceSlide(i, r.slide)
|
||||
done++
|
||||
}
|
||||
streamDone(stream)
|
||||
streamSetText(stream, '✅ 已美化 ' + done + ' 页')
|
||||
persistStream(stream)
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') streamSetText(stream, '(已停止,已美化 ' + done + ' 页)')
|
||||
else streamError(stream, e?.message || String(e))
|
||||
persistStream(stream)
|
||||
} finally {
|
||||
setBusy(false); abortCtrl = null
|
||||
}
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend() }
|
||||
}
|
||||
|
||||
/** 供父组件查询/聚焦 */
|
||||
function isBusy() { return busy.value }
|
||||
function focus() { nextTick(() => inputEl.value?.focus()) }
|
||||
defineExpose({ isBusy, focus })
|
||||
|
||||
/* ---------- 初始化:从持久化记录重建 ---------- */
|
||||
rebuildFromChatLog()
|
||||
|
||||
/* ---------- 会话切换:chatId 变化时重新加载对话 ---------- */
|
||||
watch(currentChatId, () => {
|
||||
chatLog.value = loadChat()
|
||||
rebuildFromChatLog()
|
||||
scrollBottom()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-pane ai-pane">
|
||||
<div class="ai-actions">
|
||||
<button class="ai-action" :disabled="busy" @click="onGenerate">✨ 生成整套</button>
|
||||
<button class="ai-action" :disabled="busy" @click="onPolish">🪄 润色本页</button>
|
||||
<button class="ai-action" :disabled="busy" @click="onToggleOutline">📋 大纲</button>
|
||||
<button class="ai-action" :disabled="busy" @click="onBeautify">🎨 美化</button>
|
||||
<button class="ai-action ghost" :disabled="busy" @click="onClear">🗑 清空</button>
|
||||
</div>
|
||||
|
||||
<OutlinePanel v-if="showOutline" :visible="showOutline" @busy-change="setBusy" @toast="toast" @open-settings="emit('open-settings')" />
|
||||
|
||||
<!-- 选中元素提示条:告诉用户 AI 会围绕这个元素对话 -->
|
||||
<div v-if="selectedHint" class="selected-hint" title="AI 对话将基于此选中元素">
|
||||
<span class="selected-hint-icon">④</span>
|
||||
<span class="selected-hint-text">已选中:{{ selectedHint }}</span>
|
||||
<span class="selected-hint-flag">AI 将围绕它对话</span>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages" ref="messagesEl">
|
||||
<div v-if="!renderMsgs.length" class="chat-empty">
|
||||
告诉我你的主题,例如:<br />
|
||||
「生成一份关于<b>远程办公趋势</b>的演示」
|
||||
</div>
|
||||
<div v-for="m in renderMsgs" :key="m.key" class="msg" :class="m.role">
|
||||
<div class="bubble" :class="{ error: m.error }">
|
||||
<span v-if="m.tag" class="diff-tag">✓ {{ m.tag }}</span>
|
||||
<template v-for="(p, pi) in renderMessageContent(m.content)" :key="pi">
|
||||
<pre v-if="p.type === 'code'" class="msg-code" :data-lang="p.lang"><code v-html="p.html"></code></pre>
|
||||
<span v-else class="stream-text" v-html="p.html"></span>
|
||||
</template>
|
||||
<span v-if="m.streaming" class="cursor"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-bar">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="inputText"
|
||||
id="chatInput"
|
||||
rows="3"
|
||||
placeholder="输入指令,回车发送(Shift+Enter 换行)"
|
||||
:disabled="busy"
|
||||
@keydown="onInputKeydown"
|
||||
></textarea>
|
||||
<div class="btns">
|
||||
<button v-if="!busy" class="btn primary" @click="onSend">发送</button>
|
||||
<button v-else class="btn danger" @click="onStop">停止</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
352
src/components/ai/OutlinePanel.vue
Normal file
352
src/components/ai/OutlinePanel.vue
Normal file
@@ -0,0 +1,352 @@
|
||||
<!-- =====================================================================
|
||||
OutlinePanel.vue — 大纲编辑器
|
||||
生成大纲 → 逐条编辑 → 逐页/全部生成 → 应用到文稿
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Outline, OutlineItem, Slide } from '../../core/types'
|
||||
import { store } from '../../core/store'
|
||||
import { outline as genOutline, generatePage, isConfigured } from '../../core/ai'
|
||||
|
||||
defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'busy-change', busy: boolean): void
|
||||
(e: 'toast', msg: string): void
|
||||
(e: 'open-settings'): void
|
||||
}>()
|
||||
|
||||
const outline = ref<Outline | null>(null)
|
||||
const topicInput = ref('')
|
||||
const titleInput = ref('')
|
||||
const editingTitle = ref(false)
|
||||
const busy = ref(false)
|
||||
const progress = ref({ cur: 0, total: 0 })
|
||||
const generatedSlides = ref<Slide[]>([])
|
||||
let abortCtrl: AbortController | null = null
|
||||
|
||||
/** kind → 徽标文本与颜色 */
|
||||
const KIND_META: Record<OutlineItem['kind'], { label: string; color: string }> = {
|
||||
cover: { label: '封面', color: '#4f46e5' },
|
||||
toc: { label: '目录', color: '#06b6d4' },
|
||||
content: { label: '内容', color: '#64748b' },
|
||||
quote: { label: '金句', color: '#f59e0b' },
|
||||
end: { label: '结尾', color: '#4f46e5' }
|
||||
}
|
||||
|
||||
function setBusy(b: boolean) {
|
||||
busy.value = b
|
||||
emit('busy-change', b)
|
||||
}
|
||||
|
||||
const doneCount = computed(() => outline.value?.items.filter(it => it.done).length || 0)
|
||||
const total = computed(() => outline.value?.items.length || 0)
|
||||
const allDone = computed(() => total.value > 0 && doneCount.value === total.value)
|
||||
|
||||
/* ---------- 生成大纲 ---------- */
|
||||
async function onGenOutline() {
|
||||
if (busy.value) return
|
||||
const topic = topicInput.value.trim()
|
||||
if (!topic) { emit('toast', '请先输入主题'); return }
|
||||
if (!isConfigured()) { emit('open-settings'); return }
|
||||
setBusy(true)
|
||||
abortCtrl = new AbortController()
|
||||
try {
|
||||
const r = await genOutline({ topic, signal: abortCtrl.signal })
|
||||
outline.value = r
|
||||
titleInput.value = r.title
|
||||
generatedSlides.value = []
|
||||
emit('toast', '已生成 ' + r.items.length + ' 条大纲')
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') emit('toast', '已停止')
|
||||
else emit('toast', e?.message || String(e))
|
||||
} finally {
|
||||
setBusy(false); abortCtrl = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 编辑标题 ---------- */
|
||||
function startEditTitle() { editingTitle.value = true }
|
||||
function commitTitle() {
|
||||
if (outline.value) outline.value.title = titleInput.value.trim() || outline.value.title
|
||||
editingTitle.value = false
|
||||
}
|
||||
|
||||
/* ---------- 要点编辑 ---------- */
|
||||
function addPoint(it: OutlineItem) {
|
||||
it.points.push('')
|
||||
}
|
||||
function removePoint(it: OutlineItem, idx: number) {
|
||||
it.points.splice(idx, 1)
|
||||
}
|
||||
|
||||
/* ---------- 删除条目 ---------- */
|
||||
function removeItem(idx: number) {
|
||||
if (!outline.value) return
|
||||
outline.value.items.splice(idx, 1)
|
||||
}
|
||||
|
||||
/* ---------- 生成单页 ---------- */
|
||||
async function onGenOne(idx: number) {
|
||||
if (busy.value || !outline.value) return
|
||||
const item = outline.value.items[idx]
|
||||
if (!item) return
|
||||
setBusy(true)
|
||||
abortCtrl = new AbortController()
|
||||
try {
|
||||
const slide = await generatePage({
|
||||
item,
|
||||
index: idx,
|
||||
total: outline.value.items.length,
|
||||
signal: abortCtrl.signal
|
||||
})
|
||||
// 保持 generatedSlides 与 items 顺序对齐
|
||||
generatedSlides.value[idx] = slide
|
||||
item.done = true
|
||||
emit('toast', '已生成第 ' + (idx + 1) + ' 页')
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') emit('toast', '已停止')
|
||||
else emit('toast', e?.message || String(e))
|
||||
} finally {
|
||||
setBusy(false); abortCtrl = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 全部生成(串行) ---------- */
|
||||
async function onGenAll() {
|
||||
if (busy.value || !outline.value) return
|
||||
const items = outline.value.items
|
||||
setBusy(true)
|
||||
abortCtrl = new AbortController()
|
||||
progress.value = { cur: 0, total: items.length }
|
||||
try {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (abortCtrl.signal.aborted) break
|
||||
progress.value = { cur: i + 1, total: items.length }
|
||||
const slide = await generatePage({
|
||||
item: items[i],
|
||||
index: i,
|
||||
total: items.length,
|
||||
signal: abortCtrl.signal
|
||||
})
|
||||
generatedSlides.value[i] = slide
|
||||
items[i].done = true
|
||||
}
|
||||
emit('toast', '全部生成完成(' + items.filter(it => it.done).length + '/' + items.length + ')')
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') emit('toast', '已停止')
|
||||
else emit('toast', e?.message || String(e))
|
||||
} finally {
|
||||
progress.value = { cur: 0, total: 0 }
|
||||
setBusy(false); abortCtrl = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 停止 ---------- */
|
||||
function onStop() {
|
||||
if (abortCtrl) abortCtrl.abort()
|
||||
}
|
||||
|
||||
/* ---------- 应用到文稿 ---------- */
|
||||
function onApply() {
|
||||
if (!outline.value) return
|
||||
const slides = generatedSlides.value.filter(Boolean)
|
||||
if (!slides.length) { emit('toast', '请先生成至少一页'); return }
|
||||
store.replaceDeck({ theme: store.theme.value, slides }, { newChat: true })
|
||||
emit('toast', '已应用 ' + slides.length + ' 页到文稿')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="outline-panel">
|
||||
<!-- 主题输入 + 生成大纲 -->
|
||||
<div class="outline-top">
|
||||
<input type="text" v-model="topicInput" placeholder="输入主题,例如「人工智能的产业落地」" :disabled="busy" @keydown.enter="onGenOutline" />
|
||||
<button class="btn primary" :disabled="busy" @click="onGenOutline">✨ 生成大纲</button>
|
||||
<button v-if="busy" class="btn danger" @click="onStop">停止</button>
|
||||
</div>
|
||||
|
||||
<!-- 无大纲时的空态 -->
|
||||
<div v-if="!outline" class="outline-empty">
|
||||
输入主题后点击「生成大纲」,<br />AI 会先拟定大纲,再逐页生成。
|
||||
</div>
|
||||
|
||||
<!-- 大纲内容 -->
|
||||
<div v-else class="outline-body">
|
||||
<!-- 标题 -->
|
||||
<div class="outline-title">
|
||||
<input v-if="editingTitle" type="text" v-model="titleInput" @blur="commitTitle" @keydown.enter="commitTitle" />
|
||||
<h4 v-else @click="startEditTitle" title="点击编辑">{{ outline.title || '(未命名)' }} <span class="edit-hint">✎</span></h4>
|
||||
</div>
|
||||
|
||||
<!-- 条目列表 -->
|
||||
<div class="outline-items">
|
||||
<div v-for="(it, i) in outline.items" :key="it.id" class="outline-item">
|
||||
<div class="item-head">
|
||||
<span class="kind-badge" :style="{ background: KIND_META[it.kind].color }">{{ KIND_META[it.kind].label }}</span>
|
||||
<input class="item-title" type="text" v-model="it.title" :disabled="busy" />
|
||||
<span class="status" :class="{ done: it.done }">{{ it.done ? '✓ 已生成' : '待生成' }}</span>
|
||||
<button class="btn small" :disabled="busy" @click="onGenOne(i)" :title="'生成第 ' + (i + 1) + ' 页'">生成此页</button>
|
||||
<button class="btn small danger" :disabled="busy" @click="removeItem(i)" title="删除">🗑</button>
|
||||
</div>
|
||||
|
||||
<!-- 要点列表 -->
|
||||
<div class="item-points">
|
||||
<div v-for="(p, pi) in it.points" :key="pi" class="point-row">
|
||||
<input type="text" v-model="it.points[pi]" :disabled="busy" placeholder="要点内容" />
|
||||
<button class="btn small ghost" :disabled="busy" @click="removePoint(it, pi)" title="删除要点">✕</button>
|
||||
</div>
|
||||
<button class="btn small ghost add-point" :disabled="busy" @click="addPoint(it)">+ 添加要点</button>
|
||||
</div>
|
||||
|
||||
<!-- hint -->
|
||||
<div class="item-hint">
|
||||
<input type="text" v-model="it.hint" :disabled="busy" placeholder="补充指令(可选,如「用对比卡片」「数据页」" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="outline-footer">
|
||||
<span class="progress-text" v-if="progress.total">{{ progress.cur }}/{{ progress.total }}</span>
|
||||
<span class="done-count" v-else>{{ doneCount }}/{{ total }} 页已生成</span>
|
||||
<button class="btn primary" :disabled="busy || allDone" @click="onGenAll">全部生成</button>
|
||||
<button class="btn" :disabled="busy || !doneCount" @click="onApply">应用到文稿</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outline-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.outline-top {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.outline-top input { flex: 1; }
|
||||
|
||||
.outline-empty {
|
||||
color: var(--ui-muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 24px 10px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.outline-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.outline-title h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.edit-hint {
|
||||
font-size: 11px;
|
||||
color: var(--ui-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.outline-title input {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 条目 */
|
||||
.outline-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.outline-item {
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
background: var(--ui-panel);
|
||||
}
|
||||
.item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.kind-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
padding: 1px 6px;
|
||||
border-radius: 99px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.item-title {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status {
|
||||
font-size: 11px;
|
||||
color: var(--ui-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status.done {
|
||||
color: var(--ui-success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 要点 */
|
||||
.item-points {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.point-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.point-row input {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
}
|
||||
.add-point {
|
||||
align-self: flex-start;
|
||||
font-size: 11px;
|
||||
color: var(--ui-primary);
|
||||
}
|
||||
|
||||
/* hint */
|
||||
.item-hint input {
|
||||
font-size: 12px;
|
||||
color: var(--ui-muted);
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.outline-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--ui-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.progress-text, .done-count {
|
||||
font-size: 12px;
|
||||
color: var(--ui-muted);
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
24
src/components/editor/AddGrid.vue
Normal file
24
src/components/editor/AddGrid.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<!-- =====================================================================
|
||||
AddGrid.vue — 添加元素网格(属性面板空态时显示)
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import type { ElementType } from '../../core/types'
|
||||
import { elementTypes } from '../../core/sample'
|
||||
|
||||
const TYPES: ElementType[] = ['title', 'text', 'list', 'stat', 'quote', 'image', 'shape', 'chart', 'card', 'table', 'code', 'formula']
|
||||
const ICONS: Record<string, string> = {
|
||||
title: 'T', text: '¶', list: '☰', stat: '#', quote: '“”',
|
||||
image: '🖼', shape: '▭', chart: '📊', card: '◰',
|
||||
table: '▦', code: '</>', formula: '∑'
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ (e: 'add', type: ElementType): void }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="add-grid">
|
||||
<button v-for="t in TYPES" :key="t" :data-add="t" @click="emit('add', t)">
|
||||
<span class="ic">{{ ICONS[t] || '·' }}</span>{{ elementTypes[t].label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
105
src/components/editor/Canvas.vue
Normal file
105
src/components/editor/Canvas.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<!-- =====================================================================
|
||||
Canvas.vue — 中间画布:渲染当前页元素 + 拖拽缩放交互
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { store, resolveBg } from '../../core/store'
|
||||
import { CANVAS_W } from '../../core/sample'
|
||||
import { useEditor } from '../../composables/useEditor'
|
||||
import ElementView from './ElementView.vue'
|
||||
|
||||
const canvasRef = ref<HTMLElement>()
|
||||
const canvasFrameRef = ref<HTMLElement>()
|
||||
|
||||
const {
|
||||
drag, editing,
|
||||
onCanvasMouseDown, onMouseMove, onMouseUp, onContentEditableFocus
|
||||
} = useEditor()
|
||||
|
||||
const slide = computed(() => store.currentSlide.value)
|
||||
const selectedId = computed(() => store.selectedId.value)
|
||||
const HANDLES = ['tl', 'tm', 'tr', 'lm', 'rm', 'bl', 'bm', 'br']
|
||||
|
||||
function fitCanvas() {
|
||||
if (!canvasFrameRef.value || !canvasRef.value) return
|
||||
const rect = canvasFrameRef.value.getBoundingClientRect()
|
||||
if (!rect.width) return
|
||||
const scale = rect.width / CANVAS_W
|
||||
canvasRef.value.style.transform = 'scale(' + scale + ')'
|
||||
}
|
||||
|
||||
function onElementBlur(id: string, field: string, value: string) {
|
||||
editing.value = false
|
||||
if (field === 'label') {
|
||||
store.updateElement(id, { style: { label: value } })
|
||||
} else if (store.findElement(id)?.type === 'list') {
|
||||
store.updateElement(id, { content: value })
|
||||
} else {
|
||||
store.updateElement(id, { content: value })
|
||||
}
|
||||
}
|
||||
|
||||
/** 拖拽中元素的实时位置(覆盖 state) */
|
||||
function liveBox(elId: string) {
|
||||
const d = drag.value
|
||||
if (d && d.id === elId && d.result) {
|
||||
return {
|
||||
left: d.result.x + '%',
|
||||
top: d.result.y + '%',
|
||||
width: d.result.w + '%',
|
||||
height: d.result.h + '%'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const onResize = () => fitCanvas()
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize)
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
requestAnimationFrame(fitCanvas)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="canvas-stage" ref="canvasFrameRef">
|
||||
<div class="canvas-frame">
|
||||
<div
|
||||
class="canvas"
|
||||
ref="canvasRef"
|
||||
:style="{ background: resolveBg(slide.background) }"
|
||||
@mousedown="onCanvasMouseDown($event, canvasRef!)"
|
||||
@focusin="onContentEditableFocus"
|
||||
>
|
||||
<!-- 空页占位 -->
|
||||
<div v-if="slide.elements.length === 0" class="canvas-placeholder">
|
||||
<div class="ph-icon">+</div>
|
||||
<div>这一页是空的</div>
|
||||
<div class="ph-sub">在右侧「属性」面板添加元素,或让 AI 生成整套</div>
|
||||
</div>
|
||||
|
||||
<ElementView
|
||||
v-for="el in slide.elements"
|
||||
:key="el.id"
|
||||
:el="el"
|
||||
:bg="slide.background"
|
||||
edit
|
||||
:show-handles="el.id === selectedId"
|
||||
:class="{ selected: el.id === selectedId, dragging: drag?.id === el.id && !!drag?.result }"
|
||||
:style="liveBox(el.id) || {}"
|
||||
@blur="onElementBlur"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="canvas-status">
|
||||
<span>{{ store.currentIndex.value + 1 }} / {{ store.count.value }}</span>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
499
src/components/editor/ChartView.vue
Normal file
499
src/components/editor/ChartView.vue
Normal file
@@ -0,0 +1,499 @@
|
||||
<!-- =====================================================================
|
||||
ChartView.vue — 图表渲染组件(8 种类型,纯 SVG 自绘)
|
||||
被 ElementView.vue 引用,编辑器/演示/缩略图三处复用
|
||||
|
||||
支持类型:bar / line / area / pie / doughnut / radar / hbar / progress
|
||||
数据格式:
|
||||
单系列:[{ label, value }, ...]
|
||||
多系列:{ series: string[], items: [{ label, values: number[] }, ...] }
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ChartItem, ChartType, ElementStyle } from '../../core/types'
|
||||
import { resolveColor } from '../../core/store'
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
style: ElementStyle
|
||||
/** 是否深色背景(决定文字反相) */
|
||||
dark: boolean
|
||||
}>()
|
||||
|
||||
/* ---------- 颜色调色板(最多 6 个系列,交替主题色与补色) ---------- */
|
||||
const PALETTE = computed(() => [
|
||||
resolveColor(props.style.color, props.dark) || '#4f46e5',
|
||||
resolveColor('accent', props.dark) || '#06b6d4',
|
||||
'#f59e0b', '#10b981', '#ef4444', '#8b5cf6'
|
||||
])
|
||||
|
||||
const chartType = computed<ChartType>(() => props.style.chartType || 'bar')
|
||||
const showLegend = computed(() => props.style.legend !== false)
|
||||
const showGrid = computed(() => props.style.grid !== false)
|
||||
|
||||
/* ---------- 数据解析(兼容单系列和多系列) ---------- */
|
||||
interface NormalizedItem { label: string; values: number[] }
|
||||
interface NormalizedData {
|
||||
series: string[]
|
||||
items: NormalizedItem[]
|
||||
/** 单系列标志(渲染逻辑分支用) */
|
||||
single: boolean
|
||||
}
|
||||
|
||||
const data = computed<NormalizedData>(() => {
|
||||
let raw: any = props.content
|
||||
try {
|
||||
raw = typeof raw === 'string' ? JSON.parse(raw || '[]') : (raw || [])
|
||||
} catch (e) {
|
||||
raw = []
|
||||
}
|
||||
|
||||
// 单系列:[{label, value}]
|
||||
if (Array.isArray(raw)) {
|
||||
const items: NormalizedItem[] = raw.map((d: any) => ({
|
||||
label: String((d && d.label) || ''),
|
||||
values: [Number(d && d.value) || 0]
|
||||
}))
|
||||
return { series: [''], items, single: true }
|
||||
}
|
||||
|
||||
// 多系列:{ series, items }
|
||||
if (raw && typeof raw === 'object' && Array.isArray(raw.items)) {
|
||||
const series: string[] = Array.isArray(raw.series) ? raw.series.map(String) : []
|
||||
const items: NormalizedItem[] = raw.items.map((d: any) => ({
|
||||
label: String((d && d.label) || ''),
|
||||
values: Array.isArray(d && d.values)
|
||||
? d.values.map((v: any) => Number(v) || 0)
|
||||
: [Number(d && d.value) || 0]
|
||||
}))
|
||||
return { series, items, single: items[0]?.values.length === 1 }
|
||||
}
|
||||
|
||||
return { series: [], items: [], single: true }
|
||||
})
|
||||
|
||||
/** 所有值的最大值(用于 bar/line/area/hbar 的 y 轴缩放) */
|
||||
const maxValue = computed(() => {
|
||||
const m = props.style.max
|
||||
if (m && m > 0) return m
|
||||
let max = 0
|
||||
for (const it of data.value.items) {
|
||||
for (const v of it.values) if (v > max) max = v
|
||||
}
|
||||
return Math.max(max, 1)
|
||||
})
|
||||
|
||||
/* ---------- 通用工具 ---------- */
|
||||
function esc(s: any): string {
|
||||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/** 图例数据(pie/doughnut 用) */
|
||||
const pieLegend = computed(() => {
|
||||
const items = data.value.items
|
||||
const total = items.reduce((s, it) => s + (it.values[0] || 0), 0)
|
||||
return items.map((it, i) => ({
|
||||
label: it.label,
|
||||
pct: total > 0 ? Math.round((it.values[0] / total) * 100) : 0,
|
||||
color: PALETTE.value[i % PALETTE.value.length]
|
||||
}))
|
||||
})
|
||||
|
||||
/* ============================================================
|
||||
* 1. 柱状图 bar
|
||||
* ============================================================ */
|
||||
function barSvg(): string {
|
||||
const { items, series, single } = data.value
|
||||
const n = items.length
|
||||
if (!n) return ''
|
||||
const max = maxValue.value
|
||||
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
|
||||
const groupW = 90 / n
|
||||
const barW = (groupW * 0.7) / seriesCount
|
||||
const legendH = 0 // 图例在 SVG 外
|
||||
let out = ''
|
||||
|
||||
// 网格线
|
||||
if (showGrid.value) {
|
||||
for (let g = 1; g <= 4; g++) {
|
||||
const y = 10 + (80 - legendH) * g / 5 + legendH
|
||||
out += `<line x1="6" y1="${y.toFixed(2)}" x2="96" y2="${y.toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const groupX = 8 + i * groupW
|
||||
for (let s = 0; s < seriesCount; s++) {
|
||||
const v = items[i].values[s] || 0
|
||||
const h = max > 0 ? (v / max) * 78 : 0
|
||||
const x = groupX + s * barW
|
||||
const y = 88 - h
|
||||
const color = PALETTE.value[s % PALETTE.value.length]
|
||||
out += `<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${(barW * 0.9).toFixed(2)}" height="${h.toFixed(2)}" fill="${color}" rx="0.6"/>`
|
||||
}
|
||||
// x 轴 label
|
||||
out += `<text class="chart-text" x="${(groupX + groupW * 0.35).toFixed(2)}" y="97" font-size="5" text-anchor="middle">${esc(items[i].label)}</text>`
|
||||
}
|
||||
|
||||
// 单系列时显示数值
|
||||
if (single) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = items[i].values[0] || 0
|
||||
const h = max > 0 ? (v / max) * 78 : 0
|
||||
const y = 88 - h
|
||||
const groupX = 8 + i * groupW
|
||||
out += `<text class="chart-text" x="${(groupX + groupW * 0.35).toFixed(2)}" y="${(y - 1.5).toFixed(2)}" font-size="5" text-anchor="middle">${esc(v)}</text>`
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 2. 条形图 hbar(水平柱状)
|
||||
* ============================================================ */
|
||||
function hbarSvg(): string {
|
||||
const { items, single } = data.value
|
||||
const n = items.length
|
||||
if (!n) return ''
|
||||
const max = maxValue.value
|
||||
const rowH = 76 / n
|
||||
const barH = rowH * 0.55
|
||||
let out = ''
|
||||
|
||||
if (showGrid.value) {
|
||||
for (let g = 1; g <= 4; g++) {
|
||||
const x = 20 + 76 * g / 5
|
||||
out += `<line x1="${x.toFixed(2)}" y1="6" x2="${x.toFixed(2)}" y2="94" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = items[i].values[0] || 0
|
||||
const w = max > 0 ? (v / max) * 70 : 0
|
||||
const y = 8 + i * rowH + (rowH - barH) / 2
|
||||
const color = PALETTE.value[i % PALETTE.value.length]
|
||||
out += `<rect x="20" y="${y.toFixed(2)}" width="${w.toFixed(2)}" height="${barH.toFixed(2)}" fill="${color}" rx="0.6"/>`
|
||||
out += `<text class="chart-text" x="18" y="${(y + barH * 0.7).toFixed(2)}" font-size="5" text-anchor="end">${esc(items[i].label)}</text>`
|
||||
if (single) {
|
||||
out += `<text class="chart-text" x="${(22 + w).toFixed(2)}" y="${(y + barH * 0.7).toFixed(2)}" font-size="5" text-anchor="start">${esc(v)}</text>`
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 3. 折线图 line
|
||||
* ============================================================ */
|
||||
function lineSvg(): string {
|
||||
const { items, series, single } = data.value
|
||||
const n = items.length
|
||||
if (!n) return ''
|
||||
const max = maxValue.value
|
||||
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
|
||||
let out = ''
|
||||
|
||||
if (showGrid.value) {
|
||||
for (let g = 1; g <= 4; g++) {
|
||||
const y = 10 + 78 * g / 5
|
||||
out += `<line x1="6" y1="${y.toFixed(2)}" x2="96" y2="${y.toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
|
||||
}
|
||||
}
|
||||
|
||||
for (let s = 0; s < seriesCount; s++) {
|
||||
const color = PALETTE.value[s % PALETTE.value.length]
|
||||
const pts: Array<{ x: number; y: number; val: number }> = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = items[i].values[s] || 0
|
||||
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
|
||||
const y = 88 - (max > 0 ? (v / max) * 76 : 0)
|
||||
pts.push({ x, y, val: v })
|
||||
}
|
||||
const polyPts = pts.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
|
||||
out += `<polyline points="${polyPts}" fill="none" stroke="${color}" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>`
|
||||
for (const p of pts) {
|
||||
out += `<circle cx="${p.x.toFixed(2)}" cy="${p.y.toFixed(2)}" r="1.2" fill="${color}"/>`
|
||||
}
|
||||
}
|
||||
|
||||
// x 轴 label
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
|
||||
out += `<text class="chart-text" x="${x.toFixed(2)}" y="97" font-size="5" text-anchor="middle">${esc(items[i].label)}</text>`
|
||||
}
|
||||
|
||||
// 单系列显示数值
|
||||
if (single) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = items[i].values[0] || 0
|
||||
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
|
||||
const y = 88 - (max > 0 ? (v / max) * 76 : 0)
|
||||
out += `<text class="chart-text" x="${x.toFixed(2)}" y="${(y - 2).toFixed(2)}" font-size="5" text-anchor="middle">${esc(v)}</text>`
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 4. 面积图 area(折线 + 半透明填充)
|
||||
* ============================================================ */
|
||||
function areaSvg(): string {
|
||||
const { items, series, single } = data.value
|
||||
const n = items.length
|
||||
if (!n) return ''
|
||||
const max = maxValue.value
|
||||
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
|
||||
let out = ''
|
||||
|
||||
if (showGrid.value) {
|
||||
for (let g = 1; g <= 4; g++) {
|
||||
const y = 10 + 78 * g / 5
|
||||
out += `<line x1="6" y1="${y.toFixed(2)}" x2="96" y2="${y.toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
|
||||
}
|
||||
}
|
||||
|
||||
for (let s = 0; s < seriesCount; s++) {
|
||||
const color = PALETTE.value[s % PALETTE.value.length]
|
||||
const pts: Array<{ x: number; y: number; val: number }> = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = items[i].values[s] || 0
|
||||
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
|
||||
const y = 88 - (max > 0 ? (v / max) * 76 : 0)
|
||||
pts.push({ x, y, val: v })
|
||||
}
|
||||
// 填充区域
|
||||
const areaPts = `${pts[0].x.toFixed(2)},88 ` + pts.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ') + ` ${pts[n - 1].x.toFixed(2)},88`
|
||||
out += `<polygon points="${areaPts}" fill="${color}" fill-opacity="0.18"/>`
|
||||
// 折线
|
||||
const polyPts = pts.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
|
||||
out += `<polyline points="${polyPts}" fill="none" stroke="${color}" stroke-width="1.2" stroke-linejoin="round"/>`
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
|
||||
out += `<text class="chart-text" x="${x.toFixed(2)}" y="97" font-size="5" text-anchor="middle">${esc(items[i].label)}</text>`
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 5. 饼图 pie(沿用原实现,支持多色调色板)
|
||||
* ============================================================ */
|
||||
function pieSvg(): string {
|
||||
return pieLikeSvg(0) // innerRadius=0
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 6. 环形图 doughnut
|
||||
* ============================================================ */
|
||||
function doughnutSvg(): string {
|
||||
return pieLikeSvg(22) // innerRadius=22
|
||||
}
|
||||
|
||||
/** pie / doughnut 共用:innerRadius=0 是饼图,>0 是环形 */
|
||||
function pieLikeSvg(innerR: number): string {
|
||||
const items = data.value.items
|
||||
const n = items.length
|
||||
if (!n) return ''
|
||||
const vals = items.map(it => it.values[0] || 0)
|
||||
const total = vals.reduce((a, b) => a + b, 0)
|
||||
if (total <= 0) return ''
|
||||
const r = 40
|
||||
let out = ''
|
||||
let a0 = -Math.PI / 2
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const ang = (vals[i] / total) * Math.PI * 2
|
||||
const a1 = a0 + ang
|
||||
const color = PALETTE.value[i % PALETTE.value.length]
|
||||
const largeArc = ang > Math.PI ? 1 : 0
|
||||
const x0 = (r * Math.cos(a0)).toFixed(2)
|
||||
const y0 = (r * Math.sin(a0)).toFixed(2)
|
||||
const x1 = (r * Math.cos(a1)).toFixed(2)
|
||||
const y1 = (r * Math.sin(a1)).toFixed(2)
|
||||
|
||||
if (innerR > 0) {
|
||||
// 环形:外弧 + 内弧
|
||||
const ix0 = (innerR * Math.cos(a0)).toFixed(2)
|
||||
const iy0 = (innerR * Math.sin(a0)).toFixed(2)
|
||||
const ix1 = (innerR * Math.cos(a1)).toFixed(2)
|
||||
const iy1 = (innerR * Math.sin(a1)).toFixed(2)
|
||||
if (Math.abs(ang - Math.PI * 2) < 1e-6) {
|
||||
out += `<circle cx="0" cy="0" r="${r}" fill="${color}"/>`
|
||||
out += `<circle cx="0" cy="0" r="${innerR}" fill="#fff"/>`
|
||||
} else {
|
||||
out += `<path d="M ${x0} ${y0} A ${r} ${r} 0 ${largeArc} 1 ${x1} ${y1} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix0} ${iy0} Z" fill="${color}"/>`
|
||||
}
|
||||
} else {
|
||||
// 实心饼
|
||||
if (Math.abs(ang - Math.PI * 2) < 1e-6) {
|
||||
out += `<circle cx="0" cy="0" r="${r}" fill="${color}"/>`
|
||||
} else {
|
||||
out += `<path d="M 0 0 L ${x0} ${y0} A ${r} ${r} 0 ${largeArc} 1 ${x1} ${y1} Z" fill="${color}"/>`
|
||||
}
|
||||
}
|
||||
a0 = a1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 7. 雷达图 radar
|
||||
* ============================================================ */
|
||||
function radarSvg(): string {
|
||||
const { items, series, single } = data.value
|
||||
const n = items.length
|
||||
if (n < 3) return '<text class="chart-text" x="50" y="50" font-size="6" text-anchor="middle">雷达图至少 3 个维度</text>'
|
||||
const max = maxValue.value
|
||||
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
|
||||
const cx = 50, cy = 50, r = 36
|
||||
let out = ''
|
||||
|
||||
// 同心多边形网格
|
||||
if (showGrid.value) {
|
||||
for (let layer = 1; layer <= 4; layer++) {
|
||||
const lr = r * layer / 4
|
||||
const pts: string[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = -Math.PI / 2 + i * (Math.PI * 2 / n)
|
||||
pts.push(`${(cx + lr * Math.cos(a)).toFixed(2)},${(cy + lr * Math.sin(a)).toFixed(2)}`)
|
||||
}
|
||||
out += `<polygon points="${pts.join(' ')}" fill="none" stroke="currentColor" stroke-opacity="0.12" stroke-width="0.3"/>`
|
||||
}
|
||||
// 轴线
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = -Math.PI / 2 + i * (Math.PI * 2 / n)
|
||||
out += `<line x1="${cx}" y1="${cy}" x2="${(cx + r * Math.cos(a)).toFixed(2)}" y2="${(cy + r * Math.sin(a)).toFixed(2)}" stroke="currentColor" stroke-opacity="0.12" stroke-width="0.3"/>`
|
||||
}
|
||||
}
|
||||
|
||||
// 数据多边形
|
||||
for (let s = 0; s < seriesCount; s++) {
|
||||
const color = PALETTE.value[s % PALETTE.value.length]
|
||||
const pts: string[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = items[i].values[s] || 0
|
||||
const ratio = max > 0 ? v / max : 0
|
||||
const a = -Math.PI / 2 + i * (Math.PI * 2 / n)
|
||||
pts.push(`${(cx + r * ratio * Math.cos(a)).toFixed(2)},${(cy + r * ratio * Math.sin(a)).toFixed(2)}`)
|
||||
}
|
||||
out += `<polygon points="${pts.join(' ')}" fill="${color}" fill-opacity="0.2" stroke="${color}" stroke-width="1"/>`
|
||||
// 顶点圆点
|
||||
for (const p of pts) {
|
||||
const [px, py] = p.split(',').map(Number)
|
||||
out += `<circle cx="${px}" cy="${py}" r="1" fill="${color}"/>`
|
||||
}
|
||||
}
|
||||
|
||||
// 维度 label
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = -Math.PI / 2 + i * (Math.PI * 2 / n)
|
||||
const lx = cx + (r + 7) * Math.cos(a)
|
||||
const ly = cy + (r + 7) * Math.sin(a)
|
||||
out += `<text class="chart-text" x="${lx.toFixed(2)}" y="${ly.toFixed(2)}" font-size="5" text-anchor="middle" dominant-baseline="middle">${esc(items[i].label)}</text>`
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 8. 进度图 progress(环形进度条)
|
||||
* ============================================================ */
|
||||
const progressData = computed(() => {
|
||||
const items = data.value.items
|
||||
if (!items.length) return { value: 0, label: '', max: 100 }
|
||||
const v = items[0].values[0] || 0
|
||||
const max = props.style.max && props.style.max > 0 ? props.style.max : 100
|
||||
return { value: v, label: items[0].label || '', max }
|
||||
})
|
||||
|
||||
function progressSvg(): string {
|
||||
const { value, max } = progressData.value
|
||||
const pct = Math.min(100, max > 0 ? (value / max) * 100 : 0)
|
||||
const r = 36
|
||||
const cx = 50, cy = 50
|
||||
const circumference = 2 * Math.PI * r
|
||||
const dashLen = (pct / 100) * circumference
|
||||
const color = PALETTE.value[0]
|
||||
const trackColor = props.dark ? 'rgba(255,255,255,0.12)' : 'rgba(100,116,139,0.18)'
|
||||
// 用 stroke-dasharray 画进度弧
|
||||
let out = ''
|
||||
out += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${trackColor}" stroke-width="6"/>`
|
||||
out += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="6" stroke-linecap="round"
|
||||
stroke-dasharray="${dashLen.toFixed(2)} ${(circumference - dashLen).toFixed(2)}"
|
||||
transform="rotate(-90 ${cx} ${cy})"/>`
|
||||
// 中心百分比文本
|
||||
out += `<text class="chart-text" x="${cx}" y="${cy - 1}" font-size="14" font-weight="700" text-anchor="middle">${Math.round(pct)}%</text>`
|
||||
out += `<text class="chart-text" x="${cx}" y="${cy + 7}" font-size="4" text-anchor="middle">${esc(progressData.value.label)}</text>`
|
||||
return out
|
||||
}
|
||||
|
||||
/* ---------- 主 SVG 内容分发 ---------- */
|
||||
const svgContent = computed(() => {
|
||||
switch (chartType.value) {
|
||||
case 'bar': return barSvg()
|
||||
case 'hbar': return hbarSvg()
|
||||
case 'line': return lineSvg()
|
||||
case 'area': return areaSvg()
|
||||
case 'pie': return pieSvg()
|
||||
case 'doughnut': return doughnutSvg()
|
||||
case 'radar': return radarSvg()
|
||||
case 'progress': return progressSvg()
|
||||
default: return barSvg()
|
||||
}
|
||||
})
|
||||
|
||||
/** pie/doughnut 用圆心居中的 viewBox;progress 用正常 viewBox */
|
||||
const viewBox = computed(() => {
|
||||
if (chartType.value === 'pie' || chartType.value === 'doughnut') return '-50 -50 100 100'
|
||||
return '0 0 100 100'
|
||||
})
|
||||
|
||||
/** pie/doughnut 需要外部图例;其他类型用 SVG 内 label,多系列时显示系列图例 */
|
||||
const showExternalLegend = computed(() => {
|
||||
if (!showLegend.value) return false
|
||||
if (chartType.value === 'pie' || chartType.value === 'doughnut') return true
|
||||
// 多系列非饼图:显示系列图例
|
||||
return !data.value.single && (data.value.series.length > 1 || (data.value.items[0]?.values.length || 0) > 1)
|
||||
})
|
||||
|
||||
/** 系列图例数据(非饼图用) */
|
||||
const seriesLegend = computed(() => {
|
||||
const { series, items } = data.value
|
||||
const count = series.length || items[0]?.values.length || 1
|
||||
const arr: Array<{ label: string; color: string }> = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
arr.push({
|
||||
label: series[i] || ('系列 ' + (i + 1)),
|
||||
color: PALETTE.value[i % PALETTE.value.length]
|
||||
})
|
||||
}
|
||||
return arr
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="el-chart" :class="'chart-' + chartType">
|
||||
<svg :viewBox="viewBox" :preserveAspectRatio="chartType === 'pie' || chartType === 'doughnut' ? 'xMidYMid meet' : 'none'"
|
||||
v-html="svgContent" :style="{ width: '100%', height: '100%', display: 'block', flex: chartType === 'pie' || chartType === 'doughnut' ? '1' : undefined, minWidth: chartType === 'pie' || chartType === 'doughnut' ? '0' : undefined }">
|
||||
</svg>
|
||||
<!-- 饼图/环形图图例 -->
|
||||
<div v-if="showExternalLegend && (chartType === 'pie' || chartType === 'doughnut')" class="pie-legend">
|
||||
<div v-for="(item, i) in pieLegend" :key="i" class="pie-legend-item">
|
||||
<span class="pie-legend-dot" :style="{ background: item.color }"></span>
|
||||
<span>{{ item.label }} · {{ item.pct }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 多系列图例(非饼图) -->
|
||||
<div v-if="showExternalLegend && chartType !== 'pie' && chartType !== 'doughnut'" class="series-legend">
|
||||
<span v-for="(s, i) in seriesLegend" :key="i" class="series-legend-item">
|
||||
<span class="pie-legend-dot" :style="{ background: s.color }"></span>{{ s.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.series-legend {
|
||||
position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; gap: .8em; font-size: 11px; flex-wrap: wrap; justify-content: center;
|
||||
}
|
||||
.series-legend-item { display: inline-flex; align-items: center; gap: .3em; }
|
||||
</style>
|
||||
317
src/components/editor/ElementView.vue
Normal file
317
src/components/editor/ElementView.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<!-- =====================================================================
|
||||
ElementView.vue — 单元素渲染(替代 editor.js 的 renderElement)
|
||||
editor / present / thumb 三处复用此组件
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { SlideElement, BgKey } from '../../core/types'
|
||||
import { store, resolveColor } from '../../core/store'
|
||||
import { segmentsToHtml, markdownToSegments, hasFormatting } from '../../core/richtext'
|
||||
import ChartView from './ChartView.vue'
|
||||
|
||||
/* ---------- LaTeX 子集渲染(公式元素) ---------- */
|
||||
const LATEX_SYMBOLS: Record<string, string> = {
|
||||
alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', theta: 'θ', lambda: 'λ', mu: 'μ', pi: 'π', sigma: 'σ', omega: 'ω', phi: 'φ',
|
||||
sum: '∑', prod: '∏', int: '∫', infty: '∞',
|
||||
leq: '≤', geq: '≥', neq: '≠', times: '×', pm: '±', cdot: '·', div: '÷',
|
||||
rightarrow: '→', leftarrow: '←', Rightarrow: '⇒', in: '∈', notin: '∉', subset: '⊂', supset: '⊃', cup: '∪', cap: '∩',
|
||||
forall: '∀', exists: '∃', partial: '∂', nabla: '∇'
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function renderLatex(src: string): string {
|
||||
let s = escapeHtml(src)
|
||||
// 1. \frac{a}{b}
|
||||
s = s.replace(/\\frac\{([^{}]*)\}\{([^{}]*)\}/g, (_m, a: string, b: string) =>
|
||||
`<span class="frac"><span class="num">${a}</span><span class="den">${b}</span></span>`)
|
||||
// 2. \sqrt[n]{x} 和 \sqrt{x}
|
||||
s = s.replace(/\\sqrt\[([^\[\]]+)\]\{([^{}]*)\}/g, (_m, n: string, x: string) =>
|
||||
`<span class="sqrt"><span class="rad">${n}</span><span class="overline">${x}</span></span>`)
|
||||
s = s.replace(/\\sqrt\{([^{}]*)\}/g, (_m, x: string) =>
|
||||
`<span class="sqrt"><span class="overline">${x}</span></span>`)
|
||||
// 3. \symbol → Unicode
|
||||
s = s.replace(/\\([a-zA-Z]+)/g, (_m, name: string) => {
|
||||
if (Object.prototype.hasOwnProperty.call(LATEX_SYMBOLS, name)) return LATEX_SYMBOLS[name]
|
||||
return name
|
||||
})
|
||||
// 4. ^{...} 和 ^x
|
||||
s = s.replace(/\^\{([^{}]*)\}/g, (_m, x: string) => `<sup>${x}</sup>`)
|
||||
s = s.replace(/\^([0-9a-zA-Z])/g, (_m, x: string) => `<sup>${x}</sup>`)
|
||||
// 5. _{...} 和 _x
|
||||
s = s.replace(/_\{([^{}]*)\}/g, (_m, x: string) => `<sub>${x}</sub>`)
|
||||
s = s.replace(/_([0-9a-zA-Z])/g, (_m, x: string) => `<sub>${x}</sub>`)
|
||||
return s
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
el: SlideElement
|
||||
bg: BgKey | string
|
||||
/** 是否为编辑态(启用 contenteditable) */
|
||||
edit?: boolean
|
||||
/** 是否显示八向缩放手柄 */
|
||||
showHandles?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'blur', id: string, field: string, value: string): void
|
||||
}>()
|
||||
|
||||
/** 当前页背景是否深色 → 文字是否需要反相 */
|
||||
const dark = computed(() => {
|
||||
const d = store.state.deck ? (props.el.type === 'card' ? false : isDarkBg(props.bg)) : false
|
||||
return d
|
||||
})
|
||||
|
||||
function isDarkBg(bg: string): boolean {
|
||||
if (!bg) return false
|
||||
if (bg.charAt(0) === '#') return isDarkHex(bg)
|
||||
if (bg === 'primary' || bg === 'accent') return true
|
||||
if (bg.indexOf('g-') === 0) return true
|
||||
return false
|
||||
}
|
||||
function hexToRgb(hex: string) {
|
||||
const c = String(hex).replace('#', '')
|
||||
const full = c.length === 3 ? c[0] + c[0] + c[1] + c[1] + c[2] + c[2] : c
|
||||
const r = parseInt(full.substr(0, 2), 16)
|
||||
const g = parseInt(full.substr(2, 2), 16)
|
||||
const b = parseInt(full.substr(4, 2), 16)
|
||||
return (isNaN(r) || isNaN(g) || isNaN(b)) ? null : { r, g, b }
|
||||
}
|
||||
function isDarkHex(hex: string): boolean {
|
||||
const rgb = hexToRgb(hex); if (!rgb) return false
|
||||
return (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) < 145
|
||||
}
|
||||
|
||||
const s = computed(() => props.el.style || {})
|
||||
|
||||
const boxStyle = computed(() => {
|
||||
const st = s.value
|
||||
const css: Record<string, string> = {}
|
||||
if (st.opacity != null) css.opacity = String(st.opacity)
|
||||
if (st.fontSize) css.fontSize = st.fontSize + 'px'
|
||||
if (st.align) css.textAlign = st.align
|
||||
if (st.bold === true) css.fontWeight = '700'
|
||||
if (st.bold === false) css.fontWeight = '400'
|
||||
if (st.italic === true) css.fontStyle = 'italic'
|
||||
const color = resolveColor(st.color, dark.value)
|
||||
if (color) css.color = color
|
||||
return css
|
||||
})
|
||||
|
||||
const dataList = computed(() => (props.el.content || '').split('\n'))
|
||||
|
||||
/** 表格解析 */
|
||||
const tableRows = computed(() => {
|
||||
const lines = (props.el.content || '').split('\n').map(l => l.trim()).filter(Boolean)
|
||||
const rows: string[][] = []
|
||||
for (const line of lines) {
|
||||
if (/^\|?[\s:-]+\|[\s:-|]+$/.test(line)) continue
|
||||
const cells = line.replace(/^\||\|$/g, '').split('|').map(c => c.trim())
|
||||
rows.push(cells)
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
/** 公式渲染 */
|
||||
const renderedFormula = computed(() => renderLatex(props.el.content || ''))
|
||||
|
||||
/** 形状背景 */
|
||||
const shapeBg = computed(() => {
|
||||
const st = s.value
|
||||
const fill = resolveColor(st.fill, false)
|
||||
if (st.gradient) {
|
||||
return 'linear-gradient(135deg, ' + fill + ' 0%, ' + resolveColor('accent', false) + ' 100%)'
|
||||
}
|
||||
return fill
|
||||
})
|
||||
|
||||
const isCircle = computed(() => s.value.shapeType === 'circle')
|
||||
const isTriangle = computed(() => s.value.shapeType === 'triangle')
|
||||
|
||||
const cardAccentColor = computed(() => resolveColor(s.value.accent, false))
|
||||
|
||||
/** 卡片:content 第一行=标题,其余=正文 */
|
||||
const cardParts = computed(() => {
|
||||
const lines = (props.el.content || '').split('\n')
|
||||
return { title: lines[0] || '', body: lines.slice(1).join('\n') }
|
||||
})
|
||||
|
||||
/* ---------- Rich text(segments 结构化富文本)---------- */
|
||||
/** 是否有 segments(结构化富文本),优先于 content */
|
||||
const hasSegments = computed(() => {
|
||||
return !!(props.el.segments && props.el.segments.length && hasFormatting(props.el.segments))
|
||||
})
|
||||
|
||||
/** title/text/quote 的渲染 HTML */
|
||||
const renderedContent = computed(() => {
|
||||
if (hasSegments.value) return segmentsToHtml(props.el.segments!)
|
||||
// 降级:检查 content 是否含 Markdown 语法(兼容旧数据)
|
||||
return null
|
||||
})
|
||||
|
||||
/** list 每行的渲染 HTML(segments 或纯文本) */
|
||||
const renderedListItems = computed(() => {
|
||||
if (hasSegments.value) {
|
||||
return props.el.segments!.map(line => segmentsToHtml([line]))
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
/** contenteditable 失焦回调 */
|
||||
function onBlur(e: Event, field: string) {
|
||||
const node = e.target as HTMLElement
|
||||
let val: string
|
||||
if (field === 'label') {
|
||||
val = node.textContent || ''
|
||||
} else if (props.el.type === 'list') {
|
||||
val = node.innerText.replace(/\r/g, '').trim()
|
||||
} else {
|
||||
val = node.textContent || ''
|
||||
}
|
||||
emit('blur', props.el.id, field, val)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="el"
|
||||
:data-id="el.id"
|
||||
:data-type="el.type"
|
||||
:data-anim="s.anim"
|
||||
:style="{
|
||||
left: el.x + '%',
|
||||
top: el.y + '%',
|
||||
width: el.w + '%',
|
||||
height: el.h + '%',
|
||||
...boxStyle
|
||||
}"
|
||||
>
|
||||
<!-- 标题 / 正文 / 金句 -->
|
||||
<template v-if="el.type === 'title' || el.type === 'text' || el.type === 'quote'">
|
||||
<!-- 编辑态:纯文本 contenteditable -->
|
||||
<div
|
||||
v-if="edit"
|
||||
class="el-text"
|
||||
style="white-space: pre-wrap; width: 100%"
|
||||
contenteditable="true"
|
||||
data-edit="content"
|
||||
@blur="onBlur($event, 'content')"
|
||||
>{{ el.content }}</div>
|
||||
<!-- 非编辑态 + 有 segments:渲染结构化富文本 -->
|
||||
<div
|
||||
v-else-if="renderedContent"
|
||||
class="el-text el-text-rich"
|
||||
v-html="renderedContent"
|
||||
></div>
|
||||
<!-- 非编辑态 + 纯文本 -->
|
||||
<div v-else class="el-text" style="white-space: pre-wrap; width: 100%">{{ el.content }}</div>
|
||||
</template>
|
||||
|
||||
<!-- 列表 -->
|
||||
<template v-else-if="el.type === 'list'">
|
||||
<!-- 编辑态 -->
|
||||
<div v-if="edit" class="el-list" contenteditable="true" data-edit="content" @blur="onBlur($event, 'content')">
|
||||
<div v-for="(line, i) in dataList" :key="i" class="li">{{ line }}</div>
|
||||
</div>
|
||||
<!-- 非编辑态 + 有 segments -->
|
||||
<div v-else-if="renderedListItems" class="el-list">
|
||||
<div v-for="(html, i) in renderedListItems" :key="i" class="li" v-html="html"></div>
|
||||
</div>
|
||||
<!-- 非编辑态 + 纯文本 -->
|
||||
<div v-else class="el-list">
|
||||
<div v-for="(line, i) in dataList" :key="i" class="li">{{ line }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 数据 -->
|
||||
<template v-else-if="el.type === 'stat'">
|
||||
<div class="el-stat">
|
||||
<div
|
||||
class="num"
|
||||
:contenteditable="edit"
|
||||
data-edit="content"
|
||||
@blur="edit && onBlur($event, 'content')"
|
||||
>{{ el.content }}</div>
|
||||
<div
|
||||
class="label"
|
||||
:style="{ fontSize: (s.labelSize || 16) + 'px', color: resolveColor(s.labelColor, dark) }"
|
||||
:contenteditable="edit"
|
||||
data-edit="label"
|
||||
@blur="edit && onBlur($event, 'label')"
|
||||
>{{ s.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 图片 -->
|
||||
<template v-else-if="el.type === 'image'">
|
||||
<img class="el-image" :src="el.content" draggable="false" />
|
||||
</template>
|
||||
|
||||
<!-- 形状 -->
|
||||
<template v-else-if="el.type === 'shape'">
|
||||
<div v-if="isCircle" class="el-shape" :class="{ gradient: s.gradient }" :style="{ borderRadius: '50%', background: shapeBg }"></div>
|
||||
<div v-else-if="isTriangle" class="el-shape">
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%;height:100%;display:block">
|
||||
<polygon points="50,5 95,95 5,95" :fill="shapeBg" />
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else class="el-shape" :class="{ gradient: s.gradient }" :style="{ background: shapeBg, borderRadius: (s.radius != null ? s.radius : 12) + 'px' }"></div>
|
||||
</template>
|
||||
|
||||
<!-- 图表 -->
|
||||
<template v-else-if="el.type === 'chart'">
|
||||
<ChartView :content="el.content" :style="s" :dark="dark" />
|
||||
</template>
|
||||
|
||||
<!-- 卡片 -->
|
||||
<template v-else-if="el.type === 'card'">
|
||||
<div class="card-bar" :style="{ background: cardAccentColor }"></div>
|
||||
<div class="el-card">
|
||||
<div v-if="s.icon" class="card-icon">{{ s.icon }}</div>
|
||||
<div class="card-title">{{ cardParts.title }}</div>
|
||||
<div class="card-body">{{ cardParts.body }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 表格 -->
|
||||
<template v-else-if="el.type === 'table'">
|
||||
<table class="el-table">
|
||||
<thead v-if="s.header !== false && tableRows.length">
|
||||
<tr><th v-for="(c, i) in tableRows[0]" :key="i">{{ c }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, ri) in tableRows.slice(s.header !== false ? 1 : 0)" :key="ri">
|
||||
<td v-for="(c, ci) in row" :key="ci">{{ c }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<!-- 代码 -->
|
||||
<template v-else-if="el.type === 'code'">
|
||||
<pre class="el-code">
|
||||
<code>{{ el.content }}</code>
|
||||
<span v-if="s.lang" class="code-lang">{{ s.lang }}</span>
|
||||
</pre>
|
||||
</template>
|
||||
|
||||
<!-- 公式 -->
|
||||
<template v-else-if="el.type === 'formula'">
|
||||
<div class="el-formula" v-html="renderedFormula"></div>
|
||||
</template>
|
||||
|
||||
<!-- 八向缩放手柄(仅编辑态选中时) -->
|
||||
<template v-if="showHandles">
|
||||
<div
|
||||
v-for="h in ['tl','tm','tr','lm','rm','bl','bm','br']"
|
||||
:key="h"
|
||||
class="handle"
|
||||
:class="h"
|
||||
:data-handle="h"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
355
src/components/editor/PropsPanel.vue
Normal file
355
src/components/editor/PropsPanel.vue
Normal file
@@ -0,0 +1,355 @@
|
||||
<!-- =====================================================================
|
||||
PropsPanel.vue — 右侧属性面板(元素样式 + 页面背景)
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, ref } from 'vue'
|
||||
import { store } from '../../core/store'
|
||||
import { elementTypes } from '../../core/sample'
|
||||
import { generateImage, isImageConfigured } from '../../core/ai'
|
||||
import { markdownToSegments, segmentsToPlain, hasFormatting } from '../../core/richtext'
|
||||
import AddGrid from './AddGrid.vue'
|
||||
import type { ElementType, ChartType } from '../../core/types'
|
||||
|
||||
const CHART_TYPES: Array<{ k: ChartType; label: string; icon: string }> = [
|
||||
{ k: 'bar', label: '柱状图', icon: '📊' },
|
||||
{ k: 'hbar', label: '条形图', icon: '📋' },
|
||||
{ k: 'line', label: '折线图', icon: '📈' },
|
||||
{ k: 'area', label: '面积图', icon: '🌄' },
|
||||
{ k: 'pie', label: '饼图', icon: '🥧' },
|
||||
{ k: 'doughnut', label: '环形图', icon: '🍩' },
|
||||
{ k: 'radar', label: '雷达图', icon: '🕸' },
|
||||
{ k: 'progress', label: '进度图', icon: '⭕' }
|
||||
]
|
||||
const CHART_TYPE_MAP: Record<string, { label: string; icon: string }> = Object.fromEntries(CHART_TYPES.map(t => [t.k, t]))
|
||||
|
||||
const selected = computed(() => store.getSelected())
|
||||
const slide = computed(() => store.currentSlide.value)
|
||||
|
||||
/** 临时输入态(v-model 绑定) */
|
||||
const content = ref('')
|
||||
const fontSize = ref(24)
|
||||
const colorSel = ref('primary')
|
||||
const colorPicker = ref('#000000')
|
||||
const shape = ref<'rect' | 'circle' | 'triangle'>('rect')
|
||||
const chartType = ref<ChartType>('bar')
|
||||
const codeLang = ref('')
|
||||
const imgBusy = ref(false)
|
||||
let imgAbort: AbortController | null = null
|
||||
|
||||
const BG_OPTIONS = [
|
||||
{ k: 'bg', label: '白底' },
|
||||
{ k: 'panel', label: '浅底' },
|
||||
{ k: 'primary', label: '主色' },
|
||||
{ k: 'accent', label: '强调' }
|
||||
]
|
||||
|
||||
/** 选中变化时同步输入控件 */
|
||||
watch(selected, (el) => {
|
||||
if (!el) return
|
||||
const s = el.style || {}
|
||||
if (el.type !== 'stat') content.value = el.content || ''
|
||||
fontSize.value = s.fontSize || 24
|
||||
shape.value = (s.shapeType as any) || 'rect'
|
||||
chartType.value = (s.chartType as ChartType) || 'bar'
|
||||
codeLang.value = s.lang || ''
|
||||
// 颜色下拉/picker 同步
|
||||
const presets = ['primary', 'accent', 'text', 'muted', '#ffffff']
|
||||
if (presets.includes(s.color || '')) {
|
||||
colorSel.value = s.color!
|
||||
colorPicker.value = '#000000'
|
||||
} else if (s.color && s.color.charAt(0) === '#') {
|
||||
colorSel.value = 'custom'
|
||||
colorPicker.value = /^#[0-9a-f]{6}$/i.test(s.color) ? s.color : '#000000'
|
||||
} else {
|
||||
colorSel.value = 'primary'
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const hasText = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula'].includes(selected.value.type))
|
||||
const canFont = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula'].includes(selected.value.type))
|
||||
const canAlign = computed(() => selected.value && ['title', 'text', 'quote', 'stat', 'table', 'formula'].includes(selected.value.type))
|
||||
const canBI = computed(() => selected.value && ['title', 'text', 'quote', 'list'].includes(selected.value.type))
|
||||
const canColor = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula'].includes(selected.value.type))
|
||||
const isShape = computed(() => selected.value?.type === 'shape')
|
||||
|
||||
const typeLabel = computed(() => {
|
||||
const el = selected.value
|
||||
return el ? (elementTypes[el.type]?.label || el.type) : ''
|
||||
})
|
||||
|
||||
function onAdd(type: ElementType) {
|
||||
store.addElement(type)
|
||||
}
|
||||
|
||||
function onContentInput() {
|
||||
if (!selected.value) return
|
||||
store.updateElement(selected.value.id, { content: content.value })
|
||||
}
|
||||
function onFontSizeInput() {
|
||||
if (!selected.value) return
|
||||
store.updateElement(selected.value.id, { style: { fontSize: Number(fontSize.value) } })
|
||||
}
|
||||
function onColorChange() {
|
||||
if (!selected.value) return
|
||||
if (colorSel.value === 'custom') return
|
||||
store.updateElement(selected.value.id, { style: { color: colorSel.value } })
|
||||
}
|
||||
function onColorPickerInput() {
|
||||
if (!selected.value) return
|
||||
store.updateElement(selected.value.id, { style: { color: colorPicker.value } })
|
||||
}
|
||||
function onShapeChange() {
|
||||
if (!selected.value) return
|
||||
store.updateElement(selected.value.id, { style: { shapeType: shape.value } })
|
||||
}
|
||||
function onChartTypeChange() {
|
||||
if (!selected.value) return
|
||||
store.updateElement(selected.value.id, { style: { chartType: chartType.value } })
|
||||
}
|
||||
function toggleLegend() {
|
||||
if (!selected.value) return
|
||||
const cur = selected.value.style.legend !== false
|
||||
store.updateElement(selected.value.id, { style: { legend: !cur } })
|
||||
}
|
||||
function toggleGrid() {
|
||||
if (!selected.value) return
|
||||
const cur = selected.value.style.grid !== false
|
||||
store.updateElement(selected.value.id, { style: { grid: !cur } })
|
||||
}
|
||||
function toggleHeader() {
|
||||
if (!selected.value) return
|
||||
const cur = selected.value.style.header !== false
|
||||
store.updateElement(selected.value.id, { style: { header: !cur } })
|
||||
}
|
||||
function onCodeLangInput() {
|
||||
if (!selected.value) return
|
||||
store.updateElement(selected.value.id, { style: { lang: codeLang.value } })
|
||||
}
|
||||
function onAlign(a: 'left' | 'center' | 'right') {
|
||||
if (!selected.value) return
|
||||
store.updateElement(selected.value.id, { style: { align: a } })
|
||||
}
|
||||
function toggleBold() {
|
||||
if (!selected.value) return
|
||||
store.updateElement(selected.value.id, { style: { bold: !selected.value.style.bold } })
|
||||
}
|
||||
function toggleItalic() {
|
||||
if (!selected.value) return
|
||||
store.updateElement(selected.value.id, { style: { italic: !selected.value.style.italic } })
|
||||
}
|
||||
function onZ(dir: number) {
|
||||
if (!selected.value) return
|
||||
store.moveElementZ(selected.value.id, dir)
|
||||
}
|
||||
function onDel() {
|
||||
if (!selected.value) return
|
||||
store.delElement(selected.value.id)
|
||||
}
|
||||
function onBg(k: string) {
|
||||
store.setSlideBackground(k)
|
||||
}
|
||||
|
||||
/* ---------- 富文本格式 ---------- */
|
||||
/** 当前选中元素是否有 segments */
|
||||
const hasRich = computed(() => {
|
||||
const el = selected.value
|
||||
return !!(el?.segments && el.segments.length && hasFormatting(el.segments))
|
||||
})
|
||||
|
||||
/** 从 Markdown 语法生成 segments */
|
||||
function applyMarkdown() {
|
||||
const el = selected.value
|
||||
if (!el) return
|
||||
const segs = markdownToSegments(content.value)
|
||||
if (segs.length && hasFormatting(segs)) {
|
||||
store.updateElement(el.id, { segments: segs } as any)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除 segments(降级为纯文本) */
|
||||
function clearRich() {
|
||||
const el = selected.value
|
||||
if (!el) return
|
||||
store.updateElement(el.id, { segments: undefined } as any)
|
||||
}
|
||||
|
||||
/* ---------- AI 配图 ---------- */
|
||||
async function onAiImage() {
|
||||
if (imgBusy.value) return
|
||||
const el = selected.value
|
||||
if (!el || el.type !== 'image') return
|
||||
if (!isImageConfigured()) { alert('请先在「AI 设置」中配置 API Key'); return }
|
||||
const promptText = window.prompt('描述你想要的图片,例如「现代办公室协作场景,俯拍,柔和光线」')
|
||||
if (!promptText) return
|
||||
imgBusy.value = true
|
||||
imgAbort = new AbortController()
|
||||
try {
|
||||
const r = await generateImage({ prompt: promptText, signal: imgAbort.signal })
|
||||
store.updateElement(el.id, { content: r.url })
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') alert('配图失败:' + (e?.message || String(e)))
|
||||
} finally {
|
||||
imgBusy.value = false; imgAbort = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-pane props-pane" id="panelProps">
|
||||
<!-- 空态 -->
|
||||
<section v-if="!selected" class="prop-section" id="propEmpty">
|
||||
<p class="prop-hint">点击画布元素以编辑样式<br />或在下方添加新元素</p>
|
||||
<AddGrid @add="onAdd" />
|
||||
</section>
|
||||
|
||||
<!-- 选中元素 -->
|
||||
<section v-else class="prop-section" id="propElement">
|
||||
<h4 class="prop-title">元素 · <span>{{ typeLabel }}</span></h4>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div v-if="hasText && selected.type !== 'stat'" class="prop-row">
|
||||
<label>内容</label>
|
||||
<textarea rows="3" placeholder="输入文字(列表用换行分隔)" v-model="content" @input="onContentInput"></textarea>
|
||||
<!-- 富文本格式提示 -->
|
||||
<div v-if="['title','text','quote','list'].includes(selected.type)" class="rich-hint">
|
||||
<div class="rich-syntax">
|
||||
**加粗** · *斜体* · ==高亮== · ~~删除线~~ · `代码` · ^上标^ · ~下标~
|
||||
</div>
|
||||
<div class="rich-actions">
|
||||
<button v-if="hasRich" class="rich-btn danger" @click="clearRich">清除格式</button>
|
||||
<button class="rich-btn" @click="applyMarkdown" title="把上面的 Markdown 语法转为富文本">转换格式</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 字号 -->
|
||||
<div v-if="canFont" class="prop-row">
|
||||
<label>字号 <span>{{ fontSize }}px</span></label>
|
||||
<input type="range" min="12" max="120" v-model.number="fontSize" @input="onFontSizeInput" />
|
||||
</div>
|
||||
|
||||
<!-- 颜色 -->
|
||||
<div v-if="canColor" class="prop-row">
|
||||
<label>颜色</label>
|
||||
<div class="color-row">
|
||||
<select v-model="colorSel" @change="onColorChange">
|
||||
<option value="primary">主色</option>
|
||||
<option value="accent">强调色</option>
|
||||
<option value="text">正文色</option>
|
||||
<option value="muted">次要色</option>
|
||||
<option value="#ffffff">白色</option>
|
||||
<option value="custom">自定义…</option>
|
||||
</select>
|
||||
<input type="color" v-model="colorPicker" v-show="colorSel === 'custom'" @input="onColorPickerInput" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对齐 -->
|
||||
<div v-if="canAlign" class="prop-row">
|
||||
<label>对齐</label>
|
||||
<div class="seg">
|
||||
<button :class="{ active: selected.style.align === 'left' }" @click="onAlign('left')">⬅</button>
|
||||
<button :class="{ active: selected.style.align === 'center' || !selected.style.align }" @click="onAlign('center')">⬌</button>
|
||||
<button :class="{ active: selected.style.align === 'right' }" @click="onAlign('right')">➡</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加粗/斜体 -->
|
||||
<div v-if="canBI" class="prop-row">
|
||||
<label>样式</label>
|
||||
<div class="seg">
|
||||
<button :class="{ active: !!selected.style.bold }" @click="toggleBold" title="加粗"><b>B</b></button>
|
||||
<button :class="{ active: !!selected.style.italic }" @click="toggleItalic" title="斜体"><i>I</i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 形状 -->
|
||||
<div v-if="isShape" class="prop-row">
|
||||
<label>形状</label>
|
||||
<select v-model="shape" @change="onShapeChange">
|
||||
<option value="rect">矩形</option>
|
||||
<option value="circle">圆形</option>
|
||||
<option value="triangle">三角</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 图表配置 -->
|
||||
<template v-if="selected.type === 'chart'">
|
||||
<div class="prop-row">
|
||||
<label>图表类型</label>
|
||||
<select v-model="chartType" @change="onChartTypeChange">
|
||||
<option v-for="t in CHART_TYPES" :key="t.k" :value="t.k">{{ t.icon }} {{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>显示选项</label>
|
||||
<div class="seg">
|
||||
<button :class="{ active: selected.style.legend !== false }" @click="toggleLegend" title="图例">图例</button>
|
||||
<button :class="{ active: selected.style.grid !== false }" @click="toggleGrid" title="网格线">网格</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>数据格式</label>
|
||||
<div style="font-size:11px;color:var(--ui-muted,#64748b);line-height:1.6;padding:.3em 0">
|
||||
单系列:[{"label":"A","value":65}]<br />
|
||||
多系列:{"series":["Q1","Q2"],"items":[{"label":"华东","values":[120,150]}]}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 表格选项 -->
|
||||
<div v-if="selected.type === 'table'" class="prop-row">
|
||||
<label>首行表头</label>
|
||||
<div class="seg">
|
||||
<button :class="{ active: selected.style.header !== false }" @click="toggleHeader">表头</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代码语言 -->
|
||||
<div v-if="selected.type === 'code'" class="prop-row">
|
||||
<label>语言</label>
|
||||
<input type="text" v-model="codeLang" @input="onCodeLangInput" placeholder="js / python / ..." />
|
||||
</div>
|
||||
|
||||
<!-- 公式提示 -->
|
||||
<div v-if="selected.type === 'formula'" class="prop-row">
|
||||
<label>提示</label>
|
||||
<div style="font-size:12px;color:var(--ui-muted);line-height:1.6">
|
||||
LaTeX 语法。示例:<br />
|
||||
E = mc^2<br />
|
||||
\frac{a}{b}<br />
|
||||
\sum_{i=1}^n x_i
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 配图(仅图片元素) -->
|
||||
<div v-if="selected.type === 'image'" class="prop-row">
|
||||
<label>AI 配图</label>
|
||||
<button class="btn" :disabled="imgBusy" @click="onAiImage">{{ imgBusy ? '生成中…' : '🎨 AI 配图' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 层级 -->
|
||||
<div class="prop-row">
|
||||
<label>层级</label>
|
||||
<div class="seg">
|
||||
<button @click="onZ(1)" title="上移">↑</button>
|
||||
<button @click="onZ(-1)" title="下移">↓</button>
|
||||
<button class="danger" @click="onDel" title="删除">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 当前页背景 -->
|
||||
<section class="prop-section">
|
||||
<h4 class="prop-title">当前页背景</h4>
|
||||
<div class="bg-grid">
|
||||
<button
|
||||
v-for="b in BG_OPTIONS"
|
||||
:key="b.k"
|
||||
:class="{ active: slide.background === b.k }"
|
||||
@click="onBg(b.k)"
|
||||
>{{ b.label }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
63
src/components/editor/ThumbBar.vue
Normal file
63
src/components/editor/ThumbBar.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<!-- =====================================================================
|
||||
ThumbBar.vue — 左侧缩略图列表
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { store, resolveBg } from '../../core/store'
|
||||
import ElementView from './ElementView.vue'
|
||||
|
||||
const slides = computed(() => store.slides.value)
|
||||
const currentIndex = computed(() => store.currentIndex.value)
|
||||
|
||||
function onClickItem(i: number) {
|
||||
store.setCurrentIndex(i)
|
||||
}
|
||||
function onClickDel(i: number, e: Event) {
|
||||
e.stopPropagation()
|
||||
store.delSlide(i)
|
||||
}
|
||||
function onClickAdd() {
|
||||
store.addSlide(store.getCurrentIndex())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="thumb-bar">
|
||||
<div class="thumb-list">
|
||||
<div
|
||||
v-for="(slide, i) in slides"
|
||||
:key="slide.id"
|
||||
class="thumb-item"
|
||||
:class="{ active: i === currentIndex }"
|
||||
:data-index="i"
|
||||
@click="onClickItem(i)"
|
||||
>
|
||||
<div class="thumb-num">{{ i + 1 }}</div>
|
||||
<button class="thumb-del" title="删除" :data-index="i" @click="onClickDel(i, $event)">×</button>
|
||||
<div class="thumb-preview" :style="{ background: resolveBg(slide.background) }">
|
||||
<div class="thumb-layer">
|
||||
<ElementView
|
||||
v-for="el in slide.elements"
|
||||
:key="el.id"
|
||||
:el="el"
|
||||
:bg="slide.background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="thumb-add" @click="onClickAdd">+ 新建幻灯片</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.thumb-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
transform: scale(0.12);
|
||||
transform-origin: top left;
|
||||
}
|
||||
</style>
|
||||
79
src/components/editor/Toolbar.vue
Normal file
79
src/components/editor/Toolbar.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<!-- =====================================================================
|
||||
Toolbar.vue — 顶部工具栏
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { store } from '../../core/store'
|
||||
import { themes } from '../../core/sample'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'present'): void
|
||||
(e: 'open-library'): void
|
||||
(e: 'open-settings'): void
|
||||
(e: 'open-ai'): void
|
||||
(e: 'save'): void
|
||||
(e: 'open-templates'): void
|
||||
}>()
|
||||
|
||||
const themeKeys = Object.keys(themes)
|
||||
const currentTheme = ref(store.getTheme())
|
||||
|
||||
// store 主题变化时同步下拉
|
||||
watch(() => store.theme.value, (v) => { currentTheme.value = v })
|
||||
|
||||
function onThemeChange() {
|
||||
store.setTheme(currentTheme.value)
|
||||
}
|
||||
|
||||
function action(a: string) {
|
||||
switch (a) {
|
||||
case 'add-slide': store.addSlide(store.getCurrentIndex()); break
|
||||
case 'dup-slide': store.dupSlide(); break
|
||||
case 'del-slide':
|
||||
if (store.delSlide()) { /* ok */ }
|
||||
break
|
||||
case 'reset':
|
||||
if (confirm('重置为内置示例?当前编辑内容将丢失(可用 Ctrl+Z 撤销)。')) {
|
||||
store.reset()
|
||||
}
|
||||
break
|
||||
case 'present': emit('present'); break
|
||||
case 'library': emit('open-library'); break
|
||||
case 'save': emit('save'); break
|
||||
case 'open-ai': emit('open-ai'); break
|
||||
case 'settings': emit('open-settings'); break
|
||||
}
|
||||
}
|
||||
|
||||
defineProps<{ disabledActions?: string[] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="toolbar">
|
||||
<div class="brand">
|
||||
<span class="logo">▦</span>
|
||||
<span class="name">u-ppt</span>
|
||||
<span class="sub">在线演示工具</span>
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="btn" data-action="add-slide" title="新建幻灯片" :disabled="disabledActions?.includes('add-slide')" @click="action('add-slide')">+ 幻灯片</button>
|
||||
<button class="btn" data-action="templates" title="从模板新建页" @click="emit('open-templates')">📋 模板</button>
|
||||
<button class="btn" data-action="dup-slide" title="复制当前页" :disabled="disabledActions?.includes('dup-slide')" @click="action('dup-slide')">⎘ 复制</button>
|
||||
<button class="btn" data-action="del-slide" title="删除当前页" :disabled="disabledActions?.includes('del-slide')" @click="action('del-slide')">🗑 删除</button>
|
||||
<span class="sep"></span>
|
||||
<label class="theme-select">
|
||||
主题
|
||||
<select v-model="currentTheme" @change="onThemeChange">
|
||||
<option v-for="k in themeKeys" :key="k" :value="k">{{ themes[k].name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="sep"></span>
|
||||
<button class="btn" data-action="library" title="我的演示文库" @click="action('library')">📁 文库</button>
|
||||
<button class="btn" data-action="save" title="保存到文库" :disabled="disabledActions?.includes('save')" @click="action('save')">💾 保存</button>
|
||||
<button class="btn" data-action="open-ai" title="打开 AI 助手" @click="action('open-ai')">🤖 AI</button>
|
||||
<button class="btn" data-action="reset" title="重置为内置示例" :disabled="disabledActions?.includes('reset')" @click="action('reset')">⟲ 重置</button>
|
||||
<button class="btn primary" data-action="present" title="开始演示 (F5)" :disabled="disabledActions?.includes('present')" @click="action('present')">▶ 演示</button>
|
||||
<button class="btn ghost" data-action="settings" title="AI 设置" @click="action('settings')">⚙</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
154
src/components/modals/LibraryModal.vue
Normal file
154
src/components/modals/LibraryModal.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<!-- =====================================================================
|
||||
LibraryModal.vue — 演示文库弹窗
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { LibItem, Slide } from '../../core/types'
|
||||
import { store, resolveBg } from '../../core/store'
|
||||
import ElementView from '../editor/ElementView.vue'
|
||||
|
||||
const props = defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'toast', msg: string): void
|
||||
(e: 'switch-tab', tab: string): void
|
||||
}>()
|
||||
|
||||
const libVersion = ref(0)
|
||||
const nameInput = ref('')
|
||||
|
||||
/** 读文库列表(非响应式,靠 libVersion 触发重算) */
|
||||
const library = computed<LibItem[]>(() => {
|
||||
void libVersion.value
|
||||
return store.getLibrary()
|
||||
})
|
||||
const activeId = computed(() => {
|
||||
void libVersion.value
|
||||
return store.getActiveLibId()
|
||||
})
|
||||
|
||||
const libCount = computed(() => library.value.length)
|
||||
const placeholder = computed(() => {
|
||||
const cur = library.value.find(x => x.id === activeId.value)
|
||||
return cur
|
||||
? '当前:「' + cur.name + '」,留空同名覆盖,输入新名另存为新份'
|
||||
: '为当前演示命名后存入文库…'
|
||||
})
|
||||
|
||||
function bump() { libVersion.value++ }
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) { bump(); nameInput.value = '' }
|
||||
})
|
||||
|
||||
function toast(msg: string) { emit('toast', msg) }
|
||||
|
||||
function firstSlide(item: LibItem): Slide | null {
|
||||
const s = item?.deck?.slides
|
||||
return s && s.length ? s[0] : null
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
if (!ts) return ''
|
||||
const diff = Date.now() - ts
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前'
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前'
|
||||
const d = new Date(ts)
|
||||
return (d.getMonth() + 1) + '月' + d.getDate() + '日'
|
||||
}
|
||||
|
||||
function onNameKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); onSave() }
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
const name = nameInput.value.trim()
|
||||
if (!name && !store.getActiveLibId()) {
|
||||
toast('请输入名称')
|
||||
return
|
||||
}
|
||||
store.saveToLibrary(name || null)
|
||||
toast(name ? ('已存入文库:' + name) : '已更新到文库')
|
||||
nameInput.value = ''
|
||||
bump()
|
||||
}
|
||||
|
||||
function onNewBlank() {
|
||||
store.newBlankDeck()
|
||||
emit('switch-tab', 'props')
|
||||
toast('已新建空白演示,在右侧添加元素')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onOpen(id: string) {
|
||||
if (store.loadFromLibrary(id)) {
|
||||
toast('已打开')
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
function onRename(id: string) {
|
||||
const cur = store.getLibrary().find(x => x.id === id)
|
||||
const name = prompt('重命名为', cur ? cur.name : '')
|
||||
if (name != null && name.trim()) {
|
||||
store.renameInLibrary(id, name.trim())
|
||||
bump()
|
||||
}
|
||||
}
|
||||
|
||||
function onDuplicate(id: string) {
|
||||
store.duplicateInLibrary(id)
|
||||
toast('已复制')
|
||||
bump()
|
||||
}
|
||||
|
||||
function onDelete(id: string) {
|
||||
if (confirm('删除这份演示?此操作不可撤销。')) {
|
||||
store.deleteFromLibrary(id)
|
||||
toast('已删除')
|
||||
bump()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-mask lib-modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
|
||||
<div class="modal lib-modal">
|
||||
<h3>演示文库</h3>
|
||||
<p class="modal-tip">保存多套演示文稿到本地,随时切换。</p>
|
||||
|
||||
<div class="lib-save-row">
|
||||
<input type="text" v-model="nameInput" :placeholder="placeholder" @keydown="onNameKeydown" />
|
||||
<button class="btn primary" @click="onSave">存入文库</button>
|
||||
<button class="btn" @click="onNewBlank">新建空白</button>
|
||||
</div>
|
||||
|
||||
<div class="lib-hint">共 {{ libCount }} 份</div>
|
||||
|
||||
<div class="lib-list">
|
||||
<div v-for="it in library" :key="it.id" class="lib-item" :class="{ active: it.id === activeId }" :data-id="it.id">
|
||||
<div class="lib-thumb" :style="{ background: firstSlide(it) ? resolveBg(firstSlide(it)!.background) : '#fff' }">
|
||||
<div v-if="firstSlide(it)" style="position:absolute;top:0;left:0;width:1280px;height:720px;transform:scale(0.05625);transform-origin:top left;pointer-events:none">
|
||||
<ElementView v-for="el in firstSlide(it)!.elements" :key="el.id" :el="el" :bg="firstSlide(it)!.background" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-info">
|
||||
<div class="lib-name">{{ it.name }}<span v-if="it.id === activeId"> (当前)</span></div>
|
||||
<div class="lib-meta">{{ (it.deck && it.deck.slides ? it.deck.slides.length : 0) + ' 页 · ' + formatTime(it.updatedAt || it.createdAt) }}</div>
|
||||
</div>
|
||||
<div class="lib-ops">
|
||||
<button class="btn" @click="onOpen(it.id)">打开</button>
|
||||
<button class="btn" @click="onRename(it.id)">重命名</button>
|
||||
<button class="btn" @click="onDuplicate(it.id)">复制</button>
|
||||
<button class="btn danger" @click="onDelete(it.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn" @click="emit('close')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
192
src/components/modals/SettingsModal.vue
Normal file
192
src/components/modals/SettingsModal.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<!-- =====================================================================
|
||||
SettingsModal.vue — AI 设置弹窗
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { AiCfg } from '../../core/types'
|
||||
import { store } from '../../core/store'
|
||||
|
||||
const props = defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'toast', msg: string): void
|
||||
}>()
|
||||
|
||||
const PRESETS: Record<string, { protocol: 'openai' | 'anthropic'; base: string; model: string; label: string }> = {
|
||||
zhipu: { protocol: 'openai', base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6', label: '智谱 GLM (OpenAI 协议)' },
|
||||
zhipu_anth: { protocol: 'anthropic', base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6', label: '智谱 GLM (Anthropic 协议)' },
|
||||
deepseek: { protocol: 'openai', base: 'https://api.deepseek.com', model: 'deepseek-chat', label: 'DeepSeek' },
|
||||
qwen: { protocol: 'openai', base: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', label: '通义千问' },
|
||||
kimi: { protocol: 'openai', base: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', label: 'Kimi' },
|
||||
doubao: { protocol: 'openai', base: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-pro-32k', label: '豆包' },
|
||||
openai: { protocol: 'openai', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini', label: 'OpenAI' },
|
||||
anthropic: { protocol: 'anthropic', base: 'https://api.anthropic.com', model: 'claude-sonnet-5', label: 'Anthropic' },
|
||||
gemini: { protocol: 'openai', base: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.0-flash', label: 'Gemini' },
|
||||
groq: { protocol: 'openai', base: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', label: 'Groq' },
|
||||
ollama: { protocol: 'openai', base: 'http://localhost:11434/v1', model: 'llama3.1', label: 'Ollama (本地)' }
|
||||
}
|
||||
|
||||
const PROTO_DEFAULTS: Record<string, { base: string; model: string }> = {
|
||||
openai: { base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6' },
|
||||
anthropic: { base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6' }
|
||||
}
|
||||
|
||||
const form = ref<AiCfg>({
|
||||
preset: 'zhipu', protocol: 'openai',
|
||||
base: '', key: '', model: '', proxy: '',
|
||||
imgBase: '', imgKey: '', imgModel: ''
|
||||
})
|
||||
|
||||
/** 从 store 读取并填充表单 */
|
||||
function loadFromStore() {
|
||||
const c = store.getCfg()
|
||||
form.value = {
|
||||
preset: PRESETS[c.preset] ? c.preset : 'custom',
|
||||
protocol: c.protocol || 'openai',
|
||||
base: c.base, key: c.key, model: c.model, proxy: c.proxy,
|
||||
imgBase: c.imgBase || '',
|
||||
imgKey: c.imgKey || '',
|
||||
imgModel: c.imgModel || ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) loadFromStore()
|
||||
}, { immediate: true })
|
||||
|
||||
function applyPreset(key: string) {
|
||||
const p = PRESETS[key]
|
||||
if (!p) return
|
||||
form.value.protocol = p.protocol
|
||||
form.value.base = p.base
|
||||
form.value.model = p.model
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
if (form.value.preset !== 'custom') applyPreset(form.value.preset)
|
||||
}
|
||||
|
||||
function applyProtoDefaults(p: string) {
|
||||
const d = PROTO_DEFAULTS[p] || PROTO_DEFAULTS.openai
|
||||
const other = PROTO_DEFAULTS[p === 'openai' ? 'anthropic' : 'openai']
|
||||
if (!form.value.base.trim() || form.value.base.trim() === other.base) form.value.base = d.base
|
||||
if (!form.value.model.trim() || form.value.model.trim() === other.model) form.value.model = d.model
|
||||
}
|
||||
|
||||
function onProtocolChange() {
|
||||
applyProtoDefaults(form.value.protocol)
|
||||
}
|
||||
|
||||
function save() {
|
||||
store.setCfg({
|
||||
preset: form.value.preset,
|
||||
protocol: form.value.protocol,
|
||||
base: form.value.base.trim(),
|
||||
key: form.value.key.trim(),
|
||||
model: form.value.model.trim(),
|
||||
proxy: form.value.proxy.trim(),
|
||||
imgBase: (form.value.imgBase || '').trim(),
|
||||
imgKey: (form.value.imgKey || '').trim(),
|
||||
imgModel: (form.value.imgModel || '').trim()
|
||||
})
|
||||
const label = form.value.preset === 'custom'
|
||||
? (form.value.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') + ' 自定义'
|
||||
: (PRESETS[form.value.preset]?.label || form.value.preset)
|
||||
emit('toast', '已保存 AI 设置(' + label + ')')
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<h3>AI 设置</h3>
|
||||
<p class="modal-tip">配置大模型服务商。数据仅保存在本地浏览器。</p>
|
||||
|
||||
<div class="form-row">
|
||||
<label>服务商</label>
|
||||
<select v-model="form.preset" @change="onProviderChange">
|
||||
<optgroup label="国内">
|
||||
<option value="zhipu">智谱 GLM (OpenAI 协议)</option>
|
||||
<option value="zhipu_anth">智谱 GLM (Anthropic 协议)</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="qwen">通义千问</option>
|
||||
<option value="kimi">Kimi</option>
|
||||
<option value="doubao">豆包</option>
|
||||
</optgroup>
|
||||
<optgroup label="海外">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="groq">Groq</option>
|
||||
</optgroup>
|
||||
<optgroup label="本地">
|
||||
<option value="ollama">Ollama</option>
|
||||
</optgroup>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>API 协议</label>
|
||||
<select v-model="form.protocol" @change="onProtocolChange">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Base URL</label>
|
||||
<input type="text" v-model="form.base" placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>API Key</label>
|
||||
<input type="password" v-model="form.key" placeholder="sk-..." autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>模型</label>
|
||||
<input type="text" v-model="form.model" placeholder="模型名称" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>代理 URL(可选)</label>
|
||||
<input type="text" v-model="form.proxy" placeholder="留空则直连" />
|
||||
</div>
|
||||
|
||||
<div class="section-title">图像模型(可选,用于 AI 配图)</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>图像 Base URL</label>
|
||||
<input type="text" v-model="form.imgBase" placeholder="留空则复用上方 Base URL" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>图像 API Key</label>
|
||||
<input type="password" v-model="form.imgKey" placeholder="留空则复用上方 Key" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>图像模型</label>
|
||||
<input type="text" v-model="form.imgModel" placeholder="dall-e-3" />
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn" @click="emit('close')">取消</button>
|
||||
<button class="btn primary" @click="save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-title {
|
||||
margin: 18px 0 10px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--ui-border);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
</style>
|
||||
189
src/components/modals/TemplateModal.vue
Normal file
189
src/components/modals/TemplateModal.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<!-- =====================================================================
|
||||
TemplateModal.vue — 页面模板:基于模板新建页 + 管理自存模板
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { store, resolveBg } from '../../core/store'
|
||||
import type { PageTemplate } from '../../core/types'
|
||||
import ElementView from '../editor/ElementView.vue'
|
||||
|
||||
defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'toast', msg: string): void
|
||||
}>()
|
||||
|
||||
/* ---------- 存当前页为模板 ---------- */
|
||||
const nameInput = ref('')
|
||||
|
||||
/* ---------- 非响应式列表的刷新机制(getTemplates 不返回响应式数据) ---------- */
|
||||
const tick = ref(0)
|
||||
const templates = computed(() => { tick.value; return store.getTemplates() })
|
||||
function bump() { tick.value++ }
|
||||
|
||||
function onSave() {
|
||||
const v = nameInput.value.trim()
|
||||
if (!v) { emit('toast', '请输入模板名'); return }
|
||||
// 空页存模板没意义
|
||||
const cur = store.currentSlide.value
|
||||
if (cur && cur.elements.length === 0) {
|
||||
emit('toast', '当前页为空,无法存为模板')
|
||||
return
|
||||
}
|
||||
store.saveCurrentAsTemplate(v)
|
||||
bump()
|
||||
nameInput.value = ''
|
||||
emit('toast', '已存为模板:' + v)
|
||||
}
|
||||
|
||||
/* ---------- 基于模板新建页 ---------- */
|
||||
function onPick(tpl: PageTemplate) {
|
||||
const ok = store.addSlideFromTemplate(tpl.id)
|
||||
if (ok) {
|
||||
emit('toast', '已基于「' + tpl.name + '」新建一页')
|
||||
emit('close')
|
||||
} else {
|
||||
emit('toast', '新建失败:找不到模板')
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 删除自存模板 ---------- */
|
||||
function onDelete(tpl: PageTemplate) {
|
||||
if (confirm('删除模板「' + tpl.name + '」?')) {
|
||||
store.deleteTemplate(tpl.id)
|
||||
bump()
|
||||
emit('toast', '已删除')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
|
||||
<div class="modal tpl-modal">
|
||||
<h3>📋 页面模板</h3>
|
||||
|
||||
<!-- 存当前页 -->
|
||||
<div class="tpl-save-bar">
|
||||
<input v-model="nameInput" placeholder="存当前页为模板,输入名称…" @keydown.enter="onSave" />
|
||||
<button class="btn primary" @click="onSave">💾 保存为模板</button>
|
||||
</div>
|
||||
|
||||
<!-- 模板网格 -->
|
||||
<div class="tpl-grid">
|
||||
<div v-for="tpl in templates" :key="tpl.id" class="tpl-card" :class="tpl.category" @click="onPick(tpl)">
|
||||
<div class="tpl-thumb" :style="{ background: resolveBg(tpl.background) }">
|
||||
<div class="tpl-thumb-layer">
|
||||
<ElementView v-for="el in tpl.elements" :key="el.id" :el="el" :bg="tpl.background" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="tpl-info">
|
||||
<span class="tpl-name">{{ tpl.name }}</span>
|
||||
<span class="tpl-badge" :class="tpl.category">{{ tpl.category === 'built-in' ? '内置' : '自存' }}</span>
|
||||
<button v-if="tpl.category === 'user'" class="tpl-del" title="删除" @click.stop="onDelete(tpl)">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn" @click="emit('close')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tpl-modal {
|
||||
width: 720px;
|
||||
max-width: 92vw;
|
||||
max-height: 86vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tpl-save-bar {
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.tpl-save-bar input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tpl-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1em;
|
||||
overflow-y: auto;
|
||||
padding: .2em;
|
||||
}
|
||||
|
||||
.tpl-card {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 2px solid rgba(100, 116, 139, .2);
|
||||
transition: border-color .15s, transform .15s;
|
||||
}
|
||||
|
||||
.tpl-card:hover {
|
||||
border-color: var(--primary, #4f46e5);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tpl-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 56.25%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tpl-thumb-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
transform: scale(0.094); /* 约 120px 宽 */
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tpl-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .4em;
|
||||
padding: .5em .6em;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tpl-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tpl-badge {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(100, 116, 139, .15);
|
||||
color: var(--muted, #64748b);
|
||||
}
|
||||
|
||||
.tpl-badge.user {
|
||||
background: rgba(79, 70, 229, .12);
|
||||
color: var(--primary, #4f46e5);
|
||||
}
|
||||
|
||||
.tpl-del {
|
||||
font-size: 13px;
|
||||
opacity: .5;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.tpl-del:hover {
|
||||
opacity: 1;
|
||||
color: #e11d48;
|
||||
}
|
||||
</style>
|
||||
252
src/components/present/PresentMode.vue
Normal file
252
src/components/present/PresentMode.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<!-- =====================================================================
|
||||
PresentMode.vue — 全屏演示模式
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import type { Slide } from '../../core/types'
|
||||
import { store, resolveBg } from '../../core/store'
|
||||
import { CANVAS_W, CANVAS_H } from '../../core/sample'
|
||||
import ElementView from '../editor/ElementView.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible: boolean
|
||||
startIndex?: number
|
||||
}>(), {
|
||||
startIndex: undefined
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ (e: 'exit'): void }>()
|
||||
|
||||
const index = ref(0)
|
||||
const idle = ref(false)
|
||||
const black = ref(false)
|
||||
const dir = ref<'next' | 'prev'>('next')
|
||||
const scale = ref(1)
|
||||
const showHint = ref(false)
|
||||
const innerEl = ref<HTMLElement | null>(null)
|
||||
|
||||
const slides = computed<Slide[]>(() => store.getSlides())
|
||||
const currentSlide = computed<Slide | null>(() => {
|
||||
const arr = slides.value
|
||||
if (!arr.length) return null
|
||||
const i = Math.max(0, Math.min(index.value, arr.length - 1))
|
||||
return arr[i]
|
||||
})
|
||||
const total = computed(() => slides.value.length)
|
||||
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let wheelLock = false
|
||||
let hintTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let active = false
|
||||
|
||||
/* ---------- 全屏 ---------- */
|
||||
function enterFs() {
|
||||
const el = document.documentElement as any
|
||||
const fn = el.requestFullscreen || el.webkitRequestFullscreen || el.msRequestFullscreen
|
||||
if (fn) { try { fn.call(el) } catch (e) {} }
|
||||
}
|
||||
function exitFs() {
|
||||
const d = document as any
|
||||
const fn = d.exitFullscreen || d.webkitExitFullscreen || d.msExitFullscreen
|
||||
if (fn && document.fullscreenElement) { try { fn.call(document) } catch (e) {} }
|
||||
}
|
||||
function toggleFs() {
|
||||
if (document.fullscreenElement) exitFs()
|
||||
else enterFs()
|
||||
}
|
||||
|
||||
/* ---------- 适配屏幕 ---------- */
|
||||
function fit() {
|
||||
const s = Math.min(window.innerWidth / CANVAS_W, window.innerHeight / CANVAS_H)
|
||||
scale.value = s
|
||||
}
|
||||
|
||||
/* ---------- 翻页 ---------- */
|
||||
function goto(i: number, d?: 'next' | 'prev') {
|
||||
const n = total.value
|
||||
if (i < 0 || i > n - 1) return
|
||||
if (i === index.value) return
|
||||
const forward = i > index.value
|
||||
index.value = i
|
||||
dir.value = d || (forward ? 'next' : 'prev')
|
||||
}
|
||||
function next() {
|
||||
if (index.value < total.value - 1) {
|
||||
index.value++
|
||||
dir.value = 'next'
|
||||
}
|
||||
}
|
||||
function prev() {
|
||||
if (index.value > 0) {
|
||||
index.value--
|
||||
dir.value = 'prev'
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- HUD idle ---------- */
|
||||
function wake() {
|
||||
idle.value = false
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
idleTimer = setTimeout(() => { idle.value = true }, 2200)
|
||||
}
|
||||
|
||||
/* ---------- 黑屏 ---------- */
|
||||
function toggleBlack() { black.value = !black.value }
|
||||
|
||||
/* ---------- 键盘 ---------- */
|
||||
function onKey(e: KeyboardEvent) {
|
||||
wake()
|
||||
switch (e.key) {
|
||||
case 'ArrowRight': case 'ArrowDown': case ' ': case 'PageDown':
|
||||
e.preventDefault(); next(); break
|
||||
case 'ArrowLeft': case 'ArrowUp': case 'PageUp':
|
||||
e.preventDefault(); prev(); break
|
||||
case 'Home':
|
||||
e.preventDefault(); goto(0, 'prev'); break
|
||||
case 'End':
|
||||
e.preventDefault(); goto(total.value - 1, 'next'); break
|
||||
case 'F': case 'f':
|
||||
e.preventDefault(); toggleFs(); break
|
||||
case 'B': case 'b':
|
||||
toggleBlack(); break
|
||||
case 'Escape':
|
||||
exit(); break
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 鼠标 ---------- */
|
||||
function onClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('.hud-dot')) {
|
||||
wake()
|
||||
const i = Number((target.closest('.hud-dot') as HTMLElement).dataset.idx)
|
||||
goto(i)
|
||||
return
|
||||
}
|
||||
if (target.closest('[data-action="exit-present"]')) { exit(); return }
|
||||
wake()
|
||||
next()
|
||||
}
|
||||
function onContext(e: MouseEvent) { e.preventDefault(); wake(); prev() }
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (wheelLock) return
|
||||
wheelLock = true
|
||||
if (e.deltaY > 0) next(); else prev()
|
||||
wake()
|
||||
setTimeout(() => { wheelLock = false }, 450)
|
||||
}
|
||||
|
||||
/* ---------- 圆点(>20 页不显示) ---------- */
|
||||
const showDots = computed(() => total.value <= 20)
|
||||
|
||||
/* ---------- 首次提示 ---------- */
|
||||
function triggerHint() {
|
||||
showHint.value = true
|
||||
if (hintTimer) clearTimeout(hintTimer)
|
||||
hintTimer = setTimeout(() => { showHint.value = false }, 3600)
|
||||
}
|
||||
|
||||
/* ---------- list 内部逐条入场动画延迟 ----------
|
||||
* ElementView 不可修改,用 ref + querySelector 设置 .li 的 animation-delay
|
||||
*/
|
||||
function applyListDelays() {
|
||||
const root = innerEl.value
|
||||
if (!root) return
|
||||
const nodes = root.querySelectorAll('.el')
|
||||
nodes.forEach((node, i) => {
|
||||
if (node.getAttribute('data-type') === 'list') {
|
||||
const lis = node.querySelectorAll('.li')
|
||||
lis.forEach((li, j) => {
|
||||
(li as HTMLElement).style.animationDelay = (0.24 + i * 0.09 + j * 0.08) + 's'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* 翻页后重新应用 list delay */
|
||||
watch([index, dir], () => {
|
||||
nextTick(applyListDelays)
|
||||
})
|
||||
|
||||
/* ---------- 进入/退出 ---------- */
|
||||
function bindEvents() {
|
||||
document.addEventListener('keydown', onKey)
|
||||
window.addEventListener('resize', fit)
|
||||
}
|
||||
function unbindEvents() {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('resize', fit)
|
||||
}
|
||||
|
||||
function start() {
|
||||
active = true
|
||||
const start = props.startIndex != null ? props.startIndex : store.getCurrentIndex()
|
||||
index.value = (start != null && start < store.getCount()) ? start : store.getCurrentIndex()
|
||||
dir.value = 'next'
|
||||
black.value = false
|
||||
idle.value = false
|
||||
|
||||
bindEvents()
|
||||
nextTick(() => { fit() })
|
||||
enterFs()
|
||||
triggerHint()
|
||||
wake()
|
||||
}
|
||||
|
||||
function exit() {
|
||||
if (!active) return
|
||||
active = false
|
||||
exitFs()
|
||||
unbindEvents()
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
if (hintTimer) clearTimeout(hintTimer)
|
||||
store.setCurrentIndex(index.value)
|
||||
emit('exit')
|
||||
}
|
||||
|
||||
watch(() => props.visible, (v, old) => {
|
||||
if (v && !old) start()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (active) exit()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="app-present">
|
||||
<div class="present-stage" :class="{ idle, black }" @click="onClick" @contextmenu="onContext" @wheel.passive="onWheel" @mousemove="wake">
|
||||
<div class="slide-layer" :style="{ transform: 'scale(' + scale + ')' }">
|
||||
<div v-if="currentSlide" ref="innerEl" class="slide-inner animated" :class="'enter-' + dir" :style="{ background: resolveBg(currentSlide.background) }">
|
||||
<ElementView
|
||||
v-for="(el, i) in currentSlide.elements"
|
||||
:key="el.id"
|
||||
:el="el"
|
||||
:bg="currentSlide.background"
|
||||
:style="{ animationDelay: (0.12 + i * 0.09) + 's' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showHint" class="present-hint">← → 翻页 · F 全屏 · B 黑屏 · Esc 退出</div>
|
||||
</div>
|
||||
|
||||
<div class="present-hud">
|
||||
<div class="hud-progress">
|
||||
<div class="hud-progress-bar" :style="{ width: (total <= 1 ? 100 : (index / (total - 1)) * 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="hud-row">
|
||||
<span>{{ (index + 1) + ' / ' + total }}</span>
|
||||
<div v-if="showDots" class="hud-dots">
|
||||
<div
|
||||
v-for="i in total"
|
||||
:key="i"
|
||||
class="hud-dot"
|
||||
:class="{ active: (i - 1) === index }"
|
||||
:data-idx="i - 1"
|
||||
></div>
|
||||
</div>
|
||||
<button class="btn ghost" data-action="exit-present" @click.stop="exit">退出 (Esc)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
117
src/composables/useEditor.ts
Normal file
117
src/composables/useEditor.ts
Normal file
@@ -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<DragSession | null> = 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<string, [number, number]> = {
|
||||
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
|
||||
}
|
||||
}
|
||||
611
src/core/ai.ts
Normal file
611
src/core/ai.ts
Normal file
@@ -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<string, string> = {
|
||||
'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<number, string> = {
|
||||
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<string, string>, body: unknown, signal?: AbortSignal): Promise<Response> {
|
||||
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<typeof store.getCfg>) {
|
||||
const url = apiUrl(cfg) + '/chat/completions'
|
||||
const body: Record<string, unknown> = { 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<typeof store.getCfg>) {
|
||||
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<string, unknown> = {
|
||||
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<Outline> {
|
||||
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<Slide> {
|
||||
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<ImageGenResult> {
|
||||
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<ThemeSuggestion> {
|
||||
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 }
|
||||
54
src/core/inline.ts
Normal file
54
src/core/inline.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/* =====================================================================
|
||||
* inline.ts — Markdown 行内格式渲染
|
||||
* 把 **加粗** / *斜体* / ~~删除线~~ / ==高亮== / `代码` / ~下标~ / ^上标^ 转 HTML
|
||||
* 先转义 HTML,再按顺序替换(避免互相破坏)
|
||||
* ===================================================================== */
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').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, '<strong>$1</strong>')
|
||||
// 斜体 *text* 或 _text_
|
||||
s = s.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
|
||||
s = s.replace(/(?<!\w)_([^_]+)_(?!\w)/g, '<em>$1</em>')
|
||||
// 删除线 ~~text~~
|
||||
s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>')
|
||||
// 高亮 ==text==
|
||||
s = s.replace(/==([^=]+)==/g, '<mark>$1</mark>')
|
||||
// 行内代码 `code`
|
||||
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// 下标 ~text~(与删除线区分:单个 ~)
|
||||
s = s.replace(/(?<!~)~([^~\s][^~]*?)~(?!~)/g, '<sub>$1</sub>')
|
||||
// 上标 ^text^
|
||||
s = s.replace(/\^([^\^\s][^\^]*?)\^/g, '<sup>$1</sup>')
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染多行文本:逐行 renderInline,用 <br> 连接。
|
||||
* 用于 title / text / quote 元素的 content。
|
||||
*/
|
||||
export function renderInlineMulti(text: string): string {
|
||||
if (!text) return ''
|
||||
return text.split('\n').map(renderInline).join('<br>')
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否包含 Markdown 行内语法(用于决定是否走 rich 渲染路径)
|
||||
*/
|
||||
export function hasInlineSyntax(text: string): boolean {
|
||||
if (!text) return false
|
||||
return /\*\*[^*]+\*\*|(?<!\*)\*[^*]+\*(?!\*)|~~[^~]+~~|==[^=]+==|`[^`]+`|\^[^\^]+\^/.test(text)
|
||||
}
|
||||
211
src/core/richtext.ts
Normal file
211
src/core/richtext.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/* =====================================================================
|
||||
* richtext.ts — 结构化富文本(segments)的序列化/反序列化/渲染
|
||||
*
|
||||
* 数据模型:
|
||||
* RichLine[] = 一组行,每行含若干 RichSegment
|
||||
* RichSegment = { text, bold?, italic?, color?, highlight?, ... }
|
||||
*
|
||||
* 与 content (string) 的关系:
|
||||
* - segments 存在时优先用于渲染
|
||||
* - segmentsToPlain() 可降级为纯文本(AI context / 搜索 / 向后兼容)
|
||||
* - plainToSegments() 可从纯文本升级
|
||||
* ===================================================================== */
|
||||
import type { RichLine, RichSegment } from './types'
|
||||
|
||||
/* ---------- 转义 ---------- */
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/* ---------- segments → HTML(渲染用) ---------- */
|
||||
|
||||
function segmentToHtml(seg: RichSegment): string {
|
||||
let html = escapeHtml(seg.text)
|
||||
if (!html) return ''
|
||||
|
||||
// 嵌套标签(顺序:外层 → 内层)
|
||||
if (seg.code) html = `<code class="rt-code">${html}</code>`
|
||||
if (seg.sup) html = `<sup>${html}</sup>`
|
||||
if (seg.sub) html = `<sub>${html}</sub>`
|
||||
if (seg.strike) html = `<del>${html}</del>`
|
||||
if (seg.underline) html = `<u>${html}</u>`
|
||||
if (seg.italic) html = `<em>${html}</em>`
|
||||
if (seg.bold) html = `<strong>${html}</strong>`
|
||||
|
||||
// 颜色 / 字号 / 高亮 / 链接 → 用 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 = `<a href="${escapeHtml(safeHref)}" target="_blank" rel="noopener"${styles.length ? ` style="${styles.join(';')}"` : ''}>${html}</a>`
|
||||
} else if (styles.length) {
|
||||
html = `<span style="${styles.join(';')}">${html}</span>`
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
/** segments → 完整 HTML(多行,用 <br> 分隔) */
|
||||
export function segmentsToHtml(lines: RichLine[]): string {
|
||||
if (!lines || !lines.length) return ''
|
||||
return lines
|
||||
.map(line => (line.segments || []).map(segmentToHtml).join(''))
|
||||
.join('<br>')
|
||||
}
|
||||
|
||||
/* ---------- 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<RichSegment> | null }> = [
|
||||
{ regex: /\*\*([^*]+)\*\*/, parse: (_m, inner) => ({ text: inner, bold: true }) },
|
||||
{ regex: /(?<!\*)\*([^*]+)\*(?!\*)/, parse: (_m, inner) => ({ 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: /(?<!~)~([^~\s][^~]*?)~(?!~)/, parse: (_m, inner) => ({ 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
|
||||
}
|
||||
169
src/core/sample.ts
Normal file
169
src/core/sample.ts
Normal file
@@ -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<string, Theme> = {
|
||||
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<ElementType, { label: string; defaults: ElementStyle }> = {
|
||||
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<SlideElement> & { style?: Partial<ElementStyle> }): SlideElement {
|
||||
// 按元素类型给合理的默认尺寸(w/h 百分比,画布 1280×720)
|
||||
const DEFAULT_SIZE: Record<ElementType, { w: number; h: number }> = {
|
||||
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' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
702
src/core/store.ts
Normal file
702
src/core/store.ts
Normal file
@@ -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<typeof setTimeout> | 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<typeof setTimeout> | null = null
|
||||
|
||||
/* ---------- 工具 ---------- */
|
||||
function clone<T>(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<string, string>
|
||||
if (!key) return ''
|
||||
if (key.charAt(0) === '#') {
|
||||
if (!isValidHex(key)) return dark ? '#ffffff' : (t.text || '#1e293b')
|
||||
if (dark && isDarkHex(key)) return '#ffffff'
|
||||
return key
|
||||
}
|
||||
if (dark) {
|
||||
if (key === 'text') return '#ffffff'
|
||||
if (key === 'muted') return 'rgba(255,255,255,0.72)'
|
||||
if (key === 'primary') return '#ffffff'
|
||||
return t[key] || '#ffffff'
|
||||
}
|
||||
return t[key] || t.text || '#1e293b'
|
||||
}
|
||||
|
||||
/** 背景解析:渐变键 → CSS gradient;其余 → 主题色或合法 hex;未知键回退白 */
|
||||
export function resolveBg(key: string): string {
|
||||
const t = (getAllThemes()[state.deck.theme] || {}) as unknown as Record<string, string>
|
||||
if (!key) return '#ffffff'
|
||||
if (key.charAt(0) === '#') return isValidHex(key) ? key : '#ffffff'
|
||||
if (key === 'g-primary') return 'linear-gradient(135deg, ' + t.primary + ' 0%, ' + t.accent + ' 100%)'
|
||||
if (key === 'g-deep') return 'linear-gradient(160deg, ' + shade(t.primary, -28) + ' 0%, ' + t.primary + ' 100%)'
|
||||
if (key === 'g-soft') return 'linear-gradient(135deg, ' + (t.panel || '#f8fafc') + ' 0%, ' + (t.bg || '#ffffff') + ' 100%)'
|
||||
return t[key] || '#ffffff'
|
||||
}
|
||||
|
||||
/* ---------- 历史快照 ---------- */
|
||||
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<typeof createElement>[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<SlideElement> & { style?: Record<string, unknown> }) {
|
||||
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<string, unknown>).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<string, unknown>, 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<string, import('./types').Theme> {
|
||||
let custom: Record<string, import('./types').Theme> = {}
|
||||
try { custom = JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) || '{}') || {} } catch (e) {}
|
||||
return { ...custom, ...themes }
|
||||
}
|
||||
|
||||
/** 获取自定义主题列表(仅自定义的) */
|
||||
function getCustomThemes(): Record<string, import('./types').Theme> {
|
||||
try { return JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) || '{}') || {} } catch (e) { return {} }
|
||||
}
|
||||
|
||||
/** 保存一个自定义主题(key 形式 'custom-<ts>-<rand>') */
|
||||
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,
|
||||
}
|
||||
258
src/core/types.ts
Normal file
258
src/core/types.ts
Normal file
@@ -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[]
|
||||
}
|
||||
7
src/env.d.ts
vendored
Normal file
7
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
12
src/main.ts
Normal file
12
src/main.ts
Normal file
@@ -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')
|
||||
126
src/styles/base.css
Normal file
126
src/styles/base.css
Normal file
@@ -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; }
|
||||
}
|
||||
105
src/styles/chat.css
Normal file
105
src/styles/chat.css
Normal file
@@ -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; }
|
||||
354
src/styles/editor.css
Normal file
354
src/styles/editor.css
Normal file
@@ -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; } }
|
||||
123
src/styles/present.css
Normal file
123
src/styles/present.css
Normal file
@@ -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; }
|
||||
}
|
||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
@@ -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"}
|
||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@@ -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 }
|
||||
})
|
||||
Reference in New Issue
Block a user