Files
ulthon_information/docs/phone-image-architecture.md
augushong e9d839ae8a docs(phone-image): 产出排版功能架构文档
fix(phone-image): 修复分页标记丢失bug,消除双数据源问题

- 新增 getContentHtml() 和 updateConfig() 引擎API
- 保存逻辑改用引擎内部 content_html,不再从DOM读取
- doRender 改用 updateConfig,配置变更不重置内容
- loadFromHistory 改用 init+render 全量初始化
- PHP/JS 配置字段对齐(移除template/font,新增pageAlignments)
2026-05-11 21:17:37 +08:00

316 lines
13 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 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` | 软删除标记 (默认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) 定义了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)
```javascript
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)
1. 从闭包 `postData.content_html` 读取内容
2. 在目标位置插入 `<hr>` 标签
3. 写回闭包 `postData.content_html`L1223
保存操作的配置构建 (`phone_image.html:262-268`, `284-289`)
```javascript
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** 请求体:
```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。
---
*文档基于以下源文件的完整阅读:*
- `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行)