fix: use pdf previews for documents
This commit is contained in:
@@ -25,6 +25,10 @@ RUN apk add --no-cache \
|
|||||||
oniguruma-dev \
|
oniguruma-dev \
|
||||||
# Pandoc文档转换工具
|
# Pandoc文档转换工具
|
||||||
pandoc \
|
pandoc \
|
||||||
|
# LibreOffice用于生成高保真PDF预览,Noto CJK用于中文字体渲染
|
||||||
|
libreoffice \
|
||||||
|
font-noto-cjk \
|
||||||
|
ttf-dejavu \
|
||||||
# Node.js和npm (使用较小的版本)
|
# Node.js和npm (使用较小的版本)
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
@@ -131,4 +135,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|||||||
CMD /usr/local/bin/swoole-health-check.sh || exit 1
|
CMD /usr/local/bin/swoole-health-check.sh || exit 1
|
||||||
|
|
||||||
# 使用supervisor启动多个服务
|
# 使用supervisor启动多个服务
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ class DocumentResource extends Resource
|
|||||||
->modalSubmitAction(false)
|
->modalSubmitAction(false)
|
||||||
->modalCancelActionLabel('关闭'),
|
->modalCancelActionLabel('关闭'),
|
||||||
Tables\Actions\Action::make('preview')
|
Tables\Actions\Action::make('preview')
|
||||||
->label('预览 Markdown')
|
->label('预览 PDF')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn(Document $record): bool => $record->conversion_status === 'completed')
|
->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)) {
|
if ($this->record->markdown_path && Storage::disk('markdown')->exists($this->record->markdown_path)) {
|
||||||
Storage::disk('markdown')->delete($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_path'] = $currentFile;
|
||||||
$data['file_name'] = $data['file_name'] ?? basename($currentFile);
|
$data['file_name'] = $data['file_name'] ?? basename($currentFile);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Filament\Resources\DocumentResource\Pages;
|
namespace App\Filament\Resources\DocumentResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\DocumentResource;
|
use App\Filament\Resources\DocumentResource;
|
||||||
use App\Services\DocumentPreviewService;
|
|
||||||
use App\Services\DocumentService;
|
use App\Services\DocumentService;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Infolists\Components\Section;
|
use Filament\Infolists\Components\Section;
|
||||||
@@ -56,7 +55,7 @@ class ViewDocument extends ViewRecord
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Actions\Action::make('preview')
|
Actions\Action::make('preview')
|
||||||
->label('预览 Markdown')
|
->label('预览 PDF')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn (): bool => $this->record->conversion_status === 'completed')
|
->visible(fn (): bool => $this->record->conversion_status === 'completed')
|
||||||
|
|||||||
@@ -3,27 +3,25 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Document;
|
use App\Models\Document;
|
||||||
|
use App\Services\DocumentPdfPreviewService;
|
||||||
use App\Services\DocumentService;
|
use App\Services\DocumentService;
|
||||||
use App\Services\MarkdownRenderService;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
class DocumentController extends Controller
|
class DocumentController extends Controller
|
||||||
{
|
{
|
||||||
protected DocumentService $documentService;
|
protected DocumentService $documentService;
|
||||||
protected MarkdownRenderService $markdownRenderService;
|
protected DocumentPdfPreviewService $pdfPreviewService;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
DocumentService $documentService,
|
DocumentService $documentService,
|
||||||
MarkdownRenderService $markdownRenderService
|
DocumentPdfPreviewService $pdfPreviewService
|
||||||
) {
|
) {
|
||||||
$this->documentService = $documentService;
|
$this->documentService = $documentService;
|
||||||
$this->markdownRenderService = $markdownRenderService;
|
$this->pdfPreviewService = $pdfPreviewService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预览文档的 Markdown 内容(支持图片显示)
|
* 预览文档的 PDF 内容
|
||||||
* 需求:11.1, 11.3, 11.4
|
* 需求:11.1, 11.3, 11.4
|
||||||
*
|
*
|
||||||
* @param Document $document
|
* @param Document $document
|
||||||
@@ -31,42 +29,37 @@ class DocumentController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function preview(Document $document)
|
public function preview(Document $document)
|
||||||
{
|
{
|
||||||
// 验证用户权限(使用 DocumentPolicy)
|
|
||||||
// 需求:11.3
|
|
||||||
if (!Gate::allows('view', $document)) {
|
if (!Gate::allows('view', $document)) {
|
||||||
abort(403, '您没有权限预览此文档');
|
abort(403, '您没有权限预览此文档');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查文档是否已完成转换
|
return view('documents.preview', [
|
||||||
if ($document->conversion_status !== 'completed') {
|
'document' => $document,
|
||||||
return view('documents.preview', [
|
'canPreviewPdf' => $this->pdfPreviewService->canPreview($document),
|
||||||
'document' => $document,
|
'previewPdfUrl' => $this->pdfPreviewService->previewUrl($document),
|
||||||
'markdownHtml' => null,
|
]);
|
||||||
]);
|
}
|
||||||
|
|
||||||
|
public function previewPdf(Document $document)
|
||||||
|
{
|
||||||
|
if (! Gate::allows('view', $document)) {
|
||||||
|
abort(403, '您没有权限预览此文档');
|
||||||
}
|
}
|
||||||
|
|
||||||
$markdownHtml = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用 DocumentPreviewService 的 Markdown 预览方法
|
$path = $this->pdfPreviewService->getPreviewPath($document);
|
||||||
// 这会修复图片路径并渲染 Markdown
|
} catch (\Throwable $e) {
|
||||||
// 需求:11.1
|
\Log::error('PDF 预览生成失败', [
|
||||||
$previewService = app(\App\Services\DocumentPreviewService::class);
|
|
||||||
$markdownHtml = $previewService->convertMarkdownToHtml($document);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
// 记录错误但不中断流程
|
|
||||||
\Log::error('Markdown 预览失败', [
|
|
||||||
'document_id' => $document->id,
|
'document_id' => $document->id,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
abort(500, 'PDF 预览生成失败:' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理内容为空的情况
|
return response()->file($path, [
|
||||||
// 需求:11.4
|
'Content-Type' => 'application/pdf',
|
||||||
// 返回渲染后的 HTML 视图
|
'Content-Disposition' => 'inline; filename="document-' . $document->getKey() . '.pdf"',
|
||||||
return view('documents.preview', [
|
|
||||||
'document' => $document,
|
|
||||||
'markdownHtml' => $markdownHtml,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ class DocumentObserver
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app(\App\Services\DocumentPdfPreviewService::class)->clearCachedPreview($document);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\Log::error('清理文档文件失败', [
|
\Log::error('清理文档文件失败', [
|
||||||
'document_id' => $document->id,
|
'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,
|
'report' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'previews' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/private/previews'),
|
||||||
|
'visibility' => 'private',
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
's3' => [
|
's3' => [
|
||||||
'driver' => 's3',
|
'driver' => 's3',
|
||||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
|||||||
@@ -5,162 +5,65 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
<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'])
|
@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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body class="bg-gray-100 text-gray-900">
|
||||||
<div class="preview-container">
|
<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="preview-header">
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex-1">
|
<h1 class="truncate text-2xl font-bold">{{ $document->title }}</h1>
|
||||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">{{ $document->title }}</h1>
|
<div class="mt-2 flex flex-wrap gap-4 text-sm text-gray-600">
|
||||||
|
<span>{{ $document->display_file_name }}</span>
|
||||||
<div class="flex flex-wrap gap-4 text-sm text-gray-600">
|
<span>{{ $document->uploader->name }}</span>
|
||||||
<div class="flex items-center gap-1">
|
<span>{{ $document->created_at->format('Y年m月d日 H:i') }}</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($document->description)
|
@if($document->description)
|
||||||
<p class="mt-3 text-gray-700">{{ $document->description }}</p>
|
<p class="mt-3 text-gray-700">{{ $document->description }}</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<a href="{{ route('documents.download', $document) }}"
|
<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">
|
class="inline-flex items-center rounded-lg bg-green-600 px-4 py-2 font-medium text-white transition-colors hover:bg-green-700">
|
||||||
<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>
|
</a>
|
||||||
|
|
||||||
<button onclick="window.print()"
|
@if($canPreviewPdf)
|
||||||
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">
|
<a href="{{ $previewPdfUrl }}"
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
class="inline-flex items-center rounded-lg bg-gray-700 px-4 py-2 font-medium text-white transition-colors hover:bg-gray-800"
|
||||||
<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>
|
target="_blank"
|
||||||
</svg>
|
rel="noopener">
|
||||||
<span>打印</span>
|
打开 PDF
|
||||||
</button>
|
</a>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<!-- Markdown 内容 -->
|
<main class="min-h-[70vh] flex-1 overflow-hidden rounded-lg bg-white shadow-sm">
|
||||||
<div class="preview-content">
|
@if($canPreviewPdf)
|
||||||
@if($markdownHtml)
|
<iframe
|
||||||
{!! $markdownHtml !!}
|
class="w-full bg-gray-100"
|
||||||
|
style="height: calc(100vh - 150px); min-height: 640px;"
|
||||||
|
src="{{ $previewPdfUrl }}"
|
||||||
|
title="{{ $document->title }} PDF 预览"
|
||||||
|
></iframe>
|
||||||
@else
|
@else
|
||||||
<div class="empty-state">
|
<div class="flex min-h-[420px] flex-col items-center justify-center p-8 text-center text-gray-600">
|
||||||
<div class="empty-state-icon">📄</div>
|
<h2 class="mb-2 text-xl font-semibold text-gray-800">PDF 预览暂不可用</h2>
|
||||||
<h2 class="text-xl font-semibold text-gray-700 mb-2">Markdown 内容为空</h2>
|
<p class="mb-6">该文档尚未完成转换或原文件不存在。</p>
|
||||||
<p class="text-gray-600 mb-6">该文档的 Markdown 内容尚未生成或为空</p>
|
<a href="{{ route('documents.download', $document) }}"
|
||||||
<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">
|
||||||
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">
|
</a>
|
||||||
<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>
|
</div>
|
||||||
</svg>
|
|
||||||
<span>下载原始文档</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
@php
|
@php
|
||||||
use App\Services\DocumentPreviewService;
|
use App\Services\DocumentPdfPreviewService;
|
||||||
|
|
||||||
$previewService = app(DocumentPreviewService::class);
|
$previewService = app(DocumentPdfPreviewService::class);
|
||||||
$htmlContent = null;
|
$canPreview = $previewService->canPreview($document);
|
||||||
$error = null;
|
$previewUrl = $canPreview ? $previewService->previewUrl($document) : null;
|
||||||
|
|
||||||
try {
|
|
||||||
$htmlContent = $previewService->convertToHtml($document);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$error = $e->getMessage();
|
|
||||||
}
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="document-preview-modal">
|
<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="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">
|
<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">
|
<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,81 +15,29 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold">预览失败</p>
|
<p class="font-semibold">预览失败</p>
|
||||||
<p class="text-sm">{{ $error }}</p>
|
<p class="text-sm">该文档尚未完成转换或原文件不存在</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="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="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">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
文档内容预览
|
PDF 内容预览
|
||||||
</h3>
|
</h3>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ $document->display_file_name }}
|
{{ $document->display_file_name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-h-[600px] overflow-y-auto p-6">
|
<iframe
|
||||||
<div class="prose prose-sm max-w-none dark:prose-invert">
|
class="w-full rounded-b-lg bg-gray-100 dark:bg-gray-950"
|
||||||
{!! $htmlContent !!}
|
style="height: min(82vh, 960px); min-height: 720px;"
|
||||||
</div>
|
src="{{ $previewUrl }}"
|
||||||
</div>
|
title="{{ $document->title }} PDF 预览"
|
||||||
|
></iframe>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</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
|
@php
|
||||||
use App\Services\DocumentPreviewService;
|
use App\Services\DocumentPdfPreviewService;
|
||||||
|
|
||||||
$previewService = app(DocumentPreviewService::class);
|
$previewService = app(DocumentPdfPreviewService::class);
|
||||||
$canPreview = $previewService->canPreview($document);
|
$canPreview = $previewService->canPreview($document);
|
||||||
$htmlContent = null;
|
$previewUrl = $canPreview ? $previewService->previewUrl($document) : null;
|
||||||
$error = null;
|
|
||||||
|
|
||||||
if ($canPreview) {
|
|
||||||
try {
|
|
||||||
$htmlContent = $previewService->convertToHtml($document);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$error = $e->getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="document-preview-container">
|
<div class="document-preview-container">
|
||||||
@@ -37,7 +28,7 @@
|
|||||||
<p>文档等待转换中...</p>
|
<p>文档等待转换中...</p>
|
||||||
</div>
|
</div>
|
||||||
</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="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">
|
<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">
|
<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>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold">预览加载失败</p>
|
<p class="font-semibold">预览加载失败</p>
|
||||||
<p class="text-sm">{{ $error }}</p>
|
<p class="text-sm">该文档尚未完成转换或原文件不存在</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="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="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">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
文档内容预览
|
PDF 内容预览
|
||||||
</h3>
|
</h3>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ $document->display_file_name }}
|
{{ $document->display_file_name }}
|
||||||
@@ -62,48 +53,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-h-[600px] overflow-y-auto p-6">
|
<iframe
|
||||||
<div class="prose prose-sm max-w-none dark:prose-invert">
|
class="w-full rounded-b-lg bg-gray-100 dark:bg-gray-950"
|
||||||
{!! $htmlContent !!}
|
style="height: min(82vh, 960px); min-height: 720px;"
|
||||||
</div>
|
src="{{ $previewUrl }}"
|
||||||
</div>
|
title="{{ $document->title }} PDF 预览"
|
||||||
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</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::middleware(['auth'])->group(function () {
|
||||||
Route::get('/documents/{document}/preview', [DocumentController::class, 'preview'])
|
Route::get('/documents/{document}/preview', [DocumentController::class, 'preview'])
|
||||||
->name('documents.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'])
|
Route::get('/documents/{document}/download', [DocumentController::class, 'download'])
|
||||||
->name('documents.download');
|
->name('documents.download');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ namespace Tests\Feature;
|
|||||||
|
|
||||||
use App\Models\Document;
|
use App\Models\Document;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\DocumentPreviewService;
|
use App\Services\DocumentPdfPreviewService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\UploadedFile;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -14,56 +13,79 @@ class DocumentPreviewServiceTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
protected DocumentPreviewService $previewService;
|
protected DocumentPdfPreviewService $previewService;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
$this->previewService = new DocumentPreviewService();
|
$this->previewService = new DocumentPdfPreviewService();
|
||||||
Storage::fake('local');
|
Storage::fake('local');
|
||||||
|
Storage::fake('previews');
|
||||||
|
config(['scout.driver' => 'null']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_可以检查文档是否支持预览(): void
|
public function test_可以检查文档是否支持_pdf_预览(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
// 创建一个 .docx 文档
|
|
||||||
$document = Document::factory()->create([
|
$document = Document::factory()->create([
|
||||||
'file_name' => 'test.docx',
|
'conversion_status' => 'completed',
|
||||||
'uploaded_by' => $user->id,
|
'file_path' => 'documents/test.pdf',
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertTrue($this->previewService->canPreview($document));
|
|
||||||
|
|
||||||
// 创建一个 .doc 文档
|
|
||||||
$document2 = Document::factory()->create([
|
|
||||||
'file_name' => 'test.doc',
|
|
||||||
'uploaded_by' => $user->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertTrue($this->previewService->canPreview($document2));
|
|
||||||
|
|
||||||
// 创建一个不支持的格式
|
|
||||||
$document3 = Document::factory()->create([
|
|
||||||
'file_name' => 'test.pdf',
|
'file_name' => 'test.pdf',
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
'uploaded_by' => $user->id,
|
'uploaded_by' => $user->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertFalse($this->previewService->canPreview($document3));
|
Storage::disk('local')->put($document->file_path, '%PDF-1.4 test');
|
||||||
|
|
||||||
|
$this->assertTrue($this->previewService->canPreview($document));
|
||||||
|
|
||||||
|
$document2 = Document::factory()->create([
|
||||||
|
'conversion_status' => 'pending',
|
||||||
|
'file_path' => 'documents/pending.pdf',
|
||||||
|
'file_name' => 'pending.pdf',
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
|
'uploaded_by' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Storage::disk('local')->put($document2->file_path, '%PDF-1.4 test');
|
||||||
|
|
||||||
|
$this->assertFalse($this->previewService->canPreview($document2));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_文档不存在时抛出异常(): void
|
public function test_文档不存在时抛出异常(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$document = Document::factory()->create([
|
$document = Document::factory()->create([
|
||||||
'file_path' => 'documents/2024/01/01/nonexistent.docx',
|
'conversion_status' => 'completed',
|
||||||
'file_name' => 'nonexistent.docx',
|
'file_path' => 'documents/2024/01/01/nonexistent.pdf',
|
||||||
|
'file_name' => 'nonexistent.pdf',
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
'uploaded_by' => $user->id,
|
'uploaded_by' => $user->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->expectException(\Exception::class);
|
$this->expectException(\RuntimeException::class);
|
||||||
$this->expectExceptionMessage('文档文件不存在');
|
$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 App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
Storage::fake('local');
|
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 = User::factory()->create();
|
||||||
|
$user->givePermissionTo('document.view');
|
||||||
$document = Document::factory()->create([
|
$document = Document::factory()->create([
|
||||||
'conversion_status' => 'completed',
|
'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 = $this->actingAs($user)->get(route('documents.preview', $document));
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertSee($document->title);
|
$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 = User::factory()->create();
|
||||||
|
$user->givePermissionTo('document.view');
|
||||||
$document = Document::factory()->create([
|
$document = Document::factory()->create([
|
||||||
'conversion_status' => 'completed',
|
'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 = $this->actingAs($user)->get(route('documents.preview', $document));
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertSee('Markdown 内容为空');
|
$response->assertSee('PDF 预览暂不可用');
|
||||||
$response->assertSee('下载原始文档');
|
$response->assertSee('下载原始文档');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('预览页面显示下载按钮', function () {
|
test('预览页面显示下载按钮', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
$user->givePermissionTo('document.view');
|
||||||
$document = Document::factory()->create([
|
$document = Document::factory()->create([
|
||||||
'conversion_status' => 'completed',
|
'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));
|
$response = $this->actingAs($user)->get(route('documents.preview', $document));
|
||||||
|
|
||||||
@@ -56,3 +71,21 @@ test('预览页面显示下载按钮', function () {
|
|||||||
$response->assertSee('下载原文档');
|
$response->assertSee('下载原文档');
|
||||||
$response->assertSee(route('documents.download', $document));
|
$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