文件还在测试中

This commit is contained in:
2026-08-08 15:53:53 +08:00
parent 5f1e7adb64
commit 8101177cb5
241 changed files with 18074 additions and 22 deletions
+9 -20
View File
@@ -1,20 +1,9 @@
# ---> Actionscript
# Build and Release Folders
bin-debug/
bin-release/
[Oo]bj/
[Bb]in/
# Other files and folders
.settings/
# Executables
*.swf
*.air
*.ipa
*.apk
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
# should NOT be excluded as they contain compiler settings and other important
# information for Eclipse / Flash Builder.
.workbuddy/
storage/cache/
storage/data/
storage/logs/
storage/uploads/
*.zip
_dbg_login.html
start_local.sh
CRM_PSI_交付说明.md
+1
View File
@@ -0,0 +1 @@
+3
View File
@@ -0,0 +1,3 @@
open_basedir=/www/wwwroot/coolcoth.com/:/tmp/:/www/php_session/coolcoth.com/
session.save_path=/www/php_session/coolcoth.com/
session.save_handler = files
+7
View File
@@ -0,0 +1,7 @@
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx</center>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
圣巧依官网 · 安全加固补丁部署说明
====================================
本补丁依据安全扫描报告,修复以下全部问题(部署后无需额外配置):
【高危 / High】
1. 缺失 HSTS(复查补充:增加 preload
-> 新增 Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
(preload 已随本补丁追加,可直接提交至浏览器 HSTS 预加载列表)
2. CSP 过于宽松(script-src 含 'unsafe-inline'
-> 改为基于 nonce 的严格 CSPscript-src 'self' 'nonce-xxx'(移除 'unsafe-inline'/'unsafe-eval');
前端入口通过输出缓冲自动为所有内联 <script>/<style> 注入本次请求 nonce
原 27 处内联 onclick/onsubmit 确认框已重构为 data-confirm 属性 + admin.js 委托监听。
3. 管理后台登录无验证码 / 限速不足
-> 登录增加算术验证码(无第三方依赖);并新增 IP 级失败限速(fail2ban 式,文件缓存),
与原有会话级限速叠加,抗机器人暴力破解。
【中危 / Medium】
4. 缺失 Permissions-Policy
-> 新增 Permissions-Policy 收敛 geolocation/microphone/camera/payment/usb 等敏感 API。
5. 联系表单缺垃圾防护
-> 新增蜜罐字段(隐藏,机器人填写即静默丢弃)+ 算术验证码 + 服务端输入校验(姓名/电话格式/长度)
+ 后端 IP 级提交限速(每 15 分钟最多 5 次,复用 storage/rate_contact.json)。
6. 客户端输入校验缺失
-> 登录与联系表单增加 maxlength/pattern/inputmode/autocomplete 等原生校验属性。
7. 密码框缺 autocomplete
-> 登录密码框 autocomplete="current-password",账号框 autocomplete="username",联系表单电话 autocomplete="tel"。
【低危 / Low】
8. 缺失 robots.txt -> public/robots.txt(屏蔽 /admin/、/CRM/、/PSI/)。
9. 缺失规范链接 -> 前端布局注入 <link rel="canonical">。
10. 暴露 Vite 开发产物 -> 经核查项目未使用 Vitepublic/ 下无 .vite / node_modules / @vite 资源),属误报,无需处理。
11. 缺失 X-XSS-Protection -> 新增 X-XSS-Protection: 1; mode=block(旧浏览器纵深防御)。
【额外修复(报告外但同属高危)】
- 根入口 index.php 含 phpinfo() 信息泄露,已移除并转发至正式入口 public/index.php。
二、部署步骤
-----------
1. 将 security-patch.zip 内文件按相同目录结构覆盖上传到站点根目录。
2. 确保站点根目录可写(IP 限速缓存会写入 storage/login_ip.json;若不可写,限速降级为仅会话级,不影响其他功能)。
3. 刷新后台登录页与联系页,确认出现验证码、删除/操作确认弹窗正常、页面无 JS 报错。
4. 建议部署后通过浏览器开发者工具「网络」面板确认响应头包含
Strict-Transport-Security、Content-Security-Policy(含 nonce-)、Permissions-Policy、X-XSS-Protection。
三、回滚
-------
保留部署前文件备份即可回滚。CSP 收紧若导致个别内联脚本异常,优先检查是否仍有遗漏的内联事件处理器或 <script> 未带 nonce(正常页面均会自动注入)。
+41
View File
@@ -0,0 +1,41 @@
数据库管理 / 数据库升级 模块补丁 — 部署说明
==================================================
本压缩包包含「数据库管理」与「数据库升级」两个后台模块的改动,
适用于部署在 st-joyapparel.com 的站点(需运行 MySQL)。
一、包含文件
------------
app/Core/App.php 新增 'db' 路由与 capability 映射
app/Core/Helper.php 导航项 + 升级扫描/人类可读大小等辅助函数
app/Core/Installer.php 新增 applySqlFile() / ensureUpgradeLog()
app/Controllers/Admin/DatabaseController.php 数据库管理(浏览/编辑/新增/删除)
app/Controllers/Admin/UpgradeController.php 数据库升级(重写)
app/Views/admin/system.php 系统设置页(重写)
app/Views/admin/upgrade.php 数据库升级页(重写)
app/Views/admin/db_tables.php 数据库管理 - 表列表
app/Views/admin/db_browse.php 数据库管理 - 浏览/搜索/分页
app/Views/admin/db_form.php 数据库管理 - 编辑/新增表单
app/Views/layouts/admin.php 导航渲染(支持 badge 提示)
public/assets/css/admin.css 新增相关样式
install/upgrades/README.txt 升级包目录说明
install/upgrades/examples/001_example_add_field.sql 示例(不参与自动扫描)
install/upgrades/002_create_psi_order_tables.sql ★ 服装厂成衣标准建表(6 订单/出库表 + psi_purchases 修复)
二、部署步骤
------------
1. 将压缩包内文件按相同目录结构覆盖上传到站点根目录对应位置。
2. 登录后台,进入「数据库升级」:
- 首次进入会自动创建 db_upgrades 审计表。
- 页面会提示有 1 个升级包(002_create_psi_order_tables.sql)可执行。
- 点击执行后自动创建 6 张表 + 修复 psi_purchases 缺失列 + 服装厂字段。
3. PSI 订单功能即可正常使用(销售订单/采购订单/出库单全部可用)。
4. 进入「数据库管理」(仅 MySQL 模式可用):
- 可查看所有数据表、浏览/搜索数据、新增、编辑、删除记录。
三、注意事项
------------
- 升级包 SQL 文件请放在 install/upgrades/ 根目录;examples/ 子目录仅作示例,不会被自动扫描。
- 升级前请务必对数据库做备份。
- 若服务器 MySQL 版本较老(< 5.7),存储过程中 INFORMATION_SCHEMA 查询可能需调整。
- psi_purchases 的 product_id / supplier / brand 列补齐使用 PREPARE 动态检测,可安全重复执行。
+199 -2
View File
@@ -1,3 +1,200 @@
# coolcoth.com
# 酷冰甲 · 降温服企业官网
git clone ssh://git@118.25.40.197:2222/Lucanlee/coolcoth.com.git
> 参考 [sqy58.com](http://sqy58.com) 风格,主打 **降温服 / 冷却服定制**,采用原生 PHP MVC 架构,内置 **前后台**,后台可设置 **任意网页风格**(颜色 / 字体 / 圆角 / 容器 / 导航样式 / 明暗模式 / 自定义 CSS)。
网站
---
## 一、特性
- **原生 PHP MVC**,零依赖、零 ComposerPHP 7.4+ 即可运行。
- **双存储引擎**:文件模式(JSON,开箱即用)与 MySQL 模式(生产环境),通过一处配置切换。
- **完整前后台**
- 前台:首页 / 产品列表 / 产品详情 / 新闻列表 / 新闻详情 / 单页 / 联系我们(表单留资)。
- 后台:仪表盘 / 产品 / 分类 / 新闻 / 横幅 / 单页 / 站点设置 / **主题风格编辑器**
- **后台任意网页风格**:6 套预设主题(海洋蓝 / 森野绿 / 极光紫 / 日落橙 / 极简黑金 / 冰晶青)一键套用,并支持自定义主色、辅助色、字体、圆角、容器宽度、导航样式、默认明暗模式与自定义 CSS,保存即生成 `theme.css`
- **安全与体验**:CSRF 防护、会话登录、明暗模式切换(localStorage 记忆)、玻璃拟态导航、磁吸按钮、滚动渐显动画、响应式布局。
---
## 二、环境要求
| 项目 | 要求 |
| --- | --- |
| PHP | ≥ 7.4(需 `pdo_mysql` 扩展用于 MySQL 模式;文件模式无需数据库) |
| Web 服务器 | 内置 `php -S` 可直接演示;生产建议 Nginx / Apache |
| 数据库(可选) | MySQL 5.7+ / MariaDB(仅 `mysql` 模式需要) |
| 扩展 | `curl``mbstring``json``session`(通常默认开启) |
---
## 三、快速开始(文件模式,开箱即用)
```bash
# 1. 进入项目
cd /path/to/project
# 2. 初始化数据(写入 storage/data/*.json 并生成主题样式)
php install/install.php
# 3. 启动内置演示服务器
php -S 127.0.0.1:8000 -t public
```
浏览器访问:
- 前台首页:<http://127.0.0.1:8000/>
- 后台登录:<http://127.0.0.1:8000/admin>
**默认后台账号**
- 用户名:`admin`
- 密码:`admin888`
> 首次部署后请务必修改后台密码(见 `config/config.php` 的 `admin` 段,或在后台扩展账号体系)。
---
## 四、后台:设置任意网页风格
进入 **后台 → 主题风格**,即可:
1. **一键套用预设**:选择 6 套内置配色方案之一,点击「套用预设」即时预览。
2. **精细化自定义**
- 主色 / 主色深 / 辅助色 / 点缀色
- 背景 / 表面 / 文字 / 次要文字 / 边框 / 导航背景
- 字体、圆角(px)、容器宽度(px)
- 导航样式:`center` 居中 / `left` 左对齐 / `transparent` 透明
- 默认明暗模式:`light` / `dark`
- 自定义 CSS(可注入任意样式覆盖)
3. **保存即生效**:点击「保存主题」后,系统重新生成 `public/assets/css/theme.css`,全站立即应用新风格,无需改代码、无需重启。
主题变量通过 CSS 变量注入前台(`--c-primary` 等),所有组件均基于这些变量构建,因此换肤是全局、即时、可逆的。
---
## 五、切换为 MySQL(生产环境)
1. 在 MySQL 中创建数据库并执行建表脚本:
```bash
mysql -u root -p sqy_cooling < install/schema.sql
```
2. 修改 `config/config.php`
```php
'driver' => 'mysql',
'db' => [
'mysql' => [
'host' => '127.0.0.1',
'port' => 3306,
'dbname' => 'sqy_cooling',
'user' => 'your_user',
'pass' => 'your_password',
'charset' => 'utf8mb4',
],
// ...
],
```
3. 执行安装脚本,将种子数据写入 MySQL:
```bash
php install/install.php
```
数据访问层(`app/Core/Model.php`)会根据 `driver` 自动在 JSON 文件与 MySQL 表之间切换,业务代码无需改动。
---
## 六、目录结构
```
.
├── app/
│ ├── Core/ # 框架核心:App(路由) / View / Controller / Model / Theme / Helper / Db
│ ├── Controllers/ # 前台控制器:Home / Product / News / Page / Contact
│ ├── Controllers/Admin/ # 后台控制器:Dashboard / Product / Category / News / Banner / Page / Setting / Auth
│ └── Models/ # 数据模型(Category / Product / News / Page / Banner / AdminUser / Setting
├── config/
│ └── config.php # 全局配置(driver / 后台路径 / 数据库 / 管理员)
├── install/
│ ├── install.php # 安装/重置脚本(写种子数据 + 生成 theme.css
│ ├── seed.php # 种子数据
│ └── schema.sql # MySQL 建表语句
├── public/ # Web 根目录(入口 index.php
│ ├── index.php
│ └── assets/
│ ├── css/ site.css / admin.css / theme.css(自动生成)
│ └── js/ main.js / admin.js
├── storage/
│ └── data/ # 文件模式数据存储(*.json)
└── views/ # 视图模板(layouts / 前台 / 后台)
```
---
## 七、生产环境部署
### Nginx 示例
```nginx
server {
listen 80;
server_name your-domain.com;
root /path/to/project/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~ /\.(?!well-known).* { deny all; }
}
```
### Apache 示例(`.htaccess` 置于 `public/`
```apache
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
```
> 部署要点:
> - Web 根目录必须指向 `public/`,避免暴露源码与 `storage/` 数据。
> - 确保 `storage/data/` 与 `public/assets/css/` 可写(用于文件模式与主题生成)。
> - 生产环境建议 `driver => 'mysql'`,并关闭 PHP 错误显示(`display_errors = Off`)。
---
## 八、数据备份与维护
- **文件模式**:直接备份 `storage/data/*.json` 即可。
- **MySQL 模式**:使用 `mysqldump` 定期备份数据库。
- **重置/重新初始化**:执行 `php install/install.php`(会覆盖现有数据,请先备份)。
- **重生成主题**:修改主题设置保存,或删除 `public/assets/css/theme.css` 后再次保存主题。
---
## 九、常见问题
**Q:后台登录后跳回登录页?**
A:确认 Cookie / Session 可写;文件模式无需数据库。检查 `php -S` 是否以 `public` 为根目录启动。
**Q:修改主题后页面没变化?**
A:清除浏览器缓存,确认 `public/assets/css/theme.css` 已更新;检查 `storage/data/settings.json` 中主题字段是否正确写入。
**Q:数据文件突然变成 0 字节?**
A:历史版本在写入含非法 UTF-8 字符时会触发 `json_encode` 失败并清空文件;当前版本已在 `Model::write()` 中修复(自动修正 UTF-8 且不会用失败结果覆盖文件)。如仍遇到,请检查输入是否为合法 UTF-8。
**Q:如何换域名 / 子目录部署?**
A:系统会自动根据 `DOCUMENT_ROOT` + `SCRIPT_FILENAME` 推导站点根路径,无需手动配置;若用反向代理请正确传递 `Host` 头。
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\Controller;
/** 后台基控制器:需要登录 */
class AdminController extends Controller
{
protected $layout = 'layouts/admin';
public function __construct()
{
admin_required();
}
/** 当前路由片段,用于侧栏高亮 */
protected function seg(): string
{
$u = trim(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH), '/');
$parts = explode('/', $u);
return $parts[1] ?? '';
}
/**
* 处理上传文件,返回相对站点根的路径(如 assets/uploads/x.jpg),无上传则返回 null。
* 安全红线:
* 1) 仅凭扩展名不可信 —— 光栅图用 getimagesize 校验真实图像内容;
* 2) 文件体积上限 8MB,防止磁盘打满 / DoS;
* 3) SVG 可内嵌 <script>/on* 事件 → 存储型 XSS,落盘前强制消毒;
* 4) 落盘文件名随机化,杜绝路径穿越与覆盖。
*/
protected function uploadFile(string $key): ?string
{
if (empty($_FILES[$key]['tmp_name'])) return null;
$f = $_FILES[$key];
if ($f['error'] !== UPLOAD_ERR_OK) return null;
if (!is_uploaded_file($f['tmp_name'])) return null;
if (($f['size'] ?? 0) > 8 * 1024 * 1024) return null; // 8MB 上限
$ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
if (!in_array($ext, $allowed, true)) return null;
// 真实类型校验(不信任扩展名与浏览器提交的 MIME)
$realMime = function_exists('finfo_open')
? (finfo_file(($fi = finfo_open(FILEINFO_MIME_TYPE)), $f['tmp_name']) ?: '') : '';
if (isset($fi) && $fi) { finfo_close($fi); }
$isSvg = ($ext === 'svg');
if (!$isSvg) {
// 光栅图:必须能被 GD 识别为真实图像,且 MIME 属于图片类
$info = @getimagesize($f['tmp_name']);
$okMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if ($info === false) return null;
if ($realMime && !in_array($realMime, $okMimes, true)) return null;
} else {
// SVG:类型须为 svg/xml/text,随后消毒内容
$svgMimes = ['image/svg+xml', 'text/plain', 'text/xml', 'application/xml'];
if ($realMime && !in_array($realMime, $svgMimes, true)) return null;
}
$dir = BASE_PATH . '/public/assets/uploads';
if (!is_dir($dir)) mkdir($dir, 0755, true);
$name = uniqid('u_') . '.' . $ext;
$dest = $dir . '/' . $name;
if ($isSvg) {
// 读取 → 消毒 → 写入(不使用 move_uploaded_file,因内容已被改写)
$raw = @file_get_contents($f['tmp_name']);
if ($raw === false) return null;
$clean = $this->sanitizeSvg($raw);
if ($clean === '' || @file_put_contents($dest, $clean) === false) return null;
} else {
if (!move_uploaded_file($f['tmp_name'], $dest)) return null;
}
return 'assets/uploads/' . $name;
}
/**
* 处理多文件上传(name="gallery[]"),返回相对站点根路径数组。
* 逐张复用 uploadFile 的校验逻辑(真实图像/体积上限/类型白名单/SVG 消毒),
* 任一文件失败不影响其余文件。
*/
protected function uploadFiles(string $key): array
{
if (empty($_FILES[$key]['tmp_name']) || !is_array($_FILES[$key]['tmp_name'])) return [];
$names = $_FILES[$key]['name'] ?? [];
$errors = $_FILES[$key]['error'] ?? [];
$sizes = $_FILES[$key]['size'] ?? [];
$out = [];
foreach ($_FILES[$key]['tmp_name'] as $i => $tmp) {
if (empty($tmp)) continue;
// 桥接到单文件校验逻辑
$_FILES['_multi_tmp'] = [
'name' => $names[$i] ?? 'x.bin',
'type' => '',
'tmp_name' => $tmp,
'error' => $errors[$i] ?? UPLOAD_ERR_OK,
'size' => $sizes[$i] ?? 0,
];
$rel = $this->uploadFile('_multi_tmp');
unset($_FILES['_multi_tmp']);
if ($rel) $out[] = $rel;
}
return $out;
}
/** 消毒 SVG:移除脚本、事件处理器与危险协议,阻断存储型 XSS */
protected function sanitizeSvg(string $svg): string
{
// 去除 <script>...</script>
$svg = preg_replace('#<script[^>]*>.*?</script>#is', '', $svg);
// 去除 <foreignObject>(可嵌 HTML/脚本)
$svg = preg_replace('#<foreignObject[^>]*>.*?</foreignObject>#is', '', $svg);
// 去除内联事件处理器 on*="..."
$svg = preg_replace('#\son\w+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $svg);
// 去除 javascript: / data:text 等危险协议引用
$svg = preg_replace('#(href|xlink:href)\s*=\s*("|\')?\s*javascript:[^"\'>]*#i', '', $svg);
// 去除 <use> 外部引用与 <a> 标签,避免脚本跳转
$svg = preg_replace('#<a[^>]*>|</a>#i', '', $svg);
return trim((string)$svg);
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\Controller;
use App\Models\AdminUser;
use Core\App;
class AuthController extends Controller
{
protected $layout = 'layouts/admin';
public function login()
{
if (is_admin()) { $this->redirect(login_landing()); }
$error = '';
$blocked = false;
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 质量红线:登录必须校验 CSRF + 验证码 + IP 级双窗口限速(失败 10 分钟/5 次、成功 30 分钟/5 次),杜绝机器人暴力破解
if (!csrf_check()) {
$error = '表单已过期,请刷新页面后重试';
} elseif (ip_login_blocked($ip)) {
$error = '尝试次数过多,请 30 分钟后再试';
$blocked = true;
} elseif (!captcha_check($this->post('captcha'))) {
$error = '验证码错误,请重新计算';
} else {
$u = trim($this->post('username'));
$p = $this->post('password');
$ok = false;
$user = null;
$found = (new AdminUser())->byUsername($u);
if ($found && (int)($found['status'] ?? 1) === 1 && password_verify($p, $found['password'])) {
$ok = true; $user = $found;
} elseif ($this->fallbackEnabled() && $u === \Core\App::config('admin.username') && $p === \Core\App::config('admin.password')) {
// 配置文件兜底账号:仅本地/演示模式开启(本地规则要求生产禁用默认密码后门)
$ok = true;
$user = ['id' => 0, 'username' => $u, 'name' => '管理员', 'role' => 'super_admin'];
}
if ($ok) {
ip_login_register_success($ip); // 记录成功登录(纳入 30 分钟 5 次上限),并重置失败计数
session_regenerate_id(true); // 防会话固定
$_SESSION['admin_logged'] = true;
$_SESSION['admin_id'] = $user['id'] ?? 0;
$_SESSION['admin_name'] = $user['name'] ?? $u;
$_SESSION['admin_role'] = $user['role'] ?? 'super_admin';
$_SESSION['crm_role'] = $user['crm_role'] ?? 'none';
$_SESSION['psi_role'] = $user['psi_role'] ?? 'none';
$dec = function ($v) { $a = @json_decode((string)$v, true); return is_array($a) ? $a : null; };
$_SESSION['crm_perms'] = $dec($user['crm_perms'] ?? null);
$_SESSION['psi_perms'] = $dec($user['psi_perms'] ?? null);
$this->redirect(login_landing());
}
ip_login_register_fail($ip); // 记录一次失败(纳入 10 分钟 5 次上限)
$error = '用户名或密码错误';
}
}
// 被限速(IP 失败/成功过多)时返回 429 + Retry-After,明确告知客户端稍后再试
if ($blocked && !headers_sent()) {
http_response_code(429);
header('Retry-After: ' . ip_login_remaining($ip));
}
$captcha = captcha_make(); // 每次渲染都发放新的算术验证码
return $this->view('admin/login', ['error' => $error, 'captcha' => $captcha]);
}
/** 配置文件兜底账号是否启用:生产(mysql 模式)默认关闭,本地演示可开 */
private function fallbackEnabled(): bool
{
return (bool) \Core\App::config('admin.allow_config_fallback', false)
&& \Core\App::config('app.driver', 'file') !== 'mysql';
}
/** 修改当前登录账号的密码 */
public function password()
{
admin_required();
$error = '';
$ok = '';
$uid = admin_uid();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_check()) {
$error = '表单已过期,请重试';
} else {
$cur = $this->post('current_password');
$new = $this->post('new_password');
$confirm = $this->post('confirm_password');
$m = new AdminUser();
$me = $uid ? $m->find($uid) : null;
if (!$me) {
$error = '账号异常,请重新登录';
} elseif (!password_verify($cur, $me['password'])) {
$error = '当前密码不正确';
} elseif (strlen($new) < 6) {
$error = '新密码至少 6 位';
} elseif ($new !== $confirm) {
$error = '两次输入的密码不一致';
} else {
$m->update($uid, ['password' => password_hash($new, PASSWORD_DEFAULT)]);
$ok = '密码修改成功';
}
}
}
return $this->view('admin/password', ['error' => $error, 'ok' => $ok]);
}
public function logout()
{
unset($_SESSION['admin_logged'], $_SESSION['admin_name'], $_SESSION['admin_id'], $_SESSION['admin_role'], $_SESSION['crm_role'], $_SESSION['psi_role'], $_SESSION['crm_perms'], $_SESSION['psi_perms']);
$this->redirect('admin/login');
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Controllers\Admin;
use App\Models\Banner;
class BannerController extends AdminController
{
public function index()
{
return $this->view('admin/banners', [
'banners' => (new Banner())->all(),
'seg' => $this->seg(),
]);
}
public function create()
{
return $this->view('admin/banner_form', ['b' => null]);
}
public function store()
{
if (!csrf_check()) { $this->redirect('admin/banners'); }
$image = $this->uploadFile('image') ?? $this->post('image', 'linear-gradient(135deg,#0ea5e9,#14b8a6)');
(new Banner())->insert([
'title' => $this->post('title'),
'subtitle' => $this->post('subtitle'),
'image' => $image,
'link' => $this->post('link', ''),
'sort_order' => (int)$this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
]);
$this->redirect('admin/banners');
}
public function edit($id)
{
$b = (new Banner())->find($id);
if (!$b) { $this->redirect('admin/banners'); }
return $this->view('admin/banner_form', ['b' => $b]);
}
public function update($id)
{
if (!csrf_check()) { $this->redirect('admin/banners'); }
$banner = new Banner();
$b = $banner->find($id);
if (!$b) { $this->redirect('admin/banners'); }
$image = $this->uploadFile('image');
if (!$image && $this->post('image')) $image = $this->post('image');
if (!$image) $image = $b['image'] ?? '';
$banner->update($id, [
'title' => $this->post('title'),
'subtitle' => $this->post('subtitle'),
'image' => $image,
'link' => $this->post('link', ''),
'sort_order' => (int)$this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
]);
$this->redirect('admin/banners');
}
public function delete($id)
{
if (csrf_check()) { (new Banner())->delete($id); }
$this->redirect('admin/banners');
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace App\Controllers\Admin;
use App\Models\CustomerCase;
/**
* 后台「客户案例」管理:增删改查,与新闻管理同构。
* 权限能力键:cases(在 Helper::admin_role_map 中分配)。
*/
class CaseController extends AdminController
{
public function index()
{
return $this->view('admin/cases', [
'cases' => (new CustomerCase())->all(),
'seg' => $this->seg(),
]);
}
public function create()
{
return $this->view('admin/cases_form', [
'c' => null,
'mode' => 'fixed',
]);
}
public function store()
{
if (!csrf_check()) { $this->redirect('admin/cases'); }
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$cover = $this->uploadFile('cover') ?? $this->post('cover_url', '');
$content = ($mode === 'fixed') ? $this->post('content', '') : '';
$model = new CustomerCase();
$id = $model->insert([
'title' => $this->post('title'),
'slug' => '',
'customer' => $this->post('customer', ''),
'industry' => $this->post('industry', ''),
'cover' => $cover,
'summary' => $this->post('summary'),
'content' => $content,
'published_at' => $this->post('published_at', date('Y-m-d')),
'sort_order' => (int) $this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
'views' => 0,
'layout' => $this->post('layout', ''),
'mode' => $mode,
]);
// URL 标识留空时按记录序号顺序生成(短、稳定),避免中文标题导致过长
$slug = $this->post('slug') ? slugify($this->post('slug')) : (string)$id;
$model->update($id, ['slug' => $slug]);
// 新建时若选择「可视化编辑」,保存后直接进入可视化编辑器排版
if ($mode === 'builder') {
$this->redirect('admin/cases/edit/' . $id);
}
$this->redirect('admin/cases');
}
public function edit($id)
{
$model = new CustomerCase();
$c = $model->find($id);
if (!$c) { $this->redirect('admin/cases'); }
$mode = empty($c['mode']) ? 'fixed' : $c['mode'];
if ($mode === 'builder') {
$layout = [];
if (!empty($c['layout'])) {
$dec = json_decode($c['layout'], true);
if (is_array($dec)) $layout = $dec;
}
return $this->view('admin/cases_builder', ['c' => $c, 'layout' => $layout, 'mode' => $mode]);
}
return $this->view('admin/cases_form', ['c' => $c, 'mode' => $mode]);
}
/** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 mode 字段 */
public function switchMode($id)
{
$c = (new CustomerCase())->find($id);
if (!$c) { $this->redirect('admin/cases'); }
$target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed';
(new CustomerCase())->update($id, ['mode' => $target]);
$this->redirect('admin/cases/edit/' . $id);
}
public function update($id)
{
if (!csrf_check()) { $this->redirect('admin/cases'); }
$model = new CustomerCase();
$c = $model->find($id);
if (!$c) { $this->redirect('admin/cases'); }
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$cover = $this->uploadFile('cover');
if (!$cover && $this->post('cover_url')) $cover = $this->post('cover_url');
if (!$cover) $cover = $c['cover'] ?? '';
$data = [
'title' => $this->post('title'),
'slug' => $this->post('slug') ? slugify($this->post('slug')) : (string)$id,
'customer' => $this->post('customer', ''),
'industry' => $this->post('industry', ''),
'cover' => $cover,
'summary' => $this->post('summary'),
'published_at' => $this->post('published_at', date('Y-m-d')),
'sort_order' => (int) $this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
'mode' => $mode,
];
if ($mode === 'fixed') {
$data['content'] = $this->post('content', '');
} else {
$layout = $this->post('layout', '');
if ($layout !== '' && !is_array(json_decode($layout, true))) { $layout = ''; }
$data['layout'] = $layout;
}
$model->update($id, $data);
$this->redirect('admin/cases');
}
public function delete($id)
{
if (csrf_check()) { (new CustomerCase())->delete($id); }
$this->redirect('admin/cases');
}
}
@@ -0,0 +1,111 @@
<?php
namespace App\Controllers\Admin;
use App\Models\Category;
class CategoryController extends AdminController
{
public function index()
{
return $this->view('admin/categories', [
'cats' => (new Category())->all(),
'seg' => $this->seg(),
]);
}
public function create()
{
return $this->view('admin/category_form', [
'c' => null,
'mode' => 'fixed',
]);
}
public function store()
{
if (!csrf_check()) { $this->redirect('admin/categories'); }
$cat = new Category();
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$description = ($mode === 'fixed') ? $this->post('description', '') : '';
$id = $cat->insert([
'name' => $this->post('name'),
'slug' => '',
'icon' => $this->post('icon', '❄'),
'description' => $description,
'sort_order' => (int)$this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
'layout' => $this->post('layout', ''),
'mode' => $mode,
]);
// URL 标识留空时按记录序号顺序生成(短、稳定)
$slug = $this->post('slug') ? slugify($this->post('slug')) : (string)$id;
$cat->update($id, ['slug' => $slug]);
// 新建时若选择「可视化编辑」,保存后直接进入可视化编辑器排版
if ($mode === 'builder') {
$this->redirect('admin/categories/edit/' . $id);
}
$this->redirect('admin/categories');
}
public function edit($id)
{
$cat = new Category();
$c = $cat->find($id);
if (!$c) { $this->redirect('admin/categories'); }
$mode = empty($c['mode']) ? 'fixed' : $c['mode'];
if ($mode === 'builder') {
$layout = [];
if (!empty($c['layout'])) {
$dec = json_decode($c['layout'], true);
if (is_array($dec)) $layout = $dec;
}
return $this->view('admin/category_builder', ['c' => $c, 'layout' => $layout, 'mode' => $mode]);
}
return $this->view('admin/category_form', ['c' => $c, 'mode' => $mode]);
}
/** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 mode 字段 */
public function switchMode($id)
{
$c = (new Category())->find($id);
if (!$c) { $this->redirect('admin/categories'); }
$target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed';
(new Category())->update($id, ['mode' => $target]);
$this->redirect('admin/categories/edit/' . $id);
}
public function update($id)
{
if (!csrf_check()) { $this->redirect('admin/categories'); }
$cat = new Category();
$c = $cat->find($id);
if (!$c) { $this->redirect('admin/categories'); }
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$data = [
'name' => $this->post('name'),
'slug' => $this->post('slug') ? slugify($this->post('slug')) : (string)$id,
'icon' => $this->post('icon', '❄'),
'sort_order' => (int)$this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
'mode' => $mode,
];
if ($mode === 'fixed') {
$data['description'] = $this->post('description', '');
} else {
$layout = $this->post('layout', '');
if ($layout !== '' && !is_array(json_decode($layout, true))) { $layout = ''; }
$data['layout'] = $layout;
}
$cat->update($id, $data);
$this->redirect('admin/categories');
}
public function delete($id)
{
if (csrf_check()) { (new Category())->delete($id); }
$this->redirect('admin/categories');
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Controllers\Admin;
use App\Models\Product;
use App\Models\Category;
use App\Models\News;
use App\Models\Banner;
use App\Models\Setting;
class DashboardController extends AdminController
{
public function index()
{
$setting = new Setting();
$data = [
'site_name' => $setting->get('site_name', '酷冰甲 · 降温服'),
'counts' => [
'products' => (new Product())->count(),
'categories' => (new Category())->count(),
'news' => (new News())->count(),
'banners' => (new Banner())->count(),
],
'news' => (new News())->published(5),
];
return $this->view('admin/dashboard', $data);
}
}
@@ -0,0 +1,322 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\Controller;
use Core\App;
use Core\Db;
/**
* 后台:数据库管理
* - 列出全部数据表(MySQL 模式)。
* - 浏览任意表数据、查看/编辑/新增/删除记录(基于主键)。
* - 仅 MySQL 模式可用;file 模式仅做说明提示。
*
* 路由说明:App::dispatchAdmin 会以 URL 段作为方法名调用本控制器,
* 故 browse/edit/update/create/store/delete 均为 public,并在内部用
* App::parseRoute() 重新解析完整路径(含第 3、4 段)以拿到表名与记录主键。
* /admin/db -> index() 表列表
* /admin/db/browse/<table> -> browse(table) 浏览(?q= 搜索、?page= 分页)
* /admin/db/edit/<table>/<id> -> edit(table,id) 编辑表单
* /admin/db/update/<table>/<id> -> update(...) 保存(POST
* /admin/db/create/<table> -> create(table) 新增表单
* /admin/db/store/<table> -> store(...) 新增保存(POST
* /admin/db/delete/<table>/<id> -> delete(...) 删除(GET + 确认)
*
* 安全:表名与列名均来自 DESCRIBE / SHOW TABLES 白名单,杜绝 SQL 注入。
*/
class DatabaseController extends Controller
{
protected $layout = 'layouts/admin';
/** 解析完整路径:['admin','db', action, table, id] */
private function route(): array
{
return \Core\App::parseRoute();
}
/** 路由中的表名段(full[3] */
private function segTable(): ?string
{
$r = $this->route();
return $r[3] ?? null;
}
/** 路由中的记录主键段(full[4]) */
private function segId(): ?string
{
$r = $this->route();
return $r[4] ?? null;
}
/** 表列表 */
public function index($ignored = null)
{
admin_required();
if (Db::driver() !== 'mysql') {
return $this->view('admin/db_tables', [
'fileMode' => true,
'dataFiles' => $this->fileDataFiles(),
]);
}
return $this->tablesList();
}
/** 浏览记录 */
public function browse($table = null)
{
admin_required();
if (Db::driver() !== 'mysql') { return $this->tablesList(); }
$table = $table ?? $this->segTable();
return $this->doBrowse($table);
}
/** 编辑 / 新增表单 */
public function edit($table = null)
{
admin_required();
if (Db::driver() !== 'mysql') { return $this->tablesList(); }
$table = $table ?? $this->segTable();
$id = $this->segId();
return $this->doEditForm($table, $id);
}
/** 保存编辑 */
public function update($table = null)
{
admin_required();
$table = $table ?? $this->segTable();
$id = $this->segId();
return $this->doUpdate($table, $id);
}
/** 新增表单 */
public function create($table = null)
{
admin_required();
if (Db::driver() !== 'mysql') { return $this->tablesList(); }
$table = $table ?? $this->segTable();
return $this->doEditForm($table, null);
}
/** 保存新增 */
public function store($table = null)
{
admin_required();
$table = $table ?? $this->segTable();
return $this->doStore($table);
}
/** 删除 */
public function delete($table = null)
{
admin_required();
$table = $table ?? $this->segTable();
$id = $this->segId();
return $this->doDelete($table, $id);
}
/* ---------------- 实现 ---------------- */
private function tablesList(): string
{
$pdo = Db::pdo();
$status = $pdo->query("SHOW TABLE STATUS")->fetchAll();
$tables = [];
foreach ($status as $t) {
$tables[] = [
'name' => $t['Name'],
'engine' => $t['Engine'] ?? '',
'rows' => (int)($t['Rows'] ?? 0),
'size' => (int)($t['Data_length'] ?? 0) + (int)($t['Index_length'] ?? 0),
'collation' => $t['Collation'] ?? '',
];
}
usort($tables, fn($a, $b) => strcmp($a['name'], $b['name']));
return $this->view('admin/db_tables', ['tables' => $tables, 'total' => count($tables)]);
}
private function doBrowse(?string $table): string
{
if (!$table || !$this->validTable($table)) {
$this->flash('表不存在或无权访问', 'err');
return $this->tablesList();
}
$pdo = Db::pdo();
$cols = $this->columnsOf($table);
$names = array_column($cols, 'Field');
$pk = $this->pkOf($table) ?: $names[0];
$limit = 50;
$page = max(1, (int)($_GET['page'] ?? 1));
$offset = ($page - 1) * $limit;
$q = trim((string)($_GET['q'] ?? ''));
$where = '';
$params = [];
if ($q !== '') {
$likes = [];
foreach ($names as $c) {
$likes[] = "`{$c}` LIKE ?";
$params[] = '%' . $q . '%';
}
$where = ' WHERE ' . implode(' OR ', $likes);
}
$stmt = $pdo->prepare("SELECT COUNT(*) FROM `{$table}`" . $where);
$stmt->execute($params);
$total = (int)$stmt->fetchColumn();
$orderCol = in_array($pk, $names, true) ? $pk : $names[0];
$rows = $pdo->query("SELECT * FROM `{$table}`" . $where . " ORDER BY `{$orderCol}` DESC LIMIT {$limit} OFFSET {$offset}")->fetchAll(\PDO::FETCH_ASSOC);
$totalPages = max(1, (int)ceil($total / $limit));
return $this->view('admin/db_browse', [
'table' => $table,
'cols' => $cols,
'names' => $names,
'pk' => $pk,
'rows' => $rows,
'page' => $page,
'totalPages' => $totalPages,
'total' => $total,
'q' => $q,
]);
}
private function doEditForm(?string $table, $id): string
{
if (!$table || !$this->validTable($table)) {
$this->flash('表不存在或无权访问', 'err');
return $this->tablesList();
}
$pdo = Db::pdo();
$cols = $this->columnsOf($table);
$pk = $this->pkOf($table);
$row = null;
if ($id !== null && $id !== '') {
$stmt = $pdo->prepare("SELECT * FROM `{$table}` WHERE `{$pk}` = ? LIMIT 1");
$stmt->execute([$id]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row) {
$this->flash('记录不存在', 'err');
return $this->doBrowse($table);
}
}
return $this->view('admin/db_form', [
'table' => $table,
'cols' => $cols,
'pk' => $pk,
'row' => $row,
'mode' => $row ? 'edit' : 'create',
]);
}
private function doUpdate(?string $table, $id): string
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect("admin/db/browse/$table"); return ''; }
if (!$table || !$this->validTable($table)) { $this->flash('表不存在', 'err'); return $this->tablesList(); }
$pdo = Db::pdo();
$cols = $this->columnsOf($table);
$pk = $this->pkOf($table);
$sets = [];
$params = [];
foreach ($cols as $c) {
$f = $c['Field'];
if ($f === $pk) continue;
if (!array_key_exists($f, $_POST)) continue;
$v = $_POST[$f];
if ($v === '' && $c['Null'] === 'YES') $v = null;
$sets[] = "`{$f}` = ?";
$params[] = $v;
}
if (empty($sets)) {
$this->flash('没有需要更新的字段', 'ok');
$this->redirect("admin/db/browse/$table");
return '';
}
$params[] = $id;
$pdo->prepare("UPDATE `{$table}` SET " . implode(', ', $sets) . " WHERE `{$pk}` = ?")->execute($params);
$this->flash('记录已更新', 'ok');
$this->redirect("admin/db/browse/$table");
return '';
}
private function doStore(?string $table): string
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect("admin/db/browse/$table"); return ''; }
if (!$table || !$this->validTable($table)) { $this->flash('表不存在', 'err'); return $this->tablesList(); }
$pdo = Db::pdo();
$cols = $this->columnsOf($table);
$fields = [];
$ph = [];
$params = [];
foreach ($cols as $c) {
$f = $c['Field'];
if (($c['Extra'] ?? '') === 'auto_increment') continue;
if (!array_key_exists($f, $_POST)) {
if ($c['Null'] === 'YES') { $fields[] = "`{$f}`"; $ph[] = '?'; $params[] = null; }
continue;
}
$v = $_POST[$f];
if ($v === '' && $c['Null'] === 'YES') $v = null;
$fields[] = "`{$f}`"; $ph[] = '?'; $params[] = $v;
}
if (empty($fields)) {
$this->flash('没有可写入的字段', 'err');
$this->redirect("admin/db/browse/$table");
return '';
}
$pdo->prepare("INSERT INTO `{$table}` (" . implode(', ', $fields) . ") VALUES (" . implode(', ', $ph) . ")")
->execute($params);
$this->flash('记录已新增', 'ok');
$this->redirect("admin/db/browse/$table");
return '';
}
private function doDelete(?string $table, $id): string
{
if (!$table || !$this->validTable($table)) { $this->flash('表不存在', 'err'); return $this->tablesList(); }
$pk = $this->pkOf($table);
Db::pdo()->prepare("DELETE FROM `{$table}` WHERE `{$pk}` = ?")->execute([$id]);
$this->flash('记录已删除', 'ok');
$this->redirect("admin/db/browse/$table");
return '';
}
/* ---------------- 工具方法 ---------------- */
/** 全部表名白名单(校验来自 URL 的表名) */
private function tableList(): array
{
return Db::pdo()->query("SHOW TABLES")->fetchAll(\PDO::FETCH_COLUMN);
}
private function validTable(string $t): bool
{
return in_array($t, $this->tableList(), true);
}
private function pkOf(string $t): ?string
{
$keys = Db::pdo()->query("SHOW KEYS FROM `{$t}` WHERE Key_name='PRIMARY'")->fetchAll();
if ($keys) return $keys[0]['Column_name'];
$cols = $this->columnsOf($t);
return $cols[0]['Field'] ?? null;
}
private function columnsOf(string $t): array
{
return Db::pdo()->query("DESCRIBE `{$t}`")->fetchAll();
}
/** file 模式下可用的数据文件(只读提示) */
private function fileDataFiles(): array
{
$dir = Db::fileDir();
$out = [];
foreach (glob($dir . '/*.json') as $f) {
$out[] = ['name' => basename($f), 'size' => filesize($f)];
}
return $out;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Controllers\Admin;
/**
* 素材库:图片上传 / 列表 / 删除
* 文件存于 public/assets/uploads,前后台通用(静态资源由框架直出)。
*/
class MediaController extends AdminController
{
private function dir(): string
{
$d = BASE_PATH . '/public/assets/uploads';
if (!is_dir($d)) mkdir($d, 0755, true);
return $d;
}
/** 素材列表(JSON GET admin/media */
public function index()
{
header('Content-Type: application/json; charset=utf-8');
$d = $this->dir();
$items = [];
foreach (glob($d . '/*.{jpg,jpeg,png,gif,webp,svg}', GLOB_BRACE) as $f) {
$name = basename($f);
$items[] = ['name' => $name, 'url' => site_url('assets/uploads/' . $name)];
}
echo json_encode(['items' => $items]);
}
/** 上传素材(JSON POST admin/media/upload */
public function upload()
{
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) {
echo json_encode(['ok' => false, 'msg' => '请求无效']);
return;
}
$path = $this->uploadFile('file');
if (!$path) {
echo json_encode(['ok' => false, 'msg' => '上传失败(仅支持 jpg/png/gif/webp/svg']);
return;
}
echo json_encode(['ok' => true, 'name' => basename($path), 'url' => site_url($path)]);
}
/** 删除素材 POST admin/media/delete/{name} 或 POST admin/media/delete + name 字段 */
public function delete($name = null)
{
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) {
echo json_encode(['ok' => false, 'msg' => '请求无效']);
return;
}
$name = basename($name ?? ($_POST['name'] ?? ''));
if ($name === '' || $name === '.' || $name === '..') {
echo json_encode(['ok' => false, 'msg' => '参数缺失']);
return;
}
$file = $this->dir() . '/' . $name;
if (is_file($file) && @unlink($file)) {
echo json_encode(['ok' => true]);
} else {
echo json_encode(['ok' => false, 'msg' => '删除失败']);
}
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace App\Controllers\Admin;
use App\Models\News;
class NewsController extends AdminController
{
public function index()
{
return $this->view('admin/news', [
'news' => (new News())->all(),
'seg' => $this->seg(),
]);
}
public function create()
{
return $this->view('admin/news_form', [
'n' => null,
'mode' => 'fixed',
]);
}
public function store()
{
if (!csrf_check()) { $this->redirect('admin/news'); }
$news = new News();
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$cover = $this->uploadFile('cover') ?? $this->post('cover_url', '');
$content = ($mode === 'fixed') ? $this->post('content', '') : '';
$id = $news->insert([
'title' => $this->post('title'),
'slug' => '',
'cover' => $cover,
'summary' => $this->post('summary'),
'content' => $content,
'author' => $this->post('author', '酷冰甲'),
'published_at' => $this->post('published_at', date('Y-m-d')),
'status' => $this->post('status', 1) ? 1 : 0,
'views' => 0,
'layout' => $this->post('layout', ''),
'mode' => $mode,
]);
// URL 标识留空时按记录序号顺序生成(短、稳定)
$slug = $this->post('slug') ? slugify($this->post('slug')) : (string)$id;
$news->update($id, ['slug' => $slug]);
// 新建时若选择「可视化编辑」,保存后直接进入可视化编辑器排版
if ($mode === 'builder') {
$this->redirect('admin/news/edit/' . $id);
}
$this->redirect('admin/news');
}
public function edit($id)
{
$news = new News();
$n = $news->find($id);
if (!$n) { $this->redirect('admin/news'); }
$mode = empty($n['mode']) ? 'fixed' : $n['mode'];
if ($mode === 'builder') {
$layout = [];
if (!empty($n['layout'])) {
$dec = json_decode($n['layout'], true);
if (is_array($dec)) $layout = $dec;
}
return $this->view('admin/news_builder', ['n' => $n, 'layout' => $layout, 'mode' => $mode]);
}
return $this->view('admin/news_form', ['n' => $n, 'mode' => $mode]);
}
/** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 mode 字段 */
public function switchMode($id)
{
$n = (new News())->find($id);
if (!$n) { $this->redirect('admin/news'); }
$target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed';
(new News())->update($id, ['mode' => $target]);
$this->redirect('admin/news/edit/' . $id);
}
public function update($id)
{
if (!csrf_check()) { $this->redirect('admin/news'); }
$news = new News();
$n = $news->find($id);
if (!$n) { $this->redirect('admin/news'); }
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$cover = $this->uploadFile('cover');
if (!$cover && $this->post('cover_url')) $cover = $this->post('cover_url');
if (!$cover) $cover = $n['cover'] ?? '';
$data = [
'title' => $this->post('title'),
'slug' => $this->post('slug') ? slugify($this->post('slug')) : (string)$id,
'cover' => $cover,
'summary' => $this->post('summary'),
'author' => $this->post('author', '酷冰甲'),
'published_at' => $this->post('published_at', date('Y-m-d')),
'status' => $this->post('status', 1) ? 1 : 0,
'mode' => $mode,
];
if ($mode === 'fixed') {
$data['content'] = $this->post('content', '');
} else {
$layout = $this->post('layout', '');
if ($layout !== '' && !is_array(json_decode($layout, true))) { $layout = ''; }
$data['layout'] = $layout;
}
$news->update($id, $data);
$this->redirect('admin/news');
}
public function delete($id)
{
if (csrf_check()) { (new News())->delete($id); }
$this->redirect('admin/news');
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Controllers\Admin;
use App\Models\Order;
use App\Models\Payment;
use Core\Payment\OrderService;
/** 后台:订单与付款管理(超级管理员 + 管理员) */
class OrderController extends AdminController
{
public function index()
{
$order = new Order();
$all = array_reverse($order->all()); // 最新在前
return $this->view('admin/orders', ['orders' => $all, 'seg' => 'orders']);
}
public function show($id)
{
$order = new Order();
$o = $order->find($id);
if (!$o) { \Core\App::notFound(); return; }
$payments = (new Payment())->whereAll('order_no', $o['order_no']);
return $this->view('admin/order_show', ['o' => $o, 'payments' => $payments, 'seg' => 'orders']);
}
public function markPaid($id)
{
if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
$order = new Order();
$o = $order->find($id);
if ($o && $o['status'] !== 'paid') {
OrderService::markPaid($o['order_no'], 'MANUAL' . time(), $o['channel'] ?: 'manual');
}
}
$this->redirect('admin/orders/show/' . $id);
}
public function delete($id)
{
if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
(new Order())->delete($id);
}
$this->redirect('admin/orders');
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Controllers\Admin;
use App\Models\Page;
class PageController extends AdminController
{
public function index()
{
return $this->view('admin/pages', [
'pages' => (new Page())->all(),
'seg' => $this->seg(),
]);
}
/** 编辑:按页面 mode 渲染对应编辑器(固定版面 / 可视化编辑) */
public function edit($id)
{
$p = (new Page())->find($id);
if (!$p) { $this->redirect('admin/pages'); }
$mode = empty($p['mode']) ? 'fixed' : $p['mode'];
if ($mode === 'builder') {
$layout = [];
if (!empty($p['layout'])) {
$dec = json_decode($p['layout'], true);
if (is_array($dec)) $layout = $dec;
}
return $this->view('admin/page_builder', ['p' => $p, 'layout' => $layout, 'mode' => $mode]);
}
return $this->view('admin/page_form', ['p' => $p, 'mode' => $mode]);
}
/** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 mode 字段 */
public function switchMode($id)
{
$p = (new Page())->find($id);
if (!$p) { $this->redirect('admin/pages'); }
$target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed';
(new Page())->update($id, ['mode' => $target, 'updated_at' => date('Y-m-d')]);
$this->redirect('admin/pages/edit/' . $id);
}
/** 保存:兼容两种编辑器,按实际提交的字段写入(content / layout / mode */
public function update($id)
{
if (!csrf_check()) { $this->redirect('admin/pages'); }
$data = [
'title' => $this->post('title'),
'updated_at' => date('Y-m-d'),
];
if ($this->post('content') !== null) {
$data['content'] = $this->post('content');
}
if ($this->post('layout') !== null) {
$layout = $this->post('layout', '');
if ($layout !== '' && !is_array(json_decode($layout, true))) {
$layout = '';
}
$data['layout'] = $layout;
}
$mode = $this->post('mode');
if ($mode === 'builder' || $mode === 'fixed') {
$data['mode'] = $mode;
}
(new Page())->update($id, $data);
$this->redirect('admin/pages');
}
}
+170
View File
@@ -0,0 +1,170 @@
<?php
namespace App\Controllers\Admin;
use App\Models\Product;
use App\Models\Category;
class ProductController extends AdminController
{
private function specsToJson($text): string
{
$out = [];
foreach (explode("\n", $text) as $line) {
$line = trim($line);
if (!$line) continue;
$p = strpos($line, '|');
if ($p === false) { $out[] = ['k' => $line, 'v' => '']; }
else { $out[] = ['k' => trim(substr($line, 0, $p)), 'v' => trim(substr($line, $p + 1))]; }
}
return json_encode($out, JSON_UNESCAPED_UNICODE);
}
public function index()
{
$product = new Product();
return $this->view('admin/products', [
'products' => $product->all(),
'seg' => $this->seg(),
]);
}
public function create()
{
return $this->view('admin/product_form', [
'p' => null,
'cats' => (new Category())->all(),
'mode' => 'fixed',
]);
}
public function store()
{
if (!csrf_check()) { $this->redirect('admin/products'); }
$product = new Product();
$cover = $this->uploadFile('cover') ?? $this->post('cover_url', '');
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$gallery = ($mode === 'fixed') ? $this->uploadFiles('gallery') : [];
$description = ($mode === 'fixed') ? $this->post('description', '') : '';
$id = $product->insert([
'category_id' => (int)$this->post('category_id'),
'name' => $this->post('name'),
'slug' => '',
'cover' => $cover,
'summary' => $this->post('summary'),
'description' => $description,
'price' => (float)$this->post('price', 0),
'specs' => $this->specsToJson($this->post('specs', '')),
'gallery' => json_encode($gallery, JSON_UNESCAPED_UNICODE),
'tags' => $this->post('tags', ''),
'sort_order' => (int)$this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
'created_at' => date('Y-m-d'),
'layout' => $this->post('layout', ''),
'mode' => $mode,
]);
// URL 标识留空时按记录序号顺序生成(短、稳定)
$slug = $this->post('slug') ? slugify($this->post('slug')) : (string)$id;
$product->update($id, ['slug' => $slug]);
// 新建时若选择「可视化编辑」,保存后直接进入可视化编辑器排版,无需再手动点编辑
if ($mode === 'builder') {
$this->redirect('admin/products/edit/' . $id);
}
$this->redirect('admin/products');
}
public function edit($id)
{
$product = new Product();
$p = $product->find($id);
if (!$p) { $this->redirect('admin/products'); }
$mode = empty($p['mode']) ? 'fixed' : $p['mode'];
$specs = $product->specsArray($p);
$specText = '';
foreach ($specs as $s) $specText .= ($s['k'] ?? '') . '|' . ($s['v'] ?? '') . "\n";
$cats = (new Category())->all();
if ($mode === 'builder') {
$layout = [];
if (!empty($p['layout'])) {
$dec = json_decode($p['layout'], true);
if (is_array($dec)) $layout = $dec;
}
return $this->view('admin/product_builder', [
'p' => $p,
'cats' => $cats,
'specText' => $specText,
'layout' => $layout,
'mode' => $mode,
]);
}
return $this->view('admin/product_form', [
'p' => $p,
'cats' => $cats,
'specText' => $specText,
'mode' => $mode,
]);
}
/** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 mode 字段,其余数据保留 */
public function switchMode($id)
{
$p = (new Product())->find($id);
if (!$p) { $this->redirect('admin/products'); }
$target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed';
(new Product())->update($id, ['mode' => $target]);
$this->redirect('admin/products/edit/' . $id);
}
public function update($id)
{
if (!csrf_check()) { $this->redirect('admin/products'); }
$product = new Product();
$p = $product->find($id);
if (!$p) { $this->redirect('admin/products'); }
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$cover = $this->uploadFile('cover');
if (!$cover && $this->post('cover_url')) $cover = $this->post('cover_url');
if (!$cover) $cover = $p['cover'] ?? '';
$data = [
'category_id' => (int)$this->post('category_id'),
'name' => $this->post('name'),
'slug' => $this->post('slug') ? slugify($this->post('slug')) : (string)$id,
'cover' => $cover,
'summary' => $this->post('summary'),
'price' => (float)$this->post('price', 0),
'specs' => $this->specsToJson($this->post('specs', '')),
'tags' => $this->post('tags', ''),
'sort_order' => (int)$this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
'mode' => $mode,
];
if ($mode === 'fixed') {
// 固定版面:保存图集与详细描述,保留原有 layout 不被覆盖
$newGal = $this->uploadFiles('gallery');
if (!empty($newGal)) {
$data['gallery'] = json_encode($newGal, JSON_UNESCAPED_UNICODE);
} elseif ($this->post('clear_gallery')) {
$data['gallery'] = '[]';
} else {
$data['gallery'] = $p['gallery'] ?? '[]';
}
$data['description'] = $this->post('description', '');
} else {
// 可视化编辑:更新 layout,保留图集/描述原值
$layout = $this->post('layout', '');
if ($layout !== '' && !is_array(json_decode($layout, true))) { $layout = ''; }
$data['layout'] = $layout;
}
$product->update($id, $data);
$this->redirect('admin/products');
}
public function delete($id)
{
if (csrf_check()) { (new Product())->delete($id); }
$this->redirect('admin/products');
}
}
+171
View File
@@ -0,0 +1,171 @@
<?php
namespace App\Controllers\Admin;
use App\Models\Setting;
use App\Models\PageSeo;
use Core\Theme;
class SettingController extends AdminController
{
private $themeFields = [
'preset', 'primary', 'primary_600', 'secondary', 'accent',
'bg', 'surface', 'text', 'muted', 'border', 'nav_bg',
'font', 'radius', 'container', 'header', 'default_mode', 'custom_css',
];
private $siteFields = [
'site_name', 'site_slogan', 'contact_phone', 'contact_email',
'contact_address', 'site_logo', 'icp', 'gongan', 'seo_title', 'seo_keywords', 'seo_description',
];
private $payFields = [
'pay_enabled', 'pay_mode',
'pay_alipay_appid', 'pay_alipay_private_key', 'pay_alipay_public_key', 'pay_alipay_gateway',
'pay_wechat_mchid', 'pay_wechat_appid', 'pay_wechat_key',
];
/** 站点设置 */
public function index()
{
$setting = new Setting();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (csrf_check()) {
$pairs = [];
foreach ($this->siteFields as $k) $pairs[$k] = $this->post($k, '');
// 网站 Logo:优先用上传文件,其次用填写的图片路径/网址;两者皆空则保留现值
$logoUp = $this->uploadFile('logo');
if ($logoUp !== null) {
$pairs['site_logo'] = $logoUp;
} else {
$url = trim((string) $this->post('logo_url', ''));
if ($url !== '') {
$pairs['site_logo'] = $url;
} else {
unset($pairs['site_logo']); // 不覆盖,保留数据库现有值(含默认商标)
}
}
$setting->saveMany($pairs, 'site');
}
$this->redirect('admin/settings');
}
$vals = [];
foreach ($this->siteFields as $k) $vals[$k] = $setting->get($k, Theme::get($k));
return $this->view('admin/settings', ['v' => $vals, 'seg' => 'settings']);
}
/** 风格 / 主题设置(任意网页风格) */
public function theme()
{
$setting = new Setting();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (csrf_check()) {
$pairs = [];
foreach ($this->themeFields as $k) $pairs[$k] = $this->post($k, '');
$setting->saveMany($pairs, 'theme');
Theme::clearCache();
Theme::regenerate();
}
$this->redirect('admin/theme');
}
$vals = [];
foreach ($this->themeFields as $k) $vals[$k] = $setting->get($k, Theme::get($k, ''));
return $this->view('admin/theme', [
'v' => $vals,
'presets' => Theme::presets(),
'seg' => 'theme',
]);
}
/** 支付设置(支付宝 / 微信) */
public function payment()
{
$setting = new Setting();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (csrf_check()) {
$pairs = [];
foreach ($this->payFields as $k) $pairs[$k] = $this->post($k, '');
$setting->saveMany($pairs, 'pay');
}
$this->redirect('admin/payments');
}
$vals = [];
foreach ($this->payFields as $k) $vals[$k] = $setting->get($k, Theme::get($k, ''));
return $this->view('admin/payment', ['v' => $vals, 'seg' => 'payments']);
}
/** 逐页 SEO 页面清单(首页 / 产品中心 / 产品分类 / 新闻 / 案例 / 关于我们 / 联系我们) */
private $seoPages = [
'home' => ['label' => '首页', 'title_min' => 35, 'desc_min' => 120, 'kw_min' => 10],
'products' => ['label' => '产品中心', 'title_min' => 0, 'desc_min' => 120, 'kw_min' => 10],
'product_category' => ['label' => '产品分类页', 'title_min' => 0, 'desc_min' => 80, 'kw_min' => 6, 'note' => '支持 {cat} 占位符,前端会自动替换为当前分类名。'],
'news' => ['label' => '新闻列表', 'title_min' => 0, 'desc_min' => 120, 'kw_min' => 8],
'cases' => ['label' => '客户案例', 'title_min' => 0, 'desc_min' => 120, 'kw_min' => 7],
'about' => ['label' => '关于我们', 'title_min' => 0, 'desc_min' => 120, 'kw_min' => 7],
'contact' => ['label' => '联系我们', 'title_min' => 0, 'desc_min' => 120, 'kw_min' => 7],
];
/** SEO 设置:页面清单(每个页面进入独立编辑页) */
public function seo()
{
admin_required();
$pageSeo = new PageSeo();
$rows = [];
try {
$rows = $pageSeo->allIndexed();
} catch (\Throwable $e) {
// 表尚未创建(未执行升级 SQL)时给出提示,不致命报错
$this->flash('未检测到 page_seo 表,请先到「数据库升级」执行 005_page_seo.sql', 'err');
}
return $this->view('admin/seo', [
'pages' => $this->seoPages,
'rows' => $rows,
'seg' => 'seo',
]);
}
/** SEO 单页编辑:admin/seo/edit/{key} */
public function edit($key = null)
{
admin_required();
if (!$key || !isset($this->seoPages[$key])) {
$this->flash('未找到该 SEO 页面', 'err');
$this->redirect('admin/seo');
return '';
}
$meta = $this->seoPages[$key];
$pageSeo = new PageSeo();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_check()) {
$this->flash('表单已过期,请刷新后重试', 'err');
$this->redirect('admin/seo/edit/' . $key);
return '';
}
$pageSeo->saveRow($key, [
'title' => trim((string)($_POST['title'] ?? '')),
'description' => trim((string)($_POST['description'] ?? '')),
'keywords' => trim((string)($_POST['keywords'] ?? '')),
'og_title' => trim((string)($_POST['og_title'] ?? '')),
'og_description' => trim((string)($_POST['og_description'] ?? '')),
'og_image' => trim((string)($_POST['og_image'] ?? '')),
'og_type' => trim((string)($_POST['og_type'] ?? 'website')),
'canonical' => trim((string)($_POST['canonical'] ?? '')),
'noindex' => isset($_POST['noindex']) ? 1 : 0,
]);
$this->flash('「' . $meta['label'] . '」SEO 设置已保存', 'ok');
$this->redirect('admin/seo/edit/' . $key);
return '';
}
$row = [];
try {
$row = $pageSeo->getByKey($key) ?: [];
} catch (\Throwable $e) {
$this->flash('未检测到 page_seo 表,请先到「数据库升级」执行 005_page_seo.sql', 'err');
}
return $this->view('admin/seo_edit', [
'key' => $key,
'meta' => $meta,
'v' => $row,
'seg' => 'seo',
]);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Controllers\Admin;
use Core\Installer;
/** 后台:数据升级(免 SSH,等价于 bash deploy.sh 的数据初始化部分) */
class SystemController extends AdminController
{
public function index()
{
return $this->view('admin/system', ['seg' => 'system']);
}
public function upgrade()
{
if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
$msgs = Installer::upgrade();
$_SESSION['flash'] = ['type' => 'ok', 'msg' => '数据升级完成 ✓ ' . implode('', $msgs)];
} else {
$_SESSION['flash'] = ['type' => 'err', 'msg' => '安全校验失败,请重试'];
}
$this->redirect('admin/system');
}
}
+166
View File
@@ -0,0 +1,166 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\Controller;
use Core\App;
use Core\Db;
use Core\Installer;
/**
* 后台:数据库升级
* - 将升级包(*.sql)放入 install/upgrades/ 目录后,本页自动提示「可升级」。
* - 支持单个升级与一键全部升级;每次执行写入 db_upgrades 记录(文件名 / 哈希 / 时间 / 操作人)。
* - 内容发生变更的已升级包会被重新标记为「需更新」。
* 路由:/admin/upgrade -> 升级面板
* /admin/upgrade/apply/<file.sql> -> 升级单个
* /admin/upgrade/applyall -> 一键全部升级
* /admin/upgrade/init -> 基础数据/表结构初始化(POST,仅超管)
*/
class UpgradeController extends Controller
{
protected $layout = 'layouts/admin';
private function dir(): string
{
return BASE_PATH . '/install/upgrades';
}
public function index()
{
admin_required();
if (Db::driver() !== 'mysql') {
return $this->view('admin/upgrade', ['driver' => 'file']);
}
$this->requireSuper();
Installer::ensureUpgradeLog();
$files = $this->scan();
$applied = $this->appliedMap();
$list = [];
foreach ($files as $f) {
$name = basename($f);
$hash = md5_file($f);
$state = !isset($applied[$name]) ? 'pending'
: ($applied[$name] !== $hash ? 'changed' : 'done');
$list[] = [
'name' => $name,
'size' => filesize($f),
'mtime' => filemtime($f),
'state' => $state,
];
}
// 排序:待升级 / 已变更 在前,已应用在后
$rank = ['pending' => 0, 'changed' => 1, 'done' => 2];
usort($list, fn($a, $b) => ($rank[$a['state']] ?? 9) - ($rank[$b['state']] ?? 9));
$pending = count(array_filter($list, fn($x) => $x['state'] !== 'done'));
$history = Db::query("SELECT * FROM db_upgrades ORDER BY applied_at DESC, id DESC LIMIT 50")->fetchAll();
return $this->view('admin/upgrade', [
'driver' => 'mysql',
'list' => $list,
'pending' => $pending,
'history' => $history,
]);
}
/** 升级单个升级包 */
public function apply($file = null)
{
admin_required();
$this->requireSuper();
$file = basename((string)$file);
$path = $this->dir() . '/' . $file;
if (!is_file($path) || !preg_match('/\.sql$/i', $file)) {
$this->flash('升级包不存在', 'err');
$this->redirect('admin/upgrade');
return '';
}
try {
Installer::applySqlFile($path);
Installer::ensureUpgradeLog();
Db::query(
"INSERT INTO db_upgrades (file, hash, applied_at, applied_by, note) VALUES (?, ?, ?, ?, ?)",
[$file, md5_file($path), date('Y-m-d H:i:s'), ($_SESSION['admin']['username'] ?? 'admin'), '']
);
$this->flash("已升级:{$file}", 'ok');
} catch (\Throwable $e) {
$this->flash('升级失败:' . $e->getMessage(), 'err');
}
$this->redirect('admin/upgrade');
return '';
}
/** 一键升级全部待处理 */
public function applyAll()
{
admin_required();
$this->requireSuper();
$files = $this->scan();
$applied = $this->appliedMap();
$done = 0;
foreach ($files as $f) {
$name = basename($f);
$hash = md5_file($f);
if (isset($applied[$name]) && $applied[$name] === $hash) continue;
try {
Installer::applySqlFile($f);
Installer::ensureUpgradeLog();
Db::query(
"INSERT INTO db_upgrades (file, hash, applied_at, applied_by, note) VALUES (?, ?, ?, ?, ?)",
[$name, $hash, date('Y-m-d H:i:s'), ($_SESSION['admin']['username'] ?? 'admin'), '']
);
$done++;
} catch (\Throwable $e) {
$this->flash('升级失败:' . e($e->getMessage()), 'err');
$this->redirect('admin/upgrade');
return '';
}
}
$this->flash($done > 0 ? "已批量升级 {$done} 个升级包" : '没有需要升级的包', $done > 0 ? 'ok' : 'err');
$this->redirect('admin/upgrade');
return '';
}
/** 基础数据/表结构初始化(保留旧版能力:补齐新模块表与种子) */
public function init()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('admin/upgrade'); return ''; }
admin_required();
$this->requireSuper();
if (!csrf_check()) {
$this->flash('表单已过期,请刷新后重试', 'err');
$this->redirect('admin/upgrade');
return '';
}
$msgs = Installer::upgrade();
$this->flash('基础数据升级完成 ✓ ' . implode('', $msgs), 'ok');
$this->redirect('admin/upgrade');
return '';
}
/* ---------------- 工具 ---------------- */
private function requireSuper(): void
{
if (!is_admin() || admin_role() !== 'super_admin') {
App::forbidden('仅超级管理员可执行数据库升级');
}
}
private function scan(): array
{
$d = $this->dir();
return is_dir($d) ? (glob($d . '/*.sql') ?: []) : [];
}
private function appliedMap(): array
{
try {
return Db::query("SELECT file, hash FROM db_upgrades")->fetchAll(\PDO::FETCH_KEY_PAIR);
} catch (\Throwable $e) {
return [];
}
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\Controller;
use App\Models\AdminUser;
/** 用户管理(仅超级管理员可访问,路由层已拦截) */
class UserController extends Controller
{
protected $layout = 'layouts/admin';
public function __construct()
{
admin_required();
role_required('super_admin');
}
private function model(): AdminUser
{
return new AdminUser();
}
public function index()
{
$users = $this->model()->all();
return $this->view('admin/users', ['users' => $users, 'error' => '', 'ok' => '']);
}
public function create()
{
$p = $this->defaultPermsPair();
return $this->view('admin/user_form', [
'user' => null,
'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'],
'error' => '', 'ok' => '',
]);
}
public function store()
{
if (!csrf_check()) {
$p = $this->defaultPermsPair();
return $this->view('admin/user_form', ['user' => null, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => '表单已过期,请重试', 'ok' => '']);
}
$username = trim($this->post('username'));
$name = trim($this->post('name'));
$role = $this->post('role');
$password = $this->post('password');
$status = $this->post('status') ? 1 : 0;
$err = $this->validate($username, $role, $password);
if ($err) { $p = $this->defaultPermsPair(); return $this->view('admin/user_form', ['user' => null, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => $err, 'ok' => '']); }
if ($this->model()->byUsername($username)) {
$p = $this->defaultPermsPair();
return $this->view('admin/user_form', ['user' => null, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => '该账号已存在', 'ok' => '']);
}
$this->model()->insert([
'username' => $username,
'name' => $name ?: $username,
'password' => password_hash($password, PASSWORD_DEFAULT),
'role' => $role,
'crm_role' => $this->post('crm_role') ?: 'none',
'psi_role' => $this->post('psi_role') ?: 'none',
'crm_perms' => json_encode($this->collectPerms('crm', (array)($this->post('crm_pages') ?: [])), JSON_UNESCAPED_UNICODE),
'psi_perms' => json_encode($this->collectPerms('psi', (array)($this->post('psi_pages') ?: [])), JSON_UNESCAPED_UNICODE),
'status' => $status,
'created_at' => date('Y-m-d'),
]);
$this->redirect('admin/users');
}
public function edit($id)
{
$user = $this->model()->find($id);
if (!$user) return $this->redirect('admin/users');
$p = $this->userPerms($user);
return $this->view('admin/user_form', ['user' => $user, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => '', 'ok' => '']);
}
public function update($id)
{
$user = $this->model()->find($id);
if (!$user) return $this->redirect('admin/users');
if (!csrf_check()) {
$p = $this->userPerms($user);
return $this->view('admin/user_form', ['user' => $user, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => '表单已过期,请重试', 'ok' => '']);
}
$username = trim($this->post('username'));
$name = trim($this->post('name'));
$role = $this->post('role');
$password = $this->post('password');
$status = $this->post('status') ? 1 : 0;
$err = $this->validate($username, $role, $password, true);
if ($err) { $p = $this->userPerms($user); return $this->view('admin/user_form', ['user' => $user, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => $err, 'ok' => '']); }
if ($username !== $user['username'] && $this->model()->byUsername($username)) {
$p = $this->userPerms($user);
return $this->view('admin/user_form', ['user' => $user, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => '该账号已存在', 'ok' => '']);
}
$data = [
'username' => $username,
'name' => $name ?: $username,
'role' => $role,
'crm_role' => $this->post('crm_role') ?: 'none',
'psi_role' => $this->post('psi_role') ?: 'none',
'crm_perms' => json_encode($this->collectPerms('crm', (array)($this->post('crm_pages') ?: [])), JSON_UNESCAPED_UNICODE),
'psi_perms' => json_encode($this->collectPerms('psi', (array)($this->post('psi_pages') ?: [])), JSON_UNESCAPED_UNICODE),
'status' => $status,
];
if ($password !== '') {
$data['password'] = password_hash($password, PASSWORD_DEFAULT);
}
$this->model()->update($id, $data);
$this->redirect('admin/users');
}
public function destroy($id)
{
if (admin_uid() == $id) return $this->redirect('admin/users'); // 不能删除自己
$this->model()->delete($id);
$this->redirect('admin/users');
}
/** 超级管理员重置他人密码(自己重置走修改密码页) */
public function reset($id)
{
$user = $this->model()->find($id);
if (!$user) return $this->redirect('admin/users');
if (admin_uid() == $id) return $this->redirect('admin/password');
$new = $this->genPassword();
$this->model()->update($id, ['password' => password_hash($new, PASSWORD_DEFAULT)]);
$p = $this->userPerms($user);
return $this->view('admin/user_form', [
'user' => $user,
'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'],
'error' => '',
'ok' => '已重置密码为:<b>' . e($new) . '</b>(请尽快通知对方修改)',
]);
}
private function defaultPermsPair(): array
{
return ['crm' => $this->defaultPerms('crm'), 'psi' => $this->defaultPerms('psi')];
}
/** 从用户记录解码已保存的页面权限(无记录则默认全部可见) */
private function userPerms($user): array
{
$crm = $this->defaultPerms('crm');
$psi = $this->defaultPerms('psi');
if (!empty($user['crm_perms'])) { $d = @json_decode($user['crm_perms'], true); if (is_array($d)) $crm = $d; }
if (!empty($user['psi_perms'])) { $d = @json_decode($user['psi_perms'], true); if (is_array($d)) $psi = $d; }
return ['crm' => $crm, 'psi' => $psi];
}
private function defaultPerms(string $sys): array
{
$out = [];
foreach (\subsys_pages($sys) as $p) { if ($p !== 'dashboard') $out[$p] = true; }
return $out;
}
private function collectPerms(string $sys, array $checked): array
{
$out = [];
foreach (\subsys_pages($sys) as $p) { if ($p === 'dashboard') continue; $out[$p] = in_array($p, $checked, true); }
return $out;
}
private function validate($username, $role, $password, $isEdit = false): string
{
if ($username === '' || !preg_match('/^[a-zA-Z0-9_]{3,30}$/', $username)) {
return '账号须为 3-30 位字母/数字/下划线';
}
if (!in_array($role, ['super_admin', 'admin', 'user', 'none'], true)) {
return '角色不合法';
}
if (!$isEdit && strlen($password) < 6) {
return '密码至少 6 位';
}
return '';
}
private function genPassword(): string
{
$chars = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$s = '';
for ($i = 0; $i < 10; $i++) {
$s .= $chars[random_int(0, strlen($chars) - 1)];
}
return $s;
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Controllers\CRM;
use App\Controllers\Controller;
use App\Models\CRM\Contact;
use App\Models\CRM\Customer;
/** 客户联系人(一个客户下可多人)。super_admin / crm_admin 可增删改;crm_user 仅查看。 */
class ContactsController extends Controller
{
private function nav(): array
{
return [
['k' => 'dashboard', 'label' => '仪表盘', 'icon' => '📊', 'url' => 'CRM'],
['k' => 'customers', 'label' => '客户管理', 'icon' => '🤝', 'url' => 'CRM/customers'],
['k' => 'leads', 'label' => '商机线索', 'icon' => '💡', 'url' => 'CRM/leads'],
['k' => 'followups', 'label' => '跟进记录', 'icon' => '📞', 'url' => 'CRM/followups'],
['k' => 'contacts', 'label' => '客户联系人', 'icon' => '👥', 'url' => 'CRM/contacts'],
];
}
/** 列表:/CRM/contacts(全部)或 /CRM/contacts?customer_id=ID(某客户) */
public function index($customerId = null)
{
$customerId = $customerId ? (int)$customerId : (int)($_GET['customer_id'] ?? 0);
$customer = null;
if ($customerId) {
$customer = (new Customer())->find($customerId);
$contacts = (new Contact())->where('customer_id', $customerId);
} else {
$contacts = $this->allWithCustomer();
}
return $this->renderSubsys('crm', 'crm/contacts', [
'contacts' => $contacts,
'customer' => $customer,
], $this->nav(), 'contacts');
}
public function create()
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
$preId = (int)($_GET['customer_id'] ?? 0);
$customers = (new Customer())->all();
return $this->renderSubsys('crm', 'crm/contact_form', [
'contact' => null,
'customers' => $customers,
'preId' => $preId,
], $this->nav(), 'contacts');
}
public function store()
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
if (!csrf_check()) return $this->redirect('CRM/contacts');
$cid = (int)$this->post('customer_id');
$data = $this->collect();
(new Contact())->insert($data);
return $this->redirect($cid ? "CRM/contacts?customer_id={$cid}" : 'CRM/contacts');
}
public function edit($id)
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
$contact = (new Contact())->find($id);
if (!$contact) return $this->redirect('CRM/contacts');
$customers = (new Customer())->all();
return $this->renderSubsys('crm', 'crm/contact_form', [
'contact' => $contact,
'customers' => $customers,
'preId' => (int)($contact['customer_id'] ?? 0),
], $this->nav(), 'contacts');
}
public function update($id)
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
if (!csrf_check()) return $this->redirect('CRM/contacts');
$contact = (new Contact())->find($id);
if (!$contact) return $this->redirect('CRM/contacts');
$cid = (int)$this->post('customer_id');
(new Contact())->update($id, $this->collect());
return $this->redirect($cid ? "CRM/contacts?customer_id={$cid}" : 'CRM/contacts');
}
public function destroy($id)
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
$contact = (new Contact())->find($id);
$cid = $contact ? (int)($contact['customer_id'] ?? 0) : 0;
(new Contact())->delete($id);
return $this->redirect($cid ? "CRM/contacts?customer_id={$cid}" : 'CRM/contacts');
}
private function collect(): array
{
return [
'customer_id' => (int)$this->post('customer_id'),
'name' => trim($this->post('name')),
'title' => trim($this->post('title')),
'phone' => trim($this->post('phone')),
'email' => trim($this->post('email')),
'wechat' => trim($this->post('wechat')),
'is_primary' => $this->post('is_primary') ? 1 : 0,
'remark' => trim($this->post('remark')),
'created_at' => date('Y-m-d'),
];
}
/** 全部联系人 + 客户名称(用于「全部联系人」视图) */
private function allWithCustomer(): array
{
try {
$rows = \Core\Db::query(
"SELECT c.*, cu.name AS customer_name FROM crm_contacts c
LEFT JOIN crm_customers cu ON cu.id=c.customer_id
ORDER BY c.customer_id, c.id"
)->fetchAll();
return $rows;
} catch (\Throwable $e) {
return (new Contact())->all();
}
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace App\Controllers\CRM;
use App\Controllers\Controller;
use App\Models\CRM\Customer;
/** 客户管理(CRM 系统)。super_admin / crm_admin 可增删改;crm_user 仅查看。 */
class CustomersController extends Controller
{
private function nav(): array
{
return [
['k' => 'dashboard', 'label' => '仪表盘', 'icon' => '📊', 'url' => 'CRM'],
['k' => 'customers', 'label' => '客户管理', 'icon' => '🤝', 'url' => 'CRM/customers'],
['k' => 'leads', 'label' => '商机线索', 'icon' => '💡', 'url' => 'CRM/leads'],
['k' => 'followups', 'label' => '跟进记录', 'icon' => '📞', 'url' => 'CRM/followups'],
];
}
public function index()
{
$customers = (new Customer())->all();
$contactCounts = $this->contactCounts();
return $this->renderSubsys('crm', 'crm/customers', [
'customers' => $customers,
'contactCounts' => $contactCounts,
], $this->nav(), 'customers');
}
/** 每个客户的联系人数量(键=customer_id */
private function contactCounts(): array
{
$out = [];
try {
$rows = \Core\Db::query("SELECT customer_id, COUNT(*) AS n FROM crm_contacts GROUP BY customer_id");
foreach ($rows->fetchAll() as $r) { $out[(int)$r['customer_id']] = (int)$r['n']; }
} catch (\Throwable $e) {}
return $out;
}
public function create()
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
return $this->renderSubsys('crm', 'crm/customer_form', ['customer' => null], $this->nav(), 'customers');
}
public function store()
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
if (!csrf_check()) { return $this->renderSubsys('crm', 'crm/customer_form', ['customer' => null], $this->nav(), 'customers'); }
$data = $this->collect();
(new Customer())->insert($data);
return $this->redirect('CRM/customers');
}
public function edit($id)
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
$customer = (new Customer())->find($id);
if (!$customer) return $this->redirect('CRM/customers');
return $this->renderSubsys('crm', 'crm/customer_form', ['customer' => $customer], $this->nav(), 'customers');
}
public function update($id)
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
if (!csrf_check()) return $this->redirect('CRM/customers');
$customer = (new Customer())->find($id);
if (!$customer) return $this->redirect('CRM/customers');
(new Customer())->update($id, $this->collect());
return $this->redirect('CRM/customers');
}
public function destroy($id)
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
(new Customer())->delete($id);
return $this->redirect('CRM/customers');
}
private function collect(): array
{
return [
'name' => trim($this->post('name')),
'company' => trim($this->post('company')),
'contact' => trim($this->post('contact')),
'phone' => trim($this->post('phone')),
'email' => trim($this->post('email')),
'country' => trim($this->post('country')),
'type' => $this->post('type') ?: 'brand',
'source' => trim($this->post('source')),
'level' => $this->post('level') ?: 'C',
'customer_no' => trim($this->post('customer_no')),
'industry' => trim($this->post('industry')),
'region' => trim($this->post('region')),
'credit_limit'=> (float)$this->post('credit_limit'),
'status' => $this->post('status') ?: 'lead',
'remark' => trim($this->post('remark')),
'owner' => trim($this->post('owner')) ?: ($_SESSION['admin_name'] ?? ''),
'created_at' => date('Y-m-d'),
];
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
namespace App\Controllers\CRM;
use App\Controllers\Controller;
use App\Models\CRM\Customer;
use App\Models\CRM\Lead;
use App\Models\CRM\FollowUp;
/**
* CRM 入口与子路由
* 权限:super_admin 或 crm_role ∈ {admin,user}(路由层已拦截)。
*/
class DashboardController extends Controller
{
public function nav(string $seg): array
{
return [
['k' => 'dashboard', 'label' => '仪表盘', 'icon' => '📊', 'url' => 'CRM'],
['k' => 'customers', 'label' => '客户管理', 'icon' => '🤝', 'url' => 'CRM/customers'],
['k' => 'leads', 'label' => '商机线索', 'icon' => '💡', 'url' => 'CRM/leads'],
['k' => 'followups', 'label' => '跟进记录', 'icon' => '📞', 'url' => 'CRM/followups'],
['k' => 'contacts', 'label' => '客户联系人', 'icon' => '👥', 'url' => 'CRM/contacts'],
];
}
/** 统一子路由入口 */
public function dispatch(array $s)
{
$res = $s[0] ?? 'dashboard';
$action = $s[1] ?? '';
$id = $s[2] ?? null;
// 仪表盘始终可进
if ($res === 'dashboard') {
return $this->dashboard();
}
// 用户管理(仅该系统管理员):统一管理本系统用户及其页面权限
if ($res === 'users') {
if (!\subsys_admin('crm')) {
\Core\App::forbidden('需要 CRM 管理员权限');
return;
}
$uc = new \App\Controllers\CRM\UsersController();
if ($action === '' || $action === 'index') return $uc->index();
if ($action === 'create') return $uc->create();
if ($action === 'store') return $uc->store();
if ($action === 'edit') return $uc->edit($id);
if ($action === 'update') return $uc->update($id);
if ($action === 'destroy') return $uc->destroy($id);
if ($action === 'reset') return $uc->reset($id);
\Core\App::notFound('未知操作: ' . $action);
return;
}
$map = [
'customers' => 'CustomersController',
'leads' => 'LeadsController',
'followups' => 'FollowUpsController',
'contacts' => 'ContactsController',
];
if (!isset($map[$res])) {
\Core\App::notFound('未知页面: ' . $res);
return;
}
// 页面级权限:仪表盘始终可进,其余页面按分系统「页面可见权限」拦截
if (!\subsys_page_can('crm', $res)) {
\Core\App::forbidden('您没有访问该页面的权限');
return;
}
// 资源子操作路由:/CRM/{resource}[/{action}[/{id}]]
$method = $this->resolveSubsysAction($action);
if ($method === null) {
\Core\App::notFound('未知操作: ' . $action);
return;
}
$class = 'App\\Controllers\\CRM\\' . $map[$res];
$instance = new $class();
if (!method_exists($instance, $method)) {
\Core\App::notFound('操作不存在: ' . $method);
return;
}
return $instance->$method($id);
}
/** 将 URL 动作段解析为控制器方法名;不支持的动作返回 null */
private function resolveSubsysAction(string $action): ?string
{
$verbs = [
'' => 'index',
'index' => 'index',
'create' => 'create',
'store' => 'store',
'edit' => 'edit',
'update' => 'update',
'destroy' => 'destroy',
];
return $verbs[$action] ?? null;
}
public function dashboard()
{
$customers = (new Customer())->all();
$leads = (new Lead())->all();
$follows = (new FollowUp())->all();
$stageCount = [];
foreach ($leads as $l) {
$stageCount[$l['stage'] ?? 'new'] = ($stageCount[$l['stage'] ?? 'new'] ?? 0) + 1;
}
$amountTotal = array_sum(array_map(fn($l) => (float)($l['amount'] ?? 0), $leads));
$typeCount = [];
foreach ($customers as $c) {
$typeCount[$c['type'] ?? 'trade'] = ($typeCount[$c['type'] ?? 'trade'] ?? 0) + 1;
}
$recent = array_slice(array_reverse($follows), 0, 8);
$recentCustomers = array_slice(array_reverse($customers), 0, 8);
return $this->renderSubsys('crm', 'crm/dashboard', [
'customerTotal' => count($customers),
'leadTotal' => count($leads),
'followTotal' => count($follows),
'amountTotal' => $amountTotal,
'stageCount' => $stageCount,
'typeCount' => $typeCount,
'recent' => $recent,
'recentCustomers'=> $recentCustomers,
], $this->nav('dashboard'), 'dashboard');
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Controllers\CRM;
use App\Controllers\Controller;
use App\Models\CRM\FollowUp;
use App\Models\CRM\Customer;
/** 跟进记录(CRM 系统) */
class FollowUpsController extends Controller
{
private function nav(): array
{
return [
['k' => 'dashboard', 'label' => '仪表盘', 'icon' => '📊', 'url' => 'CRM'],
['k' => 'customers', 'label' => '客户管理', 'icon' => '🤝', 'url' => 'CRM/customers'],
['k' => 'leads', 'label' => '商机线索', 'icon' => '💡', 'url' => 'CRM/leads'],
['k' => 'followups', 'label' => '跟进记录', 'icon' => '📞', 'url' => 'CRM/followups'],
];
}
public function index()
{
$follows = (new FollowUp())->all();
$customers = (new Customer())->all();
$cmap = [];
foreach ($customers as $c) { $cmap[$c['id']] = $c['name']; }
return $this->renderSubsys('crm', 'crm/followups', ['follows' => $follows, 'cmap' => $cmap], $this->nav(), 'followups');
}
public function create()
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
$customers = (new Customer())->all();
return $this->renderSubsys('crm', 'crm/followup_form', ['follow' => null, 'customers' => $customers], $this->nav(), 'followups');
}
public function store()
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
if (!csrf_check()) return $this->redirect('CRM/followups');
(new FollowUp())->insert($this->collect());
return $this->redirect('CRM/followups');
}
public function edit($id)
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
$follow = (new FollowUp())->find($id);
if (!$follow) return $this->redirect('CRM/followups');
$customers = (new Customer())->all();
return $this->renderSubsys('crm', 'crm/followup_form', ['follow' => $follow, 'customers' => $customers], $this->nav(), 'followups');
}
public function update($id)
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
if (!csrf_check()) return $this->redirect('CRM/followups');
$follow = (new FollowUp())->find($id);
if (!$follow) return $this->redirect('CRM/followups');
(new FollowUp())->update($id, $this->collect());
return $this->redirect('CRM/followups');
}
public function destroy($id)
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
(new FollowUp())->delete($id);
return $this->redirect('CRM/followups');
}
private function collect(): array
{
return [
'customer_id' => (int)$this->post('customer_id'),
'lead_id' => (int)$this->post('lead_id'),
'content' => trim($this->post('content')),
'next_at' => trim($this->post('next_at')),
'way' => trim($this->post('way')),
'result' => trim($this->post('result')),
'owner' => trim($this->post('owner')) ?: ($_SESSION['admin_name'] ?? ''),
'created_at' => date('Y-m-d'),
];
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Controllers\CRM;
use App\Controllers\Controller;
use App\Models\CRM\Lead;
use App\Models\CRM\Customer;
/** 商机/线索管理(CRM 系统) */
class LeadsController extends Controller
{
private function nav(): array
{
return [
['k' => 'dashboard', 'label' => '仪表盘', 'icon' => '📊', 'url' => 'CRM'],
['k' => 'customers', 'label' => '客户管理', 'icon' => '🤝', 'url' => 'CRM/customers'],
['k' => 'leads', 'label' => '商机线索', 'icon' => '💡', 'url' => 'CRM/leads'],
['k' => 'followups', 'label' => '跟进记录', 'icon' => '📞', 'url' => 'CRM/followups'],
];
}
public function index()
{
$leads = (new Lead())->all();
$customers = (new Customer())->all();
$cmap = [];
foreach ($customers as $c) { $cmap[$c['id']] = $c['name']; }
return $this->renderSubsys('crm', 'crm/leads', ['leads' => $leads, 'cmap' => $cmap], $this->nav(), 'leads');
}
public function create()
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
$customers = (new Customer())->all();
return $this->renderSubsys('crm', 'crm/lead_form', ['lead' => null, 'customers' => $customers], $this->nav(), 'leads');
}
public function store()
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
if (!csrf_check()) return $this->redirect('CRM/leads');
(new Lead())->insert($this->collect());
return $this->redirect('CRM/leads');
}
public function edit($id)
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
$lead = (new Lead())->find($id);
if (!$lead) return $this->redirect('CRM/leads');
$customers = (new Customer())->all();
return $this->renderSubsys('crm', 'crm/lead_form', ['lead' => $lead, 'customers' => $customers], $this->nav(), 'leads');
}
public function update($id)
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
if (!csrf_check()) return $this->redirect('CRM/leads');
$lead = (new Lead())->find($id);
if (!$lead) return $this->redirect('CRM/leads');
(new Lead())->update($id, $this->collect());
return $this->redirect('CRM/leads');
}
public function destroy($id)
{
subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
(new Lead())->delete($id);
return $this->redirect('CRM/leads');
}
private function collect(): array
{
return [
'customer_id' => (int)$this->post('customer_id'),
'title' => trim($this->post('title')),
'amount' => (float)$this->post('amount'),
'stage' => $this->post('stage') ?: 'new',
'expected_close' => trim($this->post('expected_close')),
'source' => trim($this->post('source')),
'probability' => (int)$this->post('probability'),
'owner' => trim($this->post('owner')) ?: ($_SESSION['admin_name'] ?? ''),
'remark' => trim($this->post('remark')),
'created_at' => date('Y-m-d'),
];
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Controllers\CRM;
use App\Controllers\Subsys\UsersController as BaseUsersController;
class UsersController extends BaseUsersController
{
protected function sys(): string
{
return 'crm';
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Controllers;
use App\Models\CustomerCase;
/**
* 前台「客户案例」:列表 + 详情,与新闻前台同构。
*/
class CaseController extends Controller
{
public function index()
{
$case = new CustomerCase();
$seo = page_seo('cases', [
'title' => '客户案例',
'description' => '酷冰甲降温服客户案例展示,覆盖消防、电力、钢铁、环卫、户外施工、车间制造等高温作业场景的真实合作项目,逐一呈现降温方案设计思路、现场使用效果与客户真实反馈,并附上适用行业与选型建议,为同类企业的高温防护升级提供可参考、可复用的实战样本,切实降低高温作业风险。',
'keywords' => '降温服案例,客户案例,高温作业,降温方案,消防降温,工业应用,酷冰甲案例',
'og_type' => 'website',
]);
return $this->view('cases/index', [
'pageSeo' => [
'title' => $seo['title'],
'description' => $seo['description'],
'keywords' => $seo['keywords'],
'og_type' => $seo['og_type'] ?: 'website',
'og_image' => $seo['og_image'],
'canonical' => $seo['canonical'],
'noindex' => $seo['noindex'],
'breadcrumb' => [
['name' => '首页', 'url' => site_url()],
['name' => '客户案例', 'url' => absolute_url()],
],
],
'cases' => $case->published(20),
]);
}
public function show($slug)
{
$case = new CustomerCase();
$c = $case->where('slug', $slug);
if (!$c && is_numeric($slug)) { $c = $case->find((int)$slug); }
if (!$c) { \Core\App::notFound(); return ''; }
// 浏览量 +1
$case->update($c['id'], ['views' => ($c['views'] ?? 0) + 1]);
$all = $case->published(20);
$idx = array_search($c, $all);
$prev = $idx !== false && $idx > 0 ? $all[$idx - 1] : null;
$next = $idx !== false && $idx < count($all) - 1 ? $all[$idx + 1] : null;
$cTitle = e($c['title'] ?? '案例详情');
$cSummary = mb_substr(strip_tags($c['summary'] ?? $c['body'] ?? ''), 0, 160);
$cImage = $c['image'] ?? '';
$publishedAt = $c['created_at'] ?? $c['published_at'] ?? date('Y-m-d');
// ── Article JSON-LD Schema(客户案例)────
$articleSchema = '<script type="application/ld+json">' . json_encode([
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => $c['title'] ?? '',
'description' => $cSummary,
'image' => $cImage,
'datePublished' => $publishedAt,
'dateModified' => $c['updated_at'] ?? $publishedAt,
'author' => ['@type' => 'Organization', 'name' => '酷冰甲'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . '</script>';
return $this->view('cases/show', [
'pageSeo' => [
'title' => $cTitle,
'description' => $cSummary,
'og_type' => 'article',
'og_image' => $cImage,
'breadcrumb' => [
['name' => '首页', 'url' => site_url()],
['name' => '客户案例', 'url' => site_url('cases')],
['name' => $c['title'] ?? '案例', 'url' => absolute_url()],
],
'jsonld' => $articleSchema,
],
'c' => $c,
'prev' => $prev,
'next' => $next,
]);
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Controllers;
use Core\Db;
class ContactController extends Controller
{
public function index()
{
$sent = false;
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
// 蜜罐:机器人常会填写隐藏字段,正常用户看不到也不会填
if (trim((string)$this->post('website')) !== '') {
ip_rate_register($ip, 'contact', 900); // 仍计入限速窗口,避免探测
$sent = true; // 静默当作成功,避免机器人得知被拦截
} elseif (ip_rate_blocked($ip, 'contact', 5, 900)) {
$error = '提交过于频繁,请 15 分钟后再试。';
} else {
ip_rate_register($ip, 'contact', 900); // 真实提交尝试计入限速窗口(含后续校验失败)
if (!csrf_check()) {
$error = '表单已过期,请重试。';
} elseif (!captcha_check($this->post('captcha'))) {
$error = '验证码错误,请重新计算。';
} else {
$name = trim($this->post('name'));
$phone = trim($this->post('phone'));
$msg = trim($this->post('message'));
// 服务端校验:长度与联系电话格式(防垃圾/注入)
if (mb_strlen($name) < 2 || mb_strlen($name) > 40) {
$error = '请填写有效的姓名(2-40 字)。';
} elseif (!preg_match('/^[0-9+\-\s]{5,20}$/', $phone)) {
$error = '请填写有效的联系电话(5-20 位)。';
} elseif (mb_strlen($msg) < 5 || mb_strlen($msg) > 1000) {
$error = '请填写需求描述(5-1000 字)。';
} else {
$this->saveLead(compact('name', 'phone', 'msg') + ['at' => date('Y-m-d H:i:s')]);
$sent = true;
}
}
}
}
$seo = page_seo('contact', [
'title' => '联系我们',
'description' => '联系酷冰甲,获取降温服定制方案与专属报价。我们支持企业批量采购、LOGO刺绣、尺寸与面料定制,提供在线咨询、电话与邮件多种沟通方式。7天打样、全国发货,专业团队一对一对接您的高温防护需求,从选型到交付全程跟进,确保交付准时可靠,让合作更省心、更可靠。',
'keywords' => '联系酷冰甲,降温服定制,降温服报价,降温服采购,企业定制,降温服厂家,酷冰甲联系',
'og_type' => 'website',
]);
$captcha = captcha_make();
return $this->view('contact/index', [
'pageSeo' => [
'title' => $seo['title'],
'description' => $seo['description'],
'keywords' => $seo['keywords'],
'og_type' => $seo['og_type'] ?: 'website',
'og_image' => $seo['og_image'],
'canonical' => $seo['canonical'],
'noindex' => $seo['noindex'],
'breadcrumb' => [
['name' => '首页', 'url' => site_url()],
['name' => '联系我们', 'url' => absolute_url()],
],
],
'sent' => $sent,
'error' => $error,
'captcha' => $captcha,
]);
}
private function saveLead(array $data): void
{
if (Db::driver() !== 'file') return; // MySQL 模式可由后台扩展
$file = Db::fileDir() . '/leads.json';
$rows = is_file($file) ? json_decode(file_get_contents($file), true) ?: [] : [];
$rows[] = $data;
file_put_contents($file, json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Controllers;
use Core\View;
class Controller
{
protected $layout = 'layouts/site';
protected function view(string $view, array $data = []): string
{
return View::make($view, $data, $this->layout);
}
protected function redirect(string $url)
{
header('Location: ' . site_url($url));
exit;
}
protected function back()
{
$this->redirect($_SERVER['HTTP_REFERER'] ?? '');
}
protected function json($data, int $code = 200)
{
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
/** 一次性会话消息(成功 ok / 错误 err),布局模板自动渲染 */
protected function flash(string $msg, string $type = 'err'): void
{
$_SESSION['flash'] = ['msg' => $msg, 'type' => $type];
}
protected function get(string $key, $default = '')
{
return $_GET[$key] ?? $default;
}
protected function post(string $key, $default = '')
{
return $_POST[$key] ?? $default;
}
/**
* 分系统(CRM / PSI)统一后台渲染:左侧导航 + 顶栏 + 内容区。
* 与 layouts/admin.php 同源规范,保证团队多系统体验一致、可维护。
* @param string $sys crm | psi
* @param string $view 视图名(如 crm/dashboard
* @param array $nav 子系统导航项 [['k'=>, 'label'=>, 'icon'=>, 'url'=>]]
* @param string $seg 当前激活导航 key
*/
protected function renderSubsys(string $sys, string $view, array $data, array $nav, string $seg): string
{
$data['_sys'] = $sys;
$data['_nav'] = $nav;
$data['_seg'] = $seg;
$data['_home'] = $sys; // 子系统首页路由段
return View::make($view, $data, 'layouts/subsys');
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Controllers;
use App\Models\Banner;
use App\Models\Category;
use App\Models\Product;
use App\Models\News;
use App\Models\CustomerCase;
class HomeController extends Controller
{
public function index()
{
$banner = new Banner();
$category = new Category();
$product = new Product();
$news = new News();
$seo = page_seo('home', [
'title' => '酷冰甲降温服官网 | 科技降温服定制·水冷循环·相变蓄冷·风冷背心·10套起订',
'description' => '酷冰甲专注降温服的研发、生产与定制,提供水冷循环、相变蓄冷、风冷制冷、冰袋背心等多系列降温装备,广泛适用于消防、工业、电力、钢铁、环卫及户外高温作业场景。支持企业LOGO刺绣、尺寸与面料定制,10套起订,7天打样,全国发货,为您提供一站式高温防护解决方案。',
'keywords' => '降温服,降温背心,水冷降温服,相变降温服,制冷背心,工业降温服,消防降温服,高温作业防护,降温服定制,酷冰甲',
'og_type' => 'website',
]);
// ── 首页 FAQ(可见文本 + FAQPage JSON-LDGEO 高杠杆信号)────
$faqs = [
['q' => '降温服是什么?它是怎么实现降温的?', 'a' => '降温服是一类为高温作业人群设计的主动或被动降温装备,主要通过三种原理散热:水冷循环(微型水泵驱动冷水在服装内管路循环带走体热)、相变蓄冷(冰袋或凝胶相变材料在融化过程中持续吸热)、涡扇风冷(小型风扇强制对流散热)。酷冰甲提供这三大系列,覆盖不同场景与续航需求。'],
['q' => '穿降温服体感能降多少度?多久能起效?', 'a' => '在常规高温环境下,合格降温服可让核心体表感温度下降约 8–12℃。水冷与风冷方案接通或开机后数分钟内即可感受到明显凉意;相变冰袋方案放入预冷冰袋后即刻生效,单组冰袋可持续 2–4 小时。'],
['q' => '降温服可以重复使用吗?一套能用多久?', 'a' => '可以。酷冰甲降温服主体为可水洗服装,水冷、风冷模块与相变冰袋均可反复使用。服装本体在正常保养下可用 2–3 个高温季,冰袋与电池模块按使用频率约 1–2 年更换即可。'],
['q' => '支持企业定制和 LOGO 刺绣吗?起订量多少?', 'a' => '支持。我们提供企业 LOGO 绣字、颜色与面料定制、一人一码量体服务。柔性化生产,10 套起订,确认图纸后 7 天打样、约 28 天批量交付,适合班组、车间等小批量统一配发。'],
['q' => '降温服适合哪些行业和场景?', 'a' => '广泛用于消防、钢铁、电力、化工、环卫、建筑、物流及户外军训等高温或暴晒场景,也适用于骑行、垂钓、观赛等个人户外降温。可按行业工况推荐对应系列与续航配置。'],
['q' => '降温服怎么清洗和保养?', 'a' => '服装本体可轻柔机洗或手洗,避免浸泡电子模块;水冷、风冷主机与电池需拆下后擦干存放,冰袋用后擦干冷藏。长期不用请置于阴凉干燥处,电池保持半电存放。'],
];
$faqJsonLd = '<script type="application/ld+json">' . json_encode([
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => array_map(fn($f) => [
'@type' => 'Question',
'name' => $f['q'],
'acceptedAnswer' => ['@type' => 'Answer', 'text' => $f['a']],
], $faqs),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . '</script>';
$data = [
'pageSeo' => [
'title' => $seo['title'],
'description' => $seo['description'],
'keywords' => $seo['keywords'],
'og_type' => $seo['og_type'] ?: 'website',
'og_image' => $seo['og_image'],
'canonical' => $seo['canonical'],
'noindex' => $seo['noindex'],
'jsonld' => $faqJsonLd,
],
'banners' => array_filter($banner->all(), fn($b) => ($b['status'] ?? 1) == 1),
'categories'=> $category->all(),
'products' => $product->featured(8),
'news' => $news->published(3),
'cases' => (new CustomerCase())->published(3),
'stats' => [
['n' => '20', 'u' => '年', 'l' => '服装定制经验'],
['n' => '6', 'u' => '大', 'l' => '降温产品系列'],
['n' => '10', 'u' => '套', 'l' => '起订柔性生产'],
['n' => '8', 'u' => '℃', 'l' => '体感直降'],
],
'advantages'=> [
['n' => '01', 't' => '柔性化生产', 'd' => '小单亦可定制,10 套起订,留足面辅料灵活补单。'],
['n' => '02', 't' => '量身打造', 'd' => '设计师结合企业文化与功能需求定向设计,5 天出方案。'],
['n' => '03', 't' => '一人一码', 'd' => '资深打版师打板、上门量体,高度还原设计稿,合身合体。'],
['n' => '04', 't' => '外贸级品质', 'd' => '156 道工序层层把控,欧美出口级标准出货。'],
],
'process' => [
['t' => '需求沟通', 'd' => '了解行业、人群与场景,明确颜色款式与预算。'],
['t' => '上门量体', 'd' => '试样衣、量体,采集精准尺寸数据。'],
['t' => '设计款式', 'd' => '结合沟通结果量身设计降温服方案。'],
['t' => '批量生产', 'd' => '确认图纸后快速打版、批量生产。'],
['t' => '成衣交付', 'd' => '精心包装交付上门,启动售后服务。'],
],
'faqs' => $faqs,
];
return $this->view('home/index', $data);
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace App\Controllers;
use App\Models\News;
class NewsController extends Controller
{
public function index()
{
$news = new News();
$seo = page_seo('news', [
'title' => '新闻动态',
'description' => '酷冰甲降温服行业新闻中心,汇集高温防护政策解读、降温技术深度解析、产品应用案例、客户现场实录与行业前沿动态,持续分享降温服选型、使用、保养与清洗知识,帮助企业做好高温作业人员的健康与安全防护。我们关注每一次技术迭代,也记录每一处真实应用,让高温防护更有依据、更可落地。',
'keywords' => '降温服新闻,降温技术,高温防护,工业降温,降温服应用,行业动态,酷冰甲资讯,降温服知识',
'og_type' => 'website',
]);
return $this->view('news/index', [
'pageSeo' => [
'title' => $seo['title'],
'description' => $seo['description'],
'keywords' => $seo['keywords'],
'og_type' => $seo['og_type'] ?: 'website',
'og_image' => $seo['og_image'],
'canonical' => $seo['canonical'],
'noindex' => $seo['noindex'],
'breadcrumb' => [
['name' => '首页', 'url' => site_url()],
['name' => '新闻动态', 'url' => absolute_url()],
],
],
'news' => $news->published(20),
]);
}
public function show($slug)
{
$news = new News();
$n = $news->where('slug', $slug);
if (!$n && is_numeric($slug)) { $n = $news->find((int)$slug); }
if (!$n) { \Core\App::notFound(); return ''; }
// 阅读量 +1
$news->update($n['id'], ['views' => ($n['views'] ?? 0) + 1]);
$all = $news->published(20);
$idx = array_search($n, $all);
$prev = $idx !== false && $idx > 0 ? $all[$idx - 1] : null;
$next = $idx !== false && $idx < count($all) - 1 ? $all[$idx + 1] : null;
$nTitle = e($n['title'] ?? '文章详情');
$nSummary = mb_substr(strip_tags($n['summary'] ?? $n['body'] ?? ''), 0, 160);
$nImage = $n['image'] ?? '';
$publishedAt = $n['created_at'] ?? $n['published_at'] ?? date('Y-m-d');
// ── Article JSON-LD Schema ──
$articleSchema = '<script type="application/ld+json">' . json_encode([
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => $n['title'] ?? '',
'description' => $nSummary,
'image' => $nImage,
'datePublished' => $publishedAt,
'dateModified' => $n['updated_at'] ?? $publishedAt,
'author' => ['@type' => 'Organization', 'name' => '酷冰甲'],
] + ($prev ? ['mainEntityOfPage' => ['@type' => 'WebPage', '@id' => absolute_url()]] : []),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . '</script>';
return $this->view('news/show', [
'pageSeo' => [
'title' => $nTitle,
'description' => $nSummary,
'og_type' => 'article',
'og_image' => $nImage,
'breadcrumb' => [
['name' => '首页', 'url' => site_url()],
['name' => '新闻动态', 'url' => site_url('news')],
['name' => $n['title'] ?? '文章', 'url' => absolute_url()],
],
'jsonld' => $articleSchema,
],
'n' => $n,
'prev' => $prev,
'next' => $next,
]);
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace App\Controllers;
use App\Models\Product;
use App\Models\Order;
use App\Models\Payment;
use Core\Payment\GatewayFactory;
use Core\Payment\OrderService;
use Core\Notify;
/** 前台:下单 → 支付 → 查询 */
class OrderController extends Controller
{
public function checkout($slug)
{
$product = (new Product())->where('slug', $slug);
if (!$product) { \Core\App::notFound(); return ''; }
$err = isset($_GET['err']) ? '请填写姓名与手机号' : '';
return $this->view('order/checkout', ['p' => $product, 'slug' => $slug, 'err' => $err]);
}
public function store()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('products'); }
if (!csrf_check()) { $this->redirect('products'); }
$slug = $this->post('slug', '');
$product = (new Product())->where('slug', $slug);
if (!$product) { $this->redirect('products'); }
$name = trim($this->post('name', ''));
$phone = trim($this->post('phone', ''));
$email = trim($this->post('email', ''));
$qty = max(1, (int) $this->post('qty', 1));
if ($name === '' || $phone === '') {
$this->redirect('order/checkout/' . $slug . '?err=1');
}
$amount = round((float) $product['price'] * $qty, 2);
$orderNo = $this->genNo();
$oid = (new Order())->insert([
'order_no' => $orderNo,
'product_id' => $product['id'],
'product_name' => $product['name'],
'customer_name' => $name,
'phone' => $phone,
'email' => $email,
'qty' => $qty,
'amount' => $amount,
'channel' => '',
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
]);
Notify::newCustomerOrder($orderNo, $name, $phone, $oid);
$this->redirect('order/pay/' . $orderNo);
}
public function pay($orderNo)
{
$order = (new Order())->where('order_no', $orderNo);
if (!$order) { \Core\App::notFound(); return ''; }
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$channel = $this->post('channel', '');
if ($channel === 'alipay' || $channel === 'wechat') {
if ($order['channel'] !== $channel) {
(new Order())->update($order['id'], ['channel' => $channel]);
$order['channel'] = $channel;
}
}
}
if ($order['status'] === 'paid') {
return $this->view('order/pay', ['order' => $order, 'paid' => true]);
}
if (empty($order['channel'])) {
return $this->view('order/pay', ['order' => $order, 'choose' => true]);
}
$gw = GatewayFactory::make($order['channel']);
$res = $gw->pay($order);
return $this->view('order/pay', ['order' => $order, 'gw' => $res]);
}
/** 演示支付:模拟支付成功(默认模式可用,便于走通全流程) */
public function demo($orderNo)
{
$o = (new Order())->where('order_no', $orderNo);
$channel = ($o && $o['channel'] === 'wechat') ? 'wechat' : 'alipay';
OrderService::markPaid($orderNo, 'DEMO' . time(), $channel);
$this->redirect('order/success/' . $orderNo);
}
public function success($orderNo)
{
$order = (new Order())->where('order_no', $orderNo);
if (!$order) { \Core\App::notFound(); return ''; }
$payments = (new Payment())->whereAll('order_no', $orderNo);
return $this->view('order/result', ['order' => $order, 'payments' => $payments]);
}
/**
* 客户查询订单:客户名 + 手机号 双重校验
* - 必填:客户名(下单时填写的姓名/单位)+ 手机号
* - 选填:订单号(精确查单笔)
*/
public function query()
{
$orders = []; $no = ''; $name = ''; $phone = ''; $err = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
$no = trim($this->post('order_no', ''));
$name = trim($this->post('name', ''));
$phone = trim($this->post('phone', ''));
if ($name === '' || $phone === '') {
$err = '请输入客户名与手机号以核验身份';
} else {
$om = new Order();
$pm = new Payment();
$nameKey = mb_strtolower($name, 'UTF-8');
// 客户名 + 手机号 同时匹配
$matched = array_filter($om->all(), function ($o) use ($nameKey, $phone) {
$oName = mb_strtolower(trim($o['customer_name'] ?? ''), 'UTF-8');
$oPhone = trim($o['phone'] ?? '');
return $oName === $nameKey && $oPhone === $phone;
});
if ($no !== '') {
$matched = array_values(array_filter($matched, fn($o) => ($o['order_no'] ?? '') === $no));
}
if (empty($matched)) {
$err = $no !== ''
? '未找到该订单号对应的订单,请核对客户名与手机号'
: '未找到匹配的客户名与手机号对应的订单';
} else {
$orders = array_map(function ($o) use ($pm) {
return ['order' => $o, 'payments' => $pm->whereAll('order_no', $o['order_no'])];
}, $matched);
}
}
}
return $this->view('order/query', [
'orders' => $orders,
'no' => $no,
'name' => $name,
'phone' => $phone,
'err' => $err,
]);
}
private function genNo(): string
{
return 'SQY' . date('YmdHis') . str_pad((int) ((microtime(true) * 1000) % 1000), 3, '0', STR_PAD_LEFT);
}
}
+267
View File
@@ -0,0 +1,267 @@
<?php
namespace App\Controllers\PSI;
use App\Controllers\Controller;
use App\Models\PSI\Material;
use App\Models\PSI\Product;
use App\Models\PSI\Purchase;
use App\Models\PSI\Sales;
use App\Models\PSI\StockMove;
/** PSI 进销存入口与子路由。权限:super_admin 或 psi_role ∈ {admin,user} */
class DashboardController extends Controller
{
public function nav(string $seg): array
{
return \psi_nav();
}
public function dispatch(array $s)
{
$res = $s[0] ?? 'dashboard';
$action = $s[1] ?? '';
$id = $s[2] ?? null;
// 仪表盘始终可进
if ($res === 'dashboard') {
return $this->dashboard();
}
// 用户管理(仅该系统管理员):统一管理本系统用户及其页面权限
if ($res === 'users') {
if (!\subsys_admin('psi')) {
\Core\App::forbidden('需要 PSI 管理员权限');
return;
}
$uc = new \App\Controllers\PSI\UsersController();
if ($action === '' || $action === 'index') return $uc->index();
if ($action === 'create') return $uc->create();
if ($action === 'store') return $uc->store();
if ($action === 'edit') return $uc->edit($id);
if ($action === 'update') return $uc->update($id);
if ($action === 'destroy') return $uc->destroy($id);
if ($action === 'reset') return $uc->reset($id);
\Core\App::notFound('未知操作: ' . $action);
return;
}
// 订单管理:合并原「后台订单」到 PSI,方便管理人员在同一系统内快速处理
if ($res === 'orders') {
if (!\subsys_page_can('psi', 'orders')) {
\Core\App::forbidden('您没有访问订单管理的权限');
return;
}
$oc = new \App\Controllers\PSI\OrdersController();
if ($action === '' || $action === 'index') return $oc->index();
if ($action === 'show') return $oc->show($id);
if ($action === 'markPaid') return $oc->markPaid($id);
if ($action === 'destroy') return $oc->destroy($id);
\Core\App::notFound('未知操作: ' . $action);
return;
}
// 销售订单:录入 / 打印 / 关联出库
if ($res === 'sales_orders') {
if (!\subsys_page_can('psi', 'sales_orders')) { \Core\App::forbidden('您没有访问销售订单的权限'); return; }
$c = new \App\Controllers\PSI\SalesOrdersController();
if ($action === '' || $action === 'index') return $c->index();
if ($action === 'create') return $c->create();
if ($action === 'store') return $c->store();
if ($action === 'show') return $c->show($id);
if ($action === 'edit') return $c->edit($id);
if ($action === 'update') return $c->update($id);
if ($action === 'destroy') return $c->destroy($id);
if ($action === 'print') return $c->printDoc($id);
\Core\App::notFound('未知操作: ' . $action); return;
}
// 采购订单:录入 / 打印 / 收货入库
if ($res === 'purchase_orders') {
if (!\subsys_page_can('psi', 'purchase_orders')) { \Core\App::forbidden('您没有访问采购订单的权限'); return; }
$c = new \App\Controllers\PSI\PurchaseOrdersController();
if ($action === '' || $action === 'index') return $c->index();
if ($action === 'create') return $c->create();
if ($action === 'store') return $c->store();
if ($action === 'show') return $c->show($id);
if ($action === 'edit') return $c->edit($id);
if ($action === 'update') return $c->update($id);
if ($action === 'destroy') return $c->destroy($id);
if ($action === 'receive') return $c->receive($id);
if ($action === 'print') return $c->printDoc($id);
\Core\App::notFound('未知操作: ' . $action); return;
}
// 出库单:录入 / 打印(关联销售订单、扣减库存)
if ($res === 'outbounds') {
if (!\subsys_page_can('psi', 'outbounds')) { \Core\App::forbidden('您没有访问出库单的权限'); return; }
$c = new \App\Controllers\PSI\OutboundsController();
if ($action === '' || $action === 'index') return $c->index();
if ($action === 'create') return $c->create();
if ($action === 'store') return $c->store();
if ($action === 'show') return $c->show($id);
if ($action === 'edit') return $c->edit($id);
if ($action === 'update') return $c->update($id);
if ($action === 'destroy') return $c->destroy($id);
if ($action === 'print') return $c->printDoc($id);
\Core\App::notFound('未知操作: ' . $action); return;
}
// 报表中心:采购订单明细 / 销售·采购订单明细 / 交付明细
if ($res === 'reports') {
if (!\subsys_page_can('psi', 'reports')) { \Core\App::forbidden('您没有访问报表中心的权限'); return; }
$c = new \App\Controllers\PSI\ReportsController();
if ($action === '' || $action === 'index') return $c->index();
if ($action === 'poDetail') return $c->poDetail();
if ($action === 'soPo') return $c->soPo();
if ($action === 'delivery') return $c->delivery();
\Core\App::notFound('未知操作: ' . $action); return;
}
// 紧急提醒中心(所有 PSI 用户可见)
if ($res === 'reminders') {
$c = new \App\Controllers\PSI\RemindersController();
return $c->handle(array_slice($s, 1));
}
// 通知设置(仅 PSI 管理员,控制器内二次鉴权)
if ($res === 'notifications') {
$c = new \App\Controllers\PSI\NotificationsController();
return $c->handle(array_slice($s, 1));
}
// 销售出库(支持 show/print 查看与打印预览)
if ($res === 'sales') {
if (!\subsys_page_can('psi', 'sales')) { \Core\App::forbidden('您没有访问销售出库的权限'); return; }
$c = new \App\Controllers\PSI\SalesController();
if ($action === '' || $action === 'index') return $c->index();
if ($action === 'create') return $c->create();
if ($action === 'store') return $c->store();
if ($action === 'show') return $c->show($id);
if ($action === 'destroy') return $c->destroy($id);
if ($action === 'print') return $c->printDoc($id);
\Core\App::notFound('未知操作: ' . $action); return;
}
$map = [
'materials' => 'MaterialsController',
'products' => 'ProductsController',
'suppliers' => 'SuppliersController',
'purchases' => 'PurchasesController',
'stock' => 'StockController',
];
if (!isset($map[$res])) {
\Core\App::notFound('未知页面: ' . $res);
return;
}
// 页面级权限:按分系统「页面可见权限」拦截
if (!\subsys_page_can('psi', $res)) {
\Core\App::forbidden('您没有访问该页面的权限');
return;
}
// 资源子操作路由:/PSI/{resource}[/{action}[/{id}]]
// 支持 index/create/store/edit/update/destroy/adjust
$method = $this->resolveSubsysAction($action);
if ($method === null) {
\Core\App::notFound('未知操作: ' . $action);
return;
}
$class = 'App\\Controllers\\PSI\\' . $map[$res];
$instance = new $class();
if (!method_exists($instance, $method)) {
\Core\App::notFound('操作不存在: ' . $method);
return;
}
return $instance->$method($id);
}
/** 将 URL 动作段解析为控制器方法名;不支持的动作返回 null */
private function resolveSubsysAction(string $action): ?string
{
$verbs = [
'' => 'index',
'index' => 'index',
'create' => 'create',
'store' => 'store',
'edit' => 'edit',
'update' => 'update',
'destroy' => 'destroy',
'adjust' => 'adjust',
];
return $verbs[$action] ?? null;
}
public function dashboard()
{
$materials = (new Material())->all();
$products = (new Product())->all();
$purchases = (new Purchase())->all();
$sales = (new Sales())->all();
$matStock = array_sum(array_map(fn($m) => (float)($m['stock'] ?? 0), $materials));
$prodStock = array_sum(array_map(fn($p) => (float)($p['stock'] ?? 0), $products));
$purchaseAmt = array_sum(array_map(fn($p) => (float)($p['amount'] ?? 0), $purchases));
$salesAmt = array_sum(array_map(fn($s) => (float)($s['amount'] ?? 0), $sales));
$lowStock = [];
foreach ($materials as $m) { if ((float)($m['stock'] ?? 0) < 20) $lowStock[] = ['type' => '物料', 'item' => $m]; }
foreach ($products as $p) { if ((float)($p['stock'] ?? 0) < 20) $lowStock[] = ['type' => '成品', 'item' => $p]; }
$recentPurchases = array_slice(array_reverse($purchases), 0, 5);
$recentSales = array_slice(array_reverse($sales), 0, 5);
// 订单/出库业务指标(表未创建时静默降级)
$bySalesman = [];
$salesAmount = 0; $salesAmountMonth = 0;
$undelivered = ['count' => 0, 'amount' => 0];
$monthDeliveries = ['count' => 0, 'amount' => 0];
try {
$bySalesman = \Core\Db::query(
"SELECT o.salesman AS salesman, COUNT(DISTINCT o.id) AS orders,
COALESCE(SUM(i.amount),0) AS amount
FROM psi_sales_orders o
LEFT JOIN psi_sales_order_items i ON i.so_id=o.id
WHERE o.salesman <> '' AND o.status <> 'closed'
GROUP BY o.salesman ORDER BY amount DESC"
)->fetchAll();
$r = \Core\Db::query(
"SELECT COALESCE(SUM(i.amount),0) AS amt,
COALESCE(SUM(CASE WHEN MONTH(o.created_at)=MONTH(CURDATE()) AND YEAR(o.created_at)=YEAR(CURDATE()) THEN i.amount ELSE 0 END),0) AS amt_m
FROM psi_sales_orders o LEFT JOIN psi_sales_order_items i ON i.so_id=o.id
WHERE o.status <> 'closed'"
)->fetch();
$salesAmount = (float)($r['amt'] ?? 0);
$salesAmountMonth = (float)($r['amt_m'] ?? 0);
$u = \Core\Db::query(
"SELECT COUNT(*) AS c, COALESCE(SUM(i.amount),0) AS amt
FROM psi_sales_orders o LEFT JOIN psi_sales_order_items i ON i.so_id=o.id
WHERE o.status IN ('pending','partial')"
)->fetch();
$undelivered = ['count' => (int)($u['c'] ?? 0), 'amount' => (float)($u['amt'] ?? 0)];
$d = \Core\Db::query(
"SELECT COUNT(*) AS c, COALESCE(SUM(i.amount),0) AS amt
FROM psi_outbounds ob LEFT JOIN psi_outbound_items i ON i.ob_id=ob.id
WHERE MONTH(ob.created_at)=MONTH(CURDATE()) AND YEAR(ob.created_at)=YEAR(CURDATE())"
)->fetch();
$monthDeliveries = ['count' => (int)($d['c'] ?? 0), 'amount' => (float)($d['amt'] ?? 0)];
} catch (\Throwable $e) { /* 表未创建时不报错 */ }
return $this->renderSubsys('psi', 'psi/dashboard', [
'materialTotal' => count($materials),
'productTotal' => count($products),
'matStock' => $matStock,
'prodStock' => $prodStock,
'purchaseAmt' => $purchaseAmt,
'salesAmt' => $salesAmt,
'lowStock' => $lowStock,
'recentPurchases' => $recentPurchases,
'recentSales' => $recentSales,
'bySalesman' => $bySalesman,
'salesAmount' => $salesAmount,
'salesAmountMonth' => $salesAmountMonth,
'undelivered' => $undelivered,
'monthDeliveries' => $monthDeliveries,
], $this->nav('dashboard'), 'dashboard');
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Controllers\PSI;
use App\Controllers\Controller;
use App\Models\PSI\Material;
use App\Models\PSI\Supplier;
/** 物料管理(面料/辅料等) */
class MaterialsController extends Controller
{
use StockHelper;
public function index()
{
$materials = (new Material())->all();
$suppliers = (new Supplier())->all();
$smap = [];
foreach ($suppliers as $s) { $smap[$s['id']] = $s['name']; }
return $this->renderSubsys('psi', 'psi/materials', ['materials' => $materials, 'smap' => $smap], \psi_nav(), 'materials');
}
public function create()
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
$suppliers = (new Supplier())->all();
return $this->renderSubsys('psi', 'psi/material_form', ['material' => null, 'suppliers' => $suppliers], \psi_nav(), 'materials');
}
public function store()
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
if (!csrf_check()) return $this->redirect('PSI/materials');
(new Material())->insert($this->collect());
return $this->redirect('PSI/materials');
}
public function edit($id)
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
$material = (new Material())->find($id);
if (!$material) return $this->redirect('PSI/materials');
$suppliers = (new Supplier())->all();
return $this->renderSubsys('psi', 'psi/material_form', ['material' => $material, 'suppliers' => $suppliers], \psi_nav(), 'materials');
}
public function update($id)
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
if (!csrf_check()) return $this->redirect('PSI/materials');
$material = (new Material())->find($id);
if (!$material) return $this->redirect('PSI/materials');
(new Material())->update($id, $this->collect());
return $this->redirect('PSI/materials');
}
public function destroy($id)
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
(new Material())->delete($id);
return $this->redirect('PSI/materials');
}
/** 手动调整库存(盘盈/盘亏/报损) */
public function adjust($id)
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_check()) return $this->redirect('PSI/materials');
$qty = (float)$this->post('qty');
$direction = $this->post('direction') === 'out' ? 'out' : 'in';
$this->adjustStock('material', (int)$id, abs($qty), $direction, 'ADJ-' . date('Ymd'));
return $this->redirect('PSI/materials');
}
$material = (new Material())->find($id);
if (!$material) return $this->redirect('PSI/materials');
return $this->renderSubsys('psi', 'psi/adjust_form', ['item' => $material, 'type' => 'material'], \psi_nav(), 'materials');
}
private function collect(): array
{
return [
'code' => trim($this->post('code')),
'name' => trim($this->post('name')),
'spec' => trim($this->post('spec')),
'unit' => trim($this->post('unit')) ?: '个',
'category' => trim($this->post('category')),
'composition' => trim($this->post('composition')),
'weight_gsm' => (float)$this->post('weight_gsm'),
'width_cm' => (float)$this->post('width_cm'),
'color' => trim($this->post('color')),
'batch_no' => trim($this->post('batch_no')),
'stock' => (float)$this->post('stock'),
'price' => (float)$this->post('price'),
'supplier_id' => (int)$this->post('supplier_id'),
'remark' => trim($this->post('remark')),
'created_at' => date('Y-m-d'),
];
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Controllers\PSI;
use Core\Controller;
use App\Models\Setting;
/**
* PSI 通知设置(仅 PSI 管理员可访问)
* - 总开关 + 邮件(SMTP)/ 企业微信(群机器人 webhook)配置
* - 负责人邮箱、被@手机号
* - 低库存预警开关与阈值
* 通过 PSI 仪表盘统一分发:PSI/notifications[/save]
*/
class NotificationsController extends Controller
{
private const KEYS = [
'notify_enabled', 'notify_email_enabled', 'notify_email_smtp_host', 'notify_email_smtp_port',
'notify_email_smtp_user', 'notify_email_smtp_pass', 'notify_email_from', 'notify_email_to',
'notify_wechat_enabled', 'notify_wechat_webhook', 'notify_wechat_mention',
'notify_lowstock_enabled', 'notify_lowstock_threshold',
];
/** 统一入口 */
public function handle(array $args = []): void
{
if (!subsys_admin('psi')) { http_response_code(403); echo '无权限:仅 PSI 管理员可配置通知'; return; }
$action = $args[0] ?? 'index';
if ($action === 'save') {
$this->save();
return;
}
$this->index();
}
public function index(): void
{
$s = new Setting();
$v = [];
foreach (self::KEYS as $k) {
$v[$k] = $s->get($k, '');
}
// 布尔项默认值
if ($v['notify_lowstock_enabled'] === '') $v['notify_lowstock_enabled'] = 1;
if ($v['notify_lowstock_threshold'] === '') $v['notify_lowstock_threshold'] = 20;
$this->renderSubsys('psi', 'psi/notifications', ['v' => $v], \psi_nav(), 'notifications');
}
public function save(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) {
$this->redirect('PSI/notifications');
}
$s = new Setting();
$post = $_POST;
$s->set('notify_enabled', isset($post['notify_enabled']) ? 1 : 0);
$s->set('notify_email_enabled', isset($post['notify_email_enabled']) ? 1 : 0);
$s->set('notify_email_smtp_host', trim((string) ($post['notify_email_smtp_host'] ?? '')));
$s->set('notify_email_smtp_port', (int) ($post['notify_email_smtp_port'] ?? 465));
$s->set('notify_email_smtp_user', trim((string) ($post['notify_email_smtp_user'] ?? '')));
$s->set('notify_email_smtp_pass', trim((string) ($post['notify_email_smtp_pass'] ?? '')));
$s->set('notify_email_from', trim((string) ($post['notify_email_from'] ?? '')));
$s->set('notify_email_to', trim((string) ($post['notify_email_to'] ?? '')));
$s->set('notify_wechat_enabled', isset($post['notify_wechat_enabled']) ? 1 : 0);
$s->set('notify_wechat_webhook', trim((string) ($post['notify_wechat_webhook'] ?? '')));
$s->set('notify_wechat_mention', trim((string) ($post['notify_wechat_mention'] ?? '')));
$s->set('notify_lowstock_enabled', isset($post['notify_lowstock_enabled']) ? 1 : 0);
$s->set('notify_lowstock_threshold', max(0, (float) ($post['notify_lowstock_threshold'] ?? 20)));
$this->flash('通知设置已保存', 'ok');
$this->redirect('PSI/notifications');
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Controllers\PSI;
use App\Controllers\Controller;
use App\Models\Order;
use App\Models\Payment;
use Core\Payment\OrderService;
/**
* PSI 订单管理:将原「后台订单」合并到进销存系统,便于管理人员在同一系统快速处理。
* 权限:该系统拥有 orders 页面权限的用户(默认 PSI 管理员/超管全部可见,普通用户按 perms 细粒度控制)。
* 关键动作(标记支付、删除)仅限 PSI 管理员,避免普通操作员误改订单。
*/
class OrdersController extends Controller
{
/** 列表:最新在前 */
public function index()
{
$order = new Order();
$all = array_reverse($order->all());
return $this->renderSubsys('psi', 'psi/orders', ['orders' => $all], \subsys_nav('psi'), 'orders');
}
/** 详情:订单信息 + 付款记录 */
public function show($id)
{
$order = new Order();
$o = $order->find($id);
if (!$o) { \Core\App::notFound(); return; }
$payments = (new Payment())->whereAll('order_no', $o['order_no']);
return $this->renderSubsys('psi', 'psi/order_show', [
'o' => $o, 'payments' => $payments,
], \subsys_nav('psi'), 'orders');
}
/** 标记为已支付(仅 PSI 管理员) */
public function markPaid($id)
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
$order = new Order();
$o = $order->find($id);
if ($o && $o['status'] !== 'paid') {
OrderService::markPaid($o['order_no'], 'MANUAL' . time(), $o['channel'] ?: 'manual');
$this->flash('订单已标记为已支付', 'ok');
}
}
return $this->redirect('PSI/orders/show/' . $id);
}
/** 删除订单(仅 PSI 管理员) */
public function destroy($id)
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
(new Order())->delete($id);
$this->flash('订单已删除', 'ok');
}
return $this->redirect('PSI/orders');
}
}
+195
View File
@@ -0,0 +1,195 @@
<?php
namespace App\Controllers\PSI;
use App\Controllers\Controller;
use App\Models\PSI\Outbound;
use App\Models\PSI\OutboundItem;
use App\Models\PSI\SalesOrder;
use App\Models\PSI\SalesOrderItem;
use App\Models\PSI\Product;
use App\Controllers\PSI\StockHelper;
/**
* 出库单(交付):关联销售订单,扣减成品库存,回写销售订单已交付数量。
* 编辑/删除会先回冲原库存与已交付量,再重新应用,保证数据一致。
*/
class OutboundsController extends Controller
{
public function index()
{
$list = array_reverse((new Outbound())->all());
$itemM = new OutboundItem();
foreach ($list as &$o) { $o['_items'] = $itemM->whereAll('ob_id', $o['id']); }
return $this->renderSubsys('psi', 'psi/outbounds', ['list' => $list], \psi_nav(), 'outbounds');
}
public function create()
{
$soNo = $this->get('so');
$so = $soNo ? (new SalesOrder())->where('order_no', $soNo) : null;
$soItems = $so ? (new SalesOrderItem())->whereAll('so_id', $so['id']) : [];
return $this->renderSubsys('psi', 'psi/outbound_form', [
'o' => null, 'items' => [], 'so' => $so, 'soItems' => $soItems, 'products' => (new Product())->all(),
], \psi_nav(), 'outbounds');
}
public function store()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) return $this->redirect('PSI/outbounds/create');
$items = $this->parseItems();
if (empty($items)) { $this->flash('请至少添加一条出库明细', 'err'); return $this->redirect('PSI/outbounds/create'); }
$soNo = trim($this->post('so_no'));
$so = $soNo ? (new SalesOrder())->where('order_no', $soNo) : null;
$orderNo = \psi_gen_no('OUT');
$id = (new Outbound())->insert([
'order_no' => $orderNo,
'so_no' => $soNo,
'customer' => trim($this->post('customer')) ?: ($so['customer'] ?? ''),
'salesman' => trim($this->post('salesman')) ?: ($so['salesman'] ?? ($_SESSION['admin_name'] ?? '')),
'warehouse' => trim($this->post('warehouse')),
'status' => 'delivered',
'delivery_date' => $this->post('delivery_date') ?: null,
'remark' => trim($this->post('remark')),
'created_at' => date('Y-m-d H:i:s'),
]);
$this->applyItems($id, $items, $soNo, $orderNo);
if ($so) {
\psi_recompute_so($so['id']);
$st = (new SalesOrder())->find($so['id'])['status'];
(new Outbound())->update($id, ['status' => $st === 'delivered' ? 'delivered' : 'partial']);
}
$this->flash('出库单已保存', 'ok');
if ($this->post('auto_print', '1') !== '0') {
return $this->redirect('PSI/outbounds/print/' . $id);
}
return $this->redirect('PSI/outbounds/show/' . $id);
}
public function show($id)
{
$o = (new Outbound())->find($id);
if (!$o) { \Core\App::notFound(); return; }
$items = (new OutboundItem())->whereAll('ob_id', $id);
return $this->renderSubsys('psi', 'psi/outbound_show', ['o' => $o, 'items' => $items], \psi_nav(), 'outbounds');
}
public function edit($id)
{
$o = (new Outbound())->find($id);
if (!$o) { \Core\App::notFound(); return; }
$items = (new OutboundItem())->whereAll('ob_id', $id);
$so = $o['so_no'] ? (new SalesOrder())->where('order_no', $o['so_no']) : null;
$soItems = $so ? (new SalesOrderItem())->whereAll('so_id', $so['id']) : [];
return $this->renderSubsys('psi', 'psi/outbound_form', [
'o' => $o, 'items' => $items, 'so' => $so, 'soItems' => $soItems, 'products' => (new Product())->all(),
], \psi_nav(), 'outbounds');
}
public function update($id)
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) return $this->redirect('PSI/outbounds/edit/' . $id);
$o = (new Outbound())->find($id);
if (!$o) { \Core\App::notFound(); return; }
$items = $this->parseItems();
if (empty($items)) { $this->flash('请至少添加一条出库明细', 'err'); return $this->redirect('PSI/outbounds/edit/' . $id); }
$this->reverseItems($id); // 回冲库存与已交付
(new Outbound())->update($id, [
'customer' => trim($this->post('customer')),
'salesman' => trim($this->post('salesman')) ?: ($_SESSION['admin_name'] ?? ''),
'warehouse' => trim($this->post('warehouse')),
'delivery_date' => $this->post('delivery_date') ?: null,
'remark' => trim($this->post('remark')),
]);
$this->applyItems($id, $items, $o['so_no'], $o['order_no']);
if ($o['so_no']) {
$so = (new SalesOrder())->where('order_no', $o['so_no']);
if ($so) \psi_recompute_so((int)$so['id']);
}
$this->flash('出库单已更新', 'ok');
return $this->redirect('PSI/outbounds/show/' . $id);
}
public function destroy($id)
{
if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
$this->reverseItems($id);
(new OutboundItem())->deleteRaw('ob_id', $id);
(new Outbound())->delete($id);
$this->flash('出库单已删除', 'ok');
}
return $this->redirect('PSI/outbounds');
}
public function printDoc($id)
{
$o = (new Outbound())->find($id);
if (!$o) { \Core\App::notFound(); return; }
$items = (new OutboundItem())->whereAll('ob_id', $id);
$body = \App\Core\View::render('psi/outbound_print', ['o' => $o, 'items' => $items]);
echo \psi_print_shell('出库单 ' . $o['order_no'], $body);
}
/** 应用出库明细:写明细 + 扣库存 + 回写销售订单已交付量 */
private function applyItems(int $obId, array $items, string $soNo, string $orderNo): void
{
$itemM = new OutboundItem();
$so = $soNo ? (new SalesOrder())->where('order_no', $soNo) : null;
foreach ($items as $it) {
$it['ob_id'] = $obId;
$itemM->insert($it);
if ((int)($it['product_id'] ?? 0) > 0) {
StockHelper::adjustStock((int)$it['product_id'], (int)$it['qty'], 'out', '销售出库', $orderNo);
}
if ((int)($it['so_item_id'] ?? 0) > 0) {
$si = (new SalesOrderItem())->find($it['so_item_id']);
if ($si) {
(new SalesOrderItem())->update($si['id'], ['delivered_qty' => (int)$si['delivered_qty'] + (int)$it['qty']]);
}
}
}
}
/** 回冲:把出库明细的库存与销售订单已交付量还原 */
private function reverseItems(int $obId): void
{
$old = (new OutboundItem())->whereAll('ob_id', $obId);
foreach ($old as $oi) {
if ((int)($oi['product_id'] ?? 0) > 0) {
StockHelper::adjustStock((int)$oi['product_id'], (int)$oi['qty'], 'in', '出库冲正', $oi['ob_id'] ?? '');
}
if ((int)($oi['so_item_id'] ?? 0) > 0) {
$si = (new SalesOrderItem())->find($oi['so_item_id']);
if ($si) {
$back = max(0, (int)$si['delivered_qty'] - (int)$oi['qty']);
(new SalesOrderItem())->update($si['id'], ['delivered_qty' => $back]);
}
}
}
(new OutboundItem())->deleteRaw('ob_id', $obId);
}
private function parseItems(): array
{
$raw = $_POST['items'] ?? [];
$out = [];
foreach ($raw as $row) {
$name = trim((string)($row['name'] ?? ''));
$qty = (int)($row['qty'] ?? 0);
$price = (float)($row['price'] ?? 0);
if ($name === '' || $qty <= 0) continue;
$out[] = [
'so_item_id' => (int)($row['so_item_id'] ?? 0),
'product_id' => (int)($row['product_id'] ?? 0),
'name' => $name,
'spec' => trim((string)($row['spec'] ?? '')),
'unit' => trim((string)($row['unit'] ?? '')),
'qty' => $qty,
'price' => $price,
'amount' => round($qty * $price, 2),
];
}
return $out;
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Controllers\PSI;
use App\Controllers\Controller;
use App\Models\PSI\Product;
/** 成品管理(降温服/成衣) */
class ProductsController extends Controller
{
use StockHelper;
public function index()
{
$products = (new Product())->all();
return $this->renderSubsys('psi', 'psi/products', ['products' => $products], \psi_nav(), 'products');
}
public function create()
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
return $this->renderSubsys('psi', 'psi/product_form', ['product' => null], \psi_nav(), 'products');
}
public function store()
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
if (!csrf_check()) return $this->redirect('PSI/products');
(new Product())->insert($this->collect());
return $this->redirect('PSI/products');
}
public function edit($id)
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
$product = (new Product())->find($id);
if (!$product) return $this->redirect('PSI/products');
return $this->renderSubsys('psi', 'psi/product_form', ['product' => $product], \psi_nav(), 'products');
}
public function update($id)
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
if (!csrf_check()) return $this->redirect('PSI/products');
$product = (new Product())->find($id);
if (!$product) return $this->redirect('PSI/products');
(new Product())->update($id, $this->collect());
return $this->redirect('PSI/products');
}
public function destroy($id)
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
(new Product())->delete($id);
return $this->redirect('PSI/products');
}
public function adjust($id)
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_check()) return $this->redirect('PSI/products');
$qty = (float)$this->post('qty');
$direction = $this->post('direction') === 'out' ? 'out' : 'in';
$this->adjustStock('product', (int)$id, abs($qty), $direction, 'ADJ-' . date('Ymd'));
return $this->redirect('PSI/products');
}
$product = (new Product())->find($id);
if (!$product) return $this->redirect('PSI/products');
return $this->renderSubsys('psi', 'psi/adjust_form', ['item' => $product, 'type' => 'product'], \psi_nav(), 'products');
}
private function collect(): array
{
return [
'code' => trim($this->post('code')),
'name' => trim($this->post('name')),
'spec' => trim($this->post('spec')),
'unit' => trim($this->post('unit')) ?: '件',
'category' => trim($this->post('category')),
'style_no' => trim($this->post('style_no')),
'color' => trim($this->post('color')),
'size_run' => trim($this->post('size_run')),
'season' => trim($this->post('season')),
'year' => trim($this->post('year')),
'stock' => (float)$this->post('stock'),
'cost' => (float)$this->post('cost'),
'price' => (float)$this->post('price'),
'remark' => trim($this->post('remark')),
'created_at' => date('Y-m-d'),
];
}
}
@@ -0,0 +1,176 @@
<?php
namespace App\Controllers\PSI;
use App\Controllers\Controller;
use App\Models\PSI\PurchaseOrder;
use App\Models\PSI\PurchaseOrderItem;
use App\Models\PSI\Material;
use App\Models\PSI\Product;
use App\Models\PSI\Purchase;
use App\Controllers\PSI\StockHelper;
/**
* 采购订单:录入(可打印)、收货入库(写 psi_purchases 并增加成品库存)。
* 供应商从 psi_suppliers 选择;明细可为物料或成品。
*/
class PurchaseOrdersController extends Controller
{
public function index()
{
$orders = array_reverse((new PurchaseOrder())->all());
$itemM = new PurchaseOrderItem();
foreach ($orders as &$o) { $o['_items'] = $itemM->whereAll('po_id', $o['id']); }
return $this->renderSubsys('psi', 'psi/purchase_orders', ['orders' => $orders], \psi_nav(), 'purchase_orders');
}
public function create()
{
return $this->renderSubsys('psi', 'psi/purchase_order_form', [
'o' => null, 'items' => [], 'materials' => (new Material())->all(), 'products' => (new Product())->all(),
], \psi_nav(), 'purchase_orders');
}
public function store()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) return $this->redirect('PSI/purchase_orders/create');
$items = $this->parseItems();
if (empty($items)) { $this->flash('请至少添加一条采购明细', 'err'); return $this->redirect('PSI/purchase_orders/create'); }
$id = (new PurchaseOrder())->insert([
'order_no' => $oNo = \psi_gen_no('PO'),
'supplier_id' => (int)$this->post('supplier_id'),
'supplier_name' => $supp = trim($this->post('supplier_name')),
'salesman' => $buyer = trim($this->post('salesman')) ?: ($_SESSION['admin_name'] ?? ''),
'status' => 'pending',
'expected_at' => $this->post('expected_at') ?: null,
'remark' => trim($this->post('remark')),
'created_at' => date('Y-m-d H:i:s'),
]);
$itemM = new PurchaseOrderItem();
foreach ($items as $it) { $it['po_id'] = $id; $itemM->insert($it); }
\Core\Notify::newPurchaseOrder($oNo, $supp, $buyer, $id);
$this->flash('采购订单已创建', 'ok');
if ($this->post('auto_print', '1') !== '0') {
return $this->redirect('PSI/purchase_orders/print/' . $id);
}
return $this->redirect('PSI/purchase_orders/show/' . $id);
}
public function show($id)
{
$o = (new PurchaseOrder())->find($id);
if (!$o) { \Core\App::notFound(); return; }
$items = (new PurchaseOrderItem())->whereAll('po_id', $id);
return $this->renderSubsys('psi', 'psi/purchase_order_show', ['o' => $o, 'items' => $items], \psi_nav(), 'purchase_orders');
}
public function edit($id)
{
$o = (new PurchaseOrder())->find($id);
if (!$o) { \Core\App::notFound(); return; }
if (in_array($o['status'], ['received'], true)) { $this->flash('已收货的采购订单不可编辑', 'err'); return $this->redirect('PSI/purchase_orders/show/' . $id); }
$items = (new PurchaseOrderItem())->whereAll('po_id', $id);
return $this->renderSubsys('psi', 'psi/purchase_order_form', [
'o' => $o, 'items' => $items, 'materials' => (new Material())->all(), 'products' => (new Product())->all(),
], \psi_nav(), 'purchase_orders');
}
public function update($id)
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) return $this->redirect('PSI/purchase_orders/edit/' . $id);
$o = (new PurchaseOrder())->find($id);
if (!$o) { \Core\App::notFound(); return; }
$items = $this->parseItems();
if (empty($items)) { $this->flash('请至少添加一条采购明细', 'err'); return $this->redirect('PSI/purchase_orders/edit/' . $id); }
(new PurchaseOrder())->update($id, [
'supplier_id' => (int)$this->post('supplier_id'),
'supplier_name' => trim($this->post('supplier_name')),
'salesman' => trim($this->post('salesman')) ?: ($_SESSION['admin_name'] ?? ''),
'expected_at' => $this->post('expected_at') ?: null,
'remark' => trim($this->post('remark')),
]);
$old = (new PurchaseOrderItem())->whereAll('po_id', $id);
$recv = [];
foreach ($old as $oi) { $recv[($oi['item_type'] ?? '') . '|' . ($oi['item_id'] ?? 0) . '|' . $oi['name']] = (int)($oi['received_qty'] ?? 0); }
(new PurchaseOrderItem())->deleteRaw('po_id', $id);
foreach ($items as $it) {
$key = ($it['item_type'] ?? '') . '|' . ($it['item_id'] ?? 0) . '|' . $it['name'];
$it['received_qty'] = $recv[$key] ?? 0;
$it['po_id'] = $id;
(new PurchaseOrderItem())->insert($it);
}
$this->flash('采购订单已更新', 'ok');
return $this->redirect('PSI/purchase_orders/show/' . $id);
}
/** 收货入库:成品增加库存 + 写入采购入库流水;更新已收数量与状态 */
public function receive($id)
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) return $this->redirect('PSI/purchase_orders/show/' . $id);
$o = (new PurchaseOrder())->find($id);
if (!$o) { \Core\App::notFound(); return; }
if ($o['status'] === 'received') { $this->flash('该采购订单已收货,不可重复操作', 'err'); return $this->redirect('PSI/purchase_orders/show/' . $id); }
$items = (new PurchaseOrderItem())->whereAll('po_id', $id);
$purchaseM = new Purchase();
foreach ($items as $it) {
if ((int)$it['qty'] <= 0) continue;
if ($it['item_type'] === 'product' && (int)$it['item_id'] > 0) {
StockHelper::adjustStock((int)$it['item_id'], (int)$it['qty'], 'in', '采购入库', $o['order_no']);
$purchaseM->insert([
'product_id' => (int)$it['item_id'], 'qty' => (int)$it['qty'],
'price' => (float)$it['price'], 'amount' => (float)$it['amount'],
'supplier' => $o['supplier_name'], 'order_no' => $o['order_no'],
'created_at' => date('Y-m-d H:i:s'),
]);
}
(new PurchaseOrderItem())->update($it['id'], ['received_qty' => (int)$it['qty']]);
}
(new PurchaseOrder())->update($id, ['status' => 'received']);
$this->flash('采购订单已收货入库', 'ok');
return $this->redirect('PSI/purchase_orders/show/' . $id);
}
public function destroy($id)
{
if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
(new PurchaseOrderItem())->deleteRaw('po_id', $id);
(new PurchaseOrder())->delete($id);
$this->flash('采购订单已删除', 'ok');
}
return $this->redirect('PSI/purchase_orders');
}
public function printDoc($id)
{
$o = (new PurchaseOrder())->find($id);
if (!$o) { \Core\App::notFound(); return; }
$items = (new PurchaseOrderItem())->whereAll('po_id', $id);
$body = \App\Core\View::render('psi/purchase_order_print', ['o' => $o, 'items' => $items]);
echo \psi_print_shell('采购订单 ' . $o['order_no'], $body);
}
private function parseItems(): array
{
$raw = $_POST['items'] ?? [];
$out = [];
foreach ($raw as $row) {
$name = trim((string)($row['name'] ?? ''));
$qty = (int)($row['qty'] ?? 0);
$price = (float)($row['price'] ?? 0);
if ($name === '' || $qty <= 0) continue;
$out[] = [
'item_type' => $row['item_type'] === 'product' ? 'product' : 'material',
'item_id' => (int)($row['item_id'] ?? 0),
'name' => $name,
'spec' => trim((string)($row['spec'] ?? '')),
'unit' => trim((string)($row['unit'] ?? '')),
'qty' => $qty,
'price' => $price,
'amount' => round($qty * $price, 2),
'received_qty' => 0,
];
}
return $out;
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Controllers\PSI;
use App\Controllers\Controller;
use App\Models\PSI\Purchase;
use App\Models\PSI\Supplier;
/** 采购入库:写入采购单并自动增加物料/成品库存 + 流水 */
class PurchasesController extends Controller
{
use StockHelper;
public function index()
{
$purchases = (new Purchase())->all();
$suppliers = (new Supplier())->all();
$smap = [];
foreach ($suppliers as $s) { $smap[$s['id']] = $s['name']; }
return $this->renderSubsys('psi', 'psi/purchases', ['purchases' => $purchases, 'smap' => $smap], \psi_nav(), 'purchases');
}
public function create()
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
$suppliers = (new Supplier())->all();
$materials = (new \App\Models\PSI\Material())->all();
$products = (new \App\Models\PSI\Product())->all();
return $this->renderSubsys('psi', 'psi/purchase_form', [
'purchase' => null, 'suppliers' => $suppliers,
'materials' => $materials, 'products' => $products,
], \psi_nav(), 'purchases');
}
public function store()
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
if (!csrf_check()) return $this->redirect('PSI/purchases');
$itemType = $this->post('item_type') === 'product' ? 'product' : 'material';
$itemId = (int)$this->post('item_id');
$qty = (float)$this->post('qty');
$price = (float)$this->post('price');
if ($itemId <= 0 || $qty <= 0) { $this->flash('请选择有效的物料/成品并填写数量'); return $this->redirect('PSI/purchases'); }
$no = 'PI' . date('Ymd') . '-' . substr(uniqid(), -4);
(new Purchase())->insert([
'order_no' => $no,
'supplier_id' => (int)$this->post('supplier_id'),
'item_type' => $itemType,
'item_id' => $itemId,
'qty' => $qty,
'price' => $price,
'amount' => $qty * $price,
'status' => 'stocked',
'batch_no' => trim($this->post('batch_no')),
'expected_at' => trim($this->post('expected_at')),
'remark' => trim($this->post('remark')),
'created_at' => date('Y-m-d'),
]);
$this->adjustStock($itemType, $itemId, $qty, 'in', $no, trim($this->post('batch_no')));
$this->flash('采购入库成功,库存已更新', 'ok');
return $this->redirect('PSI/purchases');
}
public function destroy($id)
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
(new Purchase())->delete($id);
return $this->redirect('PSI/purchases');
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace App\Controllers\PSI;
use Core\Controller;
use Core\Notify;
use App\Models\PSI\Event;
/**
* PSI 紧急事件 / 提醒中心
* - 列出所有“紧急事件”(站内提醒,所有 PSI 人员可见)
* - 支持单条标记已读 / 全部已读
* - 支持手动发起一条紧急提醒(通知负责人)
* 通过 PSI 仪表盘统一分发:PSI/reminders[/动作[/id]]
*/
class RemindersController extends Controller
{
/** 统一入口,由 DashboardController 分发 */
public function handle(array $args = []): void
{
$action = $args[0] ?? 'index';
$id = (int) ($args[1] ?? 0);
if ($action === 'create') {
$this->create();
return;
}
if ($action === 'mark' && $id > 0) {
$this->markRead($id);
return;
}
if ($action === 'markAll') {
$this->markAll();
return;
}
$this->index();
}
public function index(): void
{
if (!subsys_user('psi')) { http_response_code(403); echo '无权限'; return; }
$events = (new Event())->all();
$events = array_reverse($events); // 最新在前
$uid = (int) ($_SESSION['admin_uid'] ?? 0);
$unread = 0;
foreach ($events as &$ev) {
$read = json_decode($ev['read_by'] ?? '[]', true) ?: [];
$ev['_read'] = in_array($uid, $read, true);
if (!$ev['_read']) $unread++;
}
$this->renderSubsys('psi', 'psi/reminders', [
'events' => $events,
'unread' => $unread,
], \psi_nav(), 'reminders');
}
public function markRead(int $id): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('PSI/reminders'); }
if (!subsys_user('psi')) { http_response_code(403); return; }
$uid = (int) ($_SESSION['admin_uid'] ?? 0);
$ev = (new Event())->find($id);
if ($ev) {
$read = json_decode($ev['read_by'] ?? '[]', true) ?: [];
if (!in_array($uid, $read, true)) {
$read[] = $uid;
(new Event())->update($id, ['read_by' => json_encode($read, JSON_UNESCAPED_UNICODE)]);
}
}
$this->redirect('PSI/reminders');
}
public function markAll(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('PSI/reminders'); }
if (!subsys_user('psi')) { http_response_code(403); return; }
$uid = (int) ($_SESSION['admin_uid'] ?? 0);
$events = (new Event())->all();
foreach ($events as $ev) {
$read = json_decode($ev['read_by'] ?? '[]', true) ?: [];
if (!in_array($uid, $read, true)) {
$read[] = $uid;
(new Event())->update($ev['id'], ['read_by' => json_encode($read, JSON_UNESCAPED_UNICODE)]);
}
}
$this->redirect('PSI/reminders');
}
/** 手动发起一条紧急提醒(通知负责人) */
public function create(): void
{
if (!subsys_user('psi')) { http_response_code(403); echo '无权限'; return; }
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_check()) { $this->flash('表单已过期,请重试', 'err'); $this->redirect('PSI/reminders'); }
$title = trim($this->post('title', ''));
$body = trim($this->post('body', ''));
if ($title === '') { $this->flash('请填写提醒标题', 'err'); $this->redirect('PSI/reminders'); }
Notify::fire('manual', $title, $body ?: $title, [
'level' => 'urgent', 'url' => 'PSI/reminders',
]);
$this->flash('已发起紧急提醒,并已通知相关负责人', 'ok');
$this->redirect('PSI/reminders');
}
$this->renderSubsys('psi', 'psi/reminder_form', [
'title' => '', 'body' => '',
], \psi_nav(), 'reminders');
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Controllers\PSI;
use App\Controllers\Controller;
use App\Models\PSI\PurchaseOrder;
use App\Models\PSI\PurchaseOrderItem;
use App\Models\PSI\SalesOrder;
use App\Models\PSI\SalesOrderItem;
use App\Models\PSI\Outbound;
use App\Models\PSI\OutboundItem;
/**
* 报表中心:采购订单明细 / 销售·采购订单明细 / 交付明细。
* 集中展示单据之间的关联性(采购→入库、销售订单→出库交付)。
*/
class ReportsController extends Controller
{
public function index()
{
return $this->renderSubsys('psi', 'psi/reports', [
'poCount' => count((new PurchaseOrder())->all()),
'soCount' => count((new SalesOrder())->all()),
'obCount' => count((new Outbound())->all()),
], \psi_nav(), 'reports');
}
/** 采购订单明细 */
public function poDetail()
{
$orders = array_reverse((new PurchaseOrder())->all());
$itemM = new PurchaseOrderItem();
foreach ($orders as &$o) { $o['_items'] = $itemM->whereAll('po_id', $o['id']); }
return $this->renderSubsys('psi', 'psi/report_po_detail', ['orders' => $orders], \psi_nav(), 'reports');
}
/** 销售 / 采购订单明细(合并筛选) */
public function soPo()
{
$type = $this->get('type', 'all');
$so = $po = [];
if ($type !== 'purchase') {
$so = array_reverse((new SalesOrder())->all());
$soItemM = new SalesOrderItem();
foreach ($so as &$o) {
$o['_items'] = $soItemM->whereAll('so_id', $o['id']);
$o['_amt'] = array_sum(array_map(fn($i) => (float)($i['amount'] ?? 0), $o['_items']));
}
}
if ($type !== 'sales') {
$po = array_reverse((new PurchaseOrder())->all());
$poItemM = new PurchaseOrderItem();
foreach ($po as &$o) {
$o['_items'] = $poItemM->whereAll('po_id', $o['id']);
$o['_amt'] = array_sum(array_map(fn($i) => (float)($i['amount'] ?? 0), $o['_items']));
}
}
return $this->renderSubsys('psi', 'psi/report_so_po', [
'type' => $type, 'so' => $so, 'po' => $po,
], \psi_nav(), 'reports');
}
/** 交付明细:出库单 + 销售订单交付进度(应发/已发/未发) */
public function delivery()
{
$list = array_reverse((new Outbound())->all());
$obItemM = new OutboundItem();
foreach ($list as &$o) { $o['_items'] = $obItemM->whereAll('ob_id', $o['id']); }
$soList = (new SalesOrder())->all();
$soItemM = new SalesOrderItem();
$progress = [];
foreach ($soList as $so) {
$items = $soItemM->whereAll('so_id', $so['id']);
$ordered = array_sum(array_map(fn($i) => (int)($i['qty'] ?? 0), $items));
$delivered = array_sum(array_map(fn($i) => (int)($i['delivered_qty'] ?? 0), $items));
$amt = array_sum(array_map(fn($i) => (float)($i['amount'] ?? 0), $items));
if ($ordered <= 0) continue;
$progress[] = [
'order_no' => $so['order_no'],
'customer' => $so['customer'],
'salesman' => $so['salesman'],
'status' => $so['status'],
'ordered' => $ordered,
'delivered' => $delivered,
'remain' => max(0, $ordered - $delivered),
'amount' => $amt,
];
}
return $this->renderSubsys('psi', 'psi/report_delivery', [
'list' => $list, 'progress' => $progress,
], \psi_nav(), 'reports');
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Controllers\PSI;
use App\Controllers\Controller;
use App\Models\PSI\Sales;
use App\Models\PSI\Product;
use App\Models\PSI\Material;
/** 销售出库:内销 + 外贸出口。出库自动扣减库存并校验库存充足 */
class SalesController extends Controller
{
use StockHelper;
public function index()
{
$sales = (new Sales())->all();
return $this->renderSubsys('psi', 'psi/sales', ['sales' => $sales], \psi_nav(), 'sales');
}
public function create()
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
$materials = (new \App\Models\PSI\Material())->all();
$products = (new \App\Models\PSI\Product())->all();
return $this->renderSubsys('psi', 'psi/sale_form', [
'sale' => null, 'materials' => $materials, 'products' => $products,
], \psi_nav(), 'sales');
}
public function store()
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
if (!csrf_check()) return $this->redirect('PSI/sales');
$itemType = $this->post('item_type') === 'material' ? 'material' : 'product';
$itemId = (int)$this->post('item_id');
$qty = (float)$this->post('qty');
$price = (float)$this->post('price');
if ($itemId <= 0 || $qty <= 0) { $this->flash('请选择有效的成品/物料并填写数量'); return $this->redirect('PSI/sales'); }
$model = $itemType === 'product' ? new Product() : new Material();
$item = $model->find($itemId);
if (!$item || (float)($item['stock'] ?? 0) < $qty) {
$this->flash('库存不足,无法出库(当前库存:' . (isset($item) ? (float)$item['stock'] : 0) . '');
return $this->redirect('PSI/sales');
}
$no = 'SO' . date('Ymd') . '-' . substr(uniqid(), -4);
$saleId = (new Sales())->insert([
'order_no' => $no,
'customer' => trim($this->post('customer')),
'channel' => $this->post('channel') === 'export' ? 'export' : 'domestic',
'region' => trim($this->post('region')),
'item_id' => $itemId,
'item_type' => $itemType,
'qty' => $qty,
'price' => $price,
'amount' => $qty * $price,
'status' => 'shipped',
'batch_no' => trim($this->post('batch_no')),
'remark' => trim($this->post('remark')),
'created_at' => date('Y-m-d'),
]);
$this->adjustStock($itemType, $itemId, $qty, 'out', $no, trim($this->post('batch_no')));
$this->flash('销售出库成功,库存已扣减', 'ok');
if ($this->post('auto_print', '1') !== '0') {
return $this->redirect('PSI/sales/print/' . $saleId);
}
return $this->redirect('PSI/sales');
}
public function show($id)
{
$sale = (new Sales())->find($id);
if (!$sale) { \Core\App::notFound('销售记录不存在'); return; }
$itemInfo = $this->lookupItem($sale['item_id'] ?? 0, $sale['item_type'] ?? '');
return $this->renderSubsys('psi', 'psi/sales_show', [
's' => $sale,
'item' => $itemInfo,
], \psi_nav(), 'sales');
}
public function printDoc($id)
{
$sale = (new Sales())->find($id);
if (!$sale) { \Core\App::notFound('销售记录不存在'); return; }
$itemInfo = $this->lookupItem($sale['item_id'] ?? 0, $sale['item_type'] ?? '');
$body = \Core\View::buffer('psi/sales_print', ['s' => $sale, 'item' => $itemInfo]);
echo \psi_print_shell('销售出库单 - ' . e($sale['order_no']), $body);
}
/** 尝试从成品表或物料表查找物品信息 */
private function lookupItem(int $itemId, string $itemType = ''): array
{
if ($itemId <= 0) return ['name' => '—', 'spec' => '', 'unit' => ''];
if ($itemType === 'material') {
$row = (new Material())->find($itemId);
} else {
$row = (new Product())->find($itemId) ?: (new Material())->find($itemId);
}
if ($row) return ['name' => $row['name'] ?? '—', 'spec' => $row['spec'] ?? '', 'unit' => $row['unit'] ?? ''];
return ['name' => '—', 'spec' => '', 'unit' => ''];
}
public function destroy($id)
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
(new Sales())->delete($id);
return $this->redirect('PSI/sales');
}
}
@@ -0,0 +1,156 @@
<?php
namespace App\Controllers\PSI;
use App\Controllers\Controller;
use App\Models\PSI\SalesOrder;
use App\Models\PSI\SalesOrderItem;
use App\Models\PSI\Product;
/**
* 销售订单:录入(可打印)、关联出库。
* 业务员默认取当前登录人;商品可关联 psi_products(用于后续出库扣减库存)。
*/
class SalesOrdersController extends Controller
{
public function index()
{
$orders = (new SalesOrder())->all(); // id 升序
$orders = array_reverse($orders); // 最新在前
$itemM = new SalesOrderItem();
foreach ($orders as &$o) {
$o['_items'] = $itemM->whereAll('so_id', $o['id']);
}
return $this->renderSubsys('psi', 'psi/sales_orders', ['orders' => $orders], \psi_nav(), 'sales_orders');
}
public function create()
{
$products = (new Product())->all();
return $this->renderSubsys('psi', 'psi/sales_order_form', [
'o' => null, 'items' => [], 'products' => $products,
], \psi_nav(), 'sales_orders');
}
public function store()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) return $this->redirect('PSI/sales_orders/create');
$items = $this->parseItems();
if (empty($items)) { $this->flash('请至少添加一条商品明细', 'err'); return $this->redirect('PSI/sales_orders/create'); }
$id = (new SalesOrder())->insert([
'order_no' => $oNo = \psi_gen_no('SO'),
'customer' => $cust = trim($this->post('customer')),
'salesman' => $sales = trim($this->post('salesman')) ?: ($_SESSION['admin_name'] ?? ''),
'channel' => $this->post('channel') === 'export' ? 'export' : 'domestic',
'region' => trim($this->post('region')),
'delivery_date' => $this->post('delivery_date') ?: null,
'remark' => trim($this->post('remark')),
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
]);
$itemM = new SalesOrderItem();
foreach ($items as $it) { $it['so_id'] = $id; $itemM->insert($it); }
\Core\Notify::newSalesOrder($oNo, $cust, $sales, $id);
$this->flash('销售订单已创建', 'ok');
if ($this->post('auto_print', '1') !== '0') {
return $this->redirect('PSI/sales_orders/print/' . $id);
}
return $this->redirect('PSI/sales_orders/show/' . $id);
}
public function show($id)
{
$o = (new SalesOrder())->find($id);
if (!$o) { \Core\App::notFound(); return; }
$items = (new SalesOrderItem())->whereAll('so_id', $id);
return $this->renderSubsys('psi', 'psi/sales_order_show', [
'o' => $o, 'items' => $items,
], \psi_nav(), 'sales_orders');
}
public function edit($id)
{
$o = (new SalesOrder())->find($id);
if (!$o) { \Core\App::notFound(); return; }
$items = (new SalesOrderItem())->whereAll('so_id', $id);
$products = (new Product())->all();
return $this->renderSubsys('psi', 'psi/sales_order_form', [
'o' => $o, 'items' => $items, 'products' => $products,
], \psi_nav(), 'sales_orders');
}
public function update($id)
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) return $this->redirect('PSI/sales_orders/edit/' . $id);
$o = (new SalesOrder())->find($id);
if (!$o) { \Core\App::notFound(); return; }
$items = $this->parseItems();
if (empty($items)) { $this->flash('请至少添加一条商品明细', 'err'); return $this->redirect('PSI/sales_orders/edit/' . $id); }
(new SalesOrder())->update($id, [
'customer' => trim($this->post('customer')),
'salesman' => trim($this->post('salesman')) ?: ($_SESSION['admin_name'] ?? ''),
'channel' => $this->post('channel') === 'export' ? 'export' : 'domestic',
'region' => trim($this->post('region')),
'delivery_date' => $this->post('delivery_date') ?: null,
'remark' => trim($this->post('remark')),
]);
// 保留已交付数量(delivered_qty)避免覆盖出库记录
$old = (new SalesOrderItem())->whereAll('so_id', $id);
$delivered = [];
foreach ($old as $oi) { $delivered[($oi['product_id'] ?? 0) . '|' . $oi['name']] = (int)($oi['delivered_qty'] ?? 0); }
(new SalesOrderItem())->deleteRaw('so_id', $id);
foreach ($items as $it) {
$key = ($it['product_id'] ?? 0) . '|' . $it['name'];
$it['delivered_qty'] = $delivered[$key] ?? 0;
$it['so_id'] = $id;
(new SalesOrderItem())->insert($it);
}
\psi_recompute_so($id);
$this->flash('销售订单已更新', 'ok');
return $this->redirect('PSI/sales_orders/show/' . $id);
}
public function destroy($id)
{
if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
(new SalesOrderItem())->deleteRaw('so_id', $id);
(new SalesOrder())->delete($id);
$this->flash('销售订单已删除', 'ok');
}
return $this->redirect('PSI/sales_orders');
}
public function printDoc($id)
{
$o = (new SalesOrder())->find($id);
if (!$o) { \Core\App::notFound(); return; }
$items = (new SalesOrderItem())->whereAll('so_id', $id);
$body = \App\Core\View::render('psi/sales_order_print', ['o' => $o, 'items' => $items]);
echo \psi_print_shell('销售订单 ' . $o['order_no'], $body);
}
/** 解析提交的商品明细行 */
private function parseItems(): array
{
$raw = $_POST['items'] ?? [];
$out = [];
foreach ($raw as $row) {
$name = trim((string)($row['name'] ?? ''));
$qty = (int)($row['qty'] ?? 0);
$price = (float)($row['price'] ?? 0);
if ($name === '' || $qty <= 0) continue;
$out[] = [
'product_id' => (int)($row['product_id'] ?? 0),
'name' => $name,
'spec' => trim((string)($row['spec'] ?? '')),
'unit' => trim((string)($row['unit'] ?? '')),
'qty' => $qty,
'price' => $price,
'amount' => round($qty * $price, 2),
'delivered_qty' => 0,
];
}
return $out;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Controllers\PSI;
use App\Controllers\Controller;
use App\Models\PSI\StockMove;
use App\Models\PSI\Material;
use App\Models\PSI\Product;
/** 库存流水台账(所有出入库变动记录) */
class StockController extends Controller
{
public function index()
{
$moves = (new StockMove())->all();
$moves = array_reverse($moves);
$nameMap = [];
foreach ((new Material())->all() as $m) { $nameMap['material:' . $m['id']] = $m['name']; }
foreach ((new Product())->all() as $p) { $nameMap['product:' . $p['id']] = $p['name']; }
return $this->renderSubsys('psi', 'psi/stock', ['moves' => $moves, 'nameMap' => $nameMap], \psi_nav(), 'stock');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Controllers\PSI;
use App\Models\PSI\Material;
use App\Models\PSI\Product;
use App\Models\PSI\StockMove;
/**
* 库存台账统一维护:任何出入库都经此更新,保证物料/成品库存与流水一致。
* item_type: material | product
*/
trait StockHelper
{
private function adjustStock(string $itemType, int $itemId, float $qty, string $direction, string $refNo, string $batchNo = ''): void
{
$model = $itemType === 'product' ? new Product() : new Material();
$item = $model->find($itemId);
if (!$item) return;
$cur = (float)($item['stock'] ?? 0);
$new = $direction === 'in' ? $cur + $qty : max(0, $cur - $qty);
$model->update($itemId, ['stock' => $new]);
(new StockMove())->insert([
'item_type' => $itemType,
'item_id' => $itemId,
'direction' => $direction,
'qty' => $qty,
'ref_no' => $refNo,
'batch_no' => $batchNo,
'remark' => '',
'created_at' => date('Y-m-d'),
]);
// 低库存预警:仅当出库且跌破阈值时触发(避免重复骚扰)
$threshold = \Core\Notify::lowStockThreshold();
if (\Core\Notify::lowStockEnabled()
&& $direction === 'out'
&& $new <= $threshold
&& $cur > $threshold) {
\Core\Notify::lowStockEvent($itemType, $item['name'] ?? '', $new, $threshold, $itemId);
}
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Controllers\PSI;
use App\Controllers\Controller;
use App\Models\PSI\Supplier;
/** 供应商管理(进出口/面辅料供应商) */
class SuppliersController extends Controller
{
public function index()
{
$suppliers = (new Supplier())->all();
return $this->renderSubsys('psi', 'psi/suppliers', ['suppliers' => $suppliers], \psi_nav(), 'suppliers');
}
public function create()
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
return $this->renderSubsys('psi', 'psi/supplier_form', ['supplier' => null], \psi_nav(), 'suppliers');
}
public function store()
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
if (!csrf_check()) return $this->redirect('PSI/suppliers');
(new Supplier())->insert($this->collect());
return $this->redirect('PSI/suppliers');
}
public function edit($id)
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
$supplier = (new Supplier())->find($id);
if (!$supplier) return $this->redirect('PSI/suppliers');
return $this->renderSubsys('psi', 'psi/supplier_form', ['supplier' => $supplier], \psi_nav(), 'suppliers');
}
public function update($id)
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
if (!csrf_check()) return $this->redirect('PSI/suppliers');
$supplier = (new Supplier())->find($id);
if (!$supplier) return $this->redirect('PSI/suppliers');
(new Supplier())->update($id, $this->collect());
return $this->redirect('PSI/suppliers');
}
public function destroy($id)
{
subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
(new Supplier())->delete($id);
return $this->redirect('PSI/suppliers');
}
private function collect(): array
{
return [
'name' => trim($this->post('name')),
'contact' => trim($this->post('contact')),
'phone' => trim($this->post('phone')),
'country' => trim($this->post('country')),
'type' => trim($this->post('type')),
'grade' => trim($this->post('grade')),
'ontime_rate'=> (float)$this->post('ontime_rate'),
'qc_rate' => (float)$this->post('qc_rate'),
'remark' => trim($this->post('remark')),
'created_at' => date('Y-m-d'),
];
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Controllers\PSI;
use App\Controllers\Subsys\UsersController as BaseUsersController;
class UsersController extends BaseUsersController
{
protected function sys(): string
{
return 'psi';
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Controllers;
use App\Models\Page;
class PageController extends Controller
{
public function show($slug)
{
$page = new Page();
$p = $page->bySlug($slug);
if (!$p) { \Core\App::notFound(); return ''; }
$pTitle = e($p['title'] ?? '页面');
$pSummary = mb_substr(strip_tags($p['summary'] ?? $p['body'] ?? ''), 0, 160);
$seo = page_seo($slug, [
'title' => $pTitle,
'description' => $pSummary,
'keywords' => '',
'og_type' => 'article',
]);
return $this->view('page/show', [
'pageSeo' => [
'title' => $seo['title'],
'description' => $seo['description'],
'keywords' => $seo['keywords'],
'og_type' => $seo['og_type'] ?: 'article',
'og_image' => $seo['og_image'],
'canonical' => $seo['canonical'],
'noindex' => $seo['noindex'],
'breadcrumb' => [
['name' => '首页', 'url' => site_url()],
['name' => $p['title'] ?? '页面', 'url' => absolute_url()],
],
],
'p' => $p,
]);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Controllers;
use Core\Payment\GatewayFactory;
use Core\Payment\OrderService;
/** 支付网关异步通知(无需登录) */
class PayController extends Controller
{
public function notify($channel)
{
if ($channel === 'alipay') {
$gw = GatewayFactory::make('alipay');
$no = $gw->verifyNotify($_POST);
if ($no) {
OrderService::markPaid($no, $_POST['trade_no'] ?? '', 'alipay');
echo 'success';
} else {
echo 'fail';
}
} elseif ($channel === 'wechat') {
$xml = file_get_contents('php://input');
$data = $this->xmlToArray($xml);
$gw = GatewayFactory::make('wechat');
$no = $gw->verifyNotify($data);
if ($no) {
OrderService::markPaid($no, $data['transaction_id'] ?? '', 'wechat');
echo '<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>';
} else {
echo '<xml><return_code><![CDATA[FAIL]]></return_code></xml>';
}
} else {
echo 'invalid';
}
exit;
}
private function xmlToArray($xml)
{
$r = @simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);
return $r ? json_decode(json_encode($r), true) : [];
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace App\Controllers;
use App\Models\Category;
use App\Models\Product;
class ProductController extends Controller
{
public function index()
{
$product = new Product();
$category = new Category();
$catId = isset($_GET['cat']) ? (int)$_GET['cat'] : 0;
$products = $catId
? $product->byCategory($catId)
: array_filter($product->all(), fn($p) => ($p['status'] ?? 1) == 1);
$cat = $catId ? $category->find($catId) : null;
$catName = $cat['name'] ?? '';
if ($cat) {
$seo = page_seo('product_category', [
'title' => $catName . '降温服 - 酷冰甲科技降温·定制批发',
'description' => "酷冰甲{$catName}系列降温服,采用科技降温方案,专为高温作业与户外暴晒场景设计,具备清凉持久、轻便透气、可循环重复使用等特点。支持企业定制、LOGO刺绣与小批量批发,10套起订,7天打样,全国发货。",
'keywords' => $catName . '降温服,' . $catName . ',降温服定制,降温背心,工业降温,酷冰甲',
]);
foreach (['title', 'description', 'keywords'] as $f) {
$seo[$f] = str_replace('{cat}', $catName, $seo[$f]);
}
} else {
$seo = page_seo('products', [
'title' => '降温服产品中心 - 水冷/相变/风冷多系列 | 酷冰甲',
'description' => '酷冰甲降温服产品中心,系统展示水冷循环降温服、相变冰袋降温背心、风冷制冷背心、冰马甲等多系列产品,按使用场景与降温方式分类,参数规格与适用行业一目了然。支持企业批量定制、LOGO刺绣与免费拿样,提供专业选型建议与透明报价,助力高温作业安全防护。',
'keywords' => '降温服产品,水冷降温服,相变降温服,风冷降温服,制冷背心,冰马甲,工业降温装备,降温服批发,降温服定制,酷冰甲产品',
]);
}
$pageTitle = $catName ? $catName . ' - 产品中心' : '产品中心';
$pageDesc = $catName ? str_replace('{cat}', $catName, "酷冰甲{cat}系列降温服,科技降温,清凉定制。") : '酷冰甲全系列降温服产品:水冷循环、相变蓄冷、涡扇风冷、工业降温工装,支持小批量定制。';
return $this->view('product/index', [
'pageSeo' => [
'title' => $seo['title'],
'description' => $seo['description'],
'keywords' => $seo['keywords'],
'og_type' => 'website',
'og_image' => $seo['og_image'],
'canonical' => $seo['canonical'],
'noindex' => $seo['noindex'],
'breadcrumb' => $catName ? [
['name' => '首页', 'url' => site_url()],
['name' => '产品中心', 'url' => site_url('products')],
['name' => $catName, 'url' => absolute_url()],
] : null,
],
'products' => $products,
'categories'=> $category->all(),
'activeCat' => $catId,
'cat' => $cat,
]);
}
public function show($slug)
{
$product = new Product();
$p = $product->where('slug', $slug);
if (!$p && is_numeric($slug)) { $p = $product->find((int)$slug); }
if (!$p) { \Core\App::notFound(); return ''; }
$category = new Category();
$cat = $category->find($p['category_id'] ?? 0);
$related = array_filter($product->byCategory($p['category_id'] ?? 0), fn($x) => $x['id'] != $p['id']);
$pName = e($p['name'] ?? '产品详情');
$pSummary = mb_substr(strip_tags($p['summary'] ?? $p['body'] ?? ''), 0, 160);
$pImage = $p['cover'] ?? '';
$pPrice = $p['price'] ?? '';
$pSku = $p['sku'] ?? ($p['model'] ?? '');
// ── Product JSON-LD Schema ──
$productSchema = '<script type="application/ld+json">' . json_encode([
'@context' => 'https://schema.org',
'@type' => 'Product',
'name' => $p['name'] ?? '',
'description' => $pSummary,
'image' => $pImage,
'sku' => $pSku,
'category' => $cat['name'] ?? '',
] + ($pPrice ? ['offers' => [
'@type' => 'Offer',
'price' => $pPrice,
'priceCurrency' => 'CNY',
'availability' => 'https://schema.org/InStock',
]] : []), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . '</script>';
// ── 产品页 FAQ(可见文本 + FAQPage JSON-LDGEO 信号)────
$faqs = [
['q' => '这款降温服采用什么降温原理?', 'a' => '根据系列不同,分别采用水冷循环、相变蓄冷或涡扇风冷原理散热:水冷通过微型水泵驱动冷水循环带走体热,相变依靠冰袋/凝胶融化吸热,风冷由风扇强制对流降温。详情可在商品规格表中查看对应方案。'],
['q' => '一次可使用多长时间?', 'a' => '相变冰袋方案单组可持续 2–4 小时,可随用随换;水冷与风冷方案续航取决于电池容量,具体以商品规格为准,支持备用电池延长作业时间。'],
['q' => '是否支持企业定制与 LOGO 刺绣?', 'a' => '支持。提供企业 LOGO 绣字、颜色与面料定制、一人一码量体服务,10 套起订,确认图纸后 7 天打样、约 28 天批量交付。'],
['q' => '如何选择合适的尺码?', 'a' => '提供标准尺码表并支持上门量体,下单后可按身高体重推荐尺码;特殊体型或工种可单独打版,确保合身与活动便利。'],
];
$faqSchema = '<script type="application/ld+json">' . json_encode([
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => array_map(fn($f) => [
'@type' => 'Question',
'name' => $f['q'],
'acceptedAnswer' => ['@type' => 'Answer', 'text' => $f['a']],
], $faqs),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . '</script>';
return $this->view('product/show', [
'pageSeo' => [
'title' => $pName,
'description' => $pSummary,
'og_type' => 'product',
'og_image' => $pImage,
'breadcrumb' => [
['name' => '首页', 'url' => site_url()],
['name' => '产品中心', 'url' => site_url('products')],
['name' => $p['name'] ?? '产品', 'url' => absolute_url()],
],
'jsonld' => $productSchema . $faqSchema,
],
'p' => $p,
'cat' => $cat,
'related' => array_slice($related, 0, 3),
'specs' => $product->specsArray($p),
'faqs' => $faqs,
]);
}
}
+256
View File
@@ -0,0 +1,256 @@
<?php
namespace App\Controllers\Subsys;
use App\Controllers\Controller;
use App\Models\AdminUser;
use Core\Db;
/**
* 分系统用户管理(CRM/PSI 共用)。
* 仅该系统管理员可访问:管理「属于本系统的用户」及其页面权限。
* 普通用户仅能看到/访问自己被授权的页面(由 subsys_page_can 控制)。
*/
abstract class UsersController extends Controller
{
/** 子类返回 'crm' | 'psi' */
abstract protected function sys(): string;
private function sysName(): string
{
return $this->sys() === 'psi' ? 'PSI 进销存' : 'CRM 客户管理';
}
private function nav(): array
{
return \subsys_nav($this->sys());
}
public function index()
{
$sys = $this->sys();
$other = $sys === 'crm' ? 'psi' : 'crm';
$col = $sys . '_role';
$rows = Db::query("SELECT * FROM admin_users WHERE `{$col}` != 'none' ORDER BY id ASC")->fetchAll();
// 子系统管理员仅管理「本系统的用户」,严格执行边界隔离:
// - 不可越权操作后台超管 / 全局管理员;
// - 不可跨界看到 / 管理另一系统(CRM 看不到 PSI,PSI 看不到 CRM)。
if (admin_role() !== 'super_admin') {
$rows = array_filter($rows, static function ($r) use ($other) {
// 排除后台超管 / 全局管理员
if (in_array($r['role'] ?? 'none', ['super_admin', 'admin'], true)) return false;
// 排除另一系统的管理员(防跨界)
if (($r[$other . '_role'] ?? 'none') === 'admin') return false;
return true;
});
}
return $this->renderSubsys($sys, 'subsys/users', [
'users' => $rows,
'sysName' => $this->sysName(),
'pages' => \subsys_pages($sys),
'sys' => $sys,
], $this->nav(), 'users');
}
public function create()
{
$sys = $this->sys();
$user = [
'id' => 0, 'username' => '', 'name' => '', 'status' => 1,
$sys . '_role' => 'user',
$sys . '_perms' => '',
];
return $this->renderSubsys($sys, 'subsys/user_form', [
'user' => $user,
'sysName'=> $this->sysName(),
'pages' => \subsys_pages($sys),
'sys' => $sys,
'edit' => false,
], $this->nav(), 'users');
}
public function store()
{
$sys = $this->sys();
$upper = strtoupper($sys);
if (!csrf_check()) {
$this->flash('表单已过期,请重试', 'err');
return $this->redirect($upper . '/users/create');
}
$username = trim($this->post('username'));
$name = trim($this->post('name'));
$pwd = $this->post('password');
$role = $this->post($sys . '_role') === 'admin' ? 'admin' : 'user';
$perms = $this->collectPerms($sys);
$status = $this->post('status') === '0' ? 0 : 1;
if ($username === '' || $pwd === '') {
$this->flash('用户名和密码不能为空', 'err');
return $this->redirect($upper . '/users/create');
}
if (!preg_match('/^[a-zA-Z0-9_]{3,30}$/', $username)) {
$this->flash('账号须为 3-30 位字母/数字/下划线', 'err');
return $this->redirect($upper . '/users/create');
}
if (strlen($pwd) < 6) {
$this->flash('密码至少 6 位', 'err');
return $this->redirect($upper . '/users/create');
}
if ((new AdminUser())->byUsername($username)) {
$this->flash('用户名已存在', 'err');
return $this->redirect($upper . '/users/create');
}
(new AdminUser())->insert([
'username' => $username,
'password' => password_hash($pwd, PASSWORD_DEFAULT),
'name' => $name,
'role' => 'none', // 子系统账号:全局后台角色为 none
$sys . '_role' => $role,
$sys . '_perms' => json_encode($perms, JSON_UNESCAPED_UNICODE),
'status' => $status,
'created_at' => date('Y-m-d H:i:s'),
]);
$this->flash('用户已创建', 'ok');
return $this->redirect($upper . '/users');
}
public function edit($id)
{
$sys = $this->sys();
$user = (new AdminUser())->find($id);
if (!$user || !$this->manageable($user)) {
return $this->redirect(strtoupper($sys) . '/users');
}
return $this->renderSubsys($sys, 'subsys/user_form', [
'user' => $user,
'sysName'=> $this->sysName(),
'pages' => \subsys_pages($sys),
'sys' => $sys,
'edit' => true,
], $this->nav(), 'users');
}
public function update($id)
{
$sys = $this->sys();
$upper = strtoupper($sys);
if (!csrf_check()) {
$this->flash('表单已过期,请重试', 'err');
return $this->redirect($upper . '/users');
}
$user = (new AdminUser())->find($id);
if (!$user || !$this->manageable($user)) {
$this->flash('无权操作该用户', 'err');
return $this->redirect($upper . '/users');
}
$username = trim($this->post('username'));
$name = trim($this->post('name'));
$pwd = $this->post('password');
$role = $this->post($sys . '_role') === 'admin' ? 'admin' : 'user';
$perms = $this->collectPerms($sys);
$status = $this->post('status') === '0' ? 0 : 1;
if ($username === '' || !preg_match('/^[a-zA-Z0-9_]{3,30}$/', $username)) {
$this->flash('账号不合法', 'err');
return $this->redirect($upper . '/users/edit/' . $id);
}
$existing = (new AdminUser())->byUsername($username);
if ($existing && (int)$existing['id'] !== (int)$id) {
$this->flash('用户名已存在', 'err');
return $this->redirect($upper . '/users/edit/' . $id);
}
if ($pwd !== '' && strlen($pwd) < 6) {
$this->flash('密码至少 6 位', 'err');
return $this->redirect($upper . '/users/edit/' . $id);
}
$data = [
'username' => $username,
'name' => $name,
$sys . '_role' => $role,
$sys . '_perms' => json_encode($perms, JSON_UNESCAPED_UNICODE),
'status' => $status,
];
if ($pwd !== '') {
$data['password'] = password_hash($pwd, PASSWORD_DEFAULT);
}
(new AdminUser())->update($id, $data);
$this->flash('用户已更新', 'ok');
return $this->redirect($upper . '/users');
}
public function destroy($id)
{
$sys = $this->sys();
$upper = strtoupper($sys);
if (!csrf_check()) {
$this->flash('操作已失效,请重试', 'err');
return $this->redirect($upper . '/users');
}
$user = (new AdminUser())->find($id);
if (!$user || !$this->manageable($user) || (int)$user['id'] === (int)admin_uid()) {
$this->flash('无法删除该用户', 'err');
return $this->redirect($upper . '/users');
}
(new AdminUser())->delete($id);
$this->flash('用户已删除', 'ok');
return $this->redirect($upper . '/users');
}
/** 管理员为该用户重置密码 */
public function reset($id)
{
$sys = $this->sys();
$upper = strtoupper($sys);
$user = (new AdminUser())->find($id);
if (!$user || !$this->manageable($user)) {
$this->flash('无权操作该用户', 'err');
return $this->redirect($upper . '/users');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_check()) {
$this->flash('表单已过期,请重试', 'err');
return $this->redirect($upper . '/users/reset/' . $id);
}
$pwd = $this->post('password');
if ($pwd === '' || strlen($pwd) < 6) {
$this->flash('密码至少 6 位', 'err');
return $this->redirect($upper . '/users/reset/' . $id);
}
(new AdminUser())->update($id, ['password' => password_hash($pwd, PASSWORD_DEFAULT)]);
$this->flash('密码已重置', 'ok');
return $this->redirect($upper . '/users');
}
return $this->renderSubsys($sys, 'subsys/user_reset', [
'user' => $user,
'sysName'=> $this->sysName(),
'sys' => $sys,
], $this->nav(), 'users');
}
/** 收集页面权限:返回 {page: bool} 形式(与后台 UserController 一致,登录时按 JSON 解码) */
private function collectPerms(string $sys): array
{
$out = [];
foreach (\subsys_pages($sys) as $p) {
if ($p === 'dashboard') continue;
$out[$p] = $this->post('perm_' . $p) ? true : false;
}
return $out;
}
/** 当前登录管理员是否可管理该目标用户(严格边界隔离:只管本系统用户,禁止跨界) */
private function manageable(array $target): bool
{
if (admin_role() === 'super_admin') return true;
$sys = $this->sys();
$other = $sys === 'crm' ? 'psi' : 'crm';
// 不能管理后台超管 / 全局管理员
if (in_array($target['role'] ?? 'none', ['super_admin', 'admin'], true)) return false;
// 只能管理本系统用户
if (($target[$sys . '_role'] ?? 'none') === 'none') return false;
// 不能跨界管理另一系统的管理员(CRM 管不了 PSI,PSI 管不了 CRM
if (($target[$other . '_role'] ?? 'none') === 'admin') return false;
return true;
}
}
+407
View File
@@ -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 XMLGoogle/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;
}
}
+43
View File
@@ -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;
}
}
+849
View File
@@ -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_keyhome/products/news/cases/about/contact...
* @param array $default 默认 SEO 数组(title/description/keywords/og_type
* @return array {title,description,keywords,og_type,og_image,canonical,noindex}
*/
function page_seo(string $key, array $default = []): array
{
$def = array_merge([
'title' => '',
'description' => '',
'keywords' => '',
'og_type' => 'website',
'og_image' => '',
'canonical' => '',
'noindex' => 0,
], $default);
try {
$row = (new \App\Models\PageSeo())->getByKey($key);
} catch (\Throwable $e) {
$row = null;
}
if (!$row) {
return $def;
}
return [
'title' => $row['title'] ?? $def['title'],
'description' => $row['description'] ?? $def['description'],
'keywords' => $row['keywords'] ?? $def['keywords'],
'og_type' => $row['og_type'] ?? $def['og_type'],
'og_image' => $row['og_image'] ?? $def['og_image'],
'canonical' => $row['canonical'] ?? $def['canonical'],
'noindex' => $row['noindex'] ?? $def['noindex'],
];
}
}
+365
View File
@@ -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(),
];
}
}
+162
View File
@@ -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());
}
}
+326
View File
@@ -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);
}
}
+82
View File
@@ -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;
}
}
+40
View File
@@ -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, '.', '');
}
}
+35
View File
@@ -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', ''),
]);
}
}
+32
View File
@@ -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;
}
}
+89
View File
@@ -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;
}
}
+118
View File
@@ -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());
}
}
+26
View File
@@ -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;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Core\Model;
class AdminUser extends Model
{
protected $table = 'admin_users';
protected $orderBy = 'id';
public function byUsername(string $u)
{
return $this->where('username', $u);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Core\Model;
class Banner extends Model
{
protected $table = 'banners';
protected $orderBy = 'sort_order';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models\CRM;
use Core\Model;
class Contact extends Model
{
protected $table = 'crm_contacts';
protected $orderBy = 'id';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models\CRM;
use Core\Model;
class Customer extends Model
{
protected $table = 'crm_customers';
protected $orderBy = 'id';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models\CRM;
use Core\Model;
class FollowUp extends Model
{
protected $table = 'crm_followups';
protected $orderBy = 'id';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models\CRM;
use Core\Model;
class Lead extends Model
{
protected $table = 'crm_leads';
protected $orderBy = 'id';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Core\Model;
class Category extends Model
{
protected $table = 'categories';
protected $orderBy = 'sort_order';
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Core\Model;
/**
* 客户案例模型(table = cases,避开 PHP 关键字 case
* 与 News 同构:支持 status 展示开关、按 published_at 排序。
*/
class CustomerCase extends Model
{
protected $table = 'cases';
protected $orderBy = 'published_at';
public function published(int $limit = 20): array
{
$all = array_filter($this->all(), fn($c) => ($c['status'] ?? 1) == 1);
return array_slice($all, 0, $limit);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Core\Model;
class News extends Model
{
protected $table = 'news';
protected $orderBy = 'published_at';
public function published(int $limit = 20): array
{
$all = array_filter($this->all(), fn($n) => ($n['status'] ?? 1) == 1);
return array_slice($all, 0, $limit);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Core\Model;
class Order extends Model
{
protected $table = 'orders';
protected $orderBy = 'id';
/** 按订单号查询 */
public function byNo(string $no)
{
return $this->where('order_no', $no);
}
/** 某客户的订单列表(按手机号或邮箱匹配) */
public function byCustomer(string $phone): array
{
$out = [];
foreach ($this->all() as $r) {
if (($r['phone'] ?? '') === $phone || ($r['email'] ?? '') === $phone) $out[] = $r;
}
return $out;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Models\PSI;
use Core\Model;
/**
* PSI 紧急事件 / 站内提醒(psi_events
* 所有 PSI 相关人员可见;read_by 记录已读的管理员 uid。
*/
class Event extends Model
{
protected $table = 'psi_events';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models\PSI;
use Core\Model;
class Material extends Model
{
protected $table = 'psi_materials';
protected $orderBy = 'id';
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Models\PSI;
use Core\Model;
class Outbound extends Model
{
protected $table = 'psi_outbounds';
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Models\PSI;
use Core\Model;
class OutboundItem extends Model
{
protected $table = 'psi_outbound_items';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models\PSI;
use Core\Model;
class Product extends Model
{
protected $table = 'psi_products';
protected $orderBy = 'id';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models\PSI;
use Core\Model;
class Purchase extends Model
{
protected $table = 'psi_purchases';
protected $orderBy = 'id';
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Models\PSI;
use Core\Model;
class PurchaseOrder extends Model
{
protected $table = 'psi_purchase_orders';
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Models\PSI;
use Core\Model;
class PurchaseOrderItem extends Model
{
protected $table = 'psi_purchase_order_items';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models\PSI;
use Core\Model;
class Sales extends Model
{
protected $table = 'psi_sales';
protected $orderBy = 'id';
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Models\PSI;
use Core\Model;
class SalesOrder extends Model
{
protected $table = 'psi_sales_orders';
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Models\PSI;
use Core\Model;
class SalesOrderItem extends Model
{
protected $table = 'psi_sales_order_items';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models\PSI;
use Core\Model;
class StockMove extends Model
{
protected $table = 'psi_stock_moves';
protected $orderBy = 'id';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models\PSI;
use Core\Model;
class Supplier extends Model
{
protected $table = 'psi_suppliers';
protected $orderBy = 'id';
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Core\Model;
class Page extends Model
{
protected $table = 'pages';
protected $orderBy = 'id';
public function bySlug(string $slug)
{
return $this->where('slug', $slug);
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Core\Db;
use Core\Model;
/**
* 逐页 SEO 设置模型
* 表 page_seo 以 page_keyhome/products/product_category/news/cases/about/contact)为主键,
* 后台可逐页维护 标题/描述/关键词/Open Graph/规范链接/收录开关。
*/
class PageSeo extends Model
{
protected $table = 'page_seo';
protected $primaryKey = 'id';
/** 按 page_key 取单条 */
public function getByKey(string $key): ?array
{
$row = Db::query("SELECT * FROM {$this->table} WHERE page_key = ?", [$key])->fetch();
return $row ?: null;
}
/** 全部以 page_key 为索引返回 */
public function allIndexed(): array
{
$rows = Db::query("SELECT * FROM {$this->table} ORDER BY sort ASC, id ASC")->fetchAll();
$out = [];
foreach ($rows as $r) {
$out[$r['page_key']] = $r;
}
return $out;
}
/** 存在则更新,不存在则插入 */
public function saveRow(string $key, array $data): void
{
$exists = Db::query("SELECT 1 FROM {$this->table} WHERE page_key = ?", [$key])->fetch();
if ($exists) {
$sets = [];
$params = [];
foreach ($data as $k => $v) {
$sets[] = "`{$k}` = ?";
$params[] = $v;
}
$params[] = $key;
Db::query("UPDATE {$this->table} SET " . implode(', ', $sets) . " WHERE page_key = ?", $params);
} else {
$cols = array_keys($data);
$ph = array_fill(0, count($cols), '?');
Db::query(
"INSERT INTO {$this->table} (`page_key`, `" . implode('`,`', $cols) . "`) VALUES (?, " . implode(',', $ph) . ")",
array_merge([$key], array_values($data))
);
}
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Core\Model;
class Payment extends Model
{
protected $table = 'payments';
protected $orderBy = 'id';
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Core\Model;
class Product extends Model
{
protected $table = 'products';
protected $orderBy = 'sort_order';
public function byCategory(int $catId): array
{
return $this->whereAll('category_id', $catId);
}
public function featured(int $limit = 6): array
{
return array_slice(array_filter($this->all(), fn($p) => ($p['status'] ?? 1) == 1), 0, $limit);
}
public function specsArray($p): array
{
$s = $p['specs'] ?? '';
if (is_array($s)) return $s;
$dec = json_decode((string)$s, true);
return is_array($dec) ? $dec : [];
}
public function galleryArray($p): array
{
$g = $p['gallery'] ?? '';
if (is_array($g)) return $g;
$dec = json_decode((string)$g, true);
return is_array($dec) ? $g : [];
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Core\Model;
class Setting extends Model
{
protected $table = 'settings';
protected $orderBy = 'id';
/** 读取单个设置 */
public function get(string $key, $default = '')
{
$row = $this->where('skey', $key);
return $row ? $row['sval'] : $default;
}
/** 批量读取为 [skey => sval] */
public function allKV(): array
{
$out = [];
foreach ($this->all() as $r) $out[$r['skey']] = $r['sval'];
return $out;
}
/** 设置(不存在则新增) */
public function set(string $key, $val, string $group = 'site'): void
{
$row = $this->where('skey', $key);
if ($row) {
$this->update($row['id'], ['sval' => $val, 'sgroup' => $group]);
} else {
$this->insert(['skey' => $key, 'sval' => $val, 'sgroup' => $group]);
}
}
/** 批量保存 */
public function saveMany(array $pairs, string $group = 'site'): void
{
foreach ($pairs as $k => $v) $this->set($k, $v, $group);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
$v = function ($k, $d = '') use ($b) { return $b ? ($b[$k] ?? $d) : $d; };
$isEdit = !empty($b);
?>
<div class="page-head">
<div><h1><?php echo $isEdit ? '编辑轮播' : '新增轮播'; ?></h1><div class="desc">背景可填 CSS 渐变或图片地址</div></div>
<a class="btn-ghost" href="<?php echo site_url('admin/banners'); ?>">← 返回</a>
</div>
<div class="admin-card">
<form method="post" action="<?php echo site_url($isEdit ? 'admin/banners/update/' . $b['id'] : 'admin/banners/store'); ?>" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<div class="form-grid">
<div class="field"><label>标题 *</label><input name="title" value="<?php echo e($v('title')); ?>" required></div>
<div class="field"><label>副标题</label><input name="subtitle" value="<?php echo e($v('subtitle')); ?>"></div>
</div>
<div class="field"><label>链接地址(如 /products</label><input name="link" value="<?php echo e($v('link')); ?>"></div>
<div class="form-grid">
<div class="field"><label>背景图(上传,可选)</label><input type="file" name="image" accept="image/*"></div>
<div class="field"><label>或背景(图片地址 / CSS 渐变)</label><input name="image" value="<?php echo e($v('image')); ?>" placeholder="linear-gradient(135deg,#0ea5e9,#14b8a6)"></div>
</div>
<div class="field"><label>排序</label><input name="sort_order" type="number" value="<?php echo e($v('sort_order', 0)); ?>"></div>
<div class="field" style="display:flex;align-items:center;gap:8px">
<input type="checkbox" name="status" value="1" <?php echo $v('status', 1) ? 'checked' : ''; ?> id="st"> <label for="st" style="margin:0">显示</label>
</div>
<div class="form-actions">
<button class="btn-primary" type="submit">保存</button>
<a class="btn-ghost" href="<?php echo site_url('admin/banners'); ?>">取消</a>
</div>
</form>
</div>
+23
View File
@@ -0,0 +1,23 @@
<div class="page-head">
<div><h1>轮播管理</h1><div class="desc">首页轮播图(背景支持渐变或图片)</div></div>
<a class="btn-primary" href="<?php echo site_url('admin/banners/create'); ?>">+ 新增轮播</a>
</div>
<div class="admin-card">
<table class="admin-table">
<tr><th>背景</th><th>标题</th><th>副标题</th><th>链接</th><th>操作</th></tr>
<?php foreach ($banners as $b): ?>
<tr>
<td><div class="thum" style="background:<?php echo e($b['image']); ?>">🖼</div></td>
<td><b><?php echo e($b['title']); ?></b></td>
<td class="muted"><?php echo e(mb_substr($b['subtitle'] ?? '', 0, 20)); ?></td>
<td class="muted"><?php echo e($b['link']); ?></td>
<td>
<div class="row-actions">
<a class="btn-soft btn-sm" href="<?php echo site_url('admin/banners/edit/' . $b['id']); ?>">编辑</a>
<form class="del-form" method="post" action="<?php echo site_url('admin/banners/delete/' . $b['id']); ?>"><?php echo csrf_field(); ?><button class="btn-danger btn-sm" type="submit">删除</button></form>
</div>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
+32
View File
@@ -0,0 +1,32 @@
<div class="page-head">
<div><h1>客户案例管理</h1><div class="desc">客户案例展示,可像新闻一样持续新增</div></div>
<a class="btn-primary" href="<?php echo site_url('admin/cases/create'); ?>">+ 新增案例</a>
</div>
<div class="admin-card">
<table class="admin-table">
<tr><th>封面</th><th>案例标题</th><th>模式</th><th>客户</th><th>行业</th><th>日期</th><th>状态</th><th>操作</th></tr>
<?php foreach ($cases as $c): ?>
<?php $cm = empty($c['mode']) ? 'fixed' : $c['mode']; ?>
<tr>
<td><div class="thum" style="background:<?php echo gradient($c['id']); ?>">🤝</div></td>
<td><b><?php echo e($c['title']); ?></b></td>
<td><span class="mode-badge <?php echo $cm === 'builder' ? 'builder' : 'fixed'; ?>"><?php echo $cm === 'builder' ? '可视化' : '固定'; ?></span></td>
<td class="muted"><?php echo e($c['customer'] ?? ''); ?></td>
<td class="muted"><?php echo e($c['industry'] ?? ''); ?></td>
<td class="muted"><?php echo e(format_date($c['published_at'])); ?></td>
<td><?php echo ($c['status'] ?? 1) ? '<span class="tag-mini">展示中</span>' : '<span class="muted">隐藏</span>'; ?></td>
<td>
<div class="row-actions">
<a class="btn-soft btn-sm" href="<?php echo site_url('admin/cases/edit/' . $c['id']); ?>">编辑</a>
<form class="del-form" method="post" action="<?php echo site_url('admin/cases/delete/' . $c['id']); ?>"><?php echo csrf_field(); ?><button class="btn-danger btn-sm" type="submit">删除</button></form>
</div>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<style>
.mode-badge{display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600}
.mode-badge.fixed{background:#f1f5f9;color:#475569}
.mode-badge.builder{background:#ede9fe;color:#6d28d9}
</style>
+92
View File
@@ -0,0 +1,92 @@
<?php
/** @var array $c */
/** @var array $layout */
/** @var string $mode */
$v = function ($k, $d = '') use ($c) { return $c ? ($c[$k] ?? $d) : $d; };
$isEdit = !empty($c);
$switchUrl = $isEdit ? site_url('admin/cases/switchMode/' . (int)$c['id']) : '';
$initial = json_encode($layout ?: [], JSON_UNESCAPED_UNICODE);
$coverVal = $v('cover');
?>
<div class="page-head">
<div>
<h1><?php echo $isEdit ? '编辑客户案例' : '新增客户案例'; ?><span class="mode-badge mode-builder">可视化编辑</span></h1>
<div class="desc">可视化编辑:拖拽文字 / 图片 / 按钮自由排版,保存后前台按此布局整页展示。</div>
</div>
<?php if ($isEdit): ?>
<div class="mode-switch-sel">
<label for="modeSel">切换模式</label>
<select id="modeSel" class="mode-sel" onchange="location.href='<?php echo $switchUrl; ?>?mode='+this.value">
<option value="fixed" <?php echo $mode === 'fixed' ? 'selected' : ''; ?>>固定版面</option>
<option value="builder" <?php echo $mode === 'builder' ? 'selected' : ''; ?>>可视化编辑</option>
</select>
</div>
<?php else: ?>
<div class="mode-switch-sel">
<label for="modeSel">排版方式</label>
<select id="modeSel" class="mode-sel">
<option value="fixed" <?php echo $mode === 'fixed' ? 'selected' : ''; ?>>固定版面</option>
<option value="builder" <?php echo $mode === 'builder' ? 'selected' : ''; ?>>可视化编辑</option>
</select>
</div>
<?php endif; ?>
</div>
<div class="admin-card">
<form id="pbForm" method="post" action="<?php echo site_url($isEdit ? 'admin/cases/update/' . $c['id'] : 'admin/cases/store'); ?>" enctype="multipart/form-data">
<?php echo csrf_field(); ?>
<input type="hidden" name="mode" value="builder">
<input type="hidden" name="layout" id="pbLayout">
<div class="field"><label>案例标题 *</label><input name="title" value="<?php echo e($v('title')); ?>" required></div>
<div class="form-grid">
<div class="field"><label>客户名称</label><input name="customer" value="<?php echo e($v('customer')); ?>"></div>
<div class="field"><label>所属行业</label><input name="industry" value="<?php echo e($v('industry')); ?>"></div>
</div>
<div class="form-grid">
<div class="field"><label>服务时间</label><input type="date" name="published_at" value="<?php echo e($v('published_at', date('Y-m-d'))); ?>"></div>
<div class="field"><label>排序权重</label><input type="number" name="sort_order" value="<?php echo e($v('sort_order', 0)); ?>" placeholder="越大越靠前"></div>
</div>
<div class="field"><label>摘要</label><input name="summary" value="<?php echo e($v('summary')); ?>"></div>
<div class="field"><label>URL 标识(留空按序号生成)</label><input name="slug" value="<?php echo e($v('slug')); ?>" placeholder="留空则自动生成如 12"></div>
<div class="form-grid">
<div class="field"><label>封面图(上传,可选)</label><input type="file" name="cover" accept="image/*"></div>
<div class="field"><label>或填写图片地址/路径</label><input name="cover_url" value="<?php echo e($coverVal); ?>" placeholder="assets/uploads/xxx.jpg 或 http(s)://"></div>
</div>
<div class="field">
<label>可视化画布(拖拽排版案例详情)</label>
<?php echo \Core\View::buffer('admin/parts/builder', ['module' => 'case', 'layout' => $layout]); ?>
</div>
<div class="field" style="display:flex;align-items:center;gap:8px">
<input type="checkbox" name="status" value="1" <?php echo $v('status', 1) ? 'checked' : ''; ?> id="st"> <label for="st" style="margin:0">在前台展示</label>
</div>
<div class="form-actions">
<button class="btn-primary" type="submit">保存</button>
<a class="btn-ghost" href="<?php echo site_url('admin/cases'); ?>">取消</a>
</div>
</form>
</div>
<style>
.mode-switch-sel{display:inline-flex;align-items:center;gap:8px}
.mode-switch-sel label{font-size:13px;color:#64748b}
.mode-sel{border:1px solid #e2e8f0;border-radius:10px;padding:8px 12px;font-size:14px;background:#fff;color:#334155;cursor:pointer;min-width:140px}
.mode-sel:focus{border-color:#0ea5e9;box-shadow:0 0 0 3px rgba(14,165,233,.12)}
.mode-badge{display:inline-block;margin-left:10px;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;vertical-align:middle;line-height:1.7}
.mode-badge.mode-fixed{background:#e0f2fe;color:#0369a1}
.mode-badge.mode-builder{background:#ede9fe;color:#6d28d9}
</style>
<script>
window.__PB_INIT__ = <?php echo $initial; ?>;
window.__PB_MEDIA__ = '<?php echo site_url('admin/media'); ?>';
window.__PB_UPLOAD__ = '<?php echo site_url('admin/media/upload'); ?>';
window.__PB_CSRF__ = '<?php echo csrf_token(); ?>';
window.__PB_MODULE__ = 'case';
window.__PB_DELETE__ = '<?php echo site_url('admin/media/delete/'); ?>';
</script>
<script src="<?php echo asset('js/page-builder.js'); ?>"></script>

Some files were not shown because too many files have changed in this diff Show More