feat(post): 新增手机图片排版与AI智能排版功能

- 新增手机图片排版功能,支持小红书/抖音尺寸输出
- 新增AI智能排版顾问,支持内容分析与优化推荐
- 新增AI供应商管理,支持多渠道配置与同步
- 新增文章输出管理页面,支持图片预览与批量下载
- 新增字体文件与排版样式配置
This commit is contained in:
augushong
2026-05-01 12:23:17 +08:00
parent b4558b55fb
commit 83a2bd48a2
25 changed files with 4440 additions and 0 deletions

View File

@@ -0,0 +1,736 @@
/**
* PhoneImageEngine - 手机图片排版引擎
*
* 将文章HTML内容自动分页并渲染为手机尺寸图片
* 依赖: jQuery, html2canvas (项目已有)
*
* 渲染宽度540px, html2canvas scale=2 输出1080px
* 小红书: 540x720 (输出1080x1440)
* 抖音: 540x960 (输出1080x1920)
*/
var PhoneImageEngine = (function () {
// ===== 配置 =====
var config = {
template: 'minimal',
size: 'xiaohongshu',
font: 'source-han-sans',
fontSize: 14,
watermark: '',
// 尺寸映射 (渲染尺寸, 实际输出是2倍)
sizes: {
xiaohongshu: { width: 540, height: 720 },
douyin: { width: 540, height: 960 }
},
// 字体映射
fonts: {
'source-han-sans': "'Source Han Sans', 'SourceHanSans-Normal', sans-serif",
'alibaba-puhuiti': "'AlibabaPuHuiTi', sans-serif",
'lxgw-wenkai': "'LXGWWenKai', cursive"
},
// 页面内容边距
contentPadding: 20
};
// ===== 文章数据 =====
var postData = {
id: 0,
title: '',
desc: '',
content_html: '',
poster: '',
author_name: '',
create_time: '',
category_name: ''
};
// ===== 分页结果 =====
var pages = [];
// ===== DOM引用 =====
var $container = null;
/**
* 初始化引擎
* @param {Object} options - {postId, title, desc, contentHtml, poster, authorName, createTime, categoryName}
* @param {Object} userConfig - {template, size, font, fontSize, watermark}
*/
function init(options, userConfig) {
postData.id = options.postId || 0;
postData.title = options.title || '';
postData.desc = options.desc || '';
postData.content_html = options.contentHtml || '';
postData.poster = options.poster || '';
postData.author_name = options.authorName || '';
postData.create_time = options.createTime || '';
postData.category_name = options.categoryName || '';
if (userConfig) {
$.extend(config, userConfig);
}
$container = $('#phone-image-container');
}
/**
* 渲染排版预览 - 生成分页后的HTML
* @returns {Array} 页面数组,每项是 { type, html, pageNum }
*/
function render() {
pages = [];
var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu;
var pageHeight = sizeConfig.height;
var contentAreaHeight = pageHeight - (config.contentPadding * 2);
// 内容预处理
var cleanHtml = preprocessContent(postData.content_html);
// 解析为块级元素
var blocks = parseHtmlToBlocks(cleanHtml);
// 生成封面页
pages.push(generateCoverPage(sizeConfig));
// 内容分页
var contentPages = paginateContent(blocks, contentAreaHeight, sizeConfig);
pages = pages.concat(contentPages);
// 生成尾页
pages.push(generateSummaryPage(sizeConfig, pages.length));
// 渲染到DOM
renderToDOM(sizeConfig);
return pages;
}
/**
* 预处理HTML内容
*/
function preprocessContent(html) {
if (!html) return '';
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// 清除空段落
html = html.replace(/<p[^>]*>\s*<\/p>/gi, '');
return html;
}
/**
* 将HTML解析为块级元素数组
* 每个块: { type, html, estimatedHeight }
*/
function parseHtmlToBlocks(html) {
var blocks = [];
if (!html) return blocks;
var $temp = $('<div>').html(html);
var children = $temp.children();
if (children.length === 0) {
// 纯文本内容,按段落分割
var text = $temp.text().trim();
if (text) {
var paragraphs = text.split(/\n\s*\n|\n/);
for (var i = 0; i < paragraphs.length; i++) {
var p = paragraphs[i].trim();
if (p) {
blocks.push({
type: 'p',
html: '<p>' + p + '</p>',
estimatedHeight: estimateTextHeight(p, config.fontSize)
});
}
}
}
return blocks;
}
children.each(function () {
var $el = $(this);
var tagName = $el.prop('tagName').toLowerCase();
var block = {
type: tagName,
html: $el[0].outerHTML
};
switch (tagName) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
block.estimatedHeight = estimateHeadingHeight(tagName, $el.text());
break;
case 'img':
block.estimatedHeight = estimateImageHeight($el);
block.$img = $el;
break;
case 'ul':
case 'ol':
block.estimatedHeight = estimateListHeight($el);
break;
case 'blockquote':
block.estimatedHeight = estimateBlockquoteHeight($el);
break;
case 'p':
default:
block.type = 'p';
block.estimatedHeight = estimateTextHeight($el.text(), config.fontSize);
break;
}
blocks.push(block);
});
return blocks;
}
// ===== 高度估算 =====
/**
* 获取内容区可用宽度 (540 - 40px padding)
*/
function getContentWidth() {
return 500;
}
/**
* 估算文本高度
* @param {string} text
* @param {number} fontSize
* @returns {number} px
*/
function estimateTextHeight(text, fontSize) {
var charCount = text.length;
if (charCount === 0) return 0;
var charsPerLine = Math.floor(getContentWidth() / (fontSize * 1.0));
if (charsPerLine < 1) charsPerLine = 1;
var lines = Math.ceil(charCount / charsPerLine);
return lines * fontSize * 1.8 + 10; // 1.8行高 + 10px段落间距
}
/**
* 估算标题高度
*/
function estimateHeadingHeight(tag, text) {
var sizeMap = { h1: 28, h2: 24, h3: 20, h4: 18, h5: 16, h6: 14 };
var hSize = sizeMap[tag] || 20;
var charsPerLine = Math.floor(getContentWidth() / (hSize * 1.0));
if (charsPerLine < 1) charsPerLine = 1;
var lines = Math.ceil(text.length / charsPerLine);
return lines * hSize * 1.4 + 20; // heading行高1.4 + 20px上下间距
}
/**
* 估算图片高度
* 默认按内容区宽度的60%估算
*/
function estimateImageHeight($img) {
var w = parseInt($img.attr('width'), 10);
var h = parseInt($img.attr('height'), 10);
if (w > 0 && h > 0) {
// 按比例缩放到内容宽度
var ratio = getContentWidth() / w;
return Math.round(h * ratio) + 10;
}
// 无尺寸信息时默认300px
return 310;
}
/**
* 估算列表高度
*/
function estimateListHeight($list) {
var items = $list.find('li').length;
return items * (config.fontSize * 1.8 + 5) + 15;
}
/**
* 估算引用块高度
*/
function estimateBlockquoteHeight($bq) {
var text = $bq.text();
return estimateTextHeight(text, config.fontSize) + 10;
}
// ===== 分页核心算法 =====
/**
* 将块级元素按高度累加,超过可用高度时分页
* 支持超大块拆分(超长文本拆成多段)
*/
function paginateContent(blocks, contentAreaHeight, sizeConfig) {
var contentPages = [];
var currentPageBlocks = [];
var currentHeight = 0;
var pageNumber = 1;
for (var i = 0; i < blocks.length; i++) {
var block = blocks[i];
// 超大块处理:单个块超过整页高度时需要拆分
if (block.estimatedHeight > contentAreaHeight) {
// 先把当前页已有的内容推出去
if (currentPageBlocks.length > 0) {
contentPages.push(generateContentPage(
currentPageBlocks, pageNumber, sizeConfig, false
));
currentPageBlocks = [];
currentHeight = 0;
pageNumber++;
}
// 尝试拆分超大块
var splitBlocks = splitOversizedBlock(block, contentAreaHeight);
for (var s = 0; s < splitBlocks.length; s++) {
var sb = splitBlocks[s];
if (currentHeight + sb.estimatedHeight > contentAreaHeight && currentPageBlocks.length > 0) {
contentPages.push(generateContentPage(
currentPageBlocks, pageNumber, sizeConfig, false
));
currentPageBlocks = [];
currentHeight = 0;
pageNumber++;
}
currentPageBlocks.push(sb);
currentHeight += sb.estimatedHeight;
}
continue;
}
// 正常块:判断是否需要换页
if (currentHeight + block.estimatedHeight > contentAreaHeight && currentPageBlocks.length > 0) {
contentPages.push(generateContentPage(
currentPageBlocks, pageNumber, sizeConfig, false
));
currentPageBlocks = [];
currentHeight = 0;
pageNumber++;
}
currentPageBlocks.push(block);
currentHeight += block.estimatedHeight;
}
// 最后一页
if (currentPageBlocks.length > 0) {
contentPages.push(generateContentPage(
currentPageBlocks, pageNumber, sizeConfig, true
));
}
return contentPages;
}
/**
* 拆分超大块(超长段落文本拆成多段)
* @param {Object} block - 块级元素
* @param {number} pageHeight - 可用内容高度
* @returns {Array} 拆分后的块数组
*/
function splitOversizedBlock(block, pageHeight) {
// 图片块不拆分,保留原样(会被截断但保持完整)
if (block.type === 'img') {
return [block];
}
// 文本类块:按句子拆分
var text = '';
var wrapperTag = 'p';
if (block.type === 'blockquote') {
text = $(block.html).text();
wrapperTag = 'blockquote';
} else {
text = $(block.html).text();
}
if (!text || text.length === 0) {
return [block];
}
// 按句子拆分:句号、问号、叹号、换行
var sentences = text.split(/(?<=[。!?\n])/);
if (sentences.length <= 1) {
// 无法按句子拆分,按固定字符数拆分
sentences = [];
var chunkSize = Math.floor(getContentWidth() / config.fontSize) *
Math.floor(pageHeight / (config.fontSize * 1.8));
if (chunkSize < 10) chunkSize = 10;
for (var ci = 0; ci < text.length; ci += chunkSize) {
sentences.push(text.substring(ci, ci + chunkSize));
}
}
var result = [];
for (var j = 0; j < sentences.length; j++) {
var s = sentences[j].trim();
if (!s) continue;
var h = estimateTextHeight(s, config.fontSize);
result.push({
type: wrapperTag === 'blockquote' ? 'blockquote' : 'p',
html: '<' + wrapperTag + '>' + s + '</' + wrapperTag + '>',
estimatedHeight: h
});
}
return result.length > 0 ? result : [block];
}
// ===== 页面HTML生成 =====
/**
* 生成封面页HTML
*/
function generateCoverPage(sizeConfig) {
var html = '<div class="phone-image-page page-cover" style="width:' +
sizeConfig.width + 'px;height:' + sizeConfig.height + 'px;">';
if (postData.poster) {
html += '<img class="cover-image" src="' + postData.poster + '" alt="">';
}
html += '<div class="cover-title">' + escapeHtml(postData.title) + '</div>';
if (postData.desc) {
html += '<div class="cover-subtitle">' + escapeHtml(postData.desc) + '</div>';
}
html += '<div class="cover-meta">';
if (postData.category_name) {
html += escapeHtml(postData.category_name) + ' | ';
}
if (postData.author_name) {
html += escapeHtml(postData.author_name) + ' | ';
}
if (postData.create_time) {
html += postData.create_time;
}
html += '</div>';
html += '</div>';
return { type: 'cover', html: html };
}
/**
* 生成内容页HTML
*/
function generateContentPage(blocks, pageNum, sizeConfig, isLast) {
var html = '<div class="phone-image-page page-body" style="width:' +
sizeConfig.width + 'px;height:' + sizeConfig.height + 'px;">';
// 页头(仅首页内容页显示标题)
if (pageNum === 1) {
html += '<div class="page-header">';
html += '<div class="page-title">' + escapeHtml(postData.title) + '</div>';
html += '</div>';
}
// 正文内容区
html += '<div class="page-content">';
for (var i = 0; i < blocks.length; i++) {
html += blocks[i].html;
}
html += '</div>';
// 页脚
html += '<div class="page-footer">';
html += '<span>' + escapeHtml(postData.author_name || '') + '</span>';
html += '<span>' + pageNum + '</span>';
html += '</div>';
html += '</div>';
return { type: 'content', html: html, pageNum: pageNum };
}
/**
* 生成尾页/总结页HTML
*/
function generateSummaryPage(sizeConfig, totalPages) {
var html = '<div class="phone-image-page page-summary" style="width:' +
sizeConfig.width + 'px;height:' + sizeConfig.height + 'px;">';
html += '<div class="summary-title">感谢阅读</div>';
html += '<div class="summary-text">' + escapeHtml(postData.title) + '</div>';
if (postData.desc) {
html += '<div class="summary-text" style="font-size:12px;">' + escapeHtml(postData.desc) + '</div>';
}
html += '<div class="summary-footer">';
html += '共 ' + totalPages + ' 页';
if (postData.author_name) {
html += ' | ' + escapeHtml(postData.author_name);
}
html += '</div>';
html += '</div>';
return { type: 'summary', html: html };
}
// ===== DOM渲染 =====
/**
* 渲染分页结果到DOM
*/
function renderToDOM(sizeConfig) {
if (!$container || !$container.length) return;
var containerClasses = [
'phone-image-container',
'tpl-' + config.template,
'size-' + config.size,
'font-' + config.font
];
$container.attr('class', containerClasses.join(' '));
$container.css({
'font-family': config.fonts[config.font] || config.fonts['source-han-sans'],
'font-size': config.fontSize + 'px'
});
$container.empty();
for (var i = 0; i < pages.length; i++) {
$container.append(pages[i].html);
}
}
// ===== 图片生成 =====
/**
* 逐页截图生成图片
* @param {Function} [onProgress] 进度回调 function(currentIndex, totalPages, canvas)
* @returns {Promise} jQuery Deferred, resolves with array of canvas objects
*/
function generateImages(onProgress) {
var deferred = $.Deferred();
var canvases = [];
var $pages = $container.find('.phone-image-page');
if ($pages.length === 0) {
deferred.reject('没有可渲染的页面');
return deferred.promise();
}
var index = 0;
var total = $pages.length;
function captureNext() {
if (index >= total) {
if (onProgress) onProgress(total, total, null);
deferred.resolve(canvases);
return;
}
var $page = $($pages[index]);
html2canvas($page[0], {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
width: $page.outerWidth(),
height: $page.outerHeight(),
logging: false
}).then(function (canvas) {
canvases.push(canvas);
index++;
if (onProgress) onProgress(index, total, canvas);
captureNext();
}).catch(function (err) {
deferred.reject('截图失败(第' + (index + 1) + '页): ' + err);
});
}
if (onProgress) onProgress(0, total, null);
captureNext();
return deferred.promise();
}
/**
* 将canvas转为base64并保存到服务端
* @param {number} postId 文章ID
* @param {Object} saveConfig 配置信息
* @param {Function} [onProgress] 生成进度回调 function(current, total, canvas)
* @returns {Promise} jQuery Deferred
*/
function saveImages(postId, saveConfig, onProgress) {
var deferred = $.Deferred();
generateImages(onProgress).then(function (canvases) {
var pagesData = [];
for (var i = 0; i < canvases.length; i++) {
pagesData.push(canvases[i].toDataURL('image/jpeg', 0.92));
}
$.ajax({
url: '/index.php/admin/post/savePostOutput',
type: 'POST',
data: JSON.stringify({
post_id: postId,
output_type: 'phone_image',
config: saveConfig || config,
pages: pagesData
}),
contentType: 'application/json',
success: function (result) {
if (result.code === 0) {
deferred.resolve(result.data);
} else {
deferred.reject(result.msg || '保存失败');
}
},
error: function (xhr) {
deferred.reject('网络错误: ' + xhr.statusText);
}
});
}).catch(function (err) {
deferred.reject(err);
});
return deferred.promise();
}
/**
* 将生成的canvas数组显示为缩略图
* @param {Array} canvases canvas对象数组
* @param {string} containerSelector 容器选择器
*/
function showGeneratedThumbnails(canvases, containerSelector) {
var $container = $(containerSelector);
$container.empty();
for (var i = 0; i < canvases.length; i++) {
var thumb = canvases[i].toDataURL('image/jpeg', 0.5);
var $item = $('<div class="phone-thumb-item" data-index="' + i + '">' +
'<img src="' + thumb + '" alt="第' + (i + 1) + '页">' +
'<span class="phone-thumb-page">' + (i + 1) + '</span>' +
'</div>');
$container.append($item);
}
}
// ===== 配置切换 =====
/**
* 切换模板
*/
function switchTemplate(name) {
config.template = name;
if ($container) {
$container.removeClass('tpl-minimal tpl-magazine tpl-mixed').addClass('tpl-' + name);
}
}
/**
* 切换尺寸
*/
function switchSize(name) {
config.size = name;
}
/**
* 切换字体
*/
function switchFont(name) {
config.font = name;
if ($container) {
$container.removeClass('font-source-han-sans font-alibaba-puhuiti font-lxgw-wenkai').addClass('font-' + name);
$container.css('font-family', config.fonts[name] || config.fonts['source-han-sans']);
}
}
// ===== 工具方法 =====
/**
* HTML转义
*/
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
div.appendChild(document.createTextNode(text));
return div.innerHTML;
}
/**
* 获取当前配置
*/
function getConfig() {
return $.extend({}, config);
}
/**
* 获取分页结果
*/
function getPages() {
return pages.slice();
}
// ===== AI 智能排版 =====
/**
* 应用AI推荐配置
*/
function applyAiRecommendation(recommendation) {
if (recommendation.template) {
switchTemplate(recommendation.template);
$('.template-btn').removeClass('active');
$('.template-btn[data-template="' + recommendation.template + '"]').addClass('active');
$('[name="size"]').val(config.size);
}
if (recommendation.font) {
switchFont(recommendation.font);
$('[name="font"]').val(recommendation.font);
}
if (recommendation.font_size) {
config.fontSize = parseInt(recommendation.font_size);
$('[name="fontSize"]').val(config.fontSize);
$('#fontSizeValue').text(config.fontSize + 'px');
}
}
/**
* 应用AI优化内容替换预览用的内容
*/
var originalContentHtml = '';
function applyAiContent(optimizedContent) {
if (!originalContentHtml) {
originalContentHtml = postData.content_html;
}
if (optimizedContent.optimized_paragraphs) {
var newHtml = '';
for (var i = 0; i < optimizedContent.optimized_paragraphs.length; i++) {
newHtml += '<p>' + optimizedContent.optimized_paragraphs[i] + '</p>';
}
postData.content_html = newHtml;
postData.title = optimizedContent.optimized_title || postData.title;
}
}
/**
* 恢复原始内容
*/
function restoreOriginalContent() {
if (originalContentHtml) {
postData.content_html = originalContentHtml;
originalContentHtml = '';
}
}
// ===== 公开API =====
return {
init: init,
render: render,
paginate: render,
generateImages: generateImages,
saveImages: saveImages,
showGeneratedThumbnails: showGeneratedThumbnails,
switchTemplate: switchTemplate,
switchSize: switchSize,
switchFont: switchFont,
getConfig: getConfig,
getPages: getPages,
applyAiRecommendation: applyAiRecommendation,
applyAiContent: applyAiContent,
restoreOriginalContent: restoreOriginalContent
};
})();