419 lines
15 KiB
PHP
419 lines
15 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;
|
||
|
||
/**
|
||
* 模板管理核心(运行时引擎,站点级运营能力)。
|
||
*
|
||
* 职责:
|
||
* - 扫描已安装模板(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);
|
||
}
|
||
}
|