refactor: 重构知识库文件上传和处理, 支持 pdf

This commit is contained in:
2026-03-23 16:30:13 +08:00
parent 89af7c17f1
commit 63ea2686e1
17 changed files with 905 additions and 1782 deletions

View File

@@ -40,13 +40,13 @@ class DocumentResource extends Resource
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
// 应用 accessibleBy 作用域,确保用户只能看到有权限的文档
$user = auth()->user();
if ($user) {
$query->accessibleBy($user);
}
return $query;
}
@@ -60,27 +60,32 @@ class DocumentResource extends Resource
->maxLength(255)
->placeholder('请输入文档标题')
->columnSpanFull(),
Forms\Components\Textarea::make('description')
->label('文档描述')
->rows(3)
->maxLength(65535)
->placeholder('请输入文档描述(可选)')
->columnSpanFull(),
Forms\Components\FileUpload::make('file')
->label('文档文件')
->required()
->acceptedFileTypes(['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'])
->acceptedFileTypes(config('documents.supported_formats.mime_types', [
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/pdf',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
]))
->maxSize(51200) // 50MB
->disk('local')
->directory('documents/' . date('Y/m/d'))
->visibility('private')
->downloadable()
->preserveFilenames() // 保留原始文件名
->helperText('仅支持 .doc 和 .docx 格式,最大 50MB')
->helperText('支持 .docx/.pptx/.xlsx/.pdf 格式,最大 50MB')
->columnSpanFull(),
Forms\Components\Select::make('type')
->label('文档类型')
->required()
@@ -90,18 +95,19 @@ class DocumentResource extends Resource
])
->default('global')
->reactive()
->afterStateUpdated(fn ($state, callable $set) =>
->afterStateUpdated(
fn($state, callable $set) =>
$state === 'global' ? $set('group_id', null) : null
)
->helperText('全局知识库所有用户可见,专用知识库仅指定分组可见'),
Forms\Components\Select::make('group_id')
->label('所属分组')
->relationship('group', 'name')
->searchable()
->preload()
->required(fn (Forms\Get $get): bool => $get('type') === 'dedicated')
->visible(fn (Forms\Get $get): bool => $get('type') === 'dedicated')
->required(fn(Forms\Get $get): bool => $get('type') === 'dedicated')
->visible(fn(Forms\Get $get): bool => $get('type') === 'dedicated')
->helperText('专用知识库必须选择所属分组'),
]);
}
@@ -122,51 +128,51 @@ class DocumentResource extends Resource
}
return null;
}),
Tables\Columns\TextColumn::make('type')
->label('文档类型')
->badge()
->color(fn (string $state): string => match ($state) {
->color(fn(string $state): string => match ($state) {
'global' => 'success',
'dedicated' => 'warning',
})
->formatStateUsing(fn (string $state): string => match ($state) {
->formatStateUsing(fn(string $state): string => match ($state) {
'global' => '全局知识库',
'dedicated' => '专用知识库',
default => $state,
})
->sortable(),
Tables\Columns\TextColumn::make('group.name')
->label('所属分组')
->searchable()
->sortable()
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('uploader.name')
->label('上传者')
->searchable()
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('file_size')
->label('文件大小')
->formatStateUsing(fn ($state): string => self::formatFileSize($state))
->formatStateUsing(fn($state): string => self::formatFileSize($state))
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('conversion_status')
->label('转换状态')
->badge()
->color(fn (?string $state): string => match ($state) {
->color(fn(?string $state): string => match ($state) {
'completed' => 'success',
'processing' => 'info',
'pending' => 'warning',
'failed' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (?string $state): string => match ($state) {
->formatStateUsing(fn(?string $state): string => match ($state) {
'completed' => '已完成',
'processing' => '转换中',
'pending' => '等待转换',
@@ -175,13 +181,13 @@ class DocumentResource extends Resource
})
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('created_at')
->label('上传时间')
->dateTime('Y年m月d日 H:i')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('updated_at')
->label('更新时间')
->dateTime('Y年m月d日 H:i')
@@ -196,21 +202,21 @@ class DocumentResource extends Resource
'dedicated' => '专用知识库',
])
->placeholder('全部类型'),
Tables\Filters\SelectFilter::make('group_id')
->label('所属分组')
->relationship('group', 'name')
->searchable()
->preload()
->placeholder('全部分组'),
Tables\Filters\SelectFilter::make('uploaded_by')
->label('上传者')
->relationship('uploader', 'name')
->searchable()
->preload()
->placeholder('全部上传者'),
Tables\Filters\SelectFilter::make('conversion_status')
->label('转换状态')
->options([
@@ -226,32 +232,29 @@ class DocumentResource extends Resource
->label('重试转换')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (Document $record): bool =>
->visible(
fn(Document $record): bool =>
in_array($record->conversion_status, ['failed', 'processing', 'pending'])
)
->requiresConfirmation()
->modalHeading('重试文档转换')
->modalDescription(fn (Document $record): string =>
'确定要重新转换文档 "' . $record->title . '" 吗?' .
"\n\n当前状态:" . match($record->conversion_status) {
'failed' => '转换失败',
'processing' => '转换中(可能卡住)',
'pending' => '等待转换',
default => $record->conversion_status,
} .
($record->conversion_error ? "\n\n错误信息:" . $record->conversion_error : '')
->modalDescription(
fn(Document $record): string =>
'确定要重新转换文档 "' . $record->title . '" 吗?' .
"\n\n当前状态:" . match ($record->conversion_status) {
'failed' => '转换失败',
'processing' => '转换中(可能卡住)',
'pending' => '等待转换',
default => $record->conversion_status,
} .
($record->conversion_error ? "\n\n错误信息:" . $record->conversion_error : '')
)
->modalSubmitActionLabel('确认重试')
->action(function (Document $record) {
try {
// 重置转换状态
$record->conversion_status = 'pending';
$record->conversion_error = null;
$record->save();
// 重新派发转换任务
\App\Jobs\ConvertDocumentToMarkdown::dispatch($record);
app(\App\Services\DocumentConversionService::class)
->queueConversion($record);
\Filament\Notifications\Notification::make()
->success()
->title('重试成功')
@@ -269,11 +272,13 @@ class DocumentResource extends Resource
->label('查看错误')
->icon('heroicon-o-exclamation-triangle')
->color('danger')
->visible(fn (Document $record): bool =>
->visible(
fn(Document $record): bool =>
$record->conversion_status === 'failed' && !empty($record->conversion_error)
)
->modalHeading('转换错误详情')
->modalContent(fn (Document $record): \Illuminate\Contracts\View\View =>
->modalContent(
fn(Document $record): \Illuminate\Contracts\View\View =>
view('filament.modals.conversion-error', [
'document' => $record,
'error' => $record->conversion_error,
@@ -285,12 +290,13 @@ class DocumentResource extends Resource
->label('预览 Markdown')
->icon('heroicon-o-eye')
->color('info')
->visible(fn (Document $record): bool => $record->conversion_status === 'completed')
->url(fn (Document $record): string => route('documents.preview', $record))
->visible(fn(Document $record): bool => $record->conversion_status === 'completed')
->url(fn(Document $record): string => route('documents.preview', $record))
->openUrlInNewTab()
->tooltip(fn (Document $record): ?string =>
$record->conversion_status !== 'completed'
? '文档尚未完成转换'
->tooltip(
fn(Document $record): ?string =>
$record->conversion_status !== 'completed'
? '文档尚未完成转换'
: null
),
Tables\Actions\Action::make('download')
@@ -300,11 +306,11 @@ class DocumentResource extends Resource
->action(function (Document $record) {
$documentService = app(\App\Services\DocumentService::class);
$user = auth()->user();
try {
// 记录下载日志
$documentService->logDownload($record, $user);
// 返回文件下载响应
return $documentService->downloadDocument($record, $user);
} catch (\Exception $e) {
@@ -313,7 +319,7 @@ class DocumentResource extends Resource
->title('下载失败')
->body($e->getMessage())
->send();
return null;
}
}),
@@ -341,13 +347,13 @@ class DocumentResource extends Resource
if ($bytes === null) {
return '—';
}
$units = ['B', 'KB', 'MB', 'GB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, 2) . ' ' . $units[$pow];
}

View File

@@ -3,8 +3,6 @@
namespace App\Filament\Resources\DocumentResource\Pages;
use App\Filament\Resources\DocumentResource;
use App\Services\DocumentService;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Auth;
@@ -16,37 +14,28 @@ class CreateDocument extends CreateRecord
protected function mutateFormDataBeforeCreate(array $data): array
{
// 设置上传者为当前用户
$data['uploaded_by'] = Auth::id();
// 如果是全局文档,确保 group_id 为 null
if ($data['type'] === 'global') {
$data['group_id'] = null;
}
// 处理文件上传
if (isset($data['file'])) {
$filePath = $data['file'];
// 获取原始文件名(由于使用了 preserveFilenames()basename 就是原始文件名)
$originalFileName = basename($filePath);
// 保存文件信息
$data['file_path'] = $filePath;
$data['file_name'] = $originalFileName; // 保存原始文件名
$data['file_name'] = basename($filePath);
$data['file_size'] = Storage::disk('local')->size($filePath);
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);
// 移除临时的 file 字段
unset($data['file']);
}
return $data;
}
protected function afterCreate(): void
{
// 文档创建后,触发转换任务
$conversionService = app(\App\Services\DocumentConversionService::class);
$conversionService->queueConversion($this->record);
}

View File

@@ -6,12 +6,15 @@ use App\Filament\Resources\DocumentResource;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class EditDocument extends EditRecord
{
protected static string $resource = DocumentResource::class;
private ?string $previousFilePath = null;
protected function getHeaderActions(): array
{
return [
@@ -24,60 +27,55 @@ class EditDocument extends EditRecord
protected function mutateFormDataBeforeFill(array $data): array
{
// 将文件路径设置到 file 字段以便显示
$this->previousFilePath = $data['file_path'] ?? null;
if (isset($data['file_path'])) {
$data['file'] = $data['file_path'];
}
return $data;
}
protected function mutateFormDataBeforeSave(array $data): array
{
// 如果是全局文档,确保 group_id 为 null
if ($data['type'] === 'global') {
$data['group_id'] = null;
}
// 处理文件更新
if (isset($data['file']) && $data['file'] !== $this->record->file_path) {
$filePath = $data['file'];
// 删除旧的 Word 文件
if ($this->record->file_path && Storage::disk('local')->exists($this->record->file_path)) {
Storage::disk('local')->delete($this->record->file_path);
$currentFile = $data['file'] ?? null;
// 检测文件是否变更:与填充时记录的原始路径比较
if ($currentFile && $currentFile !== $this->previousFilePath) {
// 删除旧文件
if ($this->previousFilePath && Storage::disk('local')->exists($this->previousFilePath)) {
Storage::disk('local')->delete($this->previousFilePath);
}
// 删除旧的 Markdown 文件
if ($this->record->markdown_path && Storage::disk('markdown')->exists($this->record->markdown_path)) {
Storage::disk('markdown')->delete($this->record->markdown_path);
}
// 获取原始文件名(由于使用了 preserveFilenames()basename 就是原始文件名)
$originalFileName = basename($filePath);
// 更新文件信息
$data['file_path'] = $filePath;
$data['file_name'] = $originalFileName; // 保存原始文件名
$data['file_size'] = Storage::disk('local')->size($filePath);
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);
// 重置转换状态,准备重新转换
$data['file_path'] = $currentFile;
$data['file_name'] = basename($currentFile);
$data['file_size'] = Storage::disk('local')->size($currentFile);
$data['mime_type'] = Storage::disk('local')->mimeType($currentFile);
// 重置转换状态,触发重新转换
$data['conversion_status'] = 'pending';
$data['markdown_path'] = null;
$data['markdown_preview'] = null;
$data['conversion_error'] = null;
}
// 移除临时的 file 字段
unset($data['file']);
return $data;
}
protected function afterSave(): void
{
// 如果文档的转换状态是 pending说明文件已更新需要触发重新转换
// 刷新模型以获取最新数据库状态
$this->record->refresh();
if ($this->record->conversion_status === 'pending') {
$conversionService = app(\App\Services\DocumentConversionService::class);
$conversionService->queueConversion($this->record);

View File

@@ -34,21 +34,15 @@ class ViewDocument extends ViewRecord
->modalSubmitActionLabel('确认重试')
->action(function () {
try {
// 重置转换状态
$this->record->conversion_status = 'pending';
$this->record->conversion_error = null;
$this->record->save();
// 重新派发转换任务
\App\Jobs\ConvertDocumentToMarkdown::dispatch($this->record);
app(\App\Services\DocumentConversionService::class)
->queueConversion($this->record);
Notification::make()
->success()
->title('重试成功')
->body('文档转换任务已重新加入队列,请稍后查看转换结果。')
->send();
// 刷新页面数据
$this->refreshFormData([
'conversion_status',
'conversion_error',

View File

@@ -18,39 +18,12 @@ class ConvertDocumentToMarkdown implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* 任务最大尝试次数
*
* @var int
*/
public $tries;
/**
* 任务超时时间(秒)
*
* @var int
*/
public $timeout;
/**
* 重试延迟(秒)
*
* @var int
*/
public $backoff;
/**
* 文档实例
*
* @var Document
*/
protected Document $document;
/**
* 创建新的任务实例
*
* @param Document $document
*/
public function __construct(Document $document)
{
$this->document = $document;
@@ -59,120 +32,60 @@ class ConvertDocumentToMarkdown implements ShouldQueue
$this->backoff = config('documents.conversion.retry_delay', 60);
}
/**
* 执行任务
*
* @param DocumentConversionService $conversionService
* @return void
*/
public function handle(DocumentConversionService $conversionService): void
{
try {
Log::info('开始转换文档', [
'document_id' => $this->document->id,
'document_title' => $this->document->title,
'file_name' => $this->document->file_name,
'attempt' => $this->attempts(),
]);
// 转换文档为 Markdown
$result = $conversionService->convertToMarkdown($this->document);
$markdown = $result['markdown'];
$mediaDir = $result['mediaDir'] ?? null;
$tempDir = $result['tempDir'];
$tempDirName = $result['tempDirName'];
try {
// 保存 Markdown 文件和媒体文件
$markdownPath = $conversionService->saveMarkdownToFile($this->document, $markdown, $mediaDir);
$markdownPath = $conversionService->saveMarkdownToFile(
$this->document,
$result['markdown']
);
// 更新文档的 Markdown 信息
$conversionService->updateDocumentMarkdown($this->document, $markdownPath);
} finally {
// 清理临时目录
if (isset($tempDirName) && \Storage::disk('local')->exists($tempDirName)) {
\Storage::disk('local')->deleteDirectory($tempDirName);
}
}
$conversionService->updateDocumentMarkdown($this->document, $markdownPath);
Log::info('文档转换成功', [
'document_id' => $this->document->id,
'document_title' => $this->document->title,
'markdown_path' => $markdownPath,
]);
// 转换成功后,触发索引(如果需要)
// 这将在后续任务中实现
// $this->document->searchable();
} catch (\Exception $e) {
Log::error('文档转换失败', [
'document_id' => $this->document->id,
'document_title' => $this->document->title,
'file_name' => $this->document->file_name,
'attempt' => $this->attempts(),
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// 如果已达到最大重试次数,标记为失败
if ($this->attempts() >= $this->tries) {
$conversionService->handleConversionFailure($this->document, $e);
}
// 重新抛出异常以触发重试
throw $e;
}
}
/**
* 任务失败时的处理
*
* @param \Throwable $exception
* @return void
*/
public function failed(\Throwable $exception): void
{
Log::error('文档转换任务最终失败', [
'document_id' => $this->document->id,
'document_title' => $this->document->title,
'file_name' => $this->document->file_name,
'error' => $exception->getMessage(),
]);
// 确保文档状态被标记为失败
$conversionService = app(DocumentConversionService::class);
$conversionService->handleConversionFailure(
$this->document,
$exception instanceof \Exception ? $exception : new \Exception($exception->getMessage())
);
}
/**
* 递归删除目录
*
* @param string $dir 目录路径
* @return void
*/
protected function deleteDirectory(string $dir): void
{
if (!file_exists($dir)) {
return;
}
if (!is_dir($dir)) {
unlink($dir);
return;
}
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
if (is_dir($path)) {
$this->deleteDirectory($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
}

View File

@@ -4,214 +4,52 @@ namespace App\Services;
use App\Models\Document;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Paperdoc\Support\DocumentManager;
/**
* 文档转换服务
* 负责将 Word 文档转换为 Markdown 格式
* 使用 paperdoc-lib 文档DOCX/PPTX/XLSX/PDF转换为 Markdown
*/
class DocumentConversionService
{
/**
* 转换驱动
*
* @var string
*/
protected string $driver;
/**
* Pandoc 可执行文件路径
*
* @var string
*/
protected string $pandocPath;
/**
* 转换超时时间(秒)
*
* @var int
*/
protected int $timeout;
/**
* Markdown 预览长度
*
* @var int
*/
protected int $previewLength;
/**
* 构造函数
*/
public function __construct()
{
$this->driver = config('documents.conversion.driver', 'pandoc');
$this->pandocPath = config('documents.conversion.pandoc_path', 'pandoc');
$this->timeout = config('documents.conversion.timeout', 300);
$this->previewLength = config('documents.markdown.preview_length', 500);
}
/**
* Word 文档转换为 Markdown
*
* @param Document $document
* @return array 返回 ['markdown' => string, 'mediaDir' => string|null, 'tempDir' => string]
* @throws \Exception
* 将文档转换为 Markdown
*/
public function convertToMarkdown(Document $document): array
{
if ($this->driver === 'pandoc') {
return $this->convertWithPandoc($document);
}
throw new \Exception("不支持的转换驱动: {$this->driver}");
}
/**
* 使用 Pandoc 转换文档
*
* @param Document $document
* @return array 返回 ['markdown' => string, 'mediaDir' => string|null]
* @throws \Exception
*/
protected function convertWithPandoc(Document $document): array
{
// 获取文档的完整路径
$documentPath = Storage::disk('local')->path($document->file_path);
if (!file_exists($documentPath)) {
throw new \Exception("文档文件不存在: {$documentPath}");
}
// 使用 Laravel 存储系统创建临时工作目录
$tempDirName = 'temp/pandoc_' . uniqid();
// 确保临时目录存在
if (!Storage::disk('local')->exists('temp')) {
Storage::disk('local')->makeDirectory('temp');
$doc = DocumentManager::open($documentPath, ['ocr' => false]);
$markdown = DocumentManager::renderAs($doc, 'md');
if (empty(trim($markdown))) {
throw new \Exception('文档转换后内容为空,可能是扫描件或不支持的内容格式');
}
Storage::disk('local')->makeDirectory($tempDirName);
$tempDir = Storage::disk('local')->path($tempDirName);
$tempOutputPath = $tempDir . '/output.md';
try {
// 在临时目录中执行 Pandoc 转换命令
$result = Process::timeout($this->timeout)
->path($tempDir)
->run([
$this->pandocPath,
$documentPath,
'-f', $this->getInputFormat($document->mime_type),
'-t', 'markdown',
'-o', $tempOutputPath,
'--wrap=none', // 不自动换行
'--extract-media=.', // 提取媒体文件到当前目录
]);
if (!$result->successful()) {
throw new \Exception("Pandoc 转换失败: {$result->errorOutput()}");
}
// 读取转换后的 Markdown 内容
if (!file_exists($tempOutputPath)) {
throw new \Exception("转换后的 Markdown 文件不存在");
}
$markdown = file_get_contents($tempOutputPath);
if ($markdown === false) {
throw new \Exception("无法读取转换后的 Markdown 文件");
}
// 检查是否有提取的媒体文件
$mediaDir = $tempDir . '/media';
$hasMedia = is_dir($mediaDir) && count(glob($mediaDir . '/*')) > 0;
return [
'markdown' => $markdown,
'mediaDir' => $hasMedia ? $mediaDir : null,
'tempDir' => $tempDir,
'tempDirName' => $tempDirName, // 添加相对路径名
];
} catch (\Exception $e) {
// 清理临时目录
Storage::disk('local')->deleteDirectory($tempDirName);
throw $e;
}
return ['markdown' => $markdown];
}
/**
* 递归删除目录
*
* @param string $dir 目录路径
* @return void
* Markdown 内容保存到存储
*/
protected function deleteDirectory(string $dir): void
public function saveMarkdownToFile(Document $document, string $markdown): string
{
if (!file_exists($dir)) {
return;
}
if (!is_dir($dir)) {
unlink($dir);
return;
}
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
if (is_dir($path)) {
$this->deleteDirectory($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
/**
* 根据 MIME 类型获取 Pandoc 输入格式
*
* @param string $mimeType
* @return string
*/
protected function getInputFormat(string $mimeType): string
{
return match ($mimeType) {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/msword' => 'doc',
default => 'docx',
};
}
/**
* Markdown 内容和媒体文件保存到存储
*
* @param Document $document
* @param string $markdown
* @param string|null $mediaDir 临时媒体目录路径
* @return string 返回 Markdown 文件路径
* @throws \Exception
*/
public function saveMarkdownToFile(Document $document, string $markdown, ?string $mediaDir = null): string
{
// 生成文件路径
$path = $this->generateMarkdownPath($document);
$directory = dirname($path);
// 如果有媒体文件,先保存它们
if ($mediaDir && is_dir($mediaDir)) {
$this->saveMediaFiles($mediaDir, $directory);
}
// 保存 Markdown 文件
$saved = Storage::disk('markdown')->put($path, $markdown);
if (!$saved) {
throw new \Exception("无法保存 Markdown 文件");
}
@@ -219,83 +57,33 @@ class DocumentConversionService
return $path;
}
/**
* 保存媒体文件到 storage
* 媒体文件保存在文档的 UUID 目录下的 media 子目录中
*
* @param string $sourceDir 源媒体目录
* @param string $targetDir 目标目录(相对于 markdown disk例如2025/12/04/{uuid}
* @return void
*/
protected function saveMediaFiles(string $sourceDir, string $targetDir): void
{
$files = glob($sourceDir . '/*');
foreach ($files as $file) {
if (is_file($file)) {
$filename = basename($file);
// 保存到文档目录下的 media 子目录
$targetPath = $targetDir . '/media/' . $filename;
// 读取文件内容
$content = file_get_contents($file);
// 保存到 storage
Storage::disk('markdown')->put($targetPath, $content);
Log::info('媒体文件已保存', [
'filename' => $filename,
'path' => $targetPath,
]);
}
}
}
/**
* 生成 Markdown 文件路径
* 使用 UUID 作为目录名,确保每个文档有独立的 media 目录
*
* @param Document $document
* @return string
*/
protected function generateMarkdownPath(Document $document): string
{
$organizeByDate = config('documents.storage.organize_by_date', true);
// 生成唯一的 UUID 作为文档目录
$uuid = Str::uuid()->toString();
if ($organizeByDate) {
// 按日期组织: YYYY/MM/DD/{uuid}/{uuid}.md
$date = $document->created_at ?? now();
$directory = $date->format('Y/m/d') . '/' . $uuid;
} else {
// 直接使用 UUID: {uuid}/{uuid}.md
$directory = $uuid;
}
// 文件名也使用相同的 UUID
$filename = $uuid . '.md';
return "{$directory}/{$filename}";
return "{$directory}/{$uuid}.md";
}
/**
* 获取 Markdown 内容的预览(前 N 个字符)
*
* @param string $markdown
* @param int|null $length
* @return string
*/
public function getMarkdownPreview(string $markdown, ?int $length = null): string
{
$length = $length ?? $this->previewLength;
// 移除多余的空白字符
$cleaned = preg_replace('/\s+/', ' ', $markdown);
$cleaned = trim($cleaned);
// 截取指定长度
if (mb_strlen($cleaned) <= $length) {
return $cleaned;
}
@@ -305,14 +93,9 @@ class DocumentConversionService
/**
* 更新文档的 Markdown 信息
*
* @param Document $document
* @param string $markdownPath
* @return void
*/
public function updateDocumentMarkdown(Document $document, string $markdownPath): void
{
// 读取 Markdown 内容以生成预览
$markdown = Storage::disk('markdown')->get($markdownPath);
if ($markdown === false) {
@@ -325,7 +108,6 @@ class DocumentConversionService
$preview = $this->getMarkdownPreview($markdown);
}
// 更新文档记录
$document->update([
'markdown_path' => $markdownPath,
'markdown_preview' => $preview,
@@ -336,21 +118,17 @@ class DocumentConversionService
/**
* 处理转换失败
*
* @param Document $document
* @param \Exception $exception
* @return void
*/
public function handleConversionFailure(Document $document, \Exception $exception): void
{
Log::error('文档转换失败', [
'document_id' => $document->id,
'document_title' => $document->title,
'file_name' => $document->file_name,
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
// 更新文档状态
$document->update([
'conversion_status' => 'failed',
'conversion_error' => $exception->getMessage(),
@@ -359,21 +137,15 @@ class DocumentConversionService
/**
* 将转换任务加入队列
*
* @param Document $document
* @return void
*/
public function queueConversion(Document $document): void
{
// 更新文档状态为处理中
$document->update([
'conversion_status' => 'processing',
'conversion_error' => null,
]);
// 分发队列任务
$queue = config('documents.conversion.queue', 'documents');
\App\Jobs\ConvertDocumentToMarkdown::dispatch($document)->onQueue($queue);
}
}

View File

@@ -4,33 +4,25 @@ namespace App\Services;
use App\Models\Document;
use Illuminate\Support\Facades\Storage;
use PhpOffice\PhpWord\IOFactory;
use PhpOffice\PhpWord\Settings;
class DocumentPreviewService
{
/**
* 将文档转换为 HTML 用于预览
* Filament 后台中,直接从 Word 转换以保证图片正确显示
*
* 将文档 Markdown 内容转换为 HTML 用于预览
* 统一用于 Filament 后台内联预览和独立预览页面
*
* @param Document $document
* @return string HTML 内容
* @throws \Exception
*/
public function convertToHtml(Document $document): string
{
try {
// 直接从 Word 转换,以确保图片正确显示
// Markdown 转换的图片路径问题较复杂,暂时不使用
return $this->convertWordToHtml($document);
} catch (\Exception $e) {
throw new \Exception('文档预览失败:' . $e->getMessage());
}
return $this->convertMarkdownToHtml($document);
}
/**
* Markdown 转换为 HTML(用于专门的 Markdown 预览页面)
*
* Markdown 转换为 HTML
*
* @param Document $document
* @return string HTML 内容
* @throws \Exception
@@ -38,15 +30,15 @@ class DocumentPreviewService
public function convertMarkdownToHtml(Document $document): string
{
$markdownContent = $document->getMarkdownContent();
if (empty($markdownContent)) {
throw new \Exception('Markdown 内容为空');
}
// 获取 Markdown 文件的目录例如2025/12/04
// 获取 Markdown 文件的目录
$markdownDir = dirname($document->markdown_path);
// 修复图片路径:将 ./media/ 替换为 /markdown/{date}/media/
// 修复图片路径:将 ./media/ 替换为 /markdown/{dir}/media/
$markdownContent = preg_replace_callback(
'/\(\.\/media\/([^)]+)\)/',
function ($matches) use ($markdownDir) {
@@ -58,250 +50,19 @@ class DocumentPreviewService
// 使用 MarkdownRenderService 转换为 HTML
$renderService = app(MarkdownRenderService::class);
$htmlContent = $renderService->render($markdownContent);
return $htmlContent;
}
/**
* 直接从 Word 文档转换为 HTML
*
* @param Document $document
* @return string HTML 内容
* @throws \Exception
*/
protected function convertWordToHtml(Document $document): string
{
// 检查文件是否存在
if (!Storage::disk('local')->exists($document->file_path)) {
throw new \Exception('文档文件不存在');
}
// 获取文件的完整路径
$filePath = Storage::disk('local')->path($document->file_path);
// 确保临时目录存在并设置 PHPWord 的临时目录
$tempDir = storage_path('app/temp');
if (!is_dir($tempDir)) {
mkdir($tempDir, 0755, true);
}
Settings::setTempDir($tempDir);
// 加载 Word 文档
$phpWord = IOFactory::load($filePath);
// 提取图片并转换为 base64
$images = $this->extractImagesFromDocument($phpWord);
// 创建 HTML Writer
$htmlWriter = IOFactory::createWriter($phpWord, 'HTML');
// 使用 Laravel 存储系统创建临时文件
$tempFileName = 'temp/doc_preview_' . uniqid() . '.html';
// 确保临时目录存在
if (!Storage::disk('local')->exists('temp')) {
Storage::disk('local')->makeDirectory('temp');
}
$tempHtmlPath = Storage::disk('local')->path($tempFileName);
$htmlWriter->save($tempHtmlPath);
// 读取 HTML 内容
$htmlContent = Storage::disk('local')->get($tempFileName);
// 删除临时文件
Storage::disk('local')->delete($tempFileName);
// 将图片嵌入为 base64
$htmlContent = $this->embedImagesInHtml($htmlContent, $images);
// 清理和美化 HTML
$htmlContent = $this->cleanHtml($htmlContent);
return $htmlContent;
}
/**
* Word 文档中提取所有图片
*
* @param \PhpOffice\PhpWord\PhpWord $phpWord
* @return array 图片数组,键为图片索引,值为 base64 编码的图片数据
*/
protected function extractImagesFromDocument($phpWord): array
{
$images = [];
$imageIndex = 0;
foreach ($phpWord->getSections() as $section) {
foreach ($section->getElements() as $element) {
// 处理图片元素
if (method_exists($element, 'getElements')) {
foreach ($element->getElements() as $childElement) {
if ($childElement instanceof \PhpOffice\PhpWord\Element\Image) {
$imageSource = $childElement->getSource();
if (file_exists($imageSource)) {
$imageData = file_get_contents($imageSource);
$imageType = $childElement->getImageType();
$mimeType = $this->getImageMimeType($imageType);
$base64 = base64_encode($imageData);
$images[$imageIndex] = "data:{$mimeType};base64,{$base64}";
$imageIndex++;
}
}
}
} elseif ($element instanceof \PhpOffice\PhpWord\Element\Image) {
$imageSource = $element->getSource();
if (file_exists($imageSource)) {
$imageData = file_get_contents($imageSource);
$imageType = $element->getImageType();
$mimeType = $this->getImageMimeType($imageType);
$base64 = base64_encode($imageData);
$images[$imageIndex] = "data:{$mimeType};base64,{$base64}";
$imageIndex++;
}
}
}
}
return $images;
}
/**
* 根据图片类型获取 MIME 类型
*
* @param string $imageType
* @return string
*/
protected function getImageMimeType(string $imageType): string
{
$mimeTypes = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'svg' => 'image/svg+xml',
];
return $mimeTypes[strtolower($imageType)] ?? 'image/jpeg';
}
/**
* HTML 中的图片替换为 base64 编码
*
* @param string $html
* @param array $images
* @return string
*/
protected function embedImagesInHtml(string $html, array $images): string
{
// PHPWord 生成的 HTML 中,图片通常以 <img src="..." /> 的形式存在
// 我们需要将这些图片路径替换为 base64 数据
$imageIndex = 0;
$html = preg_replace_callback(
'/<img([^>]*?)src=["\']([^"\']+)["\']([^>]*?)>/i',
function ($matches) use ($images, &$imageIndex) {
$beforeSrc = $matches[1];
$src = $matches[2];
$afterSrc = $matches[3];
// 如果已经是 base64 或 http 链接,不处理
if (strpos($src, 'data:') === 0 || strpos($src, 'http') === 0) {
return $matches[0];
}
// 使用提取的图片数据
if (isset($images[$imageIndex])) {
$src = $images[$imageIndex];
$imageIndex++;
}
return "<img{$beforeSrc}src=\"{$src}\"{$afterSrc}>";
},
$html
);
return $html;
}
/**
* 清理和美化 HTML 内容
*
* @param string $html
* @return string
*/
protected function cleanHtml(string $html): string
{
// 提取 body 内容
if (preg_match('/<body[^>]*>(.*?)<\/body>/is', $html, $matches)) {
$html = $matches[1];
}
// 添加基本样式
$styledHtml = '<div class="document-preview" style="
font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', Arial, sans-serif;
line-height: 1.6;
color: #333;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
">';
$styledHtml .= $html;
$styledHtml .= '</div>';
return $styledHtml;
return $renderService->render($markdownContent);
}
/**
* 检查文档是否可以预览
*
*
* @param Document $document
* @return bool
*/
public function canPreview(Document $document): bool
{
// 检查文件扩展名
$extension = strtolower(pathinfo($document->file_name, PATHINFO_EXTENSION));
// 目前支持 .doc 和 .docx
return in_array($extension, ['doc', 'docx']);
}
/**
* 获取文档预览的纯文本内容(用于搜索等)
*
* @param Document $document
* @return string
* @throws \Exception
*/
public function extractText(Document $document): string
{
try {
if (!Storage::disk('local')->exists($document->file_path)) {
throw new \Exception('文档文件不存在');
}
$filePath = Storage::disk('local')->path($document->file_path);
$phpWord = IOFactory::load($filePath);
$text = '';
foreach ($phpWord->getSections() as $section) {
foreach ($section->getElements() as $element) {
if (method_exists($element, 'getText')) {
$text .= $element->getText() . "\n";
}
}
}
return trim($text);
} catch (\Exception $e) {
throw new \Exception('文本提取失败:' . $e->getMessage());
}
return $document->conversion_status === 'completed'
&& !empty($document->markdown_path);
}
}

View File

@@ -5,93 +5,22 @@ namespace App\Services;
use App\Models\Document;
use App\Models\DownloadLog;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DocumentService
{
/**
* 上传文档
*
* @param UploadedFile $file 上传的文件
* @param string $title 文档标题
* @param string $type 文档类型 ('global' 'dedicated')
* @param int|null $groupId 分组 ID (专用文档必填)
* @param int $uploaderId 上传者用户 ID
* @return Document
* @throws \Exception
*/
public function uploadDocument(
UploadedFile $file,
string $title,
string $type,
?int $groupId,
int $uploaderId
): Document {
// 验证文件格式
$extension = strtolower($file->getClientOriginalExtension());
if (!in_array($extension, ['doc', 'docx'])) {
throw new \InvalidArgumentException('文件格式不支持,请上传 Word 文档(.doc 或 .docx');
}
// 验证专用文档必须有分组
if ($type === 'dedicated' && empty($groupId)) {
throw new \InvalidArgumentException('专用知识库文档必须指定所属分组');
}
// 使用事务确保一致性
return DB::transaction(function () use ($file, $title, $type, $groupId, $uploaderId) {
// 获取原始文件名
$originalFileName = $file->getClientOriginalName();
// 生成文件存储路径,使用原始文件名
$directory = 'documents/' . date('Y/m/d');
$filePath = $file->storeAs($directory, $originalFileName, 'local');
// 创建数据库记录,设置初始转换状态为 pending
$document = Document::create([
'title' => $title,
'file_path' => $filePath,
'file_name' => $originalFileName,
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'type' => $type,
'group_id' => $groupId,
'uploaded_by' => $uploaderId,
'description' => '',
'conversion_status' => 'pending',
]);
// 文档保存成功后,触发异步转换
$conversionService = app(DocumentConversionService::class);
$conversionService->queueConversion($document);
return $document;
});
}
/**
* 验证用户是否有权访问指定文档
*
* @param Document $document 要访问的文档
* @param User $user 用户
* @return bool
*/
public function validateDocumentAccess(Document $document, User $user): bool
{
// 如果是全局文档,所有用户都可以访问
if ($document->type === 'global') {
return true;
}
// 如果是专用文档,检查用户是否属于该文档的分组
if ($document->type === 'dedicated') {
// 获取用户所属的所有分组 ID
$userGroupIds = $user->groups()->pluck('groups.id')->toArray();
// 检查文档的分组 ID 是否在用户的分组列表中
return in_array($document->group_id, $userGroupIds);
}
@@ -100,25 +29,17 @@ class DocumentService
/**
* 下载文档
*
* @param Document $document 要下载的文档
* @param User $user 用户
* @return StreamedResponse
* @throws \Exception
*/
public function downloadDocument(Document $document, User $user): StreamedResponse
{
// 验证用户权限
if (!$this->validateDocumentAccess($document, $user)) {
throw new \Exception('您没有权限访问此文档');
}
// 检查文件是否存在
if (!Storage::disk('local')->exists($document->file_path)) {
throw new \Exception('文档不存在或已被删除');
}
// 返回文件流式响应,使用原始文件名
return Storage::disk('local')->download(
$document->file_path,
$document->file_name
@@ -127,11 +48,6 @@ class DocumentService
/**
* 记录文档下载日志
*
* @param Document $document 被下载的文档
* @param User $user 下载的用户
* @param string|null $ipAddress IP 地址
* @return DownloadLog
*/
public function logDownload(Document $document, User $user, ?string $ipAddress = null): DownloadLog
{

View File

@@ -18,7 +18,7 @@
"league/commonmark": "^2.8",
"maatwebsite/excel": "^3.1",
"meilisearch/meilisearch-php": "^1.16",
"phpoffice/phpword": "^1.4",
"paperdoc-dev/paperdoc-lib": "^0.3.5",
"solution-forest/filament-tree": "^2.0",
"spatie/laravel-activitylog": "^4.12",
"spatie/laravel-permission": "^6.24"

910
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,35 +6,16 @@ return [
|--------------------------------------------------------------------------
| 文档转换配置
|--------------------------------------------------------------------------
|
| 这里配置文档转换相关的设置,包括转换驱动、超时时间和队列配置。
|
*/
'conversion' => [
/*
| 转换驱动
| 支持的驱动: 'pandoc', 'phpword'
| 推荐使用 pandoc 以获得更好的转换质量
*/
'driver' => env('DOCUMENT_CONVERSION_DRIVER', 'pandoc'),
/*
| Pandoc 可执行文件路径
| 如果 pandoc 在系统 PATH 中,可以直接使用 'pandoc'
| 否则需要指定完整路径,如 '/usr/local/bin/pandoc'
*/
'pandoc_path' => env('PANDOC_PATH', '/opt/homebrew/bin/pandoc'),
/*
| 转换超时时间(秒)
| 大文档可能需要更长的转换时间
*/
'timeout' => env('CONVERSION_TIMEOUT', 300),
/*
| 队列名称
| 文档转换任务将被分发到此队列
*/
'queue' => env('CONVERSION_QUEUE', 'documents'),
@@ -49,37 +30,33 @@ return [
'retry_delay' => env('CONVERSION_RETRY_DELAY', 60),
],
/*
|--------------------------------------------------------------------------
| 支持的文件格式
|--------------------------------------------------------------------------
*/
'supported_formats' => [
'extensions' => ['docx', 'pdf', 'pptx', 'xlsx'],
'mime_types' => [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/pdf',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
],
/*
|--------------------------------------------------------------------------
| Markdown 配置
|--------------------------------------------------------------------------
|
| Markdown 渲染和处理相关的配置。
|
*/
'markdown' => [
/*
| Markdown 渲染器
| 支持的渲染器: 'commonmark', 'parsedown'
*/
'renderer' => env('MARKDOWN_RENDERER', 'commonmark'),
/*
| 是否清理 HTML 以防止 XSS 攻击
*/
'sanitize' => env('MARKDOWN_SANITIZE', true),
/*
| Markdown 预览长度(字符数)
| 用于在数据库中存储的内容摘要
*/
'preview_length' => env('MARKDOWN_PREVIEW_LENGTH', 500),
/*
| Markdown 文件最大大小(字节)
| 超过此大小的文件将分块处理
*/
'max_file_size' => env('MARKDOWN_MAX_FILE_SIZE', 10485760), // 10MB
],
@@ -87,26 +64,11 @@ return [
|--------------------------------------------------------------------------
| 存储配置
|--------------------------------------------------------------------------
|
| 文档和 Markdown 文件的存储配置。
|
*/
'storage' => [
/*
| 文档存储磁盘
*/
'documents_disk' => env('DOCUMENTS_DISK', 'documents'),
/*
| Markdown 存储磁盘
*/
'markdown_disk' => env('MARKDOWN_DISK', 'markdown'),
/*
| 是否按日期组织文件目录
| 格式: YYYY/MM/DD/
*/
'organize_by_date' => env('STORAGE_ORGANIZE_BY_DATE', true),
],

438
package-lock.json generated
View File

@@ -507,9 +507,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
"integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
"cpu": [
"arm"
],
@@ -521,9 +521,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
"integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
"cpu": [
"arm64"
],
@@ -535,9 +535,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
"integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
"cpu": [
"arm64"
],
@@ -549,9 +549,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
"integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
"cpu": [
"x64"
],
@@ -563,9 +563,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
"integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
"cpu": [
"arm64"
],
@@ -577,9 +577,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
"integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
"cpu": [
"x64"
],
@@ -591,9 +591,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
"integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
"cpu": [
"arm"
],
@@ -605,9 +605,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
"integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
"cpu": [
"arm"
],
@@ -619,9 +619,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
"integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
"cpu": [
"arm64"
],
@@ -633,9 +633,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
"integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
"cpu": [
"arm64"
],
@@ -647,9 +647,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
"integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
"cpu": [
"loong64"
],
@@ -661,9 +661,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
"integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
"cpu": [
"loong64"
],
@@ -675,9 +675,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
"integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
"cpu": [
"ppc64"
],
@@ -689,9 +689,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
"integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
"cpu": [
"ppc64"
],
@@ -703,9 +703,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
"integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
"cpu": [
"riscv64"
],
@@ -717,9 +717,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
"integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
"cpu": [
"riscv64"
],
@@ -731,9 +731,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
"integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
"cpu": [
"s390x"
],
@@ -745,9 +745,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
"integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
"cpu": [
"x64"
],
@@ -759,9 +759,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
"integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
"cpu": [
"x64"
],
@@ -773,9 +773,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
"integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
"cpu": [
"x64"
],
@@ -787,9 +787,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
"integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
"cpu": [
"arm64"
],
@@ -801,9 +801,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
"integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
"cpu": [
"arm64"
],
@@ -815,9 +815,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
"integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
"cpu": [
"ia32"
],
@@ -829,9 +829,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
"integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
"cpu": [
"x64"
],
@@ -843,9 +843,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
"integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
"cpu": [
"x64"
],
@@ -857,49 +857,49 @@
]
},
"node_modules/@tailwindcss/node": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
"integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.19.0",
"jiti": "^2.6.1",
"lightningcss": "1.31.1",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.2.1"
"tailwindcss": "4.2.2"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
"integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.2.1",
"@tailwindcss/oxide-darwin-arm64": "4.2.1",
"@tailwindcss/oxide-darwin-x64": "4.2.1",
"@tailwindcss/oxide-freebsd-x64": "4.2.1",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
"@tailwindcss/oxide-linux-x64-musl": "4.2.1",
"@tailwindcss/oxide-wasm32-wasi": "4.2.1",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
"@tailwindcss/oxide-android-arm64": "4.2.2",
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
"@tailwindcss/oxide-darwin-x64": "4.2.2",
"@tailwindcss/oxide-freebsd-x64": "4.2.2",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
"@tailwindcss/oxide-linux-x64-musl": "4.2.2",
"@tailwindcss/oxide-wasm32-wasi": "4.2.2",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
"integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
"cpu": [
"arm64"
],
@@ -914,9 +914,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
"integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
"cpu": [
"arm64"
],
@@ -931,9 +931,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
"integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
"cpu": [
"x64"
],
@@ -948,9 +948,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
"integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
"cpu": [
"x64"
],
@@ -965,9 +965,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
"integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
"cpu": [
"arm"
],
@@ -982,9 +982,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
"integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
"cpu": [
"arm64"
],
@@ -999,9 +999,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
"integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
"cpu": [
"arm64"
],
@@ -1016,9 +1016,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
"integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
"cpu": [
"x64"
],
@@ -1033,9 +1033,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
"integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
"cpu": [
"x64"
],
@@ -1050,9 +1050,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
"integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -1080,9 +1080,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
"integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
"cpu": [
"arm64"
],
@@ -1097,9 +1097,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
"integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
"cpu": [
"x64"
],
@@ -1114,18 +1114,18 @@
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz",
"integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
"integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.2.1",
"@tailwindcss/oxide": "4.2.1",
"tailwindcss": "4.2.1"
"@tailwindcss/node": "4.2.2",
"@tailwindcss/oxide": "4.2.2",
"tailwindcss": "4.2.2"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7"
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@types/estree": {
@@ -1356,9 +1356,9 @@
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.20.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
"integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==",
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1713,9 +1713,9 @@
}
},
"node_modules/lightningcss": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
@@ -1729,23 +1729,23 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.31.1",
"lightningcss-darwin-arm64": "1.31.1",
"lightningcss-darwin-x64": "1.31.1",
"lightningcss-freebsd-x64": "1.31.1",
"lightningcss-linux-arm-gnueabihf": "1.31.1",
"lightningcss-linux-arm64-gnu": "1.31.1",
"lightningcss-linux-arm64-musl": "1.31.1",
"lightningcss-linux-x64-gnu": "1.31.1",
"lightningcss-linux-x64-musl": "1.31.1",
"lightningcss-win32-arm64-msvc": "1.31.1",
"lightningcss-win32-x64-msvc": "1.31.1"
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
"integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
@@ -1764,9 +1764,9 @@
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
"integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
@@ -1785,9 +1785,9 @@
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
"integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
@@ -1806,9 +1806,9 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
"integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
@@ -1827,9 +1827,9 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
"integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
@@ -1848,9 +1848,9 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
"integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
@@ -1869,9 +1869,9 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
"integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
@@ -1890,9 +1890,9 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
"integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
@@ -1911,9 +1911,9 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
"integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
@@ -1932,9 +1932,9 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
"integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
@@ -1953,9 +1953,9 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
"integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
@@ -2116,9 +2116,9 @@
}
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2132,31 +2132,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"@rollup/rollup-android-arm-eabi": "4.60.0",
"@rollup/rollup-android-arm64": "4.60.0",
"@rollup/rollup-darwin-arm64": "4.60.0",
"@rollup/rollup-darwin-x64": "4.60.0",
"@rollup/rollup-freebsd-arm64": "4.60.0",
"@rollup/rollup-freebsd-x64": "4.60.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
"@rollup/rollup-linux-arm-musleabihf": "4.60.0",
"@rollup/rollup-linux-arm64-gnu": "4.60.0",
"@rollup/rollup-linux-arm64-musl": "4.60.0",
"@rollup/rollup-linux-loong64-gnu": "4.60.0",
"@rollup/rollup-linux-loong64-musl": "4.60.0",
"@rollup/rollup-linux-ppc64-gnu": "4.60.0",
"@rollup/rollup-linux-ppc64-musl": "4.60.0",
"@rollup/rollup-linux-riscv64-gnu": "4.60.0",
"@rollup/rollup-linux-riscv64-musl": "4.60.0",
"@rollup/rollup-linux-s390x-gnu": "4.60.0",
"@rollup/rollup-linux-x64-gnu": "4.60.0",
"@rollup/rollup-linux-x64-musl": "4.60.0",
"@rollup/rollup-openbsd-x64": "4.60.0",
"@rollup/rollup-openharmony-arm64": "4.60.0",
"@rollup/rollup-win32-arm64-msvc": "4.60.0",
"@rollup/rollup-win32-ia32-msvc": "4.60.0",
"@rollup/rollup-win32-x64-gnu": "4.60.0",
"@rollup/rollup-win32-x64-msvc": "4.60.0",
"fsevents": "~2.3.2"
}
},
@@ -2238,9 +2238,9 @@
}
},
"node_modules/tailwindcss": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
"integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"dev": true,
"license": "MIT"
},

View File

@@ -10,7 +10,7 @@
<h3 class="text-sm font-medium text-danger-800 dark:text-danger-200">
文档转换失败
</h3>
<div class="mt-2 text-sm text-danger-700 dark:text-danger-300">
<div class="mt-2 text-sm text-danger-700 dark:text-danger-300 space-y-1">
<p><strong>文档:</strong>{{ $document->title }}</p>
<p><strong>文件名:</strong>{{ $document->file_name }}</p>
<p><strong>失败时间:</strong>{{ $document->updated_at->format('Y年m月d日 H:i:s') }}</p>
@@ -35,14 +35,14 @@
</div>
<div class="ml-3 flex-1">
<h3 class="text-sm font-medium text-info-800 dark:text-info-200">
常见问题及解决方案
常见原因
</h3>
<div class="mt-2 text-sm text-info-700 dark:text-info-300">
<ul class="list-disc list-inside space-y-1">
<li>如果错误提示无法连接到 Meilisearch请确保搜索服务正常运行</li>
<li>如果错误提示文件损坏或格式不支持,请检查原始文档是否完整</li>
<li>如果错误提示超时,可能是文档过大或包含大量图片,请尝试优化文档</li>
<li>您可以点击"重试转换"按钮重新尝试转换此文档</li>
<li>文件损坏或格式异常</li>
<li>PDF 扫描件或纯图片文档无法提取文本内容</li>
<li>文档过大或图片过多,请优化后重新上传</li>
<li>您可以点击 "重试转换" 按钮重新尝试</li>
</ul>
</div>
</div>

View File

@@ -10,15 +10,17 @@
<h3 class="text-base font-semibold text-danger-800 dark:text-danger-200">
文档转换失败
</h3>
<div class="mt-3 text-sm text-danger-700 dark:text-danger-300">
<p class="mb-1"><strong>失败时间</strong>{{ $document->updated_at->format('Y年m月d日 H:i:s') }}</p>
<div class="mt-3 text-sm text-danger-700 dark:text-danger-300 space-y-1">
<p><strong>文档</strong>{{ $document->title }}</p>
<p><strong>文件名:</strong>{{ $document->file_name }}</p>
<p><strong>失败时间:</strong>{{ $document->updated_at->format('Y年m月d日 H:i:s') }}</p>
</div>
</div>
</div>
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">错误详情</h4>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">错误信息</h4>
<div class="rounded-lg bg-gray-100 dark:bg-gray-900 p-4 border border-gray-300 dark:border-gray-700">
<pre class="text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words font-mono leading-relaxed">{{ $document->conversion_error }}</pre>
</div>
@@ -33,25 +35,21 @@
</div>
<div class="ml-3 flex-1">
<h3 class="text-sm font-semibold text-info-800 dark:text-info-200 mb-2">
常见问题及解决方案
常见原因
</h3>
<div class="text-sm text-info-700 dark:text-info-300">
<ul class="space-y-2">
<li class="flex items-start">
<span class="mr-2"></span>
<span><strong>无法连接到 Meilisearch</strong>请确保搜索服务正常运行,可以联系系统管理员检查服务状态</span>
<span><strong>文件损坏或格式异常</strong>请确认原始文档可以正常打开。</span>
</li>
<li class="flex items-start">
<span class="mr-2"></span>
<span><strong>文件损坏或格式不支持</strong>请检查原始文档是否完整,确保文件格式为 .doc .docx</span>
<span><strong>转换超时</strong>文档过大或包含大量图片,请尝试拆分或优化后重新上传</span>
</li>
<li class="flex items-start">
<span class="mr-2"></span>
<span><strong>转换超时</strong>可能是文档过大或包含大量图片,建议优化文档后重新上传</span>
</li>
<li class="flex items-start">
<span class="mr-2"></span>
<span><strong>Pandoc 错误:</strong>文档可能包含不支持的格式或特殊内容,请尝试简化文档格式</span>
<span><strong>内容为空</strong>PDF 扫描件或纯图片文档无法提取文本内容</span>
</li>
</ul>
</div>
@@ -71,7 +69,7 @@
下一步操作
</h3>
<div class="text-sm text-warning-700 dark:text-warning-300">
<p>您可以点击页面右上角的 <strong>"重试转换"</strong> 按钮重新尝试转换此文档。如果问题持续存在,请联系系统管理员或尝试重新上传文档。</p>
<p>您可以点击页面右上角的 <strong>"重试转换"</strong> 按钮重新尝试。如果问题持续存在,请联系系统管理员或尝试重新上传文档。</p>
</div>
</div>
</div>

View File

@@ -1,11 +1,11 @@
@php
use App\Services\DocumentPreviewService;
$previewService = app(DocumentPreviewService::class);
$canPreview = $previewService->canPreview($document);
$htmlContent = null;
$error = null;
if ($canPreview) {
try {
$htmlContent = $previewService->convertToHtml($document);
@@ -16,30 +16,39 @@
@endphp
<div class="document-preview-container">
@if ($error)
@if ($document->conversion_status === 'failed')
{{-- 转换失败状态由"转换错误信息"区块处理,此处不重复展示 --}}
@elseif ($document->conversion_status === 'processing')
<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>
@elseif ($document->conversion_status === 'pending')
<div class="rounded-lg bg-warning-50 p-4 text-warning-600 dark:bg-warning-400/10 dark:text-warning-400">
<div class="flex items-center gap-3">
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p>文档等待转换中...</p>
</div>
</div>
@elseif ($error)
<div class="rounded-lg bg-danger-50 p-4 text-danger-600 dark:bg-danger-400/10 dark:text-danger-400">
<div class="flex items-center gap-3">
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<div>
<p class="font-semibold">预览失败</p>
<p class="font-semibold">预览加载失败</p>
<p class="text-sm">{{ $error }}</p>
</div>
</div>
</div>
@elseif (!$canPreview)
<div class="rounded-lg bg-warning-50 p-4 text-warning-600 dark:bg-warning-400/10 dark:text-warning-400">
<div class="flex items-center gap-3">
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
<div>
<p class="font-semibold">无法预览此文档</p>
<p class="text-sm">该文档格式不支持在线预览,请下载后查看。</p>
</div>
</div>
</div>
@elseif ($htmlContent)
<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">
@@ -52,64 +61,48 @@
</span>
</div>
</div>
<div class="max-h-[600px] overflow-y-auto p-6">
<div class="prose prose-sm max-w-none dark:prose-invert">
{!! $htmlContent !!}
</div>
</div>
<div class="border-t border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900">
<p class="text-xs text-gray-500 dark:text-gray-400">
提示:这是文档的预览版本,可能与原始格式略有差异。如需查看完整格式,请下载文档。
</p>
</div>
</div>
@else
<div class="rounded-lg bg-gray-50 p-4 text-gray-600 dark:bg-gray-800 dark:text-gray-400">
<div class="flex items-center gap-3">
<svg class="h-5 w-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p>正在加载文档预览...</p>
</div>
</div>
@endif
</div>
<style>
.document-preview-container .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';
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;
}

View File

@@ -1,124 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\Document;
use App\Models\User;
use App\Services\DocumentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class DocumentFileNameTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
}
public function test_上传文档时保留原始文件名(): void
{
$user = User::factory()->create();
$documentService = new DocumentService();
// 创建一个测试文件,使用特定的文件名
$originalFileName = '测试文档_2024.docx';
$file = UploadedFile::fake()->create($originalFileName, 100, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
// 上传文档
$document = $documentService->uploadDocument(
$file,
'测试文档',
'global',
null,
$user->id
);
// 验证文件名被正确保存
$this->assertEquals($originalFileName, $document->file_name);
// 验证文件路径包含原始文件名
$this->assertStringContainsString($originalFileName, $document->file_path);
}
public function test_下载文档时使用原始文件名(): void
{
$user = User::factory()->create();
$documentService = new DocumentService();
// 创建一个测试文件
$originalFileName = '重要文档.docx';
$file = UploadedFile::fake()->create($originalFileName, 100, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
// 上传文档
$document = $documentService->uploadDocument(
$file,
'重要文档',
'global',
null,
$user->id
);
// 下载文档
$response = $documentService->downloadDocument($document, $user);
// 验证响应头中的文件名(可能被 URL 编码)
$contentDisposition = $response->headers->get('Content-Disposition');
// 检查是否包含文件名(可能是原始格式或 URL 编码格式)
$encodedFileName = rawurlencode($originalFileName);
$this->assertTrue(
str_contains($contentDisposition, $originalFileName) ||
str_contains($contentDisposition, $encodedFileName),
"Content-Disposition 应该包含原始文件名或其编码版本"
);
}
public function test_中文文件名正确处理(): void
{
$user = User::factory()->create();
$documentService = new DocumentService();
// 创建一个带中文名称的测试文件
$originalFileName = '知识库管理系统需求文档.docx';
$file = UploadedFile::fake()->create($originalFileName, 100, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
// 上传文档
$document = $documentService->uploadDocument(
$file,
'需求文档',
'global',
null,
$user->id
);
// 验证中文文件名被正确保存
$this->assertEquals($originalFileName, $document->file_name);
}
public function test_特殊字符文件名正确处理(): void
{
$user = User::factory()->create();
$documentService = new DocumentService();
// 创建一个带特殊字符的测试文件
$originalFileName = '文档(2024-01-01)_v1.0.docx';
$file = UploadedFile::fake()->create($originalFileName, 100, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
// 上传文档
$document = $documentService->uploadDocument(
$file,
'版本文档',
'global',
null,
$user->id
);
// 验证特殊字符文件名被正确保存
$this->assertEquals($originalFileName, $document->file_name);
}
}

View File

@@ -1,117 +0,0 @@
<?php
use App\Models\Document;
use App\Models\Group;
use App\Models\User;
use App\Services\DocumentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
Storage::fake('local');
$this->service = new DocumentService();
});
test('可以上传有效的 Word 文档', function () {
$user = User::factory()->create();
$file = UploadedFile::fake()->create('test.docx', 100, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
$document = $this->service->uploadDocument(
$file,
'测试文档',
'global',
null,
$user->id
);
expect($document)->toBeInstanceOf(Document::class);
expect($document->title)->toBe('测试文档');
expect($document->type)->toBe('global');
expect($document->uploaded_by)->toBe($user->id);
});
test('上传非 Word 文档应该抛出异常', function () {
$user = User::factory()->create();
$file = UploadedFile::fake()->create('test.pdf', 100, 'application/pdf');
$this->service->uploadDocument(
$file,
'测试文档',
'global',
null,
$user->id
);
})->throws(\InvalidArgumentException::class, '文件格式不支持,请上传 Word 文档(.doc 或 .docx');
test('专用文档没有分组应该抛出异常', function () {
$user = User::factory()->create();
$file = UploadedFile::fake()->create('test.docx', 100, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
$this->service->uploadDocument(
$file,
'测试文档',
'dedicated',
null,
$user->id
);
})->throws(\InvalidArgumentException::class, '专用知识库文档必须指定所属分组');
test('用户可以访问全局文档', function () {
$user = User::factory()->create();
$document = Document::factory()->global()->create();
$hasAccess = $this->service->validateDocumentAccess($document, $user);
expect($hasAccess)->toBeTrue();
});
test('用户可以访问自己分组的专用文档', function () {
$group = Group::factory()->create();
$user = User::factory()->create();
$user->groups()->attach($group->id);
$document = Document::factory()->dedicated($group->id)->create();
$hasAccess = $this->service->validateDocumentAccess($document, $user);
expect($hasAccess)->toBeTrue();
});
test('用户不能访问其他分组的专用文档', function () {
$group1 = Group::factory()->create();
$group2 = Group::factory()->create();
$user = User::factory()->create();
$user->groups()->attach($group1->id);
$document = Document::factory()->dedicated($group2->id)->create();
$hasAccess = $this->service->validateDocumentAccess($document, $user);
expect($hasAccess)->toBeFalse();
});
test('可以记录文档下载日志', function () {
$user = User::factory()->create();
$document = Document::factory()->global()->create();
$log = $this->service->logDownload($document, $user, '127.0.0.1');
expect($log->document_id)->toBe($document->id);
expect($log->user_id)->toBe($user->id);
expect($log->ip_address)->toBe('127.0.0.1');
expect($log->downloaded_at)->not->toBeNull();
});
test('下载文档时验证权限', function () {
$group = Group::factory()->create();
$user = User::factory()->create();
// 用户不属于该分组
$document = Document::factory()->dedicated($group->id)->create();
$this->service->downloadDocument($document, $user);
})->throws(\Exception::class, '您没有权限访问此文档');