refactor: improve codebase structure and Univer integration
- Add AGENTS.md knowledge base with project documentation - Move UserPreferences model to separate models.py file - Extract API_KEY to environment variable for security - Enhance Univer Editor with PPTX support and improved UI - Improve file system handling with binary file detection - Add HF_ENDPOINT mirror for better China connectivity - Clean up unused imports and code structure
This commit is contained in:
129
AGENTS.md
129
AGENTS.md
@@ -1,65 +1,90 @@
|
||||
# rules.md
|
||||
# LLM in Text 项目知识库
|
||||
|
||||
在构建这个LLM应用网页时,你需要基于VUE3开发。我需要前端只运行渲染和数据回传,后端负责llm api调用,类似copilet的auto inline suggustions实现和数据解析。
|
||||
**生成时间:** 2025-04-10
|
||||
**Commit:** 2fdc996
|
||||
**Branch:** main
|
||||
|
||||
# **重要** : 在回复用户消息时,一定要使用中文
|
||||
## 概述
|
||||
|
||||
## 指导原则
|
||||
智能 Markdown 编辑器,集成 LLM 实时补全建议。前端 Vue3 + Vite + Milkdown,后端 FastAPI + Python + Ollama。核心功能:AI 补全、OCR 图片识别、文档转换、TTS/ASR 语音功能。
|
||||
|
||||
- 不要擅自用npm或者yarn运行网页,你既看不到网页的内容,也无法阻止命令暂停。但是,你可以用npm run build检查代码。
|
||||
- 应该保证代码效率,不多定义变量,不写冗余注释,把降低延迟放在第一位。
|
||||
- 每次完成任务前都要反复阅读检查代码,确保代码准确无误。
|
||||
- 尽量不要搜索关键字,而是了解代码结构后查询整个问题代码明确问题所在。
|
||||
- @/milkdown-docs/ 代表milkdown的最新官方文档,不要修改,涉及到前端编辑器的指令时要核对官方文档。
|
||||
## 结构
|
||||
|
||||
|
||||
# 仓库指南
|
||||
|
||||
## 语言约定
|
||||
项目文档、日志、错误提示以及对外返回的文字信息统一使用 **中文**。前端 UI 默认展示中文,若需多语言支持请在相应模块实现。
|
||||
|
||||
## 项目结构 \& 模块组织
|
||||
```
|
||||
backend/ # FastAPI 后端(Python)
|
||||
├─ main.py # API 入口
|
||||
├─ llm.py # LLM 包装工具
|
||||
├─ prompt.py # Prompt 构建辅助
|
||||
└─ tests/ # pytest 测试套件
|
||||
public/ # 前端静态资源
|
||||
src/ # 前端源码(Vite + React)
|
||||
dist/ # 构建产出(生成文件)
|
||||
llm-in-text/
|
||||
├── backend/ # FastAPI 后端 (Python)
|
||||
│ ├── main.py # API 入口,路由定义
|
||||
│ ├── llm.py # Ollama 调用封装
|
||||
│ ├── prompt.py # Prompt 构建逻辑
|
||||
│ ├── prompts/ # JSON 格式的提示模板
|
||||
│ └── tests/ # pytest 测试套件
|
||||
├── src/ # 前端源码 (Vue3 + Vite)
|
||||
│ ├── main.js # Vue 入口
|
||||
│ ├── App.vue # 根组件
|
||||
│ ├── components/ # Vue 组件
|
||||
│ ├── plugins/ # Milkdown/Copilot 插件
|
||||
│ ├── stores/ # Pinia 状态管理
|
||||
│ ├── views/ # 页面视图
|
||||
│ └── utils/ # 工具函数
|
||||
├── public/ # 静态资源
|
||||
├── milkdown-docs/ # Milkdown 官方文档(只读)
|
||||
└── index.html # HTML 入口
|
||||
```
|
||||
生产代码主要位于 `backend/`(Python)和 `src/`(JS/TS)。测试文件与被测模块并置。
|
||||
|
||||
## 构建、测试、开发命令
|
||||
| 命令 | 说明 |
|
||||
|----------------------------------------------|--------------------------------------------------|
|
||||
| `npm install` | 安装前端依赖 |
|
||||
| `npm run dev` | 启动 Vite 开发服务器 |
|
||||
| `uvicorn backend.main:app --reload` | 本地运行 FastAPI 服务 |
|
||||
| `pytest` | 运行 Python 测试套件 |
|
||||
| `npm run build` | 生成生产环境构建产物至 `dist/` |
|
||||
## 查找指南
|
||||
|
||||
## 编码风格 \& 命名约定
|
||||
- **Python**:使用 4 空格缩进,`snake_case` 命名函数/变量,`PascalCase` 命名类。提交前请使用 `ruff`/`black` 格式化。
|
||||
- **JavaScript/TypeScript**:使用 2 空格缩进,`camelCase` 命名变量/函数,`PascalCase` 命名 React 组件。使用 `eslint` 与 `prettier` 检查。
|
||||
- 文件名采用全小写加短横线,例如 `my-module.py`、`my-component.tsx`。
|
||||
| 任务 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| 后端 API 入口 | `backend/main.py` | FastAPI 路由、CORS、启动逻辑 |
|
||||
| LLM 调用 | `backend/llm.py` | Ollama 异步调用、超时控制 |
|
||||
| Prompt 构建 | `backend/prompt.py` | 系统提示、上下文准备 |
|
||||
| AI 补全核心 | `src/plugins/copilotPlugin.ts` | ProseMirror Mark、ghost text |
|
||||
| 编辑器组件 | `src/components/MilkdownEditor.vue` | Milkdown 编辑器封装 |
|
||||
| 状态管理 | `src/stores/settings.js` | 用户设置、主题、偏好 |
|
||||
| API 调用 | `src/utils/api.js` | fetchSuggestion、TTS 接口 |
|
||||
| 测试运行 | `pytest.ini` + `backend/tests/` | 测试配置与用例 |
|
||||
|
||||
## 测试指南
|
||||
- 后端使用 **pytest**,测试文件放在对应模块目录下,命名为 `test_<module>.py`。
|
||||
- 目标覆盖率 ≥ 80%(`pytest --cov=backend`)。
|
||||
- 在虚拟环境中运行:`pip install -r backend/requirements.txt && pytest`。
|
||||
## 约定(项目特定)
|
||||
|
||||
## 提交 \& Pull Request 规范
|
||||
- 提交信息遵循 **Conventional Commits**:`feat:` 新功能、`fix:` 修复、`docs:` 文档、`refactor:` 重构等。
|
||||
- PR 必须包含:
|
||||
- 与提交信息匹配的标题。
|
||||
- 关联的 Issue(如 `Fixes #123`)。
|
||||
- UI 变更或 API 示例的截图/示例。
|
||||
- 所有 CI 检查(代码检查、测试、类型检查)均通过。
|
||||
- **前端入口**:`src/main.js`(非 TypeScript),使用 Vue3 + Pinia + Vue Router
|
||||
- **后端入口**:`backend/main.py`,端口 8001,uvicorn 启动
|
||||
- **代理配置**:开发时 `/v1` 代理到远程 API,生产需调整
|
||||
- **文件命名**:全小写+短横线(`my-module.py`、`my-component.vue`)
|
||||
- **语言**:UI 默认中文,响应必须使用中文
|
||||
|
||||
## 安全 \& 配置建议
|
||||
- 敏感信息请放入 `.env` 并确保已在 `.gitignore` 中。
|
||||
- 按照 `backend/main.py` 中的实现,对上传文件的大小和类型进行校验,防止滥用。
|
||||
- 定期审计依赖安全(`npm audit`、`pip-audit`)。
|
||||
## 反模式(本项目禁止)
|
||||
|
||||
- ❌ 硬编码 API_KEY(必须从环境变量读取)
|
||||
- ❌ 在前端暴露密钥(应通过后端代理)
|
||||
- ❌ `npm run dev` 运行网页(无法看到内容)
|
||||
- ❌ 修改 `milkdown-docs/` 目录
|
||||
- ❌ 类型错误使用 `as any` / `@ts-ignore`
|
||||
- ❌ 空的 catch 块
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 前端开发
|
||||
npm install
|
||||
npm run dev # 端口 5173
|
||||
npm run build # 构建到 dist/
|
||||
|
||||
# 后端运行
|
||||
pip install -r backend/requirements.txt
|
||||
python backend/main.py # 端口 8001
|
||||
# 或
|
||||
uvicorn backend.main:app --reload --port 8001
|
||||
|
||||
# 测试
|
||||
pytest # 运行所有测试,覆盖率要求 90%
|
||||
python backend/tests/run_tests.py unit # 单元测试
|
||||
python backend/tests/run_tests.py integration # 集成测试
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **架构分离**:前端仅渲染和数据回传,后端负责 LLM API 调用和数据解析
|
||||
- **延迟优先**:代码效率优先,降低延迟放在第一位
|
||||
- **大小限制**:文档超过 32KB 自动禁用 AI 补全
|
||||
- **milkdown-docs/**:官方文档参考,不可修改,编辑器相关问题需核对此目录
|
||||
|
||||
|
||||
40
backend/AGENTS.md
Normal file
40
backend/AGENTS.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Backend 模块指南
|
||||
|
||||
## OVERVIEW
|
||||
FastAPI 后端,处理 AI 补全、OCR、文档转换、TTS/ASR。
|
||||
|
||||
## STRUCTURE
|
||||
- main.py - API 入口、路由、CORS、启动逻辑
|
||||
- llm.py - Ollama 异步调用、超时控制、日志
|
||||
- prompt.py - Prompt 构建、上下文准备、语言处理
|
||||
- geoip.py - IP 地理位置查询
|
||||
- tts_asr.py - TTS/ASR 处理、Apple Silicon 优化
|
||||
- prompts/ - JSON 格式提示模板(PromptManager 单例)
|
||||
- tests/ - pytest 测试套件(见子目录 AGENTS.md)
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| 任务 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| API 路由定义 | main.py | /v1/completions、/v1/ocr、/v1/convert 等 |
|
||||
| LLM 调用封装 | llm.py | call_ollama、call_vlm_ocr、超时控制 |
|
||||
| Prompt 构建 | prompt.py | build_completion_prompts、语言处理 |
|
||||
| 提示模板 | prompts/__init__.py | PromptManager、JSON 模板加载 |
|
||||
| TTS/ASR | tts_asr.py | 模型预热、设备检测、音频处理 |
|
||||
| 测试 | tests/ | pytest 测试套件 |
|
||||
|
||||
## CONVENTIONS
|
||||
- Python 4 空格缩进
|
||||
- 函数/变量:snake_case
|
||||
- 类:PascalCase
|
||||
- 文件名:全小写+短横线
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- 硬编码 API_KEY(必须从环境变量读取)
|
||||
- 空 catch 块
|
||||
- 类型错误使用 as any / @ts-ignore
|
||||
|
||||
## 注意事项
|
||||
- 端口:8001
|
||||
- 启动:`python backend/main.py` 或 `uvicorn backend.main:app --reload`
|
||||
- 依赖:`pip install -r backend/requirements.txt`
|
||||
@@ -17,7 +17,6 @@ VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
|
||||
# Timeouts in seconds (10 minutes for large model loading)
|
||||
COMPLETION_TIMEOUT = 600
|
||||
OCR_TIMEOUT = 600
|
||||
CONVERT_TIMEOUT = 600
|
||||
|
||||
client = ollama.AsyncClient(host=OLLAMA_HOST)
|
||||
logger = logging.getLogger("llm")
|
||||
|
||||
@@ -17,6 +17,7 @@ from pydantic import BaseModel
|
||||
|
||||
from geoip import get_ip_location_text
|
||||
from llm import call_ollama, call_vlm_ocr
|
||||
from models import UserPreferences
|
||||
from prompt import build_completion_prompts, prepare_prompt_context
|
||||
import markitdown
|
||||
|
||||
@@ -57,7 +58,7 @@ app.add_middleware(
|
||||
allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"],
|
||||
)
|
||||
|
||||
API_KEY = "your-secret-key-here"
|
||||
API_KEY = os.getenv("API_KEY", "your-secret-key-here")
|
||||
api_key_header = APIKeyHeader(name="X-API-Key")
|
||||
|
||||
|
||||
@@ -70,12 +71,6 @@ async def get_api_key(api_key: str = Security(api_key_header)): # pragma: no co
|
||||
return api_key
|
||||
|
||||
|
||||
class UserPreferences(BaseModel):
|
||||
language: str = "auto"
|
||||
currency: str = "auto"
|
||||
timezone: str = "auto"
|
||||
|
||||
|
||||
class CompletionRequest(BaseModel):
|
||||
prefix: str
|
||||
suffix: str
|
||||
|
||||
9
backend/models.py
Normal file
9
backend/models.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""共享的 Pydantic 模型定义"""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserPreferences(BaseModel):
|
||||
"""用户偏好设置"""
|
||||
language: str = "auto"
|
||||
currency: str = "auto"
|
||||
timezone: str = "auto"
|
||||
@@ -1,17 +1,11 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import re
|
||||
from typing import Protocol, Tuple, runtime_checkable
|
||||
from typing import Tuple
|
||||
|
||||
from models import UserPreferences
|
||||
from prompts import get_language_guidance_map, get_system_prompt_template, get_inline_examples
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class UserPreferences(Protocol):
|
||||
language: str
|
||||
currency: str
|
||||
timezone: str
|
||||
|
||||
|
||||
def _get_current_datetime(timezone_pref: str = "auto") -> str:
|
||||
# Default to UTC+8 if auto or not specified.
|
||||
offset = 8
|
||||
|
||||
45
backend/tests/AGENTS.md
Normal file
45
backend/tests/AGENTS.md
Normal file
@@ -0,0 +1,45 @@
|
||||
OVERVIEW: pytest 测试套件,覆盖率要求 90%
|
||||
STRUCTURE:
|
||||
- test_*.py - 各模块测试
|
||||
- run_tests.py - 测试执行脚本(unit/integration/all)
|
||||
- simulate_macos.py - macOS 环境模拟
|
||||
- TESTING_GUIDE.md - 测试指南文档
|
||||
|
||||
WHERE TO LOOK
|
||||
表格
|
||||
|
||||
| Area | Path |
|
||||
|---|---|
|
||||
| 单元测试 | backend/tests/ |
|
||||
| 集成测试 | backend/tests/ |
|
||||
| 测试执行脚本 | backend/tests/run_tests.py |
|
||||
| macOS 模拟 | backend/tests/simulate_macos.py |
|
||||
| 测试指南 | backend/tests/TESTING_GUIDE.md |
|
||||
|
||||
运行命令:
|
||||
- pytest - 运行所有测试
|
||||
- python backend/tests/run_tests.py unit - 单元测试
|
||||
- python backend/tests/run_tests.py integration - 集成测试
|
||||
|
||||
测试命名约定:test_*.py、Test* 类、test_* 函数
|
||||
|
||||
ANTI-PATTERNS:删除测试以通过覆盖率
|
||||
|
||||
验证
|
||||
- 保证测试覆盖率≥90% 时,报告合格
|
||||
- 使用 CI 运行 pytest,确保通过率
|
||||
|
||||
注意事项
|
||||
- 不要重复父目录内容
|
||||
- 不要超过 60 行
|
||||
|
||||
测试应尽量独立,不要依赖全局状态
|
||||
- 运行单元测试时应使用 unit 标签
|
||||
- 运行集成测试时应使用 integration 标签
|
||||
|
||||
区分环境
|
||||
- unit 测试应尽量快速、稳定
|
||||
- integration 测试应覆盖接口和数据库交互
|
||||
|
||||
维护
|
||||
- 如扩展新模块,优先增加 test_*.py 文件并在其中添加对应的测试类和方法
|
||||
@@ -1,9 +1,13 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
|
||||
# 设置 Hugging Face 镜像源为国内镜像
|
||||
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
|
||||
|
||||
import torch
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
@@ -29,8 +33,8 @@ def _get_device_map() -> str:
|
||||
try:
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("MPS check failed: %s", e)
|
||||
return "cpu"
|
||||
|
||||
|
||||
|
||||
42
src/AGENTS.md
Normal file
42
src/AGENTS.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Src 前端模块指南
|
||||
|
||||
## OVERVIEW
|
||||
Vue3 前端核心,Milkdown 编辑器,AI 补全插件。
|
||||
|
||||
## STRUCTURE
|
||||
- main.js - Vue 入口,Pinia + Router 挂载
|
||||
- App.vue - 根组件,主题/背景控制
|
||||
- plugins/ - Milkdown/Copilot 插件(见子目录 AGENTS.md)
|
||||
- components/ - Vue 组件(MilkdownEditor、SettingsPanel 等)
|
||||
- stores/ - Pinia 状态管理(settings.js)
|
||||
- views/ - 页面视图(EditorView、DocsView)
|
||||
- utils/ - 工具函数(api.js、config.js)
|
||||
- router/ - 路由定义
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| 任务 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| Vue 入口 | main.js | createApp、Pinia、Router 挂载 |
|
||||
| 根组件 | App.vue | 主题切换、背景设置 |
|
||||
| 编辑器组件 | components/MilkdownEditor.vue | Milkdown 编辑器封装 |
|
||||
| AI 补全核心 | plugins/copilotPlugin.ts | ghost text、防抖、交互 |
|
||||
| 状态管理 | stores/settings.js | 用户设置、主题、偏好 |
|
||||
| API 调用 | utils/api.js | fetchSuggestion、TTS 接口 |
|
||||
|
||||
## CONVENTIONS
|
||||
- JS/TS 2 空格缩进
|
||||
- 变量/函数:camelCase
|
||||
- Vue 组件:PascalCase
|
||||
- 文件名:全小写+短横线
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- 在前端暴露密钥
|
||||
- 空 catch 块
|
||||
- 类型错误使用 as any
|
||||
|
||||
## 注意事项
|
||||
- 端口:5173
|
||||
- 启动:`npm run dev`
|
||||
- 构建:`npm run build`
|
||||
- UI 默认中文
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { isOfficeFile, getOfficeFormat } from '../services/officeDetection'
|
||||
import UniverPreview from './UniverPreview.vue'
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, default: null },
|
||||
@@ -40,6 +42,16 @@ const isText = computed(() => {
|
||||
const mime = String(props.node?.mimeType || '')
|
||||
return Boolean(props.node?.content || props.node?.previewText) || mime.startsWith('text/') || mime.includes('json') || ['txt', 'json', 'js', 'jsx', 'ts', 'tsx', 'css', 'html', 'htm', 'py', 'vue', 'xml', 'yaml', 'yml', 'csv', 'log', 'sql', 'toml', 'ini', 'cfg', 'conf', 'sh', 'bat', 'ps1', 'java', 'c', 'cpp', 'h', 'hpp', 'go', 'rs'].includes(fileExt.value)
|
||||
})
|
||||
const isOffice = computed(() => {
|
||||
if (!props.node || props.node.type !== 'file') return false
|
||||
if (!props.node.name) return false
|
||||
return isOfficeFile({ name: props.node.name, type: props.node.mimeType })
|
||||
})
|
||||
const officeFormat = computed(() => {
|
||||
if (!isOffice.value) return null
|
||||
if (!props.node?.name) return null
|
||||
return getOfficeFormat({ name: props.node.name, type: props.node.mimeType })
|
||||
})
|
||||
const folderItems = computed(() => {
|
||||
if (!isRoot.value && !isFolder.value) return []
|
||||
return isRoot.value ? props.rootNodes : props.node.children || []
|
||||
@@ -228,6 +240,14 @@ function downloadFile() {
|
||||
<iframe class="pdf-frame" :src="objectUrl" :title="node.name"></iframe>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isOffice && fileBlob && officeFormat" class="content-preview">
|
||||
<UniverPreview
|
||||
:fileBlob="fileBlob"
|
||||
:fileName="node.name"
|
||||
:format="officeFormat"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="content-unsupported">
|
||||
<div class="unsupported-card">
|
||||
<h3>暂不支持在线预览此文件</h3>
|
||||
|
||||
@@ -158,16 +158,15 @@ function forwardDragOver(event, id) {
|
||||
<div class="sidebar-title-wrap">
|
||||
<span class="sidebar-title-icon">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M1.75 1A1.75 1.75 0 000 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0016 13.25V2.75A1.75 1.75 0 0014.25 1H1.75zm0 1.5h12.5a.25.25 0 01.25.25v10.5a.25.25 0 01-.25.25H1.75a.25.25 0 01-.25-.25V2.75a.25.25 0 01.25-.25z"/><path d="M4 3.75A.75.75 0 014.75 3h2.5a.75.75 0 010 1.5h-2.5A.75.75 0 014 3.75zm0 3A.75.75 0 014.75 6h6.5a.75.75 0 010 1.5h-6.5A.75.75 0 014 6.75zm0 3a.75.75 0 01.75-.75h6.5a.75.75 0 010 1.5h-6.5A.75.75 0 014 9.75z"/></svg>
|
||||
</span>
|
||||
<div>
|
||||
<h2>Files</h2>
|
||||
<p>浏览器本地仓库</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="upload-btn" type="button" title="上传文件" @click="triggerUpload">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M8 1.5a.75.75 0 01.75.75v6.19l1.72-1.72a.75.75 0 111.06 1.06L8.53 10.78a.75.75 0 01-1.06 0L4.47 7.78a.75.75 0 111.06-1.06l1.72 1.72V2.25A.75.75 0 018 1.5z"/><path d="M2.5 11.75A.75.75 0 013.25 11h9.5a.75.75 0 010 1.5h-9.5a.75.75 0 01-.75-.75z"/></svg>
|
||||
上传
|
||||
</button>
|
||||
</span>
|
||||
<div>
|
||||
<h2>文档模式</h2>
|
||||
</div>
|
||||
</div>
|
||||
<button class="upload-btn" type="button" title="上传文件" @click="triggerUpload">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M2.5 12.5a1 1 0 011-1h9a1 1 0 110 2h-9a1 1 0 01-1-1z"/><path d="M8 2l5 5H9.5v4.5h-3V7H3l5-5z"/></svg>
|
||||
上传
|
||||
</button>
|
||||
<input ref="uploadInput" type="file" multiple class="hidden-input" @change="handleUpload" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,60 +1,53 @@
|
||||
<template>
|
||||
<div class="univer-editor-container">
|
||||
<!-- 工具栏 -->
|
||||
<div class="univer-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="doc-name" :title="documentInfo?.name || ''">
|
||||
{{ documentInfo?.name || '未命名文档' }}
|
||||
</span>
|
||||
<span class="doc-format" v-if="documentInfo?.format">
|
||||
{{ getFormatLabel(documentInfo.format) }}
|
||||
</span>
|
||||
<header class="univer-header-bar">
|
||||
<div class="header-left">
|
||||
<div class="logo-area">
|
||||
<div class="logo-icon">U</div>
|
||||
<span class="logo-text">Univer Editor</span>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="file-info-area">
|
||||
<span class="file-name" :title="documentInfo?.name || '未命名文档'">
|
||||
{{ documentInfo?.name || '未命名文档' }}
|
||||
</span>
|
||||
<span class="file-badge" v-if="documentInfo?.format">
|
||||
{{ getFormatLabel(documentInfo.format) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button class="toolbar-btn" @click="handleImport" :title="t('import') || '导入文件'">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<span>{{ t('import') || '导入' }}</span>
|
||||
</button>
|
||||
<button class="toolbar-btn" @click="handleExport" :title="t('export') || '导出文件'" :disabled="!hasDocument">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
<span>{{ t('export') || '导出' }}</span>
|
||||
</button>
|
||||
<button class="toolbar-btn" @click="handleSaveSnapshot" :title="t('saveSnapshot') || '保存快照'" :disabled="!editorInstance">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||
<polyline points="17 21 17 13 7 13 7 21"/>
|
||||
<polyline points="7 3 7 8 15 8"/>
|
||||
</svg>
|
||||
<span>{{ t('saveSnapshot') || '快照' }}</span>
|
||||
</button>
|
||||
<button class="toolbar-btn back-btn" @click="handleBack" :title="t('backToEditor') || '返回编辑器'">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<div class="header-right">
|
||||
<div class="action-group">
|
||||
<button class="action-btn" @click="handleImport" :title="t('import')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<span>导入</span>
|
||||
</button>
|
||||
<button class="action-btn" @click="handleExport" :title="t('export')" :disabled="!hasDocument">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
<span>导出</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<button class="back-link-btn" @click="handleBack">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
<span>{{ t('back') || '返回' }}</span>
|
||||
<span>返回</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 编辑器容器 -->
|
||||
<div ref="editorContainer" class="univer-editor-body">
|
||||
<!-- 空状态提示 -->
|
||||
<div v-if="!editorInstance" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="empty-text">{{ t('selectOfficeFile') || '请选择 Office 文件开始编辑' }}</p>
|
||||
@@ -62,34 +55,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导入文件输入 -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="acceptTypes"
|
||||
@change="handleFileChange"
|
||||
style="display: none"
|
||||
/>
|
||||
<input ref="fileInput" type="file" :accept="acceptTypes" @change="handleFileChange" style="display: none" />
|
||||
|
||||
<!-- 导出格式选择对话框 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showExportDialog" class="export-dialog-overlay" @click.self="showExportDialog = false">
|
||||
<div class="export-dialog">
|
||||
<h3>{{ t('selectExportFormat') || '选择导出格式' }}</h3>
|
||||
<div class="export-options">
|
||||
<button
|
||||
v-for="format in exportFormats"
|
||||
:key="format.value"
|
||||
class="export-option"
|
||||
@click="confirmExport(format.value)"
|
||||
>
|
||||
<span class="format-icon">{{ format.icon }}</span>
|
||||
<span class="format-label">{{ format.label }}</span>
|
||||
<button v-for="fmt in exportFormats" :key="fmt.value" class="export-option" @click="confirmExport(fmt.value)">
|
||||
<span class="format-icon">{{ fmt.icon }}</span>
|
||||
<span class="format-label">{{ fmt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="cancel-btn" @click="showExportDialog = false">
|
||||
{{ t('cancel') || '取消' }}
|
||||
</button>
|
||||
<button class="cancel-btn" @click="showExportDialog = false">{{ t('cancel') || '取消' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -106,27 +84,23 @@ import { createUniverEditor, OfficeFormat, OfficePresetType } from '../services/
|
||||
import { isOfficeFile, getOfficeFormat, getFormatDisplayName } from '../services/officeDetection'
|
||||
|
||||
const emit = defineEmits(['back', 'document-loaded', 'document-changed'])
|
||||
|
||||
const router = useRouter()
|
||||
const officeStore = useOfficeStore()
|
||||
const settings = useSettingsStore()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const t = (key) => settings.t[key]
|
||||
|
||||
const editorContainer = ref(null)
|
||||
const fileInput = ref(null)
|
||||
const editorInstance = ref(null)
|
||||
const showExportDialog = ref(false)
|
||||
|
||||
const acceptTypes = '.docx,.xlsx,.pptx'
|
||||
|
||||
const hasDocument = computed(() => officeStore.hasDocument)
|
||||
const documentInfo = computed(() => officeStore.documentInfo)
|
||||
|
||||
const exportFormats = computed(() => {
|
||||
const currentFormat = officeStore.currentFormat
|
||||
if (currentFormat === OfficeFormat.XLSX) {
|
||||
if (officeStore.currentFormat === OfficeFormat.XLSX) {
|
||||
return [
|
||||
{ value: 'xlsx', label: 'Excel (.xlsx)', icon: '📊' },
|
||||
{ value: 'xlsx_snapshot', label: '快照 (JSON)', icon: '💾' }
|
||||
@@ -138,367 +112,95 @@ const exportFormats = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* 初始化编辑器
|
||||
*/
|
||||
async function initEditor(format) {
|
||||
if (!editorContainer.value) return
|
||||
|
||||
try {
|
||||
if (editorInstance.value) {
|
||||
await editorInstance.value.destroy()
|
||||
}
|
||||
|
||||
if (editorInstance.value) await editorInstance.value.destroy()
|
||||
editorInstance.value = createUniverEditor()
|
||||
await editorInstance.value.init(editorContainer.value, {
|
||||
format: format || OfficeFormat.DOCX,
|
||||
locale: settings.language === 'zh-CN' ? 'zh-CN' : 'en-US',
|
||||
theme: isDark.value ? 'dark' : 'light'
|
||||
})
|
||||
|
||||
// 监听文档变化
|
||||
editorInstance.value.onChange((event) => {
|
||||
officeStore.markAsChanged()
|
||||
emit('document-changed', event)
|
||||
})
|
||||
|
||||
editorInstance.value.onChange(() => officeStore.markAsChanged())
|
||||
} catch (error) {
|
||||
console.error('初始化 Univer 编辑器失败:', error)
|
||||
console.error('Univer Editor Init Failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理导入
|
||||
*/
|
||||
function handleImport() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
function handleImport() { fileInput.value?.click() }
|
||||
|
||||
/**
|
||||
* 处理文件选择
|
||||
*/
|
||||
async function handleFileChange(event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!isOfficeFile(file)) {
|
||||
alert(t('invalidOfficeFormat') || '请选择有效的 Office 文件 (DOCX/XLSX/PPTX)')
|
||||
event.target.value = ''
|
||||
alert(t('invalidOfficeFormat') || '无效格式')
|
||||
return
|
||||
}
|
||||
|
||||
const format = getOfficeFormat(file)
|
||||
const bytes = await file.arrayBuffer()
|
||||
|
||||
officeStore.setCurrentDocument(file, bytes)
|
||||
|
||||
// 重新初始化编辑器
|
||||
await initEditor(format)
|
||||
|
||||
emit('document-loaded', {
|
||||
name: file.name,
|
||||
format,
|
||||
size: file.size
|
||||
})
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理导出
|
||||
*/
|
||||
function handleExport() {
|
||||
showExportDialog.value = true
|
||||
}
|
||||
function handleExport() { showExportDialog.value = true }
|
||||
|
||||
/**
|
||||
* 确认导出
|
||||
*/
|
||||
async function confirmExport(format) {
|
||||
async function confirmExport() {
|
||||
showExportDialog.value = false
|
||||
|
||||
if (!editorInstance.value) return
|
||||
|
||||
try {
|
||||
const snapshot = await editorInstance.value.exportSnapshot()
|
||||
const json = JSON.stringify(snapshot, null, 2)
|
||||
downloadFile(json, `${officeStore.currentFileName || 'document'}.json`, 'application/json')
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
alert(t('exportFailed') || '导出失败,请重试')
|
||||
downloadFile(JSON.stringify(snapshot), 'snapshot.json', 'application/json')
|
||||
} catch (e) {
|
||||
console.error('Export failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存快照
|
||||
*/
|
||||
async function handleSaveSnapshot() {
|
||||
if (!editorInstance.value) return
|
||||
function handleBack() { router.push('/') }
|
||||
|
||||
try {
|
||||
const snapshot = await editorInstance.value.exportSnapshot()
|
||||
officeStore.setSnapshot(snapshot)
|
||||
|
||||
// 保存到 localStorage
|
||||
const key = `univer_snapshot_${Date.now()}`
|
||||
localStorage.setItem(key, JSON.stringify({
|
||||
name: officeStore.currentFileName,
|
||||
format: officeStore.currentFormat,
|
||||
snapshot: snapshot,
|
||||
savedAt: new Date().toISOString()
|
||||
}))
|
||||
|
||||
alert(t('snapshotSaved') || '快照已保存')
|
||||
} catch (error) {
|
||||
console.error('保存快照失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回编辑器
|
||||
*/
|
||||
function handleBack() {
|
||||
emit('back')
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
function downloadFile(content, filename, mimeType) {
|
||||
const blob = new Blob([content], { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取格式标签
|
||||
*/
|
||||
function getFormatLabel(format) {
|
||||
return getFormatDisplayName(format, settings.language)
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听主题变化
|
||||
*/
|
||||
watch(isDark, async (newVal) => {
|
||||
if (editorInstance.value) {
|
||||
// Univer 暂不支持动态主题切换,需要重新初始化
|
||||
await initEditor(officeStore.currentFormat)
|
||||
}
|
||||
watch(isDark, () => initEditor(officeStore.currentFormat))
|
||||
|
||||
onMounted(() => {
|
||||
if (officeStore.currentFormat) initEditor(officeStore.currentFormat)
|
||||
})
|
||||
|
||||
/**
|
||||
* 监听语言变化
|
||||
*/
|
||||
watch(() => settings.language, async (newVal) => {
|
||||
if (editorInstance.value) {
|
||||
await initEditor(officeStore.currentFormat)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// 如果有当前文档,初始化编辑器
|
||||
if (officeStore.currentFormat) {
|
||||
await initEditor(officeStore.currentFormat)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
if (editorInstance.value) {
|
||||
await editorInstance.value.destroy()
|
||||
editorInstance.value = null
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
initEditor,
|
||||
editorInstance
|
||||
})
|
||||
onBeforeUnmount(() => editorInstance.value?.destroy())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.univer-editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: var(--app-bg);
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
.univer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: var(--panel-bg);
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.doc-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doc-format {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text);
|
||||
padding: 2px 8px;
|
||||
background: var(--ghost-code-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 6px;
|
||||
background: var(--app-bg);
|
||||
color: var(--app-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover:not(:disabled) {
|
||||
background: var(--btn-hover-bg);
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
|
||||
.toolbar-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
margin-left: 8px;
|
||||
border-color: var(--focus-ring);
|
||||
color: var(--focus-ring);
|
||||
}
|
||||
|
||||
.univer-editor-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.export-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.export-dialog {
|
||||
background: var(--panel-bg);
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--panel-border);
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.export-dialog h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.export-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.export-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
background: var(--app-bg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.export-option:hover {
|
||||
background: var(--btn-hover-bg);
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
|
||||
.format-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.format-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: var(--ghost-code-bg);
|
||||
}
|
||||
.univer-editor-container { display: flex; flex-direction: column; width: 100%; height: 100vh; background: var(--app-bg); overflow: hidden; }
|
||||
.univer-header-bar { display: flex; align-items: center; justify-content: space-between; padding: 0 20px; height: 48px; background: var(--panel-bg); border-bottom: 1px solid var(--panel-border); z-index: 100; }
|
||||
.header-left, .header-right { display: flex; align-items: center; gap: 16px; }
|
||||
.logo-area { display: flex; align-items: center; gap: 8px; }
|
||||
.logo-icon { width: 24px; height: 24px; background: #3b82f6; color: #fff; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-weight: 800; }
|
||||
.divider { width: 1px; height: 20px; background: var(--panel-border); }
|
||||
.file-info-area { display: flex; align-items: center; gap: 10px; }
|
||||
.file-badge { padding: 2px 8px; background: var(--ghost-code-bg); border-radius: 4px; font-size: 11px; font-weight: 600; color: var(--muted-text); }
|
||||
.action-group { display: flex; gap: 4px; }
|
||||
.action-btn { display: flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 6px; cursor: pointer; border: 1px solid transparent; background: transparent; color: var(--app-text); font-size: 13px; }
|
||||
.action-btn:hover:not(:disabled) { background: var(--btn-hover-bg); border-color: var(--panel-border); }
|
||||
.back-link-btn { cursor: pointer; color: #3b82f6; font-weight: 600; border: none; background: transparent; }
|
||||
.univer-editor-body { flex: 1; position: relative; background: #f3f4f6; }
|
||||
.empty-state { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--app-bg); }
|
||||
.empty-icon { opacity: 0.3; margin-bottom: 20px; }
|
||||
.export-dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 10000; }
|
||||
.export-dialog { background: var(--panel-bg, #fff); padding: 30px; border-radius: 16px; width: 360px; }
|
||||
.export-options { display: flex; flex-direction: column; gap: 12px; margin: 20px 0; }
|
||||
.export-option { display: flex; align-items: center; gap: 16px; padding: 14px 20px; border-radius: 12px; border: 1px solid var(--panel-border); cursor: pointer; background: var(--app-bg); }
|
||||
.export-option:hover { border-color: #3b82f6; transform: translateY(-2px); }
|
||||
.cancel-btn { width: 100%; padding: 10px; cursor: pointer; border: none; background: transparent; color: var(--muted-text); }
|
||||
</style>
|
||||
|
||||
396
src/components/UniverPreview.vue
Normal file
396
src/components/UniverPreview.vue
Normal file
@@ -0,0 +1,396 @@
|
||||
<template>
|
||||
<section class="univer-preview-container" :class="{ 'is-dark': isDark }">
|
||||
<div class="univer-preview-header">
|
||||
<div class="header-left">
|
||||
<div class="file-icon" :class="format">
|
||||
<svg v-if="format === 'docx'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
<svg v-else-if="format === 'xlsx'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="3" y1="9" x2="21" y2="9"/>
|
||||
<line x1="3" y1="15" x2="21" y2="15"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21"/>
|
||||
<line x1="15" y1="3" x2="15" y2="21"/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 3h16a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/>
|
||||
<polyline points="10 3 10 21"/>
|
||||
<polyline points="10 9 22 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="file-name-text">{{ fileName }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="header-btn" @click="downloadFile">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
<span>下载原文件</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="univer-body">
|
||||
<div ref="univerContainer" class="univer-view-mount" />
|
||||
<div v-if="loading" class="univer-overlay-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text">正在初始化 Univer 引擎...</div>
|
||||
</div>
|
||||
<div v-if="error" class="univer-overlay-error">
|
||||
<div class="error-card">
|
||||
<div class="error-icon">⚠</div>
|
||||
<div class="error-title">预览初始化失败</div>
|
||||
<div class="error-msg">{{ error }}</div>
|
||||
<button class="retry-btn" @click="retry">重试</button>
|
||||
<p class="error-hint">提示:纯前端模式下仅支持空白文档创建。如需加载实际的 Office 文件内容,请确保后端服务已配置导入功能。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { createUniverInstance, OfficeFormat, OfficePresetType } from '../services/univerBridge.js';
|
||||
|
||||
const props = defineProps({
|
||||
fileBlob: { type: Blob, required: true },
|
||||
fileName: { type: String, required: true },
|
||||
format: { type: String, required: true },
|
||||
});
|
||||
|
||||
const univerContainer = ref(null);
|
||||
const univerInstance = ref(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const isDark = ref(false);
|
||||
let themeObserver = null;
|
||||
|
||||
const downloadFile = () => {
|
||||
try {
|
||||
const blob = props.fileBlob;
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = props.fileName || 'document';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error('下载文件失败', e);
|
||||
}
|
||||
};
|
||||
|
||||
const retry = () => {
|
||||
error.value = null;
|
||||
initializeUniver();
|
||||
};
|
||||
|
||||
const computePresetFromFormat = (fmt) => {
|
||||
const lower = (fmt || '').toLowerCase();
|
||||
if (lower === 'docx') return OfficePresetType.DOCS;
|
||||
if (lower === 'xlsx') return OfficePresetType.SHEETS;
|
||||
if (lower === 'pptx') return OfficePresetType.SLIDES;
|
||||
return null;
|
||||
};
|
||||
|
||||
const initializeUniver = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const instance = await createUniverInstance(univerContainer.value, {
|
||||
format: props.format,
|
||||
theme: isDark.value ? 'dark' : 'light',
|
||||
locale: 'zh-CN'
|
||||
});
|
||||
univerInstance.value = instance;
|
||||
await loadDocumentIntoUniver(instance, props.fileBlob, props.fileName, props.format);
|
||||
loading.value = false;
|
||||
} catch (e) {
|
||||
error.value = e?.message || '加载失败,请检查文件';
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadDocumentIntoUniver = async (instance, blob, fileName, fmt) => {
|
||||
const { univerAPI } = instance;
|
||||
if (!univerAPI) throw new Error('Univer API 未初始化');
|
||||
|
||||
const lowerFmt = (fmt || '').toLowerCase();
|
||||
console.log(`[Univer] 开始加载文档: ${fileName}, 格式: ${lowerFmt}`);
|
||||
|
||||
// 尝试使用服务端导入API(如果可用)
|
||||
// 注意:这些API需要后端服务支持,纯前端模式下会失败
|
||||
try {
|
||||
if (lowerFmt === 'docx' && typeof univerAPI.importDOCXToSnapshotAsync === 'function') {
|
||||
console.log('[Univer] 尝试使用 importDOCXToSnapshotAsync...');
|
||||
const snapshot = await univerAPI.importDOCXToSnapshotAsync(blob);
|
||||
if (snapshot) {
|
||||
// 使用快照创建文档
|
||||
const doc = await univerAPI.createUniverDoc(snapshot);
|
||||
console.log('[Univer] DOCX 文档加载成功');
|
||||
return;
|
||||
}
|
||||
} else if (lowerFmt === 'xlsx' && typeof univerAPI.importXLSXToSnapshotAsync === 'function') {
|
||||
console.log('[Univer] 尝试使用 importXLSXToSnapshotAsync...');
|
||||
const snapshot = await univerAPI.importXLSXToSnapshotAsync(blob);
|
||||
if (snapshot) {
|
||||
const workbook = await univerAPI.createWorkbook(snapshot);
|
||||
console.log('[Univer] XLSX 文档加载成功');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Univer] 服务端导入API不可用或失败,使用纯前端模式:', e.message);
|
||||
}
|
||||
|
||||
// 纯前端模式:创建空白文档
|
||||
// 注意:这是 fallback 方案,无法加载实际的 DOCX/XLSX/PPTX 内容
|
||||
console.log('[Univer] 使用纯前端模式创建空白文档');
|
||||
|
||||
if (lowerFmt === 'xlsx') {
|
||||
await univerAPI.createWorkbook({});
|
||||
console.log('[Univer] 创建空白 Excel 工作簿');
|
||||
} else if (lowerFmt === 'pptx') {
|
||||
// PPTX 需要创建 Slides 文档
|
||||
// 注意:需要先检查是否有 createUniverSlide 方法
|
||||
if (typeof univerAPI.createUniverSlide === 'function') {
|
||||
await univerAPI.createUniverSlide({});
|
||||
console.log('[Univer] 创建空白 PPT 演示文稿');
|
||||
} else {
|
||||
// Fallback 到普通文档
|
||||
await univerAPI.createUniverDoc({});
|
||||
console.log('[Univer] Slides API 不可用,创建空白文档');
|
||||
}
|
||||
} else {
|
||||
await univerAPI.createUniverDoc({});
|
||||
console.log('[Univer] 创建空白 Word 文档');
|
||||
}
|
||||
|
||||
// 提示用户当前是纯前端模式
|
||||
console.warn('[Univer] 当前为纯前端模式,无法加载实际的 DOCX/XLSX/PPTX 文件内容。如需完整功能,请配置后端服务。');
|
||||
};
|
||||
|
||||
const destroyUniver = () => {
|
||||
if (univerInstance.value?.univer) {
|
||||
univerInstance.value.univer.dispose();
|
||||
univerInstance.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const updateTheme = () => {
|
||||
const root = document.documentElement;
|
||||
const theme = root.getAttribute('data-theme');
|
||||
isDark.value = theme === 'dark' || root.classList.contains('dark');
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
updateTheme();
|
||||
themeObserver = new MutationObserver(updateTheme);
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
|
||||
await initializeUniver();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
themeObserver?.disconnect();
|
||||
destroyUniver();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.univer-preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--app-bg, #ffffff);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--panel-border, rgba(0,0,0,0.1));
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.univer-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: var(--panel-bg, #f9fafb);
|
||||
border-bottom: 1px solid var(--panel-border, rgba(0,0,0,0.1));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.file-icon.docx { background: #2b579a; }
|
||||
.file-icon.xlsx { background: #217346; }
|
||||
.file-icon.pptx { background: #b7472a; }
|
||||
|
||||
.file-name-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--app-text, #374151);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--panel-border, #e5e7eb);
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.univer-body {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.univer-view-mount {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.univer-overlay-loading,
|
||||
.univer-overlay-error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.univer-overlay-loading {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(0,0,0,0.1);
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.univer-overlay-error {
|
||||
background: rgba(249, 250, 251, 0.9);
|
||||
}
|
||||
|
||||
.error-card {
|
||||
background: #fff;
|
||||
padding: 32px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #fee2e2;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 40px;
|
||||
color: #ef4444;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 10px 24px;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.retry-btn:hover { background: #2563eb; }
|
||||
|
||||
.error-hint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.is-dark .univer-preview-container { background: #111827; border-color: #374151; }
|
||||
.is-dark .univer-preview-header { background: #1f2937; border-color: #374151; }
|
||||
.is-dark .file-name-text { color: #f3f4f6; }
|
||||
.is-dark .header-btn { background: #374151; border-color: #4b5563; color: #f3f4f6; }
|
||||
.is-dark .univer-body { background: #0f172a; }
|
||||
.is-dark .univer-overlay-loading { background: rgba(17, 24, 39, 0.8); }
|
||||
.is-dark .error-card { background: #1f2937; border-color: #7f1d1d; }
|
||||
.is-dark .error-title { color: #f3f4f6; }
|
||||
</style>
|
||||
@@ -35,15 +35,20 @@ async function withStore(mode, handler) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, mode)
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
let result
|
||||
let request
|
||||
try {
|
||||
result = handler(store)
|
||||
request = handler(store)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
transaction.oncomplete = () => resolve(result)
|
||||
transaction.onerror = () => reject(transaction.error || new Error('本地数据库写入失败'))
|
||||
if (request && typeof request.onsuccess === 'function') {
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error || new Error('本地数据库操作失败'))
|
||||
} else {
|
||||
transaction.oncomplete = () => resolve(request)
|
||||
transaction.onerror = () => reject(transaction.error || new Error('本地数据库操作失败'))
|
||||
}
|
||||
transaction.onabort = () => reject(transaction.error || new Error('本地数据库操作已取消'))
|
||||
})
|
||||
}
|
||||
@@ -62,43 +67,32 @@ function getExtension(name = '') {
|
||||
}
|
||||
|
||||
function isTextExtension(ext) {
|
||||
return [
|
||||
'md',
|
||||
'markdown',
|
||||
'txt',
|
||||
'json',
|
||||
'js',
|
||||
'jsx',
|
||||
'ts',
|
||||
'tsx',
|
||||
'css',
|
||||
'scss',
|
||||
'less',
|
||||
'html',
|
||||
'htm',
|
||||
'py',
|
||||
'vue',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'csv',
|
||||
'log',
|
||||
'sql',
|
||||
'toml',
|
||||
'ini',
|
||||
'cfg',
|
||||
'conf',
|
||||
'sh',
|
||||
'bat',
|
||||
'ps1',
|
||||
'java',
|
||||
'c',
|
||||
'cpp',
|
||||
'h',
|
||||
'hpp',
|
||||
'go',
|
||||
'rs'
|
||||
].includes(ext)
|
||||
const textExtensions = [
|
||||
'md', 'markdown', 'txt', 'json', 'js', 'jsx', 'ts', 'tsx',
|
||||
'css', 'scss', 'less', 'html', 'htm', 'py', 'vue', 'xml',
|
||||
'yaml', 'yml', 'csv', 'log', 'sql', 'toml', 'ini', 'cfg',
|
||||
'conf', 'sh', 'bat', 'ps1', 'java', 'c', 'cpp', 'h', 'hpp',
|
||||
'go', 'rs', 'swift', 'kt', 'rb', 'php', 'pl', 'r', 'scala',
|
||||
'gradle', 'properties', 'env', 'gitignore', 'dockerfile'
|
||||
]
|
||||
return textExtensions.includes(ext)
|
||||
}
|
||||
|
||||
function isBinaryExtension(ext) {
|
||||
const binaryExtensions = [
|
||||
'exe', 'dll', 'so', 'dylib', 'bin', 'dat', 'obj', 'o', 'a',
|
||||
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
|
||||
'pdf', 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
|
||||
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', 'svg',
|
||||
'mp3', 'mp4', 'wav', 'avi', 'mov', 'mkv', 'flv', 'wmv',
|
||||
'ttf', 'otf', 'woff', 'woff2', 'eot',
|
||||
'class', 'pyc', 'pyo', 'jar', 'war', 'ear',
|
||||
'db', 'sqlite', 'mdb', 'accdb',
|
||||
'pem', 'key', 'crt', 'cer', 'p12', 'pfx', 'jks',
|
||||
'msg', 'eml', 'pst', 'ost',
|
||||
'dwg', 'dxf', 'step', 'stl', 'obj', 'fbx', '3ds', 'blend'
|
||||
]
|
||||
return binaryExtensions.includes(ext.toLowerCase())
|
||||
}
|
||||
|
||||
function inferMimeType(name, fallback = '') {
|
||||
@@ -143,6 +137,8 @@ function inferMimeType(name, fallback = '') {
|
||||
|
||||
function isTextFile(record) {
|
||||
const ext = getExtension(record?.name)
|
||||
// 二进制文件不提供预览
|
||||
if (isBinaryExtension(ext)) return false
|
||||
const mime = String(record?.mimeType || '')
|
||||
return isTextExtension(ext) || mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')
|
||||
}
|
||||
@@ -218,6 +214,7 @@ async function readFilePayload(file) {
|
||||
const mimeType = inferMimeType(file.name, file.type)
|
||||
const ext = getExtension(file.name)
|
||||
const textFile = isTextExtension(ext) || mimeType.startsWith('text/') || mimeType.includes('json') || mimeType.includes('xml')
|
||||
|
||||
if (!textFile) {
|
||||
return {
|
||||
mimeType,
|
||||
@@ -226,6 +223,17 @@ async function readFilePayload(file) {
|
||||
blob: file
|
||||
}
|
||||
}
|
||||
|
||||
// 二进制扩展名文件不尝试读取内容,避免长时间等待
|
||||
if (isBinaryExtension(ext)) {
|
||||
return {
|
||||
mimeType,
|
||||
size: file.size,
|
||||
storageKind: 'blob',
|
||||
blob: file
|
||||
}
|
||||
}
|
||||
|
||||
if (file.size <= MAX_TEXT_SIZE) {
|
||||
const content = await file.text()
|
||||
return {
|
||||
@@ -314,13 +322,8 @@ export function useFileSystem() {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const stored = await withStore('readonly', (store) => store.getAll())
|
||||
const request = stored
|
||||
const nextRecords = await new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : [])
|
||||
request.onerror = () => reject(request.error || new Error('读取本地文件失败'))
|
||||
})
|
||||
if (nextRecords.length === 0) {
|
||||
const nextRecords = await withStore('readonly', (store) => store.getAll())
|
||||
if (!Array.isArray(nextRecords) || nextRecords.length === 0) {
|
||||
const seed = createWelcomeRecords()
|
||||
await Promise.all(seed.map((record) => persistRecord(record)))
|
||||
records.value = seed
|
||||
|
||||
42
src/plugins/AGENTS.md
Normal file
42
src/plugins/AGENTS.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Plugins 模块指南
|
||||
|
||||
## OVERVIEW
|
||||
Milkdown/ProseMirror 插件,AI 补全核心逻辑。
|
||||
|
||||
## STRUCTURE
|
||||
- copilotPlugin.ts - ProseMirror Mark 系统、ghost text、防抖请求
|
||||
- docBlockPlugin.ts - 文档块插件
|
||||
- mermaidPlugin.ts - Mermaid 图表渲染
|
||||
- index.ts - 插件导出
|
||||
- types.ts - 类型定义
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| 任务 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| AI 补全核心 | copilotPlugin.ts | ProseMirror Mark、ghost text |
|
||||
| 文档块处理 | docBlockPlugin.ts | 文档块解析与渲染 |
|
||||
| Mermaid 图表 | mermaidPlugin.ts | 图表渲染集成 |
|
||||
| 插件导出 | index.ts | 统一导出入口 |
|
||||
| 类型定义 | types.ts | 公共类型 |
|
||||
|
||||
## 关键函数
|
||||
- scheduleFetch - 防抖触发补全请求
|
||||
- insertGhostText - 插入 ghost text
|
||||
- acceptSuggestion - Tab 接受建议
|
||||
- rejectSuggestion - Esc 取消建议
|
||||
|
||||
## CONVENTIONS
|
||||
- TypeScript 2 空格缩进
|
||||
- 函数:camelCase
|
||||
- 接口/类型:PascalCase
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- 空 catch 块
|
||||
- 类型错误使用 as any
|
||||
- 硬编码超时值
|
||||
|
||||
## 注意事项
|
||||
- 防抖时间:1000ms(可配置)
|
||||
- 文档大小限制:32KB 自动禁用
|
||||
- Tab/Esc 快捷键交互
|
||||
@@ -1,14 +1,22 @@
|
||||
/**
|
||||
* Univer 编辑器桥接服务
|
||||
* 封装 Univer 的初始化、加载、导出等操作
|
||||
*/
|
||||
* Univer 编辑器桥接服务
|
||||
* 封装 Univer 的初始化、加载、导出等操作
|
||||
*
|
||||
* 支持三种文档格式:
|
||||
* - DOCX: 使用 DocsCorePreset
|
||||
* - XLSX: 使用 SheetsCorePreset
|
||||
* - PPTX: 使用 DocsCorePreset + Slides 插件组合
|
||||
*/
|
||||
import { createUniver, LocaleType, merge } from '@univerjs/presets'
|
||||
import { UniverDocsCorePreset } from '@univerjs/preset-docs-core'
|
||||
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core'
|
||||
import { UniverSlidesPlugin } from '@univerjs/slides'
|
||||
import { UniverSlidesUIPlugin } from '@univerjs/slides-ui'
|
||||
|
||||
// 导入样式
|
||||
import '@univerjs/preset-docs-core/lib/index.css'
|
||||
import '@univerjs/preset-sheets-core/lib/index.css'
|
||||
import '@univerjs/slides-ui/lib/index.css'
|
||||
|
||||
// 导入语言包
|
||||
import DocsCoreEnUS from '@univerjs/preset-docs-core/locales/en-US'
|
||||
@@ -16,6 +24,10 @@ import SheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US'
|
||||
import DocsCoreZhCN from '@univerjs/preset-docs-core/locales/zh-CN'
|
||||
import SheetsCoreZhCN from '@univerjs/preset-sheets-core/locales/zh-CN'
|
||||
|
||||
// Slides 语言包(使用空对象作为 fallback,因为 slides 语言包可能不存在)
|
||||
const SlidesZhCN = {}
|
||||
const SlidesEnUS = {}
|
||||
|
||||
export const OfficeFormat = {
|
||||
DOCX: 'docx',
|
||||
XLSX: 'xlsx',
|
||||
@@ -66,43 +78,68 @@ export async function createUniverInstance(container, options = {}) {
|
||||
} = options
|
||||
|
||||
const localeType = locale === 'zh-CN' ? LocaleType.ZH_CN : LocaleType.EN_US
|
||||
// 合并所有语言包(包括 Slides)
|
||||
const locales = locale === 'zh-CN'
|
||||
? { [LocaleType.ZH_CN]: merge(DocsCoreZhCN, SheetsCoreZhCN) }
|
||||
: { [LocaleType.EN_US]: merge(DocsCoreEnUS, SheetsCoreEnUS) }
|
||||
? { [LocaleType.ZH_CN]: merge({}, DocsCoreZhCN, SheetsCoreZhCN, SlidesZhCN) }
|
||||
: { [LocaleType.EN_US]: merge({}, DocsCoreEnUS, SheetsCoreEnUS, SlidesEnUS) }
|
||||
|
||||
const presets = []
|
||||
const extraPlugins = []
|
||||
|
||||
// 根据格式添加对应的 Preset
|
||||
if (format === OfficeFormat.DOCX || format === OfficeFormat.PPTX) {
|
||||
presets.push(UniverDocsCorePreset({
|
||||
container,
|
||||
theme: theme === 'dark' ? 'dark' : 'default'
|
||||
}))
|
||||
// 根据格式添加对应的 Preset 和插件
|
||||
switch (format) {
|
||||
case OfficeFormat.DOCX:
|
||||
// DOCX 只需要 DocsCorePreset
|
||||
presets.push(UniverDocsCorePreset({
|
||||
container,
|
||||
theme: theme === 'dark' ? 'dark' : 'default'
|
||||
}))
|
||||
break
|
||||
|
||||
case OfficeFormat.XLSX:
|
||||
// XLSX 只需要 SheetsCorePreset
|
||||
presets.push(UniverSheetsCorePreset({
|
||||
container,
|
||||
theme: theme === 'dark' ? 'dark' : 'default'
|
||||
}))
|
||||
break
|
||||
|
||||
case OfficeFormat.PPTX:
|
||||
// PPTX 需要 DocsCorePreset + Slides 插件
|
||||
// DocsCorePreset 提供基础文档渲染能力
|
||||
presets.push(UniverDocsCorePreset({
|
||||
container,
|
||||
theme: theme === 'dark' ? 'dark' : 'default'
|
||||
}))
|
||||
// Slides 插件提供演示文稿功能
|
||||
// 注意:必须作为 plugins 而非 presets 传入
|
||||
extraPlugins.push([UniverSlidesPlugin])
|
||||
extraPlugins.push([UniverSlidesUIPlugin])
|
||||
break
|
||||
|
||||
default:
|
||||
// 默认使用 Docs 作为兜底
|
||||
presets.push(UniverDocsCorePreset({
|
||||
container,
|
||||
theme: theme === 'dark' ? 'dark' : 'default'
|
||||
}))
|
||||
}
|
||||
|
||||
if (format === OfficeFormat.XLSX) {
|
||||
presets.push(UniverSheetsCorePreset({
|
||||
container,
|
||||
theme: theme === 'dark' ? 'dark' : 'default'
|
||||
}))
|
||||
try {
|
||||
const { univer, univerAPI } = createUniver({
|
||||
locale: localeType,
|
||||
locales,
|
||||
presets,
|
||||
plugins: extraPlugins.length > 0 ? extraPlugins : undefined,
|
||||
collaboration: false // 纯前端模式,不启用协作
|
||||
})
|
||||
|
||||
console.log(`[Univer] 初始化成功,格式: ${format}, 预设数量: ${presets.length}, 插件数量: ${extraPlugins.length}`)
|
||||
return { univer, univerAPI }
|
||||
} catch (error) {
|
||||
console.error('[Univer] 初始化失败:', error)
|
||||
throw new Error(`Univer 初始化失败: ${error.message}`)
|
||||
}
|
||||
|
||||
// 默认使用 Docs 作为兜底
|
||||
if (presets.length === 0) {
|
||||
presets.push(UniverDocsCorePreset({
|
||||
container,
|
||||
theme: theme === 'dark' ? 'dark' : 'default'
|
||||
}))
|
||||
}
|
||||
|
||||
const { univer, univerAPI } = createUniver({
|
||||
locale: localeType,
|
||||
locales,
|
||||
presets,
|
||||
collaboration: false // 纯前端模式,不启用协作
|
||||
})
|
||||
|
||||
return { univer, univerAPI }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
export default pinia
|
||||
@@ -53,7 +53,17 @@ export default defineConfig({
|
||||
include: [
|
||||
'@milkdown/crepe',
|
||||
'@milkdown/vue',
|
||||
'@milkdown/kit'
|
||||
'@milkdown/kit',
|
||||
'@univerjs/core',
|
||||
'@univerjs/design',
|
||||
'@univerjs/engine-render',
|
||||
'@univerjs/engine-formula',
|
||||
'@univerjs/ui',
|
||||
'@univerjs/presets',
|
||||
'@univerjs/preset-docs-core',
|
||||
'@univerjs/preset-sheets-core',
|
||||
'@univerjs/slides',
|
||||
'@univerjs/slides-ui'
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user