From 2016d9160c850e75cb2614a36aff5ed14a9ce617 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: Sat, 18 Apr 2026 15:25:21 +0300 Subject: [PATCH] - Add PlagiarismGraphComponent (force-graph) with legend, abbreviated names, risk-derived node colors, and particle animation; integrated into work, event, group, and student detail pages - Extract domain API services (students, events, groups, reference-sets, users, analysis-runs, audit) from WorksApiService - Add RiskLevelPipe for translating risk level values to Russian - Replace raw IDs with entity names across all detail page overview sections - Dashboard: remove Works tab, reorder tabs (Students, Events, Groups, Ref-sets), hide list cards when empty or on error - Hide secondary blocks (runs, matches, graph, analytics) on error instead of showing error text; keep top-level entity load errors visible - Refset detail: split Ingestions tab into separate list and upload cards; hide list card when empty or on error - Convert analysis runs list to table; fix kv-grid vertical alignment - Style native select[tuiSelect] to match other form fields - Add favicon (yellow star) from sparkguardian --- .claudeignore | 93 +++++ .env.example | 14 + .gitea/workflows/ci.yml | 44 +++ .gitignore | 6 + CLAUDE.md | 24 ++ CONVENTIONS.md | 44 ++- Makefile | 44 +++ README.md | 4 +- package-lock.json | 361 ++++++++++++++++++ package.json | 11 +- proxy.conf.cjs | 15 + public/favicon.png | Bin 0 -> 878 bytes scripts/sync-env.cjs | 43 +++ src/app/app.css | 5 + src/app/app.html | 2 +- src/app/core/http/auth.interceptor.ts | 31 +- src/app/core/models/api.types.ts | 14 + .../monitoring/audit-resource-type.pipe.ts | 2 +- .../services/analysis-runs-api.service.ts | 46 +++ src/app/core/services/audit-api.service.ts | 39 ++ src/app/core/services/events-api.service.ts | 48 +++ src/app/core/services/groups-api.service.ts | 57 +++ .../services/reference-sets-api.service.ts | 48 +++ src/app/core/services/students-api.service.ts | 42 ++ src/app/core/services/users-api.service.ts | 21 + src/app/core/services/works-api.service.ts | 220 +---------- src/app/core/works/risk-level.pipe.ts | 19 + .../dashboard/dashboard.component.html | 221 +++++------ .../features/dashboard/dashboard.component.ts | 113 ++++-- .../events/event-detail.component.html | 41 +- .../features/events/event-detail.component.ts | 35 +- .../groups/group-detail.component.html | 29 +- .../features/groups/group-detail.component.ts | 60 ++- .../features/landing/landing.component.html | 4 +- .../monitoring/monitoring.component.ts | 4 +- .../refset-detail.component.html | 55 ++- .../reference-sets/refset-detail.component.ts | 4 +- .../students/student-detail.component.html | 27 +- .../students/student-detail.component.ts | 8 +- .../work-detail/work-detail.component.css | 51 ++- .../work-detail/work-detail.component.html | 103 +++-- .../work-detail/work-detail.component.ts | 53 ++- .../works-list/works-list.component.html | 48 +-- .../works/works-list/works-list.component.ts | 54 ++- .../plagiarism-graph.component.css | 81 ++++ .../plagiarism-graph.component.ts | 218 +++++++++++ src/index.html | 2 +- src/styles/sg-input-fields.css | 19 + src/styles/shared-components.css | 1 + 49 files changed, 1939 insertions(+), 589 deletions(-) create mode 100644 .claudeignore create mode 100644 .env.example create mode 100644 .gitea/workflows/ci.yml create mode 100644 CLAUDE.md create mode 100644 Makefile create mode 100644 proxy.conf.cjs create mode 100644 public/favicon.png create mode 100644 scripts/sync-env.cjs create mode 100644 src/app/core/services/analysis-runs-api.service.ts create mode 100644 src/app/core/services/audit-api.service.ts create mode 100644 src/app/core/services/events-api.service.ts create mode 100644 src/app/core/services/groups-api.service.ts create mode 100644 src/app/core/services/reference-sets-api.service.ts create mode 100644 src/app/core/services/students-api.service.ts create mode 100644 src/app/core/services/users-api.service.ts create mode 100644 src/app/core/works/risk-level.pipe.ts create mode 100644 src/app/shared/components/plagiarism-graph/plagiarism-graph.component.css create mode 100644 src/app/shared/components/plagiarism-graph/plagiarism-graph.component.ts diff --git a/.claudeignore b/.claudeignore new file mode 100644 index 0000000..951e792 --- /dev/null +++ b/.claudeignore @@ -0,0 +1,93 @@ +dist/ +tmp/ +out-tsc/ +**/build/ +**/www/ + +.angular/ +.angular/cache/ +.ngtoolscache/ +.ngcache/ + +coverage/ +**/coverage/ +nyc_output/ + +node_modules/ + +package-lock.json +yarn.lock +pnpm-lock.yaml +**/npm-shrinkwrap.json + +.idea/ +.history/ +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/mcp.json + +*.iml +*.sublime-* + +.env +.env.* +!.env.example +.secrets +*.key +*.pem +*.p12 + +src/environments/environment.ts + +.claude/settings.local.json +.claude/history/ +.claude/commands/ + +*.log +npm-debug.log* +yarn-error.log* +logs/ +*.tmp +*.temp +**/.temp/ + +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +Desktop.ini + +**/assets/fonts/ +**/assets/icons/*.{ttf,woff,woff2,eot,otf} +*.ttf +*.woff +*.woff2 +*.eot +*.otf +**/assets/images/*.{jpg,jpeg,png,gif,bmp,webp,ico,svg} +**/assets/*.{pdf,doc,docx,xls,xlsx,zip,rar,7z} +**/__tests__/ +**/*.spec.ts +**/*.test.ts +**/*.e2e-spec.ts +**/*.mock.ts +**/testing/ +**/mocks/ +**/generated/ +**/swagger-gen/ +**/api/gen/ +**/*.generated.ts +**/*.g.ts +**/speed-measure-plugin.json +**/webpack-stats.json +**/.angular-build-stats/ +docs/swagger.json +docs/antiplagiat_api.json +**/openapi.json +**/api-spec.yaml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6b8dadc --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Скопируйте в `.env` и при необходимости измените. +# Клиентский бандл: после правок выполните `npm run env:sync` (вызывается автоматически перед `start` и `build`). + +# База API (путь относительно текущего origin в браузере; на localhost/file:// — склеивается с SG_API_FALLBACK_ORIGIN) +SG_API_BASE_PATH= + +# Origin бэкенда для разрешения относительных URL при dev-режиме и file:// +SG_API_FALLBACK_ORIGIN=http://spark.returntozer0.ru + +# Только dev-сервер (`ng serve`): куда проксировать `/api/**` +SG_DEV_PROXY_TARGET=http://spark.returntozer0.ru + +# Размер страницы по умолчанию (списки работ, студентов, групп и пр.) +SG_DEFAULT_PAGE_LIMIT=20 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..afb7940 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,44 @@ +# Gitea Actions: линт, unit-тесты (Vitest/jsdom), production build. +# Нужны: включённые Actions в репозитории и зарегистрированный act_runner. +# Если шаги с `uses:` не резолвятся, в настройках Gitea укажите прокси GitHub Actions +# или замените на полные URL, например: https://github.com/actions/checkout@v4 + +name: CI + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Unit tests + run: npm run test -- --watch=false + + - name: Production build + run: npm run build diff --git a/.gitignore b/.gitignore index 854acd5..41012f0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,12 @@ yarn-error.log !.vscode/mcp.json .history/* +# Env (секреты, генерируемые файлы) +.env +.env.* +!.env.example +/src/environments/environment.ts + # Miscellaneous /.angular/cache .sass-cache/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6eedfb8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,24 @@ +## Tech Stack +- Angular 21 (Signals, OnPush, Standalone) +- TypeScript (strict) +- Taiga UI v5, Maskito, ng-web-apis +- Vitest + jsdom + +## Commands +- Dev: `npm start` (поднимает dev-сервер с прокси, перед стартом выполняет `env:sync`) +- Build: `npm run build` +- Lint: `npm run lint` +- Env sync: `npm run env:sync` + +## Rules +- Для цветов используются только переменные цветов из `src/styles/color-tokens.css`. +- Если что-то можно стилизовать через Taiga UI — делаем через него. +- Минимум any-типов; интерфейсы API — `readonly`. +- Доменные API-сервисы в `src/app/core/services/*-api.service.ts` (works, analysis-runs, students, groups, events, reference-sets, users, audit). Не возвращать WorksApiService в роль god-сервиса. +- Роуты только через `loadComponent()`, защита — `authGuard`. +- HTTP-ошибки классифицируются в `core/http/error-classification.util.ts` и показываются через `UserErrorNotifyService`. +- Backend OpenAPI: `docs/swagger.json`. + +## Конфиг окружения +- Правки делаются в `.env` (копия `.env.example`), затем `npm run env:sync` генерирует `src/environments/environment.ts`. +- `environment.ts` в git **не коммитится** — это артефакт. diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 232557b..b98d604 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -1,8 +1,8 @@ -# SparkGuardian — Конвенции и правила разработки +# SparkAntiplagiat — Конвенции и правила разработки -> Этот документ описывает все архитектурные, стилевые и технические решения проекта SparkGuardian. -> Его цель — обеспечить идентичный стиль кода и подход к дизайну в любых связанных проектах. -> **Передайте этот файл AI-ассистенту вместе с задачей**, чтобы новый проект был логическим продолжением SparkGuardian. +> Документ описывает архитектурные, стилевые и технические решения фронтенда SparkAntiplagiat. +> Базируется на конвенциях родственного проекта SparkGuardian и наследует общие правила (Angular 21, signals, Taiga UI, T-Bank tokens). +> **Передавайте этот файл AI-ассистенту вместе с задачей** — описанные правила должны соблюдаться при любых изменениях. --- @@ -13,7 +13,8 @@ | Фреймворк | Angular | 21+ | | Язык | TypeScript | strict mode | | UI-библиотека | Taiga UI | v5 | -| Видео | hls.js | — | +| Формы / маски | Maskito | 5.x | +| Web API wrappers | @ng-web-apis | 5.x | | Стили | Vanilla CSS + CSS Custom Properties | — | | Линтинг | ESLint + angular-eslint | — | | Тесты | Vitest + jsdom | — | @@ -33,24 +34,31 @@ 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 +│ ├── guards/ ← authGuard +│ ├── http/ ← interceptors (apiBaseUrl, auth, 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 +│ ├── services/ ← доменные API-клиенты: WorksApi, AnalysisRunsApi, +│ │ StudentsApi, GroupsApi, EventsApi, ReferenceSetsApi, +│ │ UsersApi, AuditApi + AuthService +│ ├── monitoring/ ← pipes для audit log +│ └── works/ ← pipes: analysis-run-status, chip-classes │ ├── features/ ← lazy-loaded smart-компоненты (по одному на маршрут) │ ├── landing/ ← LandingComponent (маркетинговая страница) -│ ├── devtools/ ← DevConsoleComponent (overlay, только isDevMode()) -│ └── sessions/ ← SessionsList, SessionDetail (+4 tab-компонента), -│ HlsPlayer, KeyboardView, MouseView, StreamSelector, -│ TelemetryEventDetail +│ ├── login/ ← LoginComponent +│ ├── dashboard/ ← DashboardComponent (сводка доменов + CRUD-формы) +│ ├── works/ ← WorksList, WorkDetail (загрузка архива, запуск check, +│ │ отчёты, adoptions по прогонам) +│ ├── groups/ ← GroupDetail (участники, привязка студентов/юзеров) +│ ├── students/ ← StudentDetail +│ ├── events/ ← EventDetail (CRUD + works события) +│ ├── reference-sets/ ← RefsetDetail (ingestions, CRUD) +│ ├── monitoring/ ← MonitoringComponent (audit logs + фильтры) +│ └── devtools/ ← DevConsoleComponent (overlay, только isDevMode()) │ └── shared/ ← чистые pure-функции, НИКАКИХ Angular-зависимостей - └── utils/ ← date-time, duration, json, math, number, stream, - telemetry-summary-human-text + └── utils/ ← date-time, duration, json, math, number ``` ### Правила файловой организации @@ -60,7 +68,7 @@ src/app/ - **Селекторы компонентов**: `app-` (e.g., `app-session-detail`). - **Классы компонентов**: `PascalCase` + суффикс `Component` (e.g., `SessionDetailComponent`). - **Pipes**: суффикс `Pipe` (e.g., `SessionStatusPipe`). -- **Сервисы**: суффикс `Service` (e.g., `SessionsApiService`). +- **Сервисы**: суффикс `Service` (e.g., `WorksApiService`). - **Утилиты**: суффикс `.util.ts` (e.g., `date-time.util.ts`). - **Конфиги**: суффикс `.config.ts` (e.g., `user-error-messages.config.ts`). - **Типы**: суффикс `.types.ts` (e.g., `api.types.ts`). @@ -83,7 +91,7 @@ src/app/ }) export class MyComponent { // DI через inject(), НЕ constructor injection - private readonly api = inject(SessionsApiService); + private readonly api = inject(WorksApiService); private readonly userErrors = inject(UserErrorNotifyService); // Inputs/Outputs через signal-based API (Angular 17+) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fb5ceeb --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +.DEFAULT_GOAL := help + +.PHONY: help install env-sync start serve build build-dev watch test lint clean + +help: + @echo "SparkAntiplagiat" + @echo " make install Установить зависимости (npm install)" + @echo " make env-sync Сгенерировать src/environments/environment.ts из .env" + @echo " make start Dev-сервер (ng serve + прокси, перед стартом — env:sync)" + @echo " make serve то же, что start" + @echo " make build Production-сборка (перед сборкой — env:sync)" + @echo " make build-dev Сборка в режиме development" + @echo " make watch ng build --watch (development)" + @echo " make test Unit-тесты (ng test)" + @echo " make lint ESLint по всему проекту" + @echo " make clean Удалить dist, out-tsc, кэш Angular" + +install: + npm install + +env-sync: + npm run env:sync + +start serve: + npm start + +build: + npm run build + +build-dev: + npm run env:sync + npx ng build --configuration development + +watch: + npm run watch + +test: + npm test + +lint: + npm run lint + +clean: + rm -rf dist out-tsc .angular/cache diff --git a/README.md b/README.md index ea30f41..98a5c90 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ A premium single-page web client for managing algorithmic plagiarism checks, han 3. Синхронизируйте переменные (вызывается авто-hooks): `npm run env:sync`. Создает `environment.ts`. 4. Запуск dev-сервера с прокси: `npm start` (по умолчанию `http://localhost:4200/`). -Актуальный OpenAPI контракт системы доступен в `docs/antiplagiat_api.json`. +Актуальный OpenAPI контракт системы доступен в `docs/swagger.json`. --- @@ -82,7 +82,7 @@ The project strictly isolates modules into `core` (services, tokens, interceptor 3. Sync environment (done automatically on hooks): `npm run env:sync`. 4. Run proxy dev-server: `npm start` (usually at `http://localhost:4200/`). -Refer to `docs/antiplagiat_api.json` for the most recent REST Open API specification. +Refer to `docs/swagger.json` for the most recent REST Open API specification. --- diff --git a/package-lock.json b/package-lock.json index c4f22dc..77699d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@taiga-ui/kit": "^5.2.0", "@taiga-ui/polymorpheus": "^5.0.0", "@taiga-ui/styles": "^5.2.0", + "force-graph": "^1.51.4", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -41,6 +42,7 @@ "@angular/compiler-cli": "^21.2.0", "@eslint/js": "^10.0.1", "angular-eslint": "^21.3.1", + "dotenv": "^16.4.7", "eslint": "^10.2.0", "jsdom": "^28.0.0", "less": "^4.6.4", @@ -4523,6 +4525,12 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -4980,6 +4988,15 @@ "node": ">= 0.6" } }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -5241,6 +5258,16 @@ "node": ">=18.0.0" } }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -5432,6 +5459,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-color-tracker": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", + "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", + "license": "MIT", + "dependencies": { + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -5799,6 +5838,223 @@ "node": "20 || >=22" } }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -5925,6 +6181,19 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6613,6 +6882,46 @@ "dev": true, "license": "ISC" }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/force-graph": { + "version": "1.51.4", + "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz", + "integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "bezier-js": "3 - 6", + "canvas-color-tracker": "^1.3", + "d3-array": "1 - 3", + "d3-drag": "2 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "d3-selection": "2 - 3", + "d3-zoom": "2 - 3", + "float-tooltip": "^1.7", + "index-array-by": "1", + "kapsule": "^1.16", + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -7034,6 +7343,15 @@ "node": ">=0.8.19" } }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -7051,6 +7369,15 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -7337,6 +7664,18 @@ ], "license": "MIT" }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7506,6 +7845,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", @@ -8762,6 +9107,16 @@ "postcss": "^8.4.31" } }, + "node_modules/preact": { + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -9602,6 +9957,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", diff --git a/package.json b/package.json index dea4cbd..3542c08 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,15 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "start": "ng serve", + "env:sync": "node scripts/sync-env.cjs", + "prestart": "npm run env:sync", + "prebuild": "npm run env:sync", + "prewatch": "npm run env:sync", + "start": "ng serve --proxy-config proxy.conf.cjs", "build": "ng build", "watch": "ng build --watch --configuration development", - "test": "ng test" + "test": "ng test", + "lint": "eslint ." }, "private": true, "packageManager": "npm@11.6.2", @@ -35,6 +40,7 @@ "@taiga-ui/kit": "^5.2.0", "@taiga-ui/polymorpheus": "^5.0.0", "@taiga-ui/styles": "^5.2.0", + "force-graph": "^1.51.4", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -44,6 +50,7 @@ "@angular/compiler-cli": "^21.2.0", "@eslint/js": "^10.0.1", "angular-eslint": "^21.3.1", + "dotenv": "^16.4.7", "eslint": "^10.2.0", "jsdom": "^28.0.0", "less": "^4.6.4", diff --git a/proxy.conf.cjs b/proxy.conf.cjs new file mode 100644 index 0000000..3224952 --- /dev/null +++ b/proxy.conf.cjs @@ -0,0 +1,15 @@ +/** + * Прокси для `ng serve`. Целевой backend задаётся в `.env` (SG_DEV_PROXY_TARGET). + */ +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '.env') }); + +const target = process.env.SG_DEV_PROXY_TARGET || 'http://spark.returntozer0.ru'; + +module.exports = { + '/api/**': { + target, + secure: false, + changeOrigin: true, + }, +}; diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..6084671a1f5ca3c16b891e02df25b454f976664f GIT binary patch literal 878 zcmV-!1CjiRP);YZZ18h|OX{!Q^T$xxnwksgWMEtuJls}&fmOq`D<|Es+Q~=&Mo*7CFLcn|d6y17ufS#vuQ%!Oc#{4bryu%no*@w4nLkOm>Bt(WcLZtuS~dqE#Khie6nWf5;YXb` ze6@#$uXHB|2M2*D8>74eEZ{l=N*lzxHs)Ac;N-lCP0ENLfSo&?PDg=ECKLLwv=`b~ z+M|WLKRCkUMT(4<=wjc2EJ@qF(f$enGm{6Dm@U!b>^^P1F4ESU(p)Z=3jz6jzFGlu zBoG4OOGSc+=l6b6`sMeU7$XBqbw>IQDgK~H*;l{#i23wnnF|>m;9p+MRIPm@icFL! zcK_IdW`p{U06<+7bi?aa*amjG_%B1a-X zOvVRul-gJ}9(327`OqqWtVU)hv;d_BF_*K7 z76DZQXH`J<;vQ7}Y<|03A=#-^K(z{T2N47y|eoX4zf98IJGa100Se8EMM zi-U6j6#yE&abm4R48(%H3=IVcW8OE3h=!zaWW>a?E=bD`6M?1zU^G`grFM8{_8hACCcqIYY_*dk^neD z#KN*W^nRmb-HsTxcK8DT0RR88Vd>@o000I_L_t&o0D>wF58?T>Gynhq07*qoM6N<$ Ef(T@W*Z=?k literal 0 HcmV?d00001 diff --git a/scripts/sync-env.cjs b/scripts/sync-env.cjs new file mode 100644 index 0000000..af0cbdf --- /dev/null +++ b/scripts/sync-env.cjs @@ -0,0 +1,43 @@ +/** + * Генерирует `src/environments/environment.ts` из переменных окружения (.env). + * Значения по умолчанию совпадают с .env.example. + */ +const fs = require('fs'); +const path = require('path'); + +require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); + +const defaults = { + SG_API_FALLBACK_ORIGIN: 'http://spark.returntozer0.ru', + SG_API_BASE_PATH: '', + SG_DEFAULT_PAGE_LIMIT: '20', +}; + +function val(key) { + const v = process.env[key]; + if (v !== undefined && v !== '') { + return v; + } + return defaults[key]; +} + +function intVal(key) { + const parsed = Number.parseInt(val(key), 10); + if (Number.isFinite(parsed)) { + return parsed; + } + return Number.parseInt(defaults[key], 10); +} + +const content = `// Сгенерировано npm run env:sync — правьте .env и снова запустите sync +export const environment = { + production: false, + apiFallbackOrigin: ${JSON.stringify(val('SG_API_FALLBACK_ORIGIN'))}, + apiBasePath: ${JSON.stringify(val('SG_API_BASE_PATH'))}, + defaultPageLimit: ${JSON.stringify(intVal('SG_DEFAULT_PAGE_LIMIT'))}, +} as const; +`; + +const outPath = path.join(__dirname, '..', 'src', 'environments', 'environment.ts'); +fs.writeFileSync(outPath, content, 'utf8'); +console.log('env:sync →', outPath); diff --git a/src/app/app.css b/src/app/app.css index 3ee1ad0..9da7f2d 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -82,6 +82,11 @@ } .shell-logout { + appearance: none; + background: none; + border: none; + padding: 0; + margin: 0; font: var(--tui-font-text-s); color: var(--tui-text-action); cursor: pointer; diff --git a/src/app/app.html b/src/app/app.html index 9cb9133..b6e9ef2 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -16,7 +16,7 @@ @if (auth.user(); as user) { } - Выйти + } diff --git a/src/app/core/http/auth.interceptor.ts b/src/app/core/http/auth.interceptor.ts index 414e39a..8d26251 100644 --- a/src/app/core/http/auth.interceptor.ts +++ b/src/app/core/http/auth.interceptor.ts @@ -1,5 +1,8 @@ -import { HttpInterceptorFn } from '@angular/common/http'; +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; import { inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { catchError, throwError } from 'rxjs'; + import { AuthService } from '../services/auth.service'; /** Endpoints that must NOT carry the Authorization header. */ @@ -7,18 +10,26 @@ const PUBLIC_PATHS = ['/auth/login', '/auth/register']; export const authInterceptor: HttpInterceptorFn = (request, next) => { const auth = inject(AuthService); + const router = inject(Router); const token = auth.token(); + const isPublic = PUBLIC_PATHS.some((p) => request.url.endsWith(p)); - if (token === null || PUBLIC_PATHS.some((p) => request.url.endsWith(p))) { - return next(request); - } + const outgoing = + token === null || isPublic + ? request + : request.clone({ + setHeaders: { Authorization: `Bearer ${token}` }, + }); - return next( - request.clone({ - setHeaders: { - Authorization: `Bearer ${token}`, - }, + return next(outgoing).pipe( + catchError((error: unknown) => { + // Токен протух / backend отклонил — чистим сессию и кидаем на логин. + // Публичные эндпоинты (сам /auth/login) пропускаем: там 401 — ожидаемая ошибка формы. + if (error instanceof HttpErrorResponse && error.status === 401 && !isPublic) { + auth.logout(); + router.navigate(['/login']); + } + return throwError(() => error); }), ); }; - diff --git a/src/app/core/models/api.types.ts b/src/app/core/models/api.types.ts index b6fd309..c6c73a1 100644 --- a/src/app/core/models/api.types.ts +++ b/src/app/core/models/api.types.ts @@ -91,14 +91,28 @@ export interface DashboardCounterpart { export interface DashboardGraphNode { readonly work_id?: number; readonly label?: string; + readonly student_id?: number; readonly student_name?: string; + readonly event_id?: number; + readonly event_name?: string; + readonly group_id?: number; + readonly group_name?: string; + readonly plagiarism_rate?: number; + readonly trust_score?: number; readonly risk_level?: string; + readonly needs_review?: boolean; + readonly in_scope?: boolean; } export interface DashboardGraphEdge { readonly from_work_id?: number; readonly to_work_id?: number; readonly score?: number; + readonly average_similarity?: number; + readonly max_similarity?: number; + readonly match_count?: number; + readonly significant_matches?: number; + readonly blatant_matches?: number; readonly risk_level?: string; } diff --git a/src/app/core/monitoring/audit-resource-type.pipe.ts b/src/app/core/monitoring/audit-resource-type.pipe.ts index 416c37e..dac3086 100644 --- a/src/app/core/monitoring/audit-resource-type.pipe.ts +++ b/src/app/core/monitoring/audit-resource-type.pipe.ts @@ -6,7 +6,7 @@ const TRANSLATIONS: Record = { student: 'Студент', user: 'Пользователь', event: 'Мероприятие', - referenceset: 'Reference Set', + referenceset: 'Эталонная база', session: 'Сессия', analysisrun: 'Запуск проверки', auth: 'Авторизация', diff --git a/src/app/core/services/analysis-runs-api.service.ts b/src/app/core/services/analysis-runs-api.service.ts new file mode 100644 index 0000000..e8505fd --- /dev/null +++ b/src/app/core/services/analysis-runs-api.service.ts @@ -0,0 +1,46 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + Adoption, + AnalysisRun, + AnalysisRunChunk, + UploadWorkResponse, +} from '../models/api.types'; + +/** + * API прогонов анализа: получение деталей прогона, заимствований и чанков, + * повторный запуск, выгрузка отчёта (PDF/HTML/JSON) и сегментов кода заимствований. + */ +@Injectable({ providedIn: 'root' }) +export class AnalysisRunsApiService { + private readonly http = inject(HttpClient); + + 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' }); + } + + getAdoptionSegment(adoptionId: number): Observable { + return this.http.get(`/adoptions/${adoptionId}/segment`, { responseType: 'text' }); + } +} diff --git a/src/app/core/services/audit-api.service.ts b/src/app/core/services/audit-api.service.ts new file mode 100644 index 0000000..b1b6e73 --- /dev/null +++ b/src/app/core/services/audit-api.service.ts @@ -0,0 +1,39 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { AuditLog } from '../models/api.types'; + +@Injectable({ providedIn: 'root' }) +export class AuditApiService { + private readonly http = inject(HttpClient); + + 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/services/events-api.service.ts b/src/app/core/services/events-api.service.ts new file mode 100644 index 0000000..11ed571 --- /dev/null +++ b/src/app/core/services/events-api.service.ts @@ -0,0 +1,48 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + DefaultSuccessResponse, + EditEventRequest, + EventEntity, + ScopeDashboard, + Work, +} from '../models/api.types'; + +@Injectable({ providedIn: 'root' }) +export class EventsApiService { + private readonly http = inject(HttpClient); + + 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`); + } +} diff --git a/src/app/core/services/groups-api.service.ts b/src/app/core/services/groups-api.service.ts new file mode 100644 index 0000000..7f5dac2 --- /dev/null +++ b/src/app/core/services/groups-api.service.ts @@ -0,0 +1,57 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + DefaultSuccessResponse, + EditGroupRequest, + Group, + ScopeDashboard, +} from '../models/api.types'; + +@Injectable({ providedIn: 'root' }) +export class GroupsApiService { + private readonly http = inject(HttpClient); + + 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}`); + } +} diff --git a/src/app/core/services/reference-sets-api.service.ts b/src/app/core/services/reference-sets-api.service.ts new file mode 100644 index 0000000..aab2d74 --- /dev/null +++ b/src/app/core/services/reference-sets-api.service.ts @@ -0,0 +1,48 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + DefaultSuccessResponse, + EditRefSetRequest, + Ingestion, + ReferenceSet, +} from '../models/api.types'; + +@Injectable({ providedIn: 'root' }) +export class ReferenceSetsApiService { + private readonly http = inject(HttpClient); + + 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); + } +} diff --git a/src/app/core/services/students-api.service.ts b/src/app/core/services/students-api.service.ts new file mode 100644 index 0000000..ff3be62 --- /dev/null +++ b/src/app/core/services/students-api.service.ts @@ -0,0 +1,42 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + DefaultSuccessResponse, + EditStudentRequest, + ScopeDashboard, + Student, +} from '../models/api.types'; + +@Injectable({ providedIn: 'root' }) +export class StudentsApiService { + private readonly http = inject(HttpClient); + + 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`); + } +} diff --git a/src/app/core/services/users-api.service.ts b/src/app/core/services/users-api.service.ts new file mode 100644 index 0000000..e585f6a --- /dev/null +++ b/src/app/core/services/users-api.service.ts @@ -0,0 +1,21 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { CreateUserRequest, UserInfo } from '../models/api.types'; + +@Injectable({ providedIn: 'root' }) +export class UsersApiService { + private readonly http = inject(HttpClient); + + 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); + } +} diff --git a/src/app/core/services/works-api.service.ts b/src/app/core/services/works-api.service.ts index 42d2ca9..0c0facf 100644 --- a/src/app/core/services/works-api.service.ts +++ b/src/app/core/services/works-api.service.ts @@ -1,38 +1,24 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; +import { HttpClient } 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'; +/** + * API работ: CRUD, загрузка архива, запуск проверки, сводка и связанные прогоны/заимствования. + * Operations по конкретным прогонам живут в {@link AnalysisRunsApiService}. + */ @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'); @@ -87,200 +73,4 @@ export class WorksApiService { 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/risk-level.pipe.ts b/src/app/core/works/risk-level.pipe.ts new file mode 100644 index 0000000..1fce5a3 --- /dev/null +++ b/src/app/core/works/risk-level.pipe.ts @@ -0,0 +1,19 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +const TRANSLATIONS: Record = { + low: 'Низкий', + medium: 'Средний', + high: 'Высокий', + critical: 'Критический', +}; + +@Pipe({ + name: 'riskLevel', + standalone: true, +}) +export class RiskLevelPipe implements PipeTransform { + transform(value: string | null | undefined): string { + if (!value) return '—'; + return TRANSLATIONS[value.trim().toLowerCase()] ?? value; + } +} diff --git a/src/app/features/dashboard/dashboard.component.html b/src/app/features/dashboard/dashboard.component.html index 0536e36..4d0db1e 100644 --- a/src/app/features/dashboard/dashboard.component.html +++ b/src/app/features/dashboard/dashboard.component.html @@ -2,99 +2,14 @@

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

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

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

@@ -112,19 +27,21 @@ @if (studentsState$ | async; as state) { @switch (state.status) { @case ('loading') {
} - @case ('error') {

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

} + @case ('error') {} @case ('ok') { -
-
    - @for (student of state.items; track student.id) { -
  • - {{ student.name }} - #{{ student.id }} - {{ student.email }} -
  • - } -
-
+ @if (state.items.length > 0) { +
+
    + @for (student of state.items; track student.id) { +
  • + {{ student.name }} + #{{ student.id }} + {{ student.email }} +
  • + } +
+
+ } } } } @@ -133,7 +50,49 @@ } } - @case (3) { + @case (1) { + @defer { +
+

Новое мероприятие

+
+ + + + + + + + +
+
+ + @if (eventsState$ | async; as state) { + @switch (state.status) { + @case ('loading') {
} + @case ('error') {} + @case ('ok') { + @if (state.items.length > 0) { +
+
    + @for (event of state.items; track event.id) { +
  • + {{ event.name }} + #{{ event.id }} + {{ groupName(event.group_id) }}, {{ formatDate(event.date) }} +
  • + } +
+
+ } + } + } + } + } @placeholder { +
+ } + } + + @case (2) { @defer {

Новая группа

@@ -148,19 +107,21 @@ @if (groupsState$ | async; as state) { @switch (state.status) { @case ('loading') {
} - @case ('error') {

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

} + @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 }} -
  • - } -
-
+ @if (state.items.length > 0) { +
+
    + @for (group of state.items; track group.id) { +
  • + {{ group.name }} + #{{ group.id }} + students={{ group.students?.length ?? 0 }}, users={{ group.users?.length ?? 0 }} +
  • + } +
+
+ } } } } @@ -169,16 +130,16 @@ } } - @case (4) { + @case (3) { @defer { -
-

Новый reference set

+
+

Новая эталонная база

- + - +
@@ -187,19 +148,21 @@ @if (refsState$ | async; as state) { @switch (state.status) { @case ('loading') {
} - @case ('error') {

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

} + @case ('error') {} @case ('ok') { -
-
    - @for (ref of state.items; track ref.id) { -
  • - {{ ref.name }} - {{ ref.kind }} - {{ ref.description ?? '—' }} -
  • - } -
-
+ @if (state.items.length > 0) { +
+
    + @for (ref of state.items; track ref.id) { +
  • + {{ ref.name }} + {{ ref.kind }} + {{ ref.description ?? '—' }} +
  • + } +
+
+ } } } } diff --git a/src/app/features/dashboard/dashboard.component.ts b/src/app/features/dashboard/dashboard.component.ts index daf106d..0b7062f 100644 --- a/src/app/features/dashboard/dashboard.component.ts +++ b/src/app/features/dashboard/dashboard.component.ts @@ -1,6 +1,6 @@ import { AsyncPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; -import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { catchError, map, Observable, of, startWith, switchMap } from 'rxjs'; @@ -12,9 +12,17 @@ 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 { TuiSelect } from '@taiga-ui/kit/components/select'; +import { tuiItemsHandlersProvider } from '@taiga-ui/core/directives/items-handlers'; import { UserErrorNotifyService } from '../../core/notifications/user-error-notify.service'; +import { AuditApiService } from '../../core/services/audit-api.service'; +import { EventsApiService } from '../../core/services/events-api.service'; +import { GroupsApiService } from '../../core/services/groups-api.service'; +import { ReferenceSetsApiService } from '../../core/services/reference-sets-api.service'; +import { StudentsApiService } from '../../core/services/students-api.service'; import { WorksApiService } from '../../core/services/works-api.service'; import { formatDateTime } from '../../shared/utils/date-time.util'; +import type { EventEntity, Group, Student } from '../../core/models/api.types'; @Component({ selector: 'app-dashboard', @@ -30,31 +38,47 @@ import { formatDateTime } from '../../shared/utils/date-time.util'; TuiLoader, TuiTitle, TuiChip, + ...TuiSelect, ], templateUrl: './dashboard.component.html', styleUrl: './dashboard.component.css', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + tuiItemsHandlersProvider<{ name: string; id: number }>({ + stringify: signal((item) => `${item.name} (#${item.id})`), + }), + ], }) export class DashboardComponent { - private readonly api = inject(WorksApiService); + private readonly worksApi = inject(WorksApiService); + private readonly studentsApi = inject(StudentsApiService); + private readonly groupsApi = inject(GroupsApiService); + private readonly eventsApi = inject(EventsApiService); + private readonly refSetsApi = inject(ReferenceSetsApiService); + private readonly auditApi = inject(AuditApiService); 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); + // Works creation + protected readonly selectedWorkStudent = signal(null); + protected readonly selectedWorkEvent = signal(null); + // Students creation protected readonly createStudentName = signal(''); protected readonly createStudentEmail = signal(''); + // Groups creation protected readonly createGroupName = signal(''); + // Events creation protected readonly createEventName = signal(''); - protected readonly createEventGroupId = signal(''); + protected readonly selectedEventGroup = signal(null); protected readonly createEventDescription = signal(''); + // Reference sets creation protected readonly createRefName = signal(''); protected readonly createRefKind = signal('template'); protected readonly createRefDescription = signal(''); @@ -63,7 +87,7 @@ export class DashboardComponent { protected readonly worksState$ = toObservable(this.reloadTick).pipe( switchMap(() => - this.api.listWorks().pipe( + this.worksApi.listWorks().pipe( map((items) => ({ status: 'ok' as const, items })), catchError((error: unknown) => { this.userErrors.notifyError(error, 'Не удалось загрузить работы'); @@ -76,7 +100,7 @@ export class DashboardComponent { protected readonly studentsState$ = toObservable(this.reloadTick).pipe( switchMap(() => - this.api.listStudents().pipe( + this.studentsApi.listStudents().pipe( map((items) => ({ status: 'ok' as const, items })), catchError((error: unknown) => { this.userErrors.notifyError(error, 'Не удалось загрузить студентов'); @@ -89,7 +113,7 @@ export class DashboardComponent { protected readonly groupsState$ = toObservable(this.reloadTick).pipe( switchMap(() => - this.api.listGroups().pipe( + this.groupsApi.listGroups().pipe( map((items) => ({ status: 'ok' as const, items })), catchError((error: unknown) => { this.userErrors.notifyError(error, 'Не удалось загрузить группы'); @@ -102,7 +126,7 @@ export class DashboardComponent { protected readonly eventsState$ = toObservable(this.reloadTick).pipe( switchMap(() => - this.api.listEvents().pipe( + this.eventsApi.listEvents().pipe( map((items) => ({ status: 'ok' as const, items })), catchError((error: unknown) => { this.userErrors.notifyError(error, 'Не удалось загрузить события'); @@ -115,10 +139,10 @@ export class DashboardComponent { protected readonly refsState$ = toObservable(this.reloadTick).pipe( switchMap(() => - this.api.listReferenceSets().pipe( + this.refSetsApi.listReferenceSets().pipe( map((items) => ({ status: 'ok' as const, items })), catchError((error: unknown) => { - this.userErrors.notifyError(error, 'Не удалось загрузить reference sets'); + this.userErrors.notifyError(error, 'Не удалось загрузить эталонные базы'); return of({ status: 'error' as const }); }), startWith({ status: 'loading' as const }), @@ -128,7 +152,7 @@ export class DashboardComponent { protected readonly auditState$ = toObservable(this.reloadTick).pipe( switchMap(() => - this.api.listAuditLogs().pipe( + this.auditApi.listAuditLogs().pipe( map((items) => ({ status: 'ok' as const, items })), catchError((error: unknown) => { this.userErrors.notifyError(error, 'Не удалось загрузить аудит'); @@ -139,27 +163,56 @@ export class DashboardComponent { ), ); + // Derived option lists for selects (reactive to reloadTick) + protected readonly studentOptions = toSignal( + this.studentsState$.pipe(map((s) => { if (s.status === 'ok') return s.items; return null; })), + { initialValue: null }, + ); + protected readonly eventOptions = toSignal( + this.eventsState$.pipe(map((s) => { if (s.status === 'ok') return s.items; return null; })), + { initialValue: null }, + ); + protected readonly groupOptions = toSignal( + this.groupsState$.pipe(map((s) => { if (s.status === 'ok') return s.items; return null; })), + { initialValue: null }, + ); + + protected studentName(id: number): string { + return this.studentOptions()?.find((s) => s.id === id)?.name ?? `#${id}`; + } + + protected eventName(id: number): string { + return this.eventOptions()?.find((e) => e.id === id)?.name ?? `#${id}`; + } + + protected groupName(id: number): string { + return this.groupOptions()?.find((g) => g.id === id)?.name ?? `#${id}`; + } + 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'), 'Валидация'); + const studentId = this.selectedWorkStudent()?.id; + const eventId = this.selectedWorkEvent()?.id; + if (studentId == null || eventId == null) { + this.userErrors.notifyError(new Error('Выберите студента и мероприятие'), 'Валидация'); return; } - this.submit(this.api.createWork({ - student_id: studentId, - event_id: eventId, - time: new Date().toISOString(), - })); + this.submit( + this.worksApi.createWork({ + student_id: studentId, + event_id: eventId, + time: new Date().toISOString(), + }), + () => { this.selectedWorkStudent.set(null); this.selectedWorkEvent.set(null); }, + ); } protected createStudent(): void { this.submit( - this.api.createStudent({ + this.studentsApi.createStudent({ name: this.createStudentName().trim(), email: this.createStudentEmail().trim(), }), @@ -167,28 +220,29 @@ export class DashboardComponent { } protected createGroup(): void { - this.submit(this.api.createGroup({ name: this.createGroupName().trim() })); + this.submit(this.groupsApi.createGroup({ name: this.createGroupName().trim() })); } protected createEvent(): void { - const groupId = Number(this.createEventGroupId()); - if (!Number.isInteger(groupId)) { - this.userErrors.notifyError(new Error('Некорректный group_id'), 'Валидация'); + const groupId = this.selectedEventGroup()?.id; + if (groupId == null) { + this.userErrors.notifyError(new Error('Выберите группу'), 'Валидация'); return; } this.submit( - this.api.createEvent({ + this.eventsApi.createEvent({ name: this.createEventName().trim(), description: this.createEventDescription().trim(), group_id: groupId, date: new Date().toISOString(), }), + () => this.selectedEventGroup.set(null), ); } protected createReferenceSet(): void { this.submit( - this.api.createReferenceSet({ + this.refSetsApi.createReferenceSet({ name: this.createRefName().trim(), kind: this.createRefKind().trim(), description: this.createRefDescription().trim(), @@ -196,11 +250,12 @@ export class DashboardComponent { ); } - private submit(request$: Observable): void { + private submit(request$: Observable, onSuccess?: () => void): void { this.isSubmitting.set(true); request$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ next: () => { this.isSubmitting.set(false); + onSuccess?.(); this.refresh(); }, error: (error: unknown) => { diff --git a/src/app/features/events/event-detail.component.html b/src/app/features/events/event-detail.component.html index 1a164fe..a120b12 100644 --- a/src/app/features/events/event-detail.component.html +++ b/src/app/features/events/event-detail.component.html @@ -18,6 +18,7 @@ + @@ -33,7 +34,7 @@
Описание
{{ state.event.description ?? '—' }}
Группа
-
{{ state.event.group_id }}
+
{{ (groupName$ | async) ?? '#' + state.event.group_id }}
Дата загрузки
{{ formatDate(state.event.date) }}
@@ -46,17 +47,19 @@ @if (worksState$ | async; as ws) { @switch (ws.status) { @case ('loading') { } - @case ('error') {

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

} + @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) }} -
  • + @if (studentNamesMap$ | async; as names) { + @for (w of ws.works; track w.id) { +
  • + Работа #{{ w.id }} + {{ names[w.student_id] ?? '#' + w.student_id }}, {{ formatDate(w.time) }} +
  • + } }
} @@ -72,14 +75,14 @@ @if (summaryState$ | async; as ss) { @switch (ss.status) { @case ('loading') { } - @case ('error') {

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

} + @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 ?? '—' }}
+
Доля заимствований{{ m.plagiarism_rate ?? '—' }}
+
Уровень риска{{ m.risk_level | riskLevel }}
} @else {

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

@@ -91,9 +94,9 @@
    @for (c of cards; track c.work_id) {
  • - Работа {{ c.work_id }} - {{ c.risk_level ?? '—' }} - {{ c.student_name ?? '—' }}, score={{ c.trust_score ?? '—' }} + Работа #{{ c.work_id }} + {{ c.risk_level | riskLevel }} + {{ c.student_name ?? '—' }}, индекс доверия: {{ c.trust_score ?? '—' }}
  • }
@@ -106,6 +109,18 @@ } @case (3) { +
+ @if (summaryState$ | async; as ss) { + @if (ss.status === 'ok') { + + } @else if (ss.status === 'loading') { +
+ } + } +
+ } + + @case (4) {

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

diff --git a/src/app/features/events/event-detail.component.ts b/src/app/features/events/event-detail.component.ts index 50a095d..2a49611 100644 --- a/src/app/features/events/event-detail.component.ts +++ b/src/app/features/events/event-detail.component.ts @@ -4,7 +4,7 @@ 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 { catchError, map, of, shareReplay, 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'; @@ -13,19 +13,25 @@ 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 { RiskLevelPipe } from '../../core/works/risk-level.pipe'; +import { PlagiarismGraphComponent } from '../../shared/components/plagiarism-graph/plagiarism-graph.component'; import { UserErrorNotifyService } from '../../core/notifications/user-error-notify.service'; -import { WorksApiService } from '../../core/services/works-api.service'; +import { EventsApiService } from '../../core/services/events-api.service'; +import { GroupsApiService } from '../../core/services/groups-api.service'; +import { StudentsApiService } from '../../core/services/students-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], + imports: [AsyncPipe, RouterLink, FormsModule, TuiButton, TuiLink, TuiLoader, TuiTitle, TuiTabs, TuiChip, TuiTextfield, TuiInputDirective, RiskLevelPipe, PlagiarismGraphComponent], templateUrl: './event-detail.component.html', styleUrl: './event-detail.component.css', changeDetection: ChangeDetectionStrategy.OnPush, }) export class EventDetailComponent { - private readonly api = inject(WorksApiService); + private readonly api = inject(EventsApiService); + private readonly groupsApi = inject(GroupsApiService); + private readonly studentsApi = inject(StudentsApiService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly userErrors = inject(UserErrorNotifyService); @@ -57,6 +63,27 @@ export class EventDetailComponent { startWith({ status: 'loading' as const }), ), ), + shareReplay(1), + ); + + protected readonly groupName$ = this.eventState$.pipe( + switchMap((state) => { + if (state.status !== 'ok') return of(null); + return this.groupsApi.getGroup(state.event.group_id).pipe( + map((g) => g.name), + catchError(() => of(null)), + ); + }), + ); + + protected readonly studentNamesMap$ = toObservable(this.reloadTick).pipe( + switchMap(() => + this.studentsApi.listStudents().pipe( + map((students) => Object.fromEntries(students.map((s) => [s.id, s.name])) as Record), + catchError(() => of({} as Record)), + ), + ), + shareReplay(1), ); protected readonly worksState$ = toObservable(this.reloadTick).pipe( diff --git a/src/app/features/groups/group-detail.component.html b/src/app/features/groups/group-detail.component.html index 3c022aa..50baf9c 100644 --- a/src/app/features/groups/group-detail.component.html +++ b/src/app/features/groups/group-detail.component.html @@ -12,6 +12,7 @@ + @@ -34,7 +35,7 @@
    @for (sid of state.group.students; track sid) {
  • - Студент #{{ sid }} + {{ getStudentName(sid) }}
  • } @@ -44,7 +45,8 @@ } - + + @@ -54,7 +56,7 @@
      @for (uid of state.group.users; track uid) {
    • - Пользователь #{{ uid }} + {{ getUserName(uid) }}
    • } @@ -64,7 +66,8 @@ }
      - + +
      @@ -77,14 +80,14 @@ @if (statsState$ | async; as ss) { @switch (ss.status) { @case ('loading') { } - @case ('error') {

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

      } + @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 ?? '—' }}
      +
      Доля заимствований{{ m.plagiarism_rate ?? '—' }}
      +
      Уровень риска{{ m.risk_level | riskLevel }}
      } @else {

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

      } } @@ -94,6 +97,18 @@ } @case (3) { +
      + @if (statsState$ | async; as ss) { + @if (ss.status === 'ok') { + + } @else if (ss.status === 'loading') { +
      + } + } +
      + } + + @case (4) {

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

      diff --git a/src/app/features/groups/group-detail.component.ts b/src/app/features/groups/group-detail.component.ts index 415f64f..c673a49 100644 --- a/src/app/features/groups/group-detail.component.ts +++ b/src/app/features/groups/group-detail.component.ts @@ -1,7 +1,7 @@ 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 { takeUntilDestroyed, toObservable, toSignal } 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'; @@ -11,19 +11,32 @@ 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 { TuiSelect } from '@taiga-ui/kit/components/select'; +import { tuiItemsHandlersProvider } from '@taiga-ui/core/directives/items-handlers'; +import { RiskLevelPipe } from '../../core/works/risk-level.pipe'; +import { PlagiarismGraphComponent } from '../../shared/components/plagiarism-graph/plagiarism-graph.component'; import { UserErrorNotifyService } from '../../core/notifications/user-error-notify.service'; -import { WorksApiService } from '../../core/services/works-api.service'; +import { GroupsApiService } from '../../core/services/groups-api.service'; +import { StudentsApiService } from '../../core/services/students-api.service'; +import { UsersApiService } from '../../core/services/users-api.service'; +import type { Student, UserInfo } from '../../core/models/api.types'; @Component({ selector: 'app-group-detail', - imports: [AsyncPipe, RouterLink, FormsModule, TuiButton, TuiLink, TuiLoader, TuiTitle, TuiTabs, TuiTextfield, TuiInputDirective], + imports: [AsyncPipe, RouterLink, FormsModule, TuiButton, TuiLink, TuiLoader, TuiTitle, TuiTabs, TuiTextfield, TuiInputDirective, ...TuiSelect, RiskLevelPipe, PlagiarismGraphComponent], templateUrl: './group-detail.component.html', styleUrl: './group-detail.component.css', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + tuiItemsHandlersProvider<{ name: string; id: number }>({ + stringify: signal((item) => `${item.name} (#${item.id})`), + }), + ], }) export class GroupDetailComponent { - private readonly api = inject(WorksApiService); + private readonly api = inject(GroupsApiService); + private readonly studentsApi = inject(StudentsApiService); + private readonly usersApi = inject(UsersApiService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly userErrors = inject(UserErrorNotifyService); @@ -36,8 +49,17 @@ export class GroupDetailComponent { protected readonly isSaving = signal(false); protected readonly editName = signal(''); - protected readonly addStudentId = signal(''); - protected readonly addUserId = signal(''); + protected readonly selectedAddStudent = signal(null); + protected readonly selectedAddUser = signal(null); + + protected readonly studentOptions = toSignal( + this.studentsApi.listStudents().pipe(catchError(() => of(null))), + { initialValue: null }, + ); + protected readonly userOptions = toSignal( + this.usersApi.listUsers().pipe(catchError(() => of(null))), + { initialValue: null }, + ); protected readonly groupState$ = toObservable(this.reloadTick).pipe( switchMap(() => @@ -65,6 +87,14 @@ export class GroupDetailComponent { ), ); + protected getStudentName(id: number): string { + return this.studentOptions()?.find((s) => s.id === id)?.name ?? `#${id}`; + } + + protected getUserName(id: number): string { + return this.userOptions()?.find((u) => u.id === id)?.name ?? `#${id}`; + } + protected reload(): void { this.reloadTick.update((v) => v + 1); } @@ -90,15 +120,15 @@ export class GroupDetailComponent { } protected addStudent(): void { - const sid = Number(this.addStudentId()); - if (!Number.isInteger(sid)) { - this.userErrors.notifyError(new Error('Некорректный student_id'), 'Валидация'); + const sid = this.selectedAddStudent()?.id; + if (sid == null) { + this.userErrors.notifyError(new Error('Выберите студента'), 'Валидация'); return; } this.api.addStudentToGroup(this.groupId(), sid) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: () => { this.addStudentId.set(''); this.reload(); }, + next: () => { this.selectedAddStudent.set(null); this.reload(); }, error: (e: unknown) => { this.userErrors.notifyError(e, 'Ошибка добавления студента'); }, }); } @@ -113,15 +143,15 @@ export class GroupDetailComponent { } protected addUser(): void { - const uid = Number(this.addUserId()); - if (!Number.isInteger(uid)) { - this.userErrors.notifyError(new Error('Некорректный user_id'), 'Валидация'); + const uid = this.selectedAddUser()?.id; + if (uid == null) { + this.userErrors.notifyError(new Error('Выберите пользователя'), 'Валидация'); return; } this.api.addUserToGroup(this.groupId(), uid) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: () => { this.addUserId.set(''); this.reload(); }, + next: () => { this.selectedAddUser.set(null); this.reload(); }, error: (e: unknown) => { this.userErrors.notifyError(e, 'Ошибка добавления пользователя'); }, }); } diff --git a/src/app/features/landing/landing.component.html b/src/app/features/landing/landing.component.html index 6dd0df7..ccf567f 100644 --- a/src/app/features/landing/landing.component.html +++ b/src/app/features/landing/landing.component.html @@ -35,7 +35,7 @@
      -

      Reference Sets

      +

      Эталонные базы

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

      @@ -127,7 +127,7 @@

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

      diff --git a/src/app/features/monitoring/monitoring.component.ts b/src/app/features/monitoring/monitoring.component.ts index 6f5ed44..9f47819 100644 --- a/src/app/features/monitoring/monitoring.component.ts +++ b/src/app/features/monitoring/monitoring.component.ts @@ -7,7 +7,7 @@ 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 { AuditApiService } from '../../core/services/audit-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'; @@ -20,7 +20,7 @@ import { formatTimestamp } from '../../shared/utils/date-time.util'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class MonitoringComponent { - private readonly api = inject(WorksApiService); + private readonly api = inject(AuditApiService); private readonly reloadTick = signal(0); protected readonly auditState$ = toObservable(this.reloadTick).pipe( diff --git a/src/app/features/reference-sets/refset-detail.component.html b/src/app/features/reference-sets/refset-detail.component.html index 624ee11..cfb9ce8 100644 --- a/src/app/features/reference-sets/refset-detail.component.html +++ b/src/app/features/reference-sets/refset-detail.component.html @@ -4,13 +4,13 @@ @if (refSetState$ | async; as state) { @switch (state.status) { @case ('loading') {
      } - @case ('error') {

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

      } + @case ('error') {

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

      } @case ('ok') {

      {{ state.refSet.name }}

      - + @@ -27,34 +27,33 @@ } @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) }} -
      • - } -
      - } - } - } + @if (ingestionsState$ | async; as is) { + @if (is.status === 'loading') { +
      +
      +
      } + @if (is.status === 'ok' && is.items.length > 0) { +
      +

      Индексации

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

      Загрузить ingestion

      +
      +

      Загрузить индексацию

      - +
      } @@ -67,7 +66,7 @@ - + @@ -77,7 +76,7 @@
      - +
      } diff --git a/src/app/features/reference-sets/refset-detail.component.ts b/src/app/features/reference-sets/refset-detail.component.ts index 724b949..817ee41 100644 --- a/src/app/features/reference-sets/refset-detail.component.ts +++ b/src/app/features/reference-sets/refset-detail.component.ts @@ -12,7 +12,7 @@ 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 { ReferenceSetsApiService } from '../../core/services/reference-sets-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'; @@ -26,7 +26,7 @@ import { UserErrorNotifyService } from '../../core/notifications/user-error-noti changeDetection: ChangeDetectionStrategy.OnPush, }) export class RefsetDetailComponent { - private readonly api = inject(WorksApiService); + private readonly api = inject(ReferenceSetsApiService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly userErrors = inject(UserErrorNotifyService); diff --git a/src/app/features/students/student-detail.component.html b/src/app/features/students/student-detail.component.html index b9837b0..e6b3d12 100644 --- a/src/app/features/students/student-detail.component.html +++ b/src/app/features/students/student-detail.component.html @@ -11,6 +11,7 @@ + @@ -32,15 +33,15 @@ @if (statsState$ | async; as ss) { @switch (ss.status) { @case ('loading') { } - @case ('error') {

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

      } + @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 ?? '—' }}
      +
      Доля заимствований{{ m.plagiarism_rate ?? '—' }}
      +
      Индекс доверия{{ m.trust_score ?? '—' }}
      +
      Уровень риска{{ m.risk_level | riskLevel }}
      } @else {

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

      } @@ -50,9 +51,9 @@ @@ -65,6 +66,18 @@ } @case (2) { +
      + @if (statsState$ | async; as ss) { + @if (ss.status === 'ok') { + + } @else if (ss.status === 'loading') { +
      + } + } +
      + } + + @case (3) {

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

      diff --git a/src/app/features/students/student-detail.component.ts b/src/app/features/students/student-detail.component.ts index 3fbd7a7..76e2fa1 100644 --- a/src/app/features/students/student-detail.component.ts +++ b/src/app/features/students/student-detail.component.ts @@ -12,18 +12,20 @@ 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 { RiskLevelPipe } from '../../core/works/risk-level.pipe'; +import { PlagiarismGraphComponent } from '../../shared/components/plagiarism-graph/plagiarism-graph.component'; import { UserErrorNotifyService } from '../../core/notifications/user-error-notify.service'; -import { WorksApiService } from '../../core/services/works-api.service'; +import { StudentsApiService } from '../../core/services/students-api.service'; @Component({ selector: 'app-student-detail', - imports: [AsyncPipe, RouterLink, FormsModule, TuiButton, TuiLink, TuiLoader, TuiTitle, TuiTabs, TuiChip, TuiTextfield, TuiInputDirective], + imports: [AsyncPipe, RouterLink, FormsModule, TuiButton, TuiLink, TuiLoader, TuiTitle, TuiTabs, TuiChip, TuiTextfield, TuiInputDirective, RiskLevelPipe, PlagiarismGraphComponent], templateUrl: './student-detail.component.html', styleUrl: './student-detail.component.css', changeDetection: ChangeDetectionStrategy.OnPush, }) export class StudentDetailComponent { - private readonly api = inject(WorksApiService); + private readonly api = inject(StudentsApiService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly userErrors = inject(UserErrorNotifyService); diff --git a/src/app/features/works/work-detail/work-detail.component.css b/src/app/features/works/work-detail/work-detail.component.css index 29f64d5..5d37f86 100644 --- a/src/app/features/works/work-detail/work-detail.component.css +++ b/src/app/features/works/work-detail/work-detail.component.css @@ -80,19 +80,62 @@ margin: 0.5rem 0 0; } +/* Runs table */ +.table-wrap { + overflow: auto; + max-height: min(600px, 80vh); +} + +.runs-table { + width: 100%; + border-collapse: collapse; + font: var(--tui-font-text-s); +} + +.runs-table th { + text-align: left; + color: var(--tui-text-primary); + padding: 0.5rem 0.75rem; + font-weight: 500; +} + +.runs-table tbody td { + padding: 0.5rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--tui-border-normal); + vertical-align: middle; +} + +.runs-table-row { + cursor: pointer; + transition: background-color 0.12s ease; +} + +.runs-table-row:hover { + background: color-mix(in srgb, var(--tui-background-accent-1) 12%, transparent); +} + +.runs-table-row_active { + background: color-mix(in srgb, var(--sg-color-accent) 14%, transparent); +} + /* Report */ .report-head { display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 0.75rem 1rem; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + text-align: left; } .report-head .section-title { margin: 0; } +.report-head p { + margin: 0; +} + .report-actions { display: flex; flex-wrap: wrap; diff --git a/src/app/features/works/work-detail/work-detail.component.html b/src/app/features/works/work-detail/work-detail.component.html index e6541e9..3b56164 100644 --- a/src/app/features/works/work-detail/work-detail.component.html +++ b/src/app/features/works/work-detail/work-detail.component.html @@ -20,6 +20,7 @@ + @@ -31,9 +32,9 @@
      Идентификатор
      {{ state.work.id }}
      Студент
      -
      #{{ state.work.student_id }}
      +
      {{ (studentName$ | async) ?? '#' + state.work.student_id }}
      Мероприятие
      -
      #{{ state.work.event_id }}
      +
      {{ (eventName$ | async) ?? '#' + state.work.event_id }}
      Время
      {{ formatDateTime(state.work.time) }}
      Архив
      @@ -54,29 +55,29 @@ @if (summaryState$ | async; as summaryState) { @if (summaryState.status === 'ok') {
      -

      Summary

      +

      Сводка

      - Risk - {{ summaryState.summary.presentation_summary?.risk_level ?? '—' }} + Уровень риска + {{ summaryState.summary.presentation_summary?.risk_level | riskLevel }}
      - Trust score + Индекс доверия {{ summaryState.summary.presentation_summary?.trust_score ?? '—' }}
      - Plagiarism rate + Доля заимствований {{ summaryState.summary.presentation_summary?.plagiarism_rate ?? '—' }}
      - Counterparts + Совпадений {{ summaryState.summary.presentation_summary?.counterparts_count ?? 0 }}
      @@ -128,7 +129,7 @@
      @if (isPolling()) { -

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

      +

      Опрос статуса запущен...

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

      @@ -140,7 +141,7 @@ @case (2) {

      -

      Analysis runs

      +

      Запуски проверки

      @if (runsState$ | async; as runsState) { @switch (runsState.status) { @case ('loading') { @@ -148,32 +149,47 @@ } - @case ('error') { -

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

      - } + @case ('error') {} @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) }}) +
        + + + + + + + + + + + + @for (run of runsState.runs; track run.id) { + + + + + + + } - @if (run.status === 'Failed' || run.status === 'Completed') { - - } - - } - + +
        IDСтатусОбновленоДлительность
        #{{ run.id }}{{ run.status | analysisRunStatus }}{{ formatDateTime(run.updated_at) }}{{ getRunDuration(run) || '—' }} + @if (run.status === 'Failed' || run.status === 'Completed') { + + } +
        +
        } } } @@ -181,7 +197,7 @@
      -

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

      +

      Совпадения выбранного запуска

      @if (adoptionsState$ | async; as adoptState) { @switch (adoptState.status) { @case ('idle') { @@ -192,9 +208,7 @@ } - @case ('error') { -

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

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

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

      @if (adoptState.adoptions.length > 0) { @@ -204,9 +218,9 @@
      ID
      {{ adoption.id }}
      -
      Path
      +
      Путь
      {{ adoption.path ?? '—' }}
      -
      Score
      +
      Схожесть
      {{ adoption.similarity_score ?? '—' }}
      @if (adoption.segment_excerpt) { @@ -223,10 +237,19 @@ } @case (3) { +
      + @if (summaryState$ | async; as ss) { + @if (ss.status === 'ok') { + + } @else if (ss.status === 'loading') { +
      + } + } +
      + } + + @case (4) {
      -
      -

      Teacher report

      -
      } - @case ('error') { -
      -

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

      -
      - } + @case ('error') {} @case ('ok') {
      @if (state.works.length === 0) { @@ -63,10 +57,10 @@ @for (work of state.works; track work.id) {
    • - Работа {{ work.id }} + Работа #{{ work.id }} - student={{ work.student_id }}, event={{ work.event_id }} + {{ studentName(work.student_id) }}, {{ eventName(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 index dad9547..b5da5f2 100644 --- a/src/app/features/works/works-list/works-list.component.ts +++ b/src/app/features/works/works-list/works-list.component.ts @@ -2,18 +2,20 @@ import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from ' import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { AsyncPipe } from '@angular/common'; -import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable, toSignal } 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 { TuiSelect } from '@taiga-ui/kit/components/select'; +import { tuiItemsHandlersProvider } from '@taiga-ui/core/directives/items-handlers'; import { WorksApiService } from '../../../core/services/works-api.service'; +import { StudentsApiService } from '../../../core/services/students-api.service'; +import { EventsApiService } from '../../../core/services/events-api.service'; import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service'; import { formatDateTime } from '../../../shared/utils/date-time.util'; +import type { Student, EventEntity } from '../../../core/models/api.types'; @Component({ selector: 'app-works-list', @@ -22,27 +24,41 @@ import { formatDateTime } from '../../../shared/utils/date-time.util'; FormsModule, RouterLink, TuiButton, - TuiInputDirective, - TuiLabel, TuiLink, TuiLoader, - TuiTextfield, TuiTitle, + ...TuiSelect, ], templateUrl: './works-list.component.html', styleUrl: './works-list.component.css', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + tuiItemsHandlersProvider<{ name: string; id: number }>({ + stringify: signal((item) => `${item.name} (#${item.id})`), + }), + ], }) export class WorksListComponent { private readonly api = inject(WorksApiService); + private readonly studentsApi = inject(StudentsApiService); + private readonly eventsApi = inject(EventsApiService); 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 selectedStudent = signal(null); + protected readonly selectedEvent = signal(null); protected readonly isCreating = signal(false); + protected readonly studentOptions = toSignal( + this.studentsApi.listStudents().pipe(catchError(() => of(null))), + { initialValue: null }, + ); + protected readonly eventOptions = toSignal( + this.eventsApi.listEvents().pipe(catchError(() => of(null))), + { initialValue: null }, + ); + protected readonly worksState$ = toObservable(this.reloadTick).pipe( switchMap(() => this.api.listWorks().pipe( @@ -57,10 +73,10 @@ export class WorksListComponent { ); 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'); + const studentId = this.selectedStudent()?.id; + const eventId = this.selectedEvent()?.id; + if (studentId == null || eventId == null) { + this.userErrors.notifyError(new Error('Выберите студента и мероприятие'), 'Валидация'); return; } @@ -75,8 +91,8 @@ export class WorksListComponent { .subscribe({ next: () => { this.isCreating.set(false); - this.createStudentId.set(''); - this.createEventId.set(''); + this.selectedStudent.set(null); + this.selectedEvent.set(null); this.reloadTick.update((value) => value + 1); }, error: (error: unknown) => { @@ -86,6 +102,14 @@ export class WorksListComponent { }); } + protected studentName(id: number): string { + return this.studentOptions()?.find((s) => s.id === id)?.name ?? `#${id}`; + } + + protected eventName(id: number): string { + return this.eventOptions()?.find((e) => e.id === id)?.name ?? `#${id}`; + } + protected reload(): void { this.reloadTick.update((value) => value + 1); } diff --git a/src/app/shared/components/plagiarism-graph/plagiarism-graph.component.css b/src/app/shared/components/plagiarism-graph/plagiarism-graph.component.css new file mode 100644 index 0000000..060e617 --- /dev/null +++ b/src/app/shared/components/plagiarism-graph/plagiarism-graph.component.css @@ -0,0 +1,81 @@ +:host { + display: block; + width: 100%; +} + +.graph-root { + position: relative; + width: 100%; +} + +.graph-wrap { + width: 100%; + height: 500px; + border-radius: var(--tui-radius-m); + background: var(--sg-color-bg); + overflow: hidden; +} + +/* Legend */ +.graph-legend { + position: absolute; + top: 0.75rem; + right: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: color-mix(in srgb, var(--sg-color-card-bg) 90%, transparent); + border: 1px solid var(--sg-color-border); + border-radius: var(--tui-radius-m); + font: var(--tui-font-text-xs); + color: var(--sg-color-text); + pointer-events: none; + max-width: 180px; +} + +.legend-title { + margin: 0 0 0.35rem; + font-weight: 500; + color: var(--sg-color-subtitle); +} + +.legend-section { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.legend-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.legend-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.legend-line { + display: inline-block; + height: 2px; + width: 24px; + background: var(--sg-color-text); + border-radius: 1px; + flex-shrink: 0; + opacity: 0.5; +} + +.legend-line_thick { + height: 4px; + opacity: 0.8; +} + +.legend-hint { + color: var(--tui-text-tertiary); + line-height: 1.4; +} diff --git a/src/app/shared/components/plagiarism-graph/plagiarism-graph.component.ts b/src/app/shared/components/plagiarism-graph/plagiarism-graph.component.ts new file mode 100644 index 0000000..89e9a01 --- /dev/null +++ b/src/app/shared/components/plagiarism-graph/plagiarism-graph.component.ts @@ -0,0 +1,218 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + OnChanges, + OnDestroy, + ViewChild, +} from '@angular/core'; +import ForceGraph from 'force-graph'; +import type { NodeObject } from 'force-graph'; +import type { DashboardGraph } from '../../../core/models/api.types'; + +const RISK_COLORS: Record = { + low: '#16a34a', + medium: '#eab308', + high: '#f97316', + critical: '#d92d20', +}; + +const DEFAULT_NODE_COLOR = '#9ca3af'; +const IN_SCOPE_COLOR = '#ffdb00'; // --sg-color-accent + +function abbreviateName(name: string): string { + const parts = name.trim().split(/\s+/); + if (parts.length < 2) return name; + const [last, first, patronymic] = parts; + const initials = [first, patronymic] + .filter(Boolean) + .map((p) => p[0].toUpperCase() + '.') + .join(''); + return `${last} ${initials}`; +} + +function riskColor(level: string | null | undefined): string { + if (!level) return DEFAULT_NODE_COLOR; + return RISK_COLORS[level.trim().toLowerCase()] ?? DEFAULT_NODE_COLOR; +} + +interface GraphNode extends NodeObject { + id: number; + label: string; + studentName: string; + riskLevel: string | undefined; + plagiarismRate: number | undefined; + inScope: boolean; + needsReview: boolean; +} + +interface GraphLink { + source: number; + target: number; + score: number; + maxSimilarity: number | undefined; + matchCount: number | undefined; + riskLevel: string | undefined; +} + +@Component({ + selector: 'app-plagiarism-graph', + standalone: true, + template: ` +
      +
      +
      +
      +

      Узлы

      +
      Текущая сущность
      +
      Низкий риск
      +
      Средний риск
      +
      Высокий риск
      +
      Критический риск
      +
      Нет данных
      +
      +
      +

      Рёбра

      +
      Слабое совпадение
      +
      Сильное совпадение
      +
      +
      +

      Управление

      +
      Перетащите узел для перемещения
      +
      Колесо мыши — масштаб
      +
      Hover по ребру — метрики
      +
      +
      +
      + `, + styleUrl: './plagiarism-graph.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PlagiarismGraphComponent implements AfterViewInit, OnChanges, OnDestroy { + @Input() graph: DashboardGraph | null | undefined; + @ViewChild('container') containerRef!: ElementRef; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private fg: any = null; + private ready = false; + + ngAfterViewInit(): void { + this.ready = true; + this.initGraph(); + } + + ngOnChanges(): void { + if (this.ready) { + this.updateData(); + } + } + + ngOnDestroy(): void { + this.fg?._destructor?.(); + this.fg = null; + } + + private initGraph(): void { + const el = this.containerRef.nativeElement; + + this.fg = new ForceGraph(el) + .backgroundColor('transparent') + .nodeId('id') + .nodeLabel((n: object) => { + const node = n as GraphNode; + const lines = [node.studentName || `Работа #${node.id}`]; + if (node.riskLevel) lines.push(`Риск: ${node.riskLevel}`); + if (node.plagiarismRate != null) lines.push(`Заимствований: ${(node.plagiarismRate * 100).toFixed(1)}%`); + return lines.join('\n'); + }) + .nodeColor((n: object) => { + const node = n as GraphNode; + if (node.inScope) return IN_SCOPE_COLOR; + return riskColor(node.riskLevel); + }) + .nodeRelSize(6) + .nodeCanvasObjectMode(() => 'after') + .nodeCanvasObject((n: object, ctx: CanvasRenderingContext2D, globalScale: number) => { + const node = n as GraphNode & { x?: number; y?: number }; + if (!node.x || !node.y) return; + const label = node.studentName ? abbreviateName(node.studentName) : `#${node.id}`; + const fontSize = Math.max(10, 12 / globalScale); + ctx.font = `${fontSize}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#383839'; + ctx.fillText(label, node.x, node.y + 12); + }) + .linkColor((l: object) => { + const link = l as GraphLink; + return riskColor(link.riskLevel) + 'aa'; + }) + .linkWidth((l: object) => { + const link = l as GraphLink; + return 1 + (link.score ?? 0) * 3; + }) + .linkLabel((l: object) => { + const link = l as GraphLink; + const parts: string[] = []; + if (link.score != null) parts.push(`Счёт: ${link.score.toFixed(2)}`); + if (link.maxSimilarity != null) parts.push(`Макс. схожесть: ${(link.maxSimilarity * 100).toFixed(1)}%`); + if (link.matchCount != null) parts.push(`Совпадений: ${link.matchCount}`); + return parts.join(' · '); + }) + .linkDirectionalParticles(2) + .linkDirectionalParticleSpeed((l: object) => ((l as GraphLink).score ?? 0.5) * 0.004) + .width(el.clientWidth || 800) + .height(500); + + this.updateData(); + } + + private updateData(): void { + if (!this.fg) return; + + const edges = this.graph?.edges ?? []; + + // Build a map: workId → worst risk level derived from connected edges + const RISK_ORDER = ['critical', 'high', 'medium', 'low']; + const edgeRiskByNode = new Map(); + for (const e of edges) { + for (const id of [e.from_work_id, e.to_work_id]) { + if (id == null || !e.risk_level) continue; + const current = edgeRiskByNode.get(id); + const incoming = e.risk_level.trim().toLowerCase(); + if (!current || RISK_ORDER.indexOf(incoming) < RISK_ORDER.indexOf(current)) { + edgeRiskByNode.set(id, incoming); + } + } + } + + const nodes: GraphNode[] = (this.graph?.nodes ?? []).map((n) => { + const id = n.work_id ?? 0; + const nodeRisk = n.risk_level ?? edgeRiskByNode.get(id); + return { + id, + label: n.label ?? `#${n.work_id}`, + studentName: n.student_name ?? '', + riskLevel: nodeRisk, + plagiarismRate: n.plagiarism_rate, + inScope: n.in_scope ?? false, + needsReview: n.needs_review ?? false, + }; + }); + + const links: GraphLink[] = edges + .filter((e) => e.from_work_id != null && e.to_work_id != null) + .map((e) => ({ + source: e.from_work_id!, + target: e.to_work_id!, + score: e.score ?? 0, + maxSimilarity: e.max_similarity, + matchCount: e.match_count, + riskLevel: e.risk_level, + })); + + this.fg.graphData({ nodes, links }); + } +} diff --git a/src/index.html b/src/index.html index f48b380..e0a32b0 100644 --- a/src/index.html +++ b/src/index.html @@ -5,7 +5,7 @@ SparkAntiplagiat - + diff --git a/src/styles/sg-input-fields.css b/src/styles/sg-input-fields.css index c7a977e..dab120c 100644 --- a/src/styles/sg-input-fields.css +++ b/src/styles/sg-input-fields.css @@ -52,6 +52,25 @@ tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data- box-shadow: none; } +/* select[tuiSelect] (TuiNativeSelect) inside sg-tui-textfield */ +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'] select { + background: transparent; + border: none; + outline: none; + box-shadow: none; + color: var(--sg-color-text); + font: inherit; + width: 100%; + cursor: pointer; +} + +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']:focus-within select, +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data-focus='true'] select { + outline: none; + box-shadow: none; + color: var(--sg-color-text); +} + 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] */ diff --git a/src/styles/shared-components.css b/src/styles/shared-components.css index 0250aa6..33c134f 100644 --- a/src/styles/shared-components.css +++ b/src/styles/shared-components.css @@ -18,6 +18,7 @@ button.accent-cta[tuiAppearance][data-appearance='primary']:hover:not([data-stat display: grid; grid-template-columns: minmax(10rem, 14rem) 1fr; gap: 0.35rem 1rem; + align-items: baseline; margin: 0; font: var(--tui-font-text-s); }