feat: align UI with T-Bank design system, redesign landing page with bento layout, migrate shared utilities from SparkGuardian, standardize status chips and date formatters, remove explicit date inputs, and add project README
This commit is contained in:
1223
CONVENTIONS.md
Normal file
1223
CONVENTIONS.md
Normal file
File diff suppressed because it is too large
Load Diff
111
README.md
111
README.md
@@ -1,59 +1,98 @@
|
||||
# SparkAntiplagiat
|
||||
# SparkGuardian Antiplagiat Frontend
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.7.
|
||||
Клиентское веб-приложение для управления процессами алгоритмической проверки академических работ на заимствования, работающее поверх REST API системы SparkGuardian Antiplagiat.
|
||||
|
||||
## Development server
|
||||
A premium single-page web client for managing algorithmic plagiarism checks, handling ZIP course uploads, and reviewing source code overlaps, built on top of the SparkGuardian Antiplagiat REST API.
|
||||
|
||||
To start a local development server, run:
|
||||
---
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
```
|
||||
## О проекте
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
Приложение предназначено для преподавателей, ассистентов и администраторов учебного процесса. Оно позволяет прозрачно и эффективно анализировать сдачу программного кода студентами с применением методов лексической токенизации и алгоритма шинглов (Shingling & Jaccard). Фронтенд построен в соответствии со строгими стандартами **T-Bank Design System**, полностью адаптивен и предоставляет мощные встроенные инструменты для конфигурации правил анализа.
|
||||
|
||||
## Code scaffolding
|
||||
### Ключевые возможности
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
* **Landing Page**: Современная маркетинговая страница с метриками системы и процессом выполнения проверок (pipeline).
|
||||
* **Управление мероприятиями и группами**: Полный CRUD для удобного распределения курсовых и потоков, чтобы исключить пересечения проверок между разными годами.
|
||||
* **Загрузка ZIP-архивов**: Массовое добавление работ (Works) — система на лету распаковывает архивы и распределяет их по студентам выбранной группы.
|
||||
* **Reference Sets (Эталоны)**: Управление коллекциями шаблонного исходного кода, позволяющими игнорировать заготовки и базовые классы при расчете плагиата.
|
||||
* **Анализ заимствований (Analysis Runs)**: Запуск фоновых задач проверки совпадений, использующих построение AST и N-граммы, благодаря чему переименование переменных не скрывает факт списывания.
|
||||
* **Выгрузка отчетов**: Мгновенная генерация сводных деталей совпадений в PDF, HTML, и JSON форматах с тепловыми картами заимствований (heatmap overlaps).
|
||||
* **Встроенная Dev-Console**: Полноценная in-app консоль разработчика для перехвата API-запросов (`dev-log.interceptor`) прямо в интерфейсе браузера, без открытия Network Tab.
|
||||
* **Мониторинг инфраструктуры**: Доступ к таблице журнала телеметрии и аудита для отслеживания загруженности серверов.
|
||||
* **Уведомления**: Интеллектуальная классификация HTTP-ошибок (Network/Timeout/Parse) с человекочитаемыми сообщениями.
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
### Технологический стек
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
| Область | Выбор |
|
||||
|--------|--------|
|
||||
| Фреймворк | Angular 21 (Signals, OnPush, Standalone) |
|
||||
| Язык | TypeScript (strict-mode) |
|
||||
| UI & Дизайн | Taiga UI v5, кастомные токены T-Bank CSS (`color-tokens.css`) |
|
||||
| Взаимодействие | `HttpClient`, RXJS, Interceptors |
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
### Архитектурные особенности
|
||||
|
||||
## Building
|
||||
* **Слоевая структура**: Строгое деление на `core` (синглтоны, DI-токены, интерсепторы), `features` (lazy-loaded самостоятельные компоненты) и `shared` (чистые pure-функции и date-time утилиты).
|
||||
* **Производительность**: Использование `ChangeDetectionStrategy.OnPush` повсеместно. Lazy-loading вкладок (`@defer`).
|
||||
|
||||
To build the project run:
|
||||
### Локальная настройка и конфигурация
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
1. Скопируйте `.env.example` в `.env` и настройте значения (например, `SG_API_BASE_PATH`, `SG_API_FALLBACK_ORIGIN`).
|
||||
2. Выполните: `npm install` (или `make install`).
|
||||
3. Синхронизируйте переменные (вызывается авто-hooks): `npm run env:sync`. Создает `environment.ts`.
|
||||
4. Запуск dev-сервера с прокси: `npm start` (по умолчанию `http://localhost:4200/`).
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
Актуальный OpenAPI контракт системы доступен в `docs/antiplagiat_api.json`.
|
||||
|
||||
## Running unit tests
|
||||
---
|
||||
|
||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||
## About the project
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
The app is aimed at professors, teaching assistants, and administrators. It provides a robust framework to transparently manage the submission and peer-review of source code using tokenization and shingling (Shingling & Jaccard index). The frontend strictly adheres to the **T-Bank Design System**, ensuring a premium, responsive, and robust user experience.
|
||||
|
||||
## Running end-to-end tests
|
||||
### Core Features
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
* **Landing Page**: A modern entrance showcasing system metrics and technical capabilities (Bento block layout).
|
||||
* **Event & Group Management**: Complete CRUD for grouping courses and isolated assignments.
|
||||
* **ZIP Batch Uploads**: Frictionless batch adding of student assignments directly via ZIP package uploads.
|
||||
* **Reference Sets**: Ignore template code or boilerplate when finding codebase matches.
|
||||
* **Analysis Runs**: Dispatching background tasks verifying structural overlaps, effectively matching similarities despite variable renaming or function shuffles.
|
||||
* **Formatted Reports**: Export matched outputs into highly readable PDF, HTML, or raw JSON deliverables.
|
||||
* **Built-in Dev-Console**: An integrated developer console overlay trapping all API requests and responses directly in the browser window.
|
||||
* **Infrastructure Telemetry**: Direct access to event auditing and system stability metrics.
|
||||
* **Smart Error Handling**: Intelligent HTTP error classification with user-friendly Russian/English notifications.
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
### Tech Stack
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
| Area | Choice |
|
||||
|------|--------|
|
||||
| Framework | Angular 21 (Signals, OnPush, Standalone components) |
|
||||
| Language | TypeScript (strict-mode) |
|
||||
| UI & Design | Taiga UI v5, custom T-Bank CSS variables (`color-tokens.css`) |
|
||||
| Data | `HttpClient`, RXJS, specialized interceptors |
|
||||
|
||||
## Additional Resources
|
||||
### Architecture
|
||||
The project strictly isolates modules into `core` (services, tokens, interceptors), `features` (routing, smart components), and `shared` (pure utilities). All components use `OnPush` change detection and Angular 21's `@defer` blocks to achieve peak rendering performance across massive administrative data tables.
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
### Running Local Environment
|
||||
|
||||
1. Copy `.env.example` to `.env` and set endpoints (`SG_API_BASE_PATH`, `SG_API_FALLBACK_ORIGIN`).
|
||||
2. Install packages: `npm install` (or `make install`).
|
||||
3. Sync environment (done automatically on hooks): `npm run env:sync`.
|
||||
4. Run proxy dev-server: `npm start` (usually at `http://localhost:4200/`).
|
||||
|
||||
Refer to `docs/antiplagiat_api.json` for the most recent REST Open API specification.
|
||||
|
||||
---
|
||||
|
||||
### Command Reference
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Dev server with proxy | `npm start` or `make start` |
|
||||
| Production build | `npm run build` or `make build` |
|
||||
| Development build | `make build-dev` |
|
||||
| Unit tests | `npm test` |
|
||||
| Lint | `npm run lint` |
|
||||
| Clean artifacts | `make clean` |
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"node_modules/@taiga-ui/styles/taiga-ui-theme.less",
|
||||
"node_modules/@taiga-ui/styles/taiga-ui-fonts.less",
|
||||
"src/styles.css"
|
||||
]
|
||||
},
|
||||
|
||||
253
docs/INTEGRATION.md
Normal file
253
docs/INTEGRATION.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# Frontend Integration Guide
|
||||
|
||||
Этот документ нужен фронтенду как краткая рабочая инструкция по интеграции с `SparkGuardBackend`.
|
||||
|
||||
## Что использовать как основной контракт
|
||||
|
||||
Frontend-команде нужно отдать три файла:
|
||||
|
||||
1. `cmd/rest/controllers/docs/swagger.json` — машинно-читаемый OpenAPI/Swagger контракт.
|
||||
2. `docs/api/README.md` — краткий REST-обзор по сценариям и смыслу ручек.
|
||||
3. `docs/frontend/README.md` — этот документ с прикладными рекомендациями для экранов и polling flow.
|
||||
|
||||
## Базовые принципы
|
||||
|
||||
- frontend общается только с backend;
|
||||
- frontend не ходит в `SparkGuardAntiplagiarism`, Kafka или MinIO;
|
||||
- upload и check — это два разных шага;
|
||||
- dashboards должны использовать `summary/stats` endpoints, а не `chunks`;
|
||||
- `chunks` и `report.raw.json` — только debug/admin слой.
|
||||
|
||||
## Авторизация
|
||||
|
||||
### Вход
|
||||
|
||||
- `POST /auth/login`
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": 1,
|
||||
"name": "Admin",
|
||||
"email": "admin@example.com",
|
||||
"access_level": "Admin"
|
||||
},
|
||||
"token": "jwt"
|
||||
}
|
||||
```
|
||||
|
||||
### Текущий пользователь
|
||||
|
||||
- `GET /auth/me`
|
||||
|
||||
Передавать:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <jwt>
|
||||
```
|
||||
|
||||
## Справочники и CRUD
|
||||
|
||||
Основные сущности:
|
||||
|
||||
- `users`
|
||||
- `students`
|
||||
- `groups`
|
||||
- `events`
|
||||
- `works`
|
||||
- `reference-sets`
|
||||
|
||||
Для большинства таблиц frontend может опираться на стандартный CRUD:
|
||||
|
||||
- list: `GET`
|
||||
- create: `POST`
|
||||
- get one: `GET /:id`
|
||||
- update: `PATCH` или `PUT`
|
||||
- delete: `DELETE`
|
||||
|
||||
## Основной flow проверки работы
|
||||
|
||||
### 1. Создать work
|
||||
|
||||
- `POST /works`
|
||||
|
||||
### 2. Загрузить архив
|
||||
|
||||
- `PUT /works/:id/archive`
|
||||
|
||||
Поддерживаются:
|
||||
|
||||
- raw `application/octet-stream`
|
||||
- `multipart/form-data`
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "archive uploaded",
|
||||
"status": "Stored",
|
||||
"archive_object_key": "submissions/12/101.zip",
|
||||
"archive_checksum": "sha256...",
|
||||
"archive_size": 20480
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Запустить проверку
|
||||
|
||||
- `POST /works/:id/check`
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "analysis queued for current archive",
|
||||
"analysis_run_id": "uuid",
|
||||
"status": "Queued"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Poll статуса
|
||||
|
||||
- `GET /analysis-runs/:id`
|
||||
|
||||
Frontend должен ждать состояния:
|
||||
|
||||
- `Queued`
|
||||
- `Processing`
|
||||
- `Completed`
|
||||
- `Failed`
|
||||
|
||||
Рекомендуемый polling:
|
||||
|
||||
- каждые `2-3` секунды первые `30` секунд;
|
||||
- затем каждые `5` секунд;
|
||||
- останавливать polling на `Completed` или `Failed`.
|
||||
|
||||
### 5. После завершения читать результат
|
||||
|
||||
- `GET /analysis-runs/:id/adoptions`
|
||||
- `GET /analysis-runs/:id/report.json`
|
||||
- `GET /analysis-runs/:id/report.html`
|
||||
- `GET /analysis-runs/:id/report.pdf`
|
||||
- `GET /works/:id/summary`
|
||||
|
||||
## Какие endpoints использовать для экранов
|
||||
|
||||
### Экран списка/карточки работы
|
||||
|
||||
- `GET /works`
|
||||
- `GET /works/:id`
|
||||
- `GET /works/:id/analysis-runs`
|
||||
- `GET /works/:id/summary`
|
||||
|
||||
`GET /works/:id/summary` — основной endpoint для детального экрана работы.
|
||||
|
||||
Он уже отдаёт:
|
||||
|
||||
- `presentation_summary`
|
||||
- `trust_score`
|
||||
- `plagiarism_rate`
|
||||
- strongest counterpart
|
||||
- `counterparts`
|
||||
- `graph`
|
||||
- latest run metadata
|
||||
|
||||
### Экран события
|
||||
|
||||
- `GET /events`
|
||||
- `GET /events/:id`
|
||||
- `GET /events/:id/works`
|
||||
- `GET /events/:id/summary`
|
||||
|
||||
### Экран группы
|
||||
|
||||
- `GET /groups`
|
||||
- `GET /groups/:id`
|
||||
- `GET /groups/:id/stats`
|
||||
|
||||
### Экран студента
|
||||
|
||||
- `GET /students`
|
||||
- `GET /students/:id`
|
||||
- `GET /students/:id/stats`
|
||||
|
||||
### Экран отчёта преподавателя
|
||||
|
||||
- основа: `GET /analysis-runs/:id/report.json`
|
||||
- экспорт:
|
||||
- `GET /analysis-runs/:id/report.html`
|
||||
- `GET /analysis-runs/:id/report.pdf`
|
||||
|
||||
### Экран аудита
|
||||
|
||||
- `GET /audit-logs`
|
||||
|
||||
Фильтры:
|
||||
|
||||
- `actor_user_id`
|
||||
- `action`
|
||||
- `resource_type`
|
||||
- `resource_id`
|
||||
- `source`
|
||||
- `limit`
|
||||
|
||||
Этот endpoint нужен только для admin UI.
|
||||
|
||||
## Что означает summary/statistics payload
|
||||
|
||||
Во всех `summary/stats` ручках frontend получает одинаковые строительные блоки:
|
||||
|
||||
- `presentation_summary`
|
||||
- `works`
|
||||
- `graph`
|
||||
|
||||
### `presentation_summary`
|
||||
|
||||
Основные поля:
|
||||
|
||||
- `works_total`
|
||||
- `works_checked`
|
||||
- `works_completed`
|
||||
- `works_failed`
|
||||
- `works_flagged`
|
||||
- `counterparts_count`
|
||||
- `connections_count`
|
||||
- `plagiarism_rate`
|
||||
- `trust_score`
|
||||
- `risk_level`
|
||||
- `headline`
|
||||
|
||||
### `graph`
|
||||
|
||||
- `nodes` — вершины графа работ;
|
||||
- `edges` — связи между работами;
|
||||
- использовать для экрана "`кто с кем совпадает`".
|
||||
|
||||
## Что не нужно использовать для продуктового UI
|
||||
|
||||
- `GET /analysis-runs/:id/chunks`
|
||||
- `GET /analysis-runs/:id/report.raw.json`
|
||||
|
||||
Они нужны для:
|
||||
|
||||
- отладки пайплайна;
|
||||
- технической диагностики;
|
||||
- проверки Kafka/analyzer boundary.
|
||||
|
||||
## Ошибки
|
||||
|
||||
Формат ошибок стабилен:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Краткое описание",
|
||||
"error": "Технические детали"
|
||||
}
|
||||
```
|
||||
|
||||
Для UI обычно показывать:
|
||||
|
||||
- `message` пользователю;
|
||||
- `error` — только в debug/admin mode.
|
||||
236
docs/REST-API.md
Normal file
236
docs/REST-API.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# REST API
|
||||
|
||||
Этот документ описывает внешний REST-контракт `SparkGuardBackend`.
|
||||
|
||||
## Общие правила
|
||||
|
||||
- авторизация: `Authorization: Bearer <jwt>`;
|
||||
- frontend работает только с backend, не с analyzer и не с MinIO;
|
||||
- `upload` и `check` — разные действия;
|
||||
- full report скачивается только через backend.
|
||||
|
||||
Формат ошибки:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Краткое описание",
|
||||
"error": "Технические детали"
|
||||
}
|
||||
```
|
||||
|
||||
## Основной сценарий
|
||||
|
||||
1. `POST /auth/login`
|
||||
2. `GET /auth/me`
|
||||
3. `GET /readyz`
|
||||
4. `POST /students`
|
||||
5. `POST /groups`
|
||||
6. `POST /groups/:id/students/:student_id`
|
||||
7. `POST /events`
|
||||
8. `POST /works`
|
||||
9. `PUT /works/:id/archive`
|
||||
10. `POST /works/:id/check`
|
||||
11. `GET /analysis-runs/:id`
|
||||
12. дождаться `Queued -> Completed` или `Failed`
|
||||
13. `GET /works/:id/summary`
|
||||
14. `GET /events/:id/summary`
|
||||
15. `GET /analysis-runs/:id/report.json`
|
||||
16. `GET /analysis-runs/:id/report.html`
|
||||
17. `GET /analysis-runs/:id/report.pdf`
|
||||
18. `GET /audit-logs` — только для admin UI
|
||||
|
||||
## Works
|
||||
|
||||
- `GET /works`
|
||||
- `POST /works`
|
||||
- `GET /works/:id`
|
||||
- `PUT /works/:id`
|
||||
- `DELETE /works/:id`
|
||||
- `PUT /works/:id/archive`
|
||||
- `POST /works/:id/check`
|
||||
- `GET /works/:id/archive`
|
||||
- `GET /works/:id/analysis-runs`
|
||||
- `GET /works/:id/summary`
|
||||
- `GET /works/:id/adoptions`
|
||||
- `GET /works/:id/adoptions/related`
|
||||
- `GET /works/:id/adoptions/archive`
|
||||
|
||||
### `PUT /works/:id/archive`
|
||||
|
||||
Смысл:
|
||||
|
||||
- repack архива;
|
||||
- сохранение текущего архива в MinIO;
|
||||
- обновление archive metadata у `work`;
|
||||
- без создания `analysis_run`.
|
||||
- принимает и raw `application/octet-stream`, и `multipart/form-data`.
|
||||
|
||||
Пример ответа:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "archive uploaded",
|
||||
"status": "Stored",
|
||||
"archive_object_key": "submissions/12/101.zip",
|
||||
"archive_checksum": "sha256...",
|
||||
"archive_size": 20480
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /works/:id/check`
|
||||
|
||||
Смысл:
|
||||
|
||||
- создать новый `analysis_run` по текущему архиву;
|
||||
- записать outbox message;
|
||||
- отправить задачу в Kafka.
|
||||
|
||||
Пример ответа:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "analysis queued for current archive",
|
||||
"analysis_run_id": "uuid",
|
||||
"status": "Queued"
|
||||
}
|
||||
```
|
||||
|
||||
## Analysis runs
|
||||
|
||||
- `GET /analysis-runs/:id`
|
||||
- `GET /analysis-runs/:id/chunks`
|
||||
- `GET /analysis-runs/:id/adoptions`
|
||||
- `GET /analysis-runs/:id/report.raw.json`
|
||||
- `GET /analysis-runs/:id/report.json`
|
||||
- `GET /analysis-runs/:id/report.html`
|
||||
- `GET /analysis-runs/:id/report.pdf`
|
||||
- `POST /analysis-runs/:id/retry`
|
||||
|
||||
### Что важно понимать
|
||||
|
||||
- `POST /works/:id/check` — единственная обычная команда запуска новой проверки;
|
||||
- `GET /works/:id/analysis-runs` — история попыток по работе;
|
||||
- `GET /analysis-runs/:id` — статус конкретной попытки;
|
||||
- `GET /analysis-runs/:id/adoptions` — материализованные suspicious findings конкретного run;
|
||||
- `GET /analysis-runs/:id/report.json/html/pdf` — teacher-facing отчёт для UI;
|
||||
- `GET /analysis-runs/:id/chunks` и `GET /analysis-runs/:id/report.raw.json` — debug/admin endpoints, не обязательны frontend dashboards;
|
||||
- `POST /analysis-runs/:id/retry` — человекочитаемая обычная ручка retry;
|
||||
|
||||
### Dashboard summaries
|
||||
|
||||
- `GET /works/:id/summary`
|
||||
- `GET /events/:id/summary`
|
||||
- `GET /students/:id/stats`
|
||||
- `GET /groups/:id/stats`
|
||||
|
||||
Они отдают:
|
||||
|
||||
- `presentation_summary` — агрегаты для frontend dashboards;
|
||||
- `trust_score` и `plagiarism_rate`;
|
||||
- список работ/контрагентов с highest risk;
|
||||
- `graph.nodes`/`graph.edges` для сценария "кто с кем совпадает".
|
||||
|
||||
## System
|
||||
|
||||
- `GET /healthz`
|
||||
- `GET /readyz`
|
||||
|
||||
`healthz` — дешёвая liveness probe.
|
||||
|
||||
`readyz` — readiness check backend API, который проверяет доступность базы данных.
|
||||
|
||||
## Audit
|
||||
|
||||
- `GET /audit-logs`
|
||||
|
||||
Доступно только `Admin`.
|
||||
|
||||
Фильтры:
|
||||
|
||||
- `actor_user_id`
|
||||
- `action`
|
||||
- `resource_type`
|
||||
- `resource_id`
|
||||
- `source`
|
||||
- `limit`
|
||||
|
||||
Аудит покрывает:
|
||||
|
||||
- административные CRUD-операции;
|
||||
- group memberships;
|
||||
- upload/check/retry по работам;
|
||||
- reference set changes;
|
||||
- reference ingestion scheduling;
|
||||
- system completion events для reference ingestion.
|
||||
|
||||
### Teacher report
|
||||
|
||||
Teacher report содержит:
|
||||
|
||||
- `context`:
|
||||
- работа;
|
||||
- студент;
|
||||
- группа;
|
||||
- событие;
|
||||
- archive metadata;
|
||||
- `summary`:
|
||||
- plagiarism probability;
|
||||
- risk level;
|
||||
- strongest counterpart;
|
||||
- strongest match;
|
||||
- `counterparts`;
|
||||
- `evidence`;
|
||||
- `technical`.
|
||||
|
||||
### Retry
|
||||
|
||||
`POST /analysis-runs/:id/retry`:
|
||||
|
||||
- не требует повторного upload;
|
||||
- использует snapshot archive metadata исходного run;
|
||||
- создаёт новый `analysis_run`.
|
||||
|
||||
## Adoptions
|
||||
|
||||
- `GET /adoptions/:id/segment`
|
||||
|
||||
Этот endpoint сначала читает snapshot excerpt из БД. Если excerpt ещё не materialized, только тогда он обращается к архиву.
|
||||
|
||||
## Reference sets
|
||||
|
||||
- `GET /reference-sets`
|
||||
- `POST /reference-sets`
|
||||
- `GET /reference-sets/:id`
|
||||
- `PATCH /reference-sets/:id`
|
||||
- `DELETE /reference-sets/:id`
|
||||
- `GET /reference-sets/:id/ingestions`
|
||||
- `POST /reference-sets/:id/ingestions`
|
||||
|
||||
Пример создания:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "course-template",
|
||||
"description": "Шаблон лабораторной работы",
|
||||
"kind": "template"
|
||||
}
|
||||
```
|
||||
|
||||
Пример ingestion ответа:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "accepted",
|
||||
"ingestion_id": "uuid",
|
||||
"status": "Queued",
|
||||
"object_key": "reference-sets/7/uuid-template.zip"
|
||||
}
|
||||
```
|
||||
|
||||
## Что смотреть при отладке
|
||||
|
||||
- `GET /analysis-runs/:id` — статусы и error message;
|
||||
- `GET /analysis-runs/:id/chunks` — Kafka compatibility flow;
|
||||
- `GET /analysis-runs/:id/report.raw.json` — исходный analyzer output;
|
||||
- `GET /works/:id/summary` — агрегированный work-level view поверх bilateral adoptions;
|
||||
- `GET /works/:id/adoptions/related` — новые двусторонние связи по уже проверенным работам.
|
||||
4272
docs/swagger.json
Normal file
4272
docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
82
eslint.config.js
Normal file
82
eslint.config.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// @ts-check
|
||||
const eslint = require('@eslint/js');
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const tseslint = require('typescript-eslint');
|
||||
const angular = require('angular-eslint');
|
||||
|
||||
module.exports = defineConfig([
|
||||
{
|
||||
ignores: [
|
||||
'**/dist/**',
|
||||
'**/.angular/**',
|
||||
'**/coverage/**',
|
||||
'**/node_modules/**',
|
||||
'**/*.min.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
extends: [
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
tseslint.configs.stylistic,
|
||||
angular.configs.tsRecommended,
|
||||
],
|
||||
processor: angular.processInlineTemplates,
|
||||
rules: {
|
||||
// Production safety rules: strict on bugs, neutral on visual style.
|
||||
'no-alert': 'error',
|
||||
'no-debugger': 'error',
|
||||
'no-unsafe-finally': 'error',
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'app',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'app',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/contextual-lifecycle': 'error',
|
||||
'@angular-eslint/no-empty-lifecycle-method': 'error',
|
||||
'@angular-eslint/no-input-rename': 'error',
|
||||
'@angular-eslint/no-output-native': 'error',
|
||||
'@angular-eslint/use-lifecycle-interface': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
|
||||
rules: {
|
||||
'@angular-eslint/template/no-call-expression': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
1959
package-lock.json
generated
1959
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -11,12 +11,30 @@
|
||||
"private": true,
|
||||
"packageManager": "npm@11.6.2",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.8",
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/compiler": "^21.2.0",
|
||||
"@angular/core": "^21.2.0",
|
||||
"@angular/forms": "^21.2.0",
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"@maskito/angular": "^5.2.2",
|
||||
"@maskito/core": "^5.2.2",
|
||||
"@maskito/kit": "^5.2.2",
|
||||
"@maskito/phone": "^5.2.2",
|
||||
"@ng-web-apis/common": "^5.2.0",
|
||||
"@ng-web-apis/intersection-observer": "^5.2.0",
|
||||
"@ng-web-apis/mutation-observer": "^5.2.0",
|
||||
"@ng-web-apis/platform": "^5.2.0",
|
||||
"@ng-web-apis/resize-observer": "^5.2.0",
|
||||
"@taiga-ui/cdk": "^5.2.0",
|
||||
"@taiga-ui/core": "^5.2.0",
|
||||
"@taiga-ui/event-plugins": "^5.0.0",
|
||||
"@taiga-ui/i18n": "^5.2.0",
|
||||
"@taiga-ui/icons": "^5.2.0",
|
||||
"@taiga-ui/kit": "^5.2.0",
|
||||
"@taiga-ui/polymorpheus": "^5.0.0",
|
||||
"@taiga-ui/styles": "^5.2.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -24,9 +42,14 @@
|
||||
"@angular/build": "^21.2.7",
|
||||
"@angular/cli": "^21.2.7",
|
||||
"@angular/compiler-cli": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"angular-eslint": "^21.3.1",
|
||||
"eslint": "^10.2.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"less": "^4.6.4",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/fonts/TinkoffSans-Bold.ttf
Normal file
BIN
public/fonts/TinkoffSans-Bold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/TinkoffSans-Medium.ttf
Normal file
BIN
public/fonts/TinkoffSans-Medium.ttf
Normal file
Binary file not shown.
BIN
public/fonts/TinkoffSans-Regular.ttf
Normal file
BIN
public/fonts/TinkoffSans-Regular.ttf
Normal file
Binary file not shown.
16
public/svg/logo/logo.svg
Normal file
16
public/svg/logo/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.1 KiB |
45
public/svg/visual/arrow-keys.svg
Normal file
45
public/svg/visual/arrow-keys.svg
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Chevron icons: Lucide (https://lucide.dev), ISC License, ? Lucide Contributors.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 104 88" width="104" height="88" aria-hidden="true">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.key-cap {
|
||||
fill: var(--sg-keyboard-key-surface-idle);
|
||||
stroke: none;
|
||||
}
|
||||
.sg-lucide path,
|
||||
.sg-lucide line,
|
||||
.sg-lucide polyline {
|
||||
fill: none;
|
||||
stroke: var(--sg-keyboard-ink-soft);
|
||||
stroke-width: 1.55;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<!-- inverted-T: up / left down right -->
|
||||
<rect id="K_kb7u" class="key-cap" x="36" y="2" width="32" height="32" rx="6" ry="6" />
|
||||
<g id="S_kb7u" class="sg-lucide" transform="translate(52,18) scale(0.72) translate(-12,-12)">
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</g>
|
||||
|
||||
<rect id="K_kb7l" class="key-cap" x="2" y="38" width="32" height="32" rx="6" ry="6" />
|
||||
<g id="S_kb7l" class="sg-lucide" transform="translate(18,54) scale(0.72) translate(-12,-12)">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</g>
|
||||
|
||||
<rect id="K_kb7d" class="key-cap" x="36" y="38" width="32" height="32" rx="6" ry="6" />
|
||||
<g id="S_kb7d" class="sg-lucide" transform="translate(52,54) scale(0.72) translate(-12,-12)">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</g>
|
||||
|
||||
<rect id="K_kb7r" class="key-cap" x="70" y="38" width="32" height="32" rx="6" ry="6" />
|
||||
<g id="S_kb7r" class="sg-lucide" transform="translate(86,54) scale(0.72) translate(-12,-12)">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
462
public/svg/visual/keyboard.svg
Normal file
462
public/svg/visual/keyboard.svg
Normal file
@@ -0,0 +1,462 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<!-- Reorganised with TEXT EDITOR (gedit under Ubuntu).
|
||||
This file is the general file for the USA alphanumerical keyboard :
|
||||
- it uses style sheet (css) [see details at section "style"]
|
||||
- it displays the standard pad
|
||||
- and optionnaly
|
||||
* for some keys : the draw and/or the text (↹ and/or TAB …)
|
||||
- keys and inscriptions are geographically referenced : "kb" + line(number) + range(letter) -->
|
||||
|
||||
<svg id="svg2"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
version="1.0"
|
||||
xml:space="preserve"
|
||||
sodipodi:version="0.32"
|
||||
inkscape:version="0.48.0 r9654"
|
||||
inkscape:output_extension="org.inkscape.output.svg.inkscape"
|
||||
|
||||
sodipodi:docname="KB_USA 800px.svg" width="800" height="247" viewBox="0 0 801 248" style="display:inline">
|
||||
|
||||
<metadata
|
||||
id="metadata316">
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
<dc:description>Meta (⌘) and Menu: Lucide icons (https://lucide.dev), ISC License, © Lucide Contributors.</dc:description>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<sodipodi:namedview id="base"
|
||||
inkscape:window-width="1679"
|
||||
inkscape:window-height="1019"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
borderopacity="1.0"
|
||||
bordercolor="#666666"
|
||||
pagecolor="#ffffff"
|
||||
showguides="true"
|
||||
inkscape:guide-bbox="true"
|
||||
inkscape:zoom="0.6"
|
||||
inkscape:cx="400"
|
||||
inkscape:cy="123.5"
|
||||
inkscape:current-layer="layer3"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
showgrid="true"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:snap-bbox="true"
|
||||
inkscape:bbox-nodes="true"
|
||||
inkscape:snap-nodes="true"
|
||||
inkscape:snap-grids="true"
|
||||
inkscape:bbox-paths="false"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
units="px"
|
||||
inkscape:snap-bbox-edge-midpoints="false"
|
||||
inkscape:snap-bbox-midpoints="false"
|
||||
inkscape:snap-global="true"
|
||||
inkscape:snap-page="true"
|
||||
inkscape:snap-smooth-nodes="true">
|
||||
</sodipodi:namedview>
|
||||
<defs id="defs4">
|
||||
<style id="style6" type="text/css">
|
||||
|
||||
<!-- General : «#» style for «id» ; «.» class style (for particular style in group)
|
||||
DRAW styles : K_ : particular key style
|
||||
TEXT styles : «text» : common style for all text | T_ : text style | _L : large ; _s : very small -->
|
||||
|
||||
<![CDATA[
|
||||
|
||||
text {
|
||||
writing-mode: lr-tb;
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
font-family: var(--sg-keyboard-font-family);
|
||||
letter-spacing: var(--sg-keyboard-letter-spacing);
|
||||
}
|
||||
|
||||
#T_others text {
|
||||
font-size: 15px;
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
text-align: start;
|
||||
text-anchor: start;
|
||||
fill: var(--sg-keyboard-ink-soft);
|
||||
}
|
||||
#T_alterninsc text {
|
||||
font-size: 13px;
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
letter-spacing: 0.04em;
|
||||
fill: var(--sg-keyboard-ink-soft);
|
||||
stroke: none;
|
||||
}
|
||||
#T_alterninsc text.T_alt-left {
|
||||
text-align: start;
|
||||
text-anchor: start;
|
||||
}
|
||||
#T_alterninsc text.T_alt-right {
|
||||
text-align: end;
|
||||
text-anchor: end;
|
||||
}
|
||||
#T_stdletters text {
|
||||
font-size: 20px;
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
letter-spacing: 0.04em;
|
||||
fill: var(--sg-keyboard-ink-soft);
|
||||
stroke: none;
|
||||
text-align: center;
|
||||
text-anchor: middle;
|
||||
}
|
||||
#T_stdletters text.T_ru {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.03em;
|
||||
opacity: 0.9;
|
||||
text-align: end;
|
||||
text-anchor: end;
|
||||
}
|
||||
#T_stdspecial text {
|
||||
font-size: 17px;
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
letter-spacing: 0.03em;
|
||||
fill: var(--sg-keyboard-ink-soft);
|
||||
stroke: none;
|
||||
text-align: center;
|
||||
text-anchor: middle;
|
||||
}
|
||||
#T_stdspecial text.T_ru {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
opacity: 0.9;
|
||||
text-align: end;
|
||||
text-anchor: end;
|
||||
}
|
||||
.T_size_s {
|
||||
font-size: 11px;
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
letter-spacing: 0.02em;
|
||||
fill: var(--sg-keyboard-ink-soft);
|
||||
stroke: none;
|
||||
text-align: start;
|
||||
text-anchor: start;
|
||||
}
|
||||
.T_size_L {
|
||||
font-size: 20px;
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
#GlyphKey rect,
|
||||
#GlyphKey use {
|
||||
fill: var(--sg-keyboard-key-main);
|
||||
stroke: var(--sg-keyboard-key-stroke);
|
||||
stroke-width: 0.85;
|
||||
}
|
||||
#ModifKey rect {
|
||||
fill: var(--sg-keyboard-key-mod);
|
||||
stroke: var(--sg-keyboard-key-stroke);
|
||||
stroke-width: 0.85;
|
||||
}
|
||||
#OtherKey rect {
|
||||
fill: var(--sg-keyboard-key-other);
|
||||
stroke: var(--sg-keyboard-key-stroke);
|
||||
stroke-width: 0.85;
|
||||
}
|
||||
|
||||
/* Lucide: в покое как подписи tab/caps; нажатие — через injectHighlight */
|
||||
.sg-lucide path,
|
||||
.sg-lucide line,
|
||||
.sg-lucide polyline,
|
||||
.sg-lucide rect {
|
||||
fill: none;
|
||||
stroke: var(--sg-keyboard-ink-soft);
|
||||
stroke-width: 1.55;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
]]>
|
||||
|
||||
</style>
|
||||
</defs>
|
||||
<!-- ======== Keys ======== -->
|
||||
<g inkscape:groupmode="layer" id="layer2" inkscape:label="Keys" style="display:inline" sodipodi:insensitive="true">
|
||||
<g id="GlyphKey">
|
||||
<!-- Num keys - Standard letters keys - Specials glyphs keys - Ponctuation keys -->
|
||||
<rect id="K_kb2a" x="2" y="2" width="48" height="44" rx="6" ry="6" />
|
||||
<use id="K_kb2b" xlink:href="#K_kb2a" transform="translate(54,0)"/>
|
||||
<use id="K_kb2c" xlink:href="#K_kb2a" transform="translate(108,0)"/>
|
||||
<use id="K_kb2d" xlink:href="#K_kb2a" transform="translate(162,0)"/>
|
||||
<use id="K_kb2e" xlink:href="#K_kb2a" transform="translate(216,0)"/>
|
||||
<use id="K_kb2f" xlink:href="#K_kb2a" transform="translate(270,0)"/>
|
||||
<use id="K_kb2g" xlink:href="#K_kb2a" transform="translate(324,0)"/>
|
||||
<use id="K_kb2h" xlink:href="#K_kb2a" transform="translate(378,0)"/>
|
||||
<use id="K_kb2i" xlink:href="#K_kb2a" transform="translate(432,0)"/>
|
||||
<use id="K_kb2j" xlink:href="#K_kb2a" transform="translate(486,0)"/>
|
||||
<use id="K_kb2k" xlink:href="#K_kb2a" transform="translate(540,0)"/>
|
||||
<use id="K_kb2l" xlink:href="#K_kb2a" transform="translate(594,0)"/>
|
||||
<use id="K_kb2m" xlink:href="#K_kb2a" transform="translate(648,0)"/>
|
||||
|
||||
<use id="K_kb3b" xlink:href="#K_kb2a" transform="translate(80,50)"/>
|
||||
<use id="K_kb3c" xlink:href="#K_kb2a" transform="translate(134,50)"/>
|
||||
<use id="K_kb3d" xlink:href="#K_kb2a" transform="translate(188,50)"/>
|
||||
<use id="K_kb3e" xlink:href="#K_kb2a" transform="translate(242,50)"/>
|
||||
<use id="K_kb3f" xlink:href="#K_kb2a" transform="translate(296,50)"/>
|
||||
<use id="K_kb3g" xlink:href="#K_kb2a" transform="translate(350,50)"/>
|
||||
<use id="K_kb3h" xlink:href="#K_kb2a" transform="translate(404,50)"/>
|
||||
<use id="K_kb3i" xlink:href="#K_kb2a" transform="translate(458,50)"/>
|
||||
<use id="K_kb3j" xlink:href="#K_kb2a" transform="translate(512,50)"/>
|
||||
<use id="K_kb3k" xlink:href="#K_kb2a" transform="translate(566,50)"/>
|
||||
<use id="K_kb3l" xlink:href="#K_kb2a" transform="translate(620,50)"/>
|
||||
<use id="K_kb3m" xlink:href="#K_kb2a" transform="translate(674,50)"/>
|
||||
<rect id="K_kb3n" x="730" y="52" width="70" height="44" rx="6" ry="6" />
|
||||
|
||||
<use id="K_kb4b" xlink:href="#K_kb2a" transform="translate(96,100)"/>
|
||||
<use id="K_kb4c" xlink:href="#K_kb2a" transform="translate(150,100)"/>
|
||||
<use id="K_kb4d" xlink:href="#K_kb2a" transform="translate(204,100)"/>
|
||||
<use id="K_kb4e" xlink:href="#K_kb2a" transform="translate(258,100)"/>
|
||||
<use id="K_kb4f" xlink:href="#K_kb2a" transform="translate(312,100)"/>
|
||||
<use id="K_kb4g" xlink:href="#K_kb2a" transform="translate(366,100)"/>
|
||||
<use id="K_kb4h" xlink:href="#K_kb2a" transform="translate(420,100)"/>
|
||||
<use id="K_kb4i" xlink:href="#K_kb2a" transform="translate(474,100)"/>
|
||||
<use id="K_kb4j" xlink:href="#K_kb2a" transform="translate(528,100)"/>
|
||||
<use id="K_kb4k" xlink:href="#K_kb2a" transform="translate(582,100)"/>
|
||||
<use id="K_kb4l" xlink:href="#K_kb2a" transform="translate(636,100)"/>
|
||||
<use id="K_kb4m" xlink:href="#K_kb2a" transform="translate(690,100)"/>
|
||||
|
||||
<use id="K_kb5c" xlink:href="#K_kb2a" transform="translate(116,150)"/>
|
||||
<use id="K_kb5d" xlink:href="#K_kb2a" transform="translate(170,150)"/>
|
||||
<use id="K_kb5e" xlink:href="#K_kb2a" transform="translate(224,150)"/>
|
||||
<use id="K_kb5f" xlink:href="#K_kb2a" transform="translate(278,150)"/>
|
||||
<use id="K_kb5g" xlink:href="#K_kb2a" transform="translate(332,150)"/>
|
||||
<use id="K_kb5h" xlink:href="#K_kb2a" transform="translate(386,150)"/>
|
||||
<use id="K_kb5i" xlink:href="#K_kb2a" transform="translate(440,150)"/>
|
||||
<use id="K_kb5j" xlink:href="#K_kb2a" transform="translate(494,150)"/>
|
||||
<use id="K_kb5k" xlink:href="#K_kb2a" transform="translate(548,150)"/>
|
||||
<use id="K_kb5l" xlink:href="#K_kb2a" transform="translate(602,150)"/>
|
||||
|
||||
<rect id="K_kb6d" x="188" y="202" width="364" height="44" rx="6" ry="6" />
|
||||
</g>
|
||||
|
||||
<!-- Modification keys -->
|
||||
<g id="ModifKey">
|
||||
<rect id="K_kb4a" x="2" y="102" width="90" height="44" rx="6" ry="6" />
|
||||
<rect id="K_kb5a" x="2" y="152" width="110" height="44" rx="6" ry="6" />
|
||||
<rect id="K_kb5m" x="658" y="152" width="142" height="44" rx="6" ry="6" />
|
||||
</g>
|
||||
|
||||
<!-- Other keys -->
|
||||
<g id="OtherKey">
|
||||
<!-- Backspace --> <rect id="K_kb2n" x="704" y="2" width="96" height="44" rx="6" ry="6" />
|
||||
|
||||
<!-- Tabulation --> <rect id="K_kb3a" x="2" y="52" width="74" height="44" rx="6" ry="6" />
|
||||
|
||||
<!-- Enter key -->
|
||||
<rect id="K_kb4n" x="692" y="102" width="107" height="44" rx="6" ry="6" />
|
||||
|
||||
<!-- Ctrl 1 --> <rect id="K_kb6a" x="2" y="202" width="56" height="44" rx="6" ry="6" />
|
||||
<!-- Win 1 --> <rect id="K_kb6b" x="64" y="202" width="56" height="44" rx="6" ry="6" />
|
||||
<!-- Alt 1 --> <rect id="K_kb6c" x="126" y="202" width="56" height="44" rx="6" ry="6" />
|
||||
<!-- Alt 2 --> <rect id="K_kb6k" x="558" y="202" width="56" height="44" rx="6" ry="6" />
|
||||
<!-- Win 2 --> <rect id="K_kb6l" x="620" y="202" width="56" height="44" rx="6" ry="6" />
|
||||
<!-- Menu --> <rect id="K_kb6m" x="682" y="202" width="56" height="44" rx="6" ry="6" />
|
||||
<!-- Ctrl 2 --> <rect id="K_kb6n" x="744" y="202" width="56" height="44" rx="6" ry="6" />
|
||||
</g>
|
||||
|
||||
</g>
|
||||
<!-- ======== Key signs ======== -->
|
||||
<g inkscape:groupmode="layer" id="DrawedInscr" inkscape:label="DrawedInscr" style="display:inline">
|
||||
|
||||
<!-- Meta / ⌘ — Lucide «command» -->
|
||||
<g id="S_kb6b" class="sg-lucide" transform="translate(82,214) scale(0.8333333333)">
|
||||
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
|
||||
</g>
|
||||
<use id="S_kb6l" xlink:href="#S_kb6b" transform="translate(556,0)" x="0" y="0" width="1" height="1" />
|
||||
<!-- Menu — Lucide «menu» -->
|
||||
<g id="S_kb6m" class="sg-lucide" transform="translate(700,214) scale(0.8333333333)">
|
||||
<line x1="4" x2="20" y1="12" y2="12" />
|
||||
<line x1="4" x2="20" y1="6" y2="6" />
|
||||
<line x1="4" x2="20" y1="18" y2="18" />
|
||||
</g>
|
||||
</g>
|
||||
<!-- ======== Inscriptions layer ======== -->
|
||||
<g inkscape:groupmode="layer" id="en-us" inkscape:label="incriptions" sodipodi:insensitive="true" style="display:inline">
|
||||
|
||||
<g id="T_stdletters">
|
||||
|
||||
<text id="T_kb3b2" x="96" y="72" >Q</text>
|
||||
<text id="T_kb3c2" x="150" y="72" >W</text>
|
||||
<text id="T_kb3d2" x="204" y="72" >E</text>
|
||||
<text id="T_kb3e2" x="258" y="72" >R</text>
|
||||
<text id="T_kb3f2" x="312" y="72" >T</text>
|
||||
<text id="T_kb3g2" x="366" y="72" >Y</text>
|
||||
<text id="T_kb3h2" x="420" y="72" >U</text>
|
||||
<text id="T_kb3i2" x="474" y="72" >I</text>
|
||||
<text id="T_kb3j2" x="528" y="72" >O</text>
|
||||
<text id="T_kb3k2" x="582" y="72" >P</text>
|
||||
|
||||
<text id="T_kb4c2" x="112" y="122" >A</text>
|
||||
<text id="T_kb4d2" x="166" y="122" >S</text>
|
||||
<text id="T_kb4e2" x="220" y="122" >D</text>
|
||||
<text id="T_kb4f2" x="274" y="122" >F</text>
|
||||
<text id="T_kb4g2" x="328" y="122" >G</text>
|
||||
<text id="T_kb4h2" x="382" y="122" >H</text>
|
||||
<text id="T_kb4i2" x="436" y="122" >J</text>
|
||||
<text id="T_kb4j2" x="490" y="122" >K</text>
|
||||
<text id="T_kb4k2" x="544" y="122" >L</text>
|
||||
|
||||
<text id="T_kb5c2" x="132" y="172" >Z</text>
|
||||
<text id="T_kb5d2" x="186" y="172" >X</text>
|
||||
<text id="T_kb5e2" x="240" y="172" >C</text>
|
||||
<text id="T_kb5f2" x="294" y="172" >V</text>
|
||||
<text id="T_kb5g2" x="348" y="172" >B</text>
|
||||
<text id="T_kb5h2" x="402" y="172" >N</text>
|
||||
<text id="T_kb5i2" x="454" y="172" >M</text>
|
||||
|
||||
<!-- Russian layout: bottom of key, opposite (right) edge -->
|
||||
<text id="T_kb3b_ru" class="T_ru" x="126" y="92" >й</text>
|
||||
<text id="T_kb3c_ru" class="T_ru" x="180" y="92" >ц</text>
|
||||
<text id="T_kb3d_ru" class="T_ru" x="234" y="92" >у</text>
|
||||
<text id="T_kb3e_ru" class="T_ru" x="288" y="92" >к</text>
|
||||
<text id="T_kb3f_ru" class="T_ru" x="342" y="92" >е</text>
|
||||
<text id="T_kb3g_ru" class="T_ru" x="396" y="92" >н</text>
|
||||
<text id="T_kb3h_ru" class="T_ru" x="450" y="92" >г</text>
|
||||
<text id="T_kb3i_ru" class="T_ru" x="504" y="92" >ш</text>
|
||||
<text id="T_kb3j_ru" class="T_ru" x="558" y="92" >щ</text>
|
||||
<text id="T_kb3k_ru" class="T_ru" x="612" y="92" >з</text>
|
||||
|
||||
<text id="T_kb4c_ru" class="T_ru" x="196" y="142" >ф</text>
|
||||
<text id="T_kb4d_ru" class="T_ru" x="250" y="142" >ы</text>
|
||||
<text id="T_kb4e_ru" class="T_ru" x="304" y="142" >в</text>
|
||||
<text id="T_kb4f_ru" class="T_ru" x="358" y="142" >а</text>
|
||||
<text id="T_kb4g_ru" class="T_ru" x="412" y="142" >п</text>
|
||||
<text id="T_kb4h_ru" class="T_ru" x="466" y="142" >р</text>
|
||||
<text id="T_kb4i_ru" class="T_ru" x="520" y="142" >о</text>
|
||||
<text id="T_kb4j_ru" class="T_ru" x="574" y="142" >л</text>
|
||||
<text id="T_kb4k_ru" class="T_ru" x="628" y="142" >д</text>
|
||||
|
||||
<text id="T_kb5c_ru" class="T_ru" x="162" y="192" >я</text>
|
||||
<text id="T_kb5d_ru" class="T_ru" x="216" y="192" >ч</text>
|
||||
<text id="T_kb5e_ru" class="T_ru" x="270" y="192" >с</text>
|
||||
<text id="T_kb5f_ru" class="T_ru" x="324" y="192" >м</text>
|
||||
<text id="T_kb5g_ru" class="T_ru" x="378" y="192" >и</text>
|
||||
<text id="T_kb5h_ru" class="T_ru" x="432" y="192" >т</text>
|
||||
<text id="T_kb5i_ru" class="T_ru" x="486" y="192" >ь</text>
|
||||
</g>
|
||||
|
||||
<g id="T_stdspecial">
|
||||
|
||||
<text id="T_kb2a1" x="14" y="42" class="T_size_L">`</text>
|
||||
<text id="T_kb2b1" x="68" y="42" >1</text>
|
||||
<text id="T_kb2c1" x="122" y="42" >2</text>
|
||||
<text id="T_kb2d1" x="176" y="42" >3</text>
|
||||
<text id="T_kb2e1" x="230" y="42" >4</text>
|
||||
<text id="T_kb2f1" x="284" y="42" >5</text>
|
||||
<text id="T_kb2g1" x="338" y="42" >6</text>
|
||||
<text id="T_kb2h1" x="392" y="42" >7</text>
|
||||
<text id="T_kb2i1" x="446" y="42" >8</text>
|
||||
<text id="T_kb2j1" x="500" y="42" >9</text>
|
||||
<text id="T_kb2k1" x="554" y="42" >0</text>
|
||||
<text id="T_kb2l1" x="610" y="42" >-</text>
|
||||
<text id="T_kb2m1" x="662" y="42" >=</text>
|
||||
|
||||
<text id="T_kb3l1" x="636" y="90" >[</text>
|
||||
<text id="T_kb3m1" x="690" y="90" >]</text>
|
||||
<text id="T_kb3n1" x="744" y="90" >\</text>
|
||||
|
||||
<text id="T_kb4l1" x="598" y="142" >;</text>
|
||||
<text id="T_kb4m1" x="652" y="142" class="T_size_L">'</text>
|
||||
|
||||
<text id="T_kb5j1" x="508" y="190" >,</text>
|
||||
<text id="T_kb5k1" x="562" y="192" >.</text>
|
||||
<text id="T_kb5l1" x="616" y="192" >/</text>
|
||||
|
||||
<!-- Shift level -->
|
||||
|
||||
<text id="T_kb2a2" x="14" y="18" class="T_size_L">~</text>
|
||||
<text id="T_kb2b2" x="68" y="20" >!</text>
|
||||
<text id="T_kb2c2" x="122" y="20" >@</text>
|
||||
<text id="T_kb2d2" x="176" y="20" >#</text>
|
||||
<text id="T_kb2e2" x="230" y="20" >$</text>
|
||||
<text id="T_kb2f2" x="284" y="20" >%</text>
|
||||
<text id="T_kb2g2" x="338" y="22" class="T_size_L">^</text>
|
||||
<text id="T_kb2h2" x="392" y="20" >&</text>
|
||||
<text id="T_kb2i2" x="446" y="20" >∗</text>
|
||||
<text id="T_kb2j2" x="500" y="20" >(</text>
|
||||
<text id="T_kb2k2" x="554" y="20" >)</text>
|
||||
<text id="T_kb2l2" x="608" y="20" >_</text>
|
||||
<text id="T_kb2m2" x="662" y="20" >+</text>
|
||||
|
||||
<text id="T_kb3l2" x="636" y="68" >{</text>
|
||||
<text id="T_kb3m2" x="690" y="68" >}</text>
|
||||
<text id="T_kb3n2" x="744" y="68" >|</text>
|
||||
|
||||
<text id="T_kb4l2" x="598" y="122" >:</text>
|
||||
<text id="T_kb4m2" x="652" y="122" class="T_size_L">"</text>
|
||||
|
||||
<text id="T_kb5j2" x="508" y="170" ><</text>
|
||||
<text id="T_kb5k2" x="562" y="170" >></text>
|
||||
<text id="T_kb5l2" x="616" y="170" >?</text>
|
||||
|
||||
<!-- Russian layout on punctuation / number row (ЙЦУКЕН, Windows) -->
|
||||
<text id="T_kb2a_ru" class="T_ru" x="46" y="42" >ё</text>
|
||||
<text id="T_kb2b_ru" class="T_ru" x="100" y="42" >!</text>
|
||||
<text id="T_kb2c_ru" class="T_ru" x="152" y="42" >"</text>
|
||||
<text id="T_kb2d_ru" class="T_ru" x="206" y="42" >№</text>
|
||||
<text id="T_kb2e_ru" class="T_ru" x="260" y="42" >;</text>
|
||||
<text id="T_kb2f_ru" class="T_ru" x="314" y="42" >%</text>
|
||||
<text id="T_kb2g_ru" class="T_ru" x="368" y="42" >:</text>
|
||||
<text id="T_kb2h_ru" class="T_ru" x="422" y="42" >?</text>
|
||||
<text id="T_kb2i_ru" class="T_ru" x="476" y="42" >*</text>
|
||||
<text id="T_kb2j_ru" class="T_ru" x="530" y="42" >(</text>
|
||||
<text id="T_kb2k_ru" class="T_ru" x="584" y="42" >)</text>
|
||||
<text id="T_kb2l_ru" class="T_ru" x="638" y="42" >-</text>
|
||||
<text id="T_kb2m_ru" class="T_ru" x="692" y="42" >+</text>
|
||||
|
||||
<text id="T_kb3l_ru" class="T_ru" x="666" y="92" >х</text>
|
||||
<text id="T_kb3m_ru" class="T_ru" x="720" y="92" >ъ</text>
|
||||
<text id="T_kb3n_ru" class="T_ru" x="796" y="92" >/</text>
|
||||
|
||||
<text id="T_kb4l_ru" class="T_ru" x="682" y="142" >ж</text>
|
||||
<text id="T_kb4m_ru" class="T_ru" x="736" y="142" >э</text>
|
||||
|
||||
<text id="T_kb5j_ru" class="T_ru" x="540" y="192" >б</text>
|
||||
<text id="T_kb5k_ru" class="T_ru" x="594" y="192" >ю</text>
|
||||
<text id="T_kb5l_ru" class="T_ru" x="648" y="192" >.</text>
|
||||
</g>
|
||||
|
||||
<g id="T_others">
|
||||
<text id="T_kb6a" x="10" y="230" >ctrl</text>
|
||||
<text id="T_kb6c" x="134" y="230" >alt</text>
|
||||
<text id="T_kb6k" x="564" y="230" >alt</text>
|
||||
<text id="T_kb6n" x="750" y="230" >ctrl</text>
|
||||
</g>
|
||||
<!-- ======== alternates inscriptions ======== -->
|
||||
<g inkscape:groupmode="layer" id="sublayer4a" inkscape:label="alternative inscriptions" style="display:inline">
|
||||
<g id="T_alterninsc" >
|
||||
<!-- K_kb2n x=704 w=96 → правый край подписи ~790 -->
|
||||
<text id="T_kb2n" class="T_alt-right" x="790" y="40" >delete</text>
|
||||
<!-- K_kb3a x=2 w=74 -->
|
||||
<text id="T_kb3a" class="T_alt-left" x="10" y="80" >tab</text>
|
||||
<!-- K_kb4n x=692 w=107 -->
|
||||
<text id="T_kb4n" class="T_alt-right" x="789" y="130">return</text>
|
||||
<!-- K_kb4a x=2 w=90 -->
|
||||
<text id="T_kb4a1" class="T_alt-left" x="10" y="128">caps lock</text>
|
||||
<!-- K_kb5a x=2 w=110 -->
|
||||
<text id="T_kb5a" class="T_alt-left" x="10" y="180">shift</text>
|
||||
<!-- K_kb5m x=658 w=142 -->
|
||||
<text id="T_kb5m" class="T_alt-right" x="790" y="180">shift</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 19 KiB |
19
public/svg/visual/mouse.svg
Normal file
19
public/svg/visual/mouse.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="707" height="1042" viewBox="0 0 707 1042" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style type="text/css"><![CDATA[
|
||||
text {
|
||||
font-family: var(--sg-keyboard-font-family);
|
||||
font-weight: var(--sg-keyboard-font-weight);
|
||||
letter-spacing: 0.04em;
|
||||
fill: var(--sg-keyboard-ink-soft);
|
||||
stroke: none;
|
||||
text-anchor: middle;
|
||||
font-size: 44px;
|
||||
}
|
||||
]]></style>
|
||||
</defs>
|
||||
<path d="M346 80.0889C319.97 83.9545 300 106.395 300 133.5V295.5C300 322.605 319.97 345.045 346 348.91V1042C154.909 1042 4.48005e-06 887.091 0 696V346C0 154.909 154.909 0 346 0V80.0889ZM361 0C552.091 0 707 154.909 707 346V696C707 887.091 552.091 1042 361 1042V349.05C387.516 345.618 408 322.951 408 295.5V133.5C408 106.049 387.516 83.3814 361 79.9492V0Z" fill="#D9D9D9"/>
|
||||
<rect x="312" y="94" width="84" height="242" rx="42" fill="#D9D9D9"/>
|
||||
<text x="210" y="210">ЛКМ</text>
|
||||
<text x="500" y="210">ПКМ</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 947 B |
@@ -1,11 +1,24 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||
import { provideTaiga } from '@taiga-ui/core';
|
||||
import { tuiNotificationOptionsProvider } from '@taiga-ui/core/components/notification';
|
||||
import { authInterceptor } from './core/http/auth.interceptor';
|
||||
import { apiBaseUrlInterceptor } from './core/http/api-base-url.interceptor';
|
||||
import { devLogInterceptor } from './core/http/dev-log.interceptor';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes)
|
||||
]
|
||||
provideAnimations(),
|
||||
provideTaiga(),
|
||||
tuiNotificationOptionsProvider(() => ({
|
||||
block: 'start',
|
||||
inline: 'end',
|
||||
})),
|
||||
provideHttpClient(withInterceptors([devLogInterceptor, apiBaseUrlInterceptor, authInterceptor])),
|
||||
provideRouter(routes),
|
||||
],
|
||||
};
|
||||
|
||||
103
src/app/app.css
103
src/app/app.css
@@ -0,0 +1,103 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.shell-header {
|
||||
border-bottom: 1px solid var(--tui-border-normal);
|
||||
background: var(--tui-background-elevation-1);
|
||||
}
|
||||
|
||||
.shell-header__inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 1.75rem;
|
||||
padding-block: 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-family: inherit;
|
||||
font-size: clamp(1.15rem, 2vw, 1.4rem);
|
||||
font-weight: 500;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.015em;
|
||||
color: var(--tui-text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: block;
|
||||
height: 2rem;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand:hover {
|
||||
color: var(--tui-text-action);
|
||||
}
|
||||
|
||||
.shell-nav {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-left: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shell-nav-link {
|
||||
text-decoration: none;
|
||||
font: var(--tui-font-text-s);
|
||||
font-weight: 400;
|
||||
color: var(--sg-color-subtitle);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.shell-nav-link:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.shell-user {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.shell-user__email {
|
||||
font: var(--tui-font-text-s);
|
||||
color: var(--tui-text-tertiary);
|
||||
}
|
||||
|
||||
.shell-logout {
|
||||
font: var(--tui-font-text-s);
|
||||
color: var(--tui-text-action);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.shell-logout:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.shell-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shell-sub {
|
||||
font: var(--tui-font-text-s);
|
||||
color: var(--sg-color-subtitle);
|
||||
}
|
||||
|
||||
368
src/app/app.html
368
src/app/app.html
@@ -1,344 +1,32 @@
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
|
||||
<!-- * * * * * * * to get started with your project! * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
<style>
|
||||
:host {
|
||||
--bright-blue: oklch(51.01% 0.274 263.83);
|
||||
--electric-violet: oklch(53.18% 0.28 296.97);
|
||||
--french-violet: oklch(47.66% 0.246 305.88);
|
||||
--vivid-pink: oklch(69.02% 0.277 332.77);
|
||||
--hot-red: oklch(61.42% 0.238 15.34);
|
||||
--orange-red: oklch(63.32% 0.24 31.68);
|
||||
|
||||
--gray-900: oklch(19.37% 0.006 300.98);
|
||||
--gray-700: oklch(36.98% 0.014 302.71);
|
||||
--gray-400: oklch(70.9% 0.015 304.04);
|
||||
|
||||
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
|
||||
180deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
|
||||
90deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--pill-accent: var(--bright-blue);
|
||||
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
box-sizing: border-box;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
display: block;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.125rem;
|
||||
color: var(--gray-900);
|
||||
font-weight: 500;
|
||||
line-height: 100%;
|
||||
letter-spacing: -0.125rem;
|
||||
margin: 0;
|
||||
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
box-sizing: inherit;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.angular-logo {
|
||||
max-width: 9.2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
background: var(--red-to-pink-to-purple-vertical-gradient);
|
||||
margin-inline: 0.5rem;
|
||||
}
|
||||
|
||||
.pill-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
--pill-accent: var(--bright-blue);
|
||||
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
|
||||
color: var(--pill-accent);
|
||||
padding-inline: 0.75rem;
|
||||
padding-block: 0.375rem;
|
||||
border-radius: 2.75rem;
|
||||
border: 0;
|
||||
transition: background 0.3s ease;
|
||||
font-family: var(--inter-font);
|
||||
font-size: 0.875rem;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 1.4rem;
|
||||
letter-spacing: -0.00875rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 1) {
|
||||
--pill-accent: var(--bright-blue);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 2) {
|
||||
--pill-accent: var(--electric-violet);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 3) {
|
||||
--pill-accent: var(--french-violet);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 4),
|
||||
.pill-group .pill:nth-child(6n + 5),
|
||||
.pill-group .pill:nth-child(6n + 6) {
|
||||
--pill-accent: var(--hot-red);
|
||||
}
|
||||
|
||||
.pill-group svg {
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.73rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.social-links path {
|
||||
transition: fill 0.3s ease;
|
||||
fill: var(--gray-400);
|
||||
}
|
||||
|
||||
.social-links a:hover svg path {
|
||||
fill: var(--gray-900);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--red-to-pink-to-purple-horizontal-gradient);
|
||||
margin-block: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<main class="main">
|
||||
<div class="content">
|
||||
<div class="left-side">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 982 239"
|
||||
fill="none"
|
||||
class="angular-logo"
|
||||
>
|
||||
<g clip-path="url(#a)">
|
||||
<path
|
||||
fill="url(#b)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#c)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="c"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#FF41F8" />
|
||||
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
|
||||
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient
|
||||
id="b"
|
||||
x1="0"
|
||||
x2="982"
|
||||
y1="192"
|
||||
y2="192"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#F0060B" />
|
||||
<stop offset="0" stop-color="#F0070C" />
|
||||
<stop offset=".526" stop-color="#CC26D5" />
|
||||
<stop offset="1" stop-color="#7702FF" />
|
||||
</linearGradient>
|
||||
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<h1>Hello, {{ title() }}</h1>
|
||||
<p>Congratulations! Your app is running. 🎉</p>
|
||||
</div>
|
||||
<div class="divider" role="separator" aria-label="Divider"></div>
|
||||
<div class="right-side">
|
||||
<div class="pill-group">
|
||||
@for (item of [
|
||||
{ title: 'Explore the Docs', link: 'https://angular.dev' },
|
||||
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
|
||||
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
|
||||
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
|
||||
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
|
||||
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
|
||||
]; track item.title) {
|
||||
<a
|
||||
class="pill"
|
||||
[href]="item.link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span>{{ item.title }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="14"
|
||||
viewBox="0 -960 960 960"
|
||||
width="14"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<tui-root>
|
||||
<div class="shell">
|
||||
<header class="shell-header">
|
||||
<div class="shell-header__inner sg-content-column">
|
||||
<a routerLink="/" class="brand">
|
||||
<img src="/svg/logo/logo.svg" alt="" class="brand-logo" width="34" height="36" />
|
||||
ANTIPLAGIAT
|
||||
</a>
|
||||
<div class="shell-nav">
|
||||
<a routerLink="/dashboard" class="shell-sub shell-nav-link">Сущности</a>
|
||||
<a routerLink="/works" class="shell-sub shell-nav-link">Работы</a>
|
||||
<a routerLink="/monitoring" class="shell-sub shell-nav-link">Мониторинг</a>
|
||||
</div>
|
||||
@if (auth.isAuthenticated()) {
|
||||
<div class="shell-user">
|
||||
@if (auth.user(); as user) {
|
||||
<span class="shell-user__email">{{ user.email }}</span>
|
||||
}
|
||||
<a class="shell-logout" (click)="logout()">Выйти</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="social-links">
|
||||
<a
|
||||
href="https://github.com/angular/angular"
|
||||
aria-label="Github"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="25"
|
||||
height="24"
|
||||
viewBox="0 0 25 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Github"
|
||||
>
|
||||
<path
|
||||
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/angular"
|
||||
aria-label="X"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="X"
|
||||
>
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
|
||||
aria-label="Youtube"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="29"
|
||||
height="20"
|
||||
viewBox="0 0 29 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Youtube"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="shell-main">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
@if (isDev) {
|
||||
<app-dev-console />
|
||||
}
|
||||
</tui-root>
|
||||
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
|
||||
<router-outlet />
|
||||
|
||||
@@ -1,3 +1,68 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard } from './core/guards/auth.guard';
|
||||
|
||||
export const routes: Routes = [];
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./features/landing/landing.component').then((m) => m.LandingComponent),
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () =>
|
||||
import('./features/login/login.component').then((m) => m.LoginComponent),
|
||||
},
|
||||
{
|
||||
path: 'works',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/works/works-list/works-list.component').then(
|
||||
(m) => m.WorksListComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/dashboard/dashboard.component').then((m) => m.DashboardComponent),
|
||||
},
|
||||
{
|
||||
path: 'works/:id',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/works/work-detail/work-detail.component').then(
|
||||
(m) => m.WorkDetailComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'events/:id',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/events/event-detail.component').then((m) => m.EventDetailComponent),
|
||||
},
|
||||
{
|
||||
path: 'groups/:id',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/groups/group-detail.component').then((m) => m.GroupDetailComponent),
|
||||
},
|
||||
{
|
||||
path: 'students/:id',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/students/student-detail.component').then((m) => m.StudentDetailComponent),
|
||||
},
|
||||
{
|
||||
path: 'reference-sets/:id',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/reference-sets/refset-detail.component').then((m) => m.RefsetDetailComponent),
|
||||
},
|
||||
{
|
||||
path: 'monitoring',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/monitoring/monitoring.component').then((m) => m.MonitoringComponent),
|
||||
},
|
||||
{ path: '**', redirectTo: '' },
|
||||
];
|
||||
|
||||
@@ -18,6 +18,6 @@ describe('App', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, SparkAntiplagiat');
|
||||
expect(compiled.querySelector('.brand')?.textContent).toContain('SparkGuardian');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { ChangeDetectionStrategy, Component, inject, isDevMode } from '@angular/core';
|
||||
import { Router, RouterLink, RouterOutlet } from '@angular/router';
|
||||
import { TuiRoot } from '@taiga-ui/core/components/root';
|
||||
import { AuthService } from './core/services/auth.service';
|
||||
import { DevConsoleComponent } from './features/devtools/dev-console.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
imports: [RouterLink, RouterOutlet, TuiRoot, DevConsoleComponent],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css'
|
||||
styleUrl: './app.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class App {
|
||||
protected readonly title = signal('SparkAntiplagiat');
|
||||
private readonly router = inject(Router);
|
||||
protected readonly auth = inject(AuthService);
|
||||
protected readonly isDev = isDevMode();
|
||||
|
||||
protected logout(): void {
|
||||
this.auth.logout();
|
||||
this.router.navigateByUrl('/login');
|
||||
}
|
||||
}
|
||||
|
||||
48
src/app/core/config/api.tokens.ts
Normal file
48
src/app/core/config/api.tokens.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { inject, InjectionToken } from '@angular/core';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
/**
|
||||
* Origin для разрешения относительных URL.
|
||||
* При `ng serve` без прокси — fallback на origin бэкенда.
|
||||
*/
|
||||
export const API_ORIGIN = new InjectionToken<string>('API_ORIGIN', {
|
||||
factory: () => {
|
||||
const doc = inject(DOCUMENT);
|
||||
const loc = doc.defaultView?.location;
|
||||
if (!loc || loc.protocol === 'file:') {
|
||||
return environment.apiFallbackOrigin;
|
||||
}
|
||||
return loc.origin;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Полный базовый URL для API-запросов.
|
||||
*
|
||||
* - На localhost (dev-сервер без прокси) → абсолютный URL из environment
|
||||
* - На деплое (origin = backend) → относительный `/api`
|
||||
* - Для `file://` — абсолютный URL из environment
|
||||
*/
|
||||
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL', {
|
||||
factory: () => {
|
||||
const doc = inject(DOCUMENT);
|
||||
const loc = doc.defaultView?.location;
|
||||
const absolute = `${environment.apiFallbackOrigin}${environment.apiBasePath}`;
|
||||
|
||||
// file:// или SSR без window
|
||||
if (!loc || loc.protocol === 'file:') {
|
||||
return absolute;
|
||||
}
|
||||
|
||||
// При ng serve на localhost → нет прокси, нужен абсолютный URL
|
||||
if (loc.hostname === 'localhost' || loc.hostname === '127.0.0.1') {
|
||||
return absolute;
|
||||
}
|
||||
|
||||
// Деплой на backend origin — относительный путь
|
||||
return environment.apiBasePath;
|
||||
},
|
||||
});
|
||||
|
||||
7
src/app/core/config/app.tokens.ts
Normal file
7
src/app/core/config/app.tokens.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
export const DEFAULT_PAGE_LIMIT = new InjectionToken<number>('DEFAULT_PAGE_LIMIT', {
|
||||
factory: () => environment.defaultPageLimit,
|
||||
});
|
||||
52
src/app/core/devtools/dev-log.service.ts
Normal file
52
src/app/core/devtools/dev-log.service.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type DevLogLevel = 'info' | 'warn' | 'error';
|
||||
export type DevLogStatus = 'pending' | 'ok' | 'error';
|
||||
|
||||
export interface DevHttpLogDetails {
|
||||
method: string;
|
||||
url: string;
|
||||
requestHeaders?: Record<string, string | null>;
|
||||
requestBody?: unknown;
|
||||
statusCode?: number;
|
||||
durationMs?: number;
|
||||
responseHeaders?: Record<string, string | null>;
|
||||
responseBody?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DevLogEntry {
|
||||
id: number;
|
||||
time: string;
|
||||
level: DevLogLevel;
|
||||
source: 'http' | 'system';
|
||||
message: string;
|
||||
status?: DevLogStatus;
|
||||
details?: DevHttpLogDetails;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DevLogService {
|
||||
private seq = 0;
|
||||
private readonly max = 300;
|
||||
readonly entries = signal<DevLogEntry[]>([]);
|
||||
|
||||
add(entry: Omit<DevLogEntry, 'id' | 'time'>): void {
|
||||
const withMeta: DevLogEntry = {
|
||||
id: ++this.seq,
|
||||
time: new Date().toISOString(),
|
||||
...entry,
|
||||
};
|
||||
this.entries.update((curr) => [...curr.slice(-(this.max - 1)), withMeta]);
|
||||
}
|
||||
|
||||
update(id: number, patch: Partial<Omit<DevLogEntry, 'id' | 'time'>>): void {
|
||||
this.entries.update((curr) =>
|
||||
curr.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
||||
);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.set([]);
|
||||
}
|
||||
}
|
||||
11
src/app/core/guards/auth.guard.ts
Normal file
11
src/app/core/guards/auth.guard.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { inject } from '@angular/core';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const auth = inject(AuthService);
|
||||
if (auth.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
return inject(Router).createUrlTree(['/login']);
|
||||
};
|
||||
19
src/app/core/http/api-base-url.interceptor.ts
Normal file
19
src/app/core/http/api-base-url.interceptor.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
|
||||
import { API_BASE_URL } from '../config/api.tokens';
|
||||
|
||||
/** Префиксы путей, которые НЕ проксируются через API base URL (статические ассеты). */
|
||||
const STATIC_ASSET_PREFIXES = ['/svg/', '/fonts/', '/images/'];
|
||||
|
||||
export const apiBaseUrlInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const base = inject(API_BASE_URL);
|
||||
if (/^https?:\/\//i.test(req.url)) {
|
||||
return next(req);
|
||||
}
|
||||
const path = req.url.startsWith('/') ? req.url : `/${req.url}`;
|
||||
if (STATIC_ASSET_PREFIXES.some((p) => path.startsWith(p))) {
|
||||
return next(req);
|
||||
}
|
||||
return next(req.clone({ url: `${base}${path}` }));
|
||||
};
|
||||
24
src/app/core/http/auth.interceptor.ts
Normal file
24
src/app/core/http/auth.interceptor.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
/** Endpoints that must NOT carry the Authorization header. */
|
||||
const PUBLIC_PATHS = ['/auth/login', '/auth/register'];
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (request, next) => {
|
||||
const auth = inject(AuthService);
|
||||
const token = auth.token();
|
||||
|
||||
if (token === null || PUBLIC_PATHS.some((p) => request.url.endsWith(p))) {
|
||||
return next(request);
|
||||
}
|
||||
|
||||
return next(
|
||||
request.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
101
src/app/core/http/dev-log.interceptor.ts
Normal file
101
src/app/core/http/dev-log.interceptor.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHeaders,
|
||||
HttpInterceptorFn,
|
||||
HttpResponse,
|
||||
} from '@angular/common/http';
|
||||
import { inject, isDevMode } from '@angular/core';
|
||||
import { tap } from 'rxjs';
|
||||
|
||||
import { httpErrorMessage } from './http-error.util';
|
||||
import { DevLogService } from '../devtools/dev-log.service';
|
||||
|
||||
function headersToObject(headers: HttpHeaders): Record<string, string | null> {
|
||||
const out: Record<string, string | null> = {};
|
||||
for (const key of headers.keys()) {
|
||||
out[key] = headers.get(key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeBody(value: unknown): unknown {
|
||||
if (value === undefined) return null;
|
||||
if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
|
||||
if (value instanceof Blob) return `[Blob ${value.type || 'unknown'} ${value.size} bytes]`;
|
||||
if (value instanceof FormData) {
|
||||
const fields: Record<string, unknown> = {};
|
||||
value.forEach((v, k) => { fields[k] = typeof v === 'string' ? v : `[File ${(v as File).name}]`; });
|
||||
return fields;
|
||||
}
|
||||
if (value instanceof ArrayBuffer) return `[ArrayBuffer ${value.byteLength} bytes]`;
|
||||
return value;
|
||||
}
|
||||
|
||||
export const devLogInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (!isDevMode()) return next(req);
|
||||
|
||||
const logs = inject(DevLogService);
|
||||
const started = performance.now();
|
||||
logs.add({
|
||||
level: 'info',
|
||||
source: 'http',
|
||||
status: 'pending',
|
||||
message: `→ ${req.method} ${req.urlWithParams} (pending)`,
|
||||
details: {
|
||||
method: req.method,
|
||||
url: req.urlWithParams,
|
||||
requestHeaders: headersToObject(req.headers),
|
||||
requestBody: normalizeBody(req.body),
|
||||
},
|
||||
});
|
||||
const entryId = logs.entries().at(-1)?.id;
|
||||
|
||||
return next(req).pipe(
|
||||
tap({
|
||||
next: (event: HttpEvent<unknown>) => {
|
||||
if (!(event instanceof HttpResponse)) return;
|
||||
const ms = Math.round(performance.now() - started);
|
||||
if (entryId) {
|
||||
logs.update(entryId, {
|
||||
level: 'info',
|
||||
status: 'ok',
|
||||
message: `✓ ${req.method} ${req.urlWithParams} [${event.status}] ${ms}ms`,
|
||||
details: {
|
||||
method: req.method,
|
||||
url: req.urlWithParams,
|
||||
requestHeaders: headersToObject(req.headers),
|
||||
requestBody: normalizeBody(req.body),
|
||||
statusCode: event.status,
|
||||
durationMs: ms,
|
||||
responseHeaders: headersToObject(event.headers),
|
||||
responseBody: normalizeBody(event.body),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
const ms = Math.round(performance.now() - started);
|
||||
const status = error instanceof HttpErrorResponse ? error.status : undefined;
|
||||
if (entryId) {
|
||||
logs.update(entryId, {
|
||||
level: 'error',
|
||||
status: 'error',
|
||||
message: `✕ ${req.method} ${req.urlWithParams}${status ? ` [${status}]` : ''} ${ms}ms: ${httpErrorMessage(error)}`,
|
||||
details: {
|
||||
method: req.method,
|
||||
url: req.urlWithParams,
|
||||
requestHeaders: headersToObject(req.headers),
|
||||
requestBody: normalizeBody(req.body),
|
||||
statusCode: status,
|
||||
durationMs: ms,
|
||||
responseHeaders: error instanceof HttpErrorResponse ? headersToObject(error.headers) : undefined,
|
||||
responseBody: error instanceof HttpErrorResponse ? normalizeBody(error.error) : undefined,
|
||||
error: httpErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
64
src/app/core/http/error-classification.util.ts
Normal file
64
src/app/core/http/error-classification.util.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { TimeoutError } from 'rxjs';
|
||||
|
||||
import type { UserErrorKind } from '../notifications/user-error-messages.config';
|
||||
|
||||
function isParseFailureMessage(message: string | undefined): boolean {
|
||||
return !!message?.includes('Http failure during parsing');
|
||||
}
|
||||
|
||||
export function classifyUserError(err: unknown): UserErrorKind {
|
||||
if (err instanceof TimeoutError) {
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
if (err instanceof HttpErrorResponse) {
|
||||
if (isParseFailureMessage(err.message)) {
|
||||
return 'parse_error';
|
||||
}
|
||||
const s = err.status;
|
||||
if (s === 0) {
|
||||
return 'network';
|
||||
}
|
||||
if (s === 408) {
|
||||
return 'timeout';
|
||||
}
|
||||
if (s === 401) {
|
||||
return 'unauthorized';
|
||||
}
|
||||
if (s === 403) {
|
||||
return 'forbidden';
|
||||
}
|
||||
if (s === 404) {
|
||||
return 'not_found';
|
||||
}
|
||||
if (s === 400) {
|
||||
return 'bad_request';
|
||||
}
|
||||
if (s >= 500) {
|
||||
return 'server_error';
|
||||
}
|
||||
if (s >= 400 && s < 500) {
|
||||
return 'client_error';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
const m = err.message;
|
||||
if (isParseFailureMessage(m)) {
|
||||
return 'parse_error';
|
||||
}
|
||||
if (/идентификатор|некорректн/i.test(m)) {
|
||||
return 'invalid_input';
|
||||
}
|
||||
if (/timeout|timed out/i.test(m)) {
|
||||
return 'timeout';
|
||||
}
|
||||
if (/network|failed to fetch|load failed|net::/i.test(m)) {
|
||||
return 'network';
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
58
src/app/core/http/http-error.util.ts
Normal file
58
src/app/core/http/http-error.util.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
|
||||
export interface ApiErrorBody {
|
||||
readonly error?: string;
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function summarizeHtmlError(text: string): string | null {
|
||||
const t = text.trim();
|
||||
if (!t.startsWith('<!') && !t.startsWith('<html')) {
|
||||
return null;
|
||||
}
|
||||
const pre = t.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i);
|
||||
if (pre?.[1]) {
|
||||
return pre[1].trim();
|
||||
}
|
||||
const title = t.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
if (title?.[1]) {
|
||||
return title[1].trim();
|
||||
}
|
||||
return 'Сервер вернул HTML вместо JSON (часто редирект на вход или ошибка прокси).';
|
||||
}
|
||||
|
||||
export function httpErrorMessage(err: unknown): string {
|
||||
if (err instanceof HttpErrorResponse) {
|
||||
const body = err.error as ApiErrorBody | string | null | undefined;
|
||||
if (body && typeof body === 'object' && typeof body.error === 'string') {
|
||||
return body.error;
|
||||
}
|
||||
if (typeof body === 'string' && body.length > 0) {
|
||||
const fromHtml = summarizeHtmlError(body);
|
||||
if (fromHtml) {
|
||||
return fromHtml;
|
||||
}
|
||||
return body.length > 200 ? `${body.slice(0, 200)}…` : body;
|
||||
}
|
||||
if (err.message?.includes('Http failure during parsing')) {
|
||||
return 'Ответ не JSON (часто HTML страницы входа или ошибка прокси /api).';
|
||||
}
|
||||
return err.message || `HTTP ${err.status}`;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('Http failure during parsing')) {
|
||||
return 'Ответ не JSON — проверьте авторизацию и прокси для API.';
|
||||
}
|
||||
return err.message;
|
||||
}
|
||||
return 'Неизвестная ошибка';
|
||||
}
|
||||
252
src/app/core/models/api.types.ts
Normal file
252
src/app/core/models/api.types.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
export interface ApiErrorResponse {
|
||||
readonly message?: string;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly access_level: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
readonly token: string;
|
||||
readonly user: UserInfo;
|
||||
}
|
||||
|
||||
export interface AuthClaims {
|
||||
readonly user_id?: number;
|
||||
readonly email?: string;
|
||||
readonly access_level?: string;
|
||||
}
|
||||
|
||||
export interface Work {
|
||||
readonly id: number;
|
||||
readonly student_id: number;
|
||||
readonly event_id: number;
|
||||
readonly time: string;
|
||||
readonly archive_object_key?: string;
|
||||
readonly archive_checksum?: string;
|
||||
readonly archive_size?: number;
|
||||
readonly archive_uploaded_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkRequest {
|
||||
readonly student_id: number;
|
||||
readonly event_id: number;
|
||||
readonly time: string;
|
||||
}
|
||||
|
||||
export interface UploadWorkResponse {
|
||||
readonly message?: string;
|
||||
readonly status?: string;
|
||||
readonly analysis_run_id?: string;
|
||||
readonly archive_object_key?: string;
|
||||
readonly archive_checksum?: string;
|
||||
readonly archive_size?: number;
|
||||
}
|
||||
|
||||
export type AnalysisRunStatus = 'Queued' | 'Processing' | 'Completed' | 'Failed' | string;
|
||||
|
||||
export interface AnalysisRun {
|
||||
readonly id: string;
|
||||
readonly work_id: number;
|
||||
readonly status: AnalysisRunStatus;
|
||||
readonly submitted_at?: string;
|
||||
readonly started_at?: string;
|
||||
readonly completed_at?: string;
|
||||
readonly updated_at?: string;
|
||||
readonly error_message?: string;
|
||||
}
|
||||
|
||||
export interface DashboardMetrics {
|
||||
readonly works_total?: number;
|
||||
readonly works_checked?: number;
|
||||
readonly works_completed?: number;
|
||||
readonly works_failed?: number;
|
||||
readonly works_flagged?: number;
|
||||
readonly connections_count?: number;
|
||||
readonly counterparts_count?: number;
|
||||
readonly plagiarism_rate?: number;
|
||||
readonly trust_score?: number;
|
||||
readonly risk_level?: string;
|
||||
readonly headline?: string;
|
||||
}
|
||||
|
||||
export interface DashboardCounterpart {
|
||||
readonly work_id?: number;
|
||||
readonly student_name?: string;
|
||||
readonly label?: string;
|
||||
readonly score?: number;
|
||||
readonly risk_level?: string;
|
||||
readonly max_similarity?: number;
|
||||
}
|
||||
|
||||
export interface DashboardGraphNode {
|
||||
readonly work_id?: number;
|
||||
readonly label?: string;
|
||||
readonly student_name?: string;
|
||||
readonly risk_level?: string;
|
||||
}
|
||||
|
||||
export interface DashboardGraphEdge {
|
||||
readonly from_work_id?: number;
|
||||
readonly to_work_id?: number;
|
||||
readonly score?: number;
|
||||
readonly risk_level?: string;
|
||||
}
|
||||
|
||||
export interface DashboardGraph {
|
||||
readonly nodes?: readonly DashboardGraphNode[];
|
||||
readonly edges?: readonly DashboardGraphEdge[];
|
||||
}
|
||||
|
||||
export interface DashboardWorkItem {
|
||||
readonly latest_run_id?: string;
|
||||
readonly latest_run_status?: string;
|
||||
readonly strongest_counterpart?: string;
|
||||
readonly plagiarism_rate?: number;
|
||||
readonly trust_score?: number;
|
||||
}
|
||||
|
||||
export interface WorkDashboard {
|
||||
readonly presentation_summary?: DashboardMetrics;
|
||||
readonly work?: DashboardWorkItem;
|
||||
readonly latest_run?: AnalysisRun;
|
||||
readonly counterparts?: readonly DashboardCounterpart[];
|
||||
readonly graph?: DashboardGraph;
|
||||
}
|
||||
|
||||
export interface Adoption {
|
||||
readonly id: number;
|
||||
readonly path?: string;
|
||||
readonly similarity_score?: number;
|
||||
readonly segment_excerpt?: string;
|
||||
readonly refers_to?: number;
|
||||
}
|
||||
|
||||
export interface Student {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly user_id?: number;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly students?: readonly number[];
|
||||
readonly users?: readonly number[];
|
||||
}
|
||||
|
||||
export interface EventEntity {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly group_id: number;
|
||||
readonly date: string;
|
||||
}
|
||||
|
||||
export interface ReferenceSet {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly kind: string;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
readonly id: number;
|
||||
readonly action?: string;
|
||||
readonly resource_type?: string;
|
||||
readonly resource_id?: string;
|
||||
readonly actor_email?: string;
|
||||
readonly created_at?: string;
|
||||
}
|
||||
|
||||
/* ── Scope Dashboard (events/{id}/summary, groups/{id}/stats, students/{id}/stats) ── */
|
||||
|
||||
export interface ScopeDashboard {
|
||||
readonly presentation_summary?: DashboardMetrics;
|
||||
readonly works?: readonly ScopeDashboardWorkCard[];
|
||||
readonly graph?: DashboardGraph;
|
||||
}
|
||||
|
||||
export interface ScopeDashboardWorkCard {
|
||||
readonly work_id?: number;
|
||||
readonly student_name?: string;
|
||||
readonly latest_run_status?: string;
|
||||
readonly plagiarism_rate?: number;
|
||||
readonly trust_score?: number;
|
||||
readonly risk_level?: string;
|
||||
}
|
||||
|
||||
/* ── Analysis Run Chunks ── */
|
||||
|
||||
export interface AnalysisRunChunk {
|
||||
readonly id?: number;
|
||||
readonly analysis_run_id?: string;
|
||||
readonly file_path?: string;
|
||||
readonly chunk_index?: number;
|
||||
readonly content_hash?: string;
|
||||
readonly token_count?: number;
|
||||
}
|
||||
|
||||
/* ── Ingestions (reference-sets) ── */
|
||||
|
||||
export interface Ingestion {
|
||||
readonly id: number;
|
||||
readonly reference_set_id: number;
|
||||
readonly status?: string;
|
||||
readonly source_object_key?: string;
|
||||
readonly created_at?: string;
|
||||
readonly completed_at?: string;
|
||||
}
|
||||
|
||||
/* ── Success response ── */
|
||||
|
||||
export interface DefaultSuccessResponse {
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
/* ── Edit / Create request types ── */
|
||||
|
||||
export interface EditEventRequest {
|
||||
readonly name?: string;
|
||||
readonly description?: string;
|
||||
readonly group_id?: number;
|
||||
readonly date?: string;
|
||||
}
|
||||
|
||||
export interface EditGroupRequest {
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
export interface EditStudentRequest {
|
||||
readonly name?: string;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
export interface EditRefSetRequest {
|
||||
readonly name?: string;
|
||||
readonly kind?: string;
|
||||
readonly description?: string;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
readonly name: string;
|
||||
readonly access_level?: string;
|
||||
}
|
||||
|
||||
export interface EditWorkRequest {
|
||||
readonly student_id?: number;
|
||||
readonly event_id?: number;
|
||||
readonly time?: string;
|
||||
}
|
||||
28
src/app/core/monitoring/audit-resource-type.pipe.ts
Normal file
28
src/app/core/monitoring/audit-resource-type.pipe.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
const TRANSLATIONS: Record<string, string> = {
|
||||
work: 'Работа',
|
||||
group: 'Группа',
|
||||
student: 'Студент',
|
||||
user: 'Пользователь',
|
||||
event: 'Мероприятие',
|
||||
referenceset: 'Reference Set',
|
||||
session: 'Сессия',
|
||||
analysisrun: 'Запуск проверки',
|
||||
auth: 'Авторизация',
|
||||
stream: 'Поток',
|
||||
};
|
||||
|
||||
@Pipe({
|
||||
name: 'auditResourceType',
|
||||
standalone: true,
|
||||
})
|
||||
export class AuditResourceTypePipe implements PipeTransform {
|
||||
transform(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const key = value.toLowerCase().trim();
|
||||
return TRANSLATIONS[key] || value;
|
||||
}
|
||||
}
|
||||
19
src/app/core/notifications/user-error-messages.config.ts
Normal file
19
src/app/core/notifications/user-error-messages.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const USER_ERROR_FRIENDLY_MESSAGES = {
|
||||
network: 'Проверьте интернет-соединение.',
|
||||
timeout: 'Запрос занял слишком много времени. Попробуйте позже.',
|
||||
server_error: 'Уже работаем над этим.',
|
||||
not_found: 'Не удалось найти запрашиваемые данные.',
|
||||
unauthorized: 'Требуется вход в систему.',
|
||||
forbidden: 'Недостаточно прав для этого действия.',
|
||||
bad_request: 'Некорректный запрос. Попробуйте позже.',
|
||||
client_error: 'Не удалось выполнить запрос. Попробуйте позже.',
|
||||
parse_error: 'Получен неожиданный ответ сервера. Попробуйте позже.',
|
||||
invalid_input: 'Проверьте введённые данные.',
|
||||
unknown: 'Попробуйте позже.',
|
||||
} as const;
|
||||
|
||||
export type UserErrorKind = keyof typeof USER_ERROR_FRIENDLY_MESSAGES;
|
||||
|
||||
export function friendlyMessageForUserError(kind: UserErrorKind): string {
|
||||
return USER_ERROR_FRIENDLY_MESSAGES[kind];
|
||||
}
|
||||
55
src/app/core/notifications/user-error-notify.service.ts
Normal file
55
src/app/core/notifications/user-error-notify.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { isDevMode, inject, Injectable } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { TuiNotificationService } from '@taiga-ui/core/components/notification';
|
||||
|
||||
import { DevLogService } from '../devtools/dev-log.service';
|
||||
import { classifyUserError } from '../http/error-classification.util';
|
||||
import { escapeHtml, httpErrorMessage } from '../http/http-error.util';
|
||||
import { friendlyMessageForUserError } from './user-error-messages.config';
|
||||
|
||||
const ERROR_TOAST_TITLE = 'Что-то пошло не так...';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserErrorNotifyService {
|
||||
private readonly notifications = inject(TuiNotificationService);
|
||||
private readonly devLog = inject(DevLogService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
notifySuccess(message: string, label: string): void {
|
||||
this.notifications
|
||||
.open(escapeHtml(message), {
|
||||
label,
|
||||
appearance: 'positive',
|
||||
autoClose: 4000,
|
||||
closable: true,
|
||||
size: 'm',
|
||||
})
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
notifyError(err: unknown, source: string): void {
|
||||
const kind = classifyUserError(err);
|
||||
const userSubtitle = friendlyMessageForUserError(kind);
|
||||
const technical = httpErrorMessage(err);
|
||||
|
||||
if (isDevMode()) {
|
||||
this.devLog.add({
|
||||
level: 'error',
|
||||
source: 'system',
|
||||
message: `${source}: ${technical}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === 'unauthorized') {
|
||||
this.router.navigateByUrl('/login');
|
||||
}
|
||||
|
||||
this.notifications
|
||||
.open(escapeHtml(userSubtitle), {
|
||||
label: ERROR_TOAST_TITLE,
|
||||
appearance: 'negative',
|
||||
autoClose: 10000,
|
||||
})
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
54
src/app/core/services/auth.service.ts
Normal file
54
src/app/core/services/auth.service.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||
import { map, Observable, tap } from 'rxjs';
|
||||
import { AuthClaims, LoginRequest, LoginResponse, UserInfo } from '../models/api.types';
|
||||
|
||||
const TOKEN_KEY = 'sg_token';
|
||||
const USER_KEY = 'sg_user';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
readonly token = signal<string | null>(localStorage.getItem(TOKEN_KEY));
|
||||
readonly user = signal<UserInfo | null>(this.readUserFromStorage());
|
||||
readonly isAuthenticated = computed(() => this.token() !== null);
|
||||
|
||||
login(payload: LoginRequest): Observable<UserInfo> {
|
||||
return this.http
|
||||
.post<LoginResponse>('/auth/login', payload)
|
||||
.pipe(
|
||||
tap((response) => {
|
||||
this.token.set(response.token);
|
||||
this.user.set(response.user);
|
||||
localStorage.setItem(TOKEN_KEY, response.token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(response.user));
|
||||
}),
|
||||
map((response) => response.user),
|
||||
);
|
||||
}
|
||||
|
||||
getMe(): Observable<AuthClaims> {
|
||||
return this.http.get<AuthClaims>('/auth/me');
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.token.set(null);
|
||||
this.user.set(null);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
private readUserFromStorage(): UserInfo | null {
|
||||
const rawUser = localStorage.getItem(USER_KEY);
|
||||
if (rawUser === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(rawUser) as UserInfo;
|
||||
} catch {
|
||||
localStorage.removeItem(USER_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
286
src/app/core/services/works-api.service.ts
Normal file
286
src/app/core/services/works-api.service.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { DEFAULT_PAGE_LIMIT } from '../config/app.tokens';
|
||||
import {
|
||||
Adoption,
|
||||
AnalysisRun,
|
||||
AnalysisRunChunk,
|
||||
AuditLog,
|
||||
CreateUserRequest,
|
||||
CreateWorkRequest,
|
||||
DefaultSuccessResponse,
|
||||
EditEventRequest,
|
||||
EditGroupRequest,
|
||||
EditRefSetRequest,
|
||||
EditStudentRequest,
|
||||
EditWorkRequest,
|
||||
EventEntity,
|
||||
Group,
|
||||
Ingestion,
|
||||
ReferenceSet,
|
||||
ScopeDashboard,
|
||||
Student,
|
||||
UploadWorkResponse,
|
||||
UserInfo,
|
||||
Work,
|
||||
WorkDashboard,
|
||||
} from '../models/api.types';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class WorksApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly defaultLimit = inject(DEFAULT_PAGE_LIMIT);
|
||||
|
||||
/* ── Works ── */
|
||||
|
||||
listWorks(): Observable<readonly Work[]> {
|
||||
return this.http.get<readonly Work[]>('/works');
|
||||
}
|
||||
|
||||
createWork(payload: CreateWorkRequest): Observable<Work> {
|
||||
return this.http.post<Work>('/works', payload);
|
||||
}
|
||||
|
||||
getWork(workId: number): Observable<Work> {
|
||||
return this.http.get<Work>(`/works/${workId}`);
|
||||
}
|
||||
|
||||
updateWork(workId: number, payload: EditWorkRequest): Observable<Work> {
|
||||
return this.http.put<Work>(`/works/${workId}`, payload);
|
||||
}
|
||||
|
||||
deleteWork(workId: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/works/${workId}`);
|
||||
}
|
||||
|
||||
uploadArchive(workId: number, file: File): Observable<UploadWorkResponse> {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
return this.http.put<UploadWorkResponse>(`/works/${workId}/archive`, data);
|
||||
}
|
||||
|
||||
getWorkArchive(workId: number): Observable<Blob> {
|
||||
return this.http.get(`/works/${workId}/archive`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
runCheck(workId: number): Observable<UploadWorkResponse> {
|
||||
return this.http.post<UploadWorkResponse>(`/works/${workId}/check`, {});
|
||||
}
|
||||
|
||||
getWorkSummary(workId: number): Observable<WorkDashboard> {
|
||||
return this.http.get<WorkDashboard>(`/works/${workId}/summary`);
|
||||
}
|
||||
|
||||
listWorkRuns(workId: number): Observable<readonly AnalysisRun[]> {
|
||||
return this.http.get<readonly AnalysisRun[]>(`/works/${workId}/analysis-runs`);
|
||||
}
|
||||
|
||||
getWorkAdoptions(workId: number): Observable<readonly Adoption[]> {
|
||||
return this.http.get<readonly Adoption[]>(`/works/${workId}/adoptions`);
|
||||
}
|
||||
|
||||
getWorkAdoptionsArchive(workId: number): Observable<Blob> {
|
||||
return this.http.get(`/works/${workId}/adoptions/archive`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
getWorkAdoptionsRelated(workId: number): Observable<readonly Work[]> {
|
||||
return this.http.get<readonly Work[]>(`/works/${workId}/adoptions/related`);
|
||||
}
|
||||
|
||||
/* ── Analysis Runs ── */
|
||||
|
||||
getAnalysisRun(runId: string): Observable<AnalysisRun> {
|
||||
return this.http.get<AnalysisRun>(`/analysis-runs/${runId}`);
|
||||
}
|
||||
|
||||
getRunAdoptions(runId: string): Observable<readonly Adoption[]> {
|
||||
return this.http.get<readonly Adoption[]>(`/analysis-runs/${runId}/adoptions`);
|
||||
}
|
||||
|
||||
getRunChunks(runId: string): Observable<readonly AnalysisRunChunk[]> {
|
||||
return this.http.get<readonly AnalysisRunChunk[]>(`/analysis-runs/${runId}/chunks`);
|
||||
}
|
||||
|
||||
retryAnalysisRun(runId: string): Observable<UploadWorkResponse> {
|
||||
return this.http.post<UploadWorkResponse>(`/analysis-runs/${runId}/retry`, {});
|
||||
}
|
||||
|
||||
downloadReport(runId: string, format: 'json' | 'html' | 'pdf'): Observable<Blob> {
|
||||
return this.http.get(`/analysis-runs/${runId}/report.${format}`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
downloadRawReport(runId: string): Observable<Blob> {
|
||||
return this.http.get(`/analysis-runs/${runId}/report.raw.json`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
/* ── Adoptions ── */
|
||||
|
||||
getAdoptionSegment(adoptionId: number): Observable<string> {
|
||||
return this.http.get(`/adoptions/${adoptionId}/segment`, { responseType: 'text' });
|
||||
}
|
||||
|
||||
/* ── Students ── */
|
||||
|
||||
listStudents(): Observable<readonly Student[]> {
|
||||
return this.http.get<readonly Student[]>('/students');
|
||||
}
|
||||
|
||||
createStudent(payload: { readonly name: string; readonly email: string; readonly user_id?: number }): Observable<Student> {
|
||||
return this.http.post<Student>('/students', payload);
|
||||
}
|
||||
|
||||
getStudent(id: number): Observable<Student> {
|
||||
return this.http.get<Student>(`/students/${id}`);
|
||||
}
|
||||
|
||||
updateStudent(id: number, payload: EditStudentRequest): Observable<Student> {
|
||||
return this.http.patch<Student>(`/students/${id}`, payload);
|
||||
}
|
||||
|
||||
deleteStudent(id: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/students/${id}`);
|
||||
}
|
||||
|
||||
getStudentStats(id: number): Observable<ScopeDashboard> {
|
||||
return this.http.get<ScopeDashboard>(`/students/${id}/stats`);
|
||||
}
|
||||
|
||||
/* ── Groups ── */
|
||||
|
||||
listGroups(): Observable<readonly Group[]> {
|
||||
return this.http.get<readonly Group[]>('/groups');
|
||||
}
|
||||
|
||||
createGroup(payload: { readonly name: string }): Observable<Group> {
|
||||
return this.http.post<Group>('/groups', payload);
|
||||
}
|
||||
|
||||
getGroup(id: number): Observable<Group> {
|
||||
return this.http.get<Group>(`/groups/${id}`);
|
||||
}
|
||||
|
||||
updateGroup(id: number, payload: EditGroupRequest): Observable<Group> {
|
||||
return this.http.patch<Group>(`/groups/${id}`, payload);
|
||||
}
|
||||
|
||||
deleteGroup(id: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/groups/${id}`);
|
||||
}
|
||||
|
||||
getGroupStats(id: number): Observable<ScopeDashboard> {
|
||||
return this.http.get<ScopeDashboard>(`/groups/${id}/stats`);
|
||||
}
|
||||
|
||||
addStudentToGroup(groupId: number, studentId: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.post<DefaultSuccessResponse>(`/groups/${groupId}/students/${studentId}`, {});
|
||||
}
|
||||
|
||||
removeStudentFromGroup(groupId: number, studentId: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/groups/${groupId}/students/${studentId}`);
|
||||
}
|
||||
|
||||
addUserToGroup(groupId: number, userId: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.post<DefaultSuccessResponse>(`/groups/${groupId}/users/${userId}`, {});
|
||||
}
|
||||
|
||||
removeUserFromGroup(groupId: number, userId: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/groups/${groupId}/users/${userId}`);
|
||||
}
|
||||
|
||||
/* ── Events ── */
|
||||
|
||||
listEvents(): Observable<readonly EventEntity[]> {
|
||||
return this.http.get<readonly EventEntity[]>('/events');
|
||||
}
|
||||
|
||||
createEvent(payload: { readonly name: string; readonly description?: string; readonly group_id: number; readonly date: string }): Observable<EventEntity> {
|
||||
return this.http.post<EventEntity>('/events', payload);
|
||||
}
|
||||
|
||||
getEvent(id: number): Observable<EventEntity> {
|
||||
return this.http.get<EventEntity>(`/events/${id}`);
|
||||
}
|
||||
|
||||
updateEvent(id: number, payload: EditEventRequest): Observable<EventEntity> {
|
||||
return this.http.patch<EventEntity>(`/events/${id}`, payload);
|
||||
}
|
||||
|
||||
deleteEvent(id: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/events/${id}`);
|
||||
}
|
||||
|
||||
getEventSummary(id: number): Observable<ScopeDashboard> {
|
||||
return this.http.get<ScopeDashboard>(`/events/${id}/summary`);
|
||||
}
|
||||
|
||||
getEventWorks(id: number): Observable<readonly Work[]> {
|
||||
return this.http.get<readonly Work[]>(`/events/${id}/works`);
|
||||
}
|
||||
|
||||
/* ── Reference Sets ── */
|
||||
|
||||
listReferenceSets(): Observable<readonly ReferenceSet[]> {
|
||||
return this.http.get<readonly ReferenceSet[]>('/reference-sets');
|
||||
}
|
||||
|
||||
createReferenceSet(payload: { readonly name: string; readonly kind: string; readonly description?: string }): Observable<ReferenceSet> {
|
||||
return this.http.post<ReferenceSet>('/reference-sets', payload);
|
||||
}
|
||||
|
||||
getRefSet(id: number): Observable<ReferenceSet> {
|
||||
return this.http.get<ReferenceSet>(`/reference-sets/${id}`);
|
||||
}
|
||||
|
||||
updateRefSet(id: number, payload: EditRefSetRequest): Observable<ReferenceSet> {
|
||||
return this.http.patch<ReferenceSet>(`/reference-sets/${id}`, payload);
|
||||
}
|
||||
|
||||
deleteRefSet(id: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/reference-sets/${id}`);
|
||||
}
|
||||
|
||||
listIngestions(refSetId: number): Observable<readonly Ingestion[]> {
|
||||
return this.http.get<readonly Ingestion[]>(`/reference-sets/${refSetId}/ingestions`);
|
||||
}
|
||||
|
||||
createIngestion(refSetId: number, file: File): Observable<Ingestion> {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
return this.http.post<Ingestion>(`/reference-sets/${refSetId}/ingestions`, data);
|
||||
}
|
||||
|
||||
/* ── Users ── */
|
||||
|
||||
listUsers(): Observable<readonly UserInfo[]> {
|
||||
return this.http.get<readonly UserInfo[]>('/users');
|
||||
}
|
||||
|
||||
getUser(id: number): Observable<UserInfo> {
|
||||
return this.http.get<UserInfo>(`/users/${id}`);
|
||||
}
|
||||
|
||||
createUser(payload: CreateUserRequest): Observable<UserInfo> {
|
||||
return this.http.post<UserInfo>('/users', payload);
|
||||
}
|
||||
|
||||
/* ── Audit ── */
|
||||
|
||||
listAuditLogs(params?: {
|
||||
actor_user_id?: number;
|
||||
action?: string;
|
||||
resource_type?: string;
|
||||
resource_id?: string;
|
||||
source?: string;
|
||||
limit?: number;
|
||||
}): Observable<readonly AuditLog[]> {
|
||||
let httpParams = new HttpParams();
|
||||
if (params?.actor_user_id != null) httpParams = httpParams.set('actor_user_id', String(params.actor_user_id));
|
||||
if (params?.action) httpParams = httpParams.set('action', params.action);
|
||||
if (params?.resource_type) httpParams = httpParams.set('resource_type', params.resource_type);
|
||||
if (params?.resource_id) httpParams = httpParams.set('resource_id', params.resource_id);
|
||||
if (params?.source) httpParams = httpParams.set('source', params.source);
|
||||
if (params?.limit != null) httpParams = httpParams.set('limit', String(params.limit));
|
||||
return this.http.get<readonly AuditLog[]>('/audit-logs', { params: httpParams });
|
||||
}
|
||||
}
|
||||
25
src/app/core/works/analysis-run-status-chip-classes.pipe.ts
Normal file
25
src/app/core/works/analysis-run-status-chip-classes.pipe.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import type { AnalysisRunStatus } from '../models/api.types';
|
||||
|
||||
@Pipe({
|
||||
name: 'analysisRunStatusChipClasses',
|
||||
standalone: true,
|
||||
})
|
||||
export class AnalysisRunStatusChipClassesPipe implements PipeTransform {
|
||||
transform(status: AnalysisRunStatus | null | undefined): string {
|
||||
if (!status) {
|
||||
return '';
|
||||
}
|
||||
switch (status.trim().toLowerCase()) {
|
||||
case 'processing':
|
||||
return 'status-chip--active';
|
||||
case 'queued':
|
||||
return 'status-chip--pending';
|
||||
case 'failed':
|
||||
return 'status-chip--unknown';
|
||||
case 'completed':
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/app/core/works/analysis-run-status.pipe.ts
Normal file
21
src/app/core/works/analysis-run-status.pipe.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import type { AnalysisRunStatus } from '../models/api.types';
|
||||
|
||||
const TRANSLATIONS: Record<string, string> = {
|
||||
queued: 'В очереди',
|
||||
processing: 'Выполняется',
|
||||
completed: 'Завершена',
|
||||
failed: 'Ошибка',
|
||||
};
|
||||
|
||||
@Pipe({
|
||||
name: 'analysisRunStatus',
|
||||
standalone: true,
|
||||
})
|
||||
export class AnalysisRunStatusPipe implements PipeTransform {
|
||||
transform(status: AnalysisRunStatus | null | undefined): string {
|
||||
if (!status) return '—';
|
||||
const key = status.trim().toLowerCase();
|
||||
return TRANSLATIONS[key] || status;
|
||||
}
|
||||
}
|
||||
13
src/app/features/dashboard/dashboard.component.css
Normal file
13
src/app/features/dashboard/dashboard.component.css
Normal file
@@ -0,0 +1,13 @@
|
||||
/* Dashboard-specific overrides */
|
||||
|
||||
.create-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.create-field {
|
||||
flex: 1 1 160px;
|
||||
min-width: 0;
|
||||
}
|
||||
211
src/app/features/dashboard/dashboard.component.html
Normal file
211
src/app/features/dashboard/dashboard.component.html
Normal file
@@ -0,0 +1,211 @@
|
||||
<div class="page sg-content-column">
|
||||
<h2 tuiTitle="m" class="heading">Управление сущностями</h2>
|
||||
|
||||
<tui-tabs class="dash-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
|
||||
<button tuiTab type="button">Работы</button>
|
||||
<button tuiTab type="button">Мероприятия</button>
|
||||
<button tuiTab type="button">Студенты</button>
|
||||
<button tuiTab type="button">Группы</button>
|
||||
<button tuiTab type="button">Reference Sets</button>
|
||||
</tui-tabs>
|
||||
|
||||
@switch (activeTabIndex()) {
|
||||
@case (0) {
|
||||
<section class="card" aria-label="Работы">
|
||||
<h3 class="section-title">Новая работа</h3>
|
||||
<form class="create-row" (ngSubmit)="createWork()">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="number" placeholder="student_id" [ngModel]="createWorkStudentId()" (ngModelChange)="createWorkStudentId.set($event)" name="workStudentId" />
|
||||
</tui-textfield>
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="number" placeholder="event_id" [ngModel]="createWorkEventId()" (ngModelChange)="createWorkEventId.set($event)" name="workEventId" />
|
||||
</tui-textfield>
|
||||
<button tuiButton class="accent-cta" appearance="primary" type="submit" [disabled]="isSubmitting()">Создать</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@if (worksState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') {
|
||||
<div class="loading-wrap"><tui-loader [loading]="true" size="xl" /></div>
|
||||
}
|
||||
@case ('error') {
|
||||
<section class="card"><p class="muted">Ошибка загрузки работ.</p></section>
|
||||
}
|
||||
@case ('ok') {
|
||||
<section class="card" aria-label="Список работ">
|
||||
@if (state.items.length === 0) {
|
||||
<p class="muted">Работ пока нет.</p>
|
||||
} @else {
|
||||
<ul class="entity-list">
|
||||
@for (work of state.items; track work.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/works', work.id]" class="entity-link">
|
||||
Работа {{ work.id }}
|
||||
</a>
|
||||
<span tuiChip size="xs">{{ work.archive_object_key ? 'Архив есть' : 'Без архива' }}</span>
|
||||
<span class="muted meta">student={{ work.student_id }}, event={{ work.event_id }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@case (1) {
|
||||
@defer {
|
||||
<section class="card" aria-label="Ивенты">
|
||||
<h3 class="section-title">Новый ивент</h3>
|
||||
<form class="create-row" (ngSubmit)="createEvent()">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="Название" [ngModel]="createEventName()" (ngModelChange)="createEventName.set($event)" name="eventName" />
|
||||
</tui-textfield>
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="number" placeholder="group_id" [ngModel]="createEventGroupId()" (ngModelChange)="createEventGroupId.set($event)" name="eventGroupId" />
|
||||
</tui-textfield>
|
||||
<button tuiButton class="accent-cta" appearance="primary" type="submit" [disabled]="isSubmitting()">Создать</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@if (eventsState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') { <div class="loading-wrap"><tui-loader [loading]="true" size="m" /></div> }
|
||||
@case ('error') { <section class="card"><p class="muted">Ошибка загрузки ивентов.</p></section> }
|
||||
@case ('ok') {
|
||||
<section class="card">
|
||||
<ul class="entity-list">
|
||||
@for (event of state.items; track event.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/events', event.id]" class="entity-link">{{ event.name }}</a>
|
||||
<span tuiChip size="xs">#{{ event.id }}</span>
|
||||
<span class="muted meta">group={{ event.group_id }}, date={{ formatDate(event.date) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="loading-wrap"><tui-loader [loading]="true" size="l" /></div>
|
||||
}
|
||||
}
|
||||
|
||||
@case (2) {
|
||||
@defer {
|
||||
<section class="card" aria-label="Студенты">
|
||||
<h3 class="section-title">Новый студент</h3>
|
||||
<form class="create-row" (ngSubmit)="createStudent()">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="ФИО" [ngModel]="createStudentName()" (ngModelChange)="createStudentName.set($event)" name="studentName" />
|
||||
</tui-textfield>
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="email" placeholder="Email" [ngModel]="createStudentEmail()" (ngModelChange)="createStudentEmail.set($event)" name="studentEmail" />
|
||||
</tui-textfield>
|
||||
<button tuiButton class="accent-cta" appearance="primary" type="submit" [disabled]="isSubmitting()">Создать</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@if (studentsState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') { <div class="loading-wrap"><tui-loader [loading]="true" size="m" /></div> }
|
||||
@case ('error') { <section class="card"><p class="muted">Ошибка загрузки студентов.</p></section> }
|
||||
@case ('ok') {
|
||||
<section class="card">
|
||||
<ul class="entity-list">
|
||||
@for (student of state.items; track student.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/students', student.id]" class="entity-link">{{ student.name }}</a>
|
||||
<span tuiChip size="xs">#{{ student.id }}</span>
|
||||
<span class="muted meta">{{ student.email }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="loading-wrap"><tui-loader [loading]="true" size="l" /></div>
|
||||
}
|
||||
}
|
||||
|
||||
@case (3) {
|
||||
@defer {
|
||||
<section class="card" aria-label="Группы">
|
||||
<h3 class="section-title">Новая группа</h3>
|
||||
<form class="create-row" (ngSubmit)="createGroup()">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="Название группы" [ngModel]="createGroupName()" (ngModelChange)="createGroupName.set($event)" name="groupName" />
|
||||
</tui-textfield>
|
||||
<button tuiButton class="accent-cta" appearance="primary" type="submit" [disabled]="isSubmitting()">Создать</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@if (groupsState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') { <div class="loading-wrap"><tui-loader [loading]="true" size="m" /></div> }
|
||||
@case ('error') { <section class="card"><p class="muted">Ошибка загрузки групп.</p></section> }
|
||||
@case ('ok') {
|
||||
<section class="card">
|
||||
<ul class="entity-list">
|
||||
@for (group of state.items; track group.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/groups', group.id]" class="entity-link">{{ group.name }}</a>
|
||||
<span tuiChip size="xs">#{{ group.id }}</span>
|
||||
<span class="muted meta">students={{ group.students?.length ?? 0 }}, users={{ group.users?.length ?? 0 }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="loading-wrap"><tui-loader [loading]="true" size="l" /></div>
|
||||
}
|
||||
}
|
||||
|
||||
@case (4) {
|
||||
@defer {
|
||||
<section class="card" aria-label="Reference Sets">
|
||||
<h3 class="section-title">Новый reference set</h3>
|
||||
<form class="create-row" (ngSubmit)="createReferenceSet()">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="name" [ngModel]="createRefName()" (ngModelChange)="createRefName.set($event)" name="refName" />
|
||||
</tui-textfield>
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="kind" [ngModel]="createRefKind()" (ngModelChange)="createRefKind.set($event)" name="refKind" />
|
||||
</tui-textfield>
|
||||
<button tuiButton class="accent-cta" appearance="primary" type="submit" [disabled]="isSubmitting()">Создать</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@if (refsState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') { <div class="loading-wrap"><tui-loader [loading]="true" size="m" /></div> }
|
||||
@case ('error') { <section class="card"><p class="muted">Ошибка загрузки reference sets.</p></section> }
|
||||
@case ('ok') {
|
||||
<section class="card">
|
||||
<ul class="entity-list">
|
||||
@for (ref of state.items; track ref.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/reference-sets', ref.id]" class="entity-link">{{ ref.name }}</a>
|
||||
<span tuiChip size="xs">{{ ref.kind }}</span>
|
||||
<span class="muted meta">{{ ref.description ?? '—' }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="loading-wrap"><tui-loader [loading]="true" size="l" /></div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
216
src/app/features/dashboard/dashboard.component.ts
Normal file
216
src/app/features/dashboard/dashboard.component.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { catchError, map, Observable, of, startWith, switchMap } from 'rxjs';
|
||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiInputDirective } from '@taiga-ui/core/components/input';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
import { TuiLink } from '@taiga-ui/core/components/link';
|
||||
import { TuiTextfield } from '@taiga-ui/core/components/textfield';
|
||||
import { TuiTitle } from '@taiga-ui/core/components/title';
|
||||
import { TuiChip } from '@taiga-ui/kit/components/chip';
|
||||
import { TuiTabs } from '@taiga-ui/kit/components/tabs';
|
||||
import { UserErrorNotifyService } from '../../core/notifications/user-error-notify.service';
|
||||
import { WorksApiService } from '../../core/services/works-api.service';
|
||||
import { formatDateTime } from '../../shared/utils/date-time.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
TuiButton,
|
||||
TuiLink,
|
||||
...TuiTabs,
|
||||
TuiTextfield,
|
||||
TuiInputDirective,
|
||||
TuiLoader,
|
||||
TuiTitle,
|
||||
TuiChip,
|
||||
],
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrl: './dashboard.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DashboardComponent {
|
||||
private readonly api = inject(WorksApiService);
|
||||
private readonly userErrors = inject(UserErrorNotifyService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly activeTabIndex = signal(0);
|
||||
private readonly reloadTick = signal(0);
|
||||
|
||||
protected readonly createWorkStudentId = signal<number | null>(null);
|
||||
protected readonly createWorkEventId = signal<number | null>(null);
|
||||
|
||||
protected readonly createStudentName = signal('');
|
||||
protected readonly createStudentEmail = signal('');
|
||||
|
||||
protected readonly createGroupName = signal('');
|
||||
|
||||
protected readonly createEventName = signal('');
|
||||
protected readonly createEventGroupId = signal('');
|
||||
protected readonly createEventDescription = signal('');
|
||||
|
||||
protected readonly createRefName = signal('');
|
||||
protected readonly createRefKind = signal('template');
|
||||
protected readonly createRefDescription = signal('');
|
||||
|
||||
protected readonly isSubmitting = signal(false);
|
||||
|
||||
protected readonly worksState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.listWorks().pipe(
|
||||
map((items) => ({ status: 'ok' as const, items })),
|
||||
catchError((error: unknown) => {
|
||||
this.userErrors.notifyError(error, 'Не удалось загрузить работы');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly studentsState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.listStudents().pipe(
|
||||
map((items) => ({ status: 'ok' as const, items })),
|
||||
catchError((error: unknown) => {
|
||||
this.userErrors.notifyError(error, 'Не удалось загрузить студентов');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly groupsState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.listGroups().pipe(
|
||||
map((items) => ({ status: 'ok' as const, items })),
|
||||
catchError((error: unknown) => {
|
||||
this.userErrors.notifyError(error, 'Не удалось загрузить группы');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly eventsState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.listEvents().pipe(
|
||||
map((items) => ({ status: 'ok' as const, items })),
|
||||
catchError((error: unknown) => {
|
||||
this.userErrors.notifyError(error, 'Не удалось загрузить события');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly refsState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.listReferenceSets().pipe(
|
||||
map((items) => ({ status: 'ok' as const, items })),
|
||||
catchError((error: unknown) => {
|
||||
this.userErrors.notifyError(error, 'Не удалось загрузить reference sets');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly auditState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.listAuditLogs().pipe(
|
||||
map((items) => ({ status: 'ok' as const, items })),
|
||||
catchError((error: unknown) => {
|
||||
this.userErrors.notifyError(error, 'Не удалось загрузить аудит');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected refresh(): void {
|
||||
this.reloadTick.update((value) => value + 1);
|
||||
}
|
||||
|
||||
protected createWork(): void {
|
||||
const studentId = Number(this.createWorkStudentId());
|
||||
const eventId = Number(this.createWorkEventId());
|
||||
if (!Number.isInteger(studentId) || !Number.isInteger(eventId)) {
|
||||
this.userErrors.notifyError(new Error('Некорректный student_id или event_id'), 'Валидация');
|
||||
return;
|
||||
}
|
||||
this.submit(this.api.createWork({
|
||||
student_id: studentId,
|
||||
event_id: eventId,
|
||||
time: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
protected createStudent(): void {
|
||||
this.submit(
|
||||
this.api.createStudent({
|
||||
name: this.createStudentName().trim(),
|
||||
email: this.createStudentEmail().trim(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
protected createGroup(): void {
|
||||
this.submit(this.api.createGroup({ name: this.createGroupName().trim() }));
|
||||
}
|
||||
|
||||
protected createEvent(): void {
|
||||
const groupId = Number(this.createEventGroupId());
|
||||
if (!Number.isInteger(groupId)) {
|
||||
this.userErrors.notifyError(new Error('Некорректный group_id'), 'Валидация');
|
||||
return;
|
||||
}
|
||||
this.submit(
|
||||
this.api.createEvent({
|
||||
name: this.createEventName().trim(),
|
||||
description: this.createEventDescription().trim(),
|
||||
group_id: groupId,
|
||||
date: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
protected createReferenceSet(): void {
|
||||
this.submit(
|
||||
this.api.createReferenceSet({
|
||||
name: this.createRefName().trim(),
|
||||
kind: this.createRefKind().trim(),
|
||||
description: this.createRefDescription().trim(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private submit(request$: Observable<unknown>): void {
|
||||
this.isSubmitting.set(true);
|
||||
request$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: () => {
|
||||
this.isSubmitting.set(false);
|
||||
this.refresh();
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.isSubmitting.set(false);
|
||||
this.userErrors.notifyError(error, 'Ошибка сохранения');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected formatDate(value: string | null | undefined): string {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
}
|
||||
53
src/app/features/devtools/dev-console.component.ts
Normal file
53
src/app/features/devtools/dev-console.component.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, isDevMode, signal } from '@angular/core';
|
||||
|
||||
import { DevLogService } from '../../core/devtools/dev-log.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dev-console',
|
||||
imports: [DatePipe],
|
||||
templateUrl: './dev-console.html',
|
||||
styleUrl: './dev-console.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DevConsoleComponent {
|
||||
private readonly logs = inject(DevLogService);
|
||||
protected readonly isDev = isDevMode();
|
||||
protected readonly collapsed = signal(false);
|
||||
protected readonly minimized = signal(true);
|
||||
protected readonly entries = this.logs.entries;
|
||||
protected readonly count = computed(() => this.entries().length);
|
||||
protected readonly expandedIds = signal<Record<number, boolean>>({});
|
||||
|
||||
constructor() {
|
||||
if (!this.isDev || typeof window === 'undefined') return;
|
||||
|
||||
window.addEventListener('error', (e) => {
|
||||
this.logs.add({ level: 'error', source: 'system', message: `${e.message} (${e.filename}:${e.lineno})` });
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||
const reason = typeof e.reason === 'string' ? e.reason : e.reason instanceof Error ? e.reason.message : 'Unhandled promise rejection';
|
||||
this.logs.add({ level: 'warn', source: 'system', message: reason });
|
||||
});
|
||||
}
|
||||
|
||||
protected toggleCollapsed(): void { this.collapsed.update((v) => !v); }
|
||||
protected minimize(): void { this.minimized.set(true); this.collapsed.set(false); }
|
||||
protected restore(): void { this.minimized.set(false); }
|
||||
protected clear(): void { this.logs.clear(); this.expandedIds.set({}); }
|
||||
|
||||
protected toggleExpanded(id: number): void {
|
||||
this.expandedIds.update((curr) => ({ ...curr, [id]: !curr[id] }));
|
||||
}
|
||||
|
||||
protected isExpanded(id: number): boolean {
|
||||
return !!this.expandedIds()[id];
|
||||
}
|
||||
|
||||
protected pretty(value: unknown): string {
|
||||
if (value === undefined) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
try { return JSON.stringify(value, null, 2); } catch { return String(value); }
|
||||
}
|
||||
}
|
||||
125
src/app/features/devtools/dev-console.css
Normal file
125
src/app/features/devtools/dev-console.css
Normal file
@@ -0,0 +1,125 @@
|
||||
:host {
|
||||
--dc-bg: color-mix(in srgb, var(--sg-color-text) 96%, var(--sg-color-bg));
|
||||
--dc-bg-header: color-mix(in srgb, var(--sg-color-text) 90%, var(--sg-color-bg));
|
||||
--dc-fg: var(--sg-color-form-bg);
|
||||
--dc-fg-muted: color-mix(in srgb, var(--dc-fg) 65%, transparent);
|
||||
--dc-fg-secondary: color-mix(in srgb, var(--dc-fg) 80%, transparent);
|
||||
--dc-border: rgb(255 255 255 / 8%);
|
||||
--dc-border-btn: rgb(255 255 255 / 18%);
|
||||
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dev-console-mini {
|
||||
border: 1px solid var(--tui-border-normal);
|
||||
border-radius: 999px;
|
||||
background: var(--dc-bg-header);
|
||||
color: var(--dc-fg);
|
||||
padding: 0.45rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
|
||||
}
|
||||
|
||||
.dev-console {
|
||||
width: min(760px, calc(100vw - 2rem));
|
||||
max-height: min(46vh, 380px);
|
||||
border: 1px solid var(--tui-border-normal);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--dc-bg);
|
||||
color: var(--dc-fg);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dev-console_collapsed { max-height: 2.5rem; }
|
||||
|
||||
.dev-console__header {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.55rem;
|
||||
background: var(--dc-bg-header);
|
||||
border-bottom: 1px solid var(--dc-border);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.dev-console__count { margin-right: auto; color: var(--dc-fg-muted); }
|
||||
|
||||
.dev-console__header button {
|
||||
border: 1px solid var(--dc-border-btn);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
padding: 0.35rem 0.45rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dev-console__list {
|
||||
overflow: auto;
|
||||
max-height: calc(min(46vh, 380px) - 2.5rem);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.dev-console__entry {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem 3.2rem 1fr;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 6%);
|
||||
}
|
||||
|
||||
.dev-console__main { min-width: 0; }
|
||||
|
||||
.dev-console__entry[data-level='warn'] { background: rgb(255 199 0 / 8%); }
|
||||
.dev-console__entry[data-level='error'] { background: rgb(217 45 32 / 12%); }
|
||||
|
||||
.dev-console__time { color: var(--dc-fg-muted); }
|
||||
.dev-console__source { color: var(--dc-fg-secondary); text-transform: uppercase; }
|
||||
.dev-console__message { word-break: break-word; }
|
||||
|
||||
.dev-console__expand {
|
||||
margin-top: 0.25rem;
|
||||
border: 1px solid var(--dc-border-btn);
|
||||
background: transparent;
|
||||
color: var(--dc-fg-secondary);
|
||||
border-radius: 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.2;
|
||||
padding: 0.2rem 0.35rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dev-console__details {
|
||||
margin-top: 0.35rem;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
border-radius: 0.4rem;
|
||||
background: rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.dev-console__meta {
|
||||
margin-bottom: 0.35rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.dev-console__details details { margin: 0.35rem 0; }
|
||||
.dev-console__details summary { cursor: pointer; color: var(--dc-fg-secondary); }
|
||||
|
||||
.dev-console__details pre {
|
||||
margin: 0.35rem 0 0;
|
||||
padding: 0.35rem;
|
||||
border-radius: 0.35rem;
|
||||
background: rgb(255 255 255 / 4%);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
79
src/app/features/devtools/dev-console.html
Normal file
79
src/app/features/devtools/dev-console.html
Normal file
@@ -0,0 +1,79 @@
|
||||
@if (isDev) {
|
||||
@if (minimized()) {
|
||||
<button type="button" class="dev-console-mini" (click)="restore()">
|
||||
Dev ({{ count() }})
|
||||
</button>
|
||||
} @else {
|
||||
<aside class="dev-console" [class.dev-console_collapsed]="collapsed()">
|
||||
<header class="dev-console__header">
|
||||
<strong>Dev Console</strong>
|
||||
<span class="dev-console__count">{{ count() }}</span>
|
||||
<button type="button" (click)="toggleCollapsed()">
|
||||
{{ collapsed() ? 'Развернуть' : 'Свернуть' }}
|
||||
</button>
|
||||
<button type="button" (click)="clear()">Очистить</button>
|
||||
<button type="button" (click)="minimize()">Скрыть</button>
|
||||
</header>
|
||||
|
||||
@if (!collapsed()) {
|
||||
<div class="dev-console__list">
|
||||
@for (entry of entries(); track entry.id) {
|
||||
<div class="dev-console__entry" [attr.data-level]="entry.level">
|
||||
<span class="dev-console__time">
|
||||
{{ entry.time | date: 'HH:mm:ss.SSS' }}
|
||||
</span>
|
||||
<span class="dev-console__source">{{ entry.source }}</span>
|
||||
<div class="dev-console__main">
|
||||
<span class="dev-console__message">{{ entry.message }}</span>
|
||||
|
||||
@if (entry.source === 'http' && entry.details) {
|
||||
<button
|
||||
type="button"
|
||||
class="dev-console__expand"
|
||||
(click)="toggleExpanded(entry.id)"
|
||||
>
|
||||
{{ isExpanded(entry.id) ? 'Скрыть детали' : 'Показать детали' }}
|
||||
</button>
|
||||
|
||||
@if (isExpanded(entry.id)) {
|
||||
<div class="dev-console__details">
|
||||
<div class="dev-console__meta">
|
||||
<span><b>Метод:</b> {{ entry.details.method }}</span>
|
||||
<span><b>Статус:</b> {{ entry.details.statusCode ?? 'ожидание' }}</span>
|
||||
<span><b>Длительность:</b> {{ entry.details.durationMs ?? '—' }} мс</span>
|
||||
</div>
|
||||
<div class="dev-console__meta"><b>URL:</b> {{ entry.details.url }}</div>
|
||||
|
||||
<details open>
|
||||
<summary>Заголовки запроса</summary>
|
||||
<pre>{{ pretty(entry.details.requestHeaders) }}</pre>
|
||||
</details>
|
||||
<details open>
|
||||
<summary>Тело запроса</summary>
|
||||
<pre>{{ pretty(entry.details.requestBody) }}</pre>
|
||||
</details>
|
||||
<details open>
|
||||
<summary>Заголовки ответа</summary>
|
||||
<pre>{{ pretty(entry.details.responseHeaders) }}</pre>
|
||||
</details>
|
||||
<details open>
|
||||
<summary>Тело ответа</summary>
|
||||
<pre>{{ pretty(entry.details.responseBody) }}</pre>
|
||||
</details>
|
||||
@if (entry.details.error) {
|
||||
<details open>
|
||||
<summary>Ошибка</summary>
|
||||
<pre>{{ entry.details.error }}</pre>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</aside>
|
||||
}
|
||||
}
|
||||
10
src/app/features/events/event-detail.component.css
Normal file
10
src/app/features/events/event-detail.component.css
Normal file
@@ -0,0 +1,10 @@
|
||||
/* Event-detail-specific: only edit-form overrides remain */
|
||||
.edit-form {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.edit-field {
|
||||
min-width: 0;
|
||||
}
|
||||
134
src/app/features/events/event-detail.component.html
Normal file
134
src/app/features/events/event-detail.component.html
Normal file
@@ -0,0 +1,134 @@
|
||||
<div class="page sg-content-column">
|
||||
<nav class="back">
|
||||
<a tuiLink routerLink="/dashboard">← К панелям данных</a>
|
||||
</nav>
|
||||
|
||||
@if (eventState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') {
|
||||
<div class="loading-wrap"><tui-loader [loading]="true" size="xl" /></div>
|
||||
}
|
||||
@case ('error') {
|
||||
<p class="muted">Не удалось загрузить мероприятие.</p>
|
||||
}
|
||||
@case ('ok') {
|
||||
<h2 tuiTitle="m" class="heading">{{ state.event.name }}</h2>
|
||||
|
||||
<tui-tabs class="detail-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
|
||||
<button tuiTab type="button">Обзор</button>
|
||||
<button tuiTab type="button">Работы</button>
|
||||
<button tuiTab type="button">Аналитика</button>
|
||||
<button tuiTab type="button">Управление</button>
|
||||
</tui-tabs>
|
||||
|
||||
@switch (activeTabIndex()) {
|
||||
@case (0) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Сведения о мероприятии</h3>
|
||||
<dl class="kv">
|
||||
<dt>ID</dt>
|
||||
<dd>{{ state.event.id }}</dd>
|
||||
<dt>Название</dt>
|
||||
<dd>{{ state.event.name }}</dd>
|
||||
<dt>Описание</dt>
|
||||
<dd>{{ state.event.description ?? '—' }}</dd>
|
||||
<dt>Группа</dt>
|
||||
<dd>{{ state.event.group_id }}</dd>
|
||||
<dt class="muted">Дата загрузки</dt>
|
||||
<dd><code class="mono">{{ formatDate(state.event.date) }}</code></dd>
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (1) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Работы мероприятия</h3>
|
||||
@if (worksState$ | async; as ws) {
|
||||
@switch (ws.status) {
|
||||
@case ('loading') { <tui-loader [loading]="true" size="m" /> }
|
||||
@case ('error') { <p class="muted">Ошибка загрузки.</p> }
|
||||
@case ('ok') {
|
||||
@if (ws.works.length === 0) {
|
||||
<p class="muted">Работ нет.</p>
|
||||
} @else {
|
||||
<ul class="entity-list">
|
||||
@for (w of ws.works; track w.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/works', w.id]">Работа {{ w.id }}</a>
|
||||
<span class="muted meta">student={{ w.student_id }}, {{ formatDate(w.time) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (2) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Дашборд мероприятия</h3>
|
||||
@if (summaryState$ | async; as ss) {
|
||||
@switch (ss.status) {
|
||||
@case ('loading') { <tui-loader [loading]="true" size="m" /> }
|
||||
@case ('error') { <p class="muted">Ошибка загрузки дашборда.</p> }
|
||||
@case ('ok') {
|
||||
@if (ss.dashboard.presentation_summary; as m) {
|
||||
<div class="config-grid">
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Всего работ</span><span class="cfg-value">{{ m.works_total ?? 0 }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Проверено</span><span class="cfg-value">{{ m.works_checked ?? 0 }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Plagiarism rate</span><span class="cfg-value">{{ m.plagiarism_rate ?? '—' }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Risk</span><span class="cfg-value">{{ m.risk_level ?? '—' }}</span></div></div>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="muted">Метрики недоступны.</p>
|
||||
}
|
||||
|
||||
@if (ss.dashboard.works; as cards) {
|
||||
@if (cards.length > 0) {
|
||||
<h4 class="section-title" style="margin-top:1.5rem">Карточки работ</h4>
|
||||
<ul class="entity-list">
|
||||
@for (c of cards; track c.work_id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/works', c.work_id]">Работа {{ c.work_id }}</a>
|
||||
<span tuiChip size="xs">{{ c.risk_level ?? '—' }}</span>
|
||||
<span class="muted meta">{{ c.student_name ?? '—' }}, score={{ c.trust_score ?? '—' }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (3) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Редактирование</h3>
|
||||
<form class="edit-form" (ngSubmit)="saveEvent()">
|
||||
<tui-textfield class="edit-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="Название" [ngModel]="editName()" (ngModelChange)="editName.set($event)" name="name" />
|
||||
</tui-textfield>
|
||||
<tui-textfield class="edit-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="Описание" [ngModel]="editDescription()" (ngModelChange)="editDescription.set($event)" name="description" />
|
||||
</tui-textfield>
|
||||
<tui-textfield class="edit-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="Дата (RFC3339)" [ngModel]="editDate()" (ngModelChange)="editDate.set($event)" name="date" />
|
||||
</tui-textfield>
|
||||
<div class="action-row">
|
||||
<button tuiButton class="accent-cta" appearance="primary" type="submit" [disabled]="isSaving()">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="danger-zone">
|
||||
<button tuiLink type="button" class="danger-link" (click)="deleteEvent()" [disabled]="isDeleting()">Удалить мероприятие</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
124
src/app/features/events/event-detail.component.ts
Normal file
124
src/app/features/events/event-detail.component.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { Router } from '@angular/router';
|
||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { catchError, map, of, startWith, switchMap, tap } from 'rxjs';
|
||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiLink } from '@taiga-ui/core/components/link';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
import { TuiTitle } from '@taiga-ui/core/components/title';
|
||||
import { TuiInputDirective } from '@taiga-ui/core/components/input';
|
||||
import { TuiTextfield } from '@taiga-ui/core/components/textfield';
|
||||
import { TuiTabs } from '@taiga-ui/kit/components/tabs';
|
||||
import { TuiChip } from '@taiga-ui/kit/components/chip';
|
||||
import { UserErrorNotifyService } from '../../core/notifications/user-error-notify.service';
|
||||
import { WorksApiService } from '../../core/services/works-api.service';
|
||||
import { formatDateTime } from '../../shared/utils/date-time.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-event-detail',
|
||||
imports: [AsyncPipe, RouterLink, FormsModule, TuiButton, TuiLink, TuiLoader, TuiTitle, TuiTabs, TuiChip, TuiTextfield, TuiInputDirective],
|
||||
templateUrl: './event-detail.component.html',
|
||||
styleUrl: './event-detail.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EventDetailComponent {
|
||||
private readonly api = inject(WorksApiService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly userErrors = inject(UserErrorNotifyService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly eventId = signal(Number(this.route.snapshot.paramMap.get('id')));
|
||||
private readonly reloadTick = signal(0);
|
||||
protected readonly activeTabIndex = signal(0);
|
||||
protected readonly isDeleting = signal(false);
|
||||
protected readonly isSaving = signal(false);
|
||||
|
||||
protected readonly editName = signal('');
|
||||
protected readonly editDescription = signal('');
|
||||
protected readonly editDate = signal('');
|
||||
|
||||
protected readonly eventState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.getEvent(this.eventId()).pipe(
|
||||
tap((event) => {
|
||||
this.editName.set(event.name);
|
||||
this.editDescription.set(event.description ?? '');
|
||||
this.editDate.set(event.date);
|
||||
}),
|
||||
map((event) => ({ status: 'ok' as const, event })),
|
||||
catchError((e: unknown) => {
|
||||
this.userErrors.notifyError(e, 'Ошибка загрузки');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly worksState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.getEventWorks(this.eventId()).pipe(
|
||||
map((works) => ({ status: 'ok' as const, works })),
|
||||
catchError((e: unknown) => {
|
||||
this.userErrors.notifyError(e, 'Ошибка загрузки работ');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly summaryState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.getEventSummary(this.eventId()).pipe(
|
||||
map((dashboard) => ({ status: 'ok' as const, dashboard })),
|
||||
catchError(() => of({ status: 'error' as const })),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected reload(): void {
|
||||
this.reloadTick.update((v) => v + 1);
|
||||
}
|
||||
|
||||
protected saveEvent(): void {
|
||||
this.isSaving.set(true);
|
||||
this.api.updateEvent(this.eventId(), {
|
||||
name: this.editName().trim(),
|
||||
description: this.editDescription().trim(),
|
||||
date: this.editDate().trim(),
|
||||
}).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: () => {
|
||||
this.isSaving.set(false);
|
||||
this.reload();
|
||||
},
|
||||
error: (e: unknown) => {
|
||||
this.isSaving.set(false);
|
||||
this.userErrors.notifyError(e, 'Ошибка обновления');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected deleteEvent(): void {
|
||||
this.isDeleting.set(true);
|
||||
this.api.deleteEvent(this.eventId()).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: () => {
|
||||
this.isDeleting.set(false);
|
||||
this.router.navigateByUrl('/dashboard');
|
||||
},
|
||||
error: (e: unknown) => {
|
||||
this.isDeleting.set(false);
|
||||
this.userErrors.notifyError(e, 'Ошибка удаления');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected formatDate(value: string | null | undefined): string {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
}
|
||||
12
src/app/features/groups/group-detail.component.css
Normal file
12
src/app/features/groups/group-detail.component.css
Normal file
@@ -0,0 +1,12 @@
|
||||
/* Group-detail-specific */
|
||||
.create-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.create-field {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
116
src/app/features/groups/group-detail.component.html
Normal file
116
src/app/features/groups/group-detail.component.html
Normal file
@@ -0,0 +1,116 @@
|
||||
<div class="page sg-content-column">
|
||||
<nav class="back"><a tuiLink routerLink="/dashboard">← К панелям данных</a></nav>
|
||||
|
||||
@if (groupState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') { <div class="loading-wrap"><tui-loader [loading]="true" size="xl" /></div> }
|
||||
@case ('error') { <p class="muted">Не удалось загрузить группу.</p> }
|
||||
@case ('ok') {
|
||||
<h2 tuiTitle="m" class="heading">{{ state.group.name }}</h2>
|
||||
|
||||
<tui-tabs class="detail-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
|
||||
<button tuiTab type="button">Обзор</button>
|
||||
<button tuiTab type="button">Участники</button>
|
||||
<button tuiTab type="button">Аналитика</button>
|
||||
<button tuiTab type="button">Управление</button>
|
||||
</tui-tabs>
|
||||
|
||||
@switch (activeTabIndex()) {
|
||||
@case (0) {
|
||||
<section class="card">
|
||||
<dl class="kv">
|
||||
<dt>ID</dt><dd>{{ state.group.id }}</dd>
|
||||
<dt>Название</dt><dd>{{ state.group.name }}</dd>
|
||||
<dt>Студентов</dt><dd>{{ state.group.students?.length ?? 0 }}</dd>
|
||||
<dt>Преподавателей</dt><dd>{{ state.group.users?.length ?? 0 }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (1) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Студенты</h3>
|
||||
@if (state.group.students?.length) {
|
||||
<ul class="entity-list">
|
||||
@for (sid of state.group.students; track sid) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/students', sid]">Студент #{{ sid }}</a>
|
||||
<button tuiLink type="button" class="danger-link meta" (click)="removeStudent(sid)">Убрать</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="muted">Нет студентов.</p>
|
||||
}
|
||||
<form class="create-row" (ngSubmit)="addStudent()" style="margin-top:1rem">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="number" placeholder="student_id" [ngModel]="addStudentId()" (ngModelChange)="addStudentId.set($event)" name="sid" />
|
||||
</tui-textfield>
|
||||
<button tuiButton class="accent-cta" appearance="primary" size="s" type="submit">Добавить</button>
|
||||
</form>
|
||||
|
||||
<h3 class="section-title" style="margin-top:1.5rem">Преподаватели</h3>
|
||||
@if (state.group.users?.length) {
|
||||
<ul class="entity-list">
|
||||
@for (uid of state.group.users; track uid) {
|
||||
<li class="entity-row">
|
||||
<span>Пользователь #{{ uid }}</span>
|
||||
<button tuiLink type="button" class="danger-link meta" (click)="removeUser(uid)">Убрать</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="muted">Нет преподавателей.</p>
|
||||
}
|
||||
<form class="create-row" (ngSubmit)="addUser()" style="margin-top:1rem">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="number" placeholder="user_id" [ngModel]="addUserId()" (ngModelChange)="addUserId.set($event)" name="uid" />
|
||||
</tui-textfield>
|
||||
<button tuiButton class="accent-cta" appearance="primary" size="s" type="submit">Добавить</button>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (2) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Дашборд группы</h3>
|
||||
@if (statsState$ | async; as ss) {
|
||||
@switch (ss.status) {
|
||||
@case ('loading') { <tui-loader [loading]="true" size="m" /> }
|
||||
@case ('error') { <p class="muted">Ошибка загрузки.</p> }
|
||||
@case ('ok') {
|
||||
@if (ss.dashboard.presentation_summary; as m) {
|
||||
<div class="config-grid">
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Всего работ</span><span class="cfg-value">{{ m.works_total ?? 0 }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Проверено</span><span class="cfg-value">{{ m.works_checked ?? 0 }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Plagiarism rate</span><span class="cfg-value">{{ m.plagiarism_rate ?? '—' }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Risk</span><span class="cfg-value">{{ m.risk_level ?? '—' }}</span></div></div>
|
||||
</div>
|
||||
} @else { <p class="muted">Метрики недоступны.</p> }
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (3) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Редактирование</h3>
|
||||
<form class="edit-form" (ngSubmit)="saveGroup()">
|
||||
<tui-textfield class="edit-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="Название" [ngModel]="editName()" (ngModelChange)="editName.set($event)" name="name" />
|
||||
</tui-textfield>
|
||||
<div class="action-row">
|
||||
<button tuiButton class="accent-cta" appearance="primary" type="submit" [disabled]="isSaving()">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="danger-zone">
|
||||
<button tuiLink type="button" class="danger-link" (click)="deleteGroup()" [disabled]="isDeleting()">Удалить группу</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
137
src/app/features/groups/group-detail.component.ts
Normal file
137
src/app/features/groups/group-detail.component.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { catchError, map, of, startWith, switchMap, tap } from 'rxjs';
|
||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiLink } from '@taiga-ui/core/components/link';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
import { TuiTitle } from '@taiga-ui/core/components/title';
|
||||
import { TuiInputDirective } from '@taiga-ui/core/components/input';
|
||||
import { TuiTextfield } from '@taiga-ui/core/components/textfield';
|
||||
import { TuiTabs } from '@taiga-ui/kit/components/tabs';
|
||||
import { TuiChip } from '@taiga-ui/kit/components/chip';
|
||||
import { UserErrorNotifyService } from '../../core/notifications/user-error-notify.service';
|
||||
import { WorksApiService } from '../../core/services/works-api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-group-detail',
|
||||
imports: [AsyncPipe, RouterLink, FormsModule, TuiButton, TuiLink, TuiLoader, TuiTitle, TuiTabs, TuiTextfield, TuiInputDirective],
|
||||
templateUrl: './group-detail.component.html',
|
||||
styleUrl: './group-detail.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class GroupDetailComponent {
|
||||
private readonly api = inject(WorksApiService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly userErrors = inject(UserErrorNotifyService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly groupId = signal(Number(this.route.snapshot.paramMap.get('id')));
|
||||
private readonly reloadTick = signal(0);
|
||||
protected readonly activeTabIndex = signal(0);
|
||||
protected readonly isDeleting = signal(false);
|
||||
protected readonly isSaving = signal(false);
|
||||
|
||||
protected readonly editName = signal('');
|
||||
protected readonly addStudentId = signal('');
|
||||
protected readonly addUserId = signal('');
|
||||
|
||||
protected readonly groupState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.getGroup(this.groupId()).pipe(
|
||||
tap((group) => {
|
||||
this.editName.set(group.name);
|
||||
}),
|
||||
map((group) => ({ status: 'ok' as const, group })),
|
||||
catchError((e: unknown) => {
|
||||
this.userErrors.notifyError(e, 'Ошибка загрузки');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly statsState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.getGroupStats(this.groupId()).pipe(
|
||||
map((dashboard) => ({ status: 'ok' as const, dashboard })),
|
||||
catchError(() => of({ status: 'error' as const })),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected reload(): void {
|
||||
this.reloadTick.update((v) => v + 1);
|
||||
}
|
||||
|
||||
protected saveGroup(): void {
|
||||
this.isSaving.set(true);
|
||||
this.api.updateGroup(this.groupId(), { name: this.editName().trim() })
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => { this.isSaving.set(false); this.reload(); },
|
||||
error: (e: unknown) => { this.isSaving.set(false); this.userErrors.notifyError(e, 'Ошибка сохранения'); },
|
||||
});
|
||||
}
|
||||
|
||||
protected deleteGroup(): void {
|
||||
this.isDeleting.set(true);
|
||||
this.api.deleteGroup(this.groupId())
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => { this.isDeleting.set(false); this.router.navigateByUrl('/dashboard'); },
|
||||
error: (e: unknown) => { this.isDeleting.set(false); this.userErrors.notifyError(e, 'Ошибка удаления'); },
|
||||
});
|
||||
}
|
||||
|
||||
protected addStudent(): void {
|
||||
const sid = Number(this.addStudentId());
|
||||
if (!Number.isInteger(sid)) {
|
||||
this.userErrors.notifyError(new Error('Некорректный student_id'), 'Валидация');
|
||||
return;
|
||||
}
|
||||
this.api.addStudentToGroup(this.groupId(), sid)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => { this.addStudentId.set(''); this.reload(); },
|
||||
error: (e: unknown) => { this.userErrors.notifyError(e, 'Ошибка добавления студента'); },
|
||||
});
|
||||
}
|
||||
|
||||
protected removeStudent(sid: number): void {
|
||||
this.api.removeStudentFromGroup(this.groupId(), sid)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => this.reload(),
|
||||
error: (e: unknown) => { this.userErrors.notifyError(e, 'Ошибка удаления студента'); },
|
||||
});
|
||||
}
|
||||
|
||||
protected addUser(): void {
|
||||
const uid = Number(this.addUserId());
|
||||
if (!Number.isInteger(uid)) {
|
||||
this.userErrors.notifyError(new Error('Некорректный user_id'), 'Валидация');
|
||||
return;
|
||||
}
|
||||
this.api.addUserToGroup(this.groupId(), uid)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => { this.addUserId.set(''); this.reload(); },
|
||||
error: (e: unknown) => { this.userErrors.notifyError(e, 'Ошибка добавления пользователя'); },
|
||||
});
|
||||
}
|
||||
|
||||
protected removeUser(uid: number): void {
|
||||
this.api.removeUserFromGroup(this.groupId(), uid)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => this.reload(),
|
||||
error: (e: unknown) => { this.userErrors.notifyError(e, 'Ошибка удаления пользователя'); },
|
||||
});
|
||||
}
|
||||
}
|
||||
334
src/app/features/landing/landing.component.css
Normal file
334
src/app/features/landing/landing.component.css
Normal file
@@ -0,0 +1,334 @@
|
||||
.ap-landing {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rem;
|
||||
color: var(--sg-color-text);
|
||||
font-family: var(--tui-font-text);
|
||||
}
|
||||
|
||||
/* ================= HERO SECTION ================= */
|
||||
.ap-hero {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6rem 0 4rem;
|
||||
animation: fadeIn 1s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.ap-hero__badge {
|
||||
display: inline-block;
|
||||
background: var(--tui-background-accent-1);
|
||||
color: #fff;
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 20px;
|
||||
font: var(--tui-typography-body-s);
|
||||
font-weight: 500;
|
||||
margin-bottom: 2rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ap-hero__title {
|
||||
font: var(--tui-typography-heading-h1);
|
||||
font-size: clamp(3rem, 5vw, 4.5rem);
|
||||
line-height: 1.1;
|
||||
margin: 0 0 1.5rem;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: var(--tui-text-brand);
|
||||
}
|
||||
|
||||
.ap-hero__subtitle {
|
||||
font: var(--tui-typography-body-l);
|
||||
font-size: clamp(1.1rem, 2vw, 1.3rem);
|
||||
color: var(--tui-text-secondary);
|
||||
max-width: 700px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 3rem;
|
||||
}
|
||||
|
||||
.ap-hero__actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ap-btn {
|
||||
border-radius: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 0 2rem !important;
|
||||
}
|
||||
|
||||
.ap-btn--yellow {
|
||||
background: #fdd835 !important;
|
||||
color: #1a1a1a !important;
|
||||
border: none !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
/* ================= BENTO BOX ================= */
|
||||
.ap-bento {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ap-bento__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-auto-rows: 240px;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.ap-bento-card {
|
||||
background: var(--sg-color-form-bg);
|
||||
border-radius: 24px;
|
||||
padding: 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.ap-bento-card:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.ap-bento-card--large {
|
||||
grid-column: span 2;
|
||||
grid-row: span 2;
|
||||
background: var(--tui-background-base);
|
||||
border: 1px solid var(--tui-border-normal);
|
||||
}
|
||||
|
||||
.ap-bento-card--wide {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.ap-bento-card__content h3 {
|
||||
font: var(--tui-typography-heading-h3);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.ap-bento-card__content h4 {
|
||||
font: var(--tui-typography-heading-h5);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.ap-bento-card__content p {
|
||||
font: var(--tui-typography-body-m);
|
||||
color: var(--tui-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ap-bento-card__visual--graph {
|
||||
margin-top: 2rem;
|
||||
color: var(--tui-text-brand);
|
||||
}
|
||||
|
||||
.ap-graph {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ap-bento-card__layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ap-bento-card__icon-huge {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
color: var(--sg-color-text);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ================= METRICS ROW ================= */
|
||||
.ap-metrics {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--tui-background-base);
|
||||
border: 1px solid var(--tui-border-normal);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.ap-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ap-metric__value {
|
||||
font: var(--tui-typography-heading-h1);
|
||||
font-size: 4rem;
|
||||
letter-spacing: -0.04em;
|
||||
background: linear-gradient(135deg, var(--sg-color-text), var(--tui-text-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.ap-metric__label {
|
||||
font: var(--tui-typography-body-m);
|
||||
color: var(--tui-text-tertiary);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ================= PIPELINE ALIGNMENT ================= */
|
||||
.ap-pipeline {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ap-heading-secondary {
|
||||
font: var(--tui-typography-heading-h2);
|
||||
margin: 0 0 4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ap-pipeline__track {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border-left: 2px dashed var(--tui-border-normal);
|
||||
padding-left: 3rem;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.ap-pipeline__node {
|
||||
position: relative;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.ap-pipeline__node:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.node-dot {
|
||||
position: absolute;
|
||||
left: -3.52rem;
|
||||
top: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: var(--tui-text-brand);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 6px var(--tui-background-base);
|
||||
}
|
||||
|
||||
.node-info h5 {
|
||||
font: var(--tui-typography-heading-h4);
|
||||
margin: -0.3rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.node-info p {
|
||||
font: var(--tui-typography-body-l);
|
||||
color: var(--tui-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ================= FAQ SECTION ================= */
|
||||
.faq {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3rem;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.faq__list {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
background: var(--tui-background-base);
|
||||
border-radius: 1.5rem;
|
||||
padding: 1rem 2rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:host ::ng-deep .faq__list [tuiAccordion] {
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .faq__list tui-expand {
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.faq__button {
|
||||
font: var(--tui-typography-body-l);
|
||||
font-weight: 500;
|
||||
color: var(--sg-color-text);
|
||||
border: none;
|
||||
padding: 1.5rem 0;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.faq__button + .faq__button {
|
||||
border-top: 1px solid var(--tui-border-normal);
|
||||
}
|
||||
|
||||
.faq__content {
|
||||
font: var(--tui-typography-body-m);
|
||||
color: var(--tui-text-tertiary);
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ================= FOOTER ================= */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--tui-border-normal);
|
||||
}
|
||||
|
||||
.footer__text {
|
||||
font: var(--tui-typography-body-s);
|
||||
color: var(--sg-color-placeholder);
|
||||
}
|
||||
|
||||
/* ================= RESPONSIVE ================= */
|
||||
@media (max-width: 1024px) {
|
||||
.ap-bento__grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
|
||||
.ap-bento-card--large, .ap-bento-card--wide {
|
||||
grid-column: span 1;
|
||||
grid-row: span 1;
|
||||
}
|
||||
|
||||
.ap-metrics {
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
162
src/app/features/landing/landing.component.html
Normal file
162
src/app/features/landing/landing.component.html
Normal file
@@ -0,0 +1,162 @@
|
||||
<div class="ap-landing">
|
||||
|
||||
<!-- HERO SECTION -->
|
||||
<header class="ap-hero">
|
||||
<h1 class="ap-hero__title">
|
||||
Исключительная <br><span class="highlight">Точность</span> Проверок
|
||||
</h1>
|
||||
<p class="ap-hero__subtitle">
|
||||
SparkGuardian Antiplagiat использует глубокий разбор синтаксических деревьев и алгоритм шинглов. Мы находим заимствования, даже если код был переписан.
|
||||
</p>
|
||||
<div class="ap-hero__actions">
|
||||
<a tuiButton size="l" routerLink="/dashboard" class="ap-btn ap-btn--yellow">Вход в систему</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- BENTO BOX FEATURES -->
|
||||
<section class="ap-bento">
|
||||
<div class="ap-bento__grid">
|
||||
|
||||
<!-- Big Card -->
|
||||
<div class="ap-bento-card ap-bento-card--large">
|
||||
<div class="ap-bento-card__content">
|
||||
<h3>Алгоритмический анализ (Shingling)</h3>
|
||||
<p>Система не полагается на текстовые совпадения. Весь исходный код токенизируется, очищается от комментариев, а N-граммы сравниваются с использованием алгоритма Jaccard.</p>
|
||||
</div>
|
||||
<div class="ap-bento-card__visual ap-bento-card__visual--graph">
|
||||
<svg viewBox="0 0 100 50" class="ap-graph" style="overflow: visible;">
|
||||
<path d="M0,45 Q25,10 50,30 T100,5" fill="none" stroke="currentColor" stroke-width="4"/>
|
||||
<circle cx="50" cy="30" r="5" fill="currentColor"/>
|
||||
<circle cx="100" cy="5" r="5" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Card 1 -->
|
||||
<div class="ap-bento-card">
|
||||
<div class="ap-bento-card__content">
|
||||
<h4>Reference Sets</h4>
|
||||
<p>Отфильтровывайте заранее выданный студентам шаблонный код. Система сама вычтет эталонные токены.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Card 2 -->
|
||||
<div class="ap-bento-card">
|
||||
<div class="ap-bento-card__content">
|
||||
<h4>ZIP Загрузка</h4>
|
||||
<p>Архивы директорий распаковываются на лету. Мы поддерживаем сотни файлов в одном проекте.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Medium Card -->
|
||||
<div class="ap-bento-card ap-bento-card--wide">
|
||||
<div class="ap-bento-card__layout">
|
||||
<div class="ap-bento-card__content">
|
||||
<h3>События и Группы</h3>
|
||||
<p>Гибкая организация потоков. Изолируйте проверку курсовых работ первого курса от экзаменов третьего курса в пару кликов.</p>
|
||||
</div>
|
||||
<div class="ap-bento-card__icon-huge">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- METRICS ROW -->
|
||||
<section class="ap-metrics">
|
||||
<div class="ap-metric">
|
||||
<span class="ap-metric__value">100%</span>
|
||||
<span class="ap-metric__label">Скрытие имен переменных</span>
|
||||
</div>
|
||||
<div class="ap-metric">
|
||||
<span class="ap-metric__value">AST</span>
|
||||
<span class="ap-metric__label">Лексическая токенизация</span>
|
||||
</div>
|
||||
<div class="ap-metric">
|
||||
<span class="ap-metric__value">3</span>
|
||||
<span class="ap-metric__label">Формата выгрузки</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PIPELINE ALIGNMENT -->
|
||||
<section class="ap-pipeline">
|
||||
<h2 class="ap-heading-secondary">Как строится процесс</h2>
|
||||
<div class="ap-pipeline__track">
|
||||
<div class="ap-pipeline__node">
|
||||
<div class="node-dot"></div>
|
||||
<div class="node-info">
|
||||
<h5>1. Распределение</h5>
|
||||
<p>Сборка структуры "Группа -> Студент -> Мероприятие".</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-pipeline__node">
|
||||
<div class="node-dot"></div>
|
||||
<div class="node-info">
|
||||
<h5>2. Парсинг</h5>
|
||||
<p>Конвертация сырых файлов в нормализованные токены.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-pipeline__node">
|
||||
<div class="node-dot"></div>
|
||||
<div class="node-info">
|
||||
<h5>3. Кросс-сравнение</h5>
|
||||
<p>Каждая работа сверяется с каждой на поиск перекрытий.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-pipeline__node">
|
||||
<div class="node-dot"></div>
|
||||
<div class="node-info">
|
||||
<h5>4. Вердикт</h5>
|
||||
<p>Генерация отчета со списком найденных совпадений кода.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ SECTION -->
|
||||
<section class="faq">
|
||||
<h2 class="ap-heading-secondary">Частые вопросы</h2>
|
||||
<tui-accordion class="faq__list">
|
||||
<button tuiAccordion class="faq__button">Игнорируется ли шаблонный код?</button>
|
||||
<tui-expand>
|
||||
<p class="faq__content">
|
||||
Да, преподаватель может загрузить архив с исходным кодом, который выдавался студентам как основа (Reference Set). Система автоматически очистит совпадения с этим кодом из финального отчёта.
|
||||
</p>
|
||||
</tui-expand>
|
||||
|
||||
<button tuiAccordion class="faq__button">Можно ли подменить файл или обмануть систему переименованиями?</button>
|
||||
<tui-expand>
|
||||
<p class="faq__content">
|
||||
Антиплагиат анализирует логическую структуру (Abstract Syntax Tree) и применяет шинглинг токенов. Переименование переменных, перестановка функций или вставка незначимых комментариев никак не повлияют на качество нахождения заимствований.
|
||||
</p>
|
||||
</tui-expand>
|
||||
|
||||
<button tuiAccordion class="faq__button">Как проверить работы разных курсов?</button>
|
||||
<tui-expand>
|
||||
<p class="faq__content">
|
||||
Организуйте независимые «Мероприятия» (Events) внутри платформы. Студенты и их работы будут сгруппированы строго в рамках своих мероприятий, что исключает пересечение проверок между разными курсами.
|
||||
</p>
|
||||
</tui-expand>
|
||||
|
||||
<button tuiAccordion class="faq__button">Могу ли я выгрузить результаты списком?</button>
|
||||
<tui-expand>
|
||||
<p class="faq__content">
|
||||
Результаты проверки выгружаются в разных форматах (JSON, HTML и PDF) и содержат консолидированную аналитику по всем студентам, включая попарную матрицу заимствований и списки самых подозрительных работ.
|
||||
</p>
|
||||
</tui-expand>
|
||||
</tui-accordion>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="footer">
|
||||
<p class="footer__text">© 2026 SparkGuardian Antiplagiat. Система глубокого алгоритмического анализа.</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
14
src/app/features/landing/landing.component.ts
Normal file
14
src/app/features/landing/landing.component.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiExpand } from '@taiga-ui/core/components/expand';
|
||||
import { TuiAccordion } from '@taiga-ui/kit/components/accordion';
|
||||
|
||||
@Component({
|
||||
selector: 'app-landing',
|
||||
imports: [RouterLink, TuiButton, TuiAccordion, TuiExpand],
|
||||
templateUrl: './landing.component.html',
|
||||
styleUrl: './landing.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LandingComponent {}
|
||||
28
src/app/features/login/login.component.css
Normal file
28
src/app/features/login/login.component.css
Normal file
@@ -0,0 +1,28 @@
|
||||
.auth-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - 5rem);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 26rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.auth-btn {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
39
src/app/features/login/login.component.html
Normal file
39
src/app/features/login/login.component.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<div class="auth-wrapper">
|
||||
<section class="card auth-card">
|
||||
<h2 tuiTitle="m" class="auth-title">Вход в систему</h2>
|
||||
|
||||
<form class="auth-form" (ngSubmit)="submit()">
|
||||
<tui-textfield class="sg-tui-textfield">
|
||||
<input
|
||||
tuiInput
|
||||
id="email"
|
||||
type="email"
|
||||
[ngModel]="email()"
|
||||
(ngModelChange)="email.set($event)"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
placeholder="Email"
|
||||
/>
|
||||
</tui-textfield>
|
||||
|
||||
<tui-textfield class="sg-tui-textfield">
|
||||
<input
|
||||
tuiInput
|
||||
id="password"
|
||||
type="password"
|
||||
[ngModel]="password()"
|
||||
(ngModelChange)="password.set($event)"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
placeholder="Пароль"
|
||||
/>
|
||||
</tui-textfield>
|
||||
|
||||
<button tuiButton appearance="primary" class="accent-cta auth-btn" type="submit" [disabled]="isSubmitting()">
|
||||
@if (isSubmitting()) { Входим... } @else { Войти }
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
48
src/app/features/login/login.component.ts
Normal file
48
src/app/features/login/login.component.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiInputDirective } from '@taiga-ui/core/components/input';
|
||||
import { TuiTextfield } from '@taiga-ui/core/components/textfield';
|
||||
import { TuiTitle } from '@taiga-ui/core/components/title';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { UserErrorNotifyService } from '../../core/notifications/user-error-notify.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
imports: [FormsModule, TuiButton, TuiInputDirective, TuiTextfield, TuiTitle],
|
||||
templateUrl: './login.component.html',
|
||||
styleUrl: './login.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LoginComponent {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly userErrors = inject(UserErrorNotifyService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly email = signal('');
|
||||
protected readonly password = signal('');
|
||||
protected readonly isSubmitting = signal(false);
|
||||
|
||||
protected submit(): void {
|
||||
this.isSubmitting.set(true);
|
||||
this.auth
|
||||
.login({
|
||||
email: this.email().trim(),
|
||||
password: this.password(),
|
||||
})
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.isSubmitting.set(false);
|
||||
this.router.navigateByUrl('/dashboard');
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.isSubmitting.set(false);
|
||||
this.userErrors.notifyError(error, 'Ошибка входа');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
37
src/app/features/monitoring/monitoring.component.css
Normal file
37
src/app/features/monitoring/monitoring.component.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/* Monitoring-specific */
|
||||
.entity-action {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
max-height: min(600px, 80vh);
|
||||
}
|
||||
|
||||
.audit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font: var(--tui-font-text-s);
|
||||
}
|
||||
|
||||
.audit-table th {
|
||||
text-align: left;
|
||||
color: var(--tui-text-primary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.audit-table tbody td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--tui-border-normal);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.audit-table-row {
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.audit-table-row:hover {
|
||||
background: color-mix(in srgb, var(--tui-background-accent-1) 12%, transparent);
|
||||
}
|
||||
76
src/app/features/monitoring/monitoring.component.html
Normal file
76
src/app/features/monitoring/monitoring.component.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<div class="page sg-content-column">
|
||||
<h2 tuiTitle="m" class="heading">Мониторинг</h2>
|
||||
|
||||
@if (auditState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') {
|
||||
<div class="loading-wrap"><tui-loader [loading]="true" size="xl" /></div>
|
||||
}
|
||||
@case ('error') {
|
||||
<section class="card"><p class="muted">Ошибка загрузки журнала аудита (доступно только Admin).</p></section>
|
||||
}
|
||||
@case ('ok') {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Журнал аудита</h3>
|
||||
@if (state.items.length === 0) {
|
||||
<p class="muted">Записей нет.</p>
|
||||
} @else {
|
||||
<div class="stream-tabs" style="margin-bottom: 1rem;">
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
size="s"
|
||||
appearance="secondary"
|
||||
[class.stream-active]="resourceTypeFilter() === null"
|
||||
(click)="pickResourceTypeFilter(null)"
|
||||
>
|
||||
Все ({{ state.items.length }})
|
||||
</button>
|
||||
@for (t of uniqueResourceTypes(state.items); track t) {
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
size="s"
|
||||
appearance="secondary"
|
||||
[class.stream-active]="resourceTypeFilter() === t"
|
||||
(click)="pickResourceTypeFilter(t)"
|
||||
>
|
||||
{{ t | auditResourceType }} ({{ resourceTypesOfType(state.items, t) }})
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (filteredAuditLogs(state.items).length === 0) {
|
||||
<p class="muted">Нет записей выбранного типа.</p>
|
||||
} @else {
|
||||
<div class="table-wrap">
|
||||
<table class="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Действие</th>
|
||||
<th>Ресурс</th>
|
||||
<th>Пользователь</th>
|
||||
<th>Время</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of filteredAuditLogs(state.items); track entry.id) {
|
||||
<tr class="audit-table-row">
|
||||
<td class="muted">#{{ entry.id }}</td>
|
||||
<td class="entity-action">{{ entry.action ?? '—' }}</td>
|
||||
<td><span tuiChip size="xs">{{ entry.resource_type ? (entry.resource_type | auditResourceType) : '—' }}</span></td>
|
||||
<td class="muted">{{ entry.actor_email ?? '—' }}</td>
|
||||
<td class="muted meta">{{ formatDate(entry.created_at) }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
73
src/app/features/monitoring/monitoring.component.ts
Normal file
73
src/app/features/monitoring/monitoring.component.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { catchError, map, of, startWith } from 'rxjs';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { switchMap } from 'rxjs';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
import { TuiTitle } from '@taiga-ui/core/components/title';
|
||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiChip } from '@taiga-ui/kit/components/chip';
|
||||
import { WorksApiService } from '../../core/services/works-api.service';
|
||||
import { AuditLog } from '../../core/models/api.types';
|
||||
import { AuditResourceTypePipe } from '../../core/monitoring/audit-resource-type.pipe';
|
||||
import { formatTimestamp } from '../../shared/utils/date-time.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-monitoring',
|
||||
imports: [AsyncPipe, TuiLoader, TuiTitle, TuiChip, TuiButton, AuditResourceTypePipe],
|
||||
templateUrl: './monitoring.component.html',
|
||||
styleUrl: './monitoring.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MonitoringComponent {
|
||||
private readonly api = inject(WorksApiService);
|
||||
private readonly reloadTick = signal(0);
|
||||
|
||||
protected readonly auditState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.listAuditLogs({ limit: 100 }).pipe(
|
||||
map((items) => ({ status: 'ok' as const, items })),
|
||||
catchError(() => of({ status: 'error' as const })),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly resourceTypeFilter = signal<string | null>(null);
|
||||
|
||||
protected uniqueResourceTypes(items: readonly AuditLog[]): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (item.resource_type) {
|
||||
set.add(item.resource_type.trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
return [...set].sort((a, b) => a.localeCompare(b, 'ru'));
|
||||
}
|
||||
|
||||
protected resourceTypesOfType(items: readonly AuditLog[], typeKey: string): number {
|
||||
return items.filter((e) => {
|
||||
const t = e.resource_type;
|
||||
return t ? t.trim().toLowerCase() === typeKey : false;
|
||||
}).length;
|
||||
}
|
||||
|
||||
protected filteredAuditLogs(items: readonly AuditLog[]): readonly AuditLog[] {
|
||||
const filter = this.resourceTypeFilter();
|
||||
if (filter === null) {
|
||||
return items;
|
||||
}
|
||||
return items.filter((e) => {
|
||||
const t = e.resource_type;
|
||||
return t ? t.trim().toLowerCase() === filter : false;
|
||||
});
|
||||
}
|
||||
|
||||
protected pickResourceTypeFilter(type: string | null): void {
|
||||
this.resourceTypeFilter.set(type);
|
||||
}
|
||||
|
||||
protected formatDate(value: string | null | undefined): string {
|
||||
return formatTimestamp(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Refset-detail-specific */
|
||||
.upload-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
88
src/app/features/reference-sets/refset-detail.component.html
Normal file
88
src/app/features/reference-sets/refset-detail.component.html
Normal file
@@ -0,0 +1,88 @@
|
||||
<div class="page sg-content-column">
|
||||
<nav class="back"><a tuiLink routerLink="/dashboard">← К панелям данных</a></nav>
|
||||
|
||||
@if (refSetState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') { <div class="loading-wrap"><tui-loader [loading]="true" size="xl" /></div> }
|
||||
@case ('error') { <p class="muted">Не удалось загрузить reference set.</p> }
|
||||
@case ('ok') {
|
||||
<h2 tuiTitle="m" class="heading">{{ state.refSet.name }}</h2>
|
||||
|
||||
<tui-tabs class="detail-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
|
||||
<button tuiTab type="button">Обзор</button>
|
||||
<button tuiTab type="button">Ingestions</button>
|
||||
<button tuiTab type="button">Управление</button>
|
||||
</tui-tabs>
|
||||
|
||||
@switch (activeTabIndex()) {
|
||||
@case (0) {
|
||||
<section class="card">
|
||||
<dl class="kv">
|
||||
<dt>ID</dt><dd>{{ state.refSet.id }}</dd>
|
||||
<dt>Название</dt><dd>{{ state.refSet.name }}</dd>
|
||||
<dt>Тип</dt><dd>{{ state.refSet.kind }}</dd>
|
||||
<dt>Описание</dt><dd>{{ state.refSet.description ?? '—' }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (1) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Ingestions</h3>
|
||||
@if (ingestionsState$ | async; as is) {
|
||||
@switch (is.status) {
|
||||
@case ('loading') { <tui-loader [loading]="true" size="m" /> }
|
||||
@case ('error') { <p class="muted">Ошибка загрузки.</p> }
|
||||
@case ('ok') {
|
||||
@if (is.items.length === 0) {
|
||||
<p class="muted">Нет ingestions.</p>
|
||||
} @else {
|
||||
<ul class="entity-list">
|
||||
@for (ig of is.items; track ig.id) {
|
||||
<li class="ingestion-row">
|
||||
<span class="ingestion-id">ID: {{ ig.id }}</span>
|
||||
<span tuiChip size="s" class="status-chip" [ngClass]="ig.status | analysisRunStatusChipClasses">{{ ig.status | analysisRunStatus }}</span>
|
||||
<span class="muted meta">{{ formatDate(ig.created_at) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<h4 class="section-title" style="margin-top:1.5rem">Загрузить ingestion</h4>
|
||||
<div class="upload-row">
|
||||
<input type="file" (change)="onFileSelected($event)" />
|
||||
<button tuiButton class="accent-cta" appearance="primary" size="s" type="button" [disabled]="isUploading() || !selectedFile" (click)="uploadIngestion()">Загрузить</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (2) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Редактирование</h3>
|
||||
<form class="edit-form" (ngSubmit)="save()">
|
||||
<tui-textfield class="edit-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="Название" [ngModel]="editName()" (ngModelChange)="editName.set($event)" name="name" />
|
||||
</tui-textfield>
|
||||
<tui-textfield class="edit-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="kind" [ngModel]="editKind()" (ngModelChange)="editKind.set($event)" name="kind" />
|
||||
</tui-textfield>
|
||||
<tui-textfield class="edit-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="Описание" [ngModel]="editDescription()" (ngModelChange)="editDescription.set($event)" name="description" />
|
||||
</tui-textfield>
|
||||
<div class="action-row">
|
||||
<button tuiButton class="accent-cta" appearance="primary" type="submit" [disabled]="isSaving()">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="danger-zone">
|
||||
<button tuiLink type="button" class="danger-link" (click)="delete()" [disabled]="isDeleting()">Удалить reference set</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
122
src/app/features/reference-sets/refset-detail.component.ts
Normal file
122
src/app/features/reference-sets/refset-detail.component.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { AsyncPipe, NgClass } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { catchError, map, of, startWith, switchMap, tap } from 'rxjs';
|
||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiLink } from '@taiga-ui/core/components/link';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
import { TuiTitle } from '@taiga-ui/core/components/title';
|
||||
import { TuiInputDirective } from '@taiga-ui/core/components/input';
|
||||
import { TuiTextfield } from '@taiga-ui/core/components/textfield';
|
||||
import { TuiTabs } from '@taiga-ui/kit/components/tabs';
|
||||
import { TuiChip } from '@taiga-ui/kit/components/chip';
|
||||
import { WorksApiService } from '../../core/services/works-api.service';
|
||||
import { formatTimestamp } from '../../shared/utils/date-time.util';
|
||||
import { AnalysisRunStatusPipe } from '../../core/works/analysis-run-status.pipe';
|
||||
import { AnalysisRunStatusChipClassesPipe } from '../../core/works/analysis-run-status-chip-classes.pipe';
|
||||
import { UserErrorNotifyService } from '../../core/notifications/user-error-notify.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-refset-detail',
|
||||
imports: [AsyncPipe, RouterLink, FormsModule, TuiButton, TuiLink, TuiLoader, TuiTitle, TuiTabs, TuiChip, TuiTextfield, TuiInputDirective, AnalysisRunStatusPipe, AnalysisRunStatusChipClassesPipe, NgClass],
|
||||
templateUrl: './refset-detail.component.html',
|
||||
styleUrl: './refset-detail.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class RefsetDetailComponent {
|
||||
private readonly api = inject(WorksApiService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly userErrors = inject(UserErrorNotifyService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly refSetId = signal(Number(this.route.snapshot.paramMap.get('id')));
|
||||
private readonly reloadTick = signal(0);
|
||||
protected readonly activeTabIndex = signal(0);
|
||||
protected readonly isDeleting = signal(false);
|
||||
protected readonly isSaving = signal(false);
|
||||
protected readonly isUploading = signal(false);
|
||||
protected readonly editName = signal('');
|
||||
protected readonly editKind = signal('');
|
||||
protected readonly editDescription = signal('');
|
||||
protected readonly selectedFile = signal<File | null>(null);
|
||||
|
||||
protected readonly refSetState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.getRefSet(this.refSetId()).pipe(
|
||||
tap((refSet) => {
|
||||
this.editName.set(refSet.name);
|
||||
this.editKind.set(refSet.kind);
|
||||
this.editDescription.set(refSet.description ?? '');
|
||||
}),
|
||||
map((refSet) => ({ status: 'ok' as const, refSet })),
|
||||
catchError((e: unknown) => {
|
||||
this.userErrors.notifyError(e, 'Ошибка загрузки');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly ingestionsState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.listIngestions(this.refSetId()).pipe(
|
||||
map((items) => ({ status: 'ok' as const, items })),
|
||||
catchError(() => of({ status: 'error' as const })),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected reload(): void {
|
||||
this.reloadTick.update((v) => v + 1);
|
||||
}
|
||||
|
||||
protected formatDate(value: string | null | undefined): string {
|
||||
return formatTimestamp(value);
|
||||
}
|
||||
|
||||
protected onFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.selectedFile.set(input.files?.[0] ?? null);
|
||||
}
|
||||
|
||||
protected uploadIngestion(): void {
|
||||
const file = this.selectedFile();
|
||||
if (file === null) return;
|
||||
this.isUploading.set(true);
|
||||
this.api.createIngestion(this.refSetId(), file)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => { this.isUploading.set(false); this.selectedFile.set(null); this.reload(); },
|
||||
error: (e: unknown) => { this.isUploading.set(false); this.userErrors.notifyError(e, 'Ошибка загрузки'); },
|
||||
});
|
||||
}
|
||||
|
||||
protected save(): void {
|
||||
this.isSaving.set(true);
|
||||
this.api.updateRefSet(this.refSetId(), {
|
||||
name: this.editName().trim(),
|
||||
kind: this.editKind().trim(),
|
||||
description: this.editDescription().trim(),
|
||||
})
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => { this.isSaving.set(false); this.reload(); },
|
||||
error: (e: unknown) => { this.isSaving.set(false); this.userErrors.notifyError(e, 'Ошибка сохранения'); },
|
||||
});
|
||||
}
|
||||
|
||||
protected delete(): void {
|
||||
this.isDeleting.set(true);
|
||||
this.api.deleteRefSet(this.refSetId())
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => { this.isDeleting.set(false); this.router.navigateByUrl('/dashboard'); },
|
||||
error: (e: unknown) => { this.isDeleting.set(false); this.userErrors.notifyError(e, 'Ошибка удаления'); },
|
||||
});
|
||||
}
|
||||
}
|
||||
1
src/app/features/students/student-detail.component.css
Normal file
1
src/app/features/students/student-detail.component.css
Normal file
@@ -0,0 +1 @@
|
||||
/* Student-detail-specific — all shared styles now global */
|
||||
90
src/app/features/students/student-detail.component.html
Normal file
90
src/app/features/students/student-detail.component.html
Normal file
@@ -0,0 +1,90 @@
|
||||
<div class="page sg-content-column">
|
||||
<nav class="back"><a tuiLink routerLink="/dashboard">← К панелям данных</a></nav>
|
||||
|
||||
@if (studentState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') { <div class="loading-wrap"><tui-loader [loading]="true" size="xl" /></div> }
|
||||
@case ('error') { <p class="muted">Не удалось загрузить студента.</p> }
|
||||
@case ('ok') {
|
||||
<h2 tuiTitle="m" class="heading">{{ state.student.name }}</h2>
|
||||
|
||||
<tui-tabs class="detail-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
|
||||
<button tuiTab type="button">Обзор</button>
|
||||
<button tuiTab type="button">Аналитика</button>
|
||||
<button tuiTab type="button">Управление</button>
|
||||
</tui-tabs>
|
||||
|
||||
@switch (activeTabIndex()) {
|
||||
@case (0) {
|
||||
<section class="card">
|
||||
<dl class="kv">
|
||||
<dt>ID</dt><dd>{{ state.student.id }}</dd>
|
||||
<dt>ФИО</dt><dd>{{ state.student.name }}</dd>
|
||||
<dt>Email</dt><dd>{{ state.student.email }}</dd>
|
||||
<dt>user_id</dt><dd>{{ state.student.user_id ?? '—' }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (1) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Статистика студента</h3>
|
||||
@if (statsState$ | async; as ss) {
|
||||
@switch (ss.status) {
|
||||
@case ('loading') { <tui-loader [loading]="true" size="m" /> }
|
||||
@case ('error') { <p class="muted">Ошибка загрузки.</p> }
|
||||
@case ('ok') {
|
||||
@if (ss.dashboard.presentation_summary; as m) {
|
||||
<div class="config-grid">
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Всего работ</span><span class="cfg-value">{{ m.works_total ?? 0 }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Проверено</span><span class="cfg-value">{{ m.works_checked ?? 0 }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Plagiarism rate</span><span class="cfg-value">{{ m.plagiarism_rate ?? '—' }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Trust score</span><span class="cfg-value">{{ m.trust_score ?? '—' }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Risk</span><span class="cfg-value">{{ m.risk_level ?? '—' }}</span></div></div>
|
||||
</div>
|
||||
} @else { <p class="muted">Метрики недоступны.</p> }
|
||||
|
||||
@if (ss.dashboard.works; as cards) {
|
||||
@if (cards.length > 0) {
|
||||
<h4 class="section-title" style="margin-top:1.5rem">Работы</h4>
|
||||
<ul class="entity-list">
|
||||
@for (c of cards; track c.work_id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/works', c.work_id]">Работа {{ c.work_id }}</a>
|
||||
<span tuiChip size="xs">{{ c.risk_level ?? '—' }}</span>
|
||||
<span class="muted meta">score={{ c.trust_score ?? '—' }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (2) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Редактирование</h3>
|
||||
<form class="edit-form" (ngSubmit)="save()">
|
||||
<tui-textfield class="edit-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="ФИО" [ngModel]="editName()" (ngModelChange)="editName.set($event)" name="name" />
|
||||
</tui-textfield>
|
||||
<tui-textfield class="edit-field sg-tui-textfield">
|
||||
<input tuiInput type="email" placeholder="Email" [ngModel]="editEmail()" (ngModelChange)="editEmail.set($event)" name="email" />
|
||||
</tui-textfield>
|
||||
<div class="action-row">
|
||||
<button tuiButton class="accent-cta" appearance="primary" type="submit" [disabled]="isSaving()">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="danger-zone">
|
||||
<button tuiLink type="button" class="danger-link" (click)="delete()" [disabled]="isDeleting()">Удалить студента</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
90
src/app/features/students/student-detail.component.ts
Normal file
90
src/app/features/students/student-detail.component.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { catchError, map, of, startWith, switchMap, tap } from 'rxjs';
|
||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiLink } from '@taiga-ui/core/components/link';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
import { TuiTitle } from '@taiga-ui/core/components/title';
|
||||
import { TuiInputDirective } from '@taiga-ui/core/components/input';
|
||||
import { TuiTextfield } from '@taiga-ui/core/components/textfield';
|
||||
import { TuiTabs } from '@taiga-ui/kit/components/tabs';
|
||||
import { TuiChip } from '@taiga-ui/kit/components/chip';
|
||||
import { UserErrorNotifyService } from '../../core/notifications/user-error-notify.service';
|
||||
import { WorksApiService } from '../../core/services/works-api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-student-detail',
|
||||
imports: [AsyncPipe, RouterLink, FormsModule, TuiButton, TuiLink, TuiLoader, TuiTitle, TuiTabs, TuiChip, TuiTextfield, TuiInputDirective],
|
||||
templateUrl: './student-detail.component.html',
|
||||
styleUrl: './student-detail.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class StudentDetailComponent {
|
||||
private readonly api = inject(WorksApiService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly userErrors = inject(UserErrorNotifyService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly studentId = signal(Number(this.route.snapshot.paramMap.get('id')));
|
||||
private readonly reloadTick = signal(0);
|
||||
protected readonly activeTabIndex = signal(0);
|
||||
protected readonly isDeleting = signal(false);
|
||||
protected readonly isSaving = signal(false);
|
||||
protected readonly editName = signal('');
|
||||
protected readonly editEmail = signal('');
|
||||
|
||||
protected readonly studentState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.getStudent(this.studentId()).pipe(
|
||||
tap((student) => {
|
||||
this.editName.set(student.name);
|
||||
this.editEmail.set(student.email);
|
||||
}),
|
||||
map((student) => ({ status: 'ok' as const, student })),
|
||||
catchError((e: unknown) => {
|
||||
this.userErrors.notifyError(e, 'Ошибка загрузки');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly statsState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.getStudentStats(this.studentId()).pipe(
|
||||
map((dashboard) => ({ status: 'ok' as const, dashboard })),
|
||||
catchError(() => of({ status: 'error' as const })),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected reload(): void {
|
||||
this.reloadTick.update((v) => v + 1);
|
||||
}
|
||||
|
||||
protected save(): void {
|
||||
this.isSaving.set(true);
|
||||
this.api.updateStudent(this.studentId(), { name: this.editName().trim(), email: this.editEmail().trim() })
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => { this.isSaving.set(false); this.reload(); },
|
||||
error: (e: unknown) => { this.isSaving.set(false); this.userErrors.notifyError(e, 'Ошибка сохранения'); },
|
||||
});
|
||||
}
|
||||
|
||||
protected delete(): void {
|
||||
this.isDeleting.set(true);
|
||||
this.api.deleteStudent(this.studentId())
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => { this.isDeleting.set(false); this.router.navigateByUrl('/dashboard'); },
|
||||
error: (e: unknown) => { this.isDeleting.set(false); this.userErrors.notifyError(e, 'Ошибка удаления'); },
|
||||
});
|
||||
}
|
||||
}
|
||||
101
src/app/features/works/work-detail/work-detail.component.css
Normal file
101
src/app/features/works/work-detail/work-detail.component.css
Normal file
@@ -0,0 +1,101 @@
|
||||
/* Work-detail-specific styles */
|
||||
|
||||
.archive-download-btn {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.subsection-title {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Upload row */
|
||||
.upload-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.choose-file-btn {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.font-normal[tuiButton] {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
/* Run list */
|
||||
.run-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.run-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 0;
|
||||
border-bottom: 1px solid var(--tui-border-normal);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
font: var(--tui-font-text-s);
|
||||
}
|
||||
|
||||
.run-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.run-row:hover {
|
||||
background: var(--sg-color-form-bg);
|
||||
}
|
||||
|
||||
.run-row_active {
|
||||
background: color-mix(in srgb, var(--sg-color-accent) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--sg-color-accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.run-id {
|
||||
font: var(--tui-font-text-m);
|
||||
}
|
||||
|
||||
/* Adoptions */
|
||||
.adoptions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.adoption-item {
|
||||
border: 1px solid var(--tui-border-normal);
|
||||
border-radius: var(--tui-radius-m);
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--tui-background-base);
|
||||
}
|
||||
|
||||
.excerpt {
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
/* Report */
|
||||
.report-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.report-head .section-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.report-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
247
src/app/features/works/work-detail/work-detail.component.html
Normal file
247
src/app/features/works/work-detail/work-detail.component.html
Normal file
@@ -0,0 +1,247 @@
|
||||
<div class="page sg-content-column">
|
||||
<nav class="back">
|
||||
<a tuiLink routerLink="/works">← К списку работ</a>
|
||||
</nav>
|
||||
|
||||
@if (workState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') {
|
||||
<div class="loading-wrap">
|
||||
<tui-loader [loading]="true" size="xl" />
|
||||
</div>
|
||||
}
|
||||
@case ('error') {
|
||||
<p class="muted">Не удалось загрузить работу.</p>
|
||||
}
|
||||
@case ('ok') {
|
||||
<h2 tuiTitle="m" class="heading">Работа #{{ workId() }}</h2>
|
||||
|
||||
<tui-tabs class="work-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
|
||||
<button tuiTab type="button">Обзор</button>
|
||||
<button tuiTab type="button">Загрузка и проверка</button>
|
||||
<button tuiTab type="button">Результаты</button>
|
||||
<button tuiTab type="button">Отчёт</button>
|
||||
</tui-tabs>
|
||||
|
||||
@switch (activeTabIndex()) {
|
||||
@case (0) {
|
||||
<section class="card" aria-label="Сессия">
|
||||
<h3 class="section-title">Сведения о работе</h3>
|
||||
<dl class="kv">
|
||||
<dt>Идентификатор</dt>
|
||||
<dd>{{ state.work.id }}</dd>
|
||||
<dt>Студент</dt>
|
||||
<dd><a tuiLink [routerLink]="['/students', state.work.student_id]">#{{ state.work.student_id }}</a></dd>
|
||||
<dt>Мероприятие</dt>
|
||||
<dd><a tuiLink [routerLink]="['/events', state.work.event_id]">#{{ state.work.event_id }}</a></dd>
|
||||
<dt>Время</dt>
|
||||
<dd><code class="mono">{{ formatDateTime(state.work.time) }}</code></dd>
|
||||
<dt>Архив</dt>
|
||||
<dd>
|
||||
@if (state.work.archive_object_key) {
|
||||
{{ state.work.archive_object_key }}
|
||||
<button tuiButton appearance="flat" size="s" type="button" (click)="downloadArchive()" class="archive-download-btn">Скачать</button>
|
||||
} @else {
|
||||
не загружен
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
<div class="danger-zone">
|
||||
<button tuiLink type="button" class="danger-link" (click)="deleteWork()" [disabled]="isDeletingWork()">Удалить работу</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (summaryState$ | async; as summaryState) {
|
||||
@if (summaryState.status === 'ok') {
|
||||
<section class="card" aria-label="Summary">
|
||||
<h3 class="section-title">Summary</h3>
|
||||
<div class="config-grid">
|
||||
<div class="config-item">
|
||||
<div class="cfg-text">
|
||||
<span class="cfg-label">Risk</span>
|
||||
<span class="cfg-value">{{ summaryState.summary.presentation_summary?.risk_level ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="cfg-text">
|
||||
<span class="cfg-label">Trust score</span>
|
||||
<span class="cfg-value">{{ summaryState.summary.presentation_summary?.trust_score ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="cfg-text">
|
||||
<span class="cfg-label">Plagiarism rate</span>
|
||||
<span class="cfg-value">{{ summaryState.summary.presentation_summary?.plagiarism_rate ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="cfg-text">
|
||||
<span class="cfg-label">Counterparts</span>
|
||||
<span class="cfg-value">{{ summaryState.summary.presentation_summary?.counterparts_count ?? 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@case (1) {
|
||||
<section class="card" aria-label="Загрузка">
|
||||
<h3 class="section-title">Загрузка архива и запуск проверки</h3>
|
||||
<div class="upload-row">
|
||||
<input
|
||||
#fileInput
|
||||
type="file"
|
||||
accept=".zip"
|
||||
style="display: none"
|
||||
(change)="onArchivePicked($event)"
|
||||
/>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="outline"
|
||||
type="button"
|
||||
class="choose-file-btn font-normal"
|
||||
(click)="fileInput.click()"
|
||||
>@if (selectedArchiveFile(); as file) { {{ file.name }} } @else { Выбрать ZIP-архив }</button>
|
||||
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
type="button"
|
||||
class="upload-btn font-normal"
|
||||
(click)="uploadArchive()"
|
||||
[disabled]="isUploading() || !selectedArchiveFile()"
|
||||
>
|
||||
@if (isUploading()) { Загружаем... } @else { Загрузить архив }
|
||||
</button>
|
||||
|
||||
<button
|
||||
tuiButton
|
||||
class="accent-cta"
|
||||
appearance="primary"
|
||||
type="button"
|
||||
(click)="runCheck()"
|
||||
[disabled]="isChecking()"
|
||||
>
|
||||
@if (isChecking()) { Запускаем... } @else { Запустить проверку }
|
||||
</button>
|
||||
</div>
|
||||
@if (isPolling()) {
|
||||
<p class="small muted">Polling статуса запущен...</p>
|
||||
}
|
||||
@if (latestRun(); as run) {
|
||||
<p class="small">
|
||||
Текущий статус: <span tuiChip size="s" class="status-chip" [ngClass]="run.status | analysisRunStatusChipClasses">{{ run.status | analysisRunStatus }}</span>
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (2) {
|
||||
<section class="card" aria-label="Analysis runs">
|
||||
<h3 class="section-title">Analysis runs</h3>
|
||||
@if (runsState$ | async; as runsState) {
|
||||
@switch (runsState.status) {
|
||||
@case ('loading') {
|
||||
<div class="loading-wrap loading-wrap_small">
|
||||
<tui-loader [loading]="true" size="m" />
|
||||
</div>
|
||||
}
|
||||
@case ('error') {
|
||||
<p class="muted">Не удалось загрузить runs.</p>
|
||||
}
|
||||
@case ('ok') {
|
||||
@if (runsState.runs.length === 0) {
|
||||
<p class="muted">Проверки пока не запускались.</p>
|
||||
} @else {
|
||||
<ul class="run-list">
|
||||
@for (run of runsState.runs; track run.id) {
|
||||
<li
|
||||
class="run-row"
|
||||
[class.run-row_active]="selectedRunId() === run.id"
|
||||
(click)="selectRun(run.id)"
|
||||
>
|
||||
<span class="run-id">{{ run.id }}</span>
|
||||
<span tuiChip size="s" class="status-chip" [ngClass]="run.status | analysisRunStatusChipClasses">{{ run.status | analysisRunStatus }}</span>
|
||||
<span class="muted">{{ formatDateTime(run.updated_at) }}</span>
|
||||
@if (getRunDuration(run)) {
|
||||
<span class="muted meta">({{ getRunDuration(run) }})</span>
|
||||
}
|
||||
@if (run.status === 'Failed' || run.status === 'Completed') {
|
||||
<button tuiButton appearance="flat" size="s" type="button" (click)="retryRun(run.id); $event.stopPropagation()" [disabled]="isRetrying()">Retry</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="card" aria-label="Совпадения">
|
||||
<h3 class="section-title">Совпадения выбранного run</h3>
|
||||
@if (adoptionsState$ | async; as adoptState) {
|
||||
@switch (adoptState.status) {
|
||||
@case ('idle') {
|
||||
<p class="muted">Выберите run.</p>
|
||||
}
|
||||
@case ('loading') {
|
||||
<div class="loading-wrap loading-wrap_small">
|
||||
<tui-loader [loading]="true" size="m" />
|
||||
</div>
|
||||
}
|
||||
@case ('error') {
|
||||
<p class="muted">Не удалось загрузить совпадения.</p>
|
||||
}
|
||||
@case ('ok') {
|
||||
<p class="small muted">Всего совпадений: {{ adoptState.adoptions.length }}</p>
|
||||
@if (adoptState.adoptions.length > 0) {
|
||||
<div class="adoptions-list">
|
||||
@for (adoption of adoptState.adoptions; track adoption.id) {
|
||||
<article class="adoption-item">
|
||||
<dl class="kv kv_compact">
|
||||
<dt>ID</dt>
|
||||
<dd>{{ adoption.id }}</dd>
|
||||
<dt>Path</dt>
|
||||
<dd><code class="mono">{{ adoption.path ?? '—' }}</code></dd>
|
||||
<dt>Score</dt>
|
||||
<dd>{{ adoption.similarity_score ?? '—' }}</dd>
|
||||
</dl>
|
||||
@if (adoption.segment_excerpt) {
|
||||
<p class="small muted excerpt">{{ adoption.segment_excerpt }}</p>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (3) {
|
||||
<section class="card" aria-label="Отчёт">
|
||||
<div class="report-head">
|
||||
<h3 class="section-title">Teacher report</h3>
|
||||
</div>
|
||||
<div class="report-actions">
|
||||
<button tuiButton appearance="secondary" size="s" type="button" (click)="downloadReport('json')" [disabled]="isDownloading()">
|
||||
Скачать JSON
|
||||
</button>
|
||||
<button tuiButton appearance="secondary" size="s" type="button" (click)="downloadReport('html')" [disabled]="isDownloading()">
|
||||
Скачать HTML
|
||||
</button>
|
||||
<button tuiButton appearance="secondary" size="s" type="button" (click)="downloadReport('pdf')" [disabled]="isDownloading()">
|
||||
Скачать PDF
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
293
src/app/features/works/work-detail/work-detail.component.ts
Normal file
293
src/app/features/works/work-detail/work-detail.component.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { AsyncPipe, NgClass } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { catchError, map, of, startWith, switchMap } from 'rxjs';
|
||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiLink } from '@taiga-ui/core/components/link';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
import { TuiTitle } from '@taiga-ui/core/components/title';
|
||||
import { TuiChip } from '@taiga-ui/kit/components/chip';
|
||||
import { TuiTabs } from '@taiga-ui/kit/components/tabs';
|
||||
import { AnalysisRun } from '../../../core/models/api.types';
|
||||
import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service';
|
||||
import { WorksApiService } from '../../../core/services/works-api.service';
|
||||
import { formatDateTime } from '../../../shared/utils/date-time.util';
|
||||
import { formatDurationMsHuman } from '../../../shared/utils/duration.util';
|
||||
import { AnalysisRunStatusPipe } from '../../../core/works/analysis-run-status.pipe';
|
||||
import { AnalysisRunStatusChipClassesPipe } from '../../../core/works/analysis-run-status-chip-classes.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-work-detail',
|
||||
imports: [NgClass, AsyncPipe, RouterLink, TuiButton, TuiLink, TuiLoader, TuiTitle, TuiTabs, TuiChip, AnalysisRunStatusPipe, AnalysisRunStatusChipClassesPipe],
|
||||
templateUrl: './work-detail.component.html',
|
||||
styleUrl: './work-detail.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class WorkDetailComponent {
|
||||
private readonly api = inject(WorksApiService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly userErrors = inject(UserErrorNotifyService);
|
||||
|
||||
protected readonly workId = signal(Number(this.route.snapshot.paramMap.get('id')));
|
||||
private readonly reloadTick = signal(0);
|
||||
protected readonly selectedRunId = signal<string | null>(null);
|
||||
protected readonly selectedArchiveFile = signal<File | null>(null);
|
||||
protected readonly isUploading = signal(false);
|
||||
protected readonly isChecking = signal(false);
|
||||
protected readonly isDownloading = signal(false);
|
||||
protected readonly latestRun = signal<AnalysisRun | null>(null);
|
||||
protected readonly isPolling = signal(false);
|
||||
protected readonly formatDateTime = formatDateTime;
|
||||
protected readonly activeTabIndex = signal(0);
|
||||
protected readonly isRetrying = signal(false);
|
||||
protected readonly isDeletingWork = signal(false);
|
||||
|
||||
private pollingTimerId: number | null = null;
|
||||
private pollStartMs = 0;
|
||||
|
||||
protected readonly workState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.getWork(this.workId()).pipe(
|
||||
map((work) => ({ status: 'ok' as const, work })),
|
||||
catchError((error: unknown) => {
|
||||
this.userErrors.notifyError(error, 'Ошибка загрузки работы');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly summaryState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.getWorkSummary(this.workId()).pipe(
|
||||
map((summary) => ({ status: 'ok' as const, summary })),
|
||||
catchError((error: unknown) => {
|
||||
this.userErrors.notifyError(error, 'Ошибка загрузки summary');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly runsState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.listWorkRuns(this.workId()).pipe(
|
||||
map((runs) => {
|
||||
const run = runs[0] ?? null;
|
||||
if (this.selectedRunId() === null && run !== null) {
|
||||
this.selectedRunId.set(run.id);
|
||||
}
|
||||
return { status: 'ok' as const, runs };
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.userErrors.notifyError(error, 'Ошибка загрузки analysis runs');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly adoptionsState$ = toObservable(this.selectedRunId).pipe(
|
||||
switchMap((runId) => {
|
||||
if (runId == null) {
|
||||
return of({ status: 'idle' as const });
|
||||
}
|
||||
return this.api.getRunAdoptions(runId).pipe(
|
||||
map((adoptions) => ({ status: 'ok' as const, adoptions })),
|
||||
catchError((error: unknown) => {
|
||||
this.userErrors.notifyError(error, 'Ошибка загрузки совпадений');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => this.stopPolling());
|
||||
}
|
||||
|
||||
protected selectRun(runId: string): void {
|
||||
this.selectedRunId.set(runId);
|
||||
}
|
||||
|
||||
protected onArchivePicked(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.item(0) ?? null;
|
||||
this.selectedArchiveFile.set(file);
|
||||
}
|
||||
|
||||
protected uploadArchive(): void {
|
||||
const file = this.selectedArchiveFile();
|
||||
if (file === null) {
|
||||
this.userErrors.notifyError(new Error('Выберите ZIP-архив для загрузки'), 'Валидация');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isUploading.set(true);
|
||||
this.api.uploadArchive(this.workId(), file).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: () => {
|
||||
this.isUploading.set(false);
|
||||
this.selectedArchiveFile.set(null);
|
||||
this.reload();
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.isUploading.set(false);
|
||||
this.userErrors.notifyError(error, 'Ошибка загрузки архива');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected runCheck(): void {
|
||||
this.isChecking.set(true);
|
||||
this.api.runCheck(this.workId()).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: (response) => {
|
||||
this.isChecking.set(false);
|
||||
const runId = response.analysis_run_id;
|
||||
if (runId != null && runId !== '') {
|
||||
this.selectedRunId.set(runId);
|
||||
this.startPolling(runId);
|
||||
} else {
|
||||
this.reload();
|
||||
}
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.isChecking.set(false);
|
||||
this.userErrors.notifyError(error, 'Не удалось запустить проверку');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected downloadReport(format: 'json' | 'html' | 'pdf'): void {
|
||||
const runId = this.selectedRunId();
|
||||
if (runId == null) {
|
||||
this.userErrors.notifyError(new Error('Сначала выберите analysis run'), 'Валидация');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isDownloading.set(true);
|
||||
this.api.downloadReport(runId, format).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: (blob) => {
|
||||
this.isDownloading.set(false);
|
||||
const extension = format === 'json' ? 'json' : format;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `report-${runId}.${extension}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.isDownloading.set(false);
|
||||
this.userErrors.notifyError(error, `Ошибка скачивания report.${format}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected reload(): void {
|
||||
this.reloadTick.update((value) => value + 1);
|
||||
}
|
||||
|
||||
protected retryRun(runId: string): void {
|
||||
this.isRetrying.set(true);
|
||||
this.api.retryAnalysisRun(runId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: (response) => {
|
||||
this.isRetrying.set(false);
|
||||
const newRunId = response.analysis_run_id;
|
||||
if (newRunId) {
|
||||
this.selectedRunId.set(newRunId);
|
||||
this.startPolling(newRunId);
|
||||
} else {
|
||||
this.reload();
|
||||
}
|
||||
},
|
||||
error: (e: unknown) => {
|
||||
this.isRetrying.set(false);
|
||||
this.userErrors.notifyError(e, 'Ошибка retry');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected deleteWork(): void {
|
||||
this.isDeletingWork.set(true);
|
||||
this.api.deleteWork(this.workId()).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: () => {
|
||||
this.isDeletingWork.set(false);
|
||||
this.router.navigateByUrl('/works');
|
||||
},
|
||||
error: (e: unknown) => {
|
||||
this.isDeletingWork.set(false);
|
||||
this.userErrors.notifyError(e, 'Ошибка удаления');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected downloadArchive(): void {
|
||||
this.api.getWorkArchive(this.workId()).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `work-${this.workId()}-archive.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
error: (e: unknown) => {
|
||||
this.userErrors.notifyError(e, 'Ошибка загрузки архива');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private startPolling(runId: string): void {
|
||||
this.stopPolling();
|
||||
this.isPolling.set(true);
|
||||
this.pollStartMs = Date.now();
|
||||
this.pollRun(runId);
|
||||
}
|
||||
|
||||
private pollRun(runId: string): void {
|
||||
this.api.getAnalysisRun(runId).subscribe({
|
||||
next: (run) => {
|
||||
this.latestRun.set(run);
|
||||
if (run.status === 'Completed' || run.status === 'Failed') {
|
||||
this.stopPolling();
|
||||
this.reload();
|
||||
return;
|
||||
}
|
||||
const elapsedMs = Date.now() - this.pollStartMs;
|
||||
const delayMs = elapsedMs < 30_000 ? 2_500 : 5_000;
|
||||
this.pollingTimerId = window.setTimeout(() => this.pollRun(runId), delayMs);
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.stopPolling();
|
||||
this.userErrors.notifyError(error, 'Ошибка polling статуса');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollingTimerId !== null) {
|
||||
window.clearTimeout(this.pollingTimerId);
|
||||
this.pollingTimerId = null;
|
||||
}
|
||||
this.isPolling.set(false);
|
||||
}
|
||||
|
||||
protected getRunDuration(run: AnalysisRun): string {
|
||||
if (!run.started_at || !run.completed_at) return '';
|
||||
const s = new Date(run.started_at).getTime();
|
||||
const e = new Date(run.completed_at).getTime();
|
||||
if (Number.isNaN(s) || Number.isNaN(e)) return '';
|
||||
return formatDurationMsHuman(e - s);
|
||||
}
|
||||
}
|
||||
41
src/app/features/works/works-list/works-list.component.css
Normal file
41
src/app/features/works/works-list/works-list.component.css
Normal file
@@ -0,0 +1,41 @@
|
||||
/* Works-list-specific styles */
|
||||
|
||||
.create-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.create-field {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.work-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.work-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--tui-border-normal);
|
||||
}
|
||||
|
||||
.work-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.work-link {
|
||||
font: var(--tui-font-text-m);
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-left: auto;
|
||||
font: var(--tui-font-text-s);
|
||||
}
|
||||
80
src/app/features/works/works-list/works-list.component.html
Normal file
80
src/app/features/works/works-list/works-list.component.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<div class="page sg-content-column">
|
||||
<h2 tuiTitle="m" class="heading">Работы</h2>
|
||||
|
||||
<section class="card create" aria-label="Создать работу">
|
||||
<h3 class="section-title">Новая работа</h3>
|
||||
<form class="create-row" (ngSubmit)="createWork()">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<label tuiLabel>student_id</label>
|
||||
<input
|
||||
tuiInput
|
||||
type="number"
|
||||
name="studentId"
|
||||
placeholder="ID студента"
|
||||
[ngModel]="createStudentId()"
|
||||
(ngModelChange)="createStudentId.set($event)"
|
||||
required
|
||||
/>
|
||||
</tui-textfield>
|
||||
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<label tuiLabel>event_id</label>
|
||||
<input
|
||||
tuiInput
|
||||
type="number"
|
||||
name="eventId"
|
||||
placeholder="ID мероприятия"
|
||||
[ngModel]="createEventId()"
|
||||
(ngModelChange)="createEventId.set($event)"
|
||||
required
|
||||
/>
|
||||
</tui-textfield>
|
||||
|
||||
<button
|
||||
tuiButton
|
||||
class="accent-cta"
|
||||
type="submit"
|
||||
appearance="primary"
|
||||
[disabled]="isCreating()"
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@if (worksState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') {
|
||||
<div class="loading-wrap">
|
||||
<tui-loader [loading]="true" size="xl" />
|
||||
</div>
|
||||
}
|
||||
@case ('error') {
|
||||
<section class="card" aria-label="Список работ">
|
||||
<p class="muted">Список временно недоступен.</p>
|
||||
</section>
|
||||
}
|
||||
@case ('ok') {
|
||||
<section class="card" aria-label="Список работ">
|
||||
@if (state.works.length === 0) {
|
||||
<p class="muted">Работы пока отсутствуют.</p>
|
||||
} @else {
|
||||
<ul class="work-list">
|
||||
@for (work of state.works; track work.id) {
|
||||
<li class="work-row">
|
||||
<a tuiLink [routerLink]="['/works', work.id]" class="work-link">
|
||||
Работа {{ work.id }}
|
||||
</a>
|
||||
<span class="muted meta">
|
||||
student={{ work.student_id }}, event={{ work.event_id }}
|
||||
</span>
|
||||
<span class="muted meta">{{ formatDate(work.time) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
96
src/app/features/works/works-list/works-list.component.ts
Normal file
96
src/app/features/works/works-list/works-list.component.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { catchError, map, of, startWith, switchMap } from 'rxjs';
|
||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiInputDirective } from '@taiga-ui/core/components/input';
|
||||
import { TuiLink } from '@taiga-ui/core/components/link';
|
||||
import { TuiLabel } from '@taiga-ui/core/components/label';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
import { TuiTextfield } from '@taiga-ui/core/components/textfield';
|
||||
import { TuiTitle } from '@taiga-ui/core/components/title';
|
||||
import { WorksApiService } from '../../../core/services/works-api.service';
|
||||
import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service';
|
||||
import { formatDateTime } from '../../../shared/utils/date-time.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-works-list',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
TuiButton,
|
||||
TuiInputDirective,
|
||||
TuiLabel,
|
||||
TuiLink,
|
||||
TuiLoader,
|
||||
TuiTextfield,
|
||||
TuiTitle,
|
||||
],
|
||||
templateUrl: './works-list.component.html',
|
||||
styleUrl: './works-list.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class WorksListComponent {
|
||||
private readonly api = inject(WorksApiService);
|
||||
private readonly userErrors = inject(UserErrorNotifyService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
private readonly reloadTick = signal(0);
|
||||
protected readonly createStudentId = signal('');
|
||||
protected readonly createEventId = signal('');
|
||||
protected readonly isCreating = signal(false);
|
||||
|
||||
protected readonly worksState$ = toObservable(this.reloadTick).pipe(
|
||||
switchMap(() =>
|
||||
this.api.listWorks().pipe(
|
||||
map((works) => ({ status: 'ok' as const, works })),
|
||||
catchError((error: unknown) => {
|
||||
this.userErrors.notifyError(error, 'Не удалось загрузить список работ');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
protected createWork(): void {
|
||||
const studentId = Number(this.createStudentId());
|
||||
const eventId = Number(this.createEventId());
|
||||
if (!Number.isInteger(studentId) || !Number.isInteger(eventId)) {
|
||||
this.userErrors.notifyError(new Error('Некорректный ID'), 'Проверьте student_id и event_id');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isCreating.set(true);
|
||||
this.api
|
||||
.createWork({
|
||||
student_id: studentId,
|
||||
event_id: eventId,
|
||||
time: new Date().toISOString(),
|
||||
})
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.isCreating.set(false);
|
||||
this.createStudentId.set('');
|
||||
this.createEventId.set('');
|
||||
this.reloadTick.update((value) => value + 1);
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.isCreating.set(false);
|
||||
this.userErrors.notifyError(error, 'Не удалось создать работу');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected reload(): void {
|
||||
this.reloadTick.update((value) => value + 1);
|
||||
}
|
||||
|
||||
protected formatDate(value: string | null | undefined): string {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
}
|
||||
50
src/app/shared/utils/date-time.util.ts
Normal file
50
src/app/shared/utils/date-time.util.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export function formatDateTime(value: string | null | undefined): string {
|
||||
if (value == null || value === '') {
|
||||
return '—';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
}).format(date);
|
||||
}
|
||||
export function formatTimestamp(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatUnixMs(value: number | null | undefined): string {
|
||||
if (value == null) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimestamp(new Date(value).toISOString());
|
||||
}
|
||||
|
||||
export function formatClockTime(valueMs: number | null | undefined): string {
|
||||
if (valueMs == null || !Number.isFinite(valueMs)) {
|
||||
return '—';
|
||||
}
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(new Date(valueMs));
|
||||
}
|
||||
46
src/app/shared/utils/duration.util.ts
Normal file
46
src/app/shared/utils/duration.util.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
const NBSP = '\u00a0';
|
||||
|
||||
export function formatDurationMsHuman(ms: number | null | undefined): string {
|
||||
if (ms === null || ms === undefined || !Number.isFinite(ms)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const rounded = Math.round(ms);
|
||||
if (rounded < 0) {
|
||||
return '—';
|
||||
}
|
||||
if (rounded === 0) {
|
||||
return `0${NBSP}с`;
|
||||
}
|
||||
|
||||
if (rounded < 1000) {
|
||||
return `${rounded}${NBSP}мс`;
|
||||
}
|
||||
|
||||
const secFloat = rounded / 1000;
|
||||
if (secFloat < 60) {
|
||||
const hasFraction = rounded % 1000 !== 0;
|
||||
const text = hasFraction
|
||||
? secFloat.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 1 })
|
||||
: String(Math.round(secFloat));
|
||||
return `${text}${NBSP}с`;
|
||||
}
|
||||
|
||||
const totalSec = Math.floor(rounded / 1000);
|
||||
const h = Math.floor(totalSec / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
const s = totalSec % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (h > 0) {
|
||||
parts.push(`${h}${NBSP}ч`);
|
||||
}
|
||||
if (m > 0) {
|
||||
parts.push(`${m}${NBSP}мин`);
|
||||
}
|
||||
if (s > 0 || parts.length === 0) {
|
||||
parts.push(`${s}${NBSP}с`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
10
src/app/shared/utils/json.util.ts
Normal file
10
src/app/shared/utils/json.util.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export function unwrapJsonPayload(data: unknown): unknown {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
return JSON.parse(data) as unknown;
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
10
src/app/shared/utils/math.util.ts
Normal file
10
src/app/shared/utils/math.util.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Ограничивает значение в диапазон [min, max].
|
||||
* Если max < min — возвращает min (защита от инверсии границ).
|
||||
*/
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
if (max < min) {
|
||||
return min;
|
||||
}
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
15
src/app/shared/utils/number.util.ts
Normal file
15
src/app/shared/utils/number.util.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Безопасно извлекает числовое значение по ключу из объекта.
|
||||
* Поддерживает как `number`, так и строки-числа.
|
||||
*/
|
||||
export function readNumericField(o: Record<string, unknown>, key: string): number | null {
|
||||
const v = o[key];
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return v;
|
||||
}
|
||||
if (typeof v === 'string' && v.trim() !== '') {
|
||||
const parsed = Number(v);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
6
src/environments/environment.prod.ts
Normal file
6
src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiFallbackOrigin: 'http://spark.returntozer0.ru',
|
||||
apiBasePath: '',
|
||||
defaultPageLimit: 20,
|
||||
} as const;
|
||||
6
src/environments/environment.ts
Normal file
6
src/environments/environment.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiFallbackOrigin: 'http://spark.returntozer0.ru',
|
||||
apiBasePath: '',
|
||||
defaultPageLimit: 20,
|
||||
} as const;
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SparkAntiplagiat</title>
|
||||
|
||||
@@ -1 +1,98 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
@import './styles/color-tokens.css';
|
||||
@import './styles/page-common.css';
|
||||
@import './styles/shared-components.css';
|
||||
@import './styles/filter-chips.css';
|
||||
@import './styles/session-status-chips.css';
|
||||
@import './styles/sg-input-fields.css';
|
||||
|
||||
@font-face {
|
||||
font-family: 'Tinkoff Sans';
|
||||
src: url('/fonts/TinkoffSans-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Tinkoff Sans';
|
||||
src: url('/fonts/TinkoffSans-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Tinkoff Sans';
|
||||
src: url('/fonts/TinkoffSans-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--sg-font-family-base: 'Tinkoff Sans', system-ui, sans-serif;
|
||||
|
||||
/* Taiga v5 typography family override */
|
||||
--tui-typography-family-text: var(--sg-font-family-base);
|
||||
--tui-typography-family-display: var(--sg-font-family-base);
|
||||
|
||||
/* Additional project typography tokens (matching Antiplagiat legacy/specifics) */
|
||||
--tui-font-heading-1: 700 2rem/1.2 var(--sg-font-family-base);
|
||||
--tui-font-heading-2: 700 1.75rem/1.25 var(--sg-font-family-base);
|
||||
--tui-font-heading-3: 700 1.5rem/1.3 var(--sg-font-family-base);
|
||||
--tui-font-heading-4: 700 1.25rem/1.35 var(--sg-font-family-base);
|
||||
--tui-font-heading-5: 600 1.125rem/1.35 var(--sg-font-family-base);
|
||||
--tui-font-heading-6: 600 1rem/1.4 var(--sg-font-family-base);
|
||||
--tui-font-text-xl: 400 1.125rem/1.45 var(--sg-font-family-base);
|
||||
--tui-font-text-l: 400 1.0625rem/1.45 var(--sg-font-family-base);
|
||||
--tui-font-text-m: 400 1rem/1.45 var(--sg-font-family-base);
|
||||
--tui-font-text-s: 400 0.875rem/1.4 var(--sg-font-family-base);
|
||||
--tui-font-text-xs: 400 0.75rem/1.35 var(--sg-font-family-base);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
box-sizing: border-box;
|
||||
background: var(--sg-color-bg);
|
||||
color: var(--sg-color-text);
|
||||
font: var(--tui-font-text-m, 15px/1.5 'Tinkoff Sans', sans-serif);
|
||||
font-family: 'Tinkoff Sans', sans-serif;
|
||||
}
|
||||
|
||||
.sg-content-column {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: var(--sg-content-max-width);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--sg-page-padding-inline);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
* Taiga zadayet font cherez shorthand na knopkakh/inputakh; bez !important rodnoy shrift
|
||||
* perebivayet body.
|
||||
*/
|
||||
* {
|
||||
font-family: 'Tinkoff Sans', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Form defaults */
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--sg-color-placeholder);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
a {
|
||||
color: color-mix(in srgb, var(--sg-color-text) 90%, black);
|
||||
}
|
||||
|
||||
103
src/styles/color-tokens.css
Normal file
103
src/styles/color-tokens.css
Normal file
@@ -0,0 +1,103 @@
|
||||
:root {
|
||||
/* SparkGuardian color tokens */
|
||||
--sg-color-accent: #ffdb00;
|
||||
--sg-color-bg: #f6f7f8;
|
||||
--sg-color-text: #383839;
|
||||
--sg-color-subtitle: #313132;
|
||||
--sg-color-form-bg: #e8edf1;
|
||||
--sg-color-placeholder: #6b6d6f;
|
||||
|
||||
/* Поля ввода: .sg-tui-textfield на tui-textfield, .sg-native-input на нативных input */
|
||||
--sg-color-textfield-bg: #f3f4f7;
|
||||
--sg-color-textfield-hover-bg: #eaeff3;
|
||||
--sg-color-textfield-focus-bg: #ffffff;
|
||||
--sg-color-textfield-focus-border: #333333;
|
||||
--sg-color-textfield-focus-label: #333333;
|
||||
--sg-textfield-radius: var(--tui-radius-l);
|
||||
--sg-native-input-min-height: 2rem;
|
||||
--sg-native-input-padding: 0.35rem 0.5rem;
|
||||
/* Основные кнопки (например «Создать»): минимальная ширина без !important */
|
||||
--sg-primary-action-min-inline-size: 11rem;
|
||||
|
||||
/* Semantic aliases */
|
||||
--sg-color-card-bg: #ffffff;
|
||||
--sg-color-border: color-mix(in srgb, var(--sg-color-text) 12%, transparent);
|
||||
--sg-color-danger: #d92d20;
|
||||
|
||||
/* Ширина и отступы основного контента (шапка, страницы с классом .page) */
|
||||
/* От 1000px: боковые поля по умолчанию; уже 999px — 48px (media ниже). */
|
||||
--sg-content-max-width: 1104px;
|
||||
--sg-page-padding-inline: 1rem;
|
||||
|
||||
/*
|
||||
* Чипы-категории (фильтры телеметрии, пресеты диапазона, вкладки потоков на просмотре).
|
||||
* Совпадают с полями там, где цвета те же: фон неактивного = --sg-color-textfield-bg.
|
||||
*/
|
||||
--sg-filter-chip-bg: #f3f4f7;
|
||||
--sg-filter-chip-bg-hover: #eaeff3;
|
||||
--sg-filter-chip-fg: #313131;
|
||||
--sg-filter-chip-active-bg: #158eff;
|
||||
--sg-filter-chip-active-bg-hover: #0070ff;
|
||||
--sg-filter-chip-active-fg: #ffffff;
|
||||
|
||||
/* Taiga accent palette override (primary-кнопки и т.п.; активная страница пагинации переопределена в sessions-list) */
|
||||
--tui-background-accent-1: var(--sg-color-accent);
|
||||
--tui-background-accent-1-hover: color-mix(in srgb, var(--sg-color-accent) 92%, black);
|
||||
--tui-background-accent-1-pressed: color-mix(in srgb, var(--sg-color-accent) 84%, black);
|
||||
--tui-text-primary-on-accent-1: var(--sg-color-text);
|
||||
|
||||
--tui-background-base: var(--sg-color-bg);
|
||||
--tui-background-elevation-1: var(--sg-color-card-bg);
|
||||
--tui-text-primary: var(--sg-color-text);
|
||||
--tui-text-tertiary: color-mix(in srgb, var(--sg-color-text) 70%, white);
|
||||
--tui-text-action: color-mix(in srgb, var(--sg-color-text) 80%, black);
|
||||
--tui-border-normal: var(--sg-color-border);
|
||||
--tui-focus: var(--sg-color-accent);
|
||||
--tui-status-negative: var(--sg-color-danger);
|
||||
|
||||
/* Чипы статусов сессии (кроме «Завершена» — без отдельной раскраски) */
|
||||
--sg-session-status-active-bg: color-mix(in srgb, #16a34a 18%, var(--sg-color-card-bg));
|
||||
--sg-session-status-active-fg: #166534;
|
||||
--sg-session-status-active-border: color-mix(in srgb, #16a34a 38%, transparent);
|
||||
|
||||
--sg-session-status-pending-bg: color-mix(in srgb, #eab308 18%, var(--sg-color-card-bg));
|
||||
--sg-session-status-pending-fg: #713f12;
|
||||
--sg-session-status-pending-border: color-mix(in srgb, #eab308 34%, transparent);
|
||||
|
||||
--sg-session-status-unknown-bg: color-mix(in srgb, var(--sg-color-text) 10%, var(--sg-color-card-bg));
|
||||
--sg-session-status-unknown-fg: var(--sg-color-text);
|
||||
--sg-session-status-unknown-border: var(--sg-color-border);
|
||||
|
||||
/* Клавиатура (SVG + HTML-компонент) */
|
||||
--sg-keyboard-body: color-mix(in srgb, var(--sg-color-form-bg) 80%, var(--sg-color-border));
|
||||
--sg-keyboard-font-family: 'Tinkoff Sans', system-ui, sans-serif;
|
||||
--sg-keyboard-font-weight: 400;
|
||||
--sg-keyboard-letter-spacing: 0.03em;
|
||||
/* Не нажатые клавиши — светлый «фоновый» тон (как подложка интерфейса) */
|
||||
--sg-keyboard-key-surface-idle: color-mix(
|
||||
in srgb,
|
||||
var(--sg-color-form-bg) 78%,
|
||||
var(--sg-color-border)
|
||||
);
|
||||
--sg-keyboard-key-main: var(--sg-keyboard-key-surface-idle);
|
||||
--sg-keyboard-key-mod: var(--sg-keyboard-key-surface-idle);
|
||||
--sg-keyboard-key-other: var(--sg-keyboard-key-surface-idle);
|
||||
--sg-keyboard-key-stroke: color-mix(in srgb, var(--sg-color-border) 65%, transparent);
|
||||
/* Базовые глифы — ink-soft; контрастный «чёрный» — --sg-keyboard-ink (см. подсветку нажатий) */
|
||||
--sg-keyboard-ink: var(--sg-color-text);
|
||||
--sg-keyboard-ink-soft: var(--tui-text-tertiary);
|
||||
/* Нажатие: контрастный жёлтый акцент, символы на жёлтом — тёмные */
|
||||
--sg-keyboard-key-pressed-fill: var(--sg-color-accent);
|
||||
--sg-keyboard-key-pressed-ink: var(--sg-color-text);
|
||||
--sg-keyboard-highlight: var(--sg-keyboard-key-pressed-fill);
|
||||
/* Анимация подсветки ввода (клавиатура/мышь): централизованные параметры */
|
||||
--sg-input-highlight-duration: 140ms;
|
||||
--sg-input-highlight-easing: cubic-bezier(0.33, 1, 0.68, 1);
|
||||
--sg-input-highlight-from-scale: 0.94;
|
||||
}
|
||||
|
||||
@media (max-width: 999px) {
|
||||
:root {
|
||||
--sg-page-padding-inline: 48px;
|
||||
}
|
||||
}
|
||||
99
src/styles/filter-chips.css
Normal file
99
src/styles/filter-chips.css
Normal file
@@ -0,0 +1,99 @@
|
||||
.stream-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stream-tabs button,
|
||||
.telemetry-presets button {
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
/* Inactive chip */
|
||||
.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active),
|
||||
.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active) {
|
||||
border-radius: 624.9375rem;
|
||||
font-weight: 400;
|
||||
background: var(--sg-filter-chip-bg);
|
||||
color: var(--sg-filter-chip-fg);
|
||||
border: 1px solid transparent;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.stream-tabs
|
||||
button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active):hover:not(:disabled):not(
|
||||
[data-state='disabled']
|
||||
),
|
||||
.telemetry-presets
|
||||
button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active):hover:not(:disabled):not(
|
||||
[data-state='disabled']
|
||||
) {
|
||||
background: var(--sg-filter-chip-bg-hover);
|
||||
color: var(--sg-filter-chip-fg);
|
||||
border-color: transparent;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Active chip */
|
||||
.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'],
|
||||
.telemetry-presets button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'] {
|
||||
border-radius: 624.9375rem;
|
||||
font-weight: 400;
|
||||
background: var(--sg-filter-chip-active-bg);
|
||||
color: var(--sg-filter-chip-active-fg);
|
||||
border: 1px solid transparent;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.stream-tabs
|
||||
button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not(
|
||||
[data-state='disabled']
|
||||
),
|
||||
.telemetry-presets
|
||||
button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not(
|
||||
[data-state='disabled']
|
||||
) {
|
||||
background: var(--sg-filter-chip-active-bg-hover);
|
||||
color: var(--sg-filter-chip-active-fg);
|
||||
border-color: transparent;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove Taiga's :focus outline/shadow after mouse click.
|
||||
* Keyboard: light ring only on :focus-visible.
|
||||
*/
|
||||
.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:focus:not(:focus-visible),
|
||||
.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:focus:not(:focus-visible) {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.stream-tabs
|
||||
button[tuiButton][tuiAppearance][data-appearance='secondary']:active:not(:disabled):not([data-state='disabled']),
|
||||
.telemetry-presets
|
||||
button[tuiButton][tuiAppearance][data-appearance='secondary']:active:not(:disabled):not([data-state='disabled']) {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible,
|
||||
.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible {
|
||||
outline: 2px solid var(--sg-filter-chip-active-bg);
|
||||
outline-offset: 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible,
|
||||
.telemetry-presets button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible {
|
||||
outline-color: var(--sg-filter-chip-active-bg-hover);
|
||||
}
|
||||
41
src/styles/page-common.css
Normal file
41
src/styles/page-common.css
Normal file
@@ -0,0 +1,41 @@
|
||||
.card {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: var(--tui-radius-l);
|
||||
background: var(--tui-background-elevation-1);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 1rem;
|
||||
font: var(--tui-font-heading-6);
|
||||
color: var(--sg-color-subtitle);
|
||||
}
|
||||
|
||||
.loading-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.loading-wrap_small {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--tui-text-tertiary);
|
||||
}
|
||||
|
||||
.small {
|
||||
font: var(--tui-font-text-s);
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
18
src/styles/session-status-chips.css
Normal file
18
src/styles/session-status-chips.css
Normal file
@@ -0,0 +1,18 @@
|
||||
/* Раскраска чипов статуса сессии (см. SessionStatusChipClassesPipe). «Завершена» — без этих классов. */
|
||||
[tuiChip].status-chip.status-chip--active {
|
||||
background: var(--sg-session-status-active-bg);
|
||||
color: var(--sg-session-status-active-fg);
|
||||
border-color: var(--sg-session-status-active-border);
|
||||
}
|
||||
|
||||
[tuiChip].status-chip.status-chip--pending {
|
||||
background: var(--sg-session-status-pending-bg);
|
||||
color: var(--sg-session-status-pending-fg);
|
||||
border-color: var(--sg-session-status-pending-border);
|
||||
}
|
||||
|
||||
[tuiChip].status-chip.status-chip--unknown {
|
||||
background: var(--sg-session-status-unknown-bg);
|
||||
color: var(--sg-session-status-unknown-fg);
|
||||
border-color: var(--sg-session-status-unknown-border);
|
||||
}
|
||||
117
src/styles/sg-input-fields.css
Normal file
117
src/styles/sg-input-fields.css
Normal file
@@ -0,0 +1,117 @@
|
||||
/* --- tui-textfield (Taiga Input) --- */
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'] {
|
||||
--tui-focus: var(--sg-color-textfield-focus-border);
|
||||
--t-shadow: none;
|
||||
box-shadow: none;
|
||||
background-color: var(--sg-color-textfield-bg);
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: -1px;
|
||||
border-width: 0;
|
||||
border-radius: var(--sg-textfield-radius, var(--tui-radius-l));
|
||||
filter: none;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
outline-color 0.15s ease;
|
||||
}
|
||||
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']::before,
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']::after {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']:hover:not(:focus-within):not(
|
||||
[data-state='disabled']
|
||||
):not(._disabled),
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data-state='hover']:not(:focus-within) {
|
||||
--t-shadow: none;
|
||||
box-shadow: none;
|
||||
background-color: var(--sg-color-textfield-hover-bg);
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']:focus-within,
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data-focus='true'],
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']:focus-visible:not([data-focus='false']) {
|
||||
--t-shadow: none;
|
||||
box-shadow: none;
|
||||
background-color: var(--sg-color-textfield-focus-bg);
|
||||
outline: 1px solid var(--sg-color-textfield-focus-border);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'] input:not(.t-filler) {
|
||||
background: transparent;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']:focus-within input:not(.t-filler),
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data-focus='true'] input:not(.t-filler) {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']:focus-within [tuiLabel],
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data-focus='true'] [tuiLabel] {
|
||||
color: var(--sg-color-textfield-focus-label) !important; /* Taiga: color … !important на [tuiLabel] */
|
||||
}
|
||||
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']:focus-within input:not(.t-filler)::placeholder,
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data-focus='true']
|
||||
input:not(.t-filler)::placeholder {
|
||||
color: var(--sg-color-textfield-focus-label);
|
||||
}
|
||||
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'] textarea {
|
||||
background: transparent;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
color: var(--sg-color-text);
|
||||
}
|
||||
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']:focus-within textarea,
|
||||
tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data-focus='true'] textarea {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* --- нативные поля (datetime-local, text, …) --- */
|
||||
.sg-native-input {
|
||||
box-sizing: border-box;
|
||||
min-block-size: var(--sg-native-input-min-height, 2rem);
|
||||
padding: var(--sg-native-input-padding, 0.35rem 0.5rem);
|
||||
border: 1px solid transparent;
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: -1px;
|
||||
border-radius: var(--sg-textfield-radius, var(--tui-radius-l));
|
||||
background: var(--sg-color-textfield-bg);
|
||||
color: var(--sg-color-text);
|
||||
font: inherit;
|
||||
box-shadow: none;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
outline-color 0.15s ease;
|
||||
}
|
||||
|
||||
.sg-native-input:hover:not(:disabled) {
|
||||
background: var(--sg-color-textfield-hover-bg);
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.sg-native-input:focus {
|
||||
outline: 1px solid var(--sg-color-textfield-focus-border);
|
||||
outline-offset: -1px;
|
||||
background: var(--sg-color-textfield-focus-bg);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.sg-native-input:focus-visible {
|
||||
outline: 1px solid var(--sg-color-textfield-focus-border);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.sg-native-input:disabled {
|
||||
opacity: var(--tui-disabled-opacity);
|
||||
}
|
||||
173
src/styles/shared-components.css
Normal file
173
src/styles/shared-components.css
Normal file
@@ -0,0 +1,173 @@
|
||||
/* ───── Accent CTA button ───── */
|
||||
button.accent-cta[tuiAppearance][data-appearance='primary'] {
|
||||
min-inline-size: var(--sg-primary-action-min-inline-size);
|
||||
--t-bg: var(--sg-color-accent);
|
||||
background: var(--t-bg);
|
||||
border-color: var(--sg-color-accent);
|
||||
color: var(--sg-color-text);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
button.accent-cta[tuiAppearance][data-appearance='primary']:hover:not([data-state='disabled']):not(:disabled) {
|
||||
--t-bg: var(--tui-background-accent-1-hover);
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
|
||||
/* ───── Key-value grid ───── */
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 14rem) 1fr;
|
||||
gap: 0.35rem 1rem;
|
||||
margin: 0;
|
||||
font: var(--tui-font-text-s);
|
||||
}
|
||||
|
||||
.kv dt {
|
||||
margin: 0;
|
||||
color: var(--tui-text-tertiary);
|
||||
}
|
||||
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.kv_compact {
|
||||
grid-template-columns: minmax(4rem, 6rem) 1fr;
|
||||
}
|
||||
|
||||
/* ───── Config grid (metrics cards) ───── */
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--tui-radius-m);
|
||||
border: 1px solid var(--tui-border-normal);
|
||||
background: var(--tui-background-base);
|
||||
}
|
||||
|
||||
.cfg-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cfg-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--tui-text-secondary, var(--tui-text-tertiary));
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.cfg-value {
|
||||
font-weight: 500;
|
||||
color: var(--tui-text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ───── Entity list (generic rows) ───── */
|
||||
.entity-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.entity-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 0;
|
||||
border-bottom: 1px solid var(--tui-border-normal);
|
||||
font: var(--tui-font-text-s);
|
||||
}
|
||||
|
||||
.entity-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.entity-row .meta {
|
||||
margin-left: auto;
|
||||
font: var(--tui-font-text-s);
|
||||
}
|
||||
|
||||
/* ───── Tabs (horizontal scrollable) ───── */
|
||||
.detail-tabs,
|
||||
.dash-tabs,
|
||||
.work-tabs {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1.25rem;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.detail-tabs [tuiTab],
|
||||
.dash-tabs [tuiTab],
|
||||
.work-tabs [tuiTab] {
|
||||
padding-block-end: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-tabs [tuiTab]:hover:not(._active),
|
||||
.dash-tabs [tuiTab]:hover:not(._active),
|
||||
.work-tabs [tuiTab]:hover:not(._active) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ───── Common layout helpers ───── */
|
||||
.heading,
|
||||
[tuiTitle].heading {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--tui-status-negative);
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--tui-border-normal);
|
||||
}
|
||||
|
||||
.danger-link[tuiLink] {
|
||||
color: var(--tui-status-negative) !important;
|
||||
font-weight: 400;
|
||||
text-decoration-color: color-mix(in srgb, var(--tui-status-negative) 30%, transparent);
|
||||
}
|
||||
|
||||
.danger-link[tuiLink]:hover {
|
||||
text-decoration-color: var(--tui-status-negative);
|
||||
}
|
||||
|
||||
/* ───── Edit forms ───── */
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.edit-field {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* Notification alert title font weight */
|
||||
tui-notification-alert [tuiTitle] {
|
||||
font-weight: 500;
|
||||
}
|
||||
Reference in New Issue
Block a user