feat(phone-image): 增加翻页预览与无封面图排版样式

- 为手机截图生成器添加翻页功能,支持在生成前预览各页内容
- 增加无封面图时的排版样式,使用装饰线条和居中布局
- 改进图片处理逻辑,清除内联样式并展平嵌套包装元素
- 修复 models.dev 同步接口,支持 GET 请求获取缓存数据
- 优化网络请求,添加直连失败后的本地代理重试机制
This commit is contained in:
augushong
2026-05-01 16:31:26 +08:00
parent eab8cee8a8
commit 34fe255829
11 changed files with 334 additions and 27 deletions

View File

@@ -107,10 +107,22 @@ class System extends Common
/**
* 同步 models.dev 数据.
*
* GET: 返回缓存的供应商列表(不触发远程同步)
* POST: 强制从 models.dev 远程同步
*/
public function syncModelsDev()
{
$sync = new \app\common\tools\ai\ModelsDevSync();
if ($this->request->isGet()) {
$result = [
'providers' => $sync->getProviders(),
'models' => [],
];
return json(['code' => 0, 'msg' => 'ok', 'data' => $result]);
}
try {
$result = $sync->syncProviders();
$count = count($result['providers'] ?? []);

View File

@@ -118,6 +118,46 @@ class ModelsDevSync
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: application/json',
]);
// 尝试直连,失败后使用本地代理
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_errno($ch);
curl_close($ch);
if ($curlError !== 0 || $httpCode !== 200) {
// 直连失败,尝试通过本地代理
$response = $this->fetchViaProxy();
if ($response !== false) {
return $response;
}
return false;
}
return $response;
}
/**
* 通过本地代理获取数据.
*
* @return string|false
*/
protected function fetchViaProxy()
{
$proxyUrl = 'http://127.0.0.1:7005';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $this->apiUrl);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_PROXY, $proxyUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: application/json',
]);

BIN
phone-image-cover-fixed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
phone-image-page2-fixed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
phone-image-page2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
phone-image-preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -116,6 +116,80 @@
margin-top: var(--pi-spacing-sm);
}
/* --- 封面页 - 无封面图装饰排版 --- */
.page-cover.no-cover-image {
justify-content: center;
padding: var(--pi-spacing-lg);
}
.page-cover.no-cover-image .cover-decor-line {
position: absolute;
left: 30px;
right: 30px;
height: 2px;
background: var(--pi-color-border);
}
.page-cover.no-cover-image .cover-decor-top {
top: 80px;
}
.page-cover.no-cover-image .cover-decor-bottom {
bottom: 80px;
}
.page-cover.no-cover-image .cover-no-img-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--pi-spacing) 0;
}
.page-cover.no-cover-image .cover-no-img-title {
font-size: 36px;
font-weight: bold;
line-height: 1.4;
color: var(--pi-color-text);
word-break: break-word;
margin-bottom: var(--pi-spacing);
padding: 0 20px;
}
.page-cover.no-cover-image .cover-decor-divider {
width: 60px;
height: 3px;
background: var(--pi-color-accent);
margin-bottom: var(--pi-spacing);
}
.page-cover.no-cover-image .cover-no-img-desc {
font-size: var(--pi-font-size-subtitle);
color: var(--pi-color-text-light);
line-height: 1.8;
margin-bottom: var(--pi-spacing);
padding: 0 30px;
word-break: break-word;
}
.page-cover.no-cover-image .cover-no-img-meta {
display: flex;
gap: 15px;
font-size: var(--pi-font-size-small);
color: var(--pi-color-text-lighter);
flex-wrap: wrap;
justify-content: center;
}
/* --- 内容页图片强制约束 --- */
.page-content img {
max-width: 100% !important;
height: auto !important;
display: block;
margin: 10px 0;
}
/* --- 内容页 --- */
.page-body {
display: flex;
@@ -170,6 +244,28 @@
margin-bottom: var(--pi-spacing-sm);
}
.page-content h4 {
font-size: var(--pi-font-size-base);
font-weight: bold;
margin-top: var(--pi-spacing-sm);
margin-bottom: 5px;
}
.page-content h5 {
font-size: 13px;
font-weight: bold;
margin-top: var(--pi-spacing-sm);
margin-bottom: 5px;
}
.page-content h6 {
font-size: 12px;
font-weight: bold;
margin-top: var(--pi-spacing-sm);
margin-bottom: 5px;
color: var(--pi-color-text-light);
}
.page-content blockquote {
border-left: 3px solid var(--pi-color-accent);
padding-left: var(--pi-spacing-sm);

View File

@@ -113,6 +113,19 @@ var PhoneImageEngine = (function () {
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// 清除空段落
html = html.replace(/<p[^>]*>\s*<\/p>/gi, '');
// 处理图片: 清除内联样式, 强制 max-width:100%
html = html.replace(/<img([^>]*?)>/gi, function (match, attrs) {
// 移除 style, width, height 内联属性
attrs = attrs.replace(/\s*style\s*=\s*"[^"]*"/gi, '');
attrs = attrs.replace(/\s*style\s*=\s*'[^']*'/gi, '');
attrs = attrs.replace(/\s*width\s*=\s*"[^"]*"/gi, '');
attrs = attrs.replace(/\s*width\s*=\s*'[^']*'/gi, '');
attrs = attrs.replace(/\s*height\s*=\s*"[^"]*"/gi, '');
attrs = attrs.replace(/\s*height\s*=\s*'[^']*'/gi, '');
return '<img' + attrs + ' style="max-width:100%;height:auto;display:block;margin:10px 0;">';
});
return html;
}
@@ -125,6 +138,29 @@ var PhoneImageEngine = (function () {
if (!html) return blocks;
var $temp = $('<div>').html(html);
// 展平嵌套的包装元素 (div, section, article, main, header, footer)
// 将其内部内容提取为直接子元素
var wrapperTags = ['div', 'section', 'article', 'main', 'header', 'footer', 'span'];
var changed = true;
var maxIterations = 10;
while (changed && maxIterations > 0) {
changed = false;
maxIterations--;
$temp.children().each(function () {
var $el = $(this);
var tagName = $el.prop('tagName').toLowerCase();
if ($.inArray(tagName, wrapperTags) !== -1) {
// 将此包装元素的子内容替换自身
var $children = $el.children();
if ($children.length > 0) {
$el.replaceWith($children);
changed = true;
}
}
});
}
var children = $temp.children();
if (children.length === 0) {
@@ -161,6 +197,7 @@ var PhoneImageEngine = (function () {
case 'h4':
case 'h5':
case 'h6':
block.type = tagName;
block.estimatedHeight = estimateHeadingHeight(tagName, $el.text());
break;
case 'img':
@@ -380,34 +417,73 @@ var PhoneImageEngine = (function () {
// ===== 页面HTML生成 =====
/**
* 检查是否有有效的封面图(排除默认头像占位图)
*/
function hasValidPoster() {
if (!postData.poster) return false;
// 排除默认头像占位图
var defaultPatterns = ['/static/images/avatar.png', '/static/images/avatar.jpeg', '/static/images/avatar.jpg'];
for (var i = 0; i < defaultPatterns.length; i++) {
if (postData.poster.indexOf(defaultPatterns[i]) !== -1) return false;
}
return true;
}
/**
* 生成封面页HTML
*/
function generateCoverPage(sizeConfig) {
var html = '<div class="phone-image-page page-cover" style="width:' +
sizeConfig.width + 'px;height:' + sizeConfig.height + 'px;">';
var hasCover = hasValidPoster();
var html = '<div class="phone-image-page page-cover';
if (hasCover) {
html += ' has-cover-image';
} else {
html += ' no-cover-image';
}
html += '" style="width:' + sizeConfig.width + 'px;height:' + sizeConfig.height + 'px;">';
if (postData.poster) {
if (hasCover) {
html += '<img class="cover-image" src="' + postData.poster + '" alt="">';
html += '<div class="cover-title">' + escapeHtml(postData.title) + '</div>';
if (postData.desc) {
html += '<div class="cover-subtitle">' + escapeHtml(postData.desc) + '</div>';
}
html += '<div class="cover-meta">';
if (postData.category_name) {
html += escapeHtml(postData.category_name) + ' | ';
}
if (postData.author_name) {
html += escapeHtml(postData.author_name) + ' | ';
}
if (postData.create_time) {
html += postData.create_time;
}
html += '</div>';
} else {
// 无封面图: 大标题+摘要+装饰线条排版
html += '<div class="cover-decor-line cover-decor-top"></div>';
html += '<div class="cover-no-img-content">';
html += '<div class="cover-no-img-title">' + escapeHtml(postData.title) + '</div>';
html += '<div class="cover-decor-divider"></div>';
if (postData.desc) {
html += '<div class="cover-no-img-desc">' + escapeHtml(postData.desc) + '</div>';
}
html += '<div class="cover-no-img-meta">';
if (postData.category_name) {
html += '<span>' + escapeHtml(postData.category_name) + '</span>';
}
if (postData.author_name) {
html += '<span>' + escapeHtml(postData.author_name) + '</span>';
}
if (postData.create_time) {
html += '<span>' + postData.create_time + '</span>';
}
html += '</div>';
html += '</div>';
html += '<div class="cover-decor-line cover-decor-bottom"></div>';
}
html += '<div class="cover-title">' + escapeHtml(postData.title) + '</div>';
if (postData.desc) {
html += '<div class="cover-subtitle">' + escapeHtml(postData.desc) + '</div>';
}
html += '<div class="cover-meta">';
if (postData.category_name) {
html += escapeHtml(postData.category_name) + ' | ';
}
if (postData.author_name) {
html += escapeHtml(postData.author_name) + ' | ';
}
if (postData.create_time) {
html += postData.create_time;
}
html += '</div>';
html += '</div>';
return { type: 'cover', html: html };
@@ -470,6 +546,9 @@ var PhoneImageEngine = (function () {
// ===== DOM渲染 =====
// 当前预览页码 (0-based)
var currentPreviewPage = 0;
/**
* 渲染分页结果到DOM
*/
@@ -493,6 +572,55 @@ var PhoneImageEngine = (function () {
for (var i = 0; i < pages.length; i++) {
$container.append(pages[i].html);
}
// 确保当前页码有效
if (currentPreviewPage >= pages.length) {
currentPreviewPage = Math.max(0, pages.length - 1);
}
// 显示当前页, 隐藏其他页
showPreviewPage(currentPreviewPage);
}
/**
* 显示指定预览页
* @param {number} index 页码(0-based)
*/
function showPreviewPage(index) {
if (!$container || !$container.length) return;
var $allPages = $container.find('.phone-image-page');
$allPages.hide();
if (index >= 0 && index < $allPages.length) {
$allPages.eq(index).show();
currentPreviewPage = index;
}
// 更新页码显示
$container.trigger('pageChange', [currentPreviewPage, pages.length]);
}
/**
* 下一页
*/
function nextPage() {
if (currentPreviewPage < pages.length - 1) {
showPreviewPage(currentPreviewPage + 1);
}
}
/**
* 上一页
*/
function prevPage() {
if (currentPreviewPage > 0) {
showPreviewPage(currentPreviewPage - 1);
}
}
/**
* 获取当前预览页码
*/
function getCurrentPageIndex() {
return currentPreviewPage;
}
// ===== 图片生成 =====
@@ -512,24 +640,31 @@ var PhoneImageEngine = (function () {
return deferred.promise();
}
// 截图时需要所有页面都显示
$pages.show();
var index = 0;
var total = $pages.length;
function captureNext() {
if (index >= total) {
if (onProgress) onProgress(total, total, null);
// 恢复单页显示
showPreviewPage(currentPreviewPage);
deferred.resolve(canvases);
return;
}
var $page = $($pages[index]);
// 隐藏其他页, 只显示当前要截图的页
$pages.hide();
$pages.eq(index).show();
html2canvas($page[0], {
html2canvas($pages.eq(index)[0], {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
width: $page.outerWidth(),
height: $page.outerHeight(),
width: $pages.eq(index).outerWidth(),
height: $pages.eq(index).outerHeight(),
logging: false
}).then(function (canvas) {
canvases.push(canvas);
@@ -537,6 +672,8 @@ var PhoneImageEngine = (function () {
if (onProgress) onProgress(index, total, canvas);
captureNext();
}).catch(function (err) {
// 恢复单页显示
showPreviewPage(currentPreviewPage);
deferred.reject('截图失败(第' + (index + 1) + '页): ' + err);
});
}
@@ -731,6 +868,10 @@ var PhoneImageEngine = (function () {
getPages: getPages,
applyAiRecommendation: applyAiRecommendation,
applyAiContent: applyAiContent,
restoreOriginalContent: restoreOriginalContent
restoreOriginalContent: restoreOriginalContent,
nextPage: nextPage,
prevPage: prevPage,
showPreviewPage: showPreviewPage,
getCurrentPageIndex: getCurrentPageIndex
};
})();

View File

@@ -305,9 +305,27 @@
fontSize: fontSize
});
var pages = PhoneImageEngine.render();
updatePageInfo();
layer.msg('排版完成,共 ' + pages.length + ' 页');
}
function updatePageInfo() {
var current = PhoneImageEngine.getCurrentPageIndex() + 1;
var total = PhoneImageEngine.getPages().length;
$('#page-info').text('第 ' + current + ' 页 / 共 ' + total + ' 页');
}
// 翻页
$('#prev-page').click(function () {
PhoneImageEngine.prevPage();
updatePageInfo();
});
$('#next-page').click(function () {
PhoneImageEngine.nextPage();
updatePageInfo();
});
// 生成并保存
$('#btn-generate').click(function () {
var btn = $(this);

View File

@@ -283,7 +283,7 @@
var channels = [];
// 读取页面内嵌的已配置渠道数据(通过模板变量)
{:php}
{php}
$allConfig = get_system_config();
$defaultProvider = get_system_config('ai_default_provider', '');
$configured = [];
@@ -494,7 +494,7 @@
// 编辑渠道
window.editChannel = function (providerId) {
{:php}
{php}
echo 'var providerConfigs = {};';
if (is_array($allConfig)) {
foreach ($configured as $ch) {