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:
@@ -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)
|
llm-in-text/
|
||||||
├─ main.py # API 入口
|
├── backend/ # FastAPI 后端 (Python)
|
||||||
├─ llm.py # LLM 包装工具
|
│ ├── main.py # API 入口,路由定义
|
||||||
├─ prompt.py # Prompt 构建辅助
|
│ ├── llm.py # Ollama 调用封装
|
||||||
└─ tests/ # pytest 测试套件
|
│ ├── prompt.py # Prompt 构建逻辑
|
||||||
public/ # 前端静态资源
|
│ ├── prompts/ # JSON 格式的提示模板
|
||||||
src/ # 前端源码(Vite + React)
|
│ └── tests/ # pytest 测试套件
|
||||||
dist/ # 构建产出(生成文件)
|
├── 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` 检查。
|
| 后端 API 入口 | `backend/main.py` | FastAPI 路由、CORS、启动逻辑 |
|
||||||
- 文件名采用全小写加短横线,例如 `my-module.py`、`my-component.tsx`。
|
| 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 规范
|
- **前端入口**:`src/main.js`(非 TypeScript),使用 Vue3 + Pinia + Vue Router
|
||||||
- 提交信息遵循 **Conventional Commits**:`feat:` 新功能、`fix:` 修复、`docs:` 文档、`refactor:` 重构等。
|
- **后端入口**:`backend/main.py`,端口 8001,uvicorn 启动
|
||||||
- PR 必须包含:
|
- **代理配置**:开发时 `/v1` 代理到远程 API,生产需调整
|
||||||
- 与提交信息匹配的标题。
|
- **文件命名**:全小写+短横线(`my-module.py`、`my-component.vue`)
|
||||||
- 关联的 Issue(如 `Fixes #123`)。
|
- **语言**:UI 默认中文,响应必须使用中文
|
||||||
- UI 变更或 API 示例的截图/示例。
|
|
||||||
- 所有 CI 检查(代码检查、测试、类型检查)均通过。
|
|
||||||
|
|
||||||
## 安全 \& 配置建议
|
## 反模式(本项目禁止)
|
||||||
- 敏感信息请放入 `.env` 并确保已在 `.gitignore` 中。
|
|
||||||
- 按照 `backend/main.py` 中的实现,对上传文件的大小和类型进行校验,防止滥用。
|
- ❌ 硬编码 API_KEY(必须从环境变量读取)
|
||||||
- 定期审计依赖安全(`npm audit`、`pip-audit`)。
|
- ❌ 在前端暴露密钥(应通过后端代理)
|
||||||
|
- ❌ `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/**:官方文档参考,不可修改,编辑器相关问题需核对此目录
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
# Timeouts in seconds (10 minutes for large model loading)
|
||||||
COMPLETION_TIMEOUT = 600
|
COMPLETION_TIMEOUT = 600
|
||||||
OCR_TIMEOUT = 600
|
OCR_TIMEOUT = 600
|
||||||
CONVERT_TIMEOUT = 600
|
|
||||||
|
|
||||||
client = ollama.AsyncClient(host=OLLAMA_HOST)
|
client = ollama.AsyncClient(host=OLLAMA_HOST)
|
||||||
logger = logging.getLogger("llm")
|
logger = logging.getLogger("llm")
|
||||||
|
|||||||
+2
-7
@@ -17,6 +17,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from geoip import get_ip_location_text
|
from geoip import get_ip_location_text
|
||||||
from llm import call_ollama, call_vlm_ocr
|
from llm import call_ollama, call_vlm_ocr
|
||||||
|
from models import UserPreferences
|
||||||
from prompt import build_completion_prompts, prepare_prompt_context
|
from prompt import build_completion_prompts, prepare_prompt_context
|
||||||
import markitdown
|
import markitdown
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ app.add_middleware(
|
|||||||
allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"],
|
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")
|
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
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
class UserPreferences(BaseModel):
|
|
||||||
language: str = "auto"
|
|
||||||
currency: str = "auto"
|
|
||||||
timezone: str = "auto"
|
|
||||||
|
|
||||||
|
|
||||||
class CompletionRequest(BaseModel):
|
class CompletionRequest(BaseModel):
|
||||||
prefix: str
|
prefix: str
|
||||||
suffix: str
|
suffix: str
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""共享的 Pydantic 模型定义"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class UserPreferences(BaseModel):
|
||||||
|
"""用户偏好设置"""
|
||||||
|
language: str = "auto"
|
||||||
|
currency: str = "auto"
|
||||||
|
timezone: str = "auto"
|
||||||
+2
-8
@@ -1,17 +1,11 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
import re
|
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
|
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:
|
def _get_current_datetime(timezone_pref: str = "auto") -> str:
|
||||||
# Default to UTC+8 if auto or not specified.
|
# Default to UTC+8 if auto or not specified.
|
||||||
offset = 8
|
offset = 8
|
||||||
|
|||||||
@@ -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 文件并在其中添加对应的测试类和方法
|
||||||
+6
-2
@@ -1,9 +1,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
# 设置 Hugging Face 镜像源为国内镜像
|
||||||
|
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -29,8 +33,8 @@ def _get_device_map() -> str:
|
|||||||
try:
|
try:
|
||||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||||
return "mps"
|
return "mps"
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.debug("MPS check failed: %s", e)
|
||||||
return "cpu"
|
return "cpu"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
|
import { isOfficeFile, getOfficeFormat } from '../services/officeDetection'
|
||||||
|
import UniverPreview from './UniverPreview.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
node: { type: Object, default: null },
|
node: { type: Object, default: null },
|
||||||
@@ -40,6 +42,16 @@ const isText = computed(() => {
|
|||||||
const mime = String(props.node?.mimeType || '')
|
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)
|
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(() => {
|
const folderItems = computed(() => {
|
||||||
if (!isRoot.value && !isFolder.value) return []
|
if (!isRoot.value && !isFolder.value) return []
|
||||||
return isRoot.value ? props.rootNodes : props.node.children || []
|
return isRoot.value ? props.rootNodes : props.node.children || []
|
||||||
@@ -228,6 +240,14 @@ function downloadFile() {
|
|||||||
<iframe class="pdf-frame" :src="objectUrl" :title="node.name"></iframe>
|
<iframe class="pdf-frame" :src="objectUrl" :title="node.name"></iframe>
|
||||||
</div>
|
</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 v-else class="content-unsupported">
|
||||||
<div class="unsupported-card">
|
<div class="unsupported-card">
|
||||||
<h3>暂不支持在线预览此文件</h3>
|
<h3>暂不支持在线预览此文件</h3>
|
||||||
|
|||||||
@@ -158,16 +158,15 @@ function forwardDragOver(event, id) {
|
|||||||
<div class="sidebar-title-wrap">
|
<div class="sidebar-title-wrap">
|
||||||
<span class="sidebar-title-icon">
|
<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>
|
<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>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h2>Files</h2>
|
<h2>文档模式</h2>
|
||||||
<p>浏览器本地仓库</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button class="upload-btn" type="button" title="上传文件" @click="triggerUpload">
|
||||||
<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>
|
||||||
<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>
|
||||||
</button>
|
|
||||||
<input ref="uploadInput" type="file" multiple class="hidden-input" @change="handleUpload" />
|
<input ref="uploadInput" type="file" multiple class="hidden-input" @change="handleUpload" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+79
-377
@@ -1,60 +1,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="univer-editor-container">
|
<div class="univer-editor-container">
|
||||||
<!-- 工具栏 -->
|
<header class="univer-header-bar">
|
||||||
<div class="univer-toolbar">
|
<div class="header-left">
|
||||||
<div class="toolbar-left">
|
<div class="logo-area">
|
||||||
<span class="doc-name" :title="documentInfo?.name || ''">
|
<div class="logo-icon">U</div>
|
||||||
{{ documentInfo?.name || '未命名文档' }}
|
<span class="logo-text">Univer Editor</span>
|
||||||
</span>
|
</div>
|
||||||
<span class="doc-format" v-if="documentInfo?.format">
|
<div class="divider"></div>
|
||||||
{{ getFormatLabel(documentInfo.format) }}
|
<div class="file-info-area">
|
||||||
</span>
|
<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>
|
||||||
<div class="toolbar-right">
|
<div class="header-right">
|
||||||
<button class="toolbar-btn" @click="handleImport" :title="t('import') || '导入文件'">
|
<div class="action-group">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<button class="action-btn" @click="handleImport" :title="t('import')">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<polyline points="17 8 12 3 7 8"/>
|
<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"/>
|
||||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
</svg>
|
||||||
</svg>
|
<span>导入</span>
|
||||||
<span>{{ t('import') || '导入' }}</span>
|
</button>
|
||||||
</button>
|
<button class="action-btn" @click="handleExport" :title="t('export')" :disabled="!hasDocument">
|
||||||
<button class="toolbar-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">
|
||||||
<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"/>
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
</svg>
|
||||||
<polyline points="7 10 12 15 17 10"/>
|
<span>导出</span>
|
||||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
</button>
|
||||||
</svg>
|
</div>
|
||||||
<span>{{ t('export') || '导出' }}</span>
|
<div class="divider"></div>
|
||||||
</button>
|
<button class="back-link-btn" @click="handleBack">
|
||||||
<button class="toolbar-btn" @click="handleSaveSnapshot" :title="t('saveSnapshot') || '保存快照'" :disabled="!editorInstance">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<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">
|
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{{ t('back') || '返回' }}</span>
|
<span>返回</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<!-- 编辑器容器 -->
|
|
||||||
<div ref="editorContainer" class="univer-editor-body">
|
<div ref="editorContainer" class="univer-editor-body">
|
||||||
<!-- 空状态提示 -->
|
|
||||||
<div v-if="!editorInstance" class="empty-state">
|
<div v-if="!editorInstance" class="empty-state">
|
||||||
<div class="empty-icon">
|
<div class="empty-icon">
|
||||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<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"/>
|
<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"/>
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
<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="17" x2="8" y2="17"/>
|
|
||||||
<polyline points="10 9 9 9 8 9"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p class="empty-text">{{ t('selectOfficeFile') || '请选择 Office 文件开始编辑' }}</p>
|
<p class="empty-text">{{ t('selectOfficeFile') || '请选择 Office 文件开始编辑' }}</p>
|
||||||
@@ -62,34 +55,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<Teleport to="body">
|
||||||
<div v-if="showExportDialog" class="export-dialog-overlay" @click.self="showExportDialog = false">
|
<div v-if="showExportDialog" class="export-dialog-overlay" @click.self="showExportDialog = false">
|
||||||
<div class="export-dialog">
|
<div class="export-dialog">
|
||||||
<h3>{{ t('selectExportFormat') || '选择导出格式' }}</h3>
|
<h3>{{ t('selectExportFormat') || '选择导出格式' }}</h3>
|
||||||
<div class="export-options">
|
<div class="export-options">
|
||||||
<button
|
<button v-for="fmt in exportFormats" :key="fmt.value" class="export-option" @click="confirmExport(fmt.value)">
|
||||||
v-for="format in exportFormats"
|
<span class="format-icon">{{ fmt.icon }}</span>
|
||||||
:key="format.value"
|
<span class="format-label">{{ fmt.label }}</span>
|
||||||
class="export-option"
|
|
||||||
@click="confirmExport(format.value)"
|
|
||||||
>
|
|
||||||
<span class="format-icon">{{ format.icon }}</span>
|
|
||||||
<span class="format-label">{{ format.label }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="cancel-btn" @click="showExportDialog = false">
|
<button class="cancel-btn" @click="showExportDialog = false">{{ t('cancel') || '取消' }}</button>
|
||||||
{{ t('cancel') || '取消' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -106,27 +84,23 @@ import { createUniverEditor, OfficeFormat, OfficePresetType } from '../services/
|
|||||||
import { isOfficeFile, getOfficeFormat, getFormatDisplayName } from '../services/officeDetection'
|
import { isOfficeFile, getOfficeFormat, getFormatDisplayName } from '../services/officeDetection'
|
||||||
|
|
||||||
const emit = defineEmits(['back', 'document-loaded', 'document-changed'])
|
const emit = defineEmits(['back', 'document-loaded', 'document-changed'])
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const officeStore = useOfficeStore()
|
const officeStore = useOfficeStore()
|
||||||
const settings = useSettingsStore()
|
const settings = useSettingsStore()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
|
||||||
const t = (key) => settings.t[key]
|
const t = (key) => settings.t[key]
|
||||||
|
|
||||||
const editorContainer = ref(null)
|
const editorContainer = ref(null)
|
||||||
const fileInput = ref(null)
|
const fileInput = ref(null)
|
||||||
const editorInstance = ref(null)
|
const editorInstance = ref(null)
|
||||||
const showExportDialog = ref(false)
|
const showExportDialog = ref(false)
|
||||||
|
|
||||||
const acceptTypes = '.docx,.xlsx,.pptx'
|
const acceptTypes = '.docx,.xlsx,.pptx'
|
||||||
|
|
||||||
const hasDocument = computed(() => officeStore.hasDocument)
|
const hasDocument = computed(() => officeStore.hasDocument)
|
||||||
const documentInfo = computed(() => officeStore.documentInfo)
|
const documentInfo = computed(() => officeStore.documentInfo)
|
||||||
|
|
||||||
const exportFormats = computed(() => {
|
const exportFormats = computed(() => {
|
||||||
const currentFormat = officeStore.currentFormat
|
if (officeStore.currentFormat === OfficeFormat.XLSX) {
|
||||||
if (currentFormat === OfficeFormat.XLSX) {
|
|
||||||
return [
|
return [
|
||||||
{ value: 'xlsx', label: 'Excel (.xlsx)', icon: '📊' },
|
{ value: 'xlsx', label: 'Excel (.xlsx)', icon: '📊' },
|
||||||
{ value: 'xlsx_snapshot', label: '快照 (JSON)', icon: '💾' }
|
{ value: 'xlsx_snapshot', label: '快照 (JSON)', icon: '💾' }
|
||||||
@@ -138,367 +112,95 @@ const exportFormats = computed(() => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化编辑器
|
|
||||||
*/
|
|
||||||
async function initEditor(format) {
|
async function initEditor(format) {
|
||||||
if (!editorContainer.value) return
|
if (!editorContainer.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editorInstance.value) {
|
if (editorInstance.value) await editorInstance.value.destroy()
|
||||||
await editorInstance.value.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
editorInstance.value = createUniverEditor()
|
editorInstance.value = createUniverEditor()
|
||||||
await editorInstance.value.init(editorContainer.value, {
|
await editorInstance.value.init(editorContainer.value, {
|
||||||
format: format || OfficeFormat.DOCX,
|
format: format || OfficeFormat.DOCX,
|
||||||
locale: settings.language === 'zh-CN' ? 'zh-CN' : 'en-US',
|
locale: settings.language === 'zh-CN' ? 'zh-CN' : 'en-US',
|
||||||
theme: isDark.value ? 'dark' : 'light'
|
theme: isDark.value ? 'dark' : 'light'
|
||||||
})
|
})
|
||||||
|
editorInstance.value.onChange(() => officeStore.markAsChanged())
|
||||||
// 监听文档变化
|
|
||||||
editorInstance.value.onChange((event) => {
|
|
||||||
officeStore.markAsChanged()
|
|
||||||
emit('document-changed', event)
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (error) {
|
} 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) {
|
async function handleFileChange(event) {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
if (!isOfficeFile(file)) {
|
if (!isOfficeFile(file)) {
|
||||||
alert(t('invalidOfficeFormat') || '请选择有效的 Office 文件 (DOCX/XLSX/PPTX)')
|
alert(t('invalidOfficeFormat') || '无效格式')
|
||||||
event.target.value = ''
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const format = getOfficeFormat(file)
|
const format = getOfficeFormat(file)
|
||||||
const bytes = await file.arrayBuffer()
|
const bytes = await file.arrayBuffer()
|
||||||
|
|
||||||
officeStore.setCurrentDocument(file, bytes)
|
officeStore.setCurrentDocument(file, bytes)
|
||||||
|
|
||||||
// 重新初始化编辑器
|
|
||||||
await initEditor(format)
|
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() {
|
||||||
* 确认导出
|
|
||||||
*/
|
|
||||||
async function confirmExport(format) {
|
|
||||||
showExportDialog.value = false
|
showExportDialog.value = false
|
||||||
|
|
||||||
if (!editorInstance.value) return
|
if (!editorInstance.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const snapshot = await editorInstance.value.exportSnapshot()
|
const snapshot = await editorInstance.value.exportSnapshot()
|
||||||
const json = JSON.stringify(snapshot, null, 2)
|
downloadFile(JSON.stringify(snapshot), 'snapshot.json', 'application/json')
|
||||||
downloadFile(json, `${officeStore.currentFileName || 'document'}.json`, 'application/json')
|
} catch (e) {
|
||||||
} catch (error) {
|
console.error('Export failed', e)
|
||||||
console.error('导出失败:', error)
|
|
||||||
alert(t('exportFailed') || '导出失败,请重试')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function handleBack() { router.push('/') }
|
||||||
* 保存快照
|
|
||||||
*/
|
|
||||||
async function handleSaveSnapshot() {
|
|
||||||
if (!editorInstance.value) return
|
|
||||||
|
|
||||||
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) {
|
function downloadFile(content, filename, mimeType) {
|
||||||
const blob = new Blob([content], { type: mimeType })
|
const blob = new Blob([content], { type: mimeType })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = filename
|
link.download = filename
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取格式标签
|
|
||||||
*/
|
|
||||||
function getFormatLabel(format) {
|
function getFormatLabel(format) {
|
||||||
return getFormatDisplayName(format, settings.language)
|
return getFormatDisplayName(format, settings.language)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
watch(isDark, () => initEditor(officeStore.currentFormat))
|
||||||
* 监听主题变化
|
|
||||||
*/
|
onMounted(() => {
|
||||||
watch(isDark, async (newVal) => {
|
if (officeStore.currentFormat) initEditor(officeStore.currentFormat)
|
||||||
if (editorInstance.value) {
|
|
||||||
// Univer 暂不支持动态主题切换,需要重新初始化
|
|
||||||
await initEditor(officeStore.currentFormat)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
onBeforeUnmount(() => editorInstance.value?.destroy())
|
||||||
* 监听语言变化
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.univer-editor-container {
|
.univer-editor-container { display: flex; flex-direction: column; width: 100%; height: 100vh; background: var(--app-bg); overflow: hidden; }
|
||||||
display: flex;
|
.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; }
|
||||||
flex-direction: column;
|
.header-left, .header-right { display: flex; align-items: center; gap: 16px; }
|
||||||
width: 100%;
|
.logo-area { display: flex; align-items: center; gap: 8px; }
|
||||||
height: 100vh;
|
.logo-icon { width: 24px; height: 24px; background: #3b82f6; color: #fff; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-weight: 800; }
|
||||||
background: var(--app-bg);
|
.divider { width: 1px; height: 20px; background: var(--panel-border); }
|
||||||
color: var(--app-text);
|
.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; }
|
||||||
.univer-toolbar {
|
.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; }
|
||||||
display: flex;
|
.action-btn:hover:not(:disabled) { background: var(--btn-hover-bg); border-color: var(--panel-border); }
|
||||||
align-items: center;
|
.back-link-btn { cursor: pointer; color: #3b82f6; font-weight: 600; border: none; background: transparent; }
|
||||||
justify-content: space-between;
|
.univer-editor-body { flex: 1; position: relative; background: #f3f4f6; }
|
||||||
padding: 8px 16px;
|
.empty-state { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--app-bg); }
|
||||||
background: var(--panel-bg);
|
.empty-icon { opacity: 0.3; margin-bottom: 20px; }
|
||||||
border-bottom: 1px solid var(--panel-border);
|
.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; }
|
||||||
flex-shrink: 0;
|
.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); }
|
||||||
.toolbar-left {
|
.export-option:hover { border-color: #3b82f6; transform: translateY(-2px); }
|
||||||
display: flex;
|
.cancel-btn { width: 100%; padding: 10px; cursor: pointer; border: none; background: transparent; color: var(--muted-text); }
|
||||||
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);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = db.transaction(STORE_NAME, mode)
|
const transaction = db.transaction(STORE_NAME, mode)
|
||||||
const store = transaction.objectStore(STORE_NAME)
|
const store = transaction.objectStore(STORE_NAME)
|
||||||
let result
|
let request
|
||||||
try {
|
try {
|
||||||
result = handler(store)
|
request = handler(store)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error)
|
reject(error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
transaction.oncomplete = () => resolve(result)
|
if (request && typeof request.onsuccess === 'function') {
|
||||||
transaction.onerror = () => reject(transaction.error || new Error('本地数据库写入失败'))
|
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('本地数据库操作已取消'))
|
transaction.onabort = () => reject(transaction.error || new Error('本地数据库操作已取消'))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -62,43 +67,32 @@ function getExtension(name = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isTextExtension(ext) {
|
function isTextExtension(ext) {
|
||||||
return [
|
const textExtensions = [
|
||||||
'md',
|
'md', 'markdown', 'txt', 'json', 'js', 'jsx', 'ts', 'tsx',
|
||||||
'markdown',
|
'css', 'scss', 'less', 'html', 'htm', 'py', 'vue', 'xml',
|
||||||
'txt',
|
'yaml', 'yml', 'csv', 'log', 'sql', 'toml', 'ini', 'cfg',
|
||||||
'json',
|
'conf', 'sh', 'bat', 'ps1', 'java', 'c', 'cpp', 'h', 'hpp',
|
||||||
'js',
|
'go', 'rs', 'swift', 'kt', 'rb', 'php', 'pl', 'r', 'scala',
|
||||||
'jsx',
|
'gradle', 'properties', 'env', 'gitignore', 'dockerfile'
|
||||||
'ts',
|
]
|
||||||
'tsx',
|
return textExtensions.includes(ext)
|
||||||
'css',
|
}
|
||||||
'scss',
|
|
||||||
'less',
|
function isBinaryExtension(ext) {
|
||||||
'html',
|
const binaryExtensions = [
|
||||||
'htm',
|
'exe', 'dll', 'so', 'dylib', 'bin', 'dat', 'obj', 'o', 'a',
|
||||||
'py',
|
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
|
||||||
'vue',
|
'pdf', 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
|
||||||
'xml',
|
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', 'svg',
|
||||||
'yaml',
|
'mp3', 'mp4', 'wav', 'avi', 'mov', 'mkv', 'flv', 'wmv',
|
||||||
'yml',
|
'ttf', 'otf', 'woff', 'woff2', 'eot',
|
||||||
'csv',
|
'class', 'pyc', 'pyo', 'jar', 'war', 'ear',
|
||||||
'log',
|
'db', 'sqlite', 'mdb', 'accdb',
|
||||||
'sql',
|
'pem', 'key', 'crt', 'cer', 'p12', 'pfx', 'jks',
|
||||||
'toml',
|
'msg', 'eml', 'pst', 'ost',
|
||||||
'ini',
|
'dwg', 'dxf', 'step', 'stl', 'obj', 'fbx', '3ds', 'blend'
|
||||||
'cfg',
|
]
|
||||||
'conf',
|
return binaryExtensions.includes(ext.toLowerCase())
|
||||||
'sh',
|
|
||||||
'bat',
|
|
||||||
'ps1',
|
|
||||||
'java',
|
|
||||||
'c',
|
|
||||||
'cpp',
|
|
||||||
'h',
|
|
||||||
'hpp',
|
|
||||||
'go',
|
|
||||||
'rs'
|
|
||||||
].includes(ext)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferMimeType(name, fallback = '') {
|
function inferMimeType(name, fallback = '') {
|
||||||
@@ -143,6 +137,8 @@ function inferMimeType(name, fallback = '') {
|
|||||||
|
|
||||||
function isTextFile(record) {
|
function isTextFile(record) {
|
||||||
const ext = getExtension(record?.name)
|
const ext = getExtension(record?.name)
|
||||||
|
// 二进制文件不提供预览
|
||||||
|
if (isBinaryExtension(ext)) return false
|
||||||
const mime = String(record?.mimeType || '')
|
const mime = String(record?.mimeType || '')
|
||||||
return isTextExtension(ext) || mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')
|
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 mimeType = inferMimeType(file.name, file.type)
|
||||||
const ext = getExtension(file.name)
|
const ext = getExtension(file.name)
|
||||||
const textFile = isTextExtension(ext) || mimeType.startsWith('text/') || mimeType.includes('json') || mimeType.includes('xml')
|
const textFile = isTextExtension(ext) || mimeType.startsWith('text/') || mimeType.includes('json') || mimeType.includes('xml')
|
||||||
|
|
||||||
if (!textFile) {
|
if (!textFile) {
|
||||||
return {
|
return {
|
||||||
mimeType,
|
mimeType,
|
||||||
@@ -226,6 +223,17 @@ async function readFilePayload(file) {
|
|||||||
blob: file
|
blob: file
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 二进制扩展名文件不尝试读取内容,避免长时间等待
|
||||||
|
if (isBinaryExtension(ext)) {
|
||||||
|
return {
|
||||||
|
mimeType,
|
||||||
|
size: file.size,
|
||||||
|
storageKind: 'blob',
|
||||||
|
blob: file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (file.size <= MAX_TEXT_SIZE) {
|
if (file.size <= MAX_TEXT_SIZE) {
|
||||||
const content = await file.text()
|
const content = await file.text()
|
||||||
return {
|
return {
|
||||||
@@ -314,13 +322,8 @@ export function useFileSystem() {
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const stored = await withStore('readonly', (store) => store.getAll())
|
const nextRecords = await withStore('readonly', (store) => store.getAll())
|
||||||
const request = stored
|
if (!Array.isArray(nextRecords) || nextRecords.length === 0) {
|
||||||
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 seed = createWelcomeRecords()
|
const seed = createWelcomeRecords()
|
||||||
await Promise.all(seed.map((record) => persistRecord(record)))
|
await Promise.all(seed.map((record) => persistRecord(record)))
|
||||||
records.value = seed
|
records.value = seed
|
||||||
|
|||||||
@@ -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 { createUniver, LocaleType, merge } from '@univerjs/presets'
|
||||||
import { UniverDocsCorePreset } from '@univerjs/preset-docs-core'
|
import { UniverDocsCorePreset } from '@univerjs/preset-docs-core'
|
||||||
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-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-docs-core/lib/index.css'
|
||||||
import '@univerjs/preset-sheets-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'
|
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 DocsCoreZhCN from '@univerjs/preset-docs-core/locales/zh-CN'
|
||||||
import SheetsCoreZhCN from '@univerjs/preset-sheets-core/locales/zh-CN'
|
import SheetsCoreZhCN from '@univerjs/preset-sheets-core/locales/zh-CN'
|
||||||
|
|
||||||
|
// Slides 语言包(使用空对象作为 fallback,因为 slides 语言包可能不存在)
|
||||||
|
const SlidesZhCN = {}
|
||||||
|
const SlidesEnUS = {}
|
||||||
|
|
||||||
export const OfficeFormat = {
|
export const OfficeFormat = {
|
||||||
DOCX: 'docx',
|
DOCX: 'docx',
|
||||||
XLSX: 'xlsx',
|
XLSX: 'xlsx',
|
||||||
@@ -66,43 +78,68 @@ export async function createUniverInstance(container, options = {}) {
|
|||||||
} = options
|
} = options
|
||||||
|
|
||||||
const localeType = locale === 'zh-CN' ? LocaleType.ZH_CN : LocaleType.EN_US
|
const localeType = locale === 'zh-CN' ? LocaleType.ZH_CN : LocaleType.EN_US
|
||||||
|
// 合并所有语言包(包括 Slides)
|
||||||
const locales = locale === 'zh-CN'
|
const locales = locale === 'zh-CN'
|
||||||
? { [LocaleType.ZH_CN]: merge(DocsCoreZhCN, SheetsCoreZhCN) }
|
? { [LocaleType.ZH_CN]: merge({}, DocsCoreZhCN, SheetsCoreZhCN, SlidesZhCN) }
|
||||||
: { [LocaleType.EN_US]: merge(DocsCoreEnUS, SheetsCoreEnUS) }
|
: { [LocaleType.EN_US]: merge({}, DocsCoreEnUS, SheetsCoreEnUS, SlidesEnUS) }
|
||||||
|
|
||||||
const presets = []
|
const presets = []
|
||||||
|
const extraPlugins = []
|
||||||
|
|
||||||
// 根据格式添加对应的 Preset
|
// 根据格式添加对应的 Preset 和插件
|
||||||
if (format === OfficeFormat.DOCX || format === OfficeFormat.PPTX) {
|
switch (format) {
|
||||||
presets.push(UniverDocsCorePreset({
|
case OfficeFormat.DOCX:
|
||||||
container,
|
// DOCX 只需要 DocsCorePreset
|
||||||
theme: theme === 'dark' ? 'dark' : 'default'
|
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) {
|
try {
|
||||||
presets.push(UniverSheetsCorePreset({
|
const { univer, univerAPI } = createUniver({
|
||||||
container,
|
locale: localeType,
|
||||||
theme: theme === 'dark' ? 'dark' : 'default'
|
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
|
|
||||||
+11
-1
@@ -53,7 +53,17 @@ export default defineConfig({
|
|||||||
include: [
|
include: [
|
||||||
'@milkdown/crepe',
|
'@milkdown/crepe',
|
||||||
'@milkdown/vue',
|
'@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