Files
YwxAppThink/app/backend/controller/Template.php
T

331 lines
13 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>
// +----------------------------------------------------------------------
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;
}
}
// 清洗 settingsallow_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());
}
}
}