diff --git a/.well-known/acme-challenge/Fq3HGpcN5ptUnalS7JU6PlaGEhLcbvPAIrtDyvYE5xc b/.well-known/acme-challenge/Fq3HGpcN5ptUnalS7JU6PlaGEhLcbvPAIrtDyvYE5xc new file mode 100644 index 0000000..2eda5c7 --- /dev/null +++ b/.well-known/acme-challenge/Fq3HGpcN5ptUnalS7JU6PlaGEhLcbvPAIrtDyvYE5xc @@ -0,0 +1 @@ +Fq3HGpcN5ptUnalS7JU6PlaGEhLcbvPAIrtDyvYE5xc.krcH3SDD7MS0vIjcTesz3UY027U_QVi9wgmRkOEaVsk \ No newline at end of file diff --git a/.well-known/acme-challenge/OjQVlOAa1eKC8T8zE9bQAiP35uEHXx_eacljapD3X3M b/.well-known/acme-challenge/OjQVlOAa1eKC8T8zE9bQAiP35uEHXx_eacljapD3X3M new file mode 100644 index 0000000..2433968 --- /dev/null +++ b/.well-known/acme-challenge/OjQVlOAa1eKC8T8zE9bQAiP35uEHXx_eacljapD3X3M @@ -0,0 +1 @@ +OjQVlOAa1eKC8T8zE9bQAiP35uEHXx_eacljapD3X3M.krcH3SDD7MS0vIjcTesz3UY027U_QVi9wgmRkOEaVsk \ No newline at end of file diff --git a/PATCH-README.txt b/PATCH-README.txt index b5dbed1..f2fe833 100644 --- a/PATCH-README.txt +++ b/PATCH-README.txt @@ -2,7 +2,7 @@ ================================================== 本压缩包包含「数据库管理」与「数据库升级」两个后台模块的改动, -适用于部署在 coolcoth.com 的站点(需运行 MySQL)。 +适用于部署在 st-joyapparel.com 的站点(需运行 MySQL)。 一、包含文件 ------------ diff --git a/app/Controllers/Admin/AdminController.php b/app/Controllers/Admin/AdminController.php index 2640aac..41f6a15 100644 --- a/app/Controllers/Admin/AdminController.php +++ b/app/Controllers/Admin/AdminController.php @@ -76,6 +76,35 @@ class AdminController extends Controller return 'assets/uploads/' . $name; } + /** + * 处理多文件上传(name="gallery[]"),返回相对站点根路径数组。 + * 逐张复用 uploadFile 的校验逻辑(真实图像/体积上限/类型白名单/SVG 消毒), + * 任一文件失败不影响其余文件。 + */ + protected function uploadFiles(string $key): array + { + if (empty($_FILES[$key]['tmp_name']) || !is_array($_FILES[$key]['tmp_name'])) return []; + $names = $_FILES[$key]['name'] ?? []; + $errors = $_FILES[$key]['error'] ?? []; + $sizes = $_FILES[$key]['size'] ?? []; + $out = []; + foreach ($_FILES[$key]['tmp_name'] as $i => $tmp) { + if (empty($tmp)) continue; + // 桥接到单文件校验逻辑 + $_FILES['_multi_tmp'] = [ + 'name' => $names[$i] ?? 'x.bin', + 'type' => '', + 'tmp_name' => $tmp, + 'error' => $errors[$i] ?? UPLOAD_ERR_OK, + 'size' => $sizes[$i] ?? 0, + ]; + $rel = $this->uploadFile('_multi_tmp'); + unset($_FILES['_multi_tmp']); + if ($rel) $out[] = $rel; + } + return $out; + } + /** 消毒 SVG:移除脚本、事件处理器与危险协议,阻断存储型 XSS */ protected function sanitizeSvg(string $svg): string { diff --git a/app/Controllers/Admin/AuthController.php b/app/Controllers/Admin/AuthController.php index dc76f71..ff2737c 100644 --- a/app/Controllers/Admin/AuthController.php +++ b/app/Controllers/Admin/AuthController.php @@ -13,17 +13,17 @@ class AuthController extends Controller { if (is_admin()) { $this->redirect(login_landing()); } $error = ''; + $blocked = false; + $ip = $_SERVER['REMOTE_ADDR'] ?? ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { - // 质量红线:登录必须校验 CSRF + 验证码 + IP/会话双重失败限速,杜绝机器人暴力破解 - $ip = $_SERVER['REMOTE_ADDR'] ?? ''; + // 质量红线:登录必须校验 CSRF + 验证码 + IP 级双窗口限速(失败 10 分钟/5 次、成功 30 分钟/5 次),杜绝机器人暴力破解 if (!csrf_check()) { $error = '表单已过期,请刷新页面后重试'; } elseif (ip_login_blocked($ip)) { - $error = '尝试次数过多,请 15 分钟后再试'; + $error = '尝试次数过多,请 30 分钟后再试'; + $blocked = true; } elseif (!captcha_check($this->post('captcha'))) { $error = '验证码错误,请重新计算'; - } elseif ($this->isBlocked()) { - $error = '尝试次数过多,请 15 分钟后再试'; } else { $u = trim($this->post('username')); $p = $this->post('password'); @@ -38,8 +38,7 @@ class AuthController extends Controller $user = ['id' => 0, 'username' => $u, 'name' => '管理员', 'role' => 'super_admin']; } if ($ok) { - $this->clearAttempts(); - ip_login_clear($ip); + ip_login_register_success($ip); // 记录成功登录(纳入 30 分钟 5 次上限),并重置失败计数 session_regenerate_id(true); // 防会话固定 $_SESSION['admin_logged'] = true; $_SESSION['admin_id'] = $user['id'] ?? 0; @@ -52,11 +51,15 @@ class AuthController extends Controller $_SESSION['psi_perms'] = $dec($user['psi_perms'] ?? null); $this->redirect(login_landing()); } - $this->registerAttempt(); - ip_login_register($ip); + ip_login_register_fail($ip); // 记录一次失败(纳入 10 分钟 5 次上限) $error = '用户名或密码错误'; } } + // 被限速(IP 失败/成功过多)时返回 429 + Retry-After,明确告知客户端稍后再试 + if ($blocked && !headers_sent()) { + http_response_code(429); + header('Retry-After: ' . ip_login_remaining($ip)); + } $captcha = captcha_make(); // 每次渲染都发放新的算术验证码 return $this->view('admin/login', ['error' => $error, 'captcha' => $captcha]); } @@ -68,25 +71,6 @@ class AuthController extends Controller && \Core\App::config('app.driver', 'file') !== 'mysql'; } - /** 暴力破解限速:单会话 15 分钟内失败 5 次即锁定 */ - private function isBlocked(): bool - { - $t = $_SESSION['login_attempts'] ?? null; - if (!$t || ($t['time'] + 900) < time()) return false; - return $t['count'] >= 5; - } - private function registerAttempt(): void - { - $t = $_SESSION['login_attempts'] ?? ['count' => 0, 'time' => time()]; - if (($t['time'] + 900) < time()) { $t = ['count' => 0, 'time' => time()]; } - $t['count']++; - $_SESSION['login_attempts'] = $t; - } - private function clearAttempts(): void - { - unset($_SESSION['login_attempts']); - } - /** 修改当前登录账号的密码 */ public function password() { diff --git a/app/Controllers/Admin/CaseController.php b/app/Controllers/Admin/CaseController.php index 90b54c8..4d12ab3 100644 --- a/app/Controllers/Admin/CaseController.php +++ b/app/Controllers/Admin/CaseController.php @@ -19,13 +19,18 @@ class CaseController extends AdminController public function create() { - return $this->view('admin/cases_form', ['c' => null]); + return $this->view('admin/cases_form', [ + 'c' => null, + 'mode' => 'fixed', + ]); } public function store() { if (!csrf_check()) { $this->redirect('admin/cases'); } + $mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed'; $cover = $this->uploadFile('cover') ?? $this->post('cover_url', ''); + $content = ($mode === 'fixed') ? $this->post('content', '') : ''; $model = new CustomerCase(); $id = $model->insert([ 'title' => $this->post('title'), @@ -34,48 +39,85 @@ class CaseController extends AdminController 'industry' => $this->post('industry', ''), 'cover' => $cover, 'summary' => $this->post('summary'), - 'content' => $this->post('content'), + 'content' => $content, 'published_at' => $this->post('published_at', date('Y-m-d')), 'sort_order' => (int) $this->post('sort_order', 0), 'status' => $this->post('status', 1) ? 1 : 0, 'views' => 0, 'layout' => $this->post('layout', ''), + 'mode' => $mode, ]); // URL 标识留空时按记录序号顺序生成(短、稳定),避免中文标题导致过长 $slug = $this->post('slug') ? slugify($this->post('slug')) : (string)$id; $model->update($id, ['slug' => $slug]); + // 新建时若选择「可视化编辑」,保存后直接进入可视化编辑器排版 + if ($mode === 'builder') { + $this->redirect('admin/cases/edit/' . $id); + } $this->redirect('admin/cases'); } public function edit($id) { - $case = (new CustomerCase())->find($id); - if (!$case) { $this->redirect('admin/cases'); } - return $this->view('admin/cases_form', ['c' => $case]); + $model = new CustomerCase(); + $c = $model->find($id); + if (!$c) { $this->redirect('admin/cases'); } + $mode = empty($c['mode']) ? 'fixed' : $c['mode']; + if ($mode === 'builder') { + $layout = []; + if (!empty($c['layout'])) { + $dec = json_decode($c['layout'], true); + if (is_array($dec)) $layout = $dec; + } + return $this->view('admin/cases_builder', ['c' => $c, 'layout' => $layout, 'mode' => $mode]); + } + return $this->view('admin/cases_form', ['c' => $c, 'mode' => $mode]); + } + + /** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 mode 字段 */ + public function switchMode($id) + { + $c = (new CustomerCase())->find($id); + if (!$c) { $this->redirect('admin/cases'); } + $target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed'; + (new CustomerCase())->update($id, ['mode' => $target]); + $this->redirect('admin/cases/edit/' . $id); } public function update($id) { if (!csrf_check()) { $this->redirect('admin/cases'); } $model = new CustomerCase(); - $case = $model->find($id); - if (!$case) { $this->redirect('admin/cases'); } + $c = $model->find($id); + if (!$c) { $this->redirect('admin/cases'); } + $mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed'; + $cover = $this->uploadFile('cover'); if (!$cover && $this->post('cover_url')) $cover = $this->post('cover_url'); - if (!$cover) $cover = $case['cover'] ?? ''; - $model->update($id, [ + if (!$cover) $cover = $c['cover'] ?? ''; + + $data = [ 'title' => $this->post('title'), 'slug' => $this->post('slug') ? slugify($this->post('slug')) : (string)$id, 'customer' => $this->post('customer', ''), 'industry' => $this->post('industry', ''), 'cover' => $cover, 'summary' => $this->post('summary'), - 'content' => $this->post('content'), 'published_at' => $this->post('published_at', date('Y-m-d')), 'sort_order' => (int) $this->post('sort_order', 0), 'status' => $this->post('status', 1) ? 1 : 0, - 'layout' => $this->post('layout', ''), - ]); + 'mode' => $mode, + ]; + + if ($mode === 'fixed') { + $data['content'] = $this->post('content', ''); + } else { + $layout = $this->post('layout', ''); + if ($layout !== '' && !is_array(json_decode($layout, true))) { $layout = ''; } + $data['layout'] = $layout; + } + + $model->update($id, $data); $this->redirect('admin/cases'); } diff --git a/app/Controllers/Admin/CategoryController.php b/app/Controllers/Admin/CategoryController.php index 488afb9..3450c10 100644 --- a/app/Controllers/Admin/CategoryController.php +++ b/app/Controllers/Admin/CategoryController.php @@ -15,47 +15,91 @@ class CategoryController extends AdminController public function create() { - return $this->view('admin/category_form', ['c' => null]); + return $this->view('admin/category_form', [ + 'c' => null, + 'mode' => 'fixed', + ]); } public function store() { if (!csrf_check()) { $this->redirect('admin/categories'); } $cat = new Category(); + $mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed'; + $description = ($mode === 'fixed') ? $this->post('description', '') : ''; $id = $cat->insert([ 'name' => $this->post('name'), 'slug' => '', 'icon' => $this->post('icon', '❄'), - 'description' => $this->post('description'), + 'description' => $description, 'sort_order' => (int)$this->post('sort_order', 0), 'status' => $this->post('status', 1) ? 1 : 0, 'layout' => $this->post('layout', ''), + 'mode' => $mode, ]); // URL 标识留空时按记录序号顺序生成(短、稳定) $slug = $this->post('slug') ? slugify($this->post('slug')) : (string)$id; $cat->update($id, ['slug' => $slug]); + // 新建时若选择「可视化编辑」,保存后直接进入可视化编辑器排版 + if ($mode === 'builder') { + $this->redirect('admin/categories/edit/' . $id); + } $this->redirect('admin/categories'); } public function edit($id) + { + $cat = new Category(); + $c = $cat->find($id); + if (!$c) { $this->redirect('admin/categories'); } + $mode = empty($c['mode']) ? 'fixed' : $c['mode']; + if ($mode === 'builder') { + $layout = []; + if (!empty($c['layout'])) { + $dec = json_decode($c['layout'], true); + if (is_array($dec)) $layout = $dec; + } + return $this->view('admin/category_builder', ['c' => $c, 'layout' => $layout, 'mode' => $mode]); + } + return $this->view('admin/category_form', ['c' => $c, 'mode' => $mode]); + } + + /** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 mode 字段 */ + public function switchMode($id) { $c = (new Category())->find($id); if (!$c) { $this->redirect('admin/categories'); } - return $this->view('admin/category_form', ['c' => $c]); + $target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed'; + (new Category())->update($id, ['mode' => $target]); + $this->redirect('admin/categories/edit/' . $id); } public function update($id) { if (!csrf_check()) { $this->redirect('admin/categories'); } - (new Category())->update($id, [ + $cat = new Category(); + $c = $cat->find($id); + if (!$c) { $this->redirect('admin/categories'); } + $mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed'; + + $data = [ 'name' => $this->post('name'), 'slug' => $this->post('slug') ? slugify($this->post('slug')) : (string)$id, 'icon' => $this->post('icon', '❄'), - 'description' => $this->post('description'), 'sort_order' => (int)$this->post('sort_order', 0), 'status' => $this->post('status', 1) ? 1 : 0, - 'layout' => $this->post('layout', ''), - ]); + 'mode' => $mode, + ]; + + if ($mode === 'fixed') { + $data['description'] = $this->post('description', ''); + } else { + $layout = $this->post('layout', ''); + if ($layout !== '' && !is_array(json_decode($layout, true))) { $layout = ''; } + $data['layout'] = $layout; + } + + $cat->update($id, $data); $this->redirect('admin/categories'); } diff --git a/app/Controllers/Admin/NewsController.php b/app/Controllers/Admin/NewsController.php index 702977a..ff6b3ac 100644 --- a/app/Controllers/Admin/NewsController.php +++ b/app/Controllers/Admin/NewsController.php @@ -15,37 +15,67 @@ class NewsController extends AdminController public function create() { - return $this->view('admin/news_form', ['n' => null]); + return $this->view('admin/news_form', [ + 'n' => null, + 'mode' => 'fixed', + ]); } public function store() { if (!csrf_check()) { $this->redirect('admin/news'); } - $cover = $this->uploadFile('cover') ?? $this->post('cover_url', ''); $news = new News(); + $mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed'; + $cover = $this->uploadFile('cover') ?? $this->post('cover_url', ''); + $content = ($mode === 'fixed') ? $this->post('content', '') : ''; $id = $news->insert([ 'title' => $this->post('title'), 'slug' => '', 'cover' => $cover, 'summary' => $this->post('summary'), - 'content' => $this->post('content'), + 'content' => $content, 'author' => $this->post('author', '酷冰甲'), 'published_at' => $this->post('published_at', date('Y-m-d')), 'status' => $this->post('status', 1) ? 1 : 0, 'views' => 0, 'layout' => $this->post('layout', ''), + 'mode' => $mode, ]); // URL 标识留空时按记录序号顺序生成(短、稳定) $slug = $this->post('slug') ? slugify($this->post('slug')) : (string)$id; $news->update($id, ['slug' => $slug]); + // 新建时若选择「可视化编辑」,保存后直接进入可视化编辑器排版 + if ($mode === 'builder') { + $this->redirect('admin/news/edit/' . $id); + } $this->redirect('admin/news'); } public function edit($id) + { + $news = new News(); + $n = $news->find($id); + if (!$n) { $this->redirect('admin/news'); } + $mode = empty($n['mode']) ? 'fixed' : $n['mode']; + if ($mode === 'builder') { + $layout = []; + if (!empty($n['layout'])) { + $dec = json_decode($n['layout'], true); + if (is_array($dec)) $layout = $dec; + } + return $this->view('admin/news_builder', ['n' => $n, 'layout' => $layout, 'mode' => $mode]); + } + return $this->view('admin/news_form', ['n' => $n, 'mode' => $mode]); + } + + /** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 mode 字段 */ + public function switchMode($id) { $n = (new News())->find($id); if (!$n) { $this->redirect('admin/news'); } - return $this->view('admin/news_form', ['n' => $n]); + $target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed'; + (new News())->update($id, ['mode' => $target]); + $this->redirect('admin/news/edit/' . $id); } public function update($id) @@ -54,20 +84,32 @@ class NewsController extends AdminController $news = new News(); $n = $news->find($id); if (!$n) { $this->redirect('admin/news'); } + $mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed'; + $cover = $this->uploadFile('cover'); if (!$cover && $this->post('cover_url')) $cover = $this->post('cover_url'); if (!$cover) $cover = $n['cover'] ?? ''; - $news->update($id, [ + + $data = [ 'title' => $this->post('title'), 'slug' => $this->post('slug') ? slugify($this->post('slug')) : (string)$id, 'cover' => $cover, 'summary' => $this->post('summary'), - 'content' => $this->post('content'), 'author' => $this->post('author', '酷冰甲'), 'published_at' => $this->post('published_at', date('Y-m-d')), 'status' => $this->post('status', 1) ? 1 : 0, - 'layout' => $this->post('layout', ''), - ]); + 'mode' => $mode, + ]; + + if ($mode === 'fixed') { + $data['content'] = $this->post('content', ''); + } else { + $layout = $this->post('layout', ''); + if ($layout !== '' && !is_array(json_decode($layout, true))) { $layout = ''; } + $data['layout'] = $layout; + } + + $news->update($id, $data); $this->redirect('admin/news'); } diff --git a/app/Controllers/Admin/PageController.php b/app/Controllers/Admin/PageController.php index 5945364..d791015 100644 --- a/app/Controllers/Admin/PageController.php +++ b/app/Controllers/Admin/PageController.php @@ -13,32 +13,56 @@ class PageController extends AdminController ]); } + /** 编辑:按页面 mode 渲染对应编辑器(固定版面 / 可视化编辑) */ public function edit($id) { $p = (new Page())->find($id); if (!$p) { $this->redirect('admin/pages'); } - $layout = []; - if (!empty($p['layout'])) { - $dec = json_decode($p['layout'], true); - if (is_array($dec)) $layout = $dec; + $mode = empty($p['mode']) ? 'fixed' : $p['mode']; + if ($mode === 'builder') { + $layout = []; + if (!empty($p['layout'])) { + $dec = json_decode($p['layout'], true); + if (is_array($dec)) $layout = $dec; + } + return $this->view('admin/page_builder', ['p' => $p, 'layout' => $layout, 'mode' => $mode]); } - return $this->view('admin/page_builder', ['p' => $p, 'layout' => $layout]); + return $this->view('admin/page_form', ['p' => $p, 'mode' => $mode]); } + /** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 mode 字段 */ + public function switchMode($id) + { + $p = (new Page())->find($id); + if (!$p) { $this->redirect('admin/pages'); } + $target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed'; + (new Page())->update($id, ['mode' => $target, 'updated_at' => date('Y-m-d')]); + $this->redirect('admin/pages/edit/' . $id); + } + + /** 保存:兼容两种编辑器,按实际提交的字段写入(content / layout / mode) */ public function update($id) { if (!csrf_check()) { $this->redirect('admin/pages'); } - $layout = $this->post('layout', ''); - // 校验:非空的 layout 必须是合法 JSON 数组 - if ($layout !== '') { - $dec = json_decode($layout, true); - if (!is_array($dec)) $layout = ''; - } - (new Page())->update($id, [ + $data = [ 'title' => $this->post('title'), - 'layout' => $layout, 'updated_at' => date('Y-m-d'), - ]); + ]; + if ($this->post('content') !== null) { + $data['content'] = $this->post('content'); + } + if ($this->post('layout') !== null) { + $layout = $this->post('layout', ''); + if ($layout !== '' && !is_array(json_decode($layout, true))) { + $layout = ''; + } + $data['layout'] = $layout; + } + $mode = $this->post('mode'); + if ($mode === 'builder' || $mode === 'fixed') { + $data['mode'] = $mode; + } + (new Page())->update($id, $data); $this->redirect('admin/pages'); } } diff --git a/app/Controllers/Admin/ProductController.php b/app/Controllers/Admin/ProductController.php index 127a43f..70068b9 100644 --- a/app/Controllers/Admin/ProductController.php +++ b/app/Controllers/Admin/ProductController.php @@ -33,6 +33,7 @@ class ProductController extends AdminController return $this->view('admin/product_form', [ 'p' => null, 'cats' => (new Category())->all(), + 'mode' => 'fixed', ]); } @@ -41,25 +42,33 @@ class ProductController extends AdminController if (!csrf_check()) { $this->redirect('admin/products'); } $product = new Product(); $cover = $this->uploadFile('cover') ?? $this->post('cover_url', ''); + $mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed'; + $gallery = ($mode === 'fixed') ? $this->uploadFiles('gallery') : []; + $description = ($mode === 'fixed') ? $this->post('description', '') : ''; $id = $product->insert([ 'category_id' => (int)$this->post('category_id'), 'name' => $this->post('name'), 'slug' => '', 'cover' => $cover, 'summary' => $this->post('summary'), - 'description' => $this->post('description'), + 'description' => $description, 'price' => (float)$this->post('price', 0), 'specs' => $this->specsToJson($this->post('specs', '')), - 'gallery' => '[]', + 'gallery' => json_encode($gallery, JSON_UNESCAPED_UNICODE), 'tags' => $this->post('tags', ''), 'sort_order' => (int)$this->post('sort_order', 0), 'status' => $this->post('status', 1) ? 1 : 0, 'created_at' => date('Y-m-d'), 'layout' => $this->post('layout', ''), + 'mode' => $mode, ]); // URL 标识留空时按记录序号顺序生成(短、稳定) $slug = $this->post('slug') ? slugify($this->post('slug')) : (string)$id; $product->update($id, ['slug' => $slug]); + // 新建时若选择「可视化编辑」,保存后直接进入可视化编辑器排版,无需再手动点编辑 + if ($mode === 'builder') { + $this->redirect('admin/products/edit/' . $id); + } $this->redirect('admin/products'); } @@ -68,39 +77,88 @@ class ProductController extends AdminController $product = new Product(); $p = $product->find($id); if (!$p) { $this->redirect('admin/products'); } + $mode = empty($p['mode']) ? 'fixed' : $p['mode']; $specs = $product->specsArray($p); $specText = ''; foreach ($specs as $s) $specText .= ($s['k'] ?? '') . '|' . ($s['v'] ?? '') . "\n"; + $cats = (new Category())->all(); + if ($mode === 'builder') { + $layout = []; + if (!empty($p['layout'])) { + $dec = json_decode($p['layout'], true); + if (is_array($dec)) $layout = $dec; + } + return $this->view('admin/product_builder', [ + 'p' => $p, + 'cats' => $cats, + 'specText' => $specText, + 'layout' => $layout, + 'mode' => $mode, + ]); + } return $this->view('admin/product_form', [ - 'p' => $p, - 'cats' => (new Category())->all(), - 'specText'=> $specText, + 'p' => $p, + 'cats' => $cats, + 'specText' => $specText, + 'mode' => $mode, ]); } + /** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 mode 字段,其余数据保留 */ + public function switchMode($id) + { + $p = (new Product())->find($id); + if (!$p) { $this->redirect('admin/products'); } + $target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed'; + (new Product())->update($id, ['mode' => $target]); + $this->redirect('admin/products/edit/' . $id); + } + public function update($id) { if (!csrf_check()) { $this->redirect('admin/products'); } $product = new Product(); $p = $product->find($id); if (!$p) { $this->redirect('admin/products'); } + $mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed'; + $cover = $this->uploadFile('cover'); if (!$cover && $this->post('cover_url')) $cover = $this->post('cover_url'); if (!$cover) $cover = $p['cover'] ?? ''; - $product->update($id, [ + + $data = [ 'category_id' => (int)$this->post('category_id'), 'name' => $this->post('name'), 'slug' => $this->post('slug') ? slugify($this->post('slug')) : (string)$id, 'cover' => $cover, 'summary' => $this->post('summary'), - 'description' => $this->post('description'), 'price' => (float)$this->post('price', 0), 'specs' => $this->specsToJson($this->post('specs', '')), 'tags' => $this->post('tags', ''), 'sort_order' => (int)$this->post('sort_order', 0), 'status' => $this->post('status', 1) ? 1 : 0, - 'layout' => $this->post('layout', ''), - ]); + 'mode' => $mode, + ]; + + if ($mode === 'fixed') { + // 固定版面:保存图集与详细描述,保留原有 layout 不被覆盖 + $newGal = $this->uploadFiles('gallery'); + if (!empty($newGal)) { + $data['gallery'] = json_encode($newGal, JSON_UNESCAPED_UNICODE); + } elseif ($this->post('clear_gallery')) { + $data['gallery'] = '[]'; + } else { + $data['gallery'] = $p['gallery'] ?? '[]'; + } + $data['description'] = $this->post('description', ''); + } else { + // 可视化编辑:更新 layout,保留图集/描述原值 + $layout = $this->post('layout', ''); + if ($layout !== '' && !is_array(json_decode($layout, true))) { $layout = ''; } + $data['layout'] = $layout; + } + + $product->update($id, $data); $this->redirect('admin/products'); } diff --git a/app/Controllers/Admin/SettingController.php b/app/Controllers/Admin/SettingController.php index 382aef0..e757987 100644 --- a/app/Controllers/Admin/SettingController.php +++ b/app/Controllers/Admin/SettingController.php @@ -14,7 +14,7 @@ class SettingController extends AdminController ]; private $siteFields = [ 'site_name', 'site_slogan', 'contact_phone', 'contact_email', - 'contact_address', 'icp', 'seo_title', 'seo_keywords', 'seo_description', + 'contact_address', 'site_logo', 'icp', 'gongan', 'seo_title', 'seo_keywords', 'seo_description', ]; private $payFields = [ 'pay_enabled', 'pay_mode', @@ -30,6 +30,18 @@ class SettingController extends AdminController if (csrf_check()) { $pairs = []; foreach ($this->siteFields as $k) $pairs[$k] = $this->post($k, ''); + // 网站 Logo:优先用上传文件,其次用填写的图片路径/网址;两者皆空则保留现值 + $logoUp = $this->uploadFile('logo'); + if ($logoUp !== null) { + $pairs['site_logo'] = $logoUp; + } else { + $url = trim((string) $this->post('logo_url', '')); + if ($url !== '') { + $pairs['site_logo'] = $url; + } else { + unset($pairs['site_logo']); // 不覆盖,保留数据库现有值(含默认商标) + } + } $setting->saveMany($pairs, 'site'); } $this->redirect('admin/settings'); diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php index 06c88b7..4aad315 100644 --- a/app/Controllers/HomeController.php +++ b/app/Controllers/HomeController.php @@ -22,6 +22,25 @@ class HomeController extends Controller 'keywords' => '降温服,降温背心,水冷降温服,相变降温服,制冷背心,工业降温服,消防降温服,高温作业防护,降温服定制,酷冰甲', 'og_type' => 'website', ]); + // ── 首页 FAQ(可见文本 + FAQPage JSON-LD,GEO 高杠杆信号)──── + $faqs = [ + ['q' => '降温服是什么?它是怎么实现降温的?', 'a' => '降温服是一类为高温作业人群设计的主动或被动降温装备,主要通过三种原理散热:水冷循环(微型水泵驱动冷水在服装内管路循环带走体热)、相变蓄冷(冰袋或凝胶相变材料在融化过程中持续吸热)、涡扇风冷(小型风扇强制对流散热)。酷冰甲提供这三大系列,覆盖不同场景与续航需求。'], + ['q' => '穿降温服体感能降多少度?多久能起效?', 'a' => '在常规高温环境下,合格降温服可让核心体表感温度下降约 8–12℃。水冷与风冷方案接通或开机后数分钟内即可感受到明显凉意;相变冰袋方案放入预冷冰袋后即刻生效,单组冰袋可持续 2–4 小时。'], + ['q' => '降温服可以重复使用吗?一套能用多久?', 'a' => '可以。酷冰甲降温服主体为可水洗服装,水冷、风冷模块与相变冰袋均可反复使用。服装本体在正常保养下可用 2–3 个高温季,冰袋与电池模块按使用频率约 1–2 年更换即可。'], + ['q' => '支持企业定制和 LOGO 刺绣吗?起订量多少?', 'a' => '支持。我们提供企业 LOGO 绣字、颜色与面料定制、一人一码量体服务。柔性化生产,10 套起订,确认图纸后 7 天打样、约 28 天批量交付,适合班组、车间等小批量统一配发。'], + ['q' => '降温服适合哪些行业和场景?', 'a' => '广泛用于消防、钢铁、电力、化工、环卫、建筑、物流及户外军训等高温或暴晒场景,也适用于骑行、垂钓、观赛等个人户外降温。可按行业工况推荐对应系列与续航配置。'], + ['q' => '降温服怎么清洗和保养?', 'a' => '服装本体可轻柔机洗或手洗,避免浸泡电子模块;水冷、风冷主机与电池需拆下后擦干存放,冰袋用后擦干冷藏。长期不用请置于阴凉干燥处,电池保持半电存放。'], + ]; + $faqJsonLd = ''; + $data = [ 'pageSeo' => [ 'title' => $seo['title'], @@ -31,6 +50,7 @@ class HomeController extends Controller 'og_image' => $seo['og_image'], 'canonical' => $seo['canonical'], 'noindex' => $seo['noindex'], + 'jsonld' => $faqJsonLd, ], 'banners' => array_filter($banner->all(), fn($b) => ($b['status'] ?? 1) == 1), 'categories'=> $category->all(), @@ -56,6 +76,7 @@ class HomeController extends Controller ['t' => '批量生产', 'd' => '确认图纸后快速打版、批量生产。'], ['t' => '成衣交付', 'd' => '精心包装交付上门,启动售后服务。'], ], + 'faqs' => $faqs, ]; return $this->view('home/index', $data); } diff --git a/app/Controllers/ProductController.php b/app/Controllers/ProductController.php index 6f3f735..8dd5f3a 100644 --- a/app/Controllers/ProductController.php +++ b/app/Controllers/ProductController.php @@ -72,7 +72,7 @@ class ProductController extends Controller $pName = e($p['name'] ?? '产品详情'); $pSummary = mb_substr(strip_tags($p['summary'] ?? $p['body'] ?? ''), 0, 160); - $pImage = $p['image'] ?? ''; + $pImage = $p['cover'] ?? ''; $pPrice = $p['price'] ?? ''; $pSku = $p['sku'] ?? ($p['model'] ?? ''); @@ -92,6 +92,23 @@ class ProductController extends Controller 'availability' => 'https://schema.org/InStock', ]] : []), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . ''; + // ── 产品页 FAQ(可见文本 + FAQPage JSON-LD,GEO 信号)──── + $faqs = [ + ['q' => '这款降温服采用什么降温原理?', 'a' => '根据系列不同,分别采用水冷循环、相变蓄冷或涡扇风冷原理散热:水冷通过微型水泵驱动冷水循环带走体热,相变依靠冰袋/凝胶融化吸热,风冷由风扇强制对流降温。详情可在商品规格表中查看对应方案。'], + ['q' => '一次可使用多长时间?', 'a' => '相变冰袋方案单组可持续 2–4 小时,可随用随换;水冷与风冷方案续航取决于电池容量,具体以商品规格为准,支持备用电池延长作业时间。'], + ['q' => '是否支持企业定制与 LOGO 刺绣?', 'a' => '支持。提供企业 LOGO 绣字、颜色与面料定制、一人一码量体服务,10 套起订,确认图纸后 7 天打样、约 28 天批量交付。'], + ['q' => '如何选择合适的尺码?', 'a' => '提供标准尺码表并支持上门量体,下单后可按身高体重推荐尺码;特殊体型或工种可单独打版,确保合身与活动便利。'], + ]; + $faqSchema = ''; + return $this->view('product/show', [ 'pageSeo' => [ 'title' => $pName, @@ -103,12 +120,13 @@ class ProductController extends Controller ['name' => '产品中心', 'url' => site_url('products')], ['name' => $p['name'] ?? '产品', 'url' => absolute_url()], ], - 'jsonld' => $productSchema, + 'jsonld' => $productSchema . $faqSchema, ], 'p' => $p, 'cat' => $cat, 'related' => array_slice($related, 0, 3), 'specs' => $product->specsArray($p), + 'faqs' => $faqs, ]); } } diff --git a/app/Core/App.php b/app/Core/App.php index 7fc56a5..d15f791 100644 --- a/app/Core/App.php +++ b/app/Core/App.php @@ -17,7 +17,6 @@ class App // 1. 时区 & 会话 date_default_timezone_set(self::config('app.timezone', 'Asia/Shanghai')); if (session_status() !== PHP_SESSION_ACTIVE) { - self::ensureSessionPath(); session_start([ 'cookie_httponly' => true, 'cookie_samesite' => 'Lax', @@ -49,43 +48,6 @@ class App self::dispatch(self::parseRoute()); } - /** - * 确保 session 存储路径可用。 - * 服务器 session.save_path 可能指向不存在/不可写目录(如旧域名残留配置、 - * php_admin_value 锁定等),此时 session_start() 会报 Warning 并失败。 - * 本方法依次尝试:1) session_save_path() 切换到项目本地目录;2) 若被锁则 - * 注册自定义文件 session handler 完全绕过服务器配置。 - */ - private static function ensureSessionPath() - { - $sp = session_save_path(); - // session.save_path 可能带 N;/path 深度前缀,取实际路径部分判断 - $spDir = ($pos = strpos($sp, ';')) !== false ? substr($sp, $pos + 1) : $sp; - if ($spDir !== '' && is_dir($spDir) && is_writable($spDir)) { - return; // 服务器路径正常,无需处理 - } - - $localSession = BASE_PATH . '/storage/sessions'; - if (!is_dir($localSession)) { - @mkdir($localSession, 0755, true); - } - if (!is_dir($localSession) || !is_writable($localSession)) { - return; // 本地目录也建不了,交给 session_start() 原样报错 - } - - // 尝试 1:session_save_path() 切换(php_value 级别可生效) - session_save_path($localSession); - $checkPath = session_save_path(); - $checkDir = ($pos = strpos($checkPath, ';')) !== false ? substr($checkPath, $pos + 1) : $checkPath; - if ($checkDir === $localSession) { - return; // 切换成功 - } - - // 尝试 2:被 php_admin_value 锁定,注册自定义文件 handler 完全绕过 - $handler = new LocalSessionHandler($localSession); - session_set_save_handler($handler, true); - } - /** 解析请求路径为段数组 */ public static function parseRoute(): array { @@ -443,59 +405,3 @@ class App exit; } } - -/** - * 本地文件 session handler —— 当服务器 session.save_path 不可用/被锁时, - * 将 session 数据存到项目 storage/sessions/ 目录,完全绕过服务器配置。 - * 兼容 PHP 7.4 ~ 8.x(不声明返回类型,用 #[\ReturnTypeWillChange] 抑制 8.x 弃用提示)。 - */ -class LocalSessionHandler implements \SessionHandlerInterface -{ - private $dir; - - public function __construct(string $dir) - { - $this->dir = $dir; - } - - public function open($savePath, $sessionName) - { - return is_dir($this->dir) && is_writable($this->dir); - } - - public function close() - { - return true; - } - - #[\ReturnTypeWillChange] - public function read($id) - { - $f = $this->dir . '/sess_' . $id; - return is_file($f) ? (string) @file_get_contents($f) : ''; - } - - public function write($id, $data) - { - return @file_put_contents($this->dir . '/sess_' . $id, $data) !== false; - } - - public function destroy($id) - { - $f = $this->dir . '/sess_' . $id; - return is_file($f) ? @unlink($f) : true; - } - - #[\ReturnTypeWillChange] - public function gc($maxlifetime) - { - $n = 0; - foreach ((array) @glob($this->dir . '/sess_*') as $f) { - if (is_file($f) && filemtime($f) + $maxlifetime < time()) { - @unlink($f); - $n++; - } - } - return $n; - } -} diff --git a/app/Core/Helper.php b/app/Core/Helper.php index 52a2698..23f2945 100644 --- a/app/Core/Helper.php +++ b/app/Core/Helper.php @@ -561,9 +561,15 @@ if (!function_exists('site_url')) { { if (headers_sent()) return; $nonce = csp_nonce(); - // 通用安全响应头(HSTS / X-Frame-Options / X-Content-Type-Options 等)已统一在 - // Nginx 服务器层下发(含静态资源),无需在此重复。 - // 此处仅补充依赖动态随机数的「严格 CSP」——nonce 每次请求不同,必须走 PHP。 + // 通用安全响应头从 PHP 兜底补齐:即便 Nginx 层未下发也不会缺失(防配置漂移)。 + // 与审计整改要求一致:补充 X-Content-Type-Options / Referrer-Policy / Permissions-Policy, + // 并将 HSTS 升级为含 includeSubDomains + preload。若 Nginx 也下发 HSTS,重复为无害, + // 浏览器取更严格项(max-age 取最大值并合并指令)。 + header("X-Content-Type-Options: nosniff"); + header("Referrer-Policy: strict-origin-when-cross-origin"); + header("Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()"); + header("Strict-Transport-Security: max-age=63072000; includeSubDomains; preload"); + // 严格 CSP(nonce 每次请求不同,必须走 PHP) header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"); } @@ -692,35 +698,89 @@ if (!function_exists('site_url')) { exit; } - /* ---------- IP 级失败限速(fail2ban 式,文件缓存,越会话更抗爆破) ---------- */ - function ip_login_blocked(string $ip): bool + /* ---------- IP 级登录限速(fail2ban 式,文件缓存,越会话更抗爆破) ---------- + * 双窗口独立限速(按需求定制): + * · 失败登录:任意 10 分钟内最多 5 次;超出即封锁 30 分钟 + * · 成功登录:任意 30 分钟内最多 5 次;超出即限制(封锁至最早成功滑出 30 分钟窗口) + * 数据文件:storage/login_ip.json —— 每个 IP 记失败/成功时间戳列表 + 封锁截止时间 + * --------------------------------------------------------------------- */ + defined('LOGIN_FAIL_WINDOW') or define('LOGIN_FAIL_WINDOW', 600); // 失败计数窗口:10 分钟 + defined('LOGIN_FAIL_LIMIT') or define('LOGIN_FAIL_LIMIT', 5); // 失败次数上限 + defined('LOGIN_OK_WINDOW') or define('LOGIN_OK_WINDOW', 1800); // 成功计数窗口:30 分钟 + defined('LOGIN_OK_LIMIT') or define('LOGIN_OK_LIMIT', 5); // 成功次数上限 + defined('LOGIN_BLOCK_SECS') or define('LOGIN_BLOCK_SECS', 1800); // 超限后封锁时长:30 分钟 + + function _ip_login_load(): array { $file = BASE_PATH . '/storage/login_ip.json'; - if (!is_file($file)) return false; - $data = json_decode(@file_get_contents($file), true) ?: []; - $now = time(); - if (!isset($data[$ip])) return false; - return $data[$ip]['count'] >= 8; + return is_file($file) ? (json_decode(@file_get_contents($file), true) ?: []) : []; } - function ip_login_register(string $ip): void + function _ip_login_save(array $data): void { $file = BASE_PATH . '/storage/login_ip.json'; if (!is_dir(dirname($file))) @mkdir(dirname($file), 0755, true); - $data = is_file($file) ? (json_decode(@file_get_contents($file), true) ?: []) : []; - $now = time(); - if (!isset($data[$ip]) || ($data[$ip]['time'] + 900) < $now) { - $data[$ip] = ['count' => 0, 'time' => $now]; - } - $data[$ip]['count']++; @file_put_contents($file, json_encode($data)); } + /** 裁剪过期时间戳并按规则重算封锁截止时间(就地修改 $st) */ + function _ip_login_prune(array &$st, int $now): void + { + $st['fail'] = array_values(array_filter((array)($st['fail'] ?? []), fn($t) => ($now - (int)$t) < LOGIN_FAIL_WINDOW)); + $st['ok'] = array_values(array_filter((array)($st['ok'] ?? []), fn($t) => ($now - (int)$t) < LOGIN_OK_WINDOW)); + if (!isset($st['block_until']) || !is_numeric($st['block_until'])) $st['block_until'] = 0; + if ($st['block_until'] <= $now) { + if (count($st['fail']) >= LOGIN_FAIL_LIMIT) { + // 失败 5 次 / 10 分钟 → 锁 30 分钟 + $st['block_until'] = $now + LOGIN_BLOCK_SECS; + } elseif (count($st['ok']) >= LOGIN_OK_LIMIT) { + // 成功 5 次 / 30 分钟 → 锁到最早一次成功滑出窗口 + $oldest = min($st['ok']); + $st['block_until'] = max($now + 60, $oldest + LOGIN_OK_WINDOW); + } + } + } + function ip_login_blocked(string $ip): bool + { + $now = time(); + $st = _ip_login_load()[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0]; + _ip_login_prune($st, $now); + return ($st['block_until'] ?? 0) > $now; + } + /** 返回剩余封锁秒数(已解封为 0),供 Retry-After 使用 */ + function ip_login_remaining(string $ip): int + { + $now = time(); + $st = _ip_login_load()[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0]; + _ip_login_prune($st, $now); + return max(0, (int)($st['block_until'] ?? 0) - $now); + } + function ip_login_register_fail(string $ip): void + { + $now = time(); + $data = _ip_login_load(); + $st = $data[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0]; + _ip_login_prune($st, $now); + $st['fail'][] = $now; + _ip_login_prune($st, $now); // 追加后重新评估是否触发封锁 + $data[$ip] = $st; + _ip_login_save($data); + } + function ip_login_register_success(string $ip): void + { + $now = time(); + $data = _ip_login_load(); + $st = $data[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0]; + _ip_login_prune($st, $now); + $st['fail'] = []; // 成功登录重置失败计数(防爆破计数器归零) + $st['ok'][] = $now; // 记录一次成功,纳入「30 分钟 5 次」上限 + _ip_login_prune($st, $now); + $data[$ip] = $st; + _ip_login_save($data); + } function ip_login_clear(string $ip): void { - $file = BASE_PATH . '/storage/login_ip.json'; - if (!is_file($file)) return; - $data = json_decode(@file_get_contents($file), true) ?: []; + $data = _ip_login_load(); unset($data[$ip]); - @file_put_contents($file, json_encode($data)); + _ip_login_save($data); } /* ---------- 通用 IP 级限速(可用于任意提交场景,如联系表单) ---------- */ @@ -745,3 +805,45 @@ if (!function_exists('site_url')) { @file_put_contents($file, json_encode($data)); } } + +if (!function_exists('page_seo')) { + /** + * 取页面 SEO(标题/描述/关键词/OG/规范链接/收录开关)。 + * 优先读 page_seo 表;无记录或字段缺失时退回控制器传入的默认值。 + * @param string $key page_key(home/products/news/cases/about/contact...) + * @param array $default 默认 SEO 数组(title/description/keywords/og_type) + * @return array {title,description,keywords,og_type,og_image,canonical,noindex} + */ + function page_seo(string $key, array $default = []): array + { + $def = array_merge([ + 'title' => '', + 'description' => '', + 'keywords' => '', + 'og_type' => 'website', + 'og_image' => '', + 'canonical' => '', + 'noindex' => 0, + ], $default); + + try { + $row = (new \App\Models\PageSeo())->getByKey($key); + } catch (\Throwable $e) { + $row = null; + } + + if (!$row) { + return $def; + } + + return [ + 'title' => $row['title'] ?? $def['title'], + 'description' => $row['description'] ?? $def['description'], + 'keywords' => $row['keywords'] ?? $def['keywords'], + 'og_type' => $row['og_type'] ?? $def['og_type'], + 'og_image' => $row['og_image'] ?? $def['og_image'], + 'canonical' => $row['canonical'] ?? $def['canonical'], + 'noindex' => $row['noindex'] ?? $def['noindex'], + ]; + } +} diff --git a/app/Core/Installer.php b/app/Core/Installer.php index 8daedc6..db826be 100644 --- a/app/Core/Installer.php +++ b/app/Core/Installer.php @@ -252,6 +252,21 @@ class Installer private static function ensureColumns($pdo, array &$msgs): void { $map = [ + 'pages' => [ + 'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'", + ], + 'products' => [ + 'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'", + ], + 'news' => [ + 'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'", + ], + 'cases' => [ + 'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'", + ], + 'categories' => [ + 'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'", + ], 'admin_users' => [ 'crm_role' => "VARCHAR(20) DEFAULT 'none'", 'psi_role' => "VARCHAR(20) DEFAULT 'none'", diff --git a/app/Core/Theme.php b/app/Core/Theme.php index 5bac358..f57dd84 100644 --- a/app/Core/Theme.php +++ b/app/Core/Theme.php @@ -16,11 +16,12 @@ class Theme // 站点信息 'site_name' => '酷冰甲 · 降温服', 'site_slogan' => '科技降温 · 清凉一夏', - 'site_logo' => '', + 'site_logo' => 'assets/img/logo.png', 'contact_phone' => '400-1783-998', - 'contact_email' => 'service@coolcoth.com', + 'contact_email' => 'service@st-joyapparel.com', 'contact_address'=> '江苏省苏州市工业园区', 'icp' => '', + 'gongan' => '', // 公安备案号(网安备),如 京公网安备11010802012345号 'seo_title' => '酷冰甲降温服 - 科技降温服装定制', 'seo_keywords' => '降温服, cooling clothing, 降温工作服, 清凉服定制', 'seo_description'=> '酷冰甲专注降温服研发与定制,采用相变蓄冷与循环水冷技术,为高温作业人群提供清凉解决方案。', diff --git a/app/Views/admin/cases.php b/app/Views/admin/cases.php index 2b197e7..27e4d47 100644 --- a/app/Views/admin/cases.php +++ b/app/Views/admin/cases.php @@ -4,11 +4,13 @@
- + + + @@ -23,3 +25,8 @@
封面案例标题客户行业日期状态操作
封面案例标题模式客户行业日期状态操作
🤝
+ diff --git a/app/Views/admin/cases_form.php b/app/Views/admin/cases_form.php index b87a334..e0b67dd 100644 --- a/app/Views/admin/cases_form.php +++ b/app/Views/admin/cases_form.php @@ -1,14 +1,42 @@
-

- ← 返回 +
+

固定版面

+
固定版面:填写案例信息 / 封面,详情使用富文本编辑器排版
+
+ +
+ + +
+ +
+ + +
+
+
-
+ + +
@@ -20,28 +48,120 @@ $isEdit = !empty($c);
+
- -

下方为可视化编辑器:上传素材、拖拽文字/图片/按钮自由排版,拖动右下角缩放、双击文字直接编辑。保存后前台按此布局整页展示。

- 'case', 'layout' => $layoutArr]); - ?> - - + + + +
+

已上传封面;重新选择文件将替换,留空则保留。

+ +
-
-
-
+ +
+ +
+ + + + + + + + + +
+
+ +

直接排版正文即可;点击「图片」可从素材库选择或上传新图。保存后前台固定版式展示。

+
id="st">
+
取消
+ + + + + + + + + diff --git a/app/Views/admin/categories.php b/app/Views/admin/categories.php index 99495d2..ed1217e 100644 --- a/app/Views/admin/categories.php +++ b/app/Views/admin/categories.php @@ -4,12 +4,14 @@
- + + +
图标名称标识描述操作
图标名称标识模式描述操作
@@ -21,3 +23,8 @@
+ diff --git a/app/Views/admin/category_form.php b/app/Views/admin/category_form.php index 3b49c61..22d63b8 100644 --- a/app/Views/admin/category_form.php +++ b/app/Views/admin/category_form.php @@ -1,37 +1,101 @@
-

- ← 返回 +
+

固定版面

+
固定版面:填写分类名称 / 图标 / 描述等基本信息
+
+ +
+ + +
+ +
+ + +
+
+
-
+ + +
+
+
-
- -

下方为可视化编辑器:上传素材、拖拽文字/图片/按钮自由排版。当前分类无独立前台详情页,编辑保存后可用于后续扩展或作为内容版式储备。

- 'category', 'layout' => $layoutArr]); - ?> - + +
+ + +

纯文本描述,保存后前台分类列表展示。如需复杂排版可切换为可视化编辑。

+
id="st">
+
取消
+ + + + diff --git a/app/Views/admin/news.php b/app/Views/admin/news.php index 1dab217..eeebdd4 100644 --- a/app/Views/admin/news.php +++ b/app/Views/admin/news.php @@ -4,11 +4,13 @@
- + + + @@ -22,3 +24,8 @@
封面标题作者日期状态操作
封面标题模式作者日期状态操作
📰
已发布' : '草稿'; ?>
+ diff --git a/app/Views/admin/news_form.php b/app/Views/admin/news_form.php index c77f1d1..fd55c40 100644 --- a/app/Views/admin/news_form.php +++ b/app/Views/admin/news_form.php @@ -1,14 +1,42 @@
-

- ← 返回 +
+

固定版面

+
固定版面:填写标题 / 摘要 / 封面,正文使用富文本编辑器排版
+
+ +
+ + +
+ +
+ + +
+
+
-
+ + +
@@ -16,28 +44,120 @@ $isEdit = !empty($n);
+
- -

下方为可视化编辑器:上传素材、拖拽文字/图片/按钮自由排版,拖动右下角缩放、双击文字直接编辑。保存后前台按此布局整页展示。

- 'news', 'layout' => $layoutArr]); - ?> - - + + + +
+

已上传封面;重新选择文件将替换,留空则保留。

+ +
-
-
-
+ +
+ +
+ + + + + + + + + +
+
+ +

直接排版正文即可;点击「图片」可从素材库选择或上传新图。保存后前台固定版式展示。

+
id="st">
+
取消
+ + + + + + + + + diff --git a/app/Views/admin/page_builder.php b/app/Views/admin/page_builder.php index f047bb2..4a6bc06 100644 --- a/app/Views/admin/page_builder.php +++ b/app/Views/admin/page_builder.php @@ -1,6 +1,10 @@
@@ -8,6 +12,16 @@ $initial = json_encode($layout ?: []);
← 返回 可视化编辑: + 可视化编辑 + +
+ + +
+
@@ -55,6 +69,13 @@ $initial = json_encode($layout ?: []); .pb-topbar{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:14px 18px;background:#fff;border-bottom:1px solid var(--admin-border,#e2e8f0);position:sticky;top:0;z-index:30} .pb-top-left{display:flex;align-items:center;gap:12px} .pb-t{font-weight:700;color:#0f172a} +.mode-switch-sel{display:inline-flex;align-items:center;gap:8px} +.mode-switch-sel label{font-size:13px;color:#64748b} +.mode-sel{border:1px solid #e2e8f0;border-radius:10px;padding:6px 12px;font-size:13px;background:#fff;color:#334155;cursor:pointer;min-width:140px} +.mode-sel:focus{border-color:#0ea5e9;box-shadow:0 0 0 3px rgba(14,165,233,.12)} +.mode-badge{display:inline-block;margin-left:6px;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;vertical-align:middle;line-height:1.7} +.mode-badge.mode-fixed{background:#e0f2fe;color:#0369a1} +.mode-badge.mode-builder{background:#ede9fe;color:#6d28d9} .pb-top-form{display:flex;align-items:center;gap:10px} .pb-title{border:1px solid #e2e8f0;border-radius:10px;padding:8px 12px;font-size:14px;min-width:200px} .pb-workspace{display:flex;gap:0;min-height:72vh} @@ -93,5 +114,7 @@ window.__PB_INIT__ = ; window.__PB_MEDIA__ = ''; window.__PB_UPLOAD__ = ''; window.__PB_CSRF__ = ''; +window.__PB_MODULE__ = 'page'; +window.__PB_DELETE__ = ''; diff --git a/app/Views/admin/page_form.php b/app/Views/admin/page_form.php index 59f963b..b14f6c0 100644 --- a/app/Views/admin/page_form.php +++ b/app/Views/admin/page_form.php @@ -1,15 +1,103 @@ +
-

编辑单页:

支持 HTML 标签
- ← 返回 +
+

编辑单页:固定版面

+
固定版面:页面套用统一模板(标题区 + 正文 + 联系按钮),您只需编辑正文内容。
+
+ +
+ + +
+
+
- + +
-
+ +
+ +
+ + + + + + + + + +
+
+ +

直接排版正文即可;切换「可视化编辑」可自由拖拽布局。保存后页面以统一版式展示。

+
+
取消
+ + + + + + + + diff --git a/app/Views/admin/pages.php b/app/Views/admin/pages.php index 9ffd213..ed1a629 100644 --- a/app/Views/admin/pages.php +++ b/app/Views/admin/pages.php @@ -3,14 +3,21 @@
- + + +
标识标题更新时间操作
标识标题模式更新时间操作
编辑
+
diff --git a/app/Views/admin/product_form.php b/app/Views/admin/product_form.php index f2f73c3..4a27121 100644 --- a/app/Views/admin/product_form.php +++ b/app/Views/admin/product_form.php @@ -1,15 +1,46 @@
-

填写产品信息,封面留空将使用渐变占位图
- ← 返回列表 +
+

固定版面

+
固定版面:填写资料并上传封面 / 图集,图片自适应展示
+
+ +
+ + +
+ +
+ + +
+
-
+ + +
@@ -23,21 +54,44 @@ $isEdit = !empty($p);
+
- -

下方为可视化编辑器:上传素材、拖拽文字/图片/按钮自由排版,拖动右下角缩放、双击文字直接编辑。保存后前台按此布局整页展示;可放置「购买按钮 / 价格 / 规格参数」专用块(自动读取本产品数据)。

- 'product', 'layout' => $layoutArr]); - ?> - + + + +
+

已上传封面;重新选择文件将替换,留空则保留。

+ +
-
-
-
+
+ + + + + + +
+ +
+ +
+ + + + + + + + + +
+
+ +

直接排版正文即可;点击「图片」可从素材库选择或上传新图。保存后前台固定版式展示。

@@ -60,3 +114,86 @@ $isEdit = !empty($p);
+ + + + + + + + + diff --git a/app/Views/admin/products.php b/app/Views/admin/products.php index 484b1ca..08fa064 100644 --- a/app/Views/admin/products.php +++ b/app/Views/admin/products.php @@ -9,13 +9,15 @@ $cmap = []; foreach ($cm->all() as $c) $cmap[$c['id']] = $c['name'];
- + + - + +
封面名称分类价格状态操作
封面名称分类价格模式状态操作

¥ 已上线' : '草稿'; ?>
@@ -26,4 +28,9 @@ $cmap = []; foreach ($cm->all() as $c) $cmap[$c['id']] = $c['name'];
+
diff --git a/app/Views/admin/settings.php b/app/Views/admin/settings.php index 4768b4b..0259f90 100644 --- a/app/Views/admin/settings.php +++ b/app/Views/admin/settings.php @@ -2,7 +2,7 @@

站点设置

网站名称、联系方式与 SEO 信息
-
+
@@ -11,7 +11,14 @@
-
+
+ + + 选择图片上传将替换当前 Logo;也可在下方直接填写图片路径或网址(留空则保留现有)。当前: + +
+
+

diff --git a/app/Views/cases/show.php b/app/Views/cases/show.php index c4d5c66..e7a05e8 100644 --- a/app/Views/cases/show.php +++ b/app/Views/cases/show.php @@ -2,7 +2,8 @@ $c = $c ?? null; $layoutArr = []; if (!empty($c['layout'])) { $d = json_decode($c['layout'], true); if (is_array($d)) $layoutArr = $d; } -$hasCanvas = !empty($layoutArr); +// 模式渲染:可视化编辑(builder)且有 layout → 画布;否则固定版式(正文富文本) +$useBuilder = !empty($layoutArr) && ($c['mode'] ?? '') !== 'fixed'; ?>
@@ -14,12 +15,12 @@ $hasCanvas = !empty($layoutArr);
- + $layoutArr, 'item' => $c, 'module' => 'case']); ?>
-
+
diff --git a/app/Views/home/index.php b/app/Views/home/index.php index bcee709..08e1a7f 100644 --- a/app/Views/home/index.php +++ b/app/Views/home/index.php @@ -54,7 +54,11 @@ $banner = $banners[0] ?? ['title' => '科技降温 · 清凉一夏', 'subtitle'
+
+
+
+

常见问题

+

关于降温服,您可能想了解

+

高频疑问一站式解答,定制与选型更省心。

+
+
+ +
+ + +
+
+ +
+
+
+
diff --git a/app/Views/layouts/site.php b/app/Views/layouts/site.php index 1d87927..211006d 100644 --- a/app/Views/layouts/site.php +++ b/app/Views/layouts/site.php @@ -9,6 +9,7 @@ $logo = $site['site_logo']; $defaultMode = $site['default_mode']; $headerStyle = $site['header']; $icp = $site['icp']; +$gongan = $site['gongan'] ?? ''; // ── 页面级 SEO 数据(控制器通过 $this->view() 或 $data 传入)──── $pageSeo = $pageSeo ?? []; @@ -137,8 +138,8 @@ $isActive = function ($url) use ($current) {