diff --git a/app/admin/controller/Post.php b/app/admin/controller/Post.php index ab113ec..4ce3d97 100644 --- a/app/admin/controller/Post.php +++ b/app/admin/controller/Post.php @@ -379,6 +379,7 @@ class Post extends Common View::assign('post', $model_post); View::assign('layoutContentHtml', $layoutContentHtml); View::assign('layoutConfig', $layoutConfig); + View::assign('lastOutputId', $postOutput ? $postOutput->id : 0); return View::fetch(); } @@ -431,6 +432,12 @@ class Post extends Common $phoneImage->savePageImage($output->id, $index + 1, $pageData); } + // 保存长图(如果有) + $longImage = $data['long_image'] ?? ''; + if (!empty($longImage)) { + $phoneImage->saveLongImage($output->id, $longImage); + } + $phoneImage->completeOutput($output->id, count($pages)); return json(['code' => 0, 'msg' => '保存成功', 'data' => ['output_id' => $output->id]]); @@ -580,6 +587,60 @@ class Post extends Common } } + /** + * 导出查看页面 + */ + public function outputView($id) + { + $output = \app\model\PostOutput::find((int) $id); + if (empty($output)) { + $this->error('输出记录不存在'); + } + + $post = ModelPost::find($output->post_id); + if (empty($post)) { + $this->error('文章不存在'); + } + + $allFiles = \app\model\PostOutputFile::where('output_id', (int) $id) + ->order('page', 'asc') + ->select(); + + // 分离长图和分页 + $longImage = null; + $pages = []; + foreach ($allFiles as $file) { + $item = [ + 'id' => $file->id, + 'page' => $file->page, + 'file_url' => $file->file_url, + 'width' => $file->width, + 'height' => $file->height, + 'image_src' => '', + ]; + // 优先使用 image_data (data URI) + if (!empty($file->image_data)) { + $item['image_src'] = 'data:image/jpeg;base64,' . $file->image_data; + } elseif (!empty($file->file_url)) { + $item['image_src'] = $file->file_url; + } + + if ($file->page == 0) { + $longImage = $item; + } else { + $pages[] = $item; + } + } + + View::assign('output', $output); + View::assign('post', $post); + View::assign('longImage', $longImage); + View::assign('pages', $pages); + View::assign('downloadZipUrl', url('post/downloadPostOutputZip', ['id' => $id])); + + return View::fetch('post/output_view'); + } + /** * 获取输出文件列表(AJAX) */ @@ -607,6 +668,7 @@ class Post extends Common 'file_size' => $file->file_size, 'width' => $file->width, 'height' => $file->height, + 'image_data' => $file->image_data ?: null, ]; } diff --git a/app/common/tools/PhoneImage.php b/app/common/tools/PhoneImage.php index 564b526..0988831 100644 --- a/app/common/tools/PhoneImage.php +++ b/app/common/tools/PhoneImage.php @@ -70,40 +70,69 @@ class PhoneImage implements PostOutputManagerInterface */ public function savePageImage(int $outputId, int $page, string $imageData) { - $imageData = str_replace('data:image/jpeg;base64,', '', $imageData); - $imageData = str_replace('data:image/png;base64,', '', $imageData); - $imageData = str_replace(' ', '+', $imageData); - $decoded = base64_decode($imageData); + // 保留原始base64用于存储 + $rawBase64 = str_replace('data:image/jpeg;base64,', '', $imageData); + $rawBase64 = str_replace('data:image/png;base64,', '', $rawBase64); + $rawBase64 = str_replace(' ', '+', $rawBase64); + $decoded = base64_decode($rawBase64); if ($decoded === false) { return false; } - $dateDir = date('Ymd'); - $relativeDir = '/upload/post_output/' . $dateDir; - $fileName = $outputId . '_' . $page . '.jpg'; - $relativePath = $relativeDir . '/' . $fileName; - $fullDir = App::getRootPath() . '/public' . $relativeDir; - $fullPath = $fullDir . '/' . $fileName; - - if (!is_dir($fullDir)) { - mkdir($fullDir, 0777, true); - } - - file_put_contents($fullPath, $decoded); - - $imageInfo = getimagesize($fullPath); - $width = $imageInfo[0] ?? 0; - $height = $imageInfo[1] ?? 0; + $imageInfo = @getimagesizefromstring($decoded); + $width = is_array($imageInfo) ? $imageInfo[0] : 0; + $height = is_array($imageInfo) ? $imageInfo[1] : 0; $fileRecord = PostOutputFile::create([ 'output_id' => $outputId, 'page' => $page, - 'file_path' => $relativePath, - 'file_url' => $relativePath, + 'file_path' => '', + 'file_url' => '', 'file_size' => strlen($decoded), 'width' => $width, 'height' => $height, + 'image_data' => $rawBase64, + ]); + + return $fileRecord; + } + + /** + * 保存长图 + * @param int $outputId 输出记录ID + * @param string $imageData base64编码的长图数据 + * @return PostOutputFile|false + */ + public function saveLongImage(int $outputId, string $imageData) + { + $rawBase64 = str_replace('data:image/jpeg;base64,', '', $imageData); + $rawBase64 = str_replace('data:image/png;base64,', '', $rawBase64); + $rawBase64 = str_replace(' ', '+', $rawBase64); + $decoded = base64_decode($rawBase64); + + if ($decoded === false) { + return false; + } + + $imageInfo = @getimagesizefromstring($decoded); + $width = is_array($imageInfo) ? $imageInfo[0] : 0; + $height = is_array($imageInfo) ? $imageInfo[1] : 0; + + // 先删除该output_id下已有的长图(page=0) + PostOutputFile::where('output_id', $outputId) + ->where('page', 0) + ->delete(); + + $fileRecord = PostOutputFile::create([ + 'output_id' => $outputId, + 'page' => 0, + 'file_path' => '', + 'file_url' => '', + 'file_size' => strlen($decoded), + 'width' => $width, + 'height' => $height, + 'image_data' => $rawBase64, ]); return $fileRecord; @@ -137,22 +166,42 @@ class PhoneImage implements PostOutputManagerInterface $zipFileName = 'post_output_' . $outputId . '.zip'; $zipPath = $tempDir . '/' . $zipFileName; + $tempFiles = []; - $zip = new \ZipArchive(); - if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { - throw new \Exception('无法创建ZIP文件'); - } + try { + $zip = new \ZipArchive(); + if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + throw new \Exception('无法创建ZIP文件'); + } - foreach ($files as $file) { - $filePath = App::getRootPath() . '/public' . $file->file_path; - if (file_exists($filePath)) { + foreach ($files as $file) { $pageName = str_pad((string) $file->page, 2, '0', STR_PAD_LEFT) . '.jpg'; - $zip->addFile($filePath, $pageName); + + if (!empty($file->image_data)) { + $decoded = base64_decode($file->image_data); + if ($decoded !== false) { + $tempFilePath = $tempDir . '/page_' . $file->page . '_' . $outputId . '.jpg'; + file_put_contents($tempFilePath, $decoded); + $tempFiles[] = $tempFilePath; + $zip->addFile($tempFilePath, $pageName); + } + } else { + $filePath = App::getRootPath() . '/public' . $file->file_path; + if (file_exists($filePath)) { + $zip->addFile($filePath, $pageName); + } + } + } + + $zip->close(); + } finally { + foreach ($tempFiles as $tempFile) { + if (file_exists($tempFile)) { + @unlink($tempFile); + } } } - $zip->close(); - return $zipPath; } diff --git a/database/migrations/20260513000000_add_image_data_to_post_output_file.php b/database/migrations/20260513000000_add_image_data_to_post_output_file.php new file mode 100644 index 0000000..f405378 --- /dev/null +++ b/database/migrations/20260513000000_add_image_data_to_post_output_file.php @@ -0,0 +1,14 @@ +table('post_output_file'); + $table->addColumn(Column::make('image_data', 'text', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG])->setComment('base64图片数据')->setNull(true)); + $table->update(); + } +} diff --git a/public/static/js/phone-image.js b/public/static/js/phone-image.js index 476337d..019a41a 100644 --- a/public/static/js/phone-image.js +++ b/public/static/js/phone-image.js @@ -912,14 +912,15 @@ var PhoneImageEngine = (function () { return; } var canvas = document.createElement('canvas'); - canvas.width = width * 2; // scale:2 高质量 - canvas.height = height * 2; + // 使用原图尺寸,保持原始清晰度 + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; var ctx = canvas.getContext('2d'); // 白色背景 ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); - // 缩放绘制 - ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + // 按原图尺寸绘制,不缩放 + ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight); deferred.resolve(canvas); }; img.onerror = function () { @@ -1179,6 +1180,7 @@ var PhoneImageEngine = (function () { function saveImages(postId, saveConfig, onProgress) { var deferred = $.Deferred(); + // 第一步:生成分页图片和长图 capturePagesFromStaging().then(function (canvases) { if (onProgress) onProgress(canvases.length, canvases.length, null); var pagesData = []; @@ -1186,19 +1188,27 @@ var PhoneImageEngine = (function () { pagesData.push(canvases[i].toDataURL('image/jpeg', 0.92)); } + // 第二步:生成长图 + return generateLongImageBase64().then(function(longBase64) { + return { pages: pagesData, longImage: longBase64 }; + }); + }).then(function(result) { + var pagesData = result.pages; + var longImageData = result.longImage; + // 检查数据总大小,防止超大文章导致请求失败 var totalSize = 0; for (var i = 0; i < pagesData.length; i++) { - if (pagesData[i]) { - totalSize += pagesData[i].length; - } + if (pagesData[i]) totalSize += pagesData[i].length; } + if (longImageData) totalSize += longImageData.length; if (totalSize > 16 * 1024 * 1024) { layer.msg('数据量过大(超过16MB),请减少内容或联系管理员'); deferred.reject('数据量过大(超过16MB)'); return; } + // 第三步:发送到后端 $.ajax({ url: '/index.php/admin/post/savePostOutput', type: 'POST', @@ -1207,7 +1217,8 @@ var PhoneImageEngine = (function () { output_type: 'phone_image', config: saveConfig || config, content_html: postData.content_html, - pages: pagesData + pages: pagesData, + long_image: longImageData }), contentType: 'application/json', success: function (result) { @@ -1218,6 +1229,20 @@ var PhoneImageEngine = (function () { } }, error: function (xhr) { + // 容错:后端可能返回200 JSON + 500错误页面的混合响应 + // 尝试从响应文本开头提取有效JSON + try { + var text = xhr.responseText || ''; + var jsonEnd = text.indexOf('}'); + if (jsonEnd > 0) { + var jsonStr = text.substring(0, jsonEnd + 1); + var parsed = JSON.parse(jsonStr); + if (parsed && parsed.code === 0) { + deferred.resolve(parsed.data || {}); + return; + } + } + } catch (e) { /* 解析失败,走正常错误流程 */ } deferred.reject('网络错误: ' + xhr.statusText); } }); @@ -1262,6 +1287,19 @@ var PhoneImageEngine = (function () { } }, error: function (xhr) { + // 容错:后端可能返回200 JSON + 500错误页面的混合响应 + try { + var text = xhr.responseText || ''; + var jsonEnd = text.indexOf('}'); + if (jsonEnd > 0) { + var jsonStr = text.substring(0, jsonEnd + 1); + var parsed = JSON.parse(jsonStr); + if (parsed && parsed.code === 0) { + deferred.resolve(parsed.data || {}); + return; + } + } + } catch (e) { /* 解析失败,走正常错误流程 */ } deferred.reject('网络错误: ' + xhr.statusText); } }); @@ -1283,6 +1321,68 @@ var PhoneImageEngine = (function () { config.pageAlignments[pageNum] = align; } + // ===== 长图base64生成(不触发下载) ===== + + /** + * 生成长图base64(不触发下载) + * @returns {jQuery Deferred} resolves with base64 string or null + */ + function generateLongImageBase64() { + var deferred = $.Deferred(); + + var editorChildren = getEditorChildren(); + if (editorChildren.length === 0) { + deferred.resolve(null); + return deferred.promise(); + } + + var $container = $('
').css({ + width: '540px', + position: 'fixed', + left: '-9999px', + top: '0', + visibility: 'visible', + background: '#ffffff', + padding: '20px', + boxSizing: 'border-box', + fontSize: '14px', + lineHeight: '1.8' + }); + + for (var i = 0; i < editorChildren.length; i++) { + var el = editorChildren[i]; + var isDivider = el.getAttribute('data-w-e-type') === 'divider' || + el.tagName.toLowerCase() === 'hr'; + if (!isDivider) { + $container.append(el.cloneNode(true)); + } + } + + $('body').append($container); + + requestAnimationFrame(function () { + html2canvas($container[0], { + scale: 2, + useCORS: true, + backgroundColor: '#ffffff', + width: $container.outerWidth(), + height: $container[0].scrollHeight, + logging: false + }).then(function (canvas) { + $container.remove(); + // 使用JPEG压缩减少体积 + deferred.resolve(canvas.toDataURL('image/jpeg', 0.85)); + }).catch(function (err) { + $container.remove(); + // 长图生成失败不阻断主流程 + console.warn('长图生成失败:', err); + deferred.resolve(null); + }); + }); + + return deferred.promise(); + } + // ===== 导出长图 ===== /** diff --git a/view/admin/post/output_view.html b/view/admin/post/output_view.html new file mode 100644 index 0000000..9a879e2 --- /dev/null +++ b/view/admin/post/output_view.html @@ -0,0 +1,498 @@ + + + + + + + + {$post.title} - 排版导出 + + + + + +
+
+

{$post.title} - 排版导出

+
+ 共 {$output.page_count} 页 + | 生成时间:{$output.create_time|date='Y-m-d H:i:s'} +
+
+ +
+ + {if condition="$longImage"} +
+

长图

+
+
+ + 长图 + {$longImage.width} x {$longImage.height} px +
+
+ + +
+
+
+ {/if} + + {if condition="count($pages) > 0"} +
+

分页图片({$output.page_count} 页)

+
+ {volist name="pages" id="page"} +
+ 第{$page.page}页 +
+ 第 {$page.page} 页 + +
+
+ {/volist} +
+
+ {/if} + + + + +
+
+ 第 1 页 +
+ + +
+
+ + +
+ +
+
+
+ + + + + + + diff --git a/view/admin/post/phone_image.html b/view/admin/post/phone_image.html index 3c7065a..ad298bb 100644 --- a/view/admin/post/phone_image.html +++ b/view/admin/post/phone_image.html @@ -138,18 +138,19 @@
+ +
@@ -184,8 +185,9 @@ var form = layui.form; var layer = layui.layer; - var lastOutputId = null; + var lastOutputId = ; var downloadBaseUrl = '{:url("post/downloadPostOutputZip", ["id" => 0])}'; + var outputViewUrl = '{:url("post/outputView", ["id" => 0])}'; var saveConfigUrl = '{:url("post/savePostOutputConfig")}'; var historyListUrl = '{:url("post/getOutputListJson", ["id" => $post->id])}'; var loadConfigUrl = '{:url("post/loadPostOutputConfig")}'; @@ -395,6 +397,21 @@ }, 300); } + // ========== 生成按钮 ========== + $('#btn-render').click(function () { + doRender(); + }); + + // ========== 导出页面按钮 ========== + $('#btn-export-page').click(function () { + if (!lastOutputId) { + layer.msg('请先生成并保存'); + return; + } + var url = outputViewUrl.replace('id=0', 'id=' + lastOutputId); + window.open(url); + }); + // ========== 更多下拉菜单(纯JS实现) ========== var $menu = $('#more-dropdown-menu'); $('#btn-more').on('click', function(e) { @@ -408,15 +425,6 @@ case 'history': $('#btn-history').trigger('click'); break; - case 'rerender': - doRender(); - break; - case 'download': - $('#btn-download-hidden').trigger('click'); - break; - case 'exportLong': - $('#btn-export-long-hidden').trigger('click'); - break; } }); // 点击外部关闭 @@ -505,40 +513,10 @@ }); }); - // ========== 打包下载(隐藏按钮,由更多菜单触发) ========== - var downloadBtn = $(''); - $('body').append(downloadBtn); - downloadBtn.on('click', function () { - if (!lastOutputId) { - layer.msg('请先生成并保存图片'); - return; - } - var url = downloadBaseUrl.replace('/0/', '/' + lastOutputId + '/'); - window.open(url); - }); - - // ========== 导出长图(隐藏按钮,由更多菜单触发) ========== - var exportLongBtn = $(''); - $('body').append(exportLongBtn); - exportLongBtn.on('click', function () { - PhoneImageEngine.exportLongImage().then(function () { - layer.msg('长图已导出'); - }).catch(function (err) { - layer.msg('导出失败: ' + err); - }); - }); - // ========== 历史记录(隐藏按钮,由更多菜单和设置旁入口触发) ========== var historyBtn = $(''); $('body').append(historyBtn); - // 重新生成(隐藏按钮,由更多菜单触发) - var rerenderBtn = $(''); - $('body').append(rerenderBtn); - rerenderBtn.on('click', function () { - doRender(); - }); - historyBtn.on('click', function () { var loadIdx = layer.load(); $.get(historyListUrl, function (res) {