Compare commits

...

10 Commits

36 changed files with 1686 additions and 677 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 \
@@ -131,4 +135,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD /usr/local/bin/swoole-health-check.sh || exit 1
# 使用supervisor启动多个服务
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@@ -79,6 +79,7 @@ class DocumentResource extends Resource
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
]))
->maxSize(51200) // 50MB
->storeFileNamesIn('file_name')
->disk('local')
->directory('documents/' . date('Y/m/d'))
->visibility('private')
@@ -247,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

@@ -20,7 +20,7 @@ class CreateDocument extends CreateRecord
$filePath = $data['file'];
$data['file_path'] = $filePath;
$data['file_name'] = basename($filePath);
$data['file_name'] = $data['file_name'] ?? basename($filePath);
$data['file_size'] = Storage::disk('local')->size($filePath);
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);

View File

@@ -49,9 +49,10 @@ 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'] = basename($currentFile);
$data['file_name'] = $data['file_name'] ?? basename($currentFile);
$data['file_size'] = Storage::disk('local')->size($currentFile);
$data['mime_type'] = Storage::disk('local')->mimeType($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')
@@ -120,7 +119,7 @@ class ViewDocument extends ViewRecord
TextEntry::make('uploader.name')
->label('上传者'),
TextEntry::make('file_name')
TextEntry::make('display_file_name')
->label('文件名'),
TextEntry::make('file_size')

View File

@@ -193,6 +193,38 @@ class GuideResource extends Resource
])
->actions([
Tables\Actions\EditAction::make()->label('编辑'),
Tables\Actions\Action::make('duplicate')
->label('复制')
->icon('heroicon-o-document-duplicate')
->color('info')
->requiresConfirmation()
->action(function (Guide $record) {
$newGuide = $record->replicate(['pages_count', 'stations_count']);
$newGuide->name = $record->name . ' (副本)';
$newGuide->created_by = auth()->id();
$newGuide->published_at = null;
$newGuide->save();
// 复制页面
$pageIdMap = [];
foreach ($record->pages as $page) {
$newPage = $page->replicate();
$newPage->guide_id = $newGuide->id;
$newPage->save();
$pageIdMap[$page->id] = $newPage->id;
}
// 复制边edges并更新页面 ID 映射
foreach ($record->edges as $edge) {
$newEdge = $edge->replicate();
$newEdge->guide_id = $newGuide->id;
$newEdge->from_page_id = $pageIdMap[$edge->from_page_id] ?? $edge->from_page_id;
$newEdge->to_page_id = $pageIdMap[$edge->to_page_id] ?? $edge->to_page_id;
$newEdge->save();
}
return redirect()->to(route('filament.admin.resources.guides.edit', ['record' => $newGuide]));
}),
Tables\Actions\DeleteAction::make()->label('删除'),
])
->bulkActions([

View File

@@ -18,6 +18,8 @@ class ManageGuidePages extends Page
protected static string $view = 'filament.resources.guide.manage-pages';
protected ?string $maxContentWidth = 'full';
public array $nodes = [];
public array $edges = [];
@@ -30,7 +32,7 @@ class ManageGuidePages extends Page
public function getTitle(): string
{
return $this->getRecord()->name . ' - 页面流程';
return $this->getRecord()->name.' - 页面流程';
}
public function loadGraph(): void
@@ -49,6 +51,18 @@ class ManageGuidePages extends Page
}
}
$pageMap = $pages->keyBy('id');
$incomingEdges = [];
$outgoingEdges = [];
foreach ($pages as $p) {
$incomingEdges[$p->id] = [];
$outgoingEdges[$p->id] = [];
}
foreach ($edgeModels as $e) {
$incomingEdges[$e->to_page_id][] = $e;
$outgoingEdges[$e->from_page_id][] = $e;
}
// BFS from entry nodes (no incoming edges) to assign levels
$hasIncoming = array_flip($edgeModels->pluck('to_page_id')->toArray());
$levels = [];
@@ -56,17 +70,17 @@ class ManageGuidePages extends Page
$queue = [];
foreach ($pages as $p) {
if (!isset($hasIncoming[$p->id])) {
if (! isset($hasIncoming[$p->id])) {
$queue[] = $p->id;
$levels[$p->id] = 0;
$visited[$p->id] = true;
}
}
while (!empty($queue)) {
while (! empty($queue)) {
$cur = array_shift($queue);
foreach ($children[$cur] ?? [] as $child) {
if (!isset($visited[$child])) {
if (! isset($visited[$child])) {
$visited[$child] = true;
$levels[$child] = $levels[$cur] + 1;
$queue[] = $child;
@@ -77,7 +91,7 @@ class ManageGuidePages extends Page
// Orphans at bottom
$maxLevel = empty($levels) ? 0 : max($levels);
foreach ($pages as $p) {
if (!isset($levels[$p->id])) {
if (! isset($levels[$p->id])) {
$levels[$p->id] = $maxLevel + 1;
}
}
@@ -89,35 +103,105 @@ class ManageGuidePages extends Page
}
ksort($levelGroups);
// Compute positions: center each level horizontally, stack vertically
$nodeWidth = 240;
$gapX = 40;
$gapY = 150;
$orders = [];
foreach ($levelGroups as $ids) {
foreach (array_values($ids) as $index => $id) {
$orders[$id] = $index;
}
}
$edgeOffset = function (GuidePageEdge $edge) use ($pageMap): float {
$page = $pageMap->get($edge->from_page_id);
$options = $page?->options ?? [];
$index = $edge->label === null ? false : array_search($edge->label, $options, true);
return $index === false ? 0 : (($index + 1) / (count($options) + 1)) * 0.4;
};
$incomingScore = function (int $id) use (&$orders, $incomingEdges, $levels, $edgeOffset): ?float {
$scores = [];
foreach ($incomingEdges[$id] ?? [] as $edge) {
if (($levels[$edge->from_page_id] ?? null) >= ($levels[$id] ?? null)) {
continue;
}
$scores[] = ($orders[$edge->from_page_id] ?? 0) + $edgeOffset($edge);
}
return empty($scores) ? null : array_sum($scores) / count($scores);
};
$outgoingScore = function (int $id) use (&$orders, $outgoingEdges, $levels): ?float {
$scores = [];
foreach ($outgoingEdges[$id] ?? [] as $edge) {
if (($levels[$edge->to_page_id] ?? null) <= ($levels[$id] ?? null)) {
continue;
}
$scores[] = $orders[$edge->to_page_id] ?? 0;
}
return empty($scores) ? null : array_sum($scores) / count($scores);
};
$sortLevel = function (array &$ids, callable $scoreResolver) use (&$orders): void {
usort($ids, function (int $a, int $b) use ($scoreResolver, $orders): int {
$scoreA = $scoreResolver($a) ?? ($orders[$a] ?? 0);
$scoreB = $scoreResolver($b) ?? ($orders[$b] ?? 0);
return $scoreA <=> $scoreB ?: ($orders[$a] ?? 0) <=> ($orders[$b] ?? 0) ?: $a <=> $b;
});
foreach ($ids as $index => $id) {
$orders[$id] = $index;
}
};
for ($i = 0; $i < 3; $i++) {
foreach ($levelGroups as $level => &$ids) {
if ($level === array_key_first($levelGroups)) {
continue;
}
$sortLevel($ids, $incomingScore);
}
unset($ids);
foreach (array_reverse(array_keys($levelGroups)) as $level) {
if ($level === array_key_last($levelGroups)) {
continue;
}
$sortLevel($levelGroups[$level], $outgoingScore);
}
}
// Compute positions: center each level vertically, stack horizontally (left-to-right)
$nodeWidth = 180; // matches CSS max-width
$nodeHeight = 80; // compact node height
$gapX = 110; // horizontal gap between levels
$gapY = 60; // vertical gap within same level
$positions = [];
foreach ($levelGroups as $level => $ids) {
$count = count($ids);
$totalWidth = $count * $nodeWidth + ($count - 1) * $gapX;
$startX = max(20, (800 - $totalWidth) / 2);
$totalHeight = $count * $nodeHeight + ($count - 1) * $gapY;
$startY = max(20, (600 - $totalHeight) / 2);
foreach ($ids as $i => $id) {
$positions[$id] = [
'x' => (int) ($startX + $i * ($nodeWidth + $gapX)),
'y' => 40 + $level * $gapY,
'x' => 40 + $level * ($nodeWidth + $gapX),
'y' => (int) ($startY + $i * ($nodeHeight + $gapY)),
];
}
}
$this->nodes = $pages->map(fn(GuidePage $p) => [
$this->nodes = $pages->map(fn (GuidePage $p) => [
'id' => $p->id,
'title' => $p->title,
'html_url' => $p->html_url,
'is_entry' => !isset($hasIncoming[$p->id]),
'uri' => $p->uri,
'is_entry' => ! isset($hasIncoming[$p->id]),
'options' => $p->options ?? [],
'x' => $positions[$p->id]['x'] ?? 50,
'y' => $positions[$p->id]['y'] ?? 50,
])->values()->toArray();
$this->edges = $edgeModels->map(fn(GuidePageEdge $e) => [
$this->edges = $edgeModels->map(fn (GuidePageEdge $e) => [
'id' => $e->id,
'from' => $e->from_page_id,
'to' => $e->to_page_id,
@@ -127,13 +211,18 @@ class ManageGuidePages extends Page
// -- Livewire methods called by Drawflow events --
private function dispatchGraphUpdated(): void
{
$this->dispatch('graphUpdated', nodes: $this->nodes, edges: $this->edges);
}
public function addEdge(int $fromPageId, int $toPageId, string $outputClass = 'output_1'): void
{
$guide = $this->getRecord();
if (
!$guide->pages()->where('id', $fromPageId)->exists() ||
!$guide->pages()->where('id', $toPageId)->exists()
! $guide->pages()->where('id', $fromPageId)->exists() ||
! $guide->pages()->where('id', $toPageId)->exists()
) {
return;
}
@@ -156,7 +245,7 @@ class ManageGuidePages extends Page
$options = $page->options ?? [];
$label = null;
if (!empty($options)) {
if (! empty($options)) {
$outputIndex = (int) str_replace('output_', '', $outputClass) - 1;
$label = $options[$outputIndex] ?? null;
}
@@ -168,7 +257,7 @@ class ManageGuidePages extends Page
]);
$this->loadGraph();
$this->dispatch('graphUpdated');
$this->dispatchGraphUpdated();
}
public function removeEdge(int $fromPageId, int $toPageId): void
@@ -179,7 +268,7 @@ class ManageGuidePages extends Page
->delete();
$this->loadGraph();
$this->dispatch('graphUpdated');
$this->dispatchGraphUpdated();
}
// -- Filament Actions --
@@ -194,7 +283,7 @@ class ManageGuidePages extends Page
$this->getRecord()->pages()->create($data);
$this->loadGraph();
$this->dispatch('graphUpdated');
$this->dispatchGraphUpdated();
});
}
@@ -207,7 +296,7 @@ class ManageGuidePages extends Page
$page = $this->getRecord()->pages()->findOrFail($arguments['id']);
$form->fill([
'title' => $page->title,
'html_url' => $page->html_url,
'content' => $page->normalized_content,
'options' => $page->options ?? [],
]);
})
@@ -217,7 +306,30 @@ class ManageGuidePages extends Page
$page->update($data);
$this->loadGraph();
$this->dispatch('graphUpdated');
$this->dispatchGraphUpdated();
});
}
public function copyPageAction(): Action
{
return Action::make('copyPage')
->label('复制页面')
->icon('heroicon-o-document-duplicate')
->requiresConfirmation()
->modalHeading('复制页面')
->modalDescription('确认复制该页面?复制后会生成一个独立的新页面,不会复制连线关系。')
->modalSubmitActionLabel('确认复制')
->action(function (array $arguments): void {
$page = $this->getRecord()->pages()->findOrFail($arguments['id']);
$this->getRecord()->pages()->create([
'title' => $page->title.' - 副本',
'content' => $page->content,
'options' => $page->options ?? [],
]);
$this->loadGraph();
$this->dispatchGraphUpdated();
});
}
@@ -233,7 +345,7 @@ class ManageGuidePages extends Page
$page->delete();
$this->loadGraph();
$this->dispatch('graphUpdated');
$this->dispatchGraphUpdated();
});
}
@@ -249,7 +361,7 @@ class ManageGuidePages extends Page
$edge->delete();
$this->loadGraph();
$this->dispatch('graphUpdated');
$this->dispatchGraphUpdated();
});
}
@@ -261,11 +373,15 @@ class ManageGuidePages extends Page
->required()
->maxLength(255),
Forms\Components\TextInput::make('html_url')
->label('HTML页面URL')
Forms\Components\RichEditor::make('content')
->label('页面内容')
->required()
->url()
->maxLength(500),
->fileAttachmentsDisk('public')
->fileAttachmentsDirectory('guide-pages')
->fileAttachmentsVisibility('public')
->getUploadedAttachmentUrlUsing(fn (string $file): string => GuidePage::uploadedAttachmentUrl($file))
->dehydrateStateUsing(fn (?string $state): string => GuidePage::normalizeRichTextContent($state))
->columnSpanFull(),
Forms\Components\TagsInput::make('options')
->label('分支选项')

View File

@@ -3,9 +3,11 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Document;
use App\Models\Guide;
use App\Models\GuidePage;
use App\Models\GuidePageEdge;
use App\Models\KnowledgeBase;
use App\Services\KnowledgeContextService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -137,7 +139,7 @@ class TerminalApiController extends Controller
$pagesMap[$page->id] = [
'id' => $page->id,
'title' => $page->title,
'html_url' => $page->html_url,
'uri' => $page->uri,
'next' => $next,
];
}
@@ -168,6 +170,66 @@ class TerminalApiController extends Controller
});
}
/**
* GET /api/terminal/documents/{document}/content
* 读取文档全文或指定行号区间
*/
public function documentContent(Request $request, int $documentId): JsonResponse
{
$request->validate([
'start_line' => 'sometimes|integer|min:1',
'end_line' => 'sometimes|integer|min:1',
]);
$terminal = $request->attributes->get('terminal');
// Find document and verify access through station → knowledge_base
$accessibleKbIds = KnowledgeBase::where(function ($q) use ($terminal) {
$q->whereDoesntHave('stations'); // global knowledge bases
if ($terminal->station_id) {
$q->orWhereHas('stations', fn ($sq) => $sq->where('stations.id', $terminal->station_id));
}
})->where('status', 'active')->pluck('id');
$document = Document::where('id', $documentId)
->whereIn('knowledge_base_id', $accessibleKbIds)
->where('conversion_status', 'completed')
->first();
if (!$document) {
return response()->json(['error' => 'Document not found'], 404);
}
$content = $document->getMarkdownContent();
if ($content === null) {
return response()->json(['error' => 'Document content unavailable'], 404);
}
$lines = explode("\n", $content);
$totalLines = count($lines);
$startLine = $request->integer('start_line', 1);
$endLine = $request->integer('end_line', min($startLine + 49, $totalLines));
$endLine = min($endLine, $totalLines);
if ($startLine > $totalLines) {
return response()->json([
'error' => "start_line ({$startLine}) exceeds total lines ({$totalLines})",
], 422);
}
$slice = array_slice($lines, $startLine - 1, $endLine - $startLine + 1);
return response()->json([
'id' => $document->id,
'title' => $document->title,
'total_lines' => $totalLines,
'start_line' => $startLine,
'end_line' => $endLine,
'content' => implode("\n", $slice),
]);
}
/**
* POST /api/terminal/heartbeat
* 终端心跳上报

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

@@ -46,7 +46,8 @@ class ConvertDocumentToMarkdown implements ShouldQueue
$markdownPath = $conversionService->saveMarkdownToFile(
$this->document,
$result['markdown']
$result['markdown'],
$result['media_files'] ?? []
);
$conversionService->updateDocumentMarkdown($this->document, $markdownPath);
@@ -56,36 +57,46 @@ class ConvertDocumentToMarkdown implements ShouldQueue
'document_title' => $this->document->title,
'markdown_path' => $markdownPath,
]);
} catch (\Exception $e) {
} catch (\Throwable $e) {
$exception = $this->normalizeException($e);
Log::error('文档转换失败', [
'document_id' => $this->document->id,
'document_title' => $this->document->title,
'file_name' => $this->document->file_name,
'attempt' => $this->attempts(),
'error' => $e->getMessage(),
'error' => $exception->getMessage(),
]);
if ($this->attempts() >= $this->tries) {
$conversionService->handleConversionFailure($this->document, $e);
$conversionService->handleConversionFailure($this->document, $exception);
}
throw $e;
throw $exception;
}
}
public function failed(\Throwable $exception): void
{
$normalized = $this->normalizeException($exception);
Log::error('文档转换任务最终失败', [
'document_id' => $this->document->id,
'document_title' => $this->document->title,
'file_name' => $this->document->file_name,
'error' => $exception->getMessage(),
'error' => $normalized->getMessage(),
]);
$conversionService = app(DocumentConversionService::class);
$conversionService->handleConversionFailure(
$this->document,
$exception instanceof \Exception ? $exception : new \Exception($exception->getMessage())
);
$conversionService->handleConversionFailure($this->document, $normalized);
}
protected function normalizeException(\Throwable $throwable): \Exception
{
if ($throwable instanceof \Exception) {
return $throwable;
}
return new \RuntimeException($throwable->getMessage(), 0, $throwable);
}
}

View File

@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Laravel\Scout\Searchable;
class Document extends Model
@@ -114,4 +115,33 @@ class Document extends Model
{
return !empty($this->markdown_path) && $this->conversion_status === 'completed';
}
/**
* 获取用于展示和下载的文件名
* 对历史上误保存为随机存储名的记录回退到“标题.扩展名”
*/
public function getDisplayFileNameAttribute(): string
{
$fileName = trim((string) $this->file_name);
if ($fileName !== '' && ! $this->looksLikeGeneratedStorageName($fileName)) {
return $fileName;
}
$extension = pathinfo($fileName ?: $this->file_path, PATHINFO_EXTENSION);
$title = trim((string) $this->title);
$title = preg_replace('/[<>:"\/\\\\|?*\x00-\x1F]+/u', '-', $title) ?? '';
$title = trim($title, " .-\t\n\r\0\x0B");
$title = $title !== '' ? $title : 'document';
return $extension !== '' ? "{$title}.{$extension}" : $title;
}
protected function looksLikeGeneratedStorageName(string $fileName): bool
{
$baseName = pathinfo($fileName, PATHINFO_FILENAME);
return Str::isUuid($baseName)
|| (bool) preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $baseName);
}
}

View File

@@ -9,7 +9,7 @@ class GuidePage extends Model
protected $fillable = [
'guide_id',
'title',
'html_url',
'content',
'options',
];
@@ -25,6 +25,40 @@ class GuidePage extends Model
});
}
public function getUriAttribute(): string
{
return route('guides.pages.show', $this->id);
}
public function getNormalizedContentAttribute(): string
{
return static::normalizeRichTextContent($this->content);
}
public static function normalizeRichTextContent(?string $content): string
{
if (blank($content)) {
return '';
}
$content = preg_replace_callback(
'~(?:https?:)?//[^"\'\s<>()]+(?<path>/storage/guide-pages/[^"\'\s<>()]*)~i',
static fn (array $matches): string => $matches['path'],
$content,
) ?? $content;
return preg_replace(
'~(?<=["\'])storage/guide-pages/~i',
'/storage/guide-pages/',
$content,
) ?? $content;
}
public static function uploadedAttachmentUrl(string $path): string
{
return '/storage/'.ltrim($path, '/');
}
public function guide()
{
return $this->belongsTo(Guide::class);
@@ -55,7 +89,6 @@ class GuidePage extends Model
public function isEntry(): bool
{
return !$this->incomingEdges()->exists();
return ! $this->incomingEdges()->exists();
}
}

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

@@ -6,6 +6,8 @@ use App\Models\Document;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Paperdoc\Contracts\DocumentInterface;
use Paperdoc\Document\Image;
use Paperdoc\Support\DocumentManager;
/**
@@ -23,9 +25,13 @@ class DocumentConversionService
/**
* 将文档转换为 Markdown
*
* @return array{markdown: string, media_files: array<string, string>}
*/
public function convertToMarkdown(Document $document): array
{
$this->ensureConversionDependenciesAvailable();
$documentPath = Storage::disk('local')->path($document->file_path);
if (!file_exists($documentPath)) {
@@ -39,24 +45,96 @@ class DocumentConversionService
throw new \Exception('文档转换后内容为空,可能是扫描件或不支持的内容格式');
}
return ['markdown' => $markdown];
return [
'markdown' => $markdown,
'media_files' => $this->extractMarkdownMediaFiles($doc),
];
}
/**
* 确保文档转换依赖已经安装
*/
protected function ensureConversionDependenciesAvailable(): void
{
if (!class_exists(DocumentManager::class)) {
throw new \RuntimeException(
'文档转换依赖未安装paperdoc-dev/paperdoc-lib。请执行 composer install 后重试。'
);
}
}
/**
* Markdown 内容保存到存储
*
* @param array<string, string> $mediaFiles
*/
public function saveMarkdownToFile(Document $document, string $markdown): string
public function saveMarkdownToFile(Document $document, string $markdown, array $mediaFiles = []): string
{
$path = $this->generateMarkdownPath($document);
$saved = Storage::disk('markdown')->put($path, $markdown);
if (!$saved) {
throw new \Exception("无法保存 Markdown 文件");
throw new \Exception('无法保存 Markdown 文件');
}
$this->storeMarkdownMediaFiles(dirname($path), $mediaFiles);
return $path;
}
/**
* 为已存在的 Markdown 文档补齐缺失的图片资源
*/
public function ensureMarkdownMediaAssets(Document $document): void
{
$this->ensureConversionDependenciesAvailable();
if (empty($document->markdown_path)) {
return;
}
$markdown = $document->getMarkdownContent();
if (empty($markdown)) {
return;
}
if (!preg_match_all('/!\[[^\]]*]\(((?:\.\/)?media\/[^)]+)\)/', $markdown, $matches)) {
return;
}
$documentDir = dirname($document->markdown_path);
$missingRefs = [];
foreach ($matches[1] as $ref) {
$relativePath = $this->normalizeMarkdownMediaPath($ref);
if ($relativePath === null) {
continue;
}
if (!Storage::disk('markdown')->exists($documentDir . '/' . $relativePath)) {
$missingRefs[] = $relativePath;
}
}
if ($missingRefs === []) {
return;
}
$documentPath = Storage::disk('local')->path($document->file_path);
if (!file_exists($documentPath)) {
throw new \Exception("文档文件不存在: {$documentPath}");
}
$doc = DocumentManager::open($documentPath, ['ocr' => false]);
$mediaFiles = array_intersect_key(
$this->extractMarkdownMediaFiles($doc),
array_flip($missingRefs)
);
$this->storeMarkdownMediaFiles($documentDir, $mediaFiles);
}
/**
* 生成 Markdown 文件路径
*/
@@ -103,16 +181,17 @@ class DocumentConversionService
'document_id' => $document->id,
'markdown_path' => $markdownPath,
]);
$preview = '';
} else {
$preview = $this->getMarkdownPreview($markdown);
$this->getMarkdownPreview($markdown);
}
$document->update([
'markdown_path' => $markdownPath,
'conversion_status' => 'completed',
'conversion_error' => null,
]);
Document::withoutSyncingToSearch(function () use ($document, $markdownPath): void {
$document->update([
'markdown_path' => $markdownPath,
'conversion_status' => 'completed',
'conversion_error' => null,
]);
});
}
/**
@@ -128,10 +207,12 @@ class DocumentConversionService
'trace' => $exception->getTraceAsString(),
]);
$document->update([
'conversion_status' => 'failed',
'conversion_error' => $exception->getMessage(),
]);
Document::withoutSyncingToSearch(function () use ($document, $exception): void {
$document->update([
'conversion_status' => 'failed',
'conversion_error' => $exception->getMessage(),
]);
});
}
/**
@@ -139,12 +220,113 @@ class DocumentConversionService
*/
public function queueConversion(Document $document): void
{
$document->update([
'conversion_status' => 'processing',
'conversion_error' => null,
]);
Document::withoutSyncingToSearch(function () use ($document): void {
$document->update([
'conversion_status' => 'processing',
'conversion_error' => null,
]);
});
$queue = config('documents.conversion.queue', 'documents');
\App\Jobs\ConvertDocumentToMarkdown::dispatch($document)->onQueue($queue);
}
/**
* @return array<string, string>
*/
protected function extractMarkdownMediaFiles(DocumentInterface $document): array
{
$mediaFiles = [];
$fallbackIndex = 1;
foreach ($document->getSections() as $section) {
foreach ($section->getElements() as $element) {
if (!$element instanceof Image || !$element->hasData()) {
continue;
}
$relativePath = $this->normalizeMarkdownMediaPath($element->getSrc());
if ($relativePath === null) {
$relativePath = sprintf(
'media/image-%d.%s',
$fallbackIndex++,
$this->guessImageExtension($element)
);
}
$mediaFiles[$relativePath] = $element->getData();
}
}
return $mediaFiles;
}
/**
* @param array<string, string> $mediaFiles
*/
protected function storeMarkdownMediaFiles(string $documentDir, array $mediaFiles): void
{
foreach ($mediaFiles as $relativePath => $contents) {
$targetPath = $documentDir . '/' . ltrim($relativePath, '/');
$targetDirectory = dirname($targetPath);
if ($targetDirectory !== '.' && !Storage::disk('markdown')->exists($targetDirectory)) {
Storage::disk('markdown')->makeDirectory($targetDirectory);
}
Storage::disk('markdown')->put($targetPath, $contents);
}
}
protected function normalizeMarkdownMediaPath(string $path): ?string
{
$path = trim($path);
if ($path === '') {
return null;
}
if (str_contains($path, '://') || str_starts_with($path, 'data:')) {
return null;
}
$path = preg_replace('/^\.?\//', '', $path) ?? $path;
$path = str_replace('\\', '/', $path);
$path = ltrim($path, '/');
if ($path === '' || !str_starts_with($path, 'media/')) {
return null;
}
$segments = array_values(array_filter(
explode('/', $path),
fn (string $segment): bool => $segment !== '' && $segment !== '.'
));
if ($segments === []) {
return null;
}
foreach ($segments as $segment) {
if ($segment === '..') {
return null;
}
}
return implode('/', $segments);
}
protected function guessImageExtension(Image $image): string
{
return match ($image->getMimeType()) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp' => 'bmp',
'image/tiff' => 'tiff',
'image/svg+xml' => 'svg',
default => pathinfo($image->getSrc(), PATHINFO_EXTENSION) ?: 'bin',
};
}
}

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

@@ -3,7 +3,6 @@
namespace App\Services;
use App\Models\Document;
use Illuminate\Support\Facades\Storage;
class DocumentPreviewService
{
@@ -11,8 +10,6 @@ class DocumentPreviewService
* 将文档的 Markdown 内容转换为 HTML 用于预览
* 统一用于 Filament 后台内联预览和独立预览页面
*
* @param Document $document
* @return string HTML 内容
* @throws \Exception
*/
public function convertToHtml(Document $document): string
@@ -23,8 +20,6 @@ class DocumentPreviewService
/**
* Markdown 转换为 HTML
*
* @param Document $document
* @return string HTML 内容
* @throws \Exception
*/
public function convertMarkdownToHtml(Document $document): string
@@ -35,30 +30,57 @@ class DocumentPreviewService
throw new \Exception('Markdown 内容为空');
}
// 获取 Markdown 文件的目录
$markdownDir = dirname($document->markdown_path);
app(DocumentConversionService::class)->ensureMarkdownMediaAssets($document);
// 修复图片路径:将 ./media/ 替换为 /markdown/{dir}/media/
$markdownContent = preg_replace_callback(
'/\(\.\/media\/([^)]+)\)/',
function ($matches) use ($markdownDir) {
$filename = $matches[1];
return '(/markdown/' . $markdownDir . '/media/' . $filename . ')';
},
$markdownContent
);
$markdownContent = $this->stripPreviewFrontMatter($markdownContent);
$markdownContent = $this->rewriteMarkdownMediaPaths($document, $markdownContent);
// 使用 MarkdownRenderService 转换为 HTML
$renderService = app(MarkdownRenderService::class);
return $renderService->render($markdownContent);
}
protected function stripPreviewFrontMatter(string $markdownContent): string
{
if (!preg_match('/\A---\R(?P<frontmatter>.*?\R)---\R*/s', $markdownContent, $matches)) {
return $markdownContent;
}
$frontMatter = $matches['frontmatter'] ?? '';
if (!preg_match('/^(author|source_file):/m', $frontMatter)) {
return $markdownContent;
}
return (string) preg_replace('/\A---\R.*?\R---\R*/s', '', $markdownContent, 1);
}
protected function rewriteMarkdownMediaPaths(Document $document, string $markdownContent): string
{
$documentDir = dirname($document->markdown_path);
return (string) preg_replace_callback(
'/!\[(?<alt>[^\]]*)]\((?<path>(?:\.\/)?media\/[^)]+)\)/',
function (array $matches) use ($documentDir): string {
$relativePath = trim($matches['path'] ?? '');
$relativePath = preg_replace('/^\.?\//', '', $relativePath) ?? $relativePath;
$relativePath = ltrim(str_replace('\\', '/', $relativePath), '/');
$segments = array_filter(
explode('/', $documentDir . '/' . $relativePath),
fn (string $segment): bool => $segment !== ''
);
$url = '/markdown-media/' . implode('/', array_map('rawurlencode', $segments));
return sprintf('![%s](%s)', $matches['alt'] ?? '', $url);
},
$markdownContent
);
}
/**
* 检查文档是否可以预览
*
* @param Document $document
* @return bool
*/
public function canPreview(Document $document): bool
{

View File

@@ -33,7 +33,7 @@ class DocumentService
return Storage::disk('local')->download(
$document->file_path,
$document->file_name
$document->display_file_name
);
}

View File

@@ -37,9 +37,12 @@ class KnowledgeContextService
}
$context .= $snippet . "\n\n";
$fullContent = $document->getMarkdownContent() ?? '';
$sources[] = [
'title' => $document->title,
'id' => 'kb-doc-' . str_pad($document->id, 3, '0', STR_PAD_LEFT),
'id' => $document->id,
'total_lines' => $fullContent !== '' ? substr_count($fullContent, "\n") + 1 : 0,
];
}
@@ -52,11 +55,12 @@ class KnowledgeContextService
private function extractSnippet($document): string
{
$content = $document->getMarkdownContent() ?? $document->description ?? '';
$header = "{$document->title}】(ID:{$document->id})";
if (mb_strlen($content) <= 500) {
return "{$document->title}\n{$content}";
return "{$header}\n{$content}";
}
return "{$document->title}\n" . mb_substr($content, 0, 500) . '...';
return "{$header}\n" . mb_substr($content, 0, 500) . '...';
}
}

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

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('guide_pages', function (Blueprint $table) {
$table->dropColumn('html_url');
$table->longText('content')->nullable()->after('title')->comment('富文本正文HTML');
});
}
public function down(): void
{
Schema::table('guide_pages', function (Blueprint $table) {
$table->dropColumn('content');
$table->string('html_url', 500)->after('title')->comment('HTML页面链接');
});
}
};

View File

@@ -11,8 +11,6 @@ use Illuminate\Database\Seeder;
class GuideSeeder extends Seeder
{
private const BASE_URL = 'https://ssrf.9z.work/guides';
/**
* Run the database seeds.
*/
@@ -46,6 +44,11 @@ class GuideSeeder extends Seeder
$this->command->info(' - 关联线站数量: ' . $stations->count());
}
private function placeholder(string $title): string
{
return '<p>本步骤说明待补充。管理员可在 Filament 后台使用富文本编辑器完善「' . e($title) . '」的操作指引。</p>';
}
private function createHowToUseBeamGuide(User $admin): Guide
{
$this->command->info('创建指引: 如何用光...');
@@ -60,49 +63,47 @@ class GuideSeeder extends Seeder
'published_at' => now(),
]);
$baseUrl = self::BASE_URL . '/how-to-use-beam';
$step1 = GuidePage::create([
'guide_id' => $guide->id,
'title' => '打开光子光闸 PS1',
'html_url' => "{$baseUrl}/step-1.html",
'content' => $this->placeholder('打开光子光闸 PS1'),
]);
$step2 = GuidePage::create([
'guide_id' => $guide->id,
'title' => '搜索光学棚屋',
'html_url' => "{$baseUrl}/step-2.html",
'content' => $this->placeholder('搜索光学棚屋'),
'options' => ['前门12', '后门'],
]);
$step3a = GuidePage::create([
'guide_id' => $guide->id,
'title' => '前门12路径 - 检查设备状态',
'html_url' => "{$baseUrl}/step-3a.html",
'content' => $this->placeholder('前门12路径 - 检查设备状态'),
]);
$step3b = GuidePage::create([
'guide_id' => $guide->id,
'title' => '后门路径 - 安全确认',
'html_url' => "{$baseUrl}/step-3b.html",
'content' => $this->placeholder('后门路径 - 安全确认'),
]);
$step4a = GuidePage::create([
'guide_id' => $guide->id,
'title' => '前门12路径 - 打开实验站光闸',
'html_url' => "{$baseUrl}/step-4a.html",
'content' => $this->placeholder('前门12路径 - 打开实验站光闸'),
]);
$step4b = GuidePage::create([
'guide_id' => $guide->id,
'title' => '后门路径 - 设备检查',
'html_url' => "{$baseUrl}/step-4b.html",
'content' => $this->placeholder('后门路径 - 设备检查'),
]);
$step5 = GuidePage::create([
'guide_id' => $guide->id,
'title' => '完成',
'html_url' => "{$baseUrl}/step-5.html",
'content' => $this->placeholder('完成'),
]);
// step1 → step2 (sequential)
@@ -185,21 +186,20 @@ class GuideSeeder extends Seeder
'published_at' => now(),
]);
$baseUrl = self::BASE_URL . '/vacuum-valve-issue';
$steps = [
['title' => '检查真空度', 'file' => 'step-1.html'],
['title' => '检查联锁状态', 'file' => 'step-2.html'],
['title' => '尝试手动复位', 'file' => 'step-3.html'],
['title' => '检查气动系统', 'file' => 'step-4.html'],
['title' => '联系维护人员', 'file' => 'step-5.html'],
'检查真空度',
'检查联锁状态',
'尝试手动复位',
'检查气动系统',
'联系维护人员',
];
$pages = [];
foreach ($steps as $i => $step) {
foreach ($steps as $title) {
$pages[] = GuidePage::create([
'guide_id' => $guide->id,
'title' => $step['title'],
'html_url' => "{$baseUrl}/{$step['file']}",
'title' => $title,
'content' => $this->placeholder($title),
]);
}
@@ -231,21 +231,20 @@ class GuideSeeder extends Seeder
'published_at' => now(),
]);
$baseUrl = self::BASE_URL . '/water-leak-alarm';
$steps = [
['title' => '确认报警位置', 'file' => 'step-1.html'],
['title' => '搜索光学棚屋', 'file' => 'step-2.html'],
['title' => '定位并处理漏水点', 'file' => 'step-3.html'],
['title' => '复位报警', 'file' => 'step-4.html'],
['title' => '完成', 'file' => 'step-5.html'],
'确认报警位置',
'搜索光学棚屋',
'定位并处理漏水点',
'复位报警',
'完成',
];
$pages = [];
foreach ($steps as $i => $step) {
foreach ($steps as $title) {
$pages[] = GuidePage::create([
'guide_id' => $guide->id,
'title' => $step['title'],
'html_url' => "{$baseUrl}/{$step['file']}",
'title' => $title,
'content' => $this->placeholder($title),
]);
}

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

@@ -12,7 +12,7 @@
</h3>
<div class="mt-2 text-sm text-danger-700 dark:text-danger-300 space-y-1">
<p><strong>文档:</strong>{{ $document->title }}</p>
<p><strong>文件名:</strong>{{ $document->file_name }}</p>
<p><strong>文件名:</strong>{{ $document->display_file_name }}</p>
<p><strong>失败时间:</strong>{{ $document->updated_at->format('Y年m月d日 H:i:s') }}</p>
</div>
</div>

View File

@@ -1,19 +1,13 @@
@php
use App\Services\DocumentPreviewService;
$previewService = app(DocumentPreviewService::class);
$htmlContent = null;
$error = null;
try {
$htmlContent = $previewService->convertToHtml($document);
} catch (\Exception $e) {
$error = $e->getMessage();
}
use App\Services\DocumentPdfPreviewService;
$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,81 +15,29 @@
</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->file_name }}
{{ $document->display_file_name }}
</span>
</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

@@ -12,7 +12,7 @@
</h3>
<div class="mt-3 text-sm text-danger-700 dark:text-danger-300 space-y-1">
<p><strong>文档:</strong>{{ $document->title }}</p>
<p><strong>文件名:</strong>{{ $document->file_name }}</p>
<p><strong>文件名:</strong>{{ $document->display_file_name }}</p>
<p><strong>失败时间:</strong>{{ $document->updated_at->format('Y年m月d日 H:i:s') }}</p>
</div>
</div>

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,65 +36,29 @@
</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->file_name }}
{{ $document->display_file_name }}
</span>
</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

@@ -2,14 +2,14 @@
<link rel="stylesheet" href="{{ asset('vendor/drawflow/drawflow.min.css') }}">
<script src="{{ asset('vendor/drawflow/drawflow.min.js') }}"></script>
<div class="space-y-4">
<div class="guide-flow-page space-y-4" data-guide-flow-component-id="{{ $this->getId() }}">
{{-- Header actions --}}
<div class="flex gap-2">
{{ $this->createPageAction }}
</div>
{{-- Drawflow canvas --}}
<div class="fi-section rounded-xl bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10 overflow-hidden">
<div class="guide-flow-canvas fi-section rounded-xl bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10 overflow-hidden">
<div id="drawflow" wire:ignore></div>
</div>
</div>
@@ -17,336 +17,466 @@
<x-filament-actions::modals />
<style>
.guide-flow-page { width: 100%; }
.guide-flow-canvas { width: 100%; }
#drawflow {
width: 100%;
height: 600px;
height: max(680px, calc(100vh - 14rem));
background-color: #f8fafc;
background-image: radial-gradient(circle, #e2e8f0 1px, transparent 1px);
background-size: 20px 20px;
}
/* Node styling */
@media (max-width: 1024px) { #drawflow { height: max(560px, calc(100vh - 16rem)); } }
@media (max-width: 640px) { #drawflow { height: max(480px, 70vh); } }
/* ── Node ── */
.drawflow .drawflow-node {
border-radius: 12px;
border-radius: 8px;
border: 1px solid #e5e7eb;
background: white;
min-width: 200px;
min-width: 160px;
max-width: 180px;
padding: 0;
box-shadow: 0 1px 3px rgba(0,0,0,.06);
}
.drawflow .drawflow-node.selected {
border-color: #f59e0b;
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.3);
box-shadow: 0 0 0 2px rgba(245,158,11,.3);
}
.drawflow .drawflow-node.entry-node {
border: 2px solid #f59e0b;
}
.drawflow .drawflow-node .drawflow_content_node { padding: 0; }
.drawflow .drawflow-node .drawflow_content_node {
padding: 0;
}
/* Node content */
.df-node-content {
padding: 10px 12px;
}
/* ── Node content ── */
.df-node-content { padding: 8px 10px; }
.df-node-header {
font-weight: 600;
font-size: 13px;
font-size: 12px;
color: #1f2937;
margin-bottom: 2px;
line-height: 1.3;
}
.df-node-url {
font-size: 11px;
color: #9ca3af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
word-break: break-all;
}
.df-node-badge {
display: inline-block;
font-size: 10px;
font-size: 9px;
background: #fef3c7;
color: #b45309;
padding: 1px 6px;
border-radius: 4px;
margin-top: 4px;
padding: 1px 5px;
border-radius: 3px;
margin-top: 3px;
font-weight: 500;
}
.df-node-actions {
margin-top: 6px;
display: flex;
gap: 8px;
margin-top: 5px;
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap;
align-items: center;
gap: 5px;
border-top: 1px solid #f3f4f6;
padding-top: 6px;
padding-top: 5px;
}
.df-node-actions button {
font-size: 11px;
.df-node-actions button,
.df-node-actions a {
display: inline-flex;
width: auto !important;
font-size: 9px;
line-height: 1.2;
color: #6b7280;
cursor: pointer;
background: none;
border: none;
padding: 2px 0;
transition: color 0.15s;
padding: 1px 0;
text-decoration: none;
transition: color .15s;
white-space: nowrap;
}
.df-node-actions button:hover, .df-node-actions a:hover { color: #3b82f6; }
.df-node-actions button.btn-danger:hover { color: #ef4444; }
.df-node-actions button:hover {
color: #3b82f6;
}
.df-node-actions button.btn-danger:hover {
color: #ef4444;
}
/* Connection styling */
/* ── Connections ── */
.drawflow .connection .main-path {
stroke: #94a3b8;
stroke: #64748b;
stroke-width: 2px;
fill: none;
marker-end: url(#arrowhead);
}
.drawflow .connection .main-path:hover {
stroke: #ef4444;
stroke-width: 3px;
stroke-width: 2.5px;
cursor: pointer;
}
/* Port containers: top (input) / bottom (output) */
/* Edge label */
.drawflow .connection-label {
font-size: 10px;
fill: #1e40af;
font-weight: 500;
pointer-events: none;
dominant-baseline: middle;
text-anchor: middle;
}
.drawflow .connection-label-bg {
fill: #dbeafe;
rx: 3;
pointer-events: none;
}
/* ── Ports ── */
.drawflow .drawflow-node .inputs {
position: absolute;
top: -6px;
left: 0;
right: 0;
width: auto !important;
display: flex;
flex-direction: row;
justify-content: center;
left: -6px; top: 0; bottom: 0;
width: auto !important; height: auto !important;
display: flex; flex-direction: column; justify-content: center;
}
.drawflow .drawflow-node .outputs {
position: absolute;
bottom: -6px;
left: 0;
right: 0;
width: auto !important;
display: flex;
flex-direction: row;
right: -6px; top: 0; bottom: 0;
width: auto !important; height: auto !important;
display: flex; flex-direction: column;
justify-content: space-evenly;
gap: 24px;
padding: 0 20px;
gap: 16px; padding: 16px 0;
}
.drawflow .drawflow-node .input { left: 0 !important; top: 0 !important; margin: 0; }
.drawflow .drawflow-node .output { right: 0 !important; top: 0 !important; margin: 0; }
/* Reset Drawflow default offsets on individual ports */
.drawflow .drawflow-node .input {
left: 0 !important;
top: 0 !important;
margin: 0;
}
.drawflow .drawflow-node .output {
right: 0 !important;
top: 0 !important;
margin: 0;
}
/* Port dots */
.drawflow .drawflow-node .input,
.drawflow .drawflow-node .output {
width: 12px;
height: 12px;
width: 11px; height: 11px;
border: 2px solid #94a3b8;
background: white;
position: relative;
}
.drawflow .drawflow-node .input:hover,
.drawflow .drawflow-node .output:hover {
background: #3b82f6;
border-color: #3b82f6;
background: #3b82f6; border-color: #3b82f6;
}
/* Output port labels (below each port) */
/* Output port option label */
.drawflow .drawflow-node .output[data-label]::after {
content: attr(data-label);
position: absolute;
left: 50%;
top: 14px;
transform: translateX(-50%);
font-size: 10px;
left: 13px; top: 50%;
transform: translateY(-50%);
font-size: 9px;
white-space: nowrap;
color: #1e40af;
background: #dbeafe;
padding: 0 5px;
padding: 0 4px;
border-radius: 3px;
font-weight: 500;
pointer-events: none;
line-height: 1.6;
}
.drawflow .drawflow-node .output[data-label].is-connected::after { content: none; }
/* Dark mode */
/* ── Dark mode ── */
.dark #drawflow {
background-color: #111827;
background-image: radial-gradient(circle, #1f2937 1px, transparent 1px);
}
.dark .drawflow .drawflow-node {
background: #1f2937;
border-color: #374151;
}
.dark .df-node-header {
color: #e5e7eb;
}
.dark .df-node-url {
color: #6b7280;
}
.dark .df-node-actions {
border-top-color: #374151;
}
.dark .drawflow .connection .main-path {
stroke: #6b7280;
}
.dark .drawflow .drawflow-node { background: #1f2937; border-color: #374151; }
.dark .df-node-header { color: #e5e7eb; }
.dark .df-node-actions { border-top-color: #374151; }
.dark .drawflow .connection .main-path { stroke: #6b7280; }
.dark .drawflow .drawflow-node .input,
.dark .drawflow .drawflow-node .output {
border-color: #6b7280;
background: #1f2937;
}
.dark .drawflow .drawflow-node .output[data-label]::after {
color: #93c5fd;
background: #1e3a5f;
}
.dark .drawflow .drawflow-node .output { border-color: #6b7280; background: #1f2937; }
.dark .drawflow .connection-label { fill: #93c5fd; }
.dark .drawflow .connection-label-bg { fill: #1e3a5f; }
.dark .drawflow .drawflow-node .output[data-label]::after { color: #93c5fd; background: #1e3a5f; }
</style>
<script>
function initFlowEditor() {
(function () {
const componentId = @js($this->getId());
const pendingImageSelector = 'figure[data-trix-attachment][data-trix-serialize="false"][data-trix-content-type^="image/"]';
function escHtml(str) {
return String(str)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function getGuideFlowComponentId() {
return document.querySelector('.guide-flow-page[data-guide-flow-component-id]')?.dataset.guideFlowComponentId ?? null;
}
function hasPendingImageUploads(form) {
if (!form?.querySelector('.fi-fo-rich-editor')) {
return false;
}
return form.querySelector(pendingImageSelector) !== null;
}
function notifyPendingImageUploads() {
if (typeof window.FilamentNotification !== 'function') {
window.alert('图片仍在上传,请等待上传完成后再保存。');
return;
}
new window.FilamentNotification()
.title('图片未上传完成')
.body('请等待图片上传完成后再保存。')
.warning()
.send();
}
function registerUploadReminder() {
if (window.__guideFlowUploadReminderRegistered) {
return;
}
window.__guideFlowUploadReminderRegistered = true;
document.addEventListener('submit', (event) => {
const form = event.target;
const activeComponentId = getGuideFlowComponentId();
if (!(form instanceof HTMLFormElement) || !activeComponentId) {
return;
}
const actionModal = form.querySelector(`[data-fi-modal-id="${activeComponentId}-action"]`);
if (!actionModal || !hasPendingImageUploads(form)) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
notifyPendingImageUploads();
}, true);
}
function ensureArrowMarker() {
if (document.getElementById('df-arrow-defs')) return;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('id', 'df-arrow-defs');
svg.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden';
svg.innerHTML = `<defs>
<marker id="arrowhead" markerWidth="8" markerHeight="6"
refX="7" refY="3" orient="auto" markerUnits="strokeWidth">
<polygon points="0 0, 8 3, 0 6" fill="#64748b"/>
</marker></defs>`;
document.body.appendChild(svg);
}
function renderConnectionLabels(edges) {
const container = document.getElementById('drawflow');
if (!container || container._dfInit) return;
container._dfInit = true;
if (!container) return;
container.querySelectorAll('.connection-label, .connection-label-bg').forEach(el => el.remove());
const paths = Array.from(container.querySelectorAll('.connection .main-path'));
paths.forEach((path, index) => {
const label = edges[index]?.label;
if (!label) return;
const svg = path.ownerSVGElement;
if (!svg || typeof path.getTotalLength !== 'function') return;
const point = path.getPointAtLength(path.getTotalLength() / 2);
const labelText = String(label);
const labelWidth = Math.max(28, labelText.length * 12 + 12);
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('class', 'connection-label-bg');
rect.setAttribute('x', point.x - labelWidth / 2);
rect.setAttribute('y', point.y - 10);
rect.setAttribute('width', labelWidth);
rect.setAttribute('height', 20);
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('class', 'connection-label');
text.setAttribute('x', point.x);
text.setAttribute('y', point.y);
text.textContent = labelText;
svg.appendChild(rect);
svg.appendChild(text);
});
}
function scheduleConnectionLabelsRender(edges = window._dfCurrentEdges ?? []) {
cancelAnimationFrame(window._dfLabelFrame);
window._dfLabelFrame = requestAnimationFrame(() => renderConnectionLabels(edges));
}
function markConnectedOutputs(edges, pageIdToNodeId) {
const container = document.getElementById('drawflow');
if (!container) return;
container.querySelectorAll('.output.is-connected').forEach(el => el.classList.remove('is-connected'));
edges.forEach(edge => {
if (!edge.label) return;
const fromNodeId = pageIdToNodeId[edge.from];
const node = window._dfCurrentNodes?.find(item => item.id === edge.from);
const outputIndex = node?.options?.indexOf(edge.label) ?? -1;
if (!fromNodeId || outputIndex < 0) return;
const output = container.querySelector(`#node-${fromNodeId} .output_${outputIndex + 1}`);
output?.classList.add('is-connected');
});
}
// ── Build / rebuild the Drawflow graph ───────────────────────────────
function buildGraph(nodes, edges) {
const container = document.getElementById('drawflow');
if (!container) return;
window._dfCurrentNodes = nodes;
window._dfCurrentEdges = edges;
// 销毁旧实例
if (window._dfEditor) {
try { window._dfEditor.clear(); } catch(e) {}
window._dfEditor = null;
}
container.innerHTML = '';
ensureArrowMarker();
const editor = new Drawflow(container);
editor.reroute = false;
editor.curvature = 0.5;
editor.start();
// Override path rendering for vertical (top-down) flow
editor.createCurvature = function(start_x, start_y, end_x, end_y, curvature, type) {
const dy = Math.abs(end_y - start_y);
const offsetY = Math.max(dy * curvature, 50);
return ` M ${start_x} ${start_y} C ${start_x} ${start_y + offsetY} ${end_x} ${end_y - offsetY} ${end_x} ${end_y}`;
// 水平贝塞尔曲线
editor.createCurvature = function(sx, sy, ex, ey, curvature) {
const ox = Math.max(Math.abs(ex - sx) * curvature, 30);
return `M ${sx} ${sy} C ${sx+ox} ${sy} ${ex-ox} ${ey} ${ex} ${ey}`;
};
window._dfEditor = editor;
const nodes = @js($this-> nodes);
const edges = @js($this-> edges);
const pageIdToNodeId = {};
// Add nodes - output count = options.length or 1 (default)
// 添加节点
nodes.forEach(node => {
const numOutputs = (node.options && node.options.length > 0) ? node.options.length : 1;
const html = `
<div class="df-node-content">
<div class="df-node-header">${node.title}</div>
<div class="df-node-url">${node.html_url}</div>
${node.is_entry ? '<div><span class="df-node-badge">入口</span></div>' : ''}
<div class="df-node-actions">
<button onclick="event.stopPropagation(); Livewire.find('${@js($this->getId())}').mountAction('editPage', { id: ${node.id} })">编辑</button>
<button class="btn-danger" onclick="event.stopPropagation(); Livewire.find('${@js($this->getId())}').mountAction('deletePage', { id: ${node.id} })">删除</button>
</div>
</div>`;
const html = `<div class="df-node-content">
<div class="df-node-header">${escHtml(node.title)}</div>
${node.is_entry ? '<div><span class="df-node-badge">入口</span></div>' : ''}
<div class="df-node-actions">
<button onclick="event.stopPropagation();Livewire.find('${componentId}').mountAction('editPage',{id:${node.id}})">编辑</button>
<button onclick="event.stopPropagation();Livewire.find('${componentId}').mountAction('copyPage',{id:${node.id}})">复制</button>
<button class="btn-danger" onclick="event.stopPropagation();Livewire.find('${componentId}').mountAction('deletePage',{id:${node.id}})">删除</button>
<a href="${escHtml(node.uri)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">预览</a>
</div></div>`;
const nodeId = editor.addNode(
'page', 1, numOutputs,
node.x, node.y,
node.is_entry ? 'entry-node' : '', {
pageId: node.id,
options: node.options || []
},
node.is_entry ? 'entry-node' : '',
{ pageId: node.id, options: node.options || [] },
html
);
pageIdToNodeId[node.id] = nodeId;
// Label output ports with option names
// 给输出端口加选项标签
if (node.options && node.options.length > 0) {
setTimeout(() => {
requestAnimationFrame(() => {
const nodeEl = container.querySelector(`#node-${nodeId}`);
if (!nodeEl) return;
const outputs = nodeEl.querySelectorAll('.output');
outputs.forEach((el, i) => {
nodeEl.querySelectorAll('.output').forEach((el, i) => {
if (node.options[i]) {
el.setAttribute('data-label', node.options[i]);
el.title = node.options[i];
}
});
}, 0);
});
}
});
// Add edges - map to correct output port based on label
// 添加连线
edges.forEach(edge => {
const fromNodeId = pageIdToNodeId[edge.from];
const toNodeId = pageIdToNodeId[edge.to];
const toNodeId = pageIdToNodeId[edge.to];
if (!fromNodeId || !toNodeId) return;
// Find which output port matches this edge's label
const fromNode = nodes.find(n => n.id === edge.from);
let outputClass = 'output_1';
if (fromNode && fromNode.options && fromNode.options.length > 0 && edge.label) {
const idx = fromNode.options.indexOf(edge.label);
if (idx >= 0) {
outputClass = `output_${idx + 1}`;
}
if (idx >= 0) outputClass = `output_${idx + 1}`;
}
editor.addConnection(fromNodeId, toNodeId, outputClass, 'input_1');
try { editor.addConnection(fromNodeId, toNodeId, outputClass, 'input_1'); } catch(e) {}
});
// Events → Livewire
let ignoreEvents = false;
markConnectedOutputs(edges, pageIdToNodeId);
editor.on('connectionCreated', (info) => {
if (ignoreEvents) return;
const fromData = editor.getNodeFromId(info.output_id);
const toData = editor.getNodeFromId(info.input_id);
if (fromData && toData) {
@this.call('addEdge', fromData.data.pageId, toData.data.pageId, info.output_class);
scheduleConnectionLabelsRender(edges);
editor.on('nodeMoved', () => scheduleConnectionLabelsRender(edges));
editor.on('translate', () => scheduleConnectionLabelsRender(edges));
editor.on('zoom', () => scheduleConnectionLabelsRender(edges));
container.addEventListener('mouseup', () => scheduleConnectionLabelsRender());
container.addEventListener('touchend', () => scheduleConnectionLabelsRender());
// Drawflow 事件 → Livewire只在本次 editor 实例上绑定)
editor.on('connectionCreated', info => {
const from = editor.getNodeFromId(info.output_id);
const to = editor.getNodeFromId(info.input_id);
if (from && to) {
Livewire.find(componentId).call('addEdge', from.data.pageId, to.data.pageId, info.output_class);
}
scheduleConnectionLabelsRender(edges);
});
editor.on('connectionRemoved', (info) => {
if (ignoreEvents) return;
const fromData = editor.getNodeFromId(info.output_id);
const toData = editor.getNodeFromId(info.input_id);
if (fromData && toData) {
@this.call('removeEdge', fromData.data.pageId, toData.data.pageId);
editor.on('connectionRemoved', info => {
const from = editor.getNodeFromId(info.output_id);
const to = editor.getNodeFromId(info.input_id);
if (from && to) {
Livewire.find(componentId).call('removeEdge', from.data.pageId, to.data.pageId);
}
});
// Livewire graph refresh
Livewire.on('graphUpdated', () => {
ignoreEvents = true;
container._dfInit = false;
editor.clear();
setTimeout(() => {
container._dfInit = false;
container.innerHTML = '';
window._dfEditor = null;
initFlowEditor();
}, 100);
scheduleConnectionLabelsRender(edges);
});
}
// Initialize on page load and SPA navigation
document.addEventListener('DOMContentLoaded', () => setTimeout(initFlowEditor, 50));
document.addEventListener('livewire:navigated', () => setTimeout(initFlowEditor, 50));
// ── Livewire 事件监听(只注册一次,在外层)────────────────────────────
function registerLivewireListener() {
Livewire.on('graphUpdated', (payload) => {
// Livewire 3 dispatch 带命名参数时payload 是 { nodes: [...], edges: [...] }
// 也兼容数组包裹的格式
let data = payload;
if (Array.isArray(payload)) data = payload[0] ?? {};
const nodes = data.nodes ?? null;
const edges = data.edges ?? null;
if (nodes !== null && edges !== null) {
buildGraph(nodes, edges);
}
});
}
// ── 初始化 ────────────────────────────────────────────────────────────
function init() {
buildGraph(
@js($this->nodes),
@js($this->edges)
);
}
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
registerUploadReminder();
registerLivewireListener();
init();
}, 50);
});
document.addEventListener('livewire:navigated', () => {
setTimeout(() => {
registerUploadReminder();
init();
}, 50);
});
})();
</script>
</x-filament-panels::page>

View File

@@ -0,0 +1,80 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $page->title }}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", Roboto, sans-serif;
color: #1f2937;
background: #f8fafc;
line-height: 1.7;
font-size: 16px;
}
article {
max-width: 820px;
margin: 0 auto;
padding: 40px 28px 80px;
}
article h1 { font-size: 28px; margin: 28px 0 16px; }
article h2 { font-size: 22px; margin: 24px 0 12px; }
article h3 { font-size: 18px; margin: 20px 0 10px; }
article p { margin: 12px 0; }
article ul, article ol { padding-left: 28px; margin: 12px 0; }
article li { margin: 4px 0; }
article img { max-width: 100%; height: auto; border-radius: 8px; margin: 12px 0; }
article figure { margin: 12px 0; }
article figure img { margin: 0; }
article figcaption { display: none; }
/* 隐藏 Trix 富文本编辑器生成的图片附件说明文字 */
article figure figcaption,
article .attachment__caption,
article .attachment__name,
article .attachment__size,
article action-text-attachment figcaption { display: none; }
article a { color: #2563eb; text-decoration: underline; }
article blockquote {
border-left: 4px solid #cbd5e1;
background: #f1f5f9;
padding: 8px 16px;
margin: 12px 0;
color: #475569;
}
article code {
background: #e2e8f0;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.92em;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
article pre {
background: #0f172a;
color: #e2e8f0;
padding: 12px 16px;
border-radius: 8px;
overflow-x: auto;
}
article pre code { background: transparent; padding: 0; color: inherit; }
article table {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
}
article th, article td {
border: 1px solid #e2e8f0;
padding: 8px 12px;
text-align: left;
}
article th { background: #f1f5f9; }
</style>
</head>
<body>
<article>
<h1>{{ $page->title }}</h1>
{!! $page->normalized_content !!}
</article>
</body>
</html>

View File

@@ -4,12 +4,15 @@ use App\Http\Controllers\Api\TerminalApiController;
use Illuminate\Support\Facades\Route;
Route::middleware('identify.terminal')->group(function () {
Route::get('/knowledge', [TerminalApiController::class, 'knowledge']);
Route::prefix('terminal')->group(function () {
Route::post('/heartbeat', [TerminalApiController::class, 'heartbeat']);
Route::get('/config', [TerminalApiController::class, 'config']);
Route::get('/guides', [TerminalApiController::class, 'guides']);
Route::post('/guides/pages', [TerminalApiController::class, 'guidePages']);
Route::post('/heartbeat', [TerminalApiController::class, 'heartbeat']);
Route::get('/knowledge', [TerminalApiController::class, 'knowledge']);
Route::get('/documents/{document}/content', [TerminalApiController::class, 'documentContent']);
});
});

View File

@@ -84,27 +84,40 @@ Route::get('/health', function () {
], $httpCode);
})->name('health.check');
// 指引步骤渲染(供终端 webview 打开,公开访问)
Route::get('/guides/pages/{page}', function (\App\Models\GuidePage $page) {
return view('guides.page', ['page' => $page]);
})->name('guides.pages.show');
// 文档预览和下载路由(需要认证)
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');
});
// 提供 markdown 目录中 media 文件的访问(需要认证)
// 路径格式: /markdown/{path}/media/{filename}
// 其中 path 可以是: 2025/12/04/{uuid} 或 {uuid}
Route::middleware(['auth'])->get('/markdown/{path}/media/{filename}', function ($path, $filename) {
// 构建完整路径
$fullPath = $path . '/media/' . $filename;
if (!Storage::disk('markdown')->exists($fullPath)) {
// 路径格式: /markdown-media/{path}
Route::middleware(['auth'])->get('/markdown-media/{path}', function ($path) {
$path = trim((string) $path, '/');
if ($path === '' || str_contains($path, '../')) {
abort(404);
}
$file = Storage::disk('markdown')->get($fullPath);
$mimeType = Storage::disk('markdown')->mimeType($fullPath);
if (!Storage::disk('markdown')->exists($path)) {
abort(404);
}
$mimeType = Storage::disk('markdown')->mimeType($path);
if (!is_string($mimeType) || !str_starts_with($mimeType, 'image/')) {
abort(404);
}
$file = Storage::disk('markdown')->get($path);
return response($file, 200)->header('Content-Type', $mimeType);
})->where('path', '.*')->where('filename', '[^/]+')->name('markdown.media');
})->where('path', '.*')->name('markdown.media');

View File

@@ -0,0 +1,101 @@
<?php
namespace Tests\Feature;
use App\Models\Document;
use App\Services\DocumentConversionService;
use App\Services\DocumentPreviewService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class DocumentPreviewFormattingTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('markdown');
config(['scout.driver' => 'null']);
}
public function test_markdown_preview_strips_internal_front_matter(): void
{
$document = Document::factory()->converted()->create([
'title' => '技术文档',
'markdown_path' => '2026/04/24/test/test.md',
]);
Storage::disk('markdown')->put($document->markdown_path, <<<'MD'
---
author: 利爪然死肥宅
source_file: /tmp/demo.docx
---
# 正文标题
这是正文内容。
MD);
$html = app(DocumentPreviewService::class)->convertMarkdownToHtml($document);
$this->assertStringContainsString('正文标题', $html);
$this->assertStringContainsString('这是正文内容。', $html);
$this->assertStringNotContainsString('author:', $html);
$this->assertStringNotContainsString('source_file:', $html);
}
public function test_markdown_preview_rewrites_media_links_to_authenticated_route(): void
{
$document = Document::factory()->converted()->create([
'title' => '技术文档',
'markdown_path' => '2026/04/24/test-links/test.md',
]);
Storage::disk('markdown')->put($document->markdown_path, <<<'MD'
# 图片示例
![示意图](media/image2.png)
MD);
Storage::disk('markdown')->put('2026/04/24/test-links/media/image2.png', 'fake-image-binary');
$html = app(DocumentPreviewService::class)->convertMarkdownToHtml($document);
$this->assertStringContainsString('/markdown-media/2026/04/24/test-links/media/image2.png', $html);
}
public function test_generated_storage_name_falls_back_to_title_for_display(): void
{
$document = Document::factory()->create([
'title' => '技术文档',
'file_name' => '01KPW4SQJTT5X15QPZ412WGSFM.docx',
'file_path' => 'documents/2026/04/23/01KPW4SQJTT5X15QPZ412WGSFM.docx',
]);
$this->assertSame('技术文档.docx', $document->display_file_name);
}
public function test_save_markdown_to_file_persists_media_assets(): void
{
$document = Document::factory()->create([
'title' => '图片文档',
]);
$path = app(DocumentConversionService::class)->saveMarkdownToFile(
$document,
"# 图片文档\n\n![示意图](media/image2.png)\n",
[
'media/image2.png' => 'image-binary',
'media/nested/image3.png' => 'nested-image-binary',
]
);
$documentDir = dirname($path);
Storage::disk('markdown')->assertExists($path);
Storage::disk('markdown')->assertExists($documentDir . '/media/image2.png');
Storage::disk('markdown')->assertExists($documentDir . '/media/nested/image3.png');
}
}

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',
'uploaded_by' => $user->id,
]);
$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([
'conversion_status' => 'completed',
'file_path' => 'documents/test.pdf',
'file_name' => 'test.pdf',
'mime_type' => 'application/pdf',
'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
{
$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->previewService->convertToHtml($document);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('文档尚未完成转换或原文件不存在');
$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');
});

View File

@@ -0,0 +1,45 @@
<?php
namespace Tests\Feature;
use App\Filament\Resources\GuideResource;
use App\Models\Guide;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Permission;
use Tests\TestCase;
class ManageGuidePagesUploadReminderTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function it_renders_the_pending_image_upload_save_reminder_on_manage_guide_pages(): void
{
config(['app.env' => 'local']);
$user = User::factory()->create();
Permission::findOrCreate('guide.view', 'web');
Permission::findOrCreate('guide.update', 'web');
$user->givePermissionTo(['guide.view', 'guide.update']);
$guide = Guide::create([
'name' => '测试指引',
'category' => 'operation',
'status' => 'draft',
'created_by' => $user->id,
]);
$response = $this
->actingAs($user)
->get(GuideResource::getUrl('manage-pages', ['record' => $guide]));
$response->assertOk();
$response->assertSee('data-guide-flow-component-id="', false);
$response->assertSee('hasPendingImageUploads', false);
$response->assertSee('图片仍在上传,请等待上传完成后再保存。');
$response->assertSee('图片未上传完成');
}
}

View File

@@ -141,6 +141,35 @@ class SwooleQueueCompatibilityTest extends TestCase
$job->handle($conversionService);
}
/**
* 测试队列任务可以包装底层 Throwable
*
* @test
*/
public function test_queue_job_wraps_throwables_from_conversion_service()
{
$user = User::factory()->create();
$document = Document::factory()->create([
'uploaded_by' => $user->id,
'title' => 'Throwable 测试文档',
'file_path' => 'throwable-test.docx',
]);
$conversionService = $this->createMock(DocumentConversionService::class);
$conversionService->expects($this->once())
->method('convertToMarkdown')
->willThrowException(new \Error('底层转换错误'));
$this->app->instance(DocumentConversionService::class, $conversionService);
$job = new ConvertDocumentToMarkdown($document);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('底层转换错误');
$job->handle($conversionService);
}
/**
* 测试队列任务的重试机制
*
@@ -243,4 +272,4 @@ class SwooleQueueCompatibilityTest extends TestCase
$jobDocument = $documentProperty->getValue($unserialized);
$this->assertEquals($document->id, $jobDocument->id);
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
use App\Models\GuidePage;
it('normalizes uploaded guide image urls for rendering', function () {
$page = new GuidePage([
'content' => <<<'HTML'
<figure data-trix-attachment='{"url":"http://localhost:8000/storage/guide-pages/example.png?signature=abc"}'>
<img src="http://localhost:8000/storage/guide-pages/example.png?signature=abc">
</figure>
HTML,
]);
expect($page->normalized_content)
->toContain('/storage/guide-pages/example.png?signature=abc')
->not->toContain('http://localhost:8000/storage/guide-pages/example.png?signature=abc');
});
it('adds a leading slash to relative guide image urls', function () {
$page = new GuidePage([
'content' => '<img src="storage/guide-pages/example.png">',
]);
expect($page->normalized_content)
->toBe('<img src="/storage/guide-pages/example.png">');
});
it('keeps external image urls unchanged', function () {
$page = new GuidePage([
'content' => '<img src="https://example.com/images/example.png">',
]);
expect($page->normalized_content)
->toBe('<img src="https://example.com/images/example.png">');
});
it('builds root relative upload urls for new attachments', function () {
expect(GuidePage::uploadedAttachmentUrl('guide-pages/example.png'))
->toBe('/storage/guide-pages/example.png');
});