NewHome/includes/layout.php
2026-09-14 22:06:21 +08:00

323 lines
13 KiB
PHP
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.

<?php
/**
* layout.php —— 页面公共输出组件
* 约定:每个页面在引入本文件前需定义全局 $P资源前缀根目录为 ''func/、admin/ 子目录为 '../')。
*/
require_once __DIR__ . '/db.php';
if (!isset($P)) {
$P = '';
}
/** HTML 转义 */
function he($s): string
{
return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
}
/**
* 生成一个公告链接:允许 http(s) 外链、协议相对 //、站内相对路径,以及裸域名(自动补 https://
* 本地磁盘路径或其它无法识别的内容原样保留。$label / $url 均为已 HTML 转义后的文本。
*/
function notice_link(string $label, string $url): string
{
$raw = trim(html_entity_decode($url, ENT_QUOTES, 'UTF-8'));
$href = $url; // 默认沿用(已转义)的原文
$external = false; // 是否新窗口打开
if (preg_match('#^(?:https?:)?//#i', $raw)) {
$external = true; // http(s):// 或 //example.com
} elseif ($raw !== '' && $raw[0] === '/') {
// 站内相对路径:保持原样
} elseif (preg_match('#^[A-Za-z]:#', $raw)) {
return '[' . $label . '](' . $url . ')'; // Windows 盘符路径等,非链接
} elseif (preg_match('#^[^\s]+\.[A-Za-z]{2,}(?:[/?\#].*)?$#', $raw)) {
$href = 'https://' . $url; // 裸域名,自动补 https://
$external = true;
} elseif (preg_match('#^[\w\-.]+/[\w\-./?%\#=&]*$#', $raw)) {
// 形如 func/index.php 的相对路径:保持原样
} else {
return '[' . $label . '](' . $url . ')'; // 无法识别:保留原写法
}
$attr = $external ? ' target="_blank" rel="noopener noreferrer"' : '';
return '<a href="' . $href . '"' . $attr . '>' . $label . '</a>';
}
/**
* 公告正文渲染:先整体转义,再仅放行 http(s) / 站内相对链接,保留换行。
* 支持 [文字](链接) 显式写法,也自动识别裸 http(s) 链接。
*/
function notice_html(string $text): string
{
$esc = he($text);
// 1) Markdown 风格链接:[文字](链接)
$esc = preg_replace_callback('/\[([^\]]+)\]\(([^)\s]+)\)/u', static function (array $m): string {
return notice_link($m[1], $m[2]);
}, $esc);
// 2) 裸链接自动识别(跳过已在标签 / href="..." 内的地址)
$esc = preg_replace_callback('/(?<![="\'\/>])https?:\/\/[^\s<"\')\]]+/u', static function (array $m): string {
$url = rtrim($m[0], '.,;:!?');
if ($url === '') {
return $m[0];
}
$tail = substr($m[0], strlen($url));
return '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>' . $tail;
}, $esc);
return nl2br($esc);
}
/** 当前 logo 的站点相对路径(后台配置优先,空则默认) */
function site_logo_rel(): string
{
$logo = setting_get('site_logo', '');
return $logo !== '' ? $logo : 'assets/img/logo.svg';
}
/** 输出页面 <head>:标题/favicon/主题防闪烁/公共样式 */
function layout_head(string $title = ''): void
{
global $P;
$site = setting_get('site_name', 'SecHome');
$logo = site_logo_rel();
$fullTitle = ($title !== '' ? $title . ' - ' : '') . $site;
$prefix = $P;
echo '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">';
echo '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
echo '<meta name="renderer" content="webkit">';
// 主题防闪烁:在任何样式加载前应用
echo '<script>(function(){var t;try{t=localStorage.getItem("hp-theme")}catch(e){}';
echo 'if(!t){t=(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)?"dark":"light"}';
echo 'document.documentElement.setAttribute("data-theme",t);})();</script>';
echo '<title>' . he($fullTitle) . '</title>';
echo '<link rel="icon" href="' . he($prefix . $logo) . '">';
echo '<link rel="stylesheet" href="' . he($prefix) . 'assets/css/common.css">';
// 站长提示开关(后台可关):关闭时统一隐藏带 st-tip 标记的辅助文案
if ((setting_get('show_st_tips', '1')) !== '1') {
echo '<style>.st-tip{display:none!important}</style>';
}
echo '</head><body>' . "\n";
}
/** 昼夜切换浮动按钮 + 返回顶部按钮(全站页面右下角) */
function layout_theme_fab(): void
{
echo '<button type="button" class="theme-fab js-theme-btn" title="切换昼夜模式" aria-label="切换昼夜模式">';
echo '<span class="js-theme-icon"></span></button>' . "\n";
echo '<button type="button" class="theme-fab to-top js-to-top" title="返回顶部" aria-label="返回顶部">↑</button>' . "\n";
}
/** 站内 logo 图片(内容区使用,同 favicon */
function layout_logo_img(string $cls = 'logo', string $alt = 'logo'): void
{
global $P;
echo '<img class="' . he($cls) . '" src="' . he($P . site_logo_rel()) . '" alt="' . he($alt) . '">';
}
/** 全站页脚 */
function layout_footer(): void
{
global $P;
$site = setting_get('site_name', 'SecHome');
$icp = setting_get('icp_no', '');
$gongan = setting_get('gongan_no', '');
$gonganLink = setting_get('gongan_link', '');
$copyright = setting_get('copyright', '');
$extra = setting_get('footer_text', '');
echo '<footer class="footer"><div class="wrap footer-inner">';
if ($icp !== '') {
echo '<div><a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" style="color:inherit">' . he($icp) . '</a></div>';
}
if ($gongan !== '') {
if ($gonganLink !== '') {
echo '<div><a href="' . he($gonganLink) . '" target="_blank" rel="noopener noreferrer" style="color:inherit">' . he($gongan) . '</a></div>';
} else {
echo '<div>' . he($gongan) . '</div>';
}
}
if ($copyright !== '') {
echo '<div>' . he(str_replace('{site_name}', $site, $copyright)) . '</div>';
}
if ($extra !== '') {
echo '<div>' . he($extra) . '</div>';
}
echo '<div style="display:flex;gap:16px;align-items:center;justify-content:center;flex-wrap:wrap">';
echo '<a class="admin-link" href="' . he($P) . 'notice.php">更新公告</a>';
echo '<a class="admin-link" href="' . he($P) . 'admin/login.php">后台管理</a>';
echo '</div>';
echo '</div></footer>' . "\n";
}
/** 首页访客信息hero 信息模式IP 与浏览器/系统/内核由服务端解析 UA 提供,屏幕/语言由前端补齐 */
function visitor_brief(): array
{
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) {
$ip = '未知';
}
$ua = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
$browser = '未知';
$kernel = '未知';
$os = '未知';
if ($ua !== '') {
// 浏览器 / 内核
if (strpos($ua, 'MicroMessenger') !== false) {
$browser = '微信内置浏览器';
$kernel = 'WebKit';
} elseif (preg_match('/Edg\/([\d.]+)/', $ua, $m)) {
$browser = 'Edge ' . $m[1];
$kernel = 'Blink';
} elseif (preg_match('/OPR\/([\d.]+)/', $ua, $m)) {
$browser = 'Opera ' . $m[1];
$kernel = 'Blink';
} elseif (strpos($ua, 'Firefox/') !== false) {
$browser = 'Firefox';
$kernel = 'Gecko';
if (preg_match('/Firefox\/([\d.]+)/', $ua, $m))
$browser .= ' ' . $m[1];
} elseif (preg_match('/(?:Chrome|CriOS)\/([\d.]+)/', $ua, $m)) {
$browser = 'Chrome ' . $m[1];
$kernel = 'Blink';
} elseif (strpos($ua, 'Safari/') !== false) {
$browser = 'Safari';
$kernel = 'WebKit';
if (preg_match('/Version\/([\d.]+)/', $ua, $m))
$browser .= ' ' . $m[1];
}
if ($kernel === '未知') {
if (strpos($ua, 'Gecko/') !== false)
$kernel = 'Gecko';
elseif (strpos($ua, 'AppleWebKit/') !== false)
$kernel = 'WebKit';
}
// 操作系统
if (strpos($ua, 'Windows NT 10.0') !== false) {
$os = 'Windows 10 / 11';
} elseif (strpos($ua, 'Windows NT 6.3') !== false) {
$os = 'Windows 8.1';
} elseif (strpos($ua, 'Windows NT 6.2') !== false) {
$os = 'Windows 8';
} elseif (strpos($ua, 'Windows NT 6.1') !== false) {
$os = 'Windows 7';
} elseif (strpos($ua, 'Windows NT 5.1') !== false) {
$os = 'Windows XP';
} elseif (strpos($ua, 'Windows Phone') !== false) {
$os = 'Windows Phone';
} elseif (strpos($ua, 'Android') !== false) {
$os = 'Android';
if (preg_match('/Android ([\d.]+)/', $ua, $m))
$os .= ' ' . $m[1];
} elseif (strpos($ua, 'iPad') !== false || strpos($ua, 'iPhone') !== false || strpos($ua, 'iPod') !== false) {
$os = 'iOS';
if (preg_match('/OS ([\d_]+)/', $ua, $m))
$os .= ' ' . str_replace('_', '.', $m[1]);
} elseif (strpos($ua, 'Mac OS X') !== false) {
$os = 'macOS';
if (preg_match('/Mac OS X ([\d_]+)/', $ua, $m))
$os .= ' ' . str_replace('_', '.', $m[1]);
} elseif (strpos($ua, 'CrOS') !== false) {
$os = 'ChromeOS';
} elseif (strpos($ua, 'Linux') !== false) {
$os = 'Linux';
}
}
return ['ip' => $ip, 'browser' => $browser, 'os' => $os, 'kernel' => $kernel];
}
/** 图标值是否属于图片dataURL / http / 常见图片扩展 / 相对路径) */
function icon_is_image(string $raw): bool
{
$v = trim($raw);
if ($v === '') {
return false;
}
if (preg_match('/^data:image\//i', $v) || preg_match('#^https?://#i', $v)) {
return true;
}
if (preg_match('/\.(png|jpe?g|gif|svg|webp)(\?|#|$)/i', $v)) {
return true;
}
return $v[0] === '/' || strpos($v, './') === 0 || strpos($v, '../') === 0;
}
/** 渲染图标:图片返回 <img>,否则返回转义文本(供前台/后台预览复用) */
function site_icon(?string $raw, string $alt = 'icon'): string
{
$v = trim((string) $raw);
if ($v === '') {
return '';
}
if (icon_is_image($v)) {
return '<img class="icon-img" src="' . he($v) . '" alt="' . he($alt) . '" loading="lazy">';
}
return he($v);
}
/** 默认 CDN IPv4 段库每行CIDR + 空格 + 厂商名;# 开头为注释,由后台可覆盖) */
function default_cdn_ranges(): string
{
return implode("\n", [
'# 内置默认Cloudflare 官方 IPv4 段可在后台“CDN IP 段库”补充其它厂商)',
'103.21.244.0/22 Cloudflare',
'103.22.200.0/22 Cloudflare',
'103.31.4.0/22 Cloudflare',
'104.16.0.0/13 Cloudflare',
'104.24.0.0/14 Cloudflare',
'108.162.192.0/18 Cloudflare',
'131.0.72.0/22 Cloudflare',
'141.101.64.0/18 Cloudflare',
'162.158.0.0/15 Cloudflare',
'172.64.0.0/13 Cloudflare',
'173.245.48.0/20 Cloudflare',
'188.114.96.0/20 Cloudflare',
'190.93.240.0/20 Cloudflare',
'197.234.240.0/22 Cloudflare',
'198.41.128.0/17 Cloudflare',
]);
}
/** 当前生效的 CDN 段库文本(后台配置优先,为空回退默认内置列表) */
function cdn_ranges_text(): string
{
$v = setting_get('cdn_ranges', '');
return $v !== '' ? $v : default_cdn_ranges();
}
/** 默认搜索引擎列表(后台未配置时的兜底) */
function default_search_engines(): array
{
return [
['key' => 'bing', 'name' => 'Bing', 'icon' => 'B', 'url' => 'https://www.bing.com/search?q={kw}'],
['key' => 'baidu', 'name' => '百度', 'icon' => '度', 'url' => 'https://www.baidu.com/s?wd={kw}'],
['key' => 'github', 'name' => 'GitHub', 'icon' => 'G', 'url' => 'https://github.com/search?q={kw}'],
['key' => 'google', 'name' => 'Google', 'icon' => 'G', 'url' => 'https://www.google.com/search?q={kw}'],
];
}
/** 读取当前生效的搜索引擎列表(优先后台配置,无效或为空回退默认) */
function search_engines(): array
{
$raw = setting_get('search_engines', '');
if ($raw === '') {
return default_search_engines();
}
$arr = json_decode($raw, true);
if (!is_array($arr) || count($arr) === 0) {
return default_search_engines();
}
$out = [];
foreach ($arr as $i => $e) {
if (!is_array($e))
continue;
$name = trim((string) ($e['name'] ?? ''));
$url = trim((string) ($e['url'] ?? ''));
$icon = trim((string) ($e['icon'] ?? ''));
if ($name === '' || $url === '' || strpos($url, '{kw}') === false)
continue;
$key = trim((string) ($e['key'] ?? ''));
if ($key === '') {
$key = 'e' . ((int) $i + 1);
}
$out[] = ['key' => $key, 'name' => $name, 'icon' => ($icon !== '' ? $icon : 'S'), 'url' => $url];
}
return $out !== [] ? $out : default_search_engines();
}