dev
This commit is contained in:
parent
9e20ae6c5f
commit
59477b6659
@ -15,3 +15,6 @@
|
||||
- [Работа с референс-кодом из других проектов](feedback_reference_code_handling.md) — пользователь кидает код из eoffice_v3 и т.п.: адаптировать критически, не копировать вслепую, но уважать явные повторные сигналы по неймингу
|
||||
- [Идея: CLI-точка входа](idea_cli_entrypoint.md) — отложенный cli.php для миграций/крона
|
||||
- [Никакого inline JS в HTML](feedback_no_inline_js.md) — только jQuery `.on()` в отдельном .js-файле, даже для мелочи вроде onchange
|
||||
- [Независимость движка от приложения](feedback_engine_app_independence.md) — Services/System не должны импортировать App\* или знать схему конкретного проекта
|
||||
- [Бюджет — актуальный план (2026)](бюджет_текущий_план.md) — что реально согласовано в разговоре; старый бюджет_проект_план.md — только источник идей, не требований
|
||||
- [Не переиспользовать память молча после сброса](feedback_no_silent_memory_reuse_after_reset.md) — после "снеси всё, начнём заново" каждое решение переподтверждается заново, даже если уже записано
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @author Egor Isaev
|
||||
* @description AboutController.php
|
||||
* @copyright (c) 07/08/2026
|
||||
*/
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use System\Classes\Controller;
|
||||
|
||||
/**
|
||||
* Страница «О нас». URL: /about
|
||||
*/
|
||||
class AboutController extends Controller
|
||||
{
|
||||
/**
|
||||
* @return string
|
||||
* @throws \System\Classes\MyException
|
||||
*/
|
||||
public function indexAction(): string
|
||||
{
|
||||
return $this->render('about');
|
||||
}
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @author Egor Isaev
|
||||
* @description AdminController.php
|
||||
* @copyright (c) 24/06/2026
|
||||
*/
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use System\Classes\Controller;
|
||||
|
||||
/**
|
||||
* Базовый контроллер админки. Рендерится в общем layout сайта
|
||||
* (App/view/layout.html), своего отдельного шаблона не имеет.
|
||||
* Боковое меню — общее для всего сайта, см. Controller::menuSide()
|
||||
* (для роли admin оно показывается на любой странице, не только в админке).
|
||||
* Требует авторизацию с ролью admin (см. Controller::$_auth_protection/$_auth_roles) —
|
||||
* неавторизованный редиректится на /login, авторизованный без роли admin получает 403.
|
||||
* Конкретные страницы (Logs и т.п.) наследуют его.
|
||||
*/
|
||||
abstract class AdminController extends Controller
|
||||
{
|
||||
/** @var bool Доступ в админку только авторизованным пользователям */
|
||||
protected bool $_auth_protection = true;
|
||||
|
||||
/** @var array<int,string> В админку пускаем только роль admin */
|
||||
protected array $_auth_roles = ['admin'];
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @author Egor Isaev
|
||||
* @description LogsController.php
|
||||
* @copyright (c) 24/06/2026
|
||||
*/
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use System\Classes\FileLogReader;
|
||||
use System\Classes\Request;
|
||||
|
||||
/**
|
||||
* Просмотр и поиск логов. URL: /admin/logs
|
||||
*/
|
||||
class LogsController extends AdminController
|
||||
{
|
||||
/**
|
||||
* Список записей лога с фильтрами (дата, канал, уровень, текст).
|
||||
*
|
||||
* @return string
|
||||
* @throws \System\Classes\MyException
|
||||
*/
|
||||
public function indexAction(): string
|
||||
{
|
||||
$request = Request::$current;
|
||||
// Позже FileLogReader заменяется на DbLogReader без изменения остального.
|
||||
$reader = new FileLogReader();
|
||||
|
||||
$filters = [
|
||||
'date' => $request?->query('date') ?: date('Y-m-d'),
|
||||
'channel' => $request?->query('channel') ?: 'action',
|
||||
'level' => $request?->query('level') ?: '',
|
||||
'q' => $request?->query('q') ?: '',
|
||||
];
|
||||
|
||||
return $this->render('logs', [
|
||||
'entries' => $reader->read($filters),
|
||||
'filters' => $filters,
|
||||
'dates' => $reader->dates(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @author Egor Isaev
|
||||
* @description FeedbackController.php
|
||||
* @copyright (c) 23/06/2026
|
||||
*/
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use System\Classes\Controller;
|
||||
use System\Classes\Request;
|
||||
use System\Classes\Validation;
|
||||
use System\Classes\HTTP\Request as HTTPRequest;
|
||||
|
||||
/**
|
||||
* Демонстрация формы с валидацией.
|
||||
* CSRF проверяется автоматически в Controller::before().
|
||||
* Один экшен и показывает форму (GET), и обрабатывает её (POST).
|
||||
*/
|
||||
class FeedbackController extends Controller
|
||||
{
|
||||
/**
|
||||
* @return string
|
||||
* @throws \System\Classes\MyException
|
||||
*/
|
||||
public function indexAction(): string
|
||||
{
|
||||
$request = Request::$current;
|
||||
$errors = [];
|
||||
|
||||
if ($request->method() === HTTPRequest::POST) {
|
||||
// CSRF уже проверен в BaseController::before(). Здесь — только данные.
|
||||
$validation = Validation::factory($request->post())
|
||||
->label('email', 'E-mail')
|
||||
->label('message', 'Сообщение')
|
||||
->rule('email', 'required')
|
||||
->rule('email', 'email')
|
||||
->rule('message', 'required')
|
||||
->rule('message', 'min_length', [10]);
|
||||
|
||||
if ($validation->check()) {
|
||||
// Данные чистые — здесь было бы сохранение/отправка письма.
|
||||
return $this->render('feedback_ok', ['email' => $request->post('email')]);
|
||||
}
|
||||
|
||||
$errors = $validation->errors();
|
||||
}
|
||||
|
||||
return $this->render('feedback', ['errors' => $errors]);
|
||||
}
|
||||
}
|
||||
@ -8,14 +8,15 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repositories\BudgetRepository;
|
||||
use Services\Auth;
|
||||
use System\Classes\Controller;
|
||||
use System\Classes\HTTP\Client\Curl;
|
||||
use System\Classes\HTTP\Client\Request as HttpRequest;
|
||||
use System\Classes\HTTP\Request as HTTPRequest;
|
||||
use System\Classes\MyException;
|
||||
use System\Classes\Request;
|
||||
use System\Classes\Validation;
|
||||
|
||||
/**
|
||||
* Демонстрационный контроллер главной страницы.
|
||||
* Главная страница = страница входа (пока в проекте нет ничего, кроме авторизации).
|
||||
*/
|
||||
class IndexController extends Controller
|
||||
{
|
||||
@ -25,30 +26,23 @@ class IndexController extends Controller
|
||||
*/
|
||||
public function indexAction(): string
|
||||
{
|
||||
$budgetR = new BudgetRepository();
|
||||
$request = Request::$current;
|
||||
$error = null;
|
||||
|
||||
$budgetR->getB();
|
||||
if ($request->method() === HTTPRequest::POST) {
|
||||
$validation = Validation::factory($request->post())
|
||||
->label('login', 'Логин')
|
||||
->label('password', 'Пароль')
|
||||
->rule('login', 'required')
|
||||
->rule('password', 'required');
|
||||
|
||||
return $this->render('index');
|
||||
if ($validation->check() && Auth::instance()->login($request->post('login'), $request->post('password'))) {
|
||||
return $this->renderContent('index', ['error' => null, 'user' => Auth::instance()->getUser()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Демонстрация POST-запроса с JSON-телом к внешнему API.
|
||||
*
|
||||
* @return string
|
||||
* @throws \JsonException
|
||||
*/
|
||||
public function postAction(): string
|
||||
{
|
||||
// POST с JSON-телом
|
||||
$response = (new Curl())->execute(
|
||||
HttpRequest::factory('https://jsonplaceholder.typicode.com/posts')
|
||||
->method('POST')
|
||||
->json(['title' => 'Bicycle', 'body' => 'test', 'userId' => 1])
|
||||
);
|
||||
$error = 'Неверный логин или пароль';
|
||||
}
|
||||
|
||||
$created = $response->isSuccess() ? $response->json() : [];
|
||||
|
||||
return $this->render('index', ['created' => $created]);
|
||||
return $this->renderContent('index', ['error' => $error, 'user' => Auth::instance()->getUser()]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @author Egor Isaev
|
||||
* @description LoginController.php
|
||||
* @copyright (c) 06/08/2026
|
||||
*/
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Services\Auth;
|
||||
use System\Classes\Controller;
|
||||
use System\Classes\HTTP\HTTPException;
|
||||
use System\Classes\HTTP\Request as HTTPRequest;
|
||||
use System\Classes\Request;
|
||||
use System\Classes\Validation;
|
||||
|
||||
/**
|
||||
* Вход/выход. URL: /login (форма + обработка), /login/logout.
|
||||
*/
|
||||
class LoginController extends Controller
|
||||
{
|
||||
/**
|
||||
* Показывает форму входа (GET) и обрабатывает её (POST).
|
||||
*
|
||||
* @return string
|
||||
* @throws \System\Classes\MyException
|
||||
*/
|
||||
public function indexAction(): string
|
||||
{
|
||||
$request = Request::$current;
|
||||
$error = null;
|
||||
|
||||
if ($request->method() === HTTPRequest::POST) {
|
||||
$validation = Validation::factory($request->post())
|
||||
->label('login', 'Логин')
|
||||
->label('password', 'Пароль')
|
||||
->rule('login', 'required')
|
||||
->rule('password', 'required');
|
||||
|
||||
if ($validation->check() && Auth::instance()->login($request->post('login'), $request->post('password'))) {
|
||||
HTTPException::factory(302, '/admin/logs')->getResponse();
|
||||
}
|
||||
|
||||
$error = 'Неверный логин или пароль';
|
||||
}
|
||||
|
||||
return $this->render('login', ['error' => $error]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Разлогинивает пользователя и возвращает на главную.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function logoutAction(): string
|
||||
{
|
||||
Auth::instance()->logout();
|
||||
|
||||
HTTPException::factory(302, '/')->getResponse();
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @author Egor Isaev
|
||||
* @description NewsController.php
|
||||
* @copyright (c) 07/08/2026
|
||||
*/
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use System\Classes\Controller;
|
||||
|
||||
/**
|
||||
* Страница «Новости». URL: /news
|
||||
*/
|
||||
class NewsController extends Controller
|
||||
{
|
||||
/**
|
||||
* @return string
|
||||
* @throws \System\Classes\MyException
|
||||
*/
|
||||
public function indexAction(): string
|
||||
{
|
||||
$news = [
|
||||
['date' => '2026-08-07', 'title' => 'Запущен Bicycle', 'text' => 'Первый релиз самописного PHP MVC-фреймворка.'],
|
||||
];
|
||||
|
||||
return $this->render('news', ['news' => $news]);
|
||||
}
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @author Egor Isaev
|
||||
* @description BudgetRepository.php
|
||||
* @copyright (c) 07/08/2026
|
||||
*/
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use PDO;
|
||||
use System\Classes\Repository;
|
||||
|
||||
/**
|
||||
* Пример конкретного репозитория поверх System\Classes\Repository — CRUD для таблицы budget.
|
||||
*/
|
||||
class BudgetRepository extends Repository
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('budget');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getB(): array
|
||||
{
|
||||
$sql = "SELECT * FROM budget";
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
}
|
||||
@ -10,10 +10,11 @@ return [
|
||||
'default' => [
|
||||
'driver' => 'pdo',
|
||||
'connection' => [
|
||||
'host' => '192.168.1.104',
|
||||
'host' => '192.168.1.36',
|
||||
'port' => 3307,
|
||||
'user' => 'root',
|
||||
'password' => 'password',
|
||||
'dbname' => 'MatMed_BudgetV2'
|
||||
'password' => 'qwerty',
|
||||
'dbname' => 'budget'
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@ -1 +0,0 @@
|
||||
/* Bicycle — собственные стили сайта поверх Bootstrap */
|
||||
@ -1 +0,0 @@
|
||||
// Bicycle — собственный JS сайта
|
||||
@ -1,6 +0,0 @@
|
||||
$(function () {
|
||||
// Смена даты/канала/уровня — сразу отправляет форму; текст ищем по кнопке "Найти".
|
||||
$('#logs-filters').on('change', 'select[name="date"], select[name="channel"], select[name="level"]', function () {
|
||||
$(this).closest('form').trigger('submit');
|
||||
});
|
||||
});
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Bicycle",
|
||||
"short_name": "Bicycle",
|
||||
"start_url": "/",
|
||||
"display": "standalone"
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @description about.html
|
||||
* @copyright (c) 07/08/2026
|
||||
*/
|
||||
|
||||
$this->setStyle('app.css');
|
||||
$this->setScript('app.js');
|
||||
?>
|
||||
|
||||
<h1 class="h3 mb-3">О нас</h1>
|
||||
<p>Bicycle — самописный PHP MVC micro-framework. Название отражает идиому «изобрести велосипед».</p>
|
||||
@ -1,81 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @description logs.html — просмотр и поиск логов
|
||||
*
|
||||
* @var array $entries Записи лога [['datetime','level','message','channel'], ...]
|
||||
* @var array $filters Текущие фильтры [date, channel, level, q]
|
||||
* @var array $dates Доступные даты (Y-m-d)
|
||||
*/
|
||||
|
||||
// Цвет badge по уровню.
|
||||
$level_class = static fn(string $l): string => [
|
||||
'error' => 'danger',
|
||||
'warning' => 'warning',
|
||||
'info' => 'primary',
|
||||
'debug' => 'secondary',
|
||||
][$l] ?? 'secondary';
|
||||
|
||||
$this->setScript('logs.js');
|
||||
?>
|
||||
<h1 class="h3 mb-3">Логи</h1>
|
||||
|
||||
<form method="get" class="row g-2 mb-3" id="logs-filters">
|
||||
<div class="col-auto">
|
||||
<select name="date" class="form-select">
|
||||
<?php if (!in_array($filters['date'], $dates, true)): ?>
|
||||
<option value="<?= htmlspecialchars($filters['date'], ENT_QUOTES) ?>" selected>
|
||||
<?= htmlspecialchars($filters['date'], ENT_QUOTES) ?>
|
||||
</option>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($dates as $d): ?>
|
||||
<option value="<?= $d ?>"<?= $d === $filters['date'] ? ' selected' : '' ?>><?= $d ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="channel" class="form-select">
|
||||
<option value="action"<?= $filters['channel'] === 'action' ? ' selected' : '' ?>>Действия</option>
|
||||
<option value="error"<?= $filters['channel'] === 'error' ? ' selected' : '' ?>>Ошибки</option>
|
||||
<option value="all"<?= $filters['channel'] === 'all' ? ' selected' : '' ?>>Все</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="level" class="form-select">
|
||||
<option value="">Все уровни</option>
|
||||
<?php foreach (['debug', 'info', 'warning', 'error'] as $lvl): ?>
|
||||
<option value="<?= $lvl ?>"<?= $filters['level'] === $lvl ? ' selected' : '' ?>><?= $lvl ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="text" name="q" class="form-control" placeholder="Поиск по тексту"
|
||||
value="<?= htmlspecialchars($filters['q'], ENT_QUOTES) ?>">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary">Найти</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (empty($entries)): ?>
|
||||
<div class="alert alert-secondary">Записей нет.</div>
|
||||
<?php else: ?>
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-nowrap">Время</th>
|
||||
<th>Уровень</th>
|
||||
<th>Сообщение</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($entries as $e): ?>
|
||||
<tr>
|
||||
<td class="text-nowrap"><?= htmlspecialchars($e['datetime'], ENT_QUOTES) ?></td>
|
||||
<td><span class="badge bg-<?= $level_class($e['level']) ?>"><?= htmlspecialchars($e['level'], ENT_QUOTES) ?></span></td>
|
||||
<td><?= htmlspecialchars($e['message'], ENT_QUOTES, 'UTF-8') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
@ -1,37 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @author Egor Isaev
|
||||
* @description feedback.html
|
||||
* @copyright (c) 23/06/2026
|
||||
*
|
||||
* @var array $errors Ошибки валидации [поле => сообщение]
|
||||
*/
|
||||
|
||||
use System\Classes\CSRF;
|
||||
|
||||
?>
|
||||
|
||||
<form method="post" action="/feedback">
|
||||
<?= CSRF::field() ?>
|
||||
|
||||
<p>
|
||||
<label>E-mail<br>
|
||||
<input type="email" name="email">
|
||||
</label>
|
||||
<?php if (isset($errors['email'])): ?>
|
||||
<span style="color:red"><?= $errors['email'] ?></span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>Сообщение<br>
|
||||
<textarea name="message" rows="4"></textarea>
|
||||
</label>
|
||||
<?php if (isset($errors['message'])): ?>
|
||||
<span style="color:red"><?= $errors['message'] ?></span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
|
||||
<button type="submit">Отправить</button>
|
||||
</form>
|
||||
@ -1,13 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @author Egor Isaev
|
||||
* @description feedback_ok.html
|
||||
* @copyright (c) 23/06/2026
|
||||
*
|
||||
* @var string $email Адрес, с которого пришло сообщение
|
||||
*/
|
||||
|
||||
?>
|
||||
|
||||
<p>Спасибо! Сообщение от <?= htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?> принято.</p>
|
||||
@ -1,13 +1,54 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @author Egor Isaev
|
||||
* @description index.html
|
||||
* @copyright (c) 03/06/2026
|
||||
* @description index.html — главная страница = вход. Без общего layout: пока единственная страница проекта, своя HTML-обвязка прямо тут.
|
||||
*
|
||||
* @var string|null $error Сообщение об ошибке (неверные данные)
|
||||
* @var array|null $user Текущий авторизованный пользователь (без пароля), null — гость
|
||||
*/
|
||||
|
||||
use System\Classes\CSRF;
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Бюджет</title>
|
||||
<link rel="stylesheet" href="/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">
|
||||
</head>
|
||||
<body class="d-flex align-items-center justify-content-center bg-body-secondary" style="min-height: 100vh;">
|
||||
<div class="card shadow-sm" style="width: 100%; max-width: 400px;">
|
||||
<div class="card-body p-4">
|
||||
<h1 class="h3 mb-3">Вход</h1>
|
||||
|
||||
<h1 class="h3 mb-3">Главная</h1>
|
||||
<p>Добро пожаловать в Bicycle — самописный PHP MVC micro-framework.</p>
|
||||
<?php if ($user !== null): ?>
|
||||
<p>Вы вошли как <strong><?= htmlspecialchars($user['login'], ENT_QUOTES, 'UTF-8') ?></strong>.</p>
|
||||
<?php else: ?>
|
||||
<?php if ($error !== null): ?>
|
||||
<div class="alert alert-danger"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/">
|
||||
<?= CSRF::field() ?>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Логин
|
||||
<input type="text" name="login" class="form-control" autocomplete="username" autofocus>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Пароль
|
||||
<input type="password" name="password" class="form-control" autocomplete="current-password">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Войти</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @description login.html — форма входа
|
||||
*
|
||||
* @var string|null $error Сообщение об ошибке (неверные данные)
|
||||
*/
|
||||
|
||||
use System\Classes\CSRF;
|
||||
|
||||
?>
|
||||
<h1 class="h3 mb-3">Вход</h1>
|
||||
|
||||
<?php if ($error !== null): ?>
|
||||
<div class="alert alert-danger"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/login" class="col-md-4">
|
||||
<?= CSRF::field() ?>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Логин
|
||||
<input type="text" name="login" class="form-control" autofocus>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Пароль
|
||||
<input type="password" name="password" class="form-control">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Войти</button>
|
||||
</form>
|
||||
@ -1,18 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @description news.html
|
||||
*
|
||||
* @var array $news [['date','title','text'], ...]
|
||||
*/
|
||||
?>
|
||||
|
||||
<h1 class="h3 mb-3">Новости</h1>
|
||||
|
||||
<?php foreach ($news as $item): ?>
|
||||
<article class="mb-3">
|
||||
<div class="text-muted small"><?= htmlspecialchars($item['date'], ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<h2 class="h5 mb-1"><?= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
<p class="mb-0"><?= htmlspecialchars($item['text'], ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
@ -1,41 +1,37 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @description layout.html — главный шаблон сайта; верхнее и боковое меню
|
||||
* приходят уже отрендеренными (см. Controller::renderMenuTop/renderMenuSide)
|
||||
* @description layout.html — главный шаблон сайта. Навигация — прямая разметка здесь,
|
||||
* не строится из PHP-массива в контроллере (см. CLAUDE.md → Controller hierarchy).
|
||||
*
|
||||
* @var string $content Содержимое страницы
|
||||
* @var string $menu_top HTML верхнего меню
|
||||
* @var string $menu_side HTML бокового меню; пустая строка — колонка не рисуется
|
||||
*/
|
||||
|
||||
use System\Classes\ProfilerToolbar;
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bicycle</title>
|
||||
<title>Бюджет</title>
|
||||
<link rel="stylesheet" href="/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css">
|
||||
<?php $this->getStyles(); ?>
|
||||
</head>
|
||||
<body>
|
||||
<?= $menu_top ?>
|
||||
<?php if ($menu_side !== ''): ?>
|
||||
<div class="container py-3">
|
||||
<div class="row">
|
||||
<aside class="col-md-3 col-lg-2 mb-3"><?= $menu_side ?></aside>
|
||||
<main class="col-md-9 col-lg-10"><?= $content ?></main>
|
||||
<nav class="navbar navbar-expand navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">Бюджет</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<main class="container py-3">
|
||||
</nav>
|
||||
<main class="container py-3">
|
||||
<?= $content ?>
|
||||
</main>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
<script src="/vendor/components/jquery/jquery.min.js"></script>
|
||||
<script src="/vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<?php $this->getScripts(); ?>
|
||||
<?= \System\Classes\ProfilerToolbar::render() ?>
|
||||
<?= ProfilerToolbar::render() ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @description menu_side.html — боковое меню раздела (Bootstrap list-group)
|
||||
*
|
||||
* @var array $menu Пункты меню [['title','url','active'], ...]
|
||||
*/
|
||||
?>
|
||||
<div class="list-group">
|
||||
<?php foreach (($menu ?? []) as $item): ?>
|
||||
<a class="list-group-item list-group-item-action<?= !empty($item['active']) ? ' active' : '' ?>"
|
||||
href="<?= htmlspecialchars($item['url'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
@ -1,23 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @description menu_top.html — верхнее меню сайта (Bootstrap navbar)
|
||||
*
|
||||
* @var array $menu Пункты меню [['title','url','active'], ...]
|
||||
*/
|
||||
?>
|
||||
<nav class="navbar navbar-expand navbar-light bg-light border-bottom">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">Bicycle</a>
|
||||
<ul class="navbar-nav">
|
||||
<?php foreach (($menu ?? []) as $item): ?>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= !empty($item['active']) ? ' active fw-bold' : '' ?>"
|
||||
href="<?= htmlspecialchars($item['url'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
@ -580,11 +580,11 @@ $this->setScript('about.js');
|
||||
Расстановка имён — как в проекте eoffice_v3:
|
||||
|
||||
- `System\Classes\BaseController` (abstract) — голое ядро: жизненный цикл `executeAction()` (`before()` → экшен → `after()`), хуки по умолчанию пустые. Нейтрально к вебу/API.
|
||||
- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + верхнее/боковое меню + content) и `renderContent()` (только content, без layout и меню — для ajax-фрагментов), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов. Меню — отдельные хуки: `menuTop()`/`menuSide()` отдают пункты (массив `[['title','url','active'], ...]`), `renderMenuTop()`/`renderMenuSide()` рендерят их через `App/view/menu_top.html`/`menu_side.html` в готовый HTML *до* передачи в layout (`renderMenuSide()` — пустая строка, если пунктов нет). Оба хука — общие для всего сайта (не переопределяются в конкретных контроллерах), активный пункт — по текущему URI, видны на любой странице любому пользователю независимо от роли/авторизации (сейчас в `menuSide()` заглушка — «Главная», «О нас», «Новости»; для последних двух заведены `AboutController`/`NewsController`). Layout — `App/view/layout.html`: верхнее меню всегда, боковое — колонка `aside` (Bootstrap grid) появляется, только если `$menu_side` не пуст; иначе `content` на всю ширину `<main>`. Никакой проверки `isAjax()` нет — экшен сам решает, вызывать `render()` или `renderContent()`. CSS/JS — Bootstrap, Bootstrap Icons, jQuery из `/vendor/`. Единый layout для всего сайта, включая админку. **Контроллеры приложения наследуют `Controller` напрямую.**
|
||||
- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + content) и `renderContent()` (только content, без layout — для ajax-фрагментов), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов. **Меню — не забота движка**: раньше `menuTop()`/`menuSide()` строили пункты меню PHP-массивом прямо в `System\Classes\Controller` — это было ошибкой (движок не должен знать структуру навигации конкретного проекта, см. `.claude/memory/feedback_engine_app_independence.md` в репозитории — тот же принцип, только для меню, а не для БД). Убрано; навигация — обычная разметка прямо в `App/view/layout.html`, без промежуточного PHP-слоя. Никакой проверки `isAjax()` нет — экшен сам решает, вызывать `render()` или `renderContent()`. CSS/JS — Bootstrap, Bootstrap Icons, jQuery из `/vendor/`. **Контроллеры приложения наследуют `Controller` напрямую.**
|
||||
|
||||
API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3).
|
||||
|
||||
**Админка** (`App/Controller/Admin/`): базовый `App\Controller\Admin\AdminController extends Controller` своего layout и меню не задаёт — рендерится в том же `App/view/layout.html`, что и весь сайт, с тем же общим `Controller::menuSide()` (не связан с админкой — заглушка на весь сайт). Включает `$_auth_protection = true` — доступ только авторизованным (см. [Auth](#auth-servicesauth)), неавторизованный редиректится на `/login`. Конкретные страницы (`LogsController` и т.п.) наследуют `AdminController`.
|
||||
> Проект (`App/*`) сейчас пуст — снесён и переписывается с нуля, см. `.claude/memory/бюджет_текущий_план.md`. Упоминания конкретных контроллеров приложения (админка, демо-страницы и т.п.) ниже по файлу могут быть устаревшими до следующего прохода по документации.
|
||||
|
||||
`Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизация конкретного контроллера включается/выключается флагом `$_auth_protection` (см. раздел Auth), а не переопределением `before()` вручную.
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ class PdoDriver
|
||||
protected int $_transaction_level = 0;
|
||||
|
||||
/**
|
||||
* @param array $config Параметры подключения: host|hosts, dbname, user, password, charset (по умолчанию utf8mb4)
|
||||
* @param array $config Параметры подключения: host|hosts, port, dbname, user, password, charset (по умолчанию utf8mb4)
|
||||
*/
|
||||
public function __construct(protected array $config)
|
||||
{
|
||||
@ -46,6 +46,7 @@ class PdoDriver
|
||||
}
|
||||
|
||||
$hosts = $this->config['hosts'] ?? [$this->config['host'] ?? 'localhost'];
|
||||
$port = $this->config['port'] ?? null;
|
||||
$dbname = $this->config['dbname'] ?? '';
|
||||
$user = $this->config['user'] ?? '';
|
||||
$password = $this->config['password'] ?? '';
|
||||
@ -54,7 +55,7 @@ class PdoDriver
|
||||
$errors = [];
|
||||
|
||||
foreach ($hosts as $host) {
|
||||
$dsn = "mysql:host=$host;dbname=$dbname;charset=$charset";
|
||||
$dsn = "mysql:host=$host;dbname=$dbname;charset=$charset" . ($port !== null ? ";port=$port" : '');
|
||||
|
||||
try {
|
||||
$pdo = new PDO($dsn, $user, $password, [
|
||||
|
||||
@ -107,7 +107,9 @@ class Controller extends BaseController
|
||||
|
||||
|
||||
/**
|
||||
* Рендерит шаблон контента внутри layout вместе с верхним и боковым меню.
|
||||
* Рендерит шаблон контента внутри layout. Меню — не забота движка: разметка/ссылки
|
||||
* навигации живут прямо в App/view/layout.html (см. этот файл в проекте), а не строятся
|
||||
* тут из PHP-массива.
|
||||
*
|
||||
* @param string $template Имя шаблона контента без расширения
|
||||
* @param array $data Данные, передаваемые в шаблон
|
||||
@ -118,8 +120,6 @@ class Controller extends BaseController
|
||||
protected function render(string $template, array $data = [], string $dir = ''): string
|
||||
{
|
||||
return (new View($this->_layout, 'view', [
|
||||
'menu_top' => $this->renderMenuTop(),
|
||||
'menu_side' => $this->renderMenuSide(),
|
||||
'content' => $this->renderContent($template, $data, $dir),
|
||||
]))->render();
|
||||
}
|
||||
@ -146,84 +146,6 @@ class Controller extends BaseController
|
||||
return (new View($template, $dir, $data))->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML верхнего меню сайта — рендерит {@see menuTop()} через шаблон view/menu_top.
|
||||
*
|
||||
* @return string
|
||||
* @throws MyException
|
||||
*/
|
||||
protected function renderMenuTop(): string
|
||||
{
|
||||
return (new View('menu_top', 'view', ['menu' => $this->menuTop()]))->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML бокового меню раздела — рендерит {@see menuSide()} через шаблон view/menu_side.
|
||||
* Пустая строка, если пунктов нет (по умолчанию бокового меню нет вовсе;
|
||||
* layout.html в этом случае не рисует колонку сайдбара).
|
||||
*
|
||||
* @return string
|
||||
* @throws MyException
|
||||
*/
|
||||
protected function renderMenuSide(): string
|
||||
{
|
||||
$items = $this->menuSide();
|
||||
|
||||
return $items ? (new View('menu_side', 'view', ['menu' => $items]))->render() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Пункты верхнего меню сайта. Активный пункт — по текущему URI.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function menuTop(): array
|
||||
{
|
||||
$uri = Request::$current?->uri() ?? '';
|
||||
$user = Auth::instance()->getUser();
|
||||
$items = [
|
||||
['title' => 'Главная', 'url' => '/'],
|
||||
['title' => 'Обратная связь', 'url' => '/feedback'],
|
||||
];
|
||||
|
||||
if (($user['role'] ?? null) === 'admin') {
|
||||
$items[] = ['title' => 'Админка', 'url' => '/admin/logs'];
|
||||
}
|
||||
|
||||
$items[] = $user
|
||||
? ['title' => 'Выйти (' . $user['login'] . ')', 'url' => '/login/logout']
|
||||
: ['title' => 'Войти', 'url' => '/login'];
|
||||
|
||||
foreach ($items as &$item) {
|
||||
$item['active'] = trim($item['url'], '/') === $uri;
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пункты бокового меню — общие для всего сайта (как и menuTop()), видны
|
||||
* на любой странице любому пользователю, независимо от роли и авторизации.
|
||||
* Активный пункт — по текущему URI.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function menuSide(): array
|
||||
{
|
||||
$uri = Request::$current?->uri() ?? '';
|
||||
$items = [
|
||||
['title' => 'Главная', 'url' => '/'],
|
||||
['title' => 'О нас', 'url' => '/about'],
|
||||
['title' => 'Новости', 'url' => '/news'],
|
||||
];
|
||||
|
||||
foreach ($items as &$item) {
|
||||
$item['active'] = trim($item['url'], '/') === $uri;
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Формирует JSON-ответ: ставит статус и Content-Type, кодирует данные.
|
||||
*
|
||||
|
||||
@ -10,9 +10,26 @@ class ViewTest extends TestCase
|
||||
{
|
||||
private string $tmpFile;
|
||||
|
||||
// setStyle()/setScript()/setManifest() всегда ищут файл в APPPATH/media (это их работа —
|
||||
// регистрировать реальные ассеты сайта, Core::findFile() не подменить временной директорией),
|
||||
// поэтому фикстуры физически создаются в App/media/* — но с явно тестовым именем (uniqid),
|
||||
// а не под видом настоящего "app.css"/"app.js" сайта, который тесту не принадлежит.
|
||||
private string $fixtureCss;
|
||||
private string $fixtureJs;
|
||||
private string $fixtureManifest;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tmpFile = sys_get_temp_dir() . '/bicycle_view_' . uniqid('', true) . '.html';
|
||||
|
||||
$unique = uniqid('viewtest_', true);
|
||||
$this->fixtureCss = $unique . '.css';
|
||||
$this->fixtureJs = $unique . '.js';
|
||||
$this->fixtureManifest = $unique . '.json';
|
||||
|
||||
file_put_contents(APPPATH . '/media/css/' . $this->fixtureCss, '');
|
||||
file_put_contents(APPPATH . '/media/js/' . $this->fixtureJs, '');
|
||||
file_put_contents(APPPATH . '/media/' . $this->fixtureManifest, '{}');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
@ -21,6 +38,16 @@ class ViewTest extends TestCase
|
||||
unlink($this->tmpFile);
|
||||
}
|
||||
|
||||
foreach ([
|
||||
APPPATH . '/media/css/' . $this->fixtureCss,
|
||||
APPPATH . '/media/js/' . $this->fixtureJs,
|
||||
APPPATH . '/media/' . $this->fixtureManifest,
|
||||
] as $fixtureFile) {
|
||||
if (file_exists($fixtureFile)) {
|
||||
unlink($fixtureFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Сбрасываем статические реестры CSS/JS/manifest между тестами
|
||||
$ref = new \ReflectionClass(View::class);
|
||||
foreach (['_styles' => [], '_scripts' => [], '_manifest' => null] as $prop => $default) {
|
||||
@ -128,12 +155,12 @@ class ViewTest extends TestCase
|
||||
|
||||
public function testSetStyleRegistersLinkTag(): void
|
||||
{
|
||||
(new View())->setStyle('app.css');
|
||||
(new View())->setStyle($this->fixtureCss);
|
||||
ob_start();
|
||||
(new View())->getStyles();
|
||||
$html = ob_get_clean();
|
||||
|
||||
$this->assertStringContainsString('<link rel="stylesheet" href="/App/media/css/app.css">', $html);
|
||||
$this->assertStringContainsString('<link rel="stylesheet" href="/App/media/css/' . $this->fixtureCss . '">', $html);
|
||||
}
|
||||
|
||||
public function testSetStyleMissingFileEchoesAlert(): void
|
||||
@ -152,19 +179,19 @@ class ViewTest extends TestCase
|
||||
|
||||
public function testSetScriptRegistersScriptTag(): void
|
||||
{
|
||||
(new View())->setScript('app.js');
|
||||
(new View())->setScript($this->fixtureJs);
|
||||
ob_start();
|
||||
(new View())->getScripts();
|
||||
$html = ob_get_clean();
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'<script type="text/javascript" src="/App/media/js/app.js"></script>', $html
|
||||
'<script type="text/javascript" src="/App/media/js/' . $this->fixtureJs . '"></script>', $html
|
||||
);
|
||||
}
|
||||
|
||||
public function testSetScriptCustomType(): void
|
||||
{
|
||||
(new View())->setScript('app.js', 'module');
|
||||
(new View())->setScript($this->fixtureJs, 'module');
|
||||
ob_start();
|
||||
(new View())->getScripts();
|
||||
$html = ob_get_clean();
|
||||
@ -187,12 +214,12 @@ class ViewTest extends TestCase
|
||||
|
||||
public function testSetManifestRegistersLinkTag(): void
|
||||
{
|
||||
(new View())->setManifest('manifest.json');
|
||||
(new View())->setManifest($this->fixtureManifest);
|
||||
ob_start();
|
||||
(new View())->getStyles();
|
||||
$html = ob_get_clean();
|
||||
|
||||
$this->assertStringContainsString('<link rel="manifest" href="/App/media/manifest.json">', $html);
|
||||
$this->assertStringContainsString('<link rel="manifest" href="/App/media/' . $this->fixtureManifest . '">', $html);
|
||||
}
|
||||
|
||||
public function testSetManifestMissingFileEchoesAlert(): void
|
||||
@ -207,8 +234,8 @@ class ViewTest extends TestCase
|
||||
public function testGetStylesAndScriptsAreSharedAcrossViewInstances(): void
|
||||
{
|
||||
// content и layout — разные объекты View, но реестр общий (см. Controller::render())
|
||||
(new View())->setStyle('app.css');
|
||||
(new View())->setScript('app.js');
|
||||
(new View())->setStyle($this->fixtureCss);
|
||||
(new View())->setScript($this->fixtureJs);
|
||||
|
||||
ob_start();
|
||||
(new View())->getStyles();
|
||||
@ -218,7 +245,7 @@ class ViewTest extends TestCase
|
||||
(new View())->getScripts();
|
||||
$scripts = ob_get_clean();
|
||||
|
||||
$this->assertStringContainsString('app.css', $styles);
|
||||
$this->assertStringContainsString('app.js', $scripts);
|
||||
$this->assertStringContainsString($this->fixtureCss, $styles);
|
||||
$this->assertStringContainsString($this->fixtureJs, $scripts);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user