Files
ulthon_information/view/admin/post/phone_image.html
augushong 10879a8037 feat(output_view): 导出页面重构 - 长图卡片化展示、缩略图增大、预览优化、纯图片页原图保存
- output_view.html: 长图改为固定高度卡片(70px),Blob URL查看,缩略图minmax(280px,1fr),
  竖图预览优先填充视口高度,下载功能完整保留
- phone-image.js: renderPureImageToCanvas()使用naturalWidth/naturalHeight保持原图分辨率,
  新增长图生成和保存功能
- Post.php: 新增outputView()方法提供导出页面渲染数据
- PhoneImage.php: 图片数据改为DB存储,新增saveLongImage()方法
- phone_image.html: 添加导出页面入口按钮
- 新增数据库迁移: post_output_file表添加image_data字段
2026-05-14 23:22:19 +08:00

618 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$post.title} - 手机图片排版</title>
<link rel="stylesheet" href="/static/lib/layui/css/layui.css">
<link rel="stylesheet" href="/static/lib/wangeditor/css/style.css">
<link rel="stylesheet" href="/static/css/phone-image-templates.css">
<link rel="stylesheet" href="/static/css/phone-image-fonts.css">
<style>
body {
margin: 0;
padding: 0;
background: #f2f2f2;
}
.page-header {
background: #fff;
padding: 10px 20px;
border-bottom: 1px solid #e8e8e8;
display: flex;
justify-content: space-between;
align-items: center;
}
.page-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.page-header-left h3 {
margin: 0;
font-size: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.page-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.main-layout {
display: flex;
height: calc(100vh - 52px);
}
.content-flow-area {
width: 540px;
overflow-y: auto;
border-right: 1px solid #e8e8e8;
background: #fafafa;
}
#editor-toolbar {
border-bottom: 1px solid #e8e8e8;
background: #fff;
}
#editor-text-area {
min-height: 300px;
padding: 10px;
background: #fff;
}
.render-preview-area {
width: 540px;
overflow-y: auto;
border-right: 1px solid #e8e8e8;
background: #fff;
flex-shrink: 0;
}
.render-preview-area .preview-header {
padding: 8px 15px;
background: #fafafa;
border-bottom: 1px solid #e8e8e8;
font-size: 13px;
color: #999;
position: sticky;
top: 0;
z-index: 5;
}
#render-preview {
min-height: 300px;
padding: 10px;
box-sizing: border-box;
}
.paginated-preview-area {
flex: 1;
overflow-x: auto;
overflow-y: hidden;
padding: 20px;
background: #f5f5f5;
}
#paginated-preview {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 20px;
height: 100%;
padding: 0 10px;
}
/* 设置弹框样式 */
.settings-form .layui-form-item {
margin-bottom: 15px;
}
.settings-form .layui-form-label {
width: 60px;
}
.settings-form .layui-input-block {
margin-left: 90px;
}
</style>
</head>
<body>
<!-- 隐藏div存放文章HTML内容供JS读取 -->
<div id="post-content-html" style="display:none;">{$layoutContentHtml|raw}</div>
<div class="page-header">
<div class="page-header-left">
<a href="{:url('post/index')}" class="layui-btn layui-btn-sm layui-btn-primary"><i
class="layui-icon layui-icon-return"></i> 返回列表</a>
<h3>{$post.title}</h3>
</div>
<div class="page-header-right">
<button type="button" class="layui-btn layui-btn-sm layui-btn-primary" id="btn-settings"><i
class="layui-icon layui-icon-set"></i> 设置</button>
<button type="button" class="layui-btn layui-btn-sm layui-btn-warm" id="btn-render"><i
class="layui-icon layui-icon-refresh"></i> 生成</button>
<button type="button" class="layui-btn layui-btn-sm layui-btn-normal" id="btn-save"><i
class="layui-icon layui-icon-ok"></i> 保存</button>
<button type="button" class="layui-btn layui-btn-sm" id="btn-generate"><i
class="layui-icon layui-icon-picture"></i> 生成并保存</button>
<button type="button" class="layui-btn layui-btn-sm layui-btn-primary" id="btn-export-page"><i
class="layui-icon layui-icon-upload-circle"></i> 导出页面</button>
<div class="more-dropdown-wrapper" style="position:relative;display:inline-block;">
<button type="button" class="layui-btn layui-btn-sm layui-btn-primary" id="btn-more"><i
class="layui-icon layui-icon-more"></i></button>
<ul class="more-dropdown-menu" id="more-dropdown-menu" style="display:none;position:absolute;right:0;top:100%;margin-top:4px;background:#fff;border:1px solid #e8e8e8;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,0.12);z-index:9999;min-width:130px;padding:4px 0;list-style:none;">
<li data-action="history" style="padding:8px 16px;cursor:pointer;font-size:13px;white-space:nowrap;">历史记录</li>
</ul>
</div>
</div>
</div>
<div class="main-layout">
<!-- 左侧wangeditor 编辑器 -->
<div class="content-flow-area">
<div id="editor-toolbar"></div>
<div id="editor-text-area"></div>
</div>
<!-- 中间:渲染预览区 -->
<div class="render-preview-area">
<div class="preview-header">渲染预览</div>
<div id="render-preview"></div>
</div>
<!-- 右侧:分页排版预览 -->
<div class="paginated-preview-area">
<div id="paginated-preview" class="preview-thumbnails"></div>
</div>
</div>
<script src="/static/lib/jquery/jquery-3.4.1.min.js"></script>
<script src="/static/lib/layui/layui.js"></script>
<script src="/static/lib/wangeditor/index.js"></script>
<script src="/static/lib/html2canvas/html2canvas.js"></script>
<script src="/static/js/phone-image.js"></script>
<script>
layui.use(['form', 'layer'], function () {
var form = layui.form;
var layer = layui.layer;
var lastOutputId = <?php echo $lastOutputId ? (int) $lastOutputId : 'null'; ?>;
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")}';
// 当前排版配置(从设置弹框维护)
var currentConfig = {
size: 'xiaohongshu',
watermark: ''
};
var postData = {
postId: {$post.id},
title: <?php echo json_encode($post->title ?? ''); ?>,
desc: <?php echo json_encode($post->desc ?? ''); ?>,
coverText: <?php echo json_encode($post->getData('cover_text') ?? ''); ?>,
contentHtml: $('#post-content-html').html(),
poster: <?php echo json_encode($post->poster ?? ''); ?>,
authorName: <?php echo json_encode($post->author_name ?? ''); ?>,
createTime: <?php echo json_encode($post->create_time_text ?? ''); ?>,
categoryName: ''
};
// 恢复之前保存的排版配置
var savedConfig = <?php echo $layoutConfig ? json_encode($layoutConfig) : '{}'; ?>;
var initConfig = {
size: savedConfig.size || 'xiaohongshu',
watermark: savedConfig.watermark || '',
pageAlignments: savedConfig.pageAlignments || {}
};
// 同步当前配置
currentConfig.size = initConfig.size;
currentConfig.watermark = initConfig.watermark;
// ========== wangeditor 初始化 ==========
var E = window.wangEditor;
var editorConfig = { MENU_CONF: {} };
editorConfig.placeholder = '请输入排版内容,使用分割线标记分页位置';
editorConfig.scroll = false;
editorConfig.MENU_CONF['uploadImage'] = {
server: '{:url("File/wangEditorSave")}',
fieldName: 'file',
meta: {
type: 'editor'
}
};
var autoSaveTimer = null;
var autoSaveLock = false;
editorConfig.onChange = function (editor) {
// 自动保存2.6s 防抖
clearTimeout(autoSaveTimer);
autoSaveLock = true;
updateSaveState('waiting');
autoSaveTimer = setTimeout(function () {
doAutoSave();
}, 2600);
// 预览同步300ms 防抖(独立于自动保存)
clearTimeout(window._previewSyncTimer);
window._previewSyncTimer = setTimeout(function () {
PhoneImageEngine.syncPreview();
}, 300);
};
// 粘贴处理:处理外部图片下载
editorConfig.customPaste = function (editor, event) {
var pasteStr = event.clipboardData.getData('text/html');
var imgReg = /<img.*?(?:>|\/>)/gi;
var srcReg = /src=[\'\"]?([^\'\"]*)[\'\"]?/i;
var arr = pasteStr.match(imgReg);
if (arr == null || arr.length == 0) {
return true;
}
layer.load();
for (var i = 0; i < arr.length; i++) {
var src = arr[i].match(srcReg);
if (src[1]) {
var imgSrc = src[1];
var prefix = imgSrc.substr(0, 4);
if (prefix == 'http') {
$.ajax({
async: false,
type: 'POST',
url: "{:url('File/urlSave')}",
data: {
url: imgSrc,
type: 'editor'
},
success: function (result) {
pasteStr = pasteStr.replace(imgSrc, result.data.src)
}
})
} else if (prefix == 'data') {
$.ajax({
async: false,
type: 'POST',
url: '{:url("File/base64Save")}',
data: {
data: imgSrc,
type: 'editor'
},
success: function (result) {
pasteStr = pasteStr.replace(imgSrc, result.data.src)
}
})
}
}
}
layer.closeAll('loading');
editor.dangerouslyInsertHtml(pasteStr);
event.preventDefault();
return false;
};
var phoneImageEditor = E.createEditor({
selector: '#editor-text-area',
html: postData.contentHtml,
config: editorConfig
});
var toolbar = E.createToolbar({
editor: phoneImageEditor,
selector: '#editor-toolbar',
config: {
excludeKeys: ['fullScreen'],
insertKeys: {
index: 24,
keys: ['divider']
}
}
});
window.phoneImageEditor = phoneImageEditor;
PhoneImageEngine.init(postData, initConfig);
// 初始同步预览区
PhoneImageEngine.syncPreview();
// ========== 设置弹框 ==========
$('#btn-settings').click(function () {
var settingsHtml = '<div class="settings-form" style="padding:20px 20px 0;">';
settingsHtml += '<form class="layui-form" lay-filter="settingsForm">';
settingsHtml += '<div class="layui-form-item">';
settingsHtml += '<label class="layui-form-label">尺寸</label>';
settingsHtml += '<div class="layui-input-block">';
settingsHtml += '<select name="s_size" lay-filter="s_size">';
settingsHtml += '<option value="xiaohongshu"' + (currentConfig.size === 'xiaohongshu' ? ' selected' : '') + '>小红书 (1080x1440)</option>';
settingsHtml += '<option value="douyin"' + (currentConfig.size === 'douyin' ? ' selected' : '') + '>抖音 (1080x1920)</option>';
settingsHtml += '</select>';
settingsHtml += '</div>';
settingsHtml += '</div>';
settingsHtml += '<div class="layui-form-item">';
settingsHtml += '<label class="layui-form-label">水印</label>';
settingsHtml += '<div class="layui-input-block">';
settingsHtml += '<input type="text" name="s_watermark" value="' + (currentConfig.watermark || '') + '" placeholder="可选水印文字" class="layui-input">';
settingsHtml += '</div>';
settingsHtml += '</div>';
settingsHtml += '</form>';
settingsHtml += '</div>';
layer.open({
type: 1,
title: '排版设置',
area: ['400px', '220px'],
content: settingsHtml,
btn: ['确定', '取消'],
success: function (layero) {
form.render('select', 'settingsForm');
},
yes: function (index, layero) {
currentConfig.size = layero.find('[name="s_size"]').val();
currentConfig.watermark = layero.find('[name="s_watermark"]').val();
layer.close(index);
doRender();
}
});
});
// ========== 渲染逻辑 ==========
var renderTimer = null;
function doRender(extraConfig) {
clearTimeout(renderTimer);
renderTimer = setTimeout(function () {
var newConfig = {
size: currentConfig.size,
watermark: currentConfig.watermark
};
if (extraConfig) {
$.extend(newConfig, extraConfig);
}
PhoneImageEngine.updateConfig(newConfig);
var loadIdx = layer.load();
PhoneImageEngine.render().then(function (pages) {
layer.close(loadIdx);
layer.msg('排版完成,共 ' + pages.length + ' 页');
}).catch(function (err) {
layer.close(loadIdx);
if (err !== 'rendering') {
layer.msg('渲染失败: ' + err);
}
});
}, 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) {
e.stopPropagation();
$menu.toggle();
});
$menu.on('click', 'li', function() {
var action = $(this).data('action');
$menu.hide();
switch (action) {
case 'history':
$('#btn-history').trigger('click');
break;
}
});
// 点击外部关闭
$(document).on('click', function() {
$menu.hide();
});
// ========== 自动保存相关函数 ==========
function updateSaveState(state) {
var $state = $('#save-state');
if (!$state.length) return;
switch(state) {
case 'waiting': $state.text('等待保存...').css('color', '#e6a23c'); break;
case 'saving': $state.text('保存中...').css('color', '#409eff'); break;
case 'saved': $state.text('已保存').css('color', '#67c23a'); break;
case 'error': $state.text('保存失败').css('color', '#f56c6c'); break;
}
}
function doAutoSave() {
if (autoSaveLock) {
autoSaveLock = false;
updateSaveState('saving');
PhoneImageEngine.saveConfig(postData.postId, {
size: currentConfig.size,
watermark: currentConfig.watermark,
content_html: PhoneImageEngine.getContentHtml()
}, saveConfigUrl).then(function (data) {
if (data.output_id) lastOutputId = data.output_id;
updateSaveState('saved');
}).catch(function (err) {
updateSaveState('error');
});
}
}
// ========== 保存状态指示器 ==========
$('<span id="save-state" style="font-size:12px;color:#999;">已保存</span>').insertAfter('#btn-generate');
// ========== 保存配置(不生成图片) ==========
$('#btn-save').click(function () {
var btn = $(this);
btn.prop('disabled', true);
// 清除自动保存定时器,避免冲突
clearTimeout(autoSaveTimer);
autoSaveLock = false;
layer.msg('保存中...');
PhoneImageEngine.saveConfig(postData.postId, {
size: currentConfig.size,
watermark: currentConfig.watermark,
content_html: PhoneImageEngine.getContentHtml()
}, saveConfigUrl).then(function (data) {
if (data.output_id) lastOutputId = data.output_id;
$('#post-content-html').html(PhoneImageEngine.getContentHtml());
layer.msg('保存成功');
updateSaveState('saved');
}).catch(function (err) {
layer.msg('保存失败: ' + err);
updateSaveState('error');
}).always(function () {
setTimeout(function () { btn.prop('disabled', false); }, 2000);
});
});
// ========== 生成并保存 ==========
$('#btn-generate').click(function () {
var btn = $(this);
btn.prop('disabled', true).html('<i class="layui-icon layui-icon-picture"></i> 生成中...');
layer.msg('正在生成图片,请稍候...');
PhoneImageEngine.saveImages(postData.postId, {
size: currentConfig.size,
watermark: currentConfig.watermark,
content_html: PhoneImageEngine.getContentHtml()
}).then(function (data) {
if (data.output_id) {
lastOutputId = data.output_id;
}
$('#post-content-html').html(PhoneImageEngine.getContentHtml());
layer.msg('保存成功!');
btn.prop('disabled', false).html('<i class="layui-icon layui-icon-picture"></i> 生成并保存');
}).catch(function (err) {
layer.msg('保存失败: ' + err);
btn.prop('disabled', false).html('<i class="layui-icon layui-icon-picture"></i> 生成并保存');
});
});
// ========== 历史记录(隐藏按钮,由更多菜单和设置旁入口触发) ==========
var historyBtn = $('<button type="button" id="btn-history" style="display:none;"></button>');
$('body').append(historyBtn);
historyBtn.on('click', function () {
var loadIdx = layer.load();
$.get(historyListUrl, function (res) {
layer.close(loadIdx);
if (res.code !== 0 || !res.data || res.data.length === 0) {
layer.msg('暂无历史记录');
return;
}
var statusMap = { 0: '草稿', 1: '已生成', 2: '失败' };
var html = '<div style="padding:15px;max-height:320px;overflow-y:auto;">';
html += '<table class="layui-table" lay-skin="line" style="margin:0;">';
html += '<colgroup><col width="150"><col width="70"><col width="70"><col width="80"></colgroup>';
html += '<thead><tr><th>时间</th><th>状态</th><th>页数</th><th>操作</th></tr></thead>';
html += '<tbody>';
for (var i = 0; i < res.data.length; i++) {
var item = res.data[i];
var timeStr = (item.create_time && parseInt(item.create_time, 10) > 0) ? new Date(parseInt(item.create_time, 10) * 1000).toLocaleString('zh-CN') : '-';
var statusText = statusMap[item.status] || '未知';
html += '<tr>';
html += '<td style="font-size:12px;">' + timeStr + '</td>';
html += '<td>' + statusText + '</td>';
html += '<td>' + (item.page_count || '-') + '</td>';
html += '<td><button type="button" class="layui-btn layui-btn-xs layui-btn-normal btn-load-history" data-id="' + item.id + '">加载</button></td>';
html += '</tr>';
}
html += '</tbody></table></div>';
layer.open({
type: 1,
title: '排版历史记录',
area: ['520px', '400px'],
content: html,
success: function (layero) {
layero.find('.btn-load-history').on('click', function () {
var outputId = $(this).data('id');
loadFromHistory(outputId);
});
}
});
}).fail(function () {
layer.close(loadIdx);
layer.msg('获取历史记录失败');
});
});
function loadFromHistory(outputId) {
var loadIdx2 = layer.load();
$.get(loadConfigUrl + '?id=' + outputId, function (res) {
layer.close(loadIdx2);
if (res.code !== 0 || !res.data) {
layer.msg('加载失败: ' + (res.msg || '未知错误'));
return;
}
var cfg = res.data.config || {};
// 用 setHtml 将历史内容加载到 wangeditor
if (res.data.content_html && window.phoneImageEditor) {
window.phoneImageEditor.setHtml(res.data.content_html);
}
// 构建历史配置
var historyConfig = {};
if (cfg.size) historyConfig.size = cfg.size;
if (cfg.watermark !== undefined) historyConfig.watermark = cfg.watermark;
if (cfg.pageAlignments) historyConfig.pageAlignments = cfg.pageAlignments;
// 同步当前配置
if (cfg.size) currentConfig.size = cfg.size;
if (cfg.watermark !== undefined) currentConfig.watermark = cfg.watermark;
lastOutputId = outputId;
layer.closeAll();
// 更新引擎配置并重新渲染
PhoneImageEngine.updateConfig(historyConfig);
var loadIdx3 = layer.load();
PhoneImageEngine.render().then(function (pages) {
layer.close(loadIdx3);
layer.msg('已加载历史配置,共 ' + pages.length + ' 页');
}).catch(function (err) {
layer.close(loadIdx3);
if (err !== 'rendering') layer.msg('渲染失败: ' + err);
});
}).fail(function () {
layer.close(loadIdx2);
layer.msg('加载历史配置失败');
});
}
// 初始渲染
doRender();
});
</script>
<div id="render-staging" style="position:fixed;left:-9999px;top:0;visibility:hidden;"></div>
</body>
</html>