文件还在测试中
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
namespace Core;
|
||||
|
||||
/**
|
||||
* 应用核心:自动加载、配置、路由、分发
|
||||
*/
|
||||
class App
|
||||
{
|
||||
public static $config = [];
|
||||
|
||||
/** 初始化:辅助函数、配置、会话、自动加载(install 脚本可复用) */
|
||||
public static function init()
|
||||
{
|
||||
// 0. 全局辅助函数
|
||||
require_once BASE_PATH . '/app/Core/Helper.php';
|
||||
|
||||
// 1. 时区 & 会话
|
||||
date_default_timezone_set(self::config('app.timezone', 'Asia/Shanghai'));
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start([
|
||||
'cookie_httponly' => true,
|
||||
'cookie_samesite' => 'Lax',
|
||||
'cookie_secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), // HTTPS 下仅安全传输
|
||||
]);
|
||||
}
|
||||
|
||||
// 2. 自动加载(Core / App\Controllers / App\Models)
|
||||
spl_autoload_register(function ($class) {
|
||||
$prefixes = [
|
||||
'Core\\' => BASE_PATH . '/app/Core/',
|
||||
'App\\Controllers\\' => BASE_PATH . '/app/Controllers/',
|
||||
'App\\Models\\' => BASE_PATH . '/app/Models/',
|
||||
];
|
||||
foreach ($prefixes as $prefix => $base) {
|
||||
if (strncmp($class, $prefix, strlen($prefix)) === 0) {
|
||||
$rel = substr($class, strlen($prefix));
|
||||
$file = $base . str_replace('\\', '/', $rel) . '.php';
|
||||
if (is_file($file)) { require $file; return true; }
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
public static function run()
|
||||
{
|
||||
self::init();
|
||||
self::dispatch(self::parseRoute());
|
||||
}
|
||||
|
||||
/** 解析请求路径为段数组 */
|
||||
public static function parseRoute(): array
|
||||
{
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
// 仅当 SCRIPT_NAME 真实以 index.php 结尾时(Apache/生产模式)才剥离目录前缀;
|
||||
// 内置服务器路由模式下 SCRIPT_NAME 等于请求路径,此时不剥离,避免误删 admin 等段。
|
||||
$script = $_SERVER['SCRIPT_NAME'] ?? '/index.php';
|
||||
if (substr($script, -strlen('/index.php')) === '/index.php') {
|
||||
$dir = dirname($script);
|
||||
if ($dir !== '/' && strpos($uri, $dir) === 0) {
|
||||
$uri = substr($uri, strlen($dir));
|
||||
}
|
||||
}
|
||||
$uri = preg_replace('#/index\.php$#i', '', $uri);
|
||||
$uri = trim($uri, '/');
|
||||
if ($uri === '') return [];
|
||||
return explode('/', $uri);
|
||||
}
|
||||
|
||||
/** 路由分发 */
|
||||
public static function dispatch(array $segments)
|
||||
{
|
||||
// 静态资源直出
|
||||
$asset = BASE_PATH . '/public/' . implode('/', $segments);
|
||||
if ($segments && is_file($asset) && !is_dir($asset)) {
|
||||
self::serveFile($asset);
|
||||
return;
|
||||
}
|
||||
|
||||
$admin = self::config('app.admin_path', 'admin');
|
||||
if (!empty($segments) && $segments[0] === $admin) {
|
||||
self::dispatchAdmin(array_slice($segments, 1));
|
||||
return;
|
||||
}
|
||||
// 分系统入口:CRM 客户管理 / PSI 进销存(统一受 super_admin 与分系统权限管辖)
|
||||
// 大小写不敏感:/crm、/CRM、/psi、/PSI 均可进入,避免因 URL 大小写不同导致 404
|
||||
if (!empty($segments)) {
|
||||
$seg0 = strtoupper($segments[0]);
|
||||
if ($seg0 === 'CRM' || $seg0 === 'PSI') {
|
||||
self::dispatchSubsys($seg0, array_slice($segments, 1));
|
||||
return;
|
||||
}
|
||||
}
|
||||
self::dispatchFront($segments);
|
||||
}
|
||||
|
||||
private static function dispatchSubsys(string $raw, array $s)
|
||||
{
|
||||
$sys = strtoupper($raw); // CRM / PSI
|
||||
$sysKey = $sys === 'PSI' ? 'psi' : 'crm';
|
||||
// 未登录或无该系统角色:拦截(拥有 crm/psi 任意角色即可进入;写操作由各 Controller 的 subsys_admin 二次把关)
|
||||
if (!subsys_can_enter($sysKey)) {
|
||||
self::forbidden('您没有访问「' . $sys . '」系统的权限');
|
||||
return;
|
||||
}
|
||||
// 统一入口:CRM\DashboardController / PSI\DashboardController 内部再做子路由
|
||||
$ctrl = 'App\\Controllers\\' . $sys . '\\DashboardController';
|
||||
$action = 'dispatch';
|
||||
if (!class_exists($ctrl)) { self::notFound("系统不存在: $sys"); return; }
|
||||
$instance = new $ctrl();
|
||||
if (!method_exists($instance, $action)) { self::notFound("入口不存在: $action"); return; }
|
||||
try {
|
||||
$html = call_user_func_array([$instance, $action], [$s]);
|
||||
} catch (\Throwable $e) {
|
||||
// 子系统异常兜底:保留左导 + 右框,框内显示错误,绝不白屏
|
||||
error_log('Subsys[' . $sys . '] error: ' . $e->getMessage());
|
||||
$html = self::subsysErrorFrame($sysKey, $instance, $e);
|
||||
}
|
||||
if (is_string($html)) echo $html;
|
||||
}
|
||||
|
||||
private static function dispatchFront(array $s)
|
||||
{
|
||||
$key = $s[0] ?? '';
|
||||
$seg2 = $s[1] ?? null;
|
||||
|
||||
// 验证码图片输出(独立端点,直接输出 PNG)
|
||||
if ($key === 'captcha' && $seg2 === 'image') {
|
||||
captcha_image();
|
||||
return;
|
||||
}
|
||||
|
||||
// Sitemap 动态生成(XML 格式,搜索引擎自动抓取)
|
||||
if ($key === 'sitemap.xml') {
|
||||
self::outputSitemap();
|
||||
return;
|
||||
}
|
||||
|
||||
// 资源详情路由(带第二段 slug)
|
||||
if (($key === 'product' || $key === 'products') && $seg2) {
|
||||
self::call('App\\Controllers\\ProductController', 'show', [$seg2]);
|
||||
return;
|
||||
}
|
||||
if ($key === 'news' && $seg2) {
|
||||
self::call('App\\Controllers\\NewsController', 'show', [$seg2]);
|
||||
return;
|
||||
}
|
||||
if ($key === 'cases' && $seg2) {
|
||||
self::call('App\\Controllers\\CaseController', 'show', [$seg2]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 订单 / 支付(含网关异步通知,无需登录)
|
||||
if ($key === 'order') {
|
||||
$action = $s[1] ?? 'checkout';
|
||||
$param = $s[2] ?? null;
|
||||
self::call('App\\Controllers\\OrderController', $action, [$param]);
|
||||
return;
|
||||
}
|
||||
if ($key === 'pay') {
|
||||
$action = $s[1] ?? 'notify';
|
||||
$param = $s[2] ?? null;
|
||||
self::call('App\\Controllers\\PayController', $action, [$param]);
|
||||
return;
|
||||
}
|
||||
|
||||
$map = [
|
||||
'' => ['HomeController', 'index'],
|
||||
'home' => ['HomeController', 'index'],
|
||||
'products' => ['ProductController', 'index'],
|
||||
'news' => ['NewsController', 'index'],
|
||||
'cases' => ['CaseController', 'index'],
|
||||
'page' => ['PageController', 'show'],
|
||||
'contact' => ['ContactController', 'index'],
|
||||
];
|
||||
if (isset($map[$key])) {
|
||||
[$ctrl, $action] = $map[$key];
|
||||
$param = $seg2;
|
||||
self::call('App\\Controllers\\' . $ctrl, $action, [$param]);
|
||||
return;
|
||||
}
|
||||
// 未知路径:按单页 slug 处理(/about、/service ...)
|
||||
self::call('App\\Controllers\\PageController', 'show', [$key]);
|
||||
}
|
||||
|
||||
private static function dispatchAdmin(array $s)
|
||||
{
|
||||
$res = $s[0] ?? '';
|
||||
$action = $s[1] ?? 'index';
|
||||
$id = $s[2] ?? null;
|
||||
$map = [
|
||||
'' => ['Admin\\DashboardController', 'index'],
|
||||
'dashboard' => ['Admin\\DashboardController', 'index'],
|
||||
'login' => ['Admin\\AuthController', 'login'],
|
||||
'logout' => ['Admin\\AuthController', 'logout'],
|
||||
'password' => ['Admin\\AuthController', 'password'],
|
||||
'products' => ['Admin\\ProductController', 'index'],
|
||||
'categories'=> ['Admin\\CategoryController', 'index'],
|
||||
'news' => ['Admin\\NewsController', 'index'],
|
||||
'pages' => ['Admin\\PageController', 'index'],
|
||||
'banners' => ['Admin\\BannerController', 'index'],
|
||||
'settings' => ['Admin\\SettingController', 'index'],
|
||||
'theme' => ['Admin\\SettingController', 'theme'],
|
||||
'seo' => ['Admin\\SettingController', 'seo'],
|
||||
'users' => ['Admin\\UserController', 'index'],
|
||||
'system' => ['Admin\\SystemController', 'index'],
|
||||
'orders' => ['Admin\\OrderController', 'index'],
|
||||
'payments' => ['Admin\\SettingController', 'payment'],
|
||||
'cases' => ['Admin\\CaseController', 'index'],
|
||||
'media' => ['Admin\\MediaController', 'index'],
|
||||
'upgrade' => ['Admin\\UpgradeController', 'index'],
|
||||
'db' => ['Admin\\DatabaseController', 'index'],
|
||||
];
|
||||
if (!isset($map[$res])) {
|
||||
self::notFound();
|
||||
return;
|
||||
}
|
||||
// 权限能力检查:未登录或权限不足直接拦截(登录态由对应控制器再兜底)
|
||||
$capMap = [
|
||||
'users' => 'users',
|
||||
'system' => 'users',
|
||||
'upgrade' => 'users',
|
||||
'db' => 'users',
|
||||
'settings' => 'settings',
|
||||
'theme' => 'settings',
|
||||
'seo' => 'settings',
|
||||
'payments' => 'settings',
|
||||
'products' => 'products',
|
||||
'categories'=> 'categories',
|
||||
'news' => 'news',
|
||||
'pages' => 'pages',
|
||||
'banners' => 'banners',
|
||||
'orders' => 'orders',
|
||||
'cases' => 'cases',
|
||||
'media' => 'pages',
|
||||
];
|
||||
if (isset($capMap[$res]) && !admin_can($capMap[$res])) {
|
||||
self::forbidden('当前账号无访问「' . $res . '」的权限');
|
||||
return;
|
||||
}
|
||||
[$ctrl, $default] = $map[$res];
|
||||
$method = ($action === 'index') ? $default : $action;
|
||||
self::call('App\\Controllers\\' . $ctrl, $method, [$id]);
|
||||
}
|
||||
|
||||
private static function call($class, $method, array $args = [])
|
||||
{
|
||||
if (!class_exists($class)) { self::notFound("类不存在: $class"); return; }
|
||||
$instance = new $class();
|
||||
if (!method_exists($instance, $method)) { self::notFound("方法不存在: $method"); return; }
|
||||
$html = call_user_func_array([$instance, $method], $args);
|
||||
if (is_string($html)) echo $html;
|
||||
}
|
||||
|
||||
public static function notFound($msg = '')
|
||||
{
|
||||
http_response_code(404);
|
||||
echo '<!doctype html><meta charset=utf-8><title>404</title>
|
||||
<style>body{font-family:system-ui;display:grid;place-items:center;height:100vh;margin:0;background:#0f172a;color:#e2e8f0}
|
||||
.b{text-align:center}.c{color:#38bdf8;font-size:64px;font-weight:800;margin:0}</style>
|
||||
<div class="b"><p class="c">404</p><p>页面不存在' . ($msg ? ':' . htmlspecialchars($msg) : '') . '</p>
|
||||
<p><a style="color:#38bdf8" href="' . site_url() . '">返回首页</a></p></div>';
|
||||
}
|
||||
|
||||
public static function forbidden(string $msg = '')
|
||||
{
|
||||
http_response_code(403);
|
||||
echo '<!doctype html><meta charset=utf-8><title>403</title>
|
||||
<style>body{font-family:system-ui;display:grid;place-items:center;height:100vh;margin:0;background:#0f172a;color:#e2e8f0}
|
||||
.b{text-align:center}.c{color:#f87171;font-size:56px;font-weight:800;margin:0}</style>
|
||||
<div class="b"><p class="c">403</p><p>无访问权限' . ($msg ? ':' . htmlspecialchars($msg) : '') . '</p>
|
||||
<p><a style="color:#38bdf8" href="' . site_url('admin') . '">返回后台</a></p></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 子系统异常兜底框:左导 + 右框保持完整,框内显示错误信息,绝不白屏。
|
||||
*/
|
||||
private static function subsysErrorFrame(string $sysKey, $instance, \Throwable $e): string
|
||||
{
|
||||
$nav = method_exists($instance, 'nav') ? $instance->nav('') : [];
|
||||
$content = '<div class="page-head"><h1>页面加载出错</h1>'
|
||||
. '<p class="sub">系统遇到问题,错误已记录,请稍后重试或联系管理员</p></div>'
|
||||
. '<div class="alert alert-err">' . e('错误信息:' . $e->getMessage()) . '</div>';
|
||||
return \Core\View::make('layouts/subsys', [
|
||||
'content' => $content,
|
||||
'_sys' => $sysKey,
|
||||
'_nav' => $nav,
|
||||
'_seg' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
private static function serveFile($file)
|
||||
{
|
||||
$real = realpath($file);
|
||||
$pub = realpath(BASE_PATH . '/public');
|
||||
if ($real === false || $pub === false || strpos($real, $pub . DIRECTORY_SEPARATOR) !== 0 || !is_file($real)) {
|
||||
self::notFound('非法文件访问');
|
||||
return;
|
||||
}
|
||||
$mime = mime_content_type($real);
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . filesize($real));
|
||||
readfile($real);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** 读取配置 config('app.name') */
|
||||
/**
|
||||
* 生成站内 URL(兼容子目录部署)。
|
||||
* 用法:App::url('PSI/reminders') -> https://host/base/PSI/reminders
|
||||
*/
|
||||
public static function url(string $path = ''): string
|
||||
{
|
||||
return site_url($path);
|
||||
}
|
||||
|
||||
public static function config(string $key, $default = null)
|
||||
{
|
||||
if (empty(self::$config)) {
|
||||
self::$config = require BASE_PATH . '/config/config.php';
|
||||
}
|
||||
$keys = explode('.', $key);
|
||||
$v = self::$config;
|
||||
foreach ($keys as $k) {
|
||||
if (!is_array($v) || !array_key_exists($k, $v)) return $default;
|
||||
$v = $v[$k];
|
||||
}
|
||||
return $v;
|
||||
}
|
||||
|
||||
/** 动态生成 Sitemap XML(Google/Bing/Baidu 自动抓取) */
|
||||
private static function outputSitemap(): void
|
||||
{
|
||||
header('Content-Type: application/xml; charset=utf-8');
|
||||
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
|
||||
|
||||
// 首页
|
||||
echo '<url><loc>' . e(site_url()) . '</loc><priority>1.0</priority><changefreq>daily</changefreq></url>' . "\n";
|
||||
|
||||
// 静态页面
|
||||
$staticPages = [
|
||||
['url' => site_url('products'), 'prio' => '0.9', 'freq' => 'weekly'],
|
||||
['url' => site_url('news'), 'prio' => '0.8', 'freq' => 'daily'],
|
||||
['url' => site_url('cases'), 'prio' => '0.8', 'freq' => 'weekly'],
|
||||
['url' => site_url('page/about'), 'prio' => '0.7', 'freq' => 'monthly'],
|
||||
['url' => site_url('contact'), 'prio' => '0.7', 'freq' => 'monthly'],
|
||||
];
|
||||
foreach ($staticPages as $p) {
|
||||
echo '<url><loc>' . e($p['url']) . '</loc><priority>' . $p['prio'] . '</priority><changefreq>' . $p['freq'] . '</changefreq></url>' . "\n";
|
||||
}
|
||||
|
||||
// 动态页面列表:产品 / 新闻 / 案例 / 单页
|
||||
$models = [
|
||||
['class' => 'App\\Models\\Product', 'method' => 'all', 'urlPrefix' => 'product/', 'prio' => '0.85', 'freq' => 'weekly'],
|
||||
['class' => 'App\\Models\\News', 'method' => 'published', 'urlPrefix' => 'news/', 'prio' => '0.75', 'freq' => 'weekly'],
|
||||
['class' => 'App\\Models\\CustomerCase', 'method' => 'published', 'urlPrefix' => 'cases/', 'prio' => '0.75', 'freq' => 'weekly'],
|
||||
['class' => 'App\\Models\\Page', 'method' => 'all', 'urlPrefix' => 'page/', 'prio' => '0.6', 'freq' => 'monthly'],
|
||||
];
|
||||
|
||||
foreach ($models as $m) {
|
||||
if (!class_exists($m['class'])) continue;
|
||||
try {
|
||||
$instance = new $m['class']();
|
||||
$items = [];
|
||||
if ($m['method'] === 'all') {
|
||||
$items = $instance->all();
|
||||
} elseif ($m['method'] === 'published') {
|
||||
$items = $instance->published(200);
|
||||
}
|
||||
foreach ($items as $item) {
|
||||
$slug = $item['slug'] ?? ($item['id'] ?? '');
|
||||
if (empty($slug)) continue;
|
||||
$url = site_url($m['urlPrefix'] . $slug);
|
||||
$lastmod = '';
|
||||
if (!empty($item['updated_at'])) {
|
||||
$lastmod = '<lastmod>' . e($item['updated_at']) . '</lastmod>';
|
||||
} elseif (!empty($item['created_at'])) {
|
||||
$lastmod = '<lastmod>' . e($item['created_at']) . '</lastmod>';
|
||||
}
|
||||
echo '<url><loc>' . e($url) . '</loc>' . $lastmod . '<priority>' . $m['prio'] . '</priority><changefreq>' . $m['freq'] . '</changefreq></url>' . "\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 静默跳过异常的模型,保证 sitemap 完整性
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 产品分类页(/products?cat=ID)
|
||||
if (class_exists('App\\Models\\Category')) {
|
||||
try {
|
||||
$catM = new \App\Models\Category();
|
||||
foreach ($catM->all() as $c) {
|
||||
$cid = $c['id'] ?? 0;
|
||||
if (!$cid) continue;
|
||||
$url = site_url('products?cat=' . $cid);
|
||||
echo '<url><loc>' . e($url) . '</loc><priority>0.7</priority><changefreq>weekly</changefreq></url>' . "\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
echo '</urlset>';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
namespace Core;
|
||||
|
||||
/**
|
||||
* 数据库连接(MySQL PDO 单例;文件路径模式)
|
||||
*/
|
||||
class Db
|
||||
{
|
||||
private static $pdo = null;
|
||||
|
||||
public static function driver(): string
|
||||
{
|
||||
return App::config('app.driver', 'file');
|
||||
}
|
||||
|
||||
public static function pdo(): \PDO
|
||||
{
|
||||
if (self::$pdo === null) {
|
||||
$c = App::config('db.mysql');
|
||||
$dsn = "mysql:host={$c['host']};port={$c['port']};dbname={$c['dbname']};charset={$c['charset']}";
|
||||
self::$pdo = new \PDO($dsn, $c['user'], $c['pass'], [
|
||||
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
|
||||
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
|
||||
\PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
}
|
||||
return self::$pdo;
|
||||
}
|
||||
|
||||
public static function fileDir(): string
|
||||
{
|
||||
$dir = App::config('db.file.dir');
|
||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public static function query(string $sql, array $params = []): \PDOStatement
|
||||
{
|
||||
$st = self::pdo()->prepare($sql);
|
||||
$st->execute($params);
|
||||
return $st;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,849 @@
|
||||
<?php
|
||||
/**
|
||||
* 全局辅助函数(视图与控制器中可直接调用)
|
||||
*/
|
||||
|
||||
if (!function_exists('site_url')) {
|
||||
function base_url(): string
|
||||
{
|
||||
$proto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
// 由 DOCUMENT_ROOT + SCRIPT_FILENAME 推导站点根路径,兼容内置服务器路由模式与生产环境
|
||||
$docroot = $_SERVER['DOCUMENT_ROOT'] ?? '';
|
||||
$scriptFile = $_SERVER['SCRIPT_FILENAME'] ?? '';
|
||||
$basePath = '';
|
||||
if ($docroot && $scriptFile && strpos($scriptFile, $docroot) === 0) {
|
||||
$rel = substr($scriptFile, strlen($docroot)); // 如 /index.php 或 /sub/index.php
|
||||
$basePath = rtrim(dirname($rel), '/');
|
||||
}
|
||||
return rtrim($proto . '://' . $host . $basePath, '/');
|
||||
}
|
||||
function site_url(string $path = ''): string
|
||||
{
|
||||
return base_url() . '/' . ltrim($path, '/');
|
||||
}
|
||||
function asset(string $path = ''): string
|
||||
{
|
||||
return site_url('assets/' . ltrim($path, '/'));
|
||||
}
|
||||
function e($v): string
|
||||
{
|
||||
return htmlspecialchars((string) $v, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
function slugify(string $s): string
|
||||
{
|
||||
$s = preg_replace('~[^\pL\pN]+~u', '-', $s);
|
||||
$s = trim($s, '-');
|
||||
return strtolower($s) ?: 'item';
|
||||
}
|
||||
function format_date($ts, string $fmt = 'Y-m-d'): string
|
||||
{
|
||||
if (!$ts) return '';
|
||||
$t = is_numeric($ts) ? (int)$ts : strtotime($ts);
|
||||
return $t ? date($fmt, $t) : '';
|
||||
}
|
||||
function csrf_token(): string
|
||||
{
|
||||
if (empty($_SESSION['_csrf'])) {
|
||||
$_SESSION['_csrf'] = bin2hex(random_bytes(16));
|
||||
}
|
||||
return $_SESSION['_csrf'];
|
||||
}
|
||||
function csrf_field(): string
|
||||
{
|
||||
return '<input type="hidden" name="_csrf" value="' . csrf_token() . '">';
|
||||
}
|
||||
function csrf_check(): bool
|
||||
{
|
||||
$token = $_POST['_csrf'] ?? ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
|
||||
return isset($_SESSION['_csrf']) && hash_equals($_SESSION['_csrf'], $token);
|
||||
}
|
||||
/** 当前管理员是否已登录 */
|
||||
function is_admin(): bool
|
||||
{
|
||||
return !empty($_SESSION['admin_logged']);
|
||||
}
|
||||
function admin_required()
|
||||
{
|
||||
if (!is_admin()) {
|
||||
header('Location: ' . site_url('admin/login'));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
/** 当前登录管理员的角色:super_admin | admin | user | none */
|
||||
function admin_role(): string
|
||||
{
|
||||
return $_SESSION['admin_role'] ?? 'user';
|
||||
}
|
||||
/** 当前登录管理员 ID(配置文件兜底登录时为 0) */
|
||||
function admin_uid(): ?int
|
||||
{
|
||||
return $_SESSION['admin_id'] ?? null;
|
||||
}
|
||||
/** 角色 -> 能力映射(可访问的模块/操作) */
|
||||
function admin_role_map(): array
|
||||
{
|
||||
return [
|
||||
'super_admin' => ['dashboard', 'products', 'categories', 'news', 'cases', 'pages', 'banners', 'settings', 'theme', 'users', 'system', 'orders', 'payments', 'password'],
|
||||
'admin' => ['dashboard', 'products', 'categories', 'news', 'cases', 'pages', 'banners', 'orders', 'password'],
|
||||
// user 为受限角色:默认仅仪表盘权限,不预开 products/news/cases 等后台模块。
|
||||
// 进入 CRM/PSI 后左导据此严格收敛——CRM 操作员(后台角色=user)只看到仪表盘 + 当前子系统功能,
|
||||
// 其余后台模块由其是否拥有对应 admin 能力决定;后台管理员/超管不受影响(见 admin / super_admin 行)。
|
||||
'user' => ['dashboard', 'password'],
|
||||
// none = 无后台主角色:该账号仅作为 CRM/PSI 子系统账号存在,不拥有任何后台模块权限。
|
||||
// 进入子系统后左导仍按 subsys_role 严格收敛,避免与「用户」混淆导致角色分配混乱。
|
||||
'none' => ['password'],
|
||||
];
|
||||
}
|
||||
/** 当前登录管理员是否具备某项能力 */
|
||||
function admin_can(string $cap): bool
|
||||
{
|
||||
$role = admin_role();
|
||||
return in_array($cap, admin_role_map()[$role] ?? [], true);
|
||||
}
|
||||
/** 角色中文名 */
|
||||
function admin_role_label(string $role): string
|
||||
{
|
||||
return ['super_admin' => '超级管理员', 'admin' => '管理员', 'user' => '用户', 'none' => '无'][$role] ?? '用户';
|
||||
}
|
||||
/** 必须是指定角色,否则拦截(用于控制器构造函数) */
|
||||
function role_required(string $role): void
|
||||
{
|
||||
if (!is_admin()) {
|
||||
header('Location: ' . site_url('admin/login'));
|
||||
exit;
|
||||
}
|
||||
if (admin_role() !== $role) {
|
||||
\Core\App::forbidden('需要 ' . admin_role_label($role) . ' 权限');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
/** 根据种子生成品牌渐变(用于占位图) */
|
||||
function gradient($seed = 0): string
|
||||
{
|
||||
$angle = 110 + (intval($seed) * 37) % 160;
|
||||
return "linear-gradient({$angle}deg,var(--c-primary),var(--c-secondary))";
|
||||
}
|
||||
|
||||
/* ---------- 分系统权限(CRM / 进销存 PSI) ---------- */
|
||||
/**
|
||||
* 用户在某业务系统中的角色。super_admin 在任意系统都视为最高权限管理员。
|
||||
* @param string $sys crm | psi
|
||||
*/
|
||||
function subsys_role(string $sys): string
|
||||
{
|
||||
if (admin_role() === 'super_admin') return 'super_admin';
|
||||
$key = $sys . '_role';
|
||||
return $_SESSION[$key] ?? 'none';
|
||||
}
|
||||
/** 是否为某系统的管理员(含超管) */
|
||||
function subsys_admin(string $sys): bool
|
||||
{
|
||||
return in_array(subsys_role($sys), ['super_admin', 'admin'], true);
|
||||
}
|
||||
/** 是否为某系统的用户(含管理员、超管) */
|
||||
function subsys_user(string $sys): bool
|
||||
{
|
||||
return subsys_role($sys) !== 'none';
|
||||
}
|
||||
/** 是否能进入某业务系统(拥有该系统任意角色即可:超管/管理员/用户) */
|
||||
function subsys_can_enter(string $sys): bool
|
||||
{
|
||||
return subsys_user($sys);
|
||||
}
|
||||
|
||||
/** 分系统页面清单(key 与子系统 nav 的 k 对应;dashboard 始终可见) */
|
||||
function subsys_pages(string $sys): array
|
||||
{
|
||||
return $sys === 'psi'
|
||||
? ['dashboard', 'materials', 'products', 'suppliers', 'purchases', 'sales', 'stock', 'orders',
|
||||
'sales_orders', 'purchase_orders', 'outbounds', 'reports', 'reminders']
|
||||
: ['dashboard', 'customers', 'leads', 'followups', 'contacts'];
|
||||
}
|
||||
|
||||
/** 当前 PSI 用户未读的紧急事件数量(用于导航铃铛徽标) */
|
||||
function psi_unread_events(): int
|
||||
{
|
||||
if (!subsys_user('psi')) return 0;
|
||||
$uid = (int) ($_SESSION['admin_uid'] ?? 0);
|
||||
if ($uid <= 0) return 0;
|
||||
try {
|
||||
$events = (new \App\Models\PSI\Event())->all();
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
$n = 0;
|
||||
foreach ($events as $ev) {
|
||||
$read = json_decode($ev['read_by'] ?? '[]', true) ?: [];
|
||||
if (!in_array($uid, $read, true)) $n++;
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
/**
|
||||
* 当前登录用户在 $sys 系统各页面的「可见」权限数组。
|
||||
* 子系统管理员(含超管,subsys_admin)拥有该系统全部页面;
|
||||
* 仅“用户”角色读 session 中的 {sys}_perms(登录时写入)做细粒度控制;
|
||||
* 旧账号无 perms 记录则默认全部可见(向后兼容,不会突然锁死)。
|
||||
*/
|
||||
function subsys_page_perms(string $sys): array
|
||||
{
|
||||
// 关键修复:以“分系统角色”判定管理员,而非仅看主角色是否为 super_admin。
|
||||
// 否则 CRM/PSI 管理员(crm_role=admin、主角色为“管理员”)会被误判为普通用户,
|
||||
// 一旦 {sys}_perms 受限就只剩仪表盘,导致左导菜单残缺、子页面 403。
|
||||
if (subsys_admin($sys)) {
|
||||
return array_fill_keys(subsys_pages($sys), true) + ['dashboard' => true];
|
||||
}
|
||||
$perms = $_SESSION[$sys . '_perms'] ?? null;
|
||||
$out = ['dashboard' => true];
|
||||
foreach (subsys_pages($sys) as $p) {
|
||||
if ($p === 'dashboard') continue;
|
||||
$out[$p] = ($perms === null) ? true : !empty($perms[$p]);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
/** 当前用户能否进入 $sys 系统的某页面 */
|
||||
function subsys_page_can(string $sys, string $page): bool
|
||||
{
|
||||
return !empty(subsys_page_perms($sys)[$page]);
|
||||
}
|
||||
/** 过滤子系统侧边导航:隐藏无权限页面项(dashboard 永留) */
|
||||
function subsys_filter_nav(string $sys, array $nav): array
|
||||
{
|
||||
return array_values(array_filter($nav, function ($n) use ($sys) {
|
||||
if (($n['k'] ?? '') === 'dashboard') return true;
|
||||
return subsys_page_can($sys, $n['k']);
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* 登录后落地页:依据子系统权限优先进入对应子系统仪表盘。
|
||||
* 设计目标:拥有 CRM / PSI 权限的账号登录后直达「CRM / PSI 仪表盘」,
|
||||
* 而非总后台仪表盘;总后台仪表盘仅留给「无任何子系统权限」的纯后台账号。
|
||||
* - 仅拥有 CRM:进入 CRM 仪表盘
|
||||
* - 仅拥有 PSI:进入 PSI 仪表盘
|
||||
* - 同时拥有 CRM+PSI:默认进入 CRM 仪表盘(左侧导航可切换 PSI)
|
||||
* - 无任何子系统权限(纯内容管理员 / 编辑):进入总后台仪表盘
|
||||
*/
|
||||
function login_landing(): string
|
||||
{
|
||||
$crm = subsys_user('crm');
|
||||
$psi = subsys_user('psi');
|
||||
if ($crm && !$psi) return 'CRM';
|
||||
if ($psi && !$crm) return 'PSI';
|
||||
if ($crm && $psi) return 'CRM';
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台(admin)左侧导航全量项。admin 主后台与 CRM / PSI 子系统布局共用,
|
||||
* 保证「后台框架一致」:进入 CRM / PSI 后左侧仍是同一套完整后台菜单(当前子系统主项高亮)。
|
||||
* 各页面项按角色能力(admin_can)过滤,子系统入口按 subsys_user 显隐,
|
||||
* 与 App 路由层的 capMap / 权限拦截保持一致。
|
||||
*/
|
||||
function admin_nav_items(): array
|
||||
{
|
||||
$baseItems = [
|
||||
['k' => '', 'label' => '仪表盘', 'ic' => 'dashboard', 'url' => 'admin'],
|
||||
['k' => 'products', 'label' => '产品管理', 'ic' => 'snowflake', 'url' => 'admin/products'],
|
||||
['k' => 'categories', 'label' => '分类管理', 'ic' => 'folders', 'url' => 'admin/categories'],
|
||||
['k' => 'news', 'label' => '新闻管理', 'ic' => 'newspaper', 'url' => 'admin/news'],
|
||||
['k' => 'cases', 'label' => '客户案例', 'ic' => 'handshake', 'url' => 'admin/cases'],
|
||||
['k' => 'pages', 'label' => '单页管理', 'ic' => 'file-text', 'url' => 'admin/pages'],
|
||||
['k' => 'banners', 'label' => '轮播管理', 'ic' => 'images', 'url' => 'admin/banners'],
|
||||
['k' => 'settings', 'label' => '站点设置', 'ic' => 'settings', 'url' => 'admin/settings'],
|
||||
['k' => 'theme', 'label' => '风格设置', 'ic' => 'palette', 'url' => 'admin/theme'],
|
||||
];
|
||||
$nav = [];
|
||||
foreach ($baseItems as $it) {
|
||||
// 仪表盘始终可见;其余按角色能力 admin_can 过滤(无权限则隐藏且不可直访)
|
||||
if ($it['k'] === '' || admin_can($it['k'])) $nav[] = $it;
|
||||
}
|
||||
// 子系统入口:拥有对应系统角色的管理员可见(点击进入 CRM / PSI)
|
||||
if (subsys_user('crm')) $nav[] = ['k' => 'crm', 'label' => '客户管理 CRM', 'ic' => 'handshake', 'url' => 'CRM'];
|
||||
if (subsys_user('psi')) $nav[] = ['k' => 'psi', 'label' => '进销存 PSI', 'ic' => 'package', 'url' => 'PSI'];
|
||||
// 仅超级管理员可见「用户管理 / 订单管理 / 支付设置 / 系统设置 / 数据库管理 / 数据库升级」
|
||||
if (admin_role() === 'super_admin') {
|
||||
$nav[] = ['k' => 'users', 'label' => '用户管理', 'ic' => 'users', 'url' => 'admin/users'];
|
||||
$nav[] = ['k' => 'orders', 'label' => '订单管理', 'ic' => 'receipt', 'url' => 'admin/orders'];
|
||||
$nav[] = ['k' => 'payments', 'label' => '支付设置', 'ic' => 'wallet', 'url' => 'admin/payments'];
|
||||
$nav[] = ['k' => 'system', 'label' => '系统设置', 'ic' => 'settings', 'url' => 'admin/system'];
|
||||
$nav[] = ['k' => 'db', 'label' => '数据库管理', 'ic' => 'database', 'url' => 'admin/db'];
|
||||
$nav[] = ['k' => 'upgrade', 'label' => '数据库升级', 'ic' => 'upload', 'url' => 'admin/upgrade',
|
||||
'badge' => (\Core\Db::driver() === 'mysql' ? db_pending_upgrades() : 0) ?: null];
|
||||
} elseif (admin_role() === 'admin') {
|
||||
$nav[] = ['k' => 'orders', 'label' => '订单管理', 'ic' => 'receipt', 'url' => 'admin/orders'];
|
||||
}
|
||||
return $nav;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分系统(CRM/PSI)侧边导航:统一来源,含「用户管理」(仅该系统管理员可见)。
|
||||
* 与 layouts/subsys.php 共用,避免逐个控制器重复维护导航数组。
|
||||
*/
|
||||
function subsys_nav(string $sys): array
|
||||
{
|
||||
$pages = $sys === 'psi'
|
||||
? [
|
||||
['k' => 'dashboard', 'label' => '仪表盘', 'url' => 'PSI'],
|
||||
['k' => 'materials', 'label' => '物料管理', 'url' => 'PSI/materials'],
|
||||
['k' => 'products', 'label' => '成品管理', 'url' => 'PSI/products'],
|
||||
['k' => 'suppliers', 'label' => '供应商', 'url' => 'PSI/suppliers'],
|
||||
['k' => 'purchases', 'label' => '采购入库', 'url' => 'PSI/purchases'],
|
||||
['k' => 'sales', 'label' => '销售出库', 'url' => 'PSI/sales'],
|
||||
['k' => 'stock', 'label' => '库存流水', 'url' => 'PSI/stock'],
|
||||
['k' => 'orders', 'label' => '订单管理', 'url' => 'PSI/orders'],
|
||||
['k' => 'sales_orders', 'label' => '销售订单', 'url' => 'PSI/sales_orders'],
|
||||
['k' => 'purchase_orders', 'label' => '采购订单', 'url' => 'PSI/purchase_orders'],
|
||||
['k' => 'outbounds', 'label' => '出库单', 'url' => 'PSI/outbounds'],
|
||||
['k' => 'reports', 'label' => '报表中心', 'url' => 'PSI/reports'],
|
||||
['k' => 'reminders', 'label' => '紧急提醒', 'url' => 'PSI/reminders'],
|
||||
]
|
||||
: [
|
||||
['k' => 'dashboard', 'label' => '仪表盘', 'url' => 'CRM'],
|
||||
['k' => 'customers', 'label' => '客户管理', 'url' => 'CRM/customers'],
|
||||
['k' => 'leads', 'label' => '商机线索', 'url' => 'CRM/leads'],
|
||||
['k' => 'followups', 'label' => '跟进记录', 'url' => 'CRM/followups'],
|
||||
['k' => 'contacts', 'label' => '客户联系人', 'url' => 'CRM/contacts'],
|
||||
];
|
||||
// 仅该系统管理员可管理本系统用户
|
||||
if (subsys_admin($sys)) {
|
||||
$pages[] = ['k' => 'users', 'label' => '用户管理', 'url' => strtoupper($sys) . '/users'];
|
||||
if ($sys === 'psi') {
|
||||
$pages[] = ['k' => 'notifications', 'label' => '通知设置', 'url' => 'PSI/notifications'];
|
||||
}
|
||||
}
|
||||
// 仅主角色为超管/管理员时显示「管理后台」入口
|
||||
if (in_array(admin_role(), ['super_admin', 'admin'], true)) {
|
||||
$pages[] = ['k' => 'admin', 'label' => '管理后台', 'url' => 'admin'];
|
||||
}
|
||||
return $pages;
|
||||
}
|
||||
|
||||
/** PSI 系统完整导航(含订单/出库/报表,所有控制器统一调用) */
|
||||
function psi_nav(): array
|
||||
{
|
||||
return [
|
||||
['k' => 'dashboard', 'label' => '仪表盘', 'icon' => admin_icon('home', 16) ?: '📊', 'url' => 'PSI'],
|
||||
['k' => 'materials', 'label' => '物料管理', 'icon' => admin_icon('box', 16) ?: '🧵', 'url' => 'PSI/materials'],
|
||||
['k' => 'products', 'label' => '成品管理', 'icon' => admin_icon('shirt', 16) ?: '👕', 'url' => 'PSI/products'],
|
||||
['k' => 'suppliers', 'label' => '供应商', 'icon' => admin_icon('buildings', 16) ?: '🏭', 'url' => 'PSI/suppliers'],
|
||||
['k' => 'purchases', 'label' => '采购入库', 'icon' => admin_icon('download', 16) ?: '📥', 'url' => 'PSI/purchases'],
|
||||
['k' => 'sales', 'label' => '销售出库', 'icon' => admin_icon('upload', 16) ?: '📤', 'url' => 'PSI/sales'],
|
||||
['k' => 'stock', 'label' => '库存流水', 'icon' => admin_icon('package', 16) ?: '📦', 'url' => 'PSI/stock'],
|
||||
['k' => 'orders', 'label' => '订单管理', 'icon' => admin_icon('receipt', 16) ?: '🧾', 'url' => 'PSI/orders'],
|
||||
['k' => 'sales_orders', 'label' => '销售订单', 'icon' => admin_icon('file-text', 16) ?: '📝', 'url' => 'PSI/sales_orders'],
|
||||
['k' => 'purchase_orders', 'label' => '采购订单', 'icon' => admin_icon('file-text', 16) ?: '📋', 'url' => 'PSI/purchase_orders'],
|
||||
['k' => 'outbounds', 'label' => '出库单', 'icon' => admin_icon('truck', 16) ?: '🚚', 'url' => 'PSI/outbounds'],
|
||||
['k' => 'reports', 'label' => '报表中心', 'icon' => admin_icon('chart-bar', 16) ?: '📊', 'url' => 'PSI/reports'],
|
||||
];
|
||||
}
|
||||
|
||||
/** 生成单据号:前缀 + 秒级时间 + 进程内计数器 + 随机,保证唯一 */
|
||||
function psi_gen_no(string $prefix): string
|
||||
{
|
||||
static $c = 0;
|
||||
$c++;
|
||||
return strtoupper($prefix) . date('YmdHis') . str_pad($c, 4, '0', STR_PAD_LEFT) . mt_rand(10, 99);
|
||||
}
|
||||
|
||||
/** 根据已交付数量重算销售订单状态(pending/partial/delivered) */
|
||||
function psi_recompute_so(int $soId): void
|
||||
{
|
||||
$items = (new \App\Models\PSI\SalesOrderItem())->whereAll('so_id', $soId);
|
||||
$total = 0; $delivered = 0;
|
||||
foreach ($items as $it) {
|
||||
$total += (int)($it['qty'] ?? 0);
|
||||
$delivered += (int)($it['delivered_qty'] ?? 0);
|
||||
}
|
||||
$status = $total <= 0 ? 'pending' : ($delivered >= $total ? 'delivered' : ($delivered > 0 ? 'partial' : 'pending'));
|
||||
(new \App\Models\PSI\SalesOrder())->update($soId, ['status' => $status]);
|
||||
}
|
||||
|
||||
/** 打印页外壳:独立 HTML + A4 样式 + 自动打印 */
|
||||
function psi_print_shell(string $title, string $body): string
|
||||
{
|
||||
$css = 'body{font-family:-apple-system,"Microsoft YaHei",sans-serif;color:#111;margin:0;padding:24px;background:#fff;}'
|
||||
. '.doc{width:210mm;max-width:100%;margin:0 auto;}'
|
||||
. '@media print{body{padding:0;}.no-print{display:none!important;}@page{margin:12mm;}}'
|
||||
. '.doc h2{text-align:center;margin:0 0 4px;font-size:20px;}'
|
||||
. '.doc .sub{text-align:center;color:#666;margin-bottom:16px;font-size:13px;}'
|
||||
. '.doc .meta{display:flex;flex-wrap:wrap;gap:6px 28px;font-size:13px;margin:12px 0;border-bottom:1px dashed #ccc;padding-bottom:10px;}'
|
||||
. '.doc .meta b{color:#374151;}'
|
||||
. '.doc table{border-collapse:collapse;width:100%;font-size:13px;margin-top:8px;}'
|
||||
. '.doc th,.doc td{border:1px solid #bbb;padding:7px 9px;}'
|
||||
. '.doc th{background:#f3f4f6;}'
|
||||
. '.doc .total{text-align:right;font-weight:700;margin-top:12px;font-size:14px;}'
|
||||
. '.doc .sign{display:flex;justify-content:space-between;margin-top:40px;font-size:13px;color:#374151;}'
|
||||
. '.btn-print{position:fixed;top:16px;right:16px;padding:10px 18px;border-radius:8px;border:1px solid #0ea5e9;background:#0ea5e9;color:#fff;cursor:pointer;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,.15);}';
|
||||
return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>' . e($title) . '</title>'
|
||||
. '<style>' . $css . '</style></head><body>'
|
||||
. '<button class="btn-print no-print" onclick="window.print()">打印 / 导出 PDF</button>'
|
||||
. '<div class="doc">' . $body . '</div>'
|
||||
. '<script>window.onload=function(){setTimeout(function(){window.print();},300);};</script>'
|
||||
. '</body></html>';
|
||||
}
|
||||
|
||||
/** 一次性提示消息(跨重定向,取值后清空) */
|
||||
function flash(string $msg, string $type = 'ok'): void
|
||||
{
|
||||
$_SESSION['_flash'] = ['msg' => $msg, 'type' => $type];
|
||||
}
|
||||
function flash_html(): string
|
||||
{
|
||||
if (empty($_SESSION['_flash'])) return '';
|
||||
$f = $_SESSION['_flash'];
|
||||
unset($_SESSION['_flash']);
|
||||
$cls = $f['type'] === 'err' ? 'alert alert-err' : 'alert alert-ok';
|
||||
return '<div class="' . $cls . '">' . e($f['msg']) . '</div>';
|
||||
}
|
||||
|
||||
/* ---------- mbstring 兼容层:远端 PHP 未启用 mbstring 扩展时提供兜底实现 ---------- */
|
||||
if (!function_exists('mb_substr')) {
|
||||
function mb_substr(string $str, int $start, ?int $length = null, string $encoding = 'UTF-8'): string
|
||||
{
|
||||
$chars = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY);
|
||||
if ($chars === false) { $chars = preg_split('//', $str, -1, PREG_SPLIT_NO_EMPTY) ?: []; }
|
||||
$len = $length ?? count($chars);
|
||||
return implode('', array_slice($chars, $start, $len));
|
||||
}
|
||||
}
|
||||
if (!function_exists('mb_strlen')) {
|
||||
function mb_strlen(string $str, string $encoding = 'UTF-8'): int
|
||||
{
|
||||
$chars = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY);
|
||||
return $chars === false ? strlen($str) : count($chars);
|
||||
}
|
||||
}
|
||||
if (!function_exists('mb_strtolower')) {
|
||||
function mb_strtolower(string $str, string $encoding = 'UTF-8'): string
|
||||
{
|
||||
return strtolower($str);
|
||||
}
|
||||
}
|
||||
if (!function_exists('mb_strtoupper')) {
|
||||
function mb_strtoupper(string $str, string $encoding = 'UTF-8'): string
|
||||
{
|
||||
return strtoupper($str);
|
||||
}
|
||||
}
|
||||
if (!function_exists('mb_check_encoding')) {
|
||||
function mb_check_encoding($var, ?string $encoding = null): bool
|
||||
{
|
||||
if (is_array($var) || is_object($var)) return false;
|
||||
$str = (string)$var;
|
||||
if ($encoding === null || strtoupper((string)$encoding) === 'UTF-8') {
|
||||
return (bool) preg_match('/\A(?: [\x00-\x7F] | [\xC2-\xDF][\x80-\xBF] | \xE0[\xA0-\xBF][\x80-\xBF] | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2} | \xED[\x80-\x9F][\x80-\xBF] | \xF0[\x90-\xBF][\x80-\xBF]{2} | [\xF1-\xF3][\x80-\xBF]{3} | \xF4[\x80-\x8F][\x80-\xBF]{2} )*\z/x', $str);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!function_exists('mb_convert_encoding')) {
|
||||
function mb_convert_encoding(string $str, string $to, ?string $from = null): string
|
||||
{
|
||||
if (function_exists('iconv')) {
|
||||
$conv = @iconv($from ?? 'UTF-8', $to . '//IGNORE', $str);
|
||||
if ($conv !== false) return $conv;
|
||||
}
|
||||
return $str;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内联 SVG 图标(Lucide 线性风格,stroke=currentColor)。
|
||||
* 取代后台原有 emoji 图标:清晰、随主题变色、跨平台一致。
|
||||
*/
|
||||
function admin_icon(string $name, int $size = 20): string
|
||||
{
|
||||
static $paths = null;
|
||||
if ($paths === null) {
|
||||
$paths = [
|
||||
'dashboard' => '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
|
||||
'snowflake' => '<line x1="2" x2="22" y1="12" y2="12"/><line x1="12" x2="12" y1="2" y2="22"/><path d="m20 16-4-4 4-4"/><path d="m4 8 4 4-4 4"/><path d="m16 4-4 4-4-4"/><path d="m8 20 4-4 4 4"/>',
|
||||
'folders' => '<path d="M8 17h12a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3.9a2 2 0 0 1-1.69-.9l-.81-1.2a2 2 0 0 0-1.67-.9H8a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2Z"/><path d="M2 8v11a2 2 0 0 0 2 2h14"/>',
|
||||
'newspaper' => '<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/>',
|
||||
'handshake' => '<path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"/><path d="m21 3 1 11h-2"/><path d="M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"/><path d="M3 4h8"/>',
|
||||
'file-text' => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
|
||||
'images' => '<path d="M18 22H4a2 2 0 0 1-2-2V6"/><path d="m22 13-1.296-1.296a2.41 2.41 0 0 0-3.408 0L11 18"/><circle cx="12" cy="8" r="2"/><rect width="16" height="16" x="6" y="2" rx="2"/>',
|
||||
'settings' => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'palette' => '<circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/>',
|
||||
'users' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
'receipt' => '<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 17.5v-11"/>',
|
||||
'wallet' => '<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/>',
|
||||
'upload' => '<circle cx="12" cy="12" r="10"/><path d="m16 12-4-4-4 4"/><path d="M12 16V8"/>',
|
||||
'database' => '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>',
|
||||
'package' => '<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="m7.5 4.27 9 5.15"/>',
|
||||
'logout' => '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
|
||||
'key' => '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
|
||||
'globe' => '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||
'menu' => '<line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/>',
|
||||
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
'user' => '<circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/>',
|
||||
'pencil' => '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
|
||||
'shield' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
|
||||
'lightbulb' => '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
|
||||
'phone' => '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/>',
|
||||
'shopping-bag' => '<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
|
||||
'truck' => '<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/><path d="M15 18H9"/><path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/>',
|
||||
'inbox' => '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
||||
'cart' => '<circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/>',
|
||||
'user-plus' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/>',
|
||||
'key' => '<path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
|
||||
'lock' => '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
'bar-chart' => '<line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/><line x1="3" y1="20" x2="21" y2="20"/>',
|
||||
'printer' => '<path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8" rx="1"/>',
|
||||
'check' => '<path d="M20 6 9 17l-5-5"/>',
|
||||
'clipboard' => '<rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
|
||||
'trending-up' => '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
|
||||
'alert' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
];
|
||||
}
|
||||
$p = $paths[$name] ?? $paths['file-text'];
|
||||
return '<svg class="li li-' . e($name) . '" width="' . $size . '" height="' . $size . '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' . $p . '</svg>';
|
||||
}
|
||||
|
||||
/* ---------- 数据库升级:升级包目录与待升级计数 ---------- */
|
||||
|
||||
/** 升级包目录(放置 *.sql 后,后台「数据库升级」即提示可升级) */
|
||||
function db_upgrade_dir(): string
|
||||
{
|
||||
return BASE_PATH . '/install/upgrades';
|
||||
}
|
||||
|
||||
/** 将字节数格式化为人类可读大小 */
|
||||
function human_size(int $bytes): string
|
||||
{
|
||||
if ($bytes < 1024) return $bytes . ' B';
|
||||
$units = ['KB', 'MB', 'GB', 'TB'];
|
||||
$i = -1;
|
||||
do { $bytes /= 1024; $i++; } while ($bytes >= 1024 && $i < count($units) - 1);
|
||||
return round($bytes, 2) . ' ' . $units[$i];
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计待升级的 SQL 文件数量(未记录或内容已变更)。
|
||||
* 用于在导航上提示「可升级」。失败安全:任何异常都返回 0。
|
||||
*/
|
||||
function db_pending_upgrades(): int
|
||||
{
|
||||
try {
|
||||
if (\Core\Db::driver() !== 'mysql') return 0;
|
||||
$dir = db_upgrade_dir();
|
||||
if (!is_dir($dir)) return 0;
|
||||
$files = glob($dir . '/*.sql') ?: [];
|
||||
if (!$files) return 0;
|
||||
\Core\Installer::ensureUpgradeLog();
|
||||
$applied = \Core\Db::query("SELECT file, hash FROM db_upgrades")->fetchAll(\PDO::FETCH_KEY_PAIR);
|
||||
$n = 0;
|
||||
foreach ($files as $f) {
|
||||
$name = basename($f);
|
||||
$h = md5_file($f);
|
||||
if (!isset($applied[$name]) || $applied[$name] !== $h) {
|
||||
$n++;
|
||||
}
|
||||
}
|
||||
return $n;
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 安全响应头(质量红线:所有后台/API 统一应用) ---------- */
|
||||
/** 生成每次请求唯一的 CSP nonce(同请求内多次调用返回同一值,并去除 base64 填充符以兼容 CSP) */
|
||||
function csp_nonce(): string
|
||||
{
|
||||
static $n;
|
||||
if ($n === null) {
|
||||
$n = rtrim(base64_encode(random_bytes(16)), '=');
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
|
||||
function apply_security_headers(): void
|
||||
{
|
||||
if (headers_sent()) return;
|
||||
$nonce = csp_nonce();
|
||||
// 通用安全响应头从 PHP 兜底补齐:即便 Nginx 层未下发也不会缺失(防配置漂移)。
|
||||
// 与审计整改要求一致:补充 X-Content-Type-Options / Referrer-Policy / Permissions-Policy,
|
||||
// 并将 HSTS 升级为含 includeSubDomains + preload。若 Nginx 也下发 HSTS,重复为无害,
|
||||
// 浏览器取更严格项(max-age 取最大值并合并指令)。
|
||||
header("X-Content-Type-Options: nosniff");
|
||||
header("Referrer-Policy: strict-origin-when-cross-origin");
|
||||
header("Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()");
|
||||
header("Strict-Transport-Security: max-age=63072000; includeSubDomains; preload");
|
||||
// 严格 CSP(nonce 每次请求不同,必须走 PHP)
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'");
|
||||
}
|
||||
|
||||
/** 当前请求的完整绝对 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 式,文件缓存,越会话更抗爆破) ----------
|
||||
* 双窗口独立限速(按需求定制):
|
||||
* · 失败登录:任意 10 分钟内最多 5 次;超出即封锁 30 分钟
|
||||
* · 成功登录:任意 30 分钟内最多 5 次;超出即限制(封锁至最早成功滑出 30 分钟窗口)
|
||||
* 数据文件:storage/login_ip.json —— 每个 IP 记失败/成功时间戳列表 + 封锁截止时间
|
||||
* --------------------------------------------------------------------- */
|
||||
defined('LOGIN_FAIL_WINDOW') or define('LOGIN_FAIL_WINDOW', 600); // 失败计数窗口:10 分钟
|
||||
defined('LOGIN_FAIL_LIMIT') or define('LOGIN_FAIL_LIMIT', 5); // 失败次数上限
|
||||
defined('LOGIN_OK_WINDOW') or define('LOGIN_OK_WINDOW', 1800); // 成功计数窗口:30 分钟
|
||||
defined('LOGIN_OK_LIMIT') or define('LOGIN_OK_LIMIT', 5); // 成功次数上限
|
||||
defined('LOGIN_BLOCK_SECS') or define('LOGIN_BLOCK_SECS', 1800); // 超限后封锁时长:30 分钟
|
||||
|
||||
function _ip_login_load(): array
|
||||
{
|
||||
$file = BASE_PATH . '/storage/login_ip.json';
|
||||
return is_file($file) ? (json_decode(@file_get_contents($file), true) ?: []) : [];
|
||||
}
|
||||
function _ip_login_save(array $data): 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
function ip_login_blocked(string $ip): bool
|
||||
{
|
||||
$now = time();
|
||||
$st = _ip_login_load()[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
|
||||
_ip_login_prune($st, $now);
|
||||
return ($st['block_until'] ?? 0) > $now;
|
||||
}
|
||||
/** 返回剩余封锁秒数(已解封为 0),供 Retry-After 使用 */
|
||||
function ip_login_remaining(string $ip): int
|
||||
{
|
||||
$now = time();
|
||||
$st = _ip_login_load()[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
|
||||
_ip_login_prune($st, $now);
|
||||
return max(0, (int)($st['block_until'] ?? 0) - $now);
|
||||
}
|
||||
function ip_login_register_fail(string $ip): void
|
||||
{
|
||||
$now = time();
|
||||
$data = _ip_login_load();
|
||||
$st = $data[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
|
||||
_ip_login_prune($st, $now);
|
||||
$st['fail'][] = $now;
|
||||
_ip_login_prune($st, $now); // 追加后重新评估是否触发封锁
|
||||
$data[$ip] = $st;
|
||||
_ip_login_save($data);
|
||||
}
|
||||
function ip_login_register_success(string $ip): void
|
||||
{
|
||||
$now = time();
|
||||
$data = _ip_login_load();
|
||||
$st = $data[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
|
||||
_ip_login_prune($st, $now);
|
||||
$st['fail'] = []; // 成功登录重置失败计数(防爆破计数器归零)
|
||||
$st['ok'][] = $now; // 记录一次成功,纳入「30 分钟 5 次」上限
|
||||
_ip_login_prune($st, $now);
|
||||
$data[$ip] = $st;
|
||||
_ip_login_save($data);
|
||||
}
|
||||
function ip_login_clear(string $ip): void
|
||||
{
|
||||
$data = _ip_login_load();
|
||||
unset($data[$ip]);
|
||||
_ip_login_save($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));
|
||||
}
|
||||
}
|
||||
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
namespace Core;
|
||||
|
||||
/**
|
||||
* 安装 / 数据升级 核心逻辑(供 install.php、后台「数据升级」与「数据库初始化」共用)
|
||||
*
|
||||
* 设计原则:
|
||||
* - install() 首次安装:建全部表 + 插种子(可指定自定义超级管理员),不破坏已有数据。
|
||||
* - upgrade() 增量升级:补齐新增模块表/列 + 按 id 补齐缺失种子,绝不 DELETE / 覆盖客户数据。
|
||||
* - 所有 DDL 均为 IF NOT EXISTS / ADD COLUMN IF NOT EXISTS 幂等写法。
|
||||
*/
|
||||
class Installer
|
||||
{
|
||||
/** 完整安装(首次)。$super 非空时用自定义超级管理员覆盖种子账号。 */
|
||||
public static function install(?array $super = null): array
|
||||
{
|
||||
$seed = require BASE_PATH . '/install/seed.php';
|
||||
if ($super) {
|
||||
$seed['admin_users'] = [[
|
||||
'id' => 1,
|
||||
'username' => $super['username'],
|
||||
'password' => password_hash($super['password'], PASSWORD_DEFAULT),
|
||||
'name' => $super['name'] ?? '超级管理员',
|
||||
'role' => 'super_admin',
|
||||
'crm_role' => 'admin',
|
||||
'psi_role' => 'admin',
|
||||
'status' => 1,
|
||||
'created_at' => date('Y-m-d'),
|
||||
]];
|
||||
}
|
||||
$driver = Db::driver();
|
||||
$msgs = [];
|
||||
if ($driver === 'file') {
|
||||
$dir = Db::fileDir();
|
||||
foreach ($seed as $table => $rows) {
|
||||
$i = 1;
|
||||
foreach ($rows as &$r) { if (!isset($r['id'])) { $r['id'] = $i; } $i++; }
|
||||
unset($r);
|
||||
file_put_contents($dir . "/{$table}.json", json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
$msgs[] = "写入 {$table}.json (" . count($rows) . " 条)";
|
||||
}
|
||||
foreach (['crm_contacts'] as $t) {
|
||||
$f = $dir . "/{$t}.json";
|
||||
if (!is_file($f)) { file_put_contents($f, '[]'); $msgs[] = "创建 {$t}.json"; }
|
||||
}
|
||||
} else {
|
||||
$pdo = Db::pdo();
|
||||
$pdo->exec(file_get_contents(BASE_PATH . '/install/schema.sql'));
|
||||
$msgs[] = "数据表已创建/更新(基础 + CRM + PSI)";
|
||||
foreach (['categories', 'products', 'news', 'cases'] as $t) {
|
||||
try { $pdo->exec("ALTER TABLE `{$t}` ADD COLUMN `layout` TEXT"); } catch (\Throwable $e) {}
|
||||
}
|
||||
self::ensureColumns($pdo, $msgs);
|
||||
$map = self::modelMap();
|
||||
foreach ($seed as $table => $rows) {
|
||||
$m = $map[$table] ?? null;
|
||||
if (!$m) continue;
|
||||
foreach ($rows as $r) { $m->insert($r); }
|
||||
$msgs[] = "插入 {$table} (" . count($rows) . " 条)";
|
||||
}
|
||||
}
|
||||
try { Theme::regenerate(); $msgs[] = "主题样式 theme.css 已生成"; } catch (\Throwable $e) {}
|
||||
@file_put_contents(BASE_PATH . '/storage/installed.lock', date('Y-m-d H:i:s') . " installed\n");
|
||||
return $msgs;
|
||||
}
|
||||
|
||||
/** 数据升级(后台按钮 / 已安装系统):补齐新模块表/列,按 id 补齐缺失种子,保留客户数据 */
|
||||
public static function upgrade(): array
|
||||
{
|
||||
$seed = require BASE_PATH . '/install/seed.php';
|
||||
$driver = Db::driver();
|
||||
$msgs = [];
|
||||
$content = [
|
||||
'categories', 'products', 'news', 'cases', 'pages', 'banners', 'settings',
|
||||
'crm_customers', 'crm_leads', 'crm_followups', 'crm_contacts',
|
||||
'psi_materials', 'psi_products', 'psi_suppliers', 'psi_purchases', 'psi_sales',
|
||||
];
|
||||
if ($driver === 'file') {
|
||||
$dir = Db::fileDir();
|
||||
foreach ($seed as $table => $rows) {
|
||||
if (!in_array($table, $content, true)) continue;
|
||||
$ef = $dir . "/{$table}.json";
|
||||
$existing = [];
|
||||
if (is_file($ef)) {
|
||||
$ed = @json_decode(file_get_contents($ef), true);
|
||||
if (is_array($ed)) $existing = $ed;
|
||||
}
|
||||
if ($table === 'settings') {
|
||||
$keys = [];
|
||||
foreach ($existing as $er) { if (isset($er['skey'])) $keys[$er['skey']] = true; }
|
||||
foreach ($rows as $r) { if (!isset($keys[$r['skey']])) $existing[] = $r; }
|
||||
} else {
|
||||
$ids = [];
|
||||
foreach ($existing as $er) { if (isset($er['id'])) $ids[$er['id']] = true; }
|
||||
foreach ($rows as $r) {
|
||||
$rid = $r['id'] ?? null;
|
||||
if ($rid !== null && !isset($ids[$rid])) $existing[] = $r;
|
||||
}
|
||||
}
|
||||
file_put_contents($ef, json_encode($existing, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
$msgs[] = "刷新 {$table}.json (" . count($existing) . " 条,保留客户数据)";
|
||||
}
|
||||
foreach (['orders', 'payments', 'crm_contacts'] as $t) {
|
||||
$f = $dir . "/{$t}.json";
|
||||
if (!is_file($f)) { file_put_contents($f, '[]'); $msgs[] = "创建 {$t}.json"; }
|
||||
}
|
||||
} else {
|
||||
$pdo = Db::pdo();
|
||||
$pdo->exec(file_get_contents(BASE_PATH . '/install/schema.sql'));
|
||||
$msgs[] = "数据表已创建/更新(补齐新增模块表)";
|
||||
foreach (['categories', 'products', 'news', 'cases'] as $t) {
|
||||
try { $pdo->exec("ALTER TABLE `{$t}` ADD COLUMN `layout` TEXT"); } catch (\Throwable $e) {}
|
||||
}
|
||||
self::ensureColumns($pdo, $msgs);
|
||||
$map = self::modelMap();
|
||||
foreach ($seed as $table => $rows) {
|
||||
if (!in_array($table, $content, true)) continue;
|
||||
$m = $map[$table] ?? null;
|
||||
if (!$m) continue;
|
||||
if ($table === 'settings') {
|
||||
$keys = [];
|
||||
try { $rs = Db::query("SELECT skey FROM `settings`"); foreach ($rs->fetchAll() as $er) $keys[$er['skey']] = true; } catch (\Throwable $e) {}
|
||||
$added = 0;
|
||||
foreach ($rows as $r) { if (!isset($keys[$r['skey']])) { $m->insert($r); $added++; } }
|
||||
$msgs[] = "补齐 settings (" . $added . " 条)";
|
||||
continue;
|
||||
}
|
||||
$ids = [];
|
||||
try { $rs = Db::query("SELECT id FROM `{$table}`"); foreach ($rs->fetchAll() as $er) $ids[$er['id']] = true; } catch (\Throwable $e) {}
|
||||
$added = 0;
|
||||
foreach ($rows as $r) {
|
||||
$rid = $r['id'] ?? null;
|
||||
if ($rid !== null && !isset($ids[$rid])) { $m->insert($r); $added++; }
|
||||
}
|
||||
$msgs[] = "补齐 {$table} 缺失种子 (" . $added . " 条,已有 " . count($ids) . " 条保留)";
|
||||
}
|
||||
}
|
||||
try { Theme::regenerate(); $msgs[] = "主题样式 theme.css 已生成"; } catch (\Throwable $e) {}
|
||||
return $msgs;
|
||||
}
|
||||
|
||||
/** 执行单个 SQL 文件(用于「数据库升级」的升级包)。失败会抛出异常由调用方捕获。 */
|
||||
public static function applySqlFile(string $path): void
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
throw new \RuntimeException("升级文件不存在:{$path}");
|
||||
}
|
||||
$sql = file_get_contents($path);
|
||||
if ($sql === false || trim($sql) === '') return;
|
||||
self::dbExecute($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分段执行 SQL(支持 DELIMITER 命令,兼容 PDO 不支持的客户端语法)。
|
||||
* @param string $sql 原始 SQL 文本(含 / 不含 DELIMITER 均可)
|
||||
* @throws \Throwable
|
||||
*/
|
||||
private static function dbExecute(string $sql): void
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
// 逐行解析 DELIMITER 与多语句拆分
|
||||
$lines = explode("\n", $sql);
|
||||
$delimiter = ';';
|
||||
$buffer = '';
|
||||
foreach ($lines as $raw) {
|
||||
$line = trim($raw);
|
||||
// 跳过空行与单行注释(兼容 PHP 7,不用 str_starts_with)
|
||||
if ($line === '' || strpos($line, '--') === 0 || strpos($line, '#') === 0) continue;
|
||||
// 检测 DELIMITER 命令(客户端命令,不进入 SQL 执行)
|
||||
if (strtoupper(substr($line, 0, 10)) === 'DELIMITER ') {
|
||||
// 积压的 SQL 遇到 DELIMITER 修改时先执行
|
||||
$stmt = trim($buffer);
|
||||
if ($stmt !== '') {
|
||||
if ($pdo->exec($stmt) === false) {
|
||||
$err = $pdo->errorInfo();
|
||||
throw new \RuntimeException("SQL 执行错误:{$err[2]} (SQL: " . substr($stmt, 0, 120) . ')');
|
||||
}
|
||||
}
|
||||
$buffer = '';
|
||||
$delimiter = trim(substr($line, 10));
|
||||
continue;
|
||||
}
|
||||
$buffer .= $raw . "\n";
|
||||
// 检查 buffer 是否以当前分隔符结尾(忽略末尾空白与行末注释)
|
||||
$trimmed = rtrim($buffer, " \t\r\n");
|
||||
if (substr($trimmed, -strlen($delimiter)) === $delimiter) {
|
||||
$stmt = rtrim(substr($trimmed, 0, -strlen($delimiter)));
|
||||
$stmt = trim($stmt);
|
||||
if ($stmt !== '') {
|
||||
if ($pdo->exec($stmt) === false) {
|
||||
$err = $pdo->errorInfo();
|
||||
throw new \RuntimeException("SQL 执行错误:{$err[2]} (SQL: " . substr($stmt, 0, 120) . ')');
|
||||
}
|
||||
}
|
||||
$buffer = '';
|
||||
}
|
||||
}
|
||||
// 最后一段(无结束分隔符的纯 SQL)
|
||||
$stmt = trim($buffer);
|
||||
if ($stmt !== '') {
|
||||
if ($pdo->exec($stmt) === false) {
|
||||
$err = $pdo->errorInfo();
|
||||
throw new \RuntimeException("SQL 执行错误:{$err[2]} (SQL: " . substr($stmt, 0, 120) . ')');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 确保升级记录表存在(幂等) */
|
||||
public static function ensureUpgradeLog(): void
|
||||
{
|
||||
if (Db::driver() !== 'mysql') return;
|
||||
try {
|
||||
Db::pdo()->exec("CREATE TABLE IF NOT EXISTS db_upgrades (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
file VARCHAR(255) NOT NULL COMMENT '升级包文件名',
|
||||
hash CHAR(32) NOT NULL COMMENT '文件 MD5,用于识别内容变更',
|
||||
applied_at DATETIME NOT NULL COMMENT '执行时间',
|
||||
applied_by VARCHAR(64) DEFAULT '' COMMENT '操作人',
|
||||
note TEXT COMMENT '备注'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
public static function isInstalled(): bool
|
||||
{
|
||||
if (Db::driver() !== 'mysql') return true; // file 模式无「安装」概念
|
||||
try {
|
||||
$cnt = (new \App\Models\AdminUser())->count();
|
||||
return $cnt > 0;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回每张预期表的存在状态(mysql 模式) */
|
||||
public static function tableStatus(): array
|
||||
{
|
||||
if (Db::driver() !== 'mysql') return [];
|
||||
$pdo = Db::pdo();
|
||||
$exist = $pdo->query("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()")->fetchAll(\PDO::FETCH_COLUMN);
|
||||
$all = [
|
||||
'categories', 'products', 'news', 'cases', 'pages', 'banners', 'admin_users',
|
||||
'crm_customers', 'crm_leads', 'crm_followups', 'crm_contacts',
|
||||
'psi_materials', 'psi_products', 'psi_suppliers', 'psi_purchases', 'psi_sales', 'psi_stock_moves',
|
||||
'settings', 'orders', 'payments',
|
||||
];
|
||||
$out = [];
|
||||
foreach ($all as $t) { $out[$t] = in_array($t, $exist, true); }
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 补齐各表新增列(防御性,schema.sql 已含,此处兜底供「数据升级」使用,幂等) */
|
||||
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'",
|
||||
'crm_perms' => "TEXT",
|
||||
'psi_perms' => "TEXT",
|
||||
],
|
||||
'crm_customers' => [
|
||||
'customer_no' => "VARCHAR(40) DEFAULT ''",
|
||||
'industry' => "VARCHAR(20) DEFAULT ''",
|
||||
'region' => "VARCHAR(40) DEFAULT ''",
|
||||
'credit_limit'=> "DECIMAL(12,2) DEFAULT 0",
|
||||
'status' => "VARCHAR(20) DEFAULT 'lead'",
|
||||
],
|
||||
'crm_leads' => [
|
||||
'source' => "VARCHAR(30) DEFAULT ''",
|
||||
'probability' => "TINYINT DEFAULT 0",
|
||||
],
|
||||
'crm_followups' => [
|
||||
'way' => "VARCHAR(20) DEFAULT ''",
|
||||
'result' => "VARCHAR(60) DEFAULT ''",
|
||||
],
|
||||
'psi_materials' => [
|
||||
'composition' => "VARCHAR(60) DEFAULT ''",
|
||||
'weight_gsm' => "DECIMAL(8,2) DEFAULT 0",
|
||||
'width_cm' => "DECIMAL(8,2) DEFAULT 0",
|
||||
'color' => "VARCHAR(40) DEFAULT ''",
|
||||
'batch_no' => "VARCHAR(40) DEFAULT ''",
|
||||
],
|
||||
'psi_products' => [
|
||||
'style_no' => "VARCHAR(40) DEFAULT ''",
|
||||
'color' => "VARCHAR(40) DEFAULT ''",
|
||||
'size_run' => "VARCHAR(60) DEFAULT ''",
|
||||
'season' => "VARCHAR(20) DEFAULT ''",
|
||||
'year' => "VARCHAR(10) DEFAULT ''",
|
||||
],
|
||||
'psi_suppliers' => [
|
||||
'type' => "VARCHAR(20) DEFAULT ''",
|
||||
'grade' => "VARCHAR(20) DEFAULT ''",
|
||||
'ontime_rate'=> "DECIMAL(5,2) DEFAULT 0",
|
||||
'qc_rate' => "DECIMAL(5,2) DEFAULT 0",
|
||||
],
|
||||
'psi_purchases' => [
|
||||
'batch_no' => "VARCHAR(40) DEFAULT ''",
|
||||
'expected_at' => "VARCHAR(20) DEFAULT ''",
|
||||
],
|
||||
'psi_sales' => [
|
||||
'region' => "VARCHAR(40) DEFAULT ''",
|
||||
'batch_no' => "VARCHAR(40) DEFAULT ''",
|
||||
],
|
||||
'psi_stock_moves' => [
|
||||
'batch_no' => "VARCHAR(40) DEFAULT ''",
|
||||
],
|
||||
];
|
||||
foreach ($map as $table => $cols) {
|
||||
try {
|
||||
$have = $pdo->query("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='{$table}'")->fetchAll(\PDO::FETCH_COLUMN);
|
||||
} catch (\Throwable $e) {
|
||||
continue;
|
||||
}
|
||||
foreach ($cols as $c => $def) {
|
||||
if (!in_array($c, $have, true)) {
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE `{$table}` ADD COLUMN `{$c}` {$def}");
|
||||
$msgs[] = "已添加 {$table}.{$c}";
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function modelMap(): array
|
||||
{
|
||||
return [
|
||||
'categories' => new \App\Models\Category(),
|
||||
'products' => new \App\Models\Product(),
|
||||
'news' => new \App\Models\News(),
|
||||
'cases' => new \App\Models\CustomerCase(),
|
||||
'pages' => new \App\Models\Page(),
|
||||
'banners' => new \App\Models\Banner(),
|
||||
'admin_users'=> new \App\Models\AdminUser(),
|
||||
'settings' => new \App\Models\Setting(),
|
||||
'orders' => new \App\Models\Order(),
|
||||
'payments' => new \App\Models\Payment(),
|
||||
'crm_customers' => new \App\Models\CRM\Customer(),
|
||||
'crm_leads' => new \App\Models\CRM\Lead(),
|
||||
'crm_followups' => new \App\Models\CRM\FollowUp(),
|
||||
'crm_contacts' => new \App\Models\CRM\Contact(),
|
||||
'psi_suppliers' => new \App\Models\PSI\Supplier(),
|
||||
'psi_materials' => new \App\Models\PSI\Material(),
|
||||
'psi_products' => new \App\Models\PSI\Product(),
|
||||
'psi_purchases' => new \App\Models\PSI\Purchase(),
|
||||
'psi_sales' => new \App\Models\PSI\Sales(),
|
||||
'psi_stock_moves'=> new \App\Models\PSI\StockMove(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
namespace Core;
|
||||
|
||||
/**
|
||||
* 模型基类:同时支持 MySQL 与 文件(JSON) 两种存储
|
||||
* 子类设置 $table 与 $orderBy 即可。
|
||||
*/
|
||||
class Model
|
||||
{
|
||||
protected $table;
|
||||
protected $pk = 'id';
|
||||
protected $orderBy = 'id';
|
||||
|
||||
/* ---------- 文件模式 ---------- */
|
||||
private function file(): string
|
||||
{
|
||||
return Db::fileDir() . '/' . $this->table . '.json';
|
||||
}
|
||||
private function read(): array
|
||||
{
|
||||
$f = $this->file();
|
||||
if (!is_file($f)) return [];
|
||||
$d = json_decode(file_get_contents($f), true);
|
||||
return is_array($d) ? $d : [];
|
||||
}
|
||||
private function write(array $rows): void
|
||||
{
|
||||
$rows = $this->sanitizeUtf8($rows);
|
||||
// JSON_INVALID_UTF8_SUBSTITUTE (PHP 7.2+) 保证即使存在非法 UTF-8 也不会让 json_encode 返回 false,
|
||||
// 避免 file_put_contents(false) 把整个数据文件清空为 0 字节(灾难性数据丢失)。
|
||||
$json = json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
if ($json === false || $json === '') {
|
||||
error_log('[Model::write] json_encode failed for ' . $this->table . '; aborting write to avoid data loss');
|
||||
return;
|
||||
}
|
||||
$f = $this->file();
|
||||
$dir = dirname($f);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
// 写入失败(最常见:服务器目录/文件权限不足,PHP 进程无写权限)必须记录日志,
|
||||
// 否则会“假成功”——页面跳转回去、用户以为保存了,数据却没变。
|
||||
$bytes = @file_put_contents($f, $json);
|
||||
if ($bytes === false) {
|
||||
$err = error_get_last();
|
||||
error_log('[Model::write] FAILED to write ' . $f . ' — ' . ($err['message'] ?? 'unknown error') .
|
||||
' | 请检查目录/文件所有者是否为 PHP 运行用户(宝塔通常是 www)并赋予写权限');
|
||||
}
|
||||
}
|
||||
|
||||
/** 递归将数组中的字符串修复为合法 UTF-8,剔除非法字节序列 */
|
||||
private function sanitizeUtf8($v)
|
||||
{
|
||||
if (is_array($v)) {
|
||||
return array_map([$this, 'sanitizeUtf8'], $v);
|
||||
}
|
||||
if (is_string($v) && !mb_check_encoding($v, 'UTF-8')) {
|
||||
return mb_convert_encoding($v, 'UTF-8', 'UTF-8');
|
||||
}
|
||||
return $v;
|
||||
}
|
||||
private function sort(array &$rows): void
|
||||
{
|
||||
$col = $this->orderBy;
|
||||
usort($rows, function ($a, $b) use ($col) {
|
||||
$va = $a[$col] ?? 0; $vb = $b[$col] ?? 0;
|
||||
return $va <=> $vb;
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 通用 CRUD ---------- */
|
||||
public function all(): array
|
||||
{
|
||||
if (Db::driver() === 'mysql') {
|
||||
return Db::query("SELECT * FROM `{$this->table}` ORDER BY `{$this->orderBy}` ASC")->fetchAll();
|
||||
}
|
||||
$rows = $this->read(); $this->sort($rows); return $rows;
|
||||
}
|
||||
|
||||
public function find($id)
|
||||
{
|
||||
if (Db::driver() === 'mysql') {
|
||||
return Db::query("SELECT * FROM `{$this->table}` WHERE `{$this->pk}`=?", [$id])->fetch() ?: null;
|
||||
}
|
||||
foreach ($this->read() as $r) if (($r[$this->pk] ?? null) == $id) return $r;
|
||||
return null;
|
||||
}
|
||||
|
||||
public function where(string $col, $val)
|
||||
{
|
||||
if (Db::driver() === 'mysql') {
|
||||
return Db::query("SELECT * FROM `{$this->table}` WHERE `{$col}`=?", [$val])->fetch() ?: null;
|
||||
}
|
||||
foreach ($this->read() as $r) if (($r[$col] ?? null) == $val) return $r;
|
||||
return null;
|
||||
}
|
||||
|
||||
public function whereAll(string $col, $val): array
|
||||
{
|
||||
if (Db::driver() === 'mysql') {
|
||||
return Db::query("SELECT * FROM `{$this->table}` WHERE `{$col}`=? ORDER BY `{$this->orderBy}` ASC", [$val])->fetchAll();
|
||||
}
|
||||
$out = []; foreach ($this->read() as $r) if (($r[$col] ?? null) == $val) $out[] = $r;
|
||||
$this->sort($out); return $out;
|
||||
}
|
||||
|
||||
public function insert(array $data)
|
||||
{
|
||||
if (Db::driver() === 'mysql') {
|
||||
$cols = array_keys($data);
|
||||
$sql = "INSERT INTO `{$this->table}` (`" . implode('`,`', $cols) . "`) VALUES (" . implode(',', array_fill(0, count($cols), '?')) . ")";
|
||||
Db::query($sql, array_values($data));
|
||||
return Db::pdo()->lastInsertId();
|
||||
}
|
||||
$rows = $this->read();
|
||||
$id = $rows ? (max(array_column($rows, $this->pk)) + 1) : 1;
|
||||
$data[$this->pk] = $id;
|
||||
$rows[] = $data; $this->write($rows);
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function update($id, array $data): void
|
||||
{
|
||||
if (Db::driver() === 'mysql') {
|
||||
$cols = array_keys($data);
|
||||
$sql = "UPDATE `{$this->table}` SET `" . implode('`=?,`', $cols) . "`=? WHERE `{$this->pk}`=?";
|
||||
Db::query($sql, array_merge(array_values($data), [$id]));
|
||||
return;
|
||||
}
|
||||
$rows = $this->read();
|
||||
foreach ($rows as &$r) {
|
||||
if (($r[$this->pk] ?? null) == $id) { $r = array_merge($r, $data); break; }
|
||||
}
|
||||
$this->write($rows);
|
||||
}
|
||||
|
||||
public function delete($id): void
|
||||
{
|
||||
if (Db::driver() === 'mysql') {
|
||||
Db::query("DELETE FROM `{$this->table}` WHERE `{$this->pk}`=?", [$id]);
|
||||
return;
|
||||
}
|
||||
$rows = array_filter($this->read(), fn($r) => ($r[$this->pk] ?? null) != $id);
|
||||
$this->write(array_values($rows));
|
||||
}
|
||||
|
||||
/** 按任意列批量删除(用于主从表级联删除从表) */
|
||||
public function deleteRaw(string $col, $val): void
|
||||
{
|
||||
if (Db::driver() === 'mysql') {
|
||||
Db::query("DELETE FROM `{$this->table}` WHERE `{$col}`=?", [$val]);
|
||||
return;
|
||||
}
|
||||
$rows = array_filter($this->read(), fn($r) => ($r[$col] ?? null) != $val);
|
||||
$this->write(array_values($rows));
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->all());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
namespace Core;
|
||||
|
||||
use App\Models\PSI\Event;
|
||||
use App\Models\Setting;
|
||||
|
||||
/**
|
||||
* PSI / 站点事件通知服务
|
||||
* --------------------------------------------------
|
||||
* 当 PSI 内出现「新订单 / 新事件」时统一调用本服务:
|
||||
* 1) 记录一条“紧急事件”到 psi_events(站内提醒中心,所有相关人员可见)
|
||||
* 2) (可选)邮件通知负责人(SMTP / mail())
|
||||
* 3) (可选)企业微信群机器人(webhook)通知负责人并 @ 提醒
|
||||
*
|
||||
* 邮件/微信是否发送取决于“通知设置”(Settings):
|
||||
* notify_enabled 总开关
|
||||
* notify_email_enabled 邮件开关
|
||||
* notify_email_smtp_host 发信服务器(留空则用 mail())
|
||||
* notify_email_smtp_port 端口(465=SSL / 587=STARTTLS)
|
||||
* notify_email_smtp_user 账号
|
||||
* notify_email_smtp_pass 密码
|
||||
* notify_email_from 发件人
|
||||
* notify_email_to 负责人邮箱(逗号分隔)
|
||||
* notify_wechat_enabled 企业微信开关
|
||||
* notify_wechat_webhook 企业微信机器人 webhook 地址
|
||||
* notify_wechat_mention 被@的手机号/英文id(逗号分隔)
|
||||
* notify_lowstock_enabled 低库存提醒开关
|
||||
* notify_lowstock_threshold 低库存阈值
|
||||
*/
|
||||
class Notify
|
||||
{
|
||||
/** 触发一个事件(默认紧急)。自动记录站内提醒并推送渠道。 */
|
||||
public static function fire(string $type, string $title, string $body, array $opts = []): void
|
||||
{
|
||||
try {
|
||||
self::ensureTable();
|
||||
|
||||
$level = $opts['level'] ?? 'urgent';
|
||||
$url = $opts['url'] ?? '';
|
||||
$refNo = $opts['ref_no'] ?? '';
|
||||
$sys = $opts['sys'] ?? 'psi';
|
||||
|
||||
$recipients = self::recipients();
|
||||
$channels = ['inapp'];
|
||||
|
||||
// ① 邮件
|
||||
if (self::emailEnabled() && $recipients['email']) {
|
||||
if (self::sendEmail($recipients['email'], $title, $body, $url, $level)) {
|
||||
$channels[] = 'email';
|
||||
}
|
||||
}
|
||||
// ② 企业微信
|
||||
if (self::wechatEnabled()) {
|
||||
if (self::sendWeChat($title, $body, $url, $recipients['wechat'])) {
|
||||
$channels[] = 'wechat';
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 站内紧急事件(始终记录,即使渠道未配置)
|
||||
(new Event())->insert([
|
||||
'sys' => $sys,
|
||||
'type' => $type,
|
||||
'level' => $level,
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'url' => $url,
|
||||
'ref_no' => $refNo,
|
||||
'recipients' => json_encode($recipients, JSON_UNESCAPED_UNICODE),
|
||||
'channels' => json_encode(array_values(array_unique($channels)), JSON_UNESCAPED_UNICODE),
|
||||
'read_by' => json_encode([], JSON_UNESCAPED_UNICODE),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Notify] fire failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== 业务便捷方法 ============== */
|
||||
|
||||
/** 新销售订单 */
|
||||
public static function newSalesOrder(string $orderNo, string $customer, string $salesman, int $id): void
|
||||
{
|
||||
$title = "【紧急】新销售订单待跟进:{$orderNo}";
|
||||
$body = "客户:{$customer}\n负责人:{$salesman}\n订单号:{$orderNo}\n请尽快处理并安排发货。";
|
||||
self::fire('sales_order', $title, $body, [
|
||||
'level' => 'urgent', 'ref_no' => $orderNo, 'url' => "PSI/sales_orders/show/{$id}",
|
||||
]);
|
||||
}
|
||||
|
||||
/** 新采购订单 */
|
||||
public static function newPurchaseOrder(string $orderNo, string $supplier, string $buyer, int $id): void
|
||||
{
|
||||
$title = "【紧急】新采购订单待处理:{$orderNo}";
|
||||
$body = "供应商:{$supplier}\n采购人:{$buyer}\n订单号:{$orderNo}\n请尽快审核并安排收货。";
|
||||
self::fire('purchase_order', $title, $body, [
|
||||
'level' => 'urgent', 'ref_no' => $orderNo, 'url' => "PSI/purchase_orders/show/{$id}",
|
||||
]);
|
||||
}
|
||||
|
||||
/** 新客户订单(来自前台网站下单) */
|
||||
public static function newCustomerOrder(string $orderNo, string $customer, string $phone, int $id): void
|
||||
{
|
||||
$title = "【紧急】收到新客户订单:{$orderNo}";
|
||||
$body = "客户:{$customer}\n电话:{$phone}\n订单号:{$orderNo}\n请尽快联系客户并安排发货。";
|
||||
self::fire('customer_order', $title, $body, [
|
||||
'level' => 'urgent', 'ref_no' => $orderNo, 'url' => "PSI/orders/show/{$id}",
|
||||
]);
|
||||
}
|
||||
|
||||
/** 低库存预警(库存跌破阈值时触发) */
|
||||
public static function lowStockEvent(string $itemType, string $name, float $stock, float $threshold, int $itemId): void
|
||||
{
|
||||
$kind = $itemType === 'product' ? '成品' : '物料';
|
||||
$title = "【紧急】{$kind}库存不足:{$name}";
|
||||
$body = "{$kind}:{$name}\n当前库存:{$stock}\n预警阈值:{$threshold}\n请及时补货。";
|
||||
self::fire('low_stock', $title, $body, [
|
||||
'level' => 'urgent', 'ref_no' => $name, 'url' => "PSI/stock",
|
||||
]);
|
||||
}
|
||||
|
||||
/* ============== 收件人与开关 ============== */
|
||||
|
||||
private static function recipients(): array
|
||||
{
|
||||
$s = new Setting();
|
||||
$emailTo = trim((string) $s->get('notify_email_to', ''), " \t\n\r,");
|
||||
$wechat = trim((string) $s->get('notify_wechat_mention', ''), " \t\n\r,");
|
||||
return [
|
||||
'email' => $emailTo === '' ? [] : array_filter(array_map('trim', explode(',', $emailTo))),
|
||||
'wechat' => $wechat === '' ? [] : array_filter(array_map('trim', explode(',', $wechat))),
|
||||
];
|
||||
}
|
||||
|
||||
private static function masterEnabled(): bool
|
||||
{
|
||||
return (int) (new Setting())->get('notify_enabled', 0) === 1;
|
||||
}
|
||||
|
||||
private static function emailEnabled(): bool
|
||||
{
|
||||
if (!self::masterEnabled()) return false;
|
||||
return (int) (new Setting())->get('notify_email_enabled', 0) === 1;
|
||||
}
|
||||
|
||||
private static function wechatEnabled(): bool
|
||||
{
|
||||
if (!self::masterEnabled()) return false;
|
||||
return (int) (new Setting())->get('notify_wechat_enabled', 0) === 1
|
||||
&& trim((string) (new Setting())->get('notify_wechat_webhook', '')) !== '';
|
||||
}
|
||||
|
||||
public static function lowStockEnabled(): bool
|
||||
{
|
||||
return (int) (new Setting())->get('notify_lowstock_enabled', 1) === 1;
|
||||
}
|
||||
|
||||
public static function lowStockThreshold(): float
|
||||
{
|
||||
return (float) (new Setting())->get('notify_lowstock_threshold', 20);
|
||||
}
|
||||
|
||||
/* ============== 邮件发送 ============== */
|
||||
|
||||
private static function sendEmail(array $to, string $subject, string $body, string $url, string $level): bool
|
||||
{
|
||||
$s = new Setting();
|
||||
$host = trim((string) $s->get('notify_email_smtp_host', ''));
|
||||
$port = (int) $s->get('notify_email_smtp_port', 465);
|
||||
$user = trim((string) $s->get('notify_email_smtp_user', ''));
|
||||
$pass = trim((string) $s->get('notify_email_smtp_pass', ''));
|
||||
$from = trim((string) $s->get('notify_email_from', ''));
|
||||
if ($from === '') $from = $user;
|
||||
$html = self::emailHtml($subject, $body, $url, $level);
|
||||
|
||||
if ($host !== '' && $user !== '') {
|
||||
$scheme = ($port === 465) ? 'ssl' : 'tls';
|
||||
return self::smtpSend($host, $port, $scheme, $user, $pass, $from, $to, $subject, $html);
|
||||
}
|
||||
|
||||
// 回退:PHP 内置 mail()
|
||||
$headers = "MIME-Version: 1.0\r\n";
|
||||
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||
$headers .= "From: {$from}\r\n";
|
||||
$ok = true;
|
||||
foreach ($to as $t) {
|
||||
if (!@mail($t, '=?UTF-8?B?' . base64_encode($subject) . '?=', $html, $headers)) $ok = false;
|
||||
}
|
||||
return $ok;
|
||||
}
|
||||
|
||||
private static function emailHtml(string $subject, string $body, string $url, string $level): string
|
||||
{
|
||||
$lines = nl2br(htmlspecialchars($body, ENT_QUOTES, 'UTF-8'));
|
||||
$link = $url ? App::url($url) : '';
|
||||
$urgent = $level === 'urgent' ? '<span style="color:#dc2626;font-weight:700;">紧急事件</span>' : '通知';
|
||||
return <<<HTML
|
||||
<!doctype html><html lang="zh-CN"><body style="margin:0;background:#f3f4f6;font-family:-apple-system,'Segoe UI',Roboto,'PingFang SC','Microsoft YaHei',sans-serif;">
|
||||
<div style="max-width:560px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 8px 30px rgba(15,42,68,.12);">
|
||||
<div style="background:linear-gradient(135deg,#0ea5e9,#14b8a6);padding:18px 22px;color:#fff;font-size:16px;font-weight:700;">酷冰甲 · PSI 进销存 {$urgent}</div>
|
||||
<div style="padding:22px;color:#0f2a44;font-size:15px;line-height:1.8;">
|
||||
<div style="font-size:16px;font-weight:700;margin-bottom:10px;">{$subject}</div>
|
||||
<div style="color:#334155;">{$lines}</div>
|
||||
{$link}
|
||||
</div>
|
||||
<div style="padding:0 22px 20px;">
|
||||
<a href="{$link}" style="display:inline-block;padding:10px 18px;border-radius:10px;background:linear-gradient(135deg,#0ea5e9,#14b8a6);color:#fff;text-decoration:none;font-weight:600;">查看详情</a>
|
||||
</div>
|
||||
<div style="padding:14px 22px;background:#f8fafc;color:#94a3b8;font-size:12px;border-top:1px solid #eef2f7;">本邮件由系统自动发出,请勿直接回复。</div>
|
||||
</div></body></html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private static function smtpSend(string $host, int $port, string $scheme, string $user, string $pass, string $from, array $to, string $subject, string $html): bool
|
||||
{
|
||||
$timeout = 15;
|
||||
$ctx = $scheme === 'ssl'
|
||||
? stream_context_create(['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]])
|
||||
: null;
|
||||
$prefix = $scheme === 'ssl' ? 'ssl://' : '';
|
||||
$fp = @stream_socket_client($prefix . $host . ':' . $port, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx);
|
||||
if (!$fp) return false;
|
||||
|
||||
$talk = function ($cmd = null) use ($fp) {
|
||||
if ($cmd !== null) fwrite($fp, $cmd . "\r\n");
|
||||
$res = '';
|
||||
while (($line = fgets($fp, 600)) !== false) {
|
||||
$res .= $line;
|
||||
if (isset($line[3]) && $line[3] === ' ') break; // 单行响应(响应码后的第4个字符是空格)
|
||||
if ($line === '') break;
|
||||
}
|
||||
return $res;
|
||||
};
|
||||
|
||||
$talk(null); // 欢迎语
|
||||
$talk('EHLO ' . (gethostname() ?: 'localhost'));
|
||||
if ($scheme === 'tls' || $port === 587 || $port === 25) {
|
||||
$r = $talk('STARTTLS');
|
||||
if (strpos($r, '220') === 0) {
|
||||
if (!@stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { fclose($fp); return false; }
|
||||
$talk('EHLO ' . (gethostname() ?: 'localhost'));
|
||||
}
|
||||
}
|
||||
if ($user !== '') {
|
||||
$talk('AUTH LOGIN');
|
||||
$talk(base64_encode($user));
|
||||
$talk(base64_encode($pass));
|
||||
}
|
||||
$talk('MAIL FROM:<' . $from . '>');
|
||||
foreach ($to as $t) $talk('RCPT TO:<' . $t . '>');
|
||||
$talk('DATA');
|
||||
$headers = "From: {$from}\r\n";
|
||||
$headers .= "To: " . implode(', ', $to) . "\r\n";
|
||||
$headers .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n";
|
||||
$headers .= "MIME-Version: 1.0\r\n";
|
||||
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||
$talk($headers . "\r\n" . $html . "\r\n.");
|
||||
$talk('QUIT');
|
||||
fclose($fp);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ============== 企业微信(群机器人 webhook) ============== */
|
||||
|
||||
private static function sendWeChat(string $title, string $body, string $url, array $mention): bool
|
||||
{
|
||||
$webhook = trim((string) (new Setting())->get('notify_wechat_webhook', ''));
|
||||
if ($webhook === '') return false;
|
||||
$content = "**{$title}**\n> " . str_replace("\n", "\n> ", $body);
|
||||
if ($url) $content .= "\n\n[查看详情](" . App::url($url) . ")";
|
||||
$payload = ['msgtype' => 'markdown', 'markdown' => ['content' => $content]];
|
||||
if ($mention) $payload['markdown']['mentioned_mobile_list'] = array_values($mention);
|
||||
return self::httpPostJson($webhook, $payload);
|
||||
}
|
||||
|
||||
private static function httpPostJson(string $url, array $payload): bool
|
||||
{
|
||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json; charset=utf-8'],
|
||||
CURLOPT_POSTFIELDS => $json,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => 0,
|
||||
]);
|
||||
$res = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
return $res !== false;
|
||||
}
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json; charset=utf-8\r\n",
|
||||
'content' => $json,
|
||||
'timeout' => 10,
|
||||
],
|
||||
]);
|
||||
$res = @file_get_contents($url, false, $ctx);
|
||||
return $res !== false;
|
||||
}
|
||||
|
||||
/* ============== 事件表(按需创建,兼容 MySQL / json 两种存储) ============== */
|
||||
|
||||
private static function ensureTable(): void
|
||||
{
|
||||
if (Db::driver() !== 'mysql') return; // json 模式由 Model 自动建文件
|
||||
$sql = "CREATE TABLE IF NOT EXISTS `psi_events` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
`sys` VARCHAR(20) NOT NULL DEFAULT 'psi',
|
||||
`type` VARCHAR(40) NOT NULL DEFAULT '',
|
||||
`level` VARCHAR(20) NOT NULL DEFAULT 'urgent',
|
||||
`title` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`body` TEXT,
|
||||
`url` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`ref_no` VARCHAR(64) NOT NULL DEFAULT '',
|
||||
`recipients` TEXT,
|
||||
`channels` VARCHAR(255) NOT NULL DEFAULT '[\"inapp\"]',
|
||||
`read_by` TEXT,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
|
||||
Db::query($sql);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
namespace Core\Payment;
|
||||
|
||||
/**
|
||||
* 支付宝网关
|
||||
* - demo:返回模拟支付入口
|
||||
* - live:电脑网站支付(alipay.trade.page.pay),RSA2 签名
|
||||
*/
|
||||
class AlipayGateway extends Gateway
|
||||
{
|
||||
public function pay(array $order): array
|
||||
{
|
||||
$simulate = site_url('order/demo/' . $order['order_no']);
|
||||
$hasCred = !empty($this->config['appid']) && !empty($this->config['private_key']);
|
||||
|
||||
if ($this->isDemo() || !$hasCred) {
|
||||
return [
|
||||
'mode' => 'demo',
|
||||
'channel' => 'alipay',
|
||||
'simulate' => $simulate,
|
||||
'config_missing' => !$hasCred,
|
||||
];
|
||||
}
|
||||
|
||||
$biz = [
|
||||
'out_trade_no' => $order['order_no'],
|
||||
'product_code' => 'FAST_INSTANT_TRADE_PAY',
|
||||
'total_amount' => $this->money($order['amount']),
|
||||
'subject' => $order['product_name'] ?: '商品购买',
|
||||
];
|
||||
$params = [
|
||||
'app_id' => $this->config['appid'],
|
||||
'method' => 'alipay.trade.page.pay',
|
||||
'format' => 'JSON',
|
||||
'charset' => 'utf-8',
|
||||
'sign_type' => 'RSA2',
|
||||
'timestamp' => date('Y-m-d H:i:s'),
|
||||
'version' => '1.0',
|
||||
'notify_url' => site_url('pay/notify/alipay'),
|
||||
'return_url' => site_url('order/success/' . $order['order_no']),
|
||||
'biz_content' => json_encode($biz, JSON_UNESCAPED_UNICODE),
|
||||
];
|
||||
$params['sign'] = $this->sign($params);
|
||||
$gateway = $this->config['gateway'] ?: 'https://openapi.alipay.com/gateway.do';
|
||||
return ['mode' => 'redirect', 'channel' => 'alipay', 'url' => $gateway . '?' . http_build_query($params)];
|
||||
}
|
||||
|
||||
/** RSA2 签名 */
|
||||
private function sign(array $params): string
|
||||
{
|
||||
ksort($params);
|
||||
$str = '';
|
||||
foreach ($params as $k => $v) {
|
||||
if ($v === '' || $v === null) continue;
|
||||
$str .= $k . '=' . $v . '&';
|
||||
}
|
||||
$str = rtrim($str, '&');
|
||||
$key = $this->normalizeKey($this->config['private_key'] ?? '', false);
|
||||
openssl_sign($str, $sign, $key, OPENSSL_ALGO_SHA256);
|
||||
return base64_encode($sign);
|
||||
}
|
||||
|
||||
private function normalizeKey(string $key, bool $isPublic): string
|
||||
{
|
||||
$key = trim($key);
|
||||
if (strpos($key, '-----BEGIN') === 0) return $key;
|
||||
$head = $isPublic ? "-----BEGIN PUBLIC KEY-----\n" : "-----BEGIN RSA PRIVATE KEY-----\n";
|
||||
$foot = $isPublic ? "\n-----END PUBLIC KEY-----" : "\n-----END RSA PRIVATE KEY-----";
|
||||
return $head . chunk_split($key, 64, "\n") . $foot;
|
||||
}
|
||||
|
||||
public function verifyNotify(array $data): ?string
|
||||
{
|
||||
if (empty($data['out_trade_no'])) return null;
|
||||
$status = $data['trade_status'] ?? '';
|
||||
if ($status === 'TRADE_SUCCESS' || $status === 'TRADE_FINISHED') {
|
||||
// 生产环境应使用支付宝公钥对签名做严格验签后再返回
|
||||
return $data['out_trade_no'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
namespace Core\Payment;
|
||||
|
||||
/**
|
||||
* 支付网关抽象基类
|
||||
* 子类:AlipayGateway / WechatGateway
|
||||
*
|
||||
* 两种运行模式:
|
||||
* - demo(默认):不接真实商户号,走「模拟支付」流程,整条下单→支付→查询链路可演示。
|
||||
* - live:填入商户号与密钥后,构造真实支付请求(支付宝电脑网站支付 / 微信 NATIVE 扫码)。
|
||||
*/
|
||||
abstract class Gateway
|
||||
{
|
||||
protected $config = [];
|
||||
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/** 发起支付,返回渲染数据
|
||||
* ['mode'=>'demo','channel'=>?,'simulate'=>url,'config_missing'=>bool]
|
||||
* ['mode'=>'redirect','channel'=>'alipay','url'=>?]
|
||||
* ['mode'=>'qrcode','channel'=>'wechat','qr'=>?]
|
||||
*/
|
||||
abstract public function pay(array $order): array;
|
||||
|
||||
/** 验证异步通知,成功返回订单号,否则返回 null */
|
||||
abstract public function verifyNotify(array $data): ?string;
|
||||
|
||||
protected function isDemo(): bool
|
||||
{
|
||||
return ($this->config['mode'] ?? 'demo') === 'demo';
|
||||
}
|
||||
|
||||
protected function money($v): string
|
||||
{
|
||||
return number_format((float) $v, 2, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
namespace Core\Payment;
|
||||
|
||||
use App\Models\Setting;
|
||||
|
||||
/**
|
||||
* 支付网关工厂:根据后台「支付设置」构建对应通道
|
||||
*/
|
||||
class GatewayFactory
|
||||
{
|
||||
public static function make(string $channel): Gateway
|
||||
{
|
||||
$s = new Setting();
|
||||
$mode = $s->get('pay_mode', 'demo');
|
||||
$enabled = $s->get('pay_enabled', '1');
|
||||
|
||||
if ($channel === 'alipay') {
|
||||
return new AlipayGateway([
|
||||
'mode' => $mode,
|
||||
'enabled' => $enabled,
|
||||
'appid' => $s->get('pay_alipay_appid', ''),
|
||||
'private_key' => $s->get('pay_alipay_private_key', ''),
|
||||
'public_key' => $s->get('pay_alipay_public_key', ''),
|
||||
'gateway' => $s->get('pay_alipay_gateway', 'https://openapi.alipay.com/gateway.do'),
|
||||
]);
|
||||
}
|
||||
return new WechatGateway([
|
||||
'mode' => $mode,
|
||||
'enabled' => $enabled,
|
||||
'mchid' => $s->get('pay_wechat_mchid', ''),
|
||||
'appid' => $s->get('pay_wechat_appid', ''),
|
||||
'key' => $s->get('pay_wechat_key', ''),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace Core\Payment;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\Payment;
|
||||
|
||||
/** 订单支付成功后的统一入账逻辑(模拟/网关回调/后台手动共用) */
|
||||
class OrderService
|
||||
{
|
||||
public static function markPaid(string $orderNo, string $tradeNo, string $channel): bool
|
||||
{
|
||||
$order = new Order();
|
||||
$o = $order->where('order_no', $orderNo);
|
||||
if (!$o || $o['status'] === 'paid') return false;
|
||||
$order->update($o['id'], [
|
||||
'status' => 'paid',
|
||||
'paid_at' => date('Y-m-d H:i:s'),
|
||||
'gateway_trade_no' => $tradeNo,
|
||||
]);
|
||||
(new Payment())->insert([
|
||||
'order_id' => $o['id'],
|
||||
'order_no' => $orderNo,
|
||||
'channel' => $channel,
|
||||
'amount' => $o['amount'],
|
||||
'trade_no' => $tradeNo,
|
||||
'status' => 'paid',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'paid_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
namespace Core\Payment;
|
||||
|
||||
/**
|
||||
* 微信支付网关(NATIVE 扫码支付)
|
||||
* - demo:返回模拟支付入口
|
||||
* - live:调用统一下单接口获取 code_url,前端展示二维码
|
||||
*/
|
||||
class WechatGateway extends Gateway
|
||||
{
|
||||
public function pay(array $order): array
|
||||
{
|
||||
$simulate = site_url('order/demo/' . $order['order_no']);
|
||||
$hasCred = !empty($this->config['mchid']) && !empty($this->config['appid']) && !empty($this->config['key']);
|
||||
|
||||
if ($this->isDemo() || !$hasCred) {
|
||||
return [
|
||||
'mode' => 'demo',
|
||||
'channel' => 'wechat',
|
||||
'simulate' => $simulate,
|
||||
'config_missing' => !$hasCred,
|
||||
];
|
||||
}
|
||||
|
||||
$params = [
|
||||
'appid' => $this->config['appid'],
|
||||
'mch_id' => $this->config['mchid'],
|
||||
'nonce_str' => bin2hex(random_bytes(16)),
|
||||
'body' => $order['product_name'] ?: '商品购买',
|
||||
'out_trade_no' => $order['order_no'],
|
||||
'total_fee' => (int) round((float) $order['amount'] * 100), // 分
|
||||
'spbill_create_ip' => $_SERVER['SERVER_ADDR'] ?? '127.0.0.1',
|
||||
'notify_url' => site_url('pay/notify/wechat'),
|
||||
'trade_type' => 'NATIVE',
|
||||
];
|
||||
$params['sign'] = $this->sign($params);
|
||||
$xml = $this->toXml($params);
|
||||
|
||||
$resp = @file_get_contents('https://api.mch.weixin.qq.com/pay/unifiedorder', false, stream_context_create([
|
||||
'http' => ['method' => 'POST', 'header' => 'Content-Type: text/xml', 'content' => $xml, 'timeout' => 8],
|
||||
]));
|
||||
$res = $resp ? $this->fromXml($resp) : [];
|
||||
|
||||
if (!empty($res['code_url'])) {
|
||||
return ['mode' => 'qrcode', 'channel' => 'wechat', 'qr' => $res['code_url']];
|
||||
}
|
||||
// 调用失败则回退演示,避免卡死
|
||||
return ['mode' => 'demo', 'channel' => 'wechat', 'simulate' => $simulate, 'config_missing' => false, 'api_error' => true];
|
||||
}
|
||||
|
||||
/** HMAC-SHA256 签名 */
|
||||
private function sign(array $params): string
|
||||
{
|
||||
ksort($params);
|
||||
$str = '';
|
||||
foreach ($params as $k => $v) {
|
||||
if ($v === '' || $v === null) continue;
|
||||
$str .= $k . '=' . $v . '&';
|
||||
}
|
||||
$str .= 'key=' . ($this->config['key'] ?? '');
|
||||
return strtoupper(hash_hmac('sha256', $str, $this->config['key'] ?? ''));
|
||||
}
|
||||
|
||||
private function toXml(array $params): string
|
||||
{
|
||||
$xml = '<xml>';
|
||||
foreach ($params as $k => $v) {
|
||||
$xml .= "<{$k}>" . htmlspecialchars($v, ENT_XML1) . "</{$k}>";
|
||||
}
|
||||
$xml .= '</xml>';
|
||||
return $xml;
|
||||
}
|
||||
|
||||
private function fromXml(string $xml): array
|
||||
{
|
||||
$r = @simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);
|
||||
return $r ? json_decode(json_encode($r), true) : [];
|
||||
}
|
||||
|
||||
public function verifyNotify(array $data): ?string
|
||||
{
|
||||
if (empty($data['out_trade_no'])) return null;
|
||||
if (($data['result_code'] ?? '') === 'SUCCESS' && ($data['return_code'] ?? '') === 'SUCCESS') {
|
||||
// 生产环境应重新按 key 验签后返回
|
||||
return $data['out_trade_no'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
namespace Core;
|
||||
|
||||
/**
|
||||
* 主题引擎:后台可设置任意网页风格(颜色 / 字体 / 圆角 / 容器 / 导航 / 明暗 / 自定义CSS)
|
||||
* 渲染前台时通过 CSS 变量注入;保存后重新生成 public/assets/css/theme.css
|
||||
*/
|
||||
class Theme
|
||||
{
|
||||
private static $cache = null;
|
||||
private static $cssPath = BASE_PATH . '/public/assets/css/theme.css';
|
||||
|
||||
public static function defaults(): array
|
||||
{
|
||||
return [
|
||||
// 站点信息
|
||||
'site_name' => '酷冰甲 · 降温服',
|
||||
'site_slogan' => '科技降温 · 清凉一夏',
|
||||
'site_logo' => 'assets/img/logo.png',
|
||||
'contact_phone' => '400-1783-998',
|
||||
'contact_email' => 'service@st-joyapparel.com',
|
||||
'contact_address'=> '江苏省苏州市工业园区',
|
||||
'icp' => '',
|
||||
'gongan' => '', // 公安备案号(网安备),如 京公网安备11010802012345号
|
||||
'seo_title' => '酷冰甲降温服 - 科技降温服装定制',
|
||||
'seo_keywords' => '降温服, cooling clothing, 降温工作服, 清凉服定制',
|
||||
'seo_description'=> '酷冰甲专注降温服研发与定制,采用相变蓄冷与循环水冷技术,为高温作业人群提供清凉解决方案。',
|
||||
// 主题风格
|
||||
'preset' => 'ocean',
|
||||
'primary' => '#0ea5e9',
|
||||
'primary_600' => '#0284c7',
|
||||
'secondary' => '#14b8a6',
|
||||
'accent' => '#f59e0b',
|
||||
'bg' => '#ffffff',
|
||||
'surface' => '#f8fafc',
|
||||
'text' => '#0f172a',
|
||||
'muted' => '#64748b',
|
||||
'border' => '#e2e8f0',
|
||||
'nav_bg' => 'rgba(255,255,255,0.72)',
|
||||
'font' => "'Noto Sans SC', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
|
||||
'radius' => '16',
|
||||
'container' => '1200',
|
||||
'header' => 'center', // center | left | transparent
|
||||
'default_mode' => 'light', // light | dark
|
||||
'custom_css' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/** 合并默认值与数据库设置 */
|
||||
public static function all(): array
|
||||
{
|
||||
if (self::$cache !== null) return self::$cache;
|
||||
$def = self::defaults();
|
||||
$setting = new \App\Models\Setting();
|
||||
$saved = $setting->allKV();
|
||||
self::$cache = array_merge($def, $saved);
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
public static function get(string $key, $default = '')
|
||||
{
|
||||
$all = self::all();
|
||||
return $all[$key] ?? $default;
|
||||
}
|
||||
|
||||
public static function clearCache(): void
|
||||
{
|
||||
self::$cache = null;
|
||||
}
|
||||
|
||||
/** 预设主题(后台一键套用) */
|
||||
public static function presets(): array
|
||||
{
|
||||
return [
|
||||
'ocean' => ['label' => '海洋蓝', 'vars' => ['primary'=>'#0ea5e9','primary_600'=>'#0284c7','secondary'=>'#14b8a6','accent'=>'#f59e0b','bg'=>'#ffffff','surface'=>'#f8fafc','text'=>'#0f172a','muted'=>'#64748b','border'=>'#e2e8f0','nav_bg'=>'rgba(255,255,255,0.72)']],
|
||||
'forest' => ['label' => '森野绿', 'vars' => ['primary'=>'#16a34a','primary_600'=>'#15803d','secondary'=>'#0d9488','accent'=>'#f97316','bg'=>'#ffffff','surface'=>'#f6fdf8','text'=>'#0f172a','muted'=>'#5b7065','border'=>'#dcefe2','nav_bg'=>'rgba(255,255,255,0.72)']],
|
||||
'aurora' => ['label' => '极光紫', 'vars' => ['primary'=>'#8b5cf6','primary_600'=>'#7c3aed','secondary'=>'#06b6d4','accent'=>'#ec4899','bg'=>'#ffffff','surface'=>'#faf7ff','text'=>'#1e1b2e','muted'=>'#6b6480','border'=>'#ece6f7','nav_bg'=>'rgba(255,255,255,0.72)']],
|
||||
'sunset' => ['label' => '日落橙', 'vars' => ['primary'=>'#f97316','primary_600'=>'#ea580c','secondary'=>'#ef4444','accent'=>'#facc15','bg'=>'#ffffff','surface'=>'#fffaf3','text'=>'#1c1917','muted'=>'#78716c','border'=>'#faead7','nav_bg'=>'rgba(255,255,255,0.72)']],
|
||||
'mono' => ['label' => '极简黑金', 'vars' => ['primary'=>'#111827','primary_600'=>'#000000','secondary'=>'#ca8a04','accent'=>'#ca8a04','bg'=>'#ffffff','surface'=>'#fafafa','text'=>'#111827','muted'=>'#6b7280','border'=>'#e5e7eb','nav_bg'=>'rgba(255,255,255,0.75)']],
|
||||
'ice' => ['label' => '冰晶青', 'vars' => ['primary'=>'#06b6d4','primary_600'=>'#0891b2','secondary'=>'#3b82f6','accent'=>'#22d3ee','bg'=>'#ffffff','surface'=>'#f0fbfd','text'=>'#0c1a24','muted'=>'#5b7686','border'=>'#d3eef5','nav_bg'=>'rgba(255,255,255,0.72)']],
|
||||
];
|
||||
}
|
||||
|
||||
/** 生成 theme.css 文本 */
|
||||
public static function buildCss(): string
|
||||
{
|
||||
$t = self::all();
|
||||
$v = function ($k) use ($t) { return $t[$k] ?? ''; };
|
||||
$css = ":root{\n";
|
||||
$css .= " --c-primary:{$v('primary')};\n";
|
||||
$css .= " --c-primary-600:{$v('primary_600')};\n";
|
||||
$css .= " --c-secondary:{$v('secondary')};\n";
|
||||
$css .= " --c-accent:{$v('accent')};\n";
|
||||
$css .= " --c-bg:{$v('bg')};\n";
|
||||
$css .= " --c-surface:{$v('surface')};\n";
|
||||
$css .= " --c-text:{$v('text')};\n";
|
||||
$css .= " --c-muted:{$v('muted')};\n";
|
||||
$css .= " --c-border:{$v('border')};\n";
|
||||
$css .= " --nav-bg:{$v('nav_bg')};\n";
|
||||
$css .= " --font-base:{$v('font')};\n";
|
||||
$css .= " --radius:{$v('radius')}px;\n";
|
||||
$css .= " --container:{$v('container')}px;\n";
|
||||
$css .= "}\n";
|
||||
$css .= "[data-theme=\"dark\"]{\n";
|
||||
$css .= " --c-bg:#0b1120;--c-surface:#111827;--c-text:#e5e7eb;--c-muted:#94a3b8;--c-border:#1f2937;--nav-bg:rgba(11,17,32,0.72);\n";
|
||||
$css .= "}\n";
|
||||
$css .= $v('custom_css') . "\n";
|
||||
return $css;
|
||||
}
|
||||
|
||||
/** 重新生成主题 CSS 文件 */
|
||||
public static function regenerate(): bool
|
||||
{
|
||||
$dir = dirname(self::$cssPath);
|
||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||
return (bool) file_put_contents(self::$cssPath, self::buildCss());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
namespace Core;
|
||||
|
||||
class View
|
||||
{
|
||||
public static function buffer(string $view, array $data = []): string
|
||||
{
|
||||
$file = BASE_PATH . '/app/Views/' . str_replace('.', '/', $view) . '.php';
|
||||
if (!is_file($file)) {
|
||||
throw new \Exception("视图不存在: $view");
|
||||
}
|
||||
ob_start();
|
||||
extract($data, EXTR_SKIP);
|
||||
include $file;
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
public static function make(string $view, array $data = [], ?string $layout = null): string
|
||||
{
|
||||
$content = self::buffer($view, $data);
|
||||
if ($layout) {
|
||||
return self::buffer($layout, array_merge($data, ['content' => $content]));
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user