211 lines
7.3 KiB
PHP
211 lines
7.3 KiB
PHP
<?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;
|
||
}
|
||
}
|