新增: 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:
2026-07-12 13:12:09 +08:00
commit cfeabf2d37
35 changed files with 7919 additions and 0 deletions

34
.gitignore vendored Normal file
View 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
View 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 | 默认模型 |
|--------|------|----------|----------|
| 智谱 GLMOpenAI | OpenAI | `open.bigmodel.cn/api/paas/v4` | glm-4.6 |
| 智谱 GLMAnthropic | 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)无需填 KeyOllama 需设环境变量 `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
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View 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
View 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>

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** 尝试把 JSON 字符串格式化(缩进),失败则返回原文 */
function tryFormatJson(raw: string): string {
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch (e) { return raw }
}
/** 对格式化后的 JSON 做轻量语法着色(不引依赖,纯正则) */
function highlightJson(code: string): string {
return escapeHtml(code)
.replace(/("(?:\\.|[^"\\])*"\s*:)/g, '<span class="jk">$1</span>')
.replace(/:\s*("(?:\\.|[^"\\])*")/g, ': <span class="js">$1</span>')
.replace(/:\s*(-?\d+\.?\d*)/g, ': <span class="jn">$1</span>')
.replace(/:\s*(true|false|null)/g, ': <span class="jb">$1</span>')
}
interface RenderedPart {
type: 'text' | 'code'
html: string
lang?: string
}
/** 把消息内容拆分为文本段 + JSON 代码块段 */
function renderMessageContent(content: string): RenderedPart[] {
if (!content) return []
const parts: RenderedPart[] = []
// 匹配 ```json ... ``` 或 ``` ... ``` 代码块
const codeBlockRe = /```(\w*)\n?([\s\S]*?)```/g
let lastIdx = 0
let m: RegExpExecArray | null
while ((m = codeBlockRe.exec(content)) !== null) {
// 代码块前的文本
if (m.index > lastIdx) {
const text = content.slice(lastIdx, m.index)
if (text.trim()) parts.push({ type: 'text', html: escapeHtml(text) })
}
const lang = m[1] || ''
const code = m[2]
const trimmed = code.trim()
// JSON 代码块 → 格式化 + 着色
if (!lang || lang === 'json') {
parts.push({ type: 'code', lang: 'json', html: highlightJson(tryFormatJson(trimmed)) })
} else {
parts.push({ type: 'code', lang, html: escapeHtml(trimmed) })
}
lastIdx = m.index + m[0].length
}
// 尾部文本
if (lastIdx < content.length) {
const text = content.slice(lastIdx)
if (text.trim()) parts.push({ type: 'text', html: escapeHtml(text) })
}
// 没有代码块 → 返回整个为文本段
if (!parts.length) parts.push({ type: 'text', html: escapeHtml(content) })
return parts
}
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>

View 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>

View 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>

View 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>

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** 图例数据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 用圆心居中的 viewBoxprogress 用正常 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>

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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 textsegments 结构化富文本)---------- */
/** 是否有 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 每行的渲染 HTMLsegments 或纯文本) */
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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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' +
'元素类型 typetitle 标题 | 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/quotecontent 为文字list 用 \\n 分多行\n' +
' - statcontent 为大数字(如 "65%")style.label 为说明\n' +
' - cardcontent 第一行=标题、其余行=正文style.accent=顶部色条键style.icon=emoji 图标\n' +
' - shapestyle.shapeType=rect|circle|trianglestyle.fill=颜色键style.gradient=true 渐变style.opacity=0~1\n' +
' - chartcontent 为 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' +
' - tablecontent 为 Markdown 管道表格字符串(首行表头,用 | 分列换行分行style.header=true 首行加粗\n' +
' - codecontent 为代码文本保留符原样不要转义style.lang=语言如 js/python可选\n' +
' - formulacontent 为 LaTeX 公式字符串(不含 $$ 分隔符),如 "E = mc^2"、"\\sum_{i=1}^n x_i"\n' +
' - imagecontent 留空\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-deepkind=end 用 g-primarykind=content 数据页用 stat+chart。'
const SYS_THEME =
'你是配色设计师。根据主题关键词,推荐一套现代、专业的 6 色配色primary 主色、accent 强调色、bg 背景、panel 面板、text 正文、muted 次要文字)。\n\n' +
'要求:\n' +
'- 主色饱和度适中,不刺眼;强调色与主色形成对比但不冲突。\n' +
'- bg 与 panel 接近白色,差异轻微。\n' +
'- text 深于 primarymuted 为中性灰。\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
View File

@@ -0,0 +1,54 @@
/* =====================================================================
* inline.ts — Markdown 行内格式渲染
* 把 **加粗** / *斜体* / ~~删除线~~ / ==高亮== / `代码` / ~下标~ / ^上标^ 转 HTML
* 先转义 HTML再按顺序替换避免互相破坏
* ===================================================================== */
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/**
* 把 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/* ---------- 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
View 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
View 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
View 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 // 图像 URLdata: 或 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 }
})