仰望星辰工作室

better-staridc-MNBT

better-staridc-MNBT/ MPHX/frontend.php 17.9 KB · 465 行 原始文件
Z zfhsh first commit 2 天前
1<?php
2/**
3 * MNBT 独立主页系统(V1.84)
4 *
5 * 主页(站点根路径 /)作为核心一等公民,不依赖插件引擎:
6 * - index.php 在插件首页接管(mnbt_plugin_dispatch_home)之后调用 mnbt_home_dispatch()
7 * - 仅当后台开启 home_enable 且无插件接管时,渲染内置默认主页
8 * - 默认主页模板:templates/{当前用户主题}/home/index.php,缺页回退 templates/default/home/index.php
9 * - 插件仍可通过 mnbt_register_home 优先覆盖主页;可通过 home.blocks 过滤器注入扩展区块
10 *
11 * 关联文档:docs/HOME_PRD.md
12 */
13if (!defined('IN_CRONLITE')) {
14 exit('Access Denied');
15}
16
17/** 站点 base path(子目录部署前缀) */
18function mnbt_home_base(): string {
19 if (function_exists('mnbt_plugin_request_info')) {
20 $info = mnbt_plugin_request_info();
21 return $info['base'] ?? '';
22 }
23 $scriptName = isset($_SERVER['SCRIPT_NAME']) ? str_replace('\\', '/', $_SERVER['SCRIPT_NAME']) : '';
24 $base = rtrim(str_replace('\\', '/', dirname($scriptName)), '/');
25 return ($base === '.' || $base === '/') ? '' : $base;
26}
27
28/** 读取主页配置(MN_config home_* 字段,空值回退默认) */
29function mnbt_home_option($key, $default = null)
30{
31 global $conf;
32 if (is_array($conf) && isset($conf[$key]) && $conf[$key] !== '') {
33 return $conf[$key];
34 }
35 return $default;
36}
37
38/** 是否启用内置主页 */
39function mnbt_home_enabled(): bool
40{
41 return mnbt_home_option('home_enable', 'true') === 'true';
42}
43
44/** 生成带 base 前缀的路由 URL(index.php?_r=/path) */
45function mnbt_home_url(string $path = ''): string
46{
47 $base = mnbt_home_base();
48 $p = ltrim($path, '/');
49 $qpos = strpos($p, '?');
50 if ($qpos !== false) {
51 $route = substr($p, 0, $qpos);
52 $query = substr($p, $qpos + 1);
53 return $base . '/index.php?_r=/' . $route . '&' . $query;
54 }
55 return $base . '/index.php?_r=/' . $p;
56}
57
58/** 生成带 base 前缀的核心物理文件 URL(如 user/login.php) */
59function mnbt_home_core_url(string $path = ''): string
60{
61 return mnbt_home_base() . '/' . ltrim($path, '/');
62}
63
64/** 资源 URL:绝对地址(http(s):// 或 / 开头)原样返回,相对路径加 base 前缀 */
65function mnbt_home_asset($path): string
66{
67 $path = (string)$path;
68 if ($path === '') {
69 return '';
70 }
71 if (preg_match('#^https?://#i', $path) || strpos($path, '/') === 0) {
72 return $path;
73 }
74 return mnbt_home_base() . '/' . ltrim($path, '/');
75}
76
77/** 解析主页模板路径:当前主页主题(templates/{theme}/home/)→ default 主题回退 */
78function mnbt_home_resolve(): ?string
79{
80 $theme = function_exists('mnbt_theme_name') ? mnbt_theme_name('home') : 'default';
81 $root = defined('MNBT_THEME_ROOT') ? MNBT_THEME_ROOT : (ROOT . 'templates/');
82 $candidates = [$root . $theme . '/home/index.php'];
83 if ($theme !== 'default') {
84 $candidates[] = $root . 'default/home/index.php';
85 }
86 foreach ($candidates as $p) {
87 if (is_file($p)) {
88 return $p;
89 }
90 }
91 return null;
92}
93
94/** 组装主页数据(模板注入变量) */
95function mnbt_home_data(): array
96{
97 global $DB, $conf;
98
99 $siteTitle = mnbt_home_option('home_title', '') ?: ($conf['name'] ?? 'MNBT');
100
101 $data = [
102 'site_title' => $siteTitle,
103 'site_logo' => mnbt_home_asset(mnbt_home_option('home_logo', '') ?: 'imsetes/upload_logo/logo.index.png'),
104 'site_primary' => mnbt_home_option('home_primary', '#4f46e5'),
105 'site_hero' => mnbt_home_option('home_hero', '高性能虚拟主机,即买即用'),
106 'site_footer' => mnbt_home_option('home_footer', '') ?: ($conf['hxp'] ?? ''),
107 'favicon' => mnbt_home_asset(mnbt_home_option('home_favicon', '') ?: 'imsetes/images/logo-ico.png'),
108 // 公告与区块开关
109 'notice' => mnbt_home_option('gg', ''),
110 'show_notice' => mnbt_home_option('home_show_notice', 'true') === 'true',
111 'show_plans' => mnbt_home_option('home_show_plans', 'true') === 'true',
112 // 登录态与能力探测(不强依赖插件)
113 'logged_in' => isset($_COOKIE['user_token']) && $_COOKIE['user_token'] !== '',
114 'has_shop' => function_exists('mnbt_plugin_enabled') && mnbt_plugin_enabled('hosting_shop'),
115 'has_balance' => function_exists('mnbt_plugin_enabled') && mnbt_plugin_enabled('balance'),
116 'has_user' => function_exists('mnbt_plugin_enabled') && mnbt_plugin_enabled('user_info'),
117 'has_site' => function_exists('mnbt_plugin_enabled') && mnbt_plugin_enabled('official_site'),
118 'has_docker' => function_exists('mnbt_plugin_enabled') && mnbt_plugin_enabled('docker_shop'),
119 // 业务数据
120 'plans' => [],
121 'blocks' => [],
122 // URL 生成回调
123 'url' => function (string $path = '') { return mnbt_home_url($path); },
124 'coreUrl' => function (string $path = '') { return mnbt_home_core_url($path); },
125 ];
126
127 // 套餐区(hosting_shop 启用且有有效套餐时展示,查询失败静默降级为空)
128 if ($data['has_shop'] && $data['show_plans'] && isset($DB)) {
129 $rows = @$DB->get_all_prepare("SELECT * FROM MN_plugin_hosting_plan WHERE status='active' ORDER BY sort ASC, id ASC") ?: [];
130 foreach ($rows as $p) {
131 $minPrice = 0;
132 if ((int)($p['price_month_cents'] ?? 0) > 0) {
133 $minPrice = (int)$p['price_month_cents'] / 100;
134 }
135 if ((int)($p['price_year_cents'] ?? 0) > 0) {
136 $yearPrice = (int)$p['price_year_cents'] / 100;
137 if ($minPrice == 0 || $yearPrice / 12 < $minPrice) {
138 $minPrice = $yearPrice / 12;
139 }
140 }
141 $feats = [];
142 if (!empty($p['spec_web'])) $feats[] = '网页空间 ' . $p['spec_web'] . ' MB';
143 if (!empty($p['spec_sql'])) $feats[] = '数据库 ' . $p['spec_sql'] . ' MB';
144 if (!empty($p['spec_flow'])) $feats[] = '月流量 ' . $p['spec_flow'] . ' GB';
145 if (!empty($p['spec_domain'])) $feats[] = '可绑定 ' . $p['spec_domain'] . ' 个域名';
146 $data['plans'][] = [
147 'id' => (int)$p['id'],
148 'name' => (string)$p['name'],
149 'desc' => (string)($p['description'] ?? ''),
150 'price' => $minPrice > 0 ? '¥' . number_format($minPrice, 2) . ' 起/月' : '免费',
151 'feats' => $feats,
152 ];
153 }
154 }
155
156 // 插件扩展区块(home.blocks 过滤器,按 order 升序;仅取结构化字段)
157 if (function_exists('mnbt_apply_filters')) {
158 $blocks = mnbt_apply_filters('home.blocks', []);
159 if (is_array($blocks)) {
160 $clean = [];
161 foreach ($blocks as $b) {
162 if (!is_array($b) || !isset($b['html'])) {
163 continue;
164 }
165 $clean[] = [
166 'id' => (string)($b['id'] ?? ''),
167 'title' => (string)($b['title'] ?? ''),
168 'html' => (string)$b['html'],
169 'order' => (int)($b['order'] ?? 50),
170 ];
171 }
172 usort($clean, function ($a, $b) {
173 return $a['order'] - $b['order'];
174 });
175 $data['blocks'] = $clean;
176 }
177 }
178
179 return $data;
180}
181
182/** 渲染内置主页(组装数据 → 加载主题模板 → 终止请求) */
183function mnbt_home_render(): void
184{
185 $path = mnbt_home_resolve();
186 if ($path === null) {
187 http_response_code(500);
188 echo 'Home template not found';
189 exit;
190 }
191 if (!headers_sent()) {
192 @header('Content-Type: text/html; charset=UTF-8');
193 }
194 $vars = mnbt_home_data();
195 $bufferLevel = ob_get_level();
196 ob_start('mnbt_csrf_inject_html');
197 try {
198 extract($vars, EXTR_SKIP);
199 include $path;
200 } finally {
201 while (ob_get_level() > $bufferLevel) {
202 ob_end_flush();
203 }
204 }
205 exit;
206}
207
208/** 分发入口:index.php 在插件首页接管之后调用;仅路径为 / 且启用时生效 */
209function mnbt_home_dispatch(): bool
210{
211 if (!function_exists('mnbt_plugin_request_info')) {
212 return false;
213 }
214 $info = mnbt_plugin_request_info();
215 if ($info['path'] !== '/') {
216 return false;
217 }
218 if (!mnbt_home_enabled()) {
219 return false;
220 }
221 mnbt_home_render();
222 return true;
223}
224
225/* ============================================================
226 * V1.84 主题注册自定义设置(结构化字段声明)
227 * ============================================================
228 *
229 * 主页主题可在 templates/{theme}/theme.php 中声明自定义设置字段:
230 *
231 * mnbt_register_home_setting([
232 * 'key' => 'bg_color',
233 * 'label' => '背景颜色',
234 * 'type' => 'color', // text | color | select | switch | textarea | number | image
235 * 'default' => '#f0f4ff',
236 * 'placeholder' => '#f0f4ff',
237 * 'hint' => '可选提示文本',
238 * 'options' => [['value'=>'a','label'=>'A'], ...], // select 专用
239 * ]);
240 *
241 * 字段统一持久化到 MN_config.home_theme_settings(JSON),模板端通过
242 * mnbt_home_theme_setting($key, $default) 读取当前值。
243 *
244 * 渲染由当前 Admin 主题负责(default 用原生 HTML,tdesign 用 TDesign 组件),
245 * 主题只需声明字段结构,不需要关心样式。
246 */
247
248$GLOBALS['mnbt_home_settings_fields'] = [];
249
250/** 注册主页自定义设置字段(由主题 theme.php 调用) */
251function mnbt_register_home_setting(array $config): bool
252{
253 $key = trim((string)($config['key'] ?? ''));
254 if ($key === '' || !preg_match('/^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/', $key)) {
255 return false;
256 }
257 $type = (string)($config['type'] ?? 'text');
258 if (!in_array($type, ['text', 'color', 'select', 'switch', 'textarea', 'number', 'image'], true)) {
259 $type = 'text';
260 }
261 if (isset($GLOBALS['mnbt_home_settings_fields'][$key])) {
262 return false; // 重复 key
263 }
264 $GLOBALS['mnbt_home_settings_fields'][$key] = [
265 'key' => $key,
266 'label' => (string)($config['label'] ?? $key),
267 'type' => $type,
268 'default' => $config['default'] ?? ($type === 'switch' ? false : ''),
269 'placeholder' => (string)($config['placeholder'] ?? ''),
270 'hint' => (string)($config['hint'] ?? ''),
271 'options' => ($type === 'select' && isset($config['options']) && is_array($config['options'])) ? $config['options'] : [],
272 ];
273 return true;
274}
275
276/** 读取当前主页主题所有已保存的自定义设置值 */
277function mnbt_home_theme_settings_all(): array
278{
279 global $conf;
280 if (is_array($conf) && !empty($conf['home_theme_settings'])) {
281 $v = $conf['home_theme_settings'];
282 if (is_string($v) && $v !== '') {
283 $decoded = json_decode($v, true);
284 if (is_array($decoded)) return $decoded;
285 }
286 }
287 return [];
288}
289
290/** 读取单个主题自定义设置值(供模板使用) */
291function mnbt_home_theme_setting(string $key, $default = null)
292{
293 $all = mnbt_home_theme_settings_all();
294 return $all[$key] ?? $default;
295}
296
297/** 获取已注册字段列表(确保 theme.php 已加载) */
298function mnbt_home_get_settings_fields(): array
299{
300 mnbt_home_ensure_theme_loaded();
301 return $GLOBALS['mnbt_home_settings_fields'] ?? [];
302}
303
304/** 合并字段定义 + 已存值 → 渲染用数据 */
305function mnbt_home_settings_view_data(): array
306{
307 $fields = mnbt_home_get_settings_fields();
308 $values = mnbt_home_theme_settings_all();
309 $result = [];
310 foreach ($fields as $f) {
311 $key = $f['key'];
312 $item = $f;
313 $item['value'] = $values[$key] ?? $f['default'];
314 $result[] = $item;
315 }
316 return $result;
317}
318
319/** 加载当前主页主题的 theme.php(若存在),确保字段已注册 */
320function mnbt_home_ensure_theme_loaded(): void
321{
322 static $done = false;
323 if ($done) return;
324 $done = true;
325 if (function_exists('mnbt_theme_ensure_loaded')) {
326 mnbt_theme_ensure_loaded('home');
327 }
328}
329
330/** 保存主题自定义设置(从 POST 收集注册字段的值,合并 JSON 写入 MN_config) */
331function mnbt_home_settings_save(): void
332{
333 mnbt_home_ensure_theme_loaded();
334 $fields = $GLOBALS['mnbt_home_settings_fields'] ?? [];
335 if (empty($fields)) return;
336 global $DB, $siteid;
337 $existing = mnbt_home_theme_settings_all();
338 foreach ($fields as $f) {
339 $k = $f['key'];
340 if ($f['type'] === 'switch') {
341 $existing[$k] = (isset($_POST['home_ts_' . $k]) && $_POST['home_ts_' . $k] === 'true');
342 } else {
343 $existing[$k] = trim((string)($_POST['home_ts_' . $k] ?? ''));
344 }
345 }
346 $json = json_encode($existing, JSON_UNESCAPED_UNICODE);
347 if ($json === false) $json = '{}';
348 $sid = isset($siteid) ? $siteid : 1;
349 @$DB->query_prepare("UPDATE `MN_config` SET `home_theme_settings` = ? WHERE `id` = ?", [$json, $sid]);
350}
351
352/* ============================================================
353 * 默认 Admin 主题渲染器
354 * ============================================================ */
355
356/** 在 default 主题后台渲染主页自定义设置字段 HTML */
357function mnbt_home_render_settings_fields_default(): string
358{
359 $items = mnbt_home_settings_view_data();
360 if (empty($items)) return '';
361 $html = '';
362 foreach ($items as $f) {
363 $label = htmlspecialchars($f['label']);
364 $key = 'home_ts_' . $f['key'];
365 $hint = $f['hint'] !== '' ? '<small>' . htmlspecialchars($f['hint']) . '</small>' : '';
366 $val = htmlspecialchars((string)$f['value']);
367 $ph = htmlspecialchars($f['placeholder']);
368 switch ($f['type']) {
369 case 'color':
370 $html .= '<div class="mn-set-field"><label>' . $label . '</label>'
371 . '<div style="display:flex;align-items:center;gap:10px;">'
372 . '<input type="color" value="' . $val . '" style="width:46px;height:34px;padding:2px;border:1px solid #ced4da;border-radius:4px;background:#fff;cursor:pointer;"'
373 . ' oninput="document.getElementById(\'hts_' . $f['key'] . '_hex\').value=this.value">'
374 . '<input type="text" class="form-control" id="hts_' . $f['key'] . '_hex" name="' . $key . '" value="' . $val . '" style="max-width:140px;" placeholder="' . $ph . '"/>'
375 . '</div>' . $hint . '</div>';
376 break;
377 case 'switch':
378 $checked = $f['value'] ? 'checked' : '';
379 $html .= '<div class="mn-set-field"><div class="mn-set-switch">'
380 . '<div class="mn-set-switch-txt"><strong>' . $label . '</strong>'
381 . ($f['hint'] !== '' ? '<span>' . htmlspecialchars($f['hint']) . '</span>' : '')
382 . '</div>'
383 . '<div class="custom-control custom-switch">'
384 . '<input type="checkbox" class="custom-control-input" id="hts_' . $f['key'] . '" ' . $checked . '>'
385 . '<label class="custom-control-label" for="hts_' . $f['key'] . '"></label>'
386 . '<input type="hidden" name="' . $key . '" value="false" data-switch="hts_' . $f['key'] . '">'
387 . '</div></div></div>';
388 break;
389 case 'select':
390 $html .= '<div class="mn-set-field"><label>' . $label . '</label>'
391 . '<select class="form-control" name="' . $key . '">';
392 if (!empty($f['options'])) {
393 foreach ($f['options'] as $opt) {
394 $ov = htmlspecialchars((string)($opt['value'] ?? ''));
395 $ol = htmlspecialchars((string)($opt['label'] ?? $ov));
396 $sel = ($ov === (string)$f['value']) ? ' selected' : '';
397 $html .= '<option value="' . $ov . '"' . $sel . '>' . $ol . '</option>';
398 }
399 }
400 $html .= '</select>' . $hint . '</div>';
401 break;
402 case 'textarea':
403 $html .= '<div class="mn-set-field"><label>' . $label . '</label>'
404 . '<textarea class="form-control" name="' . $key . '" rows="4" placeholder="' . $ph . '">' . $val . '</textarea>'
405 . $hint . '</div>';
406 break;
407 case 'number':
408 $html .= '<div class="mn-set-field"><label>' . $label . '</label>'
409 . '<input type="number" class="form-control" name="' . $key . '" value="' . $val . '" placeholder="' . $ph . '"/>'
410 . $hint . '</div>';
411 break;
412 case 'image':
413 $prevStyle = $val !== '' ? '' : ' style="display:none"';
414 $html .= '<div class="mn-set-field"><label>' . $label . '</label>'
415 . '<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">'
416 . '<img id="' . $key . '_preview" src="' . ($val !== '' ? '../' . htmlspecialchars($val, ENT_QUOTES, 'UTF-8') : '') . '"'
417 . ' alt="" style="width:120px;height:76px;object-fit:cover;border:1px solid #e5e7eb;border-radius:8px;background:#f8f9fa;"' . $prevStyle . '>'
418 . '<input type="file" id="' . $key . '_file" accept=".png,.jpg,.jpeg,.gif,.webp,.ico" style="display:none">'
419 . '<button type="button" class="btn btn-outline-secondary" onclick="document.getElementById(\'' . $key . '_file\').click()">上传图片</button>'
420 . '<button type="button" class="btn btn-outline-secondary" onclick="var i=document.getElementById(\'' . $key . '\');i.value=\'\';var p=document.getElementById(\'' . $key . '_preview\');p.style.display=\'none\';p.src=\'\';">清除</button>'
421 . '</div>'
422 . '<input type="text" class="form-control" id="' . $key . '" name="' . $key . '" data-home-image="' . $key . '" value="' . $val . '" placeholder="' . $ph . '"/>'
423 . $hint . '</div>';
424 break;
425 default: // text
426 $html .= '<div class="mn-set-field"><label>' . $label . '</label>'
427 . '<input type="text" class="form-control" name="' . $key . '" value="' . $val . '" placeholder="' . $ph . '"/>'
428 . $hint . '</div>';
429 break;
430 }
431 }
432 // switch 值同步脚本
433 $html .= '<script>(function(){'
434 . 'document.querySelectorAll(\'input[data-switch]\').forEach(function(el){'
435 . 'var cbId=el.getAttribute(\'data-switch\');'
436 . 'var cb=document.getElementById(cbId);'
437 . 'if(!cb)return;'
438 . 'el.value=cb.checked?"true":"false";'
439 . 'cb.addEventListener("change",function(){el.value=cb.checked?"true":"false"})'
440 . '})})()</script>';
441 // image 字段上传脚本(POST 到 ./ajax.php?gn=home_upload_image)
442 $html .= '<script>(function(){'
443 . 'document.querySelectorAll(\'input[data-home-image]\').forEach(function(input){'
444 . 'var k=input.getAttribute(\'data-home-image\');'
445 . 'var file=document.getElementById(k+\'_file\');'
446 . 'if(!file)return;'
447 . 'file.addEventListener(\'change\',function(){'
448 . 'if(!file.files||!file.files.length)return;'
449 . 'var fd=new FormData();'
450 . 'fd.append(\'gn\',\'home_upload_image\');'
451 . 'fd.append(\'key\',k.replace(\'home_ts_\',\'\'));'
452 . 'fd.append(\'image\',file.files[0]);'
453 . '$.ajax({url:\'./ajax.php\',type:\'POST\',data:fd,processData:false,contentType:false,'
454 . 'success:function(r){var j;try{j=typeof r===\'string\'?JSON.parse(r):r;}catch(e){j={code:\'响应解析失败\'};}'
455 . 'if(j.code===\'上传成功\'){var rel=\'imsetes/upload_logo/home_\'+k.replace(\'home_ts_\',\'\')+\'.png\';'
456 . 'input.value=rel;var p=document.getElementById(k+\'_preview\');'
457 . 'if(p){p.src=\'../\'+rel;p.style.display=\'\';}'
458 . 'if(typeof msalert===\'function\'){msalert(1,\'上传成功,请点击保存\',2000);}'
459 . '}else{if(typeof msalert===\'function\'){msalert(4,j.code||\'上传失败\',2000);}}'
460 . '},error:function(){if(typeof msalert===\'function\'){msalert(4,\'网络错误,请重试\',2000);}}'
461 . '});'
462 . '})'
463 . '})})()</script>';
464 return $html;
465}