'; } 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 '