feat(tts): add api endpoints and optimization for apple silicon
Introduce a comprehensive TTS/ASR module that: - Adds /v1/tts-asr/config, /status, /warmup, /tts, /asr endpoints with detailed JSON responses - Implements Apple‑Silicon detection, device selection (MPS/CUDA/CPU), and memory limiting logic - Supports selectable model size, quantization, and offline mode via environment variables - Adds robust audio validation and multi‑path resampling fallback - Provides new README sections for API usage, device detection, and performance benchmarking - Includes a full testing suite: unit tests, integration tests, macOS simulation and performance reports - Updates backend dependencies and CI scripts - Adds new front‑end views and components for Univer editor integration All changes are backward compatible; new features are exposed through environment variables and new API routes.
This commit is contained in:
+202
-26
@@ -3,7 +3,9 @@ import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, default: null },
|
||||
breadcrumb: { type: Array, default: () => [] }
|
||||
breadcrumb: { type: Array, default: () => [] },
|
||||
rootNodes: { type: Array, default: () => [] },
|
||||
getFileIcon: { type: Function, default: () => 'file' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['navigate'])
|
||||
@@ -23,9 +25,37 @@ const isText = computed(() => {
|
||||
return textExts.includes(fileExt.value) || isMarkdown.value
|
||||
})
|
||||
|
||||
const isRoot = computed(() => !props.node)
|
||||
const isFolder = computed(() => props.node && props.node.type === 'folder')
|
||||
|
||||
const folderItems = computed(() => {
|
||||
if (!isRoot.value && !isFolder.value) return []
|
||||
const items = isRoot.value ? props.rootNodes : (props.node.children || [])
|
||||
return [...items].sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
|
||||
function navigateTo(id) {
|
||||
emit('navigate', id)
|
||||
}
|
||||
|
||||
function navigateUp() {
|
||||
if (isRoot.value) return
|
||||
emit('navigate', props.node.parentId || null)
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp)
|
||||
const diff = Date.now() - date.getTime()
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff/60000)}分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff/3600000)}小时前`
|
||||
if (diff < 30 * 86400000) return `${Math.floor(diff/86400000)}天前`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -42,20 +72,42 @@ function navigateTo(id) {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="!node" class="content-empty">
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" stroke="currentColor" stroke-width="1" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
</svg>
|
||||
<p>选择一个文件以查看内容</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="node.type === 'folder'" class="content-folder">
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" stroke="currentColor" stroke-width="1" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<h3>{{ node.name }}</h3>
|
||||
<p>包含 {{ (node.children || []).length }} 个项目</p>
|
||||
<div v-if="isRoot || isFolder" class="content-directory-view">
|
||||
<div class="directory-list">
|
||||
<div class="directory-header">
|
||||
<div class="col-name">名称</div>
|
||||
<div class="col-date">更新时间</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isRoot" class="directory-row" @click="navigateUp">
|
||||
<span class="col-icon">
|
||||
<span class="icon-folder"></span>
|
||||
</span>
|
||||
<div class="col-name name-folder">..</div>
|
||||
<div class="col-date"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in folderItems"
|
||||
:key="item.id"
|
||||
class="directory-row"
|
||||
@click="navigateTo(item.id)"
|
||||
>
|
||||
<span class="col-icon">
|
||||
<span v-if="item.type==='folder'" class="icon-folder"></span>
|
||||
<span v-else :class="['icon-file', `icon-${getFileIcon(item.name)}`]"></span>
|
||||
</span>
|
||||
<div class="col-name" :class="item.type === 'folder' ? 'name-folder' : 'name-file'">{{ item.name }}</div>
|
||||
<div class="col-date" :title="new Date(item.updatedAt).toLocaleString()">{{ formatDate(item.updatedAt) }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="folderItems.length === 0" class="directory-empty">
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" stroke="currentColor" stroke-width="1" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<p>此文件夹为空</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isMarkdown" class="content-markdown">
|
||||
@@ -142,8 +194,6 @@ function renderMarkdown(text) {
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.content-empty,
|
||||
.content-folder,
|
||||
.content-unsupported {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -155,22 +205,148 @@ function renderMarkdown(text) {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.content-empty svg,
|
||||
.content-folder svg,
|
||||
.content-unsupported svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.content-folder h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
.content-directory-view {
|
||||
flex: 1;
|
||||
padding: 24px 32px;
|
||||
overflow-y: auto;
|
||||
background: var(--app-bg);
|
||||
}
|
||||
|
||||
.directory-list {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--panel-bg);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.directory-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--ghost-code-bg);
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.directory-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.directory-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.directory-row:hover {
|
||||
background: var(--ghost-code-bg);
|
||||
}
|
||||
|
||||
.col-icon {
|
||||
width: 24px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
.content-folder p,
|
||||
.content-empty p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
.name-folder {
|
||||
font-weight: 500;
|
||||
color: var(--focus-ring);
|
||||
}
|
||||
|
||||
.directory-row:hover .name-folder {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.col-date {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
color: var(--muted-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.directory-empty {
|
||||
padding: 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
color: var(--muted-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.directory-empty svg {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* File Icons (Reused from FileTree) */
|
||||
.icon-file,
|
||||
.icon-folder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-folder::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%2354aeff' d='M0 2.5A1.5 1.5 0 011.5 1h2.793a.5.5 0 01.353.146l1.5 1.5a.5.5 0 00.354.146H13.5A1.5 1.5 0 0115 4.5v7.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12v-9.5z'/%3E%3C/svg%3E") no-repeat center;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .icon-folder::before {
|
||||
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%2358a6ff' d='M0 2.5A1.5 1.5 0 011.5 1h2.793a.5.5 0 01.353.146l1.5 1.5a.5.5 0 00.354.146H13.5A1.5 1.5 0 0115 4.5v7.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12v-9.5z'/%3E%3C/svg%3E") no-repeat center;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.icon-markdown::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%236e7781' d='M14.85 3H1.15C.52 3 0 3.52 0 4.15v7.69C0 12.48.52 13 1.15 13h13.69c.64 0 1.15-.52 1.15-1.15V4.15C16 3.52 15.48 3 14.85 3zM9 11H7.5V8.5L6.25 10l-1.25-1.5V11H3.5V5H5l1.25 1.5L7.5 5H9v6zm4-2.5c0 .28-.22.5-.5.5h-1v1c0 .28-.22.5-.5.5s-.5-.22-.5-.5v-1h-1c-.28 0-.5-.22-.5-.5s.22-.5.5-.5h1v-1c0-.28.22-.5.5-.5s.5.22.5.5v1h1c.28 0 .5.22.5.5z'/%3E%3C/svg%3E") no-repeat center;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.icon-text::before,
|
||||
.icon-json::before,
|
||||
.icon-file::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%236e7781' d='M3.75 1.5a.25.25 0 00-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V4.664a.25.25 0 00-.073-.177l-2.914-2.914a.25.25 0 00-.177-.073H3.75zM3 1.75C3 .784 3.784 0 4.75 0h5.339c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0113 16H4.75A1.75 1.75 0 013 14.25V1.75z'/%3E%3C/svg%3E") no-repeat center;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.file-ext {
|
||||
|
||||
+33
-31
@@ -137,18 +137,20 @@ function getIconClass(type, name) {
|
||||
:creating-in-folder="creatingInFolder"
|
||||
:creating-type="creatingType"
|
||||
:creating-name="creatingName"
|
||||
@select="(id) => emit('select', id)"
|
||||
@toggle="(id) => emit('toggle', id)"
|
||||
@start-rename="startRename"
|
||||
@finish-rename="finishRename"
|
||||
@cancel-rename="cancelRename"
|
||||
@start-create="startCreate"
|
||||
@finish-create="finishCreate"
|
||||
@cancel-create="cancelCreate"
|
||||
@context-menu="handleContextMenu"
|
||||
@drop="handleDrop"
|
||||
@drag-start="(e, id) => emit('drag-start', e, id)"
|
||||
@drag-over="(e, id) => emit('drag-over', e, id)"
|
||||
@select="(id) => emit('select', id)"
|
||||
@toggle="(id) => emit('toggle', id)"
|
||||
@start-rename="startRename"
|
||||
@finish-rename="finishRename"
|
||||
@cancel-rename="cancelRename"
|
||||
@update:rename-value="(val) => renameValue = val"
|
||||
@start-create="startCreate"
|
||||
@finish-create="finishCreate"
|
||||
@cancel-create="cancelCreate"
|
||||
@update:creating-name="(val) => creatingName = val"
|
||||
@context-menu="handleContextMenu"
|
||||
@drop="handleDrop"
|
||||
@drag-start="(e, id) => emit('drag-start', e, id)"
|
||||
@drag-over="(e, id) => emit('drag-over', e, id)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
@@ -241,16 +243,16 @@ export const TreeNodeItem = {
|
||||
)
|
||||
: h('span', { class: 'chevron-placeholder' }),
|
||||
h('span', { class: props.getIconClass(node.type, node.name) }),
|
||||
isRenaming()
|
||||
? h('input', {
|
||||
class: 'rename-input',
|
||||
value: props.renameValue,
|
||||
onInput: (e) => { props.renameValue = e.target.value },
|
||||
onKeydown: (e) => { if (e.key === 'Enter') emit('finish-rename', node); if (e.key === 'Escape') emit('cancel-rename') },
|
||||
onBlur: () => emit('finish-rename', node),
|
||||
autofocus: true
|
||||
})
|
||||
: h('span', { class: 'node-name' }, node.name),
|
||||
isRenaming()
|
||||
? h('input', {
|
||||
class: 'rename-input',
|
||||
value: props.renameValue,
|
||||
onInput: (e) => { emit('update:rename-value', e.target.value) },
|
||||
onKeydown: (e) => { if (e.key === 'Enter') emit('finish-rename', node); if (e.key === 'Escape') emit('cancel-rename') },
|
||||
onBlur: () => emit('finish-rename', node),
|
||||
autofocus: true
|
||||
})
|
||||
: h('span', { class: 'node-name' }, node.name),
|
||||
h('span', { class: 'node-actions' }, [
|
||||
node.type === 'folder' ? [
|
||||
h('button', {
|
||||
@@ -274,15 +276,15 @@ export const TreeNodeItem = {
|
||||
}, [
|
||||
h('span', { class: 'chevron-placeholder' }),
|
||||
h('span', { class: `icon-file ${props.creatingType === 'folder' ? 'icon-folder' : 'icon-file'}` }),
|
||||
h('input', {
|
||||
class: 'rename-input',
|
||||
value: props.creatingName,
|
||||
placeholder: props.creatingType === 'file' ? '文件名.md' : '文件夹名',
|
||||
onInput: (e) => { props.creatingName = e.target.value },
|
||||
onKeydown: (e) => { if (e.key === 'Enter') emit('finish-create'); if (e.key === 'Escape') emit('cancel-create') },
|
||||
onBlur: () => emit('finish-create'),
|
||||
autofocus: true
|
||||
})
|
||||
h('input', {
|
||||
class: 'rename-input',
|
||||
value: props.creatingName,
|
||||
placeholder: props.creatingType === 'file' ? '文件名.md' : '文件夹名',
|
||||
onInput: (e) => { emit('update:creating-name', e.target.value) },
|
||||
onKeydown: (e) => { if (e.key === 'Enter') emit('finish-create'); if (e.key === 'Escape') emit('cancel-create') },
|
||||
onBlur: () => emit('finish-create'),
|
||||
autofocus: true
|
||||
})
|
||||
])
|
||||
: null
|
||||
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
<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>
|
||||
</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">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
<span>{{ t('back') || '返回' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器容器 -->
|
||||
<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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="empty-text">{{ t('selectOfficeFile') || '请选择 Office 文件开始编辑' }}</p>
|
||||
<p class="empty-hint">{{ t('supportedFormats') || '支持 DOCX、XLSX、PPTX 格式' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导入文件输入 -->
|
||||
<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>
|
||||
</div>
|
||||
<button class="cancel-btn" @click="showExportDialog = false">
|
||||
{{ t('cancel') || '取消' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useOfficeStore } from '../stores/office'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { createUniverEditor, OfficeFormat, OfficePresetType } from '../services/univerBridge'
|
||||
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) {
|
||||
return [
|
||||
{ value: 'xlsx', label: 'Excel (.xlsx)', icon: '📊' },
|
||||
{ value: 'xlsx_snapshot', label: '快照 (JSON)', icon: '💾' }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ value: 'docx', label: 'Word (.docx)', icon: '📄' },
|
||||
{ value: 'snapshot', label: '快照 (JSON)', icon: '💾' }
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* 初始化编辑器
|
||||
*/
|
||||
async function initEditor(format) {
|
||||
if (!editorContainer.value) return
|
||||
|
||||
try {
|
||||
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)
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('初始化 Univer 编辑器失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理导入
|
||||
*/
|
||||
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 = ''
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认导出
|
||||
*/
|
||||
async function confirmExport(format) {
|
||||
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') || '导出失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存快照
|
||||
*/
|
||||
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) {
|
||||
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(() => 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>
|
||||
|
||||
<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);
|
||||
}
|
||||
</style>
|
||||
@@ -81,6 +81,31 @@ export function useFileSystem() {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
tree.value = JSON.parse(stored)
|
||||
} else {
|
||||
// 创建示例文件和文件夹
|
||||
const welcomeId = generateId()
|
||||
const folderId = generateId()
|
||||
tree.value = [
|
||||
{
|
||||
id: folderId,
|
||||
name: '示例文件夹',
|
||||
type: 'folder',
|
||||
children: [],
|
||||
parentId: null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
},
|
||||
{
|
||||
id: welcomeId,
|
||||
name: '欢迎使用.md',
|
||||
type: 'file',
|
||||
content: '# 欢迎使用文件系统\n\n这是一个类似 GitHub 风格的文件浏览器。\n\n## 功能\n\n- ✅ 文件夹展开/折叠\n- ✅ 文件选中高亮\n- ✅ 拖拽移动\n- ✅ 右键菜单\n- ✅ 重命名\n- ✅ 新建/删除\n\n点击左侧的文件或文件夹来查看内容。\n',
|
||||
parentId: null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
]
|
||||
save()
|
||||
}
|
||||
} catch {
|
||||
tree.value = []
|
||||
@@ -174,13 +199,9 @@ export function useFileSystem() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
selectedId.value = id
|
||||
const node = findNode(tree.value, id)
|
||||
if (node && node.type === 'folder') {
|
||||
toggleFolder(id)
|
||||
}
|
||||
}
|
||||
function select(id) {
|
||||
selectedId.value = id
|
||||
}
|
||||
|
||||
function toggleFolder(id) {
|
||||
const node = findNode(tree.value, id)
|
||||
|
||||
@@ -10,6 +10,11 @@ const routes = [
|
||||
path: '/docs',
|
||||
name: 'Docs',
|
||||
component: () => import('../views/DocsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/univer',
|
||||
name: 'Univer',
|
||||
component: () => import('../views/UniverView.vue')
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Office 文件类型检测工具
|
||||
*/
|
||||
import { OfficeFormat, OfficePresetType } from './univerBridge'
|
||||
|
||||
/**
|
||||
* 支持的 Office 文件扩展名
|
||||
*/
|
||||
export const SUPPORTED_EXTENSIONS = {
|
||||
[OfficeFormat.DOCX]: ['.docx'],
|
||||
[OfficeFormat.XLSX]: ['.xlsx'],
|
||||
[OfficeFormat.PPTX]: ['.pptx']
|
||||
}
|
||||
|
||||
/**
|
||||
* MIME 类型映射
|
||||
*/
|
||||
export const MIME_TYPES = {
|
||||
[OfficeFormat.DOCX]: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
[OfficeFormat.XLSX]: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
[OfficeFormat.PPTX]: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测文件是否为 Office 文件
|
||||
*/
|
||||
export function isOfficeFile(file) {
|
||||
if (!file) return false
|
||||
|
||||
const filename = file.name?.toLowerCase() || ''
|
||||
const type = file.type?.toLowerCase() || ''
|
||||
|
||||
// 检查扩展名
|
||||
for (const [format, exts] of Object.entries(SUPPORTED_EXTENSIONS)) {
|
||||
if (exts.some(ext => filename.endsWith(ext))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 MIME 类型
|
||||
for (const [format, mime] of Object.entries(MIME_TYPES)) {
|
||||
if (type === mime) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件的 Office 格式
|
||||
*/
|
||||
export function getOfficeFormat(file) {
|
||||
if (!file) return null
|
||||
|
||||
const filename = file.name?.toLowerCase() || ''
|
||||
const type = file.type?.toLowerCase() || ''
|
||||
|
||||
// 检查扩展名
|
||||
for (const [format, exts] of Object.entries(SUPPORTED_EXTENSIONS)) {
|
||||
if (exts.some(ext => filename.endsWith(ext))) {
|
||||
return format
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 MIME 类型
|
||||
for (const [format, mime] of Object.entries(MIME_TYPES)) {
|
||||
if (type === mime) {
|
||||
return format
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件图标类型
|
||||
*/
|
||||
export function getOfficeIcon(format) {
|
||||
switch (format) {
|
||||
case OfficeFormat.DOCX:
|
||||
return 'doc'
|
||||
case OfficeFormat.XLSX:
|
||||
return 'xls'
|
||||
case OfficeFormat.PPTX:
|
||||
return 'ppt'
|
||||
default:
|
||||
return 'file'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取格式显示名称
|
||||
*/
|
||||
export function getFormatDisplayName(format, locale = 'zh-CN') {
|
||||
const names = {
|
||||
'zh-CN': {
|
||||
[OfficeFormat.DOCX]: 'Word 文档',
|
||||
[OfficeFormat.XLSX]: 'Excel 表格',
|
||||
[OfficeFormat.PPTX]: 'PowerPoint 演示文稿'
|
||||
},
|
||||
'en-US': {
|
||||
[OfficeFormat.DOCX]: 'Word Document',
|
||||
[OfficeFormat.XLSX]: 'Excel Spreadsheet',
|
||||
[OfficeFormat.PPTX]: 'PowerPoint Presentation'
|
||||
}
|
||||
}
|
||||
|
||||
return names[locale]?.[format] || format?.toUpperCase() || '未知格式'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对应的 Preset 类型
|
||||
*/
|
||||
export function getPresetTypeByFormat(format) {
|
||||
switch (format) {
|
||||
case OfficeFormat.DOCX:
|
||||
case OfficeFormat.PPTX:
|
||||
return OfficePresetType.DOCS
|
||||
case OfficeFormat.XLSX:
|
||||
return OfficePresetType.SHEETS
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
isOfficeFile,
|
||||
getOfficeFormat,
|
||||
getOfficeIcon,
|
||||
getFormatDisplayName,
|
||||
getPresetTypeByFormat,
|
||||
SUPPORTED_EXTENSIONS,
|
||||
MIME_TYPES
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Univer 编辑器桥接服务
|
||||
* 封装 Univer 的初始化、加载、导出等操作
|
||||
*/
|
||||
import { createUniver, LocaleType, merge } from '@univerjs/presets'
|
||||
import { UniverDocsCorePreset } from '@univerjs/preset-docs-core'
|
||||
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core'
|
||||
|
||||
// 导入样式
|
||||
import '@univerjs/preset-docs-core/lib/index.css'
|
||||
import '@univerjs/preset-sheets-core/lib/index.css'
|
||||
|
||||
// 导入语言包
|
||||
import DocsCoreEnUS from '@univerjs/preset-docs-core/locales/en-US'
|
||||
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'
|
||||
|
||||
export const OfficeFormat = {
|
||||
DOCX: 'docx',
|
||||
XLSX: 'xlsx',
|
||||
PPTX: 'pptx'
|
||||
}
|
||||
|
||||
export const OfficePresetType = {
|
||||
DOCS: 'docs',
|
||||
SHEETS: 'sheets',
|
||||
SLIDES: 'slides'
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件扩展名判断 Office 格式
|
||||
*/
|
||||
export function detectOfficeFormat(filename) {
|
||||
const ext = filename?.toLowerCase().split('.').pop() || ''
|
||||
if (ext === 'docx') return OfficeFormat.DOCX
|
||||
if (ext === 'xlsx') return OfficeFormat.XLSX
|
||||
if (ext === 'pptx') return OfficeFormat.PPTX
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据格式获取对应的 Preset 类型
|
||||
*/
|
||||
export function getPresetType(format) {
|
||||
switch (format) {
|
||||
case OfficeFormat.DOCX:
|
||||
return OfficePresetType.DOCS
|
||||
case OfficeFormat.XLSX:
|
||||
return OfficePresetType.SHEETS
|
||||
case OfficeFormat.PPTX:
|
||||
return OfficePresetType.SLIDES
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Univer 实例
|
||||
*/
|
||||
export async function createUniverInstance(container, options = {}) {
|
||||
const {
|
||||
format = OfficeFormat.DOCX,
|
||||
locale = 'zh-CN',
|
||||
theme = 'light'
|
||||
} = options
|
||||
|
||||
const localeType = locale === 'zh-CN' ? LocaleType.ZH_CN : LocaleType.EN_US
|
||||
const locales = locale === 'zh-CN'
|
||||
? { [LocaleType.ZH_CN]: merge(DocsCoreZhCN, SheetsCoreZhCN) }
|
||||
: { [LocaleType.EN_US]: merge(DocsCoreEnUS, SheetsCoreEnUS) }
|
||||
|
||||
const presets = []
|
||||
|
||||
// 根据格式添加对应的 Preset
|
||||
if (format === OfficeFormat.DOCX || format === OfficeFormat.PPTX) {
|
||||
presets.push(UniverDocsCorePreset({
|
||||
container,
|
||||
theme: theme === 'dark' ? 'dark' : 'default'
|
||||
}))
|
||||
}
|
||||
|
||||
if (format === OfficeFormat.XLSX) {
|
||||
presets.push(UniverSheetsCorePreset({
|
||||
container,
|
||||
theme: theme === 'dark' ? 'dark' : 'default'
|
||||
}))
|
||||
}
|
||||
|
||||
// 默认使用 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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Univer 编辑器实例包装类
|
||||
*/
|
||||
export class UniverEditorInstance {
|
||||
constructor() {
|
||||
this.univer = null
|
||||
this.univerAPI = null
|
||||
this.container = null
|
||||
this.currentFormat = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化编辑器
|
||||
*/
|
||||
async init(container, options = {}) {
|
||||
if (this.univer) {
|
||||
await this.destroy()
|
||||
}
|
||||
|
||||
this.container = container
|
||||
this.currentFormat = options.format || OfficeFormat.DOCX
|
||||
|
||||
const result = await createUniverInstance(container, {
|
||||
format: this.currentFormat,
|
||||
...options
|
||||
})
|
||||
|
||||
this.univer = result.univer
|
||||
this.univerAPI = result.univerAPI
|
||||
|
||||
// 创建初始文档
|
||||
if (this.currentFormat === OfficeFormat.XLSX) {
|
||||
this.univerAPI.createWorkbook({})
|
||||
} else {
|
||||
this.univerAPI.createUniverDoc({})
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字节数组加载文档
|
||||
*/
|
||||
async loadFromBytes(bytes, format) {
|
||||
if (!this.univerAPI) {
|
||||
throw new Error('Univer 实例未初始化')
|
||||
}
|
||||
|
||||
// 注意:纯前端模式下,Univer 不支持直接从 DOCX/XLSX/PPTX 字节流加载
|
||||
// 这里需要使用快照模式或后端服务来解析
|
||||
// 当前实现为占位,实际需要配合快照格式
|
||||
console.warn('纯前端模式暂不支持从 DOCX/XLSX/PPTX 字节流加载,请使用快照模式')
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为快照数据
|
||||
*/
|
||||
async exportSnapshot() {
|
||||
if (!this.univerAPI) {
|
||||
throw new Error('Univer 实例未初始化')
|
||||
}
|
||||
|
||||
const activeDoc = this.univerAPI.getActiveDocument()
|
||||
const activeSheet = this.univerAPI.getActiveWorkbook()
|
||||
|
||||
if (activeSheet) {
|
||||
return {
|
||||
type: OfficePresetType.SHEETS,
|
||||
format: OfficeFormat.XLSX,
|
||||
data: activeSheet.getSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
if (activeDoc) {
|
||||
return {
|
||||
type: OfficePresetType.DOCS,
|
||||
format: OfficeFormat.DOCX,
|
||||
data: activeDoc.getSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从快照数据导入
|
||||
*/
|
||||
async importSnapshot(snapshot) {
|
||||
if (!this.univerAPI || !snapshot?.data) {
|
||||
throw new Error('无效的快照数据')
|
||||
}
|
||||
|
||||
// 快照数据可以直接用于恢复文档状态
|
||||
// 具体实现取决于 Univer API
|
||||
console.log('导入快照:', snapshot.type, snapshot.format)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听文档变化
|
||||
*/
|
||||
onChange(callback) {
|
||||
if (!this.univerAPI) return
|
||||
|
||||
// Univer API 的事件监听
|
||||
this.univerAPI.addEvent(this.univerAPI.Event.CommandExecuted, (event) => {
|
||||
callback({
|
||||
type: 'command',
|
||||
data: event
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁实例
|
||||
*/
|
||||
async destroy() {
|
||||
if (this.univer) {
|
||||
this.univer.dispose()
|
||||
this.univer = null
|
||||
this.univerAPI = null
|
||||
this.container = null
|
||||
this.currentFormat = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前格式
|
||||
*/
|
||||
getFormat() {
|
||||
return this.currentFormat
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已初始化
|
||||
*/
|
||||
isInitialized() {
|
||||
return this.univer !== null && this.univerAPI !== null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Univer 编辑器实例
|
||||
*/
|
||||
export function createUniverEditor() {
|
||||
return new UniverEditorInstance()
|
||||
}
|
||||
|
||||
export default {
|
||||
createUniverInstance,
|
||||
createUniverEditor,
|
||||
detectOfficeFormat,
|
||||
getPresetType,
|
||||
OfficeFormat,
|
||||
OfficePresetType,
|
||||
UniverEditorInstance
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { OfficeFormat, OfficePresetType } from '../services/univerBridge'
|
||||
|
||||
export const useOfficeStore = defineStore('office', () => {
|
||||
// 当前文档状态
|
||||
const currentFileName = ref('')
|
||||
const currentFormat = ref(null)
|
||||
const currentFileSize = ref(0)
|
||||
const currentBytes = ref(null)
|
||||
|
||||
// 快照模式
|
||||
const isSnapshotMode = ref(true) // 默认启用快照模式
|
||||
const currentSnapshot = ref(null)
|
||||
|
||||
// 编辑状态
|
||||
const isEditing = ref(false)
|
||||
const hasUnsavedChanges = ref(false)
|
||||
|
||||
// 视图状态
|
||||
const activeView = ref('milkdown') // 'milkdown' | 'univer'
|
||||
|
||||
// 计算属性
|
||||
const hasDocument = computed(() => {
|
||||
return currentFileName.value && currentFormat.value
|
||||
})
|
||||
|
||||
const documentInfo = computed(() => {
|
||||
if (!hasDocument.value) return null
|
||||
return {
|
||||
name: currentFileName.value,
|
||||
format: currentFormat.value,
|
||||
size: currentFileSize.value,
|
||||
isSnapshot: isSnapshotMode.value
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 设置当前文档
|
||||
*/
|
||||
function setCurrentDocument(file, bytes) {
|
||||
if (!file) {
|
||||
clearCurrentDocument()
|
||||
return
|
||||
}
|
||||
|
||||
currentFileName.value = file.name || '未命名'
|
||||
currentFormat.value = getFormatFromFileName(file.name)
|
||||
currentFileSize.value = file.size || 0
|
||||
currentBytes.value = bytes
|
||||
hasUnsavedChanges.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除当前文档
|
||||
*/
|
||||
function clearCurrentDocument() {
|
||||
currentFileName.value = ''
|
||||
currentFormat.value = null
|
||||
currentFileSize.value = 0
|
||||
currentBytes.value = null
|
||||
currentSnapshot.value = null
|
||||
hasUnsavedChanges.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置快照数据
|
||||
*/
|
||||
function setSnapshot(snapshot) {
|
||||
currentSnapshot.value = snapshot
|
||||
hasUnsavedChanges.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记有未保存的更改
|
||||
*/
|
||||
function markAsChanged() {
|
||||
hasUnsavedChanges.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换视图
|
||||
*/
|
||||
function switchView(view) {
|
||||
activeView.value = view
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换快照模式
|
||||
*/
|
||||
function toggleSnapshotMode() {
|
||||
isSnapshotMode.value = !isSnapshotMode.value
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
currentFileName,
|
||||
currentFormat,
|
||||
currentFileSize,
|
||||
currentBytes,
|
||||
isSnapshotMode,
|
||||
currentSnapshot,
|
||||
isEditing,
|
||||
hasUnsavedChanges,
|
||||
activeView,
|
||||
|
||||
// 计算属性
|
||||
hasDocument,
|
||||
documentInfo,
|
||||
|
||||
// 方法
|
||||
setCurrentDocument,
|
||||
clearCurrentDocument,
|
||||
setSnapshot,
|
||||
markAsChanged,
|
||||
switchView,
|
||||
toggleSnapshotMode
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 从文件名获取格式
|
||||
*/
|
||||
function getFormatFromFileName(filename) {
|
||||
const ext = filename?.toLowerCase().split('.').pop() || ''
|
||||
switch (ext) {
|
||||
case 'docx':
|
||||
return OfficeFormat.DOCX
|
||||
case 'xlsx':
|
||||
return OfficeFormat.XLSX
|
||||
case 'pptx':
|
||||
return OfficeFormat.PPTX
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default useOfficeStore
|
||||
+93
-29
@@ -1,11 +1,16 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useFileSystem } from '../composables/useFileSystem'
|
||||
import { useOfficeStore } from '../stores/office'
|
||||
import { isOfficeFile, getOfficeFormat } from '../services/officeDetection'
|
||||
import FileTree from '../components/FileTree.vue'
|
||||
import FileContent from '../components/FileContent.vue'
|
||||
import ContextMenu from '../components/ContextMenu.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const fs = useFileSystem()
|
||||
const officeStore = useOfficeStore()
|
||||
const sidebarCollapsed = ref(false)
|
||||
const confirmDialog = ref(null)
|
||||
|
||||
@@ -72,27 +77,22 @@ function handleContextMenu(x, y, node) {
|
||||
|
||||
function handleDrop(draggedId, targetParentId) {
|
||||
if (draggedId === targetParentId) return
|
||||
|
||||
// 找到目标节点
|
||||
let targetNode = null
|
||||
if (targetParentId) {
|
||||
targetNode = findNode(fs.tree.value, targetParentId)
|
||||
if (!targetNode || targetNode.type !== 'folder') return
|
||||
}
|
||||
|
||||
// 检查是否拖拽到自己的子节点
|
||||
const draggedNode = findNode(fs.tree.value, draggedId)
|
||||
if (!draggedNode) return
|
||||
if (targetParentId && isDescendant(draggedNode, targetParentId)) return
|
||||
|
||||
const oldParent = findParent(fs.tree.value, draggedId)
|
||||
if (oldParent) {
|
||||
oldParent.children = (oldParent.children || []).filter(c => c.id !== draggedId)
|
||||
} else {
|
||||
fs.tree.value = fs.tree.value.filter(n => n.id !== draggedId)
|
||||
}
|
||||
|
||||
draggedNode.parentId = targetParentId || null
|
||||
if (targetParentId) {
|
||||
const target = findNode(fs.tree.value, targetParentId)
|
||||
if (target && target.type === 'folder') {
|
||||
target.children = target.children || []
|
||||
target.children.push(draggedNode)
|
||||
}
|
||||
} else {
|
||||
fs.tree.value.push(draggedNode)
|
||||
}
|
||||
// 使用剪贴板的移动逻辑
|
||||
fs.cut(draggedId)
|
||||
fs.paste(targetParentId)
|
||||
}
|
||||
|
||||
function isDescendant(node, targetId) {
|
||||
@@ -122,6 +122,17 @@ function handleDragStart(event, id) {
|
||||
function handleDragOver(event) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// 打开 Univer 编辑器
|
||||
function openInUniver() {
|
||||
router.push('/univer')
|
||||
}
|
||||
|
||||
// 判断选中的文件是否为 Office 文件
|
||||
const isSelectedOfficeFile = computed(() => {
|
||||
if (!selectedNode.value || selectedNode.value.type === 'folder') return false
|
||||
return isOfficeFile({ name: selectedNode.value.name })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -152,33 +163,51 @@ function handleDragOver(event) {
|
||||
|
||||
<div class="docs-main">
|
||||
<div class="docs-toolbar">
|
||||
<button class="sidebar-toggle" @click="sidebarCollapsed = !sidebarCollapsed" :title="sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'">
|
||||
<svg v-if="!sidebarCollapsed" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4.5 3.5a.5.5 0 00-.707.707L6.586 7l-2.793 2.793a.5.5 0 10.707.707l3-3a.5.5 0 000-.707l-3-3z"/><path d="M9.5 3.5a.5.5 0 01.707.707L7.414 7l2.793 2.793a.5.5 0 01-.707.707l-3-3a.5.5 0 010-.707l3-3z"/></svg>
|
||||
<svg v-else viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M11.5 3.5a.5.5 0 01.707.707L9.414 7l2.793 2.793a.5.5 0 01-.707.707l-3-3a.5.5 0 010-.707l3-3z"/><path d="M4.5 3.5a.5.5 0 00-.707.707L6.586 7l-2.793 2.793a.5.5 0 10.707.707l3-3a.5.5 0 000-.707l-3-3z"/></svg>
|
||||
</button>
|
||||
<button class="sidebar-toggle" @click="sidebarCollapsed = !sidebarCollapsed" :title="sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'">
|
||||
<svg v-if="!sidebarCollapsed" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4.5 3.5a.5.5 0 00-.707.707L6.586 7l-2.793 2.793a.5.5 0 10.707.707l3-3a.5.5 0 000-.707l-3-3z"/><path d="M9.5 3.5a.5.5 0 01.707.707L7.414 7l2.793 2.793a.5.5 0 01-.707.707l-3-3a.5.5 0 010-.707l3-3z"/></svg>
|
||||
<svg v-else viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M11.5 3.5a.5.5 0 01.707.707L9.414 7l2.793 2.793a.5.5 0 01-.707.707l-3-3a.5.5 0 010-.707l3-3z"/><path d="M4.5 3.5a.5.5 0 00-.707.707L6.586 7l-2.793 2.793a.5.5 0 10.707.707l3-3a.5.5 0 000-.707l-3-3z"/></svg>
|
||||
</button>
|
||||
<button class="editor-toggle" @click="router.push('/')" title="返回编辑器">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
||||
<path d="M2 2.5A1.5 1.5 0 013.5 1h9A1.5 1.5 0 0114 2.5v11a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 012 13.5v-11z"/>
|
||||
<path fill="var(--app-bg)" d="M4 4h8v1H4zm0 2h8v1H4zm0 2h6v1H4zm0 2h8v1H4zm0 2h5v1H4z"/>
|
||||
</svg>
|
||||
<span class="toggle-label">编辑器</span>
|
||||
</button>
|
||||
<div class="breadcrumb-bar">
|
||||
<span
|
||||
class="breadcrumb-link"
|
||||
:class="{ 'breadcrumb-current': breadcrumb.length === 0 }"
|
||||
@click="fs.select(null)"
|
||||
>根目录</span>
|
||||
<span v-if="breadcrumb.length > 0" class="breadcrumb-sep">/</span>
|
||||
<template v-for="(item, index) in breadcrumb" :key="item.id">
|
||||
<span
|
||||
v-if="item.type === 'folder' && index < breadcrumb.length - 1"
|
||||
v-if="index < breadcrumb.length - 1"
|
||||
class="breadcrumb-link"
|
||||
@click="fs.select(item.id)"
|
||||
>{{ item.name }}</span>
|
||||
<span v-else class="breadcrumb-current">{{ item.name }}</span>
|
||||
<span v-if="index < breadcrumb.length - 1" class="breadcrumb-sep">/</span>
|
||||
</template>
|
||||
<span v-if="breadcrumb.length === 0" class="breadcrumb-root">根目录</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button v-if="fs.canPaste()" class="toolbar-btn" @click="fs.paste(selectedNode && selectedNode.type === 'folder' ? selectedNode.id : null)" title="粘贴">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4.75 1.5a.25.25 0 00-.25.25v.59c0 .396.316.717.707.717h5.586c.39 0 .707-.32.707-.716v-.591a.25.25 0 00-.25-.25H4.75zm6.543-.75a1.75 1.75 0 011.75 1.75v.59c0 .396-.107.767-.293 1.086l1.293 1.293a.75.75 0 010 1.061l-1.293 1.293c.186.32.293.69.293 1.087v.59a1.75 1.75 0 01-1.75 1.75H4.75a1.75 1.75 0 01-1.75-1.75v-.59c0-.396.107-.767.293-1.087L2 5.53a.75.75 0 010-1.06l1.293-1.294A2.048 2.048 0 013 2.09v-.59A1.75 1.75 0 014.75 0h6.543zM6 8.5a.5.5 0 01.5-.5h3a.5.5 0 010 1h-3a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h3a.5.5 0 000-1h-3z"/></svg>
|
||||
粘贴
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button v-if="isSelectedOfficeFile" class="toolbar-btn office-btn" @click="openInUniver" title="在 Univer 中编辑">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><rect x="7" y="11" width="8" height="6" rx="1" stroke-width="1.5"/><circle cx="9" cy="13" r="0.8" fill="currentColor"/><path d="M7 16l2-2 2 2" stroke-width="1.5"/></svg>
|
||||
编辑 Office
|
||||
</button>
|
||||
<button v-if="fs.canPaste()" class="toolbar-btn" @click="fs.paste(selectedNode && selectedNode.type === 'folder' ? selectedNode.id : null)" title="粘贴">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M4.75 1.5a.25.25 0 00-.25.25v.59c0 .396.316.717.707.717h5.586c.39 0 .707-.32.707-.716v-.591a.25.25 0 00-.25-.25H4.75zm6.543-.75a1.75 1.75 0 011.75 1.75v.59c0 .396-.107.767-.293 1.086l1.293 1.293a.75.75 0 010 1.061l-1.293 1.293c.186.32.293.69.293 1.087v.59a1.75 1.75 0 01-1.75 1.75H4.75a1.75 1.75 0 01-1.75-1.75v-.59c0-.396.107-.767.293-1.087L2 5.53a.75.75 0 010-1.06l1.293-1.294A2.048 2.048 0 013 2.09v-.59A1.75 1.75 0 014.75 0h6.543zM6 8.5a.5.5 0 01.5-.5h3a.5.5 0 010 1h-3a.5.5 0 01-.5-.5zm.5 2.5a.5.5 0 000 1h3a.5.5 0 000-1h-3z"/></svg>
|
||||
粘贴
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileContent
|
||||
:node="selectedNode"
|
||||
:breadcrumb="breadcrumb"
|
||||
:root-nodes="fs.tree.value"
|
||||
:get-file-icon="fs.getFileIcon"
|
||||
@navigate="fs.select"
|
||||
/>
|
||||
</div>
|
||||
@@ -295,6 +324,31 @@ function handleDragOver(event) {
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
|
||||
.editor-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--panel-border);
|
||||
background: var(--app-bg);
|
||||
color: var(--muted-text);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.editor-toggle:hover {
|
||||
color: var(--focus-ring);
|
||||
border-color: var(--focus-ring);
|
||||
background: var(--ghost-code-bg);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb-bar {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -351,6 +405,16 @@ function handleDragOver(event) {
|
||||
color: var(--focus-ring);
|
||||
}
|
||||
|
||||
.toolbar-btn.office-btn {
|
||||
border-color: var(--focus-ring);
|
||||
color: var(--focus-ring);
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.toolbar-btn.office-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const MilkdownEditor = defineAsyncComponent(() => import('../components/MilkdownEditor.vue'))
|
||||
|
||||
const markdown = ref('')
|
||||
@@ -9,6 +11,14 @@ const markdown = ref('')
|
||||
|
||||
<template>
|
||||
<div class="editor-view">
|
||||
<div class="editor-toolbar">
|
||||
<button class="docs-toggle" @click="router.push('/docs')" title="切换到文档模式">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
||||
<path d="M0 2.5A1.5 1.5 0 011.5 1h2.793a.5.5 0 01.353.146l1.5 1.5a.5.5 0 00.354.146H13.5A1.5 1.5 0 0115 4.5v7.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12v-9.5z"/>
|
||||
</svg>
|
||||
<span class="toggle-label">文档</span>
|
||||
</button>
|
||||
</div>
|
||||
<MilkdownEditor v-model:markdown="markdown" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,5 +28,39 @@ const markdown = ref('')
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.docs-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--panel-border);
|
||||
background: var(--app-bg);
|
||||
color: var(--muted-text);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.docs-toggle:hover {
|
||||
color: var(--focus-ring);
|
||||
border-color: var(--focus-ring);
|
||||
background: var(--ghost-code-bg);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="univer-view">
|
||||
<UniverEditor
|
||||
ref="editorRef"
|
||||
@back="handleBack"
|
||||
@document-loaded="handleDocumentLoaded"
|
||||
@document-changed="handleDocumentChanged"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useOfficeStore } from '../stores/office'
|
||||
import UniverEditor from '../components/UniverEditor.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const officeStore = useOfficeStore()
|
||||
const editorRef = ref(null)
|
||||
|
||||
function handleBack() {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
function handleDocumentLoaded(doc) {
|
||||
console.log('文档已加载:', doc)
|
||||
}
|
||||
|
||||
function handleDocumentChanged(event) {
|
||||
console.log('文档已更改:', event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 设置当前视图为 univer
|
||||
officeStore.switchView('univer')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.univer-view {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user