diff --git a/.well-known/acme-challenge/v2e3x8BS4vM7cvb9Ytf68DSRtmxIHkPDx_e0XoBym08 b/.well-known/acme-challenge/v2e3x8BS4vM7cvb9Ytf68DSRtmxIHkPDx_e0XoBym08 new file mode 100644 index 0000000..6e0873e --- /dev/null +++ b/.well-known/acme-challenge/v2e3x8BS4vM7cvb9Ytf68DSRtmxIHkPDx_e0XoBym08 @@ -0,0 +1 @@ +v2e3x8BS4vM7cvb9Ytf68DSRtmxIHkPDx_e0XoBym08.krcH3SDD7MS0vIjcTesz3UY027U_QVi9wgmRkOEaVsk \ No newline at end of file diff --git a/Helper.php b/Helper.php new file mode 100644 index 0000000..52a2698 --- /dev/null +++ b/Helper.php @@ -0,0 +1,747 @@ +'; + } + 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 '' . e($title) . '' + . '' + . '' + . '
' . $body . '
' + . '' + . ''; + } + + /** 一次性提示消息(跨重定向,取值后清空) */ + 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 '
' . e($f['msg']) . '
'; + } + + /* ---------- 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' => '', + 'snowflake' => '', + 'folders' => '', + 'newspaper' => '', + 'handshake' => '', + 'file-text' => '', + 'images' => '', + 'settings' => '', + 'palette' => '', + 'users' => '', + 'receipt' => '', + 'wallet' => '', + 'upload' => '', + 'database' => '', + 'package' => '', + 'logout' => '', + 'key' => '', + 'globe' => '', + 'menu' => '', + 'plus' => '', + 'user' => '', + 'pencil' => '', + 'shield' => '', + 'lightbulb' => '', + 'phone' => '', + 'shopping-bag' => '', + 'truck' => '', + 'inbox' => '', + 'cart' => '', + 'user-plus' => '', + 'key' => '', + 'lock' => '', + 'bar-chart' => '', + 'printer' => '', + 'check' => '', + 'clipboard' => '', + 'trending-up' => '', + 'alert' => '', + ]; + } + $p = $paths[$name] ?? $paths['file-text']; + return ''; + } + + /* ---------- 数据库升级:升级包目录与待升级计数 ---------- */ + + /** 升级包目录(放置 *.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)); + } +} diff --git a/README.md b/README.md index 39831f0..9309310 100644 --- a/README.md +++ b/README.md @@ -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)。 --- diff --git a/app/Controllers/Admin/AdminController.php b/app/Controllers/Admin/AdminController.php index 41f6a15..2640aac 100644 --- a/app/Controllers/Admin/AdminController.php +++ b/app/Controllers/Admin/AdminController.php @@ -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 { diff --git a/app/Controllers/Admin/AuthController.php b/app/Controllers/Admin/AuthController.php index ff2737c..dc76f71 100644 --- a/app/Controllers/Admin/AuthController.php +++ b/app/Controllers/Admin/AuthController.php @@ -13,17 +13,17 @@ class AuthController extends Controller { if (is_admin()) { $this->redirect(login_landing()); } $error = ''; - $blocked = false; - $ip = $_SERVER['REMOTE_ADDR'] ?? ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { - // 质量红线:登录必须校验 CSRF + 验证码 + IP 级双窗口限速(失败 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() { diff --git a/app/Controllers/Admin/CaseController.php b/app/Controllers/Admin/CaseController.php index 4d12ab3..90b54c8 100644 --- a/app/Controllers/Admin/CaseController.php +++ b/app/Controllers/Admin/CaseController.php @@ -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'); } diff --git a/app/Controllers/Admin/CategoryController.php b/app/Controllers/Admin/CategoryController.php index 3450c10..488afb9 100644 --- a/app/Controllers/Admin/CategoryController.php +++ b/app/Controllers/Admin/CategoryController.php @@ -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'); } diff --git a/app/Controllers/Admin/NewsController.php b/app/Controllers/Admin/NewsController.php index ff6b3ac..702977a 100644 --- a/app/Controllers/Admin/NewsController.php +++ b/app/Controllers/Admin/NewsController.php @@ -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'); } diff --git a/app/Controllers/Admin/PageController.php b/app/Controllers/Admin/PageController.php index d791015..5945364 100644 --- a/app/Controllers/Admin/PageController.php +++ b/app/Controllers/Admin/PageController.php @@ -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]); + $layout = []; + if (!empty($p['layout'])) { + $dec = json_decode($p['layout'], true); + if (is_array($dec)) $layout = $dec; } - 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 = [ + $layout = $this->post('layout', ''); + // 校验:非空的 layout 必须是合法 JSON 数组 + if ($layout !== '') { + $dec = json_decode($layout, true); + if (!is_array($dec)) $layout = ''; + } + (new Page())->update($id, [ 'title' => $this->post('title'), + 'layout' => $layout, 'updated_at' => date('Y-m-d'), - ]; - if ($this->post('content') !== null) { - $data['content'] = $this->post('content'); - } - if ($this->post('layout') !== null) { - $layout = $this->post('layout', ''); - if ($layout !== '' && !is_array(json_decode($layout, true))) { - $layout = ''; - } - $data['layout'] = $layout; - } - $mode = $this->post('mode'); - if ($mode === 'builder' || $mode === 'fixed') { - $data['mode'] = $mode; - } - (new Page())->update($id, $data); + ]); $this->redirect('admin/pages'); } } diff --git a/app/Controllers/Admin/ProductController.php b/app/Controllers/Admin/ProductController.php index 70068b9..127a43f 100644 --- a/app/Controllers/Admin/ProductController.php +++ b/app/Controllers/Admin/ProductController.php @@ -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, + 'p' => $p, + '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'); } diff --git a/app/Controllers/Admin/SettingController.php b/app/Controllers/Admin/SettingController.php index e757987..382aef0 100644 --- a/app/Controllers/Admin/SettingController.php +++ b/app/Controllers/Admin/SettingController.php @@ -14,7 +14,7 @@ class SettingController extends AdminController ]; private $siteFields = [ 'site_name', 'site_slogan', 'contact_phone', 'contact_email', - 'contact_address', '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'); diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php index 4aad315..06c88b7 100644 --- a/app/Controllers/HomeController.php +++ b/app/Controllers/HomeController.php @@ -22,25 +22,6 @@ class HomeController extends Controller 'keywords' => '降温服,降温背心,水冷降温服,相变降温服,制冷背心,工业降温服,消防降温服,高温作业防护,降温服定制,酷冰甲', 'og_type' => 'website', ]); - // ── 首页 FAQ(可见文本 + FAQPage JSON-LD,GEO 高杠杆信号)──── - $faqs = [ - ['q' => '降温服是什么?它是怎么实现降温的?', 'a' => '降温服是一类为高温作业人群设计的主动或被动降温装备,主要通过三种原理散热:水冷循环(微型水泵驱动冷水在服装内管路循环带走体热)、相变蓄冷(冰袋或凝胶相变材料在融化过程中持续吸热)、涡扇风冷(小型风扇强制对流散热)。酷冰甲提供这三大系列,覆盖不同场景与续航需求。'], - ['q' => '穿降温服体感能降多少度?多久能起效?', 'a' => '在常规高温环境下,合格降温服可让核心体表感温度下降约 8–12℃。水冷与风冷方案接通或开机后数分钟内即可感受到明显凉意;相变冰袋方案放入预冷冰袋后即刻生效,单组冰袋可持续 2–4 小时。'], - ['q' => '降温服可以重复使用吗?一套能用多久?', 'a' => '可以。酷冰甲降温服主体为可水洗服装,水冷、风冷模块与相变冰袋均可反复使用。服装本体在正常保养下可用 2–3 个高温季,冰袋与电池模块按使用频率约 1–2 年更换即可。'], - ['q' => '支持企业定制和 LOGO 刺绣吗?起订量多少?', 'a' => '支持。我们提供企业 LOGO 绣字、颜色与面料定制、一人一码量体服务。柔性化生产,10 套起订,确认图纸后 7 天打样、约 28 天批量交付,适合班组、车间等小批量统一配发。'], - ['q' => '降温服适合哪些行业和场景?', 'a' => '广泛用于消防、钢铁、电力、化工、环卫、建筑、物流及户外军训等高温或暴晒场景,也适用于骑行、垂钓、观赛等个人户外降温。可按行业工况推荐对应系列与续航配置。'], - ['q' => '降温服怎么清洗和保养?', 'a' => '服装本体可轻柔机洗或手洗,避免浸泡电子模块;水冷、风冷主机与电池需拆下后擦干存放,冰袋用后擦干冷藏。长期不用请置于阴凉干燥处,电池保持半电存放。'], - ]; - $faqJsonLd = ''; - $data = [ 'pageSeo' => [ 'title' => $seo['title'], @@ -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); } diff --git a/app/Controllers/ProductController.php b/app/Controllers/ProductController.php index 8dd5f3a..6f3f735 100644 --- a/app/Controllers/ProductController.php +++ b/app/Controllers/ProductController.php @@ -72,7 +72,7 @@ class ProductController extends Controller $pName = e($p['name'] ?? '产品详情'); $pSummary = mb_substr(strip_tags($p['summary'] ?? $p['body'] ?? ''), 0, 160); - $pImage = $p['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) . ''; - // ── 产品页 FAQ(可见文本 + FAQPage JSON-LD,GEO 信号)──── - $faqs = [ - ['q' => '这款降温服采用什么降温原理?', 'a' => '根据系列不同,分别采用水冷循环、相变蓄冷或涡扇风冷原理散热:水冷通过微型水泵驱动冷水循环带走体热,相变依靠冰袋/凝胶融化吸热,风冷由风扇强制对流降温。详情可在商品规格表中查看对应方案。'], - ['q' => '一次可使用多长时间?', 'a' => '相变冰袋方案单组可持续 2–4 小时,可随用随换;水冷与风冷方案续航取决于电池容量,具体以商品规格为准,支持备用电池延长作业时间。'], - ['q' => '是否支持企业定制与 LOGO 刺绣?', 'a' => '支持。提供企业 LOGO 绣字、颜色与面料定制、一人一码量体服务,10 套起订,确认图纸后 7 天打样、约 28 天批量交付。'], - ['q' => '如何选择合适的尺码?', 'a' => '提供标准尺码表并支持上门量体,下单后可按身高体重推荐尺码;特殊体型或工种可单独打版,确保合身与活动便利。'], - ]; - $faqSchema = ''; - return $this->view('product/show', [ 'pageSeo' => [ 'title' => $pName, @@ -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, ]); } } diff --git a/app/Core/Helper.php b/app/Core/Helper.php index 23f2945..52a2698 100644 --- a/app/Core/Helper.php +++ b/app/Core/Helper.php @@ -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); - @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); - } + $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]; } - } - 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); + $data[$ip]['count']++; + @file_put_contents($file, json_encode($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_key(home/products/news/cases/about/contact...) - * @param array $default 默认 SEO 数组(title/description/keywords/og_type) - * @return array {title,description,keywords,og_type,og_image,canonical,noindex} - */ - function page_seo(string $key, array $default = []): array - { - $def = array_merge([ - 'title' => '', - 'description' => '', - 'keywords' => '', - 'og_type' => 'website', - 'og_image' => '', - 'canonical' => '', - 'noindex' => 0, - ], $default); - - try { - $row = (new \App\Models\PageSeo())->getByKey($key); - } catch (\Throwable $e) { - $row = null; - } - - if (!$row) { - return $def; - } - - return [ - 'title' => $row['title'] ?? $def['title'], - 'description' => $row['description'] ?? $def['description'], - 'keywords' => $row['keywords'] ?? $def['keywords'], - 'og_type' => $row['og_type'] ?? $def['og_type'], - 'og_image' => $row['og_image'] ?? $def['og_image'], - 'canonical' => $row['canonical'] ?? $def['canonical'], - 'noindex' => $row['noindex'] ?? $def['noindex'], - ]; - } -} diff --git a/app/Core/Installer.php b/app/Core/Installer.php index db826be..8daedc6 100644 --- a/app/Core/Installer.php +++ b/app/Core/Installer.php @@ -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'", diff --git a/app/Core/Theme.php b/app/Core/Theme.php index f57dd84..e4702b4 100644 --- a/app/Core/Theme.php +++ b/app/Core/Theme.php @@ -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'=> '酷冰甲专注降温服研发与定制,采用相变蓄冷与循环水冷技术,为高温作业人群提供清凉解决方案。', diff --git a/app/Views/admin/cases.php b/app/Views/admin/cases.php index 27e4d47..2b197e7 100644 --- a/app/Views/admin/cases.php +++ b/app/Views/admin/cases.php @@ -4,13 +4,11 @@
- + - - @@ -25,8 +23,3 @@
封面案例标题模式客户行业日期状态操作
封面案例标题客户行业日期状态操作
🤝
- diff --git a/app/Views/admin/cases_form.php b/app/Views/admin/cases_form.php index e0b67dd..b87a334 100644 --- a/app/Views/admin/cases_form.php +++ b/app/Views/admin/cases_form.php @@ -1,42 +1,14 @@
-
-

固定版面

-
固定版面:填写案例信息 / 封面,详情使用富文本编辑器排版
-
- -
- - -
- -
- - -
- +

+ ← 返回
-
-
+ - -
@@ -48,120 +20,28 @@ $coverVal = $v('cover');
-
- - - -
-

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

- - + +

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

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

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

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

固定版面

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

+ ← 返回
-
-
+ - -
-
-
- -
- - -

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

+
+ +

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

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

固定版面

-
固定版面:填写标题 / 摘要 / 封面,正文使用富文本编辑器排版
-
- -
- - -
- -
- - -
- +

+ ← 返回
-
-
+ - -
@@ -44,120 +16,28 @@ $coverVal = $v('cover');
-
- - - -
-

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

- - + +

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

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

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

+
+
+
-
id="st">
-
取消
- - - - - - - - - diff --git a/app/Views/admin/page_builder.php b/app/Views/admin/page_builder.php index 4a6bc06..f047bb2 100644 --- a/app/Views/admin/page_builder.php +++ b/app/Views/admin/page_builder.php @@ -1,10 +1,6 @@
@@ -12,16 +8,6 @@ $initial = json_encode($layout ?: []);
← 返回 可视化编辑: - 可视化编辑 - -
- - -
-
@@ -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__ = ; window.__PB_MEDIA__ = ''; window.__PB_UPLOAD__ = ''; window.__PB_CSRF__ = ''; -window.__PB_MODULE__ = 'page'; -window.__PB_DELETE__ = ''; diff --git a/app/Views/admin/page_form.php b/app/Views/admin/page_form.php index b14f6c0..59f963b 100644 --- a/app/Views/admin/page_form.php +++ b/app/Views/admin/page_form.php @@ -1,103 +1,15 @@ -
-
-

编辑单页:固定版面

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

编辑单页:

支持 HTML 标签
+ ← 返回
-
- + -
- -
- -
- - - - - - - - - -
-
- -

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

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

固定版面

-
固定版面:填写资料并上传封面 / 图集,图片自适应展示
-
- -
- - -
- -
- - -
- +

填写产品信息,封面留空将使用渐变占位图
+ ← 返回列表
-
+ - -
@@ -54,44 +23,21 @@ $desc = $v('description');
-
- - - -
-

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

- - + +

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

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

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

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

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

站点设置

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

diff --git a/app/Views/cases/show.php b/app/Views/cases/show.php index e7a05e8..c4d5c66 100644 --- a/app/Views/cases/show.php +++ b/app/Views/cases/show.php @@ -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); ?>
@@ -15,12 +14,12 @@ $useBuilder = !empty($layoutArr) && ($c['mode'] ?? '') !== 'fixed';
- + $layoutArr, 'item' => $c, 'module' => 'case']); ?>
-
+
diff --git a/app/Views/home/index.php b/app/Views/home/index.php index 08e1a7f..bcee709 100644 --- a/app/Views/home/index.php +++ b/app/Views/home/index.php @@ -54,11 +54,7 @@ $banner = $banners[0] ?? ['title' => '科技降温 · 清凉一夏', 'subtitle'
-
-
-
-

常见问题

-

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

-

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

-
-
- -
- + -
-
- -
-
-
-
diff --git a/app/Views/layouts/site.php b/app/Views/layouts/site.php index 211006d..1d87927 100644 --- a/app/Views/layouts/site.php +++ b/app/Views/layouts/site.php @@ -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) {