// +---------------------------------------------------------------------- namespace ywxapp\library; /** * 模板管理核心(运行时引擎,站点级运营能力)。 * * 职责: * - 扫描已安装模板(templates//template.json) * - 维护 active_map(插件 => 模板名)并热写回 config/template.php * - 设置激活 / 卸载时清理 SkinOverlay 渲染缓存(runtime/skin/) * * 与 TemplateInstaller 分工:Installer 负责「从 zip 落地文件」, * 本类负责「列出 / 激活 / 卸载 / 配置持久化」。两者都不触碰 DB, * 契合模板不携带 DB 迁移的约定。 */ class TemplateManager { const FILE_HEADER = "" . "// +----------------------------------------------------------------------\n" . "// | YwxApp 模板激活配置\n" . "// | 整站皮肤 + 局部覆盖 + 每插件独立选(Discuz 式卖模板)\n" . "// | active_map: 插件名 => 模板名;'*' 为全局默认;\n" . "// | 值为 'default' 或空 = 使用该插件自带视图(不覆盖)。\n" . "// +----------------------------------------------------------------------\n\n"; /** * 读取当前激活映射(插件/全局 => 模板名)。 */ public static function getActiveMap(): array { $map = config('template.active_map', []); return is_array($map) ? $map : []; } /** * 列出所有已安装模板(扫描 templates//template.json)。 * 返回元素含:name/title/version/author/description/target_addon/ * target_areas/preview/preview_url/applied_to(已被哪些插件激活) */ public static function listInstalled(): array { $dir = root_path() . 'templates' . DIRECTORY_SEPARATOR; $out = []; if (!is_dir($dir)) { return $out; } $map = self::getActiveMap(); foreach (array_diff(scandir($dir), ['.', '..']) as $name) { $p = $dir . $name . DIRECTORY_SEPARATOR; if (!is_dir($p)) { continue; } $json = $p . 'template.json'; if (!is_file($json)) { continue; } $meta = json_decode(file_get_contents($json), true) ?? []; $meta['name'] = $name; $meta['target_addon'] = $meta['target_addon'] ?? []; $meta['target_areas'] = $meta['target_areas'] ?? []; $preview = $meta['preview'] ?? 'preview.png'; $meta['preview_url'] = is_file($p . $preview) ? '/static/templates/' . $name . '/' . $preview : ''; // 多图预览(template.json 的 previews 数组)+ 首图,去重建灯箱画廊 $previews = $meta['previews'] ?? []; if (! is_array($previews)) { $previews = []; } $all = array_values(array_unique(array_filter(array_merge([$preview], $previews)))); $urls = []; foreach ($all as $pg) { if ($pg !== '' && is_file($p . $pg)) { $urls[] = '/static/templates/' . $name . '/' . $pg; } } $meta['preview_urls'] = $urls; $meta['static_url'] = '/static/templates/' . $name . '/'; // 反向推导:哪些插件当前激活了该模板 $meta['applied_to'] = array_keys(array_filter($map, static function ($v) use ($name) { return $v === $name; })); $meta['enabled'] = self::isEnabled($name); $meta['variable_defs'] = \ywxapp\library\SkinVariables::definitions($name); $meta['variable_overrides'] = self::getSettings()['variables'][$name] ?? []; $out[] = $meta; } return $out; } /** * 列出可应用模板的插件(含 view 目录的 addon)。 */ public static function listaddon(): array { $addonDir = root_path() . 'addon' . DIRECTORY_SEPARATOR; $out = []; if (!is_dir($addonDir)) { return $out; } foreach (array_diff(scandir($addonDir), ['.', '..']) as $name) { $p = $addonDir . $name; if (!is_dir($p) || !is_dir($p . DIRECTORY_SEPARATOR . 'view')) { continue; } $title = $name; if (is_file($p . DIRECTORY_SEPARATOR . 'info.php')) { $info = include $p . DIRECTORY_SEPARATOR . 'info.php'; $title = $info['title'] ?? $name; } $out[] = ['name' => $name, 'title' => $title]; } return $out; } /** * 设置某插件的激活模板('default'/空 = 回退自带视图)。 * 写回 config 文件 + 更新内存配置 + 清理该插件渲染缓存。 */ public static function setActive(string $addon, string $template): void { $map = self::getActiveMap(); if ($template === 'default' || $template === '') { unset($map[$addon]); } else { $map[$addon] = $template; } self::setActiveMap($map); self::clearOverlayCache($addon); } /** * 读取界面设置(允许会员自选 / 禁用列表 / 变量覆盖)。 */ public static function getSettings(): array { $s = config('template.settings', []); if (! is_array($s)) { $s = []; } $s['disabled'] = $s['disabled'] ?? []; $s['allow_member_select'] = $s['allow_member_select'] ?? false; $s['variables'] = $s['variables'] ?? []; return $s; } /** * 写回 active_map + settings 到 config/template.php(保留注释头),并同步内存配置。 */ public static function writeConfig(array $map, array $settings): void { $file = config_path() . 'template.php'; $content = " " . self::exportMap($map) . ",\n" . " 'settings' => " . self::exportPhp($settings) . ",\n" . "];\n"; file_put_contents($file, $content); // 同步内存配置,使当前请求立即生效(新请求会重新加载文件) \think\facade\Config::set(['active_map' => $map, 'settings' => $settings], 'template'); } /** * 仅更新 active_map,保持 settings 不变。 */ public static function setActiveMap(array $map): void { self::writeConfig($map, self::getSettings()); } /** * 仅更新 settings,保持 active_map 不变。 */ public static function setSettings(array $settings): void { self::writeConfig(self::getActiveMap(), $settings); } /** * 列出对指定插件/区域「适用」的模板(已安装 + 启用 + 提供该区域覆盖视图)。 * 供前台会员自选切换器枚举可选项。 */ public static function listApplicable(string $addon, string $area): array { $out = []; $base = root_path() . 'templates' . DIRECTORY_SEPARATOR; if (! is_dir($base)) { return $out; } foreach (array_diff(scandir($base), ['.', '..']) as $name) { $p = $base . $name . DIRECTORY_SEPARATOR; if (! is_dir($p) || ! self::isEnabled($name)) { continue; } $view = $p . 'view' . DIRECTORY_SEPARATOR . $addon . DIRECTORY_SEPARATOR . $area . DIRECTORY_SEPARATOR; if (! is_dir($view)) { continue; } $json = $p . 'template.json'; $meta = is_file($json) ? (json_decode(file_get_contents($json), true) ?? []) : []; $out[] = [ 'name' => $name, 'title' => $meta['title'] ?? $name, 'version' => $meta['version'] ?? '', ]; } return $out; } /** * 生成前台「风格切换器」浮动面板 HTML(含内联脚本)。 * 仅当 allow_member_select 开启且当前插件存在可用模板时返回非空串,否则返回 ''。 * 该面板自动读取/写入 cookie `ywx_skin`(访客级覆盖,无需登录、无需 DB)。 */ public static function switcherHtml(string $addon, string $area): string { $settings = self::getSettings(); if (empty($settings['allow_member_select'])) { return ''; } $applicable = self::listApplicable($addon, $area); if (empty($applicable)) { return ''; } $current = (string) \think\facade\Request::cookie('ywx_skin', ''); $opts = ''; foreach ($applicable as $t) { $sel = $t['name'] === $current ? ' selected' : ''; $opts .= ''; } return <<
风格切换
HTML; } /** * 导出任意值(含嵌套数组/字符串/整型/布尔/null)为合法 PHP 字面量片段。 * 用于把 settings(含变量覆盖嵌套数组)持久化进 config/template.php。 */ protected static function exportPhp($value, int $indent = 4): string { if (is_bool($value)) { return $value ? 'true' : 'false'; } if (is_int($value) || is_float($value)) { return (string) $value; } if (is_null($value)) { return 'null'; } if (is_string($value)) { return "'" . addslashes($value) . "'"; } if (is_array($value)) { if (empty($value)) { return '[]'; } $assoc = array_keys($value) !== range(0, count($value) - 1); $lines = []; foreach ($value as $k => $v) { $key = $assoc ? ("'" . addslashes((string) $k) . "' => ") : ''; $lines[] = str_repeat(' ', $indent + 4) . $key . self::exportPhp($v, $indent + 4); } return "[\n" . implode(",\n", $lines) . "\n" . str_repeat(' ', $indent) . "]"; } return 'null'; } /** * 保存某模板的变量覆盖值(写入 settings.variables[])。 * @param array $vars 形如 ['primary' => '#ff0000', 'bg' => '#fff'] */ public static function saveVariables(string $name, array $vars): void { if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) { throw new \RuntimeException('模板标识非法'); } $settings = self::getSettings(); // 仅保留该模板实际声明的变量键,避免脏数据 $defs = \ywxapp\library\SkinVariables::definitions($name); $clean = []; foreach ($defs as $key => $d) { if (array_key_exists($key, $vars)) { $clean[$key] = (string) $vars[$key]; } } $settings['variables'][$name] = $clean; self::setSettings($settings); } /** * 启用/禁用模板(禁用后即使被绑定,SkinOverlay 也不生效)。 */ public static function setEnabled(string $name, bool $enabled): void { $settings = self::getSettings(); $disabled = $settings['disabled'] ?? []; if ($enabled) { $disabled = array_values(array_diff($disabled, [$name])); } elseif (! in_array($name, $disabled, true)) { $disabled[] = $name; } $settings['disabled'] = $disabled; self::setSettings($settings); } /** * 模板是否启用。 */ public static function isEnabled(string $name): bool { $disabled = self::getSettings()['disabled'] ?? []; return ! in_array($name, $disabled, true); } /** * 卸载模板:删除 templates// 与 public/static/templates//, * 并从 active_map 移除该模板的绑定。 */ public static function uninstall(string $name): bool { $tpl = root_path() . 'templates' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR; $static = root_path() . 'public' . DIRECTORY_SEPARATOR . 'static' . DIRECTORY_SEPARATOR . 'templates' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR; if (is_dir($tpl)) { self::delTree($tpl); } if (is_dir($static)) { self::delTree($static); } // 从激活映射中摘除 $map = self::getActiveMap(); $changed = false; foreach ($map as $k => $v) { if ($v === $name) { unset($map[$k]); $changed = true; } } if ($changed) { self::setActiveMap($map); } return true; } /** * 启用/禁用 / 全局默认 等状态变更后,清理相关渲染缓存。 * @param string $addon 指定插件只清该插件;'' 清全部 */ public static function clearOverlayCache(string $addon = ''): void { $base = runtime_path() . 'skin' . DIRECTORY_SEPARATOR; if ($addon === '') { self::delTree($base); } else { self::delTree($base . $addon . DIRECTORY_SEPARATOR); } } /** * 关联数组导出为美观的 PHP 片段(键/值均为字符串)。 */ protected static function exportMap(array $map): string { if (empty($map)) { return '[]'; } $lines = []; foreach ($map as $k => $v) { $lines[] = " '" . addslashes((string) $k) . "' => '" . addslashes((string) $v) . "',"; } return "[\n" . implode("\n", $lines) . "\n ]"; } /** * 递归删除目录。 */ 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); } }