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:
2026-04-11 09:24:14 +08:00
parent 2fdc996af9
commit d8b7832b14
18 changed files with 901 additions and 544 deletions

129
AGENTS.md
View File

@@ -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`,端口 8001uvicorn 启动
- **代理配置**:开发时 `/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
View 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`

View File

@@ -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")

View File

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

@@ -0,0 +1,9 @@
"""共享的 Pydantic 模型定义"""
from pydantic import BaseModel
class UserPreferences(BaseModel):
"""用户偏好设置"""
language: str = "auto"
currency: str = "auto"
timezone: str = "auto"

View File

@@ -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
View 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 文件并在其中添加对应的测试类和方法

View File

@@ -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
View 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 默认中文

View File

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

View File

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

View File

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

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

View File

@@ -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
View 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 快捷键交互

View File

@@ -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 }
}
/**

View File

@@ -1,5 +0,0 @@
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia

View File

@@ -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'
]
}
})