// +---------------------------------------------------------------------- declare (strict_types = 1); namespace app\backend\controller; use think\facade\View; use Exception; use ywxapp\controller\BackendBase; use ywxapp\library\TemplateManager; use ywxapp\library\TemplateInstaller; /** * 模板管理(核心后台内置模块,参考 Discuz! 后台「界面」分类)。 * 站点级皮肤能力由 ywxapp/library/TemplateManager、TemplateInstaller 提供, * 与插件解耦,本控制器仅承载后台管理 UI。 */ class Template extends BackendBase { protected $noNeedLogin = []; protected $noNeedVerify = []; /** * 模板中心页面 + 数据接口。 */ public function index() { if ($this->request->isAjax()) { $map = TemplateManager::getActiveMap(); $settings = TemplateManager::getSettings(); $data = [ 'installed' => TemplateManager::listInstalled(), 'addon' => TemplateManager::listaddon(), 'active_map' => $map, 'global_default' => $map['*'] ?? 'default', 'allow_member_select' => ! empty($settings['allow_member_select']), ]; return $this->result->success($data, '获取成功'); } return View::fetch('template/index'); } /** * 界面设置(Discuz 式:全站默认模板 + 会员自选开关)。 * GET 渲染页面;POST 保存。 */ public function setting() { if ($this->request->isAjax() && $this->request->isPost()) { try { $default = (string) input('default_template', 'default'); $allow = (string) input('allow_member_select', '0'); if (! preg_match('/^[a-zA-Z0-9_]*$/', $default)) { return $this->result->error('默认模板标识非法'); } // 写入全站默认('default' 表示回退自带视图) TemplateManager::setActive('*', $default); $settings = TemplateManager::getSettings(); $settings['allow_member_select'] = ($allow === '1' || $allow === 'on'); TemplateManager::setSettings($settings); return $this->result->success([], '已保存'); } catch (Exception $e) { return $this->result->error($e->getMessage()); } } return View::fetch('template/setting'); } /** * 设为全站默认模板(写入 active_map['*'])。 */ public function setDefault() { try { $template = (string) input('template', ''); if (! preg_match('/^[a-zA-Z0-9_]*$/', $template)) { return $this->result->error('模板标识非法'); } TemplateManager::setActive('*', $template); TemplateManager::clearOverlayCache(); $label = $template === 'default' ? '默认(自带视图)' : $template; return $this->result->success([], '已设全站默认:' . $label); } catch (Exception $e) { return $this->result->error($e->getMessage()); } } /** * 启用/禁用模板(禁用后即使被绑定也不生效)。 */ public function toggle() { try { $name = (string) input('name', ''); $enabled = (int) input('enabled', 1); if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) { return $this->result->error('模板标识非法'); } TemplateManager::setEnabled($name, $enabled === 1); TemplateManager::clearOverlayCache(); return $this->result->success([], $enabled === 1 ? '已启用' : '已禁用'); } catch (Exception $e) { return $this->result->error($e->getMessage()); } } /** * 设置某插件激活的模板。 * POST {addon, template};template='default'|'' 表示回退自带视图。 */ public function setActive() { try { $addon = (string) input('addon', ''); $template = (string) input('template', ''); if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $addon)) { return $this->result->error('插件标识非法'); } if (!preg_match('/^[a-zA-Z0-9_]*$/', $template)) { return $this->result->error('模板标识非法'); } TemplateManager::setActive($addon, $template); return $this->result->success([], '已保存'); } catch (Exception $e) { return $this->result->error($e->getMessage()); } } /** * 保存某模板的变量(配色)覆盖值。 * POST {name, vars: {primary:'#fff', ...}} */ public function saveVariables() { try { $name = (string) input('name', ''); if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) { return $this->result->error('模板标识非法'); } $raw = input('vars/a', []); if (! is_array($raw)) { $raw = []; } TemplateManager::saveVariables($name, $raw); return $this->result->success([], '配色已保存'); } catch (Exception $e) { return $this->result->error($e->getMessage()); } } /** * 上传并安装模板 zip 包(由 TemplateInstaller 落地文件)。 */ public function upload() { try { if (! $this->request->isPost()) { return $this->result->error('仅允许 POST 上传'); } $file = $this->request->file('file'); if (empty($file)) { return $this->result->error('未收到上传文件'); } // 来源校验(同源):非同源直接拒绝,轻量防 CSRF $host = $this->request->host(); $referer = $this->request->server('HTTP_REFERER', ''); if ($referer !== '' && $host !== '' && stripos($referer, $host) === false) { return $this->result->error('来源校验失败'); } // 仅允许 zip 包(扩展名 + MIME 双重校验) $ext = strtolower($file->getOriginalExtension()); if ($ext !== 'zip') { return $this->result->error('仅支持 .zip 模板包'); } if (! $file->checkMime(['application/zip', 'application/x-zip-compressed', 'application/octet-stream'])) { return $this->result->error('文件类型不合法'); } $tmpDir = runtime_path() . 'templates' . DIRECTORY_SEPARATOR . '_upload_' . time() . DIRECTORY_SEPARATOR; if (!is_dir($tmpDir)) { @mkdir($tmpDir, 0755, true); } $moved = $file->move($tmpDir, $file->getOriginalName()); if (!$moved) { return $this->result->error('文件保存失败:' . $file->getError()); } $zipPath = $tmpDir . $moved->getSaveName(); $info = TemplateInstaller::install($zipPath, root_path()); // 全量清理渲染缓存 TemplateManager::clearOverlayCache(); @unlink($zipPath); @rmdir($tmpDir); return $this->result->success($info, '模板已安装:' . ($info['title'] ?? $info['name'])); } catch (Exception $e) { return $this->result->error($e->getMessage()); } } /** * 卸载模板(删除文件 + 解除绑定)。 */ public function uninstall() { try { $name = (string) input('name', ''); if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) { return $this->result->error('模板标识非法'); } TemplateManager::uninstall($name); return $this->result->success([], '已卸载'); } catch (Exception $e) { return $this->result->error($e->getMessage()); } } /** * 导出当前模板配置(active_map + settings + variables)为 JSON 文件下载。 * 用于备份 / 跨站点克隆皮肤配置。 */ public function exportConfig() { $config = [ 'type' => 'ywxapp-template-config', 'version' => '1.0', 'exported_at' => date('Y-m-d H:i:s'), 'active_map' => TemplateManager::getActiveMap(), 'settings' => TemplateManager::getSettings(), ]; $json = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); $filename = 'template-config-' . date('Ymd-His') . '.json'; return response($json)->header([ 'Content-Type' => 'application/octet-stream', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', ]); } /** * 导入模板配置文件(JSON,由 exportConfig 生成)。 * 仅采纳「已安装模板」的绑定与变量;未安装的模板绑定会被安全跳过,避免脏数据。 */ public function importConfig() { try { if (! $this->request->isPost()) { return $this->result->error('仅允许 POST 上传'); } // 同源校验(轻量防 CSRF) $host = $this->request->host(); $referer = $this->request->server('HTTP_REFERER', ''); if ($referer !== '' && $host !== '' && stripos($referer, $host) === false) { return $this->result->error('来源校验失败'); } $file = $this->request->file('file'); if (empty($file)) { return $this->result->error('未收到上传文件'); } if (strtolower($file->getOriginalExtension()) !== 'json') { return $this->result->error('仅支持 .json 配置文件'); } $cfg = json_decode(file_get_contents($file->getRealPath()), true); if (! is_array($cfg) || ($cfg['type'] ?? '') !== 'ywxapp-template-config') { return $this->result->error('不是有效的模板配置文件'); } $installed = array_column(TemplateManager::listInstalled(), 'name'); $installedSet = array_fill_keys($installed, true); // 清洗 active_map:仅保留合法标识且已安装的模板 $map = []; $srcMap = $cfg['active_map'] ?? []; foreach ($srcMap as $addon => $tpl) { if ($addon !== '*' && ! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', (string) $addon)) { continue; } $tpl = (string) $tpl; if ($tpl === '' || $tpl === 'default') { $map[$addon] = 'default'; } elseif (preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $tpl) && isset($installedSet[$tpl])) { $map[$addon] = $tpl; } } // 清洗 settings:allow_member_select / disabled / variables $settings = TemplateManager::getSettings(); $src = $cfg['settings'] ?? []; if (is_array($src)) { $settings['allow_member_select'] = ! empty($src['allow_member_select']); $disabled = []; foreach (($src['disabled'] ?? []) as $d) { $d = (string) $d; if (preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $d) && isset($installedSet[$d])) { $disabled[] = $d; } } $settings['disabled'] = $disabled; $variables = []; foreach (($src['variables'] ?? []) as $name => $vars) { $name = (string) $name; if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name) || ! isset($installedSet[$name]) || ! is_array($vars)) { continue; } $clean = []; foreach ($vars as $k => $v) { $k = (string) $k; if (preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $k)) { $clean[$k] = (string) $v; } } $variables[$name] = $clean; } $settings['variables'] = $variables; } TemplateManager::writeConfig($map, $settings); TemplateManager::clearOverlayCache(); $msg = '配置已导入'; if (count($map) < count($srcMap)) { $msg .= '(部分未安装模板的绑定已跳过)'; } return $this->result->success([], $msg); } catch (Exception $e) { return $this->result->error($e->getMessage()); } } }