fix: use pdf previews for documents
This commit is contained in:
@@ -25,6 +25,10 @@ RUN apk add --no-cache \
|
||||
oniguruma-dev \
|
||||
# Pandoc文档转换工具
|
||||
pandoc \
|
||||
# LibreOffice用于生成高保真PDF预览,Noto CJK用于中文字体渲染
|
||||
libreoffice \
|
||||
font-noto-cjk \
|
||||
ttf-dejavu \
|
||||
# Node.js和npm (使用较小的版本)
|
||||
nodejs \
|
||||
npm \
|
||||
|
||||
@@ -248,7 +248,7 @@ class DocumentResource extends Resource
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('关闭'),
|
||||
Tables\Actions\Action::make('preview')
|
||||
->label('预览 Markdown')
|
||||
->label('预览 PDF')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('info')
|
||||
->visible(fn(Document $record): bool => $record->conversion_status === 'completed')
|
||||
|
||||
@@ -49,6 +49,7 @@ class EditDocument extends EditRecord
|
||||
if ($this->record->markdown_path && Storage::disk('markdown')->exists($this->record->markdown_path)) {
|
||||
Storage::disk('markdown')->delete($this->record->markdown_path);
|
||||
}
|
||||
app(\App\Services\DocumentPdfPreviewService::class)->clearCachedPreview($this->record);
|
||||
|
||||
$data['file_path'] = $currentFile;
|
||||
$data['file_name'] = $data['file_name'] ?? basename($currentFile);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Filament\Resources\DocumentResource\Pages;
|
||||
|
||||
use App\Filament\Resources\DocumentResource;
|
||||
use App\Services\DocumentPreviewService;
|
||||
use App\Services\DocumentService;
|
||||
use Filament\Actions;
|
||||
use Filament\Infolists\Components\Section;
|
||||
@@ -56,7 +55,7 @@ class ViewDocument extends ViewRecord
|
||||
}
|
||||
}),
|
||||
Actions\Action::make('preview')
|
||||
->label('预览 Markdown')
|
||||
->label('预览 PDF')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('info')
|
||||
->visible(fn (): bool => $this->record->conversion_status === 'completed')
|
||||
|
||||
@@ -3,27 +3,25 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Services\DocumentPdfPreviewService;
|
||||
use App\Services\DocumentService;
|
||||
use App\Services\MarkdownRenderService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DocumentController extends Controller
|
||||
{
|
||||
protected DocumentService $documentService;
|
||||
protected MarkdownRenderService $markdownRenderService;
|
||||
protected DocumentPdfPreviewService $pdfPreviewService;
|
||||
|
||||
public function __construct(
|
||||
DocumentService $documentService,
|
||||
MarkdownRenderService $markdownRenderService
|
||||
DocumentPdfPreviewService $pdfPreviewService
|
||||
) {
|
||||
$this->documentService = $documentService;
|
||||
$this->markdownRenderService = $markdownRenderService;
|
||||
$this->pdfPreviewService = $pdfPreviewService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览文档的 Markdown 内容(支持图片显示)
|
||||
* 预览文档的 PDF 内容
|
||||
* 需求:11.1, 11.3, 11.4
|
||||
*
|
||||
* @param Document $document
|
||||
@@ -31,42 +29,37 @@ class DocumentController extends Controller
|
||||
*/
|
||||
public function preview(Document $document)
|
||||
{
|
||||
// 验证用户权限(使用 DocumentPolicy)
|
||||
// 需求:11.3
|
||||
if (!Gate::allows('view', $document)) {
|
||||
abort(403, '您没有权限预览此文档');
|
||||
}
|
||||
|
||||
// 检查文档是否已完成转换
|
||||
if ($document->conversion_status !== 'completed') {
|
||||
return view('documents.preview', [
|
||||
'document' => $document,
|
||||
'markdownHtml' => null,
|
||||
]);
|
||||
return view('documents.preview', [
|
||||
'document' => $document,
|
||||
'canPreviewPdf' => $this->pdfPreviewService->canPreview($document),
|
||||
'previewPdfUrl' => $this->pdfPreviewService->previewUrl($document),
|
||||
]);
|
||||
}
|
||||
|
||||
public function previewPdf(Document $document)
|
||||
{
|
||||
if (! Gate::allows('view', $document)) {
|
||||
abort(403, '您没有权限预览此文档');
|
||||
}
|
||||
|
||||
$markdownHtml = null;
|
||||
|
||||
try {
|
||||
// 使用 DocumentPreviewService 的 Markdown 预览方法
|
||||
// 这会修复图片路径并渲染 Markdown
|
||||
// 需求:11.1
|
||||
$previewService = app(\App\Services\DocumentPreviewService::class);
|
||||
$markdownHtml = $previewService->convertMarkdownToHtml($document);
|
||||
} catch (\Exception $e) {
|
||||
// 记录错误但不中断流程
|
||||
\Log::error('Markdown 预览失败', [
|
||||
$path = $this->pdfPreviewService->getPreviewPath($document);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('PDF 预览生成失败', [
|
||||
'document_id' => $document->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
abort(500, 'PDF 预览生成失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
// 处理内容为空的情况
|
||||
// 需求:11.4
|
||||
// 返回渲染后的 HTML 视图
|
||||
return view('documents.preview', [
|
||||
'document' => $document,
|
||||
'markdownHtml' => $markdownHtml,
|
||||
return response()->file($path, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="document-' . $document->getKey() . '.pdf"',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,8 @@ class DocumentObserver
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
app(\App\Services\DocumentPdfPreviewService::class)->clearCachedPreview($document);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('清理文档文件失败', [
|
||||
'document_id' => $document->id,
|
||||
|
||||
190
app/Services/DocumentPdfPreviewService.php
Normal file
190
app/Services/DocumentPdfPreviewService.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Document;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class DocumentPdfPreviewService
|
||||
{
|
||||
public function canPreview(Document $document): bool
|
||||
{
|
||||
return $document->conversion_status === 'completed'
|
||||
&& ! empty($document->file_path)
|
||||
&& Storage::disk('local')->exists($document->file_path);
|
||||
}
|
||||
|
||||
public function getPreviewPath(Document $document): string
|
||||
{
|
||||
if (! $this->canPreview($document)) {
|
||||
throw new \RuntimeException('文档尚未完成转换或原文件不存在');
|
||||
}
|
||||
|
||||
if ($this->isPdf($document)) {
|
||||
return Storage::disk('local')->path($document->file_path);
|
||||
}
|
||||
|
||||
$previewPath = $this->cachedPreviewPath($document);
|
||||
|
||||
if (! $this->cachedPreviewIsFresh($document, $previewPath)) {
|
||||
$this->generatePdfPreview($document, $previewPath);
|
||||
}
|
||||
|
||||
return Storage::disk('previews')->path($previewPath);
|
||||
}
|
||||
|
||||
public function previewUrl(Document $document): string
|
||||
{
|
||||
return route('documents.preview-pdf', $document);
|
||||
}
|
||||
|
||||
public function clearCachedPreview(Document $document): void
|
||||
{
|
||||
Storage::disk('previews')->deleteDirectory((string) $document->getKey());
|
||||
}
|
||||
|
||||
protected function isPdf(Document $document): bool
|
||||
{
|
||||
$extension = strtolower(pathinfo($document->display_file_name ?: $document->file_path, PATHINFO_EXTENSION));
|
||||
|
||||
return $document->mime_type === 'application/pdf' || $extension === 'pdf';
|
||||
}
|
||||
|
||||
protected function cachedPreviewPath(Document $document): string
|
||||
{
|
||||
return $document->getKey() . '/preview-libreoffice.pdf';
|
||||
}
|
||||
|
||||
protected function cachedPreviewIsFresh(Document $document, string $previewPath): bool
|
||||
{
|
||||
if (! Storage::disk('previews')->exists($previewPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sourceMtime = Storage::disk('local')->lastModified($document->file_path);
|
||||
$previewMtime = Storage::disk('previews')->lastModified($previewPath);
|
||||
|
||||
return $previewMtime >= $sourceMtime;
|
||||
}
|
||||
|
||||
protected function generatePdfPreview(Document $document, string $previewPath): void
|
||||
{
|
||||
$sourcePath = Storage::disk('local')->path($document->file_path);
|
||||
$absolutePreviewPath = Storage::disk('previews')->path($previewPath);
|
||||
$previewDirectory = dirname($absolutePreviewPath);
|
||||
|
||||
if (! is_dir($previewDirectory) && ! mkdir($previewDirectory, 0775, true) && ! is_dir($previewDirectory)) {
|
||||
throw new \RuntimeException('无法创建 PDF 预览目录');
|
||||
}
|
||||
|
||||
$this->convertWithLibreOffice($sourcePath, $absolutePreviewPath, $previewDirectory);
|
||||
|
||||
if (! file_exists($absolutePreviewPath) || filesize($absolutePreviewPath) === 0) {
|
||||
throw new \RuntimeException('PDF 预览生成失败');
|
||||
}
|
||||
}
|
||||
|
||||
protected function convertWithLibreOffice(string $sourcePath, string $targetPath, string $workingDirectory): void
|
||||
{
|
||||
$binary = $this->resolveLibreOfficeBinary();
|
||||
|
||||
if ($binary === null) {
|
||||
throw new \RuntimeException('无法生成准确的 PDF 预览:服务器未安装 LibreOffice/soffice。请安装 LibreOffice 和中文字体后重试。');
|
||||
}
|
||||
|
||||
$tmpDirectory = $workingDirectory . '/tmp-' . bin2hex(random_bytes(8));
|
||||
$profileDirectory = $tmpDirectory . '/profile';
|
||||
|
||||
if (! mkdir($profileDirectory, 0775, true) && ! is_dir($profileDirectory)) {
|
||||
throw new \RuntimeException('无法创建 LibreOffice 临时目录');
|
||||
}
|
||||
|
||||
$process = new Process([
|
||||
$binary,
|
||||
'--headless',
|
||||
'--nologo',
|
||||
'--nofirststartwizard',
|
||||
'--norestore',
|
||||
'-env:UserInstallation=file://' . $profileDirectory,
|
||||
'--convert-to',
|
||||
'pdf',
|
||||
'--outdir',
|
||||
$tmpDirectory,
|
||||
$sourcePath,
|
||||
]);
|
||||
$process->setTimeout((int) config('documents.conversion.timeout', 300));
|
||||
|
||||
try {
|
||||
$process->mustRun();
|
||||
$convertedPath = $this->findConvertedPdf($tmpDirectory, $sourcePath);
|
||||
|
||||
if ($convertedPath === null) {
|
||||
throw new \RuntimeException(trim($process->getOutput() . "\n" . $process->getErrorOutput()) ?: 'LibreOffice 未输出 PDF 文件');
|
||||
}
|
||||
|
||||
if (! rename($convertedPath, $targetPath)) {
|
||||
throw new \RuntimeException('无法保存 LibreOffice 生成的 PDF 预览');
|
||||
}
|
||||
} catch (ProcessFailedException $e) {
|
||||
throw new \RuntimeException('LibreOffice 转换 PDF 失败:' . trim($e->getProcess()->getErrorOutput() ?: $e->getProcess()->getOutput()), 0, $e);
|
||||
} finally {
|
||||
$this->deleteDirectory($tmpDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
protected function resolveLibreOfficeBinary(): ?string
|
||||
{
|
||||
$configured = env('LIBREOFFICE_BINARY');
|
||||
$candidates = array_filter([
|
||||
is_string($configured) && $configured !== '' ? $configured : null,
|
||||
'/opt/homebrew/bin/soffice',
|
||||
'/opt/homebrew/bin/libreoffice',
|
||||
'/usr/bin/libreoffice',
|
||||
'/usr/bin/soffice',
|
||||
'/usr/local/bin/libreoffice',
|
||||
'/usr/local/bin/soffice',
|
||||
'/Applications/LibreOffice.app/Contents/MacOS/soffice',
|
||||
]);
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (is_executable($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function findConvertedPdf(string $directory, string $sourcePath): ?string
|
||||
{
|
||||
$expectedPath = $directory . '/' . pathinfo($sourcePath, PATHINFO_FILENAME) . '.pdf';
|
||||
|
||||
if (is_file($expectedPath)) {
|
||||
return $expectedPath;
|
||||
}
|
||||
|
||||
$pdfFiles = glob($directory . '/*.pdf') ?: [];
|
||||
|
||||
return $pdfFiles[0] ?? null;
|
||||
}
|
||||
|
||||
protected function deleteDirectory(string $directory): void
|
||||
{
|
||||
if (! is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
||||
}
|
||||
|
||||
rmdir($directory);
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,14 @@ return [
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'previews' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private/previews'),
|
||||
'visibility' => 'private',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
|
||||
@@ -5,162 +5,65 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>{{ $document->title }} - Markdown 预览</title>
|
||||
<title>{{ $document->title }} - PDF 预览</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
@vite(['resources/css/app.css'])
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.preview-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.preview-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="preview-container">
|
||||
<!-- 头部信息 -->
|
||||
<div class="preview-header">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">{{ $document->title }}</h1>
|
||||
|
||||
<div class="flex flex-wrap gap-4 text-sm text-gray-600">
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path>
|
||||
</svg>
|
||||
<span>{{ $document->type === 'global' ? '全局知识库' : '专用知识库' }}</span>
|
||||
</div>
|
||||
|
||||
@if($document->group)
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||
</svg>
|
||||
<span>{{ $document->group->name }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
<span>{{ $document->uploader->name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<span>{{ $document->created_at->format('Y年m月d日 H:i') }}</span>
|
||||
</div>
|
||||
<body class="bg-gray-100 text-gray-900">
|
||||
<div class="mx-auto flex min-h-screen max-w-7xl flex-col gap-4 p-4">
|
||||
<header class="rounded-lg bg-white p-4 shadow-sm">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="truncate text-2xl font-bold">{{ $document->title }}</h1>
|
||||
<div class="mt-2 flex flex-wrap gap-4 text-sm text-gray-600">
|
||||
<span>{{ $document->display_file_name }}</span>
|
||||
<span>{{ $document->uploader->name }}</span>
|
||||
<span>{{ $document->created_at->format('Y年m月d日 H:i') }}</span>
|
||||
</div>
|
||||
|
||||
@if($document->description)
|
||||
<p class="mt-3 text-gray-700">{{ $document->description }}</p>
|
||||
<p class="mt-3 text-gray-700">{{ $document->description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="{{ route('documents.download', $document) }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
<span>下载原文档</span>
|
||||
class="inline-flex items-center rounded-lg bg-green-600 px-4 py-2 font-medium text-white transition-colors hover:bg-green-700">
|
||||
下载原文档
|
||||
</a>
|
||||
|
||||
<button onclick="window.print()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"></path>
|
||||
</svg>
|
||||
<span>打印</span>
|
||||
</button>
|
||||
@if($canPreviewPdf)
|
||||
<a href="{{ $previewPdfUrl }}"
|
||||
class="inline-flex items-center rounded-lg bg-gray-700 px-4 py-2 font-medium text-white transition-colors hover:bg-gray-800"
|
||||
target="_blank"
|
||||
rel="noopener">
|
||||
打开 PDF
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Markdown 内容 -->
|
||||
<div class="preview-content">
|
||||
@if($markdownHtml)
|
||||
{!! $markdownHtml !!}
|
||||
<main class="min-h-[70vh] flex-1 overflow-hidden rounded-lg bg-white shadow-sm">
|
||||
@if($canPreviewPdf)
|
||||
<iframe
|
||||
class="w-full bg-gray-100"
|
||||
style="height: calc(100vh - 150px); min-height: 640px;"
|
||||
src="{{ $previewPdfUrl }}"
|
||||
title="{{ $document->title }} PDF 预览"
|
||||
></iframe>
|
||||
@else
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📄</div>
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-2">Markdown 内容为空</h2>
|
||||
<p class="text-gray-600 mb-6">该文档的 Markdown 内容尚未生成或为空</p>
|
||||
<a href="{{ route('documents.download', $document) }}"
|
||||
class="inline-flex items-center gap-2 px-6 py-3 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
<span>下载原始文档</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex min-h-[420px] flex-col items-center justify-center p-8 text-center text-gray-600">
|
||||
<h2 class="mb-2 text-xl font-semibold text-gray-800">PDF 预览暂不可用</h2>
|
||||
<p class="mb-6">该文档尚未完成转换或原文件不存在。</p>
|
||||
<a href="{{ route('documents.download', $document) }}"
|
||||
class="inline-flex items-center rounded-lg bg-green-600 px-6 py-3 font-medium text-white transition-colors hover:bg-green-700">
|
||||
下载原始文档
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
@php
|
||||
use App\Services\DocumentPreviewService;
|
||||
use App\Services\DocumentPdfPreviewService;
|
||||
|
||||
$previewService = app(DocumentPreviewService::class);
|
||||
$htmlContent = null;
|
||||
$error = null;
|
||||
|
||||
try {
|
||||
$htmlContent = $previewService->convertToHtml($document);
|
||||
} catch (\Exception $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
$previewService = app(DocumentPdfPreviewService::class);
|
||||
$canPreview = $previewService->canPreview($document);
|
||||
$previewUrl = $canPreview ? $previewService->previewUrl($document) : null;
|
||||
@endphp
|
||||
|
||||
<div class="document-preview-modal">
|
||||
@if ($error)
|
||||
@if (! $canPreview)
|
||||
<div class="rounded-lg bg-danger-50 p-4 text-danger-600 dark:bg-danger-400/10 dark:text-danger-400">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
@@ -21,16 +15,16 @@
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold">预览失败</p>
|
||||
<p class="text-sm">{{ $error }}</p>
|
||||
<p class="text-sm">该文档尚未完成转换或原文件不存在</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($htmlContent)
|
||||
@elseif ($previewUrl)
|
||||
<div class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
文档内容预览
|
||||
PDF 内容预览
|
||||
</h3>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $document->display_file_name }}
|
||||
@@ -38,64 +32,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[600px] overflow-y-auto p-6">
|
||||
<div class="prose prose-sm max-w-none dark:prose-invert">
|
||||
{!! $htmlContent !!}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
提示:这是文档的预览版本,可能与原始格式略有差异。如需查看完整格式,请下载文档。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-lg bg-gray-50 p-4 text-gray-600 dark:bg-gray-800 dark:text-gray-400">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="h-5 w-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p>正在加载文档预览...</p>
|
||||
</div>
|
||||
<iframe
|
||||
class="w-full rounded-b-lg bg-gray-100 dark:bg-gray-950"
|
||||
style="height: min(82vh, 960px); min-height: 720px;"
|
||||
src="{{ $previewUrl }}"
|
||||
title="{{ $document->title }} PDF 预览"
|
||||
></iframe>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.document-preview-modal .prose {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
||||
.document-preview-modal .prose table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.document-preview-modal .prose table td,
|
||||
.document-preview-modal .prose table th {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.document-preview-modal .prose table th {
|
||||
background-color: #f9fafb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.document-preview-modal .prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.dark .document-preview-modal .prose table td,
|
||||
.dark .document-preview-modal .prose table th {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.dark .document-preview-modal .prose table th {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
@php
|
||||
use App\Services\DocumentPreviewService;
|
||||
use App\Services\DocumentPdfPreviewService;
|
||||
|
||||
$previewService = app(DocumentPreviewService::class);
|
||||
$previewService = app(DocumentPdfPreviewService::class);
|
||||
$canPreview = $previewService->canPreview($document);
|
||||
$htmlContent = null;
|
||||
$error = null;
|
||||
|
||||
if ($canPreview) {
|
||||
try {
|
||||
$htmlContent = $previewService->convertToHtml($document);
|
||||
} catch (\Exception $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
$previewUrl = $canPreview ? $previewService->previewUrl($document) : null;
|
||||
@endphp
|
||||
|
||||
<div class="document-preview-container">
|
||||
@@ -37,7 +28,7 @@
|
||||
<p>文档等待转换中...</p>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($error)
|
||||
@elseif (! $canPreview)
|
||||
<div class="rounded-lg bg-danger-50 p-4 text-danger-600 dark:bg-danger-400/10 dark:text-danger-400">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
@@ -45,16 +36,16 @@
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold">预览加载失败</p>
|
||||
<p class="text-sm">{{ $error }}</p>
|
||||
<p class="text-sm">该文档尚未完成转换或原文件不存在</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($htmlContent)
|
||||
@elseif ($previewUrl)
|
||||
<div class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
文档内容预览
|
||||
PDF 内容预览
|
||||
</h3>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $document->display_file_name }}
|
||||
@@ -62,48 +53,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[600px] overflow-y-auto p-6">
|
||||
<div class="prose prose-sm max-w-none dark:prose-invert">
|
||||
{!! $htmlContent !!}
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
class="w-full rounded-b-lg bg-gray-100 dark:bg-gray-950"
|
||||
style="height: min(82vh, 960px); min-height: 720px;"
|
||||
src="{{ $previewUrl }}"
|
||||
title="{{ $document->title }} PDF 预览"
|
||||
></iframe>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.document-preview-container .prose {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
|
||||
}
|
||||
|
||||
.document-preview-container .prose table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.document-preview-container .prose table td,
|
||||
.document-preview-container .prose table th {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.document-preview-container .prose table th {
|
||||
background-color: #f9fafb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.document-preview-container .prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.dark .document-preview-container .prose table td,
|
||||
.dark .document-preview-container .prose table th {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.dark .document-preview-container .prose table th {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -93,6 +93,8 @@ Route::get('/guides/pages/{page}', function (\App\Models\GuidePage $page) {
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Route::get('/documents/{document}/preview', [DocumentController::class, 'preview'])
|
||||
->name('documents.preview');
|
||||
Route::get('/documents/{document}/preview-pdf', [DocumentController::class, 'previewPdf'])
|
||||
->name('documents.preview-pdf');
|
||||
Route::get('/documents/{document}/download', [DocumentController::class, 'download'])
|
||||
->name('documents.download');
|
||||
});
|
||||
|
||||
@@ -4,9 +4,8 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\User;
|
||||
use App\Services\DocumentPreviewService;
|
||||
use App\Services\DocumentPdfPreviewService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -14,56 +13,79 @@ class DocumentPreviewServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected DocumentPreviewService $previewService;
|
||||
protected DocumentPdfPreviewService $previewService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->previewService = new DocumentPreviewService();
|
||||
$this->previewService = new DocumentPdfPreviewService();
|
||||
Storage::fake('local');
|
||||
Storage::fake('previews');
|
||||
config(['scout.driver' => 'null']);
|
||||
}
|
||||
|
||||
public function test_可以检查文档是否支持预览(): void
|
||||
public function test_可以检查文档是否支持_pdf_预览(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
// 创建一个 .docx 文档
|
||||
$document = Document::factory()->create([
|
||||
'file_name' => 'test.docx',
|
||||
'conversion_status' => 'completed',
|
||||
'file_path' => 'documents/test.pdf',
|
||||
'file_name' => 'test.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put($document->file_path, '%PDF-1.4 test');
|
||||
|
||||
$this->assertTrue($this->previewService->canPreview($document));
|
||||
|
||||
// 创建一个 .doc 文档
|
||||
$document2 = Document::factory()->create([
|
||||
'file_name' => 'test.doc',
|
||||
'conversion_status' => 'pending',
|
||||
'file_path' => 'documents/pending.pdf',
|
||||
'file_name' => 'pending.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->previewService->canPreview($document2));
|
||||
Storage::disk('local')->put($document2->file_path, '%PDF-1.4 test');
|
||||
|
||||
// 创建一个不支持的格式
|
||||
$document3 = Document::factory()->create([
|
||||
'file_name' => 'test.pdf',
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
$this->assertFalse($this->previewService->canPreview($document3));
|
||||
$this->assertFalse($this->previewService->canPreview($document2));
|
||||
}
|
||||
|
||||
public function test_文档不存在时抛出异常(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$document = Document::factory()->create([
|
||||
'file_path' => 'documents/2024/01/01/nonexistent.docx',
|
||||
'file_name' => 'nonexistent.docx',
|
||||
'conversion_status' => 'completed',
|
||||
'file_path' => 'documents/2024/01/01/nonexistent.pdf',
|
||||
'file_name' => 'nonexistent.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('文档文件不存在');
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('文档尚未完成转换或原文件不存在');
|
||||
|
||||
$this->previewService->convertToHtml($document);
|
||||
$this->previewService->getPreviewPath($document);
|
||||
}
|
||||
|
||||
public function test_非_pdf_文件在缺少_libreoffice_时给出明确错误(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$document = Document::factory()->create([
|
||||
'conversion_status' => 'completed',
|
||||
'file_path' => 'documents/test.docx',
|
||||
'file_name' => 'test.docx',
|
||||
'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put($document->file_path, 'fake-docx-content');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('LibreOffice');
|
||||
|
||||
$this->previewService->getPreviewPath($document);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,51 +4,66 @@ use App\Models\Document;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('local');
|
||||
Storage::fake('previews');
|
||||
config(['scout.driver' => 'null']);
|
||||
|
||||
Permission::findOrCreate('document.view', 'web');
|
||||
});
|
||||
|
||||
test('用户可以预览已转换的文档', function () {
|
||||
test('用户可以打开已转换文档的 PDF 预览页', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->givePermissionTo('document.view');
|
||||
$document = Document::factory()->create([
|
||||
'conversion_status' => 'completed',
|
||||
'markdown_path' => 'markdown/test.md',
|
||||
'file_path' => 'documents/test.pdf',
|
||||
'file_name' => 'test.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put($document->markdown_path, '# 测试标题\n\n这是测试内容。');
|
||||
Storage::disk('local')->put($document->file_path, '%PDF-1.4 test');
|
||||
|
||||
$response = $this->actingAs($user)->get(route('documents.preview', $document));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee($document->title);
|
||||
$response->assertSee('测试标题');
|
||||
$response->assertSee(route('documents.preview-pdf', $document));
|
||||
$response->assertSee('PDF 预览', false);
|
||||
});
|
||||
|
||||
test('预览页面正确处理 Markdown 内容为空的情况', function () {
|
||||
test('预览页面正确处理 PDF 预览不可用的情况', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->givePermissionTo('document.view');
|
||||
$document = Document::factory()->create([
|
||||
'conversion_status' => 'completed',
|
||||
'markdown_path' => null,
|
||||
'file_path' => 'documents/missing.pdf',
|
||||
'file_name' => 'missing.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('documents.preview', $document));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Markdown 内容为空');
|
||||
$response->assertSee('PDF 预览暂不可用');
|
||||
$response->assertSee('下载原始文档');
|
||||
});
|
||||
|
||||
test('预览页面显示下载按钮', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->givePermissionTo('document.view');
|
||||
$document = Document::factory()->create([
|
||||
'conversion_status' => 'completed',
|
||||
'markdown_path' => 'markdown/test.md',
|
||||
'file_path' => 'documents/test.pdf',
|
||||
'file_name' => 'test.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put($document->markdown_path, '# 测试');
|
||||
Storage::disk('local')->put($document->file_path, '%PDF-1.4 test');
|
||||
|
||||
$response = $this->actingAs($user)->get(route('documents.preview', $document));
|
||||
|
||||
@@ -56,3 +71,21 @@ test('预览页面显示下载按钮', function () {
|
||||
$response->assertSee('下载原文档');
|
||||
$response->assertSee(route('documents.download', $document));
|
||||
});
|
||||
|
||||
test('PDF 原文件直接以内联 PDF 响应预览', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->givePermissionTo('document.view');
|
||||
$document = Document::factory()->create([
|
||||
'conversion_status' => 'completed',
|
||||
'file_path' => 'documents/test.pdf',
|
||||
'file_name' => 'test.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put($document->file_path, '%PDF-1.4 test');
|
||||
|
||||
$response = $this->actingAs($user)->get(route('documents.preview-pdf', $document));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertHeader('content-type', 'application/pdf');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user