码桶
发现社区成员的开源项目
functions.php75.3 KB
<?php
if (!defined('__TYPECHO_ROOT_DIR__')) exit;
define('LB_THEME_VERSION', '1.2.11');
function themeConfig(\Typecho\Widget\Helper\Form $form): void
{
$cssUrl = lb_theme_url('admin/option.css') . '?v=' . LB_THEME_VERSION;
$jsUrl = lb_theme_url('admin/option.js') . '?v=' . LB_THEME_VERSION;
$ver = LB_THEME_VERSION;
$lbSiteUrl = addslashes(\Typecho\Widget::widget('\Widget\Options')->siteUrl);
echo <<<HTML
<link rel="stylesheet" href="{$cssUrl}">
<script src="{$jsUrl}" defer></script>
<div class="lb-wrap">
<div class="lb-topbar">
<div class="lb-brand">
<span class="lb-logo"></span>
<div class="lb-brand-text">
<h2>LB Down 下载站主题</h2>
<p>专业下载站主题 · 作者:小野 · <a href="https://lb5.net" target="_blank" rel="noopener">lb5.net</a> · PHP 8.0+</p>
</div>
</div>
<div class="lb-topbar-actions">
<span class="lb-version">v{$ver}</span>
</div>
</div>
<div class="lb-tabs">
<button type="button" class="lb-tab active" data-tab="basic">
<span class="lb-tab-icon" data-icon="basic"></span>
<span>基础设置</span>
</button>
<button type="button" class="lb-tab" data-tab="display">
<span class="lb-tab-icon" data-icon="display"></span>
<span>显示设置</span>
</button>
<button type="button" class="lb-tab" data-tab="beian">
<span class="lb-tab-icon" data-icon="beian"></span>
<span>备案信息</span>
</button>
<button type="button" class="lb-tab" data-tab="function">
<span class="lb-tab-icon" data-icon="function"></span>
<span>功能开关</span>
</button>
<button type="button" class="lb-tab" data-tab="mail">
<span class="lb-tab-icon" data-icon="mail"></span>
<span>邮件通知</span>
</button>
<button type="button" class="lb-tab" data-tab="other">
<span class="lb-tab-icon" data-icon="other"></span>
<span>其他设置</span>
</button>
<button type="button" class="lb-tab-save">
<span class="lb-tab-icon" data-icon="save"></span>
<span>保存设置</span>
</button>
</div>
<div class="lb-panels">
<div class="lb-panel active" data-panel="basic"></div>
<div class="lb-panel" data-panel="beian"></div>
<div class="lb-panel" data-panel="display"></div>
<div class="lb-panel" data-panel="function"></div>
<div class="lb-panel" data-panel="mail"></div>
<div class="lb-panel" data-panel="other"></div>
</div>
</div>
<script>window.LB_SITE_URL = '{$lbSiteUrl}';</script>
HTML;
$GLOBALS['LB_FIELD_TABS'] = [
'logoUrl' => 'basic',
'siteSubtitle' => 'basic',
'footerPowered' => 'basic',
'footerAbout' => 'basic',
'icp' => 'beian',
'icpUrl' => 'beian',
'govIc' => 'beian',
'govIcUrl' => 'beian',
'pageSize' => 'display',
'sidebarNum' => 'display',
'commentInfoEnable' => 'display',
'commentInfoOptions' => 'display',
'recEnable' => 'display',
'recNum' => 'display',
'recExcludeCats' => 'display',
'filterEnable' => 'display',
'filterCats' => 'display',
'listStyle' => 'display',
'newestCats' => 'display',
'enableDownloadCount' => 'function',
'enableRating' => 'function',
'analyticsCode' => 'other',
'floatBarEnable' => 'other',
'floatQQ' => 'other',
'floatQQImg' => 'other',
'floatWxImg' => 'other',
'floatTodayUrl' => 'other',
'floatVipUrl' => 'other',
'floatAskUrl' => 'other',
'floatReportUrl' => 'other',
'lb_mail_switch' => 'mail',
'lb_mail_host' => 'mail',
'lb_mail_port' => 'mail',
'lb_mail_user' => 'mail',
'lb_mail_pass' => 'mail',
'lb_mail_from_name' => 'mail',
'lb_mail_admin' => 'mail',
'lb_notify_feishu' => 'mail',
'lb_feishu_webhook' => 'mail',
'lb_notify_wecom' => 'mail',
'lb_wecom_webhook' => 'mail',
'lb_notify_dingtalk' => 'mail',
'lb_dingtalk_webhook' => 'mail',
'lb_notify_pushplus' => 'mail',
'lb_pushplus_token' => 'mail',
];
$logoUrl = new \Typecho\Widget\Helper\Form\Element\Text(
'logoUrl',
null,
null,
_t('站点 Logo'),
_t('填写图片 URL,留空则显示文字标题')
);
$form->addInput($logoUrl);
$themeMode = new \Typecho\Widget\Helper\Form\Element\Select(
'themeMode',
[
'light' => _t('浅色(默认)'),
'dark' => _t('深色'),
'auto' => _t('跟随系统')
],
'light',
_t('默认主题模式'),
_t('首次访问时的主题;访客可用右上角按钮随时切换(记忆在本地)')
);
$form->addInput($themeMode);
$siteSubtitle = new \Typecho\Widget\Helper\Form\Element\Text(
'siteSubtitle',
null,
'专注于优质资源分享',
_t('站点副标题'),
_t('显示在 Logo 下方的描述文字')
);
$form->addInput($siteSubtitle);
$pageSize = new \Typecho\Widget\Helper\Form\Element\Number(
'pageSize',
null,
'20',
_t('列表每页显示数量'),
_t('首页、分类页、搜索页每页显示的资源数量')
);
$form->addInput($pageSize);
$commentInfoEnable = new \Typecho\Widget\Helper\Form\Element\Select(
'commentInfoEnable',
[
'1' => _t('开启'),
'0' => _t('关闭')
],
'1',
_t('评论信息显示'),
_t('控制评论区是否显示访客的 IP 属地、浏览器(UA)、操作系统等信息')
);
$form->addInput($commentInfoEnable);
$commentInfoOptions = new \Typecho\Widget\Helper\Form\Element\Checkbox(
'commentInfoOptions',
[
'ip' => _t('IP 属地'),
'ua' => _t('浏览器(UA)'),
'system' => _t('操作系统')
],
['ip', 'ua', 'system'],
_t('评论信息组合'),
_t('自由选择要显示的信息,可多选,如只选“IP 属地 + 操作系统”。注意:移动端仅显示 IP 属地,浏览器(UA)/操作系统只在电脑端显示')
);
$form->addInput($commentInfoOptions);
$sidebarNum = new \Typecho\Widget\Helper\Form\Element\Number(
'sidebarNum',
null,
'8',
_t('侧边栏显示数量'),
_t('热门下载、最新更新每个区块显示的数量')
);
$form->addInput($sidebarNum);
$recEnable = new \Typecho\Widget\Helper\Form\Element\Select(
'recEnable',
[
'1' => _t('开启'),
'0' => _t('关闭')
],
'1',
_t('首页轮播'),
_t('开启后首页顶部显示推荐轮播区域')
);
$form->addInput($recEnable);
$recNum = new \Typecho\Widget\Helper\Form\Element\Number(
'recNum',
null,
'6',
_t('轮播显示数量'),
_t('首页轮播最多显示的文章数量')
);
$form->addInput($recNum);
$recExcludeCats = new \Typecho\Widget\Helper\Form\Element\Text(
'recExcludeCats',
null,
null,
_t('轮播排除分类'),
_t('填写分类 ID,多个用英文逗号分隔,这些分类下的文章不会出现在轮播中。例如: 1,3,5')
);
$form->addInput($recExcludeCats);
$filterEnable = new \Typecho\Widget\Helper\Form\Element\Select(
'filterEnable',
[
'1' => _t('开启'),
'0' => _t('关闭')
],
'1',
_t('筛选栏'),
_t('开启后首页列表上方显示分类/授权/语言/排序筛选栏')
);
$form->addInput($filterEnable);
$filterCats = new \Typecho\Widget\Helper\Form\Element\Text(
'filterCats',
null,
'',
_t('分类页筛选栏展示'),
_t('填写分类 ID,多个用英文逗号分隔,例如:1,2,3,4。留空则所有分类页都显示筛选栏,不在列表中的分类页不显示筛选栏')
);
$form->addInput($filterCats);
$listStyle = new \Typecho\Widget\Helper\Form\Element\Select(
'listStyle',
[
'list' => _t('列表模式'),
'card' => _t('卡片模式')
],
'list',
_t('列表布局样式'),
_t('列表模式为传统横向列表,卡片模式为网格卡片布局带3D倾斜效果')
);
$form->addInput($listStyle);
$paginationType = new \Typecho\Widget\Helper\Form\Element\Select(
'paginationType',
[
'pager' => _t('传统分页符'),
'scroll' => _t('滚动自动加载')
],
'pager',
_t('列表翻页方式'),
_t('传统分页符:每页独立 URL,SEO 友好;滚动自动加载:滚动到列表底部自动加载下一页,无需翻页')
);
$form->addInput($paginationType);
$floatBarEnable = new \Typecho\Widget\Helper\Form\Element\Select(
'floatBarEnable',
[
'0' => _t('关闭'),
'1' => _t('开启')
],
'0',
_t('悬浮侧栏'),
_t('在页面右侧显示圆形悬浮按钮条(客服/二维码/链接等)')
);
$form->addInput($floatBarEnable);
$floatQQ = new \Typecho\Widget\Helper\Form\Element\Text(
'floatQQ',
null,
null,
_t('QQ 客服号'),
_t('填写 QQ 号,点击按钮会打开 QQ 临时会话。留空则不显示 QQ 按钮')
);
$form->addInput($floatQQ);
$floatQQImg = new \Typecho\Widget\Helper\Form\Element\Text(
'floatQQImg',
null,
null,
_t('QQ 二维码图片 URL'),
_t('填写二维码图片地址,鼠标悬停 QQ 按钮时展开显示。留空则不显示二维码')
);
$form->addInput($floatQQImg);
$floatWxImg = new \Typecho\Widget\Helper\Form\Element\Text(
'floatWxImg',
null,
null,
_t('微信二维码图片 URL'),
_t('填写微信二维码图片地址,将显示微信客服按钮,悬停展开二维码')
);
$form->addInput($floatWxImg);
$floatTodayUrl = new \Typecho\Widget\Helper\Form\Element\Text(
'floatTodayUrl',
null,
null,
_t('悬浮按钮:今日更新链接'),
_t('填写链接地址(如 /newest/ ),留空不显示该按钮')
);
$form->addInput($floatTodayUrl);
$floatVipUrl = new \Typecho\Widget\Helper\Form\Element\Text(
'floatVipUrl',
null,
null,
_t('悬浮按钮:VIP 链接'),
_t('填写链接地址(如 /vip.html ),留空不显示该按钮')
);
$form->addInput($floatVipUrl);
$floatAskUrl = new \Typecho\Widget\Helper\Form\Element\Text(
'floatAskUrl',
null,
null,
_t('悬浮按钮:求软件链接'),
_t('填写链接地址,留空不显示该按钮')
);
$form->addInput($floatAskUrl);
$floatReportUrl = new \Typecho\Widget\Helper\Form\Element\Text(
'floatReportUrl',
null,
null,
_t('悬浮按钮:内容举报链接'),
_t('填写链接地址,留空不显示该按钮')
);
$form->addInput($floatReportUrl);
$lbMailSwitch = new \Typecho\Widget\Helper\Form\Element\Select(
'lb_mail_switch',
[
'0' => _t('关闭'),
'1' => _t('开启')
],
'0',
_t('邮件通知'),
_t('开启后,“求软件 / 内容举报”提交的内容通过 SMTP 邮件发送到管理员邮箱')
);
$form->addInput($lbMailSwitch);
$lbMailHost = new \Typecho\Widget\Helper\Form\Element\Text(
'lb_mail_host',
null,
null,
_t('SMTP 服务器'),
_t('如:smtp.qq.com / smtp.163.com')
);
$form->addInput($lbMailHost);
$lbMailPort = new \Typecho\Widget\Helper\Form\Element\Number(
'lb_mail_port',
null,
'465',
_t('SMTP 端口'),
_t('465(SSL) / 587(TLS)')
);
$form->addInput($lbMailPort);
$lbMailUser = new \Typecho\Widget\Helper\Form\Element\Text(
'lb_mail_user',
null,
null,
_t('SMTP 账号'),
_t('发信邮箱地址')
);
$form->addInput($lbMailUser);
$lbMailPass = new \Typecho\Widget\Helper\Form\Element\Password(
'lb_mail_pass',
null,
null,
_t('SMTP 密码 / 授权码'),
_t('QQ/163 等邮箱需使用授权码')
);
$form->addInput($lbMailPass);
$lbMailFromName = new \Typecho\Widget\Helper\Form\Element\Text(
'lb_mail_from_name',
null,
null,
_t('发件人名称'),
_t('收件人看到的发件人名字,留空用站点名')
);
$form->addInput($lbMailFromName);
$lbMailAdmin = new \Typecho\Widget\Helper\Form\Element\Text(
'lb_mail_admin',
null,
null,
_t('管理员接收邮箱'),
_t('求软件 / 内容举报的邮件发送到这个邮箱')
);
$form->addInput($lbMailAdmin);
$lbNotifyFeishu = new \Typecho\Widget\Helper\Form\Element\Select(
'lb_notify_feishu',
[
'0' => _t('关闭'),
'1' => _t('开启')
],
'0',
_t('飞书通知'),
_t('开启后,通过飞书群机器人推送通知')
);
$form->addInput($lbNotifyFeishu);
$lbFeishuWebhook = new \Typecho\Widget\Helper\Form\Element\Text(
'lb_feishu_webhook',
null,
null,
_t('飞书群机器人 Webhook'),
_t('在飞书群中添加“自定义机器人”后获得的 Webhook 地址,形如 https://open.feishu.cn/open-apis/bot/v2/hook/xxxx')
);
$form->addInput($lbFeishuWebhook);
$lbNotifyWecom = new \Typecho\Widget\Helper\Form\Element\Select(
'lb_notify_wecom',
[
'0' => _t('关闭'),
'1' => _t('开启')
],
'0',
_t('企业微信通知'),
_t('开启后,通过企业微信群机器人推送通知')
);
$form->addInput($lbNotifyWecom);
$lbWecomWebhook = new \Typecho\Widget\Helper\Form\Element\Text(
'lb_wecom_webhook',
null,
null,
_t('企业微信机器人 Webhook'),
_t('在企业微信群中添加机器人后获得的 Webhook 地址,形如 https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxx')
);
$form->addInput($lbWecomWebhook);
$lbNotifyDingtalk = new \Typecho\Widget\Helper\Form\Element\Select(
'lb_notify_dingtalk',
[
'0' => _t('关闭'),
'1' => _t('开启')
],
'0',
_t('钉钉通知'),
_t('开启后,通过钉钉群机器人推送通知')
);
$form->addInput($lbNotifyDingtalk);
$lbDingtalkWebhook = new \Typecho\Widget\Helper\Form\Element\Text(
'lb_dingtalk_webhook',
null,
null,
_t('钉钉群机器人 Webhook'),
_t('在钉钉群中添加自定义机器人后获得的 Webhook 地址,形如 https://oapi.dingtalk.com/robot/send?access_token=xxxx')
);
$form->addInput($lbDingtalkWebhook);
$lbNotifyPushplus = new \Typecho\Widget\Helper\Form\Element\Select(
'lb_notify_pushplus',
[
'0' => _t('关闭'),
'1' => _t('开启')
],
'0',
_t('PushPlus 通知'),
_t('开启后,通过 PushPlus 推送到微信(免费版每天 200 条)')
);
$form->addInput($lbNotifyPushplus);
$lbPushplusToken = new \Typecho\Widget\Helper\Form\Element\Text(
'lb_pushplus_token',
null,
null,
_t('PushPlus Token'),
_t('在 pushplus.plus 网站登录后获取的 token')
);
$form->addInput($lbPushplusToken);
$newestCats = new \Typecho\Widget\Helper\Form\Element\Text(
'newestCats',
null,
'',
_t('最新更新分类展示'),
_t('填写分类 ID,多个用英文逗号分隔,例如:1,2,3,4。留空则不显示分类标签栏')
);
$form->addInput($newestCats);
$rankCats = new \Typecho\Widget\Helper\Form\Element\Text(
'downloadRankCats',
null,
'',
_t('下载榜单分类'),
_t('填写分类 ID,多个用英文逗号分隔,例如:1,2。留空则不显示下载榜单区块')
);
$form->addInput($rankCats);
$newestCols = new \Typecho\Widget\Helper\Form\Element\Text(
'newestCols',
null,
'4',
_t('最新更新每排卡片数'),
_t('每排显示几张卡片,默认 4')
);
$form->addInput($newestCols);
$newestRows = new \Typecho\Widget\Helper\Form\Element\Text(
'newestRows',
null,
'3',
_t('最新更新展示排数'),
_t('展示几排卡片,默认 3(共 4×3=12 张),超出部分通过“更多”按钮查看')
);
$form->addInput($newestRows);
$articleCatIds = new \Typecho\Widget\Helper\Form\Element\Text(
'articleCatIds',
null,
'',
_t('文章卡片分类'),
_t('填写使用文章卡片(大配图 + 标题 + 日期)的分类 ID,多个用英文逗号分隔,例如:5,6。留空则全部使用软件卡片')
);
$form->addInput($articleCatIds);
$enableDownloadCount = new \Typecho\Widget\Helper\Form\Element\Select(
'enableDownloadCount',
[
'1' => _t('开启'),
'0' => _t('关闭')
],
'1',
_t('下载统计功能'),
_t('开启后会记录每个资源的下载次数')
);
$form->addInput($enableDownloadCount);
$enableRating = new \Typecho\Widget\Helper\Form\Element\Select(
'enableRating',
[
'1' => _t('开启'),
'0' => _t('关闭')
],
'1',
_t('资源评分功能'),
_t('开启后用户可以对资源进行 1-5 星评分')
);
$form->addInput($enableRating);
$footerPowered = new \Typecho\Widget\Helper\Form\Element\Textarea(
'footerPowered',
null,
_t('Powered by <a href="https://typecho.org" target="_blank" rel="nofollow noopener">Typecho</a> | Theme <strong>LB Down</strong>'),
_t('页脚版权行'),
_t('页脚最底部“Powered by … | Theme …”一行,支持 HTML,留空则显示默认内容')
);
$form->addInput($footerPowered);
$footerAbout = new \Typecho\Widget\Helper\Form\Element\Textarea(
'footerAbout',
null,
null,
_t('关于本站内容'),
_t('显示在页脚“关于本站”区域,支持 HTML 代码。留空则显示站点描述')
);
$form->addInput($footerAbout);
$icp = new \Typecho\Widget\Helper\Form\Element\Text(
'icp',
null,
null,
_t('ICP 备案号'),
_t('填写后会显示在页脚')
);
$form->addInput($icp);
$icpUrl = new \Typecho\Widget\Helper\Form\Element\Text(
'icpUrl',
null,
'https://beian.miit.gov.cn/',
_t('ICP 备案号跳转地址'),
_t('点击 ICP 备案号跳转的链接,留空默认工信部备案查询官网')
);
$form->addInput($icpUrl);
$govIc = new \Typecho\Widget\Helper\Form\Element\Text(
'govIc',
null,
null,
_t('公安备案号'),
_t('填写后会显示在页脚(如:粤公网安备xxxxxxxxxxxx号)')
);
$form->addInput($govIc);
$govIcUrl = new \Typecho\Widget\Helper\Form\Element\Text(
'govIcUrl',
null,
'http://www.beian.gov.cn/portal/registerSystemInfo?recordcode=',
_t('公安备案号跳转地址'),
_t('点击公安备案号跳转的链接,留空默认公安部备案查询(recordcode= 结尾时自动拼接备案号)')
);
$form->addInput($govIcUrl);
$analyticsCode = new \Typecho\Widget\Helper\Form\Element\Textarea(
'analyticsCode',
null,
null,
_t('第三方统计代码'),
_t('百度统计、Google Analytics 等,会输出在页脚')
);
$form->addInput($analyticsCode);
}
function themeFields(object $form): void
{
$thumb = new \Typecho\Widget\Helper\Form\Element\Text(
'thumb',
null,
null,
_t('资源缩略图'),
_t('资源封面图 URL,列表页和详情页展示')
);
$form->addItem($thumb);
$version = new \Typecho\Widget\Helper\Form\Element\Text(
'version',
null,
null,
_t('版本号'),
_t('如: v1.0.0、v2026.15 中文版')
);
$form->addItem($version);
$fileSize = new \Typecho\Widget\Helper\Form\Element\Text(
'fileSize',
null,
null,
_t('文件大小'),
_t('如: 65.67 MB、1.2 GB')
);
$form->addItem($fileSize);
$systemReq = new \Typecho\Widget\Helper\Form\Element\Text(
'systemReq',
null,
null,
_t('系统要求'),
_t('如: macOS 10.15+ / Windows 10+')
);
$form->addItem($systemReq);
$language = new \Typecho\Widget\Helper\Form\Element\Select(
'language',
[
'' => _t('未设置'),
'zh-CN' => _t('简体中文'),
'zh-TW' => _t('繁体中文'),
'en' => _t('英文'),
'multi' => _t('多语言')
],
'',
_t('资源语言'),
_t('选择资源的语言类型')
);
$form->addItem($language);
$license = new \Typecho\Widget\Helper\Form\Element\Select(
'license',
[
'' => _t('未设置'),
'free' => _t('免费'),
'vip' => _t('VIP免费'),
'paid' => _t('付费'),
'trial' => _t('试用'),
'open' => _t('开源')
],
'',
_t('授权类型'),
_t('选择资源的授权方式')
);
$form->addItem($license);
$unzipPassword = new \Typecho\Widget\Helper\Form\Element\Text(
'unzipPassword',
null,
null,
_t('解压密码'),
_t('如有解压密码请填写,详情页会显示复制按钮')
);
$form->addItem($unzipPassword);
$downloadLinks = new \Typecho\Widget\Helper\Form\Element\Textarea(
'downloadLinks',
null,
null,
_t('下载链接'),
_t('每行一个链接,格式: 线路名称|链接地址|下载方式(可选: local/cloud)。例如: 百度网盘|https://pan.baidu.com/xxx|cloud')
);
$form->addItem($downloadLinks);
$changelog = new \Typecho\Widget\Helper\Form\Element\Textarea(
'changelog',
null,
null,
_t('更新日志'),
_t('每行一条记录,格式: 版本号|日期|更新内容。例如: v1.2.0|2026-07-15|修复了已知问题')
);
$form->addItem($changelog);
$downloadCount = new \Typecho\Widget\Helper\Form\Element\Number(
'downloadCount',
null,
'0',
_t('下载次数'),
_t('由程序自动维护,可不填')
);
$form->addItem($downloadCount);
$recommend = new \Typecho\Widget\Helper\Form\Element\Select(
'recommend',
[
'0' => _t('不推荐'),
'1' => _t('推荐到轮播')
],
'0',
_t('首页轮播推荐'),
_t('设为推荐后,该文章将出现在首页顶部轮播中')
);
$form->addItem($recommend);
$noticeTitle = new \Typecho\Widget\Helper\Form\Element\Text(
'notice_title',
null,
null,
_t('温馨提示标题'),
_t('选填,默认“特别提醒”。留空则不显示标题')
);
$form->addItem($noticeTitle);
$noticeContent = new \Typecho\Widget\Helper\Form\Element\Textarea(
'notice_content',
null,
null,
_t('温馨提示内容'),
_t('填写后详情页会弹出提示(如“不支持 macOS 13.0,新系统用户可以使用 MacDroid”),支持 HTML。留空则不弹窗')
);
$form->addItem($noticeContent);
}
function lb_parse_download_links(?string $rawLinks): array
{
$links = [];
if (empty($rawLinks)) return $links;
$lines = explode("\n", trim($rawLinks));
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
$parts = explode('|', $line);
$name = trim($parts[0] ?? '下载');
$url = trim($parts[1] ?? '');
$type = trim($parts[2] ?? 'cloud');
if (!empty($url)) {
$links[] = [
'name' => $name,
'url' => $url,
'type' => $type
];
}
}
return $links;
}
function lb_parse_changelog(?string $rawLog): array
{
$log = [];
if (empty($rawLog)) return $log;
$lines = explode("\n", trim($rawLog));
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
$parts = explode('|', $line);
if (count($parts) >= 3) {
$log[] = [
'version' => trim($parts[0]),
'date' => trim($parts[1]),
'content' => trim($parts[2])
];
}
}
return $log;
}
function lb_get_thumb(object $archive): string
{
$thumb = $archive->fields->thumb ?? '';
if (!empty($thumb)) return $thumb;
$content = $archive->content;
if (preg_match('/<img[^>]+src=["\']([^"\']+)["\']/i', $content, $matches)) {
return $matches[1];
}
return lb_theme_url('assets/img/placeholder.svg');
}
function lb_theme_url(string $path = ''): string
{
static $themeUrl = null;
if ($themeUrl === null) {
$options = \Typecho\Widget::widget('\Widget\Options');
$themeUrl = $options->themeUrl;
}
return rtrim($themeUrl, '/') . '/' . ltrim($path, '/');
}
function lb_format_size(string $sizeStr): string
{
return htmlspecialchars($sizeStr);
}
function lb_get_license_label(?string $license): array
{
$map = [
'free' => ['text' => '免费', 'class' => 'tag-free'],
'vip' => ['text' => 'VIP免费', 'class' => 'tag-vip'],
'paid' => ['text' => '付费', 'class' => 'tag-paid'],
'trial' => ['text' => '试用', 'class' => 'tag-trial'],
'open' => ['text' => '开源', 'class' => 'tag-open'],
];
return $map[$license] ?? [];
}
function lb_get_language_label(?string $lang): string
{
$map = [
'zh-CN' => '简体中文',
'zh-TW' => '繁体中文',
'en' => '英文',
'multi' => '多语言',
];
return $map[$lang] ?? '';
}
function lb_get_recommend_posts(?int $limit = null): array
{
$options = \Typecho\Widget::widget('\Widget\Options');
if ($limit === null) {
$limit = intval($options->recNum ?? 6);
}
if ($limit <= 0) $limit = 6;
$excludeCats = [];
$excludeRaw = trim($options->recExcludeCats ?? '');
if (!empty($excludeRaw)) {
$excludeCats = array_filter(array_map('intval', explode(',', $excludeRaw)));
}
$db = \Typecho\Db::get();
$prefix = $db->getPrefix();
$adapter = $db->getAdapterName();
$isMysql = (strpos($adapter, 'Mysql') !== false || strpos($adapter, 'Pdo_Mysql') !== false);
$q = $isMysql ? '`' : '"';
$excludeJoin = '';
$excludeWhere = '';
if ($excludeCats) {
$excludeIds = implode(',', $excludeCats);
$excludeJoin = "LEFT JOIN {$q}{$prefix}relationships{$q} r ON c.cid = r.cid AND r.mid IN ({$excludeIds})";
$excludeWhere = "AND r.mid IS NULL";
}
$sql = "SELECT c.* FROM {$q}{$prefix}contents{$q} c
INNER JOIN {$q}{$prefix}fields{$q} f ON c.cid = f.cid
{$excludeJoin}
WHERE c.status = 'publish'
AND c.type = 'post'
AND f.name = 'recommend'
AND f.str_value = '1'
{$excludeWhere}
ORDER BY c.created DESC
LIMIT {$limit}";
$posts = $db->fetchAll($sql);
if (count($posts) < $limit) {
$more = $limit - count($posts);
$existCids = array_column($posts, 'cid');
$inClause = $existCids ? 'AND c.cid NOT IN (' . implode(',', array_map('intval', $existCids)) . ')' : '';
$sql2 = "SELECT c.* FROM {$q}{$prefix}contents{$q} c
{$excludeJoin}
WHERE c.status = 'publish'
AND c.type = 'post'
{$inClause}
{$excludeWhere}
ORDER BY c.created DESC
LIMIT {$more}";
$posts = array_merge($posts, $db->fetchAll($sql2));
}
if ($posts) {
$cids = array_column($posts, 'cid');
$inCids = implode(',', array_map('intval', $cids));
$fieldSql = "SELECT cid, name, str_value, int_value, float_value FROM {$q}{$prefix}fields{$q} WHERE cid IN ({$inCids})";
$fieldRows = $db->fetchAll($fieldSql);
$fieldsMap = [];
foreach ($fieldRows as $fr) {
$cid = $fr['cid'];
$name = $fr['name'];
$val = $fr['str_value'] ?? (string)($fr['int_value'] ?? $fr['float_value'] ?? '');
if (!isset($fieldsMap[$cid])) $fieldsMap[$cid] = [];
$fieldsMap[$cid][$name] = $val;
}
foreach ($posts as &$p) {
$cid = $p['cid'];
$p['fields'] = (object)($fieldsMap[$cid] ?? []);
}
unset($p);
}
return $posts;
}
function lb_get_rank_posts(array $catIds, int $limit = 14): array
{
if (!$catIds) return [];
$limit = max(1, min(50, $limit));
$db = \Typecho\Db::get();
$prefix = $db->getPrefix();
$adapter = $db->getAdapterName();
$isMysql = (strpos($adapter, 'Mysql') !== false || strpos($adapter, 'Pdo_Mysql') !== false);
$q = $isMysql ? '`' : '"';
$catIn = implode(',', array_map('intval', $catIds));
$sql = "SELECT c.* FROM {$q}{$prefix}contents{$q} c
INNER JOIN {$q}{$prefix}relationships{$q} r ON c.cid = r.cid
INNER JOIN {$q}{$prefix}metas{$q} m ON r.mid = m.mid
LEFT JOIN {$q}{$prefix}fields{$q} fdc ON c.cid = fdc.cid AND fdc.name = 'downloadCount'
WHERE c.status = 'publish' AND c.type = 'post'
AND m.type = 'category' AND m.mid IN ({$catIn})
GROUP BY c.cid
ORDER BY CAST(COALESCE(fdc.str_value, '0') AS UNSIGNED) DESC, c.created DESC
LIMIT {$limit}";
$posts = $db->fetchAll($sql);
if ($posts) {
$cids = array_column($posts, 'cid');
$inCids = implode(',', array_map('intval', $cids));
$fieldSql = "SELECT cid, name, str_value, int_value, float_value FROM {$q}{$prefix}fields{$q} WHERE cid IN ({$inCids})";
$fieldRows = $db->fetchAll($fieldSql);
$fieldsMap = [];
foreach ($fieldRows as $fr) {
$cid = $fr['cid'];
$name = $fr['name'];
$val = $fr['str_value'] ?? (string)($fr['int_value'] ?? $fr['float_value'] ?? '');
if (!isset($fieldsMap[$cid])) $fieldsMap[$cid] = [];
$fieldsMap[$cid][$name] = $val;
}
foreach ($posts as &$p) {
$p['fields'] = (object)($fieldsMap[$p['cid']] ?? []);
}
unset($p);
}
return $posts;
}
function lb_download_counter(): void
{
if ('POST' === ($_SERVER['REQUEST_METHOD'] ?? '')) {
$cid = intval($_POST['cid'] ?? 0);
if ($cid > 0) {
$db = \Typecho\Db::get();
$current = $db->fetchObject($db->select('str_value')
->from('table.fields')
->where('cid = ?', $cid)
->where('name = ?', 'downloadCount'));
$count = 1;
if ($current) {
$count = intval($current->str_value) + 1;
$db->query($db->update('table.fields')
->rows(['str_value' => (string)$count])
->where('cid = ?', $cid)
->where('name = ?', 'downloadCount'));
} else {
$db->query($db->insert('table.fields')
->rows([
'cid' => $cid,
'name' => 'downloadCount',
'type' => 'str',
'str_value' => '1',
'int_value' => 0,
'float_value' => 0
]));
}
header('Content-Type: application/json');
echo json_encode(['code' => 0, 'count' => $count]);
exit;
}
}
header('Content-Type: application/json');
echo json_encode(['code' => 1]);
exit;
}
function lb_rating_handler(): void
{
if ('POST' === ($_SERVER['REQUEST_METHOD'] ?? '')) {
$cid = intval($_POST['cid'] ?? 0);
$rating = intval($_POST['rating'] ?? 0);
if ($cid > 0 && $rating >= 1 && $rating <= 5) {
$db = \Typecho\Db::get();
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$existing = $db->fetchObject($db->select()
->from('table.lb_ratings')
->where('cid = ?', $cid)
->where('ip = ?', $ip)
->where('created > ?', time() - 86400));
if ($existing) {
header('Content-Type: application/json');
echo json_encode(['code' => 2, 'msg' => '您今天已经评分过了']);
exit;
}
$db->query($db->insert('table.lb_ratings')
->rows([
'cid' => $cid,
'rating' => $rating,
'ip' => $ip,
'created' => time()
]));
$result = $db->fetchObject($db->select(
['AVG(rating)' => 'avg'],
['COUNT(*)' => 'cnt']
)->from('table.lb_ratings')->where('cid = ?', $cid));
$avg = round(floatval($result->avg), 1);
$cnt = intval($result->cnt);
header('Content-Type: application/json');
echo json_encode([
'code' => 0,
'avg' => $avg,
'count' => $cnt
]);
exit;
}
}
header('Content-Type: application/json');
echo json_encode(['code' => 1]);
exit;
}
function lb_get_rating(int $cid): array
{
lb_create_ratings_table();
$db = \Typecho\Db::get();
$result = $db->fetchObject($db->select(
['AVG(rating)' => 'avg'],
['COUNT(*)' => 'cnt']
)->from('table.lb_ratings')->where('cid = ?', $cid));
return [
'avg' => $result ? round(floatval($result->avg), 1) : 0,
'count' => $result ? intval($result->cnt) : 0
];
}
function lb_create_ratings_table(): void
{
static $created = false;
if ($created) return;
$created = true;
$db = \Typecho\Db::get();
$prefix = $db->getPrefix();
$adapter = strtolower($db->getAdapterName());
$isMysql = (false !== strpos($adapter, 'mysql'));
try {
$db->query($db->select()->from('table.lb_ratings')->limit(1));
return;
} catch (\Exception $e) {
}
if ($isMysql) {
$sql = "CREATE TABLE IF NOT EXISTS `{$prefix}lb_ratings` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`cid` INT UNSIGNED NOT NULL DEFAULT 0,
`rating` TINYINT UNSIGNED NOT NULL DEFAULT 0,
`ip` VARCHAR(45) NOT NULL DEFAULT '',
`created` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `cid` (`cid`),
KEY `ip` (`ip`),
KEY `created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
} else {
$sql = "CREATE TABLE IF NOT EXISTS [{$prefix}lb_ratings] (
[id] INTEGER PRIMARY KEY AUTOINCREMENT,
[cid] INTEGER NOT NULL DEFAULT 0,
[rating] INTEGER NOT NULL DEFAULT 0,
[ip] TEXT NOT NULL DEFAULT '',
[created] INTEGER NOT NULL DEFAULT 0
);";
$db->query($sql);
try {
$db->query("CREATE INDEX IF NOT EXISTS [{$prefix}lb_ratings_cid] ON [{$prefix}lb_ratings] ([cid])");
$db->query("CREATE INDEX IF NOT EXISTS [{$prefix}lb_ratings_ip] ON [{$prefix}lb_ratings] ([ip])");
$db->query("CREATE INDEX IF NOT EXISTS [{$prefix}lb_ratings_created] ON [{$prefix}lb_ratings] ([created])");
} catch (\Exception $e) {}
return;
}
$db->query($sql);
}
function lb_gradient(int $index): string
{
$gradients = [
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
'linear-gradient(135deg, #30cfd0 0%, #330867 100%)',
'linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)',
'linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%)',
'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
'linear-gradient(135deg, #a1c4fd 0%, #c2e9fb 100%)',
'linear-gradient(135deg, #d299c2 0%, #fef9d7 100%)',
'linear-gradient(135deg, #89f7fe 0%, #66a6ff 100%)',
];
return $gradients[$index % count($gradients)];
}
function lb_resource_card(object $archive, int $index = 0): void
{
$cid = $archive->cid;
$thumb = lb_get_thumb($archive);
$version = $archive->fields->version ?? '';
$fileSize = $archive->fields->fileSize ?? '';
$license = $archive->fields->license ?? '';
$language = $archive->fields->language ?? '';
$downloadCount = $archive->fields->downloadCount ?? '0';
$licenseLabel = lb_get_license_label($license);
$langLabel = lb_get_language_label($language);
$catMid = 0;
$categories = $archive->categories;
if (!empty($categories)) {
$firstCat = is_array($categories) ? reset($categories) : $categories;
if (is_object($firstCat)) {
$catMid = $firstCat->mid;
}
}
if (!$catMid) {
try {
$db = \Typecho\Db::get();
$prefix = $db->getPrefix();
$adapter = $db->getAdapterName();
$isMysql = (strpos($adapter, 'Mysql') !== false || strpos($adapter, 'Pdo_Mysql') !== false);
$q = $isMysql ? '`' : '"';
$row = $db->fetchRow($db->query(
"SELECT m.mid FROM {$q}{$prefix}relationships{$q} r "
. "INNER JOIN {$q}{$prefix}metas{$q} m ON r.mid = m.mid "
. "WHERE r.cid = " . intval($cid) . " AND m.type = 'category' "
. "LIMIT 1"
));
if ($row) {
$catMid = intval($row['mid']);
}
} catch (\Exception $e) {
$catMid = 0;
}
}
?>
<article class="resource-card" data-cat-mid="<?php echo $catMid; ?>" data-tilt data-tilt-max="10" data-tilt-scale="1" data-tilt-speed="300" data-tilt-perspective="500" itemscope itemtype="http://schema.org/SoftwareApplication">
<div class="resource-card-inner" style="--card-gradient: <?php echo lb_gradient($index); ?>;">
<a class="card-thumb" href="<?php $archive->permalink(); ?>" itemprop="url">
<img src="<?php echo htmlspecialchars($thumb); ?>" alt="<?php $archive->title(); ?>"
loading="lazy" itemprop="image"
onerror="this.src='<?php echo lb_theme_url('assets/img/placeholder.svg'); ?>'">
</a>
<div class="card-info">
<h3 class="card-title" itemprop="name">
<a href="<?php $archive->permalink(); ?>"><?php $archive->title(); ?></a>
</h3>
<?php if ($version): ?>
<div class="card-version" itemprop="softwareVersion"><?php echo htmlspecialchars($version); ?></div>
<?php endif; ?>
<div class="card-meta">
<?php if ($fileSize): ?>
<span class="meta-item" itemprop="fileSize">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
</svg>
<?php echo htmlspecialchars($fileSize); ?>
</span>
<?php endif; ?>
<span class="meta-item">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<?php $archive->date('Y-m-d'); ?>
</span>
<span class="meta-item download-count">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<?php echo intval($downloadCount); ?> 次下载
</span>
<?php if ($langLabel): ?>
<span class="meta-item lang-tag"><?php echo $langLabel; ?></span>
<?php endif; ?>
</div>
</div>
</div>
</article>
<?php
}
function lb_resource_card_grid(object $archive, int $index = 0): void
{
$cid = $archive->cid;
$thumb = lb_get_thumb($archive);
$version = $archive->fields->version ?? '';
$fileSize = $archive->fields->fileSize ?? '';
$systemReq = $archive->fields->systemReq ?? '';
$permalink = $archive->permalink;
$title = $archive->title;
ob_start();
$archive->excerpt(80);
$excerpt = trim(ob_get_clean());
if (!$excerpt) {
$excerpt = mb_substr(strip_tags($archive->content), 0, 80) . '...';
}
$gradient = lb_gradient($index);
$catMid = 0;
$categories = $archive->categories;
if (!empty($categories)) {
$firstCat = is_array($categories) ? reset($categories) : $categories;
if (is_object($firstCat)) {
$catMid = $firstCat->mid;
}
}
if (!$catMid) {
try {
$db = \Typecho\Db::get();
$prefix = $db->getPrefix();
$adapter = $db->getAdapterName();
$isMysql = (strpos($adapter, 'Mysql') !== false || strpos($adapter, 'Pdo_Mysql') !== false);
$q = $isMysql ? '`' : '"';
$row = $db->fetchRow($db->query(
"SELECT m.mid FROM {$q}{$prefix}relationships{$q} r "
. "INNER JOIN {$q}{$prefix}metas{$q} m ON r.mid = m.mid "
. "WHERE r.cid = " . intval($cid) . " AND m.type = 'category' "
. "LIMIT 1"
));
if ($row) {
$catMid = intval($row['mid']);
}
} catch (\Exception $e) {
$catMid = 0;
}
}
$isArticleCard = false;
$lbOpts = \Typecho\Widget::widget('\Widget\Options');
$articleRaw = trim($lbOpts->articleCatIds ?? '');
if (!empty($articleRaw)) {
$articleCats = array_filter(array_map('intval', explode(',', $articleRaw)));
if (!empty($articleCats) && in_array($catMid, $articleCats)) {
$isArticleCard = true;
}
}
?>
<li class="grid-card<?php if ($isArticleCard) echo ' grid-card-article'; ?>" data-cat-mid="<?php echo $catMid; ?>" data-tilt data-tilt-max="10" data-tilt-scale="1.08" data-tilt-speed="300" data-tilt-perspective="500">
<a href="<?php echo htmlspecialchars($permalink); ?>" title="<?php echo htmlspecialchars($title); ?>">
<div class="grid-card-inner" style="--card-gradient: <?php echo $gradient; ?>;">
<?php if ($isArticleCard): ?>
<div class="grid-card-img">
<img src="<?php echo htmlspecialchars($thumb); ?>" alt="<?php echo htmlspecialchars($title); ?>"
onerror="this.src='<?php echo lb_theme_url('assets/img/placeholder.svg'); ?>'">
</div>
<div class="grid-card-con">
<p class="grid-card-title"><?php echo htmlspecialchars($title); ?></p>
<p class="grid-card-info">
<span><?php $archive->date('Y-m-d'); ?></span>
<?php if ($fileSize): ?>
<span class="sep">|</span>
<span><?php echo htmlspecialchars($fileSize); ?></span>
<?php endif; ?>
</p>
</div>
<?php else: ?>
<div class="grid-card-img">
<img src="<?php echo htmlspecialchars($thumb); ?>" alt="<?php echo htmlspecialchars($title); ?>"
onerror="this.src='<?php echo lb_theme_url('assets/img/placeholder.svg'); ?>'">
</div>
<div class="grid-card-con">
<p class="grid-card-title"><?php echo htmlspecialchars($title); ?></p>
<?php if ($version): ?>
<p class="grid-card-version"><?php echo htmlspecialchars($version); ?></p>
<?php endif; ?>
<p class="grid-card-info">
<?php if ($fileSize): ?>
<span><?php echo htmlspecialchars($fileSize); ?></span>
<span class="sep">|</span>
<?php endif; ?>
<span><?php $archive->date('Y-m-d'); ?></span>
</p>
</div>
<?php if ($systemReq): ?>
<div class="grid-card-arm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
<rect x="2" y="4" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="2" x2="8" y2="4"/>
<line x1="16" y1="2" x2="16" y2="4"/>
</svg>
<span><?php echo htmlspecialchars($systemReq); ?></span>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</a>
</li>
<?php
}
$lb_action = $_GET['lb_action'] ?? $_POST['lb_action'] ?? '';
if ('lb_download' === $lb_action) {
lb_download_counter();
exit;
}
if ('lb_rating' === $lb_action) {
lb_create_ratings_table();
lb_rating_handler();
exit;
}
if ('lb_more_posts' === $lb_action) {
lb_more_posts_handler();
exit;
}
if ('lb_test_notify' === $lb_action) {
lb_test_notify_handler();
exit;
}
if ('lb_live_search' === $lb_action) {
lb_live_search_handler();
exit;
}
function lb_more_posts_handler(): void
{
header('Content-Type: text/html; charset=UTF-8');
$catMid = isset($_GET['cat']) ? intval($_GET['cat']) : 0;
$page = max(1, isset($_GET['page']) ? intval($_GET['page']) : 1);
$license = isset($_GET['license']) ? trim($_GET['license']) : '';
$lang = isset($_GET['lang']) ? trim($_GET['lang']) : '';
$sort = isset($_GET['sort']) ? trim($_GET['sort']) : 'date';
$view = isset($_GET['view']) && in_array($_GET['view'], ['list', 'card'], true) ? $_GET['view'] : '';
$options = \Typecho\Widget::widget('\Widget\Options');
$pageSize = intval($options->pageSize ?: 20);
if ($pageSize <= 0) $pageSize = 20;
$offset = ($page - 1) * $pageSize;
$db = \Typecho\Db::get();
$prefix = $db->getPrefix();
$adapter = $db->getAdapterName();
$isMysql = (strpos($adapter, 'Mysql') !== false || strpos($adapter, 'Pdo_Mysql') !== false);
$q = $isMysql ? '`' : '"';
$sql = "SELECT c.* FROM {$q}{$prefix}contents{$q} c";
$joins = '';
$where = " WHERE c.status = 'publish' AND c.type = 'post'";
$orderby = '';
$groupby = '';
if ($catMid > 0) {
$joins .= " INNER JOIN {$q}{$prefix}relationships{$q} r ON c.cid = r.cid";
$joins .= " INNER JOIN {$q}{$prefix}metas{$q} m ON r.mid = m.mid";
$where .= " AND m.type = 'category' AND m.mid = {$catMid}";
$groupby = " GROUP BY c.cid";
}
if ($license) {
$joins .= " INNER JOIN {$q}{$prefix}fields{$q} fl ON c.cid = fl.cid AND fl.name = 'license'";
$where .= " AND fl.str_value = '{$license}'";
}
if ($lang) {
$joins .= " INNER JOIN {$q}{$prefix}fields{$q} flang ON c.cid = flang.cid AND flang.name = 'language'";
$where .= " AND flang.str_value = '{$lang}'";
}
switch ($sort) {
case 'hot':
$joins .= " LEFT JOIN {$q}{$prefix}fields{$q} fdc ON c.cid = fdc.cid AND fdc.name = 'downloadCount'";
$orderby = " ORDER BY CAST(COALESCE(fdc.str_value, '0') AS UNSIGNED) DESC, c.created DESC";
break;
case 'rating':
$joins .= " LEFT JOIN {$q}{$prefix}lb_ratings{$q} frt ON c.cid = frt.cid";
$orderby = " ORDER BY AVG(frt.rating) DESC, c.created DESC";
break;
default:
$orderby = " ORDER BY c.created DESC";
}
$fullSql = $sql . $joins . $where . $groupby . $orderby . " LIMIT {$pageSize} OFFSET {$offset}";
$posts = $db->fetchAll($fullSql);
if (!$posts) {
echo '';
return;
}
$widget = \Widget\Contents\Post\Recent::alloc();
$isCard = ($view === 'card');
foreach ($posts as $i => $post) {
$widget->reset();
$widget->push($post);
if ($isCard) {
lb_resource_card_grid($widget, $offset + $i);
} else {
lb_resource_card($widget, $offset + $i);
}
}
}
function lb_send_mail(string $to, string $subject, string $body): bool
{
$options = \Typecho\Widget::widget('\Widget\Options');
if (($options->lb_mail_switch ?? '0') !== '1') {
return false;
}
$host = trim($options->lb_mail_host ?? '');
$port = intval($options->lb_mail_port ?? 465);
$user = trim($options->lb_mail_user ?? '');
$pass = trim($options->lb_mail_pass ?? '');
if (!$host || !$user || !$pass || !$to) {
return false;
}
require_once __DIR__ . '/phpmailer.php';
require_once __DIR__ . '/smtp.php';
try {
$mail = new PHPMailer();
$mail->isSMTP();
$mail->CharSet = 'UTF-8';
$mail->SMTPAuth = true;
$mail->Host = $host;
$mail->Port = $port;
$mail->Username = $user;
$mail->Password = $pass;
if ($port == 465) {
$mail->SMTPSecure = 'ssl';
} elseif ($port == 587) {
$mail->SMTPSecure = 'tls';
}
$mail->setFrom($user, trim($options->lb_mail_from_name ?? '') ?: $options->title);
$mail->addAddress($to);
$mail->isHTML(true);
$mail->Subject = $subject;
$mail->Body = $body;
$mail->send();
return true;
} catch (\Exception $e) {
return false;
}
}
function lb_send_feishu(string $text): bool
{
$options = \Typecho\Widget::widget('\Widget\Options');
$webhook = trim($options->lb_feishu_webhook ?? '');
if (!$webhook || !$text) {
return false;
}
$payload = json_encode([
'msg_type' => 'text',
'content' => ['text' => $text],
], JSON_UNESCAPED_UNICODE);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nContent-Length: " . strlen($payload) . "\r\n",
'content' => $payload,
'timeout' => 10,
],
]);
$resp = @file_get_contents($webhook, false, $context);
if ($resp === false) {
return false;
}
$data = json_decode($resp, true);
return is_array($data) && isset($data['code']) && intval($data['code']) === 0;
}
function lb_notify(string $subject, string $mailBody, string $plainText): bool
{
$options = \Typecho\Widget::widget('\Widget\Options');
$ok = false;
if (($options->lb_mail_switch ?? '0') === '1') {
$adminMail = trim($options->lb_mail_admin ?? '');
if ($adminMail && lb_send_mail($adminMail, $subject, $mailBody)) {
$ok = true;
}
}
if (($options->lb_notify_feishu ?? '0') === '1') {
if (lb_send_feishu($plainText)) {
$ok = true;
}
}
if (($options->lb_notify_wecom ?? '0') === '1') {
if (lb_send_wecom($plainText)) {
$ok = true;
}
}
if (($options->lb_notify_dingtalk ?? '0') === '1') {
if (lb_send_dingtalk($plainText)) {
$ok = true;
}
}
if (($options->lb_notify_pushplus ?? '0') === '1') {
if (lb_send_pushplus($subject, $plainText)) {
$ok = true;
}
}
return $ok;
}
function lb_test_notify_handler(): void
{
ob_start();
$result = ['ok' => false, 'msg' => '未知错误'];
$user = \Typecho\Widget::widget('Widget_User');
if (!$user->hasLogin() || !$user->pass('administrator', false)) {
$result = ['ok' => false, 'msg' => '无权限操作'];
} else {
$channel = $_GET['channel'] ?? 'mail';
$lbOpts = \Typecho\Widget::widget('\Widget\Options');
$siteTitle = trim($lbOpts->title ?? '本站');
$channelNames = [
'mail' => '邮件',
'feishu' => '飞书',
'wecom' => '企业微信',
'dingtalk' => '钉钉',
'pushplus' => 'PushPlus',
];
$channelName = $channelNames[$channel] ?? '通知';
$testText = '【测试】' . $siteTitle . '的' . $channelName . '测试消息,收到即配置正确。' . date('Y-m-d H:i');
switch ($channel) {
case 'feishu':
$ok = lb_send_feishu($testText);
$result = ['ok' => $ok, 'msg' => $ok ? '飞书测试消息发送成功,请查看飞书群' : '发送失败,请检查飞书 Webhook 配置'];
break;
case 'wecom':
$ok = lb_send_wecom($testText);
$result = ['ok' => $ok, 'msg' => $ok ? '企业微信测试消息发送成功,请查看企业微信群' : '发送失败,请检查企业微信 Webhook 配置'];
break;
case 'dingtalk':
$ok = lb_send_dingtalk($testText);
$result = ['ok' => $ok, 'msg' => $ok ? '钉钉测试消息发送成功,请查看钉钉群' : '发送失败,请检查钉钉 Webhook 配置'];
break;
case 'pushplus':
$ok = lb_send_pushplus('【测试】' . $siteTitle . ' 通知测试', $testText);
$result = ['ok' => $ok, 'msg' => $ok ? 'PushPlus 测试消息发送成功,请查看微信' : '发送失败,请检查 PushPlus Token 配置'];
break;
case 'mail':
default:
$adminMail = trim($lbOpts->lb_mail_admin ?? '');
if (!$adminMail) {
$result = ['ok' => false, 'msg' => '未配置管理员接收邮箱'];
} else {
$body = '<h3>这是一条测试消息</h3>'
. '<p>如果你收到这封邮件,说明 SMTP 配置正确。</p>'
. '<p>时间:' . date('Y-m-d H:i') . '</p>';
$ok = lb_send_mail($adminMail, '【测试】' . $siteTitle . ' 邮件通知测试', $body);
$result = ['ok' => $ok, 'msg' => $ok ? '测试邮件发送成功,请查看收件箱' : '发送失败,请检查 SMTP 配置'];
}
break;
}
}
while (ob_get_level() > 0) {
ob_end_clean();
}
header('Content-Type: application/json; charset=UTF-8');
echo json_encode($result, JSON_UNESCAPED_UNICODE);
exit;
}
function lb_send_wecom(string $text): bool
{
$options = \Typecho\Widget::widget('\Widget\Options');
$webhook = trim($options->lb_wecom_webhook ?? '');
if (!$webhook || !$text) {
return false;
}
$payload = json_encode([
'msgtype' => 'text',
'text' => ['content' => $text],
], JSON_UNESCAPED_UNICODE);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nContent-Length: " . strlen($payload) . "\r\n",
'content' => $payload,
'timeout' => 10,
],
]);
$resp = @file_get_contents($webhook, false, $context);
if ($resp === false) {
return false;
}
$data = json_decode($resp, true);
return is_array($data) && isset($data['errcode']) && intval($data['errcode']) === 0;
}
function lb_send_dingtalk(string $text): bool
{
$options = \Typecho\Widget::widget('\Widget\Options');
$webhook = trim($options->lb_dingtalk_webhook ?? '');
if (!$webhook || !$text) {
return false;
}
$payload = json_encode([
'msgtype' => 'text',
'text' => ['content' => $text],
], JSON_UNESCAPED_UNICODE);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nContent-Length: " . strlen($payload) . "\r\n",
'content' => $payload,
'timeout' => 10,
],
]);
$resp = @file_get_contents($webhook, false, $context);
if ($resp === false) {
return false;
}
$data = json_decode($resp, true);
return is_array($data) && isset($data['errcode']) && intval($data['errcode']) === 0;
}
function lb_send_pushplus(string $title, string $content): bool
{
$options = \Typecho\Widget::widget('\Widget\Options');
$token = trim($options->lb_pushplus_token ?? '');
if (!$token) {
return false;
}
$payload = http_build_query([
'token' => $token,
'title' => $title,
'content' => $content,
'template' => 'txt',
]);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\nContent-Length: " . strlen($payload) . "\r\n",
'content' => $payload,
'timeout' => 10,
],
]);
$resp = @file_get_contents('http://www.pushplus.plus/send', false, $context);
if ($resp === false) {
return false;
}
$data = json_decode($resp, true);
return is_array($data) && isset($data['code']) && intval($data['code']) === 200;
}
function lb_avatar_url(?string $mail, int $size = 100): string
{
$mail = strtolower(trim($mail ?? ''));
if ($mail !== '' && preg_match('/^(\d+)@qq\.com$/', $mail, $m)) {
return 'http://q1.qlogo.cn/g?b=qq&nk=' . $m[1] . '&s=100';
}
return 'https://weavatar.com/avatar/' . md5($mail) . '?s=' . $size . '&d=mm';
}
function lb_get_favicon(string $host): string
{
$host = trim($host);
if ($host === '') {
return '';
}
$host = preg_replace('/[^a-zA-Z0-9.\-]/', '', $host);
if ($host === '') {
return '';
}
$uploadDir = __TYPECHO_ROOT_DIR__ . '/usr/uploads/favicon/';
$cacheFile = $uploadDir . $host . '.ico';
$failFile = $uploadDir . $host . '.fail';
$cacheUrl = \Typecho\Widget::widget('\Widget\Options')->siteUrl . 'usr/uploads/favicon/' . $host . '.ico';
if (file_exists($cacheFile) && filesize($cacheFile) > 100) {
return $cacheUrl;
}
if (file_exists($failFile) && (time() - filemtime($failFile)) < 86400) {
return '';
}
if (!is_dir($uploadDir)) {
@mkdir($uploadDir, 0755, true);
}
$content = @file_get_contents('http://' . $host . '/favicon.ico');
if ($content === false || strlen($content) < 100) {
$content = @file_get_contents('https://' . $host . '/favicon.ico');
}
if ($content !== false && strlen($content) >= 100) {
@file_put_contents($cacheFile, $content);
return $cacheUrl;
}
@file_put_contents($failFile, (string)time());
return '';
}
function threadedComments($comments, $options)
{
$commentClass = 'comment-item';
if ($comments->levels > 0) {
$commentClass .= ' comment-child';
}
?>
<li class="<?php echo $commentClass; ?>" id="<?php $comments->theId(); ?>">
<div class="comment-body">
<div class="comment-avatar">
<img src="<?php echo htmlspecialchars(lb_avatar_url($comments->mail, 48)); ?>" alt="" loading="lazy">
</div>
<div class="comment-content">
<div class="comment-meta">
<div class="comment-meta-top">
<span class="comment-author">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<?php $comments->author(); ?>
<?php
$lbCfg = \Typecho\Widget::widget('\Widget\Options');
$lbInfoSel = [];
if (($lbCfg->commentInfoEnable ?? '1') === '1') {
$lbRaw = $lbCfg->commentInfoOptions ?? ['ip', 'ua', 'system'];
if (is_array($lbRaw)) {
$lbInfoSel = array_values(array_filter(array_map('trim', $lbRaw)));
} else {
$lbInfoSel = array_filter(array_map('trim', explode(',', (string)$lbRaw)));
}
}
$lbShowIp = in_array('ip', $lbInfoSel);
$lbShowUa = in_array('ua', $lbInfoSel);
$lbShowOs = in_array('system', $lbInfoSel);
$lbIsBlogger = ($comments->authorId > 0 && $comments->authorId == $comments->ownerId);
?>
<?php if ($lbIsBlogger): ?>
<span class="comment-blogger" data-tip="博主">
<span class="comment-blogger-icon">
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M275.099846 3.625725a156.71042 156.71042 0 0 1 131.071193 69.789109A412.945151 412.945151 0 0 0 155.253199 257.024781a153.244595 153.244595 0 0 1-21.464483-164.469141A156.119655 156.119655 0 0 1 275.099846 3.625725z m324.094006 299.242467c-44.898185 31.586267-40.053907 108.622101 10.909471 172.070325 51.002763 63.448225 128.7869 89.284374 173.803238 57.698107 44.976954-31.586267 39.89637-108.622101-11.106393-172.070326-50.963379-63.448225-128.747515-89.284374-173.606316-57.698106z m-463.947915-8.940253a394.631418 394.631418 0 0 0 68.765116 441.616975 336.657621 336.657621 0 0 0 110.118707 74.003236c6.971034 3.032597 29.144436 14.414681 91.489898 13.548225 27.372139 0 72.821706-5.631965 88.536071-38.951145 2.678137-9.964246 3.662747-20.282952 2.914443-30.562274a78.374902 78.374902 0 0 1-75.460458 13.390687c-45.449566-13.233149-44.543726-59.391635-42.377586-72.073402 13.705762-79.16259 92.080664-80.659196 110.86701-83.652409a124.37585 124.37585 0 0 1 121.776482 81.05304c72.821706 195.70095-88.299764 299.990769-129.377666 313.657147-115.632519 38.990529-286.324392 8.270718-355.995347-69.513419C44.780033 860.15707 0.078769 746.218079 0.078769 625.071747c0-133.119181 47.497554-247.215709 135.167168-331.695189v0.551381z m746.215716 0a469.85557 469.85557 0 0 1 22.724783 650.629842c-142.886505 159.624864-364.147913 56.240885-218.465117-112.087925 22.724783-26.111839 79.950277-53.641516 127.290294-96.885558A394.631418 394.631418 0 0 0 881.501037 293.809786v0.118153z m-463.475302 8.940253c-45.016338-31.586267-122.642938-5.750118-173.763853 57.698106-51.160301 63.448225-55.807657 141.03544-11.067009 173.054935 44.701263 32.019495 122.642938 5.789503 173.606316-57.658722 51.002763-63.487609 55.92581-140.484059 11.067009-172.10971l0.157537-0.984609zM745.231107 0.002363c61.518391 0 117.286663 35.839779 142.453277 91.371745a153.441517 153.441517 0 0 1-25.914917 166.083901A412.196848 412.196848 0 0 0 612.336235 73.848012A156.198423 156.198423 0 0 1 745.231107 0.002363z" fill="#f59e0b"/></svg>
</span>
博主
</span>
<?php endif; ?>
<?php
$lbRegion = '';
if (preg_match('/LBRegion\/([^ ]+)/', $comments->agent, $rm)) {
$lbRegion = $rm[1];
} else {
$lbIpRaw = $comments->ip;
if ($lbIpRaw === '' || $lbIpRaw === '127.0.0.1' || $lbIpRaw === '::1'
|| preg_match('/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/', $lbIpRaw)) {
$lbRegion = '本地';
} else {
$lbRegion = '未知';
}
}
$lbIpTip = 'IP: ' . $comments->ip
. ($lbRegion !== '' && $lbRegion !== '未知' ? ' · ' . $lbRegion : '');
?>
<?php if (!$lbIsBlogger && $lbShowIp): ?>
<span class="comment-browser comment-ip-badge" data-tip="<?php echo htmlspecialchars($lbIpTip); ?>">
<span class="comment-browser-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>
</svg>
</span>
<span class="comment-browser-text"><?php echo htmlspecialchars($lbRegion); ?></span>
</span>
<?php endif; ?>
<?php
$lbIcons = lb_browser_icons();
if (!$lbIsBlogger && $lbShowUa):
$lbBadge = lb_browser_badge($comments->agent);
if ($lbBadge):
$lbIconSvg = $lbIcons[$lbBadge['key']] ?? ($lbIcons['default'] ?? '');
$lbBadgeText = $lbBadge['name'] . ($lbBadge['version'] !== '' ? ' ' . $lbBadge['version'] : '');
?>
<span class="comment-browser" data-tip="<?php echo htmlspecialchars($lbBadgeText); ?>">
<?php if ($lbIconSvg): ?>
<span class="comment-browser-icon"><?php echo $lbIconSvg; ?></span>
<?php endif; ?>
<span class="comment-browser-text"><?php echo htmlspecialchars($lbBadgeText); ?></span>
</span>
<?php endif; ?>
<?php endif; ?>
<?php
if (!$lbIsBlogger && $lbShowOs):
$lbOs = lb_os_badge($comments->agent);
$lbOsIcon = $lbIcons[$lbOs['key']] ?? ($lbIcons['default'] ?? '');
?>
<span class="comment-browser comment-os" data-tip="<?php echo htmlspecialchars($lbOs['name']); ?>">
<?php if ($lbOsIcon): ?>
<span class="comment-browser-icon"><?php echo $lbOsIcon; ?></span>
<?php endif; ?>
<span class="comment-browser-text"><?php echo htmlspecialchars($lbOs['name']); ?></span>
</span>
<?php endif; ?>
</span>
<?php $comments->reply(); ?>
</div>
<span class="comment-date">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<?php $comments->date('Y年m月d日'); ?>
</span>
</div>
<div class="comment-text">
<?php $comments->content(); ?>
</div>
</div>
</div>
<?php if ($comments->children): ?>
<div class="comment-children">
<?php $comments->threadedComments(); ?>
</div>
<?php endif; ?>
</li>
<?php
}
function lb_browser_icons(): array
{
static $icons = null;
if ($icons === null) {
$file = __DIR__ . '/browser-icons.json';
$icons = file_exists($file) ? (json_decode(file_get_contents($file), true) ?: []) : [];
}
return $icons;
}
function lb_browser_badge(?string $agent): array
{
$agent = $agent ?? '';
$rules = [
['Edg/', 'edge', 'Edge', '/Edg\/([\d.]+)/'],
['OPR/', 'opera', 'Opera', '/OPR\/([\d.]+)/'],
['QQBrowser', 'qq', 'QQ浏览器', '/QQBrowser\/([\d.]+)/'],
['UCBrowser', 'uc', 'UC浏览器', '/UCBrowser\/([\d.]+)/'],
['LBBROWSER', 'liebao', '猎豹浏览器', '/LBBROWSER/i'],
['MetaSr', 'sogou', '搜狗浏览器', '/MetaSr/i'],
['Sogou', 'sogou', '搜狗浏览器', '/Sogou\/([\d.]+)/'],
['TheWorld', 'world', '世界之窗', '/TheWorld\/([\d.]+)/'],
['TorBrowser', 'tor', 'Tor Browser', '/TorBrowser\/([\d.]+)/'],
['DuckDuckGo', 'duckduckgo','DuckDuckGo', '/DuckDuckGo\/([\d.]+)/'],
['Chromium', 'chromium', 'Chromium', '/Chromium\/([\d.]+)/'],
['Chrome/', 'chrome', 'Chrome', '/Chrome\/([\d.]+)/'],
['Firefox/', 'firefox', 'Firefox', '/Firefox\/([\d.]+)/'],
['MSIE', 'ie', 'Internet Explorer', '/MSIE ([\d.]+)/'],
['Trident', 'ie', 'Internet Explorer', '/rv:([\d.]+)/'],
['Safari/', 'safari', 'Safari', '/Version\/([\d.]+)/'],
];
foreach ($rules as $rule) {
if (stripos($agent, $rule[0]) !== false) {
$version = '';
if ($rule[3] !== '' && preg_match($rule[3], $agent, $m)) {
$version = $m[1];
}
return ['key' => $rule[1], 'name' => $rule[2], 'version' => $version];
}
}
return ['key' => 'default', 'name' => '浏览器', 'version' => ''];
}
function lb_os_badge(?string $agent): array
{
$agent = $agent ?? '';
if (preg_match('/LBWin\/(10|11)/', $agent, $wm)) {
return ['key' => 'os_windows', 'name' => 'Windows ' . $wm[1]];
}
if (stripos($agent, 'Windows') !== false) {
$ver = '';
if (preg_match('/Windows NT ([\d.]+)/', $agent, $m)) {
$map = [
'10.0' => '10/11', '6.3' => '8.1', '6.2' => '8',
'6.1' => '7', '6.0' => 'Vista', '5.1' => 'XP',
];
$ver = $map[$m[1]] ?? $m[1];
}
return ['key' => 'os_windows', 'name' => 'Windows' . ($ver !== '' ? ' ' . $ver : '')];
}
if (stripos($agent, 'Mac OS X') !== false || stripos($agent, 'Macintosh') !== false) {
$ver = '';
if (preg_match('/Mac OS X ([\d_]+)/', $agent, $m)) {
$ver = str_replace('_', '.', $m[1]);
}
return ['key' => 'os_macos', 'name' => 'macOS' . ($ver !== '' ? ' ' . $ver : '')];
}
if (preg_match('/iPhone|iPad|iPod/', $agent)) {
$ver = '';
if (preg_match('/OS ([\d_]+)/', $agent, $m)) {
$ver = str_replace('_', '.', $m[1]);
}
return ['key' => 'os_ios', 'name' => 'iOS' . ($ver !== '' ? ' ' . $ver : '')];
}
if (stripos($agent, 'Android') !== false) {
$ver = '';
if (preg_match('/Android ([\d.]+)/', $agent, $m)) {
$ver = $m[1];
}
return ['key' => 'os_android', 'name' => 'Android' . ($ver !== '' ? ' ' . $ver : '')];
}
if (stripos($agent, 'CrOS') !== false || stripos($agent, 'Chrome OS') !== false) {
return ['key' => 'default', 'name' => 'ChromeOS'];
}
if (stripos($agent, 'Linux') !== false) {
return ['key' => 'os_ubuntu', 'name' => 'Linux'];
}
return ['key' => 'default', 'name' => '未知系统'];
}
function lb_ip_region(string $ip): string
{
$ip = trim($ip);
if ($ip === '' || $ip === '127.0.0.1' || $ip === '::1'
|| preg_match('/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/', $ip)) {
return '本地';
}
static $searcher = null;
if ($searcher === null) {
$dbFile = __DIR__ . '/lib/ip2region/ip2region.xdb';
if (!file_exists($dbFile)) {
return '未知';
}
try {
require_once __DIR__ . '/lib/ip2region/Searcher.class.php';
$handle = fopen($dbFile, 'r');
$header = \ip2region\xdb\Util::loadHeader($handle);
$version = \ip2region\xdb\Util::versionFromHeader($header);
$searcher = \ip2region\xdb\Searcher::newWithFileOnly($version, $dbFile);
} catch (\Exception $e) {
return '未知';
}
}
try {
$region = $searcher->search($ip);
if ($region === null) {
return '未知';
}
$parts = explode('|', $region);
$pro = $parts[1] ?? '';
if ($pro === '0' || $pro === '') {
$pro = $parts[0] ?? '';
}
$pro = str_replace(['省', '市', '自治区', '特别行政区'], '', $pro);
return $pro !== '' ? $pro : '未知';
} catch (\Exception $e) {
return '未知';
}
}
if (!function_exists('lb_attach_platform_hook')) {
function lb_attach_platform_hook($comment, $content)
{
$platform = trim($_POST['lb_platform'] ?? '');
if ($platform === 'Win10' || $platform === 'Win11') {
$comment['agent'] .= ' LBWin/' . substr($platform, 3);
}
$region = lb_ip_region($comment['ip'] ?? '');
if ($region !== '') {
$comment['agent'] .= ' LBRegion/' . $region;
}
return $comment;
}
}
\Typecho\Plugin::factory('Widget_Feedback')->comment = 'lb_attach_platform_hook';
function lb_live_search_handler(): void
{
ob_start();
$result = ['items' => []];
$s = trim($_GET['kw'] ?? '');
if ($s !== '') {
try {
$db = \Typecho\Db::get();
$options = \Typecho\Widget::widget('\Widget\Options');
$rows = $db->fetchAll($db->select('cid, title, slug')
->from('table.contents')
->where('type = ?', 'post')
->where('status = ?', 'publish')
->where('title LIKE ?', '%' . $s . '%')
->order('created', \Typecho\Db::SORT_DESC)
->limit(10));
foreach ($rows as $r) {
$result['items'][] = [
'title' => $r['title'],
'url' => $options->siteUrl . intval($r['cid']) . '.html',
];
}
} catch (\Exception $e) {
$result = ['items' => []];
}
}
while (ob_get_level() > 0) {
ob_end_clean();
}
header('Content-Type: application/json; charset=UTF-8');
echo json_encode($result, JSON_UNESCAPED_UNICODE);
exit;
}