fix(phone-image): 修复分页标记丢失bug,消除双数据源问题 - 新增 getContentHtml() 和 updateConfig() 引擎API - 保存逻辑改用引擎内部 content_html,不再从DOM读取 - doRender 改用 updateConfig,配置变更不重置内容 - loadFromHistory 改用 init+render 全量初始化 - PHP/JS 配置字段对齐(移除template/font,新增pageAlignments)
13 KiB
手机图片排版功能 - 架构文档
基于源代码实际阅读编写,记录排版引擎的设计原理与数据流。
1. 渲染管线
排版引擎的核心是 PhoneImageEngine(IIFE 闭包,public/static/js/phone-image.js),纯前端运行,后端不参与渲染计算。
1.1 管线总览
用户触发 doRender()
|
v
PhoneImageEngine.init(options, userConfig) -- L80: 合并配置、注册事件委托
|
v
PhoneImageEngine.render() -- L129: 管线入口,带并发锁
|
+---> preprocessContent(content_html) -- L226: 清洗HTML(去script/style/iframe,规范化img)
+---> parseHtmlToBlocks(cleanHtml) -- L273: HTML -> 块级元素数组,含高度估算
+---> renderContentFlow(blocks) -- L1160: 将块渲染到中间栏 #content-flow
+---> convertFlowBlocksToImages(blocks) -- L1051: 表格/代码块 -> html2canvas截图 -> img标签替换
+---> measureBlockHeights(blocks) -- L1138: 从中间栏DOM读取实测高度,覆盖估算值
+---> generateCoverPage() -- L618: 生成封面页HTML
+---> paginateContent(blocks, areaHeight) -- L447: 分页算法,累加高度超限则换页
+---> generateSummaryPage() -- L730: 生成尾页HTML
+---> 页码补全 N/M -- L188: 二次遍历pages数组,替换占位页码
+---> renderThumbnails(sizeConfig) -- L967: 截图 -> 显示右侧缩略图
|
+---> doCapturePages({scale:1}) -- L840: staging隐藏区域渲染 + html2canvas串行截图
1.2 并发控制
render() 函数使用 _locked 标志防止并发渲染(phone-image.js:133-138)。渲染期间如果有新请求,设置 _pending = true,当前渲染完成后自动重试(phone-image.js:202-207)。insertPageBreak 和 removePageBreak 也有类似的 _retryCount 轮询机制(phone-image.js:1198-1209, phone-image.js:1233-1244)。
1.3 截图机制
截图在 #render-staging 隐藏区域进行(phone-image.js:64-73)。关键步骤:
- 将所有页面HTML append到staging区域
- 临时将
visibility:hidden切为visible(html2canvas 会跳过隐藏元素) - 串行逐页调用 html2canvas 截图(
runCaptureLoop, L880) - 纯图片页优化:直接用 Image + Canvas 绘制,跳过 html2canvas(
phone-image.js:765-829) - 截图完成后恢复
visibility:hidden
缩略图用 scale:1,保存用 scale:2(输出1080px宽度)。
1.4 表格/代码块的预转换
在分页之前,中间栏 #content-flow 里的 <table> 和 <pre> 块会被 html2canvas 截图并替换为 <img data-converted="true"> 标签(phone-image.js:1051-1132)。转换结果用 convertedBlockCache(djb2哈希做key)缓存,避免重复截图。这是为了保证分页算法拿到准确的高度值,因为 html2canvas 无法在 staging 区域中直接处理复杂元素。
2. 数据流
2.1 HTML内容的三个来源(三源问题)
文章HTML内容在运行时存在于三个独立位置,彼此之间没有同步机制:
| 来源 | 位置 | 说明 |
|---|---|---|
DOM #post-content-html |
phone_image.html:98 |
服务端渲染的隐藏div,{$layoutContentHtml|raw} |
闭包 postData.content_html |
phone-image.js:32 |
IIFE闭包变量,init()时赋值 |
初始化属性 postData.contentHtml |
phone_image.html:193 |
模板内联脚本中的局部对象属性 |
具体流向:
服务端 ($layoutContentHtml)
-> DOM #post-content-html (L98, display:none)
-> 模板脚本读取: $('#post-content-html').html() (L193)
-> 赋值给 postData.contentHtml (模板局部变量)
-> init({contentHtml: ...}) (L208)
-> 闭包 postData.content_html = options.contentHtml (phone-image.js:85)
问题:分页标记操作(insertPageBreak/removePageBreak)只更新闭包内的 postData.content_html(phone-image.js:1223, phone-image.js:1261),不更新 DOM #post-content-html 和模板局部的 postData.contentHtml。而保存操作(#btn-save, #btn-generate)读取的是 $('#post-content-html').html()(phone_image.html:267, phone_image.html:288),这意味着分页标记在保存时会丢失。
2.2 完整数据流图
[数据库 Post.content_html]
|
v
Post.php::phoneImage() (L355)
查询 PostOutput 取已保存的 content_html 副本
|
v
phone_image.html 模板渲染
- $layoutContentHtml (优先用已保存副本)
- $layoutConfig (已保存的配置)
|
v
前端初始化 (phone_image.html:188-208)
1. 构造 postData 局部对象 (L188)
2. 恢复 savedConfig (L201)
3. PhoneImageEngine.init(postData, initConfig) (L208)
|
v
渲染管线 (phone-image.js render())
- 读取 postData.content_html
- 生成 pages 数组
- 截图显示缩略图
|
v
用户交互
- 分页标记 -> 修改 postData.content_html (闭包内)
- 保存配置 -> POST savePostOutputConfig (content_html 取自 DOM)
- 生成保存 -> POST savePostOutput (content_html 取自 DOM)
|
v
后端存储
- PostOutput.config (JSON字段,含 content_html 副本)
- PostOutputFile (每页图片文件记录)
2.3 历史记录加载流
loadFromHistory(outputId) (phone_image.html:371)
-> GET loadPostOutputConfig?id=XXX
-> 更新表单控件 (size/fontSize/watermark)
-> 更新 postData.contentHtml (模板局部变量)
-> 更新 DOM #post-content-html
-> doRender(renderConfig) (含 pageAlignments)
注意:loadFromHistory 更新的是模板局部的 postData.contentHtml 和 DOM,但没有直接更新闭包内的 postData.content_html。它依赖 doRender -> init() 的间接传递来同步。
3. 存储模型
3.1 数据库表
ul_post_output (模型: app/model/PostOutput.php)
| 字段 | 说明 |
|---|---|
id |
主键 |
post_id |
关联文章ID |
output_type |
输出类型,固定为 phone_image |
config |
JSON字段,存储完整配置 + content_html 副本 |
status |
0=生成中, 1=已完成, 2=失败 (常量定义 L20-22) |
page_count |
页数 |
admin_id |
操作管理员ID |
create_time |
创建时间 (int) |
delete_time |
软删除标记 (默认0,trait: SoftDelete) |
模型使用 $json = ['config'] 自动序列化/反序列化配置(PostOutput.php:18)。
ul_post_output_file (模型: app/model/PostOutputFile.php)
| 字段 | 说明 |
|---|---|
id |
主键 |
output_id |
关联 post_output.id |
page |
页码 (从1开始) |
file_path |
相对路径,如 /upload/post_output/20260511/5_1.jpg |
file_url |
访问URL (同 file_path) |
file_size |
文件字节数 |
width |
图片宽度 (px) |
height |
图片高度 (px) |
关系: PostOutput hasMany PostOutputFile,PostOutput belongsTo Post。
3.2 config JSON 结构
{
"size": "xiaohongshu",
"fontSize": 14,
"watermark": "",
"content_html": "<p>文章HTML副本</p>",
"pageAlignments": { "1": "top", "2": "center" }
}
content_html 作为配置的一部分保存,使每次排版记录都携带当时的内容快照。这实现了排版内容与文章原文的解耦(Post.php:369-376 加载时会优先使用此副本)。
3.3 文件存储
图片保存到 public/upload/post_output/{Ymd}/{outputId}_{page}.jpg(PhoneImage.php:87-92)。ZIP 临时文件生成到 runtime/temp/ 目录,请求结束后通过 register_shutdown_function 清理(Post.php:570-575)。
4. 配置体系
4.1 PHP 端配置字段定义
PhoneImage::getConfigFields() (L15-23) 定义了5个字段:
| 字段 | 类型 | 选项/范围 | 默认值 |
|---|---|---|---|
template |
select | minimal/magazine/mixed | minimal |
size |
select | xiaohongshu/douyin | xiaohongshu |
font |
select | source-han-sans/alibaba-puhuiti/lxgw-wenkai | source-han-sans |
fontSize |
number | 10-24 | 14 |
watermark |
text | - | '' |
4.2 JS 端实际使用的配置
phone-image.js 闭包内的 config 对象 (L14-24):
config = {
size: 'xiaohongshu',
fontSize: 14,
watermark: '',
pageAlignments: {}, // 逐页对齐
sizes: { ... }, // 尺寸预设
contentPadding: 20
}
4.3 PHP/JS 字段不对齐
两端存在明显的字段定义差异:
| 问题 | PHP 定义 | JS 实际使用 |
|---|---|---|
template |
PHP有定义 (3个模板选项) | JS完全不使用,无任何模板切换逻辑 |
font |
PHP有定义 (3个字体选项) | JS不使用,字体通过CSS引入,无切换机制 |
pageAlignments |
PHP未定义 | JS核心功能,保存在config中 |
contentPadding |
PHP未定义 | JS硬编码为20 (L23) |
validateConfig() (L29-50) 验证 template/size/font/fontSize,但 pageAlignments 不在验证范围内。前端发送的配置比PHP定义的字段多,PHP不做字段白名单过滤,全部存入JSON。
5. 已知问题
5.1 分页标记丢失(根因分析)
现象:用户通过中间栏的 "+" 按钮插入分页标记后,点击"保存"或"生成并保存",重新加载页面时分页标记消失。
根因:数据源不一致。
分页标记的插入流程 (insertPageBreak, L1198-1227):
- 从闭包
postData.content_html读取内容 - 在目标位置插入
<hr>标签 - 写回闭包
postData.content_html(L1223)
保存操作的配置构建 (phone_image.html:262-268, 284-289):
content_html: $('#post-content-html').html() // 读取的是DOM元素
DOM #post-content-html 的内容在页面初始化后从未被 insertPageBreak 更新。因此保存时发送的 content_html 是初始值,不含 <hr> 分页标记。下次加载页面时,服务端用这个无标记的内容渲染,标记丢失。
5.2 保存数据量限制
saveImages() 有16MB的客户端检查(phone-image.js:1317-1320),但服务端没有对应的分块接收机制。大文章(超过约30页)的base64数据可能超出 PHP post_max_size 配置。
5.3 目录权限 0777
PhoneImage.php:95 和 :140 中 mkdir 使用 0777 权限,与项目其他位置的 anti-pattern 一致。
5.4 接口 PostOutputManagerInterface 未被充分利用
PhoneImage 实现了 process() 和 getPreview() 方法,但两者都返回空值(PhoneImage.php:56-67)。实际业务完全在控制器中直接调用 PhoneImage 的其他方法,绕过了接口定义的流程。接口仅作为类型约束存在。
5.5 saveConfigOnly 是静态方法但 createOutput 是实例方法
PhoneImage::saveConfigOnly() 是 static 方法(L223),而同类的 createOutput() 是实例方法(L193)。两者的实现几乎相同(都是 PostOutput::create()),但调用方式不同。控制器中 savePostOutput 通过实例调用 createOutput,savePostOutputConfig 通过静态调用 saveConfigOnly,风格不统一。
6. API 端点清单
所有端点位于 app/admin/controller/Post.php,通过 ThinkPHP 自动路由访问,URL前缀为 /index.php/admin/post/。
| 端点 | 方法 | 行号 | 说明 | 前端调用方 |
|---|---|---|---|---|
phoneImage/{id} |
GET | L355-383 | 排版操作页(渲染模板) | 页面跳转 |
savePostOutput |
POST (JSON) | L405-437 | 保存输出记录+图片 | phone-image.js:1324 |
savePostOutputConfig |
POST (JSON) | L442-462 | 仅保存配置(不生成图片) | phone_image.html:184 -> saveConfig() |
loadPostOutputConfig |
GET | L467-490 | 加载历史记录配置 | phone_image.html:186 -> loadFromHistory() |
getOutputListJson |
GET | L495-515 | 获取输出记录列表 | phone_image.html:185 -> 历史弹窗 |
deletePostOutput |
POST/GET | L520-531 | 删除输出记录及文件 | 未在前端模板中直接调用 |
regeneratePostOutput |
POST/GET | L536-552 | 删除旧记录,返回配置 | 未在前端模板中直接调用 |
downloadPostOutputZip/{id} |
GET | L557-579 | 打包下载ZIP | phone_image.html:183 -> #btn-download |
postOutputList/{id} |
GET | L388-400 | 输出管理列表页 | 独立页面入口 |
请求/响应格式
savePostOutput 请求体:
{
"post_id": 123,
"output_type": "phone_image",
"config": { "size": "...", "fontSize": 14, ... },
"content_html": "<p>...</p>",
"pages": ["data:image/jpeg;base64,...", ...]
}
savePostOutputConfig 请求体:
{
"post_id": 123,
"output_type": "phone_image",
"config": { "size": "...", "fontSize": 14, ... },
"content_html": "<p>...</p>"
}
通用响应格式:
{ "code": 0, "msg": "", "data": { "output_id": 456 } }
失败时 code 为 500。
文档基于以下源文件的完整阅读:
public/static/js/phone-image.js(1487行)view/admin/post/phone_image.html(425行)app/admin/controller/Post.php(L350-579)app/common/tools/PhoneImage.php(234行)app/common/tools/PostOutputManagerInterface.php(28行)app/model/PostOutput.php(62行)app/model/PostOutputFile.php(26行)