在 Laravel 12 中集成 OnlyOffice 文档编辑器,以下是完整的实现步骤:

1. 环境准备

安装必要的包

composer require guzzlehttp/guzzle

2. 配置设置

.env 文件中添加配置

ONLYOFFICE_API_URL=http://onlyoffice-server
ONLYOFFICE_JWT_SECRET=your_jwt_secret_key
ONLYOFFICE_DOCUMENT_SERVER_URL=http://onlyoffice-document-server
ONLYOFFICE_STORAGE_PATH=storage/app/onlyoffice

创建配置文件 config/onlyoffice.php

<?php

return [
    'api_url' => env('ONLYOFFICE_API_URL', 'http://localhost'),
    'jwt_secret' => env('ONLYOFFICE_JWT_SECRET', ''),
    'document_server_url' => env('ONLYOFFICE_DOCUMENT_SERVER_URL', 'http://document-server'),
    'storage_path' => env('ONLYOFFICE_STORAGE_PATH', storage_path('app/onlyoffice')),
    
    'editor_config' => [
        'callbackUrl' => env('APP_URL') . '/onlyoffice/callback',
        'customization' => [
            'autosave' => true,
            'compactHeader' => false,
            'compactToolbar' => false,
            'feedback' => true,
            'forcesave' => true,
            'help' => true,
            'hideRightMenu' => false,
            'logo' => [
                'image' => env('APP_URL') . '/logo.png',
                'imageEmbedded' => env('APP_URL') . '/logo.png',
                'url' => env('APP_URL'),
            ],
            'plugins' => false,
            'review' => [
                'hideReviewDisplay' => false,
                'reviewDisplay' => 'original',
            ],
            'toolbarNoTabs' => false,
            'zoom' => 100,
        ],
    ],
];

3. 创建模型和迁移

创建文档模型

php artisan make:model Document -m

迁移文件 database/migrations/xxxx_create_documents_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('documents', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('file_name');
            $table->string('file_path');
            $table->string('file_type');
            $table->string('onlyoffice_key')->unique();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->json('permissions')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('documents');
    }
};

文档模型 app/Models/Document.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Document extends Model
{
    protected $fillable = [
        'title',
        'file_name',
        'file_path',
        'file_type',
        'onlyoffice_key',
        'user_id',
        'permissions',
    ];

    protected $casts = [
        'permissions' => 'array',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function getFileExtension(): string
    {
        return pathinfo($this->file_name, PATHINFO_EXTENSION);
    }

    public function getMimeType(): string
    {
        $ext = strtolower($this->getFileExtension());
        
        $mimeTypes = [
            'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
            'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
            'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
            'doc' => 'application/msword',
            'xls' => 'application/vnd.ms-excel',
            'ppt' => 'application/vnd.ms-powerpoint',
            'pdf' => 'application/pdf',
            'txt' => 'text/plain',
        ];

        return $mimeTypes[$ext] ?? 'application/octet-stream';
    }
}

4. 创建服务类

创建 app/Services/OnlyOfficeService.php

<?php

namespace App\Services;

use App\Models\Document;
use Carbon\Carbon;
use Firebase\JWT\JWT;
use Illuminate\Support\Facades\Storage;
use GuzzleHttp\Client;

class OnlyOfficeService
{
    protected $config;
    protected $client;

    public function __construct()
    {
        $this->config = config('onlyoffice');
        $this->client = new Client([
            'base_uri' => $this->config['document_server_url'],
            'timeout' => 30,
        ]);
    }

    /**
     * 生成 JWT Token
     */
    public function generateToken(array $payload): string
    {
        return JWT::encode($payload, $this->config['jwt_secret'], 'HS256');
    }

    /**
     * 验证 JWT Token
     */
    public function verifyToken(string $token): ?array
    {
        try {
            return (array) JWT::decode($token, $this->config['jwt_secret'], ['HS256']);
        } catch (\Exception $e) {
            return null;
        }
    }

    /**
     * 获取文档配置
     */
    public function getDocumentConfig(Document $document, array $userInfo = []): array
    {
        $fileUrl = route('onlyoffice.download', $document->onlyoffice_key);
        $callbackUrl = $this->config['editor_config']['callbackUrl'];

        $documentType = $this->getDocumentType($document->file_name);

        $config = [
            'document' => [
                'title' => $document->title,
                'url' => $fileUrl,
                'fileType' => $document->getFileExtension(),
                'key' => $document->onlyoffice_key,
                'permissions' => [
                    'edit' => true,
                    'download' => true,
                    'print' => true,
                ],
            ],
            'documentType' => $documentType,
            'editorConfig' => [
                'callbackUrl' => $callbackUrl,
                'customization' => $this->config['editor_config']['customization'],
                'mode' => 'edit',
                'lang' => 'zh-CN',
            ],
        ];

        if (!empty($userInfo)) {
            $config['editorConfig']['user'] = [
                'id' => $userInfo['id'] ?? 'anonymous',
                'name' => $userInfo['name'] ?? 'Anonymous',
            ];
        }

        // 添加 JWT Token
        if (!empty($this->config['jwt_secret'])) {
            $config['token'] = $this->generateToken([
                'document' => $config['document'],
                'iat' => Carbon::now()->timestamp,
            ]);
        }

        return $config;
    }

    /**
     * 获取文档类型
     */
    private function getDocumentType(string $fileName): string
    {
        $extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));

        $wordTypes = ['doc', 'docx', 'odt', 'rtf', 'txt', 'html'];
        $cellTypes = ['xls', 'xlsx', 'ods', 'csv'];
        $slideTypes = ['ppt', 'pptx', 'odp'];

        if (in_array($extension, $wordTypes)) {
            return 'word';
        } elseif (in_array($extension, $cellTypes)) {
            return 'cell';
        } elseif (in_array($extension, $slideTypes)) {
            return 'slide';
        }

        return 'word';
    }

    /**
     * 处理回调
     */
    public function handleCallback(array $data): array
    {
        $status = $data['status'] ?? null;
        $key = $data['key'] ?? null;

        if (!$status || !$key) {
            return ['error' => 'Invalid callback data'];
        }

        switch ($status) {
            case 1: // 文档正在编辑
                return ['message' => 'Document is being edited'];
                
            case 2: // 文档已准备好保存
                return $this->saveDocument($key, $data['url'] ?? null);
                
            case 3: // 文档保存错误
                return ['error' => 'Document saving error'];
                
            case 4: // 文档关闭无更改
                return ['message' => 'Document closed without changes'];
                
            case 6: // 强制保存文档
                return $this->saveDocument($key, $data['url'] ?? null, true);
                
            case 7: // 强制保存错误
                return ['error' => 'Force save error'];
        }

        return ['message' => 'Unknown status'];
    }

    /**
     * 保存文档
     */
    private function saveDocument(string $key, ?string $documentUrl, bool $force = false): array
    {
        if (!$documentUrl) {
            return ['error' => 'No document URL provided'];
        }

        $document = Document::where('onlyoffice_key', $key)->first();
        if (!$document) {
            return ['error' => 'Document not found'];
        }

        try {
            $response = $this->client->get($documentUrl);
            $content = $response->getBody()->getContents();
            
            Storage::disk('local')->put($document->file_path, $content);
            
            return [
                'success' => true,
                'force' => $force,
                'message' => 'Document saved successfully',
            ];
        } catch (\Exception $e) {
            return ['error' => 'Failed to save document: ' . $e->getMessage()];
        }
    }
}

5. 创建控制器

php artisan make:controller OnlyOfficeController

app/Http/Controllers/OnlyOfficeController.php

<?php

namespace App\Http\Controllers;

use App\Models\Document;
use App\Services\OnlyOfficeService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class OnlyOfficeController extends Controller
{
    protected $onlyOfficeService;

    public function __construct(OnlyOfficeService $onlyOfficeService)
    {
        $this->onlyOfficeService = $onlyOfficeService;
    }

    /**
     * 显示文档编辑器
     */
    public function editor($key)
    {
        $document = Document::where('onlyoffice_key', $key)->firstOrFail();
        
        // 检查权限
        $this->authorize('view', $document);

        $user = Auth::user();
        $config = $this->onlyOfficeService->getDocumentConfig($document, [
            'id' => $user->id,
            'name' => $user->name,
        ]);

        return view('onlyoffice.editor', compact('document', 'config'));
    }

    /**
     * 上传文档
     */
    public function upload(Request $request)
    {
        $request->validate([
            'file' => 'required|file|mimes:doc,docx,xls,xlsx,ppt,pptx,txt,pdf|max:10240',
        ]);

        $file = $request->file('file');
        $user = Auth::user();

        // 生成唯一key
        $key = Str::uuid()->toString();
        
        // 保存文件
        $filePath = 'onlyoffice/' . $key . '/' . $file->getClientOriginalName();
        Storage::disk('local')->put($filePath, file_get_contents($file));

        // 创建文档记录
        $document = Document::create([
            'title' => $file->getClientOriginalName(),
            'file_name' => $file->getClientOriginalName(),
            'file_path' => $filePath,
            'file_type' => $file->getMimeType(),
            'onlyoffice_key' => $key,
            'user_id' => $user->id,
            'permissions' => [
                'edit' => true,
                'download' => true,
                'print' => true,
            ],
        ]);

        return response()->json([
            'success' => true,
            'key' => $key,
            'document' => $document,
        ]);
    }

    /**
     * 下载文档
     */
    public function download($key)
    {
        $document = Document::where('onlyoffice_key', $key)->firstOrFail();
        
        // 检查权限
        $this->authorize('download', $document);

        if (!Storage::disk('local')->exists($document->file_path)) {
            abort(404);
        }

        return Storage::disk('local')->download(
            $document->file_path,
            $document->file_name,
            ['Content-Type' => $document->getMimeType()]
        );
    }

    /**
     * OnlyOffice 回调接口
     */
    public function callback(Request $request)
    {
        $data = $request->all();
        
        // 验证 JWT Token
        if ($request->has('token')) {
            $verified = $this->onlyOfficeService->verifyToken($request->token);
            if (!$verified) {
                return response()->json(['error' => 'Invalid token'], 401);
            }
        }

        $result = $this->onlyOfficeService->handleCallback($data);
        
        if (isset($result['error'])) {
            return response()->json($result, 400);
        }

        return response()->json($result);
    }

    /**
     * 获取文档列表
     */
    public function index()
    {
        $user = Auth::user();
        $documents = Document::where('user_id', $user->id)
            ->orWhereJsonContains('permissions->view', $user->id)
            ->latest()
            ->paginate(10);

        return view('onlyoffice.index', compact('documents'));
    }

    /**
     * 创建新文档
     */
    public function create(Request $request)
    {
        $request->validate([
            'title' => 'required|string|max:255',
            'type' => 'required|in:docx,xlsx,pptx',
        ]);

        $user = Auth::user();
        $key = Str::uuid()->toString();
        $fileName = $request->title . '.' . $request->type;

        // 创建空文件模板
        $templatePath = $this->createTemplate($request->type);
        $filePath = 'onlyoffice/' . $key . '/' . $fileName;
        
        Storage::disk('local')->put($filePath, file_get_contents($templatePath));

        $document = Document::create([
            'title' => $request->title,
            'file_name' => $fileName,
            'file_path' => $filePath,
            'file_type' => $this->getMimeType($request->type),
            'onlyoffice_key' => $key,
            'user_id' => $user->id,
            'permissions' => [
                'edit' => true,
                'download' => true,
                'print' => true,
            ],
        ]);

        return response()->json([
            'success' => true,
            'key' => $key,
            'document' => $document,
        ]);
    }

    /**
     * 创建模板文件
     */
    private function createTemplate(string $type): string
    {
        $templates = [
            'docx' => resource_path('templates/blank.docx'),
            'xlsx' => resource_path('templates/blank.xlsx'),
            'pptx' => resource_path('templates/blank.pptx'),
        ];

        if (!file_exists($templates[$type])) {
            // 如果没有模板,创建空文件
            $tempPath = tempnam(sys_get_temp_dir(), 'onlyoffice_') . '.' . $type;
            file_put_contents($tempPath, '');
            return $tempPath;
        }

        return $templates[$type];
    }

    private function getMimeType(string $extension): string
    {
        $mimeTypes = [
            'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
            'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
            'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
        ];

        return $mimeTypes[$extension] ?? 'application/octet-stream';
    }
}

6. 创建策略(权限控制)

php artisan make:policy DocumentPolicy --model=Document

app/Policies/DocumentPolicy.php

<?php

namespace App\Policies;

use App\Models\Document;
use App\Models\User;

class DocumentPolicy
{
    public function view(User $user, Document $document): bool
    {
        return $document->user_id === $user->id 
            || in_array($user->id, $document->permissions['view'] ?? []);
    }

    public function edit(User $user, Document $document): bool
    {
        return $document->user_id === $user->id 
            || in_array($user->id, $document->permissions['edit'] ?? []);
    }

    public function download(User $user, Document $document): bool
    {
        return $document->user_id === $user->id 
            || in_array($user->id, $document->permissions['download'] ?? []);
    }

    public function delete(User $user, Document $document): bool
    {
        return $document->user_id === $user->id;
    }
}

7. 创建视图

resources/views/onlyoffice/editor.blade.php

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>{{ $document->title }} - OnlyOffice</title>
    <script src="{{ config('onlyoffice.document_server_url') }}/web-apps/apps/api/documents/api.js"></script>
    <style>
        body {
            margin: 0;
            padding: 0;
            height: 100vh;
            overflow: hidden;
        }
        #editor {
            width: 100%;
            height: 100vh;
        }
        .loading {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            font-size: 18px;
            color: #666;
        }
    </style>
</head>
<body>
    <div id="editor"></div>
    
    <script>
        const config = @json($config);
        
        window.onload = function() {
            new DocsAPI.DocEditor("editor", config);
        };

        // 处理页面关闭或离开
        window.addEventListener('beforeunload', function(e) {
            const message = '文档可能未保存,确定要离开吗?';
            e.returnValue = message;
            return message;
        });
    </script>
</body>
</html>

resources/views/onlyoffice/index.blade.php

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row mb-4">
        <div class="col-md-12">
            <div class="card">
                <div class="card-header d-flex justify-content-between align-items-center">
                    <h5 class="mb-0">文档管理</h5>
                    <div>
                        <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
                            上传文档
                        </button>
                        <button class="btn btn-success ms-2" data-bs-toggle="modal" data-bs-target="#createModal">
                            新建文档
                        </button>
                    </div>
                </div>
                <div class="card-body">
                    <div class="table-responsive">
                        <table class="table">
                            <thead>
                                <tr>
                                    <th>文档名称</th>
                                    <th>类型</th>
                                    <th>大小</th>
                                    <th>最后修改</th>
                                    <th>操作</th>
                                </tr>
                            </thead>
                            <tbody>
                                @foreach($documents as $document)
                                <tr>
                                    <td>{{ $document->title }}</td>
                                    <td>
                                        <span class="badge bg-secondary">
                                            {{ strtoupper($document->getFileExtension()) }}
                                        </span>
                                    </td>
                                    <td>{{ number_format(filesize(storage_path('app/' . $document->file_path)) / 1024, 2) }} KB</td>
                                    <td>{{ $document->updated_at->format('Y-m-d H:i') }}</td>
                                    <td>
                                        <a href="{{ route('onlyoffice.editor', $document->onlyoffice_key) }}" 
                                           class="btn btn-sm btn-primary" target="_blank">
                                            编辑
                                        </a>
                                        <a href="{{ route('onlyoffice.download', $document->onlyoffice_key) }}" 
                                           class="btn btn-sm btn-secondary">
                                            下载
                                        </a>
                                    </td>
                                </tr>
                                @endforeach
                            </tbody>
                        </table>
                    </div>
                    
                    {{ $documents->links() }}
                </div>
            </div>
        </div>
    </div>
</div>

<!-- 上传文档 Modal -->
<div class="modal fade" id="uploadModal" tabindex="-1">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">上传文档</h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
            </div>
            <form id="uploadForm" enctype="multipart/form-data">
                @csrf
                <div class="modal-body">
                    <div class="mb-3">
                        <label for="file" class="form-label">选择文件</label>
                        <input type="file" class="form-control" id="file" name="file" accept=".doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.pdf" required>
                        <div class="form-text">支持文档、表格、演示文稿格式,最大10MB</div>
                    </div>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
                    <button type="submit" class="btn btn-primary">上传</button>
                </div>
            </form>
        </div>
    </div>
</div>

<!-- 新建文档 Modal -->
<div class="modal fade" id="createModal" tabindex="-1">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">新建文档</h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
            </div>
            <form id="createForm">
                @csrf
                <div class="modal-body">
                    <div class="mb-3">
                        <label for="title" class="form-label">文档名称</label>
                        <input type="text" class="form-control" id="title" name="title" placeholder="请输入文档名称" required>
                    </div>
                    <div class="mb-3">
                        <label for="type" class="form-label">文档类型</label>
                        <select class="form-select" id="type" name="type" required>
                            <option value="docx">Word 文档 (.docx)</option>
                            <option value="xlsx">Excel 表格 (.xlsx)</option>
                            <option value="pptx">PowerPoint 演示文稿 (.pptx)</option>
                        </select>
                    </div>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
                    <button type="submit" class="btn btn-primary">创建</button>
                </div>
            </form>
        </div>
    </div>
</div>

<script>
// 处理上传表单
document.getElementById('uploadForm').addEventListener('submit', async function(e) {
    e.preventDefault();
    
    const formData = new FormData(this);
    
    try {
        const response = await fetch('{{ route("onlyoffice.upload") }}', {
            method: 'POST',
            body: formData,
            headers: {
                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
            }
        });
        
        const result = await response.json();
        
        if (result.success) {
            alert('上传成功!');
            window.location.reload();
        } else {
            alert('上传失败:' + (result.message || '未知错误'));
        }
    } catch (error) {
        alert('上传失败:' + error.message);
    }
});

// 处理创建表单
document.getElementById('createForm').addEventListener('submit', async function(e) {
    e.preventDefault();
    
    const formData = new FormData(this);
    
    try {
        const response = await fetch('{{ route("onlyoffice.create") }}', {
            method: 'POST',
            body: formData,
            headers: {
                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
            }
        });
        
        const result = await response.json();
        
        if (result.success) {
            alert('创建成功!');
            window.location.href = `/onlyoffice/editor/${result.key}`;
        } else {
            alert('创建失败:' + (result.message || '未知错误'));
        }
    } catch (error) {
        alert('创建失败:' + error.message);
    }
});
</script>
@endsection

8. 路由配置

routes/web.php

<?php

use App\Http\Controllers\OnlyOfficeController;
use Illuminate\Support\Facades\Route;

Route::middleware(['auth'])->group(function () {
    // 文档列表
    Route::get('/documents', [OnlyOfficeController::class, 'index'])
        ->name('onlyoffice.index');
    
    // 文档编辑器
    Route::get('/onlyoffice/editor/{key}', [OnlyOfficeController::class, 'editor'])
        ->name('onlyoffice.editor');
    
    // 上传文档
    Route::post('/onlyoffice/upload', [OnlyOfficeController::class, 'upload'])
        ->name('onlyoffice.upload');
    
    // 创建文档
    Route::post('/onlyoffice/create', [OnlyOfficeController::class, 'create'])
        ->name('onlyoffice.create');
    
    // 下载文档
    Route::get('/onlyoffice/download/{key}', [OnlyOfficeController::class, 'download'])
        ->name('onlyoffice.download');
    
    // OnlyOffice 回调接口
    Route::post('/onlyoffice/callback', [OnlyOfficeController::class, 'callback'])
        ->name('onlyoffice.callback');
});

9. 安装 JWT 包(如果需要 JWT 验证)

composer require firebase/php-jwt

10. OnlyOffice 服务器配置

Docker 方式运行 OnlyOffice Document Server

# docker-compose.yml
version: '3'
services:
  onlyoffice-document-server:
    image: onlyoffice/documentserver
    ports:
      - "8080:80"
    environment:
      - JWT_ENABLED=true
      - JWT_SECRET=your_jwt_secret_key
      - JWT_HEADER=Authorization
    volumes:
      - onlyoffice_data:/var/www/onlyoffice/Data
      - onlyoffice_logs:/var/log/onlyoffice

volumes:
  onlyoffice_data:
  onlyoffice_logs:

11. 安全性考虑

  1. JWT 验证:确保 OnlyOffice 服务器和 Laravel 应用使用相同的 JWT Secret
  2. 文件权限:实现细粒度的权限控制
  3. 文件验证:上传文件时验证文件类型和大小
  4. HTTPS:在生产环境中使用 HTTPS
  5. CSRF 保护:所有表单都有 CSRF 保护
  6. XSS 防护:Blade 模板自动转义输出

12. 部署注意事项

  1. 确保 Laravel 存储目录有写入权限
  2. 配置正确的文件存储路径
  3. 设置合适的文件上传大小限制
  4. 配置 OnlyOffice Document Server 的访问地址
  5. 在生产环境中启用 JWT 验证

这个集成方案提供了完整的文档管理功能,包括上传、编辑、下载和权限控制。你可以根据具体需求调整配置和功能。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐