# 手机图片排版功能 - 架构文档 > 基于源代码实际阅读编写,记录排版引擎的设计原理与数据流。 ## 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`)。关键步骤: 1. 将所有页面HTML append到staging区域 2. 临时将 `visibility:hidden` 切为 `visible`(html2canvas 会跳过隐藏元素) 3. 串行逐页调用 html2canvas 截图(`runCaptureLoop`, L880) 4. 纯图片页优化:直接用 Image + Canvas 绘制,跳过 html2canvas(`phone-image.js:765-829`) 5. 截图完成后恢复 `visibility:hidden` 缩略图用 `scale:1`,保存用 `scale:2`(输出1080px宽度)。 ### 1.4 表格/代码块的预转换 在分页之前,中间栏 `#content-flow` 里的 `` 和 `
` 块会被 html2canvas 截图并替换为 `` 标签(`phone-image.js:1051-1132`)。转换结果用 `convertedBlockCache`(djb2哈希做key)缓存,避免重复截图。这是为了保证分页算法拿到准确的高度值,因为 html2canvas 无法在 staging 区域中直接处理复杂元素。

## 2. 数据流

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

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

**权威数据源**:闭包 `postData.content_html`(`phone-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_html`(`insertPageBreak` 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_html`(`phone-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` | 软删除标记 (默认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 结构

```json
{
  "size": "xiaohongshu",
  "fontSize": 14,
  "watermark": "",
  "content_html": "

文章HTML副本

", "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) 定义了4个字段,已与 JS 端对齐: | 字段 | 类型 | 选项/范围 | 默认值 | |------|------|-----------|--------| | `size` | select | xiaohongshu/douyin | xiaohongshu | | `fontSize` | number | 10-24 | 14 | | `watermark` | text | - | '' | | `pageAlignments` | json | - | '{}' | **已移除的字段**:`template`(3个模板选项 minimal/magazine/mixed)和 `font`(3个字体选项 source-han-sans/alibaba-puhuiti/lxgw-wenkai)已从 `getConfigFields()` 中移除,因为 JS 端无对应的模板/字体切换逻辑。 **新增的字段**:`pageAlignments` 已新增到 `getConfigFields()`,类型为 `json`,默认值 `'{}'`,与 JS 端的逐页对齐功能对齐。 ### 4.2 JS 端实际使用的配置 `phone-image.js` 闭包内的 `config` 对象 (L14-24): ```javascript 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`/`fontSize`,`pageAlignments` 为 JSON 类型不做严格校验。前端发送的配置比 PHP 定义的字段多(如 `content_html`、`sizes`、`contentPadding`),PHP 不做字段白名单过滤,全部存入 JSON config 字段。 > **修复前的字段不对齐记录**:`template`(PHP 有定义但 JS 不使用)和 `font`(PHP 有定义但 JS 不使用)已从 PHP 端移除;`pageAlignments`(JS 核心功能但 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`),保存后主动同步 DOM(`phone_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`,新增 `pageAlignments`(`PhoneImage.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` 和 `:140` 中 `mkdir` 使用 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` 通过实例调用 `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** 请求体: ```json { "post_id": 123, "output_type": "phone_image", "config": { "size": "...", "fontSize": 14, ... }, "content_html": "

...

", "pages": ["data:image/jpeg;base64,...", ...] } ``` **savePostOutputConfig** 请求体: ```json { "post_id": 123, "output_type": "phone_image", "config": { "size": "...", "fontSize": 14, ... }, "content_html": "

...

" } ``` **通用响应格式**: ```json { "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-save` 的 `content_html` 取自引擎 API 而非 DOM | | 生成按钮改用 `getContentHtml()` | `phone_image.html:290` | `#btn-generate` 的 `content_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行)