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 @@ |
| 图标 | 名称 | 标识 | 描述 | 操作 | |
|---|---|---|---|---|---|
| 图标 | 名称 | 标识 | 模式 | 描述 | 操作 |
| + |
@@ -21,3 +23,8 @@
|
| 封面 | 标题 | 作者 | 日期 | 状态 | 操作 | |
|---|---|---|---|---|---|---|
| 封面 | 标题 | 模式 | 作者 | 日期 | 状态 | 操作 |
📰 |
+ | 已发布' : '草稿'; ?> | @@ -22,3 +24,8 @@