文件还在测试中

This commit is contained in:
2026-08-08 15:53:53 +08:00
parent 5f1e7adb64
commit 8101177cb5
241 changed files with 18074 additions and 22 deletions
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\Controller;
/** 后台基控制器:需要登录 */
class AdminController extends Controller
{
protected $layout = 'layouts/admin';
public function __construct()
{
admin_required();
}
/** 当前路由片段,用于侧栏高亮 */
protected function seg(): string
{
$u = trim(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH), '/');
$parts = explode('/', $u);
return $parts[1] ?? '';
}
/**
* 处理上传文件,返回相对站点根的路径(如 assets/uploads/x.jpg),无上传则返回 null。
* 安全红线:
* 1) 仅凭扩展名不可信 —— 光栅图用 getimagesize 校验真实图像内容;
* 2) 文件体积上限 8MB,防止磁盘打满 / DoS;
* 3) SVG 可内嵌 <script>/on* 事件 → 存储型 XSS,落盘前强制消毒;
* 4) 落盘文件名随机化,杜绝路径穿越与覆盖。
*/
protected function uploadFile(string $key): ?string
{
if (empty($_FILES[$key]['tmp_name'])) return null;
$f = $_FILES[$key];
if ($f['error'] !== UPLOAD_ERR_OK) return null;
if (!is_uploaded_file($f['tmp_name'])) return null;
if (($f['size'] ?? 0) > 8 * 1024 * 1024) return null; // 8MB 上限
$ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
if (!in_array($ext, $allowed, true)) return null;
// 真实类型校验(不信任扩展名与浏览器提交的 MIME)
$realMime = function_exists('finfo_open')
? (finfo_file(($fi = finfo_open(FILEINFO_MIME_TYPE)), $f['tmp_name']) ?: '') : '';
if (isset($fi) && $fi) { finfo_close($fi); }
$isSvg = ($ext === 'svg');
if (!$isSvg) {
// 光栅图:必须能被 GD 识别为真实图像,且 MIME 属于图片类
$info = @getimagesize($f['tmp_name']);
$okMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if ($info === false) return null;
if ($realMime && !in_array($realMime, $okMimes, true)) return null;
} else {
// SVG:类型须为 svg/xml/text,随后消毒内容
$svgMimes = ['image/svg+xml', 'text/plain', 'text/xml', 'application/xml'];
if ($realMime && !in_array($realMime, $svgMimes, true)) return null;
}
$dir = BASE_PATH . '/public/assets/uploads';
if (!is_dir($dir)) mkdir($dir, 0755, true);
$name = uniqid('u_') . '.' . $ext;
$dest = $dir . '/' . $name;
if ($isSvg) {
// 读取 → 消毒 → 写入(不使用 move_uploaded_file,因内容已被改写)
$raw = @file_get_contents($f['tmp_name']);
if ($raw === false) return null;
$clean = $this->sanitizeSvg($raw);
if ($clean === '' || @file_put_contents($dest, $clean) === false) return null;
} else {
if (!move_uploaded_file($f['tmp_name'], $dest)) return null;
}
return 'assets/uploads/' . $name;
}
/**
* 处理多文件上传(name="gallery[]"),返回相对站点根路径数组。
* 逐张复用 uploadFile 的校验逻辑(真实图像/体积上限/类型白名单/SVG 消毒),
* 任一文件失败不影响其余文件。
*/
protected function uploadFiles(string $key): array
{
if (empty($_FILES[$key]['tmp_name']) || !is_array($_FILES[$key]['tmp_name'])) return [];
$names = $_FILES[$key]['name'] ?? [];
$errors = $_FILES[$key]['error'] ?? [];
$sizes = $_FILES[$key]['size'] ?? [];
$out = [];
foreach ($_FILES[$key]['tmp_name'] as $i => $tmp) {
if (empty($tmp)) continue;
// 桥接到单文件校验逻辑
$_FILES['_multi_tmp'] = [
'name' => $names[$i] ?? 'x.bin',
'type' => '',
'tmp_name' => $tmp,
'error' => $errors[$i] ?? UPLOAD_ERR_OK,
'size' => $sizes[$i] ?? 0,
];
$rel = $this->uploadFile('_multi_tmp');
unset($_FILES['_multi_tmp']);
if ($rel) $out[] = $rel;
}
return $out;
}
/** 消毒 SVG:移除脚本、事件处理器与危险协议,阻断存储型 XSS */
protected function sanitizeSvg(string $svg): string
{
// 去除 <script>...</script>
$svg = preg_replace('#<script[^>]*>.*?</script>#is', '', $svg);
// 去除 <foreignObject>(可嵌 HTML/脚本)
$svg = preg_replace('#<foreignObject[^>]*>.*?</foreignObject>#is', '', $svg);
// 去除内联事件处理器 on*="..."
$svg = preg_replace('#\son\w+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $svg);
// 去除 javascript: / data:text 等危险协议引用
$svg = preg_replace('#(href|xlink:href)\s*=\s*("|\')?\s*javascript:[^"\'>]*#i', '', $svg);
// 去除 <use> 外部引用与 <a> 标签,避免脚本跳转
$svg = preg_replace('#<a[^>]*>|</a>#i', '', $svg);
return trim((string)$svg);
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\Controller;
use App\Models\AdminUser;
use Core\App;
class AuthController extends Controller
{
protected $layout = 'layouts/admin';
public function login()
{
if (is_admin()) { $this->redirect(login_landing()); }
$error = '';
$blocked = false;
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 质量红线:登录必须校验 CSRF + 验证码 + IP 级双窗口限速(失败 10 分钟/5 次、成功 30 分钟/5 次),杜绝机器人暴力破解
if (!csrf_check()) {
$error = '表单已过期,请刷新页面后重试';
} elseif (ip_login_blocked($ip)) {
$error = '尝试次数过多,请 30 分钟后再试';
$blocked = true;
} elseif (!captcha_check($this->post('captcha'))) {
$error = '验证码错误,请重新计算';
} else {
$u = trim($this->post('username'));
$p = $this->post('password');
$ok = false;
$user = null;
$found = (new AdminUser())->byUsername($u);
if ($found && (int)($found['status'] ?? 1) === 1 && password_verify($p, $found['password'])) {
$ok = true; $user = $found;
} elseif ($this->fallbackEnabled() && $u === \Core\App::config('admin.username') && $p === \Core\App::config('admin.password')) {
// 配置文件兜底账号:仅本地/演示模式开启(本地规则要求生产禁用默认密码后门)
$ok = true;
$user = ['id' => 0, 'username' => $u, 'name' => '管理员', 'role' => 'super_admin'];
}
if ($ok) {
ip_login_register_success($ip); // 记录成功登录(纳入 30 分钟 5 次上限),并重置失败计数
session_regenerate_id(true); // 防会话固定
$_SESSION['admin_logged'] = true;
$_SESSION['admin_id'] = $user['id'] ?? 0;
$_SESSION['admin_name'] = $user['name'] ?? $u;
$_SESSION['admin_role'] = $user['role'] ?? 'super_admin';
$_SESSION['crm_role'] = $user['crm_role'] ?? 'none';
$_SESSION['psi_role'] = $user['psi_role'] ?? 'none';
$dec = function ($v) { $a = @json_decode((string)$v, true); return is_array($a) ? $a : null; };
$_SESSION['crm_perms'] = $dec($user['crm_perms'] ?? null);
$_SESSION['psi_perms'] = $dec($user['psi_perms'] ?? null);
$this->redirect(login_landing());
}
ip_login_register_fail($ip); // 记录一次失败(纳入 10 分钟 5 次上限)
$error = '用户名或密码错误';
}
}
// 被限速(IP 失败/成功过多)时返回 429 + Retry-After,明确告知客户端稍后再试
if ($blocked && !headers_sent()) {
http_response_code(429);
header('Retry-After: ' . ip_login_remaining($ip));
}
$captcha = captcha_make(); // 每次渲染都发放新的算术验证码
return $this->view('admin/login', ['error' => $error, 'captcha' => $captcha]);
}
/** 配置文件兜底账号是否启用:生产(mysql 模式)默认关闭,本地演示可开 */
private function fallbackEnabled(): bool
{
return (bool) \Core\App::config('admin.allow_config_fallback', false)
&& \Core\App::config('app.driver', 'file') !== 'mysql';
}
/** 修改当前登录账号的密码 */
public function password()
{
admin_required();
$error = '';
$ok = '';
$uid = admin_uid();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_check()) {
$error = '表单已过期,请重试';
} else {
$cur = $this->post('current_password');
$new = $this->post('new_password');
$confirm = $this->post('confirm_password');
$m = new AdminUser();
$me = $uid ? $m->find($uid) : null;
if (!$me) {
$error = '账号异常,请重新登录';
} elseif (!password_verify($cur, $me['password'])) {
$error = '当前密码不正确';
} elseif (strlen($new) < 6) {
$error = '新密码至少 6 位';
} elseif ($new !== $confirm) {
$error = '两次输入的密码不一致';
} else {
$m->update($uid, ['password' => password_hash($new, PASSWORD_DEFAULT)]);
$ok = '密码修改成功';
}
}
}
return $this->view('admin/password', ['error' => $error, 'ok' => $ok]);
}
public function logout()
{
unset($_SESSION['admin_logged'], $_SESSION['admin_name'], $_SESSION['admin_id'], $_SESSION['admin_role'], $_SESSION['crm_role'], $_SESSION['psi_role'], $_SESSION['crm_perms'], $_SESSION['psi_perms']);
$this->redirect('admin/login');
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Controllers\Admin;
use App\Models\Banner;
class BannerController extends AdminController
{
public function index()
{
return $this->view('admin/banners', [
'banners' => (new Banner())->all(),
'seg' => $this->seg(),
]);
}
public function create()
{
return $this->view('admin/banner_form', ['b' => null]);
}
public function store()
{
if (!csrf_check()) { $this->redirect('admin/banners'); }
$image = $this->uploadFile('image') ?? $this->post('image', 'linear-gradient(135deg,#0ea5e9,#14b8a6)');
(new Banner())->insert([
'title' => $this->post('title'),
'subtitle' => $this->post('subtitle'),
'image' => $image,
'link' => $this->post('link', ''),
'sort_order' => (int)$this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
]);
$this->redirect('admin/banners');
}
public function edit($id)
{
$b = (new Banner())->find($id);
if (!$b) { $this->redirect('admin/banners'); }
return $this->view('admin/banner_form', ['b' => $b]);
}
public function update($id)
{
if (!csrf_check()) { $this->redirect('admin/banners'); }
$banner = new Banner();
$b = $banner->find($id);
if (!$b) { $this->redirect('admin/banners'); }
$image = $this->uploadFile('image');
if (!$image && $this->post('image')) $image = $this->post('image');
if (!$image) $image = $b['image'] ?? '';
$banner->update($id, [
'title' => $this->post('title'),
'subtitle' => $this->post('subtitle'),
'image' => $image,
'link' => $this->post('link', ''),
'sort_order' => (int)$this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
]);
$this->redirect('admin/banners');
}
public function delete($id)
{
if (csrf_check()) { (new Banner())->delete($id); }
$this->redirect('admin/banners');
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace App\Controllers\Admin;
use App\Models\CustomerCase;
/**
* 后台「客户案例」管理:增删改查,与新闻管理同构。
* 权限能力键:cases(在 Helper::admin_role_map 中分配)。
*/
class CaseController extends AdminController
{
public function index()
{
return $this->view('admin/cases', [
'cases' => (new CustomerCase())->all(),
'seg' => $this->seg(),
]);
}
public function create()
{
return $this->view('admin/cases_form', [
'c' => null,
'mode' => 'fixed',
]);
}
public function store()
{
if (!csrf_check()) { $this->redirect('admin/cases'); }
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$cover = $this->uploadFile('cover') ?? $this->post('cover_url', '');
$content = ($mode === 'fixed') ? $this->post('content', '') : '';
$model = new CustomerCase();
$id = $model->insert([
'title' => $this->post('title'),
'slug' => '',
'customer' => $this->post('customer', ''),
'industry' => $this->post('industry', ''),
'cover' => $cover,
'summary' => $this->post('summary'),
'content' => $content,
'published_at' => $this->post('published_at', date('Y-m-d')),
'sort_order' => (int) $this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
'views' => 0,
'layout' => $this->post('layout', ''),
'mode' => $mode,
]);
// URL 标识留空时按记录序号顺序生成(短、稳定),避免中文标题导致过长
$slug = $this->post('slug') ? slugify($this->post('slug')) : (string)$id;
$model->update($id, ['slug' => $slug]);
// 新建时若选择「可视化编辑」,保存后直接进入可视化编辑器排版
if ($mode === 'builder') {
$this->redirect('admin/cases/edit/' . $id);
}
$this->redirect('admin/cases');
}
public function edit($id)
{
$model = new CustomerCase();
$c = $model->find($id);
if (!$c) { $this->redirect('admin/cases'); }
$mode = empty($c['mode']) ? 'fixed' : $c['mode'];
if ($mode === 'builder') {
$layout = [];
if (!empty($c['layout'])) {
$dec = json_decode($c['layout'], true);
if (is_array($dec)) $layout = $dec;
}
return $this->view('admin/cases_builder', ['c' => $c, 'layout' => $layout, 'mode' => $mode]);
}
return $this->view('admin/cases_form', ['c' => $c, 'mode' => $mode]);
}
/** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 mode 字段 */
public function switchMode($id)
{
$c = (new CustomerCase())->find($id);
if (!$c) { $this->redirect('admin/cases'); }
$target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed';
(new CustomerCase())->update($id, ['mode' => $target]);
$this->redirect('admin/cases/edit/' . $id);
}
public function update($id)
{
if (!csrf_check()) { $this->redirect('admin/cases'); }
$model = new CustomerCase();
$c = $model->find($id);
if (!$c) { $this->redirect('admin/cases'); }
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$cover = $this->uploadFile('cover');
if (!$cover && $this->post('cover_url')) $cover = $this->post('cover_url');
if (!$cover) $cover = $c['cover'] ?? '';
$data = [
'title' => $this->post('title'),
'slug' => $this->post('slug') ? slugify($this->post('slug')) : (string)$id,
'customer' => $this->post('customer', ''),
'industry' => $this->post('industry', ''),
'cover' => $cover,
'summary' => $this->post('summary'),
'published_at' => $this->post('published_at', date('Y-m-d')),
'sort_order' => (int) $this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
'mode' => $mode,
];
if ($mode === 'fixed') {
$data['content'] = $this->post('content', '');
} else {
$layout = $this->post('layout', '');
if ($layout !== '' && !is_array(json_decode($layout, true))) { $layout = ''; }
$data['layout'] = $layout;
}
$model->update($id, $data);
$this->redirect('admin/cases');
}
public function delete($id)
{
if (csrf_check()) { (new CustomerCase())->delete($id); }
$this->redirect('admin/cases');
}
}
@@ -0,0 +1,111 @@
<?php
namespace App\Controllers\Admin;
use App\Models\Category;
class CategoryController extends AdminController
{
public function index()
{
return $this->view('admin/categories', [
'cats' => (new Category())->all(),
'seg' => $this->seg(),
]);
}
public function create()
{
return $this->view('admin/category_form', [
'c' => null,
'mode' => 'fixed',
]);
}
public function store()
{
if (!csrf_check()) { $this->redirect('admin/categories'); }
$cat = new Category();
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$description = ($mode === 'fixed') ? $this->post('description', '') : '';
$id = $cat->insert([
'name' => $this->post('name'),
'slug' => '',
'icon' => $this->post('icon', '❄'),
'description' => $description,
'sort_order' => (int)$this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
'layout' => $this->post('layout', ''),
'mode' => $mode,
]);
// URL 标识留空时按记录序号顺序生成(短、稳定)
$slug = $this->post('slug') ? slugify($this->post('slug')) : (string)$id;
$cat->update($id, ['slug' => $slug]);
// 新建时若选择「可视化编辑」,保存后直接进入可视化编辑器排版
if ($mode === 'builder') {
$this->redirect('admin/categories/edit/' . $id);
}
$this->redirect('admin/categories');
}
public function edit($id)
{
$cat = new Category();
$c = $cat->find($id);
if (!$c) { $this->redirect('admin/categories'); }
$mode = empty($c['mode']) ? 'fixed' : $c['mode'];
if ($mode === 'builder') {
$layout = [];
if (!empty($c['layout'])) {
$dec = json_decode($c['layout'], true);
if (is_array($dec)) $layout = $dec;
}
return $this->view('admin/category_builder', ['c' => $c, 'layout' => $layout, 'mode' => $mode]);
}
return $this->view('admin/category_form', ['c' => $c, 'mode' => $mode]);
}
/** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 mode 字段 */
public function switchMode($id)
{
$c = (new Category())->find($id);
if (!$c) { $this->redirect('admin/categories'); }
$target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed';
(new Category())->update($id, ['mode' => $target]);
$this->redirect('admin/categories/edit/' . $id);
}
public function update($id)
{
if (!csrf_check()) { $this->redirect('admin/categories'); }
$cat = new Category();
$c = $cat->find($id);
if (!$c) { $this->redirect('admin/categories'); }
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$data = [
'name' => $this->post('name'),
'slug' => $this->post('slug') ? slugify($this->post('slug')) : (string)$id,
'icon' => $this->post('icon', '❄'),
'sort_order' => (int)$this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
'mode' => $mode,
];
if ($mode === 'fixed') {
$data['description'] = $this->post('description', '');
} else {
$layout = $this->post('layout', '');
if ($layout !== '' && !is_array(json_decode($layout, true))) { $layout = ''; }
$data['layout'] = $layout;
}
$cat->update($id, $data);
$this->redirect('admin/categories');
}
public function delete($id)
{
if (csrf_check()) { (new Category())->delete($id); }
$this->redirect('admin/categories');
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Controllers\Admin;
use App\Models\Product;
use App\Models\Category;
use App\Models\News;
use App\Models\Banner;
use App\Models\Setting;
class DashboardController extends AdminController
{
public function index()
{
$setting = new Setting();
$data = [
'site_name' => $setting->get('site_name', '酷冰甲 · 降温服'),
'counts' => [
'products' => (new Product())->count(),
'categories' => (new Category())->count(),
'news' => (new News())->count(),
'banners' => (new Banner())->count(),
],
'news' => (new News())->published(5),
];
return $this->view('admin/dashboard', $data);
}
}
@@ -0,0 +1,322 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\Controller;
use Core\App;
use Core\Db;
/**
* 后台:数据库管理
* - 列出全部数据表(MySQL 模式)。
* - 浏览任意表数据、查看/编辑/新增/删除记录(基于主键)。
* - 仅 MySQL 模式可用;file 模式仅做说明提示。
*
* 路由说明:App::dispatchAdmin 会以 URL 段作为方法名调用本控制器,
* 故 browse/edit/update/create/store/delete 均为 public,并在内部用
* App::parseRoute() 重新解析完整路径(含第 3、4 段)以拿到表名与记录主键。
* /admin/db -> index() 表列表
* /admin/db/browse/<table> -> browse(table) 浏览(?q= 搜索、?page= 分页)
* /admin/db/edit/<table>/<id> -> edit(table,id) 编辑表单
* /admin/db/update/<table>/<id> -> update(...) 保存(POST
* /admin/db/create/<table> -> create(table) 新增表单
* /admin/db/store/<table> -> store(...) 新增保存(POST
* /admin/db/delete/<table>/<id> -> delete(...) 删除(GET + 确认)
*
* 安全:表名与列名均来自 DESCRIBE / SHOW TABLES 白名单,杜绝 SQL 注入。
*/
class DatabaseController extends Controller
{
protected $layout = 'layouts/admin';
/** 解析完整路径:['admin','db', action, table, id] */
private function route(): array
{
return \Core\App::parseRoute();
}
/** 路由中的表名段(full[3] */
private function segTable(): ?string
{
$r = $this->route();
return $r[3] ?? null;
}
/** 路由中的记录主键段(full[4]) */
private function segId(): ?string
{
$r = $this->route();
return $r[4] ?? null;
}
/** 表列表 */
public function index($ignored = null)
{
admin_required();
if (Db::driver() !== 'mysql') {
return $this->view('admin/db_tables', [
'fileMode' => true,
'dataFiles' => $this->fileDataFiles(),
]);
}
return $this->tablesList();
}
/** 浏览记录 */
public function browse($table = null)
{
admin_required();
if (Db::driver() !== 'mysql') { return $this->tablesList(); }
$table = $table ?? $this->segTable();
return $this->doBrowse($table);
}
/** 编辑 / 新增表单 */
public function edit($table = null)
{
admin_required();
if (Db::driver() !== 'mysql') { return $this->tablesList(); }
$table = $table ?? $this->segTable();
$id = $this->segId();
return $this->doEditForm($table, $id);
}
/** 保存编辑 */
public function update($table = null)
{
admin_required();
$table = $table ?? $this->segTable();
$id = $this->segId();
return $this->doUpdate($table, $id);
}
/** 新增表单 */
public function create($table = null)
{
admin_required();
if (Db::driver() !== 'mysql') { return $this->tablesList(); }
$table = $table ?? $this->segTable();
return $this->doEditForm($table, null);
}
/** 保存新增 */
public function store($table = null)
{
admin_required();
$table = $table ?? $this->segTable();
return $this->doStore($table);
}
/** 删除 */
public function delete($table = null)
{
admin_required();
$table = $table ?? $this->segTable();
$id = $this->segId();
return $this->doDelete($table, $id);
}
/* ---------------- 实现 ---------------- */
private function tablesList(): string
{
$pdo = Db::pdo();
$status = $pdo->query("SHOW TABLE STATUS")->fetchAll();
$tables = [];
foreach ($status as $t) {
$tables[] = [
'name' => $t['Name'],
'engine' => $t['Engine'] ?? '',
'rows' => (int)($t['Rows'] ?? 0),
'size' => (int)($t['Data_length'] ?? 0) + (int)($t['Index_length'] ?? 0),
'collation' => $t['Collation'] ?? '',
];
}
usort($tables, fn($a, $b) => strcmp($a['name'], $b['name']));
return $this->view('admin/db_tables', ['tables' => $tables, 'total' => count($tables)]);
}
private function doBrowse(?string $table): string
{
if (!$table || !$this->validTable($table)) {
$this->flash('表不存在或无权访问', 'err');
return $this->tablesList();
}
$pdo = Db::pdo();
$cols = $this->columnsOf($table);
$names = array_column($cols, 'Field');
$pk = $this->pkOf($table) ?: $names[0];
$limit = 50;
$page = max(1, (int)($_GET['page'] ?? 1));
$offset = ($page - 1) * $limit;
$q = trim((string)($_GET['q'] ?? ''));
$where = '';
$params = [];
if ($q !== '') {
$likes = [];
foreach ($names as $c) {
$likes[] = "`{$c}` LIKE ?";
$params[] = '%' . $q . '%';
}
$where = ' WHERE ' . implode(' OR ', $likes);
}
$stmt = $pdo->prepare("SELECT COUNT(*) FROM `{$table}`" . $where);
$stmt->execute($params);
$total = (int)$stmt->fetchColumn();
$orderCol = in_array($pk, $names, true) ? $pk : $names[0];
$rows = $pdo->query("SELECT * FROM `{$table}`" . $where . " ORDER BY `{$orderCol}` DESC LIMIT {$limit} OFFSET {$offset}")->fetchAll(\PDO::FETCH_ASSOC);
$totalPages = max(1, (int)ceil($total / $limit));
return $this->view('admin/db_browse', [
'table' => $table,
'cols' => $cols,
'names' => $names,
'pk' => $pk,
'rows' => $rows,
'page' => $page,
'totalPages' => $totalPages,
'total' => $total,
'q' => $q,
]);
}
private function doEditForm(?string $table, $id): string
{
if (!$table || !$this->validTable($table)) {
$this->flash('表不存在或无权访问', 'err');
return $this->tablesList();
}
$pdo = Db::pdo();
$cols = $this->columnsOf($table);
$pk = $this->pkOf($table);
$row = null;
if ($id !== null && $id !== '') {
$stmt = $pdo->prepare("SELECT * FROM `{$table}` WHERE `{$pk}` = ? LIMIT 1");
$stmt->execute([$id]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row) {
$this->flash('记录不存在', 'err');
return $this->doBrowse($table);
}
}
return $this->view('admin/db_form', [
'table' => $table,
'cols' => $cols,
'pk' => $pk,
'row' => $row,
'mode' => $row ? 'edit' : 'create',
]);
}
private function doUpdate(?string $table, $id): string
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect("admin/db/browse/$table"); return ''; }
if (!$table || !$this->validTable($table)) { $this->flash('表不存在', 'err'); return $this->tablesList(); }
$pdo = Db::pdo();
$cols = $this->columnsOf($table);
$pk = $this->pkOf($table);
$sets = [];
$params = [];
foreach ($cols as $c) {
$f = $c['Field'];
if ($f === $pk) continue;
if (!array_key_exists($f, $_POST)) continue;
$v = $_POST[$f];
if ($v === '' && $c['Null'] === 'YES') $v = null;
$sets[] = "`{$f}` = ?";
$params[] = $v;
}
if (empty($sets)) {
$this->flash('没有需要更新的字段', 'ok');
$this->redirect("admin/db/browse/$table");
return '';
}
$params[] = $id;
$pdo->prepare("UPDATE `{$table}` SET " . implode(', ', $sets) . " WHERE `{$pk}` = ?")->execute($params);
$this->flash('记录已更新', 'ok');
$this->redirect("admin/db/browse/$table");
return '';
}
private function doStore(?string $table): string
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect("admin/db/browse/$table"); return ''; }
if (!$table || !$this->validTable($table)) { $this->flash('表不存在', 'err'); return $this->tablesList(); }
$pdo = Db::pdo();
$cols = $this->columnsOf($table);
$fields = [];
$ph = [];
$params = [];
foreach ($cols as $c) {
$f = $c['Field'];
if (($c['Extra'] ?? '') === 'auto_increment') continue;
if (!array_key_exists($f, $_POST)) {
if ($c['Null'] === 'YES') { $fields[] = "`{$f}`"; $ph[] = '?'; $params[] = null; }
continue;
}
$v = $_POST[$f];
if ($v === '' && $c['Null'] === 'YES') $v = null;
$fields[] = "`{$f}`"; $ph[] = '?'; $params[] = $v;
}
if (empty($fields)) {
$this->flash('没有可写入的字段', 'err');
$this->redirect("admin/db/browse/$table");
return '';
}
$pdo->prepare("INSERT INTO `{$table}` (" . implode(', ', $fields) . ") VALUES (" . implode(', ', $ph) . ")")
->execute($params);
$this->flash('记录已新增', 'ok');
$this->redirect("admin/db/browse/$table");
return '';
}
private function doDelete(?string $table, $id): string
{
if (!$table || !$this->validTable($table)) { $this->flash('表不存在', 'err'); return $this->tablesList(); }
$pk = $this->pkOf($table);
Db::pdo()->prepare("DELETE FROM `{$table}` WHERE `{$pk}` = ?")->execute([$id]);
$this->flash('记录已删除', 'ok');
$this->redirect("admin/db/browse/$table");
return '';
}
/* ---------------- 工具方法 ---------------- */
/** 全部表名白名单(校验来自 URL 的表名) */
private function tableList(): array
{
return Db::pdo()->query("SHOW TABLES")->fetchAll(\PDO::FETCH_COLUMN);
}
private function validTable(string $t): bool
{
return in_array($t, $this->tableList(), true);
}
private function pkOf(string $t): ?string
{
$keys = Db::pdo()->query("SHOW KEYS FROM `{$t}` WHERE Key_name='PRIMARY'")->fetchAll();
if ($keys) return $keys[0]['Column_name'];
$cols = $this->columnsOf($t);
return $cols[0]['Field'] ?? null;
}
private function columnsOf(string $t): array
{
return Db::pdo()->query("DESCRIBE `{$t}`")->fetchAll();
}
/** file 模式下可用的数据文件(只读提示) */
private function fileDataFiles(): array
{
$dir = Db::fileDir();
$out = [];
foreach (glob($dir . '/*.json') as $f) {
$out[] = ['name' => basename($f), 'size' => filesize($f)];
}
return $out;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Controllers\Admin;
/**
* 素材库:图片上传 / 列表 / 删除
* 文件存于 public/assets/uploads,前后台通用(静态资源由框架直出)。
*/
class MediaController extends AdminController
{
private function dir(): string
{
$d = BASE_PATH . '/public/assets/uploads';
if (!is_dir($d)) mkdir($d, 0755, true);
return $d;
}
/** 素材列表(JSON GET admin/media */
public function index()
{
header('Content-Type: application/json; charset=utf-8');
$d = $this->dir();
$items = [];
foreach (glob($d . '/*.{jpg,jpeg,png,gif,webp,svg}', GLOB_BRACE) as $f) {
$name = basename($f);
$items[] = ['name' => $name, 'url' => site_url('assets/uploads/' . $name)];
}
echo json_encode(['items' => $items]);
}
/** 上传素材(JSON POST admin/media/upload */
public function upload()
{
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) {
echo json_encode(['ok' => false, 'msg' => '请求无效']);
return;
}
$path = $this->uploadFile('file');
if (!$path) {
echo json_encode(['ok' => false, 'msg' => '上传失败(仅支持 jpg/png/gif/webp/svg']);
return;
}
echo json_encode(['ok' => true, 'name' => basename($path), 'url' => site_url($path)]);
}
/** 删除素材 POST admin/media/delete/{name} 或 POST admin/media/delete + name 字段 */
public function delete($name = null)
{
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) {
echo json_encode(['ok' => false, 'msg' => '请求无效']);
return;
}
$name = basename($name ?? ($_POST['name'] ?? ''));
if ($name === '' || $name === '.' || $name === '..') {
echo json_encode(['ok' => false, 'msg' => '参数缺失']);
return;
}
$file = $this->dir() . '/' . $name;
if (is_file($file) && @unlink($file)) {
echo json_encode(['ok' => true]);
} else {
echo json_encode(['ok' => false, 'msg' => '删除失败']);
}
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace App\Controllers\Admin;
use App\Models\News;
class NewsController extends AdminController
{
public function index()
{
return $this->view('admin/news', [
'news' => (new News())->all(),
'seg' => $this->seg(),
]);
}
public function create()
{
return $this->view('admin/news_form', [
'n' => null,
'mode' => 'fixed',
]);
}
public function store()
{
if (!csrf_check()) { $this->redirect('admin/news'); }
$news = new News();
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$cover = $this->uploadFile('cover') ?? $this->post('cover_url', '');
$content = ($mode === 'fixed') ? $this->post('content', '') : '';
$id = $news->insert([
'title' => $this->post('title'),
'slug' => '',
'cover' => $cover,
'summary' => $this->post('summary'),
'content' => $content,
'author' => $this->post('author', '酷冰甲'),
'published_at' => $this->post('published_at', date('Y-m-d')),
'status' => $this->post('status', 1) ? 1 : 0,
'views' => 0,
'layout' => $this->post('layout', ''),
'mode' => $mode,
]);
// URL 标识留空时按记录序号顺序生成(短、稳定)
$slug = $this->post('slug') ? slugify($this->post('slug')) : (string)$id;
$news->update($id, ['slug' => $slug]);
// 新建时若选择「可视化编辑」,保存后直接进入可视化编辑器排版
if ($mode === 'builder') {
$this->redirect('admin/news/edit/' . $id);
}
$this->redirect('admin/news');
}
public function edit($id)
{
$news = new News();
$n = $news->find($id);
if (!$n) { $this->redirect('admin/news'); }
$mode = empty($n['mode']) ? 'fixed' : $n['mode'];
if ($mode === 'builder') {
$layout = [];
if (!empty($n['layout'])) {
$dec = json_decode($n['layout'], true);
if (is_array($dec)) $layout = $dec;
}
return $this->view('admin/news_builder', ['n' => $n, 'layout' => $layout, 'mode' => $mode]);
}
return $this->view('admin/news_form', ['n' => $n, 'mode' => $mode]);
}
/** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 mode 字段 */
public function switchMode($id)
{
$n = (new News())->find($id);
if (!$n) { $this->redirect('admin/news'); }
$target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed';
(new News())->update($id, ['mode' => $target]);
$this->redirect('admin/news/edit/' . $id);
}
public function update($id)
{
if (!csrf_check()) { $this->redirect('admin/news'); }
$news = new News();
$n = $news->find($id);
if (!$n) { $this->redirect('admin/news'); }
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$cover = $this->uploadFile('cover');
if (!$cover && $this->post('cover_url')) $cover = $this->post('cover_url');
if (!$cover) $cover = $n['cover'] ?? '';
$data = [
'title' => $this->post('title'),
'slug' => $this->post('slug') ? slugify($this->post('slug')) : (string)$id,
'cover' => $cover,
'summary' => $this->post('summary'),
'author' => $this->post('author', '酷冰甲'),
'published_at' => $this->post('published_at', date('Y-m-d')),
'status' => $this->post('status', 1) ? 1 : 0,
'mode' => $mode,
];
if ($mode === 'fixed') {
$data['content'] = $this->post('content', '');
} else {
$layout = $this->post('layout', '');
if ($layout !== '' && !is_array(json_decode($layout, true))) { $layout = ''; }
$data['layout'] = $layout;
}
$news->update($id, $data);
$this->redirect('admin/news');
}
public function delete($id)
{
if (csrf_check()) { (new News())->delete($id); }
$this->redirect('admin/news');
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Controllers\Admin;
use App\Models\Order;
use App\Models\Payment;
use Core\Payment\OrderService;
/** 后台:订单与付款管理(超级管理员 + 管理员) */
class OrderController extends AdminController
{
public function index()
{
$order = new Order();
$all = array_reverse($order->all()); // 最新在前
return $this->view('admin/orders', ['orders' => $all, 'seg' => 'orders']);
}
public function show($id)
{
$order = new Order();
$o = $order->find($id);
if (!$o) { \Core\App::notFound(); return; }
$payments = (new Payment())->whereAll('order_no', $o['order_no']);
return $this->view('admin/order_show', ['o' => $o, 'payments' => $payments, 'seg' => 'orders']);
}
public function markPaid($id)
{
if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
$order = new Order();
$o = $order->find($id);
if ($o && $o['status'] !== 'paid') {
OrderService::markPaid($o['order_no'], 'MANUAL' . time(), $o['channel'] ?: 'manual');
}
}
$this->redirect('admin/orders/show/' . $id);
}
public function delete($id)
{
if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
(new Order())->delete($id);
}
$this->redirect('admin/orders');
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Controllers\Admin;
use App\Models\Page;
class PageController extends AdminController
{
public function index()
{
return $this->view('admin/pages', [
'pages' => (new Page())->all(),
'seg' => $this->seg(),
]);
}
/** 编辑:按页面 mode 渲染对应编辑器(固定版面 / 可视化编辑) */
public function edit($id)
{
$p = (new Page())->find($id);
if (!$p) { $this->redirect('admin/pages'); }
$mode = empty($p['mode']) ? 'fixed' : $p['mode'];
if ($mode === 'builder') {
$layout = [];
if (!empty($p['layout'])) {
$dec = json_decode($p['layout'], true);
if (is_array($dec)) $layout = $dec;
}
return $this->view('admin/page_builder', ['p' => $p, 'layout' => $layout, 'mode' => $mode]);
}
return $this->view('admin/page_form', ['p' => $p, 'mode' => $mode]);
}
/** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 mode 字段 */
public function switchMode($id)
{
$p = (new Page())->find($id);
if (!$p) { $this->redirect('admin/pages'); }
$target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed';
(new Page())->update($id, ['mode' => $target, 'updated_at' => date('Y-m-d')]);
$this->redirect('admin/pages/edit/' . $id);
}
/** 保存:兼容两种编辑器,按实际提交的字段写入(content / layout / mode */
public function update($id)
{
if (!csrf_check()) { $this->redirect('admin/pages'); }
$data = [
'title' => $this->post('title'),
'updated_at' => date('Y-m-d'),
];
if ($this->post('content') !== null) {
$data['content'] = $this->post('content');
}
if ($this->post('layout') !== null) {
$layout = $this->post('layout', '');
if ($layout !== '' && !is_array(json_decode($layout, true))) {
$layout = '';
}
$data['layout'] = $layout;
}
$mode = $this->post('mode');
if ($mode === 'builder' || $mode === 'fixed') {
$data['mode'] = $mode;
}
(new Page())->update($id, $data);
$this->redirect('admin/pages');
}
}
+170
View File
@@ -0,0 +1,170 @@
<?php
namespace App\Controllers\Admin;
use App\Models\Product;
use App\Models\Category;
class ProductController extends AdminController
{
private function specsToJson($text): string
{
$out = [];
foreach (explode("\n", $text) as $line) {
$line = trim($line);
if (!$line) continue;
$p = strpos($line, '|');
if ($p === false) { $out[] = ['k' => $line, 'v' => '']; }
else { $out[] = ['k' => trim(substr($line, 0, $p)), 'v' => trim(substr($line, $p + 1))]; }
}
return json_encode($out, JSON_UNESCAPED_UNICODE);
}
public function index()
{
$product = new Product();
return $this->view('admin/products', [
'products' => $product->all(),
'seg' => $this->seg(),
]);
}
public function create()
{
return $this->view('admin/product_form', [
'p' => null,
'cats' => (new Category())->all(),
'mode' => 'fixed',
]);
}
public function store()
{
if (!csrf_check()) { $this->redirect('admin/products'); }
$product = new Product();
$cover = $this->uploadFile('cover') ?? $this->post('cover_url', '');
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$gallery = ($mode === 'fixed') ? $this->uploadFiles('gallery') : [];
$description = ($mode === 'fixed') ? $this->post('description', '') : '';
$id = $product->insert([
'category_id' => (int)$this->post('category_id'),
'name' => $this->post('name'),
'slug' => '',
'cover' => $cover,
'summary' => $this->post('summary'),
'description' => $description,
'price' => (float)$this->post('price', 0),
'specs' => $this->specsToJson($this->post('specs', '')),
'gallery' => json_encode($gallery, JSON_UNESCAPED_UNICODE),
'tags' => $this->post('tags', ''),
'sort_order' => (int)$this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
'created_at' => date('Y-m-d'),
'layout' => $this->post('layout', ''),
'mode' => $mode,
]);
// URL 标识留空时按记录序号顺序生成(短、稳定)
$slug = $this->post('slug') ? slugify($this->post('slug')) : (string)$id;
$product->update($id, ['slug' => $slug]);
// 新建时若选择「可视化编辑」,保存后直接进入可视化编辑器排版,无需再手动点编辑
if ($mode === 'builder') {
$this->redirect('admin/products/edit/' . $id);
}
$this->redirect('admin/products');
}
public function edit($id)
{
$product = new Product();
$p = $product->find($id);
if (!$p) { $this->redirect('admin/products'); }
$mode = empty($p['mode']) ? 'fixed' : $p['mode'];
$specs = $product->specsArray($p);
$specText = '';
foreach ($specs as $s) $specText .= ($s['k'] ?? '') . '|' . ($s['v'] ?? '') . "\n";
$cats = (new Category())->all();
if ($mode === 'builder') {
$layout = [];
if (!empty($p['layout'])) {
$dec = json_decode($p['layout'], true);
if (is_array($dec)) $layout = $dec;
}
return $this->view('admin/product_builder', [
'p' => $p,
'cats' => $cats,
'specText' => $specText,
'layout' => $layout,
'mode' => $mode,
]);
}
return $this->view('admin/product_form', [
'p' => $p,
'cats' => $cats,
'specText' => $specText,
'mode' => $mode,
]);
}
/** 切换编辑模式(固定版面 <-> 可视化编辑),仅更新 mode 字段,其余数据保留 */
public function switchMode($id)
{
$p = (new Product())->find($id);
if (!$p) { $this->redirect('admin/products'); }
$target = ($this->get('mode') === 'builder') ? 'builder' : 'fixed';
(new Product())->update($id, ['mode' => $target]);
$this->redirect('admin/products/edit/' . $id);
}
public function update($id)
{
if (!csrf_check()) { $this->redirect('admin/products'); }
$product = new Product();
$p = $product->find($id);
if (!$p) { $this->redirect('admin/products'); }
$mode = $this->post('mode') === 'builder' ? 'builder' : 'fixed';
$cover = $this->uploadFile('cover');
if (!$cover && $this->post('cover_url')) $cover = $this->post('cover_url');
if (!$cover) $cover = $p['cover'] ?? '';
$data = [
'category_id' => (int)$this->post('category_id'),
'name' => $this->post('name'),
'slug' => $this->post('slug') ? slugify($this->post('slug')) : (string)$id,
'cover' => $cover,
'summary' => $this->post('summary'),
'price' => (float)$this->post('price', 0),
'specs' => $this->specsToJson($this->post('specs', '')),
'tags' => $this->post('tags', ''),
'sort_order' => (int)$this->post('sort_order', 0),
'status' => $this->post('status', 1) ? 1 : 0,
'mode' => $mode,
];
if ($mode === 'fixed') {
// 固定版面:保存图集与详细描述,保留原有 layout 不被覆盖
$newGal = $this->uploadFiles('gallery');
if (!empty($newGal)) {
$data['gallery'] = json_encode($newGal, JSON_UNESCAPED_UNICODE);
} elseif ($this->post('clear_gallery')) {
$data['gallery'] = '[]';
} else {
$data['gallery'] = $p['gallery'] ?? '[]';
}
$data['description'] = $this->post('description', '');
} else {
// 可视化编辑:更新 layout,保留图集/描述原值
$layout = $this->post('layout', '');
if ($layout !== '' && !is_array(json_decode($layout, true))) { $layout = ''; }
$data['layout'] = $layout;
}
$product->update($id, $data);
$this->redirect('admin/products');
}
public function delete($id)
{
if (csrf_check()) { (new Product())->delete($id); }
$this->redirect('admin/products');
}
}
+171
View File
@@ -0,0 +1,171 @@
<?php
namespace App\Controllers\Admin;
use App\Models\Setting;
use App\Models\PageSeo;
use Core\Theme;
class SettingController extends AdminController
{
private $themeFields = [
'preset', 'primary', 'primary_600', 'secondary', 'accent',
'bg', 'surface', 'text', 'muted', 'border', 'nav_bg',
'font', 'radius', 'container', 'header', 'default_mode', 'custom_css',
];
private $siteFields = [
'site_name', 'site_slogan', 'contact_phone', 'contact_email',
'contact_address', 'site_logo', 'icp', 'gongan', 'seo_title', 'seo_keywords', 'seo_description',
];
private $payFields = [
'pay_enabled', 'pay_mode',
'pay_alipay_appid', 'pay_alipay_private_key', 'pay_alipay_public_key', 'pay_alipay_gateway',
'pay_wechat_mchid', 'pay_wechat_appid', 'pay_wechat_key',
];
/** 站点设置 */
public function index()
{
$setting = new Setting();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (csrf_check()) {
$pairs = [];
foreach ($this->siteFields as $k) $pairs[$k] = $this->post($k, '');
// 网站 Logo:优先用上传文件,其次用填写的图片路径/网址;两者皆空则保留现值
$logoUp = $this->uploadFile('logo');
if ($logoUp !== null) {
$pairs['site_logo'] = $logoUp;
} else {
$url = trim((string) $this->post('logo_url', ''));
if ($url !== '') {
$pairs['site_logo'] = $url;
} else {
unset($pairs['site_logo']); // 不覆盖,保留数据库现有值(含默认商标)
}
}
$setting->saveMany($pairs, 'site');
}
$this->redirect('admin/settings');
}
$vals = [];
foreach ($this->siteFields as $k) $vals[$k] = $setting->get($k, Theme::get($k));
return $this->view('admin/settings', ['v' => $vals, 'seg' => 'settings']);
}
/** 风格 / 主题设置(任意网页风格) */
public function theme()
{
$setting = new Setting();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (csrf_check()) {
$pairs = [];
foreach ($this->themeFields as $k) $pairs[$k] = $this->post($k, '');
$setting->saveMany($pairs, 'theme');
Theme::clearCache();
Theme::regenerate();
}
$this->redirect('admin/theme');
}
$vals = [];
foreach ($this->themeFields as $k) $vals[$k] = $setting->get($k, Theme::get($k, ''));
return $this->view('admin/theme', [
'v' => $vals,
'presets' => Theme::presets(),
'seg' => 'theme',
]);
}
/** 支付设置(支付宝 / 微信) */
public function payment()
{
$setting = new Setting();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (csrf_check()) {
$pairs = [];
foreach ($this->payFields as $k) $pairs[$k] = $this->post($k, '');
$setting->saveMany($pairs, 'pay');
}
$this->redirect('admin/payments');
}
$vals = [];
foreach ($this->payFields as $k) $vals[$k] = $setting->get($k, Theme::get($k, ''));
return $this->view('admin/payment', ['v' => $vals, 'seg' => 'payments']);
}
/** 逐页 SEO 页面清单(首页 / 产品中心 / 产品分类 / 新闻 / 案例 / 关于我们 / 联系我们) */
private $seoPages = [
'home' => ['label' => '首页', 'title_min' => 35, 'desc_min' => 120, 'kw_min' => 10],
'products' => ['label' => '产品中心', 'title_min' => 0, 'desc_min' => 120, 'kw_min' => 10],
'product_category' => ['label' => '产品分类页', 'title_min' => 0, 'desc_min' => 80, 'kw_min' => 6, 'note' => '支持 {cat} 占位符,前端会自动替换为当前分类名。'],
'news' => ['label' => '新闻列表', 'title_min' => 0, 'desc_min' => 120, 'kw_min' => 8],
'cases' => ['label' => '客户案例', 'title_min' => 0, 'desc_min' => 120, 'kw_min' => 7],
'about' => ['label' => '关于我们', 'title_min' => 0, 'desc_min' => 120, 'kw_min' => 7],
'contact' => ['label' => '联系我们', 'title_min' => 0, 'desc_min' => 120, 'kw_min' => 7],
];
/** SEO 设置:页面清单(每个页面进入独立编辑页) */
public function seo()
{
admin_required();
$pageSeo = new PageSeo();
$rows = [];
try {
$rows = $pageSeo->allIndexed();
} catch (\Throwable $e) {
// 表尚未创建(未执行升级 SQL)时给出提示,不致命报错
$this->flash('未检测到 page_seo 表,请先到「数据库升级」执行 005_page_seo.sql', 'err');
}
return $this->view('admin/seo', [
'pages' => $this->seoPages,
'rows' => $rows,
'seg' => 'seo',
]);
}
/** SEO 单页编辑:admin/seo/edit/{key} */
public function edit($key = null)
{
admin_required();
if (!$key || !isset($this->seoPages[$key])) {
$this->flash('未找到该 SEO 页面', 'err');
$this->redirect('admin/seo');
return '';
}
$meta = $this->seoPages[$key];
$pageSeo = new PageSeo();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_check()) {
$this->flash('表单已过期,请刷新后重试', 'err');
$this->redirect('admin/seo/edit/' . $key);
return '';
}
$pageSeo->saveRow($key, [
'title' => trim((string)($_POST['title'] ?? '')),
'description' => trim((string)($_POST['description'] ?? '')),
'keywords' => trim((string)($_POST['keywords'] ?? '')),
'og_title' => trim((string)($_POST['og_title'] ?? '')),
'og_description' => trim((string)($_POST['og_description'] ?? '')),
'og_image' => trim((string)($_POST['og_image'] ?? '')),
'og_type' => trim((string)($_POST['og_type'] ?? 'website')),
'canonical' => trim((string)($_POST['canonical'] ?? '')),
'noindex' => isset($_POST['noindex']) ? 1 : 0,
]);
$this->flash('「' . $meta['label'] . '」SEO 设置已保存', 'ok');
$this->redirect('admin/seo/edit/' . $key);
return '';
}
$row = [];
try {
$row = $pageSeo->getByKey($key) ?: [];
} catch (\Throwable $e) {
$this->flash('未检测到 page_seo 表,请先到「数据库升级」执行 005_page_seo.sql', 'err');
}
return $this->view('admin/seo_edit', [
'key' => $key,
'meta' => $meta,
'v' => $row,
'seg' => 'seo',
]);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Controllers\Admin;
use Core\Installer;
/** 后台:数据升级(免 SSH,等价于 bash deploy.sh 的数据初始化部分) */
class SystemController extends AdminController
{
public function index()
{
return $this->view('admin/system', ['seg' => 'system']);
}
public function upgrade()
{
if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
$msgs = Installer::upgrade();
$_SESSION['flash'] = ['type' => 'ok', 'msg' => '数据升级完成 ✓ ' . implode('', $msgs)];
} else {
$_SESSION['flash'] = ['type' => 'err', 'msg' => '安全校验失败,请重试'];
}
$this->redirect('admin/system');
}
}
+166
View File
@@ -0,0 +1,166 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\Controller;
use Core\App;
use Core\Db;
use Core\Installer;
/**
* 后台:数据库升级
* - 将升级包(*.sql)放入 install/upgrades/ 目录后,本页自动提示「可升级」。
* - 支持单个升级与一键全部升级;每次执行写入 db_upgrades 记录(文件名 / 哈希 / 时间 / 操作人)。
* - 内容发生变更的已升级包会被重新标记为「需更新」。
* 路由:/admin/upgrade -> 升级面板
* /admin/upgrade/apply/<file.sql> -> 升级单个
* /admin/upgrade/applyall -> 一键全部升级
* /admin/upgrade/init -> 基础数据/表结构初始化(POST,仅超管)
*/
class UpgradeController extends Controller
{
protected $layout = 'layouts/admin';
private function dir(): string
{
return BASE_PATH . '/install/upgrades';
}
public function index()
{
admin_required();
if (Db::driver() !== 'mysql') {
return $this->view('admin/upgrade', ['driver' => 'file']);
}
$this->requireSuper();
Installer::ensureUpgradeLog();
$files = $this->scan();
$applied = $this->appliedMap();
$list = [];
foreach ($files as $f) {
$name = basename($f);
$hash = md5_file($f);
$state = !isset($applied[$name]) ? 'pending'
: ($applied[$name] !== $hash ? 'changed' : 'done');
$list[] = [
'name' => $name,
'size' => filesize($f),
'mtime' => filemtime($f),
'state' => $state,
];
}
// 排序:待升级 / 已变更 在前,已应用在后
$rank = ['pending' => 0, 'changed' => 1, 'done' => 2];
usort($list, fn($a, $b) => ($rank[$a['state']] ?? 9) - ($rank[$b['state']] ?? 9));
$pending = count(array_filter($list, fn($x) => $x['state'] !== 'done'));
$history = Db::query("SELECT * FROM db_upgrades ORDER BY applied_at DESC, id DESC LIMIT 50")->fetchAll();
return $this->view('admin/upgrade', [
'driver' => 'mysql',
'list' => $list,
'pending' => $pending,
'history' => $history,
]);
}
/** 升级单个升级包 */
public function apply($file = null)
{
admin_required();
$this->requireSuper();
$file = basename((string)$file);
$path = $this->dir() . '/' . $file;
if (!is_file($path) || !preg_match('/\.sql$/i', $file)) {
$this->flash('升级包不存在', 'err');
$this->redirect('admin/upgrade');
return '';
}
try {
Installer::applySqlFile($path);
Installer::ensureUpgradeLog();
Db::query(
"INSERT INTO db_upgrades (file, hash, applied_at, applied_by, note) VALUES (?, ?, ?, ?, ?)",
[$file, md5_file($path), date('Y-m-d H:i:s'), ($_SESSION['admin']['username'] ?? 'admin'), '']
);
$this->flash("已升级:{$file}", 'ok');
} catch (\Throwable $e) {
$this->flash('升级失败:' . $e->getMessage(), 'err');
}
$this->redirect('admin/upgrade');
return '';
}
/** 一键升级全部待处理 */
public function applyAll()
{
admin_required();
$this->requireSuper();
$files = $this->scan();
$applied = $this->appliedMap();
$done = 0;
foreach ($files as $f) {
$name = basename($f);
$hash = md5_file($f);
if (isset($applied[$name]) && $applied[$name] === $hash) continue;
try {
Installer::applySqlFile($f);
Installer::ensureUpgradeLog();
Db::query(
"INSERT INTO db_upgrades (file, hash, applied_at, applied_by, note) VALUES (?, ?, ?, ?, ?)",
[$name, $hash, date('Y-m-d H:i:s'), ($_SESSION['admin']['username'] ?? 'admin'), '']
);
$done++;
} catch (\Throwable $e) {
$this->flash('升级失败:' . e($e->getMessage()), 'err');
$this->redirect('admin/upgrade');
return '';
}
}
$this->flash($done > 0 ? "已批量升级 {$done} 个升级包" : '没有需要升级的包', $done > 0 ? 'ok' : 'err');
$this->redirect('admin/upgrade');
return '';
}
/** 基础数据/表结构初始化(保留旧版能力:补齐新模块表与种子) */
public function init()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('admin/upgrade'); return ''; }
admin_required();
$this->requireSuper();
if (!csrf_check()) {
$this->flash('表单已过期,请刷新后重试', 'err');
$this->redirect('admin/upgrade');
return '';
}
$msgs = Installer::upgrade();
$this->flash('基础数据升级完成 ✓ ' . implode('', $msgs), 'ok');
$this->redirect('admin/upgrade');
return '';
}
/* ---------------- 工具 ---------------- */
private function requireSuper(): void
{
if (!is_admin() || admin_role() !== 'super_admin') {
App::forbidden('仅超级管理员可执行数据库升级');
}
}
private function scan(): array
{
$d = $this->dir();
return is_dir($d) ? (glob($d . '/*.sql') ?: []) : [];
}
private function appliedMap(): array
{
try {
return Db::query("SELECT file, hash FROM db_upgrades")->fetchAll(\PDO::FETCH_KEY_PAIR);
} catch (\Throwable $e) {
return [];
}
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\Controller;
use App\Models\AdminUser;
/** 用户管理(仅超级管理员可访问,路由层已拦截) */
class UserController extends Controller
{
protected $layout = 'layouts/admin';
public function __construct()
{
admin_required();
role_required('super_admin');
}
private function model(): AdminUser
{
return new AdminUser();
}
public function index()
{
$users = $this->model()->all();
return $this->view('admin/users', ['users' => $users, 'error' => '', 'ok' => '']);
}
public function create()
{
$p = $this->defaultPermsPair();
return $this->view('admin/user_form', [
'user' => null,
'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'],
'error' => '', 'ok' => '',
]);
}
public function store()
{
if (!csrf_check()) {
$p = $this->defaultPermsPair();
return $this->view('admin/user_form', ['user' => null, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => '表单已过期,请重试', 'ok' => '']);
}
$username = trim($this->post('username'));
$name = trim($this->post('name'));
$role = $this->post('role');
$password = $this->post('password');
$status = $this->post('status') ? 1 : 0;
$err = $this->validate($username, $role, $password);
if ($err) { $p = $this->defaultPermsPair(); return $this->view('admin/user_form', ['user' => null, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => $err, 'ok' => '']); }
if ($this->model()->byUsername($username)) {
$p = $this->defaultPermsPair();
return $this->view('admin/user_form', ['user' => null, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => '该账号已存在', 'ok' => '']);
}
$this->model()->insert([
'username' => $username,
'name' => $name ?: $username,
'password' => password_hash($password, PASSWORD_DEFAULT),
'role' => $role,
'crm_role' => $this->post('crm_role') ?: 'none',
'psi_role' => $this->post('psi_role') ?: 'none',
'crm_perms' => json_encode($this->collectPerms('crm', (array)($this->post('crm_pages') ?: [])), JSON_UNESCAPED_UNICODE),
'psi_perms' => json_encode($this->collectPerms('psi', (array)($this->post('psi_pages') ?: [])), JSON_UNESCAPED_UNICODE),
'status' => $status,
'created_at' => date('Y-m-d'),
]);
$this->redirect('admin/users');
}
public function edit($id)
{
$user = $this->model()->find($id);
if (!$user) return $this->redirect('admin/users');
$p = $this->userPerms($user);
return $this->view('admin/user_form', ['user' => $user, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => '', 'ok' => '']);
}
public function update($id)
{
$user = $this->model()->find($id);
if (!$user) return $this->redirect('admin/users');
if (!csrf_check()) {
$p = $this->userPerms($user);
return $this->view('admin/user_form', ['user' => $user, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => '表单已过期,请重试', 'ok' => '']);
}
$username = trim($this->post('username'));
$name = trim($this->post('name'));
$role = $this->post('role');
$password = $this->post('password');
$status = $this->post('status') ? 1 : 0;
$err = $this->validate($username, $role, $password, true);
if ($err) { $p = $this->userPerms($user); return $this->view('admin/user_form', ['user' => $user, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => $err, 'ok' => '']); }
if ($username !== $user['username'] && $this->model()->byUsername($username)) {
$p = $this->userPerms($user);
return $this->view('admin/user_form', ['user' => $user, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => '该账号已存在', 'ok' => '']);
}
$data = [
'username' => $username,
'name' => $name ?: $username,
'role' => $role,
'crm_role' => $this->post('crm_role') ?: 'none',
'psi_role' => $this->post('psi_role') ?: 'none',
'crm_perms' => json_encode($this->collectPerms('crm', (array)($this->post('crm_pages') ?: [])), JSON_UNESCAPED_UNICODE),
'psi_perms' => json_encode($this->collectPerms('psi', (array)($this->post('psi_pages') ?: [])), JSON_UNESCAPED_UNICODE),
'status' => $status,
];
if ($password !== '') {
$data['password'] = password_hash($password, PASSWORD_DEFAULT);
}
$this->model()->update($id, $data);
$this->redirect('admin/users');
}
public function destroy($id)
{
if (admin_uid() == $id) return $this->redirect('admin/users'); // 不能删除自己
$this->model()->delete($id);
$this->redirect('admin/users');
}
/** 超级管理员重置他人密码(自己重置走修改密码页) */
public function reset($id)
{
$user = $this->model()->find($id);
if (!$user) return $this->redirect('admin/users');
if (admin_uid() == $id) return $this->redirect('admin/password');
$new = $this->genPassword();
$this->model()->update($id, ['password' => password_hash($new, PASSWORD_DEFAULT)]);
$p = $this->userPerms($user);
return $this->view('admin/user_form', [
'user' => $user,
'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'],
'error' => '',
'ok' => '已重置密码为:<b>' . e($new) . '</b>(请尽快通知对方修改)',
]);
}
private function defaultPermsPair(): array
{
return ['crm' => $this->defaultPerms('crm'), 'psi' => $this->defaultPerms('psi')];
}
/** 从用户记录解码已保存的页面权限(无记录则默认全部可见) */
private function userPerms($user): array
{
$crm = $this->defaultPerms('crm');
$psi = $this->defaultPerms('psi');
if (!empty($user['crm_perms'])) { $d = @json_decode($user['crm_perms'], true); if (is_array($d)) $crm = $d; }
if (!empty($user['psi_perms'])) { $d = @json_decode($user['psi_perms'], true); if (is_array($d)) $psi = $d; }
return ['crm' => $crm, 'psi' => $psi];
}
private function defaultPerms(string $sys): array
{
$out = [];
foreach (\subsys_pages($sys) as $p) { if ($p !== 'dashboard') $out[$p] = true; }
return $out;
}
private function collectPerms(string $sys, array $checked): array
{
$out = [];
foreach (\subsys_pages($sys) as $p) { if ($p === 'dashboard') continue; $out[$p] = in_array($p, $checked, true); }
return $out;
}
private function validate($username, $role, $password, $isEdit = false): string
{
if ($username === '' || !preg_match('/^[a-zA-Z0-9_]{3,30}$/', $username)) {
return '账号须为 3-30 位字母/数字/下划线';
}
if (!in_array($role, ['super_admin', 'admin', 'user', 'none'], true)) {
return '角色不合法';
}
if (!$isEdit && strlen($password) < 6) {
return '密码至少 6 位';
}
return '';
}
private function genPassword(): string
{
$chars = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$s = '';
for ($i = 0; $i < 10; $i++) {
$s .= $chars[random_int(0, strlen($chars) - 1)];
}
return $s;
}
}