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:
2026-04-06 11:14:09 +08:00
parent c70cb2a9f0
commit 7985fe9641
27 changed files with 9304 additions and 260 deletions
+202 -26
View File
@@ -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
View File
@@ -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
+504
View File
@@ -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>
+28 -7
View File
@@ -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)
+5
View File
@@ -10,6 +10,11 @@ const routes = [
path: '/docs',
name: 'Docs',
component: () => import('../views/DocsView.vue')
},
{
path: '/univer',
name: 'Univer',
component: () => import('../views/UniverView.vue')
}
]
+135
View File
@@ -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
}
+265
View File
@@ -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
}
+138
View File
@@ -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
View File
@@ -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;
+44
View File
@@ -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>
+46
View File
@@ -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>