文件还在测试中
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' => '删除失败']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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-LD,GEO 高杠杆信号)────
|
||||
$faqs = [
|
||||
['q' => '降温服是什么?它是怎么实现降温的?', 'a' => '降温服是一类为高温作业人群设计的主动或被动降温装备,主要通过三种原理散热:水冷循环(微型水泵驱动冷水在服装内管路循环带走体热)、相变蓄冷(冰袋或凝胶相变材料在融化过程中持续吸热)、涡扇风冷(小型风扇强制对流散热)。酷冰甲提供这三大系列,覆盖不同场景与续航需求。'],
|
||||
['q' => '穿降温服体感能降多少度?多久能起效?', 'a' => '在常规高温环境下,合格降温服可让核心体表感温度下降约 8–12℃。水冷与风冷方案接通或开机后数分钟内即可感受到明显凉意;相变冰袋方案放入预冷冰袋后即刻生效,单组冰袋可持续 2–4 小时。'],
|
||||
['q' => '降温服可以重复使用吗?一套能用多久?', 'a' => '可以。酷冰甲降温服主体为可水洗服装,水冷、风冷模块与相变冰袋均可反复使用。服装本体在正常保养下可用 2–3 个高温季,冰袋与电池模块按使用频率约 1–2 年更换即可。'],
|
||||
['q' => '支持企业定制和 LOGO 刺绣吗?起订量多少?', 'a' => '支持。我们提供企业 LOGO 绣字、颜色与面料定制、一人一码量体服务。柔性化生产,10 套起订,确认图纸后 7 天打样、约 28 天批量交付,适合班组、车间等小批量统一配发。'],
|
||||
['q' => '降温服适合哪些行业和场景?', 'a' => '广泛用于消防、钢铁、电力、化工、环卫、建筑、物流及户外军训等高温或暴晒场景,也适用于骑行、垂钓、观赛等个人户外降温。可按行业工况推荐对应系列与续航配置。'],
|
||||
['q' => '降温服怎么清洗和保养?', 'a' => '服装本体可轻柔机洗或手洗,避免浸泡电子模块;水冷、风冷主机与电池需拆下后擦干存放,冰袋用后擦干冷藏。长期不用请置于阴凉干燥处,电池保持半电存放。'],
|
||||
];
|
||||
$faqJsonLd = '<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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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) : [];
|
||||
}
|
||||
}
|
||||
@@ -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-LD,GEO 信号)────
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user