上次错误

This commit is contained in:
2026-08-08 16:26:31 +08:00
parent 8101177cb5
commit ba660d0c57
50 changed files with 1250 additions and 1326 deletions
@@ -0,0 +1 @@
v2e3x8BS4vM7cvb9Ytf68DSRtmxIHkPDx_e0XoBym08.krcH3SDD7MS0vIjcTesz3UY027U_QVi9wgmRkOEaVsk
+747
View File
@@ -0,0 +1,747 @@
<?php
/**
* 全局辅助函数(视图与控制器中可直接调用)
*/
if (!function_exists('site_url')) {
function base_url(): string
{
$proto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
// 由 DOCUMENT_ROOT + SCRIPT_FILENAME 推导站点根路径,兼容内置服务器路由模式与生产环境
$docroot = $_SERVER['DOCUMENT_ROOT'] ?? '';
$scriptFile = $_SERVER['SCRIPT_FILENAME'] ?? '';
$basePath = '';
if ($docroot && $scriptFile && strpos($scriptFile, $docroot) === 0) {
$rel = substr($scriptFile, strlen($docroot)); // 如 /index.php 或 /sub/index.php
$basePath = rtrim(dirname($rel), '/');
}
return rtrim($proto . '://' . $host . $basePath, '/');
}
function site_url(string $path = ''): string
{
return base_url() . '/' . ltrim($path, '/');
}
function asset(string $path = ''): string
{
return site_url('assets/' . ltrim($path, '/'));
}
function e($v): string
{
return htmlspecialchars((string) $v, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
function slugify(string $s): string
{
$s = preg_replace('~[^\pL\pN]+~u', '-', $s);
$s = trim($s, '-');
return strtolower($s) ?: 'item';
}
function format_date($ts, string $fmt = 'Y-m-d'): string
{
if (!$ts) return '';
$t = is_numeric($ts) ? (int)$ts : strtotime($ts);
return $t ? date($fmt, $t) : '';
}
function csrf_token(): string
{
if (empty($_SESSION['_csrf'])) {
$_SESSION['_csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['_csrf'];
}
function csrf_field(): string
{
return '<input type="hidden" name="_csrf" value="' . csrf_token() . '">';
}
function csrf_check(): bool
{
$token = $_POST['_csrf'] ?? ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
return isset($_SESSION['_csrf']) && hash_equals($_SESSION['_csrf'], $token);
}
/** 当前管理员是否已登录 */
function is_admin(): bool
{
return !empty($_SESSION['admin_logged']);
}
function admin_required()
{
if (!is_admin()) {
header('Location: ' . site_url('admin/login'));
exit;
}
}
/** 当前登录管理员的角色:super_admin | admin | user | none */
function admin_role(): string
{
return $_SESSION['admin_role'] ?? 'user';
}
/** 当前登录管理员 ID(配置文件兜底登录时为 0) */
function admin_uid(): ?int
{
return $_SESSION['admin_id'] ?? null;
}
/** 角色 -> 能力映射(可访问的模块/操作) */
function admin_role_map(): array
{
return [
'super_admin' => ['dashboard', 'products', 'categories', 'news', 'cases', 'pages', 'banners', 'settings', 'theme', 'users', 'system', 'orders', 'payments', 'password'],
'admin' => ['dashboard', 'products', 'categories', 'news', 'cases', 'pages', 'banners', 'orders', 'password'],
// user 为受限角色:默认仅仪表盘权限,不预开 products/news/cases 等后台模块。
// 进入 CRM/PSI 后左导据此严格收敛——CRM 操作员(后台角色=user)只看到仪表盘 + 当前子系统功能,
// 其余后台模块由其是否拥有对应 admin 能力决定;后台管理员/超管不受影响(见 admin / super_admin 行)。
'user' => ['dashboard', 'password'],
// none = 无后台主角色:该账号仅作为 CRM/PSI 子系统账号存在,不拥有任何后台模块权限。
// 进入子系统后左导仍按 subsys_role 严格收敛,避免与「用户」混淆导致角色分配混乱。
'none' => ['password'],
];
}
/** 当前登录管理员是否具备某项能力 */
function admin_can(string $cap): bool
{
$role = admin_role();
return in_array($cap, admin_role_map()[$role] ?? [], true);
}
/** 角色中文名 */
function admin_role_label(string $role): string
{
return ['super_admin' => '超级管理员', 'admin' => '管理员', 'user' => '用户', 'none' => '无'][$role] ?? '用户';
}
/** 必须是指定角色,否则拦截(用于控制器构造函数) */
function role_required(string $role): void
{
if (!is_admin()) {
header('Location: ' . site_url('admin/login'));
exit;
}
if (admin_role() !== $role) {
\Core\App::forbidden('需要 ' . admin_role_label($role) . ' 权限');
exit;
}
}
/** 根据种子生成品牌渐变(用于占位图) */
function gradient($seed = 0): string
{
$angle = 110 + (intval($seed) * 37) % 160;
return "linear-gradient({$angle}deg,var(--c-primary),var(--c-secondary))";
}
/* ---------- 分系统权限(CRM / 进销存 PSI ---------- */
/**
* 用户在某业务系统中的角色。super_admin 在任意系统都视为最高权限管理员。
* @param string $sys crm | psi
*/
function subsys_role(string $sys): string
{
if (admin_role() === 'super_admin') return 'super_admin';
$key = $sys . '_role';
return $_SESSION[$key] ?? 'none';
}
/** 是否为某系统的管理员(含超管) */
function subsys_admin(string $sys): bool
{
return in_array(subsys_role($sys), ['super_admin', 'admin'], true);
}
/** 是否为某系统的用户(含管理员、超管) */
function subsys_user(string $sys): bool
{
return subsys_role($sys) !== 'none';
}
/** 是否能进入某业务系统(拥有该系统任意角色即可:超管/管理员/用户) */
function subsys_can_enter(string $sys): bool
{
return subsys_user($sys);
}
/** 分系统页面清单(key 与子系统 nav 的 k 对应;dashboard 始终可见) */
function subsys_pages(string $sys): array
{
return $sys === 'psi'
? ['dashboard', 'materials', 'products', 'suppliers', 'purchases', 'sales', 'stock', 'orders',
'sales_orders', 'purchase_orders', 'outbounds', 'reports', 'reminders']
: ['dashboard', 'customers', 'leads', 'followups', 'contacts'];
}
/** 当前 PSI 用户未读的紧急事件数量(用于导航铃铛徽标) */
function psi_unread_events(): int
{
if (!subsys_user('psi')) return 0;
$uid = (int) ($_SESSION['admin_uid'] ?? 0);
if ($uid <= 0) return 0;
try {
$events = (new \App\Models\PSI\Event())->all();
} catch (\Throwable $e) {
return 0;
}
$n = 0;
foreach ($events as $ev) {
$read = json_decode($ev['read_by'] ?? '[]', true) ?: [];
if (!in_array($uid, $read, true)) $n++;
}
return $n;
}
/**
* 当前登录用户在 $sys 系统各页面的「可见」权限数组。
* 子系统管理员(含超管,subsys_admin)拥有该系统全部页面;
* 仅“用户”角色读 session 中的 {sys}_perms(登录时写入)做细粒度控制;
* 旧账号无 perms 记录则默认全部可见(向后兼容,不会突然锁死)。
*/
function subsys_page_perms(string $sys): array
{
// 关键修复:以“分系统角色”判定管理员,而非仅看主角色是否为 super_admin。
// 否则 CRM/PSI 管理员(crm_role=admin、主角色为“管理员”)会被误判为普通用户,
// 一旦 {sys}_perms 受限就只剩仪表盘,导致左导菜单残缺、子页面 403。
if (subsys_admin($sys)) {
return array_fill_keys(subsys_pages($sys), true) + ['dashboard' => true];
}
$perms = $_SESSION[$sys . '_perms'] ?? null;
$out = ['dashboard' => true];
foreach (subsys_pages($sys) as $p) {
if ($p === 'dashboard') continue;
$out[$p] = ($perms === null) ? true : !empty($perms[$p]);
}
return $out;
}
/** 当前用户能否进入 $sys 系统的某页面 */
function subsys_page_can(string $sys, string $page): bool
{
return !empty(subsys_page_perms($sys)[$page]);
}
/** 过滤子系统侧边导航:隐藏无权限页面项(dashboard 永留) */
function subsys_filter_nav(string $sys, array $nav): array
{
return array_values(array_filter($nav, function ($n) use ($sys) {
if (($n['k'] ?? '') === 'dashboard') return true;
return subsys_page_can($sys, $n['k']);
}));
}
/**
* 登录后落地页:依据子系统权限优先进入对应子系统仪表盘。
* 设计目标:拥有 CRM / PSI 权限的账号登录后直达「CRM / PSI 仪表盘」,
* 而非总后台仪表盘;总后台仪表盘仅留给「无任何子系统权限」的纯后台账号。
* - 仅拥有 CRM:进入 CRM 仪表盘
* - 仅拥有 PSI:进入 PSI 仪表盘
* - 同时拥有 CRM+PSI:默认进入 CRM 仪表盘(左侧导航可切换 PSI)
* - 无任何子系统权限(纯内容管理员 / 编辑):进入总后台仪表盘
*/
function login_landing(): string
{
$crm = subsys_user('crm');
$psi = subsys_user('psi');
if ($crm && !$psi) return 'CRM';
if ($psi && !$crm) return 'PSI';
if ($crm && $psi) return 'CRM';
return 'admin';
}
/**
* 后台(admin)左侧导航全量项。admin 主后台与 CRM / PSI 子系统布局共用,
* 保证「后台框架一致」:进入 CRM / PSI 后左侧仍是同一套完整后台菜单(当前子系统主项高亮)。
* 各页面项按角色能力(admin_can)过滤,子系统入口按 subsys_user 显隐,
* 与 App 路由层的 capMap / 权限拦截保持一致。
*/
function admin_nav_items(): array
{
$baseItems = [
['k' => '', 'label' => '仪表盘', 'ic' => 'dashboard', 'url' => 'admin'],
['k' => 'products', 'label' => '产品管理', 'ic' => 'snowflake', 'url' => 'admin/products'],
['k' => 'categories', 'label' => '分类管理', 'ic' => 'folders', 'url' => 'admin/categories'],
['k' => 'news', 'label' => '新闻管理', 'ic' => 'newspaper', 'url' => 'admin/news'],
['k' => 'cases', 'label' => '客户案例', 'ic' => 'handshake', 'url' => 'admin/cases'],
['k' => 'pages', 'label' => '单页管理', 'ic' => 'file-text', 'url' => 'admin/pages'],
['k' => 'banners', 'label' => '轮播管理', 'ic' => 'images', 'url' => 'admin/banners'],
['k' => 'settings', 'label' => '站点设置', 'ic' => 'settings', 'url' => 'admin/settings'],
['k' => 'theme', 'label' => '风格设置', 'ic' => 'palette', 'url' => 'admin/theme'],
];
$nav = [];
foreach ($baseItems as $it) {
// 仪表盘始终可见;其余按角色能力 admin_can 过滤(无权限则隐藏且不可直访)
if ($it['k'] === '' || admin_can($it['k'])) $nav[] = $it;
}
// 子系统入口:拥有对应系统角色的管理员可见(点击进入 CRM / PSI)
if (subsys_user('crm')) $nav[] = ['k' => 'crm', 'label' => '客户管理 CRM', 'ic' => 'handshake', 'url' => 'CRM'];
if (subsys_user('psi')) $nav[] = ['k' => 'psi', 'label' => '进销存 PSI', 'ic' => 'package', 'url' => 'PSI'];
// 仅超级管理员可见「用户管理 / 订单管理 / 支付设置 / 系统设置 / 数据库管理 / 数据库升级」
if (admin_role() === 'super_admin') {
$nav[] = ['k' => 'users', 'label' => '用户管理', 'ic' => 'users', 'url' => 'admin/users'];
$nav[] = ['k' => 'orders', 'label' => '订单管理', 'ic' => 'receipt', 'url' => 'admin/orders'];
$nav[] = ['k' => 'payments', 'label' => '支付设置', 'ic' => 'wallet', 'url' => 'admin/payments'];
$nav[] = ['k' => 'system', 'label' => '系统设置', 'ic' => 'settings', 'url' => 'admin/system'];
$nav[] = ['k' => 'db', 'label' => '数据库管理', 'ic' => 'database', 'url' => 'admin/db'];
$nav[] = ['k' => 'upgrade', 'label' => '数据库升级', 'ic' => 'upload', 'url' => 'admin/upgrade',
'badge' => (\Core\Db::driver() === 'mysql' ? db_pending_upgrades() : 0) ?: null];
} elseif (admin_role() === 'admin') {
$nav[] = ['k' => 'orders', 'label' => '订单管理', 'ic' => 'receipt', 'url' => 'admin/orders'];
}
return $nav;
}
/**
* 分系统(CRM/PSI)侧边导航:统一来源,含「用户管理」(仅该系统管理员可见)。
* 与 layouts/subsys.php 共用,避免逐个控制器重复维护导航数组。
*/
function subsys_nav(string $sys): array
{
$pages = $sys === 'psi'
? [
['k' => 'dashboard', 'label' => '仪表盘', 'url' => 'PSI'],
['k' => 'materials', 'label' => '物料管理', 'url' => 'PSI/materials'],
['k' => 'products', 'label' => '成品管理', 'url' => 'PSI/products'],
['k' => 'suppliers', 'label' => '供应商', 'url' => 'PSI/suppliers'],
['k' => 'purchases', 'label' => '采购入库', 'url' => 'PSI/purchases'],
['k' => 'sales', 'label' => '销售出库', 'url' => 'PSI/sales'],
['k' => 'stock', 'label' => '库存流水', 'url' => 'PSI/stock'],
['k' => 'orders', 'label' => '订单管理', 'url' => 'PSI/orders'],
['k' => 'sales_orders', 'label' => '销售订单', 'url' => 'PSI/sales_orders'],
['k' => 'purchase_orders', 'label' => '采购订单', 'url' => 'PSI/purchase_orders'],
['k' => 'outbounds', 'label' => '出库单', 'url' => 'PSI/outbounds'],
['k' => 'reports', 'label' => '报表中心', 'url' => 'PSI/reports'],
['k' => 'reminders', 'label' => '紧急提醒', 'url' => 'PSI/reminders'],
]
: [
['k' => 'dashboard', 'label' => '仪表盘', 'url' => 'CRM'],
['k' => 'customers', 'label' => '客户管理', 'url' => 'CRM/customers'],
['k' => 'leads', 'label' => '商机线索', 'url' => 'CRM/leads'],
['k' => 'followups', 'label' => '跟进记录', 'url' => 'CRM/followups'],
['k' => 'contacts', 'label' => '客户联系人', 'url' => 'CRM/contacts'],
];
// 仅该系统管理员可管理本系统用户
if (subsys_admin($sys)) {
$pages[] = ['k' => 'users', 'label' => '用户管理', 'url' => strtoupper($sys) . '/users'];
if ($sys === 'psi') {
$pages[] = ['k' => 'notifications', 'label' => '通知设置', 'url' => 'PSI/notifications'];
}
}
// 仅主角色为超管/管理员时显示「管理后台」入口
if (in_array(admin_role(), ['super_admin', 'admin'], true)) {
$pages[] = ['k' => 'admin', 'label' => '管理后台', 'url' => 'admin'];
}
return $pages;
}
/** PSI 系统完整导航(含订单/出库/报表,所有控制器统一调用) */
function psi_nav(): array
{
return [
['k' => 'dashboard', 'label' => '仪表盘', 'icon' => admin_icon('home', 16) ?: '📊', 'url' => 'PSI'],
['k' => 'materials', 'label' => '物料管理', 'icon' => admin_icon('box', 16) ?: '🧵', 'url' => 'PSI/materials'],
['k' => 'products', 'label' => '成品管理', 'icon' => admin_icon('shirt', 16) ?: '👕', 'url' => 'PSI/products'],
['k' => 'suppliers', 'label' => '供应商', 'icon' => admin_icon('buildings', 16) ?: '🏭', 'url' => 'PSI/suppliers'],
['k' => 'purchases', 'label' => '采购入库', 'icon' => admin_icon('download', 16) ?: '📥', 'url' => 'PSI/purchases'],
['k' => 'sales', 'label' => '销售出库', 'icon' => admin_icon('upload', 16) ?: '📤', 'url' => 'PSI/sales'],
['k' => 'stock', 'label' => '库存流水', 'icon' => admin_icon('package', 16) ?: '📦', 'url' => 'PSI/stock'],
['k' => 'orders', 'label' => '订单管理', 'icon' => admin_icon('receipt', 16) ?: '🧾', 'url' => 'PSI/orders'],
['k' => 'sales_orders', 'label' => '销售订单', 'icon' => admin_icon('file-text', 16) ?: '📝', 'url' => 'PSI/sales_orders'],
['k' => 'purchase_orders', 'label' => '采购订单', 'icon' => admin_icon('file-text', 16) ?: '📋', 'url' => 'PSI/purchase_orders'],
['k' => 'outbounds', 'label' => '出库单', 'icon' => admin_icon('truck', 16) ?: '🚚', 'url' => 'PSI/outbounds'],
['k' => 'reports', 'label' => '报表中心', 'icon' => admin_icon('chart-bar', 16) ?: '📊', 'url' => 'PSI/reports'],
];
}
/** 生成单据号:前缀 + 秒级时间 + 进程内计数器 + 随机,保证唯一 */
function psi_gen_no(string $prefix): string
{
static $c = 0;
$c++;
return strtoupper($prefix) . date('YmdHis') . str_pad($c, 4, '0', STR_PAD_LEFT) . mt_rand(10, 99);
}
/** 根据已交付数量重算销售订单状态(pending/partial/delivered */
function psi_recompute_so(int $soId): void
{
$items = (new \App\Models\PSI\SalesOrderItem())->whereAll('so_id', $soId);
$total = 0; $delivered = 0;
foreach ($items as $it) {
$total += (int)($it['qty'] ?? 0);
$delivered += (int)($it['delivered_qty'] ?? 0);
}
$status = $total <= 0 ? 'pending' : ($delivered >= $total ? 'delivered' : ($delivered > 0 ? 'partial' : 'pending'));
(new \App\Models\PSI\SalesOrder())->update($soId, ['status' => $status]);
}
/** 打印页外壳:独立 HTML + A4 样式 + 自动打印 */
function psi_print_shell(string $title, string $body): string
{
$css = 'body{font-family:-apple-system,"Microsoft YaHei",sans-serif;color:#111;margin:0;padding:24px;background:#fff;}'
. '.doc{width:210mm;max-width:100%;margin:0 auto;}'
. '@media print{body{padding:0;}.no-print{display:none!important;}@page{margin:12mm;}}'
. '.doc h2{text-align:center;margin:0 0 4px;font-size:20px;}'
. '.doc .sub{text-align:center;color:#666;margin-bottom:16px;font-size:13px;}'
. '.doc .meta{display:flex;flex-wrap:wrap;gap:6px 28px;font-size:13px;margin:12px 0;border-bottom:1px dashed #ccc;padding-bottom:10px;}'
. '.doc .meta b{color:#374151;}'
. '.doc table{border-collapse:collapse;width:100%;font-size:13px;margin-top:8px;}'
. '.doc th,.doc td{border:1px solid #bbb;padding:7px 9px;}'
. '.doc th{background:#f3f4f6;}'
. '.doc .total{text-align:right;font-weight:700;margin-top:12px;font-size:14px;}'
. '.doc .sign{display:flex;justify-content:space-between;margin-top:40px;font-size:13px;color:#374151;}'
. '.btn-print{position:fixed;top:16px;right:16px;padding:10px 18px;border-radius:8px;border:1px solid #0ea5e9;background:#0ea5e9;color:#fff;cursor:pointer;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,.15);}';
return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>' . e($title) . '</title>'
. '<style>' . $css . '</style></head><body>'
. '<button class="btn-print no-print" onclick="window.print()">打印 / 导出 PDF</button>'
. '<div class="doc">' . $body . '</div>'
. '<script>window.onload=function(){setTimeout(function(){window.print();},300);};</script>'
. '</body></html>';
}
/** 一次性提示消息(跨重定向,取值后清空) */
function flash(string $msg, string $type = 'ok'): void
{
$_SESSION['_flash'] = ['msg' => $msg, 'type' => $type];
}
function flash_html(): string
{
if (empty($_SESSION['_flash'])) return '';
$f = $_SESSION['_flash'];
unset($_SESSION['_flash']);
$cls = $f['type'] === 'err' ? 'alert alert-err' : 'alert alert-ok';
return '<div class="' . $cls . '">' . e($f['msg']) . '</div>';
}
/* ---------- mbstring 兼容层:远端 PHP 未启用 mbstring 扩展时提供兜底实现 ---------- */
if (!function_exists('mb_substr')) {
function mb_substr(string $str, int $start, ?int $length = null, string $encoding = 'UTF-8'): string
{
$chars = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY);
if ($chars === false) { $chars = preg_split('//', $str, -1, PREG_SPLIT_NO_EMPTY) ?: []; }
$len = $length ?? count($chars);
return implode('', array_slice($chars, $start, $len));
}
}
if (!function_exists('mb_strlen')) {
function mb_strlen(string $str, string $encoding = 'UTF-8'): int
{
$chars = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY);
return $chars === false ? strlen($str) : count($chars);
}
}
if (!function_exists('mb_strtolower')) {
function mb_strtolower(string $str, string $encoding = 'UTF-8'): string
{
return strtolower($str);
}
}
if (!function_exists('mb_strtoupper')) {
function mb_strtoupper(string $str, string $encoding = 'UTF-8'): string
{
return strtoupper($str);
}
}
if (!function_exists('mb_check_encoding')) {
function mb_check_encoding($var, ?string $encoding = null): bool
{
if (is_array($var) || is_object($var)) return false;
$str = (string)$var;
if ($encoding === null || strtoupper((string)$encoding) === 'UTF-8') {
return (bool) preg_match('/\A(?: [\x00-\x7F] | [\xC2-\xDF][\x80-\xBF] | \xE0[\xA0-\xBF][\x80-\xBF] | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2} | \xED[\x80-\x9F][\x80-\xBF] | \xF0[\x90-\xBF][\x80-\xBF]{2} | [\xF1-\xF3][\x80-\xBF]{3} | \xF4[\x80-\x8F][\x80-\xBF]{2} )*\z/x', $str);
}
return true;
}
}
if (!function_exists('mb_convert_encoding')) {
function mb_convert_encoding(string $str, string $to, ?string $from = null): string
{
if (function_exists('iconv')) {
$conv = @iconv($from ?? 'UTF-8', $to . '//IGNORE', $str);
if ($conv !== false) return $conv;
}
return $str;
}
}
/**
* 内联 SVG 图标(Lucide 线性风格,stroke=currentColor)。
* 取代后台原有 emoji 图标:清晰、随主题变色、跨平台一致。
*/
function admin_icon(string $name, int $size = 20): string
{
static $paths = null;
if ($paths === null) {
$paths = [
'dashboard' => '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
'snowflake' => '<line x1="2" x2="22" y1="12" y2="12"/><line x1="12" x2="12" y1="2" y2="22"/><path d="m20 16-4-4 4-4"/><path d="m4 8 4 4-4 4"/><path d="m16 4-4 4-4-4"/><path d="m8 20 4-4 4 4"/>',
'folders' => '<path d="M8 17h12a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3.9a2 2 0 0 1-1.69-.9l-.81-1.2a2 2 0 0 0-1.67-.9H8a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2Z"/><path d="M2 8v11a2 2 0 0 0 2 2h14"/>',
'newspaper' => '<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/>',
'handshake' => '<path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"/><path d="m21 3 1 11h-2"/><path d="M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"/><path d="M3 4h8"/>',
'file-text' => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
'images' => '<path d="M18 22H4a2 2 0 0 1-2-2V6"/><path d="m22 13-1.296-1.296a2.41 2.41 0 0 0-3.408 0L11 18"/><circle cx="12" cy="8" r="2"/><rect width="16" height="16" x="6" y="2" rx="2"/>',
'settings' => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
'palette' => '<circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/>',
'users' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'receipt' => '<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 17.5v-11"/>',
'wallet' => '<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/>',
'upload' => '<circle cx="12" cy="12" r="10"/><path d="m16 12-4-4-4 4"/><path d="M12 16V8"/>',
'database' => '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>',
'package' => '<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="m7.5 4.27 9 5.15"/>',
'logout' => '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
'key' => '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
'globe' => '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
'menu' => '<line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/>',
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',
'user' => '<circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/>',
'pencil' => '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
'shield' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
'lightbulb' => '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
'phone' => '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/>',
'shopping-bag' => '<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
'truck' => '<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/><path d="M15 18H9"/><path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/>',
'inbox' => '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
'cart' => '<circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/>',
'user-plus' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/>',
'key' => '<path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
'lock' => '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'bar-chart' => '<line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/><line x1="3" y1="20" x2="21" y2="20"/>',
'printer' => '<path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8" rx="1"/>',
'check' => '<path d="M20 6 9 17l-5-5"/>',
'clipboard' => '<rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
'trending-up' => '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
'alert' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
];
}
$p = $paths[$name] ?? $paths['file-text'];
return '<svg class="li li-' . e($name) . '" width="' . $size . '" height="' . $size . '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' . $p . '</svg>';
}
/* ---------- 数据库升级:升级包目录与待升级计数 ---------- */
/** 升级包目录(放置 *.sql 后,后台「数据库升级」即提示可升级) */
function db_upgrade_dir(): string
{
return BASE_PATH . '/install/upgrades';
}
/** 将字节数格式化为人类可读大小 */
function human_size(int $bytes): string
{
if ($bytes < 1024) return $bytes . ' B';
$units = ['KB', 'MB', 'GB', 'TB'];
$i = -1;
do { $bytes /= 1024; $i++; } while ($bytes >= 1024 && $i < count($units) - 1);
return round($bytes, 2) . ' ' . $units[$i];
}
/**
* 统计待升级的 SQL 文件数量(未记录或内容已变更)。
* 用于在导航上提示「可升级」。失败安全:任何异常都返回 0。
*/
function db_pending_upgrades(): int
{
try {
if (\Core\Db::driver() !== 'mysql') return 0;
$dir = db_upgrade_dir();
if (!is_dir($dir)) return 0;
$files = glob($dir . '/*.sql') ?: [];
if (!$files) return 0;
\Core\Installer::ensureUpgradeLog();
$applied = \Core\Db::query("SELECT file, hash FROM db_upgrades")->fetchAll(\PDO::FETCH_KEY_PAIR);
$n = 0;
foreach ($files as $f) {
$name = basename($f);
$h = md5_file($f);
if (!isset($applied[$name]) || $applied[$name] !== $h) {
$n++;
}
}
return $n;
} catch (\Throwable $e) {
return 0;
}
}
/* ---------- 安全响应头(质量红线:所有后台/API 统一应用) ---------- */
/** 生成每次请求唯一的 CSP nonce(同请求内多次调用返回同一值,并去除 base64 填充符以兼容 CSP */
function csp_nonce(): string
{
static $n;
if ($n === null) {
$n = rtrim(base64_encode(random_bytes(16)), '=');
}
return $n;
}
function apply_security_headers(): void
{
if (headers_sent()) return;
$nonce = csp_nonce();
// 通用安全响应头(HSTS / X-Frame-Options / X-Content-Type-Options 等)已统一在
// Nginx 服务器层下发(含静态资源),无需在此重复。
// 此处仅补充依赖动态随机数的「严格 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'");
}
/** 当前请求的完整绝对 URL(用于 canonical 规范链接等) */
function absolute_url(): string
{
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? ($_SERVER['SERVER_NAME'] ?? 'localhost');
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
return $scheme . '://' . $host . $uri;
}
/* ---------- 图片验证码(GD 库,无第三方依赖,防机器人暴力/垃圾提交) ---------- */
/** 生成验证码字符串并存入 session,返回图片 URL */
function captcha_make(): array
{
if (session_status() !== PHP_SESSION_ACTIVE) @session_start();
// 排除易混淆字符:0/O、1/I/L、2/Z
$pool = '3456789ABCDEFGHJKMNPQRSTUVWXY';
$code = '';
for ($i = 0; $i < 4; $i++) {
$code .= $pool[random_int(0, strlen($pool) - 1)];
}
$_SESSION['captcha_code'] = $code;
// 添加随机参数防浏览器缓存同名图片
return ['url' => site_url('captcha/image') . '?_t=' . dechex(time() . random_int(1000, 9999))];
}
function captcha_check($input): bool
{
if (session_status() !== PHP_SESSION_ACTIVE) @session_start();
$ok = isset($_SESSION['captcha_code'])
&& is_string($input)
&& strtoupper(trim($input)) === strtoupper($_SESSION['captcha_code']);
unset($_SESSION['captcha_code']); // 一次性,防重放
return $ok;
}
/** 输出验证码图片(由 App 路由调用) */
function captcha_image(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) @session_start();
$code = $_SESSION['captcha_code'] ?? '';
if (empty($code)) {
// 无有效 code 时生成一个默认的,避免空白图
$code = 'XXXX';
}
$w = 130;
$h = 44;
$img = imagecreatetruecolor($w, $h);
if (!$img) {
http_response_code(500);
exit('验证码图片生成失败');
}
// ── 背景 ──
$bg = imagecolorallocate($img, 248, 250, 252);
imagefilledrectangle($img, 0, 0, $w, $h, $bg);
// ── 干扰线(5 条随机弧线)────
$colors = [];
for ($i = 0; $i < 8; $i++) {
$colors[] = imagecolorallocate($img,
random_int(140, 210),
random_int(140, 210),
random_int(160, 220)
);
}
for ($i = 0; $i < 5; $i++) {
$c = $colors[random_int(0, count($colors) - 1)];
imageline($img,
random_int(0, $w), random_int(0, $h),
random_int(0, $w), random_int(0, $h),
$c
);
}
// ── 干扰像素点 ──
for ($i = 0; $i < 80; $i++) {
$c = $colors[random_int(0, count($colors) - 1)];
imagesetpixel($img, random_int(0, $w), random_int(0, $h), $c);
}
// ── 文字(每个字符独立颜色、角度、位置)────
$len = strlen($code);
$cx = 15;
$cy = 30;
$fontFile = BASE_PATH . '/public/assets/arial.ttf'; // 可选 TTF,若无则 fallback
$hasTtf = is_file($fontFile);
$dark = imagecolorallocate($img, 25, 55, 100);
for ($i = 0; $i < $len; $i++) {
$char = $code[$i];
$textColor = imagecolorallocate($img,
random_int(20, 80),
random_int(40, 100),
random_int(80, 160)
);
if ($hasTtf) {
$size = random_int(18, 22);
$angle = random_int(-15, 15);
$x = $cx + ($i * ($w - 20) / $len);
$y = $cy + random_int(-4, 6);
imagettftext($img, $size, $angle, (int)$x, (int)$y, $textColor, $fontFile, $char);
} else {
// 无 TTF 字体时用内置字体(效果较差但仍可工作)
$fontSize = 5;
$x = $cx + ($i * ($w - 20) / $len) + random_int(-2, 2);
$y = 12 + random_int(-3, 3);
imagestring($img, $fontSize, (int)$x, (int)$y, $char, $textColor);
}
}
// ── 输出 ──
if (ob_get_level() > 0) ob_clean();
header('Content-Type: image/png');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
imagepng($img);
imagedestroy($img);
exit;
}
/* ---------- IP 级失败限速(fail2ban 式,文件缓存,越会话更抗爆破) ---------- */
function ip_login_blocked(string $ip): bool
{
$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;
}
function ip_login_register(string $ip): 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));
}
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) ?: [];
unset($data[$ip]);
@file_put_contents($file, json_encode($data));
}
/* ---------- 通用 IP 级限速(可用于任意提交场景,如联系表单) ---------- */
function ip_rate_blocked(string $ip, string $bucket, int $limit, int $window): bool
{
$file = BASE_PATH . '/storage/rate_' . $bucket . '.json';
if (!is_file($file)) return false;
$data = json_decode(@file_get_contents($file), true) ?: [];
if (!isset($data[$ip])) return false;
return $data[$ip]['count'] >= $limit;
}
function ip_rate_register(string $ip, string $bucket, int $window): void
{
$file = BASE_PATH . '/storage/rate_' . $bucket . '.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'] + $window) < $now) {
$data[$ip] = ['count' => 0, 'time' => $now];
}
$data[$ip]['count']++;
@file_put_contents($file, json_encode($data));
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
# 酷冰甲 · 降温服企业官网
git clone ssh://git@118.25.40.197:2222/Lucanlee/coolcoth.com.git
> 参考 [sqy58.com](http://sqy58.com) 风格,主打 **降温服 / 冷却服定制**,采用原生 PHP MVC 架构,内置 **前后台**,后台可设置 **任意网页风格**(颜色 / 字体 / 圆角 / 容器 / 导航样式 / 明暗模式 / 自定义 CSS)。
---
-29
View File
@@ -76,35 +76,6 @@ 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
{
+28 -12
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 级双窗口限速(失败 10 分钟/5 次、成功 30 分钟/5 次),杜绝机器人暴力破解
// 质量红线:登录必须校验 CSRF + 验证码 + IP/会话双重失败限速,杜绝机器人暴力破解
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if (!csrf_check()) {
$error = '表单已过期,请刷新页面后重试';
} elseif (ip_login_blocked($ip)) {
$error = '尝试次数过多,请 30 分钟后再试';
$blocked = true;
$error = '尝试次数过多,请 15 分钟后再试';
} elseif (!captcha_check($this->post('captcha'))) {
$error = '验证码错误,请重新计算';
} elseif ($this->isBlocked()) {
$error = '尝试次数过多,请 15 分钟后再试';
} else {
$u = trim($this->post('username'));
$p = $this->post('password');
@@ -38,7 +38,8 @@ class AuthController extends Controller
$user = ['id' => 0, 'username' => $u, 'name' => '管理员', 'role' => 'super_admin'];
}
if ($ok) {
ip_login_register_success($ip); // 记录成功登录(纳入 30 分钟 5 次上限),并重置失败计数
$this->clearAttempts();
ip_login_clear($ip);
session_regenerate_id(true); // 防会话固定
$_SESSION['admin_logged'] = true;
$_SESSION['admin_id'] = $user['id'] ?? 0;
@@ -51,15 +52,11 @@ class AuthController extends Controller
$_SESSION['psi_perms'] = $dec($user['psi_perms'] ?? null);
$this->redirect(login_landing());
}
ip_login_register_fail($ip); // 记录一次失败(纳入 10 分钟 5 次上限)
$this->registerAttempt();
ip_login_register($ip);
$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]);
}
@@ -71,6 +68,25 @@ 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()
{
+12 -54
View File
@@ -19,18 +19,13 @@ class CaseController extends AdminController
public function create()
{
return $this->view('admin/cases_form', [
'c' => null,
'mode' => 'fixed',
]);
return $this->view('admin/cases_form', ['c' => null]);
}
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'),
@@ -39,85 +34,48 @@ class CaseController extends AdminController
'industry' => $this->post('industry', ''),
'cover' => $cover,
'summary' => $this->post('summary'),
'content' => $content,
'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,
'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)
{
$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);
$case = (new CustomerCase())->find($id);
if (!$case) { $this->redirect('admin/cases'); }
return $this->view('admin/cases_form', ['c' => $case]);
}
public function update($id)
{
if (!csrf_check()) { $this->redirect('admin/cases'); }
$model = new CustomerCase();
$c = $model->find($id);
if (!$c) { $this->redirect('admin/cases'); }
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$case = $model->find($id);
if (!$case) { $this->redirect('admin/cases'); }
$cover = $this->uploadFile('cover');
if (!$cover && $this->post('cover_url')) $cover = $this->post('cover_url');
if (!$cover) $cover = $c['cover'] ?? '';
$data = [
if (!$cover) $cover = $case['cover'] ?? '';
$model->update($id, [
'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,
'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);
'layout' => $this->post('layout', ''),
]);
$this->redirect('admin/cases');
}
+7 -51
View File
@@ -15,91 +15,47 @@ class CategoryController extends AdminController
public function create()
{
return $this->view('admin/category_form', [
'c' => null,
'mode' => 'fixed',
]);
return $this->view('admin/category_form', ['c' => null]);
}
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' => $description,
'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,
]);
// 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'); }
$target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed';
(new Category())->update($id, ['mode' => $target]);
$this->redirect('admin/categories/edit/' . $id);
return $this->view('admin/category_form', ['c' => $c]);
}
public function update($id)
{
if (!csrf_check()) { $this->redirect('admin/categories'); }
$cat = new Category();
$c = $cat->find($id);
if (!$c) { $this->redirect('admin/categories'); }
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$data = [
(new Category())->update($id, [
'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,
'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);
'layout' => $this->post('layout', ''),
]);
$this->redirect('admin/categories');
}
+8 -50
View File
@@ -15,67 +15,37 @@ class NewsController extends AdminController
public function create()
{
return $this->view('admin/news_form', [
'n' => null,
'mode' => 'fixed',
]);
return $this->view('admin/news_form', ['n' => null]);
}
public function store()
{
if (!csrf_check()) { $this->redirect('admin/news'); }
$news = new News();
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$cover = $this->uploadFile('cover') ?? $this->post('cover_url', '');
$content = ($mode === 'fixed') ? $this->post('content', '') : '';
$news = new News();
$id = $news->insert([
'title' => $this->post('title'),
'slug' => '',
'cover' => $cover,
'summary' => $this->post('summary'),
'content' => $content,
'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,
'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'); }
$target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed';
(new News())->update($id, ['mode' => $target]);
$this->redirect('admin/news/edit/' . $id);
return $this->view('admin/news_form', ['n' => $n]);
}
public function update($id)
@@ -84,32 +54,20 @@ 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'] ?? '';
$data = [
$news->update($id, [
'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,
'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);
'layout' => $this->post('layout', ''),
]);
$this->redirect('admin/news');
}
+10 -34
View File
@@ -13,56 +13,32 @@ class PageController extends AdminController
]);
}
/** 编辑:按页面 mode 渲染对应编辑器(固定版面 / 可视化编辑) */
public function edit($id)
{
$p = (new Page())->find($id);
if (!$p) { $this->redirect('admin/pages'); }
$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_form', ['p' => $p, 'mode' => $mode]);
return $this->view('admin/page_builder', ['p' => $p, 'layout' => $layout]);
}
/** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 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'); }
$data = [
'title' => $this->post('title'),
'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 = '';
// 校验:非空的 layout 必须是合法 JSON 数组
if ($layout !== '') {
$dec = json_decode($layout, true);
if (!is_array($dec)) $layout = '';
}
$data['layout'] = $layout;
}
$mode = $this->post('mode');
if ($mode === 'builder' || $mode === 'fixed') {
$data['mode'] = $mode;
}
(new Page())->update($id, $data);
(new Page())->update($id, [
'title' => $this->post('title'),
'layout' => $layout,
'updated_at' => date('Y-m-d'),
]);
$this->redirect('admin/pages');
}
}
+8 -66
View File
@@ -33,7 +33,6 @@ class ProductController extends AdminController
return $this->view('admin/product_form', [
'p' => null,
'cats' => (new Category())->all(),
'mode' => 'fixed',
]);
}
@@ -42,33 +41,25 @@ 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' => $description,
'description' => $this->post('description'),
'price' => (float)$this->post('price', 0),
'specs' => $this->specsToJson($this->post('specs', '')),
'gallery' => json_encode($gallery, JSON_UNESCAPED_UNICODE),
'gallery' => '[]',
'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');
}
@@ -77,88 +68,39 @@ 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' => $cats,
'specText' => $specText,
'mode' => $mode,
'cats' => (new Category())->all(),
'specText'=> $specText,
]);
}
/** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 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'] ?? '';
$data = [
$product->update($id, [
'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,
'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);
'layout' => $this->post('layout', ''),
]);
$this->redirect('admin/products');
}
+1 -13
View File
@@ -14,7 +14,7 @@ class SettingController extends AdminController
];
private $siteFields = [
'site_name', 'site_slogan', 'contact_phone', 'contact_email',
'contact_address', 'site_logo', 'icp', 'gongan', 'seo_title', 'seo_keywords', 'seo_description',
'contact_address', 'icp', 'seo_title', 'seo_keywords', 'seo_description',
];
private $payFields = [
'pay_enabled', 'pay_mode',
@@ -30,18 +30,6 @@ 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,25 +22,6 @@ 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'],
@@ -50,7 +31,6 @@ 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(),
@@ -76,7 +56,6 @@ class HomeController extends Controller
['t' => '批量生产', 'd' => '确认图纸后快速打版、批量生产。'],
['t' => '成衣交付', 'd' => '精心包装交付上门,启动售后服务。'],
],
'faqs' => $faqs,
];
return $this->view('home/index', $data);
}
+2 -20
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['cover'] ?? '';
$pImage = $p['image'] ?? '';
$pPrice = $p['price'] ?? '';
$pSku = $p['sku'] ?? ($p['model'] ?? '');
@@ -92,23 +92,6 @@ 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,
@@ -120,13 +103,12 @@ class ProductController extends Controller
['name' => '产品中心', 'url' => site_url('products')],
['name' => $p['name'] ?? '产品', 'url' => absolute_url()],
],
'jsonld' => $productSchema . $faqSchema,
'jsonld' => $productSchema,
],
'p' => $p,
'cat' => $cat,
'related' => array_slice($related, 0, 3),
'specs' => $product->specsArray($p),
'faqs' => $faqs,
]);
}
}
+21 -123
View File
@@ -561,15 +561,9 @@ if (!function_exists('site_url')) {
{
if (headers_sent()) return;
$nonce = csp_nonce();
// 通用安全响应头从 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)
// 通用安全响应头HSTS / X-Frame-Options / X-Content-Type-Options 等)已统一在
// Nginx 服务器层下发(含静态资源),无需在此重复。
// 此处仅补充依赖动态随机数的「严格 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'");
}
@@ -698,89 +692,35 @@ if (!function_exists('site_url')) {
exit;
}
/* ---------- 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
/* ---------- IP 级失败限速(fail2ban 式,文件缓存,越会话更抗爆破) ---------- */
function ip_login_blocked(string $ip): bool
{
$file = BASE_PATH . '/storage/login_ip.json';
return is_file($file) ? (json_decode(@file_get_contents($file), true) ?: []) : [];
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;
}
function _ip_login_save(array $data): void
function ip_login_register(string $ip): 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
{
$data = _ip_login_load();
$file = BASE_PATH . '/storage/login_ip.json';
if (!is_file($file)) return;
$data = json_decode(@file_get_contents($file), true) ?: [];
unset($data[$ip]);
_ip_login_save($data);
@file_put_contents($file, json_encode($data));
}
/* ---------- 通用 IP 级限速(可用于任意提交场景,如联系表单) ---------- */
@@ -805,45 +745,3 @@ 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,21 +252,6 @@ 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'",
+1 -2
View File
@@ -16,12 +16,11 @@ class Theme
// 站点信息
'site_name' => '酷冰甲 · 降温服',
'site_slogan' => '科技降温 · 清凉一夏',
'site_logo' => 'assets/img/logo.png',
'site_logo' => '',
'contact_phone' => '400-1783-998',
'contact_email' => 'service@st-joyapparel.com',
'contact_address'=> '江苏省苏州市工业园区',
'icp' => '',
'gongan' => '', // 公安备案号(网安备),如 京公网安备11010802012345号
'seo_title' => '酷冰甲降温服 - 科技降温服装定制',
'seo_keywords' => '降温服, cooling clothing, 降温工作服, 清凉服定制',
'seo_description'=> '酷冰甲专注降温服研发与定制,采用相变蓄冷与循环水冷技术,为高温作业人群提供清凉解决方案。',
+1 -8
View File
@@ -4,13 +4,11 @@
</div>
<div class="admin-card">
<table class="admin-table">
<tr><th>封面</th><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></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>
@@ -25,8 +23,3 @@
<?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>
+16 -136
View File
@@ -1,42 +1,14 @@
<?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 ? '编辑客户案例' : '新增客户案例'; ?><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><h1><?php echo $isEdit ? '编辑客户案例' : '新增客户案例'; ?></h1></div>
<a class="btn-ghost" href="<?php echo site_url('admin/cases'); ?>">← 返回</a>
</div>
<div class="admin-card">
<form id="fxForm" method="post" action="<?php echo site_url($isEdit ? 'admin/cases/update/' . $c['id'] : 'admin/cases/store'); ?>" enctype="multipart/form-data">
<form id="pbForm" 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>
@@ -48,120 +20,28 @@ $coverVal = $v('cover');
</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>
<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">
<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">
</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 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>
<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>
+1 -8
View File
@@ -4,14 +4,12 @@
</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></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">
@@ -23,8 +21,3 @@
<?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>
+13 -77
View File
@@ -1,101 +1,37 @@
<?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 ? '编辑分类' : '新增分类'; ?><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><h1><?php echo $isEdit ? '编辑分类' : '新增分类'; ?></h1></div>
<a class="btn-ghost" href="<?php echo site_url('admin/categories'); ?>">← 返回</a>
</div>
<div class="admin-card">
<form id="fxForm" method="post" action="<?php echo site_url($isEdit ? 'admin/categories/update/' . $c['id'] : 'admin/categories/store'); ?>">
<form id="pbForm" 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 fx-fixed-only">
<label>分类描述</label>
<textarea name="description" rows="4" placeholder="分类简介,用于前台分类展示区域"><?php echo e($desc); ?></textarea>
<p class="fx-hint">纯文本描述,保存后前台分类列表展示。如需复杂排版可切换为可视化编辑。</p>
<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>
<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>
+1 -8
View File
@@ -4,13 +4,11 @@
</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></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>
@@ -24,8 +22,3 @@
<?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>
+16 -136
View File
@@ -1,42 +1,14 @@
<?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 ? '编辑新闻' : '写新闻'; ?><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><h1><?php echo $isEdit ? '编辑新闻' : '写新闻'; ?></h1></div>
<a class="btn-ghost" href="<?php echo site_url('admin/news'); ?>">← 返回</a>
</div>
<div class="admin-card">
<form id="fxForm" method="post" action="<?php echo site_url($isEdit ? 'admin/news/update/' . $n['id'] : 'admin/news/store'); ?>" enctype="multipart/form-data">
<form id="pbForm" 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>
@@ -44,120 +16,28 @@ $coverVal = $v('cover');
</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>
<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">
<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">
</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 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>
<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,10 +1,6 @@
<?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">
@@ -12,16 +8,6 @@ $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(); ?>
@@ -69,13 +55,6 @@ $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}
@@ -114,7 +93,5 @@ 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>
+4 -92
View File
@@ -1,103 +1,15 @@
<?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']); ?><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><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>
<div class="admin-card">
<form method="post" action="<?php echo site_url('admin/pages/update/' . $p['id']); ?>" id="fxForm">
<form method="post" action="<?php echo site_url('admin/pages/update/' . $p['id']); ?>">
<?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>正文内容</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="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="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>
+1 -8
View File
@@ -3,21 +3,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></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>
+15 -152
View File
@@ -1,46 +1,15 @@
<?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 ? '编辑产品' : '新增产品'; ?><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><h1><?php echo $isEdit ? '编辑产品' : '新增产品'; ?></h1><div class="desc">填写产品信息,封面留空将使用渐变占位图</div></div>
<a class="btn-ghost" href="<?php echo site_url('admin/products'); ?>">← 返回列表</a>
</div>
<div class="admin-card">
<form id="fxForm" method="post" action="<?php echo site_url($isEdit ? 'admin/products/update/' . $p['id'] : 'admin/products/store'); ?>" enctype="multipart/form-data">
<form id="pbForm" 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>
@@ -54,44 +23,21 @@ $desc = $v('description');
</div>
<div class="field"><label>一句话简介</label><input name="summary" value="<?php echo e($v('summary')); ?>"></div>
<div class="field">
<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">
<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">
</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 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>
<div class="field">
@@ -114,86 +60,3 @@ $desc = $v('description');
</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>
+2 -9
View File
@@ -9,15 +9,13 @@ $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><th>操作</th></tr>
<tr><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><?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><div class="thum" style="background:<?php echo gradient($p['id']); ?>">❄</div></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">
@@ -28,9 +26,4 @@ $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>
+2 -9
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'); ?>" enctype="multipart/form-data">
<form method="post" action="<?php echo site_url('admin/settings'); ?>">
<?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,14 +11,7 @@
<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" 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>
<div class="field"><label>备案号</label><input name="icp" value="<?php echo e($v['icp']); ?>"></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>
+3 -4
View File
@@ -2,8 +2,7 @@
$c = $c ?? null;
$layoutArr = [];
if (!empty($c['layout'])) { $d = json_decode($c['layout'], true); if (is_array($d)) $layoutArr = $d; }
// 模式渲染:可视化编辑(builder)且有 layout → 画布;否则固定版式(正文富文本)
$useBuilder = !empty($layoutArr) && ($c['mode'] ?? '') !== 'fixed';
$hasCanvas = !empty($layoutArr);
?>
<section class="page-hero">
<div class="container">
@@ -15,12 +14,12 @@ $useBuilder = !empty($layoutArr) && ($c['mode'] ?? '') !== 'fixed';
<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 ($useBuilder): ?>
<?php if ($hasCanvas): ?>
<?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 detail-rich" style="font-size:16px"><?php echo $c['content']; ?></article>
<article class="detail-desc" style="font-size:16px"><?php echo e($c['content']); ?></article>
</div>
</section>
<?php endif; ?>
-22
View File
@@ -54,11 +54,7 @@ $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>
@@ -151,24 +147,6 @@ $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">
+4 -5
View File
@@ -9,7 +9,6 @@ $logo = $site['site_logo'];
$defaultMode = $site['default_mode'];
$headerStyle = $site['header'];
$icp = $site['icp'];
$gongan = $site['gongan'] ?? '';
// ── 页面级 SEO 数据(控制器通过 $this->view() 或 $data 传入)────
$pageSeo = $pageSeo ?? [];
@@ -138,8 +137,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 $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; ?>
<?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>
</a>
<nav class="nav-links" id="navLinks">
<?php foreach ($nav as $n): ?>
@@ -162,7 +161,8 @@ $isActive = function ($url) use ($current) {
<div class="container footer-grid">
<div>
<div class="brand">
<?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; ?>
<span class="brand-mark">❄</span>
<span class="brand-name"><?php echo e($name); ?></span>
</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,7 +193,6 @@ $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>
+3 -4
View File
@@ -2,8 +2,7 @@
$n = $n ?? null;
$layoutArr = [];
if (!empty($n['layout'])) { $d = json_decode($n['layout'], true); if (is_array($d)) $layoutArr = $d; }
// 模式渲染:可视化编辑(builder)且有 layout → 画布;否则固定版式(正文富文本)
$useBuilder = !empty($layoutArr) && ($n['mode'] ?? '') !== 'fixed';
$hasCanvas = !empty($layoutArr);
?>
<section class="page-hero">
<div class="container">
@@ -15,12 +14,12 @@ $useBuilder = !empty($layoutArr) && ($n['mode'] ?? '') !== 'fixed';
<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 ($useBuilder): ?>
<?php if ($hasCanvas): ?>
<?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 detail-rich" style="font-size:16px"><?php echo $n['content']; ?></article>
<article class="detail-desc" style="font-size:16px"><?php echo e($n['content']); ?></article>
</div>
</section>
<?php endif; ?>
+1 -4
View File
@@ -5,11 +5,8 @@ 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 ($useBuilder): ?>
<?php if (!empty($layout)): ?>
<?php echo \Core\View::buffer('parts/canvas', ['layout' => $layout, 'item' => $p, 'module' => 'page']); ?>
<?php else: ?>
<section class="page-hero">
-4
View File
@@ -24,11 +24,7 @@ $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>
+3 -50
View File
@@ -1,13 +1,7 @@
<?php
$layoutArr = [];
if (!empty($p['layout'])) { $d = json_decode($p['layout'], true); if (is_array($d)) $layoutArr = $d; }
// 模式渲染:可视化编辑(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; }
$hasCanvas = !empty($layoutArr);
?>
<section class="page-hero">
<div class="container">
@@ -22,26 +16,18 @@ if (!empty($p['gallery'])) { $dg = json_decode($p['gallery'], true); if (is_arra
</nav>
</div>
<?php if ($useBuilder): ?>
<?php if ($hasCanvas): ?>
<?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>
<?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">
<?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">
@@ -53,7 +39,7 @@ if (!empty($p['gallery'])) { $dg = json_decode($p['gallery'], true); if (is_arra
<?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; ?>
<?php if (!empty($p['description'])): ?><div class="detail-desc detail-rich"><?php echo $p['description']; ?></div><?php endif; ?>
<p class="detail-desc"><?php echo e($p['description']); ?></p>
<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>
@@ -65,25 +51,6 @@ if (!empty($p['gallery'])) { $dg = json_decode($p['gallery'], true); if (is_arra
</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">
@@ -91,11 +58,7 @@ if (!empty($p['gallery'])) { $dg = json_decode($p['gallery'], true); if (is_arra
<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>
@@ -120,13 +83,3 @@ if (!empty($p['gallery'])) { $dg = json_decode($p['gallery'], true); if (is_arra
</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>
+15
View File
@@ -0,0 +1,15 @@
# ============================================================
# 酷冰甲官网 - 宝塔(Nginx) ACME 挑战修复
# 问题: 运行目录设为 /public 后, Let's Encrypt 的 HTTP-01 验证
# 文件写在真实根目录 .well-known/acme-challenge/, 但 Nginx
# root=public, 访问时 404, 导致 SSL 证书签发失败。
# 用法: 宝塔 -> 网站 -> 设置 -> 伪静态, 粘贴以下内容并保存,
# 然后重新申请 SSL 证书。
# ============================================================
# 放在原有 location / { try_files ... } 之前
location ^~ /.well-known/acme-challenge/ {
root /www/wwwroot/st-joyapparel.com; # 改成你的项目真实根目录(不含 /public)
default_type text/plain;
try_files $uri =404;
}
+15
View File
@@ -0,0 +1,15 @@
# ============================================================
# 酷冰甲官网 - 宝塔(Nginx) 伪静态配置(粘贴到 网站→设置→伪静态)
# ⚠️ 这是 Nginx 语法, 不要和 Apache 的 .htaccess 混用
# 前提: 运行目录已设为 /public
# ============================================================
# 前端控制器: 真实文件/目录直接服务, 其余转发到 public/index.php
# 必须带 ?$query_string, 以保留原始 REQUEST_URI 供本项目路由解析
try_files $uri $uri/ /index.php?$query_string;
# Let's Encrypt HTTP-01 验证路径(否则证书签发 404)
location ^~ /.well-known/acme-challenge/ {
root /www/wwwroot/st-joyapparel.com; # 改成你的项目真实根目录(不含 /public)
default_type text/plain;
}
+98
View File
@@ -0,0 +1,98 @@
# 宝塔 Nginx + DNS 验证 申请 SSL 证书(详细操作顺序)
> 适用:已建好站点 `st-joyapparel.com`、运行目录=`/public`、已配前端控制器伪静态。
> 用 DNS 验证可以**完全绕开** `.well-known/acme-challenge` 路径问题,最稳。
---
## 一、前置确认(1 分钟)
1. 域名 `st-joyapparel.com` 已在宝塔「网站」里建好站点。
2. 域名解析里**至少有一条 A 记录**指向服务器 `47.107.30.116`
(这样证书签发后 HTTPS 才能真正用起来;DNS 验证本身不要求域名解析到本机,但上线必须)。
3. 宝塔 → 网站 → `st-joyapparel.com` → 设置 → 网站目录 → **运行目录 = `/public`**(已设过可跳过)。
4. 伪静态里**只保留**前端控制器那条(去掉之前为 HTTP 验证加的 `.well-known` location 也行,DNS 用不到它):
```nginx
try_files $uri $uri/ /index.php?$query_string;
```
---
## 二、方式 A:DNS API 自动验证(最推荐,零手动)
> 前提:域名在阿里云 / 腾讯云 / DNSPod / 华为云等主流平台,且你能拿到 API 密钥。
1. 宝塔左侧 **面板设置**(或站点 SSL 页里的「DNS API」入口)。
2. 找到 **DNS API** 配置,选择你的域名服务商(如「阿里云」)。
3. 填入:
- AccessKey ID
- AccessKey Secret
(在对应云服务商的「访问控制 / API 密钥」里创建,**只给 DNS 解析权限**即可,别给全量权限)
4. 保存。
5. 回到 网站 → `st-joyapparel.com` → **SSL** → **Let's Encrypt**。
6. 验证方式下拉选 **DNS验证**(或「DNS API」)。
7. 勾选要签的域名(`st-joyapparel.com` 和 `www.st-joyapparel.com`)。
8. 点 **申请**。宝塔会自动添加 TXT 记录 → 等待 CA 校验 → **自动签发并部署**。
9. 完成后页面显示「证书已部署」,结束。✅
> 优点:以后**自动续签**也全自动,不用每年手动加记录。
---
## 三、方式 B:手动 DNS(TXT 记录)验证
> 没有 API 密钥时用这个。缺点:下次续签要再手动加一次 TXT(除非改用 API)。
1. 网站 → `st-joyapparel.com` → **SSL** → **Let's Encrypt**。
2. 验证方式选 **DNS验证**。
3. 页面会列出**需要你手动添加的 DNS 记录**,一般是:
- 记录类型:**TXT**
- 主机记录:**`_acme-challenge`**(完整即 `_acme-challenge.st-joyapparel.com`
- 记录值:一长串字符(如 `S7ASyftFIXsmMfhFqydT7QfMsFpsmTXRLD7FMZd_AIY` 之类,以页面显示为准)
- (如果要给 `www` 也签,会再给一条 `_acme-challenge.www` 的 TXT
4. **另开一个标签页**,登录你的域名解析控制台
(阿里云:域名 → 解析设置;腾讯云:DNS 解析;DNSPod / Namesilo 等同理)。
5. 添加上面那条 TXT 记录,保存。
6. **等解析生效**(看 TTL,通常 1–10 分钟;用下面命令自查):
```bash
nslookup -type=TXT _acme-challenge.st-joyapparel.com
# 或 Windows:
nslookup -type=TXT _acme-challenge.st-joyapparel.com 8.8.8.8
```
看到记录值和你填的一致,说明生效。
7. 回到宝塔,点 **验证 / 申请**。CA 读到 TXT 后即签发并自动部署。
---
## 四、签发后必做
1. SSL 页确认证书状态为「已部署」,有效期约 90 天。
2. 开启 **强制 HTTPS**SSL 页里有开关)→ 访问 `http://` 自动跳 `https://`。
3. 浏览器访问 `https://st-joyapparel.com/` 和 `https://www.st-joyapparel.com/`,确认:
- 地址栏出现小锁 🔒
- 前台页面、后台 `/admin` 都正常(之前配的 `try_files` 继续生效)
4. 跑部署收尾(若还没跑):
```bash
cd /www/wwwroot/st-joyapparel.com
bash deploy.sh
rm -rf install # 删安装目录
```
后台 → 设置 → 管理员,改掉默认密码 `admin888`。
---
## 五、自动续签
- **DNS API 方式**:宝塔默认会在计划任务里加 Let's Encrypt 自动续签,到期前自动换证,无需人工。
- **手动 DNS 方式**:续签时 TXT 记录值会变,需重新加一次(建议趁机切到 API 方式一劳永逸)。
---
## 六、常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 申请一直转圈/超时 | API 密钥权限不对或填错 | 核对 AccessKey,只给 DNS 权限 |
| 手动 DNS 提示「记录未找到」 | TXT 还没生效 / 主机名填错 | `nslookup` 自查,确认是 `_acme-challenge` 而非 `@` |
| 证书有了但网站打不开 | 运行目录不是 `/public` 或伪静态丢了 | 按「一、4」补齐 |
| 浏览器红色「非安全」 | 页面里混用了 http 资源 | 后台/主题里图片、JS 改用相对或 https 地址 |
+98
View File
@@ -0,0 +1,98 @@
# ============================================================
# 酷冰甲官网 - 宝塔(Nginx) 站点配置 coolcoth.com
# 运行目录 = /publicPHP 入口 index.php
# ------------------------------------------------------------
# 两种用法(二选一):
# A. 高级:宝塔 -> 网站 -> 设置 -> 配置文件,整体替换为下方完整 server 块
# B. 普通:宝塔新建站点后,仅把下方「伪静态区」内容粘到「伪静态」框,
# 再用「设置 -> 重定向」开启 https + www 跳转(见文件末尾说明)
# ============================================================
# ---------- 完整版 server 块(用法 A----------
server {
listen 80;
server_name coolcoth.com www.coolcoth.com;
# HTTP -> HTTPS,并规范到 www(延续原 Apache 的 www 优先策略)
return 301 https://www.coolcoth.com$request_uri;
}
server {
listen 443 ssl http2;
server_name coolcoth.com www.coolcoth.com;
root /www/wwwroot/coolcoth.com/public; # 运行目录 = /public
index index.php index.html;
# ── SSL 证书(宝塔申请 Let's Encrypt 后自动填充,或手动指定)──
# ssl_certificate /www/server/panel/vhost/cert/coolcoth.com/fullchain.pem;
# ssl_certificate_key /www/server/panel/vhost/cert/coolcoth.com/privkey.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ── 通用安全响应头(Nginx 层统一下发,含静态资源;
# CSP 含动态 nonce,由 PHP Helper::apply_security_headers() 下发,勿在此重复)──
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
add_header X-Permitted-Cross-Domain-Policies "none" always;
# ── Let's Encrypt HTTP-01 验证(运行目录=/public 时必须,否则签发 404)──
location ^~ /.well-known/acme-challenge/ {
root /www/wwwroot/coolcoth.com; # 真实根目录(不含 /public)
default_type text/plain;
try_files $uri =404;
}
# ── 静态资源长缓存 ──
location ~* \.(css|js|png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public";
}
# ── 前端控制器:真实文件直接服务,其余转发 index.php ──
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# ── PHP ──
location ~ \.php$ {
# 禁止敏感目录下的 PHP 被执行
location ~ /(app|config|storage|routes|vendor)/.*\.php$ { return 404; }
fastcgi_pass unix:/tmp/php-cgi-74.sock; # 按宝塔实际 PHP 版本调整(如 php-cgi-80.sock / php-cgi-82.sock
fastcgi_index index.php;
include fastcgi.conf;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# ── 禁止访问敏感文件 ──
location ~ \.(env|git|svn|htaccess|htpasswd|ini|log|bak|old|swp|sql|zip|gz|yml|md|lock|user\.ini)$ {
deny all;
}
}
# ============================================================
# 伪静态区(用法 B:粘贴到宝塔「伪静态」框)
# ------------------------------------------------------------
# location ^~ /.well-known/acme-challenge/ {
# root /www/wwwroot/coolcoth.com;
# default_type text/plain;
# try_files $uri =404;
# }
#
# location / {
# try_files $uri $uri/ /index.php?$query_string;
# }
#
# 安全响应头在宝塔「配置文件的 443 server 块」用 add_header ... always; 添加,
# 或保持现状由 PHP 下发(index.php 已全局调用 apply_security_headers())。
# ============================================================
#
# 用法 B 的「重定向」设置(宝塔 -> 网站 -> 设置 -> 重定向):
# 开启重定向 -> 名称任意 -> 类型 301
# 域名:coolcoth.com 目标 URLhttps://www.coolcoth.com$CACHE_URL$REQ_ARGS
# (宝塔会自动把 http:// 与 https:// 都重定向至 www;若想用裸域作主,目标改为 https://coolcoth.com
# ============================================================
+4 -9
View File
@@ -9,8 +9,7 @@ CREATE TABLE IF NOT EXISTS `categories` (
`description` TEXT,
`sort_order` INT DEFAULT 0,
`status` TINYINT DEFAULT 1,
`layout` TEXT,
`mode` VARCHAR(16) NOT NULL DEFAULT 'fixed'
`layout` TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `products` (
@@ -28,8 +27,7 @@ CREATE TABLE IF NOT EXISTS `products` (
`sort_order` INT DEFAULT 0,
`status` TINYINT DEFAULT 1,
`created_at` VARCHAR(20) DEFAULT '',
`layout` TEXT,
`mode` VARCHAR(16) NOT NULL DEFAULT 'fixed'
`layout` TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `news` (
@@ -43,8 +41,7 @@ CREATE TABLE IF NOT EXISTS `news` (
`published_at` VARCHAR(20) DEFAULT '',
`status` TINYINT DEFAULT 1,
`views` INT DEFAULT 0,
`layout` TEXT,
`mode` VARCHAR(16) NOT NULL DEFAULT 'fixed'
`layout` TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `cases` (
@@ -60,8 +57,7 @@ CREATE TABLE IF NOT EXISTS `cases` (
`sort_order` INT DEFAULT 0,
`status` TINYINT DEFAULT 1,
`views` INT DEFAULT 0,
`layout` TEXT,
`mode` VARCHAR(16) NOT NULL DEFAULT 'fixed'
`layout` TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `pages` (
@@ -70,7 +66,6 @@ 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;
+3 -10
View File
@@ -18,16 +18,9 @@
--------
1. 仅放 DDL / DML 的标准 MySQL 脚本;多条语句以分号(;)分隔,自动拆分执行。
2. 优先使用「幂等写法」,避免重复执行报错,例如:
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;
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 ...;
3. 不建议在升级包中执行 DROP TABLE / TRUNCATE 等破坏性语句,除非确有必要。
4. 升级前请务必备份数据库。
-12
View File
@@ -35,7 +35,6 @@ 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}
@@ -81,7 +80,6 @@ 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}
@@ -215,13 +213,3 @@ 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: 18 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 948 KiB

-20
View File
@@ -8,25 +8,5 @@ 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
+4 -16
View File
@@ -1,3 +1,6 @@
# www.st-joyapparel.com robots.txt
# 禁止爬虫抓取后台、系统、内部静态资源等敏感路径
User-agent: *
Disallow: /admin/
Disallow: /CRM/
@@ -11,22 +14,7 @@ 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
View File
@@ -0,0 +1,71 @@
# 圣巧依官网 ── 切换 coolcoth.comApache → Nginx)部署说明
> 适用:把原 `st-joyapparel.com` 站点迁移到新域名 `coolcoth.com`,并将 Web 服务由 Apache 切换为 Nginx(宝塔环境)。
> 联系邮箱保持 `service@st-joyapparel.com`(同属圣巧依公司,未随域名变更)。
---
## 一、本次改动清单(相对原 st-joyapparel.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@st-joyapparel.com`,未改) | ❌ 无需替换 |
| `install/seed.php` | 联系邮箱(保持 `service@st-joyapparel.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 跳转即可。
---
## 六、旧域名
`st-joyapparel.com` 若不再使用,请在 DNS / 阿里云释放;其服务器目录可保留或清理。