// +---------------------------------------------------------------------- namespace ywxapp\library; use ywxapp\library\TemplateManager; /** * 皮肤覆盖层(Discuz 式局部覆盖核心)。 * * 由于 ThinkPHP 的 {extend}/{include} 由模板引擎内部用 view_path 解析, * 无法在控制器 fetch 钩子里拦截。因此这里把「插件视图 + 皮肤覆盖」合并到 * 一个真实的覆盖层目录 runtime/skin///: * - 先复制插件 addon//view// 全部文件 * - 再用模板 templates//view/// 的文件覆盖(皮肤优先) * 控制器渲染前把 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; } }