Files
ulthon_information/docs/phone-image-architecture.md
augushong 518085d493 refactor(phone-image): 配置字段对齐,架构文档更新
- PHP getConfigFields 移除 template/font,新增 pageAlignments
- validateConfig 字段与 getConfigFields 一致
- 架构文档更新数据流、配置体系、已知问题、修复记录章节
2026-05-11 21:24:16 +08:00

18 KiB
Raw Blame History

手机图片排版功能 - 架构文档

基于源代码实际阅读编写,记录排版引擎的设计原理与数据流。

1. 渲染管线

排版引擎的核心是 PhoneImageEngineIIFE 闭包,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)。insertPageBreakremovePageBreak 也有类似的 _retryCount 轮询机制(phone-image.js:1198-1209, phone-image.js:1233-1244)。

1.3 截图机制

截图在 #render-staging 隐藏区域进行(phone-image.js:64-73)。关键步骤:

  1. 将所有页面HTML append到staging区域
  2. 临时将 visibility:hidden 切为 visiblehtml2canvas 会跳过隐藏元素)
  3. 串行逐页调用 html2canvas 截图(runCaptureLoop, L880
  4. 纯图片页优化:直接用 Image + Canvas 绘制,跳过 html2canvasphone-image.js:765-829
  5. 截图完成后恢复 visibility:hidden

缩略图用 scale:1,保存用 scale:2输出1080px宽度

1.4 表格/代码块的预转换

在分页之前,中间栏 #content-flow 里的 <table><pre> 块会被 html2canvas 截图并替换为 <img data-converted="true"> 标签(phone-image.js:1051-1132)。转换结果用 convertedBlockCachedjb2哈希做key缓存避免重复截图。这是为了保证分页算法拿到准确的高度值因为 html2canvas 无法在 staging 区域中直接处理复杂元素。

2. 数据流

2.1 修复后的单一数据源模型(当前状态)

修复后,引擎闭包变量 postData.content_html 成为唯一的权威数据源,所有读写操作统一通过引擎 API 进行。

权威数据源:闭包 postData.content_htmlphone-image.js:32),通过以下两个 API 对外暴露:

API 位置 用途
getContentHtml() phone-image.js:1485-1487 返回 postData.content_html,外部获取内容的唯一标准接口
updateConfig(newConfig) phone-image.js:1488-1492 使用 $.extend(config, newConfig) 更新配置,不重置 postData

DOM #post-content-html 的角色降级:仅作为初始化数据源和显示层,不再被保存操作读取。保存后主动同步 $('#post-content-html').html(PhoneImageEngine.getContentHtml())phone_image.html:271, phone_image.html:295),保持显示层与权威源一致。

各操作的数据流

保存配置 (#btn-save):
  content_html 取自 PhoneImageEngine.getContentHtml()  -- L268
  保存后同步: $('#post-content-html').html(getContentHtml())  -- L271

生成并保存 (#btn-generate):
  content_html 取自 PhoneImageEngine.getContentHtml()  -- L290
  保存后同步: $('#post-content-html').html(getContentHtml())  -- L295

重新排版 (doRender):
  用 PhoneImageEngine.updateConfig(newConfig)  -- L239
  不再调用 init(),不重置 postData.content_html

加载历史 (loadFromHistory):
  PhoneImageEngine.init(postData, historyConfig)  -- L414 (全量初始化)
  PhoneImageEngine.render()  -- L416

分页标记操作仍然直接修改闭包内的 postData.content_htmlinsertPageBreak L1223, removePageBreak L1261但保存时通过 getContentHtml() 读取,确保标记不会丢失。

2.2 修复前的三源问题(历史记录)

以下记录修复前的问题状态,保留作为设计决策参考。

文章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_htmlphone-image.js:1223, phone-image.js:1261),不更新 DOM #post-content-html 和模板局部的 postData.contentHtml。而保存操作(#btn-save, #btn-generate)原来读取的是 $('#post-content-html').html(),这意味着分页标记在保存时会丢失。此问题已通过将保存操作改为读取 getContentHtml() 修复。

2.3 完整数据流图(修复后)

[数据库 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 取自 getContentHtml())
  - 生成保存 -> POST savePostOutput (content_html 取自 getContentHtml())
  - 重新排版 -> updateConfig() + render() (不重置 postData)
        |
        v
后端存储
  - PostOutput.config (JSON字段含 content_html 副本)
  - PostOutputFile (每页图片文件记录)

2.4 历史记录加载流(修复后)

loadFromHistory(outputId) (phone_image.html:374)
  -> GET loadPostOutputConfig?id=XXX
  -> 更新表单控件 (size/fontSize/watermark)
  -> 更新 postData.contentHtml (模板局部变量)
  -> PhoneImageEngine.init(postData, historyConfig) (全量初始化, L414)
  -> PhoneImageEngine.render() (L416)

修复后 loadFromHistory 使用 init + render 全量初始化引擎,直接将历史内容传入闭包,不再依赖 doRender 的间接传递。

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 软删除标记 (默认0trait: 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 PostOutputFilePostOutput 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}.jpgPhoneImage.php:87-92。ZIP 临时文件生成到 runtime/temp/ 目录,请求结束后通过 register_shutdown_function 清理(Post.php:570-575)。

4. 配置体系

4.1 PHP 端配置字段定义(对齐后)

PhoneImage::getConfigFields() (L15-23) 定义了4个字段已与 JS 端对齐:

字段 类型 选项/范围 默认值
size select xiaohongshu/douyin xiaohongshu
fontSize number 10-24 14
watermark text - ''
pageAlignments json - '{}'

已移除的字段template3个模板选项 minimal/magazine/mixedfont3个字体选项 source-han-sans/alibaba-puhuiti/lxgw-wenkai已从 getConfigFields() 中移除,因为 JS 端无对应的模板/字体切换逻辑。

新增的字段pageAlignments 已新增到 getConfigFields(),类型为 json,默认值 '{}',与 JS 端的逐页对齐功能对齐。

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 使用 状态
size select, 2个选项 核心功能 已对齐
fontSize number, 10-24 核心功能 已对齐
watermark text 核心功能 已对齐
pageAlignments json 核心功能 已对齐
contentPadding 未定义 硬编码为20 (L23) 仍为 JS 内部常量,无需 PHP 定义

validateConfig() (L28-50) 验证 size/fontSizepageAlignments 为 JSON 类型不做严格校验。前端发送的配置比 PHP 定义的字段多(如 content_htmlsizescontentPaddingPHP 不做字段白名单过滤,全部存入 JSON config 字段。

修复前的字段不对齐记录templatePHP 有定义但 JS 不使用)和 fontPHP 有定义但 JS 不使用)已从 PHP 端移除;pageAlignmentsJS 核心功能但 PHP 未定义)已新增到 PHP 端。

5. 已知问题

5.1 已修复

5.1.1 分页标记丢失(已修复)

现象:用户通过中间栏的 "+" 按钮插入分页标记后,点击"保存"或"生成并保存",重新加载页面时分页标记消失。

根因:数据源不一致。分页标记写入闭包 postData.content_html,但保存时读取的是 DOM #post-content-html(不含标记)。

修复方案:保存操作改用 PhoneImageEngine.getContentHtml() 读取闭包内的权威数据源(phone_image.html:268, phone_image.html:290),保存后主动同步 DOMphone_image.html:271, phone_image.html:295)。

5.1.2 双数据源不一致(已修复)

现象doRender 调用 init() 重置 postData.content_html,导致分页标记在重新排版时丢失。

根因doRender 通过 init() 传递配置,init() 会用 DOM 内容覆盖闭包变量。

修复方案doRender 改用 PhoneImageEngine.updateConfig(newConfig)phone_image.html:239),仅更新配置不重置 postData

5.1.3 PHP/JS 字段不对齐(已修复)

现象PHP 定义了 JS 不使用的 template/font 字段,缺少 JS 核心使用的 pageAlignments 字段。

修复方案PHP getConfigFields() 移除 template/font,新增 pageAlignmentsPhoneImage.php:17-22)。

5.2 仍存在的问题

5.2.1 保存数据量限制

saveImages() 有16MB的客户端检查phone-image.js:1317-1320但服务端没有对应的分块接收机制。大文章超过约30页的base64数据可能超出 PHP post_max_size 配置。

5.2.2 目录权限 0777

PhoneImage.php:95:140mkdir 使用 0777 权限,与项目其他位置的 anti-pattern 一致。

5.2.3 接口 PostOutputManagerInterface 未被充分利用

PhoneImage 实现了 process()getPreview() 方法,但两者都返回空值(PhoneImage.php:56-67)。实际业务完全在控制器中直接调用 PhoneImage 的其他方法,绕过了接口定义的流程。接口仅作为类型约束存在。

5.2.4 saveConfigOnly 是静态方法但 createOutput 是实例方法

PhoneImage::saveConfigOnly() 是 static 方法L223而同类的 createOutput() 是实例方法L193。两者的实现几乎相同都是 PostOutput::create()),但调用方式不同。控制器中 savePostOutput 通过实例调用 createOutputsavePostOutputConfig 通过静态调用 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。


7. 修复记录

7.1 数据源统一修复

修复日期2026-05-11

核心原理将三个独立的数据源DOM、闭包变量、模板局部变量统一为单一权威数据源——引擎闭包变量 postData.content_html,通过新增的引擎 API 对外暴露。

变更清单

变更 文件 说明
新增 getContentHtml() phone-image.js:1485-1487 返回闭包内 postData.content_html,外部获取内容的唯一接口
新增 updateConfig(newConfig) phone-image.js:1488-1492 使用 $.extend(config, newConfig) 更新配置,不重置 postData
保存按钮改用 getContentHtml() phone_image.html:268 #btn-savecontent_html 取自引擎 API 而非 DOM
生成按钮改用 getContentHtml() phone_image.html:290 #btn-generatecontent_html 取自引擎 API 而非 DOM
保存后同步 DOM phone_image.html:271,295 保存成功后 $('#post-content-html').html(getContentHtml())
doRender 改用 updateConfig() phone_image.html:239 替代 init() 调用,避免重置 postData.content_html
loadFromHistory 改用 init + render phone_image.html:414-416 全量初始化引擎,直接传入历史内容
PHP getConfigFields 移除 template/font PhoneImage.php:17-22 移除 JS 端不使用的字段定义
PHP getConfigFields 新增 pageAlignments PhoneImage.php:21 与 JS 端逐页对齐功能对齐

修复效果

  • 分页标记在保存和重新排版后不再丢失
  • 历史记录加载正确恢复内容和配置
  • PHP/JS 端配置字段定义一致

文档基于以下源文件的完整阅读:

  • public/static/js/phone-image.js (1495行)
  • view/admin/post/phone_image.html (436行)
  • app/admin/controller/Post.php (L350-579)
  • app/common/tools/PhoneImage.php (229行)
  • app/common/tools/PostOutputManagerInterface.php (28行)
  • app/model/PostOutput.php (62行)
  • app/model/PostOutputFile.php (26行)