在 Laravel 12 中集成 OnlyOffice 文档编辑器
·
在 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. 安全性考虑
- JWT 验证:确保 OnlyOffice 服务器和 Laravel 应用使用相同的 JWT Secret
- 文件权限:实现细粒度的权限控制
- 文件验证:上传文件时验证文件类型和大小
- HTTPS:在生产环境中使用 HTTPS
- CSRF 保护:所有表单都有 CSRF 保护
- XSS 防护:Blade 模板自动转义输出
12. 部署注意事项
- 确保 Laravel 存储目录有写入权限
- 配置正确的文件存储路径
- 设置合适的文件上传大小限制
- 配置 OnlyOffice Document Server 的访问地址
- 在生产环境中启用 JWT 验证
这个集成方案提供了完整的文档管理功能,包括上传、编辑、下载和权限控制。你可以根据具体需求调整配置和功能。
更多推荐


所有评论(0)