From 44b80cd4b539e1a296c0079788be02c41d11c1e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BA=D0=B0=D1=8D=D0=BB=20=D0=9E=D0=B3=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D1=8F=D0=BD?= Date: Thu, 16 Apr 2026 03:31:48 +0300 Subject: [PATCH] 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 --- CONVENTIONS.md | 1223 +++++ README.md | 111 +- angular.json | 2 + docs/INTEGRATION.md | 253 + docs/REST-API.md | 236 + docs/swagger.json | 4272 +++++++++++++++++ eslint.config.js | 82 + package-lock.json | 1959 +++++++- package.json | 25 +- public/fonts/TinkoffSans-Bold.ttf | Bin 0 -> 70888 bytes public/fonts/TinkoffSans-Medium.ttf | Bin 0 -> 71272 bytes public/fonts/TinkoffSans-Regular.ttf | Bin 0 -> 70240 bytes public/svg/logo/logo.svg | 16 + public/svg/visual/arrow-keys.svg | 45 + public/svg/visual/keyboard.svg | 462 ++ public/svg/visual/mouse.svg | 19 + src/app/app.config.ts | 19 +- src/app/app.css | 103 + src/app/app.html | 368 +- src/app/app.routes.ts | 67 +- src/app/app.spec.ts | 2 +- src/app/app.ts | 21 +- src/app/core/config/api.tokens.ts | 48 + src/app/core/config/app.tokens.ts | 7 + src/app/core/devtools/dev-log.service.ts | 52 + src/app/core/guards/auth.guard.ts | 11 + src/app/core/http/api-base-url.interceptor.ts | 19 + src/app/core/http/auth.interceptor.ts | 24 + src/app/core/http/dev-log.interceptor.ts | 101 + .../core/http/error-classification.util.ts | 64 + src/app/core/http/http-error.util.ts | 58 + src/app/core/models/api.types.ts | 252 + .../monitoring/audit-resource-type.pipe.ts | 28 + .../user-error-messages.config.ts | 19 + .../user-error-notify.service.ts | 55 + src/app/core/services/auth.service.ts | 54 + src/app/core/services/works-api.service.ts | 286 ++ .../analysis-run-status-chip-classes.pipe.ts | 25 + .../core/works/analysis-run-status.pipe.ts | 21 + .../dashboard/dashboard.component.css | 13 + .../dashboard/dashboard.component.html | 211 + .../features/dashboard/dashboard.component.ts | 216 + .../devtools/dev-console.component.ts | 53 + src/app/features/devtools/dev-console.css | 125 + src/app/features/devtools/dev-console.html | 79 + .../events/event-detail.component.css | 10 + .../events/event-detail.component.html | 134 + .../features/events/event-detail.component.ts | 124 + .../groups/group-detail.component.css | 12 + .../groups/group-detail.component.html | 116 + .../features/groups/group-detail.component.ts | 137 + .../features/landing/landing.component.css | 334 ++ .../features/landing/landing.component.html | 162 + src/app/features/landing/landing.component.ts | 14 + src/app/features/login/login.component.css | 28 + src/app/features/login/login.component.html | 39 + src/app/features/login/login.component.ts | 48 + .../monitoring/monitoring.component.css | 37 + .../monitoring/monitoring.component.html | 76 + .../monitoring/monitoring.component.ts | 73 + .../refset-detail.component.css | 7 + .../refset-detail.component.html | 88 + .../reference-sets/refset-detail.component.ts | 122 + .../students/student-detail.component.css | 1 + .../students/student-detail.component.html | 90 + .../students/student-detail.component.ts | 90 + .../work-detail/work-detail.component.css | 101 + .../work-detail/work-detail.component.html | 247 + .../work-detail/work-detail.component.ts | 293 ++ .../works/works-list/works-list.component.css | 41 + .../works-list/works-list.component.html | 80 + .../works/works-list/works-list.component.ts | 96 + src/app/shared/utils/date-time.util.ts | 50 + src/app/shared/utils/duration.util.ts | 46 + src/app/shared/utils/json.util.ts | 10 + src/app/shared/utils/math.util.ts | 10 + src/app/shared/utils/number.util.ts | 15 + src/environments/environment.prod.ts | 6 + src/environments/environment.ts | 6 + src/index.html | 2 +- src/styles.css | 99 +- src/styles/color-tokens.css | 103 + src/styles/filter-chips.css | 99 + src/styles/page-common.css | 41 + src/styles/session-status-chips.css | 18 + src/styles/sg-input-fields.css | 117 + src/styles/shared-components.css | 173 + 87 files changed, 14046 insertions(+), 455 deletions(-) create mode 100644 CONVENTIONS.md create mode 100644 docs/INTEGRATION.md create mode 100644 docs/REST-API.md create mode 100644 docs/swagger.json create mode 100644 eslint.config.js create mode 100644 public/fonts/TinkoffSans-Bold.ttf create mode 100644 public/fonts/TinkoffSans-Medium.ttf create mode 100644 public/fonts/TinkoffSans-Regular.ttf create mode 100644 public/svg/logo/logo.svg create mode 100644 public/svg/visual/arrow-keys.svg create mode 100644 public/svg/visual/keyboard.svg create mode 100644 public/svg/visual/mouse.svg create mode 100644 src/app/core/config/api.tokens.ts create mode 100644 src/app/core/config/app.tokens.ts create mode 100644 src/app/core/devtools/dev-log.service.ts create mode 100644 src/app/core/guards/auth.guard.ts create mode 100644 src/app/core/http/api-base-url.interceptor.ts create mode 100644 src/app/core/http/auth.interceptor.ts create mode 100644 src/app/core/http/dev-log.interceptor.ts create mode 100644 src/app/core/http/error-classification.util.ts create mode 100644 src/app/core/http/http-error.util.ts create mode 100644 src/app/core/models/api.types.ts create mode 100644 src/app/core/monitoring/audit-resource-type.pipe.ts create mode 100644 src/app/core/notifications/user-error-messages.config.ts create mode 100644 src/app/core/notifications/user-error-notify.service.ts create mode 100644 src/app/core/services/auth.service.ts create mode 100644 src/app/core/services/works-api.service.ts create mode 100644 src/app/core/works/analysis-run-status-chip-classes.pipe.ts create mode 100644 src/app/core/works/analysis-run-status.pipe.ts create mode 100644 src/app/features/dashboard/dashboard.component.css create mode 100644 src/app/features/dashboard/dashboard.component.html create mode 100644 src/app/features/dashboard/dashboard.component.ts create mode 100644 src/app/features/devtools/dev-console.component.ts create mode 100644 src/app/features/devtools/dev-console.css create mode 100644 src/app/features/devtools/dev-console.html create mode 100644 src/app/features/events/event-detail.component.css create mode 100644 src/app/features/events/event-detail.component.html create mode 100644 src/app/features/events/event-detail.component.ts create mode 100644 src/app/features/groups/group-detail.component.css create mode 100644 src/app/features/groups/group-detail.component.html create mode 100644 src/app/features/groups/group-detail.component.ts create mode 100644 src/app/features/landing/landing.component.css create mode 100644 src/app/features/landing/landing.component.html create mode 100644 src/app/features/landing/landing.component.ts create mode 100644 src/app/features/login/login.component.css create mode 100644 src/app/features/login/login.component.html create mode 100644 src/app/features/login/login.component.ts create mode 100644 src/app/features/monitoring/monitoring.component.css create mode 100644 src/app/features/monitoring/monitoring.component.html create mode 100644 src/app/features/monitoring/monitoring.component.ts create mode 100644 src/app/features/reference-sets/refset-detail.component.css create mode 100644 src/app/features/reference-sets/refset-detail.component.html create mode 100644 src/app/features/reference-sets/refset-detail.component.ts create mode 100644 src/app/features/students/student-detail.component.css create mode 100644 src/app/features/students/student-detail.component.html create mode 100644 src/app/features/students/student-detail.component.ts create mode 100644 src/app/features/works/work-detail/work-detail.component.css create mode 100644 src/app/features/works/work-detail/work-detail.component.html create mode 100644 src/app/features/works/work-detail/work-detail.component.ts create mode 100644 src/app/features/works/works-list/works-list.component.css create mode 100644 src/app/features/works/works-list/works-list.component.html create mode 100644 src/app/features/works/works-list/works-list.component.ts create mode 100644 src/app/shared/utils/date-time.util.ts create mode 100644 src/app/shared/utils/duration.util.ts create mode 100644 src/app/shared/utils/json.util.ts create mode 100644 src/app/shared/utils/math.util.ts create mode 100644 src/app/shared/utils/number.util.ts create mode 100644 src/environments/environment.prod.ts create mode 100644 src/environments/environment.ts create mode 100644 src/styles/color-tokens.css create mode 100644 src/styles/filter-chips.css create mode 100644 src/styles/page-common.css create mode 100644 src/styles/session-status-chips.css create mode 100644 src/styles/sg-input-fields.css create mode 100644 src/styles/shared-components.css diff --git a/CONVENTIONS.md b/CONVENTIONS.md new file mode 100644 index 0000000..232557b --- /dev/null +++ b/CONVENTIONS.md @@ -0,0 +1,1223 @@ +# SparkGuardian — Конвенции и правила разработки + +> Этот документ описывает все архитектурные, стилевые и технические решения проекта SparkGuardian. +> Его цель — обеспечить идентичный стиль кода и подход к дизайну в любых связанных проектах. +> **Передайте этот файл AI-ассистенту вместе с задачей**, чтобы новый проект был логическим продолжением SparkGuardian. + +--- + +## 1. Технологический стек + +| Область | Решение | Версия | +|---------|---------|--------| +| Фреймворк | Angular | 21+ | +| Язык | TypeScript | strict mode | +| UI-библиотека | Taiga UI | v5 | +| Видео | hls.js | — | +| Стили | Vanilla CSS + CSS Custom Properties | — | +| Линтинг | ESLint + angular-eslint | — | +| Тесты | Vitest + jsdom | — | +| Анимации | `@angular/animations` | — | +| Шрифт | Tinkoff Sans (фирменный; системный fallback: `system-ui, sans-serif`) | — | + +**Категорически НЕ используется:** TailwindCSS, SCSS/SASS/LESS, NgModules, RxJS-only подход к состоянию (signals предпочтительны). + +--- + +## 2. Архитектура: слоевая модель + +Проект строго разделён на три слоя. Ни один вышестоящий слой не импортирует из нижестоящего (feature не экспортирует наружу, core не импортирует feature). + +``` +src/app/ +├── core/ ← синглтоны, DI-токены, interceptors, сервисы, модели, pipes +│ ├── config/ ← InjectionToken'ы (API_BASE_URL, API_ORIGIN, DEFAULT_PAGE_LIMIT) +│ ├── devtools/ ← DevLogService (ring-buffer 300 записей) +│ ├── http/ ← interceptors (apiBaseUrl, devLog), error-classification, http-error utils +│ ├── models/ ← api.types.ts — ВСЕ TypeScript-интерфейсы API +│ ├── notifications/ ← UserErrorNotifyService, user-error-messages config +│ ├── services/ ← SessionsApiService — единственный API-клиент +│ ├── sessions/ ← pipes, telemetry summary (rule engine) +│ ├── keyboard/ ← VK→SVG mapping, highlight service, transcript util +│ └── mouse/ ← mouse payload parsing, SVG highlight service +│ +├── features/ ← lazy-loaded smart-компоненты (по одному на маршрут) +│ ├── landing/ ← LandingComponent (маркетинговая страница) +│ ├── devtools/ ← DevConsoleComponent (overlay, только isDevMode()) +│ └── sessions/ ← SessionsList, SessionDetail (+4 tab-компонента), +│ HlsPlayer, KeyboardView, MouseView, StreamSelector, +│ TelemetryEventDetail +│ +└── shared/ ← чистые pure-функции, НИКАКИХ Angular-зависимостей + └── utils/ ← date-time, duration, json, math, number, stream, + telemetry-summary-human-text +``` + +### Правила файловой организации + +- **Один компонент = одна директория**: `feature-name/feature-name.component.ts`, `feature-name.html`, `feature-name.css`. +- **Имена файлов**: исключительно `kebab-case`. +- **Селекторы компонентов**: `app-` (e.g., `app-session-detail`). +- **Классы компонентов**: `PascalCase` + суффикс `Component` (e.g., `SessionDetailComponent`). +- **Pipes**: суффикс `Pipe` (e.g., `SessionStatusPipe`). +- **Сервисы**: суффикс `Service` (e.g., `SessionsApiService`). +- **Утилиты**: суффикс `.util.ts` (e.g., `date-time.util.ts`). +- **Конфиги**: суффикс `.config.ts` (e.g., `user-error-messages.config.ts`). +- **Типы**: суффикс `.types.ts` (e.g., `api.types.ts`). + +--- + +## 3. Angular-паттерны + +### 3.1 Компоненты + +Каждый компонент **обязан** соответствовать: + +```typescript +@Component({ + selector: 'app-my-component', + imports: [/* только то, что используется в шаблоне */], + templateUrl: './my-component.html', // отдельный файл, НЕ inline template + styleUrl: './my-component.css', // singular (Angular 21 syntax), НЕ styleUrls + changeDetection: ChangeDetectionStrategy.OnPush, // ОБЯЗАТЕЛЬНО +}) +export class MyComponent { + // DI через inject(), НЕ constructor injection + private readonly api = inject(SessionsApiService); + private readonly userErrors = inject(UserErrorNotifyService); + + // Inputs/Outputs через signal-based API (Angular 17+) + readonly sessionId = input.required(); + readonly excludeMouseMoves = model(true); + readonly telemetryToMsChange = output(); + + // Локальный state через signal() + protected readonly isLoading = signal(false); + + // Вычисляемые значения через computed() + protected readonly totalPages = computed(() => + Math.ceil(this.total() / this.limit), + ); +} +``` + +**Запрещено:** +- `@Input()` / `@Output()` декораторы — используй `input()` / `output()` / `model()`. +- `ChangeDetectionStrategy.Default` — всегда `OnPush`. +- constructor injection — всегда `inject()`. +- `styleUrls: [...]` (plural) — всегда `styleUrl: '...'` (singular, Angular 21+). +- Inline `template:` и `styles:` (кроме крошечных компонентов < 15 строк шаблона). + +### 3.2 Маршрутизация + +Все feature-компоненты загружаются **лениво** через `loadComponent`: + +```typescript +export const routes: Routes = [ + { + path: 'sessions', + loadComponent: () => + import('./features/sessions/sessions-list/sessions-list.component') + .then((m) => m.SessionsListComponent), + }, + { path: '**', redirectTo: '' }, +]; +``` + +### 3.3 Тяжёлые вложенные компоненты: `@defer` + +Если одновременно видна только одна вкладка/секция — оборачивай в `@defer`: + +```html +@case (1) { + @defer { + + } @placeholder { +
+ +
+ } +} +``` + +### 3.4 Signals vs RxJS + +| Задача | Инструмент | +|--------|-----------| +| Локальный UI state (boolean, number, string) | `signal()` | +| Derived/вычисляемые данные | `computed()` | +| Two-way binding | `model()` | +| HTTP-запросы | RxJS `Observable` → `async` pipe | +| Подписки в компоненте | `toObservable()` + `switchMap/combineLatest` | +| Lifecycle-cleanup | `effect(onCleanup)` | + +**Паттерн загрузки данных** — единая цепочка с тремя статусами: + +```typescript +protected readonly data$ = toObservable(this.sessionId).pipe( + switchMap((id) => + this.api.getData(id).pipe( + map((data) => ({ status: 'ok' as const, data })), + catchError((e: HttpErrorResponse) => { + this.userErrors.notifyError(e, 'Контекст ошибки'); + return of({ status: 'error' as const }); + }), + startWith({ status: 'loading' as const }), + ), + ), +); +``` + +В шаблоне: +```html +@if (data$ | async; as state) { + @switch (state.status) { + @case ('loading') { } + @case ('error') {

Не удалось загрузить данные.

} + @case ('ok') { /* контент --> } + } +} +``` + +### 3.5 DI-токены + +Конфигурации выносятся в `InjectionToken` с `factory`, а НЕ читаются напрямую из `environment`: + +```typescript +// core/config/api.tokens.ts +export const API_BASE_URL = new InjectionToken('API_BASE_URL', { + factory: () => { + const doc = inject(DOCUMENT); + const loc = doc.defaultView?.location; + if (loc?.protocol === 'file:') { + return `${environment.apiFallbackOrigin}${environment.apiBasePath}`; + } + return environment.apiBasePath; + }, +}); +``` + +Это обеспечивает тестируемость (можно подменить токен без мока `environment`) и поддержку `file://` протокола. + +### 3.6 Interceptors + +Два функциональных interceptor'а (Angular 21 style): + +1. **`apiBaseUrlInterceptor`** — добавляет `API_BASE_URL` ко всем запросам, кроме: + - абсолютных URL (`https://...`) + - статических ассетов (`/svg/`, `/fonts/`, `/images/`) + +2. **`devLogInterceptor`** — логирует **все** HTTP-запросы в `DevLogService` (только `isDevMode()`). + +Регистрация в `app.config.ts`: +```typescript +provideHttpClient(withInterceptors([apiBaseUrlInterceptor, devLogInterceptor])), +``` + +### 3.7 Обработка ошибок + +Полный pipeline: + +``` +HttpErrorResponse + └→ classifyUserError(err): UserErrorKind + └→ friendlyMessageForUserError(kind): string для пользователя + └→ UserErrorNotifyService.notifyError() → TuiNotificationService +``` + +Классификация по HTTP-кодам и паттернам: + +| Код/паттерн | UserErrorKind | +|-------------|---------------| +| `status === 0` | `network` | +| `status === 401` | `unauthorized` | +| `status === 403` | `forbidden` | +| `status === 404` | `not_found` | +| `status >= 500` | `server_error` | +| `TimeoutError` | `timeout` | +| `'Http failure during parsing'` | `parse_error` | + +Дружелюбные сообщения определяются в `user-error-messages.config.ts`. + +### 3.8 Pipes + +Standalone pipes для трансформации данных в шаблонах: + +```typescript +@Pipe({ + name: 'sessionStatus', + standalone: true, +}) +export class SessionStatusPipe implements PipeTransform { + private readonly devLog = inject(DevLogService); + + transform(value: string | null | undefined): string { + // ... маппинг + warn в DevLog для неизвестных значений + } +} +``` + +Pipes **логируют** неизвестные значения в `DevLogService` (только devMode), чтобы разработчик заметил незнакомые статусы/типы. + +--- + +## 4. Дизайн-система и CSS + +### 4.1 Принцип: CSS Custom Properties + Taiga UI overrides + +Все цвета, отступы и параметры анимаций **централизованы** в глобальном файле `src/styles/color-tokens.css`. + +```css +:root { + /* SparkGuardian проектные токены */ + --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-color-card-bg: #ffffff; + --sg-color-border: color-mix(in srgb, var(--sg-color-text) 12%, transparent); + --sg-color-danger: #d92d20; + + /* Layout */ + --sg-content-max-width: 1104px; + --sg-page-padding-inline: 1rem; + + /* Taiga UI overrides – переопределяем цвета дизайн-системы */ + --tui-background-accent-1: var(--sg-color-accent); + --tui-background-base: var(--sg-color-bg); + --tui-background-elevation-1: var(--sg-color-card-bg); + --tui-text-primary: var(--sg-color-text); + --tui-border-normal: var(--sg-color-border); + --tui-focus: var(--sg-color-accent); +} +``` + +### 4.2 Naming convention для токенов + +| Prefix | Назначение | Пример | +|--------|-----------|--------| +| `--sg-color-*` | Базовые цвета проекта | `--sg-color-accent` | +| `--sg-filter-chip-*` | Чипы фильтров | `--sg-filter-chip-active-bg` | +| `--sg-session-status-*` | Раскраска статусов сессий | `--sg-session-status-active-fg` | +| `--sg-keyboard-*` | Визуализация клавиатуры | `--sg-keyboard-key-pressed-fill` | +| `--sg-input-highlight-*` | Анимации подсветки | `--sg-input-highlight-duration` | +| `--sg-textfield-*` | Текстовые поля | `--sg-textfield-radius` | +| `--sg-native-input-*` | Нативные `` | `--sg-native-input-min-height` | +| `--tui-*` | Переопределения Taiga UI | `--tui-background-accent-1` | + +### 4.3 Правила работы со стилями + +1. **Никогда не используй захардкоженных цветов** в компонентных CSS. Всё через `var(--sg-*)` или `var(--tui-*)`. +2. **Не дублируй глобальные классы** в компонентных стилях. Глобальные `.muted`, `.small`, `.mono`, `.section-title`, `.card`, `.loading-wrap` определены в `page-common.css` и доступны везде. +3. **`color-mix()`** — основной инструмент для производных цветов (hover, faded, border). +4. **Кнопки Taiga UI кастомизируются** через CSS-селекторы с `[tuiButton][tuiAppearance][data-appearance='secondary']`. +5. **Текстовые поля** стилизуются через два паттерна: + - Taiga `tui-textfield` → класс `.sg-tui-textfield` + - Нативный `` → класс `.sg-native-input` +6. **Focus-visible vs focus** — при клике снимаем outline/box-shadow; ring только для keyboard navigation. + +### 4.4 Глобальные CSS-файлы + +| Файл | Назначение | +|------|-----------| +| `color-tokens.css` | Все CSS-переменные и Taiga overrides | +| `page-common.css` | `.card`, `.section-title`, `.loading-wrap`, `.muted`, `.small`, `.mono`, `.page` | +| `sg-input-fields.css` | Стилизация `tui-textfield` и нативных `` | +| `filter-chips.css` | Стилизация toggle-кнопок / чипов фильтрации | +| `session-status-chips.css` | Раскраска `[tuiChip]` по статусам сессии | + +### 4.5 Layout + +```css +/* Используется для ограничения ширины контента */ +.sg-content-column { + max-width: var(--sg-content-max-width); /* 1104px */ + margin-inline: auto; + padding-inline: var(--sg-page-padding-inline); /* 1rem / 48px на мобильных */ +} +``` + +Все page-уровневые контейнеры добавляют класс `.page .sg-content-column`. + +### 4.6 Шрифт + +Шрифт Tinkoff Sans подключается глобально. В CSS используется `font-family: 'Tinkoff Sans', system-ui, sans-serif`. Для кода/моноширинных блоков: `font-family: ui-monospace, monospace`. + +--- + +## 5. Taiga UI: правила интеграции + +### 5.1 Инициализация + +```typescript +// app.config.ts +providers: [ + provideAnimations(), + provideTaiga(), + tuiNotificationOptionsProvider(() => ({ + block: 'start', + inline: 'end', + })), +] +``` + +Корневой шаблон оборачивается в ``: +```html + +
+
...
+
+
+ @if (isDev) { } +
+``` + +### 5.2 Используемые компоненты Taiga UI + +| Компонент | Импорт | Назначение | +|-----------|--------|-----------| +| `TuiButton` | `@taiga-ui/core/components/button` | Primary/secondary кнопки | +| `TuiLoader` | `@taiga-ui/core/components/loader` | Индикаторы загрузки | +| `TuiLink` | `@taiga-ui/core/components/link` | Навигационные ссылки | +| `TuiTitle` | `@taiga-ui/core/components/title` | Заголовки с семантикой | +| `TuiTabs` | `@taiga-ui/core/directives/tabs` | Вкладки | +| `TuiIcon` | `@taiga-ui/core/components/icon` | Иконки | +| `TuiInput` | `@taiga-ui/core/components/input` | Текстовые поля (spread: `...TuiInput`) | +| `TuiChip` | `@taiga-ui/kit/components/chip` | Чипы/бейджи | +| `TuiPagination` | `@taiga-ui/kit/components/pagination` | Пагинация | +| `TuiAccordion` | `@taiga-ui/kit` | Аккордеоны | +| `TuiNotificationService` | `@taiga-ui/core/components/notification` | Всплывающие уведомления | + +### 5.3 Кастомизация Taiga-компонентов + +**Не меняй глобальные стили Taiga** напрямую. Вместо этого: +1. Переопредели `--tui-*` переменные в `color-tokens.css`. +2. Используй CSS-селекторы с `[tuiAppearance][data-appearance='...']` для точечных кастомизаций. +3. Добавляй собственные CSS-классы (`.sg-tui-textfield`, `.stream-active`) для стилевых модификаторов. + +--- + +## 6. TypeScript: строгость и конвенции + +### 6.1 tsconfig + +```json +{ + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} +``` + +### 6.2 ESLint ключевые правила + +- `no-console: ['warn', { allow: ['warn', 'error'] }]` — нет `console.log` в продакшне. +- `no-alert: 'error'`, `no-debugger: 'error'` — безопасность. +- Компоненты: prefix `app-`, kebab-case. +- Директивы: prefix `app`, camelCase. +- `@angular-eslint/template/no-call-expression: 'off'` — мы вызываем методы в шаблонах (computed → formatDate и т.д.). + +### 6.3 Правила кода + +- **`@ts-ignore` запрещён**. Используй `keyof`, `Record`, type assertions. +- **Неиспользуемые импорты** удаляются немедленно. +- **Falsy checks** (`!value`) — НЕ использовать для числовых значений, которые могут быть `0`. Используй `value == null`. +- **Track expressions** в `@for`: использовать стабильные ключи (`track item.id` или `track item.timestamp + '_' + item.type`), а НЕ `track $index`. +- **Pure utility функции** (без Angular-зависимости) → `shared/utils/`. Никогда не дублировать: вынести в `shared`. + +--- + +## 7. Паттерны проектирования + +### 7.1 Rule-based Engine (расширяемая обработка) + +Вместо switch/case используется массив правил: + +```typescript +// types +export interface TelemetrySummaryRule { + readonly id: string; + readonly match: (o: Record) => boolean; + readonly summarize: (o: Record) => string; +} + +// config — просто массив, легко расширить добавлением одного элемента +export const TELEMETRY_SUMMARY_RULES: readonly TelemetrySummaryRule[] = [ + mouseClickRule, + mouseMoveRule, + keyboardKeyRule, +]; + +// engine — итерирует и применяет первое совпавшее правило +export function summarizeTelemetryData(data: unknown): string { + const raw = unwrapJsonPayload(data); + const o = raw as Record; + for (const rule of TELEMETRY_SUMMARY_RULES) { + if (rule.match(o)) { + return rule.summarize(o); + } + } + return fallbackCompactJson(o); +} +``` + +### 7.2 Classification Pipeline (обработка ошибок) + +``` +Raw Error → classify(err) → UserErrorKind → friendlyMessage(kind) → UI Notification +``` + +Разделяет три ответственности: +1. **Классификатор** (`error-classification.util.ts`) — чистая функция, тестируемая. +2. **Словарь сообщений** (`user-error-messages.config.ts`) — `Record`. +3. **Сервис уведомления** (`UserErrorNotifyService`) — injectable, использует Taiga UI. + +### 7.3 DevTools с ring-buffer + +```typescript +@Injectable({ providedIn: 'root' }) +export class DevLogService { + private seq = 0; + private readonly max = 300; + readonly entries = signal([]); + + add(entry: Omit): void { + const withMeta: DevLogEntry = { + id: ++this.seq, + time: new Date().toISOString(), + ...entry, + }; + this.entries.update((curr) => [...curr.slice(-(this.max - 1)), withMeta]); + } +} +``` + +Всё логирование за `isDevMode()` → нулевой overhead в продакшне. + +### 7.4 SVG-инъекция (работа с графикой) + +Для динамической подсветки элементов SVG: +1. Загружаем SVG через `HttpClient` (responseType: `text`). +2. Кэшируем через `shareReplay({ bufferSize: 1, refCount: false })`. +3. Инжектируем ` + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/svg/visual/keyboard.svg b/public/svg/visual/keyboard.svg new file mode 100644 index 0000000..00a97b7 --- /dev/null +++ b/public/svg/visual/keyboard.svg @@ -0,0 +1,462 @@ + + + + + + + + + + + image/svg+xml + + + Meta (⌘) and Menu: Lucide icons (https://lucide.dev), ISC License, © Lucide Contributors. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Q + W + E + R + T + Y + U + I + O + P + + A + S + D + F + G + H + J + K + L + + Z + X + C + V + B + N + M + + + й + ц + у + к + е + н + г + ш + щ + з + + ф + ы + в + а + п + р + о + л + д + + я + ч + с + м + и + т + ь + + + + + ` + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 0 + - + = + + [ + ] + \ + + ; + ' + + , + . + / + + + + ~ + ! + @ + # + $ + % + ^ + & + + ( + ) + _ + + + + { + } + | + + : + " + + < + > + ? + + + ё + ! + " + + ; + % + : + ? + * + ( + ) + - + + + + х + ъ + / + + ж + э + + б + ю + . + + + + ctrl + alt + alt + ctrl + + + + + + delete + + tab + + return + + caps lock + + shift + + shift + + + + diff --git a/public/svg/visual/mouse.svg b/public/svg/visual/mouse.svg new file mode 100644 index 0000000..4340778 --- /dev/null +++ b/public/svg/visual/mouse.svg @@ -0,0 +1,19 @@ + + + + + + +ЛКМ +ПКМ + diff --git a/src/app/app.config.ts b/src/app/app.config.ts index cb1270e..9211891 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -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), + ], }; diff --git a/src/app/app.css b/src/app/app.css index e69de29..3ee1ad0 100644 --- a/src/app/app.css +++ b/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); +} diff --git a/src/app/app.html b/src/app/app.html index a1c4296..9cb9133 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,344 +1,32 @@ - - - - - - - - - - - -
-
-
- -

Hello, {{ title() }}

-

Congratulations! Your app is running. 🎉

-
- -
-
- @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) { - - {{ item.title }} - - - - + +
+
+
+ + + ANTIPLAGIAT + + + @if (auth.isAuthenticated()) { +
+ @if (auth.user(); as user) { + + } + Выйти +
}
- -
+ +
+ +
-
+ @if (isDev) { + + } +
- - - - - - - - - - diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index dc39edb..300e5c0 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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: '' }, +]; diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 1d5037a..3dcd7cf 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -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'); }); }); diff --git a/src/app/app.ts b/src/app/app.ts index 33b6927..94547bb 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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'); + } } diff --git a/src/app/core/config/api.tokens.ts b/src/app/core/config/api.tokens.ts new file mode 100644 index 0000000..cacea5d --- /dev/null +++ b/src/app/core/config/api.tokens.ts @@ -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('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('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; + }, +}); + diff --git a/src/app/core/config/app.tokens.ts b/src/app/core/config/app.tokens.ts new file mode 100644 index 0000000..480ecac --- /dev/null +++ b/src/app/core/config/app.tokens.ts @@ -0,0 +1,7 @@ +import { InjectionToken } from '@angular/core'; + +import { environment } from '../../../environments/environment'; + +export const DEFAULT_PAGE_LIMIT = new InjectionToken('DEFAULT_PAGE_LIMIT', { + factory: () => environment.defaultPageLimit, +}); diff --git a/src/app/core/devtools/dev-log.service.ts b/src/app/core/devtools/dev-log.service.ts new file mode 100644 index 0000000..9642894 --- /dev/null +++ b/src/app/core/devtools/dev-log.service.ts @@ -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; + requestBody?: unknown; + statusCode?: number; + durationMs?: number; + responseHeaders?: Record; + 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([]); + + add(entry: Omit): 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>): void { + this.entries.update((curr) => + curr.map((item) => (item.id === id ? { ...item, ...patch } : item)), + ); + } + + clear(): void { + this.entries.set([]); + } +} diff --git a/src/app/core/guards/auth.guard.ts b/src/app/core/guards/auth.guard.ts new file mode 100644 index 0000000..1d4ec36 --- /dev/null +++ b/src/app/core/guards/auth.guard.ts @@ -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']); +}; diff --git a/src/app/core/http/api-base-url.interceptor.ts b/src/app/core/http/api-base-url.interceptor.ts new file mode 100644 index 0000000..14358de --- /dev/null +++ b/src/app/core/http/api-base-url.interceptor.ts @@ -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}` })); +}; diff --git a/src/app/core/http/auth.interceptor.ts b/src/app/core/http/auth.interceptor.ts new file mode 100644 index 0000000..414e39a --- /dev/null +++ b/src/app/core/http/auth.interceptor.ts @@ -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}`, + }, + }), + ); +}; + diff --git a/src/app/core/http/dev-log.interceptor.ts b/src/app/core/http/dev-log.interceptor.ts new file mode 100644 index 0000000..4c91cfc --- /dev/null +++ b/src/app/core/http/dev-log.interceptor.ts @@ -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 { + const out: Record = {}; + 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 = {}; + 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) => { + 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), + }, + }); + } + }, + }), + ); +}; diff --git a/src/app/core/http/error-classification.util.ts b/src/app/core/http/error-classification.util.ts new file mode 100644 index 0000000..665934f --- /dev/null +++ b/src/app/core/http/error-classification.util.ts @@ -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'; +} diff --git a/src/app/core/http/http-error.util.ts b/src/app/core/http/http-error.util.ts new file mode 100644 index 0000000..62b154a --- /dev/null +++ b/src/app/core/http/http-error.util.ts @@ -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, '''); +} + +function summarizeHtmlError(text: string): string | null { + const t = text.trim(); + if (!t.startsWith(']*>([\s\S]*?)<\/pre>/i); + if (pre?.[1]) { + return pre[1].trim(); + } + const title = t.match(/]*>([\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 'Неизвестная ошибка'; +} diff --git a/src/app/core/models/api.types.ts b/src/app/core/models/api.types.ts new file mode 100644 index 0000000..b6fd309 --- /dev/null +++ b/src/app/core/models/api.types.ts @@ -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; +} diff --git a/src/app/core/monitoring/audit-resource-type.pipe.ts b/src/app/core/monitoring/audit-resource-type.pipe.ts new file mode 100644 index 0000000..416c37e --- /dev/null +++ b/src/app/core/monitoring/audit-resource-type.pipe.ts @@ -0,0 +1,28 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +const TRANSLATIONS: Record = { + 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; + } +} diff --git a/src/app/core/notifications/user-error-messages.config.ts b/src/app/core/notifications/user-error-messages.config.ts new file mode 100644 index 0000000..6bd36fc --- /dev/null +++ b/src/app/core/notifications/user-error-messages.config.ts @@ -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]; +} diff --git a/src/app/core/notifications/user-error-notify.service.ts b/src/app/core/notifications/user-error-notify.service.ts new file mode 100644 index 0000000..26ef277 --- /dev/null +++ b/src/app/core/notifications/user-error-notify.service.ts @@ -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(); + } +} diff --git a/src/app/core/services/auth.service.ts b/src/app/core/services/auth.service.ts new file mode 100644 index 0000000..2a87161 --- /dev/null +++ b/src/app/core/services/auth.service.ts @@ -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(localStorage.getItem(TOKEN_KEY)); + readonly user = signal(this.readUserFromStorage()); + readonly isAuthenticated = computed(() => this.token() !== null); + + login(payload: LoginRequest): Observable { + return this.http + .post('/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 { + return this.http.get('/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; + } + } +} diff --git a/src/app/core/services/works-api.service.ts b/src/app/core/services/works-api.service.ts new file mode 100644 index 0000000..42d2ca9 --- /dev/null +++ b/src/app/core/services/works-api.service.ts @@ -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 { + return this.http.get('/works'); + } + + createWork(payload: CreateWorkRequest): Observable { + return this.http.post('/works', payload); + } + + getWork(workId: number): Observable { + return this.http.get(`/works/${workId}`); + } + + updateWork(workId: number, payload: EditWorkRequest): Observable { + return this.http.put(`/works/${workId}`, payload); + } + + deleteWork(workId: number): Observable { + return this.http.delete(`/works/${workId}`); + } + + uploadArchive(workId: number, file: File): Observable { + const data = new FormData(); + data.append('file', file); + return this.http.put(`/works/${workId}/archive`, data); + } + + getWorkArchive(workId: number): Observable { + return this.http.get(`/works/${workId}/archive`, { responseType: 'blob' }); + } + + runCheck(workId: number): Observable { + return this.http.post(`/works/${workId}/check`, {}); + } + + getWorkSummary(workId: number): Observable { + return this.http.get(`/works/${workId}/summary`); + } + + listWorkRuns(workId: number): Observable { + return this.http.get(`/works/${workId}/analysis-runs`); + } + + getWorkAdoptions(workId: number): Observable { + return this.http.get(`/works/${workId}/adoptions`); + } + + getWorkAdoptionsArchive(workId: number): Observable { + return this.http.get(`/works/${workId}/adoptions/archive`, { responseType: 'blob' }); + } + + getWorkAdoptionsRelated(workId: number): Observable { + return this.http.get(`/works/${workId}/adoptions/related`); + } + + /* ── Analysis Runs ── */ + + getAnalysisRun(runId: string): Observable { + return this.http.get(`/analysis-runs/${runId}`); + } + + getRunAdoptions(runId: string): Observable { + return this.http.get(`/analysis-runs/${runId}/adoptions`); + } + + getRunChunks(runId: string): Observable { + return this.http.get(`/analysis-runs/${runId}/chunks`); + } + + retryAnalysisRun(runId: string): Observable { + return this.http.post(`/analysis-runs/${runId}/retry`, {}); + } + + downloadReport(runId: string, format: 'json' | 'html' | 'pdf'): Observable { + return this.http.get(`/analysis-runs/${runId}/report.${format}`, { responseType: 'blob' }); + } + + downloadRawReport(runId: string): Observable { + return this.http.get(`/analysis-runs/${runId}/report.raw.json`, { responseType: 'blob' }); + } + + /* ── Adoptions ── */ + + getAdoptionSegment(adoptionId: number): Observable { + return this.http.get(`/adoptions/${adoptionId}/segment`, { responseType: 'text' }); + } + + /* ── Students ── */ + + listStudents(): Observable { + return this.http.get('/students'); + } + + createStudent(payload: { readonly name: string; readonly email: string; readonly user_id?: number }): Observable { + return this.http.post('/students', payload); + } + + getStudent(id: number): Observable { + return this.http.get(`/students/${id}`); + } + + updateStudent(id: number, payload: EditStudentRequest): Observable { + return this.http.patch(`/students/${id}`, payload); + } + + deleteStudent(id: number): Observable { + return this.http.delete(`/students/${id}`); + } + + getStudentStats(id: number): Observable { + return this.http.get(`/students/${id}/stats`); + } + + /* ── Groups ── */ + + listGroups(): Observable { + return this.http.get('/groups'); + } + + createGroup(payload: { readonly name: string }): Observable { + return this.http.post('/groups', payload); + } + + getGroup(id: number): Observable { + return this.http.get(`/groups/${id}`); + } + + updateGroup(id: number, payload: EditGroupRequest): Observable { + return this.http.patch(`/groups/${id}`, payload); + } + + deleteGroup(id: number): Observable { + return this.http.delete(`/groups/${id}`); + } + + getGroupStats(id: number): Observable { + return this.http.get(`/groups/${id}/stats`); + } + + addStudentToGroup(groupId: number, studentId: number): Observable { + return this.http.post(`/groups/${groupId}/students/${studentId}`, {}); + } + + removeStudentFromGroup(groupId: number, studentId: number): Observable { + return this.http.delete(`/groups/${groupId}/students/${studentId}`); + } + + addUserToGroup(groupId: number, userId: number): Observable { + return this.http.post(`/groups/${groupId}/users/${userId}`, {}); + } + + removeUserFromGroup(groupId: number, userId: number): Observable { + return this.http.delete(`/groups/${groupId}/users/${userId}`); + } + + /* ── Events ── */ + + listEvents(): Observable { + return this.http.get('/events'); + } + + createEvent(payload: { readonly name: string; readonly description?: string; readonly group_id: number; readonly date: string }): Observable { + return this.http.post('/events', payload); + } + + getEvent(id: number): Observable { + return this.http.get(`/events/${id}`); + } + + updateEvent(id: number, payload: EditEventRequest): Observable { + return this.http.patch(`/events/${id}`, payload); + } + + deleteEvent(id: number): Observable { + return this.http.delete(`/events/${id}`); + } + + getEventSummary(id: number): Observable { + return this.http.get(`/events/${id}/summary`); + } + + getEventWorks(id: number): Observable { + return this.http.get(`/events/${id}/works`); + } + + /* ── Reference Sets ── */ + + listReferenceSets(): Observable { + return this.http.get('/reference-sets'); + } + + createReferenceSet(payload: { readonly name: string; readonly kind: string; readonly description?: string }): Observable { + return this.http.post('/reference-sets', payload); + } + + getRefSet(id: number): Observable { + return this.http.get(`/reference-sets/${id}`); + } + + updateRefSet(id: number, payload: EditRefSetRequest): Observable { + return this.http.patch(`/reference-sets/${id}`, payload); + } + + deleteRefSet(id: number): Observable { + return this.http.delete(`/reference-sets/${id}`); + } + + listIngestions(refSetId: number): Observable { + return this.http.get(`/reference-sets/${refSetId}/ingestions`); + } + + createIngestion(refSetId: number, file: File): Observable { + const data = new FormData(); + data.append('file', file); + return this.http.post(`/reference-sets/${refSetId}/ingestions`, data); + } + + /* ── Users ── */ + + listUsers(): Observable { + return this.http.get('/users'); + } + + getUser(id: number): Observable { + return this.http.get(`/users/${id}`); + } + + createUser(payload: CreateUserRequest): Observable { + return this.http.post('/users', payload); + } + + /* ── Audit ── */ + + listAuditLogs(params?: { + actor_user_id?: number; + action?: string; + resource_type?: string; + resource_id?: string; + source?: string; + limit?: number; + }): Observable { + 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('/audit-logs', { params: httpParams }); + } +} diff --git a/src/app/core/works/analysis-run-status-chip-classes.pipe.ts b/src/app/core/works/analysis-run-status-chip-classes.pipe.ts new file mode 100644 index 0000000..cd2b600 --- /dev/null +++ b/src/app/core/works/analysis-run-status-chip-classes.pipe.ts @@ -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 ''; + } + } +} diff --git a/src/app/core/works/analysis-run-status.pipe.ts b/src/app/core/works/analysis-run-status.pipe.ts new file mode 100644 index 0000000..394bf64 --- /dev/null +++ b/src/app/core/works/analysis-run-status.pipe.ts @@ -0,0 +1,21 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import type { AnalysisRunStatus } from '../models/api.types'; + +const TRANSLATIONS: Record = { + 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; + } +} diff --git a/src/app/features/dashboard/dashboard.component.css b/src/app/features/dashboard/dashboard.component.css new file mode 100644 index 0000000..0af909d --- /dev/null +++ b/src/app/features/dashboard/dashboard.component.css @@ -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; +} diff --git a/src/app/features/dashboard/dashboard.component.html b/src/app/features/dashboard/dashboard.component.html new file mode 100644 index 0000000..0536e36 --- /dev/null +++ b/src/app/features/dashboard/dashboard.component.html @@ -0,0 +1,211 @@ +
+

Управление сущностями

+ + + + + + + + + + @switch (activeTabIndex()) { + @case (0) { +
+

Новая работа

+
+ + + + + + + +
+
+ + @if (worksState$ | async; as state) { + @switch (state.status) { + @case ('loading') { +
+ } + @case ('error') { +

Ошибка загрузки работ.

+ } + @case ('ok') { +
+ @if (state.items.length === 0) { +

Работ пока нет.

+ } @else { +
    + @for (work of state.items; track work.id) { +
  • + + Работа {{ work.id }} + + {{ work.archive_object_key ? 'Архив есть' : 'Без архива' }} + student={{ work.student_id }}, event={{ work.event_id }} +
  • + } +
+ } +
+ } + } + } + } + + @case (1) { + @defer { +
+

Новый ивент

+
+ + + + + + + +
+
+ + @if (eventsState$ | async; as state) { + @switch (state.status) { + @case ('loading') {
} + @case ('error') {

Ошибка загрузки ивентов.

} + @case ('ok') { +
+
    + @for (event of state.items; track event.id) { +
  • + {{ event.name }} + #{{ event.id }} + group={{ event.group_id }}, date={{ formatDate(event.date) }} +
  • + } +
+
+ } + } + } + } @placeholder { +
+ } + } + + @case (2) { + @defer { +
+

Новый студент

+
+ + + + + + + +
+
+ + @if (studentsState$ | async; as state) { + @switch (state.status) { + @case ('loading') {
} + @case ('error') {

Ошибка загрузки студентов.

} + @case ('ok') { +
+
    + @for (student of state.items; track student.id) { +
  • + {{ student.name }} + #{{ student.id }} + {{ student.email }} +
  • + } +
+
+ } + } + } + } @placeholder { +
+ } + } + + @case (3) { + @defer { +
+

Новая группа

+
+ + + + +
+
+ + @if (groupsState$ | async; as state) { + @switch (state.status) { + @case ('loading') {
} + @case ('error') {

Ошибка загрузки групп.

} + @case ('ok') { +
+
    + @for (group of state.items; track group.id) { +
  • + {{ group.name }} + #{{ group.id }} + students={{ group.students?.length ?? 0 }}, users={{ group.users?.length ?? 0 }} +
  • + } +
+
+ } + } + } + } @placeholder { +
+ } + } + + @case (4) { + @defer { +
+

Новый reference set

+
+ + + + + + + +
+
+ + @if (refsState$ | async; as state) { + @switch (state.status) { + @case ('loading') {
} + @case ('error') {

Ошибка загрузки reference sets.

} + @case ('ok') { +
+
    + @for (ref of state.items; track ref.id) { +
  • + {{ ref.name }} + {{ ref.kind }} + {{ ref.description ?? '—' }} +
  • + } +
+
+ } + } + } + } @placeholder { +
+ } + } + } +
diff --git a/src/app/features/dashboard/dashboard.component.ts b/src/app/features/dashboard/dashboard.component.ts new file mode 100644 index 0000000..daf106d --- /dev/null +++ b/src/app/features/dashboard/dashboard.component.ts @@ -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(null); + protected readonly createWorkEventId = signal(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): 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); + } +} diff --git a/src/app/features/devtools/dev-console.component.ts b/src/app/features/devtools/dev-console.component.ts new file mode 100644 index 0000000..7333a5c --- /dev/null +++ b/src/app/features/devtools/dev-console.component.ts @@ -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>({}); + + 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); } + } +} diff --git a/src/app/features/devtools/dev-console.css b/src/app/features/devtools/dev-console.css new file mode 100644 index 0000000..c9da87e --- /dev/null +++ b/src/app/features/devtools/dev-console.css @@ -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; +} diff --git a/src/app/features/devtools/dev-console.html b/src/app/features/devtools/dev-console.html new file mode 100644 index 0000000..fa8d0ef --- /dev/null +++ b/src/app/features/devtools/dev-console.html @@ -0,0 +1,79 @@ +@if (isDev) { + @if (minimized()) { + + } @else { + + } +} diff --git a/src/app/features/events/event-detail.component.css b/src/app/features/events/event-detail.component.css new file mode 100644 index 0000000..3198e28 --- /dev/null +++ b/src/app/features/events/event-detail.component.css @@ -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; +} diff --git a/src/app/features/events/event-detail.component.html b/src/app/features/events/event-detail.component.html new file mode 100644 index 0000000..1a164fe --- /dev/null +++ b/src/app/features/events/event-detail.component.html @@ -0,0 +1,134 @@ +
+ + + @if (eventState$ | async; as state) { + @switch (state.status) { + @case ('loading') { +
+ } + @case ('error') { +

Не удалось загрузить мероприятие.

+ } + @case ('ok') { +

{{ state.event.name }}

+ + + + + + + + + @switch (activeTabIndex()) { + @case (0) { +
+

Сведения о мероприятии

+
+
ID
+
{{ state.event.id }}
+
Название
+
{{ state.event.name }}
+
Описание
+
{{ state.event.description ?? '—' }}
+
Группа
+
{{ state.event.group_id }}
+
Дата загрузки
+
{{ formatDate(state.event.date) }}
+
+
+ } + + @case (1) { +
+

Работы мероприятия

+ @if (worksState$ | async; as ws) { + @switch (ws.status) { + @case ('loading') { } + @case ('error') {

Ошибка загрузки.

} + @case ('ok') { + @if (ws.works.length === 0) { +

Работ нет.

+ } @else { +
    + @for (w of ws.works; track w.id) { +
  • + Работа {{ w.id }} + student={{ w.student_id }}, {{ formatDate(w.time) }} +
  • + } +
+ } + } + } + } +
+ } + + @case (2) { +
+

Дашборд мероприятия

+ @if (summaryState$ | async; as ss) { + @switch (ss.status) { + @case ('loading') { } + @case ('error') {

Ошибка загрузки дашборда.

} + @case ('ok') { + @if (ss.dashboard.presentation_summary; as m) { +
+
Всего работ{{ m.works_total ?? 0 }}
+
Проверено{{ m.works_checked ?? 0 }}
+
Plagiarism rate{{ m.plagiarism_rate ?? '—' }}
+
Risk{{ m.risk_level ?? '—' }}
+
+ } @else { +

Метрики недоступны.

+ } + + @if (ss.dashboard.works; as cards) { + @if (cards.length > 0) { +

Карточки работ

+
    + @for (c of cards; track c.work_id) { +
  • + Работа {{ c.work_id }} + {{ c.risk_level ?? '—' }} + {{ c.student_name ?? '—' }}, score={{ c.trust_score ?? '—' }} +
  • + } +
+ } + } + } + } + } +
+ } + + @case (3) { +
+

Редактирование

+
+ + + + + + + + + +
+ +
+
+
+ +
+
+ } + } + } + } + } +
diff --git a/src/app/features/events/event-detail.component.ts b/src/app/features/events/event-detail.component.ts new file mode 100644 index 0000000..50a095d --- /dev/null +++ b/src/app/features/events/event-detail.component.ts @@ -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); + } +} diff --git a/src/app/features/groups/group-detail.component.css b/src/app/features/groups/group-detail.component.css new file mode 100644 index 0000000..b867040 --- /dev/null +++ b/src/app/features/groups/group-detail.component.css @@ -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; +} diff --git a/src/app/features/groups/group-detail.component.html b/src/app/features/groups/group-detail.component.html new file mode 100644 index 0000000..3c022aa --- /dev/null +++ b/src/app/features/groups/group-detail.component.html @@ -0,0 +1,116 @@ +
+ + + @if (groupState$ | async; as state) { + @switch (state.status) { + @case ('loading') {
} + @case ('error') {

Не удалось загрузить группу.

} + @case ('ok') { +

{{ state.group.name }}

+ + + + + + + + + @switch (activeTabIndex()) { + @case (0) { +
+
+
ID
{{ state.group.id }}
+
Название
{{ state.group.name }}
+
Студентов
{{ state.group.students?.length ?? 0 }}
+
Преподавателей
{{ state.group.users?.length ?? 0 }}
+
+
+ } + + @case (1) { +
+

Студенты

+ @if (state.group.students?.length) { + + } @else { +

Нет студентов.

+ } +
+ + + + +
+ +

Преподаватели

+ @if (state.group.users?.length) { +
    + @for (uid of state.group.users; track uid) { +
  • + Пользователь #{{ uid }} + +
  • + } +
+ } @else { +

Нет преподавателей.

+ } +
+ + + + +
+
+ } + + @case (2) { +
+

Дашборд группы

+ @if (statsState$ | async; as ss) { + @switch (ss.status) { + @case ('loading') { } + @case ('error') {

Ошибка загрузки.

} + @case ('ok') { + @if (ss.dashboard.presentation_summary; as m) { +
+
Всего работ{{ m.works_total ?? 0 }}
+
Проверено{{ m.works_checked ?? 0 }}
+
Plagiarism rate{{ m.plagiarism_rate ?? '—' }}
+
Risk{{ m.risk_level ?? '—' }}
+
+ } @else {

Метрики недоступны.

} + } + } + } +
+ } + + @case (3) { +
+

Редактирование

+
+ + + +
+ +
+
+
+ +
+
+ } + } + } + } + } +
diff --git a/src/app/features/groups/group-detail.component.ts b/src/app/features/groups/group-detail.component.ts new file mode 100644 index 0000000..415f64f --- /dev/null +++ b/src/app/features/groups/group-detail.component.ts @@ -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, 'Ошибка удаления пользователя'); }, + }); + } +} diff --git a/src/app/features/landing/landing.component.css b/src/app/features/landing/landing.component.css new file mode 100644 index 0000000..91a308a --- /dev/null +++ b/src/app/features/landing/landing.component.css @@ -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); } +} diff --git a/src/app/features/landing/landing.component.html b/src/app/features/landing/landing.component.html new file mode 100644 index 0000000..6dd0df7 --- /dev/null +++ b/src/app/features/landing/landing.component.html @@ -0,0 +1,162 @@ +
+ + +
+

+ Исключительная
Точность Проверок +

+

+ SparkGuardian Antiplagiat использует глубокий разбор синтаксических деревьев и алгоритм шинглов. Мы находим заимствования, даже если код был переписан. +

+ +
+ + +
+
+ + +
+
+

Алгоритмический анализ (Shingling)

+

Система не полагается на текстовые совпадения. Весь исходный код токенизируется, очищается от комментариев, а N-граммы сравниваются с использованием алгоритма Jaccard.

+
+
+ + + + + +
+
+ + +
+
+

Reference Sets

+

Отфильтровывайте заранее выданный студентам шаблонный код. Система сама вычтет эталонные токены.

+
+
+ + +
+
+

ZIP Загрузка

+

Архивы директорий распаковываются на лету. Мы поддерживаем сотни файлов в одном проекте.

+
+
+ + +
+
+
+

События и Группы

+

Гибкая организация потоков. Изолируйте проверку курсовых работ первого курса от экзаменов третьего курса в пару кликов.

+
+
+ + + + + + +
+
+
+ +
+
+ + +
+
+ 100% + Скрытие имен переменных +
+
+ AST + Лексическая токенизация +
+
+ 3 + Формата выгрузки +
+
+ + +
+

Как строится процесс

+
+
+
+
+
1. Распределение
+

Сборка структуры "Группа -> Студент -> Мероприятие".

+
+
+
+
+
+
2. Парсинг
+

Конвертация сырых файлов в нормализованные токены.

+
+
+
+
+
+
3. Кросс-сравнение
+

Каждая работа сверяется с каждой на поиск перекрытий.

+
+
+
+
+
+
4. Вердикт
+

Генерация отчета со списком найденных совпадений кода.

+
+
+
+
+ + +
+

Частые вопросы

+ + + +

+ Да, преподаватель может загрузить архив с исходным кодом, который выдавался студентам как основа (Reference Set). Система автоматически очистит совпадения с этим кодом из финального отчёта. +

+
+ + + +

+ Антиплагиат анализирует логическую структуру (Abstract Syntax Tree) и применяет шинглинг токенов. Переименование переменных, перестановка функций или вставка незначимых комментариев никак не повлияют на качество нахождения заимствований. +

+
+ + + +

+ Организуйте независимые «Мероприятия» (Events) внутри платформы. Студенты и их работы будут сгруппированы строго в рамках своих мероприятий, что исключает пересечение проверок между разными курсами. +

+
+ + + +

+ Результаты проверки выгружаются в разных форматах (JSON, HTML и PDF) и содержат консолидированную аналитику по всем студентам, включая попарную матрицу заимствований и списки самых подозрительных работ. +

+
+
+
+ + +
+ +
+ +
diff --git a/src/app/features/landing/landing.component.ts b/src/app/features/landing/landing.component.ts new file mode 100644 index 0000000..cb27aa9 --- /dev/null +++ b/src/app/features/landing/landing.component.ts @@ -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 {} diff --git a/src/app/features/login/login.component.css b/src/app/features/login/login.component.css new file mode 100644 index 0000000..b0e7d16 --- /dev/null +++ b/src/app/features/login/login.component.css @@ -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; +} diff --git a/src/app/features/login/login.component.html b/src/app/features/login/login.component.html new file mode 100644 index 0000000..477bdb2 --- /dev/null +++ b/src/app/features/login/login.component.html @@ -0,0 +1,39 @@ +
+
+

Вход в систему

+ +
+ + + + + + + + + +
+
+
diff --git a/src/app/features/login/login.component.ts b/src/app/features/login/login.component.ts new file mode 100644 index 0000000..4c07608 --- /dev/null +++ b/src/app/features/login/login.component.ts @@ -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, 'Ошибка входа'); + }, + }); + } +} diff --git a/src/app/features/monitoring/monitoring.component.css b/src/app/features/monitoring/monitoring.component.css new file mode 100644 index 0000000..faff276 --- /dev/null +++ b/src/app/features/monitoring/monitoring.component.css @@ -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); +} diff --git a/src/app/features/monitoring/monitoring.component.html b/src/app/features/monitoring/monitoring.component.html new file mode 100644 index 0000000..c0dabfc --- /dev/null +++ b/src/app/features/monitoring/monitoring.component.html @@ -0,0 +1,76 @@ +
+

Мониторинг

+ + @if (auditState$ | async; as state) { + @switch (state.status) { + @case ('loading') { +
+ } + @case ('error') { +

Ошибка загрузки журнала аудита (доступно только Admin).

+ } + @case ('ok') { +
+

Журнал аудита

+ @if (state.items.length === 0) { +

Записей нет.

+ } @else { +
+ + @for (t of uniqueResourceTypes(state.items); track t) { + + } +
+ + @if (filteredAuditLogs(state.items).length === 0) { +

Нет записей выбранного типа.

+ } @else { +
+ + + + + + + + + + + + @for (entry of filteredAuditLogs(state.items); track entry.id) { + + + + + + + + } + +
IDДействиеРесурсПользовательВремя
#{{ entry.id }}{{ entry.action ?? '—' }}{{ entry.resource_type ? (entry.resource_type | auditResourceType) : '—' }}{{ entry.actor_email ?? '—' }}{{ formatDate(entry.created_at) }}
+
+ } + } +
+ } + } + } +
diff --git a/src/app/features/monitoring/monitoring.component.ts b/src/app/features/monitoring/monitoring.component.ts new file mode 100644 index 0000000..6f5ed44 --- /dev/null +++ b/src/app/features/monitoring/monitoring.component.ts @@ -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(null); + + protected uniqueResourceTypes(items: readonly AuditLog[]): string[] { + const set = new Set(); + 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); + } +} diff --git a/src/app/features/reference-sets/refset-detail.component.css b/src/app/features/reference-sets/refset-detail.component.css new file mode 100644 index 0000000..be45328 --- /dev/null +++ b/src/app/features/reference-sets/refset-detail.component.css @@ -0,0 +1,7 @@ +/* Refset-detail-specific */ +.upload-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-end; +} diff --git a/src/app/features/reference-sets/refset-detail.component.html b/src/app/features/reference-sets/refset-detail.component.html new file mode 100644 index 0000000..624ee11 --- /dev/null +++ b/src/app/features/reference-sets/refset-detail.component.html @@ -0,0 +1,88 @@ +
+ + + @if (refSetState$ | async; as state) { + @switch (state.status) { + @case ('loading') {
} + @case ('error') {

Не удалось загрузить reference set.

} + @case ('ok') { +

{{ state.refSet.name }}

+ + + + + + + + @switch (activeTabIndex()) { + @case (0) { +
+
+
ID
{{ state.refSet.id }}
+
Название
{{ state.refSet.name }}
+
Тип
{{ state.refSet.kind }}
+
Описание
{{ state.refSet.description ?? '—' }}
+
+
+ } + + @case (1) { +
+

Ingestions

+ @if (ingestionsState$ | async; as is) { + @switch (is.status) { + @case ('loading') { } + @case ('error') {

Ошибка загрузки.

} + @case ('ok') { + @if (is.items.length === 0) { +

Нет ingestions.

+ } @else { +
    + @for (ig of is.items; track ig.id) { +
  • + ID: {{ ig.id }} + {{ ig.status | analysisRunStatus }} + {{ formatDate(ig.created_at) }} +
  • + } +
+ } + } + } + } + +

Загрузить ingestion

+
+ + +
+
+ } + + @case (2) { +
+

Редактирование

+
+ + + + + + + + + +
+ +
+
+
+ +
+
+ } + } + } + } + } +
diff --git a/src/app/features/reference-sets/refset-detail.component.ts b/src/app/features/reference-sets/refset-detail.component.ts new file mode 100644 index 0000000..724b949 --- /dev/null +++ b/src/app/features/reference-sets/refset-detail.component.ts @@ -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(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, 'Ошибка удаления'); }, + }); + } +} diff --git a/src/app/features/students/student-detail.component.css b/src/app/features/students/student-detail.component.css new file mode 100644 index 0000000..d919639 --- /dev/null +++ b/src/app/features/students/student-detail.component.css @@ -0,0 +1 @@ +/* Student-detail-specific — all shared styles now global */ diff --git a/src/app/features/students/student-detail.component.html b/src/app/features/students/student-detail.component.html new file mode 100644 index 0000000..b9837b0 --- /dev/null +++ b/src/app/features/students/student-detail.component.html @@ -0,0 +1,90 @@ +
+ + + @if (studentState$ | async; as state) { + @switch (state.status) { + @case ('loading') {
} + @case ('error') {

Не удалось загрузить студента.

} + @case ('ok') { +

{{ state.student.name }}

+ + + + + + + + @switch (activeTabIndex()) { + @case (0) { +
+
+
ID
{{ state.student.id }}
+
ФИО
{{ state.student.name }}
+
Email
{{ state.student.email }}
+
user_id
{{ state.student.user_id ?? '—' }}
+
+
+ } + + @case (1) { +
+

Статистика студента

+ @if (statsState$ | async; as ss) { + @switch (ss.status) { + @case ('loading') { } + @case ('error') {

Ошибка загрузки.

} + @case ('ok') { + @if (ss.dashboard.presentation_summary; as m) { +
+
Всего работ{{ m.works_total ?? 0 }}
+
Проверено{{ m.works_checked ?? 0 }}
+
Plagiarism rate{{ m.plagiarism_rate ?? '—' }}
+
Trust score{{ m.trust_score ?? '—' }}
+
Risk{{ m.risk_level ?? '—' }}
+
+ } @else {

Метрики недоступны.

} + + @if (ss.dashboard.works; as cards) { + @if (cards.length > 0) { +

Работы

+
    + @for (c of cards; track c.work_id) { +
  • + Работа {{ c.work_id }} + {{ c.risk_level ?? '—' }} + score={{ c.trust_score ?? '—' }} +
  • + } +
+ } + } + } + } + } +
+ } + + @case (2) { +
+

Редактирование

+
+ + + + + + +
+ +
+
+
+ +
+
+ } + } + } + } + } +
diff --git a/src/app/features/students/student-detail.component.ts b/src/app/features/students/student-detail.component.ts new file mode 100644 index 0000000..3fbd7a7 --- /dev/null +++ b/src/app/features/students/student-detail.component.ts @@ -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, 'Ошибка удаления'); }, + }); + } +} diff --git a/src/app/features/works/work-detail/work-detail.component.css b/src/app/features/works/work-detail/work-detail.component.css new file mode 100644 index 0000000..29f64d5 --- /dev/null +++ b/src/app/features/works/work-detail/work-detail.component.css @@ -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; +} diff --git a/src/app/features/works/work-detail/work-detail.component.html b/src/app/features/works/work-detail/work-detail.component.html new file mode 100644 index 0000000..e6541e9 --- /dev/null +++ b/src/app/features/works/work-detail/work-detail.component.html @@ -0,0 +1,247 @@ +
+ + + @if (workState$ | async; as state) { + @switch (state.status) { + @case ('loading') { +
+ +
+ } + @case ('error') { +

Не удалось загрузить работу.

+ } + @case ('ok') { +

Работа #{{ workId() }}

+ + + + + + + + + @switch (activeTabIndex()) { + @case (0) { +
+

Сведения о работе

+
+
Идентификатор
+
{{ state.work.id }}
+
Студент
+
#{{ state.work.student_id }}
+
Мероприятие
+
#{{ state.work.event_id }}
+
Время
+
{{ formatDateTime(state.work.time) }}
+
Архив
+
+ @if (state.work.archive_object_key) { + {{ state.work.archive_object_key }} + + } @else { + не загружен + } +
+
+
+ +
+
+ + @if (summaryState$ | async; as summaryState) { + @if (summaryState.status === 'ok') { +
+

Summary

+
+
+
+ Risk + {{ summaryState.summary.presentation_summary?.risk_level ?? '—' }} +
+
+
+
+ Trust score + {{ summaryState.summary.presentation_summary?.trust_score ?? '—' }} +
+
+
+
+ Plagiarism rate + {{ summaryState.summary.presentation_summary?.plagiarism_rate ?? '—' }} +
+
+
+
+ Counterparts + {{ summaryState.summary.presentation_summary?.counterparts_count ?? 0 }} +
+
+
+
+ } + } + } + + @case (1) { +
+

Загрузка архива и запуск проверки

+
+ + + + + + +
+ @if (isPolling()) { +

Polling статуса запущен...

+ } + @if (latestRun(); as run) { +

+ Текущий статус: {{ run.status | analysisRunStatus }} +

+ } +
+ } + + @case (2) { +
+

Analysis runs

+ @if (runsState$ | async; as runsState) { + @switch (runsState.status) { + @case ('loading') { +
+ +
+ } + @case ('error') { +

Не удалось загрузить runs.

+ } + @case ('ok') { + @if (runsState.runs.length === 0) { +

Проверки пока не запускались.

+ } @else { +
    + @for (run of runsState.runs; track run.id) { +
  • + {{ run.id }} + {{ run.status | analysisRunStatus }} + {{ formatDateTime(run.updated_at) }} + @if (getRunDuration(run)) { + ({{ getRunDuration(run) }}) + } + @if (run.status === 'Failed' || run.status === 'Completed') { + + } +
  • + } +
+ } + } + } + } +
+ +
+

Совпадения выбранного run

+ @if (adoptionsState$ | async; as adoptState) { + @switch (adoptState.status) { + @case ('idle') { +

Выберите run.

+ } + @case ('loading') { +
+ +
+ } + @case ('error') { +

Не удалось загрузить совпадения.

+ } + @case ('ok') { +

Всего совпадений: {{ adoptState.adoptions.length }}

+ @if (adoptState.adoptions.length > 0) { +
+ @for (adoption of adoptState.adoptions; track adoption.id) { +
+
+
ID
+
{{ adoption.id }}
+
Path
+
{{ adoption.path ?? '—' }}
+
Score
+
{{ adoption.similarity_score ?? '—' }}
+
+ @if (adoption.segment_excerpt) { +

{{ adoption.segment_excerpt }}

+ } +
+ } +
+ } + } + } + } +
+ } + + @case (3) { +
+
+

Teacher report

+
+
+ + + +
+
+ } + } + } + } + } +
diff --git a/src/app/features/works/work-detail/work-detail.component.ts b/src/app/features/works/work-detail/work-detail.component.ts new file mode 100644 index 0000000..b479dee --- /dev/null +++ b/src/app/features/works/work-detail/work-detail.component.ts @@ -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(null); + protected readonly selectedArchiveFile = signal(null); + protected readonly isUploading = signal(false); + protected readonly isChecking = signal(false); + protected readonly isDownloading = signal(false); + protected readonly latestRun = signal(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); + } +} diff --git a/src/app/features/works/works-list/works-list.component.css b/src/app/features/works/works-list/works-list.component.css new file mode 100644 index 0000000..980e82f --- /dev/null +++ b/src/app/features/works/works-list/works-list.component.css @@ -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); +} diff --git a/src/app/features/works/works-list/works-list.component.html b/src/app/features/works/works-list/works-list.component.html new file mode 100644 index 0000000..ea4516f --- /dev/null +++ b/src/app/features/works/works-list/works-list.component.html @@ -0,0 +1,80 @@ +
+

Работы

+ +
+

Новая работа

+
+ + + + + + + + + + + +
+
+ + @if (worksState$ | async; as state) { + @switch (state.status) { + @case ('loading') { +
+ +
+ } + @case ('error') { +
+

Список временно недоступен.

+
+ } + @case ('ok') { +
+ @if (state.works.length === 0) { +

Работы пока отсутствуют.

+ } @else { +
    + @for (work of state.works; track work.id) { +
  • + + Работа {{ work.id }} + + + student={{ work.student_id }}, event={{ work.event_id }} + + {{ formatDate(work.time) }} +
  • + } +
+ } +
+ } + } + } +
diff --git a/src/app/features/works/works-list/works-list.component.ts b/src/app/features/works/works-list/works-list.component.ts new file mode 100644 index 0000000..dad9547 --- /dev/null +++ b/src/app/features/works/works-list/works-list.component.ts @@ -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); + } +} diff --git a/src/app/shared/utils/date-time.util.ts b/src/app/shared/utils/date-time.util.ts new file mode 100644 index 0000000..62dec73 --- /dev/null +++ b/src/app/shared/utils/date-time.util.ts @@ -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)); +} diff --git a/src/app/shared/utils/duration.util.ts b/src/app/shared/utils/duration.util.ts new file mode 100644 index 0000000..939b670 --- /dev/null +++ b/src/app/shared/utils/duration.util.ts @@ -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(' '); +} diff --git a/src/app/shared/utils/json.util.ts b/src/app/shared/utils/json.util.ts new file mode 100644 index 0000000..fa09ce4 --- /dev/null +++ b/src/app/shared/utils/json.util.ts @@ -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; +} diff --git a/src/app/shared/utils/math.util.ts b/src/app/shared/utils/math.util.ts new file mode 100644 index 0000000..d06a466 --- /dev/null +++ b/src/app/shared/utils/math.util.ts @@ -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)); +} diff --git a/src/app/shared/utils/number.util.ts b/src/app/shared/utils/number.util.ts new file mode 100644 index 0000000..4341e2d --- /dev/null +++ b/src/app/shared/utils/number.util.ts @@ -0,0 +1,15 @@ +/** + * Безопасно извлекает числовое значение по ключу из объекта. + * Поддерживает как `number`, так и строки-числа. + */ +export function readNumericField(o: Record, 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; +} diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts new file mode 100644 index 0000000..203b2ff --- /dev/null +++ b/src/environments/environment.prod.ts @@ -0,0 +1,6 @@ +export const environment = { + production: true, + apiFallbackOrigin: 'http://spark.returntozer0.ru', + apiBasePath: '', + defaultPageLimit: 20, +} as const; diff --git a/src/environments/environment.ts b/src/environments/environment.ts new file mode 100644 index 0000000..081956a --- /dev/null +++ b/src/environments/environment.ts @@ -0,0 +1,6 @@ +export const environment = { + production: false, + apiFallbackOrigin: 'http://spark.returntozer0.ru', + apiBasePath: '', + defaultPageLimit: 20, +} as const; diff --git a/src/index.html b/src/index.html index 84c5acb..f48b380 100644 --- a/src/index.html +++ b/src/index.html @@ -1,5 +1,5 @@ - + SparkAntiplagiat diff --git a/src/styles.css b/src/styles.css index 90d4ee0..c2b718e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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); +} diff --git a/src/styles/color-tokens.css b/src/styles/color-tokens.css new file mode 100644 index 0000000..8e61192 --- /dev/null +++ b/src/styles/color-tokens.css @@ -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; + } +} diff --git a/src/styles/filter-chips.css b/src/styles/filter-chips.css new file mode 100644 index 0000000..c5b0f36 --- /dev/null +++ b/src/styles/filter-chips.css @@ -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); +} diff --git a/src/styles/page-common.css b/src/styles/page-common.css new file mode 100644 index 0000000..e2cd15e --- /dev/null +++ b/src/styles/page-common.css @@ -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; +} diff --git a/src/styles/session-status-chips.css b/src/styles/session-status-chips.css new file mode 100644 index 0000000..eba2ce3 --- /dev/null +++ b/src/styles/session-status-chips.css @@ -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); +} diff --git a/src/styles/sg-input-fields.css b/src/styles/sg-input-fields.css new file mode 100644 index 0000000..c7a977e --- /dev/null +++ b/src/styles/sg-input-fields.css @@ -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); +} diff --git a/src/styles/shared-components.css b/src/styles/shared-components.css new file mode 100644 index 0000000..0250aa6 --- /dev/null +++ b/src/styles/shared-components.css @@ -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; +}