Files
YwxAppThink/ywxapp/library/SkinOverlay.php
T

211 lines
7.3 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
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp <admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
use ywxapp\library\TemplateManager;
/**
* 皮肤覆盖层(Discuz 式局部覆盖核心)。
*
* 由于 ThinkPHP 的 {extend}/{include} 由模板引擎内部用 view_path 解析,
* 无法在控制器 fetch 钩子里拦截。因此这里把「插件视图 + 皮肤覆盖」合并到
* 一个真实的覆盖层目录 runtime/skin/<addon>/<area>/
* - 先复制插件 addon/<addon>/view/<area>/ 全部文件
* - 再用模板 templates/<tpl>/view/<addon>/<area>/ 的文件覆盖(皮肤优先)
* 控制器渲染前把 View 的 view_path 指向该覆盖层,顶层页面与 extend 的
* layout/partial 均从覆盖层解析;无激活皮肤时返回 null(回退插件默认视图)。
*
* 合并结果按源目录最新修改时间缓存,源有变动才重建。
*/
class SkinOverlay
{
/**
* 解析某插件/区域的合并覆盖层目录;无激活皮肤返回 null。
*
* @param string $addon 插件名,如 blog
* @param string $area frontend / backend / member
* @return string|null
*/
/**
* 解析某插件当前生效的模板名(不构建覆盖层,供变量层等复用)。
*
* 生效层级(高→低):
* 1. 显式插件绑定(管理员设定,最高优先级)
* 2. 会员自选(界面设置开启 allow_member_select 且访客 cookie 有效)
* 3. 全站默认 '*'
* 无激活皮肤 / 被禁用 / 回退默认 时返回 null。
*/
public static function templateOf(string $addon): ?string
{
$map = config('template.active_map', []);
if (! is_array($map)) {
$map = [];
}
// 1) 显式插件绑定优先
if (isset($map[$addon]) && $map[$addon] !== '' && $map[$addon] !== 'default') {
$tpl = $map[$addon];
} else {
// 2) 会员自选(若开启且有效)
$member = self::memberChoice();
if ($member !== null) {
$tpl = $member;
} else {
// 3) 全站默认
$tpl = $map['*'] ?? 'default';
}
}
if (empty($tpl) || $tpl === 'default') {
return null;
}
// 模板被禁用时不生效(界面设置中可启用/禁用)
if (!TemplateManager::isEnabled($tpl)) {
return null;
}
return $tpl;
}
/**
* 读取访客自选模板(界面设置开启 allow_member_select 时生效)。
* 仅接受「已安装且启用」的合法模板名,否则返回 null(安全:防 cookie 注入/遍历)。
*/
protected static function memberChoice(): ?string
{
$settings = TemplateManager::getSettings();
if (empty($settings['allow_member_select'])) {
return null;
}
$name = (string) \think\facade\Request::cookie('ywx_skin', '');
if ($name === '' || $name === 'default') {
return null;
}
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) {
return null;
}
// 必须是真实存在且启用的模板
$dir = root_path() . 'templates' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR;
if (! is_dir($dir) || ! TemplateManager::isEnabled($name)) {
return null;
}
return $name;
}
public static function resolve(string $addon, string $area): ?string
{
$tpl = self::templateOf($addon);
if ($tpl === null) {
return null;
}
$pluginView = root_path() . 'addon' . DIRECTORY_SEPARATOR
. $addon . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR
. $area . DIRECTORY_SEPARATOR;
$skinView = root_path() . 'templates' . DIRECTORY_SEPARATOR
. $tpl . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR
. $addon . DIRECTORY_SEPARATOR . $area . DIRECTORY_SEPARATOR;
// 该区域皮肤没有提供任何覆盖文件,无需合并,直接回退插件默认视图
if (! is_dir($skinView)) {
return null;
}
$overlay = runtime_path() . 'skin' . DIRECTORY_SEPARATOR
. $addon . DIRECTORY_SEPARATOR . $area . DIRECTORY_SEPARATOR
. $tpl . DIRECTORY_SEPARATOR;
self::ensure($overlay, $pluginView, $skinView);
return $overlay;
}
/**
* 确保覆盖层目录最新(按需重建)。
* 用 .built 标记文件记录源目录最新修改时间,避免空目录误判为「缓存有效」。
*/
protected static function ensure(string $overlay, string $pluginView, string $skinView): void
{
$srcMtime = 0;
if (is_dir($pluginView)) {
$srcMtime = max($srcMtime, self::dirMtime($pluginView));
}
if (is_dir($skinView)) {
$srcMtime = max($srcMtime, self::dirMtime($skinView));
}
$marker = $overlay . '.built';
if (is_dir($overlay) && is_file($marker) && (int) file_get_contents($marker) >= $srcMtime) {
return; // 缓存有效
}
if (is_dir($overlay)) {
self::delTree($overlay);
}
if (is_dir($pluginView)) {
self::copyDir($pluginView, $overlay);
} else {
@mkdir($overlay, 0755, true);
}
if (is_dir($skinView)) {
self::copyDir($skinView, $overlay);
}
@file_put_contents($marker, (string) $srcMtime);
}
protected static function copyDir(string $src, string $dst): void
{
$src = rtrim($src, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$dst = rtrim($dst, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (! is_dir($src)) {
return;
}
if (! is_dir($dst)) {
@mkdir($dst, 0755, true);
}
foreach (array_diff(scandir($src), ['.', '..']) as $item) {
$s = $src . $item;
$d = $dst . $item;
if (is_dir($s)) {
self::copyDir($s, $d);
} else {
copy($s, $d);
}
}
}
protected static function delTree(string $dir): void
{
$dir = rtrim($dir, DIRECTORY_SEPARATOR);
if (! is_dir($dir)) {
return;
}
foreach (array_diff(scandir($dir), ['.', '..']) as $item) {
$p = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($p)) {
self::delTree($p);
} else {
@unlink($p);
}
}
@rmdir($dir);
}
protected static function dirMtime(string $dir): int
{
$mtime = 0;
$rii = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($rii as $file) {
if ($file->isFile()) {
$mtime = max($mtime, $file->getMTime());
}
}
return $mtime;
}
}