feat: add plagiarism graph, domain API services, error/empty-state handling, and UI polish
Some checks failed
CI / checks (push) Has been cancelled
Some checks failed
CI / checks (push) Has been cancelled
- 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 - Add CLAUDE.md, .env.example, proxy config, CI workflow, and Makefile Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
93
.claudeignore
Normal file
93
.claudeignore
Normal file
@@ -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
|
||||
14
.env.example
Normal file
14
.env.example
Normal file
@@ -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
|
||||
44
.gitea/workflows/ci.yml
Normal file
44
.gitea/workflows/ci.yml
Normal file
@@ -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
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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/
|
||||
|
||||
24
CLAUDE.md
Normal file
24
CLAUDE.md
Normal file
@@ -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 **не коммитится** — это артефакт.
|
||||
@@ -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-<name>` (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+)
|
||||
|
||||
44
Makefile
Normal file
44
Makefile
Normal file
@@ -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
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
361
package-lock.json
generated
361
package-lock.json
generated
@@ -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",
|
||||
|
||||
11
package.json
11
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",
|
||||
|
||||
15
proxy.conf.cjs
Normal file
15
proxy.conf.cjs
Normal file
@@ -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,
|
||||
},
|
||||
};
|
||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 878 B |
43
scripts/sync-env.cjs
Normal file
43
scripts/sync-env.cjs
Normal file
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
@if (auth.user(); as user) {
|
||||
<span class="shell-user__email">{{ user.email }}</span>
|
||||
}
|
||||
<a class="shell-logout" (click)="logout()">Выйти</a>
|
||||
<button type="button" class="shell-logout" (click)="logout()">Выйти</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const TRANSLATIONS: Record<string, string> = {
|
||||
student: 'Студент',
|
||||
user: 'Пользователь',
|
||||
event: 'Мероприятие',
|
||||
referenceset: 'Reference Set',
|
||||
referenceset: 'Эталонная база',
|
||||
session: 'Сессия',
|
||||
analysisrun: 'Запуск проверки',
|
||||
auth: 'Авторизация',
|
||||
|
||||
46
src/app/core/services/analysis-runs-api.service.ts
Normal file
46
src/app/core/services/analysis-runs-api.service.ts
Normal file
@@ -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<AnalysisRun> {
|
||||
return this.http.get<AnalysisRun>(`/analysis-runs/${runId}`);
|
||||
}
|
||||
|
||||
getRunAdoptions(runId: string): Observable<readonly Adoption[]> {
|
||||
return this.http.get<readonly Adoption[]>(`/analysis-runs/${runId}/adoptions`);
|
||||
}
|
||||
|
||||
getRunChunks(runId: string): Observable<readonly AnalysisRunChunk[]> {
|
||||
return this.http.get<readonly AnalysisRunChunk[]>(`/analysis-runs/${runId}/chunks`);
|
||||
}
|
||||
|
||||
retryAnalysisRun(runId: string): Observable<UploadWorkResponse> {
|
||||
return this.http.post<UploadWorkResponse>(`/analysis-runs/${runId}/retry`, {});
|
||||
}
|
||||
|
||||
downloadReport(runId: string, format: 'json' | 'html' | 'pdf'): Observable<Blob> {
|
||||
return this.http.get(`/analysis-runs/${runId}/report.${format}`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
downloadRawReport(runId: string): Observable<Blob> {
|
||||
return this.http.get(`/analysis-runs/${runId}/report.raw.json`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
getAdoptionSegment(adoptionId: number): Observable<string> {
|
||||
return this.http.get(`/adoptions/${adoptionId}/segment`, { responseType: 'text' });
|
||||
}
|
||||
}
|
||||
39
src/app/core/services/audit-api.service.ts
Normal file
39
src/app/core/services/audit-api.service.ts
Normal file
@@ -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<readonly AuditLog[]> {
|
||||
let httpParams = new HttpParams();
|
||||
if (params?.actor_user_id != null) {
|
||||
httpParams = httpParams.set('actor_user_id', String(params.actor_user_id));
|
||||
}
|
||||
if (params?.action) {
|
||||
httpParams = httpParams.set('action', params.action);
|
||||
}
|
||||
if (params?.resource_type) {
|
||||
httpParams = httpParams.set('resource_type', params.resource_type);
|
||||
}
|
||||
if (params?.resource_id) {
|
||||
httpParams = httpParams.set('resource_id', params.resource_id);
|
||||
}
|
||||
if (params?.source) {
|
||||
httpParams = httpParams.set('source', params.source);
|
||||
}
|
||||
if (params?.limit != null) {
|
||||
httpParams = httpParams.set('limit', String(params.limit));
|
||||
}
|
||||
return this.http.get<readonly AuditLog[]>('/audit-logs', { params: httpParams });
|
||||
}
|
||||
}
|
||||
48
src/app/core/services/events-api.service.ts
Normal file
48
src/app/core/services/events-api.service.ts
Normal file
@@ -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<readonly EventEntity[]> {
|
||||
return this.http.get<readonly EventEntity[]>('/events');
|
||||
}
|
||||
|
||||
createEvent(payload: {
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly group_id: number;
|
||||
readonly date: string;
|
||||
}): Observable<EventEntity> {
|
||||
return this.http.post<EventEntity>('/events', payload);
|
||||
}
|
||||
|
||||
getEvent(id: number): Observable<EventEntity> {
|
||||
return this.http.get<EventEntity>(`/events/${id}`);
|
||||
}
|
||||
|
||||
updateEvent(id: number, payload: EditEventRequest): Observable<EventEntity> {
|
||||
return this.http.patch<EventEntity>(`/events/${id}`, payload);
|
||||
}
|
||||
|
||||
deleteEvent(id: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/events/${id}`);
|
||||
}
|
||||
|
||||
getEventSummary(id: number): Observable<ScopeDashboard> {
|
||||
return this.http.get<ScopeDashboard>(`/events/${id}/summary`);
|
||||
}
|
||||
|
||||
getEventWorks(id: number): Observable<readonly Work[]> {
|
||||
return this.http.get<readonly Work[]>(`/events/${id}/works`);
|
||||
}
|
||||
}
|
||||
57
src/app/core/services/groups-api.service.ts
Normal file
57
src/app/core/services/groups-api.service.ts
Normal file
@@ -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<readonly Group[]> {
|
||||
return this.http.get<readonly Group[]>('/groups');
|
||||
}
|
||||
|
||||
createGroup(payload: { readonly name: string }): Observable<Group> {
|
||||
return this.http.post<Group>('/groups', payload);
|
||||
}
|
||||
|
||||
getGroup(id: number): Observable<Group> {
|
||||
return this.http.get<Group>(`/groups/${id}`);
|
||||
}
|
||||
|
||||
updateGroup(id: number, payload: EditGroupRequest): Observable<Group> {
|
||||
return this.http.patch<Group>(`/groups/${id}`, payload);
|
||||
}
|
||||
|
||||
deleteGroup(id: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/groups/${id}`);
|
||||
}
|
||||
|
||||
getGroupStats(id: number): Observable<ScopeDashboard> {
|
||||
return this.http.get<ScopeDashboard>(`/groups/${id}/stats`);
|
||||
}
|
||||
|
||||
addStudentToGroup(groupId: number, studentId: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.post<DefaultSuccessResponse>(
|
||||
`/groups/${groupId}/students/${studentId}`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
removeStudentFromGroup(groupId: number, studentId: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/groups/${groupId}/students/${studentId}`);
|
||||
}
|
||||
|
||||
addUserToGroup(groupId: number, userId: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.post<DefaultSuccessResponse>(`/groups/${groupId}/users/${userId}`, {});
|
||||
}
|
||||
|
||||
removeUserFromGroup(groupId: number, userId: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/groups/${groupId}/users/${userId}`);
|
||||
}
|
||||
}
|
||||
48
src/app/core/services/reference-sets-api.service.ts
Normal file
48
src/app/core/services/reference-sets-api.service.ts
Normal file
@@ -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<readonly ReferenceSet[]> {
|
||||
return this.http.get<readonly ReferenceSet[]>('/reference-sets');
|
||||
}
|
||||
|
||||
createReferenceSet(payload: {
|
||||
readonly name: string;
|
||||
readonly kind: string;
|
||||
readonly description?: string;
|
||||
}): Observable<ReferenceSet> {
|
||||
return this.http.post<ReferenceSet>('/reference-sets', payload);
|
||||
}
|
||||
|
||||
getRefSet(id: number): Observable<ReferenceSet> {
|
||||
return this.http.get<ReferenceSet>(`/reference-sets/${id}`);
|
||||
}
|
||||
|
||||
updateRefSet(id: number, payload: EditRefSetRequest): Observable<ReferenceSet> {
|
||||
return this.http.patch<ReferenceSet>(`/reference-sets/${id}`, payload);
|
||||
}
|
||||
|
||||
deleteRefSet(id: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/reference-sets/${id}`);
|
||||
}
|
||||
|
||||
listIngestions(refSetId: number): Observable<readonly Ingestion[]> {
|
||||
return this.http.get<readonly Ingestion[]>(`/reference-sets/${refSetId}/ingestions`);
|
||||
}
|
||||
|
||||
createIngestion(refSetId: number, file: File): Observable<Ingestion> {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
return this.http.post<Ingestion>(`/reference-sets/${refSetId}/ingestions`, data);
|
||||
}
|
||||
}
|
||||
42
src/app/core/services/students-api.service.ts
Normal file
42
src/app/core/services/students-api.service.ts
Normal file
@@ -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<readonly Student[]> {
|
||||
return this.http.get<readonly Student[]>('/students');
|
||||
}
|
||||
|
||||
createStudent(payload: {
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly user_id?: number;
|
||||
}): Observable<Student> {
|
||||
return this.http.post<Student>('/students', payload);
|
||||
}
|
||||
|
||||
getStudent(id: number): Observable<Student> {
|
||||
return this.http.get<Student>(`/students/${id}`);
|
||||
}
|
||||
|
||||
updateStudent(id: number, payload: EditStudentRequest): Observable<Student> {
|
||||
return this.http.patch<Student>(`/students/${id}`, payload);
|
||||
}
|
||||
|
||||
deleteStudent(id: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/students/${id}`);
|
||||
}
|
||||
|
||||
getStudentStats(id: number): Observable<ScopeDashboard> {
|
||||
return this.http.get<ScopeDashboard>(`/students/${id}/stats`);
|
||||
}
|
||||
}
|
||||
21
src/app/core/services/users-api.service.ts
Normal file
21
src/app/core/services/users-api.service.ts
Normal file
@@ -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<readonly UserInfo[]> {
|
||||
return this.http.get<readonly UserInfo[]>('/users');
|
||||
}
|
||||
|
||||
getUser(id: number): Observable<UserInfo> {
|
||||
return this.http.get<UserInfo>(`/users/${id}`);
|
||||
}
|
||||
|
||||
createUser(payload: CreateUserRequest): Observable<UserInfo> {
|
||||
return this.http.post<UserInfo>('/users', payload);
|
||||
}
|
||||
}
|
||||
@@ -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<readonly Work[]> {
|
||||
return this.http.get<readonly Work[]>('/works');
|
||||
@@ -87,200 +73,4 @@ export class WorksApiService {
|
||||
getWorkAdoptionsRelated(workId: number): Observable<readonly Work[]> {
|
||||
return this.http.get<readonly Work[]>(`/works/${workId}/adoptions/related`);
|
||||
}
|
||||
|
||||
/* ── Analysis Runs ── */
|
||||
|
||||
getAnalysisRun(runId: string): Observable<AnalysisRun> {
|
||||
return this.http.get<AnalysisRun>(`/analysis-runs/${runId}`);
|
||||
}
|
||||
|
||||
getRunAdoptions(runId: string): Observable<readonly Adoption[]> {
|
||||
return this.http.get<readonly Adoption[]>(`/analysis-runs/${runId}/adoptions`);
|
||||
}
|
||||
|
||||
getRunChunks(runId: string): Observable<readonly AnalysisRunChunk[]> {
|
||||
return this.http.get<readonly AnalysisRunChunk[]>(`/analysis-runs/${runId}/chunks`);
|
||||
}
|
||||
|
||||
retryAnalysisRun(runId: string): Observable<UploadWorkResponse> {
|
||||
return this.http.post<UploadWorkResponse>(`/analysis-runs/${runId}/retry`, {});
|
||||
}
|
||||
|
||||
downloadReport(runId: string, format: 'json' | 'html' | 'pdf'): Observable<Blob> {
|
||||
return this.http.get(`/analysis-runs/${runId}/report.${format}`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
downloadRawReport(runId: string): Observable<Blob> {
|
||||
return this.http.get(`/analysis-runs/${runId}/report.raw.json`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
/* ── Adoptions ── */
|
||||
|
||||
getAdoptionSegment(adoptionId: number): Observable<string> {
|
||||
return this.http.get(`/adoptions/${adoptionId}/segment`, { responseType: 'text' });
|
||||
}
|
||||
|
||||
/* ── Students ── */
|
||||
|
||||
listStudents(): Observable<readonly Student[]> {
|
||||
return this.http.get<readonly Student[]>('/students');
|
||||
}
|
||||
|
||||
createStudent(payload: { readonly name: string; readonly email: string; readonly user_id?: number }): Observable<Student> {
|
||||
return this.http.post<Student>('/students', payload);
|
||||
}
|
||||
|
||||
getStudent(id: number): Observable<Student> {
|
||||
return this.http.get<Student>(`/students/${id}`);
|
||||
}
|
||||
|
||||
updateStudent(id: number, payload: EditStudentRequest): Observable<Student> {
|
||||
return this.http.patch<Student>(`/students/${id}`, payload);
|
||||
}
|
||||
|
||||
deleteStudent(id: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/students/${id}`);
|
||||
}
|
||||
|
||||
getStudentStats(id: number): Observable<ScopeDashboard> {
|
||||
return this.http.get<ScopeDashboard>(`/students/${id}/stats`);
|
||||
}
|
||||
|
||||
/* ── Groups ── */
|
||||
|
||||
listGroups(): Observable<readonly Group[]> {
|
||||
return this.http.get<readonly Group[]>('/groups');
|
||||
}
|
||||
|
||||
createGroup(payload: { readonly name: string }): Observable<Group> {
|
||||
return this.http.post<Group>('/groups', payload);
|
||||
}
|
||||
|
||||
getGroup(id: number): Observable<Group> {
|
||||
return this.http.get<Group>(`/groups/${id}`);
|
||||
}
|
||||
|
||||
updateGroup(id: number, payload: EditGroupRequest): Observable<Group> {
|
||||
return this.http.patch<Group>(`/groups/${id}`, payload);
|
||||
}
|
||||
|
||||
deleteGroup(id: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/groups/${id}`);
|
||||
}
|
||||
|
||||
getGroupStats(id: number): Observable<ScopeDashboard> {
|
||||
return this.http.get<ScopeDashboard>(`/groups/${id}/stats`);
|
||||
}
|
||||
|
||||
addStudentToGroup(groupId: number, studentId: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.post<DefaultSuccessResponse>(`/groups/${groupId}/students/${studentId}`, {});
|
||||
}
|
||||
|
||||
removeStudentFromGroup(groupId: number, studentId: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/groups/${groupId}/students/${studentId}`);
|
||||
}
|
||||
|
||||
addUserToGroup(groupId: number, userId: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.post<DefaultSuccessResponse>(`/groups/${groupId}/users/${userId}`, {});
|
||||
}
|
||||
|
||||
removeUserFromGroup(groupId: number, userId: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/groups/${groupId}/users/${userId}`);
|
||||
}
|
||||
|
||||
/* ── Events ── */
|
||||
|
||||
listEvents(): Observable<readonly EventEntity[]> {
|
||||
return this.http.get<readonly EventEntity[]>('/events');
|
||||
}
|
||||
|
||||
createEvent(payload: { readonly name: string; readonly description?: string; readonly group_id: number; readonly date: string }): Observable<EventEntity> {
|
||||
return this.http.post<EventEntity>('/events', payload);
|
||||
}
|
||||
|
||||
getEvent(id: number): Observable<EventEntity> {
|
||||
return this.http.get<EventEntity>(`/events/${id}`);
|
||||
}
|
||||
|
||||
updateEvent(id: number, payload: EditEventRequest): Observable<EventEntity> {
|
||||
return this.http.patch<EventEntity>(`/events/${id}`, payload);
|
||||
}
|
||||
|
||||
deleteEvent(id: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/events/${id}`);
|
||||
}
|
||||
|
||||
getEventSummary(id: number): Observable<ScopeDashboard> {
|
||||
return this.http.get<ScopeDashboard>(`/events/${id}/summary`);
|
||||
}
|
||||
|
||||
getEventWorks(id: number): Observable<readonly Work[]> {
|
||||
return this.http.get<readonly Work[]>(`/events/${id}/works`);
|
||||
}
|
||||
|
||||
/* ── Reference Sets ── */
|
||||
|
||||
listReferenceSets(): Observable<readonly ReferenceSet[]> {
|
||||
return this.http.get<readonly ReferenceSet[]>('/reference-sets');
|
||||
}
|
||||
|
||||
createReferenceSet(payload: { readonly name: string; readonly kind: string; readonly description?: string }): Observable<ReferenceSet> {
|
||||
return this.http.post<ReferenceSet>('/reference-sets', payload);
|
||||
}
|
||||
|
||||
getRefSet(id: number): Observable<ReferenceSet> {
|
||||
return this.http.get<ReferenceSet>(`/reference-sets/${id}`);
|
||||
}
|
||||
|
||||
updateRefSet(id: number, payload: EditRefSetRequest): Observable<ReferenceSet> {
|
||||
return this.http.patch<ReferenceSet>(`/reference-sets/${id}`, payload);
|
||||
}
|
||||
|
||||
deleteRefSet(id: number): Observable<DefaultSuccessResponse> {
|
||||
return this.http.delete<DefaultSuccessResponse>(`/reference-sets/${id}`);
|
||||
}
|
||||
|
||||
listIngestions(refSetId: number): Observable<readonly Ingestion[]> {
|
||||
return this.http.get<readonly Ingestion[]>(`/reference-sets/${refSetId}/ingestions`);
|
||||
}
|
||||
|
||||
createIngestion(refSetId: number, file: File): Observable<Ingestion> {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
return this.http.post<Ingestion>(`/reference-sets/${refSetId}/ingestions`, data);
|
||||
}
|
||||
|
||||
/* ── Users ── */
|
||||
|
||||
listUsers(): Observable<readonly UserInfo[]> {
|
||||
return this.http.get<readonly UserInfo[]>('/users');
|
||||
}
|
||||
|
||||
getUser(id: number): Observable<UserInfo> {
|
||||
return this.http.get<UserInfo>(`/users/${id}`);
|
||||
}
|
||||
|
||||
createUser(payload: CreateUserRequest): Observable<UserInfo> {
|
||||
return this.http.post<UserInfo>('/users', payload);
|
||||
}
|
||||
|
||||
/* ── Audit ── */
|
||||
|
||||
listAuditLogs(params?: {
|
||||
actor_user_id?: number;
|
||||
action?: string;
|
||||
resource_type?: string;
|
||||
resource_id?: string;
|
||||
source?: string;
|
||||
limit?: number;
|
||||
}): Observable<readonly AuditLog[]> {
|
||||
let httpParams = new HttpParams();
|
||||
if (params?.actor_user_id != null) httpParams = httpParams.set('actor_user_id', String(params.actor_user_id));
|
||||
if (params?.action) httpParams = httpParams.set('action', params.action);
|
||||
if (params?.resource_type) httpParams = httpParams.set('resource_type', params.resource_type);
|
||||
if (params?.resource_id) httpParams = httpParams.set('resource_id', params.resource_id);
|
||||
if (params?.source) httpParams = httpParams.set('source', params.source);
|
||||
if (params?.limit != null) httpParams = httpParams.set('limit', String(params.limit));
|
||||
return this.http.get<readonly AuditLog[]>('/audit-logs', { params: httpParams });
|
||||
}
|
||||
}
|
||||
|
||||
19
src/app/core/works/risk-level.pipe.ts
Normal file
19
src/app/core/works/risk-level.pipe.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
const TRANSLATIONS: Record<string, string> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,99 +2,14 @@
|
||||
<h2 tuiTitle="m" class="heading">Управление сущностями</h2>
|
||||
|
||||
<tui-tabs class="dash-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
|
||||
<button tuiTab type="button">Работы</button>
|
||||
<button tuiTab type="button">Мероприятия</button>
|
||||
<button tuiTab type="button">Студенты</button>
|
||||
<button tuiTab type="button">Мероприятия</button>
|
||||
<button tuiTab type="button">Группы</button>
|
||||
<button tuiTab type="button">Reference Sets</button>
|
||||
<button tuiTab type="button">Эталонные базы</button>
|
||||
</tui-tabs>
|
||||
|
||||
@switch (activeTabIndex()) {
|
||||
@case (0) {
|
||||
<section class="card" aria-label="Работы">
|
||||
<h3 class="section-title">Новая работа</h3>
|
||||
<form class="create-row" (ngSubmit)="createWork()">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="number" placeholder="student_id" [ngModel]="createWorkStudentId()" (ngModelChange)="createWorkStudentId.set($event)" name="workStudentId" />
|
||||
</tui-textfield>
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="number" placeholder="event_id" [ngModel]="createWorkEventId()" (ngModelChange)="createWorkEventId.set($event)" name="workEventId" />
|
||||
</tui-textfield>
|
||||
<button tuiButton class="accent-cta" appearance="primary" type="submit" [disabled]="isSubmitting()">Создать</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@if (worksState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') {
|
||||
<div class="loading-wrap"><tui-loader [loading]="true" size="xl" /></div>
|
||||
}
|
||||
@case ('error') {
|
||||
<section class="card"><p class="muted">Ошибка загрузки работ.</p></section>
|
||||
}
|
||||
@case ('ok') {
|
||||
<section class="card" aria-label="Список работ">
|
||||
@if (state.items.length === 0) {
|
||||
<p class="muted">Работ пока нет.</p>
|
||||
} @else {
|
||||
<ul class="entity-list">
|
||||
@for (work of state.items; track work.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/works', work.id]" class="entity-link">
|
||||
Работа {{ work.id }}
|
||||
</a>
|
||||
<span tuiChip size="xs">{{ work.archive_object_key ? 'Архив есть' : 'Без архива' }}</span>
|
||||
<span class="muted meta">student={{ work.student_id }}, event={{ work.event_id }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@case (1) {
|
||||
@defer {
|
||||
<section class="card" aria-label="Ивенты">
|
||||
<h3 class="section-title">Новый ивент</h3>
|
||||
<form class="create-row" (ngSubmit)="createEvent()">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="Название" [ngModel]="createEventName()" (ngModelChange)="createEventName.set($event)" name="eventName" />
|
||||
</tui-textfield>
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="number" placeholder="group_id" [ngModel]="createEventGroupId()" (ngModelChange)="createEventGroupId.set($event)" name="eventGroupId" />
|
||||
</tui-textfield>
|
||||
<button tuiButton class="accent-cta" appearance="primary" type="submit" [disabled]="isSubmitting()">Создать</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@if (eventsState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') { <div class="loading-wrap"><tui-loader [loading]="true" size="m" /></div> }
|
||||
@case ('error') { <section class="card"><p class="muted">Ошибка загрузки ивентов.</p></section> }
|
||||
@case ('ok') {
|
||||
<section class="card">
|
||||
<ul class="entity-list">
|
||||
@for (event of state.items; track event.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/events', event.id]" class="entity-link">{{ event.name }}</a>
|
||||
<span tuiChip size="xs">#{{ event.id }}</span>
|
||||
<span class="muted meta">group={{ event.group_id }}, date={{ formatDate(event.date) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="loading-wrap"><tui-loader [loading]="true" size="l" /></div>
|
||||
}
|
||||
}
|
||||
|
||||
@case (2) {
|
||||
@defer {
|
||||
<section class="card" aria-label="Студенты">
|
||||
<h3 class="section-title">Новый студент</h3>
|
||||
@@ -112,19 +27,21 @@
|
||||
@if (studentsState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') { <div class="loading-wrap"><tui-loader [loading]="true" size="m" /></div> }
|
||||
@case ('error') { <section class="card"><p class="muted">Ошибка загрузки студентов.</p></section> }
|
||||
@case ('error') {}
|
||||
@case ('ok') {
|
||||
<section class="card">
|
||||
<ul class="entity-list">
|
||||
@for (student of state.items; track student.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/students', student.id]" class="entity-link">{{ student.name }}</a>
|
||||
<span tuiChip size="xs">#{{ student.id }}</span>
|
||||
<span class="muted meta">{{ student.email }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
@if (state.items.length > 0) {
|
||||
<section class="card">
|
||||
<ul class="entity-list">
|
||||
@for (student of state.items; track student.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/students', student.id]" class="entity-link">{{ student.name }}</a>
|
||||
<span tuiChip size="xs">#{{ student.id }}</span>
|
||||
<span class="muted meta">{{ student.email }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +50,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
@case (3) {
|
||||
@case (1) {
|
||||
@defer {
|
||||
<section class="card" aria-label="Мероприятия">
|
||||
<h3 class="section-title">Новое мероприятие</h3>
|
||||
<form class="create-row" (ngSubmit)="createEvent()">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="Название" [ngModel]="createEventName()" (ngModelChange)="createEventName.set($event)" name="eventName" />
|
||||
</tui-textfield>
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<label tuiLabel for="dashEventGroup">Группа</label>
|
||||
<select tuiSelect id="dashEventGroup" name="eventGroup" [items]="groupOptions()" [ngModel]="selectedEventGroup()" (ngModelChange)="selectedEventGroup.set($event)"></select>
|
||||
</tui-textfield>
|
||||
<button tuiButton class="accent-cta" appearance="primary" type="submit" [disabled]="isSubmitting()">Создать</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@if (eventsState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') { <div class="loading-wrap"><tui-loader [loading]="true" size="m" /></div> }
|
||||
@case ('error') {}
|
||||
@case ('ok') {
|
||||
@if (state.items.length > 0) {
|
||||
<section class="card">
|
||||
<ul class="entity-list">
|
||||
@for (event of state.items; track event.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/events', event.id]" class="entity-link">{{ event.name }}</a>
|
||||
<span tuiChip size="xs">#{{ event.id }}</span>
|
||||
<span class="muted meta">{{ groupName(event.group_id) }}, {{ formatDate(event.date) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="loading-wrap"><tui-loader [loading]="true" size="l" /></div>
|
||||
}
|
||||
}
|
||||
|
||||
@case (2) {
|
||||
@defer {
|
||||
<section class="card" aria-label="Группы">
|
||||
<h3 class="section-title">Новая группа</h3>
|
||||
@@ -148,19 +107,21 @@
|
||||
@if (groupsState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') { <div class="loading-wrap"><tui-loader [loading]="true" size="m" /></div> }
|
||||
@case ('error') { <section class="card"><p class="muted">Ошибка загрузки групп.</p></section> }
|
||||
@case ('error') {}
|
||||
@case ('ok') {
|
||||
<section class="card">
|
||||
<ul class="entity-list">
|
||||
@for (group of state.items; track group.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/groups', group.id]" class="entity-link">{{ group.name }}</a>
|
||||
<span tuiChip size="xs">#{{ group.id }}</span>
|
||||
<span class="muted meta">students={{ group.students?.length ?? 0 }}, users={{ group.users?.length ?? 0 }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
@if (state.items.length > 0) {
|
||||
<section class="card">
|
||||
<ul class="entity-list">
|
||||
@for (group of state.items; track group.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/groups', group.id]" class="entity-link">{{ group.name }}</a>
|
||||
<span tuiChip size="xs">#{{ group.id }}</span>
|
||||
<span class="muted meta">students={{ group.students?.length ?? 0 }}, users={{ group.users?.length ?? 0 }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,16 +130,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
@case (4) {
|
||||
@case (3) {
|
||||
@defer {
|
||||
<section class="card" aria-label="Reference Sets">
|
||||
<h3 class="section-title">Новый reference set</h3>
|
||||
<section class="card" aria-label="Эталонные базы">
|
||||
<h3 class="section-title">Новая эталонная база</h3>
|
||||
<form class="create-row" (ngSubmit)="createReferenceSet()">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="name" [ngModel]="createRefName()" (ngModelChange)="createRefName.set($event)" name="refName" />
|
||||
<input tuiInput type="text" placeholder="Название" [ngModel]="createRefName()" (ngModelChange)="createRefName.set($event)" name="refName" />
|
||||
</tui-textfield>
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="kind" [ngModel]="createRefKind()" (ngModelChange)="createRefKind.set($event)" name="refKind" />
|
||||
<input tuiInput type="text" placeholder="Тип" [ngModel]="createRefKind()" (ngModelChange)="createRefKind.set($event)" name="refKind" />
|
||||
</tui-textfield>
|
||||
<button tuiButton class="accent-cta" appearance="primary" type="submit" [disabled]="isSubmitting()">Создать</button>
|
||||
</form>
|
||||
@@ -187,19 +148,21 @@
|
||||
@if (refsState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') { <div class="loading-wrap"><tui-loader [loading]="true" size="m" /></div> }
|
||||
@case ('error') { <section class="card"><p class="muted">Ошибка загрузки reference sets.</p></section> }
|
||||
@case ('error') {}
|
||||
@case ('ok') {
|
||||
<section class="card">
|
||||
<ul class="entity-list">
|
||||
@for (ref of state.items; track ref.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/reference-sets', ref.id]" class="entity-link">{{ ref.name }}</a>
|
||||
<span tuiChip size="xs">{{ ref.kind }}</span>
|
||||
<span class="muted meta">{{ ref.description ?? '—' }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
@if (state.items.length > 0) {
|
||||
<section class="card">
|
||||
<ul class="entity-list">
|
||||
@for (ref of state.items; track ref.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/reference-sets', ref.id]" class="entity-link">{{ ref.name }}</a>
|
||||
<span tuiChip size="xs">{{ ref.kind }}</span>
|
||||
<span class="muted meta">{{ ref.description ?? '—' }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
protected readonly createWorkEventId = signal<number | null>(null);
|
||||
// Works creation
|
||||
protected readonly selectedWorkStudent = signal<Student | null>(null);
|
||||
protected readonly selectedWorkEvent = signal<EventEntity | null>(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<Group | null>(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<readonly Student[] | null>(
|
||||
this.studentsState$.pipe(map((s) => { if (s.status === 'ok') return s.items; return null; })),
|
||||
{ initialValue: null },
|
||||
);
|
||||
protected readonly eventOptions = toSignal<readonly EventEntity[] | null>(
|
||||
this.eventsState$.pipe(map((s) => { if (s.status === 'ok') return s.items; return null; })),
|
||||
{ initialValue: null },
|
||||
);
|
||||
protected readonly groupOptions = toSignal<readonly Group[] | null>(
|
||||
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<unknown>): void {
|
||||
private submit(request$: Observable<unknown>, onSuccess?: () => void): void {
|
||||
this.isSubmitting.set(true);
|
||||
request$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: () => {
|
||||
this.isSubmitting.set(false);
|
||||
onSuccess?.();
|
||||
this.refresh();
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<button tuiTab type="button">Обзор</button>
|
||||
<button tuiTab type="button">Работы</button>
|
||||
<button tuiTab type="button">Аналитика</button>
|
||||
<button tuiTab type="button">Граф зависимостей</button>
|
||||
<button tuiTab type="button">Управление</button>
|
||||
</tui-tabs>
|
||||
|
||||
@@ -33,7 +34,7 @@
|
||||
<dt>Описание</dt>
|
||||
<dd>{{ state.event.description ?? '—' }}</dd>
|
||||
<dt>Группа</dt>
|
||||
<dd>{{ state.event.group_id }}</dd>
|
||||
<dd><a tuiLink [routerLink]="['/groups', state.event.group_id]">{{ (groupName$ | async) ?? '#' + state.event.group_id }}</a></dd>
|
||||
<dt class="muted">Дата загрузки</dt>
|
||||
<dd><code class="mono">{{ formatDate(state.event.date) }}</code></dd>
|
||||
</dl>
|
||||
@@ -46,17 +47,19 @@
|
||||
@if (worksState$ | async; as ws) {
|
||||
@switch (ws.status) {
|
||||
@case ('loading') { <tui-loader [loading]="true" size="m" /> }
|
||||
@case ('error') { <p class="muted">Ошибка загрузки.</p> }
|
||||
@case ('error') {}
|
||||
@case ('ok') {
|
||||
@if (ws.works.length === 0) {
|
||||
<p class="muted">Работ нет.</p>
|
||||
} @else {
|
||||
<ul class="entity-list">
|
||||
@for (w of ws.works; track w.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/works', w.id]">Работа {{ w.id }}</a>
|
||||
<span class="muted meta">student={{ w.student_id }}, {{ formatDate(w.time) }}</span>
|
||||
</li>
|
||||
@if (studentNamesMap$ | async; as names) {
|
||||
@for (w of ws.works; track w.id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/works', w.id]">Работа #{{ w.id }}</a>
|
||||
<span class="muted meta">{{ names[w.student_id] ?? '#' + w.student_id }}, {{ formatDate(w.time) }}</span>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
@@ -72,14 +75,14 @@
|
||||
@if (summaryState$ | async; as ss) {
|
||||
@switch (ss.status) {
|
||||
@case ('loading') { <tui-loader [loading]="true" size="m" /> }
|
||||
@case ('error') { <p class="muted">Ошибка загрузки дашборда.</p> }
|
||||
@case ('error') {}
|
||||
@case ('ok') {
|
||||
@if (ss.dashboard.presentation_summary; as m) {
|
||||
<div class="config-grid">
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Всего работ</span><span class="cfg-value">{{ m.works_total ?? 0 }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Проверено</span><span class="cfg-value">{{ m.works_checked ?? 0 }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Plagiarism rate</span><span class="cfg-value">{{ m.plagiarism_rate ?? '—' }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Risk</span><span class="cfg-value">{{ m.risk_level ?? '—' }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Доля заимствований</span><span class="cfg-value">{{ m.plagiarism_rate ?? '—' }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Уровень риска</span><span class="cfg-value">{{ m.risk_level | riskLevel }}</span></div></div>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="muted">Метрики недоступны.</p>
|
||||
@@ -91,9 +94,9 @@
|
||||
<ul class="entity-list">
|
||||
@for (c of cards; track c.work_id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/works', c.work_id]">Работа {{ c.work_id }}</a>
|
||||
<span tuiChip size="xs">{{ c.risk_level ?? '—' }}</span>
|
||||
<span class="muted meta">{{ c.student_name ?? '—' }}, score={{ c.trust_score ?? '—' }}</span>
|
||||
<a tuiLink [routerLink]="['/works', c.work_id]">Работа #{{ c.work_id }}</a>
|
||||
<span tuiChip size="xs">{{ c.risk_level | riskLevel }}</span>
|
||||
<span class="muted meta">{{ c.student_name ?? '—' }}, индекс доверия: {{ c.trust_score ?? '—' }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -106,6 +109,18 @@
|
||||
}
|
||||
|
||||
@case (3) {
|
||||
<section class="card" aria-label="График">
|
||||
@if (summaryState$ | async; as ss) {
|
||||
@if (ss.status === 'ok') {
|
||||
<app-plagiarism-graph [graph]="ss.dashboard.graph" />
|
||||
} @else if (ss.status === 'loading') {
|
||||
<div class="loading-wrap"><tui-loader [loading]="true" size="xl" /></div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (4) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Редактирование</h3>
|
||||
<form class="edit-form" (ngSubmit)="saveEvent()">
|
||||
|
||||
@@ -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<number, string>),
|
||||
catchError(() => of({} as Record<number, string>)),
|
||||
),
|
||||
),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
protected readonly worksState$ = toObservable(this.reloadTick).pipe(
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<button tuiTab type="button">Обзор</button>
|
||||
<button tuiTab type="button">Участники</button>
|
||||
<button tuiTab type="button">Аналитика</button>
|
||||
<button tuiTab type="button">Граф зависимостей</button>
|
||||
<button tuiTab type="button">Управление</button>
|
||||
</tui-tabs>
|
||||
|
||||
@@ -34,7 +35,7 @@
|
||||
<ul class="entity-list">
|
||||
@for (sid of state.group.students; track sid) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/students', sid]">Студент #{{ sid }}</a>
|
||||
<a tuiLink [routerLink]="['/students', sid]">{{ getStudentName(sid) }}</a>
|
||||
<button tuiLink type="button" class="danger-link meta" (click)="removeStudent(sid)">Убрать</button>
|
||||
</li>
|
||||
}
|
||||
@@ -44,7 +45,8 @@
|
||||
}
|
||||
<form class="create-row" (ngSubmit)="addStudent()" style="margin-top:1rem">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="number" placeholder="student_id" [ngModel]="addStudentId()" (ngModelChange)="addStudentId.set($event)" name="sid" />
|
||||
<label tuiLabel for="gdAddStudent">Студент</label>
|
||||
<select tuiSelect id="gdAddStudent" name="addStudent" [items]="studentOptions()" [ngModel]="selectedAddStudent()" (ngModelChange)="selectedAddStudent.set($event)"></select>
|
||||
</tui-textfield>
|
||||
<button tuiButton class="accent-cta" appearance="primary" size="s" type="submit">Добавить</button>
|
||||
</form>
|
||||
@@ -54,7 +56,7 @@
|
||||
<ul class="entity-list">
|
||||
@for (uid of state.group.users; track uid) {
|
||||
<li class="entity-row">
|
||||
<span>Пользователь #{{ uid }}</span>
|
||||
<span>{{ getUserName(uid) }}</span>
|
||||
<button tuiLink type="button" class="danger-link meta" (click)="removeUser(uid)">Убрать</button>
|
||||
</li>
|
||||
}
|
||||
@@ -64,7 +66,8 @@
|
||||
}
|
||||
<form class="create-row" (ngSubmit)="addUser()" style="margin-top:1rem">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<input tuiInput type="number" placeholder="user_id" [ngModel]="addUserId()" (ngModelChange)="addUserId.set($event)" name="uid" />
|
||||
<label tuiLabel for="gdAddUser">Преподаватель</label>
|
||||
<select tuiSelect id="gdAddUser" name="addUser" [items]="userOptions()" [ngModel]="selectedAddUser()" (ngModelChange)="selectedAddUser.set($event)"></select>
|
||||
</tui-textfield>
|
||||
<button tuiButton class="accent-cta" appearance="primary" size="s" type="submit">Добавить</button>
|
||||
</form>
|
||||
@@ -77,14 +80,14 @@
|
||||
@if (statsState$ | async; as ss) {
|
||||
@switch (ss.status) {
|
||||
@case ('loading') { <tui-loader [loading]="true" size="m" /> }
|
||||
@case ('error') { <p class="muted">Ошибка загрузки.</p> }
|
||||
@case ('error') {}
|
||||
@case ('ok') {
|
||||
@if (ss.dashboard.presentation_summary; as m) {
|
||||
<div class="config-grid">
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Всего работ</span><span class="cfg-value">{{ m.works_total ?? 0 }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Проверено</span><span class="cfg-value">{{ m.works_checked ?? 0 }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Plagiarism rate</span><span class="cfg-value">{{ m.plagiarism_rate ?? '—' }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Risk</span><span class="cfg-value">{{ m.risk_level ?? '—' }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Доля заимствований</span><span class="cfg-value">{{ m.plagiarism_rate ?? '—' }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Уровень риска</span><span class="cfg-value">{{ m.risk_level | riskLevel }}</span></div></div>
|
||||
</div>
|
||||
} @else { <p class="muted">Метрики недоступны.</p> }
|
||||
}
|
||||
@@ -94,6 +97,18 @@
|
||||
}
|
||||
|
||||
@case (3) {
|
||||
<section class="card" aria-label="График">
|
||||
@if (statsState$ | async; as ss) {
|
||||
@if (ss.status === 'ok') {
|
||||
<app-plagiarism-graph [graph]="ss.dashboard.graph" />
|
||||
} @else if (ss.status === 'loading') {
|
||||
<div class="loading-wrap"><tui-loader [loading]="true" size="xl" /></div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (4) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Редактирование</h3>
|
||||
<form class="edit-form" (ngSubmit)="saveGroup()">
|
||||
|
||||
@@ -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<Student | null>(null);
|
||||
protected readonly selectedAddUser = signal<UserInfo | null>(null);
|
||||
|
||||
protected readonly studentOptions = toSignal<readonly Student[] | null>(
|
||||
this.studentsApi.listStudents().pipe(catchError(() => of(null))),
|
||||
{ initialValue: null },
|
||||
);
|
||||
protected readonly userOptions = toSignal<readonly UserInfo[] | null>(
|
||||
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, 'Ошибка добавления пользователя'); },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<!-- Detail Card 1 -->
|
||||
<div class="ap-bento-card">
|
||||
<div class="ap-bento-card__content">
|
||||
<h4>Reference Sets</h4>
|
||||
<h4>Эталонные базы</h4>
|
||||
<p>Отфильтровывайте заранее выданный студентам шаблонный код. Система сама вычтет эталонные токены.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,7 +127,7 @@
|
||||
<button tuiAccordion class="faq__button">Игнорируется ли шаблонный код?</button>
|
||||
<tui-expand>
|
||||
<p class="faq__content">
|
||||
Да, преподаватель может загрузить архив с исходным кодом, который выдавался студентам как основа (Reference Set). Система автоматически очистит совпадения с этим кодом из финального отчёта.
|
||||
Да, преподаватель может загрузить архив с исходным кодом, который выдавался студентам как основа (эталонная база). Система автоматически очистит совпадения с этим кодом из финального отчёта.
|
||||
</p>
|
||||
</tui-expand>
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
@if (refSetState$ | async; as state) {
|
||||
@switch (state.status) {
|
||||
@case ('loading') { <div class="loading-wrap"><tui-loader [loading]="true" size="xl" /></div> }
|
||||
@case ('error') { <p class="muted">Не удалось загрузить reference set.</p> }
|
||||
@case ('error') { <p class="muted">Не удалось загрузить эталонную базу.</p> }
|
||||
@case ('ok') {
|
||||
<h2 tuiTitle="m" class="heading">{{ state.refSet.name }}</h2>
|
||||
|
||||
<tui-tabs class="detail-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
|
||||
<button tuiTab type="button">Обзор</button>
|
||||
<button tuiTab type="button">Ingestions</button>
|
||||
<button tuiTab type="button">Индексации</button>
|
||||
<button tuiTab type="button">Управление</button>
|
||||
</tui-tabs>
|
||||
|
||||
@@ -27,34 +27,33 @@
|
||||
}
|
||||
|
||||
@case (1) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Ingestions</h3>
|
||||
@if (ingestionsState$ | async; as is) {
|
||||
@switch (is.status) {
|
||||
@case ('loading') { <tui-loader [loading]="true" size="m" /> }
|
||||
@case ('error') { <p class="muted">Ошибка загрузки.</p> }
|
||||
@case ('ok') {
|
||||
@if (is.items.length === 0) {
|
||||
<p class="muted">Нет ingestions.</p>
|
||||
} @else {
|
||||
<ul class="entity-list">
|
||||
@for (ig of is.items; track ig.id) {
|
||||
<li class="ingestion-row">
|
||||
<span class="ingestion-id">ID: {{ ig.id }}</span>
|
||||
<span tuiChip size="s" class="status-chip" [ngClass]="ig.status | analysisRunStatusChipClasses">{{ ig.status | analysisRunStatus }}</span>
|
||||
<span class="muted meta">{{ formatDate(ig.created_at) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
}
|
||||
@if (ingestionsState$ | async; as is) {
|
||||
@if (is.status === 'loading') {
|
||||
<section class="card">
|
||||
<div class="loading-wrap loading-wrap_small"><tui-loader [loading]="true" size="m" /></div>
|
||||
</section>
|
||||
}
|
||||
@if (is.status === 'ok' && is.items.length > 0) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Индексации</h3>
|
||||
<ul class="entity-list">
|
||||
@for (ig of is.items; track ig.id) {
|
||||
<li class="ingestion-row">
|
||||
<span class="ingestion-id">ID: {{ ig.id }}</span>
|
||||
<span tuiChip size="s" class="status-chip" [ngClass]="ig.status | analysisRunStatusChipClasses">{{ ig.status | analysisRunStatus }}</span>
|
||||
<span class="muted meta">{{ formatDate(ig.created_at) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
<h4 class="section-title" style="margin-top:1.5rem">Загрузить ingestion</h4>
|
||||
<section class="card">
|
||||
<h3 class="section-title">Загрузить индексацию</h3>
|
||||
<div class="upload-row">
|
||||
<input type="file" (change)="onFileSelected($event)" />
|
||||
<button tuiButton class="accent-cta" appearance="primary" size="s" type="button" [disabled]="isUploading() || !selectedFile" (click)="uploadIngestion()">Загрузить</button>
|
||||
<button tuiButton class="accent-cta" appearance="primary" size="s" type="button" [disabled]="isUploading() || !selectedFile()" (click)="uploadIngestion()">Загрузить</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -67,7 +66,7 @@
|
||||
<input tuiInput type="text" placeholder="Название" [ngModel]="editName()" (ngModelChange)="editName.set($event)" name="name" />
|
||||
</tui-textfield>
|
||||
<tui-textfield class="edit-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="kind" [ngModel]="editKind()" (ngModelChange)="editKind.set($event)" name="kind" />
|
||||
<input tuiInput type="text" placeholder="Тип" [ngModel]="editKind()" (ngModelChange)="editKind.set($event)" name="kind" />
|
||||
</tui-textfield>
|
||||
<tui-textfield class="edit-field sg-tui-textfield">
|
||||
<input tuiInput type="text" placeholder="Описание" [ngModel]="editDescription()" (ngModelChange)="editDescription.set($event)" name="description" />
|
||||
@@ -77,7 +76,7 @@
|
||||
</div>
|
||||
</form>
|
||||
<div class="danger-zone">
|
||||
<button tuiLink type="button" class="danger-link" (click)="delete()" [disabled]="isDeleting()">Удалить reference set</button>
|
||||
<button tuiLink type="button" class="danger-link" (click)="delete()" [disabled]="isDeleting()">Удалить эталонную базу</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<tui-tabs class="detail-tabs" [(activeItemIndex)]="activeTabIndex" size="m">
|
||||
<button tuiTab type="button">Обзор</button>
|
||||
<button tuiTab type="button">Аналитика</button>
|
||||
<button tuiTab type="button">Граф зависимостей</button>
|
||||
<button tuiTab type="button">Управление</button>
|
||||
</tui-tabs>
|
||||
|
||||
@@ -32,15 +33,15 @@
|
||||
@if (statsState$ | async; as ss) {
|
||||
@switch (ss.status) {
|
||||
@case ('loading') { <tui-loader [loading]="true" size="m" /> }
|
||||
@case ('error') { <p class="muted">Ошибка загрузки.</p> }
|
||||
@case ('error') {}
|
||||
@case ('ok') {
|
||||
@if (ss.dashboard.presentation_summary; as m) {
|
||||
<div class="config-grid">
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Всего работ</span><span class="cfg-value">{{ m.works_total ?? 0 }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Проверено</span><span class="cfg-value">{{ m.works_checked ?? 0 }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Plagiarism rate</span><span class="cfg-value">{{ m.plagiarism_rate ?? '—' }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Trust score</span><span class="cfg-value">{{ m.trust_score ?? '—' }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Risk</span><span class="cfg-value">{{ m.risk_level ?? '—' }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Доля заимствований</span><span class="cfg-value">{{ m.plagiarism_rate ?? '—' }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Индекс доверия</span><span class="cfg-value">{{ m.trust_score ?? '—' }}</span></div></div>
|
||||
<div class="config-item"><div class="cfg-text"><span class="cfg-label">Уровень риска</span><span class="cfg-value">{{ m.risk_level | riskLevel }}</span></div></div>
|
||||
</div>
|
||||
} @else { <p class="muted">Метрики недоступны.</p> }
|
||||
|
||||
@@ -50,9 +51,9 @@
|
||||
<ul class="entity-list">
|
||||
@for (c of cards; track c.work_id) {
|
||||
<li class="entity-row">
|
||||
<a tuiLink [routerLink]="['/works', c.work_id]">Работа {{ c.work_id }}</a>
|
||||
<span tuiChip size="xs">{{ c.risk_level ?? '—' }}</span>
|
||||
<span class="muted meta">score={{ c.trust_score ?? '—' }}</span>
|
||||
<a tuiLink [routerLink]="['/works', c.work_id]">Работа #{{ c.work_id }}</a>
|
||||
<span tuiChip size="xs">{{ c.risk_level | riskLevel }}</span>
|
||||
<span class="muted meta">Индекс доверия: {{ c.trust_score ?? '—' }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -65,6 +66,18 @@
|
||||
}
|
||||
|
||||
@case (2) {
|
||||
<section class="card" aria-label="График">
|
||||
@if (statsState$ | async; as ss) {
|
||||
@if (ss.status === 'ok') {
|
||||
<app-plagiarism-graph [graph]="ss.dashboard.graph" />
|
||||
} @else if (ss.status === 'loading') {
|
||||
<div class="loading-wrap"><tui-loader [loading]="true" size="xl" /></div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (3) {
|
||||
<section class="card">
|
||||
<h3 class="section-title">Редактирование</h3>
|
||||
<form class="edit-form" (ngSubmit)="save()">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<button tuiTab type="button">Обзор</button>
|
||||
<button tuiTab type="button">Загрузка и проверка</button>
|
||||
<button tuiTab type="button">Результаты</button>
|
||||
<button tuiTab type="button">Граф зависимостей</button>
|
||||
<button tuiTab type="button">Отчёт</button>
|
||||
</tui-tabs>
|
||||
|
||||
@@ -31,9 +32,9 @@
|
||||
<dt>Идентификатор</dt>
|
||||
<dd>{{ state.work.id }}</dd>
|
||||
<dt>Студент</dt>
|
||||
<dd><a tuiLink [routerLink]="['/students', state.work.student_id]">#{{ state.work.student_id }}</a></dd>
|
||||
<dd><a tuiLink [routerLink]="['/students', state.work.student_id]">{{ (studentName$ | async) ?? '#' + state.work.student_id }}</a></dd>
|
||||
<dt>Мероприятие</dt>
|
||||
<dd><a tuiLink [routerLink]="['/events', state.work.event_id]">#{{ state.work.event_id }}</a></dd>
|
||||
<dd><a tuiLink [routerLink]="['/events', state.work.event_id]">{{ (eventName$ | async) ?? '#' + state.work.event_id }}</a></dd>
|
||||
<dt>Время</dt>
|
||||
<dd><code class="mono">{{ formatDateTime(state.work.time) }}</code></dd>
|
||||
<dt>Архив</dt>
|
||||
@@ -54,29 +55,29 @@
|
||||
@if (summaryState$ | async; as summaryState) {
|
||||
@if (summaryState.status === 'ok') {
|
||||
<section class="card" aria-label="Summary">
|
||||
<h3 class="section-title">Summary</h3>
|
||||
<h3 class="section-title">Сводка</h3>
|
||||
<div class="config-grid">
|
||||
<div class="config-item">
|
||||
<div class="cfg-text">
|
||||
<span class="cfg-label">Risk</span>
|
||||
<span class="cfg-value">{{ summaryState.summary.presentation_summary?.risk_level ?? '—' }}</span>
|
||||
<span class="cfg-label">Уровень риска</span>
|
||||
<span class="cfg-value">{{ summaryState.summary.presentation_summary?.risk_level | riskLevel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="cfg-text">
|
||||
<span class="cfg-label">Trust score</span>
|
||||
<span class="cfg-label">Индекс доверия</span>
|
||||
<span class="cfg-value">{{ summaryState.summary.presentation_summary?.trust_score ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="cfg-text">
|
||||
<span class="cfg-label">Plagiarism rate</span>
|
||||
<span class="cfg-label">Доля заимствований</span>
|
||||
<span class="cfg-value">{{ summaryState.summary.presentation_summary?.plagiarism_rate ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="cfg-text">
|
||||
<span class="cfg-label">Counterparts</span>
|
||||
<span class="cfg-label">Совпадений</span>
|
||||
<span class="cfg-value">{{ summaryState.summary.presentation_summary?.counterparts_count ?? 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,7 +129,7 @@
|
||||
</button>
|
||||
</div>
|
||||
@if (isPolling()) {
|
||||
<p class="small muted">Polling статуса запущен...</p>
|
||||
<p class="small muted">Опрос статуса запущен...</p>
|
||||
}
|
||||
@if (latestRun(); as run) {
|
||||
<p class="small">
|
||||
@@ -140,7 +141,7 @@
|
||||
|
||||
@case (2) {
|
||||
<section class="card" aria-label="Analysis runs">
|
||||
<h3 class="section-title">Analysis runs</h3>
|
||||
<h3 class="section-title">Запуски проверки</h3>
|
||||
@if (runsState$ | async; as runsState) {
|
||||
@switch (runsState.status) {
|
||||
@case ('loading') {
|
||||
@@ -148,32 +149,47 @@
|
||||
<tui-loader [loading]="true" size="m" />
|
||||
</div>
|
||||
}
|
||||
@case ('error') {
|
||||
<p class="muted">Не удалось загрузить runs.</p>
|
||||
}
|
||||
@case ('error') {}
|
||||
@case ('ok') {
|
||||
@if (runsState.runs.length === 0) {
|
||||
<p class="muted">Проверки пока не запускались.</p>
|
||||
} @else {
|
||||
<ul class="run-list">
|
||||
@for (run of runsState.runs; track run.id) {
|
||||
<li
|
||||
class="run-row"
|
||||
[class.run-row_active]="selectedRunId() === run.id"
|
||||
(click)="selectRun(run.id)"
|
||||
>
|
||||
<span class="run-id">{{ run.id }}</span>
|
||||
<span tuiChip size="s" class="status-chip" [ngClass]="run.status | analysisRunStatusChipClasses">{{ run.status | analysisRunStatus }}</span>
|
||||
<span class="muted">{{ formatDateTime(run.updated_at) }}</span>
|
||||
@if (getRunDuration(run)) {
|
||||
<span class="muted meta">({{ getRunDuration(run) }})</span>
|
||||
<div class="table-wrap">
|
||||
<table class="runs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Статус</th>
|
||||
<th>Обновлено</th>
|
||||
<th>Длительность</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (run of runsState.runs; track run.id) {
|
||||
<tr
|
||||
class="runs-table-row"
|
||||
[class.runs-table-row_active]="selectedRunId() === run.id"
|
||||
(click)="selectRun(run.id)"
|
||||
(keydown.enter)="selectRun(run.id)"
|
||||
(keydown.space)="selectRun(run.id); $event.preventDefault()"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
<td class="muted">#{{ run.id }}</td>
|
||||
<td><span tuiChip size="xs" class="status-chip" [ngClass]="run.status | analysisRunStatusChipClasses">{{ run.status | analysisRunStatus }}</span></td>
|
||||
<td class="muted">{{ formatDateTime(run.updated_at) }}</td>
|
||||
<td class="muted">{{ getRunDuration(run) || '—' }}</td>
|
||||
<td>
|
||||
@if (run.status === 'Failed' || run.status === 'Completed') {
|
||||
<button tuiButton appearance="flat" size="s" type="button" (click)="retryRun(run.id); $event.stopPropagation()" [disabled]="isRetrying()">Повторить</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (run.status === 'Failed' || run.status === 'Completed') {
|
||||
<button tuiButton appearance="flat" size="s" type="button" (click)="retryRun(run.id); $event.stopPropagation()" [disabled]="isRetrying()">Retry</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +197,7 @@
|
||||
</section>
|
||||
|
||||
<section class="card" aria-label="Совпадения">
|
||||
<h3 class="section-title">Совпадения выбранного run</h3>
|
||||
<h3 class="section-title">Совпадения выбранного запуска</h3>
|
||||
@if (adoptionsState$ | async; as adoptState) {
|
||||
@switch (adoptState.status) {
|
||||
@case ('idle') {
|
||||
@@ -192,9 +208,7 @@
|
||||
<tui-loader [loading]="true" size="m" />
|
||||
</div>
|
||||
}
|
||||
@case ('error') {
|
||||
<p class="muted">Не удалось загрузить совпадения.</p>
|
||||
}
|
||||
@case ('error') {}
|
||||
@case ('ok') {
|
||||
<p class="small muted">Всего совпадений: {{ adoptState.adoptions.length }}</p>
|
||||
@if (adoptState.adoptions.length > 0) {
|
||||
@@ -204,9 +218,9 @@
|
||||
<dl class="kv kv_compact">
|
||||
<dt>ID</dt>
|
||||
<dd>{{ adoption.id }}</dd>
|
||||
<dt>Path</dt>
|
||||
<dt>Путь</dt>
|
||||
<dd><code class="mono">{{ adoption.path ?? '—' }}</code></dd>
|
||||
<dt>Score</dt>
|
||||
<dt>Схожесть</dt>
|
||||
<dd>{{ adoption.similarity_score ?? '—' }}</dd>
|
||||
</dl>
|
||||
@if (adoption.segment_excerpt) {
|
||||
@@ -223,10 +237,19 @@
|
||||
}
|
||||
|
||||
@case (3) {
|
||||
<section class="card" aria-label="График">
|
||||
@if (summaryState$ | async; as ss) {
|
||||
@if (ss.status === 'ok') {
|
||||
<app-plagiarism-graph [graph]="ss.summary.graph" />
|
||||
} @else if (ss.status === 'loading') {
|
||||
<div class="loading-wrap"><tui-loader [loading]="true" size="xl" /></div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@case (4) {
|
||||
<section class="card" aria-label="Отчёт">
|
||||
<div class="report-head">
|
||||
<h3 class="section-title">Teacher report</h3>
|
||||
</div>
|
||||
<div class="report-actions">
|
||||
<button tuiButton appearance="secondary" size="s" type="button" (click)="downloadReport('json')" [disabled]="isDownloading()">
|
||||
Скачать JSON
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '
|
||||
import { AsyncPipe, NgClass } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { catchError, map, of, startWith, switchMap } from 'rxjs';
|
||||
import { catchError, map, of, shareReplay, startWith, switchMap } from 'rxjs';
|
||||
import { TuiButton } from '@taiga-ui/core/components/button';
|
||||
import { TuiLink } from '@taiga-ui/core/components/link';
|
||||
import { TuiLoader } from '@taiga-ui/core/components/loader';
|
||||
@@ -11,21 +11,29 @@ import { TuiChip } from '@taiga-ui/kit/components/chip';
|
||||
import { TuiTabs } from '@taiga-ui/kit/components/tabs';
|
||||
import { AnalysisRun } from '../../../core/models/api.types';
|
||||
import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service';
|
||||
import { AnalysisRunsApiService } from '../../../core/services/analysis-runs-api.service';
|
||||
import { EventsApiService } from '../../../core/services/events-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 { formatDurationMsHuman } from '../../../shared/utils/duration.util';
|
||||
import { AnalysisRunStatusPipe } from '../../../core/works/analysis-run-status.pipe';
|
||||
import { AnalysisRunStatusChipClassesPipe } from '../../../core/works/analysis-run-status-chip-classes.pipe';
|
||||
import { RiskLevelPipe } from '../../../core/works/risk-level.pipe';
|
||||
import { PlagiarismGraphComponent } from '../../../shared/components/plagiarism-graph/plagiarism-graph.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-work-detail',
|
||||
imports: [NgClass, AsyncPipe, RouterLink, TuiButton, TuiLink, TuiLoader, TuiTitle, TuiTabs, TuiChip, AnalysisRunStatusPipe, AnalysisRunStatusChipClassesPipe],
|
||||
imports: [NgClass, AsyncPipe, RouterLink, TuiButton, TuiLink, TuiLoader, TuiTitle, TuiTabs, TuiChip, AnalysisRunStatusPipe, AnalysisRunStatusChipClassesPipe, RiskLevelPipe, PlagiarismGraphComponent],
|
||||
templateUrl: './work-detail.component.html',
|
||||
styleUrl: './work-detail.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class WorkDetailComponent {
|
||||
private readonly api = inject(WorksApiService);
|
||||
private readonly analysisApi = inject(AnalysisRunsApiService);
|
||||
private readonly studentsApi = inject(StudentsApiService);
|
||||
private readonly eventsApi = inject(EventsApiService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
@@ -59,6 +67,27 @@ export class WorkDetailComponent {
|
||||
startWith({ status: 'loading' as const }),
|
||||
),
|
||||
),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
protected readonly studentName$ = this.workState$.pipe(
|
||||
switchMap((state) => {
|
||||
if (state.status !== 'ok') return of(null);
|
||||
return this.studentsApi.getStudent(state.work.student_id).pipe(
|
||||
map((s) => s.name),
|
||||
catchError(() => of(null)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
protected readonly eventName$ = this.workState$.pipe(
|
||||
switchMap((state) => {
|
||||
if (state.status !== 'ok') return of(null);
|
||||
return this.eventsApi.getEvent(state.work.event_id).pipe(
|
||||
map((e) => e.name),
|
||||
catchError(() => of(null)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
protected readonly summaryState$ = toObservable(this.reloadTick).pipe(
|
||||
@@ -66,7 +95,7 @@ export class WorkDetailComponent {
|
||||
this.api.getWorkSummary(this.workId()).pipe(
|
||||
map((summary) => ({ status: 'ok' as const, summary })),
|
||||
catchError((error: unknown) => {
|
||||
this.userErrors.notifyError(error, 'Ошибка загрузки summary');
|
||||
this.userErrors.notifyError(error, 'Ошибка загрузки сводки');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
@@ -85,7 +114,7 @@ export class WorkDetailComponent {
|
||||
return { status: 'ok' as const, runs };
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.userErrors.notifyError(error, 'Ошибка загрузки analysis runs');
|
||||
this.userErrors.notifyError(error, 'Ошибка загрузки запусков проверки');
|
||||
return of({ status: 'error' as const });
|
||||
}),
|
||||
startWith({ status: 'loading' as const }),
|
||||
@@ -98,7 +127,7 @@ export class WorkDetailComponent {
|
||||
if (runId == null) {
|
||||
return of({ status: 'idle' as const });
|
||||
}
|
||||
return this.api.getRunAdoptions(runId).pipe(
|
||||
return this.analysisApi.getRunAdoptions(runId).pipe(
|
||||
map((adoptions) => ({ status: 'ok' as const, adoptions })),
|
||||
catchError((error: unknown) => {
|
||||
this.userErrors.notifyError(error, 'Ошибка загрузки совпадений');
|
||||
@@ -167,12 +196,12 @@ export class WorkDetailComponent {
|
||||
protected downloadReport(format: 'json' | 'html' | 'pdf'): void {
|
||||
const runId = this.selectedRunId();
|
||||
if (runId == null) {
|
||||
this.userErrors.notifyError(new Error('Сначала выберите analysis run'), 'Валидация');
|
||||
this.userErrors.notifyError(new Error('Сначала выберите запуск проверки'), 'Валидация');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isDownloading.set(true);
|
||||
this.api.downloadReport(runId, format).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
this.analysisApi.downloadReport(runId, format).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: (blob) => {
|
||||
this.isDownloading.set(false);
|
||||
const extension = format === 'json' ? 'json' : format;
|
||||
@@ -187,7 +216,7 @@ export class WorkDetailComponent {
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.isDownloading.set(false);
|
||||
this.userErrors.notifyError(error, `Ошибка скачивания report.${format}`);
|
||||
this.userErrors.notifyError(error, `Ошибка скачивания отчёта (${format})`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -198,7 +227,7 @@ export class WorkDetailComponent {
|
||||
|
||||
protected retryRun(runId: string): void {
|
||||
this.isRetrying.set(true);
|
||||
this.api.retryAnalysisRun(runId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
this.analysisApi.retryAnalysisRun(runId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: (response) => {
|
||||
this.isRetrying.set(false);
|
||||
const newRunId = response.analysis_run_id;
|
||||
@@ -211,7 +240,7 @@ export class WorkDetailComponent {
|
||||
},
|
||||
error: (e: unknown) => {
|
||||
this.isRetrying.set(false);
|
||||
this.userErrors.notifyError(e, 'Ошибка retry');
|
||||
this.userErrors.notifyError(e, 'Ошибка повтора проверки');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -256,7 +285,7 @@ export class WorkDetailComponent {
|
||||
}
|
||||
|
||||
private pollRun(runId: string): void {
|
||||
this.api.getAnalysisRun(runId).subscribe({
|
||||
this.analysisApi.getAnalysisRun(runId).subscribe({
|
||||
next: (run) => {
|
||||
this.latestRun.set(run);
|
||||
if (run.status === 'Completed' || run.status === 'Failed') {
|
||||
@@ -270,7 +299,7 @@ export class WorkDetailComponent {
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.stopPolling();
|
||||
this.userErrors.notifyError(error, 'Ошибка polling статуса');
|
||||
this.userErrors.notifyError(error, 'Ошибка опроса статуса');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,29 +5,27 @@
|
||||
<h3 class="section-title">Новая работа</h3>
|
||||
<form class="create-row" (ngSubmit)="createWork()">
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<label tuiLabel>student_id</label>
|
||||
<input
|
||||
tuiInput
|
||||
type="number"
|
||||
name="studentId"
|
||||
placeholder="ID студента"
|
||||
[ngModel]="createStudentId()"
|
||||
(ngModelChange)="createStudentId.set($event)"
|
||||
required
|
||||
/>
|
||||
<label tuiLabel for="wlStudent">Студент</label>
|
||||
<select
|
||||
tuiSelect
|
||||
id="wlStudent"
|
||||
name="student"
|
||||
[items]="studentOptions()"
|
||||
[ngModel]="selectedStudent()"
|
||||
(ngModelChange)="selectedStudent.set($event)"
|
||||
></select>
|
||||
</tui-textfield>
|
||||
|
||||
<tui-textfield class="create-field sg-tui-textfield">
|
||||
<label tuiLabel>event_id</label>
|
||||
<input
|
||||
tuiInput
|
||||
type="number"
|
||||
name="eventId"
|
||||
placeholder="ID мероприятия"
|
||||
[ngModel]="createEventId()"
|
||||
(ngModelChange)="createEventId.set($event)"
|
||||
required
|
||||
/>
|
||||
<label tuiLabel for="wlEvent">Мероприятие</label>
|
||||
<select
|
||||
tuiSelect
|
||||
id="wlEvent"
|
||||
name="event"
|
||||
[items]="eventOptions()"
|
||||
[ngModel]="selectedEvent()"
|
||||
(ngModelChange)="selectedEvent.set($event)"
|
||||
></select>
|
||||
</tui-textfield>
|
||||
|
||||
<button
|
||||
@@ -49,11 +47,7 @@
|
||||
<tui-loader [loading]="true" size="xl" />
|
||||
</div>
|
||||
}
|
||||
@case ('error') {
|
||||
<section class="card" aria-label="Список работ">
|
||||
<p class="muted">Список временно недоступен.</p>
|
||||
</section>
|
||||
}
|
||||
@case ('error') {}
|
||||
@case ('ok') {
|
||||
<section class="card" aria-label="Список работ">
|
||||
@if (state.works.length === 0) {
|
||||
@@ -63,10 +57,10 @@
|
||||
@for (work of state.works; track work.id) {
|
||||
<li class="work-row">
|
||||
<a tuiLink [routerLink]="['/works', work.id]" class="work-link">
|
||||
Работа {{ work.id }}
|
||||
Работа #{{ work.id }}
|
||||
</a>
|
||||
<span class="muted meta">
|
||||
student={{ work.student_id }}, event={{ work.event_id }}
|
||||
{{ studentName(work.student_id) }}, {{ eventName(work.event_id) }}
|
||||
</span>
|
||||
<span class="muted meta">{{ formatDate(work.time) }}</span>
|
||||
</li>
|
||||
|
||||
@@ -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<Student | null>(null);
|
||||
protected readonly selectedEvent = signal<EventEntity | null>(null);
|
||||
protected readonly isCreating = signal(false);
|
||||
|
||||
protected readonly studentOptions = toSignal<readonly Student[] | null>(
|
||||
this.studentsApi.listStudents().pipe(catchError(() => of(null))),
|
||||
{ initialValue: null },
|
||||
);
|
||||
protected readonly eventOptions = toSignal<readonly EventEntity[] | null>(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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: `
|
||||
<div class="graph-root">
|
||||
<div #container class="graph-wrap"></div>
|
||||
<div class="graph-legend">
|
||||
<div class="legend-section">
|
||||
<p class="legend-title">Узлы</p>
|
||||
<div class="legend-row"><span class="legend-dot" style="background:#ffdb00"></span>Текущая сущность</div>
|
||||
<div class="legend-row"><span class="legend-dot" style="background:#16a34a"></span>Низкий риск</div>
|
||||
<div class="legend-row"><span class="legend-dot" style="background:#eab308"></span>Средний риск</div>
|
||||
<div class="legend-row"><span class="legend-dot" style="background:#f97316"></span>Высокий риск</div>
|
||||
<div class="legend-row"><span class="legend-dot" style="background:#d92d20"></span>Критический риск</div>
|
||||
<div class="legend-row"><span class="legend-dot" style="background:#9ca3af"></span>Нет данных</div>
|
||||
</div>
|
||||
<div class="legend-section">
|
||||
<p class="legend-title">Рёбра</p>
|
||||
<div class="legend-row"><span class="legend-line legend-line_thin"></span>Слабое совпадение</div>
|
||||
<div class="legend-row"><span class="legend-line legend-line_thick"></span>Сильное совпадение</div>
|
||||
</div>
|
||||
<div class="legend-section">
|
||||
<p class="legend-title">Управление</p>
|
||||
<div class="legend-hint">Перетащите узел для перемещения</div>
|
||||
<div class="legend-hint">Колесо мыши — масштаб</div>
|
||||
<div class="legend-hint">Hover по ребру — метрики</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './plagiarism-graph.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PlagiarismGraphComponent implements AfterViewInit, OnChanges, OnDestroy {
|
||||
@Input() graph: DashboardGraph | null | undefined;
|
||||
@ViewChild('container') containerRef!: ElementRef<HTMLDivElement>;
|
||||
|
||||
// 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<number, string>();
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<title>SparkAntiplagiat</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="icon" type="image/png" href="favicon.png">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
|
||||
@@ -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] */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user