Files
YwxAppThink/ywxapp/library/TemplateManager.php
T

419 lines
15 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;
/**
* 模板管理核心(运行时引擎,站点级运营能力)。
*
* 职责:
* - 扫描已安装模板(templates/<name>/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/<name>/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 = "<?php\n" . self::FILE_HEADER
. "return [\n"
. " 'active_map' => " . 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 = '<option value="">默认(跟随站点)</option>';
foreach ($applicable as $t) {
$sel = $t['name'] === $current ? ' selected' : '';
$opts .= '<option value="' . htmlspecialchars($t['name'], ENT_QUOTES) . '"' . $sel . '>'
. htmlspecialchars($t['title'], ENT_QUOTES) . ' (' . htmlspecialchars($t['version'], ENT_QUOTES) . ')</option>';
}
return <<<HTML
<div id="ywx-skin-switcher" style="position:fixed;right:16px;bottom:16px;z-index:99999;background:#fff;border:1px solid #e6e6e6;border-radius:10px;padding:10px 12px;box-shadow:0 4px 18px rgba(0,0,0,.15);font-size:13px;color:#333;max-width:240px;">
<div style="margin-bottom:6px;font-weight:600;color:#666;">风格切换</div>
<select id="ywx-skin-select" style="width:100%;padding:5px 6px;border:1px solid #ddd;border-radius:6px;">{$opts}</select>
</div>
<script>
(function(){
var sel = document.getElementById('ywx-skin-select');
if (!sel) return;
sel.onchange = function () {
var v = sel.value;
document.cookie = 'ywx_skin=' + encodeURIComponent(v) + ';path=/;max-age=' + (v ? 31536000 : 0) + ';samesite=lax';
location.reload();
};
})();
</script>
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[<name>])。
* @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/<name>/ 与 public/static/templates/<name>/
* 并从 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);
}
}