callback(array $items): string
$GLOBALS['mnbt_theme_menu_renderers'] = ['user' => null, 'admin' => null, 'docker' => null];
/**
* 规范化主题名
*/
function mnbt_theme_sanitize($name)
{
return preg_replace('/[^a-zA-Z0-9_-]/', '', (string)$name);
}
/**
* 规范化 scope:user | admin | docker | home(非法值回退 user)
*/
function mnbt_theme_normalize_scope($scope)
{
if ($scope === 'admin') return 'admin';
if ($scope === 'docker') return 'docker';
if ($scope === 'home') return 'home';
return 'user';
}
/**
* 当前主题名
* @param string $scope user|admin|docker|home
*/
function mnbt_theme_name($scope = 'user')
{
global $conf;
$scope = mnbt_theme_normalize_scope($scope);
$confKeyMap = ['user' => 'usertheme', 'admin' => 'admintheme', 'docker' => 'docker_theme', 'home' => 'home_theme'];
$fileKeyMap = ['user' => 'active_user_theme', 'admin' => 'active_admin_theme', 'docker' => 'active_docker_theme', 'home' => 'active_home_theme'];
$confKey = $confKeyMap[$scope];
$fileKey = $fileKeyMap[$scope];
$name = '';
if (is_array($conf) && !empty($conf[$confKey])) {
$name = (string)$conf[$confKey];
}
if ($name === '' && is_file(MNBT_THEME_ROOT . $fileKey)) {
$name = trim((string)@file_get_contents(MNBT_THEME_ROOT . $fileKey));
}
$name = mnbt_theme_sanitize($name);
if ($name === '' || !is_dir(MNBT_THEME_ROOT . $name . '/' . $scope)) {
return MNBT_THEME_DEFAULT;
}
return $name;
}
/**
* 解析视图路径(带 default 回退)
* @param string $view 如 login / head,或 user/login / admin/login / docker/login
* @param string $scope user|admin|docker
*/
function mnbt_theme_resolve($view, $scope = 'user')
{
$view = str_replace('\\', '/', (string)$view);
$view = ltrim($view, '/');
$scope = mnbt_theme_normalize_scope($scope);
if (strpos($view, 'user/') === 0) {
$scope = 'user';
$view = substr($view, 5);
} elseif (strpos($view, 'admin/') === 0) {
$scope = 'admin';
$view = substr($view, 6);
} elseif (strpos($view, 'docker/') === 0) {
$scope = 'docker';
$view = substr($view, 7);
}
$view = preg_replace('/\.php$/i', '', $view);
if ($view === '' || strpos($view, '..') !== false) {
return null;
}
mnbt_theme_ensure_loaded($scope);
$theme = mnbt_theme_name($scope);
$candidates = [
MNBT_THEME_ROOT . $theme . '/' . $scope . '/' . $view . '.php',
];
if ($theme !== MNBT_THEME_DEFAULT) {
$candidates[] = MNBT_THEME_ROOT . MNBT_THEME_DEFAULT . '/' . $scope . '/' . $view . '.php';
}
foreach ($candidates as $path) {
if (is_file($path)) {
return $path;
}
}
return null;
}
/**
* 主题静态资源 URL(相对 user/ 或 admin/ 页面)
* 查找顺序:当前主题 → default 主题(缺文件自动回退)
* @param string $path 相对 scope 根目录,如 assets/login.css
* @param string $scope user|admin
*/
function mnbt_theme_url($path = '', $scope = 'user')
{
$scope = mnbt_theme_normalize_scope($scope);
$path = ltrim(str_replace('\\', '/', (string)$path), '/');
if ($path !== '' && (strpos($path, '..') !== false || $path[0] === '/')) {
$path = '';
}
$theme = mnbt_theme_name($scope);
if ($path === '') {
return '../templates/' . $theme . '/' . $scope . '/';
}
$candidates = [$theme];
if ($theme !== MNBT_THEME_DEFAULT) {
$candidates[] = MNBT_THEME_DEFAULT;
}
foreach ($candidates as $t) {
if (is_file(MNBT_THEME_ROOT . $t . '/' . $scope . '/' . $path)) {
return '../templates/' . $t . '/' . $scope . '/' . $path;
}
}
return '../templates/' . $theme . '/' . $scope . '/' . $path;
}
/**
* 主题 assets/ 快捷 URL(自动加 assets/ 前缀,带 default 回退)
* 例:mnbt_theme_asset('login.css') → .../user/assets/login.css
*/
function mnbt_theme_asset($path = '', $scope = 'user')
{
$path = ltrim(str_replace('\\', '/', (string)$path), '/');
if ($path === '') {
return mnbt_theme_url('assets/', $scope);
}
if (strpos($path, 'assets/') !== 0) {
$path = 'assets/' . $path;
}
return mnbt_theme_url($path, $scope);
}
/**
* 公共静态资源(imsetes)URL — 全站共享,不随主题切换
* Bootstrap / jQuery / CodeMirror / 上传 logo 等放这里
*/
function mnbt_asset_url($path = '')
{
$path = ltrim(str_replace('\\', '/', (string)$path), '/');
if ($path !== '' && strpos($path, '..') !== false) {
$path = '';
}
return $path === '' ? '../imsetes/' : '../imsetes/' . $path;
}
/**
* 注册主题菜单渲染器。
*
* 每个主题在初始化时调用此函数,告诉引擎如何把自己作用域下的插件菜单树
* (由 mnbt_register_menu 注册)渲染成该主题侧边栏所需的 HTML。
*
* @param string $scope 'user' 或 'admin'
* @param callable $callback 签名: function(array $items): string
* $items 是按 order 排好序的插件菜单树,每项含:
* - title, icon, url, order, multitabs
* - children: 子菜单数组(可选)
* @return bool
*
* 示例(layui 主题):
* mnbt_register_theme_menu_renderer('user', function ($items) {
* $html = '';
* foreach ($items as $it) {
* if (!empty($it['children'])) { ... 分组 ... }
* else { ... 叶子 ... }
* }
* return $html;
* });
*/
function mnbt_register_theme_menu_renderer($scope, $callback)
{
if (!is_callable($callback)) {
return false;
}
$scope = mnbt_theme_normalize_scope($scope);
$GLOBALS['mnbt_theme_menu_renderers'][$scope] = $callback;
return true;
}
/**
* 确保当前主题的 theme.php 初始化文件已被加载。
*
* 引擎会在首次解析主题视图时自动调用本函数。主题开发者只需在
* templates/{theme}/theme.php 中注册渲染器,无需手动在 index.php 中引入。
*/
function mnbt_theme_ensure_loaded($scope = 'user')
{
static $loaded = [];
$scope = mnbt_theme_normalize_scope($scope);
if (!empty($loaded[$scope])) {
return;
}
$loaded[$scope] = true;
$theme = mnbt_theme_name($scope);
$initFile = MNBT_THEME_ROOT . $theme . '/theme.php';
if (is_file($initFile)) {
include_once $initFile;
}
}
/**
* 内部辅助:按 priority 遍历 override filter,第一个返回非 null 的值即短路。
*
* 与 mnbt_apply_filters 不同,本函数不串联 filter 输出 —— 任何一个 filter 返回非 null
* 即停止后续 filter 调用。这样插件可以用低 priority "抢注" 接管,避免被高 priority 覆盖。
*
* @param string $hook filter 名
* @param array $vars 传给回调的变量
* @return mixed|null 第一个非 null 返回值;全部返回 null 时为 null
*/
function _mnbt_theme_first_override($hook, array $vars)
{
if (empty($GLOBALS['mnbt_plugin_filters'][$hook])) {
return null;
}
$buckets = $GLOBALS['mnbt_plugin_filters'][$hook];
ksort($buckets, SORT_NUMERIC);
foreach ($buckets as $list) {
foreach ($list as $item) {
$prev = $GLOBALS['mnbt_plugin_current'];
$GLOBALS['mnbt_plugin_current'] = $item['plugin'];
try {
$result = call_user_func($item['cb'], $vars);
} catch (Throwable $e) {
error_log('[MNBT theme] override ' . $hook . ' @' . $item['plugin'] . ': ' . $e->getMessage());
$result = null;
}
$GLOBALS['mnbt_plugin_current'] = $prev;
if ($result !== null) {
return $result;
}
}
}
return null;
}
/**
* 引入主题局部模板
*
* 插件可通过 mnbt_register_partial_override() 注册 override(filter 名: include.{scope}.{view})
* 介入 partial 渲染流程。回调签名: function(array $vars): mixed
*
* 回调返回值的三种模式:
* - null → 不接管,继续加载原 partial(默认行为)
* - string → 完全接管,直接输出该字符串,跳过原 partial
* - ['before' => string, 'after' => string] → 包裹模式,在原 partial 输出前后插入内容
*
* @param string $view 视图名
* @param array $vars 传入模板的变量
* @param string $scope 'user' 或 'admin'
* @return bool 是否成功加载
*/
function mnbt_theme_include($view, array $vars = [], $scope = 'user')
{
$filterName = 'include.' . $scope . '.' . $view;
$override = _mnbt_theme_first_override($filterName, $vars);
// 模式 1:完全接管
if (is_string($override)) {
echo mnbt_csrf_inject_html($override);
return true;
}
// 解析包裹模式
$before = '';
$after = '';
if (is_array($override)) {
$before = (string)($override['before'] ?? '');
$after = (string)($override['after'] ?? '');
}
$path = mnbt_theme_resolve($view, $scope);
if ($path === null) {
if ($before !== '' || $after !== '') {
echo $before . $after;
return true;
}
trigger_error('Theme partial not found: ' . $scope . '/' . $view, E_USER_WARNING);
return false;
}
// 模式 2:包裹模式 - 输出 before
if ($before !== '') {
echo $before;
}
// 加载原 partial
extract($GLOBALS, EXTR_SKIP);
if ($vars) {
extract($vars, EXTR_OVERWRITE);
}
$bufferLevel = ob_get_level();
ob_start('mnbt_csrf_inject_html');
try {
include $path;
} finally {
while (ob_get_level() > $bufferLevel) ob_end_flush();
}
// 模式 2:包裹模式 - 输出 after
if ($after !== '') {
echo $after;
}
return true;
}
/**
* 渲染页面模板
*
* 插件可通过 mnbt_register_page_override() 注册 override(filter 名: render.{scope}.{view})
* 介入整页渲染流程。回调签名: function(array $vars): mixed
*
* 回调返回值的三种模式:
* - null → 不接管,继续加载原主题文件(默认行为)
* - string → 完全接管,直接输出该字符串,跳过原主题文件
* - ['before' => string, 'after' => string] → 包裹模式,在原主题文件输出前后插入内容
*
* 多个插件注册同一 view 的 override 时,按 priority 升序执行,第一个返回非 null 的值生效,
* 后续 filter 不再调用(短路语义)。
*
* @param string $view 视图名
* @param array $vars 传入模板的变量
* @param bool $exit 渲染完成后是否 exit
* @param string $scope 'user' 或 'admin'
* @return bool
*/
function mnbt_render($view, array $vars = [], $exit = true, $scope = 'user')
{
$filterName = 'render.' . $scope . '.' . $view;
$override = _mnbt_theme_first_override($filterName, $vars);
// 模式 1:完全接管
if (is_string($override)) {
echo mnbt_csrf_inject_html($override);
if ($exit) {
exit;
}
return true;
}
// 解析包裹模式
$before = '';
$after = '';
if (is_array($override)) {
$before = (string)($override['before'] ?? '');
$after = (string)($override['after'] ?? '');
}
$path = mnbt_theme_resolve($view, $scope);
if ($path === null) {
http_response_code(500);
echo 'Theme view not found: ' . htmlspecialchars($scope . '/' . (string)$view);
if ($exit) {
exit;
}
return false;
}
// 模式 2:before
if ($before !== '') {
echo $before;
}
// 加载原主题文件
extract($GLOBALS, EXTR_SKIP);
if ($vars) {
extract($vars, EXTR_OVERWRITE);
}
$bufferLevel = ob_get_level();
ob_start('mnbt_csrf_inject_html');
try {
include $path;
} finally {
while (ob_get_level() > $bufferLevel) ob_end_flush();
}
// 模式 2:after
if ($after !== '') {
echo $after;
}
if ($exit) {
exit;
}
return true;
}
/** 管理端快捷渲染 */
function mnbt_admin_render($view, array $vars = [], $exit = true)
{
return mnbt_render($view, $vars, $exit, 'admin');
}
/** 管理端快捷 include */
function mnbt_admin_include($view, array $vars = [])
{
return mnbt_theme_include($view, $vars, 'admin');
}
/** Docker 端快捷渲染 */
function mnbt_docker_render($view, array $vars = [], $exit = true)
{
return mnbt_render($view, $vars, $exit, 'docker');
}
/** Docker 端快捷 include */
function mnbt_docker_include($view, array $vars = [])
{
return mnbt_theme_include($view, $vars, 'docker');
}
function mnbt_user_require_login()
{
global $islogins;
if (!isset($islogins) || (int)$islogins !== 1) {
exit("");
}
}
function mnbt_user_guest_only()
{
global $islogins;
if (isset($islogins) && (int)$islogins === 1) {
exit("");
}
}
function mnbt_admin_require_login()
{
global $islogin;
if (!isset($islogin) || (int)$islogin !== 1) {
exit("");
}
}
function mnbt_admin_guest_only()
{
global $islogin;
if (isset($islogin) && (int)$islogin === 1) {
exit("");
}
}
/**
* 列出主题
* @param string|null $scope user|admin|docker|null(全部,只要有任一侧目录)
*/
function mnbt_theme_list($scope = null)
{
$list = [];
if (!is_dir(MNBT_THEME_ROOT)) {
return $list;
}
$dirs = @scandir(MNBT_THEME_ROOT) ?: [];
foreach ($dirs as $dir) {
if ($dir === '.' || $dir === '..') {
continue;
}
$base = MNBT_THEME_ROOT . $dir;
if (!is_dir($base)) {
continue;
}
$hasUser = is_dir($base . '/user');
$hasAdmin = is_dir($base . '/admin');
$hasDocker = is_dir($base . '/docker');
$hasHome = is_dir($base . '/home');
if ($scope === 'user' && !$hasUser) {
continue;
}
if ($scope === 'admin' && !$hasAdmin) {
continue;
}
if ($scope === 'docker' && !$hasDocker) {
continue;
}
if ($scope === 'home' && !$hasHome) {
continue;
}
if ($scope === null && !$hasUser && !$hasAdmin && !$hasDocker && !$hasHome) {
continue;
}
$meta = [
'name' => $dir,
'title' => $dir,
'version' => '',
'description' => '',
'has_user' => $hasUser,
'has_admin' => $hasAdmin,
'has_docker' => $hasDocker,
'has_home' => $hasHome,
];
$json = $base . '/theme.json';
if (is_file($json)) {
$data = json_decode((string)@file_get_contents($json), true);
if (is_array($data)) {
$meta['title'] = $data['title'] ?? $meta['title'];
$meta['version'] = $data['version'] ?? '';
$meta['description'] = $data['description'] ?? '';
}
}
$list[$dir] = $meta;
}
return $list;
}
/**
* 设置当前主题(写文件;若表字段存在则同步数据库)
*/
function mnbt_theme_set_active($scope, $name)
{
global $DB, $conf, $siteid;
$scope = mnbt_theme_normalize_scope($scope);
$name = mnbt_theme_sanitize($name);
if ($name === '' || !is_dir(MNBT_THEME_ROOT . $name . '/' . $scope)) {
return [false, '主题不存在或不支持该端:' . $name];
}
$fileKeyMap = ['user' => 'active_user_theme', 'admin' => 'active_admin_theme', 'docker' => 'active_docker_theme', 'home' => 'active_home_theme'];
$confKeyMap = ['user' => 'usertheme', 'admin' => 'admintheme', 'docker' => 'docker_theme', 'home' => 'home_theme'];
$file = MNBT_THEME_ROOT . $fileKeyMap[$scope];
if (@file_put_contents($file, $name) === false) {
return [false, '无法写入主题配置文件,请检查 templates 目录写权限'];
}
$confKey = $confKeyMap[$scope];
if (is_array($conf)) {
$conf[$confKey] = $name;
}
// 可选:同步到 MN_config(字段不存在时忽略)
if (isset($DB) && is_object($DB)) {
$col = $confKey;
$sid = isset($siteid) ? $siteid : 1;
@$DB->query_prepare("UPDATE `MN_config` SET `{$col}` = ? WHERE `id` = ?", [$name, $sid]);
}
return [true, '设置成功'];
}