Files
ulthon_information/view/admin/post/phone_image.html
augushong 9aacfab11d fix(phone-image): 修复XSS注入、正则兼容性、render锁稳定性和缓存清理
T1: XSS修复 - PHP模板注入改用json_encode,poster URL转义处理
T2: fontSize NaN修复 - parseInt统一处理,lookbehind正则替换为兼容方案
T3: render锁稳定性 - insertPageBreak/removePageBreak添加15次重试上限,
     fontsReady添加catch处理,_pending递归添加错误捕获
T4: 缓存清理 - init()清空缓存,render()超过3倍blocks数自动清理
2026-05-07 21:29:45 +08:00

415 lines
17 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/css/phone-image-templates.css">
<link rel="stylesheet" href="/static/css/phone-image-fonts.css">
<link rel="stylesheet" href="/static/lib/prismjs/prism.css">
<style>
body {
margin: 0;
padding: 0;
background: #f2f2f2;
}
.page-header {
background: #fff;
padding: 15px 20px;
border-bottom: 1px solid #e8e8e8;
display: flex;
justify-content: space-between;
align-items: center;
}
.page-header h3 {
margin: 0;
font-size: 18px;
}
.main-layout {
display: flex;
height: calc(100vh - 60px);
}
.toolbar {
width: 220px;
background: #fff;
border-right: 1px solid #e8e8e8;
padding: 15px;
overflow-y: auto;
}
.content-flow-area {
width: 540px;
overflow-y: auto;
border-right: 1px solid #e8e8e8;
background: #fafafa;
}
.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;
}
.toolbar .layui-form-label {
width: 60px;
padding: 6px 8px;
font-size: 13px;
}
.toolbar .layui-input-block {
margin-left: 70px;
}
.toolbar .layui-form-item {
margin-bottom: 12px;
}
.action-btns {
margin-top: 15px;
}
.action-btns .layui-btn {
width: 100%;
margin-bottom: 8px;
}
</style>
</head>
<body>
<!-- 隐藏div存放文章HTML内容供JS读取 -->
<div id="post-content-html" style="display:none;">{$layoutContentHtml|raw}</div>
<div class="page-header">
<div>
<a href="{:url('post/index')}" class="layui-btn layui-btn-sm layui-btn-primary"><i
class="layui-icon layui-icon-return"></i> 返回列表</a>
{* 输出管理入口已移至历史记录弹窗 *}
</div>
<h3>{$post.title}</h3>
</div>
<div class="main-layout">
<!-- 左侧工具栏 -->
<div class="toolbar">
<div class="layui-form" lay-filter="phoneImageForm">
<!-- 尺寸选择 -->
<div class="layui-form-item">
<label class="layui-form-label">尺寸</label>
<div class="layui-input-block">
<select name="size" lay-filter="size">
<option value="xiaohongshu">小红书 (1080x1440)</option>
<option value="douyin">抖音 (1080x1920)</option>
</select>
</div>
</div>
<!-- 字号 -->
<div class="layui-form-item">
<label class="layui-form-label">字号</label>
<div class="layui-input-block">
<input type="range" name="fontSize" min="10" max="24" value="14" lay-filter="fontSize"
style="width:100%;">
<span id="fontSizeValue">14px</span>
</div>
</div>
<!-- 水印 -->
<div class="layui-form-item">
<label class="layui-form-label">水印</label>
<div class="layui-input-block">
<input type="text" name="watermark" placeholder="可选水印文字" class="layui-input">
</div>
</div>
<!-- 历史记录 -->
<button type="button" class="layui-btn layui-btn-sm layui-btn-primary" id="btn-history" style="width:100%;margin-bottom:10px;"><i class="layui-icon layui-icon-list"></i> 历史记录</button>
<!-- 操作按钮 -->
<div class="action-btns">
<button type="button" class="layui-btn layui-btn-primary" id="btn-rerender"><i
class="layui-icon layui-icon-refresh"></i> 重新生成</button>
<button type="button" class="layui-btn layui-btn-normal" id="btn-save"><i
class="layui-icon layui-icon-ok"></i> 保存</button>
<button type="button" class="layui-btn layui-btn-normal" id="btn-generate"><i
class="layui-icon layui-icon-picture"></i> 生成并保存</button>
<button type="button" class="layui-btn layui-btn-warm" id="btn-download"><i
class="layui-icon layui-icon-download-circle"></i> 打包下载</button>
<button type="button" class="layui-btn layui-btn-primary" id="btn-export-long"><i
class="layui-icon layui-icon-picture"></i> 导出长图</button>
</div>
</div>
</div>
<!-- 中间:内容流 -->
<div class="content-flow-area">
<div id="content-flow" class="content-flow"></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/html2canvas/html2canvas.js"></script>
<script src="/static/lib/prismjs/prism.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 = null;
var downloadBaseUrl = '{:url("post/downloadPostOutputZip", ["id" => 0])}';
var saveConfigUrl = '{:url("post/savePostOutputConfig")}';
var historyListUrl = '{:url("post/getOutputListJson", ["id" => $post->id])}';
var loadConfigUrl = '{:url("post/loadPostOutputConfig")}';
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',
fontSize: savedConfig.fontSize || 14,
watermark: savedConfig.watermark || '',
pageAlignments: savedConfig.pageAlignments || {}
};
PhoneImageEngine.init(postData, initConfig);
// 同步尺寸选择
$('[name="size"]').val(initConfig.size);
form.render('select');
// 尺寸切换
form.on('select(size)', function (data) {
doRender();
});
// 字号调整
$('[name="fontSize"]').on('input', function () {
$('#fontSizeValue').text($(this).val() + 'px');
doRender();
});
var renderTimer = null;
function doRender() {
clearTimeout(renderTimer);
renderTimer = setTimeout(function() {
var fontSize = parseInt($('[name="fontSize"]').val()) || 14;
PhoneImageEngine.init(postData, {
size: $('[name="size"]').val(),
fontSize: fontSize,
watermark: $('[name="watermark"]').val()
});
var loadIdx = layer.load();
PhoneImageEngine.render().then(function(pages) {
layer.close(loadIdx);
layer.msg('排版完成,共 ' + pages.length + ' 页');
}).catch(function(err) {
layer.close(loadIdx);
layer.msg('渲染失败: ' + err);
});
}, 300);
}
// 重新生成(强制重新渲染缩略图)
$('#btn-rerender').click(function () {
doRender();
});
// 保存配置(不生成图片)
$('#btn-save').click(function () {
var btn = $(this);
btn.prop('disabled', true);
layer.msg('保存中...');
PhoneImageEngine.saveConfig(postData.postId, {
size: $('[name="size"]').val(),
fontSize: parseInt($('[name="fontSize"]').val()) || 14,
watermark: $('[name="watermark"]').val(),
content_html: $('#post-content-html').html()
}, saveConfigUrl).then(function (data) {
if (data.output_id) lastOutputId = data.output_id;
layer.msg('保存成功');
}).catch(function (err) {
layer.msg('保存失败: ' + err);
}).always(function () {
setTimeout(function () { btn.prop('disabled', false); }, 2000);
});
});
// 生成并保存
$('#btn-generate').click(function () {
var btn = $(this);
btn.prop('disabled', true).text('生成中...');
layer.msg('正在生成图片,请稍候...');
PhoneImageEngine.saveImages(postData.postId, {
size: $('[name="size"]').val(),
fontSize: parseInt($('[name="fontSize"]').val()) || 14,
watermark: $('[name="watermark"]').val(),
content_html: $('#post-content-html').html()
}).then(function (data) {
if (data.output_id) {
lastOutputId = data.output_id;
}
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> 生成并保存');
});
});
// 打包下载
$('#btn-download').click(function () {
if (!lastOutputId) {
layer.msg('请先生成并保存图片');
return;
}
var url = downloadBaseUrl.replace('/0/', '/' + lastOutputId + '/');
window.open(url);
});
// 导出长图
$('#btn-export-long').click(function () {
var btn = $(this);
btn.prop('disabled', true).text('导出中...');
PhoneImageEngine.exportLongImage().then(function () {
btn.prop('disabled', false).html('<i class="layui-icon layui-icon-picture"></i> 导出长图');
layer.msg('长图已导出');
}).catch(function (err) {
btn.prop('disabled', false).html('<i class="layui-icon layui-icon-picture"></i> 导出长图');
layer.msg('导出失败: ' + err);
});
});
// 历史记录弹窗
$('#btn-history').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 || {};
// 更新表单控件
if (cfg.size) {
$('[name="size"]').val(cfg.size);
form.render('select');
}
if (cfg.fontSize) {
$('[name="fontSize"]').val(cfg.fontSize);
$('#fontSizeValue').text(cfg.fontSize + 'px');
}
if (cfg.watermark !== undefined) {
$('[name="watermark"]').val(cfg.watermark);
}
// 更新内容HTML如果有保存
if (res.data.content_html) {
postData.contentHtml = res.data.content_html;
$('#post-content-html').html(res.data.content_html);
}
// 关闭历史弹窗
layer.closeAll();
// 重新初始化引擎并渲染
lastOutputId = outputId;
doRender();
layer.msg('已加载历史配置');
}).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>