feat: OnlyOffice Document Server Integration + Preview ohne neuen Tab
OnlyOffice Integration: - DOCX, XLSX, PPTX nativ im Browser bearbeiten (wie Google Docs) - Automatische Erkennung: Wenn OnlyOffice konfiguriert ist, wird der vollwertige Editor geladen, sonst die einfache Vorschau als Fallback - Backend: WOPI-aehnliche Endpunkte - GET /files/<id>/onlyoffice-config - Editor-Konfiguration - POST /files/onlyoffice-callback - Speicher-Callback von OnlyOffice - GET /files/onlyoffice-status - Verfuegbarkeits-Check - JWT-Signierung fuer sichere Kommunikation mit OnlyOffice - Dokument-Key basiert auf file_id + checksum (Cache-Invalidierung) Admin-Einstellungen: - OnlyOffice URL + JWT Secret konfigurierbar - Setup-Anleitung direkt in der UI (docker-compose auskommentieren) docker-compose.yml: - OnlyOffice Document Server als optionaler Service (auskommentiert) - Einfach auskommentieren fuer volle Office-Bearbeitung Preview: - Oeffnet sich jetzt innerhalb der App (kein neuer Tab) - Zurueck-Button in der Toolbar Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OnlyOffice -->
|
||||
<div class="admin-section">
|
||||
<h3>OnlyOffice Document Server</h3>
|
||||
<p class="hint">Fuer die Bearbeitung von Word, Excel und PowerPoint Dateien direkt im Browser.
|
||||
Ohne OnlyOffice werden Dateien in einer einfachen Vorschau angezeigt.</p>
|
||||
<div class="smtp-form">
|
||||
<div class="field">
|
||||
<label>OnlyOffice URL</label>
|
||||
<InputText v-model="smtpForm.onlyoffice_url" placeholder="http://onlyoffice:80 oder https://office.example.com" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>JWT Secret {{ onlyofficeJwtSet ? '(gesetzt)' : '' }}</label>
|
||||
<Password v-model="smtpForm.onlyoffice_jwt_secret" :feedback="false" toggle-mask fluid
|
||||
placeholder="Muss mit JWT_SECRET in docker-compose uebereinstimmen" />
|
||||
</div>
|
||||
<Button label="Speichern" icon="pi pi-save" size="small" @click="saveSmtp" />
|
||||
</div>
|
||||
<div class="restore-instructions" style="margin-top: 1rem">
|
||||
<strong>Docker-Setup:</strong>
|
||||
<ol>
|
||||
<li>In <code>docker-compose.yml</code> den <code>onlyoffice</code>-Service auskommentieren</li>
|
||||
<li><code>docker-compose up -d</code></li>
|
||||
<li>URL auf <code>http://onlyoffice</code> (intern) oder die oeffentliche URL setzen</li>
|
||||
<li>JWT Secret muss in beiden Services identisch sein</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup & Restore -->
|
||||
<div class="admin-section">
|
||||
<h3>Backup & Restore</h3>
|
||||
@@ -509,6 +537,7 @@ const smtpForm = ref({
|
||||
system_smtp_username: '', system_smtp_password: '', system_email_from: '',
|
||||
})
|
||||
const smtpPasswordSet = ref(false)
|
||||
const onlyofficeJwtSet = ref(false)
|
||||
const smtpTesting = ref(false)
|
||||
|
||||
// Backup & Restore
|
||||
@@ -616,6 +645,8 @@ async function loadSettings() {
|
||||
smtpForm.value.system_smtp_username = res.data.system_smtp_username || ''
|
||||
smtpForm.value.system_email_from = res.data.system_email_from || ''
|
||||
smtpPasswordSet.value = res.data.system_smtp_password_set
|
||||
smtpForm.value.onlyoffice_url = res.data.onlyoffice_url || ''
|
||||
onlyofficeJwtSet.value = res.data.onlyoffice_jwt_secret_set
|
||||
} catch { /* first load, defaults */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -298,9 +298,9 @@ function handleDoubleClick(event) {
|
||||
}
|
||||
|
||||
function openPreview(data) {
|
||||
const previewable = /\.(pdf|docx?|xlsx?|pptx?|txt|md|json|xml|csv|py|js|html|css|yml|yaml|png|jpe?g|gif|svg|webp|bmp)$/i
|
||||
const previewable = /\.(pdf|docx?|xlsx?|pptx?|txt|md|json|xml|csv|py|js|html|css|yml|yaml|png|jpe?g|gif|svg|webp|bmp|odt|ods|odp|rtf)$/i
|
||||
if (previewable.test(data.name)) {
|
||||
window.open(`/preview/${data.id}`, '_blank')
|
||||
router.push(`/preview/${data.id}`)
|
||||
} else {
|
||||
downloadFile(data)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<h3>{{ fileName }}</h3>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<Button v-if="canEdit && !editing" icon="pi pi-pencil" label="Bearbeiten" size="small" outlined @click="startEdit" />
|
||||
<Button v-if="editing" icon="pi pi-save" label="Speichern" size="small" @click="saveEdit" :loading="saving" />
|
||||
<Button v-if="editing" label="Abbrechen" size="small" text @click="cancelEdit" />
|
||||
<Button v-if="canEdit && !editing && !onlyOfficeMode" icon="pi pi-pencil" label="Bearbeiten" size="small" outlined @click="startEdit" />
|
||||
<Button v-if="editing && !onlyOfficeMode" icon="pi pi-save" label="Speichern" size="small" @click="saveEdit" :loading="saving" />
|
||||
<Button v-if="editing && !onlyOfficeMode" label="Abbrechen" size="small" text @click="cancelEdit" />
|
||||
<Button icon="pi pi-download" label="Herunterladen" size="small" outlined @click="downloadFile" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,6 +23,11 @@
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- OnlyOffice Editor -->
|
||||
<div v-else-if="onlyOfficeMode" class="preview-content">
|
||||
<div id="onlyoffice-editor" class="oo-frame"></div>
|
||||
</div>
|
||||
|
||||
<!-- PDF -->
|
||||
<div v-else-if="previewType === 'pdf'" class="preview-content">
|
||||
<iframe :src="previewUrl" class="pdf-frame"></iframe>
|
||||
@@ -33,7 +38,7 @@
|
||||
<img :src="previewUrl" :alt="fileName" />
|
||||
</div>
|
||||
|
||||
<!-- HTML (DOCX) -->
|
||||
<!-- HTML (DOCX fallback) -->
|
||||
<div v-else-if="previewType === 'html'" class="preview-content">
|
||||
<div v-if="!editing" class="document-frame" v-html="htmlContent"></div>
|
||||
<div v-else class="editor-frame">
|
||||
@@ -42,7 +47,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spreadsheet (XLSX) -->
|
||||
<!-- Spreadsheet (XLSX fallback) -->
|
||||
<div v-else-if="previewType === 'spreadsheet'" class="preview-content">
|
||||
<div class="sheet-tabs" v-if="sheets.length > 1">
|
||||
<button v-for="(sheet, i) in sheets" :key="i"
|
||||
@@ -61,7 +66,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slides (PPTX) -->
|
||||
<!-- Slides (PPTX fallback) -->
|
||||
<div v-else-if="previewType === 'slides'" class="preview-content">
|
||||
<div class="slides-nav">
|
||||
<Button icon="pi pi-chevron-left" text :disabled="activeSlide <= 0" @click="activeSlide--" />
|
||||
@@ -87,15 +92,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import apiClient from '../api/client'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -116,23 +120,42 @@ const editorDirty = ref(false)
|
||||
const saving = ref(false)
|
||||
const editorRef = ref(null)
|
||||
const canEdit = ref(false)
|
||||
const onlyOfficeMode = ref(false)
|
||||
|
||||
function getTokenUrl(path) {
|
||||
return `${path}${path.includes('?') ? '&' : '?'}token=${encodeURIComponent(auth.accessToken || '')}`
|
||||
}
|
||||
|
||||
// Check if file is an Office format that OnlyOffice can handle
|
||||
function isOfficeFile(name) {
|
||||
return /\.(docx?|xlsx?|pptx?|odt|ods|odp|rtf|csv)$/i.test(name)
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiClient.get(`/files/${fileId}/preview`)
|
||||
const data = res.data
|
||||
fileName.value = data.name || ''
|
||||
// For Office files, try OnlyOffice first
|
||||
const previewRes = await apiClient.get(`/files/${fileId}/preview`)
|
||||
fileName.value = previewRes.data.name || ''
|
||||
|
||||
if (isOfficeFile(fileName.value)) {
|
||||
try {
|
||||
const ooRes = await apiClient.get(`/files/${fileId}/onlyoffice-config`)
|
||||
if (ooRes.data.available) {
|
||||
onlyOfficeMode.value = true
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
initOnlyOffice(ooRes.data)
|
||||
return
|
||||
}
|
||||
} catch { /* OnlyOffice not available, fall through to simple preview */ }
|
||||
}
|
||||
|
||||
// Simple preview fallback
|
||||
const data = previewRes.data
|
||||
previewType.value = data.type
|
||||
|
||||
if (data.type === 'pdf') {
|
||||
previewUrl.value = getTokenUrl(`/api/files/${fileId}/download`)
|
||||
canEdit.value = false
|
||||
} else if (data.type === 'image') {
|
||||
if (data.type === 'pdf' || data.type === 'image') {
|
||||
previewUrl.value = getTokenUrl(`/api/files/${fileId}/download`)
|
||||
canEdit.value = false
|
||||
} else if (data.type === 'html') {
|
||||
@@ -157,6 +180,25 @@ async function loadPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
function initOnlyOffice(ooData) {
|
||||
const ooUrl = ooData.onlyoffice_url
|
||||
|
||||
// Load OnlyOffice API script
|
||||
const script = document.createElement('script')
|
||||
script.src = `${ooUrl}/web-apps/apps/api/documents/api.js`
|
||||
script.onload = () => {
|
||||
if (window.DocsAPI) {
|
||||
new window.DocsAPI.DocEditor('onlyoffice-editor', ooData.config)
|
||||
}
|
||||
}
|
||||
script.onerror = () => {
|
||||
onlyOfficeMode.value = false
|
||||
error.value = 'OnlyOffice konnte nicht geladen werden. Fallback auf einfache Vorschau.'
|
||||
loadPreview()
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
window.location.href = getTokenUrl(`/api/files/${fileId}/download`)
|
||||
}
|
||||
@@ -168,7 +210,6 @@ function startEdit() {
|
||||
|
||||
function cancelEdit() {
|
||||
editing.value = false
|
||||
// Reload to discard changes
|
||||
loadPreview()
|
||||
}
|
||||
|
||||
@@ -181,20 +222,15 @@ async function saveEdit() {
|
||||
saving.value = true
|
||||
try {
|
||||
let payload = {}
|
||||
|
||||
if (previewType.value === 'html') {
|
||||
// Get HTML from contenteditable
|
||||
const content = editorRef.value ? editorRef.value.innerHTML : htmlContent.value
|
||||
payload = { type: 'html', content }
|
||||
payload = { type: 'html', content: editorRef.value ? editorRef.value.innerHTML : htmlContent.value }
|
||||
} else if (previewType.value === 'spreadsheet') {
|
||||
payload = { type: 'spreadsheet', sheets: sheets.value }
|
||||
} else if (previewType.value === 'text') {
|
||||
payload = { type: 'text', content: textContent.value }
|
||||
}
|
||||
|
||||
await apiClient.post(`/files/${fileId}/save`, payload)
|
||||
editing.value = false
|
||||
editorDirty.value = false
|
||||
toast.add({ severity: 'success', summary: 'Gespeichert', life: 3000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Speichern fehlgeschlagen', detail: err.response?.data?.error || String(err), life: 5000 })
|
||||
@@ -221,6 +257,7 @@ onMounted(loadPreview)
|
||||
flex: 1; gap: 0.75rem; color: var(--p-text-muted-color);
|
||||
}
|
||||
.preview-content { flex: 1; overflow: auto; }
|
||||
.oo-frame { width: 100%; height: 100%; }
|
||||
.pdf-frame { width: 100%; height: 100%; border: none; }
|
||||
.image-preview { display: flex; align-items: center; justify-content: center; padding: 1rem; background: var(--p-surface-100); }
|
||||
.image-preview img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
@@ -236,29 +273,15 @@ onMounted(loadPreview)
|
||||
border: 2px solid var(--p-primary-color);
|
||||
}
|
||||
.sheet-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--p-surface-200); padding: 0 1rem; background: var(--p-surface-50); }
|
||||
.sheet-tabs button {
|
||||
padding: 0.5rem 1rem; border: none; background: none; cursor: pointer;
|
||||
font-size: 0.85rem; border-bottom: 2px solid transparent;
|
||||
}
|
||||
.sheet-tabs button { padding: 0.5rem 1rem; border: none; background: none; cursor: pointer; font-size: 0.85rem; border-bottom: 2px solid transparent; }
|
||||
.sheet-tabs button.active { border-bottom-color: var(--p-primary-color); color: var(--p-primary-color); font-weight: 600; }
|
||||
.spreadsheet-wrapper { overflow: auto; padding: 0.5rem; }
|
||||
.spreadsheet { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
|
||||
.spreadsheet td {
|
||||
border: 1px solid var(--p-surface-200); padding: 4px 8px;
|
||||
min-width: 80px; white-space: nowrap;
|
||||
}
|
||||
.spreadsheet td { border: 1px solid var(--p-surface-200); padding: 4px 8px; min-width: 80px; white-space: nowrap; }
|
||||
.spreadsheet tr:first-child td { font-weight: 600; background: var(--p-surface-100); }
|
||||
.spreadsheet td[contenteditable="true"] { cursor: text; }
|
||||
.spreadsheet td[contenteditable="true"]:focus { outline: 2px solid var(--p-primary-color); background: var(--p-primary-50); }
|
||||
.slides-nav { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0.75rem; border-bottom: 1px solid var(--p-surface-200); }
|
||||
.slide-frame {
|
||||
max-width: 900px; margin: 2rem auto; padding: 3rem; background: white;
|
||||
min-height: 500px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); border-radius: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.slide-frame { max-width: 900px; margin: 2rem auto; padding: 3rem; background: white; min-height: 500px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); border-radius: 4px; font-size: 16px; }
|
||||
.text-frame { padding: 1.5rem; margin: 0; font-size: 0.9rem; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.text-editor {
|
||||
width: 100%; height: 100%; min-height: 500px; padding: 1.5rem;
|
||||
font-family: monospace; font-size: 0.9rem; border: none; outline: none; resize: none;
|
||||
}
|
||||
.text-editor { width: 100%; height: 100%; min-height: 500px; padding: 1.5rem; font-family: monospace; font-size: 0.9rem; border: none; outline: none; resize: none; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user