diff --git a/App/Controller/Admin/AdminController.php b/App/Controller/Admin/AdminController.php
new file mode 100644
index 0000000..3f3165b
--- /dev/null
+++ b/App/Controller/Admin/AdminController.php
@@ -0,0 +1,32 @@
+ $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(),
+ ]);
+ }
+}
diff --git a/App/view/Admin/Logs/logs.html b/App/view/Admin/Logs/logs.html
new file mode 100644
index 0000000..3fa9df4
--- /dev/null
+++ b/App/view/Admin/Logs/logs.html
@@ -0,0 +1,79 @@
+ [
+ 'error' => 'danger',
+ 'warning' => 'warning',
+ 'info' => 'primary',
+ 'debug' => 'secondary',
+][$l] ?? 'secondary';
+?>
+
Логи
+
+
+
+
+ Записей нет.
+
+
+
+
+ | Время |
+ Уровень |
+ Сообщение |
+
+
+
+
+
+ | = htmlspecialchars($e['datetime'], ENT_QUOTES) ?> |
+ = htmlspecialchars($e['level'], ENT_QUOTES) ?> |
+ = htmlspecialchars($e['message'], ENT_QUOTES, 'UTF-8') ?> |
+
+
+
+
+
diff --git a/App/view/Index/index.html b/App/view/Index/index.html
index 175197c..ad16d76 100644
--- a/App/view/Index/index.html
+++ b/App/view/Index/index.html
@@ -8,4 +8,4 @@
?>
-
+
diff --git a/App/view/layout.html b/App/view/layout.html
new file mode 100644
index 0000000..fd8aea8
--- /dev/null
+++ b/App/view/layout.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+ Bicycle
+
+
+
+
+
+
+ = $content ?>
+
+
+
+
+
diff --git a/CLAUDE.md b/CLAUDE.md
index cfb7c9a..255a00b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -307,7 +307,8 @@ $response = HTTPException::factory(403)->getResponse();
### View path convention
- Шаблон контента: `App/view/{Controller}/{template}.html`
-- Layout: `System/view/views/{layout}.html`
+- Layout: `App/view/views/{layout}.html` (app-специфичные шаблоны — не в `System`, там только нейтральные fallback-страницы вроде `errors/`, `exception/`)
+- Общие CSS/JS-подключения (Bootstrap, Bootstrap Icons, jQuery) вынесены в партиалы `App/view/views/_styles.html` и `_scripts.html` — layout'ы (`layout.html`, `admin.html`) инклудят их через `(new View('_styles', 'view/views'))->render()`, чтобы не дублировать теги при появлении новых layout'ов
- `Core::findFile($dir, $file, 'html')` ищет в `APPPATH/{dir}/{file}.html`, затем в `SYSPATH/{dir}/{file}.html`
- `$_paths` инициализируется лениво при первом вызове `findFile()`
- `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`)
@@ -317,11 +318,11 @@ $response = HTTPException::factory(403)->getResponse();
Расстановка имён — как в проекте eoffice_v3:
- `System\Classes\BaseController` (abstract) — голое ядро: жизненный цикл `executeAction()` (`before()` → экшен → `after()`), хуки по умолчанию пустые. Нейтрально к вебу/API.
-- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + content), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов, хук `layoutData()` (доп. данные в layout помимо `content`). **Контроллеры приложения наследуют его.**
+- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + content), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов, хуки `layoutData()` (доп. данные в layout помимо `content`; по умолчанию `['menu' => $this->menu()]`) и `menu()` (пункты главного меню сайта, активный — по текущему URI). Layout по умолчанию — `App/view/views/layout.html` (Bootstrap-навбар сверху, `$content` внутри ``). **Контроллеры приложения наследуют его.**
API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3).
-**Админка** (`App/Controller/Admin/`): базовый `App\Controller\Admin\AdminController extends Controller` задаёт layout `admin` (боковое меню, `System/view/views/admin.html`, Bootstrap из `/vendor/`) и наполняет `layoutData()` пунктами меню (`menu()` — пока статичный список, задел под БД). `before()` — заготовка под проверку прав (логина пока нет, **админка открыта**). Конкретные страницы наследуют `AdminController`.
+**Админка** (`App/Controller/Admin/`): базовый `App\Controller\Admin\AdminController extends Controller` задаёт свой layout `admin` (боковое меню, `App/view/views/admin.html`, Bootstrap из `/vendor/`) и переопределяет `layoutData()`/`menu()` под собственный список пунктов (пока статичный, задел под БД). `before()` — заготовка под проверку прав (логина пока нет, **админка открыта**). Конкретные страницы наследуют `AdminController`.
`Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизацию добавлять в `before()` конкретных контроллеров (логина пока нет).
diff --git a/System/Classes/Controller.php b/System/Classes/Controller.php
index 51ae3b5..7983c1a 100644
--- a/System/Classes/Controller.php
+++ b/System/Classes/Controller.php
@@ -17,7 +17,7 @@ use System\Classes\HTTP\Request as HTTPRequest;
*/
class Controller extends BaseController
{
- /** @var string Имя layout-шаблона в System/view/views */
+ /** @var string Имя layout-шаблона в App/view (см. Core::findFile) */
protected string $_layout = 'layout';
/** @var bool Проверять ли CSRF-токен на небезопасных методах */
@@ -63,20 +63,42 @@ class Controller extends BaseController
$dir = 'view/' . str_replace('\\', '/', substr($class, 0, -10)); // view/[Admin/]Foo
}
- return (new View($this->_layout, 'view/views', [
+ return (new View($this->_layout, 'view', [
'content' => (new View($template, $dir, $data))->render(),
] + $this->layoutData()))->render();
}
/**
- * Дополнительные данные, передаваемые в layout помимо 'content'
- * (например меню). Переопределяется наследниками. По умолчанию пусто.
+ * Дополнительные данные, передаваемые в layout помимо 'content'.
+ * По умолчанию — пункты главного меню; наследники могут переопределить
+ * полностью (как AdminController — своим боковым меню).
*
* @return array
*/
protected function layoutData(): array
{
- return [];
+ return ['menu' => $this->menu()];
+ }
+
+ /**
+ * Пункты главного меню сайта. Активный пункт — по текущему URI.
+ *
+ * @return array
+ */
+ protected function menu(): array
+ {
+ $uri = Request::$current?->uri() ?? '';
+ $items = [
+ ['title' => 'Главная', 'url' => '/'],
+ ['title' => 'Обратная связь', 'url' => '/feedback'],
+ ['title' => 'Админка', 'url' => '/admin/logs'],
+ ];
+
+ foreach ($items as &$item) {
+ $item['active'] = trim($item['url'], '/') === $uri;
+ }
+
+ return $items;
}
/**
diff --git a/System/Classes/FileLogReader.php b/System/Classes/FileLogReader.php
new file mode 100644
index 0000000..287d4b4
--- /dev/null
+++ b/System/Classes/FileLogReader.php
@@ -0,0 +1,98 @@
+ $m[1],
+ 'level' => strtolower($m[2]),
+ 'message' => $m[3],
+ 'channel' => $ch,
+ ];
+
+ if ($level !== '' && $entry['level'] !== $level) {
+ continue;
+ }
+ if ($q !== '' && stripos($line, $q) === false) {
+ continue;
+ }
+
+ $entries[] = $entry;
+ }
+ }
+
+ // Новые сверху (datetime сравним лексикографически).
+ usort($entries, static fn($a, $b) => strcmp($b['datetime'], $a['datetime']));
+
+ return array_slice($entries, 0, $this->limit);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function dates(): array
+ {
+ $dir = Log::directory();
+ if (!is_dir($dir)) {
+ return [];
+ }
+
+ $files = array_merge(
+ glob($dir . '/action-*.log') ?: [],
+ glob($dir . '/error-*.log') ?: []
+ );
+
+ $dates = [];
+ foreach ($files as $file) {
+ if (preg_match('/-(\d{4}-\d{2}-\d{2})\.log$/', $file, $m)) {
+ $dates[$m[1]] = true;
+ }
+ }
+
+ $dates = array_keys($dates);
+ rsort($dates); // новые даты сверху
+
+ return $dates;
+ }
+}
diff --git a/System/Classes/LogReader.php b/System/Classes/LogReader.php
new file mode 100644
index 0000000..98421dc
--- /dev/null
+++ b/System/Classes/LogReader.php
@@ -0,0 +1,32 @@
+
+ */
+ public function read(array $filters = []): array;
+
+ /**
+ * Возвращает доступные даты логов (Y-m-d), новые сверху — для выбора в фильтре.
+ *
+ * @return string[]
+ */
+ public function dates(): array;
+}
diff --git a/System/view/views/layout.html b/System/view/views/layout.html
deleted file mode 100644
index 1b016e2..0000000
--- a/System/view/views/layout.html
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
- Bicycle
-
-
-= $content ?>
-
-
diff --git a/tests/Unit/FileLogReaderTest.php b/tests/Unit/FileLogReaderTest.php
new file mode 100644
index 0000000..9f96d45
--- /dev/null
+++ b/tests/Unit/FileLogReaderTest.php
@@ -0,0 +1,99 @@
+dir = sys_get_temp_dir() . '/bicycle_reader_' . uniqid();
+ $this->today = date('Y-m-d');
+ mkdir($this->dir);
+ Log::$directory = $this->dir;
+
+ file_put_contents($this->dir . '/action-' . $this->today . '.log',
+ "[{$this->today} 10:00:00] INFO: GET /feedback Feedback::index\n" .
+ "[{$this->today} 10:01:00] DEBUG: какой-то debug\n"
+ );
+ file_put_contents($this->dir . '/error-' . $this->today . '.log',
+ "[{$this->today} 10:02:00] ERROR: что-то сломалось\n"
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ foreach (glob($this->dir . '/*') as $file) {
+ unlink($file);
+ }
+ rmdir($this->dir);
+ Log::$directory = null;
+ }
+
+ public function testParsesEntries(): void
+ {
+ $entries = (new FileLogReader())->read(['channel' => 'action', 'date' => $this->today]);
+
+ $this->assertCount(2, $entries);
+ $first = $entries[0];
+ $this->assertArrayHasKey('datetime', $first);
+ $this->assertArrayHasKey('level', $first);
+ $this->assertArrayHasKey('message', $first);
+ $this->assertSame('action', $first['channel']);
+ }
+
+ public function testNewestFirst(): void
+ {
+ $entries = (new FileLogReader())->read(['channel' => 'action', 'date' => $this->today]);
+ // 10:01 (debug) должно быть выше 10:00 (info)
+ $this->assertSame('debug', $entries[0]['level']);
+ $this->assertSame('info', $entries[1]['level']);
+ }
+
+ public function testFilterByLevel(): void
+ {
+ $entries = (new FileLogReader())->read(['channel' => 'action', 'date' => $this->today, 'level' => 'info']);
+ $this->assertCount(1, $entries);
+ $this->assertSame('info', $entries[0]['level']);
+ }
+
+ public function testFilterByText(): void
+ {
+ $entries = (new FileLogReader())->read(['channel' => 'action', 'date' => $this->today, 'q' => 'feedback']);
+ $this->assertCount(1, $entries);
+ $this->assertStringContainsString('feedback', $entries[0]['message']);
+ }
+
+ public function testErrorChannel(): void
+ {
+ $entries = (new FileLogReader())->read(['channel' => 'error', 'date' => $this->today]);
+ $this->assertCount(1, $entries);
+ $this->assertSame('error', $entries[0]['level']);
+ }
+
+ public function testAllChannelsCombined(): void
+ {
+ $entries = (new FileLogReader())->read(['channel' => 'all', 'date' => $this->today]);
+ $this->assertCount(3, $entries);
+ // Новейшая запись — ошибка в 10:02.
+ $this->assertSame('error', $entries[0]['level']);
+ }
+
+ public function testMissingFileReturnsEmpty(): void
+ {
+ $entries = (new FileLogReader())->read(['channel' => 'action', 'date' => '2000-01-01']);
+ $this->assertSame([], $entries);
+ }
+
+ public function testDatesListsAvailable(): void
+ {
+ $dates = (new FileLogReader())->dates();
+ $this->assertContains($this->today, $dates);
+ }
+}