Compare commits
10 Commits
ad0add4500
...
63f2827cc9
| Author | SHA1 | Date | |
|---|---|---|---|
| 63f2827cc9 | |||
| 7e5a6a3f39 | |||
| 0e73a77b86 | |||
| e935afddfe | |||
| 37dd58eff0 | |||
| a917338d0c | |||
| 1f9ee979f1 | |||
| 0b35e54fe1 | |||
| 6acd0ccad0 | |||
| 295cf12899 |
@@ -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"]
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ class DocumentResource extends Resource
|
|||||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
]))
|
]))
|
||||||
->maxSize(51200) // 50MB
|
->maxSize(51200) // 50MB
|
||||||
|
->storeFileNamesIn('file_name')
|
||||||
->disk('local')
|
->disk('local')
|
||||||
->directory('documents/' . date('Y/m/d'))
|
->directory('documents/' . date('Y/m/d'))
|
||||||
->visibility('private')
|
->visibility('private')
|
||||||
@@ -247,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')
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class CreateDocument extends CreateRecord
|
|||||||
$filePath = $data['file'];
|
$filePath = $data['file'];
|
||||||
|
|
||||||
$data['file_path'] = $filePath;
|
$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['file_size'] = Storage::disk('local')->size($filePath);
|
||||||
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);
|
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);
|
||||||
|
|
||||||
|
|||||||
@@ -49,9 +49,10 @@ 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'] = basename($currentFile);
|
$data['file_name'] = $data['file_name'] ?? basename($currentFile);
|
||||||
$data['file_size'] = Storage::disk('local')->size($currentFile);
|
$data['file_size'] = Storage::disk('local')->size($currentFile);
|
||||||
$data['mime_type'] = Storage::disk('local')->mimeType($currentFile);
|
$data['mime_type'] = Storage::disk('local')->mimeType($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')
|
||||||
@@ -120,7 +119,7 @@ class ViewDocument extends ViewRecord
|
|||||||
TextEntry::make('uploader.name')
|
TextEntry::make('uploader.name')
|
||||||
->label('上传者'),
|
->label('上传者'),
|
||||||
|
|
||||||
TextEntry::make('file_name')
|
TextEntry::make('display_file_name')
|
||||||
->label('文件名'),
|
->label('文件名'),
|
||||||
|
|
||||||
TextEntry::make('file_size')
|
TextEntry::make('file_size')
|
||||||
|
|||||||
@@ -193,6 +193,38 @@ class GuideResource extends Resource
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Tables\Actions\EditAction::make()->label('编辑'),
|
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('删除'),
|
Tables\Actions\DeleteAction::make()->label('删除'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ class ManageGuidePages extends Page
|
|||||||
|
|
||||||
protected static string $view = 'filament.resources.guide.manage-pages';
|
protected static string $view = 'filament.resources.guide.manage-pages';
|
||||||
|
|
||||||
|
protected ?string $maxContentWidth = 'full';
|
||||||
|
|
||||||
public array $nodes = [];
|
public array $nodes = [];
|
||||||
|
|
||||||
public array $edges = [];
|
public array $edges = [];
|
||||||
@@ -30,7 +32,7 @@ class ManageGuidePages extends Page
|
|||||||
|
|
||||||
public function getTitle(): string
|
public function getTitle(): string
|
||||||
{
|
{
|
||||||
return $this->getRecord()->name . ' - 页面流程';
|
return $this->getRecord()->name.' - 页面流程';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function loadGraph(): void
|
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
|
// BFS from entry nodes (no incoming edges) to assign levels
|
||||||
$hasIncoming = array_flip($edgeModels->pluck('to_page_id')->toArray());
|
$hasIncoming = array_flip($edgeModels->pluck('to_page_id')->toArray());
|
||||||
$levels = [];
|
$levels = [];
|
||||||
@@ -56,17 +70,17 @@ class ManageGuidePages extends Page
|
|||||||
$queue = [];
|
$queue = [];
|
||||||
|
|
||||||
foreach ($pages as $p) {
|
foreach ($pages as $p) {
|
||||||
if (!isset($hasIncoming[$p->id])) {
|
if (! isset($hasIncoming[$p->id])) {
|
||||||
$queue[] = $p->id;
|
$queue[] = $p->id;
|
||||||
$levels[$p->id] = 0;
|
$levels[$p->id] = 0;
|
||||||
$visited[$p->id] = true;
|
$visited[$p->id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while (!empty($queue)) {
|
while (! empty($queue)) {
|
||||||
$cur = array_shift($queue);
|
$cur = array_shift($queue);
|
||||||
foreach ($children[$cur] ?? [] as $child) {
|
foreach ($children[$cur] ?? [] as $child) {
|
||||||
if (!isset($visited[$child])) {
|
if (! isset($visited[$child])) {
|
||||||
$visited[$child] = true;
|
$visited[$child] = true;
|
||||||
$levels[$child] = $levels[$cur] + 1;
|
$levels[$child] = $levels[$cur] + 1;
|
||||||
$queue[] = $child;
|
$queue[] = $child;
|
||||||
@@ -77,7 +91,7 @@ class ManageGuidePages extends Page
|
|||||||
// Orphans at bottom
|
// Orphans at bottom
|
||||||
$maxLevel = empty($levels) ? 0 : max($levels);
|
$maxLevel = empty($levels) ? 0 : max($levels);
|
||||||
foreach ($pages as $p) {
|
foreach ($pages as $p) {
|
||||||
if (!isset($levels[$p->id])) {
|
if (! isset($levels[$p->id])) {
|
||||||
$levels[$p->id] = $maxLevel + 1;
|
$levels[$p->id] = $maxLevel + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,35 +103,105 @@ class ManageGuidePages extends Page
|
|||||||
}
|
}
|
||||||
ksort($levelGroups);
|
ksort($levelGroups);
|
||||||
|
|
||||||
// Compute positions: center each level horizontally, stack vertically
|
$orders = [];
|
||||||
$nodeWidth = 240;
|
foreach ($levelGroups as $ids) {
|
||||||
$gapX = 40;
|
foreach (array_values($ids) as $index => $id) {
|
||||||
$gapY = 150;
|
$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 = [];
|
$positions = [];
|
||||||
|
|
||||||
foreach ($levelGroups as $level => $ids) {
|
foreach ($levelGroups as $level => $ids) {
|
||||||
$count = count($ids);
|
$count = count($ids);
|
||||||
$totalWidth = $count * $nodeWidth + ($count - 1) * $gapX;
|
$totalHeight = $count * $nodeHeight + ($count - 1) * $gapY;
|
||||||
$startX = max(20, (800 - $totalWidth) / 2);
|
$startY = max(20, (600 - $totalHeight) / 2);
|
||||||
foreach ($ids as $i => $id) {
|
foreach ($ids as $i => $id) {
|
||||||
$positions[$id] = [
|
$positions[$id] = [
|
||||||
'x' => (int) ($startX + $i * ($nodeWidth + $gapX)),
|
'x' => 40 + $level * ($nodeWidth + $gapX),
|
||||||
'y' => 40 + $level * $gapY,
|
'y' => (int) ($startY + $i * ($nodeHeight + $gapY)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->nodes = $pages->map(fn(GuidePage $p) => [
|
$this->nodes = $pages->map(fn (GuidePage $p) => [
|
||||||
'id' => $p->id,
|
'id' => $p->id,
|
||||||
'title' => $p->title,
|
'title' => $p->title,
|
||||||
'html_url' => $p->html_url,
|
'uri' => $p->uri,
|
||||||
'is_entry' => !isset($hasIncoming[$p->id]),
|
'is_entry' => ! isset($hasIncoming[$p->id]),
|
||||||
'options' => $p->options ?? [],
|
'options' => $p->options ?? [],
|
||||||
'x' => $positions[$p->id]['x'] ?? 50,
|
'x' => $positions[$p->id]['x'] ?? 50,
|
||||||
'y' => $positions[$p->id]['y'] ?? 50,
|
'y' => $positions[$p->id]['y'] ?? 50,
|
||||||
])->values()->toArray();
|
])->values()->toArray();
|
||||||
|
|
||||||
$this->edges = $edgeModels->map(fn(GuidePageEdge $e) => [
|
$this->edges = $edgeModels->map(fn (GuidePageEdge $e) => [
|
||||||
'id' => $e->id,
|
'id' => $e->id,
|
||||||
'from' => $e->from_page_id,
|
'from' => $e->from_page_id,
|
||||||
'to' => $e->to_page_id,
|
'to' => $e->to_page_id,
|
||||||
@@ -127,13 +211,18 @@ class ManageGuidePages extends Page
|
|||||||
|
|
||||||
// -- Livewire methods called by Drawflow events --
|
// -- 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
|
public function addEdge(int $fromPageId, int $toPageId, string $outputClass = 'output_1'): void
|
||||||
{
|
{
|
||||||
$guide = $this->getRecord();
|
$guide = $this->getRecord();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!$guide->pages()->where('id', $fromPageId)->exists() ||
|
! $guide->pages()->where('id', $fromPageId)->exists() ||
|
||||||
!$guide->pages()->where('id', $toPageId)->exists()
|
! $guide->pages()->where('id', $toPageId)->exists()
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -156,7 +245,7 @@ class ManageGuidePages extends Page
|
|||||||
$options = $page->options ?? [];
|
$options = $page->options ?? [];
|
||||||
$label = null;
|
$label = null;
|
||||||
|
|
||||||
if (!empty($options)) {
|
if (! empty($options)) {
|
||||||
$outputIndex = (int) str_replace('output_', '', $outputClass) - 1;
|
$outputIndex = (int) str_replace('output_', '', $outputClass) - 1;
|
||||||
$label = $options[$outputIndex] ?? null;
|
$label = $options[$outputIndex] ?? null;
|
||||||
}
|
}
|
||||||
@@ -168,7 +257,7 @@ class ManageGuidePages extends Page
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->loadGraph();
|
$this->loadGraph();
|
||||||
$this->dispatch('graphUpdated');
|
$this->dispatchGraphUpdated();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function removeEdge(int $fromPageId, int $toPageId): void
|
public function removeEdge(int $fromPageId, int $toPageId): void
|
||||||
@@ -179,7 +268,7 @@ class ManageGuidePages extends Page
|
|||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
$this->loadGraph();
|
$this->loadGraph();
|
||||||
$this->dispatch('graphUpdated');
|
$this->dispatchGraphUpdated();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Filament Actions --
|
// -- Filament Actions --
|
||||||
@@ -194,7 +283,7 @@ class ManageGuidePages extends Page
|
|||||||
$this->getRecord()->pages()->create($data);
|
$this->getRecord()->pages()->create($data);
|
||||||
|
|
||||||
$this->loadGraph();
|
$this->loadGraph();
|
||||||
$this->dispatch('graphUpdated');
|
$this->dispatchGraphUpdated();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +296,7 @@ class ManageGuidePages extends Page
|
|||||||
$page = $this->getRecord()->pages()->findOrFail($arguments['id']);
|
$page = $this->getRecord()->pages()->findOrFail($arguments['id']);
|
||||||
$form->fill([
|
$form->fill([
|
||||||
'title' => $page->title,
|
'title' => $page->title,
|
||||||
'html_url' => $page->html_url,
|
'content' => $page->normalized_content,
|
||||||
'options' => $page->options ?? [],
|
'options' => $page->options ?? [],
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
@@ -217,7 +306,30 @@ class ManageGuidePages extends Page
|
|||||||
$page->update($data);
|
$page->update($data);
|
||||||
|
|
||||||
$this->loadGraph();
|
$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();
|
$page->delete();
|
||||||
|
|
||||||
$this->loadGraph();
|
$this->loadGraph();
|
||||||
$this->dispatch('graphUpdated');
|
$this->dispatchGraphUpdated();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +361,7 @@ class ManageGuidePages extends Page
|
|||||||
$edge->delete();
|
$edge->delete();
|
||||||
|
|
||||||
$this->loadGraph();
|
$this->loadGraph();
|
||||||
$this->dispatch('graphUpdated');
|
$this->dispatchGraphUpdated();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,11 +373,15 @@ class ManageGuidePages extends Page
|
|||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
|
|
||||||
Forms\Components\TextInput::make('html_url')
|
Forms\Components\RichEditor::make('content')
|
||||||
->label('HTML页面URL')
|
->label('页面内容')
|
||||||
->required()
|
->required()
|
||||||
->url()
|
->fileAttachmentsDisk('public')
|
||||||
->maxLength(500),
|
->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')
|
Forms\Components\TagsInput::make('options')
|
||||||
->label('分支选项')
|
->label('分支选项')
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Document;
|
||||||
use App\Models\Guide;
|
use App\Models\Guide;
|
||||||
use App\Models\GuidePage;
|
use App\Models\GuidePage;
|
||||||
use App\Models\GuidePageEdge;
|
use App\Models\GuidePageEdge;
|
||||||
|
use App\Models\KnowledgeBase;
|
||||||
use App\Services\KnowledgeContextService;
|
use App\Services\KnowledgeContextService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -137,7 +139,7 @@ class TerminalApiController extends Controller
|
|||||||
$pagesMap[$page->id] = [
|
$pagesMap[$page->id] = [
|
||||||
'id' => $page->id,
|
'id' => $page->id,
|
||||||
'title' => $page->title,
|
'title' => $page->title,
|
||||||
'html_url' => $page->html_url,
|
'uri' => $page->uri,
|
||||||
'next' => $next,
|
'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
|
* POST /api/terminal/heartbeat
|
||||||
* 终端心跳上报
|
* 终端心跳上报
|
||||||
|
|||||||
@@ -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,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ class ConvertDocumentToMarkdown implements ShouldQueue
|
|||||||
|
|
||||||
$markdownPath = $conversionService->saveMarkdownToFile(
|
$markdownPath = $conversionService->saveMarkdownToFile(
|
||||||
$this->document,
|
$this->document,
|
||||||
$result['markdown']
|
$result['markdown'],
|
||||||
|
$result['media_files'] ?? []
|
||||||
);
|
);
|
||||||
|
|
||||||
$conversionService->updateDocumentMarkdown($this->document, $markdownPath);
|
$conversionService->updateDocumentMarkdown($this->document, $markdownPath);
|
||||||
@@ -56,36 +57,46 @@ class ConvertDocumentToMarkdown implements ShouldQueue
|
|||||||
'document_title' => $this->document->title,
|
'document_title' => $this->document->title,
|
||||||
'markdown_path' => $markdownPath,
|
'markdown_path' => $markdownPath,
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Throwable $e) {
|
||||||
|
$exception = $this->normalizeException($e);
|
||||||
|
|
||||||
Log::error('文档转换失败', [
|
Log::error('文档转换失败', [
|
||||||
'document_id' => $this->document->id,
|
'document_id' => $this->document->id,
|
||||||
'document_title' => $this->document->title,
|
'document_title' => $this->document->title,
|
||||||
'file_name' => $this->document->file_name,
|
'file_name' => $this->document->file_name,
|
||||||
'attempt' => $this->attempts(),
|
'attempt' => $this->attempts(),
|
||||||
'error' => $e->getMessage(),
|
'error' => $exception->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($this->attempts() >= $this->tries) {
|
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
|
public function failed(\Throwable $exception): void
|
||||||
{
|
{
|
||||||
|
$normalized = $this->normalizeException($exception);
|
||||||
|
|
||||||
Log::error('文档转换任务最终失败', [
|
Log::error('文档转换任务最终失败', [
|
||||||
'document_id' => $this->document->id,
|
'document_id' => $this->document->id,
|
||||||
'document_title' => $this->document->title,
|
'document_title' => $this->document->title,
|
||||||
'file_name' => $this->document->file_name,
|
'file_name' => $this->document->file_name,
|
||||||
'error' => $exception->getMessage(),
|
'error' => $normalized->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$conversionService = app(DocumentConversionService::class);
|
$conversionService = app(DocumentConversionService::class);
|
||||||
$conversionService->handleConversionFailure(
|
$conversionService->handleConversionFailure($this->document, $normalized);
|
||||||
$this->document,
|
}
|
||||||
$exception instanceof \Exception ? $exception : new \Exception($exception->getMessage())
|
|
||||||
);
|
protected function normalizeException(\Throwable $throwable): \Exception
|
||||||
|
{
|
||||||
|
if ($throwable instanceof \Exception) {
|
||||||
|
return $throwable;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \RuntimeException($throwable->getMessage(), 0, $throwable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Scout\Searchable;
|
use Laravel\Scout\Searchable;
|
||||||
|
|
||||||
class Document extends Model
|
class Document extends Model
|
||||||
@@ -114,4 +115,33 @@ class Document extends Model
|
|||||||
{
|
{
|
||||||
return !empty($this->markdown_path) && $this->conversion_status === 'completed';
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class GuidePage extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'guide_id',
|
'guide_id',
|
||||||
'title',
|
'title',
|
||||||
'html_url',
|
'content',
|
||||||
'options',
|
'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()
|
public function guide()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Guide::class);
|
return $this->belongsTo(Guide::class);
|
||||||
@@ -55,7 +89,6 @@ class GuidePage extends Model
|
|||||||
|
|
||||||
public function isEntry(): bool
|
public function isEntry(): bool
|
||||||
{
|
{
|
||||||
return !$this->incomingEdges()->exists();
|
return ! $this->incomingEdges()->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use App\Models\Document;
|
|||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Paperdoc\Contracts\DocumentInterface;
|
||||||
|
use Paperdoc\Document\Image;
|
||||||
use Paperdoc\Support\DocumentManager;
|
use Paperdoc\Support\DocumentManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,9 +25,13 @@ class DocumentConversionService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 将文档转换为 Markdown
|
* 将文档转换为 Markdown
|
||||||
|
*
|
||||||
|
* @return array{markdown: string, media_files: array<string, string>}
|
||||||
*/
|
*/
|
||||||
public function convertToMarkdown(Document $document): array
|
public function convertToMarkdown(Document $document): array
|
||||||
{
|
{
|
||||||
|
$this->ensureConversionDependenciesAvailable();
|
||||||
|
|
||||||
$documentPath = Storage::disk('local')->path($document->file_path);
|
$documentPath = Storage::disk('local')->path($document->file_path);
|
||||||
|
|
||||||
if (!file_exists($documentPath)) {
|
if (!file_exists($documentPath)) {
|
||||||
@@ -39,24 +45,96 @@ class DocumentConversionService
|
|||||||
throw new \Exception('文档转换后内容为空,可能是扫描件或不支持的内容格式');
|
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 内容保存到存储
|
* 将 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);
|
$path = $this->generateMarkdownPath($document);
|
||||||
|
|
||||||
$saved = Storage::disk('markdown')->put($path, $markdown);
|
$saved = Storage::disk('markdown')->put($path, $markdown);
|
||||||
if (!$saved) {
|
if (!$saved) {
|
||||||
throw new \Exception("无法保存 Markdown 文件");
|
throw new \Exception('无法保存 Markdown 文件');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->storeMarkdownMediaFiles(dirname($path), $mediaFiles);
|
||||||
|
|
||||||
return $path;
|
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 文件路径
|
* 生成 Markdown 文件路径
|
||||||
*/
|
*/
|
||||||
@@ -103,16 +181,17 @@ class DocumentConversionService
|
|||||||
'document_id' => $document->id,
|
'document_id' => $document->id,
|
||||||
'markdown_path' => $markdownPath,
|
'markdown_path' => $markdownPath,
|
||||||
]);
|
]);
|
||||||
$preview = '';
|
|
||||||
} else {
|
} else {
|
||||||
$preview = $this->getMarkdownPreview($markdown);
|
$this->getMarkdownPreview($markdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
$document->update([
|
Document::withoutSyncingToSearch(function () use ($document, $markdownPath): void {
|
||||||
'markdown_path' => $markdownPath,
|
$document->update([
|
||||||
'conversion_status' => 'completed',
|
'markdown_path' => $markdownPath,
|
||||||
'conversion_error' => null,
|
'conversion_status' => 'completed',
|
||||||
]);
|
'conversion_error' => null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -128,10 +207,12 @@ class DocumentConversionService
|
|||||||
'trace' => $exception->getTraceAsString(),
|
'trace' => $exception->getTraceAsString(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$document->update([
|
Document::withoutSyncingToSearch(function () use ($document, $exception): void {
|
||||||
'conversion_status' => 'failed',
|
$document->update([
|
||||||
'conversion_error' => $exception->getMessage(),
|
'conversion_status' => 'failed',
|
||||||
]);
|
'conversion_error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,12 +220,113 @@ class DocumentConversionService
|
|||||||
*/
|
*/
|
||||||
public function queueConversion(Document $document): void
|
public function queueConversion(Document $document): void
|
||||||
{
|
{
|
||||||
$document->update([
|
Document::withoutSyncingToSearch(function () use ($document): void {
|
||||||
'conversion_status' => 'processing',
|
$document->update([
|
||||||
'conversion_error' => null,
|
'conversion_status' => 'processing',
|
||||||
]);
|
'conversion_error' => null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
$queue = config('documents.conversion.queue', 'documents');
|
$queue = config('documents.conversion.queue', 'documents');
|
||||||
\App\Jobs\ConvertDocumentToMarkdown::dispatch($document)->onQueue($queue);
|
\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',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Document;
|
use App\Models\Document;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
class DocumentPreviewService
|
class DocumentPreviewService
|
||||||
{
|
{
|
||||||
@@ -11,8 +10,6 @@ class DocumentPreviewService
|
|||||||
* 将文档的 Markdown 内容转换为 HTML 用于预览
|
* 将文档的 Markdown 内容转换为 HTML 用于预览
|
||||||
* 统一用于 Filament 后台内联预览和独立预览页面
|
* 统一用于 Filament 后台内联预览和独立预览页面
|
||||||
*
|
*
|
||||||
* @param Document $document
|
|
||||||
* @return string HTML 内容
|
|
||||||
* @throws \Exception
|
* @throws \Exception
|
||||||
*/
|
*/
|
||||||
public function convertToHtml(Document $document): string
|
public function convertToHtml(Document $document): string
|
||||||
@@ -23,8 +20,6 @@ class DocumentPreviewService
|
|||||||
/**
|
/**
|
||||||
* 将 Markdown 转换为 HTML
|
* 将 Markdown 转换为 HTML
|
||||||
*
|
*
|
||||||
* @param Document $document
|
|
||||||
* @return string HTML 内容
|
|
||||||
* @throws \Exception
|
* @throws \Exception
|
||||||
*/
|
*/
|
||||||
public function convertMarkdownToHtml(Document $document): string
|
public function convertMarkdownToHtml(Document $document): string
|
||||||
@@ -35,30 +30,57 @@ class DocumentPreviewService
|
|||||||
throw new \Exception('Markdown 内容为空');
|
throw new \Exception('Markdown 内容为空');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取 Markdown 文件的目录
|
app(DocumentConversionService::class)->ensureMarkdownMediaAssets($document);
|
||||||
$markdownDir = dirname($document->markdown_path);
|
|
||||||
|
|
||||||
// 修复图片路径:将 ./media/ 替换为 /markdown/{dir}/media/
|
$markdownContent = $this->stripPreviewFrontMatter($markdownContent);
|
||||||
$markdownContent = preg_replace_callback(
|
$markdownContent = $this->rewriteMarkdownMediaPaths($document, $markdownContent);
|
||||||
'/\(\.\/media\/([^)]+)\)/',
|
|
||||||
function ($matches) use ($markdownDir) {
|
|
||||||
$filename = $matches[1];
|
|
||||||
return '(/markdown/' . $markdownDir . '/media/' . $filename . ')';
|
|
||||||
},
|
|
||||||
$markdownContent
|
|
||||||
);
|
|
||||||
|
|
||||||
// 使用 MarkdownRenderService 转换为 HTML
|
|
||||||
$renderService = app(MarkdownRenderService::class);
|
$renderService = app(MarkdownRenderService::class);
|
||||||
|
|
||||||
return $renderService->render($markdownContent);
|
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('', $matches['alt'] ?? '', $url);
|
||||||
|
},
|
||||||
|
$markdownContent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查文档是否可以预览
|
* 检查文档是否可以预览
|
||||||
*
|
|
||||||
* @param Document $document
|
|
||||||
* @return bool
|
|
||||||
*/
|
*/
|
||||||
public function canPreview(Document $document): bool
|
public function canPreview(Document $document): bool
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class DocumentService
|
|||||||
|
|
||||||
return Storage::disk('local')->download(
|
return Storage::disk('local')->download(
|
||||||
$document->file_path,
|
$document->file_path,
|
||||||
$document->file_name
|
$document->display_file_name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,12 @@ class KnowledgeContextService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$context .= $snippet . "\n\n";
|
$context .= $snippet . "\n\n";
|
||||||
|
|
||||||
|
$fullContent = $document->getMarkdownContent() ?? '';
|
||||||
$sources[] = [
|
$sources[] = [
|
||||||
'title' => $document->title,
|
'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
|
private function extractSnippet($document): string
|
||||||
{
|
{
|
||||||
$content = $document->getMarkdownContent() ?? $document->description ?? '';
|
$content = $document->getMarkdownContent() ?? $document->description ?? '';
|
||||||
|
$header = "【{$document->title}】(ID:{$document->id})";
|
||||||
|
|
||||||
if (mb_strlen($content) <= 500) {
|
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) . '...';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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页面链接');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,8 +11,6 @@ use Illuminate\Database\Seeder;
|
|||||||
|
|
||||||
class GuideSeeder extends Seeder
|
class GuideSeeder extends Seeder
|
||||||
{
|
{
|
||||||
private const BASE_URL = 'https://ssrf.9z.work/guides';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the database seeds.
|
* Run the database seeds.
|
||||||
*/
|
*/
|
||||||
@@ -46,6 +44,11 @@ class GuideSeeder extends Seeder
|
|||||||
$this->command->info(' - 关联线站数量: ' . $stations->count());
|
$this->command->info(' - 关联线站数量: ' . $stations->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function placeholder(string $title): string
|
||||||
|
{
|
||||||
|
return '<p>本步骤说明待补充。管理员可在 Filament 后台使用富文本编辑器完善「' . e($title) . '」的操作指引。</p>';
|
||||||
|
}
|
||||||
|
|
||||||
private function createHowToUseBeamGuide(User $admin): Guide
|
private function createHowToUseBeamGuide(User $admin): Guide
|
||||||
{
|
{
|
||||||
$this->command->info('创建指引: 如何用光...');
|
$this->command->info('创建指引: 如何用光...');
|
||||||
@@ -60,49 +63,47 @@ class GuideSeeder extends Seeder
|
|||||||
'published_at' => now(),
|
'published_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$baseUrl = self::BASE_URL . '/how-to-use-beam';
|
|
||||||
|
|
||||||
$step1 = GuidePage::create([
|
$step1 = GuidePage::create([
|
||||||
'guide_id' => $guide->id,
|
'guide_id' => $guide->id,
|
||||||
'title' => '打开光子光闸 PS1',
|
'title' => '打开光子光闸 PS1',
|
||||||
'html_url' => "{$baseUrl}/step-1.html",
|
'content' => $this->placeholder('打开光子光闸 PS1'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$step2 = GuidePage::create([
|
$step2 = GuidePage::create([
|
||||||
'guide_id' => $guide->id,
|
'guide_id' => $guide->id,
|
||||||
'title' => '搜索光学棚屋',
|
'title' => '搜索光学棚屋',
|
||||||
'html_url' => "{$baseUrl}/step-2.html",
|
'content' => $this->placeholder('搜索光学棚屋'),
|
||||||
'options' => ['前门12', '后门'],
|
'options' => ['前门12', '后门'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$step3a = GuidePage::create([
|
$step3a = GuidePage::create([
|
||||||
'guide_id' => $guide->id,
|
'guide_id' => $guide->id,
|
||||||
'title' => '前门12路径 - 检查设备状态',
|
'title' => '前门12路径 - 检查设备状态',
|
||||||
'html_url' => "{$baseUrl}/step-3a.html",
|
'content' => $this->placeholder('前门12路径 - 检查设备状态'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$step3b = GuidePage::create([
|
$step3b = GuidePage::create([
|
||||||
'guide_id' => $guide->id,
|
'guide_id' => $guide->id,
|
||||||
'title' => '后门路径 - 安全确认',
|
'title' => '后门路径 - 安全确认',
|
||||||
'html_url' => "{$baseUrl}/step-3b.html",
|
'content' => $this->placeholder('后门路径 - 安全确认'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$step4a = GuidePage::create([
|
$step4a = GuidePage::create([
|
||||||
'guide_id' => $guide->id,
|
'guide_id' => $guide->id,
|
||||||
'title' => '前门12路径 - 打开实验站光闸',
|
'title' => '前门12路径 - 打开实验站光闸',
|
||||||
'html_url' => "{$baseUrl}/step-4a.html",
|
'content' => $this->placeholder('前门12路径 - 打开实验站光闸'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$step4b = GuidePage::create([
|
$step4b = GuidePage::create([
|
||||||
'guide_id' => $guide->id,
|
'guide_id' => $guide->id,
|
||||||
'title' => '后门路径 - 设备检查',
|
'title' => '后门路径 - 设备检查',
|
||||||
'html_url' => "{$baseUrl}/step-4b.html",
|
'content' => $this->placeholder('后门路径 - 设备检查'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$step5 = GuidePage::create([
|
$step5 = GuidePage::create([
|
||||||
'guide_id' => $guide->id,
|
'guide_id' => $guide->id,
|
||||||
'title' => '完成',
|
'title' => '完成',
|
||||||
'html_url' => "{$baseUrl}/step-5.html",
|
'content' => $this->placeholder('完成'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// step1 → step2 (sequential)
|
// step1 → step2 (sequential)
|
||||||
@@ -185,21 +186,20 @@ class GuideSeeder extends Seeder
|
|||||||
'published_at' => now(),
|
'published_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$baseUrl = self::BASE_URL . '/vacuum-valve-issue';
|
|
||||||
$steps = [
|
$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 = [];
|
$pages = [];
|
||||||
foreach ($steps as $i => $step) {
|
foreach ($steps as $title) {
|
||||||
$pages[] = GuidePage::create([
|
$pages[] = GuidePage::create([
|
||||||
'guide_id' => $guide->id,
|
'guide_id' => $guide->id,
|
||||||
'title' => $step['title'],
|
'title' => $title,
|
||||||
'html_url' => "{$baseUrl}/{$step['file']}",
|
'content' => $this->placeholder($title),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,21 +231,20 @@ class GuideSeeder extends Seeder
|
|||||||
'published_at' => now(),
|
'published_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$baseUrl = self::BASE_URL . '/water-leak-alarm';
|
|
||||||
$steps = [
|
$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 = [];
|
$pages = [];
|
||||||
foreach ($steps as $i => $step) {
|
foreach ($steps as $title) {
|
||||||
$pages[] = GuidePage::create([
|
$pages[] = GuidePage::create([
|
||||||
'guide_id' => $guide->id,
|
'guide_id' => $guide->id,
|
||||||
'title' => $step['title'],
|
'title' => $title,
|
||||||
'html_url' => "{$baseUrl}/{$step['file']}",
|
'content' => $this->placeholder($title),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="mt-2 text-sm text-danger-700 dark:text-danger-300 space-y-1">
|
<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->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>
|
<p><strong>失败时间:</strong>{{ $document->updated_at->format('Y年m月d日 H:i:s') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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->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>
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="mt-3 text-sm text-danger-700 dark:text-danger-300 space-y-1">
|
<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->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>
|
<p><strong>失败时间:</strong>{{ $document->updated_at->format('Y年m月d日 H:i:s') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,65 +36,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->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>
|
</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>
|
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
<link rel="stylesheet" href="{{ asset('vendor/drawflow/drawflow.min.css') }}">
|
<link rel="stylesheet" href="{{ asset('vendor/drawflow/drawflow.min.css') }}">
|
||||||
<script src="{{ asset('vendor/drawflow/drawflow.min.js') }}"></script>
|
<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 --}}
|
{{-- Header actions --}}
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
{{ $this->createPageAction }}
|
{{ $this->createPageAction }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Drawflow canvas --}}
|
{{-- 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 id="drawflow" wire:ignore></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -17,336 +17,466 @@
|
|||||||
<x-filament-actions::modals />
|
<x-filament-actions::modals />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.guide-flow-page { width: 100%; }
|
||||||
|
.guide-flow-canvas { width: 100%; }
|
||||||
|
|
||||||
#drawflow {
|
#drawflow {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 600px;
|
height: max(680px, calc(100vh - 14rem));
|
||||||
background-color: #f8fafc;
|
background-color: #f8fafc;
|
||||||
background-image: radial-gradient(circle, #e2e8f0 1px, transparent 1px);
|
background-image: radial-gradient(circle, #e2e8f0 1px, transparent 1px);
|
||||||
background-size: 20px 20px;
|
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 {
|
.drawflow .drawflow-node {
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
background: white;
|
background: white;
|
||||||
min-width: 200px;
|
min-width: 160px;
|
||||||
|
max-width: 180px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawflow .drawflow-node.selected {
|
.drawflow .drawflow-node.selected {
|
||||||
border-color: #f59e0b;
|
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 {
|
.drawflow .drawflow-node.entry-node {
|
||||||
border: 2px solid #f59e0b;
|
border: 2px solid #f59e0b;
|
||||||
}
|
}
|
||||||
|
.drawflow .drawflow-node .drawflow_content_node { padding: 0; }
|
||||||
|
|
||||||
.drawflow .drawflow-node .drawflow_content_node {
|
/* ── Node content ── */
|
||||||
padding: 0;
|
.df-node-content { padding: 8px 10px; }
|
||||||
}
|
|
||||||
|
|
||||||
/* Node content */
|
|
||||||
.df-node-content {
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.df-node-header {
|
.df-node-header {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
margin-bottom: 2px;
|
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
word-break: break-all;
|
||||||
|
|
||||||
.df-node-url {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #9ca3af;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.df-node-badge {
|
.df-node-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
background: #fef3c7;
|
background: #fef3c7;
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
padding: 1px 6px;
|
padding: 1px 5px;
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
margin-top: 4px;
|
margin-top: 3px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.df-node-actions {
|
.df-node-actions {
|
||||||
margin-top: 6px;
|
margin-top: 5px;
|
||||||
display: flex;
|
display: flex !important;
|
||||||
gap: 8px;
|
flex-direction: row !important;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
border-top: 1px solid #f3f4f6;
|
border-top: 1px solid #f3f4f6;
|
||||||
padding-top: 6px;
|
padding-top: 5px;
|
||||||
}
|
}
|
||||||
|
.df-node-actions button,
|
||||||
.df-node-actions button {
|
.df-node-actions a {
|
||||||
font-size: 11px;
|
display: inline-flex;
|
||||||
|
width: auto !important;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.2;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 2px 0;
|
padding: 1px 0;
|
||||||
transition: color 0.15s;
|
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 {
|
/* ── Connections ── */
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.df-node-actions button.btn-danger:hover {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Connection styling */
|
|
||||||
.drawflow .connection .main-path {
|
.drawflow .connection .main-path {
|
||||||
stroke: #94a3b8;
|
stroke: #64748b;
|
||||||
stroke-width: 2px;
|
stroke-width: 2px;
|
||||||
|
fill: none;
|
||||||
|
marker-end: url(#arrowhead);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawflow .connection .main-path:hover {
|
.drawflow .connection .main-path:hover {
|
||||||
stroke: #ef4444;
|
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 {
|
.drawflow .drawflow-node .inputs {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -6px;
|
left: -6px; top: 0; bottom: 0;
|
||||||
left: 0;
|
width: auto !important; height: auto !important;
|
||||||
right: 0;
|
display: flex; flex-direction: column; justify-content: center;
|
||||||
width: auto !important;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
.drawflow .drawflow-node .outputs {
|
.drawflow .drawflow-node .outputs {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -6px;
|
right: -6px; top: 0; bottom: 0;
|
||||||
left: 0;
|
width: auto !important; height: auto !important;
|
||||||
right: 0;
|
display: flex; flex-direction: column;
|
||||||
width: auto !important;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-evenly;
|
justify-content: space-evenly;
|
||||||
gap: 24px;
|
gap: 16px; padding: 16px 0;
|
||||||
padding: 0 20px;
|
|
||||||
}
|
}
|
||||||
|
.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 .input,
|
||||||
.drawflow .drawflow-node .output {
|
.drawflow .drawflow-node .output {
|
||||||
width: 12px;
|
width: 11px; height: 11px;
|
||||||
height: 12px;
|
|
||||||
border: 2px solid #94a3b8;
|
border: 2px solid #94a3b8;
|
||||||
background: white;
|
background: white;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.drawflow .drawflow-node .input:hover,
|
.drawflow .drawflow-node .input:hover,
|
||||||
.drawflow .drawflow-node .output:hover {
|
.drawflow .drawflow-node .output:hover {
|
||||||
background: #3b82f6;
|
background: #3b82f6; border-color: #3b82f6;
|
||||||
border-color: #3b82f6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Output port labels (below each port) */
|
/* Output port option label */
|
||||||
.drawflow .drawflow-node .output[data-label]::after {
|
.drawflow .drawflow-node .output[data-label]::after {
|
||||||
content: attr(data-label);
|
content: attr(data-label);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 13px; top: 50%;
|
||||||
top: 14px;
|
transform: translateY(-50%);
|
||||||
transform: translateX(-50%);
|
font-size: 9px;
|
||||||
font-size: 10px;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: #1e40af;
|
color: #1e40af;
|
||||||
background: #dbeafe;
|
background: #dbeafe;
|
||||||
padding: 0 5px;
|
padding: 0 4px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
.drawflow .drawflow-node .output[data-label].is-connected::after { content: none; }
|
||||||
|
|
||||||
/* Dark mode */
|
/* ── Dark mode ── */
|
||||||
.dark #drawflow {
|
.dark #drawflow {
|
||||||
background-color: #111827;
|
background-color: #111827;
|
||||||
background-image: radial-gradient(circle, #1f2937 1px, transparent 1px);
|
background-image: radial-gradient(circle, #1f2937 1px, transparent 1px);
|
||||||
}
|
}
|
||||||
|
.dark .drawflow .drawflow-node { background: #1f2937; border-color: #374151; }
|
||||||
.dark .drawflow .drawflow-node {
|
.dark .df-node-header { color: #e5e7eb; }
|
||||||
background: #1f2937;
|
.dark .df-node-actions { border-top-color: #374151; }
|
||||||
border-color: #374151;
|
.dark .drawflow .connection .main-path { stroke: #6b7280; }
|
||||||
}
|
|
||||||
|
|
||||||
.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 .input,
|
.dark .drawflow .drawflow-node .input,
|
||||||
.dark .drawflow .drawflow-node .output {
|
.dark .drawflow .drawflow-node .output { border-color: #6b7280; background: #1f2937; }
|
||||||
border-color: #6b7280;
|
.dark .drawflow .connection-label { fill: #93c5fd; }
|
||||||
background: #1f2937;
|
.dark .drawflow .connection-label-bg { fill: #1e3a5f; }
|
||||||
}
|
.dark .drawflow .drawflow-node .output[data-label]::after { color: #93c5fd; background: #1e3a5f; }
|
||||||
|
|
||||||
.dark .drawflow .drawflow-node .output[data-label]::after {
|
|
||||||
color: #93c5fd;
|
|
||||||
background: #1e3a5f;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<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,'&').replace(/</g,'<')
|
||||||
|
.replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
const container = document.getElementById('drawflow');
|
||||||
if (!container || container._dfInit) return;
|
if (!container) return;
|
||||||
container._dfInit = true;
|
|
||||||
|
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);
|
const editor = new Drawflow(container);
|
||||||
editor.reroute = false;
|
editor.reroute = false;
|
||||||
editor.curvature = 0.5;
|
editor.curvature = 0.5;
|
||||||
editor.start();
|
editor.start();
|
||||||
|
|
||||||
// Override path rendering for vertical (top-down) flow
|
// 水平贝塞尔曲线
|
||||||
editor.createCurvature = function(start_x, start_y, end_x, end_y, curvature, type) {
|
editor.createCurvature = function(sx, sy, ex, ey, curvature) {
|
||||||
const dy = Math.abs(end_y - start_y);
|
const ox = Math.max(Math.abs(ex - sx) * curvature, 30);
|
||||||
const offsetY = Math.max(dy * curvature, 50);
|
return `M ${sx} ${sy} C ${sx+ox} ${sy} ${ex-ox} ${ey} ${ex} ${ey}`;
|
||||||
return ` M ${start_x} ${start_y} C ${start_x} ${start_y + offsetY} ${end_x} ${end_y - offsetY} ${end_x} ${end_y}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window._dfEditor = editor;
|
window._dfEditor = editor;
|
||||||
|
|
||||||
const nodes = @js($this-> nodes);
|
|
||||||
const edges = @js($this-> edges);
|
|
||||||
const pageIdToNodeId = {};
|
const pageIdToNodeId = {};
|
||||||
|
|
||||||
// Add nodes - output count = options.length or 1 (default)
|
// 添加节点
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
const numOutputs = (node.options && node.options.length > 0) ? node.options.length : 1;
|
const numOutputs = (node.options && node.options.length > 0) ? node.options.length : 1;
|
||||||
|
const html = `<div class="df-node-content">
|
||||||
const html = `
|
<div class="df-node-header">${escHtml(node.title)}</div>
|
||||||
<div class="df-node-content">
|
${node.is_entry ? '<div><span class="df-node-badge">入口</span></div>' : ''}
|
||||||
<div class="df-node-header">${node.title}</div>
|
<div class="df-node-actions">
|
||||||
<div class="df-node-url">${node.html_url}</div>
|
<button onclick="event.stopPropagation();Livewire.find('${componentId}').mountAction('editPage',{id:${node.id}})">编辑</button>
|
||||||
${node.is_entry ? '<div><span class="df-node-badge">入口</span></div>' : ''}
|
<button onclick="event.stopPropagation();Livewire.find('${componentId}').mountAction('copyPage',{id:${node.id}})">复制</button>
|
||||||
<div class="df-node-actions">
|
<button class="btn-danger" onclick="event.stopPropagation();Livewire.find('${componentId}').mountAction('deletePage',{id:${node.id}})">删除</button>
|
||||||
<button onclick="event.stopPropagation(); Livewire.find('${@js($this->getId())}').mountAction('editPage', { id: ${node.id} })">编辑</button>
|
<a href="${escHtml(node.uri)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">预览</a>
|
||||||
<button class="btn-danger" onclick="event.stopPropagation(); Livewire.find('${@js($this->getId())}').mountAction('deletePage', { id: ${node.id} })">删除</button>
|
</div></div>`;
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const nodeId = editor.addNode(
|
const nodeId = editor.addNode(
|
||||||
'page', 1, numOutputs,
|
'page', 1, numOutputs,
|
||||||
node.x, node.y,
|
node.x, node.y,
|
||||||
node.is_entry ? 'entry-node' : '', {
|
node.is_entry ? 'entry-node' : '',
|
||||||
pageId: node.id,
|
{ pageId: node.id, options: node.options || [] },
|
||||||
options: node.options || []
|
|
||||||
},
|
|
||||||
html
|
html
|
||||||
);
|
);
|
||||||
pageIdToNodeId[node.id] = nodeId;
|
pageIdToNodeId[node.id] = nodeId;
|
||||||
|
|
||||||
// Label output ports with option names
|
// 给输出端口加选项标签
|
||||||
if (node.options && node.options.length > 0) {
|
if (node.options && node.options.length > 0) {
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
const nodeEl = container.querySelector(`#node-${nodeId}`);
|
const nodeEl = container.querySelector(`#node-${nodeId}`);
|
||||||
if (!nodeEl) return;
|
if (!nodeEl) return;
|
||||||
const outputs = nodeEl.querySelectorAll('.output');
|
nodeEl.querySelectorAll('.output').forEach((el, i) => {
|
||||||
outputs.forEach((el, i) => {
|
|
||||||
if (node.options[i]) {
|
if (node.options[i]) {
|
||||||
el.setAttribute('data-label', node.options[i]);
|
el.setAttribute('data-label', node.options[i]);
|
||||||
el.title = node.options[i];
|
el.title = node.options[i];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 0);
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add edges - map to correct output port based on label
|
// 添加连线
|
||||||
edges.forEach(edge => {
|
edges.forEach(edge => {
|
||||||
const fromNodeId = pageIdToNodeId[edge.from];
|
const fromNodeId = pageIdToNodeId[edge.from];
|
||||||
const toNodeId = pageIdToNodeId[edge.to];
|
const toNodeId = pageIdToNodeId[edge.to];
|
||||||
if (!fromNodeId || !toNodeId) return;
|
if (!fromNodeId || !toNodeId) return;
|
||||||
|
|
||||||
// Find which output port matches this edge's label
|
|
||||||
const fromNode = nodes.find(n => n.id === edge.from);
|
const fromNode = nodes.find(n => n.id === edge.from);
|
||||||
let outputClass = 'output_1';
|
let outputClass = 'output_1';
|
||||||
if (fromNode && fromNode.options && fromNode.options.length > 0 && edge.label) {
|
if (fromNode && fromNode.options && fromNode.options.length > 0 && edge.label) {
|
||||||
const idx = fromNode.options.indexOf(edge.label);
|
const idx = fromNode.options.indexOf(edge.label);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) outputClass = `output_${idx + 1}`;
|
||||||
outputClass = `output_${idx + 1}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
try { editor.addConnection(fromNodeId, toNodeId, outputClass, 'input_1'); } catch(e) {}
|
||||||
editor.addConnection(fromNodeId, toNodeId, outputClass, 'input_1');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Events → Livewire
|
markConnectedOutputs(edges, pageIdToNodeId);
|
||||||
let ignoreEvents = false;
|
|
||||||
|
|
||||||
editor.on('connectionCreated', (info) => {
|
scheduleConnectionLabelsRender(edges);
|
||||||
if (ignoreEvents) return;
|
|
||||||
const fromData = editor.getNodeFromId(info.output_id);
|
editor.on('nodeMoved', () => scheduleConnectionLabelsRender(edges));
|
||||||
const toData = editor.getNodeFromId(info.input_id);
|
editor.on('translate', () => scheduleConnectionLabelsRender(edges));
|
||||||
if (fromData && toData) {
|
editor.on('zoom', () => scheduleConnectionLabelsRender(edges));
|
||||||
@this.call('addEdge', fromData.data.pageId, toData.data.pageId, info.output_class);
|
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) => {
|
editor.on('connectionRemoved', info => {
|
||||||
if (ignoreEvents) return;
|
const from = editor.getNodeFromId(info.output_id);
|
||||||
const fromData = editor.getNodeFromId(info.output_id);
|
const to = editor.getNodeFromId(info.input_id);
|
||||||
const toData = editor.getNodeFromId(info.input_id);
|
if (from && to) {
|
||||||
if (fromData && toData) {
|
Livewire.find(componentId).call('removeEdge', from.data.pageId, to.data.pageId);
|
||||||
@this.call('removeEdge', fromData.data.pageId, toData.data.pageId);
|
|
||||||
}
|
}
|
||||||
});
|
scheduleConnectionLabelsRender(edges);
|
||||||
|
|
||||||
// Livewire graph refresh
|
|
||||||
Livewire.on('graphUpdated', () => {
|
|
||||||
ignoreEvents = true;
|
|
||||||
container._dfInit = false;
|
|
||||||
editor.clear();
|
|
||||||
setTimeout(() => {
|
|
||||||
container._dfInit = false;
|
|
||||||
container.innerHTML = '';
|
|
||||||
window._dfEditor = null;
|
|
||||||
initFlowEditor();
|
|
||||||
}, 100);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize on page load and SPA navigation
|
// ── Livewire 事件监听(只注册一次,在外层)────────────────────────────
|
||||||
document.addEventListener('DOMContentLoaded', () => setTimeout(initFlowEditor, 50));
|
function registerLivewireListener() {
|
||||||
document.addEventListener('livewire:navigated', () => setTimeout(initFlowEditor, 50));
|
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>
|
</script>
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|||||||
80
resources/views/guides/page.blade.php
Normal file
80
resources/views/guides/page.blade.php
Normal 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>
|
||||||
@@ -4,12 +4,15 @@ use App\Http\Controllers\Api\TerminalApiController;
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::middleware('identify.terminal')->group(function () {
|
Route::middleware('identify.terminal')->group(function () {
|
||||||
Route::get('/knowledge', [TerminalApiController::class, 'knowledge']);
|
|
||||||
|
|
||||||
Route::prefix('terminal')->group(function () {
|
Route::prefix('terminal')->group(function () {
|
||||||
|
Route::post('/heartbeat', [TerminalApiController::class, 'heartbeat']);
|
||||||
|
|
||||||
Route::get('/config', [TerminalApiController::class, 'config']);
|
Route::get('/config', [TerminalApiController::class, 'config']);
|
||||||
|
|
||||||
Route::get('/guides', [TerminalApiController::class, 'guides']);
|
Route::get('/guides', [TerminalApiController::class, 'guides']);
|
||||||
Route::post('/guides/pages', [TerminalApiController::class, 'guidePages']);
|
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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,27 +84,40 @@ Route::get('/health', function () {
|
|||||||
], $httpCode);
|
], $httpCode);
|
||||||
})->name('health.check');
|
})->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::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');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 提供 markdown 目录中 media 文件的访问(需要认证)
|
// 提供 markdown 目录中 media 文件的访问(需要认证)
|
||||||
// 路径格式: /markdown/{path}/media/{filename}
|
// 路径格式: /markdown-media/{path}
|
||||||
// 其中 path 可以是: 2025/12/04/{uuid} 或 {uuid}
|
Route::middleware(['auth'])->get('/markdown-media/{path}', function ($path) {
|
||||||
Route::middleware(['auth'])->get('/markdown/{path}/media/{filename}', function ($path, $filename) {
|
$path = trim((string) $path, '/');
|
||||||
// 构建完整路径
|
|
||||||
$fullPath = $path . '/media/' . $filename;
|
if ($path === '' || str_contains($path, '../')) {
|
||||||
|
|
||||||
if (!Storage::disk('markdown')->exists($fullPath)) {
|
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$file = Storage::disk('markdown')->get($fullPath);
|
if (!Storage::disk('markdown')->exists($path)) {
|
||||||
$mimeType = Storage::disk('markdown')->mimeType($fullPath);
|
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);
|
return response($file, 200)->header('Content-Type', $mimeType);
|
||||||
})->where('path', '.*')->where('filename', '[^/]+')->name('markdown.media');
|
})->where('path', '.*')->name('markdown.media');
|
||||||
|
|||||||
101
tests/Feature/DocumentPreviewFormattingTest.php
Normal file
101
tests/Feature/DocumentPreviewFormattingTest.php
Normal 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'
|
||||||
|
# 图片示例
|
||||||
|
|
||||||
|

|
||||||
|
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\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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
45
tests/Feature/ManageGuidePagesUploadReminderTest.php
Normal file
45
tests/Feature/ManageGuidePagesUploadReminderTest.php
Normal 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('图片未上传完成');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,35 @@ class SwooleQueueCompatibilityTest extends TestCase
|
|||||||
$job->handle($conversionService);
|
$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);
|
$jobDocument = $documentProperty->getValue($unserialized);
|
||||||
$this->assertEquals($document->id, $jobDocument->id);
|
$this->assertEquals($document->id, $jobDocument->id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
tests/Unit/GuidePageContentTest.php
Normal file
40
tests/Unit/GuidePageContentTest.php
Normal 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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user