refactor(phone-image): Wave 2 - form, controller, JS engine, API updates

T3: Add cover_text textarea to post edit form

T4: Update Post controller - content copy + cover_text passing

T5: Refactor JS engine - remove old APIs, add forced breaks, page numbers, per-page alignment

T8: Add cover_text to API default_fields, apidoc (4 places), AGENTS.md
This commit is contained in:
augushong
2026-05-02 09:16:05 +08:00
parent 2aa60f87ed
commit bcd00e32ea
6 changed files with 109 additions and 186 deletions

View File

@@ -73,6 +73,7 @@ AJAX请求 -> JSON; 普通请求 -> 模板页面(common@tpl/success|error)
Post --(M:N)--> Category (通过 post_category)
Post --(M:N)--> Tag (通过 post_tag)
Post --(1:N)--> PostComment, PostVisit
Post 字段含 cover_text: 封面文案,用于手机图片排版封面展示
Admin --(N:1)--> AdminGroup --(权限列表)--> AdminPermission
表前缀: ul_时间戳: int(10),软删除: delete_time字段
```

View File

@@ -358,7 +358,24 @@ class Post extends Common
if (empty($model_post)) {
$this->error('文章不存在');
}
// 查询已有的排版输出,获取排版内容副本
$postOutput = \app\model\PostOutput::where('post_id', $id)
->where('output_type', 'phone_image')
->order('id', 'desc')
->find();
// 排版内容副本:优先使用已保存的副本,否则使用原文
$layoutContentHtml = $model_post->content_html;
$layoutConfig = [];
if ($postOutput && !empty($postOutput->config['content_html'])) {
$layoutContentHtml = $postOutput->config['content_html'];
$layoutConfig = $postOutput->config;
}
View::assign('post', $model_post);
View::assign('layoutContentHtml', $layoutContentHtml);
View::assign('layoutConfig', $layoutConfig);
return View::fetch();
}
@@ -395,6 +412,10 @@ class Post extends Common
}
$config = $data['config'] ?? [];
// 确保排版内容副本被保存到 config
if (isset($data['content_html']) && !isset($config['content_html'])) {
$config['content_html'] = $data['content_html'];
}
$pages = $data['pages'] ?? [];
$admin_id = session('admin_id') ?? 0;

View File

@@ -154,6 +154,7 @@ class Articles extends BaseController
'jump_to_url_status' => 0,
'poster' => '',
'desc' => '',
'cover_text' => '',
'author_name' => '',
'hits' => 0,
'is_top' => 0,

View File

@@ -12,23 +12,14 @@ var PhoneImageEngine = (function () {
// ===== 配置 =====
var config = {
template: 'minimal',
size: 'xiaohongshu',
font: 'source-han-sans',
fontSize: 14,
watermark: '',
// 尺寸映射 (渲染尺寸, 实际输出是2倍)
pageAlignments: {}, // key=页码(1-based), value='top'|'center'
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
};
@@ -37,6 +28,7 @@ var PhoneImageEngine = (function () {
id: 0,
title: '',
desc: '',
coverText: '', // 封面文案
content_html: '',
poster: '',
author_name: '',
@@ -47,18 +39,16 @@ var PhoneImageEngine = (function () {
// ===== 分页结果 =====
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}
* @param {Object} options - {postId, title, desc, coverText, contentHtml, poster, authorName, createTime, categoryName}
* @param {Object} userConfig - {size, fontSize, watermark}
*/
function init(options, userConfig) {
postData.id = options.postId || 0;
postData.title = options.title || '';
postData.desc = options.desc || '';
postData.coverText = options.coverText || '';
postData.content_html = options.contentHtml || '';
postData.poster = options.poster || '';
postData.author_name = options.authorName || '';
@@ -68,12 +58,11 @@ var PhoneImageEngine = (function () {
if (userConfig) {
$.extend(config, userConfig);
}
$container = $('#phone-image-container');
}
/**
* 渲染排版预览 - 生成分页后的HTML
* 两遍遍历: 先算总页数, 再带页码生成
* @returns {Array} 页面数组,每项是 { type, html, pageNum }
*/
function render() {
@@ -82,22 +71,31 @@ var PhoneImageEngine = (function () {
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));
// 两遍遍历: 现在知道了总页数, 给每页补上 N/M 页码
var totalPages = pages.length;
for (var i = 0; i < pages.length; i++) {
if (pages[i].type === 'content') {
// 在页脚中添加 N/M 格式页码
pages[i].html = pages[i].html.replace(
'<span>' + pages[i].pageNum + '</span>',
'<span class="page-number-indicator">' + (i + 1) + '/' + totalPages + '</span>'
);
}
}
// 渲染到DOM
renderToDOM(sizeConfig);
@@ -191,6 +189,11 @@ var PhoneImageEngine = (function () {
};
switch (tagName) {
case 'hr':
block.type = 'page-break';
block.html = '';
block.estimatedHeight = 0;
break;
case 'h1':
case 'h2':
case 'h3':
@@ -296,7 +299,7 @@ var PhoneImageEngine = (function () {
/**
* 将块级元素按高度累加,超过可用高度时分页
* 支持超大块拆分(超长文本拆成多段)
* 支持 <hr> 强制分页和超大块拆分(超长文本拆成多段)
*/
function paginateContent(blocks, contentAreaHeight, sizeConfig) {
var contentPages = [];
@@ -307,6 +310,20 @@ var PhoneImageEngine = (function () {
for (var i = 0; i < blocks.length; i++) {
var block = blocks[i];
// 强制分页标记
if (block.type === 'page-break') {
// 结束当前页
if (currentPageBlocks.length > 0) {
contentPages.push(generateContentPage(
currentPageBlocks, pageNumber, sizeConfig, false
));
currentPageBlocks = [];
currentHeight = 0;
pageNumber++;
}
continue; // 跳过这个 page-break 块本身
}
// 超大块处理:单个块超过整页高度时需要拆分
if (block.estimatedHeight > contentAreaHeight) {
// 先把当前页已有的内容推出去
@@ -449,6 +466,10 @@ var PhoneImageEngine = (function () {
if (postData.desc) {
html += '<div class="cover-subtitle">' + escapeHtml(postData.desc) + '</div>';
}
// 封面文案(有封面图)
if (postData.coverText) {
html += '<div style="font-size:14px;color:#1890ff;margin-top:8px;">' + escapeHtml(postData.coverText) + '</div>';
}
html += '<div class="cover-meta">';
if (postData.category_name) {
html += escapeHtml(postData.category_name) + ' | ';
@@ -469,6 +490,10 @@ var PhoneImageEngine = (function () {
if (postData.desc) {
html += '<div class="cover-no-img-desc">' + escapeHtml(postData.desc) + '</div>';
}
// 封面文案(无封面图)
if (postData.coverText) {
html += '<div style="font-size:14px;color:#1890ff;margin-top:10px;padding:0 30px;word-break:break-word;">' + escapeHtml(postData.coverText) + '</div>';
}
html += '<div class="cover-no-img-meta">';
if (postData.category_name) {
html += '<span>' + escapeHtml(postData.category_name) + '</span>';
@@ -490,13 +515,17 @@ var PhoneImageEngine = (function () {
}
/**
* 生成内容页HTML
* 生成内容页HTML(含逐页对齐支持)
*/
function generateContentPage(blocks, pageNum, sizeConfig, isLast) {
var html = '<div class="phone-image-page page-body" style="width:' +
// 读取逐页对齐配置
var alignment = (config.pageAlignments && config.pageAlignments[pageNum]) || 'top';
var valignClass = alignment === 'center' ? ' valign-center' : '';
var html = '<div class="phone-image-page page-body' + valignClass + '" 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>';
@@ -510,7 +539,7 @@ var PhoneImageEngine = (function () {
}
html += '</div>';
// 页脚
// 页脚 - 占位render() 中会替换页码
html += '<div class="page-footer">';
html += '<span>' + escapeHtml(postData.author_name || '') + '</span>';
html += '<span>' + pageNum + '</span>';
@@ -546,119 +575,46 @@ var PhoneImageEngine = (function () {
// ===== DOM渲染 =====
// 当前预览页码 (0-based)
var currentPreviewPage = 0;
/**
* 渲染分页结果到DOM
* 全尺寸平铺渲染到 #paginated-preview
*/
function renderToDOM(sizeConfig) {
if (!$container || !$container.length) return;
var $preview = $('#paginated-preview');
if (!$preview.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();
$preview.empty();
for (var i = 0; i < pages.length; i++) {
$container.append(pages[i].html);
$preview.append(pages[i].html);
}
// 确保当前页码有效
if (currentPreviewPage >= pages.length) {
currentPreviewPage = Math.max(0, pages.length - 1);
}
// 显示当前页, 隐藏其他页
showPreviewPage(currentPreviewPage);
}
/**
* 显示指定预览页
* @param {number} index 页码(0-based)
*/
function showPreviewPage(index) {
if (!$container || !$container.length) return;
var $allPages = $container.find('.phone-image-page');
$allPages.hide();
if (index >= 0 && index < $allPages.length) {
$allPages.eq(index).show();
currentPreviewPage = index;
}
// 更新页码显示
$container.trigger('pageChange', [currentPreviewPage, pages.length]);
}
/**
* 下一页
*/
function nextPage() {
if (currentPreviewPage < pages.length - 1) {
showPreviewPage(currentPreviewPage + 1);
}
}
/**
* 上一页
*/
function prevPage() {
if (currentPreviewPage > 0) {
showPreviewPage(currentPreviewPage - 1);
}
}
/**
* 获取当前预览页码
*/
function getCurrentPageIndex() {
return currentPreviewPage;
}
// ===== 图片生成 =====
/**
* 逐页截图生成图片
* 逐页截图生成图片简化版所有页面已在DOM中可见
* @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');
var $pages = $('#paginated-preview .phone-image-page');
if ($pages.length === 0) {
deferred.reject('没有可渲染的页面');
return deferred.promise();
}
// 截图时需要所有页面都显示
$pages.show();
var index = 0;
var total = $pages.length;
function captureNext() {
if (index >= total) {
if (onProgress) onProgress(total, total, null);
// 恢复单页显示
showPreviewPage(currentPreviewPage);
deferred.resolve(canvases);
return;
}
// 隐藏其他页, 只显示当前要截图的页
$pages.hide();
$pages.eq(index).show();
html2canvas($pages.eq(index)[0], {
scale: 2,
useCORS: true,
@@ -672,8 +628,6 @@ var PhoneImageEngine = (function () {
if (onProgress) onProgress(index, total, canvas);
captureNext();
}).catch(function (err) {
// 恢复单页显示
showPreviewPage(currentPreviewPage);
deferred.reject('截图失败(第' + (index + 1) + '页): ' + err);
});
}
@@ -748,16 +702,6 @@ var PhoneImageEngine = (function () {
// ===== 配置切换 =====
/**
* 切换模板
*/
function switchTemplate(name) {
config.template = name;
if ($container) {
$container.removeClass('tpl-minimal tpl-magazine tpl-mixed').addClass('tpl-' + name);
}
}
/**
* 切换尺寸
*/
@@ -765,15 +709,18 @@ var PhoneImageEngine = (function () {
config.size = name;
}
// ===== 逐页对齐 =====
/**
* 切换字体
* 设置指定页的对齐方式
* @param {number} pageNum 页码(1-based)
* @param {string} align 'top'|'center'
*/
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']);
function setPageAlignment(pageNum, align) {
if (!config.pageAlignments) {
config.pageAlignments = {};
}
config.pageAlignments[pageNum] = align;
}
// ===== 工具方法 =====
@@ -802,76 +749,16 @@ var PhoneImageEngine = (function () {
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,
nextPage: nextPage,
prevPage: prevPage,
showPreviewPage: showPreviewPage,
getCurrentPageIndex: getCurrentPageIndex
setPageAlignment: setPageAlignment
};
})();

View File

@@ -66,6 +66,15 @@
</div>
</div>
{/notin}
<div class="layui-form-item">
<div class="layui-form-label">
封面文案
<p class="layui-word-aux">用于手机图片排版封面展示</p>
</div>
<div class="layui-input-block">
<textarea name="cover_text" class="layui-textarea" placeholder="用于手机图片排版封面展示(可选)">{$post->getData('cover_text')}</textarea>
</div>
</div>
<div class="layui-form-item">
<div class="layui-form-label">描述</div>
<div class="layui-input-block">

View File

@@ -200,6 +200,7 @@ X-API-Key: {api_key}</div>
"type": "1",
"status": 1,
"source": "api",
"cover_text": "",
"create_time": 1700000000,
"update_time": 1700000000,
"categorys": [...],
@@ -241,6 +242,7 @@ X-API-Key: {api_key}</div>
"content_html": "&lt;p&gt;HTML内容&lt;/p&gt;",
"content_type": "html",
"desc": "摘要",
"cover_text": "封面文案内容示例",
"poster": "/uploads/poster.jpg",
"type": "1",
"status": 1,
@@ -267,6 +269,7 @@ X-API-Key: {api_key}</div>
<tr><td>content</td><td>string</td><td></td><td>文章内容Markdown</td></tr>
<tr><td>content_html</td><td>string</td><td></td><td>文章内容HTML</td></tr>
<tr><td>desc</td><td>string</td><td></td><td>文章摘要</td></tr>
<tr><td>cover_text</td><td>string</td><td></td><td>封面文案,用于手机图片排版封面展示</td></tr>
<tr><td>poster</td><td>string</td><td></td><td>封面图 URL</td></tr>
<tr><td>type</td><td>string</td><td></td><td>文章类型,默认 "1"</td></tr>
<tr><td>status</td><td>int</td><td></td><td>状态,默认 0草稿</td></tr>
@@ -338,6 +341,7 @@ X-API-Key: {api_key}</div>
<tr><td>content</td><td>string</td><td></td><td>文章内容Markdown</td></tr>
<tr><td>content_html</td><td>string</td><td></td><td>文章内容HTML</td></tr>
<tr><td>desc</td><td>string</td><td></td><td>文章摘要</td></tr>
<tr><td>cover_text</td><td>string</td><td></td><td>封面文案,用于手机图片排版封面展示</td></tr>
<tr><td>poster</td><td>string</td><td></td><td>封面图 URL</td></tr>
<tr><td>status</td><td>int</td><td></td><td>状态</td></tr>
<tr><td>content_type</td><td>string</td><td></td><td>内容类型: "html"(默认) 或 "markdown"<br/>当为 "markdown" 时,系统会自动将 content 转换为 HTML 存储到 content_html 字段</td></tr>