331 lines
13 KiB
PHP
331 lines
13 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>
|
||
// +----------------------------------------------------------------------
|
||
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());
|
||
}
|
||
}
|
||
}
|