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

397 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 手机图片排版功能 - 架构文档
> 基于源代码实际阅读编写,记录排版引擎的设计原理与数据流。
## 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` 里的 `<table>``<pre>` 块会被 html2canvas 截图并替换为 `<img data-converted="true">` 标签(`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` | 软删除标记 (默认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 PostOutputFile``PostOutput belongsTo Post`
### 3.2 config JSON 结构
```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) 定义了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": "<p>...</p>",
"pages": ["data:image/jpeg;base64,...", ...]
}
```
**savePostOutputConfig** 请求体:
```json
{
"post_id": 123,
"output_type": "phone_image",
"config": { "size": "...", "fontSize": 14, ... },
"content_html": "<p>...</p>"
}
```
**通用响应格式**:
```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行)