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 @@
-
-
固定版面
-
固定版面:填写案例信息 / 封面,详情使用富文本编辑器排版
-
-
-
-
-
-
-
-
-
-
-
-
+
+
← 返回
-
- | 图标 | 名称 | 标识 | 模式 | 描述 | 操作 |
+ | 图标 | 名称 | 标识 | 描述 | 操作 |
-
|
|
|
- |
|
@@ -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 @@
-
-
固定版面
-
固定版面:填写分类名称 / 图标 / 描述等基本信息
-
-
-
-
-
-
-
-
-
-
-
-
+
+
← 返回
-
-
- | 封面 | 标题 | 模式 | 作者 | 日期 | 状态 | 操作 |
+ | 封面 | 标题 | 作者 | 日期 | 状态 | 操作 |
-
📰 |
|
- |
|
|
已发布' : '草稿'; ?> |
@@ -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 @@
-
-
固定版面
-
固定版面:填写标题 / 摘要 / 封面,正文使用富文本编辑器排版
-
-
-
-
-
-
-
-
-
-
-
-
+
+
← 返回
-
-