This commit is contained in:
2026-08-08 17:19:44 +08:00
parent 9276ed7358
commit dc0d2622f3
76 changed files with 1384 additions and 376 deletions
@@ -0,0 +1 @@
Fq3HGpcN5ptUnalS7JU6PlaGEhLcbvPAIrtDyvYE5xc.krcH3SDD7MS0vIjcTesz3UY027U_QVi9wgmRkOEaVsk
@@ -0,0 +1 @@
OjQVlOAa1eKC8T8zE9bQAiP35uEHXx_eacljapD3X3M.krcH3SDD7MS0vIjcTesz3UY027U_QVi9wgmRkOEaVsk
+1 -1
View File
@@ -2,7 +2,7 @@
==================================================
本压缩包包含「数据库管理」与「数据库升级」两个后台模块的改动,
适用于部署在 coolcoth.com 的站点(需运行 MySQL)。
适用于部署在 st-joyapparel.com 的站点(需运行 MySQL)。
一、包含文件
------------
+29
View File
@@ -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
{
+12 -28
View File
@@ -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()
{
+54 -12
View File
@@ -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');
}
+51 -7
View File
@@ -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');
}
+50 -8
View File
@@ -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');
}
+38 -14
View File
@@ -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');
}
}
+67 -9
View File
@@ -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');
}
+13 -1
View File
@@ -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');
+21
View File
@@ -22,6 +22,25 @@ class HomeController extends Controller
'keywords' => '降温服,降温背心,水冷降温服,相变降温服,制冷背心,工业降温服,消防降温服,高温作业防护,降温服定制,酷冰甲',
'og_type' => 'website',
]);
// ── 首页 FAQ(可见文本 + FAQPage JSON-LDGEO 高杠杆信号)────
$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 = '<script type="application/ld+json">' . json_encode([
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => array_map(fn($f) => [
'@type' => 'Question',
'name' => $f['q'],
'acceptedAnswer' => ['@type' => 'Answer', 'text' => $f['a']],
], $faqs),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . '</script>';
$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);
}
+20 -2
View File
@@ -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) . '</script>';
// ── 产品页 FAQ(可见文本 + FAQPage JSON-LDGEO 信号)────
$faqs = [
['q' => '这款降温服采用什么降温原理?', 'a' => '根据系列不同,分别采用水冷循环、相变蓄冷或涡扇风冷原理散热:水冷通过微型水泵驱动冷水循环带走体热,相变依靠冰袋/凝胶融化吸热,风冷由风扇强制对流降温。详情可在商品规格表中查看对应方案。'],
['q' => '一次可使用多长时间?', 'a' => '相变冰袋方案单组可持续 2–4 小时,可随用随换;水冷与风冷方案续航取决于电池容量,具体以商品规格为准,支持备用电池延长作业时间。'],
['q' => '是否支持企业定制与 LOGO 刺绣?', 'a' => '支持。提供企业 LOGO 绣字、颜色与面料定制、一人一码量体服务,10 套起订,确认图纸后 7 天打样、约 28 天批量交付。'],
['q' => '如何选择合适的尺码?', 'a' => '提供标准尺码表并支持上门量体,下单后可按身高体重推荐尺码;特殊体型或工种可单独打版,确保合身与活动便利。'],
];
$faqSchema = '<script type="application/ld+json">' . json_encode([
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => array_map(fn($f) => [
'@type' => 'Question',
'name' => $f['q'],
'acceptedAnswer' => ['@type' => 'Answer', 'text' => $f['a']],
], $faqs),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . '</script>';
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,
]);
}
}
-94
View File
@@ -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() 原样报错
}
// 尝试 1session_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;
}
}
+123 -21
View File
@@ -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_keyhome/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'],
];
}
}
+15
View File
@@ -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'",
+3 -2
View File
@@ -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'=> '酷冰甲专注降温服研发与定制,采用相变蓄冷与循环水冷技术,为高温作业人群提供清凉解决方案。',
+8 -1
View File
@@ -4,11 +4,13 @@
</div>
<div class="admin-card">
<table class="admin-table">
<tr><th>封面</th><th>案例标题</th><th>客户</th><th>行业</th><th>日期</th><th>状态</th><th>操作</th></tr>
<tr><th>封面</th><th>案例标题</th><th>模式</th><th>客户</th><th>行业</th><th>日期</th><th>状态</th><th>操作</th></tr>
<?php foreach ($cases as $c): ?>
<?php $cm = empty($c['mode']) ? 'fixed' : $c['mode']; ?>
<tr>
<td><div class="thum" style="background:<?php echo gradient($c['id']); ?>">🤝</div></td>
<td><b><?php echo e($c['title']); ?></b></td>
<td><span class="mode-badge <?php echo $cm === 'builder' ? 'builder' : 'fixed'; ?>"><?php echo $cm === 'builder' ? '可视化' : '固定'; ?></span></td>
<td class="muted"><?php echo e($c['customer'] ?? ''); ?></td>
<td class="muted"><?php echo e($c['industry'] ?? ''); ?></td>
<td class="muted"><?php echo e(format_date($c['published_at'])); ?></td>
@@ -23,3 +25,8 @@
<?php endforeach; ?>
</table>
</div>
<style>
.mode-badge{display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600}
.mode-badge.fixed{background:#f1f5f9;color:#475569}
.mode-badge.builder{background:#ede9fe;color:#6d28d9}
</style>
+136 -16
View File
@@ -1,14 +1,42 @@
<?php
/** @var array $c */
/** @var string $mode */
$v = function ($k, $d = '') use ($c) { return $c ? ($c[$k] ?? $d) : $d; };
$isEdit = !empty($c);
$mode = $mode ?? 'fixed';
$switchUrl = $isEdit ? site_url('admin/cases/switchMode/' . (int)$c['id']) : '';
$content = $v('content');
$coverVal = $v('cover');
?>
<div class="page-head">
<div><h1><?php echo $isEdit ? '编辑客户案例' : '新增客户案例'; ?></h1></div>
<a class="btn-ghost" href="<?php echo site_url('admin/cases'); ?>">← 返回</a>
<div>
<h1><?php echo $isEdit ? '编辑客户案例' : '新增客户案例'; ?><span class="mode-badge mode-fixed">固定版面</span></h1>
<div class="desc">固定版面:填写案例信息 / 封面,详情使用富文本编辑器排版<?php echo $isEdit ? ';可切换为可视化自由排版。' : '。'; ?></div>
</div>
<?php if ($isEdit): ?>
<div class="mode-switch-sel">
<label for="modeSel">切换模式</label>
<select id="modeSel" class="mode-sel" onchange="location.href='<?php echo $switchUrl; ?>?mode='+this.value">
<option value="fixed" <?php echo $mode === 'fixed' ? 'selected' : ''; ?>>固定版面</option>
<option value="builder" <?php echo $mode === 'builder' ? 'selected' : ''; ?>>可视化编辑</option>
</select>
</div>
<?php else: ?>
<div class="mode-switch-sel">
<label for="modeSel">排版方式</label>
<select id="modeSel" class="mode-sel">
<option value="fixed" <?php echo $mode === 'fixed' ? 'selected' : ''; ?>>固定版面</option>
<option value="builder" <?php echo $mode === 'builder' ? 'selected' : ''; ?>>可视化编辑</option>
</select>
</div>
<?php endif; ?>
</div>
<div class="admin-card">
<form id="pbForm" method="post" action="<?php echo site_url($isEdit ? 'admin/cases/update/' . $c['id'] : 'admin/cases/store'); ?>" enctype="multipart/form-data">
<form id="fxForm" method="post" action="<?php echo site_url($isEdit ? 'admin/cases/update/' . $c['id'] : 'admin/cases/store'); ?>" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<input type="hidden" name="mode" value="fixed">
<div class="field"><label>案例标题 *</label><input name="title" value="<?php echo e($v('title')); ?>" required></div>
<div class="form-grid">
<div class="field"><label>客户名称</label><input name="customer" value="<?php echo e($v('customer')); ?>"></div>
@@ -20,28 +48,120 @@ $isEdit = !empty($c);
</div>
<div class="field"><label>摘要</label><input name="summary" value="<?php echo e($v('summary')); ?>"></div>
<div class="field"><label>URL 标识(留空按序号生成,短而稳定)</label><input name="slug" value="<?php echo e($v('slug')); ?>" placeholder="留空则自动生成如 12"></div>
<div class="field">
<label>案例详情(可视化编辑</label>
<p class="pb-hint" style="margin:0 0 8px">下方为可视化编辑器:上传素材、拖拽文字/图片/按钮自由排版,拖动右下角缩放、双击文字直接编辑。保存后前台按此布局整页展示。</p>
<?php
$layoutJson = $v('layout');
$layoutArr = $layoutJson ? @json_decode($layoutJson, true) : [];
if (!is_array($layoutArr)) $layoutArr = [];
echo \Core\View::buffer('admin/parts/builder', ['module' => 'case', 'layout' => $layoutArr]);
?>
<input type="hidden" name="content" value="<?php echo e($v('content')); ?>">
<input type="hidden" name="layout" id="pbLayout">
<label>封面图(上传,可选</label>
<input type="file" name="cover" accept="image/*">
<?php if ($coverVal): ?>
<div class="img-prev"><img src="<?php echo e(site_url($coverVal)); ?>" alt=""><span class="img-prev-path"><?php echo e($coverVal); ?></span></div>
<p class="fx-hint">已上传封面;重新选择文件将替换,留空则保留。</p>
<?php endif; ?>
<input type="text" name="cover_url" value="<?php echo e($coverVal); ?>" placeholder="或填写图片地址 assets/uploads/xxx.jpg 或 http(s)://" style="margin-top:8px">
</div>
<div class="form-grid">
<div class="field"><label>封面图(上传,可选)</label><input type="file" name="cover" accept="image/*"></div>
<div class="field"><label>或图片地址</label><input name="cover_url" value="<?php echo e($v('cover')); ?>"></div>
<div class="field fx-fixed-only">
<label>案例详情(富文本编辑器)</label>
<div class="fx-toolbar">
<button type="button" data-cmd="bold" title="加粗"><b>B</b></button>
<button type="button" data-cmd="italic" title="斜体"><i>I</i></button>
<button type="button" data-cmd="formatBlock" data-val="H2">H2</button>
<button type="button" data-cmd="formatBlock" data-val="H3">H3</button>
<button type="button" data-cmd="insertUnorderedList" title="无序列表">• 列表</button>
<button type="button" data-cmd="insertOrderedList" title="有序列表">1. 列表</button>
<button type="button" data-cmd="formatBlock" data-val="BLOCKQUOTE" title="引用">引用</button>
<button type="button" id="fxLink" title="插入链接">链接</button>
<button type="button" id="fxImg" title="插入图片">图片</button>
</div>
<div class="fx-editor" id="fxEditor" contenteditable="true"><?php echo $content; ?></div>
<textarea name="content" id="fxContent" hidden><?php echo e($content); ?></textarea>
<p class="fx-hint">直接排版正文即可;点击「图片」可从素材库选择或上传新图。保存后前台固定版式展示。</p>
</div>
<div class="field" style="display:flex;align-items:center;gap:8px">
<input type="checkbox" name="status" value="1" <?php echo $v('status', 1) ? 'checked' : ''; ?> id="st"> <label for="st" style="margin:0">在前台展示</label>
</div>
<div class="form-actions">
<button class="btn-primary" type="submit">保存</button>
<a class="btn-ghost" href="<?php echo site_url('admin/cases'); ?>">取消</a>
</div>
</form>
</div>
<!-- 图片选择弹层 -->
<div class="fx-modal" id="fxModal" hidden>
<div class="fx-modal-box">
<div class="fx-modal-head"><span>选择图片</span><button type="button" id="fxModalClose">×</button></div>
<div class="fx-modal-body">
<label class="fx-upload"> 上传图片<input type="file" id="fxUp" accept="image/*" hidden></label>
<div class="fx-lib" id="fxLib"></div>
</div>
</div>
</div>
<style>
.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:8px 12px;font-size:14px;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:10px;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}
.img-prev{display:flex;align-items:center;gap:10px;margin-top:10px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:8px 10px}
.img-prev img{width:72px;height:54px;object-fit:cover;border-radius:8px;display:block}
.img-prev-path{font-size:12px;color:#94a3b8;word-break:break-all}
.fx-toolbar{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
.fx-toolbar button{width:auto;min-width:38px;height:34px;padding:0 10px;border:1px solid #e2e8f0;background:#fff;border-radius:8px;cursor:pointer;font-size:14px;color:#334155}
.fx-toolbar button:hover{border-color:#0ea5e9;color:#0ea5e9}
.fx-editor{min-height:320px;border:1px solid #e2e8f0;border-radius:10px;padding:16px 18px;font-size:16px;line-height:1.9;outline:none;background:#fff;overflow:auto}
.fx-editor:focus{border-color:#0ea5e9;box-shadow:0 0 0 3px rgba(14,165,233,.12)}
.fx-editor h2{font-size:24px;margin:.6em 0 .4em}
.fx-editor h3{font-size:20px;margin:.6em 0 .4em}
.fx-editor blockquote{margin:.6em 0;padding:8px 14px;border-left:4px solid #0ea5e9;color:#475569;background:#f8fafc}
.fx-editor img{max-width:100%;height:auto;border-radius:8px;display:block;margin:8px 0}
.fx-editor a{color:#0ea5e9}
.fx-hint{font-size:12px;color:#94a3b8;margin:8px 2px 0}
.fx-modal{position:fixed;inset:0;background:rgba(15,23,42,.45);display:flex;align-items:center;justify-content:center;z-index:80}
.fx-modal[hidden]{display:none}
.fx-modal-box{background:#fff;border-radius:14px;width:420px;max-width:92vw;overflow:hidden}
.fx-modal-head{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid #e2e8f0;font-weight:700}
.fx-modal-head button{border:none;background:none;font-size:22px;cursor:pointer;color:#64748b;line-height:1}
.fx-modal-body{padding:16px;max-height:60vh;overflow:auto}
.fx-upload{display:block;text-align:center;background:#0ea5e9;color:#fff;border-radius:10px;padding:10px;cursor:pointer;font-weight:600;margin-bottom:12px}
.fx-lib{display:flex;flex-wrap:wrap;gap:8px}
.fx-lib img{width:72px;height:72px;object-fit:cover;border-radius:8px;cursor:pointer;border:2px solid transparent}
.fx-lib img:hover{border-color:#0ea5e9}
</style>
<script>
window.__PB_MEDIA__ = '<?php echo site_url('admin/media'); ?>';
window.__PB_UPLOAD__ = '<?php echo site_url('admin/media/upload'); ?>';
window.__PB_CSRF__ = '<?php echo csrf_token(); ?>';
</script>
<script src="<?php echo asset('js/fixed-editor.js'); ?>"></script>
<script>
// 新增案例时按「排版方式」选择器联动:选可视化编辑则隐藏正文编辑器,并提示保存后进入可视化编辑器
(function(){
var sel = document.getElementById('modeSel');
if(!sel) return;
var isCreate = <?php echo $isEdit ? 'false' : 'true'; ?>;
var modeField = document.querySelector('#fxForm input[name="mode"]');
var fixedOnly = document.querySelectorAll('.fx-fixed-only');
var note = document.createElement('p');
note.className = 'fx-hint fx-builder-note';
note.style.cssText = 'margin-top:10px;color:#6d28d9;background:#f5f3ff;border:1px solid #ddd6fe;border-radius:8px;padding:8px 12px;display:none';
note.textContent = '已选择「可视化编辑」:案例详情无需填写,保存后将进入可视化编辑器排版。';
var firstFixed = fixedOnly[0];
if(firstFixed && firstFixed.parentNode){ firstFixed.parentNode.insertBefore(note, firstFixed); }
function apply(){
var m = sel.value;
if(modeField) modeField.value = m;
if(!isCreate) return;
var isBuilder = (m === 'builder');
for(var i=0;i<fixedOnly.length;i++){ fixedOnly[i].style.display = isBuilder ? 'none' : ''; }
note.style.display = isBuilder ? 'block' : 'none';
}
sel.addEventListener('change', apply);
apply();
})();
</script>
+8 -1
View File
@@ -4,12 +4,14 @@
</div>
<div class="admin-card">
<table class="admin-table">
<tr><th>图标</th><th>名称</th><th>标识</th><th>描述</th><th>操作</th></tr>
<tr><th>图标</th><th>名称</th><th>标识</th><th>模式</th><th>描述</th><th>操作</th></tr>
<?php foreach ($cats as $c): ?>
<?php $cm = empty($c['mode']) ? 'fixed' : $c['mode']; ?>
<tr>
<td style="font-size:24px"><?php echo e($c['icon']); ?></td>
<td><b><?php echo e($c['name']); ?></b></td>
<td class="muted"><?php echo e($c['slug']); ?></td>
<td><span class="mode-badge <?php echo $cm === 'builder' ? 'builder' : 'fixed'; ?>"><?php echo $cm === 'builder' ? '可视化' : '固定'; ?></span></td>
<td class="muted"><?php echo e(mb_substr($c['description'] ?? '', 0, 24)); ?></td>
<td>
<div class="row-actions">
@@ -21,3 +23,8 @@
<?php endforeach; ?>
</table>
</div>
<style>
.mode-badge{display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600}
.mode-badge.fixed{background:#f1f5f9;color:#475569}
.mode-badge.builder{background:#ede9fe;color:#6d28d9}
</style>
+77 -13
View File
@@ -1,37 +1,101 @@
<?php
/** @var array $c */
/** @var string $mode */
$v = function ($k, $d = '') use ($c) { return $c ? ($c[$k] ?? $d) : $d; };
$isEdit = !empty($c);
$mode = $mode ?? 'fixed';
$switchUrl = $isEdit ? site_url('admin/categories/switchMode/' . (int)$c['id']) : '';
$desc = $v('description');
?>
<div class="page-head">
<div><h1><?php echo $isEdit ? '编辑分类' : '新增分类'; ?></h1></div>
<a class="btn-ghost" href="<?php echo site_url('admin/categories'); ?>">← 返回</a>
<div>
<h1><?php echo $isEdit ? '编辑分类' : '新增分类'; ?><span class="mode-badge mode-fixed">固定版面</span></h1>
<div class="desc">固定版面:填写分类名称 / 图标 / 描述等基本信息<?php echo $isEdit ? ';可切换为可视化自由排版。' : '。'; ?></div>
</div>
<?php if ($isEdit): ?>
<div class="mode-switch-sel">
<label for="modeSel">切换模式</label>
<select id="modeSel" class="mode-sel" onchange="location.href='<?php echo $switchUrl; ?>?mode='+this.value">
<option value="fixed" <?php echo $mode === 'fixed' ? 'selected' : ''; ?>>固定版面</option>
<option value="builder" <?php echo $mode === 'builder' ? 'selected' : ''; ?>>可视化编辑</option>
</select>
</div>
<?php else: ?>
<div class="mode-switch-sel">
<label for="modeSel">排版方式</label>
<select id="modeSel" class="mode-sel">
<option value="fixed" <?php echo $mode === 'fixed' ? 'selected' : ''; ?>>固定版面</option>
<option value="builder" <?php echo $mode === 'builder' ? 'selected' : ''; ?>>可视化编辑</option>
</select>
</div>
<?php endif; ?>
</div>
<div class="admin-card">
<form id="pbForm" method="post" action="<?php echo site_url($isEdit ? 'admin/categories/update/' . $c['id'] : 'admin/categories/store'); ?>">
<form id="fxForm" method="post" action="<?php echo site_url($isEdit ? 'admin/categories/update/' . $c['id'] : 'admin/categories/store'); ?>">
<?php echo csrf_field(); ?>
<input type="hidden" name="mode" value="fixed">
<div class="form-grid">
<div class="field"><label>分类名称 *</label><input name="name" value="<?php echo e($v('name')); ?>" required></div>
<div class="field"><label>图标(emoji</label><input name="icon" value="<?php echo e($v('icon', '❄')); ?>"></div>
</div>
<div class="form-grid">
<div class="field"><label>标识(留空按序号生成)</label><input name="slug" value="<?php echo e($v('slug')); ?>"></div>
<div class="field"><label>排序</label><input name="sort_order" type="number" value="<?php echo e($v('sort_order', 0)); ?>"></div>
</div>
<div class="field">
<label>描述(可视化编辑)</label>
<p class="pb-hint" style="margin:0 0 8px">下方为可视化编辑器:上传素材、拖拽文字/图片/按钮自由排版。当前分类无独立前台详情页,编辑保存后可用于后续扩展或作为内容版式储备。</p>
<?php
$layoutJson = $v('layout');
$layoutArr = $layoutJson ? @json_decode($layoutJson, true) : [];
if (!is_array($layoutArr)) $layoutArr = [];
echo \Core\View::buffer('admin/parts/builder', ['module' => 'category', 'layout' => $layoutArr]);
?>
<input type="hidden" name="layout" id="pbLayout">
<div class="field fx-fixed-only">
<label>分类描述</label>
<textarea name="description" rows="4" placeholder="分类简介,用于前台分类展示区域"><?php echo e($desc); ?></textarea>
<p class="fx-hint">纯文本描述,保存后前台分类列表展示。如需复杂排版可切换为可视化编辑。</p>
</div>
<div class="field" style="display:flex;align-items:center;gap:8px">
<input type="checkbox" name="status" value="1" <?php echo $v('status', 1) ? 'checked' : ''; ?> id="st"> <label for="st" style="margin:0">显示</label>
</div>
<div class="form-actions">
<button class="btn-primary" type="submit">保存</button>
<a class="btn-ghost" href="<?php echo site_url('admin/categories'); ?>">取消</a>
</div>
</form>
</div>
<style>
.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:8px 12px;font-size:14px;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:10px;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}
.fx-hint{font-size:12px;color:#94a3b8;margin:8px 2px 0}
</style>
<script>
// 新增分类时按「排版方式」选择器联动:选可视化编辑则隐藏描述,并提示保存后进入可视化编辑器
(function(){
var sel = document.getElementById('modeSel');
if(!sel) return;
var isCreate = <?php echo $isEdit ? 'false' : 'true'; ?>;
var modeField = document.querySelector('#fxForm input[name="mode"]');
var fixedOnly = document.querySelectorAll('.fx-fixed-only');
var note = document.createElement('p');
note.className = 'fx-hint fx-builder-note';
note.style.cssText = 'margin-top:10px;color:#6d28d9;background:#f5f3ff;border:1px solid #ddd6fe;border-radius:8px;padding:8px 12px;display:none';
note.textContent = '已选择「可视化编辑」:描述无需填写,保存后将进入可视化编辑器排版分类内容。';
var firstFixed = fixedOnly[0];
if(firstFixed && firstFixed.parentNode){ firstFixed.parentNode.insertBefore(note, firstFixed); }
function apply(){
var m = sel.value;
if(modeField) modeField.value = m;
if(!isCreate) return;
var isBuilder = (m === 'builder');
for(var i=0;i<fixedOnly.length;i++){ fixedOnly[i].style.display = isBuilder ? 'none' : ''; }
note.style.display = isBuilder ? 'block' : 'none';
}
sel.addEventListener('change', apply);
apply();
})();
</script>
+8 -1
View File
@@ -4,11 +4,13 @@
</div>
<div class="admin-card">
<table class="admin-table">
<tr><th>封面</th><th>标题</th><th>作者</th><th>日期</th><th>状态</th><th>操作</th></tr>
<tr><th>封面</th><th>标题</th><th>模式</th><th>作者</th><th>日期</th><th>状态</th><th>操作</th></tr>
<?php foreach ($news as $n): ?>
<?php $nm = empty($n['mode']) ? 'fixed' : $n['mode']; ?>
<tr>
<td><div class="thum" style="background:<?php echo gradient($n['id']); ?>">📰</div></td>
<td><b><?php echo e($n['title']); ?></b></td>
<td><span class="mode-badge <?php echo $nm === 'builder' ? 'builder' : 'fixed'; ?>"><?php echo $nm === 'builder' ? '可视化' : '固定'; ?></span></td>
<td class="muted"><?php echo e($n['author']); ?></td>
<td class="muted"><?php echo e(format_date($n['published_at'])); ?></td>
<td><?php echo ($n['status'] ?? 1) ? '<span class="tag-mini">已发布</span>' : '<span class="muted">草稿</span>'; ?></td>
@@ -22,3 +24,8 @@
<?php endforeach; ?>
</table>
</div>
<style>
.mode-badge{display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600}
.mode-badge.fixed{background:#f1f5f9;color:#475569}
.mode-badge.builder{background:#ede9fe;color:#6d28d9}
</style>
+136 -16
View File
@@ -1,14 +1,42 @@
<?php
/** @var array $n */
/** @var string $mode */
$v = function ($k, $d = '') use ($n) { return $n ? ($n[$k] ?? $d) : $d; };
$isEdit = !empty($n);
$mode = $mode ?? 'fixed';
$switchUrl = $isEdit ? site_url('admin/news/switchMode/' . (int)$n['id']) : '';
$content = $v('content');
$coverVal = $v('cover');
?>
<div class="page-head">
<div><h1><?php echo $isEdit ? '编辑新闻' : '写新闻'; ?></h1></div>
<a class="btn-ghost" href="<?php echo site_url('admin/news'); ?>">← 返回</a>
<div>
<h1><?php echo $isEdit ? '编辑新闻' : '写新闻'; ?><span class="mode-badge mode-fixed">固定版面</span></h1>
<div class="desc">固定版面:填写标题 / 摘要 / 封面,正文使用富文本编辑器排版<?php echo $isEdit ? ';可切换为可视化自由排版。' : '。'; ?></div>
</div>
<?php if ($isEdit): ?>
<div class="mode-switch-sel">
<label for="modeSel">切换模式</label>
<select id="modeSel" class="mode-sel" onchange="location.href='<?php echo $switchUrl; ?>?mode='+this.value">
<option value="fixed" <?php echo $mode === 'fixed' ? 'selected' : ''; ?>>固定版面</option>
<option value="builder" <?php echo $mode === 'builder' ? 'selected' : ''; ?>>可视化编辑</option>
</select>
</div>
<?php else: ?>
<div class="mode-switch-sel">
<label for="modeSel">排版方式</label>
<select id="modeSel" class="mode-sel">
<option value="fixed" <?php echo $mode === 'fixed' ? 'selected' : ''; ?>>固定版面</option>
<option value="builder" <?php echo $mode === 'builder' ? 'selected' : ''; ?>>可视化编辑</option>
</select>
</div>
<?php endif; ?>
</div>
<div class="admin-card">
<form id="pbForm" method="post" action="<?php echo site_url($isEdit ? 'admin/news/update/' . $n['id'] : 'admin/news/store'); ?>" enctype="multipart/form-data">
<form id="fxForm" method="post" action="<?php echo site_url($isEdit ? 'admin/news/update/' . $n['id'] : 'admin/news/store'); ?>" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<input type="hidden" name="mode" value="fixed">
<div class="field"><label>标题 *</label><input name="title" value="<?php echo e($v('title')); ?>" required></div>
<div class="form-grid">
<div class="field"><label>作者</label><input name="author" value="<?php echo e($v('author', '酷冰甲')); ?>"></div>
@@ -16,28 +44,120 @@ $isEdit = !empty($n);
</div>
<div class="field"><label>摘要</label><input name="summary" value="<?php echo e($v('summary')); ?>"></div>
<div class="field"><label>URL 标识(留空按序号生成,短而稳定)</label><input name="slug" value="<?php echo e($v('slug')); ?>" placeholder="留空则自动生成如 12"></div>
<div class="field">
<label>正文(可视化编辑</label>
<p class="pb-hint" style="margin:0 0 8px">下方为可视化编辑器:上传素材、拖拽文字/图片/按钮自由排版,拖动右下角缩放、双击文字直接编辑。保存后前台按此布局整页展示。</p>
<?php
$layoutJson = $v('layout');
$layoutArr = $layoutJson ? @json_decode($layoutJson, true) : [];
if (!is_array($layoutArr)) $layoutArr = [];
echo \Core\View::buffer('admin/parts/builder', ['module' => 'news', 'layout' => $layoutArr]);
?>
<input type="hidden" name="content" value="<?php echo e($v('content')); ?>">
<input type="hidden" name="layout" id="pbLayout">
<label>封面图(上传,可选</label>
<input type="file" name="cover" accept="image/*">
<?php if ($coverVal): ?>
<div class="img-prev"><img src="<?php echo e(site_url($coverVal)); ?>" alt=""><span class="img-prev-path"><?php echo e($coverVal); ?></span></div>
<p class="fx-hint">已上传封面;重新选择文件将替换,留空则保留。</p>
<?php endif; ?>
<input type="text" name="cover_url" value="<?php echo e($coverVal); ?>" placeholder="或填写图片地址 assets/uploads/xxx.jpg 或 http(s)://" style="margin-top:8px">
</div>
<div class="form-grid">
<div class="field"><label>封面图(上传,可选)</label><input type="file" name="cover" accept="image/*"></div>
<div class="field"><label>或图片地址</label><input name="cover_url" value="<?php echo e($v('cover')); ?>"></div>
<div class="field fx-fixed-only">
<label>正文内容(富文本编辑器)</label>
<div class="fx-toolbar">
<button type="button" data-cmd="bold" title="加粗"><b>B</b></button>
<button type="button" data-cmd="italic" title="斜体"><i>I</i></button>
<button type="button" data-cmd="formatBlock" data-val="H2">H2</button>
<button type="button" data-cmd="formatBlock" data-val="H3">H3</button>
<button type="button" data-cmd="insertUnorderedList" title="无序列表">• 列表</button>
<button type="button" data-cmd="insertOrderedList" title="有序列表">1. 列表</button>
<button type="button" data-cmd="formatBlock" data-val="BLOCKQUOTE" title="引用">引用</button>
<button type="button" id="fxLink" title="插入链接">链接</button>
<button type="button" id="fxImg" title="插入图片">图片</button>
</div>
<div class="fx-editor" id="fxEditor" contenteditable="true"><?php echo $content; ?></div>
<textarea name="content" id="fxContent" hidden><?php echo e($content); ?></textarea>
<p class="fx-hint">直接排版正文即可;点击「图片」可从素材库选择或上传新图。保存后前台固定版式展示。</p>
</div>
<div class="field" style="display:flex;align-items:center;gap:8px">
<input type="checkbox" name="status" value="1" <?php echo $v('status', 1) ? 'checked' : ''; ?> id="st"> <label for="st" style="margin:0">立即发布</label>
</div>
<div class="form-actions">
<button class="btn-primary" type="submit">保存</button>
<a class="btn-ghost" href="<?php echo site_url('admin/news'); ?>">取消</a>
</div>
</form>
</div>
<!-- 图片选择弹层 -->
<div class="fx-modal" id="fxModal" hidden>
<div class="fx-modal-box">
<div class="fx-modal-head"><span>选择图片</span><button type="button" id="fxModalClose">×</button></div>
<div class="fx-modal-body">
<label class="fx-upload"> 上传图片<input type="file" id="fxUp" accept="image/*" hidden></label>
<div class="fx-lib" id="fxLib"></div>
</div>
</div>
</div>
<style>
.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:8px 12px;font-size:14px;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:10px;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}
.img-prev{display:flex;align-items:center;gap:10px;margin-top:10px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:8px 10px}
.img-prev img{width:72px;height:54px;object-fit:cover;border-radius:8px;display:block}
.img-prev-path{font-size:12px;color:#94a3b8;word-break:break-all}
.fx-toolbar{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
.fx-toolbar button{width:auto;min-width:38px;height:34px;padding:0 10px;border:1px solid #e2e8f0;background:#fff;border-radius:8px;cursor:pointer;font-size:14px;color:#334155}
.fx-toolbar button:hover{border-color:#0ea5e9;color:#0ea5e9}
.fx-editor{min-height:320px;border:1px solid #e2e8f0;border-radius:10px;padding:16px 18px;font-size:16px;line-height:1.9;outline:none;background:#fff;overflow:auto}
.fx-editor:focus{border-color:#0ea5e9;box-shadow:0 0 0 3px rgba(14,165,233,.12)}
.fx-editor h2{font-size:24px;margin:.6em 0 .4em}
.fx-editor h3{font-size:20px;margin:.6em 0 .4em}
.fx-editor blockquote{margin:.6em 0;padding:8px 14px;border-left:4px solid #0ea5e9;color:#475569;background:#f8fafc}
.fx-editor img{max-width:100%;height:auto;border-radius:8px;display:block;margin:8px 0}
.fx-editor a{color:#0ea5e9}
.fx-hint{font-size:12px;color:#94a3b8;margin:8px 2px 0}
.fx-modal{position:fixed;inset:0;background:rgba(15,23,42,.45);display:flex;align-items:center;justify-content:center;z-index:80}
.fx-modal[hidden]{display:none}
.fx-modal-box{background:#fff;border-radius:14px;width:420px;max-width:92vw;overflow:hidden}
.fx-modal-head{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid #e2e8f0;font-weight:700}
.fx-modal-head button{border:none;background:none;font-size:22px;cursor:pointer;color:#64748b;line-height:1}
.fx-modal-body{padding:16px;max-height:60vh;overflow:auto}
.fx-upload{display:block;text-align:center;background:#0ea5e9;color:#fff;border-radius:10px;padding:10px;cursor:pointer;font-weight:600;margin-bottom:12px}
.fx-lib{display:flex;flex-wrap:wrap;gap:8px}
.fx-lib img{width:72px;height:72px;object-fit:cover;border-radius:8px;cursor:pointer;border:2px solid transparent}
.fx-lib img:hover{border-color:#0ea5e9}
</style>
<script>
window.__PB_MEDIA__ = '<?php echo site_url('admin/media'); ?>';
window.__PB_UPLOAD__ = '<?php echo site_url('admin/media/upload'); ?>';
window.__PB_CSRF__ = '<?php echo csrf_token(); ?>';
</script>
<script src="<?php echo asset('js/fixed-editor.js'); ?>"></script>
<script>
// 新增新闻时按「排版方式」选择器联动:选可视化编辑则隐藏正文编辑器,并提示保存后进入可视化编辑器
(function(){
var sel = document.getElementById('modeSel');
if(!sel) return;
var isCreate = <?php echo $isEdit ? 'false' : 'true'; ?>;
var modeField = document.querySelector('#fxForm input[name="mode"]');
var fixedOnly = document.querySelectorAll('.fx-fixed-only');
var note = document.createElement('p');
note.className = 'fx-hint fx-builder-note';
note.style.cssText = 'margin-top:10px;color:#6d28d9;background:#f5f3ff;border:1px solid #ddd6fe;border-radius:8px;padding:8px 12px;display:none';
note.textContent = '已选择「可视化编辑」:正文无需填写,保存后将进入可视化编辑器排版新闻详情。';
var firstFixed = fixedOnly[0];
if(firstFixed && firstFixed.parentNode){ firstFixed.parentNode.insertBefore(note, firstFixed); }
function apply(){
var m = sel.value;
if(modeField) modeField.value = m;
if(!isCreate) return;
var isBuilder = (m === 'builder');
for(var i=0;i<fixedOnly.length;i++){ fixedOnly[i].style.display = isBuilder ? 'none' : ''; }
note.style.display = isBuilder ? 'block' : 'none';
}
sel.addEventListener('change', apply);
apply();
})();
</script>
+23
View File
@@ -1,6 +1,10 @@
<?php
/** @var array $p */
/** @var array $layout */
/** @var string $mode */
$v = function ($k, $d = '') use ($p) { return $p ? ($p[$k] ?? $d) : $d; };
$isEdit = !empty($p);
$switchUrl = $isEdit ? site_url('admin/pages/switchMode/' . (int)$p['id']) : '';
$initial = json_encode($layout ?: []);
?>
<div class="pb-root">
@@ -8,6 +12,16 @@ $initial = json_encode($layout ?: []);
<div class="pb-top-left">
<a class="btn-ghost" href="<?php echo site_url('admin/pages'); ?>">← 返回</a>
<span class="pb-t">可视化编辑:<?php echo e($p['title']); ?></span>
<span class="mode-badge mode-builder">可视化编辑</span>
<?php if ($isEdit): ?>
<div class="mode-switch-sel">
<label for="modeSel">切换模式</label>
<select id="modeSel" class="mode-sel" onchange="location.href='<?php echo $switchUrl; ?>?mode='+this.value">
<option value="fixed" <?php echo $mode === 'fixed' ? 'selected' : ''; ?>>固定版面</option>
<option value="builder" <?php echo $mode === 'builder' ? 'selected' : ''; ?>>可视化编辑</option>
</select>
</div>
<?php endif; ?>
</div>
<form id="pbForm" method="post" action="<?php echo site_url('admin/pages/update/' . $p['id']); ?>" class="pb-top-form">
<?php echo csrf_field(); ?>
@@ -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__ = <?php echo $initial; ?>;
window.__PB_MEDIA__ = '<?php echo site_url('admin/media'); ?>';
window.__PB_UPLOAD__ = '<?php echo site_url('admin/media/upload'); ?>';
window.__PB_CSRF__ = '<?php echo csrf_token(); ?>';
window.__PB_MODULE__ = 'page';
window.__PB_DELETE__ = '<?php echo site_url('admin/media/delete/'); ?>';
</script>
<script src="<?php echo asset('js/page-builder.js'); ?>"></script>
+92 -4
View File
@@ -1,15 +1,103 @@
<?php
/** @var array $p */
/** @var string $mode */
$v = function ($k, $d = '') use ($p) { return $p ? ($p[$k] ?? $d) : $d; };
$isEdit = !empty($p);
$mode = $mode ?? 'fixed';
$switchUrl = $isEdit ? site_url('admin/pages/switchMode/' . (int)$p['id']) : '';
?>
<div class="page-head">
<div><h1>编辑单页:<?php echo e($p['title']); ?></h1><div class="desc">支持 HTML 标签</div></div>
<a class="btn-ghost" href="<?php echo site_url('admin/pages'); ?>">← 返回</a>
<div>
<h1>编辑单页:<?php echo e($p['title']); ?><span class="mode-badge mode-fixed">固定版面</span></h1>
<div class="desc">固定版面:页面套用统一模板(标题区 + 正文 + 联系按钮),您只需编辑正文内容。</div>
</div>
<?php if ($isEdit): ?>
<div class="mode-switch-sel">
<label for="modeSel">切换模式</label>
<select id="modeSel" class="mode-sel" onchange="location.href='<?php echo $switchUrl; ?>?mode='+this.value">
<option value="fixed" <?php echo $mode === 'fixed' ? 'selected' : ''; ?>>固定版面</option>
<option value="builder" <?php echo $mode === 'builder' ? 'selected' : ''; ?>>可视化编辑</option>
</select>
</div>
<?php endif; ?>
</div>
<div class="admin-card">
<form method="post" action="<?php echo site_url('admin/pages/update/' . $p['id']); ?>">
<form method="post" action="<?php echo site_url('admin/pages/update/' . $p['id']); ?>" id="fxForm">
<?php echo csrf_field(); ?>
<input type="hidden" name="mode" value="fixed">
<div class="field"><label>标题</label><input name="title" value="<?php echo e($p['title']); ?>"></div>
<div class="field"><label>内容(可包含 &lt;p&gt; &lt;b&gt; 等标签)</label><textarea name="content" style="min-height:260px;font-family:monospace"><?php echo e($p['content']); ?></textarea></div>
<div class="field">
<label>正文内容</label>
<div class="fx-toolbar">
<button type="button" data-cmd="bold" title="加粗"><b>B</b></button>
<button type="button" data-cmd="italic" title="斜体"><i>I</i></button>
<button type="button" data-cmd="formatBlock" data-val="H2">H2</button>
<button type="button" data-cmd="formatBlock" data-val="H3">H3</button>
<button type="button" data-cmd="insertUnorderedList" title="无序列表">• 列表</button>
<button type="button" data-cmd="insertOrderedList" title="有序列表">1. 列表</button>
<button type="button" data-cmd="formatBlock" data-val="BLOCKQUOTE" title="引用">引用</button>
<button type="button" id="fxLink" title="插入链接">链接</button>
<button type="button" id="fxImg" title="插入图片">图片</button>
</div>
<div class="fx-editor" id="fxEditor" contenteditable="true"><?php echo $p['content']; ?></div>
<textarea name="content" id="fxContent" hidden><?php echo e($p['content']); ?></textarea>
<p class="fx-hint">直接排版正文即可;切换「可视化编辑」可自由拖拽布局。保存后页面以统一版式展示。</p>
</div>
<div class="form-actions">
<button class="btn-primary" type="submit">保存</button>
<a class="btn-ghost" href="<?php echo site_url('admin/pages'); ?>">取消</a>
</div>
</form>
</div>
<!-- 图片选择弹层 -->
<div class="fx-modal" id="fxModal" hidden>
<div class="fx-modal-box">
<div class="fx-modal-head"><span>选择图片</span><button type="button" id="fxModalClose">×</button></div>
<div class="fx-modal-body">
<label class="fx-upload"> 上传图片<input type="file" id="fxUp" accept="image/*" hidden></label>
<div class="fx-lib" id="fxLib"></div>
</div>
</div>
</div>
<style>
.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:8px 12px;font-size:14px;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:10px;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}
.fx-toolbar{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
.fx-toolbar button{width:auto;min-width:38px;height:34px;padding:0 10px;border:1px solid #e2e8f0;background:#fff;border-radius:8px;cursor:pointer;font-size:14px;color:#334155}
.fx-toolbar button:hover{border-color:#0ea5e9;color:#0ea5e9}
.fx-editor{min-height:320px;border:1px solid #e2e8f0;border-radius:10px;padding:16px 18px;font-size:16px;line-height:1.9;outline:none;background:#fff;overflow:auto}
.fx-editor:focus{border-color:#0ea5e9;box-shadow:0 0 0 3px rgba(14,165,233,.12)}
.fx-editor h2{font-size:24px;margin:.6em 0 .4em}
.fx-editor h3{font-size:20px;margin:.6em 0 .4em}
.fx-editor blockquote{margin:.6em 0;padding:8px 14px;border-left:4px solid #0ea5e9;color:#475569;background:#f8fafc}
.fx-editor img{max-width:100%;border-radius:8px;display:block;margin:8px 0}
.fx-editor a{color:#0ea5e9}
.fx-hint{font-size:12px;color:#94a3b8;margin:8px 2px 0}
.fx-modal{position:fixed;inset:0;background:rgba(15,23,42,.45);display:flex;align-items:center;justify-content:center;z-index:80}
.fx-modal[hidden]{display:none}
.fx-modal-box{background:#fff;border-radius:14px;width:420px;max-width:92vw;overflow:hidden}
.fx-modal-head{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid #e2e8f0;font-weight:700}
.fx-modal-head button{border:none;background:none;font-size:22px;cursor:pointer;color:#64748b;line-height:1}
.fx-modal-body{padding:16px;max-height:60vh;overflow:auto}
.fx-upload{display:block;text-align:center;background:#0ea5e9;color:#fff;border-radius:10px;padding:10px;cursor:pointer;font-weight:600;margin-bottom:12px}
.fx-lib{display:flex;flex-wrap:wrap;gap:8px}
.fx-lib img{width:72px;height:72px;object-fit:cover;border-radius:8px;cursor:pointer;border:2px solid transparent}
.fx-lib img:hover{border-color:#0ea5e9}
</style>
<script>
window.__PB_MEDIA__ = '<?php echo site_url('admin/media'); ?>';
window.__PB_UPLOAD__ = '<?php echo site_url('admin/media/upload'); ?>';
window.__PB_CSRF__ = '<?php echo csrf_token(); ?>';
</script>
<script src="<?php echo asset('js/fixed-editor.js'); ?>"></script>
+8 -1
View File
@@ -3,14 +3,21 @@
</div>
<div class="admin-card">
<table class="admin-table">
<tr><th>标识</th><th>标题</th><th>更新时间</th><th>操作</th></tr>
<tr><th>标识</th><th>标题</th><th>模式</th><th>更新时间</th><th>操作</th></tr>
<?php foreach ($pages as $p): ?>
<?php $m = empty($p['mode']) ? 'fixed' : $p['mode']; ?>
<tr>
<td class="muted"><?php echo e($p['slug']); ?></td>
<td><b><?php echo e($p['title']); ?></b></td>
<td><span class="mode-badge <?php echo $m === 'builder' ? 'builder' : 'fixed'; ?>"><?php echo $m === 'builder' ? '可视化编辑' : '固定版面'; ?></span></td>
<td class="muted"><?php echo e(format_date($p['updated_at'])); ?></td>
<td><a class="btn-soft btn-sm" href="<?php echo site_url('admin/pages/edit/' . $p['id']); ?>">编辑</a></td>
</tr>
<?php endforeach; ?>
</table>
<style>
.mode-badge{display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600}
.mode-badge.fixed{background:#f1f5f9;color:#475569}
.mode-badge.builder{background:#e0f2fe;color:#0369a1}
</style>
</div>
+152 -15
View File
@@ -1,15 +1,46 @@
<?php
/** @var array $p */
/** @var string $mode */
$v = function ($k, $d = '') use ($p) { return $p ? ($p[$k] ?? $d) : $d; };
$isEdit = !empty($p);
$mode = $mode ?? 'fixed';
$switchUrl = $isEdit ? site_url('admin/products/switchMode/' . (int)$p['id']) : '';
$coverVal = $v('cover');
$galleryArr = [];
$g = $v('gallery');
if ($g) { $d = @json_decode($g, true); if (is_array($d)) $galleryArr = $d; }
$desc = $v('description');
?>
<div class="page-head">
<div><h1><?php echo $isEdit ? '编辑产品' : '新增产品'; ?></h1><div class="desc">填写产品信息,封面留空将使用渐变占位图</div></div>
<a class="btn-ghost" href="<?php echo site_url('admin/products'); ?>">← 返回列表</a>
<div>
<h1><?php echo $isEdit ? '编辑产品' : '新增产品'; ?><span class="mode-badge mode-fixed">固定版面</span></h1>
<div class="desc">固定版面:填写资料并上传封面 / 图集,图片自适应展示<?php echo $isEdit ? ';可切换为可视化自由排版。' : '。'; ?></div>
</div>
<?php if ($isEdit): ?>
<div class="mode-switch-sel">
<label for="modeSel">切换模式</label>
<select id="modeSel" class="mode-sel" onchange="location.href='<?php echo $switchUrl; ?>?mode='+this.value">
<option value="fixed" <?php echo $mode === 'fixed' ? 'selected' : ''; ?>>固定版面</option>
<option value="builder" <?php echo $mode === 'builder' ? 'selected' : ''; ?>>可视化编辑</option>
</select>
</div>
<?php else: ?>
<div class="mode-switch-sel">
<label for="modeSel">排版方式</label>
<select id="modeSel" class="mode-sel">
<option value="fixed" <?php echo $mode === 'fixed' ? 'selected' : ''; ?>>固定版面</option>
<option value="builder" <?php echo $mode === 'builder' ? 'selected' : ''; ?>>可视化编辑</option>
</select>
</div>
<?php endif; ?>
</div>
<div class="admin-card">
<form id="pbForm" method="post" action="<?php echo site_url($isEdit ? 'admin/products/update/' . $p['id'] : 'admin/products/store'); ?>" enctype="multipart/form-data">
<form id="fxForm" method="post" action="<?php echo site_url($isEdit ? 'admin/products/update/' . $p['id'] : 'admin/products/store'); ?>" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<input type="hidden" name="mode" value="fixed">
<div class="form-grid">
<div class="field">
<label>所属分类 *</label>
@@ -23,21 +54,44 @@ $isEdit = !empty($p);
</div>
<div class="field"><label>一句话简介</label><input name="summary" value="<?php echo e($v('summary')); ?>"></div>
<div class="field">
<label>详细描述(可视化编辑</label>
<p class="pb-hint" style="margin:0 0 8px">下方为可视化编辑器:上传素材、拖拽文字/图片/按钮自由排版,拖动右下角缩放、双击文字直接编辑。保存后前台按此布局整页展示;可放置「购买按钮 / 价格 / 规格参数」专用块(自动读取本产品数据)。</p>
<?php
$layoutJson = $v('layout');
$layoutArr = $layoutJson ? @json_decode($layoutJson, true) : [];
if (!is_array($layoutArr)) $layoutArr = [];
echo \Core\View::buffer('admin/parts/builder', ['module' => 'product', 'layout' => $layoutArr]);
?>
<input type="hidden" name="layout" id="pbLayout">
<label>封面图(上传,建议 4:3,将作为列表/详情主图</label>
<input type="file" name="cover" accept="image/*">
<?php if ($coverVal): ?>
<div class="img-prev"><img src="<?php echo e(site_url($coverVal)); ?>" alt=""><span class="img-prev-path"><?php echo e($coverVal); ?></span></div>
<p class="fx-hint">已上传封面;重新选择文件将替换,留空则保留。</p>
<?php endif; ?>
<input type="text" name="cover_url" value="<?php echo e($coverVal); ?>" placeholder="或填写图片地址/路径 assets/uploads/xxx.jpg 或 http(s)://" style="margin-top:8px">
</div>
<div class="form-grid">
<div class="field"><label>封面图(上传,可选)</label><input type="file" name="cover" accept="image/*"></div>
<div class="field"><label>或填写图片地址/路径</label><input name="cover_url" value="<?php echo e($v('cover')); ?>" placeholder="assets/uploads/xxx.jpg 或 http(s)://"></div>
<div class="field fx-fixed-only">
<label>图集(可一次选择多张,每张都会单独上传)</label>
<input type="file" name="gallery[]" accept="image/*" multiple>
<?php if (!empty($galleryArr)): ?>
<div class="gallery-prev">
<?php foreach ($galleryArr as $gp): ?><div class="gp-item"><img src="<?php echo e(site_url($gp)); ?>" alt=""><span><?php echo e($gp); ?></span></div><?php endforeach; ?>
</div>
<label class="chk-inline"><input type="checkbox" name="clear_gallery" value="1"> 清空现有图集(重新上传)</label>
<?php endif; ?>
</div>
<div class="field fx-fixed-only">
<label>详细描述(富文本,每个图片均可上传,前台自适应展示)</label>
<div class="fx-toolbar">
<button type="button" data-cmd="bold" title="加粗"><b>B</b></button>
<button type="button" data-cmd="italic" title="斜体"><i>I</i></button>
<button type="button" data-cmd="formatBlock" data-val="H2">H2</button>
<button type="button" data-cmd="formatBlock" data-val="H3">H3</button>
<button type="button" data-cmd="insertUnorderedList" title="无序列表">• 列表</button>
<button type="button" data-cmd="insertOrderedList" title="有序列表">1. 列表</button>
<button type="button" data-cmd="formatBlock" data-val="BLOCKQUOTE" title="引用">引用</button>
<button type="button" id="fxLink" title="插入链接">链接</button>
<button type="button" id="fxImg" title="插入图片">图片</button>
</div>
<div class="fx-editor" id="fxEditor" contenteditable="true"><?php echo $desc; ?></div>
<textarea name="description" id="fxContent" hidden><?php echo e($desc); ?></textarea>
<p class="fx-hint">直接排版正文即可;点击「图片」可从素材库选择或上传新图。保存后前台固定版式展示。</p>
</div>
<div class="field">
@@ -60,3 +114,86 @@ $isEdit = !empty($p);
</div>
</form>
</div>
<!-- 图片选择弹层 -->
<div class="fx-modal" id="fxModal" hidden>
<div class="fx-modal-box">
<div class="fx-modal-head"><span>选择图片</span><button type="button" id="fxModalClose">×</button></div>
<div class="fx-modal-body">
<label class="fx-upload"> 上传图片<input type="file" id="fxUp" accept="image/*" hidden></label>
<div class="fx-lib" id="fxLib"></div>
</div>
</div>
</div>
<style>
.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:8px 12px;font-size:14px;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:10px;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}
.img-prev{display:flex;align-items:center;gap:10px;margin-top:10px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:8px 10px}
.img-prev img{width:72px;height:54px;object-fit:cover;border-radius:8px;display:block}
.img-prev-path{font-size:12px;color:#94a3b8;word-break:break-all}
.gallery-prev{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px}
.gallery-prev .gp-item{width:104px;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden;background:#fff}
.gallery-prev .gp-item img{width:100%;height:74px;object-fit:cover;display:block}
.gallery-prev .gp-item span{display:block;font-size:11px;color:#94a3b8;padding:4px 6px;word-break:break-all;line-height:1.3}
.chk-inline{display:inline-flex;align-items:center;gap:6px;margin-top:10px;font-size:13px;color:#64748b;cursor:pointer}
.fx-toolbar{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
.fx-toolbar button{width:auto;min-width:38px;height:34px;padding:0 10px;border:1px solid #e2e8f0;background:#fff;border-radius:8px;cursor:pointer;font-size:14px;color:#334155}
.fx-toolbar button:hover{border-color:#0ea5e9;color:#0ea5e9}
.fx-editor{min-height:260px;border:1px solid #e2e8f0;border-radius:10px;padding:16px 18px;font-size:16px;line-height:1.9;outline:none;background:#fff;overflow:auto}
.fx-editor:focus{border-color:#0ea5e9;box-shadow:0 0 0 3px rgba(14,165,233,.12)}
.fx-editor h2{font-size:24px;margin:.6em 0 .4em}
.fx-editor h3{font-size:20px;margin:.6em 0 .4em}
.fx-editor blockquote{margin:.6em 0;padding:8px 14px;border-left:4px solid #0ea5e9;color:#475569;background:#f8fafc}
.fx-editor img{max-width:100%;height:auto;border-radius:8px;display:block;margin:8px 0}
.fx-editor a{color:#0ea5e9}
.fx-hint{font-size:12px;color:#94a3b8;margin:8px 2px 0}
.fx-modal{position:fixed;inset:0;background:rgba(15,23,42,.45);display:flex;align-items:center;justify-content:center;z-index:80}
.fx-modal[hidden]{display:none}
.fx-modal-box{background:#fff;border-radius:14px;width:420px;max-width:92vw;overflow:hidden}
.fx-modal-head{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid #e2e8f0;font-weight:700}
.fx-modal-head button{border:none;background:none;font-size:22px;cursor:pointer;color:#64748b;line-height:1}
.fx-modal-body{padding:16px;max-height:60vh;overflow:auto}
.fx-upload{display:block;text-align:center;background:#0ea5e9;color:#fff;border-radius:10px;padding:10px;cursor:pointer;font-weight:600;margin-bottom:12px}
.fx-lib{display:flex;flex-wrap:wrap;gap:8px}
.fx-lib img{width:72px;height:72px;object-fit:cover;border-radius:8px;cursor:pointer;border:2px solid transparent}
.fx-lib img:hover{border-color:#0ea5e9}
</style>
<script>
window.__PB_MEDIA__ = '<?php echo site_url('admin/media'); ?>';
window.__PB_UPLOAD__ = '<?php echo site_url('admin/media/upload'); ?>';
window.__PB_CSRF__ = '<?php echo csrf_token(); ?>';
</script>
<script src="<?php echo asset('js/fixed-editor.js'); ?>"></script>
<script>
// 新增产品时按「排版方式」选择器联动:选可视化编辑则隐藏图集/详细描述,并提示保存后进入可视化编辑器
(function(){
var sel = document.getElementById('modeSel');
if(!sel) return;
var isCreate = <?php echo $isEdit ? 'false' : 'true'; ?>;
var modeField = document.querySelector('#fxForm input[name="mode"]');
var fixedOnly = document.querySelectorAll('.fx-fixed-only');
var note = document.createElement('p');
note.className = 'fx-hint fx-builder-note';
note.style.cssText = 'margin-top:10px;color:#6d28d9;background:#f5f3ff;border:1px solid #ddd6fe;border-radius:8px;padding:8px 12px;display:none';
note.textContent = '已选择「可视化编辑」:图集与详细描述无需填写,保存后将进入可视化编辑器排版产品详情。';
var firstFixed = fixedOnly[0];
if(firstFixed && firstFixed.parentNode){ firstFixed.parentNode.insertBefore(note, firstFixed); }
function apply(){
var m = sel.value;
if(modeField) modeField.value = m;
if(!isCreate) return; // 编辑页选择器仅用于切换页面,不在此隐藏字段
var isBuilder = (m === 'builder');
for(var i=0;i<fixedOnly.length;i++){ fixedOnly[i].style.display = isBuilder ? 'none' : ''; }
note.style.display = isBuilder ? 'block' : 'none';
}
sel.addEventListener('change', apply);
apply();
})();
</script>
+9 -2
View File
@@ -9,13 +9,15 @@ $cmap = []; foreach ($cm->all() as $c) $cmap[$c['id']] = $c['name'];
<div class="admin-card">
<table class="admin-table">
<tr><th>封面</th><th>名称</th><th>分类</th><th>价格</th><th>状态</th><th>操作</th></tr>
<tr><th>封面</th><th>名称</th><th>分类</th><th>价格</th><th>模式</th><th>状态</th><th>操作</th></tr>
<?php foreach ($products as $p): ?>
<?php $pm = empty($p['mode']) ? 'fixed' : $p['mode']; ?>
<tr>
<td><div class="thum" style="background:<?php echo gradient($p['id']); ?>">❄</div></td>
<td><?php if (!empty($p['cover'])): ?><div class="thum" style="background:#fff"><img src="<?php echo e(site_url($p['cover'])); ?>" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:10px"></div><?php else: ?><div class="thum" style="background:<?php echo gradient($p['id']); ?>">❄</div><?php endif; ?></td>
<td><b><?php echo e($p['name']); ?></b><br><span class="muted" style="font-size:12px"><?php echo e($p['slug']); ?></span></td>
<td><?php echo e($cmap[$p['category_id']] ?? '—'); ?></td>
<td>¥<?php echo e($p['price']); ?></td>
<td><span class="mode-badge <?php echo $pm === 'builder' ? 'builder' : 'fixed'; ?>"><?php echo $pm === 'builder' ? '可视化编辑' : '固定版面'; ?></span></td>
<td><?php echo ($p['status'] ?? 1) ? '<span class="tag-mini">已上线</span>' : '<span class="muted">草稿</span>'; ?></td>
<td>
<div class="row-actions">
@@ -26,4 +28,9 @@ $cmap = []; foreach ($cm->all() as $c) $cmap[$c['id']] = $c['name'];
</tr>
<?php endforeach; ?>
</table>
<style>
.mode-badge{display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600}
.mode-badge.fixed{background:#f1f5f9;color:#475569}
.mode-badge.builder{background:#e0f2fe;color:#0369a1}
</style>
</div>
+9 -2
View File
@@ -2,7 +2,7 @@
<div><h1>站点设置</h1><div class="desc">网站名称、联系方式与 SEO 信息</div></div>
</div>
<div class="admin-card">
<form method="post" action="<?php echo site_url('admin/settings'); ?>">
<form method="post" action="<?php echo site_url('admin/settings'); ?>" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<div class="form-grid">
<div class="field"><label>网站名称</label><input name="site_name" value="<?php echo e($v['site_name']); ?>"></div>
@@ -11,7 +11,14 @@
<div class="field"><label>联系邮箱</label><input name="contact_email" value="<?php echo e($v['contact_email']); ?>"></div>
</div>
<div class="field"><label>联系地址</label><input name="contact_address" value="<?php echo e($v['contact_address']); ?>"></div>
<div class="field"><label>备案号</label><input name="icp" value="<?php echo e($v['icp']); ?>"></div>
<div class="field" style="grid-column:1/-1">
<label>网站 Logo(酷冰甲商标)</label>
<input type="file" name="logo" accept="image/*">
<small style="color:#888;display:block;margin-top:4px">选择图片上传将替换当前 Logo;也可在下方直接填写图片路径或网址(留空则保留现有)。当前:<?php echo e($v['site_logo'] ?: '默认商标'); ?></small>
<input type="text" name="logo_url" value="<?php echo e($v['site_logo']); ?>" placeholder="图片路径或网址,如 assets/img/logo.png 或 https://...">
</div>
<div class="field"><label>备案号(ICP</label><input name="icp" value="<?php echo e($v['icp']); ?>" placeholder="如 苏ICP备XXXXXXXX号"></div>
<div class="field"><label>公安备案号(网安备)</label><input name="gongan" value="<?php echo e($v['gongan'] ?? ''); ?>" placeholder="如 京公网安备11010802012345号"></div>
<hr style="border:none;border-top:1px solid #eef2f7;margin:18px 0">
<div class="field"><label>SEO 标题</label><input name="seo_title" value="<?php echo e($v['seo_title']); ?>"></div>
<div class="field"><label>SEO 关键词</label><input name="seo_keywords" value="<?php echo e($v['seo_keywords']); ?>"></div>
+4 -3
View File
@@ -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';
?>
<section class="page-hero">
<div class="container">
@@ -14,12 +15,12 @@ $hasCanvas = !empty($layoutArr);
<nav class="breadcrumb"><a href="<?php echo site_url(); ?>">首页</a> / <a href="<?php echo site_url('cases'); ?>">客户案例</a> / <?php echo e($c['title']); ?></nav>
</div>
<?php if ($hasCanvas): ?>
<?php if ($useBuilder): ?>
<?php echo \Core\View::buffer('parts/canvas', ['layout' => $layoutArr, 'item' => $c, 'module' => 'case']); ?>
<?php else: ?>
<section class="section" style="padding-top:10px">
<div class="container" style="max-width:780px">
<article class="detail-desc" style="font-size:16px"><?php echo e($c['content']); ?></article>
<article class="detail-desc detail-rich" style="font-size:16px"><?php echo $c['content']; ?></article>
</div>
</section>
<?php endif; ?>
+22
View File
@@ -54,7 +54,11 @@ $banner = $banners[0] ?? ['title' => '科技降温 · 清凉一夏', 'subtitle'
<div class="product-grid">
<?php foreach ($products as $p): ?>
<a class="product-card reveal" href="<?php echo site_url('products/' . $p['slug']); ?>">
<?php if (!empty($p['cover'])): ?>
<div class="product-thumb"><img src="<?php echo e(site_url($p['cover'])); ?>" alt="<?php echo e($p['name']); ?>"></div>
<?php else: ?>
<div class="product-thumb" style="background:<?php echo gradient($p['id']); ?>">❄</div>
<?php endif; ?>
<div class="product-body">
<div class="product-name"><?php echo e($p['name']); ?></div>
<div class="product-sum"><?php echo e($p['summary']); ?></div>
@@ -147,6 +151,24 @@ $banner = $banners[0] ?? ['title' => '科技降温 · 清凉一夏', 'subtitle'
</div>
</section>
<section class="section" style="background:var(--c-surface)">
<div class="container">
<div class="section-head reveal">
<p class="eyebrow">常见问题</p>
<h2 class="section-title">关于降温服,您可能想了解</h2>
<p class="section-sub">高频疑问一站式解答,定制与选型更省心。</p>
</div>
<div class="faq-list">
<?php foreach ($faqs as $f): ?>
<details class="faq-item reveal">
<summary><?php echo e($f['q']); ?><span class="faq-ico">+</span></summary>
<div class="faq-a"><?php echo e($f['a']); ?></div>
</details>
<?php endforeach; ?>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="cta-banner reveal">
+5 -4
View File
@@ -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) {
<header class="site-header" id="siteHeader">
<div class="container nav-inner">
<a class="brand" href="<?php echo site_url(); ?>">
<?php if ($logo): ?><img src="<?php echo e($logo); ?>" alt="<?php echo e($name); ?>" class="brand-logo"><?php else: ?><span class="brand-mark">❄</span><?php endif; ?>
<span class="brand-name"><?php echo e($name); ?></span>
<?php $logoSrc = $logo ? (preg_match('#^https?://|^\/\/#i', (string)$logo) ? $logo : site_url(ltrim($logo, '/'))) : ''; ?>
<?php if ($logoSrc): ?><img src="<?php echo e($logoSrc); ?>" alt="<?php echo e($name); ?>" class="brand-logo"><?php else: ?><span class="brand-mark">❄</span><?php endif; ?>
</a>
<nav class="nav-links" id="navLinks">
<?php foreach ($nav as $n): ?>
@@ -161,8 +162,7 @@ $isActive = function ($url) use ($current) {
<div class="container footer-grid">
<div>
<div class="brand">
<span class="brand-mark">❄</span>
<span class="brand-name"><?php echo e($name); ?></span>
<?php if ($logoSrc): ?><img src="<?php echo e($logoSrc); ?>" alt="<?php echo e($name); ?>" class="brand-logo"><?php else: ?><span class="brand-mark">❄</span><span class="brand-name"><?php echo e($name); ?></span><?php endif; ?>
</div>
<p class="footer-slogan"><?php echo e($slogan); ?></p>
<p class="footer-line">电话:<a href="tel:<?php echo e($phone); ?>"><?php echo e($phone); ?></a></p>
@@ -193,6 +193,7 @@ $isActive = function ($url) use ($current) {
<div class="footer-bottom container">
<span>© <?php echo date('Y'); ?> <?php echo e($name); ?> · 科技降温服装定制</span>
<?php if ($icp): ?><span><?php echo e($icp); ?></span><?php endif; ?>
<?php if ($gongan): ?><span><a href="https://beian.mps.gov.cn/" target="_blank" rel="noopener"><?php echo e($gongan); ?></a></span><?php endif; ?>
</div>
</footer>
+4 -3
View File
@@ -2,7 +2,8 @@
$n = $n ?? null;
$layoutArr = [];
if (!empty($n['layout'])) { $d = json_decode($n['layout'], true); if (is_array($d)) $layoutArr = $d; }
$hasCanvas = !empty($layoutArr);
// 模式渲染:可视化编辑(builder)且有 layout → 画布;否则固定版式(正文富文本)
$useBuilder = !empty($layoutArr) && ($n['mode'] ?? '') !== 'fixed';
?>
<section class="page-hero">
<div class="container">
@@ -14,12 +15,12 @@ $hasCanvas = !empty($layoutArr);
<nav class="breadcrumb"><a href="<?php echo site_url(); ?>">首页</a> / <a href="<?php echo site_url('news'); ?>">新闻动态</a> / <?php echo e($n['title']); ?></nav>
</div>
<?php if ($hasCanvas): ?>
<?php if ($useBuilder): ?>
<?php echo \Core\View::buffer('parts/canvas', ['layout' => $layoutArr, 'item' => $n, 'module' => 'news']); ?>
<?php else: ?>
<section class="section" style="padding-top:10px">
<div class="container" style="max-width:780px">
<article class="detail-desc" style="font-size:16px"><?php echo e($n['content']); ?></article>
<article class="detail-desc detail-rich" style="font-size:16px"><?php echo $n['content']; ?></article>
</div>
</section>
<?php endif; ?>
+4 -1
View File
@@ -5,8 +5,11 @@ if (!empty($p['layout'])) {
$dec = json_decode($p['layout'], true);
if (is_array($dec)) $layout = $dec;
}
// 模式渲染:可视化编辑(builder)且有 layout → 画布;否则固定版式正文。
// 旧页面 mode 为空且含 layout 时仍走画布,保持向后兼容。
$useBuilder = !empty($layout) && ($p['mode'] ?? '') !== 'fixed';
?>
<?php if (!empty($layout)): ?>
<?php if ($useBuilder): ?>
<?php echo \Core\View::buffer('parts/canvas', ['layout' => $layout, 'item' => $p, 'module' => 'page']); ?>
<?php else: ?>
<section class="page-hero">
+4
View File
@@ -24,7 +24,11 @@ $sub = $cat ? $cat['description'] : '水冷循环 / 相变蓄冷 / 涡扇风冷
<?php foreach ($products as $p): ?>
<div class="product-card reveal">
<a class="product-link" href="<?php echo site_url('products/' . $p['slug']); ?>">
<?php if (!empty($p['cover'])): ?>
<div class="product-thumb"><img src="<?php echo e(site_url($p['cover'])); ?>" alt="<?php echo e($p['name']); ?>"></div>
<?php else: ?>
<div class="product-thumb" style="background:<?php echo gradient($p['id']); ?>">❄</div>
<?php endif; ?>
<div class="product-body">
<div class="product-name"><?php echo e($p['name']); ?></div>
<div class="product-sum"><?php echo e($p['summary']); ?></div>
+54 -7
View File
@@ -1,7 +1,13 @@
<?php
$layoutArr = [];
if (!empty($p['layout'])) { $d = json_decode($p['layout'], true); if (is_array($d)) $layoutArr = $d; }
$hasCanvas = !empty($layoutArr);
// 模式渲染:可视化编辑(builder)且有 layout → 画布;否则固定版式(封面/图集/描述)。
$useBuilder = !empty($layoutArr) && ($p['mode'] ?? '') !== 'fixed';
// 固定版面素材
$cover = $p['cover'] ?? '';
$galleryArr = [];
if (!empty($p['gallery'])) { $dg = json_decode($p['gallery'], true); if (is_array($dg)) $galleryArr = $dg; }
?>
<section class="page-hero">
<div class="container">
@@ -16,18 +22,26 @@ $hasCanvas = !empty($layoutArr);
</nav>
</div>
<?php if ($hasCanvas): ?>
<?php if ($useBuilder): ?>
<?php echo \Core\View::buffer('parts/canvas', ['layout' => $layoutArr, 'item' => $p, 'module' => 'product']); ?>
<?php else: ?>
<section class="section detail-sec" style="padding-top:20px">
<div class="container">
<div class="detail-wrap">
<div>
<div class="detail-thumb" style="background:<?php echo gradient($p['id']); ?>">❄</div>
<?php if ($cover): ?>
<div class="detail-thumb"><img class="detail-cover" src="<?php echo e(site_url($cover)); ?>" alt="<?php echo e($p['name']); ?>"></div>
<?php else: ?>
<div class="detail-thumb" style="background:<?php echo gradient($p['id']); ?>">❄</div>
<?php endif; ?>
<div class="gallery">
<div class="detail-thumb" style="aspect-ratio:1/1;font-size:28px;background:<?php echo gradient($p['id'] + 1); ?>">❄</div>
<div class="detail-thumb" style="aspect-ratio:1/1;font-size:28px;background:<?php echo gradient($p['id'] + 2); ?>">❄</div>
<div class="detail-thumb" style="aspect-ratio:1/1;font-size:28px;background:<?php echo gradient($p['id'] + 3); ?>">❄</div>
<?php if (!empty($galleryArr)): ?>
<?php foreach ($galleryArr as $gi): ?><div class="detail-thumb gal"><img src="<?php echo e(site_url($gi)); ?>" alt=""></div><?php endforeach; ?>
<?php else: ?>
<div class="detail-thumb" style="aspect-ratio:1/1;font-size:28px;background:<?php echo gradient($p['id'] + 1); ?>">❄</div>
<div class="detail-thumb" style="aspect-ratio:1/1;font-size:28px;background:<?php echo gradient($p['id'] + 2); ?>">❄</div>
<div class="detail-thumb" style="aspect-ratio:1/1;font-size:28px;background:<?php echo gradient($p['id'] + 3); ?>">❄</div>
<?php endif; ?>
</div>
</div>
<div class="detail-info">
@@ -39,7 +53,7 @@ $hasCanvas = !empty($layoutArr);
<?php foreach ($specs as $s): ?><tr><td><?php echo e($s['k']); ?></td><td><?php echo e($s['v']); ?></td></tr><?php endforeach; ?>
</table>
<?php endif; ?>
<p class="detail-desc"><?php echo e($p['description']); ?></p>
<?php if (!empty($p['description'])): ?><div class="detail-desc detail-rich"><?php echo $p['description']; ?></div><?php endif; ?>
<div style="display:flex;gap:12px;margin-top:24px;flex-wrap:wrap">
<a class="btn btn-primary magnetic" href="<?php echo site_url('order/checkout/' . $p['slug']); ?>">立即购买</a>
<a class="btn btn-ghost" href="<?php echo site_url('contact'); ?>">咨询报价</a>
@@ -51,6 +65,25 @@ $hasCanvas = !empty($layoutArr);
</section>
<?php endif; ?>
<?php if (!empty($faqs)): ?>
<section class="section" style="padding-top:10px">
<div class="container">
<div class="section-head reveal">
<p class="eyebrow">常见问题</p>
<h2 class="section-title">关于本品,您可能想了解</h2>
</div>
<div class="faq-list">
<?php foreach ($faqs as $f): ?>
<details class="faq-item reveal">
<summary><?php echo e($f['q']); ?><span class="faq-ico">+</span></summary>
<div class="faq-a"><?php echo e($f['a']); ?></div>
</details>
<?php endforeach; ?>
</div>
</div>
</section>
<?php endif; ?>
<?php if (!empty($related)): ?>
<section class="section" style="padding-top:30px">
<div class="container">
@@ -58,7 +91,11 @@ $hasCanvas = !empty($layoutArr);
<div class="product-grid">
<?php foreach ($related as $r): ?>
<a class="product-card" href="<?php echo site_url('products/' . $r['slug']); ?>">
<?php if (!empty($r['cover'])): ?>
<div class="product-thumb"><img src="<?php echo e(site_url($r['cover'])); ?>" alt="<?php echo e($r['name']); ?>"></div>
<?php else: ?>
<div class="product-thumb" style="background:<?php echo gradient($r['id']); ?>">❄</div>
<?php endif; ?>
<div class="product-body">
<div class="product-name"><?php echo e($r['name']); ?></div>
<div class="product-sum"><?php echo e($r['summary']); ?></div>
@@ -83,3 +120,13 @@ $hasCanvas = !empty($layoutArr);
</div>
</div>
</div>
<style>
.detail-cover{width:100%;height:100%;object-fit:cover;display:block;border-radius:var(--radius)}
.detail-thumb.gal img{width:100%;height:100%;object-fit:cover;display:block;border-radius:var(--radius)}
.detail-rich img{max-width:100%;height:auto;display:block;border-radius:8px;margin:10px 0}
.detail-rich h2{font-size:24px;margin:.6em 0 .4em}
.detail-rich h3{font-size:20px;margin:.6em 0 .4em}
.detail-rich blockquote{margin:.6em 0;padding:8px 14px;border-left:4px solid var(--c-primary);color:var(--c-muted);background:rgba(14,165,233,.06)}
.detail-rich a{color:var(--c-primary)}
</style>
+9
View File
@@ -0,0 +1,9 @@
-- 存量站点迁移:为 pages 表增加 mode 字段(固定版面 fixed / 可视化编辑 builder
-- 执行方式(二选一):
-- A. 宝塔 / phpMyAdmin:直接运行本文件 SQL
-- B. 命令行:mysql -u<用户> -p<库名> < install/pages-add-mode.sql
-- 说明:默认 'fixed',旧数据(用 content 正文渲染)行为不变;
-- 仅当页面用可视化编辑器存过 layout 时,建议手动将该页 mode 置为 'builder'。
ALTER TABLE `pages`
ADD COLUMN `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed' AFTER `layout`;
+5
View File
@@ -0,0 +1,5 @@
-- 产品表增加编辑模式字段(固定版面 / 可视化编辑)
-- 执行方式:在服务器 MySQL 中运行(已存在则忽略)
ALTER TABLE `products`
ADD COLUMN `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed'
AFTER `layout`;
+9 -4
View File
@@ -9,7 +9,8 @@ CREATE TABLE IF NOT EXISTS `categories` (
`description` TEXT,
`sort_order` INT DEFAULT 0,
`status` TINYINT DEFAULT 1,
`layout` TEXT
`layout` TEXT,
`mode` VARCHAR(16) NOT NULL DEFAULT 'fixed'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `products` (
@@ -27,7 +28,8 @@ CREATE TABLE IF NOT EXISTS `products` (
`sort_order` INT DEFAULT 0,
`status` TINYINT DEFAULT 1,
`created_at` VARCHAR(20) DEFAULT '',
`layout` TEXT
`layout` TEXT,
`mode` VARCHAR(16) NOT NULL DEFAULT 'fixed'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `news` (
@@ -41,7 +43,8 @@ CREATE TABLE IF NOT EXISTS `news` (
`published_at` VARCHAR(20) DEFAULT '',
`status` TINYINT DEFAULT 1,
`views` INT DEFAULT 0,
`layout` TEXT
`layout` TEXT,
`mode` VARCHAR(16) NOT NULL DEFAULT 'fixed'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `cases` (
@@ -57,7 +60,8 @@ CREATE TABLE IF NOT EXISTS `cases` (
`sort_order` INT DEFAULT 0,
`status` TINYINT DEFAULT 1,
`views` INT DEFAULT 0,
`layout` TEXT
`layout` TEXT,
`mode` VARCHAR(16) NOT NULL DEFAULT 'fixed'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `pages` (
@@ -66,6 +70,7 @@ CREATE TABLE IF NOT EXISTS `pages` (
`title` VARCHAR(200) DEFAULT '',
`content` TEXT,
`layout` TEXT,
`mode` VARCHAR(16) NOT NULL DEFAULT 'fixed',
`updated_at` VARCHAR(20) DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+1 -1
View File
@@ -54,7 +54,7 @@ return [
['skey'=>'site_name','sval'=>'酷冰甲 · 降温服','sgroup'=>'site'],
['skey'=>'site_slogan','sval'=>'科技降温 · 清凉一夏','sgroup'=>'site'],
['skey'=>'contact_phone','sval'=>'400-1783-998','sgroup'=>'site'],
['skey'=>'contact_email','sval'=>'service@coolcoth.com','sgroup'=>'site'],
['skey'=>'contact_email','sval'=>'service@st-joyapparel.com','sgroup'=>'site'],
['skey'=>'contact_address','sval'=>'江苏省苏州市工业园区','sgroup'=>'site'],
['skey'=>'icp','sval'=>'苏ICP备10206899号','sgroup'=>'site'],
['skey'=>'seo_title','sval'=>'酷冰甲降温服 - 科技降温服装定制','sgroup'=>'site'],
+10 -3
View File
@@ -18,9 +18,16 @@
--------
1. 仅放 DDL / DML 的标准 MySQL 脚本;多条语句以分号(;)分隔,自动拆分执行。
2. 优先使用「幂等写法」,避免重复执行报错,例如:
CREATE TABLE IF NOT EXISTS xxx (...);
ALTER TABLE yyy ADD COLUMN IF NOT EXISTS zzz ...; -- MySQL 8.0.28+
INSERT INTO ... ON DUPLICATE KEY UPDATE ...;
CREATE TABLE IF NOT EXISTS xxx (...); -- 全版本支持
INSERT INTO ... ON DUPLICATE KEY UPDATE ...; -- 全版本支持
注意:ALTER TABLE ... ADD COLUMN IF NOT EXISTS 仅 MySQL 8.0.28+ / MariaDB 10.8+ 支持,
旧版本(含多数宝塔默认的 MySQL 5.7 与 MariaDB 10.4/10.6)会直接报 1064 语法错误。
跨版本安全的「加列」幂等方式(强烈推荐):
SET @db = DATABASE();
SET @has = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='yyy' AND COLUMN_NAME='zzz');
SET @sql = IF(@has=0, 'ALTER TABLE `yyy` ADD COLUMN `zzz` VARCHAR(16) NOT NULL DEFAULT \'x\'', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
3. 不建议在升级包中执行 DROP TABLE / TRUNCATE 等破坏性语句,除非确有必要。
4. 升级前请务必备份数据库。
+1
View File
@@ -1,2 +1,3 @@
open_basedir=/www/wwwroot/coolcoth.com/:/tmp/:/www/php_session/coolcoth.com/
session.save_path=/www/php_session/coolcoth.com/
session.save_handler = files
@@ -0,0 +1 @@
PHqawapl1d7V9jyaDpgR989D6OsYYgu3uhu0okCFSiQ.krcH3SDD7MS0vIjcTesz3UY027U_QVi9wgmRkOEaVsk
@@ -0,0 +1 @@
RujJU7tnXzKz7XKgxMybOa03xFazoKaO7J6ivq9ObvI.krcH3SDD7MS0vIjcTesz3UY027U_QVi9wgmRkOEaVsk
+12
View File
@@ -35,6 +35,7 @@ img{max-width:100%;display:block}
.site-header.scrolled{box-shadow:0 10px 30px -18px rgba(0,0,0,.35)}
.nav-inner{display:flex;align-items:center;justify-content:space-between;height:72px;gap:20px}
.brand{display:flex;align-items:center;gap:10px;font-weight:800;font-size:19px}
.brand-logo{display:block;height:52px;width:auto}
.brand-mark{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;background:linear-gradient(135deg,var(--c-primary),var(--c-secondary));color:#fff;font-size:18px}
.brand-name{letter-spacing:-.01em}
.nav-links{display:flex;gap:6px}
@@ -80,6 +81,7 @@ img{max-width:100%;display:block}
.product-card{background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column;transition:transform .3s cubic-bezier(.16,1,.3,1),box-shadow .3s,border-color .3s}
.product-card:hover{transform:translateY(-8px);box-shadow:0 30px 60px -30px var(--c-primary);border-color:color-mix(in srgb,var(--c-primary) 50%,var(--c-border))}
.product-thumb{aspect-ratio:4/3;display:grid;place-items:center;color:#fff;font-size:40px;position:relative;overflow:hidden}
.product-thumb img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.product-thumb::after{content:"";position:absolute;inset:0;background:radial-gradient(circle at 30% 20%,rgba(255,255,255,.35),transparent 50%)}
.product-body{padding:20px;display:flex;flex-direction:column;gap:8px;flex:1}
.product-name{font-size:17px;font-weight:800}
@@ -213,3 +215,13 @@ img{max-width:100%;display:block}
.pay-demo-box{margin-top:18px;padding:30px 20px;border-radius:var(--radius);text-align:center;background:linear-gradient(160deg,color-mix(in srgb,var(--c-primary) 10%,var(--c-surface)),var(--c-surface));border:1px solid var(--c-border)}
.pay-demo-icon{font-size:48px;margin-bottom:8px}
.order-sum{margin-bottom:6px}
/* ===== FAQ 折叠(首页/产品页)===== */
.faq-list{display:flex;flex-direction:column;gap:12px;margin-top:8px}
.faq-item{border:1.5px solid var(--c-border);border-radius:var(--radius);background:var(--c-surface);overflow:hidden;transition:border-color .2s,box-shadow .2s}
.faq-item[open]{border-color:var(--c-primary);box-shadow:0 16px 40px -24px var(--c-primary)}
.faq-item summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:18px 20px;cursor:pointer;font-size:17px;font-weight:700;list-style:none}
.faq-item summary::-webkit-details-marker{display:none}
.faq-item .faq-ico{flex:none;width:26px;height:26px;display:grid;place-items:center;border-radius:50%;background:color-mix(in srgb,var(--c-primary) 14%,transparent);color:var(--c-primary);font-size:20px;line-height:1;transition:transform .2s}
.faq-item[open] .faq-ico{transform:rotate(45deg)}
.faq-a{padding:0 20px 20px;color:var(--c-muted);line-height:1.85;font-size:15px}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 688 B

After

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 948 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+20
View File
@@ -8,5 +8,25 @@ Disallow: /config/
Disallow: /storage/
Disallow: /index.php
Disallow: /*.php$
Disallow: /public/css/
Disallow: /public/js/
Allow: /$
# 显式放行主流 AI 搜索 / 回答引擎爬虫(默认本就放行,此处为明确声明,避免误伤)
User-agent: GPTBot
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: CCBot
Allow: /
User-agent: anthropic-ai
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Applebot
Allow: /
Sitemap: https://coolcoth.com/sitemap.xml
+16 -4
View File
@@ -1,6 +1,3 @@
# www.coolcoth.com robots.txt
# 禁止爬虫抓取后台、系统、内部静态资源等敏感路径
User-agent: *
Disallow: /admin/
Disallow: /CRM/
@@ -14,7 +11,22 @@ Disallow: /*.php$
Disallow: /public/css/
Disallow: /public/js/
# 允许抓取前台主站内容
Allow: /$
# 显式放行主流 AI 搜索 / 回答引擎爬虫(默认本就放行,此处为明确声明,避免误伤)
User-agent: GPTBot
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: CCBot
Allow: /
User-agent: anthropic-ai
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Applebot
Allow: /
Sitemap: https://coolcoth.com/sitemap.xml
+1
View File
@@ -0,0 +1 @@
{"113.90.83.212":{"fail":[],"ok":[1786108679],"block_until":0},"183.23.145.141":{"fail":[],"ok":[1786092620,1786092704],"block_until":0},"117.82.91.178":{"fail":[],"ok":[1786094733,1786095394,1786095571],"block_until":0},"113.84.64.243":{"fail":[],"ok":[1786095727],"block_until":0}}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# verify_coolcoth_deploy.sh
# 部署到 coolcoth.com 后自动复核安全响应头是否生效。
# 用法: bash verify_coolcoth_deploy.sh
set -u
HOST="coolcoth.com"
pass=0; fail=0
check(){ # $1=名称 $2=是否通过(1/0) $3=实际值
if [ "$2" = "1" ]; then echo "[PASS] $1"; pass=$((pass+1));
else echo "[FAIL] $1 -> 实际: $3"; fail=$((fail+1)); fi
}
hdr=$(curl -s -D - -o /dev/null -m 15 "https://$HOST/")
hsts=$(printf '%s' "$hdr" | tr -d '\r' | awk -F': ' 'tolower($1)=="strict-transport-security"{print $2}')
ref=$(printf '%s' "$hdr" | tr -d '\r' | awk -F': ' 'tolower($1)=="referrer-policy"{print $2}')
perm=$(printf '%s' "$hdr" | tr -d '\r' | awk -F': ' 'tolower($1)=="permissions-policy"{print $2}')
xct=$(printf '%s' "$hdr" | tr -d '\r' | awk -F': ' 'tolower($1)=="x-content-type-options"{print $2}')
echo "=== coolcoth.com 部署复核 @ $(date '+%F %T') ==="
echo "$hsts" | grep -q "max-age=63072000" && check "HSTS max-age=63072000" 1 || check "HSTS max-age" 0 "$hsts"
echo "$hsts" | grep -q "includeSubDomains" && check "HSTS includeSubDomains" 1 || check "HSTS includeSubDomains" 0 "$hsts"
echo "$hsts" | grep -q "preload" && check "HSTS preload" 1 || check "HSTS preload" 0 "$hsts"
echo "$ref" | grep -qi "strict-origin-when-cross-origin" && check "Referrer-Policy=strict-origin-when-cross-origin" 1 || check "Referrer-Policy" 0 "$ref"
echo "$perm" | grep -q "payment=()" && check "Permissions-Policy 含 payment=()" 1 || check "Permissions-Policy payment" 0 "$perm"
echo "$xct" | grep -qi "nosniff" && check "X-Content-Type-Options=nosniff" 1 || check "X-Content-Type-Options" 0 "$xct"
echo "----"
echo "PASS=$pass FAIL=$fail"
[ "$fail" = "0" ] && echo ">> 部署成功 ✅(所有安全头已生效)" || echo ">> 仍有未生效项 ❌(部署/清OPcache未完成)"
echo
echo "【登录 429 限流需人工确认】验证码为算术图,外部自动化无法解;请按手册第5步用有效 csrf+captcha 压测 5~6 次错密,确认返回 429。"
@@ -1,71 +0,0 @@
# 圣巧依官网 ── 切换 coolcoth.comApache → Nginx)部署说明
> 适用:把原 `coolcoth.com` 站点迁移到新域名 `coolcoth.com`,并将 Web 服务由 Apache 切换为 Nginx(宝塔环境)。
> 联系邮箱保持 `service@coolcoth.com`(同属圣巧依公司,未随域名变更)。
---
## 一、本次改动清单(相对原 coolcoth.com 代码)
| 文件 | 改动内容 | 是否需上传替换 |
|---|---|---|
| `nginx/coolcoth.com.conf` | **新增**Nginx 完整站点配置(http→https、www 规范、伪静态、安全头、acme) | ✅ 新增(参考/直接用作站点配置) |
| `app/Core/Helper.php` | `apply_security_headers()` 改为只输出动态 CSP;通用安全头(HSTS 等)移交 Nginx 层下发,避免重复头 | ✅ 替换 |
| `public/robots.txt` | Sitemap 地址改为 `https://coolcoth.com/sitemap.xml` | ✅ 替换 |
| `robots.txt`(项目根) | 同上 | ✅ 替换 |
| `public/user.ini` | 会话目录改为 `/www/php_session/coolcoth.com/` | ✅ 替换 |
| `app/Core/Theme.php` | 联系邮箱(保持 `service@coolcoth.com`,未改) | ❌ 无需替换 |
| `install/seed.php` | 联系邮箱(保持 `service@coolcoth.com`,未改) | ❌ 无需替换 |
| `.htaccess` / `public/.htaccess` | Apache 规则,Nginx 下不生效,保留无害 | ❌ 无需替换 |
> 因站点功能(路由、SEO、后台)基于请求域名自动生成 URL`site_url()` / `absolute_url()`),
> **除上方显式列出的硬编码点外,其余代码无需改动即可适配新域名**。
---
## 二、宝塔 Nginx 部署步骤
1. **DNS 解析**`coolcoth.com``www.coolcoth.com` 均 A 记录指向阿里云 ECS 公网 IP。
2. **建站**:宝塔 → 网站 → 新建站点 `coolcoth.com`(同时添加 `www.coolcoth.com`),
- Web 服务:**Nginx**
- **运行目录 = `/public`**
3. **站点配置**
- 方式 A(推荐):网站 → 设置 → **配置文件**,整体替换为 `nginx/coolcoth.com.conf` 中的 `server` 块;
- 方式 B:只把文件「伪静态区」内容粘到「**伪静态**」框,再用「设置 → 重定向」开启 https + www 跳转。
- ⚠️ 修改 `fastcgi_pass``php-cgi-74.sock` 为你服务器实际 PHP 版本(如 `php-cgi-80.sock` / `php-cgi-82.sock`)。
4. **SSL**:网站 → SSL → Let's Encrypt**勾选 `coolcoth.com``www.coolcoth.com`** → 申请并开启「强制 HTTPS」。
5. **上传代码**:把整站代码传到 `/www/wwwroot/coolcoth.com/`(运行目录为 `public/`),
并用本包内的 `app/Core/Helper.php``public/robots.txt``robots.txt``public/user.ini` 覆盖对应文件。
6. **建会话目录**:服务器上创建 `/www/php_session/coolcoth.com/` 并赋予 PHP 进程写权限(与 `user.ini` 中路径一致)。
---
## 三、安全头分工(重要)
- **通用头**HSTS / X-Frame-Options / X-Content-Type-Options / Referrer-Policy / Permissions-Policy 等):
由 Nginx 在服务器层用 `add_header ... always` 统一下发,**覆盖静态资源**,不依赖 PHP。
- **严格 CSP**(含每次请求随机 nonce):由 PHP `Helper::apply_security_headers()``public/index.php` 入口下发。
- 二者不再重复,行为与原 Apache 环境一致。
---
## 四、验证
- `http://coolcoth.com` / `https://coolcoth.com` / `https://www.coolcoth.com` 均应 301/200 收敛到 `https://www.coolcoth.com`
- 首页、产品、新闻、后台 `/admin` 正常;`/sitemap.xml` 可访问。
- 用夸克浏览器测试(新域名未被旧拦截规则影响)。
- 浏览器 F12 → Network 查看响应头含 `Strict-Transport-Security``Content-Security-Policy``X-Frame-Options` 等。
---
## 五、WWW 策略说明
当前配置延续原 Apache 的「强制 www」策略(所有访问跳到 `www.coolcoth.com`)。
若希望以裸域 `coolcoth.com` 为主,删除 `nginx/coolcoth.com.conf` 中 80 server 的 `www.` 前缀,
以及 443 server 内对 `$host` 的 www 跳转即可。
---
## 六、旧域名
`coolcoth.com` 若不再使用,请在 DNS / 阿里云释放;其服务器目录可保留或清理。
+1 -1
View File
@@ -1,7 +1,7 @@
# 圣巧依 / 酷冰甲 CMS — 完整性 · 安全性审计 & 后台美化报告
- 本地:`G:\www\phpEnv\www\圣巧依新的`
- 远端:https://coolcoth.com/ (后台 `admin`
- 远端:https://st-joyapparel.com/ (后台 `admin`
- 技术栈:原生 PHP MVC(自研路由/View/Model),MySQL / 文件双驱动
- 说明:项目为 PHP 服务端渲染,与 Nuxt/Nuxt-UI 无关,本次未引入前端框架。
+2 -2
View File
@@ -6,7 +6,7 @@
---
## 一、项目定位与技术底座
- **站点**:酷冰甲降温服企业官网,线上 `coolcoth.com`,后台 `/admin`
- **站点**:酷冰甲降温服企业官网,线上 `st-joyapparel.com`,后台 `/admin`
- **架构**:原生 PHP 7.4 MVC(无 Composer/框架),前端 jQuery + 自研 `page-builder.js`CSS 变量主题引擎。
- **存储**:双模式——默认文件 JSON(`storage/data/*`),可切 MySQL`install/schema.sql`)。`Core\Model` 统一抽象。
- **能力**:产品/分类/新闻/客户案例/单页 5 类内容 + 订单/支付(支付宝/微信 demo+live+ 三级权限(super_admin/admin/user+ 可视化自由画布。
@@ -38,7 +38,7 @@
### 阶段 E:打磨与 Bug 收口
14. **暗色主题自适应**`canvas.php` 改用 CSS 变量,历史 `#0f172a` 归一 `var(--c-text)``page/show.php` 复用共享 canvas 去掉重复内联。
15. **全站品牌更名**`圣巧依``酷冰甲``service@sqy58.com``service@coolcoth.com`19 文件替换 + grep 0 命中(保留 SQY 型号代号/英文 eyebrow/目录名/参考外链)。
15. **全站品牌更名**`圣巧依``酷冰甲``service@sqy58.com``service@st-joyapparel.com`19 文件替换 + grep 0 命中(保留 SQY 型号代号/英文 eyebrow/目录名/参考外链)。
16. **案例/新闻编辑 404**:表单缺 slug 框 → update 按中文标题重建 slug。补 slug 框 + 隐藏 content 框。
17. **升级清数据根因**`Installer::upgrade()``DELETE` 清空客户数据。改为非破坏(按 id/skey 补齐);update slug 空则保留;前台 show 加数字 id 兜底。
18. **slug 顺序生成**store 先插取 id → slug=id(自定义则 slugify),URL 短而稳定。