chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+418
View File
@@ -0,0 +1,418 @@
<?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);
}
}