This commit is contained in:
IsaevEA 2026-08-10 23:24:03 +03:00
parent 9e20ae6c5f
commit 59477b6659
28 changed files with 134 additions and 677 deletions

View File

@ -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) — после "снеси всё, начнём заново" каждое решение переподтверждается заново, даже если уже записано

View File

@ -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');
}
}

View File

@ -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'];
}

View File

@ -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(),
]);
}
}

View File

@ -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]);
}
}

View File

@ -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()]);
}
}

View File

@ -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();
}
}

View File

@ -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]);
}
}

View File

@ -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();
}
}

View File

@ -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'
],
],
],

View File

@ -1 +0,0 @@
/* Bicycle — собственные стили сайта поверх Bootstrap */

View File

@ -1 +0,0 @@
// Bicycle — собственный JS сайта

View File

@ -1,6 +0,0 @@
$(function () {
// Смена даты/канала/уровня — сразу отправляет форму; текст ищем по кнопке "Найти".
$('#logs-filters').on('change', 'select[name="date"], select[name="channel"], select[name="level"]', function () {
$(this).closest('form').trigger('submit');
});
});

View File

@ -1,6 +0,0 @@
{
"name": "Bicycle",
"short_name": "Bicycle",
"start_url": "/",
"display": "standalone"
}

View File

@ -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>

View File

@ -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; ?>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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; ?>

View File

@ -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 бокового меню; пустая строка — колонка не рисуется
* @var string $content Содержимое страницы
*/
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>
</div>
<nav class="navbar navbar-expand navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="/">Бюджет</a>
</div>
<?php else: ?>
<main class="container py-3">
<?= $content ?>
</main>
<?php endif; ?>
</nav>
<main class="container py-3">
<?= $content ?>
</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>

View File

@ -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>

View File

@ -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>

View File

@ -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()` вручную.

View File

@ -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, [

View File

@ -107,7 +107,9 @@ class Controller extends BaseController
/**
* Рендерит шаблон контента внутри layout вместе с верхним и боковым меню.
* Рендерит шаблон контента внутри layout. Меню — не забота движка: разметка/ссылки
* навигации живут прямо в App/view/layout.html (см. этот файл в проекте), а не строятся
* тут из PHP-массива.
*
* @param string $template Имя шаблона контента без расширения
* @param array $data Данные, передаваемые в шаблон
@ -118,9 +120,7 @@ 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),
'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, кодирует данные.
*

View File

@ -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);
}
}