better-staridc-MNBT
1<?php
2/**
3 * MNBT 主题系统(用户端 + 管理端 + Docker 端)
4 * 目录: templates/{theme}/user/ 、templates/{theme}/admin/ 、templates/{theme}/docker/
5 * 缺页自动回退到 templates/default/{scope}/
6 */
7
8if (!defined('IN_CRONLITE')) {
9 exit('Access Denied');
10}
11
12define('MNBT_THEME_ROOT', ROOT . 'templates/');
13define('MNBT_THEME_DEFAULT', 'default');
14
15// 主题可注册的菜单渲染器:scope => callback(array $items): string
16$GLOBALS['mnbt_theme_menu_renderers'] = ['user' => null, 'admin' => null, 'docker' => null];
17
18/**
19 * 规范化主题名
20 */
21function mnbt_theme_sanitize($name)
22{
23 return preg_replace('/[^a-zA-Z0-9_-]/', '', (string)$name);
24}
25
26/**
27 * 规范化 scope:user | admin | docker | home(非法值回退 user)
28 */
29function mnbt_theme_normalize_scope($scope)
30{
31 if ($scope === 'admin') return 'admin';
32 if ($scope === 'docker') return 'docker';
33 if ($scope === 'home') return 'home';
34 return 'user';
35}
36
37/**
38 * 当前主题名
39 * @param string $scope user|admin|docker|home
40 */
41function mnbt_theme_name($scope = 'user')
42{
43 global $conf;
44 $scope = mnbt_theme_normalize_scope($scope);
45 $confKeyMap = ['user' => 'usertheme', 'admin' => 'admintheme', 'docker' => 'docker_theme', 'home' => 'home_theme'];
46 $fileKeyMap = ['user' => 'active_user_theme', 'admin' => 'active_admin_theme', 'docker' => 'active_docker_theme', 'home' => 'active_home_theme'];
47 $confKey = $confKeyMap[$scope];
48 $fileKey = $fileKeyMap[$scope];
49
50 $name = '';
51 if (is_array($conf) && !empty($conf[$confKey])) {
52 $name = (string)$conf[$confKey];
53 }
54 if ($name === '' && is_file(MNBT_THEME_ROOT . $fileKey)) {
55 $name = trim((string)@file_get_contents(MNBT_THEME_ROOT . $fileKey));
56 }
57 $name = mnbt_theme_sanitize($name);
58 if ($name === '' || !is_dir(MNBT_THEME_ROOT . $name . '/' . $scope)) {
59 return MNBT_THEME_DEFAULT;
60 }
61 return $name;
62}
63
64/**
65 * 解析视图路径(带 default 回退)
66 * @param string $view 如 login / head,或 user/login / admin/login / docker/login
67 * @param string $scope user|admin|docker
68 */
69function mnbt_theme_resolve($view, $scope = 'user')
70{
71 $view = str_replace('\\', '/', (string)$view);
72 $view = ltrim($view, '/');
73 $scope = mnbt_theme_normalize_scope($scope);
74
75 if (strpos($view, 'user/') === 0) {
76 $scope = 'user';
77 $view = substr($view, 5);
78 } elseif (strpos($view, 'admin/') === 0) {
79 $scope = 'admin';
80 $view = substr($view, 6);
81 } elseif (strpos($view, 'docker/') === 0) {
82 $scope = 'docker';
83 $view = substr($view, 7);
84 }
85
86 $view = preg_replace('/\.php$/i', '', $view);
87 if ($view === '' || strpos($view, '..') !== false) {
88 return null;
89 }
90
91 mnbt_theme_ensure_loaded($scope);
92 $theme = mnbt_theme_name($scope);
93 $candidates = [
94 MNBT_THEME_ROOT . $theme . '/' . $scope . '/' . $view . '.php',
95 ];
96 if ($theme !== MNBT_THEME_DEFAULT) {
97 $candidates[] = MNBT_THEME_ROOT . MNBT_THEME_DEFAULT . '/' . $scope . '/' . $view . '.php';
98 }
99 foreach ($candidates as $path) {
100 if (is_file($path)) {
101 return $path;
102 }
103 }
104 return null;
105}
106
107/**
108 * 主题静态资源 URL(相对 user/ 或 admin/ 页面)
109 * 查找顺序:当前主题 → default 主题(缺文件自动回退)
110 * @param string $path 相对 scope 根目录,如 assets/login.css
111 * @param string $scope user|admin
112 */
113function mnbt_theme_url($path = '', $scope = 'user')
114{
115 $scope = mnbt_theme_normalize_scope($scope);
116 $path = ltrim(str_replace('\\', '/', (string)$path), '/');
117 if ($path !== '' && (strpos($path, '..') !== false || $path[0] === '/')) {
118 $path = '';
119 }
120 $theme = mnbt_theme_name($scope);
121 if ($path === '') {
122 return '../templates/' . $theme . '/' . $scope . '/';
123 }
124 $candidates = [$theme];
125 if ($theme !== MNBT_THEME_DEFAULT) {
126 $candidates[] = MNBT_THEME_DEFAULT;
127 }
128 foreach ($candidates as $t) {
129 if (is_file(MNBT_THEME_ROOT . $t . '/' . $scope . '/' . $path)) {
130 return '../templates/' . $t . '/' . $scope . '/' . $path;
131 }
132 }
133 return '../templates/' . $theme . '/' . $scope . '/' . $path;
134}
135
136/**
137 * 主题 assets/ 快捷 URL(自动加 assets/ 前缀,带 default 回退)
138 * 例:mnbt_theme_asset('login.css') → .../user/assets/login.css
139 */
140function mnbt_theme_asset($path = '', $scope = 'user')
141{
142 $path = ltrim(str_replace('\\', '/', (string)$path), '/');
143 if ($path === '') {
144 return mnbt_theme_url('assets/', $scope);
145 }
146 if (strpos($path, 'assets/') !== 0) {
147 $path = 'assets/' . $path;
148 }
149 return mnbt_theme_url($path, $scope);
150}
151
152/**
153 * 公共静态资源(imsetes)URL — 全站共享,不随主题切换
154 * Bootstrap / jQuery / CodeMirror / 上传 logo 等放这里
155 */
156function mnbt_asset_url($path = '')
157{
158 $path = ltrim(str_replace('\\', '/', (string)$path), '/');
159 if ($path !== '' && strpos($path, '..') !== false) {
160 $path = '';
161 }
162 return $path === '' ? '../imsetes/' : '../imsetes/' . $path;
163}
164
165/**
166 * 注册主题菜单渲染器。
167 *
168 * 每个主题在初始化时调用此函数,告诉引擎如何把自己作用域下的插件菜单树
169 * (由 mnbt_register_menu 注册)渲染成该主题侧边栏所需的 HTML。
170 *
171 * @param string $scope 'user' 或 'admin'
172 * @param callable $callback 签名: function(array $items): string
173 * $items 是按 order 排好序的插件菜单树,每项含:
174 * - title, icon, url, order, multitabs
175 * - children: 子菜单数组(可选)
176 * @return bool
177 *
178 * 示例(layui 主题):
179 * mnbt_register_theme_menu_renderer('user', function ($items) {
180 * $html = '';
181 * foreach ($items as $it) {
182 * if (!empty($it['children'])) { ... 分组 ... }
183 * else { ... 叶子 ... }
184 * }
185 * return $html;
186 * });
187 */
188function mnbt_register_theme_menu_renderer($scope, $callback)
189{
190 if (!is_callable($callback)) {
191 return false;
192 }
193 $scope = mnbt_theme_normalize_scope($scope);
194 $GLOBALS['mnbt_theme_menu_renderers'][$scope] = $callback;
195 return true;
196}
197
198/**
199 * 确保当前主题的 theme.php 初始化文件已被加载。
200 *
201 * 引擎会在首次解析主题视图时自动调用本函数。主题开发者只需在
202 * templates/{theme}/theme.php 中注册渲染器,无需手动在 index.php 中引入。
203 */
204function mnbt_theme_ensure_loaded($scope = 'user')
205{
206 static $loaded = [];
207 $scope = mnbt_theme_normalize_scope($scope);
208 if (!empty($loaded[$scope])) {
209 return;
210 }
211 $loaded[$scope] = true;
212 $theme = mnbt_theme_name($scope);
213 $initFile = MNBT_THEME_ROOT . $theme . '/theme.php';
214 if (is_file($initFile)) {
215 include_once $initFile;
216 }
217}
218
219/**
220 * 内部辅助:按 priority 遍历 override filter,第一个返回非 null 的值即短路。
221 *
222 * 与 mnbt_apply_filters 不同,本函数不串联 filter 输出 —— 任何一个 filter 返回非 null
223 * 即停止后续 filter 调用。这样插件可以用低 priority "抢注" 接管,避免被高 priority 覆盖。
224 *
225 * @param string $hook filter 名
226 * @param array $vars 传给回调的变量
227 * @return mixed|null 第一个非 null 返回值;全部返回 null 时为 null
228 */
229function _mnbt_theme_first_override($hook, array $vars)
230{
231 if (empty($GLOBALS['mnbt_plugin_filters'][$hook])) {
232 return null;
233 }
234 $buckets = $GLOBALS['mnbt_plugin_filters'][$hook];
235 ksort($buckets, SORT_NUMERIC);
236 foreach ($buckets as $list) {
237 foreach ($list as $item) {
238 $prev = $GLOBALS['mnbt_plugin_current'];
239 $GLOBALS['mnbt_plugin_current'] = $item['plugin'];
240 try {
241 $result = call_user_func($item['cb'], $vars);
242 } catch (Throwable $e) {
243 error_log('[MNBT theme] override ' . $hook . ' @' . $item['plugin'] . ': ' . $e->getMessage());
244 $result = null;
245 }
246 $GLOBALS['mnbt_plugin_current'] = $prev;
247 if ($result !== null) {
248 return $result;
249 }
250 }
251 }
252 return null;
253}
254
255/**
256 * 引入主题局部模板
257 *
258 * 插件可通过 mnbt_register_partial_override() 注册 override(filter 名: include.{scope}.{view})
259 * 介入 partial 渲染流程。回调签名: function(array $vars): mixed
260 *
261 * 回调返回值的三种模式:
262 * - null → 不接管,继续加载原 partial(默认行为)
263 * - string → 完全接管,直接输出该字符串,跳过原 partial
264 * - ['before' => string, 'after' => string] → 包裹模式,在原 partial 输出前后插入内容
265 *
266 * @param string $view 视图名
267 * @param array $vars 传入模板的变量
268 * @param string $scope 'user' 或 'admin'
269 * @return bool 是否成功加载
270 */
271function mnbt_theme_include($view, array $vars = [], $scope = 'user')
272{
273 $filterName = 'include.' . $scope . '.' . $view;
274 $override = _mnbt_theme_first_override($filterName, $vars);
275
276 // 模式 1:完全接管
277 if (is_string($override)) {
278 echo mnbt_csrf_inject_html($override);
279 return true;
280 }
281
282 // 解析包裹模式
283 $before = '';
284 $after = '';
285 if (is_array($override)) {
286 $before = (string)($override['before'] ?? '');
287 $after = (string)($override['after'] ?? '');
288 }
289
290 $path = mnbt_theme_resolve($view, $scope);
291 if ($path === null) {
292 if ($before !== '' || $after !== '') {
293 echo $before . $after;
294 return true;
295 }
296 trigger_error('Theme partial not found: ' . $scope . '/' . $view, E_USER_WARNING);
297 return false;
298 }
299
300 // 模式 2:包裹模式 - 输出 before
301 if ($before !== '') {
302 echo $before;
303 }
304
305 // 加载原 partial
306 extract($GLOBALS, EXTR_SKIP);
307 if ($vars) {
308 extract($vars, EXTR_OVERWRITE);
309 }
310 $bufferLevel = ob_get_level();
311 ob_start('mnbt_csrf_inject_html');
312 try {
313 include $path;
314 } finally {
315 while (ob_get_level() > $bufferLevel) ob_end_flush();
316 }
317
318 // 模式 2:包裹模式 - 输出 after
319 if ($after !== '') {
320 echo $after;
321 }
322 return true;
323}
324
325/**
326 * 渲染页面模板
327 *
328 * 插件可通过 mnbt_register_page_override() 注册 override(filter 名: render.{scope}.{view})
329 * 介入整页渲染流程。回调签名: function(array $vars): mixed
330 *
331 * 回调返回值的三种模式:
332 * - null → 不接管,继续加载原主题文件(默认行为)
333 * - string → 完全接管,直接输出该字符串,跳过原主题文件
334 * - ['before' => string, 'after' => string] → 包裹模式,在原主题文件输出前后插入内容
335 *
336 * 多个插件注册同一 view 的 override 时,按 priority 升序执行,第一个返回非 null 的值生效,
337 * 后续 filter 不再调用(短路语义)。
338 *
339 * @param string $view 视图名
340 * @param array $vars 传入模板的变量
341 * @param bool $exit 渲染完成后是否 exit
342 * @param string $scope 'user' 或 'admin'
343 * @return bool
344 */
345function mnbt_render($view, array $vars = [], $exit = true, $scope = 'user')
346{
347 $filterName = 'render.' . $scope . '.' . $view;
348 $override = _mnbt_theme_first_override($filterName, $vars);
349
350 // 模式 1:完全接管
351 if (is_string($override)) {
352 echo mnbt_csrf_inject_html($override);
353 if ($exit) {
354 exit;
355 }
356 return true;
357 }
358
359 // 解析包裹模式
360 $before = '';
361 $after = '';
362 if (is_array($override)) {
363 $before = (string)($override['before'] ?? '');
364 $after = (string)($override['after'] ?? '');
365 }
366
367 $path = mnbt_theme_resolve($view, $scope);
368 if ($path === null) {
369 http_response_code(500);
370 echo 'Theme view not found: ' . htmlspecialchars($scope . '/' . (string)$view);
371 if ($exit) {
372 exit;
373 }
374 return false;
375 }
376
377 // 模式 2:before
378 if ($before !== '') {
379 echo $before;
380 }
381
382 // 加载原主题文件
383 extract($GLOBALS, EXTR_SKIP);
384 if ($vars) {
385 extract($vars, EXTR_OVERWRITE);
386 }
387 $bufferLevel = ob_get_level();
388 ob_start('mnbt_csrf_inject_html');
389 try {
390 include $path;
391 } finally {
392 while (ob_get_level() > $bufferLevel) ob_end_flush();
393 }
394
395 // 模式 2:after
396 if ($after !== '') {
397 echo $after;
398 }
399
400 if ($exit) {
401 exit;
402 }
403 return true;
404}
405
406/** 管理端快捷渲染 */
407function mnbt_admin_render($view, array $vars = [], $exit = true)
408{
409 return mnbt_render($view, $vars, $exit, 'admin');
410}
411
412/** 管理端快捷 include */
413function mnbt_admin_include($view, array $vars = [])
414{
415 return mnbt_theme_include($view, $vars, 'admin');
416}
417
418/** Docker 端快捷渲染 */
419function mnbt_docker_render($view, array $vars = [], $exit = true)
420{
421 return mnbt_render($view, $vars, $exit, 'docker');
422}
423
424/** Docker 端快捷 include */
425function mnbt_docker_include($view, array $vars = [])
426{
427 return mnbt_theme_include($view, $vars, 'docker');
428}
429
430function mnbt_user_require_login()
431{
432 global $islogins;
433 if (!isset($islogins) || (int)$islogins !== 1) {
434 exit("<script language='javascript'>window.location.href='./login.php';</script>");
435 }
436}
437
438function mnbt_user_guest_only()
439{
440 global $islogins;
441 if (isset($islogins) && (int)$islogins === 1) {
442 exit("<script language='javascript'>window.location.href='./index.php';</script>");
443 }
444}
445
446function mnbt_admin_require_login()
447{
448 global $islogin;
449 if (!isset($islogin) || (int)$islogin !== 1) {
450 exit("<script language='javascript'>window.location.href='./login.php';</script>");
451 }
452}
453
454function mnbt_admin_guest_only()
455{
456 global $islogin;
457 if (isset($islogin) && (int)$islogin === 1) {
458 exit("<script language='javascript'>window.location.href='./index.php';</script>");
459 }
460}
461
462/**
463 * 列出主题
464 * @param string|null $scope user|admin|docker|null(全部,只要有任一侧目录)
465 */
466function mnbt_theme_list($scope = null)
467{
468 $list = [];
469 if (!is_dir(MNBT_THEME_ROOT)) {
470 return $list;
471 }
472 $dirs = @scandir(MNBT_THEME_ROOT) ?: [];
473 foreach ($dirs as $dir) {
474 if ($dir === '.' || $dir === '..') {
475 continue;
476 }
477 $base = MNBT_THEME_ROOT . $dir;
478 if (!is_dir($base)) {
479 continue;
480 }
481 $hasUser = is_dir($base . '/user');
482 $hasAdmin = is_dir($base . '/admin');
483 $hasDocker = is_dir($base . '/docker');
484 $hasHome = is_dir($base . '/home');
485 if ($scope === 'user' && !$hasUser) {
486 continue;
487 }
488 if ($scope === 'admin' && !$hasAdmin) {
489 continue;
490 }
491 if ($scope === 'docker' && !$hasDocker) {
492 continue;
493 }
494 if ($scope === 'home' && !$hasHome) {
495 continue;
496 }
497 if ($scope === null && !$hasUser && !$hasAdmin && !$hasDocker && !$hasHome) {
498 continue;
499 }
500 $meta = [
501 'name' => $dir,
502 'title' => $dir,
503 'version' => '',
504 'description' => '',
505 'has_user' => $hasUser,
506 'has_admin' => $hasAdmin,
507 'has_docker' => $hasDocker,
508 'has_home' => $hasHome,
509 ];
510 $json = $base . '/theme.json';
511 if (is_file($json)) {
512 $data = json_decode((string)@file_get_contents($json), true);
513 if (is_array($data)) {
514 $meta['title'] = $data['title'] ?? $meta['title'];
515 $meta['version'] = $data['version'] ?? '';
516 $meta['description'] = $data['description'] ?? '';
517 }
518 }
519 $list[$dir] = $meta;
520 }
521 return $list;
522}
523
524/**
525 * 设置当前主题(写文件;若表字段存在则同步数据库)
526 */
527function mnbt_theme_set_active($scope, $name)
528{
529 global $DB, $conf, $siteid;
530 $scope = mnbt_theme_normalize_scope($scope);
531 $name = mnbt_theme_sanitize($name);
532 if ($name === '' || !is_dir(MNBT_THEME_ROOT . $name . '/' . $scope)) {
533 return [false, '主题不存在或不支持该端:' . $name];
534 }
535
536 $fileKeyMap = ['user' => 'active_user_theme', 'admin' => 'active_admin_theme', 'docker' => 'active_docker_theme', 'home' => 'active_home_theme'];
537 $confKeyMap = ['user' => 'usertheme', 'admin' => 'admintheme', 'docker' => 'docker_theme', 'home' => 'home_theme'];
538 $file = MNBT_THEME_ROOT . $fileKeyMap[$scope];
539 if (@file_put_contents($file, $name) === false) {
540 return [false, '无法写入主题配置文件,请检查 templates 目录写权限'];
541 }
542
543 $confKey = $confKeyMap[$scope];
544 if (is_array($conf)) {
545 $conf[$confKey] = $name;
546 }
547
548 // 可选:同步到 MN_config(字段不存在时忽略)
549 if (isset($DB) && is_object($DB)) {
550 $col = $confKey;
551 $sid = isset($siteid) ? $siteid : 1;
552 @$DB->query_prepare("UPDATE `MN_config` SET `{$col}` = ? WHERE `id` = ?", [$name, $sid]);
553 }
554
555 return [true, '设置成功'];
556}