fix: use pdf previews for documents

This commit is contained in:
2026-05-19 08:44:35 +08:00
parent 7e5a6a3f39
commit 63f2827cc9
14 changed files with 399 additions and 345 deletions

View File

@@ -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 \

View File

@@ -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')

View File

@@ -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);

View File

@@ -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')

View File

@@ -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"',
]);
}

View File

@@ -124,6 +124,8 @@ class DocumentObserver
]);
}
}
app(\App\Services\DocumentPdfPreviewService::class)->clearCachedPreview($document);
} catch (\Exception $e) {
\Log::error('清理文档文件失败', [
'document_id' => $document->id,

View 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);
}
}

View File

@@ -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'),

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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');
});

View File

@@ -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);
}
}

View File

@@ -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');
});